Files
winnow/CHANGELOG.md
flan 01fa307031 Fix Frigate faces crash, tracker races, and batch write loss
- frigate_api._get_faces_data now validates the /api/faces response is a
  dict before returning it, so a malformed body no longer crashes
  data.items() in get_all_frigate_person_files (and the equivalent
  data.get() in get_frigate_person_files).
- upload_tracker now takes an exclusive flock on a DATA_DIR lock file for
  every tracker load-mutate-save cycle, so a scheduled run and a manual
  docker exec against the same DATA_DIR can no longer race a
  read-modify-write and silently drop the loser's marks.
- begin_batch()/flush_batch() now flush to disk every 10 marks instead of
  deferring the whole per-person upload loop, bounding how many uploaded
  marks a crash mid-batch can lose.
2026-07-19 20:46:09 +00:00

66 KiB
Raw Permalink Blame History

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]

Fixed

  • get_all_frigate_person_files crashed on a non-dict /api/faces response: data.items() assumed the JSON body was always an object; a malformed response from Frigate crashed the call instead of degrading gracefully like the rest of the API layer. _get_faces_data now validates the response shape and returns None on a non-dict body, protecting both get_all_frigate_person_files and get_frigate_person_files.

  • Tracker JSON files had no cross-process locking: two winnow invocations against the same DATA_DIR (e.g. a scheduled run overlapping a manual docker exec, which the docs explicitly instruct) could race a read-modify-write on frigate_uploaded_ids.json/frigate_rejected_ids.json and silently lose the loser's marks. Tracker load-mutate-save cycles now hold an exclusive flock on a DATA_DIR lock file for the duration of the operation.

  • begin_batch/flush_batch could lose an entire person's upload marks on a crash: tracker writes were deferred for the whole per-person upload loop, so a SIGKILL/OOM/host crash mid-batch lost every successful Frigate upload from the tracker even though the files were already live in Frigate, causing duplicate re-uploads on the next run. Batches now flush to disk every 10 marks, bounding the loss to a small, fixed window.

[0.6.6] - 2026-06-18

Changed

  • MAX_AUTO_IMAGES default lowered from 20 to 5 — existing users who have not set this variable and already have more than 5 winnow-managed images in Frigate will find themselves at cap on the next run. With QUALITY_REPLACEMENT=true (the default), winnow will attempt to swap weaker images rather than uploading new ones. Set MAX_AUTO_IMAGES=20 to restore the previous behaviour.

[0.6.5] - 2026-06-17

Added

  • Version displayed in startup banner: winnow now prints its installed version at launch.

Fixed

  • GPU image: CUDAExecutionProvider missing due to parallel install race: insightface declares onnxruntime (CPU) as a dependency, causing uv sync to install both onnxruntime and onnxruntime-gpu in parallel — both packages claim the same pybind11_state.so binary. On GitHub Actions the CPU binary consistently won the race, leaving the GPU build without CUDA support at runtime despite all CUDA libraries being present. Fixed by reinstalling onnxruntime-gpu sequentially after uv sync to guarantee its GPU binary is on disk.

  • GPU extra was missing three required nvidia pip packages: onnxruntime-gpu 1.26.0 gates CUDA EP loading on the Python-importability of nvidia-cuda-runtime-cu12, nvidia-cufft-cu12, and nvidia-curand-cu12. These packages were not declared in the gpu extra and were absent on fresh installs, silently disabling GPU inference.

  • _handle_duplicate_people raises KeyError on id-less person records: bare p["id"] subscripts in the auto-merge loop and _smaller_duplicate_ids raised KeyError when Immich returned a person dict without an id field (e.g. unconfirmed face clusters). Fixed by using p.get("id") and filtering None from skip_ids.

  • _smaller_duplicate_ids could include None in the skip set: p.get("id") without a None guard populated skip_ids with None, causing p.get("id") not in skip_ids to pass for every id-less person, so unnamed face clusters were silently re-included in all return paths.

  • _handle_duplicate_people dead code removed: guards if not survivor_id and if not merge_ids became unreachable after the id-gate fix; their presence suggested they still ran.

  • _valid_people in jobs.py used wrong name filter: whitespace-only names (e.g. " ") passed the p.get("name") truthiness check and were included in the person list. Fixed using (p.get("name") or "").strip() consistent with the cli.py gate.

  • interactive_configure queued-marker check was O(N²): [j for j in jobs if j["person"]["id"] == p.get("id")] ran a full scan over jobs for every person in the display loop. Replaced with a queued_ids set hoisted before the loop.

  • executor.py slot restore did not clear min_quality_score_for_slot: when a replacement upload failed all retries after a deletion, effective_count was restored but the stale quality-score floor from the deleted file remained, blocking the next candidate from filling the slot.

  • get_immich_version swallowed KeyError on unexpected schema: bare data["major"] / data["minor"] / data["patch"] subscripts were silently caught by the surrounding except Exception, returning None without logging. Replaced with .get() calls that log a debug warning on unexpected schemas.

  • Face embedding selects nearest face to crop centre, not largest by area: a 25 % margin on the crop window can pull a larger neighbouring face into the bounding box; selecting the biggest face by area then embeds the wrong person. Centre-proximity is now used instead.

  • Zero-norm face embeddings skipped before diversity selection: InsightFace occasionally returns a zero vector for low-quality detections; zero embeddings pass deduplication with similarity 0 and score distance 1.0, causing them to be selected first as maximally diverse.

  • executor.py slot restore did not clear min_quality_score_for_slot: stale quality floor from the deleted file blocked the next candidate from filling the restored slot in quality-replacement mode.

