From 26b598db98ebedce183d6ff532a34cf24afe1922 Mon Sep 17 00:00:00 2001 From: Holden Date: Sat, 13 Jun 2026 16:29:29 +0000 Subject: [PATCH] feat: post-upload quality gate via FRIGATE_SCORE_THRESHOLD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When FRIGATE_SCORE_THRESHOLD > 0, images that score below the threshold after upload are deleted from Frigate and removed from the tracker. Skipped when pre_run_count == 0 (cold start — no class mean to compare against yet). Deletion happens after reconciliation so the Frigate filename is known. Disabled by default (0.0). Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 1 + winnow/config.py | 2 ++ winnow/executor.py | 39 +++++++++++++++++++++++++++++++++++++++ winnow/upload_tracker.py | 10 ++++++++++ 4 files changed, 52 insertions(+) diff --git a/.env.example b/.env.example index 7389783..da0f54c 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +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) # ── Caching & Models ────────────────────────────────────────────────────────── # FORCE_CPU=true # Disable GPU, fall back to CPU diff --git a/winnow/config.py b/winnow/config.py index ed5335f..6bd4dff 100644 --- a/winnow/config.py +++ b/winnow/config.py @@ -31,6 +31,7 @@ class _Config: MIN_CONFIDENCE: float = 0.7 MAX_AUTO_IMAGES: int = 80 QUALITY_REPLACEMENT: bool = True + FRIGATE_SCORE_THRESHOLD: float = 0.0 # People filtering MIN_FACE_COUNT: int = 0 @@ -62,6 +63,7 @@ class _Config: self.MIN_CONFIDENCE = float(os.getenv("MIN_CONFIDENCE", "0.7")) self.MAX_AUTO_IMAGES = int(os.getenv("MAX_AUTO_IMAGES", "80")) self.QUALITY_REPLACEMENT = os.getenv("QUALITY_REPLACEMENT", "true").lower() in ("true", "1", "yes") + self.FRIGATE_SCORE_THRESHOLD = float(os.getenv("FRIGATE_SCORE_THRESHOLD", "0.0")) self.FACE_MARGIN = float(os.getenv("FACE_MARGIN", "0.15")) self.USE_FULL_RESOLUTION = os.getenv("USE_FULL_RESOLUTION", "true").lower() in ("true", "1", "yes") self.ENABLE_FACE_ALIGNMENT = os.getenv("ENABLE_FACE_ALIGNMENT", "true").lower() in ("true", "1", "yes") diff --git a/winnow/executor.py b/winnow/executor.py index 05a3930..16c2982 100644 --- a/winnow/executor.py +++ b/winnow/executor.py @@ -19,6 +19,7 @@ from .immich_api import fetch_face_data, fetch_full_image from .log_config import console from .quality import assess_quality from .upload_tracker import ( + get_frigate_filename_for_asset, get_lowest_quality_mapped_file, get_tracked_frigate_file_count, get_tracked_frigate_filenames, @@ -356,9 +357,11 @@ def upload_to_frigate(jobs: list[dict]) -> None: else: known_frigate_files_at_start: set[str] = set(_snapshot) effective_count = get_tracked_frigate_file_count(name) + pre_run_count = effective_count quality_replacement = job.get("config", {}).get("quality_replacement", False) actually_uploaded: list[tuple[str, str | None]] = [] failed_deletes: set[str] = set() + quality_gate_failed: set[str] = set() min_quality_score_for_slot: float | None = None for fname in person_files: @@ -455,6 +458,22 @@ def upload_to_frigate(jobs: list[dict]) -> None: ) actually_uploaded.append((fname, asset_id)) + # 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 + and pre_run_count > 0 + and post_fscore is not None + and post_fscore < 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]" + ) + break else: if attempt < max_retries: @@ -520,6 +539,26 @@ def upload_to_frigate(jobs: list[dict]) -> None: if actually_uploaded: _reconcile_frigate_mappings(name, known_frigate_files_at_start, actually_uploaded) + # Post-reconcile quality gate: filenames are now mapped, so we can delete. + if quality_gate_failed: + removed = 0 + 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 + 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 ({Config.FRIGATE_SCORE_THRESHOLD:.2f})[/yellow]" + ) + # Per-person summary if person_failed == 0: progress.console.print( diff --git a/winnow/upload_tracker.py b/winnow/upload_tracker.py index b3d4528..3f9f025 100644 --- a/winnow/upload_tracker.py +++ b/winnow/upload_tracker.py @@ -250,6 +250,16 @@ def get_lowest_quality_mapped_file( return min(candidates, key=lambda x: x[2]) +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) + entry = _migrate_entry(data.get("by_person", {}).get(person_name, {})) + for frigate_filename, aid in entry["frigate_files"].items(): + if aid == asset_id: + return frigate_filename + return None + + def find_by_crop_dimension(size: int) -> list[dict]: """Return all tracked crops whose width or height matches `size` pixels.