From 5e08be3ba7e1e28801e6679e2da7ef33d1dc9f87 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 14:17:33 +0000 Subject: [PATCH 1/7] Align with PyMoDAQ 5.3.x: fix the extension start-up, FieldLoaderApp and the deprecations - require pymodaq 5.3.x explicitly (>=5.3.2,<5.4), python >= 3.10 - AlgoApp: module_and_data_saver is a property of CustomApp, override it with a setter - FieldLoaderApp: do not trigger the legend of the 2D viewers (paint error in pymodaq_gui 5.3, crash with PySide6), use the ROI of the ROIMeta for the mask, detach widgets of the previous loader without processEvents - calibrating: no more import of the removed ScanManager.get_scanner_folder (calibration scan entry to be redone) - _epsilons, current_value, setup_docks_and_widgets, pymodaq.utils.custom_ext, load_dashboard_with_arguments - tests creating the CustomApp and the extension Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01CAhZwmbyCSpSgfTyicUxuS --- pyproject.toml | 12 ++-- .../algorithms/algorithm_app.py | 10 ++- .../daq_move_plugins/daq_move_FocalLength.py | 8 +-- .../daq_move_MockAutoFocus.py | 8 +-- .../daq_move_plugins/daq_move_MockShaper.py | 8 +-- .../daq_move_plugins/daq_move_Zernike.py | 8 +-- .../extensions/spatial_beam_shaping.py | 10 +-- .../field/field_loader_app.py | 25 ++++--- .../utilities/calibrating.py | 19 ++--- .../utilities/corrections.py | 2 +- .../utilities/zernike.py | 2 +- tests/conftest.py | 19 +++++ tests/test_custom_apps.py | 69 +++++++++++++++++++ 13 files changed, 144 insertions(+), 56 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_custom_apps.py diff --git a/pyproject.toml b/pyproject.toml index 61e8efc..8c90127 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,10 +12,10 @@ package-url = 'https://github.com/IumiShaping/pymodaq_plugins_optical_2D_shaping name = "pymodaq_plugins_beam_shaping" description = "PyMoDAQ's extension plugin to run optical shaping from various shapers (focusing on amplitude and phase shaping) and the image propagation" dependencies = [ - "pymodaq>=5.3.2", - "pymodaq_utils>=5.3.1", - "pymodaq_gui>=5.3.0", - "pymodaq_data>=5.3.0", + "pymodaq>=5.3.2,<5.4", + "pymodaq_utils>=5.3.1,<5.4", + "pymodaq_gui>=5.3.0,<5.4", + "pymodaq_data>=5.3.0,<5.4", 'pyfftw', 'scikit-image', 'opencv-python', @@ -36,7 +36,7 @@ maintainers = [ dynamic = ["version", "urls", "entry-points"] readme = "README.rst" license = { file="LICENSE" } -requires-python = ">=3.8" +requires-python = ">=3.10" classifiers = [ "Development Status :: 5 - Production/Stable", @@ -66,7 +66,7 @@ pytorch = [ requires = [ "hatchling>=1.9.0", "hatch-vcs", "toml", - "pymodaq_utils>=0.0.6", + "pymodaq_utils>=5.3.6", ] build-backend = "hatchling.build" diff --git a/src/pymodaq_plugins_beam_shaping/algorithms/algorithm_app.py b/src/pymodaq_plugins_beam_shaping/algorithms/algorithm_app.py index f3b27da..2ac564c 100644 --- a/src/pymodaq_plugins_beam_shaping/algorithms/algorithm_app.py +++ b/src/pymodaq_plugins_beam_shaping/algorithms/algorithm_app.py @@ -145,10 +145,18 @@ def __init__(self, dockarea, toolbar: QtWidgets.QToolBar=None): 'visible': self.settings['stopping', 'stopping_type'] == stop, 'children': stopping_factory.get_stop_class(stop).params}) - self.module_and_data_saver: ShapingSaver = None + self._module_and_data_saver: ShapingSaver = None self._h5saver: H5Saver = None + @property + def module_and_data_saver(self) -> ShapingSaver: + return self._module_and_data_saver + + @module_and_data_saver.setter + def module_and_data_saver(self, saver: ShapingSaver): + self._module_and_data_saver = saver + @property def h5saver(self) -> H5Saver: if self._h5saver is None: diff --git a/src/pymodaq_plugins_beam_shaping/daq_move_plugins/daq_move_FocalLength.py b/src/pymodaq_plugins_beam_shaping/daq_move_plugins/daq_move_FocalLength.py index d722518..3521c2d 100644 --- a/src/pymodaq_plugins_beam_shaping/daq_move_plugins/daq_move_FocalLength.py +++ b/src/pymodaq_plugins_beam_shaping/daq_move_plugins/daq_move_FocalLength.py @@ -26,10 +26,10 @@ class DAQ_Move_FocalLength(DAQ_Move_base): is_multiaxes = False _axis_names: Union[List[str], Dict[str, int]] = ['Focal'] _controller_units: Union[str, List[str]] = 'cm' - _epsilon: Union[float, List[float]] = 0.1 + _epsilons: Union[float, List[float]] = 0.1 data_actuator_type = DataActuatorType.DataActuator - params = [] + comon_parameters_fun(is_multiaxes, axis_names=_axis_names, epsilon=_epsilon) + params = [] + comon_parameters_fun(is_multiaxes, axis_names=_axis_names, epsilon=_epsilons) def ini_attributes(self): self.controller: 'Correction' = None @@ -84,8 +84,8 @@ def move_rel(self, value: DataActuator): ---------- value: (float) value of the relative target positioning """ - value = self.check_bound(self.current_position + value) - self.current_position - self.target_value = value + self.current_position + value = self.check_bound(self.current_value + value) - self.current_value + self.target_value = value + self.current_value value = self.set_position_relative_with_scaling(value) self.move_abs(self.target_value) diff --git a/src/pymodaq_plugins_beam_shaping/daq_move_plugins/daq_move_MockAutoFocus.py b/src/pymodaq_plugins_beam_shaping/daq_move_plugins/daq_move_MockAutoFocus.py index d4165ef..10a8948 100644 --- a/src/pymodaq_plugins_beam_shaping/daq_move_plugins/daq_move_MockAutoFocus.py +++ b/src/pymodaq_plugins_beam_shaping/daq_move_plugins/daq_move_MockAutoFocus.py @@ -25,10 +25,10 @@ class DAQ_Move_MockAutoFocus(DAQ_Move_base): is_multiaxes = False _axis_names: Union[List[str], Dict[str, int]] = ['Focus'] _controller_units: Union[str, List[str]] = '' - _epsilon: Union[float, List[float]] = 0.1 + _epsilons: Union[float, List[float]] = 0.1 data_actuator_type = DataActuatorType.DataActuator - params = [] + comon_parameters_fun(is_multiaxes, axis_names=_axis_names, epsilon=_epsilon) + params = [] + comon_parameters_fun(is_multiaxes, axis_names=_axis_names, epsilon=_epsilons) def ini_attributes(self): self.controller: Optional[Autofocus] = None @@ -86,8 +86,8 @@ def move_rel(self, value: DataActuator): ---------- value: (float) value of the relative target positioning """ - value = self.check_bound(self.current_position + value) - self.current_position - self.target_value = value + self.current_position + value = self.check_bound(self.current_value + value) - self.current_value + self.target_value = value + self.current_value value = self.set_position_relative_with_scaling(value) self.move_abs(self.target_value) diff --git a/src/pymodaq_plugins_beam_shaping/daq_move_plugins/daq_move_MockShaper.py b/src/pymodaq_plugins_beam_shaping/daq_move_plugins/daq_move_MockShaper.py index 93b9d64..6d38524 100644 --- a/src/pymodaq_plugins_beam_shaping/daq_move_plugins/daq_move_MockShaper.py +++ b/src/pymodaq_plugins_beam_shaping/daq_move_plugins/daq_move_MockShaper.py @@ -29,10 +29,10 @@ class DAQ_Move_MockShaper(DAQ_Move_base): is_multiaxes = False _axis_names: Union[List[str], Dict[str, int]] = [''] _controller_units: Union[str, List[str]] = '' - _epsilon: Union[float, List[float]] = 0.1 + _epsilons: Union[float, List[float]] = 0.1 data_actuator_type = DataActuatorType.DataActuator - params = [] + comon_parameters_fun(is_multiaxes, axis_names=_axis_names, epsilon=_epsilon) + params = [] + comon_parameters_fun(is_multiaxes, axis_names=_axis_names, epsilon=_epsilons) def ini_attributes(self): @@ -116,8 +116,8 @@ def move_rel(self, value: DataActuator): ---------- value: (float) value of the relative target positioning """ - value = self.check_bound(self.current_position + value) - self.current_position - self.target_value = value + self.current_position + value = self.check_bound(self.current_value + value) - self.current_value + self.target_value = value + self.current_value value = self.set_position_relative_with_scaling(value) self.move_abs(self.target_value) diff --git a/src/pymodaq_plugins_beam_shaping/daq_move_plugins/daq_move_Zernike.py b/src/pymodaq_plugins_beam_shaping/daq_move_plugins/daq_move_Zernike.py index 44bf01d..b23a76f 100644 --- a/src/pymodaq_plugins_beam_shaping/daq_move_plugins/daq_move_Zernike.py +++ b/src/pymodaq_plugins_beam_shaping/daq_move_plugins/daq_move_Zernike.py @@ -33,10 +33,10 @@ class DAQ_Move_Zernike(DAQ_Move_base): for m in range(-n, n+2, 2): _axis_names.append(f'{n}{m}') _controller_units: Union[str, List[str]] = '' - _epsilon: Union[float, List[float]] = 0.1 + _epsilons: Union[float, List[float]] = 0.1 data_actuator_type = DataActuatorType.DataActuator - params = [] + comon_parameters_fun(is_multiaxes, axis_names=_axis_names, epsilon=_epsilon) + params = [] + comon_parameters_fun(is_multiaxes, axis_names=_axis_names, epsilon=_epsilons) def ini_attributes(self): self.controller: 'Correction' = None @@ -94,8 +94,8 @@ def move_rel(self, value: DataActuator): ---------- value: (float) value of the relative target positioning """ - value = self.check_bound(self.current_position + value) - self.current_position - self.target_value = value + self.current_position + value = self.check_bound(self.current_value + value) - self.current_value + self.target_value = value + self.current_value value = self.set_position_relative_with_scaling(value) self.move_abs(self.target_value) diff --git a/src/pymodaq_plugins_beam_shaping/extensions/spatial_beam_shaping.py b/src/pymodaq_plugins_beam_shaping/extensions/spatial_beam_shaping.py index 5010b60..37ce2ed 100644 --- a/src/pymodaq_plugins_beam_shaping/extensions/spatial_beam_shaping.py +++ b/src/pymodaq_plugins_beam_shaping/extensions/spatial_beam_shaping.py @@ -19,7 +19,7 @@ from pymodaq_gui.utils.layout import save_layout_state, load_layout_state from pymodaq_gui.parameter.ioxml import xml_string_to_parameter -from pymodaq.extensions.custom_ext import CustomExt +from pymodaq.utils.custom_ext import CustomExt from pymodaq.utils.gui_utils.loader_utils import create_extension from pymodaq.utils.data import DataToExport, DataCalculated, DataDim @@ -666,15 +666,15 @@ def _quit_fun(self) -> bool: def main(): import sys from pymodaq_gui.qt_utils import mkQApp - from pymodaq.dashboard import create_load_dashboard + from pymodaq.dashboard import load_dashboard_with_arguments from pymodaq.utils.gui_utils.loader_utils import create_extension + app = mkQApp('BeamShaping') - win, dashboard = create_load_dashboard() + win, dashboard, _ = load_dashboard_with_arguments(show_dashboard=False, load_extension=False) win.mainwindow.setVisible(False) - win_ext, scan = create_extension(dashboard, BeamShaping) - win_ext.show() + win_ext, ext = create_extension(dashboard, BeamShaping, show_extension=True) sys.exit(app.exec()) diff --git a/src/pymodaq_plugins_beam_shaping/field/field_loader_app.py b/src/pymodaq_plugins_beam_shaping/field/field_loader_app.py index 5cfdb9f..941b055 100644 --- a/src/pymodaq_plugins_beam_shaping/field/field_loader_app.py +++ b/src/pymodaq_plugins_beam_shaping/field/field_loader_app.py @@ -192,13 +192,14 @@ def value_changed(self, param: Parameter): viewer = self.phase_viewer if param.value(): viewer.roi_manager.add_roi_programmatically('RectROI') - self.mask = viewer.roi_manager.get_roi_from_index(0) + roi_meta = viewer.roi_manager.get_roi_from_index(0) # ROIMeta, the ROI itself is in its roi attribute + self.mask = roi_meta.roi self.mask.sigRegionChangeFinished.connect(self.update_mask_slices) self.update_mask_slices() - else: - viewer.roi_manager.remove_roi_programmatically(0) - self.mask.sigRegionChangeFinished.disconnect() + elif self.mask is not None: + self.mask.sigRegionChangeFinished.disconnect(self.update_mask_slices) self.mask = None + viewer.roi_manager.remove_roi_programmatically(0) elif param.name() == 'save': if param.value(): @@ -468,12 +469,12 @@ def loader(self, loader_name: str): **self.loader_kwargs ) - while True: - child = self._loader_settings_widget.layout().takeAt(0) - if not child: - break - child.widget().deleteLater() - QtWidgets.QApplication.processEvents() + layout = self._loader_settings_widget.layout() + while layout.count(): + widget = layout.takeAt(0).widget() + if widget is not None: + widget.setParent(None) + widget.deleteLater() self._loader_settings_widget.layout().addWidget(self._field_loader.settings_tree) self._field_loader.register_listener(self) @@ -481,7 +482,7 @@ def loader(self, loader_name: str): except ValueError as e: pass - def setup_docks(self): + def setup_docks_and_widgets(self): self.docks['target'] = Dock('Target') self._main_widget = QtWidgets.QWidget() self._main_widget.setLayout(QtWidgets.QHBoxLayout()) @@ -493,7 +494,6 @@ def setup_docks(self): amp_widget = QtWidgets.QWidget() self.amp_viewer = Viewer2D(amp_widget) - self.amp_viewer.view.get_action('legend').trigger() self.slm_size_roi_amp = pgROI(pos=(0, 0), size=(20, 20), movable=False, rotatable=False, resizable=False, pen=(0, 255, 0)) self.amp_viewer.view.plotitem.addItem(self.slm_size_roi_amp) @@ -501,7 +501,6 @@ def setup_docks(self): phase_widget = QtWidgets.QWidget() self.phase_viewer = Viewer2D(phase_widget) - self.phase_viewer.view.get_action('legend').trigger() self.slm_size_roi_phase = pgROI(pos=(0, 0), size=(20, 20), movable=False, rotatable=False, resizable=False, pen=(0, 255, 0)) self.phase_viewer.view.plotitem.addItem(self.slm_size_roi_phase) diff --git a/src/pymodaq_plugins_beam_shaping/utilities/calibrating.py b/src/pymodaq_plugins_beam_shaping/utilities/calibrating.py index 1f643a4..f7168b2 100644 --- a/src/pymodaq_plugins_beam_shaping/utilities/calibrating.py +++ b/src/pymodaq_plugins_beam_shaping/utilities/calibrating.py @@ -32,15 +32,9 @@ logger = set_logger(get_module_name(__file__)) -def copy_scanner_settings(): - import shutil - from pymodaq.extensions.scan.scan_manager import ScanManager - scanner_settings = Path(__file__).parent.parent.joinpath('resources/holography_calibration.xml') - shutil.copyfile(str(scanner_settings), - str(ScanManager.get_scanner_folder().joinpath('holography_calibration.xml'))) - - -copy_scanner_settings() +# TODO: the xml file resources/holography_calibration.xml was a scan configuration of the pymodaq < 5.3 scanner manager +# (ScanManager.get_scanner_folder no longer exists). It has to be rewritten as an entry of the scan manager of 5.3 +# (folder given by ScanManager.get_entry_folder) before BeamShapingCalibration can be used again. def check_monotonic(phases: np.ndarray) -> bool: @@ -285,16 +279,15 @@ def show_calibration(show=True, calibration_dwa: DataWithAxes = None): def main(): from pymodaq_gui.qt_utils import mkQApp - from pymodaq.dashboard import create_load_dashboard + from pymodaq.dashboard import load_dashboard_with_arguments from pymodaq.utils.gui_utils.loader_utils import create_extension qapp = mkQApp('SLMCalibration') - win, dashboard = create_load_dashboard() + win, dashboard, _ = load_dashboard_with_arguments(show_dashboard=False, load_extension=False) win.mainwindow.setVisible(False) - win_ext, scan = create_extension(dashboard, BeamShapingCalibration) - win_ext.show() + win_ext, scan = create_extension(dashboard, BeamShapingCalibration, show_extension=True) sys.exit(qapp.exec()) diff --git a/src/pymodaq_plugins_beam_shaping/utilities/corrections.py b/src/pymodaq_plugins_beam_shaping/utilities/corrections.py index b515aad..cab640e 100644 --- a/src/pymodaq_plugins_beam_shaping/utilities/corrections.py +++ b/src/pymodaq_plugins_beam_shaping/utilities/corrections.py @@ -85,7 +85,7 @@ def update_zernike(self, n: int, m: int, value: float): self._zernike_coeffs.set(n, m, value) self.emit_corrections() - def setup_docks(self): + def setup_docks_and_widgets(self): self.docks['corrections'] = Dock('Corrections') self.dockarea.addDock(self.docks['corrections']) diff --git a/src/pymodaq_plugins_beam_shaping/utilities/zernike.py b/src/pymodaq_plugins_beam_shaping/utilities/zernike.py index 54a6c62..307d006 100644 --- a/src/pymodaq_plugins_beam_shaping/utilities/zernike.py +++ b/src/pymodaq_plugins_beam_shaping/utilities/zernike.py @@ -41,7 +41,7 @@ def __init__(self, parent): self.setup_ui() - def setup_docks(self): + def setup_docks_and_widgets(self): self.grid_layout = QtWidgets.QGridLayout() self.parent.setLayout(self.grid_layout) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8823b66 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,19 @@ +"""this package imports opencv, whose pip wheel points Qt to its own bundled platform plugins (QT_QPA_PLATFORM_PLUGIN_PATH). +With PyQt5 this makes the creation of the QApplication abort when slmsuite was imported before it, as the tests of +the controller do. Import cv2 here and drop these variables, so that Qt keeps using the plugins of the installed binding. +""" +import os + +try: + import cv2 # noqa: F401 +except ImportError: + pass + +for variable in ('QT_QPA_PLATFORM_PLUGIN_PATH', 'QT_QPA_FONTDIR'): + os.environ.pop(variable, None) + +try: # the apps read the theme set on the QApplication by mkQApp, pytest-qt reuses this instance + from pymodaq_gui.qt_utils import mkQApp + mkQApp('tests') +except ImportError: + pass diff --git a/tests/test_custom_apps.py b/tests/test_custom_apps.py new file mode 100644 index 0000000..63b59d9 --- /dev/null +++ b/tests/test_custom_apps.py @@ -0,0 +1,69 @@ +"""Create and use the CustomApp / CustomExt of the package, as the Dashboard does, to catch the incompatibilities with +the PyMoDAQ release (base classes, viewers, ROI API)""" +import pytest +from qtpy import QtWidgets + +from pymodaq_gui.utils.dock import DockArea + + +@pytest.fixture +def dockarea(qtbot): + window = QtWidgets.QMainWindow() + area = DockArea() + window.setCentralWidget(area) + qtbot.addWidget(window) + window.show() + yield area # the generator keeps the window alive + + +def test_field_loader_app(qtbot, dockarea): + from pymodaq_plugins_beam_shaping.field.field_loader_app import FieldLoaderApp + + app = FieldLoaderApp(dockarea, title='loader') + loaders = [name for name in app.settings.child('loader').opts['limits'] + if name not in ('SavedField', 'Dashboard', 'ImageFiles')] # these ones ask for a file or a dashboard + for name in loaders[:3]: + app.settings.child('loader').setValue(name) + app.load_field() + qtbot.wait(10) + assert app.field.amplitude.shape == (app.settings['needed_size', 'height'], + app.settings['needed_size', 'width']) + + +def test_field_loader_app_mask(qtbot, dockarea): + from pymodaq_plugins_beam_shaping.field.field_loader_app import FieldLoaderApp + + app = FieldLoaderApp(dockarea, title='loader') + app.settings.child('loader').setValue('GaussianIntensity') + app.load_field() + show_mask = app.settings.child('utils', 'masking', 'show_mask') + + show_mask.setValue(True) + assert app.mask is not None + assert app.get_mask_as_slices() is not None + + show_mask.setValue(False) + assert app.mask is None + + +def test_algo_app(qtbot, dockarea): + from pymodaq_plugins_beam_shaping.algorithms.algorithm_app import AlgoApp + + app = AlgoApp(dockarea) + assert app.module_and_data_saver is None + + +def test_correction_app(qtbot, dockarea): + from pymodaq_plugins_beam_shaping.utilities.corrections import Correction + + Correction(dockarea) + + +def test_extension_on_empty_dashboard(qtbot): + from pymodaq.dashboard import create_load_dashboard + from pymodaq.utils.gui_utils.loader_utils import create_extension + from pymodaq_plugins_beam_shaping.extensions.spatial_beam_shaping import BeamShaping + + _, dashboard = create_load_dashboard() + win_ext, extension = create_extension(dashboard, BeamShaping) + assert extension.quit_fun() From 19dc39b59efa8cd66614cffb280ee9a9fc711dcc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 14:24:01 +0000 Subject: [PATCH 2/7] tests: create the QApplication in a qapp fixture instead of at conftest import At import time pytest-xvfb has not started its display yet, which aborted pytest on the Linux CI jobs. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01CAhZwmbyCSpSgfTyicUxuS --- tests/conftest.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 8823b66..9c0a49b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,8 +12,11 @@ for variable in ('QT_QPA_PLATFORM_PLUGIN_PATH', 'QT_QPA_FONTDIR'): os.environ.pop(variable, None) -try: # the apps read the theme set on the QApplication by mkQApp, pytest-qt reuses this instance +import pytest + + +@pytest.fixture(scope='session') +def qapp(): + """The apps read the theme set on the QApplication by mkQApp""" from pymodaq_gui.qt_utils import mkQApp - mkQApp('tests') -except ImportError: - pass + return mkQApp('tests') From 34e72d8bd89cc1a5be7369ebb89820c312509efc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Weber?= Date: Mon, 26 Oct 2026 21:17:13 +0100 Subject: [PATCH 3/7] using sharedui for field loader standalone --- .../field/field_loader_app.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/pymodaq_plugins_beam_shaping/field/field_loader_app.py b/src/pymodaq_plugins_beam_shaping/field/field_loader_app.py index 941b055..ad870b6 100644 --- a/src/pymodaq_plugins_beam_shaping/field/field_loader_app.py +++ b/src/pymodaq_plugins_beam_shaping/field/field_loader_app.py @@ -4,6 +4,9 @@ from qtpy import QtWidgets, QtCore from skimage.transform import rescale, resize from scipy.ndimage import gaussian_filter + +from pymodaq_gui.utils.shared_ui import SharedUI +from pymodaq_gui.utils.widgets.window import make_window from pyqtgraph import ROI as pgROI from pymodaq_utils.config import GlobalConfig @@ -543,16 +546,12 @@ def main(): from pymodaq_gui.qt_utils import mkQApp app = mkQApp('Optical Shaping') - - win = QtWidgets.QMainWindow() - area = DockArea() - win.setCentralWidget(area) - win.resize(1000, 500) - win.setWindowTitle('PyMoDAQ Dashboard') - win.show() - + win, area = make_window(title='FieldLoaderApp') + shared_ui = SharedUI(win) optical_app = FieldLoaderApp(area) + shared_ui.affect_application(optical_app) + app.exec() From 56c92e21563ce7368708e6553a5c8f0fa67f96ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Weber?= Date: Mon, 26 Oct 2026 21:17:46 +0100 Subject: [PATCH 4/7] calling set_algorithm otherwise self._algor is not defined... --- src/pymodaq_plugins_beam_shaping/algorithms/algorithm_app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pymodaq_plugins_beam_shaping/algorithms/algorithm_app.py b/src/pymodaq_plugins_beam_shaping/algorithms/algorithm_app.py index 2ac564c..e1ccdd4 100644 --- a/src/pymodaq_plugins_beam_shaping/algorithms/algorithm_app.py +++ b/src/pymodaq_plugins_beam_shaping/algorithms/algorithm_app.py @@ -365,7 +365,7 @@ def ini_algo(self): if self.is_action_checked('ini_algo'): self.get_action('algorithms').widget.setEnabled(False) #self.set_action_enabled('ini_algo', False) - #self.set_algorithm() + self.set_algorithm() self.runner_thread = QtCore.QThread() runner = AlgoRunner(self._algorithm) @@ -410,7 +410,7 @@ def setup_docks_and_widgets(self): self.settings_widget = QtWidgets.QWidget() self.settings_widget.setLayout(QtWidgets.QVBoxLayout()) self.settings_widget.layout().setContentsMargins(0, 0, 0, 0) - splitter = QtWidgets.QSplitter(QtCore.Qt.Vertical) + splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Vertical) self.settings_widget.layout().addWidget(splitter) splitter.addWidget(self.settings_tree) From e1b78582281049df9311c40839637c4dec462770 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 20:33:17 +0000 Subject: [PATCH 5/7] masking: regions in real units (axes of the ROIs), converted to indexes only to slice arrays Default region is 2/3 of the effective area, centered, in each plane. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01CAhZwmbyCSpSgfTyicUxuS --- .../algorithms/algorithm_app.py | 68 ++++++++---- .../extensions/spatial_beam_shaping.py | 11 +- .../field/field_loader_app.py | 45 ++++++-- .../utilities/regions.py | 76 +++++++++++++ tests/test_regions.py | 105 ++++++++++++++++++ 5 files changed, 265 insertions(+), 40 deletions(-) create mode 100644 src/pymodaq_plugins_beam_shaping/utilities/regions.py create mode 100644 tests/test_regions.py diff --git a/src/pymodaq_plugins_beam_shaping/algorithms/algorithm_app.py b/src/pymodaq_plugins_beam_shaping/algorithms/algorithm_app.py index e1ccdd4..ce79b7f 100644 --- a/src/pymodaq_plugins_beam_shaping/algorithms/algorithm_app.py +++ b/src/pymodaq_plugins_beam_shaping/algorithms/algorithm_app.py @@ -39,7 +39,7 @@ from pymodaq_plugins_beam_shaping.algorithms.algo_config import AlgoConfig from pymodaq_plugins_beam_shaping.utilities.h5saving import ShapingSaver -from pymodaq_plugins_beam_shaping.utilities import sizing +from pymodaq_plugins_beam_shaping.utilities import sizing, regions algo_factory = AlgorithmFactory() phase_factory = PhaseFactory() @@ -96,16 +96,14 @@ class AlgoApp(CustomApp): {'title': 'Apply Mask', 'name': 'apply_mask', 'type': 'bool', 'value': False}, {'title': 'Mask Type', 'name': 'mask_type', 'type': 'list', 'value': str(MaskType.SQUARE), 'limits': MaskType.names()}, - {'title': 'Slices', 'name': 'slices', 'type': 'str', - 'value': '(slice(277, 1770, None), slice(252, 1795, None))'}, + {'title': 'Slices (m):', 'name': 'slices', 'type': 'str', 'value': ''}, ]}, {'title': 'Intermediate Masking', 'name': str(ApplyMaskTo.INTERMEDIATE), 'type': 'group', 'visible': False, 'children': [ {'title': 'Apply Mask', 'name': 'apply_mask', 'type': 'bool', 'value': False}, {'title': 'Mask Type', 'name': 'mask_type', 'type': 'list', 'value': str(MaskType.ELLIPTICAL), 'limits': MaskType.names()}, - {'title': 'Slices', 'name': 'slices', 'type': 'str', - 'value': '(slice(162, 882, None), slice(545, 1825, None))'}, + {'title': 'Slices (m):', 'name': 'slices', 'type': 'str', 'value': ''}, ]} ] @@ -276,6 +274,7 @@ def set_target_field(self, field: Field): self._algorithm.set_target_field(field) self._target_field = field + self.check_slices() def set_input_field(self, field: Field): @@ -336,7 +335,7 @@ def set_algorithm(self, algo_name: str = None): self.config_saver_loader.base_path = [self._algorithm.ALGO_NAME] self.set_settings_values() - self.update_target_slices(eval(self.settings[str(ApplyMaskTo.TARGET), 'slices'])) + self.check_slices() self.algo_changed.emit(self._algorithm) @@ -529,14 +528,27 @@ def compute_phase(self): def apply_mask(self, apply_to: ApplyMaskTo) -> bool: return self.settings[str(apply_to), 'apply_mask'] + def get_plane_pixel_sizes(self, apply_to: ApplyMaskTo) -> Union[None, tuple]: + """ Pixel sizes of the plane the mask applies to, None if not (yet) defined""" + try: + if str(apply_to) == str(ApplyMaskTo.INTERMEDIATE): + return self._algorithm.intermediate_pixel_sizes + return self._target_field.pixels_sizes + except (AttributeError, ValueError, TypeError): + return None + + def get_mask_region(self, apply_to: ApplyMaskTo) -> Union[None, tuple[slice, slice]]: + """ Get the mask region as slices in real units (m), None if not defined""" + return regions.parse_slices(self.settings[str(apply_to), 'slices']) + def get_mask_as_slices(self, apply_to: ApplyMaskTo) -> Union[None, tuple[slice, slice]]: + """ Get the mask region as slices of indexes to be used on the arrays of the corresponding plane""" if self.apply_mask(apply_to): - slices = eval(self.settings[str(apply_to), 'slices']) - if hasattr(slices, '__iter__'): - for _slice in slices: - if not isinstance(_slice, slice): - return None - return slices + region = self.get_mask_region(apply_to) + pixel_sizes = self.get_plane_pixel_sizes(apply_to) + if region is None or pixel_sizes is None: + return None + return regions.slices_to_indexes(region, pixel_sizes, sizing.get_effective_needed_field_size()) else: return None @@ -544,23 +556,31 @@ def get_mask_type(self, apply_to: ApplyMaskTo) -> MaskType: return MaskType[self.settings[str(apply_to), 'mask_type']] def update_intermediate_slices(self, slices: tuple[slice, slice]): - self.settings.child(str(ApplyMaskTo.INTERMEDIATE), 'slices').setValue(str(slices)) + self.update_slices(ApplyMaskTo.INTERMEDIATE, slices) def update_target_slices(self, slices: tuple[slice, slice]): - slices = self.constrains_slices(slices) - self.settings.child(str(ApplyMaskTo.TARGET), 'slices').setValue(str(slices)) - - @staticmethod - def constrains_slices(slices: tuple[slice, slice]) -> tuple[slice, slice]: - position, size = sizing.get_effective_area_pos_size_in_pxls() - slices = (slice(int(max(slices[0].start, position[0])), int(min(slices[0].stop, position[0] + size[0]))), - slice(int(max(slices[1].start, position[1])), int(min(slices[1].stop, position[1] + size[1]))) - ) - return slices + self.update_slices(ApplyMaskTo.TARGET, slices) + + def update_slices(self, apply_to: ApplyMaskTo, slices: tuple[slice, slice]): + """ Set the mask region (slices in m) constrained to the effective area of the plane""" + pixel_sizes = self.get_plane_pixel_sizes(apply_to) + if pixel_sizes is not None: + slices = regions.constrain_slices(slices, pixel_sizes) + self.settings.child(str(apply_to), 'slices').setValue(regions.slices_to_str(slices)) + + def check_slices(self): + """ Set the default region (2/3 of the effective area, centered) if the current one is not valid""" + for apply_to in (ApplyMaskTo.TARGET, ApplyMaskTo.INTERMEDIATE): + pixel_sizes = self.get_plane_pixel_sizes(apply_to) + if pixel_sizes is None: + continue + region = self.get_mask_region(apply_to) + if region is None or not regions.are_slices_in_area(region, pixel_sizes): + self.update_slices(apply_to, regions.default_slices(pixel_sizes)) def value_changed(self, param: Parameter): for applied in ApplyMaskTo.values(): - if applied in putils.get_param_path(param): + if applied in putils.get_param_path(param) and self._algorithm is not None: self._algorithm.update_mask = True if param.name() == 'ini_phase_factory': for param_child in self.settings.child('ini_phase_group', 'phase_params').children(): diff --git a/src/pymodaq_plugins_beam_shaping/extensions/spatial_beam_shaping.py b/src/pymodaq_plugins_beam_shaping/extensions/spatial_beam_shaping.py index 37ce2ed..75a534f 100644 --- a/src/pymodaq_plugins_beam_shaping/extensions/spatial_beam_shaping.py +++ b/src/pymodaq_plugins_beam_shaping/extensions/spatial_beam_shaping.py @@ -474,6 +474,7 @@ def setup_actions(self): def update_ui_from_algo(self, algo: AlgoBase): self.set_action_visible('show_intermediate', algo.SETUP_TYPE == LensSetup.FourF) + self.show_set_target_roi_select() def plot_target(self, field: Field): self.target_viewers.show_data(DataToExport('Target', data=[ @@ -513,11 +514,11 @@ def do_things_after_ui_setup(self): self.set_menu(self._algorithm.menu) #adds this to the menu dict (automatically added in the sharedUI) self.intermediate_viewer.roi_select_signal.connect( - lambda roi: self._algorithm.update_intermediate_slices(roi.to_slices())) + lambda roi: self._algorithm.update_intermediate_slices(roi.to_slices(as_integer=False))) self.show_set_target_roi_select() for viewer in (self._target_field_loader.amp_viewer, self._target_field_loader.phase_viewer): - viewer.roi_select_signal.connect(lambda roi: self._algorithm.update_target_slices(roi.to_slices())) + viewer.roi_select_signal.connect(lambda roi: self._algorithm.update_target_slices(roi.to_slices(as_integer=False))) if layout_path.joinpath('shaping.dock').is_file(): try: @@ -529,10 +530,12 @@ def do_things_after_ui_setup(self): def show_set_target_roi_select(self): - slices = self._algorithm.constrains_slices(eval(self._algorithm.settings[ApplyMaskTo.TARGET, 'slices'])) + slices = self._algorithm.get_mask_region(ApplyMaskTo.TARGET) + if slices is None: + return self._target_field_loader.amp_viewer.view.set_action_checked('ROIselect', True) pos = [slices[0].start, slices[1].start] - size = [slices[0].stop - slices[0].start, slices[1].stop - slices[0].start] + size = [slices[0].stop - slices[0].start, slices[1].stop - slices[1].start] self._target_field_loader.amp_viewer.view.show_ROI_select( size=size[::-1], pos=pos[::-1]) diff --git a/src/pymodaq_plugins_beam_shaping/field/field_loader_app.py b/src/pymodaq_plugins_beam_shaping/field/field_loader_app.py index ad870b6..36dde32 100644 --- a/src/pymodaq_plugins_beam_shaping/field/field_loader_app.py +++ b/src/pymodaq_plugins_beam_shaping/field/field_loader_app.py @@ -26,6 +26,7 @@ from pymodaq_gui.parameter.ioxml import parameter_to_xml_string from pymodaq_plugins_beam_shaping import config as plugin_config from pymodaq_plugins_beam_shaping.field import Field, Q_, LoaderFactory, FieldLoader +from pymodaq_plugins_beam_shaping.utilities import regions from pymodaq_plugins_beam_shaping.utilities.masking import MaskType from pymodaq_plugins_beam_shaping.utilities.sizing import (get_effective_needed_field_size, @@ -96,8 +97,7 @@ class FieldLoaderApp(CustomApp): {'title': 'Masking', 'name': 'masking', 'type': 'group', 'children': [ {'title': 'Mask Type', 'name': 'mask_type', 'type': 'list', 'value': str(MaskType.SQUARE), 'limits': MaskType.names()}, - {'title': 'Slices', 'name': 'slices', 'type': 'str', - 'value': '(slice(338, 757, None), slice(665, 1770, None))'}, + {'title': 'Slices (m):', 'name': 'slices', 'type': 'str', 'value': ''}, {'title': 'Boundary', 'name': 'boundary', 'type': 'group', 'children': [ {'title': 'Apply', 'name': 'apply_boundary', 'type': 'bool', 'value': False}, {'title': 'Sharpness', 'name': 'sharpness', 'type': 'int', 'value': 3, } @@ -150,21 +150,36 @@ def update_pixels(self, pixel_sizes: Tuple[Q_, Q_]): self.settings.child('needed_size', 'pixel_width').setValue(pixel_sizes[1].m_as('um')) self.load_field() - def get_mask_as_slices(self) -> Union[None, tuple[slice, slice]]: + def get_pixel_sizes(self) -> tuple[Q_, Q_]: + return (Q_(self.settings['needed_size', 'pixel_height'], 'um'), + Q_(self.settings['needed_size', 'pixel_width'], 'um')) + + def get_mask_region(self) -> Union[None, tuple[slice, slice]]: + """ Get the mask region as slices in real units (m), None if not defined""" + return regions.parse_slices(self.settings['utils', 'masking', 'slices']) - slices = eval(self.settings['utils', 'masking', 'slices']) - if hasattr(slices, '__iter__'): - for _slice in slices: - if not isinstance(_slice, slice): - return None - return slices + def get_mask_as_slices(self) -> Union[None, tuple[slice, slice]]: + """ Get the mask region as slices of indexes to be used on the array of the field""" + region = self.get_mask_region() + if region is None: + return None + shape = (self.settings['needed_size', 'height'], self.settings['needed_size', 'width']) + return regions.slices_to_indexes(region, self.get_pixel_sizes(), shape) + + def check_mask_region(self): + """ Set the default region (2/3 of the effective area, centered) if the current one is not valid""" + region = self.get_mask_region() + if region is None or not regions.are_slices_in_area(region, self.get_pixel_sizes()): + self.settings.child('utils', 'masking', 'slices').setValue( + regions.slices_to_str(regions.default_slices(self.get_pixel_sizes()))) def get_mask_type(self) -> MaskType: return MaskType[self.settings['utils', 'masking', 'mask_type']] def update_mask_slices(self): if self.mask is not None: - self.settings.child('utils', 'masking', 'slices').setValue(str(self.mask.to_info().to_slices())) + self.settings.child('utils', 'masking', 'slices').setValue( + regions.slices_to_str(self.mask.to_info().to_slices(as_integer=False))) def value_changed(self, param: Parameter): if param.name() == 'loader': @@ -197,6 +212,10 @@ def value_changed(self, param: Parameter): viewer.roi_manager.add_roi_programmatically('RectROI') roi_meta = viewer.roi_manager.get_roi_from_index(0) # ROIMeta, the ROI itself is in its roi attribute self.mask = roi_meta.roi + self.check_mask_region() + region = self.get_mask_region() + self.mask.setPos((region[1].start, region[0].start)) + self.mask.setSize((region[1].stop - region[1].start, region[0].stop - region[0].start)) self.mask.sigRegionChangeFinished.connect(self.update_mask_slices) self.update_mask_slices() elif self.mask is not None: @@ -262,7 +281,9 @@ def update_apply_mask(self, size: tuple[int, int], slices = (slice(max(0, center[0] - size[0] // 2), min(shape[0], center[0] + size[0] // 2)), slice(max(0, center[1] - size[1] // 2), min(shape[1], center[1] + size[1] // 2))) - self.settings.child('utils', 'masking', 'slices').setValue(str(slices)) + pixel_sizes = regions.pixel_sizes_in_meters(self.get_pixel_sizes()) + self.settings.child('utils', 'masking', 'slices').setValue(regions.slices_to_str( + tuple(slice(_slice.start * pxl, _slice.stop * pxl) for _slice, pxl in zip(slices, pixel_sizes)))) self.settings.child('utils', 'masking', 'apply_mask').setValue(apply) def updated_slm(self, slm_default_name: str): @@ -383,7 +404,7 @@ def update_final_size(self): self.normalise(self.field, NormaliseTo[self.settings['utils', 'normalisation']]) if self.settings['utils', 'masking', 'apply_mask']: - + self.check_mask_region() mask = self.get_mask_array() self.field.amplitude = self.field.amplitude * mask self.field.phase = self.field.phase * mask diff --git a/src/pymodaq_plugins_beam_shaping/utilities/regions.py b/src/pymodaq_plugins_beam_shaping/utilities/regions.py new file mode 100644 index 0000000..b667e8e --- /dev/null +++ b/src/pymodaq_plugins_beam_shaping/utilities/regions.py @@ -0,0 +1,76 @@ +"""Masking regions expressed in real (axes) units and their conversion to array indexes. + +The ROIs of the viewers (pymodaq_gui >= 5.3) work in the units of the data axes (meters for the Fields). The +regions are therefore stored as slices of floats in these units and only converted to indexes when an array has to be +sliced (this is what ``DataWithAxes.vsig`` / ``isig`` do for the data with axes). +""" +from typing import Optional, Sequence + +import numpy as np + +from pymodaq_data.data import Axis + +from pymodaq_plugins_beam_shaping.utilities import sizing + + +def pixel_sizes_in_meters(pixel_sizes: Sequence) -> np.ndarray: + """ pixel sizes as quantities (or floats in m) -> array of floats in m""" + return np.array([getattr(size, 'm_as', lambda unit, _s=size: _s)('m') for size in pixel_sizes], dtype=float) + + +def get_effective_area(pixel_sizes: Sequence) -> tuple[np.ndarray, np.ndarray]: + """ Position of the origin and size (in m, (y, x)) of the SLM effective area in a plane of given pixel sizes""" + pos_pxl, size_pxl = sizing.get_effective_area_pos_size_in_pxls() + pxl = pixel_sizes_in_meters(pixel_sizes) + return pos_pxl * pxl, size_pxl * pxl + + +def default_slices(pixel_sizes: Sequence, fraction: float = 2 / 3) -> tuple[slice, slice]: + """ Region centered on the effective area and covering a fraction of its size (in m)""" + pos, size = get_effective_area(pixel_sizes) + center = pos + size / 2 + half = size * fraction / 2 + return tuple(slice(float(c - h), float(c + h)) for c, h in zip(center, half)) + + +def constrain_slices(slices: Sequence[slice], pixel_sizes: Sequence) -> tuple[slice, slice]: + """ Restrict the slices (in m) to the effective area""" + pos, size = get_effective_area(pixel_sizes) + return tuple(slice(float(max(_slice.start, p)), float(min(_slice.stop, p + s))) + for _slice, p, s in zip(slices, pos, size)) + + +def are_slices_in_area(slices: Sequence[slice], pixel_sizes: Sequence) -> bool: + """ True if the slices (in m) are not empty and lie in the effective area""" + pos, size = get_effective_area(pixel_sizes) + tol = 1e-6 * size + return all(_slice.start < _slice.stop and _slice.start >= p - t and _slice.stop <= p + s + t + for _slice, p, s, t in zip(slices, pos, size, tol)) + + +def parse_slices(text: str) -> Optional[tuple[slice, slice]]: + """ Get the slices from their string representation, None if not valid""" + try: + slices = eval(text, {'slice': slice, 'None': None}) + except Exception: + return None + if (isinstance(slices, tuple) and len(slices) == 2 and + all(isinstance(_slice, slice) and _slice.start is not None and _slice.stop is not None + for _slice in slices)): + return slices + return None + + +def slices_to_str(slices: Sequence[slice]) -> str: + return str(tuple(slice(float(_slice.start), float(_slice.stop)) for _slice in slices)) + + +def slices_to_indexes(slices: Sequence[slice], pixel_sizes: Sequence, shape: Sequence[int]) -> tuple[slice, slice]: + """ Convert slices in m to slices of indexes (as ``DataWithAxes.vsig`` would do) clipped to the array shape""" + pxl = pixel_sizes_in_meters(pixel_sizes) + indexes = [] + for dim, _slice in enumerate(slices): + axis = Axis(units='m', scaling=pxl[dim], offset=0., size=int(shape[dim]), index=dim) + indexes.append(slice(int(np.clip(axis.find_index(_slice.start), 0, shape[dim])), + int(np.clip(axis.find_index(_slice.stop), 0, shape[dim])))) + return tuple(indexes) diff --git a/tests/test_regions.py b/tests/test_regions.py new file mode 100644 index 0000000..599c3a1 --- /dev/null +++ b/tests/test_regions.py @@ -0,0 +1,105 @@ +"""Masking regions are expressed in real units (as the ROIs of the viewers) and converted to indexes""" +import numpy as np +import pytest +from qtpy import QtWidgets + +from pymodaq_data import Q_ +from pymodaq_gui.utils.dock import DockArea + +from pymodaq_plugins_beam_shaping.utilities import regions, sizing + + +PIXELS = (Q_(8., 'um'), Q_(8., 'um')) + + +def test_slices_to_indexes_matches_vsig(): + from pymodaq_data.data import DataRaw, Axis + shape = (100, 200) + axes = [Axis('y', units='m', scaling=8e-6, offset=0, index=0, size=shape[0]), + Axis('x', units='m', scaling=8e-6, offset=0, index=1, size=shape[1])] + dwa = DataRaw('d', data=[np.zeros(shape)], axes=axes, nav_indexes=()) + slices = (slice(80e-6, 160e-6), slice(16e-6, 96e-6)) + idx = regions.slices_to_indexes(slices, PIXELS, shape) + assert idx == (slice(10, 20), slice(2, 12)) + assert dwa.isig[idx].shape == dwa.vsig[slices].shape + + +def test_slices_to_indexes_clipped(): + idx = regions.slices_to_indexes((slice(-1e-3, 1.), slice(0, 1.)), PIXELS, (100, 200)) + assert idx == (slice(0, 100), slice(0, 200)) + + +def test_default_slices_are_two_thirds_centered(): + pos, size = regions.get_effective_area(PIXELS) + slices = regions.default_slices(PIXELS) + for _slice, p, s in zip(slices, pos, size): + assert _slice.stop - _slice.start == pytest.approx(2 / 3 * s) + assert (_slice.start + _slice.stop) / 2 == pytest.approx(p + s / 2) + assert regions.are_slices_in_area(slices, PIXELS) + + +def test_legacy_pixel_slices_are_not_valid(): + legacy = regions.parse_slices('(slice(277, 1770, None), slice(252, 1795, None))') + assert not regions.are_slices_in_area(legacy, PIXELS) + assert regions.parse_slices('') is None + + +def test_slices_str_roundtrip(): + slices = (slice(1e-5, 2e-5), slice(np.float64(3e-5), 4e-5)) + assert regions.parse_slices(regions.slices_to_str(slices)) == (slice(1e-5, 2e-5), slice(3e-5, 4e-5)) + + +@pytest.fixture +def dockarea(qtbot): + window = QtWidgets.QMainWindow() + area = DockArea() + window.setCentralWidget(area) + qtbot.addWidget(window) + window.show() + yield area + + +def test_loader_mask_in_real_units(qtbot, dockarea): + from pymodaq_plugins_beam_shaping.field.field_loader_app import FieldLoaderApp + + app = FieldLoaderApp(dockarea, title='loader') + app.settings.child('loader').setValue('GaussianIntensity') + app.load_field() + app.settings.child('utils', 'masking', 'show_mask').setValue(True) + + region = app.get_mask_region() + assert regions.are_slices_in_area(region, app.get_pixel_sizes()) + pos, size = sizing.get_effective_area_pos_size_in_pxls() + idx = app.get_mask_as_slices() + for _slice, p, s in zip(idx, pos, size): + assert _slice.stop - _slice.start == pytest.approx(2 / 3 * s, abs=2) + assert (_slice.start + _slice.stop) / 2 == pytest.approx(p + s / 2, abs=2) + + # moving the ROI updates the setting in meters and the indexes follow + pxl = app.get_pixel_sizes()[0].m_as('m') + app.mask.setPos((int(pos[1]) * pxl, int(pos[0]) * pxl)) + app.mask.setSize((100 * pxl, 50 * pxl)) + app.update_mask_slices() + idx = app.get_mask_as_slices() + assert (idx[0].stop - idx[0].start, idx[1].stop - idx[1].start) == (50, 100) + assert app.get_mask_array().sum() > 0 + + +def test_algo_app_slices(qtbot, dockarea): + from pymodaq_plugins_beam_shaping.algorithms.algorithm_app import AlgoApp + from pymodaq_plugins_beam_shaping.algorithms.utils import ApplyMaskTo + from pymodaq_plugins_beam_shaping.field import Field + + app = AlgoApp(dockarea) + shape = sizing.get_effective_needed_field_size() + app.set_target_field(Field(amplitude=np.ones(shape), pixel_sizes=PIXELS)) + region = app.get_mask_region(ApplyMaskTo.TARGET) + assert region is not None and regions.are_slices_in_area(region, PIXELS) + + app.settings.child(str(ApplyMaskTo.TARGET), 'apply_mask').setValue(True) + app.update_target_slices((slice(0., 1.), slice(0., 1.))) # constrained to the effective area + idx = app.get_mask_as_slices(ApplyMaskTo.TARGET) + pos, size = sizing.get_effective_area_pos_size_in_pxls() + for _slice, p, s in zip(idx, pos, size): + assert _slice.start == pytest.approx(p, abs=1) + assert _slice.stop == pytest.approx(p + s, abs=1) From 4bf546b5fa6a8eb9ce79dc4fc03d6adb5d2301c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 23:10:06 +0000 Subject: [PATCH 6/7] masking: slices in um (unit of the pixel sizes) and target ROI placed on the default region show_ROI_select ignores its pos and size arguments in pymodaq_gui 5.3: set the ROI directly, and each time a new target field is loaded. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01CAhZwmbyCSpSgfTyicUxuS --- .../algorithms/algorithm_app.py | 6 +-- .../extensions/spatial_beam_shaping.py | 18 ++++--- .../field/field_loader_app.py | 10 ++-- .../utilities/regions.py | 50 +++++++++++-------- tests/test_custom_apps.py | 11 ++++ tests/test_regions.py | 14 +++--- 6 files changed, 66 insertions(+), 43 deletions(-) diff --git a/src/pymodaq_plugins_beam_shaping/algorithms/algorithm_app.py b/src/pymodaq_plugins_beam_shaping/algorithms/algorithm_app.py index ce79b7f..2616ae1 100644 --- a/src/pymodaq_plugins_beam_shaping/algorithms/algorithm_app.py +++ b/src/pymodaq_plugins_beam_shaping/algorithms/algorithm_app.py @@ -96,14 +96,14 @@ class AlgoApp(CustomApp): {'title': 'Apply Mask', 'name': 'apply_mask', 'type': 'bool', 'value': False}, {'title': 'Mask Type', 'name': 'mask_type', 'type': 'list', 'value': str(MaskType.SQUARE), 'limits': MaskType.names()}, - {'title': 'Slices (m):', 'name': 'slices', 'type': 'str', 'value': ''}, + {'title': 'Slices (um):', 'name': 'slices', 'type': 'str', 'value': ''}, ]}, {'title': 'Intermediate Masking', 'name': str(ApplyMaskTo.INTERMEDIATE), 'type': 'group', 'visible': False, 'children': [ {'title': 'Apply Mask', 'name': 'apply_mask', 'type': 'bool', 'value': False}, {'title': 'Mask Type', 'name': 'mask_type', 'type': 'list', 'value': str(MaskType.ELLIPTICAL), 'limits': MaskType.names()}, - {'title': 'Slices (m):', 'name': 'slices', 'type': 'str', 'value': ''}, + {'title': 'Slices (um):', 'name': 'slices', 'type': 'str', 'value': ''}, ]} ] @@ -538,7 +538,7 @@ def get_plane_pixel_sizes(self, apply_to: ApplyMaskTo) -> Union[None, tuple]: return None def get_mask_region(self, apply_to: ApplyMaskTo) -> Union[None, tuple[slice, slice]]: - """ Get the mask region as slices in real units (m), None if not defined""" + """ Get the mask region as slices in real units (um), None if not defined""" return regions.parse_slices(self.settings[str(apply_to), 'slices']) def get_mask_as_slices(self, apply_to: ApplyMaskTo) -> Union[None, tuple[slice, slice]]: diff --git a/src/pymodaq_plugins_beam_shaping/extensions/spatial_beam_shaping.py b/src/pymodaq_plugins_beam_shaping/extensions/spatial_beam_shaping.py index 75a534f..df34255 100644 --- a/src/pymodaq_plugins_beam_shaping/extensions/spatial_beam_shaping.py +++ b/src/pymodaq_plugins_beam_shaping/extensions/spatial_beam_shaping.py @@ -30,6 +30,7 @@ from pymodaq_plugins_beam_shaping.algorithms import AlgorithmFactory, AlgoBase from pymodaq_plugins_beam_shaping.utilities import sizing from pymodaq_plugins_beam_shaping.algorithms.utils import ApplyMaskTo, LensSetup +from pymodaq_plugins_beam_shaping.utilities import regions from pymodaq_plugins_beam_shaping.utilities.calibrating import BeamShapingCalibration, Calibration from pymodaq_plugins_beam_shaping.utilities.data import DataShaper from pymodaq_plugins_beam_shaping.utilities.shaper import Shaper, ModulatorType, get_shaper @@ -510,15 +511,16 @@ def do_things_after_ui_setup(self): self._algorithm.modulator_field_signal.connect(self.update_modulator_field) self._input_field_loader.field_signal.connect(self._algorithm.set_input_field) self._target_field_loader.field_signal.connect(self._algorithm.set_target_field) + self._target_field_loader.field_signal.connect(lambda field: self.show_set_target_roi_select()) self.set_menu(self._algorithm.menu) #adds this to the menu dict (automatically added in the sharedUI) self.intermediate_viewer.roi_select_signal.connect( - lambda roi: self._algorithm.update_intermediate_slices(roi.to_slices(as_integer=False))) + lambda roi: self._algorithm.update_intermediate_slices(regions.from_axis_units(roi.to_slices(as_integer=False)))) self.show_set_target_roi_select() for viewer in (self._target_field_loader.amp_viewer, self._target_field_loader.phase_viewer): - viewer.roi_select_signal.connect(lambda roi: self._algorithm.update_target_slices(roi.to_slices(as_integer=False))) + viewer.roi_select_signal.connect(lambda roi: self._algorithm.update_target_slices(regions.from_axis_units(roi.to_slices(as_integer=False)))) if layout_path.joinpath('shaping.dock').is_file(): try: @@ -533,12 +535,12 @@ def show_set_target_roi_select(self): slices = self._algorithm.get_mask_region(ApplyMaskTo.TARGET) if slices is None: return - self._target_field_loader.amp_viewer.view.set_action_checked('ROIselect', True) - pos = [slices[0].start, slices[1].start] - size = [slices[0].stop - slices[0].start, slices[1].stop - slices[1].start] - self._target_field_loader.amp_viewer.view.show_ROI_select( - size=size[::-1], - pos=pos[::-1]) + slices = regions.to_axis_units(slices) # the ROI is in the units of the axes + view = self._target_field_loader.amp_viewer.view + view.set_action_checked('ROIselect', True) + view.show_ROI_select() # position and size are not taken into account by the viewer, set below + view.ROIselect.setPos((slices[1].start, slices[0].start)) + view.ROIselect.setSize((slices[1].stop - slices[1].start, slices[0].stop - slices[0].start)) def connect_things(self): logger.debug('connecting things') diff --git a/src/pymodaq_plugins_beam_shaping/field/field_loader_app.py b/src/pymodaq_plugins_beam_shaping/field/field_loader_app.py index 36dde32..3b14358 100644 --- a/src/pymodaq_plugins_beam_shaping/field/field_loader_app.py +++ b/src/pymodaq_plugins_beam_shaping/field/field_loader_app.py @@ -97,7 +97,7 @@ class FieldLoaderApp(CustomApp): {'title': 'Masking', 'name': 'masking', 'type': 'group', 'children': [ {'title': 'Mask Type', 'name': 'mask_type', 'type': 'list', 'value': str(MaskType.SQUARE), 'limits': MaskType.names()}, - {'title': 'Slices (m):', 'name': 'slices', 'type': 'str', 'value': ''}, + {'title': 'Slices (um):', 'name': 'slices', 'type': 'str', 'value': ''}, {'title': 'Boundary', 'name': 'boundary', 'type': 'group', 'children': [ {'title': 'Apply', 'name': 'apply_boundary', 'type': 'bool', 'value': False}, {'title': 'Sharpness', 'name': 'sharpness', 'type': 'int', 'value': 3, } @@ -155,7 +155,7 @@ def get_pixel_sizes(self) -> tuple[Q_, Q_]: Q_(self.settings['needed_size', 'pixel_width'], 'um')) def get_mask_region(self) -> Union[None, tuple[slice, slice]]: - """ Get the mask region as slices in real units (m), None if not defined""" + """ Get the mask region as slices in real units (um), None if not defined""" return regions.parse_slices(self.settings['utils', 'masking', 'slices']) def get_mask_as_slices(self) -> Union[None, tuple[slice, slice]]: @@ -179,7 +179,7 @@ def get_mask_type(self) -> MaskType: def update_mask_slices(self): if self.mask is not None: self.settings.child('utils', 'masking', 'slices').setValue( - regions.slices_to_str(self.mask.to_info().to_slices(as_integer=False))) + regions.slices_to_str(regions.from_axis_units(self.mask.to_info().to_slices(as_integer=False)))) def value_changed(self, param: Parameter): if param.name() == 'loader': @@ -213,7 +213,7 @@ def value_changed(self, param: Parameter): roi_meta = viewer.roi_manager.get_roi_from_index(0) # ROIMeta, the ROI itself is in its roi attribute self.mask = roi_meta.roi self.check_mask_region() - region = self.get_mask_region() + region = regions.to_axis_units(self.get_mask_region()) # the ROI is in the units of the axes self.mask.setPos((region[1].start, region[0].start)) self.mask.setSize((region[1].stop - region[1].start, region[0].stop - region[0].start)) self.mask.sigRegionChangeFinished.connect(self.update_mask_slices) @@ -281,7 +281,7 @@ def update_apply_mask(self, size: tuple[int, int], slices = (slice(max(0, center[0] - size[0] // 2), min(shape[0], center[0] + size[0] // 2)), slice(max(0, center[1] - size[1] // 2), min(shape[1], center[1] + size[1] // 2))) - pixel_sizes = regions.pixel_sizes_in_meters(self.get_pixel_sizes()) + pixel_sizes = regions.pixel_sizes_in_um(self.get_pixel_sizes()) self.settings.child('utils', 'masking', 'slices').setValue(regions.slices_to_str( tuple(slice(_slice.start * pxl, _slice.stop * pxl) for _slice, pxl in zip(slices, pixel_sizes)))) self.settings.child('utils', 'masking', 'apply_mask').setValue(apply) diff --git a/src/pymodaq_plugins_beam_shaping/utilities/regions.py b/src/pymodaq_plugins_beam_shaping/utilities/regions.py index b667e8e..fb23a46 100644 --- a/src/pymodaq_plugins_beam_shaping/utilities/regions.py +++ b/src/pymodaq_plugins_beam_shaping/utilities/regions.py @@ -1,32 +1,43 @@ -"""Masking regions expressed in real (axes) units and their conversion to array indexes. +"""Masking regions expressed in real units (um, the unit of the pixel sizes) and their conversion to array indexes. -The ROIs of the viewers (pymodaq_gui >= 5.3) work in the units of the data axes (meters for the Fields). The -regions are therefore stored as slices of floats in these units and only converted to indexes when an array has to be -sliced (this is what ``DataWithAxes.vsig`` / ``isig`` do for the data with axes). +The ROIs of the viewers (pymodaq_gui >= 5.3) work in the units of the data axes (m for the Fields). The regions are +stored in the settings as slices of floats in um (readable numbers) and only converted to indexes when an array has to +be sliced (this is what ``DataWithAxes.vsig`` / ``isig`` do for the data with axes). """ from typing import Optional, Sequence import numpy as np -from pymodaq_data.data import Axis - from pymodaq_plugins_beam_shaping.utilities import sizing -def pixel_sizes_in_meters(pixel_sizes: Sequence) -> np.ndarray: - """ pixel sizes as quantities (or floats in m) -> array of floats in m""" - return np.array([getattr(size, 'm_as', lambda unit, _s=size: _s)('m') for size in pixel_sizes], dtype=float) +AXIS_UNIT_TO_UM = 1e6 # the axes of the Fields (hence the ROIs) are in m, the settings in um + + +def from_axis_units(slices: Sequence[slice]) -> tuple[slice, ...]: + """ slices in the units of the axes (m) -> slices in um""" + return tuple(slice(_slice.start * AXIS_UNIT_TO_UM, _slice.stop * AXIS_UNIT_TO_UM) for _slice in slices) + + +def to_axis_units(slices: Sequence[slice]) -> tuple[slice, ...]: + """ slices in um -> slices in the units of the axes (m)""" + return tuple(slice(_slice.start / AXIS_UNIT_TO_UM, _slice.stop / AXIS_UNIT_TO_UM) for _slice in slices) + + +def pixel_sizes_in_um(pixel_sizes: Sequence) -> np.ndarray: + """ pixel sizes as quantities (or floats in um) -> array of floats in um""" + return np.array([getattr(size, 'm_as', lambda unit, _s=size: _s)('um') for size in pixel_sizes], dtype=float) def get_effective_area(pixel_sizes: Sequence) -> tuple[np.ndarray, np.ndarray]: - """ Position of the origin and size (in m, (y, x)) of the SLM effective area in a plane of given pixel sizes""" + """ Position of the origin and size (in um, (y, x)) of the SLM effective area in a plane of given pixel sizes""" pos_pxl, size_pxl = sizing.get_effective_area_pos_size_in_pxls() - pxl = pixel_sizes_in_meters(pixel_sizes) + pxl = pixel_sizes_in_um(pixel_sizes) return pos_pxl * pxl, size_pxl * pxl def default_slices(pixel_sizes: Sequence, fraction: float = 2 / 3) -> tuple[slice, slice]: - """ Region centered on the effective area and covering a fraction of its size (in m)""" + """ Region centered on the effective area and covering a fraction of its size (in um)""" pos, size = get_effective_area(pixel_sizes) center = pos + size / 2 half = size * fraction / 2 @@ -34,14 +45,14 @@ def default_slices(pixel_sizes: Sequence, fraction: float = 2 / 3) -> tuple[slic def constrain_slices(slices: Sequence[slice], pixel_sizes: Sequence) -> tuple[slice, slice]: - """ Restrict the slices (in m) to the effective area""" + """ Restrict the slices (in um) to the effective area""" pos, size = get_effective_area(pixel_sizes) return tuple(slice(float(max(_slice.start, p)), float(min(_slice.stop, p + s))) for _slice, p, s in zip(slices, pos, size)) def are_slices_in_area(slices: Sequence[slice], pixel_sizes: Sequence) -> bool: - """ True if the slices (in m) are not empty and lie in the effective area""" + """ True if the slices (in um) are not empty and lie in the effective area""" pos, size = get_effective_area(pixel_sizes) tol = 1e-6 * size return all(_slice.start < _slice.stop and _slice.start >= p - t and _slice.stop <= p + s + t @@ -66,11 +77,10 @@ def slices_to_str(slices: Sequence[slice]) -> str: def slices_to_indexes(slices: Sequence[slice], pixel_sizes: Sequence, shape: Sequence[int]) -> tuple[slice, slice]: - """ Convert slices in m to slices of indexes (as ``DataWithAxes.vsig`` would do) clipped to the array shape""" - pxl = pixel_sizes_in_meters(pixel_sizes) + """ Convert slices in um to slices of indexes (as ``DataWithAxes.vsig`` would do) clipped to the array shape""" + pxl = pixel_sizes_in_um(pixel_sizes) indexes = [] - for dim, _slice in enumerate(slices): - axis = Axis(units='m', scaling=pxl[dim], offset=0., size=int(shape[dim]), index=dim) - indexes.append(slice(int(np.clip(axis.find_index(_slice.start), 0, shape[dim])), - int(np.clip(axis.find_index(_slice.stop), 0, shape[dim])))) + for dim, _slice in enumerate(slices): # nearest pixel, as the axes find_index does but robust to rounding errors + indexes.append(slice(int(np.clip(round(_slice.start / pxl[dim]), 0, shape[dim])), + int(np.clip(round(_slice.stop / pxl[dim]), 0, shape[dim])))) return tuple(indexes) diff --git a/tests/test_custom_apps.py b/tests/test_custom_apps.py index 63b59d9..b169844 100644 --- a/tests/test_custom_apps.py +++ b/tests/test_custom_apps.py @@ -66,4 +66,15 @@ def test_extension_on_empty_dashboard(qtbot): _, dashboard = create_load_dashboard() win_ext, extension = create_extension(dashboard, BeamShaping) + + extension._target_field_loader.load_field() + # the target ROI starts on the default region: 2/3 of the effective area, in the units of the axes (m) + from pymodaq_plugins_beam_shaping.utilities import regions, sizing + roi = extension._target_field_loader.amp_viewer.view.ROIselect + pixel_sizes = extension._algorithm.get_plane_pixel_sizes('target_mask') + pos, size = regions.get_effective_area(pixel_sizes) # um, (y, x) + assert roi.size()[0] * 1e6 == pytest.approx(2 / 3 * size[1], rel=1e-3) + assert roi.size()[1] * 1e6 == pytest.approx(2 / 3 * size[0], rel=1e-3) + assert roi.pos()[0] * 1e6 == pytest.approx(pos[1] + size[1] / 6, rel=1e-3) + assert extension.quit_fun() diff --git a/tests/test_regions.py b/tests/test_regions.py index 599c3a1..e76584f 100644 --- a/tests/test_regions.py +++ b/tests/test_regions.py @@ -18,14 +18,14 @@ def test_slices_to_indexes_matches_vsig(): axes = [Axis('y', units='m', scaling=8e-6, offset=0, index=0, size=shape[0]), Axis('x', units='m', scaling=8e-6, offset=0, index=1, size=shape[1])] dwa = DataRaw('d', data=[np.zeros(shape)], axes=axes, nav_indexes=()) - slices = (slice(80e-6, 160e-6), slice(16e-6, 96e-6)) + slices = (slice(80., 160.), slice(16., 96.)) idx = regions.slices_to_indexes(slices, PIXELS, shape) assert idx == (slice(10, 20), slice(2, 12)) - assert dwa.isig[idx].shape == dwa.vsig[slices].shape + assert dwa.isig[idx].shape == dwa.vsig[regions.to_axis_units(slices)].shape def test_slices_to_indexes_clipped(): - idx = regions.slices_to_indexes((slice(-1e-3, 1.), slice(0, 1.)), PIXELS, (100, 200)) + idx = regions.slices_to_indexes((slice(-1e3, 1e9), slice(0, 1e9)), PIXELS, (100, 200)) assert idx == (slice(0, 100), slice(0, 200)) @@ -45,8 +45,8 @@ def test_legacy_pixel_slices_are_not_valid(): def test_slices_str_roundtrip(): - slices = (slice(1e-5, 2e-5), slice(np.float64(3e-5), 4e-5)) - assert regions.parse_slices(regions.slices_to_str(slices)) == (slice(1e-5, 2e-5), slice(3e-5, 4e-5)) + slices = (slice(10., 20.), slice(np.float64(30.), 40.)) + assert regions.parse_slices(regions.slices_to_str(slices)) == (slice(10., 20.), slice(30., 40.)) @pytest.fixture @@ -76,7 +76,7 @@ def test_loader_mask_in_real_units(qtbot, dockarea): assert (_slice.start + _slice.stop) / 2 == pytest.approx(p + s / 2, abs=2) # moving the ROI updates the setting in meters and the indexes follow - pxl = app.get_pixel_sizes()[0].m_as('m') + pxl = app.get_pixel_sizes()[0].m_as('m') # the ROI is in the units of the axes app.mask.setPos((int(pos[1]) * pxl, int(pos[0]) * pxl)) app.mask.setSize((100 * pxl, 50 * pxl)) app.update_mask_slices() @@ -97,7 +97,7 @@ def test_algo_app_slices(qtbot, dockarea): assert region is not None and regions.are_slices_in_area(region, PIXELS) app.settings.child(str(ApplyMaskTo.TARGET), 'apply_mask').setValue(True) - app.update_target_slices((slice(0., 1.), slice(0., 1.))) # constrained to the effective area + app.update_target_slices((slice(0., 1e9), slice(0., 1e9))) # constrained to the effective area idx = app.get_mask_as_slices(ApplyMaskTo.TARGET) pos, size = sizing.get_effective_area_pos_size_in_pxls() for _slice, p, s in zip(idx, pos, size): From 5fdc4eb5be7364ff5e3cf48ec4a787ee667af294 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 09:02:37 +0000 Subject: [PATCH 7/7] masking: store the slices rounded to the micron Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01CAhZwmbyCSpSgfTyicUxuS --- src/pymodaq_plugins_beam_shaping/utilities/regions.py | 9 +++++---- tests/test_regions.py | 6 ++++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/pymodaq_plugins_beam_shaping/utilities/regions.py b/src/pymodaq_plugins_beam_shaping/utilities/regions.py index fb23a46..81a806f 100644 --- a/src/pymodaq_plugins_beam_shaping/utilities/regions.py +++ b/src/pymodaq_plugins_beam_shaping/utilities/regions.py @@ -54,9 +54,9 @@ def constrain_slices(slices: Sequence[slice], pixel_sizes: Sequence) -> tuple[sl def are_slices_in_area(slices: Sequence[slice], pixel_sizes: Sequence) -> bool: """ True if the slices (in um) are not empty and lie in the effective area""" pos, size = get_effective_area(pixel_sizes) - tol = 1e-6 * size - return all(_slice.start < _slice.stop and _slice.start >= p - t and _slice.stop <= p + s + t - for _slice, p, s, t in zip(slices, pos, size, tol)) + tol = 1. # um, the slices are stored with a 1 um resolution + return all(_slice.start < _slice.stop and _slice.start >= p - tol and _slice.stop <= p + s + tol + for _slice, p, s in zip(slices, pos, size)) def parse_slices(text: str) -> Optional[tuple[slice, slice]]: @@ -73,7 +73,8 @@ def parse_slices(text: str) -> Optional[tuple[slice, slice]]: def slices_to_str(slices: Sequence[slice]) -> str: - return str(tuple(slice(float(_slice.start), float(_slice.stop)) for _slice in slices)) + """ String representation (to be stored in the settings) with a resolution of 1 um, below the pixel size""" + return str(tuple(slice(int(round(_slice.start)), int(round(_slice.stop))) for _slice in slices)) def slices_to_indexes(slices: Sequence[slice], pixel_sizes: Sequence, shape: Sequence[int]) -> tuple[slice, slice]: diff --git a/tests/test_regions.py b/tests/test_regions.py index e76584f..036c287 100644 --- a/tests/test_regions.py +++ b/tests/test_regions.py @@ -45,8 +45,10 @@ def test_legacy_pixel_slices_are_not_valid(): def test_slices_str_roundtrip(): - slices = (slice(10., 20.), slice(np.float64(30.), 40.)) - assert regions.parse_slices(regions.slices_to_str(slices)) == (slice(10., 20.), slice(30., 40.)) + slices = (slice(10.2, 19.7), slice(np.float64(30.), 40.)) + text = regions.slices_to_str(slices) + assert text == '(slice(10, 20, None), slice(30, 40, None))' # 1 um resolution + assert regions.parse_slices(text) == (slice(10, 20), slice(30, 40)) @pytest.fixture