[0.6.4] - 2026-06-17

Fixed

  • Face bbox scaled to thumbnail space before quality filtering: assess_quality now receives coordinates in thumbnail-pixel space rather than detection-image space. Previously, a face detected on a full-resolution image (e.g. 4000 px wide) was compared against MIN_FACE_WIDTH using its original pixel dimensions, causing faces that appear small on the thumbnail to pass the quality filter — and faces that appear large to be incorrectly rejected.

  • conf_array default restored to 1.0 for faces with missing confidence: the default was incorrectly set to 0.5, causing images with no score field in the Immich faces API response to receive a 1.7× FPS diversity boost and be selected ahead of genuinely high-confidence detections. The default is now 1.0 (no boost), treating missing confidence as neutral.

  • hard_weight computed once outside FPS loop: conf_array is constant after initialisation; moving the np.where call outside the while loop eliminates one O(n) numpy pass per selected image.

  • has_frigate_model snapshot prevents mid-batch recognize_face calls on first run: effective_count is incremented inside the upload loop, so using it as the recognize_face gate would incorrectly trigger scoring after the first upload on a first run. A boolean snapshot is now taken before the loop.

  • person_has_fscores only set when tracker write succeeds: the flag was moved outside the try/except else block, causing at-cap replacement to switch into Frigate-score mode even when the score was never written to the tracker — get_most_redundant_mapped_file then returned None and all replacement candidates were silently skipped. The flag is now set only in the else branch.

  • STRATEGY=skip honoured before embedding and limit checks: the strategy was silently converted to auto when InsightFace was available, because two early-returns in _resolve_strategy ran before the strategy_map lookup.

  • limit="auto" preserved on first run: switching to limit = capacity unconditionally caused the FPS adaptive early-stop to never fire on a person's first upload run. limit="auto" is now kept when already_uploaded == 0.

  • EmbeddingCache.get falls back gracefully on all load errors: a MemoryError during np.load of a cached embedding was re-raised, crashing the entire diversity-selection batch for that person. Cache-read failures of any kind now return None so the embedding is recomputed fresh.

  • get_people returns [] when Immich sends {"people": null}: .get("people", []) only uses the default when the key is absent, not when its value is null. Changed to data.get("people") or [] so null-valued responses are handled the same as missing keys.

  • get_people and fetch_all_assets guard against non-dict responses: a proxy or CDN returning a JSON array (or other non-dict body) previously caused an AttributeError from .get(). Both functions now check isinstance(data, dict) and return an empty result with an error log.

  • filter_recent_assets counts and logs assets with missing or unparseable timestamps instead of silently dropping them.

  • _suppress_output fd cleanup restructured: the context manager now initialises devnull_fd, saved_out, and saved_err to None before the try block, so the finally can close only the descriptors that were successfully opened. Each os.close is wrapped in its own try/except OSError so a failed close cannot prevent subsequent descriptors from being released. OSError from os.dup2 restore is logged at DEBUG rather than silently swallowed.

  • blur_score_from_image copies the image before thumbnail resize: Image.thumbnail modifies the image in-place. When the caller's image was already in RGB mode (no convert copy), the resize would have mutated the caller's object. A copy is now made when score_img is img.

  • imageWidth/imageHeight zero-value treated as missing in image_processing.py: the old or img_w fallback silently set scale = 1.0 for a zero-valued dimension (correct) but also for None (also correct) with no distinction. The explicit scale = img_w / meta_w if meta_w else 1.0 form matches the pattern used in the new _scale_bbox_to_thumbnail helper and makes the fallback intent clear.

  • _mark and update_frigate_count copy before mutate: both functions now create a shallow copy of the top-level tracker dict before assigning into by_person, so a failed _save cannot leave the in-memory cache ahead of the on-disk file.

  • reset_person flat-list guard only warns when cleanup would have run: the isinstance(data[flat_key], list) check previously emitted a warning even when person_ids was empty (a no-op call). The warning is now gated behind person_ids and, matching the guard on the cleanup branch.

  • _handle_duplicate_people uses p.get("id") consistently: all four return-path filter comprehensions and the _smaller_duplicate_ids set comprehension now use .get("id") instead of bare p["id"], preventing a KeyError if the Immich API returns a person record without an id field.

  • K-Medoids non-medoid membership test is O(1): non_medoids now filters against set(medoids) instead of the list, eliminating an O(k) scan per candidate on each outer iteration.

