From 040107e354344d2d26ac1e03663d94e20ae51fd0 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Wed, 25 Mar 2026 02:50:58 -0700 Subject: [PATCH 1/2] fix: Lock contrast limits after first FOV to prevent re-autoscaling During live acquisition, NDV auto-scales contrast on every data update, causing brightness to jump between FOVs with different signal levels. Now locks contrast to manual mode after the first FOV is displayed, preserving consistent visualization across FOVs. Reset on new acquisition. Co-Authored-By: Claude Opus 4.6 (1M context) --- ndviewer_light/core.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/ndviewer_light/core.py b/ndviewer_light/core.py index 0dc470d..b504d6f 100644 --- a/ndviewer_light/core.py +++ b/ndviewer_light/core.py @@ -1484,6 +1484,7 @@ def __init__(self, dataset_path: str = ""): self._plane_cache = MemoryBoundedLRUCache(PLANE_CACHE_MAX_MEMORY_BYTES) self._updating_sliders: bool = False # Prevent recursive updates self._acquisition_active: bool = False # True during live acquisition + self._auto_contrast_done: bool = False # Lock contrast after first FOV self._time_play_timer: Optional[QTimer] = None # Timer for T slider animation self._fov_play_timer: Optional[QTimer] = None # Timer for FOV slider animation self._load_debounce_timer: Optional[QTimer] = ( @@ -1781,6 +1782,7 @@ def start_acquisition( self._current_time_idx = 0 self._max_time_idx = 0 self._acquisition_active = True + self._auto_contrast_done = False # Update sliders self._updating_sliders = True @@ -2187,6 +2189,32 @@ def _update_ndv_data(self, data): # Fallback: full rebuild (shouldn't happen often) self._set_ndv_data(xarr) + # After the first FOV is displayed, schedule locking contrast limits + # so subsequent FOVs don't re-autoscale. + if not self._auto_contrast_done: + QTimer.singleShot(500, self._lock_contrast_limits) + + def _lock_contrast_limits(self): + """Lock current contrast limits so subsequent FOVs don't re-autoscale. + + Reads the auto-computed clims from each channel and switches them + to manual mode with the same values. + """ + if self._auto_contrast_done or not self.ndv_viewer: + return + try: + from ndv.models._lut_model import ClimsManual + + display = self.ndv_viewer._data_model.display + for key, lut_model in display.luts.items(): + cached = lut_model.clims.cached_clims + if cached is not None: + lut_model.clims = ClimsManual(min=cached[0], max=cached[1]) + self._auto_contrast_done = True + logger.info("Contrast limits locked after first FOV") + except Exception as e: + logger.debug("Could not lock contrast limits: %s", e) + def end_acquisition(self): """Mark acquisition as ended. From 526ed23089c677510741b958f3c4f71205c6920b Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Wed, 25 Mar 2026 03:03:59 -0700 Subject: [PATCH 2/2] fix: Add retry for contrast locking, narrow exception handling - Retry up to 3 times if clims aren't computed yet (data still loading) - Narrow except to (ImportError, AttributeError), log at warning level - Prevent redundant timer scheduling on rapid _update_ndv_data calls Co-Authored-By: Claude Opus 4.6 (1M context) --- ndviewer_light/core.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/ndviewer_light/core.py b/ndviewer_light/core.py index b504d6f..9575143 100644 --- a/ndviewer_light/core.py +++ b/ndviewer_light/core.py @@ -2192,28 +2192,37 @@ def _update_ndv_data(self, data): # After the first FOV is displayed, schedule locking contrast limits # so subsequent FOVs don't re-autoscale. if not self._auto_contrast_done: + self._auto_contrast_done = True # Prevent redundant scheduling QTimer.singleShot(500, self._lock_contrast_limits) - def _lock_contrast_limits(self): + def _lock_contrast_limits(self, _retries: int = 3): """Lock current contrast limits so subsequent FOVs don't re-autoscale. Reads the auto-computed clims from each channel and switches them - to manual mode with the same values. + to manual mode with the same values. Retries if clims aren't + computed yet (data may still be loading). """ - if self._auto_contrast_done or not self.ndv_viewer: + if not self.ndv_viewer: return try: from ndv.models._lut_model import ClimsManual display = self.ndv_viewer._data_model.display + all_locked = True for key, lut_model in display.luts.items(): cached = lut_model.clims.cached_clims if cached is not None: lut_model.clims = ClimsManual(min=cached[0], max=cached[1]) - self._auto_contrast_done = True - logger.info("Contrast limits locked after first FOV") - except Exception as e: - logger.debug("Could not lock contrast limits: %s", e) + else: + all_locked = False + if all_locked: + logger.info("Contrast limits locked after first FOV") + elif _retries > 0: + QTimer.singleShot(500, lambda: self._lock_contrast_limits(_retries - 1)) + else: + logger.warning("Could not lock contrast limits: clims not yet computed") + except (ImportError, AttributeError) as e: + logger.warning("Could not lock contrast limits: %s", e) def end_acquisition(self): """Mark acquisition as ended.