Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
56947fc
Observe Points and sync trajectory plot
C-Achard Mar 19, 2026
53746d6
Add PointsInteractionObserver and traj UI tests
C-Achard Mar 19, 2026
616eb75
Apply napari theme to matplotlib canvas and toolbar
C-Achard Mar 19, 2026
1be979d
Improve trajectory plot widget sizing and layout
C-Achard Mar 19, 2026
864f698
Make trajectory plot robust to incomplete Points
C-Achard Apr 6, 2026
0829f64
Rename KeypointMatplotlibCanvas to TrajectoryMatplotlibCanvas
C-Achard Apr 7, 2026
ccbd912
Update test_points_layers.py
C-Achard Apr 7, 2026
1e4423f
Update _widgets.py
C-Achard Apr 7, 2026
0980fcd
Use qtbot.add_widget and wait in tests
C-Achard Apr 7, 2026
105839f
Update src/napari_deeplabcut/ui/plots/trajectory.py
C-Achard Apr 7, 2026
f3751e4
Improve widget teardown, event docs, and size
C-Achard Apr 7, 2026
55dad3b
Improve points-layer UI tweaks and update tests
C-Achard Apr 7, 2026
64e8865
Mark except blocks as no-cover in color patch
C-Achard Apr 7, 2026
c6d3729
Update test_widgets.py
C-Achard Apr 7, 2026
78e4f59
Make points/plot handling more robust
C-Achard Apr 7, 2026
68e9639
Add unit tests for cropping UI module
C-Achard Apr 7, 2026
2beef5a
Support individual-based coloring in trajectory plot
C-Achard Apr 7, 2026
9c6f62c
Support multi-animal color cycles in trajectory plot
C-Achard Apr 7, 2026
20d9aa2
Update tests for bodypart mode and line keys
C-Achard Apr 7, 2026
ce26ca2
Prefer active DLC Points layer for plotting
C-Achard Apr 7, 2026
c6d230b
Use preferred_paths in frame inference
C-Achard Apr 7, 2026
f5a830f
Enhance label progress reporting and UI
C-Achard Apr 7, 2026
2b8394f
Add info button and clarify progress wording
C-Achard Apr 7, 2026
a8043ec
Update _widgets dev notes
C-Achard Apr 10, 2026
d700140
Fix formatting and newline in layer_stats text
C-Achard Apr 10, 2026
ad37c33
Replace layer info icon
C-Achard Apr 20, 2026
ec4b061
Add TODO to move models.py to core/
C-Achard Apr 20, 2026
6250e76
Prevent duplicate KeypointControls opening
C-Achard Apr 20, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
from __future__ import annotations

import numpy as np
import pytest