[0.6.3] - 2026-06-16

Fixed

  • record_frigate_files_batch no longer mutates the tracker cache before write: the function shared the same cache-corruption-on-write-failure bug that was fixed in remove_frigate_files_batch in v0.6.1 — data.setdefault("by_person", {}) mutated the cached dict in-place, so a disk-full or permission error left the in-memory cache ahead of the on-disk file. Now uses the same copy-before-mutate pattern (shallow copies of the top-level dict and by_person sub-dict) so a failed write leaves cache and disk in sync.

  • tracker_ok boolean flag replaced with try/else: the intermediate boolean was a misleading placeholder — the True initial value suggested success before the operation ran. The control flow is now expressed directly with a try/except/else block.

  • LIMIT env var guard simplified: the two adjacent if custom_limit is not None checks in _resolve_strategy are collapsed into a single if custom_limit is not None: with nested branches, removing redundant evaluation.

[0.6.2] - 2026-06-16

Changed

  • Flat uploaded_asset_ids / rejected_asset_ids lists dropped as primary storage: asset IDs are now derived on read from by_person entries, which are the single source of truth. The legacy flat lists in existing tracker files are still read (union) so no assets become re-eligible after upgrading. New writes no longer maintain the flat lists. This removes the dual-representation sync hazard and paves the way for multi-instance support (per-instance by_person keying in a future release).

  • Tracker writes batched per person: mark_uploaded calls inside the per-person upload loop are now accumulated in memory (begin_batch) and flushed in a single os.replace write at the end of each person's loop (flush_batch), reducing N tracker writes per person to 1. Benefits users on slow storage (NAS, SD card, spinning disks).

  • RESET_PERSON=* is now O(1) disk writes: replaced the per-person reset_person loop with reset_all_people(), which makes one Frigate API call per person for file deletion and then clears both tracker files in two writes. Previously it was O(P²) iterations and 2P writes.

  • blur_score_from_image inlines Laplacian computation: replaced the assess_quality() call (which ran grayscale, exposure, and confidence checks whose results were discarded) with a direct cv2.Laplacian computation. The function is now self-contained and does not silently inherit future costs added to the full quality pipeline.

[0.6.1] - 2026-06-16

