From 16b3d3b95de7de6d915998f702f35afa447189fc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 07:48:54 +0000 Subject: [PATCH 1/5] Cookbook camera recipe on the camera base class of pymodaq_plugins_utils CookbookCamera is the camera plugin written on CameraBase: it only creates its driver, the base class does the acquisition thread, the ROI drawn in the viewer applied by the camera, the binning, the exposure and the axes in um. The driver, MockCamera, follows CameraDriverBase (the methods of the pylablib cameras) and has its own thread and buffer like a real one, so it can be copied to write the driver of a camera that pylablib does not drive. The package depends on pymodaq_plugins_utils (the release that has CameraBase). The README sentence that said each recipe is an actuator and a 0D detector is corrected. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Tdiso1u1ZhikSutj2Qs5Cg --- CONTEXT.md | 6 +- README.rst | 25 ++- pyproject.toml | 1 + .../plugins_2D/daq_2Dviewer_CookbookCamera.py | 43 +++++ .../hardware/mock_camera.py | 168 +++++++++++++++++ tests/test_cookbook_camera.py | 175 ++++++++++++++++++ 6 files changed, 410 insertions(+), 8 deletions(-) create mode 100644 src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_2D/daq_2Dviewer_CookbookCamera.py create mode 100644 src/pymodaq_plugins_mockexamples/hardware/mock_camera.py create mode 100644 tests/test_cookbook_camera.py diff --git a/CONTEXT.md b/CONTEXT.md index 89003d1..583fcae 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -13,9 +13,9 @@ A set of mock instruments that simulate one experiment together (beam steering, setup has an experiment file in `resources/`. **Recipe**: -A cookbook example of a device pattern: plugins (an actuator and a detector, 0D or 1D) with their driver in -`hardware/`, that run on a mock device and are meant to be copied and adapted. The camera recipe is the exception: it -is an existing mock plugin (`BSCamera`) completed to show the pattern. +A cookbook example of a device pattern: plugins (an actuator and a detector, 0D, 1D or 2D) with their driver in +`hardware/`, that run on a mock device and are meant to be copied and adapted. The `BSCamera` recipe is the exception: +it is an existing mock plugin completed to show the pattern. **Hardware ROI**: The region of the sensor read and binned by the camera itself, in sensor pixels; the image is smaller. Opposed to the diff --git a/README.rst b/README.rst index fe4bb5a..ef73fd8 100644 --- a/README.rst +++ b/README.rst @@ -79,6 +79,7 @@ Viewer2D * **BSCamera** (`beam steering`_): simulates a camera imaging a laser spot, with fringes and drift; shows live acquisition, several data and a shared controller. * **RoiStuff** (`beam steering`_): simulates the beam steering camera cropped to a ROI; shows roi_select receiving the ROI from the viewer. +* **CookbookCamera** (`camera driver`_): simulates a camera with its own acquisition thread and buffer; shows the camera base class of pymodaq_plugins_utils and its driver contract. ViewerND ++++++++ @@ -144,6 +145,13 @@ plugin to stop it), they load in the Dashboard of PyMoDAQ 5.3. - actuators *MockComplexSignal*; 0D *MockComplexSignal*, *MockAdaptive* - none - Scans in one or two dimensions, the ``positions`` argument of an adaptive scan + * - .. _camera driver: + + **Camera with its own thread** + + - 2D *CookbookCamera* + - none + - A camera driver whose thread fills a buffer, with the ROI drawn in the viewer applied by the camera * - .. _spectrometer: **Spectrometer with a calibrated axis** @@ -176,12 +184,12 @@ plugin to stop it), they load in the Dashboard of PyMoDAQ 5.3. Cookbook of device patterns =========================== -Short, runnable and tested plugins to copy from when writing a plugin for a real instrument. Each recipe is a pair -(actuator and 0D detector) that runs without hardware: leave the port or the address to ``MOCK`` and the plugin talks -to a simulated device: a stage with a voltmeter (``hardware/mock_serial_visa_instrument.py``) or a 3-axis controller -behind a library (``hardware/mock_dll_controller.py``). Each file starts with a header telling what to +Short, runnable and tested plugins to copy from when writing a plugin for a real instrument. Each recipe runs without +hardware: leave the port or the address to ``MOCK`` and the plugin talks to a simulated device: a stage with a voltmeter +(``hardware/mock_serial_visa_instrument.py``), a 3-axis controller behind a library (``hardware/mock_dll_controller.py``), +a camera (``hardware/mock_camera.py``)... Each file starts with a header telling what to copy, what to adapt and what to keep. The tests (``tests/test_cookbook_drivers.py``, ``tests/test_cookbook_plugins.py``, -``tests/test_cookbook_dll.py``, ``tests/test_cookbook_dotnet.py``, ``tests/test_cookbook_spectrometer.py``, ``tests/test_bs_camera.py``) show how to test a +``tests/test_cookbook_dll.py``, ``tests/test_cookbook_dotnet.py``, ``tests/test_cookbook_spectrometer.py``, ``tests/test_cookbook_camera.py``, ``tests/test_bs_camera.py``) show how to test a plugin against a simulated device with the harness of ``pymodaq.utils.plugin_testing``. Giving the same *controller ID* to an actuator and a detector of a recipe makes them share one driver: the first @@ -215,6 +223,13 @@ done by the camera ``hardware/beam_steering.py`` scales the signal, axes in um (pixel size) that follow the ROI and the binning. ``RoiStuff`` is the other way: the ROI is drawn in the viewer and the plugin crops after the grab +Camera, on the camera base class of ``daq_2Dviewer_CookbookCamera``, A camera driver that follows ``CameraDriverBase`` (the methods +``pymodaq_plugins_utils`` ``hardware/mock_camera.py`` of the pylablib cameras) behind ``CameraBase``, which does the + rest: a thread that waits for the frames, snap and live, the ROI + drawn in the viewer applied by the camera, binning, exposure, + FPS and axes in um. The simulated camera has its own thread and + buffer, as a real one. Copy ``mock_camera.py`` to write the driver + of a camera that pylablib does not drive Spectrometer, calibrated axis that ``daq_1Dviewer_CookbookSpectrometer``, A wavelength axis in nm asked to the instrument at every grab changes, actuator slave of a detector ``daq_move_CookbookSpectrometer``, because the grating moves it. The actuator (central wavelength) ``hardware/spectrometer.py`` and the detector share one instrument; a homing that waits for diff --git a/pyproject.toml b/pyproject.toml index 916b5d9..f3a45c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "pymodaq>=5.3.2", "pymodaq_utils>=5.3.1", "pymodaq_gui>=5.3.0", + "pymodaq_plugins_utils>=5.3.1", # CameraBase and the camera driver contract of the camera recipe "pymodaq_data>=5.3.0", "pillow", "pymeasure>=0.14", # also brings pyvisa and pyserial: needed by the cookbook recipes of the hardware folder diff --git a/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_2D/daq_2Dviewer_CookbookCamera.py b/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_2D/daq_2Dviewer_CookbookCamera.py new file mode 100644 index 0000000..43774d7 --- /dev/null +++ b/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_2D/daq_2Dviewer_CookbookCamera.py @@ -0,0 +1,43 @@ +"""Cookbook recipe 3b: 2D detector for a camera, on the camera base class of ``pymodaq_plugins_utils``. + +COPY this file for a camera. It is short because ``CameraBase`` does the work: acquisition in a thread of its own that +waits for the frames of the camera, snap and live modes, averaging, the ROI drawn in the viewer applied by the camera +("Update ROI from Viewer", "Apply ROI", "Clear ROI+Bin"), the binning, the exposure time, the FPS, the colour conversion +and the axes of the viewer (in um if the driver knows the pixel size, else in pixels). +ADAPT: ``ini_detector_custom``, which creates the camera driver. The driver follows ``CameraDriverBase`` +(``pymodaq_plugins_utils.hardware.camera_driver``), whose methods are those of the pylablib cameras: use one of them +as it is (see ``CameraBasePyLabLib`` for those) or write yours, as ``hardware/mock_camera.py`` does for a simulated one. +Add the settings of your camera to ``params`` and handle them in ``commit_settings`` after calling the one of the base. +KEEP: +- the master/slave handling in ``ini_detector_custom``: alone the plugin owns the camera, with the same controller ID + as another module it uses the camera of the master; +- ``params = comon_parameters + cam_params``: the settings ``CameraBase`` reads (exposure, ROI, binning, buffer...). + +``BSCamera`` is the other camera of the package: there the plugin itself does the readout of a simulated sensor, which +shows the ROI, the binning and the calibrated axes without a base class. +""" +from pymodaq.control_modules.viewer_utility_classes import comon_parameters, main + +from pymodaq_plugins_utils.hardware.camera_base import CameraBase, cam_params + +from pymodaq_plugins_mockexamples.hardware.mock_camera import MockCamera + + +class DAQ_2DViewer_CookbookCamera(CameraBase): + """A simulated 256 x 256 camera with 5.5 um pixels, looking at a Gaussian spot + + The exposure time sets the frame rate (at least 10 ms) and the signal. Draw a ROI in the viewer, click "Update ROI + from Viewer" then "Apply ROI" to read only that part of the sensor; change the binning to sum pixels. + """ + params = comon_parameters + cam_params + + def ini_detector_custom(self, controller=None): + """Create the camera (master) or take the one of the master (slave)""" + if self.is_master: + self.controller = MockCamera() + else: + self.controller = controller + + +if __name__ == '__main__': + main(__file__) diff --git a/src/pymodaq_plugins_mockexamples/hardware/mock_camera.py b/src/pymodaq_plugins_mockexamples/hardware/mock_camera.py new file mode 100644 index 0000000..e400821 --- /dev/null +++ b/src/pymodaq_plugins_mockexamples/hardware/mock_camera.py @@ -0,0 +1,168 @@ +"""A simulated camera that behaves like the cameras of pylablib: images arrive in a buffer filled by its own thread. + +It is the driver of the cookbook camera recipe (``DAQ_2DViewer_CookbookCamera``). The methods are those listed in +``pymodaq_plugins_utils.hardware.camera_driver.CameraDriverBase``, and the semantics those of a real driver: + +- ``set_roi`` and ``set_exposure`` act on the next images, ``setup_acquisition`` makes the buffer, ``start_acquisition`` + starts the thread that "exposes" the sensor and fills the buffer, ``clear_acquisition`` stops it; +- ``wait_for_frame`` blocks (with a timeout) until new frames are in the buffer, ``read_multiple_images`` and + ``read_newest_image`` read them. The plugin calls them from its own thread, never from the thread of the camera. + +COPY this file as the starting point of the driver of a camera that pylablib does not drive: inherit from +``CameraDriverBase``, replace ``_expose`` and the thread by the calls of the vendor library (or its callback that tells +a frame is ready) and keep the buffer, the counters and ``wait_for_frame``. +""" +import threading +import time +from collections import deque +from typing import Optional, Tuple + +import numpy as np + +from pymodaq_plugins_utils.hardware.camera_driver import CameraDriverBase + +from pymodaq_plugins_mockexamples.hardware.beam_steering import Camera as Sensor + + +class MockCamera(CameraDriverBase): + """A 256 x 256 camera with 5.5 um pixels, 16 bits images, looking at a Gaussian spot""" + gain = 100. # counts per unit of the signal of the sensor + buffer_size = 20 + min_frame_period = 0.01 # s, the readout time + + def __init__(self): + super().__init__() + self._sensor = Sensor() + self._width, self._height = Sensor.Nx, Sensor.Ny + self._roi = (0, self._width, 0, self._height, 1, 1) + self._exposure = 0.1 # s + self._rng = np.random.default_rng() + + self._buffer: deque = deque(maxlen=self.buffer_size) + self._condition = threading.Condition() + self._n_acquired = 0 # frames made since the start + self._last_read = 0 # index of the first frame not read yet + self._last_wait = 0 # value of ``_n_acquired`` at the end of the last wait + self._thread: Optional[threading.Thread] = None + self._running = threading.Event() + + # --- sensor ---------------------------------------------------------------------------------------------------- + + def get_detector_size(self) -> Tuple[int, int]: + return self._width, self._height + + def get_data_dimensions(self) -> Tuple[int, int]: + hstart, hend, vstart, vend, hbin, vbin = self._roi + return (vend - vstart) // vbin, (hend - hstart) // hbin + + def get_roi(self) -> Tuple[int, int, int, int, int, int]: + return self._roi + + def set_roi(self, hstart=0, hend=None, vstart=0, vend=None, hbin=1, vbin=1): + hend = self._width if hend is None else hend + vend = self._height if vend is None else vend + if not (0 <= hstart < hend <= self._width and 0 <= vstart < vend <= self._height): + raise ValueError(f'The ROI {(hstart, hend, vstart, vend)} is not inside the {self._width}x{self._height} ' + f'sensor') + if hbin < 1 or vbin < 1 or hend - hstart < hbin or vend - vstart < vbin: + raise ValueError(f'The binning {(hbin, vbin)} does not fit in the ROI') + if self.acquisition_in_progress(): + raise RuntimeError('Stop the acquisition before changing the ROI') # as most real cameras + self._roi = (hstart, hend, vstart, vend, hbin, vbin) + + def set_exposure(self, exposure: float): + self._exposure = max(float(exposure), 0.) + + def get_pixel_size(self) -> Tuple[float, float]: + return self._sensor.pixel_size, self._sensor.pixel_size + + def get_device_info(self): + from types import SimpleNamespace + return SimpleNamespace(name='Mock camera (cookbook)') + + # --- acquisition ----------------------------------------------------------------------------------------------- + + def setup_acquisition(self): + """Make the buffer; the ROI cannot change while it exists, as for most cameras""" + with self._condition: + self._buffer = deque(maxlen=self.buffer_size) + self._n_acquired = self._last_read = self._last_wait = 0 + + def clear_acquisition(self): + """Stop the thread of the camera and empty the buffer""" + self._running.clear() + if self._thread is not None and self._thread is not threading.current_thread(): + self._thread.join() + self._thread = None + self.setup_acquisition() + + def start_acquisition(self, nframes: int = 1): + if self.acquisition_in_progress(): + return + self.setup_acquisition() + self._running.set() + self._thread = threading.Thread(target=self._acquire, name='mock camera', daemon=True) + self._thread.start() + + def acquisition_in_progress(self) -> bool: + return self._running.is_set() + + def _acquire(self): + """Thread of the camera: one frame every exposure time, put in the buffer""" + while self._running.is_set(): + time.sleep(max(self._exposure, self.min_frame_period)) + frame = self._expose() + with self._condition: + if not self._running.is_set(): + break + self._buffer.append(frame) + self._n_acquired += 1 + self._condition.notify_all() + + def _expose(self) -> np.ndarray: + """What the sensor gives for one exposure: the ROI, binned, in counts with shot noise""" + hstart, hend, vstart, vend, hbin, vbin = self._roi + height, width = self.get_data_dimensions() + image = self._sensor.get_sensor_image(0, 0) * self._exposure * 1000 / self._sensor.reference_exposure_ms + image = image[vstart:vstart + height * vbin, hstart:hstart + width * hbin] + image = image.reshape(height, vbin, width, hbin).sum(axis=(1, 3)) + return np.clip(self._rng.poisson(image * self.gain), 0, 2 ** 16 - 1).astype(np.uint16) + + def wait_for_frame(self, since: str = 'lastread', nframes: int = 1, timeout: float = 20.0): + with self._condition: + if since == 'now': + target = self._n_acquired + nframes + elif since == 'lastwait': + target = self._last_wait + nframes + elif since == 'start': + target = nframes + else: # 'lastread' + target = self._last_read + nframes + if not self._condition.wait_for(lambda: self._n_acquired >= target or not self._running.is_set(), + timeout=timeout): + raise TimeoutError('Timeout while waiting for a frame') + self._last_wait = self._n_acquired # also returns, without a new frame, if the acquisition is stopped + + def read_multiple_images(self, rng=None, peek: bool = False, missing_frame: str = 'skip', + return_rng: bool = False): + """The frames not read yet, as an array (nframes, height, width); older ones may have left the buffer""" + with self._condition: + first = max(self._last_read, self._n_acquired - len(self._buffer)) + frames = list(self._buffer)[first - (self._n_acquired - len(self._buffer)):] + if not peek: + self._last_read = self._n_acquired + last = self._n_acquired + height, width = self.get_data_dimensions() + array = np.array(frames) if frames else np.zeros((0, height, width), dtype=np.uint16) + return (array, (first, last)) if return_rng else array + + def read_newest_image(self, peek: bool = False) -> Optional[np.ndarray]: + with self._condition: + if not self._buffer: + return None + if not peek: + self._last_read = self._n_acquired + return self._buffer[-1] + + def close(self): + self.clear_acquisition() diff --git a/tests/test_cookbook_camera.py b/tests/test_cookbook_camera.py new file mode 100644 index 0000000..3e39e25 --- /dev/null +++ b/tests/test_cookbook_camera.py @@ -0,0 +1,175 @@ +"""The camera recipe: a camera driver with its own thread behind the camera base class of pymodaq_plugins_utils""" +import time + +import numpy as np +import pytest + +from pymodaq.utils.plugin_testing import make_detector, grab_and_wait, wait_for_signal + +from pymodaq_gui.plotting.items.roi import RoiInfo + +from pymodaq_plugins_utils.hardware.camera_driver import CameraDriverBase + +from pymodaq_plugins_mockexamples.hardware.mock_camera import MockCamera +from pymodaq_plugins_mockexamples.daq_viewer_plugins.plugins_2D.daq_2Dviewer_CookbookCamera import ( + DAQ_2DViewer_CookbookCamera) + + +@pytest.fixture +def driver(): + camera = MockCamera() + camera.set_exposure(0.02) + yield camera + camera.close() + + +@pytest.fixture +def camera(qapp): + plugin = make_detector(DAQ_2DViewer_CookbookCamera) + plugin.settings.child('timing_opts', 'exposure_time').setValue(20) + plugin.commit_settings(plugin.settings.child('timing_opts', 'exposure_time')) + yield plugin + plugin.close() + + +def apply_roi(plugin, rows_um: slice, columns_um: slice): + plugin.settings.child('roi', 'update_roi').setValue(True) + plugin.roi_select(RoiInfo.from_slices([rows_um, columns_um])) + param = plugin.settings.child('roi', 'apply_roi') + param.setValue(True) + plugin.commit_settings(param) + + +class TestMockCamera: + def test_follows_the_driver_contract(self, driver): + assert isinstance(driver, CameraDriverBase) + not_implemented = [name for name in dir(CameraDriverBase) + if not name.startswith('_') and getattr(MockCamera, name) is getattr(CameraDriverBase, name) + and name not in ('get_roi_limits', 'set_frame_format')] + assert not_implemented == [] + + def test_frames_arrive_in_the_buffer_of_its_own_thread(self, driver): + driver.setup_acquisition() + driver.start_acquisition() + assert driver.acquisition_in_progress() + driver.wait_for_frame(since='start', nframes=3, timeout=2) + frames, (first, last) = driver.read_multiple_images(return_rng=True) + assert frames.dtype == np.uint16 + assert frames.shape[1:] == (256, 256) + assert last - first == len(frames) >= 3 + assert driver.read_multiple_images().shape[0] <= 1 # what was read is not given again + + def test_wait_times_out_when_nothing_is_acquired(self, driver): + driver.set_exposure(1) + driver.setup_acquisition() + driver.start_acquisition() + with pytest.raises(TimeoutError): + driver.wait_for_frame(since='now', timeout=0.05) + + def test_stopping_releases_a_waiting_thread(self, driver): + driver.setup_acquisition() + driver.start_acquisition() + driver.clear_acquisition() + t0 = time.perf_counter() + driver.wait_for_frame(since='now', timeout=2) # returns at once, the acquisition is stopped + assert time.perf_counter() - t0 < 0.5 + assert not driver.acquisition_in_progress() + + def test_roi_and_binning_make_a_smaller_image(self, driver): + driver.set_roi(hstart=10, hend=110, vstart=20, vend=60, hbin=2, vbin=4) + assert driver.get_data_dimensions() == (10, 50) + driver.setup_acquisition() + driver.start_acquisition() + driver.wait_for_frame(since='start', timeout=2) + assert driver.read_newest_image().shape == (10, 50) + + def test_wrong_roi_is_refused(self, driver): + with pytest.raises(ValueError): + driver.set_roi(hstart=0, hend=300) + with pytest.raises(ValueError): + driver.set_roi(hstart=0, hend=10, hbin=20) + + def test_roi_cannot_change_during_the_acquisition(self, driver): + driver.setup_acquisition() + driver.start_acquisition() + with pytest.raises(RuntimeError): + driver.set_roi(hstart=10, hend=20, vstart=10, vend=20) + + def test_signal_is_proportional_to_the_exposure(self, driver): + means = [] + for exposure in (0.02, 0.04): + driver.clear_acquisition() + driver.set_exposure(exposure) + driver.setup_acquisition() + driver.start_acquisition() + driver.wait_for_frame(since='start', timeout=2) + means.append(driver.read_newest_image().astype(float).mean()) + assert means[1] == pytest.approx(2 * means[0], rel=0.1) + + +class TestPlugin: + def test_snap(self, camera): + dte = grab_and_wait(camera) + assert dte[0].shape == (256, 256) + assert dte[0][0].max() > 100 + + def test_average_is_made_by_the_plugin(self, camera): + dte = grab_and_wait(camera, naverage=3) + assert dte[0].shape == (256, 256) + + def test_axes_are_in_um(self, camera): + dte = grab_and_wait(camera) + assert camera.x_axis.units == 'µm' + assert camera.x_axis.scaling == pytest.approx(5.5) + assert camera.settings['camera_name'] == 'Mock camera (cookbook)' + + def test_live_gives_images_until_stopped(self, camera): + received = [] + camera.dte_signal.connect(received.append) + wait_for_signal(camera.dte_signal, lambda: camera.grab_data(1, live=True), timeout_ms=3000) + wait_for_signal(camera.dte_signal, lambda: None, timeout_ms=3000) + camera.stop() + assert len(received) >= 2 + assert not camera.controller.acquisition_in_progress() + + def test_roi_drawn_in_the_viewer_is_applied_by_the_camera(self, camera): + apply_roi(camera, rows_um=slice(110, 220), columns_um=slice(55, 165)) # um: pixels 20 to 40 and 10 to 30 + assert camera.controller.get_roi() == (10, 30, 20, 40, 1, 1) + dte = grab_and_wait(camera) + assert dte[0].shape == (20, 20) + assert camera.x_axis.offset == pytest.approx(10 * 5.5) + + def test_binning_keeps_the_roi_and_scales_the_axes(self, camera): + apply_roi(camera, rows_um=slice(110, 220), columns_um=slice(55, 165)) + for name in ('x_binning', 'y_binning'): + camera.settings.child('roi', name).setValue(2) + camera.commit_settings(camera.settings.child('roi', name)) + assert camera.controller.get_roi() == (10, 30, 20, 40, 2, 2) + dte = grab_and_wait(camera) + assert dte[0].shape == (10, 10) + assert camera.x_axis.scaling == pytest.approx(11.) + + def test_clear_roi(self, camera): + apply_roi(camera, rows_um=slice(110, 220), columns_um=slice(55, 165)) + clear = camera.settings.child('roi', 'clear_roi') + clear.setValue(True) + camera.commit_settings(clear) + assert camera.controller.get_roi() == (0, 256, 0, 256, 1, 1) + + def test_close_stops_the_thread_of_the_camera(self, qapp): + plugin = make_detector(DAQ_2DViewer_CookbookCamera) + grab_and_wait(plugin) + controller = plugin.controller + plugin.close() + assert not controller.acquisition_in_progress() + + def test_roi_can_change_during_the_live_and_the_plugin_still_closes(self, qapp): + plugin = make_detector(DAQ_2DViewer_CookbookCamera) + try: + plugin.settings.child('timing_opts', 'exposure_time').setValue(20) + plugin.commit_settings(plugin.settings.child('timing_opts', 'exposure_time')) + wait_for_signal(plugin.dte_signal, lambda: plugin.grab_data(1, live=True), timeout_ms=3000) + apply_roi(plugin, rows_um=slice(110, 220), columns_um=slice(55, 165)) + assert plugin.controller.get_roi() == (10, 30, 20, 40, 1, 1) + finally: + plugin.close() # would block if the thread waiting for the frames never ended From 7f1208e9849dca3a5cbdda1f126b1b6dcb7a0135 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 15:49:50 +0000 Subject: [PATCH 2/5] Require utils 5.3.2 and pymodaq 5.3.9, press the checkable ROI buttons in the tests Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Tdiso1u1ZhikSutj2Qs5Cg --- pyproject.toml | 6 +++--- tests/test_cookbook_camera.py | 13 +++++++++---- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f3a45c1..279b67e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,10 +12,10 @@ package-url = 'https://github.com/PyMoDAQ/pymodaq_plugins_mockexamples' name = "pymodaq_plugins_mockexamples" description = 'Set of advanced Mock examples to test PyMoDAQ functionnalities' dependencies = [ - "pymodaq>=5.3.2", + "pymodaq>=5.3.9", "pymodaq_utils>=5.3.1", - "pymodaq_gui>=5.3.0", - "pymodaq_plugins_utils>=5.3.1", # CameraBase and the camera driver contract of the camera recipe + "pymodaq_gui>=5.3.9", + "pymodaq_plugins_utils>=5.3.2", # CameraBase and the camera driver contract of the camera recipe "pymodaq_data>=5.3.0", "pillow", "pymeasure>=0.14", # also brings pyvisa and pyserial: needed by the cookbook recipes of the hardware folder diff --git a/tests/test_cookbook_camera.py b/tests/test_cookbook_camera.py index 3e39e25..a0d70b4 100644 --- a/tests/test_cookbook_camera.py +++ b/tests/test_cookbook_camera.py @@ -32,12 +32,17 @@ def camera(qapp): plugin.close() +def click_led(plugin, name: str): + """What the user does: press the (checkable) button of the parameter, which the DAQ_Viewer sends to the plugin""" + param = plugin.settings.child('roi', name) + param.opts['checked'] = True + param.activate() + + def apply_roi(plugin, rows_um: slice, columns_um: slice): - plugin.settings.child('roi', 'update_roi').setValue(True) plugin.roi_select(RoiInfo.from_slices([rows_um, columns_um])) - param = plugin.settings.child('roi', 'apply_roi') - param.setValue(True) - plugin.commit_settings(param) + click_led(plugin, 'update_roi') + click_led(plugin, 'apply_roi') class TestMockCamera: From 37e99ddf36d28cc4b563ea791a6baff7f73873e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 16:11:49 +0000 Subject: [PATCH 3/5] BSCamera: say that the CameraBase recipe is CookbookCamera Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Tdiso1u1ZhikSutj2Qs5Cg --- .../daq_viewer_plugins/plugins_2D/daq_2Dviewer_BSCamera.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_2D/daq_2Dviewer_BSCamera.py b/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_2D/daq_2Dviewer_BSCamera.py index 7f546e1..30819be 100644 --- a/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_2D/daq_2Dviewer_BSCamera.py +++ b/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_2D/daq_2Dviewer_BSCamera.py @@ -14,9 +14,9 @@ class DAQ_2DViewer_BSCamera(DAQ_Viewer_base): """ Generate a 2D image with a spot on it whose position can be set using DAQ_Move_BSPiezoMirror - It is also the camera recipe of the cookbook. It shows what a camera driver gives to its plugin: a region of - interest (ROI) and a binning applied by the camera itself, an exposure time that scales the signal, and axes - calibrated in um (the pixel size of the sensor) that follow the ROI and the binning. Compare with + It is a plain ``DAQ_Viewer_base`` plugin: the ROI, the binning and the exposure time below are settings of its own, + applied by its simulated camera. The camera recipe of the cookbook, built on ``CameraBase`` (settings + ``comon_parameters + cam_params``), is ``DAQ_2DViewer_CookbookCamera``. Compare also with ``DAQ_2DViewer_RoiStuff``, where the ROI is drawn in the viewer and applied by the plugin after the grab. """ live_mode_available = True From 75f2ce19bee917eb707d426e99e357ff864825a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 16:13:57 +0000 Subject: [PATCH 4/5] BSCamera: explain why it does not follow the shared camera pattern Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Tdiso1u1ZhikSutj2Qs5Cg --- .../plugins_2D/daq_2Dviewer_BSCamera.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_2D/daq_2Dviewer_BSCamera.py b/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_2D/daq_2Dviewer_BSCamera.py index 30819be..0637605 100644 --- a/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_2D/daq_2Dviewer_BSCamera.py +++ b/src/pymodaq_plugins_mockexamples/daq_viewer_plugins/plugins_2D/daq_2Dviewer_BSCamera.py @@ -14,10 +14,14 @@ class DAQ_2DViewer_BSCamera(DAQ_Viewer_base): """ Generate a 2D image with a spot on it whose position can be set using DAQ_Move_BSPiezoMirror - It is a plain ``DAQ_Viewer_base`` plugin: the ROI, the binning and the exposure time below are settings of its own, - applied by its simulated camera. The camera recipe of the cookbook, built on ``CameraBase`` (settings - ``comon_parameters + cam_params``), is ``DAQ_2DViewer_CookbookCamera``. Compare also with - ``DAQ_2DViewer_RoiStuff``, where the ROI is drawn in the viewer and applied by the plugin after the grab. + It is a plain ``DAQ_Viewer_base`` plugin on purpose, it does not follow the shared pattern of + ``pymodaq_plugins_utils`` (``CameraBase``, settings ``comon_parameters + cam_params``). It is the camera of the + beam steering demonstrations: its settings (beam, drift, sensor, ROI, binning, exposure) are those the demo + experiments (``beam_steering.xml``) use, and it must stay short and self-contained to demonstrate the features + of PyMoDAQ rather than a camera driver. The ROI, the binning and the exposure time are settings of its own, + applied by its simulated camera. The camera recipe of the cookbook, to copy for a real camera, is + ``DAQ_2DViewer_CookbookCamera``. Compare also with ``DAQ_2DViewer_RoiStuff``, where the ROI is drawn in the + viewer and applied by the plugin after the grab. """ live_mode_available = True hardware_averaging = True From 1c5a509fb6b3ad544fb1faeb7a6f1d9f2c8b64b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 21:12:24 +0000 Subject: [PATCH 5/5] Require pymodaq_plugins_utils 5.3.3 (several frames kept by the camera no longer give a DataND) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Tdiso1u1ZhikSutj2Qs5Cg --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 279b67e..4d42cbd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ "pymodaq>=5.3.9", "pymodaq_utils>=5.3.1", "pymodaq_gui>=5.3.9", - "pymodaq_plugins_utils>=5.3.2", # CameraBase and the camera driver contract of the camera recipe + "pymodaq_plugins_utils>=5.3.3", # CameraBase and the camera driver contract of the camera recipe "pymodaq_data>=5.3.0", "pillow", "pymeasure>=0.14", # also brings pyvisa and pyserial: needed by the cookbook recipes of the hardware folder