Compare commits

...
27 Commits
Author SHA1 Message Date
flan 46bef19e71 fix: cap fetch_all_assets at 5000 items; filter non-dict page entries
Fetching up to MAX_PAGES*page_size (1M) assets before the 3000-item
diversity pool cap was applied could exhaust memory on large Immich
libraries. Early-exit once 5000 items are collected — the pool cap
of 3000 makes anything beyond that wasteful. Also filter null/non-dict
items from page responses at fetch time.
2026-06-14 03:49:20 +00:00
flan 3c602e2ef6 fix: code review corrections — dedup O(N²), truncated rejection check, pool warning, path guard
- _dedup_embeddings: rebuild kept_stack only on keep (was every iteration → O(N²))
- _dedup_embeddings: fix quality_score sort key to use explicit None check (falsy-zero)
- _select_by_embedding: add post-dedup pool < limit guard with warning
- executor: use full resp.text for 'face' keyword check; only truncate display snippet
- _safe_person_dir: avoid false "//" prefix when output_dir resolves to filesystem root
2026-06-14 03:40:39 +00:00
flan a7d4504db9 docs: add disclaimer that winnow is not an approved Frigate training method 2026-06-14 03:24:31 +00:00
github-actions[bot] b276d686f8 chore: update lockfiles 2026-06-14 03:22:57 +00:00
flan f9482eec4d chore: bump version to 0.4.4, update changelog 2026-06-14 03:22:32 +00:00
flan 694f860b6d Raise near-duplicate dedup threshold from 0.10 to 0.20
0.10 only removed burst shots (distance 0.01-0.05). Same-event photos with
similar pose and lighting sit at 0.10-0.20 and were passing through,
producing visually similar training images especially for people with small
datasets. 0.20 removes these while still preserving genuinely different
poses, expressions, and lighting conditions.
2026-06-14 02:52:12 +00:00
flan 6e34d41036 Guard person-name path traversal in output directory construction
os.path.join silently discards the base when the second arg is absolute,
and '../..' sequences escape the output tree. _safe_person_dir() resolves
both paths with realpath and rejects any name that lands outside the output
directory, logging an error and skipping the job rather than touching an
unintended path.
2026-06-14 01:48:20 +00:00
flan a9c1114b86 Warn when a person named '*' exists during RESET_PERSON=* bulk reset 2026-06-14 01:46:46 +00:00
flan 19f1a5e03b Add RESET_PERSON=* to reset all tracked people; fix near-duplicate dedup
- RESET_PERSON=* resets every tracked person (deletes their Frigate files
  and clears the tracker). Any other value resets that specific person by
  name, including someone literally named 'all'.
- Near-duplicate removal pass added before diversity clustering: greedily
  drops candidates within 0.10 cosine distance of a higher-quality image,
  eliminating burst-shot duplicates that FPS would otherwise pass through.
2026-06-14 01:46:28 +00:00
flan 0a8a0c16dd Deduplicate near-identical embeddings before diversity selection
Burst shots produce embeddings that differ slightly (~0.01-0.05 cosine
distance) due to JPEG noise and minor lighting variation, so FPS does not
filter them. Add a greedy dedup pass after embedding collection: sort
candidates by quality score descending, then drop any candidate within
0.10 cosine distance of an already-kept image. The best frame from each
near-identical group survives; the rest are dropped before clustering.
2026-06-14 01:26:41 +00:00
flan eb3abe2cca Fix misleading HTTP 500 detail and RuntimeWarning on single-image selection
- HTTP 500 errors from Frigate no longer echo the response body to the user
  (Frigate's generic message says 'Try restarting Frigate' which is wrong —
  500s on upload are almost always image-specific, not a health issue). The
  detail is now logged at debug level. HTTP 400 detail is still shown since
  'No face was detected' is genuinely useful.
- np.median on empty upper triangle (n=1 after quality filtering) no longer
  emits RuntimeWarning; _compute_adaptive_threshold returns the floor (0.05)
  immediately when there are no pairwise distances to sample.
- k-medoids cluster count floor raised to 1 (was 0 when n < 3), preventing
  k=0 being passed to _kmedoids.
2026-06-14 01:23:46 +00:00
github-actions[bot] 168a8e33b5 chore: update lockfiles 2026-06-14 00:27:42 +00:00
flan fd0bd213e8 chore: bump version to 0.4.3, update changelog 2026-06-14 00:26:56 +00:00
flan 7efe283e9a Merge feature/insightface-crop-alignment into dev 2026-06-14 00:26:23 +00:00
flan 2dd911e9ea Detect and handle duplicate Immich people with the same name
When Immich has multiple person records sharing a name (e.g. unmerged
face clusters), winnow would previously run separate jobs for each,
with the second job wiping the first job's output directory — resulting
in far fewer training images than expected.

New behaviour:
- At startup, duplicate names are detected and a warning is printed
  showing asset counts for each duplicate.
- By default (MERGE_DUPLICATE_PEOPLE=false), only the person with the
  most assets is processed; smaller duplicates are skipped cleanly.
- With MERGE_DUPLICATE_PEOPLE=true, the duplicates are permanently
  merged inside Immich via PUT /api/people/{id}/merge (keeps the
  largest), then the people list is re-fetched before jobs run.

Also adds an explicit comment in executor.py confirming that replacement
targets come exclusively from tracker-mapped files, so manually-added
Frigate training images are never selected for deletion.
2026-06-13 23:58:38 +00:00
flan 341c6b0e85 Use InsightFace for landmark-based face crop alignment
Immich's /api/faces endpoint only returns bounding boxes, not facial
landmarks. This meant align_face() never fired and all crops fell back
to a plain bbox rectangle — producing partial crops (forehead-only,
off-angle faces) when Immich's detection was slightly off.

Now, when ENABLE_FACE_ALIGNMENT is true and InsightFace is loaded,
execute_jobs() passes the app to process_face_mode(). For each face,
it expands the Immich bbox by 50%, crops that search region, runs
InsightFace detection within it, and aligns the nearest face to the
standard ArcFace 112×112 format using norm_crop(). Falls back to bbox
crop if InsightFace finds no face in the search region.

The InsightFace model is already in GPU memory from the diversity/
embedding phase, so the singleton lookup adds no load cost.
2026-06-13 23:13:50 +00:00
flan 6fb3d2f61d ci: drop arm64 from GPU build — use :cpu for arm64 instead 2026-06-13 22:43:27 +00:00
flan 3888a5e6db docs: disclose AI-assisted development in CONTRIBUTING.md 2026-06-13 22:28:41 +00:00
github-actions[bot] b804b13644 chore: update lockfiles 2026-06-13 22:19:49 +00:00
flanandClaude Sonnet 4.6 62bc1b70c5 chore: bump version to 0.4.2, update changelog
CUDA base image downgraded to 12.8.1 (driver 570 compatibility fix),
benchmark script added.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 22:19:06 +00:00
flanandClaude Sonnet 4.6 67f1845687 Fix ruff lint errors in benchmark.py
Remove unused imports, fix unsorted imports, remove bare f-strings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 22:16:54 +00:00
flanandClaude Sonnet 4.6 39111d3a6a Downgrade GPU base image to CUDA 12.8.1; add benchmark script
CUDA 13.3 requires driver >= 575 but the host only has 570 (error 804).
CUDA 12.8.1 is the highest version supported by driver 570 and works
correctly with the NVIDIA Container Toolkit.

Add scripts/benchmark.py to measure InsightFace + SigLIP latency and
throughput across GPU and CPU modes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 22:14:07 +00:00
flanandClaude Sonnet 4.6 5553877c90 ci: consolidate Docker builds — eliminate duplicate builds on release
release.yml now calls docker-publish.yml via workflow_call instead of
re-running all four image builds independently. docker-publish.yml gains
workflow_call inputs (tag, version) for release context; branch trigger
is narrowed to dev only (main changes only land via tagged releases).

Each release previously built all four variants twice (~90 min) — once on
merge to main, once on tag push. Now it builds once.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 21:00:58 +00:00
github-actions[bot] e4edf202fa chore: update lockfiles 2026-06-13 19:59:26 +00:00
flanandClaude Sonnet 4.6 405413490b fix: RESET_PERSON now deletes managed Frigate files before clearing tracker
Previously reset_person wiped the local tracker but left existing Frigate
training files as orphans, causing the next run to upload a full new batch
on top of them. Now deletes all winnow-managed files from Frigate first so
the next run starts truly clean. Manually-added Frigate files are never
touched.

Also fixes a spurious warning when FRIGATE_URL is unset: the deletion step
is now skipped at info level rather than logging a misleading error. Moves
the deferred import to top-level and eliminates a double disk read.

Bumps to 0.4.1. Also fixes ruff lint violations in executor.py (import
sort, line length) and promotes the "winnow only touches files it uploaded"
callout to the README intro.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 19:58:08 +00:00
flanandClaude Sonnet 4.6 e67f2d9638 refactor: cleanup audit findings — dedup helpers, prune orphan scores, cache has_frigate_scores
- upload_tracker: extract _pick_mapped_file() private helper; get_lowest_quality_mapped_file
  and get_most_redundant_mapped_file are now one-liners over the same body
- upload_tracker: remove_frigate_file now also prunes the corresponding frigate_scores entry,
  preventing unbounded accumulation of orphaned score entries across replacement cycles
- frigate_api: get_frigate_face_counts delegates to get_all_frigate_person_files, eliminating
  the duplicated "name != 'train' and isinstance(files, list)" filter body
- executor: cache has_frigate_scores(name) as person_has_fscores before the per-file loop;
  refresh it after each remove_frigate_file call and after each scored upload, eliminating
  two redundant disk reads per at-cap file iteration
