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
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ camera = [
name = "pymodaq_plugins_utils"
description = 'Set of utility methods and classes to interact with instruments'
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_data>=5.3.0",
#todo: list here all dependencies your package may have
]
Expand Down
130 changes: 107 additions & 23 deletions src/pymodaq_plugins_utils/hardware/camera_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from pymodaq_utils.logger import set_logger, get_module_name
from pymodaq_utils.utils import ThreadCommand
from pymodaq_gui.parameter import Parameter
from pymodaq_gui.utils.widgets import Status
try:
from pymodaq_gui.plotting.items.roi import RoiInfo # pymodaq > 5.1.x
except ImportError:
Expand All @@ -33,13 +34,19 @@
logger = set_logger(get_module_name(__file__))


# States of the LEDs of the ROI buttons (Status workflow): grey when the button is released, blue while the change is
# applied, green when it is, orange on error. They are set by the plugin, the user only presses or releases the button.
ROI_LED_STATES = [Status.OFF, Status.RUNNING, Status.IDLE, Status.ERROR]

cam_params = [
{'title': 'Camera name:', 'name': 'camera_name', 'type': 'str', 'value': '', 'readonly': True},
{'title': 'Color Conversion:', 'name': 'color_conversion', 'type': 'list',
{'title': 'Color Conversion:', 'name': 'color_conversion', 'type': 'list', 'value': 'None',
'limits': ['None', 'RGB2GRAY', 'BAYER_BG2RGB', 'BAYER_BG2GRAY'] if cv2 is not None else ['None']},
{'title': 'ROI', 'name': 'roi', 'type': 'group', 'children': [
{'title': 'Update ROI from Viewer', 'name': 'update_roi', 'type': 'led_push', 'value': False},
{'title': 'Apply ROI', 'name': 'apply_roi', 'type': 'led_push', 'value': False},
{'title': 'Update ROI from Viewer', 'name': 'update_roi', 'type': 'action_led',
'value': Status.OFF.value, 'label': 'Update', 'limits': ROI_LED_STATES, 'checkable': True},
{'title': 'Apply ROI', 'name': 'apply_roi', 'type': 'action_led',
'value': Status.OFF.value, 'label': 'Apply', 'limits': ROI_LED_STATES, 'checkable': True},
{'title': 'Clear ROI+Bin', 'name': 'clear_roi', 'type': 'bool_push', 'value': False},
{'title': 'ROI:', 'name': 'roi_slices', 'type': 'str', 'value': '', 'readonly': True},
{'title': 'X binning', 'name': 'x_binning', 'type': 'int', 'value': 1},
Expand Down Expand Up @@ -137,6 +144,15 @@ class CameraBase(DAQ_Viewer_base):
live_mode_available = True
hardware_averaging = False

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# the activation of the buttons is sent by the DAQ_Viewer to the thread of the plugin (pymodaq >= 5.3.9)
for name, slot in (('update_roi', self._on_update_roi), ('apply_roi', self._on_apply_roi)):
try:
self.settings.child('roi', name).sigActivated.connect(slot)
except KeyError: # a subclass without these parameters
pass

def ini_attributes(self):
self.controller = None
self.callback_thread: QtCore.QThread = None
Expand All @@ -161,41 +177,111 @@ def pixel_size(self) -> Optional[Tuple[float, float]]:
get_pixel_size = getattr(self.controller, 'get_pixel_size', None)
return get_pixel_size() if get_pixel_size is not None else None

def _set_roi_led(self, name: str, status: Status):
"""Show the state of the 'update_roi' or 'apply_roi' LED"""
self.settings.child('roi', name).setValue(status)

def _roi_led_is(self, name: str, status: Status) -> bool:
return self.settings['roi', name] == status

def _fail_roi_button(self, name: str, message: str):
"""The change asked by the button could not be done: orange LED, the button is released"""
self._set_roi_led(name, Status.ERROR)
self.settings.child('roi', name).setOpts(checked=False)
self.emit_status(ThreadCommand('Update_Status', [message, 'log']))

def _on_update_roi(self, param):
"""The user pressed or released 'Update ROI from Viewer'

Pressed: take the ROI now in the viewer (blue LED while it is taken, green once it is, orange if there is
none to take) and follow the ROI of the viewer. Released: stop following it (grey LED).
"""
if not param.opts.get('checked', False):
self._set_roi_led('update_roi', Status.OFF)
return
if self.roi_select_info is None:
self._fail_roi_button('update_roi', 'No ROI selected in the viewer')
return
self._set_roi_led('update_roi', Status.RUNNING)
self.update_roi_from_viewer()
self._set_roi_led('update_roi', Status.IDLE)
if self._roi_led_is('apply_roi', Status.IDLE):
self.apply_roi()

def _on_apply_roi(self, param):
"""The user pressed or released 'Apply ROI'

Pressed: apply the ROI selected in the viewer (the one last taken, or the one now in the viewer if none was
taken), and apply it again when it changes. Released: the camera goes back to the full sensor (grey LED).
"""
if not param.opts.get('checked', False):
self._set_roi_led('apply_roi', Status.RUNNING)
self.release_roi()
self._set_roi_led('apply_roi', Status.OFF)
return
if self.roi_sensor_slices is None and self.roi_select_info is not None:
self.update_roi_from_viewer()
self.apply_roi()

def release_roi(self):
"""Full sensor, with the binning, the ROI selected in the viewer is kept"""
wdet, hdet = self.controller.get_detector_size()
self.update_rois((0, wdet, 0, hdet,
self.settings['roi', 'x_binning'], self.settings['roi', 'y_binning']))

def update_roi_from_viewer(self):
"""Compute the ROI of the sensor, in pixels, from the one last selected in the viewer"""
# the slices are in the values of the axes of the viewer (pixels of the sensor, or um if the pixel size is known).
# They are kept as floats: a truncation to integers would be wrong in um.
rows, columns = self.roi_select_info.to_slices(as_integer=False)
x_size, y_size = self.pixel_size if self.pixel_size is not None else (1, 1)
rows = slice(int(round(rows.start / y_size)), int(round(rows.stop / y_size)))
columns = slice(int(round(columns.start / x_size)), int(round(columns.stop / x_size)))
self.roi_sensor_slices = (rows, columns)
self.settings['roi', 'roi_slices'] = str(list(self.roi_sensor_slices))

def roi_select(self, roi_info: RoiInfo, ind_viewer: int = 0):
""" Automatically called when a user use the RoiSelect ROi from a 2D viewer"""
self.roi_select_info = roi_info
self.roi_select_viewer_index = ind_viewer

if self.settings['roi', 'update_roi']:
rows, columns = roi_info.to_slices() # in the units of the viewer axes
pixel_size = self.pixel_size
if pixel_size is not None: # axes in um, back to pixels of the sensor
rows = slice(int(round(rows.start / pixel_size[1])), int(round(rows.stop / pixel_size[1])))
columns = slice(int(round(columns.start / pixel_size[0])), int(round(columns.stop / pixel_size[0])))
self.roi_sensor_slices = (rows, columns)
self.settings['roi', 'roi_slices'] = str(list(self.roi_sensor_slices))
if self.settings['roi', 'apply_roi']:
if self._roi_led_is('update_roi', Status.IDLE): # the ROI of the viewer is followed
self.update_roi_from_viewer()
if self._roi_led_is('apply_roi', Status.IDLE):
self.apply_roi()

def apply_roi(self):
"""Set the ROI of the camera from the one last selected in the viewer"""
"""Set the ROI of the camera from the one last selected in the viewer

The 'apply_roi' LED is blue while the ROI is applied, then green, or orange if it cannot be applied.
"""
if self.roi_sensor_slices is None:
self._fail_roi_button('apply_roi', 'No ROI selected in the viewer')
return
width, height = self.controller.get_detector_size()
rows, columns = self.roi_sensor_slices
hstart, hend = max(columns.start, 0), min(columns.stop, width)
vstart, vend = max(rows.start, 0), min(rows.stop, height)
if hend <= hstart or vend <= vstart:
self.emit_status(ThreadCommand('Update_Status', ['The ROI is outside of the sensor', 'log']))
self._fail_roi_button('apply_roi', 'The ROI is outside of the sensor')
return
self.update_rois((hstart, hend, vstart, vend,
self.settings['roi', 'x_binning'], self.settings['roi', 'y_binning']))
self._set_roi_led('apply_roi', Status.RUNNING)
try:
self.update_rois((hstart, hend, vstart, vend,
self.settings['roi', 'x_binning'], self.settings['roi', 'y_binning']))
except Exception as exc:
logger.exception(exc)
self._fail_roi_button('apply_roi', f'The ROI could not be applied: {exc}')
return
self._set_roi_led('apply_roi', Status.IDLE)

def compute_axes(self):
"""Axes of the viewer from the current ROI and binning of the camera"""
(hstart, hend, vstart, vend, hbin, vbin) = self.controller.get_roi()
height, width = self.controller.get_data_dimensions()
self.settings.child('roi', 'roi_slices').setValue(str([slice(vstart, vend, vbin), slice(hstart, hend, hbin)]))
self.settings.child('hdet').setValue(int(width))
self.settings.child('vdet').setValue(int(height))

pixel_size = self.pixel_size
x_size, y_size = pixel_size if pixel_size is not None else (1, 1)
Expand All @@ -212,6 +298,10 @@ def clear_roi(self):
self.settings.child('roi', 'y_binning').setValue(1)

self.update_rois((0, wdet, 0, hdet, 1, 1))
self.roi_sensor_slices = None
for name in ('update_roi', 'apply_roi'):
self._set_roi_led(name, Status.OFF)
self.settings.child('roi', name).setOpts(checked=False)

def update_rois(self, new_roi: Tuple[int, int, int, int, int, int]):
"""Set the ROI and binning of the camera if they changed
Expand Down Expand Up @@ -249,12 +339,6 @@ def commit_settings(self, param: Parameter):
elif param.name() == "fps_on":
self.settings.child('timing_opts', 'fps').setOpts(visible=param.value())

elif param.name() == "apply_roi":
if param.value(): # Switching on ROI
self.apply_roi()
else:
self.clear_roi()

elif param.name() in ['x_binning', 'y_binning']:
# We handle ROI and binning separately for clarity
(hstart, hend, vstart, vend, *_) = self.controller.get_roi() # Get current ROI
Expand Down Expand Up @@ -427,7 +511,7 @@ def emit_data(self, frame: np.ndarray=None):
# Emit the frame.
if frame is not None:
conversion_str = self.settings['color_conversion']
if conversion_str != "None" and cv2 is not None:
if conversion_str not in (None, "None") and cv2 is not None:
for ind_average in range(frame.shape[0]):
for ind_frame in range(frame.shape[1]):
if ind_frame == 0 and ind_average == 0:
Expand Down
93 changes: 89 additions & 4 deletions tests/test_camera_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,13 +118,22 @@ def calibrated_camera(qapp):
plugin.close()


def click_led(plugin, name: str, pressed: bool = True):
"""What the user does: press (or release) the button of the parameter, which the DAQ_Viewer sends to the plugin"""
param = plugin.settings.child('roi', name)
param.opts['checked'] = pressed
param.activate()


def select_roi(plugin, rows: slice, columns: slice):
"""What the user does: draw a ROI in the viewer, update it, then apply it"""
plugin.settings.child('roi', 'update_roi').setValue(True)
plugin.roi_select(RoiInfo.from_slices([rows, columns]))
apply_roi = plugin.settings.child('roi', 'apply_roi')
apply_roi.setValue(True)
plugin.commit_settings(apply_roi)
click_led(plugin, 'update_roi')
click_led(plugin, 'apply_roi')


def roi_leds(plugin):
return plugin.settings['roi', 'update_roi'], plugin.settings['roi', 'apply_roi']


def set_binning(plugin, xbin: int, ybin: int):
Expand Down Expand Up @@ -184,6 +193,77 @@ def test_roi_is_kept_inside_the_sensor(self, camera):
select_roi(camera, rows=slice(-5, 15), columns=slice(10, 100))
assert camera.controller.get_roi() == (10, WIDTH, 0, 15, 1, 1)

def test_leds_are_grey_until_activated(self, camera):
assert roi_leds(camera) == ('off', 'off')

def test_leds_are_green_once_the_roi_is_updated_and_applied(self, camera):
select_roi(camera, rows=slice(5, 15), columns=slice(10, 30))
assert roi_leds(camera) == ('idle', 'idle')

def test_updating_takes_the_roi_now_in_the_viewer(self, camera):
camera.roi_select(RoiInfo.from_slices([slice(5, 15), slice(10, 30)])) # drawn before the click
assert camera.roi_sensor_slices is None

click_led(camera, 'update_roi')

assert camera.roi_sensor_slices == (slice(5, 15), slice(10, 30))
assert roi_leds(camera) == ('idle', 'off')
assert camera.controller.get_roi() == (0, WIDTH, 0, HEIGHT, 1, 1) # not applied yet

def test_updating_without_roi_in_the_viewer_is_an_error(self, camera):
click_led(camera, 'update_roi')
assert roi_leds(camera) == ('error', 'off')
assert not camera.settings.child('roi', 'update_roi').opts['checked'] # the button is released

def test_releasing_update_stops_following_the_viewer(self, camera):
select_roi(camera, rows=slice(5, 15), columns=slice(10, 30))
click_led(camera, 'update_roi', pressed=False)
assert roi_leds(camera) == ('off', 'idle')

camera.roi_select(RoiInfo.from_slices([slice(0, 10), slice(0, 10)]))
assert camera.controller.get_roi() == (10, 30, 5, 15, 1, 1)

def test_releasing_apply_goes_back_to_the_full_sensor(self, camera):
select_roi(camera, rows=slice(5, 15), columns=slice(10, 30))
click_led(camera, 'apply_roi', pressed=False)
assert roi_leds(camera) == ('idle', 'off')
assert camera.controller.get_roi() == (0, WIDTH, 0, HEIGHT, 1, 1)
assert camera.settings['hdet'] == WIDTH
assert camera.roi_sensor_slices == (slice(5, 15), slice(10, 30)) # applying again gives the same ROI

click_led(camera, 'apply_roi')
assert camera.controller.get_roi() == (10, 30, 5, 15, 1, 1)
assert roi_leds(camera) == ('idle', 'idle')

def test_applying_computes_the_image_size(self, camera):
select_roi(camera, rows=slice(5, 15), columns=slice(10, 30))
assert camera.settings['hdet'] == 20
assert camera.settings['vdet'] == 10

def test_applying_a_roi_outside_the_sensor_is_an_error(self, camera):
select_roi(camera, rows=slice(5, 15), columns=slice(WIDTH + 10, WIDTH + 30))
assert roi_leds(camera)[1] == 'error'
assert camera.controller.get_roi() == (0, WIDTH, 0, HEIGHT, 1, 1)

def test_applying_with_a_failing_camera_is_an_error(self, camera):
camera.controller.set_roi = lambda **kwargs: (_ for _ in ()).throw(RuntimeError('refused'))
camera.roi_select(RoiInfo.from_slices([slice(5, 15), slice(10, 30)]))
click_led(camera, 'apply_roi')
assert roi_leds(camera)[1] == 'error'

def test_applied_roi_follows_the_viewer_until_cleared(self, camera):
select_roi(camera, rows=slice(5, 15), columns=slice(10, 30))

camera.roi_select(RoiInfo.from_slices([slice(0, 10), slice(0, 10)]))
assert camera.controller.get_roi() == (0, 10, 0, 10, 1, 1)

clear = camera.settings.child('roi', 'clear_roi')
clear.setValue(True)
camera.commit_settings(clear)
assert roi_leds(camera) == ('off', 'off')
camera.roi_select(RoiInfo.from_slices([slice(0, 5), slice(0, 5)]))
assert camera.controller.get_roi() == (0, WIDTH, 0, HEIGHT, 1, 1)

def test_changing_the_binning_keeps_the_roi(self, camera):
select_roi(camera, rows=slice(5, 15), columns=slice(10, 30))
set_binning(camera, 2, 5)
Expand Down Expand Up @@ -231,6 +311,11 @@ def test_axes_are_calibrated(self, calibrated_camera):
assert calibrated_camera.y_axis.scaling == pytest.approx(10.0)
assert dte[0].shape == (HEIGHT, WIDTH)

def test_roi_with_fractional_values_is_rounded_in_pixels_not_truncated_in_um(self, calibrated_camera):
# 5 um x 10 um pixels: 52.4 -> 10.48 and 157.6 -> 31.52 pixels (columns), 53 -> 5.3 and 147 -> 14.7 (rows)
select_roi(calibrated_camera, rows=slice(53.0, 147.0), columns=slice(52.4, 157.6))
assert calibrated_camera.controller.get_roi() == (10, 32, 5, 15, 1, 1)

def test_roi_drawn_on_calibrated_axes_is_converted_to_pixels(self, calibrated_camera):
select_roi(calibrated_camera, rows=slice(50, 150), columns=slice(50, 150)) # um: 5 to 15 rows, 10 to 30 cols
assert calibrated_camera.controller.get_roi() == (10, 30, 5, 15, 1, 1)
Expand Down
Loading