Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea99ad10e3 | ||
|
|
a237983777 | ||
|
|
a2d0541493 | ||
|
|
1574aed7e4 | ||
|
|
0192b6cb5b | ||
|
|
0ef15c5c12 | ||
|
|
72dbbfa18a | ||
|
|
274d50ee99 | ||
|
|
2d7b52470e | ||
|
|
835016e0e3 | ||
|
|
3105b15beb | ||
|
|
d12abbc543 | ||
|
|
efce4e3443 | ||
|
|
cb670dd555 | ||
|
|
f027f0a7d1 | ||
|
|
4e989b042e | ||
|
|
d63bcfc10b | ||
|
|
650629d3ed | ||
|
|
d7dfc1446a | ||
|
|
20904b6b22 | ||
|
|
e47fdaadf6 | ||
|
|
dfaa03de47 | ||
|
|
9d5741f626 | ||
|
|
d70a246a1c | ||
|
|
51cc7032eb | ||
|
|
105099c819 | ||
|
|
0afc9386c6 | ||
|
|
a59d05e7fd | ||
|
|
d0cb2e17b1 | ||
|
|
2dc26b5a3d | ||
|
|
fe4cfac7b5 | ||
|
|
ec074fd279 | ||
|
|
6018bf7222 | ||
|
|
4eb6e3169b | ||
|
|
f5ec9a0001 | ||
|
|
7dce4a5c71 | ||
|
|
56c802a245 |
@@ -7,6 +7,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.4.11] - 2026-06-14
|
||||
|
||||
### Removed
|
||||
|
||||
- **Object mode pipeline fully removed**: YOLO object detection, SigLIP image classification, `TRAINING_MODE`, and `OBJECT_CLASS` env vars are gone. Frigate has no training API for objects; the ~2 GB model stack (torch, torchvision, transformers, ultralytics) was dead weight.
|
||||
- **Dead Immich embedding path removed**: `FaceData.embedding` field and the `immich_embedding` parameter to `get_embedding()` were never consumed by any caller. Both removed along with the NumPy import in `immich_api.py` that existed solely for that path.
|
||||
- **Dead `mode` config key removed**: `"mode": "face"` was written into job config dicts in `jobs.py` but never read after object mode removal.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **InsightFace `FutureWarning` suppressed in crop-alignment path**: the `insightface_app.get()` call in `image_processing.py` now wraps the same `warnings.catch_warnings()` suppressor already present in `embeddings.py`, preventing scikit-image deprecation noise in logs.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Variant pyproject files synced to current state**: `pyproject-rocm.toml`, `pyproject-cpu.toml`, `pyproject-intel.toml` were at v0.2.13 and still listed torch/transformers/ultralytics. Updated to v0.4.11 and cleaned to face-only deps. Note: corresponding lockfiles (uv-rocm.lock, uv-cpu.lock, uv-intel.lock) need regeneration in their respective platform environments.
|
||||
- **`MERGE_DUPLICATE_PEOPLE` documented**: README and wiki now explain the default warn-and-skip behaviour vs. setting `true` for a permanent Immich merge, with irreversibility callout.
|
||||
- **Wiki fully updated**: all five wiki pages rewritten to remove object mode references, correct model size (~300 MB InsightFace vs former ~1–2 GB HuggingFace+InsightFace), fix default values (`MAX_AUTO_IMAGES` 80→20, `MIN_FACE_COUNT` 0→3), add `MERGE_DUPLICATE_PEOPLE` coverage, and update GPU verification commands for current ONNX provider API.
|
||||
|
||||
## [0.4.10] - 2026-06-14
|
||||
|
||||
### Changed
|
||||
|
||||
- **`MAX_AUTO_IMAGES` default lowered from `80` to `20`**: winnow is designed to fill the gap where manual Frigate training images don't exist — not to be the primary dataset. A conservative default ensures winnow-imported images remain secondary to hand-picked ones where both exist.
|
||||
|
||||
## [0.4.9] - 2026-06-14
|
||||
|
||||
### Changed
|
||||
|
||||
- **`FRIGATE_SCORE_CEILING` is now dynamic by default**: previously defaulted to `0` (disabled). Now unset (default) enables a self-calibrating novelty gate — below-cap candidates are skipped if their pre-upload Frigate score exceeds the most-redundant tracked file's score. This catches conditions already covered by manually-added Frigate images that winnow cannot track. Set `FRIGATE_SCORE_CEILING=0` to disable entirely; set a positive value (e.g. `0.85`) for a fixed hard ceiling.
|
||||
- **Quality replacement branches consolidated**: the Frigate-score and blur-score replacement paths in the upload loop shared identical structure. Merged into a single code path parameterised by score source and comparison direction.
|
||||
- **`MIN_FACE_COUNT` default raised from `0` to `3`**: people with fewer than 3 tagged photos produce degenerate training sets; skipping them by default avoids noisy runs.
|
||||
- **`STRATEGY=adaptive`** is the new primary name for embedding-based diversity selection; `auto` remains a silent alias for backwards compatibility.
|
||||
- **`MERGE_DUPLICATE_PEOPLE` and `TRACE_CROP_SIZE`** added to the README env var table (were in the codebase but undocumented).
|
||||
- **CUDA version corrected** in the image tags table (was 13.3, actual base image is 12.8.1).
|
||||
|
||||
## [0.4.8] - 2026-06-14
|
||||
|
||||
### Changed
|
||||
|
||||
- **Tracker write-through cache**: `upload_tracker` now keeps an in-memory copy of each JSON file keyed by its resolved path. All reads after the first hit the cache instead of disk; writes go to both disk and cache atomically. Cuts per-person disk I/O in the upload loop from ~90 reads to ~1, with no API or behaviour changes.
|
||||
|
||||
## [0.4.7] - 2026-06-14
|
||||
|
||||
### Changed
|
||||
|
||||
+3
-3
@@ -67,7 +67,7 @@ FROM base-${TARGETARCH}-${VARIANT} AS runtime
|
||||
ARG VARIANT=gpu
|
||||
ARG VERSION=dev
|
||||
LABEL org.opencontainers.image.title="winnow" \
|
||||
org.opencontainers.image.description="Selects diverse, high-quality photos from Immich as training data for Frigate face recognition and object classification." \
|
||||
org.opencontainers.image.description="Selects diverse, high-quality photos from Immich as training data for Frigate face recognition." \
|
||||
org.opencontainers.image.source="https://github.com/sudolulo/winnow" \
|
||||
org.opencontainers.image.licenses="AGPL-3.0-or-later" \
|
||||
org.opencontainers.image.version="${VERSION}"
|
||||
@@ -116,7 +116,7 @@ https://repositories.intel.com/graphics/ubuntu jammy flex" \
|
||||
fi
|
||||
|
||||
RUN groupadd -g 568 apps && useradd -u 568 -g apps -m -s /bin/bash appuser \
|
||||
&& mkdir -p /models/.insightface /models/huggingface \
|
||||
&& mkdir -p /models/.insightface \
|
||||
&& chown -R appuser:apps /app /models
|
||||
|
||||
WORKDIR /app
|
||||
@@ -124,7 +124,7 @@ USER appuser
|
||||
# PYTHONPATH=/app makes the winnow package importable from the entry point script.
|
||||
# uv sync builds the wheel before winnow/ is COPY'd, so site-packages has only
|
||||
# the dist-info. Explicitly adding /app lets Python find winnow/__init__.py there.
|
||||
ENV HF_HOME=/models/huggingface INSIGHTFACE_HOME=/models/.insightface PYTHONPATH=/app
|
||||
ENV INSIGHTFACE_HOME=/models/.insightface PYTHONPATH=/app
|
||||
|
||||
HEALTHCHECK CMD test -f /app/entrypoint.sh || exit 1
|
||||
ENTRYPOINT ["tini", "--", "/app/entrypoint.sh"]
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
|
||||
**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 and object classification models.
|
||||
`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.
|
||||
|
||||
Frigate's face recognition is only as good as its training data — and the key quality metric is **diversity**, not volume. A hundred photos from the same week teach the model one lighting condition. What you need is a spread: different years, different angles, different lighting, different contexts. Your photo library already has that data. winnow finds and delivers the right subset automatically.
|
||||
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.
|
||||
|
||||
> **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. If you have a curated training set you want to keep, it is safe.
|
||||
> **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.
|
||||
|
||||
---
|
||||
|
||||
@@ -39,49 +39,46 @@ Immich library
|
||||
│
|
||||
▼
|
||||
4. Compute embeddings from the same preview thumbnails
|
||||
• Faces → InsightFace (ArcFace / Buffalo_L) → 512-dim vector
|
||||
• Objects → SigLIP (Vision Transformer) → 768-dim vector
|
||||
• InsightFace (ArcFace / Buffalo_L) → 512-dim vector
|
||||
│
|
||||
▼
|
||||
5. Diversity selection
|
||||
5. Near-duplicate removal — greedy cosine-distance pass drops burst shots
|
||||
and near-identical photos before clustering runs; the highest-quality
|
||||
image from each near-duplicate group is kept
|
||||
│
|
||||
▼
|
||||
6. Diversity selection
|
||||
• K-Medoids clustering → one representative per natural group
|
||||
• Farthest Point Sampling → fill remaining slots with maximally spread picks
|
||||
• Hard example weighting — unusual angles and low-confidence detections
|
||||
are biased toward selection, since those are where models tend to fail
|
||||
• Auto mode: stops when similarity to the existing set exceeds a threshold
|
||||
(20 % of median pairwise distance for faces, 10 % for objects)
|
||||
• 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)
|
||||
│
|
||||
▼
|
||||
6. Download full-resolution originals from Immich
|
||||
7. Download full-resolution originals from Immich
|
||||
│
|
||||
▼
|
||||
7. Crop and process
|
||||
• Face mode: EXIF-corrected, landmark-aligned 112×112 crop (ArcFace format)
|
||||
• Object mode: YOLOv9c detection → one crop per matched instance
|
||||
8. Crop and process — EXIF-corrected, landmark-aligned 112×112 crop (ArcFace format)
|
||||
│
|
||||
▼
|
||||
8. Deliver
|
||||
• Face mode: upload crops to Frigate's face registration API
|
||||
↳ below MAX_AUTO_IMAGES — upload freely
|
||||
9. Deliver — upload crops to Frigate's face registration API
|
||||
↳ below MAX_AUTO_IMAGES — upload, unless the novelty gate
|
||||
(FRIGATE_SCORE_CEILING) determines the candidate is already
|
||||
covered by the current training set
|
||||
↳ at cap + QUALITY_REPLACEMENT=true — with Frigate scoring active,
|
||||
swap the most redundant tracked image (highest pre-upload recognize
|
||||
score) if the candidate is more novel (lower score); falling back to
|
||||
blur-score comparison when no Frigate scores are available; manually
|
||||
added files are never touched
|
||||
↳ at cap + QUALITY_REPLACEMENT=false — skip this person
|
||||
• Object mode: save crops to disk → place into your Frigate data directory
|
||||
```
|
||||
|
||||
Uploaded and rejected asset IDs are persisted across runs. The same image is never processed twice; Frigate rejections are permanently skipped unless `RETRY_REJECTED=true`.
|
||||
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`.
|
||||
|
||||
---
|
||||
|
||||
## Modes
|
||||
|
||||
**Face mode** (default) — extracts face crops using Immich's bounding box metadata, applies EXIF orientation correction, and aligns them to ArcFace's standard 112×112 format using 5-point facial landmarks. Crops are uploaded directly to Frigate's face registration API.
|
||||
|
||||
**Object mode** — runs each full-resolution image through YOLOv9c to detect instances of a target class (dog, cat, car, etc.), crops each detection, and saves it to the output directory. Frigate has no API for uploading object training data; place the crops into your Frigate data directory manually.
|
||||
|
||||
---
|
||||
|
||||
## Running in Docker
|
||||
@@ -90,7 +87,7 @@ Uploaded and rejected asset IDs are persisted across runs. The same image is nev
|
||||
|
||||
| Tag | Arch | Acceleration |
|
||||
| :-- | :-- | :-- |
|
||||
| `:latest` | amd64 + arm64 | NVIDIA CUDA 13.3 (amd64) · requires [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html) |
|
||||
| `:latest` | amd64 + arm64 | NVIDIA CUDA 12.8 (amd64) · 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 |
|
||||
@@ -108,7 +105,7 @@ services:
|
||||
- FRIGATE_URL=http://192.168.1.10:5000
|
||||
- CRON_SCHEDULE=0 3 * * 0
|
||||
volumes:
|
||||
- /path/to/models:/models
|
||||
- /path/to/models:/models # INSIGHTFACE_HOME — persists Buffalo_L model (~300 MB)
|
||||
- /path/to/cache:/app/.if_cache
|
||||
- /path/to/output:/app/frigate_train
|
||||
deploy:
|
||||
@@ -154,7 +151,7 @@ See [compose.yml](compose.yml) for the full annotated example with all options.
|
||||
| *(empty string)* | Stay alive, run nothing — trigger manually with `docker exec -it winnow winnow` |
|
||||
| Cron expression | Run on startup, then repeat on schedule |
|
||||
|
||||
In scheduled mode the process (and loaded models) stays resident between runs. The first run after a fresh install downloads the embedding models (~1–2 GB); subsequent runs use the cached models from the mounted volume.
|
||||
In scheduled mode the process (and loaded models) stays resident between runs. The first run after a fresh install downloads InsightFace Buffalo_L (~300 MB); subsequent runs use the cached model from the mounted volume.
|
||||
|
||||
---
|
||||
|
||||
@@ -172,11 +169,9 @@ In scheduled mode the process (and loaded models) stays resident between runs. T
|
||||
|
||||
| Variable | Default | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `TRAINING_MODE` | `face` | `face` — upload crops to Frigate; `object` — save crops to disk |
|
||||
| `STRATEGY` | `auto` | `auto` (embedding-based adaptive), `standard` (30 images), `broad` (100 images) |
|
||||
| `STRATEGY` | `adaptive` | `adaptive` — embedding-based diversity selection, stops when candidates become redundant; `standard` — fixed 30 images; `broad` — fixed 100 images |
|
||||
| `LIMIT` | *(unset)* | Exact image count — overrides `STRATEGY` |
|
||||
| `OBJECT_CLASS` | `dog` | Target class for object mode (any YOLO class: `dog`, `cat`, `car`, etc.) |
|
||||
| `AUTO_MODE` | *(auto)* | Force non-interactive mode in a terminal; auto-detected otherwise |
|
||||
| `AUTO_MODE` | *(auto)* | Skip interactive prompts and process all people unattended — auto-detected when no TTY is present (Docker, cron); set `true` to force in a terminal |
|
||||
| `VERBOSE` | `false` | Enable DEBUG-level console output (log file is always DEBUG) |
|
||||
|
||||
### People Filtering
|
||||
@@ -185,23 +180,33 @@ In scheduled mode the process (and loaded models) stays resident between runs. T
|
||||
| :--- | :--- | :--- |
|
||||
| `ONLY_PEOPLE` | *(unset)* | Comma-separated whitelist — process only these people |
|
||||
| `SKIP_PEOPLE` | *(unset)* | Comma-separated list — skip these people |
|
||||
| `MIN_FACE_COUNT` | `0` | Skip people with fewer than N tagged assets in Immich |
|
||||
| `MIN_FACE_COUNT` | `3` | Skip people with fewer than N tagged assets in Immich |
|
||||
| `MERGE_DUPLICATE_PEOPLE` | `false` | When Immich has duplicate entries for the same person (same face split across multiple names), merge their asset pools before processing. Without this, each duplicate group emits a warning and is skipped |
|
||||
| `YEARS_FILTER` | `10` | Ignore images older than N years |
|
||||
|
||||
> **Duplicate people detection** — winnow warns at startup if the same name appears on multiple Immich person records (a common side-effect of Immich's face clustering creating separate pools for the same individual). By default (`false`) it logs the duplicates, keeps only the person with the most assets, and skips the rest — no data is changed. Set `MERGE_DUPLICATE_PEOPLE=true` to permanently merge each duplicate group inside Immich (the person with the most assets absorbs the others). **This modifies Immich and cannot be undone.** Only enable it once you've verified the duplicates are actually the same person.
|
||||
|
||||
### Image Quality
|
||||
|
||||
| Variable | Default | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `MAX_AUTO_IMAGES` | `20` | Maximum training images per person in Frigate |
|
||||
| `QUALITY_REPLACEMENT` | `true` | When at cap, swap a weaker tracked image for a better candidate. With Frigate scoring active, targets the most redundant image (highest pre-upload recognize score); otherwise uses blur score. Never touches manually added Frigate files. Set `false` to skip people at cap |
|
||||
|
||||
#### Advanced Tuning *(calibrated — do not adjust)*
|
||||
|
||||
These defaults are tuned for Frigate's ArcFace requirements. winnow will warn on launch if any are set. Image quality issues caused by non-default values will not be investigated.
|
||||
|
||||
| Variable | Default | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `ENABLE_FRIGATE_SCORES` | `true` | Call Frigate's recognize endpoint pre-upload to store diversity scores used for quality replacement. Adds ~200 ms per upload. Disabling also disables the below-cap novelty gate |
|
||||
| `FRIGATE_SCORE_CEILING` | *(unset)* | Below-cap novelty gate. Unset: dynamic — skips candidates whose Frigate score exceeds the most-redundant tracked file's score, auto-calibrates each run. `0`: disable entirely. Positive value (e.g. `0.85`): fixed hard ceiling |
|
||||
| `MIN_FACE_WIDTH` | `90` | Minimum face crop width in pixels |
|
||||
| `FACE_MARGIN` | `0.15` | Padding around bounding box crop (fraction of face size) |
|
||||
| `ENABLE_FACE_ALIGNMENT` | `true` | Align to ArcFace 112×112 format using facial landmarks |
|
||||
| `USE_FULL_RESOLUTION` | `true` | Download full-resolution originals rather than preview thumbnails |
|
||||
| `MIN_CONFIDENCE` | `0.7` | Minimum Immich face detection confidence |
|
||||
| `BLUR_THRESHOLD` | `120.0` | Laplacian variance threshold — lower accepts more blur |
|
||||
| `MAX_AUTO_IMAGES` | `80` | Maximum training images per person in Frigate |
|
||||
| `QUALITY_REPLACEMENT` | `true` | When at cap, swap a weaker tracked image for a better candidate. With Frigate scoring active, targets the most redundant image (highest pre-upload recognize score); otherwise uses blur score. Never touches manually added Frigate files. Set `false` to skip people at cap |
|
||||
| `FRIGATE_SCORE_CEILING` | `0.0` | Skip uploads whose pre-upload Frigate recognize score exceeds this value — they are already well-covered. `0` disables; requires at least one prior run to have scores |
|
||||
| `ENABLE_FRIGATE_SCORES` | `true` | Call Frigate's recognize endpoint pre-upload to store diversity scores used for quality replacement. Adds ~200 ms per upload. Disable to use blur-score replacement only |
|
||||
|
||||
### GPU & Models
|
||||
|
||||
@@ -211,22 +216,22 @@ In scheduled mode the process (and loaded models) stays resident between runs. T
|
||||
| `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 |
|
||||
| `HF_HOME` | *(system)* | HuggingFace model cache path (SigLIP) |
|
||||
| `INSIGHTFACE_HOME` | *(system)* | InsightFace model cache path (Buffalo_L) |
|
||||
|
||||
### Output
|
||||
|
||||
| Variable | Default | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `OUTPUT_DIR` | `./frigate_train` | Directory for object-mode crops and the `winnow.log` file. In Docker, set this via the volume mount instead. |
|
||||
| `OUTPUT_DIR` | `./frigate_train` | Directory where face crops are staged before upload and where `winnow.log` is written. In Docker, set this via the volume mount instead. |
|
||||
|
||||
### Tracker Overrides *(one-shot — remove after use)*
|
||||
|
||||
| Variable | Default | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `DRY_RUN` | `false` | Preview selection without downloading or uploading |
|
||||
| `RETRY_REJECTED` | `false` | Re-attempt assets previously rejected by Frigate |
|
||||
| `RESET_PERSON` | *(unset)* | Clear upload history for one person and delete their winnow-managed Frigate training files so the next run starts fresh. Manually added Frigate files are never touched |
|
||||
| `RETRY_REJECTED` | `false` | Re-attempt all previously rejected assets (low-confidence skips, Frigate rejections, and other permanent exclusions) |
|
||||
| `RESET_PERSON` | *(unset)* | Set to a person's name to clear their upload history and delete their winnow-managed Frigate training files so the next run starts fresh. Set to `*` to reset all tracked people at once. Manually added Frigate files are never touched |
|
||||
| `TRACE_CROP_SIZE` | *(unset)* | Debug: print all tracked crops whose width or height matches this pixel value, then exit |
|
||||
|
||||
### Scheduling
|
||||
|
||||
@@ -247,14 +252,14 @@ uv run winnow
|
||||
|
||||
Requires Python 3.13+ and [uv](https://astral.sh/uv). An NVIDIA, AMD, or Intel GPU is recommended — CPU mode works but embedding computation is slower.
|
||||
|
||||
When run with a terminal attached, winnow starts an interactive session: select which people to process and choose a strategy (auto, standard, broad, or a custom count) per person. Without a TTY — Docker, cron, or `AUTO_MODE=true` — it processes all people automatically using the configured defaults.
|
||||
When run with a terminal attached, winnow starts an interactive session: select which people to process and choose a strategy (adaptive, standard, broad, or a custom count) per person. Without a TTY — Docker, cron, or `AUTO_MODE=true` — it processes all people unattended using the configured defaults.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Immich** v1.106+
|
||||
- **Frigate** v0.16+ (face mode only — object mode has no Frigate dependency)
|
||||
- **Frigate** v0.16+
|
||||
- **GPU** recommended: NVIDIA (CUDA), AMD (ROCm), or Intel (Arc / iGPU via OpenVINO)
|
||||
- **Python** 3.13+
|
||||
|
||||
|
||||
+1
-6
@@ -13,13 +13,9 @@ services:
|
||||
# Set AUTO_MODE=true to force auto mode in an interactive terminal.
|
||||
# To run interactively: docker exec -it winnow winnow
|
||||
# - VERBOSE=true # Enable DEBUG-level console output
|
||||
# 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
|
||||
# - 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
|
||||
@@ -35,14 +31,13 @@ services:
|
||||
# - 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=100.0 # Laplacian blur threshold; lower = accept more blur (default: 100.0)
|
||||
# - MAX_AUTO_IMAGES=80 # Hard cap on auto-diversity selection (default: 80)
|
||||
# - MAX_AUTO_IMAGES=80 # Hard cap on auto-diversity selection (default: 20)
|
||||
|
||||
# ── Caching & Models ──────────────────────────────────────────────────
|
||||
# - 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
|
||||
- HF_HOME=/models/huggingface
|
||||
- INSIGHTFACE_HOME=/models/.insightface
|
||||
|
||||
# ── Tracker overrides (one-shot, remove after use) ────────────────────
|
||||
|
||||
+3
-22
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "winnow"
|
||||
version = "0.2.13"
|
||||
description = "Immich to Frigate training sets"
|
||||
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" }]
|
||||
@@ -23,10 +23,6 @@ dependencies = [
|
||||
"python-dotenv>=1.2.1",
|
||||
"requests>=2.32.5",
|
||||
"rich>=14.2.0",
|
||||
"torch>=2.12.0",
|
||||
"torchvision>=0.27.0",
|
||||
"transformers>=5.12.0",
|
||||
"ultralytics>=8.4.66",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@@ -34,25 +30,13 @@ 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'",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
torch = [
|
||||
{ index = "pytorch-cpu", marker = "sys_platform == 'linux' and platform_machine == 'x86_64'" },
|
||||
]
|
||||
torchvision = [
|
||||
{ index = "pytorch-cpu", marker = "sys_platform == 'linux' and platform_machine == 'x86_64'" },
|
||||
]
|
||||
|
||||
[[tool.uv.index]]
|
||||
name = "pytorch-cpu"
|
||||
url = "https://download.pytorch.org/whl/cpu"
|
||||
explicit = true
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
@@ -81,9 +65,6 @@ numpy = "numpy"
|
||||
onnxruntime = "onnxruntime"
|
||||
requests = "requests"
|
||||
rich = "rich"
|
||||
torch = "torch"
|
||||
transformers = "transformers"
|
||||
ultralytics = "ultralytics"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
+3
-22
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "winnow"
|
||||
version = "0.2.13"
|
||||
description = "Immich to Frigate training sets"
|
||||
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" }]
|
||||
@@ -23,10 +23,6 @@ dependencies = [
|
||||
"python-dotenv>=1.2.1",
|
||||
"requests>=2.32.5",
|
||||
"rich>=14.2.0",
|
||||
"torch>=2.12.0",
|
||||
"torchvision>=0.27.0",
|
||||
"transformers>=5.12.0",
|
||||
"ultralytics>=8.4.66",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@@ -34,6 +30,7 @@ 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 = [
|
||||
@@ -47,19 +44,6 @@ required-environments = [
|
||||
"sys_platform == 'linux' and platform_machine == 'x86_64'",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
torch = [
|
||||
{ index = "pytorch-cpu", marker = "sys_platform == 'linux' and platform_machine == 'x86_64'" },
|
||||
]
|
||||
torchvision = [
|
||||
{ index = "pytorch-cpu", marker = "sys_platform == 'linux' and platform_machine == 'x86_64'" },
|
||||
]
|
||||
|
||||
[[tool.uv.index]]
|
||||
name = "pytorch-cpu"
|
||||
url = "https://download.pytorch.org/whl/cpu"
|
||||
explicit = true
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
@@ -88,9 +72,6 @@ numpy = "numpy"
|
||||
onnxruntime-openvino = "onnxruntime"
|
||||
requests = "requests"
|
||||
rich = "rich"
|
||||
torch = "torch"
|
||||
transformers = "transformers"
|
||||
ultralytics = "ultralytics"
|
||||
|
||||
[tool.deptry.per_rule_ignores]
|
||||
DEP002 = ["onnxruntime-openvino"]
|
||||
|
||||
+3
-21
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "winnow"
|
||||
version = "0.2.13"
|
||||
description = "Immich to Frigate training sets"
|
||||
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" }]
|
||||
@@ -23,10 +23,6 @@ dependencies = [
|
||||
"python-dotenv>=1.2.1",
|
||||
"requests>=2.32.5",
|
||||
"rich>=14.2.0",
|
||||
"torch>=2.5.0",
|
||||
"torchvision>=0.20.0",
|
||||
"transformers>=5.12.0",
|
||||
"ultralytics>=8.4.66",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@@ -34,6 +30,7 @@ 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"
|
||||
@@ -48,18 +45,6 @@ required-environments = [
|
||||
"sys_platform == 'linux' and platform_machine == 'x86_64'",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
torch = [
|
||||
{ index = "pytorch-rocm63", marker = "sys_platform == 'linux' and platform_machine == 'x86_64'" },
|
||||
]
|
||||
torchvision = [
|
||||
{ index = "pytorch-rocm63", marker = "sys_platform == 'linux' and platform_machine == 'x86_64'" },
|
||||
]
|
||||
|
||||
[[tool.uv.index]]
|
||||
name = "pytorch-rocm63"
|
||||
url = "https://download.pytorch.org/whl/rocm6.3"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
@@ -88,9 +73,6 @@ numpy = "numpy"
|
||||
onnxruntime-rocm = "onnxruntime"
|
||||
requests = "requests"
|
||||
rich = "rich"
|
||||
torch = "torch"
|
||||
transformers = "transformers"
|
||||
ultralytics = "ultralytics"
|
||||
|
||||
[tool.deptry.per_rule_ignores]
|
||||
DEP002 = ["onnxruntime-rocm"]
|
||||
|
||||
+2
-30
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "winnow"
|
||||
version = "0.4.7"
|
||||
description = "Selects diverse, high-quality photos from Immich as training data for Frigate face recognition and object classification."
|
||||
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" }]
|
||||
@@ -27,10 +27,6 @@ dependencies = [
|
||||
"python-dotenv>=1.2.1",
|
||||
"requests>=2.32.5",
|
||||
"rich>=14.2.0",
|
||||
"torch>=2.12.0",
|
||||
"torchvision>=0.27.0",
|
||||
"transformers>=5.12.0",
|
||||
"ultralytics>=8.4.66",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@@ -53,27 +49,6 @@ required-environments = [
|
||||
"sys_platform == 'linux' and platform_machine == 'aarch64'",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
torch = [
|
||||
{ index = "pytorch-cu126", marker = "sys_platform == 'linux' and platform_machine == 'x86_64'" },
|
||||
{ index = "pytorch-cpu", marker = "sys_platform == 'linux' and platform_machine == 'aarch64'" },
|
||||
{ index = "pytorch-cpu", marker = "sys_platform != 'linux'" },
|
||||
]
|
||||
torchvision = [
|
||||
{ index = "pytorch-cu126", marker = "sys_platform == 'linux' and platform_machine == 'x86_64'" },
|
||||
{ index = "pytorch-cpu", marker = "sys_platform == 'linux' and platform_machine == 'aarch64'" },
|
||||
{ index = "pytorch-cpu", marker = "sys_platform != 'linux'" },
|
||||
]
|
||||
|
||||
[[tool.uv.index]]
|
||||
name = "pytorch-cu126"
|
||||
url = "https://download.pytorch.org/whl/cu126"
|
||||
explicit = true
|
||||
|
||||
[[tool.uv.index]]
|
||||
name = "pytorch-cpu"
|
||||
url = "https://download.pytorch.org/whl/cpu"
|
||||
explicit = true
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
@@ -104,9 +79,6 @@ nvidia-cudnn-cu12 = "nvidia.cudnn"
|
||||
onnxruntime-gpu = "onnxruntime"
|
||||
requests = "requests"
|
||||
rich = "rich"
|
||||
torch = "torch"
|
||||
transformers = "transformers"
|
||||
ultralytics = "ultralytics"
|
||||
|
||||
[tool.deptry.per_rule_ignores]
|
||||
DEP002 = ["onnxruntime-gpu", "nvidia-cudnn-cu12"]
|
||||
|
||||
@@ -16,7 +16,6 @@ except ImportError:
|
||||
from winnow.cli import main
|
||||
|
||||
SCHEDULE = os.environ["CRON_SCHEDULE"]
|
||||
MODELS_DIR = os.environ.get("HF_HOME", "/models/huggingface")
|
||||
INSIGHTFACE_HOME = os.environ.get("INSIGHTFACE_HOME", "/models/.insightface")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -24,11 +23,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def check_models() -> None:
|
||||
buffalo = Path(INSIGHTFACE_HOME) / "models" / "buffalo_l"
|
||||
hf_hub = Path(MODELS_DIR) / "hub"
|
||||
if not buffalo.exists():
|
||||
print(" InsightFace Buffalo_L not found — will download on first run", flush=True)
|
||||
if not (hf_hub.exists() and any(hf_hub.iterdir())):
|
||||
print(" HuggingFace models not found — will download on first run", flush=True)
|
||||
|
||||
|
||||
NOW = time.time()
|
||||
|
||||
+2
-66
@@ -2,8 +2,8 @@
|
||||
"""
|
||||
winnow inference benchmark: GPU vs CPU throughput.
|
||||
|
||||
Measures InsightFace (face mode) and SigLIP (object mode) latency and
|
||||
throughput. Run with FORCE_CPU=true for CPU-only baseline.
|
||||
Measures InsightFace (ArcFace) latency and throughput.
|
||||
Run with FORCE_CPU=true for CPU-only baseline.
|
||||
|
||||
Usage inside container:
|
||||
# GPU mode:
|
||||
@@ -47,11 +47,6 @@ def make_face_image(size: int = 640) -> Image.Image:
|
||||
return img
|
||||
|
||||
|
||||
def make_random_image(width: int = 224, height: int = 224) -> Image.Image:
|
||||
rng = np.random.default_rng(42)
|
||||
return Image.fromarray(rng.integers(0, 256, (height, width, 3), dtype=np.uint8), "RGB")
|
||||
|
||||
|
||||
def _stats(times_s: list[float]) -> dict:
|
||||
arr = np.array(times_s) * 1000 # ms
|
||||
return {
|
||||
@@ -119,61 +114,6 @@ def bench_insightface(n_warmup: int = 5, n_runs: int = 30) -> None:
|
||||
print(f" 320×320 median : {s2['median_ms']:.1f} ms ({s2['ips']:.1f} img/s)")
|
||||
|
||||
|
||||
def bench_siglip(
|
||||
n_warmup: int = 3,
|
||||
n_runs: int = 20,
|
||||
batch_sizes: tuple = (1, 4, 8, 16, 32),
|
||||
) -> None:
|
||||
import torch
|
||||
|
||||
import winnow.embeddings as emb_mod
|
||||
emb_mod._siglip_model = None
|
||||
emb_mod._siglip_processor = None
|
||||
emb_mod._siglip_loaded = False
|
||||
|
||||
print(" Loading model...")
|
||||
t_load = time.perf_counter()
|
||||
model, processor = emb_mod.get_siglip_model()
|
||||
load_s = time.perf_counter() - t_load
|
||||
|
||||
if model is None:
|
||||
print(" SKIP: SigLIP failed to load")
|
||||
return
|
||||
|
||||
device = next(model.parameters()).device
|
||||
print(f" Model load time : {load_s:.2f} s (device: {device})")
|
||||
|
||||
print(f" {'Batch':>5} {'ms/batch':>10} {'ms/img':>8} {'img/s':>8} {'p95/img':>9}")
|
||||
for bs in batch_sizes:
|
||||
imgs = [make_random_image(224, 224) for _ in range(bs)]
|
||||
inputs = processor(images=imgs, return_tensors="pt")
|
||||
inputs = {k: v.to(device) for k, v in inputs.items()}
|
||||
|
||||
# Warmup
|
||||
for _ in range(n_warmup):
|
||||
with torch.no_grad():
|
||||
model(**inputs)
|
||||
if str(device) != "cpu":
|
||||
torch.cuda.synchronize()
|
||||
|
||||
times: list[float] = []
|
||||
for _ in range(n_runs):
|
||||
if str(device) != "cpu":
|
||||
torch.cuda.synchronize()
|
||||
t0 = time.perf_counter()
|
||||
with torch.no_grad():
|
||||
model(**inputs)
|
||||
if str(device) != "cpu":
|
||||
torch.cuda.synchronize()
|
||||
times.append(time.perf_counter() - t0)
|
||||
|
||||
s = _stats(times)
|
||||
print(
|
||||
f" {bs:>5} {s['median_ms']:>10.1f} {s['median_ms']/bs:>8.2f}"
|
||||
f" {bs * 1000 / s['median_ms']:>8.1f} {s['p95_ms']/bs:>9.2f}"
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("=" * 56)
|
||||
print(" winnow inference benchmark")
|
||||
@@ -185,10 +125,6 @@ def main() -> None:
|
||||
bench_insightface()
|
||||
print()
|
||||
|
||||
print("── SigLIP google/siglip-base-patch16-224 (objects) ───")
|
||||
bench_siglip()
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Add winnow to path when run directly inside container
|
||||
|
||||
@@ -18,10 +18,10 @@ def test_config_loads_defaults(monkeypatch):
|
||||
assert cfg.OUTPUT_DIR == "./frigate_train"
|
||||
assert cfg.YEARS_FILTER == 10
|
||||
assert cfg.MIN_FACE_WIDTH == 90
|
||||
assert cfg.MIN_FACE_COUNT == 0
|
||||
assert cfg.MIN_FACE_COUNT == 3
|
||||
assert cfg.BLUR_THRESHOLD == 120.0
|
||||
assert cfg.MIN_CONFIDENCE == 0.7
|
||||
assert cfg.MAX_AUTO_IMAGES == 80
|
||||
assert cfg.MAX_AUTO_IMAGES == 20
|
||||
assert cfg.QUALITY_REPLACEMENT is True
|
||||
assert cfg.FACE_MARGIN == 0.15
|
||||
assert cfg.USE_FULL_RESOLUTION is True
|
||||
|
||||
+1
-2
@@ -1,8 +1,7 @@
|
||||
"""Immich to Frigate training set curator.
|
||||
|
||||
AI-powered tool to extract high-quality, diverse training images from your
|
||||
Immich library for Frigate's Face Recognition (ArcFace) and Object/State
|
||||
Classification models.
|
||||
Immich library for Frigate's face recognition (ArcFace/Buffalo_L).
|
||||
"""
|
||||
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
|
||||
@@ -15,7 +15,6 @@ logger = logging.getLogger(__name__)
|
||||
# Model versions — bump these when the upstream model changes
|
||||
MODEL_VERSIONS = {
|
||||
"insightface": "buffalo_l_v1",
|
||||
"siglip": "siglip-base-patch16-224_v1",
|
||||
"immich": "immich_buffalo_l_v1",
|
||||
}
|
||||
|
||||
|
||||
@@ -130,6 +130,18 @@ def _handle_duplicate_people(people: list[dict]) -> list[dict]:
|
||||
return people
|
||||
|
||||
|
||||
_UNSUPPORTED_VARS = [
|
||||
"ENABLE_FRIGATE_SCORES",
|
||||
"FRIGATE_SCORE_CEILING",
|
||||
"MIN_FACE_WIDTH",
|
||||
"FACE_MARGIN",
|
||||
"ENABLE_FACE_ALIGNMENT",
|
||||
"USE_FULL_RESOLUTION",
|
||||
"MIN_CONFIDENCE",
|
||||
"BLUR_THRESHOLD",
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Entry point for winnow CLI."""
|
||||
try:
|
||||
@@ -145,6 +157,17 @@ def main() -> None:
|
||||
[dim]Immich -> Frigate Training Data Curator[/dim]
|
||||
""")
|
||||
|
||||
set_unsupported = [v for v in _UNSUPPORTED_VARS if os.environ.get(v)]
|
||||
if set_unsupported:
|
||||
console.print(
|
||||
f"[bold yellow]⚠ Advanced tuning vars set: "
|
||||
f"{', '.join(set_unsupported)}[/bold yellow]"
|
||||
)
|
||||
console.print(
|
||||
"[dim] These defaults are calibrated for Frigate's ArcFace requirements. "
|
||||
"Image quality issues caused by non-default values will not be investigated.[/dim]\n"
|
||||
)
|
||||
|
||||
ConfigManager.get().interactive_setup()
|
||||
|
||||
try:
|
||||
|
||||
+7
-6
@@ -29,13 +29,13 @@ class _Config:
|
||||
MIN_FACE_WIDTH: int = 90
|
||||
BLUR_THRESHOLD: float = 120.0
|
||||
MIN_CONFIDENCE: float = 0.7
|
||||
MAX_AUTO_IMAGES: int = 80
|
||||
MAX_AUTO_IMAGES: int = 20
|
||||
QUALITY_REPLACEMENT: bool = True
|
||||
FRIGATE_SCORE_CEILING: float = 0.0
|
||||
FRIGATE_SCORE_CEILING: float | None = None
|
||||
ENABLE_FRIGATE_SCORES: bool = True
|
||||
|
||||
# People filtering
|
||||
MIN_FACE_COUNT: int = 0
|
||||
MIN_FACE_COUNT: int = 3
|
||||
MERGE_DUPLICATE_PEOPLE: bool = False
|
||||
|
||||
# Output quality
|
||||
@@ -60,13 +60,14 @@ class _Config:
|
||||
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", "0"))
|
||||
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", "80"))
|
||||
self.MAX_AUTO_IMAGES = int(os.getenv("MAX_AUTO_IMAGES", "20"))
|
||||
self.QUALITY_REPLACEMENT = os.getenv("QUALITY_REPLACEMENT", "true").lower() in ("true", "1", "yes")
|
||||
self.FRIGATE_SCORE_CEILING = float(os.getenv("FRIGATE_SCORE_CEILING", "0.0"))
|
||||
_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")
|
||||
|
||||
+14
-37
@@ -5,7 +5,7 @@ Selection pipeline:
|
||||
1. Concurrent thumbnail download
|
||||
2. Quality filtering (blur, IR, exposure, confidence, face size)
|
||||
3. Face crop extraction (embed person's face, not full image)
|
||||
4. Embedding computation (InsightFace or SigLIP)
|
||||
4. Embedding computation (InsightFace)
|
||||
5. Cluster-aware selection (K-Medoids + FPS with hard example weighting)
|
||||
"""
|
||||
|
||||
@@ -28,7 +28,6 @@ def select_diverse_assets(
|
||||
limit: int | str,
|
||||
entity_name: str,
|
||||
selection_mode: str = "smart",
|
||||
entity_type: str = "face",
|
||||
person_id: str | None = None,
|
||||
progress_callback=None,
|
||||
) -> list:
|
||||
@@ -38,9 +37,8 @@ def select_diverse_assets(
|
||||
Args:
|
||||
assets: List of asset dicts from Immich API
|
||||
limit: Number to select, or "auto" for dynamic selection
|
||||
entity_name: Name of the person/object for logging
|
||||
entity_name: Name of the person for logging
|
||||
selection_mode: 'smart' (embedding-based) or 'time' (time spread)
|
||||
entity_type: 'face' or 'object' - determines embedding model
|
||||
progress_callback: Optional callback(current, total) for progress
|
||||
|
||||
Returns:
|
||||
@@ -53,14 +51,13 @@ def select_diverse_assets(
|
||||
# Sort by creation time
|
||||
assets = sorted(assets, key=lambda x: x.get("fileCreatedAt", ""))
|
||||
|
||||
if selection_mode != "smart" or not is_embedding_available(entity_type):
|
||||
if selection_mode != "smart" or not is_embedding_available():
|
||||
if selection_mode == "smart":
|
||||
model_name = "InsightFace" if entity_type == "face" else "SigLIP"
|
||||
logger.warning(f"{model_name} unavailable. Falling back to time spread.")
|
||||
logger.warning("InsightFace unavailable. Falling back to time spread.")
|
||||
return _select_time_spread(assets, limit)
|
||||
|
||||
try:
|
||||
return _select_by_embedding(assets, limit, entity_type, person_id, progress_callback)
|
||||
return _select_by_embedding(assets, limit, person_id, progress_callback)
|
||||
except Exception as e:
|
||||
logger.error(f"Smart Diversity failed: {e}. Falling back to time spread.")
|
||||
return _select_time_spread(assets, limit)
|
||||
@@ -179,7 +176,6 @@ def _crop_face_from_thumbnail(
|
||||
def _select_by_embedding(
|
||||
assets: list,
|
||||
limit: int | str,
|
||||
entity_type: str,
|
||||
person_id: str | None = None,
|
||||
progress_callback=None,
|
||||
) -> list:
|
||||
@@ -188,7 +184,7 @@ def _select_by_embedding(
|
||||
Pipeline:
|
||||
1. Concurrent thumbnail download
|
||||
2. Quality filtering
|
||||
3. Face crop extraction (face mode only)
|
||||
3. Face crop extraction
|
||||
4. Embedding computation
|
||||
5. Cluster-aware selection with hard example weighting
|
||||
"""
|
||||
@@ -243,7 +239,6 @@ def _select_by_embedding(
|
||||
|
||||
confidence = _get_face_confidence(asset, person_id=person_id)
|
||||
|
||||
if entity_type == "face":
|
||||
face_bbox = _get_face_bbox(asset, person_id=person_id)
|
||||
quality = assess_quality(
|
||||
img,
|
||||
@@ -261,10 +256,8 @@ def _select_by_embedding(
|
||||
asset["quality_score"] = quality.blur_score
|
||||
face_crop = _crop_face_from_thumbnail(img, asset, person_id=person_id)
|
||||
embed_img = face_crop if face_crop is not None else img
|
||||
else:
|
||||
embed_img = img
|
||||
|
||||
emb = get_embedding(embed_img, entity_type, asset_id=asset["id"])
|
||||
emb = get_embedding(embed_img, asset_id=asset["id"])
|
||||
if emb is not None:
|
||||
embeddings.append(emb)
|
||||
valid_candidates.append(asset)
|
||||
@@ -300,7 +293,6 @@ def _select_by_embedding(
|
||||
embeddings,
|
||||
valid_candidates,
|
||||
limit,
|
||||
entity_type=entity_type,
|
||||
confidence_scores=confidence_scores,
|
||||
)
|
||||
|
||||
@@ -429,11 +421,11 @@ def _kmedoids(dist_matrix: np.ndarray, k: int, max_iter: int = 50) -> tuple[list
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _compute_adaptive_threshold(emb_normed: np.ndarray, entity_type: str) -> float:
|
||||
def _compute_adaptive_threshold(emb_normed: np.ndarray) -> float:
|
||||
"""Compute adaptive FPS stop threshold based on actual embedding distribution.
|
||||
|
||||
Instead of a hardcoded threshold, samples pairwise distances and sets
|
||||
the threshold as a fraction of the median pairwise distance.
|
||||
the threshold as 20% of the median pairwise distance.
|
||||
"""
|
||||
n = len(emb_normed)
|
||||
sample_size = min(200, n)
|
||||
@@ -441,22 +433,14 @@ def _compute_adaptive_threshold(emb_normed: np.ndarray, entity_type: str) -> flo
|
||||
indices = rng.choice(n, sample_size, replace=False) if n > sample_size else np.arange(n)
|
||||
sample = emb_normed[indices]
|
||||
|
||||
# Compute pairwise cosine distances for the sample
|
||||
pairwise = 1 - sample @ sample.T
|
||||
upper_tri = pairwise[np.triu_indices(len(sample), k=1)]
|
||||
if len(upper_tri) == 0:
|
||||
return 0.05
|
||||
median_dist = float(np.median(upper_tri))
|
||||
threshold = max(0.05, median_dist * 0.20)
|
||||
|
||||
# Faces: 20% of median (tighter — want fewer, more distinct images)
|
||||
# Objects: 10% of median (wider — want more diversity)
|
||||
fraction = 0.20 if entity_type == "face" else 0.10
|
||||
threshold = max(0.05, median_dist * fraction)
|
||||
|
||||
logger.debug(
|
||||
f"Adaptive threshold: {threshold:.4f} "
|
||||
f"(median_dist={median_dist:.4f}, fraction={fraction}, type={entity_type})"
|
||||
)
|
||||
logger.debug(f"Adaptive threshold: {threshold:.4f} (median_dist={median_dist:.4f})")
|
||||
return threshold
|
||||
|
||||
|
||||
@@ -464,7 +448,6 @@ def _cluster_aware_selection(
|
||||
embeddings: list,
|
||||
candidates: list,
|
||||
limit: int | str,
|
||||
entity_type: str = "face",
|
||||
confidence_scores: list | None = None,
|
||||
) -> list:
|
||||
"""Two-stage selection: K-Medoids clustering → FPS with hard example weighting.
|
||||
@@ -484,13 +467,13 @@ def _cluster_aware_selection(
|
||||
|
||||
# Build confidence weight array for hard example boosting
|
||||
conf_array = np.ones(n)
|
||||
if confidence_scores and entity_type == "face":
|
||||
if confidence_scores:
|
||||
for i, c in enumerate(confidence_scores):
|
||||
if c is not None:
|
||||
conf_array[i] = c
|
||||
|
||||
# Compute adaptive threshold for auto mode
|
||||
auto_threshold = _compute_adaptive_threshold(emb_normed, entity_type) if limit == "auto" else 0.0
|
||||
auto_threshold = _compute_adaptive_threshold(emb_normed) if limit == "auto" else 0.0
|
||||
target = Config.MAX_AUTO_IMAGES if limit == "auto" else limit
|
||||
|
||||
# --- Stage 1: K-Medoids clustering ---
|
||||
@@ -542,15 +525,9 @@ def _cluster_aware_selection(
|
||||
min_dists = np.minimum(min_dists, dists_to_new)
|
||||
min_dists[best_idx] = -np.inf
|
||||
|
||||
# Log hard example stats
|
||||
if entity_type == "face":
|
||||
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 " f"({hard_count} hard examples with confidence < 0.85)."
|
||||
)
|
||||
else:
|
||||
logger.info(f"Selection complete: {len(selected)} diverse images.")
|
||||
logger.info(f"Selection complete: {len(selected)} images ({hard_count} hard examples with confidence < 0.85).")
|
||||
|
||||
return [candidates[i] for i in selected]
|
||||
|
||||
|
||||
+13
-171
@@ -1,8 +1,7 @@
|
||||
"""
|
||||
Unified embedding interface for faces and objects.
|
||||
Embedding interface for face diversity selection.
|
||||
|
||||
- Faces: InsightFace (ArcFace/Buffalo_L) — or reuse from Immich
|
||||
- Objects: SigLIP (Vision Transformer via transformers)
|
||||
- Caching: Disk-based cache avoids recomputation on reruns
|
||||
"""
|
||||
|
||||
@@ -42,12 +41,9 @@ def _suppress_output():
|
||||
os.close(saved_err)
|
||||
|
||||
|
||||
# Lazy-loaded singletons
|
||||
# Lazy-loaded singleton
|
||||
_insightface_app = None
|
||||
_insightface_loaded = False
|
||||
_siglip_model = None
|
||||
_siglip_processor = None
|
||||
_siglip_loaded = False
|
||||
|
||||
|
||||
def _is_force_cpu() -> bool:
|
||||
@@ -202,169 +198,33 @@ def get_face_embedding(img_pil: Image.Image) -> np.ndarray | None:
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SigLIP (Objects)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_siglip_model():
|
||||
"""Singleton for SigLIP model and processor with GPU auto-detection."""
|
||||
global _siglip_model, _siglip_processor, _siglip_loaded
|
||||
if _siglip_loaded:
|
||||
return _siglip_model, _siglip_processor
|
||||
_siglip_loaded = True
|
||||
|
||||
try:
|
||||
import warnings
|
||||
|
||||
import torch
|
||||
from transformers import AutoImageProcessor, SiglipVisionModel
|
||||
|
||||
model_name = "google/siglip-base-patch16-224"
|
||||
|
||||
# Disk cache check — path derived from model_name using HuggingFace's slug convention
|
||||
hf_home = os.environ.get("HF_HOME", os.path.join(os.path.expanduser("~"), ".cache", "huggingface"))
|
||||
cache_slug = "models--" + model_name.replace("/", "--")
|
||||
model_cache = Path(hf_home) / "hub" / cache_slug
|
||||
if model_cache.exists() and any(model_cache.iterdir()):
|
||||
logger.info(f"SigLIP {model_name}: found in model cache")
|
||||
else:
|
||||
logger.info(f"SigLIP {model_name}: not cached — downloading now (~380 MB)")
|
||||
|
||||
logger.info(f"SigLIP {model_name}: loading into memory...")
|
||||
t0 = time.time()
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings("ignore", category=FutureWarning)
|
||||
warnings.filterwarnings("ignore", message=".*use_fast.*")
|
||||
_siglip_processor = AutoImageProcessor.from_pretrained(model_name, use_fast=True)
|
||||
_siglip_model = SiglipVisionModel.from_pretrained(model_name)
|
||||
|
||||
_siglip_model.eval()
|
||||
|
||||
# Move to GPU if available (ROCm builds expose torch.cuda.is_available() == True)
|
||||
if not _is_force_cpu():
|
||||
if torch.cuda.is_available():
|
||||
_siglip_model = _siglip_model.cuda()
|
||||
device_name = "CUDA GPU"
|
||||
elif hasattr(torch, "xpu") and torch.xpu.is_available():
|
||||
_siglip_model = _siglip_model.to("xpu")
|
||||
device_name = "Intel XPU"
|
||||
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
_siglip_model = _siglip_model.to("mps")
|
||||
device_name = "Apple MPS"
|
||||
else:
|
||||
device_name = "CPU"
|
||||
else:
|
||||
device_name = "CPU (FORCE_CPU)"
|
||||
|
||||
logger.info(f"SigLIP {model_name}: ready on {device_name} ({time.time() - t0:.1f}s)")
|
||||
return _siglip_model, _siglip_processor
|
||||
|
||||
except ImportError as e:
|
||||
logger.error(f"transformers/torch not installed: {e}")
|
||||
return None, None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load SigLIP: {e}")
|
||||
return None, None
|
||||
|
||||
|
||||
def get_object_embedding(img_pil: Image.Image) -> np.ndarray | None:
|
||||
"""Get 768-dim SigLIP embedding for an image."""
|
||||
model, processor = get_siglip_model()
|
||||
if model is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
import torch
|
||||
|
||||
inputs = processor(images=img_pil, return_tensors="pt")
|
||||
device = next(model.parameters()).device
|
||||
inputs = {k: v.to(device) for k, v in inputs.items()}
|
||||
|
||||
with torch.no_grad():
|
||||
outputs = model(**inputs)
|
||||
return outputs.pooler_output.squeeze().cpu().numpy()
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting object embedding: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_object_embeddings_batch(images: list[Image.Image]) -> list[np.ndarray | None]:
|
||||
"""Get SigLIP embeddings for a batch of images (GPU-efficient)."""
|
||||
model, processor = get_siglip_model()
|
||||
if model is None:
|
||||
return [None] * len(images)
|
||||
|
||||
try:
|
||||
import torch
|
||||
|
||||
inputs = processor(images=images, return_tensors="pt", padding=True)
|
||||
device = next(model.parameters()).device
|
||||
inputs = {k: v.to(device) for k, v in inputs.items()}
|
||||
|
||||
with torch.no_grad():
|
||||
outputs = model(**inputs)
|
||||
embeddings = outputs.pooler_output.cpu().numpy()
|
||||
return [embeddings[i] for i in range(len(embeddings))]
|
||||
except Exception as e:
|
||||
logger.error(f"Error in batch embedding: {e}")
|
||||
# Fall back to individual computation
|
||||
return [get_object_embedding(img) for img in images]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Unified Interface with Caching
|
||||
# Embedding Interface with Caching
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_embedding(
|
||||
img_pil: Image.Image,
|
||||
entity_type: str = "face",
|
||||
asset_id: str | None = None,
|
||||
immich_embedding: np.ndarray | None = None,
|
||||
) -> np.ndarray | None:
|
||||
"""Get embedding for an image based on entity type.
|
||||
"""Get embedding for a face image.
|
||||
|
||||
Priority:
|
||||
1. Pre-fetched Immich embedding (if provided)
|
||||
2. Disk cache (if enabled and asset_id provided)
|
||||
3. Local model computation (InsightFace or SigLIP)
|
||||
|
||||
Args:
|
||||
img_pil: The image to embed
|
||||
entity_type: 'face' or 'object'
|
||||
asset_id: Optional asset ID for cache lookup
|
||||
immich_embedding: Optional pre-fetched embedding from Immich API
|
||||
Checks disk cache first (if enabled and asset_id provided),
|
||||
then falls back to local InsightFace computation.
|
||||
"""
|
||||
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
|
||||
# Use a single consistent cache key per model so lookups and stores always match.
|
||||
# "immich" was previously used as the face key on the lookup path but "insightface"
|
||||
# on the store path — meaning the cache was never hit for locally-computed embeddings.
|
||||
cache_key = "insightface" if entity_type == "face" else "siglip"
|
||||
|
||||
# 1. Use Immich embedding if provided
|
||||
if immich_embedding is not None:
|
||||
if cache:
|
||||
cache.put(asset_id, immich_embedding, cache_key)
|
||||
return immich_embedding
|
||||
|
||||
# 2. Check disk cache
|
||||
if cache:
|
||||
cached = cache.get(asset_id, cache_key)
|
||||
cached = cache.get(asset_id, "insightface")
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# 3. Compute locally
|
||||
if entity_type == "face":
|
||||
emb = get_face_embedding(img_pil)
|
||||
else:
|
||||
emb = get_object_embedding(img_pil)
|
||||
|
||||
if emb is not None and cache:
|
||||
cache.put(asset_id, emb, cache_key)
|
||||
cache.put(asset_id, emb, "insightface")
|
||||
|
||||
return emb
|
||||
|
||||
@@ -378,36 +238,18 @@ def _is_module_available(module_name: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def is_embedding_available(entity_type: str = "face", *, load: bool = False) -> bool:
|
||||
"""Check if embedding model is available for the given entity type.
|
||||
def is_embedding_available(*, load: bool = False) -> bool:
|
||||
"""Check if InsightFace is available.
|
||||
|
||||
By default this performs a lightweight import-check only (no model loading).
|
||||
Pass ``load=True`` to actually load the model (expensive, hundreds of MB).
|
||||
|
||||
Args:
|
||||
entity_type: 'face' or 'object'
|
||||
load: If True, fully load the model to verify. If False (default),
|
||||
only check that the required packages are importable.
|
||||
Pass ``load=True`` to actually load the model (expensive, ~300 MB).
|
||||
"""
|
||||
if load:
|
||||
if entity_type == "face":
|
||||
return get_insightface_app() is not None
|
||||
model, _ = get_siglip_model()
|
||||
return model is not None
|
||||
|
||||
# Lightweight check: just verify the packages are importable
|
||||
if entity_type == "face":
|
||||
return _is_module_available("insightface") and _is_module_available("onnxruntime")
|
||||
return _is_module_available("transformers") and _is_module_available("torch")
|
||||
|
||||
|
||||
def load_embedding_model(entity_type: str = "face") -> bool:
|
||||
"""Explicitly load the embedding model for the given entity type.
|
||||
|
||||
Returns True if the model loaded successfully.
|
||||
"""
|
||||
if entity_type == "face":
|
||||
def load_embedding_model() -> bool:
|
||||
"""Explicitly load InsightFace. Returns True if the model loaded successfully."""
|
||||
return get_insightface_app() is not None
|
||||
model, _ = get_siglip_model()
|
||||
return model is not None
|
||||
|
||||
|
||||
+63
-98
@@ -19,7 +19,7 @@ from .frigate_api import (
|
||||
get_frigate_person_files,
|
||||
recognize_face,
|
||||
)
|
||||
from .image_processing import process_face_mode, process_full_mode, process_object_mode
|
||||
from .image_processing import process_face_mode
|
||||
from .immich_api import fetch_face_data, fetch_full_image
|
||||
from .log_config import console
|
||||
from .quality import assess_quality
|
||||
@@ -31,7 +31,6 @@ from .upload_tracker import (
|
||||
has_frigate_scores,
|
||||
mark_rejected,
|
||||
mark_uploaded,
|
||||
record_frigate_file,
|
||||
record_frigate_files_batch,
|
||||
remove_frigate_file,
|
||||
)
|
||||
@@ -174,11 +173,11 @@ def execute_jobs(jobs: list[dict]) -> None:
|
||||
|
||||
use_full_res = Config.USE_FULL_RESOLUTION
|
||||
|
||||
# Load InsightFace app for landmark-based crop alignment (face mode only).
|
||||
# Load InsightFace app for landmark-based crop alignment.
|
||||
# The model is already resident from the diversity/embedding phase, so this
|
||||
# is just a singleton lookup — no load cost.
|
||||
insightface_app = None
|
||||
if any(j["config"].get("mode", "face") == "face" for j in jobs) and Config.ENABLE_FACE_ALIGNMENT:
|
||||
if Config.ENABLE_FACE_ALIGNMENT:
|
||||
try:
|
||||
from .embeddings import get_insightface_app
|
||||
|
||||
@@ -197,8 +196,8 @@ def execute_jobs(jobs: list[dict]) -> None:
|
||||
overall_task = progress.add_task("[green]Overall Progress", total=grand_total)
|
||||
|
||||
for job in jobs:
|
||||
person, assets, config = job["person"], job["assets"], job["config"]
|
||||
name, mode = person["name"], config.get("mode", "face")
|
||||
person, assets = job["person"], job["assets"]
|
||||
name = person["name"]
|
||||
|
||||
job_task = progress.add_task(f"Processing {name}...", total=len(assets))
|
||||
try:
|
||||
@@ -207,8 +206,7 @@ def execute_jobs(jobs: list[dict]) -> None:
|
||||
logger.error(str(e))
|
||||
continue
|
||||
# Face crops are transient (uploaded then discarded); wipe before each run.
|
||||
# Object crops are the deliverable; preserve them across runs.
|
||||
if mode == "face" and os.path.isdir(person_dir):
|
||||
if os.path.isdir(person_dir):
|
||||
shutil.rmtree(person_dir)
|
||||
os.makedirs(person_dir, exist_ok=True)
|
||||
|
||||
@@ -220,9 +218,8 @@ def execute_jobs(jobs: list[dict]) -> None:
|
||||
count = 0
|
||||
for asset in assets:
|
||||
try:
|
||||
# For face mode, enrich the asset with face bounding box data
|
||||
# from the Immich faces API (not included in search/metadata results)
|
||||
if mode == "face":
|
||||
# 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)
|
||||
# Skip download if detection confidence already disqualifies
|
||||
# the asset — avoids fetching a large image we'll discard.
|
||||
@@ -232,6 +229,7 @@ def execute_jobs(jobs: list[dict]) -> None:
|
||||
f"[yellow]Skipped {asset['id']}"
|
||||
f" (detection confidence {conf:.2f} < {Config.MIN_CONFIDENCE})[/yellow]"
|
||||
)
|
||||
mark_rejected(asset["id"], person_name=name)
|
||||
progress.advance(job_task)
|
||||
progress.advance(overall_task)
|
||||
continue
|
||||
@@ -250,26 +248,21 @@ def execute_jobs(jobs: list[dict]) -> None:
|
||||
if img is None:
|
||||
progress.console.print(f"[red]Failed download {asset['id']}[/red]")
|
||||
else:
|
||||
saved = (
|
||||
process_face_mode(img, asset, person, person_dir, count, insightface_app=insightface_app)
|
||||
if mode == "face"
|
||||
else process_object_mode(img, config, person_dir, count)
|
||||
if mode == "object"
|
||||
else process_full_mode(img, person_dir, count)
|
||||
saved = process_face_mode(
|
||||
img, asset, person, person_dir, count, insightface_app=insightface_app
|
||||
)
|
||||
if saved:
|
||||
# Record which asset produced which output file
|
||||
filename = f"{count}.jpg"
|
||||
asset_map[filename] = asset["id"]
|
||||
score_map[filename] = asset.get("quality_score")
|
||||
if mode == "face" and isinstance(saved, tuple):
|
||||
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.
|
||||
if mode == "face" and score_map[filename] is None:
|
||||
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:
|
||||
@@ -279,12 +272,6 @@ def execute_jobs(jobs: list[dict]) -> None:
|
||||
except Exception as exc:
|
||||
logger.debug(f"Quality score fallback for {asset['id']}: {exc}")
|
||||
score_map[filename] = 0.0 # unknown quality — treat as lowest
|
||||
# Also record object-mode variant filenames
|
||||
if mode == "object":
|
||||
for f in sorted(os.listdir(person_dir)):
|
||||
if f.startswith(f"{count}_") and f not in asset_map:
|
||||
asset_map[f] = asset["id"]
|
||||
score_map[f] = asset.get("face_confidence")
|
||||
|
||||
count += 1
|
||||
else:
|
||||
@@ -312,29 +299,13 @@ def execute_jobs(jobs: list[dict]) -> None:
|
||||
def upload_to_frigate(jobs: list[dict]) -> None:
|
||||
"""Upload processed face crops to Frigate via API with detailed logging.
|
||||
|
||||
Only runs for face-mode jobs. Object-mode crops are saved to the output
|
||||
directory as the deliverable and must be copied to Frigate manually.
|
||||
|
||||
After each successful upload, records the Immich asset ID in the
|
||||
upload tracker so it is skipped on future runs.
|
||||
"""
|
||||
face_jobs = [j for j in jobs if j["config"].get("mode", "face") == "face"]
|
||||
|
||||
if not face_jobs:
|
||||
rprint("[dim]No face-mode jobs to upload.[/dim]")
|
||||
if not jobs:
|
||||
rprint("[dim]No jobs to upload.[/dim]")
|
||||
return
|
||||
|
||||
# Notify user about object-mode jobs that were skipped
|
||||
object_jobs = [j for j in jobs if j["config"].get("mode") == "object"]
|
||||
for job in object_jobs:
|
||||
name = job["person"]["name"]
|
||||
try:
|
||||
person_dir = _safe_person_dir(Config.OUTPUT_DIR, name)
|
||||
except ValueError as e:
|
||||
logger.error(str(e))
|
||||
continue
|
||||
rprint(f" [dim]📁 {name} (object): crops saved to {person_dir} — copy to Frigate manually[/dim]")
|
||||
|
||||
frigate_url = os.environ.get("FRIGATE_URL", "")
|
||||
if not frigate_url:
|
||||
rprint("[yellow]⚠️ FRIGATE_URL not set, skipping upload.[/yellow]")
|
||||
@@ -347,7 +318,7 @@ def upload_to_frigate(jobs: list[dict]) -> None:
|
||||
# from the asset_map stored on each job during execute_jobs()
|
||||
filename_to_asset_id: dict[str, dict[str, str]] = {}
|
||||
total_files = 0
|
||||
for job in face_jobs:
|
||||
for job in jobs:
|
||||
name = job["person"]["name"]
|
||||
asset_map = job.get("asset_map", {})
|
||||
filename_to_asset_id[name] = asset_map
|
||||
@@ -357,7 +328,7 @@ def upload_to_frigate(jobs: list[dict]) -> None:
|
||||
rprint(" [yellow]No images found to upload.[/yellow]")
|
||||
return
|
||||
|
||||
rprint(f" People: [bold]{len(face_jobs)}[/bold], Total images: [bold]{total_files}[/bold]")
|
||||
rprint(f" People: [bold]{len(jobs)}[/bold], Total images: [bold]{total_files}[/bold]")
|
||||
|
||||
uploaded, failed = 0, 0
|
||||
max_retries = 2
|
||||
@@ -375,7 +346,7 @@ def upload_to_frigate(jobs: list[dict]) -> None:
|
||||
) as progress:
|
||||
upload_task = progress.add_task("[green]Uploading to Frigate", total=total_files)
|
||||
|
||||
for job in face_jobs:
|
||||
for job in jobs:
|
||||
name = job["person"]["name"]
|
||||
# URL-encode the name for the API (handles spaces, special chars)
|
||||
encoded_name = quote(name, safe="")
|
||||
@@ -483,14 +454,28 @@ def upload_to_frigate(jobs: list[dict]) -> None:
|
||||
if _result is not None and (_result[0] or "").casefold() == name.casefold():
|
||||
pre_fscore = _result[1]
|
||||
|
||||
# Ceiling check: skip if the existing training set already covers this
|
||||
# face condition well. Applies below cap only — at cap, replacement logic
|
||||
# drives the decision.
|
||||
if not at_cap and Config.FRIGATE_SCORE_CEILING > 0 and pre_run_count > 0:
|
||||
if pre_fscore is not None and pre_fscore > Config.FRIGATE_SCORE_CEILING:
|
||||
# Below-cap novelty gate: skip candidates already covered by the Frigate model,
|
||||
# including conditions learned from manually-added images winnow can't track.
|
||||
# pre_fscore is None on the first run (pre_run_count == 0 skips recognize_face
|
||||
# above), so this block never fires on the first run without an extra guard.
|
||||
if not at_cap and pre_fscore is not None:
|
||||
_ceiling = Config.FRIGATE_SCORE_CEILING
|
||||
if _ceiling is None:
|
||||
# Dynamic default: bar = most-redundant tracked file's Frigate score.
|
||||
# Falls back to uploading freely when no tracked scores exist yet.
|
||||
_bar = get_most_redundant_mapped_file(name)
|
||||
_skip = _bar is not None and pre_fscore > _bar[2]
|
||||
_bar_str = f"most redundant tracked {_bar[2]:.2f}" if _bar else ""
|
||||
elif _ceiling == 0.0:
|
||||
_skip = False # explicitly disabled
|
||||
_bar_str = ""
|
||||
else:
|
||||
_skip = pre_fscore > _ceiling
|
||||
_bar_str = f"ceiling {_ceiling:.2f}"
|
||||
if _skip:
|
||||
progress.console.print(
|
||||
f" [dim]⏭ {fname}: Frigate score {pre_fscore:.2f}"
|
||||
f" > ceiling {Config.FRIGATE_SCORE_CEILING:.2f}, already covered[/dim]"
|
||||
f" > {_bar_str}, already covered[/dim]"
|
||||
)
|
||||
progress.advance(upload_task)
|
||||
continue
|
||||
@@ -504,65 +489,45 @@ def upload_to_frigate(jobs: list[dict]) -> None:
|
||||
using_fscore = person_has_fscores and Config.ENABLE_FRIGATE_SCORES
|
||||
if using_fscore:
|
||||
candidate_score = pre_fscore
|
||||
if candidate_score is None:
|
||||
progress.console.print(
|
||||
f" [dim]⏭ {fname}: Frigate recognize unavailable, skipping replacement[/dim]"
|
||||
)
|
||||
progress.advance(upload_task)
|
||||
continue
|
||||
# Low score = more novel than the most redundant mapped file = replace
|
||||
target = get_most_redundant_mapped_file(name, exclude=failed_deletes)
|
||||
if target is None or candidate_score >= target[2]:
|
||||
target_score_str = f"{target[2]:.3f}" if target is not None else "N/A"
|
||||
progress.console.print(
|
||||
f" [dim]⏭ {fname}: frigate {candidate_score:.3f} ≥ most redundant"
|
||||
f" {target_score_str}, not more novel[/dim]"
|
||||
)
|
||||
progress.advance(upload_task)
|
||||
continue
|
||||
target_frigate_file, _target_asset_id, target_score = target
|
||||
progress.console.print(
|
||||
f" 🔄 {fname}: frigate {candidate_score:.3f} < {target_score:.3f},"
|
||||
f" replacing {target_frigate_file} (more novel)"
|
||||
)
|
||||
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
|
||||
# clear any blur-mode slot floor — Frigate uses a different score metric
|
||||
min_quality_score_for_slot = None
|
||||
else:
|
||||
logger.warning(f"Failed to delete {target_frigate_file} for {name}, skipping replacement")
|
||||
failed_deletes.add(target_frigate_file)
|
||||
progress.advance(upload_task)
|
||||
continue
|
||||
get_target = get_most_redundant_mapped_file
|
||||
score_label, better_note = "frigate", " (more novel)"
|
||||
no_score_msg = "Frigate recognize unavailable, skipping replacement"
|
||||
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"
|
||||
|
||||
if candidate_score is None:
|
||||
progress.console.print(f" [dim]⏭ {fname}: {no_score_msg}[/dim]")
|
||||
progress.advance(upload_task)
|
||||
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]
|
||||
)
|
||||
if not_better:
|
||||
target_str = f"{target[2]:.3f}" if target is not None else "N/A"
|
||||
op = "<" if using_fscore else ">"
|
||||
progress.console.print(
|
||||
f" [dim]⏭ {fname}: no quality score, skipping replacement[/dim]"
|
||||
)
|
||||
progress.advance(upload_task)
|
||||
continue
|
||||
target = get_lowest_quality_mapped_file(name, exclude=failed_deletes)
|
||||
if target is None or candidate_score <= target[2]:
|
||||
target_score_str = f"{target[2]:.3f}" if target is not None else "N/A"
|
||||
progress.console.print(
|
||||
f" [dim]⏭ {fname}: blur {candidate_score:.3f} ≤ worst"
|
||||
f" {target_score_str}, skipping[/dim]"
|
||||
f" [dim]⏭ {fname}: {score_label} {candidate_score:.3f}"
|
||||
f" not {op} {target_str}, skipping[/dim]"
|
||||
)
|
||||
progress.advance(upload_task)
|
||||
continue
|
||||
|
||||
target_frigate_file, _target_asset_id, target_score = target
|
||||
op = "<" if using_fscore else ">"
|
||||
progress.console.print(
|
||||
f" 🔄 {fname}: blur {candidate_score:.3f} > {target_score:.3f},"
|
||||
f" replacing {target_frigate_file}"
|
||||
f" 🔄 {fname}: {score_label} {candidate_score:.3f} {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 = score_map.get(fname)
|
||||
min_quality_score_for_slot = None if using_fscore else candidate_score
|
||||
else:
|
||||
logger.warning(f"Failed to delete {target_frigate_file} for {name}, skipping replacement")
|
||||
failed_deletes.add(target_frigate_file)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Image processing functions for cropping faces and objects."""
|
||||
"""Image processing functions for cropping faces."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import warnings
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
@@ -10,9 +11,6 @@ from .config import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Lazy singleton
|
||||
_yolo_model = None
|
||||
|
||||
|
||||
def _save_jpeg(img: Image.Image, path: str) -> None:
|
||||
if img.mode != "RGB":
|
||||
@@ -20,17 +18,6 @@ def _save_jpeg(img: Image.Image, path: str) -> None:
|
||||
img.save(path, format="JPEG")
|
||||
|
||||
|
||||
def get_yolo_model():
|
||||
"""Singleton for YOLO model."""
|
||||
global _yolo_model
|
||||
if _yolo_model is None:
|
||||
from ultralytics import YOLO
|
||||
|
||||
logger.info("Loading YOLOv9c model...")
|
||||
_yolo_model = YOLO("yolov9c.pt")
|
||||
return _yolo_model
|
||||
|
||||
|
||||
def align_face(img: Image.Image, landmarks: list[list[float]] | np.ndarray) -> Image.Image | None:
|
||||
"""Align face using 5-point landmarks to standard ArcFace input format (112x112).
|
||||
|
||||
@@ -127,6 +114,8 @@ def process_face_mode(
|
||||
min(img_h, y2 + pad_y),
|
||||
)
|
||||
search_crop = img.crop(search_box)
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings("ignore", message=".*estimate.*is deprecated", category=FutureWarning)
|
||||
detected = insightface_app.get(np.asarray(search_crop))
|
||||
if detected:
|
||||
cx, cy = search_crop.width / 2, search_crop.height / 2
|
||||
@@ -170,49 +159,4 @@ def process_face_mode(
|
||||
return face_crop.size
|
||||
|
||||
|
||||
def process_object_mode(
|
||||
img: Image.Image,
|
||||
config: dict,
|
||||
output_dir: str,
|
||||
count: int,
|
||||
) -> bool:
|
||||
"""Detect and crop objects using YOLO."""
|
||||
try:
|
||||
model = get_yolo_model()
|
||||
target_class = config.get("object_class", "dog")
|
||||
import torch
|
||||
|
||||
if os.getenv("FORCE_CPU", "").lower() in ("true", "1", "yes"):
|
||||
device = "cpu"
|
||||
elif hasattr(torch, "xpu") and torch.xpu.is_available():
|
||||
device = "xpu"
|
||||
else:
|
||||
device = None # YOLO auto-selects (CUDA/ROCm/CPU)
|
||||
|
||||
results = model(img, verbose=False, device=device)
|
||||
|
||||
found = False
|
||||
class_idx = 0 # Sequential counter per target class (Issue #10)
|
||||
for box in (box for r in results for box in r.boxes):
|
||||
cls_id = int(box.cls[0])
|
||||
conf = float(box.conf[0])
|
||||
if 0 <= cls_id < len(model.names) and model.names[cls_id] == target_class and conf > 0.5:
|
||||
x1, y1, x2, y2 = box.xyxy[0].tolist()
|
||||
_save_jpeg(
|
||||
img.crop((x1, y1, x2, y2)),
|
||||
os.path.join(output_dir, f"{count}_{class_idx}.jpg"),
|
||||
)
|
||||
class_idx += 1
|
||||
found = True
|
||||
|
||||
return found
|
||||
except Exception as e:
|
||||
logger.error(f"YOLO processing failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def process_full_mode(img: Image.Image, output_dir: str, count: int) -> bool:
|
||||
"""Save full image."""
|
||||
_save_jpeg(img, os.path.join(output_dir, f"{count}.jpg"))
|
||||
return True
|
||||
|
||||
|
||||
+2
-11
@@ -5,7 +5,6 @@ from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from io import BytesIO
|
||||
|
||||
import numpy as np
|
||||
import requests
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
@@ -21,7 +20,6 @@ _MAX_ASSETS_PER_PERSON = 5000 # Stop fetching after this many — diversity poo
|
||||
class FaceData:
|
||||
"""Pre-computed face data from Immich."""
|
||||
|
||||
embedding: np.ndarray | None
|
||||
bbox: tuple[float, float, float, float] # (x1, y1, x2, y2)
|
||||
confidence: float | None
|
||||
image_width: int
|
||||
@@ -110,7 +108,7 @@ def fetch_all_assets(person: dict) -> list[dict]:
|
||||
|
||||
|
||||
def fetch_face_data(asset_id: str, person_id: str | None = None) -> FaceData | None:
|
||||
"""Fetch pre-computed face data (embedding, bbox, confidence) from Immich.
|
||||
"""Fetch pre-computed face data (bbox, confidence) from Immich.
|
||||
|
||||
Queries GET /api/faces?id={asset_id} to retrieve face detection results
|
||||
that Immich already computed using InsightFace Buffalo_L.
|
||||
@@ -120,7 +118,7 @@ def fetch_face_data(asset_id: str, person_id: str | None = None) -> FaceData | N
|
||||
person_id: Optional person ID to match the specific face
|
||||
|
||||
Returns:
|
||||
FaceData with embedding, bbox, and confidence, or None if unavailable
|
||||
FaceData with bbox and confidence, or None if unavailable
|
||||
"""
|
||||
try:
|
||||
resp = requests.get(
|
||||
@@ -148,12 +146,6 @@ def fetch_face_data(asset_id: str, person_id: str | None = None) -> FaceData | N
|
||||
if face is None:
|
||||
face = faces[0] # Fall back to first/largest face
|
||||
|
||||
# Extract embedding if available
|
||||
embedding = None
|
||||
if "embedding" in face:
|
||||
embedding = np.array(face["embedding"], dtype=np.float32)
|
||||
|
||||
# Extract bounding box
|
||||
bbox = (
|
||||
face.get("boundingBoxX1", 0),
|
||||
face.get("boundingBoxY1", 0),
|
||||
@@ -163,7 +155,6 @@ def fetch_face_data(asset_id: str, person_id: str | None = None) -> FaceData | N
|
||||
|
||||
score = face.get("score")
|
||||
return FaceData(
|
||||
embedding=embedding,
|
||||
bbox=bbox,
|
||||
confidence=score if score is not None else face.get("confidence"),
|
||||
image_width=face.get("imageWidth", 0),
|
||||
|
||||
+19
-47
@@ -20,18 +20,16 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Strategy presets: (limit, mode_name)
|
||||
STRATEGY_PRESETS = {
|
||||
"1": ("auto", "Auto Diversity"),
|
||||
"1": ("auto", "Adaptive Diversity"),
|
||||
"2": (30, "Standard (30)"),
|
||||
"3": (100, "Broad (100)"),
|
||||
}
|
||||
|
||||
|
||||
def _get_strategy_choice(has_embedding: bool, entity_type: str) -> tuple[int | str, str]:
|
||||
def _get_strategy_choice(has_embedding: bool) -> tuple[int | str, str]:
|
||||
"""Prompt user for training strategy and return (limit, selection_mode)."""
|
||||
model_name = "InsightFace" if entity_type == "face" else "SigLIP"
|
||||
|
||||
if has_embedding:
|
||||
rprint(" [bold]1.[/bold] Auto (Objective Diversity) [green][Recommended][/green]")
|
||||
rprint(" [bold]1.[/bold] Adaptive Diversity [green][Recommended][/green]")
|
||||
rprint(" [dim]• Dynamically selects images until redundancy starts[/dim]")
|
||||
rprint(" [bold]2.[/bold] Standard (30 images)")
|
||||
rprint(" [bold]3.[/bold] Broad (100 images)")
|
||||
@@ -51,7 +49,7 @@ def _get_strategy_choice(has_embedding: bool, entity_type: str) -> tuple[int | s
|
||||
return 30, "smart"
|
||||
|
||||
# Fallback when embedding model not available
|
||||
rprint(f" [yellow]Note: {model_name} not available. Using Time Spread.[/yellow]")
|
||||
rprint(" [yellow]Note: InsightFace not available. Using Time Spread.[/yellow]")
|
||||
rprint(" [bold]1.[/bold] Standard (30 images) [green][Recommended][/green]")
|
||||
rprint(" [bold]2.[/bold] Broad (100 images)")
|
||||
rprint(" [bold]3.[/bold] Custom Count")
|
||||
@@ -77,7 +75,8 @@ def _resolve_strategy(strategy: str, has_embedding: bool) -> tuple[int | str, st
|
||||
return int(custom_limit), "smart"
|
||||
|
||||
strategy_map = {
|
||||
"auto": ("auto", "smart"),
|
||||
"adaptive": ("auto", "smart"),
|
||||
"auto": ("auto", "smart"), # legacy alias for adaptive
|
||||
"standard": (30, "smart"),
|
||||
"broad": (100, "smart"),
|
||||
}
|
||||
@@ -85,15 +84,13 @@ def _resolve_strategy(strategy: str, has_embedding: bool) -> tuple[int | str, st
|
||||
|
||||
|
||||
def _perform_selection(
|
||||
assets: list, limit: int | str, name: str, selection_mode: str, entity_type: str, person_id: str | None = None
|
||||
assets: list, limit: int | str, name: str, selection_mode: str, person_id: str | None = None
|
||||
) -> list:
|
||||
"""Run diversity selection with progress display."""
|
||||
if selection_mode == "smart":
|
||||
model_display = "InsightFace (face embeddings)" if entity_type == "face" else "SigLIP (visual embeddings)"
|
||||
rprint(f"\n[cyan]Using {model_display} for diversity analysis...[/cyan]")
|
||||
rprint("\n[cyan]Using InsightFace (face embeddings) for diversity analysis...[/cyan]")
|
||||
|
||||
# Pre-load model explicitly (separate from availability check)
|
||||
load_embedding_model(entity_type)
|
||||
load_embedding_model()
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
@@ -108,7 +105,6 @@ def _perform_selection(
|
||||
limit,
|
||||
name,
|
||||
selection_mode=selection_mode,
|
||||
entity_type=entity_type,
|
||||
person_id=person_id,
|
||||
progress_callback=lambda c, t: progress.update(task, completed=c, total=t),
|
||||
)
|
||||
@@ -119,9 +115,7 @@ def _perform_selection(
|
||||
|
||||
rprint(f"\n[cyan]Using time-spread selection for {limit} images...[/cyan]")
|
||||
with console.status(f"[bold]Selecting {limit} images evenly distributed over time...[/bold]"):
|
||||
selected = select_diverse_assets(
|
||||
assets, limit, name, selection_mode="time", entity_type=entity_type, person_id=person_id
|
||||
)
|
||||
selected = select_diverse_assets(assets, limit, name, selection_mode="time", person_id=person_id)
|
||||
rprint(f" [green]Selected {len(selected)} images using time spread.[/green]")
|
||||
return selected
|
||||
|
||||
@@ -131,22 +125,12 @@ def _configure_person(person: dict, people: list[dict]) -> dict | None:
|
||||
name = person["name"]
|
||||
console.print(f"\nSelected: [bold green]{name}[/bold green]")
|
||||
|
||||
# Select training mode
|
||||
rprint("\n[bold cyan]Training Mode:[/bold cyan]")
|
||||
rprint(" [bold]1.[/bold] Face (Frigate Face Recognition)")
|
||||
rprint(" [bold]2.[/bold] Object (Frigate Object Classification)")
|
||||
|
||||
mode_choice = Prompt.ask("Choice", choices=["1", "2"], default="1")
|
||||
entity_type = "face" if mode_choice == "1" else "object"
|
||||
|
||||
config = {"name": name, "mode": entity_type, "quality_replacement": Config.QUALITY_REPLACEMENT}
|
||||
if entity_type == "object":
|
||||
config["object_class"] = Prompt.ask("Enter Object Class (e.g. dog, cat, car)", default="dog")
|
||||
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} ({entity_type})...")
|
||||
console.print(f"Scanning for {name}...")
|
||||
with console.status("[bold green]Fetching assets...[/bold green]"):
|
||||
all_assets = fetch_all_assets(person)
|
||||
recent_assets = filter_recent_assets(all_assets, years=years)
|
||||
@@ -170,17 +154,15 @@ def _configure_person(person: dict, people: list[dict]) -> dict | None:
|
||||
return None
|
||||
|
||||
# Strategy selection
|
||||
has_embedding = is_embedding_available(entity_type)
|
||||
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, entity_type)
|
||||
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, entity_type, person_id=person["id"]
|
||||
)
|
||||
selected_assets = _perform_selection(recent_assets, limit, name, selection_mode, person_id=person["id"])
|
||||
|
||||
rprint(f" [green]Queued {len(selected_assets)} images for {name}.[/green]")
|
||||
return {"person": person, "assets": selected_assets, "limit": len(selected_assets), "config": config}
|
||||
@@ -230,7 +212,6 @@ def auto_configure(people: list[dict]) -> list[dict]:
|
||||
rprint("[red]No people found with names in Immich.[/red]")
|
||||
return []
|
||||
|
||||
mode = os.environ.get("TRAINING_MODE", "face")
|
||||
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 []
|
||||
@@ -259,11 +240,7 @@ def auto_configure(people: list[dict]) -> list[dict]:
|
||||
jobs = []
|
||||
for person in valid_people:
|
||||
name = person["name"]
|
||||
entity_type = mode
|
||||
|
||||
config = {"name": name, "mode": entity_type}
|
||||
if entity_type == "object":
|
||||
config["object_class"] = os.environ.get("OBJECT_CLASS", "dog")
|
||||
config = {"name": name}
|
||||
|
||||
all_assets = fetch_all_assets(person)
|
||||
recent_assets = filter_recent_assets(all_assets, years=Config.YEARS_FILTER)
|
||||
@@ -306,7 +283,7 @@ def auto_configure(people: list[dict]) -> list[dict]:
|
||||
|
||||
config["quality_replacement"] = quality_replacement_only or Config.QUALITY_REPLACEMENT
|
||||
|
||||
has_embedding = is_embedding_available(entity_type)
|
||||
has_embedding = is_embedding_available()
|
||||
limit, selection_mode = _resolve_strategy(strategy, has_embedding)
|
||||
|
||||
# Cap selection to remaining capacity (no cap when replacement-only — executor
|
||||
@@ -322,9 +299,7 @@ def auto_configure(people: list[dict]) -> list[dict]:
|
||||
if selection_mode == "skip":
|
||||
continue
|
||||
|
||||
selected_assets = _perform_selection(
|
||||
recent_assets, limit, name, selection_mode, entity_type, person_id=person["id"]
|
||||
)
|
||||
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]
|
||||
|
||||
@@ -339,20 +314,17 @@ def _show_preview(jobs: list[dict]) -> None:
|
||||
"""Show a summary table of all queued jobs before execution."""
|
||||
table = Table(title="📋 Training Job Preview", show_header=True, header_style="bold cyan")
|
||||
table.add_column("Person", style="bold")
|
||||
table.add_column("Mode", style="dim")
|
||||
table.add_column("Images", justify="right")
|
||||
table.add_column("Date Range", style="dim")
|
||||
|
||||
for job in jobs:
|
||||
name = job["person"]["name"]
|
||||
mode = job["config"].get("mode", "face")
|
||||
count = str(job["limit"])
|
||||
|
||||
# Date range
|
||||
dates = sorted(a.get("fileCreatedAt", "")[:10] for a in job["assets"] if a.get("fileCreatedAt"))
|
||||
date_range = f"{dates[0]} → {dates[-1]}" if len(dates) >= 2 else (dates[0] if dates else "—")
|
||||
|
||||
table.add_row(name, mode, count, date_range)
|
||||
table.add_row(name, count, date_range)
|
||||
|
||||
console.print()
|
||||
console.print(table)
|
||||
|
||||
@@ -13,12 +13,9 @@ console = Console()
|
||||
NOISY_LOGGERS = (
|
||||
"urllib3",
|
||||
"PIL",
|
||||
"ultralytics",
|
||||
"insightface",
|
||||
"onnxruntime",
|
||||
"matplotlib",
|
||||
"transformers",
|
||||
"torch",
|
||||
)
|
||||
|
||||
|
||||
@@ -51,7 +48,6 @@ def setup_logging(verbose: bool = False) -> logging.Logger:
|
||||
|
||||
# Suppress Python warnings from ML libraries
|
||||
warnings.filterwarnings("ignore", category=UserWarning, module="onnxruntime")
|
||||
warnings.filterwarnings("ignore", category=FutureWarning, module="transformers")
|
||||
|
||||
return root
|
||||
|
||||
|
||||
@@ -39,6 +39,11 @@ logger = logging.getLogger(__name__)
|
||||
UPLOAD_TRACKER_FILE = "frigate_uploaded_ids.json"
|
||||
REJECT_TRACKER_FILE = "frigate_rejected_ids.json"
|
||||
|
||||
# Write-through in-memory cache keyed by the resolved file path.
|
||||
# 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] = {}
|
||||
|
||||
|
||||
def _tracker_path(filename: str) -> Path:
|
||||
try:
|
||||
@@ -50,18 +55,23 @@ def _tracker_path(filename: str) -> Path:
|
||||
|
||||
def _load(filename: str) -> dict:
|
||||
path = _tracker_path(filename)
|
||||
if not path.exists():
|
||||
return {}
|
||||
key = str(path)
|
||||
if key in _cache:
|
||||
return _cache[key]
|
||||
data: dict = {}
|
||||
if path.exists():
|
||||
try:
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
data = json.load(f)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning(f"Could not load tracker {filename}: {e}")
|
||||
return {}
|
||||
_cache[key] = data
|
||||
return data
|
||||
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user