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..2616ae1 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 (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', 'name': 'slices', 'type': 'str', - 'value': '(slice(162, 882, None), slice(545, 1825, None))'}, + {'title': 'Slices (um):', 'name': 'slices', 'type': 'str', 'value': ''}, ]} ] @@ -145,10 +143,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: @@ -268,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): @@ -328,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) @@ -357,7 +364,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) @@ -402,7 +409,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) @@ -521,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 (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]]: + """ 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 @@ -536,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/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..df34255 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 @@ -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 @@ -474,6 +475,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=[ @@ -509,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())) + 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())) + 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: @@ -529,13 +532,15 @@ 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'])) - 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] - self._target_field_loader.amp_viewer.view.show_ROI_select( - size=size[::-1], - pos=pos[::-1]) + slices = self._algorithm.get_mask_region(ApplyMaskTo.TARGET) + if slices is None: + return + 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') @@ -666,15 +671,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..3b14358 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 @@ -23,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, @@ -93,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 (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, } @@ -147,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 (um), 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(regions.from_axis_units(self.mask.to_info().to_slices(as_integer=False)))) def value_changed(self, param: Parameter): if param.name() == 'loader': @@ -192,13 +210,18 @@ 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.check_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) 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(): @@ -258,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_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) def updated_slm(self, slm_default_name: str): @@ -379,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 @@ -468,12 +493,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 +506,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 +518,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 +525,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) @@ -544,16 +567,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() 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/regions.py b/src/pymodaq_plugins_beam_shaping/utilities/regions.py new file mode 100644 index 0000000..81a806f --- /dev/null +++ b/src/pymodaq_plugins_beam_shaping/utilities/regions.py @@ -0,0 +1,87 @@ +"""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 (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_plugins_beam_shaping.utilities import sizing + + +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 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_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 um)""" + 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 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 um) are not empty and lie in the effective area""" + pos, size = get_effective_area(pixel_sizes) + 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]]: + """ 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: + """ 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]: + """ 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): # 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/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..9c0a49b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,22 @@ +"""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) + +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 + return mkQApp('tests') diff --git a/tests/test_custom_apps.py b/tests/test_custom_apps.py new file mode 100644 index 0000000..b169844 --- /dev/null +++ b/tests/test_custom_apps.py @@ -0,0 +1,80 @@ +"""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) + + 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 new file mode 100644 index 0000000..036c287 --- /dev/null +++ b/tests/test_regions.py @@ -0,0 +1,107 @@ +"""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(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[regions.to_axis_units(slices)].shape + + +def test_slices_to_indexes_clipped(): + idx = regions.slices_to_indexes((slice(-1e3, 1e9), slice(0, 1e9)), 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(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 +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') # 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() + 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., 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): + assert _slice.start == pytest.approx(p, abs=1) + assert _slice.stop == pytest.approx(p + s, abs=1)