Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e1114ce95 | ||
|
|
dd582fa252 | ||
|
|
c57e1a5d45 | ||
|
|
85830809f2 | ||
|
|
9598142997 | ||
|
|
82dbc8502a | ||
|
|
6311227763 | ||
|
|
19416cc7b8 | ||
|
|
80c5b563b2 | ||
|
|
de1df19642 | ||
|
|
3ae6a86684 | ||
|
|
621c310d5b | ||
|
|
467f258fa5 | ||
|
|
36a5fb56b6 | ||
|
|
f90af65cd2 | ||
|
|
cd3fad8afe | ||
|
|
102827537e | ||
|
|
aa12c79f9d | ||
|
|
0f6b17f76f | ||
|
|
c61e4dc698 |
+1
-1
@@ -23,7 +23,7 @@ STRATEGY=auto
|
||||
# YEARS_FILTER=10 # Only include images from the last N years (default: 10)
|
||||
|
||||
# ── 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)
|
||||
# ENABLE_FACE_ALIGNMENT=true # Align face before cropping (default: true)
|
||||
# USE_FULL_RESOLUTION=true # Use full-res images vs thumbnails (default: true)
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
|
||||
- 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
|
||||
@@ -67,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
|
||||
@@ -164,7 +164,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
@@ -186,7 +186,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Build and push CPU image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
@@ -248,7 +248,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Build and push ROCm image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
@@ -310,7 +310,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Build and push Intel image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
|
||||
@@ -146,7 +146,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
@@ -159,7 +159,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push GPU image (latest)
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
@@ -191,7 +191,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
@@ -204,7 +204,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push CPU image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
@@ -238,7 +238,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
@@ -251,7 +251,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push ROCm image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
@@ -285,7 +285,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
@@ -298,7 +298,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Intel image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
|
||||
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [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
|
||||
|
||||
### Fixed
|
||||
|
||||
+1
-1
@@ -25,7 +25,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",
|
||||
]
|
||||
|
||||
|
||||
@@ -25,7 +25,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",
|
||||
]
|
||||
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ dependencies = [
|
||||
"rich>=14.2.0",
|
||||
"torch>=2.5.0",
|
||||
"torchvision>=0.20.0",
|
||||
"transformers>=4.57.6",
|
||||
"transformers>=5.12.0",
|
||||
"ultralytics>=8.4.66",
|
||||
]
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
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."
|
||||
license = "AGPL-3.0-or-later"
|
||||
requires-python = ">=3.13"
|
||||
@@ -29,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",
|
||||
]
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ def test_config_loads_defaults(monkeypatch):
|
||||
assert cfg.API_KEY == "test-key"
|
||||
assert cfg.OUTPUT_DIR == "./frigate_train"
|
||||
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.BLUR_THRESHOLD == 100.0
|
||||
assert cfg.MIN_CONFIDENCE == 0.7
|
||||
|
||||
+1
-1
@@ -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 = "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 = "transformers", specifier = ">=4.57.6" },
|
||||
{ name = "transformers", specifier = ">=5.12.0" },
|
||||
{ name = "ultralytics", specifier = ">=8.4.66" },
|
||||
]
|
||||
|
||||
|
||||
+1
-1
@@ -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 = "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 = "transformers", specifier = ">=4.57.6" },
|
||||
{ name = "transformers", specifier = ">=5.12.0" },
|
||||
{ name = "ultralytics", specifier = ">=8.4.66" },
|
||||
]
|
||||
|
||||
|
||||
+1
-1
@@ -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 = "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 = "transformers", specifier = ">=4.57.6" },
|
||||
{ name = "transformers", specifier = ">=5.12.0" },
|
||||
{ name = "ultralytics", specifier = ">=8.4.66" },
|
||||
]
|
||||
|
||||
|
||||
@@ -2348,7 +2348,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.3.1"
|
||||
version = "0.3.3"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ 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 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 = "transformers", specifier = ">=4.57.6" },
|
||||
{ name = "transformers", specifier = ">=5.12.0" },
|
||||
{ name = "ultralytics", specifier = ">=8.4.66" },
|
||||
]
|
||||
|
||||
|
||||
+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]
|
||||
|
||||
+2
-2
@@ -26,7 +26,7 @@ class _Config:
|
||||
YEARS_FILTER: int = 10
|
||||
|
||||
# Quality filtering
|
||||
MIN_FACE_WIDTH: int = 50
|
||||
MIN_FACE_WIDTH: int = 90
|
||||
BLUR_THRESHOLD: float = 100.0
|
||||
MIN_CONFIDENCE: float = 0.7
|
||||
MAX_AUTO_IMAGES: int = 80
|
||||
@@ -56,7 +56,7 @@ class _Config:
|
||||
self.API_KEY = os.getenv("API_KEY")
|
||||
self.OUTPUT_DIR = os.getenv("OUTPUT_DIR", "./frigate_train")
|
||||
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.BLUR_THRESHOLD = float(os.getenv("BLUR_THRESHOLD", "100.0"))
|
||||
self.MIN_CONFIDENCE = float(os.getenv("MIN_CONFIDENCE", "0.7"))
|
||||
|
||||
+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