feat: post-upload quality gate via FRIGATE_SCORE_THRESHOLD
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user