Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
20e1c91
Handle deletions and save-scope in keypoint merges
C-Achard Jun 3, 2026
0ca5a3e
Extract drop_likelihood; report keypoint deletions
C-Achard Jun 3, 2026
5013e91
Improve overwrite conflict dialog and reporting
C-Achard Jun 3, 2026
e6f89d9
Add separator and tweak overwrite dialogs
C-Achard Jun 3, 2026
c35e286
Harmonize indexes for keypoint deletions
C-Achard Jun 3, 2026
6d9cc5d
Update conflicts tests for deletions & completion
C-Achard Jun 3, 2026
0f187ae
Add tests for save/merge and keypoint utilities
C-Achard Jun 3, 2026
07195bb
Add layer identity metadata utilities
C-Achard Jun 3, 2026
e2824d8
Use explicit layer identity metadata
C-Achard Jun 3, 2026
e6d944d
Add tag_* metadata helpers and use tag_frames
C-Achard Jun 3, 2026
bd14ad4
Add allow_deletions flag to complete_df_for_save
C-Achard Jun 3, 2026
16eceed
Tag tracking results and update detection logic
C-Achard Jun 3, 2026
2486f5c
Respect save behavior to disallow deletions
C-Achard Jun 3, 2026
097937f
Don't materialize unmentioned rows/bodyparts
C-Achard Jun 3, 2026
5402e3c
Move save identity helper into workflow class
C-Achard Jun 3, 2026
da05187
Update test stub to accept allow_deletions
C-Achard Jun 3, 2026
c006635
Refactor placeholder layer checks
C-Achard Jun 3, 2026
5cf1a73
Use TRACKING_RESULT_TYPE for tracking result kind
C-Achard Jun 3, 2026
0d9c308
Move layer identity to schemas/
C-Achard Jun 3, 2026
f6c5188
Rename layer identity
C-Achard Jun 3, 2026
294420d
Create __init__.py
C-Achard Jun 3, 2026
8918a0d
Split schemas into metadata and layer_identity
C-Achard Jun 3, 2026
299f250
PARTIAL_UPDATE save behavior and DLC save routing
C-Achard Jun 3, 2026
692bd36
Tests: config save behavior and deletion warnings
C-Achard Jun 3, 2026
3bfd2f3
Refactor layer identity metadata and add helpers
C-Achard Jun 3, 2026
ee7a3cc
Refactor save ownership and promote placeholders
C-Achard Jun 3, 2026
86d3ed6
Support plugin-managed DLC save workflow
C-Achard Jun 3, 2026
c900c99
Add tests for layer lifecycle manager
C-Achard Jun 3, 2026
456f9e9
Use validate_plugin_managed_dlc_annotation_metadata
C-Achard Jun 3, 2026
6a67239
tests: add DLC layer identity tests
C-Achard Jun 3, 2026
b70a64f
Log HDF writer failures and re-raise
C-Achard Jun 4, 2026
4bbf82d
Prepare Points layer for DLC plugin save
C-Achard Jun 4, 2026
6102e23
Tag tests' metadata for DLC annotation writer
C-Achard Jun 4, 2026
a76bdb8
Improve e2e test for DLC save/overwrite flow
C-Achard Jun 4, 2026
4ffa162
Refine tracking layer test assertions
C-Achard Jun 4, 2026
dbe34d0
Warn on unresolved image paths before saving
C-Achard Jun 4, 2026
76ab04d
Record deletions in overwrite confirmation helper
C-Achard Jun 4, 2026
98c4b12
Tests: expect NAPARI_MANAGED and add manager stubs
C-Achard Jun 4, 2026
6dbf55e
e2e: add point-label helpers and refine save tests
C-Achard Jun 4, 2026
195fbe4
Handle placeholder save and improve warnings
C-Achard Jun 4, 2026
a4d4b01
Add DummyDims to DummyViewer in tests
C-Achard Jun 4, 2026
e367440
Adjust DLC metadata tagging return type and comments
C-Achard Jun 4, 2026
fcea3c2
Do not alter md when checking layer props
C-Achard Jun 4, 2026
e2ab7c8
Make lifecycle predicates static and update tests
C-Achard Jun 4, 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
25 changes: 18 additions & 7 deletions src/napari_deeplabcut/_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,34 +25,45 @@
looks_like_dlc_labeled_folder,
session_key_from_project_context,
)
from napari_deeplabcut.core.schemas.layer_identity import (
FrameLayerType,
tag_frames_metadata,
)

