feat: Implement advanced diversity selection algorithms, comprehensive quality filtering, and caching for improved image curation.
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
"""Immich to Frigate training set curator.
|
||||
|
||||
AI-powered tool to extract high-quality, diverse training images from your
|
||||
Immich library for Frigate's Face Recognition (ArcFace) and Object/State
|
||||
Classification models.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Disk-based embedding cache.
|
||||
|
||||
Caches embeddings keyed by (asset_id, model_version) to avoid
|
||||
recomputing on reruns. Uses numpy binary format for fast I/O.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Model versions — bump these when the upstream model changes
|
||||
MODEL_VERSIONS = {
|
||||
"insightface": "buffalo_l_v1",
|
||||
"siglip": "siglip-base-patch16-224_v1",
|
||||
"immich": "immich_buffalo_l_v1",
|
||||
}
|
||||
|
||||
|
||||
class EmbeddingCache:
|
||||
"""Simple disk-based embedding cache.
|
||||
|
||||
Embeddings are stored as .npy files in a flat directory,
|
||||
keyed by a hash of (asset_id, model_version).
|
||||
"""
|
||||
|
||||
def __init__(self, cache_dir: str = ".if_cache") -> None:
|
||||
self.cache_dir = cache_dir
|
||||
self._ensured = False
|
||||
|
||||
def _ensure_dir(self) -> None:
|
||||
if not self._ensured:
|
||||
os.makedirs(self.cache_dir, exist_ok=True)
|
||||
self._ensured = True
|
||||
|
||||
@staticmethod
|
||||
def _key(asset_id: str, model: str) -> str:
|
||||
version = MODEL_VERSIONS.get(model, model)
|
||||
raw = f"{asset_id}:{version}"
|
||||
return hashlib.sha256(raw.encode()).hexdigest()[:16]
|
||||
|
||||
def _path(self, asset_id: str, model: str) -> str:
|
||||
return os.path.join(self.cache_dir, f"{self._key(asset_id, model)}.npy")
|
||||
|
||||
def get(self, asset_id: str, model: str = "insightface") -> np.ndarray | None:
|
||||
"""Retrieve cached embedding, or None if not cached."""
|
||||
path = self._path(asset_id, model)
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
return np.load(path)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
def put(self, asset_id: str, embedding: np.ndarray, model: str = "insightface") -> None:
|
||||
"""Store an embedding in the cache."""
|
||||
self._ensure_dir()
|
||||
try:
|
||||
np.save(self._path(asset_id, model), embedding)
|
||||
except Exception as e:
|
||||
logger.debug(f"Cache write failed for {asset_id}: {e}")
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Delete all cached embeddings."""
|
||||
if not os.path.isdir(self.cache_dir):
|
||||
return
|
||||
count = 0
|
||||
for f in os.listdir(self.cache_dir):
|
||||
if f.endswith(".npy"):
|
||||
os.remove(os.path.join(self.cache_dir, f))
|
||||
count += 1
|
||||
logger.info(f"Cleared {count} cached embeddings.")
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_cache: EmbeddingCache | None = None
|
||||
|
||||
|
||||
def get_cache(cache_dir: str = ".if_cache") -> EmbeddingCache:
|
||||
"""Get or create the singleton cache instance."""
|
||||
global _cache
|
||||
if _cache is None:
|
||||
_cache = EmbeddingCache(cache_dir)
|
||||
return _cache
|
||||
+103
-40
@@ -9,13 +9,15 @@ from PIL import Image
|
||||
from rich import print as rprint
|
||||
from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn
|
||||
from rich.prompt import Confirm, IntPrompt, Prompt
|
||||
from rich.table import Table
|
||||
|
||||
from .config import Config, ConfigManager
|
||||
from .diversity import select_diverse_assets
|
||||
from .embeddings import is_embedding_available
|
||||
from .image_processing import process_face_mode, process_full_mode, process_object_mode
|
||||
from .immich_api import fetch_all_assets, filter_recent_assets, get_people
|
||||
from .immich_api import fetch_all_assets, fetch_full_image, filter_recent_assets, get_people
|
||||
from .logging import console, setup_logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Strategy presets: (limit, mode_name)
|
||||
@@ -62,23 +64,9 @@ def _get_strategy_choice(has_embedding: bool, entity_type: str) -> tuple[int | s
|
||||
return limits.get(choice, 0), "time" if choice != "4" else "skip"
|
||||
|
||||
|
||||
def interactive_configure(people: list[dict]) -> list[dict]:
|
||||
"""Interactive phase: select person, mode, and configure training strategy."""
|
||||
valid_people = sorted([p for p in people if p.get("name")], key=lambda x: x["name"])
|
||||
|
||||
if not valid_people:
|
||||
rprint("[red]No people found with names in Immich.[/red]")
|
||||
return []
|
||||
|
||||
# Select person
|
||||
console.print("\n[bold cyan]Select Person to Train:[/bold cyan]")
|
||||
for idx, p in enumerate(valid_people, 1):
|
||||
console.print(f" [bold]{idx}.[/bold] {p['name']}")
|
||||
|
||||
p_choice = IntPrompt.ask("Enter Number", choices=[str(i) for i in range(1, len(valid_people) + 1)])
|
||||
person = valid_people[p_choice - 1]
|
||||
def _configure_person(person: dict, people: list[dict]) -> dict | None:
|
||||
"""Configure training for a single person. Returns job dict or None."""
|
||||
name = person["name"]
|
||||
|
||||
console.print(f"\nSelected: [bold green]{name}[/bold green]")
|
||||
|
||||
# Select training mode
|
||||
@@ -105,7 +93,7 @@ def interactive_configure(people: list[dict]) -> list[dict]:
|
||||
|
||||
if not recent_assets:
|
||||
rprint(" [dim]Skipping (0 recent images).[/dim]")
|
||||
return []
|
||||
return None
|
||||
|
||||
# Strategy selection
|
||||
has_embedding = is_embedding_available(entity_type)
|
||||
@@ -113,18 +101,52 @@ def interactive_configure(people: list[dict]) -> list[dict]:
|
||||
|
||||
limit, selection_mode = _get_strategy_choice(has_embedding, entity_type)
|
||||
if selection_mode == "skip":
|
||||
return []
|
||||
return None
|
||||
|
||||
# Perform selection
|
||||
selected_assets = _perform_selection(recent_assets, limit, name, selection_mode, entity_type)
|
||||
|
||||
rprint(f" [green]Queued {len(selected_assets)} images for {name}.[/green]")
|
||||
return [{"person": person, "assets": selected_assets, "limit": len(selected_assets), "config": config}]
|
||||
return {"person": person, "assets": selected_assets, "limit": len(selected_assets), "config": config}
|
||||
|
||||
|
||||
def _perform_selection(
|
||||
assets: list, limit: int | str, name: str, selection_mode: str, entity_type: str
|
||||
) -> list:
|
||||
def interactive_configure(people: list[dict]) -> list[dict]:
|
||||
"""Interactive phase: select person(s), mode, and configure training strategy.
|
||||
|
||||
Supports multi-person batch mode — after configuring one person,
|
||||
prompts to add another.
|
||||
"""
|
||||
valid_people = sorted([p for p in people if p.get("name")], key=lambda x: x["name"])
|
||||
|
||||
if not valid_people:
|
||||
rprint("[red]No people found with names in Immich.[/red]")
|
||||
return []
|
||||
|
||||
jobs = []
|
||||
|
||||
while True:
|
||||
# Select person
|
||||
console.print("\n[bold cyan]Select Person to Train:[/bold cyan]")
|
||||
for idx, p in enumerate(valid_people, 1):
|
||||
# Mark already-queued people
|
||||
marker = " [dim](queued)[/dim]" if any(j["person"]["id"] == p["id"] for j in jobs) else ""
|
||||
console.print(f" [bold]{idx}.[/bold] {p['name']}{marker}")
|
||||
|
||||
p_choice = IntPrompt.ask("Enter Number", choices=[str(i) for i in range(1, len(valid_people) + 1)])
|
||||
person = valid_people[p_choice - 1]
|
||||
|
||||
job = _configure_person(person, valid_people)
|
||||
if job:
|
||||
jobs.append(job)
|
||||
|
||||
# Multi-person: ask to add another
|
||||
if not Confirm.ask("\nAdd another person?", default=False):
|
||||
break
|
||||
|
||||
return jobs
|
||||
|
||||
|
||||
def _perform_selection(assets: list, limit: int | str, name: str, selection_mode: str, entity_type: str) -> list:
|
||||
"""Run diversity selection with progress display."""
|
||||
if selection_mode == "smart":
|
||||
model_display = "InsightFace (face embeddings)" if entity_type == "face" else "SigLIP (visual embeddings)"
|
||||
@@ -134,12 +156,17 @@ def _perform_selection(
|
||||
is_embedding_available(entity_type)
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(), TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(), TaskProgressColumn(), console=console,
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(),
|
||||
TaskProgressColumn(),
|
||||
console=console,
|
||||
) as progress:
|
||||
task = progress.add_task(f"[cyan]Computing embeddings for {len(assets)} images...", total=None)
|
||||
selected = select_diverse_assets(
|
||||
assets, limit, name,
|
||||
assets,
|
||||
limit,
|
||||
name,
|
||||
selection_mode=selection_mode,
|
||||
entity_type=entity_type,
|
||||
progress_callback=lambda c, t: progress.update(task, completed=c, total=t),
|
||||
@@ -156,6 +183,30 @@ def _perform_selection(
|
||||
return selected
|
||||
|
||||
|
||||
def _show_preview(jobs: list[dict]) -> None:
|
||||
"""Show a summary table of all queued jobs before execution."""
|
||||
table = Table(title="📋 Training Job Preview", show_header=True, header_style="bold cyan")
|
||||
table.add_column("Person", style="bold")
|
||||
table.add_column("Mode", style="dim")
|
||||
table.add_column("Images", justify="right")
|
||||
table.add_column("Date Range", style="dim")
|
||||
|
||||
for job in jobs:
|
||||
name = job["person"]["name"]
|
||||
mode = job["config"].get("mode", "face")
|
||||
count = str(job["limit"])
|
||||
|
||||
# Date range
|
||||
dates = sorted(a.get("fileCreatedAt", "")[:10] for a in job["assets"] if a.get("fileCreatedAt"))
|
||||
date_range = f"{dates[0]} → {dates[-1]}" if len(dates) >= 2 else (dates[0] if dates else "—")
|
||||
|
||||
table.add_row(name, mode, count, date_range)
|
||||
|
||||
console.print()
|
||||
console.print(table)
|
||||
console.print()
|
||||
|
||||
|
||||
def execute_jobs(jobs: list[dict]) -> None:
|
||||
"""Download and process images for all jobs."""
|
||||
if not jobs:
|
||||
@@ -163,9 +214,14 @@ def execute_jobs(jobs: list[dict]) -> None:
|
||||
|
||||
console.rule("[bold blue]Execution Phase")
|
||||
|
||||
use_full_res = Config.USE_FULL_RESOLUTION
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(), TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(), TaskProgressColumn(), console=console,
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(),
|
||||
TaskProgressColumn(),
|
||||
console=console,
|
||||
) as progress:
|
||||
grand_total = sum(j["limit"] for j in jobs)
|
||||
overall_task = progress.add_task("[green]Overall Progress", total=grand_total)
|
||||
@@ -181,17 +237,22 @@ def execute_jobs(jobs: list[dict]) -> None:
|
||||
count = 0
|
||||
for asset in assets:
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"{Config.IMMICH_URL}/api/assets/{asset['id']}/thumbnail?size=preview&format=JPEG",
|
||||
headers={"x-api-key": Config.API_KEY, "Accept": "application/json"},
|
||||
timeout=30,
|
||||
)
|
||||
if not resp.ok:
|
||||
# Use full-resolution for final output when configured
|
||||
if use_full_res:
|
||||
img = fetch_full_image(asset["id"])
|
||||
else:
|
||||
resp = requests.get(
|
||||
f"{Config.IMMICH_URL}/api/assets/{asset['id']}/thumbnail?size=preview&format=JPEG",
|
||||
headers={"x-api-key": Config.API_KEY, "Accept": "application/json"},
|
||||
timeout=30,
|
||||
)
|
||||
img = Image.open(BytesIO(resp.content)) if resp.ok else None
|
||||
|
||||
if img is None:
|
||||
progress.console.print(f"[red]Failed download {asset['id']}[/red]")
|
||||
else:
|
||||
img = Image.open(BytesIO(resp.content))
|
||||
saved = (
|
||||
process_face_mode(img, asset, person, person_dir, count, min_width=Config.MIN_FACE_WIDTH)
|
||||
process_face_mode(img, asset, person, person_dir, count)
|
||||
if mode == "face"
|
||||
else process_object_mode(img, config, person_dir, count)
|
||||
if mode == "object"
|
||||
@@ -236,10 +297,12 @@ def main() -> None:
|
||||
|
||||
jobs = interactive_configure(people)
|
||||
|
||||
if jobs and Confirm.ask(f"\nReady to process {sum(j['limit'] for j in jobs)} images?"):
|
||||
execute_jobs(jobs)
|
||||
rprint("\n[bold green]Done! Happy Training.[/bold green]")
|
||||
elif not jobs:
|
||||
if jobs:
|
||||
_show_preview(jobs)
|
||||
if Confirm.ask(f"Ready to process {sum(j['limit'] for j in jobs)} images?"):
|
||||
execute_jobs(jobs)
|
||||
rprint("\n[bold green]Done! Happy Training.[/bold green]")
|
||||
else:
|
||||
rprint("[yellow]No jobs configured.[/yellow]")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
|
||||
+15
-1
@@ -24,7 +24,21 @@ class Config:
|
||||
API_KEY: str | None = None
|
||||
OUTPUT_DIR: str = "./frigate_train"
|
||||
YEARS_FILTER: int = 10
|
||||
MIN_FACE_WIDTH: int = 50
|
||||
|
||||
# Quality filtering
|
||||
MIN_FACE_WIDTH: int = 100
|
||||
BLUR_THRESHOLD: float = 100.0
|
||||
MIN_CONFIDENCE: float = 0.7
|
||||
MAX_AUTO_IMAGES: int = 80
|
||||
|
||||
# Output quality
|
||||
FACE_MARGIN: float = 0.15
|
||||
USE_FULL_RESOLUTION: bool = True
|
||||
ENABLE_FACE_ALIGNMENT: bool = True
|
||||
|
||||
# Caching (opt-in to avoid unexpected files)
|
||||
ENABLE_CACHE: bool = False
|
||||
CACHE_DIR: str = ".if_cache"
|
||||
|
||||
def __new__(cls) -> "Config":
|
||||
if cls._instance is None:
|
||||
|
||||
+332
-32
@@ -1,9 +1,12 @@
|
||||
"""
|
||||
Diversity selection for training data curation.
|
||||
|
||||
Uses Farthest Point Sampling (FPS) algorithm with embeddings:
|
||||
- Faces: InsightFace embeddings
|
||||
- Objects: SigLIP embeddings
|
||||
Selection pipeline:
|
||||
1. Concurrent thumbnail download
|
||||
2. Quality filtering (blur, IR, exposure, confidence, face size)
|
||||
3. Face crop extraction (embed person's face, not full image)
|
||||
4. Embedding computation (InsightFace or SigLIP)
|
||||
5. Cluster-aware selection (K-Medoids + FPS with hard example weighting)
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -15,6 +18,7 @@ from PIL import Image
|
||||
|
||||
from .config import Config, get_headers
|
||||
from .embeddings import get_embedding, is_embedding_available
|
||||
from .quality import assess_quality
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -28,7 +32,7 @@ def select_diverse_assets(
|
||||
progress_callback=None,
|
||||
) -> list:
|
||||
"""
|
||||
Select diverse assets using Farthest Point Sampling or time spread.
|
||||
Select diverse assets using cluster-aware FPS or time spread.
|
||||
|
||||
Args:
|
||||
assets: List of asset dicts from Immich API
|
||||
@@ -61,6 +65,11 @@ def select_diverse_assets(
|
||||
return _select_time_spread(assets, limit)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Thumbnail & Metadata Helpers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _fetch_thumbnail(asset_id: str, timeout: int = 10) -> Image.Image | None:
|
||||
"""Fetch thumbnail from Immich API."""
|
||||
try:
|
||||
@@ -71,13 +80,106 @@ def _fetch_thumbnail(asset_id: str, timeout: int = 10) -> Image.Image | None:
|
||||
return None
|
||||
|
||||
|
||||
def _get_face_bbox(asset: dict) -> tuple[float, float, float, float] | None:
|
||||
"""Extract face bounding box from asset metadata if available."""
|
||||
for person in asset.get("people", []):
|
||||
faces = person.get("faces", [])
|
||||
if faces:
|
||||
f = faces[0]
|
||||
return (
|
||||
f.get("boundingBoxX1", 0),
|
||||
f.get("boundingBoxY1", 0),
|
||||
f.get("boundingBoxX2", 0),
|
||||
f.get("boundingBoxY2", 0),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _get_face_confidence(asset: dict) -> float | None:
|
||||
"""Extract face detection confidence from asset metadata if available."""
|
||||
for person in asset.get("people", []):
|
||||
faces = person.get("faces", [])
|
||||
if faces:
|
||||
return faces[0].get("score") or faces[0].get("confidence")
|
||||
return None
|
||||
|
||||
|
||||
def _crop_face_from_thumbnail(
|
||||
img: Image.Image,
|
||||
asset: dict,
|
||||
margin: float = 0.25,
|
||||
) -> Image.Image | None:
|
||||
"""Crop the face region from a thumbnail using Immich bbox metadata.
|
||||
|
||||
By cropping before embedding, we guarantee InsightFace embeds the
|
||||
correct person's face (not the largest face in a group photo).
|
||||
|
||||
Args:
|
||||
img: Full preview thumbnail
|
||||
asset: Asset dict with people/faces metadata
|
||||
margin: Extra margin around the bbox (fraction, default 25%)
|
||||
|
||||
Returns:
|
||||
Cropped face PIL image, or None if no face metadata available
|
||||
"""
|
||||
bbox = _get_face_bbox(asset)
|
||||
if bbox is None:
|
||||
return None
|
||||
|
||||
x1, y1, x2, y2 = bbox
|
||||
img_w, img_h = img.size
|
||||
|
||||
# Get metadata dimensions to scale bbox
|
||||
for person in asset.get("people", []):
|
||||
faces = person.get("faces", [])
|
||||
if faces:
|
||||
meta_w = faces[0].get("imageWidth") or img_w
|
||||
meta_h = faces[0].get("imageHeight") or img_h
|
||||
scale_x, scale_y = img_w / meta_w, img_h / meta_h
|
||||
x1, y1 = x1 * scale_x, y1 * scale_y
|
||||
x2, y2 = x2 * scale_x, y2 * scale_y
|
||||
break
|
||||
|
||||
face_w, face_h = x2 - x1, y2 - y1
|
||||
|
||||
# Add margin so InsightFace's internal alignment has context
|
||||
mx, my = face_w * margin, face_h * margin
|
||||
crop = img.crop(
|
||||
(
|
||||
max(0, x1 - mx),
|
||||
max(0, y1 - my),
|
||||
min(img_w, x2 + mx),
|
||||
min(img_h, y2 + my),
|
||||
)
|
||||
)
|
||||
|
||||
# Skip if too small for meaningful embedding
|
||||
if crop.width < 30 or crop.height < 30:
|
||||
return None
|
||||
|
||||
return crop
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Embedding Collection
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _select_by_embedding(
|
||||
assets: list,
|
||||
limit: int | str,
|
||||
entity_type: str,
|
||||
progress_callback=None,
|
||||
) -> list:
|
||||
"""Select assets using embedding-based Farthest Point Sampling."""
|
||||
"""Select assets using embedding-based cluster-aware FPS.
|
||||
|
||||
Pipeline:
|
||||
1. Concurrent thumbnail download
|
||||
2. Quality filtering
|
||||
3. Face crop extraction (face mode only)
|
||||
4. Embedding computation
|
||||
5. Cluster-aware selection with hard example weighting
|
||||
"""
|
||||
# Determine candidate pool (cap at 3000 for performance)
|
||||
effective_limit = 30 if limit == "auto" else limit
|
||||
pool_size = min(3000, max(effective_limit * 20, len(assets)))
|
||||
@@ -89,24 +191,68 @@ def _select_by_embedding(
|
||||
else:
|
||||
candidates = assets
|
||||
|
||||
# Compute embeddings
|
||||
embeddings, valid_candidates = [], []
|
||||
for i, asset in enumerate(candidates):
|
||||
if progress_callback:
|
||||
progress_callback(i, len(candidates))
|
||||
# --- Phase 1: Concurrent thumbnail download ---
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
img = _fetch_thumbnail(asset["id"])
|
||||
thumbnail_map: dict[str, Image.Image] = {}
|
||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||
futures = {pool.submit(_fetch_thumbnail, a["id"]): a for a in candidates}
|
||||
for i, future in enumerate(as_completed(futures)):
|
||||
if progress_callback:
|
||||
progress_callback(i, len(candidates))
|
||||
asset = futures[future]
|
||||
try:
|
||||
img = future.result()
|
||||
if img is not None:
|
||||
thumbnail_map[asset["id"]] = img
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# --- Phase 2-4: Quality filter → Crop → Embed ---
|
||||
embeddings, valid_candidates, confidence_scores = [], [], []
|
||||
quality_filtered = 0
|
||||
|
||||
for asset in candidates:
|
||||
img = thumbnail_map.get(asset["id"])
|
||||
if img is None:
|
||||
continue
|
||||
|
||||
emb = get_embedding(img, entity_type)
|
||||
confidence = _get_face_confidence(asset)
|
||||
|
||||
# Quality gate: filter before expensive embedding computation
|
||||
if entity_type == "face":
|
||||
face_bbox = _get_face_bbox(asset)
|
||||
quality = assess_quality(
|
||||
img,
|
||||
face_bbox=face_bbox,
|
||||
confidence=confidence,
|
||||
blur_threshold=Config.BLUR_THRESHOLD,
|
||||
min_face_px=Config.MIN_FACE_WIDTH,
|
||||
min_confidence=Config.MIN_CONFIDENCE,
|
||||
)
|
||||
if not quality.passed:
|
||||
quality_filtered += 1
|
||||
logger.debug(f"Quality filtered {asset['id']}: {quality.reason}")
|
||||
continue
|
||||
|
||||
# Crop the target person's face before embedding
|
||||
face_crop = _crop_face_from_thumbnail(img, asset)
|
||||
embed_img = face_crop if face_crop is not None else img
|
||||
else:
|
||||
embed_img = img
|
||||
|
||||
emb = get_embedding(embed_img, entity_type, asset_id=asset["id"])
|
||||
if emb is not None:
|
||||
embeddings.append(emb)
|
||||
valid_candidates.append(asset)
|
||||
confidence_scores.append(confidence)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(len(candidates), len(candidates))
|
||||
|
||||
if quality_filtered > 0:
|
||||
logger.info(f"Quality filtering removed {quality_filtered} images.")
|
||||
|
||||
if not embeddings:
|
||||
logger.warning("No valid embeddings found. Falling back to time spread.")
|
||||
return _select_time_spread(assets, limit)
|
||||
@@ -115,58 +261,212 @@ def _select_by_embedding(
|
||||
logger.warning(f"Only {len(valid_candidates)} valid embeddings. Returning all.")
|
||||
return valid_candidates
|
||||
|
||||
# Farthest Point Sampling with vectorized distance computation
|
||||
return _farthest_point_sampling(
|
||||
embeddings, valid_candidates, limit, auto_threshold=0.15
|
||||
# --- Phase 5: Cluster-aware selection ---
|
||||
return _cluster_aware_selection(
|
||||
embeddings,
|
||||
valid_candidates,
|
||||
limit,
|
||||
entity_type=entity_type,
|
||||
confidence_scores=confidence_scores,
|
||||
)
|
||||
|
||||
|
||||
def _farthest_point_sampling(
|
||||
# =============================================================================
|
||||
# K-Medoids (Lightweight Implementation)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _kmedoids(dist_matrix: np.ndarray, k: int, max_iter: int = 50) -> tuple[list[int], np.ndarray]:
|
||||
"""Lightweight K-Medoids clustering using cosine distance matrix.
|
||||
|
||||
Args:
|
||||
dist_matrix: (N, N) pairwise distance matrix
|
||||
k: Number of clusters
|
||||
max_iter: Maximum iterations for swap step
|
||||
|
||||
Returns:
|
||||
(medoid_indices, cluster_labels) tuple
|
||||
"""
|
||||
n = dist_matrix.shape[0]
|
||||
rng = np.random.default_rng(42)
|
||||
|
||||
# Initialize medoids: first = most central point, rest = farthest from chosen
|
||||
total_dist = dist_matrix.sum(axis=1)
|
||||
medoids = [int(np.argmin(total_dist))]
|
||||
|
||||
for _ in range(k - 1):
|
||||
dists_to_chosen = dist_matrix[:, medoids].min(axis=1)
|
||||
dists_to_chosen[medoids] = -np.inf
|
||||
medoids.append(int(np.argmax(dists_to_chosen)))
|
||||
|
||||
# Iterative swap step
|
||||
medoids = list(medoids)
|
||||
labels = np.argmin(dist_matrix[:, medoids], axis=1)
|
||||
cost = sum(dist_matrix[i, medoids[labels[i]]] for i in range(n))
|
||||
|
||||
for _ in range(max_iter):
|
||||
improved = False
|
||||
# Try swapping each medoid with a random non-medoid
|
||||
non_medoids = [i for i in range(n) if i not in medoids]
|
||||
if not non_medoids:
|
||||
break
|
||||
|
||||
for m_idx in range(k):
|
||||
candidates = rng.choice(non_medoids, size=min(10, len(non_medoids)), replace=False)
|
||||
for cand in candidates:
|
||||
new_medoids = medoids.copy()
|
||||
new_medoids[m_idx] = cand
|
||||
new_labels = np.argmin(dist_matrix[:, new_medoids], axis=1)
|
||||
new_cost = sum(dist_matrix[i, new_medoids[new_labels[i]]] for i in range(n))
|
||||
if new_cost < cost:
|
||||
medoids = new_medoids
|
||||
labels = new_labels
|
||||
cost = new_cost
|
||||
improved = True
|
||||
break
|
||||
if improved:
|
||||
break
|
||||
|
||||
if not improved:
|
||||
break
|
||||
|
||||
return medoids, labels
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Cluster-Aware Selection (K-Medoids + FPS Hybrid)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _compute_adaptive_threshold(emb_normed: np.ndarray, entity_type: str) -> float:
|
||||
"""Compute adaptive FPS stop threshold based on actual embedding distribution.
|
||||
|
||||
Instead of a hardcoded threshold, samples pairwise distances and sets
|
||||
the threshold as a fraction of the median pairwise distance.
|
||||
"""
|
||||
n = len(emb_normed)
|
||||
sample_size = min(200, n)
|
||||
rng = np.random.default_rng(42)
|
||||
indices = rng.choice(n, sample_size, replace=False) if n > sample_size else np.arange(n)
|
||||
sample = emb_normed[indices]
|
||||
|
||||
# Compute pairwise cosine distances for the sample
|
||||
pairwise = 1 - sample @ sample.T
|
||||
upper_tri = pairwise[np.triu_indices(len(sample), k=1)]
|
||||
median_dist = float(np.median(upper_tri))
|
||||
|
||||
# Faces: 20% of median (tighter — want fewer, more distinct images)
|
||||
# Objects: 10% of median (wider — want more diversity)
|
||||
fraction = 0.20 if entity_type == "face" else 0.10
|
||||
threshold = max(0.05, median_dist * fraction)
|
||||
|
||||
logger.info(
|
||||
f"Adaptive threshold: {threshold:.4f} "
|
||||
f"(median_dist={median_dist:.4f}, fraction={fraction}, type={entity_type})"
|
||||
)
|
||||
return threshold
|
||||
|
||||
|
||||
def _cluster_aware_selection(
|
||||
embeddings: list,
|
||||
candidates: list,
|
||||
limit: int | str,
|
||||
auto_threshold: float = 0.15,
|
||||
entity_type: str = "face",
|
||||
confidence_scores: list | None = None,
|
||||
) -> list:
|
||||
"""Vectorized Farthest Point Sampling."""
|
||||
"""Two-stage selection: K-Medoids clustering → FPS with hard example weighting.
|
||||
|
||||
Stage 1: Cluster embeddings into k groups, select medoids as initial picks.
|
||||
This guarantees at least one representative from every distinct "look".
|
||||
|
||||
Stage 2: Fill remaining budget with FPS across cluster boundaries,
|
||||
biasing toward hard examples (low-confidence candidates).
|
||||
"""
|
||||
emb_matrix = np.vstack(embeddings) # (N, D)
|
||||
n = len(emb_matrix)
|
||||
|
||||
# Normalize for cosine distance (cosine_dist = 1 - cosine_sim)
|
||||
# Normalize for cosine distance
|
||||
norms = np.linalg.norm(emb_matrix, axis=1, keepdims=True)
|
||||
emb_normed = emb_matrix / np.maximum(norms, 1e-8)
|
||||
|
||||
# Start from median-time sample
|
||||
selected = [n // 2]
|
||||
# Build confidence weight array for hard example boosting
|
||||
conf_array = np.ones(n)
|
||||
if confidence_scores and entity_type == "face":
|
||||
for i, c in enumerate(confidence_scores):
|
||||
if c is not None:
|
||||
conf_array[i] = c
|
||||
|
||||
# Compute adaptive threshold for auto mode
|
||||
auto_threshold = _compute_adaptive_threshold(emb_normed, entity_type) if limit == "auto" else 0.0
|
||||
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
|
||||
logger.info(f"Clustering {n} embeddings into {k} groups (K-Medoids)...")
|
||||
|
||||
# Compute full cosine distance matrix
|
||||
dist_matrix = 1 - emb_normed @ emb_normed.T
|
||||
|
||||
medoid_indices, cluster_labels = _kmedoids(dist_matrix, k)
|
||||
selected = list(medoid_indices)
|
||||
selected_set = set(selected)
|
||||
|
||||
logger.info(f"Selected {len(selected)} cluster medoids as initial picks.")
|
||||
|
||||
# --- Stage 2: FPS with hard example weighting ---
|
||||
min_dists = np.full(n, np.inf)
|
||||
|
||||
target = 500 if limit == "auto" else limit
|
||||
# Initialize min distances from all medoids
|
||||
for idx in selected:
|
||||
dists = dist_matrix[idx]
|
||||
min_dists = np.minimum(min_dists, dists)
|
||||
for idx in selected:
|
||||
min_dists[idx] = -np.inf
|
||||
|
||||
while len(selected) < target:
|
||||
# Update min distances with last selected point
|
||||
last_emb = emb_normed[selected[-1]]
|
||||
dists_to_last = 1 - emb_normed @ last_emb # Cosine distance
|
||||
min_dists = np.minimum(min_dists, dists_to_last)
|
||||
min_dists[selected[-1]] = -np.inf # Exclude already selected
|
||||
# Hard example weighting: boost distance for low-confidence candidates
|
||||
# Confidence < 0.85 gets up to 1.5× distance boost
|
||||
hard_weight = np.where(conf_array < 0.85, 1.0 + (0.85 - conf_array) * 2.0, 1.0)
|
||||
weighted_dists = min_dists * hard_weight
|
||||
|
||||
# Find farthest point
|
||||
best_idx = np.argmax(min_dists)
|
||||
best_dist = min_dists[best_idx]
|
||||
best_idx = int(np.argmax(weighted_dists))
|
||||
best_dist = min_dists[best_idx] # Use unweighted for threshold comparison
|
||||
|
||||
if best_dist == -np.inf:
|
||||
break # All points selected
|
||||
|
||||
if limit == "auto" and best_dist < auto_threshold:
|
||||
logger.info(
|
||||
f"Auto-stop: Next best image {best_dist:.3f} away (threshold {auto_threshold})."
|
||||
f"Auto-stop: Next best image {best_dist:.3f} away " f"(adaptive threshold {auto_threshold:.4f})."
|
||||
)
|
||||
break
|
||||
|
||||
selected.append(best_idx)
|
||||
selected_set.add(best_idx)
|
||||
|
||||
# Update min distances
|
||||
dists_to_new = dist_matrix[best_idx]
|
||||
min_dists = np.minimum(min_dists, dists_to_new)
|
||||
min_dists[best_idx] = -np.inf
|
||||
|
||||
# Log hard example stats
|
||||
if entity_type == "face":
|
||||
selected_conf = [conf_array[i] for i in selected if conf_array[i] < 1.0]
|
||||
hard_count = sum(1 for c in selected_conf if c < 0.85)
|
||||
logger.info(
|
||||
f"Selection complete: {len(selected)} images " f"({hard_count} hard examples with confidence < 0.85)."
|
||||
)
|
||||
else:
|
||||
logger.info(f"Selection complete: {len(selected)} diverse images.")
|
||||
|
||||
logger.info(f"Smart selection complete. Picked {len(selected)} diverse images.")
|
||||
return [candidates[i] for i in selected]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Time Spread Fallback
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _select_time_spread(assets: list, limit: int | str) -> list:
|
||||
"""Select N assets evenly distributed in time."""
|
||||
if limit == "auto":
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""
|
||||
Unified embedding interface for faces and objects.
|
||||
|
||||
- Faces: InsightFace (ArcFace/Buffalo_L)
|
||||
- Faces: InsightFace (ArcFace/Buffalo_L) — or reuse from Immich
|
||||
- Objects: SigLIP (Vision Transformer via transformers)
|
||||
- Caching: Disk-based cache avoids recomputation on reruns
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
@@ -14,6 +15,8 @@ import cv2
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from .cache import get_cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Lazy-loaded singletons
|
||||
@@ -48,8 +51,10 @@ def get_insightface_app():
|
||||
|
||||
# Determine device: 0 for GPU, -1 for CPU
|
||||
gpu_providers = {
|
||||
"CUDAExecutionProvider", "ROCmExecutionProvider",
|
||||
"MPSExecutionProvider", "CoreMLExecutionProvider",
|
||||
"CUDAExecutionProvider",
|
||||
"ROCmExecutionProvider",
|
||||
"MPSExecutionProvider",
|
||||
"CoreMLExecutionProvider",
|
||||
}
|
||||
ctx_id = -1 if _is_force_cpu() else (0 if gpu_providers & set(providers) else -1)
|
||||
|
||||
@@ -73,6 +78,7 @@ def get_insightface_app():
|
||||
logger.warning("Retrying InsightFace on CPU...")
|
||||
try:
|
||||
from insightface.app import FaceAnalysis
|
||||
|
||||
_insightface_app = FaceAnalysis(name="buffalo_l", root="~/.insightface")
|
||||
_insightface_app.prepare(ctx_id=-1, det_size=(640, 640))
|
||||
return _insightface_app
|
||||
@@ -179,14 +185,83 @@ def get_object_embedding(img_pil: Image.Image) -> np.ndarray | None:
|
||||
return None
|
||||
|
||||
|
||||
def get_object_embeddings_batch(images: list[Image.Image]) -> list[np.ndarray | None]:
|
||||
"""Get SigLIP embeddings for a batch of images (GPU-efficient)."""
|
||||
model, processor = get_siglip_model()
|
||||
if model is None:
|
||||
return [None] * len(images)
|
||||
|
||||
try:
|
||||
import torch
|
||||
|
||||
inputs = processor(images=images, return_tensors="pt", padding=True)
|
||||
device = next(model.parameters()).device
|
||||
inputs = {k: v.to(device) for k, v in inputs.items()}
|
||||
|
||||
with torch.no_grad():
|
||||
outputs = model(**inputs)
|
||||
embeddings = outputs.pooler_output.cpu().numpy()
|
||||
return [embeddings[i] for i in range(len(embeddings))]
|
||||
except Exception as e:
|
||||
logger.error(f"Error in batch embedding: {e}")
|
||||
# Fall back to individual computation
|
||||
return [get_object_embedding(img) for img in images]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Unified Interface
|
||||
# Unified Interface with Caching
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_embedding(img_pil: Image.Image, entity_type: str = "face") -> np.ndarray | None:
|
||||
"""Get embedding for an image based on entity type ('face' or 'object')."""
|
||||
return get_face_embedding(img_pil) if entity_type == "face" else get_object_embedding(img_pil)
|
||||
def get_embedding(
|
||||
img_pil: Image.Image,
|
||||
entity_type: str = "face",
|
||||
asset_id: str | None = None,
|
||||
immich_embedding: np.ndarray | None = None,
|
||||
) -> np.ndarray | None:
|
||||
"""Get embedding for an image based on entity type.
|
||||
|
||||
Priority:
|
||||
1. Pre-fetched Immich embedding (if provided)
|
||||
2. Disk cache (if enabled and asset_id provided)
|
||||
3. Local model computation (InsightFace or SigLIP)
|
||||
|
||||
Args:
|
||||
img_pil: The image to embed
|
||||
entity_type: 'face' or 'object'
|
||||
asset_id: Optional asset ID for cache lookup
|
||||
immich_embedding: Optional pre-fetched embedding from Immich API
|
||||
"""
|
||||
from .config import Config
|
||||
|
||||
use_cache = Config.ENABLE_CACHE and asset_id is not None
|
||||
cache = get_cache(Config.CACHE_DIR) if use_cache else None
|
||||
model_key = "immich" if entity_type == "face" else "siglip"
|
||||
|
||||
# 1. Use Immich embedding if provided
|
||||
if immich_embedding is not None:
|
||||
if cache:
|
||||
cache.put(asset_id, immich_embedding, model_key)
|
||||
return immich_embedding
|
||||
|
||||
# 2. Check disk cache
|
||||
if cache:
|
||||
cached = cache.get(asset_id, model_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# 3. Compute locally
|
||||
if entity_type == "face":
|
||||
emb = get_face_embedding(img_pil)
|
||||
model_key = "insightface"
|
||||
else:
|
||||
emb = get_object_embedding(img_pil)
|
||||
|
||||
# Cache the result
|
||||
if emb is not None and cache:
|
||||
cache.put(asset_id, emb, model_key)
|
||||
|
||||
return emb
|
||||
|
||||
|
||||
def is_embedding_available(entity_type: str = "face") -> bool:
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from .config import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Lazy singleton
|
||||
@@ -16,20 +19,59 @@ def get_yolo_model():
|
||||
global _yolo_model
|
||||
if _yolo_model is None:
|
||||
from ultralytics import YOLO
|
||||
|
||||
logger.info("Loading YOLOv9c model...")
|
||||
_yolo_model = YOLO("yolov9c.pt")
|
||||
return _yolo_model
|
||||
|
||||
|
||||
def align_face(img: Image.Image, landmarks: list[list[float]] | np.ndarray) -> Image.Image | None:
|
||||
"""Align face using 5-point landmarks to standard ArcFace input format (112x112).
|
||||
|
||||
This produces a normalized face crop that matches exactly what ArcFace
|
||||
was trained on, improving recognition accuracy.
|
||||
|
||||
Args:
|
||||
img: Full PIL image containing the face
|
||||
landmarks: 5-point facial landmarks [[x,y], ...] (eyes, nose, mouth corners)
|
||||
|
||||
Returns:
|
||||
Aligned 112x112 face image, or None if alignment fails
|
||||
"""
|
||||
try:
|
||||
from insightface.utils.face_align import norm_crop
|
||||
|
||||
img_np = np.asarray(img)
|
||||
lm = np.array(landmarks, dtype=np.float32)
|
||||
if lm.shape != (5, 2):
|
||||
logger.debug(f"Invalid landmark shape: {lm.shape}, expected (5, 2)")
|
||||
return None
|
||||
aligned = norm_crop(img_np, lm)
|
||||
return Image.fromarray(aligned)
|
||||
except ImportError:
|
||||
logger.debug("InsightFace not available for face alignment")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"Face alignment failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def process_face_mode(
|
||||
img: Image.Image,
|
||||
asset: dict,
|
||||
person: dict,
|
||||
output_dir: str,
|
||||
count: int,
|
||||
min_width: int = 50,
|
||||
min_width: int | None = None,
|
||||
) -> bool:
|
||||
"""Crop face based on Immich metadata and save to output directory."""
|
||||
"""Crop face based on Immich metadata and save to output directory.
|
||||
|
||||
If face alignment is enabled and landmarks are available, produces
|
||||
an aligned 112x112 crop. Otherwise falls back to bounding box crop
|
||||
with configurable margin.
|
||||
"""
|
||||
min_width = min_width or Config.MIN_FACE_WIDTH
|
||||
|
||||
# Find face metadata for this person
|
||||
face_info = None
|
||||
for p in asset.get("people", []):
|
||||
@@ -57,8 +99,20 @@ def process_face_mode(
|
||||
logger.debug(f"Face too small ({face_w:.1f}x{face_h:.1f})")
|
||||
return False
|
||||
|
||||
# Add 10% margin
|
||||
margin_x, margin_y = face_w * 0.10, face_h * 0.10
|
||||
# Try face alignment if enabled and landmarks available
|
||||
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:
|
||||
aligned.save(os.path.join(output_dir, f"{count}.jpg"), format="JPEG")
|
||||
return True
|
||||
|
||||
# Fall back to bounding box crop with configurable margin
|
||||
margin = Config.FACE_MARGIN
|
||||
margin_x, margin_y = face_w * margin, face_h * margin
|
||||
crop_box = (
|
||||
max(0, x1 - margin_x),
|
||||
max(0, y1 - margin_y),
|
||||
@@ -87,9 +141,7 @@ def process_object_mode(
|
||||
|
||||
found = False
|
||||
for idx, (box, cls_id, conf) in enumerate(
|
||||
(box, int(box.cls[0]), float(box.conf[0]))
|
||||
for r in results
|
||||
for box in r.boxes
|
||||
(box, int(box.cls[0]), float(box.conf[0])) for r in results for box in r.boxes
|
||||
):
|
||||
if 0 <= cls_id < len(model.names) and model.names[cls_id] == target_class and conf > 0.5:
|
||||
x1, y1, x2, y2 = box.xyxy[0].tolist()
|
||||
|
||||
+117
-1
@@ -1,15 +1,30 @@
|
||||
"""Immich API client for fetching people and assets."""
|
||||
"""Immich API client for fetching people, assets, and face data."""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from io import BytesIO
|
||||
|
||||
import numpy as np
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
from .config import Config, get_headers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FaceData:
|
||||
"""Pre-computed face data from Immich."""
|
||||
|
||||
embedding: np.ndarray | None
|
||||
bbox: tuple[float, float, float, float] # (x1, y1, x2, y2)
|
||||
confidence: float | None
|
||||
image_width: int
|
||||
image_height: int
|
||||
|
||||
|
||||
def get_people() -> list[dict]:
|
||||
"""Fetch all people from Immich."""
|
||||
try:
|
||||
@@ -68,6 +83,107 @@ def fetch_all_assets(person: dict) -> list[dict]:
|
||||
return assets
|
||||
|
||||
|
||||
def fetch_face_data(asset_id: str, person_id: str | None = None) -> FaceData | None:
|
||||
"""Fetch pre-computed face data (embedding, bbox, confidence) from Immich.
|
||||
|
||||
Queries GET /api/faces?id={asset_id} to retrieve face detection results
|
||||
that Immich already computed using InsightFace Buffalo_L.
|
||||
|
||||
Args:
|
||||
asset_id: The asset to get face data for
|
||||
person_id: Optional person ID to match the specific face
|
||||
|
||||
Returns:
|
||||
FaceData with embedding, bbox, and confidence, or None if unavailable
|
||||
"""
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"{Config.IMMICH_URL}/api/faces",
|
||||
params={"id": asset_id},
|
||||
headers=get_headers(),
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if not resp.ok:
|
||||
logger.debug(f"Face data endpoint returned {resp.status_code} for {asset_id}")
|
||||
return None
|
||||
|
||||
faces = resp.json()
|
||||
if not faces:
|
||||
return None
|
||||
|
||||
# Match the target person if specified
|
||||
face = None
|
||||
if person_id:
|
||||
face = next((f for f in faces if f.get("person", {}).get("id") == person_id), None)
|
||||
if face is None:
|
||||
face = faces[0] # Fall back to first/largest face
|
||||
|
||||
# Extract embedding if available
|
||||
embedding = None
|
||||
if "embedding" in face:
|
||||
embedding = np.array(face["embedding"], dtype=np.float32)
|
||||
|
||||
# Extract bounding box
|
||||
bbox = (
|
||||
face.get("boundingBoxX1", 0),
|
||||
face.get("boundingBoxY1", 0),
|
||||
face.get("boundingBoxX2", 0),
|
||||
face.get("boundingBoxY2", 0),
|
||||
)
|
||||
|
||||
return FaceData(
|
||||
embedding=embedding,
|
||||
bbox=bbox,
|
||||
confidence=face.get("score") or face.get("confidence"),
|
||||
image_width=face.get("imageWidth", 0),
|
||||
image_height=face.get("imageHeight", 0),
|
||||
)
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.debug(f"Failed to fetch face data for {asset_id}: {e}")
|
||||
return None
|
||||
except (KeyError, TypeError, ValueError) as e:
|
||||
logger.debug(f"Failed to parse face data for {asset_id}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def fetch_full_image(asset_id: str, timeout: int = 60) -> Image.Image | None:
|
||||
"""Fetch full-resolution image from Immich, falling back to preview thumbnail.
|
||||
|
||||
The /original endpoint may return HEIC, RAW, or video files that PIL
|
||||
cannot open directly. In that case, we fall back to the JPEG thumbnail.
|
||||
"""
|
||||
# Try original first
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"{Config.IMMICH_URL}/api/assets/{asset_id}/original",
|
||||
headers=get_headers(),
|
||||
timeout=timeout,
|
||||
)
|
||||
if resp.ok:
|
||||
try:
|
||||
return Image.open(BytesIO(resp.content))
|
||||
except Exception:
|
||||
logger.debug(f"PIL can't open original for {asset_id}, falling back to preview")
|
||||
except requests.RequestException:
|
||||
logger.debug(f"Original request failed for {asset_id}, falling back to preview")
|
||||
|
||||
# Fall back to preview thumbnail (always JPEG)
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"{Config.IMMICH_URL}/api/assets/{asset_id}/thumbnail?size=preview&format=JPEG",
|
||||
headers=get_headers(),
|
||||
timeout=30,
|
||||
)
|
||||
if resp.ok:
|
||||
return Image.open(BytesIO(resp.content))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch image {asset_id}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def filter_recent_assets(assets: list[dict], years: int | None = None) -> list[dict]:
|
||||
"""Filter assets to keep only those from the last N years."""
|
||||
years = years or Config.YEARS_FILTER
|
||||
|
||||
@@ -36,9 +36,7 @@ def setup_logging(verbose: bool = False) -> logging.Logger:
|
||||
# File handler (always debug level)
|
||||
file_handler = logging.FileHandler("immich_export.log")
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
file_handler.setFormatter(
|
||||
logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
||||
)
|
||||
file_handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
|
||||
root.addHandler(file_handler)
|
||||
|
||||
# Silence noisy libraries
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Image quality assessment for training data curation.
|
||||
|
||||
Filters out images that would hurt Frigate's ArcFace model training:
|
||||
blur, grayscale/IR, over/underexposure, tiny faces, low-confidence detections.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QualityResult:
|
||||
"""Result of quality assessment on a face/image crop."""
|
||||
|
||||
passed: bool
|
||||
reasons: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def reason(self) -> str:
|
||||
return "; ".join(self.reasons) if self.reasons else "OK"
|
||||
|
||||
|
||||
def check_blur(img_np: np.ndarray, threshold: float = 100.0) -> tuple[bool, str]:
|
||||
"""Detect blur using Laplacian variance.
|
||||
|
||||
Lower variance = blurrier image. ArcFace needs clear facial features.
|
||||
"""
|
||||
gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY) if img_np.ndim == 3 else img_np
|
||||
variance = cv2.Laplacian(gray, cv2.CV_64F).var()
|
||||
if variance < threshold:
|
||||
return False, f"Blurry (laplacian={variance:.1f}, threshold={threshold})"
|
||||
return True, ""
|
||||
|
||||
|
||||
def check_grayscale(img_np: np.ndarray) -> tuple[bool, str]:
|
||||
"""Detect grayscale/IR images by checking channel similarity.
|
||||
|
||||
ArcFace is trained on color images; IR/grayscale degrades recognition.
|
||||
"""
|
||||
if img_np.ndim != 3 or img_np.shape[2] < 3:
|
||||
return False, "Grayscale (single channel)"
|
||||
|
||||
# Compare channel means — IR/grayscale has nearly identical R, G, B
|
||||
means = img_np[:, :, :3].mean(axis=(0, 1))
|
||||
max_diff = max(abs(means[0] - means[1]), abs(means[1] - means[2]), abs(means[0] - means[2]))
|
||||
|
||||
if max_diff < 5.0:
|
||||
return False, f"Grayscale/IR (channel diff={max_diff:.1f})"
|
||||
return True, ""
|
||||
|
||||
|
||||
def check_exposure(img_np: np.ndarray, lo: float = 30.0, hi: float = 225.0) -> tuple[bool, str]:
|
||||
"""Check for severe under/overexposure using mean brightness.
|
||||
|
||||
Extremely dark or blown-out faces lack usable features.
|
||||
"""
|
||||
gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY) if img_np.ndim == 3 else img_np
|
||||
mean_brightness = gray.mean()
|
||||
|
||||
if mean_brightness < lo:
|
||||
return False, f"Underexposed (brightness={mean_brightness:.1f}, min={lo})"
|
||||
if mean_brightness > hi:
|
||||
return False, f"Overexposed (brightness={mean_brightness:.1f}, max={hi})"
|
||||
return True, ""
|
||||
|
||||
|
||||
def check_face_size(face_width: float, face_height: float, min_px: int = 100) -> tuple[bool, str]:
|
||||
"""Validate face crop is large enough for meaningful features."""
|
||||
if face_width < min_px or face_height < min_px:
|
||||
return False, f"Face too small ({face_width:.0f}x{face_height:.0f}, min={min_px}px)"
|
||||
return True, ""
|
||||
|
||||
|
||||
def check_confidence(score: float | None, min_conf: float = 0.7) -> tuple[bool, str]:
|
||||
"""Check detection confidence from Immich.
|
||||
|
||||
Low confidence often means partial, occluded, or false-positive faces.
|
||||
"""
|
||||
if score is None:
|
||||
return True, "" # No score available, pass by default
|
||||
if score < min_conf:
|
||||
return False, f"Low confidence ({score:.2f}, min={min_conf})"
|
||||
return True, ""
|
||||
|
||||
|
||||
def assess_quality(
|
||||
img: Image.Image,
|
||||
face_bbox: tuple[float, float, float, float] | None = None,
|
||||
confidence: float | None = None,
|
||||
blur_threshold: float = 100.0,
|
||||
min_face_px: int = 100,
|
||||
min_confidence: float = 0.7,
|
||||
) -> QualityResult:
|
||||
"""Run all quality checks on an image.
|
||||
|
||||
Args:
|
||||
img: PIL Image (RGB)
|
||||
face_bbox: (x1, y1, x2, y2) bounding box, or None to skip face-size check
|
||||
confidence: Detection confidence score from Immich, or None
|
||||
blur_threshold: Laplacian variance threshold for blur detection
|
||||
min_face_px: Minimum face dimension in pixels
|
||||
min_confidence: Minimum acceptable detection confidence
|
||||
|
||||
Returns:
|
||||
QualityResult with passed=True if all checks pass
|
||||
"""
|
||||
img_np = np.asarray(img)
|
||||
reasons = []
|
||||
|
||||
# Run all checks, collect failures
|
||||
checks = [
|
||||
check_blur(img_np, blur_threshold),
|
||||
check_grayscale(img_np),
|
||||
check_exposure(img_np),
|
||||
check_confidence(confidence, min_confidence),
|
||||
]
|
||||
|
||||
if face_bbox is not None:
|
||||
x1, y1, x2, y2 = face_bbox
|
||||
checks.append(check_face_size(x2 - x1, y2 - y1, min_face_px))
|
||||
|
||||
for passed, reason in checks:
|
||||
if not passed:
|
||||
reasons.append(reason)
|
||||
|
||||
return QualityResult(passed=len(reasons) == 0, reasons=reasons)
|
||||
Reference in New Issue
Block a user