Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9598142997 | ||
|
|
82dbc8502a | ||
|
|
6311227763 | ||
|
|
19416cc7b8 | ||
|
|
80c5b563b2 | ||
|
|
de1df19642 | ||
|
|
3ae6a86684 | ||
|
|
621c310d5b | ||
|
|
467f258fa5 | ||
|
|
36a5fb56b6 | ||
|
|
f90af65cd2 | ||
|
|
cd3fad8afe | ||
|
|
102827537e | ||
|
|
aa12c79f9d | ||
|
|
0f6b17f76f | ||
|
|
c61e4dc698 | ||
|
|
18f3171667 | ||
|
|
1fdb35727e | ||
|
|
870560de1a | ||
|
|
b29b98fa29 | ||
|
|
40c2a850bc | ||
|
|
3e8dddb050 | ||
|
|
12789e83f2 | ||
|
|
8ee370406b | ||
|
|
5ab2cf2ac5 | ||
|
|
608ade8499 | ||
|
|
7e90b8e669 | ||
|
|
694e9af127 | ||
|
|
2cee73fd6f | ||
|
|
3da5f67c21 | ||
|
|
ac249e6242 | ||
|
|
0e2915f564 | ||
|
|
cf760df930 | ||
|
|
7613fdcd5b | ||
|
|
b786199a4f | ||
|
|
2055fe7aae | ||
|
|
fd65157c25 | ||
|
|
d2bc7a94b1 | ||
|
|
0ce67a7570 | ||
|
|
650dadd102 | ||
|
|
1615e2e77e | ||
|
|
785ac3cbc6 | ||
|
|
12bccaa631 | ||
|
|
11e7d4aad7 | ||
|
|
4c8c219598 | ||
|
|
9f92e1c919 | ||
|
|
615c3c3cc6 | ||
|
|
a0083cb9fe | ||
|
|
129c74720e | ||
|
|
3e68128ac5 | ||
|
|
927ad3a68c | ||
|
|
b2be19e259 | ||
|
|
8103801ec8 | ||
|
|
a281b4b896 | ||
|
|
5345798dc1 | ||
|
|
046004a5d0 | ||
|
|
86ee9a5ba2 | ||
|
|
248b7a6270 | ||
|
|
5b25b0df06 | ||
|
|
af9949c47f | ||
|
|
82d057b235 | ||
|
|
fa6dc01366 | ||
|
|
567e568c47 | ||
|
|
1ed8d7e25e | ||
|
|
32e4235384 | ||
|
|
f1df1c6bb3 | ||
|
|
85e499a677 | ||
|
|
ad4d212df0 | ||
|
|
31e7ca5af0 | ||
|
|
4f6967258f | ||
|
|
882af37d8e | ||
|
|
cad053e88f | ||
|
|
3db52c2141 | ||
|
|
af8bae1c45 | ||
|
|
ac9e9530f3 | ||
|
|
015db63d19 | ||
|
|
2804b21f9e | ||
|
|
981a86f28a | ||
|
|
ee585d4bae | ||
|
|
99185c2da9 | ||
|
|
196b0a5147 | ||
|
|
125ce54c7f | ||
|
|
f50d3fe1ab | ||
|
|
af92fe5fcc |
+13
-5
@@ -4,7 +4,10 @@ API_KEY=your-immich-api-key
|
||||
FRIGATE_URL=http://192.168.1.10:5000
|
||||
|
||||
# ── Mode & Strategy ───────────────────────────────────────────────────────────
|
||||
AUTO_MODE=true
|
||||
# Auto mode is active by default when no TTY is present (Docker/cron).
|
||||
# Set AUTO_MODE=true to force auto mode even in an interactive terminal.
|
||||
# AUTO_MODE=true
|
||||
# VERBOSE=true # Enable DEBUG-level console output (log file is always DEBUG)
|
||||
# TRAINING_MODE: face = upload to Frigate face recognition API
|
||||
# object = save crops to output dir for manual Frigate placement
|
||||
TRAINING_MODE=face
|
||||
@@ -29,8 +32,8 @@ STRATEGY=auto
|
||||
# MAX_AUTO_IMAGES=80 # Hard cap on auto-diversity selection (default: 80)
|
||||
|
||||
# ── Caching & Models ──────────────────────────────────────────────────────────
|
||||
FORCE_CPU=false
|
||||
ENABLE_CACHE=true
|
||||
# FORCE_CPU=true # Disable GPU, fall back to CPU
|
||||
# ENABLE_CACHE=false # Disable embedding cache (default: true)
|
||||
CACHE_DIR=/app/.if_cache
|
||||
HF_HOME=/models/huggingface
|
||||
INSIGHTFACE_HOME=/models/.insightface
|
||||
@@ -41,5 +44,10 @@ INSIGHTFACE_HOME=/models/.insightface
|
||||
# RESET_PERSON=John # Clear uploaded+rejected history for one person
|
||||
|
||||
# ── Scheduling ────────────────────────────────────────────────────────────────
|
||||
# Cron expression (unset = run once and exit)
|
||||
CRON_SCHEDULE=0 3 * * 0 # Every Sunday at 3 AM
|
||||
# CRON_SCHEDULE controls container lifetime:
|
||||
# unset — run once on startup, then exit
|
||||
# empty string — stay alive, run nothing (trigger manually: docker exec -it winnow winnow)
|
||||
# cron expression — run on startup, then on schedule
|
||||
# CRON_SCHEDULE= # Manual mode (keep alive, no auto-run)
|
||||
# CRON_SCHEDULE=0 3 * * 0 # Every Sunday at 3 AM
|
||||
# CRON_SCHEDULE=0 3 1 * * # First of every month
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
name: Bug Report
|
||||
description: Something isn't working as expected
|
||||
title: "[Bug]: "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Before filing, check the [Troubleshooting wiki](https://github.com/sudolulo/winnow/wiki/Troubleshooting) and [existing issues](https://github.com/sudolulo/winnow/issues).
|
||||
|
||||
- type: dropdown
|
||||
id: image-tag
|
||||
attributes:
|
||||
label: Image tag
|
||||
description: Which winnow image are you running?
|
||||
options:
|
||||
- ":latest (NVIDIA CUDA)"
|
||||
- ":rocm (AMD)"
|
||||
- ":intel (Intel Arc / iGPU)"
|
||||
- ":cpu (CPU only)"
|
||||
- "Local install (uv)"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: winnow version
|
||||
description: Output of `docker inspect ghcr.io/sudolulo/winnow:<tag> | grep org.opencontainers.image.version` or the version in `pyproject.toml`.
|
||||
placeholder: "0.2.13"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: immich-version
|
||||
attributes:
|
||||
label: Immich version
|
||||
placeholder: "v1.110.0"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: frigate-version
|
||||
attributes:
|
||||
label: Frigate version
|
||||
placeholder: "0.16.0"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: A clear description of the bug.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: What did you expect to happen?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant log output
|
||||
description: Paste logs from `docker logs winnow` or `winnow.log`. Set `VERBOSE=true` for more detail.
|
||||
render: text
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: compose
|
||||
attributes:
|
||||
label: Relevant compose / env config
|
||||
description: Paste your `services.winnow` block. Redact your API key.
|
||||
render: yaml
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Question or help request
|
||||
url: https://github.com/sudolulo/winnow/discussions
|
||||
about: Ask questions and get help in GitHub Discussions
|
||||
- name: Wiki / Documentation
|
||||
url: https://github.com/sudolulo/winnow/wiki
|
||||
about: Setup, troubleshooting, and FAQ
|
||||
@@ -0,0 +1,36 @@
|
||||
name: Feature Request
|
||||
description: Suggest an improvement or new capability
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: What problem does this solve?
|
||||
description: Describe the use case or limitation you're running into.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: What would you like winnow to do? New env var, different behaviour, etc.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: Any workarounds you've tried or other approaches you considered.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
options:
|
||||
- label: I checked existing issues and this hasn't been requested before.
|
||||
required: true
|
||||
@@ -0,0 +1,26 @@
|
||||
## What does this PR do?
|
||||
|
||||
<!-- One or two sentences. -->
|
||||
|
||||
## Why?
|
||||
|
||||
<!-- Link to the issue this addresses, or explain the motivation if there isn't one. -->
|
||||
|
||||
Closes #
|
||||
|
||||
## Changes
|
||||
|
||||
<!-- Bullet list of the meaningful changes. -->
|
||||
|
||||
-
|
||||
|
||||
## Testing
|
||||
|
||||
<!-- How did you verify this works? New tests added? Manual test steps? -->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Targets the `dev` branch (not `main`)
|
||||
- [ ] `uv run pytest` passes
|
||||
- [ ] `uv run ruff check` passes
|
||||
- [ ] `CHANGELOG.md` `[Unreleased]` section updated
|
||||
@@ -6,9 +6,19 @@ on:
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- "docs/**"
|
||||
- ".github/ISSUE_TEMPLATE/**"
|
||||
- ".github/PULL_REQUEST_TEMPLATE.md"
|
||||
- ".github/workflows/release.yml"
|
||||
- ".github/workflows/lint.yml"
|
||||
- ".github/dependabot.yml"
|
||||
- "uv.lock"
|
||||
- "uv-cpu.lock"
|
||||
- "uv-rocm.lock"
|
||||
- "uv-intel.lock"
|
||||
|
||||
concurrency:
|
||||
group: docker-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
@@ -32,18 +42,18 @@ jobs:
|
||||
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."
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
|
||||
sudo rm -rf /opt/hostedtoolcache/CodeQL /usr/share/swift
|
||||
sudo rm -rf "/usr/local/share/boost" "$AGENT_TOOLSDIRECTORY"
|
||||
docker system prune -af
|
||||
df -h
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up QEMU
|
||||
if: matrix.platform == 'linux/arm64'
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
@@ -57,7 +67,7 @@ jobs:
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
@@ -126,3 +136,199 @@ jobs:
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ steps.tags.outputs.tags }}
|
||||
|
||||
- name: Ensure package is public
|
||||
run: |
|
||||
gh api -X PATCH /user/packages/container/winnow \
|
||||
-f visibility=public || true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build-cpu:
|
||||
name: Build CPU (amd64 + arm64)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Free up disk space
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
|
||||
sudo rm -rf /opt/hostedtoolcache/CodeQL /usr/share/swift
|
||||
sudo rm -rf "/usr/local/share/boost" "$AGENT_TOOLSDIRECTORY"
|
||||
docker system prune -af
|
||||
df -h
|
||||
|
||||
- name: Checkout repository
|
||||
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: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Determine CPU image tag
|
||||
id: cpu-tag
|
||||
run: |
|
||||
if [ "${{ github.ref_name }}" = "dev" ]; then
|
||||
echo "tag=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev-cpu" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:cpu" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Build and push CPU image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
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-tag.outputs.tag }}
|
||||
|
||||
- name: Inspect CPU image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ steps.cpu-tag.outputs.tag }}
|
||||
|
||||
- name: Ensure package is public
|
||||
run: |
|
||||
gh api -X PATCH /user/packages/container/winnow \
|
||||
-f visibility=public || true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build-rocm:
|
||||
name: Build ROCm / AMD GPU (amd64)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Free up disk space
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
|
||||
sudo rm -rf /opt/hostedtoolcache/CodeQL /usr/share/swift
|
||||
sudo rm -rf "/usr/local/share/boost" "$AGENT_TOOLSDIRECTORY"
|
||||
docker system prune -af
|
||||
df -h
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Determine ROCm image tag
|
||||
id: rocm-tag
|
||||
run: |
|
||||
if [ "${{ github.ref_name }}" = "dev" ]; then
|
||||
echo "tag=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev-rocm" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:rocm" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Build and push ROCm image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
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-tag.outputs.tag }}
|
||||
|
||||
- name: Inspect ROCm image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ steps.rocm-tag.outputs.tag }}
|
||||
|
||||
- name: Ensure package is public
|
||||
run: |
|
||||
gh api -X PATCH /user/packages/container/winnow \
|
||||
-f visibility=public || true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build-intel:
|
||||
name: Build Intel GPU (amd64)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Free up disk space
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
|
||||
sudo rm -rf /opt/hostedtoolcache/CodeQL /usr/share/swift
|
||||
sudo rm -rf "/usr/local/share/boost" "$AGENT_TOOLSDIRECTORY"
|
||||
docker system prune -af
|
||||
df -h
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Determine Intel image tag
|
||||
id: intel-tag
|
||||
run: |
|
||||
if [ "${{ github.ref_name }}" = "dev" ]; then
|
||||
echo "tag=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev-intel" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:intel" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Build and push Intel image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
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-tag.outputs.tag }}
|
||||
|
||||
- name: Inspect Intel image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ steps.intel-tag.outputs.tag }}
|
||||
|
||||
- name: Ensure package is public
|
||||
run: |
|
||||
gh api -X PATCH /user/packages/container/winnow \
|
||||
-f visibility=public || true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
+195
-13
@@ -15,11 +15,13 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Create GitHub Release & Build Image
|
||||
name: Create GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
outputs:
|
||||
tag: ${{ steps.tag.outputs.TAG }}
|
||||
version: ${{ steps.tag.outputs.VERSION }}
|
||||
steps:
|
||||
- name: Free up disk space
|
||||
run: |
|
||||
@@ -37,14 +39,28 @@ jobs:
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Ensure uv.lock is current
|
||||
run: uv lock
|
||||
- name: Set up Python
|
||||
run: uv python install 3.13
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Verify release branch
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
if [ "${{ github.ref_name }}" != "main" ]; then
|
||||
echo "::error::Releases must be dispatched from main (current: ${{ github.ref_name }})"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Ensure lockfiles are current
|
||||
run: |
|
||||
cp pyproject.toml _pyproject_orig.toml
|
||||
for variant in cpu rocm intel; do
|
||||
cp pyproject-${variant}.toml pyproject.toml
|
||||
uv lock
|
||||
cp uv.lock uv-${variant}.lock
|
||||
done
|
||||
cp _pyproject_orig.toml pyproject.toml
|
||||
uv lock
|
||||
rm _pyproject_orig.toml
|
||||
|
||||
- name: Resolve tag name
|
||||
id: tag
|
||||
@@ -111,6 +127,30 @@ jobs:
|
||||
prerelease: false,
|
||||
});
|
||||
|
||||
build-gpu:
|
||||
name: Build GPU 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:
|
||||
@@ -118,15 +158,157 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
- name: Build and push GPU image (latest)
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
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:${{ steps.tag.outputs.TAG }}
|
||||
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
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
# .github/workflows/update-lockfile.yml
|
||||
name: Update uv.lock
|
||||
name: Update lockfiles
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
paths:
|
||||
- 'pyproject.toml'
|
||||
- 'pyproject-cpu.toml'
|
||||
- 'pyproject-rocm.toml'
|
||||
- 'pyproject-intel.toml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -30,24 +35,32 @@ jobs:
|
||||
- name: Set up Python
|
||||
run: uv python install 3.13
|
||||
|
||||
- name: Regenerate lockfile
|
||||
run: uv lock
|
||||
- name: Regenerate all lockfiles
|
||||
run: |
|
||||
cp pyproject.toml _pyproject_orig.toml
|
||||
for variant in cpu rocm intel; do
|
||||
cp pyproject-${variant}.toml pyproject.toml
|
||||
uv lock
|
||||
cp uv.lock uv-${variant}.lock
|
||||
done
|
||||
cp _pyproject_orig.toml pyproject.toml
|
||||
uv lock
|
||||
rm _pyproject_orig.toml
|
||||
|
||||
- name: Check for changes
|
||||
id: diff
|
||||
run: |
|
||||
if git diff --quiet uv.lock; then
|
||||
if git diff --quiet uv.lock uv-cpu.lock uv-rocm.lock uv-intel.lock; then
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Commit and push updated lockfile
|
||||
- name: Commit and push updated lockfiles
|
||||
if: steps.diff.outputs.changed == 'true'
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add uv.lock
|
||||
git commit -m "chore: update uv.lock"
|
||||
git add uv.lock uv-cpu.lock uv-rocm.lock uv-intel.lock
|
||||
git commit -m "chore: update lockfiles"
|
||||
git push
|
||||
|
||||
|
||||
+20
-223
@@ -1,239 +1,36 @@
|
||||
# Custom
|
||||
# Output and runtime artefacts
|
||||
frigate_train/
|
||||
*.log
|
||||
runs/
|
||||
|
||||
# Model and cache files
|
||||
yolov9c.pt
|
||||
.insightface/
|
||||
.huggingface/
|
||||
.cache/huggingface
|
||||
.if_cache/
|
||||
|
||||
.immich_config.json
|
||||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[codz]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
# Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
# poetry.lock
|
||||
# poetry.toml
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
||||
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
||||
# pdm.lock
|
||||
# pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# pixi
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
||||
# pixi.lock
|
||||
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
||||
# in the .venv directory. It is recommended not to include this directory in version control.
|
||||
.pixi
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# Redis
|
||||
*.rdb
|
||||
*.aof
|
||||
*.pid
|
||||
|
||||
# RabbitMQ
|
||||
mnesia/
|
||||
rabbitmq/
|
||||
rabbitmq-data/
|
||||
|
||||
# ActiveMQ
|
||||
activemq-data/
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
# Secrets
|
||||
.env
|
||||
.envrc
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
*.so
|
||||
.Python
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
# Packaging
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
wheels/
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
# Virtual environments
|
||||
.venv/
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
# .idea/
|
||||
|
||||
# Abstra
|
||||
# Abstra is an AI-powered process automation framework.
|
||||
# Ignore directories containing user credentials, local state, and settings.
|
||||
# Learn more at https://abstra.io/docs
|
||||
.abstra/
|
||||
|
||||
# Visual Studio Code
|
||||
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
||||
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
||||
# you could uncomment the following to ignore the entire vscode folder
|
||||
# .vscode/
|
||||
|
||||
# Ruff stuff:
|
||||
# Tools
|
||||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# Marimo
|
||||
marimo/_static/
|
||||
marimo/_lsp/
|
||||
__marimo__/
|
||||
|
||||
# Streamlit
|
||||
.streamlit/secrets.toml
|
||||
compose.override.yml
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.python-version
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
3.13
|
||||
@@ -7,6 +7,105 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.3.2] - 2026-06-13
|
||||
|
||||
### Added
|
||||
|
||||
- **Crop dimension tracing**: winnow now records the pixel dimensions (width × height) of each face crop at upload time in the tracker (`crop_dims` field). Run `TRACE_CROP_SIZE=3848 winnow` to look up which Immich asset produced a crop with that pixel dimension — output includes person name, asset ID, Immich URL, blur score, and the Frigate filename. Useful for tracing low-quality or unexpected images visible in Frigate back to their source.
|
||||
|
||||
## [0.3.1] - 2026-06-13
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Lint**: split overly long line in `quality.py` (`E501`, 146 → ≤120 chars).
|
||||
- **CI — lockfile update workflow**: added `branches: ['**']` filter to `on.push` so tag pushes no longer trigger the job; tag checkouts land in detached HEAD and the subsequent `git push` had no branch target.
|
||||
- **CI — release workflow**: split four Docker image builds into parallel jobs (`build-gpu`, `build-cpu`, `build-rocm`, `build-intel`), each with its own runner. Previously all four ran in a single job; building the GPU and CPU multi-platform images exhausted disk, causing ROCm and Intel builds to be cancelled.
|
||||
|
||||
## [0.3.0] - 2026-06-13
|
||||
|
||||
### Added
|
||||
|
||||
- **`get_tracked_frigate_filenames()`** — new upload-tracker function that returns the set of Frigate filenames currently mapped for a person. Used internally as a reconciliation baseline when the Frigate GET endpoint is unreachable; also available to callers that need the mapped filename set without a count.
|
||||
- **Community scaffolding**: `CONTRIBUTING.md`, `SECURITY.md`, GitHub issue templates (bug report, feature request), and pull request template.
|
||||
- **OCI image labels**: `org.opencontainers.image.*` labels added to the runtime stage of the Dockerfile so image metadata is surfaced by container registries.
|
||||
- **Additional tracker tests**: coverage added for `get_tracked_frigate_filenames` and for `get_lowest_quality_mapped_file` with the `exclude` parameter.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Quality score scale mismatch (USE_FULL_RESOLUTION=true)**: the time-spread quality-score fallback called `assess_quality` on the full-resolution download, while the embedding path always scores on preview thumbnails. Laplacian variance scales with image resolution, so the two paths produced incomparable scores for people with mixed-mode files. The fallback now caps the image at 1440 px before scoring to match the thumbnail scale.
|
||||
- **assess_quality failure left file permanently unreplaceable**: if `assess_quality` raised an exception (e.g. an RGBA image with an unsupported channel count), `score_map` kept `None` and `mark_uploaded(score=None)` skipped writing the score. The uploaded file was then permanently invisible to `get_lowest_quality_mapped_file` because it had no entry in `scores{}`. The fallback now converts the image to RGB before scoring and stores `0.0` on any exception, so every uploaded file is eligible for future quality replacement.
|
||||
- **Uploads during Frigate GET outage never mapped**: when `GET /api/faces` failed at upload start, the reconciliation guard (`_snapshot is not None`) correctly skipped the post-upload diff — but uploads that succeeded during the outage were never recorded in `frigate_files`, causing `get_tracked_frigate_file_count` to permanently under-report and Frigate to eventually exceed `MAX_AUTO_IMAGES`. The code now uses the tracker's mapped filenames as a pre-upload baseline when the live snapshot is unavailable, so reconciliation proceeds normally (the `>target` guard handles concurrent external uploads as before).
|
||||
- **Freed quality-replacement slot could be filled by a worse image**: when a replacement delete succeeded but the subsequent upload failed all retries, `effective_count` stayed decremented and the next file in the iteration uploaded unconditionally — it could have a lower quality score than the file that was deleted. A `min_quality_score_for_slot` variable now records the deleted file's score on a successful delete; any candidate that doesn't beat that floor is skipped until the slot is filled by a qualifying image or the run ends.
|
||||
- **CI multi-arch and lockfile bot**: hardened the build workflow — lockfile-update bot no longer races against Docker publish on the same push event; CPU image now builds for both `linux/amd64` and `linux/arm64`; `paths-ignore` prevents documentation-only pushes from triggering image builds.
|
||||
- **README pipeline diagram updated**: step 8 now explicitly documents the quality-replacement decision tree (`below cap → upload`, `at cap + enabled → swap if better`, `at cap + disabled → skip`).
|
||||
|
||||
## [0.2.13] - 2026-06-13
|
||||
|
||||
### Added
|
||||
|
||||
- **Quality replacement**: when a person is at `MAX_AUTO_IMAGES`, winnow now checks each new candidate against the lowest-quality image already in Frigate and swaps it in if the new image scores higher. Only images winnow uploaded (tracked in `frigate_files`) are ever replaced — files added manually through Frigate's UI are left untouched permanently. Enabled by default; set `QUALITY_REPLACEMENT=false` to revert to the previous behaviour of skipping people at cap.
|
||||
- **Frigate filename mapping**: each successful upload now records the mapping from Frigate's assigned filename to the originating Immich asset ID and face confidence score in the tracker (`frigate_files` field). This is the foundation for quality replacement and future management of the Frigate training set.
|
||||
- **`QUALITY_REPLACEMENT` env var** (default `true`): controls whether at-cap people are eligible for quality replacement. When disabled, people at `MAX_AUTO_IMAGES` are skipped as before.
|
||||
- **NOTICES file**: third-party attribution for if_curator (MIT, Copyright © 2026 Sebastian) added to satisfy upstream license requirements.
|
||||
|
||||
## [0.2.12] - 2026-06-13
|
||||
|
||||
### Added
|
||||
|
||||
- **ROCm (AMD GPU) support**: new `:rocm` image tag. InsightFace runs via `ROCmExecutionProvider`; SigLIP runs via PyTorch ROCm 6.3 (ROCm builds expose `torch.cuda.is_available() == True`, so the existing CUDA path is reused automatically). Requires `/dev/kfd` and `/dev/dri` device passthrough plus `video` and `render` group membership — see `compose.yml` for the snippet.
|
||||
- **Intel GPU support**: new `:intel` image tag. InsightFace runs via `OpenVINOExecutionProvider` from `onnxruntime-openvino`. By default OpenVINO targets CPU (no device passthrough needed); set `OPENVINO_DEVICE=GPU` to target Intel Arc discrete or integrated graphics. Intel's GPU compute runtime (Level Zero + OpenCL ICD) is installed automatically from Intel's official graphics repo in the image — no manual package installation required. SigLIP uses CPU inference for now (Intel Extension for PyTorch has no Python 3.13 wheels yet; the `torch.xpu` path is wired and will activate automatically when they ship).
|
||||
- **`OPENVINO_DEVICE` env var**: controls the OpenVINO execution provider device for the `:intel` variant. `CPU` (default) requires no device passthrough. `GPU` targets Intel Arc discrete and integrated graphics via Level Zero.
|
||||
- **AMD and Intel device passthrough snippets in `compose.yml`**: documented as commented-out alternatives to the NVIDIA `deploy:` block.
|
||||
- **`:rocm` and `:intel` CI jobs**: `docker-publish.yml` now builds and pushes `:rocm` / `:dev-rocm` and `:intel` / `:dev-intel` alongside `:latest` and `:cpu`. `release.yml` builds all four variants on tag push.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Interactive custom-count prompt firing for all choices**: in the no-embedding fallback path of the strategy selector, `IntPrompt.ask` was inside a dict literal and evaluated eagerly — users selecting Standard (30) or Broad (100) were still prompted to enter a custom image count. Each choice is now handled in a dedicated branch.
|
||||
|
||||
## [0.2.11] - 2026-06-12
|
||||
|
||||
### Fixed
|
||||
|
||||
- **GPU broken on x86_64 Linux**: `insightface` 1.0.1 (pulled in by the 0.2.10 lock update) added a hard dependency on the CPU `onnxruntime` package. Combined with an incorrect `override-dependencies` entry introduced in 0.2.10, both `onnxruntime` (CPU) and `onnxruntime-gpu` were being installed into the same venv. The CPU package landed last and overwrote the GPU one, causing `CUDAExecutionProvider` to disappear from the provider list even when a GPU was present. Fixed by declaring `onnxruntime` and `onnxruntime-gpu` as conflicting packages in uv's resolver, ensuring only the correct one is installed per platform.
|
||||
- **OOM crash on large person libraries (CPU mode)**: All candidate thumbnails were downloaded into a single in-memory dict before any processing began. At ~5 MB per decoded preview image, a person with 472 candidates would accumulate ~2.4 GB of thumbnail data alone, exhausting a 4 GB container memory limit. Thumbnails are now downloaded and processed in batches of 32, with each image released immediately after embedding. Peak in-flight thumbnail memory is now bounded to ~256 MB regardless of candidate pool size. GPU users also benefit from lower host RAM pressure and faster time-to-first-result on large libraries.
|
||||
|
||||
## [0.2.10] - 2026-06-12
|
||||
|
||||
### Added
|
||||
|
||||
- **`VERBOSE` env var**: set `VERBOSE=true` to enable DEBUG-level console output. The log file always captures DEBUG; this flag controls what appears on the terminal. Useful when diagnosing issues without a full shell into the container.
|
||||
- **`:cpu` Docker image tag**: a separate CPU-only image (`ghcr.io/sudolulo/winnow:cpu`) is now built and pushed alongside `:latest`. Uses `onnxruntime` instead of `onnxruntime-gpu`; ~2 GB smaller. Suitable for systems without an NVIDIA GPU.
|
||||
- **Empty `CRON_SCHEDULE` keeps container alive**: setting `CRON_SCHEDULE=` (empty string) starts the container without running immediately and without exiting — useful for `docker exec` ad-hoc runs on a long-lived container. Previously, an empty value was treated the same as unset (run once, then exit).
|
||||
|
||||
### Changed
|
||||
|
||||
- **TTY auto-detection replaces `AUTO_MODE`**: winnow now detects whether a TTY is attached (`sys.stdin.isatty()`) and switches between interactive and auto mode automatically. `AUTO_MODE=true` becomes an explicit override for forcing auto mode in a terminal session. No config change needed for normal Docker deployments.
|
||||
- **Logging levels audited**: internal algorithmic detail (clustering steps, asset fetch progress, per-page pagination) demoted from INFO to DEBUG. INFO now reflects meaningful pipeline milestones only (model ready, selection complete, quality filtered). Reduces noise in production logs without losing information.
|
||||
- **Model load logging improved**: SigLIP and InsightFace loading now reports cache hit/miss, download size estimate, device used, and load time.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **GPU OOM crash loop (production)**: `CUDAExecutionProvider` was silently absent even with a GPU attached, causing InsightFace to run on CPU and exhaust RAM processing large person libraries. Root cause: CUDA/cuDNN libraries in nvidia pip packages were invisible to onnxruntime. Fixed by running `ldconfig` over all `nvidia-*/lib/` directories in the venv at image build time.
|
||||
- **ldconfig path now Python-version-agnostic**: the `find` command used to register nvidia pip libraries hardcoded `python3.13`; replaced with `python3.*` glob so the path survives a Python upgrade without silently producing an empty ldconfig config.
|
||||
- **`PYTHONPATH=/app` added to Dockerfile**: the entry point script sets `sys.path[0]` to the script directory, not `/app`. Since `uv sync` runs before `COPY winnow/`, the wheel has only dist-info in site-packages. `PYTHONPATH=/app` makes the `winnow` package importable without reverting to `python -m`.
|
||||
- **CPU fallback retrying broken GPU provider**: InsightFace CPU fallback omitted `providers=["CPUExecutionProvider"]`, causing onnxruntime to retry `CUDAExecutionProvider` on every inference call. Now explicitly sets the CPU provider and suppresses C-extension noise via fd-level redirect.
|
||||
- **`_suppress_output` stderr loss on fd exhaustion**: if the first `os.dup2` in the finally block raised `OSError`, the second call was skipped, permanently redirecting stderr to `/dev/null` for the process lifetime. Wrapped in nested `try/finally` so both restores are always attempted.
|
||||
- **Frigate `/api/faces` response parsing**: the response is `{person_name: [files], "train": [...]}` — `"train"` is a flat pending list, not a person. Previous code called `.items()` on the `"train"` value (a list), crashing with `AttributeError`. Now skips the `"train"` key explicitly.
|
||||
- **Immich 401 detection**: a stale or invalid API key now logs a clear error message (`Immich API key is invalid or expired (401 Unauthorized)`) instead of raising an unhandled exception.
|
||||
- **Falsy-zero detection confidence**: `face.get("score") or face.get("confidence")` treated a valid `score=0.0` as falsy, falling through to the `confidence` field (often `None`). Replaced with an explicit `None` check. Affected both quality filtering and hard-example weighting in diversity selection.
|
||||
- **Face crop using wrong person's image dimensions**: in multi-person assets, `_crop_face_from_thumbnail`'s scale-factor loop matched the first person with any face regardless of `person_id`, producing incorrectly scaled bounding box coordinates for the target person. Loop now applies the same `person_id` filter as `_get_face_bbox`.
|
||||
- **Adaptive stopping bypassed for partially-trained people**: in auto mode with `already_uploaded > 0`, `limit` was converted from `"auto"` to an integer, disabling the FPS adaptive threshold and early-stop check. Now keeps `limit="auto"` through selection and trims the result to the remaining capacity afterward.
|
||||
- **Embedding cache key mismatch**: HuggingFace cache path check hardcoded the model slug string; replaced with a derivation from `model_name` using `"models--" + model_name.replace("/", "--")` so the check stays correct if the model name changes.
|
||||
- **Scheduler: sleep until next run**: the loop slept a fixed 60 seconds regardless of schedule interval, causing runs to fire up to 59 seconds late and waking the process unnecessarily on long schedules (e.g. weekly). Now sleeps exactly until `next_run`.
|
||||
- **Scheduler swallowing `SystemExit`**: `except BaseException` in the run wrapper was replaced with `except Exception` (with `KeyboardInterrupt` re-raised above), so `sys.exit()` calls propagate correctly.
|
||||
- **Log handler leak**: `setup_logging` now closes and removes existing handlers before adding new ones, preventing file handle accumulation across repeated calls.
|
||||
- **`RETRY_REJECTED` silently applied in interactive mode**: the env var was applied unconditionally even in interactive sessions. Now used only as the default for the interactive prompt so users can override it per-run.
|
||||
- **`compose.yml` comment inverted**: a comment stated `-it` forces non-interactive mode; corrected to reflect that `-it` allocates a TTY (interactive mode).
|
||||
|
||||
### Security
|
||||
|
||||
- API key is no longer stored in any config file. All authentication uses environment variables or `.env` only.
|
||||
|
||||
## [0.2.9] - 2026-06-12
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Contributing to winnow
|
||||
|
||||
Bug reports, feature requests, and pull requests are all welcome.
|
||||
|
||||
## Before You Start
|
||||
|
||||
- Check [existing issues](https://github.com/sudolulo/winnow/issues) to avoid duplicates.
|
||||
- For large changes, open an issue first to discuss the approach.
|
||||
- All PRs target the `dev` branch — never `main` directly.
|
||||
|
||||
## Development Setup
|
||||
|
||||
Requires Python 3.13+ and [uv](https://astral.sh/uv).
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sudolulo/winnow.git
|
||||
cd winnow
|
||||
git checkout dev
|
||||
uv sync
|
||||
```
|
||||
|
||||
## Running Tests and Lint
|
||||
|
||||
```bash
|
||||
uv run pytest # run the test suite
|
||||
uv run ruff check # lint
|
||||
uv run ruff check --fix # auto-fix lint issues
|
||||
```
|
||||
|
||||
CI runs both on every push and PR to `main` and `dev`. PRs must pass before merging.
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
- One logical change per PR.
|
||||
- If you add behaviour, add a test for it.
|
||||
- Keep the `CHANGELOG.md` entry in the `[Unreleased]` section updated.
|
||||
- Commit messages should be plain English describing what changed and why.
|
||||
|
||||
## License
|
||||
|
||||
By submitting a contribution you agree that your work will be released under the project's [AGPLv3+ license](LICENSE).
|
||||
+71
-18
@@ -1,20 +1,31 @@
|
||||
# ── Platform-conditional base ─────────────────────────────────────────────
|
||||
# amd64: NVIDIA CUDA 13.3 (GPU acceleration when available, CPU fallback)
|
||||
# arm64: Ubuntu 24.04 (CPU-only; no CUDA on ARM)
|
||||
# ── Base images ───────────────────────────────────────────────────────────────
|
||||
# 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)
|
||||
# arm64: Ubuntu 24.04 (CPU-only; no CUDA/ROCm wheels on ARM)
|
||||
|
||||
FROM --platform=$BUILDPLATFORM nvidia/cuda:13.3.0-cudnn-runtime-ubuntu22.04 AS base-amd64
|
||||
FROM ubuntu:24.04 AS base-arm64
|
||||
ARG VARIANT=gpu
|
||||
|
||||
# ── Build stage ───────────────────────────────────────────────────────────
|
||||
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
|
||||
FROM ubuntu:24.04 AS base-arm64-gpu
|
||||
FROM ubuntu:24.04 AS base-arm64-rocm
|
||||
FROM ubuntu:24.04 AS base-arm64-intel
|
||||
FROM ubuntu:24.04 AS base-arm64-cpu
|
||||
|
||||
# ── Build stage ───────────────────────────────────────────────────────────────
|
||||
ARG TARGETARCH
|
||||
|
||||
FROM base-${TARGETARCH} AS build
|
||||
FROM base-${TARGETARCH}-${VARIANT} AS build
|
||||
|
||||
ARG VARIANT=gpu
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Both bases (Ubuntu 22.04 CUDA / Ubuntu 24.04) need Python 3.13 from the
|
||||
# deadsnakes PPA. GNUPGHOME is isolated to a tmpdir so gpg never tries to
|
||||
# contact an agent socket, which fails silently under QEMU.
|
||||
# Both Ubuntu 22.04 and 24.04 get Python 3.13 from the deadsnakes PPA.
|
||||
# GNUPGHOME is isolated so gpg never contacts an agent socket under QEMU.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl gnupg software-properties-common \
|
||||
&& GNUPGHOME=$(mktemp -d) add-apt-repository ppa:deadsnakes/ppa -y \
|
||||
@@ -30,20 +41,36 @@ RUN curl -LsSf https://astral.sh/uv/install.sh | sh \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN uv sync --frozen --no-dev \
|
||||
# Swap in the variant-specific pyproject and lockfile before syncing.
|
||||
COPY pyproject.toml uv.lock pyproject-cpu.toml uv-cpu.lock \
|
||||
pyproject-rocm.toml uv-rocm.lock pyproject-intel.toml uv-intel.lock ./
|
||||
RUN if [ "$VARIANT" = "cpu" ]; then \
|
||||
cp pyproject-cpu.toml pyproject.toml && cp uv-cpu.lock uv.lock; \
|
||||
elif [ "$VARIANT" = "rocm" ]; then \
|
||||
cp pyproject-rocm.toml pyproject.toml && cp uv-rocm.lock uv.lock; \
|
||||
elif [ "$VARIANT" = "intel" ]; then \
|
||||
cp pyproject-intel.toml pyproject.toml && cp uv-intel.lock uv.lock; \
|
||||
fi && \
|
||||
uv sync --frozen --no-dev \
|
||||
&& uv cache clean
|
||||
|
||||
COPY winnow/ winnow/
|
||||
COPY entrypoint.sh scheduler.py ./
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
# ── Runtime stage ─────────────────────────────────────────────────────────
|
||||
# ── Runtime stage ─────────────────────────────────────────────────────────────
|
||||
# Starts fresh from the base image — excludes build tools (g++,
|
||||
# python3.13-dev, gnupg, software-properties-common) not needed at runtime.
|
||||
|
||||
FROM base-${TARGETARCH} AS runtime
|
||||
FROM base-${TARGETARCH}-${VARIANT} AS runtime
|
||||
|
||||
ARG VARIANT=gpu
|
||||
ARG VERSION=dev
|
||||
LABEL org.opencontainers.image.title="winnow" \
|
||||
org.opencontainers.image.description="Selects diverse, high-quality photos from Immich as training data for Frigate face recognition and object classification." \
|
||||
org.opencontainers.image.source="https://github.com/sudolulo/winnow" \
|
||||
org.opencontainers.image.licenses="AGPL-3.0-or-later" \
|
||||
org.opencontainers.image.version="${VERSION}"
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
@@ -61,9 +88,32 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
COPY --from=build /app /app
|
||||
COPY --from=build /usr/local/bin/uv /usr/local/bin/uv
|
||||
|
||||
# Expose CUDA/cuDNN libraries from pip packages so onnxruntime-gpu
|
||||
# can find libcublasLt.so.12 and libcudnn.so.9 at runtime (amd64 only)
|
||||
ENV LD_LIBRARY_PATH="/app/.venv/lib/python3.13/site-packages/nvidia/cudnn/lib:/app/.venv/lib/python3.13/site-packages/nvidia/cuda_runtime/lib:${LD_LIBRARY_PATH}"
|
||||
# NVIDIA: register pip-installed nvidia lib/ dirs with ldconfig so onnxruntime-gpu
|
||||
# and torch can find libcudnn, libcublas, etc. Skipped silently on other variants.
|
||||
RUN if [ "$VARIANT" = "gpu" ]; then \
|
||||
find /app/.venv/lib/python3.*/site-packages/nvidia -type d -name "lib" \
|
||||
2>/dev/null > /etc/ld.so.conf.d/nvidia-pip.conf && ldconfig || true; \
|
||||
fi
|
||||
# Intel: install GPU compute runtime so OpenVINO EP can target Intel Arc / iGPU.
|
||||
# onnxruntime-openvino bundles OpenVINO itself; only the userspace GPU driver
|
||||
# (OpenCL ICD + Level Zero) is needed from the OS.
|
||||
# These packages aren't in Ubuntu 22.04 main, so this block adds Intel's
|
||||
# official GPU repo first, then installs. libze-intel-gpu1 was renamed to
|
||||
# level-zero in Intel's repo.
|
||||
RUN if [ "$VARIANT" = "intel" ]; then \
|
||||
apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl gnupg \
|
||||
&& curl -fsSL https://repositories.intel.com/graphics/intel-graphics.key \
|
||||
| gpg --dearmor > /usr/share/keyrings/intel-graphics.gpg \
|
||||
&& echo "deb [arch=amd64 signed-by=/usr/share/keyrings/intel-graphics.gpg] \
|
||||
https://repositories.intel.com/graphics/ubuntu jammy flex" \
|
||||
> /etc/apt/sources.list.d/intel-graphics.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
intel-opencl-icd intel-level-zero-gpu level-zero \
|
||||
&& apt-get remove -y --autoremove curl gnupg \
|
||||
&& rm -rf /var/lib/apt/lists/*; \
|
||||
fi
|
||||
|
||||
RUN groupadd -g 568 apps && useradd -u 568 -g apps -m -s /bin/bash appuser \
|
||||
&& mkdir -p /models/.insightface /models/huggingface \
|
||||
@@ -71,7 +121,10 @@ RUN groupadd -g 568 apps && useradd -u 568 -g apps -m -s /bin/bash appuser \
|
||||
|
||||
WORKDIR /app
|
||||
USER appuser
|
||||
ENV HF_HOME=/models/huggingface INSIGHTFACE_HOME=/models
|
||||
# PYTHONPATH=/app makes the winnow package importable from the entry point script.
|
||||
# uv sync builds the wheel before winnow/ is COPY'd, so site-packages has only
|
||||
# the dist-info. Explicitly adding /app lets Python find winnow/__init__.py there.
|
||||
ENV HF_HOME=/models/huggingface INSIGHTFACE_HOME=/models/.insightface PYTHONPATH=/app
|
||||
|
||||
HEALTHCHECK CMD test -f /app/entrypoint.sh || exit 1
|
||||
ENTRYPOINT ["tini", "--", "/app/entrypoint.sh"]
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
winnow incorporates portions of if_curator (https://github.com/ds-sebastian/if_curator).
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Sebastian
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -1,31 +1,17 @@
|
||||
# winnow
|
||||
|
||||
[](https://github.com/sudolulo/winnow/actions/workflows/docker-publish.yml) [](https://github.com/sudolulo/winnow/actions/workflows/test.yml) [](https://immich.app) [](https://frigate.video)
|
||||
[](https://github.com/sudolulo/winnow/actions/workflows/docker-publish.yml) [](https://github.com/sudolulo/winnow/actions/workflows/test.yml) [](https://github.com/sudolulo/winnow/releases/latest) [](LICENSE) [](https://immich.app) [](https://frigate.video)
|
||||
|
||||
**Docs:** [Setup](https://github.com/sudolulo/winnow/wiki/Setup) · [Troubleshooting](https://github.com/sudolulo/winnow/wiki/Troubleshooting) · [FAQ](https://github.com/sudolulo/winnow/wiki/FAQ)
|
||||
|
||||
`winnow` pulls photos of people and objects from your [Immich](https://immich.app) library, selects the most diverse and highest-quality subset using AI embeddings, and delivers them as training data for [Frigate](https://frigate.video)'s face recognition and object classification models.
|
||||
`winnow` pulls photos from your [Immich](https://immich.app) library, selects the most diverse and highest-quality subset using AI embeddings, and delivers them as training data for [Frigate](https://frigate.video)'s face recognition and object classification models.
|
||||
|
||||
It runs fully headless in Docker, is configured entirely through environment variables, and can run on a schedule — no interactive prompts, no manual steps.
|
||||
|
||||
---
|
||||
|
||||
## The Problem
|
||||
|
||||
Frigate's face recognition model (ArcFace) and object classifier are only as good as the training data you give them. The instinct is to feed them as many photos as possible, but volume is not what matters — **diversity is**.
|
||||
|
||||
If you upload 100 photos from the same week, the model learns the lighting in your living room and the jacket you wore that month. It struggles the moment anything changes. What you actually want is a spread: different years, different lighting conditions, different angles, different contexts.
|
||||
|
||||
This is especially true for people who have never been to your property, or who visit rarely — family members, friends, anyone Frigate has never seen in person. Live detections alone will never build a reliable model for these people. Your photo library already has the data; winnow finds and delivers the right subset of it.
|
||||
|
||||
Finding that spread manually across a library of thousands of photos is not practical. `winnow` does it 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.
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
For each person (or object) you configure, the tool runs this pipeline:
|
||||
|
||||
```
|
||||
Immich library
|
||||
│
|
||||
@@ -33,86 +19,76 @@ Immich library
|
||||
1. Fetch all assets tagged with this person
|
||||
│
|
||||
▼
|
||||
2. Filter by recency (configurable years window)
|
||||
2. Filter by recency (YEARS_FILTER) and skip already-uploaded
|
||||
and rejected assets (persistent tracker in CACHE_DIR)
|
||||
│
|
||||
▼
|
||||
3. Skip already-uploaded assets (persistent tracker)
|
||||
│
|
||||
▼
|
||||
4. Quality filter — reject:
|
||||
3. Quality filter — download preview thumbnails and reject:
|
||||
• Blurry images (Laplacian variance)
|
||||
• Grayscale / infrared (channel similarity check)
|
||||
• Grayscale / infrared (channel similarity)
|
||||
• Over- or underexposed
|
||||
• Low detection confidence
|
||||
• Face crops below minimum pixel size
|
||||
│
|
||||
▼
|
||||
5. Compute embeddings for remaining candidates
|
||||
• Faces → InsightFace (ArcFace / Buffalo_L)
|
||||
• Objects → SigLIP (Vision Transformer)
|
||||
4. Compute embeddings from the same preview thumbnails
|
||||
• Faces → InsightFace (ArcFace / Buffalo_L) → 512-dim vector
|
||||
• Objects → SigLIP (Vision Transformer) → 768-dim vector
|
||||
│
|
||||
▼
|
||||
6. Diversity selection
|
||||
• K-Medoids clustering to find natural groupings
|
||||
• Farthest Point Sampling (FPS) to pick maximally spread representatives
|
||||
• Hard example weighting — unusual angles, partial occlusions,
|
||||
and low-confidence detections are biased toward selection
|
||||
• Auto mode: keeps selecting until marginal diversity drops off
|
||||
5. Diversity selection
|
||||
• K-Medoids clustering → one representative per natural group
|
||||
• Farthest Point Sampling → fill remaining slots with maximally spread picks
|
||||
• Hard example weighting — unusual angles and low-confidence detections
|
||||
are biased toward selection, since those are where models tend to fail
|
||||
• Auto mode: stops when similarity to the existing set exceeds a threshold
|
||||
(20 % of median pairwise distance for faces, 10 % for objects)
|
||||
│
|
||||
▼
|
||||
7. Crop and export
|
||||
• Face mode: aligned 112×112 crops (ArcFace standard input),
|
||||
uploaded directly to Frigate's face training API
|
||||
• Object mode: YOLO-detected crops saved to disk
|
||||
6. Download full-resolution originals from Immich
|
||||
│
|
||||
▼
|
||||
7. Crop and process
|
||||
• Face mode: EXIF-corrected, landmark-aligned 112×112 crop (ArcFace format)
|
||||
• Object mode: YOLOv9c detection → one crop per matched instance
|
||||
│
|
||||
▼
|
||||
8. Deliver
|
||||
• Face mode: upload crops to Frigate's face registration API
|
||||
↳ below MAX_AUTO_IMAGES — upload freely
|
||||
↳ at cap + QUALITY_REPLACEMENT=true — swap the lowest-scoring tracked
|
||||
image if the new candidate scores higher; manually added files are
|
||||
never touched
|
||||
↳ at cap + QUALITY_REPLACEMENT=false — skip this person
|
||||
• Object mode: save crops to disk → place into your Frigate data directory
|
||||
```
|
||||
|
||||
Uploaded asset IDs are recorded so the same image is never uploaded twice, even across runs weeks apart.
|
||||
|
||||
---
|
||||
|
||||
## Note on Crop Quality
|
||||
|
||||
winnow works well, but no automated pipeline is perfect. Occasionally a bad crop will slip through quality filtering — a partial face, someone in the background, a blurry frame. After a run it's worth a quick review in Frigate's face management UI to remove anything that doesn't belong.
|
||||
|
||||
Issues and feedback welcome via [GitHub Issues](https://github.com/sudolulo/winnow/issues).
|
||||
Uploaded and rejected asset IDs are persisted across runs. The same image is never processed twice; Frigate rejections are permanently skipped unless `RETRY_REJECTED=true`.
|
||||
|
||||
---
|
||||
|
||||
## Modes
|
||||
|
||||
### Face Mode (default)
|
||||
**Face mode** (default) — extracts face crops using Immich's bounding box metadata, applies EXIF orientation correction, and aligns them to ArcFace's standard 112×112 format using 5-point facial landmarks. Crops are uploaded directly to Frigate's face registration API.
|
||||
|
||||
Extracts face crops using Immich's bounding box metadata, scales them to the source image resolution, applies EXIF orientation correction, then either aligns them to the standard ArcFace 112×112 format using 5-point facial landmarks or falls back to a margin-padded bounding box crop.
|
||||
|
||||
Crops are uploaded directly to Frigate's face registration API (`POST /api/faces/{name}/register`). After each successful upload the asset ID is marked in the tracker so future runs skip it.
|
||||
|
||||
### Object Mode
|
||||
|
||||
Runs each full image through YOLOv9c to detect instances of a target class (dog, cat, car, etc.), then crops each detection and saves it to the output directory. Frigate has no API for uploading object training images, so the crops are saved for you to place into your Frigate data directory manually.
|
||||
|
||||
---
|
||||
|
||||
## Diversity Selection in Detail
|
||||
|
||||
The core of the tool is the embedding-based selection. Rather than picking images at random or evenly across time, it computes a vector embedding for each candidate image that encodes what the face or object actually looks like — the angle, lighting, expression, background context.
|
||||
|
||||
It then:
|
||||
|
||||
1. **Clusters** those embeddings using K-Medoids to find natural groups (e.g. "holiday photos", "outdoor summer shots", "indoor low light")
|
||||
2. **Selects one representative** from each cluster — the most central image in each group
|
||||
3. **Fills remaining slots** using Farthest Point Sampling, iteratively picking whichever image is most different from everything already selected
|
||||
4. **Weights toward hard examples** — images with unusual angles, partial occlusions, or borderline detection confidence are more likely to be picked, because those edge cases are where models fail
|
||||
|
||||
In **Auto mode**, there is no fixed limit. The tool keeps selecting until the most-different remaining image is already close to something already in the set — at that point adding more would be redundant. This is capped at `MAX_AUTO_IMAGES` (default 80) as a safety limit.
|
||||
|
||||
If the embedding model is unavailable, the tool falls back to **time spread**: evenly distributing picks across the date range of your photos.
|
||||
**Object mode** — runs each full-resolution image through YOLOv9c to detect instances of a target class (dog, cat, car, etc.), crops each detection, and saves it to the output directory. Frigate has no API for uploading object training data; place the crops into your Frigate data directory manually.
|
||||
|
||||
---
|
||||
|
||||
## Running in Docker
|
||||
|
||||
### Image Tags
|
||||
|
||||
| Tag | Arch | Acceleration |
|
||||
| :-- | :-- | :-- |
|
||||
| `:latest` | amd64 + arm64 | NVIDIA CUDA 13.3 (amd64) · requires [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html) |
|
||||
| `:rocm` | amd64 | AMD ROCm · pass `/dev/kfd` + `/dev/dri` |
|
||||
| `:intel` | amd64 | Intel Arc / iGPU via OpenVINO · pass `/dev/dri`, set `OPENVINO_DEVICE=GPU` |
|
||||
| `:cpu` | amd64 + arm64 | CPU only · ~2 GB smaller · no GPU required |
|
||||
|
||||
### Quick Start
|
||||
|
||||
**NVIDIA:**
|
||||
```yaml
|
||||
services:
|
||||
winnow:
|
||||
@@ -121,8 +97,7 @@ services:
|
||||
- IMMICH_URL=http://192.168.1.10:2283
|
||||
- API_KEY=your-immich-api-key
|
||||
- FRIGATE_URL=http://192.168.1.10:5000
|
||||
- AUTO_MODE=true
|
||||
- CRON_SCHEDULE=0 3 * * 0 # Every Sunday at 3 AM
|
||||
- CRON_SCHEDULE=0 3 * * 0
|
||||
volumes:
|
||||
- /path/to/models:/models
|
||||
- /path/to/cache:/app/.if_cache
|
||||
@@ -136,72 +111,109 @@ services:
|
||||
capabilities: [gpu]
|
||||
```
|
||||
|
||||
See [compose.yml](compose.yml) for the full annotated example.
|
||||
**AMD (`:rocm`):** use `image: ghcr.io/sudolulo/winnow:rocm` and replace the `deploy:` block with:
|
||||
```yaml
|
||||
devices:
|
||||
- /dev/kfd
|
||||
- /dev/dri
|
||||
group_add:
|
||||
- video
|
||||
- render
|
||||
```
|
||||
|
||||
### Scheduling Behaviour
|
||||
**Intel (`:intel`):** use `image: ghcr.io/sudolulo/winnow:intel` and replace the `deploy:` block with:
|
||||
```yaml
|
||||
devices:
|
||||
- /dev/dri
|
||||
group_add:
|
||||
- render
|
||||
environment:
|
||||
- OPENVINO_DEVICE=GPU # omit to run OpenVINO inference on CPU (default)
|
||||
```
|
||||
|
||||
On startup the container always runs once immediately. If `CRON_SCHEDULE` is set, it then starts a scheduler that fires on the defined interval, keeping the process (and loaded models) alive between runs. Without `CRON_SCHEDULE` the container exits after the first run.
|
||||
**CPU (`:cpu`):** use `image: ghcr.io/sudolulo/winnow:cpu`, remove the `deploy:` block, and add `mem_limit: 2g` to prevent OOM on large libraries.
|
||||
|
||||
The first run after a fresh install downloads the embedding models (~1-2 GB). Subsequent runs use the cached models from the mounted volume and start immediately.
|
||||
See [compose.yml](compose.yml) for the full annotated example with all options.
|
||||
|
||||
### Scheduling
|
||||
|
||||
`CRON_SCHEDULE` controls container lifetime:
|
||||
|
||||
| `CRON_SCHEDULE` value | Behaviour |
|
||||
| :-- | :-- |
|
||||
| *(unset)* | Run once on startup, then exit |
|
||||
| *(empty string)* | Stay alive, run nothing — trigger manually with `docker exec -it winnow winnow` |
|
||||
| Cron expression | Run on startup, then repeat on schedule |
|
||||
|
||||
In scheduled mode the process (and loaded models) stays resident between runs. The first run after a fresh install downloads the embedding models (~1–2 GB); subsequent runs use the cached models from the mounted volume.
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Mode & Strategy
|
||||
|
||||
| Variable | Default | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `AUTO_MODE` | `false` | Run without interactive prompts — required for Docker/cron use |
|
||||
| `TRAINING_MODE` | `face` | `face` — upload crops to Frigate API; `object` — save crops to disk |
|
||||
| `STRATEGY` | `auto` | `auto` (adaptive), `standard` (30 images), `broad` (100 images) |
|
||||
| `LIMIT` | *(unset)* | Exact image count — overrides `STRATEGY` |
|
||||
| `OBJECT_CLASS` | `dog` | Target class for object mode (any YOLO class: `dog`, `cat`, `car`, etc.) |
|
||||
|
||||
### People Filtering
|
||||
|
||||
| Variable | Default | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `ONLY_PEOPLE` | *(unset)* | Comma-separated whitelist — only these people are processed |
|
||||
| `SKIP_PEOPLE` | *(unset)* | Comma-separated list of people to skip |
|
||||
| `MIN_FACE_COUNT` | `0` | Skip people with fewer than N tagged assets in Immich |
|
||||
| `YEARS_FILTER` | `10` | Ignore images older than N years |
|
||||
|
||||
### Connection
|
||||
|
||||
| Variable | Default | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `IMMICH_URL` | *(required)* | Full URL to your Immich instance |
|
||||
| `API_KEY` | *(required)* | Immich API key |
|
||||
| `FRIGATE_URL` | *(unset)* | Frigate URL — required for face upload; omit to skip upload |
|
||||
| `FRIGATE_URL` | *(unset)* | Frigate URL — required for face upload; omit to skip |
|
||||
|
||||
### Mode & Strategy
|
||||
|
||||
| Variable | Default | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `TRAINING_MODE` | `face` | `face` — upload crops to Frigate; `object` — save crops to disk |
|
||||
| `STRATEGY` | `auto` | `auto` (embedding-based adaptive), `standard` (30 images), `broad` (100 images) |
|
||||
| `LIMIT` | *(unset)* | Exact image count — overrides `STRATEGY` |
|
||||
| `OBJECT_CLASS` | `dog` | Target class for object mode (any YOLO class: `dog`, `cat`, `car`, etc.) |
|
||||
| `AUTO_MODE` | *(auto)* | Force non-interactive mode in a terminal; auto-detected otherwise |
|
||||
| `VERBOSE` | `false` | Enable DEBUG-level console output (log file is always DEBUG) |
|
||||
|
||||
### People Filtering
|
||||
|
||||
| Variable | Default | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `ONLY_PEOPLE` | *(unset)* | Comma-separated whitelist — process only these people |
|
||||
| `SKIP_PEOPLE` | *(unset)* | Comma-separated list — skip these people |
|
||||
| `MIN_FACE_COUNT` | `0` | Skip people with fewer than N tagged assets in Immich |
|
||||
| `YEARS_FILTER` | `10` | Ignore images older than N years |
|
||||
|
||||
### Image Quality
|
||||
|
||||
| Variable | Default | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `MIN_FACE_WIDTH` | `50` | Minimum face crop width in pixels |
|
||||
| `FACE_MARGIN` | `0.15` | Padding added around the bounding box crop (fraction of face size) |
|
||||
| `FACE_MARGIN` | `0.15` | Padding around bounding box crop (fraction of face size) |
|
||||
| `ENABLE_FACE_ALIGNMENT` | `true` | Align to ArcFace 112×112 format using facial landmarks |
|
||||
| `USE_FULL_RESOLUTION` | `true` | Download full-resolution originals rather than preview thumbnails |
|
||||
| `MIN_CONFIDENCE` | `0.7` | Minimum Immich face detection confidence |
|
||||
| `BLUR_THRESHOLD` | `100.0` | Laplacian variance threshold — lower accepts more blur |
|
||||
| `MAX_AUTO_IMAGES` | `80` | Maximum images in auto-diversity mode |
|
||||
| `MAX_AUTO_IMAGES` | `80` | Maximum training images per person in Frigate |
|
||||
| `QUALITY_REPLACEMENT` | `true` | When at cap, swap the lowest-scoring tracked image for a better candidate. Never touches manually added Frigate files. Set `false` to skip people already at cap |
|
||||
|
||||
### Caching & Models
|
||||
### GPU & Models
|
||||
|
||||
| Variable | Default | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `FORCE_CPU` | `false` | Disable GPU — fall back to CPU for embedding computation |
|
||||
| `ENABLE_CACHE` | `false` | Cache computed embeddings to disk (speeds up re-runs on the same library) |
|
||||
| `FORCE_CPU` | `false` | Disable GPU — fall back to CPU for all inference |
|
||||
| `OPENVINO_DEVICE` | `CPU` | Intel variant only: set `GPU` to use Arc or iGPU; default runs on CPU |
|
||||
| `ENABLE_CACHE` | `true` | Cache computed embeddings to disk (speeds up re-runs on the same library) |
|
||||
| `CACHE_DIR` | `.if_cache` | Path for embedding cache and upload tracker files |
|
||||
| `HF_HOME` | *(system)* | HuggingFace model cache location (SigLIP) |
|
||||
| `INSIGHTFACE_HOME` | *(system)* | InsightFace model cache location (Buffalo_L) |
|
||||
| `HF_HOME` | *(system)* | HuggingFace model cache path (SigLIP) |
|
||||
| `INSIGHTFACE_HOME` | *(system)* | InsightFace model cache path (Buffalo_L) |
|
||||
|
||||
### Output
|
||||
|
||||
| Variable | Default | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `OUTPUT_DIR` | `./frigate_train` | Directory for object-mode crops and the `winnow.log` file. In Docker, set this via the volume mount instead. |
|
||||
|
||||
### Tracker Overrides *(one-shot — remove after use)*
|
||||
|
||||
| Variable | Default | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `DRY_RUN` | `false` | Show what would be selected and uploaded without doing it |
|
||||
| `DRY_RUN` | `false` | Preview selection without downloading or uploading |
|
||||
| `RETRY_REJECTED` | `false` | Re-attempt assets previously rejected by Frigate |
|
||||
| `RESET_PERSON` | *(unset)* | Clear upload and rejection history for one person by name |
|
||||
|
||||
@@ -209,7 +221,7 @@ The first run after a fresh install downloads the embedding models (~1-2 GB). Su
|
||||
|
||||
| Variable | Default | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `CRON_SCHEDULE` | *(unset)* | Cron expression for recurring runs — unset exits after first run |
|
||||
| `CRON_SCHEDULE` | *(unset)* | Unset = run once and exit; empty = stay alive; cron expression = scheduled |
|
||||
|
||||
---
|
||||
|
||||
@@ -222,16 +234,26 @@ uv sync
|
||||
uv run winnow
|
||||
```
|
||||
|
||||
Requires Python 3.13+ and [uv](https://astral.sh/uv/). An NVIDIA GPU is strongly recommended — CPU mode works but embedding computation is significantly slower.
|
||||
Requires Python 3.13+ and [uv](https://astral.sh/uv). An NVIDIA, AMD, or Intel GPU is recommended — CPU mode works but embedding computation is slower.
|
||||
|
||||
When run with a terminal attached, winnow starts an interactive session: select which people to process and choose a strategy (auto, standard, broad, or a custom count) per person. Without a TTY — Docker, cron, or `AUTO_MODE=true` — it processes all people automatically using the configured defaults.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Immich** v1.106+
|
||||
- **Frigate** v0.16+ (face mode only — object mode has no Frigate API dependency)
|
||||
- **NVIDIA GPU** recommended (CUDA 12.x)
|
||||
- **Python 3.13+**
|
||||
- **Frigate** v0.16+ (face mode only — object mode has no Frigate dependency)
|
||||
- **GPU** recommended: NVIDIA (CUDA), AMD (ROCm), or Intel (Arc / iGPU via OpenVINO)
|
||||
- **Python** 3.13+
|
||||
|
||||
---
|
||||
|
||||
## Getting Help
|
||||
|
||||
- **[GitHub Discussions](https://github.com/sudolulo/winnow/discussions)** — questions, setup help, and general discussion
|
||||
- **[Wiki](https://github.com/sudolulo/winnow/wiki)** — setup guide, troubleshooting, and FAQ
|
||||
- **[Issues](https://github.com/sudolulo/winnow/issues)** — bugs and feature requests only
|
||||
|
||||
---
|
||||
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Only the latest release is supported with security fixes.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please do **not** open a public GitHub issue for security vulnerabilities.
|
||||
|
||||
Email **holden@arch.fyi** with:
|
||||
|
||||
- A description of the vulnerability and its potential impact
|
||||
- Steps to reproduce or a proof of concept
|
||||
- Any suggested fix, if you have one
|
||||
|
||||
You will receive an acknowledgement within 48 hours. If the vulnerability is confirmed, a fix will be released as soon as possible and you will be credited in the changelog unless you prefer otherwise.
|
||||
+33
-10
@@ -9,7 +9,10 @@ services:
|
||||
- FRIGATE_URL=${FRIGATE_URL}
|
||||
|
||||
# ── Mode & Strategy ───────────────────────────────────────────────────
|
||||
- AUTO_MODE=true
|
||||
# Auto mode is active by default when no TTY is present (Docker/cron).
|
||||
# Set AUTO_MODE=true to force auto mode in an interactive terminal.
|
||||
# To run interactively: docker exec -it winnow winnow
|
||||
# - VERBOSE=true # Enable DEBUG-level console output
|
||||
# TRAINING_MODE: face = upload to Frigate face recognition API
|
||||
# object = save crops to output dir for manual Frigate placement
|
||||
- TRAINING_MODE=face
|
||||
@@ -34,8 +37,9 @@ services:
|
||||
# - MAX_AUTO_IMAGES=80 # Hard cap on auto-diversity selection (default: 80)
|
||||
|
||||
# ── Caching & Models ──────────────────────────────────────────────────
|
||||
- FORCE_CPU=false
|
||||
- ENABLE_CACHE=true
|
||||
# - FORCE_CPU=true # Disable GPU, fall back to CPU
|
||||
# - OPENVINO_DEVICE=GPU # Intel variant only: use Arc/iGPU instead of CPU (default: CPU)
|
||||
# - ENABLE_CACHE=false # Disable embedding cache (default: true)
|
||||
- CACHE_DIR=/app/.if_cache
|
||||
- HF_HOME=/models/huggingface
|
||||
- INSIGHTFACE_HOME=/models/.insightface
|
||||
@@ -46,19 +50,23 @@ services:
|
||||
# - RESET_PERSON=John # Clear uploaded+rejected history for one person
|
||||
|
||||
# ── Scheduling ────────────────────────────────────────────────────────
|
||||
# Cron expression (unset = run once and exit)
|
||||
# Every Sunday at 3 AM:
|
||||
- CRON_SCHEDULE=0 3 * * 0
|
||||
# - CRON_SCHEDULE=0 3 1 * *
|
||||
# - CRON_SCHEDULE=*/30 * * * *
|
||||
# CRON_SCHEDULE controls container lifetime:
|
||||
# unset — run once on startup, then exit
|
||||
# empty string — stay alive, run nothing; trigger manually with:
|
||||
# docker exec -it winnow winnow
|
||||
# cron expression — run on startup, then on schedule
|
||||
# - CRON_SCHEDULE= # Manual mode (keep alive, no auto-run)
|
||||
# - CRON_SCHEDULE=0 3 * * 0 # Every Sunday at 3 AM
|
||||
# - CRON_SCHEDULE=0 3 1 * * # First of every month
|
||||
volumes:
|
||||
# Replace with absolute paths on your host, e.g. /opt/winnow/models
|
||||
- /path/to/winnow/models:/models
|
||||
- /path/to/winnow/cache:/app/.if_cache
|
||||
- /path/to/winnow/output:/app/frigate_train
|
||||
stdin_open: true
|
||||
tty: true
|
||||
restart: unless-stopped
|
||||
|
||||
# ── GPU device passthrough ─────────────────────────────────────────────────
|
||||
# NVIDIA (default — requires nvidia-container-toolkit):
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
@@ -66,3 +74,18 @@ services:
|
||||
- driver: nvidia
|
||||
count: all
|
||||
capabilities: [gpu]
|
||||
#
|
||||
# AMD ROCm — replace the deploy block above with:
|
||||
# devices:
|
||||
# - /dev/kfd
|
||||
# - /dev/dri
|
||||
# group_add:
|
||||
# - video
|
||||
# - render
|
||||
#
|
||||
# Intel Arc / iGPU — replace the deploy block above with:
|
||||
# devices:
|
||||
# - /dev/dri
|
||||
# group_add:
|
||||
# - render
|
||||
# Also set: OPENVINO_DEVICE=GPU in the environment section above.
|
||||
|
||||
+11
-4
@@ -2,11 +2,19 @@
|
||||
set -e
|
||||
export PYTHONUNBUFFERED=1
|
||||
|
||||
# 1. Run the job immediately on startup
|
||||
# CRON_SCHEDULE controls container lifetime:
|
||||
# unset — run once and exit
|
||||
# empty string — stay alive, run nothing (use: docker exec -it winnow winnow)
|
||||
# cron expression — run immediately, then on schedule
|
||||
|
||||
if [ "${CRON_SCHEDULE+isset}" = "isset" ] && [ -z "$CRON_SCHEDULE" ]; then
|
||||
echo "▶ CRON_SCHEDULE is empty — manual mode. Use 'docker exec -it winnow winnow' to run."
|
||||
exec sleep infinity
|
||||
fi
|
||||
|
||||
echo "▶ Running on startup..."
|
||||
/app/.venv/bin/python -m winnow.cli
|
||||
/app/.venv/bin/winnow
|
||||
|
||||
# 2. If a schedule exists, start the scheduler
|
||||
if [ -n "${CRON_SCHEDULE:-}" ]; then
|
||||
echo "▶ CRON_SCHEDULE set to: $CRON_SCHEDULE"
|
||||
echo "▶ Switching to scheduled mode..."
|
||||
@@ -14,4 +22,3 @@ if [ -n "${CRON_SCHEDULE:-}" ]; then
|
||||
else
|
||||
echo "▶ No schedule set, exiting."
|
||||
fi
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
[project]
|
||||
name = "winnow"
|
||||
version = "0.2.13"
|
||||
description = "Immich to Frigate training sets"
|
||||
license = "AGPL-3.0-or-later"
|
||||
requires-python = ">=3.13"
|
||||
authors = [{ name = "Holden Salomon", email = "holden@arch.fyi" }]
|
||||
keywords = ["immich", "frigate", "face-recognition", "training-data", "arcface", "insightface"]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Scientific/Engineering :: Image Recognition",
|
||||
]
|
||||
dependencies = [
|
||||
"croniter>=5.0.2",
|
||||
"insightface>=0.7.3",
|
||||
"numpy>=2.2.6",
|
||||
"onnxruntime>=1.23.2",
|
||||
"opencv-python-headless>=4.12.0.88",
|
||||
"pillow>=12.1.0",
|
||||
"python-dotenv>=1.2.1",
|
||||
"requests>=2.32.5",
|
||||
"rich>=14.2.0",
|
||||
"torch>=2.12.0",
|
||||
"torchvision>=0.27.0",
|
||||
"transformers>=5.12.0",
|
||||
"ultralytics>=8.4.66",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
winnow = "winnow.cli:main"
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/sudolulo/winnow"
|
||||
|
||||
[tool.uv]
|
||||
required-environments = [
|
||||
"sys_platform == 'linux' and platform_machine == 'x86_64'",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
torch = [
|
||||
{ index = "pytorch-cpu", marker = "sys_platform == 'linux' and platform_machine == 'x86_64'" },
|
||||
]
|
||||
torchvision = [
|
||||
{ index = "pytorch-cpu", marker = "sys_platform == 'linux' and platform_machine == 'x86_64'" },
|
||||
]
|
||||
|
||||
[[tool.uv.index]]
|
||||
name = "pytorch-cpu"
|
||||
url = "https://download.pytorch.org/whl/cpu"
|
||||
explicit = true
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"ruff>=0.15.17",
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["winnow"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py313"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I"]
|
||||
|
||||
[tool.deptry]
|
||||
pep621_dev_dependency_groups = ["dev"]
|
||||
|
||||
[tool.deptry.package_module_name_map]
|
||||
pillow = "PIL"
|
||||
opencv-python-headless = "cv2"
|
||||
python-dotenv = "dotenv"
|
||||
insightface = "insightface"
|
||||
numpy = "numpy"
|
||||
onnxruntime = "onnxruntime"
|
||||
requests = "requests"
|
||||
rich = "rich"
|
||||
torch = "torch"
|
||||
transformers = "transformers"
|
||||
ultralytics = "ultralytics"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
@@ -0,0 +1,103 @@
|
||||
[project]
|
||||
name = "winnow"
|
||||
version = "0.2.13"
|
||||
description = "Immich to Frigate training sets"
|
||||
license = "AGPL-3.0-or-later"
|
||||
requires-python = ">=3.13"
|
||||
authors = [{ name = "Holden Salomon", email = "holden@arch.fyi" }]
|
||||
keywords = ["immich", "frigate", "face-recognition", "training-data", "arcface", "insightface"]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Scientific/Engineering :: Image Recognition",
|
||||
]
|
||||
dependencies = [
|
||||
"croniter>=5.0.2",
|
||||
"insightface>=0.7.3",
|
||||
"numpy>=2.2.6",
|
||||
"onnxruntime-openvino>=1.20.0",
|
||||
"opencv-python-headless>=4.12.0.88",
|
||||
"pillow>=12.1.0",
|
||||
"python-dotenv>=1.2.1",
|
||||
"requests>=2.32.5",
|
||||
"rich>=14.2.0",
|
||||
"torch>=2.12.0",
|
||||
"torchvision>=0.27.0",
|
||||
"transformers>=5.12.0",
|
||||
"ultralytics>=8.4.66",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
winnow = "winnow.cli:main"
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/sudolulo/winnow"
|
||||
|
||||
[tool.uv]
|
||||
conflicts = [
|
||||
[
|
||||
{ package = "onnxruntime" },
|
||||
{ package = "onnxruntime-gpu" },
|
||||
{ package = "onnxruntime-openvino" },
|
||||
],
|
||||
]
|
||||
required-environments = [
|
||||
"sys_platform == 'linux' and platform_machine == 'x86_64'",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
torch = [
|
||||
{ index = "pytorch-cpu", marker = "sys_platform == 'linux' and platform_machine == 'x86_64'" },
|
||||
]
|
||||
torchvision = [
|
||||
{ index = "pytorch-cpu", marker = "sys_platform == 'linux' and platform_machine == 'x86_64'" },
|
||||
]
|
||||
|
||||
[[tool.uv.index]]
|
||||
name = "pytorch-cpu"
|
||||
url = "https://download.pytorch.org/whl/cpu"
|
||||
explicit = true
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"ruff>=0.15.17",
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["winnow"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py313"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I"]
|
||||
|
||||
[tool.deptry]
|
||||
pep621_dev_dependency_groups = ["dev"]
|
||||
|
||||
[tool.deptry.package_module_name_map]
|
||||
pillow = "PIL"
|
||||
opencv-python-headless = "cv2"
|
||||
python-dotenv = "dotenv"
|
||||
insightface = "insightface"
|
||||
numpy = "numpy"
|
||||
onnxruntime-openvino = "onnxruntime"
|
||||
requests = "requests"
|
||||
rich = "rich"
|
||||
torch = "torch"
|
||||
transformers = "transformers"
|
||||
ultralytics = "ultralytics"
|
||||
|
||||
[tool.deptry.per_rule_ignores]
|
||||
DEP002 = ["onnxruntime-openvino"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
@@ -0,0 +1,103 @@
|
||||
[project]
|
||||
name = "winnow"
|
||||
version = "0.2.13"
|
||||
description = "Immich to Frigate training sets"
|
||||
license = "AGPL-3.0-or-later"
|
||||
requires-python = ">=3.13"
|
||||
authors = [{ name = "Holden Salomon", email = "holden@arch.fyi" }]
|
||||
keywords = ["immich", "frigate", "face-recognition", "training-data", "arcface", "insightface"]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Scientific/Engineering :: Image Recognition",
|
||||
]
|
||||
dependencies = [
|
||||
"croniter>=5.0.2",
|
||||
"insightface>=0.7.3",
|
||||
"numpy>=2.2.6",
|
||||
"onnxruntime-rocm>=1.16.0",
|
||||
"opencv-python-headless>=4.12.0.88",
|
||||
"pillow>=12.1.0",
|
||||
"python-dotenv>=1.2.1",
|
||||
"requests>=2.32.5",
|
||||
"rich>=14.2.0",
|
||||
"torch>=2.5.0",
|
||||
"torchvision>=0.20.0",
|
||||
"transformers>=5.12.0",
|
||||
"ultralytics>=8.4.66",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
winnow = "winnow.cli:main"
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/sudolulo/winnow"
|
||||
|
||||
[tool.uv]
|
||||
index-strategy = "unsafe-best-match"
|
||||
conflicts = [
|
||||
[
|
||||
{ package = "onnxruntime" },
|
||||
{ package = "onnxruntime-gpu" },
|
||||
{ package = "onnxruntime-rocm" },
|
||||
],
|
||||
]
|
||||
required-environments = [
|
||||
"sys_platform == 'linux' and platform_machine == 'x86_64'",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
torch = [
|
||||
{ index = "pytorch-rocm63", marker = "sys_platform == 'linux' and platform_machine == 'x86_64'" },
|
||||
]
|
||||
torchvision = [
|
||||
{ index = "pytorch-rocm63", marker = "sys_platform == 'linux' and platform_machine == 'x86_64'" },
|
||||
]
|
||||
|
||||
[[tool.uv.index]]
|
||||
name = "pytorch-rocm63"
|
||||
url = "https://download.pytorch.org/whl/rocm6.3"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"ruff>=0.15.17",
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["winnow"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py313"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I"]
|
||||
|
||||
[tool.deptry]
|
||||
pep621_dev_dependency_groups = ["dev"]
|
||||
|
||||
[tool.deptry.package_module_name_map]
|
||||
pillow = "PIL"
|
||||
opencv-python-headless = "cv2"
|
||||
python-dotenv = "dotenv"
|
||||
insightface = "insightface"
|
||||
numpy = "numpy"
|
||||
onnxruntime-rocm = "onnxruntime"
|
||||
requests = "requests"
|
||||
rich = "rich"
|
||||
torch = "torch"
|
||||
transformers = "transformers"
|
||||
ultralytics = "ultralytics"
|
||||
|
||||
[tool.deptry.per_rule_ignores]
|
||||
DEP002 = ["onnxruntime-rocm"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
+12
-4
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "winnow"
|
||||
version = "0.2.9"
|
||||
description = "Immich to Frigate training sets"
|
||||
version = "0.3.2"
|
||||
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"
|
||||
authors = [{ name = "Holden Salomon", email = "holden@arch.fyi" }]
|
||||
@@ -9,6 +9,7 @@ keywords = ["immich", "frigate", "face-recognition", "training-data", "arcface",
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"Intended Audience :: System Administrators",
|
||||
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Scientific/Engineering :: Image Recognition",
|
||||
@@ -28,7 +29,7 @@ dependencies = [
|
||||
"rich>=14.2.0",
|
||||
"torch>=2.12.0",
|
||||
"torchvision>=0.27.0",
|
||||
"transformers>=4.57.6",
|
||||
"transformers>=5.12.0",
|
||||
"ultralytics>=8.4.66",
|
||||
]
|
||||
|
||||
@@ -37,9 +38,16 @@ winnow = "winnow.cli:main"
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/sudolulo/winnow"
|
||||
Changelog = "https://github.com/sudolulo/winnow/blob/main/CHANGELOG.md"
|
||||
Documentation = "https://github.com/sudolulo/winnow/wiki"
|
||||
|
||||
[tool.uv]
|
||||
override-dependencies = ["onnxruntime>=1.23.2"]
|
||||
conflicts = [
|
||||
[
|
||||
{ package = "onnxruntime" },
|
||||
{ package = "onnxruntime-gpu" },
|
||||
],
|
||||
]
|
||||
required-environments = [
|
||||
"sys_platform == 'linux' and platform_machine == 'x86_64'",
|
||||
"sys_platform == 'linux' and platform_machine == 'aarch64'",
|
||||
|
||||
+22
-28
@@ -1,7 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -9,33 +8,27 @@ from pathlib import Path
|
||||
try:
|
||||
from croniter import croniter
|
||||
except ImportError:
|
||||
print("❌ croniter not installed. Run: uv add croniter")
|
||||
print("croniter not installed. Run: uv add croniter")
|
||||
sys.exit(1)
|
||||
|
||||
# Imported at module level so models loaded during the first run stay
|
||||
# resident in memory across all subsequent scheduled runs.
|
||||
from winnow.cli import main
|
||||
|
||||
SCHEDULE = os.environ["CRON_SCHEDULE"]
|
||||
MODELS_DIR = os.environ.get("HF_HOME", "/models/huggingface")
|
||||
INSIGHTFACE_BASE = os.environ.get("INSIGHTFACE_HOME", "/models")
|
||||
|
||||
RUN_ENV = {**os.environ, "PYTHONUNBUFFERED": "1"}
|
||||
INSIGHTFACE_HOME = os.environ.get("INSIGHTFACE_HOME", "/models/.insightface")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def check_models():
|
||||
"""Log model status before each run."""
|
||||
print("📦 Checking models...", flush=True)
|
||||
buffalo = Path(INSIGHTFACE_BASE) / ".insightface" / "models" / "buffalo_l"
|
||||
if buffalo.exists():
|
||||
print(" ✅ InsightFace Buffalo_L: present", flush=True)
|
||||
else:
|
||||
print(" ⬇️ InsightFace Buffalo_L: not found — will download", flush=True)
|
||||
|
||||
def check_models() -> None:
|
||||
buffalo = Path(INSIGHTFACE_HOME) / "models" / "buffalo_l"
|
||||
hf_hub = Path(MODELS_DIR) / "hub"
|
||||
if hf_hub.exists() and any(hf_hub.iterdir()):
|
||||
print(" ✅ HuggingFace models: present", flush=True)
|
||||
else:
|
||||
print(" ⬇️ HuggingFace models: not found — will download", flush=True)
|
||||
print("🚀 Starting winnow...", flush=True)
|
||||
if not buffalo.exists():
|
||||
print(" InsightFace Buffalo_L not found — will download on first run", flush=True)
|
||||
if not (hf_hub.exists() and any(hf_hub.iterdir())):
|
||||
print(" HuggingFace models not found — will download on first run", flush=True)
|
||||
|
||||
|
||||
NOW = time.time()
|
||||
@@ -45,14 +38,15 @@ next_run = cron.get_next(float)
|
||||
while True:
|
||||
now = time.time()
|
||||
if now >= next_run:
|
||||
print(f"\n▶ [{time.strftime('%Y-%m-%d %H:%M:%S')}] Starting winnow...", flush=True)
|
||||
print(f"\n[{time.strftime('%Y-%m-%d %H:%M:%S')}] Starting winnow run...", flush=True)
|
||||
check_models()
|
||||
result = subprocess.run(["uv", "run", "winnow"], env=RUN_ENV)
|
||||
if result.returncode != 0:
|
||||
logger.error(f"winnow exited with code {result.returncode}")
|
||||
print(f"❌ winnow failed with exit code {result.returncode}", flush=True)
|
||||
else:
|
||||
print("✅ winnow completed successfully", flush=True)
|
||||
try:
|
||||
main()
|
||||
print("winnow run complete", flush=True)
|
||||
except KeyboardInterrupt:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"winnow run failed: {e}", exc_info=True)
|
||||
print(f"winnow run failed: {e}", flush=True)
|
||||
next_run = cron.get_next(float)
|
||||
time.sleep(60)
|
||||
|
||||
time.sleep(max(1, next_run - time.time()))
|
||||
|
||||
@@ -22,10 +22,11 @@ def test_config_loads_defaults(monkeypatch):
|
||||
assert cfg.BLUR_THRESHOLD == 100.0
|
||||
assert cfg.MIN_CONFIDENCE == 0.7
|
||||
assert cfg.MAX_AUTO_IMAGES == 80
|
||||
assert cfg.QUALITY_REPLACEMENT is True
|
||||
assert cfg.FACE_MARGIN == 0.15
|
||||
assert cfg.USE_FULL_RESOLUTION is True
|
||||
assert cfg.ENABLE_FACE_ALIGNMENT is True
|
||||
assert cfg.ENABLE_CACHE is False
|
||||
assert cfg.ENABLE_CACHE is True
|
||||
|
||||
_Config.reset()
|
||||
|
||||
@@ -39,6 +40,7 @@ def test_config_env_overrides(monkeypatch):
|
||||
monkeypatch.setenv("BLUR_THRESHOLD", "50.0")
|
||||
monkeypatch.setenv("MIN_CONFIDENCE", "0.9")
|
||||
monkeypatch.setenv("MAX_AUTO_IMAGES", "40")
|
||||
monkeypatch.setenv("QUALITY_REPLACEMENT", "false")
|
||||
monkeypatch.setenv("FACE_MARGIN", "0.2")
|
||||
monkeypatch.setenv("USE_FULL_RESOLUTION", "false")
|
||||
monkeypatch.setenv("ENABLE_FACE_ALIGNMENT", "false")
|
||||
@@ -54,6 +56,7 @@ def test_config_env_overrides(monkeypatch):
|
||||
assert cfg.BLUR_THRESHOLD == 50.0
|
||||
assert cfg.MIN_CONFIDENCE == 0.9
|
||||
assert cfg.MAX_AUTO_IMAGES == 40
|
||||
assert cfg.QUALITY_REPLACEMENT is False
|
||||
assert cfg.FACE_MARGIN == 0.2
|
||||
assert cfg.USE_FULL_RESOLUTION is False
|
||||
assert cfg.ENABLE_FACE_ALIGNMENT is False
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Tests for image quality filtering functions."""
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def _rgb_image(r, g, b, size=(100, 100)) -> Image.Image:
|
||||
arr = np.full((*size, 3), [r, g, b], dtype=np.uint8)
|
||||
return Image.fromarray(arr, "RGB")
|
||||
|
||||
|
||||
def _noisy_color_image(size=(100, 100)) -> Image.Image:
|
||||
"""Noisy image with a strong red channel so grayscale check passes."""
|
||||
rng = np.random.default_rng(0)
|
||||
arr = rng.integers(0, 256, (*size, 3), dtype=np.uint8)
|
||||
arr[:, :, 0] = np.clip(arr[:, :, 0].astype(int) + 80, 0, 255).astype(np.uint8)
|
||||
arr[:, :, 2] = np.clip(arr[:, :, 2].astype(int) - 80, 0, 255).astype(np.uint8)
|
||||
return Image.fromarray(arr, "RGB")
|
||||
|
||||
|
||||
# ── check_blur ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_blur_rejects_flat_image():
|
||||
from winnow.quality import check_blur
|
||||
flat = np.full((100, 100, 3), 128, dtype=np.uint8)
|
||||
passed, reason = check_blur(flat, threshold=100.0)
|
||||
assert not passed
|
||||
assert "Blurry" in reason
|
||||
|
||||
|
||||
def test_blur_passes_noisy_color_image():
|
||||
from winnow.quality import check_blur
|
||||
img = _noisy_color_image()
|
||||
passed, _ = check_blur(np.asarray(img), threshold=100.0)
|
||||
assert passed
|
||||
|
||||
|
||||
# ── check_grayscale ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_grayscale_rejects_ir_image():
|
||||
from winnow.quality import check_grayscale
|
||||
gray = np.full((100, 100, 3), 128, dtype=np.uint8)
|
||||
passed, reason = check_grayscale(gray)
|
||||
assert not passed
|
||||
assert "Grayscale" in reason
|
||||
|
||||
|
||||
def test_grayscale_passes_color_image():
|
||||
from winnow.quality import check_grayscale
|
||||
color = np.zeros((100, 100, 3), dtype=np.uint8)
|
||||
color[:, :, 0] = 200 # strong red channel
|
||||
passed, _ = check_grayscale(color)
|
||||
assert passed
|
||||
|
||||
|
||||
def test_grayscale_rejects_single_channel():
|
||||
from winnow.quality import check_grayscale
|
||||
single = np.full((100, 100, 1), 128, dtype=np.uint8)
|
||||
passed, reason = check_grayscale(single)
|
||||
assert not passed
|
||||
|
||||
|
||||
# ── check_exposure ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_exposure_rejects_black_image():
|
||||
from winnow.quality import check_exposure
|
||||
black = np.zeros((100, 100, 3), dtype=np.uint8)
|
||||
passed, reason = check_exposure(black)
|
||||
assert not passed
|
||||
assert "Underexposed" in reason
|
||||
|
||||
|
||||
def test_exposure_rejects_white_image():
|
||||
from winnow.quality import check_exposure
|
||||
white = np.full((100, 100, 3), 255, dtype=np.uint8)
|
||||
passed, reason = check_exposure(white)
|
||||
assert not passed
|
||||
assert "Overexposed" in reason
|
||||
|
||||
|
||||
def test_exposure_passes_normal_image():
|
||||
from winnow.quality import check_exposure
|
||||
mid = np.full((100, 100, 3), 128, dtype=np.uint8)
|
||||
passed, _ = check_exposure(mid)
|
||||
assert passed
|
||||
|
||||
|
||||
# ── check_face_size ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_face_size_rejects_small_face():
|
||||
from winnow.quality import check_face_size
|
||||
passed, reason = check_face_size(30, 30, min_px=50)
|
||||
assert not passed
|
||||
assert "small" in reason
|
||||
|
||||
|
||||
def test_face_size_passes_adequate_face():
|
||||
from winnow.quality import check_face_size
|
||||
passed, _ = check_face_size(100, 100, min_px=50)
|
||||
assert passed
|
||||
|
||||
|
||||
def test_face_size_rejects_if_either_dimension_small():
|
||||
from winnow.quality import check_face_size
|
||||
passed, _ = check_face_size(100, 30, min_px=50)
|
||||
assert not passed
|
||||
|
||||
|
||||
# ── check_confidence ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_confidence_rejects_low_score():
|
||||
from winnow.quality import check_confidence
|
||||
passed, reason = check_confidence(0.5, min_conf=0.7)
|
||||
assert not passed
|
||||
assert "confidence" in reason.lower()
|
||||
|
||||
|
||||
def test_confidence_passes_high_score():
|
||||
from winnow.quality import check_confidence
|
||||
passed, _ = check_confidence(0.95, min_conf=0.7)
|
||||
assert passed
|
||||
|
||||
|
||||
def test_confidence_passes_none_score():
|
||||
from winnow.quality import check_confidence
|
||||
passed, _ = check_confidence(None, min_conf=0.7)
|
||||
assert passed
|
||||
|
||||
|
||||
# ── assess_quality (integration) ─────────────────────────────────────────────
|
||||
|
||||
def test_assess_quality_passes_good_image():
|
||||
from winnow.quality import assess_quality
|
||||
img = _noisy_color_image()
|
||||
result = assess_quality(img, face_bbox=(10, 10, 110, 110), confidence=0.9)
|
||||
assert result.passed
|
||||
assert result.blur_score is not None
|
||||
assert result.blur_score > 0
|
||||
|
||||
|
||||
def test_assess_quality_blur_score_is_low_for_flat_image():
|
||||
from winnow.quality import assess_quality
|
||||
flat = _rgb_image(128, 128, 128)
|
||||
result = assess_quality(flat)
|
||||
assert result.blur_score is not None
|
||||
assert result.blur_score < 1.0
|
||||
|
||||
|
||||
def test_assess_quality_collects_multiple_failures():
|
||||
from winnow.quality import assess_quality
|
||||
black = _rgb_image(0, 0, 0)
|
||||
result = assess_quality(black, face_bbox=(0, 0, 10, 10), confidence=0.3)
|
||||
assert not result.passed
|
||||
assert len(result.reasons) >= 2
|
||||
|
||||
|
||||
def test_assess_quality_skips_face_size_without_bbox():
|
||||
from winnow.quality import assess_quality
|
||||
img = _noisy_color_image()
|
||||
result = assess_quality(img, face_bbox=None, confidence=0.9)
|
||||
assert result.passed
|
||||
@@ -69,3 +69,152 @@ def test_duplicate_marks_are_idempotent():
|
||||
mark_uploaded("dup", person_name="Alice")
|
||||
mark_uploaded("dup", person_name="Alice")
|
||||
assert filter_already_uploaded(["dup", "new"]) == ["new"]
|
||||
|
||||
|
||||
# ── frigate_files mapping ─────────────────────────────────────────────────────
|
||||
|
||||
def test_record_and_remove_frigate_file():
|
||||
from winnow.upload_tracker import get_person_summary, record_frigate_file, remove_frigate_file
|
||||
record_frigate_file("Alice", "Alice-1000.webp", "asset-a1")
|
||||
assert "Alice-1000.webp" in get_person_summary()["Alice"]["frigate_files"]
|
||||
remove_frigate_file("Alice", "Alice-1000.webp")
|
||||
assert "Alice-1000.webp" not in get_person_summary()["Alice"]["frigate_files"]
|
||||
|
||||
|
||||
def test_remove_nonexistent_frigate_file_is_safe():
|
||||
from winnow.upload_tracker import remove_frigate_file
|
||||
# Should not raise even if the file was never recorded
|
||||
remove_frigate_file("Alice", "Alice-ghost.webp")
|
||||
|
||||
|
||||
def test_remove_frigate_file_does_not_unmark_asset():
|
||||
"""Deleting a Frigate file should not re-expose the source asset for upload."""
|
||||
from winnow.upload_tracker import (
|
||||
filter_already_uploaded,
|
||||
mark_uploaded,
|
||||
record_frigate_file,
|
||||
remove_frigate_file,
|
||||
)
|
||||
mark_uploaded("asset-a1", person_name="Alice")
|
||||
record_frigate_file("Alice", "Alice-1000.webp", "asset-a1")
|
||||
remove_frigate_file("Alice", "Alice-1000.webp")
|
||||
# Asset must still be excluded — it was deliberately replaced, not lost
|
||||
assert filter_already_uploaded(["asset-a1"]) == []
|
||||
|
||||
|
||||
def test_get_tracked_frigate_file_count_zero_when_empty():
|
||||
from winnow.upload_tracker import get_tracked_frigate_file_count
|
||||
assert get_tracked_frigate_file_count("Alice") == 0
|
||||
|
||||
|
||||
def test_get_tracked_frigate_file_count_counts_only_mapped():
|
||||
"""Only files explicitly recorded via record_frigate_file count toward the cap."""
|
||||
from winnow.upload_tracker import get_tracked_frigate_file_count, mark_uploaded, record_frigate_file
|
||||
mark_uploaded("asset-a", person_name="Alice")
|
||||
mark_uploaded("asset-b", person_name="Alice")
|
||||
record_frigate_file("Alice", "Alice-1000.webp", "asset-a")
|
||||
# asset-b is uploaded but not yet mapped — does not count
|
||||
assert get_tracked_frigate_file_count("Alice") == 1
|
||||
record_frigate_file("Alice", "Alice-1001.webp", "asset-b")
|
||||
assert get_tracked_frigate_file_count("Alice") == 2
|
||||
|
||||
|
||||
def test_get_lowest_quality_mapped_file_none_when_empty():
|
||||
from winnow.upload_tracker import get_lowest_quality_mapped_file
|
||||
assert get_lowest_quality_mapped_file("Alice") is None
|
||||
|
||||
|
||||
def test_get_lowest_quality_mapped_file_returns_lowest():
|
||||
from winnow.upload_tracker import (
|
||||
get_lowest_quality_mapped_file,
|
||||
mark_uploaded,
|
||||
record_frigate_file,
|
||||
)
|
||||
mark_uploaded("asset-hi", person_name="Alice", score=0.95)
|
||||
mark_uploaded("asset-lo", person_name="Alice", score=0.71)
|
||||
record_frigate_file("Alice", "Alice-1000.webp", "asset-hi")
|
||||
record_frigate_file("Alice", "Alice-1001.webp", "asset-lo")
|
||||
result = get_lowest_quality_mapped_file("Alice")
|
||||
assert result is not None
|
||||
frigate_filename, asset_id, score = result
|
||||
assert frigate_filename == "Alice-1001.webp"
|
||||
assert asset_id == "asset-lo"
|
||||
assert score == pytest.approx(0.71, abs=0.001)
|
||||
|
||||
|
||||
def test_get_lowest_quality_mapped_file_skips_unscored():
|
||||
"""Files mapped without a score should not be returned as candidates."""
|
||||
from winnow.upload_tracker import (
|
||||
get_lowest_quality_mapped_file,
|
||||
mark_uploaded,
|
||||
record_frigate_file,
|
||||
)
|
||||
mark_uploaded("asset-scored", person_name="Alice", score=0.85)
|
||||
mark_uploaded("asset-noscr", person_name="Alice")
|
||||
record_frigate_file("Alice", "Alice-1000.webp", "asset-scored")
|
||||
record_frigate_file("Alice", "Alice-1001.webp", "asset-noscr")
|
||||
result = get_lowest_quality_mapped_file("Alice")
|
||||
assert result is not None
|
||||
assert result[1] == "asset-scored" # only scored file is a candidate
|
||||
|
||||
|
||||
# ── get_tracked_frigate_filenames ─────────────────────────────────────────────
|
||||
|
||||
def test_get_tracked_frigate_filenames_empty():
|
||||
from winnow.upload_tracker import get_tracked_frigate_filenames
|
||||
assert get_tracked_frigate_filenames("Alice") == set()
|
||||
|
||||
|
||||
def test_get_tracked_frigate_filenames_returns_mapped():
|
||||
from winnow.upload_tracker import get_tracked_frigate_filenames, record_frigate_file
|
||||
record_frigate_file("Alice", "Alice-1000.webp", "asset-a")
|
||||
record_frigate_file("Alice", "Alice-1001.webp", "asset-b")
|
||||
assert get_tracked_frigate_filenames("Alice") == {"Alice-1000.webp", "Alice-1001.webp"}
|
||||
|
||||
|
||||
def test_get_tracked_frigate_filenames_excludes_removed():
|
||||
from winnow.upload_tracker import (
|
||||
get_tracked_frigate_filenames,
|
||||
record_frigate_file,
|
||||
remove_frigate_file,
|
||||
)
|
||||
record_frigate_file("Alice", "Alice-1000.webp", "asset-a")
|
||||
record_frigate_file("Alice", "Alice-1001.webp", "asset-b")
|
||||
remove_frigate_file("Alice", "Alice-1000.webp")
|
||||
assert get_tracked_frigate_filenames("Alice") == {"Alice-1001.webp"}
|
||||
|
||||
|
||||
def test_get_tracked_frigate_filenames_isolated_by_person():
|
||||
from winnow.upload_tracker import get_tracked_frigate_filenames, record_frigate_file
|
||||
record_frigate_file("Alice", "Alice-1000.webp", "asset-a")
|
||||
record_frigate_file("Bob", "Bob-2000.webp", "asset-b")
|
||||
assert get_tracked_frigate_filenames("Alice") == {"Alice-1000.webp"}
|
||||
assert get_tracked_frigate_filenames("Bob") == {"Bob-2000.webp"}
|
||||
|
||||
|
||||
# ── get_lowest_quality_mapped_file with exclude ───────────────────────────────
|
||||
|
||||
def test_get_lowest_quality_exclude_skips_specified_file():
|
||||
from winnow.upload_tracker import (
|
||||
get_lowest_quality_mapped_file,
|
||||
mark_uploaded,
|
||||
record_frigate_file,
|
||||
)
|
||||
mark_uploaded("asset-lo", person_name="Alice", score=0.10)
|
||||
mark_uploaded("asset-hi", person_name="Alice", score=0.90)
|
||||
record_frigate_file("Alice", "Alice-lo.webp", "asset-lo")
|
||||
record_frigate_file("Alice", "Alice-hi.webp", "asset-hi")
|
||||
result = get_lowest_quality_mapped_file("Alice", exclude={"Alice-lo.webp"})
|
||||
assert result is not None
|
||||
assert result[1] == "asset-hi" # lo was excluded; hi is returned
|
||||
|
||||
|
||||
def test_get_lowest_quality_exclude_all_returns_none():
|
||||
from winnow.upload_tracker import (
|
||||
get_lowest_quality_mapped_file,
|
||||
mark_uploaded,
|
||||
record_frigate_file,
|
||||
)
|
||||
mark_uploaded("asset-a", person_name="Alice", score=0.50)
|
||||
record_frigate_file("Alice", "Alice-a.webp", "asset-a")
|
||||
assert get_lowest_quality_mapped_file("Alice", exclude={"Alice-a.webp"}) is None
|
||||
|
||||
+1884
File diff suppressed because it is too large
Load Diff
+1941
File diff suppressed because it is too large
Load Diff
+1933
File diff suppressed because it is too large
Load Diff
+6
-1
@@ -5,4 +5,9 @@ Immich library for Frigate's Face Recognition (ArcFace) and Object/State
|
||||
Classification models.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
|
||||
try:
|
||||
__version__ = version("winnow")
|
||||
except PackageNotFoundError:
|
||||
__version__ = "unknown"
|
||||
|
||||
+45
-6
@@ -2,6 +2,7 @@
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
from rich import print as rprint
|
||||
from rich.prompt import Confirm
|
||||
@@ -10,16 +11,53 @@ from .config import Config, ConfigManager
|
||||
from .executor import execute_jobs, upload_to_frigate
|
||||
from .immich_api import get_people
|
||||
from .jobs import _show_preview, auto_configure, interactive_configure
|
||||
from .logging import console, setup_logging
|
||||
from .upload_tracker import get_person_summary, reset_person
|
||||
from .log_config import console, setup_logging
|
||||
from .upload_tracker import find_by_crop_dimension, get_person_summary, reset_person
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _handle_trace_crop(size_str: str) -> None:
|
||||
"""Print tracker records whose crop dimension matches the given pixel size and exit."""
|
||||
try:
|
||||
size = int(size_str)
|
||||
except ValueError:
|
||||
rprint(f"[bold red]TRACE_CROP_SIZE must be an integer, got: {size_str!r}[/bold red]")
|
||||
sys.exit(1)
|
||||
|
||||
immich_url = os.environ.get("IMMICH_URL", "").rstrip("/")
|
||||
matches = find_by_crop_dimension(size)
|
||||
if not matches:
|
||||
rprint(f"[yellow]No crops with dimension {size}px found in tracker.[/yellow]")
|
||||
rprint("[dim]Note: crop dimensions are only recorded for uploads made after this feature was added.[/dim]")
|
||||
sys.exit(0)
|
||||
|
||||
rprint(f"\n[bold]Crops matching dimension {size}px:[/bold] ({len(matches)} found)\n")
|
||||
for m in matches:
|
||||
rprint(f" [bold cyan]{m['person']}[/bold cyan]")
|
||||
rprint(f" Dimensions: {m['width']}×{m['height']}px")
|
||||
rprint(f" Asset ID: {m['asset_id']}")
|
||||
if immich_url:
|
||||
rprint(f" Immich URL: {immich_url}/photos/{m['asset_id']}")
|
||||
blur = m.get("blur_score")
|
||||
rprint(f" Blur score: {blur:.1f}" if blur is not None else " Blur score: unknown")
|
||||
if m.get("frigate_filename"):
|
||||
rprint(f" Frigate file: {m['frigate_filename']}")
|
||||
else:
|
||||
rprint(" Frigate file: [dim]unmapped (reconciliation race)[/dim]")
|
||||
rprint()
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Entry point for winnow CLI."""
|
||||
try:
|
||||
setup_logging(verbose=False)
|
||||
verbose = os.environ.get("VERBOSE", "").lower() in ("true", "1", "yes")
|
||||
setup_logging(verbose=verbose)
|
||||
|
||||
trace_size = os.environ.get("TRACE_CROP_SIZE", "").strip()
|
||||
if trace_size:
|
||||
_handle_trace_crop(trace_size)
|
||||
|
||||
console.print(r"""
|
||||
[bold blue]winnow[/bold blue]
|
||||
@@ -63,17 +101,18 @@ def main() -> None:
|
||||
rprint("[bold red]Could not fetch people from Immich. Check URL/Key.[/bold red]")
|
||||
return
|
||||
|
||||
# Check for non-interactive mode
|
||||
auto_mode = os.environ.get("AUTO_MODE", "false").lower() == "true"
|
||||
# Auto mode when no TTY (Docker, cron, pipes) — the primary use case.
|
||||
# A TTY means local interactive use; AUTO_MODE=true overrides that for scripting.
|
||||
auto_mode = not sys.stdin.isatty() or os.environ.get("AUTO_MODE", "").lower() in ("true", "1", "yes")
|
||||
dry_run = os.environ.get("DRY_RUN", "false").lower() in ("true", "1", "yes")
|
||||
|
||||
if dry_run:
|
||||
rprint("[bold yellow]DRY RUN — no images will be downloaded or uploaded[/bold yellow]")
|
||||
|
||||
if auto_mode:
|
||||
rprint("[bold cyan]Running in AUTO mode (non-interactive)[/bold cyan]")
|
||||
jobs = auto_configure(people)
|
||||
else:
|
||||
rprint("[bold cyan]Interactive mode — set AUTO_MODE=true to skip prompts[/bold cyan]")
|
||||
jobs = interactive_configure(people)
|
||||
|
||||
if jobs:
|
||||
|
||||
+10
-4
@@ -30,6 +30,7 @@ class _Config:
|
||||
BLUR_THRESHOLD: float = 100.0
|
||||
MIN_CONFIDENCE: float = 0.7
|
||||
MAX_AUTO_IMAGES: int = 80
|
||||
QUALITY_REPLACEMENT: bool = True
|
||||
|
||||
# People filtering
|
||||
MIN_FACE_COUNT: int = 0
|
||||
@@ -39,8 +40,7 @@ class _Config:
|
||||
USE_FULL_RESOLUTION: bool = True
|
||||
ENABLE_FACE_ALIGNMENT: bool = True
|
||||
|
||||
# Caching (opt-in to avoid unexpected files)
|
||||
ENABLE_CACHE: bool = False
|
||||
ENABLE_CACHE: bool = True
|
||||
CACHE_DIR: str = ".if_cache"
|
||||
|
||||
def __new__(cls) -> "_Config":
|
||||
@@ -61,10 +61,11 @@ class _Config:
|
||||
self.BLUR_THRESHOLD = float(os.getenv("BLUR_THRESHOLD", "100.0"))
|
||||
self.MIN_CONFIDENCE = float(os.getenv("MIN_CONFIDENCE", "0.7"))
|
||||
self.MAX_AUTO_IMAGES = int(os.getenv("MAX_AUTO_IMAGES", "80"))
|
||||
self.QUALITY_REPLACEMENT = os.getenv("QUALITY_REPLACEMENT", "true").lower() in ("true", "1", "yes")
|
||||
self.FACE_MARGIN = float(os.getenv("FACE_MARGIN", "0.15"))
|
||||
self.USE_FULL_RESOLUTION = os.getenv("USE_FULL_RESOLUTION", "true").lower() in ("true", "1", "yes")
|
||||
self.ENABLE_FACE_ALIGNMENT = os.getenv("ENABLE_FACE_ALIGNMENT", "true").lower() in ("true", "1", "yes")
|
||||
self.ENABLE_CACHE = os.getenv("ENABLE_CACHE", "false").lower() in ("true", "1", "yes")
|
||||
self.ENABLE_CACHE = os.getenv("ENABLE_CACHE", "true").lower() in ("true", "1", "yes")
|
||||
self.CACHE_DIR = os.getenv("CACHE_DIR", ".if_cache")
|
||||
|
||||
# Fall back to config file for non-sensitive values (API_KEY not stored here)
|
||||
@@ -161,7 +162,12 @@ class _ConfigAccessor:
|
||||
|
||||
|
||||
Config = _ConfigAccessor()
|
||||
ConfigManager = type("ConfigManager", (), {"get": staticmethod(lambda: _Config())})
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
@staticmethod
|
||||
def get() -> _Config:
|
||||
return _Config()
|
||||
|
||||
|
||||
def get_headers() -> dict[str, str]:
|
||||
|
||||
+80
-60
@@ -29,6 +29,7 @@ def select_diverse_assets(
|
||||
entity_name: str,
|
||||
selection_mode: str = "smart",
|
||||
entity_type: str = "face",
|
||||
person_id: str | None = None,
|
||||
progress_callback=None,
|
||||
) -> list:
|
||||
"""
|
||||
@@ -59,7 +60,7 @@ def select_diverse_assets(
|
||||
return _select_time_spread(assets, limit)
|
||||
|
||||
try:
|
||||
return _select_by_embedding(assets, limit, entity_type, progress_callback)
|
||||
return _select_by_embedding(assets, limit, entity_type, person_id, progress_callback)
|
||||
except Exception as e:
|
||||
logger.error(f"Smart Diversity failed: {e}. Falling back to time spread.")
|
||||
return _select_time_spread(assets, limit)
|
||||
@@ -80,9 +81,11 @@ def _fetch_thumbnail(asset_id: str, timeout: int = 10) -> Image.Image | None:
|
||||
return None
|
||||
|
||||
|
||||
def _get_face_bbox(asset: dict) -> tuple[float, float, float, float] | None:
|
||||
"""Extract face bounding box from asset metadata if available."""
|
||||
def _get_face_bbox(asset: dict, person_id: str | None = None) -> tuple[float, float, float, float] | None:
|
||||
"""Extract face bounding box from asset metadata for the given person."""
|
||||
for person in asset.get("people", []):
|
||||
if person_id and person.get("id") != person_id:
|
||||
continue
|
||||
faces = person.get("faces", [])
|
||||
if faces:
|
||||
f = faces[0]
|
||||
@@ -95,12 +98,16 @@ def _get_face_bbox(asset: dict) -> tuple[float, float, float, float] | None:
|
||||
return None
|
||||
|
||||
|
||||
def _get_face_confidence(asset: dict) -> float | None:
|
||||
"""Extract face detection confidence from asset metadata if available."""
|
||||
def _get_face_confidence(asset: dict, person_id: str | None = None) -> float | None:
|
||||
"""Extract face detection confidence from asset metadata for the given person."""
|
||||
for person in asset.get("people", []):
|
||||
if person_id and person.get("id") != person_id:
|
||||
continue
|
||||
faces = person.get("faces", [])
|
||||
if faces:
|
||||
return faces[0].get("score") or faces[0].get("confidence")
|
||||
f = faces[0]
|
||||
score = f.get("score")
|
||||
return score if score is not None else f.get("confidence")
|
||||
return None
|
||||
|
||||
|
||||
@@ -108,6 +115,7 @@ def _crop_face_from_thumbnail(
|
||||
img: Image.Image,
|
||||
asset: dict,
|
||||
margin: float = 0.25,
|
||||
person_id: str | None = None,
|
||||
) -> Image.Image | None:
|
||||
"""Crop the face region from a thumbnail using Immich bbox metadata.
|
||||
|
||||
@@ -118,19 +126,22 @@ def _crop_face_from_thumbnail(
|
||||
img: Full preview thumbnail
|
||||
asset: Asset dict with people/faces metadata
|
||||
margin: Extra margin around the bbox (fraction, default 25%)
|
||||
person_id: If provided, only crop from this person's face data.
|
||||
|
||||
Returns:
|
||||
Cropped face PIL image, or None if no face metadata available
|
||||
"""
|
||||
bbox = _get_face_bbox(asset)
|
||||
bbox = _get_face_bbox(asset, person_id=person_id)
|
||||
if bbox is None:
|
||||
return None
|
||||
|
||||
x1, y1, x2, y2 = bbox
|
||||
img_w, img_h = img.size
|
||||
|
||||
# Get metadata dimensions to scale bbox
|
||||
# Get metadata dimensions to scale bbox — must match the same person as _get_face_bbox
|
||||
for person in asset.get("people", []):
|
||||
if person_id and person.get("id") != person_id:
|
||||
continue
|
||||
faces = person.get("faces", [])
|
||||
if faces:
|
||||
meta_w = faces[0].get("imageWidth") or img_w
|
||||
@@ -169,6 +180,7 @@ def _select_by_embedding(
|
||||
assets: list,
|
||||
limit: int | str,
|
||||
entity_type: str,
|
||||
person_id: str | None = None,
|
||||
progress_callback=None,
|
||||
) -> list:
|
||||
"""Select assets using embedding-based cluster-aware FPS.
|
||||
@@ -191,64 +203,72 @@ def _select_by_embedding(
|
||||
else:
|
||||
candidates = assets
|
||||
|
||||
# --- Phase 1: Concurrent thumbnail download ---
|
||||
# --- Phases 1-4: Batched download → quality filter → crop → embed ---
|
||||
# Process in bounded batches so at most _BATCH decoded images live in RAM
|
||||
# at once. With 472 candidates each thumbnail is ~3-8 MB decoded; loading
|
||||
# all at once easily exhausts a 4 GB container limit on CPU.
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
thumbnail_map: dict[str, Image.Image] = {}
|
||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||
futures = {pool.submit(_fetch_thumbnail, a["id"]): a for a in candidates}
|
||||
for i, future in enumerate(as_completed(futures)):
|
||||
if progress_callback:
|
||||
progress_callback(i, len(candidates))
|
||||
asset = futures[future]
|
||||
try:
|
||||
img = future.result()
|
||||
if img is not None:
|
||||
thumbnail_map[asset["id"]] = img
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# --- Phase 2-4: Quality filter → Crop → Embed ---
|
||||
_BATCH = 32
|
||||
embeddings, valid_candidates, confidence_scores = [], [], []
|
||||
quality_filtered = 0
|
||||
processed = 0
|
||||
|
||||
for asset in candidates:
|
||||
img = thumbnail_map.get(asset["id"])
|
||||
if img is None:
|
||||
continue
|
||||
for batch_start in range(0, len(candidates), _BATCH):
|
||||
batch = candidates[batch_start : batch_start + _BATCH]
|
||||
|
||||
confidence = _get_face_confidence(asset)
|
||||
# Download this batch concurrently
|
||||
batch_images: dict[str, Image.Image] = {}
|
||||
with ThreadPoolExecutor(max_workers=min(8, len(batch))) as pool:
|
||||
futures = {pool.submit(_fetch_thumbnail, a["id"]): a for a in batch}
|
||||
for future in as_completed(futures):
|
||||
asset = futures[future]
|
||||
try:
|
||||
img = future.result()
|
||||
if img is not None:
|
||||
batch_images[asset["id"]] = img
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to fetch thumbnail for {asset['id']}: {e}")
|
||||
continue
|
||||
|
||||
# Quality gate: filter before expensive embedding computation
|
||||
if entity_type == "face":
|
||||
face_bbox = _get_face_bbox(asset)
|
||||
quality = assess_quality(
|
||||
img,
|
||||
face_bbox=face_bbox,
|
||||
confidence=confidence,
|
||||
blur_threshold=Config.BLUR_THRESHOLD,
|
||||
min_face_px=Config.MIN_FACE_WIDTH,
|
||||
min_confidence=Config.MIN_CONFIDENCE,
|
||||
)
|
||||
if not quality.passed:
|
||||
quality_filtered += 1
|
||||
logger.debug(f"Quality filtered {asset['id']}: {quality.reason}")
|
||||
# Process each image; batch_images goes out of scope after this loop,
|
||||
# bounding peak thumbnail memory to _BATCH images per iteration.
|
||||
for asset in batch:
|
||||
img = batch_images.get(asset["id"])
|
||||
processed += 1
|
||||
if progress_callback:
|
||||
progress_callback(processed, len(candidates))
|
||||
if img is None:
|
||||
continue
|
||||
|
||||
# Crop the target person's face before embedding
|
||||
face_crop = _crop_face_from_thumbnail(img, asset)
|
||||
embed_img = face_crop if face_crop is not None else img
|
||||
else:
|
||||
embed_img = img
|
||||
confidence = _get_face_confidence(asset, person_id=person_id)
|
||||
|
||||
emb = get_embedding(embed_img, entity_type, asset_id=asset["id"])
|
||||
if emb is not None:
|
||||
embeddings.append(emb)
|
||||
valid_candidates.append(asset)
|
||||
confidence_scores.append(confidence)
|
||||
if entity_type == "face":
|
||||
face_bbox = _get_face_bbox(asset, person_id=person_id)
|
||||
quality = assess_quality(
|
||||
img,
|
||||
face_bbox=face_bbox,
|
||||
confidence=confidence,
|
||||
blur_threshold=Config.BLUR_THRESHOLD,
|
||||
min_face_px=Config.MIN_FACE_WIDTH,
|
||||
min_confidence=Config.MIN_CONFIDENCE,
|
||||
)
|
||||
if not quality.passed:
|
||||
quality_filtered += 1
|
||||
logger.debug(f"Quality filtered {asset['id']}: {quality.reason}")
|
||||
continue
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(len(candidates), len(candidates))
|
||||
asset["quality_score"] = quality.blur_score
|
||||
face_crop = _crop_face_from_thumbnail(img, asset, person_id=person_id)
|
||||
embed_img = face_crop if face_crop is not None else img
|
||||
else:
|
||||
embed_img = img
|
||||
|
||||
emb = get_embedding(embed_img, entity_type, asset_id=asset["id"])
|
||||
if emb is not None:
|
||||
embeddings.append(emb)
|
||||
valid_candidates.append(asset)
|
||||
confidence_scores.append(confidence)
|
||||
|
||||
if quality_filtered > 0:
|
||||
logger.info(f"Quality filtering removed {quality_filtered} images.")
|
||||
@@ -360,7 +380,7 @@ def _compute_adaptive_threshold(emb_normed: np.ndarray, entity_type: str) -> flo
|
||||
fraction = 0.20 if entity_type == "face" else 0.10
|
||||
threshold = max(0.05, median_dist * fraction)
|
||||
|
||||
logger.info(
|
||||
logger.debug(
|
||||
f"Adaptive threshold: {threshold:.4f} "
|
||||
f"(median_dist={median_dist:.4f}, fraction={fraction}, type={entity_type})"
|
||||
)
|
||||
@@ -402,7 +422,7 @@ def _cluster_aware_selection(
|
||||
|
||||
# --- Stage 1: K-Medoids clustering ---
|
||||
k = min(max(5, target // 4), n // 3, n) # e.g., 5-20 clusters
|
||||
logger.info(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
|
||||
dist_matrix = 1 - emb_normed @ emb_normed.T
|
||||
@@ -411,7 +431,7 @@ def _cluster_aware_selection(
|
||||
selected = list(medoid_indices)
|
||||
selected_set = set(selected)
|
||||
|
||||
logger.info(f"Selected {len(selected)} cluster medoids as initial picks.")
|
||||
logger.debug(f"Selected {len(selected)} cluster medoids as initial picks.")
|
||||
|
||||
# --- Stage 2: FPS with hard example weighting ---
|
||||
min_dists = np.full(n, np.inf)
|
||||
@@ -436,8 +456,8 @@ def _cluster_aware_selection(
|
||||
break # All points selected
|
||||
|
||||
if limit == "auto" and best_dist < auto_threshold:
|
||||
logger.info(
|
||||
f"Auto-stop: Next best image {best_dist:.3f} away " f"(adaptive threshold {auto_threshold:.4f})."
|
||||
logger.debug(
|
||||
f"Auto-stop: next best image {best_dist:.3f} away (adaptive threshold {auto_threshold:.4f})."
|
||||
)
|
||||
break
|
||||
|
||||
|
||||
+111
-37
@@ -6,11 +6,13 @@ Unified embedding interface for faces and objects.
|
||||
- Caching: Disk-based cache avoids recomputation on reruns
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import warnings
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -20,6 +22,26 @@ from .cache import get_cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _suppress_output():
|
||||
"""Suppress stdout/stderr at the file-descriptor level, silencing C extension noise."""
|
||||
devnull_fd = os.open(os.devnull, os.O_WRONLY)
|
||||
saved_out, saved_err = os.dup(1), os.dup(2)
|
||||
try:
|
||||
os.dup2(devnull_fd, 1)
|
||||
os.dup2(devnull_fd, 2)
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
os.dup2(saved_out, 1)
|
||||
finally:
|
||||
os.dup2(saved_err, 2)
|
||||
os.close(devnull_fd)
|
||||
os.close(saved_out)
|
||||
os.close(saved_err)
|
||||
|
||||
|
||||
# Lazy-loaded singletons
|
||||
_insightface_app = None
|
||||
_insightface_loaded = False
|
||||
@@ -37,18 +59,14 @@ def _preload_cuda_libs() -> None:
|
||||
"""Preload CUDA/cuDNN DLLs so onnxruntime-gpu registers CUDAExecutionProvider.
|
||||
|
||||
Starting with onnxruntime-gpu 1.19+, CUDA/cuDNN libraries are no longer
|
||||
bundled inside the ORT package. They must be loaded from the nvidia-*
|
||||
pip packages (nvidia-cuda-runtime-cu12, nvidia-cudnn-cu12) before any
|
||||
InferenceSession is created.
|
||||
|
||||
Calling preload_dlls() with directory="" searches NVIDIA site-packages
|
||||
directories automatically.
|
||||
bundled inside the ORT package — they come from the nvidia-* pip packages.
|
||||
preload_dlls() locates them automatically via site-packages discovery.
|
||||
"""
|
||||
try:
|
||||
import onnxruntime
|
||||
if hasattr(onnxruntime, "preload_dlls"):
|
||||
onnxruntime.preload_dlls(cuda=True, cudnn=True, directory="")
|
||||
logger.info("Preloaded CUDA/cuDNN DLLs for onnxruntime-gpu")
|
||||
onnxruntime.preload_dlls(cuda=True, cudnn=True)
|
||||
logger.debug("Preloaded CUDA/cuDNN DLLs for onnxruntime-gpu")
|
||||
else:
|
||||
logger.debug("onnxruntime.preload_dlls() not available (ORT < 1.21)")
|
||||
except Exception as e:
|
||||
@@ -67,35 +85,69 @@ def get_insightface_app():
|
||||
return _insightface_app
|
||||
_insightface_loaded = True
|
||||
|
||||
# Preload CUDA/cuDNN DLLs BEFORE any ORT InferenceSession is created
|
||||
_preload_cuda_libs()
|
||||
|
||||
ctx_id = -1
|
||||
insightface_home = os.environ.get("INSIGHTFACE_HOME", os.path.expanduser("~/.insightface"))
|
||||
try:
|
||||
import onnxruntime as ort
|
||||
from insightface.app import FaceAnalysis
|
||||
|
||||
# Preload CUDA/cuDNN DLLs before any ORT InferenceSession is created.
|
||||
# Silently no-ops on ROCm/Intel builds where preload_dlls() is absent.
|
||||
_preload_cuda_libs()
|
||||
|
||||
# Disk cache check — lets the user know whether a download is coming
|
||||
buffalo_path = Path(insightface_home) / "models" / "buffalo_l"
|
||||
if buffalo_path.exists() and any(buffalo_path.iterdir()):
|
||||
logger.info("InsightFace Buffalo_L: found in model cache")
|
||||
else:
|
||||
logger.info("InsightFace Buffalo_L: not cached — downloading now (~300 MB)")
|
||||
|
||||
# Get providers, excluding TensorRT to avoid noisy errors
|
||||
providers = [p for p in ort.get_available_providers() if p != "TensorrtExecutionProvider"]
|
||||
logger.info(f"Available ONNX providers: {providers}")
|
||||
logger.debug(f"ONNX providers available: {providers}")
|
||||
|
||||
# Determine device: 0 for GPU, -1 for CPU
|
||||
gpu_providers = {
|
||||
"CUDAExecutionProvider",
|
||||
"ROCmExecutionProvider",
|
||||
"MPSExecutionProvider",
|
||||
"CoreMLExecutionProvider",
|
||||
"OpenVINOExecutionProvider",
|
||||
}
|
||||
ctx_id = -1 if _is_force_cpu() else (0 if gpu_providers & set(providers) else -1)
|
||||
has_gpu_provider = bool(gpu_providers & set(providers))
|
||||
ctx_id = -1 if _is_force_cpu() else (0 if has_gpu_provider else -1)
|
||||
|
||||
device_str = "GPU" if ctx_id >= 0 else "CPU"
|
||||
logger.info(f"Loading InsightFace Buffalo_L on {device_str} (ctx_id={ctx_id})...")
|
||||
# For OpenVINO EP, inject device_type from env var (default CPU; set GPU for Intel Arc/iGPU)
|
||||
has_openvino = "OpenVINOExecutionProvider" in providers
|
||||
if has_openvino:
|
||||
openvino_device = os.getenv("OPENVINO_DEVICE", "CPU")
|
||||
providers = [
|
||||
("OpenVINOExecutionProvider", {"device_type": openvino_device})
|
||||
if p == "OpenVINOExecutionProvider" else p
|
||||
for p in providers
|
||||
]
|
||||
logger.debug(f"OpenVINO EP: device_type={openvino_device}")
|
||||
|
||||
# Suppress C-level output during model loading
|
||||
with open(os.devnull, "w") as devnull, contextlib.redirect_stdout(devnull), contextlib.redirect_stderr(devnull):
|
||||
insightface_home = os.environ.get("INSIGHTFACE_HOME", os.path.expanduser("~/.insightface"))
|
||||
if not has_gpu_provider and not _is_force_cpu():
|
||||
logger.warning(
|
||||
"No GPU execution provider found — running InsightFace on CPU. "
|
||||
"Ensure the container has GPU access and the correct variant image is used "
|
||||
"(gpu for NVIDIA, rocm for AMD, intel for Intel Arc/iGPU)."
|
||||
)
|
||||
|
||||
if ctx_id < 0:
|
||||
device_str = "CPU"
|
||||
elif has_openvino:
|
||||
device_str = f"OpenVINO ({os.getenv('OPENVINO_DEVICE', 'CPU')})"
|
||||
else:
|
||||
device_str = "GPU"
|
||||
logger.info(f"InsightFace Buffalo_L: loading into memory on {device_str}...")
|
||||
|
||||
t0 = time.time()
|
||||
with _suppress_output():
|
||||
_insightface_app = FaceAnalysis(name="buffalo_l", root=insightface_home, providers=providers)
|
||||
_insightface_app.prepare(ctx_id=ctx_id, det_size=(640, 640))
|
||||
|
||||
logger.info(f"InsightFace Buffalo_L: ready on {device_str} ({time.time() - t0:.1f}s)")
|
||||
return _insightface_app
|
||||
|
||||
except ImportError:
|
||||
@@ -103,17 +155,23 @@ def get_insightface_app():
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load InsightFace: {e}")
|
||||
# Retry on CPU if GPU failed
|
||||
if ctx_id == 0:
|
||||
logger.warning("Retrying InsightFace on CPU...")
|
||||
logger.warning("InsightFace GPU load failed — retrying on CPU...")
|
||||
try:
|
||||
from insightface.app import FaceAnalysis
|
||||
|
||||
_insightface_app = FaceAnalysis(name="buffalo_l", root=insightface_home)
|
||||
_insightface_app.prepare(ctx_id=-1, det_size=(640, 640))
|
||||
t0 = time.time()
|
||||
with _suppress_output():
|
||||
_insightface_app = FaceAnalysis(
|
||||
name="buffalo_l",
|
||||
root=insightface_home,
|
||||
providers=["CPUExecutionProvider"],
|
||||
)
|
||||
_insightface_app.prepare(ctx_id=-1, det_size=(640, 640))
|
||||
logger.info(f"InsightFace Buffalo_L: ready on CPU (fallback, {time.time() - t0:.1f}s)")
|
||||
return _insightface_app
|
||||
except Exception as ex:
|
||||
logger.error(f"CPU fallback failed: {ex}")
|
||||
logger.error(f"InsightFace CPU fallback failed: {ex}")
|
||||
return None
|
||||
|
||||
|
||||
@@ -162,7 +220,18 @@ def get_siglip_model():
|
||||
from transformers import AutoImageProcessor, SiglipVisionModel
|
||||
|
||||
model_name = "google/siglip-base-patch16-224"
|
||||
logger.info(f"Loading SigLIP model ({model_name})...")
|
||||
|
||||
# Disk cache check — path derived from model_name using HuggingFace's slug convention
|
||||
hf_home = os.environ.get("HF_HOME", os.path.join(os.path.expanduser("~"), ".cache", "huggingface"))
|
||||
cache_slug = "models--" + model_name.replace("/", "--")
|
||||
model_cache = Path(hf_home) / "hub" / cache_slug
|
||||
if model_cache.exists() and any(model_cache.iterdir()):
|
||||
logger.info(f"SigLIP {model_name}: found in model cache")
|
||||
else:
|
||||
logger.info(f"SigLIP {model_name}: not cached — downloading now (~380 MB)")
|
||||
|
||||
logger.info(f"SigLIP {model_name}: loading into memory...")
|
||||
t0 = time.time()
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings("ignore", category=FutureWarning)
|
||||
@@ -172,19 +241,23 @@ def get_siglip_model():
|
||||
|
||||
_siglip_model.eval()
|
||||
|
||||
# Move to GPU if available
|
||||
# Move to GPU if available (ROCm builds expose torch.cuda.is_available() == True)
|
||||
if not _is_force_cpu():
|
||||
if torch.cuda.is_available():
|
||||
_siglip_model = _siglip_model.cuda()
|
||||
logger.info("SigLIP running on CUDA GPU")
|
||||
device_name = "CUDA GPU"
|
||||
elif hasattr(torch, "xpu") and torch.xpu.is_available():
|
||||
_siglip_model = _siglip_model.to("xpu")
|
||||
device_name = "Intel XPU"
|
||||
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
_siglip_model = _siglip_model.to("mps")
|
||||
logger.info("SigLIP running on Apple MPS")
|
||||
device_name = "Apple MPS"
|
||||
else:
|
||||
logger.info("SigLIP running on CPU")
|
||||
device_name = "CPU"
|
||||
else:
|
||||
logger.info("FORCE_CPU set. SigLIP running on CPU")
|
||||
device_name = "CPU (FORCE_CPU)"
|
||||
|
||||
logger.info(f"SigLIP {model_name}: ready on {device_name} ({time.time() - t0:.1f}s)")
|
||||
return _siglip_model, _siglip_processor
|
||||
|
||||
except ImportError as e:
|
||||
@@ -267,30 +340,31 @@ def get_embedding(
|
||||
|
||||
use_cache = Config.ENABLE_CACHE and asset_id is not None
|
||||
cache = get_cache(Config.CACHE_DIR) if use_cache else None
|
||||
model_key = "immich" if entity_type == "face" else "siglip"
|
||||
# Use a single consistent cache key per model so lookups and stores always match.
|
||||
# "immich" was previously used as the face key on the lookup path but "insightface"
|
||||
# on the store path — meaning the cache was never hit for locally-computed embeddings.
|
||||
cache_key = "insightface" if entity_type == "face" else "siglip"
|
||||
|
||||
# 1. Use Immich embedding if provided
|
||||
if immich_embedding is not None:
|
||||
if cache:
|
||||
cache.put(asset_id, immich_embedding, model_key)
|
||||
cache.put(asset_id, immich_embedding, cache_key)
|
||||
return immich_embedding
|
||||
|
||||
# 2. Check disk cache
|
||||
if cache:
|
||||
cached = cache.get(asset_id, model_key)
|
||||
cached = cache.get(asset_id, cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# 3. Compute locally
|
||||
if entity_type == "face":
|
||||
emb = get_face_embedding(img_pil)
|
||||
model_key = "insightface"
|
||||
else:
|
||||
emb = get_object_embedding(img_pil)
|
||||
|
||||
# Cache the result
|
||||
if emb is not None and cache:
|
||||
cache.put(asset_id, emb, model_key)
|
||||
cache.put(asset_id, emb, cache_key)
|
||||
|
||||
return emb
|
||||
|
||||
|
||||
+188
-6
@@ -3,6 +3,7 @@
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from io import BytesIO
|
||||
from urllib.parse import quote
|
||||
|
||||
@@ -12,14 +13,86 @@ 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_frigate_person_files
|
||||
from .image_processing import process_face_mode, process_full_mode, process_object_mode
|
||||
from .immich_api import fetch_face_data, fetch_full_image
|
||||
from .logging import console
|
||||
from .upload_tracker import mark_rejected, mark_uploaded
|
||||
from .log_config import console
|
||||
from .quality import assess_quality
|
||||
from .upload_tracker import (
|
||||
get_lowest_quality_mapped_file,
|
||||
get_tracked_frigate_file_count,
|
||||
get_tracked_frigate_filenames,
|
||||
mark_rejected,
|
||||
mark_uploaded,
|
||||
record_frigate_file,
|
||||
remove_frigate_file,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _reconcile_frigate_mappings(
|
||||
person_name: str,
|
||||
known_files_before: set[str],
|
||||
uploaded: list[tuple[str, str | None]],
|
||||
) -> None:
|
||||
"""Map Frigate filenames to asset IDs after a batch of uploads.
|
||||
|
||||
Polls until all expected new files appear in the Frigate API, then maps
|
||||
them to asset IDs by filename timestamp order (Frigate processes the
|
||||
upload queue in FIFO order, so earlier uploads get earlier timestamps).
|
||||
|
||||
KNOWN LIMITATION — race condition with external uploads:
|
||||
If another client uploads a face file for this person concurrently, the
|
||||
count of new files will exceed `len(uploaded)` and we bail out entirely
|
||||
(the "> target" branch). That's safe — we never record a wrong mapping —
|
||||
but those uploads become permanently unmapped (they won't be eligible for
|
||||
quality replacement). The right fix is a Frigate API that returns the
|
||||
filename in the upload response, removing the need for any post-upload
|
||||
diffing. Until then, the external-upload guard keeps mappings correct at
|
||||
the cost of occasionally missing them when another client is active.
|
||||
"""
|
||||
target = len(uploaded)
|
||||
current_files: set[str] = set()
|
||||
|
||||
for delay in (1, 2, 4, 8):
|
||||
time.sleep(delay)
|
||||
fresh = get_frigate_person_files(person_name)
|
||||
if fresh is None:
|
||||
logger.warning(
|
||||
f"{person_name}: Frigate API unreachable during mapping reconciliation"
|
||||
" — quality replacement won't target these files"
|
||||
)
|
||||
return
|
||||
current_files = set(fresh)
|
||||
if len(current_files - known_files_before) >= target:
|
||||
break
|
||||
|
||||
new_files = current_files - known_files_before
|
||||
|
||||
if len(new_files) == target:
|
||||
def _ts(fname: str) -> float:
|
||||
try:
|
||||
return float(fname.rsplit("_", 1)[-1].replace(".webp", ""))
|
||||
except (ValueError, IndexError):
|
||||
return 0.0
|
||||
|
||||
for (fname, asset_id), frigate_file in zip(uploaded, sorted(new_files, key=_ts)):
|
||||
if asset_id:
|
||||
record_frigate_file(person_name, frigate_file, asset_id)
|
||||
logger.debug(f"{person_name}: batch-mapped {target} Frigate file(s)")
|
||||
elif len(new_files) > target:
|
||||
logger.info(
|
||||
f"{person_name}: {len(new_files)} new Frigate files for {target} uploads"
|
||||
" (external upload detected) — skipping file mapping"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"{person_name}: only {len(new_files)} of {target} expected Frigate files"
|
||||
" appeared after reconciliation — mapping skipped"
|
||||
)
|
||||
|
||||
|
||||
def _enrich_asset_with_face_data(asset: dict, person: dict) -> dict:
|
||||
"""Enrich an asset dict with face bounding box data from the Immich faces API.
|
||||
|
||||
@@ -97,9 +170,10 @@ def execute_jobs(jobs: list[dict]) -> None:
|
||||
shutil.rmtree(person_dir)
|
||||
os.makedirs(person_dir, exist_ok=True)
|
||||
|
||||
# Track filename → asset_id and filename → confidence score
|
||||
# Track filename → asset_id, filename → confidence score, filename → crop dims
|
||||
asset_map: dict[str, str] = {}
|
||||
score_map: dict[str, float | None] = {}
|
||||
dims_map: dict[str, tuple[int, int]] = {}
|
||||
|
||||
count = 0
|
||||
for asset in assets:
|
||||
@@ -134,7 +208,24 @@ def execute_jobs(jobs: list[dict]) -> None:
|
||||
# Record which asset produced which output file
|
||||
filename = f"{count}.jpg"
|
||||
asset_map[filename] = asset["id"]
|
||||
score_map[filename] = asset.get("face_confidence")
|
||||
score_map[filename] = asset.get("quality_score")
|
||||
if mode == "face" and isinstance(saved, tuple):
|
||||
dims_map[filename] = saved
|
||||
# Time-spread path: compute blur score from the downloaded
|
||||
# image. Cap at 1440px so the scale matches the preview
|
||||
# thumbnails the embedding path uses for scoring — Laplacian
|
||||
# variance grows with resolution, making full-res and
|
||||
# thumbnail scores incomparable if left uncapped.
|
||||
if mode == "face" and score_map[filename] is None:
|
||||
try:
|
||||
score_img = img.convert("RGB") if img.mode != "RGB" else img
|
||||
if score_img.width > 1440 or score_img.height > 1440:
|
||||
score_img = score_img.copy()
|
||||
score_img.thumbnail((1440, 1440), Image.LANCZOS)
|
||||
score_map[filename] = assess_quality(score_img).blur_score
|
||||
except Exception as exc:
|
||||
logger.debug(f"Quality score fallback for {asset['id']}: {exc}")
|
||||
score_map[filename] = 0.0 # unknown quality — treat as lowest
|
||||
# Also record object-mode variant filenames
|
||||
if mode == "object":
|
||||
for f in sorted(os.listdir(person_dir)):
|
||||
@@ -156,6 +247,7 @@ def execute_jobs(jobs: list[dict]) -> None:
|
||||
# Store maps on the job so upload_to_frigate can use them
|
||||
job["asset_map"] = asset_map
|
||||
job["score_map"] = score_map
|
||||
job["dims_map"] = dims_map
|
||||
|
||||
progress.remove_task(job_task)
|
||||
|
||||
@@ -236,6 +328,7 @@ def upload_to_frigate(jobs: list[dict]) -> None:
|
||||
|
||||
asset_map = filename_to_asset_id.get(name, {})
|
||||
score_map = job.get("score_map", {})
|
||||
dims_map = job.get("dims_map", {})
|
||||
person_files = sorted(asset_map.keys())
|
||||
|
||||
if not person_files:
|
||||
@@ -246,8 +339,80 @@ def upload_to_frigate(jobs: list[dict]) -> None:
|
||||
person_uploaded = 0
|
||||
person_failed = 0
|
||||
|
||||
# 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.
|
||||
_snapshot = get_frigate_person_files(name)
|
||||
if _snapshot is None:
|
||||
# Frigate GET is down; fall back to the tracker's mapped filenames
|
||||
# as the pre-upload baseline. reconciliation will still work unless
|
||||
# there are concurrent manual uploads (handled by >target guard).
|
||||
logger.warning(
|
||||
f"{name}: Frigate API unreachable at upload start"
|
||||
" — using tracker baseline for post-upload reconciliation"
|
||||
)
|
||||
known_frigate_files_at_start: set[str] = get_tracked_frigate_filenames(name)
|
||||
else:
|
||||
known_frigate_files_at_start: set[str] = set(_snapshot)
|
||||
effective_count = get_tracked_frigate_file_count(name)
|
||||
quality_replacement = job.get("config", {}).get("quality_replacement", False)
|
||||
actually_uploaded: list[tuple[str, str | None]] = []
|
||||
failed_deletes: set[str] = set()
|
||||
min_quality_score_for_slot: float | None = None
|
||||
|
||||
for fname in person_files:
|
||||
fpath = os.path.join(person_dir, fname)
|
||||
|
||||
# If a previous replacement delete succeeded but that upload failed,
|
||||
# require the next candidate to beat the deleted file's score so the
|
||||
# freed slot isn't filled with something worse than what we removed.
|
||||
if min_quality_score_for_slot is not None:
|
||||
file_score = score_map.get(fname)
|
||||
if file_score is None or file_score <= min_quality_score_for_slot:
|
||||
score_str = f"{file_score:.3f}" if file_score is not None else "N/A"
|
||||
progress.console.print(
|
||||
f" [dim]⏭ {fname}: score {score_str} ≤ freed slot floor"
|
||||
f" {min_quality_score_for_slot:.3f}, skipping[/dim]"
|
||||
)
|
||||
progress.advance(upload_task)
|
||||
continue
|
||||
|
||||
at_cap = effective_count >= Config.MAX_AUTO_IMAGES
|
||||
if at_cap:
|
||||
if not quality_replacement:
|
||||
progress.console.print(f" [dim]⏭ {fname}: at cap, quality replacement disabled[/dim]")
|
||||
progress.advance(upload_task)
|
||||
continue
|
||||
new_score = score_map.get(fname)
|
||||
if new_score is None:
|
||||
progress.console.print(f" [dim]⏭ {fname}: no confidence score, skipping replacement[/dim]")
|
||||
progress.advance(upload_task)
|
||||
continue
|
||||
worst = get_lowest_quality_mapped_file(name, exclude=failed_deletes)
|
||||
if worst is None or new_score <= worst[2]:
|
||||
worst_score_str = f"{worst[2]:.3f}" if worst is not None else "N/A"
|
||||
progress.console.print(
|
||||
f" [dim]⏭ {fname}: score {new_score:.3f} ≤ worst mapped"
|
||||
f" {worst_score_str}, skipping[/dim]"
|
||||
)
|
||||
progress.advance(upload_task)
|
||||
continue
|
||||
# Delete the worst mapped file to make room for the better one
|
||||
worst_frigate_file, _worst_asset_id, worst_score = worst
|
||||
progress.console.print(
|
||||
f" 🔄 {fname}: score {new_score:.3f} > {worst_score:.3f},"
|
||||
f" replacing {worst_frigate_file}"
|
||||
)
|
||||
if delete_frigate_person_files(name, [worst_frigate_file]):
|
||||
remove_frigate_file(name, worst_frigate_file)
|
||||
effective_count -= 1
|
||||
min_quality_score_for_slot = worst_score
|
||||
else:
|
||||
logger.warning(f"Failed to delete {worst_frigate_file} for {name}, skipping replacement")
|
||||
failed_deletes.add(worst_frigate_file)
|
||||
progress.advance(upload_task)
|
||||
continue
|
||||
|
||||
for attempt in range(1, max_retries + 1):
|
||||
try:
|
||||
with open(fpath, "rb") as f:
|
||||
@@ -259,11 +424,18 @@ def upload_to_frigate(jobs: list[dict]) -> None:
|
||||
if resp.status_code == 200:
|
||||
uploaded += 1
|
||||
person_uploaded += 1
|
||||
effective_count += 1
|
||||
min_quality_score_for_slot = None
|
||||
|
||||
# Mark this asset as uploaded so it's skipped on future runs
|
||||
asset_id = asset_map.get(fname)
|
||||
if asset_id:
|
||||
mark_uploaded(asset_id, person_name=name, score=score_map.get(fname))
|
||||
mark_uploaded(
|
||||
asset_id,
|
||||
person_name=name,
|
||||
score=score_map.get(fname),
|
||||
crop_dims=dims_map.get(fname),
|
||||
)
|
||||
actually_uploaded.append((fname, asset_id))
|
||||
|
||||
break
|
||||
else:
|
||||
@@ -320,6 +492,16 @@ def upload_to_frigate(jobs: list[dict]) -> None:
|
||||
|
||||
progress.advance(upload_task)
|
||||
|
||||
if min_quality_score_for_slot is not None:
|
||||
logger.warning(
|
||||
f"{name}: freed replacement slot (floor {min_quality_score_for_slot:.3f})"
|
||||
" was not filled this run — will be available next run"
|
||||
)
|
||||
|
||||
# Batch-map Frigate filenames to asset IDs now that all uploads are done.
|
||||
if actually_uploaded:
|
||||
_reconcile_frigate_mappings(name, known_frigate_files_at_start, actually_uploaded)
|
||||
|
||||
# Per-person summary
|
||||
if person_failed == 0:
|
||||
progress.console.print(
|
||||
|
||||
+67
-10
@@ -8,21 +8,78 @@ import requests
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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."
|
||||
"""
|
||||
def _get_faces_data() -> dict | None:
|
||||
"""Fetch raw GET /api/faces response. Returns None if unavailable."""
|
||||
frigate_url = os.environ.get("FRIGATE_URL", "").rstrip("/")
|
||||
if not frigate_url:
|
||||
return None
|
||||
try:
|
||||
resp = requests.get(f"{frigate_url}/api/faces", timeout=10)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
train = data.get("train", {})
|
||||
return {name: len(files) for name, files in train.items() if isinstance(files, list)}
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not query Frigate face counts: {e}")
|
||||
logger.warning(f"Could not query Frigate faces API: {e}")
|
||||
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_frigate_person_files(person_name: str) -> list[str] | None:
|
||||
"""Return the list of training filenames for a person in Frigate.
|
||||
|
||||
Returns None if the API is unreachable. Returns an empty list if the
|
||||
person exists but has no training images yet.
|
||||
"""
|
||||
data = _get_faces_data()
|
||||
if data is None:
|
||||
return None
|
||||
files = data.get(person_name)
|
||||
return files if isinstance(files, list) else []
|
||||
|
||||
|
||||
def delete_frigate_person_files(person_name: str, filenames: list[str]) -> bool:
|
||||
"""Delete specific training files for a person from Frigate.
|
||||
|
||||
Uses POST /api/faces/{name}/delete with body {"ids": [filename, ...]}.
|
||||
Returns True on success, False if unreachable or the request fails.
|
||||
"""
|
||||
frigate_url = os.environ.get("FRIGATE_URL", "").rstrip("/")
|
||||
if not frigate_url or not filenames:
|
||||
return False
|
||||
from urllib.parse import quote
|
||||
encoded = quote(person_name, safe="")
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{frigate_url}/api/faces/{encoded}/delete",
|
||||
json={"ids": filenames},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.ok:
|
||||
logger.debug(f"Deleted {len(filenames)} Frigate file(s) for {person_name}")
|
||||
return True
|
||||
if resp.status_code == 404:
|
||||
# File already absent — stale tracker entry. Return True so the caller
|
||||
# removes it from the tracker and frees the slot cleanly.
|
||||
logger.warning(f"Frigate file(s) not found for {person_name} (stale tracker entry?): {filenames}")
|
||||
return True
|
||||
logger.warning(f"Frigate delete returned {resp.status_code} for {person_name}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete Frigate files for {person_name}: {e}")
|
||||
return False
|
||||
|
||||
@@ -69,9 +69,10 @@ def process_face_mode(
|
||||
output_dir: str,
|
||||
count: int,
|
||||
min_width: int | None = None,
|
||||
) -> bool:
|
||||
) -> 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.
|
||||
If face alignment is enabled and landmarks are available, produces
|
||||
an aligned 112x112 crop. Otherwise falls back to bounding box crop
|
||||
with configurable margin.
|
||||
@@ -90,7 +91,7 @@ def process_face_mode(
|
||||
|
||||
if not face_info:
|
||||
logger.debug(f"No face info for {person.get('name')} in asset {asset.get('id')}")
|
||||
return False
|
||||
return None
|
||||
|
||||
img_w, img_h = img.size
|
||||
meta_w = face_info.get("imageWidth") or img_w
|
||||
@@ -106,7 +107,7 @@ def process_face_mode(
|
||||
face_w, face_h = x2 - x1, y2 - y1
|
||||
if face_w < min_width or face_h < min_width:
|
||||
logger.debug(f"Face too small ({face_w:.1f}x{face_h:.1f})")
|
||||
return False
|
||||
return None
|
||||
|
||||
# Try face alignment if enabled and landmarks available
|
||||
if Config.ENABLE_FACE_ALIGNMENT:
|
||||
@@ -117,7 +118,7 @@ def process_face_mode(
|
||||
aligned = align_face(img, scaled_landmarks)
|
||||
if aligned is not None:
|
||||
_save_jpeg(aligned, os.path.join(output_dir, f"{count}.jpg"))
|
||||
return True
|
||||
return aligned.size
|
||||
|
||||
# Fall back to bounding box crop with configurable margin
|
||||
margin = Config.FACE_MARGIN
|
||||
@@ -131,7 +132,7 @@ def process_face_mode(
|
||||
|
||||
face_crop = img.crop(crop_box)
|
||||
_save_jpeg(face_crop, os.path.join(output_dir, f"{count}.jpg"))
|
||||
return True
|
||||
return face_crop.size
|
||||
|
||||
|
||||
def process_object_mode(
|
||||
@@ -144,7 +145,14 @@ def process_object_mode(
|
||||
try:
|
||||
model = get_yolo_model()
|
||||
target_class = config.get("object_class", "dog")
|
||||
device = "cpu" if os.getenv("FORCE_CPU", "").lower() in ("true", "1", "yes") else None
|
||||
import torch
|
||||
|
||||
if os.getenv("FORCE_CPU", "").lower() in ("true", "1", "yes"):
|
||||
device = "cpu"
|
||||
elif hasattr(torch, "xpu") and torch.xpu.is_available():
|
||||
device = "xpu"
|
||||
else:
|
||||
device = None # YOLO auto-selects (CUDA/ROCm/CPU)
|
||||
|
||||
results = model(img, verbose=False, device=device)
|
||||
|
||||
|
||||
@@ -35,10 +35,13 @@ def get_people() -> list[dict]:
|
||||
headers=get_headers(),
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 401:
|
||||
logger.error("Immich API key is invalid or expired (401 Unauthorized). Update API_KEY.")
|
||||
return []
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("people", [])
|
||||
except (requests.RequestException, ValueError) as e:
|
||||
logger.error(f"Failed to fetch people: {e}")
|
||||
logger.error(f"Failed to fetch people from Immich: {e}")
|
||||
return []
|
||||
|
||||
|
||||
@@ -49,7 +52,7 @@ def fetch_all_assets(person: dict) -> list[dict]:
|
||||
url = f"{Config.IMMICH_URL}/api/search/metadata"
|
||||
page_size = 1000
|
||||
|
||||
logger.info(f"Fetching assets for {name}...")
|
||||
logger.debug(f"Fetching assets for {name}...")
|
||||
|
||||
assets = []
|
||||
for page in range(1, MAX_PAGES + 1):
|
||||
@@ -137,10 +140,11 @@ def fetch_face_data(asset_id: str, person_id: str | None = None) -> FaceData | N
|
||||
face.get("boundingBoxY2", 0),
|
||||
)
|
||||
|
||||
score = face.get("score")
|
||||
return FaceData(
|
||||
embedding=embedding,
|
||||
bbox=bbox,
|
||||
confidence=face.get("score") or face.get("confidence"),
|
||||
confidence=score if score is not None else face.get("confidence"),
|
||||
image_width=face.get("imageWidth", 0),
|
||||
image_height=face.get("imageHeight", 0),
|
||||
)
|
||||
@@ -212,6 +216,6 @@ def filter_recent_assets(assets: list[dict], years: int | None = None) -> list[d
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
logger.info(f"Retained {len(recent)} assets (filtered {skipped} old assets).")
|
||||
logger.debug(f"Retained {len(recent)} assets (filtered {skipped} old assets).")
|
||||
return recent
|
||||
|
||||
|
||||
+53
-28
@@ -13,7 +13,7 @@ from .diversity import select_diverse_assets
|
||||
from .embeddings import is_embedding_available, load_embedding_model
|
||||
from .frigate_api import get_frigate_face_counts
|
||||
from .immich_api import fetch_all_assets, filter_recent_assets
|
||||
from .logging import console
|
||||
from .log_config import console
|
||||
from .upload_tracker import filter_already_uploaded, get_person_summary, update_frigate_count
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -58,8 +58,11 @@ def _get_strategy_choice(has_embedding: bool, entity_type: str) -> tuple[int | s
|
||||
rprint(" [bold]4.[/bold] Skip")
|
||||
|
||||
choice = Prompt.ask("Choice", choices=["1", "2", "3", "4"], default="1")
|
||||
limits = {"1": 30, "2": 100, "3": IntPrompt.ask("Enter number of images", default=30)}
|
||||
return limits.get(choice, 0), "time" if choice != "4" else "skip"
|
||||
if choice == "4":
|
||||
return 0, "skip"
|
||||
if choice == "3":
|
||||
return IntPrompt.ask("Enter number of images", default=30), "time"
|
||||
return {"1": 30, "2": 100}.get(choice, 30), "time"
|
||||
|
||||
|
||||
def _resolve_strategy(strategy: str, has_embedding: bool) -> tuple[int | str, str]:
|
||||
@@ -81,7 +84,9 @@ def _resolve_strategy(strategy: str, has_embedding: bool) -> tuple[int | str, st
|
||||
return strategy_map.get(strategy, ("auto", "smart"))
|
||||
|
||||
|
||||
def _perform_selection(assets: list, limit: int | str, name: str, selection_mode: str, entity_type: str) -> list:
|
||||
def _perform_selection(
|
||||
assets: list, limit: int | str, name: str, selection_mode: str, entity_type: str, person_id: str | None = None
|
||||
) -> list:
|
||||
"""Run diversity selection with progress display."""
|
||||
if selection_mode == "smart":
|
||||
model_display = "InsightFace (face embeddings)" if entity_type == "face" else "SigLIP (visual embeddings)"
|
||||
@@ -104,6 +109,7 @@ def _perform_selection(assets: list, limit: int | str, name: str, selection_mode
|
||||
name,
|
||||
selection_mode=selection_mode,
|
||||
entity_type=entity_type,
|
||||
person_id=person_id,
|
||||
progress_callback=lambda c, t: progress.update(task, completed=c, total=t),
|
||||
)
|
||||
|
||||
@@ -113,7 +119,9 @@ def _perform_selection(assets: list, limit: int | str, name: str, selection_mode
|
||||
|
||||
rprint(f"\n[cyan]Using time-spread selection for {limit} images...[/cyan]")
|
||||
with console.status(f"[bold]Selecting {limit} images evenly distributed over time...[/bold]"):
|
||||
selected = select_diverse_assets(assets, limit, name, selection_mode="time", entity_type=entity_type)
|
||||
selected = select_diverse_assets(
|
||||
assets, limit, name, selection_mode="time", entity_type=entity_type, person_id=person_id
|
||||
)
|
||||
rprint(f" [green]Selected {len(selected)} images using time spread.[/green]")
|
||||
return selected
|
||||
|
||||
@@ -131,7 +139,7 @@ def _configure_person(person: dict, people: list[dict]) -> dict | None:
|
||||
mode_choice = Prompt.ask("Choice", choices=["1", "2"], default="1")
|
||||
entity_type = "face" if mode_choice == "1" else "object"
|
||||
|
||||
config = {"name": name, "mode": entity_type}
|
||||
config = {"name": name, "mode": entity_type, "quality_replacement": Config.QUALITY_REPLACEMENT}
|
||||
if entity_type == "object":
|
||||
config["object_class"] = Prompt.ask("Enter Object Class (e.g. dog, cat, car)", default="dog")
|
||||
|
||||
@@ -145,8 +153,11 @@ def _configure_person(person: dict, people: list[dict]) -> dict | None:
|
||||
|
||||
rprint(f" Found [bold]{len(all_assets)}[/bold] total, [bold]{len(recent_assets)}[/bold] in range ({years} years).")
|
||||
|
||||
# Filter out assets already uploaded to Frigate
|
||||
retry_rejected = os.environ.get("RETRY_REJECTED", "false").lower() in ("true", "1", "yes")
|
||||
# Filter out assets already uploaded to Frigate.
|
||||
# In interactive mode, ask — use the env var only as the default so it can
|
||||
# still be pre-set (e.g. RETRY_REJECTED=true) without forcing the answer.
|
||||
retry_env = os.environ.get("RETRY_REJECTED", "false").lower() in ("true", "1", "yes")
|
||||
retry_rejected = Confirm.ask("Include previously rejected images?", default=retry_env)
|
||||
before_dedup = len(recent_assets)
|
||||
new_asset_ids = set(filter_already_uploaded([a["id"] for a in recent_assets], retry_rejected=retry_rejected))
|
||||
recent_assets = [a for a in recent_assets if a["id"] in new_asset_ids]
|
||||
@@ -167,7 +178,9 @@ def _configure_person(person: dict, people: list[dict]) -> dict | None:
|
||||
return None
|
||||
|
||||
# Perform selection
|
||||
selected_assets = _perform_selection(recent_assets, limit, name, selection_mode, entity_type)
|
||||
selected_assets = _perform_selection(
|
||||
recent_assets, limit, name, selection_mode, entity_type, person_id=person["id"]
|
||||
)
|
||||
|
||||
rprint(f" [green]Queued {len(selected_assets)} images for {name}.[/green]")
|
||||
return {"person": person, "assets": selected_assets, "limit": len(selected_assets), "config": config}
|
||||
@@ -270,38 +283,50 @@ def auto_configure(people: list[dict]) -> list[dict]:
|
||||
rprint(f" [dim]Skipping {name} (0 new images after dedup).[/dim]")
|
||||
continue
|
||||
|
||||
# Enforce MAX_AUTO_IMAGES as a lifetime cap per person.
|
||||
# Priority: live Frigate count → last cached Frigate count → local uploaded count.
|
||||
# Enforce MAX_AUTO_IMAGES against the tracked file count only.
|
||||
# Manually-added Frigate files are invisible to this cap so users can
|
||||
# curate their own files without shrinking winnow's managed quota.
|
||||
person_summary = upload_summary.get(name, {})
|
||||
if frigate_counts is not None:
|
||||
already_uploaded = frigate_counts.get(name, 0)
|
||||
else:
|
||||
already_uploaded = (
|
||||
person_summary.get("frigate_count")
|
||||
or person_summary.get("uploaded", 0)
|
||||
)
|
||||
already_uploaded = len(person_summary.get("frigate_files", {}))
|
||||
capacity = Config.MAX_AUTO_IMAGES - already_uploaded
|
||||
if capacity <= 0:
|
||||
if not Config.QUALITY_REPLACEMENT:
|
||||
rprint(
|
||||
f" [dim]Skipping {name} (at cap:"
|
||||
f" {already_uploaded}/{Config.MAX_AUTO_IMAGES}, quality replacement disabled).[/dim]"
|
||||
)
|
||||
continue
|
||||
rprint(
|
||||
f" [dim]Skipping {name} (at lifetime cap:"
|
||||
f" {already_uploaded}/{Config.MAX_AUTO_IMAGES} trained).[/dim]"
|
||||
f" [cyan]{name}: at cap ({already_uploaded}/{Config.MAX_AUTO_IMAGES}),"
|
||||
f" checking for quality improvements...[/cyan]"
|
||||
)
|
||||
continue
|
||||
quality_replacement_only = True
|
||||
else:
|
||||
quality_replacement_only = False
|
||||
|
||||
config["quality_replacement"] = quality_replacement_only or Config.QUALITY_REPLACEMENT
|
||||
|
||||
has_embedding = is_embedding_available(entity_type)
|
||||
limit, selection_mode = _resolve_strategy(strategy, has_embedding)
|
||||
|
||||
# Cap selection to remaining capacity
|
||||
if limit == "auto":
|
||||
if already_uploaded > 0:
|
||||
limit = capacity # partially filled — select exactly what remains
|
||||
else:
|
||||
limit = min(limit, capacity)
|
||||
# Cap selection to remaining capacity (no cap when replacement-only — executor
|
||||
# decides per-image whether to swap; any candidate could be an improvement).
|
||||
auto_cap = None
|
||||
if not quality_replacement_only:
|
||||
if limit == "auto":
|
||||
if already_uploaded > 0:
|
||||
auto_cap = capacity
|
||||
else:
|
||||
limit = min(limit, capacity)
|
||||
|
||||
if selection_mode == "skip":
|
||||
continue
|
||||
|
||||
selected_assets = _perform_selection(recent_assets, limit, name, selection_mode, entity_type)
|
||||
selected_assets = _perform_selection(
|
||||
recent_assets, limit, name, selection_mode, entity_type, person_id=person["id"]
|
||||
)
|
||||
if auto_cap is not None:
|
||||
selected_assets = selected_assets[:auto_cap]
|
||||
|
||||
if selected_assets:
|
||||
rprint(f" [green]Queued {len(selected_assets)} images for {name}.[/green]")
|
||||
|
||||
@@ -26,10 +26,12 @@ def setup_logging(verbose: bool = False) -> logging.Logger:
|
||||
"""Configure logging with Rich console and file output."""
|
||||
level = logging.DEBUG if verbose else logging.INFO
|
||||
|
||||
# Configure root logger
|
||||
# Configure root logger; close existing handlers before replacing them
|
||||
root = logging.getLogger()
|
||||
root.setLevel(level)
|
||||
root.handlers.clear()
|
||||
for h in root.handlers[:]:
|
||||
h.close()
|
||||
root.removeHandler(h)
|
||||
|
||||
# Rich console handler - uses shared console to avoid breaking progress bars
|
||||
root.addHandler(RichHandler(rich_tracebacks=True, markup=True, console=console))
|
||||
@@ -37,7 +39,7 @@ def setup_logging(verbose: bool = False) -> logging.Logger:
|
||||
# File handler (always debug level) — log file respects OUTPUT_DIR if set
|
||||
log_dir = os.environ.get("OUTPUT_DIR", ".")
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
log_path = os.path.join(log_dir, "immich_export.log")
|
||||
log_path = os.path.join(log_dir, "winnow.log")
|
||||
file_handler = logging.FileHandler(log_path)
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
file_handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
|
||||
+10
-3
@@ -20,6 +20,7 @@ class QualityResult:
|
||||
|
||||
passed: bool
|
||||
reasons: list[str] = field(default_factory=list)
|
||||
blur_score: float | None = None
|
||||
|
||||
@property
|
||||
def reason(self) -> str:
|
||||
@@ -113,9 +114,15 @@ def assess_quality(
|
||||
img_np = np.asarray(img)
|
||||
reasons = []
|
||||
|
||||
# Run all checks, collect failures
|
||||
# Compute laplacian variance once (used by check_blur and stored as blur_score)
|
||||
gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY) if img_np.ndim == 3 else img_np
|
||||
blur_score = float(cv2.Laplacian(gray, cv2.CV_64F).var())
|
||||
|
||||
checks = [
|
||||
check_blur(img_np, blur_threshold),
|
||||
(
|
||||
blur_score >= blur_threshold,
|
||||
f"Blurry (laplacian={blur_score:.1f}, threshold={blur_threshold})" if blur_score < blur_threshold else "",
|
||||
),
|
||||
check_grayscale(img_np),
|
||||
check_exposure(img_np),
|
||||
check_confidence(confidence, min_confidence),
|
||||
@@ -129,5 +136,5 @@ def assess_quality(
|
||||
if not passed:
|
||||
reasons.append(reason)
|
||||
|
||||
return QualityResult(passed=len(reasons) == 0, reasons=reasons)
|
||||
return QualityResult(passed=len(reasons) == 0, reasons=reasons, blur_score=blur_score)
|
||||
|
||||
|
||||
+127
-8
@@ -11,10 +11,15 @@ Both are excluded from future candidate pools. To reset:
|
||||
|
||||
by_person schema (frigate_uploaded_ids.json):
|
||||
{
|
||||
"asset_ids": ["immich-id-1", ...], # all assets we attempted to upload
|
||||
"scores": {"immich-id-1": 0.953}, # Immich face confidence at upload time
|
||||
"frigate_count": 42 # last known Frigate training image count
|
||||
"asset_ids": ["immich-id-1", ...], # all assets we attempted to upload
|
||||
"scores": {"immich-id-1": 450.3}, # Laplacian blur variance at upload time
|
||||
"frigate_files": {"PersonName-123.webp": "immich-id-1"}, # Frigate filename → asset ID
|
||||
"crop_dims": {"immich-id-1": [640, 480]}, # crop pixel dimensions at upload time
|
||||
"frigate_count": 42 # last known Frigate training image count
|
||||
}
|
||||
|
||||
frigate_files only contains files winnow uploaded — files added manually through
|
||||
Frigate's UI are never mapped here and are never touched by quality replacement.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -72,13 +77,21 @@ def _get_ids(entry: list | dict) -> list[str]:
|
||||
def _migrate_entry(entry: list | dict) -> dict:
|
||||
"""Ensure by_person entry is in the current dict format."""
|
||||
if isinstance(entry, list):
|
||||
return {"asset_ids": sorted(entry), "scores": {}}
|
||||
return {"asset_ids": sorted(entry), "scores": {}, "frigate_files": {}, "crop_dims": {}}
|
||||
entry.setdefault("asset_ids", [])
|
||||
entry.setdefault("scores", {})
|
||||
entry.setdefault("frigate_files", {})
|
||||
entry.setdefault("crop_dims", {})
|
||||
return entry
|
||||
|
||||
|
||||
def _mark(filename: str, asset_id: str, person_name: str | None, score: float | None = None) -> None:
|
||||
def _mark(
|
||||
filename: str,
|
||||
asset_id: str,
|
||||
person_name: str | None,
|
||||
score: float | None = None,
|
||||
crop_dims: tuple[int, int] | None = None,
|
||||
) -> None:
|
||||
data = _load(filename)
|
||||
flat_key = _flat_key(filename)
|
||||
flat = set(data.get(flat_key, []))
|
||||
@@ -92,6 +105,8 @@ def _mark(filename: str, asset_id: str, person_name: str | None, score: float |
|
||||
entry["asset_ids"] = sorted(ids)
|
||||
if score is not None:
|
||||
entry["scores"][asset_id] = round(score, 4)
|
||||
if crop_dims is not None:
|
||||
entry["crop_dims"][asset_id] = [crop_dims[0], crop_dims[1]]
|
||||
by_person[person_name] = entry
|
||||
_save(filename, data)
|
||||
|
||||
@@ -106,8 +121,13 @@ def load_rejected_ids() -> set[str]:
|
||||
return _load_flat(REJECT_TRACKER_FILE)
|
||||
|
||||
|
||||
def mark_uploaded(asset_id: str, person_name: str | None = None, score: float | None = None) -> None:
|
||||
_mark(UPLOAD_TRACKER_FILE, asset_id, person_name, score=score)
|
||||
def mark_uploaded(
|
||||
asset_id: str,
|
||||
person_name: str | None = None,
|
||||
score: float | None = None,
|
||||
crop_dims: tuple[int, int] | None = None,
|
||||
) -> None:
|
||||
_mark(UPLOAD_TRACKER_FILE, asset_id, person_name, score=score, crop_dims=crop_dims)
|
||||
logger.debug(f"Marked {asset_id} as uploaded ({person_name})")
|
||||
|
||||
|
||||
@@ -116,6 +136,104 @@ def mark_rejected(asset_id: str, person_name: str | None = None) -> None:
|
||||
logger.debug(f"Marked {asset_id} as rejected ({person_name})")
|
||||
|
||||
|
||||
def record_frigate_file(person_name: str, frigate_filename: str, asset_id: str) -> None:
|
||||
"""Record the mapping from a Frigate training filename to an Immich asset ID."""
|
||||
data = _load(UPLOAD_TRACKER_FILE)
|
||||
by_person = data.setdefault("by_person", {})
|
||||
entry = _migrate_entry(by_person.get(person_name, {}))
|
||||
entry["frigate_files"][frigate_filename] = asset_id
|
||||
by_person[person_name] = entry
|
||||
_save(UPLOAD_TRACKER_FILE, data)
|
||||
logger.debug(f"Mapped Frigate file {frigate_filename} → {asset_id} ({person_name})")
|
||||
|
||||
|
||||
def remove_frigate_file(person_name: str, frigate_filename: str) -> None:
|
||||
"""Remove a Frigate filename from the mapping after it has been deleted.
|
||||
|
||||
Does NOT unmark the source asset_id — the deletion was deliberate and
|
||||
we don't want to re-upload the inferior image on the next run.
|
||||
"""
|
||||
data = _load(UPLOAD_TRACKER_FILE)
|
||||
by_person = data.get("by_person", {})
|
||||
entry = _migrate_entry(by_person.get(person_name, {}))
|
||||
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})")
|
||||
|
||||
|
||||
def get_tracked_frigate_file_count(person_name: str) -> int:
|
||||
"""Return the number of Frigate training files winnow has mapped for this person.
|
||||
|
||||
Used as the cap baseline so that manually-added Frigate files do not
|
||||
consume slots from winnow's managed quota.
|
||||
"""
|
||||
data = _load(UPLOAD_TRACKER_FILE)
|
||||
entry = _migrate_entry(data.get("by_person", {}).get(person_name, {}))
|
||||
return len(entry["frigate_files"])
|
||||
|
||||
|
||||
def get_tracked_frigate_filenames(person_name: str) -> set[str]:
|
||||
"""Return the set of Frigate filenames currently mapped in the tracker for a person.
|
||||
|
||||
Used as a pre-upload baseline when the Frigate GET API is unreachable at
|
||||
upload start, so reconciliation can still identify newly uploaded files.
|
||||
"""
|
||||
data = _load(UPLOAD_TRACKER_FILE)
|
||||
entry = _migrate_entry(data.get("by_person", {}).get(person_name, {}))
|
||||
return set(entry["frigate_files"].keys())
|
||||
|
||||
|
||||
def get_lowest_quality_mapped_file(
|
||||
person_name: str, exclude: set[str] | None = None
|
||||
) -> tuple[str, str, float] | None:
|
||||
"""Return (frigate_filename, asset_id, score) for the mapped file with the lowest
|
||||
quality score, or None if no mapped files with known scores exist.
|
||||
|
||||
Pass `exclude` to skip files that failed to delete this run without removing
|
||||
them from the tracker — they remain candidates on the next run.
|
||||
"""
|
||||
data = _load(UPLOAD_TRACKER_FILE)
|
||||
entry = _migrate_entry(data.get("by_person", {}).get(person_name, {}))
|
||||
frigate_files = entry.get("frigate_files", {})
|
||||
scores = entry.get("scores", {})
|
||||
candidates = [
|
||||
(frigate_filename, asset_id, scores[asset_id])
|
||||
for frigate_filename, asset_id in frigate_files.items()
|
||||
if asset_id in scores and (exclude is None or frigate_filename not in exclude)
|
||||
]
|
||||
if not candidates:
|
||||
return None
|
||||
return min(candidates, key=lambda x: x[2])
|
||||
|
||||
|
||||
def find_by_crop_dimension(size: int) -> list[dict]:
|
||||
"""Return all tracked crops whose width or height matches `size` pixels.
|
||||
|
||||
Returns a list of dicts: {person, asset_id, width, height, blur_score, frigate_filename}.
|
||||
frigate_filename is None when the Frigate mapping was lost to a reconciliation race.
|
||||
"""
|
||||
data = _load(UPLOAD_TRACKER_FILE)
|
||||
results = []
|
||||
for person_name, raw_entry in data.get("by_person", {}).items():
|
||||
entry = _migrate_entry(raw_entry)
|
||||
scores = entry.get("scores", {})
|
||||
frigate_files = entry.get("frigate_files", {})
|
||||
asset_to_frigate = {v: k for k, v in frigate_files.items()}
|
||||
for asset_id, dims in entry.get("crop_dims", {}).items():
|
||||
w, h = dims[0], dims[1]
|
||||
if w == size or h == size:
|
||||
results.append({
|
||||
"person": person_name,
|
||||
"asset_id": asset_id,
|
||||
"width": w,
|
||||
"height": h,
|
||||
"blur_score": scores.get(asset_id),
|
||||
"frigate_filename": asset_to_frigate.get(asset_id),
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def update_frigate_count(person_name: str, count: int) -> None:
|
||||
"""Record Frigate's authoritative training image count for a person."""
|
||||
data = _load(UPLOAD_TRACKER_FILE)
|
||||
@@ -143,7 +261,7 @@ def reset_person(person_name: str) -> None:
|
||||
|
||||
|
||||
def get_person_summary() -> dict[str, dict]:
|
||||
"""Return {person_name: {uploaded, rejected, frigate_count, scores}} for display/capacity."""
|
||||
"""Return {person_name: {uploaded, rejected, frigate_count, scores, frigate_files}} for display/capacity."""
|
||||
uploaded_data = _load(UPLOAD_TRACKER_FILE).get("by_person", {})
|
||||
rejected_data = _load(REJECT_TRACKER_FILE).get("by_person", {})
|
||||
names = set(uploaded_data) | set(rejected_data)
|
||||
@@ -156,6 +274,7 @@ def get_person_summary() -> dict[str, dict]:
|
||||
"rejected": len(_get_ids(r_entry)),
|
||||
"frigate_count": u_entry.get("frigate_count") if isinstance(u_entry, dict) else None,
|
||||
"scores": u_entry.get("scores", {}) if isinstance(u_entry, dict) else {},
|
||||
"frigate_files": u_entry.get("frigate_files", {}) if isinstance(u_entry, dict) else {},
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
Reference in New Issue
Block a user