Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9482eec4d | ||
|
|
694f860b6d | ||
|
|
6e34d41036 | ||
|
|
a9c1114b86 | ||
|
|
19f1a5e03b | ||
|
|
0a8a0c16dd | ||
|
|
eb3abe2cca | ||
|
|
168a8e33b5 | ||
|
|
fd0bd213e8 | ||
|
|
7efe283e9a | ||
|
|
2dd911e9ea | ||
|
|
341c6b0e85 | ||
|
|
6fb3d2f61d | ||
|
|
3888a5e6db | ||
|
|
b804b13644 | ||
|
|
62bc1b70c5 | ||
|
|
67f1845687 | ||
|
|
39111d3a6a | ||
|
|
5553877c90 | ||
|
|
e4edf202fa |
@@ -36,15 +36,13 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build (${{ matrix.platform }})
|
||||
runs-on: ${{ matrix.runner }}
|
||||
name: Build (linux/amd64)
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -63,10 +61,6 @@ jobs:
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
|
||||
- name: Set up QEMU
|
||||
if: matrix.platform == 'linux/arm64'
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
@@ -108,7 +102,7 @@ jobs:
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digest-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
|
||||
name: digest-amd64
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.4.4] - 2026-06-14
|
||||
|
||||
### Added
|
||||
|
||||
- **`RESET_PERSON=*` bulk reset**: resets every tracked person at once (deletes their Frigate training files and clears tracker data). Any other value still resets that specific person by name. If a person is literally named `*` they are reset as part of the bulk operation, and a warning is printed to clarify this.
|
||||
- **Near-duplicate removal before diversity selection**: a greedy dedup pass now runs after embedding collection and before clustering. Candidates within 0.20 cosine distance of a higher-quality image are dropped, eliminating burst shots and same-event lookalike photos that produce redundant training images. The best-quality frame from each near-identical group is kept. Dropped count is logged per person.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **HTTP 500 upload errors no longer show Frigate's misleading "Try restarting Frigate" message**: the response body is now logged at debug level only. HTTP 400 detail (e.g. "No face was detected") is still shown since it is actionable.
|
||||
- **`RuntimeWarning: Mean of empty slice`** when a person has only one image after quality filtering: `_compute_adaptive_threshold` now returns the floor value immediately when there are no pairwise distances to sample, and the k-medoids cluster count is floored at 1 to prevent `k=0`.
|
||||
- **Path traversal guard on output directory**: person names with `../` sequences or absolute paths (e.g. `/etc`) are now rejected before any filesystem operation, logging an error and skipping the job rather than writing outside the output tree.
|
||||
|
||||
## [0.4.3] - 2026-06-14
|
||||
|
||||
### Added
|
||||
|
||||
- **InsightFace landmark-based face crop alignment**: face crops for Frigate training are now aligned using InsightFace's `norm_crop` (ArcFace 112×112 alignment with 5-point facial landmarks). Previously, Immich's API returned only bounding boxes with no landmarks, so `align_face()` was dead code and crops were plain bbox slices — resulting in misaligned or partial crops (e.g. foreheads). The fix runs InsightFace detection on an expanded region around the Immich bbox, finds the nearest face, and uses its keypoints for proper alignment. Controlled by `ENABLE_FACE_ALIGNMENT` (default `true`).
|
||||
- **Duplicate Immich person detection and handling**: when multiple Immich person records share the same name, winnow now detects this at startup and warns with a per-group summary. Without handling, two jobs would run for the same Frigate folder and overwrite each other's output. By default (`MERGE_DUPLICATE_PEOPLE=false`) only the first person per name is processed. Set `MERGE_DUPLICATE_PEOPLE=true` to permanently merge duplicate records inside Immich (keeps the person with the most assets).
|
||||
|
||||
## [0.4.2] - 2026-06-13
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -36,6 +36,10 @@ CI runs both on every push and PR to `main` and `dev`. PRs must pass before merg
|
||||
- Keep the `CHANGELOG.md` entry in the `[Unreleased]` section updated.
|
||||
- Commit messages should be plain English describing what changed and why.
|
||||
|
||||
## Development Tooling
|
||||
|
||||
Development uses Claude Code (Anthropic) for implementation assistance. All code is reviewed and the final call on design, behavior, and what ships is made by the maintainer. Contributions from humans are equally welcome.
|
||||
|
||||
## License
|
||||
|
||||
By submitting a contribution you agree that your work will be released under the project's [AGPLv3+ license](LICENSE).
|
||||
|
||||
@@ -26,6 +26,7 @@ services:
|
||||
# - SKIP_PEOPLE=Unknown # Comma-separated; skip these people
|
||||
# - MIN_FACE_COUNT=5 # Skip people with fewer than N assets in Immich
|
||||
# - YEARS_FILTER=10 # Only include images from the last N years (default: 10)
|
||||
# - MERGE_DUPLICATE_PEOPLE=true # Auto-merge Immich people with the same name (keeps most assets)
|
||||
|
||||
# ── Image Quality ─────────────────────────────────────────────────────
|
||||
# - MIN_FACE_WIDTH=50 # Minimum face width in pixels (default: 50)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "winnow"
|
||||
version = "0.4.2"
|
||||
version = "0.4.4"
|
||||
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"
|
||||
|
||||
@@ -2348,7 +2348,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.4.1"
|
||||
version = "0.4.3"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "croniter" },
|
||||
|
||||
+99
-2
@@ -9,7 +9,7 @@ from rich.prompt import Confirm
|
||||
|
||||
from .config import Config, ConfigManager
|
||||
from .executor import execute_jobs, upload_to_frigate
|
||||
from .immich_api import get_people
|
||||
from .immich_api import get_people, merge_people
|
||||
from .jobs import _show_preview, auto_configure, interactive_configure
|
||||
from .log_config import console, setup_logging
|
||||
from .upload_tracker import find_by_crop_dimension, get_person_summary, reset_person
|
||||
@@ -51,6 +51,85 @@ def _handle_trace_crop(size_str: str) -> None:
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def _handle_duplicate_people(people: list[dict]) -> list[dict]:
|
||||
"""Warn about or merge Immich people that share the same name.
|
||||
|
||||
Duplicates arise when Immich creates separate person records for the same
|
||||
individual (e.g. unmerged face clusters). Without handling, winnow would
|
||||
run multiple jobs for the same Frigate folder and overwrite its own output,
|
||||
leaving far fewer training images than expected.
|
||||
|
||||
With MERGE_DUPLICATE_PEOPLE=false (default): prints a warning, skips the
|
||||
smaller duplicates so only the person with the most assets is processed,
|
||||
and returns a deduplicated people list.
|
||||
With MERGE_DUPLICATE_PEOPLE=true: merges each duplicate group inside
|
||||
Immich via its API (permanently combines the face records), then
|
||||
re-fetches the people list so the rest of the run sees the merged state.
|
||||
"""
|
||||
from collections import defaultdict
|
||||
|
||||
by_name: dict[str, list[dict]] = defaultdict(list)
|
||||
for p in people:
|
||||
name = (p.get("name") or "").strip()
|
||||
if name:
|
||||
by_name[name].append(p)
|
||||
|
||||
duplicates = {name: ps for name, ps in by_name.items() if len(ps) > 1}
|
||||
if not duplicates:
|
||||
return people
|
||||
|
||||
if not Config.MERGE_DUPLICATE_PEOPLE:
|
||||
rprint("\n[bold yellow]⚠ Duplicate person names detected in Immich:[/bold yellow]")
|
||||
for name, ps in sorted(duplicates.items()):
|
||||
ordered = sorted(ps, key=lambda x: x.get("assetCount", 0), reverse=True)
|
||||
entries = ", ".join(
|
||||
f"[dim]{p['id'][:8]}…[/dim] ({p.get('assetCount', 0)} assets)"
|
||||
for p in ordered
|
||||
)
|
||||
rprint(f" [yellow]{name}[/yellow] → {len(ps)} people: {entries}")
|
||||
skipped = ordered[1:]
|
||||
rprint(
|
||||
f" [dim] Processing largest only "
|
||||
f"({ordered[0].get('assetCount', 0)} assets). "
|
||||
f"Skipping {len(skipped)} smaller duplicate(s) to avoid overwriting output.[/dim]"
|
||||
)
|
||||
rprint(
|
||||
" [dim]Set MERGE_DUPLICATE_PEOPLE=true to permanently merge duplicates "
|
||||
"inside Immich (keeps the person with the most assets).[/dim]\n"
|
||||
)
|
||||
# Return deduplicated list — keep only the largest per name so that
|
||||
# downstream job creation never runs two jobs for the same Frigate folder.
|
||||
skip_ids = {
|
||||
p["id"]
|
||||
for ps in duplicates.values()
|
||||
for p in sorted(ps, key=lambda x: x.get("assetCount", 0), reverse=True)[1:]
|
||||
}
|
||||
return [p for p in people if p["id"] not in skip_ids]
|
||||
|
||||
# Auto-merge: survivor = largest asset count, rest merge into it inside Immich
|
||||
merged_any = False
|
||||
for name, ps in sorted(duplicates.items()):
|
||||
ordered = sorted(ps, key=lambda x: x.get("assetCount", 0), reverse=True)
|
||||
survivor = ordered[0]
|
||||
merge_ids = [p["id"] for p in ordered[1:]]
|
||||
rprint(
|
||||
f" [cyan]Merging {name!r} inside Immich:[/cyan] keeping "
|
||||
f"[dim]{survivor['id'][:8]}…[/dim] ({survivor.get('assetCount', 0)} assets), "
|
||||
f"absorbing {len(merge_ids)} smaller duplicate(s)..."
|
||||
)
|
||||
if merge_people(survivor["id"], merge_ids):
|
||||
rprint(f" [green]✓ Merged {name!r}[/green]")
|
||||
merged_any = True
|
||||
else:
|
||||
rprint(f" [red]✗ Failed to merge {name!r}[/red]")
|
||||
|
||||
if merged_any:
|
||||
rprint(" [dim]Re-fetching people after merge...[/dim]")
|
||||
return get_people()
|
||||
|
||||
return people
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Entry point for winnow CLI."""
|
||||
try:
|
||||
@@ -77,9 +156,25 @@ def main() -> None:
|
||||
rprint(f"Server: [dim]{Config.IMMICH_URL}[/dim]")
|
||||
rprint(f"Output: [dim]{Config.OUTPUT_DIR}[/dim]")
|
||||
|
||||
# Handle RESET_PERSON before anything else
|
||||
# Handle RESET_PERSON before anything else.
|
||||
# RESET_PERSON=* resets every tracked person; any other value resets
|
||||
# that specific person by name.
|
||||
reset_person_name = os.environ.get("RESET_PERSON", "").strip()
|
||||
if reset_person_name:
|
||||
if reset_person_name == "*":
|
||||
names = list(get_person_summary().keys())
|
||||
if "*" in names:
|
||||
rprint(
|
||||
"[yellow]Note: a person literally named '*' exists in the tracker "
|
||||
"and will be reset along with everyone else.[/yellow]"
|
||||
)
|
||||
if names:
|
||||
for name in names:
|
||||
reset_person(name)
|
||||
rprint(f"[bold yellow]Reset tracking data for all {len(names)} people.[/bold yellow]")
|
||||
else:
|
||||
rprint("[dim]No tracking data to reset.[/dim]")
|
||||
else:
|
||||
reset_person(reset_person_name)
|
||||
rprint(f"[bold yellow]Reset tracking data for: {reset_person_name}[/bold yellow]")
|
||||
|
||||
@@ -103,6 +198,8 @@ def main() -> None:
|
||||
rprint("[bold red]Could not fetch people from Immich. Check URL/Key.[/bold red]")
|
||||
return
|
||||
|
||||
people = _handle_duplicate_people(people)
|
||||
|
||||
# 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")
|
||||
|
||||
@@ -36,6 +36,7 @@ class _Config:
|
||||
|
||||
# People filtering
|
||||
MIN_FACE_COUNT: int = 0
|
||||
MERGE_DUPLICATE_PEOPLE: bool = False
|
||||
|
||||
# Output quality
|
||||
FACE_MARGIN: float = 0.15
|
||||
@@ -60,6 +61,7 @@ class _Config:
|
||||
self.YEARS_FILTER = int(os.getenv("YEARS_FILTER", "10"))
|
||||
self.MIN_FACE_WIDTH = int(os.getenv("MIN_FACE_WIDTH", "90"))
|
||||
self.MIN_FACE_COUNT = int(os.getenv("MIN_FACE_COUNT", "0"))
|
||||
self.MERGE_DUPLICATE_PEOPLE = os.getenv("MERGE_DUPLICATE_PEOPLE", "false").lower() in ("true", "1", "yes")
|
||||
self.BLUR_THRESHOLD = float(os.getenv("BLUR_THRESHOLD", "120.0"))
|
||||
self.MIN_CONFIDENCE = float(os.getenv("MIN_CONFIDENCE", "0.7"))
|
||||
self.MAX_AUTO_IMAGES = int(os.getenv("MAX_AUTO_IMAGES", "80"))
|
||||
|
||||
+66
-2
@@ -281,7 +281,16 @@ def _select_by_embedding(
|
||||
logger.warning(f"Only {len(valid_candidates)} valid embeddings. Returning all.")
|
||||
return valid_candidates
|
||||
|
||||
# --- Phase 5: Cluster-aware selection ---
|
||||
# --- Phase 5: Near-duplicate removal ---
|
||||
# Burst shots and repeated near-identical photos produce embeddings that are
|
||||
# close but not identical, so FPS doesn't filter them out on its own.
|
||||
# Greedily drop any candidate within DEDUP_THRESHOLD cosine distance of a
|
||||
# higher-quality image already in the kept set.
|
||||
embeddings, valid_candidates, confidence_scores = _dedup_embeddings(
|
||||
embeddings, valid_candidates, confidence_scores
|
||||
)
|
||||
|
||||
# --- Phase 6: Cluster-aware selection ---
|
||||
return _cluster_aware_selection(
|
||||
embeddings,
|
||||
valid_candidates,
|
||||
@@ -291,6 +300,59 @@ def _select_by_embedding(
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Near-Duplicate Removal
|
||||
# =============================================================================
|
||||
|
||||
_DEDUP_THRESHOLD = 0.20 # cosine distance — burst shots ~0.01-0.05, same-event similar shots ~0.10-0.20
|
||||
|
||||
|
||||
def _dedup_embeddings(
|
||||
embeddings: list,
|
||||
candidates: list,
|
||||
confidence_scores: list,
|
||||
) -> tuple[list, list, list]:
|
||||
"""Greedy near-duplicate removal before clustering.
|
||||
|
||||
Sorts by quality score descending (best first), then for each candidate
|
||||
drops it if any already-kept embedding is within _DEDUP_THRESHOLD cosine
|
||||
distance. This eliminates burst-shot near-duplicates while preserving the
|
||||
highest-quality representative from each near-identical group.
|
||||
"""
|
||||
if len(embeddings) < 2:
|
||||
return embeddings, candidates, confidence_scores
|
||||
|
||||
emb_matrix = np.vstack(embeddings)
|
||||
norms = np.linalg.norm(emb_matrix, axis=1, keepdims=True)
|
||||
emb_normed = emb_matrix / np.maximum(norms, 1e-8)
|
||||
|
||||
# Sort by quality descending so the best image in each near-duplicate group wins
|
||||
quality_scores = [c.get("quality_score") or 0.0 for c in candidates]
|
||||
order = sorted(range(len(candidates)), key=lambda i: quality_scores[i], reverse=True)
|
||||
|
||||
kept_indices = []
|
||||
kept_normed = []
|
||||
|
||||
for i in order:
|
||||
if kept_normed:
|
||||
kept_stack = np.vstack(kept_normed)
|
||||
sims = emb_normed[i] @ kept_stack.T
|
||||
if np.any(sims > 1 - _DEDUP_THRESHOLD):
|
||||
continue
|
||||
kept_indices.append(i)
|
||||
kept_normed.append(emb_normed[i])
|
||||
|
||||
dropped = len(embeddings) - len(kept_indices)
|
||||
if dropped:
|
||||
logger.info(f"Near-duplicate removal dropped {dropped} images (threshold {_DEDUP_THRESHOLD}).")
|
||||
|
||||
return (
|
||||
[embeddings[i] for i in kept_indices],
|
||||
[candidates[i] for i in kept_indices],
|
||||
[confidence_scores[i] for i in kept_indices],
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# K-Medoids (Lightweight Implementation)
|
||||
# =============================================================================
|
||||
@@ -373,6 +435,8 @@ def _compute_adaptive_threshold(emb_normed: np.ndarray, entity_type: str) -> flo
|
||||
# Compute pairwise cosine distances for the sample
|
||||
pairwise = 1 - sample @ sample.T
|
||||
upper_tri = pairwise[np.triu_indices(len(sample), k=1)]
|
||||
if len(upper_tri) == 0:
|
||||
return 0.05
|
||||
median_dist = float(np.median(upper_tri))
|
||||
|
||||
# Faces: 20% of median (tighter — want fewer, more distinct images)
|
||||
@@ -421,7 +485,7 @@ def _cluster_aware_selection(
|
||||
target = Config.MAX_AUTO_IMAGES if limit == "auto" else limit
|
||||
|
||||
# --- Stage 1: K-Medoids clustering ---
|
||||
k = min(max(5, target // 4), n // 3, n) # e.g., 5-20 clusters
|
||||
k = min(max(5, target // 4), max(1, n // 3), n) # e.g., 1-20 clusters
|
||||
logger.debug(f"Clustering {n} embeddings into {k} groups (K-Medoids)...")
|
||||
|
||||
# Compute full cosine distance matrix
|
||||
|
||||
+46
-5
@@ -38,6 +38,19 @@ from .upload_tracker import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _safe_person_dir(output_dir: str, person_name: str) -> str:
|
||||
"""Return the output subdirectory for a person, raising ValueError on path traversal.
|
||||
|
||||
os.path.join silently discards output_dir when person_name is absolute,
|
||||
and '../..' sequences resolve outside the tree. Both are rejected here.
|
||||
"""
|
||||
candidate = os.path.realpath(os.path.join(output_dir, person_name))
|
||||
base = os.path.realpath(output_dir)
|
||||
if not candidate.startswith(base + os.sep) and candidate != base:
|
||||
raise ValueError(f"Person name {person_name!r} escapes output directory — skipping")
|
||||
return candidate
|
||||
|
||||
|
||||
def _reconcile_frigate_mappings(
|
||||
person_name: str,
|
||||
known_files_before: set[str],
|
||||
@@ -155,6 +168,18 @@ def execute_jobs(jobs: list[dict]) -> None:
|
||||
|
||||
use_full_res = Config.USE_FULL_RESOLUTION
|
||||
|
||||
# Load InsightFace app for landmark-based crop alignment (face mode only).
|
||||
# The model is already resident from the diversity/embedding phase, so this
|
||||
# is just a singleton lookup — no load cost.
|
||||
insightface_app = None
|
||||
if any(j["config"].get("mode", "face") == "face" for j in jobs) and Config.ENABLE_FACE_ALIGNMENT:
|
||||
try:
|
||||
from .embeddings import get_insightface_app
|
||||
|
||||
insightface_app = get_insightface_app()
|
||||
except Exception as e:
|
||||
logger.debug(f"InsightFace unavailable for crop alignment: {e}")
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
@@ -170,7 +195,11 @@ def execute_jobs(jobs: list[dict]) -> None:
|
||||
name, mode = person["name"], config.get("mode", "face")
|
||||
|
||||
job_task = progress.add_task(f"Processing {name}...", total=len(assets))
|
||||
person_dir = os.path.join(Config.OUTPUT_DIR, name)
|
||||
try:
|
||||
person_dir = _safe_person_dir(Config.OUTPUT_DIR, name)
|
||||
except ValueError as e:
|
||||
logger.error(str(e))
|
||||
continue
|
||||
# Face crops are transient (uploaded then discarded); wipe before each run.
|
||||
# Object crops are the deliverable; preserve them across runs.
|
||||
if mode == "face" and os.path.isdir(person_dir):
|
||||
@@ -216,7 +245,7 @@ def execute_jobs(jobs: list[dict]) -> None:
|
||||
progress.console.print(f"[red]Failed download {asset['id']}[/red]")
|
||||
else:
|
||||
saved = (
|
||||
process_face_mode(img, asset, person, person_dir, count)
|
||||
process_face_mode(img, asset, person, person_dir, count, insightface_app=insightface_app)
|
||||
if mode == "face"
|
||||
else process_object_mode(img, config, person_dir, count)
|
||||
if mode == "object"
|
||||
@@ -293,7 +322,11 @@ def upload_to_frigate(jobs: list[dict]) -> None:
|
||||
object_jobs = [j for j in jobs if j["config"].get("mode") == "object"]
|
||||
for job in object_jobs:
|
||||
name = job["person"]["name"]
|
||||
person_dir = os.path.join(Config.OUTPUT_DIR, name)
|
||||
try:
|
||||
person_dir = _safe_person_dir(Config.OUTPUT_DIR, name)
|
||||
except ValueError as e:
|
||||
logger.error(str(e))
|
||||
continue
|
||||
rprint(f" [dim]📁 {name} (object): crops saved to {person_dir} — copy to Frigate manually[/dim]")
|
||||
|
||||
frigate_url = os.environ.get("FRIGATE_URL", "")
|
||||
@@ -343,7 +376,11 @@ def upload_to_frigate(jobs: list[dict]) -> None:
|
||||
if " " in name:
|
||||
progress.console.print(f" ℹ️ URL-encoded name for Frigate API: '{name}' → '{encoded_name}'")
|
||||
|
||||
person_dir = os.path.join(Config.OUTPUT_DIR, name)
|
||||
try:
|
||||
person_dir = _safe_person_dir(Config.OUTPUT_DIR, name)
|
||||
except ValueError as e:
|
||||
logger.error(str(e))
|
||||
continue
|
||||
if not os.path.isdir(person_dir):
|
||||
progress.console.print(f" [dim]⏭️ {name}: no output directory, skipping[/dim]")
|
||||
continue
|
||||
@@ -364,6 +401,8 @@ def upload_to_frigate(jobs: list[dict]) -> None:
|
||||
# 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.
|
||||
# Replacement targets also come exclusively from the tracker, so manually
|
||||
# added files are never selected for deletion — only winnow-uploaded ones.
|
||||
_snapshot = (
|
||||
all_frigate_files.get(name, []) if all_frigate_files is not None
|
||||
else get_frigate_person_files(name)
|
||||
@@ -566,10 +605,12 @@ def upload_to_frigate(jobs: list[dict]) -> None:
|
||||
)
|
||||
try:
|
||||
error_detail = resp.json().get("message", resp.text[:100])
|
||||
progress.console.print(f" [dim]{error_detail}[/dim]")
|
||||
except Exception:
|
||||
error_detail = resp.text[:100]
|
||||
if resp.status_code == 400:
|
||||
progress.console.print(f" [dim]{error_detail}[/dim]")
|
||||
else:
|
||||
logger.debug(f"{fname} HTTP {resp.status_code}: {error_detail}")
|
||||
if resp.status_code == 400 and "face" in error_detail.lower():
|
||||
asset_id = asset_map.get(fname)
|
||||
if asset_id:
|
||||
|
||||
@@ -69,13 +69,15 @@ def process_face_mode(
|
||||
output_dir: str,
|
||||
count: int,
|
||||
min_width: int | None = None,
|
||||
insightface_app=None,
|
||||
) -> 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.
|
||||
When insightface_app is provided and ENABLE_FACE_ALIGNMENT is True,
|
||||
re-detects the face in the Immich bbox region using InsightFace to get
|
||||
precise landmarks for a proper 112x112 aligned crop. Falls back to
|
||||
bounding box crop with configurable margin if alignment is unavailable.
|
||||
"""
|
||||
min_width = min_width or Config.MIN_FACE_WIDTH
|
||||
|
||||
@@ -109,18 +111,51 @@ def process_face_mode(
|
||||
logger.debug(f"Face too small ({face_w:.1f}x{face_h:.1f})")
|
||||
return None
|
||||
|
||||
# Try face alignment if enabled and landmarks available
|
||||
# Re-detect face with InsightFace for landmark-based alignment.
|
||||
# Immich's /api/faces endpoint does not include landmarks, so the
|
||||
# align_face fallback below never fires without this step.
|
||||
if insightface_app is not None and Config.ENABLE_FACE_ALIGNMENT:
|
||||
try:
|
||||
# Expand the Immich bbox by 50% to give InsightFace enough context
|
||||
# for detection and alignment, then search for the face nearest the
|
||||
# centre of that region (handles group photos at the boundary).
|
||||
pad_x, pad_y = face_w * 0.5, face_h * 0.5
|
||||
search_box = (
|
||||
max(0, x1 - pad_x),
|
||||
max(0, y1 - pad_y),
|
||||
min(img_w, x2 + pad_x),
|
||||
min(img_h, y2 + pad_y),
|
||||
)
|
||||
search_crop = img.crop(search_box)
|
||||
detected = insightface_app.get(np.asarray(search_crop))
|
||||
if detected:
|
||||
cx, cy = search_crop.width / 2, search_crop.height / 2
|
||||
best = min(
|
||||
detected,
|
||||
key=lambda f: abs((f.bbox[0] + f.bbox[2]) / 2 - cx)
|
||||
+ abs((f.bbox[1] + f.bbox[3]) / 2 - cy),
|
||||
)
|
||||
kps = getattr(best, "kps", None)
|
||||
if kps is not None and np.asarray(kps).shape == (5, 2):
|
||||
aligned = align_face(search_crop, kps)
|
||||
if aligned is not None:
|
||||
_save_jpeg(aligned, os.path.join(output_dir, f"{count}.jpg"))
|
||||
return aligned.size
|
||||
except Exception as e:
|
||||
logger.debug(f"InsightFace re-detection failed for {asset.get('id')}: {e}")
|
||||
|
||||
# Landmark alignment from Immich metadata (Immich does not currently
|
||||
# expose landmarks, so this path is a future-proofing fallback)
|
||||
if Config.ENABLE_FACE_ALIGNMENT:
|
||||
landmarks = face_info.get("landmarks") or face_info.get("landmark")
|
||||
if landmarks:
|
||||
# Scale landmarks
|
||||
scaled_landmarks = [[lm[0] * scale_x, lm[1] * scale_y] for lm in landmarks]
|
||||
aligned = align_face(img, scaled_landmarks)
|
||||
if aligned is not None:
|
||||
_save_jpeg(aligned, os.path.join(output_dir, f"{count}.jpg"))
|
||||
return aligned.size
|
||||
|
||||
# Fall back to bounding box crop with configurable margin
|
||||
# Final fallback: bounding box crop with configurable margin
|
||||
margin = Config.FACE_MARGIN
|
||||
margin_x, margin_y = face_w * margin, face_h * margin
|
||||
crop_box = (
|
||||
|
||||
@@ -45,6 +45,26 @@ def get_people() -> list[dict]:
|
||||
return []
|
||||
|
||||
|
||||
def merge_people(survivor_id: str, merge_ids: list[str]) -> bool:
|
||||
"""Merge duplicate people into survivor via Immich's merge endpoint.
|
||||
|
||||
The survivor (identified by survivor_id) absorbs all faces and assets
|
||||
from the people in merge_ids, which are then removed from Immich.
|
||||
"""
|
||||
try:
|
||||
resp = requests.put(
|
||||
f"{Config.IMMICH_URL}/api/people/{survivor_id}/merge",
|
||||
headers={**get_headers(), "Content-Type": "application/json"},
|
||||
json={"ids": merge_ids},
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return True
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to merge people into {survivor_id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def fetch_all_assets(person: dict) -> list[dict]:
|
||||
"""Fetch all assets for a person with pagination."""
|
||||
name = person.get("name", "Unknown")
|
||||
|
||||
Reference in New Issue
Block a user