Spaces:
Runtime error
Runtime error
| """ | |
| storage/pg_storage.py β Supabase PostgreSQL: quαΊ£n lΓ½ 4 bαΊ£ng theo ERD | |
| Tables: | |
| persons β ThΓ΄ng tin cΓ‘ nhΓ’n | |
| visits β Lượt ghΓ© thΔm | |
| face_embeddings β Vector embedding 512 chiα»u | |
| recognition_logs β NhαΊt kΓ½ nhαΊn diα»n (ΔαΊ§y Δα»§ field theo ERD) | |
| """ | |
| import json | |
| import logging | |
| import uuid | |
| import psycopg2 | |
| from psycopg2.extras import RealDictCursor | |
| import config | |
| logger = logging.getLogger("pg_storage") | |
| class PostgresStorage: | |
| """QuαΊ£n lΓ½ tαΊ₯t cαΊ£ thao tΓ‘c SQL trΓͺn Supabase PostgreSQL.""" | |
| def __init__(self, uri: str = config.SUPABASE_DB_URI): | |
| self.uri = uri | |
| def _conn(self): | |
| return psycopg2.connect(self.uri) | |
| # ββ Load embeddings ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def load_all_embeddings(self) -> list[dict]: | |
| """ | |
| NαΊ‘p toΓ n bα» active embeddings vΓ metadata ngΖ°α»i dΓΉng tα»« DB. | |
| TrαΊ£ vα» list[{person_id, full_name, embedding, face_crop_mongo_id, embedding_id}] | |
| """ | |
| query = """ | |
| SELECT | |
| p.id::text AS person_id, | |
| p.full_name, | |
| e.id::text AS embedding_id, | |
| e.embedding, | |
| e.face_crop_mongo_id | |
| FROM persons p | |
| JOIN face_embeddings e ON p.id = e.person_id | |
| WHERE e.is_active = TRUE | |
| ORDER BY p.full_name; | |
| """ | |
| conn = self._conn() | |
| try: | |
| with conn.cursor(cursor_factory=RealDictCursor) as cur: | |
| cur.execute(query) | |
| rows = cur.fetchall() | |
| result = [] | |
| for r in rows: | |
| emb = r["embedding"] | |
| if isinstance(emb, str): | |
| cleaned = emb.strip("[]") | |
| embedding = [float(x) for x in cleaned.split(",") if x.strip()] | |
| elif isinstance(emb, list): | |
| embedding = [float(x) for x in emb] | |
| else: | |
| continue | |
| result.append({ | |
| "person_id": r["person_id"], | |
| "full_name": r["full_name"], | |
| "embedding_id": r["embedding_id"], | |
| "embedding": embedding, | |
| "face_crop_mongo_id": r["face_crop_mongo_id"], | |
| }) | |
| logger.info(f"[PG] Loaded {len(result)} embeddings from Supabase.") | |
| return result | |
| finally: | |
| conn.close() | |
| # ββ match_face RPC βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def match_face_rpc( | |
| self, | |
| embedding: list[float], | |
| threshold: float = config.SIMILARITY_THRESHOLD, | |
| ) -> dict | None: | |
| """ | |
| Gα»i Supabase RPC function `match_face` Δα» tΓ¬m khuΓ΄n mαΊ·t khα»p nhαΊ₯t. | |
| Returns: | |
| dict vα»i {person_id, full_name, embedding_id, similarity} hoαΊ·c None | |
| """ | |
| emb_str = "[" + ",".join(map(str, embedding)) + "]" | |
| conn = self._conn() | |
| try: | |
| with conn.cursor(cursor_factory=RealDictCursor) as cur: | |
| cur.execute( | |
| "SELECT * FROM match_face(%s::vector, %s);", | |
| (emb_str, threshold), | |
| ) | |
| row = cur.fetchone() | |
| if row: | |
| return dict(row) | |
| return None | |
| except Exception as e: | |
| logger.warning(f"[PG] match_face RPC failed (will fallback to RAM): {e}") | |
| return None | |
| finally: | |
| conn.close() | |
| # ββ register_new_face ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def register_new_face( | |
| self, | |
| name: str, | |
| embedding: list[float], | |
| face_crop_mongo_id: str, | |
| model_name: str = config.MODEL_NAME, | |
| ) -> tuple[str, str]: | |
| """ | |
| ΔΔng kΓ½ ngΖ°α»i mα»i: thΓͺm vΓ o persons + face_embeddings. | |
| Returns: | |
| (person_id, embedding_id) | |
| """ | |
| person_id = str(uuid.uuid4()) | |
| emb_id = str(uuid.uuid4()) | |
| emb_str = "[" + ",".join(map(str, embedding)) + "]" | |
| conn = self._conn() | |
| try: | |
| with conn.cursor() as cur: | |
| cur.execute( | |
| """ | |
| INSERT INTO persons | |
| (id, full_name, status, total_appearances, total_visits, created_at, updated_at) | |
| VALUES (%s, %s, 'active', 1, 1, NOW(), NOW()); | |
| """, | |
| (person_id, name), | |
| ) | |
| cur.execute( | |
| """ | |
| INSERT INTO face_embeddings | |
| (id, person_id, embedding, model_name, model_version, | |
| face_crop_mongo_id, is_primary, is_active, created_at) | |
| VALUES (%s, %s, %s, %s, '1.0.0', %s, TRUE, TRUE, NOW()); | |
| """, | |
| (emb_id, person_id, emb_str, model_name, face_crop_mongo_id), | |
| ) | |
| conn.commit() | |
| logger.info(f"[PG] Registered new face: {name} (person_id={person_id})") | |
| return person_id, emb_id | |
| except Exception as e: | |
| conn.rollback() | |
| logger.error(f"[PG] register_new_face failed: {e}") | |
| raise | |
| finally: | |
| conn.close() | |
| # ββ record_matched_visit βββββββββββββββββββββββββββββββββββββββββββββ | |
| def record_matched_visit( | |
| self, | |
| person_id: str, | |
| embedding_id: str, | |
| face_crop_mongo_id: str, | |
| raw_image_mongo_id: str, | |
| similarity: float, | |
| bbox: dict, | |
| ui_color: str = "#00DC50", | |
| ui_track_index: int = 0, | |
| ) -> tuple[str, str]: | |
| """ | |
| Ghi nhαΊn lượt ghΓ© thΔm cho khuΓ΄n mαΊ·t khα»p. | |
| - TαΊ‘o hoαΊ·c cαΊp nhαΊt visit (trong 30 phΓΊt) | |
| - Ghi recognition_logs ΔαΊ§y Δα»§ field theo ERD | |
| - CαΊp nhαΊt persons (total_appearances, total_visits, last_seen_at) | |
| Returns: | |
| (visit_id, log_id) | |
| """ | |
| log_id = str(uuid.uuid4()) | |
| visit_id = None | |
| conn = self._conn() | |
| try: | |
| with conn.cursor() as cur: | |
| # 1. Kiα»m tra visit trong 30 phΓΊt gαΊ§n nhαΊ₯t | |
| cur.execute( | |
| """ | |
| SELECT id FROM visits | |
| WHERE person_id = %s | |
| AND last_seen_at >= NOW() - INTERVAL '30 minutes' | |
| ORDER BY last_seen_at DESC LIMIT 1; | |
| """, | |
| (person_id,), | |
| ) | |
| row = cur.fetchone() | |
| if row: | |
| visit_id = str(row[0]) | |
| cur.execute( | |
| """ | |
| UPDATE visits | |
| SET last_seen_at = NOW(), | |
| appearance_count = appearance_count + 1 | |
| WHERE id = %s; | |
| """, | |
| (visit_id,), | |
| ) | |
| else: | |
| visit_id = str(uuid.uuid4()) | |
| cur.execute( | |
| """ | |
| INSERT INTO visits | |
| (id, person_id, first_seen_at, last_seen_at, appearance_count, created_at) | |
| VALUES (%s, %s, NOW(), NOW(), 1, NOW()); | |
| """, | |
| (visit_id, person_id), | |
| ) | |
| # 2. Ghi recognition_log ΔαΊ§y Δα»§ field theo ERD | |
| cur.execute( | |
| """ | |
| INSERT INTO recognition_logs ( | |
| id, person_id, visit_id, embedding_id, | |
| raw_image_mongo_id, face_crop_mongo_id, | |
| recognition_status, similarity_distance, confidence, | |
| bbox_x, bbox_y, bbox_width, bbox_height, | |
| ui_color, ui_track_index, | |
| detected_at | |
| ) | |
| VALUES ( | |
| %s, %s, %s, %s, | |
| %s, %s, | |
| 'recognized', %s, %s, | |
| %s, %s, %s, %s, | |
| %s, %s, | |
| NOW() | |
| ); | |
| """, | |
| ( | |
| log_id, person_id, visit_id, embedding_id, | |
| raw_image_mongo_id, face_crop_mongo_id, | |
| float(1.0 - similarity), float(similarity), | |
| float(bbox.get("x1", 0)), | |
| float(bbox.get("y1", 0)), | |
| float(bbox.get("x2", 0) - bbox.get("x1", 0)), | |
| float(bbox.get("y2", 0) - bbox.get("y1", 0)), | |
| ui_color, ui_track_index, | |
| ), | |
| ) | |
| # 3. CαΊp nhαΊt persons | |
| cur.execute( | |
| """ | |
| UPDATE persons | |
| SET total_appearances = total_appearances + 1, | |
| total_visits = (SELECT COUNT(*) FROM visits WHERE person_id = %s), | |
| last_seen_at = NOW(), | |
| updated_at = NOW() | |
| WHERE id = %s; | |
| """, | |
| (person_id, person_id), | |
| ) | |
| conn.commit() | |
| logger.info(f"[PG] Matched visit recorded: person={person_id}, visit={visit_id}") | |
| return visit_id, log_id | |
| except Exception as e: | |
| conn.rollback() | |
| logger.error(f"[PG] record_matched_visit failed: {e}") | |
| raise | |
| finally: | |
| conn.close() | |
| # ββ record_unknown_log βββββββββββββββββββββββββββββββββββββββββββββββ | |
| def record_unknown_log( | |
| self, | |
| face_crop_mongo_id: str, | |
| raw_image_mongo_id: str, | |
| similarity: float, | |
| bbox: dict, | |
| ui_color: str = "#FF5500", | |
| ui_track_index: int = 0, | |
| ) -> str: | |
| """ | |
| Ghi recognition_log vα»i person_id = NULL cho khuΓ΄n mαΊ·t khΓ΄ng nhαΊn diα»n. | |
| Returns: | |
| log_id (str) | |
| """ | |
| log_id = str(uuid.uuid4()) | |
| conn = self._conn() | |
| try: | |
| with conn.cursor() as cur: | |
| cur.execute( | |
| """ | |
| INSERT INTO recognition_logs ( | |
| id, person_id, visit_id, embedding_id, | |
| raw_image_mongo_id, face_crop_mongo_id, | |
| recognition_status, similarity_distance, confidence, | |
| bbox_x, bbox_y, bbox_width, bbox_height, | |
| ui_color, ui_track_index, | |
| detected_at | |
| ) | |
| VALUES ( | |
| %s, NULL, NULL, NULL, | |
| %s, %s, | |
| 'unknown', %s, %s, | |
| %s, %s, %s, %s, | |
| %s, %s, | |
| NOW() | |
| ); | |
| """, | |
| ( | |
| log_id, | |
| raw_image_mongo_id, face_crop_mongo_id, | |
| float(1.0 - similarity), float(similarity), | |
| float(bbox.get("x1", 0)), | |
| float(bbox.get("y1", 0)), | |
| float(bbox.get("x2", 0) - bbox.get("x1", 0)), | |
| float(bbox.get("y2", 0) - bbox.get("y1", 0)), | |
| ui_color, ui_track_index, | |
| ), | |
| ) | |
| conn.commit() | |
| logger.info(f"[PG] Unknown face logged: log_id={log_id}") | |
| return log_id | |
| except Exception as e: | |
| conn.rollback() | |
| logger.error(f"[PG] record_unknown_log failed: {e}") | |
| raise | |
| finally: | |
| conn.close() | |
| # ββ update_person_info βββββββββββββββββββββββββββββββββββββββββββββββ | |
| def update_person_info( | |
| self, | |
| person_id: str, | |
| full_name: str, | |
| date_of_birth: str | None = None, | |
| phone_number: str | None = None, | |
| address: str | None = None, | |
| ) -> bool: | |
| """CαΊp nhαΊt thΓ΄ng tin cΓ‘ nhΓ’n cα»§a mα»t person.""" | |
| conn = self._conn() | |
| try: | |
| with conn.cursor() as cur: | |
| cur.execute( | |
| """ | |
| UPDATE persons | |
| SET full_name = %s, | |
| date_of_birth = %s, | |
| phone_number = %s, | |
| address = %s, | |
| status = 'active', | |
| updated_at = NOW() | |
| WHERE id = %s; | |
| """, | |
| (full_name, date_of_birth, phone_number, address, person_id), | |
| ) | |
| updated = cur.rowcount > 0 | |
| conn.commit() | |
| return updated | |
| except Exception as e: | |
| conn.rollback() | |
| logger.error(f"[PG] update_person_info failed: {e}") | |
| raise | |
| finally: | |
| conn.close() | |
| # ββ delete_person ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def delete_person(self, person_id: str) -> tuple[bool, list[str]]: | |
| """ | |
| XΓ³a person vΓ toΓ n bα» dα»― liα»u liΓͺn quan trΓͺn Supabase. | |
| Returns: | |
| (success, list of face_crop_mongo_ids cαΊ§n xΓ³a trΓͺn MongoDB) | |
| """ | |
| conn = self._conn() | |
| try: | |
| with conn.cursor() as cur: | |
| cur.execute("SELECT id FROM persons WHERE id = %s;", (person_id,)) | |
| if not cur.fetchone(): | |
| return False, [] | |
| # Thu thαΊp tαΊ₯t cαΊ£ mongo_ids cαΊ§n xΓ³a | |
| mongo_ids: list[str] = [] | |
| cur.execute( | |
| "SELECT face_crop_mongo_id FROM face_embeddings WHERE person_id = %s;", | |
| (person_id,), | |
| ) | |
| mongo_ids.extend([r[0] for r in cur.fetchall() if r[0]]) | |
| cur.execute( | |
| "SELECT face_crop_mongo_id FROM recognition_logs WHERE person_id = %s;", | |
| (person_id,), | |
| ) | |
| mongo_ids.extend([r[0] for r in cur.fetchall() if r[0]]) | |
| # XΓ³a theo thα»© tα»± FK | |
| cur.execute("DELETE FROM recognition_logs WHERE person_id = %s;", (person_id,)) | |
| cur.execute("DELETE FROM visits WHERE person_id = %s;", (person_id,)) | |
| cur.execute("DELETE FROM face_embeddings WHERE person_id = %s;", (person_id,)) | |
| cur.execute("DELETE FROM persons WHERE id = %s;", (person_id,)) | |
| conn.commit() | |
| logger.info(f"[PG] Deleted person: {person_id}") | |
| return True, list(set(mongo_ids)) | |
| except Exception as e: | |
| conn.rollback() | |
| logger.error(f"[PG] delete_person failed: {e}") | |
| raise | |
| finally: | |
| conn.close() | |
| # ββ delete_person_by_name ββββββββββββββββββββββββββββββββββββββββββββ | |
| def delete_person_by_name(self, name: str) -> tuple[bool, list[str]]: | |
| """XΓ³a person theo full_name. Convenience wrapper.""" | |
| conn = self._conn() | |
| try: | |
| with conn.cursor() as cur: | |
| cur.execute("SELECT id FROM persons WHERE full_name = %s;", (name,)) | |
| row = cur.fetchone() | |
| if not row: | |
| return False, [] | |
| person_id = str(row[0]) | |
| return self.delete_person(person_id) | |
| finally: | |
| conn.close() | |
| # ββ list_persons βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def list_persons_with_faces(self) -> list[dict]: | |
| """ | |
| LαΊ₯y danh sΓ‘ch tαΊ₯t cαΊ£ persons cΓ³ face_embedding, kΓ¨m mongo_id αΊ£nh crop. | |
| """ | |
| query = """ | |
| SELECT | |
| p.id::text AS person_id, | |
| p.full_name, | |
| p.status, | |
| p.total_appearances, | |
| p.total_visits, | |
| p.last_seen_at, | |
| e.id::text AS embedding_id, | |
| e.face_crop_mongo_id | |
| FROM persons p | |
| JOIN face_embeddings e ON p.id = e.person_id | |
| WHERE e.is_active = TRUE | |
| ORDER BY p.full_name; | |
| """ | |
| conn = self._conn() | |
| try: | |
| with conn.cursor(cursor_factory=RealDictCursor) as cur: | |
| cur.execute(query) | |
| rows = cur.fetchall() | |
| result = [] | |
| for r in rows: | |
| d = dict(r) | |
| if d.get("last_seen_at"): | |
| d["last_seen_at"] = d["last_seen_at"].isoformat() | |
| result.append(d) | |
| return result | |
| finally: | |
| conn.close() | |
| # ββ Singleton βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| pg_storage = PostgresStorage() | |