From 16cf91d1bac6c2394eb0c99ff113b4517542a85c Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sat, 28 Feb 2026 19:22:49 -0800 Subject: [PATCH 1/6] feat: add global reset mode option for Toupcam and Hamamatsu cameras Add use_global_reset_mode config option to CameraConfig that enables global shutter mode on supported cameras. For Toupcam, this sets TOUPCAM_OPTION_GLOBAL_RESET_MODE and returns zero strobe delay. For Hamamatsu, this sets DCAM SHUTTER_MODE to GLOBAL with debug logging for strobe time verification. Co-Authored-By: Claude Opus 4.6 --- software/control/camera_hamamatsu.py | 15 +++++++++++++- software/control/camera_toupcam.py | 29 +++++++++++++++++++++++++++- software/squid/config.py | 5 +++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/software/control/camera_hamamatsu.py b/software/control/camera_hamamatsu.py index 2d6c3d45c..a0af6613b 100644 --- a/software/control/camera_hamamatsu.py +++ b/software/control/camera_hamamatsu.py @@ -98,6 +98,15 @@ def __init__( self._capabilities: HamamatsuCapabilities = capabilities self._is_streaming = threading.Event() + # Configure global reset (global shutter) mode if requested + if self._config.use_global_reset_mode: + if self._set_prop(DCAM_IDPROP.SHUTTER_MODE, DCAMPROP.SHUTTER_MODE.GLOBAL): + self._log.info("Global reset mode (global shutter) enabled") + else: + self._log.warning( + "Failed to set global shutter mode, camera may not support it. Using rolling shutter." + ) + # We store exposure time so we don't need to worry about backing out strobe time from the # time stored on the camera. # @@ -208,7 +217,11 @@ def get_strobe_time(self) -> float: if isinstance(line_interval_s, bool) or isinstance(trigger_delay_s, bool): raise CameraError("Failed to get strobe delay properties from camera") - return (line_interval_s + trigger_delay_s) * 1000.0 + strobe_time_ms = (line_interval_s + trigger_delay_s) * 1000.0 + self._log.debug( + f"Strobe time: {strobe_time_ms:.3f} ms (line_interval={line_interval_s*1000:.3f} ms, trigger_delay={trigger_delay_s*1000:.3f} ms, resolution={resolution})" + ) + return strobe_time_ms def set_frame_format(self, frame_format: CameraFrameFormat): if frame_format != CameraFrameFormat.RAW: diff --git a/software/control/camera_toupcam.py b/software/control/camera_toupcam.py index d82b0b6bd..5de6fd3f5 100644 --- a/software/control/camera_toupcam.py +++ b/software/control/camera_toupcam.py @@ -31,6 +31,7 @@ class ToupCamCapabilities(pydantic.BaseModel): has_TEC: bool has_low_noise_mode: bool has_black_level: bool + has_global_shutter: bool class StrobeInfo(pydantic.BaseModel): @@ -66,9 +67,18 @@ def _tdib_width_bytes(w): @staticmethod def _calculate_strobe_info( - camera: toupcam.Toupcam, pixel_size: int, exposure_time_ms: float, capabilities: ToupCamCapabilities + camera: toupcam.Toupcam, + pixel_size: int, + exposure_time_ms: float, + capabilities: ToupCamCapabilities, + is_global_reset: bool = False, ) -> StrobeInfo: log = squid.logging.get_logger("ToupcamCamera._calculate_strobe_delay") + + if is_global_reset: + log.debug("Global reset mode active, strobe_time_us=0, trigger_delay_us=0") + return StrobeInfo(strobe_time_us=0, trigger_delay_us=0) + # use camera arguments such as resolutuon, ROI, exposure time, set max FPS, bandwidth to calculate the trigger delay time pixel_bits = pixel_size * 8 @@ -215,6 +225,7 @@ def _open(index=None, sn=None) -> Tuple[toupcam.Toupcam, ToupCamCapabilities]: has_TEC=(devices[index].model.flag & toupcam.TOUPCAM_FLAG_TEC_ONOFF) > 0, has_low_noise_mode=(devices[index].model.flag & toupcam.TOUPCAM_FLAG_LOW_NOISE) > 0, has_black_level=(devices[index].model.flag & toupcam.TOUPCAM_FLAG_BLACKLEVEL) > 0, + has_global_shutter=(devices[index].model.flag & toupcam.TOUPCAM_FLAG_GLOBALSHUTTER) > 0, ) return camera, capabilities @@ -238,6 +249,7 @@ def __init__(self, config: CameraConfig, hw_trigger_fn, hw_set_strobe_delay_ms_f self._raw_camera_stream_started = False self._raw_frame_callback_lock = threading.Lock() (self._camera, self._capabilities) = ToupcamCamera._open(index=0) + self._is_global_reset = False self._pixel_format = self._config.default_pixel_format self._binning = self._config.default_binning @@ -354,6 +366,7 @@ def _update_internal_settings(self, send_exposure=True): pixel_size=self._get_pixel_size_in_bytes(), exposure_time_ms=camera_exposure_time_ms, capabilities=self._capabilities, + is_global_reset=self._is_global_reset, ) if self._hw_set_strobe_delay_ms_fn and self.get_acquisition_mode() == CameraAcquisitionMode.HARDWARE_TRIGGER: self._hw_set_strobe_delay_ms_fn(self.get_strobe_time()) @@ -393,6 +406,20 @@ def _configure_camera(self): else: self.set_temperature(self._config.default_temperature) + # Configure global reset mode if requested + if self._config.use_global_reset_mode: + if self._capabilities.has_global_shutter: + try: + self._camera.put_Option(toupcam.TOUPCAM_OPTION_GLOBAL_RESET_MODE, 1) + self._is_global_reset = True + self._log.info("Global reset mode enabled") + except toupcam.HRESULTException as ex: + self._log.error(f"Failed to enable global reset mode: hr=0x{ex.hr:x}") + else: + self._log.warning( + "Global reset mode requested but camera does not support global shutter, using rolling shutter" + ) + self._raw_set_frame_format(CameraFrameFormat.RAW) self._raw_set_pixel_format(self._pixel_format) # 'MONO8' try: diff --git a/software/squid/config.py b/software/squid/config.py index a6ed18368..88f1b9c12 100644 --- a/software/squid/config.py +++ b/software/squid/config.py @@ -534,6 +534,11 @@ class CameraConfig(pydantic.BaseModel): # After initialization, set the white balance gains to this once. Only valid for color cameras. default_white_balance_gains: Optional[RGBValue] = None + # Enable global reset mode (global shutter) if the camera supports it. + # When enabled, all sensor rows are exposed simultaneously instead of sequentially (rolling shutter). + # If the camera does not support global reset, a warning is logged and rolling shutter is used. + use_global_reset_mode: bool = False + def _old_camera_variant_to_enum(old_string) -> CameraVariant: if old_string == "Toupcam": From 6b9efaf044f77ad79a802e3a130bad72d27f1f8c Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sat, 28 Feb 2026 19:41:08 -0800 Subject: [PATCH 2/6] feat: add global reset mode setting in Settings > Advanced > Hardware Add GUI checkbox in Settings > Advanced > Hardware Configuration to toggle global reset mode. The setting is saved to the INI config and loaded on startup, requiring app restart to take effect. Co-Authored-By: Claude Opus 4.6 --- software/control/_def.py | 6 ++++++ software/control/widgets.py | 14 ++++++++++++++ software/squid/config.py | 1 + 3 files changed, 21 insertions(+) diff --git a/software/control/_def.py b/software/control/_def.py index 73099f023..291edbcc4 100644 --- a/software/control/_def.py +++ b/software/control/_def.py @@ -761,6 +761,7 @@ class CAMERA_CONFIG: AWB_RATIOS_R = 1.375 AWB_RATIOS_G = 1 AWB_RATIOS_B = 1.4141 + USE_GLOBAL_RESET_MODE = False class AF: @@ -1466,6 +1467,11 @@ class SlackNotifications: "yes", ) log.info(f"Loaded ENABLE_MEMORY_PROFILING={ENABLE_MEMORY_PROFILING} from config") + if _general_config.has_option("GENERAL", "use_global_reset_mode"): + CAMERA_CONFIG.USE_GLOBAL_RESET_MODE = _general_config.get( + "GENERAL", "use_global_reset_mode" + ).lower() in ("true", "1", "yes") + log.info(f"Loaded USE_GLOBAL_RESET_MODE={CAMERA_CONFIG.USE_GLOBAL_RESET_MODE} from config") except Exception as e: log.warning(f"Failed to load GENERAL settings from config: {e}") diff --git a/software/control/widgets.py b/software/control/widgets.py index 4dee98ac5..0d1bb6390 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -1346,6 +1346,10 @@ def _create_advanced_tab(self): self.illumination_factor.setValue(self._get_config_float("GENERAL", "illumination_intensity_factor", 0.6)) hw_layout.addRow("Illumination Intensity Factor:", self.illumination_factor) + self.global_reset_checkbox = QCheckBox() + self.global_reset_checkbox.setChecked(self._get_config_bool("GENERAL", "use_global_reset_mode", False)) + hw_layout.addRow("Use Global Reset Mode *:", self.global_reset_checkbox) + hw_group.content.addLayout(hw_layout) layout.addWidget(hw_group) @@ -1837,6 +1841,11 @@ def _apply_settings(self) -> bool: self.config.set("GENERAL", "led_matrix_g_factor", str(self.led_g_factor.value())) self.config.set("GENERAL", "led_matrix_b_factor", str(self.led_b_factor.value())) self.config.set("GENERAL", "illumination_intensity_factor", str(self.illumination_factor.value())) + self.config.set( + "GENERAL", + "use_global_reset_mode", + "true" if self.global_reset_checkbox.isChecked() else "false", + ) # Advanced - Development Settings self.config.set( @@ -2202,6 +2211,11 @@ def _get_changes(self): if not self._floats_equal(old_val, new_val): changes.append(("Illumination Intensity Factor", str(old_val), str(new_val), False)) + old_val = self._get_config_bool("GENERAL", "use_global_reset_mode", False) + new_val = self.global_reset_checkbox.isChecked() + if old_val != new_val: + changes.append(("Use Global Reset Mode", str(old_val), str(new_val), True)) + # Advanced - Development Settings # Enable/disable requires restart (for warning banner/dialog), but speed/compression # take effect on next acquisition since each acquisition starts a fresh subprocess diff --git a/software/squid/config.py b/software/squid/config.py index 88f1b9c12..3352e7771 100644 --- a/software/squid/config.py +++ b/software/squid/config.py @@ -583,6 +583,7 @@ def _old_camera_variant_to_enum(old_string) -> CameraVariant: default_white_balance_gains=RGBValue( r=_def.CAMERA_CONFIG.AWB_RATIOS_R, g=_def.CAMERA_CONFIG.AWB_RATIOS_G, b=_def.CAMERA_CONFIG.AWB_RATIOS_B ), + use_global_reset_mode=_def.CAMERA_CONFIG.USE_GLOBAL_RESET_MODE, ) From f388dd9883a9bdd92f0c150b66ce9182815e2ce6 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sat, 28 Feb 2026 20:18:01 -0800 Subject: [PATCH 3/6] feat: add set_global_reset_mode() method to Toupcam and Hamamatsu drivers Extract global reset logic into a public method on both camera drivers so it can be called at runtime without requiring app restart. The init code now delegates to this method. Toupcam recalculates strobe timing after toggling; Hamamatsu recalculates exposure time to pick up new SDK-reported strobe values. Co-Authored-By: Claude Opus 4.6 --- software/control/camera_hamamatsu.py | 16 ++++++++----- software/control/camera_toupcam.py | 34 +++++++++++++++++++--------- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/software/control/camera_hamamatsu.py b/software/control/camera_hamamatsu.py index a0af6613b..c69c8b0c8 100644 --- a/software/control/camera_hamamatsu.py +++ b/software/control/camera_hamamatsu.py @@ -100,12 +100,7 @@ def __init__( # Configure global reset (global shutter) mode if requested if self._config.use_global_reset_mode: - if self._set_prop(DCAM_IDPROP.SHUTTER_MODE, DCAMPROP.SHUTTER_MODE.GLOBAL): - self._log.info("Global reset mode (global shutter) enabled") - else: - self._log.warning( - "Failed to set global shutter mode, camera may not support it. Using rolling shutter." - ) + self.set_global_reset_mode(True) # We store exposure time so we don't need to worry about backing out strobe time from the # time stored on the camera. @@ -114,6 +109,15 @@ def __init__( self._exposure_time_ms: int = 20 self.set_exposure_time(self._exposure_time_ms) + def set_global_reset_mode(self, enabled: bool): + mode = DCAMPROP.SHUTTER_MODE.GLOBAL if enabled else DCAMPROP.SHUTTER_MODE.ROLLING + mode_name = "global" if enabled else "rolling" + if self._set_prop(DCAM_IDPROP.SHUTTER_MODE, mode): + self._log.info(f"Shutter mode set to {mode_name}") + self.set_exposure_time(self.get_exposure_time()) + else: + self._log.warning(f"Failed to set shutter mode to {mode_name}, camera may not support it") + def close(self): self._cleanup_read_thread() diff --git a/software/control/camera_toupcam.py b/software/control/camera_toupcam.py index 5de6fd3f5..114e5621b 100644 --- a/software/control/camera_toupcam.py +++ b/software/control/camera_toupcam.py @@ -408,17 +408,7 @@ def _configure_camera(self): # Configure global reset mode if requested if self._config.use_global_reset_mode: - if self._capabilities.has_global_shutter: - try: - self._camera.put_Option(toupcam.TOUPCAM_OPTION_GLOBAL_RESET_MODE, 1) - self._is_global_reset = True - self._log.info("Global reset mode enabled") - except toupcam.HRESULTException as ex: - self._log.error(f"Failed to enable global reset mode: hr=0x{ex.hr:x}") - else: - self._log.warning( - "Global reset mode requested but camera does not support global shutter, using rolling shutter" - ) + self.set_global_reset_mode(True) self._raw_set_frame_format(CameraFrameFormat.RAW) self._raw_set_pixel_format(self._pixel_format) # 'MONO8' @@ -434,6 +424,28 @@ def _configure_camera(self): # TODO: Do hardware cropping here (set ROI) + def set_global_reset_mode(self, enabled: bool): + if enabled: + if not self._capabilities.has_global_shutter: + self._log.warning("Global reset mode requested but camera does not support global shutter") + return + try: + self._camera.put_Option(toupcam.TOUPCAM_OPTION_GLOBAL_RESET_MODE, 1) + self._is_global_reset = True + self._log.info("Global reset mode enabled") + except toupcam.HRESULTException as ex: + self._log.error(f"Failed to enable global reset mode: hr=0x{ex.hr:x}") + return + else: + try: + self._camera.put_Option(toupcam.TOUPCAM_OPTION_GLOBAL_RESET_MODE, 0) + self._is_global_reset = False + self._log.info("Global reset mode disabled") + except toupcam.HRESULTException as ex: + self._log.error(f"Failed to disable global reset mode: hr=0x{ex.hr:x}") + return + self._update_internal_settings() + def set_temperature_reading_callback(self, func): self.temperature_reading_callback = func From a99c05e7040343c0ca35e2cfc21b30a72471d518 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sat, 28 Feb 2026 20:29:30 -0800 Subject: [PATCH 4/6] feat: add supports_global_reset_mode() to AbstractCamera Add default implementations of supports_global_reset_mode() and set_global_reset_mode() to AbstractCamera. Default returns False / logs warning, so cameras that don't support it need no changes. Toupcam checks the GLOBALSHUTTER hardware flag; Hamamatsu queries the SHUTTER_MODE property from the DCAM SDK. Co-Authored-By: Claude Opus 4.6 --- software/control/camera_hamamatsu.py | 4 ++++ software/control/camera_toupcam.py | 3 +++ software/squid/abc.py | 12 ++++++++++++ 3 files changed, 19 insertions(+) diff --git a/software/control/camera_hamamatsu.py b/software/control/camera_hamamatsu.py index c69c8b0c8..2d7ab5910 100644 --- a/software/control/camera_hamamatsu.py +++ b/software/control/camera_hamamatsu.py @@ -109,6 +109,10 @@ def __init__( self._exposure_time_ms: int = 20 self.set_exposure_time(self._exposure_time_ms) + def supports_global_reset_mode(self) -> bool: + attr = self._camera.prop_getattr(DCAM_IDPROP.SHUTTER_MODE) + return attr is not None and not isinstance(attr, bool) + def set_global_reset_mode(self, enabled: bool): mode = DCAMPROP.SHUTTER_MODE.GLOBAL if enabled else DCAMPROP.SHUTTER_MODE.ROLLING mode_name = "global" if enabled else "rolling" diff --git a/software/control/camera_toupcam.py b/software/control/camera_toupcam.py index 114e5621b..980c5a32d 100644 --- a/software/control/camera_toupcam.py +++ b/software/control/camera_toupcam.py @@ -424,6 +424,9 @@ def _configure_camera(self): # TODO: Do hardware cropping here (set ROI) + def supports_global_reset_mode(self) -> bool: + return self._capabilities.has_global_shutter + def set_global_reset_mode(self, enabled: bool): if enabled: if not self._capabilities.has_global_shutter: diff --git a/software/squid/abc.py b/software/squid/abc.py index 279968d96..3731b7c8b 100644 --- a/software/squid/abc.py +++ b/software/squid/abc.py @@ -864,6 +864,18 @@ def set_temperature_reading_callback(self, callback: Callable): """ pass + def supports_global_reset_mode(self) -> bool: + """Whether the camera supports global reset (global shutter) mode.""" + return False + + def set_global_reset_mode(self, enabled: bool): + """Enable or disable global reset (global shutter) mode. + + Override in subclasses that support this feature. + """ + if enabled: + self._log.warning(f"{self.__class__.__name__} does not support global reset mode") + @abc.abstractmethod def close(self): """ From faf1ec211c2f07d636d70cfdad3a0121ca3cd1b5 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sat, 28 Feb 2026 21:01:23 -0800 Subject: [PATCH 5/6] fix: keep trigger_delay_us in global reset mode, only zero strobe_time trigger_delay_us is not necessarily 0 in global reset mode (e.g. Hamamatsu has ~3x row time delay). Only strobe_time_us (rolling readout delay) should be zeroed. The trigger_delay_us calculation is kept but may need refinement after checking Toupcam global reset docs. Co-Authored-By: Claude Opus 4.6 --- software/control/camera_toupcam.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/software/control/camera_toupcam.py b/software/control/camera_toupcam.py index 980c5a32d..a61be109f 100644 --- a/software/control/camera_toupcam.py +++ b/software/control/camera_toupcam.py @@ -75,10 +75,6 @@ def _calculate_strobe_info( ) -> StrobeInfo: log = squid.logging.get_logger("ToupcamCamera._calculate_strobe_delay") - if is_global_reset: - log.debug("Global reset mode active, strobe_time_us=0, trigger_delay_us=0") - return StrobeInfo(strobe_time_us=0, trigger_delay_us=0) - # use camera arguments such as resolutuon, ROI, exposure time, set max FPS, bandwidth to calculate the trigger delay time pixel_bits = pixel_size * 8 @@ -166,6 +162,16 @@ def _calculate_strobe_info( trigger_delay_us = (shr * line_length) / 72 strobe_time = int(vheight * row_time) + if is_global_reset: + # Global reset: all rows expose simultaneously, so no rolling readout delay. + # trigger_delay_us is kept as-is — TODO: verify against Toupcam global reset docs, + # the actual trigger delay in global reset mode may differ from rolling shutter. + log.debug( + f"Global reset mode: strobe_time_us=0, trigger_delay_us={trigger_delay_us}. " + f"{resolution_width=}, {resolution_height=}, {pixel_bits=}, {line_length=}, {low_noise=}, {vheight=}" + ) + return StrobeInfo(strobe_time_us=0, trigger_delay_us=trigger_delay_us) + log.debug( f"New strobe time calculated as {strobe_time} [us]. {resolution_width=}, {resolution_height=}, {pixel_bits=}, {line_length=}, {low_noise=}, {vheight=}, {trigger_delay_us=}" ) From b311460275d057941ec182d1a94c7e61dcfb8321 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sat, 28 Feb 2026 21:14:53 -0800 Subject: [PATCH 6/6] fix: skip line_interval in Hamamatsu get_strobe_time for global reset INTERNAL_LINEINTERVAL is a sensor property that doesn't change with shutter mode. In global reset mode, the rolling readout delay (line_interval * height) doesn't apply since all rows expose simultaneously. Only TRIGGERDELAY is used for strobe timing. Co-Authored-By: Claude Opus 4.6 --- software/control/camera_hamamatsu.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/software/control/camera_hamamatsu.py b/software/control/camera_hamamatsu.py index 2d7ab5910..2f7601ab4 100644 --- a/software/control/camera_hamamatsu.py +++ b/software/control/camera_hamamatsu.py @@ -97,6 +97,7 @@ def __init__( self._camera: Dcam = camera self._capabilities: HamamatsuCapabilities = capabilities self._is_streaming = threading.Event() + self._is_global_reset = False # Configure global reset (global shutter) mode if requested if self._config.use_global_reset_mode: @@ -117,6 +118,7 @@ def set_global_reset_mode(self, enabled: bool): mode = DCAMPROP.SHUTTER_MODE.GLOBAL if enabled else DCAMPROP.SHUTTER_MODE.ROLLING mode_name = "global" if enabled else "rolling" if self._set_prop(DCAM_IDPROP.SHUTTER_MODE, mode): + self._is_global_reset = enabled self._log.info(f"Shutter mode set to {mode_name}") self.set_exposure_time(self.get_exposure_time()) else: @@ -225,10 +227,20 @@ def get_strobe_time(self) -> float: if isinstance(line_interval_s, bool) or isinstance(trigger_delay_s, bool): raise CameraError("Failed to get strobe delay properties from camera") - strobe_time_ms = (line_interval_s + trigger_delay_s) * 1000.0 - self._log.debug( - f"Strobe time: {strobe_time_ms:.3f} ms (line_interval={line_interval_s*1000:.3f} ms, trigger_delay={trigger_delay_s*1000:.3f} ms, resolution={resolution})" - ) + if self._is_global_reset: + # Global reset: all rows expose simultaneously, no rolling readout delay. + # Only the trigger delay applies. + strobe_time_ms = trigger_delay_s * 1000.0 + self._log.debug( + f"Strobe time (global reset): {strobe_time_ms:.3f} ms " + f"(line_interval={line_interval_s*1000:.3f} ms skipped, trigger_delay={trigger_delay_s*1000:.3f} ms, resolution={resolution})" + ) + else: + strobe_time_ms = (line_interval_s + trigger_delay_s) * 1000.0 + self._log.debug( + f"Strobe time: {strobe_time_ms:.3f} ms " + f"(line_interval={line_interval_s*1000:.3f} ms, trigger_delay={trigger_delay_s*1000:.3f} ms, resolution={resolution})" + ) return strobe_time_ms def set_frame_format(self, frame_format: CameraFrameFormat):