fix+logging: model load logging, CPU fallback, log level audit

Bugs fixed (from high-effort review):
- InsightFace CPU fallback now passes providers=['CPUExecutionProvider'] and
  wraps in _suppress_output() so broken GPU drivers don't cause fallback to
  try the same broken provider again, and C-extension noise stays suppressed
- scheduler.py: BaseException → Exception (KeyboardInterrupt already re-raised;
  winnow has no sys.exit() calls, so SystemExit would not occur, but Exception
  is the correct scope)
- compose.yml: fix inverted AUTO_MODE comment (docker run -it enables
  interactive mode via TTY, not non-interactive)

Model loading logging (embeddings.py):
- InsightFace: disk cache check, "not cached — downloading now (~300 MB)",
  "loading into memory on GPU/CPU...", "ready on GPU/CPU (Xs)"
- SigLIP: same treatment; cache path derived dynamically from model_name
  via HuggingFace slug convention (models--org--model) so it stays correct
  if the model variant ever changes

Logging level audit (INFO/DEBUG/WARNING/ERROR):
- diversity.py: internal algo steps (clustering, medoids, adaptive threshold,
  auto-stop decision) → DEBUG; final selection summaries stay INFO
- immich_api.py: "Fetching assets" and "Retained N assets" → DEBUG (callers
  already print this to the console via rprint)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 15:23:44 +00:00
