Compare commits

...
20 Commits
Author SHA1 Message Date
flanandClaude Sonnet 4.6 8e1114ce95 release: v0.3.3
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 15:44:56 +00:00
flanandClaude Sonnet 4.6 dd582fa252 chore: bump version to 0.3.3, update changelog and lockfile
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 15:44:51 +00:00
flanandClaude Sonnet 4.6 c57e1a5d45 release: v0.3.3 — raise MIN_FACE_WIDTH to 90px
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 15:43:45 +00:00
flanandClaude Sonnet 4.6 85830809f2 fix: raise MIN_FACE_WIDTH default from 50 to 90px (8k pixel floor)
50px crops produce ~2,500–4,225 total pixels — well below Frigate's own
camera capture range of 16k–50k px. 90px guarantees ≥8,100 total pixels
even when face margins are fully clipped by image edges.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 15:43:31 +00:00
flanandClaude Sonnet 4.6 9598142997 release: v0.3.2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 15:32:33 +00:00
flanandClaude Sonnet 4.6 82dbc8502a feat: merge crop-dimension-trace into dev
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 15:32:27 +00:00
flanandClaude Sonnet 4.6 6311227763 chore: regenerate uv.lock for 0.3.2 version bump
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 15:30:14 +00:00
flanandClaude Sonnet 4.6 19416cc7b8 feat: record crop pixel dimensions and add TRACE_CROP_SIZE lookup
Store (width, height) of each face crop in the tracker at upload time
alongside the existing blur score. Expose TRACE_CROP_SIZE=<px> to look
up which Immich asset produced a crop with that pixel dimension, making
it straightforward to trace unexpected or low-quality images visible in
Frigate back to their source.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 15:20:54 +00:00
flanandClaude Sonnet 4.6 80c5b563b2 Merge main: bump transformers lower bound to >=5.12.0 in variant files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:54:02 +00:00
flanandClaude Sonnet 4.6 de1df19642 chore: bump transformers lower bound to >=5.12.0 in variant pyproject files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:53:56 +00:00
github-actions[bot] 3ae6a86684 chore: update lockfiles 2026-06-13 14:53:14 +00:00
github-actions[bot] 621c310d5b chore: update lockfiles 2026-06-13 14:52:59 +00:00
flanandClaude Sonnet 4.6 467f258fa5 chore: bump transformers lower bound to >=5.12.0
Was >=4.57.6; installed version is 5.12.0. Prevents users from
accidentally resolving the old 4.x series.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:52:27 +00:00
flanandClaude Sonnet 4.6 36a5fb56b6 fix: revert ruff-action to v3 (v4 major tag does not exist)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:47:31 +00:00
flanandClaude Sonnet 4.6 f90af65cd2 fix: revert setup-uv to v7 (v8 major tag does not exist)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:45:02 +00:00
flanandClaude Sonnet 4.6 cd3fad8afe chore: bump astral-sh actions to latest major versions
ruff-action v3 → v4, setup-uv v7 → v8

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:42:43 +00:00
flanandClaude Sonnet 4.6 102827537e Merge dev: bump docker actions to Node.js 24-compatible versions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:40:48 +00:00
flanandClaude Sonnet 4.6 aa12c79f9d chore: bump docker actions to Node.js 24-compatible versions
setup-qemu-action v3 → v4, build-push-action v6 → v7
Required before June 16 when Node.js 20 actions are forced to Node.js 24

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:40:37 +00:00
github-actions[bot] 0f6b17f76f chore: update lockfiles 2026-06-13 14:26:04 +00:00
github-actions[bot] c61e4dc698 chore: update lockfiles 2026-06-13 14:26:03 +00:00
18 changed files with 141 additions and 40 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ STRATEGY=auto
# YEARS_FILTER=10 # Only include images from the last N years (default: 10) # YEARS_FILTER=10 # Only include images from the last N years (default: 10)
# ── Image Quality ───────────────────────────────────────────────────────────── # ── Image Quality ─────────────────────────────────────────────────────────────
# MIN_FACE_WIDTH=50 # Minimum face width in pixels (default: 50) # MIN_FACE_WIDTH=90 # Minimum face width in pixels (default: 90, guarantees ≥8,100px crop)
# FACE_MARGIN=0.15 # Padding around face crop as fraction (default: 0.15) # FACE_MARGIN=0.15 # Padding around face crop as fraction (default: 0.15)
# ENABLE_FACE_ALIGNMENT=true # Align face before cropping (default: true) # ENABLE_FACE_ALIGNMENT=true # Align face before cropping (default: true)
# USE_FULL_RESOLUTION=true # Use full-res images vs thumbnails (default: true) # USE_FULL_RESOLUTION=true # Use full-res images vs thumbnails (default: true)
+6 -6
View File
@@ -53,7 +53,7 @@ jobs:
- name: Set up QEMU - name: Set up QEMU
if: matrix.platform == 'linux/arm64' if: matrix.platform == 'linux/arm64'
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v4
@@ -67,7 +67,7 @@ jobs:
- name: Build and push by digest - name: Build and push by digest
id: build id: build
uses: docker/build-push-action@v6 uses: docker/build-push-action@v7
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
@@ -164,7 +164,7 @@ jobs:
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v4
@@ -186,7 +186,7 @@ jobs:
fi fi
- name: Build and push CPU image - name: Build and push CPU image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v7
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
@@ -248,7 +248,7 @@ jobs:
fi fi
- name: Build and push ROCm image - name: Build and push ROCm image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v7
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
@@ -310,7 +310,7 @@ jobs:
fi fi
- name: Build and push Intel image - name: Build and push Intel image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v7
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
+8 -8
View File
@@ -146,7 +146,7 @@ jobs:
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v4
@@ -159,7 +159,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push GPU image (latest) - name: Build and push GPU image (latest)
uses: docker/build-push-action@v6 uses: docker/build-push-action@v7
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
@@ -191,7 +191,7 @@ jobs:
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v4
@@ -204,7 +204,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push CPU image - name: Build and push CPU image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v7
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
@@ -238,7 +238,7 @@ jobs:
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v4
@@ -251,7 +251,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push ROCm image - name: Build and push ROCm image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v7
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
@@ -285,7 +285,7 @@ jobs:
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v4
@@ -298,7 +298,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Intel image - name: Build and push Intel image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v7
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
+12
View File
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.3.3] - 2026-06-13
### Fixed
- **`MIN_FACE_WIDTH` default raised from 50 → 90px**: 50px crops produce 2,500–4,225 total pixels, well below Frigate's own camera capture range of 16k–50k px. 90px guarantees ≥8,100 total pixels even when face margins are fully clipped by image edges, keeping winnow training crops above the floor Frigate considers useful.
## [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 ## [0.3.1] - 2026-06-13
### Fixed ### Fixed
+1 -1
View File
@@ -25,7 +25,7 @@ dependencies = [
"rich>=14.2.0", "rich>=14.2.0",
"torch>=2.12.0", "torch>=2.12.0",
"torchvision>=0.27.0", "torchvision>=0.27.0",
"transformers>=4.57.6", "transformers>=5.12.0",
"ultralytics>=8.4.66", "ultralytics>=8.4.66",
] ]
+1 -1
View File
@@ -25,7 +25,7 @@ dependencies = [
"rich>=14.2.0", "rich>=14.2.0",
"torch>=2.12.0", "torch>=2.12.0",
"torchvision>=0.27.0", "torchvision>=0.27.0",
"transformers>=4.57.6", "transformers>=5.12.0",
"ultralytics>=8.4.66", "ultralytics>=8.4.66",
] ]
+1 -1
View File
@@ -25,7 +25,7 @@ dependencies = [
"rich>=14.2.0", "rich>=14.2.0",
"torch>=2.5.0", "torch>=2.5.0",
"torchvision>=0.20.0", "torchvision>=0.20.0",
"transformers>=4.57.6", "transformers>=5.12.0",
"ultralytics>=8.4.66", "ultralytics>=8.4.66",
] ]
+2 -2
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "winnow" name = "winnow"
version = "0.3.1" version = "0.3.3"
description = "Selects diverse, high-quality photos from Immich as training data for Frigate face recognition and object classification." description = "Selects diverse, high-quality photos from Immich as training data for Frigate face recognition and object classification."
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
requires-python = ">=3.13" requires-python = ">=3.13"
@@ -29,7 +29,7 @@ dependencies = [
"rich>=14.2.0", "rich>=14.2.0",
"torch>=2.12.0", "torch>=2.12.0",
"torchvision>=0.27.0", "torchvision>=0.27.0",
"transformers>=4.57.6", "transformers>=5.12.0",
"ultralytics>=8.4.66", "ultralytics>=8.4.66",
] ]
+1 -1
View File
@@ -17,7 +17,7 @@ def test_config_loads_defaults(monkeypatch):
assert cfg.API_KEY == "test-key" assert cfg.API_KEY == "test-key"
assert cfg.OUTPUT_DIR == "./frigate_train" assert cfg.OUTPUT_DIR == "./frigate_train"
assert cfg.YEARS_FILTER == 10 assert cfg.YEARS_FILTER == 10
assert cfg.MIN_FACE_WIDTH == 50 assert cfg.MIN_FACE_WIDTH == 90
assert cfg.MIN_FACE_COUNT == 0 assert cfg.MIN_FACE_COUNT == 0
assert cfg.BLUR_THRESHOLD == 100.0 assert cfg.BLUR_THRESHOLD == 100.0
assert cfg.MIN_CONFIDENCE == 0.7 assert cfg.MIN_CONFIDENCE == 0.7
+1 -1
View File
@@ -1873,7 +1873,7 @@ requires-dist = [
{ name = "torch", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = ">=2.12.0", index = "https://download.pytorch.org/whl/cpu" }, { name = "torch", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = ">=2.12.0", index = "https://download.pytorch.org/whl/cpu" },
{ name = "torchvision", marker = "platform_machine != 'x86_64' or sys_platform != 'linux'", specifier = ">=0.27.0" }, { name = "torchvision", marker = "platform_machine != 'x86_64' or sys_platform != 'linux'", specifier = ">=0.27.0" },
{ name = "torchvision", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = ">=0.27.0", index = "https://download.pytorch.org/whl/cpu" }, { name = "torchvision", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = ">=0.27.0", index = "https://download.pytorch.org/whl/cpu" },
{ name = "transformers", specifier = ">=4.57.6" }, { name = "transformers", specifier = ">=5.12.0" },
{ name = "ultralytics", specifier = ">=8.4.66" }, { name = "ultralytics", specifier = ">=8.4.66" },
] ]
+1 -1
View File
@@ -1930,7 +1930,7 @@ requires-dist = [
{ name = "torch", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = ">=2.12.0", index = "https://download.pytorch.org/whl/cpu" }, { name = "torch", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = ">=2.12.0", index = "https://download.pytorch.org/whl/cpu" },
{ name = "torchvision", marker = "platform_machine != 'x86_64' or sys_platform != 'linux'", specifier = ">=0.27.0" }, { name = "torchvision", marker = "platform_machine != 'x86_64' or sys_platform != 'linux'", specifier = ">=0.27.0" },
{ name = "torchvision", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = ">=0.27.0", index = "https://download.pytorch.org/whl/cpu" }, { name = "torchvision", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = ">=0.27.0", index = "https://download.pytorch.org/whl/cpu" },
{ name = "transformers", specifier = ">=4.57.6" }, { name = "transformers", specifier = ">=5.12.0" },
{ name = "ultralytics", specifier = ">=8.4.66" }, { name = "ultralytics", specifier = ">=8.4.66" },
] ]
+1 -1
View File
@@ -1922,7 +1922,7 @@ requires-dist = [
{ name = "torch", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = ">=2.5.0", index = "https://download.pytorch.org/whl/rocm6.3" }, { name = "torch", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = ">=2.5.0", index = "https://download.pytorch.org/whl/rocm6.3" },
{ name = "torchvision", marker = "platform_machine != 'x86_64' or sys_platform != 'linux'", specifier = ">=0.20.0" }, { name = "torchvision", marker = "platform_machine != 'x86_64' or sys_platform != 'linux'", specifier = ">=0.20.0" },
{ name = "torchvision", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = ">=0.20.0", index = "https://download.pytorch.org/whl/rocm6.3" }, { name = "torchvision", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = ">=0.20.0", index = "https://download.pytorch.org/whl/rocm6.3" },
{ name = "transformers", specifier = ">=4.57.6" }, { name = "transformers", specifier = ">=5.12.0" },
{ name = "ultralytics", specifier = ">=8.4.66" }, { name = "ultralytics", specifier = ">=8.4.66" },
] ]
Generated
+2 -2
View File
@@ -2348,7 +2348,7 @@ wheels = [
[[package]] [[package]]
name = "winnow" name = "winnow"
version = "0.3.1" version = "0.3.3"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "croniter" }, { name = "croniter" },
@@ -2403,7 +2403,7 @@ requires-dist = [
{ name = "torchvision", marker = "platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux'", specifier = ">=0.27.0" }, { name = "torchvision", marker = "platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux'", specifier = ">=0.27.0" },
{ name = "torchvision", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'", specifier = ">=0.27.0", index = "https://download.pytorch.org/whl/cpu" }, { name = "torchvision", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'", specifier = ">=0.27.0", index = "https://download.pytorch.org/whl/cpu" },
{ name = "torchvision", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = ">=0.27.0", index = "https://download.pytorch.org/whl/cu126" }, { name = "torchvision", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = ">=0.27.0", index = "https://download.pytorch.org/whl/cu126" },
{ name = "transformers", specifier = ">=4.57.6" }, { name = "transformers", specifier = ">=5.12.0" },
{ name = "ultralytics", specifier = ">=8.4.66" }, { name = "ultralytics", specifier = ">=8.4.66" },
] ]
+37 -1
View File
@@ -12,17 +12,53 @@ from .executor import execute_jobs, upload_to_frigate
from .immich_api import get_people from .immich_api import get_people
from .jobs import _show_preview, auto_configure, interactive_configure from .jobs import _show_preview, auto_configure, interactive_configure
from .log_config import console, setup_logging from .log_config import console, setup_logging
from .upload_tracker import get_person_summary, reset_person from .upload_tracker import find_by_crop_dimension, get_person_summary, reset_person
logger = logging.getLogger(__name__) 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: def main() -> None:
"""Entry point for winnow CLI.""" """Entry point for winnow CLI."""
try: try:
verbose = os.environ.get("VERBOSE", "").lower() in ("true", "1", "yes") verbose = os.environ.get("VERBOSE", "").lower() in ("true", "1", "yes")
setup_logging(verbose=verbose) setup_logging(verbose=verbose)
trace_size = os.environ.get("TRACE_CROP_SIZE", "").strip()
if trace_size:
_handle_trace_crop(trace_size)
console.print(r""" console.print(r"""
[bold blue]winnow[/bold blue] [bold blue]winnow[/bold blue]
[dim]Immich -> Frigate Training Data Curator[/dim] [dim]Immich -> Frigate Training Data Curator[/dim]
+2 -2
View File
@@ -26,7 +26,7 @@ class _Config:
YEARS_FILTER: int = 10 YEARS_FILTER: int = 10
# Quality filtering # Quality filtering
MIN_FACE_WIDTH: int = 50 MIN_FACE_WIDTH: int = 90
BLUR_THRESHOLD: float = 100.0 BLUR_THRESHOLD: float = 100.0
MIN_CONFIDENCE: float = 0.7 MIN_CONFIDENCE: float = 0.7
MAX_AUTO_IMAGES: int = 80 MAX_AUTO_IMAGES: int = 80
@@ -56,7 +56,7 @@ class _Config:
self.API_KEY = os.getenv("API_KEY") self.API_KEY = os.getenv("API_KEY")
self.OUTPUT_DIR = os.getenv("OUTPUT_DIR", "./frigate_train") self.OUTPUT_DIR = os.getenv("OUTPUT_DIR", "./frigate_train")
self.YEARS_FILTER = int(os.getenv("YEARS_FILTER", "10")) self.YEARS_FILTER = int(os.getenv("YEARS_FILTER", "10"))
self.MIN_FACE_WIDTH = int(os.getenv("MIN_FACE_WIDTH", "50")) self.MIN_FACE_WIDTH = int(os.getenv("MIN_FACE_WIDTH", "90"))
self.MIN_FACE_COUNT = int(os.getenv("MIN_FACE_COUNT", "0")) self.MIN_FACE_COUNT = int(os.getenv("MIN_FACE_COUNT", "0"))
self.BLUR_THRESHOLD = float(os.getenv("BLUR_THRESHOLD", "100.0")) self.BLUR_THRESHOLD = float(os.getenv("BLUR_THRESHOLD", "100.0"))
self.MIN_CONFIDENCE = float(os.getenv("MIN_CONFIDENCE", "0.7")) self.MIN_CONFIDENCE = float(os.getenv("MIN_CONFIDENCE", "0.7"))
+12 -2
View File
@@ -170,9 +170,10 @@ def execute_jobs(jobs: list[dict]) -> None:
shutil.rmtree(person_dir) shutil.rmtree(person_dir)
os.makedirs(person_dir, exist_ok=True) 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] = {} asset_map: dict[str, str] = {}
score_map: dict[str, float | None] = {} score_map: dict[str, float | None] = {}
dims_map: dict[str, tuple[int, int]] = {}
count = 0 count = 0
for asset in assets: for asset in assets:
@@ -208,6 +209,8 @@ def execute_jobs(jobs: list[dict]) -> None:
filename = f"{count}.jpg" filename = f"{count}.jpg"
asset_map[filename] = asset["id"] asset_map[filename] = asset["id"]
score_map[filename] = asset.get("quality_score") 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 # Time-spread path: compute blur score from the downloaded
# image. Cap at 1440px so the scale matches the preview # image. Cap at 1440px so the scale matches the preview
# thumbnails the embedding path uses for scoring — Laplacian # thumbnails the embedding path uses for scoring — Laplacian
@@ -244,6 +247,7 @@ def execute_jobs(jobs: list[dict]) -> None:
# Store maps on the job so upload_to_frigate can use them # Store maps on the job so upload_to_frigate can use them
job["asset_map"] = asset_map job["asset_map"] = asset_map
job["score_map"] = score_map job["score_map"] = score_map
job["dims_map"] = dims_map
progress.remove_task(job_task) progress.remove_task(job_task)
@@ -324,6 +328,7 @@ def upload_to_frigate(jobs: list[dict]) -> None:
asset_map = filename_to_asset_id.get(name, {}) asset_map = filename_to_asset_id.get(name, {})
score_map = job.get("score_map", {}) score_map = job.get("score_map", {})
dims_map = job.get("dims_map", {})
person_files = sorted(asset_map.keys()) person_files = sorted(asset_map.keys())
if not person_files: if not person_files:
@@ -424,7 +429,12 @@ def upload_to_frigate(jobs: list[dict]) -> None:
asset_id = asset_map.get(fname) asset_id = asset_map.get(fname)
if asset_id: 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)) actually_uploaded.append((fname, asset_id))
break break
+6 -5
View File
@@ -69,9 +69,10 @@ def process_face_mode(
output_dir: str, output_dir: str,
count: int, count: int,
min_width: int | None = None, min_width: int | None = None,
) -> bool: ) -> tuple[int, int] | None:
"""Crop face based on Immich metadata and save to output directory. """Crop face based on Immich metadata and save to output directory.
Returns (width, height) of the saved crop, or None if no crop was saved.
If face alignment is enabled and landmarks are available, produces If face alignment is enabled and landmarks are available, produces
an aligned 112x112 crop. Otherwise falls back to bounding box crop an aligned 112x112 crop. Otherwise falls back to bounding box crop
with configurable margin. with configurable margin.
@@ -90,7 +91,7 @@ def process_face_mode(
if not face_info: if not face_info:
logger.debug(f"No face info for {person.get('name')} in asset {asset.get('id')}") 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 img_w, img_h = img.size
meta_w = face_info.get("imageWidth") or img_w 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 face_w, face_h = x2 - x1, y2 - y1
if face_w < min_width or face_h < min_width: if face_w < min_width or face_h < min_width:
logger.debug(f"Face too small ({face_w:.1f}x{face_h:.1f})") logger.debug(f"Face too small ({face_w:.1f}x{face_h:.1f})")
return False return None
# Try face alignment if enabled and landmarks available # Try face alignment if enabled and landmarks available
if Config.ENABLE_FACE_ALIGNMENT: if Config.ENABLE_FACE_ALIGNMENT:
@@ -117,7 +118,7 @@ def process_face_mode(
aligned = align_face(img, scaled_landmarks) aligned = align_face(img, scaled_landmarks)
if aligned is not None: if aligned is not None:
_save_jpeg(aligned, os.path.join(output_dir, f"{count}.jpg")) _save_jpeg(aligned, os.path.join(output_dir, f"{count}.jpg"))
return True return aligned.size
# Fall back to bounding box crop with configurable margin # Fall back to bounding box crop with configurable margin
margin = Config.FACE_MARGIN margin = Config.FACE_MARGIN
@@ -131,7 +132,7 @@ def process_face_mode(
face_crop = img.crop(crop_box) face_crop = img.crop(crop_box)
_save_jpeg(face_crop, os.path.join(output_dir, f"{count}.jpg")) _save_jpeg(face_crop, os.path.join(output_dir, f"{count}.jpg"))
return True return face_crop.size
def process_object_mode( def process_object_mode(
+46 -4
View File
@@ -14,6 +14,7 @@ by_person schema (frigate_uploaded_ids.json):
"asset_ids": ["immich-id-1", ...], # all assets we attempted to upload "asset_ids": ["immich-id-1", ...], # all assets we attempted to upload
"scores": {"immich-id-1": 450.3}, # Laplacian blur variance at upload time "scores": {"immich-id-1": 450.3}, # Laplacian blur variance at upload time
"frigate_files": {"PersonName-123.webp": "immich-id-1"}, # Frigate filename → asset ID "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_count": 42 # last known Frigate training image count
} }
@@ -76,14 +77,21 @@ def _get_ids(entry: list | dict) -> list[str]:
def _migrate_entry(entry: list | dict) -> dict: def _migrate_entry(entry: list | dict) -> dict:
"""Ensure by_person entry is in the current dict format.""" """Ensure by_person entry is in the current dict format."""
if isinstance(entry, list): if isinstance(entry, list):
return {"asset_ids": sorted(entry), "scores": {}, "frigate_files": {}} return {"asset_ids": sorted(entry), "scores": {}, "frigate_files": {}, "crop_dims": {}}
entry.setdefault("asset_ids", []) entry.setdefault("asset_ids", [])
entry.setdefault("scores", {}) entry.setdefault("scores", {})
entry.setdefault("frigate_files", {}) entry.setdefault("frigate_files", {})
entry.setdefault("crop_dims", {})
return entry 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) data = _load(filename)
flat_key = _flat_key(filename) flat_key = _flat_key(filename)
flat = set(data.get(flat_key, [])) flat = set(data.get(flat_key, []))
@@ -97,6 +105,8 @@ def _mark(filename: str, asset_id: str, person_name: str | None, score: float |
entry["asset_ids"] = sorted(ids) entry["asset_ids"] = sorted(ids)
if score is not None: if score is not None:
entry["scores"][asset_id] = round(score, 4) 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 by_person[person_name] = entry
_save(filename, data) _save(filename, data)
@@ -111,8 +121,13 @@ def load_rejected_ids() -> set[str]:
return _load_flat(REJECT_TRACKER_FILE) return _load_flat(REJECT_TRACKER_FILE)
def mark_uploaded(asset_id: str, person_name: str | None = None, score: float | None = None) -> None: def mark_uploaded(
_mark(UPLOAD_TRACKER_FILE, asset_id, person_name, score=score) 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})") logger.debug(f"Marked {asset_id} as uploaded ({person_name})")
@@ -192,6 +207,33 @@ def get_lowest_quality_mapped_file(
return min(candidates, key=lambda x: x[2]) 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: def update_frigate_count(person_name: str, count: int) -> None:
"""Record Frigate's authoritative training image count for a person.""" """Record Frigate's authoritative training image count for a person."""
data = _load(UPLOAD_TRACKER_FILE) data = _load(UPLOAD_TRACKER_FILE)