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:
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user