Detect and handle duplicate Immich people with the same name
When Immich has multiple person records sharing a name (e.g. unmerged
face clusters), winnow would previously run separate jobs for each,
with the second job wiping the first job's output directory — resulting
in far fewer training images than expected.
New behaviour:
- At startup, duplicate names are detected and a warning is printed
showing asset counts for each duplicate.
- By default (MERGE_DUPLICATE_PEOPLE=false), only the person with the
most assets is processed; smaller duplicates are skipped cleanly.
- With MERGE_DUPLICATE_PEOPLE=true, the duplicates are permanently
merged inside Immich via PUT /api/people/{id}/merge (keeps the
largest), then the people list is re-fetched before jobs run.
Also adds an explicit comment in executor.py confirming that replacement
targets come exclusively from tracker-mapped files, so manually-added
Frigate training images are never selected for deletion.
This commit is contained in:
@@ -26,6 +26,7 @@ services:
|
|||||||
# - SKIP_PEOPLE=Unknown # Comma-separated; skip these people
|
# - SKIP_PEOPLE=Unknown # Comma-separated; skip these people
|
||||||
# - MIN_FACE_COUNT=5 # Skip people with fewer than N assets in Immich
|
# - 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)
|
# - 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 ─────────────────────────────────────────────────────
|
# ── Image Quality ─────────────────────────────────────────────────────
|
||||||
# - MIN_FACE_WIDTH=50 # Minimum face width in pixels (default: 50)
|
# - MIN_FACE_WIDTH=50 # Minimum face width in pixels (default: 50)
|
||||||
|
|||||||
+82
-1
@@ -9,7 +9,7 @@ from rich.prompt import Confirm
|
|||||||
|
|
||||||
from .config import Config, ConfigManager
|
from .config import Config, ConfigManager
|
||||||
from .executor import execute_jobs, upload_to_frigate
|
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 .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 find_by_crop_dimension, get_person_summary, reset_person
|
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)
|
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:
|
def main() -> None:
|
||||||
"""Entry point for winnow CLI."""
|
"""Entry point for winnow CLI."""
|
||||||
try:
|
try:
|
||||||
@@ -103,6 +182,8 @@ def main() -> None:
|
|||||||
rprint("[bold red]Could not fetch people from Immich. Check URL/Key.[/bold red]")
|
rprint("[bold red]Could not fetch people from Immich. Check URL/Key.[/bold red]")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
people = _handle_duplicate_people(people)
|
||||||
|
|
||||||
# Auto mode when no TTY (Docker, cron, pipes) — the primary use case.
|
# 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.
|
# 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")
|
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
|
# People filtering
|
||||||
MIN_FACE_COUNT: int = 0
|
MIN_FACE_COUNT: int = 0
|
||||||
|
MERGE_DUPLICATE_PEOPLE: bool = False
|
||||||
|
|
||||||
# Output quality
|
# Output quality
|
||||||
FACE_MARGIN: float = 0.15
|
FACE_MARGIN: float = 0.15
|
||||||
@@ -60,6 +61,7 @@ class _Config:
|
|||||||
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", "90"))
|
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.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.BLUR_THRESHOLD = float(os.getenv("BLUR_THRESHOLD", "120.0"))
|
||||||
self.MIN_CONFIDENCE = float(os.getenv("MIN_CONFIDENCE", "0.7"))
|
self.MIN_CONFIDENCE = float(os.getenv("MIN_CONFIDENCE", "0.7"))
|
||||||
self.MAX_AUTO_IMAGES = int(os.getenv("MAX_AUTO_IMAGES", "80"))
|
self.MAX_AUTO_IMAGES = int(os.getenv("MAX_AUTO_IMAGES", "80"))
|
||||||
|
|||||||
@@ -376,6 +376,8 @@ def upload_to_frigate(jobs: list[dict]) -> None:
|
|||||||
# Snapshot live Frigate files for post-upload reconciliation diff only.
|
# Snapshot live Frigate files for post-upload reconciliation diff only.
|
||||||
# effective_count is sourced from the tracker (mapped files) so that
|
# effective_count is sourced from the tracker (mapped files) so that
|
||||||
# manually-added Frigate files don't consume winnow's managed quota.
|
# 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 = (
|
_snapshot = (
|
||||||
all_frigate_files.get(name, []) if all_frigate_files is not None
|
all_frigate_files.get(name, []) if all_frigate_files is not None
|
||||||
else get_frigate_person_files(name)
|
else get_frigate_person_files(name)
|
||||||
|
|||||||
@@ -45,6 +45,26 @@ def get_people() -> list[dict]:
|
|||||||
return []
|
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]:
|
def fetch_all_assets(person: dict) -> list[dict]:
|
||||||
"""Fetch all assets for a person with pagination."""
|
"""Fetch all assets for a person with pagination."""
|
||||||
name = person.get("name", "Unknown")
|
name = person.get("name", "Unknown")
|
||||||
|
|||||||
Reference in New Issue
Block a user