Compare commits
31
Commits
v0.6.4
...
3ea6b9d566
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ea6b9d566 | ||
|
|
e281ac01e1 | ||
|
|
0ac02c3108 | ||
|
|
db30449b09 | ||
|
|
1e4425bfd7 | ||
|
|
2be9f400dd | ||
|
|
f3b5bd9334 | ||
|
|
0906342edf | ||
|
|
5e0a871314 | ||
|
|
86caffc8d7 | ||
|
|
f6e494071e | ||
|
|
b69f776378 | ||
|
|
c4910d82ef | ||
|
|
5b8b3ab736 | ||
|
|
dc0c431e6b | ||
|
|
3296940806 | ||
|
|
fdcb4efac3 | ||
|
|
c1f04be15b | ||
|
|
001dd2c575 | ||
|
|
edf576bc93 | ||
|
|
561a1a3d72 | ||
|
|
614542decd | ||
|
|
3fccf9c8f9 | ||
|
|
eab3d9fe64 | ||
|
|
5509be150e | ||
|
|
0bd2eaf9fb | ||
|
|
b80d26b36b | ||
|
|
068a8e675f | ||
|
|
c36e7bf28e | ||
|
|
0ffe08bc6f | ||
|
|
3423d41535 |
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if git diff --cached --name-only | grep -q "^pyproject\.toml$"; then
|
||||
uv lock
|
||||
git add uv.lock
|
||||
fi
|
||||
@@ -21,6 +21,9 @@ jobs:
|
||||
- name: Set up Python
|
||||
run: uv python install 3.13
|
||||
|
||||
- name: Check lockfile is up to date
|
||||
run: uv lock --check
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra cpu
|
||||
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
# .github/workflows/update-lockfile.yml
|
||||
name: Update lockfile
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
paths:
|
||||
- 'pyproject.toml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
update-lockfile:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install 3.13
|
||||
|
||||
- name: Regenerate lockfile
|
||||
run: uv lock
|
||||
|
||||
- name: Check for changes
|
||||
id: diff
|
||||
run: |
|
||||
if git diff --quiet uv.lock; then
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- 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
|
||||
git commit -m "chore: update lockfile"
|
||||
git push
|
||||
@@ -7,6 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.6.6] - 2026-06-18
|
||||
|
||||
### Changed
|
||||
|
||||
- **`MAX_AUTO_IMAGES` default lowered from 20 to 5** — existing users who have not set this variable and already have more than 5 winnow-managed images in Frigate will find themselves at cap on the next run. With `QUALITY_REPLACEMENT=true` (the default), winnow will attempt to swap weaker images rather than uploading new ones. Set `MAX_AUTO_IMAGES=20` to restore the previous behaviour.
|
||||
|
||||
## [0.6.5] - 2026-06-17
|
||||
|
||||
### Added
|
||||
|
||||
- **Version displayed in startup banner**: winnow now prints its installed version at launch.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **GPU image: `CUDAExecutionProvider` missing due to parallel install race**: `insightface` declares `onnxruntime` (CPU) as a dependency, causing `uv sync` to install both `onnxruntime` and `onnxruntime-gpu` in parallel — both packages claim the same `pybind11_state.so` binary. On GitHub Actions the CPU binary consistently won the race, leaving the GPU build without CUDA support at runtime despite all CUDA libraries being present. Fixed by reinstalling `onnxruntime-gpu` sequentially after `uv sync` to guarantee its GPU binary is on disk.
|
||||
|
||||
- **GPU extra was missing three required nvidia pip packages**: `onnxruntime-gpu` 1.26.0 gates CUDA EP loading on the Python-importability of `nvidia-cuda-runtime-cu12`, `nvidia-cufft-cu12`, and `nvidia-curand-cu12`. These packages were not declared in the `gpu` extra and were absent on fresh installs, silently disabling GPU inference.
|
||||
|
||||
- **`_handle_duplicate_people` raises `KeyError` on id-less person records**: bare `p["id"]` subscripts in the auto-merge loop and `_smaller_duplicate_ids` raised `KeyError` when Immich returned a person dict without an `id` field (e.g. unconfirmed face clusters). Fixed by using `p.get("id")` and filtering `None` from `skip_ids`.
|
||||
|
||||
- **`_smaller_duplicate_ids` could include `None` in the skip set**: `p.get("id")` without a `None` guard populated `skip_ids` with `None`, causing `p.get("id") not in skip_ids` to pass for every id-less person, so unnamed face clusters were silently re-included in all return paths.
|
||||
|
||||
- **`_handle_duplicate_people` dead code removed**: guards `if not survivor_id` and `if not merge_ids` became unreachable after the id-gate fix; their presence suggested they still ran.
|
||||
|
||||
- **`_valid_people` in `jobs.py` used wrong name filter**: whitespace-only names (e.g. `" "`) passed the `p.get("name")` truthiness check and were included in the person list. Fixed using `(p.get("name") or "").strip()` consistent with the cli.py gate.
|
||||
|
||||
- **`interactive_configure` queued-marker check was O(N²)**: `[j for j in jobs if j["person"]["id"] == p.get("id")]` ran a full scan over jobs for every person in the display loop. Replaced with a `queued_ids` set hoisted before the loop.
|
||||
|
||||
- **`executor.py` slot restore did not clear `min_quality_score_for_slot`**: when a replacement upload failed all retries after a deletion, `effective_count` was restored but the stale quality-score floor from the deleted file remained, blocking the next candidate from filling the slot.
|
||||
|
||||
- **`get_immich_version` swallowed `KeyError` on unexpected schema**: bare `data["major"]` / `data["minor"]` / `data["patch"]` subscripts were silently caught by the surrounding `except Exception`, returning `None` without logging. Replaced with `.get()` calls that log a debug warning on unexpected schemas.
|
||||
|
||||
- **Face embedding selects nearest face to crop centre, not largest by area**: a 25 % margin on the crop window can pull a larger neighbouring face into the bounding box; selecting the biggest face by area then embeds the wrong person. Centre-proximity is now used instead.
|
||||
|
||||
- **Zero-norm face embeddings skipped before diversity selection**: InsightFace occasionally returns a zero vector for low-quality detections; zero embeddings pass deduplication with similarity 0 and score distance 1.0, causing them to be selected first as maximally diverse.
|
||||
|
||||
- **`executor.py` slot restore did not clear `min_quality_score_for_slot`**: stale quality floor from the deleted file blocked the next candidate from filling the restored slot in quality-replacement mode.
|
||||
|
||||
## [0.6.4] - 2026-06-17
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -17,8 +17,11 @@ git clone https://github.com/sudolulo/winnow.git
|
||||
cd winnow
|
||||
git checkout dev
|
||||
uv sync
|
||||
git config core.hooksPath .githooks
|
||||
```
|
||||
|
||||
The last line activates the project's git hooks. The pre-commit hook automatically runs `uv lock` and stages the result whenever `pyproject.toml` is part of a commit, keeping the lockfile in sync without any extra steps.
|
||||
|
||||
## Running Tests and Lint
|
||||
|
||||
```bash
|
||||
|
||||
+3
-1
@@ -50,7 +50,9 @@ RUN if [ "$VARIANT" = "cpu" ]; then \
|
||||
elif [ "$VARIANT" = "intel" ]; then \
|
||||
uv sync --frozen --no-dev --extra intel; \
|
||||
elif [ "$VARIANT" = "gpu" ]; then \
|
||||
uv sync --frozen --no-dev --extra gpu; \
|
||||
uv sync --frozen --no-dev --extra gpu && \
|
||||
ORT_GPU_VER=$(.venv/bin/python -c "import importlib.metadata; print(importlib.metadata.version('onnxruntime-gpu'))") && \
|
||||
uv pip install --python .venv/bin/python --no-deps --reinstall "onnxruntime-gpu==$ORT_GPU_VER"; \
|
||||
else \
|
||||
echo "Unknown VARIANT: '$VARIANT'. Must be one of: cpu, rocm, intel, gpu" >&2; \
|
||||
exit 1; \
|
||||
|
||||
@@ -187,7 +187,7 @@ In scheduled mode the process (and loaded models) stays resident between runs. T
|
||||
|
||||
| Variable | Default | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `MAX_AUTO_IMAGES` | `20` | Maximum training images per person in Frigate |
|
||||
| `MAX_AUTO_IMAGES` | `5` | 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)*
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ 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: 20)
|
||||
# - MAX_AUTO_IMAGES=80 # Hard cap on auto-diversity selection (default: 5)
|
||||
|
||||
# ── Caching & Models ──────────────────────────────────────────────────
|
||||
# - FORCE_CPU=true # Disable GPU, fall back to CPU
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "winnow"
|
||||
version = "0.6.4"
|
||||
version = "0.6.6"
|
||||
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"
|
||||
@@ -29,6 +29,9 @@ 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'",
|
||||
"nvidia-cuda-runtime-cu12>=12.0; sys_platform == 'linux' and platform_machine == 'x86_64'",
|
||||
"nvidia-cufft-cu12>=11.0; sys_platform == 'linux' and platform_machine == 'x86_64'",
|
||||
"nvidia-curand-cu12>=10.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'"]
|
||||
|
||||
+2
-1
@@ -49,11 +49,12 @@ def _run_scheduler() -> None:
|
||||
try:
|
||||
main()
|
||||
print("winnow run complete", flush=True)
|
||||
except KeyboardInterrupt:
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("winnow run failed: %s", e, exc_info=True)
|
||||
print(f"winnow run failed: {e}", flush=True)
|
||||
cron = croniter(schedule, time.time())
|
||||
next_run = cron.get_next(float)
|
||||
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())))
|
||||
|
||||
@@ -21,7 +21,7 @@ def test_config_loads_defaults(monkeypatch):
|
||||
assert cfg.MIN_FACE_COUNT == 3
|
||||
assert cfg.BLUR_THRESHOLD == 120.0
|
||||
assert cfg.MIN_CONFIDENCE == 0.7
|
||||
assert cfg.MAX_AUTO_IMAGES == 20
|
||||
assert cfg.MAX_AUTO_IMAGES == 5
|
||||
assert cfg.QUALITY_REPLACEMENT is True
|
||||
assert cfg.FACE_MARGIN == 0.15
|
||||
assert cfg.USE_FULL_RESOLUTION is True
|
||||
|
||||
@@ -340,6 +340,16 @@ 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-cuda-runtime-cu12"
|
||||
version = "12.9.79"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/e0/0279bd94539fda525e0c8538db29b72a5a8495b0c12173113471d28bce78/nvidia_cuda_runtime_cu12-12.9.79-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83469a846206f2a733db0c42e223589ab62fd2fabac4432d2f8802de4bded0a4", size = 3515012, upload-time = "2025-06-05T20:00:35.519Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/46/a92db19b8309581092a3add7e6fceb4c301a3fd233969856a8cbf042cd3c/nvidia_cuda_runtime_cu12-12.9.79-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25bba2dfb01d48a9b59ca474a1ac43c6ebf7011f1b0b8cc44f54eb6ac48a96c3", size = 3493179, upload-time = "2025-06-05T20:00:53.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/df/e7c3a360be4f7b93cee39271b792669baeb3846c58a4df6dfcf187a7ffab/nvidia_cuda_runtime_cu12-12.9.79-py3-none-win_amd64.whl", hash = "sha256:8e018af8fa02363876860388bd10ccb89eb9ab8fb0aa749aaf58430a9f7c4891", size = 3591604, upload-time = "2025-06-05T20:11:17.036Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nvidia-cudnn-cu12"
|
||||
version = "9.23.1.3"
|
||||
@@ -353,6 +363,39 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/75/ec/62b56fc5e8219a268c6f62c4e9fb1369ebec049512328e650d1a9a28bcc8/nvidia_cudnn_cu12-9.23.1.3-py3-none-win_amd64.whl", hash = "sha256:b874af5bfab5e1010ae88bfead14bf8e9da6b20283582288f1c05f056090a398", size = 689996767, upload-time = "2026-06-09T19:44:25.343Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nvidia-cufft-cu12"
|
||||
version = "11.4.1.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nvidia-nvjitlink-cu12", marker = "platform_machine != 's390x'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/2b/76445b0af890da61b501fde30650a1a4bd910607261b209cccb5235d3daa/nvidia_cufft_cu12-11.4.1.4-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1a28c9b12260a1aa7a8fd12f5ebd82d027963d635ba82ff39a1acfa7c4c0fbcf", size = 200822453, upload-time = "2025-06-05T20:05:27.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/f4/61e6996dd20481ee834f57a8e9dca28b1869366a135e0d42e2aa8493bdd4/nvidia_cufft_cu12-11.4.1.4-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c67884f2a7d276b4b80eb56a79322a95df592ae5e765cf1243693365ccab4e28", size = 200877592, upload-time = "2025-06-05T20:05:45.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/ee/29955203338515b940bd4f60ffdbc073428f25ef9bfbce44c9a066aedc5c/nvidia_cufft_cu12-11.4.1.4-py3-none-win_amd64.whl", hash = "sha256:8e5bfaac795e93f80611f807d42844e8e27e340e0cde270dcb6c65386d795b80", size = 200067309, upload-time = "2025-06-05T20:13:59.762Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nvidia-curand-cu12"
|
||||
version = "10.3.10.19"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/1c/2a45afc614d99558d4a773fa740d8bb5471c8398eeed925fc0fcba020173/nvidia_curand_cu12-10.3.10.19-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:de663377feb1697e1d30ed587b07d5721fdd6d2015c738d7528a6002a6134d37", size = 68292066, upload-time = "2025-05-01T19:39:13.595Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/44/193a0e171750ca9f8320626e8a1f2381e4077a65e69e2fb9708bd479e34a/nvidia_curand_cu12-10.3.10.19-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:49b274db4780d421bd2ccd362e1415c13887c53c214f0d4b761752b8f9f6aa1e", size = 68295626, upload-time = "2025-05-01T19:39:38.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/98/1bd66fd09cbe1a5920cb36ba87029d511db7cca93979e635fd431ad3b6c0/nvidia_curand_cu12-10.3.10.19-py3-none-win_amd64.whl", hash = "sha256:e8129e6ac40dc123bd948e33d3e11b4aa617d87a583fa2f21b3210e90c743cde", size = 68774847, upload-time = "2025-05-01T19:48:52.93Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nvidia-nvjitlink-cu12"
|
||||
version = "12.9.86"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/46/0c/c75bbfb967457a0b7670b8ad267bfc4fffdf341c074e0a80db06c24ccfd4/nvidia_nvjitlink_cu12-12.9.86-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:e3f1171dbdc83c5932a45f0f4c99180a70de9bd2718c1ab77d14104f6d7147f9", size = 39748338, upload-time = "2025-06-05T20:10:25.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/bc/2dcba8e70cf3115b400fef54f213bcd6715a3195eba000f8330f11e40c45/nvidia_nvjitlink_cu12-12.9.86-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:994a05ef08ef4b0b299829cde613a424382aff7efb08a7172c1fa616cc3af2ca", size = 39514880, upload-time = "2025-06-05T20:10:04.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/7e/2eecb277d8a98184d881fb98a738363fd4f14577a4d2d7f8264266e82623/nvidia_nvjitlink_cu12-12.9.86-py3-none-win_amd64.whl", hash = "sha256:cc6fcec260ca843c10e34c936921a1c426b351753587fdd638e8cff7b16bb9db", size = 35584936, upload-time = "2025-06-05T20:16:08.525Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "onnx"
|
||||
version = "1.21.0"
|
||||
@@ -862,7 +905,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.6.2"
|
||||
version = "0.6.6"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "croniter" },
|
||||
@@ -880,7 +923,10 @@ cpu = [
|
||||
{ name = "onnxruntime" },
|
||||
]
|
||||
gpu = [
|
||||
{ name = "nvidia-cuda-runtime-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||
{ name = "nvidia-cudnn-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||
{ name = "nvidia-cufft-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||
{ name = "nvidia-curand-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||
{ name = "onnxruntime-gpu", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||
]
|
||||
intel = [
|
||||
@@ -901,7 +947,10 @@ requires-dist = [
|
||||
{ name = "croniter", specifier = ">=5.0.2" },
|
||||
{ name = "insightface", specifier = ">=0.7.3" },
|
||||
{ name = "numpy", specifier = ">=2.2.6" },
|
||||
{ name = "nvidia-cuda-runtime-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'gpu'", specifier = ">=12.0" },
|
||||
{ name = "nvidia-cudnn-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'gpu'", specifier = ">=9.0.0" },
|
||||
{ name = "nvidia-cufft-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'gpu'", specifier = ">=11.0" },
|
||||
{ name = "nvidia-curand-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'gpu'", specifier = ">=10.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" },
|
||||
|
||||
+18
-12
@@ -7,6 +7,7 @@ import sys
|
||||
from rich import print as rprint
|
||||
from rich.prompt import Confirm
|
||||
|
||||
from . import __version__
|
||||
from .config import Config, _getenv_bool
|
||||
from .executor import execute_jobs, upload_to_frigate
|
||||
from .immich_api import get_immich_version, get_people, merge_people
|
||||
@@ -71,7 +72,7 @@ def _handle_duplicate_people(people: list[dict]) -> list[dict]:
|
||||
by_name: dict[str, list[dict]] = defaultdict(list)
|
||||
for p in people:
|
||||
name = (p.get("name") or "").strip()
|
||||
if name:
|
||||
if name and p.get("id"):
|
||||
by_name[name].append(p)
|
||||
|
||||
duplicates = {name: ps for name, ps in by_name.items() if len(ps) > 1}
|
||||
@@ -81,19 +82,23 @@ def _handle_duplicate_people(people: list[dict]) -> list[dict]:
|
||||
def _smaller_duplicate_ids(groups: dict) -> set[str]:
|
||||
"""IDs of all but the largest person in each duplicate group."""
|
||||
return {
|
||||
p.get("id")
|
||||
pid
|
||||
for ps in groups.values()
|
||||
for p in sorted(ps, key=lambda x: x.get("assetCount", 0), reverse=True)[1:]
|
||||
if (pid := p.get("id"))
|
||||
}
|
||||
|
||||
skip_ids = _smaller_duplicate_ids(duplicates)
|
||||
|
||||
def _excl(lst: list[dict]) -> list[dict]:
|
||||
return [p for p in lst if p.get("id") not in skip_ids]
|
||||
|
||||
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()):
|
||||
ordered = sorted(ps, key=lambda x: x.get("assetCount", 0), reverse=True)
|
||||
entries = ", ".join(
|
||||
f"[dim]{p['id'][:8]}…[/dim] ({p.get('assetCount', 0)} assets)"
|
||||
f"[dim]{(p.get('id') or '?')[:8]}…[/dim] ({p.get('assetCount', 0)} assets)"
|
||||
for p in ordered
|
||||
)
|
||||
rprint(f" [yellow]{name}[/yellow] → {len(ps)} people: {entries}")
|
||||
@@ -109,20 +114,21 @@ 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.
|
||||
return [p for p in people if p.get("id") not in skip_ids]
|
||||
return _excl(people)
|
||||
|
||||
# Auto-merge: survivor = largest asset count, rest merge into it inside Immich
|
||||
merged_any = False
|
||||
for name, ps in sorted(duplicates.items()):
|
||||
ordered = sorted(ps, key=lambda x: x.get("assetCount", 0), reverse=True)
|
||||
survivor = ordered[0]
|
||||
merge_ids = [p["id"] for p in ordered[1:]]
|
||||
survivor_id = survivor.get("id")
|
||||
merge_ids = [pid for p in ordered[1:] if (pid := p.get("id")) is not None]
|
||||
rprint(
|
||||
f" [cyan]Merging {name!r} inside Immich:[/cyan] keeping "
|
||||
f"[dim]{survivor['id'][:8]}…[/dim] ({survivor.get('assetCount', 0)} assets), "
|
||||
f"[dim]{survivor_id[:8]}…[/dim] ({survivor.get('assetCount', 0)} assets), "
|
||||
f"absorbing {len(merge_ids)} smaller duplicate(s)..."
|
||||
)
|
||||
if merge_people(survivor["id"], merge_ids):
|
||||
if merge_people(survivor_id, merge_ids):
|
||||
rprint(f" [green]✓ Merged {name!r}[/green]")
|
||||
merged_any = True
|
||||
else:
|
||||
@@ -141,12 +147,12 @@ def _handle_duplicate_people(people: list[dict]) -> list[dict]:
|
||||
" — possible transient error or expired API key;"
|
||||
" proceeding with pre-merge list. Check IMMICH_API_KEY if this recurs."
|
||||
)
|
||||
return [p for p in people if p.get("id") not in skip_ids]
|
||||
return _excl(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.
|
||||
return [p for p in fresh if p.get("id") not in skip_ids]
|
||||
return _excl(fresh)
|
||||
|
||||
# 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.
|
||||
@@ -154,7 +160,7 @@ def _handle_duplicate_people(people: list[dict]) -> list[dict]:
|
||||
" [yellow]All merges failed — applying local deduplication"
|
||||
" to avoid overwriting output.[/yellow]"
|
||||
)
|
||||
return [p for p in people if p.get("id") not in skip_ids]
|
||||
return _excl(people)
|
||||
|
||||
|
||||
_UNSUPPORTED_VARS = [
|
||||
@@ -179,8 +185,8 @@ def main() -> None:
|
||||
if trace_size:
|
||||
_handle_trace_crop(trace_size)
|
||||
|
||||
console.print(r"""
|
||||
[bold blue]winnow[/bold blue]
|
||||
console.print(f"""
|
||||
[bold blue]winnow[/bold blue] [dim]v{__version__}[/dim]
|
||||
[dim]Immich -> Frigate Training Data Curator[/dim]
|
||||
""")
|
||||
|
||||
|
||||
+4
-1
@@ -127,12 +127,15 @@ class _Config:
|
||||
self.API_KEY = os.getenv("API_KEY")
|
||||
self.OUTPUT_DIR = os.getenv("OUTPUT_DIR", "./frigate_train")
|
||||
self.YEARS_FILTER = _getenv_int("YEARS_FILTER", 10)
|
||||
if self.YEARS_FILTER < 0:
|
||||
logging.warning("YEARS_FILTER=%s is negative — using default 10", self.YEARS_FILTER)
|
||||
self.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.MAX_AUTO_IMAGES = _getenv_int("MAX_AUTO_IMAGES", 5)
|
||||
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)
|
||||
|
||||
@@ -303,6 +303,9 @@ def _select_by_embedding(
|
||||
|
||||
emb = get_embedding(embed_img, asset_id=asset["id"])
|
||||
if emb is not None:
|
||||
if np.linalg.norm(emb) < 1e-6:
|
||||
logger.debug("Zero-norm embedding for asset %s, skipping", asset["id"])
|
||||
continue
|
||||
embeddings.append(emb)
|
||||
valid_candidates.append(asset)
|
||||
confidence_scores.append(confidence)
|
||||
|
||||
+12
-4
@@ -107,7 +107,6 @@ def get_insightface_app():
|
||||
global _insightface_app, _insightface_loaded
|
||||
if _insightface_loaded:
|
||||
return _insightface_app
|
||||
_insightface_loaded = True
|
||||
|
||||
ctx_id = -1
|
||||
insightface_home = os.environ.get("INSIGHTFACE_HOME", os.path.expanduser("~/.insightface"))
|
||||
@@ -172,10 +171,12 @@ def get_insightface_app():
|
||||
_insightface_app.prepare(ctx_id=ctx_id, det_size=(640, 640))
|
||||
|
||||
logger.info("InsightFace Buffalo_L: ready on %s (%.1fs)", device_str, time.time() - t0)
|
||||
_insightface_loaded = True
|
||||
return _insightface_app
|
||||
|
||||
except ImportError:
|
||||
logger.error("InsightFace not installed!")
|
||||
_insightface_loaded = True
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error("Failed to load InsightFace: %s", e)
|
||||
@@ -193,9 +194,11 @@ def get_insightface_app():
|
||||
)
|
||||
_insightface_app.prepare(ctx_id=-1, det_size=(640, 640))
|
||||
logger.info("InsightFace Buffalo_L: ready on CPU (fallback, %.1fs)", time.time() - t0)
|
||||
_insightface_loaded = True
|
||||
return _insightface_app
|
||||
except Exception as ex:
|
||||
logger.error("InsightFace CPU fallback failed: %s", ex)
|
||||
_insightface_loaded = True
|
||||
return None
|
||||
|
||||
|
||||
@@ -218,9 +221,14 @@ def get_face_embedding(img_pil: Image.Image) -> np.ndarray | None:
|
||||
if not faces:
|
||||
return None
|
||||
|
||||
# Return embedding of largest face
|
||||
largest = max(faces, key=lambda f: (f.bbox[2] - f.bbox[0]) * (f.bbox[3] - f.bbox[1]))
|
||||
return largest.embedding
|
||||
# Return embedding of the face nearest the crop centre; a large margin can pull
|
||||
# a bigger neighbouring face into frame, and max-by-area would pick the wrong person.
|
||||
cx, cy = img_pil.width / 2, img_pil.height / 2
|
||||
nearest = min(
|
||||
faces,
|
||||
key=lambda f: ((f.bbox[0] + f.bbox[2]) / 2 - cx) ** 2 + ((f.bbox[1] + f.bbox[3]) / 2 - cy) ** 2,
|
||||
)
|
||||
return nearest.embedding
|
||||
except Exception as e:
|
||||
logger.error("Error getting face embedding: %s", e)
|
||||
return None
|
||||
|
||||
+19
-9
@@ -186,12 +186,11 @@ def execute_jobs(jobs: list[dict]) -> None:
|
||||
saved = process_face_mode(
|
||||
img, asset, person, person_dir, count, insightface_app=insightface_app
|
||||
)
|
||||
if saved:
|
||||
if isinstance(saved, tuple):
|
||||
filename = f"{count}.jpg"
|
||||
asset_map[filename] = asset["id"]
|
||||
score_map[filename] = asset.get("quality_score")
|
||||
if isinstance(saved, tuple):
|
||||
dims_map[filename] = saved
|
||||
dims_map[filename] = saved
|
||||
# Time-spread path: compute blur score from the downloaded
|
||||
# image. Capped at 1440px via blur_score_from_image() so the
|
||||
# scale matches the preview thumbnails the embedding path uses
|
||||
@@ -202,8 +201,9 @@ def execute_jobs(jobs: list[dict]) -> None:
|
||||
|
||||
count += 1
|
||||
else:
|
||||
reason = saved if isinstance(saved, str) else "no usable face data"
|
||||
progress.console.print(
|
||||
f"[yellow]Skipped {asset['id']} (no usable face data)[/yellow]"
|
||||
f"[yellow]Skipped {asset['id']} ({reason})[/yellow]"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to process asset %s: %s", asset.get("id", "<unknown>"), e)
|
||||
@@ -384,9 +384,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 not None and file_score <= min_quality_score_for_slot:
|
||||
if file_score is not None and file_score < min_quality_score_for_slot:
|
||||
progress.console.print(
|
||||
f" [dim]⏭ {fname}: score {file_score:.3f} ≤ 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)
|
||||
@@ -514,8 +514,7 @@ def upload_to_frigate(jobs: list[dict]) -> None:
|
||||
uploaded += 1
|
||||
person_uploaded += 1
|
||||
effective_count += 1
|
||||
min_quality_score_for_slot = None
|
||||
|
||||
min_quality_score_for_slot = None # for/else rollback mirrors this pair
|
||||
asset_id = asset_map.get(fname)
|
||||
if asset_id:
|
||||
try:
|
||||
@@ -567,13 +566,14 @@ def upload_to_frigate(jobs: list[dict]) -> None:
|
||||
error_detail = resp.json().get("message", full_body[:100])
|
||||
except Exception:
|
||||
error_detail = full_body[:100]
|
||||
if resp.status_code == 400:
|
||||
if resp.status_code in (400, 500):
|
||||
progress.console.print(f" [dim]{error_detail}[/dim]")
|
||||
else:
|
||||
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
|
||||
or (resp.status_code == 500 and "could not process" in full_body.lower())
|
||||
)
|
||||
if _is_permanent:
|
||||
asset_id = asset_map.get(fname)
|
||||
@@ -608,6 +608,16 @@ def upload_to_frigate(jobs: list[dict]) -> None:
|
||||
progress.console.print(
|
||||
f" [red]✗ {fname}: {type(e).__name__} - {e} (after {max_retries} attempts)[/red]"
|
||||
)
|
||||
else:
|
||||
# All retries exhausted without a successful upload.
|
||||
# Restore the slot freed by the preceding delete so the next
|
||||
# candidate still sees at_cap=True and must beat the replacement gate.
|
||||
# Also clear the quality floor — the deleted file's score no longer
|
||||
# represents any live Frigate file, and leaving it blocks the next
|
||||
# candidate from filling the restored slot.
|
||||
if at_cap:
|
||||
effective_count += 1
|
||||
min_quality_score_for_slot = None
|
||||
|
||||
progress.advance(upload_task)
|
||||
|
||||
|
||||
@@ -146,8 +146,10 @@ def delete_frigate_person_files(person_name: str, filenames: list[str]) -> bool:
|
||||
Returns True on success, False if unreachable or the request fails.
|
||||
"""
|
||||
frigate_url = _get_frigate_url()
|
||||
if not frigate_url or not filenames:
|
||||
if not frigate_url:
|
||||
return False
|
||||
if not filenames:
|
||||
return True
|
||||
from urllib.parse import quote
|
||||
encoded_name = quote(person_name, safe="")
|
||||
try:
|
||||
|
||||
@@ -48,7 +48,9 @@ def align_face(img: Image.Image, landmarks: list[list[float]] | np.ndarray) -> I
|
||||
if lm.shape != (5, 2):
|
||||
logger.debug("Invalid landmark shape: %s, expected (5, 2)", lm.shape)
|
||||
return None
|
||||
aligned = norm_crop(img_np, lm)
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings("ignore", message=".*estimate.*is deprecated", category=FutureWarning)
|
||||
aligned = norm_crop(img_np, lm)
|
||||
return Image.fromarray(aligned)
|
||||
except ImportError:
|
||||
logger.debug("InsightFace not available for face alignment")
|
||||
@@ -66,10 +68,11 @@ def process_face_mode(
|
||||
count: int,
|
||||
min_width: int | None = None,
|
||||
insightface_app=None,
|
||||
) -> tuple[int, int] | None:
|
||||
) -> tuple[int, int] | str:
|
||||
"""Crop face based on Immich metadata and save to output directory.
|
||||
|
||||
Returns (width, height) of the saved crop, or None if no crop was saved.
|
||||
Returns (width, height) of the saved crop, or a skip-reason string if the
|
||||
face was filtered out.
|
||||
When insightface_app is provided and ENABLE_FACE_ALIGNMENT is True,
|
||||
re-detects the face in the Immich bbox region using InsightFace to get
|
||||
precise landmarks for a proper 112x112 aligned crop. Falls back to
|
||||
@@ -89,7 +92,7 @@ def process_face_mode(
|
||||
|
||||
if not face_info:
|
||||
logger.debug("No face info for %s in asset %s", person.get("name"), asset.get("id"))
|
||||
return None
|
||||
return "no face metadata"
|
||||
|
||||
img_w, img_h = img.size
|
||||
meta_w = face_info.get("imageWidth") or 0
|
||||
@@ -108,7 +111,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("Face too small (%.1fx%.1f)", face_w, face_h)
|
||||
return None
|
||||
return f"face too small ({face_w:.0f}x{face_h:.0f}px, min {min_width}px)"
|
||||
|
||||
# Re-detect face with InsightFace for landmark-based alignment.
|
||||
# Immich's /api/faces endpoint does not include landmarks, so the
|
||||
|
||||
@@ -39,7 +39,11 @@ def get_immich_version() -> tuple[int, int, int] | None:
|
||||
)
|
||||
if resp.ok:
|
||||
data = resp.json()
|
||||
return (int(data["major"]), int(data["minor"]), int(data["patch"]))
|
||||
major, minor, patch = data.get("major"), data.get("minor"), data.get("patch")
|
||||
if major is None or minor is None or patch is None:
|
||||
logger.debug("Unexpected Immich version schema: %s", data)
|
||||
return None
|
||||
return (int(major), int(minor), int(patch))
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
@@ -130,7 +134,7 @@ def fetch_all_assets(person: dict) -> tuple[list[dict], int]:
|
||||
# Immich ≥2.x returns {"assets": {"items": [...]}};
|
||||
# earlier versions returned {"assets": [...]} directly.
|
||||
if isinstance(page_assets, dict):
|
||||
page_assets = page_assets.get("items", [])
|
||||
page_assets = page_assets.get("items") or []
|
||||
|
||||
page_count = len(page_assets) # raw count for termination check before filtering
|
||||
|
||||
@@ -299,7 +303,7 @@ def filter_recent_assets(assets: list[dict], years: int | None = None) -> list[d
|
||||
recent.append(asset)
|
||||
else:
|
||||
skipped += 1
|
||||
except ValueError:
|
||||
except (ValueError, TypeError):
|
||||
bad_timestamp += 1
|
||||
continue
|
||||
|
||||
|
||||
+20
-9
@@ -86,7 +86,11 @@ def _resolve_strategy(strategy: str, has_embedding: bool) -> tuple[int | str, st
|
||||
"standard": (30, "smart"),
|
||||
"broad": (100, "smart"),
|
||||
}
|
||||
return strategy_map.get(strategy, ("auto", "smart"))
|
||||
result = strategy_map.get(strategy)
|
||||
if result is None:
|
||||
logger.warning("Unrecognised STRATEGY=%r — falling back to auto", strategy)
|
||||
return ("auto", "smart")
|
||||
return result
|
||||
|
||||
|
||||
def _perform_selection(
|
||||
@@ -192,13 +196,20 @@ def _configure_person(person: dict, people: list[dict]) -> dict | None:
|
||||
return job
|
||||
|
||||
|
||||
def _valid_people(people: list[dict]) -> list[dict]:
|
||||
return sorted(
|
||||
[p for p in people if (p.get("name") or "").strip() and p.get("id")],
|
||||
key=lambda x: x["name"],
|
||||
)
|
||||
|
||||
|
||||
def interactive_configure(people: list[dict]) -> list[dict]:
|
||||
"""Interactive phase: select person(s), mode, and configure training strategy.
|
||||
|
||||
Supports multi-person batch mode — after configuring one person,
|
||||
prompts to add another.
|
||||
"""
|
||||
valid_people = sorted([p for p in people if p.get("name")], key=lambda x: x["name"])
|
||||
valid_people = _valid_people(people)
|
||||
|
||||
if not valid_people:
|
||||
rprint("[red]No people found with names in Immich.[/red]")
|
||||
@@ -209,9 +220,9 @@ def interactive_configure(people: list[dict]) -> list[dict]:
|
||||
while True:
|
||||
# Select person
|
||||
console.print("\n[bold cyan]Select Person to Train:[/bold cyan]")
|
||||
queued_ids = {j["person"]["id"] for j in jobs}
|
||||
for idx, p in enumerate(valid_people, 1):
|
||||
# Mark already-queued people
|
||||
marker = " [dim](queued)[/dim]" if any(j["person"]["id"] == p["id"] for j in jobs) else ""
|
||||
marker = " [dim](queued)[/dim]" if p.get("id") in queued_ids else ""
|
||||
console.print(f" [bold]{idx}.[/bold] {p['name']}{marker}")
|
||||
|
||||
p_choice = IntPrompt.ask("Enter Number", choices=[str(i) for i in range(1, len(valid_people) + 1)])
|
||||
@@ -230,20 +241,20 @@ def interactive_configure(people: list[dict]) -> list[dict]:
|
||||
|
||||
def auto_configure(people: list[dict]) -> list[dict]:
|
||||
"""Non-interactive: configure jobs for all named people automatically."""
|
||||
valid_people = sorted([p for p in people if p.get("name")], key=lambda x: x["name"])
|
||||
valid_people = _valid_people(people)
|
||||
|
||||
if not valid_people:
|
||||
rprint("[red]No people found with names in Immich.[/red]")
|
||||
return []
|
||||
|
||||
strategy = os.environ.get("STRATEGY", "auto")
|
||||
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()]
|
||||
skip = {s.strip().casefold() for s in os.environ.get("SKIP_PEOPLE", "").split(",") if s.strip()}
|
||||
only = {s.strip().casefold() 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]
|
||||
valid_people = [p for p in valid_people if p["name"].casefold() in only]
|
||||
if skip:
|
||||
valid_people = [p for p in valid_people if p["name"] not in skip]
|
||||
valid_people = [p for p in valid_people if p["name"].casefold() not in skip]
|
||||
|
||||
min_face_count = Config.MIN_FACE_COUNT
|
||||
|
||||
|
||||
Reference in New Issue
Block a user