logger = logging.getLogger(__name__)


def _build_dlc_layer_meta(
*,
session_role: str | None,
session_role: FrameLayerType | str | None,
project_context: DLCProjectContext | None,
) -> dict:
"""
Build explicit DLC lifecycle metadata for image/video layers.
Build explicit DLC lifecycle metadata for image/video frame layers.

If session_role is None or project_context is None, the layer should be
treated as a non-session image/video by lifecycle code.
"""
if session_role is None or project_context is None:
if isinstance(session_role, FrameLayerType):
session_role_value = session_role.value
else:
session_role_value = session_role

if session_role_value is None or project_context is None:
return {
"session_role": None,
"project_context": None,
"session_key": None,
}

return {
"session_role": session_role,
meta = {
"session_role": session_role_value,
"project_context": project_context.model_dump(mode="python", exclude_none=True),
"session_key": session_key_from_project_context(project_context),
}

return tag_frames_metadata(meta)


def get_hdf_reader(path):
if isinstance(path, list):
Expand Down Expand Up @@ -90,7 +101,7 @@ def get_video_reader(path):
return partial(
read_video,
dlc_meta=_build_dlc_layer_meta(
session_role="video",
session_role=FrameLayerType.VIDEO,
project_context=ctx,
),
)
Expand Down Expand Up @@ -144,7 +155,7 @@ def get_folder_parser(path):
read_images(
images,
dlc_meta=_build_dlc_layer_meta(
session_role="image",
session_role=FrameLayerType.IMAGES,
project_context=ctx,
),
)
Expand Down
24 changes: 17 additions & 7 deletions src/napari_deeplabcut/_tests/core/io/test_write_routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,20 @@
import pytest

from napari_deeplabcut.config.models import AnnotationKind, DLCHeaderModel
from napari_deeplabcut.core.dataframes import drop_likelihood_columns
from napari_deeplabcut.core.errors import AmbiguousSaveError, MissingProvenanceError
from napari_deeplabcut.core.io import (
_drop_likelihood_columns,
_drop_likelihood_from_header,
resolve_output_path_from_metadata,
write_hdf,
)
from napari_deeplabcut.core.schemas.layer_identity import tag_dlc_annotation_metadata


def _tag_plugin_managed_dlc_annotation(attrs: dict) -> dict:
"""Tag attrs['metadata'] as eligible for the plugin DLC annotation writer."""
tagged = tag_dlc_annotation_metadata(attrs.get("metadata"))
return tagged


def test_resolve_output_path_returns_none_for_machine_without_save_target():
Expand All @@ -28,6 +35,7 @@ def test_resolve_output_path_returns_none_for_machine_without_save_target():
}
}
}
md["metadata"] = _tag_plugin_managed_dlc_annotation(md)
out_path, scorer, kind = resolve_output_path_from_metadata(md)
assert out_path is None
assert scorer is None
Expand All @@ -54,6 +62,7 @@ def test_write_hdf_refuses_machine_without_promotion(tmp_path: Path):
},
"properties": {"label": ["bp1"], "id": [""], "likelihood": [1.0]},
}
attrs["metadata"] = _tag_plugin_managed_dlc_annotation(attrs)

with pytest.raises(MissingProvenanceError):
write_hdf("__dlc__.h5", data, attrs)
Expand All @@ -75,7 +84,7 @@ def test_write_hdf_raises_ambiguous_when_multiple_gt_candidates_and_no_provenanc
},
"properties": {"label": ["bp1"], "id": [""], "likelihood": [1.0]},
}

attrs["metadata"] = _tag_plugin_managed_dlc_annotation(attrs)
with pytest.raises(AmbiguousSaveError):
write_hdf("__dlc__.h5", data, attrs)

