Skip to content

Commit a24e58f

Browse files
committed
Improve config discovery and scorer resolution
Centralize and harden config.yaml discovery and scorer resolution across the plugin. Key changes: - Prefer find_nearest_config for automatic config lookup and switch core/io to use it when loading colormap for HDF files. - Remove legacy misc.find_project_config_path and provenance.find_config_scorer_nearby; provenance now imports find_nearest_config. - Overhauled the save flow in KeypointControls: auto-load scorer from an auto-discovered config, present a project-config chooser when no config is found, validate unreadable/missing scorers, fall back to sidecar or manual prompt only when appropriate, and persist sidecar defaults when possible. - Add UI dialog helpers in ui/dialogs.py: load_scorer_from_config, warn_invalid_config_for_scorer, and extend prompt_for_project_config_for_save to optionally resolve and return a scorer. - Add module-level note in _widgets.py recommending future refactors to keep the file small. These changes make project association and scorer selection more robust and user-friendly, add clearer warnings for invalid configs, and consolidate config lookup logic.
1 parent 519ace6 commit a24e58f

5 files changed

Lines changed: 194 additions & 61 deletions

File tree

src/napari_deeplabcut/_widgets.py

Lines changed: 92 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
1-
"""Main widget and controls for napari-deeplabcut, including the tutorial and shortcuts windows."""
1+
"""Main widget and controls for napari-deeplabcut, including the tutorial and shortcuts windows.
2+
3+
NOTE: This file is generally already too long. For future development, please consider:
4+
- Moving existing responsibilities out into separate modules (existing or new)
5+
- Avoiding adding anything that is not strictly related to :
6+
- Building the final UI (blocks can be moved to ui/ for better organization)
7+
- Wiring to the core plugin functionality (e.g. via signals/slots, method calls, etc.)
8+
- Anything that requires the full widget+viewer+signal/event context to function properly
9+
- Similarly, test_widgets.py is a bit of a default drawer right now, please create new tests in _tests/ui
10+
"""
211