from napari_deeplabcut.napari_compat import (
apply_points_layer_ui_tweaks,
Expand Down Expand Up @@ -111,7 +110,6 @@ def paste_func(this):
assert seen == [layer]


@pytest.mark.xfail(reason="This test is fixed in a subsequent PR, to be added")
def test_apply_points_layer_ui_tweaks_real_dropdown(qtbot):
from types import SimpleNamespace

Expand Down Expand Up @@ -147,7 +145,12 @@ def __init__(self):
def layout(self):
return self._layout

layer = SimpleNamespace(metadata={"colormap_name": "magma"})
class DummyLayer:
def __init__(self):
self.metadata = {"colormap_name": "magma"}

layer = DummyLayer()

point_controls = PointControls()
qtbot.addWidget(point_controls)

Expand Down
74 changes: 72 additions & 2 deletions src/napari_deeplabcut/_tests/e2e/test_points_layers.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from __future__ import annotations

import numpy as np
import pytest
from napari.layers import Points

from napari_deeplabcut.core.layers import populate_keypoint_layer_properties
from napari_deeplabcut.core.layers import PointsInteractionObserver, populate_keypoint_layer_properties


@pytest.mark.usefixtures("qtbot")
Expand Down Expand Up @@ -100,7 +102,7 @@ def test_layer_insert_does_not_crash_when_current_property_is_nan(viewer, keypoi
layer = viewer.add_points(data, **md)
# Plot cannot be formed because of the NaN,
# but the layer must still be added and cycle mode must not be enabled.
assert keypoint_controls._matplotlib_canvas.df is None
assert keypoint_controls._traj_mpl_canvas.df is None
assert isinstance(layer, Points)
assert layer.face_color_mode != "cycle"

Expand Down Expand Up @@ -239,3 +241,71 @@ def test_copy_paste_same_frame_does_not_duplicate_existing_keypoints(

# no duplicates expected on same frame
assert len(layer.data) == before


def test_points_interaction_observer_emits_on_selected_data_change(viewer, qtbot):
layer = viewer.add_points(
np.array([[0, 0], [1, 1]]),
properties={"label": np.array(["nose", "tail"], dtype=object)},
)
viewer.layers.selection.active = layer

seen = []

observer = PointsInteractionObserver(viewer, seen.append, debounce_ms=0)
observer.install()

layer.selected_data.select_only(1)

qtbot.waitUntil(lambda: len(seen) >= 1, timeout=1000)

evt = seen[-1]
assert evt.viewer is viewer
assert isinstance(evt.layer, Points)
assert "selection" in evt.reasons
assert tuple(sorted(evt.layer.selected_data)) == (1,)

observer.close()


def test_points_interaction_observer_rebinds_when_active_layer_changes(viewer, qtbot):
layer1 = viewer.add_points(
np.array([[0, 0], [1, 1]]),
properties={"label": np.array(["nose", "tail"], dtype=object)},
name="points-1",
)
layer2 = viewer.add_points(
np.array([[2, 2], [3, 3]]),
properties={"label": np.array(["paw", "ear"], dtype=object)},
name="points-2",
)

seen = []
observer = PointsInteractionObserver(viewer, seen.append, debounce_ms=0)
observer.install()

viewer.layers.selection.active = layer1
layer1.selected_data.select_only(0)
qtbot.waitUntil(lambda: any("selection" in ev.reasons for ev in seen), timeout=1000)

count_after_layer1 = len(seen)

# Switch active layer
viewer.layers.selection.active = layer2
qtbot.waitUntil(lambda: len(seen) > count_after_layer1, timeout=1000)

count_after_active_switch = len(seen)

# Mutating old inactive layer selection should not produce a new callback
layer1.selected_data.select_only(1)
qtbot.wait(50)
assert len(seen) == count_after_active_switch

# Mutating new active layer selection should produce a callback
layer2.selected_data.select_only(1)
qtbot.waitUntil(lambda: len(seen) > count_after_active_switch, timeout=1000)
assert seen[-1].layer is not None
assert seen[-1].layer.name == layer2.name
assert "selection" in seen[-1].reasons

observer.close()
25 changes: 16 additions & 9 deletions src/napari_deeplabcut/_tests/test_widgets.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
"""
NOTE: This file can be somewhat non-specific, please ensure functionalities from ui/ are tested
in the _tests/ui folder and consider moving tests below to more specific files as needed."""

# src/napari_deeplabcut/_tests/test_widgets.py
import os
import types
Expand All @@ -16,7 +20,7 @@
from napari_deeplabcut.ui.color_scheme_display import ColorSchemeDisplay
from napari_deeplabcut.ui.dialogs import ShortcutRow
from napari_deeplabcut.ui.labels_and_dropdown import KeypointsDropdownMenu, LabelPair
from napari_deeplabcut.ui.plots.trajectory import KeypointMatplotlibCanvas
from napari_deeplabcut.ui.plots.trajectory import TrajectoryMatplotlibCanvas

from .conftest import force_show

Expand Down Expand Up @@ -235,7 +239,7 @@ def test_color_scheme_display(qtbot):
@pytest.mark.usefixtures("qtbot")
def test_matplotlib_canvas_initialization_and_slider(viewer, points, qtbot):
# Create the canvas widget
canvas = KeypointMatplotlibCanvas(viewer)
canvas = TrajectoryMatplotlibCanvas(viewer)
qtbot.add_widget(canvas)

# Simulate adding a Points layer (triggers _load_dataframe)
Expand All @@ -254,8 +258,11 @@ def test_matplotlib_canvas_initialization_and_slider(viewer, points, qtbot):
assert canvas._window == initial_window + 100
assert canvas.slider_value.text() == str(initial_window + 100)

# Test plot refresh on frame change
# Test plot refresh does nothing when plot is hidden
canvas.update_plot_range(event=type("Event", (), {"value": [5]}))
assert canvas._n == 0
# Test plot refresh on frame change (forced as it is hidden)
canvas.update_plot_range(event=type("Event", (), {"value": [5]}), force=True)
assert canvas._n == 5
# Check that x-limits reflect the new window
start, end = canvas.ax.get_xlim()
Expand Down Expand Up @@ -286,7 +293,7 @@ def fake_add_dock_widget(*args, **kwargs):
# Ensure it wouldn't try to dock again
monkeypatch.setattr(controls.viewer.window, "add_dock_widget", fake_add_dock_widget)

controls._ensure_mpl_canvas_docked()
controls._ensure_traj_canvas_docked()
assert called["count"] == 0, "add_dock_widget should not be called when already docked"
assert controls._mpl_docked is True # stays docked

Expand All @@ -301,7 +308,7 @@ def test_ensure_mpl_canvas_docked_missing_window(keypoint_controls, qtbot):
controls.viewer = types.SimpleNamespace() # no 'window'

controls._mpl_docked = False
controls._ensure_mpl_canvas_docked()
controls._ensure_traj_canvas_docked()

# Nothing should change; crucially, no exceptions should be raised
assert controls._mpl_docked is False
Expand All @@ -322,11 +329,11 @@ def test_trajectory_loader_ignores_invalid_properties(viewer, keypoint_controls,

layer = viewer.add_points(np.array([[0.0, 10.0, 20.0]]), **md)
assert layer is not None
assert keypoint_controls._matplotlib_canvas.df is None # loader should have bailed out safely
assert keypoint_controls._traj_mpl_canvas.df is None # loader should have bailed out safely


@pytest.mark.usefixtures("qtbot")
def test_ensure_mpl_canvas_docked_missing_qt_window(keypoint_controls, qtbot):
def test_ensure_traj_canvas_docked_missing_qt_window(keypoint_controls, qtbot):
"""If window._qt_window is None, method should safely no-op."""
controls = keypoint_controls
qtbot.add_widget(controls)
Expand All @@ -341,7 +348,7 @@ def add_dock_widget(self, *args, **kwargs):
controls.viewer = types.SimpleNamespace(window=DummyWindow())

controls._mpl_docked = False
controls._ensure_mpl_canvas_docked()
controls._ensure_traj_canvas_docked()

# Still undocked, no crash
assert controls._mpl_docked is False
Expand All @@ -365,7 +372,7 @@ def add_dock_widget(self, *args, **kwargs):
controls._mpl_docked = False

# Should not raise
controls._ensure_mpl_canvas_docked()
controls._ensure_traj_canvas_docked()

# Docking failed → remains undocked
assert controls._mpl_docked is False
Expand Down
Loading
Loading