From 903d7f1054f33eab876fd1cbcb7d1ec64268e584 Mon Sep 17 00:00:00 2001 From: Holden Date: Sat, 13 Jun 2026 16:41:31 +0000 Subject: [PATCH] fix: quality gate disabled at threshold 0; batch deletions; summary shows net count - FRIGATE_SCORE_THRESHOLD=0.0 now fully disables the quality gate including the dynamic floor; a positive value is required to activate either - Post-reconcile gate deletions are batched into one API call per person instead of one call per file - Per-person summary reports gate removals and net uploaded count when the gate fires; grand summary includes total removed across all people - .env.example comment updated to match the corrected opt-in behaviour Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 2 +- winnow/executor.py | 61 +++++++++++++++++++++++++++++----------------- 2 files changed, 39 insertions(+), 24 deletions(-) diff --git a/.env.example b/.env.example index da0f54c..81e3aaf 100644 --- a/.env.example +++ b/.env.example @@ -30,7 +30,7 @@ STRATEGY=auto # MIN_CONFIDENCE=0.7 # Minimum face detection confidence (default: 0.7) # BLUR_THRESHOLD=120.0 # Laplacian blur threshold; lower = accept more blur (default: 120.0) # MAX_AUTO_IMAGES=80 # Hard cap on auto-diversity selection (default: 80) -# FRIGATE_SCORE_THRESHOLD=0.0 # Remove uploaded images scoring below this after upload (0 = disabled; skipped on cold start) +# FRIGATE_SCORE_THRESHOLD=0.0 # Quality gate: remove images scoring below this after upload (0 = disabled; requires at least one prior run) # ── Caching & Models ────────────────────────────────────────────────────────── # FORCE_CPU=true # Disable GPU, fall back to CPU diff --git a/winnow/executor.py b/winnow/executor.py index 626c242..eeab846 100644 --- a/winnow/executor.py +++ b/winnow/executor.py @@ -305,7 +305,7 @@ def upload_to_frigate(jobs: list[dict]) -> None: rprint(f" People: [bold]{len(face_jobs)}[/bold], Total images: [bold]{total_files}[/bold]") - uploaded, failed = 0, 0 + uploaded, failed, gate_total = 0, 0, 0 max_retries = 2 with Progress( @@ -360,15 +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}") + # Dynamic threshold: only active when FRIGATE_SCORE_THRESHOLD > 0. + # Zero means the gate is disabled — the dynamic floor does not activate. + if Config.FRIGATE_SCORE_THRESHOLD > 0: + _dynamic = get_min_frigate_score(name) + effective_threshold = max(Config.FRIGATE_SCORE_THRESHOLD, _dynamic or 0.0) + if _dynamic is not None and _dynamic > Config.FRIGATE_SCORE_THRESHOLD: + logger.debug(f"{name}: dynamic Frigate score threshold {_dynamic:.3f}") + else: + effective_threshold = 0.0 actually_uploaded: list[tuple[str, str | None]] = [] failed_deletes: set[str] = set() quality_gate_failed: set[str] = set() @@ -549,33 +549,46 @@ def upload_to_frigate(jobs: list[dict]) -> None: _reconcile_frigate_mappings(name, known_frigate_files_at_start, actually_uploaded) # Post-reconcile quality gate: filenames are now mapped, so we can delete. + gate_removed = 0 if quality_gate_failed: - removed = 0 + to_delete: list[tuple[str, str]] = [] # (frigate_fn, asset_id) for asset_id in quality_gate_failed: frigate_fn = get_frigate_filename_for_asset(name, asset_id) - if frigate_fn and delete_frigate_person_files(name, [frigate_fn]): - remove_frigate_file(name, frigate_fn) - effective_count -= 1 - removed += 1 + if frigate_fn: + to_delete.append((frigate_fn, asset_id)) else: logger.warning( f"{name}: could not remove low-score file for {asset_id}" " — no Frigate filename mapped (reconciliation race?)" ) - if removed: - progress.console.print( - f" [yellow]🗑 {name}: removed {removed} image(s) below" - f" Frigate score threshold ({effective_threshold:.2f})[/yellow]" - ) + if to_delete: + if delete_frigate_person_files(name, [fn for fn, _ in to_delete]): + for frigate_fn, _aid in to_delete: + remove_frigate_file(name, frigate_fn) + gate_removed = len(to_delete) + effective_count -= gate_removed + gate_total += gate_removed + else: + logger.warning( + f"{name}: batch delete of {len(to_delete)} low-score file(s) failed" + ) # Per-person summary - if person_failed == 0: + if person_failed == 0 and gate_removed == 0: progress.console.print( f" ✅ {name}: {person_uploaded}/{person_uploaded} uploaded" ) - else: + elif person_failed == 0: + net = person_uploaded - gate_removed progress.console.print( - f" ⚠️ {name}: {person_uploaded} succeeded, {person_failed} failed" + f" [yellow]✅ {name}: {person_uploaded} uploaded," + f" {gate_removed} removed by quality gate (score < {effective_threshold:.2f})" + f" → {net} net[/yellow]" + ) + else: + gate_note = f", {gate_removed} removed by quality gate" if gate_removed else "" + progress.console.print( + f" ⚠️ {name}: {person_uploaded} succeeded, {person_failed} failed{gate_note}" ) # Grand summary @@ -585,6 +598,8 @@ def upload_to_frigate(jobs: list[dict]) -> None: rprint(f" ❌ Failed: [red]{failed}[/red]") else: rprint(" ❌ Failed: 0") + if gate_total: + rprint(f" 🗑 Removed (quality gate): [yellow]{gate_total}[/yellow]") if failed > 0: rprint(" [yellow]Check logs above for per-file error details.[/yellow]")