Files
AareDAQ/src/aare/gui/widgets/camera_image.py
T
David Perl 3b7fe21fcd
CI / lint (push) Skipped
CI / test (3.12) (push) Skipped
CI / test (3.13) (push) Skipped
CI / test-with-beamline-plugins (pxi_bec) (push) Skipped
CI / test-with-beamline-plugins (pxii_bec) (push) Skipped
CI / test-with-beamline-plugins (pxiii_bec) (push) Skipped
fix: improve log messages
2026-08-27 10:11:10 +02:00

1548 lines
58 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,
SCALE_BAR_GREY,
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
# Scene position under the cursor; drives the corner coords/scale HUD.
self._hover_pos: QPointF | None = None
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_hover_hud(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()
if self._hover_pos is not None:
self._hover_pos = None
self.update()
if self._help_expanded:
# Pointer left the widget with the cheatsheet open — fold it,
# same as wandering off the box below.
self._help_expanded = False
self.update()
super().leaveEvent(event)
def mouseMoveEvent(self, event):
# Hover HUD before the interaction gate: watching is free, so the
# coords/scale readout must work even without the session baton.
self._hover_pos = self.mapToScene(event.pos())
self.update()
# Expanded help folds when the pointer wanders off the box — click
# is not the only way back to the "?" badge. Before the gate: the
# badge is a pure UI affordance, live even in viewing mode.
if (
self._help_expanded
and self._help_hit_rect is not None
and not self._help_hit_rect.contains(
QPointF(self.viewport().mapFrom(self, event.pos()))
)
):
self._help_expanded = False
self.update()
# 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):
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()
@staticmethod
def _scale_bar(um_per_view_px: float) -> tuple[float, str]:
"""Nice 1-2-5 scale-bar length (µm) for the current zoom, with label."""
target_um = um_per_view_px * 120.0 # aim for a ~120 px wide bar
exponent = math.floor(math.log10(target_um))
base = target_um / 10.0**exponent
nice = 5.0 if base >= 5.0 else 2.0 if base >= 2.0 else 1.0
bar_um = nice * 10.0**exponent
label = f"{bar_um / 1000.0:g} mm" if bar_um >= 1000.0 else f"{bar_um:g} µm"
return bar_um, label
def _draw_hover_hud(self, painter: QPainter):
# Bottom-right HUD: grey scale bar over the hovered pixel coordinates.
if self._hover_pos is None or self.pixmap_item is None:
return
if not self.pixmap_item.sceneBoundingRect().contains(self._hover_pos):
return
painter.save()
painter.resetTransform()
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
font = QFont()
font.setPointSize(10)
font.setBold(True)
painter.setFont(font)
fm = QFontMetrics(font)
margin = 18
right = self.viewport().width() - margin
coords_text = f"{self._hover_pos.x():.0f}, {self._hover_pos.y():.0f} pxl"
coords_baseline = self.viewport().height() - margin - fm.descent()
grey = qcolor(SCALE_BAR_GREY)
def shadowed_text(x: float, baseline: float, text: str, color: QColor):
painter.setPen(QPen(qcolor(SHADOW, 200)))
painter.drawText(QPointF(x + 1, baseline + 1), text)
painter.setPen(QPen(color))
painter.drawText(QPointF(x, baseline), text)
shadowed_text(
right - fm.horizontalAdvance(coords_text), coords_baseline, coords_text, qcolor(WHITE)
)
# view-pixel scale: scene px -> viewport px via the current zoom.
zoom = self.transform().m11()
if zoom > 0:
um_per_view_px = self._geom.pixel_in_mm * 1000.0 / zoom
bar_um, label = self._scale_bar(um_per_view_px)
bar_px = bar_um / um_per_view_px
bar_y = coords_baseline - fm.ascent() - 12
painter.setPen(QPen(grey, 3))
painter.drawLine(QPointF(right - bar_px, bar_y), QPointF(right, bar_y))
painter.drawLine(QPointF(right - bar_px, bar_y - 4), QPointF(right - bar_px, bar_y + 4))
painter.drawLine(QPointF(right, bar_y - 4), QPointF(right, bar_y + 4))
shadowed_text(
right - (bar_px + fm.horizontalAdvance(label)) / 2, bar_y - 8, label, grey
)
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)