Compare commits

...
66 Commits
Author SHA1 Message Date
flan 4cdd4657d6 fix: v0.6.2 — structural tracker refactor, batch writes, multi-instance prep
- Drop flat list as primary storage; derive uploaded/rejected IDs from by_person
  (single source of truth). Legacy flat lists in existing files still read for
  backward compat. Removes dual-representation sync hazard.
- Add begin_batch/flush_batch: per-person upload loop now does 1 os.replace
  instead of N (one per mark_uploaded call). Benefit on slow storage.
- reset_all_people(): RESET_PERSON=* is now O(1) disk writes instead of O(P^2).
- blur_score_from_image inlines cv2.Laplacian directly, removing assess_quality
  call overhead and decoupling from the full quality pipeline.
2026-06-16 15:44:41 +00:00
flan dc2efb5ac4 fix: v0.6.1 — tracker integrity, quality replacement correctness, code review fixes
- Catch OSError alongside PIL.UnidentifiedImageError for corrupt thumbnails
- Fix quality replacement mode flip mid-loop (person_has_fscores no longer re-evaluated)
- reset_person rebuilds flat list from remaining entries instead of subtracting
- _save cache updated only after os.replace succeeds (prevents cache/disk split-brain)
- Stale Frigate file cleanup uses remove_frigate_files_batch (N writes → 1)
- _migrate_entry deep-copies nested dicts so .pop() cannot mutate the cache
- find_by_crop_dimension and _pick_mapped_file consistent on duplicate asset→file mapping
- Atomic JSON write (tmp + os.replace) guards against truncated files on crash
- get_person_summary uses _migrate_entry instead of three isinstance guards
- Quality floor check allows None-scored candidates through (don't block freed slots)
- Fix comment-only if body (IndentationError on import) in full-res download path
- Merge duplicate if-stale guard into one block
- _flat_key uses constant equality instead of substring match
- remove_frigate_file returns early when person absent (no ghost entries)
- skip_ids extracted to _smaller_duplicate_ids() helper (was duplicated 3×)
- blur_score_from_image returns None on error instead of 0.0
2026-06-16 15:21:12 +00:00
flan 9e84e276da Merge remote-tracking branch 'origin/main' into dev 2026-06-15 15:52:29 +00:00
flan 794dbe2a1d revert: replace SQLite tracker with JSON backend (v0.6.0) (#32)
* revert: replace SQLite tracker with JSON backend (v0.6.0)

The SQLite migration (v0.5.0) spawned 21 bug-fix releases in two days:
data-loss risk in the migration layer, schema PK conflicts on per-person
tracking, tracker isolation races under concurrent runs, and a disk-full
error that triggered duplicate Frigate uploads. The complexity cost
outweighs the benefit.

Restored the pre-SQL JSON tracker (frigate_uploaded_ids.json /
frigate_rejected_ids.json in DATA_DIR). Public API is identical — all
callers in executor.py, jobs.py, cli.py, and reconcile.py work unchanged.
Existing JSON files are read automatically; frigate_tracker.db can be
deleted once verified.

* fix: narrow corrupt-thumbnail exception to UnidentifiedImageError; restore IMMICH_URL empty-string fallback

* docs: rewrite v0.6.0 changelog, strip v0.5.x entries, fix README SQLite references

* chore: remove dead get_frigate_filename_for_asset (orphaned since FRIGATE_SCORE_THRESHOLD removal in v0.4.0)

* fix: sort imports in executor.py (ruff I001)
2026-06-15 11:44:41 -04:00
flan 86c76ba6a7 Update README.md (#33) 2026-06-15 11:44:12 -04:00
flan 0602c4ac04 fix: v0.5.21 — diversity cap, fetch rejection, tracker isolation, merge dedup, env parsing
- diversity: cap k-medoids seed count at target so _cluster_aware_selection
  never returns more images than requested (violated MAX_AUTO_IMAGES when
  remaining capacity was 1-4 slots); add early return for limit=0 to
  prevent k-medoids from running with a zero budget; slice return to target
  as a final guard
- executor: mark_rejected() when fetch_full_image returns None so assets
  that can't be fetched (both original and preview) aren't retried every run
- executor: wrap mark_uploaded() in its own try/except so a SQLite disk-full
  error after a successful HTTP 200 doesn't retry the Frigate POST (duplicate
  upload) — the upload succeeded; only the tracker write failed
- cli: apply skip_ids deduplication to the re-fetched people list after a
  partial merge (some groups succeed, some fail) so unmerged duplicates
  don't produce two jobs for the same Frigate folder
- jobs: strip whitespace from SKIP_PEOPLE/ONLY_PEOPLE elements on split
  so "Alice, Bob" (space after comma) correctly matches "Bob"
2026-06-15 03:56:57 +00:00
flan 95fb39ed50 fix: v0.5.20 — migration safety, URL normalization, image rejection, atomic writes
- upload_tracker: replace executescript() in _migrate_schema_v2 with
  individual execute() calls inside a transaction so a crash between DROP
  and RENAME rolls back instead of permanently destroying tracked_assets
- frigate_api: _get_frigate_url now strips leading/trailing whitespace
  before rstrip('/') so whitespace-only FRIGATE_URL is treated as unset
- executor: upload_to_frigate now uses _get_frigate_url() eliminating
  double-slash upload paths when FRIGATE_URL has a trailing slash
- executor: corrupt thumbnail (resp.ok=True, Image.open fails) now calls
  mark_rejected() so permanently broken assets are not retried forever
- upload_tracker: reset_person now uses _get_frigate_url() instead of
  inline os.environ.get('FRIGATE_URL', '').strip()
- image_processing: _save_jpeg writes to a .tmp file and calls
  os.replace() so a disk-full error never leaves a truncated JPEG
- cli: _handle_duplicate_people falls back to local deduplication when
  all Immich merges fail, preventing two jobs from overwriting the same
  Frigate folder
- config: _getenv_optional_float now delegates to _getenv_num() like
  _getenv_optional_int, eliminating the inconsistent duplicate
- reconcile: _ts() uses rsplit('.', 1)[0] instead of .replace('.webp','')
  so FIFO mapping works with any Frigate training-file extension
2026-06-15 03:26:51 +00:00
flan 728b84dc8c fix: address 10 full-codebase audit findings (v0.5.19)
Correctness:
- fetch_face_data: only fall back to faces[0] when person_id is absent;
  previously a missing person match injected a different person's bbox
- upload_tracker: change PK from (asset_id, status) to
  (asset_id, person_name, status); old PK allowed INSERT OR REPLACE to
  silently overwrite person_name when the same photo appeared in two
  people's jobs, breaking quality-replacement JOINs; auto-migrates DBs
- filter_recent_assets: treat years=0 as "no age filter" instead of
  falling through to Config.YEARS_FILTER via falsy `or`
- _is_module_available: return find_spec(...) is not None; find_spec
  returns None (not raises) for absent top-level modules, so the
  previous code always returned True
- execute_jobs error handler: use asset.get("id", "<unknown>") to avoid
  a secondary KeyError propagating out of execute_jobs on malformed dicts
- upload_to_frigate: also mark_rejected on HTTP 422, not only HTTP 400
  with "face" in body; other permanent errors left assets untracked and
  retried forever
- reconcile_frigate_mappings: sort key lambda f: (_ts(f), f) makes order
  deterministic when timestamps are equal or 0.0; set iteration order is
  hash-randomised, stable sort preserves it

Reuse / cleanup:
- config.py: add _getenv_optional_int delegating to _getenv_num(name, None, int)
- jobs.py: _resolve_strategy uses _getenv_optional_int("LIMIT") instead
  of inline os.environ.get + int() + warning duplicate of _getenv_num
- frigate_api.py: add _get_frigate_url() helper; eliminates 4× copy of
  os.environ.get("FRIGATE_URL", "").rstrip("/")
- quality.py: extract blur_score_from_image(img, max_dim=1440) helper;
  executor.py time-spread blur fallback now uses it instead of inlining
  the resize+RGB+assess_quality sequence, keeping scale logic in one place
2026-06-15 02:56:51 +00:00
flan 3c4479a110 fix: replace inline FORCE_CPU check in benchmark.py with _getenv_bool
scripts/benchmark.py retained the old os.getenv inline pattern after
_getenv_bool was introduced in v0.5.16. Now uses a deferred local
import of _getenv_bool, consistent with the script's pattern of keeping
all winnow imports inside function bodies rather than at the top level.
2026-06-15 02:29:30 +00:00
flan 06c2c4a584 fix: strip empty env vars in _getenv_num/_getenv_bool; unify FORCE_CPU
- _getenv_num: add raw.strip() + empty-string guard so numeric vars set
  to "" (common Compose pattern for "use default") return the default
  silently instead of warning "not a valid int/float"
- _getenv_bool: same guard so True-defaulted flags set to "" return
  the configured default instead of silently returning False
- embeddings.py: replace inline FORCE_CPU bool parse with _getenv_bool
2026-06-15 02:20:34 +00:00
flan de6804226d refactor: unify env var helpers and remove magic slice in cache
- Add _getenv_num as shared core for _getenv_int/_getenv_float
- Add _getenv_optional_float for FRIGATE_SCORE_CEILING (replaces 9-line inline block)
- Add _getenv_bool; replace 11 inline .lower()-in-("true","1","yes") sites
  across config.py, jobs.py, and cli.py with single call site
- _resolve_strategy no-embedding branch: inline try/except → _getenv_int("LIMIT", 30)
- cache.py: final[:-4] → final.removesuffix(".npy") — assumption is now explicit
2026-06-15 02:10:00 +00:00
flan fe5e1574ac release: v0.5.15 — fix cache regression and structural cleanup
- cache.py: fix np.save extension bug from v0.5.13 — tmp path used
  final+".tmp" (abc.npy.tmp) but np.save auto-appends .npy to paths not
  ending in .npy, writing to abc.npy.tmp.npy instead; os.replace then
  raised FileNotFoundError silently, making every cache write a no-op
  and leaking *.npy.tmp.npy files. Fixed by inserting .tmp before .npy:
  tmp = final[:-4] + ".tmp.npy"

- config.py: remove str(default) round-trip in _getenv_int/_getenv_float
  — use raw = os.getenv(name); return default if raw is None else int(raw)
  so a future float default can't cause a spurious "not a valid integer"
  warning and return the wrong type

- executor.py: consolidate 4 progress.remove_task calls into one
  try/finally around the per-job body; continue inside try/finally
  executes the finally before the next iteration, making the invariant
  structurally enforced rather than relying on discipline across 4 sites
2026-06-15 01:45:43 +00:00
flan 5bcc5975bc release: v0.5.14 — graceful fallback for invalid numeric env vars
YEARS_FILTER, MIN_FACE_WIDTH, MIN_FACE_COUNT, MAX_AUTO_IMAGES,
BLUR_THRESHOLD, MIN_CONFIDENCE, and FACE_MARGIN used bare int()/float()
calls with no error handler. A typo (trailing space, non-numeric value)
raised ValueError inside __getattr__, producing a cryptic traceback on
the first config access rather than at the validate() step. Values are
now parsed by _getenv_int/_getenv_float helpers that warn and fall back
to the documented default, matching the existing FRIGATE_SCORE_CEILING
pattern.
2026-06-15 01:35:27 +00:00
flan 51f7ed3961 release: v0.5.13 — robustness fixes from full-project audit
- executor.py: wrap shutil.rmtree/os.makedirs in try/except OSError so a
  permission failure logs and skips the job rather than aborting the run
- cache.py: write embeddings to a .tmp file and atomically rename into place
  via os.replace so a process kill can't leave a corrupted .npy cache slot
- immich_api.py: guard fileCreatedAt with isinstance(str) check before calling
  .replace() so a non-string timestamp doesn't raise AttributeError and kill
  the entire filter_recent_assets pass
- upload_tracker.py: raise SQLite busy timeout from 5 s to 30 s to handle
  concurrent cron+manual run overlap without dropping upload-tracking records
2026-06-15 01:19:38 +00:00
flan 850ae2f9fc fix: remove progress task on skipped jobs (v0.5.12)
progress.add_task() fires unconditionally at the top of the job loop;
both continue paths (ValueError from _safe_person_dir and the symlink
TOCTOU guard) skipped remove_task(), leaving orphaned 0% rows in the
terminal for the rest of the run.
2026-06-15 01:05:49 +00:00
flan 796aded2da fix: log+skip on symlink TOCTOU in execute_jobs (v0.5.11)
The v0.5.10 compound guard 'isdir and not islink' silently skipped the
rmtree when person_dir was a symlink-to-directory, then let makedirs
follow the symlink — allowing crop writes outside output_dir with no
diagnostic. Replace with an explicit islink pre-check that logs an error
and continues, matching the ValueError path from _safe_person_dir.
2026-06-15 01:02:28 +00:00
flan 480bf80534 fix: reconcile < target severity and rmtree symlink guard (v0.5.10)
- reconcile.py: re-escalate the < target branch from INFO to WARNING and
  add 'permanently unmapped' label. Both post-loop branches produce identical
  permanent mapping loss; v0.5.9 incorrectly treated the timeout case as
  recoverable.

- executor.py: guard shutil.rmtree with 'not os.path.islink(person_dir)'
  so a race-replaced symlink-to-directory is skipped rather than raising
  an unhandled OSError that aborts all remaining jobs. Correct comment:
  rmtree raises OSError, not NotADirectoryError.
2026-06-15 00:58:22 +00:00
flan 2d39291fe7 fix: correct reconcile log severity, docstring gaps, and _entry allocation (v0.5.9)
- reconcile.py: swap log levels — external-upload path (permanent mapping
  loss) escalated to WARNING; timeout path (transient, retries next cycle)
  downgraded to INFO. Also extend the warning message to note the files are
  permanently unmapped.

- immich_api.py: extend fetch_all_assets docstring to document that
  all-garbage page termination (in addition to network errors) makes
  total_raw a lower bound.

- executor.py: add comment above shutil.rmtree noting that POSIX rmtree
  raises NotADirectoryError on a top-level symlink, documenting why the
  removed islink guard is safe to omit.

- upload_tracker.py: replace setdefault with explicit guard in _entry() —
  setdefault evaluates its default-dict argument before checking key
  presence, allocating and discarding a dict on every already-present call.
2026-06-15 00:51:21 +00:00
flan 1556d90bcc fix: correct path traversal docstring, total_raw inflation, and config re-stat (v0.5.8)
- executor.py: fix _safe_person_dir docstring — realpath+startswith is the
  load-bearing traversal guard; islink is a supplementary early-exit for the
  symlink sub-case only. The previous comment "checking after realpath would be
  too late" implied islink was the primary guard, which is backwards.

- immich_api.py: move total_raw accumulation to after the dead-end-page break
  so all-garbage pages don't inflate the count and produce misleading
  "N total, 0 recent" output. Mixed pages (some valid, some non-dict) still
  count page_count so transient schema issues don't shrink MIN_FACE_COUNT below
  threshold. Add warning when a RequestException interrupts pagination mid-way
  so operators know total_raw is a lower bound.

- config.py: eliminate residual TOCTOU — change `if config_file.exists():` to
  `if _data_cfg_exists or config_file.exists():` so _data_cfg is never
  stat'd twice (the v0.5.7 fix cached the first check but not the second).
2026-06-15 00:41:00 +00:00
flan 00e378b375 Merge remote-tracking branch 'origin/dev' 2026-06-15 00:27:25 +00:00
flan 9685c310af fix: symlink guard placement, fetch_all_assets raw count, config TOCTOU (#31)
executor.py:
- Move islink check into _safe_person_dir on the raw path, before realpath
  resolves it; the previous check at the rmtree site was unreachable dead code
  because realpath already followed any symlink

immich_api.py / jobs.py:
- fetch_all_assets now returns (assets, total_raw) where total_raw is the
  item count seen before non-dict filtering; callers use it for MIN_FACE_COUNT
  guard and display so transient non-dict API items can't incorrectly skip people
- Add WARNING when pagination stops because a page had items but all were non-dict

config.py:
- Cache _data_cfg.exists() in _data_cfg_exists so the dual-config warning
  and config_file selection always read from the same stat() result; previously
  two calls created a TOCTOU window where log and code could disagree

Bump version to 0.5.7
2026-06-14 20:27:16 -04:00
flan e5b9293861 Merge remote-tracking branch 'origin/dev' 2026-06-15 00:16:33 +00:00
flan 363190dbe5 fix: pagination runaway, double iteration, and reconciliation efficiency (#30)
immich_api.py:
- Move empty-page break after non-dict filtering — a page of all-null
  items no longer loops to MAX_PAGES without terminating
- Single-pass partition replaces two inverse isinstance scans per page
- Upgrade non-dict item log from DEBUG to WARNING (silent asset loss)

reconcile.py:
- Check Frigate before the first sleep so fast responses return
  immediately rather than always paying a 1 s delay
- Compute set difference once per poll iteration instead of twice

Bump version to 0.5.6
2026-06-14 20:16:27 -04:00
flan 78e01d9621 Merge remote-tracking branch 'origin/dev' 2026-06-15 00:04:29 +00:00
flan 3a052db1ce chore: quality cleanup — extract magic numbers, improve docs and naming (#29)
- diversity.py: extract 3000/20/32 to _POOL_CAP/_POOL_SCALE/_EMBEDDING_BATCH_SIZE
- reconcile.py: extract (1,2,4,8) poll delays to _RECONCILE_POLL_DELAYS with comment
- immich_api.py: document dual response shape; debug-log skipped non-dict items
- frigate_api.py: rename `encoded` → `encoded_name` for clarity
- upload_tracker.py: atomic-write note on record_frigate_files_batch docstring;
  get_person_summary() uses setdefault to eliminate four repeated default dicts;
  _VALID_SCORE_COLS comment explains SQL-injection guard intent

Bump version to 0.5.5
2026-06-14 20:04:24 -04:00
flan df98169398 Merge pull request #28 from sudolulo/dev
release: 0.5.4
2026-06-14 19:53:48 -04:00
flan 84ebd91929 fix: quality replacement slot floor uses deleted file's score not failed candidate's (#27)
* fix: quality replacement slot floor uses deleted file's score not failed candidate's

When a blur-score replacement deletes a low-quality Frigate file but the
subsequent upload fails, min_quality_score_for_slot was set to candidate_score
(the good file that failed to upload). This filtered out any subsequent
candidate that didn't beat the failed upload, even if it was better than
the file we just deleted — leaving the freed slot unfilled unnecessarily.

The comment on the guard already documented the correct intent: 'require
the next candidate to beat the deleted file's score'. Fix: use target_score
(the deleted file's blur score) as the floor instead of candidate_score.

* chore: bump version to 0.5.4
2026-06-14 19:53:33 -04:00
flan c77069f2e2 Merge pull request #26 from sudolulo/dev
release: 0.5.3
2026-06-14 19:39:51 -04:00
flan 2e08504682 fix: audit hardening — input validation, error handling, and robustness (#25)
* fix: audit hardening — input validation, error handling, and robustness

- immich_api: guard person["id"] with .get() + early return on missing field
- immich_api: include page number in pagination exception log
- immich_api: validate faces response is a list before indexing
- executor: wrap Image.open() in try/except for non-image HTTP responses
- executor: strip leading 'v' from Frigate version before parsing (v0.16.0 was misread)
- config: wrap FRIGATE_SCORE_CEILING float() parse in try/except with warning
- config: warn when both DATA_DIR and legacy CWD config files exist simultaneously
- scheduler: wrap PID file write in try/except so /tmp failures don't crash startup
- scheduler: clamp sleep to 60s max to bound recovery time after NTP clock jumps
- frigate_api: log unexpected non-list type in get_frigate_person_files at DEBUG

* fix: LIMIT env var crash and symlink guard on person output dir

- jobs: wrap int(LIMIT) parse in try/except — bad value (e.g. "30.5", "all")
  now logs a warning and falls back to the default instead of crashing
- executor: check for symlink before shutil.rmtree on person_dir — prevents
  following a symlink out of OUTPUT_DIR on a shared volume

* chore: bump version to 0.5.3
2026-06-14 19:39:35 -04:00
flan 8bf23edc85 Merge pull request #24 from sudolulo/dev
release: 0.5.2
2026-06-14 19:17:24 -04:00
flan 166729a17d Merge remote-tracking branch 'origin/main' into dev
# Conflicts:
#	CHANGELOG.md
#	pyproject.toml
#	uv.lock
2026-06-14 23:17:16 +00:00
flanandgithub-actions[bot] 8acf8b52b8 fix: Immich v2.7.5 compat, supply-chain hardening, and quality fixes (0.5.2) (#23)
* fix: Immich v2.7.5 compat, supply-chain hardening, and quality fixes (0.5.2)

- Remove assetCount pre-filter broken by Immich v2.7.5 API change; check
  MIN_FACE_COUNT after fetch_all_assets instead
- Replace curl|sh uv installer with COPY --from Docker stage (supply chain)
- Fix HEALTHCHECK to use kill -0 on PID file instead of static file test
- Fix CONFIG_FILE path to resolve inside DATA_DIR for volume persistence
- Fix EmbeddingCache singleton to re-init when cache_dir changes
- Fix fd leak in _suppress_output() with nested finally closes
- Fix silent exception on SQLite connection close in upload_tracker
- Log unexpected Frigate API keys at DEBUG in get_all_frigate_person_files
- Add reconcile FIFO-mapping debug log
- Pin all CI action SHAs; update setup-uv v8.2.0, upload/download-artifact,
  ruff-action v4.0.0

* chore: update lockfile

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-14 19:15:20 -04:00
flanandgithub-actions[bot] e795a42e20 refactor: rename CACHE_DIR → DATA_DIR, container path .if_cache → data (#21) (#22)
* refactor: rename CACHE_DIR to DATA_DIR, default path .if_cache → data

CACHE_DIR held both the embedding cache and the SQLite tracker DB, making
the name misleading. DATA_DIR is more accurate.

- Config reads DATA_DIR first; falls back to CACHE_DIR with a deprecation
  warning so existing setups don't break on upgrade
- Default local path: data (was .if_cache)
- Docker default path: /app/data (was /app/.if_cache)
- Internal references (embeddings.py, upload_tracker.py) updated to DATA_DIR
- compose.yml, .env.example, README, wiki, and changelog updated
- Version bumped to 0.5.1

* chore: update lockfile

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-14 18:00:08 -04:00
flanandgithub-actions[bot] d99d607fc8 refactor: rename CACHE_DIR → DATA_DIR, container path .if_cache → data (#21)
* refactor: rename CACHE_DIR to DATA_DIR, default path .if_cache → data

CACHE_DIR held both the embedding cache and the SQLite tracker DB, making
the name misleading. DATA_DIR is more accurate.

- Config reads DATA_DIR first; falls back to CACHE_DIR with a deprecation
  warning so existing setups don't break on upgrade
- Default local path: data (was .if_cache)
- Docker default path: /app/data (was /app/.if_cache)
- Internal references (embeddings.py, upload_tracker.py) updated to DATA_DIR
- compose.yml, .env.example, README, wiki, and changelog updated
- Version bumped to 0.5.1

* chore: update lockfile

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-14 17:59:48 -04:00
flan d30f2956e0 Merge pull request #20 from sudolulo/docs/sync-readme-to-main
docs: sync README accuracy fixes to main
2026-06-14 17:45:11 -04:00
flan 8f30379262 Merge pull request #19 from sudolulo/fix/readme-accuracy
fix: README accuracy for 0.5.0
2026-06-14 17:43:40 -04:00
flan 6c23755654 fix: README accuracy — remove object-mode reference, correct :latest arch to amd64-only 2026-06-14 21:42:33 +00:00
flan 98734d071a Merge pull request #18 from sudolulo/release/workflow-fix
fix: release workflow for main (0.5.0 Docker builds)
2026-06-14 17:30:31 -04:00
flan a77b7b1cc8 Merge pull request #17 from sudolulo/fix/release-workflow-and-docs
fix: release workflow and docs for 0.5.0
2026-06-14 17:25:36 -04:00
flan 4e0e8032ef fix: update release workflow and docs for 0.5.0 single-lockfile refactor
- release.yml: replace per-variant lockfile generation loop with a
  single 'uv lock'; add idempotent release creation (skip if tag
  already has a release so re-triggered runs don't 422)
- docker-publish.yml: remove stale uv-cpu/rocm/intel.lock entries
  from paths-ignore (those files no longer exist)
- README: CACHE_DIR description now names winnow_tracker.db; tracker
  description mentions SQLite
2026-06-14 21:24:06 +00:00
flan 08623088f3 Merge pull request #16 from sudolulo/dev
release: winnow 0.5.0
2026-06-14 17:18:11 -04:00
flan edd22407e9 chore: merge main into dev, resolve lockfile consolidation conflicts 2026-06-14 21:16:29 +00:00
flan d6cb9c6ab8 Merge pull request #15 from sudolulo/release/0.5.0
chore: release 0.5.0
2026-06-14 17:03:23 -04:00
github-actions[bot] fa4fc8984c chore: update lockfile 2026-06-14 21:02:02 +00:00
flan a4571f59f6 chore: release 0.5.0 — version bump, changelog, clean up .env.example 2026-06-14 21:01:41 +00:00
flan 51ea7bd43c Merge pull request #14 from sudolulo/refactor/code-quality
refactor: collapse Config proxy, SQLite tracker, split reconcile, consolidate pyproject
2026-06-14 16:58:52 -04:00
flan 2a2c6b0c47 Merge remote-tracking branch 'origin/dev' into refactor/code-quality 2026-06-14 20:57:31 +00:00
flan 043ebf85d7 fix: prevent silent reject-ID data loss on partial migration rename
Two fixes found during post-refactor audit:

1. upload_tracker: remove COUNT(*) guard from _maybe_migrate. The guard
   blocked re-migration when a previous run successfully committed both
   JSON files but a PermissionError on the second rename() left it on
   disk. On the next startup COUNT > 0 → early return → rejected IDs
   permanently unimported. INSERT OR IGNORE is idempotent so re-running
   migration is always safe; guard not needed.

   Also wrap each rename() in its own try/except so a failure on one
   file is logged and does not propagate uncaught.

2. reconcile: break early when new_count > target is detected in the
   poll loop. Previously the loop ran all four delay intervals (1+2+4+8s)
   before the post-loop > target branch fired, wasting up to 15 seconds
   when a concurrent external upload was visible on the first poll.
2026-06-14 20:54:39 +00:00
flan 886b51fdce Revert "chore: update example URLs to local instance addresses"
This reverts commit caa916a508.
2026-06-14 20:40:45 +00:00
flan caa916a508 chore: update example URLs to local instance addresses 2026-06-14 20:40:27 +00:00
flan cabdb9c0eb fix: address 9 code review findings
- upload_tracker: partial migration now rolls back atomically on failure;
  JSON renamed only after successful commit so failed runs retry cleanly
- upload_tracker: allowlist score_col in _pick_mapped_file to close
  latent SQL injection surface
- config: move load_dotenv() from module import into _load() so no I/O
  at import time and reset() fully resets env loading
- config: use is None checks for IMMICH_URL/OUTPUT_DIR config-file
  fallback so explicitly empty env vars are not overridden by the file
- executor: skip reconcile when Frigate API is unreachable at upload
  start — tracker baseline is incomplete and would mis-trigger the
  external-upload guard, permanently losing file mappings
- reconcile: change polling break condition from >= to == target so
  transient overshoots don't prematurely exit the loop and trigger
  the external-upload guard
- jobs: apply capacity cap as the selection limit rather than truncating
  post-selection by position, so the diversity algorithm works within
  the right budget from the start
- Dockerfile: explicit gpu branch + exit 1 on unknown VARIANT instead
  of silent fallback
2026-06-14 20:37:51 +00:00
flan 71f1924f1a fix: restore original dedup/prompt order in jobs.py, move time import to module level in reconcile.py
_build_job no longer calls filter_already_uploaded internally; callers pass
pre-filtered assets so there's no double DB hit and the interactive path
restores the original prompt order (retry_rejected asked before strategy,
so post-dedup count informs the choice). Skip-count rprint restored in
auto_configure. Late 'import time' inside reconcile_frigate_mappings moved
to module level.
2026-06-14 20:14:55 +00:00
github-actions[bot] 343cb2ad0f chore: update lockfile 2026-06-14 20:04:44 +00:00
flan 02c56493f6 fix: add platform markers to GPU extras, use --extra cpu in CI, simplify lockfile workflow
- rocm/intel/gpu extras are x86_64-only; aarch64 wheels don't exist so uv
  failed to resolve them when required-environments includes aarch64
- test.yml: switch from --all-extras (broken by conflicts + missing wheels)
  to --extra cpu which is cross-platform and sufficient for unit tests
- update-lockfile.yml: drop old file-swap loop; single pyproject means a
  single uv lock run and a single uv.lock to commit
2026-06-14 20:04:25 +00:00
flan e2a1924fb0 refactor: collapse Config proxy, migrate tracker to SQLite, split reconcile module
- Config: remove _ConfigAccessor and ConfigManager; use __getattr__ for lazy
  loading on single _Config class; re-register self as _instance in __getattr__
  so reset() always clears the correct object (item 1)
- upload_tracker: replace hand-rolled JSON store with sqlite3; auto-migrates
  existing JSON on first run; remove dead record_frigate_file function;
  connection re-opens when CACHE_DIR changes for test isolation (items 2, 8)
- diversity: move ThreadPoolExecutor import to module level; inject optional
  fetch_fn parameter for testability (items 3, 6)
- pyproject: consolidate 4 variant files into extras (gpu/rocm/intel/cpu);
  update Dockerfile to use --extra flag; delete variant pyproject/lock files;
  uv.lock needs regen with `uv lock` after this change (item 4)
- jobs: extract _build_job helper to separate business logic from terminal I/O;
  auto_configure delegates dedup/selection to _build_job (item 5)
- logging: convert f-string log calls to % interpolation throughout all winnow/
  modules (item 7)
- reconcile: new module with reconcile_frigate_mappings and
  enrich_asset_with_face_data extracted from executor.py (item 9)
- scheduler: print next scheduled run time after startup and after each run;
  fix f-string logger.error call (item 10)
2026-06-14 19:59:17 +00:00
flan 321b6c66e1 Merge pull request #13 from sudolulo/feature/bump-ubuntu-26-04
build: bump amd64 rocm and cpu bases to Ubuntu 26.04
2026-06-14 15:38:00 -04:00
flan 237dd2091b build: bump amd64 gpu base to NVIDIA CUDA ubuntu24.04
Highest Ubuntu version NVIDIA currently publishes for CUDA 12.8.1.
26.04 not yet available from NVIDIA's image registry.
2026-06-14 19:17:13 +00:00
flan e7bfe00d5d build: bump amd64 rocm and cpu bases to Ubuntu 26.04
22.04 non-GPU amd64 bases were never updated when arm64 moved to 24.04.
Python 3.13 is still pulled from deadsnakes PPA (26.04 ships 3.14 natively).

intel stays on 22.04: the Intel GPU repo URL is pinned to the "jammy"
codename and cannot be bumped until Intel publishes 26.04 packages.
2026-06-14 18:55:50 +00:00
flan ad1fbd4c2a Merge pull request #12 from sudolulo/feature/document-limitations
docs+fix: annotate limitations; fix cache invalidation and version checks
2026-06-14 14:54:48 -04:00
flan 28424b0f16 fix: implement fixable limitations from annotation pass
cache.py — model fingerprint auto-invalidation:
  Replace hardcoded "buffalo_l_v1" version string with a fingerprint
  derived from buffalo_l .onnx file sizes and mtimes. EmbeddingCache now
  computes this at init time; stale embeddings from replaced or updated
  model files are automatically invalidated. Falls back to the static
  string before the model is downloaded.
  Note: existing caches built against the old key will miss on the first
  run after upgrade and recompute cleanly.

frigate_api.py — Frigate version check:
  Add get_frigate_version() (GET /api/version). Called at the start of
  upload_to_frigate(); warns if below v0.16 where the face training API
  endpoints don't exist.

immich_api.py + cli.py — Immich version check:
  Add get_immich_version() (GET /api/server/version). Called at startup
  before get_people(); warns if below v1.106 where the face data and
  merge APIs winnow depends on aren't guaranteed present.

Remaining TODO(frigate-api) annotations are left in place — they require
Frigate to expose per-file embeddings or a rebuild-complete signal before
they can be addressed.
2026-06-14 18:48:07 +00:00
flan 1d44df6e96 docs: annotate known limitations and Frigate API improvement hooks
Adds inline LIMITATION / TODO(frigate-api) comments at each specific
code site rather than a separate doc that would drift from the code.

frigate_api.py — recognize_face:
  Mean-embedding limitation: score reflects the arithmetic mean of all
  training embeddings. A bimodal set (frontals + profiles) has a mean
  between clusters, making both ends look more novel than they are.
  Fixable if Frigate exposes per-file embeddings for nearest-neighbour
  comparison.

frigate_api.py — get_all_frigate_person_files:
  "train" key exclusion is a hardcoded string. If Frigate adds other
  special top-level keys in /api/faces they'll be silently treated as
  person names. Needs a typed schema when Frigate documents the contract.

executor.py — recognize_face call site:
  Async rebuild: each deletion triggers a background model rebuild in
  Frigate. Subsequent recognize calls in the same run return None
  (rebuild in progress), degrading quality replacement for later
  candidates. Fixable with a rebuild-complete signal from Frigate.

executor.py — effective_count / manual file handling:
  Manually-added files are invisible to diversity decisions. Winnow
  observes their effect only indirectly via the Frigate score, not by
  measuring their embedding distribution. Per-file embeddings from
  Frigate would allow direct diversity measurement against the full set.

executor.py — Frigate version assumption:
  All face training endpoints are v0.16+. No version check at startup;
  failures on older versions are opaque 404s.

cache.py — MODEL_VERSIONS:
  Version string is a hardcoded constant. Manual model file replacement
  (custom weights, InsightFace update) won't invalidate cached embeddings.
  Needs file-checksum-derived versioning or a CLEAR_EMBEDDING_CACHE flag.

diversity.py — thumbnail-resolution embeddings:
  Diversity selection runs InsightFace on preview thumbnails; the actual
  training crop comes from full-resolution originals. Negligible in
  practice but degrades if Immich preview quality is low.
2026-06-14 18:41:37 +00:00
flan 4147dbec1c test: add diversity algorithm tests (60 → 93) (#11)
Tests cover the core ML pipeline algorithms in diversity.py — previously
untested. No network or model dependencies; all pure-function or
numpy-only paths:

- Face bbox and confidence extraction from Immich metadata, including
  person_id filtering and missing-data edge cases
- Face crop scaling: verifies bbox coordinates are correctly scaled when
  the thumbnail dimensions differ from the metadata image dimensions
- Near-duplicate dedup: removal below cosine threshold, quality-score
  preference between duplicates, zero-quality-score treated as zero not
  missing (falsy bug guard)
- K-Medoids: correct medoid count, distinctness, valid index range, and
  full-N edge case
- Adaptive threshold: positive output, floor at 0.05 for identical
  embeddings, single-point, scales with embedding spread
- Time-spread fallback: exact count, all-under-limit passthrough,
  auto→30 default, first/last inclusion
- Cluster-aware selection: exact limit, subset invariant, auto-stop on
  tight cluster, hard-example confidence weighting accepted
2026-06-14 14:33:38 -04:00
github-actions[bot] 4b63aafb0e chore: update lockfiles 2026-06-14 18:06:15 +00:00
flan 588a5b2af8 Merge branch 'main' of github.com:sudolulo/winnow 2026-06-14 18:05:14 +00:00
flan 3e030b361e Update README.md 2026-06-14 13:15:57 -04:00
flan 6ec075b9bf Update README.md 2026-06-14 13:15:20 -04:00
35 changed files with 1736 additions and 6808 deletions
+8 -12
View File
@@ -8,19 +8,16 @@ FRIGATE_URL=http://192.168.1.10:5000
# Set AUTO_MODE=true to force auto mode even in an interactive terminal.
# AUTO_MODE=true
# VERBOSE=true # Enable DEBUG-level console output (log file is always DEBUG)
# TRAINING_MODE: face = upload to Frigate face recognition API
# object = save crops to output dir for manual Frigate placement
TRAINING_MODE=face
# STRATEGY: auto = objective diversity (recommended), standard = 30 imgs, broad = 100 imgs
STRATEGY=auto
# STRATEGY: adaptive = embedding diversity (recommended), standard = 30 imgs, broad = 100 imgs
STRATEGY=adaptive
# LIMIT=50 # Custom image count; overrides STRATEGY preset
# OBJECT_CLASS=dog # Object label for object mode (e.g. dog, cat, car)
# ── People Filtering ──────────────────────────────────────────────────────────
# ONLY_PEOPLE=John,Jane # Comma-separated; process only these people
# SKIP_PEOPLE=Unknown # Comma-separated; skip these people
# MIN_FACE_COUNT=5 # Skip people with fewer than N assets in Immich
# MIN_FACE_COUNT=3 # Skip people with fewer than N assets in Immich (default: 3)
# YEARS_FILTER=10 # Only include images from the last N years (default: 10)
# MERGE_DUPLICATE_PEOPLE=false # Merge duplicate Immich person records permanently (default: false — warn and skip)
# ── Image Quality ─────────────────────────────────────────────────────────────
# MIN_FACE_WIDTH=90 # Minimum face width in pixels (default: 90, guarantees ≥8,100px crop)
@@ -29,22 +26,21 @@ STRATEGY=auto
# USE_FULL_RESOLUTION=true # Use full-res images vs thumbnails (default: true)
# MIN_CONFIDENCE=0.7 # Minimum face detection confidence (default: 0.7)
# BLUR_THRESHOLD=120.0 # Laplacian blur threshold; lower = accept more blur (default: 120.0)
# MAX_AUTO_IMAGES=80 # Hard cap on auto-diversity selection (default: 80)
# MAX_AUTO_IMAGES=20 # Hard cap on auto-diversity selection (default: 20)
# QUALITY_REPLACEMENT=true # At cap, replace a weaker tracked image with a better candidate (default: true)
# FRIGATE_SCORE_CEILING=0.0 # Skip uploads already well-covered (pre-upload score > ceiling = redundant; 0 = disabled; requires at least one prior run)
# FRIGATE_SCORE_CEILING= # Below-cap novelty gate: unset = dynamic (default), 0 = disabled, e.g. 0.85 = fixed ceiling
# ENABLE_FRIGATE_SCORES=true # Call Frigate's recognize endpoint pre-upload to store diversity scores (default: true; adds ~200ms per upload)
# ── Caching & Models ──────────────────────────────────────────────────────────
# FORCE_CPU=true # Disable GPU, fall back to CPU
# ENABLE_CACHE=false # Disable embedding cache (default: true)
CACHE_DIR=/app/.if_cache
HF_HOME=/models/huggingface
DATA_DIR=/app/data
INSIGHTFACE_HOME=/models/.insightface
# ── Tracker overrides (one-shot — remove after use) ───────────────────────────
# DRY_RUN=true # Preview selection without downloading/uploading
# RETRY_REJECTED=true # Re-attempt previously rejected images
# RESET_PERSON=John # Clear uploaded+rejected history for one person
# RESET_PERSON=John # Clear uploaded+rejected history for one person (use * for all)
# ── Scheduling ────────────────────────────────────────────────────────────────
# CRON_SCHEDULE controls container lifetime:
+21 -24
View File
@@ -12,9 +12,6 @@ on:
- ".github/workflows/lint.yml"
- ".github/dependabot.yml"
- "uv.lock"
- "uv-cpu.lock"
- "uv-rocm.lock"
- "uv-intel.lock"
workflow_call:
inputs:
tag:
@@ -57,15 +54,15 @@ jobs:
df -h
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ inputs.tag || github.ref }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Log in to GHCR
uses: docker/login-action@v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -82,7 +79,7 @@ jobs:
- name: Build and push by digest
id: build
uses: docker/build-push-action@v7
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: ./Dockerfile
@@ -100,7 +97,7 @@ jobs:
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: digest-amd64
path: /tmp/digests/*
@@ -117,17 +114,17 @@ jobs:
steps:
- name: Download digests
uses: actions/download-artifact@v4
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: /tmp/digests
pattern: digest-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Log in to GHCR
uses: docker/login-action@v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -181,18 +178,18 @@ jobs:
df -h
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ inputs.tag || github.ref }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Log in to GHCR
uses: docker/login-action@v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -225,7 +222,7 @@ jobs:
fi
- name: Build and push CPU image
uses: docker/build-push-action@v7
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: ./Dockerfile
@@ -267,15 +264,15 @@ jobs:
df -h
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ inputs.tag || github.ref }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Log in to GHCR
uses: docker/login-action@v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -308,7 +305,7 @@ jobs:
fi
- name: Build and push ROCm image
uses: docker/build-push-action@v7
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: ./Dockerfile
@@ -350,15 +347,15 @@ jobs:
df -h
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ inputs.tag || github.ref }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Log in to GHCR
uses: docker/login-action@v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -391,7 +388,7 @@ jobs:
fi
- name: Build and push Intel image
uses: docker/build-push-action@v7
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: ./Dockerfile
+2 -2
View File
@@ -23,10 +23,10 @@ jobs:
echo "Disk space freed."
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Run Ruff
uses: astral-sh/ruff-action@v3
uses: astral-sh/ruff-action@0ce1b0bf8b818ef400413f810f8a11cdbda0034b # v4.0.0
with:
args: "check"
+15 -14
View File
@@ -32,12 +32,12 @@ jobs:
echo "Disk space freed."
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Install uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
- name: Set up Python
run: uv python install 3.13
@@ -50,17 +50,8 @@ jobs:
exit 1
fi
- name: Ensure lockfiles are current
run: |
cp pyproject.toml _pyproject_orig.toml
for variant in cpu rocm intel; do
cp pyproject-${variant}.toml pyproject.toml
uv lock
cp uv.lock uv-${variant}.lock
done
cp _pyproject_orig.toml pyproject.toml
uv lock
rm _pyproject_orig.toml
- name: Ensure lockfile is current
run: uv lock
- name: Resolve tag name
id: tag
@@ -109,7 +100,7 @@ jobs:
fi
- name: Create GitHub Release
uses: actions/github-script@v9
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
RELEASE_TAG: ${{ steps.tag.outputs.TAG }}
RELEASE_NOTES: ${{ steps.changelog.outputs.NOTES }}
@@ -117,6 +108,15 @@ jobs:
script: |
const tag = process.env.RELEASE_TAG;
const notes = (process.env.RELEASE_NOTES || '').trim();
try {
const existing = await github.rest.repos.getReleaseByTag({
owner: context.repo.owner,
repo: context.repo.repo,
tag: tag,
});
console.log(`Release ${tag} already exists (id ${existing.data.id}), skipping creation.`);
} catch (err) {
if (err.status !== 404) throw err;
await github.rest.repos.createRelease({
owner: context.repo.owner,
repo: context.repo.repo,
@@ -126,6 +126,7 @@ jobs:
draft: false,
prerelease: false,
});
}
build-images:
name: Build and push Docker images
+3 -3
View File
@@ -13,16 +13,16 @@ jobs:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Install uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
- name: Set up Python
run: uv python install 3.13
- name: Install dependencies
run: uv sync --all-extras
run: uv sync --extra cpu
- name: Run tests
run: uv run pytest
+9 -29
View File
@@ -1,5 +1,5 @@
# .github/workflows/update-lockfile.yml
name: Update lockfiles
name: Update lockfile
on:
push:
@@ -7,9 +7,6 @@ on:
- '**'
paths:
- 'pyproject.toml'
- 'pyproject-cpu.toml'
- 'pyproject-rocm.toml'
- 'pyproject-intel.toml'
workflow_dispatch:
jobs:
@@ -18,49 +15,32 @@ jobs:
permissions:
contents: write
steps:
- name: Free up disk space
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc
sudo rm -rf "/usr/local/share/boost"
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
echo "Disk space freed."
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Install uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
- name: Set up Python
run: uv python install 3.13
- name: Regenerate all lockfiles
run: |
cp pyproject.toml _pyproject_orig.toml
for variant in cpu rocm intel; do
cp pyproject-${variant}.toml pyproject.toml
uv lock
cp uv.lock uv-${variant}.lock
done
cp _pyproject_orig.toml pyproject.toml
uv lock
rm _pyproject_orig.toml
- name: Regenerate lockfile
run: uv lock
- name: Check for changes
id: diff
run: |
if git diff --quiet uv.lock uv-cpu.lock uv-rocm.lock uv-intel.lock; then
if git diff --quiet uv.lock; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Commit and push updated lockfiles
- name: Commit and push updated lockfile
if: steps.diff.outputs.changed == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add uv.lock uv-cpu.lock uv-rocm.lock uv-intel.lock
git commit -m "chore: update lockfiles"
git add uv.lock
git commit -m "chore: update lockfile"
git push
+94
View File
@@ -7,6 +7,100 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [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
+23 -19
View File
@@ -1,16 +1,17 @@
# ── Base images ───────────────────────────────────────────────────────────────
# amd64 + gpu: NVIDIA CUDA 12.8 + cuDNN (GPU acceleration via NVIDIA Container Toolkit)
# amd64 + rocm: Ubuntu 22.04 (AMD GPU via ROCm — pass /dev/kfd and /dev/dri)
# amd64 + gpu: NVIDIA CUDA 12.8 + cuDNN on Ubuntu 24.04 (highest Ubuntu NVIDIA publishes)
# amd64 + rocm: Ubuntu 26.04 (AMD GPU via ROCm — pass /dev/kfd and /dev/dri)
# amd64 + intel: Ubuntu 22.04 (Intel Arc / iGPU via OpenVINO — pass /dev/dri)
# amd64 + cpu: Ubuntu 22.04 (CPU-only, ~2 GB smaller image)
# Note: intel stays on 22.04 — Intel's GPU repo only publishes for jammy
# amd64 + cpu: Ubuntu 26.04 (CPU-only, ~2 GB smaller image)
# arm64: Ubuntu 24.04 (CPU-only; no CUDA/ROCm wheels on ARM)
ARG VARIANT=gpu
FROM --platform=$BUILDPLATFORM nvidia/cuda:12.8.1-cudnn-runtime-ubuntu22.04 AS base-amd64-gpu
FROM ubuntu:22.04 AS base-amd64-rocm
FROM --platform=$BUILDPLATFORM nvidia/cuda:12.8.1-cudnn-runtime-ubuntu24.04 AS base-amd64-gpu
FROM ubuntu:26.04 AS base-amd64-rocm
FROM ubuntu:22.04 AS base-amd64-intel
FROM ubuntu:22.04 AS base-amd64-cpu
FROM ubuntu:26.04 AS base-amd64-cpu
FROM ubuntu:24.04 AS base-arm64-gpu
FROM ubuntu:24.04 AS base-arm64-rocm
FROM ubuntu:24.04 AS base-arm64-intel
@@ -24,8 +25,9 @@ FROM base-${TARGETARCH}-${VARIANT} AS build
ARG VARIANT=gpu
ENV DEBIAN_FRONTEND=noninteractive
# Both Ubuntu 22.04 and 24.04 get Python 3.13 from the deadsnakes PPA.
# GNUPGHOME is isolated so gpg never contacts an agent socket under QEMU.
# All base images get Python 3.13 from the deadsnakes PPA (26.04 ships 3.14 natively;
# 3.13 is used to keep dependencies tested and aligned). GNUPGHOME is isolated
# so gpg never contacts an agent socket under QEMU.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl gnupg software-properties-common \
&& GNUPGHOME=$(mktemp -d) add-apt-repository ppa:deadsnakes/ppa -y \
@@ -36,23 +38,24 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& rm -rf /var/lib/apt/lists/* \
&& ln -sf /usr/bin/python3.13 /usr/bin/python3
RUN curl -LsSf https://astral.sh/uv/install.sh | sh \
&& cp /root/.local/bin/uv /usr/local/bin/uv
COPY --from=ghcr.io/astral-sh/uv:0.11.21 /uv /usr/local/bin/uv
WORKDIR /app
# Swap in the variant-specific pyproject and lockfile before syncing.
COPY pyproject.toml uv.lock pyproject-cpu.toml uv-cpu.lock \
pyproject-rocm.toml uv-rocm.lock pyproject-intel.toml uv-intel.lock ./
COPY pyproject.toml uv.lock ./
RUN if [ "$VARIANT" = "cpu" ]; then \
cp pyproject-cpu.toml pyproject.toml && cp uv-cpu.lock uv.lock; \
uv sync --frozen --no-dev --extra cpu; \
elif [ "$VARIANT" = "rocm" ]; then \
cp pyproject-rocm.toml pyproject.toml && cp uv-rocm.lock uv.lock; \
uv sync --frozen --no-dev --extra rocm; \
elif [ "$VARIANT" = "intel" ]; then \
cp pyproject-intel.toml pyproject.toml && cp uv-intel.lock uv.lock; \
uv sync --frozen --no-dev --extra intel; \
elif [ "$VARIANT" = "gpu" ]; then \
uv sync --frozen --no-dev --extra gpu; \
else \
echo "Unknown VARIANT: '$VARIANT'. Must be one of: cpu, rocm, intel, gpu" >&2; \
exit 1; \
fi && \
uv sync --frozen --no-dev \
&& uv cache clean
uv cache clean
COPY winnow/ winnow/
COPY entrypoint.sh scheduler.py ./
@@ -126,5 +129,6 @@ USER appuser
# the dist-info. Explicitly adding /app lets Python find winnow/__init__.py there.
ENV INSIGHTFACE_HOME=/models/.insightface PYTHONPATH=/app
HEALTHCHECK CMD test -f /app/entrypoint.sh || exit 1
HEALTHCHECK --interval=60s --timeout=5s --start-period=120s --retries=3 \
CMD sh -c 'if [ -f /tmp/winnow.pid ]; then kill -0 "$(cat /tmp/winnow.pid)"; fi'
ENTRYPOINT ["tini", "--", "/app/entrypoint.sh"]
+8 -11
View File
@@ -5,13 +5,13 @@
> **Note:** winnow's approach to training Frigate face recognition is not an officially documented workflow — results may vary.
> **Early Development — Use With Caution**
> winnow is functional but still maturing. Features that modify your Frigate training data — quality replacement, stale mapping cleanup — can remove images from your dataset and are not yet battle-tested at scale. Review the logs after each run and keep backups of your Frigate face training directory until you are confident in the results.
> winnow is in an unfinished state and maturing. Features that modify your Frigate training data — quality replacement, stale mapping cleanup — can remove images from your dataset and are not yet battle-tested at scale. Review the logs after each run and keep backups of your Frigate face training directory until you are confident in the results.
**Docs:** [Setup](https://github.com/sudolulo/winnow/wiki/Setup) · [Troubleshooting](https://github.com/sudolulo/winnow/wiki/Troubleshooting) · [FAQ](https://github.com/sudolulo/winnow/wiki/FAQ)
`winnow` pulls photos from your [Immich](https://immich.app) library, selects the most diverse and highest-quality subset using AI embeddings, and delivers them as training data for [Frigate](https://frigate.video)'s face recognition.
The best Frigate training data is images you curate manually — photos taken specifically for recognition, in controlled conditions, uploaded directly through Frigate's UI. For people you can do that for, do it. winnow is for everyone else: people in your library you want Frigate to recognise but don't have dedicated training photos for. It mines your existing Immich library for the most diverse spread of real-world appearances and fills the gap.
The best Frigate training data is images you curate manually — photos taken specifically for recognition, in controlled conditions, uploaded directly through Frigate's UI. Winnow is meant to supplement people in your library, not replace manual training. In some cases one has people they would like to recognize that do not occur in detections often enough to train a diverse dataset. This is meant to fill that gap.
> **winnow only touches files it uploaded.** Faces added to Frigate manually through its UI are never deleted, replaced, or modified — not by quality replacement, not by `RESET_PERSON`, not by stale cleanup. Your manually curated images are always the primary dataset; winnow only adds to it.
@@ -27,7 +27,7 @@ Immich library
│
▼
2. Filter by recency (YEARS_FILTER) and skip already-uploaded
and rejected assets (persistent tracker in CACHE_DIR)
and rejected assets (persistent tracker in DATA_DIR)
│
▼
3. Quality filter — download preview thumbnails and reject:
@@ -53,8 +53,7 @@ Immich library
• Hard example weighting — low-confidence detections get a distance boost
so unusual angles and harder looks are preferred over easy frontals
• Adaptive mode: stops when the next candidate is too similar to those already
selected (distance threshold = 20 % of median pairwise distance for
faces, 10 % for objects)
selected (distance threshold = 20 % of median pairwise distance)
│
▼
7. Download full-resolution originals from Immich
@@ -75,9 +74,7 @@ Immich library
↳ at cap + QUALITY_REPLACEMENT=false — skip this person
```
Uploaded and rejected asset IDs are persisted across runs. The same image is never processed twice; rejected assets are permanently skipped unless `RETRY_REJECTED=true`.
---
Uploaded and rejected asset IDs are persisted across runs in two JSON files (`frigate_uploaded_ids.json` and `frigate_rejected_ids.json` in `DATA_DIR`). The same image is never processed twice; rejected assets are permanently skipped unless `RETRY_REJECTED=true`.
---
@@ -87,7 +84,7 @@ Uploaded and rejected asset IDs are persisted across runs. The same image is nev
| Tag | Arch | Acceleration |
| :-- | :-- | :-- |
| `:latest` | amd64 + arm64 | NVIDIA CUDA 12.8 (amd64) · requires [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html) |
| `:latest` | amd64 | NVIDIA CUDA 12.8 · requires [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html) |
| `:rocm` | amd64 | AMD ROCm · pass `/dev/kfd` + `/dev/dri` |
| `:intel` | amd64 | Intel Arc / iGPU via OpenVINO · pass `/dev/dri`, set `OPENVINO_DEVICE=GPU` |
| `:cpu` | amd64 + arm64 | CPU only · ~2 GB smaller · no GPU required |
@@ -106,7 +103,7 @@ services:
- CRON_SCHEDULE=0 3 * * 0
volumes:
- /path/to/models:/models # INSIGHTFACE_HOME — persists Buffalo_L model (~300 MB)
- /path/to/cache:/app/.if_cache
- /path/to/data:/app/data
- /path/to/output:/app/frigate_train
deploy:
resources:
@@ -215,7 +212,7 @@ These defaults are tuned for Frigate's ArcFace requirements. winnow will warn on
| `FORCE_CPU` | `false` | Disable GPU — fall back to CPU for all inference |
| `OPENVINO_DEVICE` | `CPU` | Intel variant only: set `GPU` to use Arc or iGPU; default runs on CPU |
| `ENABLE_CACHE` | `true` | Cache computed embeddings to disk (speeds up re-runs on the same library) |
| `CACHE_DIR` | `.if_cache` | Path for embedding cache and upload tracker files |
| `DATA_DIR` | `data` | Path for embedding cache and upload tracker JSON files |
| `INSIGHTFACE_HOME` | *(system)* | InsightFace model cache path (Buffalo_L) |
### Output
+2 -2
View File
@@ -37,7 +37,7 @@ services:
# - FORCE_CPU=true # Disable GPU, fall back to CPU
# - OPENVINO_DEVICE=GPU # Intel variant only: use Arc/iGPU instead of CPU (default: CPU)
# - ENABLE_CACHE=false # Disable embedding cache (default: true)
- CACHE_DIR=/app/.if_cache
- DATA_DIR=/app/data
- INSIGHTFACE_HOME=/models/.insightface
# ── Tracker overrides (one-shot, remove after use) ────────────────────
@@ -57,7 +57,7 @@ services:
volumes:
# Replace with absolute paths on your host, e.g. /opt/winnow/models
- /path/to/winnow/models:/models
- /path/to/winnow/cache:/app/.if_cache
- /path/to/winnow/data:/app/data
- /path/to/winnow/output:/app/frigate_train
restart: unless-stopped
-74
View File
@@ -1,74 +0,0 @@
[project]
name = "winnow"
version = "0.4.11"
description = "Selects diverse, high-quality photos from Immich as training data for Frigate face recognition."
license = "AGPL-3.0-or-later"
requires-python = ">=3.13"
authors = [{ name = "Holden Salomon", email = "holden@arch.fyi" }]
keywords = ["immich", "frigate", "face-recognition", "training-data", "arcface", "insightface"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
"Programming Language :: Python :: 3.13",
"Topic :: Scientific/Engineering :: Image Recognition",
]
dependencies = [
"croniter>=5.0.2",
"insightface>=0.7.3",
"numpy>=2.2.6",
"onnxruntime>=1.23.2",
"opencv-python-headless>=4.12.0.88",
"pillow>=12.1.0",
"python-dotenv>=1.2.1",
"requests>=2.32.5",
"rich>=14.2.0",
]
[project.scripts]
winnow = "winnow.cli:main"
[project.urls]
Repository = "https://github.com/sudolulo/winnow"
Changelog = "https://github.com/sudolulo/winnow/blob/main/CHANGELOG.md"
[tool.uv]
required-environments = [
"sys_platform == 'linux' and platform_machine == 'x86_64'",
]
[dependency-groups]
dev = [
"pytest>=8.0",
"ruff>=0.15.17",
]
[tool.hatch.build.targets.wheel]
packages = ["winnow"]
[tool.ruff]
line-length = 120
target-version = "py313"
[tool.ruff.lint]
select = ["E", "F", "I"]
[tool.deptry]
pep621_dev_dependency_groups = ["dev"]
[tool.deptry.package_module_name_map]
pillow = "PIL"
opencv-python-headless = "cv2"
python-dotenv = "dotenv"
insightface = "insightface"
numpy = "numpy"
onnxruntime = "onnxruntime"
requests = "requests"
rich = "rich"
[tool.pytest.ini_options]
testpaths = ["tests"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
-84
View File
@@ -1,84 +0,0 @@
[project]
name = "winnow"
version = "0.4.11"
description = "Selects diverse, high-quality photos from Immich as training data for Frigate face recognition."
license = "AGPL-3.0-or-later"
requires-python = ">=3.13"
authors = [{ name = "Holden Salomon", email = "holden@arch.fyi" }]
keywords = ["immich", "frigate", "face-recognition", "training-data", "arcface", "insightface"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
"Programming Language :: Python :: 3.13",
"Topic :: Scientific/Engineering :: Image Recognition",
]
dependencies = [
"croniter>=5.0.2",
"insightface>=0.7.3",
"numpy>=2.2.6",
"onnxruntime-openvino>=1.20.0",
"opencv-python-headless>=4.12.0.88",
"pillow>=12.1.0",
"python-dotenv>=1.2.1",
"requests>=2.32.5",
"rich>=14.2.0",
]
[project.scripts]
winnow = "winnow.cli:main"
[project.urls]
Repository = "https://github.com/sudolulo/winnow"
Changelog = "https://github.com/sudolulo/winnow/blob/main/CHANGELOG.md"
[tool.uv]
conflicts = [
[
{ package = "onnxruntime" },
{ package = "onnxruntime-gpu" },
{ package = "onnxruntime-openvino" },
],
]
required-environments = [
"sys_platform == 'linux' and platform_machine == 'x86_64'",
]
[dependency-groups]
dev = [
"pytest>=8.0",
"ruff>=0.15.17",
]
[tool.hatch.build.targets.wheel]
packages = ["winnow"]
[tool.ruff]
line-length = 120
target-version = "py313"
[tool.ruff.lint]
select = ["E", "F", "I"]
[tool.deptry]
pep621_dev_dependency_groups = ["dev"]
[tool.deptry.package_module_name_map]
pillow = "PIL"
opencv-python-headless = "cv2"
python-dotenv = "dotenv"
insightface = "insightface"
numpy = "numpy"
onnxruntime-openvino = "onnxruntime"
requests = "requests"
rich = "rich"
[tool.deptry.per_rule_ignores]
DEP002 = ["onnxruntime-openvino"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
-85
View File
@@ -1,85 +0,0 @@
[project]
name = "winnow"
version = "0.4.11"
description = "Selects diverse, high-quality photos from Immich as training data for Frigate face recognition."
license = "AGPL-3.0-or-later"
requires-python = ">=3.13"
authors = [{ name = "Holden Salomon", email = "holden@arch.fyi" }]
keywords = ["immich", "frigate", "face-recognition", "training-data", "arcface", "insightface"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
"Programming Language :: Python :: 3.13",
"Topic :: Scientific/Engineering :: Image Recognition",
]
dependencies = [
"croniter>=5.0.2",
"insightface>=0.7.3",
"numpy>=2.2.6",
"onnxruntime-rocm>=1.16.0",
"opencv-python-headless>=4.12.0.88",
"pillow>=12.1.0",
"python-dotenv>=1.2.1",
"requests>=2.32.5",
"rich>=14.2.0",
]
[project.scripts]
winnow = "winnow.cli:main"
[project.urls]
Repository = "https://github.com/sudolulo/winnow"
Changelog = "https://github.com/sudolulo/winnow/blob/main/CHANGELOG.md"
[tool.uv]
index-strategy = "unsafe-best-match"
conflicts = [
[
{ package = "onnxruntime" },
{ package = "onnxruntime-gpu" },
{ package = "onnxruntime-rocm" },
],
]
required-environments = [
"sys_platform == 'linux' and platform_machine == 'x86_64'",
]
[dependency-groups]
dev = [
"pytest>=8.0",
"ruff>=0.15.17",
]
[tool.hatch.build.targets.wheel]
packages = ["winnow"]
[tool.ruff]
line-length = 120
target-version = "py313"
[tool.ruff.lint]
select = ["E", "F", "I"]
[tool.deptry]
pep621_dev_dependency_groups = ["dev"]
[tool.deptry.package_module_name_map]
pillow = "PIL"
opencv-python-headless = "cv2"
python-dotenv = "dotenv"
insightface = "insightface"
numpy = "numpy"
onnxruntime-rocm = "onnxruntime"
requests = "requests"
rich = "rich"
[tool.deptry.per_rule_ignores]
DEP002 = ["onnxruntime-rocm"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
+19 -9
View File
@@ -1,6 +1,6 @@
[project]
name = "winnow"
version = "0.4.11"
version = "0.6.2"
description = "Selects diverse, high-quality photos from Immich as training data for Frigate face recognition."
license = "AGPL-3.0-or-later"
requires-python = ">=3.13"
@@ -17,11 +17,7 @@ classifiers = [
dependencies = [
"croniter>=5.0.2",
"insightface>=0.7.3",
"nvidia-cudnn-cu12>=9.0.0",
"numpy>=2.2.6",
"onnxruntime-gpu>=1.23.2; sys_platform == 'linux' and platform_machine == 'x86_64'",
"onnxruntime>=1.23.2; sys_platform == 'linux' and platform_machine != 'x86_64'",
"onnxruntime>=1.23.2; sys_platform != 'linux'",
"opencv-python-headless>=4.12.0.88",
"pillow>=12.1.0",
"python-dotenv>=1.2.1",
@@ -29,6 +25,15 @@ dependencies = [
"rich>=14.2.0",
]
[project.optional-dependencies]
gpu = [
"onnxruntime-gpu>=1.23.2; sys_platform == 'linux' and platform_machine == 'x86_64'",
"nvidia-cudnn-cu12>=9.0.0; sys_platform == 'linux' and platform_machine == 'x86_64'",
]
rocm = ["onnxruntime-rocm>=1.16.0; sys_platform == 'linux' and platform_machine == 'x86_64'"]
intel = ["onnxruntime-openvino>=1.20.0; sys_platform == 'linux' and platform_machine == 'x86_64'"]
cpu = ["onnxruntime>=1.23.2"]
[project.scripts]
winnow = "winnow.cli:main"
@@ -38,10 +43,13 @@ Changelog = "https://github.com/sudolulo/winnow/blob/main/CHANGELOG.md"
Documentation = "https://github.com/sudolulo/winnow/wiki"
[tool.uv]
index-strategy = "unsafe-best-match"
conflicts = [
[
{ package = "onnxruntime" },
{ package = "onnxruntime-gpu" },
{ extra = "gpu" },
{ extra = "rocm" },
{ extra = "intel" },
{ extra = "cpu" },
],
]
required-environments = [
@@ -77,11 +85,14 @@ insightface = "insightface"
numpy = "numpy"
nvidia-cudnn-cu12 = "nvidia.cudnn"
onnxruntime-gpu = "onnxruntime"
onnxruntime-rocm = "onnxruntime"
onnxruntime-openvino = "onnxruntime"
onnxruntime = "onnxruntime"
requests = "requests"
rich = "rich"
[tool.deptry.per_rule_ignores]
DEP002 = ["onnxruntime-gpu", "nvidia-cudnn-cu12"]
DEP002 = ["onnxruntime-gpu", "nvidia-cudnn-cu12", "onnxruntime-rocm", "onnxruntime-openvino", "onnxruntime"]
[tool.pytest.ini_options]
testpaths = ["tests"]
@@ -89,4 +100,3 @@ testpaths = ["tests"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
+27 -12
View File
@@ -15,34 +15,49 @@ except ImportError:
# resident in memory across all subsequent scheduled runs.
from winnow.cli import main
SCHEDULE = os.environ["CRON_SCHEDULE"]
INSIGHTFACE_HOME = os.environ.get("INSIGHTFACE_HOME", "/models/.insightface")
logger = logging.getLogger(__name__)
def check_models() -> None:
buffalo = Path(INSIGHTFACE_HOME) / "models" / "buffalo_l"
def _check_models() -> None:
insightface_home = os.environ.get("INSIGHTFACE_HOME", "/models/.insightface")
buffalo = Path(insightface_home) / "models" / "buffalo_l"
if not buffalo.exists():
print(" InsightFace Buffalo_L not found — will download on first run", flush=True)
NOW = time.time()
cron = croniter(SCHEDULE, NOW)
next_run = cron.get_next(float)
def _run_scheduler() -> None:
schedule = os.environ.get("CRON_SCHEDULE")
if not schedule:
print("Error: CRON_SCHEDULE environment variable is required.", flush=True)
sys.exit(1)
while True:
try:
Path("/tmp/winnow.pid").write_text(str(os.getpid()))
except OSError as e:
print(f"Warning: could not write PID file: {e}", flush=True)
now = time.time()
cron = croniter(schedule, now)
next_run = cron.get_next(float)
print(f"Next run: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(next_run))}", flush=True)
while True:
now = time.time()
if now >= next_run:
print(f"\n[{time.strftime('%Y-%m-%d %H:%M:%S')}] Starting winnow run...", flush=True)
check_models()
_check_models()
try:
main()
print("winnow run complete", flush=True)
except KeyboardInterrupt:
raise
except Exception as e:
logger.error(f"winnow run failed: {e}", exc_info=True)
logger.error("winnow run failed: %s", e, exc_info=True)
print(f"winnow run failed: {e}", flush=True)
next_run = cron.get_next(float)
time.sleep(max(1, next_run - time.time()))
print(f"Next run: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(next_run))}", flush=True)
time.sleep(min(60, max(1, next_run - time.time())))
if __name__ == "__main__":
_run_scheduler()
+2 -4
View File
@@ -13,7 +13,6 @@ Usage inside container:
docker exec -e FORCE_CPU=true winnow python /app/scripts/benchmark.py
"""
import os
import sys
import time
@@ -22,9 +21,8 @@ from PIL import Image, ImageDraw
def _mode_label() -> str:
if os.getenv("FORCE_CPU", "").lower() in ("true", "1", "yes"):
return "CPU (FORCE_CPU=true)"
return "GPU (auto)"
from winnow.config import _getenv_bool
return "CPU (FORCE_CPU=true)" if _getenv_bool("FORCE_CPU", False) else "GPU (auto)"
def make_face_image(size: int = 640) -> Image.Image:
+336
View File
@@ -0,0 +1,336 @@
"""Tests for core diversity selection algorithms (pure functions, no network)."""
import numpy as np
import pytest
from PIL import Image
def _unit_embeddings(n: int, d: int = 512, seed: int = 42) -> list:
"""Return n normalised random embeddings — well-separated in high-dim space."""
rng = np.random.default_rng(seed)
embs = rng.standard_normal((n, d)).astype(np.float32)
embs /= np.linalg.norm(embs, axis=1, keepdims=True)
return list(embs)
def _asset_with_face(
asset_id="a1", person_id="p1",
x1=10, y1=10, x2=60, y2=60,
img_w=100, img_h=100, score=0.9,
):
return {
"id": asset_id,
"people": [{
"id": person_id,
"faces": [{
"boundingBoxX1": x1, "boundingBoxY1": y1,
"boundingBoxX2": x2, "boundingBoxY2": y2,
"imageWidth": img_w, "imageHeight": img_h,
"score": score,
}],
}],
}
# ── _get_face_bbox ─────────────────────────────────────────────────────────────
def test_get_face_bbox_returns_coords():
from winnow.diversity import _get_face_bbox
asset = _asset_with_face(x1=5, y1=10, x2=55, y2=70)
assert _get_face_bbox(asset) == (5, 10, 55, 70)
def test_get_face_bbox_filters_by_person_id():
from winnow.diversity import _get_face_bbox
asset = _asset_with_face(person_id="p1")
assert _get_face_bbox(asset, person_id="p999") is None
def test_get_face_bbox_returns_none_empty_faces():
from winnow.diversity import _get_face_bbox
asset = {"id": "a1", "people": [{"id": "p1", "faces": []}]}
assert _get_face_bbox(asset) is None
def test_get_face_bbox_returns_none_no_people():
from winnow.diversity import _get_face_bbox
assert _get_face_bbox({"id": "a1"}) is None
# ── _get_face_confidence ───────────────────────────────────────────────────────
def test_get_face_confidence_returns_score():
from winnow.diversity import _get_face_confidence
asset = _asset_with_face(score=0.92)
assert _get_face_confidence(asset) == pytest.approx(0.92)
def test_get_face_confidence_filters_by_person_id():
from winnow.diversity import _get_face_confidence
asset = _asset_with_face(person_id="p1", score=0.9)
assert _get_face_confidence(asset, person_id="p999") is None
def test_get_face_confidence_returns_none_empty_faces():
from winnow.diversity import _get_face_confidence
asset = {"id": "a1", "people": [{"id": "p1", "faces": []}]}
assert _get_face_confidence(asset) is None
# ── _crop_face_from_thumbnail ──────────────────────────────────────────────────
def test_crop_face_returns_image():
from winnow.diversity import _crop_face_from_thumbnail
img = Image.new("RGB", (200, 200), color=(128, 64, 32))
asset = _asset_with_face(x1=50, y1=50, x2=150, y2=150, img_w=200, img_h=200)
crop = _crop_face_from_thumbnail(img, asset)
assert crop is not None
assert crop.width > 0 and crop.height > 0
def test_crop_face_scales_bbox_to_thumbnail():
"""When thumbnail is half the metadata dimensions, bbox is scaled accordingly."""
from winnow.diversity import _crop_face_from_thumbnail
img = Image.new("RGB", (200, 200))
# Metadata says 400×400; bbox covers the centre quarter
asset = _asset_with_face(x1=100, y1=100, x2=300, y2=300, img_w=400, img_h=400)
crop = _crop_face_from_thumbnail(img, asset)
assert crop is not None
assert crop.width <= 200 and crop.height <= 200
def test_crop_face_returns_none_no_metadata():
from winnow.diversity import _crop_face_from_thumbnail
img = Image.new("RGB", (100, 100))
assert _crop_face_from_thumbnail(img, {"id": "a1"}) is None
def test_crop_face_returns_none_for_sub_30px_bbox():
"""A 1×1 bbox produces a crop too small to embed — should be rejected."""
from winnow.diversity import _crop_face_from_thumbnail
img = Image.new("RGB", (100, 100))
asset = _asset_with_face(x1=50, y1=50, x2=51, y2=51, img_w=100, img_h=100)
assert _crop_face_from_thumbnail(img, asset) is None
def test_crop_face_respects_person_id_filter():
from winnow.diversity import _crop_face_from_thumbnail
img = Image.new("RGB", (200, 200))
asset = _asset_with_face(person_id="p1", x1=50, y1=50, x2=150, y2=150)
assert _crop_face_from_thumbnail(img, asset, person_id="p999") is None
# ── _dedup_embeddings ──────────────────────────────────────────────────────────
def test_dedup_keeps_all_diverse_embeddings():
from winnow.diversity import _dedup_embeddings
embs = _unit_embeddings(20)
candidates = [{"id": str(i)} for i in range(20)]
_, out_cands, _ = _dedup_embeddings(embs, candidates, [None] * 20)
# Random 512-dim unit vectors are far apart — all should survive
assert len(out_cands) == 20
def test_dedup_removes_near_duplicate():
from winnow.diversity import _dedup_embeddings
base = np.zeros(512, dtype=np.float32)
base[0] = 1.0
# Cosine distance ≈ 0.01 — well within the 0.20 dedup threshold
near_dup = base.copy()
near_dup[1] = 0.014
near_dup /= np.linalg.norm(near_dup)
embs = [base, near_dup]
candidates = [{"id": "base", "quality_score": 0.9}, {"id": "dup", "quality_score": 0.5}]
_, out_cands, _ = _dedup_embeddings(embs, candidates, [None, None])
assert len(out_cands) == 1
assert out_cands[0]["id"] == "base"
def test_dedup_keeps_higher_quality_from_duplicate_pair():
from winnow.diversity import _dedup_embeddings
base = np.zeros(512, dtype=np.float32)
base[0] = 1.0
near_dup = base.copy()
near_dup[1] = 0.014
near_dup /= np.linalg.norm(near_dup)
# Reversed quality: near_dup is sharper
embs = [base, near_dup]
candidates = [{"id": "base", "quality_score": 0.3}, {"id": "dup", "quality_score": 0.95}]
_, out_cands, _ = _dedup_embeddings(embs, candidates, [None, None])
assert len(out_cands) == 1
assert out_cands[0]["id"] == "dup"
def test_dedup_single_embedding_passes_through():
from winnow.diversity import _dedup_embeddings
embs = _unit_embeddings(1)
out_embs, out_cands, _ = _dedup_embeddings(embs, [{"id": "only"}], [None])
assert len(out_cands) == 1
def test_dedup_treats_zero_quality_score_as_zero_not_missing():
"""quality_score=0.0 is a valid score — should not be treated as absent."""
from winnow.diversity import _dedup_embeddings
base = np.zeros(512, dtype=np.float32)
base[0] = 1.0
near_dup = base.copy()
near_dup[1] = 0.014
near_dup /= np.linalg.norm(near_dup)
embs = [base, near_dup]
# base has explicit 0.0; near_dup has 0.5 — near_dup should win
candidates = [{"id": "base", "quality_score": 0.0}, {"id": "dup", "quality_score": 0.5}]
_, out_cands, _ = _dedup_embeddings(embs, candidates, [None, None])
assert out_cands[0]["id"] == "dup"
# ── _kmedoids ──────────────────────────────────────────────────────────────────
def _dist_matrix(embs):
m = np.vstack(embs)
m /= np.linalg.norm(m, axis=1, keepdims=True)
return 1 - m @ m.T
def test_kmedoids_returns_k_distinct_medoids():
from winnow.diversity import _kmedoids
dist = _dist_matrix(_unit_embeddings(30))
medoids, _ = _kmedoids(dist, k=5)
assert len(medoids) == 5
assert len(set(medoids)) == 5
def test_kmedoids_labels_cover_all_points():
from winnow.diversity import _kmedoids
dist = _dist_matrix(_unit_embeddings(20))
medoids, labels = _kmedoids(dist, k=4)
assert len(labels) == 20
assert set(labels).issubset(set(range(4)))
def test_kmedoids_medoids_are_valid_indices():
from winnow.diversity import _kmedoids
n = 15
dist = _dist_matrix(_unit_embeddings(n))
medoids, _ = _kmedoids(dist, k=3)
assert all(0 <= m < n for m in medoids)
def test_kmedoids_k_equals_n_selects_all():
from winnow.diversity import _kmedoids
n = 5
dist = _dist_matrix(_unit_embeddings(n))
medoids, _ = _kmedoids(dist, k=n)
assert len(medoids) == n
# ── _compute_adaptive_threshold ────────────────────────────────────────────────
def test_adaptive_threshold_positive():
from winnow.diversity import _compute_adaptive_threshold
embs = np.array(_unit_embeddings(50))
assert _compute_adaptive_threshold(embs) > 0
def test_adaptive_threshold_floor_for_identical_embeddings():
"""All-identical embeddings → median pairwise distance = 0 → floor at 0.05."""
from winnow.diversity import _compute_adaptive_threshold
base = np.zeros((10, 512), dtype=np.float32)
base[:, 0] = 1.0
assert _compute_adaptive_threshold(base) == pytest.approx(0.05)
def test_adaptive_threshold_single_point_returns_floor():
from winnow.diversity import _compute_adaptive_threshold
single = np.ones((1, 512), dtype=np.float32)
single /= np.linalg.norm(single)
assert _compute_adaptive_threshold(single) == pytest.approx(0.05)
def test_adaptive_threshold_scales_with_spread():
"""A more spread-out embedding set should produce a higher threshold."""
from winnow.diversity import _compute_adaptive_threshold
tight = np.array(_unit_embeddings(30, seed=0)) * 0.001 + np.array([1.0] + [0.0] * 511)
tight /= np.linalg.norm(tight, axis=1, keepdims=True)
diverse = np.array(_unit_embeddings(30, seed=1))
assert _compute_adaptive_threshold(diverse) > _compute_adaptive_threshold(tight)
# ── _select_time_spread ────────────────────────────────────────────────────────
def test_time_spread_returns_exact_n():
from winnow.diversity import _select_time_spread
assets = [{"id": str(i)} for i in range(100)]
assert len(_select_time_spread(assets, limit=10)) == 10
def test_time_spread_returns_all_when_under_limit():
from winnow.diversity import _select_time_spread
assets = [{"id": str(i)} for i in range(5)]
assert len(_select_time_spread(assets, limit=20)) == 5
def test_time_spread_auto_defaults_to_30():
from winnow.diversity import _select_time_spread
assets = [{"id": str(i)} for i in range(200)]
assert len(_select_time_spread(assets, limit="auto")) == 30
def test_time_spread_includes_first_and_last():
from winnow.diversity import _select_time_spread
assets = [{"id": str(i)} for i in range(100)]
result = _select_time_spread(assets, limit=5)
ids = [int(a["id"]) for a in result]
assert ids[0] == 0
assert ids[-1] == 99
# ── _cluster_aware_selection ───────────────────────────────────────────────────
def test_cluster_selection_returns_exact_limit(monkeypatch):
from winnow.diversity import _cluster_aware_selection
monkeypatch.setattr("winnow.diversity.Config.MAX_AUTO_IMAGES", 20)
embs = _unit_embeddings(50)
candidates = [{"id": str(i)} for i in range(50)]
result = _cluster_aware_selection(embs, candidates, limit=10)
assert len(result) == 10
def test_cluster_selection_output_is_subset_of_input(monkeypatch):
from winnow.diversity import _cluster_aware_selection
monkeypatch.setattr("winnow.diversity.Config.MAX_AUTO_IMAGES", 20)
embs = _unit_embeddings(30)
candidates = [{"id": str(i)} for i in range(30)]
result = _cluster_aware_selection(embs, candidates, limit=10)
result_ids = {a["id"] for a in result}
assert result_ids.issubset({a["id"] for a in candidates})
def test_cluster_selection_auto_stops_early_on_tight_cluster(monkeypatch):
"""When all embeddings are nearly identical auto mode should stop early."""
from winnow.diversity import _cluster_aware_selection
monkeypatch.setattr("winnow.diversity.Config.MAX_AUTO_IMAGES", 20)
rng = np.random.default_rng(0)
base = np.zeros(512, dtype=np.float32)
base[0] = 1.0
embs = []
for _ in range(50):
v = base + rng.standard_normal(512).astype(np.float32) * 0.001
v /= np.linalg.norm(v)
embs.append(v)
candidates = [{"id": str(i)} for i in range(50)]
result = _cluster_aware_selection(list(embs), candidates, limit="auto")
assert len(result) < 20
def test_cluster_selection_hard_example_weighting_accepted(monkeypatch):
"""Confidence scores are accepted without error."""
from winnow.diversity import _cluster_aware_selection
monkeypatch.setattr("winnow.diversity.Config.MAX_AUTO_IMAGES", 20)
embs = _unit_embeddings(20)
candidates = [{"id": str(i)} for i in range(20)]
conf = [0.7 if i % 2 == 0 else 0.95 for i in range(20)]
result = _cluster_aware_selection(embs, candidates, limit=5, confidence_scores=conf)
assert len(result) == 5
+1 -1
View File
@@ -7,7 +7,7 @@ import pytest
@pytest.fixture(autouse=True)
def isolated_cache(monkeypatch, tmp_path):
"""Point tracker at a temp directory so tests don't touch real cache files."""
monkeypatch.setenv("CACHE_DIR", str(tmp_path))
monkeypatch.setenv("DATA_DIR", str(tmp_path))
from winnow.config import _Config
_Config.reset()
yield tmp_path
-1884
View File
File diff suppressed because it is too large Load Diff
-1941
View File
File diff suppressed because it is too large Load Diff
-1933
View File
File diff suppressed because it is too large Load Diff
Generated
+122 -80
View File
@@ -2,24 +2,18 @@ version = 1
revision = 3
requires-python = ">=3.13"
resolution-markers = [
"platform_machine == 'aarch64' and sys_platform == 'linux'",
"platform_machine != 'aarch64' and platform_machine != 's390x' and platform_machine != 'x86_64' and sys_platform == 'linux'",
"platform_machine == 's390x' and sys_platform == 'linux'",
"platform_machine != 's390x' and sys_platform == 'win32'",
"platform_machine == 's390x' and sys_platform == 'win32'",
"platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'",
"platform_machine == 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'",
"platform_machine != 's390x' and sys_platform == 'darwin'",
"platform_machine == 's390x' and sys_platform == 'darwin'",
"platform_machine == 'x86_64' and sys_platform == 'linux'",
"platform_machine != 's390x'",
"platform_machine == 's390x'",
]
required-markers = [
"platform_machine == 'x86_64' and sys_platform == 'linux'",
"platform_machine == 'aarch64' and sys_platform == 'linux'",
]
conflicts = [[
{ package = "onnxruntime" },
{ package = "onnxruntime-gpu" },
{ package = "winnow", extra = "cpu" },
{ package = "winnow", extra = "gpu" },
{ package = "winnow", extra = "intel" },
{ package = "winnow", extra = "rocm" },
]]
[[package]]
@@ -97,6 +91,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coloredlogs"
version = "15.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "humanfriendly", marker = "platform_machine != 's390x'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" },
]
[[package]]
name = "croniter"
version = "6.2.2"
@@ -117,6 +123,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" },
]
[[package]]
name = "humanfriendly"
version = "10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyreadline3", marker = "platform_machine != 's390x' and sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" },
]
[[package]]
name = "idna"
version = "3.18"
@@ -231,6 +249,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ad/3f/3d42e9a78fe5edf792a83c074b13b9b770092a4fbf3462872f4303135f09/ml_dtypes-0.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:11942cbf2cf92157db91e5022633c0d9474d4dfd813a909383bd23ce828a4b7d", size = 168825, upload-time = "2025-11-17T22:32:23.766Z" },
]
[[package]]
name = "mpmath"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" },
]
[[package]]
name = "networkx"
version = "3.6.1"
@@ -290,36 +317,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" },
]
[[package]]
name = "nvidia-cublas-cu12"
version = "12.6.4.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"platform_machine == 'x86_64' and sys_platform == 'linux'",
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/af/eb/ff4b8c503fa1f1796679dce648854d58751982426e4e4b37d6fce49d259c/nvidia_cublas_cu12-12.6.4.1-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08ed2686e9875d01b58e3cb379c6896df8e76c75e0d4a7f7dace3d7b6d9ef8eb", size = 393138322, upload-time = "2024-11-20T17:40:25.65Z" },
{ url = "https://files.pythonhosted.org/packages/97/0d/f1f0cadbf69d5b9ef2e4f744c9466cb0a850741d08350736dfdb4aa89569/nvidia_cublas_cu12-12.6.4.1-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:235f728d6e2a409eddf1df58d5b0921cf80cfa9e72b9f2775ccb7b4a87984668", size = 390794615, upload-time = "2024-11-20T17:39:52.715Z" },
{ url = "https://files.pythonhosted.org/packages/84/f7/985e9bdbe3e0ac9298fcc8cfa51a392862a46a0ffaccbbd56939b62a9c83/nvidia_cublas_cu12-12.6.4.1-py3-none-win_amd64.whl", hash = "sha256:9e4fa264f4d8a4eb0cdbd34beadc029f453b3bafae02401e999cf3d5a5af75f8", size = 434535301, upload-time = "2024-11-20T17:50:41.681Z" },
]
[[package]]
name = "nvidia-cublas-cu12"
version = "12.9.2.10"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"platform_machine == 'aarch64' and sys_platform == 'linux'",
"platform_machine != 'aarch64' and platform_machine != 's390x' and platform_machine != 'x86_64' and sys_platform == 'linux'",
"platform_machine == 's390x' and sys_platform == 'linux'",
"platform_machine != 's390x' and sys_platform == 'win32'",
"platform_machine == 's390x' and sys_platform == 'win32'",
"platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'",
"platform_machine == 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'",
"platform_machine != 's390x' and sys_platform == 'darwin'",
"platform_machine == 's390x' and sys_platform == 'darwin'",
]
dependencies = [
{ name = "nvidia-cuda-nvrtc-cu12", marker = "platform_machine != 'x86_64' or sys_platform != 'linux' or (extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" },
{ name = "nvidia-cuda-nvrtc-cu12", marker = "platform_machine != 's390x'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/a2/c96163a0fff1839c0c9548bbdeae7b853b867009e33b9b9264adc238b1cf/nvidia_cublas_cu12-12.9.2.10-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:5572131a59c3eebeeb1c4c8144f772d49372c20124916e072a0e3fc30df421d5", size = 575012079, upload-time = "2026-04-08T18:51:47.303Z" },
@@ -337,39 +340,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/52/de/823919be3b9d0ccbf1f784035423c5f18f4267fb0123558d58b813c6ec86/nvidia_cuda_nvrtc_cu12-12.9.86-py3-none-win_amd64.whl", hash = "sha256:72972ebdcf504d69462d3bcd67e7b81edd25d0fb85a2c46d3ea3517666636349", size = 76408187, upload-time = "2025-06-05T20:12:27.819Z" },
]
[[package]]
name = "nvidia-cudnn-cu12"
version = "9.10.2.21"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"platform_machine == 'x86_64' and sys_platform == 'linux'",
]
dependencies = [
{ name = "nvidia-cublas-cu12", version = "12.6.4.1", source = { registry = "https://pypi.org/simple" }, marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine != 'x86_64' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/41/e79269ce215c857c935fd86bcfe91a451a584dfc27f1e068f568b9ad1ab7/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:c9132cc3f8958447b4910a1720036d9eff5928cc3179b0a51fb6d167c6cc87d8", size = 705026878, upload-time = "2025-06-06T21:52:51.348Z" },
{ url = "https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8", size = 706758467, upload-time = "2025-06-06T21:54:08.597Z" },
{ url = "https://files.pythonhosted.org/packages/3d/90/0bd6e586701b3a890fd38aa71c387dab4883d619d6e5ad912ccbd05bfd67/nvidia_cudnn_cu12-9.10.2.21-py3-none-win_amd64.whl", hash = "sha256:c6288de7d63e6cf62988f0923f96dc339cea362decb1bf5b3141883392a7d65e", size = 692992268, upload-time = "2025-06-06T21:55:18.114Z" },
]
[[package]]
name = "nvidia-cudnn-cu12"
version = "9.23.1.3"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"platform_machine == 'aarch64' and sys_platform == 'linux'",
"platform_machine != 'aarch64' and platform_machine != 's390x' and platform_machine != 'x86_64' and sys_platform == 'linux'",
"platform_machine == 's390x' and sys_platform == 'linux'",
"platform_machine != 's390x' and sys_platform == 'win32'",
"platform_machine == 's390x' and sys_platform == 'win32'",
"platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'",
"platform_machine == 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'",
"platform_machine != 's390x' and sys_platform == 'darwin'",
"platform_machine == 's390x' and sys_platform == 'darwin'",
]
dependencies = [
{ name = "nvidia-cublas-cu12", version = "12.9.2.10", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'linux' or (extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" },
{ name = "nvidia-cublas-cu12", marker = "platform_machine != 's390x'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/30/ecca1c8194c8077c4b57a3d96b56d96f15852551b01b919bae6429d92218/nvidia_cudnn_cu12-9.23.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:6dbc18f05aab2a323a4ffd43d985410608f7db7db9a8596e189cddbd3e527441", size = 778220760, upload-time = "2026-06-09T19:38:19.281Z" },
@@ -439,10 +415,10 @@ name = "onnxruntime-gpu"
version = "1.26.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flatbuffers", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine != 'x86_64' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" },
{ name = "numpy", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine != 'x86_64' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" },
{ name = "packaging", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine != 'x86_64' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" },
{ name = "protobuf", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine != 'x86_64' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" },
{ name = "flatbuffers", marker = "platform_machine != 's390x'" },
{ name = "numpy", marker = "platform_machine != 's390x'" },
{ name = "packaging", marker = "platform_machine != 's390x'" },
{ name = "protobuf", marker = "platform_machine != 's390x'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/dd/97/fe8979f44b9275654b42f7bb556e30789b71a1b22998c83b540df2b1b774/onnxruntime_gpu-1.26.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cfda2fad535595bfc3e570eb588092717711dcb2957656d814695e0c9ceb1508", size = 276974871, upload-time = "2026-05-08T19:15:58.052Z" },
@@ -453,6 +429,38 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/97/91/93ffe5431d154989f5e04864a25a97eea480997d771232bcbbc538188241/onnxruntime_gpu-1.26.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56dc7b73954ff4bdc71f5b8ab306b6f61be5d007881b6ef423a609e2b9cd088b", size = 276991545, upload-time = "2026-05-08T19:16:33.347Z" },
]
[[package]]
name = "onnxruntime-openvino"
version = "1.24.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flatbuffers", marker = "platform_machine != 's390x'" },
{ name = "numpy", marker = "platform_machine != 's390x'" },
{ name = "packaging", marker = "platform_machine != 's390x'" },
{ name = "protobuf", marker = "platform_machine != 's390x'" },
{ name = "sympy", marker = "platform_machine != 's390x'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/08/07/f225999919f56506b603aaa3ff837ad563ab26f86906ed7fa7e5abcd849e/onnxruntime_openvino-1.24.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:2c3bb73e68ac27f4891af8a595c1faf574ec68b772e6583c90a0b997a1822782", size = 84433183, upload-time = "2026-02-26T13:44:50.254Z" },
{ url = "https://files.pythonhosted.org/packages/3e/92/46ae2cd565961a89189900f385bb2f13a9fa731ea4674001d23720fbb1e0/onnxruntime_openvino-1.24.1-cp313-cp313-win_amd64.whl", hash = "sha256:434bf49aa71393c577a456c9d76c98e6d6958a833fa0876793e3d5437b5a511a", size = 13658485, upload-time = "2026-02-26T13:44:53.889Z" },
]
[[package]]
name = "onnxruntime-rocm"
version = "1.22.2.post1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coloredlogs", marker = "platform_machine != 's390x'" },
{ name = "flatbuffers", marker = "platform_machine != 's390x'" },
{ name = "numpy", marker = "platform_machine != 's390x'" },
{ name = "packaging", marker = "platform_machine != 's390x'" },
{ name = "protobuf", marker = "platform_machine != 's390x'" },
{ name = "sympy", marker = "platform_machine != 's390x'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/d0/e6f011c8e01853a8a4a56b48b1421257d89767fa76e8a273eeb2c54eb725/onnxruntime_rocm-1.22.2.post1-cp313-cp313-manylinux_2_35_x86_64.whl", hash = "sha256:19b56e9e41da3c7042dc97223ef46976ed8bbdf0ffe43646129f773647794b44", size = 217937775, upload-time = "2025-09-11T16:40:34.403Z" },
]
[[package]]
name = "opencv-python"
version = "4.13.0.92"
@@ -589,12 +597,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pyreadline3"
version = "3.5.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b6/6d/f94028646d7bbe6d9d873c47ee7c246f2d29129d253f0d96cb6fcab70733/pyreadline3-3.5.6.tar.gz", hash = "sha256:61e53218b99656091ddb077df9e71f25850e72e030b6183b39c9b7e6e4f4a9bf", size = 100368, upload-time = "2026-05-14T17:55:04.471Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/5e/35c856e186b74678c24927847ad9895a51f1bc02a0c6126477a6c6040064/pyreadline3-3.5.6-py3-none-any.whl", hash = "sha256:8449b734232e42a5dcd74048e39b60db2839a4c38cf3ae2bf7707d58b5389c0d", size = 85243, upload-time = "2026-05-14T17:55:03.262Z" },
]
[[package]]
name = "pytest"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32' or (extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" },
{ name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-6-winnow-cpu' and extra == 'extra-6-winnow-gpu') or (extra == 'extra-6-winnow-cpu' and extra == 'extra-6-winnow-intel') or (extra == 'extra-6-winnow-cpu' and extra == 'extra-6-winnow-rocm') or (extra == 'extra-6-winnow-gpu' and extra == 'extra-6-winnow-intel') or (extra == 'extra-6-winnow-gpu' and extra == 'extra-6-winnow-rocm') or (extra == 'extra-6-winnow-intel' and extra == 'extra-6-winnow-rocm')" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
@@ -789,6 +806,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "sympy"
version = "1.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mpmath", marker = "platform_machine != 's390x'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" },
]
[[package]]
name = "tifffile"
version = "2026.6.1"
@@ -806,7 +835,7 @@ name = "tqdm"
version = "4.68.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32' or (extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" },
{ name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-6-winnow-cpu' and extra == 'extra-6-winnow-gpu') or (extra == 'extra-6-winnow-cpu' and extra == 'extra-6-winnow-intel') or (extra == 'extra-6-winnow-cpu' and extra == 'extra-6-winnow-rocm') or (extra == 'extra-6-winnow-gpu' and extra == 'extra-6-winnow-intel') or (extra == 'extra-6-winnow-gpu' and extra == 'extra-6-winnow-rocm') or (extra == 'extra-6-winnow-intel' and extra == 'extra-6-winnow-rocm')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/85/05/0d5260f1f1ca784f4a4a0def9cbe6affe587f5b4025328d446c3d67765f4/tqdm-4.68.2.tar.gz", hash = "sha256:89c230e8dbc67c7615c142487111222f878c77427ea09549960f62389e258add", size = 171923, upload-time = "2026-06-09T13:26:42.539Z" }
wheels = [
@@ -833,16 +862,12 @@ wheels = [
[[package]]
name = "winnow"
version = "0.4.11"
version = "0.6.1"
source = { editable = "." }
dependencies = [
{ name = "croniter" },
{ name = "insightface" },
{ name = "numpy" },
{ name = "nvidia-cudnn-cu12", version = "9.10.2.21", source = { registry = "https://pypi.org/simple" }, marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine != 'x86_64' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" },
{ name = "nvidia-cudnn-cu12", version = "9.23.1.3", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'linux' or (extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" },
{ name = "onnxruntime", marker = "platform_machine != 'x86_64' or sys_platform != 'linux' or (extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" },
{ name = "onnxruntime-gpu", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine != 'x86_64' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" },
{ name = "opencv-python-headless" },
{ name = "pillow" },
{ name = "python-dotenv" },
@@ -850,6 +875,21 @@ dependencies = [
{ name = "rich" },
]
[package.optional-dependencies]
cpu = [
{ name = "onnxruntime" },
]
gpu = [
{ name = "nvidia-cudnn-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "onnxruntime-gpu", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
]
intel = [
{ name = "onnxruntime-openvino", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
]
rocm = [
{ name = "onnxruntime-rocm", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
@@ -861,16 +901,18 @@ requires-dist = [
{ name = "croniter", specifier = ">=5.0.2" },
{ name = "insightface", specifier = ">=0.7.3" },
{ name = "numpy", specifier = ">=2.2.6" },
{ name = "nvidia-cudnn-cu12", specifier = ">=9.0.0" },
{ name = "onnxruntime", marker = "sys_platform != 'linux'", specifier = ">=1.23.2" },
{ name = "onnxruntime", marker = "platform_machine != 'x86_64' and sys_platform == 'linux'", specifier = ">=1.23.2" },
{ name = "onnxruntime-gpu", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = ">=1.23.2" },
{ name = "nvidia-cudnn-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'gpu'", specifier = ">=9.0.0" },
{ name = "onnxruntime", marker = "extra == 'cpu'", specifier = ">=1.23.2" },
{ name = "onnxruntime-gpu", marker = "platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'gpu'", specifier = ">=1.23.2" },
{ name = "onnxruntime-openvino", marker = "platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'intel'", specifier = ">=1.20.0" },
{ name = "onnxruntime-rocm", marker = "platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'rocm'", specifier = ">=1.16.0" },
{ name = "opencv-python-headless", specifier = ">=4.12.0.88" },
{ name = "pillow", specifier = ">=12.1.0" },
{ name = "python-dotenv", specifier = ">=1.2.1" },
{ name = "requests", specifier = ">=2.32.5" },
{ name = "rich", specifier = ">=14.2.0" },
]
provides-extras = ["gpu", "rocm", "intel", "cpu"]
[package.metadata.requires-dev]
dev = [
+50 -16
View File
@@ -7,37 +7,62 @@ recomputing on reruns. Uses numpy binary format for fast I/O.
import hashlib
import logging
import os
from pathlib import Path
import numpy as np
logger = logging.getLogger(__name__)
# Model versions — bump these when the upstream model changes
MODEL_VERSIONS = {
"insightface": "buffalo_l_v1",
"immich": "immich_buffalo_l_v1",
}
def _insightface_model_fingerprint() -> str:
"""Derive a version string from buffalo_l .onnx file sizes and mtimes.
Changes automatically when model files are replaced or updated, preventing
stale embeddings from a previous model being served from cache.
Falls back to a static string before the model is downloaded (first run).
"""
insightface_home = os.environ.get("INSIGHTFACE_HOME", os.path.expanduser("~/.insightface"))
model_dir = Path(insightface_home) / "models" / "buffalo_l"
if not model_dir.exists():
return "buffalo_l_v1"
onnx_files = sorted(model_dir.glob("*.onnx"))
if not onnx_files:
return "buffalo_l_v1"
fingerprint = "|".join(
f"{f.name}:{f.stat().st_size}:{int(f.stat().st_mtime)}"
for f in onnx_files
)
return hashlib.sha256(fingerprint.encode()).hexdigest()[:12]
class EmbeddingCache:
"""Simple disk-based embedding cache.
Embeddings are stored as .npy files in a flat directory,
keyed by a hash of (asset_id, model_version).
keyed by a hash of (asset_id, model_version). The InsightFace version
is derived from buffalo_l model file metadata so the cache auto-invalidates
when model files are replaced or updated.
"""
def __init__(self, cache_dir: str = ".if_cache") -> None:
self.cache_dir = cache_dir
self._ensured = False
self._model_versions = {
**MODEL_VERSIONS,
"insightface": _insightface_model_fingerprint(),
}
def _ensure_dir(self) -> None:
if not self._ensured:
os.makedirs(self.cache_dir, exist_ok=True)
self._ensured = True
@staticmethod
def _key(asset_id: str, model: str) -> str:
version = MODEL_VERSIONS.get(model, model)
def _key(self, asset_id: str, model: str) -> str:
version = self._model_versions.get(model, model)
raw = f"{asset_id}:{version}"
return hashlib.sha256(raw.encode()).hexdigest()[:16]
@@ -57,10 +82,19 @@ class EmbeddingCache:
def put(self, asset_id: str, embedding: np.ndarray, model: str = "insightface") -> None:
"""Store an embedding in the cache."""
self._ensure_dir()
final = self._path(asset_id, model)
# Insert .tmp before .npy so np.save doesn't auto-append another .npy extension
# (np.save appends .npy to paths that don't already end in .npy).
tmp = final.removesuffix(".npy") + ".tmp.npy"
try:
np.save(self._path(asset_id, model), embedding)
np.save(tmp, embedding)
os.replace(tmp, final)
except Exception as e:
logger.debug(f"Cache write failed for {asset_id}: {e}")
logger.debug("Cache write failed for %s: %s", asset_id, e)
try:
os.remove(tmp)
except OSError:
pass
def clear(self) -> None:
"""Delete all cached embeddings."""
@@ -71,23 +105,23 @@ class EmbeddingCache:
if f.endswith(".npy"):
os.remove(os.path.join(self.cache_dir, f))
count += 1
logger.info(f"Cleared {count} cached embeddings.")
logger.info("Cleared %s cached embeddings.", count)
# Singleton instance
_cache: EmbeddingCache | None = None
_cache_dir: str | None = None
def get_cache(cache_dir: str = ".if_cache") -> EmbeddingCache:
"""Get or create the singleton cache instance.
Note: The ``cache_dir`` parameter is only used when creating the
singleton for the first time. Subsequent calls return the existing
instance regardless of ``cache_dir``. If you need a cache with a
different directory, instantiate ``EmbeddingCache`` directly.
Re-creates the instance when ``cache_dir`` changes so that test
isolation (which resets Config.DATA_DIR via _Config.reset()) always
writes to the correct directory rather than a stale one.
"""
global _cache
if _cache is None:
global _cache, _cache_dir
if _cache is None or _cache_dir != cache_dir:
_cache = EmbeddingCache(cache_dir)
_cache_dir = cache_dir
return _cache
+38 -17
View File
@@ -7,12 +7,12 @@ import sys
from rich import print as rprint
from rich.prompt import Confirm
from .config import Config, ConfigManager
from .config import Config, _getenv_bool
from .executor import execute_jobs, upload_to_frigate
from .immich_api import get_people, merge_people
from .immich_api import get_immich_version, get_people, merge_people
from .jobs import _show_preview, auto_configure, interactive_configure
from .log_config import console, setup_logging
from .upload_tracker import find_by_crop_dimension, get_person_summary, reset_person
from .upload_tracker import find_by_crop_dimension, get_person_summary, reset_all_people, reset_person
logger = logging.getLogger(__name__)
@@ -78,6 +78,14 @@ def _handle_duplicate_people(people: list[dict]) -> list[dict]:
if not duplicates:
return people
def _smaller_duplicate_ids(groups: dict) -> set[str]:
"""IDs of all but the largest person in each duplicate group."""
return {
p["id"]
for ps in groups.values()
for p in sorted(ps, key=lambda x: x.get("assetCount", 0), reverse=True)[1:]
}
if not Config.MERGE_DUPLICATE_PEOPLE:
rprint("\n[bold yellow]⚠ Duplicate person names detected in Immich:[/bold yellow]")
for name, ps in sorted(duplicates.items()):
@@ -99,12 +107,7 @@ def _handle_duplicate_people(people: list[dict]) -> list[dict]:
)
# Return deduplicated list — keep only the largest per name so that
# downstream job creation never runs two jobs for the same Frigate folder.
skip_ids = {
p["id"]
for ps in duplicates.values()
for p in sorted(ps, key=lambda x: x.get("assetCount", 0), reverse=True)[1:]
}
return [p for p in people if p["id"] not in skip_ids]
return [p for p in people if p["id"] not in _smaller_duplicate_ids(duplicates)]
# Auto-merge: survivor = largest asset count, rest merge into it inside Immich
merged_any = False
@@ -125,9 +128,21 @@ def _handle_duplicate_people(people: list[dict]) -> list[dict]:
if merged_any:
rprint(" [dim]Re-fetching people after merge...[/dim]")
return get_people()
fresh = get_people()
# Filter out the smaller duplicate from any group whose merge failed — those
# IDs still exist in Immich and would produce two jobs for the same folder.
# IDs from groups that merged successfully are already gone from Immich, so
# this filter is a no-op for them.
skip_ids = _smaller_duplicate_ids(duplicates)
return [p for p in fresh if p.get("id") not in skip_ids]
return people
# All merges failed — fall back to local deduplication (keep largest per name) so
# downstream job creation never runs two jobs for the same Frigate folder.
rprint(
" [yellow]All merges failed — applying local deduplication"
" to avoid overwriting output.[/yellow]"
)
return [p for p in people if p["id"] not in _smaller_duplicate_ids(duplicates)]
_UNSUPPORTED_VARS = [
@@ -145,7 +160,7 @@ _UNSUPPORTED_VARS = [
def main() -> None:
"""Entry point for winnow CLI."""
try:
verbose = os.environ.get("VERBOSE", "").lower() in ("true", "1", "yes")
verbose = _getenv_bool("VERBOSE", False)
setup_logging(verbose=verbose)
trace_size = os.environ.get("TRACE_CROP_SIZE", "").strip()
@@ -168,7 +183,7 @@ def main() -> None:
"Image quality issues caused by non-default values will not be investigated.[/dim]\n"
)
ConfigManager.get().interactive_setup()
Config.interactive_setup()
try:
Config.validate()
@@ -192,8 +207,7 @@ def main() -> None:
"and will be reset along with everyone else.[/yellow]"
)
if names:
for name in names:
reset_person(name)
reset_all_people()
rprint(f"[bold yellow]Reset tracking data for all {len(names)} people.[/bold yellow]")
else:
rprint("[dim]No tracking data to reset.[/dim]")
@@ -216,6 +230,13 @@ def main() -> None:
f" {counts['rejected']} rejected{frigate_part}[/dim]"
)
_immich_version = get_immich_version()
if _immich_version is not None and _immich_version < (1, 106, 0):
rprint(
f" [yellow]⚠ Immich {'.'.join(str(x) for x in _immich_version)} detected — "
"winnow requires v1.106+. Some features may not work.[/yellow]"
)
people = get_people()
if not people:
rprint("[bold red]Could not fetch people from Immich. Check URL/Key.[/bold red]")
@@ -225,8 +246,8 @@ def main() -> None:
# Auto mode when no TTY (Docker, cron, pipes) — the primary use case.
# A TTY means local interactive use; AUTO_MODE=true overrides that for scripting.
auto_mode = not sys.stdin.isatty() or os.environ.get("AUTO_MODE", "").lower() in ("true", "1", "yes")
dry_run = os.environ.get("DRY_RUN", "false").lower() in ("true", "1", "yes")
auto_mode = not sys.stdin.isatty() or _getenv_bool("AUTO_MODE", False)
dry_run = _getenv_bool("DRY_RUN", False)
if dry_run:
rprint("[bold yellow]DRY RUN — no images will be downloaded or uploaded[/bold yellow]")
+149 -92
View File
@@ -9,85 +9,180 @@ from typing import ClassVar
from dotenv import load_dotenv
from rich.prompt import Prompt
load_dotenv()
_LEGACY_CONFIG_FILE = Path(".immich_config.json") # pre-v0.6: lived in process CWD, not on a volume
CONFIG_FILE = Path(".immich_config.json")
def _getenv_num(name: str, default, cast):
raw = os.getenv(name)
if raw is None:
return default
raw = raw.strip()
if not raw:
return default
try:
return cast(raw)
except ValueError:
logging.warning("%s=%r is not a valid %s — using default %s", name, raw, cast.__name__, default)
return default
def _getenv_int(name: str, default: int) -> int:
return _getenv_num(name, default, int)
def _getenv_float(name: str, default: float) -> float:
return _getenv_num(name, default, float)
def _getenv_optional_float(name: str) -> float | None:
"""Return float value of env var, or None if unset/empty. Warns and returns None on invalid."""
return _getenv_num(name, None, float)
def _getenv_optional_int(name: str) -> int | None:
"""Return int value of env var, or None if unset/empty. Warns and returns None on invalid."""
return _getenv_num(name, None, int)
def _getenv_bool(name: str, default: bool) -> bool:
raw = os.getenv(name)
if raw is None:
return default
raw = raw.strip()
if not raw:
return default
return raw.lower() in ("true", "1", "yes")
class _Config:
"""Singleton configuration with uppercase attribute access for backward compatibility."""
"""Singleton configuration with lazy loading via __getattr__.
Class-level attributes are annotations only (no defaults), so attribute
access on an un-loaded instance falls through to __getattr__, which
triggers _load() exactly once.
"""
_instance: ClassVar["_Config | None"] = None
# Configuration values
IMMICH_URL: str | None = None
API_KEY: str | None = None
OUTPUT_DIR: str = "./frigate_train"
YEARS_FILTER: int = 10
# Annotations only — no class-level defaults so __getattr__ fires on first access
IMMICH_URL: str | None
API_KEY: str | None
OUTPUT_DIR: str
YEARS_FILTER: int
# Quality filtering
MIN_FACE_WIDTH: int = 90
BLUR_THRESHOLD: float = 120.0
MIN_CONFIDENCE: float = 0.7
MAX_AUTO_IMAGES: int = 20
QUALITY_REPLACEMENT: bool = True
FRIGATE_SCORE_CEILING: float | None = None
ENABLE_FRIGATE_SCORES: bool = True
MIN_FACE_WIDTH: int
BLUR_THRESHOLD: float
MIN_CONFIDENCE: float
MAX_AUTO_IMAGES: int
QUALITY_REPLACEMENT: bool
FRIGATE_SCORE_CEILING: float | None
ENABLE_FRIGATE_SCORES: bool
# People filtering
MIN_FACE_COUNT: int = 3
MERGE_DUPLICATE_PEOPLE: bool = False
MIN_FACE_COUNT: int
MERGE_DUPLICATE_PEOPLE: bool
# Output quality
FACE_MARGIN: float = 0.15
USE_FULL_RESOLUTION: bool = True
ENABLE_FACE_ALIGNMENT: bool = True
FACE_MARGIN: float
USE_FULL_RESOLUTION: bool
ENABLE_FACE_ALIGNMENT: bool
ENABLE_CACHE: bool = True
CACHE_DIR: str = ".if_cache"
ENABLE_CACHE: bool
DATA_DIR: str
def __new__(cls) -> "_Config":
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._load()
# Do NOT call _load() here — keep __new__ I/O-free so that import
# time does not trigger env/file reads.
return cls._instance
def __getattr__(self, name: str):
"""Called only when the attribute is not found on the instance.
On first access to any config attribute, load all values from env/file
and return the requested one. Re-registers self as _instance so that
a subsequent reset() correctly finds and clears this object's attrs.
"""
if name.startswith("_"):
raise AttributeError(name)
self._load()
# Re-register self as the singleton so reset() can clear our __dict__.
# This handles the case where __getattr__ is called on the module-level
# Config object after a reset() set _instance to None.
_Config._instance = self
# _load() sets the attribute as an instance attr; retrieve it directly
# to avoid infinite recursion through __getattr__.
try:
return self.__dict__[name]
except KeyError:
raise AttributeError(f"_Config has no attribute {name!r}")
def _load(self) -> None:
"""Load configuration from environment and config file."""
load_dotenv()
# Load from environment (highest priority)
self.IMMICH_URL = os.getenv("IMMICH_URL")
self.API_KEY = os.getenv("API_KEY")
self.OUTPUT_DIR = os.getenv("OUTPUT_DIR", "./frigate_train")
self.YEARS_FILTER = int(os.getenv("YEARS_FILTER", "10"))
self.MIN_FACE_WIDTH = int(os.getenv("MIN_FACE_WIDTH", "90"))
self.MIN_FACE_COUNT = int(os.getenv("MIN_FACE_COUNT", "3"))
self.MERGE_DUPLICATE_PEOPLE = os.getenv("MERGE_DUPLICATE_PEOPLE", "false").lower() in ("true", "1", "yes")
self.BLUR_THRESHOLD = float(os.getenv("BLUR_THRESHOLD", "120.0"))
self.MIN_CONFIDENCE = float(os.getenv("MIN_CONFIDENCE", "0.7"))
self.MAX_AUTO_IMAGES = int(os.getenv("MAX_AUTO_IMAGES", "20"))
self.QUALITY_REPLACEMENT = os.getenv("QUALITY_REPLACEMENT", "true").lower() in ("true", "1", "yes")
_ceiling_env = os.getenv("FRIGATE_SCORE_CEILING", "").strip()
self.FRIGATE_SCORE_CEILING = float(_ceiling_env) if _ceiling_env else None
self.ENABLE_FRIGATE_SCORES = os.getenv("ENABLE_FRIGATE_SCORES", "true").lower() in ("true", "1", "yes")
self.FACE_MARGIN = float(os.getenv("FACE_MARGIN", "0.15"))
self.USE_FULL_RESOLUTION = os.getenv("USE_FULL_RESOLUTION", "true").lower() in ("true", "1", "yes")
self.ENABLE_FACE_ALIGNMENT = os.getenv("ENABLE_FACE_ALIGNMENT", "true").lower() in ("true", "1", "yes")
self.ENABLE_CACHE = os.getenv("ENABLE_CACHE", "true").lower() in ("true", "1", "yes")
self.CACHE_DIR = os.getenv("CACHE_DIR", ".if_cache")
self.YEARS_FILTER = _getenv_int("YEARS_FILTER", 10)
self.MIN_FACE_WIDTH = _getenv_int("MIN_FACE_WIDTH", 90)
self.MIN_FACE_COUNT = _getenv_int("MIN_FACE_COUNT", 3)
self.MERGE_DUPLICATE_PEOPLE = _getenv_bool("MERGE_DUPLICATE_PEOPLE", False)
self.BLUR_THRESHOLD = _getenv_float("BLUR_THRESHOLD", 120.0)
self.MIN_CONFIDENCE = _getenv_float("MIN_CONFIDENCE", 0.7)
self.MAX_AUTO_IMAGES = _getenv_int("MAX_AUTO_IMAGES", 20)
self.QUALITY_REPLACEMENT = _getenv_bool("QUALITY_REPLACEMENT", True)
self.FRIGATE_SCORE_CEILING = _getenv_optional_float("FRIGATE_SCORE_CEILING")
self.ENABLE_FRIGATE_SCORES = _getenv_bool("ENABLE_FRIGATE_SCORES", True)
self.FACE_MARGIN = _getenv_float("FACE_MARGIN", 0.15)
self.USE_FULL_RESOLUTION = _getenv_bool("USE_FULL_RESOLUTION", True)
self.ENABLE_FACE_ALIGNMENT = _getenv_bool("ENABLE_FACE_ALIGNMENT", True)
self.ENABLE_CACHE = _getenv_bool("ENABLE_CACHE", True)
_data_dir = os.getenv("DATA_DIR")
_cache_dir_legacy = os.getenv("CACHE_DIR")
if _data_dir:
self.DATA_DIR = _data_dir
elif _cache_dir_legacy:
logging.warning(
"CACHE_DIR is deprecated — rename it to DATA_DIR in your .env or compose.yml"
)
self.DATA_DIR = _cache_dir_legacy
else:
self.DATA_DIR = "data"
# Fall back to config file for non-sensitive values (API_KEY not stored here)
if CONFIG_FILE.exists():
# Fall back to config file when the env var is absent or blank — a blank
# IMMICH_URL= placeholder in .env should not override the config file.
# Prefer DATA_DIR/.immich_config.json (volume-safe in Docker) and fall back
# to the legacy CWD path so existing installations continue to work.
_data_cfg = Path(self.DATA_DIR) / ".immich_config.json"
_data_cfg_exists = _data_cfg.exists()
if _data_cfg_exists and _LEGACY_CONFIG_FILE.exists():
logging.warning(
"Two config files found: %s and %s — using %s. Remove the legacy file to silence this.",
_data_cfg,
_LEGACY_CONFIG_FILE,
_data_cfg,
)
config_file = _data_cfg if _data_cfg_exists else _LEGACY_CONFIG_FILE
# _data_cfg_exists already confirmed the primary path — avoid re-stat.
# The short-circuit means the legacy path is stat'd at most once here.
if _data_cfg_exists or config_file.exists():
try:
data = json.loads(CONFIG_FILE.read_text())
self.IMMICH_URL = self.IMMICH_URL or data.get("IMMICH_URL")
if not os.getenv("OUTPUT_DIR"):
data = json.loads(config_file.read_text())
if not self.IMMICH_URL:
self.IMMICH_URL = data.get("IMMICH_URL")
if os.getenv("OUTPUT_DIR") is None:
self.OUTPUT_DIR = data.get("OUTPUT_DIR", self.OUTPUT_DIR)
except (json.JSONDecodeError, OSError) as e:
logging.warning(f"Failed to load config file: {e}")
logging.warning("Failed to load config file: %s", e)
@classmethod
def reset(cls) -> None:
"""Reset the singleton — mainly useful for testing or delayed env setup."""
if cls._instance is not None:
cls._instance.__dict__.clear()
cls._instance = None
def save(self) -> None:
@@ -95,9 +190,13 @@ class _Config:
API_KEY is intentionally excluded — store it in .env or as an
environment variable instead of a plain-text config file.
Writes to DATA_DIR/.immich_config.json so the file survives container
restarts when DATA_DIR is a mounted volume.
"""
config_file = Path(self.DATA_DIR) / ".immich_config.json"
try:
CONFIG_FILE.write_text(
Path(self.DATA_DIR).mkdir(parents=True, exist_ok=True)
config_file.write_text(
json.dumps(
{
"IMMICH_URL": self.IMMICH_URL,
@@ -106,9 +205,9 @@ class _Config:
indent=2,
)
)
logging.info(f"Configuration saved to {CONFIG_FILE}")
logging.info("Configuration saved to %s", config_file)
except OSError as e:
logging.error(f"Failed to save config: {e}")
logging.error("Failed to save config: %s", e)
def interactive_setup(self) -> None:
"""Prompt user for missing configuration."""
@@ -132,52 +231,10 @@ class _Config:
raise ValueError("Missing Immich URL or API Key.")
# Singleton instance — use a lazy property pattern to avoid import-time side effects
# when env vars aren't yet set. Call Config.instance() or just access attributes on
# the module-level `Config` (which delegates to the singleton).
class _ConfigAccessor:
"""Lazy accessor that defers singleton creation until first attribute access.
This avoids reading .env and config files at import time, so environment
variables set after importing the module are properly picked up.
"""
def __getattr__(self, name: str):
return getattr(_Config(), name)
def __setattr__(self, name: str, value):
if name.startswith("_"):
super().__setattr__(name, value)
else:
setattr(_Config(), name, value)
def reset(self) -> None:
"""Reset the underlying singleton."""
_Config.reset()
def interactive_setup(self) -> None:
"""Delegate to the singleton."""
_Config().interactive_setup()
def validate(self) -> None:
"""Delegate to the singleton."""
_Config().validate()
def save(self) -> None:
"""Delegate to the singleton."""
_Config().save()
Config = _ConfigAccessor()
class ConfigManager:
@staticmethod
def get() -> _Config:
return _Config()
# Module-level singleton — lazy: no I/O until first attribute access.
Config = _Config()
def get_headers() -> dict[str, str]:
"""Return HTTP headers for Immich API requests."""
return {"x-api-key": Config.API_KEY or "", "Accept": "application/json"}
+51 -23
View File
@@ -10,6 +10,7 @@ Selection pipeline:
"""
import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
from io import BytesIO
import numpy as np
@@ -22,6 +23,15 @@ from .quality import assess_quality
logger = logging.getLogger(__name__)
# Candidate pool: cap at _POOL_CAP assets, but take at least _POOL_SCALE × the
# requested limit so small limits don't artificially narrow the search space.
_POOL_CAP = 3000
_POOL_SCALE = 20
# Embedding batch size: bounds decoded thumbnails in memory.
# At ~3-8 MB each, 32 images ≈ 100–250 MB peak — safe in a 4 GB container.
_EMBEDDING_BATCH_SIZE = 32
def select_diverse_assets(
assets: list,
@@ -30,6 +40,7 @@ def select_diverse_assets(
selection_mode: str = "smart",
person_id: str | None = None,
progress_callback=None,
fetch_fn=None,
) -> list:
"""
Select diverse assets using cluster-aware FPS or time spread.
@@ -40,6 +51,8 @@ def select_diverse_assets(
entity_name: Name of the person for logging
selection_mode: 'smart' (embedding-based) or 'time' (time spread)
progress_callback: Optional callback(current, total) for progress
fetch_fn: Optional callable(asset_id) -> Image | None; defaults to
_fetch_thumbnail. Injected for testability.
Returns:
List of selected assets
@@ -57,9 +70,9 @@ def select_diverse_assets(
return _select_time_spread(assets, limit)
try:
return _select_by_embedding(assets, limit, person_id, progress_callback)
return _select_by_embedding(assets, limit, person_id, progress_callback, fetch_fn=fetch_fn)
except Exception as e:
logger.error(f"Smart Diversity failed: {e}. Falling back to time spread.")
logger.error("Smart Diversity failed: %s. Falling back to time spread.", e)
return _select_time_spread(assets, limit)
@@ -178,6 +191,7 @@ def _select_by_embedding(
limit: int | str,
person_id: str | None = None,
progress_callback=None,
fetch_fn=None,
) -> list:
"""Select assets using embedding-based cluster-aware FPS.
@@ -188,9 +202,8 @@ def _select_by_embedding(
4. Embedding computation
5. Cluster-aware selection with hard example weighting
"""
# Determine candidate pool (cap at 3000 for performance)
effective_limit = 30 if limit == "auto" else limit
pool_size = min(3000, max(effective_limit * 20, len(assets)))
pool_size = min(_POOL_CAP, max(effective_limit * _POOL_SCALE, len(assets)))
# Subsample if needed (evenly distributed in time)
if len(assets) > pool_size:
@@ -203,20 +216,25 @@ def _select_by_embedding(
# Process in bounded batches so at most _BATCH decoded images live in RAM
# at once. With 472 candidates each thumbnail is ~3-8 MB decoded; loading
# all at once easily exhausts a 4 GB container limit on CPU.
from concurrent.futures import ThreadPoolExecutor, as_completed
_BATCH = 32
# LIMITATION — thumbnail-resolution embeddings drive full-res crop selection:
# diversity selection runs InsightFace on Immich preview thumbnails (~720p)
# to avoid downloading full-res for every candidate, but the training crop
# comes from the full-resolution original. Embeddings from thumbnails are
# representative in practice, but heavy JPEG compression on a preview could
# produce a subtly different embedding than the full-res version. For most
# libraries this is negligible; it matters if Immich preview quality is low.
_fetch = fetch_fn or _fetch_thumbnail
embeddings, valid_candidates, confidence_scores = [], [], []
quality_filtered = 0
processed = 0
for batch_start in range(0, len(candidates), _BATCH):
batch = candidates[batch_start : batch_start + _BATCH]
for batch_start in range(0, len(candidates), _EMBEDDING_BATCH_SIZE):
batch = candidates[batch_start : batch_start + _EMBEDDING_BATCH_SIZE]
# Download this batch concurrently
batch_images: dict[str, Image.Image] = {}
with ThreadPoolExecutor(max_workers=min(8, len(batch))) as pool:
futures = {pool.submit(_fetch_thumbnail, a["id"]): a for a in batch}
futures = {pool.submit(_fetch, a["id"]): a for a in batch}
for future in as_completed(futures):
asset = futures[future]
try:
@@ -224,7 +242,7 @@ def _select_by_embedding(
if img is not None:
batch_images[asset["id"]] = img
except Exception as e:
logger.debug(f"Failed to fetch thumbnail for {asset['id']}: {e}")
logger.debug("Failed to fetch thumbnail for %s: %s", asset["id"], e)
continue
# Process each image; batch_images goes out of scope after this loop,
@@ -250,7 +268,7 @@ def _select_by_embedding(
)
if not quality.passed:
quality_filtered += 1
logger.debug(f"Quality filtered {asset['id']}: {quality.reason}")
logger.debug("Quality filtered %s: %s", asset["id"], quality.reason)
continue
asset["quality_score"] = quality.blur_score
@@ -264,14 +282,14 @@ def _select_by_embedding(
confidence_scores.append(confidence)
if quality_filtered > 0:
logger.info(f"Quality filtering removed {quality_filtered} images.")
logger.info("Quality filtering removed %s images.", quality_filtered)
if not embeddings:
logger.warning("No valid embeddings found. Falling back to time spread.")
return _select_time_spread(assets, limit)
if limit != "auto" and len(valid_candidates) < limit:
logger.warning(f"Only {len(valid_candidates)} valid embeddings. Returning all.")
logger.warning("Only %s valid embeddings. Returning all.", len(valid_candidates))
return valid_candidates
# --- Phase 5: Near-duplicate removal ---
@@ -285,7 +303,7 @@ def _select_by_embedding(
# Re-check after dedup: pool may have shrunk below limit
if limit != "auto" and len(valid_candidates) < limit:
logger.warning(f"Only {len(valid_candidates)} embeddings after near-duplicate removal. Returning all.")
logger.warning("Only %s embeddings after near-duplicate removal. Returning all.", len(valid_candidates))
return valid_candidates
# --- Phase 6: Cluster-aware selection ---
@@ -345,7 +363,7 @@ def _dedup_embeddings(
dropped = len(embeddings) - len(kept_indices)
if dropped:
logger.info(f"Near-duplicate removal dropped {dropped} images (threshold {_DEDUP_THRESHOLD}).")
logger.info("Near-duplicate removal dropped %s images (threshold %s).", dropped, _DEDUP_THRESHOLD)
return (
[embeddings[i] for i in kept_indices],
@@ -440,7 +458,7 @@ def _compute_adaptive_threshold(emb_normed: np.ndarray) -> float:
median_dist = float(np.median(upper_tri))
threshold = max(0.05, median_dist * 0.20)
logger.debug(f"Adaptive threshold: {threshold:.4f} (median_dist={median_dist:.4f})")
logger.debug("Adaptive threshold: %.4f (median_dist=%.4f)", threshold, median_dist)
return threshold
@@ -476,9 +494,14 @@ def _cluster_aware_selection(
auto_threshold = _compute_adaptive_threshold(emb_normed) if limit == "auto" else 0.0
target = Config.MAX_AUTO_IMAGES if limit == "auto" else limit
# Short-circuit: nothing to select
if limit != "auto" and target <= 0:
return []
# --- Stage 1: K-Medoids clustering ---
k = min(max(5, target // 4), max(1, n // 3), n) # e.g., 1-20 clusters
logger.debug(f"Clustering {n} embeddings into {k} groups (K-Medoids)...")
# Cap k at target so we never seed more cluster representatives than requested.
k = min(max(5, target // 4), max(1, n // 3), n, target) # e.g., 1-20 clusters
logger.debug("Clustering %s embeddings into %s groups (K-Medoids)...", n, k)
# Compute full cosine distance matrix
dist_matrix = 1 - emb_normed @ emb_normed.T
@@ -487,7 +510,7 @@ def _cluster_aware_selection(
selected = list(medoid_indices)
selected_set = set(selected)
logger.debug(f"Selected {len(selected)} cluster medoids as initial picks.")
logger.debug("Selected %s cluster medoids as initial picks.", len(selected))
# --- Stage 2: FPS with hard example weighting ---
min_dists = np.full(n, np.inf)
@@ -527,9 +550,14 @@ def _cluster_aware_selection(
selected_conf = [conf_array[i] for i in selected if conf_array[i] < 1.0]
hard_count = sum(1 for c in selected_conf if c < 0.85)
logger.info(f"Selection complete: {len(selected)} images ({hard_count} hard examples with confidence < 0.85).")
logger.info("Selection complete: %s images (%s hard examples with confidence < 0.85).", len(selected), hard_count)
return [candidates[i] for i in selected]
# Slice to target: the while loop enforces this for non-auto mode, but
# guard here too in case the medoid seed already exceeded target (small target).
result = [candidates[i] for i in selected]
if limit != "auto":
result = result[:target]
return result
# =============================================================================
@@ -542,7 +570,7 @@ def _select_time_spread(assets: list, limit: int | str) -> list:
if limit == "auto":
limit = 30
logger.info(f"Selecting {limit} images using time spread.")
logger.info("Selecting %s images using time spread.", limit)
if len(assets) <= limit:
return assets
+15 -13
View File
@@ -18,6 +18,7 @@ import numpy as np
from PIL import Image
from .cache import get_cache
from .config import _getenv_bool
logger = logging.getLogger(__name__)
@@ -35,7 +36,9 @@ def _suppress_output():
try:
os.dup2(saved_out, 1)
finally:
try:
os.dup2(saved_err, 2)
finally:
os.close(devnull_fd)
os.close(saved_out)
os.close(saved_err)
@@ -48,7 +51,7 @@ _insightface_loaded = False
def _is_force_cpu() -> bool:
"""Check if CPU mode is forced via environment variable."""
return os.getenv("FORCE_CPU", "").lower() in ("true", "1", "yes")
return _getenv_bool("FORCE_CPU", False)
def _preload_cuda_libs() -> None:
@@ -66,7 +69,7 @@ def _preload_cuda_libs() -> None:
else:
logger.debug("onnxruntime.preload_dlls() not available (ORT < 1.21)")
except Exception as e:
logger.warning(f"Failed to preload CUDA/cuDNN DLLs: {e}")
logger.warning("Failed to preload CUDA/cuDNN DLLs: %s", e)
# =============================================================================
@@ -100,7 +103,7 @@ def get_insightface_app():
# Get providers, excluding TensorRT to avoid noisy errors
providers = [p for p in ort.get_available_providers() if p != "TensorrtExecutionProvider"]
logger.debug(f"ONNX providers available: {providers}")
logger.debug("ONNX providers available: %s", providers)
gpu_providers = {
"CUDAExecutionProvider",
@@ -121,7 +124,7 @@ def get_insightface_app():
if p == "OpenVINOExecutionProvider" else p
for p in providers
]
logger.debug(f"OpenVINO EP: device_type={openvino_device}")
logger.debug("OpenVINO EP: device_type=%s", openvino_device)
if not has_gpu_provider and not _is_force_cpu():
logger.warning(
@@ -136,21 +139,21 @@ def get_insightface_app():
device_str = f"OpenVINO ({os.getenv('OPENVINO_DEVICE', 'CPU')})"
else:
device_str = "GPU"
logger.info(f"InsightFace Buffalo_L: loading into memory on {device_str}...")
logger.info("InsightFace Buffalo_L: loading into memory on %s...", device_str)
t0 = time.time()
with _suppress_output():
_insightface_app = FaceAnalysis(name="buffalo_l", root=insightface_home, providers=providers)
_insightface_app.prepare(ctx_id=ctx_id, det_size=(640, 640))
logger.info(f"InsightFace Buffalo_L: ready on {device_str} ({time.time() - t0:.1f}s)")
logger.info("InsightFace Buffalo_L: ready on %s (%.1fs)", device_str, time.time() - t0)
return _insightface_app
except ImportError:
logger.error("InsightFace not installed!")
return None
except Exception as e:
logger.error(f"Failed to load InsightFace: {e}")
logger.error("Failed to load InsightFace: %s", e)
if ctx_id == 0:
logger.warning("InsightFace GPU load failed — retrying on CPU...")
try:
@@ -164,10 +167,10 @@ def get_insightface_app():
providers=["CPUExecutionProvider"],
)
_insightface_app.prepare(ctx_id=-1, det_size=(640, 640))
logger.info(f"InsightFace Buffalo_L: ready on CPU (fallback, {time.time() - t0:.1f}s)")
logger.info("InsightFace Buffalo_L: ready on CPU (fallback, %.1fs)", time.time() - t0)
return _insightface_app
except Exception as ex:
logger.error(f"InsightFace CPU fallback failed: {ex}")
logger.error("InsightFace CPU fallback failed: %s", ex)
return None
@@ -193,7 +196,7 @@ def get_face_embedding(img_pil: Image.Image) -> np.ndarray | None:
largest = max(faces, key=lambda f: (f.bbox[2] - f.bbox[0]) * (f.bbox[3] - f.bbox[1]))
return largest.embedding
except Exception as e:
logger.error(f"Error getting face embedding: {e}")
logger.error("Error getting face embedding: %s", e)
return None
@@ -214,7 +217,7 @@ def get_embedding(
from .config import Config
use_cache = Config.ENABLE_CACHE and asset_id is not None
cache = get_cache(Config.CACHE_DIR) if use_cache else None
cache = get_cache(Config.DATA_DIR) if use_cache else None
if cache:
cached = cache.get(asset_id, "insightface")
@@ -232,8 +235,7 @@ def get_embedding(
def _is_module_available(module_name: str) -> bool:
"""Check if a Python module is importable without importing it fully."""
try:
importlib.util.find_spec(module_name)
return True
return importlib.util.find_spec(module_name) is not None
except (ModuleNotFoundError, ValueError):
return False
+128 -158
View File
@@ -3,10 +3,10 @@
import logging
import os
import shutil
import time
from io import BytesIO
from urllib.parse import quote
import PIL
import requests
from PIL import Image
from rich import print as rprint
@@ -14,15 +14,18 @@ from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn
from .config import Config, get_headers
from .frigate_api import (
_get_frigate_url,
delete_frigate_person_files,
get_all_frigate_person_files,
get_frigate_person_files,
get_frigate_version,
recognize_face,
)
from .image_processing import process_face_mode
from .immich_api import fetch_face_data, fetch_full_image
from .immich_api import fetch_full_image
from .log_config import console
from .quality import assess_quality
from .quality import blur_score_from_image
from .reconcile import enrich_asset_with_face_data, reconcile_frigate_mappings
from .upload_tracker import (
get_lowest_quality_mapped_file,
get_most_redundant_mapped_file,
@@ -31,8 +34,10 @@ from .upload_tracker import (
has_frigate_scores,
mark_rejected,
mark_uploaded,
record_frigate_files_batch,
begin_batch,
flush_batch,
remove_frigate_file,
remove_frigate_files_batch,
)
logger = logging.getLogger(__name__)
@@ -42,9 +47,16 @@ def _safe_person_dir(output_dir: str, person_name: str) -> str:
"""Return the output subdirectory for a person, raising ValueError on path traversal.
os.path.join silently discards output_dir when person_name is absolute,
and '../..' sequences resolve outside the tree. Both are rejected here.
and '../..' sequences resolve outside the tree. Both are rejected by the
realpath+startswith guard, which is the load-bearing security check.
The islink check below provides an earlier, cleaner error message for the
symlink sub-case; it is redundant with (not a replacement for) the
realpath+startswith traversal check.
"""
candidate = os.path.realpath(os.path.join(output_dir, person_name))
raw = os.path.join(output_dir, person_name)
if os.path.islink(raw):
raise ValueError(f"Person name {person_name!r} resolves to a symlink — skipping")
candidate = os.path.realpath(raw)
base = os.path.realpath(output_dir)
# Use the base path as its own prefix when it's the filesystem root ("/"),
# otherwise append os.sep — avoids the false "//" double-slash when base == "/".
@@ -54,112 +66,6 @@ def _safe_person_dir(output_dir: str, person_name: str) -> str:
return candidate
def _reconcile_frigate_mappings(
person_name: str,
known_files_before: set[str],
uploaded: list[tuple[str, str | None]],
) -> None:
"""Map Frigate filenames to asset IDs after a batch of uploads.
Polls until all expected new files appear in the Frigate API, then maps
them to asset IDs by filename timestamp order (Frigate processes the
upload queue in FIFO order, so earlier uploads get earlier timestamps).
KNOWN LIMITATION — race condition with external uploads:
If another client uploads a face file for this person concurrently, the
count of new files will exceed `len(uploaded)` and we bail out entirely
(the "> target" branch). That's safe — we never record a wrong mapping —
but those uploads become permanently unmapped (they won't be eligible for
quality replacement). The right fix is a Frigate API that returns the
filename in the upload response, removing the need for any post-upload
diffing. Until then, the external-upload guard keeps mappings correct at
the cost of occasionally missing them when another client is active.
"""
target = len(uploaded)
current_files: set[str] = set()
for delay in (1, 2, 4, 8):
time.sleep(delay)
fresh = get_frigate_person_files(person_name)
if fresh is None:
logger.warning(
f"{person_name}: Frigate API unreachable during mapping reconciliation"
" — quality replacement won't target these files"
)
return
current_files = set(fresh)
if len(current_files - known_files_before) >= target:
break
new_files = current_files - known_files_before
if len(new_files) == target:
def _ts(fname: str) -> float:
try:
return float(fname.rsplit("_", 1)[-1].replace(".webp", ""))
except (ValueError, IndexError):
return 0.0
mappings = {
frigate_file: asset_id
for (_, asset_id), frigate_file in zip(uploaded, sorted(new_files, key=_ts))
if asset_id
}
record_frigate_files_batch(person_name, mappings)
elif len(new_files) > target:
logger.info(
f"{person_name}: {len(new_files)} new Frigate files for {target} uploads"
" (external upload detected) — skipping file mapping"
)
else:
logger.warning(
f"{person_name}: only {len(new_files)} of {target} expected Frigate files"
" appeared after reconciliation — mapping skipped"
)
def _enrich_asset_with_face_data(asset: dict, person: dict) -> dict:
"""Enrich an asset dict with face bounding box data from the Immich faces API.
The search/metadata endpoint does not include face bounding box data,
so we fetch it from GET /api/faces?id={asset_id} and inject it into
the asset's "people" field so process_face_mode can find it.
Returns the enriched asset dict (modifies in place and returns it).
"""
person_id = person["id"]
face_data = fetch_face_data(asset["id"], person_id=person_id)
if face_data is None:
logger.debug(f"No face data returned for {person.get('name')} in asset {asset.get('id')}")
# Clean any None entries from the people list (can come from Immich API)
if "people" in asset:
asset["people"] = [p for p in asset["people"] if p is not None]
return asset
# Skip zero-area bounding boxes (face detection failed or no face found)
if face_data.bbox == (0, 0, 0, 0):
logger.debug(f"Zero-area bounding box for {person.get('name')} in asset {asset.get('id')}")
# Clean any None entries from the people list (can come from Immich API)
if "people" in asset:
asset["people"] = [p for p in asset["people"] if p is not None]
return asset
face_info = {
"boundingBoxX1": face_data.bbox[0],
"boundingBoxY1": face_data.bbox[1],
"boundingBoxX2": face_data.bbox[2],
"boundingBoxY2": face_data.bbox[3],
"imageWidth": face_data.image_width,
"imageHeight": face_data.image_height,
}
# Inject into asset so process_face_mode can find it via asset["people"]
asset["people"] = [{"id": person_id, "faces": [face_info]}]
asset["face_confidence"] = face_data.confidence
return asset
def execute_jobs(jobs: list[dict]) -> None:
"""Download and process images for all jobs.
@@ -183,7 +89,7 @@ def execute_jobs(jobs: list[dict]) -> None:
insightface_app = get_insightface_app()
except Exception as e:
logger.debug(f"InsightFace unavailable for crop alignment: {e}")
logger.debug("InsightFace unavailable for crop alignment: %s", e)
with Progress(
SpinnerColumn(),
@@ -200,15 +106,25 @@ def execute_jobs(jobs: list[dict]) -> None:
name = person["name"]
job_task = progress.add_task(f"Processing {name}...", total=len(assets))
try:
try:
person_dir = _safe_person_dir(Config.OUTPUT_DIR, name)
except ValueError as e:
logger.error(str(e))
continue
# Face crops are transient (uploaded then discarded); wipe before each run.
# A symlink could appear here via a TOCTOU race after _safe_person_dir
# returned — writing through it would land crops outside output_dir.
if os.path.islink(person_dir):
logger.error("person_dir %s became a symlink after path check — skipping job", person_dir)
continue
try:
if os.path.isdir(person_dir):
shutil.rmtree(person_dir)
os.makedirs(person_dir, exist_ok=True)
except OSError as e:
logger.error("Failed to prepare output dir for %s: %s", name, e)
continue
# Track filename → asset_id, filename → confidence score, filename → crop dims
asset_map: dict[str, str] = {}
@@ -220,7 +136,7 @@ def execute_jobs(jobs: list[dict]) -> None:
try:
# Enrich the asset with face bounding box data from the Immich
# faces API (not included in search/metadata results).
asset = _enrich_asset_with_face_data(asset, person)
asset = enrich_asset_with_face_data(asset, person)
# Skip download if detection confidence already disqualifies
# the asset — avoids fetching a large image we'll discard.
conf = asset.get("face_confidence")
@@ -237,13 +153,29 @@ def execute_jobs(jobs: list[dict]) -> None:
# Use full-resolution for final output when configured
if use_full_res:
img = fetch_full_image(asset["id"])
if img is None:
# Full-res download failed — could be a transient network
# error, so don't mark rejected; it will be retried next run.
pass
else:
resp = requests.get(
f"{Config.IMMICH_URL}/api/assets/{asset['id']}/thumbnail?size=preview&format=JPEG",
headers=get_headers(),
timeout=30,
)
img = Image.open(BytesIO(resp.content)) if resp.ok else None
if resp.ok:
try:
img = Image.open(BytesIO(resp.content))
except (PIL.UnidentifiedImageError, OSError):
# Pillow cannot identify the format or the content is
# truncated. The download already succeeded (resp.ok),
# so this is a data problem, not a transient network
# error — mark rejected so it isn't retried forever.
logger.warning("Invalid image data for asset %s — marking rejected", asset["id"])
mark_rejected(asset["id"], person_name=name)
img = None
else:
img = None
if img is None:
progress.console.print(f"[red]Failed download {asset['id']}[/red]")
@@ -258,20 +190,12 @@ def execute_jobs(jobs: list[dict]) -> None:
if isinstance(saved, tuple):
dims_map[filename] = saved
# Time-spread path: compute blur score from the downloaded
# image. Cap at 1440px so the scale matches the preview
# thumbnails the embedding path uses for scoring — Laplacian
# variance grows with resolution, making full-res and
# thumbnail scores incomparable if left uncapped.
# image. Capped at 1440px via blur_score_from_image() so the
# scale matches the preview thumbnails the embedding path uses
# — Laplacian variance grows with resolution, making full-res
# and thumbnail scores incomparable if left uncapped.
if score_map[filename] is None:
try:
score_img = img.convert("RGB") if img.mode != "RGB" else img
if score_img.width > 1440 or score_img.height > 1440:
score_img = score_img.copy()
score_img.thumbnail((1440, 1440), Image.LANCZOS)
score_map[filename] = assess_quality(score_img).blur_score
except Exception as exc:
logger.debug(f"Quality score fallback for {asset['id']}: {exc}")
score_map[filename] = 0.0 # unknown quality — treat as lowest
score_map[filename] = blur_score_from_image(img)
count += 1
else:
@@ -279,7 +203,7 @@ def execute_jobs(jobs: list[dict]) -> None:
f"[yellow]Skipped {asset['id']} (no usable face data)[/yellow]"
)
except Exception as e:
logger.error(f"Failed to process asset {asset['id']}: {e}")
logger.error("Failed to process asset %s: %s", asset.get("id", "<unknown>"), e)
progress.advance(job_task)
progress.advance(overall_task)
@@ -289,11 +213,11 @@ def execute_jobs(jobs: list[dict]) -> None:
job["score_map"] = score_map
job["dims_map"] = dims_map
progress.remove_task(job_task)
# Log how many images were actually saved vs selected
if count < len(assets):
logger.info(f"{name}: saved {count}/{len(assets)} selected images")
logger.info("%s: saved %s/%s selected images", name, count, len(assets))
finally:
progress.remove_task(job_task)
def upload_to_frigate(jobs: list[dict]) -> None:
@@ -306,11 +230,23 @@ def upload_to_frigate(jobs: list[dict]) -> None:
rprint("[dim]No jobs to upload.[/dim]")
return
frigate_url = os.environ.get("FRIGATE_URL", "")
frigate_url = _get_frigate_url()
if not frigate_url:
rprint("[yellow]⚠️ FRIGATE_URL not set, skipping upload.[/yellow]")
return
_frigate_version = get_frigate_version()
if _frigate_version is not None:
try:
parts = [int(x) for x in _frigate_version.lstrip("v").split("-")[0].split(".") if x.isdigit()]
if len(parts) >= 2 and (parts[0], parts[1]) < (0, 16):
rprint(
f" [yellow]⚠ Frigate {_frigate_version} detected — "
"face training API requires v0.16+. Uploads may fail.[/yellow]"
)
except Exception:
pass
rprint("\n[bold cyan]📤 Uploading to Frigate[/bold cyan]")
rprint(f" Target: [dim]{frigate_url}[/dim]")
@@ -380,28 +316,40 @@ def upload_to_frigate(jobs: list[dict]) -> None:
# manually-added Frigate files don't consume winnow's managed quota.
# Replacement targets also come exclusively from the tracker, so manually
# added files are never selected for deletion — only winnow-uploaded ones.
# LIMITATION — manual files are invisible to diversity decisions: winnow
# can observe their effect on the Frigate score (indirectly, via recognize)
# but cannot measure their embedding distribution directly. If a user has
# 20 manually-added frontals and winnow has room for 20 more, winnow may
# add more frontals because it can't see that frontals are already covered.
# TODO(frigate-api): if Frigate exposes per-file embeddings, compute
# diversity against the full training set (tracked + manual) rather than
# relying solely on the Frigate score as a proxy signal.
_snapshot = (
all_frigate_files.get(name, []) if all_frigate_files is not None
else get_frigate_person_files(name)
)
if _snapshot is None:
# Frigate GET is down; fall back to the tracker's mapped filenames
# as the pre-upload baseline. reconciliation will still work unless
# there are concurrent manual uploads (handled by >target guard).
# Frigate GET is down. The tracker only knows files winnow mapped
# previously — it is blind to manually-added Frigate files. Using
# the tracker as the baseline would make those unmapped files look
# like new uploads in reconcile, triggering the >target guard and
# silently dropping all mappings. Skip reconciliation entirely when
# we can't get a reliable live snapshot.
logger.warning(
f"{name}: Frigate API unreachable at upload start"
" — using tracker baseline for post-upload reconciliation"
"%s: Frigate API unreachable at upload start"
" — file mapping will be skipped for this batch", name
)
known_frigate_files_at_start: set[str] = get_tracked_frigate_filenames(name)
known_frigate_files_at_start: set[str] = set()
_skip_reconcile = True
else:
known_frigate_files_at_start: set[str] = set(_snapshot)
_skip_reconcile = False
# Remove tracker mappings for files that no longer exist in Frigate
# (manually deleted, or cleaned up outside winnow). This corrects the
# effective_count so those slots are available for new uploads.
stale = get_tracked_frigate_filenames(name) - known_frigate_files_at_start
for stale_fn in stale:
remove_frigate_file(name, stale_fn)
if stale:
remove_frigate_files_batch(name, list(stale))
progress.console.print(
f" [dim]{name}: cleared {len(stale)} stale mapping(s)"
" (file(s) no longer in Frigate)[/dim]"
@@ -418,6 +366,7 @@ def upload_to_frigate(jobs: list[dict]) -> None:
min_quality_score_for_slot: float | None = None
person_has_fscores: bool = has_frigate_scores(name)
begin_batch(UPLOAD_TRACKER_FILE)
for fname in person_files:
fpath = os.path.join(person_dir, fname)
@@ -426,10 +375,9 @@ def upload_to_frigate(jobs: list[dict]) -> None:
# freed slot isn't filled with something worse than what we removed.
if min_quality_score_for_slot is not None:
file_score = score_map.get(fname)
if file_score is None or file_score <= min_quality_score_for_slot:
score_str = f"{file_score:.3f}" if file_score is not None else "N/A"
if file_score is not None and file_score <= min_quality_score_for_slot:
progress.console.print(
f" [dim]⏭ {fname}: score {score_str} ≤ freed slot floor"
f" [dim]⏭ {fname}: score {file_score:.3f} ≤ freed slot floor"
f" {min_quality_score_for_slot:.3f}, skipping[/dim]"
)
progress.advance(upload_task)
@@ -447,6 +395,14 @@ def upload_to_frigate(jobs: list[dict]) -> None:
# Frigate rebuilds its model asynchronously after any delete (clear + background
# thread), so the first recognize call after a deletion returns None — our code
# handles this conservatively by skipping that candidate until the next run.
# LIMITATION — async rebuild during multi-replacement runs: each deletion in a
# single run triggers a background model rebuild in Frigate. Subsequent recognize
# calls in the same run may get None (rebuild in progress), causing later
# candidates to fall back to blur-score replacement or be skipped entirely.
# The more replacements that happen in one run, the worse the scoring gets.
# TODO(frigate-api): if Frigate exposes a model generation counter or a
# rebuild-complete signal, poll it between recognize calls during replacement
# sequences rather than accepting stale/None scores.
pre_fscore: float | None = None
if Config.ENABLE_FRIGATE_SCORES and pre_run_count > 0:
if not at_cap or person_has_fscores:
@@ -492,11 +448,13 @@ def upload_to_frigate(jobs: list[dict]) -> None:
get_target = get_most_redundant_mapped_file
score_label, better_note = "frigate", " (more novel)"
no_score_msg = "Frigate recognize unavailable, skipping replacement"
is_better_than = lambda c, t: c < t
else:
candidate_score = score_map.get(fname)
get_target = get_lowest_quality_mapped_file
score_label, better_note = "blur", ""
no_score_msg = "no quality score, skipping replacement"
is_better_than = lambda c, t: c > t
if candidate_score is None:
progress.console.print(f" [dim]⏭ {fname}: {no_score_msg}[/dim]")
@@ -504,32 +462,29 @@ def upload_to_frigate(jobs: list[dict]) -> None:
continue
target = get_target(name, exclude=failed_deletes)
not_better = target is None or (
candidate_score >= target[2] if using_fscore else candidate_score <= target[2]
)
not_better = target is None or not is_better_than(candidate_score, target[2])
if not_better:
target_str = f"{target[2]:.3f}" if target is not None else "N/A"
op = "<" if using_fscore else ">"
cmp_op = "<" if using_fscore else ">"
progress.console.print(
f" [dim]⏭ {fname}: {score_label} {candidate_score:.3f}"
f" not {op} {target_str}, skipping[/dim]"
f" not {cmp_op} {target_str}, skipping[/dim]"
)
progress.advance(upload_task)
continue
target_frigate_file, _target_asset_id, target_score = target
op = "<" if using_fscore else ">"
cmp_op = "<" if using_fscore else ">"
progress.console.print(
f" 🔄 {fname}: {score_label} {candidate_score:.3f} {op} {target_score:.3f},"
f" 🔄 {fname}: {score_label} {candidate_score:.3f} {cmp_op} {target_score:.3f},"
f" replacing {target_frigate_file}{better_note}"
)
if delete_frigate_person_files(name, [target_frigate_file]):
remove_frigate_file(name, target_frigate_file)
person_has_fscores = has_frigate_scores(name)
effective_count -= 1
min_quality_score_for_slot = None if using_fscore else candidate_score
min_quality_score_for_slot = None if using_fscore else target_score
else:
logger.warning(f"Failed to delete {target_frigate_file} for {name}, skipping replacement")
logger.warning("Failed to delete %s for %s, skipping replacement", target_frigate_file, name)
failed_deletes.add(target_frigate_file)
progress.advance(upload_task)
continue
@@ -550,6 +505,7 @@ def upload_to_frigate(jobs: list[dict]) -> None:
asset_id = asset_map.get(fname)
if asset_id:
try:
mark_uploaded(
asset_id,
person_name=name,
@@ -557,6 +513,14 @@ def upload_to_frigate(jobs: list[dict]) -> None:
crop_dims=dims_map.get(fname),
frigate_score=pre_fscore,
)
except Exception as tracker_exc:
# Upload to Frigate succeeded — don't retry on tracker
# failure or we'd upload a duplicate to Frigate.
logger.error(
"Tracker write failed for %s — upload succeeded"
" but asset may be re-selected next run: %s",
fname, tracker_exc,
)
if pre_fscore is not None:
person_has_fscores = True
actually_uploaded.append((fname, asset_id))
@@ -582,8 +546,12 @@ def upload_to_frigate(jobs: list[dict]) -> None:
if resp.status_code == 400:
progress.console.print(f" [dim]{error_detail}[/dim]")
else:
logger.debug(f"{fname} HTTP {resp.status_code}: {error_detail}")
if resp.status_code == 400 and "face" in full_body.lower():
logger.debug("%s HTTP %s: %s", fname, resp.status_code, error_detail)
_is_permanent = (
(resp.status_code == 400 and "face" in full_body.lower())
or resp.status_code == 422
)
if _is_permanent:
asset_id = asset_map.get(fname)
if asset_id:
mark_rejected(asset_id, person_name=name)
@@ -625,9 +593,11 @@ def upload_to_frigate(jobs: list[dict]) -> None:
" was not filled this run — will be available next run"
)
flush_batch(UPLOAD_TRACKER_FILE)
# Batch-map Frigate filenames to asset IDs now that all uploads are done.
if actually_uploaded:
_reconcile_frigate_mappings(name, known_frigate_files_at_start, actually_uploaded)
if actually_uploaded and not _skip_reconcile:
reconcile_frigate_mappings(name, known_frigate_files_at_start, actually_uploaded)
# Per-person summary
if person_failed == 0:
+59 -17
View File
@@ -8,9 +8,32 @@ import requests
logger = logging.getLogger(__name__)
def _get_frigate_url() -> str:
"""Return normalized FRIGATE_URL with whitespace and trailing slash stripped, or '' if unset."""
return os.environ.get("FRIGATE_URL", "").strip().rstrip("/")
def get_frigate_version() -> str | None:
"""Fetch Frigate's version string from GET /api/version.
Returns the version string (e.g. "0.16.0-beta4") or None if FRIGATE_URL
is unset, the endpoint is unreachable, or the response is not parseable.
"""
frigate_url = _get_frigate_url()
if not frigate_url:
return None
try:
resp = requests.get(f"{frigate_url}/api/version", timeout=5)
if resp.ok:
return resp.text.strip().strip('"')
return None
except Exception:
return None
def _get_faces_data() -> dict | None:
"""Fetch raw GET /api/faces response. Returns None if unavailable."""
frigate_url = os.environ.get("FRIGATE_URL", "").rstrip("/")
frigate_url = _get_frigate_url()
if not frigate_url:
return None
try:
@@ -18,7 +41,7 @@ def _get_faces_data() -> dict | None:
resp.raise_for_status()
return resp.json()
except Exception as e:
logger.warning(f"Could not query Frigate faces API: {e}")
logger.warning("Could not query Frigate faces API: %s", e)
return None
@@ -33,11 +56,17 @@ def get_all_frigate_person_files() -> dict[str, list[str]] | None:
return None
# Response: {person_name: [file, ...], "train": [...], ...}
# "train" is a flat pending list, not a person — skip it.
return {
name: files
for name, files in data.items()
if name != "train" and isinstance(files, list)
}
# TODO(frigate-api): "train" is the only known special key as of Frigate v0.16.
# Log unexpected non-list values so future Frigate schema additions are visible.
result = {}
for name, files in data.items():
if name == "train":
continue
if isinstance(files, list):
result[name] = files
else:
logger.debug("Frigate API: skipping unexpected key %r (got %s, not list)", name, type(files).__name__)
return result
def get_frigate_face_counts() -> dict[str, int] | None:
@@ -62,7 +91,10 @@ def get_frigate_person_files(person_name: str) -> list[str] | None:
if data is None:
return None
files = data.get(person_name)
return files if isinstance(files, list) else []
if files is not None and not isinstance(files, list):
logger.debug("Frigate API: unexpected type for %r — got %s, not list", person_name, type(files).__name__)
return []
return files if files is not None else []
def recognize_face(file_path: str) -> tuple[str | None, float] | None:
@@ -75,8 +107,18 @@ def recognize_face(file_path: str) -> tuple[str | None, float] | None:
Returns None if FRIGATE_URL is unset, the API is unreachable, no face is
detected, or face recognition is not enabled in Frigate.
LIMITATION — mean embedding comparison: the score reflects similarity to
the arithmetic mean of all training embeddings, not to individual ones.
A bimodal training set (e.g. frontals + profiles) has a mean that sits
between both clusters, making candidates from either cluster look more
novel than they are. Winnow could add redundant frontals while the score
suggests novelty, because the mean is pulled toward profiles.
TODO(frigate-api): if Frigate exposes per-file embeddings via the API,
replace mean-comparison with nearest-neighbour distance across individual
training embeddings for accurate coverage detection.
"""
frigate_url = os.environ.get("FRIGATE_URL", "").rstrip("/")
frigate_url = _get_frigate_url()
if not frigate_url:
return None
try:
@@ -93,7 +135,7 @@ def recognize_face(file_path: str) -> tuple[str | None, float] | None:
return (data.get("face_name"), round(float(data["score"]), 4))
return None
except Exception as e:
logger.debug(f"Frigate recognize failed for {file_path}: {e}")
logger.debug("Frigate recognize failed for %s: %s", file_path, e)
return None
@@ -103,27 +145,27 @@ def delete_frigate_person_files(person_name: str, filenames: list[str]) -> bool:
Uses POST /api/faces/{name}/delete with body {"ids": [filename, ...]}.
Returns True on success, False if unreachable or the request fails.
"""
frigate_url = os.environ.get("FRIGATE_URL", "").rstrip("/")
frigate_url = _get_frigate_url()
if not frigate_url or not filenames:
return False
from urllib.parse import quote
encoded = quote(person_name, safe="")
encoded_name = quote(person_name, safe="")
try:
resp = requests.post(
f"{frigate_url}/api/faces/{encoded}/delete",
f"{frigate_url}/api/faces/{encoded_name}/delete",
json={"ids": filenames},
timeout=10,
)
if resp.ok:
logger.debug(f"Deleted {len(filenames)} Frigate file(s) for {person_name}")
logger.debug("Deleted %s Frigate file(s) for %s", len(filenames), person_name)
return True
if resp.status_code == 404:
# File already absent — stale tracker entry. Return True so the caller
# removes it from the tracker and frees the slot cleanly.
logger.warning(f"Frigate file(s) not found for {person_name} (stale tracker entry?): {filenames}")
logger.warning("Frigate file(s) not found for %s (stale tracker entry?): %s", person_name, filenames)
return True
logger.warning(f"Frigate delete returned {resp.status_code} for {person_name}")
logger.warning("Frigate delete returned %s for %s", resp.status_code, person_name)
return False
except Exception as e:
logger.warning(f"Failed to delete Frigate files for {person_name}: {e}")
logger.warning("Failed to delete Frigate files for %s: %s", person_name, e)
return False
+15 -6
View File
@@ -15,7 +15,16 @@ logger = logging.getLogger(__name__)
def _save_jpeg(img: Image.Image, path: str) -> None:
if img.mode != "RGB":
img = img.convert("RGB")
img.save(path, format="JPEG")
tmp = path + ".tmp"
try:
img.save(tmp, format="JPEG")
os.replace(tmp, path)
except Exception:
try:
os.remove(tmp)
except OSError:
pass
raise
def align_face(img: Image.Image, landmarks: list[list[float]] | np.ndarray) -> Image.Image | None:
@@ -37,7 +46,7 @@ def align_face(img: Image.Image, landmarks: list[list[float]] | np.ndarray) -> I
img_np = np.asarray(img)
lm = np.array(landmarks, dtype=np.float32)
if lm.shape != (5, 2):
logger.debug(f"Invalid landmark shape: {lm.shape}, expected (5, 2)")
logger.debug("Invalid landmark shape: %s, expected (5, 2)", lm.shape)
return None
aligned = norm_crop(img_np, lm)
return Image.fromarray(aligned)
@@ -45,7 +54,7 @@ def align_face(img: Image.Image, landmarks: list[list[float]] | np.ndarray) -> I
logger.debug("InsightFace not available for face alignment")
return None
except Exception as e:
logger.debug(f"Face alignment failed: {e}")
logger.debug("Face alignment failed: %s", e)
return None
@@ -79,7 +88,7 @@ def process_face_mode(
break
if not face_info:
logger.debug(f"No face info for {person.get('name')} in asset {asset.get('id')}")
logger.debug("No face info for %s in asset %s", person.get("name"), asset.get("id"))
return None
img_w, img_h = img.size
@@ -95,7 +104,7 @@ def process_face_mode(
face_w, face_h = x2 - x1, y2 - y1
if face_w < min_width or face_h < min_width:
logger.debug(f"Face too small ({face_w:.1f}x{face_h:.1f})")
logger.debug("Face too small (%.1fx%.1f)", face_w, face_h)
return None
# Re-detect face with InsightFace for landmark-based alignment.
@@ -131,7 +140,7 @@ def process_face_mode(
_save_jpeg(aligned, os.path.join(output_dir, f"{count}.jpg"))
return aligned.size
except Exception as e:
logger.debug(f"InsightFace re-detection failed for {asset.get('id')}: {e}")
logger.debug("InsightFace re-detection failed for %s: %s", asset.get("id"), e)
# Landmark alignment from Immich metadata (Immich does not currently
# expose landmarks, so this path is a future-proofing fallback)
+94 -29
View File
@@ -26,6 +26,25 @@ class FaceData:
image_height: int
def get_immich_version() -> tuple[int, int, int] | None:
"""Fetch Immich server version from GET /api/server/version.
Returns (major, minor, patch) or None if unreachable or unparseable.
"""
try:
resp = requests.get(
f"{Config.IMMICH_URL}/api/server/version",
headers=get_headers(),
timeout=5,
)
if resp.ok:
data = resp.json()
return (int(data["major"]), int(data["minor"]), int(data["patch"]))
return None
except Exception:
return None
def get_people() -> list[dict]:
"""Fetch all people from Immich."""
try:
@@ -40,7 +59,7 @@ def get_people() -> list[dict]:
resp.raise_for_status()
return resp.json().get("people", [])
except (requests.RequestException, ValueError) as e:
logger.error(f"Failed to fetch people from Immich: {e}")
logger.error("Failed to fetch people from Immich: %s", e)
return []
@@ -60,20 +79,32 @@ def merge_people(survivor_id: str, merge_ids: list[str]) -> bool:
resp.raise_for_status()
return True
except requests.RequestException as e:
logger.error(f"Failed to merge people into {survivor_id}: {e}")
logger.error("Failed to merge people into %s: %s", survivor_id, e)
return False
def fetch_all_assets(person: dict) -> list[dict]:
"""Fetch all assets for a person with pagination."""
def fetch_all_assets(person: dict) -> tuple[list[dict], int]:
"""Fetch all assets for a person with pagination.
Returns (assets, total_raw) where assets is the list of valid dict items
and total_raw is the raw item count across pages that had at least one valid
dict. All-garbage pages (every item non-dict) stop pagination and are not
counted. total_raw is a lower bound in two cases: a network error interrupts
pagination (a warning is logged), or an all-garbage page terminates it early
(a warning is logged and later pages are not fetched).
"""
name = person.get("name", "Unknown")
person_id = person["id"]
person_id = person.get("id")
if not person_id:
logger.error("Person dict missing 'id' field for %s — skipping asset fetch", name)
return [], 0
url = f"{Config.IMMICH_URL}/api/search/metadata"
page_size = 1000
logger.debug(f"Fetching assets for {name}...")
logger.debug("Fetching assets for %s...", name)
assets = []
assets: list[dict] = []
total_raw = 0 # raw item count across pages that yielded at least one valid dict
for page in range(1, MAX_PAGES + 1):
try:
resp = requests.post(
@@ -84,27 +115,56 @@ def fetch_all_assets(person: dict) -> list[dict]:
)
if not resp.ok:
logger.error(f"Error fetching assets for {name} (page {page}): {resp.status_code}")
logger.error("Error fetching assets for %s (page %s): %s", name, page, resp.status_code)
break
page_assets = resp.json().get("assets", [])
# Immich ≥2.x returns {"assets": {"items": [...]}};
# earlier versions returned {"assets": [...]} directly.
if isinstance(page_assets, dict):
page_assets = page_assets.get("items", [])
if not page_assets:
page_count = len(page_assets) # raw count for termination check before filtering
# Single pass: partition valid assets from unexpected non-dict items
valid_assets, skipped_count = [], 0
for item in page_assets:
if isinstance(item, dict):
valid_assets.append(item)
else:
skipped_count += 1
if skipped_count:
logger.warning("%s: skipping %s non-dict item(s) in page %s", name, skipped_count, page)
if not valid_assets:
if page_count > 0:
logger.warning(
"%s: page %s returned %s item(s) but none were valid dicts — stopping pagination",
name, page, page_count,
)
break
assets.extend(a for a in page_assets if isinstance(a, dict))
logger.debug(f"Fetched page {page}, total: {len(assets)}")
# Count page_count (not just valid items) so that non-dict items from a
# transient schema issue on a mixed page don't cause MIN_FACE_COUNT to
# skip a real person. Pages where every item is a non-dict are excluded —
# they indicate a structural problem and break above without contributing.
total_raw += page_count
assets.extend(valid_assets)
logger.debug("Fetched page %s, total: %s", page, len(assets))
if len(page_assets) < page_size or len(assets) >= _MAX_ASSETS_PER_PERSON:
if page_count < page_size or len(assets) >= _MAX_ASSETS_PER_PERSON:
break
except (requests.RequestException, ValueError) as e:
logger.error(f"Exception fetching assets for {name}: {e}")
logger.error("Exception fetching assets for %s (page %s): %s", name, page, e)
if page > 1:
logger.warning(
"%s: pagination interrupted at page %s — total_raw=%s may undercount actual assets",
name, page, total_raw,
)
break
return assets
return assets, total_raw
def fetch_face_data(asset_id: str, person_id: str | None = None) -> FaceData | None:
@@ -129,22 +189,24 @@ def fetch_face_data(asset_id: str, person_id: str | None = None) -> FaceData | N
)
if not resp.ok:
logger.debug(f"Face data endpoint returned {resp.status_code} for {asset_id}")
logger.debug("Face data endpoint returned %s for %s", resp.status_code, asset_id)
return None
faces = resp.json()
if not faces:
if not isinstance(faces, list) or not faces:
return None
# Match the target person if specified
# Match the target person if specified; never fall back to a different person's face.
face = None
if person_id:
face = next(
(f for f in faces if (f.get("person") or {}).get("id") == person_id),
(f for f in faces if isinstance(f, dict) and (f.get("person") or {}).get("id") == person_id),
None,
)
else:
face = faces[0] if isinstance(faces[0], dict) else None
if face is None:
face = faces[0] # Fall back to first/largest face
return None
bbox = (
face.get("boundingBoxX1", 0),
@@ -162,10 +224,10 @@ def fetch_face_data(asset_id: str, person_id: str | None = None) -> FaceData | N
)
except requests.RequestException as e:
logger.debug(f"Failed to fetch face data for {asset_id}: {e}")
logger.debug("Failed to fetch face data for %s: %s", asset_id, e)
return None
except (AttributeError, KeyError, TypeError, ValueError) as e:
logger.debug(f"Failed to parse face data for {asset_id}: {e}")
logger.debug("Failed to parse face data for %s: %s", asset_id, e)
return None
@@ -186,9 +248,9 @@ def fetch_full_image(asset_id: str, timeout: int = 60) -> Image.Image | None:
try:
return ImageOps.exif_transpose(Image.open(BytesIO(resp.content)))
except Exception:
logger.debug(f"PIL can't open original for {asset_id}, falling back to preview")
logger.debug("PIL can't open original for %s, falling back to preview", asset_id)
except requests.RequestException:
logger.debug(f"Original request failed for {asset_id}, falling back to preview")
logger.debug("Original request failed for %s, falling back to preview", asset_id)
# Fall back to preview thumbnail (always JPEG)
try:
@@ -200,22 +262,25 @@ def fetch_full_image(asset_id: str, timeout: int = 60) -> Image.Image | None:
if resp.ok:
return ImageOps.exif_transpose(Image.open(BytesIO(resp.content)))
except Exception as e:
logger.error(f"Failed to fetch image {asset_id}: {e}")
logger.error("Failed to fetch image %s: %s", asset_id, e)
return None
def filter_recent_assets(assets: list[dict], years: int | None = None) -> list[dict]:
"""Filter assets to keep only those from the last N years."""
years = years or Config.YEARS_FILTER
"""Filter assets to keep only those from the last N years. Pass years=0 to include all."""
if years is None:
years = Config.YEARS_FILTER
if not years:
return list(assets)
cutoff = datetime.now(timezone.utc) - timedelta(days=365 * years)
logger.debug(f"Filtering assets older than {years} years ({cutoff})")
logger.debug("Filtering assets older than %s years (%s)", years, cutoff)
recent, skipped = [], 0
for asset in assets:
created_at_str = asset.get("fileCreatedAt")
if not created_at_str:
if not isinstance(created_at_str, str) or not created_at_str:
continue
try:
@@ -228,6 +293,6 @@ def filter_recent_assets(assets: list[dict], years: int | None = None) -> list[d
except ValueError:
continue
logger.debug(f"Retained {len(recent)} assets (filtered {skipped} old assets).")
logger.debug("Retained %s assets (filtered %s old assets).", len(recent), skipped)
return recent
+72 -55
View File
@@ -8,7 +8,7 @@ from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn
from rich.prompt import Confirm, IntPrompt, Prompt
from rich.table import Table
from .config import Config
from .config import Config, _getenv_bool, _getenv_int, _getenv_optional_int
from .diversity import select_diverse_assets
from .embeddings import is_embedding_available, load_embedding_model
from .frigate_api import get_frigate_face_counts
@@ -65,14 +65,12 @@ def _get_strategy_choice(has_embedding: bool) -> tuple[int | str, str]:
def _resolve_strategy(strategy: str, has_embedding: bool) -> tuple[int | str, str]:
"""Resolve env var strategy to (limit, selection_mode) without prompts."""
custom_limit = os.environ.get("LIMIT", "").strip()
if not has_embedding:
limit = int(custom_limit) if custom_limit else 30
return limit, "time"
return _getenv_int("LIMIT", 30), "time"
if custom_limit:
return int(custom_limit), "smart"
custom_limit = _getenv_optional_int("LIMIT")
if custom_limit is not None:
return custom_limit, "smart"
strategy_map = {
"adaptive": ("auto", "smart"),
@@ -120,28 +118,46 @@ def _perform_selection(
return selected
def _build_job(
person: dict,
assets: list,
limit: int | str,
selection_mode: str,
quality_replacement: bool = False,
) -> dict | None:
"""Select from pre-filtered assets and build a job dict. No terminal I/O."""
if not assets:
return None
name = person["name"]
selected = _perform_selection(assets, limit, name, selection_mode, person_id=person["id"])
if not selected:
return None
return {
"person": person,
"assets": selected,
"limit": len(selected),
"config": {"name": name, "quality_replacement": quality_replacement},
}
def _configure_person(person: dict, people: list[dict]) -> dict | None:
"""Configure training for a single person. Returns job dict or None."""
name = person["name"]
console.print(f"\nSelected: [bold green]{name}[/bold green]")
config = {"name": name, "quality_replacement": Config.QUALITY_REPLACEMENT}
# Fetch and filter assets
years = IntPrompt.ask("Filter images older than (years)", default=Config.YEARS_FILTER)
console.print(f"Scanning for {name}...")
with console.status("[bold green]Fetching assets...[/bold green]"):
all_assets = fetch_all_assets(person)
all_assets, total_raw = fetch_all_assets(person)
recent_assets = filter_recent_assets(all_assets, years=years)
rprint(f" Found [bold]{len(all_assets)}[/bold] total, [bold]{len(recent_assets)}[/bold] in range ({years} years).")
rprint(f" Found [bold]{total_raw}[/bold] total, [bold]{len(recent_assets)}[/bold] in range ({years} years).")
# Filter out assets already uploaded to Frigate.
# In interactive mode, ask — use the env var only as the default so it can
# still be pre-set (e.g. RETRY_REJECTED=true) without forcing the answer.
retry_env = os.environ.get("RETRY_REJECTED", "false").lower() in ("true", "1", "yes")
# Ask before strategy so the post-dedup count can inform the choice
retry_env = _getenv_bool("RETRY_REJECTED", False)
retry_rejected = Confirm.ask("Include previously rejected images?", default=retry_env)
before_dedup = len(recent_assets)
new_asset_ids = set(filter_already_uploaded([a["id"] for a in recent_assets], retry_rejected=retry_rejected))
recent_assets = [a for a in recent_assets if a["id"] in new_asset_ids]
@@ -153,19 +169,19 @@ def _configure_person(person: dict, people: list[dict]) -> dict | None:
rprint(" [dim]Skipping (0 new images after dedup).[/dim]")
return None
# Strategy selection
has_embedding = is_embedding_available()
rprint(f"\n[bold cyan]Select Training Strategy for {name}:[/bold cyan]")
limit, selection_mode = _get_strategy_choice(has_embedding)
if selection_mode == "skip":
return None
# Perform selection
selected_assets = _perform_selection(recent_assets, limit, name, selection_mode, person_id=person["id"])
job = _build_job(person, recent_assets, limit, selection_mode, quality_replacement=Config.QUALITY_REPLACEMENT)
if job is None:
rprint(" [dim]Skipping (0 images selected).[/dim]")
return None
rprint(f" [green]Queued {len(selected_assets)} images for {name}.[/green]")
return {"person": person, "assets": selected_assets, "limit": len(selected_assets), "config": config}
rprint(f" [green]Queued {job['limit']} images for {name}.[/green]")
return job
def interactive_configure(people: list[dict]) -> list[dict]:
@@ -213,23 +229,15 @@ def auto_configure(people: list[dict]) -> list[dict]:
return []
strategy = os.environ.get("STRATEGY", "auto")
skip = os.environ.get("SKIP_PEOPLE", "").split(",") if os.environ.get("SKIP_PEOPLE") else []
only = os.environ.get("ONLY_PEOPLE", "").split(",") if os.environ.get("ONLY_PEOPLE") else []
skip = [s.strip() for s in os.environ.get("SKIP_PEOPLE", "").split(",") if s.strip()]
only = [s.strip() for s in os.environ.get("ONLY_PEOPLE", "").split(",") if s.strip()]
if only:
valid_people = [p for p in valid_people if p["name"] in only]
if skip:
valid_people = [p for p in valid_people if p["name"] not in skip]
# Filter by minimum face count (Issue #6: previously unimplemented)
min_face_count = Config.MIN_FACE_COUNT
if min_face_count > 0:
valid_people = [p for p in valid_people if p.get("assetCount", 0) >= min_face_count]
if valid_people:
rprint(
f" Filtered to {len(valid_people)} people with"
f" ≥{min_face_count} assets (MIN_FACE_COUNT={min_face_count})"
)
frigate_counts = get_frigate_face_counts()
# Persist each count to tracker so the last known value survives Frigate downtime
@@ -240,24 +248,19 @@ def auto_configure(people: list[dict]) -> list[dict]:
jobs = []
for person in valid_people:
name = person["name"]
config = {"name": name}
all_assets = fetch_all_assets(person)
all_assets, total_raw = fetch_all_assets(person)
recent_assets = filter_recent_assets(all_assets, years=Config.YEARS_FILTER)
rprint(f" {name}: {len(all_assets)} total, {len(recent_assets)} recent")
rprint(f" {name}: {total_raw} total, {len(recent_assets)} recent")
# Filter out assets already uploaded to Frigate
retry_rejected = os.environ.get("RETRY_REJECTED", "false").lower() in ("true", "1", "yes")
before_dedup = len(recent_assets)
new_asset_ids = set(filter_already_uploaded([a["id"] for a in recent_assets], retry_rejected=retry_rejected))
recent_assets = [a for a in recent_assets if a["id"] in new_asset_ids]
skipped = before_dedup - len(recent_assets)
if skipped:
rprint(f" [dim]Skipped {skipped} assets already uploaded to Frigate.[/dim]")
if not recent_assets:
rprint(f" [dim]Skipping {name} (0 new images after dedup).[/dim]")
# MIN_FACE_COUNT guard: skip people with too few Immich assets.
# Uses total_raw so that non-dict items from a transient Immich schema
# issue on a mixed page don't shrink the count below the threshold.
# Done here (after fetch) rather than upfront because Immich v2.7.5+
# dropped assetCount from the /api/people response.
if min_face_count > 0 and total_raw < min_face_count:
rprint(f" [dim]Skipping {name} ({total_raw} assets < MIN_FACE_COUNT={min_face_count}).[/dim]")
continue
# Enforce MAX_AUTO_IMAGES against the tracked file count only.
@@ -281,31 +284,45 @@ def auto_configure(people: list[dict]) -> list[dict]:
else:
quality_replacement_only = False
config["quality_replacement"] = quality_replacement_only or Config.QUALITY_REPLACEMENT
quality_replacement = quality_replacement_only or Config.QUALITY_REPLACEMENT
has_embedding = is_embedding_available()
limit, selection_mode = _resolve_strategy(strategy, has_embedding)
# Cap selection to remaining capacity (no cap when replacement-only — executor
# decides per-image whether to swap; any candidate could be an improvement).
auto_cap = None
if not quality_replacement_only:
if limit == "auto":
# Switch from open-ended auto to a fixed budget at remaining capacity
# so the diversity selector itself stops at the right count instead of
# selecting MAX_AUTO_IMAGES and then discarding the excess by position.
if already_uploaded > 0:
auto_cap = capacity
limit = capacity
else:
limit = min(limit, capacity)
if selection_mode == "skip":
continue
selected_assets = _perform_selection(recent_assets, limit, name, selection_mode, person_id=person["id"])
if auto_cap is not None:
selected_assets = selected_assets[:auto_cap]
retry_rejected = _getenv_bool("RETRY_REJECTED", False)
before_dedup = len(recent_assets)
new_asset_ids = set(filter_already_uploaded([a["id"] for a in recent_assets], retry_rejected=retry_rejected))
recent_assets = [a for a in recent_assets if a["id"] in new_asset_ids]
skipped = before_dedup - len(recent_assets)
if skipped:
rprint(f" [dim]Skipped {skipped} assets already uploaded to Frigate.[/dim]")
if selected_assets:
rprint(f" [green]Queued {len(selected_assets)} images for {name}.[/green]")
jobs.append({"person": person, "assets": selected_assets, "limit": len(selected_assets), "config": config})
if not recent_assets:
rprint(f" [dim]Skipping {name} (0 new images after dedup).[/dim]")
continue
job = _build_job(person, recent_assets, limit, selection_mode, quality_replacement=quality_replacement)
if job is None:
rprint(f" [dim]Skipping {name} (0 images selected).[/dim]")
continue
rprint(f" [green]Queued {job['limit']} images for {name}.[/green]")
jobs.append(job)
return jobs
+22
View File
@@ -138,3 +138,25 @@ def assess_quality(
return QualityResult(passed=len(reasons) == 0, reasons=reasons, blur_score=blur_score)
def blur_score_from_image(img: Image.Image, max_dim: int = 1440) -> float | None:
"""Compute Laplacian-variance blur score, capped at max_dim px to normalise scale.
Caps resolution so full-res and thumbnail scores are comparable — Laplacian
variance grows with pixel count, making uncapped full-res scores much larger
than thumbnail scores for the same perceived sharpness.
Returns None on error so callers can distinguish a failed measurement from a
legitimately low (near-zero) score.
"""
try:
score_img = img.convert("RGB") if img.mode != "RGB" else img
if score_img.width > max_dim or score_img.height > max_dim:
score_img = score_img.copy()
score_img.thumbnail((max_dim, max_dim), Image.LANCZOS)
gray = cv2.cvtColor(np.array(score_img), cv2.COLOR_RGB2GRAY)
return float(cv2.Laplacian(gray, cv2.CV_64F).var())
except Exception as exc:
logger.debug("blur_score_from_image failed: %s", exc)
return None
+137
View File
@@ -0,0 +1,137 @@
"""Frigate upload post-processing: reconciliation and asset enrichment."""
import logging
import time
from .frigate_api import get_frigate_person_files
from .immich_api import fetch_face_data
from .upload_tracker import record_frigate_files_batch
logger = logging.getLogger(__name__)
# Exponential back-off delays (seconds) when polling Frigate after uploads.
# Frigate processes the upload queue asynchronously, so files aren't
# immediately visible in GET /api/faces — we wait progressively longer
# rather than hammering the API.
_RECONCILE_POLL_DELAYS = (1, 2, 4, 8)
def reconcile_frigate_mappings(
person_name: str,
known_files_before: set[str],
uploaded: list[tuple[str, str | None]],
) -> None:
"""Map Frigate filenames to asset IDs after a batch of uploads.
Polls until all expected new files appear in the Frigate API, then maps
them to asset IDs by filename timestamp order (Frigate processes the
upload queue in FIFO order, so earlier uploads get earlier timestamps).
KNOWN LIMITATION — race condition with external uploads:
If another client uploads a face file for this person concurrently, the
count of new files will exceed `len(uploaded)` and we bail out entirely
(the "> target" branch). That's safe — we never record a wrong mapping —
but those uploads become permanently unmapped (they won't be eligible for
quality replacement). The right fix is a Frigate API that returns the
filename in the upload response, removing the need for any post-upload
diffing. Until then, the external-upload guard keeps mappings correct at
the cost of occasionally missing them when another client is active.
"""
target = len(uploaded)
new_files: set[str] = set()
# Check before the first sleep so a fast Frigate response returns immediately.
for delay in (None, *_RECONCILE_POLL_DELAYS):
if delay is not None:
time.sleep(delay)
fresh = get_frigate_person_files(person_name)
if fresh is None:
logger.warning(
"%s: Frigate API unreachable during mapping reconciliation"
" — quality replacement won't target these files",
person_name,
)
return
new_files = set(fresh) - known_files_before
if len(new_files) >= target:
break
if len(new_files) == target:
def _ts(fname: str) -> float:
try:
return float(fname.rsplit("_", 1)[-1].rsplit(".", 1)[0])
except (ValueError, IndexError):
return 0.0
logger.debug(
"%s: mapping %s file(s) by filename timestamp — assumes Frigate processes"
" uploads in FIFO order; mapping may be wrong if that ever changes",
person_name,
target,
)
mappings = {
frigate_file: asset_id
for (_, asset_id), frigate_file in zip(uploaded, sorted(new_files, key=lambda f: (_ts(f), f)))
if asset_id
}
record_frigate_files_batch(person_name, mappings)
elif len(new_files) > target:
logger.warning(
"%s: %s new Frigate files for %s uploads"
" (external upload detected) — skipping file mapping;"
" these files are permanently unmapped",
person_name,
len(new_files),
target,
)
else:
logger.warning(
"%s: only %s of %s expected Frigate files"
" appeared after reconciliation — mapping skipped;"
" these files are permanently unmapped",
person_name,
len(new_files),
target,
)
def enrich_asset_with_face_data(asset: dict, person: dict) -> dict:
"""Enrich an asset dict with face bounding box data from the Immich faces API.
The search/metadata endpoint does not include face bounding box data,
so we fetch it from GET /api/faces?id={asset_id} and inject it into
the asset's "people" field so process_face_mode can find it.
Returns the enriched asset dict (modifies in place and returns it).
"""
person_id = person["id"]
face_data = fetch_face_data(asset["id"], person_id=person_id)
if face_data is None:
logger.debug("No face data returned for %s in asset %s", person.get("name"), asset.get("id"))
# Clean any None entries from the people list (can come from Immich API)
if "people" in asset:
asset["people"] = [p for p in asset["people"] if p is not None]
return asset
# Skip zero-area bounding boxes (face detection failed or no face found)
if face_data.bbox == (0, 0, 0, 0):
logger.debug("Zero-area bounding box for %s in asset %s", person.get("name"), asset.get("id"))
# Clean any None entries from the people list (can come from Immich API)
if "people" in asset:
asset["people"] = [p for p in asset["people"] if p is not None]
return asset
face_info = {
"boundingBoxX1": face_data.bbox[0],
"boundingBoxY1": face_data.bbox[1],
"boundingBoxX2": face_data.bbox[2],
"boundingBoxY2": face_data.bbox[3],
"imageWidth": face_data.image_width,
"imageHeight": face_data.image_height,
}
# Inject into asset so process_face_mode can find it via asset["people"]
asset["people"] = [{"id": person_id, "faces": [face_info]}]
asset["face_confidence"] = face_data.confidence
return asset
+117 -62
View File
@@ -1,6 +1,6 @@
"""Persistent tracker for Immich asset IDs already uploaded/rejected by Frigate.
Two separate JSON files in CACHE_DIR:
Two separate JSON files in DATA_DIR:
frigate_uploaded_ids.json — successfully uploaded assets
frigate_rejected_ids.json — assets Frigate rejected (e.g. no face detected)
@@ -43,12 +43,13 @@ REJECT_TRACKER_FILE = "frigate_rejected_ids.json"
# Reduces per-call JSON reads from O(calls) to O(1) after the first load.
# Keyed by full path so tests with isolated tmp dirs never share entries.
_cache: dict[str, dict] = {}
_deferred: set[str] = set() # paths whose disk writes are batched until flush_batch()
def _tracker_path(filename: str) -> Path:
try:
from .config import Config
return Path(Config.CACHE_DIR) / filename
return Path(Config.DATA_DIR) / filename
except (ImportError, AttributeError):
return Path(filename)
@@ -69,20 +70,46 @@ def _load(filename: str) -> dict:
return data
def _write_to_disk(path: Path, data: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
try:
with open(tmp, "w") as f:
json.dump(data, f, indent=2)
os.replace(tmp, path)
except Exception:
tmp.unlink(missing_ok=True)
raise
def _save(filename: str, data: dict) -> None:
path = _tracker_path(filename)
_cache[str(path)] = data # keep cache consistent with what we write
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w") as f:
json.dump(data, f, indent=2)
key = str(path)
if key in _deferred:
_cache[key] = data # accumulate in cache; disk write deferred until flush_batch()
return
_write_to_disk(path, data)
_cache[key] = data # update cache only after successful write
def begin_batch(filename: str) -> None:
"""Defer tracker disk writes for filename. All _save calls accumulate in the
in-memory cache until flush_batch() is called. Use around per-person upload loops
to reduce N writes to 1."""
_deferred.add(str(_tracker_path(filename)))
def flush_batch(filename: str) -> None:
"""Write the accumulated cache state for filename to disk."""
path = _tracker_path(filename)
key = str(path)
_deferred.discard(key)
if key in _cache:
_write_to_disk(path, _cache[key])
def _flat_key(filename: str) -> str:
return "uploaded_asset_ids" if "uploaded" in filename else "rejected_asset_ids"
def _load_flat(filename: str) -> set[str]:
return set(_load(filename).get(_flat_key(filename), []))
return "uploaded_asset_ids" if filename == UPLOAD_TRACKER_FILE else "rejected_asset_ids"
def _get_ids(entry: list | dict) -> list[str]:
@@ -96,12 +123,14 @@ def _migrate_entry(entry: list | dict) -> dict:
"""Ensure by_person entry is in the current dict format."""
if isinstance(entry, list):
return {"asset_ids": sorted(entry), "scores": {}, "frigate_scores": {}, "frigate_files": {}, "crop_dims": {}}
entry.setdefault("asset_ids", [])
entry.setdefault("scores", {})
entry.setdefault("frigate_scores", {})
entry.setdefault("frigate_files", {})
entry.setdefault("crop_dims", {})
return entry
# Copy top-level and all nested dicts so callers' mutations never reach the cache.
result = dict(entry)
result["asset_ids"] = list(result.get("asset_ids", []))
result["scores"] = dict(result.get("scores", {}))
result["frigate_scores"] = dict(result.get("frigate_scores", {}))
result["frigate_files"] = dict(result.get("frigate_files", {}))
result["crop_dims"] = dict(result.get("crop_dims", {}))
return result
def _mark(
@@ -113,10 +142,6 @@ def _mark(
frigate_score: float | None = None,
) -> None:
data = _load(filename)
flat_key = _flat_key(filename)
flat = set(data.get(flat_key, []))
flat.add(asset_id)
data[flat_key] = sorted(flat)
if person_name:
by_person = data.setdefault("by_person", {})
entry = _migrate_entry(by_person.get(person_name, {}))
@@ -136,11 +161,21 @@ def _mark(
# ── Public API ────────────────────────────────────────────────────────────────
def load_uploaded_ids() -> set[str]:
return _load_flat(UPLOAD_TRACKER_FILE)
"""Return all asset IDs recorded as uploaded. Derives from by_person (primary)
plus any legacy flat list still present in old tracker files."""
data = _load(UPLOAD_TRACKER_FILE)
ids = {aid for e in data.get("by_person", {}).values() for aid in _get_ids(e)}
ids.update(data.get("uploaded_asset_ids", [])) # backward compat with pre-0.6.1 files
return ids
def load_rejected_ids() -> set[str]:
return _load_flat(REJECT_TRACKER_FILE)
"""Return all asset IDs recorded as rejected. Derives from by_person (primary)
plus any legacy flat list still present in old tracker files."""
data = _load(REJECT_TRACKER_FILE)
ids = {aid for e in data.get("by_person", {}).values() for aid in _get_ids(e)}
ids.update(data.get("rejected_asset_ids", [])) # backward compat with pre-0.6.1 files
return ids
def mark_uploaded(
@@ -160,15 +195,10 @@ def mark_rejected(asset_id: str, person_name: str | None = None) -> None:
def record_frigate_file(person_name: str, frigate_filename: str, asset_id: str) -> None:
"""Record the mapping from a Frigate training filename to an Immich asset ID."""
data = _load(UPLOAD_TRACKER_FILE)
by_person = data.setdefault("by_person", {})
entry = _migrate_entry(by_person.get(person_name, {}))
entry["frigate_files"][frigate_filename] = asset_id
by_person[person_name] = entry
_save(UPLOAD_TRACKER_FILE, data)
logger.debug(f"Mapped Frigate file {frigate_filename} → {asset_id} ({person_name})")
"""Record a single Frigate filename → asset_id mapping."""
record_frigate_files_batch(person_name, {frigate_filename: asset_id})
def record_frigate_files_batch(person_name: str, mappings: dict[str, str]) -> None:
@@ -190,15 +220,24 @@ def remove_frigate_file(person_name: str, frigate_filename: str) -> None:
Does NOT unmark the source asset_id — the deletion was deliberate and
we don't want to re-upload the inferior image on the next run.
"""
remove_frigate_files_batch(person_name, [frigate_filename])
def remove_frigate_files_batch(person_name: str, frigate_filenames: list[str]) -> None:
"""Remove multiple Frigate filenames in a single load/save."""
data = _load(UPLOAD_TRACKER_FILE)
by_person = data.get("by_person", {})
entry = _migrate_entry(by_person.get(person_name, {}))
asset_id = entry["frigate_files"].pop(frigate_filename, None)
raw = by_person.get(person_name)
if raw is None:
return
entry = _migrate_entry(raw)
for fn in frigate_filenames:
asset_id = entry["frigate_files"].pop(fn, None)
if asset_id:
entry["frigate_scores"].pop(asset_id, None)
by_person[person_name] = entry
_save(UPLOAD_TRACKER_FILE, data)
logger.debug(f"Removed Frigate file mapping {frigate_filename} ({person_name})")
logger.debug(f"Removed {len(frigate_filenames)} Frigate file mapping(s) for {person_name}")
def get_tracked_frigate_file_count(person_name: str) -> int:
@@ -238,11 +277,12 @@ def _pick_mapped_file(
data = _load(UPLOAD_TRACKER_FILE)
entry = _migrate_entry(data.get("by_person", {}).get(person_name, {}))
scores = entry.get(score_key, {})
candidates = [
(ff, asset_id, scores[asset_id])
for ff, asset_id in entry.get("frigate_files", {}).items()
if (exclude is None or ff not in exclude) and asset_id in scores
]
seen_assets: set[str] = set()
candidates = []
for ff, asset_id in entry.get("frigate_files", {}).items():
if (exclude is None or ff not in exclude) and asset_id in scores and asset_id not in seen_assets:
seen_assets.add(asset_id)
candidates.append((ff, asset_id, scores[asset_id]))
if not candidates:
return None
return max(candidates, key=lambda x: x[2]) if highest else min(candidates, key=lambda x: x[2])
@@ -273,16 +313,6 @@ def get_most_redundant_mapped_file(
return _pick_mapped_file(person_name, "frigate_scores", highest=True, exclude=exclude)
def get_frigate_filename_for_asset(person_name: str, asset_id: str) -> str | None:
"""Return the Frigate training filename mapped to this asset ID, or None."""
data = _load(UPLOAD_TRACKER_FILE)
entry = _migrate_entry(data.get("by_person", {}).get(person_name, {}))
for frigate_filename, aid in entry["frigate_files"].items():
if aid == asset_id:
return frigate_filename
return None
def find_by_crop_dimension(size: int) -> list[dict]:
"""Return all tracked crops whose width or height matches `size` pixels.
@@ -295,7 +325,9 @@ def find_by_crop_dimension(size: int) -> list[dict]:
entry = _migrate_entry(raw_entry)
scores = entry.get("scores", {})
frigate_files = entry.get("frigate_files", {})
asset_to_frigate = {v: k for k, v in frigate_files.items()}
asset_to_frigate: dict[str, str] = {}
for fn, aid in frigate_files.items():
asset_to_frigate.setdefault(aid, fn) # first-seen wins; plain inversion silently drops duplicates
frigate_scores = entry.get("frigate_scores", {})
for asset_id, dims in entry.get("crop_dims", {}).items():
w, h = dims[0], dims[1]
@@ -322,6 +354,29 @@ def update_frigate_count(person_name: str, count: int) -> None:
_save(UPLOAD_TRACKER_FILE, data)
def reset_all_people() -> None:
"""Reset all tracking data in two writes (O(P) Frigate API calls, O(1) disk writes).
Preferred over calling reset_person() in a loop when RESET_PERSON=* — that
approach is O(P²) because each call rebuilds the flat list from all remaining entries.
"""
upload_data = _load(UPLOAD_TRACKER_FILE)
for person_name, raw_entry in upload_data.get("by_person", {}).items():
entry = _migrate_entry(raw_entry)
frigate_filenames = list(entry.get("frigate_files", {}).keys())
if not frigate_filenames:
continue
if not os.environ.get("FRIGATE_URL", "").strip():
logger.info(f"FRIGATE_URL not set — skipping Frigate file deletion for {person_name}")
elif delete_frigate_person_files(person_name, frigate_filenames):
logger.info(f"Deleted {len(frigate_filenames)} Frigate file(s) for {person_name}")
else:
logger.warning(f"Could not delete Frigate files for {person_name} — tracker reset proceeding anyway")
_save(UPLOAD_TRACKER_FILE, {})
_save(REJECT_TRACKER_FILE, {})
logger.info("Reset all tracking data")
def reset_person(person_name: str) -> None:
"""Remove all uploaded and rejected records for a given person.
@@ -342,15 +397,15 @@ def reset_person(person_name: str) -> None:
logger.warning(f"Could not delete Frigate files for {person_name} — tracker reset proceeding anyway")
changed = False
tracker_files = ((UPLOAD_TRACKER_FILE, upload_data), (REJECT_TRACKER_FILE, _load(REJECT_TRACKER_FILE)))
for filename, data in tracker_files:
flat_key = _flat_key(filename)
for filename in (UPLOAD_TRACKER_FILE, REJECT_TRACKER_FILE):
data = upload_data if filename == UPLOAD_TRACKER_FILE else _load(REJECT_TRACKER_FILE)
by_person = data.get("by_person", {})
tracker_entry = by_person.pop(person_name, None)
if tracker_entry is not None:
flat_key = _flat_key(filename)
person_ids = set(_get_ids(tracker_entry))
flat = set(data.get(flat_key, [])) - person_ids
data[flat_key] = sorted(flat)
if person_ids and flat_key in data:
data[flat_key] = sorted(set(data[flat_key]) - person_ids)
data["by_person"] = by_person
_save(filename, data)
changed = True
@@ -367,14 +422,14 @@ def get_person_summary() -> dict[str, dict]:
names = set(uploaded_data) | set(rejected_data)
result = {}
for name in sorted(names):
u_entry = uploaded_data.get(name, {})
r_entry = rejected_data.get(name, {})
u_entry = _migrate_entry(uploaded_data.get(name, {}))
r_entry = _migrate_entry(rejected_data.get(name, {}))
result[name] = {
"uploaded": len(_get_ids(u_entry)),
"rejected": len(_get_ids(r_entry)),
"frigate_count": u_entry.get("frigate_count") if isinstance(u_entry, dict) else None,
"scores": u_entry.get("scores", {}) if isinstance(u_entry, dict) else {},
"frigate_files": u_entry.get("frigate_files", {}) if isinstance(u_entry, dict) else {},
"uploaded": len(u_entry["asset_ids"]),
"rejected": len(r_entry["asset_ids"]),
"frigate_count": u_entry.get("frigate_count"),
"scores": u_entry["scores"],
"frigate_files": u_entry["frigate_files"],
}
return result