Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/napari_deeplabcut/_tests/core/test_dataframes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
7 changes: 5 additions & 2 deletions src/napari_deeplabcut/_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
logger.exception("write_hdf_napari_dlc failed")
raise
Comment thread
C-Achard marked this conversation as resolved.

logger.debug("write_hdf_napari_dlc RETURN written=%r", written)
logger.debug(
Expand Down
118 changes: 97 additions & 21 deletions src/napari_deeplabcut/config/keybinds.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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):
Expand All @@ -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

Expand Down
8 changes: 6 additions & 2 deletions src/napari_deeplabcut/core/dataframes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 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}."
)

idx = df_old2.index.union(df_new2.index)
Expand Down
Loading