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/camera_hamamatsu.py b/software/control/camera_hamamatsu.py index 2d6c3d45c..2f7601ab4 100644 --- a/software/control/camera_hamamatsu.py +++ b/software/control/camera_hamamatsu.py @@ -97,6 +97,11 @@ 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: + 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. @@ -105,6 +110,20 @@ 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" + 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: + self._log.warning(f"Failed to set shutter mode to {mode_name}, camera may not support it") + def close(self): self._cleanup_read_thread() @@ -208,7 +227,21 @@ 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 + 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): if frame_format != CameraFrameFormat.RAW: diff --git a/software/control/camera_toupcam.py b/software/control/camera_toupcam.py index d82b0b6bd..a61be109f 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,14 @@ 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") + # use camera arguments such as resolutuon, ROI, exposure time, set max FPS, bandwidth to calculate the trigger delay time pixel_bits = pixel_size * 8 @@ -156,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=}" ) @@ -215,6 +231,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 +255,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 +372,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 +412,10 @@ 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: + self.set_global_reset_mode(True) + self._raw_set_frame_format(CameraFrameFormat.RAW) self._raw_set_pixel_format(self._pixel_format) # 'MONO8' try: @@ -407,6 +430,31 @@ 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: + 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 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/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): """ diff --git a/software/squid/config.py b/software/squid/config.py index a6ed18368..3352e7771 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": @@ -578,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, )