- 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.
118 lines
3.7 KiB
Python
118 lines
3.7 KiB
Python
"""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()
|