co-authored by Claude Sonnet 4.6
parent af8bae1c45
commit 3db52c2141
5 changed files with 51 additions and 21 deletions
+2 -2
View File
@@ -10,8 +10,8 @@ services:
# ── Mode & Strategy ───────────────────────────────────────────────────
# Auto mode is active by default when no TTY is present (Docker/cron).
# Run with `docker run -it` or set AUTO_MODE=true to force non-interactive
# in a terminal.
# Set AUTO_MODE=true to force auto mode in an interactive terminal.
# To run interactively: docker exec -it winnow winnow
# TRAINING_MODE: face = upload to Frigate face recognition API
# object = save crops to output dir for manual Frigate placement
- TRAINING_MODE=face
+1 -1
View File
@@ -45,7 +45,7 @@ while True:
print("winnow run complete", flush=True)
except KeyboardInterrupt:
raise
except BaseException as e:
except Exception as e:
logger.error(f"winnow run failed: {e}", exc_info=True)
print(f"winnow run failed: {e}", flush=True)
next_run = cron.get_next(float)
+5 -5
View File
@@ -368,7 +368,7 @@ def _compute_adaptive_threshold(emb_normed: np.ndarray, entity_type: str) -> flo
fraction = 0.20 if entity_type == "face" else 0.10
threshold = max(0.05, median_dist * fraction)
logger.info(
logger.debug(
f"Adaptive threshold: {threshold:.4f} "
f"(median_dist={median_dist:.4f}, fraction={fraction}, type={entity_type})"
)
@@ -410,7 +410,7 @@ def _cluster_aware_selection(
# --- 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)...")
logger.debug(f"Clustering {n} embeddings into {k} groups (K-Medoids)...")
# Compute full cosine distance matrix
dist_matrix = 1 - emb_normed @ emb_normed.T
@@ -419,7 +419,7 @@ def _cluster_aware_selection(
selected = list(medoid_indices)
selected_set = set(selected)
logger.info(f"Selected {len(selected)} cluster medoids as initial picks.")
logger.debug(f"Selected {len(selected)} cluster medoids as initial picks.")
# --- Stage 2: FPS with hard example weighting ---
min_dists = np.full(n, np.inf)
@@ -444,8 +444,8 @@ def _cluster_aware_selection(
break # All points selected
if limit == "auto" and best_dist < auto_threshold:
logger.info(
f"Auto-stop: Next best image {best_dist:.3f} away " f"(adaptive threshold {auto_threshold:.4f})."
logger.debug(
f"Auto-stop: next best image {best_dist:.3f} away (adaptive threshold {auto_threshold:.4f})."
)
break
+41 -11
View File
@@ -9,8 +9,10 @@ Unified embedding interface for faces and objects.
import importlib
import logging
import os
import time
import warnings
from contextlib import contextmanager
from pathlib import Path
import cv2
import numpy as np
@@ -90,9 +92,16 @@ def get_insightface_app():
import onnxruntime as ort
from insightface.app import FaceAnalysis
# 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.info(f"Available ONNX providers: {providers}")
logger.debug(f"ONNX providers available: {providers}")
gpu_providers = {
"CUDAExecutionProvider",
@@ -111,12 +120,14 @@ def get_insightface_app():
)
device_str = "GPU" if ctx_id >= 0 else "CPU"
logger.info(f"Loading InsightFace Buffalo_L on {device_str} (ctx_id={ctx_id})...")
logger.info(f"InsightFace Buffalo_L: loading into memory on {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(f"InsightFace Buffalo_L: ready on {device_str} ({time.time() - t0:.1f}s)")
return _insightface_app
except ImportError:
@@ -125,15 +136,22 @@ def get_insightface_app():
except Exception as e:
logger.error(f"Failed to load InsightFace: {e}")
if ctx_id == 0:
logger.warning("Retrying InsightFace on CPU...")
logger.warning("InsightFace GPU load failed — retrying on CPU...")
try:
from insightface.app import FaceAnalysis
_insightface_app = FaceAnalysis(name="buffalo_l", root=insightface_home)
_insightface_app.prepare(ctx_id=-1, det_size=(640, 640))
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(f"InsightFace Buffalo_L: ready on CPU (fallback, {time.time() - t0:.1f}s)")
return _insightface_app
except Exception as ex:
logger.error(f"CPU fallback failed: {ex}")
logger.error(f"InsightFace CPU fallback failed: {ex}")
return None
@@ -182,7 +200,18 @@ def get_siglip_model():
from transformers import AutoImageProcessor, SiglipVisionModel
model_name = "google/siglip-base-patch16-224"
logger.info(f"Loading SigLIP model ({model_name})...")
# Disk cache check — path derived from model_name using HuggingFace's slug convention
hf_home = os.environ.get("HF_HOME", os.path.join(os.path.expanduser("~"), ".cache", "huggingface"))
cache_slug = "models--" + model_name.replace("/", "--")
model_cache = Path(hf_home) / "hub" / cache_slug
if model_cache.exists() and any(model_cache.iterdir()):
logger.info(f"SigLIP {model_name}: found in model cache")
else:
logger.info(f"SigLIP {model_name}: not cached — downloading now (~380 MB)")
logger.info(f"SigLIP {model_name}: loading into memory...")
t0 = time.time()
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=FutureWarning)
@@ -196,15 +225,16 @@ def get_siglip_model():
if not _is_force_cpu():
if torch.cuda.is_available():
_siglip_model = _siglip_model.cuda()
logger.info("SigLIP running on CUDA GPU")
device_name = "CUDA GPU"
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
_siglip_model = _siglip_model.to("mps")
logger.info("SigLIP running on Apple MPS")
device_name = "Apple MPS"
else:
logger.info("SigLIP running on CPU")
device_name = "CPU"
else:
logger.info("FORCE_CPU set. SigLIP running on CPU")
device_name = "CPU (FORCE_CPU)"
logger.info(f"SigLIP {model_name}: ready on {device_name} ({time.time() - t0:.1f}s)")
return _siglip_model, _siglip_processor
except ImportError as e:
+2 -2
View File
@@ -49,7 +49,7 @@ def fetch_all_assets(person: dict) -> list[dict]:
url = f"{Config.IMMICH_URL}/api/search/metadata"
page_size = 1000
logger.info(f"Fetching assets for {name}...")
logger.debug(f"Fetching assets for {name}...")
assets = []
for page in range(1, MAX_PAGES + 1):
@@ -212,6 +212,6 @@ def filter_recent_assets(assets: list[dict], years: int | None = None) -> list[d
except ValueError:
continue
logger.info(f"Retained {len(recent)} assets (filtered {skipped} old assets).")
logger.debug(f"Retained {len(recent)} assets (filtered {skipped} old assets).")
return recent