github-actions
Deploy to Hugging Face
c794b6b
Raw
History Blame Contribute Delete
32.5 kB
"""
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