diff --git a/README.rst b/README.rst index 1013c1c..49eb755 100644 --- a/README.rst +++ b/README.rst @@ -41,4 +41,15 @@ Infos ===== The SDK from Holoeye is required to operate those plugins. All plugins can also receive a numpy -array directly to be loaded into the SLM. Working with **SLM Display SDK (Python) < 4.0.0** +array directly to be loaded into the SLM. + +Both the **SLM Display SDK (Python) 3** and **4** are supported, and can be installed at the same time. The plugins +find them through environment variables: + +* SDK 3: ``HEDS__PYTHON_MODULES`` (the last one, sorted by name, is used) +* SDK 4 and newer: ``HEDS___PYTHON``, e.g. ``HEDS_4_0_PYTHON`` (the most recent version is used, its + folders ``api/python`` and ``examples`` are added to the python path) + +The ``SDK version`` setting of the plugins selects the one to use: ``auto`` (default) uses the SDK 4 if it is +installed (more recent), else the SDK 3. With the SDK 4, the Holoeye preview window is not opened at initialization +(it can fail when authorizations are required), use the ``Show Preview?`` setting to open it. diff --git a/src/pymodaq_plugins_holoeye/__init__.py b/src/pymodaq_plugins_holoeye/__init__.py index d64aa4b..2e19f90 100644 --- a/src/pymodaq_plugins_holoeye/__init__.py +++ b/src/pymodaq_plugins_holoeye/__init__.py @@ -1,25 +1,41 @@ # call the Holoeye python library for the correct path import os +import re from pathlib import Path import sys from pathlib import Path from pymodaq_utils.logger import set_logger # to be imported by other modules. +from pymodaq_utils.utils import get_version from pymodaq_plugins_holoeye.utils import Config + +__version__ = get_version(__package__) config = Config() -environs = [] -for env in os.environ.keys(): - if 'HEDS' in env and 'MODULES' in env: - environs.append(env) -environs = sorted(environs) +def _add_to_path(folder: str): + if folder and os.path.isdir(folder) and folder not in sys.path: + sys.path.append(folder) -if 'HEDS_PYTHON_MODULES' in environs: #old stuff without the sdk version - environs.remove('HEDS_PYTHON_MODULES', ) -sys.path.append(os.getenv(environs[-1], '')) +# SDK 3: python modules folder given by the HEDS_*_PYTHON_MODULES environment variable +environs = sorted(env for env in os.environ if 'HEDS' in env and 'MODULES' in env) +if 'HEDS_PYTHON_MODULES' in environs: # old stuff without the sdk version + environs.remove('HEDS_PYTHON_MODULES') +if environs: + _add_to_path(os.getenv(environs[-1], '')) +# SDK 4 and newer: root folder given by HEDS___PYTHON (the name changes with the version of the SDK), +# the python libraries are in api/python and examples. The most recent version is used. +_sdk_versions = {} +for env in os.environ: + match = re.fullmatch(r'HEDS_(\d+)_(\d+)_PYTHON', env) + if match and int(match.group(1)) >= 4: + _sdk_versions[(int(match.group(1)), int(match.group(2)))] = env +if _sdk_versions: + _sdk_root = os.getenv(_sdk_versions[max(_sdk_versions)], '') + _add_to_path(os.path.join(_sdk_root, 'api', 'python')) + _add_to_path(os.path.join(_sdk_root, 'examples')) diff --git a/src/pymodaq_plugins_holoeye/daq_move_plugins/daq_move_Holoeye.py b/src/pymodaq_plugins_holoeye/daq_move_plugins/daq_move_Holoeye.py index 681a325..fad4b43 100644 --- a/src/pymodaq_plugins_holoeye/daq_move_plugins/daq_move_Holoeye.py +++ b/src/pymodaq_plugins_holoeye/daq_move_plugins/daq_move_Holoeye.py @@ -1,6 +1,5 @@ import pymodaq_plugins_holoeye # mandatory if not imported from somewhere else to load holeye module from local install -from holoeye import slmdisplaysdk from pymodaq.control_modules.move_utility_classes import main diff --git a/src/pymodaq_plugins_holoeye/daq_move_plugins/daq_move_HoloeyeSplitScreen.py b/src/pymodaq_plugins_holoeye/daq_move_plugins/daq_move_HoloeyeSplitScreen.py index a4c9d1a..0bc025e 100644 --- a/src/pymodaq_plugins_holoeye/daq_move_plugins/daq_move_HoloeyeSplitScreen.py +++ b/src/pymodaq_plugins_holoeye/daq_move_plugins/daq_move_HoloeyeSplitScreen.py @@ -2,7 +2,6 @@ from typing import Union, List, Dict import pymodaq_plugins_holoeye # mandatory if not imported from somewhere else to load holeye module from local install -from holoeye import slmdisplaysdk from pymodaq.control_modules.move_utility_classes import DAQ_Move_base, comon_parameters_fun, main diff --git a/src/pymodaq_plugins_holoeye/hardware/mock_holoeye.py b/src/pymodaq_plugins_holoeye/hardware/mock_holoeye.py index bc76c50..1140f33 100644 --- a/src/pymodaq_plugins_holoeye/hardware/mock_holoeye.py +++ b/src/pymodaq_plugins_holoeye/hardware/mock_holoeye.py @@ -10,11 +10,14 @@ class SLMInstance: def __init__(self): pass + def close(self): + pass + def open(self): return ErrorCode.NoError.value - def errorString(self, error_code: ErrorCode): - return error_code.name + def errorString(self, error_code): + return ErrorCode(error_code).name @property def width_px(self) -> int: diff --git a/src/pymodaq_plugins_holoeye/hardware/sdk.py b/src/pymodaq_plugins_holoeye/hardware/sdk.py new file mode 100644 index 0000000..504b14d --- /dev/null +++ b/src/pymodaq_plugins_holoeye/hardware/sdk.py @@ -0,0 +1,60 @@ +"""Selection of the Holoeye SLM Display SDK to use: version 3 (``holoeye.slmdisplaysdk``) or version 4 (``HEDS``). + +Both SDKs can be installed at the same time (each has its own environment variable, see ``pymodaq_plugins_holoeye``). +They are only imported when requested, and both expose the same minimal interface to the plugins, the one of the +``SLMInstance`` of the SDK 3: ``open``, ``close``, ``errorString``, ``width_px``, ``height_px``, ``showData``, +``showPhasevalues`` and ``utilsSLMPreviewShow``. +""" +import importlib.util +import sys +from typing import List + +import pymodaq_plugins_holoeye # adds the SDK folders to the python path + +SDK_CHOICES = ['auto', '3', '4'] +NO_ERROR = 0 + + +def _is_available(module_name: str) -> bool: + if module_name in sys.modules: + return True + try: + return importlib.util.find_spec(module_name) is not None + except (ImportError, ValueError): + return False + + +def installed_sdks() -> List[str]: + """The versions of the SDK that can be imported, the most recent first""" + sdks = [] + if _is_available('HEDS') and _is_available('hedslib'): + sdks.append('4') + if _is_available('holoeye.slmdisplaysdk'): + sdks.append('3') + return sdks + + +def select_sdk(version: str = 'auto') -> str: + """Return '3' or '4'. With 'auto' the SDK 4 is used when installed (more recent), else the SDK 3""" + version = str(version) + if version not in SDK_CHOICES: + raise ValueError(f'Unknown SDK version {version}, should be one of {SDK_CHOICES}') + sdks = installed_sdks() + if version == 'auto': + if not sdks: + raise ImportError('No Holoeye SLM Display SDK found: install the SDK 3 or 4 and set its environment ' + 'variable (HEDS_*_PYTHON_MODULES for the SDK 3, HEDS___PYTHON for the SDK 4 and newer)') + return sdks[0] + if version not in sdks: + raise ImportError(f'The Holoeye SLM Display SDK {version} cannot be imported, ' + f'installed SDKs: {sdks if sdks else "none"}') + return version + + +def get_slm_class(version: str = 'auto'): + """Return the class driving the SLM with the requested SDK""" + if select_sdk(version) == '3': + from holoeye.slmdisplaysdk import SLMInstance + return SLMInstance + from pymodaq_plugins_holoeye.hardware.sdk4 import SLMInstance4 + return SLMInstance4 diff --git a/src/pymodaq_plugins_holoeye/hardware/sdk4.py b/src/pymodaq_plugins_holoeye/hardware/sdk4.py new file mode 100644 index 0000000..b777664 --- /dev/null +++ b/src/pymodaq_plugins_holoeye/hardware/sdk4.py @@ -0,0 +1,54 @@ +"""Adapter of the Holoeye SLM Display SDK 4 (module ``HEDS``) to the interface of the ``SLMInstance`` of the SDK 3""" +import numpy as np + +NO_ERROR = 0 + + +class SLMInstance4: + """Same methods as the ``SLMInstance`` of the SDK 3, used by the plugins, but based on the SDK 4""" + + def __init__(self): + import HEDS + from hedslib import heds_types + self._heds = HEDS + self._types = heds_types + self._slm = None + self.width_px = 0 + self.height_px = 0 + self.pixelsize_um = 0. + + def open(self, preview: bool = False): + """Open the SLM window. The preview window is not opened by default (it may require authorizations)""" + error = self._heds.SDK.Init(4, 0) + if error != self._types.HEDSERR_NoError: + return error + self._slm = self._heds.SLM.Init(preselect='', openPreview=preview) + error = self._slm.errorCode() + if error != self._types.HEDSERR_NoError: + return error + self.width_px = self._slm.width_px() + self.height_px = self._slm.height_px() + self.pixelsize_um = self._slm.pixelsize_um() + return NO_ERROR + + def errorString(self, error): + return self._heds.SDK.ErrorString(error) + + def close(self): + if self._slm is not None: + self._slm.window().close() + self._slm = None + + def showData(self, data): + """Show 8 bits grey levels""" + grey = np.ascontiguousarray(np.clip(np.rint(np.asarray(data, dtype=float)), 0, 255).astype(np.uint8)) + return self._slm.showImageData(grey) + + def showPhasevalues(self, data): + """Show phase values in radians, wrapped in [0, 2pi[""" + phase = np.ascontiguousarray(np.mod(np.asarray(data, dtype=np.float32), 2 * np.pi)) + return self._slm.showPhaseData(phase, phase_unit=2 * np.pi) + + def utilsSLMPreviewShow(self, show=True): + preview = self._slm.preview() + return preview.open() if show else preview.close() diff --git a/src/pymodaq_plugins_holoeye/resources/daq_move_HoloeyeBase.py b/src/pymodaq_plugins_holoeye/resources/daq_move_HoloeyeBase.py index f99f23d..f5ea761 100644 --- a/src/pymodaq_plugins_holoeye/resources/daq_move_HoloeyeBase.py +++ b/src/pymodaq_plugins_holoeye/resources/daq_move_HoloeyeBase.py @@ -17,7 +17,7 @@ from pymodaq_utils.logger import set_logger, get_module_name from pymodaq_utils.math_utils import wrap -from holoeye.slmdisplaysdk import SLMInstance, ErrorCode +from pymodaq_plugins_holoeye.hardware.sdk import SDK_CHOICES, NO_ERROR, get_slm_class logger = set_logger(get_module_name(__file__)) config = HoloConfig() @@ -25,7 +25,7 @@ class DAQ_Move_HoloeyeBase(DAQ_Move_base): - controller_class = SLMInstance + controller_class = None # if None: the class of the SDK selected in the settings shaping_type: str = None shaping_settings: List = [] @@ -39,6 +39,8 @@ class DAQ_Move_HoloeyeBase(DAQ_Move_base): {'title': 'Width:', 'name': 'width', 'type': 'int', 'value': 0, 'readonly': True}, {'title': 'Height:', 'name': 'height', 'type': 'int', 'value': 0, 'readonly': True}, ]}, + {'title': 'SDK version:', 'name': 'sdk', 'type': 'list', 'limits': SDK_CHOICES, 'value': 'auto', + 'tip': 'Holoeye SLM Display SDK to use, auto: the SDK 4 if installed, else the SDK 3'}, {'title': 'Show Preview?:', 'name': 'show_preview', 'type': 'bool', 'value': False}, {'title': 'Shaping type:', 'name': 'shaping_type', 'type': 'str', 'value': ''}, {'title': 'shaping options:', 'name': 'options', 'type': 'group', 'visible': True, @@ -81,14 +83,14 @@ def ini_stage(self, controller=None): daq_utils.ThreadCommand """ if self.is_master: - self.controller = self.controller_class() + self.controller = self.create_controller() else: self.controller = controller if self.is_master: error = self.controller.open() - if error != ErrorCode.NoError: + if error != NO_ERROR: raise IOError(f'SLM Error: {self.controller.errorString(error)}') data_width = self.controller.width_px @@ -103,12 +105,18 @@ def ini_stage(self, controller=None): initialized = True return info, initialized + def create_controller(self): + controller_class = self.controller_class + if controller_class is None: + controller_class = get_slm_class(self.settings['sdk']) + return controller_class() + def commit_settings(self, param): """Apply settings modification to the SLM To be implemented in real implementations """ - if param.name() == 'show_preview': + if param.name() == 'show_preview' and self.controller is not None: self.controller.utilsSLMPreviewShow(param.value()) @property diff --git a/tests/test_sdk_selection.py b/tests/test_sdk_selection.py new file mode 100644 index 0000000..94389dc --- /dev/null +++ b/tests/test_sdk_selection.py @@ -0,0 +1,166 @@ +"""Tests of the selection between the Holoeye SDK 3 and 4, with fake SDK modules (no hardware)""" +import sys +import types + +import numpy as np +import pytest + +from pymodaq.utils.plugin_testing import make_actuator, move_abs_and_wait +from pymodaq.utils.data import DataActuator + +from pymodaq_plugins_holoeye.hardware import sdk +from pymodaq_plugins_holoeye.hardware.sdk4 import SLMInstance4 +from pymodaq_plugins_holoeye.daq_move_plugins.daq_move_Holoeye import DAQ_Move_Holoeye +from pymodaq_plugins_holoeye.daq_move_plugins.daq_move_HoloeyeMock import DAQ_Move_HoloeyeMock + + +class FakeSlm4: + def __init__(self, **kwargs): + self.init_kwargs = kwargs + self.shown = [] + self.closed = False + self.preview_state = [] + + def errorCode(self): + return 0 + + def width_px(self): + return 8 + + def height_px(self): + return 6 + + def pixelsize_um(self): + return 8. + + def showImageData(self, data, flags=None): + self.shown.append((np.asarray(data), None)) + return 0 + + def showPhaseData(self, data, phase_unit=256): + self.shown.append((np.asarray(data), phase_unit)) + return 0 + + def window(self): + slm = self + return types.SimpleNamespace(close=lambda: setattr(slm, 'closed', True) or 0) + + def preview(self): + return types.SimpleNamespace(open=lambda: self.preview_state.append(True) or 0, + close=lambda: self.preview_state.append(False) or 0) + + +@pytest.fixture +def fake_sdk4(monkeypatch): + slms = [] + + def init_with_option(preselect='', openPreview=True, previewScale=1.0): + slms.append(FakeSlm4(preselect=preselect, openPreview=openPreview)) + return slms[-1] + + heds = types.ModuleType('HEDS') + heds.SDK = types.SimpleNamespace(Init=lambda major, minor: 0, ErrorString=lambda error: f'error {error}') + heds.SLM = types.SimpleNamespace(Init=init_with_option) + hedslib = types.ModuleType('hedslib') + heds_types = types.ModuleType('hedslib.heds_types') + heds_types.HEDSERR_NoError = 0 + hedslib.heds_types = heds_types + monkeypatch.setitem(sys.modules, 'HEDS', heds) + monkeypatch.setitem(sys.modules, 'hedslib', hedslib) + monkeypatch.setitem(sys.modules, 'hedslib.heds_types', heds_types) + heds.slms = slms + return heds + + +class TestSelection: + def test_unknown_version(self): + with pytest.raises(ValueError): + sdk.select_sdk('5') + + @pytest.mark.parametrize('installed, requested, expected', [ + (['4', '3'], 'auto', '4'), (['4'], 'auto', '4'), (['3'], 'auto', '3'), + (['3', '4'], '4', '4'), (['3', '4'], '3', '3')]) + def test_choice(self, monkeypatch, installed, requested, expected): + monkeypatch.setattr(sdk, 'installed_sdks', lambda: installed) + assert sdk.select_sdk(requested) == expected + + @pytest.mark.parametrize('installed, requested', [([], 'auto'), (['3'], '4'), (['4'], '3')]) + def test_not_installed(self, monkeypatch, installed, requested): + monkeypatch.setattr(sdk, 'installed_sdks', lambda: installed) + with pytest.raises(ImportError): + sdk.select_sdk(requested) + + def test_sdk4_detected_and_class(self, fake_sdk4): + assert '4' in sdk.installed_sdks() + assert sdk.get_slm_class('4') is SLMInstance4 + + +class TestSdk4Adapter: + def test_open_without_preview_by_default(self, fake_sdk4): + slm = SLMInstance4() + assert slm.open() == 0 + assert fake_sdk4.slms[-1].init_kwargs['openPreview'] is False + assert (slm.width_px, slm.height_px) == (8, 6) + + def test_grey_levels_and_phase(self, fake_sdk4): + slm = SLMInstance4() + slm.open() + slm.showData(np.full((6, 8), 300.4)) + slm.showPhasevalues(np.full((6, 8), 2 * np.pi + 1)) + (grey, grey_unit), (phase, phase_unit) = fake_sdk4.slms[-1].shown + assert grey.dtype == np.uint8 and np.all(grey == 255) and grey_unit is None + assert np.allclose(phase, 1, atol=1e-5) and phase_unit == pytest.approx(2 * np.pi) + + def test_preview_and_close(self, fake_sdk4): + slm = SLMInstance4() + slm.open() + slm.utilsSLMPreviewShow(True) + slm.utilsSLMPreviewShow(False) + fake = fake_sdk4.slms[-1] + assert fake.preview_state == [True, False] + slm.close() + assert fake.closed + + def test_error_string(self, fake_sdk4): + assert SLMInstance4().errorString(3) == 'error 3' + + +def test_plugin_with_sdk4(qapp, fake_sdk4): + plugin = make_actuator(DAQ_Move_Holoeye, sdk='4') + assert plugin.initialized + assert plugin.settings['info', 'width'] == 8 + move_abs_and_wait(plugin, DataActuator(data=[np.zeros((6, 8))])) + assert len(fake_sdk4.slms[-1].shown) == 1 + plugin.close() + assert fake_sdk4.slms[-1].closed + + +def test_plugin_without_any_sdk(qapp, monkeypatch): + monkeypatch.setattr(sdk, 'installed_sdks', lambda: []) + with pytest.raises(ImportError): + make_actuator(DAQ_Move_Holoeye) + + +def test_mock_plugin_still_works(qapp): + plugin = make_actuator(DAQ_Move_HoloeyeMock) + assert plugin.initialized + move_abs_and_wait(plugin, 0.5) + plugin.close() + + +def test_sdk4_folder_from_the_most_recent_variable(tmp_path): + import json + import os + import subprocess + + env = dict(os.environ) + for version in ('4_0', '4_1', '3_0'): + (tmp_path / version / 'api' / 'python').mkdir(parents=True) + (tmp_path / version / 'examples').mkdir() + env[f'HEDS_{version}_PYTHON'] = str(tmp_path / version) + code = 'import sys, json, pymodaq_plugins_holoeye; print(json.dumps(sys.path))' + run = subprocess.run([sys.executable, '-c', code], env=env, cwd=tmp_path, capture_output=True, text=True) + path = json.loads(run.stdout.strip().splitlines()[-1]) + assert str(tmp_path / '4_1' / 'api' / 'python') in path + assert str(tmp_path / '4_1' / 'examples') in path + assert not any(str(tmp_path / v) in p for v in ('4_0', '3_0') for p in path)