- executor: casefold() both sides of the recognize_face person-name comparison so a Frigate
  casing normalization or manual-registration casing mismatch does not silently suppress scoring

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 18:55:18 +00:00
github-actions[bot] bbbac18207 chore: update lockfiles 2026-06-13 18:35:54 +00:00
18 changed files with 769 additions and 309 deletions
+122 -39
View File
@@ -2,7 +2,7 @@ name: Publish Docker Image
on: on:
push: push:
branches: ["main", "dev"] branches: ["dev"]
paths-ignore: paths-ignore:
- "**.md" - "**.md"
- "docs/**" - "docs/**"
@@ -15,6 +15,16 @@ on:
- "uv-cpu.lock" - "uv-cpu.lock"
- "uv-rocm.lock" - "uv-rocm.lock"
- "uv-intel.lock" - "uv-intel.lock"
workflow_call:
inputs:
tag:
type: string
required: false
description: "Release tag, e.g. v0.4.1 — triggers :latest + versioned image tags"
version:
type: string
required: false
description: "Version string without v prefix, e.g. 0.4.1"
concurrency: concurrency:
group: docker-${{ github.ref }} group: docker-${{ github.ref }}
@@ -26,15 +36,13 @@ env:
jobs: jobs:
build: build:
name: Build (${{ matrix.platform }}) name: Build (linux/amd64)
runs-on: ${{ matrix.runner }} runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
include: include:
- platform: linux/amd64 - platform: linux/amd64
runner: ubuntu-latest runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-latest
permissions: permissions:
contents: read contents: read
packages: write packages: write
@@ -50,10 +58,8 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v6
with:
- name: Set up QEMU ref: ${{ inputs.tag || github.ref }}
if: matrix.platform == 'linux/arm64'
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v4
@@ -65,6 +71,15 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Compute build version
id: version
run: |
if [ -n "${{ inputs.version }}" ]; then
echo "value=${{ inputs.version }}" >> "$GITHUB_OUTPUT"
else
echo "value=dev" >> "$GITHUB_OUTPUT"
fi
- name: Build and push by digest - name: Build and push by digest
id: build id: build
uses: docker/build-push-action@v7 uses: docker/build-push-action@v7
@@ -72,6 +87,7 @@ jobs:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
build-args: VERSION=${{ steps.version.outputs.value }}
cache-from: type=gha,scope=${{ matrix.platform }} cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }} cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -86,7 +102,7 @@ jobs:
- name: Upload digest - name: Upload digest
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: digest-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }} name: digest-amd64
path: /tmp/digests/* path: /tmp/digests/*
if-no-files-found: error if-no-files-found: error
retention-days: 1 retention-days: 1
@@ -120,22 +136,26 @@ jobs:
- name: Determine image tags - name: Determine image tags
id: tags id: tags
run: | run: |
if [ "${{ github.ref_name }}" = "dev" ]; then IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
echo "tags=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev" >> "$GITHUB_OUTPUT" INPUT_TAG="${{ inputs.tag }}"
if [ -n "$INPUT_TAG" ]; then
echo "tag_args=-t ${IMAGE}:latest -t ${IMAGE}:${INPUT_TAG}" >> "$GITHUB_OUTPUT"
echo "inspect_tag=${IMAGE}:latest" >> "$GITHUB_OUTPUT"
else else
echo "tags=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" >> "$GITHUB_OUTPUT" echo "tag_args=-t ${IMAGE}:dev" >> "$GITHUB_OUTPUT"
echo "inspect_tag=${IMAGE}:dev" >> "$GITHUB_OUTPUT"
fi fi
- name: Create and push multi-arch manifest - name: Create and push multi-arch manifest
working-directory: /tmp/digests working-directory: /tmp/digests
run: | run: |
docker buildx imagetools create \ docker buildx imagetools create \
-t ${{ steps.tags.outputs.tags }} \ ${{ steps.tags.outputs.tag_args }} \
$(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *) $(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image - name: Inspect image
run: | run: |
docker buildx imagetools inspect ${{ steps.tags.outputs.tags }} docker buildx imagetools inspect ${{ steps.tags.outputs.inspect_tag }}
- name: Ensure package is public - name: Ensure package is public
run: | run: |
@@ -162,6 +182,8 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v6
with:
ref: ${{ inputs.tag || github.ref }}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v4 uses: docker/setup-qemu-action@v4
@@ -176,13 +198,30 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine CPU image tag - name: Determine CPU image tags
id: cpu-tag id: cpu-tags
run: | run: |
if [ "${{ github.ref_name }}" = "dev" ]; then IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
echo "tag=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev-cpu" >> "$GITHUB_OUTPUT" INPUT_TAG="${{ inputs.tag }}"
if [ -n "$INPUT_TAG" ]; then
{
echo "tags<<EOF"
printf '%s\n' "${IMAGE}:cpu" "${IMAGE}:${INPUT_TAG}-cpu"
echo "EOF"
} >> "$GITHUB_OUTPUT"
echo "inspect_tag=${IMAGE}:cpu" >> "$GITHUB_OUTPUT"
else else
echo "tag=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:cpu" >> "$GITHUB_OUTPUT" echo "tags=${IMAGE}:dev-cpu" >> "$GITHUB_OUTPUT"
echo "inspect_tag=${IMAGE}:dev-cpu" >> "$GITHUB_OUTPUT"
fi
- name: Compute build version
id: version
run: |
if [ -n "${{ inputs.version }}" ]; then
echo "value=${{ inputs.version }}" >> "$GITHUB_OUTPUT"
else
echo "value=dev" >> "$GITHUB_OUTPUT"
fi fi
- name: Build and push CPU image - name: Build and push CPU image
@@ -191,16 +230,18 @@ jobs:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
build-args: VARIANT=cpu build-args: |
VARIANT=cpu
VERSION=${{ steps.version.outputs.value }}
cache-from: type=gha,scope=cpu cache-from: type=gha,scope=cpu
cache-to: type=gha,mode=max,scope=cpu cache-to: type=gha,mode=max,scope=cpu
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
push: true push: true
tags: ${{ steps.cpu-tag.outputs.tag }} tags: ${{ steps.cpu-tags.outputs.tags }}
- name: Inspect CPU image - name: Inspect CPU image
run: | run: |
docker buildx imagetools inspect ${{ steps.cpu-tag.outputs.tag }} docker buildx imagetools inspect ${{ steps.cpu-tags.outputs.inspect_tag }}
- name: Ensure package is public - name: Ensure package is public
run: | run: |
@@ -227,6 +268,8 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v6
with:
ref: ${{ inputs.tag || github.ref }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v4
@@ -238,13 +281,30 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine ROCm image tag - name: Determine ROCm image tags
id: rocm-tag id: rocm-tags
run: | run: |
if [ "${{ github.ref_name }}" = "dev" ]; then IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
echo "tag=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev-rocm" >> "$GITHUB_OUTPUT" INPUT_TAG="${{ inputs.tag }}"
if [ -n "$INPUT_TAG" ]; then
{
echo "tags<<EOF"
printf '%s\n' "${IMAGE}:rocm" "${IMAGE}:${INPUT_TAG}-rocm"
echo "EOF"
} >> "$GITHUB_OUTPUT"
echo "inspect_tag=${IMAGE}:rocm" >> "$GITHUB_OUTPUT"
else else
echo "tag=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:rocm" >> "$GITHUB_OUTPUT" echo "tags=${IMAGE}:dev-rocm" >> "$GITHUB_OUTPUT"
echo "inspect_tag=${IMAGE}:dev-rocm" >> "$GITHUB_OUTPUT"
fi
- name: Compute build version
id: version
run: |
if [ -n "${{ inputs.version }}" ]; then
echo "value=${{ inputs.version }}" >> "$GITHUB_OUTPUT"
else
echo "value=dev" >> "$GITHUB_OUTPUT"
fi fi
- name: Build and push ROCm image - name: Build and push ROCm image
@@ -253,16 +313,18 @@ jobs:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64 platforms: linux/amd64
build-args: VARIANT=rocm build-args: |
VARIANT=rocm
VERSION=${{ steps.version.outputs.value }}
cache-from: type=gha,scope=linux/amd64-rocm cache-from: type=gha,scope=linux/amd64-rocm
cache-to: type=gha,mode=max,scope=linux/amd64-rocm cache-to: type=gha,mode=max,scope=linux/amd64-rocm
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
push: true push: true
tags: ${{ steps.rocm-tag.outputs.tag }} tags: ${{ steps.rocm-tags.outputs.tags }}
- name: Inspect ROCm image - name: Inspect ROCm image
run: | run: |
docker buildx imagetools inspect ${{ steps.rocm-tag.outputs.tag }} docker buildx imagetools inspect ${{ steps.rocm-tags.outputs.inspect_tag }}
- name: Ensure package is public - name: Ensure package is public
run: | run: |
@@ -289,6 +351,8 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v6
with:
ref: ${{ inputs.tag || github.ref }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v4
@@ -300,13 +364,30 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine Intel image tag - name: Determine Intel image tags
id: intel-tag id: intel-tags
run: | run: |
if [ "${{ github.ref_name }}" = "dev" ]; then IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
echo "tag=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev-intel" >> "$GITHUB_OUTPUT" INPUT_TAG="${{ inputs.tag }}"
if [ -n "$INPUT_TAG" ]; then
{
echo "tags<<EOF"
printf '%s\n' "${IMAGE}:intel" "${IMAGE}:${INPUT_TAG}-intel"
echo "EOF"
} >> "$GITHUB_OUTPUT"
echo "inspect_tag=${IMAGE}:intel" >> "$GITHUB_OUTPUT"
else else
echo "tag=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:intel" >> "$GITHUB_OUTPUT" echo "tags=${IMAGE}:dev-intel" >> "$GITHUB_OUTPUT"
echo "inspect_tag=${IMAGE}:dev-intel" >> "$GITHUB_OUTPUT"
fi
- name: Compute build version
id: version
run: |
if [ -n "${{ inputs.version }}" ]; then
echo "value=${{ inputs.version }}" >> "$GITHUB_OUTPUT"
else
echo "value=dev" >> "$GITHUB_OUTPUT"
fi fi
- name: Build and push Intel image - name: Build and push Intel image
@@ -315,16 +396,18 @@ jobs:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64 platforms: linux/amd64
build-args: VARIANT=intel build-args: |
VARIANT=intel
VERSION=${{ steps.version.outputs.value }}
cache-from: type=gha,scope=linux/amd64-intel cache-from: type=gha,scope=linux/amd64-intel
cache-to: type=gha,mode=max,scope=linux/amd64-intel cache-to: type=gha,mode=max,scope=linux/amd64-intel
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
push: true push: true
tags: ${{ steps.intel-tag.outputs.tag }} tags: ${{ steps.intel-tags.outputs.tags }}
- name: Inspect Intel image - name: Inspect Intel image
run: | run: |
docker buildx imagetools inspect ${{ steps.intel-tag.outputs.tag }} docker buildx imagetools inspect ${{ steps.intel-tags.outputs.inspect_tag }}
- name: Ensure package is public - name: Ensure package is public
run: | run: |
+8 -182
View File
@@ -127,188 +127,14 @@ jobs:
prerelease: false, prerelease: false,
}); });
build-gpu: build-images:
name: Build GPU image name: Build and push Docker images
needs: release needs: release
runs-on: ubuntu-latest uses: ./.github/workflows/docker-publish.yml
with:
tag: ${{ needs.release.outputs.tag }}
version: ${{ needs.release.outputs.version }}
secrets: inherit
permissions: permissions:
packages: write packages: write
steps: contents: read
- name: Free up disk space
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc
sudo rm -rf "/usr/local/share/boost"
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
echo "Disk space freed."
- name: Checkout
uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push GPU image (latest)
uses: docker/build-push-action@v7
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
build-args: VERSION=${{ needs.release.outputs.version }}
cache-from: type=gha,scope=release-gpu
cache-to: type=gha,mode=max,scope=release-gpu
tags: |
ghcr.io/sudolulo/winnow:latest
ghcr.io/sudolulo/winnow:${{ needs.release.outputs.tag }}
build-cpu:
name: Build CPU image
needs: release
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- name: Free up disk space
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc
sudo rm -rf "/usr/local/share/boost"
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
echo "Disk space freed."
- name: Checkout
uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push CPU image
uses: docker/build-push-action@v7
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
build-args: |
VARIANT=cpu
VERSION=${{ needs.release.outputs.version }}
cache-from: type=gha,scope=release-cpu
cache-to: type=gha,mode=max,scope=release-cpu
tags: |
ghcr.io/sudolulo/winnow:cpu
ghcr.io/sudolulo/winnow:${{ needs.release.outputs.tag }}-cpu
build-rocm:
name: Build ROCm image
needs: release
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- name: Free up disk space
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc
sudo rm -rf "/usr/local/share/boost"
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
echo "Disk space freed."
- name: Checkout
uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push ROCm image
uses: docker/build-push-action@v7
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
push: true
build-args: |
VARIANT=rocm
VERSION=${{ needs.release.outputs.version }}
cache-from: type=gha,scope=release-rocm
cache-to: type=gha,mode=max,scope=release-rocm
tags: |
ghcr.io/sudolulo/winnow:rocm
ghcr.io/sudolulo/winnow:${{ needs.release.outputs.tag }}-rocm
build-intel:
name: Build Intel image
needs: release
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- name: Free up disk space
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc
sudo rm -rf "/usr/local/share/boost"
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
echo "Disk space freed."
- name: Checkout
uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Intel image
uses: docker/build-push-action@v7
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
push: true
build-args: |
VARIANT=intel
VERSION=${{ needs.release.outputs.version }}
cache-from: type=gha,scope=release-intel
cache-to: type=gha,mode=max,scope=release-intel
tags: |
ghcr.io/sudolulo/winnow:intel
ghcr.io/sudolulo/winnow:${{ needs.release.outputs.tag }}-intel
+54
View File
@@ -7,6 +7,60 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.4.6] - 2026-06-14
### Fixed
- **OOM when Immich returns many pages per person**: `fetch_all_assets` now stops fetching once 5000 assets have been collected — the diversity selection pool is already capped at 3000 items, so fetching up to 1,000,000 was wasteful and could exhaust memory on large libraries. 5000 provides ample headroom for the pool cap while bounding per-person memory to ~2 MB.
- **Non-dict items in Immich asset pages silently skipped**: a malformed or partially-null Immich response page could include `null` or non-object items in the assets array. These are now filtered at fetch time rather than causing `AttributeError` downstream.
## [0.4.5] - 2026-06-14
### Fixed
- **Near-duplicate dedup O(N²) allocation**: `np.vstack(kept_normed)` was rebuilt on every loop iteration even for candidates that would be dropped; the stack is now rebuilt only when a new item is kept, reducing memory pressure significantly for large pools.
- **`quality_score` falsy-zero in dedup sort**: the sort key used `c.get("quality_score") or 0.0`, which treated a legitimate `quality_score=0.0` identically to a missing key. Changed to an explicit `None` check so zero is preserved as-is, and object-mode candidates (which have no `quality_score`) continue to sort stably to the back.
- **Post-dedup pool not re-checked against limit**: after near-duplicate removal the pool could silently shrink below the requested limit with no warning. A second `len < limit` guard now fires after dedup and emits the same "Only N embeddings" warning that the pre-dedup guard does.
- **`mark_rejected` could miss plain-text 400 bodies longer than 100 bytes**: `error_detail = resp.text[:100]` was being searched for the keyword `"face"` to gate `mark_rejected()`, so a response body with `"face"` after byte 100 would never mark the asset rejected and it would be retried on every future run. The `"face"` check now uses the full response body; truncation is kept only for the displayed snippet.
- **`_safe_person_dir` raised ValueError for all person names when `output_dir` resolved to `/`**: `base + os.sep` produced `"//"` when base was `"/"`, and valid paths like `/alice` don't start with `"//"`. Fixed by using `base` directly as the prefix when `base == os.sep`.
## [0.4.4] - 2026-06-14
### Added
- **`RESET_PERSON=*` bulk reset**: resets every tracked person at once (deletes their Frigate training files and clears tracker data). Any other value still resets that specific person by name. If a person is literally named `*` they are reset as part of the bulk operation, and a warning is printed to clarify this.
- **Near-duplicate removal before diversity selection**: a greedy dedup pass now runs after embedding collection and before clustering. Candidates within 0.20 cosine distance of a higher-quality image are dropped, eliminating burst shots and same-event lookalike photos that produce redundant training images. The best-quality frame from each near-identical group is kept. Dropped count is logged per person.
### Fixed
- **HTTP 500 upload errors no longer show Frigate's misleading "Try restarting Frigate" message**: the response body is now logged at debug level only. HTTP 400 detail (e.g. "No face was detected") is still shown since it is actionable.
- **`RuntimeWarning: Mean of empty slice`** when a person has only one image after quality filtering: `_compute_adaptive_threshold` now returns the floor value immediately when there are no pairwise distances to sample, and the k-medoids cluster count is floored at 1 to prevent `k=0`.
- **Path traversal guard on output directory**: person names with `../` sequences or absolute paths (e.g. `/etc`) are now rejected before any filesystem operation, logging an error and skipping the job rather than writing outside the output tree.
## [0.4.3] - 2026-06-14
### Added
- **InsightFace landmark-based face crop alignment**: face crops for Frigate training are now aligned using InsightFace's `norm_crop` (ArcFace 112×112 alignment with 5-point facial landmarks). Previously, Immich's API returned only bounding boxes with no landmarks, so `align_face()` was dead code and crops were plain bbox slices — resulting in misaligned or partial crops (e.g. foreheads). The fix runs InsightFace detection on an expanded region around the Immich bbox, finds the nearest face, and uses its keypoints for proper alignment. Controlled by `ENABLE_FACE_ALIGNMENT` (default `true`).
- **Duplicate Immich person detection and handling**: when multiple Immich person records share the same name, winnow now detects this at startup and warns with a per-group summary. Without handling, two jobs would run for the same Frigate folder and overwrite each other's output. By default (`MERGE_DUPLICATE_PEOPLE=false`) only the first person per name is processed. Set `MERGE_DUPLICATE_PEOPLE=true` to permanently merge duplicate records inside Immich (keeps the person with the most assets).
## [0.4.2] - 2026-06-13
### Changed
- **GPU image now uses CUDA 12.8.1** (was 13.3): CUDA 13.3 requires driver ≥ 575; driver 570 (the current stable release) was incorrectly rejected with "CUDA driver version is insufficient" at startup. The `:latest` image now works with any NVIDIA driver ≥ 570.
### Added
- **`scripts/benchmark.py`**: measures InsightFace and SigLIP inference latency and throughput across GPU and CPU modes. Run inside the container with `python /app/scripts/benchmark.py`. RTX 2070 SUPER results: InsightFace 12.8 ms / 78 img/s (8× CPU), SigLIP batch 32 at 5.4 ms/img / 187 img/s (33× CPU).
## [0.4.1] - 2026-06-13
### Fixed
- **`RESET_PERSON` no longer creates duplicate Frigate files**: previously, resetting a person only wiped the local tracker — existing Frigate training files were left as unmanaged orphans, causing the next run to upload a full new batch on top of them. `reset_person` now deletes all winnow-managed files for that person from Frigate before clearing the tracker. Manually-added Frigate files are unaffected.
- **No spurious warning when `FRIGATE_URL` is unset and `RESET_PERSON` is used**: the deletion step is now skipped silently at info level rather than logging a misleading "could not delete" warning.
## [0.4.0] - 2026-06-13 ## [0.4.0] - 2026-06-13
### Added ### Added
+4
View File
@@ -36,6 +36,10 @@ CI runs both on every push and PR to `main` and `dev`. PRs must pass before merg
- Keep the `CHANGELOG.md` entry in the `[Unreleased]` section updated. - Keep the `CHANGELOG.md` entry in the `[Unreleased]` section updated.
- Commit messages should be plain English describing what changed and why. - Commit messages should be plain English describing what changed and why.
## Development Tooling
Development uses Claude Code (Anthropic) for implementation assistance. All code is reviewed and the final call on design, behavior, and what ships is made by the maintainer. Contributions from humans are equally welcome.
## License ## License
By submitting a contribution you agree that your work will be released under the project's [AGPLv3+ license](LICENSE). By submitting a contribution you agree that your work will be released under the project's [AGPLv3+ license](LICENSE).
+2 -2
View File
@@ -1,5 +1,5 @@
# ── Base images ─────────────────────────────────────────────────────────────── # ── Base images ───────────────────────────────────────────────────────────────
# amd64 + gpu: NVIDIA CUDA 13.3 + cuDNN (GPU acceleration via NVIDIA Container Toolkit) # amd64 + gpu: NVIDIA CUDA 12.8 + cuDNN (GPU acceleration via NVIDIA Container Toolkit)
# amd64 + rocm: Ubuntu 22.04 (AMD GPU via ROCm — pass /dev/kfd and /dev/dri) # amd64 + rocm: Ubuntu 22.04 (AMD GPU via ROCm — pass /dev/kfd and /dev/dri)
# amd64 + intel: Ubuntu 22.04 (Intel Arc / iGPU via OpenVINO — pass /dev/dri) # amd64 + intel: Ubuntu 22.04 (Intel Arc / iGPU via OpenVINO — pass /dev/dri)
# amd64 + cpu: Ubuntu 22.04 (CPU-only, ~2 GB smaller image) # amd64 + cpu: Ubuntu 22.04 (CPU-only, ~2 GB smaller image)
@@ -7,7 +7,7 @@
ARG VARIANT=gpu ARG VARIANT=gpu
FROM --platform=$BUILDPLATFORM nvidia/cuda:13.3.0-cudnn-runtime-ubuntu22.04 AS base-amd64-gpu FROM --platform=$BUILDPLATFORM nvidia/cuda:12.8.1-cudnn-runtime-ubuntu22.04 AS base-amd64-gpu
FROM ubuntu:22.04 AS base-amd64-rocm FROM ubuntu:22.04 AS base-amd64-rocm
FROM ubuntu:22.04 AS base-amd64-intel FROM ubuntu:22.04 AS base-amd64-intel
FROM ubuntu:22.04 AS base-amd64-cpu FROM ubuntu:22.04 AS base-amd64-cpu
+5 -1
View File
@@ -2,6 +2,8 @@
[![Docker](https://github.com/sudolulo/winnow/actions/workflows/docker-publish.yml/badge.svg)](https://github.com/sudolulo/winnow/actions/workflows/docker-publish.yml) [![Test](https://github.com/sudolulo/winnow/actions/workflows/test.yml/badge.svg)](https://github.com/sudolulo/winnow/actions/workflows/test.yml) [![GitHub release](https://img.shields.io/github/v/release/sudolulo/winnow)](https://github.com/sudolulo/winnow/releases/latest) [![License: AGPL v3](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](LICENSE) [![Immich](https://img.shields.io/badge/Immich-v1.106%2B-blueviolet)](https://immich.app) [![Frigate](https://img.shields.io/badge/Frigate-Ready-brightgreen)](https://frigate.video) [![Docker](https://github.com/sudolulo/winnow/actions/workflows/docker-publish.yml/badge.svg)](https://github.com/sudolulo/winnow/actions/workflows/docker-publish.yml) [![Test](https://github.com/sudolulo/winnow/actions/workflows/test.yml/badge.svg)](https://github.com/sudolulo/winnow/actions/workflows/test.yml) [![GitHub release](https://img.shields.io/github/v/release/sudolulo/winnow)](https://github.com/sudolulo/winnow/releases/latest) [![License: AGPL v3](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](LICENSE) [![Immich](https://img.shields.io/badge/Immich-v1.106%2B-blueviolet)](https://immich.app) [![Frigate](https://img.shields.io/badge/Frigate-Ready-brightgreen)](https://frigate.video)
> **Note:** winnow's approach to training Frigate face recognition is not an officially documented workflow — results may vary.
> **Early Development — Use With Caution** > **Early Development — Use With Caution**
> winnow is functional but still maturing. Features that modify your Frigate training data — quality replacement, stale mapping cleanup — can remove images from your dataset and are not yet battle-tested at scale. Review the logs after each run and keep backups of your Frigate face training directory until you are confident in the results. > winnow is functional but still maturing. Features that modify your Frigate training data — quality replacement, stale mapping cleanup — can remove images from your dataset and are not yet battle-tested at scale. Review the logs after each run and keep backups of your Frigate face training directory until you are confident in the results.
@@ -11,6 +13,8 @@
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. 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.
> **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.
--- ---
## How It Works ## How It Works
@@ -222,7 +226,7 @@ In scheduled mode the process (and loaded models) stays resident between runs. T
| :--- | :--- | :--- | | :--- | :--- | :--- |
| `DRY_RUN` | `false` | Preview selection without downloading or uploading | | `DRY_RUN` | `false` | Preview selection without downloading or uploading |
| `RETRY_REJECTED` | `false` | Re-attempt assets previously rejected by Frigate | | `RETRY_REJECTED` | `false` | Re-attempt assets previously rejected by Frigate |
| `RESET_PERSON` | *(unset)* | Clear upload and rejection history for one person by name | | `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 |
### Scheduling ### Scheduling
+1
View File
@@ -26,6 +26,7 @@ services:
# - SKIP_PEOPLE=Unknown # Comma-separated; skip these people # - SKIP_PEOPLE=Unknown # Comma-separated; skip these people
# - MIN_FACE_COUNT=5 # Skip people with fewer than N assets in Immich # - MIN_FACE_COUNT=5 # Skip people with fewer than N assets in Immich
# - YEARS_FILTER=10 # Only include images from the last N years (default: 10) # - YEARS_FILTER=10 # Only include images from the last N years (default: 10)
# - MERGE_DUPLICATE_PEOPLE=true # Auto-merge Immich people with the same name (keeps most assets)
# ── Image Quality ───────────────────────────────────────────────────── # ── Image Quality ─────────────────────────────────────────────────────
# - MIN_FACE_WIDTH=50 # Minimum face width in pixels (default: 50) # - MIN_FACE_WIDTH=50 # Minimum face width in pixels (default: 50)
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "winnow" name = "winnow"
version = "0.4.0" version = "0.4.6"
description = "Selects diverse, high-quality photos from Immich as training data for Frigate face recognition and object classification." description = "Selects diverse, high-quality photos from Immich as training data for Frigate face recognition and object classification."
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
requires-python = ">=3.13" requires-python = ">=3.13"
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""
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.
Usage inside container:
# GPU mode:
docker exec winnow python /app/scripts/benchmark.py
# CPU mode:
docker exec -e FORCE_CPU=true winnow python /app/scripts/benchmark.py
"""
import os
import sys
import time
import numpy as np
from PIL import Image, ImageDraw
def _mode_label() -> str:
if os.getenv("FORCE_CPU", "").lower() in ("true", "1", "yes"):
return "CPU (FORCE_CPU=true)"
return "GPU (auto)"
def make_face_image(size: int = 640) -> Image.Image:
"""Synthetic face-like image: skin-tone rectangle with landmark blobs."""
img = Image.new("RGB", (size, size), (200, 170, 140))
draw = ImageDraw.Draw(img)
# Head oval
cx, cy = size // 2, size // 2
hw, hh = int(size * 0.3), int(size * 0.38)
draw.ellipse([cx - hw, cy - hh, cx + hw, cy + hh], fill=(220, 185, 155))
# Eyes
for ex in [cx - int(size * 0.1), cx + int(size * 0.1)]:
ey = cy - int(size * 0.05)
r = max(4, size // 40)
draw.ellipse([ex - r, ey - r, ex + r, ey + r], fill=(40, 30, 20))
# Nose
draw.ellipse([cx - 5, cy + 5, cx + 5, cy + 15], fill=(180, 140, 110))
# Mouth
draw.arc([cx - 20, cy + 25, cx + 20, cy + 45], start=0, end=180, fill=(160, 80, 80), width=3)
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 {
"median_ms": float(np.median(arr)),
"mean_ms": float(np.mean(arr)),
"min_ms": float(np.min(arr)),
"p95_ms": float(np.percentile(arr, 95)),
"ips": 1000.0 / float(np.median(arr)),
}
def bench_insightface(n_warmup: int = 5, n_runs: int = 30) -> None:
import cv2
import winnow.embeddings as emb_mod
from winnow.embeddings import get_insightface_app
# Reset singleton so we get a fresh load
emb_mod._insightface_app = None
emb_mod._insightface_loaded = False
print(" Loading model...")
t_load = time.perf_counter()
app = get_insightface_app()
load_s = time.perf_counter() - t_load
if app is None:
print(" SKIP: InsightFace failed to load")
return
img_pil = make_face_image(640)
img_bgr = cv2.cvtColor(np.asarray(img_pil), cv2.COLOR_RGB2BGR)
# Warmup
for _ in range(n_warmup):
app.get(img_bgr)
# Timed — single image 640×640
times: list[float] = []
for _ in range(n_runs):
t0 = time.perf_counter()
app.get(img_bgr)
times.append(time.perf_counter() - t0)
s = _stats(times)
print(f" Model load time : {load_s:.2f} s")
print(" Input size : 640×640")
print(f" Runs : {n_runs} (after {n_warmup} warmup)")
print(f" Median latency : {s['median_ms']:.1f} ms")
print(f" Mean / p95 : {s['mean_ms']:.1f} ms / {s['p95_ms']:.1f} ms")
print(f" Min latency : {s['min_ms']:.1f} ms")
print(f" Throughput : {s['ips']:.1f} images/s")
# Also test at 320×320
img_sm = make_face_image(320)
img_sm_bgr = cv2.cvtColor(np.asarray(img_sm), cv2.COLOR_RGB2BGR)
for _ in range(n_warmup):
app.get(img_sm_bgr)
times_sm: list[float] = []
for _ in range(n_runs):
t0 = time.perf_counter()
app.get(img_sm_bgr)
times_sm.append(time.perf_counter() - t0)
s2 = _stats(times_sm)
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")
print(f" Mode: {_mode_label()}")
print("=" * 56)
print()
print("── InsightFace Buffalo_L (face detection + ArcFace) ──")
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
sys.path.insert(0, "/app")
main()
Generated
+1 -1
View File
@@ -2348,7 +2348,7 @@ wheels = [
[[package]] [[package]]
name = "winnow" name = "winnow"
version = "0.3.3" version = "0.4.4"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "croniter" }, { name = "croniter" },
+101 -4
View File
@@ -9,7 +9,7 @@ from rich.prompt import Confirm
from .config import Config, ConfigManager from .config import Config, ConfigManager
from .executor import execute_jobs, upload_to_frigate from .executor import execute_jobs, upload_to_frigate
from .immich_api import get_people from .immich_api import get_people, merge_people
from .jobs import _show_preview, auto_configure, interactive_configure from .jobs import _show_preview, auto_configure, interactive_configure
from .log_config import console, setup_logging from .log_config import console, setup_logging
from .upload_tracker import find_by_crop_dimension, get_person_summary, reset_person from .upload_tracker import find_by_crop_dimension, get_person_summary, reset_person
@@ -51,6 +51,85 @@ def _handle_trace_crop(size_str: str) -> None:
sys.exit(0) sys.exit(0)
def _handle_duplicate_people(people: list[dict]) -> list[dict]:
"""Warn about or merge Immich people that share the same name.
Duplicates arise when Immich creates separate person records for the same
individual (e.g. unmerged face clusters). Without handling, winnow would
run multiple jobs for the same Frigate folder and overwrite its own output,
leaving far fewer training images than expected.
With MERGE_DUPLICATE_PEOPLE=false (default): prints a warning, skips the
smaller duplicates so only the person with the most assets is processed,
and returns a deduplicated people list.
With MERGE_DUPLICATE_PEOPLE=true: merges each duplicate group inside
Immich via its API (permanently combines the face records), then
re-fetches the people list so the rest of the run sees the merged state.
"""
from collections import defaultdict
by_name: dict[str, list[dict]] = defaultdict(list)
for p in people:
name = (p.get("name") or "").strip()
if name:
by_name[name].append(p)
duplicates = {name: ps for name, ps in by_name.items() if len(ps) > 1}
if not duplicates:
return people
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)"
for p in ordered
)
rprint(f" [yellow]{name}[/yellow] → {len(ps)} people: {entries}")
skipped = ordered[1:]
rprint(
f" [dim] Processing largest only "
f"({ordered[0].get('assetCount', 0)} assets). "
f"Skipping {len(skipped)} smaller duplicate(s) to avoid overwriting output.[/dim]"
)
rprint(
" [dim]Set MERGE_DUPLICATE_PEOPLE=true to permanently merge duplicates "
"inside Immich (keeps the person with the most assets).[/dim]\n"
)
# Return deduplicated list — keep only the largest per name so that
# downstream job creation never runs two jobs for the same Frigate folder.
skip_ids = {
p["id"]
for ps in duplicates.values()
for p in sorted(ps, key=lambda x: x.get("assetCount", 0), reverse=True)[1:]
}
return [p for p in people if p["id"] not in skip_ids]
# 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:]]
rprint(
f" [cyan]Merging {name!r} inside Immich:[/cyan] keeping "
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):
rprint(f" [green]✓ Merged {name!r}[/green]")
merged_any = True
else:
rprint(f" [red]✗ Failed to merge {name!r}[/red]")
if merged_any:
rprint(" [dim]Re-fetching people after merge...[/dim]")
return get_people()
return people
def main() -> None: def main() -> None:
"""Entry point for winnow CLI.""" """Entry point for winnow CLI."""
try: try:
@@ -77,11 +156,27 @@ def main() -> None:
rprint(f"Server: [dim]{Config.IMMICH_URL}[/dim]") rprint(f"Server: [dim]{Config.IMMICH_URL}[/dim]")
rprint(f"Output: [dim]{Config.OUTPUT_DIR}[/dim]") rprint(f"Output: [dim]{Config.OUTPUT_DIR}[/dim]")
# Handle RESET_PERSON before anything else # Handle RESET_PERSON before anything else.
# RESET_PERSON=* resets every tracked person; any other value resets
# that specific person by name.
reset_person_name = os.environ.get("RESET_PERSON", "").strip() reset_person_name = os.environ.get("RESET_PERSON", "").strip()
if reset_person_name: if reset_person_name:
reset_person(reset_person_name) if reset_person_name == "*":
rprint(f"[bold yellow]Reset tracking data for: {reset_person_name}[/bold yellow]") names = list(get_person_summary().keys())
if "*" in names:
rprint(
"[yellow]Note: a person literally named '*' exists in the tracker "
"and will be reset along with everyone else.[/yellow]"
)
if names:
for name in names:
reset_person(name)
rprint(f"[bold yellow]Reset tracking data for all {len(names)} people.[/bold yellow]")
else:
rprint("[dim]No tracking data to reset.[/dim]")
else:
reset_person(reset_person_name)
rprint(f"[bold yellow]Reset tracking data for: {reset_person_name}[/bold yellow]")
# Show per-person tracker summary if data exists # Show per-person tracker summary if data exists
summary = get_person_summary() summary = get_person_summary()
@@ -103,6 +198,8 @@ def main() -> None:
rprint("[bold red]Could not fetch people from Immich. Check URL/Key.[/bold red]") rprint("[bold red]Could not fetch people from Immich. Check URL/Key.[/bold red]")
return return
people = _handle_duplicate_people(people)
# Auto mode when no TTY (Docker, cron, pipes) — the primary use case. # Auto mode when no TTY (Docker, cron, pipes) — the primary use case.
# A TTY means local interactive use; AUTO_MODE=true overrides that for scripting. # A TTY means local interactive use; AUTO_MODE=true overrides that for scripting.
auto_mode = not sys.stdin.isatty() or os.environ.get("AUTO_MODE", "").lower() in ("true", "1", "yes") auto_mode = not sys.stdin.isatty() or os.environ.get("AUTO_MODE", "").lower() in ("true", "1", "yes")
+2
View File
@@ -36,6 +36,7 @@ class _Config:
# People filtering # People filtering
MIN_FACE_COUNT: int = 0 MIN_FACE_COUNT: int = 0
MERGE_DUPLICATE_PEOPLE: bool = False
# Output quality # Output quality
FACE_MARGIN: float = 0.15 FACE_MARGIN: float = 0.15
@@ -60,6 +61,7 @@ class _Config:
self.YEARS_FILTER = int(os.getenv("YEARS_FILTER", "10")) self.YEARS_FILTER = int(os.getenv("YEARS_FILTER", "10"))
self.MIN_FACE_WIDTH = int(os.getenv("MIN_FACE_WIDTH", "90")) 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", "0"))
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.BLUR_THRESHOLD = float(os.getenv("BLUR_THRESHOLD", "120.0"))
self.MIN_CONFIDENCE = float(os.getenv("MIN_CONFIDENCE", "0.7")) 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", "80"))
+72 -2
View File
@@ -281,7 +281,21 @@ def _select_by_embedding(
logger.warning(f"Only {len(valid_candidates)} valid embeddings. Returning all.") logger.warning(f"Only {len(valid_candidates)} valid embeddings. Returning all.")
return valid_candidates return valid_candidates
# --- Phase 5: Cluster-aware selection --- # --- Phase 5: Near-duplicate removal ---
# Burst shots and repeated near-identical photos produce embeddings that are
# close but not identical, so FPS doesn't filter them out on its own.
# Greedily drop any candidate within DEDUP_THRESHOLD cosine distance of a
# higher-quality image already in the kept set.
embeddings, valid_candidates, confidence_scores = _dedup_embeddings(
embeddings, valid_candidates, confidence_scores
)
# Re-check after dedup: pool may have shrunk below limit
if limit != "auto" and len(valid_candidates) < limit:
logger.warning(f"Only {len(valid_candidates)} embeddings after near-duplicate removal. Returning all.")
return valid_candidates
# --- Phase 6: Cluster-aware selection ---
return _cluster_aware_selection( return _cluster_aware_selection(
embeddings, embeddings,
valid_candidates, valid_candidates,
@@ -291,6 +305,60 @@ def _select_by_embedding(
) )
# =============================================================================
# Near-Duplicate Removal
# =============================================================================
_DEDUP_THRESHOLD = 0.20 # cosine distance — burst shots ~0.01-0.05, same-event similar shots ~0.10-0.20
def _dedup_embeddings(
embeddings: list,
candidates: list,
confidence_scores: list,
) -> tuple[list, list, list]:
"""Greedy near-duplicate removal before clustering.
Sorts by quality score descending (best first), then for each candidate
drops it if any already-kept embedding is within _DEDUP_THRESHOLD cosine
distance. This eliminates burst-shot near-duplicates while preserving the
highest-quality representative from each near-identical group.
"""
if len(embeddings) < 2:
return embeddings, candidates, confidence_scores
emb_matrix = np.vstack(embeddings)
norms = np.linalg.norm(emb_matrix, axis=1, keepdims=True)
emb_normed = emb_matrix / np.maximum(norms, 1e-8)
# Sort by quality descending so the best image in each near-duplicate group wins.
# Use explicit None check so a legitimate quality_score=0.0 isn't treated as missing.
quality_scores = [qs if (qs := c.get("quality_score")) is not None else 0.0 for c in candidates]
order = sorted(range(len(candidates)), key=lambda i: quality_scores[i], reverse=True)
kept_indices = []
kept_stack: np.ndarray | None = None # rebuilt only when a new item is kept (not every iteration)
for i in order:
if kept_stack is not None:
sims = emb_normed[i] @ kept_stack.T
if np.any(sims > 1 - _DEDUP_THRESHOLD):
continue
kept_indices.append(i)
row = emb_normed[i : i + 1]
kept_stack = row if kept_stack is None else np.vstack([kept_stack, row])
dropped = len(embeddings) - len(kept_indices)
if dropped:
logger.info(f"Near-duplicate removal dropped {dropped} images (threshold {_DEDUP_THRESHOLD}).")
return (
[embeddings[i] for i in kept_indices],
[candidates[i] for i in kept_indices],
[confidence_scores[i] for i in kept_indices],
)
# ============================================================================= # =============================================================================
# K-Medoids (Lightweight Implementation) # K-Medoids (Lightweight Implementation)
# ============================================================================= # =============================================================================
@@ -373,6 +441,8 @@ def _compute_adaptive_threshold(emb_normed: np.ndarray, entity_type: str) -> flo
# Compute pairwise cosine distances for the sample # Compute pairwise cosine distances for the sample
pairwise = 1 - sample @ sample.T pairwise = 1 - sample @ sample.T
upper_tri = pairwise[np.triu_indices(len(sample), k=1)] 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)) median_dist = float(np.median(upper_tri))
# Faces: 20% of median (tighter — want fewer, more distinct images) # Faces: 20% of median (tighter — want fewer, more distinct images)
@@ -421,7 +491,7 @@ def _cluster_aware_selection(
target = Config.MAX_AUTO_IMAGES if limit == "auto" else limit target = Config.MAX_AUTO_IMAGES if limit == "auto" else limit
# --- Stage 1: K-Medoids clustering --- # --- Stage 1: K-Medoids clustering ---
k = min(max(5, target // 4), n // 3, n) # e.g., 5-20 clusters k = min(max(5, target // 4), max(1, n // 3), n) # e.g., 1-20 clusters
logger.debug(f"Clustering {n} embeddings into {k} groups (K-Medoids)...") logger.debug(f"Clustering {n} embeddings into {k} groups (K-Medoids)...")
# Compute full cosine distance matrix # Compute full cosine distance matrix
+69 -13
View File
@@ -13,7 +13,12 @@ from rich import print as rprint
from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn
from .config import Config, get_headers from .config import Config, get_headers
from .frigate_api import delete_frigate_person_files, get_all_frigate_person_files, get_frigate_person_files, recognize_face from .frigate_api import (
delete_frigate_person_files,
get_all_frigate_person_files,
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, process_full_mode, process_object_mode
from .immich_api import fetch_face_data, fetch_full_image from .immich_api import fetch_face_data, fetch_full_image
from .log_config import console from .log_config import console
@@ -33,6 +38,22 @@ from .upload_tracker import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _safe_person_dir(output_dir: str, person_name: str) -> str:
"""Return the output subdirectory for a person, raising ValueError on path traversal.
os.path.join silently discards output_dir when person_name is absolute,
and '../..' sequences resolve outside the tree. Both are rejected here.
"""
candidate = os.path.realpath(os.path.join(output_dir, person_name))
base = os.path.realpath(output_dir)
# Use the base path as its own prefix when it's the filesystem root ("/"),
# otherwise append os.sep — avoids the false "//" double-slash when base == "/".
base_prefix = base if base == os.sep else base + os.sep
if not candidate.startswith(base_prefix) and candidate != base:
raise ValueError(f"Person name {person_name!r} escapes output directory — skipping")
return candidate
def _reconcile_frigate_mappings( def _reconcile_frigate_mappings(
person_name: str, person_name: str,
known_files_before: set[str], known_files_before: set[str],
@@ -150,6 +171,18 @@ def execute_jobs(jobs: list[dict]) -> None:
use_full_res = Config.USE_FULL_RESOLUTION use_full_res = Config.USE_FULL_RESOLUTION
# Load InsightFace app for landmark-based crop alignment (face mode only).
# 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:
try:
from .embeddings import get_insightface_app
insightface_app = get_insightface_app()
except Exception as e:
logger.debug(f"InsightFace unavailable for crop alignment: {e}")
with Progress( with Progress(
SpinnerColumn(), SpinnerColumn(),
TextColumn("[progress.description]{task.description}"), TextColumn("[progress.description]{task.description}"),
@@ -165,7 +198,11 @@ def execute_jobs(jobs: list[dict]) -> None:
name, mode = person["name"], config.get("mode", "face") name, mode = person["name"], config.get("mode", "face")
job_task = progress.add_task(f"Processing {name}...", total=len(assets)) job_task = progress.add_task(f"Processing {name}...", total=len(assets))
person_dir = os.path.join(Config.OUTPUT_DIR, name) try:
person_dir = _safe_person_dir(Config.OUTPUT_DIR, name)
except ValueError as e:
logger.error(str(e))
continue
# Face crops are transient (uploaded then discarded); wipe before each run. # Face crops are transient (uploaded then discarded); wipe before each run.
# Object crops are the deliverable; preserve them across runs. # Object crops are the deliverable; preserve them across runs.
if mode == "face" and os.path.isdir(person_dir): if mode == "face" and os.path.isdir(person_dir):
@@ -211,7 +248,7 @@ def execute_jobs(jobs: list[dict]) -> None:
progress.console.print(f"[red]Failed download {asset['id']}[/red]") progress.console.print(f"[red]Failed download {asset['id']}[/red]")
else: else:
saved = ( saved = (
process_face_mode(img, asset, person, person_dir, count) process_face_mode(img, asset, person, person_dir, count, insightface_app=insightface_app)
if mode == "face" if mode == "face"
else process_object_mode(img, config, person_dir, count) else process_object_mode(img, config, person_dir, count)
if mode == "object" if mode == "object"
@@ -288,7 +325,11 @@ def upload_to_frigate(jobs: list[dict]) -> None:
object_jobs = [j for j in jobs if j["config"].get("mode") == "object"] object_jobs = [j for j in jobs if j["config"].get("mode") == "object"]
for job in object_jobs: for job in object_jobs:
name = job["person"]["name"] name = job["person"]["name"]
person_dir = os.path.join(Config.OUTPUT_DIR, 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]") rprint(f" [dim]📁 {name} (object): crops saved to {person_dir} — copy to Frigate manually[/dim]")
frigate_url = os.environ.get("FRIGATE_URL", "") frigate_url = os.environ.get("FRIGATE_URL", "")
@@ -338,7 +379,11 @@ def upload_to_frigate(jobs: list[dict]) -> None:
if " " in name: if " " in name:
progress.console.print(f" ℹ️ URL-encoded name for Frigate API: '{name}' → '{encoded_name}'") progress.console.print(f" ℹ️ URL-encoded name for Frigate API: '{name}' → '{encoded_name}'")
person_dir = os.path.join(Config.OUTPUT_DIR, name) try:
person_dir = _safe_person_dir(Config.OUTPUT_DIR, name)
except ValueError as e:
logger.error(str(e))
continue
if not os.path.isdir(person_dir): if not os.path.isdir(person_dir):
progress.console.print(f" [dim]⏭️ {name}: no output directory, skipping[/dim]") progress.console.print(f" [dim]⏭️ {name}: no output directory, skipping[/dim]")
continue continue
@@ -359,6 +404,8 @@ def upload_to_frigate(jobs: list[dict]) -> None:
# Snapshot live Frigate files for post-upload reconciliation diff only. # Snapshot live Frigate files for post-upload reconciliation diff only.
# effective_count is sourced from the tracker (mapped files) so that # effective_count is sourced from the tracker (mapped files) so that
# manually-added Frigate files don't consume winnow's managed quota. # manually-added Frigate files don't consume winnow's managed quota.
# Replacement targets also come exclusively from the tracker, so manually
# added files are never selected for deletion — only winnow-uploaded ones.
_snapshot = ( _snapshot = (
all_frigate_files.get(name, []) if all_frigate_files is not None all_frigate_files.get(name, []) if all_frigate_files is not None
else get_frigate_person_files(name) else get_frigate_person_files(name)
@@ -395,6 +442,7 @@ def upload_to_frigate(jobs: list[dict]) -> None:
actually_uploaded: list[tuple[str, str | None]] = [] actually_uploaded: list[tuple[str, str | None]] = []
failed_deletes: set[str] = set() failed_deletes: set[str] = set()
min_quality_score_for_slot: float | None = None min_quality_score_for_slot: float | None = None
person_has_fscores: bool = has_frigate_scores(name)
for fname in person_files: for fname in person_files:
fpath = os.path.join(person_dir, fname) fpath = os.path.join(person_dir, fname)
@@ -427,9 +475,9 @@ def upload_to_frigate(jobs: list[dict]) -> None:
# handles this conservatively by skipping that candidate until the next run. # handles this conservatively by skipping that candidate until the next run.
pre_fscore: float | None = None pre_fscore: float | None = None
if Config.ENABLE_FRIGATE_SCORES and pre_run_count > 0: if Config.ENABLE_FRIGATE_SCORES and pre_run_count > 0:
if not at_cap or has_frigate_scores(name): if not at_cap or person_has_fscores:
_result = recognize_face(fpath) _result = recognize_face(fpath)
if _result is not None and _result[0] == name: if _result is not None and (_result[0] or "").casefold() == name.casefold():
pre_fscore = _result[1] pre_fscore = _result[1]
# Ceiling check: skip if the existing training set already covers this # Ceiling check: skip if the existing training set already covers this
@@ -450,7 +498,7 @@ def upload_to_frigate(jobs: list[dict]) -> None:
progress.advance(upload_task) progress.advance(upload_task)
continue continue
using_fscore = has_frigate_scores(name) and Config.ENABLE_FRIGATE_SCORES using_fscore = person_has_fscores and Config.ENABLE_FRIGATE_SCORES
if using_fscore: if using_fscore:
candidate_score = pre_fscore candidate_score = pre_fscore
if candidate_score is None: if candidate_score is None:
@@ -476,8 +524,10 @@ def upload_to_frigate(jobs: list[dict]) -> None:
) )
if delete_frigate_person_files(name, [target_frigate_file]): if delete_frigate_person_files(name, [target_frigate_file]):
remove_frigate_file(name, target_frigate_file) remove_frigate_file(name, target_frigate_file)
person_has_fscores = has_frigate_scores(name)
effective_count -= 1 effective_count -= 1
min_quality_score_for_slot = None # clear any blur-mode slot floor — Frigate uses a different score metric # clear any blur-mode slot floor — Frigate uses a different score metric
min_quality_score_for_slot = None
else: else:
logger.warning(f"Failed to delete {target_frigate_file} for {name}, skipping replacement") logger.warning(f"Failed to delete {target_frigate_file} for {name}, skipping replacement")
failed_deletes.add(target_frigate_file) failed_deletes.add(target_frigate_file)
@@ -507,6 +557,7 @@ def upload_to_frigate(jobs: list[dict]) -> None:
) )
if delete_frigate_person_files(name, [target_frigate_file]): if delete_frigate_person_files(name, [target_frigate_file]):
remove_frigate_file(name, target_frigate_file) remove_frigate_file(name, target_frigate_file)
person_has_fscores = has_frigate_scores(name)
effective_count -= 1 effective_count -= 1
min_quality_score_for_slot = score_map.get(fname) min_quality_score_for_slot = score_map.get(fname)
else: else:
@@ -538,6 +589,8 @@ def upload_to_frigate(jobs: list[dict]) -> None:
crop_dims=dims_map.get(fname), crop_dims=dims_map.get(fname),
frigate_score=pre_fscore, frigate_score=pre_fscore,
) )
if pre_fscore is not None:
person_has_fscores = True
actually_uploaded.append((fname, asset_id)) actually_uploaded.append((fname, asset_id))
break break
@@ -553,13 +606,16 @@ def upload_to_frigate(jobs: list[dict]) -> None:
progress.console.print( progress.console.print(
f" [red]✗ {fname}: HTTP {resp.status_code} (after {max_retries} attempts)[/red]" f" [red]✗ {fname}: HTTP {resp.status_code} (after {max_retries} attempts)[/red]"
) )
full_body = resp.text
try: try:
error_detail = resp.json().get("message", resp.text[:100]) error_detail = resp.json().get("message", full_body[:100])
progress.console.print(f" [dim]{error_detail}[/dim]")
except Exception: except Exception:
error_detail = resp.text[:100] error_detail = full_body[:100]
if resp.status_code == 400:
progress.console.print(f" [dim]{error_detail}[/dim]") progress.console.print(f" [dim]{error_detail}[/dim]")
if resp.status_code == 400 and "face" in error_detail.lower(): else:
logger.debug(f"{fname} HTTP {resp.status_code}: {error_detail}")
if resp.status_code == 400 and "face" in full_body.lower():
asset_id = asset_map.get(fname) asset_id = asset_map.get(fname)
if asset_id: if asset_id:
mark_rejected(asset_id, person_name=name) mark_rejected(asset_id, person_name=name)
+14 -18
View File
@@ -22,24 +22,6 @@ def _get_faces_data() -> dict | None:
return None return None
def get_frigate_face_counts() -> dict[str, int] | None:
"""Return {person_name: training_image_count} from Frigate's train directory.
Returns None if FRIGATE_URL is not set or the API is unreachable, so callers
can distinguish "API unavailable" from "person has 0 images."
"""
data = _get_faces_data()
if data is None:
return None
# Response: {person_name: [file, ...], "train": [...], ...}
# "train" is a flat pending list, not a person — skip it.
return {
name: len(files)
for name, files in data.items()
if name != "train" and isinstance(files, list)
}
def get_all_frigate_person_files() -> dict[str, list[str]] | None: def get_all_frigate_person_files() -> dict[str, list[str]] | None:
"""Return {person_name: [filename, ...]} for every person in Frigate. """Return {person_name: [filename, ...]} for every person in Frigate.
@@ -49,6 +31,8 @@ def get_all_frigate_person_files() -> dict[str, list[str]] | None:
data = _get_faces_data() data = _get_faces_data()
if data is None: if data is None:
return None return None
# Response: {person_name: [file, ...], "train": [...], ...}
# "train" is a flat pending list, not a person — skip it.
return { return {
name: files name: files
for name, files in data.items() for name, files in data.items()
@@ -56,6 +40,18 @@ def get_all_frigate_person_files() -> dict[str, list[str]] | None:
} }
def get_frigate_face_counts() -> dict[str, int] | None:
"""Return {person_name: training_image_count} from Frigate's train directory.
Returns None if FRIGATE_URL is not set or the API is unreachable, so callers
can distinguish "API unavailable" from "person has 0 images."
"""
all_files = get_all_frigate_person_files()
if all_files is None:
return None
return {name: len(files) for name, files in all_files.items()}
def get_frigate_person_files(person_name: str) -> list[str] | None: def get_frigate_person_files(person_name: str) -> list[str] | None:
"""Return the list of training filenames for a person in Frigate. """Return the list of training filenames for a person in Frigate.
+41 -6
View File
@@ -69,13 +69,15 @@ def process_face_mode(
output_dir: str, output_dir: str,
count: int, count: int,
min_width: int | None = None, min_width: int | None = None,
insightface_app=None,
) -> tuple[int, int] | None: ) -> tuple[int, int] | None:
"""Crop face based on Immich metadata and save to output directory. """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 None if no crop was saved.
If face alignment is enabled and landmarks are available, produces When insightface_app is provided and ENABLE_FACE_ALIGNMENT is True,
an aligned 112x112 crop. Otherwise falls back to bounding box crop re-detects the face in the Immich bbox region using InsightFace to get
with configurable margin. precise landmarks for a proper 112x112 aligned crop. Falls back to
bounding box crop with configurable margin if alignment is unavailable.
""" """
min_width = min_width or Config.MIN_FACE_WIDTH min_width = min_width or Config.MIN_FACE_WIDTH
@@ -109,18 +111,51 @@ def process_face_mode(
logger.debug(f"Face too small ({face_w:.1f}x{face_h:.1f})") logger.debug(f"Face too small ({face_w:.1f}x{face_h:.1f})")
return None return None
# Try face alignment if enabled and landmarks available # Re-detect face with InsightFace for landmark-based alignment.
# Immich's /api/faces endpoint does not include landmarks, so the
# align_face fallback below never fires without this step.
if insightface_app is not None and Config.ENABLE_FACE_ALIGNMENT:
try:
# Expand the Immich bbox by 50% to give InsightFace enough context
# for detection and alignment, then search for the face nearest the
# centre of that region (handles group photos at the boundary).
pad_x, pad_y = face_w * 0.5, face_h * 0.5
search_box = (
max(0, x1 - pad_x),
max(0, y1 - pad_y),
min(img_w, x2 + pad_x),
min(img_h, y2 + pad_y),
)
search_crop = img.crop(search_box)
detected = insightface_app.get(np.asarray(search_crop))
if detected:
cx, cy = search_crop.width / 2, search_crop.height / 2
best = min(
detected,
key=lambda f: abs((f.bbox[0] + f.bbox[2]) / 2 - cx)
+ abs((f.bbox[1] + f.bbox[3]) / 2 - cy),
)
kps = getattr(best, "kps", None)
if kps is not None and np.asarray(kps).shape == (5, 2):
aligned = align_face(search_crop, kps)
if aligned is not None:
_save_jpeg(aligned, os.path.join(output_dir, f"{count}.jpg"))
return aligned.size
except Exception as e:
logger.debug(f"InsightFace re-detection failed for {asset.get('id')}: {e}")
# Landmark alignment from Immich metadata (Immich does not currently
# expose landmarks, so this path is a future-proofing fallback)
if Config.ENABLE_FACE_ALIGNMENT: if Config.ENABLE_FACE_ALIGNMENT:
landmarks = face_info.get("landmarks") or face_info.get("landmark") landmarks = face_info.get("landmarks") or face_info.get("landmark")
if landmarks: if landmarks:
# Scale landmarks
scaled_landmarks = [[lm[0] * scale_x, lm[1] * scale_y] for lm in landmarks] scaled_landmarks = [[lm[0] * scale_x, lm[1] * scale_y] for lm in landmarks]
aligned = align_face(img, scaled_landmarks) aligned = align_face(img, scaled_landmarks)
if aligned is not None: if aligned is not None:
_save_jpeg(aligned, os.path.join(output_dir, f"{count}.jpg")) _save_jpeg(aligned, os.path.join(output_dir, f"{count}.jpg"))
return aligned.size return aligned.size
# Fall back to bounding box crop with configurable margin # Final fallback: bounding box crop with configurable margin
margin = Config.FACE_MARGIN margin = Config.FACE_MARGIN
margin_x, margin_y = face_w * margin, face_h * margin margin_x, margin_y = face_w * margin, face_h * margin
crop_box = ( crop_box = (
+23 -2
View File
@@ -14,6 +14,7 @@ from .config import Config, get_headers
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MAX_PAGES = 1000 # Safety limit for pagination MAX_PAGES = 1000 # Safety limit for pagination
_MAX_ASSETS_PER_PERSON = 5000 # Stop fetching after this many — diversity pool is capped at 3000 anyway
@dataclass @dataclass
@@ -45,6 +46,26 @@ def get_people() -> list[dict]:
return [] return []
def merge_people(survivor_id: str, merge_ids: list[str]) -> bool:
"""Merge duplicate people into survivor via Immich's merge endpoint.
The survivor (identified by survivor_id) absorbs all faces and assets
from the people in merge_ids, which are then removed from Immich.
"""
try:
resp = requests.put(
f"{Config.IMMICH_URL}/api/people/{survivor_id}/merge",
headers={**get_headers(), "Content-Type": "application/json"},
json={"ids": merge_ids},
timeout=30,
)
resp.raise_for_status()
return True
except requests.RequestException as e:
logger.error(f"Failed to merge people into {survivor_id}: {e}")
return False
def fetch_all_assets(person: dict) -> list[dict]: def fetch_all_assets(person: dict) -> list[dict]:
"""Fetch all assets for a person with pagination.""" """Fetch all assets for a person with pagination."""
name = person.get("name", "Unknown") name = person.get("name", "Unknown")
@@ -75,10 +96,10 @@ def fetch_all_assets(person: dict) -> list[dict]:
if not page_assets: if not page_assets:
break break
assets.extend(page_assets) assets.extend(a for a in page_assets if isinstance(a, dict))
logger.debug(f"Fetched page {page}, total: {len(assets)}") logger.debug(f"Fetched page {page}, total: {len(assets)}")
if len(page_assets) < page_size: if len(page_assets) < page_size or len(assets) >= _MAX_ASSETS_PER_PERSON:
break break
except (requests.RequestException, ValueError) as e: except (requests.RequestException, ValueError) as e:
+53 -38
View File
@@ -29,8 +29,11 @@ Frigate's UI are never mapped here and are never touched by quality replacement.
import json import json
import logging import logging
import os
from pathlib import Path from pathlib import Path
from .frigate_api import delete_frigate_person_files
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
UPLOAD_TRACKER_FILE = "frigate_uploaded_ids.json" UPLOAD_TRACKER_FILE = "frigate_uploaded_ids.json"
@@ -167,7 +170,9 @@ def remove_frigate_file(person_name: str, frigate_filename: str) -> None:
data = _load(UPLOAD_TRACKER_FILE) data = _load(UPLOAD_TRACKER_FILE)
by_person = data.get("by_person", {}) by_person = data.get("by_person", {})
entry = _migrate_entry(by_person.get(person_name, {})) entry = _migrate_entry(by_person.get(person_name, {}))
entry["frigate_files"].pop(frigate_filename, None) asset_id = entry["frigate_files"].pop(frigate_filename, None)
if asset_id:
entry["frigate_scores"].pop(asset_id, None)
by_person[person_name] = entry by_person[person_name] = entry
_save(UPLOAD_TRACKER_FILE, data) _save(UPLOAD_TRACKER_FILE, data)
logger.debug(f"Removed Frigate file mapping {frigate_filename} ({person_name})") logger.debug(f"Removed Frigate file mapping {frigate_filename} ({person_name})")
@@ -204,6 +209,22 @@ def has_frigate_scores(person_name: str) -> bool:
return any(asset_id in frigate_scores for asset_id in frigate_files.values()) return any(asset_id in frigate_scores for asset_id in frigate_files.values())
def _pick_mapped_file(
person_name: str, score_key: str, *, highest: bool, exclude: set[str] | None = None
) -> tuple[str, str, float] | None:
data = _load(UPLOAD_TRACKER_FILE)
entry = _migrate_entry(data.get("by_person", {}).get(person_name, {}))
scores = entry.get(score_key, {})
candidates = [
(ff, asset_id, scores[asset_id])
for ff, asset_id in entry.get("frigate_files", {}).items()
if (exclude is None or ff not in exclude) and asset_id in scores
]
if not candidates:
return None
return max(candidates, key=lambda x: x[2]) if highest else min(candidates, key=lambda x: x[2])
def get_lowest_quality_mapped_file( def get_lowest_quality_mapped_file(
person_name: str, exclude: set[str] | None = None person_name: str, exclude: set[str] | None = None
) -> tuple[str, str, float] | None: ) -> tuple[str, str, float] | None:
@@ -213,21 +234,7 @@ def get_lowest_quality_mapped_file(
Used for quality replacement when no Frigate scores are available. Used for quality replacement when no Frigate scores are available.
Pass `exclude` to skip files that failed to delete this run. Pass `exclude` to skip files that failed to delete this run.
""" """
data = _load(UPLOAD_TRACKER_FILE) return _pick_mapped_file(person_name, "scores", highest=False, exclude=exclude)
entry = _migrate_entry(data.get("by_person", {}).get(person_name, {}))
frigate_files = entry.get("frigate_files", {})
blur_scores = entry.get("scores", {})
candidates = [
(ff, asset_id, blur_scores[asset_id])
for ff, asset_id in frigate_files.items()
if (exclude is None or ff not in exclude)
and asset_id in blur_scores
]
if not candidates:
return None
return min(candidates, key=lambda x: x[2])
def get_most_redundant_mapped_file( def get_most_redundant_mapped_file(
@@ -240,21 +247,7 @@ def get_most_redundant_mapped_file(
= the most redundant file and therefore the best replacement target. = the most redundant file and therefore the best replacement target.
Pass `exclude` to skip files that failed to delete this run. Pass `exclude` to skip files that failed to delete this run.
""" """
data = _load(UPLOAD_TRACKER_FILE) return _pick_mapped_file(person_name, "frigate_scores", highest=True, exclude=exclude)
entry = _migrate_entry(data.get("by_person", {}).get(person_name, {}))
frigate_files = entry.get("frigate_files", {})
frigate_scores = entry.get("frigate_scores", {})
candidates = [
(ff, asset_id, frigate_scores[asset_id])
for ff, asset_id in frigate_files.items()
if (exclude is None or ff not in exclude)
and asset_id in frigate_scores
]
if not candidates:
return None
return max(candidates, key=lambda x: x[2])
def get_frigate_filename_for_asset(person_name: str, asset_id: str) -> str | None: def get_frigate_filename_for_asset(person_name: str, asset_id: str) -> str | None:
@@ -307,19 +300,41 @@ def update_frigate_count(person_name: str, count: int) -> None:
def reset_person(person_name: str) -> None: def reset_person(person_name: str) -> None:
"""Remove all uploaded and rejected records for a given person.""" """Remove all uploaded and rejected records for a given person.
for filename in (UPLOAD_TRACKER_FILE, REJECT_TRACKER_FILE):
data = _load(filename) Also deletes winnow-managed Frigate training files so the next run starts
clean rather than uploading on top of orphaned files. Manually-added Frigate
files (not in frigate_files) are never touched. Proceeds with tracker reset
even if Frigate is unreachable.
"""
upload_data = _load(UPLOAD_TRACKER_FILE)
entry = _migrate_entry(upload_data.get("by_person", {}).get(person_name, {}))
frigate_filenames = list(entry.get("frigate_files", {}).keys())
if frigate_filenames:
if not os.environ.get("FRIGATE_URL", "").strip():
logger.info(f"FRIGATE_URL not set — skipping Frigate file deletion for {person_name}")
elif delete_frigate_person_files(person_name, frigate_filenames):
logger.info(f"Deleted {len(frigate_filenames)} Frigate file(s) for {person_name}")
else:
logger.warning(f"Could not delete Frigate files for {person_name} — tracker reset proceeding anyway")
changed = False
tracker_files = ((UPLOAD_TRACKER_FILE, upload_data), (REJECT_TRACKER_FILE, _load(REJECT_TRACKER_FILE)))
for filename, data in tracker_files:
flat_key = _flat_key(filename) flat_key = _flat_key(filename)
by_person = data.get("by_person", {}) by_person = data.get("by_person", {})
entry = by_person.pop(person_name, None) tracker_entry = by_person.pop(person_name, None)
if entry is not None: if tracker_entry is not None:
person_ids = set(_get_ids(entry)) person_ids = set(_get_ids(tracker_entry))
flat = set(data.get(flat_key, [])) - person_ids flat = set(data.get(flat_key, [])) - person_ids
data[flat_key] = sorted(flat) data[flat_key] = sorted(flat)
data["by_person"] = by_person data["by_person"] = by_person
_save(filename, data) _save(filename, data)
logger.info(f"Reset tracking data for {person_name}") changed = True
if changed:
logger.info(f"Reset tracking data for {person_name}")
else:
logger.debug(f"reset_person: no tracking data found for {person_name}")
def get_person_summary() -> dict[str, dict]: def get_person_summary() -> dict[str, dict]: