224 lines
7.3 KiB
Python
224 lines
7.3 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from collections.abc import Mapping, Sequence
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from io import BytesIO
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from cmvr_edge_ai.application import create_default_registry
|
|
from cmvr_edge_ai.compiler import compile_pipeline
|
|
from cmvr_edge_ai.config import load_config_data
|
|
from cmvr_edge_ai.contracts import (
|
|
BoundingBox,
|
|
Detection,
|
|
DetectionAlert,
|
|
ImageFrame,
|
|
)
|
|
from cmvr_edge_ai.core import Envelope, Sink
|
|
from cmvr_edge_ai.detection import (
|
|
DetectionModel,
|
|
DetectionModelRegistry,
|
|
DetectionModelSpec,
|
|
)
|
|
from cmvr_edge_ai.plugins import PluginKind, PluginSpec
|
|
|
|
|
|
class _AlwaysViolationModel(DetectionModel):
|
|
def __init__(self) -> None:
|
|
self.load_calls = 0
|
|
self.predict_calls = 0
|
|
self.close_calls = 0
|
|
|
|
def load(self) -> None:
|
|
self.load_calls += 1
|
|
|
|
def predict(
|
|
self,
|
|
frame: ImageFrame,
|
|
labels: tuple[str, ...],
|
|
confidence: float,
|
|
) -> Sequence[Detection]:
|
|
del frame, confidence
|
|
self.predict_calls += 1
|
|
if "No-Helmet" not in labels:
|
|
return ()
|
|
return (
|
|
Detection(
|
|
"No-Helmet",
|
|
0.95,
|
|
BoundingBox(1.0, 2.0, 20.0, 40.0),
|
|
),
|
|
)
|
|
|
|
def close(self) -> None:
|
|
self.close_calls += 1
|
|
|
|
|
|
class _CollectAlertSink(Sink):
|
|
def __init__(self) -> None:
|
|
self.alerts: list[Envelope[Any]] = []
|
|
|
|
async def consume(
|
|
self, envelope: Envelope[Any], input_port: str = "alerts"
|
|
) -> None:
|
|
del input_port
|
|
self.alerts.append(envelope)
|
|
|
|
|
|
async def _keep_event_loop_responsive() -> None:
|
|
while True:
|
|
await asyncio.sleep(0.01)
|
|
|
|
|
|
def test_decoded_frames_flow_through_registered_model_and_repeat_gate() -> None:
|
|
pillow_image = pytest.importorskip("PIL.Image")
|
|
model = _AlwaysViolationModel()
|
|
models = DetectionModelRegistry()
|
|
models.register(
|
|
DetectionModelSpec(
|
|
model_id="fake-ppe@1",
|
|
name="Fake PPE",
|
|
supported_labels=("No-Helmet",),
|
|
factory=lambda options: model,
|
|
backend="fake",
|
|
)
|
|
)
|
|
registry = create_default_registry(
|
|
discover_entry_points=False,
|
|
model_registry=models,
|
|
)
|
|
sink = _CollectAlertSink()
|
|
|
|
def sink_factory(node_id: str, params: Mapping[str, Any]) -> Sink:
|
|
del node_id, params
|
|
return sink
|
|
|
|
registry.register(
|
|
PluginSpec(
|
|
plugin_id="test.alert_sink@1",
|
|
kind=PluginKind.SINK,
|
|
factory=sink_factory,
|
|
inputs={"alerts": "DetectionAlert/v1"},
|
|
)
|
|
)
|
|
width, height = 64, 48
|
|
frames = (
|
|
ImageFrame(bytes((0, 0, 255)) * (width * height), width, height, "BGR8"),
|
|
ImageFrame(bytes((0, 255, 0)) * (width * height), width, height, "BGR8"),
|
|
ImageFrame(bytes((255, 0, 0)) * (width * height), width, height, "BGR8"),
|
|
)
|
|
config = load_config_data(
|
|
{
|
|
"api_version": "cmvr.edge.ai/v1",
|
|
"pipelines": {
|
|
"ppe": {
|
|
"nodes": {
|
|
"frames": {
|
|
"uses": "core.sequence_source@1",
|
|
"with": {
|
|
"items": list(frames),
|
|
"schema_name": "ImageFrame",
|
|
},
|
|
},
|
|
"detector": {
|
|
"uses": "detection.model@1",
|
|
"with": {
|
|
"model": "fake-ppe@1",
|
|
"detect_labels": ["No-Helmet"],
|
|
"attach_frame": True,
|
|
},
|
|
},
|
|
"gate": {
|
|
"uses": "detection.repeat_gate@1",
|
|
"with": {
|
|
"time_source": "received",
|
|
"alert_image": {
|
|
"enabled": True,
|
|
"jpeg_quality": 85,
|
|
},
|
|
"rules": [
|
|
{
|
|
"id": "no-helmet",
|
|
"labels": ["No-Helmet"],
|
|
"min_hits": 2,
|
|
"window_ms": 10_000,
|
|
"cooldown_ms": 30_000,
|
|
}
|
|
],
|
|
},
|
|
},
|
|
"sink": {"uses": "test.alert_sink@1"},
|
|
},
|
|
"edges": [
|
|
{"from": "frames.output", "to": "detector.frames"},
|
|
{
|
|
"from": "detector.detections",
|
|
"to": "gate.detections",
|
|
},
|
|
{"from": "gate.alerts", "to": "sink.alerts"},
|
|
],
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
gate_health_details: list[str] = []
|
|
|
|
async def scenario() -> None:
|
|
loop = asyncio.get_running_loop()
|
|
executor = ThreadPoolExecutor(max_workers=2)
|
|
loop.set_default_executor(executor)
|
|
ticker = asyncio.create_task(_keep_event_loop_responsive())
|
|
try:
|
|
compiled = compile_pipeline(
|
|
config,
|
|
"ppe",
|
|
registry,
|
|
metadata={"thread_executor": executor},
|
|
)
|
|
await compiled.runtime.run()
|
|
gate_health = await compiled.runtime.nodes["gate"].health()
|
|
gate_health_details.append(gate_health.detail)
|
|
finally:
|
|
ticker.cancel()
|
|
await asyncio.gather(ticker, return_exceptions=True)
|
|
executor.shutdown(wait=True, cancel_futures=True)
|
|
loop._default_executor = None # type: ignore[attr-defined]
|
|
|
|
asyncio.run(scenario())
|
|
|
|
assert model.load_calls == 1
|
|
assert model.predict_calls == 3
|
|
assert model.close_calls == 1
|
|
assert len(sink.alerts) == 1
|
|
alert = sink.alerts[0].payload
|
|
assert isinstance(alert, DetectionAlert)
|
|
assert alert.rule_id == "no-helmet"
|
|
assert alert.hit_count == 2
|
|
assert alert.labels == ("No-Helmet",)
|
|
assert sink.alerts[0].sequence == 1
|
|
assert alert.image is not None
|
|
assert alert.image.media_type == "image/jpeg"
|
|
assert (alert.image.width, alert.image.height) == (width, height)
|
|
assert alert.image.data.startswith(b"\xff\xd8")
|
|
|
|
with pillow_image.open(BytesIO(alert.image.data)) as decoded:
|
|
decoded.load()
|
|
assert decoded.format == "JPEG"
|
|
assert decoded.mode == "RGB"
|
|
assert decoded.size == (width, height)
|
|
# This pixel lies outside the annotation and proves that the evidence
|
|
# image came from sequence 1 (green), the frame that reached min_hits.
|
|
red, green, blue = decoded.getpixel((width - 1, height - 1))
|
|
assert green > 200
|
|
assert red < 40
|
|
assert blue < 40
|
|
|
|
assert gate_health_details
|
|
assert "processed_frames=3" in gate_health_details[0]
|
|
assert "alerts_emitted=1" in gate_health_details[0]
|
|
assert "alert_images_encoded=1" in gate_health_details[0]
|