"""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