"""BboxOverlay — draws YOLO detection bounding boxes on the camera view.""" from __future__ import annotations import logging from typing import NamedTuple from PySide6.QtCore import QRect, QSize, Qt, Slot from PySide6.QtGui import QColor, QFont, QPainter, QPen from app.config import ( BBOX_COLOR, BBOX_FONT_SIZE, BBOX_LABEL_BG_COLOR, BBOX_LABEL_TEXT_COLOR, BBOX_LINE_WIDTH, ) from app.overlay.overlay_layer import IOverlayLayer logger = logging.getLogger(__name__) class Detection(NamedTuple): """ A single object detection result. Coordinates (x1, y1, x2, y2) are in pixels of the *source frame* (i.e. the frame that was submitted to inference). BboxOverlay maps them to the letterboxed video_rect before drawing. """ x1: float y1: float x2: float y2: float conf: float label: str class BboxOverlay(IOverlayLayer): """ Overlay layer that renders detection bounding boxes. Usage: overlay = BboxOverlay() camera_view.add_overlay_layer(overlay) inference_manager.detections_ready.connect(overlay.on_detections) Thread safety: on_detections() is called from the GUI thread (via Qt signal). paint() is also called from the GUI thread (paintEvent). No locks required. """ def __init__(self) -> None: super().__init__() self._detections: list[Detection] = [] self._source_size: QSize = QSize(0, 0) self._pen = QPen(QColor(*BBOX_COLOR)) self._pen.setWidth(BBOX_LINE_WIDTH) self._pen.setJoinStyle(Qt.PenJoinStyle.MiterJoin) self._font = QFont("Monospace") self._font.setStyleHint(QFont.StyleHint.TypeWriter) self._font.setPointSize(BBOX_FONT_SIZE) self._font.setBold(True) self._box_color = QColor(*BBOX_COLOR) self._bg_color = QColor(*BBOX_LABEL_BG_COLOR) self._text_color = QColor(*BBOX_LABEL_TEXT_COLOR) # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ @Slot(object, object) def on_detections( self, detections: list[Detection], source_size: tuple[int, int], ) -> None: """ Receive detection results from InferenceManager. Args: detections: List of Detection namedtuples (pixel coords). source_size: (width, height) of the frame that was inferred. """ self._detections = detections self._source_size = QSize(*source_size) def clear(self) -> None: """Remove all currently displayed detections.""" self._detections = [] # ------------------------------------------------------------------ # IOverlayLayer implementation # ------------------------------------------------------------------ def paint(self, painter: QPainter, video_rect: QRect) -> None: if not self._detections: return if self._source_size.isEmpty(): return src_w = self._source_size.width() src_h = self._source_size.height() vr = video_rect # Scale factors: source-pixel → video_rect-pixel scale_x = vr.width() / src_w scale_y = vr.height() / src_h painter.setFont(self._font) fm = painter.fontMetrics() for det in self._detections: # Map to widget coordinates wx1 = vr.x() + int(det.x1 * scale_x) wy1 = vr.y() + int(det.y1 * scale_y) wx2 = vr.x() + int(det.x2 * scale_x) wy2 = vr.y() + int(det.y2 * scale_y) box_rect = QRect(wx1, wy1, wx2 - wx1, wy2 - wy1) # Draw bounding box painter.setPen(self._pen) painter.setBrush(Qt.BrushStyle.NoBrush) painter.drawRect(box_rect) # Label text: "label 0.87" label_text = f"{det.label} {det.conf:.2f}" text_w = fm.horizontalAdvance(label_text) + 6 text_h = fm.height() + 2 # Position label above box, clamped to video_rect lx = wx1 ly = wy1 - text_h if ly < vr.top(): ly = wy1 # draw inside box if no room above label_bg = QRect(lx, ly, text_w, text_h) painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(self._bg_color) painter.drawRect(label_bg) painter.setPen(QPen(self._text_color)) painter.drawText( lx + 3, ly + fm.ascent() + 1, label_text, )