Files
winnow/winnow/embeddings.py
T
flan 0906342edf fix: 10 correctness bugs from full codebase audit
- immich_api: .get("items") or [] handles {"items": null} without crashing len()
- immich_api: catch TypeError alongside ValueError in filter_recent_assets for
  timezone-naive fileCreatedAt comparisons
- scheduler: catch SystemExit in addition to KeyboardInterrupt so cli.main()
  cannot kill the long-running scheduler process
- scheduler: reseed croniter from wall-clock time after each run so overrunning
  jobs don't schedule an immediate back-to-back rerun
- config: reject negative YEARS_FILTER values with a warning, reset to default 10
- frigate_api: return True (not False) for empty filenames list — callers cannot
  distinguish no-op from network failure on False
- jobs: warn on unrecognised STRATEGY value instead of silently falling back
- jobs: casefold ONLY_PEOPLE / SKIP_PEOPLE matching so "john doe" matches "John Doe"
- executor: <= → < so a same-score candidate can fill a freed replacement slot
- embeddings: set _insightface_loaded=True on GPU+CPU double-failure to prevent
  N re-init attempts (one per asset) when InsightFace is broken for a whole run
2026-06-17 19:12:58 +00:00

292 lines
10 KiB
Python

"""
Embedding interface for face diversity selection.
- Faces: InsightFace (ArcFace/Buffalo_L) — or reuse from Immich
- Caching: Disk-based cache avoids recomputation on reruns
"""
import importlib
import logging
import os
import time
import warnings
from contextlib import contextmanager
from pathlib import Path
import cv2
import numpy as np
from PIL import Image
from .cache import get_cache
from .config import _getenv_bool
logger = logging.getLogger(__name__)
@contextmanager
def _suppress_output():
"""Suppress stdout/stderr at the file-descriptor level, silencing C extension noise."""
devnull_fd = None
saved_out = None
saved_err = None
try:
devnull_fd = os.open(os.devnull, os.O_WRONLY)
saved_out = os.dup(1)
saved_err = os.dup(2)
os.dup2(devnull_fd, 1)
os.dup2(devnull_fd, 2)
yield
finally:
# Each block is a separate sequential statement. A BaseException (e.g.
# KeyboardInterrupt) raised inside block N would propagate past blocks N+1
# and N+2, leaving saved_err or devnull_fd unclosed. In CPython, KI is
# delivered between bytecodes, not mid-syscall; os.dup2 is a single C call
# and completes atomically, so this race is not realistically triggerable.
if saved_out is not None:
try:
os.dup2(saved_out, 1)
except OSError as e:
logger.debug("_suppress_output: failed to restore stdout fd: %s", e)
finally:
try:
os.close(saved_out)
except OSError:
pass
if saved_err is not None:
try:
os.dup2(saved_err, 2)
except OSError as e:
logger.debug("_suppress_output: failed to restore stderr fd: %s", e)
finally:
try:
os.close(saved_err)
except OSError:
pass
if devnull_fd is not None:
try:
os.close(devnull_fd)
except OSError:
pass
# Lazy-loaded singleton
_insightface_app = None
_insightface_loaded = False
def _is_force_cpu() -> bool:
"""Check if CPU mode is forced via environment variable."""
return _getenv_bool("FORCE_CPU", False)
def _preload_cuda_libs() -> None:
"""Preload CUDA/cuDNN DLLs so onnxruntime-gpu registers CUDAExecutionProvider.
Starting with onnxruntime-gpu 1.19+, CUDA/cuDNN libraries are no longer
bundled inside the ORT package — they come from the nvidia-* pip packages.
preload_dlls() locates them automatically via site-packages discovery.
"""
try:
import onnxruntime
if hasattr(onnxruntime, "preload_dlls"):
onnxruntime.preload_dlls(cuda=True, cudnn=True)
logger.debug("Preloaded CUDA/cuDNN DLLs for onnxruntime-gpu")
else:
logger.debug("onnxruntime.preload_dlls() not available (ORT < 1.21)")
except Exception as e:
logger.warning("Failed to preload CUDA/cuDNN DLLs: %s", e)
# =============================================================================
# InsightFace (Faces)
# =============================================================================
def get_insightface_app():
"""Singleton for InsightFace app with automatic GPU/CPU fallback."""
global _insightface_app, _insightface_loaded
if _insightface_loaded:
return _insightface_app
ctx_id = -1
insightface_home = os.environ.get("INSIGHTFACE_HOME", os.path.expanduser("~/.insightface"))
try:
import onnxruntime as ort
from insightface.app import FaceAnalysis
# Preload CUDA/cuDNN DLLs before any ORT InferenceSession is created.
# Silently no-ops on ROCm/Intel builds where preload_dlls() is absent.
_preload_cuda_libs()
# Disk cache check — lets the user know whether a download is coming
buffalo_path = Path(insightface_home) / "models" / "buffalo_l"
if buffalo_path.exists() and any(buffalo_path.iterdir()):
logger.info("InsightFace Buffalo_L: found in model cache")
else:
logger.info("InsightFace Buffalo_L: not cached — downloading now (~300 MB)")
# Get providers, excluding TensorRT to avoid noisy errors
providers = [p for p in ort.get_available_providers() if p != "TensorrtExecutionProvider"]
logger.debug("ONNX providers available: %s", providers)
gpu_providers = {
"CUDAExecutionProvider",
"ROCmExecutionProvider",
"MPSExecutionProvider",
"CoreMLExecutionProvider",
"OpenVINOExecutionProvider",
}
has_gpu_provider = bool(gpu_providers & set(providers))
ctx_id = -1 if _is_force_cpu() else (0 if has_gpu_provider else -1)
# For OpenVINO EP, inject device_type from env var (default CPU; set GPU for Intel Arc/iGPU)
has_openvino = "OpenVINOExecutionProvider" in providers
if has_openvino:
openvino_device = os.getenv("OPENVINO_DEVICE", "CPU")
providers = [
("OpenVINOExecutionProvider", {"device_type": openvino_device})
if p == "OpenVINOExecutionProvider" else p
for p in providers
]
logger.debug("OpenVINO EP: device_type=%s", openvino_device)
if not has_gpu_provider and not _is_force_cpu():
logger.warning(
"No GPU execution provider found — running InsightFace on CPU. "
"Ensure the container has GPU access and the correct variant image is used "
"(gpu for NVIDIA, rocm for AMD, intel for Intel Arc/iGPU)."
)
if ctx_id < 0:
device_str = "CPU"
elif has_openvino:
device_str = f"OpenVINO ({os.getenv('OPENVINO_DEVICE', 'CPU')})"
else:
device_str = "GPU"
logger.info("InsightFace Buffalo_L: loading into memory on %s...", device_str)
t0 = time.time()
with _suppress_output():
_insightface_app = FaceAnalysis(name="buffalo_l", root=insightface_home, providers=providers)
_insightface_app.prepare(ctx_id=ctx_id, det_size=(640, 640))
logger.info("InsightFace Buffalo_L: ready on %s (%.1fs)", device_str, time.time() - t0)
_insightface_loaded = True
return _insightface_app
except ImportError:
logger.error("InsightFace not installed!")
_insightface_loaded = True
return None
except Exception as e:
logger.error("Failed to load InsightFace: %s", e)
if ctx_id == 0:
logger.warning("InsightFace GPU load failed — retrying on CPU...")
try:
from insightface.app import FaceAnalysis
t0 = time.time()
with _suppress_output():
_insightface_app = FaceAnalysis(
name="buffalo_l",
root=insightface_home,
providers=["CPUExecutionProvider"],
)
_insightface_app.prepare(ctx_id=-1, det_size=(640, 640))
logger.info("InsightFace Buffalo_L: ready on CPU (fallback, %.1fs)", time.time() - t0)
_insightface_loaded = True
return _insightface_app
except Exception as ex:
logger.error("InsightFace CPU fallback failed: %s", ex)
_insightface_loaded = True
return None
def get_face_embedding(img_pil: Image.Image) -> np.ndarray | None:
"""Get embedding of the largest face in a PIL image."""
app = get_insightface_app()
if not app:
return None
try:
# InsightFace expects BGR cv2 image; normalise mode first so RGBA/grayscale don't
# raise a channel-count error inside cvtColor.
img_bgr = cv2.cvtColor(np.asarray(img_pil.convert("RGB")), cv2.COLOR_RGB2BGR)
# Suppress scikit-image FutureWarning from InsightFace's face_align.py
with warnings.catch_warnings():
warnings.filterwarnings("ignore", message=".*estimate.*is deprecated", category=FutureWarning)
faces = app.get(img_bgr)
if not faces:
return None
# Return embedding of the face nearest the crop centre; a large margin can pull
# a bigger neighbouring face into frame, and max-by-area would pick the wrong person.
cx, cy = img_pil.width / 2, img_pil.height / 2
nearest = min(
faces,
key=lambda f: ((f.bbox[0] + f.bbox[2]) / 2 - cx) ** 2 + ((f.bbox[1] + f.bbox[3]) / 2 - cy) ** 2,
)
return nearest.embedding
except Exception as e:
logger.error("Error getting face embedding: %s", e)
return None
# =============================================================================
# Embedding Interface with Caching
# =============================================================================
def get_embedding(
img_pil: Image.Image,
asset_id: str | None = None,
) -> np.ndarray | None:
"""Get embedding for a face image.
Checks disk cache first (if enabled and asset_id provided),
then falls back to local InsightFace computation.
"""
from .config import Config
use_cache = Config.ENABLE_CACHE and asset_id is not None
cache = get_cache(Config.DATA_DIR) if use_cache else None
if cache:
cached = cache.get(asset_id, "insightface")
if cached is not None:
return cached
emb = get_face_embedding(img_pil)
if emb is not None and cache:
cache.put(asset_id, emb, "insightface")
return emb
def _is_module_available(module_name: str) -> bool:
"""Check if a Python module is importable without importing it fully."""
try:
return importlib.util.find_spec(module_name) is not None
except (ModuleNotFoundError, ValueError):
return False
def is_embedding_available(*, load: bool = False) -> bool:
"""Check if InsightFace is available.
By default this performs a lightweight import-check only (no model loading).
Pass ``load=True`` to actually load the model (expensive, ~300 MB).
"""
if load:
return get_insightface_app() is not None
return _is_module_available("insightface") and _is_module_available("onnxruntime")
def load_embedding_model() -> bool:
"""Explicitly load InsightFace. Returns True if the model loaded successfully."""
return get_insightface_app() is not None