diff --git a/.gitignore b/.gitignore index 5d381cc..232b864 100644 --- a/.gitignore +++ b/.gitignore @@ -160,3 +160,15 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# ──────────────────────────────────────────────────────── +# ENTORNO VIRTUAL DEL PROYECTO +# ──────────────────────────────────────────────────────── +ia_env/ + +# ──────────────────────────────────────────────────────── +# MODELOS DE IA (Límite de GitHub: 100 MB) +# ──────────────────────────────────────────────────────── + +*.pt + *.onnx \ No newline at end of file diff --git a/Rodrigo Cahuantzi_1.jpg b/Rodrigo Cahuantzi_1.jpg new file mode 100644 index 0000000..82215bb Binary files /dev/null and b/Rodrigo Cahuantzi_1.jpg differ diff --git a/base_datos_rostros.pkl b/base_datos_rostros.pkl new file mode 100644 index 0000000..a269f81 Binary files /dev/null and b/base_datos_rostros.pkl differ diff --git a/cache_nombres/generos.json b/cache_nombres/generos.json new file mode 100644 index 0000000..6442f12 --- /dev/null +++ b/cache_nombres/generos.json @@ -0,0 +1 @@ +{"Emanuel Flores": "Man", "Vikicar Aldana": "Man", "Cristian Hernandez Suarez": "Man", "Oscar Atriano Ponce_1": "Man", "Carlos Eduardo Cuamatzi": "Man", "Rosa maria": "Woman", "Ximena": "Woman", "Ana Karen Guerrero": "Woman", "Diana laura": "Woman", "Diana Laura": "Woman", "Diana Laura Tecpa": "Woman", "aridai montiel zistecatl": "Woman", "Aridai montiel": "Woman", "Vikicar": "Man", "Ian Axel": "Man", "Rafael": "Man", "Rubisela Barrientos": "Woman", "ian axel": "Man", "Aridai Montiel": "Woman", "Adriana Lopez": "Man", "Oscar Atriano Ponce": "Man", "Xayli Ximena": "Man", "Victor Manuel Ocampo Mendez": "Man", "Rodrigo C": "Man", "Rodrigo Cahuantzi C": "Man", "Rodrigo c": "Man", "Yuriel": "Man", "Miguel Angel": "Man", "Omar": "Man"} \ No newline at end of file diff --git a/cache_nombres/nombre_Miguel Angel.mp3 b/cache_nombres/nombre_Miguel Angel.mp3 new file mode 100644 index 0000000..65f9b98 Binary files /dev/null and b/cache_nombres/nombre_Miguel Angel.mp3 differ diff --git a/cache_nombres/nombre_Omar.mp3 b/cache_nombres/nombre_Omar.mp3 new file mode 100644 index 0000000..9c4c4dc Binary files /dev/null and b/cache_nombres/nombre_Omar.mp3 differ diff --git a/cache_nombres/nombre_Rosa maria.mp3 b/cache_nombres/nombre_Rosa maria.mp3 new file mode 100644 index 0000000..8386987 Binary files /dev/null and b/cache_nombres/nombre_Rosa maria.mp3 differ diff --git a/cache_nombres/nombre_Yuriel.mp3 b/cache_nombres/nombre_Yuriel.mp3 new file mode 100644 index 0000000..e6dbb45 Binary files /dev/null and b/cache_nombres/nombre_Yuriel.mp3 differ diff --git a/db_institucion/Adriana Lopez.jpg b/db_institucion/Adriana Lopez.jpg index 91e9370..54fbfd5 100644 Binary files a/db_institucion/Adriana Lopez.jpg and b/db_institucion/Adriana Lopez.jpg differ diff --git a/db_institucion/Ana Karen Guerrero.jpg b/db_institucion/Ana Karen Guerrero.jpg index ada30bc..251942e 100644 Binary files a/db_institucion/Ana Karen Guerrero.jpg and b/db_institucion/Ana Karen Guerrero.jpg differ diff --git a/db_institucion/Aridai Montiel.jpg b/db_institucion/Aridai Montiel.jpg index 76b7844..1f334a5 100644 Binary files a/db_institucion/Aridai Montiel.jpg and b/db_institucion/Aridai Montiel.jpg differ diff --git a/db_institucion/Aridai montiel.jpg b/db_institucion/Aridai montiel.jpg index 3e2b00d..4fbe6c2 100644 Binary files a/db_institucion/Aridai montiel.jpg and b/db_institucion/Aridai montiel.jpg differ diff --git a/db_institucion/Carlos Eduardo Cuamatzi.jpg b/db_institucion/Carlos Eduardo Cuamatzi.jpg index e9ab70c..a2100d5 100644 Binary files a/db_institucion/Carlos Eduardo Cuamatzi.jpg and b/db_institucion/Carlos Eduardo Cuamatzi.jpg differ diff --git a/db_institucion/Cristian Hernandez Suarez.jpg b/db_institucion/Cristian Hernandez Suarez.jpg index 1ca481b..2dd6b48 100644 Binary files a/db_institucion/Cristian Hernandez Suarez.jpg and b/db_institucion/Cristian Hernandez Suarez.jpg differ diff --git a/db_institucion/Diana Laura Tecpa.jpg b/db_institucion/Diana Laura Tecpa.jpg index a7f70e4..35079d1 100644 Binary files a/db_institucion/Diana Laura Tecpa.jpg and b/db_institucion/Diana Laura Tecpa.jpg differ diff --git a/db_institucion/Diana Laura.jpg b/db_institucion/Diana Laura.jpg index 2e8318c..479b9fd 100644 Binary files a/db_institucion/Diana Laura.jpg and b/db_institucion/Diana Laura.jpg differ diff --git a/db_institucion/Diana laura.jpg b/db_institucion/Diana laura.jpg index 100562a..33b003e 100644 Binary files a/db_institucion/Diana laura.jpg and b/db_institucion/Diana laura.jpg differ diff --git a/db_institucion/Emanuel Flores.jpg b/db_institucion/Emanuel Flores.jpg index ce282e7..a2bd0f5 100644 Binary files a/db_institucion/Emanuel Flores.jpg and b/db_institucion/Emanuel Flores.jpg differ diff --git a/db_institucion/Ian Axel.jpg b/db_institucion/Ian Axel.jpg index 25a9030..e672d38 100644 Binary files a/db_institucion/Ian Axel.jpg and b/db_institucion/Ian Axel.jpg differ diff --git a/db_institucion/Miguel Angel.jpg b/db_institucion/Miguel Angel.jpg new file mode 100644 index 0000000..91f16e2 Binary files /dev/null and b/db_institucion/Miguel Angel.jpg differ diff --git a/db_institucion/Omar.jpg b/db_institucion/Omar.jpg new file mode 100644 index 0000000..5e96916 Binary files /dev/null and b/db_institucion/Omar.jpg differ diff --git a/db_institucion/Oscar Atriano Ponce.jpg b/db_institucion/Oscar Atriano Ponce.jpg index 5f05c42..f143a03 100644 Binary files a/db_institucion/Oscar Atriano Ponce.jpg and b/db_institucion/Oscar Atriano Ponce.jpg differ diff --git a/db_institucion/Oscar Atriano Ponce_1.jpg b/db_institucion/Oscar Atriano Ponce_1.jpg index 4a71dee..39b5b46 100644 Binary files a/db_institucion/Oscar Atriano Ponce_1.jpg and b/db_institucion/Oscar Atriano Ponce_1.jpg differ diff --git a/db_institucion/Rafael.jpg b/db_institucion/Rafael.jpg index 5745ad4..2c28256 100644 Binary files a/db_institucion/Rafael.jpg and b/db_institucion/Rafael.jpg differ diff --git a/db_institucion/Rodrigo C.jpg b/db_institucion/Rodrigo C.jpg index da37fac..6e8fb4a 100644 Binary files a/db_institucion/Rodrigo C.jpg and b/db_institucion/Rodrigo C.jpg differ diff --git a/db_institucion/Rodrigo Cahuantzi C.jpg b/db_institucion/Rodrigo Cahuantzi C.jpg new file mode 100644 index 0000000..249733b Binary files /dev/null and b/db_institucion/Rodrigo Cahuantzi C.jpg differ diff --git a/db_institucion/Rodrigo Cahuantzi_1.jpg b/db_institucion/Rodrigo Cahuantzi_1.jpg deleted file mode 100644 index e35066d..0000000 Binary files a/db_institucion/Rodrigo Cahuantzi_1.jpg and /dev/null differ diff --git a/db_institucion/Rodrigo c.jpg b/db_institucion/Rodrigo c.jpg new file mode 100644 index 0000000..ccfa3b8 Binary files /dev/null and b/db_institucion/Rodrigo c.jpg differ diff --git a/db_institucion/Rosa Maria.jpg b/db_institucion/Rosa Maria.jpg deleted file mode 100644 index 0adb5a9..0000000 Binary files a/db_institucion/Rosa Maria.jpg and /dev/null differ diff --git a/db_institucion/Rosa Maria_2.jpg b/db_institucion/Rosa Maria_2.jpg deleted file mode 100644 index 3e81394..0000000 Binary files a/db_institucion/Rosa Maria_2.jpg and /dev/null differ diff --git a/db_institucion/Rosa maria.jpg b/db_institucion/Rosa maria.jpg index 61be68d..dfe98e7 100644 Binary files a/db_institucion/Rosa maria.jpg and b/db_institucion/Rosa maria.jpg differ diff --git a/db_institucion/Rubisela Barrientos.jpg b/db_institucion/Rubisela Barrientos.jpg index 40dc211..5a686e9 100644 Binary files a/db_institucion/Rubisela Barrientos.jpg and b/db_institucion/Rubisela Barrientos.jpg differ diff --git a/db_institucion/Victor Manuel Ocampo Mendez.jpg b/db_institucion/Victor Manuel Ocampo Mendez.jpg index 25af5b9..03c139a 100644 Binary files a/db_institucion/Victor Manuel Ocampo Mendez.jpg and b/db_institucion/Victor Manuel Ocampo Mendez.jpg differ diff --git a/db_institucion/Vikicar Aldana.jpg b/db_institucion/Vikicar Aldana.jpg index 0f0cf32..479c6d6 100644 Binary files a/db_institucion/Vikicar Aldana.jpg and b/db_institucion/Vikicar Aldana.jpg differ diff --git a/db_institucion/Vikicar.jpg b/db_institucion/Vikicar.jpg index e7f1def..6d6629b 100644 Binary files a/db_institucion/Vikicar.jpg and b/db_institucion/Vikicar.jpg differ diff --git a/db_institucion/Xayli Ximena.jpg b/db_institucion/Xayli Ximena.jpg index c3b0902..e30a415 100644 Binary files a/db_institucion/Xayli Ximena.jpg and b/db_institucion/Xayli Ximena.jpg differ diff --git a/db_institucion/Ximena.jpg b/db_institucion/Ximena.jpg index 3cce987..ee7ba9f 100644 Binary files a/db_institucion/Ximena.jpg and b/db_institucion/Ximena.jpg differ diff --git a/db_institucion/Yuriel.jpg b/db_institucion/Yuriel.jpg new file mode 100644 index 0000000..d30efb1 Binary files /dev/null and b/db_institucion/Yuriel.jpg differ diff --git a/db_institucion/aridai montiel zistecatl.jpg b/db_institucion/aridai montiel zistecatl.jpg index 70b44c7..4cee475 100644 Binary files a/db_institucion/aridai montiel zistecatl.jpg and b/db_institucion/aridai montiel zistecatl.jpg differ diff --git a/db_institucion/ian axel.jpg b/db_institucion/ian axel.jpg index d72d3e9..2c2824f 100644 Binary files a/db_institucion/ian axel.jpg and b/db_institucion/ian axel.jpg differ diff --git a/fusion.py b/fusion.py index 7552b2a..d11164c 100644 --- a/fusion.py +++ b/fusion.py @@ -1,7 +1,7 @@ import os os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3' os.environ['CUDA_VISIBLE_DEVICES'] = '-1' - +os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = "rtsp_transport;tcp|stimeout;3000000" import cv2 import numpy as np import time @@ -17,7 +17,7 @@ warnings.filterwarnings("ignore") # 1. IMPORTAMOS NUESTROS MÓDULOS # ────────────────────────────────────────────────────────────────────────────── # Del motor matemático y tracking -from seguimiento2 import GlobalMemory, CamManager, SECUENCIA, URLS, FUENTE +from seguimiento2 import GlobalMemory, CamManager, SECUENCIA, URLS, FUENTE, similitud_hibrida # Del motor de reconocimiento facial y audio from reconocimiento2 import ( @@ -43,37 +43,46 @@ BASE_DATOS_ROSTROS = gestionar_vectores(actualizar=True) # ────────────────────────────────────────────────────────────────────────────── # 3. MOTOR ASÍNCRONO # ────────────────────────────────────────────────────────────────────────────── -def procesar_rostro_async(frame, box, gid, cam_id, global_mem, trk): - """ Toma el recorte del tracker, usa YuNet importado, y hace la Fusión Mágica """ +def procesar_rostro_async(frame_hd, box_480, gid, cam_id, global_mem, trk): + """ Toma el recorte del tracker, escala a HD, aplica filtros físicos y votación biométrica """ try: if not BASE_DATOS_ROSTROS: return - h_real, w_real = frame.shape[:2] + # ────────────────────────────────────────────────────────── + # 1. ESCALADO HD Y EXTRACCIÓN DE CABEZA (Solución Xayli) + # ────────────────────────────────────────────────────────── + h_real, w_real = frame_hd.shape[:2] + if w_real <= 480: + print(f"[ERROR CAM {cam_id}] Le estás pasando el frame_show (480x270) a ArcFace, no el HD.") + escala_x = w_real / 480.0 escala_y = h_real / 270.0 - x_min, y_min, x_max, y_max = box + x_min, y_min, x_max, y_max = box_480 h_box = y_max - y_min y_min_expandido = max(0, y_min - (h_box * 0.15)) - y_max_cabeza = min(y_max, y_min + (h_box * 0.40)) + # ⚡ 50% del cuerpo para no cortar cabezas de personas de menor estatura + y_max_cabeza = min(270, y_min + (h_box * 0.50)) - x1 = int(max(0, x_min) * escala_x) - y1 = int(y_min_expandido * escala_y) - x2 = int(min(480, x_max) * escala_x) - y2 = int(y_max_cabeza * escala_y) + x1_hd = int(max(0, x_min) * escala_x) + y1_hd = int(y_min_expandido * escala_y) + x2_hd = int(min(480, x_max) * escala_x) + y2_hd = int(y_max_cabeza * escala_y) - roi_cabeza = frame[y1:y2, x1:x2] + roi_cabeza = frame_hd[y1_hd:y2_hd, x1_hd:x2_hd] - if roi_cabeza.size == 0 or roi_cabeza.shape[0] < 20 or roi_cabeza.shape[1] < 20: + # ⚡ Filtro físico relajado a 40x40 + if roi_cabeza.size == 0 or roi_cabeza.shape[0] < 40 or roi_cabeza.shape[1] < 40: return h_roi, w_roi = roi_cabeza.shape[:2] - # Usamos la función de YuNet importada, pasándole nuestro candado + # ────────────────────────────────────────────────────────── + # 2. DETECCIÓN YUNET Y FILTROS ANTI-BASURA + # ────────────────────────────────────────────────────────── faces = detectar_rostros_yunet(roi_cabeza, lock=YUNET_LOCK) - # OJO: Tu detectar_rostros_yunet devuelve 5 valores (x, y, w, h, score) for (rx, ry, rw, rh, score) in faces: rx, ry = max(0, rx), max(0, ry) rw, rh = min(w_roi - rx, rw), min(h_roi - ry, rh) @@ -94,8 +103,9 @@ def procesar_rostro_async(frame, box, gid, cam_id, global_mem, trk): necesita_saludo = True if nombre_actual is None or area_rostro_actual >= (area_ref * 1.5) or necesita_saludo: - m_x = int(rw * 0.15) - m_y = int(rh * 0.15) + + m_x = int(rw * 0.25) + m_y = int(rh * 0.25) roi_rostro = roi_cabeza[max(0, ry-m_y):min(h_roi, ry+rh+m_y), max(0, rx-m_x):min(w_roi, rx+rw+m_x)] @@ -103,68 +113,135 @@ def procesar_rostro_async(frame, box, gid, cam_id, global_mem, trk): if roi_rostro.size == 0 or roi_rostro.shape[0] < 40 or roi_rostro.shape[1] < 40: continue - # ── Filtro de nitidez ── - gray_roi = cv2.cvtColor(roi_rostro, cv2.COLOR_BGR2GRAY) - nitidez = cv2.Laplacian(gray_roi, cv2.CV_64F).var() - if nitidez < 50.0: + # 🛡️ FILTRO ANTI-PERFIL: Evita falsos positivos de personas viendo de lado + ratio_aspecto = roi_rostro.shape[1] / float(roi_rostro.shape[0]) + if ratio_aspecto < 0.60: continue - # ── ArcFace (Protegido con IA_LOCK) ── + # 🛡️ FILTRO ÓPTICO (Movimiento) + gray_roi = cv2.cvtColor(roi_rostro, cv2.COLOR_BGR2GRAY) + nitidez = cv2.Laplacian(gray_roi, cv2.CV_64F).var() + if nitidez < 15.0: + continue + + # VISIÓN NOCTURNA (Simetría con Base de Datos) + try: + lab = cv2.cvtColor(roi_rostro, cv2.COLOR_BGR2LAB) + l, a, b = cv2.split(lab) + clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8)) + l = clahe.apply(l) + roi_mejorado = cv2.cvtColor(cv2.merge((l, a, b)), cv2.COLOR_LAB2BGR) + except Exception: + roi_mejorado = roi_rostro + + # ────────────────────────────────────────────────────────── + # 3. MOTOR MTCNN Y SISTEMA DE VOTACIÓN + # ────────────────────────────────────────────────────────── with IA_LOCK: try: - res = DeepFace.represent(img_path=roi_rostro, model_name="ArcFace", enforce_detection=False) + res = DeepFace.represent( + img_path=roi_mejorado, + model_name="ArcFace", + detector_backend="mtcnn", + align=True, + enforce_detection=True + ) emb = np.array(res[0]["embedding"], dtype=np.float32) - - # Usamos tu función importada (ya con producto punto) mejor_match, max_sim = buscar_mejor_match(emb, BASE_DATOS_ROSTROS) except Exception: - continue + continue # Cara inválida para MTCNN - print(f"[DEBUG CAM {cam_id}] ArcFace: {mejor_match} al {max_sim:.2f} (Umbral: {UMBRAL_SIM})") + print(f"[DEBUG CAM {cam_id}] ArcFace: {mejor_match} al {max_sim:.2f}") - if max_sim > UMBRAL_SIM and mejor_match: + if max_sim >= UMBRAL_SIM and mejor_match: nombre_limpio = mejor_match.split('_')[0] with global_mem.lock: - gid_original = None - for otro_gid, datos_otro in global_mem.db.items(): - if datos_otro.get('nombre') == nombre_limpio and otro_gid != gid: - gid_original = otro_gid - break - - if gid_original is not None: - print(f"\n[FUSIÓN MÁGICA] Uniendo el ID {gid} al original {gid_original} ({nombre_limpio})") - if gid in global_mem.db: - del global_mem.db[gid] - - global_mem.db[gid_original]['ts'] = time.time() - global_mem.db[gid_original]['last_cam'] = cam_id - trk.gid = gid_original - else: - global_mem.db[gid]['nombre'] = nombre_limpio - global_mem.db[gid]['area_rostro_ref'] = area_rostro_actual + datos_id = global_mem.db.get(gid) + if not datos_id: continue - if str(cam_id) == "7" and necesita_saludo: - global_mem.ultimos_saludos[nombre_limpio] = time.time() + # SISTEMA DE VOTACIÓN (Anti-Falsos Positivos) + if datos_id.get('candidato_nombre') == nombre_limpio: + datos_id['votos_nombre'] = datos_id.get('votos_nombre', 0) + 1 + else: + datos_id['candidato_nombre'] = nombre_limpio + datos_id['votos_nombre'] = 1 + + # ⚡ EL PASE VIP: Si la certeza es aplastante (>0.55), salta la burocracia + if max_sim >= 0.45: + datos_id['votos_nombre'] = max(2, datos_id['votos_nombre']) + + # Solo actuamos si tiene 2 votos consistentes... + if datos_id['votos_nombre'] >= 2: + nombre_actual = datos_id.get('nombre') - try: - with IA_LOCK: - analisis = DeepFace.analyze(roi_rostro, actions=['gender'], enforce_detection=False)[0] - genero = analisis.get('dominant_gender', 'Man') - except Exception: - genero = "Man" - - # Usamos la función importada para el audio - threading.Thread( - target=hilo_bienvenida, - args=(nombre_limpio, genero), - daemon=True - ).start() - break + # CANDADO DE BAUTIZO: Protege a los VIPs de alucinaciones borrosas + if nombre_actual is not None and nombre_actual != nombre_limpio: + if max_sim < 0.55: + # Si es un puntaje bajo, es una confusión de ArcFace. Lo ignoramos. + print(f" [RECHAZO] ArcFace intentó renombrar a {nombre_actual} como {nombre_limpio} con solo {max_sim:.2f}") + continue + else: + # Si el puntaje es masivo, significa que OSNet se equivocó y pegó 2 personas + print(f"[CORRECCIÓN VIP] OSNet se confundió. Renombrando a {nombre_limpio} ({max_sim:.2f})") + + # ⚡ BAUTIZO Y LIMPIEZA + if nombre_actual != nombre_limpio: + datos_id['nombre'] = nombre_limpio + print(f" [BAUTIZO] ID {gid} confirmado como {nombre_limpio}") + + ids_a_borrar = [] + firma_actual = datos_id['firmas'][0] if datos_id['firmas'] else None + + for otro_gid, datos_otro in list(global_mem.db.items()): + if otro_gid == gid: continue + if datos_otro.get('nombre') == nombre_limpio: + ids_a_borrar.append(otro_gid) + elif datos_otro.get('nombre') is None and firma_actual and datos_otro['firmas']: + sim_huerfano = similitud_hibrida(firma_actual, datos_otro['firmas'][0]) + if sim_huerfano > 0.75: + ids_a_borrar.append(otro_gid) + + for id_basura in ids_a_borrar: + del global_mem.db[id_basura] + + # Actualizamos referencias + datos_id['area_rostro_ref'] = area_rostro_actual + datos_id['ts'] = time.time() + + # BLINDAJE VIP: Si la certeza es absoluta, amarrar la ropa a este ID + if max_sim > 0.65: + # Usamos la función externa para evitar bloqueos dobles (Deadlocks) + pass + + # 🔊 SALUDO DE BIENVENIDA + if str(cam_id) == "7" and necesita_saludo: + global_mem.ultimos_saludos[nombre_limpio] = time.time() + + import json + genero = "Man" # Valor por defecto seguro + ruta_generos = os.path.join("cache_nombres", "generos.json") + + if os.path.exists(ruta_generos): + try: + with open(ruta_generos, 'r') as f: + dic_generos = json.load(f) + genero = dic_generos.get(nombre_limpio, "Man") + except Exception: + pass # Si el archivo está ocupado, usamos el defecto + + # Lanzamos el audio instantáneamente sin IA pesada + threading.Thread(target=hilo_bienvenida, args=(nombre_limpio, genero), daemon=True).start() + + # Ejecutamos el blindaje fuera del lock principal + if max_sim > 0.65 and datos_id.get('votos_nombre', 0) >= 2: + global_mem.confirmar_firma_vip(gid, time.time()) + + break # Salimos del loop de rostros si ya identificamos except Exception as e: pass finally: - trk.procesando_rostro = False + trk.procesando_rostro = False def worker_rostros(global_mem): """ Consumidor de la cola multihilo """ @@ -245,11 +322,11 @@ def main(): turno_activo = (i == cam_ia) if turno_activo: - res = model.predict(frame_show, conf=0.40, iou=0.50, classes=[0], verbose=False, imgsz=480) + res = model.predict(frame_show, conf=0.50, iou=0.50, classes=[0], verbose=False, imgsz=480) if res[0].boxes: boxes = res[0].boxes.xyxy.cpu().numpy().tolist() - tracks = managers[cid].update(boxes, frame_show, now, turno_activo) + tracks = managers[cid].update(boxes, frame_show, frame, now, turno_activo) for trk in tracks: if trk.time_since_update <= 1: @@ -270,10 +347,86 @@ def main(): cv2.imshow("SmartSoft Fusion", np.vstack([np.hstack(tiles[0:3]), np.hstack(tiles[3:6])])) idx += 1 - if cv2.waitKey(1) == ord('q'): + + # ⚡ CAPTURAMOS LA TECLA EN UNA VARIABLE + # ⚡ CAPTURAMOS LA TECLA EN UNA VARIABLE + key = cv2.waitKey(1) & 0xFF + + if key == ord('q'): break - cv2.destroyAllWindows() + elif key == ord('r'): + print("\n[MODO REGISTRO] Escaneando mosaico para registrar...") + mejor_roi = None + max_area = 0 + + # ⚡ CORRECCIÓN: 'cams' es una lista, usamos enumerate + for i, cam_obj in enumerate(cams): + if cam_obj.frame is None: continue + + faces = detectar_rostros_yunet(cam_obj.frame) + for (fx, fy, fw, fh, score) in faces: + area = fw * fh + if area > max_area: + max_area = area + h_frame, w_frame = cam_obj.frame.shape[:2] + + # Margen amplio (30%) para MTCNN + m_x, m_y = int(fw * 0.30), int(fh * 0.30) + y1 = max(0, fy - m_y) + y2 = min(h_frame, fy + fh + m_y) + x1 = max(0, fx - m_x) + x2 = min(w_frame, fx + fw + m_x) + + mejor_roi = cam_obj.frame[y1:y2, x1:x2] + + if mejor_roi is not None and mejor_roi.size > 0: + cv2.imshow("Nueva Persona", mejor_roi) + cv2.waitKey(1) + + nom = input("Escribe el nombre de la persona: ").strip() + cv2.destroyWindow("Nueva Persona") + + if nom: + import json + # 1. Pedimos el género para no usar IA en el futuro + gen_input = input("¿Es Hombre (h) o Mujer (m)?: ").strip().lower() + genero_guardado = "Woman" if gen_input == 'm' else "Man" + + # 2. Actualizamos el caché de géneros al instante + ruta_generos = os.path.join("cache_nombres", "generos.json") + os.makedirs("cache_nombres", exist_ok=True) + dic_generos = {} + + if os.path.exists(ruta_generos): + try: + with open(ruta_generos, 'r') as f: + dic_generos = json.load(f) + except Exception: + pass + + dic_generos[nom] = genero_guardado + try: + with open(ruta_generos, 'w') as f: + json.dump(dic_generos, f) + except Exception as e: + print(f"[!] Error al guardar el género: {e}") + + # 3. Guardado tradicional de la foto + ruta_db = "db_institucion" + os.makedirs(ruta_db, exist_ok=True) + cv2.imwrite(os.path.join(ruta_db, f"{nom}.jpg"), mejor_roi) + + print(f"[OK] Rostro de '{nom}' guardado como {genero_guardado}.") + print(" Sincronizando base de datos en caliente...") + + # Al llamar a esta función, el sistema alineará la foto sin pisar nuestro JSON + nuevos_vectores = gestionar_vectores(actualizar=True) + BASE_DATOS_ROSTROS.clear() + BASE_DATOS_ROSTROS.update(nuevos_vectores) + print(" Sistema listo.") + else: + print("[!] Registro cancelado.") if __name__ == "__main__": main() \ No newline at end of file diff --git a/generar_db_rostros.py b/generar_db_rostros.py new file mode 100644 index 0000000..b0412a5 --- /dev/null +++ b/generar_db_rostros.py @@ -0,0 +1,110 @@ +import cv2 +import os +import json +import numpy as np + +# ⚡ Importamos tus motores exactos para no romper la simetría +from reconocimiento2 import detectar_rostros_yunet, gestionar_vectores + +def registrar_desde_webcam(): + print("\n" + "="*50) + print("📸 MÓDULO DE REGISTRO LIMPIO (WEBCAM LOCAL)") + print("Alinea tu rostro, mira a la cámara con buena luz.") + print("Presiona [R] para capturar | [Q] para salir") + print("="*50 + "\n") + + DB_PATH = "db_institucion" + CACHE_PATH = "cache_nombres" + os.makedirs(DB_PATH, exist_ok=True) + os.makedirs(CACHE_PATH, exist_ok=True) + + # 0 es la cámara por defecto de tu laptop + cap = cv2.VideoCapture(0) + if not cap.isOpened(): + print("[!] Error: No se pudo abrir la webcam local.") + return + + while True: + ret, frame = cap.read() + if not ret: continue + + # Espejamos la imagen para que actúe como un espejo natural + frame = cv2.flip(frame, 1) + display_frame = frame.copy() + + # Usamos YuNet para garantizar que estamos capturando una cara válida + faces = detectar_rostros_yunet(frame) + mejor_rostro = None + max_area = 0 + + for (fx, fy, fw, fh, score) in faces: + area = fw * fh + cv2.rectangle(display_frame, (fx, fy), (fx+fw, fy+fh), (0, 255, 0), 2) + + if area > max_area: + max_area = area + h_frame, w_frame = frame.shape[:2] + + # Mismo margen del 30% que requiere MTCNN para alinear correctamente + m_x, m_y = int(fw * 0.30), int(fh * 0.30) + y1 = max(0, fy - m_y) + y2 = min(h_frame, fy + fh + m_y) + x1 = max(0, fx - m_x) + x2 = min(w_frame, fx + fw + m_x) + + mejor_rostro = frame[y1:y2, x1:x2] + + cv2.putText(display_frame, "Alineate y presiona [R]", (10, 30), + cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 255), 2) + cv2.imshow("Registro Webcam Local", display_frame) + + key = cv2.waitKey(1) & 0xFF + if key == ord('q'): + break + elif key == ord('r'): + if mejor_rostro is not None and mejor_rostro.size > 0: + cv2.imshow("Captura Congelada", mejor_rostro) + cv2.waitKey(1) + + print("\n--- NUEVO REGISTRO ---") + nom = input("Escribe el nombre exacto de la persona: ").strip() + + if nom: + gen_input = input("¿Es Hombre (h) o Mujer (m)?: ").strip().lower() + genero_guardado = "Woman" if gen_input == 'm' else "Man" + + # 1. Guardamos la foto pura + foto_path = os.path.join(DB_PATH, f"{nom}.jpg") + cv2.imwrite(foto_path, mejor_rostro) + + # 2. Actualizamos el caché de géneros sin usar IA + ruta_generos = os.path.join(CACHE_PATH, "generos.json") + dic_generos = {} + if os.path.exists(ruta_generos): + try: + with open(ruta_generos, 'r') as f: + dic_generos = json.load(f) + except Exception: pass + + dic_generos[nom] = genero_guardado + with open(ruta_generos, 'w') as f: + json.dump(dic_generos, f) + + print(f"\n[OK] Foto guardada. Generando punto de gravedad matemático...") + + # 3. Forzamos la creación del vector en la base de datos + gestionar_vectores(actualizar=True) + print(" Registro inyectado exitosamente en el sistema principal.") + + else: + print("[!] Registro cancelado por nombre vacío.") + + cv2.destroyWindow("Captura Congelada") + else: + print("[!] No se detectó ningún rostro claro. Acércate más a la luz.") + + cap.release() + cv2.destroyAllWindows() + +if __name__ == "__main__": + registrar_desde_webcam() \ No newline at end of file diff --git a/prueba_video.py b/prueba_video.py new file mode 100644 index 0000000..d72ea2f --- /dev/null +++ b/prueba_video.py @@ -0,0 +1,43 @@ +import cv2 +import time +from ultralytics import YOLO +from seguimiento2 import GlobalMemory, CamManager, dibujar_track + +def test_video(video_path): + print(f"Iniciando Benchmark de Video: {video_path}") + model = YOLO("yolov8n.pt") + global_mem = GlobalMemory() + manager = CamManager("TEST_CAM", global_mem) + + cap = cv2.VideoCapture(video_path) + cv2.namedWindow("Benchmark TT", cv2.WINDOW_AUTOSIZE) + + while cap.isOpened(): + ret, frame = cap.read() + if not ret: break + + now = time.time() + frame_show = cv2.resize(frame, (480, 270)) + + # Inferencia frame por frame sin hilos (sincrónico) + res = model.predict(frame_show, conf=0.40, iou=0.50, classes=[0], verbose=False, imgsz=480, device='cpu') + boxes = res[0].boxes.xyxy.cpu().numpy().tolist() if res[0].boxes else [] + + tracks = manager.update(boxes, frame_show, now, turno_activo=True) + + for trk in tracks: + if trk.time_since_update == 0: + dibujar_track(frame_show, trk) + + cv2.imshow("Benchmark TT", frame_show) + + # Si presionas espacio se pausa, con 'q' sales + key = cv2.waitKey(30) & 0xFF + if key == ord('q'): break + elif key == ord(' '): cv2.waitKey(-1) + + cap.release() + cv2.destroyAllWindows() + +if __name__ == "__main__": + test_video("video.mp4") # Pon aquí el nombre de tu video \ No newline at end of file diff --git a/reconocimiento2.py b/reconocimiento2.py index df4a647..968d85c 100644 --- a/reconocimiento2.py +++ b/reconocimiento2.py @@ -22,12 +22,12 @@ warnings.filterwarnings("ignore") # ────────────────────────────────────────────────────────────────────────────── DB_PATH = "db_institucion" CACHE_PATH = "cache_nombres" -VECTORS_FILE = "representaciones.pkl" +VECTORS_FILE = "base_datos_rostros.pkl" TIMESTAMPS_FILE = "representaciones_timestamps.pkl" -UMBRAL_SIM = 0.50 # Por encima → identificado. Por debajo → desconocido. +UMBRAL_SIM = 0.39 # Por encima → identificado. Por debajo → desconocido. COOLDOWN_TIME = 15 # Segundos entre saludos -USUARIO, PASSWORD, IP_DVR = "admin", "TCA200503", "192.168.1.65" +USUARIO, PASSWORD, IP_DVR = "admin", "TCA200503", "192.168.1.244" RTSP_URL = f"rtsp://{USUARIO}:{PASSWORD}@{IP_DVR}:554/Streaming/Channels/702" for path in [DB_PATH, CACHE_PATH]: @@ -46,7 +46,6 @@ if not os.path.exists(YUNET_MODEL_PATH): print("YuNet descargado.") # Detector estricto para ROIs grandes (persona cerca) -# score_threshold alto → menos falsos positivos en fondos detector_yunet = cv2.FaceDetectorYN.create( model=YUNET_MODEL_PATH, config="", input_size=(320, 320), @@ -56,7 +55,6 @@ detector_yunet = cv2.FaceDetectorYN.create( ) # Detector permisivo para ROIs pequeños (persona lejos) -# score_threshold bajo → no perdemos caras pequeñas o a contraluz detector_yunet_lejano = cv2.FaceDetectorYN.create( model=YUNET_MODEL_PATH, config="", input_size=(320, 320), @@ -68,9 +66,6 @@ detector_yunet_lejano = cv2.FaceDetectorYN.create( def detectar_rostros_yunet(roi, lock=None): """ Elige automáticamente el detector según el tamaño del ROI. - ROI grande → detector estricto (evita falsos positivos). - ROI pequeño → detector permisivo (no pierde caras lejanas). - Devuelve lista de (x, y, w, h) o lista vacía. """ h_roi, w_roi = roi.shape[:2] area = w_roi * h_roi @@ -119,7 +114,6 @@ def obtener_audios_humanos(genero): async def sintetizar_nombre(nombre, ruta): - """Genera el audio del nombre con edge-tts (solo si no existe en caché).""" nombre_limpio = nombre.replace('_', ' ') try: comunicador = edge_tts.Communicate(nombre_limpio, "es-MX-DaliaNeural", rate="+10%") @@ -129,7 +123,6 @@ async def sintetizar_nombre(nombre, ruta): def reproducir(archivo): - """Lanza mpv en segundo plano sin bloquear.""" if os.path.exists(archivo): subprocess.Popen( ["mpv", "--no-video", "--volume=100", archivo], @@ -138,15 +131,9 @@ def reproducir(archivo): ) -# mpv encadena los 3 archivos en una sola llamada def hilo_bienvenida(nombre, genero): - """ - Reproduce intro + nombre + cierre sin time.sleep(). - mpv los reproduce en orden nativamente → el hilo queda libre de inmediato. - """ archivo_nombre = os.path.join(CACHE_PATH, f"nombre_{nombre}.mp3") - # Sintetizar solo si no estaba en caché if not os.path.exists(archivo_nombre): try: asyncio.run(sintetizar_nombre(nombre, archivo_nombre)) @@ -155,7 +142,6 @@ def hilo_bienvenida(nombre, genero): intro, cierre = obtener_audios_humanos(genero) - # Una sola llamada a mpv con los 3 archivos en secuencia archivos = [f for f in [intro, archivo_nombre, cierre] if os.path.exists(f)] if archivos: subprocess.Popen( @@ -166,21 +152,12 @@ def hilo_bienvenida(nombre, genero): # ────────────────────────────────────────────────────────────────────────────── -# GESTIÓN DE BASE DE DATOS -# Incremental: solo procesa fotos nuevas o modificadas -# Vectores normalizados al guardar → comparación 3x más rápida +# GESTIÓN DE BASE DE DATOS (AHORA CON RETINAFACE Y ALINEACIÓN) # ────────────────────────────────────────────────────────────────────────────── def gestionar_vectores(actualizar=False): - """ - Carga o actualiza el diccionario {nombre: vector_normalizado}. + import json # ⚡ Asegúrate de tener importado json - - Si actualizar=False y el .pkl existe → carga directamente (instantáneo). - - Si actualizar=True → solo reprocesa fotos nuevas o modificadas (incremental). - - Los vectores se guardan NORMALIZADOS → la comparación es un simple np.dot(). - """ vectores_actuales = {} - - # Intentar cargar lo que ya teníamos if os.path.exists(VECTORS_FILE): try: with open(VECTORS_FILE, 'rb') as f: @@ -191,7 +168,6 @@ def gestionar_vectores(actualizar=False): if not actualizar: return vectores_actuales - # ── Cargar timestamps para detectar cambios ─────────────────────────────── timestamps = {} if os.path.exists(TIMESTAMPS_FILE): try: @@ -200,10 +176,23 @@ def gestionar_vectores(actualizar=False): except Exception: timestamps = {} - print("\nACTUALIZANDO BASE DE DATOS (solo fotos nuevas o modificadas)...") + # ────────────────────────────────────────────────────────── + # CARGA DEL CACHÉ DE GÉNEROS + # ────────────────────────────────────────────────────────── + ruta_generos = os.path.join(CACHE_PATH, "generos.json") + dic_generos = {} + if os.path.exists(ruta_generos): + try: + with open(ruta_generos, 'r') as f: + dic_generos = json.load(f) + except Exception: + pass + + print("\nACTUALIZANDO BASE DE DATOS (Alineación y Caché de Géneros)...") imagenes = [f for f in os.listdir(DB_PATH) if f.lower().endswith(('.jpg', '.png'))] nombres_en_disco = set() hubo_cambios = False + cambio_generos = False # Bandera para saber si actualizamos el JSON for archivo in imagenes: nombre_archivo = os.path.splitext(archivo)[0] @@ -213,17 +202,39 @@ def gestionar_vectores(actualizar=False): ts_actual = os.path.getmtime(ruta_img) ts_guardado = timestamps.get(nombre_archivo, 0) - # Si ya la teníamos y no cambió → saltar (no llamar a ArcFace) - if nombre_archivo in vectores_actuales and ts_actual == ts_guardado: + # Si ya tenemos el vector pero NO tenemos su género en el JSON, forzamos el procesamiento + falta_genero = nombre_archivo not in dic_generos + + if nombre_archivo in vectores_actuales and ts_actual == ts_guardado and not falta_genero: continue try: + img_db = cv2.imread(ruta_img) + lab = cv2.cvtColor(img_db, cv2.COLOR_BGR2LAB) + l, a, b = cv2.split(lab) + clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8)) + l = clahe.apply(l) + img_mejorada = cv2.cvtColor(cv2.merge((l, a, b)), cv2.COLOR_LAB2BGR) + + # IA DE GÉNERO (Solo se ejecuta 1 vez por persona en toda la vida del sistema) + if falta_genero: + try: + analisis = DeepFace.analyze(img_mejorada, actions=['gender'], enforce_detection=False)[0] + dic_generos[nombre_archivo] = analisis.get('dominant_gender', 'Man') + except Exception: + dic_generos[nombre_archivo] = "Man" # Respaldo + cambio_generos = True + + # Extraemos el vector res = DeepFace.represent( - img_path=ruta_img, model_name="ArcFace", enforce_detection=True + img_path=img_mejorada, + model_name="ArcFace", + detector_backend="mtcnn", + align=True, + enforce_detection=True ) emb = np.array(res[0]["embedding"], dtype=np.float32) - # MEJORA 2: Normalizar al guardar (una sola vez para siempre) norma = np.linalg.norm(emb) if norma > 0: emb = emb / norma @@ -231,62 +242,64 @@ def gestionar_vectores(actualizar=False): vectores_actuales[nombre_archivo] = emb timestamps[nombre_archivo] = ts_actual hubo_cambios = True - print(f" Procesado: {nombre_archivo}") + print(f" Procesado y alineado: {nombre_archivo} | Género: {dic_generos.get(nombre_archivo)}") - except Exception: - print(f" Rostro no válido en '{archivo}', omitido.") + except Exception as e: + print(f" Rostro no válido en '{archivo}', omitido. Error: {e}") - # Eliminar personas cuya foto fue borrada del disco + # Limpieza de eliminados for nombre in list(vectores_actuales.keys()): if nombre not in nombres_en_disco: del vectores_actuales[nombre] timestamps.pop(nombre, None) + if nombre in dic_generos: + del dic_generos[nombre] + cambio_generos = True hubo_cambios = True - print(f" Eliminado (sin foto): {nombre}") + print(f" Eliminado (sin foto): {nombre}") + # Guardado de la memoria if hubo_cambios: with open(VECTORS_FILE, 'wb') as f: pickle.dump(vectores_actuales, f) with open(TIMESTAMPS_FILE, 'wb') as f: pickle.dump(timestamps, f) + + # Guardado del JSON de géneros si hubo descubrimientos nuevos + if cambio_generos: + with open(ruta_generos, 'w') as f: + json.dump(dic_generos, f) + + if hubo_cambios or cambio_generos: print(" Sincronización terminada.\n") else: print(" Sin cambios. Base de datos al día.\n") return vectores_actuales - +# ────────────────────────────────────────────────────────────────────────────── +# BÚSQUEDA BLINDADA (Similitud Coseno estricta) +# ────────────────────────────────────────────────────────────────────────────── def buscar_mejor_match(emb_consulta, base_datos): - """ - Compara un embedding (ya normalizado) contra la base de datos. - MEJORA 2: Solo producto punto → no hay divisiones por norma en el loop. - Devuelve (mejor_nombre, similitud_maxima). - """ - # Normalizar el embedding de consulta + # ⚡ MAGIA 3: Normalización L2 del vector entrante norma = np.linalg.norm(emb_consulta) if norma > 0: emb_consulta = emb_consulta / norma mejor_match, max_sim = None, -1.0 for nombre, vec in base_datos.items(): - # vec ya está normalizado → sim coseno = producto punto puro + # Como ambos están normalizados, esto es Similitud Coseno pura (-1.0 a 1.0) sim = float(np.dot(emb_consulta, vec)) if sim > max_sim: - max_sim, mejor_match = sim, nombre + max_sim = sim + mejor_match = nombre return mejor_match, max_sim - # ────────────────────────────────────────────────────────────────────────────── -# LOOP DE PRUEBA Y REGISTRO -# MEJORA 5 — Reemplaza face_cascade por YuNet (consistente con principal2.py) +# LOOP DE PRUEBA Y REGISTRO (CON SIMETRÍA ESTRICTA) # ────────────────────────────────────────────────────────────────────────────── def sistema_interactivo(): - """ - Ventana de depuración y registro de nuevas personas. - Muestra barra de similitud en consola y en pantalla. - Controles: [R] Registrar | [Q] Salir - """ base_datos = gestionar_vectores(actualizar=False) cap = cv2.VideoCapture(RTSP_URL) ultimo_saludo = 0 @@ -294,11 +307,11 @@ def sistema_interactivo(): confirmaciones = 0 print("\n" + "=" * 50) - print(" MÓDULO DE REGISTRO Y DEPURACIÓN") + print(" MÓDULO DE REGISTRO Y DEPURACIÓN ESTRICTO") print(" [R] Registrar nuevo rostro | [Q] Salir") print("=" * 50 + "\n") - faces_ultimo_frame = [] # Para que [R] pueda usarlas aunque no haya detección nueva + faces_ultimo_frame = [] while True: ret, frame = cap.read() @@ -311,36 +324,33 @@ def sistema_interactivo(): display_frame = frame.copy() tiempo_actual = time.time() - # ── Detección con YuNet sobre el frame completo ─────────────────────── faces_raw = detectar_rostros_yunet(frame) - faces_ultimo_frame = faces_raw # Guardamos para usar con [R] + faces_ultimo_frame = faces_raw for (fx, fy, fw, fh, score_yunet) in faces_raw: - # Clampear dentro de la imagen fx = max(0, fx); fy = max(0, fy) fw = min(w - fx, fw); fh = min(h - fy, fh) if fw <= 0 or fh <= 0: continue - # Rectángulo base (amarillo) cv2.rectangle(display_frame, (fx, fy), (fx+fw, fy+fh), (255, 200, 0), 2) cv2.putText(display_frame, f"YN:{score_yunet:.2f}", (fx, fy - 25), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (255, 200, 0), 1) if (tiempo_actual - ultimo_saludo) <= COOLDOWN_TIME: - continue # En cooldown, no procesamos + continue - # ── Recorte del rostro con margen ───────────────────────────────── m = int(fw * 0.15) roi = frame[max(0, fy-m): min(h, fy+fh+m), max(0, fx-m): min(w, fx+fw+m)] + # 🛡️ FILTRO DE TAMAÑO FÍSICO if roi.size == 0 or roi.shape[0] < 40 or roi.shape[1] < 40: - cv2.putText(display_frame, "muy pequeño", + cv2.putText(display_frame, "muy pequeno", (fx, fy-10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 100, 255), 1) continue - # ── Filtro de nitidez (descarta caras movidas antes de ArcFace) ─── + # 🛡️ FILTRO DE NITIDEZ gray_roi = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) nitidez = cv2.Laplacian(gray_roi, cv2.CV_64F).var() if nitidez < 50.0: @@ -348,19 +358,34 @@ def sistema_interactivo(): (fx, fy-10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 165, 255), 1) continue - # ── ArcFace ─────────────────────────────────────────────────────── + # 🌙 SIMETRÍA 1: VISIÓN NOCTURNA (CLAHE) AL VIDEO EN VIVO + try: + lab = cv2.cvtColor(roi, cv2.COLOR_BGR2LAB) + l, a, b = cv2.split(lab) + clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8)) + l = clahe.apply(l) + roi_mejorado = cv2.cvtColor(cv2.merge((l, a, b)), cv2.COLOR_LAB2BGR) + except Exception: + roi_mejorado = roi # Respaldo de seguridad + + # 🧠 SIMETRÍA 2: MOTOR MTCNN Y ALINEACIÓN (Igual que la Base de Datos) try: res = DeepFace.represent( - img_path=roi, model_name="ArcFace", enforce_detection=False + img_path=roi_mejorado, + model_name="ArcFace", + detector_backend="mtcnn", # El mismo que en gestionar_vectores + align=True, # Enderezamos la cara + enforce_detection=True # Si MTCNN no ve cara clara, aborta ) emb = np.array(res[0]["embedding"], dtype=np.float32) mejor_match, max_sim = buscar_mejor_match(emb, base_datos) - except Exception as e: - print(f"[ERROR ArcFace]: {e}") + except Exception: + # MTCNN abortó porque la cara estaba de perfil, tapada o no era una cara + cv2.putText(display_frame, "MTCNN Ignorado", + (fx, fy-10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1) continue - # ── Barra de similitud en consola ───────────────────────────────── estado = " IDENTIFICADO" if max_sim > UMBRAL_SIM else "DESCONOCIDO" nombre_d = mejor_match.split('_')[0] if mejor_match else "nadie" n_bloques = int(max_sim * 20) @@ -368,7 +393,6 @@ def sistema_interactivo(): print(f"[REGISTRO] {estado} | {nombre_d:<14} | {barra} | " f"{max_sim*100:.1f}% (umbral: {UMBRAL_SIM*100:.0f}%)") - # ── Resultado en pantalla ───────────────────────────────────────── if max_sim > UMBRAL_SIM and mejor_match: color = (0, 255, 0) texto = f"{mejor_match.split('_')[0]} ({max_sim:.2f})" @@ -382,7 +406,7 @@ def sistema_interactivo(): cv2.rectangle(display_frame, (fx, fy), (fx+fw, fy+fh), (0, 255, 0), 3) try: analisis = DeepFace.analyze( - roi, actions=['gender'], enforce_detection=False + roi_mejorado, actions=['gender'], enforce_detection=False )[0] genero = analisis['dominant_gender'] except Exception: @@ -404,22 +428,21 @@ def sistema_interactivo(): cv2.putText(display_frame, texto, (fx, fy - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.7, color, 2) - # ── Mostrar frame ───────────────────────────────────────────────────── cv2.imshow("Módulo de Registro", display_frame) key = cv2.waitKey(1) & 0xFF if key == ord('q'): break - #R: Registrar el rostro más grande del frame ───────────────── elif key == ord('r'): if faces_ultimo_frame: - # Elegimos la cara más grande (más cercana) areas = [fw * fh for (fx, fy, fw, fh, _) in faces_ultimo_frame] fx, fy, fw, fh, _ = faces_ultimo_frame[np.argmax(areas)] - m = 25 - face_roi = frame[max(0, fy-m): min(h, fy+fh+m), - max(0, fx-m): min(w, fx+fw+m)] + + m_x = int(fw * 0.30) + m_y = int(fh * 0.30) + face_roi = frame[max(0, fy-m_y): min(h, fy+fh+m_y), + max(0, fx-m_x): min(w, fx+fw+m_x)] if face_roi.size > 0: nom = input("\nNombre de la persona: ").strip() @@ -438,7 +461,5 @@ def sistema_interactivo(): cap.release() cv2.destroyAllWindows() - -# ────────────────────────────────────────────────────────────────────────────── if __name__ == "__main__": - sistema_interactivo() \ No newline at end of file + sistema_interactivo() diff --git a/representaciones.pkl b/representaciones.pkl deleted file mode 100644 index d2cff45..0000000 Binary files a/representaciones.pkl and /dev/null differ diff --git a/representaciones_timestamps.pkl b/representaciones_timestamps.pkl index 8cc84a1..fd8cb38 100644 Binary files a/representaciones_timestamps.pkl and b/representaciones_timestamps.pkl differ diff --git a/seguimiento2.py b/seguimiento2.py index b9ea6ec..3edbba1 100644 --- a/seguimiento2.py +++ b/seguimiento2.py @@ -11,9 +11,10 @@ import os # ────────────────────────────────────────────────────────────────────────────── # CONFIGURACIÓN DEL SISTEMA # ────────────────────────────────────────────────────────────────────────────── -USUARIO, PASSWORD, IP_DVR = "admin", "TCA200503", "192.168.1.65" +USUARIO, PASSWORD, IP_DVR = "admin", "TCA200503", "192.168.1.244" SECUENCIA = [1, 7, 5, 8, 3, 6] -os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = "rtsp_transport;tcp" +# 🛡️ RED ESTABILIZADA (Timeout de 3s para evitar congelamientos de FFmpeg) +os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = "rtsp_transport;tcp|stimeout;3000000" URLS = [f"rtsp://{USUARIO}:{PASSWORD}@{IP_DVR}:554/Streaming/Channels/{i}02" for i in SECUENCIA] ONNX_MODEL_PATH = "osnet_x0_25_msmt17.onnx" @@ -22,19 +23,16 @@ VECINOS = { "8": ["5", "3"], "3": ["8", "6"], "6": ["3"] } -# ─── PARÁMETROS TÉCNICOS ASPECT_RATIO_MIN = 0.5 ASPECT_RATIO_MAX = 4.0 AREA_MIN_CALIDAD = 1200 FRAMES_CALIDAD = 2 -TIEMPO_MIN_TRANSITO_NO_VECINO = 10.0 TIEMPO_MAX_AUSENCIA = 800.0 -# ─── UMBRALES DE RE-ID (VERSIÓN ESTABLE) -UMBRAL_REID_MISMA_CAM = 0.65 -UMBRAL_REID_VECINO = 0.55 -UMBRAL_REID_NO_VECINO = 0.72 -MAX_FIRMAS_MEMORIA = 15 +UMBRAL_REID_MISMA_CAM = 0.53 # Antes 0.65 +UMBRAL_REID_VECINO = 0.48 # Antes 0.53 +UMBRAL_REID_NO_VECINO = 0.67 # Antes 0.72 +MAX_FIRMAS_MEMORIA = 10 C_CANDIDATO = (150, 150, 150) C_LOCAL = (0, 255, 0) @@ -44,7 +42,7 @@ C_APRENDIZAJE = (255, 255, 0) FUENTE = cv2.FONT_HERSHEY_SIMPLEX # ────────────────────────────────────────────────────────────────────────────── -# INICIALIZACIÓN DEL MOTOR DEEP LEARNING (ONNX) +# INICIALIZACIÓN OSNET # ────────────────────────────────────────────────────────────────────────────── print("Cargando cerebro de Re-Identificación (OSNet)...") try: @@ -59,7 +57,7 @@ MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32).reshape(1, 3, 1, 1) STD = np.array([0.229, 0.224, 0.225], dtype=np.float32).reshape(1, 3, 1, 1) # ────────────────────────────────────────────────────────────────────────────── -# 1. MOTOR HÍBRIDO ESTABLE (Sin filtros destructivos) +# 1. EXTRACCIÓN DE FIRMAS (Deep + Color + Textura) # ────────────────────────────────────────────────────────────────────────────── def analizar_calidad(box): x1, y1, x2, y2 = box @@ -77,8 +75,7 @@ def preprocess_onnx(roi): def extraer_color_zonas(img): h_roi = img.shape[0] - t1 = int(h_roi * 0.15) - t2 = int(h_roi * 0.55) + t1, t2 = int(h_roi * 0.15), int(h_roi * 0.55) zonas = [img[:t1, :], img[t1:t2, :], img[t2:, :]] def hist_zona(z): @@ -87,19 +84,34 @@ def extraer_color_zonas(img): hist = cv2.calcHist([hsv], [0, 1], None, [16, 8], [0, 180, 0, 256]) cv2.normalize(hist, hist) return hist.flatten() - return np.concatenate([hist_zona(z) for z in zonas]) -def extraer_firma_hibrida(frame, box): +def extraer_textura_rapida(roi): + if roi.size == 0: return np.zeros(16) + gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) + gray_eq = cv2.equalizeHist(gray) + gx = cv2.Sobel(gray_eq, cv2.CV_32F, 1, 0, ksize=3) + gy = cv2.Sobel(gray_eq, cv2.CV_32F, 0, 1, ksize=3) + mag, _ = cv2.cartToPolar(gx, gy) + hist = cv2.calcHist([mag], [0], None, [16], [0, 256]) + cv2.normalize(hist, hist) + return hist.flatten() + +def extraer_firma_hibrida(frame_hd, box_480): try: - x1, y1, x2, y2 = map(int, box) - fh, fw = frame.shape[:2] - x1_c, y1_c = max(0, x1), max(0, y1) - x2_c, y2_c = min(fw, x2), min(fh, y2) - - roi = frame[y1_c:y2_c, x1_c:x2_c] + h_hd, w_hd = frame_hd.shape[:2] + escala_x = w_hd / 480.0 + escala_y = h_hd / 270.0 - if roi.size == 0 or roi.shape[0] < 20 or roi.shape[1] < 10: return None + x1, y1, x2, y2 = box_480 + x1_hd, y1_hd = int(x1 * escala_x), int(y1 * escala_y) + x2_hd, y2_hd = int(x2 * escala_x), int(y2 * escala_y) + + x1_c, y1_c = max(0, x1_hd), max(0, y1_hd) + x2_c, y2_c = min(w_hd, x2_hd), min(h_hd, y2_hd) + + roi = frame_hd[y1_c:y2_c, x1_c:x2_c] + if roi.size == 0 or roi.shape[0] < 40 or roi.shape[1] < 20: return None calidad_area = (x2_c - x1_c) * (y2_c - y1_c) @@ -111,24 +123,44 @@ def extraer_firma_hibrida(frame, box): if norma > 0: deep_feat = deep_feat / norma color_feat = extraer_color_zonas(roi) + textura_feat = extraer_textura_rapida(roi) - return {'deep': deep_feat, 'color': color_feat, 'calidad': calidad_area} - except Exception as e: + return {'deep': deep_feat, 'color': color_feat, 'textura': textura_feat, 'calidad': calidad_area} + except Exception: return None -def similitud_hibrida(f1, f2): +# ⚡ EL SECRETO: 100% IA entre cámaras. Textura solo en la misma cámara. +# ⚡ EL SECRETO: 100% IA entre cámaras. Textura solo en la misma cámara. +def similitud_hibrida(f1, f2, cross_cam=False): if f1 is None or f2 is None: return 0.0 + sim_deep = max(0.0, 1.0 - cosine(f1['deep'], f2['deep'])) + # 1. Calculamos SIEMPRE los histogramas de color por zonas if f1['color'].shape == f2['color'].shape and f1['color'].size > 1: L = len(f1['color']) // 3 sim_head = max(0.0, float(cv2.compareHist(f1['color'][:L].astype(np.float32), f2['color'][:L].astype(np.float32), cv2.HISTCMP_CORREL))) sim_torso = max(0.0, float(cv2.compareHist(f1['color'][L:2*L].astype(np.float32), f2['color'][L:2*L].astype(np.float32), cv2.HISTCMP_CORREL))) sim_legs = max(0.0, float(cv2.compareHist(f1['color'][2*L:].astype(np.float32), f2['color'][2*L:].astype(np.float32), cv2.HISTCMP_CORREL))) - sim_color = (0.10 * sim_head) + (0.60 * sim_torso) + (0.30 * sim_legs) - else: sim_color = 0.0 + else: + sim_head, sim_torso, sim_legs = 0.0, 0.0, 0.0 + + if cross_cam: + # Si OSNet se confunde por la chamarra, revisamos las piernas. + # Una similitud < 0.25 en HISTCMP_CORREL significa colores radicalmente distintos (Ej. Negro vs Gris/Blanco). + if sim_legs < 0.25: + sim_deep -= 0.15 # Castigo letal: Le tumbamos 15 puntos porcentuales a OSNet + + return max(0.0, sim_deep) - return (sim_deep * 0.90) + (sim_color * 0.10) + # 2. Si está en la misma cámara, usamos la fórmula híbrida completa + sim_color = (0.10 * sim_head) + (0.60 * sim_torso) + (0.30 * sim_legs) + + if 'textura' in f1 and 'textura' in f2 and f1['textura'].size > 1: + sim_textura = max(0.0, float(cv2.compareHist(f1['textura'].astype(np.float32), f2['textura'].astype(np.float32), cv2.HISTCMP_CORREL))) + else: sim_textura = 0.0 + + return (sim_deep * 0.80) + (sim_color * 0.10) + (sim_textura * 0.10) # ────────────────────────────────────────────────────────────────────────────── # 2. KALMAN TRACKER @@ -137,8 +169,7 @@ class KalmanTrack: _count = 0 def __init__(self, box, now): self.kf = cv2.KalmanFilter(7, 4) - self.kf.measurementMatrix = np.array([ - [1,0,0,0,0,0,0], [0,1,0,0,0,0,0], [0,0,1,0,0,0,0], [0,0,0,1,0,0,0]], np.float32) + self.kf.measurementMatrix = np.array([[1,0,0,0,0,0,0], [0,1,0,0,0,0,0], [0,0,1,0,0,0,0], [0,0,0,1,0,0,0]], np.float32) self.kf.transitionMatrix = np.eye(7, dtype=np.float32) self.kf.transitionMatrix[0,4] = 1; self.kf.transitionMatrix[1,5] = 1; self.kf.transitionMatrix[2,6] = 1 self.kf.processNoiseCov *= 0.03 @@ -150,10 +181,8 @@ class KalmanTrack: self.origen_global = False self.aprendiendo = False self.box = list(box) - self.ts_creacion = now self.ts_ultima_deteccion = now - self.time_since_update = 0 self.en_grupo = False self.frames_buena_calidad = 0 @@ -192,7 +221,7 @@ class KalmanTrack: self.frames_buena_calidad = max(0, self.frames_buena_calidad - 1) # ────────────────────────────────────────────────────────────────────────────── -# 3. MEMORIA GLOBAL (Top-3 Ponderado y Anti-Robo) +# 3. MEMORIA GLOBAL (Anti-Robos y Físicas de Tiempo) # ────────────────────────────────────────────────────────────────────────────── class GlobalMemory: def __init__(self): @@ -207,27 +236,25 @@ class GlobalMemory: if ultima_cam == cam_destino: return True vecinos = VECINOS.get(ultima_cam, []) + # Permite teletransportación mínima (-0.5s) para que no te fragmente en los pasillos conectados if cam_destino in vecinos: return dt >= -0.5 return dt >= 4.0 - def _sim_robusta(self, firma_nueva, firmas_guardadas): + def _sim_robusta(self, firma_nueva, firmas_guardadas, cross_cam=False): if not firmas_guardadas: return 0.0 - - sims = sorted( - [similitud_hibrida(firma_nueva, f) for f in firmas_guardadas], - reverse=True - ) - - if len(sims) == 1: - return sims[0] - elif len(sims) <= 4: - return (sims[0] * 0.6) + (sims[1] * 0.4) - else: - return (sims[0] * 0.50) + (sims[1] * 0.30) + (sims[2] * 0.20) + sims = sorted([similitud_hibrida(firma_nueva, f, cross_cam) for f in firmas_guardadas], reverse=True) + if len(sims) == 1: return sims[0] + elif len(sims) <= 4: return (sims[0] * 0.6) + (sims[1] * 0.4) + else: return (sims[0] * 0.50) + (sims[1] * 0.30) + (sims[2] * 0.20) - def identificar_candidato(self, firma_hibrida, cam_id, now, active_gids): + # ⚡ SE AGREGÓ 'en_borde' A LOS PARÁMETROS + def identificar_candidato(self, firma_hibrida, cam_id, now, active_gids, en_borde=True): + # 1. LIMPIEZA DE MEMORIA (El Recolector de Basura) + # Esto asume que ya agregaste la función limpiar_fantasmas(self) a tu clase + self.limpiar_fantasmas() + with self.lock: - best_gid, best_score = None, -1.0 + candidatos = [] vecinos = VECINOS.get(str(cam_id), []) for gid, data in self.db.items(): @@ -236,81 +263,138 @@ class GlobalMemory: if dt > TIEMPO_MAX_AUSENCIA or not self._es_transito_posible(data, cam_id, now): continue if not data['firmas']: continue - sim = self._sim_robusta(firma_hibrida, data['firmas']) - - misma_cam = str(data['last_cam']) == str(cam_id) + misma_cam = (str(data['last_cam']) == str(cam_id)) + es_cross_cam = not misma_cam es_vecino = str(data['last_cam']) in vecinos + # ⚡ FÍSICA DE PUERTAS + if es_vecino and not en_borde: + es_vecino = False + + sim = self._sim_robusta(firma_hibrida, data['firmas'], cross_cam=es_cross_cam) + if misma_cam: umbral = UMBRAL_REID_MISMA_CAM elif es_vecino: umbral = UMBRAL_REID_VECINO else: umbral = UMBRAL_REID_NO_VECINO - if sim > best_score and sim > umbral: - best_score = sim - best_gid = gid + # PROTECCIÓN VIP + if data.get('nombre') is not None: + if misma_cam: + umbral += 0.04 + else: + umbral += 0.03 - if best_gid is not None: - self._actualizar_sin_lock(best_gid, firma_hibrida, cam_id, now) - return best_gid, True - else: + if sim > umbral: + # ⚡ MODIFICACIÓN: Guardamos también si es de la misma cam o vecino para el desempate final + candidatos.append((sim, gid, misma_cam, es_vecino)) + + if not candidatos: nid = self.next_gid; self.next_gid += 1 self._actualizar_sin_lock(nid, firma_hibrida, cam_id, now) return nid, False + # Ordena por la similitud (el primer elemento de la tupla) de mayor a menor + candidatos.sort(reverse=True) + best_sim, best_gid, best_misma, best_vecino = candidatos[0] + + if len(candidatos) >= 2: + segunda_sim, segundo_gid, seg_misma, seg_vecino = candidatos[1] + margen = best_sim - segunda_sim + + # Si las chamarras son casi idénticas... + if margen <= 0.02 and best_sim < 0.75: + + # ⚖️ DESEMPATE GEOGRÁFICO INTELLIGENTE + # Le damos un "peso" matemático extra a la lógica espacial + peso_1 = best_sim + (0.10 if (best_misma or best_vecino) else 0.0) + peso_2 = segunda_sim + (0.10 if (seg_misma or seg_vecino) else 0.0) + + if peso_1 > peso_2: + best_gid = best_gid # Gana el primero por lógica espacial + elif peso_2 > peso_1: + best_gid = segundo_gid # Gana el segundo por lógica espacial + else: + # Si empatan en TODO (ropa y espacio), entonces sí entramos en pánico seguro + print(f"\n[ ALERTA] Empate extremo entre ID {best_gid} ({best_sim:.2f}) y ID {segundo_gid} ({segunda_sim:.2f}). Se asigna temporal.") + nid = self.next_gid; self.next_gid += 1 + self._actualizar_sin_lock(nid, firma_hibrida, cam_id, now) + return nid, False + + self._actualizar_sin_lock(best_gid, firma_hibrida, cam_id, now) + return best_gid, True + def _actualizar_sin_lock(self, gid, firma_dict, cam_id, now): if gid not in self.db: self.db[gid] = {'firmas': [], 'last_cam': cam_id, 'ts': now} - if firma_dict is not None: firmas_list = self.db[gid]['firmas'] - if not firmas_list: firmas_list.append(firma_dict) else: if firma_dict['calidad'] > (firmas_list[0]['calidad'] * 1.50): - vieja_ancla = firmas_list[0] - firmas_list[0] = firma_dict - firma_dict = vieja_ancla - + vieja_ancla = firmas_list[0]; firmas_list[0] = firma_dict; firma_dict = vieja_ancla if len(firmas_list) >= MAX_FIRMAS_MEMORIA: - max_sim_interna = -1.0 - idx_redundante = 1 - + max_sim_interna = -1.0; idx_redundante = 1 for i in range(1, len(firmas_list)): - sims_con_otras = [ - similitud_hibrida(firmas_list[i], firmas_list[j]) - for j in range(1, len(firmas_list)) if j != i - ] + sims_con_otras = [similitud_hibrida(firmas_list[i], firmas_list[j]) for j in range(1, len(firmas_list)) if j != i] sim_promedio = np.mean(sims_con_otras) if sims_con_otras else 0.0 - - if sim_promedio > max_sim_interna: - max_sim_interna = sim_promedio - idx_redundante = i - + if sim_promedio > max_sim_interna: max_sim_interna = sim_promedio; idx_redundante = i firmas_list[idx_redundante] = firma_dict else: firmas_list.append(firma_dict) - self.db[gid]['last_cam'] = cam_id self.db[gid]['ts'] = now def actualizar(self, gid, firma, cam_id, now): with self.lock: self._actualizar_sin_lock(gid, firma, cam_id, now) + def limpiar_fantasmas(self): + """ Aniquila los IDs temporales que llevan más de 15 segundos sin verse """ + with self.lock: + ahora = time.time() + ids_a_borrar = [] + + for gid, data in self.db.items(): + tiempo_inactivo = ahora - data.get('ts', ahora) + + # Si es un ID anónimo y lleva 15 segundos desaparecido, es basura + if data.get('nombre') is None and tiempo_inactivo > 600.0: + ids_a_borrar.append(gid) + + # Si es un VIP (con nombre), le damos 5 minutos de memoria antes de borrarlo + elif data.get('nombre') is not None and tiempo_inactivo > 900.0: + ids_a_borrar.append(gid) + + for gid in ids_a_borrar: + del self.db[gid] + + def confirmar_firma_vip(self, gid, ts): + """ + ArcFace confirma la identidad con alta certeza (>0.55). + Aniquila las firmas viejas y deja solo la más reciente como la firma VIP definitiva. + """ + with self.lock: + if gid in self.db and self.db[gid]['firmas']: + # Tomamos la última firma que el hilo principal guardó (la ropa actual) + firma_actual_pura = self.db[gid]['firmas'][-1] + + # Sobrescribimos el historial dejando solo esta firma confirmada + self.db[gid]['firmas'] = [firma_actual_pura] + self.db[gid]['ts'] = ts + # ────────────────────────────────────────────────────────────────────────────── -# 4. GESTOR LOCAL +# 4. GESTOR LOCAL (Kalman Elasticity & Ghost Killer) # ────────────────────────────────────────────────────────────────────────────── def iou_overlap(boxA, boxB): xA, yA, xB, yB = max(boxA[0], boxB[0]), max(boxA[1], boxB[1]), min(boxA[2], boxB[2]), min(boxA[3], boxB[3]) inter = max(0, xB-xA) * max(0, yB-yA) - areaA = (boxA[2]-boxA[0]) * (boxA[3]-boxA[1]) - areaB = (boxB[2]-boxB[0]) * (boxB[3]-boxB[1]) + areaA = (boxA[2]-boxA[0]) * (boxA[3]-boxA[1]); areaB = (boxB[2]-boxB[0]) * (boxB[3]-boxB[1]) return inter / (areaA + areaB - inter + 1e-6) class CamManager: def __init__(self, cam_id, global_mem): self.cam_id, self.global_mem, self.trackers = cam_id, global_mem, [] - def update(self, boxes, frame, now, turno_activo): + def update(self, boxes, frame_show, frame_hd, now, turno_activo): for trk in self.trackers: trk.predict(turno_activo=turno_activo) if not turno_activo: return self.trackers @@ -318,45 +402,56 @@ class CamManager: for t_idx, d_idx in matched: trk = self.trackers[t_idx]; box = boxes[d_idx] - en_grupo = any(other is not trk and iou_overlap(box, other.box) > 0.10 for other in self.trackers) trk.update(box, en_grupo, now) active_gids = {t.gid for t in self.trackers if t.gid is not None} area_actual = (box[2] - box[0]) * (box[3] - box[1]) - if trk.gid is None and trk.listo_para_id: - firma = extraer_firma_hibrida(frame, box) + # IGNORAMOS VECTORES MUTANTES DE GRUPOS + if trk.gid is None and trk.listo_para_id and not trk.en_grupo: + # ⚡ Usamos el frame_hd + firma = extraer_firma_hibrida(frame_hd, box) if firma is not None: - gid, es_reid = self.global_mem.identificar_candidato(firma, self.cam_id, now, active_gids) + # ⚡ DETECCIÓN DE ZONA DE NACIMIENTO + fh, fw = frame_hd.shape[:2] + bx1, by1, bx2, by2 = map(int, box) + # Si nace a menos de 40 píxeles del margen, entró por el pasillo + nace_en_borde = (bx1 < 25 or by1 < 25 or bx2 > fw - 25 or by2 > fh - 25) + + # Mandamos esa información al identificador + gid, es_reid = self.global_mem.identificar_candidato(firma, self.cam_id, now, active_gids, en_borde=nace_en_borde) trk.gid, trk.origen_global, trk.area_referencia = gid, es_reid, area_actual elif trk.gid is not None and not trk.en_grupo: tiempo_ultima_firma = getattr(trk, 'ultimo_aprendizaje', 0) - if (now - tiempo_ultima_firma) > 1.5 and analizar_calidad(box): - fh, fw = frame.shape[:2] + # ⚡ APRENDIZAJE RÁPIDO: Bajamos de 1.5s a 0.5s para que llene la memoria volando + if (now - tiempo_ultima_firma) > 0.5 and analizar_calidad(box): + fh, fw = frame_hd.shape[:2] x1, y1, x2, y2 = map(int, box) en_borde = (x1 < 15 or y1 < 15 or x2 > fw - 15 or y2 > fh - 15) if not en_borde: - firma_nueva = extraer_firma_hibrida(frame, box) + firma_nueva = extraer_firma_hibrida(frame_hd, box) if firma_nueva is not None: with self.global_mem.lock: if trk.gid in self.global_mem.db and self.global_mem.db[trk.gid]['firmas']: - firma_ancla = self.global_mem.db[trk.gid]['firmas'][0] - sim_coherencia = similitud_hibrida(firma_nueva, firma_ancla) - if sim_coherencia > 0.55: + # ⚡ APRENDIZAJE EN CADENA: Comparamos contra la ÚLTIMA foto (-1), no contra la primera. + # Esto permite que el sistema "entienda" cuando te estás dando la vuelta o mostrando la mochila. + firma_reciente = self.global_mem.db[trk.gid]['firmas'][-1] + sim_coherencia = similitud_hibrida(firma_nueva, firma_reciente) + + # Tolerancia relajada a 0.50 para permitir la transición de la espalda + if sim_coherencia > 0.50: es_coherente = True - for otro_gid, otro_data in self.global_mem.db.items(): if otro_gid == trk.gid or not otro_data['firmas']: continue sim_intruso = similitud_hibrida(firma_nueva, otro_data['firmas'][0]) if sim_intruso > sim_coherencia: es_coherente = False break - if es_coherente: self.global_mem._actualizar_sin_lock(trk.gid, firma_nueva, self.cam_id, now) trk.ultimo_aprendizaje = now @@ -365,16 +460,21 @@ class CamManager: for d_idx in unmatched_dets: self.trackers.append(KalmanTrack(boxes[d_idx], now)) vivos = [] - fh, fw = frame.shape[:2] + fh, fw = frame_show.shape[:2] # Usamos frame_show para evaluar los bordes de la caja 480 for t in self.trackers: x1, y1, x2, y2 = t.box - toca_borde = (x1 < 5 or y1 < 5 or x2 > fw - 5 or y2 > fh - 5) + toca_borde = (x1 < 20 or y1 < 20 or x2 > fw - 20 or y2 > fh - 20) tiempo_oculto = now - t.ts_ultima_deteccion - if toca_borde and tiempo_oculto > 1.0: - continue + # ⚡ MUERTE JUSTA: Si es anónimo en el borde, muere rápido. + # Si ya tiene un ID asignado, le damos al menos 3 segundos de gracia. + if t.gid is None and toca_borde: + limite_vida = 1.0 + elif t.gid is not None and toca_borde: + limite_vida = 3.0 + else: + limite_vida = 10.0 - limite_vida = 5.0 if t.gid else 1.0 if tiempo_oculto < limite_vida: vivos.append(t) @@ -382,47 +482,40 @@ class CamManager: return self.trackers def _asignar(self, boxes, now): - n_trk = len(self.trackers) - n_det = len(boxes) - + n_trk = len(self.trackers); n_det = len(boxes) if n_trk == 0: return [], list(range(n_det)), [] if n_det == 0: return [], [], list(range(n_trk)) cost_mat = np.zeros((n_trk, n_det), dtype=np.float32) + TIEMPO_TURNO_ROTATIVO = len(SECUENCIA) * 0.035 for t, trk in enumerate(self.trackers): for d, det in enumerate(boxes): iou = iou_overlap(trk.box, det) cx_t, cy_t = (trk.box[0]+trk.box[2])/2, (trk.box[1]+trk.box[3])/2 cx_d, cy_d = (det[0]+det[2])/2, (det[1]+det[3])/2 - dist_norm = np.sqrt((cx_t-cx_d)**2 + (cy_t-cy_d)**2) / 550.0 area_trk = (trk.box[2] - trk.box[0]) * (trk.box[3] - trk.box[1]) area_det = (det[2] - det[0]) * (det[3] - det[1]) ratio_area = max(area_trk, area_det) / (min(area_trk, area_det) + 1e-6) - - castigo_tamano = (ratio_area - 1.0) * 0.4 + castigo_tam = (ratio_area - 1.0) * 0.7 tiempo_oculto = now - trk.ts_ultima_deteccion - - TIEMPO_TURNO_ROTATIVO = len(SECUENCIA) * 0.035 - if tiempo_oculto > (TIEMPO_TURNO_ROTATIVO * 2) and iou < 0.10: fantasma_penalty = 5.0 - else: - fantasma_penalty = 0.0 + else: fantasma_penalty = 0.0 - if iou >= 0.05 or dist_norm < 0.50: - cost_mat[t, d] = (1.0 - iou) + (dist_norm * 2.0) + fantasma_penalty + castigo_tamano - else: - cost_mat[t, d] = 100.0 + if iou >= 0.05 or dist_norm < 0.80: + cost_mat[t, d] = (1.0 - iou) + (dist_norm * 2.0) + fantasma_penalty + castigo_tam + else: cost_mat[t, d] = 100.0 row_ind, col_ind = linear_sum_assignment(cost_mat) matched, unmatched_dets, unmatched_trks = [], [], [] for r, c in zip(row_ind, col_ind): - if cost_mat[r, c] > 4.0: + # ⚡ CAJAS PEGAJOSAS: 6.0 evita que suelte el ID si te mueves rápido + if cost_mat[r, c] > 7.0: unmatched_trks.append(r); unmatched_dets.append(c) else: matched.append((r, c)) @@ -446,11 +539,9 @@ class CamStream: while True: ret, f = self.cap.read() if ret: - self.frame = f - time.sleep(0.01) + self.frame = f; time.sleep(0.01) else: - time.sleep(2) - self.cap.open(self.url) + time.sleep(2); self.cap.open(self.url) def dibujar_track(frame_show, trk): try: x1, y1, x2, y2 = map(int, trk.box) @@ -466,14 +557,9 @@ def dibujar_track(frame_show, trk): (tw, th), _ = cv2.getTextSize(label, FUENTE, 0.55, 1) cv2.rectangle(frame_show, (x1, y1-th-6), (x1+tw+2, y1), color, -1) cv2.putText(frame_show, label, (x1+1, y1-4), FUENTE, 0.55, (0,0,0), 1) - - if trk.gid is None: - pct = min(trk.frames_buena_calidad / FRAMES_CALIDAD, 1.0) - bw = x2 - x1; cv2.rectangle(frame_show, (x1, y2+2), (x2, y2+7), (50,50,50), -1) - cv2.rectangle(frame_show, (x1, y2+2), (x1+int(bw*pct), y2+7), (0,220,220), -1) def main(): - print("Iniciando Sistema V-PRO — Tracker Resiliente (Rollback Estable)") + print("Iniciando Sistema V-PRO — Tracker Resiliente (Código Unificado Maestro)") model = YOLO("yolov8n.pt") global_mem = GlobalMemory() managers = {str(c): CamManager(c, global_mem) for c in SECUENCIA} @@ -490,9 +576,9 @@ def main(): if frame is None: tiles.append(np.zeros((270, 480, 3), np.uint8)); continue frame_show = cv2.resize(frame.copy(), (480, 270)); boxes = []; turno_activo = (i == cam_ia) if turno_activo: - res = model.predict(frame_show, conf=0.50, iou=0.40, classes=[0], verbose=False, imgsz=480) + res = model.predict(frame_show, conf=0.50, iou=0.40, classes=[0], verbose=False, imgsz=480, device='cpu') if res[0].boxes: boxes = res[0].boxes.xyxy.cpu().numpy().tolist() - tracks = managers[cid].update(boxes, frame_show, now, turno_activo) + tracks = managers[cid].update(boxes, frame_show, frame, now, turno_activo) for trk in tracks: if trk.time_since_update <= 1: dibujar_track(frame_show, trk) if turno_activo: cv2.circle(frame_show, (460, 20), 6, (0, 0, 255), -1) diff --git a/vesiones_seguras.txt b/vesiones_seguras.txt new file mode 100644 index 0000000..0653fd8 --- /dev/null +++ b/vesiones_seguras.txt @@ -0,0 +1,1258 @@ +############################################################ seguimiento2.py +import cv2 +import numpy as np +import time +import threading +from scipy.optimize import linear_sum_assignment +from scipy.spatial.distance import cosine +from ultralytics import YOLO +import onnxruntime as ort +import os + +# ────────────────────────────────────────────────────────────────────────────── +# CONFIGURACIÓN DEL SISTEMA +# ────────────────────────────────────────────────────────────────────────────── +USUARIO, PASSWORD, IP_DVR = "admin", "TCA200503", "192.168.1.65" +SECUENCIA = [1, 7, 5, 8, 3, 6] +# 🛡️ RED ESTABILIZADA (Timeout de 3s para evitar congelamientos de FFmpeg) +os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = "rtsp_transport;tcp|stimeout;3000000" +URLS = [f"rtsp://{USUARIO}:{PASSWORD}@{IP_DVR}:554/Streaming/Channels/{i}02" for i in SECUENCIA] +ONNX_MODEL_PATH = "osnet_x0_25_msmt17.onnx" + +VECINOS = { + "1": ["7"], "7": ["1", "5"], "5": ["7", "8"], + "8": ["5", "3"], "3": ["8", "6"], "6": ["3"] +} + +ASPECT_RATIO_MIN = 0.5 +ASPECT_RATIO_MAX = 4.0 +AREA_MIN_CALIDAD = 1200 +FRAMES_CALIDAD = 2 +TIEMPO_MAX_AUSENCIA = 800.0 + +# ⚡ UMBRALES MAESTROS: Tolerancia altísima entre cámaras vecinas para ignorar cambios de luz +UMBRAL_REID_MISMA_CAM = 0.62 +UMBRAL_REID_VECINO = 0.53 +UMBRAL_REID_NO_VECINO = 0.72 +MAX_FIRMAS_MEMORIA = 15 + +C_CANDIDATO = (150, 150, 150) +C_LOCAL = (0, 255, 0) +C_GLOBAL = (0, 165, 255) +C_GRUPO = (0, 0, 255) +C_APRENDIZAJE = (255, 255, 0) +FUENTE = cv2.FONT_HERSHEY_SIMPLEX + +# ────────────────────────────────────────────────────────────────────────────── +# INICIALIZACIÓN OSNET +# ────────────────────────────────────────────────────────────────────────────── +print("Cargando cerebro de Re-Identificación (OSNet)...") +try: + ort_session = ort.InferenceSession(ONNX_MODEL_PATH, providers=['CPUExecutionProvider']) + input_name = ort_session.get_inputs()[0].name + print("Modelo OSNet cargado exitosamente.") +except Exception as e: + print(f"ERROR FATAL: No se pudo cargar {ONNX_MODEL_PATH}.") + exit() + +MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32).reshape(1, 3, 1, 1) +STD = np.array([0.229, 0.224, 0.225], dtype=np.float32).reshape(1, 3, 1, 1) + +# ────────────────────────────────────────────────────────────────────────────── +# 1. EXTRACCIÓN DE FIRMAS (Deep + Color + Textura) +# ────────────────────────────────────────────────────────────────────────────── +def analizar_calidad(box): + x1, y1, x2, y2 = box + w, h = x2 - x1, y2 - y1 + if w <= 0 or h <= 0: return False + return (ASPECT_RATIO_MIN < (h / w) < ASPECT_RATIO_MAX) and ((w * h) > AREA_MIN_CALIDAD) + +def preprocess_onnx(roi): + img = cv2.resize(roi, (128, 256)) + img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + img = img.transpose(2, 0, 1).astype(np.float32) / 255.0 + img = np.expand_dims(img, axis=0) + img = (img - MEAN) / STD + return img + +def extraer_color_zonas(img): + h_roi = img.shape[0] + t1, t2 = int(h_roi * 0.15), int(h_roi * 0.55) + zonas = [img[:t1, :], img[t1:t2, :], img[t2:, :]] + + def hist_zona(z): + if z.size == 0: return np.zeros(16 * 8) + hsv = cv2.cvtColor(z, cv2.COLOR_BGR2HSV) + hist = cv2.calcHist([hsv], [0, 1], None, [16, 8], [0, 180, 0, 256]) + cv2.normalize(hist, hist) + return hist.flatten() + return np.concatenate([hist_zona(z) for z in zonas]) + +def extraer_textura_rapida(roi): + if roi.size == 0: return np.zeros(16) + gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) + gray_eq = cv2.equalizeHist(gray) + gx = cv2.Sobel(gray_eq, cv2.CV_32F, 1, 0, ksize=3) + gy = cv2.Sobel(gray_eq, cv2.CV_32F, 0, 1, ksize=3) + mag, _ = cv2.cartToPolar(gx, gy) + hist = cv2.calcHist([mag], [0], None, [16], [0, 256]) + cv2.normalize(hist, hist) + return hist.flatten() + +def extraer_firma_hibrida(frame, box): + try: + x1, y1, x2, y2 = map(int, box) + fh, fw = frame.shape[:2] + x1_c, y1_c = max(0, x1), max(0, y1) + x2_c, y2_c = min(fw, x2), min(fh, y2) + + roi = frame[y1_c:y2_c, x1_c:x2_c] + if roi.size == 0 or roi.shape[0] < 20 or roi.shape[1] < 10: return None + + calidad_area = (x2_c - x1_c) * (y2_c - y1_c) + + blob = preprocess_onnx(roi) + blob_16 = np.zeros((16, 3, 256, 128), dtype=np.float32) + blob_16[0] = blob[0] + deep_feat = ort_session.run(None, {input_name: blob_16})[0][0].flatten() + norma = np.linalg.norm(deep_feat) + if norma > 0: deep_feat = deep_feat / norma + + color_feat = extraer_color_zonas(roi) + textura_feat = extraer_textura_rapida(roi) + + return {'deep': deep_feat, 'color': color_feat, 'textura': textura_feat, 'calidad': calidad_area} + except Exception: + return None + +# ⚡ EL SECRETO: 100% IA entre cámaras. Textura solo en la misma cámara. +def similitud_hibrida(f1, f2, cross_cam=False): + if f1 is None or f2 is None: return 0.0 + + sim_deep = max(0.0, 1.0 - cosine(f1['deep'], f2['deep'])) + + if cross_cam: + # Si saltó de cámara, la luz cambia. Ignoramos color y textura. Confiamos 100% en OSNet. + return sim_deep + + # Si está en la misma cámara, usamos color y textura para separar a los vestidos de negro. + if f1['color'].shape == f2['color'].shape and f1['color'].size > 1: + L = len(f1['color']) // 3 + sim_head = max(0.0, float(cv2.compareHist(f1['color'][:L].astype(np.float32), f2['color'][:L].astype(np.float32), cv2.HISTCMP_CORREL))) + sim_torso = max(0.0, float(cv2.compareHist(f1['color'][L:2*L].astype(np.float32), f2['color'][L:2*L].astype(np.float32), cv2.HISTCMP_CORREL))) + sim_legs = max(0.0, float(cv2.compareHist(f1['color'][2*L:].astype(np.float32), f2['color'][2*L:].astype(np.float32), cv2.HISTCMP_CORREL))) + sim_color = (0.10 * sim_head) + (0.60 * sim_torso) + (0.30 * sim_legs) + else: sim_color = 0.0 + + if 'textura' in f1 and 'textura' in f2 and f1['textura'].size > 1: + sim_textura = max(0.0, float(cv2.compareHist(f1['textura'].astype(np.float32), f2['textura'].astype(np.float32), cv2.HISTCMP_CORREL))) + else: sim_textura = 0.0 + + return (sim_deep * 0.80) + (sim_color * 0.10) + (sim_textura * 0.10) + +# ────────────────────────────────────────────────────────────────────────────── +# 2. KALMAN TRACKER +# ────────────────────────────────────────────────────────────────────────────── +class KalmanTrack: + _count = 0 + def __init__(self, box, now): + self.kf = cv2.KalmanFilter(7, 4) + self.kf.measurementMatrix = np.array([[1,0,0,0,0,0,0], [0,1,0,0,0,0,0], [0,0,1,0,0,0,0], [0,0,0,1,0,0,0]], np.float32) + self.kf.transitionMatrix = np.eye(7, dtype=np.float32) + self.kf.transitionMatrix[0,4] = 1; self.kf.transitionMatrix[1,5] = 1; self.kf.transitionMatrix[2,6] = 1 + self.kf.processNoiseCov *= 0.03 + self.kf.statePost = np.zeros((7, 1), np.float32) + self.kf.statePost[:4] = self._convert_bbox_to_z(box) + self.local_id = KalmanTrack._count + KalmanTrack._count += 1 + self.gid = None + self.origen_global = False + self.aprendiendo = False + self.box = list(box) + self.ts_creacion = now + self.ts_ultima_deteccion = now + self.time_since_update = 0 + self.en_grupo = False + self.frames_buena_calidad = 0 + self.listo_para_id = False + self.area_referencia = 0.0 + + def _convert_bbox_to_z(self, bbox): + w = bbox[2] - bbox[0]; h = bbox[3] - bbox[1]; x = bbox[0] + w/2.; y = bbox[1] + h/2. + return np.array([[x],[y],[w*h],[w/float(h+1e-6)]]).astype(np.float32) + + def _convert_x_to_bbox(self, x): + cx, cy, s, r = float(x[0].item()), float(x[1].item()), float(x[2].item()), float(x[3].item()) + w = np.sqrt(s * r); h = s / (w + 1e-6) + return [cx-w/2., cy-h/2., cx+w/2., cy+h/2.] + + def predict(self, turno_activo=True): + if (self.kf.statePost[6] + self.kf.statePost[2]) <= 0: self.kf.statePost[6] *= 0.0 + self.kf.predict() + if turno_activo: self.time_since_update += 1 + self.aprendiendo = False + self.box = self._convert_x_to_bbox(self.kf.statePre) + return self.box + + def update(self, box, en_grupo, now): + self.ts_ultima_deteccion = now + self.time_since_update = 0 + self.box = list(box) + self.en_grupo = en_grupo + self.kf.correct(self._convert_bbox_to_z(box)) + + if analizar_calidad(box) and not en_grupo: + self.frames_buena_calidad += 1 + if self.frames_buena_calidad >= FRAMES_CALIDAD: + self.listo_para_id = True + elif self.gid is None: + self.frames_buena_calidad = max(0, self.frames_buena_calidad - 1) + +# ────────────────────────────────────────────────────────────────────────────── +# 3. MEMORIA GLOBAL (Anti-Robos y Físicas de Tiempo) +# ────────────────────────────────────────────────────────────────────────────── +class GlobalMemory: + def __init__(self): + self.db = {} + self.next_gid = 100 + self.lock = threading.Lock() + + def _es_transito_posible(self, data, cam_destino, now): + ultima_cam = str(data['last_cam']) + cam_destino = str(cam_destino) + dt = now - data['ts'] + + if ultima_cam == cam_destino: return True + vecinos = VECINOS.get(ultima_cam, []) + # Permite teletransportación mínima (-0.5s) para que no te fragmente en los pasillos conectados + if cam_destino in vecinos: return dt >= -0.5 + return dt >= 4.0 + + def _sim_robusta(self, firma_nueva, firmas_guardadas, cross_cam=False): + if not firmas_guardadas: return 0.0 + sims = sorted([similitud_hibrida(firma_nueva, f, cross_cam) for f in firmas_guardadas], reverse=True) + if len(sims) == 1: return sims[0] + elif len(sims) <= 4: return (sims[0] * 0.6) + (sims[1] * 0.4) + else: return (sims[0] * 0.50) + (sims[1] * 0.30) + (sims[2] * 0.20) + + # ⚡ SE AGREGÓ 'en_borde' A LOS PARÁMETROS + def identificar_candidato(self, firma_hibrida, cam_id, now, active_gids, en_borde=True): + with self.lock: + candidatos = [] + vecinos = VECINOS.get(str(cam_id), []) + + for gid, data in self.db.items(): + if gid in active_gids: continue + dt = now - data['ts'] + if dt > TIEMPO_MAX_AUSENCIA or not self._es_transito_posible(data, cam_id, now): continue + if not data['firmas']: continue + + misma_cam = (str(data['last_cam']) == str(cam_id)) + es_cross_cam = not misma_cam + es_vecino = str(data['last_cam']) in vecinos + + # ⚡ FÍSICA DE PUERTAS: Si "nació" en el centro de la pantalla, NO viene caminando del pasillo adyacente. + if es_vecino and not en_borde: + es_vecino = False + + sim = self._sim_robusta(firma_hibrida, data['firmas'], cross_cam=es_cross_cam) + + if misma_cam: umbral = UMBRAL_REID_MISMA_CAM + elif es_vecino: umbral = UMBRAL_REID_VECINO + else: umbral = UMBRAL_REID_NO_VECINO + + # 🛡️ PROTECCIÓN VIP: Si este ID ya tiene un nombre real asignado por ArcFace, + # nos volvemos súper estrictos (+0.08) para que un desconocido no se lo robe. + if data.get('nombre') is not None: + umbral += 0.08 + + if sim > umbral: + candidatos.append((sim, gid)) + + if not candidatos: + nid = self.next_gid; self.next_gid += 1 + self._actualizar_sin_lock(nid, firma_hibrida, cam_id, now) + return nid, False + + candidatos.sort(reverse=True) + best_sim, best_gid = candidatos[0] + + if len(candidatos) >= 2: + segunda_sim, segundo_gid = candidatos[1] + margen = best_sim - segunda_sim + if margen <= 0.02 and best_sim < 0.75: + print(f"\n[⚠️ ALERTA ROPA SIMILAR] Empate técnico entre ID {best_gid} ({best_sim:.2f}) y ID {segundo_gid} ({segunda_sim:.2f}). Se asigna ID temporal nuevo.") + nid = self.next_gid; self.next_gid += 1 + self._actualizar_sin_lock(nid, firma_hibrida, cam_id, now) + return nid, False + + self._actualizar_sin_lock(best_gid, firma_hibrida, cam_id, now) + return best_gid, True + + def _actualizar_sin_lock(self, gid, firma_dict, cam_id, now): + if gid not in self.db: self.db[gid] = {'firmas': [], 'last_cam': cam_id, 'ts': now} + if firma_dict is not None: + firmas_list = self.db[gid]['firmas'] + if not firmas_list: + firmas_list.append(firma_dict) + else: + if firma_dict['calidad'] > (firmas_list[0]['calidad'] * 1.50): + vieja_ancla = firmas_list[0]; firmas_list[0] = firma_dict; firma_dict = vieja_ancla + if len(firmas_list) >= MAX_FIRMAS_MEMORIA: + max_sim_interna = -1.0; idx_redundante = 1 + for i in range(1, len(firmas_list)): + sims_con_otras = [similitud_hibrida(firmas_list[i], firmas_list[j]) for j in range(1, len(firmas_list)) if j != i] + sim_promedio = np.mean(sims_con_otras) if sims_con_otras else 0.0 + if sim_promedio > max_sim_interna: max_sim_interna = sim_promedio; idx_redundante = i + firmas_list[idx_redundante] = firma_dict + else: + firmas_list.append(firma_dict) + self.db[gid]['last_cam'] = cam_id + self.db[gid]['ts'] = now + + def actualizar(self, gid, firma, cam_id, now): + with self.lock: self._actualizar_sin_lock(gid, firma, cam_id, now) + +# ────────────────────────────────────────────────────────────────────────────── +# 4. GESTOR LOCAL (Kalman Elasticity & Ghost Killer) +# ────────────────────────────────────────────────────────────────────────────── +def iou_overlap(boxA, boxB): + xA, yA, xB, yB = max(boxA[0], boxB[0]), max(boxA[1], boxB[1]), min(boxA[2], boxB[2]), min(boxA[3], boxB[3]) + inter = max(0, xB-xA) * max(0, yB-yA) + areaA = (boxA[2]-boxA[0]) * (boxA[3]-boxA[1]); areaB = (boxB[2]-boxB[0]) * (boxB[3]-boxB[1]) + return inter / (areaA + areaB - inter + 1e-6) + +class CamManager: + def __init__(self, cam_id, global_mem): + self.cam_id, self.global_mem, self.trackers = cam_id, global_mem, [] + + def update(self, boxes, frame, now, turno_activo): + for trk in self.trackers: trk.predict(turno_activo=turno_activo) + if not turno_activo: return self.trackers + + matched, unmatched_dets, unmatched_trks = self._asignar(boxes, now) + + for t_idx, d_idx in matched: + trk = self.trackers[t_idx]; box = boxes[d_idx] + en_grupo = any(other is not trk and iou_overlap(box, other.box) > 0.10 for other in self.trackers) + trk.update(box, en_grupo, now) + + active_gids = {t.gid for t in self.trackers if t.gid is not None} + area_actual = (box[2] - box[0]) * (box[3] - box[1]) + + # IGNORAMOS VECTORES MUTANTES DE GRUPOS + if trk.gid is None and trk.listo_para_id and not trk.en_grupo: + firma = extraer_firma_hibrida(frame, box) + if firma is not None: + # ⚡ DETECCIÓN DE ZONA DE NACIMIENTO + fh, fw = frame.shape[:2] + bx1, by1, bx2, by2 = map(int, box) + # Si nace a menos de 40 píxeles del margen, entró por el pasillo + nace_en_borde = (bx1 < 80 or by1 < 80 or bx2 > fw - 80 or by2 > fh - 80) + + # Mandamos esa información al identificador + gid, es_reid = self.global_mem.identificar_candidato(firma, self.cam_id, now, active_gids, en_borde=nace_en_borde) + trk.gid, trk.origen_global, trk.area_referencia = gid, es_reid, area_actual + + elif trk.gid is not None and not trk.en_grupo: + tiempo_ultima_firma = getattr(trk, 'ultimo_aprendizaje', 0) + + # ⚡ APRENDIZAJE RÁPIDO: Bajamos de 1.5s a 0.5s para que llene la memoria volando + if (now - tiempo_ultima_firma) > 0.5 and analizar_calidad(box): + fh, fw = frame.shape[:2] + x1, y1, x2, y2 = map(int, box) + en_borde = (x1 < 15 or y1 < 15 or x2 > fw - 15 or y2 > fh - 15) + + if not en_borde: + firma_nueva = extraer_firma_hibrida(frame, box) + if firma_nueva is not None: + with self.global_mem.lock: + if trk.gid in self.global_mem.db and self.global_mem.db[trk.gid]['firmas']: + + # ⚡ APRENDIZAJE EN CADENA: Comparamos contra la ÚLTIMA foto (-1), no contra la primera. + # Esto permite que el sistema "entienda" cuando te estás dando la vuelta o mostrando la mochila. + firma_reciente = self.global_mem.db[trk.gid]['firmas'][-1] + sim_coherencia = similitud_hibrida(firma_nueva, firma_reciente) + + # Tolerancia relajada a 0.50 para permitir la transición de la espalda + if sim_coherencia > 0.50: + es_coherente = True + for otro_gid, otro_data in self.global_mem.db.items(): + if otro_gid == trk.gid or not otro_data['firmas']: continue + sim_intruso = similitud_hibrida(firma_nueva, otro_data['firmas'][0]) + if sim_intruso > sim_coherencia: + es_coherente = False + break + if es_coherente: + self.global_mem._actualizar_sin_lock(trk.gid, firma_nueva, self.cam_id, now) + trk.ultimo_aprendizaje = now + trk.aprendiendo = True + + for d_idx in unmatched_dets: self.trackers.append(KalmanTrack(boxes[d_idx], now)) + + vivos = [] + fh, fw = frame.shape[:2] + for t in self.trackers: + x1, y1, x2, y2 = t.box + toca_borde = (x1 < 15 or y1 < 15 or x2 > fw - 15 or y2 > fh - 15) + tiempo_oculto = now - t.ts_ultima_deteccion + + # ⚡ MUERTE DE FANTASMAS: Si toca el borde muere en 1s. Evita robo de IDs. + limite_vida = 1.0 if toca_borde else 10.0 + if tiempo_oculto < limite_vida: + vivos.append(t) + + self.trackers = vivos + return self.trackers + + def _asignar(self, boxes, now): + n_trk = len(self.trackers); n_det = len(boxes) + if n_trk == 0: return [], list(range(n_det)), [] + if n_det == 0: return [], [], list(range(n_trk)) + + cost_mat = np.zeros((n_trk, n_det), dtype=np.float32) + TIEMPO_TURNO_ROTATIVO = len(SECUENCIA) * 0.035 + + for t, trk in enumerate(self.trackers): + for d, det in enumerate(boxes): + iou = iou_overlap(trk.box, det) + cx_t, cy_t = (trk.box[0]+trk.box[2])/2, (trk.box[1]+trk.box[3])/2 + cx_d, cy_d = (det[0]+det[2])/2, (det[1]+det[3])/2 + dist_norm = np.sqrt((cx_t-cx_d)**2 + (cy_t-cy_d)**2) / 550.0 + + area_trk = (trk.box[2] - trk.box[0]) * (trk.box[3] - trk.box[1]) + area_det = (det[2] - det[0]) * (det[3] - det[1]) + ratio_area = max(area_trk, area_det) / (min(area_trk, area_det) + 1e-6) + castigo_tam = (ratio_area - 1.0) * 0.7 + + tiempo_oculto = now - trk.ts_ultima_deteccion + if tiempo_oculto > (TIEMPO_TURNO_ROTATIVO * 2) and iou < 0.10: + fantasma_penalty = 5.0 + else: fantasma_penalty = 0.0 + + if iou >= 0.05 or dist_norm < 0.80: + cost_mat[t, d] = (1.0 - iou) + (dist_norm * 2.0) + fantasma_penalty + castigo_tam + else: cost_mat[t, d] = 100.0 + + row_ind, col_ind = linear_sum_assignment(cost_mat) + matched, unmatched_dets, unmatched_trks = [], [], [] + + for r, c in zip(row_ind, col_ind): + # ⚡ CAJAS PEGAJOSAS: 6.0 evita que suelte el ID si te mueves rápido + if cost_mat[r, c] > 7.0: + unmatched_trks.append(r); unmatched_dets.append(c) + else: matched.append((r, c)) + + for t in range(n_trk): + if t not in [m[0] for m in matched]: unmatched_trks.append(t) + for d in range(n_det): + if d not in [m[1] for m in matched]: unmatched_dets.append(d) + + return matched, unmatched_dets, unmatched_trks + +# ────────────────────────────────────────────────────────────────────────────── +# 5. STREAM Y MAIN LOOP (Standalone) +# ────────────────────────────────────────────────────────────────────────────── +class CamStream: + def __init__(self, url): + self.url, self.cap = url, cv2.VideoCapture(url) + self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1); self.frame = None + threading.Thread(target=self._run, daemon=True).start() + + def _run(self): + while True: + ret, f = self.cap.read() + if ret: + self.frame = f; time.sleep(0.01) + else: + time.sleep(2); self.cap.open(self.url) + +def dibujar_track(frame_show, trk): + try: x1, y1, x2, y2 = map(int, trk.box) + except Exception: return + + if trk.gid is None: color, label = C_CANDIDATO, f"?{trk.local_id}" + elif trk.en_grupo: color, label = C_GRUPO, f"ID:{trk.gid} [grp]" + elif trk.aprendiendo: color, label = C_APRENDIZAJE, f"ID:{trk.gid} [++]" + elif trk.origen_global: color, label = C_GLOBAL, f"ID:{trk.gid} [re-id]" + else: color, label = C_LOCAL, f"ID:{trk.gid}" + + cv2.rectangle(frame_show, (x1, y1), (x2, y2), color, 2) + (tw, th), _ = cv2.getTextSize(label, FUENTE, 0.55, 1) + cv2.rectangle(frame_show, (x1, y1-th-6), (x1+tw+2, y1), color, -1) + cv2.putText(frame_show, label, (x1+1, y1-4), FUENTE, 0.55, (0,0,0), 1) + +def main(): + print("Iniciando Sistema V-PRO — Tracker Resiliente (Código Unificado Maestro)") + model = YOLO("yolov8n.pt") + global_mem = GlobalMemory() + managers = {str(c): CamManager(c, global_mem) for c in SECUENCIA} + cams = [CamStream(u) for u in URLS] + cv2.namedWindow("SmartSoft", cv2.WINDOW_AUTOSIZE) + + idx = 0 + while True: + now = time.time() + tiles = [] + cam_ia = idx % len(cams) + for i, cam_obj in enumerate(cams): + frame = cam_obj.frame; cid = str(SECUENCIA[i]) + if frame is None: tiles.append(np.zeros((270, 480, 3), np.uint8)); continue + frame_show = cv2.resize(frame.copy(), (480, 270)); boxes = []; turno_activo = (i == cam_ia) + if turno_activo: + res = model.predict(frame_show, conf=0.50, iou=0.40, classes=[0], verbose=False, imgsz=480, device='cpu') + if res[0].boxes: boxes = res[0].boxes.xyxy.cpu().numpy().tolist() + tracks = managers[cid].update(boxes, frame_show, now, turno_activo) + for trk in tracks: + if trk.time_since_update <= 1: dibujar_track(frame_show, trk) + if turno_activo: cv2.circle(frame_show, (460, 20), 6, (0, 0, 255), -1) + con_id = sum(1 for t in tracks if t.gid and t.time_since_update==0) + cv2.putText(frame_show, f"CAM {cid} [{con_id} ID]", (10, 28), FUENTE, 0.7, (255, 255, 255), 2) + tiles.append(frame_show) + + if len(tiles) == 6: cv2.imshow("SmartSoft", np.vstack([np.hstack(tiles[0:3]), np.hstack(tiles[3:6])])) + idx += 1 + if cv2.waitKey(1) == ord('q'): break + cv2.destroyAllWindows() + +if __name__ == "__main__": + main() + + + + + + + + + + +############################################################### fusion.py +import os +os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3' +os.environ['CUDA_VISIBLE_DEVICES'] = '-1' +os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = "rtsp_transport;tcp|stimeout;3000000" +import cv2 +import numpy as np +import time +import threading +from queue import Queue +from deepface import DeepFace +from ultralytics import YOLO +import warnings + +warnings.filterwarnings("ignore") + +# ────────────────────────────────────────────────────────────────────────────── +# 1. IMPORTAMOS NUESTROS MÓDULOS +# ────────────────────────────────────────────────────────────────────────────── +# Del motor matemático y tracking +from seguimiento2 import GlobalMemory, CamManager, SECUENCIA, URLS, FUENTE, similitud_hibrida + +# Del motor de reconocimiento facial y audio +from reconocimiento2 import ( + gestionar_vectores, + detectar_rostros_yunet, + buscar_mejor_match, + hilo_bienvenida, + UMBRAL_SIM, + COOLDOWN_TIME +) + +# ────────────────────────────────────────────────────────────────────────────── +# 2. PROTECCIONES MULTIHILO E INICIALIZACIÓN +# ────────────────────────────────────────────────────────────────────────────── +COLA_ROSTROS = Queue(maxsize=4) +YUNET_LOCK = threading.Lock() +IA_LOCK = threading.Lock() + +# Inicializamos la base de datos usando tu función importada +print("\nIniciando carga de base de datos...") +BASE_DATOS_ROSTROS = gestionar_vectores(actualizar=True) + +# ────────────────────────────────────────────────────────────────────────────── +# 3. MOTOR ASÍNCRONO +# ────────────────────────────────────────────────────────────────────────────── +def procesar_rostro_async(frame_hd, box_480, gid, cam_id, global_mem, trk): + """ Toma el recorte del tracker, escala a Alta Definición, usa YuNet y hace la Fusión Mágica """ + try: + if not BASE_DATOS_ROSTROS: return + + # ────────────────────────────────────────────────────────── + # 1. VALIDACIÓN DEL FRAME HD Y ESCALADO MATEMÁTICO + # ────────────────────────────────────────────────────────── + h_real, w_real = frame_hd.shape[:2] + + # ⚡ TRAMPA ANTI-BUGS: Si esto salta, corrige la llamada en tu main_fusion.py + if w_real <= 480: + print(f"[❌ ERROR CAM {cam_id}] Le estás pasando el frame_show (480x270) a ArcFace, no el HD.") + + escala_x = w_real / 480.0 + escala_y = h_real / 270.0 + + x_min, y_min, x_max, y_max = box_480 + h_box = y_max - y_min + + y_min_expandido = max(0, y_min - (h_box * 0.15)) + y_max_cabeza = min(270, y_min + (h_box * 0.40)) # Límite máximo en la escala de 270 + + x1_hd = int(max(0, x_min) * escala_x) + y1_hd = int(y_min_expandido * escala_y) + x2_hd = int(min(480, x_max) * escala_x) + y2_hd = int(y_max_cabeza * escala_y) + + roi_cabeza = frame_hd[y1_hd:y2_hd, x1_hd:x2_hd] + + # Si la cabeza HD mide menos de 60x60, está demasiado lejos incluso en HD + if roi_cabeza.size == 0 or roi_cabeza.shape[0] < 60 or roi_cabeza.shape[1] < 60: + return + + h_roi, w_roi = roi_cabeza.shape[:2] + + # ────────────────────────────────────────────────────────── + # 2. DETECCIÓN DE ROSTRO CON YUNET (Ahora operando en HD) + # ────────────────────────────────────────────────────────── + faces = detectar_rostros_yunet(roi_cabeza, lock=YUNET_LOCK) + + for (rx, ry, rw, rh, score) in faces: + rx, ry = max(0, rx), max(0, ry) + rw, rh = min(w_roi - rx, rw), min(h_roi - ry, rh) + + area_rostro_actual = rw * rh + + with global_mem.lock: + data = global_mem.db.get(gid, {}) + nombre_actual = data.get('nombre') + area_ref = data.get('area_rostro_ref', 0) + + necesita_saludo = False + if str(cam_id) == "7": + if not hasattr(global_mem, 'ultimos_saludos'): + global_mem.ultimos_saludos = {} + ultimo = global_mem.ultimos_saludos.get(nombre_actual if nombre_actual else "", 0) + if (time.time() - ultimo) > COOLDOWN_TIME: + necesita_saludo = True + + if nombre_actual is None or area_rostro_actual >= (area_ref * 1.5) or necesita_saludo: + + # ⚡ MÁRGENES MÁS AMPLIOS: ArcFace necesita ver frente y barbilla (25%) + m_x = int(rw * 0.25) + m_y = int(rh * 0.25) + + roi_rostro = roi_cabeza[max(0, ry-m_y):min(h_roi, ry+rh+m_y), + max(0, rx-m_x):min(w_roi, rx+rw+m_x)] + + if roi_rostro.size == 0 or roi_rostro.shape[0] < 60 or roi_rostro.shape[1] < 60: + continue + + # ⚡ Laplaciano ajustado para imágenes HD (30.0 es más justo) + gray_roi = cv2.cvtColor(roi_rostro, cv2.COLOR_BGR2GRAY) + nitidez = cv2.Laplacian(gray_roi, cv2.CV_64F).var() + if nitidez < 15.0: + continue + + # ────────────────────────────────────────────────────────── + # 3. RECONOCIMIENTO FACIAL ARCFACE + # ────────────────────────────────────────────────────────── + with IA_LOCK: + try: + # ⚡ CAMBIO DRÁSTICO: Usamos RetinaFace para alinear la cabeza obligatoriamente. + # Si RetinaFace no logra enderezar la cara (ej. estás totalmente de perfil), + # lanzará una excepción y abortará, evitando falsos positivos. + # Así DEBE estar en main_fusion.py para que sea compatible con tu nueva DB + res = DeepFace.represent( + img_path=roi_cabeza, + model_name="ArcFace", + detector_backend="retinaface", # Obligatorio + align=True, # Obligatorio + enforce_detection=True # Obligatorio + ) + emb = np.array(res[0]["embedding"], dtype=np.float32) + mejor_match, max_sim = buscar_mejor_match(emb, BASE_DATOS_ROSTROS) + except Exception: + # Si falla la alineación o estás muy borroso, lo ignoramos en silencio. + continue + + print(f"[DEBUG CAM {cam_id}] ArcFace: {mejor_match} al {max_sim:.2f} (Umbral: {UMBRAL_SIM})") + + if max_sim >= UMBRAL_SIM and mejor_match: + nombre_limpio = mejor_match.split('_')[0] + + with global_mem.lock: + global_mem.db[gid]['nombre'] = nombre_limpio + global_mem.db[gid]['area_rostro_ref'] = area_rostro_actual + global_mem.db[gid]['ts'] = time.time() + + ids_a_borrar = [] + firma_actual = global_mem.db[gid]['firmas'][0] if global_mem.db[gid]['firmas'] else None + + for otro_gid, datos_otro in list(global_mem.db.items()): + if otro_gid == gid: continue + + if datos_otro.get('nombre') == nombre_limpio: + ids_a_borrar.append(otro_gid) + + elif datos_otro.get('nombre') is None and firma_actual and datos_otro['firmas']: + sim_huerfano = similitud_hibrida(firma_actual, datos_otro['firmas'][0]) + if sim_huerfano > 0.75: + ids_a_borrar.append(otro_gid) + + for id_basura in ids_a_borrar: + del global_mem.db[id_basura] + print(f"[🧹 LIMPIEZA] ID huérfano/clon {id_basura} eliminado tras reconocer a {nombre_limpio}.") + + if str(cam_id) == "7" and necesita_saludo: + global_mem.ultimos_saludos[nombre_limpio] = time.time() + try: + with IA_LOCK: + analisis = DeepFace.analyze(roi_rostro, actions=['gender'], enforce_detection=False)[0] + genero = analisis.get('dominant_gender', 'Man') + except Exception: + genero = "Man" + + threading.Thread(target=hilo_bienvenida, args=(nombre_limpio, genero), daemon=True).start() + break + except Exception as e: + pass + finally: + trk.procesando_rostro = False + +def worker_rostros(global_mem): + """ Consumidor de la cola multihilo """ + while True: + frame, box, gid, cam_id, trk = COLA_ROSTROS.get() + procesar_rostro_async(frame, box, gid, cam_id, global_mem, trk) + COLA_ROSTROS.task_done() + +# ────────────────────────────────────────────────────────────────────────────── +# 4. LOOP PRINCIPAL DE FUSIÓN +# ────────────────────────────────────────────────────────────────────────────── +class CamStream: + def __init__(self, url): + self.url = url + self.cap = cv2.VideoCapture(url) + self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) + self.frame = None + threading.Thread(target=self._run, daemon=True).start() + + def _run(self): + while True: + ret, f = self.cap.read() + if ret: + self.frame = f + time.sleep(0.01) + else: + time.sleep(2) + self.cap.open(self.url) + +def dibujar_track_fusion(frame_show, trk, global_mem): + try: x1, y1, x2, y2 = map(int, trk.box) + except Exception: return + + nombre_str = "" + if trk.gid is not None: + with global_mem.lock: + nombre = global_mem.db.get(trk.gid, {}).get('nombre') + if nombre: nombre_str = f" [{nombre}]" + + if trk.gid is None: color, label = (150, 150, 150), f"?{trk.local_id}" + elif nombre_str: color, label = (255, 0, 255), f"ID:{trk.gid}{nombre_str}" + elif trk.en_grupo: color, label = (0, 0, 255), f"ID:{trk.gid} [grp]" + elif trk.aprendiendo: color, label = (255, 255, 0), f"ID:{trk.gid} [++]" + elif trk.origen_global: color, label = (0, 165, 255), f"ID:{trk.gid} [re-id]" + else: color, label = (0, 255, 0), f"ID:{trk.gid}" + + cv2.rectangle(frame_show, (x1, y1), (x2, y2), color, 2) + (tw, th), _ = cv2.getTextSize(label, FUENTE, 0.55, 1) + cv2.rectangle(frame_show, (x1, y1-th-6), (x1+tw+2, y1), color, -1) + cv2.putText(frame_show, label, (x1+1, y1-4), FUENTE, 0.55, (0,0,0), 1) + +def main(): + print("\nIniciando Sistema") + model = YOLO("yolov8n.pt") + global_mem = GlobalMemory() + managers = {str(c): CamManager(c, global_mem) for c in SECUENCIA} + cams = [CamStream(u) for u in URLS] + + for _ in range(2): + threading.Thread(target=worker_rostros, args=(global_mem,), daemon=True).start() + + cv2.namedWindow("SmartSoft", cv2.WINDOW_AUTOSIZE) + idx = 0 + + while True: + now = time.time() + tiles = [] + cam_ia = idx % len(cams) + + for i, cam_obj in enumerate(cams): + frame = cam_obj.frame; cid = str(SECUENCIA[i]) + if frame is None: + tiles.append(np.zeros((270, 480, 3), np.uint8)) + continue + + frame_show = cv2.resize(frame.copy(), (480, 270)) + boxes = [] + turno_activo = (i == cam_ia) + + if turno_activo: + res = model.predict(frame_show, conf=0.50, iou=0.50, classes=[0], verbose=False, imgsz=480) + if res[0].boxes: + boxes = res[0].boxes.xyxy.cpu().numpy().tolist() + + tracks = managers[cid].update(boxes, frame_show, now, turno_activo) + + for trk in tracks: + if trk.time_since_update <= 1: + dibujar_track_fusion(frame_show, trk, global_mem) + + if turno_activo and trk.gid is not None and not getattr(trk, 'procesando_rostro', False): + if not COLA_ROSTROS.full(): + trk.procesando_rostro = True + COLA_ROSTROS.put((frame.copy(), trk.box, trk.gid, cid, trk)) + + if turno_activo: cv2.circle(frame_show, (460, 20), 6, (0, 0, 255), -1) + + con_id = sum(1 for t in tracks if t.gid and t.time_since_update==0) + cv2.putText(frame_show, f"CAM {cid} [{con_id} ID]", (10, 28), FUENTE, 0.7, (255, 255, 255), 2) + tiles.append(frame_show) + + if len(tiles) == 6: + cv2.imshow("SmartSoft Fusion", np.vstack([np.hstack(tiles[0:3]), np.hstack(tiles[3:6])])) + + idx += 1 + if cv2.waitKey(1) == ord('q'): + break + + cv2.destroyAllWindows() + +if __name__ == "__main__": + main() + + + + + + + + + + + + + + + + + + + +################################################################### reconocimeito2.py + +import os +os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3' +os.environ['CUDA_VISIBLE_DEVICES'] = '-1' + +import cv2 +import numpy as np +from deepface import DeepFace +import pickle +import time +import threading +import asyncio +import edge_tts +import subprocess +from datetime import datetime +import warnings +import urllib.request + +warnings.filterwarnings("ignore") + +# ────────────────────────────────────────────────────────────────────────────── +# CONFIGURACIÓN +# ────────────────────────────────────────────────────────────────────────────── +DB_PATH = "db_institucion" +CACHE_PATH = "cache_nombres" +VECTORS_FILE = "base_datos_rostros.pkl" +TIMESTAMPS_FILE = "representaciones_timestamps.pkl" +UMBRAL_SIM = 0.45 # Por encima → identificado. Por debajo → desconocido. +COOLDOWN_TIME = 15 # Segundos entre saludos + +USUARIO, PASSWORD, IP_DVR = "admin", "TCA200503", "192.168.1.65" +RTSP_URL = f"rtsp://{USUARIO}:{PASSWORD}@{IP_DVR}:554/Streaming/Channels/702" + +for path in [DB_PATH, CACHE_PATH]: + os.makedirs(path, exist_ok=True) + +# ────────────────────────────────────────────────────────────────────────────── +# YUNET — Detector facial rápido en CPU +# ────────────────────────────────────────────────────────────────────────────── +YUNET_MODEL_PATH = "face_detection_yunet_2023mar.onnx" + +if not os.path.exists(YUNET_MODEL_PATH): + print(f"Descargando YuNet ({YUNET_MODEL_PATH})...") + url = ("https://github.com/opencv/opencv_zoo/raw/main/models/" + "face_detection_yunet/face_detection_yunet_2023mar.onnx") + urllib.request.urlretrieve(url, YUNET_MODEL_PATH) + print("YuNet descargado.") + +# Detector estricto para ROIs grandes (persona cerca) +detector_yunet = cv2.FaceDetectorYN.create( + model=YUNET_MODEL_PATH, config="", + input_size=(320, 320), + score_threshold=0.70, + nms_threshold=0.3, + top_k=5000 +) + +# Detector permisivo para ROIs pequeños (persona lejos) +detector_yunet_lejano = cv2.FaceDetectorYN.create( + model=YUNET_MODEL_PATH, config="", + input_size=(320, 320), + score_threshold=0.45, + nms_threshold=0.3, + top_k=5000 +) + +def detectar_rostros_yunet(roi, lock=None): + """ + Elige automáticamente el detector según el tamaño del ROI. + """ + h_roi, w_roi = roi.shape[:2] + area = w_roi * h_roi + det = detector_yunet if area > 8000 else detector_yunet_lejano + + try: + if lock: + with lock: + det.setInputSize((w_roi, h_roi)) + _, faces = det.detect(roi) + else: + det.setInputSize((w_roi, h_roi)) + _, faces = det.detect(roi) + except Exception: + return [] + + if faces is None: + return [] + + resultado = [] + for face in faces: + try: + fx, fy, fw, fh = map(int, face[:4]) + score = float(face[14]) if len(face) > 14 else 1.0 + resultado.append((fx, fy, fw, fh, score)) + except (ValueError, OverflowError, TypeError): + continue + return resultado + + +# ────────────────────────────────────────────────────────────────────────────── +# SISTEMA DE AUDIO +# ────────────────────────────────────────────────────────────────────────────── +def obtener_audios_humanos(genero): + hora = datetime.now().hour + es_mujer = genero.lower() == 'woman' + suffix = "_m.mp3" if es_mujer else "_h.mp3" + if 5 <= hora < 12: + intro = "dias.mp3" + elif 12 <= hora < 19: + intro = "tarde.mp3" + else: + intro = "noches.mp3" + cierre = ("fin_noche" if (hora >= 19 or hora < 5) else "fin_dia") + suffix + return intro, cierre + + +async def sintetizar_nombre(nombre, ruta): + nombre_limpio = nombre.replace('_', ' ') + try: + comunicador = edge_tts.Communicate(nombre_limpio, "es-MX-DaliaNeural", rate="+10%") + await comunicador.save(ruta) + except Exception: + pass + + +def reproducir(archivo): + if os.path.exists(archivo): + subprocess.Popen( + ["mpv", "--no-video", "--volume=100", archivo], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + + +def hilo_bienvenida(nombre, genero): + archivo_nombre = os.path.join(CACHE_PATH, f"nombre_{nombre}.mp3") + + if not os.path.exists(archivo_nombre): + try: + asyncio.run(sintetizar_nombre(nombre, archivo_nombre)) + except Exception: + pass + + intro, cierre = obtener_audios_humanos(genero) + + archivos = [f for f in [intro, archivo_nombre, cierre] if os.path.exists(f)] + if archivos: + subprocess.Popen( + ["mpv", "--no-video", "--volume=100"] + archivos, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + + +# ────────────────────────────────────────────────────────────────────────────── +# GESTIÓN DE BASE DE DATOS (AHORA CON RETINAFACE Y ALINEACIÓN) +# ────────────────────────────────────────────────────────────────────────────── +def gestionar_vectores(actualizar=False): + vectores_actuales = {} + + if os.path.exists(VECTORS_FILE): + try: + with open(VECTORS_FILE, 'rb') as f: + vectores_actuales = pickle.load(f) + except Exception: + vectores_actuales = {} + + if not actualizar: + return vectores_actuales + + timestamps = {} + if os.path.exists(TIMESTAMPS_FILE): + try: + with open(TIMESTAMPS_FILE, 'rb') as f: + timestamps = pickle.load(f) + except Exception: + timestamps = {} + + print("\nACTUALIZANDO BASE DE DATOS (Alineación con RetinaFace)...") + imagenes = [f for f in os.listdir(DB_PATH) if f.lower().endswith(('.jpg', '.png'))] + nombres_en_disco = set() + hubo_cambios = False + + for archivo in imagenes: + nombre_archivo = os.path.splitext(archivo)[0] + ruta_img = os.path.join(DB_PATH, archivo) + nombres_en_disco.add(nombre_archivo) + + ts_actual = os.path.getmtime(ruta_img) + ts_guardado = timestamps.get(nombre_archivo, 0) + + if nombre_archivo in vectores_actuales and ts_actual == ts_guardado: + continue + + try: + # ⚡ MAGIA 1: RetinaFace alinea matemáticamente los rostros de la base de datos + res = DeepFace.represent( + img_path=ruta_img, + model_name="ArcFace", + detector_backend="retinaface", # Localiza ojos/nariz + align=True, # Rota la imagen para alinear + enforce_detection=True # Obliga a que haya cara válida + ) + emb = np.array(res[0]["embedding"], dtype=np.float32) + + # ⚡ MAGIA 2: Normalización L2 al guardar (Elimina el "Efecto Rosa María") + norma = np.linalg.norm(emb) + if norma > 0: + emb = emb / norma + + vectores_actuales[nombre_archivo] = emb + timestamps[nombre_archivo] = ts_actual + hubo_cambios = True + print(f" ✅ Procesado y alineado: {nombre_archivo}") + + except Exception as e: + print(f" ❌ Rostro no válido en '{archivo}', omitido. Error: {e}") + + for nombre in list(vectores_actuales.keys()): + if nombre not in nombres_en_disco: + del vectores_actuales[nombre] + timestamps.pop(nombre, None) + hubo_cambios = True + print(f" 🗑️ Eliminado (sin foto): {nombre}") + + if hubo_cambios: + with open(VECTORS_FILE, 'wb') as f: + pickle.dump(vectores_actuales, f) + with open(TIMESTAMPS_FILE, 'wb') as f: + pickle.dump(timestamps, f) + print(" Sincronización terminada.\n") + else: + print(" Sin cambios. Base de datos al día.\n") + + return vectores_actuales + +# ────────────────────────────────────────────────────────────────────────────── +# BÚSQUEDA BLINDADA (Similitud Coseno estricta) +# ────────────────────────────────────────────────────────────────────────────── +def buscar_mejor_match(emb_consulta, base_datos): + # ⚡ MAGIA 3: Normalización L2 del vector entrante + norma = np.linalg.norm(emb_consulta) + if norma > 0: + emb_consulta = emb_consulta / norma + + mejor_match, max_sim = None, -1.0 + for nombre, vec in base_datos.items(): + # Como ambos están normalizados, esto es Similitud Coseno pura (-1.0 a 1.0) + sim = float(np.dot(emb_consulta, vec)) + if sim > max_sim: + max_sim = sim + mejor_match = nombre + + return mejor_match, max_sim + +# ────────────────────────────────────────────────────────────────────────────── +# LOOP DE PRUEBA Y REGISTRO +# ────────────────────────────────────────────────────────────────────────────── +def sistema_interactivo(): + base_datos = gestionar_vectores(actualizar=False) + cap = cv2.VideoCapture(RTSP_URL) + ultimo_saludo = 0 + persona_actual = None + confirmaciones = 0 + + print("\n" + "=" * 50) + print(" MÓDULO DE REGISTRO Y DEPURACIÓN") + print(" [R] Registrar nuevo rostro | [Q] Salir") + print("=" * 50 + "\n") + + faces_ultimo_frame = [] + + while True: + ret, frame = cap.read() + if not ret: + time.sleep(2) + cap.open(RTSP_URL) + continue + + h, w = frame.shape[:2] + display_frame = frame.copy() + tiempo_actual = time.time() + + faces_raw = detectar_rostros_yunet(frame) + faces_ultimo_frame = faces_raw + + for (fx, fy, fw, fh, score_yunet) in faces_raw: + fx = max(0, fx); fy = max(0, fy) + fw = min(w - fx, fw); fh = min(h - fy, fh) + if fw <= 0 or fh <= 0: + continue + + cv2.rectangle(display_frame, (fx, fy), (fx+fw, fy+fh), (255, 200, 0), 2) + cv2.putText(display_frame, f"YN:{score_yunet:.2f}", + (fx, fy - 25), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (255, 200, 0), 1) + + if (tiempo_actual - ultimo_saludo) <= COOLDOWN_TIME: + continue + + m = int(fw * 0.15) + roi = frame[max(0, fy-m): min(h, fy+fh+m), + max(0, fx-m): min(w, fx+fw+m)] + + if roi.size == 0 or roi.shape[0] < 40 or roi.shape[1] < 40: + cv2.putText(display_frame, "muy pequeño", + (fx, fy-10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 100, 255), 1) + continue + + gray_roi = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) + nitidez = cv2.Laplacian(gray_roi, cv2.CV_64F).var() + if nitidez < 50.0: + cv2.putText(display_frame, f"blur({nitidez:.0f})", + (fx, fy-10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 165, 255), 1) + continue + + try: + # ⚡ En el modo de prueba interactivo usamos las reglas viejas + # para que sea rápido y puedas registrar fotos fácilmente. + res = DeepFace.represent( + img_path=roi, model_name="ArcFace", enforce_detection=False + ) + emb = np.array(res[0]["embedding"], dtype=np.float32) + mejor_match, max_sim = buscar_mejor_match(emb, base_datos) + + except Exception as e: + print(f"[ERROR ArcFace]: {e}") + continue + + estado = " IDENTIFICADO" if max_sim > UMBRAL_SIM else "DESCONOCIDO" + nombre_d = mejor_match.split('_')[0] if mejor_match else "nadie" + n_bloques = int(max_sim * 20) + barra = "█" * n_bloques + "░" * (20 - n_bloques) + print(f"[REGISTRO] {estado} | {nombre_d:<14} | {barra} | " + f"{max_sim*100:.1f}% (umbral: {UMBRAL_SIM*100:.0f}%)") + + if max_sim > UMBRAL_SIM and mejor_match: + color = (0, 255, 0) + texto = f"{mejor_match.split('_')[0]} ({max_sim:.2f})" + + if mejor_match == persona_actual: + confirmaciones += 1 + else: + persona_actual, confirmaciones = mejor_match, 1 + + if confirmaciones >= 2: + cv2.rectangle(display_frame, (fx, fy), (fx+fw, fy+fh), (0, 255, 0), 3) + try: + analisis = DeepFace.analyze( + roi, actions=['gender'], enforce_detection=False + )[0] + genero = analisis['dominant_gender'] + except Exception: + genero = "Man" + + threading.Thread( + target=hilo_bienvenida, + args=(mejor_match, genero), + daemon=True + ).start() + ultimo_saludo = tiempo_actual + confirmaciones = 0 + + else: + color = (0, 0, 255) + texto = f"? ({max_sim:.2f})" + confirmaciones = max(0, confirmaciones - 1) + + cv2.putText(display_frame, texto, + (fx, fy - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.7, color, 2) + + cv2.imshow("Módulo de Registro", display_frame) + key = cv2.waitKey(1) & 0xFF + + if key == ord('q'): + break + + elif key == ord('r'): + if faces_ultimo_frame: + areas = [fw * fh for (fx, fy, fw, fh, _) in faces_ultimo_frame] + fx, fy, fw, fh, _ = faces_ultimo_frame[np.argmax(areas)] + + # Le damos más margen al registro (30%) para que RetinaFace no falle + # cuando procese la foto en la carpeta. + m_x = int(fw * 0.30) + m_y = int(fh * 0.30) + face_roi = frame[max(0, fy-m_y): min(h, fy+fh+m_y), + max(0, fx-m_x): min(w, fx+fw+m_x)] + + if face_roi.size > 0: + nom = input("\nNombre de la persona: ").strip() + if nom: + foto_path = os.path.join(DB_PATH, f"{nom}.jpg") + cv2.imwrite(foto_path, face_roi) + print(f"[OK] Rostro de '{nom}' guardado. Sincronizando...") + # ⚡ Al sincronizar, RetinaFace alineará esta foto guardada. + base_datos = gestionar_vectores(actualizar=True) + else: + print("[!] Registro cancelado.") + else: + print("[!] Recorte vacío. Intenta de nuevo.") + else: + print("\n[!] No se detectó rostro. Acércate más o mira a la lente.") + + cap.release() + cv2.destroyAllWindows() + +if __name__ == "__main__": + sistema_interactivo() diff --git a/video.mp4 b/video.mp4 new file mode 100644 index 0000000..cc2ba25 Binary files /dev/null and b/video.mp4 differ