Previously reset_person wiped the local tracker but left existing Frigate
training files as orphans, causing the next run to upload a full new batch
on top of them. Now deletes all winnow-managed files from Frigate first so
the next run starts truly clean. Manually-added Frigate files are never
touched.
Also fixes a spurious warning when FRIGATE_URL is unset: the deletion step
is now skipped at info level rather than logging a misleading error. Moves
the deferred import to top-level and eliminates a double disk read.
Bumps to 0.4.1. Also fixes ruff lint violations in executor.py (import
sort, line length) and promotes the "winnow only touches files it uploaded"
callout to the README intro.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- upload_tracker: extract _pick_mapped_file() private helper; get_lowest_quality_mapped_file
and get_most_redundant_mapped_file are now one-liners over the same body
- upload_tracker: remove_frigate_file now also prunes the corresponding frigate_scores entry,
preventing unbounded accumulation of orphaned score entries across replacement cycles
- frigate_api: get_frigate_face_counts delegates to get_all_frigate_person_files, eliminating
the duplicated "name != 'train' and isinstance(files, list)" filter body
- executor: cache has_frigate_scores(name) as person_has_fscores before the per-file loop;
refresh it after each remove_frigate_file call and after each scored upload, eliminating
two redundant disk reads per at-cap file iteration
- executor: casefold() both sides of the recognize_face person-name comparison so a Frigate
casing normalization or manual-registration casing mismatch does not silently suppress scoring
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Frigate pre-upload scoring, quality replacement inversion, bootstrap fix,
FRIGATE_SCORE_CEILING / ENABLE_FRIGATE_SCORES, removal of post-upload gate.
See CHANGELOG.md [0.4.0] for the full list.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
Fetch all Frigate training files once before the upload loop instead of once
per person — for N people this reduces GET /api/faces calls from N to 1.
Falls back to per-person calls if the pre-fetch fails.
Check InsightFace detection confidence immediately after face enrichment,
before fetching the full-resolution image. Assets that fail MIN_CONFIDENCE
are skipped without downloading, saving potentially large image downloads.
Collapse the gate removal tracker writes from 3×N file ops into 2 total
via remove_and_reclassify_batch: one write to the uploaded tracker (remove
file mappings + remove from flat set) and one write to the rejected tracker.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Dynamic floor now always active once Frigate scores exist — new images must
score at least as well as the weakest image already in the set, with no
config required. FRIGATE_SCORE_THRESHOLD adds an explicit absolute floor on
top. Gate active state is surfaced in normal output for both cases.
Quality replacement now pre-checks the gate threshold before deleting the
worst image. If the candidate would fail the gate, replacement is skipped
entirely rather than creating a net slot loss.
Gate-failed assets are reclassified as rejected (moved from uploaded_asset_ids
to rejected_asset_ids) so they are excluded from future runs without wasting
API calls on re-upload. RESET_PERSON still clears rejected records for a true
full reset. RETRY_REJECTED can recover them if the threshold is later lowered.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the dynamic threshold (min stored Frigate score) raises the effective
gate floor above the configured FRIGATE_SCORE_THRESHOLD, print it as a dim
info line rather than only logging at DEBUG/VERBOSE level.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- At upload start, diff tracker vs live Frigate file list and remove any
mappings for files no longer present; corrects effective_count so manually
deleted files don't permanently consume quota slots
- Add ENABLE_FRIGATE_SCORES config (default true); when false, skips all
recognize_face calls and falls back to blur scores for quality replacement
- Print a dim notice when FRIGATE_SCORE_THRESHOLD is set but pre_run_count
is zero, so users know the gate is deferred to the next run
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- FRIGATE_SCORE_THRESHOLD=0.0 now fully disables the quality gate including the
dynamic floor; a positive value is required to activate either
- Post-reconcile gate deletions are batched into one API call per person instead
of one call per file
- Per-person summary reports gate removals and net uploaded count when the gate
fires; grand summary includes total removed across all people
- .env.example comment updated to match the corrected opt-in behaviour
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
At the start of each person's upload phase, compute the minimum stored
Frigate recognition score across all currently mapped files. Use
max(config_threshold, dynamic_min) as the effective gate threshold so
new uploads must score at least as well as the weakest image already
in the training set.
Prevents overtraining well-recognised people: if all 80 images score
≥0.85, the dynamic threshold becomes ~0.85 and new additions that
score below that are removed rather than diluting a good training set.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When FRIGATE_SCORE_THRESHOLD > 0, images that score below the threshold
after upload are deleted from Frigate and removed from the tracker.
Skipped when pre_run_count == 0 (cold start — no class mean to compare
against yet). Deletion happens after reconciliation so the Frigate
filename is known. Disabled by default (0.0).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
Frigate classifies images with Laplacian variance < 120 as "very blurry"
and its own docs recommend avoiding blurry training data. Winnow was
accepting images in the 100-120 range that Frigate considers too blurry.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
50px crops produce ~2,500–4,225 total pixels — well below Frigate's own
camera capture range of 16k–50k px. 90px guarantees ≥8,100 total pixels
even when face margins are fully clipped by image edges.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
50px crops produce ~2,500–4,225 total pixels — well below Frigate's own
camera capture range of 16k–50k px. 90px guarantees ≥8,100 total pixels
even when face margins are fully clipped by image edges.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Store (width, height) of each face crop in the tracker at upload time
alongside the existing blur score. Expose TRACE_CROP_SIZE=<px> to look
up which Immich asset produced a crop with that pixel dimension, making
it straightforward to trace unexpected or low-quality images visible in
Frigate back to their source.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Was >=4.57.6; installed version is 5.12.0. Prevents users from
accidentally resolving the old 4.x series.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
setup-qemu-action v3 → v4, build-push-action v6 → v7
Required before June 16 when Node.js 20 actions are forced to Node.js 24
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- quality.py:122: split long tuple line to satisfy E501 (146 → ≤120)
- update-lockfile.yml: add branches filter so tag pushes don't trigger
the workflow (tag checkout is detached HEAD; git push has no target)
- release.yml: split four Docker build steps into parallel jobs (build-gpu,
build-cpu, build-rocm, build-intel), each with its own runner; previously
all four ran in one job and exhausted disk after GPU+CPU builds, leaving
ROCm and Intel cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
C1/C4: Cap blur-score computation at 1440 px before calling assess_quality
so scores are always on the same Laplacian scale as the embedding path
(which operates on Immich preview thumbnails). Also converts the image to
RGB before scoring and stores 0.0 on assess_quality failure so files
uploaded without a score remain eligible for future quality replacement
instead of occupying a slot permanently.
C2: Fall back to the tracker's mapped-filename set as the pre-upload
baseline when the Frigate GET /api/faces endpoint is unreachable at upload
start. Previously, uploads that succeeded during a partial API outage were
never mapped in frigate_files, leaving get_tracked_frigate_file_count
permanently under-counting those files and allowing Frigate to exceed
MAX_AUTO_IMAGES over time.
C3: Track min_quality_score_for_slot when a quality-replacement delete
succeeds but the subsequent upload fails. This ensures the freed slot can
only be filled by a candidate that beats the deleted file's score, not just
the next file in iteration order (which could be lower quality than what
was deleted).
Add get_tracked_frigate_filenames() to upload_tracker and expand tracker
tests to cover the new function and exclude-parameter behaviour.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Pipeline rewritten as 8 steps: separates thumbnail pass (quality filter +
embeddings) from full-res download and crop; adds quality replacement
logic at the upload step
- Persistence note updated to cover rejected IDs and RETRY_REJECTED
- Auto mode stopping threshold documented (20%/10% of median pairwise distance)
- Add OUTPUT_DIR env var (was undocumented)
- Local Install section notes interactive vs auto mode behaviour
- QUALITY_REPLACEMENT description tightened
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- update-lockfile.yml: rename workflow to "Update lockfiles"
- release.yml: add explicit uv python install 3.13 for consistency with other workflows
- docker-publish.yml: rename cpu cache scope from linux/amd64-cpu to cpu (now multi-arch)
- README: add GitHub release version badge and License badge
- README: shorten QUALITY_REPLACEMENT table cell
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- update-lockfile.yml: regenerate all four lockfiles (main + cpu/rocm/intel
variants) on any pyproject change; add variant pyproject files to trigger
- docker-publish.yml: add lockfiles to paths-ignore so the bot commit does
not trigger a second Docker build; remove redundant CONTRIBUTING/SECURITY
entries already covered by **.md; add QEMU to build-cpu; set CPU image to
linux/amd64,linux/arm64 to match release
- release.yml: inline lockfile generation now covers all variants; add
workflow_dispatch guard that fails if not dispatched from main
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Dockerfile: ARG VERSION + OCI labels (title, description, source, licenses, version)
- release.yml: pass VERSION build-arg to all four image builds
- docker-publish.yml: extend paths-ignore to cover community files
- .github/ISSUE_TEMPLATE/config.yml: disable blank issues, link to Discussions and wiki
- README.md: add Getting Help section
- pyproject.toml: expand description; add System Administrators audience classifier
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
C1/C4: Cap blur-score computation at 1440 px before calling assess_quality
so scores are always on the same Laplacian scale as the embedding path
(which operates on Immich preview thumbnails). Also converts the image to
RGB before scoring and stores 0.0 on assess_quality failure so files
uploaded without a score remain eligible for future quality replacement
instead of occupying a slot permanently.
C2: Fall back to the tracker's mapped-filename set as the pre-upload
baseline when the Frigate GET /api/faces endpoint is unreachable at upload
start. Previously, uploads that succeeded during a partial API outage were
never mapped in frigate_files, leaving get_tracked_frigate_file_count
permanently under-counting those files and allowing Frigate to exceed
MAX_AUTO_IMAGES over time.
C3: Track min_quality_score_for_slot when a quality-replacement delete
succeeds but the subsequent upload fails. This ensures the freed slot can
only be filled by a candidate that beats the deleted file's score, not just
the next file in iteration order (which could be lower quality than what
was deleted).
Add get_tracked_frigate_filenames() to upload_tracker and expand tracker
tests to cover the new function and exclude-parameter behaviour.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Pipeline rewritten as 8 steps: separates thumbnail pass (quality filter +
embeddings) from full-res download and crop; adds quality replacement
logic at the upload step
- Persistence note updated to cover rejected IDs and RETRY_REJECTED
- Auto mode stopping threshold documented (20%/10% of median pairwise distance)
- Add OUTPUT_DIR env var (was undocumented)
- Local Install section notes interactive vs auto mode behaviour
- QUALITY_REPLACEMENT description tightened
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- update-lockfile.yml: rename workflow to "Update lockfiles"
- release.yml: add explicit uv python install 3.13 for consistency with other workflows
- docker-publish.yml: rename cpu cache scope from linux/amd64-cpu to cpu (now multi-arch)
- README: add GitHub release version badge and License badge
- README: shorten QUALITY_REPLACEMENT table cell
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- update-lockfile.yml: regenerate all four lockfiles (main + cpu/rocm/intel
variants) on any pyproject change; add variant pyproject files to trigger
- docker-publish.yml: add lockfiles to paths-ignore so the bot commit does
not trigger a second Docker build; remove redundant CONTRIBUTING/SECURITY
entries already covered by **.md; add QEMU to build-cpu; set CPU image to
linux/amd64,linux/arm64 to match release
- release.yml: inline lockfile generation now covers all variants; add
workflow_dispatch guard that fails if not dispatched from main
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Dockerfile: ARG VERSION + OCI labels (title, description, source, licenses, version)
- release.yml: pass VERSION build-arg to all four image builds
- docker-publish.yml: extend paths-ignore to cover community files
- .github/ISSUE_TEMPLATE/config.yml: disable blank issues, link to Discussions and wiki
- README.md: add Getting Help section
- pyproject.toml: expand description; add System Administrators audience classifier
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously, effective_count and the jobs.py cap check used the total
Frigate file count (including manually-added files), so any file a user
curated by hand ate into winnow's managed quota. Now:
- get_tracked_frigate_file_count() returns len(frigate_files) from the
tracker — only files winnow uploaded and reconciled
- effective_count in the upload loop uses this tracker count so
manually-added files are invisible to the cap
- jobs.py capacity check uses len(frigate_files) instead of the live
Frigate API count or cached frigate_count
- Frigate API call for known_frigate_files_at_start is now only used
for the post-upload reconciliation diff, not for cap enforcement
Side-effect: fixes audit bug #1 — an unreachable Frigate GET no longer
zeroes effective_count and bypasses the cap, because the cap is now
read from the always-available local tracker.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds quality replacement for Frigate face training images:
- When a person is at MAX_AUTO_IMAGES cap, replace the lowest-quality
mapped Frigate file if a better candidate is available
- Quality score is laplacian blur variance from the quality filtering
pipeline (stored on asset, propagated through tracker)
- Frigate filename mapping uses post-person batch reconciliation:
poll after all uploads complete, map by filename timestamp order
(documented race condition limitation in code)
- QUALITY_REPLACEMENT env var (default true) to disable the feature
- Bug fix: f-string TypeError when no mapped files exist (worst=None)
- CI: more aggressive runner disk cleanup to fix NVIDIA build OOM
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Config tests now verify QUALITY_REPLACEMENT defaults to True and
respects the QUALITY_REPLACEMENT=false env override.
CI: replace minimal disk cleanup with more aggressive removal
(Android SDK ~14GB, Swift, CodeQL, docker system prune) so the
NVIDIA GPU image build no longer exhausts runner disk space.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When no mapped files exist for a person, worst is None and the old
f-string tried to subscript it before the conditional was evaluated.
Extracted worst_score_str as a local variable to avoid the crash.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Track laplacian blur score through quality filtering pipeline
(quality.py: blur_score on QualityResult; diversity.py: store on asset;
executor.py: read via quality_score key)
- Replace per-file polling with post-person batch reconciliation:
after all uploads for a person complete, poll Frigate (up to 15s)
until the expected number of new files appear, then map by filename
timestamp order (Frigate FIFO queue = upload order = timestamp order)
- Document race condition limitation: concurrent external uploads cause
the batch to be skipped entirely (safe but files go unmapped); noted
in code as requiring a Frigate API fix (return filename on upload)
- Add two assess_quality integration tests for blur_score
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Intel renamed libze-intel-gpu1 to level-zero in their graphics repository,
breaking the amd64 Intel GPU image build.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Intel renamed libze-intel-gpu1 to level-zero in their graphics repository,
breaking the amd64 Intel GPU image build.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Delete failure retry loop: when delete_frigate_person_files() fails,
remove the file from the tracker so the next candidate targets a
different worst file rather than re-attempting the same failed delete.
- Interactive mode quality replacement: _configure_person() never set
config["quality_replacement"], causing the executor to always default
to False and silently skip all uploads for at-cap interactive jobs.
Now mirrors auto_configure by reading Config.QUALITY_REPLACEMENT.
- Silent mapping loss on API flap: after a successful upload, if the
post-upload GET /api/faces returns None (transient API failure),
the file was silently left unmapped. Now logs a warning so users
know quality replacement won't target that file.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When a person is at MAX_AUTO_IMAGES, winnow now replaces the
lowest-quality mapped training image in Frigate if a higher-confidence
candidate is available, keeping the training set always optimised.
Only files winnow uploaded (tracked via frigate_files mapping) are ever
replaced — manually added Frigate training images are never touched.
A concurrent-upload race condition is detected per-file: if N>1 new
files appear after one upload, the mapping is skipped rather than
guessed, logging at INFO level. The per-file snapshot approach is
retained over a batch approach because wrong mappings (which a batch
approach risks on race) are worse than no mapping.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds ROCm (AMD GPU) and Intel GPU support as new :rocm and :intel image
variants. Full changelog in CHANGELOG.md under [0.2.12].
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New image variants:
- :rocm — InsightFace via ROCmExecutionProvider, SigLIP via PyTorch ROCm 6.3
- :intel — InsightFace via OpenVINOExecutionProvider (onnxruntime-openvino);
Intel GPU compute runtime auto-installed from Intel graphics repo;
OPENVINO_DEVICE=GPU opts into Arc/iGPU inference (default: CPU)
Also adds:
- pyproject-rocm.toml + uv-rocm.lock, pyproject-intel.toml + uv-intel.lock
- compose.yml device passthrough snippets for AMD and Intel
- CI: build-rocm and build-intel jobs in docker-publish.yml; all four
variants built and tagged in release.yml
- README reworked: cleaner structure, GPU variant quick-start examples,
OPENVINO_DEVICE env var documented
- CHANGELOG entry and version bump to 0.2.12
Fix: IntPrompt in dict literal was eagerly evaluated in the no-embedding
fallback path of _get_strategy_choice, prompting users for a custom count
regardless of which strategy they picked.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- fix: resolve onnxruntime/onnxruntime-gpu conflict clobbering GPU support
- perf: stream thumbnail download in bounded batches to cap peak RAM
- fix: log thumbnail fetch failures; restore get() for duplicate-ID safety
- ci: cancel in-progress Docker builds on superseding push
- ci: enforce GHCR package visibility public after each push
- docs: README and wiki updated with memory guidance and GPU troubleshooting
GHCR packages default private on first creation. Add a best-effort
gh api PATCH call at the end of both the multi-arch merge job and the
cpu build job so any new package version is immediately public without
requiring a manual UI step.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two small correctness fixes from post-commit code review:
- except Exception: continue swallowed network/auth errors silently; add
logger.debug so systematic failures are diagnosable in winnow.log
- batch_images.pop() regressed duplicate-asset-ID handling: if Immich
returns the same asset ID twice within the same 32-item batch window
(pagination edge case), the second occurrence got None and its embedding
was silently dropped. Switching back to .get() matches the old
thumbnail_map.get() behaviour. Peak memory is still bounded to _BATCH
images because batch_images goes out of scope between batches.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously all candidate thumbnails (up to 3000) were loaded into a
single dict before any processing started. At ~5 MB per decoded preview
image, 472 candidates = ~2.4 GB of thumbnail data alone, easily
exhausting a 4 GB container memory limit on CPU.
Now thumbnails are downloaded and processed in batches of 32. Each
image is pop()'d from the batch dict immediately after embedding so the
decoder memory is released before the next batch starts. Peak in-flight
thumbnail memory is now bounded to ~32 × 8 MB = ~256 MB regardless of
candidate pool size. GPU users benefit too — faster first results and
lower host RAM pressure during large runs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
insightface 1.0.1 added a hard dep on the CPU onnxruntime package.
Combined with an incorrect override-dependencies entry in 0.2.10 that
forced onnxruntime (no platform marker) unconditionally, both packages
were installed into the venv on x86_64 Linux — the CPU package landed
last and overwrote onnxruntime-gpu, removing CUDAExecutionProvider
from the provider list.
Fix: declare the two packages as conflicting in uv's resolver so only
the correct one is installed per environment.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- immich_api: `score or confidence` treated 0.0 score as falsy; use explicit None check
- diversity: same falsy-zero fix in _get_face_confidence
- diversity: _crop_face_from_thumbnail scale loop now filters by person_id (was
using first person's imageWidth/imageHeight regardless of target in group photos)
- jobs: partially-trained auto mode kept limit="auto" for adaptive stopping, then
caps result to remaining capacity (was converting to int, silently disabling FPS
adaptive threshold and early-stop)
- embeddings: _suppress_output finally block wraps first dup2 in try/finally so
stderr is always restored even if stdout restore raises OSError
- Dockerfile: ldconfig find uses python3.* glob instead of hardcoded python3.13
- scheduler: sleep until next_run instead of fixed 60s; eliminates late-fire jitter
and unnecessary wakeups on long schedules
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Frigate /api/faces response has person names as top-level keys with
lists of filenames — {person: [file, ...], "train": [...]}. The old
code incorrectly looked inside data["train"] as if it were a dict of
persons, causing 'list object has no attribute items' on every run.
Immich get_people() now checks for 401 before raise_for_status() and
logs a clear "API key invalid or expired" message instead of the raw
requests exception string.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- .env.example: add VERBOSE, remove active AUTO_MODE=true (now TTY-detected),
comment out FORCE_CPU/ENABLE_CACHE default values, update CRON_SCHEDULE
section to document all three modes
- README: clarify that log file is always DEBUG regardless of VERBOSE
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The log file already captures DEBUG unconditionally. This env var wires
the same to the Rich console handler for troubleshooting without needing
to read the log file.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
uv sync runs before winnow/ is COPY'd into the build stage, so the wheel
uv builds contains only dist-info (no Python files). The console_scripts
entry point sets sys.path[0] to its own directory (/app/.venv/bin), not
/app, so 'from winnow.cli import main' fails at container startup.
PYTHONPATH=/app makes the package importable regardless of how Python
is invoked (script, -m, entry point, docker exec). This is preferable
to re-ordering the COPY layers, which would bust the heavy uv sync cache
on every winnow/ source change.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bugs fixed (from high-effort review):
- InsightFace CPU fallback now passes providers=['CPUExecutionProvider'] and
wraps in _suppress_output() so broken GPU drivers don't cause fallback to
try the same broken provider again, and C-extension noise stays suppressed
- scheduler.py: BaseException → Exception (KeyboardInterrupt already re-raised;
winnow has no sys.exit() calls, so SystemExit would not occur, but Exception
is the correct scope)
- compose.yml: fix inverted AUTO_MODE comment (docker run -it enables
interactive mode via TTY, not non-interactive)
Model loading logging (embeddings.py):
- InsightFace: disk cache check, "not cached — downloading now (~300 MB)",
"loading into memory on GPU/CPU...", "ready on GPU/CPU (Xs)"
- SigLIP: same treatment; cache path derived dynamically from model_name
via HuggingFace slug convention (models--org--model) so it stays correct
if the model variant ever changes
Logging level audit (INFO/DEBUG/WARNING/ERROR):
- diversity.py: internal algo steps (clustering, medoids, adaptive threshold,
auto-stop decision) → DEBUG; final selection summaries stay INFO
- immich_api.py: "Fetching assets" and "Retained N assets" → DEBUG (callers
already print this to the console via rprint)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
embeddings.py: face embedding cache used 'immich' for lookup but
'insightface' for storage, so the cache was never hit for locally-
computed embeddings. Unified to 'insightface'/'siglip' throughout.
This affects all users since ENABLE_CACHE now defaults to true.
scheduler.py: INSIGHTFACE_HOME=/models/.insightface was having
'.insightface' appended again, making buffalo_l check always report
'will download'. Also catch BaseException (not just Exception) so a
SystemExit from a library call can't silently kill all future runs.
log_config.py: handlers.clear() abandoned open FileHandler fds on
each scheduled main() call. Close each handler properly before removal.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
RETRY_REJECTED was silently read from the environment in _configure_person,
bypassing user control in interactive sessions. Now prompts the user with
the env var value as the default, so the setting is visible and overridable.
All other env vars in the interactive path are already correct:
YEARS_FILTER is a prompt default, ONLY_PEOPLE/SKIP_PEOPLE/MIN_FACE_COUNT
are auto_configure-only, and TRAINING_MODE/STRATEGY/OBJECT_CLASS are
always prompted.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three container lifetime modes via CRON_SCHEDULE:
unset — run once on startup, exit
empty string — sleep infinity; use docker exec -it winnow winnow
cron expression — run on startup, then on schedule
This replaces the need for a separate MANUAL_MODE env var. The empty
string is a natural "I want the container alive but unscheduled" signal.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The primary use case is headless Docker, so auto mode is now the default
whenever stdin has no TTY. Interactive mode activates when a terminal is
present (docker run -it, local shell). AUTO_MODE=true remains as an
explicit override for scripting with a pseudo-TTY.
Removes AUTO_MODE=true, stdin_open, tty, and FORCE_CPU=false from
compose.yml — none are needed for headless operation. Updates README
and the interactive-mode hint in the CLI.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The static LD_LIBRARY_PATH only covered cudnn and cuda_runtime — missing
cublas, cufft, curand, cusolver, cusparse, nvjitlink, etc. onnxruntime-gpu
needs libcublasLt.so at minimum, so GPU mode silently fell back to CPU.
Replace with a one-shot ldconfig call over every nvidia site-packages lib/
dir, which covers all packages regardless of what gets installed.
Also: remove the ambiguous directory="" from preload_dlls (use auto-search
default) and add a clear warning when CUDAExecutionProvider is absent so
the user sees actionable guidance instead of silent CPU fallback.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduces a VARIANT=gpu|cpu build arg to the Dockerfile. The cpu
variant uses ubuntu:22.04 (no CUDA base), installs torch+cpu and
onnxruntime (no GPU deps) via a separate pyproject-cpu.toml / uv-cpu.lock,
and is published as :cpu (dev-cpu on the dev branch) via a new
build-cpu CI job. Saves ~2 GB over the default GPU image.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When stdin has no TTY (Docker without -it), IntPrompt/Confirm raise
EOFError and crash the container into a restart loop. Treat a non-TTY
stdin the same as AUTO_MODE=true so headless runs work without any env
var configuration.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Dockerfile: ENV INSIGHTFACE_HOME=/models → /models/.insightface to
match compose.yml and .env.example; the old value caused InsightFace
to store models at /models/models/buffalo_l (double-appended subdir)
- entrypoint.sh: use /app/.venv/bin/winnow (installed entry point)
instead of python -m winnow.cli
- config.py: ENABLE_CACHE default false → true; embedding cache is
always beneficial in practice; users can opt out with ENABLE_CACHE=false
- compose.yml: comment out CRON_SCHEDULE so scheduling is opt-in;
flip ENABLE_CACHE to commented opt-out to reflect new default
- README.md: update ENABLE_CACHE default documentation to true
- tests/test_config.py: update default assertion to match
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- __init__.py: derive __version__ from importlib.metadata instead of
a hardcoded "0.1.0" that was six releases out of date
- scheduler.py: move winnow.cli import to module top (no more noqa);
clean up redundant bool variables in check_models
- tests/test_quality.py: 17 tests covering all five quality check
functions individually plus assess_quality integration cases
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- winnow/logging.py → winnow/log_config.py: avoids shadowing the stdlib
logging module; log file renamed from immich_export.log to winnow.log
- scheduler.py: run main() in-process instead of subprocess.run so
InsightFace and SigLIP models stay resident in memory across scheduled
runs (hundreds of MB load, previously reloaded every run)
- .gitignore: replaced 200-line boilerplate with ~30 project-relevant
patterns; removed Django/Flask/Redis/RabbitMQ/Scrapy/etc. noise
- .python-version: untracked (redundant with requires-python in
pyproject.toml; kept in .gitignore for local pyenv users)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- embeddings: initialize ctx_id=-1 before try block so the except
handler cannot NameError; move insightface_home out of try for the
same reason
- embeddings: replace contextlib.redirect_stdout/stderr (Python-level
only) with fd-level dup2 suppression — actually silences C extension
noise from InsightFace during model loading
- jobs: fix frigate_count==0 falling through `or` chain; use explicit
`is not None` check so a real zero is not treated as missing data
- diversity: thread person_id through select_diverse_assets →
_select_by_embedding → _get_face_bbox / _get_face_confidence /
_crop_face_from_thumbnail so group-photo assets embed the target
person's face rather than whichever person is listed first
- config: replace type() hack for ConfigManager with a proper class
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
onnxruntime-gpu has no arm64 wheels (manylinux_2_27_x86_64 /
manylinux_2_28_x86_64 only). uv sync --frozen failed on the arm64
image with exit code 2. Gated onnxruntime-gpu behind the x86_64
marker; arm64 and non-Linux use the CPU onnxruntime package. Added
required-environments so the lockfile is solved for both platforms.
Removed onnxruntime-gpu from override-dependencies (it had no marker
support and blocked arm64 resolution).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
curl|gpg --dearmor was silently dropping the deadsnakes key (gpg exits 0
on bad input), leaving apt unable to find python3.13. Reverted to
add-apt-repository with GNUPGHOME=$(mktemp -d) to isolate gpg from any
pre-existing agent socket. Removed --platform=\$BUILDPLATFORM from the
arm64 base so the image contains real arm64 binaries. License updated
from MIT to AGPL-3.0-or-later.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Title first, then badges on one consistent line. Removed redundant
Release and Lint badges. Fixed docs links to point to the wiki.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Ubuntu 26.04 ships Python 3.14, not 3.13. Reverted arm64 to ubuntu:24.04.
Both architectures now add the deadsnakes PPA by fetching the GPG key via
curl and piping through gpg --dearmor — no gpg-agent, safe under QEMU.
Removes the per-arch conditional and ARG TARGETARCH dependency in RUN commands.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>