Codigos seguros y mejora en el reconocimiento

This commit is contained in:
rodrigo 2026-03-30 11:11:49 -06:00
parent e10bfb7bf4
commit d2e90d9c50
49 changed files with 1958 additions and 274 deletions

12
.gitignore vendored
View File

@ -160,3 +160,15 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ #.idea/
# ────────────────────────────────────────────────────────
# ENTORNO VIRTUAL DEL PROYECTO
# ────────────────────────────────────────────────────────
ia_env/
# ────────────────────────────────────────────────────────
# MODELOS DE IA (Límite de GitHub: 100 MB)
# ────────────────────────────────────────────────────────
*.pt
*.onnx

BIN
Rodrigo Cahuantzi_1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
base_datos_rostros.pkl Normal file

Binary file not shown.

View File

@ -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"}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

BIN
db_institucion/Omar.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
db_institucion/Yuriel.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

285
fusion.py
View File

@ -1,7 +1,7 @@
import os import os
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3' os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'
os.environ['CUDA_VISIBLE_DEVICES'] = '-1' os.environ['CUDA_VISIBLE_DEVICES'] = '-1'
os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = "rtsp_transport;tcp|stimeout;3000000"
import cv2 import cv2
import numpy as np import numpy as np
import time import time
@ -17,7 +17,7 @@ warnings.filterwarnings("ignore")
# 1. IMPORTAMOS NUESTROS MÓDULOS # 1. IMPORTAMOS NUESTROS MÓDULOS
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
# Del motor matemático y tracking # 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 # Del motor de reconocimiento facial y audio
from reconocimiento2 import ( from reconocimiento2 import (
@ -43,37 +43,46 @@ BASE_DATOS_ROSTROS = gestionar_vectores(actualizar=True)
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
# 3. MOTOR ASÍNCRONO # 3. MOTOR ASÍNCRONO
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
def procesar_rostro_async(frame, box, gid, cam_id, global_mem, trk): def procesar_rostro_async(frame_hd, box_480, gid, cam_id, global_mem, trk):
""" Toma el recorte del tracker, usa YuNet importado, y hace la Fusión Mágica """ """ Toma el recorte del tracker, escala a HD, aplica filtros físicos y votación biométrica """
try: try:
if not BASE_DATOS_ROSTROS: return 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_x = w_real / 480.0
escala_y = h_real / 270.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 h_box = y_max - y_min
y_min_expandido = max(0, y_min - (h_box * 0.15)) 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) x1_hd = int(max(0, x_min) * escala_x)
y1 = int(y_min_expandido * escala_y) y1_hd = int(y_min_expandido * escala_y)
x2 = int(min(480, x_max) * escala_x) x2_hd = int(min(480, x_max) * escala_x)
y2 = int(y_max_cabeza * escala_y) 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 return
h_roi, w_roi = roi_cabeza.shape[:2] 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) 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: for (rx, ry, rw, rh, score) in faces:
rx, ry = max(0, rx), max(0, ry) rx, ry = max(0, rx), max(0, ry)
rw, rh = min(w_roi - rx, rw), min(h_roi - ry, rh) 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 necesita_saludo = True
if nombre_actual is None or area_rostro_actual >= (area_ref * 1.5) or necesita_saludo: 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), 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)] 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: if roi_rostro.size == 0 or roi_rostro.shape[0] < 40 or roi_rostro.shape[1] < 40:
continue continue
# ── Filtro de nitidez ── # 🛡️ FILTRO ANTI-PERFIL: Evita falsos positivos de personas viendo de lado
gray_roi = cv2.cvtColor(roi_rostro, cv2.COLOR_BGR2GRAY) ratio_aspecto = roi_rostro.shape[1] / float(roi_rostro.shape[0])
nitidez = cv2.Laplacian(gray_roi, cv2.CV_64F).var() if ratio_aspecto < 0.60:
if nitidez < 50.0:
continue 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: with IA_LOCK:
try: 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) 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) mejor_match, max_sim = buscar_mejor_match(emb, BASE_DATOS_ROSTROS)
except Exception: 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] nombre_limpio = mejor_match.split('_')[0]
with global_mem.lock: with global_mem.lock:
gid_original = None datos_id = global_mem.db.get(gid)
for otro_gid, datos_otro in global_mem.db.items(): if not datos_id: continue
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
if str(cam_id) == "7" and necesita_saludo: # SISTEMA DE VOTACIÓN (Anti-Falsos Positivos)
global_mem.ultimos_saludos[nombre_limpio] = time.time() 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: # CANDADO DE BAUTIZO: Protege a los VIPs de alucinaciones borrosas
with IA_LOCK: if nombre_actual is not None and nombre_actual != nombre_limpio:
analisis = DeepFace.analyze(roi_rostro, actions=['gender'], enforce_detection=False)[0] if max_sim < 0.55:
genero = analisis.get('dominant_gender', 'Man') # Si es un puntaje bajo, es una confusión de ArcFace. Lo ignoramos.
except Exception: print(f" [RECHAZO] ArcFace intentó renombrar a {nombre_actual} como {nombre_limpio} con solo {max_sim:.2f}")
genero = "Man" continue
else:
# Usamos la función importada para el audio # Si el puntaje es masivo, significa que OSNet se equivocó y pegó 2 personas
threading.Thread( print(f"[CORRECCIÓN VIP] OSNet se confundió. Renombrando a {nombre_limpio} ({max_sim:.2f})")
target=hilo_bienvenida,
args=(nombre_limpio, genero), # ⚡ BAUTIZO Y LIMPIEZA
daemon=True if nombre_actual != nombre_limpio:
).start() datos_id['nombre'] = nombre_limpio
break 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: except Exception as e:
pass pass
finally: finally:
trk.procesando_rostro = False trk.procesando_rostro = False
def worker_rostros(global_mem): def worker_rostros(global_mem):
""" Consumidor de la cola multihilo """ """ Consumidor de la cola multihilo """
@ -245,11 +322,11 @@ def main():
turno_activo = (i == cam_ia) turno_activo = (i == cam_ia)
if turno_activo: 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: if res[0].boxes:
boxes = res[0].boxes.xyxy.cpu().numpy().tolist() 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: for trk in tracks:
if trk.time_since_update <= 1: 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])])) cv2.imshow("SmartSoft Fusion", np.vstack([np.hstack(tiles[0:3]), np.hstack(tiles[3:6])]))
idx += 1 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 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__": if __name__ == "__main__":
main() main()

