cmvr_edge_ai/tests/unit/test_detection_trigger.py
2026-07-20 16:59:37 +08:00

693 lines
22 KiB
Python

from __future__ import annotations
import asyncio
from dataclasses import asdict
import json
import threading
from concurrent.futures import ThreadPoolExecutor
from typing import Any
import pytest
from cmvr_edge_ai.contracts.events import (
BoundingBox,
Detection,
DetectionAlert,
DetectionResult,
)
from cmvr_edge_ai.contracts.media import EncodedImage, ImageFrame
from cmvr_edge_ai.core import ComponentContext, Emission, Envelope
import cmvr_edge_ai.detection.rules as detection_rules
from cmvr_edge_ai.detection.annotation import ImageAnnotationDependencyError
from cmvr_edge_ai.detection.rules import DetectionTriggerOperator
NS_PER_MS = 1_000_000
def _frame(value: int = 0) -> ImageFrame:
return ImageFrame(bytes([value]) * 4 * 4 * 3, 4, 4, "BGR8")
async def _keep_restricted_event_loop_responsive() -> None:
while True:
await asyncio.sleep(0.01)
def _detection(
label: str = "No-Helmet",
confidence: float = 0.9,
*,
track_id: str | None = None,
) -> Detection:
return Detection(
label=label,
confidence=confidence,
box=BoundingBox(1.0, 2.0, 30.0, 40.0),
track_id=track_id,
)
def _envelope(
sequence: int,
at_ms: float,
*detections: Detection,
source_id: str = "camera-a",
captured: bool = False,
trace_id: str | None = None,
frame: ImageFrame | None = None,
) -> Envelope[DetectionResult]:
timestamp_ns = int(at_ms * NS_PER_MS)
kwargs: dict[str, Any] = {
"payload": DetectionResult(
detections=tuple(detections),
model_id="construction-ppe-yolov8@1",
inference_ms=12.5,
model_name="Construction PPE YOLOv8",
source_frame=frame,
),
"schema_name": "DetectionResult",
"schema_version": 1,
"source_id": source_id,
"sequence": sequence,
"received_at_ns": timestamp_ns,
"captured_at_ns": timestamp_ns if captured else None,
}
if trace_id is not None:
kwargs["trace_id"] = trace_id
return Envelope(**kwargs)
def _operator(
*,
labels: list[str] | None = None,
min_hits: int = 3,
window_ms: float = 1_000,
cooldown_ms: float = 0,
min_confidence: float = 0.5,
scope: str = "source",
time_source: str = "received",
alert_image: dict[str, Any] | None = None,
) -> DetectionTriggerOperator:
params: dict[str, Any] = {
"time_source": time_source,
"rules": [
{
"id": "missing-ppe",
"labels": labels or ["No-Helmet"],
"min_hits": min_hits,
"window_ms": window_ms,
"cooldown_ms": cooldown_ms,
"min_confidence": min_confidence,
"scope": scope,
}
],
}
if alert_image is not None:
params["alert_image"] = alert_image
return DetectionTriggerOperator("trigger", params)
def _process(
operator: DetectionTriggerOperator, envelope: Envelope[Any]
) -> tuple[Emission, ...] | None:
return asyncio.run(operator.process(envelope))
def _single_alert(
result: tuple[Emission, ...] | None
) -> tuple[Emission, DetectionAlert]:
assert result is not None
assert len(result) == 1
emission = result[0]
assert isinstance(emission.envelope.payload, DetectionAlert)
return emission, emission.envelope.payload
@pytest.mark.parametrize(
("params", "message"),
[
({"rules": [], "unexpected": True}, "unknown detection trigger"),
({"rules": []}, "non-empty list"),
(
{"rules": [], "alert_image": "enabled"},
"alert_image must be a mapping",
),
(
{"rules": [], "alert_image": {"unexpected": True}},
"alert_image has unknown field",
),
(
{"rules": [], "alert_image": {"enabled": "true"}},
"alert_image.enabled must be a boolean",
),
(
{"rules": [], "alert_image": {"jpeg_quality": True}},
"alert_image.jpeg_quality must be an integer",
),
(
{"rules": [], "alert_image": {"jpeg_quality": 0}},
"alert_image.jpeg_quality must be between 1 and 95",
),
(
{"rules": [], "alert_image": {"jpeg_quality": 96}},
"alert_image.jpeg_quality must be between 1 and 95",
),
(
{
"rules": [
{
"id": "r",
"labels": ["No-Helmet"],
"min_hits": 2,
"window_ms": 100,
"typo": True,
}
]
},
"unknown field",
),
(
{
"rules": [
{
"id": "r",
"labels": ["No-Helmet", "No-Helmet"],
"min_hits": 2,
"window_ms": 100,
}
]
},
"duplicates",
),
(
{
"rules": [
{
"id": "r",
"labels": ["No-Helmet"],
"min_hits": True,
"window_ms": 100,
}
]
},
"must be an integer",
),
(
{
"time_source": "wall",
"rules": [
{
"id": "r",
"labels": ["No-Helmet"],
"min_hits": 2,
"window_ms": 100,
}
],
},
"time_source",
),
],
)
def test_trigger_configuration_is_strict(params: dict[str, Any], message: str) -> None:
with pytest.raises(ValueError, match=message):
DetectionTriggerOperator("trigger", params)
def test_source_rule_counts_distinct_frames_not_boxes_and_preserves_metadata() -> None:
operator = _operator(min_hits=3, window_ms=1_000, min_confidence=0.7)
first = _envelope(
1,
0,
_detection(confidence=0.8),
_detection(confidence=0.91),
_detection("No-Vest", confidence=0.99),
)
assert _process(operator, first) is None
# Replaying a frame with the same source and sequence must not create a hit.
assert _process(operator, _envelope(1, 100, _detection(confidence=0.99))) is None
# Below-threshold detections do not count.
assert _process(operator, _envelope(2, 500, _detection(confidence=0.69))) is None
assert _process(operator, _envelope(2, 500, _detection(confidence=0.85))) is None
threshold = _envelope(
3,
1_000,
_detection(confidence=0.95),
trace_id="threshold-frame",
)
emission, alert = _single_alert(_process(operator, threshold))
assert emission.port == "alerts"
assert emission.envelope.schema == "DetectionAlert/v1"
assert emission.envelope.trace_id == "threshold-frame"
assert emission.envelope.source_id == "camera-a"
assert alert.rule_id == "missing-ppe"
assert alert.model_id == "construction-ppe-yolov8@1"
assert alert.model_name == "Construction PPE YOLOv8"
assert alert.labels == ("No-Helmet",)
assert alert.scope == "source"
assert alert.scope_id == "camera-a"
assert alert.hit_count == 3
assert alert.window_ms == 1_000
assert alert.first_seen_ns == 0
assert alert.last_seen_ns == 1_000 * NS_PER_MS
assert alert.triggered_at_ns == 1_000 * NS_PER_MS
assert alert.max_confidence == pytest.approx(0.95)
assert alert.detections == threshold.payload.detections
assert alert.event_id
def test_invalid_detections_are_counted_and_never_reach_an_alert_payload() -> None:
operator = _operator(min_hits=1)
invalid_nan = Detection(
"No-Helmet",
0.99,
BoundingBox(float("nan"), 2.0, 30.0, 40.0),
)
invalid_reversed = Detection(
"No-Helmet",
0.98,
BoundingBox(30.0, 40.0, 1.0, 2.0),
)
valid = _detection(confidence=0.95)
assert _process(operator, _envelope(1, 0, invalid_nan, invalid_reversed)) is None
_, alert = _single_alert(
_process(
operator,
_envelope(2, 10, invalid_nan, invalid_reversed, valid),
)
)
assert alert.detections == (valid,)
# The remaining structured alert can be emitted by a strict JSON client.
json.dumps(asdict(alert), allow_nan=False)
health = asyncio.run(operator.health())
assert health.healthy is False
assert "invalid_detections=4" in health.detail
def test_window_lower_boundary_is_inclusive_and_older_hits_expire() -> None:
inclusive = _operator(min_hits=2, window_ms=1_000)
assert _process(inclusive, _envelope(1, 0, _detection())) is None
_, alert = _single_alert(_process(inclusive, _envelope(2, 1_000, _detection())))
assert alert.first_seen_ns == 0
expired = _operator(min_hits=2, window_ms=1_000)
assert _process(expired, _envelope(1, 0, _detection())) is None
assert _process(expired, _envelope(2, 1_001, _detection())) is None
def test_cooldown_does_not_accumulate_hits_and_requires_a_fresh_window() -> None:
operator = _operator(min_hits=2, window_ms=1_000, cooldown_ms=500)
assert _process(operator, _envelope(1, 0, _detection())) is None
_single_alert(_process(operator, _envelope(2, 100, _detection())))
# The threshold frame itself remains deduplicated even after the hit deque
# was reset, and new frames during cooldown are ignored rather than queued.
assert _process(operator, _envelope(2, 100, _detection())) is None
assert _process(operator, _envelope(3, 200, _detection())) is None
assert _process(operator, _envelope(4, 599, _detection())) is None
assert _process(operator, _envelope(5, 600, _detection())) is None
_, alert = _single_alert(_process(operator, _envelope(6, 700, _detection())))
assert alert.first_seen_ns == 600 * NS_PER_MS
assert alert.last_seen_ns == 700 * NS_PER_MS
health = asyncio.run(operator.health())
assert health.healthy is True
assert "alerts_emitted=2" in health.detail
assert "cooldown_suppressed=2" in health.detail
assert "duplicate_frames=1" in health.detail
def test_source_state_is_isolated_per_camera() -> None:
operator = _operator(min_hits=2)
assert (
_process(operator, _envelope(1, 0, _detection(), source_id="camera-a")) is None
)
assert (
_process(operator, _envelope(1, 10, _detection(), source_id="camera-b")) is None
)
_, alert = _single_alert(
_process(operator, _envelope(2, 20, _detection(), source_id="camera-a"))
)
assert alert.scope_id == "camera-a"
def test_track_rules_are_isolated_and_can_emit_multiple_alerts_per_frame() -> None:
operator = _operator(min_hits=2, scope="track")
first = _envelope(
1,
0,
_detection(track_id="worker-1"),
_detection(confidence=0.95, track_id="worker-1"),
_detection(track_id="worker-2"),
_detection(track_id=None),
)
assert _process(operator, first) is None
result = _process(
operator,
_envelope(
2,
100,
_detection(track_id="worker-1"),
_detection(track_id="worker-2"),
),
)
assert result is not None
assert {item.envelope.payload.scope_id for item in result} == {
"worker-1",
"worker-2",
}
assert all(item.envelope.payload.hit_count == 2 for item in result)
assert all(item.envelope.payload.scope == "track" for item in result)
assert "untracked_detections=1" in asyncio.run(operator.health()).detail
def test_multiple_rules_can_trigger_from_one_result() -> None:
operator = DetectionTriggerOperator(
"trigger",
{
"time_source": "received",
"rules": [
{
"id": "helmet",
"labels": ["No-Helmet"],
"min_hits": 1,
"window_ms": 100,
"cooldown_ms": 0,
"min_confidence": 0.5,
"scope": "source",
},
{
"id": "vest",
"labels": ["No-Vest"],
"min_hits": 1,
"window_ms": 100,
"cooldown_ms": 0,
"min_confidence": 0.5,
"scope": "source",
},
],
},
)
result = _process(
operator,
_envelope(1, 0, _detection("No-Helmet"), _detection("No-Vest")),
)
assert result is not None
assert [item.envelope.payload.rule_id for item in result] == ["helmet", "vest"]
def test_alert_image_is_encoded_once_only_when_the_threshold_frame_triggers(
monkeypatch: pytest.MonkeyPatch,
) -> None:
render_calls: list[
tuple[int, ImageFrame, tuple[Detection, ...], int]
] = []
encoded = EncodedImage(b"jpeg", "image/jpeg", 4, 4)
def render(
frame: ImageFrame,
detections: tuple[Detection, ...],
*,
jpeg_quality: int,
) -> EncodedImage:
render_calls.append(
(threading.get_ident(), frame, tuple(detections), jpeg_quality)
)
return encoded
monkeypatch.setattr(detection_rules, "ensure_annotation_support", lambda: None)
monkeypatch.setattr(detection_rules, "render_annotated_jpeg", render)
operator = _operator(
min_hits=2,
alert_image={"enabled": True, "jpeg_quality": 73},
)
empty_frame = _frame(1)
first_hit_frame = _frame(2)
threshold_frame = _frame(3)
threshold_detection = _detection(confidence=0.96)
async def scenario() -> tuple[Emission, ...] | None:
event_loop_thread = threading.get_ident()
executor = ThreadPoolExecutor(max_workers=1)
ticker = asyncio.create_task(_keep_restricted_event_loop_responsive())
try:
await operator.setup(
ComponentContext(
pipeline_id="detection",
node_id="trigger",
shutdown_event=asyncio.Event(),
metadata={"thread_executor": executor},
)
)
assert (
await operator.process(_envelope(1, 0, frame=empty_frame)) is None
)
assert (
await operator.process(
_envelope(2, 100, _detection(), frame=first_hit_frame)
)
is None
)
assert render_calls == []
result = await operator.process(
_envelope(
3,
200,
threshold_detection,
frame=threshold_frame,
)
)
assert render_calls[0][0] != event_loop_thread
return result
finally:
ticker.cancel()
await asyncio.gather(ticker, return_exceptions=True)
await operator.stop()
executor.shutdown(wait=True, cancel_futures=True)
result = asyncio.run(scenario())
_, alert = _single_alert(result)
assert len(render_calls) == 1
_, rendered_frame, rendered_detections, quality = render_calls[0]
assert rendered_frame is threshold_frame
assert rendered_detections == (threshold_detection,)
assert quality == 73
assert alert.image is encoded
health = asyncio.run(operator.health())
assert health.healthy is True
assert "alert_images_encoded=1" in health.detail
assert "alert_images_attached=1" in health.detail
def test_multiple_alerts_from_one_frame_share_one_union_image(
monkeypatch: pytest.MonkeyPatch,
) -> None:
helmet = _detection("No-Helmet", confidence=0.91)
vest = _detection("No-Vest", confidence=0.88)
threshold_frame = _frame(4)
encoded = EncodedImage(b"shared-jpeg", "image/jpeg", 4, 4)
render_calls: list[tuple[ImageFrame, tuple[Detection, ...]]] = []
def render(
frame: ImageFrame,
detections: tuple[Detection, ...],
*,
jpeg_quality: int,
) -> EncodedImage:
assert jpeg_quality == 85
render_calls.append((frame, tuple(detections)))
return encoded
async def run_inline(
function: Any,
*args: Any,
executor: Any = None,
**kwargs: Any,
) -> Any:
del executor
return function(*args, **kwargs)
monkeypatch.setattr(detection_rules, "render_annotated_jpeg", render)
monkeypatch.setattr(detection_rules, "run_blocking", run_inline)
operator = DetectionTriggerOperator(
"trigger",
{
"time_source": "received",
"alert_image": {"enabled": True},
"rules": [
{
"id": "helmet",
"labels": ["No-Helmet"],
"min_hits": 1,
"window_ms": 100,
},
{
"id": "vest",
"labels": ["No-Vest"],
"min_hits": 1,
"window_ms": 100,
},
],
},
)
result = _process(
operator,
_envelope(1, 0, helmet, vest, frame=threshold_frame),
)
assert result is not None
assert render_calls == [(threshold_frame, (helmet, vest))]
assert [item.envelope.payload.rule_id for item in result] == ["helmet", "vest"]
assert all(item.envelope.payload.image is encoded for item in result)
health = asyncio.run(operator.health())
assert "alert_images_encoded=1" in health.detail
assert "alert_images_attached=2" in health.detail
def test_missing_threshold_frame_is_fail_soft_and_commits_cooldown() -> None:
operator = _operator(
min_hits=1,
cooldown_ms=500,
alert_image={"enabled": True},
)
_, alert = _single_alert(_process(operator, _envelope(1, 0, _detection())))
assert alert.image is None
# The alert state is committed even though its evidence image is missing.
assert _process(operator, _envelope(1, 100, _detection())) is None
assert _process(operator, _envelope(2, 100, _detection())) is None
health = asyncio.run(operator.health())
assert health.healthy is False
assert "alerts_emitted=1" in health.detail
assert "duplicate_frames=1" in health.detail
assert "cooldown_suppressed=1" in health.detail
assert "alert_image_missing_frames=1" in health.detail
assert "alert_images_encoded=0" in health.detail
def test_renderer_failure_is_fail_soft_and_commits_cooldown(
monkeypatch: pytest.MonkeyPatch,
) -> None:
render_calls = 0
def fail_render(*args: Any, **kwargs: Any) -> EncodedImage:
nonlocal render_calls
render_calls += 1
raise ValueError("bad packed frame")
async def run_inline(
function: Any,
*args: Any,
executor: Any = None,
**kwargs: Any,
) -> Any:
del executor
return function(*args, **kwargs)
monkeypatch.setattr(detection_rules, "render_annotated_jpeg", fail_render)
monkeypatch.setattr(detection_rules, "run_blocking", run_inline)
operator = _operator(
min_hits=1,
cooldown_ms=500,
alert_image={"enabled": True},
)
frame = _frame(5)
_, alert = _single_alert(
_process(operator, _envelope(1, 0, _detection(), frame=frame))
)
assert alert.image is None
assert render_calls == 1
assert _process(operator, _envelope(1, 100, _detection(), frame=frame)) is None
assert _process(operator, _envelope(2, 100, _detection(), frame=frame)) is None
health = asyncio.run(operator.health())
assert health.healthy is False
assert "alerts_emitted=1" in health.detail
assert "duplicate_frames=1" in health.detail
assert "cooldown_suppressed=1" in health.detail
assert "alert_image_failures=1" in health.detail
assert "alert_images_encoded=0" in health.detail
def test_pillow_dependency_is_checked_only_when_alert_images_are_enabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
checks = 0
def missing_pillow() -> None:
nonlocal checks
checks += 1
raise ImageAnnotationDependencyError("install the 'image' extra")
monkeypatch.setattr(detection_rules, "ensure_annotation_support", missing_pillow)
context = ComponentContext(
pipeline_id="detection",
node_id="trigger",
shutdown_event=asyncio.Event(),
)
asyncio.run(_operator(min_hits=1).setup(context))
assert checks == 0
enabled = _operator(
min_hits=1,
alert_image={"enabled": True},
)
with pytest.raises(ImageAnnotationDependencyError, match="image.*extra"):
asyncio.run(enabled.setup(context))
assert checks == 1
def test_time_source_is_explicit_and_captured_requires_a_timestamp() -> None:
captured = _operator(min_hits=2, window_ms=10, time_source="captured")
with pytest.raises(ValueError, match="requires captured_at_ns"):
_process(captured, _envelope(1, 0, _detection()))
auto = _operator(min_hits=2, window_ms=10, time_source="auto")
first = _envelope(1, 0, _detection(), captured=True)
second = Envelope(
payload=first.payload,
schema_name="DetectionResult",
source_id="camera-a",
sequence=2,
captured_at_ns=20 * NS_PER_MS,
received_at_ns=5 * NS_PER_MS,
)
assert _process(auto, first) is None
# auto prefers capture time, so the first hit is outside this 10 ms window.
assert _process(auto, second) is None
received = _operator(min_hits=2, window_ms=10, time_source="received")
assert _process(received, first) is None
_single_alert(_process(received, second))
def test_wrong_payload_and_out_of_order_frames_fail_closed() -> None:
operator = _operator(min_hits=2)
with pytest.raises(TypeError, match="expected DetectionResult"):
_process(
operator,
Envelope(
"not-a-detection",
source_id="camera-a",
sequence=1,
received_at_ns=0,
),
)
assert _process(operator, _envelope(1, 100, _detection())) is None
assert _process(operator, _envelope(2, 99, _detection())) is None
assert "stale_frames=1" in asyncio.run(operator.health()).detail