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,
mark_rejected,
mark_uploaded,
reclassify_as_rejected,
record_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)
pre_run_count = effective_count
quality_replacement = job.get("config", {}).get("quality_replacement", False)
# 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:
# Dynamic floor is always active once scores exist — new images must score
# at least as well as the weakest image already in the set.
# FRIGATE_SCORE_THRESHOLD adds an explicit absolute minimum on top.
_dynamic = get_min_frigate_score(name)
effective_threshold = max(Config.FRIGATE_SCORE_THRESHOLD, _dynamic or 0.0)
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(
f" [dim]{name}: quality gate floor raised to {_dynamic:.2f}"
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(
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:
effective_threshold = 0.0
if Config.ENABLE_FRIGATE_SCORES and pre_run_count == 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]] = []
failed_deletes: 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)
continue
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)
if worst is None or candidate_score <= worst[2]:
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 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)
reclassify_as_rejected(aid, name)
gate_removed = len(to_delete)
effective_count -= 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})")
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:
"""Record the mapping from a Frigate training filename to an Immich asset ID."""
data = _load(UPLOAD_TRACKER_FILE)