feat(xray_eye): add target crosshair, zoom controls, fix ROI styling and zoom-reset bug

- Add show/hide/positionable target crosshair (RPC), separate from bec_widgets' mouse crosshair
- Add zoom in/out/fit buttons; fix manual zoom/pan being wiped on every live-view re-enable
- Add reset_zoom() RPC method, called at start of x_ray_eye_align.py
- Style ROI outline blue/thinner (was near-white via compact_color default)
This commit is contained in:
x12sa
2026-06-30 14:31:36 +02:00
committed by holler
parent c4fa9b2eb7
commit 716e2e8e61
3 changed files with 272 additions and 5 deletions
@@ -94,6 +94,8 @@ class XrayEyeAlign:
def align(self, keep_shutter_open=False):
self.flomni.flomnigui_show_xeyealign()
self.gui.set_dap_params_forwarding(True)
self.gui.reset_zoom()
try:
self._align_impl(keep_shutter_open)
finally:
@@ -101,6 +103,10 @@ class XrayEyeAlign:
self.gui.set_dap_params_forwarding(False)
except Exception as exc: # pylint: disable=broad-except
logger.warning(f"Failed to disable XRayEye DAP parameter forwarding: {exc}")
try:
self.gui.hide_crosshair()
except Exception as exc: # pylint: disable=broad-except
logger.warning(f"Failed to hide XRayEye alignment crosshair: {exc}")
def _align_impl(self, keep_shutter_open=False):
if not keep_shutter_open:
@@ -145,10 +151,8 @@ class XrayEyeAlign:
if not self.test_wo_movements:
self.flomni.fosa_out()
self.flomni.ffzp_in()
self.flomni.feedback_disable()
fsamx_in = self.flomni._get_user_param_safe("fsamx", "in")
umv(dev.fsamx, fsamx_in - 0.25)
@@ -189,6 +193,15 @@ class XrayEyeAlign:
self.flomni.feedback_enable_with_reset()
self.update_frame(keep_shutter_open)
# Mark the FZP center on the live view: it stays visible as a
# fixed reference while the sample is aligned at each
# subsequent rotation angle (steps 1-4 below).
fzp_center_x = dev.omny_xray_gui.xval_x_0.get()
fzp_center_y = dev.omny_xray_gui.yval_y_0.get()
self.gui.set_crosshair_position(fzp_center_x, fzp_center_y)
self.gui.show_crosshair()
self.send_message("Step 1/5: Adjust sample height and submit center")
self.gui.enable_submit_button(True)
self.movement_buttons_enabled(True, True)
@@ -212,6 +225,7 @@ class XrayEyeAlign:
self.gui.enable_submit_button(False)
self.movement_buttons_enabled(False, False)
self.update_fov(k)
self.gui.hide_crosshair()
break
k += 1
@@ -299,4 +313,4 @@ class XrayEyeAlign:
)
self.gui.submit_fit_array(data)
print(f"fit submited with {data}")
# self.flomni.flomnigui_show_xeyealign_fittab()
# self.flomni.flomnigui_show_xeyealign_fittab()
+57
View File
@@ -116,6 +116,63 @@ class XRayEye(RPCBase):
None
"""
@rpc_timeout(20)
@rpc_call
def show_crosshair(self):
"""
Show the alignment target crosshair on the image view.
"""
@rpc_timeout(20)
@rpc_call
def hide_crosshair(self):
"""
Hide the alignment target crosshair on the image view.
"""
@property
@rpc_call
def crosshair_visible(self) -> "bool":
"""
Whether the alignment target crosshair is currently shown.
"""
@crosshair_visible.setter
@rpc_call
def crosshair_visible(self) -> "bool":
"""
Whether the alignment target crosshair is currently shown.
"""
@rpc_timeout(20)
@rpc_call
def set_crosshair_position(self, x: "float", y: "float"):
"""
Move the alignment target crosshair to (x, y). Does not change visibility.
Args:
x(float): x position, in the same image/data coordinates as ROIs
(see e.g. ``omny_xray_gui.xval_x_*``).
y(float): y position, in the same image/data coordinates as ROIs
(see e.g. ``omny_xray_gui.yval_y_*``).
"""
@rpc_call
def crosshair_position(self) -> "tuple[float, float]":
"""
Current position of the alignment target crosshair as (x, y).
"""
@rpc_timeout(20)
@rpc_call
def reset_zoom(self):
"""
Reset the image view to fit the current frame, discarding any manual
zoom/pan. Intended to be called once at the start of an alignment
routine; live view re-enabling does not reset zoom on its own (see
on_live_view_enabled).
"""
class XRayEye2DControl(RPCBase):
_IMPORT_MODULE = "csaxs_bec.bec_widgets.widgets.xray_eye.x_ray_eye"
@@ -1,5 +1,6 @@
from __future__ import annotations
import pyqtgraph as pg
from bec_lib import bec_logger
from bec_lib.endpoints import MessageEndpoints
from bec_qthemes import material_icon
@@ -31,6 +32,109 @@ logger = bec_logger.logger
CAMERA = ("cam_xeye", "image")
class TargetCrosshair:
"""
Fixed, RPC-positionable crosshair overlay for an image plot item.
This is intentionally separate from bec_widgets' built-in mouse-tracking
``Crosshair`` (toggled from the image toolbar): that one follows the
cursor and is purely a UI convenience. This crosshair never reacts to the
mouse - it marks a single target position that is shown, hidden and
moved entirely under program control (e.g. from ``xray_eye_align.py`` or
any other BEC client via RPC), so it can be used to mark a reference
position (such as a previously submitted alignment center) on the live
view while the user works through subsequent steps.
Position is expressed in the same image/data coordinate system used by
the widget's ROIs (see ``XRayEye.submit``), so values read back from
``omny_xray_gui.xval_x_*`` / ``yval_y_*`` can be passed in directly.
"""
def __init__(self, plot_item: pg.PlotItem):
self.plot_item = plot_item
pen = pg.mkPen(color="#ff2e2e", width=2, style=Qt.PenStyle.DashLine)
self.v_line = pg.InfiniteLine(angle=90, movable=False, pen=pen)
self.h_line = pg.InfiniteLine(angle=0, movable=False, pen=pen)
for line in (self.v_line, self.h_line):
line.skip_auto_range = True
line.setVisible(False)
self.plot_item.addItem(line, ignoreBounds=True)
def set_position(self, x: float, y: float):
"""Move the crosshair to (x, y) in image/data coordinates."""
self.v_line.setPos(x)
self.h_line.setPos(y)
def position(self) -> tuple[float, float]:
"""Current crosshair position as (x, y) in image/data coordinates."""
return (self.v_line.value(), self.h_line.value())
def set_visible(self, visible: bool):
"""Show or hide the crosshair without changing its position."""
self.v_line.setVisible(visible)
self.h_line.setVisible(visible)
def is_visible(self) -> bool:
return self.v_line.isVisible()
def cleanup(self):
self.plot_item.removeItem(self.v_line)
self.plot_item.removeItem(self.h_line)
class ImageZoomControl(QWidget):
"""
Discrete zoom controls for an Image widget's view.
Mouse-wheel zoom steps can be far too coarse to use precisely over a
remote desktop connection, so this provides explicit zoom in/out buttons
plus a "fit to view" button that resets pan/zoom to frame the current
image (the same operation as ``XRayEye.reset_zoom()``).
"""
# scaleBy() factor for one "zoom in" click; >1 (its inverse) zooms out.
ZOOM_STEP_FACTOR = 0.8
def __init__(self, parent=None, image_widget: Image | None = None, *args, **kwargs):
super().__init__(parent=parent, *args, **kwargs)
self._image_widget = image_widget
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(4)
self.zoom_out_button = QToolButton(parent=self)
self.zoom_out_button.setIcon(material_icon("zoom_out"))
self.zoom_out_button.setToolTip("Zoom out")
layout.addWidget(self.zoom_out_button)
self.zoom_in_button = QToolButton(parent=self)
self.zoom_in_button.setIcon(material_icon("zoom_in"))
self.zoom_in_button.setToolTip("Zoom in")
layout.addWidget(self.zoom_in_button)
self.fit_view_button = QToolButton(parent=self)
self.fit_view_button.setIcon(material_icon("fit_screen"))
self.fit_view_button.setToolTip("Reset zoom/pan to fit the image")
layout.addWidget(self.fit_view_button)
self.zoom_in_button.clicked.connect(lambda: self.zoom(self.ZOOM_STEP_FACTOR))
self.zoom_out_button.clicked.connect(lambda: self.zoom(1 / self.ZOOM_STEP_FACTOR))
self.fit_view_button.clicked.connect(self.reset_view)
def zoom(self, factor: float):
"""Scale the view by `factor` around the current view center."""
if self._image_widget is None:
return
self._image_widget.plot_item.vb.scaleBy((factor, factor))
def reset_view(self):
"""Reset pan/zoom to fit the current image."""
if self._image_widget is None:
return
self._image_widget.auto_range(True)
class XRayEye2DControl(BECWidget, QWidget):
def __init__(self, parent=None, step_size: int = 100, *arg, **kwargs):
super().__init__(parent=parent, *arg, **kwargs)
@@ -142,9 +246,20 @@ class XRayEye(BECWidget, QWidget):
"switch_tab",
"set_dap_params_forwarding",
"submit_fit_array",
"show_crosshair",
"hide_crosshair",
"crosshair_visible",
"crosshair_visible.setter",
"set_crosshair_position",
"crosshair_position",
"reset_zoom",
]
PLUGIN = True
# Styling for ROIs drawn in the (single, compact-mode) alignment view.
ROI_LINE_COLOR = "blue"
ROI_LINE_WIDTH = 2
def __init__(self, parent=None, **kwargs):
super().__init__(parent=parent, **kwargs)
self._connected_motor = None
@@ -157,6 +272,7 @@ class XRayEye(BECWidget, QWidget):
self.get_bec_shortcuts()
self._init_ui()
self.target_crosshair = TargetCrosshair(self.image.plot_item)
self._make_connections()
# Connection to redis endpoints
@@ -200,7 +316,11 @@ class XRayEye(BECWidget, QWidget):
# ROI toolbar + Live toggle (header row)
self.roi_manager = ROIPropertyTree(
parent=self, image_widget=self.image, compact=True, compact_orientation="horizontal"
parent=self,
image_widget=self.image,
compact=True,
compact_orientation="horizontal",
compact_color=self.ROI_LINE_COLOR,
)
header_row = QHBoxLayout()
header_row.setContentsMargins(0, 0, 0, 0)
@@ -242,6 +362,12 @@ class XRayEye(BECWidget, QWidget):
self.motor_control_2d, 0, Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignCenter
)
# Zoom controls (mouse-wheel zoom steps are too coarse over remote desktop)
self.zoom_control = ImageZoomControl(parent=self, image_widget=self.image)
self.control_panel_layout.addWidget(
self.zoom_control, 0, Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignCenter
)
# separator
self.control_panel_layout.addWidget(self._create_separator())
@@ -341,6 +467,14 @@ class XRayEye(BECWidget, QWidget):
lambda x: self.motor_control_2d.setProperty("step_size", x)
)
self.submit_button.clicked.connect(self.submit)
# ROIPropertyTree's compact_color only styles the line color; line width
# still needs to be forced per-ROI here.
self.roi_manager.controller.roiAdded.connect(self._style_new_roi)
@SafeSlot(object)
def _style_new_roi(self, roi):
"""Force a thinner outline on newly drawn ROIs (color is set via compact_color)."""
roi.line_width = self.ROI_LINE_WIDTH
def _create_separator(self):
sep = QFrame(parent=self)
@@ -478,6 +612,58 @@ class XRayEye(BECWidget, QWidget):
else:
self.tab_widget.setCurrentIndex(0)
@SafeSlot()
@rpc_timeout(20)
def show_crosshair(self):
"""Show the alignment target crosshair on the image view."""
self.target_crosshair.set_visible(True)
@SafeSlot()
@rpc_timeout(20)
def hide_crosshair(self):
"""Hide the alignment target crosshair on the image view."""
self.target_crosshair.set_visible(False)
@SafeProperty(bool)
def crosshair_visible(self) -> bool:
"""Whether the alignment target crosshair is currently shown."""
return self.target_crosshair.is_visible()
@crosshair_visible.setter
@rpc_timeout(20)
def crosshair_visible(self, visible: bool):
self.target_crosshair.set_visible(visible)
@SafeSlot(float, float)
@rpc_timeout(20)
def set_crosshair_position(self, x: float, y: float):
"""
Move the alignment target crosshair to (x, y). Does not change visibility.
Args:
x(float): x position, in the same image/data coordinates as ROIs
(see e.g. ``omny_xray_gui.xval_x_*``).
y(float): y position, in the same image/data coordinates as ROIs
(see e.g. ``omny_xray_gui.yval_y_*``).
"""
self.target_crosshair.set_position(x, y)
@SafeSlot()
def crosshair_position(self) -> tuple[float, float]:
"""Current position of the alignment target crosshair as (x, y)."""
return self.target_crosshair.position()
@SafeSlot()
@rpc_timeout(20)
def reset_zoom(self):
"""
Reset the image view to fit the current frame, discarding any manual
zoom/pan. Intended to be called once at the start of an alignment
routine; live view re-enabling does not reset zoom on its own (see
on_live_view_enabled).
"""
self.zoom_control.reset_view()
@SafeSlot()
def get_roi_coordinates(self) -> dict | None:
"""Get the coordinates of the currently active ROI."""
@@ -499,6 +685,15 @@ class XRayEye(BECWidget, QWidget):
if enabled:
self.live_preview_toggle.checked = enabled
self.image.image(device=CAMERA[0], signal=CAMERA[1])
# Reconnecting the monitor schedules a one-shot view autorange on
# the next incoming frame (bec_widgets Image._autorange_on_next_update),
# which would silently discard any manual zoom/pan every time live
# view is re-enabled (e.g. once per step of an alignment routine).
# Suppress it; an explicit reset is available via reset_zoom() /
# the "fit to view" button. Private attribute - re-check this if
# bec_widgets' Image implementation changes.
if hasattr(self.image, "_autorange_on_next_update"):
self.image._autorange_on_next_update = False
self.live_preview_toggle.blockSignals(False)
return
@@ -674,6 +869,7 @@ class XRayEye(BECWidget, QWidget):
def cleanup(self):
"""Cleanup connections on widget close -> disconnect slots and stop live mode of camera."""
self._queue_idle_timer.stop()
self.target_crosshair.cleanup()
if self._connected_motor is not None:
self.bec_dispatcher.disconnect_slot(
self.on_tomo_angle_readback, MessageEndpoints.device_readback(self._connected_motor)
@@ -707,4 +903,4 @@ if __name__ == "__main__":
win.resize(1000, 800)
win.show()
sys.exit(app.exec_())
sys.exit(app.exec_())