refactor: add crosshair for center and live mode button
CI for csaxs_bec / test (pull_request) Failing after 1m28s
CI for csaxs_bec / test (push) Failing after 1m31s

This commit is contained in:
2026-07-09 16:39:53 +02:00
parent f8c5a049e1
commit dff7e67ead
4 changed files with 302 additions and 31 deletions
@@ -81,8 +81,12 @@ ROI and mapping provenance:
| `raw_roi_x`, `raw_roi_y` | Raw ROI origin in image coordinates. |
| `raw_roi_width`, `raw_roi_height` | Raw ROI size in image coordinates. |
| `correction_scale_x`, `correction_scale_y` | Scale factors used to convert ROI coordinates to positions. |
| `correction_offset_x`, `correction_offset_y` | Offsets used after scaling. |
| `correction_offset_x`, `correction_offset_y` | Physical positions at the mapping reference point. |
| `correction_direction_x`, `correction_direction_y` | Direction signs, usually `1` or `-1`. |
| `correction_reference_x`, `correction_reference_y` | Image coordinates used as the zero/reference point for the correction. New rows use the image center when image data exists, otherwise the ROI center. |
The mapping reference is reset to the image center only when a first image becomes available
or the image shape changes. While no image data is available, it follows the ROI center.
Bookkeeping and compatibility fields:
@@ -15,6 +15,7 @@ from qtpy.QtWidgets import (
QGridLayout,
QGroupBox,
QLabel,
QLineEdit,
QPushButton,
)
@@ -238,6 +239,13 @@ class ROIMappingBox(QGroupBox):
self.offset_y = spinbox(value=0.0, single_step=0.1, parent=self)
self.direction_y = direction_combo(self)
self.mapping_lock_toggle = ToggleSwitch(self, checked=False)
self.reference_x = QLineEdit("0", self)
self.reference_y = QLineEdit("0", self)
for reference in [self.reference_x, self.reference_y]:
reference.setObjectName("saxs_reference_field")
reference.setReadOnly(True)
reference.setAlignment(Qt.AlignmentFlag.AlignRight)
reference.setToolTip("Image coordinate used as the correction offset reference.")
layout.addWidget(
field_label("Edit lock", self), 0, 2, alignment=Qt.AlignmentFlag.AlignRight
@@ -256,6 +264,11 @@ class ROIMappingBox(QGroupBox):
layout.addWidget(self.offset_y, 3, 2)
layout.addWidget(self.direction_y, 3, 3)
layout.addWidget(field_label("Reference", self), 4, 0)
layout.addWidget(self.reference_x, 4, 1)
layout.addWidget(self.reference_y, 4, 2)
layout.addWidget(field_label("x / y", self), 4, 3)
self.apply_corrections_button = QPushButton("Apply corrections", self)
self.apply_corrections_button.setIcon(
material_icon("published_with_changes", size=(16, 16))
@@ -265,7 +278,7 @@ class ROIMappingBox(QGroupBox):
self.apply_corrections_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.apply_corrections_button.setFlat(True)
self.apply_corrections_button.setProperty("saxsButtonRole", "toolbar")
layout.addWidget(self.apply_corrections_button, 4, 0, 1, 4)
layout.addWidget(self.apply_corrections_button, 5, 0, 1, 4)
self.apply_corrections_button.clicked.connect(self.apply_requested.emit)
self.mapping_lock_toggle.stateChanged.connect(self.set_locked)
@@ -279,8 +292,14 @@ class ROIMappingBox(QGroupBox):
scale_y=self.scale_y.value(),
offset_y=self.offset_y.value(),
direction_y=int(self.direction_y.currentData() or 1),
reference_x=float(self.reference_x.text() or 0.0),
reference_y=float(self.reference_y.text() or 0.0),
)
def set_reference_position(self, x: float, y: float) -> None:
self.reference_x.setText(f"{x:.6g}")
self.reference_y.setText(f"{y:.6g}")
def is_locked(self) -> bool:
return self.mapping_lock_toggle.isChecked()
@@ -232,6 +232,8 @@ class Correction:
scale_y: float
offset_y: float
direction_y: int
reference_x: float = 0.0
reference_y: float = 0.0
@dataclass(frozen=True)
@@ -397,6 +399,8 @@ class SAXSRow:
"correction_scale_y": self.correction.scale_y,
"correction_offset_y": self.correction.offset_y,
"correction_direction_y": self.correction.direction_y,
"correction_reference_x": self.correction.reference_x,
"correction_reference_y": self.correction.reference_y,
"scan_results": scan_results_json,
# Compatibility aliases for older consumers.
"step_size": geometry.fast_step,
@@ -453,11 +457,13 @@ class SAXSRow:
"raw_roi_width": "Raw ROI width in image coordinates.",
"raw_roi_height": "Raw ROI height in image coordinates.",
"correction_scale_x": "Scale factor applied from ROI X to physical X.",
"correction_offset_x": "Offset applied after ROI X scaling.",
"correction_offset_x": "Physical X position at correction_reference_x.",
"correction_direction_x": "Direction sign for X mapping, typically +1 or -1.",
"correction_scale_y": "Scale factor applied from ROI Y to physical Y.",
"correction_offset_y": "Offset applied after ROI Y scaling.",
"correction_offset_y": "Physical Y position at correction_reference_y.",
"correction_direction_y": "Direction sign for Y mapping, typically +1 or -1.",
"correction_reference_x": "Image coordinate that maps to correction_offset_x.",
"correction_reference_y": "Image coordinate that maps to correction_offset_y.",
"scan_results": "JSON string list of scan result objects with scan_number and success.",
"step_size": "Compatibility alias of fast_step.",
"step_size_x": "Compatibility alias of step size in X coordinates.",
@@ -484,14 +490,18 @@ def corrected_geometry(
def axis_limits(axis: Literal["x", "y"]) -> tuple[float, float]:
if axis == "x":
start = correction.offset_x + correction.direction_x * correction.scale_x * raw_roi.x
start = correction.offset_x + correction.direction_x * correction.scale_x * (
raw_roi.x - correction.reference_x
)
stop = correction.offset_x + correction.direction_x * correction.scale_x * (
raw_roi.x + raw_roi.width
raw_roi.x + raw_roi.width - correction.reference_x
)
return start, stop
start = correction.offset_y + correction.direction_y * correction.scale_y * raw_roi.y
start = correction.offset_y + correction.direction_y * correction.scale_y * (
raw_roi.y - correction.reference_y
)
stop = correction.offset_y + correction.direction_y * correction.scale_y * (
raw_roi.y + raw_roi.height
raw_roi.y + raw_roi.height - correction.reference_y
)
return start, stop
@@ -17,6 +17,7 @@ from bec_qthemes import material_icon
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import Colors, get_accent_colors, get_theme_name, rgba, theme_color
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox2D
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.roi.image_roi import RectangularROI
@@ -84,8 +85,6 @@ class SAXSWidget(BECWidget, QWidget):
"mark_scan_result",
]
submission_requested = Signal(object)
ROI_NAME = "SAXS ROI"
def __init__(self, parent=None, **kwargs):
@@ -101,6 +100,10 @@ class SAXSWidget(BECWidget, QWidget):
self._last_stepper_step = DEFAULT_STEP_SIZE
self._active_session: str | None = None
self._session_dirty = False
self._crosshair_pin_enabled = False
self._mapping_reference: tuple[float, float] | None = None
self._mapping_reference_image_shape: tuple[int, int] | None = None
self._crosshair_pin_position: tuple[float, float] | None = None
self._accent_colors = get_accent_colors()
self._roi_color = self._accent_colors.highlight or "#00bcd4"
@@ -108,6 +111,8 @@ class SAXSWidget(BECWidget, QWidget):
self.apply_theme()
self._connect_signals()
self._ensure_saxs_roi()
self._sync_mapping_reference_to_display()
self._refresh_crosshair_pin_if_enabled()
def _build_ui(self) -> None:
root = QVBoxLayout(self)
@@ -126,9 +131,7 @@ class SAXSWidget(BECWidget, QWidget):
top_layout.setSpacing(8)
self.image = Image(parent=top)
self.image.enable_full_colorbar = True
self.image.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.image.toolbar.show_bundles(["device_selection"])
self._customize_image_widget(self.image)
top_layout.addWidget(self.image, 1)
side_panel = QFrame(top)
@@ -214,6 +217,209 @@ class SAXSWidget(BECWidget, QWidget):
self.main_splitter.setStretchFactor(1, 2)
self._update_session_ui_state()
def _customize_image_widget(self, image: Image) -> None:
image.enable_full_colorbar = True
image.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
# Enable crosshair toggle in the image toolbar.
toggle_cross_action = MaterialIconAction(
icon_name="add",
checkable=True,
label_text="Show/Hide Crosshair",
text_position="under",
parent=image,
tooltip="Toggle crosshair visibility in the image",
)
toggle_cross_action.action.toggled.connect(self._toggle_crosshair_visibility)
toggle_cross_action.action.setChecked(True)
image.toolbar.components.add_safe("toggle_cross_action", toggle_cross_action)
image.toolbar.get_bundle("device_selection").add_action("toggle_cross_action")
# Enable Live mode for camera
toggle_cam = MaterialIconAction(
icon_name="videocam",
checkable=True,
label_text="Live Camera Mode",
text_position="under",
parent=image,
tooltip="Toggle camera live mode",
)
toggle_cam.action.toggled.connect(self._toggle_camera_live_mode)
toggle_cam.action.setChecked(True)
image.toolbar.components.add_safe("toggle_cam", toggle_cam)
image.toolbar.get_bundle("device_selection").add_action("toggle_cam")
image.toolbar.show_bundles(["device_selection"])
def _toggle_crosshair_visibility(self, enabled: bool) -> None:
self._crosshair_pin_enabled = bool(enabled)
if enabled:
self._refresh_crosshair_pin_if_enabled(force=True)
return
self._clear_reference_crosshair_pin()
# self.image.toggle_crosshair(False)
def _toggle_camera_live_mode(self, enabled: bool) -> None:
# If enabled, set the camera to live mode, if disabled, stop the live mode.
print(f"Toggling camera live mode to {'enabled' if enabled else 'disabled'}")
def _refresh_crosshair_pin_if_enabled(self, *_args, force: bool = False) -> None:
if self._crosshair_pin_enabled:
pin_position = self._mapping_reference_position()
if (
not force
and self._positions_match(self._crosshair_pin_position, pin_position)
and self._has_reference_crosshair_pin()
):
self._update_reference_crosshair_label(*pin_position)
return
self._place_reference_crosshair_pin(pin_position)
def _sync_mapping_reference_to_display(self) -> None:
shape = self._display_image_shape()
if shape is None:
if self._mapping_reference is None or self._mapping_reference_image_shape is not None:
self._set_mapping_reference_position(*self._roi_center_position(), image_shape=None)
return
self._update_mapping_reference_fields()
return
if self._mapping_reference is None or self._mapping_reference_image_shape != shape:
self._set_mapping_reference_position(
*self._image_center_position(shape), image_shape=shape
)
return
self._update_mapping_reference_fields()
def _on_image_updated(self, *_args) -> None:
self._sync_mapping_reference_to_display()
self._refresh_crosshair_pin_if_enabled()
def _on_roi_reference_changed(self, *_args) -> None:
shape = self._display_image_shape()
if shape is None:
self._set_mapping_reference_position(*self._roi_center_position(), image_shape=None)
elif self._mapping_reference is None:
self._set_mapping_reference_position(
*self._image_center_position(shape), image_shape=shape
)
self._refresh_crosshair_pin_if_enabled()
def _place_reference_crosshair_pin(
self, pin_position: tuple[float, float] | None = None
) -> None:
if getattr(self, "image", None) is None:
return
if self.image.crosshair is None:
self.image.toggle_crosshair(True)
crosshair = self.image.crosshair
if crosshair is None:
return
pin_x, pin_y = pin_position or self._mapping_reference_position()
image_item = self.image.main_image
item_name = image_item.objectName() or str(id(image_item))
snap_x = {item_name: pin_x - 0.5}
snap_y = {item_name: pin_y - 0.5}
crosshair.set_pin(pin_x, pin_y, snap_x, snap_y)
if crosshair.pinned_pos is None:
# ``set_pin`` needs at least one visible data item. During early widget
# construction there may be no image data yet, so draw the same pin
# position directly as a fallback.
crosshair._draw_pin(pin_x, pin_y)
crosshair.pinned_pos = (pin_x, pin_y)
crosshair.mouse_moved(manual_pos=(pin_x, pin_y))
self._crosshair_pin_position = (pin_x, pin_y)
self._update_reference_crosshair_label(pin_x, pin_y)
self.image.toggle_crosshair(False)
@staticmethod
def _positions_match(
first: tuple[float, float] | None, second: tuple[float, float] | None
) -> bool:
if first is None or second is None:
return False
return abs(first[0] - second[0]) <= 1e-9 and abs(first[1] - second[1]) <= 1e-9
def _has_reference_crosshair_pin(self) -> bool:
crosshair = getattr(self.image, "crosshair", None)
if crosshair is not None and crosshair.pinned_pos is not None:
return True
detached_pin = getattr(self.image, "_detached_pin", None)
return detached_pin is not None and detached_pin.get("pos") is not None
def _clear_reference_crosshair_pin(self) -> None:
crosshair = getattr(self.image, "crosshair", None)
if crosshair is not None:
crosshair.clear_pin()
if hasattr(self.image, "_discard_detached_pin"):
self.image._discard_detached_pin()
self._crosshair_pin_position = None
def _update_reference_crosshair_label(self, image_x: float, image_y: float) -> None:
crosshair = getattr(self.image, "crosshair", None)
pinned_label = crosshair.pinned_label if crosshair is not None else None
if pinned_label is None:
detached_pin = getattr(self.image, "_detached_pin", None)
if detached_pin is not None:
pinned_label = detached_pin.get("label")
if pinned_label is None:
return
samx, samy = self._map_image_point_to_position(image_x, image_y)
x_name = (
getattr(self, "device_box", None).x_positioner if hasattr(self, "device_box") else ""
)
y_name = (
getattr(self, "device_box", None).y_positioner if hasattr(self, "device_box") else ""
)
x_name = x_name or "samx"
y_name = y_name or "samy"
pinned_label.setText(f"pin\n{x_name}: {samx:.6g}\n{y_name}: {samy:.6g}")
def _display_image_shape(self) -> tuple[int, int] | None:
try:
data = self.image.main_image.image
except (AttributeError, KeyError):
return None
shape = getattr(data, "shape", None)
if data is None or shape is None or len(shape) < 2:
return None
if shape[0] <= 0 or shape[1] <= 0:
return None
return int(shape[0]), int(shape[1])
@staticmethod
def _image_center_position(shape: tuple[int, int]) -> tuple[float, float]:
return float(shape[0]) / 2.0, float(shape[1]) / 2.0
def _roi_center_position(self) -> tuple[float, float]:
roi = self._ensure_saxs_roi()
coords = roi.get_coordinates(typed=True)
return float(coords["center_x"]), float(coords["center_y"])
def _set_mapping_reference_position(
self, x: float, y: float, *, image_shape: tuple[int, int] | None
) -> None:
self._mapping_reference = (float(x), float(y))
self._mapping_reference_image_shape = image_shape
self._update_mapping_reference_fields()
def _update_mapping_reference_fields(self) -> None:
if self._mapping_reference is None or not hasattr(self, "mapping_box"):
return
self.mapping_box.set_reference_position(*self._mapping_reference)
def _mapping_reference_position(self) -> tuple[float, float]:
self._sync_mapping_reference_to_display()
return self._mapping_reference or (0.0, 0.0)
def _map_image_point_to_position(self, image_x: float, image_y: float) -> tuple[float, float]:
correction = self._current_correction()
samx = correction.offset_x + correction.direction_x * correction.scale_x * (
image_x - correction.reference_x
)
samy = correction.offset_y + correction.direction_y * correction.scale_y * (
image_y - correction.reference_y
)
return samx, samy
def _build_table_session_bar(self) -> QWidget:
bar = QFrame(self.table_group)
bar.setObjectName("saxs_session_bar")
@@ -268,12 +474,13 @@ class SAXSWidget(BECWidget, QWidget):
self.device_box.y_positioner_changed.connect(self.positioner_box.set_positioner_ver)
self.device_box.sastt_enabled_changed.connect(self._update_sastt_enabled)
self.mapping_box.apply_requested.connect(self._prompt_apply_corrections)
self.image.image_updated.connect(self._on_image_updated)
self._connect_mapping_reference_signals()
self.table_widget.edit_requested.connect(self._edit_row_by_index)
self.table_widget.clear_selected_requested.connect(self.delete_selected_rows)
self.table_widget.clear_all_requested.connect(self.clear_rows)
self.table_widget.save_requested.connect(self.save_session)
self.table_widget.upload_requested.connect(self.submit_selected)
self.table_widget.store_csv_requested.connect(self.store_rows_as_csv)
self.new_session_button.clicked.connect(self._new_session)
@@ -286,6 +493,21 @@ class SAXSWidget(BECWidget, QWidget):
self._update_sastt_enabled(self.device_box.is_sastt_enabled())
def _connect_mapping_reference_signals(self) -> None:
for spin_box in [
self.mapping_box.scale_x,
self.mapping_box.offset_x,
self.mapping_box.scale_y,
self.mapping_box.offset_y,
]:
spin_box.valueChanged.connect(self._refresh_crosshair_pin_if_enabled)
self.mapping_box.direction_x.currentIndexChanged.connect(
self._refresh_crosshair_pin_if_enabled
)
self.mapping_box.direction_y.currentIndexChanged.connect(
self._refresh_crosshair_pin_if_enabled
)
@SafeSlot(str)
@SafeSlot()
def apply_theme(self, _theme: str | None = None) -> None:
@@ -357,6 +579,13 @@ class SAXSWidget(BECWidget, QWidget):
"font-weight: 700;"
"font-size: 11px;"
"}"
"QLineEdit#saxs_reference_field {"
f"background-color: {recessed_bg.name()};"
f"border: 1px solid {subtle_border.name()};"
"border-radius: 4px;"
f"color: {muted.name()};"
"padding: 3px 6px;"
"}"
"QFrame#saxs_session_bar, QFrame#saxs_table_controls {"
f"background-color: {panel_bg.name()};"
f"border: 1px solid {accent_border.name()};"
@@ -442,6 +671,7 @@ class SAXSWidget(BECWidget, QWidget):
)
roi.line_color = self._roi_color
roi.removable = False
roi.edgesChanged.connect(self._on_roi_reference_changed)
self._saxs_roi = roi
return roi
@@ -459,7 +689,29 @@ class SAXSWidget(BECWidget, QWidget):
)
def _current_correction(self) -> Correction:
return self.mapping_box.correction()
reference_x, reference_y = self._mapping_reference_position()
if not hasattr(self, "mapping_box"):
return Correction(
scale_x=1.0,
offset_x=0.0,
direction_x=1,
scale_y=1.0,
offset_y=0.0,
direction_y=1,
reference_x=reference_x,
reference_y=reference_y,
)
correction = self.mapping_box.correction()
return Correction(
scale_x=correction.scale_x,
offset_x=correction.offset_x,
direction_x=correction.direction_x,
scale_y=correction.scale_y,
offset_y=correction.offset_y,
direction_y=correction.direction_y,
reference_x=reference_x,
reference_y=reference_y,
)
def _make_sample_id(self) -> str:
return uuid4().hex
@@ -851,22 +1103,6 @@ class SAXSWidget(BECWidget, QWidget):
self._rows[index].scan_results.append(ScanResult(str(scan_number), bool(success)))
self._refresh_table()
@SafeSlot()
def submit_selected(self) -> None:
rows = self.table_widget.selected_model_rows()
if not rows:
rows = self._rows
if not rows:
self._warn("There are no rows to upload.")
return
if self._session_dirty:
self._warn("Save the session before uploading.")
return
session_name = self._active_session or ""
payload = [row.to_dict() for row in rows]
self.submission_requested.emit((session_name, payload))
self.status_label.setText(f"Submitted {len(payload)} rows")
@SafeSlot()
def save_session(self, show_feedback: bool = True) -> None:
if not self._active_session:
@@ -1074,6 +1310,8 @@ class SAXSWidget(BECWidget, QWidget):
scale_y=float(entry["correction_scale_y"]),
offset_y=float(entry["correction_offset_y"]),
direction_y=int(entry["correction_direction_y"]),
reference_x=float(entry.get("correction_reference_x", 0.0)),
reference_y=float(entry.get("correction_reference_y", 0.0)),
)
geometry = corrected_geometry(
raw_roi,