From 4723f6768b8f84415c03eda7b2b52b9fde285ce8 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 15 Oct 2025 12:43:07 +0200 Subject: [PATCH] feat(xray_eye): add XRayEye widget and plugin for GUI integration --- csaxs_bec/bec_widgets/widgets/client.py | 75 +++- .../bec_widgets/widgets/xray_eye/__init__.py | 0 .../widgets/xray_eye/register_x_ray_eye.py | 15 + .../bec_widgets/widgets/xray_eye/x_ray_eye.py | 423 ++++++++++++++++++ .../widgets/xray_eye/x_ray_eye.pyproject | 1 + .../widgets/xray_eye/x_ray_eye_plugin.py | 57 +++ 6 files changed, 570 insertions(+), 1 deletion(-) create mode 100644 csaxs_bec/bec_widgets/widgets/xray_eye/__init__.py create mode 100644 csaxs_bec/bec_widgets/widgets/xray_eye/register_x_ray_eye.py create mode 100644 csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py create mode 100644 csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.pyproject create mode 100644 csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye_plugin.py diff --git a/csaxs_bec/bec_widgets/widgets/client.py b/csaxs_bec/bec_widgets/widgets/client.py index 0c7f321..ec2d37c 100644 --- a/csaxs_bec/bec_widgets/widgets/client.py +++ b/csaxs_bec/bec_widgets/widgets/client.py @@ -5,7 +5,7 @@ from __future__ import annotations from bec_lib.logger import bec_logger -from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call +from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout logger = bec_logger.logger @@ -14,6 +14,7 @@ logger = bec_logger.logger _Widgets = { "OmnyAlignment": "OmnyAlignment", + "XRayEye": "XRayEye", } @@ -73,3 +74,75 @@ class OmnyAlignment(RPCBase): """ None """ + + +class XRayEye(RPCBase): + @rpc_call + def active_roi(self) -> "BaseROI | None": + """ + Return the currently active ROI, or None if no ROI is active. + """ + + @property + @rpc_call + def enable_live_view(self): + """ + Get or set the live view enabled state. + """ + + @enable_live_view.setter + @rpc_call + def enable_live_view(self): + """ + Get or set the live view enabled state. + """ + + @property + @rpc_call + def user_message(self): + """ + None + """ + + @user_message.setter + @rpc_call + def user_message(self): + """ + None + """ + + @property + @rpc_call + def sample_name(self): + """ + None + """ + + @sample_name.setter + @rpc_call + def sample_name(self): + """ + None + """ + + @property + @rpc_call + def enable_move_buttons(self): + """ + None + """ + + @enable_move_buttons.setter + @rpc_call + def enable_move_buttons(self): + """ + None + """ + + +class XRayEye2DControl(RPCBase): + @rpc_call + def remove(self): + """ + Cleanup the BECConnector + """ diff --git a/csaxs_bec/bec_widgets/widgets/xray_eye/__init__.py b/csaxs_bec/bec_widgets/widgets/xray_eye/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/csaxs_bec/bec_widgets/widgets/xray_eye/register_x_ray_eye.py b/csaxs_bec/bec_widgets/widgets/xray_eye/register_x_ray_eye.py new file mode 100644 index 0000000..8fd0e39 --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/xray_eye/register_x_ray_eye.py @@ -0,0 +1,15 @@ +def main(): # pragma: no cover + from qtpy import PYSIDE6 + + if not PYSIDE6: + print("PYSIDE6 is not available in the environment. Cannot patch designer.") + return + from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection + + from csaxs_bec.bec_widgets.widgets.xray_eye.x_ray_eye_plugin import XRayEyePlugin + + QPyDesignerCustomWidgetCollection.addCustomWidget(XRayEyePlugin()) + + +if __name__ == "__main__": # pragma: no cover + main() 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 new file mode 100644 index 0000000..406cc63 --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py @@ -0,0 +1,423 @@ +from __future__ import annotations + +from bec_lib import bec_logger +from bec_lib.endpoints import MessageEndpoints +from bec_qthemes import material_icon +from bec_widgets import BECWidget, SafeProperty, SafeSlot +from bec_widgets.widgets.plots.image.image import Image +from bec_widgets.widgets.plots.image.setting_widgets.image_roi_tree import ROIPropertyTree +from bec_widgets.widgets.plots.roi.image_roi import BaseROI, CircularROI, RectangularROI +from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch +from qtpy.QtCore import Qt, QTimer +from qtpy.QtWidgets import ( + QFrame, + QGridLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QSizePolicy, + QSpinBox, + QToolButton, + QVBoxLayout, + QWidget, +) + +logger = bec_logger.logger +CAMERA = ("cam_xeye_rgb", "image") #TODO here put correct camera + + +class XRayEye2DControl(BECWidget, QWidget): + def __init__(self, parent=None, step_size: int = 100, *arg, **kwargs): + super().__init__(parent=parent, *arg, **kwargs) + self.get_bec_shortcuts() + self._step_size = step_size + self.root_layout = QGridLayout(self) + self.setStyleSheet(""" + QToolButton { + border: 1px solid; + border-radius: 4px; + } + """) + # Up + self.move_up_button = QToolButton(parent=self) + self.move_up_button.setIcon(material_icon('keyboard_double_arrow_up')) + self.root_layout.addWidget(self.move_up_button, 0, 2) + # Up tweak button + self.move_up_tweak_button = QToolButton(parent=self) + self.move_up_tweak_button.setIcon(material_icon('keyboard_arrow_up')) + self.root_layout.addWidget(self.move_up_tweak_button, 1, 2) + + # Left + self.move_left_button = QToolButton(parent=self) + self.move_left_button.setIcon(material_icon('keyboard_double_arrow_left')) + self.root_layout.addWidget(self.move_left_button, 2, 0) + # Left tweak button + self.move_left_tweak_button = QToolButton(parent=self) + self.move_left_tweak_button.setIcon(material_icon('keyboard_arrow_left')) + self.root_layout.addWidget(self.move_left_tweak_button, 2, 1) + + # Right + self.move_right_button = QToolButton(parent=self) + self.move_right_button.setIcon(material_icon('keyboard_double_arrow_right')) + self.root_layout.addWidget(self.move_right_button, 2, 4) + # Right tweak button + self.move_right_tweak_button = QToolButton(parent=self) + self.move_right_tweak_button.setIcon(material_icon('keyboard_arrow_right')) + self.root_layout.addWidget(self.move_right_tweak_button, 2, 3) + + # Down + self.move_down_button = QToolButton(parent=self) + self.move_down_button.setIcon(material_icon('keyboard_double_arrow_down')) + self.root_layout.addWidget(self.move_down_button, 4, 2) + # Down tweak button + self.move_down_tweak_button = QToolButton(parent=self) + self.move_down_tweak_button.setIcon(material_icon('keyboard_arrow_down')) + self.root_layout.addWidget(self.move_down_tweak_button, 3, 2) + + # Connections + self.move_up_button.clicked.connect(lambda: self.move("up", tweak=False)) + self.move_up_tweak_button.clicked.connect(lambda: self.move("up", tweak=True)) + self.move_down_button.clicked.connect(lambda: self.move("down", tweak=False)) + self.move_down_tweak_button.clicked.connect(lambda: self.move("down", tweak=True)) + self.move_left_button.clicked.connect(lambda: self.move("left", tweak=False)) + self.move_left_tweak_button.clicked.connect(lambda: self.move("left", tweak=True)) + self.move_right_button.clicked.connect(lambda: self.move("right", tweak=False)) + self.move_right_tweak_button.clicked.connect(lambda: self.move("right", tweak=True)) + + @SafeProperty(int) + def step_size(self) -> int: + return self._step_size + + @step_size.setter + def step_size(self, step_size: int): + self._step_size = step_size + + @SafeSlot(bool) + def enable_controls_hor(self, enable: bool): + self.move_left_button.setEnabled(enable) + self.move_left_tweak_button.setEnabled(enable) + self.move_right_button.setEnabled(enable) + self.move_right_tweak_button.setEnabled(enable) + + @SafeSlot(bool) + def enable_controls_ver(self, enable: bool): + self.move_up_button.setEnabled(enable) + self.move_up_tweak_button.setEnabled(enable) + self.move_down_button.setEnabled(enable) + self.move_down_tweak_button.setEnabled(enable) + + def move(self, direction: str, tweak: bool = False): + step = self._step_size + if tweak: + step = int(self._step_size / 5) + if direction == "up": + self.dev.omny_xray_gui.mvy.set(step) + elif direction == "down": + self.dev.omny_xray_gui.mvy.set(-step) + elif direction == "left": + self.dev.omny_xray_gui.mvx.set(-step) + elif direction == "right": + self.dev.omny_xray_gui.mvx.set(step) + else: + logger.warning(f"Unknown direction {direction} for move command.") + + +class XRayEye(BECWidget, QWidget): + USER_ACCESS = ["active_roi", "enable_live_view", "enable_live_view.setter", "user_message", "user_message.setter", + "sample_name", "sample_name.setter", "enable_move_buttons", "enable_move_buttons.setter"] + PLUGIN = True + + def __init__(self, parent=None, **kwargs): + super().__init__(parent=parent, **kwargs) + self.get_bec_shortcuts() + + self._init_ui() + self._make_connections() + + # Connection to redis endpoints + self.bec_dispatcher.connect_slot(self.device_updates, MessageEndpoints.device_readback("omny_xray_gui")) + self.connect_motors() + self.resize(800, 600) + QTimer.singleShot(0, self._init_gui_trigger) + + def _init_ui(self): + self.core_layout = QHBoxLayout(self) + + self.image = Image(parent=self) + self.image.enable_toolbar = False # Disable default toolbar to not allow to user set anything + self.image.inner_axes = False # Disable inner axes to maximize image area + self.image.plot_item.vb.invertY(True) # #TODO Invert y axis to match logic of LabView GUI + + # Control panel on the right: vertical layout inside a fixed-width widget + self.control_panel = QWidget(parent=self) + self.control_panel_layout = QVBoxLayout(self.control_panel) + self.control_panel_layout.setContentsMargins(0, 0, 0, 0) + self.control_panel_layout.setSpacing(10) + + # ROI toolbar + Live toggle (header row) + self.roi_manager = ROIPropertyTree(parent=self, image_widget=self.image, compact=True, + compact_orientation="horizontal") + header_row = QHBoxLayout() + header_row.setContentsMargins(0, 0, 0, 0) + header_row.setSpacing(8) + header_row.addWidget(self.roi_manager, 0) + header_row.addStretch() + self.live_preview_label = QLabel("Live Preview", parent=self) + self.live_preview_toggle = ToggleSwitch(parent=self) + self.live_preview_toggle.checked = False + header_row.addWidget(self.live_preview_label, 0, Qt.AlignVCenter) + header_row.addWidget(self.live_preview_toggle, 0, Qt.AlignVCenter) + self.control_panel_layout.addLayout(header_row) + + # separator + self.control_panel_layout.addWidget(self._create_separator()) + + # 2D Positioner (fixed size) + self.motor_control_2d = XRayEye2DControl(parent=self) + self.control_panel_layout.addWidget(self.motor_control_2d, 0, Qt.AlignTop | Qt.AlignCenter) + + # separator + self.control_panel_layout.addWidget(self._create_separator()) + + # Step size label + step_size_form = QGridLayout() + # General Step size + self.step_size = QSpinBox(parent=self) + self.step_size.setRange(10, 100) + self.step_size.setSingleStep(10) + self.step_size.setValue(100) + # Submit button + self.submit_button = QPushButton("Submit", parent=self) + # Add to layout form + step_size_form.addWidget(QLabel("Horizontal", parent=self), 0, 0) + step_size_form.addWidget(self.step_size, 0, 1) + step_size_form.addWidget(QLabel("Vertical", parent=self), 1, 0) + step_size_form.addWidget(self.submit_button, 2, 0, 1, 2) + + # Add form to control panel + self.control_panel_layout.addLayout(step_size_form) + + # Push form to bottom + self.control_panel_layout.addStretch() + + # Sample/Message form (bottom) + form = QGridLayout() + self.sample_name_line_edit = QLineEdit(parent=self) + self.sample_name_line_edit.setReadOnly(True) + form.addWidget(QLabel("Sample", parent=self), 0, 0) + form.addWidget(self.sample_name_line_edit, 0, 1) + self.message_line_edit = QLineEdit(parent=self) + self.message_line_edit.setReadOnly(True) + form.addWidget(QLabel("Message", parent=self), 1, 0) + form.addWidget(self.message_line_edit, 1, 1) + self.control_panel_layout.addLayout(form) + + # Fix panel width and allow vertical expansion + self.control_panel.adjustSize() + p_hint = self.control_panel.sizeHint() + self.control_panel.setFixedWidth(p_hint.width()) + self.control_panel.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) + + # Core Layout: image (expanding) | control panel (fixed) + self.core_layout.addWidget(self.image) + self.core_layout.addWidget(self.control_panel) + + def _make_connections(self): + # Fetch initial state + self.on_live_view_enabled(True) + self.step_size.setValue(self.motor_control_2d.step_size) + + # Make connections + self.live_preview_toggle.enabled.connect(self.on_live_view_enabled) + self.step_size.valueChanged.connect(lambda x: self.motor_control_2d.setProperty("step_size", x)) + self.submit_button.clicked.connect(self.submit) + + def _create_separator(self): + sep = QFrame(parent=self) + sep.setFrameShape(QFrame.HLine) + sep.setFrameShadow(QFrame.Sunken) + sep.setLineWidth(1) + return sep + + def _init_gui_trigger(self): + self.dev.omny_xray_gui.read() + + ################################################################################ + # Device Connection logic + ################################################################################ + + def connect_motors(self): + """ Checks one of the possible motors for flomni, omny and lamni setup.""" + possible_motors = ['osamroy', 'lsamrot', 'fsamroy'] + + for motor in possible_motors: + if motor in self.dev: + self.bec_dispatcher.connect_slot(self.on_tomo_angle_readback, MessageEndpoints.device_readback(motor)) + logger.info(f"Succesfully connected to {motor}") + + ################################################################################ + # Properties ported from the original OmnyAlignment, can be adjusted as needed + ################################################################################ + @SafeProperty(bool) + def enable_live_view(self): + """Get or set the live view enabled state.""" + return self.live_preview_toggle.checked + + @enable_live_view.setter + def enable_live_view(self, enable: bool): + self.live_preview_toggle.checked = enable + + @SafeProperty(str) + def user_message(self): + return self.message_line_edit.text() + + @user_message.setter + def user_message(self, message: str): + self.message_line_edit.setText(message) + + @SafeProperty(str) + def sample_name(self): + return self.sample_name_line_edit.text() + + @sample_name.setter + def sample_name(self, message: str): + self.sample_name_line_edit.setText(message) + + @SafeProperty(bool) + def enable_move_buttons(self): + return self.motor_control_2d.isEnabled() + + @enable_move_buttons.setter + def enable_move_buttons(self, enabled: bool): + self.motor_control_2d.setEnabled(enabled) + + def active_roi(self) -> BaseROI | None: + """Return the currently active ROI, or None if no ROI is active.""" + return self.roi_manager.single_active_roi + + ################################################################################ + # Slots ported from the original OmnyAlignment, can be adjusted as needed + ################################################################################ + + @SafeSlot() + def get_roi_coordinates(self) -> dict | None: + """Get the coordinates of the currently active ROI.""" + roi = self.roi_manager.single_active_roi + if roi is None: + logger.warning("No active ROI") + return None + logger.info(f"Active ROI coordinates: {roi.get_coordinates()}") + return roi.get_coordinates() + + @SafeSlot(bool) + def on_live_view_enabled(self, enabled: bool): + logger.info(f"Live view is enabled: {enabled}") + self.live_preview_toggle.blockSignals(True) + if enabled: + self.live_preview_toggle.checked = enabled + self.image.image(CAMERA) + self.live_preview_toggle.blockSignals(False) + return + + self.image.disconnect_monitor(CAMERA) + self.live_preview_toggle.checked = enabled + self.live_preview_toggle.blockSignals(False) + + @SafeSlot(bool, bool) + def on_motors_enable(self, x_enable: bool, y_enable: bool): + self.motor_control_2d.enable_controls_hor(x_enable) + self.motor_control_2d.enable_controls_ver(y_enable) + + @SafeSlot(str) + def set_message(self, msg: str): + self.message_line_edit.setText(msg) + + @SafeSlot(str) + def set_sample_name(self, msg: str): + self.sample_name_line_edit.setText(msg) + + @SafeSlot(int) + def enable_submit_button(self, enable: int): + """If -1 disable else enable""" + if enable == -1: + self.submit_button.setEnabled(False) + else: + self.submit_button.setEnabled(True) + + @SafeSlot(bool, bool) + def on_tomo_angle_readback(self, data: dict, meta: dict): + print(f"data: {data}") + print(f"meta: {meta}") + + @SafeSlot(dict, dict) + def device_updates(self, data: dict, meta: dict): + + signals = data.get('signals') + enable_live_preview = signals.get("omny_xray_gui_update_frame_acq").get('value') + enable_x_motor = signals.get("omny_xray_gui_enable_mv_x").get('value') + enable_y_motor = signals.get("omny_xray_gui_enable_mv_y").get('value') + self.on_live_view_enabled(bool(enable_live_preview)) + self.on_motors_enable(bool(enable_x_motor), bool(enable_y_motor)) + + # Signals from epics gui device + # send message + send_message = signals.get("omny_xray_gui_send_message").get('value') + self.set_message(send_message) + # sample name + sample_message = signals.get("omny_xray_gui_sample_name").get('value') + self.set_sample_name(sample_message) + # enable frame acquisition + update_frame_acq = signals.get("omny_xray_gui_update_frame_acq").get('value') + self.on_live_view_enabled(bool(update_frame_acq)) + # enable submit button + enable_submit_button = signals.get("omny_xray_gui_submit").get('value') + self.enable_submit_button(enable_submit_button) + + @SafeSlot() + def submit(self): + """Execute submit action by submit button.""" + if self.roi_manager.single_active_roi is None: + logger.warning("No active ROI") + return + roi_coordinates = self.roi_manager.single_active_roi.get_coordinates() + roi_center_x = roi_coordinates['center_x'] + roi_center_y = roi_coordinates['center_y'] + # Case of rectangular ROI + if isinstance(self.roi_manager.single_active_roi, RectangularROI): + roi_width = roi_coordinates['width'] + roi_height = roi_coordinates['height'] + elif isinstance(self.roi_manager.single_active_roi, CircularROI): + roi_width = roi_coordinates['diameter'] + roi_height = roi_coordinates['radius'] + else: + logger.warning("Unsupported ROI type for submit action.") + return + + print(f"current roi: {roi_center_x},{roi_center_y}, {roi_width},{roi_height}") + # submit roi coordinates + step = int(self.dev.omny_xray_gui.step.read().get("omny_xray_gui_step").get('value')) + + xval_x = getattr(self.dev.omny_xray_gui.xval_x, f"xval_x_{step}").set(roi_center_x) + xval_y = getattr(self.dev.omny_xray_gui.yval_y, f"yval_y_{step}").set(roi_center_y) + width_x = getattr(self.dev.omny_xray_gui.width_x, f"width_x_{step}").set(roi_width) + width_y = getattr(self.dev.omny_xray_gui.width_y, f"width_y_{step}").set(roi_height) + self.dev.omny_xray_gui.submit.set(1) + + def cleanup(self): + self.bec_dispatcher.disconnect_slot(self.device_updates, MessageEndpoints.device_readback("omny_xray_gui")) + getattr(self.dev,CAMERA).live_mode = False + super().cleanup() + +if __name__ == "__main__": + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + + win = XRayEye() + + win.resize(1000, 800) + win.show() + sys.exit(app.exec_()) diff --git a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.pyproject b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.pyproject new file mode 100644 index 0000000..b8433bf --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.pyproject @@ -0,0 +1 @@ +{'files': ['x_ray_eye.py']} \ No newline at end of file diff --git a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye_plugin.py b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye_plugin.py new file mode 100644 index 0000000..f611e47 --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye_plugin.py @@ -0,0 +1,57 @@ +# Copyright (C) 2022 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause + +from bec_widgets.utils.bec_designer import designer_material_icon +from qtpy.QtDesigner import QDesignerCustomWidgetInterface +from qtpy.QtWidgets import QWidget + +from csaxs_bec.bec_widgets.widgets.xray_eye.x_ray_eye import XRayEye + +DOM_XML = """ + + + + +""" + + +class XRayEyePlugin(QDesignerCustomWidgetInterface): # pragma: no cover + def __init__(self): + super().__init__() + self._form_editor = None + + def createWidget(self, parent): + if parent is None: + return QWidget() + t = XRayEye(parent) + return t + + def domXml(self): + return DOM_XML + + def group(self): + return "" + + def icon(self): + return designer_material_icon(XRayEye.ICON_NAME) + + def includeFile(self): + return "x_ray_eye" + + def initialize(self, form_editor): + self._form_editor = form_editor + + def isContainer(self): + return False + + def isInitialized(self): + return self._form_editor is not None + + def name(self): + return "XRayEye" + + def toolTip(self): + return "XRayEye" + + def whatsThis(self): + return self.toolTip()