From 2dc26b5a3d3e3667959e4c4f53029c3437402cd8 Mon Sep 17 00:00:00 2001 From: Holden Date: Sun, 14 Jun 2026 16:11:21 +0000 Subject: [PATCH 1/8] docs: fix CUDA version, add MERGE_DUPLICATE_PEOPLE and TRACE_CROP_SIZE to README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `:latest` image tag listed CUDA 13.3 — actual base is 12.8.1 - MERGE_DUPLICATE_PEOPLE existed in config but was absent from env var table - TRACE_CROP_SIZE existed in CLI but was absent from env var table - RESET_PERSON description now mentions `*` wildcard for bulk reset of all people --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a966efd..a72dba0 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ Uploaded and rejected asset IDs are persisted across runs. The same image is nev | Tag | Arch | Acceleration | | :-- | :-- | :-- | -| `:latest` | amd64 + arm64 | NVIDIA CUDA 13.3 (amd64) · requires [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html) | +| `:latest` | amd64 + arm64 | NVIDIA CUDA 12.8 (amd64) · requires [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html) | | `:rocm` | amd64 | AMD ROCm · pass `/dev/kfd` + `/dev/dri` | | `:intel` | amd64 | Intel Arc / iGPU via OpenVINO · pass `/dev/dri`, set `OPENVINO_DEVICE=GPU` | | `:cpu` | amd64 + arm64 | CPU only · ~2 GB smaller · no GPU required | @@ -192,6 +192,7 @@ In scheduled mode the process (and loaded models) stays resident between runs. T | `ONLY_PEOPLE` | *(unset)* | Comma-separated whitelist — process only these people | | `SKIP_PEOPLE` | *(unset)* | Comma-separated list — skip these people | | `MIN_FACE_COUNT` | `0` | Skip people with fewer than N tagged assets in Immich | +| `MERGE_DUPLICATE_PEOPLE` | `false` | When Immich has duplicate entries for the same person (same face split across multiple names), merge their asset pools before processing. Without this, each duplicate group emits a warning and is skipped | | `YEARS_FILTER` | `10` | Ignore images older than N years | ### Image Quality @@ -232,7 +233,8 @@ In scheduled mode the process (and loaded models) stays resident between runs. T | :--- | :--- | :--- | | `DRY_RUN` | `false` | Preview selection without downloading or uploading | | `RETRY_REJECTED` | `false` | Re-attempt assets previously rejected by Frigate | -| `RESET_PERSON` | *(unset)* | Clear upload history for one person and delete their winnow-managed Frigate training files so the next run starts fresh. Manually added Frigate files are never touched | +| `RESET_PERSON` | *(unset)* | Set to a person's name to clear their upload history and delete their winnow-managed Frigate training files so the next run starts fresh. Set to `*` to reset all tracked people at once. Manually added Frigate files are never touched | +| `TRACE_CROP_SIZE` | *(unset)* | Debug: print all tracked crops whose width or height matches this pixel value, then exit | ### Scheduling From d0cb2e17b1b4982be74900eee1b95275662c6cfc Mon Sep 17 00:00:00 2001 From: Holden Date: Sun, 14 Jun 2026 16:12:23 +0000 Subject: [PATCH 2/8] config: raise MIN_FACE_COUNT default from 0 to 3 People with fewer than 3 tagged photos produce degenerate training sets and rarely benefit from processing. Skip them by default. --- README.md | 2 +- winnow/config.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a72dba0..5e077ad 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,7 @@ In scheduled mode the process (and loaded models) stays resident between runs. T | :--- | :--- | :--- | | `ONLY_PEOPLE` | *(unset)* | Comma-separated whitelist — process only these people | | `SKIP_PEOPLE` | *(unset)* | Comma-separated list — skip these people | -| `MIN_FACE_COUNT` | `0` | Skip people with fewer than N tagged assets in Immich | +| `MIN_FACE_COUNT` | `3` | Skip people with fewer than N tagged assets in Immich | | `MERGE_DUPLICATE_PEOPLE` | `false` | When Immich has duplicate entries for the same person (same face split across multiple names), merge their asset pools before processing. Without this, each duplicate group emits a warning and is skipped | | `YEARS_FILTER` | `10` | Ignore images older than N years | diff --git a/winnow/config.py b/winnow/config.py index b7b4c95..435e596 100644 --- a/winnow/config.py +++ b/winnow/config.py @@ -35,7 +35,7 @@ class _Config: ENABLE_FRIGATE_SCORES: bool = True # People filtering - MIN_FACE_COUNT: int = 0 + MIN_FACE_COUNT: int = 3 MERGE_DUPLICATE_PEOPLE: bool = False # Output quality @@ -60,7 +60,7 @@ class _Config: self.OUTPUT_DIR = os.getenv("OUTPUT_DIR", "./frigate_train") self.YEARS_FILTER = int(os.getenv("YEARS_FILTER", "10")) self.MIN_FACE_WIDTH = int(os.getenv("MIN_FACE_WIDTH", "90")) - self.MIN_FACE_COUNT = int(os.getenv("MIN_FACE_COUNT", "0")) + self.MIN_FACE_COUNT = int(os.getenv("MIN_FACE_COUNT", "3")) self.MERGE_DUPLICATE_PEOPLE = os.getenv("MERGE_DUPLICATE_PEOPLE", "false").lower() in ("true", "1", "yes") self.BLUR_THRESHOLD = float(os.getenv("BLUR_THRESHOLD", "120.0")) self.MIN_CONFIDENCE = float(os.getenv("MIN_CONFIDENCE", "0.7")) From a59d05e7fd73711867b9136668a2f57edff9fe7f Mon Sep 17 00:00:00 2001 From: Holden Date: Sun, 14 Jun 2026 16:15:34 +0000 Subject: [PATCH 3/8] rename STRATEGY=auto to STRATEGY=adaptive; keep auto as alias AUTO_MODE (batch/unattended) and STRATEGY=auto (diversity algorithm) shared the same word for unrelated concepts. Renaming the strategy value to 'adaptive' eliminates the ambiguity. The old value is kept as a silent alias so existing configs continue to work. Interactive menu updated from "Auto (Objective Diversity)" to "Adaptive Diversity". README AUTO_MODE description clarified to emphasise unattended batch processing, not selection strategy. --- README.md | 8 ++++---- winnow/jobs.py | 7 ++++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 5e077ad..3a186e1 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Immich library • Farthest Point Sampling → fill remaining slots with maximally spread picks • Hard example weighting — low-confidence detections get a distance boost so unusual angles and harder looks are preferred over easy frontals - • Auto mode: stops when the next candidate is too similar to those already + • Adaptive mode: stops when the next candidate is too similar to those already selected (distance threshold = 20 % of median pairwise distance for faces, 10 % for objects) │ @@ -179,10 +179,10 @@ In scheduled mode the process (and loaded models) stays resident between runs. T | Variable | Default | Description | | :--- | :--- | :--- | | `TRAINING_MODE` | `face` | `face` — upload crops to Frigate; `object` — save crops to disk | -| `STRATEGY` | `auto` | `auto` (embedding-based adaptive), `standard` (30 images), `broad` (100 images) | +| `STRATEGY` | `adaptive` | `adaptive` — embedding-based diversity selection, stops when candidates become redundant; `standard` — fixed 30 images; `broad` — fixed 100 images | | `LIMIT` | *(unset)* | Exact image count — overrides `STRATEGY` | | `OBJECT_CLASS` | `dog` | Target class for object mode (any YOLO class: `dog`, `cat`, `car`, etc.) | -| `AUTO_MODE` | *(auto)* | Force non-interactive mode in a terminal; auto-detected otherwise | +| `AUTO_MODE` | *(auto)* | Skip interactive prompts and process all people unattended — auto-detected when no TTY is present (Docker, cron); set `true` to force in a terminal | | `VERBOSE` | `false` | Enable DEBUG-level console output (log file is always DEBUG) | ### People Filtering @@ -255,7 +255,7 @@ uv run winnow Requires Python 3.13+ and [uv](https://astral.sh/uv). An NVIDIA, AMD, or Intel GPU is recommended — CPU mode works but embedding computation is slower. -When run with a terminal attached, winnow starts an interactive session: select which people to process and choose a strategy (auto, standard, broad, or a custom count) per person. Without a TTY — Docker, cron, or `AUTO_MODE=true` — it processes all people automatically using the configured defaults. +When run with a terminal attached, winnow starts an interactive session: select which people to process and choose a strategy (adaptive, standard, broad, or a custom count) per person. Without a TTY — Docker, cron, or `AUTO_MODE=true` — it processes all people unattended using the configured defaults. --- diff --git a/winnow/jobs.py b/winnow/jobs.py index fda3fd8..bd8ce3a 100644 --- a/winnow/jobs.py +++ b/winnow/jobs.py @@ -20,7 +20,7 @@ logger = logging.getLogger(__name__) # Strategy presets: (limit, mode_name) STRATEGY_PRESETS = { - "1": ("auto", "Auto Diversity"), + "1": ("auto", "Adaptive Diversity"), "2": (30, "Standard (30)"), "3": (100, "Broad (100)"), } @@ -31,7 +31,7 @@ def _get_strategy_choice(has_embedding: bool, entity_type: str) -> tuple[int | s model_name = "InsightFace" if entity_type == "face" else "SigLIP" if has_embedding: - rprint(" [bold]1.[/bold] Auto (Objective Diversity) [green][Recommended][/green]") + rprint(" [bold]1.[/bold] Adaptive Diversity [green][Recommended][/green]") rprint(" [dim]• Dynamically selects images until redundancy starts[/dim]") rprint(" [bold]2.[/bold] Standard (30 images)") rprint(" [bold]3.[/bold] Broad (100 images)") @@ -77,7 +77,8 @@ def _resolve_strategy(strategy: str, has_embedding: bool) -> tuple[int | str, st return int(custom_limit), "smart" strategy_map = { - "auto": ("auto", "smart"), + "adaptive": ("auto", "smart"), + "auto": ("auto", "smart"), # legacy alias for adaptive "standard": (30, "smart"), "broad": (100, "smart"), } From 0afc9386c60db4b1e90e18c9dfff4634d8581724 Mon Sep 17 00:00:00 2001 From: Holden Date: Sun, 14 Jun 2026 16:38:25 +0000 Subject: [PATCH 4/8] feat: dynamic Frigate score ceiling; consolidate quality replacement branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 11 ++++ README.md | 2 +- pyproject.toml | 2 +- tests/test_config.py | 2 +- winnow/config.py | 5 +- winnow/executor.py | 126 +++++++++++++++++++++---------------------- 6 files changed, 77 insertions(+), 71 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17cefef..9e686dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 3a186e1..afd8f24 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 737a7b3..9a2792b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_config.py b/tests/test_config.py index 5d700d4..d2c56ed 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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 diff --git a/winnow/config.py b/winnow/config.py index 435e596..b22e369 100644 --- a/winnow/config.py +++ b/winnow/config.py @@ -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") diff --git a/winnow/executor.py b/winnow/executor.py index afdc4c8..ac33577 100644 --- a/winnow/executor.py +++ b/winnow/executor.py @@ -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: From 105099c819be2180665abdbee6349b9ce8d0eedd Mon Sep 17 00:00:00 2001 From: Holden Date: Sun, 14 Jun 2026 16:46:09 +0000 Subject: [PATCH 5/8] fix: mark assets rejected when faces API confidence is below threshold Previously, assets that passed embedding-phase selection but failed the faces API confidence check in execute_jobs were silently skipped with no tracker entry. They appeared as valid candidates on every future run, were re-selected, and re-skipped in an endless cycle. Now they are marked rejected so they are excluded from future runs. RETRY_REJECTED=true clears them if Immich later re-processes the image. --- winnow/executor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/winnow/executor.py b/winnow/executor.py index ac33577..10f852a 100644 --- a/winnow/executor.py +++ b/winnow/executor.py @@ -231,6 +231,7 @@ def execute_jobs(jobs: list[dict]) -> None: f"[yellow]Skipped {asset['id']}" f" (detection confidence {conf:.2f} < {Config.MIN_CONFIDENCE})[/yellow]" ) + mark_rejected(asset["id"], person_name=name) progress.advance(job_task) progress.advance(overall_task) continue From 51cc7032ebcffe65f14da9053cf0b77615145f62 Mon Sep 17 00:00:00 2001 From: Holden Date: Sun, 14 Jun 2026 16:54:56 +0000 Subject: [PATCH 6/8] =?UTF-8?q?docs:=20README=20accuracy=20audit=20?= =?UTF-8?q?=E2=80=94=20novelty=20gate,=20rejection=20scope,=20calibrated?= =?UTF-8?q?=20defaults?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - How It Works step 9: "upload freely" → note novelty gate may skip below-cap candidates - Persistence note: "Frigate rejections" → "rejected assets" (covers confidence skips too) - RETRY_REJECTED description: explicitly covers all rejection types, not just Frigate - Image Quality section: split into user-adjustable controls and calibrated image processing defaults with a support disclaimer to deter blind tuning --- README.md | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index afd8f24..ec9d066 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,9 @@ Immich library ▼ 9. Deliver • Face mode: upload crops to Frigate's face registration API - ↳ below MAX_AUTO_IMAGES — upload freely + ↳ below MAX_AUTO_IMAGES — upload, unless the novelty gate + (FRIGATE_SCORE_CEILING) determines the candidate is already + covered by the current training set ↳ at cap + QUALITY_REPLACEMENT=true — with Frigate scoring active, swap the most redundant tracked image (highest pre-upload recognize score) if the candidate is more novel (lower score); falling back to @@ -78,7 +80,7 @@ Immich library • Object mode: save crops to disk → place into your Frigate data directory ``` -Uploaded and rejected asset IDs are persisted across runs. The same image is never processed twice; Frigate rejections are permanently skipped unless `RETRY_REJECTED=true`. +Uploaded and rejected asset IDs are persisted across runs. The same image is never processed twice; rejected assets are permanently skipped unless `RETRY_REJECTED=true`. --- @@ -197,6 +199,17 @@ In scheduled mode the process (and loaded models) stays resident between runs. T ### Image Quality +| Variable | Default | Description | +| :--- | :--- | :--- | +| `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` | *(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 | + +#### Image Processing *(calibrated — do not adjust)* + +These defaults are tuned for Frigate's ArcFace requirements. If you change them and run into image quality issues, support will not be provided. + | Variable | Default | Description | | :--- | :--- | :--- | | `MIN_FACE_WIDTH` | `90` | Minimum face crop width in pixels | @@ -205,10 +218,6 @@ In scheduled mode the process (and loaded models) stays resident between runs. T | `USE_FULL_RESOLUTION` | `true` | Download full-resolution originals rather than preview thumbnails | | `MIN_CONFIDENCE` | `0.7` | Minimum Immich face detection confidence | | `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` | *(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 @@ -232,7 +241,7 @@ In scheduled mode the process (and loaded models) stays resident between runs. T | Variable | Default | Description | | :--- | :--- | :--- | | `DRY_RUN` | `false` | Preview selection without downloading or uploading | -| `RETRY_REJECTED` | `false` | Re-attempt assets previously rejected by Frigate | +| `RETRY_REJECTED` | `false` | Re-attempt all previously rejected assets (low-confidence skips, Frigate rejections, and other permanent exclusions) | | `RESET_PERSON` | *(unset)* | Set to a person's name to clear their upload history and delete their winnow-managed Frigate training files so the next run starts fresh. Set to `*` to reset all tracked people at once. Manually added Frigate files are never touched | | `TRACE_CROP_SIZE` | *(unset)* | Debug: print all tracked crops whose width or height matches this pixel value, then exit | From d70a246a1c52c2c51c367bb5f32c3f931b535c06 Mon Sep 17 00:00:00 2001 From: Holden Date: Sun, 14 Jun 2026 16:56:24 +0000 Subject: [PATCH 7/8] docs: move FRIGATE_SCORE_CEILING to unsupported advanced tuning section --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ec9d066..290be65 100644 --- a/README.md +++ b/README.md @@ -203,15 +203,15 @@ In scheduled mode the process (and loaded models) stays resident between runs. T | :--- | :--- | :--- | | `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` | *(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 | -#### Image Processing *(calibrated — do not adjust)* +#### Advanced Tuning *(calibrated — do not adjust)* -These defaults are tuned for Frigate's ArcFace requirements. If you change them and run into image quality issues, support will not be provided. +These defaults are tuned for Frigate's ArcFace requirements. If you change them and run into issues, support will not be provided. | Variable | Default | Description | | :--- | :--- | :--- | +| `FRIGATE_SCORE_CEILING` | *(unset)* | Below-cap novelty gate. 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 | | `MIN_FACE_WIDTH` | `90` | Minimum face crop width in pixels | | `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 | From 9d5741f626c893f8dd6ab44731feb6802b67aef0 Mon Sep 17 00:00:00 2001 From: Holden Date: Sun, 14 Jun 2026 16:59:46 +0000 Subject: [PATCH 8/8] feat: warn on launch when unsupported advanced tuning vars are set; move ENABLE_FRIGATE_SCORES to unsupported section --- README.md | 4 ++-- winnow/cli.py | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 290be65..bb8b32f 100644 --- a/README.md +++ b/README.md @@ -203,14 +203,14 @@ In scheduled mode the process (and loaded models) stays resident between runs. T | :--- | :--- | :--- | | `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 | -| `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 | #### Advanced Tuning *(calibrated — do not adjust)* -These defaults are tuned for Frigate's ArcFace requirements. If you change them and run into issues, support will not be provided. +These defaults are tuned for Frigate's ArcFace requirements. winnow will warn on launch if any are set. Image quality issues caused by non-default values will not be investigated. | Variable | Default | Description | | :--- | :--- | :--- | +| `ENABLE_FRIGATE_SCORES` | `true` | Call Frigate's recognize endpoint pre-upload to store diversity scores used for quality replacement. Adds ~200 ms per upload. Disabling also disables the below-cap novelty gate | | `FRIGATE_SCORE_CEILING` | *(unset)* | Below-cap novelty gate. 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 | | `MIN_FACE_WIDTH` | `90` | Minimum face crop width in pixels | | `FACE_MARGIN` | `0.15` | Padding around bounding box crop (fraction of face size) | diff --git a/winnow/cli.py b/winnow/cli.py index 169b49f..a88ee4e 100644 --- a/winnow/cli.py +++ b/winnow/cli.py @@ -130,6 +130,18 @@ def _handle_duplicate_people(people: list[dict]) -> list[dict]: return people +_UNSUPPORTED_VARS = [ + "ENABLE_FRIGATE_SCORES", + "FRIGATE_SCORE_CEILING", + "MIN_FACE_WIDTH", + "FACE_MARGIN", + "ENABLE_FACE_ALIGNMENT", + "USE_FULL_RESOLUTION", + "MIN_CONFIDENCE", + "BLUR_THRESHOLD", +] + + def main() -> None: """Entry point for winnow CLI.""" try: @@ -145,6 +157,17 @@ def main() -> None: [dim]Immich -> Frigate Training Data Curator[/dim] """) + set_unsupported = [v for v in _UNSUPPORTED_VARS if os.environ.get(v)] + if set_unsupported: + console.print( + f"[bold yellow]⚠ Advanced tuning vars set: " + f"{', '.join(set_unsupported)}[/bold yellow]" + ) + console.print( + "[dim] These defaults are calibrated for Frigate's ArcFace requirements. " + "Image quality issues caused by non-default values will not be investigated.[/dim]\n" + ) + ConfigManager.get().interactive_setup() try: