| """ |
| 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 |
| |
| 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) |
| |
| 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 |
| |
| _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: |
| 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_"), |
| |
| |
| "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 |
|
|