"""Widget that renders an image and draws detection overlays. The confidence threshold is applied at paint time, so changing it is instant. One detection can be *highlighted* (selected in the detail table) — it is drawn boldly even if below the threshold, while the others dim, so the user can inspect exactly what the detector found. """ from __future__ import annotations import zlib import cv2 import numpy as np from PySide6.QtCore import QPointF, QRectF, Qt from PySide6.QtGui import QBrush, QColor, QFont, QImage, QPainter, QPen, QPolygonF from PySide6.QtWidgets import QWidget from ..config import OverlayConfig from ..core.detection.types import Detection class ImageView(QWidget): def __init__(self, overlay_cfg: OverlayConfig, parent: QWidget | None = None) -> None: super().__init__(parent) self._cfg = overlay_cfg self._qimage: QImage | None = None self._dets: list[Detection] = [] self._threshold = 0.0 self._model_thresholds: dict[str, float] = {} # model stem -> override threshold self._highlight: int | None = None self.setMinimumSize(480, 360) # ------------------------------------------------------------------ slots def set_image(self, image_bgr: np.ndarray | None, dets: list[Detection]) -> None: if image_bgr is None: self._qimage = None else: rgb = np.ascontiguousarray(cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)) h, w, ch = rgb.shape # .copy() so the QImage owns its pixels (the numpy buffer can be freed). self._qimage = QImage(rgb.data, w, h, ch * w, QImage.Format_RGB888).copy() self._dets = dets self._highlight = None self.update() def set_threshold(self, threshold: float) -> None: self._threshold = threshold self.update() def set_model_thresholds(self, thresholds: dict[str, float]) -> None: """Per-model overlay threshold overrides (keyed by model stem). Empty = none.""" self._model_thresholds = dict(thresholds) self.update() def _eff_threshold(self, d: Detection) -> float: """The threshold this detection must clear — its model's override, else global.""" return self._model_thresholds.get(d.model, self._threshold) def set_highlight(self, index: int | None) -> None: self._highlight = index self.update() # ------------------------------------------------------------------ paint def _color(self, key: str) -> QColor: """Colour for a detection category — fixed if configured, else a stable palette pick.""" rgb = self._cfg.colors.get(key) if rgb is None: palette = self._cfg.palette rgb = palette[zlib.crc32(key.encode("utf-8")) % len(palette)] return QColor(*rgb) def paintEvent(self, event) -> None: painter = QPainter(self) painter.fillRect(self.rect(), QColor(18, 18, 18)) if self._qimage is None: painter.setPen(QColor(160, 160, 160)) painter.drawText(self.rect(), Qt.AlignCenter, "Откройте проект (Файл → Открыть проект…)") painter.end() return iw, ih = self._qimage.width(), self._qimage.height() scale = min(self.width() / iw, self.height() / ih) dw, dh = iw * scale, ih * scale ox, oy = (self.width() - dw) / 2, (self.height() - dh) / 2 painter.setRenderHint(QPainter.SmoothPixmapTransform, True) painter.drawImage(QRectF(ox, oy, dw, dh), self._qimage) if self._dets: painter.setRenderHint(QPainter.Antialiasing, True) for i, d in enumerate(self._dets): highlighted = i == self._highlight # A highlighted detection is always drawn; others respect the threshold # (per-model override if any, else the global one). if not highlighted and d.score < self._eff_threshold(d): continue dim = self._highlight is not None and not highlighted self._draw_detection(painter, d, ox, oy, scale, highlighted, dim) painter.end() def _draw_detection( self, painter: QPainter, d: Detection, ox: float, oy: float, scale: float, highlighted: bool, dim: bool, ) -> None: color = self._color(d.display) width = self._cfg.line_width * (2 if highlighted else 1) pen_color = QColor(color) if dim: pen_color.setAlpha(70) painter.setPen(QPen(pen_color, width)) fill = QColor(color) fill.setAlpha(0 if dim else (self._cfg.fill_alpha * 2 if highlighted else self._cfg.fill_alpha)) painter.setBrush(QBrush(fill)) points = d.polygon if len(d.polygon) >= 3 else self._bbox_points(d.bbox) poly = QPolygonF([QPointF(ox + x * scale, oy + y * scale) for x, y in points]) painter.drawPolygon(poly) if self._cfg.show_labels and not dim: x, y, _w, _h = d.bbox self._draw_label(painter, f"{d.display} {d.score:.2f}", ox + x * scale, oy + y * scale, color) @staticmethod def _bbox_points(bbox: tuple[int, int, int, int]) -> list[tuple[int, int]]: x, y, w, h = bbox return [(x, y), (x + w, y), (x + w, y + h), (x, y + h)] def _draw_label(self, painter: QPainter, text: str, x: float, y: float, color: QColor) -> None: font = QFont() font.setPointSize(9) painter.setFont(font) metrics = painter.fontMetrics() tw = metrics.horizontalAdvance(text) + 8 th = metrics.height() + 2 bg = QRectF(x, max(0.0, y - th), tw, th) painter.fillRect(bg, color) painter.setPen(QColor(0, 0, 0)) painter.drawText(bg, Qt.AlignCenter, text)