""" CONSTABLE – FAISS embedding store for face vectors. Face embeddings (512-d float32 from FaceNet/InceptionResnetV1) are stored in a flat L2 index. A parallel JSON sidecar maps FAISS integer IDs → employee IDs. """ import os import json import numpy as np try: import faiss FAISS_AVAILABLE = True except ImportError: FAISS_AVAILABLE = False print("[EmbeddingStore] faiss-cpu not installed – using brute-force fallback.") DB_DIR = os.path.join(os.path.dirname(__file__), "..", "database") INDEX_PATH = os.path.join(DB_DIR, "face_index.faiss") META_PATH = os.path.join(DB_DIR, "face_meta.json") EMBEDDING_DIM = 512 SIMILARITY_THRESHOLD = 0.85 # cosine similarity threshold (after L2-normalisation) class EmbeddingStore: def __init__(self): os.makedirs(DB_DIR, exist_ok=True) self._load() # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ def _load(self): if FAISS_AVAILABLE and os.path.exists(INDEX_PATH) and os.path.exists(META_PATH): self.index = faiss.read_index(INDEX_PATH) with open(META_PATH) as f: self.meta = json.load(f) # {str(faiss_id): employee_id} else: if FAISS_AVAILABLE: self.index = faiss.IndexFlatIP(EMBEDDING_DIM) # inner product on L2-normed vecs = cosine else: self.index = None self.meta = {} def _save(self): if FAISS_AVAILABLE and self.index is not None: faiss.write_index(self.index, INDEX_PATH) with open(META_PATH, "w") as f: json.dump(self.meta, f) @staticmethod def _normalise(vec: np.ndarray) -> np.ndarray: norm = np.linalg.norm(vec) return vec / norm if norm > 1e-10 else vec # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ def add(self, employee_id: str, embeddings: list): """Add one or more embeddings for an employee.""" for emb in embeddings: vec = self._normalise(np.array(emb, dtype=np.float32)).reshape(1, -1) if FAISS_AVAILABLE and self.index is not None: faiss_id = self.index.ntotal self.index.add(vec) self.meta[str(faiss_id)] = employee_id else: # Brute-force fallback: store as list in meta faiss_id = len(self.meta) self.meta[str(faiss_id)] = {"id": employee_id, "vec": vec.tolist()[0]} self._save() def search(self, embedding: np.ndarray, top_k: int = 1): """ Returns (employee_id, similarity_score) or (None, 0.0) if no match. """ vec = self._normalise(np.array(embedding, dtype=np.float32)).reshape(1, -1) if FAISS_AVAILABLE and self.index is not None and self.index.ntotal > 0: distances, indices = self.index.search(vec, top_k) best_idx = int(indices[0][0]) best_score = float(distances[0][0]) if best_score >= SIMILARITY_THRESHOLD and best_idx != -1: employee_id = self.meta.get(str(best_idx)) return employee_id, best_score return None, best_score # Brute-force fallback best_score = -1.0 best_id = None for key, val in self.meta.items(): if isinstance(val, dict): stored_vec = np.array(val["vec"], dtype=np.float32) score = float(np.dot(vec.flatten(), stored_vec)) if score > best_score: best_score = score best_id = val["id"] if best_score >= SIMILARITY_THRESHOLD: return best_id, best_score return None, best_score def remove_employee(self, employee_id: str): """Remove all vectors for an employee (requires index rebuild).""" if not FAISS_AVAILABLE or self.index is None: self.meta = {k: v for k, v in self.meta.items() if not (isinstance(v, dict) and v.get("id") == employee_id)} self._save() return # Collect surviving entries survivors = [(k, v) for k, v in self.meta.items() if v != employee_id] new_index = faiss.IndexFlatIP(EMBEDDING_DIM) new_meta = {} # We can't retrieve raw vectors from IndexFlatIP after the fact, # so we rebuild from scratch using stored reconstructed vectors. # (IndexFlatIP supports reconstruct) for old_key, emp_id in self.meta.items(): if emp_id == employee_id: continue vec = np.zeros((1, EMBEDDING_DIM), dtype=np.float32) self.index.reconstruct(int(old_key), vec.reshape(-1)) new_id = new_index.ntotal new_index.add(vec) new_meta[str(new_id)] = emp_id self.index = new_index self.meta = new_meta self._save() @property def total_vectors(self): if FAISS_AVAILABLE and self.index is not None: return self.index.ntotal return sum(1 for v in self.meta.values() if isinstance(v, dict))