507 lines
15 KiB
Python
507 lines
15 KiB
Python
|
|
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
|