110
generar_db_rostros.py Normal file
View File

@ -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()

43
prueba_video.py Normal file
View File

@ -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

View File

@ -22,12 +22,12 @@ warnings.filterwarnings("ignore")
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
DB_PATH = "db_institucion" DB_PATH = "db_institucion"
CACHE_PATH = "cache_nombres" CACHE_PATH = "cache_nombres"
VECTORS_FILE = "representaciones.pkl" VECTORS_FILE = "base_datos_rostros.pkl"
TIMESTAMPS_FILE = "representaciones_timestamps.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 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" RTSP_URL = f"rtsp://{USUARIO}:{PASSWORD}@{IP_DVR}:554/Streaming/Channels/702"
for path in [DB_PATH, CACHE_PATH]: for path in [DB_PATH, CACHE_PATH]:
@ -46,7 +46,6 @@ if not os.path.exists(YUNET_MODEL_PATH):
print("YuNet descargado.") print("YuNet descargado.")
# Detector estricto para ROIs grandes (persona cerca) # Detector estricto para ROIs grandes (persona cerca)
# score_threshold alto → menos falsos positivos en fondos
detector_yunet = cv2.FaceDetectorYN.create( detector_yunet = cv2.FaceDetectorYN.create(
model=YUNET_MODEL_PATH, config="", model=YUNET_MODEL_PATH, config="",
input_size=(320, 320), input_size=(320, 320),
@ -56,7 +55,6 @@ detector_yunet = cv2.FaceDetectorYN.create(
) )
# Detector permisivo para ROIs pequeños (persona lejos) # 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( detector_yunet_lejano = cv2.FaceDetectorYN.create(
model=YUNET_MODEL_PATH, config="", model=YUNET_MODEL_PATH, config="",
input_size=(320, 320), input_size=(320, 320),
@ -68,9 +66,6 @@ detector_yunet_lejano = cv2.FaceDetectorYN.create(
def detectar_rostros_yunet(roi, lock=None): def detectar_rostros_yunet(roi, lock=None):
""" """
Elige automáticamente el detector según el tamaño del ROI. 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] h_roi, w_roi = roi.shape[:2]
area = w_roi * h_roi area = w_roi * h_roi
@ -119,7 +114,6 @@ def obtener_audios_humanos(genero):
async def sintetizar_nombre(nombre, ruta): async def sintetizar_nombre(nombre, ruta):
"""Genera el audio del nombre con edge-tts (solo si no existe en caché)."""
nombre_limpio = nombre.replace('_', ' ') nombre_limpio = nombre.replace('_', ' ')
try: try:
comunicador = edge_tts.Communicate(nombre_limpio, "es-MX-DaliaNeural", rate="+10%") comunicador = edge_tts.Communicate(nombre_limpio, "es-MX-DaliaNeural", rate="+10%")
@ -129,7 +123,6 @@ async def sintetizar_nombre(nombre, ruta):
def reproducir(archivo): def reproducir(archivo):
"""Lanza mpv en segundo plano sin bloquear."""
if os.path.exists(archivo): if os.path.exists(archivo):
subprocess.Popen( subprocess.Popen(
["mpv", "--no-video", "--volume=100", archivo], ["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): 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") 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): if not os.path.exists(archivo_nombre):
try: try:
asyncio.run(sintetizar_nombre(nombre, archivo_nombre)) asyncio.run(sintetizar_nombre(nombre, archivo_nombre))
@ -155,7 +142,6 @@ def hilo_bienvenida(nombre, genero):
intro, cierre = obtener_audios_humanos(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)] archivos = [f for f in [intro, archivo_nombre, cierre] if os.path.exists(f)]
if archivos: if archivos:
subprocess.Popen( subprocess.Popen(
@ -166,21 +152,12 @@ def hilo_bienvenida(nombre, genero):
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
# GESTIÓN DE BASE DE DATOS # GESTIÓN DE BASE DE DATOS (AHORA CON RETINAFACE Y ALINEACIÓN)
# Incremental: solo procesa fotos nuevas o modificadas
# Vectores normalizados al guardar → comparación 3x más rápida
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
def gestionar_vectores(actualizar=False): def gestionar_vectores(actualizar=False):
""" import json # ⚡ Asegúrate de tener importado json
Carga o actualiza el diccionario {nombre: vector_normalizado}.
- 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 = {} vectores_actuales = {}
# Intentar cargar lo que ya teníamos
if os.path.exists(VECTORS_FILE): if os.path.exists(VECTORS_FILE):
try: try:
with open(VECTORS_FILE, 'rb') as f: with open(VECTORS_FILE, 'rb') as f:
@ -191,7 +168,6 @@ def gestionar_vectores(actualizar=False):
if not actualizar: if not actualizar:
return vectores_actuales return vectores_actuales
# ── Cargar timestamps para detectar cambios ───────────────────────────────
timestamps = {} timestamps = {}
if os.path.exists(TIMESTAMPS_FILE): if os.path.exists(TIMESTAMPS_FILE):
try: try:
@ -200,10 +176,23 @@ def gestionar_vectores(actualizar=False):
except Exception: except Exception:
timestamps = {} 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'))] imagenes = [f for f in os.listdir(DB_PATH) if f.lower().endswith(('.jpg', '.png'))]
nombres_en_disco = set() nombres_en_disco = set()
hubo_cambios = False hubo_cambios = False
cambio_generos = False # Bandera para saber si actualizamos el JSON
for archivo in imagenes: for archivo in imagenes:
nombre_archivo = os.path.splitext(archivo)[0] nombre_archivo = os.path.splitext(archivo)[0]
@ -213,17 +202,39 @@ def gestionar_vectores(actualizar=False):
ts_actual = os.path.getmtime(ruta_img) ts_actual = os.path.getmtime(ruta_img)
ts_guardado = timestamps.get(nombre_archivo, 0) ts_guardado = timestamps.get(nombre_archivo, 0)
# Si ya la teníamos y no cambió → saltar (no llamar a ArcFace) # Si ya tenemos el vector pero NO tenemos su género en el JSON, forzamos el procesamiento
if nombre_archivo in vectores_actuales and ts_actual == ts_guardado: falta_genero = nombre_archivo not in dic_generos
if nombre_archivo in vectores_actuales and ts_actual == ts_guardado and not falta_genero:
continue continue
try: 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( 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) emb = np.array(res[0]["embedding"], dtype=np.float32)
# MEJORA 2: Normalizar al guardar (una sola vez para siempre)
norma = np.linalg.norm(emb) norma = np.linalg.norm(emb)
if norma > 0: if norma > 0:
emb = emb / norma emb = emb / norma
@ -231,62 +242,64 @@ def gestionar_vectores(actualizar=False):
vectores_actuales[nombre_archivo] = emb vectores_actuales[nombre_archivo] = emb
timestamps[nombre_archivo] = ts_actual timestamps[nombre_archivo] = ts_actual
hubo_cambios = True hubo_cambios = True
print(f" Procesado: {nombre_archivo}") print(f" Procesado y alineado: {nombre_archivo} | Género: {dic_generos.get(nombre_archivo)}")
except Exception: except Exception as e:
print(f" Rostro no válido en '{archivo}', omitido.") 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()): for nombre in list(vectores_actuales.keys()):
if nombre not in nombres_en_disco: if nombre not in nombres_en_disco:
del vectores_actuales[nombre] del vectores_actuales[nombre]
timestamps.pop(nombre, None) timestamps.pop(nombre, None)
if nombre in dic_generos:
del dic_generos[nombre]
cambio_generos = True
hubo_cambios = True hubo_cambios = True
print(f" Eliminado (sin foto): {nombre}") print(f" Eliminado (sin foto): {nombre}")
# Guardado de la memoria
if hubo_cambios: if hubo_cambios:
with open(VECTORS_FILE, 'wb') as f: with open(VECTORS_FILE, 'wb') as f:
pickle.dump(vectores_actuales, f) pickle.dump(vectores_actuales, f)
with open(TIMESTAMPS_FILE, 'wb') as f: with open(TIMESTAMPS_FILE, 'wb') as f:
pickle.dump(timestamps, 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") print(" Sincronización terminada.\n")
else: else:
print(" Sin cambios. Base de datos al día.\n") print(" Sin cambios. Base de datos al día.\n")
return vectores_actuales return vectores_actuales
# ──────────────────────────────────────────────────────────────────────────────
# BÚSQUEDA BLINDADA (Similitud Coseno estricta)
# ──────────────────────────────────────────────────────────────────────────────
def buscar_mejor_match(emb_consulta, base_datos): def buscar_mejor_match(emb_consulta, base_datos):
""" # ⚡ MAGIA 3: Normalización L2 del vector entrante
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
norma = np.linalg.norm(emb_consulta) norma = np.linalg.norm(emb_consulta)
if norma > 0: if norma > 0:
emb_consulta = emb_consulta / norma emb_consulta = emb_consulta / norma
mejor_match, max_sim = None, -1.0 mejor_match, max_sim = None, -1.0
for nombre, vec in base_datos.items(): 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)) sim = float(np.dot(emb_consulta, vec))
if sim > max_sim: if sim > max_sim:
max_sim, mejor_match = sim, nombre max_sim = sim
mejor_match = nombre
return mejor_match, max_sim return mejor_match, max_sim
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
# LOOP DE PRUEBA Y REGISTRO # LOOP DE PRUEBA Y REGISTRO (CON SIMETRÍA ESTRICTA)
# MEJORA 5 — Reemplaza face_cascade por YuNet (consistente con principal2.py)
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
def sistema_interactivo(): 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) base_datos = gestionar_vectores(actualizar=False)
cap = cv2.VideoCapture(RTSP_URL) cap = cv2.VideoCapture(RTSP_URL)
ultimo_saludo = 0 ultimo_saludo = 0
@ -294,11 +307,11 @@ def sistema_interactivo():
confirmaciones = 0 confirmaciones = 0
print("\n" + "=" * 50) 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(" [R] Registrar nuevo rostro | [Q] Salir")
print("=" * 50 + "\n") print("=" * 50 + "\n")
faces_ultimo_frame = [] # Para que [R] pueda usarlas aunque no haya detección nueva faces_ultimo_frame = []
while True: while True:
ret, frame = cap.read() ret, frame = cap.read()
@ -311,36 +324,33 @@ def sistema_interactivo():
display_frame = frame.copy() display_frame = frame.copy()
tiempo_actual = time.time() tiempo_actual = time.time()
# ── Detección con YuNet sobre el frame completo ───────────────────────
faces_raw = detectar_rostros_yunet(frame) 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: for (fx, fy, fw, fh, score_yunet) in faces_raw:
# Clampear dentro de la imagen
fx = max(0, fx); fy = max(0, fy) fx = max(0, fx); fy = max(0, fy)
fw = min(w - fx, fw); fh = min(h - fy, fh) fw = min(w - fx, fw); fh = min(h - fy, fh)
if fw <= 0 or fh <= 0: if fw <= 0 or fh <= 0:
continue continue
# Rectángulo base (amarillo)
cv2.rectangle(display_frame, (fx, fy), (fx+fw, fy+fh), (255, 200, 0), 2) cv2.rectangle(display_frame, (fx, fy), (fx+fw, fy+fh), (255, 200, 0), 2)
cv2.putText(display_frame, f"YN:{score_yunet:.2f}", cv2.putText(display_frame, f"YN:{score_yunet:.2f}",
(fx, fy - 25), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (255, 200, 0), 1) (fx, fy - 25), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (255, 200, 0), 1)
if (tiempo_actual - ultimo_saludo) <= COOLDOWN_TIME: if (tiempo_actual - ultimo_saludo) <= COOLDOWN_TIME:
continue # En cooldown, no procesamos continue
# ── Recorte del rostro con margen ─────────────────────────────────
m = int(fw * 0.15) m = int(fw * 0.15)
roi = frame[max(0, fy-m): min(h, fy+fh+m), roi = frame[max(0, fy-m): min(h, fy+fh+m),
max(0, fx-m): min(w, fx+fw+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: 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) (fx, fy-10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 100, 255), 1)
continue continue
# ── Filtro de nitidez (descarta caras movidas antes de ArcFace) ─── # 🛡️ FILTRO DE NITIDEZ
gray_roi = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) gray_roi = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
nitidez = cv2.Laplacian(gray_roi, cv2.CV_64F).var() nitidez = cv2.Laplacian(gray_roi, cv2.CV_64F).var()
if nitidez < 50.0: if nitidez < 50.0:
@ -348,19 +358,34 @@ def sistema_interactivo():
(fx, fy-10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 165, 255), 1) (fx, fy-10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 165, 255), 1)
continue 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: try:
res = DeepFace.represent( 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) emb = np.array(res[0]["embedding"], dtype=np.float32)
mejor_match, max_sim = buscar_mejor_match(emb, base_datos) mejor_match, max_sim = buscar_mejor_match(emb, base_datos)
except Exception as e: except Exception:
print(f"[ERROR ArcFace]: {e}") # 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 continue
# ── Barra de similitud en consola ─────────────────────────────────
estado = " IDENTIFICADO" if max_sim > UMBRAL_SIM else "DESCONOCIDO" estado = " IDENTIFICADO" if max_sim > UMBRAL_SIM else "DESCONOCIDO"
nombre_d = mejor_match.split('_')[0] if mejor_match else "nadie" nombre_d = mejor_match.split('_')[0] if mejor_match else "nadie"
n_bloques = int(max_sim * 20) n_bloques = int(max_sim * 20)
@ -368,7 +393,6 @@ def sistema_interactivo():
print(f"[REGISTRO] {estado} | {nombre_d:<14} | {barra} | " print(f"[REGISTRO] {estado} | {nombre_d:<14} | {barra} | "
f"{max_sim*100:.1f}% (umbral: {UMBRAL_SIM*100:.0f}%)") f"{max_sim*100:.1f}% (umbral: {UMBRAL_SIM*100:.0f}%)")
# ── Resultado en pantalla ─────────────────────────────────────────
if max_sim > UMBRAL_SIM and mejor_match: if max_sim > UMBRAL_SIM and mejor_match:
color = (0, 255, 0) color = (0, 255, 0)
texto = f"{mejor_match.split('_')[0]} ({max_sim:.2f})" 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) cv2.rectangle(display_frame, (fx, fy), (fx+fw, fy+fh), (0, 255, 0), 3)
try: try:
analisis = DeepFace.analyze( analisis = DeepFace.analyze(
roi, actions=['gender'], enforce_detection=False roi_mejorado, actions=['gender'], enforce_detection=False
)[0] )[0]
genero = analisis['dominant_gender'] genero = analisis['dominant_gender']
except Exception: except Exception:
@ -404,22 +428,21 @@ def sistema_interactivo():
cv2.putText(display_frame, texto, cv2.putText(display_frame, texto,
(fx, fy - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.7, color, 2) (fx, fy - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.7, color, 2)
# ── Mostrar frame ─────────────────────────────────────────────────────
cv2.imshow("Módulo de Registro", display_frame) cv2.imshow("Módulo de Registro", display_frame)
key = cv2.waitKey(1) & 0xFF key = cv2.waitKey(1) & 0xFF
if key == ord('q'): if key == ord('q'):
break break
#R: Registrar el rostro más grande del frame ─────────────────
elif key == ord('r'): elif key == ord('r'):
if faces_ultimo_frame: 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] areas = [fw * fh for (fx, fy, fw, fh, _) in faces_ultimo_frame]
fx, fy, fw, fh, _ = faces_ultimo_frame[np.argmax(areas)] fx, fy, fw, fh, _ = faces_ultimo_frame[np.argmax(areas)]
m = 25
face_roi = frame[max(0, fy-m): min(h, fy+fh+m), m_x = int(fw * 0.30)
max(0, fx-m): min(w, fx+fw+m)] 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: if face_roi.size > 0:
nom = input("\nNombre de la persona: ").strip() nom = input("\nNombre de la persona: ").strip()
@ -438,7 +461,5 @@ def sistema_interactivo():
cap.release() cap.release()
cv2.destroyAllWindows() cv2.destroyAllWindows()
# ──────────────────────────────────────────────────────────────────────────────
if __name__ == "__main__": if __name__ == "__main__":
sistema_interactivo() sistema_interactivo()

Binary file not shown.

Binary file not shown.

View File

@ -11,9 +11,10 @@ import os
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
# CONFIGURACIÓN DEL SISTEMA # 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] 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] URLS = [f"rtsp://{USUARIO}:{PASSWORD}@{IP_DVR}:554/Streaming/Channels/{i}02" for i in SECUENCIA]
ONNX_MODEL_PATH = "osnet_x0_25_msmt17.onnx" ONNX_MODEL_PATH = "osnet_x0_25_msmt17.onnx"
@ -22,19 +23,16 @@ VECINOS = {
"8": ["5", "3"], "3": ["8", "6"], "6": ["3"] "8": ["5", "3"], "3": ["8", "6"], "6": ["3"]
} }
# ─── PARÁMETROS TÉCNICOS
ASPECT_RATIO_MIN = 0.5 ASPECT_RATIO_MIN = 0.5
ASPECT_RATIO_MAX = 4.0 ASPECT_RATIO_MAX = 4.0
AREA_MIN_CALIDAD = 1200 AREA_MIN_CALIDAD = 1200
FRAMES_CALIDAD = 2 FRAMES_CALIDAD = 2
TIEMPO_MIN_TRANSITO_NO_VECINO = 10.0
TIEMPO_MAX_AUSENCIA = 800.0 TIEMPO_MAX_AUSENCIA = 800.0
# ─── UMBRALES DE RE-ID (VERSIÓN ESTABLE) UMBRAL_REID_MISMA_CAM = 0.53 # Antes 0.65
UMBRAL_REID_MISMA_CAM = 0.65 UMBRAL_REID_VECINO = 0.48 # Antes 0.53
UMBRAL_REID_VECINO = 0.55 UMBRAL_REID_NO_VECINO = 0.67 # Antes 0.72
UMBRAL_REID_NO_VECINO = 0.72 MAX_FIRMAS_MEMORIA = 10
MAX_FIRMAS_MEMORIA = 15
C_CANDIDATO = (150, 150, 150) C_CANDIDATO = (150, 150, 150)
C_LOCAL = (0, 255, 0) C_LOCAL = (0, 255, 0)
@ -44,7 +42,7 @@ C_APRENDIZAJE = (255, 255, 0)
FUENTE = cv2.FONT_HERSHEY_SIMPLEX FUENTE = cv2.FONT_HERSHEY_SIMPLEX
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
# INICIALIZACIÓN DEL MOTOR DEEP LEARNING (ONNX) # INICIALIZACIÓN OSNET
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
print("Cargando cerebro de Re-Identificación (OSNet)...") print("Cargando cerebro de Re-Identificación (OSNet)...")
try: 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) 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): def analizar_calidad(box):
x1, y1, x2, y2 = box x1, y1, x2, y2 = box
@ -77,8 +75,7 @@ def preprocess_onnx(roi):
def extraer_color_zonas(img): def extraer_color_zonas(img):
h_roi = img.shape[0] h_roi = img.shape[0]
t1 = int(h_roi * 0.15) t1, t2 = int(h_roi * 0.15), int(h_roi * 0.55)
t2 = int(h_roi * 0.55)
zonas = [img[:t1, :], img[t1:t2, :], img[t2:, :]] zonas = [img[:t1, :], img[t1:t2, :], img[t2:, :]]
def hist_zona(z): 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]) hist = cv2.calcHist([hsv], [0, 1], None, [16, 8], [0, 180, 0, 256])
cv2.normalize(hist, hist) cv2.normalize(hist, hist)
return hist.flatten() return hist.flatten()
return np.concatenate([hist_zona(z) for z in zonas]) 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: try:
x1, y1, x2, y2 = map(int, box) h_hd, w_hd = frame_hd.shape[:2]
fh, fw = frame.shape[:2] escala_x = w_hd / 480.0
x1_c, y1_c = max(0, x1), max(0, y1) escala_y = h_hd / 270.0
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 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) 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 if norma > 0: deep_feat = deep_feat / norma
color_feat = extraer_color_zonas(roi) color_feat = extraer_color_zonas(roi)
textura_feat = extraer_textura_rapida(roi)
return {'deep': deep_feat, 'color': color_feat, 'calidad': calidad_area} return {'deep': deep_feat, 'color': color_feat, 'textura': textura_feat, 'calidad': calidad_area}
except Exception as e: except Exception:
return None 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 if f1 is None or f2 is None: return 0.0
sim_deep = max(0.0, 1.0 - cosine(f1['deep'], f2['deep'])) 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: if f1['color'].shape == f2['color'].shape and f1['color'].size > 1:
L = len(f1['color']) // 3 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_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_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_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:
else: sim_color = 0.0 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 # 2. KALMAN TRACKER
@ -137,8 +169,7 @@ class KalmanTrack:
_count = 0 _count = 0
def __init__(self, box, now): def __init__(self, box, now):
self.kf = cv2.KalmanFilter(7, 4) self.kf = cv2.KalmanFilter(7, 4)
self.kf.measurementMatrix = np.array([ 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)
[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 = 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.transitionMatrix[0,4] = 1; self.kf.transitionMatrix[1,5] = 1; self.kf.transitionMatrix[2,6] = 1
self.kf.processNoiseCov *= 0.03 self.kf.processNoiseCov *= 0.03
@ -150,10 +181,8 @@ class KalmanTrack:
self.origen_global = False self.origen_global = False
self.aprendiendo = False self.aprendiendo = False
self.box = list(box) self.box = list(box)
self.ts_creacion = now self.ts_creacion = now
self.ts_ultima_deteccion = now self.ts_ultima_deteccion = now
self.time_since_update = 0 self.time_since_update = 0
self.en_grupo = False self.en_grupo = False
self.frames_buena_calidad = 0 self.frames_buena_calidad = 0
@ -192,7 +221,7 @@ class KalmanTrack:
self.frames_buena_calidad = max(0, self.frames_buena_calidad - 1) 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: class GlobalMemory:
def __init__(self): def __init__(self):
@ -207,27 +236,25 @@ class GlobalMemory:
if ultima_cam == cam_destino: return True if ultima_cam == cam_destino: return True
vecinos = VECINOS.get(ultima_cam, []) 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 if cam_destino in vecinos: return dt >= -0.5
return dt >= 4.0 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 if not firmas_guardadas: return 0.0
sims = sorted([similitud_hibrida(firma_nueva, f, cross_cam) for f in firmas_guardadas], reverse=True)
sims = sorted( if len(sims) == 1: return sims[0]
[similitud_hibrida(firma_nueva, f) for f in firmas_guardadas], elif len(sims) <= 4: return (sims[0] * 0.6) + (sims[1] * 0.4)
reverse=True else: return (sims[0] * 0.50) + (sims[1] * 0.30) + (sims[2] * 0.20)
)
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: with self.lock:
best_gid, best_score = None, -1.0 candidatos = []
vecinos = VECINOS.get(str(cam_id), []) vecinos = VECINOS.get(str(cam_id), [])
for gid, data in self.db.items(): 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 dt > TIEMPO_MAX_AUSENCIA or not self._es_transito_posible(data, cam_id, now): continue
if not data['firmas']: continue if not data['firmas']: continue
sim = self._sim_robusta(firma_hibrida, data['firmas']) misma_cam = (str(data['last_cam']) == str(cam_id))
es_cross_cam = not misma_cam
misma_cam = str(data['last_cam']) == str(cam_id)
es_vecino = str(data['last_cam']) in vecinos 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 if misma_cam: umbral = UMBRAL_REID_MISMA_CAM
elif es_vecino: umbral = UMBRAL_REID_VECINO elif es_vecino: umbral = UMBRAL_REID_VECINO
else: umbral = UMBRAL_REID_NO_VECINO else: umbral = UMBRAL_REID_NO_VECINO
if sim > best_score and sim > umbral: # PROTECCIÓN VIP
best_score = sim if data.get('nombre') is not None:
best_gid = gid if misma_cam:
umbral += 0.04
else:
umbral += 0.03
if best_gid is not None: if sim > umbral:
self._actualizar_sin_lock(best_gid, firma_hibrida, cam_id, now) # ⚡ MODIFICACIÓN: Guardamos también si es de la misma cam o vecino para el desempate final
return best_gid, True candidatos.append((sim, gid, misma_cam, es_vecino))
else:
if not candidatos:
nid = self.next_gid; self.next_gid += 1 nid = self.next_gid; self.next_gid += 1
self._actualizar_sin_lock(nid, firma_hibrida, cam_id, now) self._actualizar_sin_lock(nid, firma_hibrida, cam_id, now)
return nid, False 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): 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 gid not in self.db: self.db[gid] = {'firmas': [], 'last_cam': cam_id, 'ts': now}
if firma_dict is not None: if firma_dict is not None:
firmas_list = self.db[gid]['firmas'] firmas_list = self.db[gid]['firmas']
if not firmas_list: if not firmas_list:
firmas_list.append(firma_dict) firmas_list.append(firma_dict)
else: else:
if firma_dict['calidad'] > (firmas_list[0]['calidad'] * 1.50): if firma_dict['calidad'] > (firmas_list[0]['calidad'] * 1.50):
vieja_ancla = firmas_list[0] vieja_ancla = firmas_list[0]; firmas_list[0] = firma_dict; firma_dict = vieja_ancla
firmas_list[0] = firma_dict
firma_dict = vieja_ancla
if len(firmas_list) >= MAX_FIRMAS_MEMORIA: if len(firmas_list) >= MAX_FIRMAS_MEMORIA:
max_sim_interna = -1.0 max_sim_interna = -1.0; idx_redundante = 1
idx_redundante = 1
for i in range(1, len(firmas_list)): for i in range(1, len(firmas_list)):
sims_con_otras = [ sims_con_otras = [similitud_hibrida(firmas_list[i], firmas_list[j]) for j in range(1, len(firmas_list)) if j != i]
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 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 firmas_list[idx_redundante] = firma_dict
else: else:
firmas_list.append(firma_dict) firmas_list.append(firma_dict)
self.db[gid]['last_cam'] = cam_id self.db[gid]['last_cam'] = cam_id
self.db[gid]['ts'] = now self.db[gid]['ts'] = now
def actualizar(self, gid, firma, cam_id, now): def actualizar(self, gid, firma, cam_id, now):
with self.lock: self._actualizar_sin_lock(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): 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]) 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) inter = max(0, xB-xA) * max(0, yB-yA)
areaA = (boxA[2]-boxA[0]) * (boxA[3]-boxA[1]) areaA = (boxA[2]-boxA[0]) * (boxA[3]-boxA[1]); areaB = (boxB[2]-boxB[0]) * (boxB[3]-boxB[1])
areaB = (boxB[2]-boxB[0]) * (boxB[3]-boxB[1])
return inter / (areaA + areaB - inter + 1e-6) return inter / (areaA + areaB - inter + 1e-6)
class CamManager: class CamManager:
def __init__(self, cam_id, global_mem): def __init__(self, cam_id, global_mem):
self.cam_id, self.global_mem, self.trackers = 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) for trk in self.trackers: trk.predict(turno_activo=turno_activo)
if not turno_activo: return self.trackers if not turno_activo: return self.trackers
@ -318,45 +402,56 @@ class CamManager:
for t_idx, d_idx in matched: for t_idx, d_idx in matched:
trk = self.trackers[t_idx]; box = boxes[d_idx] 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) 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) trk.update(box, en_grupo, now)
active_gids = {t.gid for t in self.trackers if t.gid is not None} 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]) area_actual = (box[2] - box[0]) * (box[3] - box[1])
if trk.gid is None and trk.listo_para_id: # IGNORAMOS VECTORES MUTANTES DE GRUPOS
firma = extraer_firma_hibrida(frame, box) 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: 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 trk.gid, trk.origen_global, trk.area_referencia = gid, es_reid, area_actual
elif trk.gid is not None and not trk.en_grupo: elif trk.gid is not None and not trk.en_grupo:
tiempo_ultima_firma = getattr(trk, 'ultimo_aprendizaje', 0) tiempo_ultima_firma = getattr(trk, 'ultimo_aprendizaje', 0)
if (now - tiempo_ultima_firma) > 1.5 and analizar_calidad(box): # ⚡ APRENDIZAJE RÁPIDO: Bajamos de 1.5s a 0.5s para que llene la memoria volando
fh, fw = frame.shape[:2] if (now - tiempo_ultima_firma) > 0.5 and analizar_calidad(box):
fh, fw = frame_hd.shape[:2]
x1, y1, x2, y2 = map(int, box) x1, y1, x2, y2 = map(int, box)
en_borde = (x1 < 15 or y1 < 15 or x2 > fw - 15 or y2 > fh - 15) en_borde = (x1 < 15 or y1 < 15 or x2 > fw - 15 or y2 > fh - 15)
if not en_borde: 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: if firma_nueva is not None:
with self.global_mem.lock: with self.global_mem.lock:
if trk.gid in self.global_mem.db and self.global_mem.db[trk.gid]['firmas']: 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 es_coherente = True
for otro_gid, otro_data in self.global_mem.db.items(): for otro_gid, otro_data in self.global_mem.db.items():
if otro_gid == trk.gid or not otro_data['firmas']: continue if otro_gid == trk.gid or not otro_data['firmas']: continue
sim_intruso = similitud_hibrida(firma_nueva, otro_data['firmas'][0]) sim_intruso = similitud_hibrida(firma_nueva, otro_data['firmas'][0])
if sim_intruso > sim_coherencia: if sim_intruso > sim_coherencia:
es_coherente = False es_coherente = False
break break
if es_coherente: if es_coherente:
self.global_mem._actualizar_sin_lock(trk.gid, firma_nueva, self.cam_id, now) self.global_mem._actualizar_sin_lock(trk.gid, firma_nueva, self.cam_id, now)
trk.ultimo_aprendizaje = 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)) for d_idx in unmatched_dets: self.trackers.append(KalmanTrack(boxes[d_idx], now))
vivos = [] 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: for t in self.trackers:
x1, y1, x2, y2 = t.box 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 tiempo_oculto = now - t.ts_ultima_deteccion
if toca_borde and tiempo_oculto > 1.0: # ⚡ MUERTE JUSTA: Si es anónimo en el borde, muere rápido.
continue # 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: if tiempo_oculto < limite_vida:
vivos.append(t) vivos.append(t)
@ -382,47 +482,40 @@ class CamManager:
return self.trackers return self.trackers
def _asignar(self, boxes, now): def _asignar(self, boxes, now):
n_trk = len(self.trackers) n_trk = len(self.trackers); n_det = len(boxes)
n_det = len(boxes)
if n_trk == 0: return [], list(range(n_det)), [] if n_trk == 0: return [], list(range(n_det)), []
if n_det == 0: return [], [], list(range(n_trk)) if n_det == 0: return [], [], list(range(n_trk))
cost_mat = np.zeros((n_trk, n_det), dtype=np.float32) 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 t, trk in enumerate(self.trackers):
for d, det in enumerate(boxes): for d, det in enumerate(boxes):
iou = iou_overlap(trk.box, det) 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_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 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 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_trk = (trk.box[2] - trk.box[0]) * (trk.box[3] - trk.box[1])
area_det = (det[2] - det[0]) * (det[3] - det[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) ratio_area = max(area_trk, area_det) / (min(area_trk, area_det) + 1e-6)
castigo_tam = (ratio_area - 1.0) * 0.7
castigo_tamano = (ratio_area - 1.0) * 0.4
tiempo_oculto = now - trk.ts_ultima_deteccion 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: if tiempo_oculto > (TIEMPO_TURNO_ROTATIVO * 2) and iou < 0.10:
fantasma_penalty = 5.0 fantasma_penalty = 5.0
else: else: fantasma_penalty = 0.0
fantasma_penalty = 0.0
if iou >= 0.05 or dist_norm < 0.50: if iou >= 0.05 or dist_norm < 0.80:
cost_mat[t, d] = (1.0 - iou) + (dist_norm * 2.0) + fantasma_penalty + castigo_tamano cost_mat[t, d] = (1.0 - iou) + (dist_norm * 2.0) + fantasma_penalty + castigo_tam
else: else: cost_mat[t, d] = 100.0
cost_mat[t, d] = 100.0
row_ind, col_ind = linear_sum_assignment(cost_mat) row_ind, col_ind = linear_sum_assignment(cost_mat)
matched, unmatched_dets, unmatched_trks = [], [], [] matched, unmatched_dets, unmatched_trks = [], [], []
for r, c in zip(row_ind, col_ind): 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) unmatched_trks.append(r); unmatched_dets.append(c)
else: matched.append((r, c)) else: matched.append((r, c))
@ -446,11 +539,9 @@ class CamStream:
while True: while True:
ret, f = self.cap.read() ret, f = self.cap.read()
if ret: if ret:
self.frame = f self.frame = f; time.sleep(0.01)
time.sleep(0.01)
else: else:
time.sleep(2) time.sleep(2); self.cap.open(self.url)
self.cap.open(self.url)
def dibujar_track(frame_show, trk): def dibujar_track(frame_show, trk):
try: x1, y1, x2, y2 = map(int, trk.box) 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) (tw, th), _ = cv2.getTextSize(label, FUENTE, 0.55, 1)
cv2.rectangle(frame_show, (x1, y1-th-6), (x1+tw+2, y1), color, -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) 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(): 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") model = YOLO("yolov8n.pt")
global_mem = GlobalMemory() global_mem = GlobalMemory()
managers = {str(c): CamManager(c, global_mem) for c in SECUENCIA} 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 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) frame_show = cv2.resize(frame.copy(), (480, 270)); boxes = []; turno_activo = (i == cam_ia)
if turno_activo: 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() 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: for trk in tracks:
if trk.time_since_update <= 1: dibujar_track(frame_show, trk) 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) if turno_activo: cv2.circle(frame_show, (460, 20), 6, (0, 0, 255), -1)

1258
vesiones_seguras.txt Normal file

File diff suppressed because it is too large Load Diff

BIN
video.mp4 Normal file

Binary file not shown.