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 8299fdd..7dd643d 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 @@ -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() \ No newline at end of file diff --git a/csaxs_bec/bec_widgets/widgets/client.py b/csaxs_bec/bec_widgets/widgets/client.py index bef6d7c..92d4240 100644 --- a/csaxs_bec/bec_widgets/widgets/client.py +++ b/csaxs_bec/bec_widgets/widgets/client.py @@ -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" 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 32b20a7..3ad8b2a 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 @@ -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_()) \ No newline at end of file