Same exception as the motion watch: busy in Sample alignment is the alignment itself, and the sample motion is watched in these very views - the curtain hid exactly what the user needed to see. Gated at both style call sites (sample camera and the Axis views); the session-driven 'In viewing mode' badge is unaffected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1457 lines
54 KiB
Python
1457 lines
54 KiB
Python
import math
|
|
import time
|
|
from enum import Enum
|
|
from typing import ClassVar
|
|
|
|
from aarecommon.config.logger import setup_logger
|
|
from aarecommon.math.coordinate import Coordinate, SmargonCoordinate
|
|
from aarecommon.math.sample_geometry import SampleGeometryModel
|
|
from aarecommon.models.models import (
|
|
AutofocusSettings,
|
|
BeamlineStateEnum,
|
|
DAQStatusModel,
|
|
SampleCameraSettings,
|
|
SessionsStateEnum,
|
|
)
|
|
from PySide6.QtCore import QPoint, QPointF, QRect, QRectF, Qt, QTimer, Signal, Slot
|
|
from PySide6.QtGui import (
|
|
QBrush,
|
|
QColor,
|
|
QCursor,
|
|
QFont,
|
|
QFontMetrics,
|
|
QLinearGradient,
|
|
QPainter,
|
|
QPen,
|
|
QPixmap,
|
|
QPolygonF,
|
|
QTransform,
|
|
QWheelEvent,
|
|
)
|
|
from PySide6.QtWidgets import (
|
|
QFileDialog,
|
|
QFrame,
|
|
QGraphicsPixmapItem,
|
|
QGraphicsScene,
|
|
QGraphicsView,
|
|
QMenu,
|
|
QToolTip,
|
|
)
|
|
|
|
from aare.gui.constants import LOGGER_NAME
|
|
from aare.gui.models.bookmark import SmargonBookmarkList
|
|
from aare.gui.scan_logic.raster_grid_manager import RasterGridManager
|
|
from aare.gui.styles import (
|
|
BEAM_BUSY,
|
|
BEAM_IDLE,
|
|
BEAM_MARKING,
|
|
BEAM_OPEN,
|
|
CLASS_COLORS,
|
|
LEGEND_BG,
|
|
LEGEND_TEXT,
|
|
MARK_BADGE_BG,
|
|
MARK_TOOLTIP_GOLD,
|
|
MARK_TOOLTIP_ORANGE,
|
|
MARK_TOOLTIP_RED,
|
|
MARKER_GREEN,
|
|
PATH_END,
|
|
PATH_START,
|
|
SHADOW,
|
|
TARGET_COLORS,
|
|
THEME_SUNSET,
|
|
TOOLTIP_TEXT,
|
|
WHITE,
|
|
qcolor,
|
|
)
|
|
from aare.gui.widgets.busy_overlay import (
|
|
BusyOverlayStyle,
|
|
build_busy_overlay_style,
|
|
draw_busy_badge,
|
|
draw_wave_text,
|
|
)
|
|
|
|
logger = setup_logger(LOGGER_NAME)
|
|
|
|
|
|
class SampleCameraImageState(Enum):
|
|
IDLE = 0
|
|
DRAWING_RASTER_GRID = 1
|
|
MOVING_RASTER_GRID = 2
|
|
RESIZE_RASTER_GRID = 3
|
|
BEAM_MARKING = 4
|
|
|
|
|
|
class SampleCameraImageLabel(QGraphicsView):
|
|
smargon = Signal(SmargonCoordinate)
|
|
session_badge_clicked = Signal()
|
|
|
|
evaluate_grid = Signal()
|
|
clear_grid = Signal()
|
|
clear_evaluated_grids = Signal()
|
|
load_image = Signal(QPointF)
|
|
|
|
zoom_change = Signal(int)
|
|
set_omega = Signal(float)
|
|
set_helical_start = Signal(SmargonCoordinate)
|
|
set_helical_end = Signal(SmargonCoordinate)
|
|
update_beam_mark = Signal(float, float)
|
|
autofocus = Signal(AutofocusSettings)
|
|
samcam_updated = Signal(SampleCameraSettings)
|
|
|
|
switch_raster_grid = Signal()
|
|
# "More…" link in the help overlay; main window opens the full F1 dialog.
|
|
open_full_help = Signal()
|
|
|
|
def __init__(
|
|
self,
|
|
geom: SampleGeometryModel,
|
|
raster: RasterGridManager,
|
|
default_image: str | None,
|
|
parent=None,
|
|
):
|
|
super().__init__(parent)
|
|
|
|
self._shutter = None
|
|
self._raster_mgr = raster
|
|
self._state = SampleCameraImageState.IDLE
|
|
self._session_state: SessionsStateEnum | None = None
|
|
self._sam_cam = SampleCameraSettings(exposure=0.1, gain=100.0)
|
|
self._is_daq_busy = False
|
|
self._camera_available = True
|
|
self._camera_error_message: str | None = None
|
|
# Baton gate: watching allowed, operating not (main_window drives it).
|
|
self._operations_allowed = True
|
|
self._session_badge_rect: QRect | None = None # viewport coords
|
|
self._session_badge_hovered = False
|
|
# Hover polarity for the badge: light themes darken, Sunset brightens.
|
|
# Set via set_theme from MainWindow._apply_theme.
|
|
self._dark_theme = False
|
|
self._last_grid_update_ts = 0.0
|
|
self._grid_update_min_interval_s = 1.0 / 25.0
|
|
self._tell_state = None
|
|
self._busy_overlay_style: BusyOverlayStyle | None = None
|
|
|
|
self._geom = geom
|
|
self._bookmarks: SmargonBookmarkList = SmargonBookmarkList()
|
|
# Fit-to-view from the first frame; the right-click "Scale to fit"
|
|
# toggle can still switch back to 1:1.
|
|
self._autoscale = True
|
|
self._show_coords = False
|
|
self._helical_start = SmargonCoordinate()
|
|
self._helical_end = SmargonCoordinate()
|
|
self._raster_alpha = 127
|
|
self._bounding_box = None
|
|
self._show_detections = True
|
|
self._show_detection_polygons = True
|
|
|
|
self._show_target_point = True
|
|
self._show_target_coordinates = True
|
|
self._show_overlay_legend = False
|
|
self._compact_overlay_legend = False
|
|
# "?" badge is a mouse-controls cheatsheet, decoupled from the legend —
|
|
# legend visibility is already handled by the panel checkboxes.
|
|
self._help_expanded = False
|
|
self._help_hit_rect: QRectF | None = None # viewport coords, set on paint
|
|
self._help_more_rect: QRectF | None = None # "More…" link inside the overlay
|
|
self._target_point = None
|
|
self._target_shape = None
|
|
self._target_color_name = "Cyan"
|
|
self._last_target_update_ts = 0.0
|
|
self._target_min_update_interval_s = 0.03
|
|
self._target_smoothing_alpha = 0.45
|
|
self._smoothed_target_point: tuple[float, float] | None = None
|
|
|
|
self.start_point = None # Starting point of the rectangle
|
|
self.end_point = None # Ending point of the rectangle
|
|
|
|
self.scene = QGraphicsScene(self)
|
|
self.setScene(self.scene)
|
|
|
|
self.setMouseTracking(True)
|
|
# QGraphicsView delivers mouse moves through its viewport, so hover
|
|
# events (no button pressed) only fire if the viewport tracks the mouse.
|
|
# Without this the "Show coordinates" hover tooltip never appears.
|
|
self.viewport().setMouseTracking(True)
|
|
self._update_camera_interaction_feedback()
|
|
|
|
if default_image is not None:
|
|
pixmap = QPixmap(default_image)
|
|
self.pixmap_item = QGraphicsPixmapItem(pixmap)
|
|
self.scene.addItem(self.pixmap_item)
|
|
else:
|
|
pixmap = QPixmap(2000, 2000)
|
|
pixmap.fill(Qt.GlobalColor.white)
|
|
self.pixmap_item = QGraphicsPixmapItem(pixmap)
|
|
self.scene.addItem(self.pixmap_item)
|
|
|
|
self.setFrameShape(QFrame.Shape.NoFrame)
|
|
self.setRenderHints(QPainter.RenderHint.TextAntialiasing)
|
|
|
|
# Create a timer for throttling wheel events
|
|
self.wheel_event_timer = QTimer()
|
|
self.wheel_event_timer.setSingleShot(True)
|
|
self.wheel_event_threshold = 500 # Minimum delay between wheel events in ms
|
|
self.last_wheel_event = 0 # Timestamp of the last processed wheel event
|
|
|
|
self.click_timer = QTimer() # Timer to detect click vs hold
|
|
self.click_timer.setSingleShot(True) # The timer runs only once
|
|
self.left_click_hold_threshold = 200
|
|
self.right_click_hold_threshold = 200
|
|
|
|
self.raster_timer = QTimer()
|
|
self.raster_timer.setSingleShot(True)
|
|
self.raster_timer_interval = 400
|
|
self.raster_timer.timeout.connect(self._on_raster_timer_timeout)
|
|
self._pending_load_pos = None
|
|
|
|
self._detections = [] # list of dicts from publisher
|
|
self._det_shape = None # shape from payload [h,w] so we can scale
|
|
|
|
def _update_camera_interaction_feedback(self) -> None:
|
|
if self._camera_available:
|
|
self.viewport().setCursor(Qt.CursorShape.ArrowCursor)
|
|
self.setToolTip("")
|
|
else:
|
|
self.viewport().setCursor(Qt.CursorShape.ForbiddenCursor)
|
|
self.setToolTip("Sample camera feed unavailable")
|
|
|
|
def _show_camera_unavailable_tooltip(
|
|
self, event, action: str = "Sample camera interaction"
|
|
) -> None:
|
|
QToolTip.showText(
|
|
self.mapToGlobal(event.pos()),
|
|
f"{action} disabled: sample camera feed unavailable",
|
|
self,
|
|
)
|
|
|
|
def _camera_interaction_enabled(self) -> bool:
|
|
# Watching is free; clicking (targets, raster, smargon moves) needs
|
|
# the camera AND the session baton.
|
|
return self._camera_available and self._operations_allowed
|
|
|
|
@Slot(bool)
|
|
def set_operations_enabled(self, enabled: bool):
|
|
self._operations_allowed = enabled
|
|
self.update()
|
|
|
|
@Slot(bool)
|
|
def set_camera_available(self, available: bool):
|
|
self._camera_available = available
|
|
if available:
|
|
self._camera_error_message = None
|
|
self._update_camera_interaction_feedback()
|
|
self.update()
|
|
|
|
@Slot(str)
|
|
def set_camera_error_message(self, message: str):
|
|
# Thread errors arrive as "...unavailable: X" — reword to the
|
|
# "...unavailable because X" phrasing the overlay shows.
|
|
self._camera_error_message = message.replace(": ", " because ", 1)
|
|
self.update()
|
|
|
|
@Slot(dict)
|
|
def update_detections(self, payload: dict):
|
|
try:
|
|
self._det_shape = payload.get("shape", None)
|
|
self._detections = payload.get("boxes", []) or []
|
|
except Exception as e:
|
|
logger.debug(f"Exception in update_detections: {e}", exc_info=True)
|
|
self._detections = []
|
|
self.update()
|
|
|
|
@Slot(dict)
|
|
def update_target_point(self, payload: dict):
|
|
now = time.monotonic()
|
|
if now - self._last_target_update_ts < self._target_min_update_interval_s:
|
|
return
|
|
|
|
try:
|
|
self._target_shape = payload.get("shape", self._det_shape)
|
|
raw_target = payload.get("target_point")
|
|
|
|
target_xy = self._coerce_target_point(raw_target)
|
|
if target_xy is None:
|
|
return
|
|
|
|
self._target_point = raw_target
|
|
if self._smoothed_target_point is None:
|
|
self._smoothed_target_point = target_xy
|
|
else:
|
|
px, py = self._smoothed_target_point
|
|
nx, ny = target_xy
|
|
alpha = self._target_smoothing_alpha
|
|
self._smoothed_target_point = (
|
|
(1.0 - alpha) * px + alpha * nx,
|
|
(1.0 - alpha) * py + alpha * ny,
|
|
)
|
|
self._last_target_update_ts = now
|
|
except Exception as e:
|
|
logger.debug(f"Exception in update_target_point: {e}", exc_info=True)
|
|
self._target_point = None
|
|
self._smoothed_target_point = None
|
|
self.update()
|
|
|
|
def _busy_overlay_text(self) -> str:
|
|
tell_state = self._tell_state
|
|
if tell_state is None:
|
|
return "BEAMLINE BUSY"
|
|
|
|
activity_value = str(getattr(tell_state.activity, "value", "") or "").lower()
|
|
if activity_value not in {"mounting", "unmounting", "drying", "cooling"}:
|
|
return "BEAMLINE BUSY"
|
|
|
|
activity_name = tell_state.activity.display_name()
|
|
|
|
return f"TELL {activity_name}".upper()
|
|
|
|
def _draw_status_text(
|
|
self, painter: QPainter, text: str, color, center_x: int, baseline_y: int, fm: QFontMetrics
|
|
):
|
|
# Solid colored text with a 1px shadow — survives any camera image
|
|
# behind it without a badge box.
|
|
x = center_x - fm.horizontalAdvance(text) // 2
|
|
painter.setPen(QPen(qcolor(SHADOW, 200)))
|
|
painter.drawText(QPoint(x + 1, baseline_y + 1), text)
|
|
painter.setPen(QPen(qcolor(color) if isinstance(color, str) else color))
|
|
painter.drawText(QPoint(x, baseline_y), text)
|
|
|
|
def _draw_busy_overlay(self, painter: QPainter):
|
|
if self._busy_overlay_style is None:
|
|
return
|
|
|
|
style = self._busy_overlay_style
|
|
|
|
painter.save()
|
|
painter.resetTransform()
|
|
|
|
font = QFont()
|
|
font.setPointSize(24)
|
|
font.setBold(True)
|
|
font_metrics = QFontMetrics(font)
|
|
|
|
# Robot/busy WARNINGS are not clickable: no badge box, just solid
|
|
# text in the state's color. Only the session badges (a real click
|
|
# target) keep the button-like pill below.
|
|
if self._session_state not in (
|
|
SessionsStateEnum.Vacant,
|
|
SessionsStateEnum.OwnedByElse,
|
|
SessionsStateEnum.PendingYouToElse,
|
|
):
|
|
self._session_badge_rect = None
|
|
painter.setFont(font)
|
|
baseline = int(self.viewport().height() * 0.68) + font_metrics.ascent() // 2
|
|
if style.animate:
|
|
x = self.viewport().width() // 2 - font_metrics.horizontalAdvance(style.text) // 2
|
|
draw_wave_text(
|
|
painter,
|
|
x,
|
|
baseline,
|
|
style.text,
|
|
font_metrics,
|
|
qcolor(style.badge_bg),
|
|
shadow=qcolor(SHADOW, 200),
|
|
)
|
|
else:
|
|
self._draw_status_text(
|
|
painter,
|
|
style.text,
|
|
style.badge_bg,
|
|
self.viewport().width() // 2,
|
|
baseline,
|
|
font_metrics,
|
|
)
|
|
painter.restore()
|
|
return
|
|
|
|
# SESSION VACANT / GUEST MODE badges double as the click target for the
|
|
# grab/request menu, same as the _draw_session_overlay badge they hide.
|
|
session_badge = self._session_state in (
|
|
SessionsStateEnum.Vacant,
|
|
SessionsStateEnum.OwnedByElse,
|
|
SessionsStateEnum.PendingYouToElse,
|
|
)
|
|
|
|
fill = QColor(style.overlay_fill)
|
|
if session_badge and self._session_badge_hovered:
|
|
# Hover: darker in the light themes, brighter in Sunset.
|
|
fill = fill.lighter(125) if self._dark_theme else fill.darker(115)
|
|
|
|
bg_rect = draw_busy_badge(
|
|
painter, self.viewport().width(), self.viewport().height(), style, fill=fill
|
|
)
|
|
self._session_badge_rect = bg_rect if session_badge else None
|
|
|
|
painter.restore()
|
|
|
|
def _draw_session_overlay(self, painter: QPainter):
|
|
if self._busy_overlay_style is not None:
|
|
# Busy overlay drew (and owns) the session badge rect — don't clobber.
|
|
return
|
|
self._session_badge_rect = None
|
|
|
|
if self._session_state in (
|
|
SessionsStateEnum.OwnedByYou,
|
|
SessionsStateEnum.PendingElseToYou,
|
|
):
|
|
return
|
|
|
|
painter.save()
|
|
painter.resetTransform()
|
|
|
|
font = QFont()
|
|
font.setPointSize(24)
|
|
font.setBold(True)
|
|
painter.setFont(font)
|
|
|
|
if self._session_state == SessionsStateEnum.Vacant:
|
|
bg_color = qcolor(MARK_TOOLTIP_GOLD, 180)
|
|
text = "Session Vacant"
|
|
elif self._session_state == SessionsStateEnum.PendingYouToElse:
|
|
bg_color = qcolor(MARK_TOOLTIP_ORANGE, 150)
|
|
text = "Baton Requested..."
|
|
else:
|
|
bg_color = qcolor(MARK_TOOLTIP_RED, 150)
|
|
text = "Guest Mode"
|
|
|
|
fm = QFontMetrics(font)
|
|
text_rect = fm.boundingRect(text)
|
|
padding = 16
|
|
|
|
vw = self.viewport().width()
|
|
vh = self.viewport().height()
|
|
|
|
bg_w = text_rect.width() + 2 * padding
|
|
bg_h = text_rect.height() + 2 * padding
|
|
position_x = int((vw - bg_w) / 2)
|
|
position_y = int((vh - bg_h) / 2)
|
|
|
|
bg_rect = QRect(position_x, position_y, bg_w, bg_h)
|
|
# Clicking the badge opens the session (grab/request) menu.
|
|
self._session_badge_rect = bg_rect
|
|
|
|
if self._session_badge_hovered:
|
|
# Same hover polarity as the busy-overlay badge.
|
|
bg_color = bg_color.lighter(125) if self._dark_theme else bg_color.darker(115)
|
|
|
|
painter.setPen(QPen(qcolor(WHITE, 220)))
|
|
painter.setBrush(bg_color)
|
|
painter.drawRoundedRect(bg_rect, 10, 10)
|
|
|
|
painter.setPen(QPen(qcolor(WHITE)))
|
|
painter.drawText(QPoint(position_x + padding, position_y + padding + fm.ascent()), text)
|
|
|
|
painter.restore()
|
|
|
|
def _draw_camera_unavailable_overlay(self, painter: QPainter):
|
|
if self._camera_available:
|
|
return
|
|
|
|
painter.save()
|
|
painter.resetTransform()
|
|
|
|
font = QFont()
|
|
font.setPointSize(22)
|
|
font.setBold(True)
|
|
painter.setFont(font)
|
|
fm = QFontMetrics(font)
|
|
|
|
# Bottom-center, no badge box — solid colored text (the pill read
|
|
# as a button). The camera thread's reason is appended upstream as
|
|
# "... because <reason>" when it is known.
|
|
margin = 18
|
|
text = fm.elidedText(
|
|
self._camera_error_message or "Sample camera feed unavailable",
|
|
Qt.TextElideMode.ElideRight,
|
|
self.viewport().width() - 2 * margin,
|
|
)
|
|
baseline = self.viewport().height() - margin - fm.descent()
|
|
self._draw_status_text(
|
|
painter, text, MARK_BADGE_BG, self.viewport().width() // 2, baseline, fm
|
|
)
|
|
|
|
painter.restore()
|
|
|
|
def drawForeground(self, painter, rect):
|
|
self._draw_ml_bounding_box(painter)
|
|
self._draw_beam_center(painter)
|
|
self._raster_mgr.draw_grid(painter, self._raster_alpha)
|
|
self._draw_helical(painter)
|
|
self._draw_busy_overlay(painter)
|
|
self._draw_session_overlay(painter)
|
|
self._draw_camera_unavailable_overlay(painter)
|
|
self._draw_detections(painter, rect)
|
|
self._draw_target_point(painter)
|
|
self._draw_overlay_legend(painter)
|
|
self._draw_help_overlay(painter)
|
|
|
|
def resizeEvent(self, event):
|
|
super().resizeEvent(event)
|
|
self._scaling()
|
|
|
|
def mousePressEvent(self, event):
|
|
# "More…" link before the box toggle: its rect lies inside the help box,
|
|
# so the toggle below would swallow the click otherwise.
|
|
if (
|
|
event.button() == Qt.MouseButton.LeftButton
|
|
and self._help_more_rect is not None
|
|
and self._help_more_rect.contains(QPointF(self.viewport().mapFrom(self, event.pos())))
|
|
):
|
|
self._help_expanded = False
|
|
self.update()
|
|
self.open_full_help.emit()
|
|
event.accept()
|
|
return
|
|
|
|
# Help badge first: pure UI affordance, must work even when camera
|
|
# interaction is disabled (session overlay etc.).
|
|
if (
|
|
event.button() == Qt.MouseButton.LeftButton
|
|
and self._help_hit_rect is not None
|
|
and self._help_hit_rect.contains(QPointF(self.viewport().mapFrom(self, event.pos())))
|
|
):
|
|
self._help_expanded = not self._help_expanded
|
|
self.update()
|
|
event.accept()
|
|
return
|
|
|
|
# Session badge: the way IN when everything else is gated — must fire
|
|
# before the interaction-enabled check below.
|
|
if (
|
|
event.button() == Qt.MouseButton.LeftButton
|
|
and self._session_badge_rect is not None
|
|
and self._session_badge_rect.contains(self.viewport().mapFrom(self, event.pos()))
|
|
):
|
|
self.session_badge_clicked.emit()
|
|
event.accept()
|
|
return
|
|
|
|
if not self._camera_interaction_enabled():
|
|
if event.button() in (Qt.MouseButton.LeftButton, Qt.MouseButton.RightButton):
|
|
self._show_camera_unavailable_tooltip(event)
|
|
event.accept()
|
|
return
|
|
|
|
self.start_point = self.mapToScene(event.pos())
|
|
ctrl_override_move = bool(event.modifiers() & Qt.KeyboardModifier.ControlModifier)
|
|
|
|
match self._state:
|
|
case SampleCameraImageState.BEAM_MARKING:
|
|
self.click_timer.start(self.right_click_hold_threshold)
|
|
case SampleCameraImageState.IDLE:
|
|
self.click_timer.start(self.right_click_hold_threshold)
|
|
if event.button() == Qt.MouseButton.RightButton:
|
|
if self._raster_mgr.is_part_of_active_grid(self.start_point):
|
|
self._state = SampleCameraImageState.RESIZE_RASTER_GRID
|
|
else:
|
|
self._state = SampleCameraImageState.DRAWING_RASTER_GRID
|
|
elif event.button() == Qt.MouseButton.LeftButton and (
|
|
self._raster_mgr.is_part_of_active_grid(self.start_point)
|
|
and not ctrl_override_move
|
|
):
|
|
self._state = SampleCameraImageState.MOVING_RASTER_GRID
|
|
|
|
def _update_grid(self):
|
|
now = time.monotonic()
|
|
if (now - self._last_grid_update_ts) < self._grid_update_min_interval_s:
|
|
return
|
|
self._last_grid_update_ts = now
|
|
|
|
match self._state:
|
|
case SampleCameraImageState.DRAWING_RASTER_GRID:
|
|
self.switch_raster_grid.emit()
|
|
self._raster_mgr.update_active_grid(self.start_point, self.end_point)
|
|
case SampleCameraImageState.MOVING_RASTER_GRID:
|
|
self.switch_raster_grid.emit()
|
|
self._raster_mgr.move_active_grid(self.end_point - self.start_point)
|
|
self.start_point = self.end_point
|
|
case SampleCameraImageState.RESIZE_RASTER_GRID:
|
|
self.switch_raster_grid.emit()
|
|
self._raster_mgr.resize_active_grid(self.end_point)
|
|
|
|
@Slot(str)
|
|
def set_theme(self, theme: str):
|
|
self._dark_theme = theme == THEME_SUNSET
|
|
self.update()
|
|
|
|
def leaveEvent(self, event):
|
|
if self._session_badge_hovered:
|
|
self._session_badge_hovered = False
|
|
self.update()
|
|
super().leaveEvent(event)
|
|
|
|
def mouseMoveEvent(self, event):
|
|
# Badge hover feedback must run BEFORE the interaction gate: the
|
|
# badge is visible precisely when interaction is disabled.
|
|
hovered = self._session_badge_rect is not None and self._session_badge_rect.contains(
|
|
self.viewport().mapFrom(self, event.pos())
|
|
)
|
|
if hovered != self._session_badge_hovered:
|
|
self._session_badge_hovered = hovered
|
|
self.update()
|
|
|
|
if not self._camera_interaction_enabled():
|
|
return
|
|
|
|
mouse_pos = self.mapToScene(event.pos())
|
|
|
|
# Hover tooltips (completed-grid info / pixel coordinates) should work in
|
|
# any state, not only IDLE -- e.g. while beam-marking in beam location.
|
|
grid_txt = self._raster_mgr.is_part_of_completed_grid(mouse_pos)
|
|
if grid_txt:
|
|
QToolTip.showText(self.mapToGlobal(event.pos()), grid_txt, self)
|
|
elif self._show_coords:
|
|
QToolTip.showText(
|
|
self.mapToGlobal(event.pos()), f"{mouse_pos.x():.0f}, {mouse_pos.y():.0f} pxl", self
|
|
)
|
|
|
|
if self._state == SampleCameraImageState.IDLE and (
|
|
event.modifiers() & Qt.KeyboardModifier.ShiftModifier
|
|
):
|
|
if not self.raster_timer.isActive():
|
|
self.load_image.emit(mouse_pos)
|
|
self.raster_timer.start(self.raster_timer_interval)
|
|
else:
|
|
self._pending_load_pos = mouse_pos
|
|
|
|
self.end_point = mouse_pos
|
|
|
|
if not self.click_timer.isActive():
|
|
self.end_point = self.mapToScene(event.pos())
|
|
self._update_grid()
|
|
self.update()
|
|
|
|
def _on_raster_timer_timeout(self):
|
|
if self._pending_load_pos is not None:
|
|
self.load_image.emit(self._pending_load_pos)
|
|
self._pending_load_pos = None
|
|
|
|
def mouseReleaseEvent(self, event):
|
|
if not self._camera_interaction_enabled():
|
|
self.click_timer.stop()
|
|
if self._state != SampleCameraImageState.BEAM_MARKING:
|
|
self._state = SampleCameraImageState.IDLE
|
|
event.accept()
|
|
return
|
|
|
|
if event.button() == Qt.MouseButton.LeftButton:
|
|
if self.click_timer.isActive():
|
|
self.click_timer.stop()
|
|
self._left_single_click(event)
|
|
else:
|
|
self._update_grid()
|
|
self.update()
|
|
if event.button() == Qt.MouseButton.RightButton:
|
|
if self.click_timer.isActive():
|
|
self.click_timer.stop()
|
|
self._right_click_menu(event)
|
|
else:
|
|
self._update_grid()
|
|
self.update()
|
|
if self._state != SampleCameraImageState.BEAM_MARKING:
|
|
self._state = SampleCameraImageState.IDLE
|
|
|
|
def _right_click_menu(self, event):
|
|
if not self._camera_interaction_enabled():
|
|
self._show_camera_unavailable_tooltip(event)
|
|
return
|
|
|
|
menu = QMenu(self)
|
|
|
|
scale_action = menu.addAction("Scale to fit")
|
|
scale_action.setCheckable(True)
|
|
scale_action.setChecked(self._autoscale)
|
|
|
|
show_coord_action = menu.addAction("Show coordinates")
|
|
show_coord_action.setCheckable(True)
|
|
show_coord_action.setChecked(self._show_coords)
|
|
|
|
show_detections_action = menu.addAction("Show ML predictions")
|
|
show_detections_action.setCheckable(True)
|
|
show_detections_action.setChecked(self._show_detections)
|
|
|
|
grab_action = menu.addAction("Grab")
|
|
grab_with_overlay_action = menu.addAction("Grab with overlay")
|
|
|
|
autofocus_action = menu.addAction("Auto-focus")
|
|
|
|
beam_mark_action = menu.addAction("Mark beam center")
|
|
|
|
delete_action = None
|
|
evaluate_action = None
|
|
|
|
scene_pos = self.mapToScene(event.pos())
|
|
|
|
menu.addSection("Grid")
|
|
|
|
if self._raster_mgr.is_part_of_active_grid(scene_pos):
|
|
delete_action = menu.addAction("Delete grid")
|
|
evaluate_action = menu.addAction("Evaluate grid")
|
|
delete_completed_action = menu.addAction("Delete completed grids")
|
|
|
|
action = menu.exec_(self.mapToGlobal(event.pos()))
|
|
|
|
if action is None:
|
|
return
|
|
elif action == show_coord_action:
|
|
self._show_coords = not self._show_coords
|
|
logger.info(
|
|
f"Show-coordinates hover tooltip {'enabled' if self._show_coords else 'disabled'}"
|
|
)
|
|
self.update()
|
|
elif action == scale_action:
|
|
self._autoscale = not self._autoscale
|
|
self._scaling()
|
|
elif action == show_detections_action:
|
|
self._show_detections = not self._show_detections
|
|
self.update()
|
|
elif action == delete_action:
|
|
self.clear_grid.emit()
|
|
elif action == evaluate_action:
|
|
self.evaluate_grid.emit()
|
|
elif action == delete_completed_action:
|
|
self.clear_evaluated_grids.emit()
|
|
elif action == grab_action:
|
|
self._screenshot_with_dialog(overlay=False)
|
|
elif action == grab_with_overlay_action:
|
|
self._screenshot_with_dialog(overlay=True)
|
|
elif action == autofocus_action:
|
|
self.autofocus.emit(
|
|
AutofocusSettings(
|
|
center_x_pxl=None, center_y_pxl=None, radius_pxl=30, z_range_um=2000, z_steps=10
|
|
)
|
|
)
|
|
elif action == beam_mark_action:
|
|
c = self.mapToScene(event.pos())
|
|
self.update_beam_mark.emit(c.x(), c.y())
|
|
self.update()
|
|
|
|
def _screenshot_with_dialog(self, overlay: bool):
|
|
file_path, _ = QFileDialog.getSaveFileName(
|
|
self, "Save View As", "", "JPEG Files (*.jpg; *.jpeg);;All Files (*)"
|
|
)
|
|
|
|
if file_path:
|
|
self._screenshot(file_path=file_path, overlay=overlay)
|
|
|
|
def _screenshot(self, file_path: str, overlay: bool):
|
|
scene_rect = self.scene.sceneRect()
|
|
pixmap = QPixmap(scene_rect.size().toSize())
|
|
painter = QPainter(pixmap)
|
|
self.scene.render(painter)
|
|
if overlay:
|
|
self.drawForeground(painter, scene_rect)
|
|
painter.end()
|
|
pixmap.save(file_path, "JPEG")
|
|
|
|
def _scaling(self):
|
|
if not self._autoscale:
|
|
self.setTransform(QTransform())
|
|
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
|
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
|
return
|
|
|
|
if self.pixmap_item is None:
|
|
return
|
|
|
|
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
|
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
|
|
|
if (
|
|
self.pixmap_item.boundingRect().width() == 0
|
|
or self.pixmap_item.boundingRect().height() == 0
|
|
):
|
|
ratio = 1.0
|
|
else:
|
|
ratio_w = self.viewport().size().width() / self.pixmap_item.boundingRect().width()
|
|
ratio_h = self.viewport().size().height() / self.pixmap_item.boundingRect().height()
|
|
ratio = min(ratio_w, ratio_h)
|
|
|
|
ratio = max(ratio, 0.1)
|
|
|
|
if ratio >= 1.0:
|
|
# Don't enable scaling when gain in ratio is < 5% (to avoid back-and-forth)
|
|
self.setTransform(QTransform())
|
|
else:
|
|
matrix = QTransform()
|
|
matrix.scale(ratio, ratio)
|
|
self.setTransform(matrix)
|
|
|
|
@Slot(QPixmap)
|
|
def update_pixmap(self, pixmap: QPixmap):
|
|
size_changed = self.pixmap_item is None or self.pixmap_item.pixmap().size() != pixmap.size()
|
|
if self.pixmap_item is not None: # Ensure pixmap_item exists
|
|
self.pixmap_item.setPixmap(pixmap) # Update the pixmap in the item
|
|
else:
|
|
# If no pixmap item exists (rare case), create one
|
|
self.pixmap_item = QGraphicsPixmapItem(pixmap)
|
|
self.scene.addItem(self.pixmap_item)
|
|
if size_changed and self._autoscale:
|
|
# Refit when the frame size differs from what was fitted (real
|
|
# stream resolution vs the 2000x2000 startup placeholder, or a
|
|
# camera source switch) — resizeEvent only refits on view resize.
|
|
self._scaling()
|
|
self.viewport().update() # Request an update to redraw the view
|
|
|
|
@Slot(DAQStatusModel)
|
|
def update_daq_status(self, s: DAQStatusModel):
|
|
self._geom = s.geom
|
|
self._sam_cam = s.bl.sample_camera
|
|
self._bounding_box = s.box
|
|
self._tell_state = s.tell_state
|
|
|
|
new_session_state = s.session.session if hasattr(s, "session") else None
|
|
if new_session_state != self._session_state:
|
|
self._session_state = new_session_state
|
|
self.update()
|
|
|
|
# Same exception as the main window's motion watch: busy during
|
|
# Sample alignment IS the alignment, and the sample motion is watched
|
|
# right here — no BEAMLINE BUSY curtain over it. The "In viewing
|
|
# mode" badge is session-driven and unaffected by the gate.
|
|
new_busy_style = build_busy_overlay_style(
|
|
is_busy=bool(s.busy) and s.state != BeamlineStateEnum.SampleAlignment,
|
|
tell_state=s.tell_state,
|
|
session_state=self._session_state,
|
|
)
|
|
if new_busy_style != self._busy_overlay_style:
|
|
self._busy_overlay_style = new_busy_style
|
|
self.update()
|
|
|
|
if s.bl.shutter_open != self._shutter:
|
|
self._shutter = s.bl.shutter_open
|
|
|
|
if s.busy != self._is_daq_busy:
|
|
self._is_daq_busy = s.busy
|
|
self.update()
|
|
|
|
if s.state == BeamlineStateEnum.BeamLocation:
|
|
self._state = SampleCameraImageState.BEAM_MARKING
|
|
elif self._state == SampleCameraImageState.BEAM_MARKING:
|
|
self._state = SampleCameraImageState.IDLE
|
|
|
|
def _left_single_click(self, event):
|
|
if not self._camera_interaction_enabled():
|
|
logger.info("Ignoring click because sample camera feed is unavailable")
|
|
self._show_camera_unavailable_tooltip(event, "Point-on-click")
|
|
return
|
|
|
|
match self._state:
|
|
case SampleCameraImageState.BEAM_MARKING:
|
|
if (
|
|
event.button() == Qt.MouseButton.LeftButton
|
|
and event.modifiers() & Qt.KeyboardModifier.ShiftModifier
|
|
):
|
|
point = self.mapToScene(event.pos())
|
|
self.update_beam_mark.emit(point.x(), point.y())
|
|
case SampleCameraImageState.IDLE:
|
|
point = self.mapToScene(event.pos())
|
|
if not self.scene.sceneRect().contains(point):
|
|
logger.error("Point is outside scene bounds")
|
|
return
|
|
|
|
if event.modifiers() & Qt.KeyboardModifier.ShiftModifier:
|
|
sc = SmargonCoordinate(sh_mm=self._geom.smargon_z(point.y()))
|
|
self.smargon.emit(sc)
|
|
else:
|
|
sample_coord = self._geom.picture_to_sample(
|
|
Coordinate(x=point.x(), y=point.y())
|
|
)
|
|
smargon_coord = SmargonCoordinate(
|
|
sh_mm=self._geom.beamline_to_smargon(sample_coord)
|
|
)
|
|
self.smargon.emit(smargon_coord)
|
|
|
|
def _draw_detections(self, painter: QPainter, rect):
|
|
if not self._show_detections:
|
|
return
|
|
if not getattr(self, "_detections", None):
|
|
return
|
|
if self.pixmap_item is None:
|
|
return
|
|
pix = self.pixmap_item.pixmap()
|
|
if pix.isNull():
|
|
return
|
|
|
|
det_shape = self._det_shape
|
|
if det_shape is None:
|
|
return
|
|
img_h, img_w = det_shape[0], det_shape[1]
|
|
disp_w = pix.width()
|
|
disp_h = pix.height()
|
|
sx = disp_w / float(img_w)
|
|
sy = disp_h / float(img_h)
|
|
|
|
color_map = {label: qcolor(hex_str) for label, hex_str in CLASS_COLORS.items()}
|
|
|
|
for det in self._detections:
|
|
try:
|
|
x1 = det["x1"] * sx
|
|
y1 = det["y1"] * sy
|
|
x2 = det["x2"] * sx
|
|
y2 = det["y2"] * sy
|
|
poly = det.get("poly", None)
|
|
label = str(det.get("label", "")).lower()
|
|
conf = det.get("conf", 0.0)
|
|
except Exception as e:
|
|
logger.debug(f"Error in draw detection {det}: {e}", exc_info=True)
|
|
continue
|
|
|
|
color = color_map.get(label, qcolor(CLASS_COLORS["needle"]))
|
|
|
|
pen = QPen(color, 3)
|
|
painter.setPen(pen)
|
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
|
detection_rect = QRect(int(x1), int(y1), int(max(1, x2 - x1)), int(max(1, y2 - y1)))
|
|
painter.drawRect(detection_rect)
|
|
|
|
# Only draw polygon if the separate toggle is enabled
|
|
if self._show_detection_polygons and poly and len(poly) >= 3:
|
|
polygon = QPolygonF(
|
|
[QPointF(x1 + float(p[0]) * sx, y1 + float(p[1]) * sy) for p in poly]
|
|
)
|
|
painter.drawPolygon(polygon)
|
|
|
|
painter.setPen(QPen(qcolor(WHITE), 1))
|
|
painter.setBrush(color)
|
|
text_bg_rect = QRect(int(x1), int(y1 - 16), int(8 + 7 * len(label)), 16)
|
|
painter.drawRect(text_bg_rect)
|
|
painter.setPen(QPen(qcolor(WHITE)))
|
|
painter.drawText(QPoint(int(x1) + 2, int(y1 - 4)), f"{label} {conf:.2f}")
|
|
|
|
def _target_color(self) -> QColor:
|
|
color_map = {name: qcolor(hex_str) for name, hex_str in TARGET_COLORS.items()}
|
|
return color_map.get(self._target_color_name, qcolor(TARGET_COLORS["Cyan"]))
|
|
|
|
def _coerce_target_point(self, raw) -> tuple[float, float] | None:
|
|
try:
|
|
if isinstance(raw, dict):
|
|
return float(raw["x"]), float(raw["y"])
|
|
if isinstance(raw, (list, tuple)) and len(raw) >= 2:
|
|
return float(raw[0]), float(raw[1])
|
|
except Exception as e:
|
|
logger.debug(f"Error parsing target point {raw}: {e}", exc_info=True)
|
|
return None
|
|
|
|
def _draw_target_point(self, painter: QPainter):
|
|
if not self._show_target_point:
|
|
return
|
|
if self._smoothed_target_point is None:
|
|
return
|
|
if self.pixmap_item is None:
|
|
return
|
|
|
|
pix = self.pixmap_item.pixmap()
|
|
if pix.isNull():
|
|
return
|
|
|
|
shape = self._target_shape or self._det_shape
|
|
if shape is None or len(shape) < 2:
|
|
return
|
|
|
|
try:
|
|
img_h, img_w = int(shape[0]), int(shape[1])
|
|
tx, ty = self._smoothed_target_point
|
|
except Exception as e:
|
|
logger.debug(
|
|
f"Error using smoothed target point {self._smoothed_target_point}: {e}",
|
|
exc_info=True,
|
|
)
|
|
return
|
|
|
|
sx = pix.width() / float(img_w)
|
|
sy = pix.height() / float(img_h)
|
|
|
|
px = tx * sx
|
|
py = ty * sy
|
|
|
|
color = self._target_color()
|
|
glow = QColor(color)
|
|
glow.setAlpha(70)
|
|
|
|
painter.save()
|
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
|
|
|
painter.setPen(Qt.PenStyle.NoPen)
|
|
painter.setBrush(QBrush(glow))
|
|
painter.drawEllipse(QPointF(px, py), 20, 20)
|
|
|
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
|
painter.setPen(QPen(color, 3, Qt.PenStyle.SolidLine))
|
|
painter.drawEllipse(QPointF(px, py), 12, 12)
|
|
painter.setPen(QPen(qcolor(WHITE), 1, Qt.PenStyle.SolidLine))
|
|
painter.drawEllipse(QPointF(px, py), 5, 5)
|
|
|
|
painter.setPen(QPen(color, 2, Qt.PenStyle.SolidLine))
|
|
painter.drawLine(QPointF(px - 20, py), QPointF(px - 7, py))
|
|
painter.drawLine(QPointF(px + 7, py), QPointF(px + 20, py))
|
|
painter.drawLine(QPointF(px, py - 20), QPointF(px, py - 7))
|
|
painter.drawLine(QPointF(px, py + 7), QPointF(px, py + 20))
|
|
|
|
label_text = "Target"
|
|
if self._show_target_coordinates:
|
|
label_text = f"Target ({tx:.1f}, {ty:.1f})"
|
|
|
|
font = QFont()
|
|
font.setPointSize(10)
|
|
font.setBold(True)
|
|
painter.setFont(font)
|
|
|
|
fm = QFontMetrics(font)
|
|
text_rect = fm.boundingRect(label_text)
|
|
bubble_rect = QRectF(px + 16, py - 28, text_rect.width() + 16, text_rect.height() + 10)
|
|
|
|
painter.setPen(QPen(color, 2))
|
|
painter.setBrush(qcolor(LEGEND_BG, 190))
|
|
painter.drawRoundedRect(bubble_rect, 8, 8)
|
|
|
|
painter.setPen(QPen(qcolor(LEGEND_TEXT), 1))
|
|
painter.drawText(
|
|
QPointF(bubble_rect.left() + 8, bubble_rect.top() + 7 + fm.ascent()), label_text
|
|
)
|
|
|
|
painter.restore()
|
|
|
|
def _legend_should_show(self) -> bool:
|
|
return self._show_overlay_legend and (
|
|
self._show_target_point
|
|
or self._show_detections
|
|
or self._show_coords
|
|
or self._state == SampleCameraImageState.BEAM_MARKING
|
|
)
|
|
|
|
def _legend_lines(self) -> list[tuple[str, QColor | None]]:
|
|
lines: list[tuple[str, QColor | None]] = []
|
|
|
|
if self._compact_overlay_legend:
|
|
if self._show_target_point:
|
|
target_label = "Target"
|
|
if self._show_target_coordinates:
|
|
target_label += " + coords"
|
|
lines.append((target_label, self._target_color()))
|
|
|
|
if self._show_detections:
|
|
lines.extend(
|
|
[
|
|
("Pin", qcolor(CLASS_COLORS["pin"])),
|
|
("Loop", qcolor(CLASS_COLORS["loop_all"])),
|
|
("Face", qcolor(CLASS_COLORS["loop_face"])),
|
|
("Crystal", qcolor(CLASS_COLORS["crystal"])),
|
|
]
|
|
)
|
|
|
|
if self._show_coords:
|
|
lines.append(("Coords tooltip", qcolor(TOOLTIP_TEXT)))
|
|
|
|
lines.append(("Beam marker: shutter open", qcolor(BEAM_OPEN)))
|
|
lines.append(("Beam marker: idle", qcolor(BEAM_IDLE)))
|
|
lines.append(("Beam marker: busy", qcolor(BEAM_BUSY)))
|
|
return lines
|
|
|
|
if self._show_target_point:
|
|
target_label = f"Target marker ({self._target_color_name})"
|
|
if self._show_target_coordinates:
|
|
target_label += " + coords"
|
|
lines.append((target_label, self._target_color()))
|
|
|
|
if self._show_detections:
|
|
lines.extend(
|
|
[
|
|
("Prediction: Pin", qcolor(CLASS_COLORS["pin"])),
|
|
("Prediction: Loop_all", qcolor(CLASS_COLORS["loop_all"])),
|
|
("Prediction: Loop_face", qcolor(CLASS_COLORS["loop_face"])),
|
|
("Prediction: Crystal", qcolor(CLASS_COLORS["crystal"])),
|
|
("Prediction: Needle", qcolor(CLASS_COLORS["needle"])),
|
|
("Prediction: Ice", qcolor(CLASS_COLORS["ice"])),
|
|
]
|
|
)
|
|
|
|
if self._show_coords:
|
|
lines.append(("Cursor tooltip: pixel coordinates", qcolor(TOOLTIP_TEXT)))
|
|
|
|
lines.append(("Beam marker: shutter open", qcolor(BEAM_OPEN)))
|
|
lines.append(("Beam marker: idle", qcolor(BEAM_IDLE)))
|
|
lines.append(("Beam marker: busy", qcolor(BEAM_BUSY)))
|
|
lines.append(("Beam marker: marking mode", qcolor(BEAM_MARKING)))
|
|
|
|
return lines
|
|
|
|
# (header, [entries]) — kept concise on purpose; the full table lives in
|
|
# docs/cheatsheet.md.
|
|
_HELP_SECTIONS: ClassVar[list[tuple[str, list[str]]]] = [
|
|
(
|
|
"Sample camera",
|
|
[
|
|
"Left click — move sample here",
|
|
"Shift + Left click — Z-alignment move",
|
|
"Right click — context menu",
|
|
"Wheel — rotate omega 90° (Shift: 10°)",
|
|
"Ctrl / Alt + Wheel — exposure coarse / fine",
|
|
],
|
|
),
|
|
(
|
|
"Raster grid",
|
|
[
|
|
"Right drag — draw grid (on grid: resize)",
|
|
"Left drag — move grid",
|
|
"Ctrl + Left click — move sample under grid",
|
|
"Shift + move — inspect raster image at cursor",
|
|
],
|
|
),
|
|
]
|
|
|
|
def _draw_help_badge(self, painter: QPainter):
|
|
# ponytail: painted circle, not a real QWidget button — the overlay it
|
|
# toggles is painter-drawn too, and a widget would need layout juggling.
|
|
diameter = 22
|
|
# Center top, not a corner: corners drift oddly on scale-to-fit,
|
|
# the top middle stays put and stays out of the sample's way.
|
|
rect = QRectF((self.viewport().width() - diameter) / 2, 18, diameter, diameter)
|
|
|
|
painter.save()
|
|
painter.resetTransform()
|
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
|
painter.setPen(QPen(qcolor(LEGEND_TEXT, 60), 1))
|
|
painter.setBrush(qcolor(LEGEND_BG, 170))
|
|
painter.drawEllipse(rect)
|
|
|
|
font = QFont()
|
|
font.setPointSize(10)
|
|
font.setBold(True)
|
|
painter.setFont(font)
|
|
painter.setPen(QPen(qcolor(LEGEND_TEXT), 1))
|
|
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, "?")
|
|
painter.restore()
|
|
|
|
self._help_hit_rect = rect
|
|
|
|
def _draw_help_overlay(self, painter: QPainter):
|
|
self._help_hit_rect = None
|
|
self._help_more_rect = None
|
|
if not self._help_expanded:
|
|
self._draw_help_badge(painter)
|
|
return
|
|
|
|
painter.save()
|
|
painter.resetTransform()
|
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
|
|
|
font = QFont()
|
|
font.setPointSize(9)
|
|
header_font = QFont(font)
|
|
header_font.setBold(True)
|
|
fm = QFontMetrics(font)
|
|
header_fm = QFontMetrics(header_font)
|
|
|
|
line_height = fm.height() + 4
|
|
header_height = header_fm.height() + 6
|
|
padding = 10
|
|
|
|
max_width = 0
|
|
n_lines = 0
|
|
for header, entries in self._HELP_SECTIONS:
|
|
max_width = max(max_width, header_fm.horizontalAdvance(header))
|
|
n_lines += len(entries)
|
|
for entry in entries:
|
|
max_width = max(max_width, fm.horizontalAdvance(entry))
|
|
|
|
more_text = "More… (F1)"
|
|
max_width = max(max_width, fm.horizontalAdvance(more_text))
|
|
|
|
width = max_width + padding * 2
|
|
height = (
|
|
len(self._HELP_SECTIONS) * header_height
|
|
+ (n_lines + 1) * line_height # +1: trailing "More…" link line
|
|
+ padding * 2
|
|
)
|
|
|
|
# Top center, where the collapsed "?" badge sits — expanding must not
|
|
# send the mouse to the other end of the view to close it again.
|
|
bg_rect = QRectF((self.viewport().width() - width) / 2, 18, width, height)
|
|
self._help_hit_rect = bg_rect # click anywhere on the box to close
|
|
painter.setPen(QPen(qcolor(LEGEND_TEXT, 60), 1))
|
|
painter.setBrush(qcolor(LEGEND_BG, 190))
|
|
painter.drawRoundedRect(bg_rect, 8, 8)
|
|
|
|
y = bg_rect.top() + padding
|
|
for header, entries in self._HELP_SECTIONS:
|
|
painter.setFont(header_font)
|
|
painter.setPen(QPen(qcolor(LEGEND_TEXT), 1))
|
|
painter.drawText(QPointF(bg_rect.left() + padding, y + header_fm.ascent()), header)
|
|
y += header_height
|
|
painter.setFont(font)
|
|
painter.setPen(QPen(qcolor(LEGEND_TEXT), 1))
|
|
for entry in entries:
|
|
painter.drawText(QPointF(bg_rect.left() + padding, y + fm.ascent()), entry)
|
|
y += line_height
|
|
|
|
# Trailing link to the full F1 dialog; underlined so it reads as
|
|
# clickable, right-aligned like a dialog's action button.
|
|
link_font = QFont(font)
|
|
link_font.setUnderline(True)
|
|
painter.setFont(link_font)
|
|
painter.setPen(QPen(qcolor(LEGEND_TEXT), 1))
|
|
more_x = bg_rect.right() - padding - fm.horizontalAdvance(more_text)
|
|
painter.drawText(QPointF(more_x, y + fm.ascent()), more_text)
|
|
self._help_more_rect = QRectF(more_x, y, fm.horizontalAdvance(more_text), line_height)
|
|
|
|
painter.restore()
|
|
|
|
def _draw_overlay_legend(self, painter: QPainter):
|
|
if not self._legend_should_show() or self._help_expanded:
|
|
return
|
|
|
|
painter.save()
|
|
painter.resetTransform()
|
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
|
|
|
font = QFont()
|
|
font.setPointSize(8 if self._compact_overlay_legend else 9)
|
|
painter.setFont(font)
|
|
fm = QFontMetrics(font)
|
|
|
|
lines = self._legend_lines()
|
|
line_height = max(14 if self._compact_overlay_legend else 16, fm.height() + 2)
|
|
swatch_size = 8 if self._compact_overlay_legend else 10
|
|
text_padding = 6 if self._compact_overlay_legend else 8
|
|
section_padding = 8 if self._compact_overlay_legend else 10
|
|
left = 18
|
|
# Bottom-anchored above the "?" help badge (18px margin + 22px badge + gap).
|
|
top = self.viewport().height() - (len(lines) * line_height + 16) - 48
|
|
|
|
max_text_width = 0
|
|
for text, _color in lines:
|
|
max_text_width = max(max_text_width, fm.horizontalAdvance(text))
|
|
|
|
width = swatch_size + text_padding + max_text_width + section_padding * 2
|
|
height = len(lines) * line_height + 16
|
|
|
|
bg_rect = QRectF(left, max(18, top), width, height)
|
|
painter.setPen(QPen(qcolor(LEGEND_TEXT, 60), 1))
|
|
painter.setBrush(qcolor(LEGEND_BG, 170))
|
|
painter.drawRoundedRect(bg_rect, 8, 8)
|
|
|
|
y = bg_rect.top() + 12
|
|
for text, color in lines:
|
|
if color is not None:
|
|
painter.setPen(QPen(color, 2))
|
|
painter.setBrush(color)
|
|
painter.drawRect(
|
|
QRectF(bg_rect.left() + section_padding, y + 2, swatch_size, swatch_size)
|
|
)
|
|
else:
|
|
painter.setPen(Qt.PenStyle.NoPen)
|
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
|
|
|
painter.setPen(QPen(qcolor(LEGEND_TEXT), 1))
|
|
painter.drawText(
|
|
QPointF(
|
|
bg_rect.left() + section_padding + swatch_size + text_padding,
|
|
y + fm.ascent() + 1,
|
|
),
|
|
text,
|
|
)
|
|
y += line_height
|
|
|
|
painter.restore()
|
|
|
|
@Slot(bool)
|
|
def set_show_detections(self, show: bool):
|
|
self._show_detections = show
|
|
self.update()
|
|
|
|
@Slot(bool)
|
|
def set_show_detection_polygons(self, show: bool):
|
|
self._show_detection_polygons = show
|
|
self.update()
|
|
|
|
@Slot(bool)
|
|
def set_show_target_point(self, show: bool):
|
|
self._show_target_point = show
|
|
self.update()
|
|
|
|
@Slot(bool)
|
|
def set_show_target_coordinates(self, show: bool):
|
|
self._show_target_coordinates = show
|
|
self.update()
|
|
|
|
@Slot(bool)
|
|
def set_show_overlay_legend(self, show: bool):
|
|
self._show_overlay_legend = show
|
|
self.update()
|
|
|
|
@Slot(bool)
|
|
def set_compact_overlay_legend(self, compact: bool):
|
|
self._compact_overlay_legend = compact
|
|
self.update()
|
|
|
|
@Slot(str)
|
|
def set_target_color(self, color_name: str):
|
|
self._target_color_name = color_name
|
|
self.update()
|
|
|
|
def target_overlay_settings(self) -> dict:
|
|
return {
|
|
"show_target_point": self._show_target_point,
|
|
"show_target_coordinates": self._show_target_coordinates,
|
|
"show_detections": self._show_detections,
|
|
"show_detection_polygons": self._show_detection_polygons,
|
|
"show_overlay_legend": self._show_overlay_legend,
|
|
"compact_overlay_legend": self._compact_overlay_legend,
|
|
"target_color": self._target_color_name,
|
|
}
|
|
|
|
def _draw_ml_bounding_box(self, painter: QPainter):
|
|
if self._bounding_box is None:
|
|
return
|
|
|
|
painter.setPen(QPen(qcolor(MARKER_GREEN), 3, Qt.PenStyle.SolidLine))
|
|
|
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
|
painter.drawRect(
|
|
QRect(
|
|
int(self._bounding_box.bottom_x),
|
|
int(self._bounding_box.bottom_y),
|
|
int(self._bounding_box.top_x - self._bounding_box.bottom_x),
|
|
int(self._bounding_box.top_y - self._bounding_box.bottom_y),
|
|
)
|
|
)
|
|
|
|
def _draw_beam_center(self, painter: QPainter):
|
|
beam_size_pxl = self._geom.beam_size_pxl
|
|
|
|
if self._state == SampleCameraImageState.BEAM_MARKING:
|
|
painter.setPen(QPen(qcolor(BEAM_MARKING), 3, Qt.PenStyle.SolidLine))
|
|
|
|
elif self._shutter:
|
|
painter.setPen(QPen(qcolor(BEAM_OPEN), 3, Qt.PenStyle.SolidLine))
|
|
|
|
elif self._is_daq_busy is True:
|
|
# Use red color to indicate busy state
|
|
painter.setPen(QPen(qcolor(BEAM_BUSY), 3, Qt.PenStyle.SolidLine))
|
|
else:
|
|
painter.setPen(QPen(qcolor(BEAM_IDLE), 3, Qt.PenStyle.SolidLine))
|
|
|
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
|
painter.drawRect(
|
|
QRect(
|
|
int(self._geom.beam_location_pxl.x - beam_size_pxl.x / 2),
|
|
int(self._geom.beam_location_pxl.y - beam_size_pxl.y / 2),
|
|
int(beam_size_pxl.x),
|
|
int(beam_size_pxl.y),
|
|
)
|
|
)
|
|
|
|
@staticmethod
|
|
def _draw_circle(painter, coord: Coordinate, color: QColor, radius=10):
|
|
painter.setPen(QPen(Qt.PenStyle.NoPen))
|
|
painter.setBrush(color)
|
|
painter.drawEllipse(coord.x - radius, coord.y - radius, 2 * radius, 2 * radius)
|
|
|
|
@staticmethod
|
|
def _draw_arrow(painter: QPainter, start: Coordinate, end: Coordinate):
|
|
gradient = QLinearGradient()
|
|
gradient.setStart(QPointF(start.x, start.y)) # Start of the gradient (green)
|
|
gradient.setFinalStop(QPointF(end.x, end.y)) # End of the gradient (red)
|
|
|
|
gradient.setColorAt(0.0, qcolor(PATH_START)) # Start color
|
|
gradient.setColorAt(1.0, qcolor(PATH_END)) # End color
|
|
|
|
pen = QPen()
|
|
pen.setBrush(gradient) # Use gradient as the brush for the pen
|
|
pen.setWidth(4) # Set pen width
|
|
|
|
painter.setPen(pen)
|
|
|
|
painter.drawLine(QPointF(start.x, start.y), QPointF(end.x, end.y))
|
|
|
|
def _draw_helical(self, painter: QPainter):
|
|
if self._helical_start.sh_mm is not None:
|
|
start_pxl = self._geom.smargon_to_picture(self._helical_start.sh_mm)
|
|
self._draw_circle(painter, start_pxl, qcolor(PATH_START))
|
|
else:
|
|
start_pxl = None
|
|
|
|
if self._helical_end.sh_mm is not None:
|
|
end_pxl = self._geom.smargon_to_picture(self._helical_end.sh_mm)
|
|
self._draw_circle(painter, end_pxl, qcolor(PATH_END))
|
|
else:
|
|
end_pxl = None
|
|
|
|
if start_pxl is not None and end_pxl is not None:
|
|
self._draw_arrow(painter, start_pxl, end_pxl)
|
|
|
|
@Slot(SmargonCoordinate)
|
|
def show_helical_start(self, pos: SmargonCoordinate):
|
|
self._helical_start = pos
|
|
self.update()
|
|
|
|
@Slot(SmargonCoordinate)
|
|
def show_helical_end(self, pos: SmargonCoordinate):
|
|
self._helical_end = pos
|
|
self.update()
|
|
|
|
@Slot(int)
|
|
def raster_alpha(self, value: int):
|
|
if 0 <= value <= 255:
|
|
self._raster_alpha = value
|
|
|
|
def keyPressEvent(self, event):
|
|
mouse_global_pos = QCursor.pos()
|
|
mouse_view_pos = self.mapFromGlobal(mouse_global_pos)
|
|
mouse_scene_pos = self.mapToScene(mouse_view_pos)
|
|
|
|
if (
|
|
event.key() == Qt.Key.Key_Shift
|
|
and self._state == SampleCameraImageState.IDLE
|
|
and self._camera_interaction_enabled()
|
|
and not self.raster_timer.isActive()
|
|
):
|
|
self.load_image.emit(mouse_scene_pos)
|
|
self.raster_timer.start(self.raster_timer_interval)
|
|
|
|
super().keyPressEvent(event)
|
|
|
|
def wheelEvent(self, event: QWheelEvent):
|
|
if not self.wheel_event_timer.isActive():
|
|
# Qt's xcb/windows platform plugins swap wheel axes while Alt is held,
|
|
# so a vertical scroll lands in angleDelta().x() and y() is 0. Without
|
|
# this fallback, copysign(_, 0) is always positive and Alt+wheel
|
|
# exposure can only ever increase.
|
|
delta_y = event.angleDelta().y() or event.angleDelta().x()
|
|
if delta_y == 0:
|
|
return
|
|
match self._state:
|
|
case SampleCameraImageState.BEAM_MARKING:
|
|
if event.modifiers() & Qt.KeyboardModifier.AltModifier:
|
|
new_exp_time = self._sam_cam.exposure * (1.0 + math.copysign(0.1, delta_y))
|
|
new_settings = SampleCameraSettings(
|
|
exposure=new_exp_time, gain=self._sam_cam.gain
|
|
)
|
|
self.samcam_updated.emit(new_settings)
|
|
else:
|
|
new_exp_time = self._sam_cam.exposure * (1.0 + math.copysign(0.5, delta_y))
|
|
new_settings = SampleCameraSettings(
|
|
exposure=new_exp_time, gain=self._sam_cam.gain
|
|
)
|
|
self.samcam_updated.emit(new_settings)
|
|
case SampleCameraImageState.IDLE:
|
|
if event.modifiers() & Qt.KeyboardModifier.AltModifier:
|
|
new_exp_time = self._sam_cam.exposure * (1.0 + math.copysign(0.1, delta_y))
|
|
new_settings = SampleCameraSettings(
|
|
exposure=new_exp_time, gain=self._sam_cam.gain
|
|
)
|
|
self.samcam_updated.emit(new_settings)
|
|
elif event.modifiers() & Qt.KeyboardModifier.ControlModifier:
|
|
new_exp_time = self._sam_cam.exposure * (1.0 + math.copysign(0.5, delta_y))
|
|
new_settings = SampleCameraSettings(
|
|
exposure=new_exp_time, gain=self._sam_cam.gain
|
|
)
|
|
self.samcam_updated.emit(new_settings)
|
|
elif event.modifiers() & Qt.KeyboardModifier.ShiftModifier:
|
|
self.set_omega.emit(self._geom.omega_deg + math.copysign(10.0, delta_y))
|
|
else:
|
|
self.set_omega.emit(self._geom.omega_deg + math.copysign(90.0, delta_y))
|
|
|
|
# Start the timer to throttle further events
|
|
self.wheel_event_timer.start(self.wheel_event_threshold)
|