IdentificacionIA/seguimiento2.py
2026-06-25 11:06:15 -06:00

1692 lines
85 KiB
Python

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