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>
This commit is contained in:
2026-06-13 15:20:54 +00:00
co-authored by Claude Sonnet 4.6
parent 80c5b563b2
commit 19416cc7b8
6 changed files with 108 additions and 13 deletions
+6
View File
@@ -7,6 +7,12 @@ 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
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "winnow"
version = "0.3.1"
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"
+37 -1
View File
@@ -12,17 +12,53 @@ from .executor import execute_jobs, upload_to_frigate
from .immich_api import get_people
from .jobs import _show_preview, auto_configure, interactive_configure
from .log_config import console, setup_logging
from .upload_tracker import get_person_summary, reset_person
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:
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]
[dim]Immich -> Frigate Training Data Curator[/dim]
+12 -2
View File
@@ -170,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:
@@ -208,6 +209,8 @@ def execute_jobs(jobs: list[dict]) -> None:
filename = f"{count}.jpg"
asset_map[filename] = asset["id"]
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
@@ -244,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)
@@ -324,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:
@@ -424,7 +429,12 @@ def upload_to_frigate(jobs: list[dict]) -> None:
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
+6 -5
View File
@@ -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(
+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
"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
}
@@ -76,14 +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": {}, "frigate_files": {}}
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, []))
@@ -97,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)
@@ -111,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})")
@@ -192,6 +207,33 @@ def get_lowest_quality_mapped_file(
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)