Files
winnow/CHANGELOG.md
T

365 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.4.4] - 2026-06-14
### Added
- **`RESET_PERSON=*` bulk reset**: resets every tracked person at once (deletes their Frigate training files and clears tracker data). Any other value still resets that specific person by name. If a person is literally named `*` they are reset as part of the bulk operation, and a warning is printed to clarify this.
- **Near-duplicate removal before diversity selection**: a greedy dedup pass now runs after embedding collection and before clustering. Candidates within 0.20 cosine distance of a higher-quality image are dropped, eliminating burst shots and same-event lookalike photos that produce redundant training images. The best-quality frame from each near-identical group is kept. Dropped count is logged per person.
### Fixed
- **HTTP 500 upload errors no longer show Frigate's misleading "Try restarting Frigate" message**: the response body is now logged at debug level only. HTTP 400 detail (e.g. "No face was detected") is still shown since it is actionable.
- **`RuntimeWarning: Mean of empty slice`** when a person has only one image after quality filtering: `_compute_adaptive_threshold` now returns the floor value immediately when there are no pairwise distances to sample, and the k-medoids cluster count is floored at 1 to prevent `k=0`.
- **Path traversal guard on output directory**: person names with `../` sequences or absolute paths (e.g. `/etc`) are now rejected before any filesystem operation, logging an error and skipping the job rather than writing outside the output tree.
## [0.4.3] - 2026-06-14
### Added
- **InsightFace landmark-based face crop alignment**: face crops for Frigate training are now aligned using InsightFace's `norm_crop` (ArcFace 112×112 alignment with 5-point facial landmarks). Previously, Immich's API returned only bounding boxes with no landmarks, so `align_face()` was dead code and crops were plain bbox slices — resulting in misaligned or partial crops (e.g. foreheads). The fix runs InsightFace detection on an expanded region around the Immich bbox, finds the nearest face, and uses its keypoints for proper alignment. Controlled by `ENABLE_FACE_ALIGNMENT` (default `true`).
- **Duplicate Immich person detection and handling**: when multiple Immich person records share the same name, winnow now detects this at startup and warns with a per-group summary. Without handling, two jobs would run for the same Frigate folder and overwrite each other's output. By default (`MERGE_DUPLICATE_PEOPLE=false`) only the first person per name is processed. Set `MERGE_DUPLICATE_PEOPLE=true` to permanently merge duplicate records inside Immich (keeps the person with the most assets).
## [0.4.2] - 2026-06-13
### Changed
- **GPU image now uses CUDA 12.8.1** (was 13.3): CUDA 13.3 requires driver ≥ 575; driver 570 (the current stable release) was incorrectly rejected with "CUDA driver version is insufficient" at startup. The `:latest` image now works with any NVIDIA driver ≥ 570.
### Added
- **`scripts/benchmark.py`**: measures InsightFace and SigLIP inference latency and throughput across GPU and CPU modes. Run inside the container with `python /app/scripts/benchmark.py`. RTX 2070 SUPER results: InsightFace 12.8 ms / 78 img/s (8× CPU), SigLIP batch 32 at 5.4 ms/img / 187 img/s (33× CPU).
## [0.4.1] - 2026-06-13
### Fixed
- **`RESET_PERSON` no longer creates duplicate Frigate files**: previously, resetting a person only wiped the local tracker — existing Frigate training files were left as unmanaged orphans, causing the next run to upload a full new batch on top of them. `reset_person` now deletes all winnow-managed files for that person from Frigate before clearing the tracker. Manually-added Frigate files are unaffected.
- **No spurious warning when `FRIGATE_URL` is unset and `RESET_PERSON` is used**: the deletion step is now skipped silently at info level rather than logging a misleading "could not delete" warning.
## [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
### Fixed
- **`MIN_FACE_WIDTH` default raised from 50 → 90px**: 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, keeping winnow training crops above the floor Frigate considers useful.
## [0.3.2] - 2026-06-13
### Added
- **Crop dimension tracing**: winnow now records the pixel dimensions (width × height) of each face crop at upload time in the tracker (`crop_dims` field). Run `TRACE_CROP_SIZE=3848 winnow` to look up which Immich asset produced a crop with that pixel dimension — output includes person name, asset ID, Immich URL, blur score, and the Frigate filename. Useful for tracing low-quality or unexpected images visible in Frigate back to their source.
## [0.3.1] - 2026-06-13
### Fixed
- **Lint**: split overly long line in `quality.py` (`E501`, 146 → ≤120 chars).
- **CI — lockfile update workflow**: added `branches: ['**']` filter to `on.push` so tag pushes no longer trigger the job; tag checkouts land in detached HEAD and the subsequent `git push` had no branch target.
- **CI — release workflow**: split four Docker image builds into parallel jobs (`build-gpu`, `build-cpu`, `build-rocm`, `build-intel`), each with its own runner. Previously all four ran in a single job; building the GPU and CPU multi-platform images exhausted disk, causing ROCm and Intel builds to be cancelled.
## [0.3.0] - 2026-06-13
### Added
- **`get_tracked_frigate_filenames()`** — new upload-tracker function that returns the set of Frigate filenames currently mapped for a person. Used internally as a reconciliation baseline when the Frigate GET endpoint is unreachable; also available to callers that need the mapped filename set without a count.
- **Community scaffolding**: `CONTRIBUTING.md`, `SECURITY.md`, GitHub issue templates (bug report, feature request), and pull request template.
- **OCI image labels**: `org.opencontainers.image.*` labels added to the runtime stage of the Dockerfile so image metadata is surfaced by container registries.
- **Additional tracker tests**: coverage added for `get_tracked_frigate_filenames` and for `get_lowest_quality_mapped_file` with the `exclude` parameter.
### Fixed
- **Quality score scale mismatch (USE_FULL_RESOLUTION=true)**: the time-spread quality-score fallback called `assess_quality` on the full-resolution download, while the embedding path always scores on preview thumbnails. Laplacian variance scales with image resolution, so the two paths produced incomparable scores for people with mixed-mode files. The fallback now caps the image at 1440 px before scoring to match the thumbnail scale.
- **assess_quality failure left file permanently unreplaceable**: if `assess_quality` raised an exception (e.g. an RGBA image with an unsupported channel count), `score_map` kept `None` and `mark_uploaded(score=None)` skipped writing the score. The uploaded file was then permanently invisible to `get_lowest_quality_mapped_file` because it had no entry in `scores{}`. The fallback now converts the image to RGB before scoring and stores `0.0` on any exception, so every uploaded file is eligible for future quality replacement.
- **Uploads during Frigate GET outage never mapped**: when `GET /api/faces` failed at upload start, the reconciliation guard (`_snapshot is not None`) correctly skipped the post-upload diff — but uploads that succeeded during the outage were never recorded in `frigate_files`, causing `get_tracked_frigate_file_count` to permanently under-report and Frigate to eventually exceed `MAX_AUTO_IMAGES`. The code now uses the tracker's mapped filenames as a pre-upload baseline when the live snapshot is unavailable, so reconciliation proceeds normally (the `>target` guard handles concurrent external uploads as before).
- **Freed quality-replacement slot could be filled by a worse image**: when a replacement delete succeeded but the subsequent upload failed all retries, `effective_count` stayed decremented and the next file in the iteration uploaded unconditionally — it could have a lower quality score than the file that was deleted. A `min_quality_score_for_slot` variable now records the deleted file's score on a successful delete; any candidate that doesn't beat that floor is skipped until the slot is filled by a qualifying image or the run ends.
- **CI multi-arch and lockfile bot**: hardened the build workflow — lockfile-update bot no longer races against Docker publish on the same push event; CPU image now builds for both `linux/amd64` and `linux/arm64`; `paths-ignore` prevents documentation-only pushes from triggering image builds.
- **README pipeline diagram updated**: step 8 now explicitly documents the quality-replacement decision tree (`below cap → upload`, `at cap + enabled → swap if better`, `at cap + disabled → skip`).
## [0.2.13] - 2026-06-13
### Added
- **Quality replacement**: when a person is at `MAX_AUTO_IMAGES`, winnow now checks each new candidate against the lowest-quality image already in Frigate and swaps it in if the new image scores higher. Only images winnow uploaded (tracked in `frigate_files`) are ever replaced — files added manually through Frigate's UI are left untouched permanently. Enabled by default; set `QUALITY_REPLACEMENT=false` to revert to the previous behaviour of skipping people at cap.
- **Frigate filename mapping**: each successful upload now records the mapping from Frigate's assigned filename to the originating Immich asset ID and face confidence score in the tracker (`frigate_files` field). This is the foundation for quality replacement and future management of the Frigate training set.
- **`QUALITY_REPLACEMENT` env var** (default `true`): controls whether at-cap people are eligible for quality replacement. When disabled, people at `MAX_AUTO_IMAGES` are skipped as before.
- **NOTICES file**: third-party attribution for if_curator (MIT, Copyright © 2026 Sebastian) added to satisfy upstream license requirements.
## [0.2.12] - 2026-06-13
### Added
- **ROCm (AMD GPU) support**: new `:rocm` image tag. InsightFace runs via `ROCmExecutionProvider`; SigLIP runs via PyTorch ROCm 6.3 (ROCm builds expose `torch.cuda.is_available() == True`, so the existing CUDA path is reused automatically). Requires `/dev/kfd` and `/dev/dri` device passthrough plus `video` and `render` group membership — see `compose.yml` for the snippet.
- **Intel GPU support**: new `:intel` image tag. InsightFace runs via `OpenVINOExecutionProvider` from `onnxruntime-openvino`. By default OpenVINO targets CPU (no device passthrough needed); set `OPENVINO_DEVICE=GPU` to target Intel Arc discrete or integrated graphics. Intel's GPU compute runtime (Level Zero + OpenCL ICD) is installed automatically from Intel's official graphics repo in the image — no manual package installation required. SigLIP uses CPU inference for now (Intel Extension for PyTorch has no Python 3.13 wheels yet; the `torch.xpu` path is wired and will activate automatically when they ship).
- **`OPENVINO_DEVICE` env var**: controls the OpenVINO execution provider device for the `:intel` variant. `CPU` (default) requires no device passthrough. `GPU` targets Intel Arc discrete and integrated graphics via Level Zero.
- **AMD and Intel device passthrough snippets in `compose.yml`**: documented as commented-out alternatives to the NVIDIA `deploy:` block.
- **`:rocm` and `:intel` CI jobs**: `docker-publish.yml` now builds and pushes `:rocm` / `:dev-rocm` and `:intel` / `:dev-intel` alongside `:latest` and `:cpu`. `release.yml` builds all four variants on tag push.
### Fixed
- **Interactive custom-count prompt firing for all choices**: in the no-embedding fallback path of the strategy selector, `IntPrompt.ask` was inside a dict literal and evaluated eagerly — users selecting Standard (30) or Broad (100) were still prompted to enter a custom image count. Each choice is now handled in a dedicated branch.
## [0.2.11] - 2026-06-12
### Fixed
- **GPU broken on x86_64 Linux**: `insightface` 1.0.1 (pulled in by the 0.2.10 lock update) added a hard dependency on the CPU `onnxruntime` package. Combined with an incorrect `override-dependencies` entry introduced in 0.2.10, both `onnxruntime` (CPU) and `onnxruntime-gpu` were being installed into the same venv. The CPU package landed last and overwrote the GPU one, causing `CUDAExecutionProvider` to disappear from the provider list even when a GPU was present. Fixed by declaring `onnxruntime` and `onnxruntime-gpu` as conflicting packages in uv's resolver, ensuring only the correct one is installed per platform.
- **OOM crash on large person libraries (CPU mode)**: All candidate thumbnails were downloaded into a single in-memory dict before any processing began. At ~5 MB per decoded preview image, a person with 472 candidates would accumulate ~2.4 GB of thumbnail data alone, exhausting a 4 GB container memory limit. Thumbnails are now downloaded and processed in batches of 32, with each image released immediately after embedding. Peak in-flight thumbnail memory is now bounded to ~256 MB regardless of candidate pool size. GPU users also benefit from lower host RAM pressure and faster time-to-first-result on large libraries.
## [0.2.10] - 2026-06-12
### Added
- **`VERBOSE` env var**: set `VERBOSE=true` to enable DEBUG-level console output. The log file always captures DEBUG; this flag controls what appears on the terminal. Useful when diagnosing issues without a full shell into the container.
- **`:cpu` Docker image tag**: a separate CPU-only image (`ghcr.io/sudolulo/winnow:cpu`) is now built and pushed alongside `:latest`. Uses `onnxruntime` instead of `onnxruntime-gpu`; ~2 GB smaller. Suitable for systems without an NVIDIA GPU.
- **Empty `CRON_SCHEDULE` keeps container alive**: setting `CRON_SCHEDULE=` (empty string) starts the container without running immediately and without exiting — useful for `docker exec` ad-hoc runs on a long-lived container. Previously, an empty value was treated the same as unset (run once, then exit).
### Changed
- **TTY auto-detection replaces `AUTO_MODE`**: winnow now detects whether a TTY is attached (`sys.stdin.isatty()`) and switches between interactive and auto mode automatically. `AUTO_MODE=true` becomes an explicit override for forcing auto mode in a terminal session. No config change needed for normal Docker deployments.
- **Logging levels audited**: internal algorithmic detail (clustering steps, asset fetch progress, per-page pagination) demoted from INFO to DEBUG. INFO now reflects meaningful pipeline milestones only (model ready, selection complete, quality filtered). Reduces noise in production logs without losing information.
- **Model load logging improved**: SigLIP and InsightFace loading now reports cache hit/miss, download size estimate, device used, and load time.
### Fixed
- **GPU OOM crash loop (production)**: `CUDAExecutionProvider` was silently absent even with a GPU attached, causing InsightFace to run on CPU and exhaust RAM processing large person libraries. Root cause: CUDA/cuDNN libraries in nvidia pip packages were invisible to onnxruntime. Fixed by running `ldconfig` over all `nvidia-*/lib/` directories in the venv at image build time.
- **ldconfig path now Python-version-agnostic**: the `find` command used to register nvidia pip libraries hardcoded `python3.13`; replaced with `python3.*` glob so the path survives a Python upgrade without silently producing an empty ldconfig config.
- **`PYTHONPATH=/app` added to Dockerfile**: the entry point script sets `sys.path[0]` to the script directory, not `/app`. Since `uv sync` runs before `COPY winnow/`, the wheel has only dist-info in site-packages. `PYTHONPATH=/app` makes the `winnow` package importable without reverting to `python -m`.
- **CPU fallback retrying broken GPU provider**: InsightFace CPU fallback omitted `providers=["CPUExecutionProvider"]`, causing onnxruntime to retry `CUDAExecutionProvider` on every inference call. Now explicitly sets the CPU provider and suppresses C-extension noise via fd-level redirect.
- **`_suppress_output` stderr loss on fd exhaustion**: if the first `os.dup2` in the finally block raised `OSError`, the second call was skipped, permanently redirecting stderr to `/dev/null` for the process lifetime. Wrapped in nested `try/finally` so both restores are always attempted.
- **Frigate `/api/faces` response parsing**: the response is `{person_name: [files], "train": [...]}` — `"train"` is a flat pending list, not a person. Previous code called `.items()` on the `"train"` value (a list), crashing with `AttributeError`. Now skips the `"train"` key explicitly.
- **Immich 401 detection**: a stale or invalid API key now logs a clear error message (`Immich API key is invalid or expired (401 Unauthorized)`) instead of raising an unhandled exception.
- **Falsy-zero detection confidence**: `face.get("score") or face.get("confidence")` treated a valid `score=0.0` as falsy, falling through to the `confidence` field (often `None`). Replaced with an explicit `None` check. Affected both quality filtering and hard-example weighting in diversity selection.
- **Face crop using wrong person's image dimensions**: in multi-person assets, `_crop_face_from_thumbnail`'s scale-factor loop matched the first person with any face regardless of `person_id`, producing incorrectly scaled bounding box coordinates for the target person. Loop now applies the same `person_id` filter as `_get_face_bbox`.
- **Adaptive stopping bypassed for partially-trained people**: in auto mode with `already_uploaded > 0`, `limit` was converted from `"auto"` to an integer, disabling the FPS adaptive threshold and early-stop check. Now keeps `limit="auto"` through selection and trims the result to the remaining capacity afterward.
- **Embedding cache key mismatch**: HuggingFace cache path check hardcoded the model slug string; replaced with a derivation from `model_name` using `"models--" + model_name.replace("/", "--")` so the check stays correct if the model name changes.
- **Scheduler: sleep until next run**: the loop slept a fixed 60 seconds regardless of schedule interval, causing runs to fire up to 59 seconds late and waking the process unnecessarily on long schedules (e.g. weekly). Now sleeps exactly until `next_run`.
- **Scheduler swallowing `SystemExit`**: `except BaseException` in the run wrapper was replaced with `except Exception` (with `KeyboardInterrupt` re-raised above), so `sys.exit()` calls propagate correctly.
- **Log handler leak**: `setup_logging` now closes and removes existing handlers before adding new ones, preventing file handle accumulation across repeated calls.
- **`RETRY_REJECTED` silently applied in interactive mode**: the env var was applied unconditionally even in interactive sessions. Now used only as the default for the interactive prompt so users can override it per-run.
- **`compose.yml` comment inverted**: a comment stated `-it` forces non-interactive mode; corrected to reflect that `-it` allocates a TTY (interactive mode).
### Security
- API key is no longer stored in any config file. All authentication uses environment variables or `.env` only.
## [0.2.9] - 2026-06-12
### Fixed
- **arm64: `onnxruntime-gpu` has no arm64 wheels**: `onnxruntime-gpu` only publishes `manylinux_2_27_x86_64` and `manylinux_2_28_x86_64` wheels — `uv sync` on arm64 failed with exit code 2. Gated `onnxruntime-gpu` behind `sys_platform == 'linux' and platform_machine == 'x86_64'`; arm64 and non-Linux installs now get the CPU `onnxruntime` package instead.
- **uv lockfile now covers arm64**: Added `required-environments` to `[tool.uv]` so the lockfile is solved for both `linux/x86_64` and `linux/aarch64`, preventing silent resolution gaps for the non-build platform.
## [0.2.8] - 2026-06-12
### Fixed
- **Dockerfile: use `add-apt-repository` with isolated GNUPGHOME**: the `curl | gpg --dearmor` approach was silently failing — gpg exits 0 on bad/empty input, leaving an invalid keyring and causing apt to skip the deadsnakes PPA entirely. Reverted to `add-apt-repository ppa:deadsnakes/ppa` with `GNUPGHOME=$(mktemp -d)` to prevent gpg from touching any pre-existing agent socket (the original QEMU crash cause).
- **arm64 base: removed `--platform=$BUILDPLATFORM`**: the Ubuntu 24.04 base for arm64 is now pulled for the target platform, so the arm64 image contains real arm64 binaries rather than amd64 binaries in an arm64 manifest.
### Changed
- **License changed from MIT to AGPLv3+**: source and network use of winnow now require derivative works to be open-sourced under the same terms.
## [0.2.7] - 2026-06-12
### Fixed
- **arm64 base reverted to Ubuntu 24.04**: Ubuntu 26.04 ships Python 3.14, not 3.13 — `python3.13` was not locatable in its repos. Reverted arm64 base to `ubuntu:24.04`.
- **Deadsnakes PPA now uses curl+gpg instead of `add-apt-repository`**: `add-apt-repository` spawns a gpg-agent which crashes under QEMU (arm64 CI). The PPA is now added by fetching the key via `curl` and piping through `gpg --dearmor` — no agent, works on both architectures.
- **PPA conditional removed**: both amd64 (Ubuntu 22.04 CUDA) and arm64 (Ubuntu 24.04) now go through the same deadsnakes install path, eliminating the per-arch branching and the `ARG TARGETARCH` dependency in RUN commands.
## [0.2.6] - 2026-06-12
### Fixed
- **Dockerfile: `ARG TARGETARCH` re-declared in each stage**: Docker's automatic platform ARGs are only in scope for `FROM` instructions, not `RUN` commands. The `$TARGETARCH` variable in the deadsnakes PPA conditional was silently empty, so the PPA was never added and `python3.13` could not be located on the Ubuntu 22.04 CUDA base. Adding `ARG TARGETARCH` at the top of both the `build` and `runtime` stage bodies fixes the amd64 build.
## [0.2.5] - 2026-06-12
### Changed
- **CUDA base upgraded to 13.3.0**: amd64 base image bumped from `nvidia/cuda:12.9.2-cudnn-runtime-ubuntu22.04` to `nvidia/cuda:13.3.0-cudnn-runtime-ubuntu22.04`. Python packages (torch, onnxruntime-gpu) still target CUDA 12.6 via pip-installed libraries; the base image upgrade requires a host GPU driver that supports CUDA 13+.
- **`torchvision>=0.27.0` added as explicit dependency**: routed through the `pytorch-cu126` index for linux/x86_64, ensuring it resolves to `0.27.0+cu126` (paired with `torch 2.12.0+cu126`) rather than being pulled from PyPI where the older `0.21.0` wheel would downgrade torch to 2.6.0.
- **`torch>=2.12.0`** floor raised from 2.6.0 to prevent silent downgrade.
- **`nvidia-cudnn-cu12>=9.0.0`** floor kept intentionally loose — torch pins an exact cuDNN ABI version (`9.10.2.21`) and must own that constraint.
## [0.2.4] - 2026-06-12
### Changed
- **Python 3.13 across all platforms**: bumped from 3.12 to 3.13. amd64 installs Python 3.13 from the deadsnakes PPA on the Ubuntu 22.04 CUDA base; arm64 uses the Python 3.13 package available natively in Ubuntu 26.04. All confirmed dependencies (`insightface 1.0.1`, `onnxruntime-gpu 1.26.0`, `torch 2.12.0+cu126`) have Python 3.13 wheels.
- **arm64 base: Ubuntu 26.04**: Python 3.12 was removed from Ubuntu 26.04's default repos; upgrading the base pulls Python 3.13 without a PPA.
- **`requires-python = ">=3.13"`** and `target-version = "py313"` updated in `pyproject.toml`.
- **CI updated to Python 3.13**: `test.yml` and `update-lockfile.yml` now install Python 3.13.
- **`uv.lock` regenerated** for Python 3.13.5.
## [0.2.3] - 2026-06-12
### Fixed
- **Clear-text API key storage**: `config.py` no longer writes `API_KEY` to `.immich_config.json`. The key must come from an environment variable or `.env` file. Interactive mode now prints a tip directing users to `.env`. Resolves CodeQL `py/clear-text-storage-sensitive-data`.
### Changed
- **CI workflow permissions**: `test.yml` and `lint.yml` now declare `permissions: contents: read`, following least-privilege principle and resolving `actions/missing-workflow-permissions` scanner alerts.
- **CI lockfile race condition**: removed the `verify-lockfile` pre-job from `docker-publish.yml` and `release.yml`. The `update-lockfile.yml` bot maintains the lockfile; the verify step raced against it on the same push event and caused false failures. `release.yml` now runs `uv lock` inline so tag-triggered builds are always self-consistent.
- **Docs moved to wiki**: `docs/` folder removed from the repository. Setup, Troubleshooting, and FAQ pages are now at the [GitHub wiki](https://github.com/sudolulo/winnow/wiki).
## [0.2.2] - 2026-06-12
### Added
- **Confidence scores in upload tracker**: Immich face confidence scores are now stored per asset in `frigate_uploaded_ids.json` under `by_person[name].scores`. Lays the groundwork for future replacement logic (remove low-confidence uploads when better images are found).
- **Frigate-authoritative capacity tracking**: At startup, `GET /api/faces` is queried on the Frigate host to retrieve the actual number of trained images per person from the `train` directory (pending/unclassified queue is excluded). This count is stored as `frigate_count` in the tracker JSON so it survives Frigate downtime.
- **Lifetime cap uses Frigate count**: `MAX_AUTO_IMAGES` is now enforced against Frigate's live training image count rather than the local uploaded-asset tally. Fallback priority: live Frigate API → last cached `frigate_count` in JSON → local uploaded count.
- **Startup summary shows Frigate count**: Tracker summary at startup now includes the last known Frigate training count per person (e.g. `78 uploaded, 2 rejected, 42 in Frigate`).
- **`winnow/frigate_api.py`**: new module encapsulating Frigate API helpers; currently exposes `get_frigate_face_counts()`.
### Changed
- `upload_tracker.py`: `by_person` entries migrated from flat list to `{asset_ids, scores, frigate_count}` dict. Old list format is read and migrated transparently on first write.
- `mark_uploaded()` now accepts an optional `score` keyword argument.
- `get_person_summary()` now returns `frigate_count` and `scores` fields alongside `uploaded` and `rejected`.
## [0.2.1] - 2026-06-12
### Fixed
- **Container startup reinstalling packages**: `entrypoint.sh` used `uv run`, which performs a sync check on every startup and re-downloaded `ruff` and rebuilt the package each time. Replaced with direct `.venv/bin/python` calls to skip the sync entirely.
- **InsightFace double `models/` path**: `INSIGHTFACE_HOME=/models` caused InsightFace to download Buffalo_L to `/models/models/buffalo_l` (InsightFace always appends `models/` to the root). Updated default in `compose.yml` and `.env.example` to `/models/.insightface`.
- **Lint errors in CI**: unused imports in `tests/test_config.py` and `tests/test_upload_tracker.py`, unsorted imports in `scheduler.py` — all would have failed the ruff CI check.
## [0.2.0] - 2026-06-12
First release of winnow. Forked from [if-curator](https://github.com/ds-sebastian/if_curator) by Sebastian and rewritten for headless Docker deployment.
### Added
**Headless operation**
- `AUTO_MODE` env var — runs without any interactive prompts; required for Docker/cron use
- `DRY_RUN` env var — previews selection without downloading, cropping, or uploading anything
- `RETRY_REJECTED` env var — re-attempts assets previously rejected by Frigate's face API
- `RESET_PERSON` env var — clears upload and rejection history for one named person
**Docker and scheduling**
- `Dockerfile` — multi-stage build (CUDA 12.9 on amd64, plain Ubuntu on arm64); runtime stage excludes build tools (g++, python3.12-dev, curl, gnupg)
- `compose.yml` — fully annotated with inline comments grouped by concern
- `entrypoint.sh` — runs the tool once on startup, then hands off to the scheduler if `CRON_SCHEDULE` is set
- `scheduler.py` — in-process cron scheduler that keeps the container (and loaded models) alive between runs
- `CRON_SCHEDULE` env var — standard cron expression for recurring runs; unset exits after first run
- `.dockerignore` — keeps `.venv`, `__pycache__`, test files, and logs out of the image context
- Multi-arch image: `linux/amd64` and `linux/arm64` built and merged into a single manifest on GHCR
- `tini` as PID 1 init process for correct signal handling
- Non-root container user (`appuser`, uid 568)
- `HEALTHCHECK` in Dockerfile
**Object mode**
- `TRAINING_MODE=object` — runs YOLOv9c detection on full images, crops each detected instance of a target class, and saves crops to the output volume (Frigate has no training API for objects — crops are placed manually)
- `OBJECT_CLASS` env var — target YOLO class label (e.g. `dog`, `cat`, `car`); defaults to `dog`
**People filtering**
- `ONLY_PEOPLE` env var — comma-separated whitelist; only these people are processed
- `SKIP_PEOPLE` env var — comma-separated list; these people are skipped
- `MIN_FACE_COUNT` env var — skip people with fewer than N tagged assets in Immich
- `YEARS_FILTER` env var — ignore assets older than N years (default: 10)
**Image quality controls** (previously hardcoded)
- `BLUR_THRESHOLD` env var — Laplacian variance threshold for blur rejection
- `MIN_CONFIDENCE` env var — minimum Immich face detection confidence
- `MAX_AUTO_IMAGES` env var — hard cap on auto-diversity selection
- `FACE_MARGIN` env var — padding around bounding box crops as a fraction of face size
- `USE_FULL_RESOLUTION` env var — download full-res originals vs preview thumbnails
- `ENABLE_FACE_ALIGNMENT` env var — align to ArcFace 112×112 format via InsightFace landmarks
**GPU and model configuration**
- `FORCE_CPU` env var — disable GPU; fall back to CPU for embedding computation
- `INSIGHTFACE_HOME` env var — controls model persistence for Buffalo_L
- `HF_HOME` env var — HuggingFace model cache path for SigLIP
- `LD_LIBRARY_PATH` set in the image to expose CUDA and cuDNN pip libraries so `onnxruntime-gpu` can find them at runtime
**Caching and upload tracking**
- `ENABLE_CACHE` / `CACHE_DIR` env vars — opt-in embedding cache to skip recomputation on reruns
- Per-person upload tracker persisted as JSON; prevents the same asset from being uploaded twice across runs weeks apart, even if the container is recreated
- Startup summary showing uploaded and rejected counts per person
- `LIMIT` env var — exact image count overriding `STRATEGY` preset
**CI/CD**
- `docker-publish.yml` — builds multi-arch image and pushes to GHCR on push to `main` (`:latest`) or `dev` (`:dev`)
- `release.yml` — triggered by `v*` tags or `workflow_dispatch`; creates a GitHub Release, extracts changelog notes, builds and pushes versioned image to GHCR
- `lint.yml` — runs Ruff on push/PR to `main` and `dev`
- `test.yml` — runs pytest on push/PR to `main` and `dev`
- `update-lockfile.yml` — regenerates `uv.lock` and commits it when `pyproject.toml` changes
- Dependabot: weekly grouped PRs for Python dependencies (uv ecosystem) and GitHub Actions versions
**Testing**
- 24 unit tests across four modules: `test_config`, `test_immich_api`, `test_jobs`, `test_upload_tracker`
**Documentation**
- `docs/setup.md` — step-by-step install and GPU passthrough guide
- `docs/troubleshooting.md` — common failure modes with fixes
- `docs/faq.md` — answers to questions new users will ask
- `.env.example` — copy-paste starting point with every env var and inline comments
- README rewritten: pipeline diagram, env var reference tables, scheduling behaviour, requirements
### Changed
- `cli.py` split into three focused modules — `cli.py` (entry point), `jobs.py` (configuration and strategy resolution), `executor.py` (download, crop, upload)
- Dependency management replaced with [`uv`](https://astral.sh/uv); `uv.lock` pins the full transitive graph for reproducible builds
- `compose.yml` fully annotated; all env vars documented with inline comments
- `LD_LIBRARY_PATH` extended to include both cuDNN and CUDA runtime libraries
### Fixed
- **EXIF orientation**: PIL opens JPEGs without applying rotation metadata; Immich computes face bounding boxes on orientation-corrected images, so portrait photos produced misaligned crops. `ImageOps.exif_transpose()` now normalizes orientation before any coordinate math.
- **Model persistence**: `FaceAnalysis` was initialized with `root="~/.insightface"` (hardcoded), ignoring `INSIGHTFACE_HOME`. Buffalo_L was re-downloaded into the container on every run instead of persisting to the mounted volume.
- **Upload deduplication**: `upload_to_frigate()` scanned the output directory with `os.listdir()`, picking up leftover files from previous runs and re-uploading them. Now only files created in the current run are uploaded.
- **RGBA images**: Images in RGBA mode raised an error when encoding to JPEG. All images are now converted to RGB before saving.
- **Object mode uploads**: Object mode incorrectly called the Frigate face registration API. Frigate has no API for object training data — object mode now only saves crops to disk.
- **Stale output files**: The output directory was not cleaned between runs, causing crops to accumulate. Now wiped at the start of each face-mode run.
- **JSON decode errors**: `get_people()` and `fetch_all_assets()` only caught `RequestException`, leaving `JSONDecodeError` unhandled on non-JSON 200 responses from Immich.
- **Spaces in names**: People names with spaces caused downstream errors.
- **Inconsistent headers**: Some API calls used a raw header dict instead of `get_headers()`.
- **Docker layer caching**: `uv sync` was placed after `COPY if_curator/`, so any source change invalidated the 800 MB dependency cache. Dependencies are now installed before source is copied.
### Security
- CUDA base image bumped from `nvidia/cuda:12.6.3` to `nvidia/cuda:12.9.2-cudnn-runtime-ubuntu22.04`, picking up Ubuntu security patches flagged by Dependabot.