Expand All @@ -96,6 +105,7 @@ def test_write_hdf_aborts_machine_without_promotion_target(tmp_path: Path):
},
"properties": {"label": ["bp1"], "id": [""], "likelihood": [1.0]},
}
attrs["metadata"] = _tag_plugin_managed_dlc_annotation(attrs)

with pytest.raises(MissingProvenanceError):
write_hdf("__dlc__.h5", data, attrs)
Expand Down Expand Up @@ -133,8 +143,8 @@ def test_drop_likelihood_before_merge_prevents_machine_likelihood_from_leaking()
)

# Mimic writer behavior: strip likelihood on both sides before merge
df_old = _drop_likelihood_columns(df_old)
df_new = _drop_likelihood_columns(df_new)
df_old = drop_likelihood_columns(df_old)
df_new = drop_likelihood_columns(df_new)

df_out = df_new.combine_first(df_old)

Expand Down Expand Up @@ -169,8 +179,8 @@ def test_drop_likelihood_cleans_existing_gt_columns_too():
columns=cols_with_likelihood,
)

df_old = _drop_likelihood_columns(df_old)
df_new = _drop_likelihood_columns(df_new)
df_old = drop_likelihood_columns(df_old)
df_new = drop_likelihood_columns(df_new)
df_out = df_new.combine_first(df_old)

coords = df_out.columns.get_level_values("coords")
Expand All @@ -188,7 +198,7 @@ def test_drop_likelihood_columns_removes_likelihood_from_empty_dataframe():
)
df = pd.DataFrame([], columns=cols, index=pd.Index([], name="image"))

out = _drop_likelihood_columns(df)
out = drop_likelihood_columns(df)

assert out.empty
assert "likelihood" not in out.columns.get_level_values("coords")
Expand Down
194 changes: 194 additions & 0 deletions src/napari_deeplabcut/_tests/core/layer_manager/test_layer_id.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
from types import SimpleNamespace

import numpy as np
from napari.layers import Points

from napari_deeplabcut.core.layer_lifecycle import LayerLifecycleManager
from napari_deeplabcut.core.schemas.layer_identity import (
DLC_LAYER_ROLE_KEY,
DLC_SAVE_BEHAVIOR_KEY,
LayerRole,
LayerSaveBehavior,
tag_config_placeholder_metadata,
tag_dlc_annotation_metadata,
tag_tracking_result_metadata,
)

from .test_manager import DummyViewer, connect_signal_recorders, make_image, make_points, mark_as_dlc_session_image


def make_dlc_points_with_header(
header,
*,
name="pts",
data=None,
project="C:/project",
):
layer = Points(np.zeros((0, 3)) if data is None else data)
layer.name = name
layer.metadata = {
"header": header,
"project": project,
}
return layer


def make_annotation_points_with_header(
header,
*,
name="annotation",
data=None,
):
layer = make_dlc_points_with_header(
header,
name=name,
data=np.array([[0, 1, 2]], dtype=float) if data is None else data,
)
layer.metadata = tag_dlc_annotation_metadata(layer.metadata)
return layer


def make_config_placeholder_points_with_header(
header,
*,
name="config",
):
layer = make_dlc_points_with_header(
header,
name=name,
data=np.zeros((0, 3)),
)
layer.metadata = tag_config_placeholder_metadata(
layer.metadata,
config_path="C:/project/config.yaml",
)
return layer


def make_tracking_points_with_header(
header,
*,
name="tracking",
data=None,
):
layer = make_dlc_points_with_header(
header,
name=name,
data=np.array([[0, 1, 2]], dtype=float) if data is None else data,
)
layer.metadata = tag_tracking_result_metadata(layer.metadata)
return layer


def test_manager_save_behavior_defaults_to_napari_managed_for_generic_points(qtbot):
viewer = DummyViewer()
manager = LayerLifecycleManager(viewer=viewer)

pts = make_points("generic")

assert manager.save_behavior_for_points_layer(pts) is LayerSaveBehavior.NAPARI_MANAGED


def test_manager_save_behavior_for_dlc_annotation_is_plugin_managed(
qtbot,
make_real_header_factory,
):
viewer = DummyViewer()
manager = LayerLifecycleManager(viewer=viewer)

