IdentificacionIA/seguimiento2.py

1692 lines
85 KiB
Python
Raw Normal View History

2026-03-18 17:45:30 +00:00
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
from datetime import datetime
import json
2026-06-25 17:06:15 +00:00
import csv
import math
import queue
2026-06-25 17:06:15 +00:00
from scipy.spatial.distance import cosine
from queue import Queue
from reconocimiento import app, gestionar_vectores, buscar_mejor_match, hilo_bienvenida
COLA_ROSTROS = Queue(maxsize=4)
IA_LOCK = threading.Lock()
try:
BASE_DATOS_ROSTROS = gestionar_vectores(actualizar=True)
except:
BASE_DATOS_ROSTROS = {}
2026-03-18 17:45:30 +00:00
# ──────────────────────────────────────────────────────────────────────────────
2026-06-25 17:06:15 +00:00
# CONFIGURACIÓN
2026-03-18 17:45:30 +00:00
# ──────────────────────────────────────────────────────────────────────────────
USUARIO, PASSWORD, IP_DVR = "admin", "TCA200503", "192.168.1.244"
2026-03-18 17:45:30 +00:00
SECUENCIA = [1, 7, 5, 8, 3, 6]
2026-06-25 17:06:15 +00:00
# ⚡ FIX: Optimización extrema de red sin romper la interfaz gráfica de Linux
os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = "rtsp_transport;tcp|fflags;nobuffer|flags;low_delay"
os.environ["OPENCV_LOG_LEVEL"] = "ERROR"
2026-03-18 17:45:30 +00:00
URLS = [f"rtsp://{USUARIO}:{PASSWORD}@{IP_DVR}:554/Streaming/Channels/{i}02" for i in SECUENCIA]
2026-06-25 17:06:15 +00:00
ONNX_MODEL_PATH = "osnet_x1_0_msmt17_batch1.onnx"
2026-03-18 17:45:30 +00:00
VECINOS = {
"1": ["7"], "7": ["1", "5"], "5": ["7", "8"],
"8": ["5", "3"], "3": ["8", "6"], "6": ["3"]
}
2026-06-25 17:06:15 +00:00
TIEMPO_MAX_AUSENCIA = 900.0
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)
2026-03-18 17:45:30 +00:00
FUENTE = cv2.FONT_HERSHEY_SIMPLEX
# ──────────────────────────────────────────────────────────────────────────────
2026-06-25 17:06:15 +00:00
# OSNET
2026-03-18 17:45:30 +00:00
# ──────────────────────────────────────────────────────────────────────────────
2026-06-25 17:06:15 +00:00
print("Cargando OSNet...")
2026-03-18 17:45:30 +00:00
try:
2026-06-25 17:06:15 +00:00
sess_options = ort.SessionOptions()
# Desbloquea los núcleos del procesador para evitar cuellos de botella con 3+ personas
sess_options.intra_op_num_threads = 4
sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
ort_session = ort.InferenceSession(ONNX_MODEL_PATH, sess_options, providers=['CPUExecutionProvider'])
2026-03-18 18:27:26 +00:00
input_name = ort_session.get_inputs()[0].name
2026-06-25 17:06:15 +00:00
print("OSNet listo para CPU (Multi-Thread).")
2026-03-18 17:45:30 +00:00
except Exception as e:
2026-06-25 17:06:15 +00:00
print(f"ERROR FATAL: {e}"); exit()
MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32).reshape(3, 1, 1)
STD = np.array([0.229, 0.224, 0.225], dtype=np.float32).reshape(3, 1, 1)
2026-03-18 17:45:30 +00:00
# ──────────────────────────────────────────────────────────────────────────────
2026-06-25 17:06:15 +00:00
# CONTROL DE PAGINACIÓN DINÁMICA (Para Comunicación con Interfaz)
2026-03-18 17:45:30 +00:00
# ──────────────────────────────────────────────────────────────────────────────
2026-06-25 17:06:15 +00:00
# Variables globales para interfaz
PAGINA_ACTUAL = 0
CAMS_POR_PANTALLA = 4
ULTIMO_FRAME_COMPUESTO = None
CAMARA_MAXIMIZADA = None
TELEMETRIA = {'fps': 0.0, 'ids_activos': 0}
MOTOR_ACTIVO = False
def obtener_ultimo_frame():
global ULTIMO_FRAME_COMPUESTO
return ULTIMO_FRAME_COMPUESTO
def construir_mosaico(tiles):
"""Construye una matriz dinámica según la cantidad de cámaras visibles en la página"""
n = len(tiles)
if n == 0: return np.zeros((270, 480, 3), dtype=np.uint8)
if n == 1: return tiles[0]
# Calcular distribución óptima de filas y columnas de forma cuadrada
import math
cols = math.ceil(math.sqrt(n))
rows = math.ceil(n / cols)
h, w, c = tiles[0].shape
mosaico = np.zeros((rows * h, cols * w, c), dtype=np.uint8)
for i, tile in enumerate(tiles):
r = i // cols
c_idx = i % cols
mosaico[r*h:(r+1)*h, c_idx*w:(c_idx+1)*w] = tile
return mosaico
2026-03-18 17:45:30 +00:00
2026-06-25 17:06:15 +00:00
# ──────────────────────────────────────────────────────────────────────────────
# EXTRACCIÓN DE FIRMAS MULTI-DESCRIPTOR
# ──────────────────────────────────────────────────────────────────────────────
def extraer_features_osnet_seguro(rois):
#Procesamiento secuencial 1-a-1 de grado comercial. Garantiza 0% de probabilidad de crasheo C++ en ONNX.
if not rois: return []
features_norm = []
for roi in rois:
# 1. Preprocesamiento matemático exacto para OSNet
img = cv2.resize(roi, (128, 256))
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
img = img.transpose(2, 0, 1).astype(np.float32) / 255.0
# Expandimos la dimensión para crear un Batch estricto de tamaño 1: (1, 3, 256, 128)
blob = np.expand_dims((img - MEAN) / STD, 0)
# 2. INFERENCIA AISLADA (El motor ONNX no sufrirá estrés de memoria)
# El [0][0] extrae el vector ignorando la dimensión del batch
df = ort_session.run(None, {input_name: blob})[0][0].flatten()
# 3. Normalización L2 (Vital para que funcione la distancia Coseno)
n = np.linalg.norm(df)
if n > 0: df /= n
features_norm.append(df)
return features_norm
def calcular_nitidez(img):
if img is None or img.size == 0: return 0
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
return cv2.Laplacian(gray, cv2.CV_64F).var()
def extraer_color_zonas(roi):
if roi is None or roi.size == 0 or roi.shape[0] < 30 or roi.shape[1] < 10:
return np.zeros(512 * 3, dtype=np.float32)
2026-06-25 17:06:15 +00:00
h, w = roi.shape[:2]
2026-06-25 17:06:15 +00:00
# ⚡ LA MÁSCARA ELÍPTICA: Ignora el fondo/paredes
mask = np.zeros((h, w), dtype=np.uint8)
centro_x, centro_y = int(w/2), int(h/2)
eje_x, eje_y = int(w * 0.35), int(h * 0.48)
cv2.ellipse(mask, (centro_x, centro_y), (eje_x, eje_y), 0, 0, 360, 255, -1)
hsv = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV)
t1, t2 = int(h * 0.30), int(h * 0.65)
2026-06-25 17:06:15 +00:00
z1_img, z1_mask = hsv[:t1, :], mask[:t1, :]
z2_img, z2_mask = hsv[t1:t2, :], mask[t1:t2, :]
z3_img, z3_mask = hsv[t2:, :], mask[t2:, :]
def calc_hist(img_part, mask_part):
if img_part.size == 0: return np.zeros(512, dtype=np.float32)
hist = cv2.calcHist([img_part], [0, 1, 2], mask_part, [8, 8, 8], [0, 180, 0, 256, 0, 256])
cv2.normalize(hist, hist, alpha=1.0, norm_type=cv2.NORM_L1)
2026-03-18 17:45:30 +00:00
return hist.flatten()
2026-06-25 17:06:15 +00:00
h1 = calc_hist(z1_img, z1_mask)
h2 = calc_hist(z2_img, z2_mask)
h3 = calc_hist(z3_img, z3_mask)
return np.concatenate([h1, h2, h3]).astype(np.float32)
def extraer_proporciones_anatomicas(kpts):
if kpts is None or len(kpts) < 17: return None
kpts = np.array(kpts)
if kpts.shape[0] < 17: return None
puntos = {i: kpts[i] for i in range(17)}
if puntos[0][2] < 0.30: return None
if puntos[5][2] < 0.30 and puntos[6][2] < 0.30: return None
if puntos[11][2] < 0.30 and puntos[12][2] < 0.30: return None
def dist(i, j):
if puntos[i][2] < 0.30 or puntos[j][2] < 0.30: return 0.0
return float(np.linalg.norm(puntos[i][:2] - puntos[j][:2]))
hombros = dist(5, 6)
caderas = dist(11, 12)
torso = max(dist(5, 11), dist(6, 12))
pierna_izq = dist(11, 13) + dist(13, 15) if puntos[13][2] > 0.30 and puntos[15][2] > 0.30 else 0
pierna_der = dist(12, 14) + dist(14, 16) if puntos[14][2] > 0.30 and puntos[16][2] > 0.30 else 0
piernas = max(pierna_izq, pierna_der)
brazo_izq = dist(5, 7) + dist(7, 9) if puntos[7][2] > 0.30 and puntos[9][2] > 0.30 else 0
brazo_der = dist(6, 8) + dist(8, 10) if puntos[8][2] > 0.30 and puntos[10][2] > 0.30 else 0
brazos = max(brazo_izq, brazo_der)
altura = torso + piernas
if altura < 15.0: return None
feats = np.array([
hombros / max(altura, 1e-6),
caderas / max(altura, 1e-6),
torso / max(altura, 1e-6),
piernas / max(altura, 1e-6),
brazos / max(altura, 1e-6),
hombros / max(caderas, 1e-6),
piernas / max(torso, 1e-6),
], dtype=np.float32)
feats = np.clip(feats, 0.0, 3.0)
n = np.linalg.norm(feats)
if n > 0: feats /= n
return feats
def es_humano_valido(kpts, box, conf):
if conf < 0.45: return False
if kpts is None or len(kpts) < 17: return False
x1, y1, x2, y2 = box
w, h = x2 - x1, y2 - y1
# Tolerancia extrema a la deformación del cuerpo (agacharse)
if h < 40 or w < 20: return False
if h / max(w, 1) < 0.20: return False # Soporta que estén doblados
# Si no le vemos la cabeza (ej. inclinado hacia adelante),
# pero el torso está firme, ES humano.
cabeza_segura = sum(1 for p in kpts[:5] if p[2] > 0.40) >= 1
torso_seguro = sum(1 for p in kpts[5:13] if p[2] > 0.40) >= 2
if not (cabeza_segura or torso_seguro): return False
return True
def extraer_firma_hibrida(frame_hd, box_480, kpts=None, deep_feat=None):
2026-03-18 17:45:30 +00:00
try:
h_hd, w_hd = frame_hd.shape[:2]
x1, y1, x2, y2 = box_480
2026-06-25 17:06:15 +00:00
# Conversión de coordenadas de 480p a resolucion HD
x1_c = max(0, int(x1 * (w_hd/480.0)))
y1_c = max(0, int(y1 * (h_hd/270.0)))
x2_c = min(w_hd, int(x2 * (w_hd/480.0)))
y2_c = min(h_hd, int(y2 * (h_hd/270.0)))
# ROI COMPLETO (Se usará para OSNet/Deep)
roi = frame_hd[y1_c:y2_c, x1_c:x2_c]
2026-03-18 18:27:26 +00:00
2026-06-25 17:06:15 +00:00
if roi.size == 0 or roi.shape[0] < 40 or roi.shape[1] < 20:
return None
score_nitidez = calcular_nitidez(roi)
# ⚡ PREPROCESAMIENTO OSNET (Usa el roi completo: pies a cabeza)
if deep_feat is None:
img = cv2.resize(roi, (128, 256))
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
img = img.transpose(2, 0, 1).astype(np.float32) / 255.0
blob = np.expand_dims((img - MEAN) / STD, 0)
# Inferencia de rescate individual
deep_feat = ort_session.run(None, {input_name: blob})[0][0].flatten()
n = np.linalg.norm(deep_feat)
if n > 0: deep_feat /= n
# ─────────────────────────────────────────────────────────────
# ⚡ FIX: RECORTE EXCLUSIVO PARA EL COLOR (Ignora fondo)
# ─────────────────────────────────────────────────────────────
h_r, w_r = roi.shape[:2]
# Cortamos cabeza (15%), piso (20%) y paredes (25% lateral)
y_col1, y_col2 = int(h_r * 0.15), int(h_r * 0.80)
x_col1, x_col2 = int(w_r * 0.25), int(w_r * 0.75)
roi_color = roi[y_col1:y_col2, x_col1:x_col2]
# Si el recorte es válido, se lo mandamos a tu extractor de color
if roi_color.size > 0 and roi_color.shape[0] > 5 and roi_color.shape[1] > 5:
color_f = extraer_color_zonas(roi_color)
else:
color_f = extraer_color_zonas(roi) # Fallback por si la caja es muy pequeña
anat_f = extraer_proporciones_anatomicas(kpts)
calidad = (x2_c - x1_c) * (y2_c - y1_c)
# Retorna el diccionario original intacto
return {
'deep': deep_feat,
'color': color_f,
'anatomia': anat_f,
'calidad': calidad,
'nitidez': score_nitidez
}
except Exception as e:
return None
def desglose_similitud(f1, f2, cross_cam=False):
if f1 is None or f2 is None: return -1.0, -1.0, -1.0
deep1, deep2 = f1.get('deep'), f2.get('deep')
c1, c2 = f1.get('color'), f2.get('color')
a1, a2 = f1.get('anatomia'), f2.get('anatomia')
2026-03-18 17:45:30 +00:00
2026-06-25 17:06:15 +00:00
sim_deep = max(0.0, 1.0 - cosine(deep1, deep2)) if (deep1 is not None and deep2 is not None) else -1.0
# --- ⚡ FIX RADICAL: COMPARACIÓN DE COLOR POR ZONAS (ANTI-INVERSIÓN) ---
sim_color = -1.0
if c1 is not None and c2 is not None and c1.shape == c2.shape:
s1 = 1.0 - cv2.compareHist(c1[:512], c2[:512], cv2.HISTCMP_BHATTACHARYYA) # Cabeza
s2 = 1.0 - cv2.compareHist(c1[512:1024], c2[512:1024], cv2.HISTCMP_BHATTACHARYYA) # Torso
s3 = 1.0 - cv2.compareHist(c1[1024:], c2[1024:], cv2.HISTCMP_BHATTACHARYYA) # Piernas
2026-06-25 17:06:15 +00:00
# Penalización severa si el torso o las piernas están invertidas
if s2 < 0.25 or s3 < 0.25:
sim_color = ((s1 * 0.1) + (s2 * 0.6) + (s3 * 0.3)) * 0.3 # Destruimos el score de color
else:
sim_color = (s1 * 0.1) + (s2 * 0.6) + (s3 * 0.3)
2026-03-18 17:45:30 +00:00
2026-06-25 17:06:15 +00:00
sim_anat = -1.0
if a1 is not None and a2 is not None and a1.shape == a2.shape:
sim_anat = max(0.0, 1.0 - np.linalg.norm(a1 - a2))
2026-03-18 17:45:30 +00:00
2026-06-25 17:06:15 +00:00
return float(sim_deep), float(sim_color), float(sim_anat)
2026-03-18 17:45:30 +00:00
2026-06-25 17:06:15 +00:00
def similitud_hibrida(f1, f2, cross_cam=False, confianza_fisica=1.0):
2026-03-18 18:27:26 +00:00
if f1 is None or f2 is None: return 0.0
2026-06-25 17:06:15 +00:00
from scipy.spatial.distance import cosine
import numpy as np
deep1, deep2 = f1.get('deep'), f2.get('deep')
if deep1 is None or deep2 is None: return 0.0
2026-06-25 17:06:15 +00:00
sim_deep = max(0.0, 1.0 - cosine(deep1, deep2))
# ⚡ ECUALIZADOR CROSS-CAM
if cross_cam and sim_deep > 0.50:
sim_deep += 0.05
# ZONA DE CERTEZA
if sim_deep >= 0.70: return min(1.0, sim_deep + 0.15)
# VÍA DE RECHAZO
if sim_deep < 0.45: return sim_deep
# TRIBUNAL DE ANATOMÍA
resultado_final = sim_deep + 0.05
a1, a2 = f1.get("anatomia"), f2.get("anatomia")
2026-03-18 18:27:26 +00:00
2026-06-25 17:06:15 +00:00
if a1 is not None and a2 is not None and hasattr(a1, "shape") and a1.shape == a2.shape:
sim_anat = max(0.0, 1.0 - np.linalg.norm(a1 - a2))
peso_anat = 0.12 * confianza_fisica
2026-06-25 17:06:15 +00:00
if sim_anat > 0.88: resultado_final += peso_anat
if sim_anat >= 0.95: resultado_final += 0.04
elif sim_anat < 0.35: resultado_final -= peso_anat
else:
if sim_deep >= 0.50: resultado_final += 0.12
return float(max(0.0, min(1.0, resultado_final)))
2026-03-18 17:45:30 +00:00
# ──────────────────────────────────────────────────────────────────────────────
2026-06-25 17:06:15 +00:00
# KALMAN TRACKER
2026-03-18 17:45:30 +00:00
# ──────────────────────────────────────────────────────────────────────────────
class KalmanTrack:
_count = 0
2026-06-25 17:06:15 +00:00
2026-03-18 17:45:30 +00:00
def __init__(self, box, now):
2026-06-25 17:06:15 +00:00
kf = cv2.KalmanFilter(7, 4)
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)
kf.transitionMatrix = np.eye(7, dtype=np.float32)
kf.transitionMatrix[0,4] = kf.transitionMatrix[1,5] = kf.transitionMatrix[2,6] = 1
# El índice 3 pasa de 0.01 a 0.08 para permitir deformación elástica de la caja
kf.processNoiseCov = np.diag([2.0, 4.0, 15.0, 0.08, 1.0, 2.0, 1.0]).astype(np.float32)
kf.measurementNoiseCov = np.diag([10.0, 10.0, 50.0, 0.10]).astype(np.float32)
kf.errorCovPost = np.diag([10., 10., 25., 1., 500., 500., 100.]).astype(np.float32)
kf.statePost = np.zeros((7,1), np.float32)
kf.statePost[:4] = self._z(box)
self.kf = kf
self.local_id = KalmanTrack._count; KalmanTrack._count += 1
self.gid, self.origen_global, self.aprendiendo = None, False, False
2026-03-18 18:27:26 +00:00
self.box = list(box)
2026-06-25 17:06:15 +00:00
self.ts_creacion = self.ts_ultima_deteccion = now
self.time_since_update = 0
2026-03-18 18:27:26 +00:00
self.en_grupo = False
2026-06-25 17:06:15 +00:00
self.frames_buena_calidad = 0
2026-03-18 18:27:26 +00:00
self.listo_para_id = False
self.firma_pre_grupo = None
2026-06-25 17:06:15 +00:00
self.ts_salio_grupo = 0.0
self.fallos_post_grupo = 0
self.ultimo_aprendizaje = 0.0
self.frames_continuos = 0
self.frames_observados = 0
self.firma_ema = None
self.muestras_capturadas = 0
self.ultimo_cambio_id = 0.0
def _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/max(h,1e-6)]]).astype(np.float32)
def _bbox(self, x):
cx, cy = float(x[0].item()), float(x[1].item())
s, r = float(x[2].item()), float(x[3].item())
w = np.sqrt(max(s * r, 1e-6)); h = s / (w + 1e-6)
2026-03-18 17:45:30 +00:00
return [cx-w/2., cy-h/2., cx+w/2., cy+h/2.]
2026-06-25 17:06:15 +00:00
@property
def confianza_fisica(self):
return min(1.0, self.frames_continuos / 20.0)
def calcular_score_calidad(self, firma):
if firma is None: return 0.0
score_nitidez = min(firma.get('nitidez', 0) / 100.0, 1.0) * 50.0
score_area = min(firma.get('calidad', 0) / 12000.0, 1.0) * 50.0
return score_nitidez + score_area
def actualizar_ema(self, firma):
if firma is None: return
self.muestras_capturadas = getattr(self, 'muestras_capturadas', 0) + 1
score_nuevo = self.calcular_score_calidad(firma)
score_viejo = getattr(self, 'score_ema', 0.0)
if self.firma_ema is None:
# Primera impresión (puede ser mala, pero es lo único que tenemos)
self.firma_ema = {k: v.copy() if isinstance(v, np.ndarray) else v for k, v in firma.items()}
self.score_ema = score_nuevo
return
2026-06-25 17:06:15 +00:00
# ⚡ PROTECCIÓN DE MEMORIA (Anti-polución)
if self.muestras_capturadas > 4 and score_nuevo < (score_viejo * 0.60):
return
2026-06-25 17:06:15 +00:00
# ─────────────────────────────────────────────────────────────
# ⚡ ASIMILACIÓN DINÁMICA (La cura a la mala primera impresión)
# ─────────────────────────────────────────────────────────────
# Si estamos en los primeros 5 frames, O la nueva foto es notoriamente mejor
# que nuestro mejor recuerdo (>20% mejor), somos una "esponja".
if self.muestras_capturadas <= 5 or score_nuevo > (score_viejo * 1.20):
# Alpha bajo = Sobrescribe agresivamente el pasado
alpha_deep = 0.20 # 20% vieja, 80% NUEVA
alpha_color = 0.20
alpha_geo = 0.50
self.score_ema = score_nuevo # Actualizamos nuestro estándar de calidad
else:
# Estado de madurez: La firma ya es excelente y estable.
# Los cambios deben ser muy lentos para no arruinarla.
alpha_deep = 0.85 # 85% vieja, 15% nueva
alpha_color = 0.50
alpha_geo = 0.80
self.score_ema = max(score_nuevo, score_viejo)
ema = self.firma_ema
2026-06-25 17:06:15 +00:00
if 'deep' in firma:
ema['deep'] = alpha_deep * ema['deep'] + (1-alpha_deep) * firma['deep']
n = np.linalg.norm(ema['deep'])
if n > 0: ema['deep'] /= n
2026-06-25 17:06:15 +00:00
# Ya sin la textura LBP que eliminamos por rendimiento
for key in ['color']:
if key in firma and firma[key] is not None:
ema[key] = alpha_color * ema[key] + (1-alpha_color) * firma[key]
normed = cv2.normalize(ema[key], None, alpha=1.0, norm_type=cv2.NORM_L1)
ema[key] = normed.flatten()
if 'anatomia' in firma and firma['anatomia'] is not None:
if 'anatomia' not in ema or ema['anatomia'] is None:
ema['anatomia'] = np.array(firma['anatomia'], dtype=np.float32)
else:
ema['anatomia'] = alpha_geo * np.asarray(ema['anatomia']) + (1-alpha_geo) * np.asarray(firma['anatomia'])
if 'calidad' in firma:
ema['calidad'] = max(ema.get('calidad', 0), firma['calidad'])
def predict(self, turno_activo=True):
if self.time_since_update > 30: return None
if getattr(self, 'en_grupo', False):
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._bbox(self.kf.statePre)
return self.box
if self.time_since_update > 0:
self.kf.statePost[4] *= 0.5
self.kf.statePost[5] *= 0.5
self.kf.statePost[6] = 0.0
self.frames_continuos = max(0, self.frames_continuos - 1)
if (self.kf.statePost[6] + self.kf.statePost[2]) <= 0: self.kf.statePost[6] = 0.0
2026-03-18 17:45:30 +00:00
self.kf.predict()
2026-03-18 18:27:26 +00:00
if turno_activo: self.time_since_update += 1
2026-06-25 17:06:15 +00:00
self.aprendiendo = False
self.box = self._bbox(self.kf.statePre)
return self.box
2026-03-18 17:45:30 +00:00
2026-06-25 17:06:15 +00:00
def update(self, box, en_grupo, now, kpts=None, firma_actual=None):
2026-03-18 17:45:30 +00:00
self.ts_ultima_deteccion = now
2026-06-25 17:06:15 +00:00
self.time_since_update, self.en_grupo = 0, en_grupo
2026-03-18 18:27:26 +00:00
self.box = list(box)
2026-06-25 17:06:15 +00:00
self.kf.correct(self._z(box))
if not en_grupo:
self.frames_observados += 1
self.frames_continuos += 1
self.listo_para_id = True
def desglose_similitud(f1, f2, cross_cam=False):
from scipy.spatial.distance import cosine
import numpy as np
import cv2
sim_deep = 0.0
deep1, deep2 = f1.get('deep'), f2.get('deep')
if deep1 is not None and deep2 is not None and np.sum(deep1) > 0 and np.sum(deep2) > 0:
sim_deep = max(0.0, 1.0 - cosine(deep1, deep2))
sim_color = -1.0
c1, c2 = f1.get("color"), f2.get("color")
if c1 is not None and c2 is not None and c1.shape == c2.shape and len(c1) == 1536:
if np.sum(c1) > 0.1 and np.sum(c2) > 0.1:
dist1 = cv2.compareHist(c1[:512].astype(np.float32), c2[:512].astype(np.float32), cv2.HISTCMP_BHATTACHARYYA)
dist2 = cv2.compareHist(c1[512:1024].astype(np.float32), c2[512:1024].astype(np.float32), cv2.HISTCMP_BHATTACHARYYA)
dist3 = cv2.compareHist(c1[1024:].astype(np.float32), c2[1024:].astype(np.float32), cv2.HISTCMP_BHATTACHARYYA)
s1 = max(0.0, 1.0 - float(dist1))
s2 = max(0.0, 1.0 - float(dist2))
s3 = max(0.0, 1.0 - float(dist3))
# ⚡ Escudo chamarra abierta original
if cross_cam and s3 > 0.60 and s2 < 0.35:
s2 = max(s2, s3 * 0.80)
sim_color = (s1 * 0.20) + (s2 * 0.50) + (s3 * 0.30)
sim_anat = -1.0
a1, a2 = f1.get("anatomia"), f2.get("anatomia")
if a1 is not None and a2 is not None and hasattr(a1, "shape") and hasattr(a2, "shape") and a1.shape == a2.shape:
dist_a = np.linalg.norm(a1 - a2)
sim_anat = max(0.0, 1.0 - dist_a)
2026-03-18 17:45:30 +00:00
2026-06-25 17:06:15 +00:00
return sim_deep, sim_color, sim_anat
2026-03-18 17:45:30 +00:00
# ──────────────────────────────────────────────────────────────────────────────
2026-06-25 17:06:15 +00:00
# MEMORIA GLOBAL CON PERSISTENCIA
2026-03-18 17:45:30 +00:00
# ──────────────────────────────────────────────────────────────────────────────
class GlobalMemory:
def __init__(self):
2026-03-18 18:27:26 +00:00
self.db = {}
2026-06-25 17:06:15 +00:00
self.mejores_rostros = {}
2026-03-18 17:45:30 +00:00
self.next_gid = 100
self.lock = threading.RLock()
2026-06-25 17:06:15 +00:00
self.ruta_memoria = os.path.join("cache_nombres", "memoria_ids.json")
self.ruta_movimientos = os.path.join("cache_nombres", "registro_movimientos.csv")
os.makedirs("cache_nombres", exist_ok=True)
self.nombres_activos = {}
self.ultimos_saludos = {}
self.fecha_actual = datetime.now().date()
self.reserva_temporal = {}
self.id_locks = {}
self.ultimas_detecciones = {}
2026-06-25 17:06:15 +00:00
# 1. Cargamos la memoria base (que ya limpia si es otro día)
self._cargar_memoria()
# 2. ⚡ REINICIO DIARIO INTELIGENTE: Buscamos el ID más alto de HOY en el CSV
# Esto evita que si apagaste el motor a las 3 PM, vuelva a empezar en 100 y cicle los IDs.
try:
2026-06-25 17:06:15 +00:00
import csv
fecha_hoy_str = datetime.now().strftime('%Y-%m-%d')
if os.path.exists(self.ruta_movimientos):
with open(self.ruta_movimientos, 'r', encoding='utf-8') as f:
for row in csv.reader(f):
# Buscamos filas que sean de HOY y cuyo nombre empiece con "ID "
if len(row) > 1 and fecha_hoy_str in row[0] and str(row[1]).startswith("ID "):
try:
val = int(str(row[1]).replace("ID ", "").strip())
if val >= self.next_gid:
self.next_gid = val + 1
except: pass
except Exception as e:
print(f"[MEMORIA] Error leyendo CSV histórico: {e}")
def _distancia_topologica(self, cam_o, cam_d):
if str(cam_o) == str(cam_d): return 0
try:
2026-06-25 17:06:15 +00:00
import seguimiento2
vecinos_dict = getattr(seguimiento2, 'VECINOS', {})
vecinos_directos = vecinos_dict.get(str(cam_o), [])
if str(cam_d) in vecinos_directos: return 1
for v in vecinos_directos:
if str(cam_d) in vecinos_dict.get(str(v), []): return 2
except Exception: pass
return 3
def _bonus_reserva(self, gid_evaluado, cam_actual, now):
return 0.0
def _cargar_memoria(self):
self.fecha_actual = datetime.now().date()
if os.path.exists(self.ruta_memoria):
fecha_archivo = datetime.fromtimestamp(os.path.getmtime(self.ruta_memoria)).date()
if fecha_archivo != self.fecha_actual:
print(f"[Memoria] Archivo de ayer ({fecha_archivo}). Iniciando memoria limpia desde ID 100.")
return
try:
2026-06-25 17:06:15 +00:00
with open(self.ruta_memoria, 'r') as f:
datos = json.load(f)
2026-06-25 17:06:15 +00:00
ahora = time.time()
cargados_validos = 0
for gid_str, info in datos.items():
ts_guardado = info.get('ts', 0)
es_vip = info.get('nombre') is not None
if es_vip and (ahora - ts_guardado > 10800.0): continue
if not es_vip and (ahora - ts_guardado > 3600.0): continue
gid = int(gid_str)
galeria_deep = [np.array(g, dtype=np.float32) for g in info.get('galeria_deep', [])]
self.db[gid] = {
'galeria_deep': galeria_deep,
'last_cam': info.get('last_cam', '1'),
'ts': ts_guardado,
'nombre': info.get('nombre'),
'actualizaciones_globales': info.get('actualizaciones_globales', 0)
}
cargados_validos += 1
if cargados_validos > 0:
self.next_gid = max([int(k) for k in datos.keys()] + [99]) + 1
print(f"[Memoria] {cargados_validos} IDs persistentes cargados.")
except Exception as e: pass
ruta_saludos = os.path.join("cache_nombres", "registro_saludos.json")
if os.path.exists(ruta_saludos):
try:
with open(ruta_saludos, 'r') as f: self.ultimos_saludos = json.load(f)
except Exception: pass
def guardar_memoria(self):
try:
datos = {}
for gid, info in self.db.items():
galeria_list = [g.tolist() for g in info.get('galeria_deep', [])]
datos[str(gid)] = {
'galeria_deep': galeria_list,
'last_cam': info.get('last_cam', '1'),
'nombre': info.get('nombre'),
'ts': info.get('ts', time.time()),
'actualizaciones_globales': info.get('actualizaciones_globales', 0)
}
with open(self.ruta_memoria, 'w') as f:
json.dump(datos, f)
except Exception: pass
# ─────────────────────────────────────────────────────────────
# ⚡ MOTOR DE SOLAPAMIENTO (CÁMARAS VECINAS SIMULTÁNEAS)
# ─────────────────────────────────────────────────────────────
# ⚡ FIX 4: RESTAURADAS LAS FUNCIONES CRÍTICAS DE SOLAPAMIENTO Y CLONES
# ─────────────────────────────────────────────────────────────
# ⚡ MOTOR DE SOLAPAMIENTO Y LIMPIEZA
# ─────────────────────────────────────────────────────────────
def registrar_deteccion_global(self, gid, cam_id, firma, now):
with self.lock:
if not hasattr(self, 'buffer_solapamiento'): self.buffer_solapamiento = []
if 'deep' in firma:
self.buffer_solapamiento.append({'gid': gid, 'cam': str(cam_id), 'firma': firma['deep'], 'ts': now})
self.buffer_solapamiento = [x for x in self.buffer_solapamiento if now - x['ts'] < 3.0]
def buscar_coincidencia_solapada(self, firma, cam_id, now, umbral_sim=0.72):
if 'deep' not in firma: return None
with self.lock:
if not hasattr(self, 'buffer_solapamiento') or not self.buffer_solapamiento: return None
from scipy.spatial.distance import cosine
mejor_sim, mejor_gid = -1.0, None
for item in self.buffer_solapamiento:
if item['cam'] == str(cam_id): continue
sim = 1.0 - cosine(firma['deep'], item['firma'])
if sim > mejor_sim: mejor_sim, mejor_gid = sim, item['gid']
if mejor_sim >= umbral_sim:
print(f" [ZONA SOLAPADA] Cam {cam_id} hereda ID {mejor_gid} en tiempo real (Sim: {mejor_sim:.2f})")
return mejor_gid
return None
def consolidar_clones(self):
with self.lock:
gids = list(self.db.keys())
for i in range(len(gids)):
id_a = gids[i]
if 'fusionado_con' in self.db[id_a]: continue
for j in range(i + 1, len(gids)):
id_b = gids[j]
if 'fusionado_con' in self.db[id_b]: continue
nombre_a, nombre_b = self.db[id_a].get('nombre'), self.db[id_b].get('nombre')
# ⚡ Si dos cámaras le dieron IDs distintos pero ArcFace descubrió que son la misma persona:
if nombre_a and nombre_b and nombre_a == nombre_b and nombre_a != "Desconocido":
print(f" [LIMPIEZA] Uniendo clon {id_b} a {id_a} (Confirmado facialmente).")
self.db[id_b]['fusionado_con'] = id_a
self.db[id_a]['galeria_deep'].extend(self.db[id_b].get('galeria_deep', []))
def lock_id_for_camera(self, gid, cam_id, now, duration=15.0):
self.id_locks[gid] = {'cam': str(cam_id), 'unlock_ts': now + duration}
def is_id_locked(self, gid, cam_id, now):
if gid not in self.id_locks: return False
lock = self.id_locks[gid]
if lock['cam'] != str(cam_id): return False
if now < lock['unlock_ts']: return True
else:
del self.id_locks[gid]
return False
def limpiar_locks_vencidos(self, now):
vencidos = [gid for gid, lock in self.id_locks.items() if now >= lock['unlock_ts']]
for gid in vencidos: del self.id_locks[gid]
def guardar_saludo(self, nombre):
2026-06-25 17:06:15 +00:00
ahora = time.time()
self.ultimos_saludos[nombre] = {'fecha': datetime.fromtimestamp(ahora).strftime("%Y-%m-%d"), 'timestamp': ahora}
ruta_saludos = os.path.join("cache_nombres", "registro_saludos.json")
try:
with open(ruta_saludos, 'w') as f: json.dump(self.ultimos_saludos, f, indent=4)
except Exception: pass
def registrar_movimiento(self, nombre, cam_origen, cam_destino, duracion, gid=None):
ahora = time.time()
fecha_str = datetime.fromtimestamp(ahora).strftime('%Y-%m-%d %H:%M:%S')
# ⚡ SOLUCIÓN: Sacamos la fecha actual para vincularla a la foto
fecha_hoy = datetime.fromtimestamp(ahora).strftime('%Y-%m-%d')
conf_reid = 0.0
conf_facial = 0.0
# 1. Aseguramos tener el ID correcto
gid_str = str(gid) if gid else "0"
with self.lock:
if gid and gid in self.db:
conf_reid = self.db[gid].get('ultimo_score_reid', 0.0)
conf_facial = self.db[gid].get('confianza_bautizo', 0.0)
# Convertimos "Desconocido" en "ID 105"
if not nombre or nombre == "Desconocido":
nombre = f"ID {gid}"
# 2. ⚡ ESTAMPADO DE FECHA: La ruta ahora exige que la foto tenga la fecha del día
# Ej: 100_cam6_2026-06-24.jpg
ruta_imagen = os.path.join("cache_nombres", "auditoria_caras", f"{gid_str}_cam{cam_destino}_{fecha_hoy}.jpg")
if nombre and not nombre.startswith("ID "):
ruta_nombre = os.path.join("cache_nombres", "auditoria_caras", f"{nombre}_cam{cam_destino}_{fecha_hoy}.jpg")
if os.path.exists(ruta_nombre):
ruta_imagen = ruta_nombre
elif os.path.exists(os.path.join("cache_nombres", "auditoria_caras", f"{nombre}_cam{cam_origen}_{fecha_hoy}.jpg")):
ruta_imagen = os.path.join("cache_nombres", "auditoria_caras", f"{nombre}_cam{cam_origen}_{fecha_hoy}.jpg")
else:
if not os.path.exists(ruta_imagen) and os.path.exists(os.path.join("cache_nombres", "auditoria_caras", f"{gid_str}_cam{cam_origen}_{fecha_hoy}.jpg")):
ruta_imagen = os.path.join("cache_nombres", "auditoria_caras", f"{gid_str}_cam{cam_origen}_{fecha_hoy}.jpg")
# 3. Escribimos en el CSV sin censurar a nadie
try:
import csv
file_exists = os.path.isfile(self.ruta_movimientos)
with open(self.ruta_movimientos, mode='a', newline='') as f:
writer = csv.writer(f)
if not file_exists:
writer.writerow(['fecha', 'nombre', 'cam_origen', 'cam_destino', 'duracion_seg', 'certeza_reid', 'certeza_facial', 'imagen_rostro'])
writer.writerow([fecha_str, nombre, str(cam_origen), str(cam_destino), round(duracion, 2), round(conf_reid, 1), round(conf_facial, 1), ruta_imagen])
except Exception: pass
def _actualizar_sin_lock(self, gid, firma, cam_id, now):
if gid not in self.db:
self.db[gid] = {
'galeria_deep': [],
'color_promedio': None,
'last_cam': cam_id,
'ts': now,
'nombre': None,
'actualizaciones_globales': 1,
'votos_nombre': 0
}
rec = self.db[gid]
rec['actualizaciones_globales'] += 1
# ─────────────────────────────────────────────────────────────
# ⚡ SOLUCIÓN PARTE 1: EL ANCLA DE HIERRO (Anti-Mutación)
# ─────────────────────────────────────────────────────────────
galeria = rec.get('galeria_deep', [])
if 'deep' in firma:
if not galeria:
# La primera firma es el Ancla (Índice 0). JAMÁS SE BORRA.
galeria.append(firma['deep'])
else:
from scipy.spatial.distance import cosine
sim_con_ancla = 1.0 - cosine(firma['deep'], galeria[0])
sim_max = max([1.0 - cosine(firma['deep'], g) for g in galeria])
# Exigimos 70% de similitud con el Ancla original.
# Si es un extraño (ej. 0.58), será ignorado y forzado a tener un ID nuevo.
if sim_con_ancla >= 0.68 and sim_max <= 0.88:
galeria.append(firma['deep'])
# Siempre borramos el índice 1, NUNCA el 0 (para no borrar el Ancla)
if len(galeria) > 5: galeria.pop(1)
rec['galeria_deep'] = galeria
2026-06-25 17:06:15 +00:00
if 'color' in firma and firma['color'] is not None:
if rec.get('color_promedio') is None:
rec['color_promedio'] = firma['color']
else:
import cv2
rec['color_promedio'] = 0.6 * rec['color_promedio'] + 0.4 * firma['color']
rec['color_promedio'] = cv2.normalize(rec['color_promedio'], None, 1.0, cv2.NORM_L1).flatten()
rec['last_cam'] = cam_id
rec['ts'] = now
def _sim_contra_firma(self, firma_nueva, firma_guardada, cross_cam, confianza_fisica):
if firma_guardada is None: return 0.0
return similitud_hibrida(firma_nueva, firma_guardada, cross_cam, confianza_fisica)
def _sim_robusta(self, firma_nueva, gid_data, cross_cam, confianza_fisica=0.0):
ema = gid_data.get('ema')
if not ema: return 0.0
sim_ema = self._sim_contra_firma(firma_nueva, ema, cross_cam, confianza_fisica)
if sim_ema > 0.75: return sim_ema # Si es muy alta, atajo rápido
# ⚡ MAGIA RESTAURADA: Evaluación contra las anclas VIP cristalizadas
sim_anclas = [sim_ema]
for ancla in gid_data.get('firmas_altas', []):
sim_anclas.append(self._sim_contra_firma(firma_nueva, ancla, cross_cam, confianza_fisica))
mejor_ancla = max(sim_anclas[1:]) if len(sim_anclas) > 1 else sim_ema
return sim_ema * 0.60 + mejor_ancla * 0.40
def identificar_candidato(self, firma, cam_id, now, active_gids, en_borde=False, firma_ema_local=None, confianza_fisica=0.0):
self.limpiar_fantasmas()
2026-06-25 17:06:15 +00:00
from scipy.spatial.distance import cosine
import cv2
import numpy as np
2026-03-18 18:27:26 +00:00
2026-03-18 17:45:30 +00:00
with self.lock:
2026-06-25 17:06:15 +00:00
# ⚡ 0. INICIALIZAMOS LA LISTA PRINCIPAL (Evita el UnboundLocalError)
candidatos = []
2026-06-25 17:06:15 +00:00
# ─────────────────────────────────────────────────────────────
# ⚡ 1. RESCATE INSTANTÁNEO Y SOLAPAMIENTO (Inercia Táctica)
# ─────────────────────────────────────────────────────────────
candidatos_rescate = []
if 'deep' in firma:
for gid, data in self.db.items():
if 'fusionado_con' in data: continue
last_cam = str(data.get('last_cam'))
misma_cam = (last_cam == str(cam_id))
# No hereda el ID si la persona original ya está en esta misma cámara
if gid in active_gids and misma_cam: continue
dt = now - data.get('ts', now)
if dt < 15.0:
distancia = self._distancia_topologica(last_cam, str(cam_id))
galeria = data.get('galeria_deep', [])
if galeria:
sim = max([1.0 - cosine(firma['deep'], g) for g in galeria])
# Filtro express: aborta si la ropa es opuesta (Ej. blanco vs negro)
if 'color' in firma and firma['color'] is not None and data.get('color_promedio') is not None:
try:
sim_color_rapida = cv2.compareHist(firma['color'].astype(np.float32), data['color_promedio'].astype(np.float32), cv2.HISTCMP_CORREL)
if sim_color_rapida < 0.15: continue
except: pass
umbral_rescate = 0.72 if (distancia <= 1) else 0.85
if sim > umbral_rescate:
candidatos_rescate.append((sim, gid))
if candidatos_rescate:
candidatos_rescate.sort(reverse=True)
best_sim, best_gid = candidatos_rescate[0]
self._actualizar_sin_lock(best_gid, firma, cam_id, now)
print(f" [INERCIA] ID {best_gid} rescatado instantáneamente en Cam {cam_id} (Sim: {best_sim:.2f})")
return best_gid, True
# ─────────────────────────────────────────────────────────────
# ⚡ 2. RE-ID HÍBRIDO NORMAL (Bucle principal)
# ─────────────────────────────────────────────────────────────
try: TIEMPO_MAX = TIEMPO_MAX_AUSENCIA
except: TIEMPO_MAX = 3600
2026-03-18 17:45:30 +00:00
for gid, data in self.db.items():
2026-06-25 17:06:15 +00:00
if 'fusionado_con' in data: continue
2026-06-25 17:06:15 +00:00
dt = now - data.get('ts', now)
last_cam = str(data.get('last_cam'))
cam_actual = str(cam_id)
distancia = self._distancia_topologica(last_cam, cam_actual)
misma_cam = (distancia == 0)
es_vecino = (distancia == 1)
es_salto = (distancia == 2)
es_cross = not misma_cam
if gid in active_gids:
if misma_cam or distancia >= 3: continue
if dt > TIEMPO_MAX or data.get('ema') is None: continue
if self.is_id_locked(gid, cam_id, now): continue
# ⚡ Barrera Física Espacial Restaurada
if not misma_cam:
if (es_vecino and dt < 0.3) or (es_salto and dt < 2.0) or (distancia >= 3 and dt < 8.0):
continue
# ─────────────────────────────────────────────────────────────
# ⚡ 3. TRIBUNAL DE SIMILITUD ROBUSTA (Tu arquitectura original)
# ─────────────────────────────────────────────────────────────
# Calculamos el peso contra TODA la historia de la persona
sim = self._sim_robusta(firma, data, es_cross, confianza_fisica)
# Desglose para logs y veto
sim_deep, sim_color, sim_anat = -1.0, -1.0, -1.0
ema = data.get("ema")
if ema is not None:
try: sim_deep, sim_color, sim_anat = desglose_similitud(firma, ema, cross_cam=es_cross)
except: pass
# El Veto de Color ahora usa el cálculo Bhattacharyya hiper-preciso
color_alert = ""
sim = sim_deep
if sim_color >= 0:
if sim_color < 0.15:
# ⚡ SALVAVIDAS: Si el cuerpo y la anatomía juran que eres tú, perdonamos la sombra del pasillo.
if sim_deep > 0.65 and sim_anat > 0.65:
sim -= 0.08 # Castigo muy leve
color_alert = " [VETO MITIGADO!]"
else:
sim -= 0.30 # Castigo brutal a los verdaderos impostores
color_alert = " [VETO COLOR!]"
elif sim_color >= 0.50:
sim += 0.05
color_alert = " [BONO COLOR]"
if not misma_cam:
if es_salto and dt < 1.5: continue
if distancia >= 3 and dt < 5.0: continue
if firma_ema_local is not None:
sim_local = self._sim_robusta(firma_ema_local, data, es_cross, confianza_fisica)
sim = 0.60 * sim + 0.40 * sim_local
# ─────────────────────────────────────────────────────────────
# ⚡ VALIDACIONES VIP (Escudo Anti-Suplantación)
# ─────────────────────────────────────────────────────────────
es_vip = data.get('nombre') is not None
tiene_firmas_altas = len(data.get('firmas_altas', [])) > 0
if es_vip and tiene_firmas_altas and sim < 0.78: continue
elif es_vip and not misma_cam and sim < 0.75: continue
2026-06-25 17:06:15 +00:00
# ─────────────────────────────────────────────────────────────
# ⚡ 4. UMBRALES DE CRUCE (Los originales, altos y seguros)
# ─────────────────────────────────────────────────────────────
penal_multitud = min(0.04, max(0, len(active_gids)-10) * 0.002)
2026-06-25 17:06:15 +00:00
if misma_cam: umbral = 0.68
elif es_vecino: umbral = 0.72
elif es_salto: umbral = 0.75
else: umbral = 0.78
if sim_anat > 0.90 and sim_deep > 0.65:
umbral -= 0.04
color_alert += " [+ANAT]"
if en_borde and not misma_cam: umbral -= 0.04
umbral = max(0.65, umbral + penal_multitud)
# Bonus si el sistema previno su salida
bonus_aplicado = min(self._bonus_reserva(gid, cam_id, now) if hasattr(self, '_bonus_reserva') else 0.0, 0.03)
sim += bonus_aplicado
if sim >= umbral - 0.08 or len(candidatos) < 3:
estado = "ACEPTADO" if sim >= umbral else "RECHAZADO"
faltante = f"(Faltó {umbral - sim:.2f})" if sim < umbral else ""
color_str = f"{sim_color:.2f}" if sim_color >= 0 else "N/A"
anat_str = f"{sim_anat:.2f}" if sim_anat >= 0 else "N/A"
print(f"[EVAL] Cam{cam_id} evalúa ID{gid} | Deep={sim_deep:.2f} | Color={color_str} | Anat={anat_str}{color_alert} | Final={sim:.2f} | Umb={umbral:.2f} {faltante} -> {estado}")
if sim >= umbral:
candidatos.append((sim, sim, gid))
# ─────────────────────────────────────────────────────────────
# ⚡ 5. RESOLUCIÓN FINAL
# ─────────────────────────────────────────────────────────────
firma_guardar = firma_ema_local if firma_ema_local is not None else firma
if not candidatos:
2026-06-25 17:06:15 +00:00
nid = self.next_gid
self.next_gid += 1
self._actualizar_sin_lock(nid, firma_guardar, cam_id, now)
threading.Thread(
target=self.registrar_movimiento,
args=(f"ID {nid}", cam_id, cam_id, 0.0, nid),
daemon=True
).start()
2026-03-18 17:45:30 +00:00
return nid, False
candidatos.sort(reverse=True)
2026-06-25 17:06:15 +00:00
best_sim, _, best_gid = candidatos[0]
cam_anterior = self.db[best_gid].get('last_cam')
nombre_registrado = self.db[best_gid].get('nombre')
if cam_anterior and str(cam_anterior) != str(cam_id):
tiempo_origen = now - self.db[best_gid].get('ts', now)
nombre_usar = nombre_registrado if nombre_registrado else f"ID {best_gid}"
2026-06-25 17:06:15 +00:00
threading.Thread(
target=self.registrar_movimiento,
args=(nombre_usar, cam_anterior, cam_id, tiempo_origen, best_gid),
daemon=True
).start()
self._actualizar_sin_lock(best_gid, firma_guardar, cam_id, now)
self.db[best_gid]['ultimo_score_reid'] = best_sim * 100
return best_gid, True
2026-06-25 17:06:15 +00:00
2026-03-18 17:45:30 +00:00
def limpiar_fantasmas(self):
2026-06-25 17:06:15 +00:00
ahora = time.time()
fecha_hoy = datetime.fromtimestamp(ahora).date()
with self.lock:
2026-06-25 17:06:15 +00:00
if fecha_hoy != self.fecha_actual:
self.db.clear()
self.nombres_activos.clear()
self.next_gid = 100
self.fecha_actual = fecha_hoy
self.guardar_memoria()
return
muertos = []
for g, d in self.db.items():
if d.get('nombre') is None and (ahora - d.get('ts', ahora)) > 3600:
muertos.append(g)
elif d.get('nombre') is not None and (ahora - d.get('ts', ahora)) > 10800:
muertos.append(g)
2026-06-25 17:06:15 +00:00
for g in muertos:
nombre_perdido = self.db[g].get('nombre')
if nombre_perdido and nombre_perdido in self.nombres_activos:
del self.nombres_activos[nombre_perdido]
del self.db[g]
2026-06-25 17:06:15 +00:00
if muertos:
self.guardar_memoria()
2026-03-18 17:45:30 +00:00
# ──────────────────────────────────────────────────────────────────────────────
2026-06-25 17:06:15 +00:00
# GESTOR LOCAL
2026-03-18 17:45:30 +00:00
# ──────────────────────────────────────────────────────────────────────────────
2026-06-25 17:06:15 +00:00
def iou_overlap(A, B):
xA,yA = max(A[0],B[0]),max(A[1],B[1])
xB,yB = min(A[2],B[2]),min(A[3],B[3])
inter = max(0,xB-xA)*max(0,yB-yA)
return inter/((A[2]-A[0])*(A[3]-A[1])+(B[2]-B[0])*(B[3]-B[1])-inter+1e-6)
2026-03-18 17:45:30 +00:00
class CamManager:
def __init__(self, cam_id, global_mem):
2026-03-18 18:27:26 +00:00
self.cam_id, self.global_mem, self.trackers = cam_id, global_mem, []
2026-06-25 17:06:15 +00:00
self.ultimas_detecciones_globales = {}
self.tracks_post_cruce = {}
def _limpiar_tracks_post_cruce(self, now):
#Limpia registros de post-cruce antiguos.
antiguos = [idx for idx, info in self.tracks_post_cruce.items() if now - info['ts_cruce'] > 10.0]
for idx in antiguos:
del self.tracks_post_cruce[idx]
def _detectar_grupo(self, trk, box, todos):
x1,y1,x2,y2 = box
w,h = x2-x1, y2-y1
cx,cy = (x1+x2)/2, (y1+y2)/2
fx, fy = (1.1, 0.45) if getattr(trk,'en_grupo',False) else (0.9, 0.35)
for o in todos:
if o is trk or not hasattr(o,'box') or o.box is None or o.time_since_update > 2: continue
if iou_overlap(box, o.box) > 0.05: return True
ocx,ocy = (o.box[0]+o.box[2])/2, (o.box[1]+o.box[3])/2
if abs(cx-ocx)<w*fx and abs(cy-ocy)<h*fy: return True
return False
2026-06-25 17:06:15 +00:00
def _gestionar_post_grupo(self, now, frame_hd):
for trk in self.trackers:
2026-06-25 17:06:15 +00:00
if trk.gid is None or trk.en_grupo or getattr(trk,'firma_pre_grupo',None) is None or now - getattr(trk,'ts_salio_grupo',0) < 1.2: continue
fa = extraer_firma_hibrida(frame_hd, trk.box)
if fa is None: continue
sim = similitud_hibrida(fa, trk.firma_pre_grupo, confianza_fisica=trk.confianza_fisica)
if sim >= 0.48:
trk.fallos_post_grupo = 0; trk.firma_pre_grupo = None
with self.global_mem.lock:
if trk.gid in self.global_mem.db: self.global_mem.db[trk.gid]['ema'] = fa
else:
trk.fallos_post_grupo += 1
if trk.fallos_post_grupo >= 3:
trk.gid = trk.firma_pre_grupo = None; trk.fallos_post_grupo = 0
def _asignar(self, boxes, confidences, frame_hd, now, keypoints_list):
n_trk, n_det = len(self.trackers), len(boxes)
firmas = [None] * n_det
if n_det > 0:
rois_validos = []
mapa_rois = {}
h_hd, w_hd = frame_hd.shape[:2]
2026-06-25 17:06:15 +00:00
for d_idx, box in enumerate(boxes):
x1, y1, x2, y2 = box
x1_c = max(0, int(x1 * (w_hd/480.0)))
y1_c = max(0, int(y1 * (h_hd/270.0)))
x2_c = min(w_hd, int(x2 * (w_hd/480.0)))
y2_c = min(h_hd, int(y2 * (h_hd/270.0)))
roi = frame_hd[y1_c:y2_c, x1_c:x2_c]
if roi.size > 0 and roi.shape[0] >= 40 and roi.shape[1] >= 20:
mapa_rois[d_idx] = len(rois_validos)
rois_validos.append(roi)
2026-06-25 17:06:15 +00:00
if rois_validos:
deep_feats_seguros = extraer_features_osnet_seguro(rois_validos)
2026-06-25 17:06:15 +00:00
for d_idx, box in enumerate(boxes):
if d_idx in mapa_rois:
df_persona = deep_feats_seguros[mapa_rois[d_idx]]
kpts = keypoints_list[d_idx] if keypoints_list and d_idx < len(keypoints_list) else None
firmas[d_idx] = extraer_firma_hibrida(frame_hd, box, kpts, deep_feat=df_persona)
def obtener_firma(d_idx):
return firmas[d_idx]
if n_trk == 0: return [], list(range(n_det)), [], firmas
if n_det == 0: return [], [], list(range(n_trk)), firmas
alta = [(d,boxes[d]) for d,c in enumerate(confidences) if c >= 0.45]
baja = [(d,boxes[d]) for d,c in enumerate(confidences) if c < 0.45]
matched, sin_match_trk = [], list(range(n_trk))
if alta:
C = np.full((n_trk, len(alta)), 100.0, np.float32)
for t, trk in enumerate(self.trackers):
dt_oculto = now - trk.ts_ultima_deteccion
radio = min(400., max(150., 300.*math.sqrt(max(0.1,dt_oculto))))
ref_firma = trk.firma_ema or (self.global_mem.db.get(trk.gid, {}).get('ema') if trk.gid else None)
for idx, (d, det) in enumerate(alta):
iou = iou_overlap(trk.box, det)
dist = math.sqrt(((trk.box[0]+trk.box[2])/2-(det[0]+det[2])/2)**2 + ((trk.box[1]+trk.box[3])/2-(det[1]+det[3])/2)**2)
if dist > radio: continue
a_trk = (trk.box[2]-trk.box[0])*(trk.box[3]-trk.box[1])
a_det = (det[2]-det[0])*(det[3]-det[1])
costo = (1.0*(1-iou)) + (0.3*(dist/radio)) + ((max(a_trk,a_det)/(min(a_trk,a_det)+1e-6)-1)*0.1)
costo += (abs((trk.box[3]-trk.box[1])/(trk.box[2]-trk.box[0]+1e-6) - (det[3]-det[1])/(det[2]-det[0]+1e-6))*0.1)
costo += min(0.3, dt_oculto*0.1)
if ref_firma is not None and obtener_firma(d) is not None:
sim_ap = similitud_hibrida(ref_firma, firmas[d], cross_cam=False, confianza_fisica=trk.confianza_fisica)
2026-06-25 17:06:15 +00:00
if getattr(trk, 'en_grupo', False):
if sim_ap < 0.40:
costo += 5.0 # Castigo suave en oclusiones, nos aferramos a la física de Kalman
else:
costo += (1.0 - sim_ap) * 0.5
else:
2026-06-25 17:06:15 +00:00
if sim_ap < 0.45:
costo += 50.0
else:
costo += (1.0 - sim_ap) * 1.5
nombre_vip = self.global_mem.db.get(trk.gid, {}).get('nombre')
if nombre_vip and not getattr(trk, 'en_grupo', False):
umbral_proteccion = 0.65
if sim_ap < umbral_proteccion:
costo += (umbral_proteccion - sim_ap) * 5.0
C[t,idx] = costo
for r,c in zip(*linear_sum_assignment(C)):
if C[r,c] <= 6.5:
matched.append((r, alta[c][0])); sin_match_trk.remove(r)
if sin_match_trk and baja:
C2 = np.ones((len(sin_match_trk), len(baja)), np.float32)
for ti, trk_i in enumerate(sin_match_trk):
for dj, (d,det) in enumerate(baja): C2[ti,dj] = 1.0 - iou_overlap(self.trackers[trk_i].box, det)
nuevos_sin = []
for r,c in zip(*linear_sum_assignment(C2)):
if C2[r,c] < 0.72: matched.append((sin_match_trk[r], baja[c][0]))
else: nuevos_sin.append(sin_match_trk[r])
sin_match_trk = nuevos_sin
return matched, [d for d in range(n_det) if d not in [m[1] for m in matched] and confidences[d] >= 0.52], sin_match_trk, firmas
def update(self, boxes, confidences, keypoints, frame_show, frame_hd, now, turno_activo):
# ─────────────────────────────────────────────────────────────
# 1. Resolver fusiones globales
# ─────────────────────────────────────────────────────────────
for trk in self.trackers:
2026-06-25 17:06:15 +00:00
if trk.gid:
with self.global_mem.lock:
2026-06-25 17:06:15 +00:00
d = self.global_mem.db.get(trk.gid)
if d and 'fusionado_con' in d:
trk.gid = d['fusionado_con']
# ─────────────────────────────────────────────────────────────
# 2. Predict y filtrar tracks muertos por Kalman
# ─────────────────────────────────────────────────────────────
self.trackers = [t for t in self.trackers if t.predict(turno_activo) is not None]
if not turno_activo:
return self.trackers
# ─────────────────────────────────────────────────────────────
# 3. Asignar detecciones a tracks existentes
# ─────────────────────────────────────────────────────────────
matched, new_dets, _, firmas = self._asignar(boxes, confidences, frame_hd, now, keypoints)
active_gids = {t.gid for t in self.trackers if t.gid is not None}
2026-06-25 17:06:15 +00:00
fh, fw = frame_hd.shape[:2]
# ─────────────────────────────────────────────────────────────
# 4. DETECCIÓN DE CRUCES + Registro para recuperación post-cruce
# ─────────────────────────────────────────────────────────────
tracks_en_cruce_locals = set()
for i in range(len(self.trackers)):
for j in range(i + 1, len(self.trackers)):
trk_i, trk_j = self.trackers[i], self.trackers[j]
if trk_i.gid is None or trk_j.gid is None or trk_i.box is None or trk_j.box is None: continue
iou = iou_overlap(trk_i.box, trk_j.box)
if iou > 0.30:
tracks_en_cruce_locals.add(trk_i.local_id)
tracks_en_cruce_locals.add(trk_j.local_id)
if not hasattr(self, 'tracks_post_cruce'): self.tracks_post_cruce = {}
self.tracks_post_cruce[trk_i.local_id] = {'gid_antes': trk_i.gid, 'ts_cruce': now}
self.tracks_post_cruce[trk_j.local_id] = {'gid_antes': trk_j.gid, 'ts_cruce': now}
# ─────────────────────────────────────────────────────────────
# ⚡ 4.5. EXTRACCIÓN GARANTIZADA DE FIRMAS (Fix de Ceguera)
# Asegura que tanto los "matched" como los "nuevos" tengan firma.
# ─────────────────────────────────────────────────────────────
for d_idx in range(len(boxes)):
if firmas[d_idx] is None:
kpts = keypoints[d_idx] if keypoints and d_idx < len(keypoints) else None
firmas[d_idx] = extraer_firma_hibrida(frame_hd, boxes[d_idx], kpts)
# ─────────────────────────────────────────────────────────────
# 5. PROCESAR MATCHES
# ─────────────────────────────────────────────────────────────
2026-03-18 17:45:30 +00:00
for t_idx, d_idx in matched:
2026-06-25 17:06:15 +00:00
trk, box = self.trackers[t_idx], boxes[d_idx]
firma_det = firmas[d_idx]
2026-06-25 17:06:15 +00:00
# Detección de grupo
es_grupo = self._detectar_grupo(trk, box, self.trackers)
if not trk.en_grupo and es_grupo:
with self.global_mem.lock:
trk.firma_pre_grupo = self.global_mem.db.get(trk.gid, {}).get('ema')
trk.ts_salio_grupo = 0.0
2026-06-25 17:06:15 +00:00
elif trk.en_grupo and not es_grupo:
trk.ts_salio_grupo = now
2026-06-25 17:06:15 +00:00
trk.en_grupo = es_grupo
2026-03-18 18:27:26 +00:00
2026-06-25 17:06:15 +00:00
# Update Kalman
trk.update(box, es_grupo, now, kpts=(keypoints[d_idx] if keypoints else None), firma_actual=firma_det)
# Geometría de frame
margen_x = fw * 0.05
esta_en_centro = (box[0] > margen_x and box[2] < (fw - margen_x))
# ─────────────────────────────────────────────────────────
# 5a. APRENDIZAJE EMA (zona segura, centro del frame)
# ─────────────────────────────────────────────────────────
if firma_det is not None and not es_grupo and esta_en_centro:
trk.actualizar_ema(firma_det)
2026-03-18 18:27:26 +00:00
2026-06-25 17:06:15 +00:00
# Nutrir memoria global cada 2 segundos
if trk.gid is not None:
if not hasattr(trk, 'ultimo_update_global'):
trk.ultimo_update_global = 0
if (now - trk.ultimo_update_global) > 2.0:
with self.global_mem.lock:
if trk.gid in self.global_mem.db:
self.global_mem.db[trk.gid]['ema'] = trk.firma_ema.copy()
self.global_mem.db[trk.gid]['ts'] = now
self.global_mem.db[trk.gid]['actualizaciones_globales'] = \
self.global_mem.db[trk.gid].get('actualizaciones_globales', 0) + 1
trk.ultimo_update_global = now
# ─────────────────────────────────────────────────────────
# 5b. RESCATE DE BORDE (Acelerado)
# ─────────────────────────────────────────────────────────
elif firma_det is not None and not es_grupo and not esta_en_centro:
muestras_act = getattr(trk, 'muestras_capturadas', 0)
if muestras_act < 3:
trk.actualizar_ema(firma_det)
elif muestras_act < 5 and trk.frames_observados % 3 == 0:
trk.actualizar_ema(firma_det)
# ─────────────────────────────────────────────────────────
# 5c. IDENTIFICACIÓN CON PROTECCIÓN
# ─────────────────────────────────────────────────────────
if trk.gid is None and trk.listo_para_id and firma_det is not None:
# ⚡ FIX RADICAL: Identificación rápida pero segura.
frames_requeridos = 3 if esta_en_centro else 6
2026-03-18 18:27:26 +00:00
2026-06-25 17:06:15 +00:00
if getattr(trk, 'muestras_capturadas', 0) >= frames_requeridos:
if trk.local_id not in tracks_en_cruce_locals:
tiempo_enfriamiento = now - getattr(trk, 'ultimo_cambio_id', 0)
if tiempo_enfriamiento >= 1.0:
gid_c, es_reid = self.global_mem.identificar_candidato(
firma_det, self.cam_id, now, active_gids,
en_borde=not esta_en_centro,
firma_ema_local=trk.firma_ema,
confianza_fisica=trk.confianza_fisica
)
if gid_c:
active_gids.add(gid_c)
trk.gid = gid_c
trk.origen_global = es_reid
trk.ultimo_cambio_id = now
# ─────────────────────────────────────────────────────────────
# 6. VERIFICACIÓN POST-CRUCE (recuperar ID original tras separarse)
# ─────────────────────────────────────────────────────────────
if hasattr(self, 'tracks_post_cruce') and self.tracks_post_cruce:
tracks_post_cruce_nuevos = {}
2026-06-25 17:06:15 +00:00
for local_id, info in list(self.tracks_post_cruce.items()):
trk = next((t for t in self.trackers if t.local_id == local_id), None)
if trk is None: continue
2026-03-18 18:27:26 +00:00
2026-06-25 17:06:15 +00:00
if now - info['ts_cruce'] > 2.0:
if trk.gid is not None and trk.gid != info['gid_antes']:
if trk.firma_ema is not None and info['gid_antes'] in self.global_mem.db:
ema_original = self.global_mem.db[info['gid_antes']].get('ema')
if ema_original is not None:
sim_recuperacion = similitud_hibrida(
trk.firma_ema, ema_original, cross_cam=False
)
if sim_recuperacion > 0.85:
if info['gid_antes'] not in active_gids:
print(f" [POST-CRUCE] Track {trk.local_id} recuperó ID {info['gid_antes']} (sim={sim_recuperacion:.2f})")
active_gids.discard(trk.gid)
trk.gid = info['gid_antes']
active_gids.add(trk.gid)
continue
2026-06-25 17:06:15 +00:00
tracks_post_cruce_nuevos[local_id] = info
2026-03-18 18:27:26 +00:00
2026-06-25 17:06:15 +00:00
self.tracks_post_cruce = tracks_post_cruce_nuevos
# ─────────────────────────────────────────────────────────────
# 7. CREAR NUEVOS TRACKERS para detecciones sin match
# ─────────────────────────────────────────────────────────────
for d_idx in new_dets:
nt = KalmanTrack(boxes[d_idx], now)
# ⚡ AHORA SÍ RECIBEN FIRMA AL NACER (Gracias al Paso 4.5)
f = firmas[d_idx]
if f:
nt.actualizar_ema(f)
self.trackers.append(nt)
# ─────────────────────────────────────────────────────────────
# 8. LIMPIEZA Y ASESINO DE CLONES
# ─────────────────────────────────────────────────────────────
tracks_vivos = [t for t in self.trackers if (now - t.ts_ultima_deteccion) < 4.0]
tracks_sin_clones = []
for t in tracks_vivos:
es_clon = False
for otro in tracks_sin_clones:
if iou_overlap(t.box, otro.box) > 0.40:
es_clon = True
if getattr(t, 'muestras_capturadas', 0) > getattr(otro, 'muestras_capturadas', 0):
tracks_sin_clones.remove(otro)
tracks_sin_clones.append(t)
break
if not es_clon:
tracks_sin_clones.append(t)
self.trackers = tracks_sin_clones
if hasattr(self, 'tracks_post_cruce'):
local_ids_vivos = {t.local_id for t in self.trackers}
self.tracks_post_cruce = {lid: info for lid, info in self.tracks_post_cruce.items() if lid in local_ids_vivos}
# ─────────────────────────────────────────────────────────────
# 9. Gestión post-grupo
# ─────────────────────────────────────────────────────────────
self._gestionar_post_grupo(now, frame_hd)
return self.trackers
2026-03-18 17:45:30 +00:00
class CamStream:
2026-06-25 17:06:15 +00:00
def __init__(self, url, cam_id):
self.url = url
2026-06-25 17:06:15 +00:00
self.cam_id = str(cam_id)
self.cap = cv2.VideoCapture(url)
2026-06-25 17:06:15 +00:00
self.q = queue.Queue(maxsize=1)
self.stopped = False
2026-06-25 17:06:15 +00:00
self.is_alive = True
self.fallos = 0
2026-03-18 18:27:26 +00:00
2026-06-25 17:06:15 +00:00
# Pantalla de muerte con el número de cámara
self.frame_error = np.zeros((270, 480, 3), dtype=np.uint8)
cv2.putText(self.frame_error, f"CAM {self.cam_id} OFFLINE", (110, 135), cv2.FONT_HERSHEY_SIMPLEX, 1.2, (0, 0, 255), 3)
threading.Thread(target=self._run, daemon=True).start()
2026-03-18 17:45:30 +00:00
def _run(self):
while not self.stopped:
2026-03-18 17:45:30 +00:00
ret, f = self.cap.read()
2026-06-25 17:06:15 +00:00
if not ret:
if self.stopped: break
self.is_alive = False
self.fallos += 1
2026-06-25 17:06:15 +00:00
# Liberamos el socket inmediatamente para que el DVR respire
if self.cap: self.cap.release()
wait_time = min(30, 2 ** self.fallos)
print(f" [ALERTA] Cam {self.cam_id} caída. Reintentando en {wait_time}s...")
# Espera fraccionada para no bloquear la orden de cierre del usuario
for _ in range(wait_time * 10):
if self.stopped: break
time.sleep(0.1)
if self.stopped: break
self.cap = cv2.VideoCapture(self.url)
continue
if self.fallos > 0:
print(f" [INFO] Cam {self.cam_id} RECUPERADA exitosamente.")
self.fallos = 0
self.is_alive = True
if self.q.full():
2026-06-25 17:06:15 +00:00
try: self.q.get_nowait()
except queue.Empty: pass
self.q.put(f)
2026-06-25 17:06:15 +00:00
# La memoria C++ se libera DENTRO de su propio hilo.
if self.cap:
self.cap.release()
@property
def frame(self):
2026-06-25 17:06:15 +00:00
if not self.is_alive: return self.frame_error.copy()
try: return self.q.get(timeout=0.5)
except queue.Empty: return self.frame_error.copy()
2026-06-25 17:06:15 +00:00
def stop(self):
self.stopped = True
2026-03-18 17:45:30 +00:00
def dibujar_track(frame_show, trk):
2026-06-25 17:06:15 +00:00
try: x1,y1,x2,y2 = map(int,trk.box)
except: return
if trk.gid is None: color,label = C_CANDIDATO, f"?{trk.local_id}"
elif getattr(trk,'en_grupo',False): color,label = C_GRUPO, f"ID:{trk.gid}[G]"
elif getattr(trk,'aprendiendo',False): color,label = C_APRENDIZAJE, f"ID:{trk.gid}[+]"
elif getattr(trk,'origen_global',False): color,label = C_GLOBAL, f"ID:{trk.gid}[R]"
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 procesar_rostro_async(roi_cabeza, gid, cam_id, global_mem, trk):
try:
if roi_cabeza is None or roi_cabeza.size == 0: return
with IA_LOCK: faces = app.get(roi_cabeza)
if len(faces) == 0: return
face = max(faces, key=lambda f: (f.bbox[2] - f.bbox[0]) * (f.bbox[3] - f.bbox[1]))
box = face.bbox.astype(int)
w_f, h_f = box[2] - box[0], box[3] - box[1]
if w_f < 20 or h_f < 20: return
emb = np.array(face.normed_embedding, dtype=np.float32)
genero_detectado = "Man" if face.sex == "M" else "Woman"
h_roi, w_roi = roi_cabeza.shape[:2]
m_x, m_y_top, m_y_bot = int(w_f * 0.50), int(h_f * 0.50), int(h_f * 0.70)
y1, y2 = max(0, box[1] - m_y_top), min(h_roi, box[3] + m_y_bot)
x1, x2 = max(0, box[0] - m_x), min(w_roi, box[2] + m_x)
rostro_puro = roi_cabeza[y1:y2, x1:x2]
area_rostro = w_f * h_f
es_mejor_rostro_camara = False
llave_camara = f"{gid}_cam{cam_id}"
with global_mem.lock:
if not hasattr(global_mem, 'mejores_rostros'): global_mem.mejores_rostros = {}
mejor_area_actual = global_mem.mejores_rostros.get(llave_camara, {}).get('area', 0)
if area_rostro > mejor_area_actual:
global_mem.mejores_rostros[llave_camara] = {'area': area_rostro, 'ts': time.time()}
es_mejor_rostro_camara = True
ruta_dir = os.path.join("cache_nombres", "auditoria_caras")
os.makedirs(ruta_dir, exist_ok=True)
fecha_hoy = time.strftime('%Y-%m-%d')
if BASE_DATOS_ROSTROS: mejor_match, max_sim = buscar_mejor_match(emb, BASE_DATOS_ROSTROS)
else: mejor_match, max_sim = None, 0.0
# ─────────────────────────────────────────────────────────────
# ⚡ SISTEMA DE VOTACIÓN COMPRENSIVO (Tolerancia a perfiles)
# ─────────────────────────────────────────────────────────────
puntos_voto = 0
if max_sim >= 0.35: puntos_voto = 3 # Frontal / Claro
elif max_sim >= 0.25: puntos_voto = 2 # Ángulo Ligero
elif max_sim >= 0.18: puntos_voto = 1 # Perfil / Borroso / Luz difícil
with global_mem.lock:
datos_id = global_mem.db.get(gid)
if not datos_id: return
nombre_actual = datos_id.get('nombre')
# ─────────────────────────────────────────────────────────────
# 🧠 SISTEMA DE AUTO-APRENDIZAJE (VALIDACIÓN DE DOBLE LLAVE)
# ─────────────────────────────────────────────────────────────
if nombre_actual and nombre_actual != "Desconocido" and mejor_match:
nombre_limpio = mejor_match.split('_')[0]
# LLAVE 1: OSNet dice que es nombre_actual.
# LLAVE 2: ArcFace dice que el match más cercano es nombre_limpio.
if nombre_actual == nombre_limpio and 0.18 <= max_sim < 0.40:
ahora = time.time()
ultimo_ap = datos_id.get('ultimo_aprendizaje', 0)
# Control de spam: Solo aprende 1 cara cada 10 segundos
if (ahora - ultimo_ap) > 10.0 and rostro_puro.size > 0:
ruta_ap = "db_aprendida"
os.makedirs(ruta_ap, exist_ok=True)
# Guardamos la foto en el disco duro para respaldo
nombre_archivo = f"{nombre_limpio}_auto_{int(ahora)}.jpg"
ruta_guardado = os.path.join(ruta_ap, nombre_archivo)
cv2.imwrite(ruta_guardado, rostro_puro)
# INYECCIÓN EN RAM: Actualizamos la base de datos viva al instante
BASE_DATOS_ROSTROS[nombre_archivo] = emb
datos_id['ultimo_aprendizaje'] = ahora
print(f" 🧠 [APRENDIZAJE] Cam {cam_id}: Nuevo ángulo de {nombre_limpio} aprendido! (Sim base: {max_sim:.2f})")
# Al validar que las llaves empataron, le damos los 3 puntos completos
# para que ArcFace deje de dudar y estabilice el Tracker.
puntos_voto = 3
# --- CASO A: LA IA DUDA ---
if puntos_voto == 0 or not mejor_match:
if nombre_actual and nombre_actual != "Desconocido":
datos_id['votos_nombre'] = datos_id.get('votos_nombre', 0) - 1
if datos_id['votos_nombre'] <= 0:
datos_id['nombre'] = "Desconocido"
datos_id['votos_nombre'] = 0
print(f" [CORRECCIÓN] IA duda del ID {gid}. Pierde el nombre '{nombre_actual}'.")
else:
datos_id['nombre'] = "Desconocido"
if es_mejor_rostro_camara and rostro_puro.size > 0:
cv2.imwrite(os.path.join(ruta_dir, f"{gid}_cam{cam_id}_{fecha_hoy}.jpg"), rostro_puro)
import fision1
fision1.NUEVAS_ALERTAS.append((gid, cam_id, rostro_puro))
return
nombre_limpio = mejor_match.split('_')[0]
# --- CASO B: CONFLICTO DE ROSTROS (El escudo anti-envenenamiento) ---
if nombre_actual and nombre_actual != "Desconocido" and nombre_actual != nombre_limpio:
datos_id['votos_nombre'] = datos_id.get('votos_nombre', 0) - puntos_voto
if datos_id['votos_nombre'] <= 0:
datos_id['nombre'] = "Desconocido"
datos_id['votos_nombre'] = 0
print(f" [CORRECCIÓN] Conflicto facial en ID {gid}. Revertido a Desconocido.")
return
# --- CASO C: CONFIRMACIÓN Y BAUTIZO ---
if es_mejor_rostro_camara and rostro_puro.size > 0:
cv2.imwrite(os.path.join(ruta_dir, f"{nombre_limpio}_cam{cam_id}_{fecha_hoy}.jpg"), rostro_puro)
if nombre_actual == nombre_limpio:
datos_id['votos_nombre'] = min(5, datos_id.get('votos_nombre', 0) + puntos_voto)
return
votos = datos_id.get('votos_nombre', 0) + puntos_voto
datos_id['votos_nombre'] = votos
if votos >= 3:
id_veterano = None
for gid_mem, data_mem in global_mem.db.items():
if data_mem.get('nombre') == nombre_limpio and gid_mem != gid:
id_veterano = gid_mem
break
if id_veterano is not None:
print(f" [FUSIÓN] ArcFace une clon {gid} al original {nombre_limpio} (ID {id_veterano}).")
datos_id['fusionado_con'] = id_veterano
trk.gid = id_veterano
global_mem.db[id_veterano]['ts'] = time.time()
else:
datos_id['nombre'] = nombre_limpio
print(f" [BAUTIZO VIP] Cam {cam_id}: ID {gid} es {nombre_limpio} ({max_sim*100:.1f}%)")
threading.Thread(
target=global_mem.registrar_movimiento,
args=(nombre_limpio, cam_id, cam_id, 0.0, gid),
daemon=True
).start()
if str(cam_id) == "7":
ahora = time.time()
info_saludo = global_mem.ultimos_saludos.get(nombre_limpio, {})
if (ahora - info_saludo.get('timestamp', 0)) > 30:
global_mem.guardar_saludo(nombre_limpio)
threading.Thread(target=hilo_bienvenida, args=(nombre_limpio, genero_detectado), daemon=True).start()
except Exception as e:
print(f" [ERROR ARCFACE] ID {gid}: {str(e)}")
finally:
trk.procesando_rostro = False
def worker_rostros(global_mem):
while True:
roi, gid, cam, trk = COLA_ROSTROS.get()
procesar_rostro_async(roi, gid, cam, global_mem, trk)
COLA_ROSTROS.task_done()
# ──────────────────────────────────────────────────────────────────────────────
# FUNCIÓN DE DIBUJO VISUAL (CON NOMBRES VIP)
# ──────────────────────────────────────────────────────────────────────────────
def dibujar_track_fusion(frame_show, trk, global_mem):
2026-03-18 18:27:26 +00:00
try: x1, y1, x2, y2 = map(int, trk.box)
except Exception: return
2026-06-25 17:06:15 +00:00
nombre_str = ""
# Si tiene ID global, buscamos su nombre en la memoria
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}]"
# Asignación de colores
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}" # VIP = Morado
elif getattr(trk, 'en_grupo', False): color, label = (0, 0, 255), f"ID:{trk.gid} [grp]"
elif getattr(trk, 'aprendiendo', False): color, label = (255, 255, 0), f"ID:{trk.gid} [++]"
elif getattr(trk, 'origen_global', False): color, label = (0, 165, 255), f"ID:{trk.gid} [re-id]"
else: color, label = (0, 255, 0), f"ID:{trk.gid}"
2026-03-18 18:27:26 +00:00
2026-03-18 17:45:30 +00:00
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)
2026-03-18 18:27:26 +00:00
cv2.putText(frame_show, label, (x1+1, y1-4), FUENTE, 0.55, (0,0,0), 1)