20 KiB
20 KiB
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
[0.2.10] - 2026-06-12
Added
VERBOSEenv var: setVERBOSE=trueto 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.:cpuDocker image tag: a separate CPU-only image (ghcr.io/sudolulo/winnow:cpu) is now built and pushed alongside:latest. Usesonnxruntimeinstead ofonnxruntime-gpu; ~2 GB smaller. Suitable for systems without an NVIDIA GPU.- Empty
CRON_SCHEDULEkeeps container alive: settingCRON_SCHEDULE=(empty string) starts the container without running immediately and without exiting — useful fordocker execad-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=truebecomes 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):
CUDAExecutionProviderwas 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 runningldconfigover allnvidia-*/lib/directories in the venv at image build time. - ldconfig path now Python-version-agnostic: the
findcommand used to register nvidia pip libraries hardcodedpython3.13; replaced withpython3.*glob so the path survives a Python upgrade without silently producing an empty ldconfig config. PYTHONPATH=/appadded to Dockerfile: the entry point script setssys.path[0]to the script directory, not/app. Sinceuv syncruns beforeCOPY winnow/, the wheel has only dist-info in site-packages.PYTHONPATH=/appmakes thewinnowpackage importable without reverting topython -m.- CPU fallback retrying broken GPU provider: InsightFace CPU fallback omitted
providers=["CPUExecutionProvider"], causing onnxruntime to retryCUDAExecutionProvideron every inference call. Now explicitly sets the CPU provider and suppresses C-extension noise via fd-level redirect. _suppress_outputstderr loss on fd exhaustion: if the firstos.dup2in the finally block raisedOSError, the second call was skipped, permanently redirecting stderr to/dev/nullfor the process lifetime. Wrapped in nestedtry/finallyso both restores are always attempted.- Frigate
/api/facesresponse 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 withAttributeError. 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 validscore=0.0as falsy, falling through to theconfidencefield (oftenNone). Replaced with an explicitNonecheck. 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 ofperson_id, producing incorrectly scaled bounding box coordinates for the target person. Loop now applies the sameperson_idfilter as_get_face_bbox. - Adaptive stopping bypassed for partially-trained people: in auto mode with
already_uploaded > 0,limitwas converted from"auto"to an integer, disabling the FPS adaptive threshold and early-stop check. Now keepslimit="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_nameusing"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 BaseExceptionin the run wrapper was replaced withexcept Exception(withKeyboardInterruptre-raised above), sosys.exit()calls propagate correctly. - Log handler leak:
setup_loggingnow closes and removes existing handlers before adding new ones, preventing file handle accumulation across repeated calls. RETRY_REJECTEDsilently 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.ymlcomment inverted: a comment stated-itforces non-interactive mode; corrected to reflect that-itallocates a TTY (interactive mode).
Security
- API key is no longer stored in any config file. All authentication uses environment variables or
.envonly.
[0.2.9] - 2026-06-12
Fixed
- arm64:
onnxruntime-gpuhas no arm64 wheels:onnxruntime-gpuonly publishesmanylinux_2_27_x86_64andmanylinux_2_28_x86_64wheels —uv syncon arm64 failed with exit code 2. Gatedonnxruntime-gpubehindsys_platform == 'linux' and platform_machine == 'x86_64'; arm64 and non-Linux installs now get the CPUonnxruntimepackage instead. - uv lockfile now covers arm64: Added
required-environmentsto[tool.uv]so the lockfile is solved for bothlinux/x86_64andlinux/aarch64, preventing silent resolution gaps for the non-build platform.
[0.2.8] - 2026-06-12
Fixed
- Dockerfile: use
add-apt-repositorywith isolated GNUPGHOME: thecurl | gpg --dearmorapproach was silently failing — gpg exits 0 on bad/empty input, leaving an invalid keyring and causing apt to skip the deadsnakes PPA entirely. Reverted toadd-apt-repository ppa:deadsnakes/ppawithGNUPGHOME=$(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.13was not locatable in its repos. Reverted arm64 base toubuntu:24.04. - Deadsnakes PPA now uses curl+gpg instead of
add-apt-repository:add-apt-repositoryspawns a gpg-agent which crashes under QEMU (arm64 CI). The PPA is now added by fetching the key viacurland piping throughgpg --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 TARGETARCHdependency in RUN commands.
[0.2.6] - 2026-06-12
Fixed
- Dockerfile:
ARG TARGETARCHre-declared in each stage: Docker's automatic platform ARGs are only in scope forFROMinstructions, notRUNcommands. The$TARGETARCHvariable in the deadsnakes PPA conditional was silently empty, so the PPA was never added andpython3.13could not be located on the Ubuntu 22.04 CUDA base. AddingARG TARGETARCHat the top of both thebuildandruntimestage 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.04tonvidia/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.0added as explicit dependency: routed through thepytorch-cu126index for linux/x86_64, ensuring it resolves to0.27.0+cu126(paired withtorch 2.12.0+cu126) rather than being pulled from PyPI where the older0.21.0wheel would downgrade torch to 2.6.0.torch>=2.12.0floor raised from 2.6.0 to prevent silent downgrade.nvidia-cudnn-cu12>=9.0.0floor 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"andtarget-version = "py313"updated inpyproject.toml.- CI updated to Python 3.13:
test.ymlandupdate-lockfile.ymlnow install Python 3.13. uv.lockregenerated for Python 3.13.5.
[0.2.3] - 2026-06-12
Fixed
- Clear-text API key storage:
config.pyno longer writesAPI_KEYto.immich_config.json. The key must come from an environment variable or.envfile. Interactive mode now prints a tip directing users to.env. Resolves CodeQLpy/clear-text-storage-sensitive-data.
Changed
- CI workflow permissions:
test.ymlandlint.ymlnow declarepermissions: contents: read, following least-privilege principle and resolvingactions/missing-workflow-permissionsscanner alerts. - CI lockfile race condition: removed the
verify-lockfilepre-job fromdocker-publish.ymlandrelease.yml. Theupdate-lockfile.ymlbot maintains the lockfile; the verify step raced against it on the same push event and caused false failures.release.ymlnow runsuv lockinline 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.
[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.jsonunderby_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/facesis queried on the Frigate host to retrieve the actual number of trained images per person from thetraindirectory (pending/unclassified queue is excluded). This count is stored asfrigate_countin the tracker JSON so it survives Frigate downtime. - Lifetime cap uses Frigate count:
MAX_AUTO_IMAGESis now enforced against Frigate's live training image count rather than the local uploaded-asset tally. Fallback priority: live Frigate API → last cachedfrigate_countin 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 exposesget_frigate_face_counts().
Changed
upload_tracker.py:by_personentries 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 optionalscorekeyword argument.get_person_summary()now returnsfrigate_countandscoresfields alongsideuploadedandrejected.
[0.2.1] - 2026-06-12
Fixed
- Container startup reinstalling packages:
entrypoint.shuseduv run, which performs a sync check on every startup and re-downloadedruffand rebuilt the package each time. Replaced with direct.venv/bin/pythoncalls to skip the sync entirely. - InsightFace double
models/path:INSIGHTFACE_HOME=/modelscaused InsightFace to download Buffalo_L to/models/models/buffalo_l(InsightFace always appendsmodels/to the root). Updated default incompose.ymland.env.exampleto/models/.insightface. - Lint errors in CI: unused imports in
tests/test_config.pyandtests/test_upload_tracker.py, unsorted imports inscheduler.py— all would have failed the ruff CI check.
[0.2.0] - 2026-06-12
First release of winnow. Forked from if-curator by Sebastian and rewritten for headless Docker deployment.
Added
Headless operation
AUTO_MODEenv var — runs without any interactive prompts; required for Docker/cron useDRY_RUNenv var — previews selection without downloading, cropping, or uploading anythingRETRY_REJECTEDenv var — re-attempts assets previously rejected by Frigate's face APIRESET_PERSONenv 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 concernentrypoint.sh— runs the tool once on startup, then hands off to the scheduler ifCRON_SCHEDULEis setscheduler.py— in-process cron scheduler that keeps the container (and loaded models) alive between runsCRON_SCHEDULEenv 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/amd64andlinux/arm64built and merged into a single manifest on GHCR tinias PID 1 init process for correct signal handling- Non-root container user (
appuser, uid 568) HEALTHCHECKin 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_CLASSenv var — target YOLO class label (e.g.dog,cat,car); defaults todog
People filtering
ONLY_PEOPLEenv var — comma-separated whitelist; only these people are processedSKIP_PEOPLEenv var — comma-separated list; these people are skippedMIN_FACE_COUNTenv var — skip people with fewer than N tagged assets in ImmichYEARS_FILTERenv var — ignore assets older than N years (default: 10)
Image quality controls (previously hardcoded)
BLUR_THRESHOLDenv var — Laplacian variance threshold for blur rejectionMIN_CONFIDENCEenv var — minimum Immich face detection confidenceMAX_AUTO_IMAGESenv var — hard cap on auto-diversity selectionFACE_MARGINenv var — padding around bounding box crops as a fraction of face sizeUSE_FULL_RESOLUTIONenv var — download full-res originals vs preview thumbnailsENABLE_FACE_ALIGNMENTenv var — align to ArcFace 112×112 format via InsightFace landmarks
GPU and model configuration
FORCE_CPUenv var — disable GPU; fall back to CPU for embedding computationINSIGHTFACE_HOMEenv var — controls model persistence for Buffalo_LHF_HOMEenv var — HuggingFace model cache path for SigLIPLD_LIBRARY_PATHset in the image to expose CUDA and cuDNN pip libraries soonnxruntime-gpucan find them at runtime
Caching and upload tracking
ENABLE_CACHE/CACHE_DIRenv 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
LIMITenv var — exact image count overridingSTRATEGYpreset
CI/CD
docker-publish.yml— builds multi-arch image and pushes to GHCR on push tomain(:latest) ordev(:dev)release.yml— triggered byv*tags orworkflow_dispatch; creates a GitHub Release, extracts changelog notes, builds and pushes versioned image to GHCRlint.yml— runs Ruff on push/PR tomainanddevtest.yml— runs pytest on push/PR tomainanddevupdate-lockfile.yml— regeneratesuv.lockand commits it whenpyproject.tomlchanges- 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 guidedocs/troubleshooting.md— common failure modes with fixesdocs/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.pysplit into three focused modules —cli.py(entry point),jobs.py(configuration and strategy resolution),executor.py(download, crop, upload)- Dependency management replaced with
uv;uv.lockpins the full transitive graph for reproducible builds compose.ymlfully annotated; all env vars documented with inline commentsLD_LIBRARY_PATHextended 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:
FaceAnalysiswas initialized withroot="~/.insightface"(hardcoded), ignoringINSIGHTFACE_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 withos.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()andfetch_all_assets()only caughtRequestException, leavingJSONDecodeErrorunhandled 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 syncwas placed afterCOPY 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.3tonvidia/cuda:12.9.2-cudnn-runtime-ubuntu22.04, picking up Ubuntu security patches flagged by Dependabot.