"""---------------------------------------------version

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

# ──────────────────────────────────────────────────────────────────────────────
#  CONFIGURACIÓN
# ──────────────────────────────────────────────────────────────────────────────
USUARIO, PASSWORD, IP_DVR = "admin", "TCA200503", "192.168.1.244"
SECUENCIA = [1, 7, 5, 8, 3, 6]
os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = "rtsp_transport;tcp|stimeout;3000000"
URLS = [f"rtsp://{USUARIO}:{PASSWORD}@{IP_DVR}:554/Streaming/Channels/{i}02" for i in SECUENCIA]
ONNX_MODEL_PATH = "osnet_x0_5_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:
    ort_session = ort.InferenceSession(ONNX_MODEL_PATH, providers=['CPUExecutionProvider'])
    input_name = ort_session.get_inputs()[0].name
    print("OSNet listo para CPU.")
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)

# ──────────────────────────────────────────────────────────────────────────────
#  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 preprocess_onnx(roi):
    img = cv2.resize(roi, (128, 256))
    img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
    img = img.transpose(2, 0, 1).astype(np.float32) / 255.0
    return np.expand_dims((img - MEAN) / STD, 0)

SOPORTA_BATCH = None  # Variable global para detectar compatibilidad ONNX

def procesar_batch_osnet(rois):
#Procesa los recortes de forma segura y secuencial para evitar abortos C++ en ONNX
    if not rois: return []
    
    features_norm = []
    for roi in rois:
        # Preprocesamiento individual
        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)
        
        # ⚡ Ejecución segura 1 a 1 (Garantiza que ONNX no colapse)
        df = ort_session.run(None, {input_name: blob})[0][0].flatten()
        
        n = np.linalg.norm(df)
        if n > 0: df /= n
        features_norm.append(df)
        
    return features_norm

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]
    
    # ⚡ ALUCÍN OPTIMIZADO: LA MÁSCARA ELÍPTICA
    # Creamos un lienzo negro y dibujamos un óvalo blanco en el centro.
    # El histograma IGNORARÁ todo lo negro (la pared/fondo) y solo leerá lo blanco (el cuerpo).
    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) # Óvalo ajustado a proporciones humanas
    cv2.ellipse(mask, (centro_x, centro_y), (eje_x, eje_y), 0, 0, 360, 255, -1)

    # ⚡ REGRESO AL HSV PURO (Sin CLAHE que adultere la luz)
    hsv = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV)

    t1 = int(h * 0.30)
    t2 = int(h * 0.65)
    
    # Cortamos la imagen Y la máscara en las 3 zonas (Cabeza, Torso, Piernas)
    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)
        # Calculamos el color 3D (H,S,V) PERO pasándole la máscara para ignorar el fondo
        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)
    
    # Retorna 1536 dimensiones de color PURO y libre de paredes
    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
    if h / max(w, 1) < 0.50 or h < 50: return False
    
    cabeza_segura = any(p[2] > 0.55 for p in kpts[:5])
    if not cabeza_segura: return False
    
    torso_seguro = any(p[2] > 0.40 for p in kpts[5:13])
    if not 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
        
        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 or roi.shape[0] < 40 or roi.shape[1] < 20:
            return None
            
        score_nitidez = calcular_nitidez(roi)

        # ⚡ SOPORTE BATCH: Si no se entregó deep_feat masivo, lo calculamos individualmente (Fallback)
        if deep_feat is None:
            blob = preprocess_onnx(roi)
            deep_feat = ort_session.run(None, {input_name: blob})[0][0].flatten()
            n = np.linalg.norm(deep_feat)
            if n > 0: deep_feat /= n

        color_f = extraer_color_zonas(roi) 
        anat_f = extraer_proporciones_anatomicas(kpts) 
        
        ratio_hw = roi.shape[0] / max(roi.shape[1], 1)
        calidad = (x2_c - x1_c) * (y2_c - y1_c)

        return {
                'deep': deep_feat, 
                'color': color_f, # ⚡ ELIMINADO: Textura LBP
                'anatomia': anat_f, 
                'ratio_hw': ratio_hw,
                'calidad': calidad, 
                'nitidez': score_nitidez
            }
    except Exception as e: 
        return None

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 (Domain Gap Fix)
    # Compensamos la pérdida de puntaje causada por el cambio de iluminación.
    # Evita que IDs viejos de la misma cámara ganen injustamente.
    # ─────────────────────────────────────────────────────────────
    if cross_cam and sim_deep > 0.50:
        sim_deep += 0.05 

    # 1. ZONA DE CERTEZA ABSOLUTA
    if sim_deep >= 0.70: 
        return min(1.0, sim_deep + 0.15) 
    
    # 2. VÍA DE RECHAZO (No hay forma de que sea él)
    if sim_deep < 0.45: 
        return sim_deep

    # 3. TRIBUNAL DE TEXTURAS Y 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:
        # PUNTOS CIEGOS (Solapamiento / Espaldas)
        if sim_deep >= 0.50:
            resultado_final += 0.12 

    return float(max(0.0, min(1.0, resultado_final)))


def desglose_similitud(f1, f2, cross_cam=False):
    # -------------------------------------------------
    # 1. DEEP (OSNET)
    # -------------------------------------------------
    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))

    # -------------------------------------------------
    # 2. COLOR (2D HS INMUNE A SOMBRAS)
    # -------------------------------------------------
    sim_color = -1.0  
    c1, c2 = f1.get("color"), f2.get("color")
    
    # ⚡ CÓDIGO RESTAURADO A 1536 PARA LEER EL HSV PURO Y ENMASCARADO
    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
            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)

    # -------------------------------------------------
    # 3. ANATOMIA (7 Puntos)
    # -------------------------------------------------
    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

# ──────────────────────────────────────────────────────────────────────────────
#  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
        kf.processNoiseCov = np.diag([2.0, 4.0, 15.0, 0.01, 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 'ratio_hw' in firma: 
            ema['ratio_hw'] = alpha_geo * ema['ratio_hw'] + (1-alpha_geo) * firma['ratio_hw']
        
        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


# ──────────────────────────────────────────────────────────────────────────────
#  MEMORIA GLOBAL CON PERSISTENCIA
# ──────────────────────────────────────────────────────────────────────────────
class GlobalMemory:
    def __init__(self):
        self.db = {}
        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._votos_nombre = {}
        self.fecha_actual = datetime.now().date()
        self._cargar_memoria()
        self.reserva_temporal = {}
        self.id_locks = {}
        self.ultimas_detecciones = {}


    def consolidar_clones(self):
        
        #Rutina de Auto-Merge comercial con Fusión Forzada por Biometría Facial.
        from scipy.spatial.distance import cosine
        
        with self.lock:
            ids_activos = list(self.db.keys()) 
            
            for i in range(len(ids_activos)):
                for j in range(i + 1, len(ids_activos)):
                    id1, id2 = ids_activos[i], ids_activos[j]
                    
                    if id1 not in self.db or id2 not in self.db: 
                        continue
                    
                    ema1 = self.db[id1].get('ema')
                    ema2 = self.db[id2].get('ema')
                    if not ema1 or not ema2: continue
                        
                    deep1, deep2 = ema1.get('deep'), ema2.get('deep')
                    if deep1 is None or deep2 is None: continue
                    
                    nom1 = self.db[id1].get('nombre')
                    nom2 = self.db[id2].get('nombre')
                    
                    fusion_forzada = False
                    
                    # ⚡ AUTORIDAD FACIAL
                    if nom1 and nom2 and nom1 == nom2:
                        fusion_forzada = True # InsightFace los bautizó igual. Fusionamos sin importar la ropa.
                    elif nom1 and nom2 and nom1 != nom2:
                        continue # Nombres distintos = Personas distintas. Bloquear fusión.
                    
                    similitud = 1.0 - cosine(deep1, deep2)
                    
                    # Se fusionan si OSNet está seguro O si InsightFace forzó la orden
                    if similitud > 0.76 or fusion_forzada:
                        id_ganador = min(id1, id2)
                        id_clon = max(id1, id2)
                        
                        if self.db[id_clon].get('nombre'):
                            self.db[id_ganador]['nombre'] = self.db[id_clon]['nombre']
                        
                        # Promediamos las firmas para que OSNet aprenda de ambos ángulos
                        self.db[id_ganador]['ema']['deep'] = (self.db[id_ganador]['ema']['deep'] * 0.7) + (self.db[id_clon]['ema']['deep'] * 0.3)
                        
                        self.db[id_clon] = {'fusionado_con': id_ganador, 'ts': time.time()}
                        
                        print(f"  [AUTO-MERGE] Fragmentación corregida. Clon ID {id_clon} absorbido por Original ID {id_ganador} (Sim: {similitud:.2f})")
                        razon = "FUSIÓN FACIAL FORZADA" if fusion_forzada else "Fragmentación corregida"
                        print(f"  [AUTO-MERGE] {razon}. Clon ID {id_clon} absorbido por Original ID {id_ganador} (Sim: {similitud:.2f})")

        # Método 1: Registrar detección global
    def registrar_deteccion_global(self, gid, cam_id, firma, now):
        #Registra que un ID fue detectado en una cámara para coordinación solapada.
        if firma is None or 'deep' not in firma:
            return
        
        # Hash simple de la firma deep para comparación rápida
        firma_hash = hash(firma['deep'].tobytes()) % 10000
        
        self.ultimas_detecciones[gid] = {
            'cam': str(cam_id),
            'ts': now,
            'firma_hash': firma_hash
        }
        
        # Limpiar detecciones antiguas (>10 segundos)
        antiguas = [g for g, d in self.ultimas_detecciones.items() if now - d['ts'] > 10.0]
        for g in antiguas:
            del self.ultimas_detecciones[g]

    # Método 2: Buscar coincidencia solapada
    def buscar_coincidencia_solapada(self, firma, cam_id, now, umbral_sim=0.85):
        #Busca si esta detección podría ser un ID ya detectado en cámara vecina recientemente.
        if firma is None or 'deep' not in firma:
            return None
        
        firma_hash = hash(firma['deep'].tobytes()) % 10000
        vecinos = VECINOS.get(str(cam_id), [])
        
        for gid, info in self.ultimas_detecciones.items():
            # Solo considerar si fue detectado en cámara vecina hace <5s
            if info['cam'] not in vecinos:
                continue
            if now - info['ts'] > 5.0:
                continue
            
            # Si el hash coincide exactamente, es muy probable que sea el mismo
            if info['firma_hash'] == firma_hash:
                return gid
            
            # Si no, hacer comparación completa (más costosa)
            ema = self.db.get(gid, {}).get('ema')
            if ema is not None:
                sim = similitud_hibrida(firma, ema, cross_cam=True)
                if sim >= umbral_sim:
                    return gid
        
        return None

    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,
            'nombre': self.db.get(gid, {}).get('nombre')
        }
        print(f"  [ID LOCK] ID {gid} bloqueado en Cam{cam_id} por {duration}s")

    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 registrar_salida(self, gid, cam_salida, now):
        if gid not in self.db: return
        
        vecinas = VECINOS.get(str(cam_salida), [])
        self.reserva_temporal[gid] = {
            'cam_salida': str(cam_salida),
            'ts_salida': now,
            'cam_esperadas': vecinas,
            'nombre': self.db[gid].get('nombre')
        }
        
        reservas_a_limpiar = []
        for gid_reserva, info in self.reserva_temporal.items():
            if now - info['ts_salida'] > 30.0:
                reservas_a_limpiar.append(gid_reserva)
        
        for gid_limpiar in reservas_a_limpiar:
            del self.reserva_temporal[gid_limpiar]

    def _bonus_reserva(self, gid_evaluado, cam_actual, now):
        return 0.0
        

    def _firma_a_dict(self, firma):
        if not firma: return None
        res = {}
        for k, v in firma.items():
            if isinstance(v, np.ndarray):
                res[k] = v.tolist()
            elif k == 'anatomia' and v is not None:
                res[k] = v.tolist() if isinstance(v, np.ndarray) else list(v)
            else:
                res[k] = v
        return res

    def _dict_a_firma(self, d):
        if not d: return None
        res = {}
        for k, v in d.items():
            if isinstance(v, list) and k in ['deep', 'color', 'textura', 'anatomia']:
                res[k] = np.array(v, dtype=np.float32)
            else:
                res[k] = v
        return res

    def _cargar_memoria(self):
        self.fecha_actual = datetime.now().date()
        if os.path.exists(self.ruta_memoria):
            # ⚡ RESET DIARIO EN INICIO: Si el archivo es de un día anterior, iniciar limpio.
            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)
                    
                    # ⚡ PERSISTENCIA COMERCIAL
                    es_vip = info.get('nombre') is not None
                    # Los VIP duran 3 horas (10800s), los desconocidos 1 hora (3600s) para no saturar RAM
                    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)
                    self.db[gid] = {
                        'ema': self._dict_a_firma(info.get('ema')),
                        'last_cam': info.get('last_cam', '1'),
                        'ts': ts_guardado, 
                        'nombre': info.get('nombre'),
                        'actualizaciones_globales': info.get('actualizaciones_globales', 0),
                        'firmas_altas': [self._dict_a_firma(f) for f in info.get('firmas_altas', []) if f],
                    }
                    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 válidos cargados.")
            except Exception as e:
                print(f"[Memoria] Error cargando: {e}")

            # ⚡ FIX: Cargar los saludos previos para evitar spam auditivo
            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():
                datos[str(gid)] = {
                    'ema': self._firma_a_dict(info.get('ema')),
                    'last_cam': info.get('last_cam', '1'),
                    'nombre': info.get('nombre'),
                    'ts': info.get('ts', time.time()), 
                    'actualizaciones_globales': info.get('actualizaciones_globales', 0),
                    'firmas_altas': [self._firma_a_dict(f) for f in info.get('firmas_altas', [])],
                }
            with open(self.ruta_memoria, 'w') as f:
                json.dump(datos, f)
        except Exception as e:
            print(f"[Memoria] Error guardando: {e}")

    def registrar_movimiento(self, nombre, cam_origen, cam_destino, duracion_seg):
        nuevo = not os.path.exists(self.ruta_movimientos)
        try:
            with open(self.ruta_movimientos, 'a', newline='', encoding='utf-8') as f:
                w = csv.writer(f)
                if nuevo: w.writerow(["Fecha_Hora", "Nombre", "Origen", "Destino", "Seg_en_Origen"])
                w.writerow([datetime.now().strftime("%Y-%m-%d %H:%M:%S"), nombre, cam_origen, cam_destino, round(duracion_seg, 2)])
        except: pass

    def asignar_nombre(self, gid, nombre, confianza, es_fusion=False):
        VOTOS_NOMBRE_MIN    = 3      
        UMBRAL_VOTO         = 0.52   
        UMBRAL_CONFIRMACION = 0.62   
        
        if confianza < (0.50 if es_fusion else UMBRAL_VOTO):
            return False

        with self.lock:
            rec = self.db.get(gid)
            if not rec: return False
            
            nombre_viejo = rec.get('nombre')

            if not hasattr(self, 'nombres_activos'):
                self.nombres_activos = {}

            gid_ocupante = self.nombres_activos.get(nombre)
            if gid_ocupante is not None and gid_ocupante != gid:
                if gid_ocupante in self.db:
                    ts_ultimo = self.db[gid_ocupante].get('ts', 0)
                    if (time.time() - ts_ultimo) < 10.0:
                        return False

            conf_actual = rec.get('confianza_nombre', 0.0)
            if nombre_viejo == nombre and conf_actual > confianza:
                return False

            if not hasattr(self, '_votos_nombre'):
                self._votos_nombre = {}
            
            if gid not in self._votos_nombre:
                self._votos_nombre[gid] = {}
            
            votos_gid = self._votos_nombre[gid]
            
            if any(n != nombre for n in votos_gid.keys()):
                votos_gid.clear()
            
            if nombre not in votos_gid:
                votos_gid[nombre] = []
            
            votos_gid[nombre].append(confianza)
            votos_gid[nombre] = votos_gid[nombre][-6:] 
            
            n_votos = len(votos_gid[nombre])
            conf_media = sum(votos_gid[nombre]) / n_votos
            
            if n_votos < VOTOS_NOMBRE_MIN or conf_media < UMBRAL_CONFIRMACION:
                return False
            
            if nombre_viejo is not None and nombre_viejo != nombre:
                if confianza < 0.70:
                    print(f" [PROTECCIÓN] Rechazando cambio de {nombre_viejo} a {nombre} (conf={confianza:.2f} < 0.70)")
                    return False  # Rechazar cambio automático
                # Si confianza >= 0.70, permitir cambio pero con advertencia
                print(f" [CAMBIO VIP] ID {gid} cambió de {nombre_viejo} a {nombre} (conf={confianza:.2f} >= 0.70)")
            
            rec['nombre'] = nombre
            rec['confianza_nombre'] = conf_media
            self.nombres_activos[nombre] = gid 
            
            votos_gid.clear()
            
        tipo = "FUSIÓN" if es_fusion else "BAUTIZO"
        print(f" [{tipo}] ID {gid} confirmado como {nombre} | Votos: {n_votos} | Conf. Media: {conf_media:.2f}")
        return True
    
    def confirmar_firma_vip(self, gid, ts=None):
        with self.lock:
            rec = self.db.get(gid)
            if rec and rec.get('ema') is not None:
                # ⚡ FIX P2: Respetar la cristalización
                if rec.get('cristalizado', False):
                    print(f" [MEMORIA] ID {gid} está cristalizado. No se añaden más firmas VIP.")
                    return

                firma_actual = rec['ema']
                if 'firmas_altas' not in rec:
                    rec['firmas_altas'] = []
                rec['firmas_altas'].append({
                    k: v.copy() if isinstance(v, np.ndarray) else v 
                    for k, v in firma_actual.items()
                })
                if ts is not None:
                    rec['ts'] = ts
                print(f" [MEMORIA] Firma de ID {gid} bloqueada y protegida como VIP.")

    def guardar_saludo(self, nombre):
        #Guarda el saludo en memoria y lo escribe en el disco duro.
        ahora = time.time()
        fecha_hoy = datetime.fromtimestamp(ahora).strftime("%Y-%m-%d")
        
        self.ultimos_saludos[nombre] = {
            'fecha': fecha_hoy, 
            'timestamp': ahora
        }
        
        # Guardar físicamente en el disco
        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 as e:
            print(f" [MEMORIA] Error guardando registro de saludos: {e}")

    def _actualizar_sin_lock(self, gid, firma, cam_id, now):
        if gid not in self.db:
            self.db[gid] = {
                'ema': firma, 
                'last_cam': cam_id, 
                'ts': now, 
                'nombre': None, 
                'actualizaciones_globales': 1,
                'cristalizado': False
            }
            return

        rec = self.db[gid]
        
        # ⚡ CRISTALIZACIÓN: Después de 15 actualizaciones, CONGELAR EMA
        if not rec.get('cristalizado', False) and rec.get('actualizaciones_globales', 0) >= 15:
            rec['cristalizado'] = True
            print(f"  [CRISTALIZADO] ID {gid} ha alcanzado madurez. EMA congelado permanentemente.")
        
        # ⚡ BLINDAJE DE CRISTALIZACIÓN
        # Si el ID ya es maduro, actualizamos dónde fue visto por última vez, 
        # pero NUNCA permitimos que una cámara modifique su firma maestra perfecta.
        if rec.get('cristalizado', False):
            rec['last_cam'] = cam_id
            rec['ts'] = now
            rec['actualizaciones_globales'] += 1
            return  
        
        ema = rec['ema']
        rec['actualizaciones_globales'] += 1
        
        if ema is None:
            rec['ema'] = firma
        else:
            alpha_deep = 0.85
            alpha_color = 0.50
            alpha_geo = 0.80
            
            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
                
            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 'ratio_hw' in firma: 
                ema['ratio_hw'] = alpha_geo * ema['ratio_hw'] + (1-alpha_geo) * firma['ratio_hw']
            
            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'] = 0.80 * np.asarray(ema['anatomia']) + 0.20 * np.asarray(firma['anatomia'])
                    
        if 'calidad' in firma: 
            ema['calidad'] = firma['calidad']

        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
        
        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()
        
        with self.lock:
            candidatos = []
            
            for gid, data in self.db.items():
                dt = now - data.get('ts', now) 
                distancia = self._distancia_topologica(str(data['last_cam']), str(cam_id))
                
                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_AUSENCIA or data.get('ema') is None: continue
                if self.is_id_locked(gid, cam_id, now): continue

                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

                # ⚡ CÁLCULO OBLIGATORIO DE SIMILITUD ANTES DE CUALQUIER FILTRO
                sim = self._sim_robusta(firma, data, es_cross, confianza_fisica)
                
                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

                # Penalizaciones y ajustes
                if not misma_cam:
                    # NUNCA bloqueamos cámaras vecinas (distancia 1) por tiempo, 
                    # porque al estar solapadas, pueden ver a la persona en el mismo milisegundo (dt=0.0).
                    if es_salto and dt < 1.5: 
                        continue # Mínimo 1.5s para saltar una cámara entera
                    if distancia >= 3 and dt < 5.0: 
                        continue # Mínimo 5.0s para cruzar a la otra punta del edificio

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

                # ─────────────────────────────────────────────────────────────
                # ⚡ UMBRALES CORREGIDOS (Ni parálisis, ni robo de identidad)
                # ─────────────────────────────────────────────────────────────
                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
                
                # AHORA SÍ: sim_anat y sim_deep existen
                if sim_anat > 0.90 and sim_deep > 0.65: 
                    umbral -= 0.04
                    
                if en_borde and not misma_cam: 
                    umbral -= 0.04  
                
                umbral = max(0.65, umbral + penal_multitud)

                bonus_aplicado = min(self._bonus_reserva(gid, cam_id, now), 0.03)
                sim += bonus_aplicado

                # ⚡ RESTAURAR LOGS DE EVAL PARA DIAGNÓSTICO
                if sim >= umbral - 0.10 or len(candidatos) < 3:
                    estado = "ACEPTADO" if sim >= umbral else "RECHAZADO"
                    faltante = umbral - sim if sim < umbral else 0.0
                    info = f"(Faltó {faltante:.2f})" if faltante > 0 else ""
                    bonus_str = f" [+{bonus_aplicado:.2f}]" if bonus_aplicado > 0 else ""
                    
                    # ⚡ FIX P0: Eliminada la llamada doble a desglose_similitud. 
                    # Usamos sim_deep, sim_color y sim_anat pre-calculados.
                    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} "
                        f"(Cam{data['last_cam']}, dt={dt:.1f}s) | "
                        f"Deep={sim_deep:.2f} | Color={color_str} | Anat={anat_str} | "
                        f"Final={sim:.2f}{bonus_str} | Umb={umbral:.2f} {info} -> {estado}"
                    )

                if sim >= umbral: 
                    candidatos.append((sim, sim, gid))

            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)
                return nid, False

            candidatos.sort(reverse=True)
            best_sim, _, best_gid = candidatos[0]

            # ⚡ ANTI-GEMELOS RECALIBRADO: Solo duda si son casi idénticos (<0.02)
            if len(candidatos) >= 2:
                _, segunda_sim, segundo_gid = candidatos[1]
                if abs(best_sim - segunda_sim) < 0.02 and best_sim < 0.75:
                    nid = self.next_gid
                    self.next_gid += 1
                    self._actualizar_sin_lock(nid, firma_guardar, cam_id, now)
                    return nid, False

            if best_gid in self.reserva_temporal: 
                del self.reserva_temporal[best_gid]
            
            # ─────────────────────────────────────────────────────────────
            # ⚡ FIX: REGISTRO DE MOVIMIENTOS EN CSV
            # Antes de actualizar, revisamos si viene de OTRA cámara y si tiene nombre
            # ─────────────────────────────────────────────────────────────
            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) and nombre_registrado:
                tiempo_origen = now - self.db[best_gid].get('ts', now)
                
                # Lanzamos el registro en un hilo para no frenar el motor de tracking
                threading.Thread(
                    target=self.registrar_movimiento, 
                    args=(nombre_registrado, cam_anterior, cam_id, tiempo_origen),
                    daemon=True
                ).start()
            
            # Ahora sí, actualizamos la memoria con la nueva cámara
            self._actualizar_sin_lock(best_gid, firma_guardar, cam_id, now)
            return best_gid, True
        

    def limpiar_fantasmas(self):
        ahora = time.time()
        fecha_hoy = datetime.fromtimestamp(ahora).date()
        
        with self.lock:
            # ⚡ RESET DIARIO EN CALIENTE (Si el programa corre 24/7)
            if fecha_hoy != self.fecha_actual:
                print(f"\n[SISTEMA] ¡Cambio de día! Purgando memoria y reiniciando contador a 100.\n")
                self.db.clear()
                self.nombres_activos.clear()
                self.next_gid = 100
                self.fecha_actual = fecha_hoy
                self.guardar_memoria()
                return

            # ⚡ PURGA NORMAL (3 hrs VIP, 1 hr Desconocidos)
            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()

    def _distancia_topologica(self, cam_o, cam_d):
        if cam_o == cam_d: return 0
        vecinos_directos = VECINOS.get(cam_o, [])
        if cam_d in vecinos_directos: return 1
        for v in vecinos_directos:
            if cam_d in VECINOS.get(v, []): return 2
        return 3

# ──────────────────────────────────────────────────────────────────────────────
#  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)<w*fx and abs(cy-ocy)<h*fy: return True
        return False

    def _gestionar_post_grupo(self, now, frame_hd):
        for trk in self.trackers:
            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

        # ─────────────────────────────────────────────────────────────
        # ⚡ OPTIMIZACIÓN BATCH: Extraemos OSNet para todas las detecciones de un golpe
        # ─────────────────────────────────────────────────────────────
        if n_det > 0:
            rois_validos = []
            mapa_rois = {} 
            h_hd, w_hd = frame_hd.shape[:2]
            
            # 1. Recolectar todos los recortes (ROIs) de la imagen
            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]
                
                # Descartamos basura óptica de inmediato
                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)
            
            # 2. Ejecutar la red neuronal para todos SIMULTÁNEAMENTE
            if rois_validos:
                # ⚡ Llamamos a la función segura que procesa 1 a 1 rápidamente
                deep_feats_seguros = extraer_features_osnet_seguro(rois_validos)
                
                # Ensamblar las firmas
                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
                        
                        # Extraemos el resto de la firma (Color enmascarado, Anatomía, etc.)
                        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

        # ... (A partir de aquí, el código de "alta" y "baja" confidence se queda EXACTAMENTE como ya lo tienes) ...
        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])
                        
                        if sim_ap < 0.25: 
                            costo += 50.0  
                        else:
                            costo += (1.0 - sim_ap) * 2.0 
                            
                        nombre_vip = self.global_mem.db.get(trk.gid, {}).get('nombre')
                        umbral_ap = 0.55 if nombre_vip else 0.40 
                        if sim_ap < umbral_ap: 
                            costo += (umbral_ap - sim_ap) * 3.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}


        # ─────────────────────────────────────────────────────────────
        # 5. PROCESAR MATCHES
        # ─────────────────────────────────────────────────────────────
        for t_idx, d_idx in matched:
            trk, box = self.trackers[t_idx], boxes[d_idx]
            
            # Asegurar firma visual REAL (mitigación del bug de contaminación en _asignar)
            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, box, kpts)
            
            firma_det = firmas[d_idx]
            
            # Debug
            if trk.gid is None:
                print(f"  [DEBUG] LOCAL Trk {trk.local_id}: listo={trk.listo_para_id}, cap_locales={getattr(trk, 'muestras_capturadas', 0)}")
            else:
                with self.global_mem.lock:
                    db_act = self.global_mem.db.get(trk.gid, {}).get('actualizaciones_globales', 0)
                print(f"  [MEMORIA] ID {trk.gid} ha sido actualizado {db_act} veces en la red global.")
                
            # 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
                        
                        # ⚡ INYECCIÓN EXACTA AQUÍ: Disparar el Auto-Merge
                        self.global_mem.consolidar_clones()

            # ─────────────────────────────────────────────────────────
            # 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:
                if getattr(trk, 'muestras_capturadas', 0) >= 4:
                    if trk.local_id in tracks_en_cruce_locals:
                        print(f"  [CRUCE] Track {trk.local_id} en cruce físico. Bloqueando identificación.")
                    else:
                        tiempo_enfriamiento = now - getattr(trk, 'ultimo_cambio_id', 0)
                        if tiempo_enfriamiento < 0.6:
                            print(f"  [HYSTERESIS] Track {trk.local_id} en enfriamiento ({tiempo_enfriamiento:.2f}s)")
                        else:
                            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 = {}
            
            # ⚡ CAMBIO CRÍTICO: Iterar por local_id, no por índice
            for local_id, info in list(self.tracks_post_cruce.items()):
                # Buscar el track con este local_id (único e inmutable)
                trk = next((t for t in self.trackers if t.local_id == local_id), None)
                if trk is None:
                    continue  # El track ya murió, ignorar
                
                # Solo actuar si ya pasaron >2 segundos desde el cruce
                if now - info['ts_cruce'] > 2.0:
                    # Si el ID cambió durante el cruce (o se asignó uno nuevo incorrecto)
                    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:
                                    # Verificar que el ID original no esté activo en otro track
                                    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)
                    # No guardamos este registro más (ya cumplió su ciclo)
                    continue
                
                # Aún no pasan 2 segundos, mantener registro (usando local_id)
                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)
            f = firmas[d_idx]
            if f: 
                nt.actualizar_ema(f)
            self.trackers.append(nt)

        # ─────────────────────────────────────────────────────────────
        # 8. LIMPIEZA: matar tracks sin detección por >3 segundos
        # ─────────────────────────────────────────────────────────────
        tracks_vivos = []
        for t in self.trackers:
            if (now - t.ts_ultima_deteccion) < 3.0:
                tracks_vivos.append(t)
        self.trackers = tracks_vivos

        # ⚡ FIX P0: Purga de memory leak en tracks_post_cruce
        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 (recuperación de firma tras salir de multitud)
        # ─────────────────────────────────────────────────────────────
        self._gestionar_post_grupo(now, frame_hd)
        
        return self.trackers

class CamStream:
    def __init__(self, url):
        self.url = url; self.cap = cv2.VideoCapture(url)
        self.q = queue.Queue(maxsize=1); self.stopped = False
        threading.Thread(target=self._run, daemon=True).start()
    def _run(self):
        while not self.stopped:
            ret, f = self.cap.read()
            if not ret: time.sleep(2); self.cap.open(self.url); continue
            if self.q.full():
                try: self.q.get_nowait()
                except queue.Empty: pass
            self.q.put(f)
    @property
    def frame(self):
        try: return self.q.get(timeout=0.5)
        except queue.Empty: return None
    def stop(self): self.stopped = True; self.cap.release()

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)

# ──────────────────────────────────────────────────────────────────────────────
#  MAIN
# ──────────────────────────────────────────────────────────────────────────────
def main():
    print("\n=== Tracker Autónomo Definitivo ===")
    print("Persistencia activa. Los IDs se recuerdan entre reinicios.")
    print("Tecla [q] para salir (la memoria se guarda automáticamente).\n")
    
    model = YOLO("yolov8n-pose.pt")
    global_mem = GlobalMemory()
    managers = {str(c): CamManager(c, global_mem) for c in SECUENCIA}
    cams = [CamStream(u) for u in URLS]
    cv2.namedWindow("SmartSoft", cv2.WINDOW_AUTOSIZE)

    idx = 0
    ultimo_guardado = time.time()
    
    try:
        while True:
            now, tiles = time.time(), []
            cam_ia = idx % len(cams)

            for i, cam_obj in enumerate(cams):
                frame = cam_obj.frame
                cid = str(SECUENCIA[i])
                if frame is None:
                    tiles.append(np.zeros((270,480,3),np.uint8)); continue

                frame_show = cv2.resize(frame.copy(),(480,270))
                boxes, confs, kpts = [], [], []
                turno_activo = (i == cam_ia)

                if turno_activo:
                    res = model.predict(frame_show, conf=0.50, iou=0.40, classes=[0], verbose=False, imgsz=480, device='cpu')
                
                    if res[0].boxes:
                        raw_boxes = res[0].boxes.xyxy.cpu().numpy().tolist()
                        raw_confs = res[0].boxes.conf.cpu().numpy().tolist()
                        raw_kpts = res[0].keypoints.data.cpu().numpy().tolist() if res[0].keypoints is not None else []
                        
                        for d_idx, (box, conf) in enumerate(zip(raw_boxes, raw_confs)):
                            kpt_persona = raw_kpts[d_idx] if d_idx < len(raw_kpts) else None
                            
                            if es_humano_valido(kpt_persona, box, conf):
                                boxes.append(box)
                                confs.append(conf)
                                kpts.append(kpt_persona)

                        ESQUELETO = [(0,1),(0,2),(1,3),(2,4),(5,6),(5,7),(7,9),(6,8),(8,10),(5,11),(6,12),(11,12),(11,13),(13,15),(12,14),(14,16)]
                        for pk in kpts:
                            if pk is None: continue
                            for a,b in ESQUELETO:
                                if pk[a][2]>0.40 and pk[b][2]>0.40:
                                    cv2.line(frame_show, (int(pk[a][0]),int(pk[a][1])), (int(pk[b][0]),int(pk[b][1])), (255,0,255), 2)
                            for kx, ky, kc in pk:
                                if kc > 0.40: cv2.circle(frame_show, (int(kx), int(ky)), 3, (0, 255, 255), -1)

                tracks = managers[cid].update(boxes, confs, kpts, frame_show, frame, now, turno_activo)

                for trk in tracks:
                    if trk.time_since_update <= 1: dibujar_track(frame_show, trk)
                if turno_activo: cv2.circle(frame_show,(460,20),6,(0,0,255),-1)

                con_id = sum(1 for t in tracks if getattr(t,'gid',None) and t.time_since_update==0)
                cv2.putText(frame_show,f"CAM {cid} [{con_id} ID]",(10,28),FUENTE,0.7,(255,255,255),2)
                tiles.append(frame_show)

            if len(tiles)==6:
                cv2.imshow("SmartSoft", np.vstack([np.hstack(tiles[:3]),np.hstack(tiles[3:])]))
            
            idx += 1
            
            if now - ultimo_guardado > 30:
                global_mem.guardar_memoria()
                ultimo_guardado = now
            
            if cv2.waitKey(1) == ord('q'): break

    finally:
        global_mem.guardar_memoria()
        print("[Memoria] Guardada antes de salir.")
        cv2.destroyAllWindows()

if __name__ == "__main__":
    main()
"""