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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx3KffiFyyDMKT8vvPENUW
This commit is contained in:
x01dc
2026-09-17 14:22:20 +02:00
co-authored by Claude Sonnet 5
parent e0cb504c90
commit 0690fbb391
3 changed files with 200 additions and 0 deletions
@@ -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")
@@ -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."""
@@ -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)