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:
180
tests/test_bbox_overlay.py
Normal file
180
tests/test_bbox_overlay.py
Normal 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
|
||||
Reference in New Issue
Block a user