From 756b7b0b4dbbfd22f19d0075aa34d350bd5edb6f Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 15 Jul 2026 16:46:25 +0200 Subject: [PATCH 1/4] Improve save-path errors and writer logging Add exception logging around `write_hdf` in `write_hdf_napari_dlc` so failures are recorded with stack traces before being re-raised. Also replace the generic merge-overlap error with a clearer message that explains dataset path/index mismatches (e.g., renamed or copied labeled-data folders) and includes old/new row index examples for debugging. --- src/napari_deeplabcut/_writer.py | 7 +++++-- src/napari_deeplabcut/core/dataframes.py | 8 ++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/napari_deeplabcut/_writer.py b/src/napari_deeplabcut/_writer.py index 4981d493..2034863f 100644 --- a/src/napari_deeplabcut/_writer.py +++ b/src/napari_deeplabcut/_writer.py @@ -26,8 +26,11 @@ def write_hdf_napari_dlc(path: str, data, attributes: dict) -> list[str]: ) logger.debug("write_hdf_napari_dlc ENTER path=%r", path) - - written = write_hdf(path, data, attributes) + try: + written = write_hdf(path, data, attributes) + except Exception as e: + logger.exception("write_hdf_napari_dlc failed with exception: %s", e) + raise logger.debug("write_hdf_napari_dlc RETURN written=%r", written) logger.debug( diff --git a/src/napari_deeplabcut/core/dataframes.py b/src/napari_deeplabcut/core/dataframes.py index 482f897a..9c16bbef 100644 --- a/src/napari_deeplabcut/core/dataframes.py +++ b/src/napari_deeplabcut/core/dataframes.py @@ -560,8 +560,12 @@ def merge_save_df( overlap = df_old2.index.intersection(df_new2.index) if overlap.empty: raise ValueError( - "Cannot merge save dataframe: no row-index overlap after harmonization. " - "Existing labels would be preserved instead of overwritten/deleted." + "Cannot merge annotations because the dataset path stored in the " + "destination file does not match the current labeled-data folder. " + "This can happen when a labeled-data folder is renamed or copied " + "without rewriting the annotation row indexes. " + f"Existing row example: {df_old2.index[0]!r}. " + f"Incoming row example: {df_new2.index[0]!r}." ) idx = df_old2.index.union(df_new2.index) From aeac20a0f9eeedf91f6be1b23b41ed6a10fcb0b5 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 15 Jul 2026 16:46:55 +0200 Subject: [PATCH 2/4] Add accelerated hold-to-repeat frame keybinds Reworks frame navigation key repeat handling to use per-viewer repeat state with separate delay and repeat timers. Holding A/D now moves once immediately, waits briefly, then repeats with staged acceleration based on hold duration while still ignoring duplicate press events for the same held key. --- src/napari_deeplabcut/config/keybinds.py | 118 +++++++++++++++++++---- 1 file changed, 97 insertions(+), 21 deletions(-) diff --git a/src/napari_deeplabcut/config/keybinds.py b/src/napari_deeplabcut/config/keybinds.py index ca75b5c1..f05f81c1 100644 --- a/src/napari_deeplabcut/config/keybinds.py +++ b/src/napari_deeplabcut/config/keybinds.py @@ -14,7 +14,7 @@ increment_dims_right, ) from napari.layers import Points -from qtpy.QtCore import QTimer +from qtpy.QtCore import QElapsedTimer, QTimer from .settings import TRACKING_SHORTCUTS_ENABLED @@ -58,9 +58,41 @@ class ShortcutAction(Enum): # ---------------------------------------- # Functions with associated keybind callbacks # ---------------------------------------- +_FRAME_REPEAT_INITIAL_DELAY_MS = 400 + +_FRAME_REPEAT_STAGES = ( + # Milliseconds since repeating began, interval in milliseconds. + (0, 180), + (800, 100), + (1_500, 40), +) + + +@dataclass +class _FrameRepeatState: + delay_timer: QTimer + repeat_timer: QTimer + elapsed: QElapsedTimer + + +_frame_repeat_states: dict[ + tuple[int, str], + _FrameRepeatState, +] = {} -_FRAME_REPEAT_INTERVAL_MS = 60 -_frame_repeat_timers: dict[tuple[int, str], QTimer] = {} + +def _repeat_interval_for_elapsed( + elapsed_ms: int, +) -> int: + interval = _FRAME_REPEAT_STAGES[0][1] + + for threshold_ms, stage_interval in _FRAME_REPEAT_STAGES: + if elapsed_ms < threshold_ms: + break + + interval = stage_interval + + return interval def _viewer_from_callback_arg(ctx: BindingContext, obj): @@ -77,41 +109,85 @@ def _viewer_from_callback_arg(ctx: BindingContext, obj): return None -def _make_repeating_viewer_callback(ctx: BindingContext, action, repeat_id: str): +def _make_repeating_viewer_callback( + ctx: BindingContext, + action, + repeat_id: str, +): """ - Call a napari viewer action once, then continue calling it while the key is held. - - This reuses napari's own increment_dims_* functions, but adds hold-to-repeat - for A/D, which napari otherwise filters as non-navigation autorepeat keys. + Move once immediately, then repeat with progressive acceleration while + the key remains held. """ def callback(obj): - viewer = _viewer_from_callback_arg(ctx, obj) + viewer = _viewer_from_callback_arg( + ctx, + obj, + ) if viewer is None: return - timer_key = (id(viewer), repeat_id) + timer_key = ( + id(viewer), + repeat_id, + ) - # Avoid duplicate timers if repeat key-press events sneak through. - if timer_key in _frame_repeat_timers: + # Ignore duplicate press events while this key is already held. + if timer_key in _frame_repeat_states: return - # Move once immediately. + # A tap always moves exactly one frame. action(viewer) - timer = QTimer() - timer.setInterval(_FRAME_REPEAT_INTERVAL_MS) - timer.timeout.connect(lambda: action(viewer)) + delay_timer = QTimer() + delay_timer.setSingleShot(True) - _frame_repeat_timers[timer_key] = timer - timer.start() + repeat_timer = QTimer() + elapsed = QElapsedTimer() + + state = _FrameRepeatState( + delay_timer=delay_timer, + repeat_timer=repeat_timer, + elapsed=elapsed, + ) + _frame_repeat_states[timer_key] = state + + def repeat_once(): + action(viewer) + + next_interval = _repeat_interval_for_elapsed(elapsed.elapsed()) + + if repeat_timer.interval() != next_interval: + repeat_timer.setInterval(next_interval) + + def begin_repeating(): + elapsed.start() + + initial_interval = _repeat_interval_for_elapsed(0) + repeat_timer.setInterval(initial_interval) + + # Move when the initial hold delay expires, then continue. + repeat_once() + repeat_timer.start() + + delay_timer.timeout.connect(begin_repeating) + repeat_timer.timeout.connect(repeat_once) + + delay_timer.start(_FRAME_REPEAT_INITIAL_DELAY_MS) try: yield finally: - timer.stop() - timer.deleteLater() - _frame_repeat_timers.pop(timer_key, None) + delay_timer.stop() + repeat_timer.stop() + + delay_timer.deleteLater() + repeat_timer.deleteLater() + + _frame_repeat_states.pop( + timer_key, + None, + ) return callback From e3a88ba095cfccebe95208308b8aa31f5e32d431 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 15 Jul 2026 16:49:44 +0200 Subject: [PATCH 3/4] Relax merge_save_df error message assertion Update the dataframe merge test to match the new user-facing error text ("Cannot merge annotations") instead of the previous detailed phrase. This keeps the test aligned with current exception messaging while preserving the same failure-path coverage. --- src/napari_deeplabcut/_tests/core/test_dataframes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/napari_deeplabcut/_tests/core/test_dataframes.py b/src/napari_deeplabcut/_tests/core/test_dataframes.py index 5718ea37..8986d6f9 100644 --- a/src/napari_deeplabcut/_tests/core/test_dataframes.py +++ b/src/napari_deeplabcut/_tests/core/test_dataframes.py @@ -742,7 +742,7 @@ def test_merge_save_df_merge_refuses_no_row_overlap_after_harmonization(): columns=cols, ) - with pytest.raises(ValueError, match="no row-index overlap after harmonization"): + with pytest.raises(ValueError, match="Cannot merge annotations"): merge_save_df(df_old, df_new, nan_clears_existing=True) From 3e8c438481fa7f7c4312b56f2eeafe98d5362c46 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 16 Jul 2026 10:14:49 +0200 Subject: [PATCH 4/4] Minor exception logging tweaks Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/napari_deeplabcut/_writer.py | 4 ++-- src/napari_deeplabcut/core/dataframes.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/napari_deeplabcut/_writer.py b/src/napari_deeplabcut/_writer.py index 2034863f..60c305b1 100644 --- a/src/napari_deeplabcut/_writer.py +++ b/src/napari_deeplabcut/_writer.py @@ -28,8 +28,8 @@ def write_hdf_napari_dlc(path: str, data, attributes: dict) -> list[str]: logger.debug("write_hdf_napari_dlc ENTER path=%r", path) try: written = write_hdf(path, data, attributes) - except Exception as e: - logger.exception("write_hdf_napari_dlc failed with exception: %s", e) + except Exception: + logger.exception("write_hdf_napari_dlc failed") raise logger.debug("write_hdf_napari_dlc RETURN written=%r", written) diff --git a/src/napari_deeplabcut/core/dataframes.py b/src/napari_deeplabcut/core/dataframes.py index 9c16bbef..a09a1c19 100644 --- a/src/napari_deeplabcut/core/dataframes.py +++ b/src/napari_deeplabcut/core/dataframes.py @@ -560,10 +560,10 @@ def merge_save_df( overlap = df_old2.index.intersection(df_new2.index) if overlap.empty: raise ValueError( - "Cannot merge annotations because the dataset path stored in the " - "destination file does not match the current labeled-data folder. " - "This can happen when a labeled-data folder is renamed or copied " - "without rewriting the annotation row indexes. " + "Cannot merge annotations because no annotation rows overlap between the " + "destination file and the incoming data after harmonization. " + "This usually means the annotations refer to different image paths/names " + "(e.g., a labeled-data folder was moved/renamed, or you are saving to a different dataset). " f"Existing row example: {df_old2.index[0]!r}. " f"Incoming row example: {df_new2.index[0]!r}." )