from __future__ import annotations import asyncio import importlib.util from pathlib import Path import sys from typing import Any import pytest from cmvr_edge_ai.application import validate_application from cmvr_edge_ai.config import load_config from cmvr_edge_ai.contracts import ( BoundingBox, Detection, DetectionResult, ImageFrame, ) from cmvr_edge_ai.core import ComponentContext, Envelope PROJECT_ROOT = Path(__file__).resolve().parents[2] DEMO_CONFIG = PROJECT_ROOT / "configs" / "debug" / "detection_viewer.yaml" DEMO_SCRIPT = PROJECT_ROOT / "detect_server" / "show_detections.py" def _load_demo_module() -> Any: spec = importlib.util.spec_from_file_location( "cmvr_edge_ai_detection_viewer_demo", DEMO_SCRIPT, ) if spec is None or spec.loader is None: raise RuntimeError(f"could not load demo module: {DEMO_SCRIPT}") module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module spec.loader.exec_module(module) return module demo = _load_demo_module() class _FakeCv2: WINDOW_NORMAL = 0 WND_PROP_VISIBLE = 1 FONT_HERSHEY_SIMPLEX = 2 LINE_AA = 3 COLOR_RGB2BGR = 4 def __init__(self, *, key: int = -1, visible: float = 1.0, gui: str = "QT5") -> None: self.key = key self.visible = visible self.gui = gui self.named: list[tuple[str, int]] = [] self.resized: list[tuple[str, int, int]] = [] self.shown: list[tuple[str, Any]] = [] self.destroyed: list[str] = [] def getBuildInformation(self) -> str: return f"OpenCV test build\n GUI: {self.gui}\n" def namedWindow(self, name: str, mode: int) -> None: self.named.append((name, mode)) def resizeWindow(self, name: str, width: int, height: int) -> None: self.resized.append((name, width, height)) def imshow(self, name: str, image: Any) -> None: self.shown.append((name, image)) def waitKey(self, delay: int) -> int: del delay return self.key def getWindowProperty(self, name: str, prop: int) -> float: del name, prop return self.visible def destroyWindow(self, name: str) -> None: self.destroyed.append(name) def _frame( *, pixel_format: str = "BGR8", codec: str = "none", data: bytes | None = None, ) -> ImageFrame: return ImageFrame( data=bytes((10, 20, 30)) * 4 if data is None else data, width=2, height=2, pixel_format=pixel_format, codec=codec, ) def _result(*, source_frame: ImageFrame | None = None) -> DetectionResult: return DetectionResult( detections=( Detection( "No-Helmet", 0.91, BoundingBox(0.0, 0.0, 1.0, 1.0), ), ), model_id="construction-ppe-yolov8@1", model_name="Construction PPE YOLOv8s", inference_ms=12.5, source_frame=_frame() if source_frame is None else source_frame, ) def _envelope(payload: Any) -> Envelope[Any]: return Envelope( payload, schema_name="DetectionResult", schema_version=1, source_id="wrist_cam", sequence=7, ) def _context() -> ComponentContext: return ComponentContext( pipeline_id="detection_show", node_id="viewer", shutdown_event=asyncio.Event(), metadata={}, ) def test_demo_config_compiles_with_only_camera_decoder_detector_and_viewer() -> None: config = load_config(DEMO_CONFIG) compiled = validate_application( config, demo._build_registry(lambda: None), ("detection_show",), ) assert len(compiled) == 1 assert set(compiled[0].plugin_specs) == { "camera", "decoder", "detector", "viewer", } detector = config.pipelines["detection_show"].nodes["detector"] assert detector.params["max_fps"] == 10 assert detector.params["attach_frame"] is True viewer_edge = next( edge for edge in config.pipelines["detection_show"].edges if edge.target == "viewer.input" ) assert viewer_edge.source == "detector.detections" assert viewer_edge.qos.profile == "realtime_latest" assert viewer_edge.qos.capacity == 1 assert viewer_edge.qos.overflow == "drop_oldest" @pytest.mark.parametrize( ("params", "message"), [ ({"unknown": 1}, "unknown detection viewer parameter"), ({"window_name": ""}, "window_name must be a non-empty"), ({"window_width": True}, "window_width must be an integer"), ({"window_height": 0}, "window_height must be between"), ({"wait_key_ms": 0}, "wait_key_ms must be between"), ({"wait_key_ms": 21}, "wait_key_ms must be between"), ({"box_thickness": 21}, "box_thickness must be between"), ({"font_scale": float("nan")}, "font_scale must be between"), ({"show_stats": "true"}, "show_stats must be a boolean"), ], ) def test_viewer_parameters_are_strict(params: dict[str, Any], message: str) -> None: with pytest.raises(ValueError, match=message): demo.OpenCvDetectionViewerSink( "viewer", params, request_stop=lambda: None, ) def test_box_clipping_skips_invalid_or_fully_outside_boxes() -> None: valid = Detection("valid", 0.9, BoundingBox(-2.2, 1.2, 20.0, 9.8)) reversed_box = Detection("bad", 0.9, BoundingBox(8.0, 8.0, 2.0, 2.0)) outside = Detection("outside", 0.9, BoundingBox(12.0, 2.0, 20.0, 5.0)) not_finite = Detection("nan", 0.9, BoundingBox(float("nan"), 1, 2, 3)) assert demo._clipped_box(valid, 10, 8) == (0, 1, 9, 7) assert demo._clipped_box(reversed_box, 10, 8) is None assert demo._clipped_box(outside, 10, 8) is None assert demo._clipped_box(not_finite, 10, 8) is None @pytest.mark.parametrize( ("frame", "message"), [ (_frame(codec="H265"), "requires a decoded"), (_frame(pixel_format="GRAY8"), "supports BGR8 or RGB8"), (_frame(data=b"too-short"), "packed frame has"), ( ImageFrame(b"", 0, 2, "BGR8"), "dimensions must be positive integers", ), ( ImageFrame(b"", 2, 2, "BGR8", codec=None), # type: ignore[arg-type] "codec must be a string", ), ], ) def test_display_frame_validation_is_actionable( frame: ImageFrame, message: str, ) -> None: with pytest.raises(demo.DetectionViewerError, match=message): demo._validate_display_frame(frame) def test_viewer_rejects_missing_source_frame( monkeypatch: pytest.MonkeyPatch, ) -> None: fake_cv2 = _FakeCv2() stop_requested = False def request_stop() -> None: nonlocal stop_requested stop_requested = True def load_module(name: str) -> Any: return fake_cv2 if name == "cv2" else object() async def scenario() -> None: viewer = demo.OpenCvDetectionViewerSink( "viewer", {}, request_stop=request_stop, module_loader=load_module, ) await viewer.setup(_context()) result = DetectionResult((), "model@1", 1.0, source_frame=None) with pytest.raises(demo.DetectionViewerError, match="attach_frame: true"): await viewer.consume(_envelope(result)) await viewer.stop() monkeypatch.setenv("DISPLAY", ":99") asyncio.run(scenario()) assert stop_requested is False @pytest.mark.parametrize("key", [ord("q"), ord("Q"), 27]) def test_viewer_quit_keys_work_before_any_detection_frame( key: int, monkeypatch: pytest.MonkeyPatch, ) -> None: fake_cv2 = _FakeCv2(key=key) stop_calls = 0 stop_event: asyncio.Event | None = None def request_stop() -> None: nonlocal stop_calls, stop_event stop_calls += 1 assert stop_event is not None stop_event.set() def load_module(name: str) -> Any: return fake_cv2 if name == "cv2" else object() monkeypatch.setenv("DISPLAY", ":99") async def scenario() -> None: nonlocal stop_event stop_event = asyncio.Event() viewer = demo.OpenCvDetectionViewerSink( "viewer", {}, request_stop=request_stop, module_loader=load_module, ) await viewer.setup(_context()) await viewer.start() await asyncio.wait_for(stop_event.wait(), timeout=0.2) await viewer.stop() await viewer.stop() asyncio.run(scenario()) assert stop_calls == 1 assert fake_cv2.shown == [] assert fake_cv2.destroyed == ["CMVR PPE Detection"] def test_viewer_window_close_requests_outer_application_stop( monkeypatch: pytest.MonkeyPatch, ) -> None: fake_cv2 = _FakeCv2(visible=0.0) stop_calls = 0 stop_event: asyncio.Event | None = None def request_stop() -> None: nonlocal stop_calls, stop_event stop_calls += 1 assert stop_event is not None stop_event.set() monkeypatch.setenv("DISPLAY", ":99") async def scenario() -> None: nonlocal stop_event stop_event = asyncio.Event() viewer = demo.OpenCvDetectionViewerSink( "viewer", {}, request_stop=request_stop, module_loader=lambda name: fake_cv2 if name == "cv2" else object(), ) await viewer.setup(_context()) await viewer.start() await asyncio.wait_for(stop_event.wait(), timeout=0.2) await viewer.stop() asyncio.run(scenario()) assert stop_calls == 1 def test_unknown_visibility_does_not_close_window( monkeypatch: pytest.MonkeyPatch, ) -> None: fake_cv2 = _FakeCv2(visible=-1.0) stop_calls = 0 def request_stop() -> None: nonlocal stop_calls stop_calls += 1 monkeypatch.setenv("DISPLAY", ":99") async def scenario() -> None: viewer = demo.OpenCvDetectionViewerSink( "viewer", {}, request_stop=request_stop, module_loader=lambda name: fake_cv2 if name == "cv2" else object(), ) await viewer.setup(_context()) await viewer.start() await asyncio.sleep(0.03) await viewer.stop() asyncio.run(scenario()) assert stop_calls == 0 def test_consume_displays_rendered_detection_frame( monkeypatch: pytest.MonkeyPatch, ) -> None: fake_cv2 = _FakeCv2() monkeypatch.setattr( demo, "_render_detection_frame", lambda *args, **kwargs: "frame", ) monkeypatch.setenv("DISPLAY", ":99") async def scenario() -> None: viewer = demo.OpenCvDetectionViewerSink( "viewer", {}, request_stop=lambda: None, module_loader=lambda name: fake_cv2 if name == "cv2" else object(), ) await viewer.setup(_context()) await viewer.start() await viewer.consume(_envelope(_result())) await viewer.stop() asyncio.run(scenario()) assert fake_cv2.shown == [("CMVR PPE Detection", "frame")] def test_linux_headless_session_fails_before_loading_opencv( monkeypatch: pytest.MonkeyPatch, ) -> None: modules_loaded: list[str] = [] viewer = demo.OpenCvDetectionViewerSink( "viewer", {}, request_stop=lambda: None, module_loader=lambda name: modules_loaded.append(name), ) monkeypatch.setattr(demo.sys, "platform", "linux") monkeypatch.delenv("DISPLAY", raising=False) monkeypatch.delenv("WAYLAND_DISPLAY", raising=False) with pytest.raises(demo.DetectionViewerError, match="DISPLAY"): asyncio.run(viewer.setup(_context())) assert modules_loaded == [] def test_headless_opencv_build_is_rejected( monkeypatch: pytest.MonkeyPatch, ) -> None: fake_cv2 = _FakeCv2(gui="NONE") viewer = demo.OpenCvDetectionViewerSink( "viewer", {}, request_stop=lambda: None, module_loader=lambda name: fake_cv2 if name == "cv2" else object(), ) monkeypatch.setenv("DISPLAY", ":99") with pytest.raises(demo.DetectionViewerError, match="no GUI backend"): asyncio.run(viewer.setup(_context())) assert fake_cv2.named == [] def test_render_preserves_bgr_and_converts_rgb() -> None: numpy = pytest.importorskip("numpy") class DrawingCv2(_FakeCv2): def __init__(self) -> None: super().__init__() self.operations: list[tuple[Any, ...]] = [] def cvtColor(self, image: Any, code: int) -> Any: assert code == self.COLOR_RGB2BGR return image[:, :, ::-1].copy() def rectangle(self, *args: Any, **kwargs: Any) -> None: del kwargs _, top_left, bottom_right, _, thickness = args self.operations.append( ("rectangle", top_left, bottom_right, thickness) ) def getTextSize(self, *args: Any, **kwargs: Any) -> tuple[tuple[int, int], int]: del args, kwargs return (10, 5), 1 def putText(self, *args: Any, **kwargs: Any) -> None: del kwargs self.operations.append(("text", args[1])) cv2 = DrawingCv2() bgr = _frame(pixel_format="BGR8") rgb = _frame(pixel_format="RGB8") kwargs = { "cv2_module": cv2, "numpy_module": numpy, "box_thickness": 2, "font_scale": 0.6, } bgr_image = demo._render_detection_frame( bgr, _result().detections, header="stats", **kwargs, ) rgb_image = demo._render_detection_frame(rgb, (), header=None, **kwargs) assert tuple(int(value) for value in bgr_image[0, 0]) == (10, 20, 30) assert tuple(int(value) for value in rgb_image[0, 0]) == (30, 20, 10) assert bgr.data == bytes((10, 20, 30)) * 4 assert cv2.operations[:2] == [ ("rectangle", (0, 0), (1, 1), -1), ("text", "stats"), ] assert ("rectangle", (0, 0), (1, 1), 2) in cv2.operations[2:] assert ("text", "No-Helmet 0.91") in cv2.operations[2:] def test_run_demo_viewer_callback_stops_outer_application( monkeypatch: pytest.MonkeyPatch, ) -> None: applications: list[Any] = [] class FakeApplication: def __init__(self, config: Any, registry: Any) -> None: del config self.registry = registry self.started_with: tuple[str, ...] | None = None self.stop_calls = 0 applications.append(self) async def start(self, pipeline_ids: tuple[str, ...]) -> None: self.started_with = pipeline_ids viewer = self.registry.resolve(demo._VIEWER_PLUGIN_ID).factory( "viewer", {}, ) viewer._request_demo_stop("test") async def wait(self) -> None: await asyncio.Event().wait() async def stop(self, *, graceful: bool) -> None: assert graceful is True self.stop_calls += 1 monkeypatch.setattr(demo, "EdgeAIApplication", FakeApplication) config = load_config(DEMO_CONFIG) assert asyncio.run(demo._run_demo(config, "detection_show")) == 0 assert len(applications) == 1 assert applications[0].started_with == ("detection_show",) assert applications[0].stop_calls >= 1