Fixed

  • Corrupt or truncated full-res thumbnails now marked rejected: OSError (truncated file) is caught alongside PIL.UnidentifiedImageError in the thumbnail path so persistently bad assets are tombstoned instead of retried forever. Full-res download failures (USE_FULL_RESOLUTION=true) remain transient — not marked rejected — so a Immich blip doesn't permanently blacklist valid assets.

  • Quality replacement mode no longer flips mid-loop: person_has_fscores was re-evaluated after each file deletion, which could switch the remaining replacements from Frigate-score mode to blur-score mode if the deleted file was the last scored one. The mode is now fixed for the duration of the upload loop.

  • reset_person no longer removes shared asset IDs: the flat uploaded_asset_ids list is now rebuilt from all remaining by_person entries rather than subtracting the reset person's IDs. Previously, resetting Alice could remove an asset ID that also appeared under Bob, making it re-eligible for upload.

  • _save cache updated only after successful write: the in-memory tracker cache is now updated after os.replace succeeds rather than before. A disk-full or permission error no longer leaves the cache permanently ahead of the on-disk file.

  • Stale Frigate file cleanup batched: the per-file remove_frigate_file loop is replaced with a single remove_frigate_files_batch call, reducing N tracker writes to 1 when stale mappings are cleaned up.

  • _migrate_entry no longer mutates the cache through nested dict aliases: all five nested dicts (asset_ids, scores, frigate_scores, frigate_files, crop_dims) are now individually copied so .pop() calls in write paths cannot reach the in-memory cache.

  • find_by_crop_dimension and _pick_mapped_file now agree on duplicate asset→file handling: both use first-seen-wins when the same asset_id maps to multiple Frigate filenames, preventing inconsistent replacement decisions.

  • Non-atomic JSON write: tracker files are written to a .tmp sibling then renamed with os.replace so a crash mid-write never leaves a truncated file.

  • get_person_summary uses _migrate_entry: replaced three ad-hoc isinstance guards with a single _migrate_entry call, making old-format (list) entries consistent with every other read path.

  • Quality replacement floor check: a candidate with a None blur score (PIL error during scoring) no longer blocks a freed slot — the <= floor comparison is only applied when a score is actually available.

  • executor.py syntax error: the if img is None: block in the full-res download path was comment-only and would have raised IndentationError on import. Added pass.

  • Duplicate if stale: guard: two consecutive identical guards around stale-cleanup and its log print were merged into one.

  • _flat_key uses constant equality instead of substring match, removing a latent routing bug for any filename that happens to contain "uploaded".

  • remove_frigate_file no longer creates ghost entries: returns early when the person is absent rather than writing an empty stub.

  • skip_ids extracted to helper: the identical set comprehension in _handle_duplicate_people that appeared in three branches is now a single _smaller_duplicate_ids() inner function.

  • blur_score_from_image returns None on error instead of 0.0, so callers can distinguish a failed measurement from a legitimately near-zero Laplacian variance score.

[0.6.0] - 2026-06-15

Changed

  • Upload tracker reverted to JSON storage: the SQLite-based tracker introduced in v0.5.0 produced 17 bug-fix releases in two days due to data-loss risks in the migration layer, schema primary key conflicts, tracker isolation races, and disk-full retry storms. The JSON backend (frigate_uploaded_ids.json / frigate_rejected_ids.json in DATA_DIR) is restored. It is simpler, has no migration layer, and carries no external dependency. If you ran any v0.5.x version, delete frigate_tracker.db from your DATA_DIR once you confirm the JSON files look correct. JSON files from before v0.5.0 are read automatically with no changes required.

  • CACHE_DIR env var accepted as DATA_DIR alias: the rename introduced in v0.5.1 is preserved — CACHE_DIR still works with a deprecation warning. The default data path remains data (Docker: /app/data).

  • Config file now lives in DATA_DIR: .immich_config.json resolves to DATA_DIR/.immich_config.json so it persists across container restarts. The legacy CWD location is still checked as a fallback for existing setups.

  • Diversity selector receives capacity as its limit directly: instead of selecting up to MAX_AUTO_IMAGES and then slicing to the remaining capacity, the selector now runs with the actual remaining slot count as its budget.

