feat: implement VideoWidget for rendering camera frames and metrics overlay

This commit is contained in:
2026-05-12 20:43:38 +02:00
parent 22e52d5f5a
commit 74a5dcd057
2 changed files with 214 additions and 61 deletions

View File

@@ -4,17 +4,16 @@ from __future__ import annotations
import logging import logging
from PySide6.QtCore import Qt, QTimer from PySide6.QtCore import QTimer
from PySide6.QtMultimediaWidgets import QVideoWidget from PySide6.QtWidgets import QLabel, QMainWindow, QSizePolicy, QStatusBar
from PySide6.QtWidgets import QLabel, QMainWindow, QSizePolicy, QStatusBar, QVBoxLayout, QWidget, QTextEdit
from app.camera.camera_enumerator import CameraEnumerator, CameraInfo from app.camera.camera_enumerator import CameraEnumerator, CameraInfo
from app.camera.camera_service import CameraService from app.camera.camera_service import CameraService
from app.config import APP_NAME, APP_VERSION from app.config import APP_NAME, APP_VERSION
from app.overlay.overlay_widget import OverlayWidget
from app.pipeline.frame_dispatcher import FrameDispatcher from app.pipeline.frame_dispatcher import FrameDispatcher
from app.telemetry.telemetry_collector import TelemetryCollector from app.telemetry.telemetry_collector import TelemetryCollector
from app.ui.menu_bar import AppMenuBar from app.ui.menu_bar import AppMenuBar
from app.ui.video_widget import VideoWidget
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -23,10 +22,21 @@ class MainWindow(QMainWindow):
""" """
Top-level application window. Top-level application window.
Wires together: Rendering architecture:
CameraService → FrameDispatcher → TelemetryCollector QVideoWidget is intentionally NOT used. On Windows its native HWND
→ OverlayWidget (via metrics_updated) surface occludes all sibling/child QWidgets regardless of z-order,
CameraService.capture_session → QVideoWidget (direct, zero-copy path) making overlay rendering impossible.
Instead, frames are received as QVideoFrame via CameraService.frame_ready,
converted to QImage inside VideoWidget.on_frame(), and drawn together with
the metrics overlay in a single paintEvent pass.
Signal flow:
CameraService.frame_ready
→ FrameDispatcher.dispatch
→ VideoWidget.on_frame (render)
→ TelemetryCollector.on_frame (metrics)
→ VideoWidget.update_metrics (overlay data)
""" """
def __init__(self) -> None: def __init__(self) -> None:
@@ -41,39 +51,13 @@ class MainWindow(QMainWindow):
self._dispatcher = FrameDispatcher(self) self._dispatcher = FrameDispatcher(self)
self._telemetry = TelemetryCollector(parent=self) self._telemetry = TelemetryCollector(parent=self)
# --- Central container --- # --- Video widget (central widget) ---
# We need an extra QWidget layer between QMainWindow and QVideoWidget so # Plain QWidget — no native surface, overlay rendered in same paintEvent.
# that OverlayWidget can be a sibling of QVideoWidget (not its child). self._video_widget = VideoWidget(self)
# QVideoWidget renders via a native D3D/GL surface that occludes any
# QWidget children painted on top of it.
self._container = QWidget(self)
self._container.setStyleSheet("background: black;")
container_layout = QVBoxLayout(self._container)
container_layout.setContentsMargins(0, 0, 0, 0)
container_layout.setSpacing(0)
# --- Video widget ---
self._video_widget = QVideoWidget(self._container)
self._video_widget.setSizePolicy( self._video_widget.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
) )
self._video_widget.setAspectRatioMode(Qt.AspectRatioMode.KeepAspectRatio) self.setCentralWidget(self._video_widget)
container_layout.addWidget(self._video_widget)
self.setCentralWidget(self._container)
# Connect camera session to video widget — this is the zero-copy render path
self._camera_service.capture_session().setVideoOutput(self._video_widget)
# --- Overlay ---
# Sibling of QVideoWidget inside _container; positioned manually so it
# floats above the video without being occluded by the native GL surface.
self._overlay = OverlayWidget(parent=self._container)
self._overlay.raise_()
# --- Overlay for testing ---
self._test_overlay = QTextEdit(parent=self._container)
self._test_overlay.setReadOnly(True)
self._test_overlay.setStyleSheet("background-color: rgba(20, 20, 20, 0.5); color: white; font-size: 14px; border: none; padding: 8px;")
# --- Menu bar --- # --- Menu bar ---
self._menu = AppMenuBar(self) self._menu = AppMenuBar(self)
@@ -90,8 +74,6 @@ class MainWindow(QMainWindow):
# --- Enumerate cameras and start --- # --- Enumerate cameras and start ---
QTimer.singleShot(0, self._initialise_cameras) QTimer.singleShot(0, self._initialise_cameras)
# Reposition overlay after the event loop starts (layout is finalised)
QTimer.singleShot(0, self._reposition_overlay)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Initialisation # Initialisation
@@ -127,11 +109,14 @@ class MainWindow(QMainWindow):
# CameraService → FrameDispatcher # CameraService → FrameDispatcher
self._camera_service.frame_ready.connect(self._dispatcher.dispatch) self._camera_service.frame_ready.connect(self._dispatcher.dispatch)
# FrameDispatcher → TelemetryCollector (never drop for telemetry) # FrameDispatcher → VideoWidget (render) — drop if busy: skip frame, keep UI fluid
self._dispatcher.subscribe(self._video_widget.on_frame, drop_if_busy=True)
# FrameDispatcher → TelemetryCollector — never drop, count every frame
self._dispatcher.subscribe(self._telemetry.on_frame, drop_if_busy=False) self._dispatcher.subscribe(self._telemetry.on_frame, drop_if_busy=False)
# TelemetryCollector → OverlayWidget # TelemetryCollector → VideoWidget overlay
self._telemetry.metrics_updated.connect(self._overlay.update_metrics) self._telemetry.metrics_updated.connect(self._video_widget.update_metrics)
# CameraService status # CameraService status
self._camera_service.camera_started.connect(self._on_camera_started) self._camera_service.camera_started.connect(self._on_camera_started)
@@ -143,7 +128,7 @@ class MainWindow(QMainWindow):
self._menu.resolution_selected.connect(self._on_resolution_selected) self._menu.resolution_selected.connect(self._on_resolution_selected)
self._menu.fps_selected.connect(self._on_fps_selected) self._menu.fps_selected.connect(self._on_fps_selected)
self._menu.reconnect_requested.connect(self._camera_service.reconnect) self._menu.reconnect_requested.connect(self._camera_service.reconnect)
self._menu.overlay_toggled.connect(self._overlay.set_overlay_visible) self._menu.overlay_toggled.connect(self._video_widget.set_overlay_visible)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Camera status slots # Camera status slots
@@ -179,23 +164,6 @@ class MainWindow(QMainWindow):
# Qt overrides # Qt overrides
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def resizeEvent(self, event) -> None: # noqa: N802
super().resizeEvent(event)
self._reposition_overlay()
def _reposition_overlay(self) -> None:
"""Keep the overlay covering the video widget exactly."""
if not (hasattr(self, "_overlay") and hasattr(self, "_video_widget")):
return
# _overlay and _video_widget share the same parent (_container),
# so video_widget.geometry() is already in the right coordinate space.
self._overlay.setGeometry(self._video_widget.geometry())
self._overlay.raise_()
# --- overlar for testing ---
panel_width = min(300, max(280, self._container.width() // 3))
self._test_overlay.setGeometry(18, 18, panel_width, 500)
def closeEvent(self, event) -> None: # noqa: N802 def closeEvent(self, event) -> None: # noqa: N802
self._camera_service.stop() self._camera_service.stop()
super().closeEvent(event) super().closeEvent(event)

185
app/ui/video_widget.py Normal file
View File

@@ -0,0 +1,185 @@
"""VideoWidget — custom QWidget that renders camera frames and the metrics overlay.
Why not QVideoWidget?
QVideoWidget on Windows uses a native HWND surface (D3D / Media Foundation)
that is painted *outside* the normal Qt widget hierarchy. Any sibling or
child QWidget is occluded by this native surface regardless of z-order.
The only reliable cross-platform solution is to render frames ourselves
in paintEvent() so that overlay drawing happens in the same Qt paint pass.
Pipeline:
CameraService.frame_ready(QVideoFrame)
→ VideoWidget.on_frame() — stores latest QImage, schedules repaint
→ paintEvent() — draws image + overlay text in one pass
"""
from __future__ import annotations
import logging
from PySide6.QtCore import QRect, Qt, Slot
from PySide6.QtGui import QColor, QFont, QImage, QPainter, QPen
from PySide6.QtMultimedia import QVideoFrame
from PySide6.QtWidgets import QWidget
from app.config import (
OVERLAY_BG_COLOR,
OVERLAY_FONT_SIZE,
OVERLAY_MARGIN,
OVERLAY_PADDING,
OVERLAY_TEXT_COLOR,
)
from app.telemetry.telemetry_collector import TelemetrySnapshot
logger = logging.getLogger(__name__)
class VideoWidget(QWidget):
"""
Renders camera frames using plain QPainter.
Accepts frames via on_frame() slot (connect to CameraService.frame_ready).
Accepts telemetry via update_metrics() slot (connect to TelemetryCollector.metrics_updated).
Overlay visibility is controlled by set_overlay_visible().
"""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setStyleSheet("background: black;")
self._current_image: QImage | None = None
self._snapshot: TelemetrySnapshot | None = None
self._overlay_visible: bool = True
# Overlay font
self._font = QFont("Monospace")
self._font.setStyleHint(QFont.StyleHint.TypeWriter)
self._font.setPointSize(OVERLAY_FONT_SIZE)
self._font.setBold(False)
# ------------------------------------------------------------------
# Frame input
# ------------------------------------------------------------------
@Slot(QVideoFrame)
def on_frame(self, frame: QVideoFrame) -> None:
"""Receive a new camera frame, convert to QImage and schedule repaint."""
if not frame.isValid():
return
image = frame.toImage()
if image.isNull():
return
# Convert to a format that QPainter handles efficiently
if image.format() != QImage.Format.Format_RGB32:
image = image.convertToFormat(QImage.Format.Format_RGB32)
self._current_image = image
self.update() # schedule paintEvent — non-blocking
# ------------------------------------------------------------------
# Telemetry input
# ------------------------------------------------------------------
@Slot(object)
def update_metrics(self, snapshot: TelemetrySnapshot) -> None:
"""Receive a new telemetry snapshot. Overlay will reflect it on next repaint."""
self._snapshot = snapshot
if self._overlay_visible:
self.update()
# ------------------------------------------------------------------
# Overlay control
# ------------------------------------------------------------------
def set_overlay_visible(self, visible: bool) -> None:
self._overlay_visible = visible
self.update()
def toggle_overlay(self) -> None:
self.set_overlay_visible(not self._overlay_visible)
# ------------------------------------------------------------------
# Qt paint
# ------------------------------------------------------------------
def paintEvent(self, event) -> None: # noqa: N802
painter = QPainter(self)
# 1. Background
painter.fillRect(self.rect(), Qt.GlobalColor.black)
# 2. Camera frame — letterboxed to keep aspect ratio
if self._current_image is not None:
img = self._current_image
target = _letterbox_rect(img.width(), img.height(), self.width(), self.height())
painter.drawImage(target, img)
# 3. Overlay
if self._overlay_visible and self._snapshot is not None:
self._paint_overlay(painter)
painter.end()
# ------------------------------------------------------------------
# Private — overlay rendering
# ------------------------------------------------------------------
def _paint_overlay(self, painter: QPainter) -> None:
lines = _format_lines(self._snapshot)
if not lines:
return
painter.setFont(self._font)
fm = painter.fontMetrics()
line_height = fm.height()
max_width = max(fm.horizontalAdvance(line) for line in lines)
box_w = max_width + OVERLAY_PADDING * 2
box_h = line_height * len(lines) + OVERLAY_PADDING * 2
x = OVERLAY_MARGIN
y = OVERLAY_MARGIN
# Semi-transparent background
painter.setBrush(QColor(*OVERLAY_BG_COLOR))
painter.setPen(Qt.PenStyle.NoPen)
painter.drawRoundedRect(QRect(x, y, box_w, box_h), 6, 6)
# Text
painter.setPen(QPen(QColor(*OVERLAY_TEXT_COLOR)))
text_x = x + OVERLAY_PADDING
text_y = y + OVERLAY_PADDING + fm.ascent()
for line in lines:
painter.drawText(text_x, text_y, line)
text_y += line_height
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _letterbox_rect(img_w: int, img_h: int, widget_w: int, widget_h: int) -> QRect:
"""Return the largest rect that fits img inside widget while keeping aspect ratio."""
if img_w <= 0 or img_h <= 0:
return QRect(0, 0, widget_w, widget_h)
scale = min(widget_w / img_w, widget_h / img_h)
draw_w = int(img_w * scale)
draw_h = int(img_h * scale)
x = (widget_w - draw_w) // 2
y = (widget_h - draw_h) // 2
return QRect(x, y, draw_w, draw_h)
def _format_lines(snap: TelemetrySnapshot) -> list[str]:
lines = [
f"FPS {snap.fps:>6.1f}",
f"Frame {snap.frame_time_ms:>6.1f} ms",
f"Drop {snap.dropped_frames:>6d}",
f"CPU {snap.cpu_percent:>5.1f} %",
]
if snap.memory_mb is not None:
lines.append(f"Mem {snap.memory_mb:>5.0f} MB")
return lines