feat: dynamic Frigate score ceiling; consolidate quality replacement branches

FRIGATE_SCORE_CEILING now defaults to dynamic mode (unset): below-cap
candidates are skipped if their pre-upload Frigate score exceeds the
most-redundant tracked file's score. This catches conditions already
covered by manually-added Frigate images that winnow cannot track —
the embedding-based diversity selection has no visibility into those.
Set FRIGATE_SCORE_CEILING=0 to disable; a positive value (e.g. 0.85)
still acts as a fixed hard ceiling. First-run safety is unchanged
(pre_run_count==0 prevents recognize_face from being called).

The two quality replacement branches (Frigate-score and blur-score)
shared identical structure and are merged into a single code path
parameterised by score source and comparison direction.

Also raises MIN_FACE_COUNT default from 0 to 3 and updates the
config test to match.
This commit is contained in:
2026-06-14 16:38:25 +00:00
parent a59d05e7fd
commit 0afc9386c6
6 changed files with 77 additions and 71 deletions
+11
View File
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.4.9] - 2026-06-14
### Changed
- **`FRIGATE_SCORE_CEILING` is now dynamic by default**: previously defaulted to `0` (disabled). Now unset (default) enables a self-calibrating novelty gate — below-cap candidates are skipped if their pre-upload Frigate score exceeds the most-redundant tracked file's score. This catches conditions already covered by manually-added Frigate images that winnow cannot track. Set `FRIGATE_SCORE_CEILING=0` to disable entirely; set a positive value (e.g. `0.85`) for a fixed hard ceiling.
- **Quality replacement branches consolidated**: the Frigate-score and blur-score replacement paths in the upload loop shared identical structure. Merged into a single code path parameterised by score source and comparison direction.
- **`MIN_FACE_COUNT` default raised from `0` to `3`**: people with fewer than 3 tagged photos produce degenerate training sets; skipping them by default avoids noisy runs.
- **`STRATEGY=adaptive`** is the new primary name for embedding-based diversity selection; `auto` remains a silent alias for backwards compatibility.
- **`MERGE_DUPLICATE_PEOPLE` and `TRACE_CROP_SIZE`** added to the README env var table (were in the codebase but undocumented).
- **CUDA version corrected** in the image tags table (was 13.3, actual base image is 12.8.1).
## [0.4.8] - 2026-06-14
### Changed
+1 -1
View File
@@ -207,7 +207,7 @@ In scheduled mode the process (and loaded models) stays resident between runs. T
| `BLUR_THRESHOLD` | `120.0` | Laplacian variance threshold — lower accepts more blur |
| `MAX_AUTO_IMAGES` | `80` | Maximum training images per person in Frigate |
| `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 |
| `FRIGATE_SCORE_CEILING` | *(unset)* | Below-cap novelty gate against Frigate's live model (catches conditions covered by manually-added images too). Unset: dynamic — skips candidates whose Frigate score exceeds the most-redundant tracked file's score, auto-calibrates each run. `0`: disable entirely. Positive value (e.g. `0.85`): fixed hard ceiling. No effect on the first run or when `ENABLE_FRIGATE_SCORES=false` |
| `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
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "winnow"
version = "0.4.8"
version = "0.4.9"
description = "Selects diverse, high-quality photos from Immich as training data for Frigate face recognition and object classification."
license = "AGPL-3.0-or-later"
requires-python = ">=3.13"
+1 -1
View File
@@ -18,7 +18,7 @@ def test_config_loads_defaults(monkeypatch):
assert cfg.OUTPUT_DIR == "./frigate_train"
assert cfg.YEARS_FILTER == 10
assert cfg.MIN_FACE_WIDTH == 90
assert cfg.MIN_FACE_COUNT == 0
assert cfg.MIN_FACE_COUNT == 3
assert cfg.BLUR_THRESHOLD == 120.0
assert cfg.MIN_CONFIDENCE == 0.7
assert cfg.MAX_AUTO_IMAGES == 80
+3 -2
View File
@@ -31,7 +31,7 @@ class _Config:
MIN_CONFIDENCE: float = 0.7
MAX_AUTO_IMAGES: int = 80
QUALITY_REPLACEMENT: bool = True
FRIGATE_SCORE_CEILING: float = 0.0
FRIGATE_SCORE_CEILING: float | None = None
ENABLE_FRIGATE_SCORES: bool = True
# People filtering
@@ -66,7 +66,8 @@ class _Config:
self.MIN_CONFIDENCE = float(os.getenv("MIN_CONFIDENCE", "0.7"))
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.FRIGATE_SCORE_CEILING = float(os.getenv("FRIGATE_SCORE_CEILING", "0.0"))
_ceiling_env = os.getenv("FRIGATE_SCORE_CEILING", "").strip()
self.FRIGATE_SCORE_CEILING = float(_ceiling_env) if _ceiling_env else None
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.USE_FULL_RESOLUTION = os.getenv("USE_FULL_RESOLUTION", "true").lower() in ("true", "1", "yes")
+60 -66
View File
@@ -482,14 +482,28 @@ def upload_to_frigate(jobs: list[dict]) -> None:
if _result is not None and (_result[0] or "").casefold() == name.casefold():
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:
# Below-cap novelty gate: skip candidates already covered by the Frigate model,
# including conditions learned from manually-added images winnow can't track.
# pre_fscore is None on the first run (pre_run_count == 0 skips recognize_face
# above), so this block never fires on the first run without an extra guard.
if not at_cap and pre_fscore is not None:
_ceiling = Config.FRIGATE_SCORE_CEILING
if _ceiling is None:
# Dynamic default: bar = most-redundant tracked file's Frigate score.
# Falls back to uploading freely when no tracked scores exist yet.
_bar = get_most_redundant_mapped_file(name)
_skip = _bar is not None and pre_fscore > _bar[2]
_bar_str = f"most redundant tracked {_bar[2]:.2f}" if _bar else ""
elif _ceiling == 0.0:
_skip = False # explicitly disabled
_bar_str = ""
else:
_skip = pre_fscore > _ceiling
_bar_str = f"ceiling {_ceiling:.2f}"
if _skip:
progress.console.print(
f" [dim]⏭ {fname}: Frigate score {pre_fscore:.2f}"
f" > ceiling {Config.FRIGATE_SCORE_CEILING:.2f}, already covered[/dim]"
f" > {_bar_str}, already covered[/dim]"
)
progress.advance(upload_task)
continue
@@ -503,70 +517,50 @@ def upload_to_frigate(jobs: list[dict]) -> None:
using_fscore = person_has_fscores and Config.ENABLE_FRIGATE_SCORES
if using_fscore:
candidate_score = pre_fscore
if candidate_score is None:
progress.console.print(
f" [dim]⏭ {fname}: Frigate recognize unavailable, skipping replacement[/dim]"
)
progress.advance(upload_task)
continue
# 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)
person_has_fscores = has_frigate_scores(name)
effective_count -= 1
# clear any blur-mode slot floor — Frigate uses a different score metric
min_quality_score_for_slot = None
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
get_target = get_most_redundant_mapped_file
score_label, better_note = "frigate", " (more novel)"
no_score_msg = "Frigate recognize unavailable, skipping replacement"
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
target = get_lowest_quality_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}: 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
get_target = get_lowest_quality_mapped_file
score_label, better_note = "blur", ""
no_score_msg = "no quality score, skipping replacement"
if candidate_score is None:
progress.console.print(f" [dim]⏭ {fname}: {no_score_msg}[/dim]")
progress.advance(upload_task)
continue
target = get_target(name, exclude=failed_deletes)
not_better = target is None or (
candidate_score >= target[2] if using_fscore else candidate_score <= target[2]
)
if not_better:
target_str = f"{target[2]:.3f}" if target is not None else "N/A"
op = "<" if using_fscore else ">"
progress.console.print(
f" 🔄 {fname}: blur {candidate_score:.3f} > {target_score:.3f},"
f" replacing {target_frigate_file}"
f" [dim]⏭ {fname}: {score_label} {candidate_score:.3f}"
f" not {op} {target_str}, skipping[/dim]"
)
if delete_frigate_person_files(name, [target_frigate_file]):
remove_frigate_file(name, target_frigate_file)
person_has_fscores = has_frigate_scores(name)
effective_count -= 1
min_quality_score_for_slot = score_map.get(fname)
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
progress.advance(upload_task)
continue
target_frigate_file, _target_asset_id, target_score = target
op = "<" if using_fscore else ">"
progress.console.print(
f" 🔄 {fname}: {score_label} {candidate_score:.3f} {op} {target_score:.3f},"
f" replacing {target_frigate_file}{better_note}"
)
if delete_frigate_person_files(name, [target_frigate_file]):
remove_frigate_file(name, target_frigate_file)
person_has_fscores = has_frigate_scores(name)
effective_count -= 1
min_quality_score_for_slot = None if using_fscore else candidate_score
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
for attempt in range(1, max_retries + 1):
try: