145 lines
4.9 KiB
Python
145 lines
4.9 KiB
Python
"""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)
|