chore: bump version to 0.4.0, update changelog and all docs

Finalizes the 0.4.0 release:

- Version bumped to 0.4.0 in pyproject.toml
- CHANGELOG.md: add [0.4.0] section covering Frigate pre-upload scoring,
  quality replacement inversion, bootstrap fix, FRIGATE_SCORE_CEILING,
  ENABLE_FRIGATE_SCORES, removal of post-upload quality gate, and all
  doc/default corrections
- README.md: step 8 updated for dual-mode replacement, FRIGATE_SCORE_CEILING
  and ENABLE_FRIGATE_SCORES added to env var table, MIN_FACE_WIDTH and
  BLUR_THRESHOLD defaults corrected (50→90, 100→120)
- .env.example: FRIGATE_SCORE_THRESHOLD replaced with FRIGATE_SCORE_CEILING;
  QUALITY_REPLACEMENT line added; comments updated to match current semantics
- winnow/executor.py: bootstrap fix — recognize now called for all below-cap
  uploads when ENABLE_FRIGATE_SCORES=true (was gated on CEILING > 0)
- winnow/upload_tracker.py: frigate_scores schema comment corrected to
  pre-upload; get_most_redundant_mapped_file() added
- winnow/frigate_api.py: recognize_face returns (face_name, score)|None tuple
  so wrong-person scores never drive replacement or ceiling decisions
- winnow/config.py: FRIGATE_SCORE_THRESHOLD renamed to FRIGATE_SCORE_CEILING;
  ENABLE_FRIGATE_SCORES added
