Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 20 additions & 5 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
++++++++
Expand Down Expand Up @@ -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**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +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_gui>=5.3.9",
"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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
``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
Expand Down
Original file line number Diff line number Diff line change
@@ -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__)
168 changes: 168 additions & 0 deletions src/pymodaq_plugins_mockexamples/hardware/mock_camera.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading