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:
@@ -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)
|
||||
Reference in New Issue
Block a user