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 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 16:41:31 +00:00
co-authored by Claude Sonnet 4.6
parent f322eba380
commit 903d7f1054
2 changed files with 39 additions and 24 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ STRATEGY=auto
# MIN_CONFIDENCE=0.7 # Minimum face detection confidence (default: 0.7) # 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) # 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) # 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 ────────────────────────────────────────────────────────── # ── Caching & Models ──────────────────────────────────────────────────────────
# FORCE_CPU=true # Disable GPU, fall back to CPU # FORCE_CPU=true # Disable GPU, fall back to CPU
+34 -19
View File
@@ -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]") 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 max_retries = 2
with Progress( with Progress(
@@ -360,15 +360,15 @@ def upload_to_frigate(jobs: list[dict]) -> None:
effective_count = get_tracked_frigate_file_count(name) effective_count = get_tracked_frigate_file_count(name)
pre_run_count = effective_count pre_run_count = effective_count
quality_replacement = job.get("config", {}).get("quality_replacement", False) quality_replacement = job.get("config", {}).get("quality_replacement", False)
# Dynamic threshold: at least as strict as the weakest image already stored. # Dynamic threshold: only active when FRIGATE_SCORE_THRESHOLD > 0.
# Takes whichever is higher — the configured floor or the current set minimum. # Zero means the gate is disabled — the dynamic floor does not activate.
if Config.FRIGATE_SCORE_THRESHOLD > 0:
_dynamic = get_min_frigate_score(name) _dynamic = get_min_frigate_score(name)
effective_threshold = max( effective_threshold = max(Config.FRIGATE_SCORE_THRESHOLD, _dynamic or 0.0)
Config.FRIGATE_SCORE_THRESHOLD,
_dynamic if _dynamic is not None else 0.0,
)
if _dynamic is not None and _dynamic > Config.FRIGATE_SCORE_THRESHOLD: if _dynamic is not None and _dynamic > Config.FRIGATE_SCORE_THRESHOLD:
logger.debug(f"{name}: dynamic Frigate score threshold {_dynamic:.3f}") logger.debug(f"{name}: dynamic Frigate score threshold {_dynamic:.3f}")
else:
effective_threshold = 0.0
actually_uploaded: list[tuple[str, str | None]] = [] actually_uploaded: list[tuple[str, str | None]] = []
failed_deletes: set[str] = set() failed_deletes: set[str] = set()
quality_gate_failed: 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) _reconcile_frigate_mappings(name, known_frigate_files_at_start, actually_uploaded)
# Post-reconcile quality gate: filenames are now mapped, so we can delete. # Post-reconcile quality gate: filenames are now mapped, so we can delete.
gate_removed = 0
if quality_gate_failed: if quality_gate_failed:
removed = 0 to_delete: list[tuple[str, str]] = [] # (frigate_fn, asset_id)
for asset_id in quality_gate_failed: for asset_id in quality_gate_failed:
frigate_fn = get_frigate_filename_for_asset(name, asset_id) frigate_fn = get_frigate_filename_for_asset(name, asset_id)
if frigate_fn and delete_frigate_person_files(name, [frigate_fn]): if frigate_fn:
remove_frigate_file(name, frigate_fn) to_delete.append((frigate_fn, asset_id))
effective_count -= 1
removed += 1
else: else:
logger.warning( logger.warning(
f"{name}: could not remove low-score file for {asset_id}" f"{name}: could not remove low-score file for {asset_id}"
" — no Frigate filename mapped (reconciliation race?)" " — no Frigate filename mapped (reconciliation race?)"
) )
if removed: if to_delete:
progress.console.print( if delete_frigate_person_files(name, [fn for fn, _ in to_delete]):
f" [yellow]🗑 {name}: removed {removed} image(s) below" for frigate_fn, _aid in to_delete:
f" Frigate score threshold ({effective_threshold:.2f})[/yellow]" 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 # Per-person summary
if person_failed == 0: if person_failed == 0 and gate_removed == 0:
progress.console.print( progress.console.print(
f" ✅ {name}: {person_uploaded}/{person_uploaded} uploaded" f" ✅ {name}: {person_uploaded}/{person_uploaded} uploaded"
) )
else: elif person_failed == 0:
net = person_uploaded - gate_removed
progress.console.print( 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 # Grand summary
@@ -585,6 +598,8 @@ def upload_to_frigate(jobs: list[dict]) -> None:
rprint(f" ❌ Failed: [red]{failed}[/red]") rprint(f" ❌ Failed: [red]{failed}[/red]")
else: else:
rprint(" ❌ Failed: 0") rprint(" ❌ Failed: 0")
if gate_total:
rprint(f" 🗑 Removed (quality gate): [yellow]{gate_total}[/yellow]")
if failed > 0: if failed > 0:
rprint(" [yellow]Check logs above for per-file error details.[/yellow]") rprint(" [yellow]Check logs above for per-file error details.[/yellow]")