feat: store Frigate recognition scores and use them for quality replacement

After each successful upload, call POST /api/faces/recognize to get
Frigate's own confidence score (0-1) for the uploaded crop. Store it
in the tracker as frigate_scores alongside the existing blur score.

When quality replacement activates and frigate_scores are present,
use them for the replacement comparison instead of blur scores — an
image Frigate recognizes poorly is a worse training image than one it
recognizes well, regardless of sharpness. Falls back to blur scores
on first run before any frigate_scores are populated.

Also surfaces frigate_score in TRACE_CROP_SIZE output.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 16:21:58 +00:00
co-authored by Claude Sonnet 4.6
parent ab641847b2
commit 03be6ce2cb
4 changed files with 115 additions and 23 deletions
+2
View File
@@ -41,6 +41,8 @@ def _handle_trace_crop(size_str: str) -> None:
rprint(f" Immich URL: {immich_url}/photos/{m['asset_id']}") rprint(f" Immich URL: {immich_url}/photos/{m['asset_id']}")
blur = m.get("blur_score") blur = m.get("blur_score")
rprint(f" Blur score: {blur:.1f}" if blur is not None else " Blur score: unknown") rprint(f" Blur score: {blur:.1f}" if blur is not None else " Blur score: unknown")
fscore = m.get("frigate_score")
rprint(f" Frigate score: {fscore:.2f}" if fscore is not None else " Frigate score: unknown")
if m.get("frigate_filename"): if m.get("frigate_filename"):
rprint(f" Frigate file: {m['frigate_filename']}") rprint(f" Frigate file: {m['frigate_filename']}")
else: else:
+27 -9
View File
@@ -13,7 +13,7 @@ from rich import print as rprint
from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn
from .config import Config, get_headers from .config import Config, get_headers
from .frigate_api import delete_frigate_person_files, get_frigate_person_files from .frigate_api import delete_frigate_person_files, get_frigate_person_files, recognize_face
from .image_processing import process_face_mode, process_full_mode, process_object_mode from .image_processing import process_face_mode, process_full_mode, process_object_mode
from .immich_api import fetch_face_data, fetch_full_image from .immich_api import fetch_face_data, fetch_full_image
from .log_config import console from .log_config import console
@@ -22,6 +22,7 @@ from .upload_tracker import (
get_lowest_quality_mapped_file, get_lowest_quality_mapped_file,
get_tracked_frigate_file_count, get_tracked_frigate_file_count,
get_tracked_frigate_filenames, get_tracked_frigate_filenames,
has_frigate_scores,
mark_rejected, mark_rejected,
mark_uploaded, mark_uploaded,
record_frigate_file, record_frigate_file,
@@ -383,30 +384,45 @@ def upload_to_frigate(jobs: list[dict]) -> None:
progress.console.print(f" [dim]⏭ {fname}: at cap, quality replacement disabled[/dim]") progress.console.print(f" [dim]⏭ {fname}: at cap, quality replacement disabled[/dim]")
progress.advance(upload_task) progress.advance(upload_task)
continue continue
new_score = score_map.get(fname) using_fscore = has_frigate_scores(name)
if new_score is None: if using_fscore:
progress.console.print(f" [dim]⏭ {fname}: no confidence score, skipping replacement[/dim]") candidate_score = recognize_face(fpath)
if candidate_score is None:
progress.console.print(
f" [dim]⏭ {fname}: Frigate recognize unavailable, skipping replacement[/dim]"
)
progress.advance(upload_task) progress.advance(upload_task)
continue continue
score_label = "frigate"
else:
candidate_score = score_map.get(fname)
if candidate_score is None:
progress.console.print(
f" [dim]⏭ {fname}: no quality score, skipping replacement[/dim]"
)
progress.advance(upload_task)
continue
score_label = "blur"
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 new_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"
progress.console.print( progress.console.print(
f" [dim]⏭ {fname}: score {new_score:.3f} ≤ worst mapped" f" [dim]⏭ {fname}: {score_label} {candidate_score:.3f} ≤ worst"
f" {worst_score_str}, skipping[/dim]" f" {worst_score_str}, skipping[/dim]"
) )
progress.advance(upload_task) progress.advance(upload_task)
continue continue
# Delete the worst mapped file to make room for the better one
worst_frigate_file, _worst_asset_id, worst_score = worst worst_frigate_file, _worst_asset_id, worst_score = worst
progress.console.print( progress.console.print(
f" 🔄 {fname}: score {new_score:.3f} > {worst_score:.3f}," f" 🔄 {fname}: {score_label} {candidate_score:.3f} > {worst_score:.3f},"
f" replacing {worst_frigate_file}" f" replacing {worst_frigate_file}"
) )
if delete_frigate_person_files(name, [worst_frigate_file]): if delete_frigate_person_files(name, [worst_frigate_file]):
remove_frigate_file(name, worst_frigate_file) remove_frigate_file(name, worst_frigate_file)
effective_count -= 1 effective_count -= 1
min_quality_score_for_slot = worst_score # Slot floor guard uses blur scores only — frigate_score mode
# will re-evaluate the next candidate via recognize_face anyway.
min_quality_score_for_slot = score_map.get(fname) if not using_fscore else None
else: else:
logger.warning(f"Failed to delete {worst_frigate_file} for {name}, skipping replacement") logger.warning(f"Failed to delete {worst_frigate_file} for {name}, skipping replacement")
failed_deletes.add(worst_frigate_file) failed_deletes.add(worst_frigate_file)
@@ -429,11 +445,13 @@ def upload_to_frigate(jobs: list[dict]) -> None:
asset_id = asset_map.get(fname) asset_id = asset_map.get(fname)
if asset_id: if asset_id:
post_fscore = recognize_face(fpath)
mark_uploaded( mark_uploaded(
asset_id, asset_id,
person_name=name, person_name=name,
score=score_map.get(fname), score=score_map.get(fname),
crop_dims=dims_map.get(fname), crop_dims=dims_map.get(fname),
frigate_score=post_fscore,
) )
actually_uploaded.append((fname, asset_id)) actually_uploaded.append((fname, asset_id))
+27
View File
@@ -53,6 +53,33 @@ def get_frigate_person_files(person_name: str) -> list[str] | None:
return files if isinstance(files, list) else [] return files if isinstance(files, list) else []
def recognize_face(file_path: str) -> float | None:
"""Submit an image to Frigate's recognize endpoint and return the confidence score.
Returns None if FRIGATE_URL is unset, the API is unreachable, no face is
detected, or face recognition is not enabled in Frigate.
"""
frigate_url = os.environ.get("FRIGATE_URL", "").rstrip("/")
if not frigate_url:
return None
try:
with open(file_path, "rb") as f:
resp = requests.post(
f"{frigate_url}/api/faces/recognize",
files={"file": (os.path.basename(file_path), f, "image/jpeg")},
timeout=15,
)
if not resp.ok:
return None
data = resp.json()
if data.get("success") and "score" in data:
return round(float(data["score"]), 4)
return None
except Exception as e:
logger.debug(f"Frigate recognize failed for {file_path}: {e}")
return None
def delete_frigate_person_files(person_name: str, filenames: list[str]) -> bool: def delete_frigate_person_files(person_name: str, filenames: list[str]) -> bool:
"""Delete specific training files for a person from Frigate. """Delete specific training files for a person from Frigate.
+52 -7
View File
@@ -13,11 +13,17 @@ by_person schema (frigate_uploaded_ids.json):
{ {
"asset_ids": ["immich-id-1", ...], # all assets we attempted to upload "asset_ids": ["immich-id-1", ...], # all assets we attempted to upload
"scores": {"immich-id-1": 450.3}, # Laplacian blur variance at upload time "scores": {"immich-id-1": 450.3}, # Laplacian blur variance at upload time
"frigate_scores": {"immich-id-1": 0.87}, # Frigate recognition confidence (0-1) post-upload
"frigate_files": {"PersonName-123.webp": "immich-id-1"}, # Frigate filename → asset ID "frigate_files": {"PersonName-123.webp": "immich-id-1"}, # Frigate filename → asset ID
"crop_dims": {"immich-id-1": [640, 480]}, # crop pixel dimensions at upload time "crop_dims": {"immich-id-1": [640, 480]}, # crop pixel dimensions at upload time
"frigate_count": 42 # last known Frigate training image count "frigate_count": 42 # last known Frigate training image count
} }
frigate_scores uses the same 0-1 sigmoid-mapped cosine similarity that Frigate
displays in its UI. When available, quality replacement uses frigate_scores in
preference to blur scores — an image Frigate cannot recognize is a poor training
image regardless of sharpness.
frigate_files only contains files winnow uploaded — files added manually through frigate_files only contains files winnow uploaded — files added manually through
Frigate's UI are never mapped here and are never touched by quality replacement. Frigate's UI are never mapped here and are never touched by quality replacement.
""" """
@@ -77,9 +83,10 @@ def _get_ids(entry: list | dict) -> list[str]:
def _migrate_entry(entry: list | dict) -> dict: def _migrate_entry(entry: list | dict) -> dict:
"""Ensure by_person entry is in the current dict format.""" """Ensure by_person entry is in the current dict format."""
if isinstance(entry, list): if isinstance(entry, list):
return {"asset_ids": sorted(entry), "scores": {}, "frigate_files": {}, "crop_dims": {}} return {"asset_ids": sorted(entry), "scores": {}, "frigate_scores": {}, "frigate_files": {}, "crop_dims": {}}
entry.setdefault("asset_ids", []) entry.setdefault("asset_ids", [])
entry.setdefault("scores", {}) entry.setdefault("scores", {})
entry.setdefault("frigate_scores", {})
entry.setdefault("frigate_files", {}) entry.setdefault("frigate_files", {})
entry.setdefault("crop_dims", {}) entry.setdefault("crop_dims", {})
return entry return entry
@@ -91,6 +98,7 @@ def _mark(
person_name: str | None, person_name: str | None,
score: float | None = None, score: float | None = None,
crop_dims: tuple[int, int] | None = None, crop_dims: tuple[int, int] | None = None,
frigate_score: float | None = None,
) -> None: ) -> None:
data = _load(filename) data = _load(filename)
flat_key = _flat_key(filename) flat_key = _flat_key(filename)
@@ -107,6 +115,8 @@ def _mark(
entry["scores"][asset_id] = round(score, 4) entry["scores"][asset_id] = round(score, 4)
if crop_dims is not None: if crop_dims is not None:
entry["crop_dims"][asset_id] = [crop_dims[0], crop_dims[1]] entry["crop_dims"][asset_id] = [crop_dims[0], crop_dims[1]]
if frigate_score is not None:
entry["frigate_scores"][asset_id] = round(frigate_score, 4)
by_person[person_name] = entry by_person[person_name] = entry
_save(filename, data) _save(filename, data)
@@ -126,8 +136,9 @@ def mark_uploaded(
person_name: str | None = None, person_name: str | None = None,
score: float | None = None, score: float | None = None,
crop_dims: tuple[int, int] | None = None, crop_dims: tuple[int, int] | None = None,
frigate_score: float | None = None,
) -> None: ) -> None:
_mark(UPLOAD_TRACKER_FILE, asset_id, person_name, score=score, crop_dims=crop_dims) _mark(UPLOAD_TRACKER_FILE, asset_id, person_name, score=score, crop_dims=crop_dims, frigate_score=frigate_score)
logger.debug(f"Marked {asset_id} as uploaded ({person_name})") logger.debug(f"Marked {asset_id} as uploaded ({person_name})")
@@ -184,24 +195,56 @@ def get_tracked_frigate_filenames(person_name: str) -> set[str]:
return set(entry["frigate_files"].keys()) return set(entry["frigate_files"].keys())
def has_frigate_scores(person_name: str) -> bool:
"""Return True if any mapped file for this person has a stored Frigate recognition score."""
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", {})
return any(asset_id in frigate_scores for asset_id in frigate_files.values())
def get_lowest_quality_mapped_file( def get_lowest_quality_mapped_file(
person_name: str, exclude: set[str] | None = None person_name: str, exclude: set[str] | None = None
) -> tuple[str, str, float] | None: ) -> tuple[str, str, float] | None:
"""Return (frigate_filename, asset_id, score) for the mapped file with the lowest """Return (frigate_filename, asset_id, score) for the mapped file with the lowest
quality score, or None if no mapped files with known scores exist. quality score, or None if no mapped files with known scores exist.
Uses Frigate recognition scores (0-1) when any are present for this person,
treating files without a Frigate score as 0.0. Falls back to Laplacian blur
scores when no Frigate scores exist yet.
Pass `exclude` to skip files that failed to delete this run without removing Pass `exclude` to skip files that failed to delete this run without removing
them from the tracker — they remain candidates on the next run. them from the tracker — they remain candidates on the next run.
""" """
data = _load(UPLOAD_TRACKER_FILE) data = _load(UPLOAD_TRACKER_FILE)
entry = _migrate_entry(data.get("by_person", {}).get(person_name, {})) entry = _migrate_entry(data.get("by_person", {}).get(person_name, {}))
frigate_files = entry.get("frigate_files", {}) frigate_files = entry.get("frigate_files", {})
scores = entry.get("scores", {}) blur_scores = entry.get("scores", {})
candidates = [ frigate_scores = entry.get("frigate_scores", {})
(frigate_filename, asset_id, scores[asset_id])
for frigate_filename, asset_id in frigate_files.items() mapped = [
if asset_id in scores and (exclude is None or frigate_filename not in exclude) (ff, asset_id)
for ff, asset_id in frigate_files.items()
if exclude is None or ff not in exclude
] ]
if not mapped:
return None
use_frigate = any(asset_id in frigate_scores for _, asset_id in mapped)
if use_frigate:
candidates = [
(ff, asset_id, frigate_scores.get(asset_id, 0.0))
for ff, asset_id in mapped
]
else:
candidates = [
(ff, asset_id, blur_scores[asset_id])
for ff, asset_id in mapped
if asset_id in blur_scores
]
if not candidates: if not candidates:
return None return None
return min(candidates, key=lambda x: x[2]) return min(candidates, key=lambda x: x[2])
@@ -220,6 +263,7 @@ def find_by_crop_dimension(size: int) -> list[dict]:
scores = entry.get("scores", {}) scores = entry.get("scores", {})
frigate_files = entry.get("frigate_files", {}) frigate_files = entry.get("frigate_files", {})
asset_to_frigate = {v: k for k, v in frigate_files.items()} asset_to_frigate = {v: k for k, v in frigate_files.items()}
frigate_scores = entry.get("frigate_scores", {})
for asset_id, dims in entry.get("crop_dims", {}).items(): for asset_id, dims in entry.get("crop_dims", {}).items():
w, h = dims[0], dims[1] w, h = dims[0], dims[1]
if w == size or h == size: if w == size or h == size:
@@ -229,6 +273,7 @@ def find_by_crop_dimension(size: int) -> list[dict]:
"width": w, "width": w,
"height": h, "height": h,
"blur_score": scores.get(asset_id), "blur_score": scores.get(asset_id),
"frigate_score": frigate_scores.get(asset_id),
"frigate_filename": asset_to_frigate.get(asset_id), "frigate_filename": asset_to_frigate.get(asset_id),
}) })
return results return results