diff --git a/app/ui/main_window.py b/app/ui/main_window.py index 19639fb..08eae02 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -4,17 +4,16 @@ from __future__ import annotations import logging -from PySide6.QtCore import Qt, QTimer -from PySide6.QtMultimediaWidgets import QVideoWidget -from PySide6.QtWidgets import QLabel, QMainWindow, QSizePolicy, QStatusBar, QVBoxLayout, QWidget, QTextEdit +from PySide6.QtCore import QTimer +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.overlay_widget import OverlayWidget from app.pipeline.frame_dispatcher import FrameDispatcher from app.telemetry.telemetry_collector import TelemetryCollector from app.ui.menu_bar import AppMenuBar +from app.ui.video_widget import VideoWidget logger = logging.getLogger(__name__) @@ -23,10 +22,21 @@ class MainWindow(QMainWindow): """ Top-level application window. - Wires together: - CameraService → FrameDispatcher → TelemetryCollector - → OverlayWidget (via metrics_updated) - CameraService.capture_session → QVideoWidget (direct, zero-copy path) + 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. + + Signal flow: + CameraService.frame_ready + → FrameDispatcher.dispatch + → VideoWidget.on_frame (render) + → TelemetryCollector.on_frame (metrics) + → VideoWidget.update_metrics (overlay data) """ def __init__(self) -> None: @@ -41,39 +51,13 @@ class MainWindow(QMainWindow): self._dispatcher = FrameDispatcher(self) self._telemetry = TelemetryCollector(parent=self) - # --- Central container --- - # We need an extra QWidget layer between QMainWindow and QVideoWidget so - # that OverlayWidget can be a sibling of QVideoWidget (not its child). - # 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) + # --- Video widget (central widget) --- + # Plain QWidget — no native surface, overlay rendered in same paintEvent. + self._video_widget = VideoWidget(self) self._video_widget.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding ) - self._video_widget.setAspectRatioMode(Qt.AspectRatioMode.KeepAspectRatio) - 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;") + self.setCentralWidget(self._video_widget) # --- Menu bar --- self._menu = AppMenuBar(self) @@ -90,8 +74,6 @@ class MainWindow(QMainWindow): # --- Enumerate cameras and start --- QTimer.singleShot(0, self._initialise_cameras) - # Reposition overlay after the event loop starts (layout is finalised) - QTimer.singleShot(0, self._reposition_overlay) # ------------------------------------------------------------------ # Initialisation @@ -127,11 +109,14 @@ class MainWindow(QMainWindow): # CameraService → FrameDispatcher 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) - # TelemetryCollector → OverlayWidget - self._telemetry.metrics_updated.connect(self._overlay.update_metrics) + # TelemetryCollector → VideoWidget overlay + self._telemetry.metrics_updated.connect(self._video_widget.update_metrics) # CameraService status 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.fps_selected.connect(self._on_fps_selected) 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 @@ -179,23 +164,6 @@ class MainWindow(QMainWindow): # 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 self._camera_service.stop() super().closeEvent(event) diff --git a/app/ui/video_widget.py b/app/ui/video_widget.py new file mode 100644 index 0000000..897cb2a --- /dev/null +++ b/app/ui/video_widget.py @@ -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