header = make_real_header_factory(bodyparts=("nose", "tail"))
pts = make_annotation_points_with_header(header)

assert manager.save_behavior_for_points_layer(pts) is LayerSaveBehavior.PLUGIN_MANAGED


def test_manager_save_behavior_for_config_placeholder_is_napari_managed_until_promoted(
qtbot,
make_real_header_factory,
):
viewer = DummyViewer()
manager = LayerLifecycleManager(viewer=viewer)

header = make_real_header_factory(bodyparts=("nose", "tail"))
pts = make_config_placeholder_points_with_header(header)

assert manager.save_behavior_for_points_layer(pts) is LayerSaveBehavior.NAPARI_MANAGED


def test_config_placeholder_without_save_context_is_not_promoted(
qtbot,
make_real_header_factory,
):
viewer = DummyViewer()
manager = LayerLifecycleManager(viewer=viewer)

header = make_real_header_factory(bodyparts=("nose", "tail"))
pts = make_config_placeholder_points_with_header(header)

promoted = manager._maybe_promote_config_placeholder_points_layer(pts)

assert promoted is False
assert pts.metadata[DLC_LAYER_ROLE_KEY] == LayerRole.CONFIG_PLACEHOLDER.value
assert DLC_SAVE_BEHAVIOR_KEY not in pts.metadata


def test_frames_first_then_config_placeholder_promotes_after_wiring(
qtbot,
monkeypatch,
make_real_header_factory,
):
img = mark_as_dlc_session_image(make_image("frames"))

header = make_real_header_factory(bodyparts=("nose", "tail"))
pts = make_config_placeholder_points_with_header(header)

viewer = DummyViewer([img, pts])
manager = LayerLifecycleManager(viewer=viewer)
rec = connect_signal_recorders(manager)

monkeypatch.setattr(manager, "validate_header", lambda layer: True)

manager.on_insert(SimpleNamespace(value=img, index=0, source=viewer.layers))
manager.on_insert(SimpleNamespace(value=pts, index=1, source=viewer.layers))

assert manager.is_managed(pts) is True
assert pts.metadata["root"] == "C:/project/labeled-data/test"
assert pts.metadata["paths"] == ["img001.png", "img002.png"]

assert pts.metadata[DLC_LAYER_ROLE_KEY] == LayerRole.DLC_ANNOTATION.value
assert pts.metadata[DLC_SAVE_BEHAVIOR_KEY] == LayerSaveBehavior.PLUGIN_MANAGED.value

assert rec.setup_points.count == 1
req = rec.setup_points.calls[0][0]
assert req.layer is pts
assert req.layer.metadata[DLC_LAYER_ROLE_KEY] == LayerRole.DLC_ANNOTATION.value


def test_config_placeholder_first_then_frames_promotes_after_image_sync(
qtbot,
monkeypatch,
make_real_header_factory,
):
header = make_real_header_factory(bodyparts=("nose", "tail"))
pts = make_config_placeholder_points_with_header(header)

img = mark_as_dlc_session_image(make_image("frames"))

viewer = DummyViewer([pts, img])
manager = LayerLifecycleManager(viewer=viewer)

monkeypatch.setattr(manager, "validate_header", lambda layer: True)

manager.on_insert(SimpleNamespace(value=pts, index=0, source=viewer.layers))

assert manager.is_managed(pts) is True
assert pts.metadata[DLC_LAYER_ROLE_KEY] == LayerRole.CONFIG_PLACEHOLDER.value
assert DLC_SAVE_BEHAVIOR_KEY not in pts.metadata

manager.on_insert(SimpleNamespace(value=img, index=1, source=viewer.layers))

assert pts.metadata["root"] == "C:/project/labeled-data/test"
assert pts.metadata["paths"] == ["img001.png", "img002.png"]

assert pts.metadata[DLC_LAYER_ROLE_KEY] == LayerRole.DLC_ANNOTATION.value
assert pts.metadata[DLC_SAVE_BEHAVIOR_KEY] == LayerSaveBehavior.PLUGIN_MANAGED.value
Loading
Loading