feat: implement overlay architecture with IOverlayLayer interface and telemetry overlay

This commit is contained in:
2026-05-12 21:10:37 +02:00
parent 74a5dcd057
commit 4cc4f4bf6c
8 changed files with 334 additions and 342 deletions

144
app/ui/camera_view.py Normal file
View File

@@ -0,0 +1,144 @@
"""CameraView — QWidget that renders camera frames and composites overlay layers.
Responsibilities:
- Receive QVideoFrame, convert to QImage, schedule repaint.
- In paintEvent: draw the frame (letterboxed) then call paint() on each
registered IOverlayLayer in order.
- Know nothing about what specific overlays draw — that is entirely up to
each IOverlayLayer implementation.
Adding a new overlay (e.g. YOLO bboxes):
layer = YoloBboxOverlay()
camera_view.add_overlay_layer(layer)
yolo_processor.results_ready.connect(layer.on_results)
No modification to CameraView is needed.
"""
from __future__ import annotations
import logging
from PySide6.QtCore import QRect, Qt, Slot
from PySide6.QtGui import QImage, QPainter
from PySide6.QtMultimedia import QVideoFrame
from PySide6.QtWidgets import QWidget
from app.overlay.overlay_layer import IOverlayLayer
logger = logging.getLogger(__name__)
class CameraView(QWidget):
"""
Camera preview widget.
Frame pipeline:
CameraService.frame_ready(QVideoFrame)
→ CameraView.on_frame() — convert to QImage, schedule update()
→ paintEvent() — draw image + all overlay layers
"""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setStyleSheet("background: black;")
self._current_image: QImage | None = None
self._video_rect: QRect = QRect()
self._overlay_layers: list[IOverlayLayer] = []
# ------------------------------------------------------------------
# Overlay layer registry
# ------------------------------------------------------------------
def add_overlay_layer(self, layer: IOverlayLayer) -> None:
"""Register an overlay layer. Layers are painted in registration order."""
if layer not in self._overlay_layers:
self._overlay_layers.append(layer)
logger.debug("Overlay layer added: %s", layer.name)
def remove_overlay_layer(self, layer: IOverlayLayer) -> None:
"""Unregister an overlay layer."""
try:
self._overlay_layers.remove(layer)
logger.debug("Overlay layer removed: %s", layer.name)
except ValueError:
pass
def set_all_overlays_visible(self, visible: bool) -> None:
"""Show or hide all registered overlay layers at once."""
for layer in self._overlay_layers:
layer.visible = visible
self.update()
# ------------------------------------------------------------------
# Frame input
# ------------------------------------------------------------------
@Slot(QVideoFrame)
def on_frame(self, frame: QVideoFrame) -> None:
"""Receive a camera frame, convert to QImage, schedule repaint."""
if not frame.isValid():
return
image = frame.toImage()
if image.isNull():
return
# Convert to Format_RGB32 — QPainter's fastest path on all platforms
if image.format() != QImage.Format.Format_RGB32:
image = image.convertToFormat(QImage.Format.Format_RGB32)
self._current_image = image
self.update()
# ------------------------------------------------------------------
# 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
if self._current_image is not None:
img = self._current_image
self._video_rect = _letterbox_rect(
img.width(), img.height(), self.width(), self.height()
)
painter.drawImage(self._video_rect, img)
else:
self._video_rect = self.rect()
# 3. Overlay layers — each gets a clean painter state
for layer in self._overlay_layers:
if not layer.visible:
continue
painter.save()
try:
layer.paint(painter, self._video_rect)
except Exception:
logger.exception("Error painting overlay layer %s", layer.name)
finally:
painter.restore()
painter.end()
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _letterbox_rect(img_w: int, img_h: int, widget_w: int, widget_h: int) -> QRect:
"""Return the largest rect that fits the image while preserving 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)

View File

@@ -10,10 +10,11 @@ from PySide6.QtWidgets import QLabel, QMainWindow, QSizePolicy, QStatusBar
from app.camera.camera_enumerator import CameraEnumerator, CameraInfo
from app.camera.camera_service import CameraService
from app.config import APP_NAME, APP_VERSION
from app.overlay.telemetry_overlay import TelemetryOverlay
from app.pipeline.frame_dispatcher import FrameDispatcher
from app.telemetry.telemetry_collector import TelemetryCollector
from app.ui.camera_view import CameraView
from app.ui.menu_bar import AppMenuBar
from app.ui.video_widget import VideoWidget
logger = logging.getLogger(__name__)
@@ -23,20 +24,18 @@ class MainWindow(QMainWindow):
Top-level application window.
Rendering architecture:
QVideoWidget is intentionally NOT used. On Windows its native HWND
surface occludes all sibling/child QWidgets regardless of z-order,
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.
QVideoWidget is intentionally NOT used — on Windows its native HWND
surface occludes all sibling/child QWidgets regardless of z-order.
CameraView is a plain QWidget that renders frames and overlay layers
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)
CameraView.on_frame (render frame)
→ TelemetryCollector.on_frame (measure metrics)
TelemetryOverlay.on_metrics_updated (feed overlay data)
(CameraView repaints and calls TelemetryOverlay.paint())
"""
def __init__(self) -> None:
@@ -46,18 +45,21 @@ class MainWindow(QMainWindow):
self.setMinimumSize(640, 480)
self.resize(1280, 720)
# --- Core components ---
# --- Core pipeline components ---
self._camera_service = CameraService(self)
self._dispatcher = FrameDispatcher(self)
self._telemetry = TelemetryCollector(parent=self)
# --- Video widget (central widget) ---
# Plain QWidget — no native surface, overlay rendered in same paintEvent.
self._video_widget = VideoWidget(self)
self._video_widget.setSizePolicy(
# --- Camera view (central widget) ---
self._camera_view = CameraView(self)
self._camera_view.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
)
self.setCentralWidget(self._video_widget)
self.setCentralWidget(self._camera_view)
# --- Overlay layers ---
self._telemetry_overlay = TelemetryOverlay()
self._camera_view.add_overlay_layer(self._telemetry_overlay)
# --- Menu bar ---
self._menu = AppMenuBar(self)
@@ -109,14 +111,14 @@ class MainWindow(QMainWindow):
# CameraService → FrameDispatcher
self._camera_service.frame_ready.connect(self._dispatcher.dispatch)
# FrameDispatcher → VideoWidget (render) — drop if busy: skip frame, keep UI fluid
self._dispatcher.subscribe(self._video_widget.on_frame, drop_if_busy=True)
# FrameDispatcher → CameraView (render) — drop if busy: stay fluid
self._dispatcher.subscribe(self._camera_view.on_frame, drop_if_busy=True)
# FrameDispatcher → TelemetryCollector — never drop, count every frame
self._dispatcher.subscribe(self._telemetry.on_frame, drop_if_busy=False)
# TelemetryCollector → VideoWidget overlay
self._telemetry.metrics_updated.connect(self._video_widget.update_metrics)
# TelemetryCollector → TelemetryOverlay (data only, no repaint trigger here)
self._telemetry.metrics_updated.connect(self._telemetry_overlay.on_metrics_updated)
# CameraService status
self._camera_service.camera_started.connect(self._on_camera_started)
@@ -128,7 +130,7 @@ class MainWindow(QMainWindow):
self._menu.resolution_selected.connect(self._on_resolution_selected)
self._menu.fps_selected.connect(self._on_fps_selected)
self._menu.reconnect_requested.connect(self._camera_service.reconnect)
self._menu.overlay_toggled.connect(self._video_widget.set_overlay_visible)
self._menu.overlay_toggled.connect(self._camera_view.set_all_overlays_visible)
# ------------------------------------------------------------------
# Camera status slots

View File

@@ -1,185 +0,0 @@
"""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