Files
AareDAQ/src/aare/gui/new_gui/widgets/camera.py
T
appleb_mandClaude Opus 4.8 5b833bf9eb new_gui Phase 2: camera ML overlays, controls/screenshot, gonio/beamline video, preconditions
- camera: ML detection boxes+polygons (per-class colours), state-coloured beam
  centre marker, focus readout
- camera controls in tab strip: gain/exposure (samcam_settings), autofocus,
  screenshot-to-DB
- Gonio/Beamline tabs wired to axis IP video via VideoThread
- precondition gate (ring/shutter/hutch + 1h snooze) on scans; hutch-PSS mount
  block; sample-missing modal

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:51:54 +02:00

557 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""The central camera viewport with crosshair, overlays and a raster grid-draw tool.
Wires to the live SAMCAM feed via :class:`PredictionSubscriber` (``image`` ->
:meth:`update_pixmap`). When no frame is available it paints the design's dark
radial gradient so the layout still reads. Empty/picking states and the Mount
CTA are real child widgets so their buttons are clickable; the crosshair, target
ring, legend and raster grid are painted.
"""
from __future__ import annotations
from PySide6.QtCore import QPoint, QRect, QRectF, Qt, Signal
from PySide6.QtGui import (
QColor,
QFont,
QPainter,
QPen,
QPixmap,
QRadialGradient,
)
from PySide6.QtWidgets import (
QFrame,
QLabel,
QPushButton,
QVBoxLayout,
QWidget,
)
from aare.gui.new_gui.theme import FONT_MONO_FALLBACK, Palette
# Per-class detection colours (match the existing GUI).
_DET_COLORS = {
"pin": "#ff5555", "loop_all": "#3fb950", "loop_face": "#d29922",
"crystal": "#4d8bff", "needle": "#d24bff", "ice": "#33d6e0",
}
class CameraViewport(QWidget):
"""Camera image + overlays. Modes: 'empty' | 'picking' | 'mounted'."""
mount_clicked = Signal()
# Emitted while/after dragging a raster grid. Args are the rectangle in
# IMAGE-PIXEL coordinates (x, y, w, h) so the raster controller can convert
# to mm using the status pixel_in_mm.
grid_drawn = Signal(float, float, float, float)
# Left-click on the live image (not in grid-draw mode) in IMAGE-PIXEL coords —
# drives implicit click-to-centre.
clicked_point = Signal(float, float)
def __init__(self, palette: Palette, parent: QWidget | None = None):
super().__init__(parent)
self._p = palette
self._pixmap: QPixmap | None = None
self._fps: float = 0.0
self._zoom: float = 2.0
self._mode = "empty"
self._target_point: QPoint | None = None # image-pixel coords
self._beam_px: QPoint | None = None # beam centre, image-pixel coords
self._beam_shutter = False
self._beam_busy = False
self._focus: float | None = None
self._detections: list = [] # raw boxes from prediction
self._det_shape = None # [h, w] of the inference image
self._show_detections = True
# Raster grid-draw state
self._grid_draw_enabled = False
self._grid_rect_img: QRectF | None = None # in image-pixel coords
self._grid_nx = 0
self._grid_ny = 0
self._heatmap = None # QImage or None
self._heatmap_alpha = 180
self._drag_start: QPoint | None = None # widget coords
self._drag_cur: QPoint | None = None
# geometry of the last painted frame (for widget<->image mapping)
self._img_w = 1
self._img_h = 1
self._draw_rect = QRect() # where the frame is drawn in widget space
# control (baton) state — when False the viewport is a read-only guest view
self._in_control = True
self.setMinimumHeight(220)
self.setMouseTracking(True)
self._build_overlays()
self._build_control_overlay()
self.set_mode("empty")
# ------------------------------------------------------------ overlays
def _build_overlays(self) -> None:
p = self._p
# Empty-state modal card
self._empty_card = QFrame(self)
self._empty_card.setStyleSheet(
"QFrame { background: rgba(18,17,16,0.74);"
" border:1px solid rgba(255,255,255,0.14); border-radius:16px; }"
)
ecl = QVBoxLayout(self._empty_card)
ecl.setContentsMargins(34, 30, 34, 30)
ecl.setSpacing(0)
ecl.setAlignment(Qt.AlignHCenter)
tile = QLabel("⊕")
tile.setAlignment(Qt.AlignCenter)
tile.setFixedSize(56, 56)
tile.setStyleSheet(
f"background:{p.accent}; color:#fff; border-radius:14px; font-size:27px;"
)
title = QLabel("No sample mounted")
title.setAlignment(Qt.AlignCenter)
title.setStyleSheet("color:#fff; font-size:17px; font-weight:700;")
body = QLabel(
"The beamline is idle. Mount a sample from the changer to begin the "
"Center → Collect pipeline."
)
body.setWordWrap(True)
body.setAlignment(Qt.AlignCenter)
body.setStyleSheet("color:#c7c2bb; font-size:12.5px;")
body.setMaximumWidth(280)
btn = QPushButton("⊕ Mount sample")
btn.setCursor(Qt.PointingHandCursor)
btn.setStyleSheet(
f"QPushButton {{ background:{p.accent}; color:#fff; border:none;"
f" border-radius:10px; padding:11px 22px; font-size:14px; font-weight:600; }}"
)
btn.clicked.connect(self.mount_clicked)
ecl.addWidget(tile, 0, Qt.AlignHCenter)
ecl.addSpacing(16)
ecl.addWidget(title)
ecl.addSpacing(6)
ecl.addWidget(body)
ecl.addSpacing(20)
ecl.addWidget(btn, 0, Qt.AlignHCenter)
self._empty_card.adjustSize()
# Picking toast
self._toast = QLabel(
"← Choose a sample in the Sample Changer to mount it", self
)
self._toast.setStyleSheet(
"background: rgba(18,17,16,0.66); border:1px solid rgba(255,255,255,0.12);"
" border-radius:12px; padding:15px 22px; font-size:13px; color:#e7e3db;"
)
self._toast.adjustSize()
def _build_control_overlay(self) -> None:
"""Full-rect dim + message shown when this user does not hold the baton."""
self._control_overlay = QFrame(self)
self._control_overlay.setStyleSheet(
"QFrame { background: rgba(8,9,12,0.62); }"
)
col = QVBoxLayout(self._control_overlay)
col.setAlignment(Qt.AlignCenter)
col.setSpacing(6)
self._control_title = QLabel("GUEST MODE")
self._control_title.setAlignment(Qt.AlignCenter)
self._control_title.setStyleSheet(
"color:#ece8f4; font-size:18px; font-weight:700; letter-spacing:2px;"
" background:transparent;"
)
self._control_sub = QLabel("")
self._control_sub.setAlignment(Qt.AlignCenter)
self._control_sub.setStyleSheet(
"color:#c7c2bb; font-size:12.5px; background:transparent;"
)
col.addWidget(self._control_title)
col.addWidget(self._control_sub)
self._control_overlay.setVisible(False)
def set_control_state(self, in_control: bool, title: str = "GUEST MODE",
sub: str = "") -> None:
"""in_control=False dims the viewport and blocks interaction."""
self._in_control = in_control
self._control_title.setText(title)
self._control_sub.setText(sub)
self._control_overlay.setVisible(not in_control)
if not in_control:
self._control_overlay.setGeometry(self.rect())
self._control_overlay.raise_()
self.update()
# --------------------------------------------------------------- state
def set_mode(self, mode: str) -> None:
self._mode = mode
self._empty_card.setVisible(mode == "empty")
self._toast.setVisible(mode == "picking")
self._reposition_overlays()
self.update()
def set_grid_draw_enabled(self, enabled: bool) -> None:
self._grid_draw_enabled = enabled
self.setCursor(Qt.CrossCursor if enabled else Qt.ArrowCursor)
if not enabled:
self._drag_start = self._drag_cur = None
self.update()
def set_grid_cells(self, n_x: int, n_y: int) -> None:
self._grid_nx, self._grid_ny = max(0, n_x), max(0, n_y)
self.update()
def clear_grid(self) -> None:
self._grid_rect_img = None
self._grid_nx = self._grid_ny = 0
self._drag_start = self._drag_cur = None
self._heatmap = None
self.update()
def set_heatmap(self, image, alpha: int | None = None) -> None:
"""Overlay a completed-raster heatmap (QImage) on the current grid rect."""
self._heatmap = image
if alpha is not None:
self._heatmap_alpha = max(0, min(255, alpha))
self.update()
def set_heatmap_alpha(self, alpha: int) -> None:
self._heatmap_alpha = max(0, min(255, alpha))
self.update()
def set_grid_rect(self, x: float, y: float, w: float, h: float) -> None:
"""Set the grid rect in image-pixel coords (used to place a heatmap)."""
self._grid_rect_img = QRectF(x, y, w, h)
self.update()
# ------------------------------------------------------------- slots
def update_pixmap(self, pm: QPixmap) -> None:
self._pixmap = pm
if not pm.isNull():
self._img_w = pm.width()
self._img_h = pm.height()
self.update()
def update_fps(self, fps: float) -> None:
self._fps = fps
self.update()
def set_zoom(self, zoom: float) -> None:
self._zoom = zoom
self.update()
def update_target_point(self, data: dict) -> None:
tp = data.get("target_point") if isinstance(data, dict) else None
if tp and "x" in tp and "y" in tp:
self._target_point = QPoint(int(tp["x"]), int(tp["y"]))
else:
self._target_point = None
self.update()
def set_beam_center(self, x: float, y: float) -> None:
self._beam_px = QPoint(int(x), int(y))
self.update()
def set_beam_state(self, shutter_open: bool, busy: bool) -> None:
self._beam_shutter = bool(shutter_open)
self._beam_busy = bool(busy)
self.update()
def update_focus(self, value: float) -> None:
self._focus = value
self.update()
def update_predictions(self, payload: dict) -> None:
if not isinstance(payload, dict):
return
self._detections = payload.get("boxes", []) or []
self._det_shape = payload.get("shape")
self.update()
def set_detections_visible(self, visible: bool) -> None:
self._show_detections = visible
self.update()
# -------------------------------------------------- coordinate mapping
def _widget_to_image(self, pt: QPoint) -> QPoint:
if self._draw_rect.width() <= 0 or self._draw_rect.height() <= 0:
return QPoint(0, 0)
fx = (pt.x() - self._draw_rect.x()) / self._draw_rect.width()
fy = (pt.y() - self._draw_rect.y()) / self._draw_rect.height()
fx = min(max(fx, 0.0), 1.0)
fy = min(max(fy, 0.0), 1.0)
return QPoint(int(fx * self._img_w), int(fy * self._img_h))
def _image_to_widget(self, pt: QPoint) -> QPoint:
if self._img_w <= 0 or self._img_h <= 0:
return QPoint(0, 0)
x = self._draw_rect.x() + pt.x() / self._img_w * self._draw_rect.width()
y = self._draw_rect.y() + pt.y() / self._img_h * self._draw_rect.height()
return QPoint(int(x), int(y))
def _image_rect_to_widget(self, r: QRectF) -> QRect:
tl = self._image_to_widget(QPoint(int(r.left()), int(r.top())))
br = self._image_to_widget(QPoint(int(r.right()), int(r.bottom())))
return QRect(tl, br)
# --------------------------------------------------------- mouse (grid)
def mousePressEvent(self, event): # noqa: N802
if not self._in_control:
super().mousePressEvent(event)
return
if self._grid_draw_enabled and event.button() == Qt.LeftButton:
self._drag_start = event.position().toPoint()
self._drag_cur = self._drag_start
self.update()
elif (event.button() == Qt.LeftButton and self._mode == "mounted"
and self._pixmap is not None and not self._pixmap.isNull()):
# Implicit click-to-centre: report the clicked point in image pixels.
img = self._widget_to_image(event.position().toPoint())
self.clicked_point.emit(float(img.x()), float(img.y()))
else:
super().mousePressEvent(event)
def mouseMoveEvent(self, event): # noqa: N802
if self._grid_draw_enabled and self._drag_start is not None:
self._drag_cur = event.position().toPoint()
self.update()
else:
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event): # noqa: N802
if (self._grid_draw_enabled and self._drag_start is not None
and event.button() == Qt.LeftButton):
self._drag_cur = event.position().toPoint()
a = self._widget_to_image(self._drag_start)
b = self._widget_to_image(self._drag_cur)
x, y = min(a.x(), b.x()), min(a.y(), b.y())
w, h = abs(b.x() - a.x()), abs(b.y() - a.y())
if w > 2 and h > 2:
self._grid_rect_img = QRectF(x, y, w, h)
self.grid_drawn.emit(float(x), float(y), float(w), float(h))
self._drag_start = self._drag_cur = None
self.update()
else:
super().mouseReleaseEvent(event)
# ------------------------------------------------------------- layout
def resizeEvent(self, event): # noqa: N802
super().resizeEvent(event)
self._reposition_overlays()
if hasattr(self, "_control_overlay"):
self._control_overlay.setGeometry(self.rect())
def _reposition_overlays(self) -> None:
if self._empty_card.isVisible():
self._empty_card.adjustSize()
sz = self._empty_card.size()
self._empty_card.move(
(self.width() - sz.width()) // 2,
(self.height() - sz.height()) // 2,
)
if self._toast.isVisible():
self._toast.adjustSize()
sz = self._toast.size()
self._toast.move(
(self.width() - sz.width()) // 2,
(self.height() - sz.height()) // 2,
)
# ------------------------------------------------------------- paint
def paintEvent(self, event): # noqa: N802
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
r = self.rect()
if self._pixmap is not None and not self._pixmap.isNull():
scaled = self._pixmap.scaled(
r.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation
)
x = (r.width() - scaled.width()) // 2
y = (r.height() - scaled.height()) // 2
self._draw_rect = QRect(x, y, scaled.width(), scaled.height())
# letterbox
painter.fillRect(r, QColor(self._p.cam_stop2))
painter.drawPixmap(x, y, scaled)
else:
grad = QRadialGradient(r.width() * 0.75, r.height() * 0.80,
max(r.width(), r.height()) * 1.0)
grad.setColorAt(0.0, QColor(self._p.cam_stop0))
grad.setColorAt(0.45, QColor(self._p.cam_stop1))
grad.setColorAt(1.0, QColor(self._p.cam_stop2))
painter.fillRect(r, grad)
self._draw_rect = r
self._paint_crosshair(painter, r)
self._paint_overlay_text(painter, r)
if self._mode == "mounted":
self._paint_mounted_overlays(painter, r)
if self._show_detections and self._mode == "mounted":
self._paint_detections(painter)
self._paint_grid(painter)
def _paint_detections(self, painter: QPainter) -> None:
if not self._detections or self._pixmap is None or self._pixmap.isNull():
return
# Map detection-image coords -> our image coords (handles differing inference res).
det_w = det_h = None
if self._det_shape and len(self._det_shape) >= 2:
det_h, det_w = float(self._det_shape[0]), float(self._det_shape[1])
sx = self._img_w / det_w if det_w else 1.0
sy = self._img_h / det_h if det_h else 1.0
f = QFont()
f.setPixelSize(10)
painter.setFont(f)
for d in self._detections:
try:
x1, y1 = float(d["x1"]) * sx, float(d["y1"]) * sy
x2, y2 = float(d["x2"]) * sx, float(d["y2"]) * sy
except (KeyError, TypeError, ValueError):
continue
label = str(d.get("label", ""))
color = QColor(_DET_COLORS.get(label, "#cccccc"))
tl = self._image_to_widget(QPoint(int(x1), int(y1)))
br = self._image_to_widget(QPoint(int(x2), int(y2)))
rect = QRect(tl, br).normalized()
painter.setBrush(Qt.NoBrush)
painter.setPen(QPen(color, 2))
painter.drawRect(rect)
# polygon (points are relative to the box origin in detection coords)
poly = d.get("poly")
if poly:
from PySide6.QtGui import QPolygon
pts = []
for px, py in poly:
ix = int((float(px) + float(d["x1"])) * sx)
iy = int((float(py) + float(d["y1"])) * sy)
pts.append(self._image_to_widget(QPoint(ix, iy)))
if len(pts) >= 3:
faint = QColor(color)
faint.setAlpha(150)
painter.setPen(QPen(faint, 1))
painter.drawPolygon(QPolygon(pts))
conf = d.get("conf")
tag = f"{label} {conf:.2f}" if isinstance(conf, (int, float)) else label
painter.setPen(color)
painter.drawText(rect.left(), rect.top() - 3, tag)
def _paint_crosshair(self, painter: QPainter, r: QRect) -> None:
# Beam-centre crosshair at the real beam pixel when known, else viewport centre.
if self._beam_px is not None and self._pixmap is not None \
and not self._pixmap.isNull():
c = self._image_to_widget(self._beam_px)
# colour by state: shutter open = green, busy = red, idle = orange
if self._beam_busy:
col = QColor(255, 0, 0)
elif self._beam_shutter:
col = QColor(0, 200, 0)
else:
col = QColor(245, 121, 0)
pen = QPen(col)
pen.setWidth(2)
painter.setPen(pen)
painter.drawLine(c.x(), c.y() - 14, c.x(), c.y() + 14)
painter.drawLine(c.x() - 14, c.y(), c.x() + 14, c.y())
painter.setBrush(Qt.NoBrush)
painter.drawEllipse(c, 7, 7)
else:
pen = QPen(QColor(255, 255, 255, 46))
pen.setWidth(1)
painter.setPen(pen)
cx, cy = r.width() // 2, r.height() // 2
painter.drawLine(cx, cy - 21, cx, cy + 21)
painter.drawLine(cx - 21, cy, cx + 21, cy)
def _paint_overlay_text(self, painter: QPainter, r: QRect) -> None:
painter.setPen(QColor("#867f96"))
f = QFont(FONT_MONO_FALLBACK.split(",")[0].strip('"'))
f.setPixelSize(11)
painter.setFont(f)
fps = "-" if self._fps != self._fps else f"{self._fps:.0f}" # NaN-safe
text = f"SAMCAM · {fps} fps · {self._zoom:.1f}×"
if self._focus is not None and self._focus == self._focus: # NaN-safe
text += f" · focus {self._focus:.0f}"
painter.drawText(QRect(0, 8, r.width() - 14, 16),
Qt.AlignRight | Qt.AlignTop, text)
def _paint_mounted_overlays(self, painter: QPainter, r: QRect) -> None:
# Target ring: at target point if known, else design's bottom-right area.
if self._target_point is not None:
c = self._image_to_widget(self._target_point)
else:
c = QPoint(r.width() - 120, r.height() - 90)
painter.setBrush(Qt.NoBrush)
painter.setPen(QPen(QColor(self._p.accent), 2))
painter.drawEllipse(c, 15, 15)
halo = QColor(self._p.accent)
halo.setAlpha(56)
painter.setPen(QPen(halo, 4))
painter.drawEllipse(c, 17, 17)
# Legend card (bottom-left)
self._paint_legend(painter, r)
def _paint_legend(self, painter: QPainter, r: QRect) -> None:
items = [("#9b7bff", "Target + coords"),
("#d29922", "Loop / face"),
("#3fb950", "Crystal")]
pad, line_h, sw = 11, 18, 9
f = QFont(FONT_MONO_FALLBACK.split(",")[0].strip('"'))
f.setPixelSize(10)
painter.setFont(f)
w = 150
h = pad * 2 + line_h * len(items)
x, y = 14, r.height() - 14 - h
card = QRect(x, y, w, h)
painter.setBrush(QColor(8, 10, 13, 168))
painter.setPen(QPen(QColor("#332c44"), 1))
painter.drawRoundedRect(card, 7, 7)
for i, (col, label) in enumerate(items):
ly = y + pad + i * line_h
painter.setBrush(QColor(col))
painter.setPen(Qt.NoPen)
painter.drawRoundedRect(QRect(x + pad, ly + 2, sw, sw), 2, 2)
painter.setPen(QColor("#d9d3e4"))
painter.drawText(QRect(x + pad + sw + 7, ly, w - pad * 2 - sw, line_h),
Qt.AlignLeft | Qt.AlignVCenter, label)
def _paint_grid(self, painter: QPainter) -> None:
# Live drag rectangle
rect = None
if self._drag_start is not None and self._drag_cur is not None:
rect = QRect(self._drag_start, self._drag_cur).normalized()
elif self._grid_rect_img is not None:
rect = self._image_rect_to_widget(self._grid_rect_img)
if rect is None or rect.width() < 1 or rect.height() < 1:
return
dragging = self._drag_start is not None and self._drag_cur is not None
# Completed-result heatmap (blitted, nearest-neighbour, when not dragging).
if self._heatmap is not None and not dragging:
painter.save()
painter.setRenderHint(QPainter.SmoothPixmapTransform, False)
painter.setOpacity(self._heatmap_alpha / 255.0)
painter.drawImage(rect, self._heatmap)
painter.restore()
painter.setBrush(Qt.NoBrush)
painter.setPen(QPen(QColor(self._p.accent), 2))
painter.drawRect(rect)
# Cell gridlines (only while drawing / before a heatmap exists).
if self._heatmap is None or dragging:
nx = self._grid_nx if self._grid_nx > 0 else 0
ny = self._grid_ny if self._grid_ny > 0 else 0
cell = QColor(self._p.accent)
cell.setAlpha(90)
painter.setPen(QPen(cell, 1))
if nx > 1:
for i in range(1, nx):
gx = rect.left() + rect.width() * i / nx
painter.drawLine(int(gx), rect.top(), int(gx), rect.bottom())
if ny > 1:
for j in range(1, ny):
gy = rect.top() + rect.height() * j / ny
painter.drawLine(rect.left(), int(gy), rect.right(), int(gy))