Fixed

  • Immich v2.7.5 compatibility: auto_configure no longer pre-filters people by assetCount from /api/people, which Immich v2.7.5 dropped. The MIN_FACE_COUNT check now runs after fetch_all_assets using the actual fetched count.

  • fetch_face_data no longer falls back to a wrong person's bounding box: when person_id is provided but not found in the Immich /api/faces response, the function now returns None instead of using faces[0]. Previously a group photo where the target person's face entry was missing would inject a different person's bounding box into the crop.

  • Corrupt thumbnail permanently rejected: when resp.ok=True but PIL.UnidentifiedImageError is raised (Pillow cannot identify the image format), the asset is now marked rejected so it isn't re-downloaded on every future run. Transient OSError/truncation errors are intentionally not caught here — those are retried normally.

  • mark_uploaded tracker failure no longer aborts the upload loop: a tracker write failure after a successful Frigate POST is logged and the loop continues; the asset will be re-uploaded on the next run rather than the current run dying mid-job.

  • progress.remove_task now in finally block: the progress bar task is cleaned up even when a job exits via an exception, preventing orphaned progress rows in the terminal.

  • SKIP_PEOPLE/ONLY_PEOPLE now strip whitespace: "Alice, Bob".split(",") produces [" Bob"]; the leading space now stripped so comma-separated values with spaces work as expected.

  • FRIGATE_URL with trailing slash no longer produces double-slash paths: all Frigate API calls now use _get_frigate_url() for URL normalization rather than reading FRIGATE_URL inline.

  • Frigate version v-prefix now stripped: v0.16.0-style version strings are correctly parsed.

  • Invalid numeric env var values warn and use defaults: a typo such as YEARS_FILTER=10 (trailing space) or MIN_FACE_WIDTH=auto now logs a WARNING and falls back to the documented default instead of raising ValueError at startup. Affects YEARS_FILTER, MIN_FACE_WIDTH, MIN_FACE_COUNT, MAX_AUTO_IMAGES, BLUR_THRESHOLD, MIN_CONFIDENCE, and FACE_MARGIN.

  • IMMICH_URL blank placeholder falls back to config file: IMMICH_URL= (empty or blank) in .env is now treated as unset and falls through to DATA_DIR/.immich_config.json, matching pre-v0.5.0 behaviour.

  • Reconciliation checks Frigate immediately before first sleep: the poll loop now performs an immediate check after upload, then backs off with (1, 2, 4, 8) s delays only if needed.

  • Dockerfile unknown VARIANT now fails loudly: an unrecognised value now exits with an error instead of silently falling through to the cpu branch.

  • Embedding cache writes are now atomic: .npy files are written to a .tmp sibling and renamed into place with os.replace, preventing truncated cache entries on process kill.

  • EmbeddingCache singleton re-creates when DATA_DIR changes: prevents test runs from sharing cache state across different DATA_DIR values.

