feat: quality gate on by default; fix replacement/gate conflict; gate-failed → rejected

Dynamic floor now always active once Frigate scores exist — new images must
score at least as well as the weakest image already in the set, with no
config required. FRIGATE_SCORE_THRESHOLD adds an explicit absolute floor on
top. Gate active state is surfaced in normal output for both cases.

Quality replacement now pre-checks the gate threshold before deleting the
worst image. If the candidate would fail the gate, replacement is skipped
entirely rather than creating a net slot loss.

Gate-failed assets are reclassified as rejected (moved from uploaded_asset_ids
to rejected_asset_ids) so they are excluded from future runs without wasting
API calls on re-upload. RESET_PERSON still clears rejected records for a true
full reset. RETRY_REJECTED can recover them if the threshold is later lowered.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 17:10:30 +00:00
co-authored by Claude Sonnet 4.6
parent e0a5d98df6
commit 785c9d4a22
2 changed files with 43 additions and 11 deletions
+25 -11
View File
@@ -27,6 +27,7 @@ from .upload_tracker import (
has_frigate_scores, has_frigate_scores,
mark_rejected, mark_rejected,
mark_uploaded, mark_uploaded,
reclassify_as_rejected,
record_frigate_file, record_frigate_file,
remove_frigate_file, remove_frigate_file,
) )
@@ -371,22 +372,25 @@ 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: only active when FRIGATE_SCORE_THRESHOLD > 0. # Dynamic floor is always active once scores exist — new images must score
# Zero means the gate is disabled — the dynamic floor does not activate. # at least as well as the weakest image already in the set.
if Config.FRIGATE_SCORE_THRESHOLD > 0: # FRIGATE_SCORE_THRESHOLD adds an explicit absolute minimum on top.
_dynamic = get_min_frigate_score(name) _dynamic = get_min_frigate_score(name)
effective_threshold = max(Config.FRIGATE_SCORE_THRESHOLD, _dynamic or 0.0) effective_threshold = max(Config.FRIGATE_SCORE_THRESHOLD, _dynamic or 0.0)
if _dynamic is not None and _dynamic > Config.FRIGATE_SCORE_THRESHOLD: if _dynamic is not None and pre_run_count > 0:
if Config.FRIGATE_SCORE_THRESHOLD > 0 and _dynamic > Config.FRIGATE_SCORE_THRESHOLD:
progress.console.print( progress.console.print(
f" [dim]{name}: quality gate floor raised to {_dynamic:.2f}" f" [dim]{name}: quality gate floor raised to {_dynamic:.2f}"
f" (min stored score, above configured {Config.FRIGATE_SCORE_THRESHOLD:.2f})[/dim]" f" (min stored score, above configured {Config.FRIGATE_SCORE_THRESHOLD:.2f})[/dim]"
) )
if pre_run_count == 0: elif Config.FRIGATE_SCORE_THRESHOLD == 0:
progress.console.print( progress.console.print(
f" [dim]{name}: first run — quality gate will apply from the next run[/dim]" f" [dim]{name}: quality gate active at {_dynamic:.2f} (min stored score)[/dim]"
) )
else: if Config.ENABLE_FRIGATE_SCORES and pre_run_count == 0:
effective_threshold = 0.0 progress.console.print(
f" [dim]{name}: first run — quality gate will apply from the next run[/dim]"
)
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()
@@ -434,6 +438,15 @@ def upload_to_frigate(jobs: list[dict]) -> None:
progress.advance(upload_task) progress.advance(upload_task)
continue continue
score_label = "blur" score_label = "blur"
# Skip replacement if candidate would fail the quality gate —
# deleting the worst then gating the new one is a net slot loss.
if using_fscore and effective_threshold > 0 and pre_run_count > 0 and candidate_score < effective_threshold:
progress.console.print(
f" [dim]⏭ {fname}: frigate {candidate_score:.3f} below gate threshold"
f" {effective_threshold:.2f}, skipping replacement[/dim]"
)
progress.advance(upload_task)
continue
worst = get_lowest_quality_mapped_file(name, exclude=failed_deletes) worst = get_lowest_quality_mapped_file(name, exclude=failed_deletes)
if worst is None or candidate_score <= worst[2]: if worst is None or candidate_score <= worst[2]:
worst_score_str = f"{worst[2]:.3f}" if worst is not None else "N/A" worst_score_str = f"{worst[2]:.3f}" if worst is not None else "N/A"
@@ -581,8 +594,9 @@ def upload_to_frigate(jobs: list[dict]) -> None:
) )
if to_delete: if to_delete:
if delete_frigate_person_files(name, [fn for fn, _ in to_delete]): if delete_frigate_person_files(name, [fn for fn, _ in to_delete]):
for frigate_fn, _aid in to_delete: for frigate_fn, aid in to_delete:
remove_frigate_file(name, frigate_fn) remove_frigate_file(name, frigate_fn)
reclassify_as_rejected(aid, name)
gate_removed = len(to_delete) gate_removed = len(to_delete)
effective_count -= gate_removed effective_count -= gate_removed
gate_total += gate_removed gate_total += gate_removed
+18
View File
@@ -147,6 +147,24 @@ def mark_rejected(asset_id: str, person_name: str | None = None) -> None:
logger.debug(f"Marked {asset_id} as rejected ({person_name})") logger.debug(f"Marked {asset_id} as rejected ({person_name})")
def reclassify_as_rejected(asset_id: str, person_name: str | None = None) -> None:
"""Move a gate-failed asset from the uploaded flat set to rejected.
Preserves by_person history in the uploaded tracker (scores, crop dims,
etc.) but removes the asset from uploaded_asset_ids so it is excluded
from future candidate pools via the rejected tracker instead.
RESET_PERSON clears both trackers, so a full reset still re-evaluates
gate-failed images.
"""
data = _load(UPLOAD_TRACKER_FILE)
flat = set(data.get("uploaded_asset_ids", []))
flat.discard(asset_id)
data["uploaded_asset_ids"] = sorted(flat)
_save(UPLOAD_TRACKER_FILE, data)
_mark(REJECT_TRACKER_FILE, asset_id, person_name)
logger.debug(f"Reclassified {asset_id} as gate-failed rejected ({person_name})")
def record_frigate_file(person_name: str, frigate_filename: str, asset_id: str) -> None: def record_frigate_file(person_name: str, frigate_filename: str, asset_id: str) -> None:
"""Record the mapping from a Frigate training filename to an Immich asset ID.""" """Record the mapping from a Frigate training filename to an Immich asset ID."""
data = _load(UPLOAD_TRACKER_FILE) data = _load(UPLOAD_TRACKER_FILE)