- tests/test_upload_tracker.py: 4 new tests for get_most_redundant_mapped_file

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 18:35:11 +00:00
co-authored by Claude Sonnet 4.6
parent ed045f07dd
commit 6fcea587ff
9 changed files with 213 additions and 219 deletions
+3 -2
View File
@@ -30,8 +30,9 @@ STRATEGY=auto
# MIN_CONFIDENCE=0.7 # Minimum face detection confidence (default: 0.7) # MIN_CONFIDENCE=0.7 # Minimum face detection confidence (default: 0.7)
# BLUR_THRESHOLD=120.0 # Laplacian blur threshold; lower = accept more blur (default: 120.0) # BLUR_THRESHOLD=120.0 # Laplacian blur threshold; lower = accept more blur (default: 120.0)
# MAX_AUTO_IMAGES=80 # Hard cap on auto-diversity selection (default: 80) # MAX_AUTO_IMAGES=80 # Hard cap on auto-diversity selection (default: 80)
# FRIGATE_SCORE_THRESHOLD=0.0 # Quality gate: remove images scoring below this after upload (0 = disabled; requires at least one prior run) # QUALITY_REPLACEMENT=true # At cap, replace a weaker tracked image with a better candidate (default: true)
# ENABLE_FRIGATE_SCORES=true # Call Frigate's recognize endpoint after each upload to store quality scores (default: true; adds ~200ms per upload) # FRIGATE_SCORE_CEILING=0.0 # Skip uploads already well-covered (pre-upload score > ceiling = redundant; 0 = disabled; requires at least one prior run)
# ENABLE_FRIGATE_SCORES=true # Call Frigate's recognize endpoint pre-upload to store diversity scores (default: true; adds ~200ms per upload)
# ── Caching & Models ────────────────────────────────────────────────────────── # ── Caching & Models ──────────────────────────────────────────────────────────
# FORCE_CPU=true # Disable GPU, fall back to CPU # FORCE_CPU=true # Disable GPU, fall back to CPU
+31
View File
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.4.0] - 2026-06-13
### Added
- **Pre-upload Frigate recognition scores**: `recognize_face` is now called before each upload to measure how novel the candidate is relative to the existing training set. The score is stored in the tracker (`frigate_scores` field) and drives quality replacement in subsequent runs. Adds ~200 ms per upload.
- **`ENABLE_FRIGATE_SCORES`** (default `true`): controls all pre-upload Frigate recognize calls. Set `false` to use blur-score replacement only and skip the Frigate round-trip entirely.
- **`FRIGATE_SCORE_CEILING`** (default `0.0`): skip uploads whose pre-upload recognize score already exceeds this value — those face conditions are already well-covered by the training set. `0` disables (no ceiling); requires at least one prior run to have stored scores.
- **`get_most_redundant_mapped_file()`**: new upload-tracker function that returns the mapped file with the highest Frigate pre-upload score. High score = the training set already covers that face condition well = the best deletion target for quality replacement.
- **Cold-start notice**: first run (no existing Frigate model) now logs a clear message explaining why Frigate scores are unavailable and that they will populate on subsequent runs.
- **4 new tests** for `get_most_redundant_mapped_file` covering score ordering, ties, excludes, and no-score cases.
### Changed
- **Quality replacement now uses Frigate scores**: when Frigate scores are available, at-cap replacement targets the _most redundant_ mapped file (highest pre-upload score) and replaces it only when the candidate is _more novel_ (lower score). Falls back to blur-score comparison when no Frigate scores have been stored yet.
- **`recognize_face` returns `(face_name, score) | None`** instead of `float | None`: the caller now validates that the recognized person matches the expected person before using the score. Wrong-person scores no longer drive ceiling skips or replacement decisions.
- **Bootstrap fix**: recognize was previously called below-cap only when `FRIGATE_SCORE_CEILING > 0`, so `frigate_scores` was never populated with default settings and the Frigate replacement path never activated. Recognize is now called for all below-cap uploads when `ENABLE_FRIGATE_SCORES=true`, seeding scores for future at-cap runs regardless of ceiling setting.
- **Batch GET `/api/faces`**: Frigate file-count lookups are now batched to reduce round-trip overhead on runs with many people.
- **Skip candidate download on low Frigate confidence**: candidates where the Immich detection confidence is below threshold are now filtered before the full-resolution download, saving bandwidth.
### Removed
- **Post-upload quality gate (`FRIGATE_SCORE_THRESHOLD`)**: enforcement of a Frigate score threshold after upload has been removed. Post-upload scores are taken after the image is already in the training set, so the model has already retrained on it — deleting it at that point is wasteful and disrupts the model for the next Frigate run. Pre-upload scoring (`FRIGATE_SCORE_CEILING`) provides a cleaner signal at the right moment.
### Fixed
- **Frigate replacement path never activated with default settings**: with `FRIGATE_SCORE_CEILING=0.0` (default), the bootstrap call to `recognize_face` was gated behind `CEILING > 0`, so `frigate_scores` stayed empty, `has_frigate_scores` was always False, and the Frigate replacement branch was permanently unreachable. Removing the ceiling guard from the below-cap recognize call breaks the circular dependency.
- **Schema comment contradiction**: `upload_tracker.py` line-16 comment described `frigate_scores` as "post-upload" while the block comment on lines 22–24 said "pre-upload". Corrected to "pre-upload" throughout.
- **README default values**: `MIN_FACE_WIDTH` was documented as `50` (actual default: `90`); `BLUR_THRESHOLD` was documented as `100.0` (actual default: `120.0`). Both corrected.
- **README missing env vars**: `FRIGATE_SCORE_CEILING` and `ENABLE_FRIGATE_SCORES` were present in `config.py` and `.env.example` but absent from the README env var table. Both added.
- **README quality-replacement description**: Step 8 and the `QUALITY_REPLACEMENT` row now document the dual-mode behaviour (Frigate-score path and blur-score fallback) instead of describing only the original blur-score path.
## [0.3.3] - 2026-06-13 ## [0.3.3] - 2026-06-13
### Fixed ### Fixed
+13 -6
View File
@@ -2,6 +2,9 @@
[![Docker](https://github.com/sudolulo/winnow/actions/workflows/docker-publish.yml/badge.svg)](https://github.com/sudolulo/winnow/actions/workflows/docker-publish.yml) [![Test](https://github.com/sudolulo/winnow/actions/workflows/test.yml/badge.svg)](https://github.com/sudolulo/winnow/actions/workflows/test.yml) [![GitHub release](https://img.shields.io/github/v/release/sudolulo/winnow)](https://github.com/sudolulo/winnow/releases/latest) [![License: AGPL v3](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](LICENSE) [![Immich](https://img.shields.io/badge/Immich-v1.106%2B-blueviolet)](https://immich.app) [![Frigate](https://img.shields.io/badge/Frigate-Ready-brightgreen)](https://frigate.video) [![Docker](https://github.com/sudolulo/winnow/actions/workflows/docker-publish.yml/badge.svg)](https://github.com/sudolulo/winnow/actions/workflows/docker-publish.yml) [![Test](https://github.com/sudolulo/winnow/actions/workflows/test.yml/badge.svg)](https://github.com/sudolulo/winnow/actions/workflows/test.yml) [![GitHub release](https://img.shields.io/github/v/release/sudolulo/winnow)](https://github.com/sudolulo/winnow/releases/latest) [![License: AGPL v3](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](LICENSE) [![Immich](https://img.shields.io/badge/Immich-v1.106%2B-blueviolet)](https://immich.app) [![Frigate](https://img.shields.io/badge/Frigate-Ready-brightgreen)](https://frigate.video)
> **Early Development — Use With Caution**
> winnow is functional but still maturing. Features that modify your Frigate training data — quality replacement, stale mapping cleanup — can remove images from your dataset and are not yet battle-tested at scale. Review the logs after each run and keep backups of your Frigate face training directory until you are confident in the results.
**Docs:** [Setup](https://github.com/sudolulo/winnow/wiki/Setup) · [Troubleshooting](https://github.com/sudolulo/winnow/wiki/Troubleshooting) · [FAQ](https://github.com/sudolulo/winnow/wiki/FAQ) **Docs:** [Setup](https://github.com/sudolulo/winnow/wiki/Setup) · [Troubleshooting](https://github.com/sudolulo/winnow/wiki/Troubleshooting) · [FAQ](https://github.com/sudolulo/winnow/wiki/FAQ)
`winnow` pulls photos from your [Immich](https://immich.app) library, selects the most diverse and highest-quality subset using AI embeddings, and delivers them as training data for [Frigate](https://frigate.video)'s face recognition and object classification models. `winnow` pulls photos from your [Immich](https://immich.app) library, selects the most diverse and highest-quality subset using AI embeddings, and delivers them as training data for [Frigate](https://frigate.video)'s face recognition and object classification models.
@@ -56,9 +59,11 @@ Immich library
8. Deliver 8. Deliver
• Face mode: upload crops to Frigate's face registration API • Face mode: upload crops to Frigate's face registration API
↳ below MAX_AUTO_IMAGES — upload freely ↳ below MAX_AUTO_IMAGES — upload freely
↳ at cap + QUALITY_REPLACEMENT=true — swap the lowest-scoring tracked ↳ at cap + QUALITY_REPLACEMENT=true — with Frigate scoring active,
image if the new candidate scores higher; manually added files are swap the most redundant tracked image (highest pre-upload recognize
never touched score) if the candidate is more novel (lower score); falling back to
blur-score comparison when no Frigate scores are available; manually
added files are never touched
↳ at cap + QUALITY_REPLACEMENT=false — skip this person ↳ at cap + QUALITY_REPLACEMENT=false — skip this person
• Object mode: save crops to disk → place into your Frigate data directory • Object mode: save crops to disk → place into your Frigate data directory
``` ```
@@ -183,14 +188,16 @@ In scheduled mode the process (and loaded models) stays resident between runs. T
| Variable | Default | Description | | Variable | Default | Description |
| :--- | :--- | :--- | | :--- | :--- | :--- |
| `MIN_FACE_WIDTH` | `50` | Minimum face crop width in pixels | | `MIN_FACE_WIDTH` | `90` | Minimum face crop width in pixels |
| `FACE_MARGIN` | `0.15` | Padding around bounding box crop (fraction of face size) | | `FACE_MARGIN` | `0.15` | Padding around bounding box crop (fraction of face size) |
| `ENABLE_FACE_ALIGNMENT` | `true` | Align to ArcFace 112×112 format using facial landmarks | | `ENABLE_FACE_ALIGNMENT` | `true` | Align to ArcFace 112×112 format using facial landmarks |
| `USE_FULL_RESOLUTION` | `true` | Download full-resolution originals rather than preview thumbnails | | `USE_FULL_RESOLUTION` | `true` | Download full-resolution originals rather than preview thumbnails |
| `MIN_CONFIDENCE` | `0.7` | Minimum Immich face detection confidence | | `MIN_CONFIDENCE` | `0.7` | Minimum Immich face detection confidence |
| `BLUR_THRESHOLD` | `100.0` | Laplacian variance threshold — lower accepts more blur | | `BLUR_THRESHOLD` | `120.0` | Laplacian variance threshold — lower accepts more blur |
| `MAX_AUTO_IMAGES` | `80` | Maximum training images per person in Frigate | | `MAX_AUTO_IMAGES` | `80` | Maximum training images per person in Frigate |
| `QUALITY_REPLACEMENT` | `true` | When at cap, swap the lowest-scoring tracked image for a better candidate. Never touches manually added Frigate files. Set `false` to skip people already at cap | | `QUALITY_REPLACEMENT` | `true` | When at cap, swap a weaker tracked image for a better candidate. With Frigate scoring active, targets the most redundant image (highest pre-upload recognize score); otherwise uses blur score. Never touches manually added Frigate files. Set `false` to skip people at cap |
| `FRIGATE_SCORE_CEILING` | `0.0` | Skip uploads whose pre-upload Frigate recognize score exceeds this value — they are already well-covered. `0` disables; requires at least one prior run to have scores |
| `ENABLE_FRIGATE_SCORES` | `true` | Call Frigate's recognize endpoint pre-upload to store diversity scores used for quality replacement. Adds ~200 ms per upload. Disable to use blur-score replacement only |
### GPU & Models ### GPU & Models
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "winnow" name = "winnow"
version = "0.3.3" version = "0.4.0"
description = "Selects diverse, high-quality photos from Immich as training data for Frigate face recognition and object classification." description = "Selects diverse, high-quality photos from Immich as training data for Frigate face recognition and object classification."
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
requires-python = ">=3.13" requires-python = ">=3.13"
+42
View File
@@ -218,3 +218,45 @@ def test_get_lowest_quality_exclude_all_returns_none():
mark_uploaded("asset-a", person_name="Alice", score=0.50) mark_uploaded("asset-a", person_name="Alice", score=0.50)
record_frigate_file("Alice", "Alice-a.webp", "asset-a") record_frigate_file("Alice", "Alice-a.webp", "asset-a")
assert get_lowest_quality_mapped_file("Alice", exclude={"Alice-a.webp"}) is None assert get_lowest_quality_mapped_file("Alice", exclude={"Alice-a.webp"}) is None
# ── get_most_redundant_mapped_file ────────────────────────────────────────────
def test_get_most_redundant_none_when_no_frigate_scores():
from winnow.upload_tracker import get_most_redundant_mapped_file, mark_uploaded, record_frigate_file
mark_uploaded("asset-a", person_name="Alice", score=0.80)
record_frigate_file("Alice", "Alice-a.webp", "asset-a")
# blur score only, no frigate_score → no candidates
assert get_most_redundant_mapped_file("Alice") is None
def test_get_most_redundant_returns_highest_frigate_score():
from winnow.upload_tracker import get_most_redundant_mapped_file, mark_uploaded, record_frigate_file
mark_uploaded("asset-novel", person_name="Alice", score=0.50, frigate_score=0.31)
mark_uploaded("asset-redundant", person_name="Alice", score=0.90, frigate_score=0.88)
record_frigate_file("Alice", "Alice-novel.webp", "asset-novel")
record_frigate_file("Alice", "Alice-redundant.webp", "asset-redundant")
result = get_most_redundant_mapped_file("Alice")
assert result is not None
frigate_filename, asset_id, score = result
assert frigate_filename == "Alice-redundant.webp"
assert asset_id == "asset-redundant"
assert score == pytest.approx(0.88, abs=0.001)
def test_get_most_redundant_exclude_skips_file():
from winnow.upload_tracker import get_most_redundant_mapped_file, mark_uploaded, record_frigate_file
mark_uploaded("asset-hi", person_name="Alice", score=0.9, frigate_score=0.85)
mark_uploaded("asset-lo", person_name="Alice", score=0.5, frigate_score=0.40)
record_frigate_file("Alice", "Alice-hi.webp", "asset-hi")
record_frigate_file("Alice", "Alice-lo.webp", "asset-lo")
result = get_most_redundant_mapped_file("Alice", exclude={"Alice-hi.webp"})
assert result is not None
assert result[1] == "asset-lo" # hi excluded; lo is next highest
def test_get_most_redundant_exclude_all_returns_none():
from winnow.upload_tracker import get_most_redundant_mapped_file, mark_uploaded, record_frigate_file
mark_uploaded("asset-a", person_name="Alice", score=0.5, frigate_score=0.70)
record_frigate_file("Alice", "Alice-a.webp", "asset-a")
assert get_most_redundant_mapped_file("Alice", exclude={"Alice-a.webp"}) is None
+2 -2
View File
@@ -31,7 +31,7 @@ class _Config:
MIN_CONFIDENCE: float = 0.7 MIN_CONFIDENCE: float = 0.7
MAX_AUTO_IMAGES: int = 80 MAX_AUTO_IMAGES: int = 80
QUALITY_REPLACEMENT: bool = True QUALITY_REPLACEMENT: bool = True
FRIGATE_SCORE_THRESHOLD: float = 0.0 FRIGATE_SCORE_CEILING: float = 0.0
ENABLE_FRIGATE_SCORES: bool = True ENABLE_FRIGATE_SCORES: bool = True
# People filtering # People filtering
@@ -64,7 +64,7 @@ class _Config:
self.MIN_CONFIDENCE = float(os.getenv("MIN_CONFIDENCE", "0.7")) self.MIN_CONFIDENCE = float(os.getenv("MIN_CONFIDENCE", "0.7"))
self.MAX_AUTO_IMAGES = int(os.getenv("MAX_AUTO_IMAGES", "80")) self.MAX_AUTO_IMAGES = int(os.getenv("MAX_AUTO_IMAGES", "80"))
self.QUALITY_REPLACEMENT = os.getenv("QUALITY_REPLACEMENT", "true").lower() in ("true", "1", "yes") self.QUALITY_REPLACEMENT = os.getenv("QUALITY_REPLACEMENT", "true").lower() in ("true", "1", "yes")
self.FRIGATE_SCORE_THRESHOLD = float(os.getenv("FRIGATE_SCORE_THRESHOLD", "0.0")) self.FRIGATE_SCORE_CEILING = float(os.getenv("FRIGATE_SCORE_CEILING", "0.0"))
self.ENABLE_FRIGATE_SCORES = os.getenv("ENABLE_FRIGATE_SCORES", "true").lower() in ("true", "1", "yes") self.ENABLE_FRIGATE_SCORES = os.getenv("ENABLE_FRIGATE_SCORES", "true").lower() in ("true", "1", "yes")
self.FACE_MARGIN = float(os.getenv("FACE_MARGIN", "0.15")) self.FACE_MARGIN = float(os.getenv("FACE_MARGIN", "0.15"))
self.USE_FULL_RESOLUTION = os.getenv("USE_FULL_RESOLUTION", "true").lower() in ("true", "1", "yes") self.USE_FULL_RESOLUTION = os.getenv("USE_FULL_RESOLUTION", "true").lower() in ("true", "1", "yes")
+83 -110
View File
@@ -19,17 +19,14 @@ from .immich_api import fetch_face_data, fetch_full_image
from .log_config import console from .log_config import console
from .quality import assess_quality from .quality import assess_quality
from .upload_tracker import ( from .upload_tracker import (
get_frigate_filename_for_asset,
get_lowest_quality_mapped_file, get_lowest_quality_mapped_file,
get_min_frigate_score, get_most_redundant_mapped_file,
get_tracked_frigate_file_count, get_tracked_frigate_file_count,
get_tracked_frigate_filenames, get_tracked_frigate_filenames,
has_frigate_scores, has_frigate_scores,
mark_rejected, mark_rejected,
mark_uploaded, mark_uploaded,
reclassify_as_rejected,
record_frigate_file, record_frigate_file,
remove_and_reclassify_batch,
remove_frigate_file, remove_frigate_file,
) )
@@ -318,7 +315,7 @@ def upload_to_frigate(jobs: list[dict]) -> None:
rprint(f" People: [bold]{len(face_jobs)}[/bold], Total images: [bold]{total_files}[/bold]") rprint(f" People: [bold]{len(face_jobs)}[/bold], Total images: [bold]{total_files}[/bold]")
uploaded, failed, gate_total = 0, 0, 0 uploaded, failed = 0, 0
max_retries = 2 max_retries = 2
# Fetch all Frigate training files once — avoids one GET /api/faces per person. # Fetch all Frigate training files once — avoids one GET /api/faces per person.
@@ -391,28 +388,12 @@ 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 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]"
)
elif Config.FRIGATE_SCORE_THRESHOLD == 0:
progress.console.print(
f" [dim]{name}: quality gate active at {_dynamic:.2f} (min stored score)[/dim]"
)
if Config.ENABLE_FRIGATE_SCORES and pre_run_count == 0: if Config.ENABLE_FRIGATE_SCORES and pre_run_count == 0:
progress.console.print( progress.console.print(
f" [dim]{name}: first run — quality gate will apply from the next run[/dim]" f" [dim]{name}: first run — Frigate diversity scoring 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()
min_quality_score_for_slot: float | None = None min_quality_score_for_slot: float | None = None
for fname in person_files: for fname in person_files:
@@ -433,21 +414,75 @@ def upload_to_frigate(jobs: list[dict]) -> None:
continue continue
at_cap = effective_count >= Config.MAX_AUTO_IMAGES at_cap = effective_count >= Config.MAX_AUTO_IMAGES
# Pre-upload Frigate score — clean measurement (image not yet in training set).
# Called for all below-cap uploads (seeds frigate_scores for future at-cap
# replacement) and for at-cap uploads when scores already exist. Skipped on
# the first run (pre_run_count == 0) since Frigate has no model yet.
# recognize_face returns (face_name, score); we only use the score when the
# best match is for the correct person. Mismatches (or "unknown") are treated
# as None so a wrong-person score never drives a ceiling skip or replacement.
# Frigate rebuilds its model asynchronously after any delete (clear + background
# thread), so the first recognize call after a deletion returns None — our code
# handles this conservatively by skipping that candidate until the next run.
pre_fscore: float | None = None
if Config.ENABLE_FRIGATE_SCORES and pre_run_count > 0:
if not at_cap or has_frigate_scores(name):
_result = recognize_face(fpath)
if _result is not None and _result[0] == name:
pre_fscore = _result[1]
# Ceiling check: skip if the existing training set already covers this
# face condition well. Applies below cap only — at cap, replacement logic
# drives the decision.
if not at_cap and Config.FRIGATE_SCORE_CEILING > 0 and pre_run_count > 0:
if pre_fscore is not None and pre_fscore > Config.FRIGATE_SCORE_CEILING:
progress.console.print(
f" [dim]⏭ {fname}: Frigate score {pre_fscore:.2f}"
f" > ceiling {Config.FRIGATE_SCORE_CEILING:.2f}, already covered[/dim]"
)
progress.advance(upload_task)
continue
if at_cap: if at_cap:
if not quality_replacement: if not quality_replacement:
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
using_fscore = has_frigate_scores(name) and Config.ENABLE_FRIGATE_SCORES using_fscore = has_frigate_scores(name) and Config.ENABLE_FRIGATE_SCORES
if using_fscore: if using_fscore:
candidate_score = recognize_face(fpath) candidate_score = pre_fscore
if candidate_score is None: if candidate_score is None:
progress.console.print( progress.console.print(
f" [dim]⏭ {fname}: Frigate recognize unavailable, skipping replacement[/dim]" f" [dim]⏭ {fname}: Frigate recognize unavailable, skipping replacement[/dim]"
) )
progress.advance(upload_task) progress.advance(upload_task)
continue continue
score_label = "frigate" # Low score = more novel than the most redundant mapped file = replace
target = get_most_redundant_mapped_file(name, exclude=failed_deletes)
if target is None or candidate_score >= target[2]:
target_score_str = f"{target[2]:.3f}" if target is not None else "N/A"
progress.console.print(
f" [dim]⏭ {fname}: frigate {candidate_score:.3f} ≥ most redundant"
f" {target_score_str}, not more novel[/dim]"
)
progress.advance(upload_task)
continue
target_frigate_file, _target_asset_id, target_score = target
progress.console.print(
f" 🔄 {fname}: frigate {candidate_score:.3f} < {target_score:.3f},"
f" replacing {target_frigate_file} (more novel)"
)
if delete_frigate_person_files(name, [target_frigate_file]):
remove_frigate_file(name, target_frigate_file)
effective_count -= 1
min_quality_score_for_slot = None # clear any blur-mode slot floor — Frigate uses a different score metric
else:
logger.warning(f"Failed to delete {target_frigate_file} for {name}, skipping replacement")
failed_deletes.add(target_frigate_file)
progress.advance(upload_task)
continue
else: else:
candidate_score = score_map.get(fname) candidate_score = score_map.get(fname)
if candidate_score is None: if candidate_score is None:
@@ -456,41 +491,29 @@ def upload_to_frigate(jobs: list[dict]) -> None:
) )
progress.advance(upload_task) progress.advance(upload_task)
continue continue
score_label = "blur" target = get_lowest_quality_mapped_file(name, exclude=failed_deletes)
# Skip replacement if candidate would fail the quality gate — if target is None or candidate_score <= target[2]:
# deleting the worst then gating the new one is a net slot loss. target_score_str = f"{target[2]:.3f}" if target is not None else "N/A"
if using_fscore and effective_threshold > 0 and pre_run_count > 0 and candidate_score < effective_threshold: progress.console.print(
f" [dim]⏭ {fname}: blur {candidate_score:.3f} ≤ worst"
f" {target_score_str}, skipping[/dim]"
)
progress.advance(upload_task)
continue
target_frigate_file, _target_asset_id, target_score = target
progress.console.print( progress.console.print(
f" [dim]⏭ {fname}: frigate {candidate_score:.3f} below gate threshold" f" 🔄 {fname}: blur {candidate_score:.3f} > {target_score:.3f},"
f" {effective_threshold:.2f}, skipping replacement[/dim]" f" replacing {target_frigate_file}"
) )
progress.advance(upload_task) if delete_frigate_person_files(name, [target_frigate_file]):
continue remove_frigate_file(name, target_frigate_file)
worst = get_lowest_quality_mapped_file(name, exclude=failed_deletes) effective_count -= 1
if worst is None or candidate_score <= worst[2]: min_quality_score_for_slot = score_map.get(fname)
worst_score_str = f"{worst[2]:.3f}" if worst is not None else "N/A" else:
progress.console.print( logger.warning(f"Failed to delete {target_frigate_file} for {name}, skipping replacement")
f" [dim]⏭ {fname}: {score_label} {candidate_score:.3f} ≤ worst" failed_deletes.add(target_frigate_file)
f" {worst_score_str}, skipping[/dim]" progress.advance(upload_task)
) continue
progress.advance(upload_task)
continue
worst_frigate_file, _worst_asset_id, worst_score = worst
progress.console.print(
f" 🔄 {fname}: {score_label} {candidate_score:.3f} > {worst_score:.3f},"
f" replacing {worst_frigate_file}"
)
if delete_frigate_person_files(name, [worst_frigate_file]):
remove_frigate_file(name, worst_frigate_file)
effective_count -= 1
# 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:
logger.warning(f"Failed to delete {worst_frigate_file} for {name}, skipping replacement")
failed_deletes.add(worst_frigate_file)
progress.advance(upload_task)
continue
for attempt in range(1, max_retries + 1): for attempt in range(1, max_retries + 1):
try: try:
@@ -508,31 +531,15 @@ 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) if Config.ENABLE_FRIGATE_SCORES else None
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, frigate_score=pre_fscore,
) )
actually_uploaded.append((fname, asset_id)) actually_uploaded.append((fname, asset_id))
# 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.
if (
effective_threshold > 0
and pre_run_count > 0
and post_fscore is not None
and post_fscore < effective_threshold
):
quality_gate_failed.add(asset_id)
progress.console.print(
f" [yellow]⚠ {fname}: Frigate score {post_fscore:.2f}"
f" < threshold {effective_threshold:.2f}, will remove after mapping[/yellow]"
)
break break
else: else:
if attempt < max_retries: if attempt < max_retries:
@@ -598,46 +605,14 @@ def upload_to_frigate(jobs: list[dict]) -> None:
if actually_uploaded: if actually_uploaded:
_reconcile_frigate_mappings(name, known_frigate_files_at_start, actually_uploaded) _reconcile_frigate_mappings(name, known_frigate_files_at_start, actually_uploaded)
# Post-reconcile quality gate: filenames are now mapped, so we can delete.
gate_removed = 0
if quality_gate_failed:
to_delete: list[tuple[str, str]] = [] # (frigate_fn, asset_id)
for asset_id in quality_gate_failed:
frigate_fn = get_frigate_filename_for_asset(name, asset_id)
if frigate_fn:
to_delete.append((frigate_fn, asset_id))
else:
logger.warning(
f"{name}: could not remove low-score file for {asset_id}"
" — no Frigate filename mapped (reconciliation race?)"
)
if to_delete:
if delete_frigate_person_files(name, [fn for fn, _ in to_delete]):
remove_and_reclassify_batch(name, to_delete)
gate_removed = len(to_delete)
effective_count -= gate_removed
gate_total += gate_removed
else:
logger.warning(
f"{name}: batch delete of {len(to_delete)} low-score file(s) failed"
)
# Per-person summary # Per-person summary
if person_failed == 0 and gate_removed == 0: if person_failed == 0:
progress.console.print( progress.console.print(
f" ✅ {name}: {person_uploaded}/{person_uploaded} uploaded" f" ✅ {name}: {person_uploaded}/{person_uploaded} uploaded"
) )
elif person_failed == 0:
net = person_uploaded - gate_removed
progress.console.print(
f" [yellow]✅ {name}: {person_uploaded} uploaded,"
f" {gate_removed} removed by quality gate (score < {effective_threshold:.2f})"
f" → {net} net[/yellow]"
)
else: else:
gate_note = f", {gate_removed} removed by quality gate" if gate_removed else ""
progress.console.print( progress.console.print(
f" ⚠️ {name}: {person_uploaded} succeeded, {person_failed} failed{gate_note}" f" ⚠️ {name}: {person_uploaded} succeeded, {person_failed} failed"
) )
# Grand summary # Grand summary
@@ -647,8 +622,6 @@ def upload_to_frigate(jobs: list[dict]) -> None:
rprint(f" ❌ Failed: [red]{failed}[/red]") rprint(f" ❌ Failed: [red]{failed}[/red]")
else: else:
rprint(" ❌ Failed: 0") rprint(" ❌ Failed: 0")
if gate_total:
rprint(f" 🗑 Removed (quality gate): [yellow]{gate_total}[/yellow]")
if failed > 0: if failed > 0:
rprint(" [yellow]Check logs above for per-file error details.[/yellow]") rprint(" [yellow]Check logs above for per-file error details.[/yellow]")
+8 -3
View File
@@ -69,8 +69,13 @@ 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: def recognize_face(file_path: str) -> tuple[str | None, float] | None:
"""Submit an image to Frigate's recognize endpoint and return the confidence score. """Submit an image to Frigate's recognize endpoint.
Returns (face_name, score) where face_name is the best-matching person
(may be "unknown" if below Frigate's confidence threshold) and score is
the sigmoid-mapped cosine similarity (0-1) against that person's mean
embedding.
Returns None if FRIGATE_URL is unset, the API is unreachable, no face is Returns None if FRIGATE_URL is unset, the API is unreachable, no face is
detected, or face recognition is not enabled in Frigate. detected, or face recognition is not enabled in Frigate.
@@ -89,7 +94,7 @@ def recognize_face(file_path: str) -> float | None:
return None return None
data = resp.json() data = resp.json()
if data.get("success") and "score" in data: if data.get("success") and "score" in data:
return round(float(data["score"]), 4) return (data.get("face_name"), round(float(data["score"]), 4))
return None return None
except Exception as e: except Exception as e:
logger.debug(f"Frigate recognize failed for {file_path}: {e}") logger.debug(f"Frigate recognize failed for {file_path}: {e}")
+30 -95
View File
@@ -13,16 +13,15 @@ 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_scores": {"immich-id-1": 0.87}, # Frigate recognition confidence (0-1) pre-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 frigate_scores stores pre-upload recognize scores (0-1 sigmoid-mapped cosine
displays in its UI. When available, quality replacement uses frigate_scores in similarity). High score = the existing training set already covers this face
preference to blur scores — an image Frigate cannot recognize is a poor training condition well. Low score = a gap — novel/diverse for the training set.
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.
@@ -147,62 +146,6 @@ 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 remove_and_reclassify_batch(
person_name: str, frigate_files_and_assets: list[tuple[str, str]]
) -> None:
"""Remove Frigate file mappings and reclassify asset IDs as rejected in one pass.
Replaces individual remove_frigate_file + reclassify_as_rejected calls in the
quality gate batch — 2 file writes total instead of 3×N.
"""
frigate_fns = [fn for fn, _ in frigate_files_and_assets]
asset_ids = [aid for _, aid in frigate_files_and_assets]
# Uploaded tracker: remove file mappings + remove from flat set
uploaded_data = _load(UPLOAD_TRACKER_FILE)
flat_up = set(uploaded_data.get("uploaded_asset_ids", []))
for aid in asset_ids:
flat_up.discard(aid)
uploaded_data["uploaded_asset_ids"] = sorted(flat_up)
by_person = uploaded_data.setdefault("by_person", {})
entry = _migrate_entry(by_person.get(person_name, {}))
for fn in frigate_fns:
entry["frigate_files"].pop(fn, None)
by_person[person_name] = entry
_save(UPLOAD_TRACKER_FILE, uploaded_data)
# Rejected tracker: add to flat set + by_person
rejected_data = _load(REJECT_TRACKER_FILE)
flat_rej = set(rejected_data.get("rejected_asset_ids", []))
flat_rej.update(asset_ids)
rejected_data["rejected_asset_ids"] = sorted(flat_rej)
rej_by_person = rejected_data.setdefault("by_person", {})
rej_entry = _migrate_entry(rej_by_person.get(person_name, {}))
ids = set(rej_entry["asset_ids"])
ids.update(asset_ids)
rej_entry["asset_ids"] = sorted(ids)
rej_by_person[person_name] = rej_entry
_save(REJECT_TRACKER_FILE, rejected_data)
logger.debug(f"Batch-reclassified {len(asset_ids)} asset(s) 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."""
@@ -265,61 +208,53 @@ 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. blur score, or None if no mapped files with known scores exist.
Uses Frigate recognition scores (0-1) when any are present for this person, Used for quality replacement when no Frigate scores are available.
treating files without a Frigate score as 0.0. Falls back to Laplacian blur Pass `exclude` to skip files that failed to delete this run.
scores when no Frigate scores exist yet.
Pass `exclude` to skip files that failed to delete this run without removing
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", {})
blur_scores = entry.get("scores", {}) blur_scores = entry.get("scores", {})
frigate_scores = entry.get("frigate_scores", {})
mapped = [ candidates = [
(ff, asset_id) (ff, asset_id, blur_scores[asset_id])
for ff, asset_id in frigate_files.items() for ff, asset_id in frigate_files.items()
if exclude is None or ff not in exclude if (exclude is None or ff not in exclude)
and asset_id in blur_scores
] ]
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])
def get_min_frigate_score(person_name: str) -> float | None: def get_most_redundant_mapped_file(
"""Return the lowest stored Frigate recognition score for this person's mapped files. person_name: str, exclude: set[str] | None = None
) -> tuple[str, str, float] | None:
"""Return (frigate_filename, asset_id, score) for the mapped file with the highest
Frigate recognition score, or None if no mapped files with Frigate scores exist.
Returns None if no Frigate scores have been recorded yet (cold start or High Frigate score = the training set already covers this face condition well
feature not yet active). Used to derive a dynamic quality threshold so new = the most redundant file and therefore the best replacement target.
uploads must score at least as well as the weakest image already in the set. Pass `exclude` to skip files that failed to delete this 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", {})
frigate_scores = entry.get("frigate_scores", {}) 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 candidates = [
(ff, asset_id, frigate_scores[asset_id])
for ff, asset_id in frigate_files.items()
if (exclude is None or ff not in exclude)
and asset_id in frigate_scores
]
if not candidates:
return None
return max(candidates, key=lambda x: x[2])
def get_frigate_filename_for_asset(person_name: str, asset_id: str) -> str | None: def get_frigate_filename_for_asset(person_name: str, asset_id: str) -> str | None: