From b627c834e101d1e0394fdf6cbbd978014072c39b 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 | 67 +++++- .../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 | 218 ++++++++++++++++++ .../widgets/xray_eye/x_ray_eye.pyproject | 1 + .../widgets/xray_eye/x_ray_eye_plugin.py | 57 +++++ 6 files changed, 357 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..f4a9fbb 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,67 @@ 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): + """ + None + """ + + @enable_live_view.setter + @rpc_call + def enable_live_view(self): + """ + None + """ + + @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 + """ 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..2cf42a4 --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QDoubleSpinBox +from qtpy.QtWidgets import QPushButton +from qtpy.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QLabel, QLineEdit, QSizePolicy, QFrame + +from bec_lib import bec_logger +from bec_widgets import BECWidget, SafeSlot, SafeProperty +from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox2D +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 +from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch + +logger = bec_logger.logger +#TODO replace with actual device names, these are just placeholders from simulation framework +DEVICE_HORIZONTAL = "samx" +DEVICE_VERTICAL = "samy" +CAMERA = "eiger" + +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._init_ui() + self._make_connections() + + 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 + + # 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) + 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 = PositionerBox2D(parent=self, device_hor=DEVICE_HORIZONTAL, device_ver=DEVICE_VERTICAL) + self.motor_control_2d.hide_device_boxes = True + self.motor_control_2d.adjustSize() + m_hint = self.motor_control_2d.sizeHint() + self.motor_control_2d.setFixedWidth(m_hint.width()) + self.motor_control_2d.setMaximumHeight(m_hint.height()) + self.motor_control_2d.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + self.control_panel_layout.addWidget(self.motor_control_2d, 0, Qt.AlignTop | Qt.AlignRight) + + # separator + self.control_panel_layout.addWidget(self._create_separator()) + + # Step size label + step_size_form = QGridLayout() + #Horizontal + self.step_size_horizontal = QDoubleSpinBox(parent=self) + self.step_size_horizontal.setDecimals(4) + self.step_size_horizontal.setRange(0.0001, 100000) + self.step_size_horizontal.setSingleStep(0.1) + self.step_size_horizontal.setValue(1.0) + #Vertical + self.step_size_vertical = QDoubleSpinBox(parent=self) + self.step_size_vertical.setDecimals(4) + self.step_size_vertical.setRange(0.0001, 100000) + self.step_size_vertical.setSingleStep(0.1) + self.step_size_vertical.setValue(1.0) + # Submit button + self.submit_button = QPushButton("Get ROI Coordinates", parent=self) + self.submit_button.setStyleSheet("""background-color: #4CAF50; color: white; padding: 5px 10px; border: none; border-radius: 4px;""") + # Add to layout form + step_size_form.addWidget(QLabel("Horizontal", parent=self),0,0) + step_size_form.addWidget(self.step_size_horizontal,0,1) + step_size_form.addWidget(QLabel("Vertical", parent=self),1,0) + step_size_form.addWidget(self.step_size_vertical,1,1) + 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) + form.addWidget(QLabel("Sample", parent=self),0,0) + form.addWidget(self.sample_name_line_edit,0,1) + self.message_line_edit = QLineEdit(parent=self) + 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_horizontal.setValue(self.motor_control_2d.step_size_hor) + self.step_size_vertical.setValue(self.motor_control_2d.step_size_ver) + + # Make connections + self.live_preview_toggle.enabled.connect(self.on_live_view_enabled) + self.step_size_horizontal.valueChanged.connect(lambda x: self.motor_control_2d.setProperty("step_size_hor",x)) + self.step_size_vertical.valueChanged.connect(lambda x: self.motor_control_2d.setProperty("step_size_ver",x)) + self.submit_button.clicked.connect(self.get_roi_coordinates) + + def _create_separator(self): + sep = QFrame(parent=self) + sep.setFrameShape(QFrame.HLine) + sep.setFrameShadow(QFrame.Sunken) + sep.setLineWidth(1) + return sep + + ################################################################################ + # Properties ported from the original OmnyAlignment, can be adjusted as needed + ################################################################################ + @SafeProperty(bool) + def enable_live_view(self): + 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}") + if enabled: + self.image.image(CAMERA) + return + + self.image.disconnect_monitor(CAMERA) + + +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..fb94732 --- /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 qtpy.QtDesigner import QDesignerCustomWidgetInterface +from qtpy.QtWidgets import QWidget + +from bec_widgets.utils.bec_designer import designer_material_icon +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()