feat: Add video playback functionality and inference support

- Introduced VideoPlayer class to handle local video playback, emitting frames via frame_ready signal.
- Updated MainWindow to switch between camera and video sources, integrating video playback controls.
- Enhanced AppMenuBar with options to open video files and manage inference models.
- Implemented BboxOverlay for displaying detection results on video frames.
- Added InferenceManager to manage YOLO inference in a separate process, with error handling and restart logic.
- Created tests for BboxOverlay and InferenceManager to ensure functionality and robustness.
- Updated pyproject.toml to include optional dependencies for inference support.
This commit is contained in:
2026-05-13 21:30:13 +02:00
parent ac51498b7a
commit e9b474b1ed
14 changed files with 1524 additions and 49 deletions

180
tests/test_bbox_overlay.py Normal file
View File

@@ -0,0 +1,180 @@
"""Tests for BboxOverlay — coordinate mapping and state management."""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from PySide6.QtCore import QRect, QSize
from app.inference.bbox_overlay import BboxOverlay, Detection
class TestDetection:
def test_namedtuple_fields(self) -> None:
d = Detection(x1=10.0, y1=20.0, x2=100.0, y2=200.0, conf=0.87, label="label")
assert d.x1 == 10.0
assert d.label == "label"
assert d.conf == pytest.approx(0.87)
def test_immutable(self) -> None:
d = Detection(0, 0, 1, 1, 0.5, "x")
with pytest.raises(AttributeError):
d.conf = 0.9 # type: ignore[misc]
class TestBboxOverlayState:
def setup_method(self) -> None:
self.overlay = BboxOverlay()
def test_initially_no_detections(self) -> None:
assert self.overlay._detections == []
def test_initially_source_size_empty(self) -> None:
assert self.overlay._source_size.isEmpty()
def test_on_detections_stores_data(self) -> None:
dets = [Detection(0, 0, 100, 100, 0.9, "label")]
self.overlay.on_detections(dets, (640, 480))
assert self.overlay._detections == dets
assert self.overlay._source_size == QSize(640, 480)
def test_clear_removes_detections(self) -> None:
self.overlay.on_detections([Detection(0, 0, 10, 10, 0.5, "x")], (100, 100))
self.overlay.clear()
assert self.overlay._detections == []
def test_visible_by_default(self) -> None:
assert self.overlay.visible is True
def test_multiple_detections_stored(self) -> None:
dets = [
Detection(0, 0, 50, 50, 0.9, "label"),
Detection(100, 100, 200, 200, 0.75, "label"),
]
self.overlay.on_detections(dets, (640, 480))
assert len(self.overlay._detections) == 2
def test_replace_detections_on_new_call(self) -> None:
self.overlay.on_detections([Detection(0, 0, 10, 10, 0.5, "x")], (100, 100))
self.overlay.on_detections([], (100, 100))
assert self.overlay._detections == []
class TestBboxOverlayCoordinateMapping:
"""
Verify that BboxOverlay correctly maps source-frame pixel coordinates
onto the letterboxed video_rect when painting.
We don't test actual QPainter output — instead we verify that the
QRect values passed to painter.drawRect() correspond to the expected
scaled coordinates.
"""
def setup_method(self) -> None:
self.overlay = BboxOverlay()
def _make_painter_mock(self):
painter = MagicMock()
fm = MagicMock()
fm.height.return_value = 14
fm.ascent.return_value = 11
fm.horizontalAdvance.return_value = 60
painter.fontMetrics.return_value = fm
return painter
def test_paint_skips_when_no_detections(self) -> None:
painter = self._make_painter_mock()
self.overlay.paint(painter, QRect(0, 0, 640, 480))
painter.drawRect.assert_not_called()
def test_paint_skips_when_source_size_empty(self) -> None:
# detections present but source_size not set
self.overlay._detections = [Detection(0, 0, 100, 100, 0.9, "label")]
painter = self._make_painter_mock()
self.overlay.paint(painter, QRect(0, 0, 640, 480))
painter.drawRect.assert_not_called()
def test_bbox_scaled_to_full_video_rect(self) -> None:
"""
Source: 640×480, covers full frame.
video_rect: 640×480 at origin.
Detection: full-frame box → should map 1:1.
"""
self.overlay.on_detections(
[Detection(0.0, 0.0, 640.0, 480.0, 0.99, "label")],
(640, 480),
)
painter = self._make_painter_mock()
video_rect = QRect(0, 0, 640, 480)
self.overlay.paint(painter, video_rect)
# First drawRect call = the bounding box
first_call_rect: QRect = painter.drawRect.call_args_list[0][0][0]
assert first_call_rect.x() == 0
assert first_call_rect.y() == 0
assert first_call_rect.width() == 640
assert first_call_rect.height() == 480
def test_bbox_scaled_with_half_size_video_rect(self) -> None:
"""
Source: 640×480, video_rect: 320×240 at origin (0.5× scale).
Detection at (64, 48)→(128, 96) should map to (32, 24)→(64, 48).
"""
self.overlay.on_detections(
[Detection(64.0, 48.0, 128.0, 96.0, 0.8, "label")],
(640, 480),
)
painter = self._make_painter_mock()
video_rect = QRect(0, 0, 320, 240)
self.overlay.paint(painter, video_rect)
first_call_rect: QRect = painter.drawRect.call_args_list[0][0][0]
assert first_call_rect.x() == 32
assert first_call_rect.y() == 24
assert first_call_rect.width() == 32 # (128-64) * 0.5
assert first_call_rect.height() == 24 # (96-48) * 0.5
def test_bbox_offset_by_video_rect_origin(self) -> None:
"""
video_rect at (100, 50) — letterboxed with margins.
Detection at origin of source should map to (100, 50).
"""
self.overlay.on_detections(
[Detection(0.0, 0.0, 100.0, 100.0, 0.9, "label")],
(640, 480),
)
painter = self._make_painter_mock()
# video_rect 320×240 starting at (100, 50)
video_rect = QRect(100, 50, 320, 240)
self.overlay.paint(painter, video_rect)
first_call_rect: QRect = painter.drawRect.call_args_list[0][0][0]
# x: 100 + int(0 * 320/640) = 100
# y: 50 + int(0 * 240/480) = 50
assert first_call_rect.x() == 100
assert first_call_rect.y() == 50
class TestBboxOverlayWorkerPacket:
"""Test FramePacket and ResultPacket data structures."""
def test_frame_packet_fields(self) -> None:
from app.inference.worker import FramePacket
pkt = FramePacket(
frame_id=1,
raw_bytes=b"\x00" * 12,
width=2,
height=2,
channels=3,
)
assert pkt.frame_id == 1
assert pkt.width == 2
assert pkt.channels == 3
def test_result_packet_fields(self) -> None:
from app.inference.worker import ResultPacket
pkt = ResultPacket(frame_id=5, detections=[], width=640, height=480)
assert pkt.frame_id == 5
assert pkt.detections == []
assert pkt.width == 640