From 0690fbb391fc9767defc3e8563edd03eb48106c3 Mon Sep 17 00:00:00 2001 From: x01dc Date: Thu, 17 Sep 2026 14:22:20 +0200 Subject: [PATCH] feat(flomni): auto-lock ROI vertical center to FZP height in x-ray eye alignment Implements docs/plans/xrayeye-vertical-lock.md: once the height-centering step fixes the sample's vertical position against the FZP crosshair, every selection box drawn for the remaining angle-alignment steps (1-5) no longer needs manual vertical placement -- it snaps to the crosshair's y position on draw and again on every drag/resize. - OMNY_XRayEye: new _vertical_lock_enabled state, lock_vertical_center()/ unlock_vertical_center() RPC methods, _snap_roi_vertical() helper wired into _style_new_roi() (both roiAdded and sigRegionChangeFinished). Snap is idempotent (no-op once already at the target y) so it can't loop back into itself via its own set_position() call. - Also added a "ROI vertical lock" toggle in the control panel so an operator can release/re-engage the lock manually mid-run -- e.g. to freely draw/measure an unrelated box without aborting the alignment. Both the toggle and the RPC methods stay in sync (blockSignals pattern already used elsewhere in this file for on_live_view_enabled). - x_ray_eye_align.py: lock_vertical_center() right after _height_centered flips to True; unlock_vertical_center() at the start of every fresh run (so a run never inherits a stale lock) and in align()'s finally block (so the lock never outlives a run, including on error/interruption). FZP box (step 0) and the height-centering box itself remain fully free -- the lock isn't engaged yet at that point. LamNI/OMNY have their own near-identical scripts but are explicitly out of scope (per the plan); the widget change is inert by default. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Lx3KffiFyyDMKT8vvPENUW --- .../plugins/flomni/x_ray_eye_align.py | 12 +++ .../bec_widgets/widgets/xray_eye/x_ray_eye.py | 86 +++++++++++++++ .../test_x_ray_eye_vertical_lock.py | 102 ++++++++++++++++++ 3 files changed, 200 insertions(+) create mode 100644 tests/tests_bec_widgets/test_x_ray_eye_vertical_lock.py diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py b/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py index 87e0fbfc..e74598f1 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py @@ -172,6 +172,10 @@ class XrayEyeAlign: self.gui.hide_crosshair() except Exception as exc: # pylint: disable=broad-except logger.warning(f"Failed to hide XRayEye alignment crosshair: {exc}") + try: + self.gui.unlock_vertical_center() + except Exception as exc: # pylint: disable=broad-except + logger.warning(f"Failed to unlock XRayEye ROI vertical center: {exc}") def _align_impl(self, keep_shutter_open=False): if not keep_shutter_open: @@ -194,6 +198,9 @@ class XrayEyeAlign: # reset shift xy and fov params self._reset_init_values() + # a fresh run must not inherit a stale lock from a previous (possibly + # interrupted) run -- see lock_vertical_center()/unlock_vertical_center() + self.gui.unlock_vertical_center() # Moved here from __init__: this is the actual start of a fresh # alignment run (matching what start_x_ray_eye_alignment() already @@ -332,6 +339,11 @@ class XrayEyeAlign: self.flomni.feedback_enable_with_reset() self._height_centered = True + # From here through step 5, the sample's vertical position is + # physically fixed at the FZP height -- auto-snap every new/ + # dragged/resized selection box's vertical center to the + # crosshair instead of requiring manual placement each time. + self.gui.lock_vertical_center() self.update_frame(keep_shutter_open) self.send_message("Step 1/5: Submit sample center") diff --git a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py index fe5f6a74..d4e8a924 100644 --- a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py +++ b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py @@ -251,6 +251,8 @@ class OMNY_XRayEye(BECWidget, QWidget): "submit_fit_array", "show_crosshair", "hide_crosshair", + "lock_vertical_center", + "unlock_vertical_center", "crosshair_visible", "crosshair_visible.setter", "set_crosshair_position", @@ -297,6 +299,8 @@ class OMNY_XRayEye(BECWidget, QWidget): self._init_ui() self.target_crosshair = TargetCrosshair(self.image.plot_item) + # See lock_vertical_center()/unlock_vertical_center()/_snap_roi_vertical() + self._vertical_lock_enabled = False self._make_connections() # Connection to redis endpoints @@ -406,6 +410,17 @@ class OMNY_XRayEye(BECWidget, QWidget): self.smear_active_toggle.checked = False self.smear_active_toggle.setEnabled(False) # read-only status, not operator-togglable + # Manual override for the ROI-vertical-lock feature (see + # lock_vertical_center()/unlock_vertical_center()): a script (e.g. + # x_ray_eye_align.py) engages the lock automatically during the + # angle-alignment steps, but an operator may still want to freely + # draw/measure an unrelated box mid-run -- this toggle lets them + # release it without aborting the run, and re-engage it after. + self.vertical_lock_label = QLabel("ROI vertical lock", parent=self) + self.vertical_lock_toggle = ToggleSwitch(parent=self) + self.vertical_lock_toggle.checked = False + self.vertical_lock_toggle.enabled.connect(self._on_vertical_lock_toggle_changed) + _right_vcenter = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter switch_grid.addWidget(self.shutter_label, 0, 1, _right_vcenter) switch_grid.addWidget(self.shutter_toggle, 0, 2, Qt.AlignmentFlag.AlignVCenter) @@ -415,6 +430,8 @@ class OMNY_XRayEye(BECWidget, QWidget): switch_grid.addWidget(self.smear_active_toggle, 1, 2, Qt.AlignmentFlag.AlignVCenter) switch_grid.addWidget(self.smear_preview_label, 1, 3, _right_vcenter) switch_grid.addWidget(self.smear_preview_toggle, 1, 4, Qt.AlignmentFlag.AlignVCenter) + switch_grid.addWidget(self.vertical_lock_label, 2, 1, _right_vcenter) + switch_grid.addWidget(self.vertical_lock_toggle, 2, 2, Qt.AlignmentFlag.AlignVCenter) self.control_panel_layout.addWidget(self.switch_grid_widget) @@ -634,6 +651,33 @@ class OMNY_XRayEye(BECWidget, QWidget): # Live-update the microns readout while this ROI is drawn/resized. roi.sigRegionChanged.connect(lambda r=roi: self._update_roi_size_readout(r)) self._update_roi_size_readout(roi) + # If the vertical lock is on (see lock_vertical_center()), snap this box's + # vertical center to the crosshair immediately, and again on every future + # drag/resize. + self._snap_roi_vertical(roi) + roi.sigRegionChangeFinished.connect(lambda r=roi: self._snap_roi_vertical(r)) + + def _snap_roi_vertical(self, roi): + """Snap `roi`'s vertical center to the crosshair's fixed y position, keeping + its x position and height/diameter unchanged. No-op unless the vertical lock + is enabled (``lock_vertical_center()``) and the crosshair is visible. Safe to + call from ``roi.sigRegionChangeFinished`` -- if the ROI is already at the + target position this is a no-op, so it cannot loop back into itself. + """ + if not self._vertical_lock_enabled or not self.target_crosshair.is_visible(): + return + _, fzp_y = self.crosshair_position() + if isinstance(roi, RectangularROI): + height = roi.get_coordinates(typed=True)["height"] + elif isinstance(roi, CircularROI): + height = roi.get_coordinates(typed=True)["diameter"] + else: + return + current_x = roi.pos().x() + new_origin_y = fzp_y - height / 2 + if math.isclose(roi.pos().y(), new_origin_y, abs_tol=1e-9): + return + roi.set_position(current_x, new_origin_y) def _microns_per_pixel(self): """Resolve microns/pixel and unit label from the camera user parameter. @@ -840,6 +884,48 @@ class OMNY_XRayEye(BECWidget, QWidget): """Hide the alignment target crosshair on the image view.""" self.target_crosshair.set_visible(False) + @SafeSlot() + @rpc_timeout(20) + def lock_vertical_center(self): + """Snap every ROI's vertical center to the crosshair's fixed y position + (see ``crosshair_position()``), immediately and again on every future + draw/drag/resize, until ``unlock_vertical_center()`` is called. Used by + ``x_ray_eye_align.py`` once the sample's height has been physically + centered on the FZP, so subsequent alignment boxes no longer need manual + vertical placement. See ``_snap_roi_vertical()``. + + Also settable by the operator directly via the "ROI vertical lock" + toggle in the control panel (``_on_vertical_lock_toggle_changed``) -- + e.g. to temporarily release a script-engaged lock mid-run in order to + freely draw/measure an unrelated box, then re-engage it. Both paths + keep the toggle's visual state in sync with ``_vertical_lock_enabled``. + """ + self._vertical_lock_enabled = True + self.vertical_lock_toggle.blockSignals(True) + self.vertical_lock_toggle.checked = True + self.vertical_lock_toggle.blockSignals(False) + for roi in self.roi_manager.controller.rois: + self._snap_roi_vertical(roi) + + @SafeSlot() + @rpc_timeout(20) + def unlock_vertical_center(self): + """Stop auto-snapping ROIs' vertical center -- see ``lock_vertical_center()``.""" + self._vertical_lock_enabled = False + self.vertical_lock_toggle.blockSignals(True) + self.vertical_lock_toggle.checked = False + self.vertical_lock_toggle.blockSignals(False) + + @SafeSlot(bool) + def _on_vertical_lock_toggle_changed(self, enabled: bool): + """Operator flipped the "ROI vertical lock" toggle directly -- delegate to + lock_vertical_center()/unlock_vertical_center() so both entry points + (this toggle, and script/RPC calls) stay in sync.""" + if enabled: + self.lock_vertical_center() + else: + self.unlock_vertical_center() + @SafeProperty(bool) def crosshair_visible(self) -> bool: """Whether the alignment target crosshair is currently shown.""" diff --git a/tests/tests_bec_widgets/test_x_ray_eye_vertical_lock.py b/tests/tests_bec_widgets/test_x_ray_eye_vertical_lock.py new file mode 100644 index 00000000..49925018 --- /dev/null +++ b/tests/tests_bec_widgets/test_x_ray_eye_vertical_lock.py @@ -0,0 +1,102 @@ +"""Tests for OMNY_XRayEye's ROI-vertical-lock feature (see +docs/plans/xrayeye-vertical-lock.md): _snap_roi_vertical() snaps a selection +box's vertical center to the crosshair's fixed y position while leaving its +x position and height/diameter untouched, but only while the lock is enabled +and the crosshair is visible. + +_snap_roi_vertical is a plain (non-Qt-slot-decorated) method, so it can be +exercised directly on a minimal stub without constructing the full +OMNY_XRayEye widget (which needs a live BEC client context via +get_bec_shortcuts()). +""" + +from unittest import mock + +from bec_widgets.widgets.plots.roi.image_roi import CircularROI, RectangularROI + +from csaxs_bec.bec_widgets.widgets.xray_eye.x_ray_eye import OMNY_XRayEye + + +class _StubXRayEye: + """Minimal stand-in for OMNY_XRayEye, isolated to just the attributes + _snap_roi_vertical touches.""" + + _snap_roi_vertical = OMNY_XRayEye._snap_roi_vertical + + +def _make_stub(*, lock_enabled: bool, crosshair_visible: bool, fzp_y: float = 42.0): + stub = _StubXRayEye() + stub._vertical_lock_enabled = lock_enabled + stub.target_crosshair = mock.Mock() + stub.target_crosshair.is_visible.return_value = crosshair_visible + stub.crosshair_position = mock.Mock(return_value=(0.0, fzp_y)) + return stub + + +def _make_rect_roi(x: float, y: float, height: float): + roi = mock.Mock(spec=RectangularROI) + roi.get_coordinates.return_value = {"height": height} + roi.pos.return_value = mock.Mock(x=lambda: x, y=lambda: y) + return roi + + +def test_snap_roi_vertical_snaps_to_crosshair_when_locked(): + stub = _make_stub(lock_enabled=True, crosshair_visible=True, fzp_y=42.0) + roi = _make_rect_roi(x=5.0, y=0.0, height=10.0) + + stub._snap_roi_vertical(roi) + + # new_origin_y = fzp_y - height/2 = 42 - 5 = 37; x is untouched + roi.set_position.assert_called_once_with(5.0, 37.0) + + +def test_snap_roi_vertical_noop_when_lock_disabled(): + stub = _make_stub(lock_enabled=False, crosshair_visible=True) + roi = _make_rect_roi(x=5.0, y=0.0, height=10.0) + + stub._snap_roi_vertical(roi) + + roi.set_position.assert_not_called() + + +def test_snap_roi_vertical_noop_when_crosshair_hidden(): + stub = _make_stub(lock_enabled=True, crosshair_visible=False) + roi = _make_rect_roi(x=5.0, y=0.0, height=10.0) + + stub._snap_roi_vertical(roi) + + roi.set_position.assert_not_called() + + +def test_snap_roi_vertical_noop_when_already_snapped(): + # Regression guard: sigRegionChangeFinished is connected to this method, + # so a call that finds the ROI already at the target position must not + # call set_position() again -- otherwise a re-triggered signal could loop. + stub = _make_stub(lock_enabled=True, crosshair_visible=True, fzp_y=42.0) + roi = _make_rect_roi(x=5.0, y=37.0, height=10.0) # already at target y + + stub._snap_roi_vertical(roi) + + roi.set_position.assert_not_called() + + +def test_snap_roi_vertical_preserves_x_and_height(): + stub = _make_stub(lock_enabled=True, crosshair_visible=True, fzp_y=-10.0) + roi = _make_rect_roi(x=-3.5, y=100.0, height=4.0) + + stub._snap_roi_vertical(roi) + + # new_origin_y = -10 - 2 = -12; x (-3.5) is preserved + roi.set_position.assert_called_once_with(-3.5, -12.0) + + +def test_snap_roi_vertical_handles_circular_roi(): + stub = _make_stub(lock_enabled=True, crosshair_visible=True, fzp_y=20.0) + roi = mock.Mock(spec=CircularROI) + roi.get_coordinates.return_value = {"diameter": 8.0} + roi.pos.return_value = mock.Mock(x=lambda: 1.0, y=lambda: 0.0) + + stub._snap_roi_vertical(roi) + + # new_origin_y = fzp_y - diameter/2 = 20 - 4 = 16 + roi.set_position.assert_called_once_with(1.0, 16.0)