From dff7e67eadd4b4cd1c18bd316093cc5742148533 Mon Sep 17 00:00:00 2001 From: appel_c Date: Thu, 9 Jul 2026 16:39:53 +0200 Subject: [PATCH] refactor: add crosshair for center and live mode button --- .../bec_widgets/widgets/saxs_widget/README.md | 6 +- .../widgets/saxs_widget/controls.py | 21 +- .../bec_widgets/widgets/saxs_widget/models.py | 22 +- .../widgets/saxs_widget/saxs_widget.py | 284 ++++++++++++++++-- 4 files changed, 302 insertions(+), 31 deletions(-) diff --git a/csaxs_bec/bec_widgets/widgets/saxs_widget/README.md b/csaxs_bec/bec_widgets/widgets/saxs_widget/README.md index 497f585..4ef5f34 100644 --- a/csaxs_bec/bec_widgets/widgets/saxs_widget/README.md +++ b/csaxs_bec/bec_widgets/widgets/saxs_widget/README.md @@ -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: diff --git a/csaxs_bec/bec_widgets/widgets/saxs_widget/controls.py b/csaxs_bec/bec_widgets/widgets/saxs_widget/controls.py index 32c4061..83758f9 100644 --- a/csaxs_bec/bec_widgets/widgets/saxs_widget/controls.py +++ b/csaxs_bec/bec_widgets/widgets/saxs_widget/controls.py @@ -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() diff --git a/csaxs_bec/bec_widgets/widgets/saxs_widget/models.py b/csaxs_bec/bec_widgets/widgets/saxs_widget/models.py index b4e519f..937327e 100644 --- a/csaxs_bec/bec_widgets/widgets/saxs_widget/models.py +++ b/csaxs_bec/bec_widgets/widgets/saxs_widget/models.py @@ -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 diff --git a/csaxs_bec/bec_widgets/widgets/saxs_widget/saxs_widget.py b/csaxs_bec/bec_widgets/widgets/saxs_widget/saxs_widget.py index 6e9fce6..f7e5ff7 100644 --- a/csaxs_bec/bec_widgets/widgets/saxs_widget/saxs_widget.py +++ b/csaxs_bec/bec_widgets/widgets/saxs_widget/saxs_widget.py @@ -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,