- 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>
557 lines
22 KiB
Python
557 lines
22 KiB
Python
"""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))
|