1692 lines
85 KiB
Python
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)
|