312
# src/napari_deeplabcut/_widgets.py
413
from __future__ import annotations
@@ -79,7 +88,7 @@
7988
)
8089
from napari_deeplabcut.core.provenance import (
8190
apply_gt_save_target,
82-
find_config_scorer_nearby,
91+
find_nearest_config,
8392
is_projectless_folder_association_candidate,
8493
requires_gt_promotion,
8594
suggest_human_placeholder,
@@ -1447,31 +1456,89 @@ def _ensure_promotion_save_target(self, layer: Points) -> bool:
14471456
QMessageBox.warning(self, "Cannot save", "Could not determine a folder anchor for saving.")
14481457
return False
14491458

1450-
scorer = find_config_scorer_nearby(anchor) or get_default_scorer(anchor)
1451-
if not scorer:
1452-
suggested = suggest_human_placeholder(anchor)
1453-
while True:
1454-
s = _prompt_for_scorer(self, anchor=anchor, suggested=suggested)
1455-
if s is None:
1456-
return False
1457-
if s.startswith("human_"):
1458-
choice = QMessageBox.question(
1459-
self,
1460-
"Generic scorer name",
1461-
"You entered a generic scorer name starting with 'human_'.\n\n"
1462-
"We strongly recommend using a real name or stable identifier.\n"
1463-
"Do you want to keep this generic scorer anyway?",
1464-
QMessageBox.Yes | QMessageBox.No,
1465-
)
1466-
if choice == QMessageBox.No:
1467-
suggested = s
1468-
continue
1469-
scorer = s
1470-
break
1459+
scorer = None
1460+
1461+
# 1) Auto-discovered config.yaml always wins
1462+
cfg_path = None
1463+
try:
1464+
cfg_path = find_nearest_config(anchor)
1465+
except Exception:
1466+
logger.debug("Automatic config discovery failed for anchor=%r", anchor, exc_info=True)
1467+
1468+
if cfg_path:
14711469
try:
1472-
set_default_scorer(anchor, scorer)
1470+
scorer = ui_dialogs.load_scorer_from_config(cfg_path)
14731471
except Exception:
1474-
logger.debug("Failed to persist default scorer to sidecar", exc_info=True)
1472+
logger.exception("Failed to load auto-discovered config.yaml: %s", cfg_path)
1473+
ui_dialogs.warn_invalid_config_for_scorer(
1474+
self,
1475+
config_path=cfg_path,
1476+
reason="unreadable",
1477+
auto_found=True,
1478+
)
1479+
return False
1480+
1481+
if not scorer:
1482+
ui_dialogs.warn_invalid_config_for_scorer(
1483+
self,
1484+
config_path=cfg_path,
1485+
reason="missing_scorer",
1486+
auto_found=True,
1487+
)
1488+
return False
1489+
1490+
else:
1491+
# 2) No config found automatically -> let the user choose one
1492+
dialog_result = ui_dialogs.prompt_for_project_config_for_save(
1493+
self,
1494+
initial_dir=self._project_path or anchor,
1495+
window_title="Locate DLC config for scorer resolution",
1496+
message=(
1497+
"No DeepLabCut config.yaml could be found automatically for this machine-labeled layer.\n\n"
1498+
"If this layer belongs to a DLC project, choose its config.yaml so the save uses the "
1499+
"project scorer and standard naming.\n\n"
1500+
"If no config.yaml exists, you can continue without one."
1501+
),
1502+
choose_button_text="Choose config.yaml",
1503+
skip_button_text="Continue without config",
1504+
resolve_scorer=True,
1505+
)
1506+
1507+
if dialog_result.action is ui_dialogs.ProjectConfigPromptAction.CANCEL:
1508+
return False
1509+
1510+
if dialog_result.action is ui_dialogs.ProjectConfigPromptAction.ASSOCIATE:
1511+
scorer = dialog_result.scorer
1512+
1513+
else:
1514+
# 3) Only if no config is available at all may sidecar be consulted
1515+
scorer = get_default_scorer(anchor)
1516+
1517+
# 4) Final fallback: prompt manually
1518+
if not scorer:
1519+
suggested = suggest_human_placeholder(anchor)
1520+
while True:
1521+
s = _prompt_for_scorer(self, anchor=anchor, suggested=suggested)
1522+
if s is None:
1523+
return False
1524+
if s.startswith("human_"):
1525+
choice = QMessageBox.question(
1526+
self,
1527+
"Generic scorer name",
1528+
"You entered a generic scorer name starting with 'human_'.\n\n"
1529+
"We strongly recommend using a real name or stable identifier.\n"
1530+
"Do you want to keep this generic scorer anyway?",
1531+
QMessageBox.Yes | QMessageBox.No,
1532+
)
1533+
if choice == QMessageBox.No:
1534+
suggested = s
1535+
continue
1536+
scorer = s
1537+
break
1538+
try:
1539+
set_default_scorer(anchor, scorer)
1540+
except Exception:
1541+
logger.debug("Failed to persist default scorer to sidecar", exc_info=True)
14751542

14761543
updated = apply_gt_save_target(
14771544
pts,

src/napari_deeplabcut/core/io.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,11 @@
4848
from napari_deeplabcut.core.errors import AmbiguousSaveError, MissingProvenanceError
4949
from napari_deeplabcut.core.layers import populate_keypoint_layer_properties
5050
from napari_deeplabcut.core.metadata import attach_source_and_io_to_layer_kwargs, parse_points_metadata
51-
from napari_deeplabcut.core.project_paths import canonicalize_path, infer_dlc_project_from_points_meta
51+
from napari_deeplabcut.core.project_paths import (
52+
canonicalize_path,
53+
find_nearest_config,
54+
infer_dlc_project_from_points_meta,
55+
)
5256
from napari_deeplabcut.core.provenance import resolve_output_path_from_metadata
5357

5458
logger = logging.getLogger(__name__)
@@ -135,7 +139,7 @@ def read_hdf_single(file: Path, *, kind: AnnotationKind | None = None) -> list[L
135139
# Handle legacy/single-animal column layout by inserting empty "individuals" level.
136140
# Colormap selection also falls back to config when possible.
137141
try:
138-
cfg = load_config(misc.find_project_config_path(str(file)))
142+
cfg = load_config(find_nearest_config(file, max_levels=3))
139143
config_colormap = str(cfg.get("colormap", DEFAULT_SINGLE_ANIMAL_CMAP))
140144
except Exception as e:
141145
logger.warning("Could not load config for %s; falling back to default colormap. Error: %s", file, e)

src/napari_deeplabcut/core/provenance.py

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,35 +7,20 @@
77

88
from pydantic import ValidationError
99

10-
import napari_deeplabcut.core.io as io
11-
from napari_deeplabcut import misc
1210
from napari_deeplabcut.config.models import AnnotationKind, IOProvenance, PointsMetadata
1311
from napari_deeplabcut.core.errors import MissingProvenanceError, UnresolvablePathError
1412
from napari_deeplabcut.core.metadata import parse_points_metadata
15-
from napari_deeplabcut.core.project_paths import infer_dlc_project_from_points_meta, is_windows_absolute_path
13+
from napari_deeplabcut.core.project_paths import (
14+
infer_dlc_project_from_points_meta,
15+
is_windows_absolute_path,
16+
)
1617

1718
logger = logging.getLogger(__name__)
1819

1920

2021
# ----------------------------------------
2122
# Helper functions
2223
# ----------------------------------------
23-
def find_config_scorer_nearby(anchor: str) -> str | None:
24-
"""
25-
Best-effort lookup of DLC config.yaml scorer near a folder anchor.
26-
"""
27-
try:
28-
cfg_path = misc.find_project_config_path(anchor)
29-
if cfg_path:
30-
cfg = io.load_config(cfg_path)
31-
scorer = cfg.get("scorer")
32-
if isinstance(scorer, str) and scorer.strip():
33-
return scorer.strip()
34-
except Exception:
35-
pass
36-
return None
37-
38-
3924
def suggest_human_placeholder(anchor: str) -> str:
4025
"""
4126
Deterministic fallback scorer placeholder derived from anchor path.

src/napari_deeplabcut/misc.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
from collections.abc import Sequence
66
from enum import Enum, EnumMeta
77
from itertools import cycle
8-
from pathlib import Path
98
from typing import Protocol
109

1110
import numpy as np
@@ -24,10 +23,6 @@ def bodyparts(self) -> list[str]: ...
2423
def individuals(self) -> list[str]: ...
2524

2625

27-
def find_project_config_path(labeled_data_path: str) -> str:
28-
return str(Path(labeled_data_path).parents[2] / "config.yaml")
29-
30-
3126
@deprecated(
3227
since="napari-deeplabcut>0.2.1.8, DLC>3.0.0rc14",
3328
mode=DeprecationMode.WARN,

src/napari_deeplabcut/ui/dialogs.py

Lines changed: 92 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
QWidget,
2727
)
2828

29+
import napari_deeplabcut.core.io as io
2930
from napari_deeplabcut.config.keybinds import iter_shortcuts
3031
from napari_deeplabcut.config.settings import get_overwrite_confirmation_enabled
3132
from napari_deeplabcut.core.conflicts import OverwriteConflictReport
@@ -483,38 +484,96 @@ class ProjectConfigPromptAction(str, Enum):
483484
class ProjectConfigPromptResult:
484485
action: ProjectConfigPromptAction
485486
config_path: str | None = None
487+
scorer: str | None = None
488+
489+
490+
def load_scorer_from_config(config_path: str | Path) -> str | None:
491+
"""Return the non-empty DLC scorer from a config.yaml, if present."""
492+
cfg = io.load_config(str(config_path))
493+
scorer = cfg.get("scorer") if isinstance(cfg, dict) else None
494+
if isinstance(scorer, str) and scorer.strip():
495+
return scorer.strip()
496+
return None
497+
498+
499+
def warn_invalid_config_for_scorer(
500+
parent,
501+
*,
502+
config_path: str | Path,
503+
reason: str = "missing_scorer",
504+
auto_found: bool = False,
505+
) -> None:
506+
"""Explain why a config.yaml cannot be used to resolve a scorer."""
507+
config_path = str(config_path)
508+
509+
if reason == "unreadable":
510+
if auto_found:
511+
text = (
512+
"A DeepLabCut config.yaml was found automatically, but it could not be read:\n\n"
513+
f"{config_path}\n\n"
514+
"Please fix the file or choose another config.yaml."
515+
)
516+
else:
517+
text = f"The selected file could not be read as a DeepLabCut config.yaml:\n\n{config_path}"
518+
else:
519+
if auto_found:
520+
text = (
521+
"A DeepLabCut config.yaml was found automatically, but its 'scorer' field is missing or empty:\n\n"
522+
f"{config_path}\n\n"
523+
"Please fix the config.yaml scorer or choose another valid project configuration."
524+
)
525+
else:
526+
text = f"The selected config.yaml does not define a valid non-empty 'scorer' field:\n\n{config_path}"
527+
528+
QMessageBox.warning(parent, "Invalid project configuration", text)
486529

487530

488531
def prompt_for_project_config_for_save(
489532
parent,
490533
*,
491534
initial_dir: str | None = None,
535+
window_title: str = "Associate folder with DLC project?",
536+
message: str | None = None,
537+
choose_button_text: str = "Choose config.yaml",
538+
skip_button_text: str = "Continue without association",
539+
resolve_scorer: bool = False,
492540
) -> ProjectConfigPromptResult:
493541
"""
494542
Ask the user whether to associate the current labeled folder with an
495-
existing DLC project.
543+
existing DLC project, optionally resolving a scorer from the selected config.
496544
497545
Returns
498546
-------
499547
ProjectConfigPromptResult
500548
- ASSOCIATE: user selected a config.yaml
501549
- SKIP: user explicitly chose not to associate, but wants to continue
502550
- CANCEL: user cancelled the flow and the caller should abort save
551+
552+
Notes
553+
-----
554+
If resolve_scorer=True:
555+
- the selected config.yaml is loaded immediately
556+
- its scorer is validated
557+
- the returned result includes `scorer`
558+
- invalid/unreadable config files are warned about and the flow is cancelled
503559
"""
504560
msg = QMessageBox(parent)
505561
msg.setIcon(QMessageBox.Question)
506-
msg.setWindowTitle("Associate folder with DLC project?")
562+
msg.setWindowTitle(window_title)
507563
msg.setText(
508-
"No DLC project root could be inferred for this layer.\n\n"
509-
"Do you want to choose a config.yaml so this labeled folder can be saved "
510-
"using DeepLabCut's standard dataset paths?\n\n"
511-
"Important: the current folder name will become the DLC dataset name:\n"
512-
"labeled-data/<current-folder-name>/...\n\n"
513-
"This will not move files on disk or edit config.yaml."
564+
message
565+
or (
566+
"No DLC project root could be inferred for this layer.\n\n"
567+
"Do you want to choose a config.yaml so this labeled folder can be saved "
568+
"using DeepLabCut's standard dataset paths?\n\n"
569+
"Important: the current folder name will become the DLC dataset name:\n"
570+
"labeled-data/<current-folder-name>/...\n\n"
571+
"This will not move files on disk or edit config.yaml."
572+
)
514573
)
515574

516-
yes_btn = msg.addButton("Choose config.yaml", QMessageBox.YesRole)
517-
no_btn = msg.addButton("Continue without association", QMessageBox.NoRole)
575+
yes_btn = msg.addButton(choose_button_text, QMessageBox.YesRole)
576+
no_btn = msg.addButton(skip_button_text, QMessageBox.NoRole)
518577
msg.addButton(QMessageBox.Cancel)
519578

520579
msg.setDefaultButton(yes_btn)
@@ -538,9 +597,32 @@ def prompt_for_project_config_for_save(
538597
if not filename:
539598
return ProjectConfigPromptResult(ProjectConfigPromptAction.CANCEL)
540599

600+
scorer = None
601+
if resolve_scorer:
602+
try:
603+
scorer = load_scorer_from_config(filename)
604+
except Exception:
605+
warn_invalid_config_for_scorer(
606+
parent,
607+
config_path=filename,
608+
reason="unreadable",
609+
auto_found=False,
610+
)
611+
return ProjectConfigPromptResult(ProjectConfigPromptAction.CANCEL)
612+
613+
if not scorer:
614+
warn_invalid_config_for_scorer(
615+
parent,
616+
config_path=filename,
617+
reason="missing_scorer",
618+
auto_found=False,
619+
)
620+
return ProjectConfigPromptResult(ProjectConfigPromptAction.CANCEL)
621+
541622
return ProjectConfigPromptResult(
542623
ProjectConfigPromptAction.ASSOCIATE,
543624
config_path=filename,
625+
scorer=scorer,
544626
)
545627

546628

0 commit comments

Comments
 (0)