From f322eba380fc114af9de8f8de809fbda1404cdbc Mon Sep 17 00:00:00 2001 From: Holden Date: Sat, 13 Jun 2026 16:32:47 +0000 Subject: [PATCH] feat: dynamic Frigate score threshold from stored set minimum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At the start of each person's upload phase, compute the minimum stored Frigate recognition score across all currently mapped files. Use max(config_threshold, dynamic_min) as the effective gate threshold so new uploads must score at least as well as the weakest image already in the training set. Prevents overtraining well-recognised people: if all 80 images score ≥0.85, the dynamic threshold becomes ~0.85 and new additions that score below that are removed rather than diluting a good training set. Co-Authored-By: Claude Sonnet 4.6 --- winnow/executor.py | 19 ++++++++++++++----- winnow/upload_tracker.py | 15 +++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/winnow/executor.py b/winnow/executor.py index 16c2982..626c242 100644 --- a/winnow/executor.py +++ b/winnow/executor.py @@ -21,6 +21,7 @@ from .quality import assess_quality from .upload_tracker import ( get_frigate_filename_for_asset, get_lowest_quality_mapped_file, + get_min_frigate_score, get_tracked_frigate_file_count, get_tracked_frigate_filenames, has_frigate_scores, @@ -359,6 +360,15 @@ def upload_to_frigate(jobs: list[dict]) -> None: effective_count = get_tracked_frigate_file_count(name) pre_run_count = effective_count quality_replacement = job.get("config", {}).get("quality_replacement", False) + # Dynamic threshold: at least as strict as the weakest image already stored. + # Takes whichever is higher — the configured floor or the current set minimum. + _dynamic = get_min_frigate_score(name) + effective_threshold = max( + Config.FRIGATE_SCORE_THRESHOLD, + _dynamic if _dynamic is not None else 0.0, + ) + if _dynamic is not None and _dynamic > Config.FRIGATE_SCORE_THRESHOLD: + logger.debug(f"{name}: dynamic Frigate score threshold {_dynamic:.3f}") actually_uploaded: list[tuple[str, str | None]] = [] failed_deletes: set[str] = set() quality_gate_failed: set[str] = set() @@ -461,17 +471,16 @@ def upload_to_frigate(jobs: list[dict]) -> None: # Flag for post-reconcile removal if below threshold. # We don't know the Frigate filename yet — reconcile maps # it first, then we delete using the mapped name. - threshold = Config.FRIGATE_SCORE_THRESHOLD if ( - threshold > 0 + effective_threshold > 0 and pre_run_count > 0 and post_fscore is not None - and post_fscore < threshold + and post_fscore < effective_threshold ): quality_gate_failed.add(asset_id) progress.console.print( f" [yellow]⚠ {fname}: Frigate score {post_fscore:.2f}" - f" < threshold {threshold:.2f}, will remove after mapping[/yellow]" + f" < threshold {effective_threshold:.2f}, will remove after mapping[/yellow]" ) break @@ -556,7 +565,7 @@ def upload_to_frigate(jobs: list[dict]) -> None: if removed: progress.console.print( f" [yellow]🗑 {name}: removed {removed} image(s) below" - f" Frigate score threshold ({Config.FRIGATE_SCORE_THRESHOLD:.2f})[/yellow]" + f" Frigate score threshold ({effective_threshold:.2f})[/yellow]" ) # Per-person summary diff --git a/winnow/upload_tracker.py b/winnow/upload_tracker.py index 3f9f025..ffd252f 100644 --- a/winnow/upload_tracker.py +++ b/winnow/upload_tracker.py @@ -250,6 +250,21 @@ def get_lowest_quality_mapped_file( return min(candidates, key=lambda x: x[2]) +def get_min_frigate_score(person_name: str) -> float | None: + """Return the lowest stored Frigate recognition score for this person's mapped files. + + Returns None if no Frigate scores have been recorded yet (cold start or + feature not yet active). Used to derive a dynamic quality threshold so new + uploads must score at least as well as the weakest image already in the set. + """ + data = _load(UPLOAD_TRACKER_FILE) + entry = _migrate_entry(data.get("by_person", {}).get(person_name, {})) + frigate_files = entry.get("frigate_files", {}) + frigate_scores = entry.get("frigate_scores", {}) + scored = [frigate_scores[aid] for aid in frigate_files.values() if aid in frigate_scores] + return min(scored) if scored else None + + def get_frigate_filename_for_asset(person_name: str, asset_id: str) -> str | None: """Return the Frigate training filename mapped to this asset ID, or None.""" data = _load(UPLOAD_TRACKER_FILE)