From cdeefce5374a928608113d6fa060c9057d8fb2f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 10:58:06 +0000 Subject: [PATCH 1/6] CameraBase: ROI buttons with a LED (grey, blue, green, orange) and image size updated Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Tdiso1u1ZhikSutj2Qs5Cg --- .../hardware/camera_base.py | 106 ++++++++++++++---- tests/test_camera_base.py | 65 ++++++++++- 2 files changed, 145 insertions(+), 26 deletions(-) diff --git a/src/pymodaq_plugins_utils/hardware/camera_base.py b/src/pymodaq_plugins_utils/hardware/camera_base.py index 86edcd5..4ba62b0 100644 --- a/src/pymodaq_plugins_utils/hardware/camera_base.py +++ b/src/pymodaq_plugins_utils/hardware/camera_base.py @@ -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 StatusPalette, Status try: from pymodaq_gui.plotting.items.roi import RoiInfo # pymodaq > 5.1.x except ImportError: @@ -33,13 +34,21 @@ logger = set_logger(get_module_name(__file__)) +# States of the LEDs of the ROI: grey when not activated, blue while applying, green when activated, orange on error. +# They are set by the plugin, the user only clicks the button of the parameter. Colors are given as hex strings so that +# the parameters can be copied. +ROI_LED_STATES = [(status.value, StatusPalette.color(status).name()) + for status in (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', 'states': ROI_LED_STATES}, + {'title': 'Apply ROI', 'name': 'apply_roi', 'type': 'action_led', + 'value': Status.OFF.value, 'label': 'Apply', 'states': ROI_LED_STATES}, {'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}, @@ -137,6 +146,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.2) + 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 @@ -161,41 +179,88 @@ 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.value) + + def _roi_led_is(self, name: str, status: Status) -> bool: + return self.settings['roi', name] == status.value + + def _on_update_roi(self, *args): + """The user clicked 'Update ROI from Viewer': take the ROI now in the viewer. The LED is green while the ROI + of the viewer is followed, orange if there is none to take""" + if self.roi_select_info is None: + self._set_roi_led('update_roi', Status.ERROR) + self.emit_status(ThreadCommand('Update_Status', ['No ROI selected in the viewer', 'log'])) + return + 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, *args): + """The user clicked 'Apply ROI': apply the ROI selected in the viewer (the one last taken, or the one now in + the viewer if none was taken). The LED stays green, and the ROI of the viewer is applied again when it + changes, until the ROI is cleared""" + if self.roi_sensor_slices is None and self.roi_select_info is not None: + self.update_roi_from_viewer() + self.apply_roi() + + def update_roi_from_viewer(self): + """Compute the ROI of the sensor, in pixels, from the one last selected in the viewer""" + rows, columns = self.roi_select_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)) + 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._set_roi_led('apply_roi', Status.ERROR) + self.emit_status(ThreadCommand('Update_Status', ['No ROI selected in the viewer', 'log'])) 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._set_roi_led('apply_roi', Status.ERROR) self.emit_status(ThreadCommand('Update_Status', ['The ROI is outside of the sensor', 'log'])) 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._set_roi_led('apply_roi', Status.ERROR) + self.emit_status(ThreadCommand('Update_Status', [f'The ROI could not be applied: {exc}', 'log'])) + 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) @@ -212,6 +277,9 @@ 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 + self._set_roi_led('update_roi', Status.OFF) + self._set_roi_led('apply_roi', Status.OFF) def update_rois(self, new_roi: Tuple[int, int, int, int, int, int]): """Set the ROI and binning of the camera if they changed @@ -249,12 +317,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 @@ -427,7 +489,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: diff --git a/tests/test_camera_base.py b/tests/test_camera_base.py index a43710f..e6a2592 100644 --- a/tests/test_camera_base.py +++ b/tests/test_camera_base.py @@ -118,13 +118,20 @@ def calibrated_camera(qapp): plugin.close() +def click_led(plugin, name: str): + """What the user does: click the button of the parameter, which the DAQ_Viewer sends to the plugin""" + plugin.settings.child('roi', name).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): @@ -184,6 +191,56 @@ 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') + + 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) From 075d2249a5328b1cfab5bfe957e359e2f405b18b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 12:37:00 +0000 Subject: [PATCH 2/6] CameraBase: the ROI buttons are toggles, pressed applies the change and released undoes it Pressed: blue LED while the change is done, green when it is, orange on error (the button is released). Released: grey LED; 'Update' stops following the ROI of the viewer, 'Apply' goes back to the full sensor. The LED states are the Status workflow. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Tdiso1u1ZhikSutj2Qs5Cg --- .../hardware/camera_base.py | 75 ++++++++++++------- tests/test_camera_base.py | 29 ++++++- 2 files changed, 74 insertions(+), 30 deletions(-) diff --git a/src/pymodaq_plugins_utils/hardware/camera_base.py b/src/pymodaq_plugins_utils/hardware/camera_base.py index 4ba62b0..0ebf87d 100644 --- a/src/pymodaq_plugins_utils/hardware/camera_base.py +++ b/src/pymodaq_plugins_utils/hardware/camera_base.py @@ -17,7 +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 StatusPalette, Status +from pymodaq_gui.utils.widgets import Status try: from pymodaq_gui.plotting.items.roi import RoiInfo # pymodaq > 5.1.x except ImportError: @@ -34,11 +34,9 @@ logger = set_logger(get_module_name(__file__)) -# States of the LEDs of the ROI: grey when not activated, blue while applying, green when activated, orange on error. -# They are set by the plugin, the user only clicks the button of the parameter. Colors are given as hex strings so that -# the parameters can be copied. -ROI_LED_STATES = [(status.value, StatusPalette.color(status).name()) - for status in (Status.OFF, Status.RUNNING, Status.IDLE, Status.ERROR)] +# 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}, @@ -46,9 +44,9 @@ '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': 'action_led', - 'value': Status.OFF.value, 'label': 'Update', 'states': ROI_LED_STATES}, + 'value': Status.OFF.value, 'label': 'Update', 'states': ROI_LED_STATES, 'checkable': True}, {'title': 'Apply ROI', 'name': 'apply_roi', 'type': 'action_led', - 'value': Status.OFF.value, 'label': 'Apply', 'states': ROI_LED_STATES}, + 'value': Status.OFF.value, 'label': 'Apply', 'states': 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}, @@ -181,31 +179,56 @@ def pixel_size(self) -> Optional[Tuple[float, float]]: 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.value) + self.settings.child('roi', name).setValue(status) def _roi_led_is(self, name: str, status: Status) -> bool: - return self.settings['roi', name] == status.value + return self.settings['roi', name] == status - def _on_update_roi(self, *args): - """The user clicked 'Update ROI from Viewer': take the ROI now in the viewer. The LED is green while the ROI - of the viewer is followed, orange if there is none to take""" + 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._set_roi_led('update_roi', Status.ERROR) - self.emit_status(ThreadCommand('Update_Status', ['No ROI selected in the viewer', 'log'])) + 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, *args): - """The user clicked 'Apply ROI': apply the ROI selected in the viewer (the one last taken, or the one now in - the viewer if none was taken). The LED stays green, and the ROI of the viewer is applied again when it - changes, until the ROI is cleared""" + 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""" rows, columns = self.roi_select_info.to_slices() # in the units of the viewer axes @@ -232,16 +255,14 @@ def apply_roi(self): 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._set_roi_led('apply_roi', Status.ERROR) - self.emit_status(ThreadCommand('Update_Status', ['No ROI selected in the viewer', 'log'])) + 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._set_roi_led('apply_roi', Status.ERROR) - 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._set_roi_led('apply_roi', Status.RUNNING) try: @@ -249,8 +270,7 @@ def apply_roi(self): self.settings['roi', 'x_binning'], self.settings['roi', 'y_binning'])) except Exception as exc: logger.exception(exc) - self._set_roi_led('apply_roi', Status.ERROR) - self.emit_status(ThreadCommand('Update_Status', [f'The ROI could not be applied: {exc}', 'log'])) + self._fail_roi_button('apply_roi', f'The ROI could not be applied: {exc}') return self._set_roi_led('apply_roi', Status.IDLE) @@ -278,8 +298,9 @@ def clear_roi(self): self.update_rois((0, wdet, 0, hdet, 1, 1)) self.roi_sensor_slices = None - self._set_roi_led('update_roi', Status.OFF) - self._set_roi_led('apply_roi', Status.OFF) + 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 diff --git a/tests/test_camera_base.py b/tests/test_camera_base.py index e6a2592..469ccad 100644 --- a/tests/test_camera_base.py +++ b/tests/test_camera_base.py @@ -118,9 +118,11 @@ def calibrated_camera(qapp): plugin.close() -def click_led(plugin, name: str): - """What the user does: click the button of the parameter, which the DAQ_Viewer sends to the plugin""" - plugin.settings.child('roi', name).activate() +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): @@ -211,6 +213,27 @@ def test_updating_takes_the_roi_now_in_the_viewer(self, camera): 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)) From 74231a18606295138c0b4e0e6fbf76d17089f118 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 13:16:49 +0000 Subject: [PATCH 3/6] CameraBase: the states of the ROI LEDs are the limits of the parameters Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Tdiso1u1ZhikSutj2Qs5Cg --- src/pymodaq_plugins_utils/hardware/camera_base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pymodaq_plugins_utils/hardware/camera_base.py b/src/pymodaq_plugins_utils/hardware/camera_base.py index 0ebf87d..7f59b5b 100644 --- a/src/pymodaq_plugins_utils/hardware/camera_base.py +++ b/src/pymodaq_plugins_utils/hardware/camera_base.py @@ -44,9 +44,9 @@ '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': 'action_led', - 'value': Status.OFF.value, 'label': 'Update', 'states': ROI_LED_STATES, 'checkable': True}, + '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', 'states': ROI_LED_STATES, 'checkable': True}, + '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}, From 2876b64c6f8d12eeef4f450941e13cb23e47b5ea Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 14:26:36 +0000 Subject: [PATCH 4/6] Require pymodaq and pymodaq_gui 5.3.9 (activation of action_led, checkable button, limits of the LEDs) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Tdiso1u1ZhikSutj2Qs5Cg --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 67150e9..d1cfc7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 ] From 332ab3c4e3bbd9df711397f66140c558d6c616f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 14:26:45 +0000 Subject: [PATCH 5/6] CameraBase: the forwarding of the activation comes with pymodaq 5.3.9 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Tdiso1u1ZhikSutj2Qs5Cg --- src/pymodaq_plugins_utils/hardware/camera_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pymodaq_plugins_utils/hardware/camera_base.py b/src/pymodaq_plugins_utils/hardware/camera_base.py index 7f59b5b..e4b03bf 100644 --- a/src/pymodaq_plugins_utils/hardware/camera_base.py +++ b/src/pymodaq_plugins_utils/hardware/camera_base.py @@ -146,7 +146,7 @@ class CameraBase(DAQ_Viewer_base): 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.2) + # 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) From c79a87033be944430120af2eb5d2db2d27a662fa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 14:30:09 +0000 Subject: [PATCH 6/6] CameraBase: keep the values of the ROI as floats until they are converted to pixels The ROI of the viewer is given in the values of its axes. Truncating them to integers before dividing by the pixel size was wrong for calibrated axes (um). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Tdiso1u1ZhikSutj2Qs5Cg --- src/pymodaq_plugins_utils/hardware/camera_base.py | 11 ++++++----- tests/test_camera_base.py | 5 +++++ 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/pymodaq_plugins_utils/hardware/camera_base.py b/src/pymodaq_plugins_utils/hardware/camera_base.py index e4b03bf..f4f27c8 100644 --- a/src/pymodaq_plugins_utils/hardware/camera_base.py +++ b/src/pymodaq_plugins_utils/hardware/camera_base.py @@ -231,11 +231,12 @@ def release_roi(self): def update_roi_from_viewer(self): """Compute the ROI of the sensor, in pixels, from the one last selected in the viewer""" - rows, columns = self.roi_select_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]))) + # 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)) diff --git a/tests/test_camera_base.py b/tests/test_camera_base.py index 469ccad..eef18e3 100644 --- a/tests/test_camera_base.py +++ b/tests/test_camera_base.py @@ -311,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)