186 lines
6.4 KiB
Python
186 lines
6.4 KiB
Python
"""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
|