Compare commits

..
5 Commits
Author SHA1 Message Date
flanandClaude Sonnet 4.6 9bb0727807 release: merge dev → main for 0.4.0
Frigate pre-upload scoring, quality replacement inversion, bootstrap fix,
FRIGATE_SCORE_CEILING / ENABLE_FRIGATE_SCORES, removal of post-upload gate.
See CHANGELOG.md [0.4.0] for the full list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 18:36:02 +00:00
flanandClaude Sonnet 4.6 7c306a4423 chore: bump version to 0.3.3, update changelog and lockfile
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 15:45:06 +00:00
flanandClaude Sonnet 4.6 c22857b912 fix: raise MIN_FACE_WIDTH default from 50 to 90px (8k pixel floor)
50px crops produce ~2,500–4,225 total pixels — well below Frigate's own
camera capture range of 16k–50k px. 90px guarantees ≥8,100 total pixels
even when face margins are fully clipped by image edges.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 15:45:06 +00:00
github-actions[bot] c53172f5ff chore: update lockfiles 2026-06-13 15:33:27 +00:00
flanandClaude Sonnet 4.6 9598142997 release: v0.3.2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 15:32:33 +00:00
17 changed files with 295 additions and 605 deletions
+39 -122
View File
@@ -2,7 +2,7 @@ name: Publish Docker Image
on:
push:
branches: ["dev"]
branches: ["main", "dev"]
paths-ignore:
- "**.md"
- "docs/**"
@@ -15,16 +15,6 @@ on:
- "uv-cpu.lock"
- "uv-rocm.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:
group: docker-${{ github.ref }}
@@ -36,13 +26,15 @@ env:
jobs:
build:
name: Build (linux/amd64)
runs-on: ubuntu-latest
name: Build (${{ matrix.platform }})
runs-on: ${{ matrix.runner }}
strategy:
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-latest
permissions:
contents: read
packages: write
@@ -58,8 +50,10 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
with:
ref: ${{ inputs.tag || github.ref }}
- name: Set up QEMU
if: matrix.platform == 'linux/arm64'
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
@@ -71,15 +65,6 @@ jobs:
username: ${{ github.actor }}
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
id: build
uses: docker/build-push-action@v7
@@ -87,7 +72,6 @@ jobs:
context: .
file: ./Dockerfile
platforms: ${{ matrix.platform }}
build-args: VERSION=${{ steps.version.outputs.value }}
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -102,7 +86,7 @@ jobs:
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digest-amd64
name: digest-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
@@ -136,26 +120,22 @@ jobs:
- name: Determine image tags
id: tags
run: |
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
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"
if [ "${{ github.ref_name }}" = "dev" ]; then
echo "tags=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev" >> "$GITHUB_OUTPUT"
else
echo "tag_args=-t ${IMAGE}:dev" >> "$GITHUB_OUTPUT"
echo "inspect_tag=${IMAGE}:dev" >> "$GITHUB_OUTPUT"
echo "tags=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" >> "$GITHUB_OUTPUT"
fi
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
run: |
docker buildx imagetools create \
${{ steps.tags.outputs.tag_args }} \
-t ${{ steps.tags.outputs.tags }} \
$(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ steps.tags.outputs.inspect_tag }}
docker buildx imagetools inspect ${{ steps.tags.outputs.tags }}
- name: Ensure package is public
run: |
@@ -182,8 +162,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
with:
ref: ${{ inputs.tag || github.ref }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
@@ -198,30 +176,13 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine CPU image tags
id: cpu-tags
- name: Determine CPU image tag
id: cpu-tag
run: |
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
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"
if [ "${{ github.ref_name }}" = "dev" ]; then
echo "tag=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev-cpu" >> "$GITHUB_OUTPUT"
else
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"
echo "tag=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:cpu" >> "$GITHUB_OUTPUT"
fi
- name: Build and push CPU image
@@ -230,18 +191,16 @@ jobs:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
build-args: |
VARIANT=cpu
VERSION=${{ steps.version.outputs.value }}
build-args: VARIANT=cpu
cache-from: type=gha,scope=cpu
cache-to: type=gha,mode=max,scope=cpu
github-token: ${{ secrets.GITHUB_TOKEN }}
push: true
tags: ${{ steps.cpu-tags.outputs.tags }}
tags: ${{ steps.cpu-tag.outputs.tag }}
- name: Inspect CPU image
run: |
docker buildx imagetools inspect ${{ steps.cpu-tags.outputs.inspect_tag }}
docker buildx imagetools inspect ${{ steps.cpu-tag.outputs.tag }}
- name: Ensure package is public
run: |
@@ -268,8 +227,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
with:
ref: ${{ inputs.tag || github.ref }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
@@ -281,30 +238,13 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine ROCm image tags
id: rocm-tags
- name: Determine ROCm image tag
id: rocm-tag
run: |
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
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"
if [ "${{ github.ref_name }}" = "dev" ]; then
echo "tag=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev-rocm" >> "$GITHUB_OUTPUT"
else
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"
echo "tag=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:rocm" >> "$GITHUB_OUTPUT"
fi
- name: Build and push ROCm image
@@ -313,18 +253,16 @@ jobs:
context: .
file: ./Dockerfile
platforms: linux/amd64
build-args: |
VARIANT=rocm
VERSION=${{ steps.version.outputs.value }}
build-args: VARIANT=rocm
cache-from: type=gha,scope=linux/amd64-rocm
cache-to: type=gha,mode=max,scope=linux/amd64-rocm
github-token: ${{ secrets.GITHUB_TOKEN }}
push: true
tags: ${{ steps.rocm-tags.outputs.tags }}
tags: ${{ steps.rocm-tag.outputs.tag }}
- name: Inspect ROCm image
run: |
docker buildx imagetools inspect ${{ steps.rocm-tags.outputs.inspect_tag }}
docker buildx imagetools inspect ${{ steps.rocm-tag.outputs.tag }}
- name: Ensure package is public
run: |
@@ -351,8 +289,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
with:
ref: ${{ inputs.tag || github.ref }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
@@ -364,30 +300,13 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine Intel image tags
id: intel-tags
- name: Determine Intel image tag
id: intel-tag
run: |
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
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"
if [ "${{ github.ref_name }}" = "dev" ]; then
echo "tag=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev-intel" >> "$GITHUB_OUTPUT"
else
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"
echo "tag=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:intel" >> "$GITHUB_OUTPUT"
fi
- name: Build and push Intel image
@@ -396,18 +315,16 @@ jobs:
context: .
file: ./Dockerfile
platforms: linux/amd64
build-args: |
VARIANT=intel
VERSION=${{ steps.version.outputs.value }}
build-args: VARIANT=intel
cache-from: type=gha,scope=linux/amd64-intel
cache-to: type=gha,mode=max,scope=linux/amd64-intel
github-token: ${{ secrets.GITHUB_TOKEN }}
push: true
tags: ${{ steps.intel-tags.outputs.tags }}
tags: ${{ steps.intel-tag.outputs.tag }}
- name: Inspect Intel image
run: |
docker buildx imagetools inspect ${{ steps.intel-tags.outputs.inspect_tag }}
docker buildx imagetools inspect ${{ steps.intel-tag.outputs.tag }}
- name: Ensure package is public
run: |
+182 -8
View File
@@ -127,14 +127,188 @@ jobs:
prerelease: false,
});
build-images:
name: Build and push Docker images
build-gpu:
name: Build GPU image
needs: release
uses: ./.github/workflows/docker-publish.yml
with:
tag: ${{ needs.release.outputs.tag }}
version: ${{ needs.release.outputs.version }}
secrets: inherit
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
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 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
-24
View File
@@ -7,30 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.4.3] - 2026-06-14
### Added
- **InsightFace landmark-based face crop alignment**: face crops for Frigate training are now aligned using InsightFace's `norm_crop` (ArcFace 112×112 alignment with 5-point facial landmarks). Previously, Immich's API returned only bounding boxes with no landmarks, so `align_face()` was dead code and crops were plain bbox slices — resulting in misaligned or partial crops (e.g. foreheads). The fix runs InsightFace detection on an expanded region around the Immich bbox, finds the nearest face, and uses its keypoints for proper alignment. Controlled by `ENABLE_FACE_ALIGNMENT` (default `true`).
- **Duplicate Immich person detection and handling**: when multiple Immich person records share the same name, winnow now detects this at startup and warns with a per-group summary. Without handling, two jobs would run for the same Frigate folder and overwrite each other's output. By default (`MERGE_DUPLICATE_PEOPLE=false`) only the first person per name is processed. Set `MERGE_DUPLICATE_PEOPLE=true` to permanently merge duplicate records inside Immich (keeps the person with the most assets).
## [0.4.2] - 2026-06-13
### Changed
- **GPU image now uses CUDA 12.8.1** (was 13.3): CUDA 13.3 requires driver ≥ 575; driver 570 (the current stable release) was incorrectly rejected with "CUDA driver version is insufficient" at startup. The `:latest` image now works with any NVIDIA driver ≥ 570.
### Added
- **`scripts/benchmark.py`**: measures InsightFace and SigLIP inference latency and throughput across GPU and CPU modes. Run inside the container with `python /app/scripts/benchmark.py`. RTX 2070 SUPER results: InsightFace 12.8 ms / 78 img/s (8× CPU), SigLIP batch 32 at 5.4 ms/img / 187 img/s (33× CPU).
## [0.4.1] - 2026-06-13
### Fixed
- **`RESET_PERSON` no longer creates duplicate Frigate files**: previously, resetting a person only wiped the local tracker — existing Frigate training files were left as unmanaged orphans, causing the next run to upload a full new batch on top of them. `reset_person` now deletes all winnow-managed files for that person from Frigate before clearing the tracker. Manually-added Frigate files are unaffected.
- **No spurious warning when `FRIGATE_URL` is unset and `RESET_PERSON` is used**: the deletion step is now skipped silently at info level rather than logging a misleading "could not delete" warning.
## [0.4.0] - 2026-06-13
### Added
-4
View File
@@ -36,10 +36,6 @@ 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.
- 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
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 ───────────────────────────────────────────────────────────────
# amd64 + gpu: NVIDIA CUDA 12.8 + cuDNN (GPU acceleration via NVIDIA Container Toolkit)
# amd64 + gpu: NVIDIA CUDA 13.3 + cuDNN (GPU acceleration via NVIDIA Container Toolkit)
# 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 + cpu: Ubuntu 22.04 (CPU-only, ~2 GB smaller image)
@@ -7,7 +7,7 @@
ARG VARIANT=gpu
FROM --platform=$BUILDPLATFORM nvidia/cuda:12.8.1-cudnn-runtime-ubuntu22.04 AS base-amd64-gpu
FROM --platform=$BUILDPLATFORM nvidia/cuda:13.3.0-cudnn-runtime-ubuntu22.04 AS base-amd64-gpu
FROM ubuntu:22.04 AS base-amd64-rocm
FROM ubuntu:22.04 AS base-amd64-intel
FROM ubuntu:22.04 AS base-amd64-cpu
+1 -3
View File
@@ -11,8 +11,6 @@
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
@@ -224,7 +222,7 @@ In scheduled mode the process (and loaded models) stays resident between runs. T
| :--- | :--- | :--- |
| `DRY_RUN` | `false` | Preview selection without downloading or uploading |
| `RETRY_REJECTED` | `false` | Re-attempt assets previously rejected by Frigate |
| `RESET_PERSON` | *(unset)* | Clear upload history for one person and delete their winnow-managed Frigate training files so the next run starts fresh. Manually added Frigate files are never touched |
| `RESET_PERSON` | *(unset)* | Clear upload and rejection history for one person by name |
### Scheduling
-1
View File
@@ -26,7 +26,6 @@ services:
# - SKIP_PEOPLE=Unknown # Comma-separated; skip these people
# - 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)
# - MERGE_DUPLICATE_PEOPLE=true # Auto-merge Immich people with the same name (keeps most assets)
# ── Image Quality ─────────────────────────────────────────────────────
# - MIN_FACE_WIDTH=50 # Minimum face width in pixels (default: 50)
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "winnow"
version = "0.4.3"
version = "0.4.0"
description = "Selects diverse, high-quality photos from Immich as training data for Frigate face recognition and object classification."
license = "AGPL-3.0-or-later"
requires-python = ">=3.13"
-196
View File
@@ -1,196 +0,0 @@
#!/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]]
name = "winnow"
version = "0.4.2"
version = "0.3.3"
source = { editable = "." }
dependencies = [
{ name = "croniter" },
+1 -82
View File
@@ -9,7 +9,7 @@ from rich.prompt import Confirm
from .config import Config, ConfigManager
from .executor import execute_jobs, upload_to_frigate
from .immich_api import get_people, merge_people
from .immich_api import get_people
from .jobs import _show_preview, auto_configure, interactive_configure
from .log_config import console, setup_logging
from .upload_tracker import find_by_crop_dimension, get_person_summary, reset_person
@@ -51,85 +51,6 @@ def _handle_trace_crop(size_str: str) -> None:
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:
"""Entry point for winnow CLI."""
try:
@@ -182,8 +103,6 @@ def main() -> None:
rprint("[bold red]Could not fetch people from Immich. Check URL/Key.[/bold red]")
return
people = _handle_duplicate_people(people)
# Auto mode when no TTY (Docker, cron, pipes) — the primary use case.
# A TTY means local interactive use; AUTO_MODE=true overrides that for scripting.
auto_mode = not sys.stdin.isatty() or os.environ.get("AUTO_MODE", "").lower() in ("true", "1", "yes")
-2
View File
@@ -36,7 +36,6 @@ class _Config:
# People filtering
MIN_FACE_COUNT: int = 0
MERGE_DUPLICATE_PEOPLE: bool = False
# Output quality
FACE_MARGIN: float = 0.15
@@ -61,7 +60,6 @@ class _Config:
self.YEARS_FILTER = int(os.getenv("YEARS_FILTER", "10"))
self.MIN_FACE_WIDTH = int(os.getenv("MIN_FACE_WIDTH", "90"))
self.MIN_FACE_COUNT = int(os.getenv("MIN_FACE_COUNT", "0"))
self.MERGE_DUPLICATE_PEOPLE = os.getenv("MERGE_DUPLICATE_PEOPLE", "false").lower() in ("true", "1", "yes")
self.BLUR_THRESHOLD = float(os.getenv("BLUR_THRESHOLD", "120.0"))
self.MIN_CONFIDENCE = float(os.getenv("MIN_CONFIDENCE", "0.7"))
self.MAX_AUTO_IMAGES = int(os.getenv("MAX_AUTO_IMAGES", "80"))
+6 -31
View File
@@ -13,12 +13,7 @@ from rich import print as rprint
from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn
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 .immich_api import fetch_face_data, fetch_full_image
from .log_config import console
@@ -155,18 +150,6 @@ def execute_jobs(jobs: list[dict]) -> None:
use_full_res = Config.USE_FULL_RESOLUTION
# Load InsightFace app for landmark-based crop alignment (face mode only).
# 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(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
@@ -228,7 +211,7 @@ def execute_jobs(jobs: list[dict]) -> None:
progress.console.print(f"[red]Failed download {asset['id']}[/red]")
else:
saved = (
process_face_mode(img, asset, person, person_dir, count, insightface_app=insightface_app)
process_face_mode(img, asset, person, person_dir, count)
if mode == "face"
else process_object_mode(img, config, person_dir, count)
if mode == "object"
@@ -376,8 +359,6 @@ def upload_to_frigate(jobs: list[dict]) -> None:
# Snapshot live Frigate files for post-upload reconciliation diff only.
# effective_count is sourced from the tracker (mapped files) so that
# 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 = (
all_frigate_files.get(name, []) if all_frigate_files is not None
else get_frigate_person_files(name)
@@ -414,7 +395,6 @@ def upload_to_frigate(jobs: list[dict]) -> None:
actually_uploaded: list[tuple[str, str | None]] = []
failed_deletes: set[str] = set()
min_quality_score_for_slot: float | None = None
person_has_fscores: bool = has_frigate_scores(name)
for fname in person_files:
fpath = os.path.join(person_dir, fname)
@@ -447,9 +427,9 @@ def upload_to_frigate(jobs: list[dict]) -> None:
# handles this conservatively by skipping that candidate until the next run.
pre_fscore: float | None = None
if Config.ENABLE_FRIGATE_SCORES and pre_run_count > 0:
if not at_cap or person_has_fscores:
if not at_cap or has_frigate_scores(name):
_result = recognize_face(fpath)
if _result is not None and (_result[0] or "").casefold() == name.casefold():
if _result is not None and _result[0] == name:
pre_fscore = _result[1]
# Ceiling check: skip if the existing training set already covers this
@@ -470,7 +450,7 @@ def upload_to_frigate(jobs: list[dict]) -> None:
progress.advance(upload_task)
continue
using_fscore = person_has_fscores and Config.ENABLE_FRIGATE_SCORES
using_fscore = has_frigate_scores(name) and Config.ENABLE_FRIGATE_SCORES
if using_fscore:
candidate_score = pre_fscore
if candidate_score is None:
@@ -496,10 +476,8 @@ def upload_to_frigate(jobs: list[dict]) -> None:
)
if delete_frigate_person_files(name, [target_frigate_file]):
remove_frigate_file(name, target_frigate_file)
person_has_fscores = has_frigate_scores(name)
effective_count -= 1
# clear any blur-mode slot floor — Frigate uses a different score metric
min_quality_score_for_slot = None
min_quality_score_for_slot = None # clear any blur-mode slot floor — Frigate uses a different score metric
else:
logger.warning(f"Failed to delete {target_frigate_file} for {name}, skipping replacement")
failed_deletes.add(target_frigate_file)
@@ -529,7 +507,6 @@ def upload_to_frigate(jobs: list[dict]) -> None:
)
if delete_frigate_person_files(name, [target_frigate_file]):
remove_frigate_file(name, target_frigate_file)
person_has_fscores = has_frigate_scores(name)
effective_count -= 1
min_quality_score_for_slot = score_map.get(fname)
else:
@@ -561,8 +538,6 @@ def upload_to_frigate(jobs: list[dict]) -> None:
crop_dims=dims_map.get(fname),
frigate_score=pre_fscore,
)
if pre_fscore is not None:
person_has_fscores = True
actually_uploaded.append((fname, asset_id))
break
+18 -14
View File
@@ -22,6 +22,24 @@ def _get_faces_data() -> dict | 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:
"""Return {person_name: [filename, ...]} for every person in Frigate.
@@ -31,8 +49,6 @@ def get_all_frigate_person_files() -> dict[str, list[str]] | None:
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: files
for name, files in data.items()
@@ -40,18 +56,6 @@ 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:
"""Return the list of training filenames for a person in Frigate.
+6 -41
View File
@@ -69,15 +69,13 @@ def process_face_mode(
output_dir: str,
count: int,
min_width: int | None = None,
insightface_app=None,
) -> tuple[int, int] | None:
"""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.
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
bounding box crop with configurable margin if alignment is unavailable.
If face alignment is enabled and landmarks are available, produces
an aligned 112x112 crop. Otherwise falls back to bounding box crop
with configurable margin.
"""
min_width = min_width or Config.MIN_FACE_WIDTH
@@ -111,51 +109,18 @@ def process_face_mode(
logger.debug(f"Face too small ({face_w:.1f}x{face_h:.1f})")
return None
# 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)
# Try face alignment if enabled and landmarks available
if Config.ENABLE_FACE_ALIGNMENT:
landmarks = face_info.get("landmarks") or face_info.get("landmark")
if landmarks:
# Scale landmarks
scaled_landmarks = [[lm[0] * scale_x, lm[1] * scale_y] for lm in landmarks]
aligned = align_face(img, scaled_landmarks)
if aligned is not None:
_save_jpeg(aligned, os.path.join(output_dir, f"{count}.jpg"))
return aligned.size
# Final fallback: bounding box crop with configurable margin
# Fall back to bounding box crop with configurable margin
margin = Config.FACE_MARGIN
margin_x, margin_y = face_w * margin, face_h * margin
crop_box = (
-20
View File
@@ -45,26 +45,6 @@ def get_people() -> list[dict]:
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]:
"""Fetch all assets for a person with pagination."""
name = person.get("name", "Unknown")
+37 -52
View File
@@ -29,11 +29,8 @@ Frigate's UI are never mapped here and are never touched by quality replacement.
import json
import logging
import os
from pathlib import Path
from .frigate_api import delete_frigate_person_files
logger = logging.getLogger(__name__)
UPLOAD_TRACKER_FILE = "frigate_uploaded_ids.json"
@@ -170,9 +167,7 @@ def remove_frigate_file(person_name: str, frigate_filename: str) -> None:
data = _load(UPLOAD_TRACKER_FILE)
by_person = data.get("by_person", {})
entry = _migrate_entry(by_person.get(person_name, {}))
asset_id = entry["frigate_files"].pop(frigate_filename, None)
if asset_id:
entry["frigate_scores"].pop(asset_id, None)
entry["frigate_files"].pop(frigate_filename, None)
by_person[person_name] = entry
_save(UPLOAD_TRACKER_FILE, data)
logger.debug(f"Removed Frigate file mapping {frigate_filename} ({person_name})")
@@ -209,22 +204,6 @@ def has_frigate_scores(person_name: str) -> bool:
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(
person_name: str, exclude: set[str] | None = None
) -> tuple[str, str, float] | None:
@@ -234,7 +213,21 @@ def get_lowest_quality_mapped_file(
Used for quality replacement when no Frigate scores are available.
Pass `exclude` to skip files that failed to delete this run.
"""
return _pick_mapped_file(person_name, "scores", highest=False, exclude=exclude)
data = _load(UPLOAD_TRACKER_FILE)
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(
@@ -247,7 +240,21 @@ def get_most_redundant_mapped_file(
= the most redundant file and therefore the best replacement target.
Pass `exclude` to skip files that failed to delete this run.
"""
return _pick_mapped_file(person_name, "frigate_scores", highest=True, exclude=exclude)
data = _load(UPLOAD_TRACKER_FILE)
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:
@@ -300,41 +307,19 @@ def update_frigate_count(person_name: str, count: int) -> None:
def reset_person(person_name: str) -> None:
"""Remove all uploaded and rejected records for a given person.
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:
"""Remove all uploaded and rejected records for a given person."""
for filename in (UPLOAD_TRACKER_FILE, REJECT_TRACKER_FILE):
data = _load(filename)
flat_key = _flat_key(filename)
by_person = data.get("by_person", {})
tracker_entry = by_person.pop(person_name, None)
if tracker_entry is not None:
person_ids = set(_get_ids(tracker_entry))
entry = by_person.pop(person_name, None)
if entry is not None:
person_ids = set(_get_ids(entry))
flat = set(data.get(flat_key, [])) - person_ids
data[flat_key] = sorted(flat)
data["by_person"] = by_person
_save(filename, data)
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]: