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:
+25
-11
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user