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..4d42cbd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 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..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 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 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..a0d70b4 --- /dev/null +++ b/tests/test_cookbook_camera.py @@ -0,0 +1,180 @@ +"""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 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.roi_select(RoiInfo.from_slices([rows_um, columns_um])) + click_led(plugin, 'update_roi') + click_led(plugin, 'apply_roi') + + +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