feat: dynamic Frigate score threshold from stored set minimum

At the start of each person's upload phase, compute the minimum stored
Frigate recognition score across all currently mapped files. Use
max(config_threshold, dynamic_min) as the effective gate threshold so
new uploads must score at least as well as the weakest image already
in the training set.

Prevents overtraining well-recognised people: if all 80 images score
≥0.85, the dynamic threshold becomes ~0.85 and new additions that
score below that are removed rather than diluting a good training set.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 16:32:47 +00:00
co-authored by Claude Sonnet 4.6
parent 26b598db98
commit f322eba380
2 changed files with 29 additions and 5 deletions
+14 -5
View File
@@ -21,6 +21,7 @@ from .quality import assess_quality
from .upload_tracker import (
get_frigate_filename_for_asset,
get_lowest_quality_mapped_file,
get_min_frigate_score,
get_tracked_frigate_file_count,
get_tracked_frigate_filenames,
has_frigate_scores,
@@ -359,6 +360,15 @@ 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: at least as strict as the weakest image already stored.
# Takes whichever is higher — the configured floor or the current set minimum.
_dynamic = get_min_frigate_score(name)
effective_threshold = max(
Config.FRIGATE_SCORE_THRESHOLD,
_dynamic if _dynamic is not None else 0.0,
)
if _dynamic is not None and _dynamic > Config.FRIGATE_SCORE_THRESHOLD:
logger.debug(f"{name}: dynamic Frigate score threshold {_dynamic:.3f}")
actually_uploaded: list[tuple[str, str | None]] = []
failed_deletes: set[str] = set()
quality_gate_failed: set[str] = set()
@@ -461,17 +471,16 @@ def upload_to_frigate(jobs: list[dict]) -> None:
# 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
effective_threshold > 0
and pre_run_count > 0
and post_fscore is not None
and post_fscore < threshold
and post_fscore < effective_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]"
f" < threshold {effective_threshold:.2f}, will remove after mapping[/yellow]"
)
break
@@ -556,7 +565,7 @@ def upload_to_frigate(jobs: list[dict]) -> None:
if removed:
progress.console.print(
f" [yellow]🗑 {name}: removed {removed} image(s) below"
f" Frigate score threshold ({Config.FRIGATE_SCORE_THRESHOLD:.2f})[/yellow]"
f" Frigate score threshold ({effective_threshold:.2f})[/yellow]"
)
# Per-person summary
+15
View File
@@ -250,6 +250,21 @@ def get_lowest_quality_mapped_file(
return min(candidates, key=lambda x: x[2])
def get_min_frigate_score(person_name: str) -> float | None:
"""Return the lowest stored Frigate recognition score for this person's mapped files.
Returns None if no Frigate scores have been recorded yet (cold start or
feature not yet active). Used to derive a dynamic quality threshold so new
uploads must score at least as well as the weakest image already in the set.
"""
data = _load(UPLOAD_TRACKER_FILE)
entry = _migrate_entry(data.get("by_person", {}).get(person_name, {}))
frigate_files = entry.get("frigate_files", {})
frigate_scores = entry.get("frigate_scores", {})
scored = [frigate_scores[aid] for aid in frigate_files.values() if aid in frigate_scores]
return min(scored) if scored else None
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)