feat(xray_eye): add XRayEye widget and plugin for GUI integration
All checks were successful
CI for csaxs_bec / test (push) Successful in 1m16s
CI for csaxs_bec / test (pull_request) Successful in 1m17s

This commit is contained in:
2025-10-15 12:43:07 +02:00
parent 50f7e6cdb8
commit b627c834e1
6 changed files with 357 additions and 1 deletions

View File

@@ -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
"""

View File

@@ -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()

View File

@@ -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_())

View File

@@ -0,0 +1 @@
{'files': ['x_ray_eye.py']}

View File

@@ -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 = """
<ui language='c++'>
<widget class='XRayEye' name='x_ray_eye'>
</widget>
</ui>
"""
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()