182 lines
5.8 KiB
Python
182 lines
5.8 KiB
Python
"""
|
|
Diversity selection for training data curation.
|
|
|
|
Uses Farthest Point Sampling (FPS) algorithm with embeddings:
|
|
- Faces: InsightFace embeddings
|
|
- Objects: SigLIP embeddings
|
|
"""
|
|
|
|
import logging
|
|
from io import BytesIO
|
|
|
|
import numpy as np
|
|
import requests
|
|
from PIL import Image
|
|
|
|
from .config import Config, get_headers
|
|
from .embeddings import get_embedding, is_embedding_available
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def select_diverse_assets(
|
|
assets: list,
|
|
limit: int | str,
|
|
entity_name: str,
|
|
selection_mode: str = "smart",
|
|
entity_type: str = "face",
|
|
progress_callback=None,
|
|
) -> list:
|
|
"""
|
|
Select diverse assets using Farthest Point Sampling or time spread.
|
|
|
|
Args:
|
|
assets: List of asset dicts from Immich API
|
|
limit: Number to select, or "auto" for dynamic selection
|
|
entity_name: Name of the person/object for logging
|
|
selection_mode: 'smart' (embedding-based) or 'time' (time spread)
|
|
entity_type: 'face' or 'object' - determines embedding model
|
|
progress_callback: Optional callback(current, total) for progress
|
|
|
|
Returns:
|
|
List of selected assets
|
|
"""
|
|
# Fast path: fewer assets than limit
|
|
if limit != "auto" and len(assets) <= limit:
|
|
return assets
|
|
|
|
# Sort by creation time
|
|
assets = sorted(assets, key=lambda x: x.get("fileCreatedAt", ""))
|
|
|
|
if selection_mode != "smart" or not is_embedding_available(entity_type):
|
|
if selection_mode == "smart":
|
|
model_name = "InsightFace" if entity_type == "face" else "SigLIP"
|
|
logger.warning(f"{model_name} unavailable. Falling back to time spread.")
|
|
return _select_time_spread(assets, limit)
|
|
|
|
try:
|
|
return _select_by_embedding(assets, limit, entity_type, progress_callback)
|
|
except Exception as e:
|
|
logger.error(f"Smart Diversity failed: {e}. Falling back to time spread.")
|
|
return _select_time_spread(assets, limit)
|
|
|
|
|
|
def _fetch_thumbnail(asset_id: str, timeout: int = 10) -> Image.Image | None:
|
|
"""Fetch thumbnail from Immich API."""
|
|
try:
|
|
url = f"{Config.IMMICH_URL}/api/assets/{asset_id}/thumbnail?size=preview&format=JPEG"
|
|
resp = requests.get(url, headers=get_headers(), timeout=timeout)
|
|
return Image.open(BytesIO(resp.content)) if resp.ok else None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _select_by_embedding(
|
|
assets: list,
|
|
limit: int | str,
|
|
entity_type: str,
|
|
progress_callback=None,
|
|
) -> list:
|
|
"""Select assets using embedding-based Farthest Point Sampling."""
|
|
# 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)))
|
|
|
|
# Subsample if needed (evenly distributed in time)
|
|
if len(assets) > pool_size:
|
|
indices = np.linspace(0, len(assets) - 1, pool_size, dtype=int)
|
|
candidates = [assets[i] for i in indices]
|
|
else:
|
|
candidates = assets
|
|
|
|
# Compute embeddings
|
|
embeddings, valid_candidates = [], []
|
|
for i, asset in enumerate(candidates):
|
|
if progress_callback:
|
|
progress_callback(i, len(candidates))
|
|
|
|
img = _fetch_thumbnail(asset["id"])
|
|
if img is None:
|
|
continue
|
|
|
|
emb = get_embedding(img, entity_type)
|
|
if emb is not None:
|
|
embeddings.append(emb)
|
|
valid_candidates.append(asset)
|
|
|
|
if progress_callback:
|
|
progress_callback(len(candidates), len(candidates))
|
|
|
|
if not embeddings:
|
|
logger.warning("No valid embeddings found. Falling back to time spread.")
|
|
return _select_time_spread(assets, limit)
|
|
|
|
if limit != "auto" and len(valid_candidates) < limit:
|
|
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
|
|
)
|
|
|
|
|
|
def _farthest_point_sampling(
|
|
embeddings: list,
|
|
candidates: list,
|
|
limit: int | str,
|
|
auto_threshold: float = 0.15,
|
|
) -> list:
|
|
"""Vectorized Farthest Point Sampling."""
|
|
emb_matrix = np.vstack(embeddings) # (N, D)
|
|
n = len(emb_matrix)
|
|
|
|
# Normalize for cosine distance (cosine_dist = 1 - cosine_sim)
|
|
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]
|
|
min_dists = np.full(n, np.inf)
|
|
|
|
target = 500 if limit == "auto" else limit
|
|
|
|
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
|
|
|
|
# Find farthest point
|
|
best_idx = np.argmax(min_dists)
|
|
best_dist = min_dists[best_idx]
|
|
|
|
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})."
|
|
)
|
|
break
|
|
|
|
selected.append(best_idx)
|
|
|
|
logger.info(f"Smart selection complete. Picked {len(selected)} diverse images.")
|
|
return [candidates[i] for i in selected]
|
|
|
|
|
|
def _select_time_spread(assets: list, limit: int | str) -> list:
|
|
"""Select N assets evenly distributed in time."""
|
|
if limit == "auto":
|
|
limit = 30
|
|
|
|
logger.info(f"Selecting {limit} images using time spread.")
|
|
|
|
if len(assets) <= limit:
|
|
return assets
|
|
|
|
indices = np.linspace(0, len(assets) - 1, limit, dtype=int)
|
|
return [assets[i] for i in np.unique(indices)]
|