""" Unified face enrollment and matching for CEPHEUS API. Uses InsightFace + embeddings in faces_db/ (same pipeline as register_face.py). """ from __future__ import annotations import logging import os import sys import threading import uuid import json from datetime import datetime, timezone from typing import Any import cv2 import numpy as np from embedding_store import EMB_SUFFIX, load_embedding, save_f32emb logger = logging.getLogger(__name__) _FR_DIR = os.path.dirname(os.path.abspath(__file__)) if _FR_DIR not in sys.path: sys.path.insert(0, _FR_DIR) FACE_DB_ROOT = os.path.join(_FR_DIR, "face_database") EMB_ROOT = os.path.join(_FR_DIR, "faces_db") TEMP_EMB_ROOT = os.path.join(_FR_DIR, "temp_faces_db") TEMP_UNKNOWN_IMG_ROOT = os.path.join(_FR_DIR, "temp_unknown_faces") DEFAULT_THRESHOLD = float(os.getenv("FACE_MATCH_THRESHOLD", "0.22")) UNKNOWN_REIDENT_THRESHOLD = max(0.15, DEFAULT_THRESHOLD - 0.05) def _load_person_thresholds() -> dict[str, float]: raw = os.getenv("FACE_PERSON_THRESHOLDS", "").strip() if raw: try: parsed = json.loads(raw) if isinstance(parsed, dict): return {str(k).lower().replace(" ", "_"): float(v) for k, v in parsed.items()} except (json.JSONDecodeError, TypeError, ValueError): pass # Stricter match bar for enrolled demo identities (reduces false positives). return {"mk": 0.35, "urvi": 0.35, "vidit": 0.35} PERSON_THRESHOLDS = _load_person_thresholds() def _threshold_for_person(name: str | None, default: float) -> float: if not name: return default key = name.strip().lower().replace(" ", "_") return PERSON_THRESHOLDS.get(key, default) # Re-embed enrolled faces when self-test score falls below this (model mismatch / stale .npy). EMBEDDING_SELF_TEST_MIN = float(os.getenv("FACE_EMBEDDING_SELF_TEST_MIN", "0.50")) MIN_FACE_CONFIDENCE = float(os.environ.get("MIN_FACE_CONFIDENCE", "0.50")) MIN_BBOX_AREA = int(os.environ.get("MIN_BBOX_AREA", "1200")) REF_FRAME_PIXELS = 640 * 480 # InsightFace ONNX is not safe for parallel inference on CPU — serialize all detect/match work. _FACE_INFER_SEM = threading.Semaphore(1) def scaled_min_bbox_area(frame, base_area: int | None = None, *, floor: int = 200) -> int: """Scale minimum face bbox area when inference runs on a downscaled frame.""" base = base_area if base_area is not None else MIN_BBOX_AREA if frame is None or getattr(frame, "size", 0) == 0: return base h, w = frame.shape[:2] actual = max(1, w * h) return max(floor, int(base * actual / REF_FRAME_PIXELS)) def _cosine(a: np.ndarray, b: np.ndarray) -> float: na, nb = np.linalg.norm(a), np.linalg.norm(b) if na == 0 or nb == 0: return 0.0 return float(np.dot(a, b) / (na * nb)) def _best_score(emb: np.ndarray, db_emb: np.ndarray) -> float: if db_emb.ndim == 1: return _cosine(emb, db_emb) return max((_cosine(emb, row) for row in db_emb), default=0.0) class FaceMatcher: _insightface_prepared: bool = False _prepare_lock = threading.Lock() def __init__(self): self.app = None self.db: dict[str, np.ndarray] = {} self.lock = threading.Lock() self._db_stamp: float = 0.0 self._unknown_cache: dict[str, np.ndarray] = {} self._unknown_lock = threading.Lock() self._embeddings_refreshed = False self._load_insightface() if os.getenv("CEPHEUS_EMBEDDINGS_STARTUP_ONLY", "1").strip().lower() in ("0", "false", "no"): threading.Thread(target=self._initial_embedding_refresh, daemon=True).start() def _initial_embedding_refresh(self) -> None: """Refresh stale enrolled .npy in background so startup stays fast.""" if self._embeddings_refreshed: return self._embeddings_refreshed = True try: self._refresh_stale_enrolled_embeddings() except Exception as exc: logger.warning("Background embedding refresh failed: %s", exc) def _load_insightface(self) -> None: try: import insightface from insightface.app import FaceAnalysis except Exception as exc: # pragma: no cover - import guard logger.error( "FaceMatcher: InsightFace import failed (%s). " "Install with `pip install insightface==0.7.3 onnxruntime` " "(requires a version that supports the 'buffalo_l' model pack).", exc, ) self.app = None return version = getattr(insightface, "__version__", "unknown") if version != "unknown": try: major, minor = (int(p) for p in version.split(".")[:2]) if (major, minor) < (0, 7): logger.error( "FaceMatcher: InsightFace %s is too old for the 'buffalo_l' model " "and the 'allowed_modules' API. Upgrade with " "`pip install --upgrade insightface==0.7.3`.", version, ) self.app = None return except ValueError: pass backend_dir = os.path.dirname(_FR_DIR) if backend_dir not in sys.path: sys.path.insert(0, backend_dir) try: from vision_runtime import insightface_ctx_id ctx_id = insightface_ctx_id() except Exception: ctx_id = -1 model_pack = os.getenv("FACE_MODEL_PACK", "buffalo_sc") model_root = os.getenv("FACE_MODEL_ROOT", "/app/model_cache") last_exc: Exception | None = None for name in (model_pack, "buffalo_sc", "buffalo_l"): try: fa = FaceAnalysis( name=name, root=model_root, providers=["CPUExecutionProvider"], allowed_modules=["detection", "recognition"], ) self.app = fa logger.info( "FaceMatcher: InsightFace %s loaded (model=%s, root=%s, ctx_id=%s). Waiting for force_prepare().", version, name, model_root, ctx_id, ) return except Exception as exc: logger.warning("FaceMatcher: model pack %s failed: %s", name, exc) last_exc = exc logger.error("FaceMatcher: All fallback models failed. Last error: %s", last_exc) self.app = None def _force_prepare(self) -> None: """Call app.prepare() exactly once. Thread-safe.""" if self.app is None: return with FaceMatcher._prepare_lock: if FaceMatcher._insightface_prepared: return try: from vision_runtime import insightface_ctx_id ctx_id = insightface_ctx_id() except Exception: ctx_id = -1 self.app.prepare(ctx_id=ctx_id, det_size=(320, 320)) FaceMatcher._insightface_prepared = True logger.info("FaceMatcher: app.prepare() completed (first and only call).") def _enrolled_dirs_mtime(self) -> float: """Mtime for enrolled embeddings only — temp/unknown writes must not trigger full reload.""" latest = 0.0 for folder in (EMB_ROOT, FACE_DB_ROOT): if not os.path.isdir(folder): continue try: latest = max(latest, os.path.getmtime(folder)) if folder == FACE_DB_ROOT: for name in os.listdir(folder): if name.startswith("unknown_"): continue person_dir = os.path.join(folder, name) if os.path.isdir(person_dir): latest = max(latest, os.path.getmtime(person_dir)) else: for fname in os.listdir(folder): if fname.endswith(EMB_SUFFIX) or fname.endswith(".npy"): latest = max(latest, os.path.getmtime(os.path.join(folder, fname))) except OSError: pass return latest def _embedding_dirs_mtime(self) -> float: """Full store mtime including temp unknown embeddings.""" latest = self._enrolled_dirs_mtime() if not os.path.isdir(TEMP_EMB_ROOT): return latest try: latest = max(latest, os.path.getmtime(TEMP_EMB_ROOT)) for fname in os.listdir(TEMP_EMB_ROOT): if fname.endswith(EMB_SUFFIX) or fname.endswith(".npy"): latest = max(latest, os.path.getmtime(os.path.join(TEMP_EMB_ROOT, fname))) except OSError: pass return latest def _merge_new_temp_embeddings(self) -> None: """Incrementally load new unknown .npy files without a full DB rebuild.""" if not os.path.isdir(TEMP_EMB_ROOT): return loaded: list[str] = [] for fname in os.listdir(TEMP_EMB_ROOT): if fname.endswith(EMB_SUFFIX): name = fname[: -len(EMB_SUFFIX)] elif fname.endswith(".npy"): name = fname[:-4] else: continue if not name.startswith("unknown_") or name in self.db: continue emb = load_embedding(os.path.join(TEMP_EMB_ROOT, name)) if emb is None: continue with self.lock: self.db[name] = emb loaded.append(name) if loaded: logger.debug("FaceMatcher: merged temp embeddings %s", loaded) def invalidate_db(self) -> None: """Force next ensure_db() to reload from disk (after enroll/delete).""" self._db_stamp = 0.0 def ensure_db(self) -> None: """Load embeddings only when enrolled store changed — merge temp unknowns incrementally.""" stamp = self._enrolled_dirs_mtime() if self.db and stamp <= self._db_stamp: self._merge_new_temp_embeddings() return self.reload_db() self._db_stamp = stamp self._merge_new_temp_embeddings() def reload_db(self) -> None: os.makedirs(EMB_ROOT, exist_ok=True) os.makedirs(TEMP_EMB_ROOT, exist_ok=True) os.makedirs(TEMP_EMB_ROOT, exist_ok=True) new_db = {} for folder in (EMB_ROOT, TEMP_EMB_ROOT): if not os.path.isdir(folder): continue loaded: set[str] = set() for fname in os.listdir(folder): if fname.endswith(EMB_SUFFIX): name = fname[: -len(EMB_SUFFIX)] elif fname.endswith(".npy"): name = fname[:-4] else: continue if name.startswith("unknown_") or name in loaded: continue loaded.add(name) emb = load_embedding(os.path.join(folder, name)) if emb is None: continue try: new_db[name] = emb logger.info("FaceMatcher backfill: %s loaded from cache.", name) except Exception as exc: logger.error("Failed loading embedding %s: %s", name, exc) with self.lock: self.db = new_db with self._unknown_lock: self._unknown_cache.clear() self._load_unknown_cache_from_disk() self._db_stamp = self._enrolled_dirs_mtime() if self.db: logger.info("FaceMatcher DB: %s", list(self.db.keys())) else: logger.warning("FaceMatcher DB empty — enroll faces via Face Database") def backfill_from_db(self) -> None: """Generate missing .npy files from face_database image folders.""" if self.app is None or not os.path.isdir(FACE_DB_ROOT): return import register_face os.makedirs(EMB_ROOT, exist_ok=True) for person in os.listdir(FACE_DB_ROOT): if person.startswith("unknown_"): continue person_dir = os.path.join(FACE_DB_ROOT, person) if not os.path.isdir(person_dir): continue key = person.replace("_", " ") if key in self.db or person in self.db: continue imgs = [ f for f in os.listdir(person_dir) if f.lower().endswith((".jpg", ".jpeg", ".png", ".webp")) ] if not imgs: continue try: embs = register_face.generate_embeddings( person, FACE_DB_ROOT, EMB_ROOT, app=self.app ) with self.lock: self.db[person] = embs if key != person: self.db[key] = embs logger.info("FaceMatcher backfill: %s embedding computed OK.", person) except Exception as exc: logger.warning("Could not backfill %s: %s", person, exc) def _refresh_stale_enrolled_embeddings(self) -> None: """Regenerate .npy files when embeddings were built with a different model pack.""" if self.app is None or not os.path.isdir(FACE_DB_ROOT): return import register_face for person in os.listdir(FACE_DB_ROOT): if person.startswith("unknown_"): continue person_dir = os.path.join(FACE_DB_ROOT, person) if not os.path.isdir(person_dir): continue imgs = [ f for f in os.listdir(person_dir) if f.lower().endswith((".jpg", ".jpeg", ".png", ".webp")) ] if not imgs: continue probe = cv2.imread(os.path.join(person_dir, imgs[0])) if probe is None: continue try: with self.lock: faces = self.app.get(probe) except Exception as exc: logger.debug("Self-test detect failed for %s: %s", person, exc) continue if not faces: continue fresh_emb = faces[0].embedding stored = self.db.get(person) needs_refresh = stored is None if stored is not None: score = _best_score(fresh_emb, stored) if score < EMBEDDING_SELF_TEST_MIN: needs_refresh = True logger.warning( "Stale embedding for %s (self-test=%.3f, min=%.2f) — regenerating with active model", person, score, EMBEDDING_SELF_TEST_MIN, ) if not needs_refresh: continue try: embs = register_face.generate_embeddings( person, FACE_DB_ROOT, EMB_ROOT, app=self.app ) self.db[person] = embs alt = person.replace("_", " ") if alt != person: self.db[alt] = embs logger.info("Refreshed embeddings for %s (%d vectors)", person, len(embs)) except Exception as exc: logger.warning("Could not refresh embeddings for %s: %s", person, exc) def _load_unknown_cache_from_disk(self) -> None: """Load persisted unknown_N embeddings into session cache (never into enrolled db).""" with self._unknown_lock: for folder in (TEMP_EMB_ROOT,): if not os.path.isdir(folder): continue for fname in os.listdir(folder): if not fname.startswith("unknown_"): continue if fname.endswith(EMB_SUFFIX): name = fname[: -len(EMB_SUFFIX)] path = os.path.join(folder, fname) elif fname.endswith(".npy"): name = fname[:-4] path = os.path.join(folder, fname) else: continue try: emb = load_embedding(os.path.join(folder, name)) if emb is None: emb = np.load(path) sample = emb[0] if getattr(emb, "ndim", 1) > 1 else emb self._unknown_cache[name] = np.asarray(sample, dtype=np.float32) except Exception as exc: logger.warning("Failed loading unknown embedding %s: %s", name, exc) if self._unknown_cache: logger.info("Unknown cache loaded: %s", sorted(self._unknown_cache.keys())) def _persist_unknown_emb(self, name: str, embedding: np.ndarray) -> None: os.makedirs(TEMP_EMB_ROOT, exist_ok=True) save_f32emb(os.path.join(TEMP_EMB_ROOT, f"{name}{EMB_SUFFIX}"), np.array([embedding])) def _persist_unknown_crop( self, name: str, frame: np.ndarray | None, bbox: list | tuple | None, ) -> None: if frame is None or bbox is None: return try: x1, y1, x2, y2 = (int(v) for v in bbox) except (TypeError, ValueError): return h, w = frame.shape[:2] x1, y1 = max(0, x1), max(0, y1) x2, y2 = min(w, x2), min(h, y2) if x2 <= x1 or y2 <= y1: return face_img = frame[y1:y2, x1:x2] if face_img.size == 0: return img_dir = os.path.join(TEMP_UNKNOWN_IMG_ROOT, name) os.makedirs(img_dir, exist_ok=True) cv2.imwrite(os.path.join(img_dir, "0.jpg"), face_img) def _persist_unknown_identity( self, name: str, embedding: np.ndarray, frame: np.ndarray | None = None, bbox: list | tuple | None = None, ) -> None: """Save unknown slot to temp_faces_db + face_database crop for re-ID across restarts.""" self._persist_unknown_emb(name, embedding) self._persist_unknown_crop(name, frame, bbox) def _detect_largest_face(self, frame: np.ndarray): if self.app is None: return None try: with self.lock: faces = self.app.get(frame) except Exception as exc: logger.error("InsightFace detection error (model may still be loading): %s", exc) return None if not faces: return None return max(faces, key=lambda f: (f.bbox[2] - f.bbox[0]) * (f.bbox[3] - f.bbox[1])) def _match_embedding( self, emb: np.ndarray, threshold: float | None = None, skip_unknown: bool = False, allow_near_match: bool = True, ) -> dict[str, Any]: """Match against enrolled identities first — unknown never wins over a qualifying enrolled hit.""" enrolled_name: str | None = None enrolled_score = 0.0 for name, db_emb in self.db.items(): if name.startswith("unknown_"): continue score = _best_score(emb, db_emb) if score > enrolled_score: enrolled_score = score enrolled_name = name computed_threshold = threshold if threshold is not None else DEFAULT_THRESHOLD person_threshold = _threshold_for_person(enrolled_name, computed_threshold) near_threshold = person_threshold * 0.85 enrolled_count = len({k for k in self.db if not k.startswith("unknown_")}) closest_display = enrolled_name.replace("_", " ") if enrolled_name else None if enrolled_name and enrolled_score >= person_threshold: display_name = closest_display or enrolled_name logger.info( "Face matched (enrolled): %s score=%.4f threshold=%.4f", display_name, enrolled_score, person_threshold, ) return { "found": True, "name": display_name, "canonical_name": enrolled_name, "confidence": round(enrolled_score, 3), "best_score": round(enrolled_score, 3), "threshold": round(person_threshold, 3), "location": "Enrolled database", "cam_id": "database", "timestamp": datetime.now(timezone.utc).isoformat(), "reason": "Matched enrolled face database", } if allow_near_match and enrolled_name and enrolled_score >= near_threshold: display_name = closest_display or enrolled_name logger.info( "Face near-match (enrolled): %s score=%.4f near=%.4f", display_name, enrolled_score, near_threshold, ) return { "found": True, "name": display_name, "canonical_name": enrolled_name, "confidence": round(enrolled_score, 3), "best_score": round(enrolled_score, 3), "threshold": round(person_threshold, 3), "location": "Enrolled database", "cam_id": "database", "timestamp": datetime.now(timezone.utc).isoformat(), "reason": f"Near-match to enrolled identity {display_name}", } if enrolled_score == 0.0 and enrolled_count > 0: for name, db_emb in list(self.db.items())[:3]: if not name.startswith("unknown_"): logger.warning( "Possible bad embedding: %s shape=%s norm=%.4f", name, getattr(db_emb, "shape", "?"), float(np.linalg.norm(db_emb)), ) return { "found": False, "reason": "Face detected but no match in enrolled database", "best_score": round(enrolled_score, 3), "threshold": round(computed_threshold, 3), "closest": closest_display, "enrolled_count": enrolled_count, } def _assign_unknown_identity( self, embedding: np.ndarray, frame: np.ndarray | None = None, bbox: list | tuple | None = None, ) -> str: """Match existing unknown_N or allocate next id; always persist to disk.""" name = self._find_or_create_unknown(embedding) with self._unknown_lock: emb = self._unknown_cache.get(name, embedding) self._persist_unknown_identity(name, emb, frame, bbox) with self.lock: arr = np.array([emb], dtype=np.float32) if emb.ndim == 1 else emb self.db[name] = arr logger.info("Face assigned unknown identity: %s", name) return name def match_frame(self, frame: np.ndarray, threshold: float | None = None) -> dict[str, Any]: self._force_prepare() if frame is None or frame.size == 0: return {"found": False, "reason": "Invalid image", "best_score": 0.0} if self.app is None: return {"found": False, "reason": "Face recognition engine unavailable (InsightFace not loaded — model may still be downloading)", "best_score": 0.0} self.ensure_db() enrolled_count = len([k for k in self.db if not k.startswith("unknown_")]) face = self._detect_largest_face(frame) if face is None: return { "found": False, "reason": "No face detected in uploaded image — ensure the photo shows a clear, well-lit face.", "best_score": None, "enrolled_count": enrolled_count, } det_score = float(getattr(face, "det_score", 1.0)) bbox = face.bbox bbox_area = (bbox[2] - bbox[0]) * (bbox[3] - bbox[1]) if det_score < MIN_FACE_CONFIDENCE or bbox_area < MIN_BBOX_AREA: return { "found": False, "reason": "Face detected but quality too low (partial or tiny detection skipped).", "best_score": None, "enrolled_count": enrolled_count, } match = self._match_embedding(face.embedding, threshold) if not match.get("found"): bbox = [int(v) for v in face.bbox] if face.bbox is not None else None new_name = self._assign_unknown_identity(face.embedding, frame, bbox) match["found"] = True match["name"] = new_name match["confidence"] = float(match.get("best_score") or 0.0) match["reason"] = f"Persisted unknown identity {new_name} (not enrolled)" return match def _find_or_create_unknown(self, embedding: np.ndarray) -> str: """Return existing unknown ID if embedding matches, else create a new one.""" with self._unknown_lock: best_uid: str | None = None best_score = 0.0 for uid, cached_emb in self._unknown_cache.items(): score = _best_score(embedding, cached_emb) if score > best_score: best_score = score best_uid = uid if best_uid and best_score >= UNKNOWN_REIDENT_THRESHOLD: updated = 0.7 * self._unknown_cache[best_uid] + 0.3 * embedding self._unknown_cache[best_uid] = updated.astype(np.float32) return best_uid new_id = self._allocate_unknown_id() new_name = f"unknown_{new_id}" self._unknown_cache[new_name] = embedding.copy() return new_name def _allocate_unknown_id(self) -> int: existing_ids = [] for k in self.db.keys(): if k.startswith("unknown_"): try: existing_ids.append(int(k.split("_")[1])) except (IndexError, ValueError): pass with self._unknown_lock: for k in self._unknown_cache.keys(): if k.startswith("unknown_"): try: existing_ids.append(int(k.split("_")[1])) except (IndexError, ValueError): pass for folder in ["faces_db", "temp_faces_db", "face_database", "temp_face_database"]: path = os.path.join(_FR_DIR, folder) if os.path.exists(path): for item in os.listdir(path): name = item.replace(".npy", "") if name.startswith("unknown_"): try: existing_ids.append(int(name.split("_")[1])) except (IndexError, ValueError): pass return max(existing_ids) + 1 if existing_ids else 1 def match_all_faces( self, frame: np.ndarray, threshold: float | None = None, allow_near_match: bool = True, min_det_score: float | None = None, min_bbox_area: int | None = None, ) -> list[dict[str, Any]]: """Detect and identify every face in the frame. Returns a list of {name, confidence, bbox:[x1,y1,x2,y2], found} entries. Used by the gossip contact-tracing pipeline. """ with _FACE_INFER_SEM: return self._match_all_faces_impl( frame, threshold=threshold, allow_near_match=allow_near_match, min_det_score=min_det_score, min_bbox_area=min_bbox_area, ) def _match_all_faces_impl( self, frame: np.ndarray, threshold: float | None = None, allow_near_match: bool = True, min_det_score: float | None = None, min_bbox_area: int | None = None, ) -> list[dict[str, Any]]: self._force_prepare() if frame is None or getattr(frame, "size", 0) == 0 or self.app is None: return [] self.ensure_db() min_conf = min_det_score if min_det_score is not None else MIN_FACE_CONFIDENCE min_area = min_bbox_area if min_bbox_area is not None else scaled_min_bbox_area(frame) with self.lock: faces = self.app.get(frame) results: list[dict[str, Any]] = [] filtered = 0 for face in faces or []: det_score = float(getattr(face, "det_score", 1.0)) bbox = face.bbox bbox_area = (bbox[2] - bbox[0]) * (bbox[3] - bbox[1]) if det_score < min_conf or bbox_area < min_area: filtered += 1 continue match = self._match_embedding(face.embedding, threshold, allow_near_match=allow_near_match) try: x1, y1, x2, y2 = (int(v) for v in face.bbox) except Exception: x1 = y1 = x2 = y2 = 0 found = bool(match.get("found")) name = match.get("name", "Unknown") confidence = match.get("confidence", match.get("best_score", 0.0)) if not found: new_name = self._assign_unknown_identity( face.embedding, frame, [x1, y1, x2, y2], ) name = new_name confidence = float(match.get("best_score") or 0.0) found = True match["reason"] = f"Unknown visitor tracked as {new_name}" results.append({ "name": name, "confidence": confidence, "bbox": [x1, y1, x2, y2], "found": found, "is_unknown": str(name).lower().startswith("unknown_"), # Store the raw embedding so live-feed cross-searches can compare # directly without re-running detection on the frame. "embedding": face.embedding.tolist() if hasattr(face.embedding, "tolist") else list(face.embedding), }) if not faces: logger.info( "match_all_faces: no faces detected (frame=%dx%d)", frame.shape[1], frame.shape[0], ) elif not results and filtered: logger.info( "match_all_faces: %d face(s) detected but filtered (min_conf=%.2f min_area=%d frame=%dx%d)", len(faces), min_conf, min_area, frame.shape[1], frame.shape[0], ) return results def register_from_frame(self, name: str, frame: np.ndarray) -> bool: self._force_prepare() if self.app is None: return False cleaned = name.strip().replace(" ", "_") if not cleaned: return False face = self._detect_largest_face(frame) if face is None: logger.warning("Registration failed: no face in frame for %s", name) return False import register_face os.makedirs(FACE_DB_ROOT, exist_ok=True) os.makedirs(EMB_ROOT, exist_ok=True) temp_path = os.path.join(_FR_DIR, f"temp_reg_{uuid.uuid4().hex}.jpg") cv2.imwrite(temp_path, frame) try: embs = register_face.register_face( cleaned, temp_path, db_root=FACE_DB_ROOT, emb_root=EMB_ROOT, known_embedding=face.embedding, app=self.app, ) with self.lock: self.db[cleaned] = embs self.db[name.strip()] = embs self.invalidate_db() self._db_stamp = self._enrolled_dirs_mtime() logger.info("Registered face %s (%d embeddings)", cleaned, len(embs)) return True except Exception as exc: logger.error("register_from_frame error: %s", exc) return False finally: if os.path.exists(temp_path): os.remove(temp_path) register_face_from_frame = register_from_frame