Added

  • Diversity test suite (PR #11): 33 tests covering k-medoids clustering, farthest-point sampling, adaptive threshold computation, near-duplicate deduplication, and time-spread selection. Total: 93 tests.

[0.4.11] - 2026-06-14

Removed

  • Object mode pipeline fully removed: YOLO object detection, SigLIP image classification, TRAINING_MODE, and OBJECT_CLASS env vars are gone. Frigate has no training API for objects; the ~2 GB model stack (torch, torchvision, transformers, ultralytics) was dead weight.
  • Dead Immich embedding path removed: FaceData.embedding field and the immich_embedding parameter to get_embedding() were never consumed by any caller. Both removed along with the NumPy import in immich_api.py that existed solely for that path.
  • Dead mode config key removed: "mode": "face" was written into job config dicts in jobs.py but never read after object mode removal.

Fixed

  • InsightFace FutureWarning suppressed in crop-alignment path: the insightface_app.get() call in image_processing.py now wraps the same warnings.catch_warnings() suppressor already present in embeddings.py, preventing scikit-image deprecation noise in logs.

Changed

  • Variant pyproject files synced to current state: pyproject-rocm.toml, pyproject-cpu.toml, pyproject-intel.toml were at v0.2.13 and still listed torch/transformers/ultralytics. Updated to v0.4.11 and cleaned to face-only deps. Note: corresponding lockfiles (uv-rocm.lock, uv-cpu.lock, uv-intel.lock) need regeneration in their respective platform environments.
  • MERGE_DUPLICATE_PEOPLE documented: README and wiki now explain the default warn-and-skip behaviour vs. setting true for a permanent Immich merge, with irreversibility callout.
  • Wiki fully updated: all five wiki pages rewritten to remove object mode references, correct model size (~300 MB InsightFace vs former ~12 GB HuggingFace+InsightFace), fix default values (MAX_AUTO_IMAGES 80→20, MIN_FACE_COUNT 0→3), add MERGE_DUPLICATE_PEOPLE coverage, and update GPU verification commands for current ONNX provider API.

[0.4.10] - 2026-06-14

Changed

  • MAX_AUTO_IMAGES default lowered from 80 to 20: winnow is designed to fill the gap where manual Frigate training images don't exist — not to be the primary dataset. A conservative default ensures winnow-imported images remain secondary to hand-picked ones where both exist.

[0.4.9] - 2026-06-14

Changed

  • FRIGATE_SCORE_CEILING is now dynamic by default: previously defaulted to 0 (disabled). Now unset (default) enables a self-calibrating novelty gate — below-cap candidates are skipped if their pre-upload Frigate score exceeds the most-redundant tracked file's score. This catches conditions already covered by manually-added Frigate images that winnow cannot track. Set FRIGATE_SCORE_CEILING=0 to disable entirely; set a positive value (e.g. 0.85) for a fixed hard ceiling.
  • Quality replacement branches consolidated: the Frigate-score and blur-score replacement paths in the upload loop shared identical structure. Merged into a single code path parameterised by score source and comparison direction.
  • MIN_FACE_COUNT default raised from 0 to 3: people with fewer than 3 tagged photos produce degenerate training sets; skipping them by default avoids noisy runs.
  • STRATEGY=adaptive is the new primary name for embedding-based diversity selection; auto remains a silent alias for backwards compatibility.
  • MERGE_DUPLICATE_PEOPLE and TRACE_CROP_SIZE added to the README env var table (were in the codebase but undocumented).
  • CUDA version corrected in the image tags table (was 13.3, actual base image is 12.8.1).

[0.4.8] - 2026-06-14

Changed

  • Tracker write-through cache: upload_tracker now keeps an in-memory copy of each JSON file keyed by its resolved path. All reads after the first hit the cache instead of disk; writes go to both disk and cache atomically. Cuts per-person disk I/O in the upload loop from ~90 reads to ~1, with no API or behaviour changes.

[0.4.7] - 2026-06-14

Changed

  • _dedup_embeddings pre-allocated buffer: replaced the grow-on-keep np.vstack pattern with a pre-allocated (Q, D) buffer filled row-by-row. Eliminates O(K²) copy work and the GC pressure from K intermediate heap allocations while keeping identical arithmetic for the similarity checks.
  • _kmedoids cost computation vectorized: the Python-level sum(dist_matrix[i, medoids[labels[i]]] for i in range(n)) generator (called once per swap evaluation) is replaced with dist_matrix[np.arange(n), np.array(medoids)[labels]].sum() — a single numpy fancy-index + reduction, ~2050× faster in the swap loop.
  • _reconcile_frigate_mappings single-write batch: previously called record_frigate_file once per uploaded file, each doing a full JSON load + save (O(L) disk round-trips per person). Now builds the full {frigate_filename: asset_id} mapping dict and writes it in one record_frigate_files_batch call (O(1) disk round-trip).

[0.4.6] - 2026-06-14

Fixed

  • OOM when Immich returns many pages per person: fetch_all_assets now stops fetching once 5000 assets have been collected — the diversity selection pool is already capped at 3000 items, so fetching up to 1,000,000 was wasteful and could exhaust memory on large libraries. 5000 provides ample headroom for the pool cap while bounding per-person memory to ~2 MB.
  • Non-dict items in Immich asset pages silently skipped: a malformed or partially-null Immich response page could include null or non-object items in the assets array. These are now filtered at fetch time rather than causing AttributeError downstream.

[0.4.5] - 2026-06-14

Fixed

  • Near-duplicate dedup O(N²) allocation: np.vstack(kept_normed) was rebuilt on every loop iteration even for candidates that would be dropped; the stack is now rebuilt only when a new item is kept, reducing memory pressure significantly for large pools.
  • quality_score falsy-zero in dedup sort: the sort key used c.get("quality_score") or 0.0, which treated a legitimate quality_score=0.0 identically to a missing key. Changed to an explicit None check so zero is preserved as-is, and object-mode candidates (which have no quality_score) continue to sort stably to the back.
  • Post-dedup pool not re-checked against limit: after near-duplicate removal the pool could silently shrink below the requested limit with no warning. A second len < limit guard now fires after dedup and emits the same "Only N embeddings" warning that the pre-dedup guard does.
  • mark_rejected could miss plain-text 400 bodies longer than 100 bytes: error_detail = resp.text[:100] was being searched for the keyword "face" to gate mark_rejected(), so a response body with "face" after byte 100 would never mark the asset rejected and it would be retried on every future run. The "face" check now uses the full response body; truncation is kept only for the displayed snippet.
  • _safe_person_dir raised ValueError for all person names when output_dir resolved to /: base + os.sep produced "//" when base was "/", and valid paths like /alice don't start with "//". Fixed by using base directly as the prefix when base == os.sep.

[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 2224 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,5004,225 total pixels, well below Frigate's own camera capture range of 16k50k 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.

[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 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; 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.