diff --git a/src/napari_deeplabcut/_tests/core/test_config_sync.py b/src/napari_deeplabcut/_tests/core/test_config_sync.py new file mode 100644 index 00000000..28125293 --- /dev/null +++ b/src/napari_deeplabcut/_tests/core/test_config_sync.py @@ -0,0 +1,437 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import napari_deeplabcut.core.config_sync as cs + + +class DummyLayer: + def __init__(self, *, metadata=None, source_path=None): + self.metadata = metadata or {} + self.source = SimpleNamespace(path=source_path) if source_path is not None else None + + +# ----------------------------------------------------------------------------- +# Small helpers +# ----------------------------------------------------------------------------- + + +def test_coerce_point_size_rounds_and_clamps(): + assert cs._coerce_point_size(12) == 12 + assert cs._coerce_point_size(12.6) == 13 + assert cs._coerce_point_size("7") == 7 + assert cs._coerce_point_size(-5) == 1 + assert cs._coerce_point_size(999) == 100 + assert cs._coerce_point_size("not-a-number") == 6 + + +def test_layer_source_path_returns_string_when_available(): + layer = DummyLayer(source_path="/tmp/some/file.png") + assert cs._layer_source_path(layer) == "/tmp/some/file.png" + + +def test_layer_source_path_returns_none_when_source_missing(): + layer = DummyLayer() + assert cs._layer_source_path(layer) is None + + +def test_layer_source_path_returns_none_when_source_path_access_fails(): + class BadSource: + @property + def path(self): + raise RuntimeError("boom") + + layer = DummyLayer() + layer.source = BadSource() + assert cs._layer_source_path(layer) is None + + +# ----------------------------------------------------------------------------- +# resolve_config_path_from_layer +# ----------------------------------------------------------------------------- + + +def test_resolve_config_prefers_points_meta_inference(monkeypatch, tmp_path): + config_path = tmp_path / "config.yaml" + config_path.write_text("dotsize: 6\n", encoding="utf-8") + + layer = DummyLayer(metadata={}) + + monkeypatch.setattr( + cs, + "read_points_meta", + lambda *args, **kwargs: SimpleNamespace(project=None, root=None, paths=[]), + ) + monkeypatch.setattr( + cs, + "infer_dlc_project_from_points_meta", + lambda *args, **kwargs: SimpleNamespace(config_path=config_path), + ) + + resolved = cs.resolve_config_path_from_layer(layer) + + assert resolved == config_path + + +def test_resolve_config_uses_image_layer_inference_when_points_meta_has_no_config(monkeypatch, tmp_path): + config_path = tmp_path / "config.yaml" + config_path.write_text("dotsize: 6\n", encoding="utf-8") + + layer = DummyLayer(metadata={}) + image_layer = DummyLayer(metadata={"root": str(tmp_path)}) + + monkeypatch.setattr( + cs, + "read_points_meta", + lambda *args, **kwargs: SimpleNamespace(project=None, root=None, paths=[]), + ) + monkeypatch.setattr( + cs, + "infer_dlc_project_from_points_meta", + lambda *args, **kwargs: SimpleNamespace(config_path=None), + ) + monkeypatch.setattr( + cs, + "infer_dlc_project_from_image_layer", + lambda *args, **kwargs: SimpleNamespace(config_path=config_path), + ) + + resolved = cs.resolve_config_path_from_layer(layer, image_layer=image_layer) + + assert resolved == config_path + + +def test_resolve_config_uses_generic_fallback_hints(monkeypatch, tmp_path): + config_path = tmp_path / "config.yaml" + config_path.write_text("dotsize: 6\n", encoding="utf-8") + + layer = DummyLayer(metadata={}) + + monkeypatch.setattr( + cs, + "read_points_meta", + lambda *args, **kwargs: SimpleNamespace(project=None, root=None, paths=[]), + ) + monkeypatch.setattr( + cs, + "infer_dlc_project_from_points_meta", + lambda *args, **kwargs: SimpleNamespace(config_path=None), + ) + monkeypatch.setattr( + cs, + "infer_dlc_project", + lambda *args, **kwargs: SimpleNamespace(config_path=config_path), + ) + + resolved = cs.resolve_config_path_from_layer(layer, fallback_project=str(tmp_path)) + + assert resolved == config_path + + +def test_resolve_config_uses_find_nearest_config_as_last_resort(monkeypatch, tmp_path): + config_path = tmp_path / "config.yaml" + config_path.write_text("dotsize: 6\n", encoding="utf-8") + + layer = DummyLayer(metadata={"root": str(tmp_path)}) + + monkeypatch.setattr(cs, "read_points_meta", lambda *args, **kwargs: None) + monkeypatch.setattr(cs, "infer_dlc_project", lambda *args, **kwargs: SimpleNamespace(config_path=None)) + monkeypatch.setattr(cs, "find_nearest_config", lambda *args, **kwargs: config_path) + + resolved = cs.resolve_config_path_from_layer(layer) + + assert resolved == config_path + + +def test_resolve_config_returns_none_when_everything_fails(monkeypatch): + layer = DummyLayer(metadata={}) + + monkeypatch.setattr(cs, "read_points_meta", lambda *args, **kwargs: None) + monkeypatch.setattr(cs, "infer_dlc_project", lambda *args, **kwargs: SimpleNamespace(config_path=None)) + monkeypatch.setattr(cs, "find_nearest_config", lambda *args, **kwargs: None) + + resolved = cs.resolve_config_path_from_layer(layer) + + assert resolved is None + + +def test_resolve_config_ignores_points_meta_when_read_points_meta_raises(monkeypatch, tmp_path): + config_path = tmp_path / "config.yaml" + config_path.write_text("dotsize: 6\n", encoding="utf-8") + + layer = DummyLayer(metadata={"root": str(tmp_path)}) + + monkeypatch.setattr(cs, "read_points_meta", lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("boom"))) + monkeypatch.setattr(cs, "infer_dlc_project", lambda *args, **kwargs: SimpleNamespace(config_path=config_path)) + + resolved = cs.resolve_config_path_from_layer(layer) + + assert resolved == config_path + + +def test_resolve_config_skips_points_meta_when_errors_attribute_present(monkeypatch, tmp_path): + config_path = tmp_path / "config.yaml" + config_path.write_text("dotsize: 6\n", encoding="utf-8") + + layer = DummyLayer(metadata={"root": str(tmp_path)}) + + monkeypatch.setattr(cs, "read_points_meta", lambda *args, **kwargs: SimpleNamespace(errors=["bad meta"])) + monkeypatch.setattr(cs, "infer_dlc_project", lambda *args, **kwargs: SimpleNamespace(config_path=config_path)) + + resolved = cs.resolve_config_path_from_layer(layer) + + assert resolved == config_path + + +def test_resolve_config_skips_non_file_points_meta_config_and_falls_through(monkeypatch, tmp_path): + missing_config = tmp_path / "missing_config.yaml" + real_config = tmp_path / "config.yaml" + real_config.write_text("dotsize: 6\n", encoding="utf-8") + + layer = DummyLayer(metadata={"root": str(tmp_path)}) + + monkeypatch.setattr( + cs, + "read_points_meta", + lambda *args, **kwargs: SimpleNamespace(project=None, root=None, paths=[]), + ) + monkeypatch.setattr( + cs, + "infer_dlc_project_from_points_meta", + lambda *args, **kwargs: SimpleNamespace(config_path=missing_config), + ) + monkeypatch.setattr( + cs, + "infer_dlc_project", + lambda *args, **kwargs: SimpleNamespace(config_path=real_config), + ) + + resolved = cs.resolve_config_path_from_layer(layer) + + assert resolved == real_config + + +def test_resolve_config_passes_paths_into_generic_inference(monkeypatch, tmp_path): + captured = {} + + layer = DummyLayer( + metadata={ + "project": str(tmp_path / "proj"), + "root": str(tmp_path / "root"), + "paths": [ + "labeled-data/session_001/img001.png", + "labeled-data/session_001/img002.png", + "labeled-data/session_001/img003.png", + "labeled-data/session_001/img004.png", + ], + }, + source_path=str(tmp_path / "video.mp4"), + ) + + monkeypatch.setattr(cs, "read_points_meta", lambda *args, **kwargs: None) + + def fake_infer_dlc_project(**kwargs): + captured.update(kwargs) + return SimpleNamespace(config_path=None) + + monkeypatch.setattr(cs, "infer_dlc_project", fake_infer_dlc_project) + monkeypatch.setattr(cs, "find_nearest_config", lambda *args, **kwargs: None) + + resolved = cs.resolve_config_path_from_layer( + layer, + fallback_project=str(tmp_path / "fallback_project"), + fallback_root=str(tmp_path / "fallback_root"), + prefer_project_root=False, + max_levels=7, + ) + + assert resolved is None + assert captured["dataset_candidates"] == ["labeled-data/session_001/img001.png"] + assert captured["anchor_candidates"] == [ + str(tmp_path / "proj"), + str(tmp_path / "root"), + str(tmp_path / "video.mp4"), + str(tmp_path / "fallback_project"), + str(tmp_path / "fallback_root"), + "labeled-data/session_001/img001.png", + "labeled-data/session_001/img002.png", + "labeled-data/session_001/img003.png", + ] + assert captured["prefer_project_root"] is False + assert captured["max_levels"] == 7 + + +def test_resolve_config_uses_image_inference_after_points_inference_exception(monkeypatch, tmp_path): + config_path = tmp_path / "config.yaml" + config_path.write_text("dotsize: 6\n", encoding="utf-8") + + layer = DummyLayer(metadata={}) + image_layer = DummyLayer(metadata={"root": str(tmp_path)}) + + monkeypatch.setattr( + cs, + "read_points_meta", + lambda *args, **kwargs: SimpleNamespace(project=None, root=None, paths=[]), + ) + monkeypatch.setattr( + cs, + "infer_dlc_project_from_points_meta", + lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("boom")), + ) + monkeypatch.setattr( + cs, + "infer_dlc_project_from_image_layer", + lambda *args, **kwargs: SimpleNamespace(config_path=config_path), + ) + + resolved = cs.resolve_config_path_from_layer(layer, image_layer=image_layer) + + assert resolved == config_path + + +def test_resolve_config_continues_when_find_nearest_config_raises_for_one_candidate(monkeypatch, tmp_path): + config_path = tmp_path / "config.yaml" + config_path.write_text("dotsize: 6\n", encoding="utf-8") + + layer = DummyLayer( + metadata={ + "project": "bad-candidate", + "root": str(tmp_path), + } + ) + + monkeypatch.setattr(cs, "read_points_meta", lambda *args, **kwargs: None) + monkeypatch.setattr(cs, "infer_dlc_project", lambda *args, **kwargs: SimpleNamespace(config_path=None)) + + def fake_find(candidate, **kwargs): + if candidate == "bad-candidate": + raise RuntimeError("boom") + return config_path + + monkeypatch.setattr(cs, "find_nearest_config", fake_find) + + resolved = cs.resolve_config_path_from_layer(layer) + + assert resolved == config_path + + +# ----------------------------------------------------------------------------- +# load_point_size_from_config +# ----------------------------------------------------------------------------- + + +def test_load_point_size_from_config_returns_none_for_missing_path(): + assert cs.load_point_size_from_config(None) is None + + +def test_load_point_size_from_config_returns_none_when_load_fails(monkeypatch, tmp_path): + config_path = tmp_path / "config.yaml" + + monkeypatch.setattr(cs.io, "load_config", lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("boom"))) + + assert cs.load_point_size_from_config(config_path) is None + + +def test_load_point_size_from_config_returns_none_when_key_missing(monkeypatch, tmp_path): + config_path = tmp_path / "config.yaml" + + monkeypatch.setattr(cs.io, "load_config", lambda *args, **kwargs: {"colormap": "rainbow"}) + + assert cs.load_point_size_from_config(config_path) is None + + +def test_load_point_size_from_config_coerces_and_clamps(monkeypatch, tmp_path): + config_path = tmp_path / "config.yaml" + + monkeypatch.setattr(cs.io, "load_config", lambda *args, **kwargs: {"dotsize": "250"}) + + assert cs.load_point_size_from_config(config_path) == 100 + + +# ----------------------------------------------------------------------------- +# save_point_size_to_config +# ----------------------------------------------------------------------------- + + +def test_save_point_size_to_config_returns_false_when_path_missing(): + assert cs.save_point_size_to_config(None, 12) is False + + +def test_save_point_size_to_config_returns_false_when_load_fails(monkeypatch, tmp_path): + config_path = tmp_path / "config.yaml" + + monkeypatch.setattr(cs.io, "load_config", lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("boom"))) + + assert cs.save_point_size_to_config(config_path, 12) is False + + +def test_save_point_size_to_config_returns_false_when_value_unchanged(monkeypatch, tmp_path): + config_path = tmp_path / "config.yaml" + + written = [] + + monkeypatch.setattr(cs.io, "load_config", lambda *args, **kwargs: {"dotsize": 12}) + monkeypatch.setattr(cs.io, "write_config", lambda *args, **kwargs: written.append(True)) + + assert cs.save_point_size_to_config(config_path, 12) is False + assert written == [] + + +def test_save_point_size_to_config_writes_updated_value(monkeypatch, tmp_path): + config_path = tmp_path / "config.yaml" + + written = {} + + monkeypatch.setattr(cs.io, "load_config", lambda *args, **kwargs: {"dotsize": 6, "colormap": "rainbow"}) + + def fake_write(path, cfg): + written["path"] = path + written["cfg"] = cfg + + monkeypatch.setattr(cs.io, "write_config", fake_write) + + assert cs.save_point_size_to_config(config_path, 12) is True + assert written["path"] == str(config_path) + assert written["cfg"]["dotsize"] == 12 + assert written["cfg"]["colormap"] == "rainbow" + + +def test_save_point_size_to_config_clamps_before_writing(monkeypatch, tmp_path): + config_path = tmp_path / "config.yaml" + + written = {} + + monkeypatch.setattr(cs.io, "load_config", lambda *args, **kwargs: {}) + + def fake_write(path, cfg): + written["cfg"] = cfg + + monkeypatch.setattr(cs.io, "write_config", fake_write) + + assert cs.save_point_size_to_config(config_path, 999) is True + assert written["cfg"]["dotsize"] == 100 + + +def test_save_point_size_to_config_still_writes_when_old_value_is_not_coercible(monkeypatch, tmp_path): + config_path = tmp_path / "config.yaml" + + written = {} + + monkeypatch.setattr(cs.io, "load_config", lambda *args, **kwargs: {"dotsize": object()}) + + def fake_write(path, cfg): + written["cfg"] = cfg + + monkeypatch.setattr(cs.io, "write_config", fake_write) + + assert cs.save_point_size_to_config(config_path, 15) is True + assert written["cfg"]["dotsize"] == 15 + + +def test_save_point_size_to_config_returns_false_when_write_fails(monkeypatch, tmp_path): + config_path = tmp_path / "config.yaml" + + monkeypatch.setattr(cs.io, "load_config", lambda *args, **kwargs: {"dotsize": 6}) + monkeypatch.setattr(cs.io, "write_config", lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("boom"))) + + assert cs.save_point_size_to_config(config_path, 15) is False diff --git a/src/napari_deeplabcut/_tests/e2e/test_routing_and_provenance.py b/src/napari_deeplabcut/_tests/e2e/test_routing_and_provenance.py index 3aee6026..149d02a2 100644 --- a/src/napari_deeplabcut/_tests/e2e/test_routing_and_provenance.py +++ b/src/napari_deeplabcut/_tests/e2e/test_routing_and_provenance.py @@ -36,6 +36,33 @@ def forbid_project_config_dialog(monkeypatch): ) +@pytest.fixture +def skip_project_config_dialog(monkeypatch): + """ + Simulate the new promotion policy when no config.yaml exists. + + The save flow now asks whether the user wants to locate a DLC config.yaml + before falling back to sidecar/manual scorer entry. In these no-config e2e + scenarios, emulate the user explicitly choosing to continue without config. + """ + from napari_deeplabcut.ui import dialogs as ui_dialogs + + calls = {"count": 0, "kwargs": None} + + def _skip(*args, **kwargs): + calls["count"] += 1 + calls["kwargs"] = kwargs + return ui_dialogs.ProjectConfigPromptResult( + action=ui_dialogs.ProjectConfigPromptAction.SKIP, + ) + + monkeypatch.setattr( + "napari_deeplabcut._widgets.ui_dialogs.prompt_for_project_config_for_save", + _skip, + ) + return calls + + @pytest.mark.usefixtures("qtbot") def test_save_routes_to_correct_gt_when_multiple_gt_exist( viewer, keypoint_controls, qtbot, tmp_path, overwrite_confirm @@ -302,12 +329,14 @@ def test_config_first_save_writes_gt_into_dataset_folder(viewer, keypoint_contro @pytest.mark.usefixtures("qtbot") -def test_promotion_first_save_prompts_and_creates_sidecar( - viewer, keypoint_controls, qtbot, tmp_path, inputdialog, forbid_project_config_dialog +def test_promotion_first_save_skip_config_then_prompt_scorer_and_create_sidecar( + viewer, keypoint_controls, qtbot, tmp_path, inputdialog, skip_project_config_dialog ): """ First save on a machine/prediction layer (no config.yaml, no sidecar): - - prompts for scorer + - offers project-config lookup first + - user continues without config + - then prompts for scorer - writes .napari-deeplabcut.json sidecar - creates CollectedData_.h5 - does NOT modify machinelabels-iter0.h5 @@ -348,6 +377,13 @@ def test_promotion_first_save_prompts_and_creates_sidecar( qtbot.wait(200) assert "save_target" in machine_layer.metadata, machine_layer.metadata.keys() + keypoint_controls._save_layers_dialog(selected=True) + qtbot.wait(200) + + assert skip_project_config_dialog["count"] == 1 + assert skip_project_config_dialog["kwargs"]["resolve_scorer"] is True + assert "save_target" in machine_layer.metadata, machine_layer.metadata.keys() + # Sidecar created sidecar = labeled_folder / ".napari-deeplabcut.json" assert sidecar.exists() @@ -363,12 +399,14 @@ def test_promotion_first_save_prompts_and_creates_sidecar( @pytest.mark.usefixtures("qtbot") -def test_promotion_second_save_uses_sidecar_no_prompt( - viewer, keypoint_controls, qtbot, tmp_path, inputdialog, forbid_project_config_dialog +def test_promotion_second_save_skip_config_then_use_sidecar_without_scorer_prompt( + viewer, keypoint_controls, qtbot, tmp_path, inputdialog, skip_project_config_dialog ): """ - After sidecar exists, saving again must not prompt: - - QInputDialog.getText not called + After sidecar exists, saving again with no config.yaml available: + - offers project-config lookup first + - user continues without config + - QInputDialog.getText not called because sidecar provides scorer - writes/updates same CollectedData_.h5 - machine file unchanged """ @@ -410,6 +448,13 @@ def test_promotion_second_save_uses_sidecar_no_prompt( machine_post = pd.read_hdf(machine_path, key="keypoints") pd.testing.assert_frame_equal(machine_pre, machine_post) + controls._save_layers_dialog(selected=True) + qtbot.wait(200) + + assert skip_project_config_dialog["count"] == 1 + assert skip_project_config_dialog["kwargs"]["resolve_scorer"] is True + assert inputdialog.calls == 0 + @pytest.mark.usefixtures("qtbot") def test_projectless_folder_save_can_associate_with_config_and_coerce_paths_to_dlc_row_keys( @@ -632,3 +677,162 @@ def test_projectless_folder_save_refuses_when_target_dataset_folder_already_cont # No GT should be created in the external folder because association was refused. assert not (external_folder / "CollectedData_John.h5").exists() + + +@pytest.mark.usefixtures("qtbot") +def test_promotion_nearby_config_wins_no_dialog_no_prompt( + viewer, + keypoint_controls, + qtbot, + tmp_path, + monkeypatch, + inputdialog, +): + """ + If a valid DLC config.yaml is discoverable near a machine-labeled layer, + promotion must use the scorer from that config without showing either: + - the project-config selection dialog + - the manual scorer prompt + + Sidecar, if present, must be ignored in favor of config.yaml. + """ + project, config_path, labeled_folder, _gt_paths, machine_path = _make_dlc_project_with_multiple_gt( + tmp_path, scorers=("John", "Jane"), with_machine=True + ) + assert machine_path is not None + + # Create a conflicting sidecar scorer to prove config.yaml wins. + sidecar = labeled_folder / ".napari-deeplabcut.json" + sidecar.write_text('{"schema_version": 1, "default_scorer": "Alice"}', encoding="utf-8") + + machine_pre = pd.read_hdf(machine_path, key="keypoints") + + dialog_calls = {"count": 0} + + def _unexpected_config_dialog(*args, **kwargs): + dialog_calls["count"] += 1 + pytest.fail("Config-selection dialog must not appear when nearby config.yaml is auto-discovered.") + + monkeypatch.setattr( + "napari_deeplabcut._widgets.ui_dialogs.prompt_for_project_config_for_save", + _unexpected_config_dialog, + ) + + # Manual scorer prompt must not be used either. + inputdialog.forbid() + + viewer.open(str(labeled_folder), plugin="napari-deeplabcut") + qtbot.waitUntil(lambda: len(viewer.layers) >= 2, timeout=10_000) + qtbot.wait(200) + + pts_layers = [ly for ly in viewer.layers if isinstance(ly, Points)] + machine_layer = next(p for p in pts_layers if p.name == machine_path.stem) + + store = keypoint_controls._stores.get(machine_layer) + assert store is not None + _set_or_add_bodypart_xy(machine_layer, store, "bodypart2", x=54.0, y=43.0) + + viewer.layers.selection.active = machine_layer + keypoint_controls.viewer.layers.selection.active = machine_layer + keypoint_controls.viewer.layers.selection.select_only(machine_layer) + + keypoint_controls._save_layers_dialog(selected=True) + qtbot.wait(300) + + assert dialog_calls["count"] == 0 + assert inputdialog.calls == 0 + assert "save_target" in machine_layer.metadata, machine_layer.metadata.keys() + + # Config scorer must win over sidecar scorer. + expected_gt = labeled_folder / "CollectedData_John.h5" + unexpected_gt = labeled_folder / "CollectedData_Alice.h5" + assert expected_gt.exists(), f"Expected GT with config scorer to be created: {expected_gt}" + assert not unexpected_gt.exists(), f"Sidecar scorer must be ignored when config.yaml is nearby: {unexpected_gt}" + + machine_post = pd.read_hdf(machine_path, key="keypoints") + pd.testing.assert_frame_equal(machine_pre, machine_post) + + +@pytest.mark.usefixtures("qtbot") +def test_promotion_selected_external_config_wins_no_scorer_prompt( + viewer, + keypoint_controls, + qtbot, + tmp_path, + monkeypatch, + inputdialog, +): + """ + If no nearby config.yaml is found, but the user points the save flow to a + valid external DLC config.yaml, promotion must use that config scorer and + must not show the manual scorer prompt. + + Sidecar, if present, must be ignored in favor of the user-selected config. + """ + labeled_folder = _make_labeled_folder_with_machine_only(tmp_path) + machine_path = labeled_folder / "machinelabels-iter0.h5" + machine_pre = pd.read_hdf(machine_path, key="keypoints") + + # External DLC project whose config scorer should be used. + external_project, external_config_path, _external_dataset = _make_project_config_and_frames_no_gt( + tmp_path / "extproj" + ) + assert external_config_path.exists() + + # Create a conflicting sidecar scorer to prove selected config wins. + sidecar = labeled_folder / ".napari-deeplabcut.json" + sidecar.write_text('{"schema_version": 1, "default_scorer": "Alice"}', encoding="utf-8") + + from napari_deeplabcut.ui import dialogs as ui_dialogs + + dialog_calls = {"count": 0, "kwargs": None} + + def _choose_external_config(*args, **kwargs): + dialog_calls["count"] += 1 + dialog_calls["kwargs"] = kwargs + return ui_dialogs.ProjectConfigPromptResult( + action=ui_dialogs.ProjectConfigPromptAction.ASSOCIATE, + config_path=str(external_config_path), + scorer="John", + ) + + monkeypatch.setattr( + "napari_deeplabcut._widgets.ui_dialogs.prompt_for_project_config_for_save", + _choose_external_config, + ) + + # Manual scorer prompt must not be used when selected config already resolves scorer. + inputdialog.forbid() + + viewer.open(str(labeled_folder), plugin="napari-deeplabcut") + qtbot.waitUntil(lambda: len(viewer.layers) >= 2, timeout=10_000) + qtbot.wait(200) + + pts_layers = [ly for ly in viewer.layers if isinstance(ly, Points)] + machine_layer = next(p for p in pts_layers if p.name == "machinelabels-iter0") + + store = keypoint_controls._stores.get(machine_layer) + assert store is not None + _set_or_add_bodypart_xy(machine_layer, store, "bodypart1", x=91.0, y=82.0) + + viewer.layers.selection.active = machine_layer + keypoint_controls.viewer.layers.selection.active = machine_layer + keypoint_controls.viewer.layers.selection.select_only(machine_layer) + + keypoint_controls._save_layers_dialog(selected=True) + qtbot.wait(300) + + assert dialog_calls["count"] == 1 + assert dialog_calls["kwargs"]["resolve_scorer"] is True + assert inputdialog.calls == 0 + assert "save_target" in machine_layer.metadata, machine_layer.metadata.keys() + + expected_gt = labeled_folder / "CollectedData_John.h5" + unexpected_gt = labeled_folder / "CollectedData_Alice.h5" + assert expected_gt.exists(), f"Expected GT with user-selected config scorer to be created: {expected_gt}" + assert not unexpected_gt.exists(), ( + f"Sidecar scorer must be ignored when a valid external config is selected: {unexpected_gt}" + ) + + machine_post = pd.read_hdf(machine_path, key="keypoints") + pd.testing.assert_frame_equal(machine_pre, machine_post) diff --git a/src/napari_deeplabcut/_tests/ui/test_dialogs.py b/src/napari_deeplabcut/_tests/ui/test_dialogs.py index 4e066140..6b7951d1 100644 --- a/src/napari_deeplabcut/_tests/ui/test_dialogs.py +++ b/src/napari_deeplabcut/_tests/ui/test_dialogs.py @@ -3,17 +3,22 @@ from types import SimpleNamespace import numpy as np +import pytest from napari.layers import Image, Points from qtpy.QtCore import QPoint, Qt from qtpy.QtWidgets import QDialog, QLabel, QPlainTextEdit, QPushButton, QScrollArea +import napari_deeplabcut.ui.dialogs as ui_dialogs from napari_deeplabcut.config.keybinds import iter_shortcuts from napari_deeplabcut.ui.dialogs import ( OverwriteConflictsDialog, + ProjectConfigPromptAction, ShortcutRow, Shortcuts, Tutorial, + load_scorer_from_config, maybe_confirm_overwrite, + prompt_for_project_config_for_save, ) # ----------------------------------------------------------------------------- @@ -147,11 +152,9 @@ def test_tutorial_initial_state(dialog_parent, qtbot): qtbot.addWidget(dlg) assert dlg.parent() is dialog_parent - assert dlg.windowTitle() == "Tutorial" assert dlg.isModal() assert dlg._current_tip == -1 assert dlg.count.text() == "" - assert "Let's get started with a quick walkthrough!" in dlg.message.text() # initial nav state with "intro" screen before first tip assert not dlg.prev_button.isEnabled() @@ -168,9 +171,6 @@ def test_tutorial_next_advances_to_first_tip_and_updates_position(dialog_parent, qtbot.mouseClick(dlg.next_button, Qt.LeftButton) assert dlg._current_tip == 0 - assert dlg.count.text() == f"Tip 1|{len(dlg._tips)}" - assert dlg.message.text().startswith("💡\n\n") - assert "Load a folder of annotated data" in dlg.message.text() # first real tip still has prev disabled, next enabled assert not dlg.prev_button.isEnabled() @@ -195,7 +195,6 @@ def test_tutorial_navigation_enables_and_disables_buttons(dialog_parent, qtbot): assert dlg._current_tip == 1 assert dlg.prev_button.isEnabled() assert dlg.next_button.isEnabled() - assert dlg.count.text() == f"Tip 2|{len(dlg._tips)}" qtbot.mouseClick(dlg.prev_button, Qt.LeftButton) assert dlg._current_tip == 0 @@ -215,7 +214,6 @@ def test_tutorial_last_tip_has_no_emoji_prefix_and_disables_next(dialog_parent, assert not dlg.next_button.isEnabled() # last tip should not be prefixed with the emoji - assert not dlg.message.text().startswith("💡\n\n") assert "napari-deeplabcut" in dlg.message.text() @@ -428,3 +426,255 @@ def fake_confirm(parent, **kwargs): "affected_text": "3 keypoint overwrite(s) across 2 frame(s)/image(s).", "details": "img001.png -> nose, tail", } + + +# ----------------------------------------------------------------------------- +# Project config / scorer resolution dialogs +# ----------------------------------------------------------------------------- + + +class _FakeButton: + def __init__(self, text=None, role=None): + self.text = text + self.role = role + + +class _FakeMessageBox: + Question = object() + YesRole = object() + NoRole = object() + Cancel = object() + Rejected = 0 + + planned_click = "yes" # "yes" | "no" | "cancel" + warnings = [] + last_instance = None + + def __init__(self, parent=None): + self.parent = parent + self._buttons = [] + self._clicked = None + self.window_title = None + self.text = None + self.default_button = None + type(self).last_instance = self + + def setIcon(self, icon): + self.icon = icon + + def setWindowTitle(self, title): + self.window_title = title + + def setText(self, text): + self.text = text + + def addButton(self, *args): + if len(args) == 2: + text, role = args + btn = _FakeButton(text=text, role=role) + else: + btn = _FakeButton(text="cancel", role=None) + self._buttons.append(btn) + return btn + + def setDefaultButton(self, btn): + self.default_button = btn + + def exec_(self): + if self.planned_click == "cancel": + self._clicked = None + return self.Rejected + + if self.planned_click == "no": + self._clicked = next((b for b in self._buttons if b.role is self.NoRole), None) + return 1 + + self._clicked = next((b for b in self._buttons if b.role is self.YesRole), None) + return 1 + + def clickedButton(self): + return self._clicked + + @staticmethod + def warning(parent, title, text): + _FakeMessageBox.warnings.append((title, text)) + + +class _FakeFileDialog: + next_result = ("", "") + calls = [] + + @staticmethod + def getOpenFileName(*args, **kwargs): + _FakeFileDialog.calls.append((args, kwargs)) + return _FakeFileDialog.next_result + + +@pytest.fixture +def fake_config_prompt_qt(monkeypatch): + _FakeMessageBox.planned_click = "yes" + _FakeMessageBox.warnings = [] + _FakeMessageBox.last_instance = None + _FakeFileDialog.next_result = ("", "") + _FakeFileDialog.calls = [] + monkeypatch.setattr(ui_dialogs, "QMessageBox", _FakeMessageBox) + monkeypatch.setattr(ui_dialogs, "QFileDialog", _FakeFileDialog) + return _FakeMessageBox, _FakeFileDialog + + +def test_load_scorer_from_config_returns_trimmed_scorer(tmp_path): + cfg = tmp_path / "config.yaml" + cfg.write_text("scorer: ' John '\n", encoding="utf-8") + + scorer = load_scorer_from_config(cfg) + + assert scorer == "John" + + +def test_load_scorer_from_config_returns_none_when_missing(tmp_path): + cfg = tmp_path / "config.yaml" + cfg.write_text("dotsize: 5\npcutoff: 0.6\n", encoding="utf-8") + + scorer = load_scorer_from_config(cfg) + + assert scorer is None + + +def test_load_scorer_from_config_returns_none_when_blank(tmp_path): + cfg = tmp_path / "config.yaml" + cfg.write_text("scorer: ' '\n", encoding="utf-8") + + scorer = load_scorer_from_config(cfg) + + assert scorer is None + + +def test_prompt_for_project_config_for_save_returns_skip_when_user_chooses_no(fake_config_prompt_qt): + fake_messagebox, fake_filedialog = fake_config_prompt_qt + fake_messagebox.planned_click = "no" + + result = prompt_for_project_config_for_save(parent=None) + + assert result.action is ProjectConfigPromptAction.SKIP + assert result.config_path is None + assert result.scorer is None + assert fake_filedialog.calls == [] + + +def test_prompt_for_project_config_for_save_returns_cancel_when_messagebox_cancelled(fake_config_prompt_qt): + fake_messagebox, fake_filedialog = fake_config_prompt_qt + fake_messagebox.planned_click = "cancel" + + result = prompt_for_project_config_for_save(parent=None) + + assert result.action is ProjectConfigPromptAction.CANCEL + assert result.config_path is None + assert result.scorer is None + assert fake_filedialog.calls == [] + + +def test_prompt_for_project_config_for_save_resolve_scorer_valid_config(fake_config_prompt_qt, tmp_path): + fake_messagebox, fake_filedialog = fake_config_prompt_qt + fake_messagebox.planned_click = "yes" + + cfg = tmp_path / "config.yaml" + cfg.write_text("scorer: John\n", encoding="utf-8") + fake_filedialog.next_result = (str(cfg), "DeepLabCut config (config.yaml)") + + result = prompt_for_project_config_for_save(parent=None, resolve_scorer=True) + + assert result.action is ProjectConfigPromptAction.ASSOCIATE + assert result.config_path == str(cfg) + assert result.scorer == "John" + assert fake_messagebox.warnings == [] + + +def test_prompt_for_project_config_for_save_resolve_scorer_invalid_config_missing_scorer( + fake_config_prompt_qt, + tmp_path, +): + fake_messagebox, fake_filedialog = fake_config_prompt_qt + fake_messagebox.planned_click = "yes" + + cfg = tmp_path / "config.yaml" + cfg.write_text("dotsize: 8\n", encoding="utf-8") + fake_filedialog.next_result = (str(cfg), "DeepLabCut config (config.yaml)") + + result = prompt_for_project_config_for_save(parent=None, resolve_scorer=True) + + assert result.action is ProjectConfigPromptAction.CANCEL + assert result.config_path is None + assert result.scorer is None + + assert len(fake_messagebox.warnings) == 1 + title, text = fake_messagebox.warnings[0] + assert title == "Invalid project configuration" + assert "does not define a valid non-empty 'scorer' field" in text + assert str(cfg) in text + + +def test_prompt_for_project_config_for_save_resolve_scorer_unreadable_config( + monkeypatch, + fake_config_prompt_qt, + tmp_path, +): + fake_messagebox, fake_filedialog = fake_config_prompt_qt + fake_messagebox.planned_click = "yes" + + cfg = tmp_path / "config.yaml" + cfg.write_text("scorer: John\n", encoding="utf-8") + fake_filedialog.next_result = (str(cfg), "DeepLabCut config (config.yaml)") + + def _boom(*args, **kwargs): + raise ValueError("bad yaml") + + monkeypatch.setattr(ui_dialogs, "load_scorer_from_config", _boom) + + result = prompt_for_project_config_for_save(parent=None, resolve_scorer=True) + + assert result.action is ProjectConfigPromptAction.CANCEL + assert result.config_path is None + assert result.scorer is None + + assert len(fake_messagebox.warnings) == 1 + title, text = fake_messagebox.warnings[0] + assert title == "Invalid project configuration" + assert "could not be read as a DeepLabCut config.yaml" in text + assert str(cfg) in text + + +def test_prompt_for_project_config_for_save_uses_custom_text(fake_config_prompt_qt): + fake_messagebox, _ = fake_config_prompt_qt + fake_messagebox.planned_click = "cancel" + + prompt_for_project_config_for_save( + parent=None, + window_title="Locate config", + message="Pick a config for scorer resolution", + choose_button_text="Browse…", + skip_button_text="Continue without config", + ) + + inst = fake_messagebox.last_instance + assert inst is not None + assert inst.window_title == "Locate config" + assert inst.text == "Pick a config for scorer resolution" + assert [b.text for b in inst._buttons[:2]] == ["Browse…", "Continue without config"] + + +def test_warn_invalid_config_for_scorer_auto_found_unreadable(fake_config_prompt_qt): + fake_messagebox, _ = fake_config_prompt_qt + + ui_dialogs.warn_invalid_config_for_scorer( + parent=None, + config_path="/tmp/config.yaml", + reason="unreadable", + auto_found=True, + ) + + assert len(fake_messagebox.warnings) == 1 + title, text = fake_messagebox.warnings[0] + assert title == "Invalid project configuration" + assert "found automatically" in text + assert "could not be read" in text + assert "/tmp/config.yaml" in text diff --git a/src/napari_deeplabcut/_tests/ui/test_layer_stats.py b/src/napari_deeplabcut/_tests/ui/test_layer_stats.py new file mode 100644 index 00000000..ae01179c --- /dev/null +++ b/src/napari_deeplabcut/_tests/ui/test_layer_stats.py @@ -0,0 +1,27 @@ +from napari_deeplabcut.ui.layer_stats import LayerStatusPanel + + +def test_set_invalid_points_layer_disables_slider_and_updates_text(qtbot): + panel = LayerStatusPanel() + qtbot.addWidget(panel) + + panel.set_invalid_points_layer() + + assert panel._progress_value.text() == "Active layer is not a DLC keypoints layer" + assert not panel._size_slider.isEnabled() + assert not panel._size_value.isEnabled() + + +def test_set_no_active_points_layer_disables_slider_and_value_label(qtbot): + panel = LayerStatusPanel() + qtbot.addWidget(panel) + + panel.set_point_size_enabled(True) + assert panel._size_slider.isEnabled() + assert panel._size_value.isEnabled() + + panel.set_no_active_points_layer() + + assert panel._progress_value.text() == "No active keypoints layer" + assert not panel._size_slider.isEnabled() + assert not panel._size_value.isEnabled() diff --git a/src/napari_deeplabcut/_widgets.py b/src/napari_deeplabcut/_widgets.py index d43a0bd5..293c9270 100644 --- a/src/napari_deeplabcut/_widgets.py +++ b/src/napari_deeplabcut/_widgets.py @@ -1,4 +1,13 @@ -"""Main widget and controls for napari-deeplabcut, including the tutorial and shortcuts windows.""" +"""Main widget and controls for napari-deeplabcut, including the tutorial and shortcuts windows. + +NOTE: This file is generally already too long. For future development, please consider: +- Moving existing responsibilities out into separate modules (existing or new) +- Avoiding adding anything that is not strictly related to : + - Building the final UI (blocks can be moved to ui/ for better organization) + - Wiring to the core plugin functionality (e.g. via signals/slots, method calls, etc.) + - Anything that requires the full widget+viewer+signal/event context to function properly + - Similarly, test_widgets.py is a bit of a default drawer right now, please create new tests in _tests/ui +""" # src/napari_deeplabcut/_widgets.py from __future__ import annotations @@ -44,12 +53,22 @@ ) from napari_deeplabcut.config.models import DLCHeaderModel, ImageMetadata, PointsMetadata from napari_deeplabcut.core import keypoints +from napari_deeplabcut.core.config_sync import ( + load_point_size_from_config, + resolve_config_path_from_layer, + save_point_size_to_config, +) from napari_deeplabcut.core.conflicts import compute_overwrite_report_for_points_save from napari_deeplabcut.core.layer_versioning import mark_layer_presentation_changed from napari_deeplabcut.core.layers import ( + compute_label_progress, + find_relevant_image_layer, get_first_points_layer, get_points_layer_with_tables, + get_uniform_point_size, + infer_folder_display_name, is_machine_layer, + set_uniform_point_size, ) from napari_deeplabcut.core.metadata import ( MergePolicy, @@ -64,12 +83,12 @@ PathMatchPolicy, coerce_paths_to_dlc_row_keys, dataset_folder_has_files, + find_nearest_config, resolve_project_root_from_config, target_dataset_folder_for_config, ) from napari_deeplabcut.core.provenance import ( apply_gt_save_target, - find_config_scorer_nearby, is_projectless_folder_association_candidate, requires_gt_promotion, suggest_human_placeholder, @@ -103,6 +122,7 @@ DropdownMenu, KeypointsDropdownMenu, ) +from napari_deeplabcut.ui.layer_stats import LayerStatusPanel from napari_deeplabcut.ui.plots.trajectory import KeypointMatplotlibCanvas logger = logging.getLogger("napari-deeplabcut._widgets") @@ -248,6 +268,12 @@ def _close_event(event): grid.addWidget(self._trail_cb, 2, 0) grid.addWidget(self._view_scheme_cb, 3, 0) + # UX / status panel (folder, progress, point size) + self._layer_status_panel = LayerStatusPanel(self) + self._layer_status_panel.point_size_changed.connect(self._on_active_points_size_changed) + self._layer_status_panel.point_size_commit_requested.connect(self._commit_active_points_size_to_config) + self._layout.addWidget(self._layer_status_panel) + self._layout.addLayout(grid) # form buttons for selection of annotation mode @@ -333,6 +359,9 @@ def _close_event(event): # adopt them so keypoint controls take ownership immediately. QTimer.singleShot(0, self._adopt_existing_layers) + # Refresh layers stats widget + QTimer.singleShot(0, self._refresh_layer_status_panel) + # ######################## # # Layer setup core methods # # ######################## # @@ -503,6 +532,8 @@ def _wire_points_layer(self, layer: Points) -> keypoints.KeypointStore | None: # apply cycles (works even if empty; see method) self._apply_points_coloring_from_metadata(layer) + self._maybe_initialize_layer_point_size_from_config(layer) + self._connect_layer_status_events(layer) # refresh trails if enabled (e.g. when merging a config points layer with trails metadata) self._trails_controller.on_points_layer_added_or_rewired(checkbox_checked=self._trail_cb.isChecked()) @@ -827,6 +858,21 @@ def _sync_points_layers_from_image_meta(self) -> None: out, ) + def _resolve_config_path_for_layer(self, layer: Points | None) -> Path | None: + if layer is None: + return None + + image_layer = find_relevant_image_layer(self.viewer) + + return resolve_config_path_from_layer( + layer, + fallback_project=self._project_path, + fallback_root=self._image_meta.root, + image_layer=image_layer, + prefer_project_root=True, + max_levels=5, + ) + def _maybe_prepare_project_path_override_metadata(self, layer: Points) -> tuple[dict | None, bool]: """ Optionally prepare save-time metadata by associating a project-less labeled @@ -932,15 +978,134 @@ def _show_color_scheme(self): show = self._view_scheme_cb.isChecked() self._color_scheme_display.setVisible(show) + def _current_dlc_points_layer(self) -> Points | None: + active = self.viewer.layers.selection.active + if not isinstance(active, Points): + return None + + try: + res = read_points_meta(active, migrate_legacy=True, drop_controls=True, drop_header=False) + except Exception: + return None + + if isinstance(res, ValidationError): + return None + + if getattr(res, "header", None) is None: + return None + + return active + + def _refresh_layer_status_panel(self) -> None: + active_layer = self.viewer.layers.selection.active + active_dlc_points = self._current_dlc_points_layer() + active_image = find_relevant_image_layer(self.viewer) + + folder_name = infer_folder_display_name( + active_image if active_image is not None else active_layer, + fallback_root=self._image_meta.root, + ) + self._layer_status_panel.set_folder_name(folder_name) + + # No active layer or not a Points layer at all + if active_layer is None or not isinstance(active_layer, Points): + self._layer_status_panel.set_no_active_points_layer() + return + + # Active layer is a Points layer, but not a valid DLC points layer + if active_dlc_points is None: + self._layer_status_panel.set_invalid_points_layer() + return + + self._layer_status_panel.set_point_size_enabled(True) + self._layer_status_panel.set_point_size(get_uniform_point_size(active_dlc_points)) + + progress = compute_label_progress(active_dlc_points, fallback_paths=self._image_meta.paths) + self._layer_status_panel.set_progress_summary( + labeled_percent=progress.labeled_percent, + remaining_percent=progress.remaining_percent, + labeled_points=progress.labeled_points, + total_points=progress.total_points, + frame_count=progress.frame_count, + bodypart_count=progress.bodypart_count, + individual_count=progress.individual_count, + ) + + def _on_active_points_size_changed(self, size: int) -> None: + layer = self._current_dlc_points_layer() + if layer is None: + return + + set_uniform_point_size(layer, size) + mark_layer_presentation_changed(layer) + + def _commit_active_points_size_to_config(self, size: int) -> None: + layer = self._current_dlc_points_layer() + if layer is None: + return + + config_path = self._resolve_config_path_for_layer(layer) + if config_path is None: + logger.debug( + "No config.yaml could be resolved at commit time for active layer %r", + getattr(layer, "name", layer), + ) + return + + try: + changed = save_point_size_to_config(config_path, int(size)) + if changed: + self.viewer.status = f"Updated config dotsize to {int(size)}" + except Exception: + logger.debug("Failed to sync point size to config", exc_info=True) + + def _maybe_initialize_layer_point_size_from_config(self, layer: Points) -> None: + config_path = self._resolve_config_path_for_layer(layer) + if config_path is None: + return + + config_size = load_point_size_from_config(config_path) + if config_size is None: + return + + current_size = get_uniform_point_size(layer) + + # Conservative initialization + if current_size <= 8: + try: + set_uniform_point_size(layer, config_size) + mark_layer_presentation_changed(layer) + except Exception: + logger.debug("Could not initialize layer point size from config", exc_info=True) + + def _connect_layer_status_events(self, layer: Points) -> None: + """ + Keep the UX panel live without adding heavy watchers. + """ + try: + layer.events.data.connect(lambda event=None, _layer=layer: self._refresh_layer_status_panel()) + except Exception: + pass + + try: + layer.events.size.connect(lambda event=None, _layer=layer: self._refresh_layer_status_panel()) + except Exception: + pass + + try: + layer.events.properties.connect(lambda event=None, _layer=layer: self._refresh_layer_status_panel()) + except Exception: + pass + def _form_help_buttons(self): layout = QVBoxLayout() help_buttons_layout = QHBoxLayout() - show_shortcuts = QPushButton("View shortcuts") - show_shortcuts.clicked.connect(self.display_shortcuts) - help_buttons_layout.addWidget(show_shortcuts) - tutorial = QPushButton("Start tutorial") - tutorial.clicked.connect(self.start_tutorial) - help_buttons_layout.addWidget(tutorial) + self.show_shortcuts_btn = QPushButton("View shortcuts") + self.show_shortcuts_btn.clicked.connect(self.display_shortcuts) + help_buttons_layout.addWidget(self.show_shortcuts_btn) + self.tutorial_btn = QPushButton("Start tutorial") + self.tutorial_btn.clicked.connect(self.start_tutorial) + help_buttons_layout.addWidget(self.tutorial_btn) layout.addLayout(help_buttons_layout) self._keypoint_mapping_button = QPushButton("Load superkeypoints diagram") self._load_superkeypoints_action = self._keypoint_mapping_button.clicked.connect( @@ -1212,6 +1377,7 @@ def on_insert(self, event): if not isinstance(layer_, Image): self._remap_frame_indices(layer_) self._refresh_video_panel_context() + self._refresh_layer_status_panel() def on_remove(self, event): layer = event.value @@ -1249,6 +1415,7 @@ def on_remove(self, event): self._trail_cb.setChecked(False) self._refresh_video_panel_context() + self._refresh_layer_status_panel() def _on_show_trails_toggled(self, state): self._trails_controller.toggle(Qt.CheckState(state) == Qt.CheckState.Checked) @@ -1289,31 +1456,89 @@ def _ensure_promotion_save_target(self, layer: Points) -> bool: QMessageBox.warning(self, "Cannot save", "Could not determine a folder anchor for saving.") return False - scorer = find_config_scorer_nearby(anchor) or get_default_scorer(anchor) - if not scorer: - suggested = suggest_human_placeholder(anchor) - while True: - s = _prompt_for_scorer(self, anchor=anchor, suggested=suggested) - if s is None: - return False - if s.startswith("human_"): - choice = QMessageBox.question( - self, - "Generic scorer name", - "You entered a generic scorer name starting with 'human_'.\n\n" - "We strongly recommend using a real name or stable identifier.\n" - "Do you want to keep this generic scorer anyway?", - QMessageBox.Yes | QMessageBox.No, - ) - if choice == QMessageBox.No: - suggested = s - continue - scorer = s - break + scorer = None + + # 1) Auto-discovered config.yaml always wins + cfg_path = None + try: + cfg_path = find_nearest_config(anchor) + except Exception: + logger.debug("Automatic config discovery failed for anchor=%r", anchor, exc_info=True) + + if cfg_path: try: - set_default_scorer(anchor, scorer) + scorer = ui_dialogs.load_scorer_from_config(cfg_path) except Exception: - logger.debug("Failed to persist default scorer to sidecar", exc_info=True) + logger.exception("Failed to load auto-discovered config.yaml: %s", cfg_path) + ui_dialogs.warn_invalid_config_for_scorer( + self, + config_path=cfg_path, + reason="unreadable", + auto_found=True, + ) + return False + + if not scorer: + ui_dialogs.warn_invalid_config_for_scorer( + self, + config_path=cfg_path, + reason="missing_scorer", + auto_found=True, + ) + return False + + else: + # 2) No config found automatically -> let the user choose one + dialog_result = ui_dialogs.prompt_for_project_config_for_save( + self, + initial_dir=self._project_path or anchor, + window_title="Locate DLC config for scorer resolution", + message=( + "No DeepLabCut config.yaml could be found automatically for this machine-labeled layer.\n\n" + "If this layer belongs to a DLC project, choose its config.yaml so the save uses the " + "project scorer and standard naming.\n\n" + "If no config.yaml exists, you can continue without one." + ), + choose_button_text="Choose config.yaml", + skip_button_text="Continue without config", + resolve_scorer=True, + ) + + if dialog_result.action is ui_dialogs.ProjectConfigPromptAction.CANCEL: + return False + + if dialog_result.action is ui_dialogs.ProjectConfigPromptAction.ASSOCIATE: + scorer = dialog_result.scorer + + else: + # 3) Only if no config is available at all may sidecar be consulted + scorer = get_default_scorer(anchor) + + # 4) Final fallback: prompt manually + if not scorer: + suggested = suggest_human_placeholder(anchor) + while True: + s = _prompt_for_scorer(self, anchor=anchor, suggested=suggested) + if s is None: + return False + if s.startswith("human_"): + choice = QMessageBox.question( + self, + "Generic scorer name", + "You entered a generic scorer name starting with 'human_'.\n\n" + "We strongly recommend using a real name or stable identifier.\n" + "Do you want to keep this generic scorer anyway?", + QMessageBox.Yes | QMessageBox.No, + ) + if choice == QMessageBox.No: + suggested = s + continue + scorer = s + break + try: + set_default_scorer(anchor, scorer) + except Exception: + logger.debug("Failed to persist default scorer to sidecar", exc_info=True) updated = apply_gt_save_target( pts, @@ -1491,6 +1716,7 @@ def on_active_layer_change(self, event) -> None: menu.setHidden(True) self._refresh_video_panel_context() + self._refresh_layer_status_panel() def _update_colormap(self, colormap_name: str): for layer in self.viewer.layers.selection: diff --git a/src/napari_deeplabcut/core/config_sync.py b/src/napari_deeplabcut/core/config_sync.py new file mode 100644 index 00000000..dd59e8dd --- /dev/null +++ b/src/napari_deeplabcut/core/config_sync.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +import logging +from pathlib import Path + +import napari_deeplabcut.core.io as io +from napari_deeplabcut.core.metadata import read_points_meta +from napari_deeplabcut.core.project_paths import ( + find_nearest_config, + infer_dlc_project, + infer_dlc_project_from_image_layer, + infer_dlc_project_from_points_meta, +) + +logger = logging.getLogger("napari-deeplabcut.core.config_sync") + +_POINT_SIZE_KEY = "dotsize" + + +def _coerce_point_size(value, *, default: int = 6, minimum: int = 1, maximum: int = 100) -> int: + try: + size = int(round(float(value))) + except Exception: + size = default + return max(minimum, min(maximum, size)) + + +def _layer_source_path(layer) -> str | None: + try: + src = getattr(layer, "source", None) + p = getattr(src, "path", None) if src is not None else None + return str(p) if p else None + except Exception: + return None + + +def resolve_config_path_from_layer( + layer, + *, + fallback_project: str | Path | None = None, + fallback_root: str | Path | None = None, + image_layer=None, + prefer_project_root: bool = True, + max_levels: int = 5, +) -> Path | None: + """ + Best-effort, lightweight config resolution using centralized DLC project inference. + + Resolution order + ---------------- + 1. Infer from Points metadata via infer_dlc_project_from_points_meta(...) + 2. Infer from current image/video layer via infer_dlc_project_from_image_layer(...) + 3. Infer from generic path-like hints via infer_dlc_project(...) + 4. Last-resort upward search with find_nearest_config(...) + + This intentionally: + - does not do recursive filesystem crawling + - only searches upward with bounded max_levels + - reuses the plugin's root-anchor / project-context semantics + """ + # ------------------------------------------------------------------ + # 1) Points-layer-centric inference (authoritative when available) + # ------------------------------------------------------------------ + try: + pts_meta = read_points_meta( + layer, + migrate_legacy=True, + drop_controls=True, + drop_header=False, + ) + except Exception: + pts_meta = None + + if pts_meta is not None and not hasattr(pts_meta, "errors"): + try: + ctx = infer_dlc_project_from_points_meta( + pts_meta, + prefer_project_root=prefer_project_root, + max_levels=max_levels, + ) + if ctx.config_path is not None and ctx.config_path.is_file(): + return ctx.config_path + except Exception: + logger.debug("Failed to infer config from points metadata", exc_info=True) + + # ------------------------------------------------------------------ + # 2) Image/video-layer-centric inference + # ------------------------------------------------------------------ + if image_layer is not None: + try: + ctx = infer_dlc_project_from_image_layer( + image_layer, + prefer_project_root=prefer_project_root, + max_levels=max_levels, + ) + if ctx.config_path is not None and ctx.config_path.is_file(): + return ctx.config_path + except Exception: + logger.debug("Failed to infer config from image layer", exc_info=True) + + # ------------------------------------------------------------------ + # 3) Generic fallback inference from path-like hints + # ------------------------------------------------------------------ + md = getattr(layer, "metadata", {}) or {} + paths = md.get("paths") or [] + + anchor_candidates: list[str | Path] = [] + dataset_candidates: list[str | Path] = [] + + for value in ( + md.get("project"), + md.get("root"), + _layer_source_path(layer), + fallback_project, + fallback_root, + ): + if value: + anchor_candidates.append(value) + + # Paths can help infer the labeled-data dataset folder/root anchor + if paths: + # dataset candidate: first opened path / row-key hint + dataset_candidates.append(paths[0]) + + # Add a few paths as anchors (bounded/lightweight) + for value in paths[:3]: + anchor_candidates.append(value) + + try: + ctx = infer_dlc_project( + anchor_candidates=anchor_candidates, + dataset_candidates=dataset_candidates, + explicit_root=None, + prefer_project_root=prefer_project_root, + max_levels=max_levels, + ) + if ctx.config_path is not None and ctx.config_path.is_file(): + return ctx.config_path + except Exception: + logger.debug("Failed to infer config from generic path hints", exc_info=True) + + # ------------------------------------------------------------------ + # 4) Fallback upward search on a bounded set of candidates + # ------------------------------------------------------------------ + for candidate in anchor_candidates: + try: + cfg = find_nearest_config(candidate, max_levels=max_levels) + if cfg is not None and cfg.is_file(): + return cfg + except Exception: + logger.debug("find_nearest_config failed for %r", candidate, exc_info=True) + + return None + + +def load_point_size_from_config(config_path: str | Path | None) -> int | None: + if not config_path: + return None + + try: + cfg = io.load_config(str(config_path)) + except Exception: + logger.debug("Could not read config file %r", config_path, exc_info=True) + return None + + if not isinstance(cfg, dict): + logger.debug( + "Config file %r did not contain a mapping; ignoring for point-size load.", + config_path, + ) + return None + + if _POINT_SIZE_KEY in cfg: + return _coerce_point_size(cfg.get(_POINT_SIZE_KEY)) + return None + + +def save_point_size_to_config(config_path: str | Path | None, size: int) -> bool: + """ + Persist point size in config.yaml if possible. + + Returns + ------- + bool + True if the config was changed and written, False otherwise. + """ + if not config_path: + logger.debug("Skipping point-size config sync: no config path resolved.") + return False + + size = _coerce_point_size(size) + + try: + cfg = io.load_config(str(config_path)) + except Exception: + logger.debug("Could not read config file %r", config_path, exc_info=True) + return False + + if not isinstance(cfg, dict): + logger.debug( + "Config file %r did not contain a mapping; replacing with empty config for point-size sync.", + config_path, + ) + cfg = {} + + old_value = cfg.get(_POINT_SIZE_KEY, None) + + try: + if old_value is not None and _coerce_point_size(old_value) == size: + logger.debug("Skipping point-size config sync: dotsize already %s", size) + return False + except Exception: + pass + + cfg[_POINT_SIZE_KEY] = size + + try: + io.write_config(str(config_path), cfg) + logger.debug("Updated dotsize=%s in %s", size, config_path) + return True + except Exception: + logger.debug("Could not write config file %r", config_path, exc_info=True) + return False diff --git a/src/napari_deeplabcut/core/dataframes.py b/src/napari_deeplabcut/core/dataframes.py index e00b4d7e..8a0fc142 100644 --- a/src/napari_deeplabcut/core/dataframes.py +++ b/src/napari_deeplabcut/core/dataframes.py @@ -371,13 +371,14 @@ def keypoint_conflicts(df_old: pd.DataFrame, df_new: pd.DataFrame) -> pd.DataFra # Reduce across coords first -> any conflict for that coord-set # This yields a DataFrame with columns still multi-level including scorer and coords. # We then group by key_levels. - # Step 1: ensure we can group: drop coords by grouping over it using "any". + # Ensure we can group by dropping coords by grouping over it using "any". # We'll group over all columns that share the same (individual/bodypart), ignoring coords. # To do that cleanly: swap coords to last, then groupby on key_levels. conflict_cols = cell_conflict.copy() # Group columns by key_levels and reduce with any() across remaining levels (coords + scorer) - # pandas allows groupby on axis=1 by level names: + # pandas no longer allows groupby on axis=1 by level names + # we use .T to swap axes, groupby on rows, then .T back to original orientation instead key_conflict = conflict_cols.T.groupby(level=key_levels).any().T return key_conflict diff --git a/src/napari_deeplabcut/core/io.py b/src/napari_deeplabcut/core/io.py index b43f7604..d7fa59d8 100644 --- a/src/napari_deeplabcut/core/io.py +++ b/src/napari_deeplabcut/core/io.py @@ -48,7 +48,11 @@ from napari_deeplabcut.core.errors import AmbiguousSaveError, MissingProvenanceError from napari_deeplabcut.core.layers import populate_keypoint_layer_properties from napari_deeplabcut.core.metadata import attach_source_and_io_to_layer_kwargs, parse_points_metadata -from napari_deeplabcut.core.project_paths import canonicalize_path, infer_dlc_project_from_points_meta +from napari_deeplabcut.core.project_paths import ( + canonicalize_path, + find_nearest_config, + infer_dlc_project_from_points_meta, +) from napari_deeplabcut.core.provenance import resolve_output_path_from_metadata logger = logging.getLogger(__name__) @@ -135,7 +139,7 @@ def read_hdf_single(file: Path, *, kind: AnnotationKind | None = None) -> list[L # Handle legacy/single-animal column layout by inserting empty "individuals" level. # Colormap selection also falls back to config when possible. try: - cfg = load_config(misc.find_project_config_path(str(file))) + cfg = load_config(find_nearest_config(file, max_levels=3)) config_colormap = str(cfg.get("colormap", DEFAULT_SINGLE_ANIMAL_CMAP)) except Exception as e: logger.warning("Could not load config for %s; falling back to default colormap. Error: %s", file, e) diff --git a/src/napari_deeplabcut/core/layers.py b/src/napari_deeplabcut/core/layers.py index 7e14403c..afbc9ea7 100644 --- a/src/napari_deeplabcut/core/layers.py +++ b/src/napari_deeplabcut/core/layers.py @@ -2,15 +2,12 @@ import logging from collections.abc import Callable, Iterable, Sequence +from dataclasses import dataclass +from pathlib import Path from typing import Any, TypeVar import numpy as np - -try: - # napari is an optional dependency at import time in some test setups - from napari.layers import Image, Layer, Points, Shapes, Tracks -except Exception: # pragma: no cover - Image = Points = Shapes = Tracks = Layer = object # type: ignore +from napari.layers import Image, Points, Shapes, Tracks from napari_deeplabcut.config.models import AnnotationKind, DLCHeaderModel from napari_deeplabcut.core.keypoints import build_color_cycles @@ -209,3 +206,187 @@ def get_first_shapes_layer(viewer_or_layers: Any) -> Any | None: def get_first_tracks_layer(viewer_or_layers: Any) -> Any | None: return find_first_layer(viewer_or_layers, Tracks) + + +@dataclass(frozen=True) +class LabelProgress: + labeled_points: int + total_points: int + labeled_percent: float + remaining_percent: float + frame_count: int + bodypart_count: int + individual_count: int + + +def _get_header_model_from_metadata(md: dict) -> DLCHeaderModel | None: + if not isinstance(md, dict): + return None + + hdr = md.get("header") + if hdr is None: + return None + + if isinstance(hdr, DLCHeaderModel): + return hdr + + if isinstance(hdr, dict): + try: + return DLCHeaderModel.model_validate(hdr) + except Exception: + return None + + try: + return DLCHeaderModel(columns=hdr) + except Exception: + return None + + +def get_uniform_point_size(layer: Points, *, default: int = 6) -> int: + size = getattr(layer, "size", default) + try: + arr = np.asarray(size, dtype=float).ravel() + if arr.size == 0: + return default + return int(round(float(np.nanmean(arr)))) + except Exception: + try: + return int(round(float(size))) + except Exception: + return default + + +def set_uniform_point_size(layer: Points, size: int) -> None: + # Scalar assignment keeps it lightweight and applies uniformly. + layer.size = float(size) + + +def infer_frame_count(layer: Points, *, fallback_paths: list[str] | None = None) -> int: + md = getattr(layer, "metadata", {}) or {} + + paths = md.get("paths") or fallback_paths or [] + if paths: + return len(paths) + + data = np.asarray(getattr(layer, "data", [])) + if data.size == 0: + return 0 + + try: + # Points layers use frame/time in first column + return int(np.nanmax(data[:, 0])) + 1 + except Exception: + return 0 + + +def infer_bodypart_count(layer: Points) -> int: + hdr = _get_header_model_from_metadata(getattr(layer, "metadata", {}) or {}) + if hdr is None: + return 0 + + try: + return len([bp for bp in hdr.bodyparts if str(bp) != ""]) + except Exception: + return 0 + + +def infer_individual_count(layer: Points) -> int: + """ + Returns the number of valid DLC individuals. + + Single-animal convention: + - if no individuals are defined + - or individuals are empty / blank + => returns 1 + """ + hdr = _get_header_model_from_metadata(getattr(layer, "metadata", {}) or {}) + if hdr is None: + return 1 + + try: + inds = [str(ind) for ind in hdr.individuals if str(ind) != ""] + return max(1, len(inds)) + except Exception: + return 1 + + +def compute_label_progress(layer: Points, *, fallback_paths: list[str] | None = None) -> LabelProgress: + frame_count = infer_frame_count(layer, fallback_paths=fallback_paths) + bodypart_count = infer_bodypart_count(layer) + individual_count = infer_individual_count(layer) + + total_points = frame_count * bodypart_count * individual_count + + data = np.asarray(getattr(layer, "data", [])) + labeled_points = int(data.shape[0]) if data.ndim >= 2 else 0 + + if total_points > 0: + labeled_points = min(labeled_points, total_points) + labeled_percent = 100.0 * labeled_points / total_points + else: + labeled_percent = 0.0 + + remaining_percent = max(0.0, 100.0 - labeled_percent) + + return LabelProgress( + labeled_points=labeled_points, + total_points=total_points, + labeled_percent=labeled_percent, + remaining_percent=remaining_percent, + frame_count=frame_count, + bodypart_count=bodypart_count, + individual_count=individual_count, + ) + + +def infer_folder_display_name( + active_layer, + *, + fallback_root: str | None = None, +) -> str: + """ + Best-effort label for the current image/video folder context. + """ + if active_layer is None: + return "—" + + md = getattr(active_layer, "metadata", {}) or {} + + paths = md.get("paths") or [] + if paths: + try: + return Path(paths[0]).expanduser().parent.name or "—" + except Exception: + pass + + root = md.get("root") or fallback_root + if root: + try: + return Path(root).expanduser().name or "—" + except Exception: + pass + + try: + src = getattr(getattr(active_layer, "source", None), "path", None) + if src: + p = Path(str(src)) + if p.is_file(): + # video source: show parent folder name + return p.parent.name or p.stem or "—" + return p.name or "—" + except Exception: + pass + + return "—" + + +def find_relevant_image_layer(viewer) -> Image | None: + active = viewer.layers.selection.active + if isinstance(active, Image): + return active + + for layer in viewer.layers: + if isinstance(layer, Image): + return layer + + return None diff --git a/src/napari_deeplabcut/core/provenance.py b/src/napari_deeplabcut/core/provenance.py index d1394b69..78b9d34b 100644 --- a/src/napari_deeplabcut/core/provenance.py +++ b/src/napari_deeplabcut/core/provenance.py @@ -7,12 +7,13 @@ from pydantic import ValidationError -import napari_deeplabcut.core.io as io -from napari_deeplabcut import misc from napari_deeplabcut.config.models import AnnotationKind, IOProvenance, PointsMetadata from napari_deeplabcut.core.errors import MissingProvenanceError, UnresolvablePathError from napari_deeplabcut.core.metadata import parse_points_metadata -from napari_deeplabcut.core.project_paths import infer_dlc_project_from_points_meta, is_windows_absolute_path +from napari_deeplabcut.core.project_paths import ( + infer_dlc_project_from_points_meta, + is_windows_absolute_path, +) logger = logging.getLogger(__name__) @@ -20,22 +21,6 @@ # ---------------------------------------- # Helper functions # ---------------------------------------- -def find_config_scorer_nearby(anchor: str) -> str | None: - """ - Best-effort lookup of DLC config.yaml scorer near a folder anchor. - """ - try: - cfg_path = misc.find_project_config_path(anchor) - if cfg_path: - cfg = io.load_config(cfg_path) - scorer = cfg.get("scorer") - if isinstance(scorer, str) and scorer.strip(): - return scorer.strip() - except Exception: - pass - return None - - def suggest_human_placeholder(anchor: str) -> str: """ Deterministic fallback scorer placeholder derived from anchor path. diff --git a/src/napari_deeplabcut/misc.py b/src/napari_deeplabcut/misc.py index f3c05b69..6165456e 100644 --- a/src/napari_deeplabcut/misc.py +++ b/src/napari_deeplabcut/misc.py @@ -5,7 +5,6 @@ from collections.abc import Sequence from enum import Enum, EnumMeta from itertools import cycle -from pathlib import Path from typing import Protocol import numpy as np @@ -24,10 +23,6 @@ def bodyparts(self) -> list[str]: ... def individuals(self) -> list[str]: ... -def find_project_config_path(labeled_data_path: str) -> str: - return str(Path(labeled_data_path).parents[2] / "config.yaml") - - @deprecated( since="napari-deeplabcut>0.2.1.8, DLC>3.0.0rc14", mode=DeprecationMode.WARN, diff --git a/src/napari_deeplabcut/ui/cropping.py b/src/napari_deeplabcut/ui/cropping.py index 4d599035..65a375e6 100644 --- a/src/napari_deeplabcut/ui/cropping.py +++ b/src/napari_deeplabcut/ui/cropping.py @@ -16,7 +16,7 @@ import napari_deeplabcut.core.io as io from napari_deeplabcut._writer import _write_image from napari_deeplabcut.core.conflicts import compute_overwrite_report_for_extracted_labels_row -from napari_deeplabcut.core.dataframes import guarantee_multiindex_rows +from napari_deeplabcut.core.dataframes import align_old_new, guarantee_multiindex_rows from napari_deeplabcut.core.project_paths import ( canonicalize_path, infer_dlc_project, @@ -741,6 +741,7 @@ def execute_frame_extraction(plan: FrameExtractionPlan) -> tuple[list[Path], str df_prev = pd.read_hdf(plan.labels_path) guarantee_multiindex_rows(df_prev) + df_prev, df_new = align_old_new(df_prev, df_new) df_merged = pd.concat([df_prev, df_new]) # IMPORTANT: diff --git a/src/napari_deeplabcut/ui/dialogs.py b/src/napari_deeplabcut/ui/dialogs.py index c405b5d3..ba7333e7 100644 --- a/src/napari_deeplabcut/ui/dialogs.py +++ b/src/napari_deeplabcut/ui/dialogs.py @@ -26,6 +26,7 @@ QWidget, ) +import napari_deeplabcut.core.io as io from napari_deeplabcut.config.keybinds import iter_shortcuts from napari_deeplabcut.config.settings import get_overwrite_confirmation_enabled from napari_deeplabcut.core.conflicts import OverwriteConflictReport @@ -483,16 +484,63 @@ class ProjectConfigPromptAction(str, Enum): class ProjectConfigPromptResult: action: ProjectConfigPromptAction config_path: str | None = None + scorer: str | None = None + + +def load_scorer_from_config(config_path: str | Path) -> str | None: + """Return the non-empty DLC scorer from a config.yaml, if present.""" + cfg = io.load_config(str(config_path)) + scorer = cfg.get("scorer") if isinstance(cfg, dict) else None + if isinstance(scorer, str) and scorer.strip(): + return scorer.strip() + return None + + +def warn_invalid_config_for_scorer( + parent, + *, + config_path: str | Path, + reason: str = "missing_scorer", + auto_found: bool = False, +) -> None: + """Explain why a config.yaml cannot be used to resolve a scorer.""" + config_path = str(config_path) + + if reason == "unreadable": + if auto_found: + text = ( + "A DeepLabCut config.yaml was found automatically, but it could not be read:\n\n" + f"{config_path}\n\n" + "Please fix the file or choose another config.yaml." + ) + else: + text = f"The selected file could not be read as a DeepLabCut config.yaml:\n\n{config_path}" + else: + if auto_found: + text = ( + "A DeepLabCut config.yaml was found automatically, but its 'scorer' field is missing or empty:\n\n" + f"{config_path}\n\n" + "Please fix the config.yaml scorer or choose another valid project configuration." + ) + else: + text = f"The selected config.yaml does not define a valid non-empty 'scorer' field:\n\n{config_path}" + + QMessageBox.warning(parent, "Invalid project configuration", text) def prompt_for_project_config_for_save( parent, *, initial_dir: str | None = None, + window_title: str = "Associate folder with DLC project?", + message: str | None = None, + choose_button_text: str = "Choose config.yaml", + skip_button_text: str = "Continue without association", + resolve_scorer: bool = False, ) -> ProjectConfigPromptResult: """ Ask the user whether to associate the current labeled folder with an - existing DLC project. + existing DLC project, optionally resolving a scorer from the selected config. Returns ------- @@ -500,21 +548,32 @@ def prompt_for_project_config_for_save( - ASSOCIATE: user selected a config.yaml - SKIP: user explicitly chose not to associate, but wants to continue - CANCEL: user cancelled the flow and the caller should abort save + + Notes + ----- + If resolve_scorer=True: + - the selected config.yaml is loaded immediately + - its scorer is validated + - the returned result includes `scorer` + - invalid/unreadable config files are warned about and the flow is cancelled """ msg = QMessageBox(parent) msg.setIcon(QMessageBox.Question) - msg.setWindowTitle("Associate folder with DLC project?") + msg.setWindowTitle(window_title) msg.setText( - "No DLC project root could be inferred for this layer.\n\n" - "Do you want to choose a config.yaml so this labeled folder can be saved " - "using DeepLabCut's standard dataset paths?\n\n" - "Important: the current folder name will become the DLC dataset name:\n" - "labeled-data//...\n\n" - "This will not move files on disk or edit config.yaml." + message + or ( + "No DLC project root could be inferred for this layer.\n\n" + "Do you want to choose a config.yaml so this labeled folder can be saved " + "using DeepLabCut's standard dataset paths?\n\n" + "Important: the current folder name will become the DLC dataset name:\n" + "labeled-data//...\n\n" + "This will not move files on disk or edit config.yaml." + ) ) - yes_btn = msg.addButton("Choose config.yaml", QMessageBox.YesRole) - no_btn = msg.addButton("Continue without association", QMessageBox.NoRole) + yes_btn = msg.addButton(choose_button_text, QMessageBox.YesRole) + no_btn = msg.addButton(skip_button_text, QMessageBox.NoRole) msg.addButton(QMessageBox.Cancel) msg.setDefaultButton(yes_btn) @@ -538,9 +597,32 @@ def prompt_for_project_config_for_save( if not filename: return ProjectConfigPromptResult(ProjectConfigPromptAction.CANCEL) + scorer = None + if resolve_scorer: + try: + scorer = load_scorer_from_config(filename) + except Exception: + warn_invalid_config_for_scorer( + parent, + config_path=filename, + reason="unreadable", + auto_found=False, + ) + return ProjectConfigPromptResult(ProjectConfigPromptAction.CANCEL) + + if not scorer: + warn_invalid_config_for_scorer( + parent, + config_path=filename, + reason="missing_scorer", + auto_found=False, + ) + return ProjectConfigPromptResult(ProjectConfigPromptAction.CANCEL) + return ProjectConfigPromptResult( ProjectConfigPromptAction.ASSOCIATE, config_path=filename, + scorer=scorer, ) diff --git a/src/napari_deeplabcut/ui/layer_stats.py b/src/napari_deeplabcut/ui/layer_stats.py new file mode 100644 index 00000000..1b3d9b56 --- /dev/null +++ b/src/napari_deeplabcut/ui/layer_stats.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +from qtpy.QtCore import QSignalBlocker, Qt, Signal +from qtpy.QtWidgets import ( + QFormLayout, + QGraphicsOpacityEffect, + QGroupBox, + QHBoxLayout, + QLabel, + QSlider, + QVBoxLayout, + QWidget, +) + + +class LayerStatusPanel(QGroupBox): + """ + Small dock-widget panel showing: + - current folder name + - labeling progress + - point size control (slider) + """ + + point_size_changed = Signal(int) + point_size_commit_requested = Signal(int) + + def __init__(self, parent: QWidget | None = None): + super().__init__("Layer status", parent=parent) + + self._folder_value = QLabel("—") + self._folder_value.setTextInteractionFlags(Qt.TextSelectableByMouse) + + self._progress_value = QLabel("No active keypoints layer") + self._progress_value.setWordWrap(True) + self._progress_value.setTextInteractionFlags(Qt.TextSelectableByMouse) + + self._size_slider = QSlider(Qt.Horizontal, self) + self._size_slider.setRange(1, 100) + self._size_slider.setSingleStep(1) + self._size_slider.setPageStep(2) + self._size_slider.setValue(6) + self._size_opacity = QGraphicsOpacityEffect(self._size_slider) + self._size_slider.setGraphicsEffect(self._size_opacity) + + self._size_value = QLabel("6") + self._size_value.setMinimumWidth(28) + self._size_value.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + + # Dedicated container for the whole size-control row + self._size_controls = QWidget(self) + size_row = QHBoxLayout(self._size_controls) + size_row.setContentsMargins(0, 0, 0, 0) + size_row.addWidget(self._size_slider, stretch=1) + size_row.addWidget(self._size_value, stretch=0) + + size_tooltip = "Point size for the active DLC keypoints layer. Saved to config.yaml as dotsize when changed." + self._size_slider.setToolTip(size_tooltip) + self._size_value.setToolTip(size_tooltip) + self._size_controls.setToolTip(size_tooltip) + + form = QFormLayout() + form.addRow("Folder", self._folder_value) + form.addRow("Progress", self._progress_value) + form.addRow("Point size", self._size_controls) + + wrapper = QVBoxLayout(self) + wrapper.addLayout(form) + + self._size_slider.setTracking(False) + + self._size_slider.sliderMoved.connect(self._on_slider_moved_preview) + self._size_slider.valueChanged.connect(self._on_value_changed_commit) + + self.set_point_size_enabled(False, reason="Select a DLC keypoints layer to edit point size.") + + def _on_slider_moved_preview(self, value: int) -> None: + self._size_value.setText(str(int(value))) + self.point_size_changed.emit(int(value)) # visual only + + def _on_value_changed_commit(self, value: int) -> None: + self._size_value.setText(str(int(value))) + # Ensure non-mouse / programmatic changes also update the visual layer size + self.point_size_changed.emit(int(value)) # visual update on commit + self.point_size_commit_requested.emit(int(value)) # save / persist + + def _emit_commit(self) -> None: + self.point_size_commit_requested.emit(self.point_size()) + + def point_size(self) -> int: + return int(self._size_slider.value()) + + def set_point_size(self, value: int) -> None: + blocker = QSignalBlocker(self._size_slider) + self._size_slider.setValue(int(value)) + del blocker + self._size_value.setText(str(int(value))) + + def set_point_size_enabled(self, enabled: bool, *, reason: str | None = None) -> None: + enabled = bool(enabled) + + # Disable the entire container so both slider and label get proper native disabled styling + self._size_controls.setEnabled(enabled) + self._size_slider.setEnabled(enabled) + self._size_value.setEnabled(enabled) + + opacity = 1.0 if enabled else 0.35 + self._size_opacity.setOpacity(opacity) + + tooltip = ( + "Point size for the active DLC keypoints layer. Saved to config.yaml as dotsize when changed." + if enabled + else (reason or "Select a DLC keypoints layer to edit point size.") + ) + + self._size_controls.setToolTip(tooltip) + self._size_slider.setToolTip(tooltip) + self._size_value.setToolTip(tooltip) + + def set_folder_name(self, folder_name: str) -> None: + self._folder_value.setText(folder_name or "—") + + def set_progress_summary( + self, + *, + labeled_percent: float, + remaining_percent: float, + labeled_points: int, + total_points: int, + frame_count: int, + bodypart_count: int, + individual_count: int, + ) -> None: + if total_points <= 0: + self._progress_value.setText("Not enough metadata to estimate progress yet") + self._progress_value.setToolTip("") + return + + if individual_count <= 1: + breakdown = f"{frame_count} frames × {bodypart_count} bodyparts" + else: + breakdown = f"{frame_count} frames × {bodypart_count} bodyparts × {individual_count} individuals" + + self._progress_value.setText(f"{labeled_percent:.1f}% labeled") + self._progress_value.setToolTip( + f"{labeled_percent:.1f}% labeled, {remaining_percent:.1f}% remaining\n" + f"{labeled_points}/{total_points} of all possible points labeled • {breakdown}" + ) + + def set_no_active_points_layer(self) -> None: + self._progress_value.setText("No active keypoints layer") + self._progress_value.setToolTip("") + self.set_point_size_enabled(False, reason="Select a DLC keypoints layer to edit point size.") + + def set_invalid_points_layer(self) -> None: + self._progress_value.setText("Active layer is not a DLC keypoints layer") + self._progress_value.setToolTip("") + self.set_point_size_enabled(False, reason="This control only works for DLC keypoints layers.")