diff --git a/winnow/cli.py b/winnow/cli.py index 379be13..55391a0 100644 --- a/winnow/cli.py +++ b/winnow/cli.py @@ -131,10 +131,15 @@ def _handle_duplicate_people(people: list[dict]) -> list[dict]: if merged_any: rprint(" [dim]Re-fetching people after merge...[/dim]") fresh = get_people() + if not fresh: + # Retry once: get_people() returns [] for both transient failures and + # auth errors (401); a second empty result strongly suggests a real failure. + fresh = get_people() if not fresh: logger.warning( - "Re-fetch after merge returned no people" - " — possible transient error; proceeding with pre-merge list" + "Re-fetch after merge returned no people (tried twice)" + " — possible transient error or expired API key;" + " proceeding with pre-merge list. Check IMMICH_API_KEY if this recurs." ) return [p for p in people if p["id"] not in skip_ids] # Filter out the smaller duplicate from any group whose merge failed — those diff --git a/winnow/diversity.py b/winnow/diversity.py index 5f85f7a..3c6ad8d 100644 --- a/winnow/diversity.py +++ b/winnow/diversity.py @@ -556,10 +556,11 @@ def _cluster_aware_selection( for idx in selected: min_dists[idx] = -np.inf + # Hard example weighting: boost distance for low-confidence candidates. + # conf_array is constant after this point, so compute once outside the loop. + hard_weight = np.where(conf_array < 0.85, 1.0 + (0.85 - conf_array) * 2.0, 1.0) + while len(selected) < target: - # Hard example weighting: boost distance for low-confidence candidates - # Confidence < 0.85 gets up to 1.5× distance boost - hard_weight = np.where(conf_array < 0.85, 1.0 + (0.85 - conf_array) * 2.0, 1.0) weighted_dists = min_dists * hard_weight best_idx = int(np.argmax(weighted_dists)) diff --git a/winnow/executor.py b/winnow/executor.py index f2d138e..07b72b6 100644 --- a/winnow/executor.py +++ b/winnow/executor.py @@ -363,6 +363,11 @@ def upload_to_frigate(jobs: list[dict]) -> None: progress.console.print( f" [dim]{name}: first run — Frigate diversity scoring will apply from the next run[/dim]" ) + # Snapshot whether Frigate has a model before the upload loop starts. + # effective_count is incremented inside the loop on each successful upload, + # so using the live value would incorrectly trigger recognize_face calls + # mid-batch on the first run (after the first upload sets it to 1). + has_frigate_model = effective_count > 0 actually_uploaded: list[tuple[str, str | None]] = [] failed_deletes: set[str] = set() min_quality_score_for_slot: float | None = None @@ -392,7 +397,7 @@ def upload_to_frigate(jobs: list[dict]) -> None: # Pre-upload Frigate score — clean measurement (image not yet in training set). # Called for all below-cap uploads (seeds frigate_scores for future at-cap # replacement) and for at-cap uploads when scores already exist. Skipped on - # skipped when effective_count == 0 since Frigate has no model yet. + # skipped when has_frigate_model is False (effective_count was 0 before the loop). # recognize_face returns (face_name, score); we only use the score when the # best match is for the correct person. Mismatches (or "unknown") are treated # as None so a wrong-person score never drives a ceiling skip or replacement. @@ -408,7 +413,7 @@ def upload_to_frigate(jobs: list[dict]) -> None: # rebuild-complete signal, poll it between recognize calls during replacement # sequences rather than accepting stale/None scores. pre_fscore: float | None = None - if Config.ENABLE_FRIGATE_SCORES and effective_count > 0: + if Config.ENABLE_FRIGATE_SCORES and has_frigate_model: if not at_cap or person_has_fscores: _result = recognize_face(fpath) if _result is not None and (_result[0] or "").casefold() == name.casefold(): diff --git a/winnow/jobs.py b/winnow/jobs.py index 2145879..396fc7a 100644 --- a/winnow/jobs.py +++ b/winnow/jobs.py @@ -301,10 +301,12 @@ def auto_configure(people: list[dict]) -> list[dict]: # decides per-image whether to swap; any candidate could be an improvement). if not quality_replacement_only: if limit == "auto": - # Switch from open-ended auto to a fixed budget at remaining capacity - # so the diversity selector itself stops at the right count instead of - # selecting more than MAX_AUTO_IMAGES and overflowing the cap. - limit = capacity + if already_uploaded > 0: + # Switch from open-ended auto to a fixed budget at remaining capacity + # so the diversity selector stops at the right count instead of + # selecting more than MAX_AUTO_IMAGES and overflowing the cap. + # First runs keep limit="auto" so FPS adaptive early-stop can fire. + limit = capacity else: limit = min(limit, capacity)