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

117
app/video/video_player.py Normal file
View File

@@ -0,0 +1,117 @@
"""VideoPlayer — plays a local video file and delivers frames via frame_ready signal.
The public interface mirrors CameraService so MainWindow can treat both
interchangeably: both emit frame_ready(QVideoFrame).
"""
from __future__ import annotations
import logging
from pathlib import Path
from PySide6.QtCore import QObject, QUrl, Signal, Slot
from PySide6.QtMultimedia import (
QMediaPlayer,
QVideoFrame,
QVideoSink,
)
logger = logging.getLogger(__name__)
class VideoPlayer(QObject):
"""
Wraps QMediaPlayer + QVideoSink to replay a local video file.
Signal flow (identical interface to CameraService):
VideoPlayer.frame_ready(QVideoFrame) → FrameDispatcher
Notes:
- Playback is real-time (1×) — no seek/pause in this version.
- At end-of-file: emits playback_stopped() and stops.
- On any error: emits playback_error(str) then playback_stopped().
"""
frame_ready = Signal(QVideoFrame)
playback_started = Signal()
playback_stopped = Signal()
playback_error = Signal(str)
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent)
self._player = QMediaPlayer(self)
self._sink = QVideoSink(self)
self._player.setVideoSink(self._sink)
self._sink.videoFrameChanged.connect(self._on_frame)
self._player.playbackStateChanged.connect(self._on_playback_state_changed)
self._player.errorOccurred.connect(self._on_error)
self._current_path: str | None = None
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def play(self, path: str) -> None:
"""Open and start playing a video file."""
self.stop()
p = Path(path)
if not p.exists():
msg = f"Video file not found: {path}"
logger.error(msg)
self.playback_error.emit(msg)
return
self._current_path = path
url = QUrl.fromLocalFile(str(p.resolve()))
self._player.setSource(url)
self._player.play()
logger.info("VideoPlayer: starting playback of '%s'", p.name)
def stop(self) -> None:
"""Stop playback and clear source."""
if self._player.playbackState() != QMediaPlayer.PlaybackState.StoppedState:
self._player.stop()
self._player.setSource(QUrl())
self._current_path = None
@property
def is_playing(self) -> bool:
return (
self._player.playbackState()
== QMediaPlayer.PlaybackState.PlayingState
)
@property
def current_path(self) -> str | None:
return self._current_path
# ------------------------------------------------------------------
# Private slots
# ------------------------------------------------------------------
@Slot(QVideoFrame)
def _on_frame(self, frame: QVideoFrame) -> None:
if frame.isValid():
self.frame_ready.emit(frame)
@Slot(QMediaPlayer.PlaybackState)
def _on_playback_state_changed(self, state: QMediaPlayer.PlaybackState) -> None:
if state == QMediaPlayer.PlaybackState.PlayingState:
logger.info("VideoPlayer: playing")
self.playback_started.emit()
elif state == QMediaPlayer.PlaybackState.StoppedState:
logger.info("VideoPlayer: stopped")
self.playback_stopped.emit()
@Slot(QMediaPlayer.Error, str)
def _on_error(self, error: QMediaPlayer.Error, error_string: str) -> None:
if error == QMediaPlayer.Error.NoError:
return
msg = f"VideoPlayer error: {error_string}"
logger.error(msg)
self.playback_error.emit(msg)
self.playback_stopped.emit()