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 import csv import math import queue 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 = {} # ────────────────────────────────────────────────────────────────────────────── # CONFIGURACIÓN # ────────────────────────────────────────────────────────────────────────────── USUARIO, PASSWORD, IP_DVR = "admin", "TCA200503", "192.168.1.244" SECUENCIA = [1, 7, 5, 8, 3, 6] # ⚡ 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" URLS = [f"rtsp://{USUARIO}:{PASSWORD}@{IP_DVR}:554/Streaming/Channels/{i}02" for i in SECUENCIA] ONNX_MODEL_PATH = "osnet_x1_0_msmt17_batch1.onnx" VECINOS = { "1": ["7"], "7": ["1", "5"], "5": ["7", "8"], "8": ["5", "3"], "3": ["8", "6"], "6": ["3"] } 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) FUENTE = cv2.FONT_HERSHEY_SIMPLEX # ────────────────────────────────────────────────────────────────────────────── # OSNET # ────────────────────────────────────────────────────────────────────────────── print("Cargando OSNet...") try: 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']) input_name = ort_session.get_inputs()[0].name print("OSNet listo para CPU (Multi-Thread).") except Exception as e: 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) # ────────────────────────────────────────────────────────────────────────────── # CONTROL DE PAGINACIÓN DINÁMICA (Para Comunicación con Interfaz) # ────────────────────────────────────────────────────────────────────────────── # 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 # ────────────────────────────────────────────────────────────────────────────── # 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) h, w = roi.shape[:2] # ⚡ 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) 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) return hist.flatten() 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): try: h_hd, w_hd = frame_hd.shape[:2] x1, y1, x2, y2 = box_480 # 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] 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') 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 # 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) 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)) return float(sim_deep), float(sim_color), float(sim_anat) def similitud_hibrida(f1, f2, cross_cam=False, confianza_fisica=1.0): if f1 is None or f2 is None: return 0.0 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 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") 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 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))) # ────────────────────────────────────────────────────────────────────────────── # KALMAN TRACKER # ────────────────────────────────────────────────────────────────────────────── class KalmanTrack: _count = 0 def __init__(self, box, now): 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 self.box = list(box) self.ts_creacion = self.ts_ultima_deteccion = now self.time_since_update = 0 self.en_grupo = False self.frames_buena_calidad = 0 self.listo_para_id = False self.firma_pre_grupo = None 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) return [cx-w/2., cy-h/2., cx+w/2., cy+h/2.] @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 # ⚡ PROTECCIÓN DE MEMORIA (Anti-polución) if self.muestras_capturadas > 4 and score_nuevo < (score_viejo * 0.60): return # ───────────────────────────────────────────────────────────── # ⚡ 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 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 # 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 self.kf.predict() if turno_activo: self.time_since_update += 1 self.aprendiendo = False self.box = self._bbox(self.kf.statePre) return self.box def update(self, box, en_grupo, now, kpts=None, firma_actual=None): self.ts_ultima_deteccion = now self.time_since_update, self.en_grupo = 0, en_grupo self.box = list(box) 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) return sim_deep, sim_color, sim_anat # ────────────────────────────────────────────────────────────────────────────── # MEMORIA GLOBAL CON PERSISTENCIA # ────────────────────────────────────────────────────────────────────────────── class GlobalMemory: def __init__(self): self.db = {} self.mejores_rostros = {} self.next_gid = 100 self.lock = threading.RLock() 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 = {} # 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: 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: 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: with open(self.ruta_memoria, 'r') as f: datos = json.load(f) 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): 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 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() from scipy.spatial.distance import cosine import cv2 import numpy as np with self.lock: # ⚡ 0. INICIALIZAMOS LA LISTA PRINCIPAL (Evita el UnboundLocalError) candidatos = [] # ───────────────────────────────────────────────────────────── # ⚡ 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 for gid, data in self.db.items(): if 'fusionado_con' in data: continue 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 # ───────────────────────────────────────────────────────────── # ⚡ 4. UMBRALES DE CRUCE (Los originales, altos y seguros) # ───────────────────────────────────────────────────────────── penal_multitud = min(0.04, max(0, len(active_gids)-10) * 0.002) 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: 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() return nid, False candidatos.sort(reverse=True) 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}" 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 def limpiar_fantasmas(self): ahora = time.time() fecha_hoy = datetime.fromtimestamp(ahora).date() with self.lock: 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) 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] if muertos: self.guardar_memoria() # ────────────────────────────────────────────────────────────────────────────── # GESTOR LOCAL # ────────────────────────────────────────────────────────────────────────────── 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) class CamManager: def __init__(self, cam_id, global_mem): self.cam_id, self.global_mem, self.trackers = cam_id, global_mem, [] 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)= 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] 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) if rois_validos: deep_feats_seguros = extraer_features_osnet_seguro(rois_validos) 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) 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: 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: if trk.gid: with self.global_mem.lock: 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} 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 # ───────────────────────────────────────────────────────────── for t_idx, d_idx in matched: trk, box = self.trackers[t_idx], boxes[d_idx] firma_det = firmas[d_idx] # 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 elif trk.en_grupo and not es_grupo: trk.ts_salio_grupo = now trk.en_grupo = es_grupo # 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) # 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 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 = {} 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 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 tracks_post_cruce_nuevos[local_id] = info 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 class CamStream: def __init__(self, url, cam_id): self.url = url self.cam_id = str(cam_id) self.cap = cv2.VideoCapture(url) self.q = queue.Queue(maxsize=1) self.stopped = False self.is_alive = True self.fallos = 0 # 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() def _run(self): while not self.stopped: ret, f = self.cap.read() if not ret: if self.stopped: break self.is_alive = False self.fallos += 1 # 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(): try: self.q.get_nowait() except queue.Empty: pass self.q.put(f) # La memoria C++ se libera DENTRO de su propio hilo. if self.cap: self.cap.release() @property def frame(self): 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() def stop(self): self.stopped = True def dibujar_track(frame_show, trk): 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): try: x1, y1, x2, y2 = map(int, trk.box) except Exception: return 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}" 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)