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