1623 lines
77 KiB
Plaintext
1623 lines
77 KiB
Plaintext
|
|
"""---------------------------------------------version
|
||
|
|
|
||
|
|
import cv2
|
||
|
|
import numpy as np
|
||
|
|
import time
|
||
|
|
import threading
|
||
|
|
from scipy.optimize import linear_sum_assignment
|
||
|
|
from scipy.spatial.distance import cosine
|
||
|
|
from ultralytics import YOLO
|
||
|
|
import onnxruntime as ort
|
||
|
|
import os
|
||
|
|
from datetime import datetime
|
||
|
|
import json
|
||
|
|
import csv
|
||
|
|
import math
|
||
|
|
import queue
|
||
|
|
from scipy.spatial.distance import cosine
|
||
|
|
|
||
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
|
# CONFIGURACIÓN
|
||
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
|
USUARIO, PASSWORD, IP_DVR = "admin", "TCA200503", "192.168.1.244"
|
||
|
|
SECUENCIA = [1, 7, 5, 8, 3, 6]
|
||
|
|
os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = "rtsp_transport;tcp|stimeout;3000000"
|
||
|
|
URLS = [f"rtsp://{USUARIO}:{PASSWORD}@{IP_DVR}:554/Streaming/Channels/{i}02" for i in SECUENCIA]
|
||
|
|
ONNX_MODEL_PATH = "osnet_x0_5_msmt17_batch1.onnx"
|
||
|
|
|
||
|
|
VECINOS = {
|
||
|
|
"1": ["7"], "7": ["1", "5"], "5": ["7", "8"],
|
||
|
|
"8": ["5", "3"], "3": ["8", "6"], "6": ["3"]
|
||
|
|
}
|
||
|
|
|
||
|
|
TIEMPO_MAX_AUSENCIA = 900.0
|
||
|
|
C_CANDIDATO = (150, 150, 150)
|
||
|
|
C_LOCAL = (0, 255, 0)
|
||
|
|
C_GLOBAL = (0, 165, 255)
|
||
|
|
C_GRUPO = (0, 0, 255)
|
||
|
|
C_APRENDIZAJE = (255, 255, 0)
|
||
|
|
FUENTE = cv2.FONT_HERSHEY_SIMPLEX
|
||
|
|
|
||
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
|
# OSNET
|
||
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
|
print("Cargando OSNet...")
|
||
|
|
try:
|
||
|
|
ort_session = ort.InferenceSession(ONNX_MODEL_PATH, providers=['CPUExecutionProvider'])
|
||
|
|
input_name = ort_session.get_inputs()[0].name
|
||
|
|
print("OSNet listo para CPU.")
|
||
|
|
except Exception as e:
|
||
|
|
print(f"ERROR FATAL: {e}"); exit()
|
||
|
|
|
||
|
|
MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32).reshape(3, 1, 1)
|
||
|
|
STD = np.array([0.229, 0.224, 0.225], dtype=np.float32).reshape(3, 1, 1)
|
||
|
|
|
||
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
|
# EXTRACCIÓN DE FIRMAS MULTI-DESCRIPTOR
|
||
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
|
def extraer_features_osnet_seguro(rois):
|
||
|
|
|
||
|
|
#Procesamiento secuencial 1-a-1 de grado comercial. Garantiza 0% de probabilidad de crasheo C++ en ONNX.
|
||
|
|
|
||
|
|
if not rois: return []
|
||
|
|
|
||
|
|
features_norm = []
|
||
|
|
for roi in rois:
|
||
|
|
# 1. Preprocesamiento matemático exacto para OSNet
|
||
|
|
img = cv2.resize(roi, (128, 256))
|
||
|
|
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
|
||
|
|
img = img.transpose(2, 0, 1).astype(np.float32) / 255.0
|
||
|
|
|
||
|
|
# Expandimos la dimensión para crear un Batch estricto de tamaño 1: (1, 3, 256, 128)
|
||
|
|
blob = np.expand_dims((img - MEAN) / STD, 0)
|
||
|
|
|
||
|
|
# 2. INFERENCIA AISLADA (El motor ONNX no sufrirá estrés de memoria)
|
||
|
|
# El [0][0] extrae el vector ignorando la dimensión del batch
|
||
|
|
df = ort_session.run(None, {input_name: blob})[0][0].flatten()
|
||
|
|
|
||
|
|
# 3. Normalización L2 (Vital para que funcione la distancia Coseno)
|
||
|
|
n = np.linalg.norm(df)
|
||
|
|
if n > 0: df /= n
|
||
|
|
|
||
|
|
features_norm.append(df)
|
||
|
|
|
||
|
|
return features_norm
|
||
|
|
|
||
|
|
def calcular_nitidez(img):
|
||
|
|
if img is None or img.size == 0: return 0
|
||
|
|
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
||
|
|
return cv2.Laplacian(gray, cv2.CV_64F).var()
|
||
|
|
|
||
|
|
def preprocess_onnx(roi):
|
||
|
|
img = cv2.resize(roi, (128, 256))
|
||
|
|
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
|
||
|
|
img = img.transpose(2, 0, 1).astype(np.float32) / 255.0
|
||
|
|
return np.expand_dims((img - MEAN) / STD, 0)
|
||
|
|
|
||
|
|
SOPORTA_BATCH = None # Variable global para detectar compatibilidad ONNX
|
||
|
|
|
||
|
|
def procesar_batch_osnet(rois):
|
||
|
|
#Procesa los recortes de forma segura y secuencial para evitar abortos C++ en ONNX
|
||
|
|
if not rois: return []
|
||
|
|
|
||
|
|
features_norm = []
|
||
|
|
for roi in rois:
|
||
|
|
# Preprocesamiento individual
|
||
|
|
img = cv2.resize(roi, (128, 256))
|
||
|
|
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
|
||
|
|
img = img.transpose(2, 0, 1).astype(np.float32) / 255.0
|
||
|
|
blob = np.expand_dims((img - MEAN) / STD, 0)
|
||
|
|
|
||
|
|
# ⚡ Ejecución segura 1 a 1 (Garantiza que ONNX no colapse)
|
||
|
|
df = ort_session.run(None, {input_name: blob})[0][0].flatten()
|
||
|
|
|
||
|
|
n = np.linalg.norm(df)
|
||
|
|
if n > 0: df /= n
|
||
|
|
features_norm.append(df)
|
||
|
|
|
||
|
|
return features_norm
|
||
|
|
|
||
|
|
def extraer_color_zonas(roi):
|
||
|
|
if roi is None or roi.size == 0 or roi.shape[0] < 30 or roi.shape[1] < 10:
|
||
|
|
return np.zeros(512 * 3, dtype=np.float32)
|
||
|
|
|
||
|
|
h, w = roi.shape[:2]
|
||
|
|
|
||
|
|
# ⚡ ALUCÍN OPTIMIZADO: LA MÁSCARA ELÍPTICA
|
||
|
|
# Creamos un lienzo negro y dibujamos un óvalo blanco en el centro.
|
||
|
|
# El histograma IGNORARÁ todo lo negro (la pared/fondo) y solo leerá lo blanco (el cuerpo).
|
||
|
|
mask = np.zeros((h, w), dtype=np.uint8)
|
||
|
|
centro_x, centro_y = int(w/2), int(h/2)
|
||
|
|
eje_x, eje_y = int(w * 0.35), int(h * 0.48) # Óvalo ajustado a proporciones humanas
|
||
|
|
cv2.ellipse(mask, (centro_x, centro_y), (eje_x, eje_y), 0, 0, 360, 255, -1)
|
||
|
|
|
||
|
|
# ⚡ REGRESO AL HSV PURO (Sin CLAHE que adultere la luz)
|
||
|
|
hsv = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV)
|
||
|
|
|
||
|
|
t1 = int(h * 0.30)
|
||
|
|
t2 = int(h * 0.65)
|
||
|
|
|
||
|
|
# Cortamos la imagen Y la máscara en las 3 zonas (Cabeza, Torso, Piernas)
|
||
|
|
z1_img, z1_mask = hsv[:t1, :], mask[:t1, :]
|
||
|
|
z2_img, z2_mask = hsv[t1:t2, :], mask[t1:t2, :]
|
||
|
|
z3_img, z3_mask = hsv[t2:, :], mask[t2:, :]
|
||
|
|
|
||
|
|
def calc_hist(img_part, mask_part):
|
||
|
|
if img_part.size == 0: return np.zeros(512, dtype=np.float32)
|
||
|
|
# Calculamos el color 3D (H,S,V) PERO pasándole la máscara para ignorar el fondo
|
||
|
|
hist = cv2.calcHist([img_part], [0, 1, 2], mask_part, [8, 8, 8], [0, 180, 0, 256, 0, 256])
|
||
|
|
cv2.normalize(hist, hist, alpha=1.0, norm_type=cv2.NORM_L1)
|
||
|
|
return hist.flatten()
|
||
|
|
|
||
|
|
h1 = calc_hist(z1_img, z1_mask)
|
||
|
|
h2 = calc_hist(z2_img, z2_mask)
|
||
|
|
h3 = calc_hist(z3_img, z3_mask)
|
||
|
|
|
||
|
|
# Retorna 1536 dimensiones de color PURO y libre de paredes
|
||
|
|
return np.concatenate([h1, h2, h3]).astype(np.float32)
|
||
|
|
|
||
|
|
|
||
|
|
def extraer_proporciones_anatomicas(kpts):
|
||
|
|
if kpts is None or len(kpts) < 17: return None
|
||
|
|
kpts = np.array(kpts)
|
||
|
|
if kpts.shape[0] < 17: return None
|
||
|
|
|
||
|
|
puntos = {i: kpts[i] for i in range(17)}
|
||
|
|
|
||
|
|
if puntos[0][2] < 0.30: return None
|
||
|
|
if puntos[5][2] < 0.30 and puntos[6][2] < 0.30: return None
|
||
|
|
if puntos[11][2] < 0.30 and puntos[12][2] < 0.30: return None
|
||
|
|
|
||
|
|
def dist(i, j):
|
||
|
|
if puntos[i][2] < 0.30 or puntos[j][2] < 0.30: return 0.0
|
||
|
|
return float(np.linalg.norm(puntos[i][:2] - puntos[j][:2]))
|
||
|
|
|
||
|
|
hombros = dist(5, 6)
|
||
|
|
caderas = dist(11, 12)
|
||
|
|
torso = max(dist(5, 11), dist(6, 12))
|
||
|
|
pierna_izq = dist(11, 13) + dist(13, 15) if puntos[13][2] > 0.30 and puntos[15][2] > 0.30 else 0
|
||
|
|
pierna_der = dist(12, 14) + dist(14, 16) if puntos[14][2] > 0.30 and puntos[16][2] > 0.30 else 0
|
||
|
|
piernas = max(pierna_izq, pierna_der)
|
||
|
|
brazo_izq = dist(5, 7) + dist(7, 9) if puntos[7][2] > 0.30 and puntos[9][2] > 0.30 else 0
|
||
|
|
brazo_der = dist(6, 8) + dist(8, 10) if puntos[8][2] > 0.30 and puntos[10][2] > 0.30 else 0
|
||
|
|
brazos = max(brazo_izq, brazo_der)
|
||
|
|
|
||
|
|
altura = torso + piernas
|
||
|
|
if altura < 15.0: return None
|
||
|
|
|
||
|
|
feats = np.array([
|
||
|
|
hombros / max(altura, 1e-6),
|
||
|
|
caderas / max(altura, 1e-6),
|
||
|
|
torso / max(altura, 1e-6),
|
||
|
|
piernas / max(altura, 1e-6),
|
||
|
|
brazos / max(altura, 1e-6),
|
||
|
|
hombros / max(caderas, 1e-6),
|
||
|
|
piernas / max(torso, 1e-6),
|
||
|
|
], dtype=np.float32)
|
||
|
|
|
||
|
|
feats = np.clip(feats, 0.0, 3.0)
|
||
|
|
n = np.linalg.norm(feats)
|
||
|
|
if n > 0: feats /= n
|
||
|
|
return feats
|
||
|
|
|
||
|
|
def es_humano_valido(kpts, box, conf):
|
||
|
|
if conf < 0.45: return False
|
||
|
|
if kpts is None or len(kpts) < 17: return False
|
||
|
|
|
||
|
|
x1, y1, x2, y2 = box
|
||
|
|
w, h = x2 - x1, y2 - y1
|
||
|
|
if h / max(w, 1) < 0.50 or h < 50: return False
|
||
|
|
|
||
|
|
cabeza_segura = any(p[2] > 0.55 for p in kpts[:5])
|
||
|
|
if not cabeza_segura: return False
|
||
|
|
|
||
|
|
torso_seguro = any(p[2] > 0.40 for p in kpts[5:13])
|
||
|
|
if not torso_seguro: return False
|
||
|
|
|
||
|
|
return True
|
||
|
|
|
||
|
|
def extraer_firma_hibrida(frame_hd, box_480, kpts=None, deep_feat=None):
|
||
|
|
try:
|
||
|
|
h_hd, w_hd = frame_hd.shape[:2]
|
||
|
|
x1, y1, x2, y2 = box_480
|
||
|
|
|
||
|
|
x1_c = max(0, int(x1 * (w_hd/480.0)))
|
||
|
|
y1_c = max(0, int(y1 * (h_hd/270.0)))
|
||
|
|
x2_c = min(w_hd, int(x2 * (w_hd/480.0)))
|
||
|
|
y2_c = min(h_hd, int(y2 * (h_hd/270.0)))
|
||
|
|
|
||
|
|
roi = frame_hd[y1_c:y2_c, x1_c:x2_c]
|
||
|
|
|
||
|
|
if roi.size == 0 or roi.shape[0] < 40 or roi.shape[1] < 20:
|
||
|
|
return None
|
||
|
|
|
||
|
|
score_nitidez = calcular_nitidez(roi)
|
||
|
|
|
||
|
|
# ⚡ SOPORTE BATCH: Si no se entregó deep_feat masivo, lo calculamos individualmente (Fallback)
|
||
|
|
if deep_feat is None:
|
||
|
|
blob = preprocess_onnx(roi)
|
||
|
|
deep_feat = ort_session.run(None, {input_name: blob})[0][0].flatten()
|
||
|
|
n = np.linalg.norm(deep_feat)
|
||
|
|
if n > 0: deep_feat /= n
|
||
|
|
|
||
|
|
color_f = extraer_color_zonas(roi)
|
||
|
|
anat_f = extraer_proporciones_anatomicas(kpts)
|
||
|
|
|
||
|
|
ratio_hw = roi.shape[0] / max(roi.shape[1], 1)
|
||
|
|
calidad = (x2_c - x1_c) * (y2_c - y1_c)
|
||
|
|
|
||
|
|
return {
|
||
|
|
'deep': deep_feat,
|
||
|
|
'color': color_f, # ⚡ ELIMINADO: Textura LBP
|
||
|
|
'anatomia': anat_f,
|
||
|
|
'ratio_hw': ratio_hw,
|
||
|
|
'calidad': calidad,
|
||
|
|
'nitidez': score_nitidez
|
||
|
|
}
|
||
|
|
except Exception as e:
|
||
|
|
return None
|
||
|
|
|
||
|
|
def similitud_hibrida(f1, f2, cross_cam=False, confianza_fisica=1.0):
|
||
|
|
if f1 is None or f2 is None: return 0.0
|
||
|
|
|
||
|
|
from scipy.spatial.distance import cosine
|
||
|
|
import numpy as np
|
||
|
|
|
||
|
|
deep1, deep2 = f1.get('deep'), f2.get('deep')
|
||
|
|
if deep1 is None or deep2 is None: return 0.0
|
||
|
|
|
||
|
|
sim_deep = max(0.0, 1.0 - cosine(deep1, deep2))
|
||
|
|
|
||
|
|
# ─────────────────────────────────────────────────────────────
|
||
|
|
# ⚡ ECUALIZADOR CROSS-CAM (Domain Gap Fix)
|
||
|
|
# Compensamos la pérdida de puntaje causada por el cambio de iluminación.
|
||
|
|
# Evita que IDs viejos de la misma cámara ganen injustamente.
|
||
|
|
# ─────────────────────────────────────────────────────────────
|
||
|
|
if cross_cam and sim_deep > 0.50:
|
||
|
|
sim_deep += 0.05
|
||
|
|
|
||
|
|
# 1. ZONA DE CERTEZA ABSOLUTA
|
||
|
|
if sim_deep >= 0.70:
|
||
|
|
return min(1.0, sim_deep + 0.15)
|
||
|
|
|
||
|
|
# 2. VÍA DE RECHAZO (No hay forma de que sea él)
|
||
|
|
if sim_deep < 0.45:
|
||
|
|
return sim_deep
|
||
|
|
|
||
|
|
# 3. TRIBUNAL DE TEXTURAS Y ANATOMÍA
|
||
|
|
resultado_final = sim_deep + 0.05
|
||
|
|
|
||
|
|
a1, a2 = f1.get("anatomia"), f2.get("anatomia")
|
||
|
|
if a1 is not None and a2 is not None and hasattr(a1, "shape") and a1.shape == a2.shape:
|
||
|
|
sim_anat = max(0.0, 1.0 - np.linalg.norm(a1 - a2))
|
||
|
|
peso_anat = 0.12 * confianza_fisica
|
||
|
|
|
||
|
|
if sim_anat > 0.88:
|
||
|
|
resultado_final += peso_anat
|
||
|
|
if sim_anat >= 0.95: resultado_final += 0.04
|
||
|
|
elif sim_anat < 0.35:
|
||
|
|
resultado_final -= peso_anat
|
||
|
|
|
||
|
|
else:
|
||
|
|
# PUNTOS CIEGOS (Solapamiento / Espaldas)
|
||
|
|
if sim_deep >= 0.50:
|
||
|
|
resultado_final += 0.12
|
||
|
|
|
||
|
|
return float(max(0.0, min(1.0, resultado_final)))
|
||
|
|
|
||
|
|
|
||
|
|
def desglose_similitud(f1, f2, cross_cam=False):
|
||
|
|
# -------------------------------------------------
|
||
|
|
# 1. DEEP (OSNET)
|
||
|
|
# -------------------------------------------------
|
||
|
|
sim_deep = 0.0
|
||
|
|
deep1, deep2 = f1.get('deep'), f2.get('deep')
|
||
|
|
if deep1 is not None and deep2 is not None and np.sum(deep1) > 0 and np.sum(deep2) > 0:
|
||
|
|
sim_deep = max(0.0, 1.0 - cosine(deep1, deep2))
|
||
|
|
|
||
|
|
# -------------------------------------------------
|
||
|
|
# 2. COLOR (2D HS INMUNE A SOMBRAS)
|
||
|
|
# -------------------------------------------------
|
||
|
|
sim_color = -1.0
|
||
|
|
c1, c2 = f1.get("color"), f2.get("color")
|
||
|
|
|
||
|
|
# ⚡ CÓDIGO RESTAURADO A 1536 PARA LEER EL HSV PURO Y ENMASCARADO
|
||
|
|
if c1 is not None and c2 is not None and c1.shape == c2.shape and len(c1) == 1536:
|
||
|
|
if np.sum(c1) > 0.1 and np.sum(c2) > 0.1:
|
||
|
|
dist1 = cv2.compareHist(c1[:512].astype(np.float32), c2[:512].astype(np.float32), cv2.HISTCMP_BHATTACHARYYA)
|
||
|
|
dist2 = cv2.compareHist(c1[512:1024].astype(np.float32), c2[512:1024].astype(np.float32), cv2.HISTCMP_BHATTACHARYYA)
|
||
|
|
dist3 = cv2.compareHist(c1[1024:].astype(np.float32), c2[1024:].astype(np.float32), cv2.HISTCMP_BHATTACHARYYA)
|
||
|
|
|
||
|
|
s1 = max(0.0, 1.0 - float(dist1))
|
||
|
|
s2 = max(0.0, 1.0 - float(dist2))
|
||
|
|
s3 = max(0.0, 1.0 - float(dist3))
|
||
|
|
|
||
|
|
# Escudo chamarra abierta
|
||
|
|
if cross_cam and s3 > 0.60 and s2 < 0.35:
|
||
|
|
s2 = max(s2, s3 * 0.80)
|
||
|
|
|
||
|
|
sim_color = (s1 * 0.20) + (s2 * 0.50) + (s3 * 0.30)
|
||
|
|
|
||
|
|
# -------------------------------------------------
|
||
|
|
# 3. ANATOMIA (7 Puntos)
|
||
|
|
# -------------------------------------------------
|
||
|
|
sim_anat = -1.0
|
||
|
|
a1, a2 = f1.get("anatomia"), f2.get("anatomia")
|
||
|
|
|
||
|
|
if a1 is not None and a2 is not None and hasattr(a1, "shape") and hasattr(a2, "shape") and a1.shape == a2.shape:
|
||
|
|
dist_a = np.linalg.norm(a1 - a2)
|
||
|
|
sim_anat = max(0.0, 1.0 - dist_a)
|
||
|
|
|
||
|
|
return sim_deep, sim_color, sim_anat
|
||
|
|
|
||
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
|
# KALMAN TRACKER
|
||
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
|
class KalmanTrack:
|
||
|
|
_count = 0
|
||
|
|
|
||
|
|
def __init__(self, box, now):
|
||
|
|
kf = cv2.KalmanFilter(7, 4)
|
||
|
|
kf.measurementMatrix = np.array([[1,0,0,0,0,0,0],[0,1,0,0,0,0,0],[0,0,1,0,0,0,0],[0,0,0,1,0,0,0]], np.float32)
|
||
|
|
kf.transitionMatrix = np.eye(7, dtype=np.float32)
|
||
|
|
kf.transitionMatrix[0,4] = kf.transitionMatrix[1,5] = kf.transitionMatrix[2,6] = 1
|
||
|
|
kf.processNoiseCov = np.diag([2.0, 4.0, 15.0, 0.01, 1.0, 2.0, 1.0]).astype(np.float32)
|
||
|
|
kf.measurementNoiseCov = np.diag([10.0, 10.0, 50.0, 0.10]).astype(np.float32)
|
||
|
|
kf.errorCovPost = np.diag([10., 10., 25., 1., 500., 500., 100.]).astype(np.float32)
|
||
|
|
kf.statePost = np.zeros((7,1), np.float32)
|
||
|
|
kf.statePost[:4] = self._z(box)
|
||
|
|
self.kf = kf
|
||
|
|
|
||
|
|
self.local_id = KalmanTrack._count; KalmanTrack._count += 1
|
||
|
|
self.gid, self.origen_global, self.aprendiendo = None, False, False
|
||
|
|
self.box = list(box)
|
||
|
|
self.ts_creacion = self.ts_ultima_deteccion = now
|
||
|
|
self.time_since_update = 0
|
||
|
|
self.en_grupo = False
|
||
|
|
self.frames_buena_calidad = 0
|
||
|
|
self.listo_para_id = False
|
||
|
|
self.firma_pre_grupo = None
|
||
|
|
self.ts_salio_grupo = 0.0
|
||
|
|
self.fallos_post_grupo = 0
|
||
|
|
self.ultimo_aprendizaje = 0.0
|
||
|
|
self.frames_continuos = 0
|
||
|
|
self.frames_observados = 0
|
||
|
|
self.firma_ema = None
|
||
|
|
self.muestras_capturadas = 0
|
||
|
|
self.ultimo_cambio_id = 0.0
|
||
|
|
|
||
|
|
def _z(self, bbox):
|
||
|
|
w = bbox[2]-bbox[0]; h = bbox[3]-bbox[1]
|
||
|
|
x = bbox[0]+w/2.; y = bbox[1]+h/2.
|
||
|
|
return np.array([[x],[y],[w*h],[w/max(h,1e-6)]]).astype(np.float32)
|
||
|
|
|
||
|
|
def _bbox(self, x):
|
||
|
|
cx, cy = float(x[0].item()), float(x[1].item())
|
||
|
|
s, r = float(x[2].item()), float(x[3].item())
|
||
|
|
w = np.sqrt(max(s * r, 1e-6)); h = s / (w + 1e-6)
|
||
|
|
return [cx-w/2., cy-h/2., cx+w/2., cy+h/2.]
|
||
|
|
|
||
|
|
@property
|
||
|
|
def confianza_fisica(self):
|
||
|
|
return min(1.0, self.frames_continuos / 20.0)
|
||
|
|
|
||
|
|
def calcular_score_calidad(self, firma):
|
||
|
|
if firma is None: return 0.0
|
||
|
|
score_nitidez = min(firma.get('nitidez', 0) / 100.0, 1.0) * 50.0
|
||
|
|
score_area = min(firma.get('calidad', 0) / 12000.0, 1.0) * 50.0
|
||
|
|
return score_nitidez + score_area
|
||
|
|
|
||
|
|
def actualizar_ema(self, firma):
|
||
|
|
if firma is None: return
|
||
|
|
|
||
|
|
self.muestras_capturadas = getattr(self, 'muestras_capturadas', 0) + 1
|
||
|
|
score_nuevo = self.calcular_score_calidad(firma)
|
||
|
|
score_viejo = getattr(self, 'score_ema', 0.0)
|
||
|
|
|
||
|
|
if self.firma_ema is None:
|
||
|
|
# Primera impresión (puede ser mala, pero es lo único que tenemos)
|
||
|
|
self.firma_ema = {k: v.copy() if isinstance(v, np.ndarray) else v for k, v in firma.items()}
|
||
|
|
self.score_ema = score_nuevo
|
||
|
|
return
|
||
|
|
|
||
|
|
# ⚡ PROTECCIÓN DE MEMORIA (Anti-polución)
|
||
|
|
if self.muestras_capturadas > 4 and score_nuevo < (score_viejo * 0.60):
|
||
|
|
return
|
||
|
|
|
||
|
|
# ─────────────────────────────────────────────────────────────
|
||
|
|
# ⚡ ASIMILACIÓN DINÁMICA (La cura a la mala primera impresión)
|
||
|
|
# ─────────────────────────────────────────────────────────────
|
||
|
|
# Si estamos en los primeros 5 frames, O la nueva foto es notoriamente mejor
|
||
|
|
# que nuestro mejor recuerdo (>20% mejor), somos una "esponja".
|
||
|
|
if self.muestras_capturadas <= 5 or score_nuevo > (score_viejo * 1.20):
|
||
|
|
# Alpha bajo = Sobrescribe agresivamente el pasado
|
||
|
|
alpha_deep = 0.20 # 20% vieja, 80% NUEVA
|
||
|
|
alpha_color = 0.20
|
||
|
|
alpha_geo = 0.50
|
||
|
|
self.score_ema = score_nuevo # Actualizamos nuestro estándar de calidad
|
||
|
|
else:
|
||
|
|
# Estado de madurez: La firma ya es excelente y estable.
|
||
|
|
# Los cambios deben ser muy lentos para no arruinarla.
|
||
|
|
alpha_deep = 0.85 # 85% vieja, 15% nueva
|
||
|
|
alpha_color = 0.50
|
||
|
|
alpha_geo = 0.80
|
||
|
|
self.score_ema = max(score_nuevo, score_viejo)
|
||
|
|
|
||
|
|
ema = self.firma_ema
|
||
|
|
|
||
|
|
if 'deep' in firma:
|
||
|
|
ema['deep'] = alpha_deep * ema['deep'] + (1-alpha_deep) * firma['deep']
|
||
|
|
n = np.linalg.norm(ema['deep'])
|
||
|
|
if n > 0: ema['deep'] /= n
|
||
|
|
|
||
|
|
# Ya sin la textura LBP que eliminamos por rendimiento
|
||
|
|
for key in ['color']:
|
||
|
|
if key in firma and firma[key] is not None:
|
||
|
|
ema[key] = alpha_color * ema[key] + (1-alpha_color) * firma[key]
|
||
|
|
normed = cv2.normalize(ema[key], None, alpha=1.0, norm_type=cv2.NORM_L1)
|
||
|
|
ema[key] = normed.flatten()
|
||
|
|
|
||
|
|
if 'ratio_hw' in firma:
|
||
|
|
ema['ratio_hw'] = alpha_geo * ema['ratio_hw'] + (1-alpha_geo) * firma['ratio_hw']
|
||
|
|
|
||
|
|
if 'anatomia' in firma and firma['anatomia'] is not None:
|
||
|
|
if 'anatomia' not in ema or ema['anatomia'] is None:
|
||
|
|
ema['anatomia'] = np.array(firma['anatomia'], dtype=np.float32)
|
||
|
|
else:
|
||
|
|
ema['anatomia'] = alpha_geo * np.asarray(ema['anatomia']) + (1-alpha_geo) * np.asarray(firma['anatomia'])
|
||
|
|
|
||
|
|
if 'calidad' in firma:
|
||
|
|
ema['calidad'] = max(ema.get('calidad', 0), firma['calidad'])
|
||
|
|
|
||
|
|
def predict(self, turno_activo=True):
|
||
|
|
if self.time_since_update > 30: return None
|
||
|
|
|
||
|
|
if getattr(self, 'en_grupo', False):
|
||
|
|
if (self.kf.statePost[6] + self.kf.statePost[2]) <= 0: self.kf.statePost[6] = 0.0
|
||
|
|
self.kf.predict()
|
||
|
|
if turno_activo: self.time_since_update += 1
|
||
|
|
self.aprendiendo = False
|
||
|
|
self.box = self._bbox(self.kf.statePre)
|
||
|
|
return self.box
|
||
|
|
|
||
|
|
if self.time_since_update > 0:
|
||
|
|
self.kf.statePost[4] *= 0.5
|
||
|
|
self.kf.statePost[5] *= 0.5
|
||
|
|
self.kf.statePost[6] = 0.0
|
||
|
|
self.frames_continuos = max(0, self.frames_continuos - 1)
|
||
|
|
|
||
|
|
if (self.kf.statePost[6] + self.kf.statePost[2]) <= 0: self.kf.statePost[6] = 0.0
|
||
|
|
self.kf.predict()
|
||
|
|
if turno_activo: self.time_since_update += 1
|
||
|
|
self.aprendiendo = False
|
||
|
|
self.box = self._bbox(self.kf.statePre)
|
||
|
|
return self.box
|
||
|
|
|
||
|
|
def update(self, box, en_grupo, now, kpts=None, firma_actual=None):
|
||
|
|
self.ts_ultima_deteccion = now
|
||
|
|
self.time_since_update, self.en_grupo = 0, en_grupo
|
||
|
|
self.box = list(box)
|
||
|
|
self.kf.correct(self._z(box))
|
||
|
|
|
||
|
|
if not en_grupo:
|
||
|
|
self.frames_observados += 1
|
||
|
|
self.frames_continuos += 1
|
||
|
|
self.listo_para_id = True
|
||
|
|
|
||
|
|
|
||
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
|
# MEMORIA GLOBAL CON PERSISTENCIA
|
||
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
|
class GlobalMemory:
|
||
|
|
def __init__(self):
|
||
|
|
self.db = {}
|
||
|
|
self.next_gid = 100
|
||
|
|
self.lock = threading.RLock()
|
||
|
|
self.ruta_memoria = os.path.join("cache_nombres", "memoria_ids.json")
|
||
|
|
self.ruta_movimientos = os.path.join("cache_nombres", "registro_movimientos.csv")
|
||
|
|
os.makedirs("cache_nombres", exist_ok=True)
|
||
|
|
self.nombres_activos = {}
|
||
|
|
self.ultimos_saludos = {}
|
||
|
|
self._votos_nombre = {}
|
||
|
|
self.fecha_actual = datetime.now().date()
|
||
|
|
self._cargar_memoria()
|
||
|
|
self.reserva_temporal = {}
|
||
|
|
self.id_locks = {}
|
||
|
|
self.ultimas_detecciones = {}
|
||
|
|
|
||
|
|
|
||
|
|
def consolidar_clones(self):
|
||
|
|
|
||
|
|
#Rutina de Auto-Merge comercial con Fusión Forzada por Biometría Facial.
|
||
|
|
from scipy.spatial.distance import cosine
|
||
|
|
|
||
|
|
with self.lock:
|
||
|
|
ids_activos = list(self.db.keys())
|
||
|
|
|
||
|
|
for i in range(len(ids_activos)):
|
||
|
|
for j in range(i + 1, len(ids_activos)):
|
||
|
|
id1, id2 = ids_activos[i], ids_activos[j]
|
||
|
|
|
||
|
|
if id1 not in self.db or id2 not in self.db:
|
||
|
|
continue
|
||
|
|
|
||
|
|
ema1 = self.db[id1].get('ema')
|
||
|
|
ema2 = self.db[id2].get('ema')
|
||
|
|
if not ema1 or not ema2: continue
|
||
|
|
|
||
|
|
deep1, deep2 = ema1.get('deep'), ema2.get('deep')
|
||
|
|
if deep1 is None or deep2 is None: continue
|
||
|
|
|
||
|
|
nom1 = self.db[id1].get('nombre')
|
||
|
|
nom2 = self.db[id2].get('nombre')
|
||
|
|
|
||
|
|
fusion_forzada = False
|
||
|
|
|
||
|
|
# ⚡ AUTORIDAD FACIAL
|
||
|
|
if nom1 and nom2 and nom1 == nom2:
|
||
|
|
fusion_forzada = True # InsightFace los bautizó igual. Fusionamos sin importar la ropa.
|
||
|
|
elif nom1 and nom2 and nom1 != nom2:
|
||
|
|
continue # Nombres distintos = Personas distintas. Bloquear fusión.
|
||
|
|
|
||
|
|
similitud = 1.0 - cosine(deep1, deep2)
|
||
|
|
|
||
|
|
# Se fusionan si OSNet está seguro O si InsightFace forzó la orden
|
||
|
|
if similitud > 0.76 or fusion_forzada:
|
||
|
|
id_ganador = min(id1, id2)
|
||
|
|
id_clon = max(id1, id2)
|
||
|
|
|
||
|
|
if self.db[id_clon].get('nombre'):
|
||
|
|
self.db[id_ganador]['nombre'] = self.db[id_clon]['nombre']
|
||
|
|
|
||
|
|
# Promediamos las firmas para que OSNet aprenda de ambos ángulos
|
||
|
|
self.db[id_ganador]['ema']['deep'] = (self.db[id_ganador]['ema']['deep'] * 0.7) + (self.db[id_clon]['ema']['deep'] * 0.3)
|
||
|
|
|
||
|
|
self.db[id_clon] = {'fusionado_con': id_ganador, 'ts': time.time()}
|
||
|
|
|
||
|
|
print(f" [AUTO-MERGE] Fragmentación corregida. Clon ID {id_clon} absorbido por Original ID {id_ganador} (Sim: {similitud:.2f})")
|
||
|
|
razon = "FUSIÓN FACIAL FORZADA" if fusion_forzada else "Fragmentación corregida"
|
||
|
|
print(f" [AUTO-MERGE] {razon}. Clon ID {id_clon} absorbido por Original ID {id_ganador} (Sim: {similitud:.2f})")
|
||
|
|
|
||
|
|
# Método 1: Registrar detección global
|
||
|
|
def registrar_deteccion_global(self, gid, cam_id, firma, now):
|
||
|
|
#Registra que un ID fue detectado en una cámara para coordinación solapada.
|
||
|
|
if firma is None or 'deep' not in firma:
|
||
|
|
return
|
||
|
|
|
||
|
|
# Hash simple de la firma deep para comparación rápida
|
||
|
|
firma_hash = hash(firma['deep'].tobytes()) % 10000
|
||
|
|
|
||
|
|
self.ultimas_detecciones[gid] = {
|
||
|
|
'cam': str(cam_id),
|
||
|
|
'ts': now,
|
||
|
|
'firma_hash': firma_hash
|
||
|
|
}
|
||
|
|
|
||
|
|
# Limpiar detecciones antiguas (>10 segundos)
|
||
|
|
antiguas = [g for g, d in self.ultimas_detecciones.items() if now - d['ts'] > 10.0]
|
||
|
|
for g in antiguas:
|
||
|
|
del self.ultimas_detecciones[g]
|
||
|
|
|
||
|
|
# Método 2: Buscar coincidencia solapada
|
||
|
|
def buscar_coincidencia_solapada(self, firma, cam_id, now, umbral_sim=0.85):
|
||
|
|
#Busca si esta detección podría ser un ID ya detectado en cámara vecina recientemente.
|
||
|
|
if firma is None or 'deep' not in firma:
|
||
|
|
return None
|
||
|
|
|
||
|
|
firma_hash = hash(firma['deep'].tobytes()) % 10000
|
||
|
|
vecinos = VECINOS.get(str(cam_id), [])
|
||
|
|
|
||
|
|
for gid, info in self.ultimas_detecciones.items():
|
||
|
|
# Solo considerar si fue detectado en cámara vecina hace <5s
|
||
|
|
if info['cam'] not in vecinos:
|
||
|
|
continue
|
||
|
|
if now - info['ts'] > 5.0:
|
||
|
|
continue
|
||
|
|
|
||
|
|
# Si el hash coincide exactamente, es muy probable que sea el mismo
|
||
|
|
if info['firma_hash'] == firma_hash:
|
||
|
|
return gid
|
||
|
|
|
||
|
|
# Si no, hacer comparación completa (más costosa)
|
||
|
|
ema = self.db.get(gid, {}).get('ema')
|
||
|
|
if ema is not None:
|
||
|
|
sim = similitud_hibrida(firma, ema, cross_cam=True)
|
||
|
|
if sim >= umbral_sim:
|
||
|
|
return gid
|
||
|
|
|
||
|
|
return None
|
||
|
|
|
||
|
|
def lock_id_for_camera(self, gid, cam_id, now, duration=15.0):
|
||
|
|
self.id_locks[gid] = {
|
||
|
|
'cam': str(cam_id),
|
||
|
|
'unlock_ts': now + duration,
|
||
|
|
'nombre': self.db.get(gid, {}).get('nombre')
|
||
|
|
}
|
||
|
|
print(f" [ID LOCK] ID {gid} bloqueado en Cam{cam_id} por {duration}s")
|
||
|
|
|
||
|
|
def is_id_locked(self, gid, cam_id, now):
|
||
|
|
if gid not in self.id_locks:
|
||
|
|
return False
|
||
|
|
lock = self.id_locks[gid]
|
||
|
|
if lock['cam'] != str(cam_id):
|
||
|
|
return False
|
||
|
|
if now < lock['unlock_ts']:
|
||
|
|
return True
|
||
|
|
else:
|
||
|
|
del self.id_locks[gid]
|
||
|
|
return False
|
||
|
|
|
||
|
|
def limpiar_locks_vencidos(self, now):
|
||
|
|
vencidos = [gid for gid, lock in self.id_locks.items() if now >= lock['unlock_ts']]
|
||
|
|
for gid in vencidos:
|
||
|
|
del self.id_locks[gid]
|
||
|
|
|
||
|
|
def registrar_salida(self, gid, cam_salida, now):
|
||
|
|
if gid not in self.db: return
|
||
|
|
|
||
|
|
vecinas = VECINOS.get(str(cam_salida), [])
|
||
|
|
self.reserva_temporal[gid] = {
|
||
|
|
'cam_salida': str(cam_salida),
|
||
|
|
'ts_salida': now,
|
||
|
|
'cam_esperadas': vecinas,
|
||
|
|
'nombre': self.db[gid].get('nombre')
|
||
|
|
}
|
||
|
|
|
||
|
|
reservas_a_limpiar = []
|
||
|
|
for gid_reserva, info in self.reserva_temporal.items():
|
||
|
|
if now - info['ts_salida'] > 30.0:
|
||
|
|
reservas_a_limpiar.append(gid_reserva)
|
||
|
|
|
||
|
|
for gid_limpiar in reservas_a_limpiar:
|
||
|
|
del self.reserva_temporal[gid_limpiar]
|
||
|
|
|
||
|
|
def _bonus_reserva(self, gid_evaluado, cam_actual, now):
|
||
|
|
return 0.0
|
||
|
|
|
||
|
|
|
||
|
|
def _firma_a_dict(self, firma):
|
||
|
|
if not firma: return None
|
||
|
|
res = {}
|
||
|
|
for k, v in firma.items():
|
||
|
|
if isinstance(v, np.ndarray):
|
||
|
|
res[k] = v.tolist()
|
||
|
|
elif k == 'anatomia' and v is not None:
|
||
|
|
res[k] = v.tolist() if isinstance(v, np.ndarray) else list(v)
|
||
|
|
else:
|
||
|
|
res[k] = v
|
||
|
|
return res
|
||
|
|
|
||
|
|
def _dict_a_firma(self, d):
|
||
|
|
if not d: return None
|
||
|
|
res = {}
|
||
|
|
for k, v in d.items():
|
||
|
|
if isinstance(v, list) and k in ['deep', 'color', 'textura', 'anatomia']:
|
||
|
|
res[k] = np.array(v, dtype=np.float32)
|
||
|
|
else:
|
||
|
|
res[k] = v
|
||
|
|
return res
|
||
|
|
|
||
|
|
def _cargar_memoria(self):
|
||
|
|
self.fecha_actual = datetime.now().date()
|
||
|
|
if os.path.exists(self.ruta_memoria):
|
||
|
|
# ⚡ RESET DIARIO EN INICIO: Si el archivo es de un día anterior, iniciar limpio.
|
||
|
|
fecha_archivo = datetime.fromtimestamp(os.path.getmtime(self.ruta_memoria)).date()
|
||
|
|
if fecha_archivo != self.fecha_actual:
|
||
|
|
print(f"[Memoria] Archivo de ayer ({fecha_archivo}). Iniciando memoria limpia desde ID 100.")
|
||
|
|
return
|
||
|
|
|
||
|
|
try:
|
||
|
|
with open(self.ruta_memoria, 'r') as f:
|
||
|
|
datos = json.load(f)
|
||
|
|
|
||
|
|
ahora = time.time()
|
||
|
|
cargados_validos = 0
|
||
|
|
|
||
|
|
for gid_str, info in datos.items():
|
||
|
|
ts_guardado = info.get('ts', 0)
|
||
|
|
|
||
|
|
# ⚡ PERSISTENCIA COMERCIAL
|
||
|
|
es_vip = info.get('nombre') is not None
|
||
|
|
# Los VIP duran 3 horas (10800s), los desconocidos 1 hora (3600s) para no saturar RAM
|
||
|
|
if es_vip and (ahora - ts_guardado > 10800.0): continue
|
||
|
|
if not es_vip and (ahora - ts_guardado > 3600.0): continue
|
||
|
|
|
||
|
|
gid = int(gid_str)
|
||
|
|
self.db[gid] = {
|
||
|
|
'ema': self._dict_a_firma(info.get('ema')),
|
||
|
|
'last_cam': info.get('last_cam', '1'),
|
||
|
|
'ts': ts_guardado,
|
||
|
|
'nombre': info.get('nombre'),
|
||
|
|
'actualizaciones_globales': info.get('actualizaciones_globales', 0),
|
||
|
|
'firmas_altas': [self._dict_a_firma(f) for f in info.get('firmas_altas', []) if f],
|
||
|
|
}
|
||
|
|
cargados_validos += 1
|
||
|
|
|
||
|
|
if cargados_validos > 0:
|
||
|
|
self.next_gid = max([int(k) for k in datos.keys()] + [99]) + 1
|
||
|
|
print(f"[Memoria] {cargados_validos} IDs persistentes válidos cargados.")
|
||
|
|
except Exception as e:
|
||
|
|
print(f"[Memoria] Error cargando: {e}")
|
||
|
|
|
||
|
|
# ⚡ FIX: Cargar los saludos previos para evitar spam auditivo
|
||
|
|
ruta_saludos = os.path.join("cache_nombres", "registro_saludos.json")
|
||
|
|
if os.path.exists(ruta_saludos):
|
||
|
|
try:
|
||
|
|
with open(ruta_saludos, 'r') as f:
|
||
|
|
self.ultimos_saludos = json.load(f)
|
||
|
|
except Exception: pass
|
||
|
|
|
||
|
|
def guardar_memoria(self):
|
||
|
|
try:
|
||
|
|
datos = {}
|
||
|
|
for gid, info in self.db.items():
|
||
|
|
datos[str(gid)] = {
|
||
|
|
'ema': self._firma_a_dict(info.get('ema')),
|
||
|
|
'last_cam': info.get('last_cam', '1'),
|
||
|
|
'nombre': info.get('nombre'),
|
||
|
|
'ts': info.get('ts', time.time()),
|
||
|
|
'actualizaciones_globales': info.get('actualizaciones_globales', 0),
|
||
|
|
'firmas_altas': [self._firma_a_dict(f) for f in info.get('firmas_altas', [])],
|
||
|
|
}
|
||
|
|
with open(self.ruta_memoria, 'w') as f:
|
||
|
|
json.dump(datos, f)
|
||
|
|
except Exception as e:
|
||
|
|
print(f"[Memoria] Error guardando: {e}")
|
||
|
|
|
||
|
|
def registrar_movimiento(self, nombre, cam_origen, cam_destino, duracion_seg):
|
||
|
|
nuevo = not os.path.exists(self.ruta_movimientos)
|
||
|
|
try:
|
||
|
|
with open(self.ruta_movimientos, 'a', newline='', encoding='utf-8') as f:
|
||
|
|
w = csv.writer(f)
|
||
|
|
if nuevo: w.writerow(["Fecha_Hora", "Nombre", "Origen", "Destino", "Seg_en_Origen"])
|
||
|
|
w.writerow([datetime.now().strftime("%Y-%m-%d %H:%M:%S"), nombre, cam_origen, cam_destino, round(duracion_seg, 2)])
|
||
|
|
except: pass
|
||
|
|
|
||
|
|
def asignar_nombre(self, gid, nombre, confianza, es_fusion=False):
|
||
|
|
VOTOS_NOMBRE_MIN = 3
|
||
|
|
UMBRAL_VOTO = 0.52
|
||
|
|
UMBRAL_CONFIRMACION = 0.62
|
||
|
|
|
||
|
|
if confianza < (0.50 if es_fusion else UMBRAL_VOTO):
|
||
|
|
return False
|
||
|
|
|
||
|
|
with self.lock:
|
||
|
|
rec = self.db.get(gid)
|
||
|
|
if not rec: return False
|
||
|
|
|
||
|
|
nombre_viejo = rec.get('nombre')
|
||
|
|
|
||
|
|
if not hasattr(self, 'nombres_activos'):
|
||
|
|
self.nombres_activos = {}
|
||
|
|
|
||
|
|
gid_ocupante = self.nombres_activos.get(nombre)
|
||
|
|
if gid_ocupante is not None and gid_ocupante != gid:
|
||
|
|
if gid_ocupante in self.db:
|
||
|
|
ts_ultimo = self.db[gid_ocupante].get('ts', 0)
|
||
|
|
if (time.time() - ts_ultimo) < 10.0:
|
||
|
|
return False
|
||
|
|
|
||
|
|
conf_actual = rec.get('confianza_nombre', 0.0)
|
||
|
|
if nombre_viejo == nombre and conf_actual > confianza:
|
||
|
|
return False
|
||
|
|
|
||
|
|
if not hasattr(self, '_votos_nombre'):
|
||
|
|
self._votos_nombre = {}
|
||
|
|
|
||
|
|
if gid not in self._votos_nombre:
|
||
|
|
self._votos_nombre[gid] = {}
|
||
|
|
|
||
|
|
votos_gid = self._votos_nombre[gid]
|
||
|
|
|
||
|
|
if any(n != nombre for n in votos_gid.keys()):
|
||
|
|
votos_gid.clear()
|
||
|
|
|
||
|
|
if nombre not in votos_gid:
|
||
|
|
votos_gid[nombre] = []
|
||
|
|
|
||
|
|
votos_gid[nombre].append(confianza)
|
||
|
|
votos_gid[nombre] = votos_gid[nombre][-6:]
|
||
|
|
|
||
|
|
n_votos = len(votos_gid[nombre])
|
||
|
|
conf_media = sum(votos_gid[nombre]) / n_votos
|
||
|
|
|
||
|
|
if n_votos < VOTOS_NOMBRE_MIN or conf_media < UMBRAL_CONFIRMACION:
|
||
|
|
return False
|
||
|
|
|
||
|
|
if nombre_viejo is not None and nombre_viejo != nombre:
|
||
|
|
if confianza < 0.70:
|
||
|
|
print(f" [PROTECCIÓN] Rechazando cambio de {nombre_viejo} a {nombre} (conf={confianza:.2f} < 0.70)")
|
||
|
|
return False # Rechazar cambio automático
|
||
|
|
# Si confianza >= 0.70, permitir cambio pero con advertencia
|
||
|
|
print(f" [CAMBIO VIP] ID {gid} cambió de {nombre_viejo} a {nombre} (conf={confianza:.2f} >= 0.70)")
|
||
|
|
|
||
|
|
rec['nombre'] = nombre
|
||
|
|
rec['confianza_nombre'] = conf_media
|
||
|
|
self.nombres_activos[nombre] = gid
|
||
|
|
|
||
|
|
votos_gid.clear()
|
||
|
|
|
||
|
|
tipo = "FUSIÓN" if es_fusion else "BAUTIZO"
|
||
|
|
print(f" [{tipo}] ID {gid} confirmado como {nombre} | Votos: {n_votos} | Conf. Media: {conf_media:.2f}")
|
||
|
|
return True
|
||
|
|
|
||
|
|
def confirmar_firma_vip(self, gid, ts=None):
|
||
|
|
with self.lock:
|
||
|
|
rec = self.db.get(gid)
|
||
|
|
if rec and rec.get('ema') is not None:
|
||
|
|
# ⚡ FIX P2: Respetar la cristalización
|
||
|
|
if rec.get('cristalizado', False):
|
||
|
|
print(f" [MEMORIA] ID {gid} está cristalizado. No se añaden más firmas VIP.")
|
||
|
|
return
|
||
|
|
|
||
|
|
firma_actual = rec['ema']
|
||
|
|
if 'firmas_altas' not in rec:
|
||
|
|
rec['firmas_altas'] = []
|
||
|
|
rec['firmas_altas'].append({
|
||
|
|
k: v.copy() if isinstance(v, np.ndarray) else v
|
||
|
|
for k, v in firma_actual.items()
|
||
|
|
})
|
||
|
|
if ts is not None:
|
||
|
|
rec['ts'] = ts
|
||
|
|
print(f" [MEMORIA] Firma de ID {gid} bloqueada y protegida como VIP.")
|
||
|
|
|
||
|
|
def guardar_saludo(self, nombre):
|
||
|
|
#Guarda el saludo en memoria y lo escribe en el disco duro.
|
||
|
|
ahora = time.time()
|
||
|
|
fecha_hoy = datetime.fromtimestamp(ahora).strftime("%Y-%m-%d")
|
||
|
|
|
||
|
|
self.ultimos_saludos[nombre] = {
|
||
|
|
'fecha': fecha_hoy,
|
||
|
|
'timestamp': ahora
|
||
|
|
}
|
||
|
|
|
||
|
|
# Guardar físicamente en el disco
|
||
|
|
ruta_saludos = os.path.join("cache_nombres", "registro_saludos.json")
|
||
|
|
try:
|
||
|
|
with open(ruta_saludos, 'w') as f:
|
||
|
|
json.dump(self.ultimos_saludos, f, indent=4)
|
||
|
|
except Exception as e:
|
||
|
|
print(f" [MEMORIA] Error guardando registro de saludos: {e}")
|
||
|
|
|
||
|
|
def _actualizar_sin_lock(self, gid, firma, cam_id, now):
|
||
|
|
if gid not in self.db:
|
||
|
|
self.db[gid] = {
|
||
|
|
'ema': firma,
|
||
|
|
'last_cam': cam_id,
|
||
|
|
'ts': now,
|
||
|
|
'nombre': None,
|
||
|
|
'actualizaciones_globales': 1,
|
||
|
|
'cristalizado': False
|
||
|
|
}
|
||
|
|
return
|
||
|
|
|
||
|
|
rec = self.db[gid]
|
||
|
|
|
||
|
|
# ⚡ CRISTALIZACIÓN: Después de 15 actualizaciones, CONGELAR EMA
|
||
|
|
if not rec.get('cristalizado', False) and rec.get('actualizaciones_globales', 0) >= 15:
|
||
|
|
rec['cristalizado'] = True
|
||
|
|
print(f" [CRISTALIZADO] ID {gid} ha alcanzado madurez. EMA congelado permanentemente.")
|
||
|
|
|
||
|
|
# ⚡ BLINDAJE DE CRISTALIZACIÓN
|
||
|
|
# Si el ID ya es maduro, actualizamos dónde fue visto por última vez,
|
||
|
|
# pero NUNCA permitimos que una cámara modifique su firma maestra perfecta.
|
||
|
|
if rec.get('cristalizado', False):
|
||
|
|
rec['last_cam'] = cam_id
|
||
|
|
rec['ts'] = now
|
||
|
|
rec['actualizaciones_globales'] += 1
|
||
|
|
return
|
||
|
|
|
||
|
|
ema = rec['ema']
|
||
|
|
rec['actualizaciones_globales'] += 1
|
||
|
|
|
||
|
|
if ema is None:
|
||
|
|
rec['ema'] = firma
|
||
|
|
else:
|
||
|
|
alpha_deep = 0.85
|
||
|
|
alpha_color = 0.50
|
||
|
|
alpha_geo = 0.80
|
||
|
|
|
||
|
|
if 'deep' in firma:
|
||
|
|
ema['deep'] = alpha_deep * ema['deep'] + (1-alpha_deep) * firma['deep']
|
||
|
|
n = np.linalg.norm(ema['deep'])
|
||
|
|
if n > 0: ema['deep'] /= n
|
||
|
|
|
||
|
|
for key in ['color']:
|
||
|
|
if key in firma and firma[key] is not None:
|
||
|
|
ema[key] = alpha_color * ema[key] + (1-alpha_color) * firma[key]
|
||
|
|
normed = cv2.normalize(ema[key], None, alpha=1.0, norm_type=cv2.NORM_L1)
|
||
|
|
ema[key] = normed.flatten()
|
||
|
|
|
||
|
|
if 'ratio_hw' in firma:
|
||
|
|
ema['ratio_hw'] = alpha_geo * ema['ratio_hw'] + (1-alpha_geo) * firma['ratio_hw']
|
||
|
|
|
||
|
|
if 'anatomia' in firma and firma['anatomia'] is not None:
|
||
|
|
if 'anatomia' not in ema or ema['anatomia'] is None:
|
||
|
|
ema['anatomia'] = np.array(firma['anatomia'], dtype=np.float32)
|
||
|
|
else:
|
||
|
|
ema['anatomia'] = 0.80 * np.asarray(ema['anatomia']) + 0.20 * np.asarray(firma['anatomia'])
|
||
|
|
|
||
|
|
if 'calidad' in firma:
|
||
|
|
ema['calidad'] = firma['calidad']
|
||
|
|
|
||
|
|
rec['last_cam'] = cam_id
|
||
|
|
rec['ts'] = now
|
||
|
|
|
||
|
|
def _sim_contra_firma(self, firma_nueva, firma_guardada, cross_cam, confianza_fisica):
|
||
|
|
if firma_guardada is None: return 0.0
|
||
|
|
return similitud_hibrida(firma_nueva, firma_guardada, cross_cam, confianza_fisica)
|
||
|
|
|
||
|
|
def _sim_robusta(self, firma_nueva, gid_data, cross_cam, confianza_fisica=0.0):
|
||
|
|
ema = gid_data.get('ema')
|
||
|
|
if not ema: return 0.0
|
||
|
|
|
||
|
|
sim_ema = self._sim_contra_firma(firma_nueva, ema, cross_cam, confianza_fisica)
|
||
|
|
|
||
|
|
if sim_ema > 0.75:
|
||
|
|
return sim_ema
|
||
|
|
|
||
|
|
sim_anclas = [sim_ema]
|
||
|
|
for ancla in gid_data.get('firmas_altas', []):
|
||
|
|
sim_anclas.append(self._sim_contra_firma(firma_nueva, ancla, cross_cam, confianza_fisica))
|
||
|
|
|
||
|
|
mejor_ancla = max(sim_anclas[1:]) if len(sim_anclas) > 1 else sim_ema
|
||
|
|
return sim_ema * 0.60 + mejor_ancla * 0.40
|
||
|
|
|
||
|
|
def identificar_candidato(self, firma, cam_id, now, active_gids, en_borde=False, firma_ema_local=None, confianza_fisica=0.0):
|
||
|
|
self.limpiar_fantasmas()
|
||
|
|
|
||
|
|
with self.lock:
|
||
|
|
candidatos = []
|
||
|
|
|
||
|
|
for gid, data in self.db.items():
|
||
|
|
dt = now - data.get('ts', now)
|
||
|
|
distancia = self._distancia_topologica(str(data['last_cam']), str(cam_id))
|
||
|
|
|
||
|
|
misma_cam = (distancia == 0)
|
||
|
|
es_vecino = (distancia == 1)
|
||
|
|
es_salto = (distancia == 2)
|
||
|
|
es_cross = not misma_cam
|
||
|
|
|
||
|
|
if gid in active_gids:
|
||
|
|
if misma_cam or distancia >= 3: continue
|
||
|
|
if dt > TIEMPO_MAX_AUSENCIA or data.get('ema') is None: continue
|
||
|
|
if self.is_id_locked(gid, cam_id, now): continue
|
||
|
|
|
||
|
|
if not misma_cam:
|
||
|
|
if (es_vecino and dt < 0.3) or (es_salto and dt < 2.0) or (distancia >= 3 and dt < 8.0):
|
||
|
|
continue
|
||
|
|
|
||
|
|
# ⚡ CÁLCULO OBLIGATORIO DE SIMILITUD ANTES DE CUALQUIER FILTRO
|
||
|
|
sim = self._sim_robusta(firma, data, es_cross, confianza_fisica)
|
||
|
|
|
||
|
|
sim_deep, sim_color, sim_anat = -1.0, -1.0, -1.0
|
||
|
|
ema = data.get("ema")
|
||
|
|
if ema is not None:
|
||
|
|
try:
|
||
|
|
sim_deep, sim_color, sim_anat = desglose_similitud(firma, ema, cross_cam=es_cross)
|
||
|
|
except:
|
||
|
|
pass
|
||
|
|
|
||
|
|
# Penalizaciones y ajustes
|
||
|
|
if not misma_cam:
|
||
|
|
# NUNCA bloqueamos cámaras vecinas (distancia 1) por tiempo,
|
||
|
|
# porque al estar solapadas, pueden ver a la persona en el mismo milisegundo (dt=0.0).
|
||
|
|
if es_salto and dt < 1.5:
|
||
|
|
continue # Mínimo 1.5s para saltar una cámara entera
|
||
|
|
if distancia >= 3 and dt < 5.0:
|
||
|
|
continue # Mínimo 5.0s para cruzar a la otra punta del edificio
|
||
|
|
|
||
|
|
if firma_ema_local is not None:
|
||
|
|
sim_local = self._sim_robusta(firma_ema_local, data, es_cross, confianza_fisica)
|
||
|
|
sim = 0.60 * sim + 0.40 * sim_local
|
||
|
|
|
||
|
|
# ⚡ VALIDACIONES VIP
|
||
|
|
es_vip = data.get('nombre') is not None
|
||
|
|
tiene_firmas_altas = len(data.get('firmas_altas', [])) > 0
|
||
|
|
|
||
|
|
if es_vip and tiene_firmas_altas and sim < 0.78:
|
||
|
|
continue
|
||
|
|
elif es_vip and not misma_cam and sim < 0.75:
|
||
|
|
continue
|
||
|
|
|
||
|
|
# ─────────────────────────────────────────────────────────────
|
||
|
|
# ⚡ UMBRALES CORREGIDOS (Ni parálisis, ni robo de identidad)
|
||
|
|
# ─────────────────────────────────────────────────────────────
|
||
|
|
penal_multitud = min(0.04, max(0, len(active_gids)-10) * 0.002)
|
||
|
|
|
||
|
|
if misma_cam: umbral = 0.68
|
||
|
|
elif es_vecino: umbral = 0.72
|
||
|
|
elif es_salto: umbral = 0.75
|
||
|
|
else: umbral = 0.78
|
||
|
|
|
||
|
|
# AHORA SÍ: sim_anat y sim_deep existen
|
||
|
|
if sim_anat > 0.90 and sim_deep > 0.65:
|
||
|
|
umbral -= 0.04
|
||
|
|
|
||
|
|
if en_borde and not misma_cam:
|
||
|
|
umbral -= 0.04
|
||
|
|
|
||
|
|
umbral = max(0.65, umbral + penal_multitud)
|
||
|
|
|
||
|
|
bonus_aplicado = min(self._bonus_reserva(gid, cam_id, now), 0.03)
|
||
|
|
sim += bonus_aplicado
|
||
|
|
|
||
|
|
# ⚡ RESTAURAR LOGS DE EVAL PARA DIAGNÓSTICO
|
||
|
|
if sim >= umbral - 0.10 or len(candidatos) < 3:
|
||
|
|
estado = "ACEPTADO" if sim >= umbral else "RECHAZADO"
|
||
|
|
faltante = umbral - sim if sim < umbral else 0.0
|
||
|
|
info = f"(Faltó {faltante:.2f})" if faltante > 0 else ""
|
||
|
|
bonus_str = f" [+{bonus_aplicado:.2f}]" if bonus_aplicado > 0 else ""
|
||
|
|
|
||
|
|
# ⚡ FIX P0: Eliminada la llamada doble a desglose_similitud.
|
||
|
|
# Usamos sim_deep, sim_color y sim_anat pre-calculados.
|
||
|
|
color_str = f"{sim_color:.2f}" if sim_color >= 0 else "N/A"
|
||
|
|
anat_str = f"{sim_anat:.2f}" if sim_anat >= 0 else "N/A"
|
||
|
|
|
||
|
|
print(
|
||
|
|
f"[EVAL] Cam{cam_id} evalúa ID{gid} "
|
||
|
|
f"(Cam{data['last_cam']}, dt={dt:.1f}s) | "
|
||
|
|
f"Deep={sim_deep:.2f} | Color={color_str} | Anat={anat_str} | "
|
||
|
|
f"Final={sim:.2f}{bonus_str} | Umb={umbral:.2f} {info} -> {estado}"
|
||
|
|
)
|
||
|
|
|
||
|
|
if sim >= umbral:
|
||
|
|
candidatos.append((sim, sim, gid))
|
||
|
|
|
||
|
|
firma_guardar = firma_ema_local if firma_ema_local is not None else firma
|
||
|
|
|
||
|
|
if not candidatos:
|
||
|
|
nid = self.next_gid
|
||
|
|
self.next_gid += 1
|
||
|
|
self._actualizar_sin_lock(nid, firma_guardar, cam_id, now)
|
||
|
|
return nid, False
|
||
|
|
|
||
|
|
candidatos.sort(reverse=True)
|
||
|
|
best_sim, _, best_gid = candidatos[0]
|
||
|
|
|
||
|
|
# ⚡ ANTI-GEMELOS RECALIBRADO: Solo duda si son casi idénticos (<0.02)
|
||
|
|
if len(candidatos) >= 2:
|
||
|
|
_, segunda_sim, segundo_gid = candidatos[1]
|
||
|
|
if abs(best_sim - segunda_sim) < 0.02 and best_sim < 0.75:
|
||
|
|
nid = self.next_gid
|
||
|
|
self.next_gid += 1
|
||
|
|
self._actualizar_sin_lock(nid, firma_guardar, cam_id, now)
|
||
|
|
return nid, False
|
||
|
|
|
||
|
|
if best_gid in self.reserva_temporal:
|
||
|
|
del self.reserva_temporal[best_gid]
|
||
|
|
|
||
|
|
# ─────────────────────────────────────────────────────────────
|
||
|
|
# ⚡ FIX: REGISTRO DE MOVIMIENTOS EN CSV
|
||
|
|
# Antes de actualizar, revisamos si viene de OTRA cámara y si tiene nombre
|
||
|
|
# ─────────────────────────────────────────────────────────────
|
||
|
|
cam_anterior = self.db[best_gid].get('last_cam')
|
||
|
|
nombre_registrado = self.db[best_gid].get('nombre')
|
||
|
|
|
||
|
|
if cam_anterior and str(cam_anterior) != str(cam_id) and nombre_registrado:
|
||
|
|
tiempo_origen = now - self.db[best_gid].get('ts', now)
|
||
|
|
|
||
|
|
# Lanzamos el registro en un hilo para no frenar el motor de tracking
|
||
|
|
threading.Thread(
|
||
|
|
target=self.registrar_movimiento,
|
||
|
|
args=(nombre_registrado, cam_anterior, cam_id, tiempo_origen),
|
||
|
|
daemon=True
|
||
|
|
).start()
|
||
|
|
|
||
|
|
# Ahora sí, actualizamos la memoria con la nueva cámara
|
||
|
|
self._actualizar_sin_lock(best_gid, firma_guardar, cam_id, now)
|
||
|
|
return best_gid, True
|
||
|
|
|
||
|
|
|
||
|
|
def limpiar_fantasmas(self):
|
||
|
|
ahora = time.time()
|
||
|
|
fecha_hoy = datetime.fromtimestamp(ahora).date()
|
||
|
|
|
||
|
|
with self.lock:
|
||
|
|
# ⚡ RESET DIARIO EN CALIENTE (Si el programa corre 24/7)
|
||
|
|
if fecha_hoy != self.fecha_actual:
|
||
|
|
print(f"\n[SISTEMA] ¡Cambio de día! Purgando memoria y reiniciando contador a 100.\n")
|
||
|
|
self.db.clear()
|
||
|
|
self.nombres_activos.clear()
|
||
|
|
self.next_gid = 100
|
||
|
|
self.fecha_actual = fecha_hoy
|
||
|
|
self.guardar_memoria()
|
||
|
|
return
|
||
|
|
|
||
|
|
# ⚡ PURGA NORMAL (3 hrs VIP, 1 hr Desconocidos)
|
||
|
|
muertos = []
|
||
|
|
for g, d in self.db.items():
|
||
|
|
if d.get('nombre') is None and (ahora - d.get('ts', ahora)) > 3600:
|
||
|
|
muertos.append(g)
|
||
|
|
elif d.get('nombre') is not None and (ahora - d.get('ts', ahora)) > 10800:
|
||
|
|
muertos.append(g)
|
||
|
|
|
||
|
|
for g in muertos:
|
||
|
|
nombre_perdido = self.db[g].get('nombre')
|
||
|
|
if nombre_perdido and nombre_perdido in self.nombres_activos:
|
||
|
|
del self.nombres_activos[nombre_perdido]
|
||
|
|
del self.db[g]
|
||
|
|
|
||
|
|
if muertos:
|
||
|
|
self.guardar_memoria()
|
||
|
|
|
||
|
|
def _distancia_topologica(self, cam_o, cam_d):
|
||
|
|
if cam_o == cam_d: return 0
|
||
|
|
vecinos_directos = VECINOS.get(cam_o, [])
|
||
|
|
if cam_d in vecinos_directos: return 1
|
||
|
|
for v in vecinos_directos:
|
||
|
|
if cam_d in VECINOS.get(v, []): return 2
|
||
|
|
return 3
|
||
|
|
|
||
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
|
# GESTOR LOCAL
|
||
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
|
def iou_overlap(A, B):
|
||
|
|
xA,yA = max(A[0],B[0]),max(A[1],B[1])
|
||
|
|
xB,yB = min(A[2],B[2]),min(A[3],B[3])
|
||
|
|
inter = max(0,xB-xA)*max(0,yB-yA)
|
||
|
|
return inter/((A[2]-A[0])*(A[3]-A[1])+(B[2]-B[0])*(B[3]-B[1])-inter+1e-6)
|
||
|
|
|
||
|
|
class CamManager:
|
||
|
|
def __init__(self, cam_id, global_mem):
|
||
|
|
self.cam_id, self.global_mem, self.trackers = cam_id, global_mem, []
|
||
|
|
self.ultimas_detecciones_globales = {}
|
||
|
|
self.tracks_post_cruce = {}
|
||
|
|
|
||
|
|
def _limpiar_tracks_post_cruce(self, now):
|
||
|
|
#Limpia registros de post-cruce antiguos.
|
||
|
|
antiguos = [idx for idx, info in self.tracks_post_cruce.items() if now - info['ts_cruce'] > 10.0]
|
||
|
|
for idx in antiguos:
|
||
|
|
del self.tracks_post_cruce[idx]
|
||
|
|
|
||
|
|
def _detectar_grupo(self, trk, box, todos):
|
||
|
|
x1,y1,x2,y2 = box
|
||
|
|
w,h = x2-x1, y2-y1
|
||
|
|
cx,cy = (x1+x2)/2, (y1+y2)/2
|
||
|
|
fx, fy = (1.1, 0.45) if getattr(trk,'en_grupo',False) else (0.9, 0.35)
|
||
|
|
for o in todos:
|
||
|
|
if o is trk or not hasattr(o,'box') or o.box is None or o.time_since_update > 2: continue
|
||
|
|
if iou_overlap(box, o.box) > 0.05: return True
|
||
|
|
ocx,ocy = (o.box[0]+o.box[2])/2, (o.box[1]+o.box[3])/2
|
||
|
|
if abs(cx-ocx)<w*fx and abs(cy-ocy)<h*fy: return True
|
||
|
|
return False
|
||
|
|
|
||
|
|
def _gestionar_post_grupo(self, now, frame_hd):
|
||
|
|
for trk in self.trackers:
|
||
|
|
if trk.gid is None or trk.en_grupo or getattr(trk,'firma_pre_grupo',None) is None or now - getattr(trk,'ts_salio_grupo',0) < 1.2: continue
|
||
|
|
fa = extraer_firma_hibrida(frame_hd, trk.box)
|
||
|
|
if fa is None: continue
|
||
|
|
sim = similitud_hibrida(fa, trk.firma_pre_grupo, confianza_fisica=trk.confianza_fisica)
|
||
|
|
if sim >= 0.48:
|
||
|
|
trk.fallos_post_grupo = 0; trk.firma_pre_grupo = None
|
||
|
|
with self.global_mem.lock:
|
||
|
|
if trk.gid in self.global_mem.db: self.global_mem.db[trk.gid]['ema'] = fa
|
||
|
|
else:
|
||
|
|
trk.fallos_post_grupo += 1
|
||
|
|
if trk.fallos_post_grupo >= 3:
|
||
|
|
trk.gid = trk.firma_pre_grupo = None; trk.fallos_post_grupo = 0
|
||
|
|
|
||
|
|
def _asignar(self, boxes, confidences, frame_hd, now, keypoints_list):
|
||
|
|
n_trk, n_det = len(self.trackers), len(boxes)
|
||
|
|
firmas = [None] * n_det
|
||
|
|
|
||
|
|
# ─────────────────────────────────────────────────────────────
|
||
|
|
# ⚡ OPTIMIZACIÓN BATCH: Extraemos OSNet para todas las detecciones de un golpe
|
||
|
|
# ─────────────────────────────────────────────────────────────
|
||
|
|
if n_det > 0:
|
||
|
|
rois_validos = []
|
||
|
|
mapa_rois = {}
|
||
|
|
h_hd, w_hd = frame_hd.shape[:2]
|
||
|
|
|
||
|
|
# 1. Recolectar todos los recortes (ROIs) de la imagen
|
||
|
|
for d_idx, box in enumerate(boxes):
|
||
|
|
x1, y1, x2, y2 = box
|
||
|
|
x1_c = max(0, int(x1 * (w_hd/480.0)))
|
||
|
|
y1_c = max(0, int(y1 * (h_hd/270.0)))
|
||
|
|
x2_c = min(w_hd, int(x2 * (w_hd/480.0)))
|
||
|
|
y2_c = min(h_hd, int(y2 * (h_hd/270.0)))
|
||
|
|
roi = frame_hd[y1_c:y2_c, x1_c:x2_c]
|
||
|
|
|
||
|
|
# Descartamos basura óptica de inmediato
|
||
|
|
if roi.size > 0 and roi.shape[0] >= 40 and roi.shape[1] >= 20:
|
||
|
|
mapa_rois[d_idx] = len(rois_validos)
|
||
|
|
rois_validos.append(roi)
|
||
|
|
|
||
|
|
# 2. Ejecutar la red neuronal para todos SIMULTÁNEAMENTE
|
||
|
|
if rois_validos:
|
||
|
|
# ⚡ Llamamos a la función segura que procesa 1 a 1 rápidamente
|
||
|
|
deep_feats_seguros = extraer_features_osnet_seguro(rois_validos)
|
||
|
|
|
||
|
|
# Ensamblar las firmas
|
||
|
|
for d_idx, box in enumerate(boxes):
|
||
|
|
if d_idx in mapa_rois:
|
||
|
|
df_persona = deep_feats_seguros[mapa_rois[d_idx]]
|
||
|
|
kpts = keypoints_list[d_idx] if keypoints_list and d_idx < len(keypoints_list) else None
|
||
|
|
|
||
|
|
# Extraemos el resto de la firma (Color enmascarado, Anatomía, etc.)
|
||
|
|
firmas[d_idx] = extraer_firma_hibrida(frame_hd, box, kpts, deep_feat=df_persona)
|
||
|
|
|
||
|
|
def obtener_firma(d_idx):
|
||
|
|
return firmas[d_idx]
|
||
|
|
|
||
|
|
if n_trk == 0: return [], list(range(n_det)), [], firmas
|
||
|
|
if n_det == 0: return [], [], list(range(n_trk)), firmas
|
||
|
|
|
||
|
|
# ... (A partir de aquí, el código de "alta" y "baja" confidence se queda EXACTAMENTE como ya lo tienes) ...
|
||
|
|
alta = [(d,boxes[d]) for d,c in enumerate(confidences) if c >= 0.45]
|
||
|
|
baja = [(d,boxes[d]) for d,c in enumerate(confidences) if c < 0.45]
|
||
|
|
matched, sin_match_trk = [], list(range(n_trk))
|
||
|
|
|
||
|
|
if alta:
|
||
|
|
C = np.full((n_trk, len(alta)), 100.0, np.float32)
|
||
|
|
for t, trk in enumerate(self.trackers):
|
||
|
|
dt_oculto = now - trk.ts_ultima_deteccion
|
||
|
|
radio = min(400., max(150., 300.*math.sqrt(max(0.1,dt_oculto))))
|
||
|
|
ref_firma = trk.firma_ema or (self.global_mem.db.get(trk.gid, {}).get('ema') if trk.gid else None)
|
||
|
|
|
||
|
|
for idx, (d, det) in enumerate(alta):
|
||
|
|
iou = iou_overlap(trk.box, det)
|
||
|
|
dist = math.sqrt(((trk.box[0]+trk.box[2])/2-(det[0]+det[2])/2)**2 + ((trk.box[1]+trk.box[3])/2-(det[1]+det[3])/2)**2)
|
||
|
|
if dist > radio: continue
|
||
|
|
|
||
|
|
a_trk = (trk.box[2]-trk.box[0])*(trk.box[3]-trk.box[1])
|
||
|
|
a_det = (det[2]-det[0])*(det[3]-det[1])
|
||
|
|
costo = (1.0*(1-iou)) + (0.3*(dist/radio)) + ((max(a_trk,a_det)/(min(a_trk,a_det)+1e-6)-1)*0.1)
|
||
|
|
costo += (abs((trk.box[3]-trk.box[1])/(trk.box[2]-trk.box[0]+1e-6) - (det[3]-det[1])/(det[2]-det[0]+1e-6))*0.1)
|
||
|
|
costo += min(0.3, dt_oculto*0.1)
|
||
|
|
|
||
|
|
if ref_firma is not None and obtener_firma(d) is not None:
|
||
|
|
sim_ap = similitud_hibrida(ref_firma, firmas[d])
|
||
|
|
|
||
|
|
if sim_ap < 0.25:
|
||
|
|
costo += 50.0
|
||
|
|
else:
|
||
|
|
costo += (1.0 - sim_ap) * 2.0
|
||
|
|
|
||
|
|
nombre_vip = self.global_mem.db.get(trk.gid, {}).get('nombre')
|
||
|
|
umbral_ap = 0.55 if nombre_vip else 0.40
|
||
|
|
if sim_ap < umbral_ap:
|
||
|
|
costo += (umbral_ap - sim_ap) * 3.0
|
||
|
|
|
||
|
|
C[t,idx] = costo
|
||
|
|
|
||
|
|
for r,c in zip(*linear_sum_assignment(C)):
|
||
|
|
if C[r,c] <= 6.5:
|
||
|
|
matched.append((r, alta[c][0])); sin_match_trk.remove(r)
|
||
|
|
|
||
|
|
if sin_match_trk and baja:
|
||
|
|
C2 = np.ones((len(sin_match_trk), len(baja)), np.float32)
|
||
|
|
for ti, trk_i in enumerate(sin_match_trk):
|
||
|
|
for dj, (d,det) in enumerate(baja): C2[ti,dj] = 1.0 - iou_overlap(self.trackers[trk_i].box, det)
|
||
|
|
nuevos_sin = []
|
||
|
|
for r,c in zip(*linear_sum_assignment(C2)):
|
||
|
|
if C2[r,c] < 0.72: matched.append((sin_match_trk[r], baja[c][0]))
|
||
|
|
else: nuevos_sin.append(sin_match_trk[r])
|
||
|
|
sin_match_trk = nuevos_sin
|
||
|
|
|
||
|
|
return matched, [d for d in range(n_det) if d not in [m[1] for m in matched] and confidences[d] >= 0.52], sin_match_trk, firmas
|
||
|
|
|
||
|
|
def update(self, boxes, confidences, keypoints, frame_show, frame_hd, now, turno_activo):
|
||
|
|
# ─────────────────────────────────────────────────────────────
|
||
|
|
# 1. Resolver fusiones globales
|
||
|
|
# ─────────────────────────────────────────────────────────────
|
||
|
|
for trk in self.trackers:
|
||
|
|
if trk.gid:
|
||
|
|
with self.global_mem.lock:
|
||
|
|
d = self.global_mem.db.get(trk.gid)
|
||
|
|
if d and 'fusionado_con' in d:
|
||
|
|
trk.gid = d['fusionado_con']
|
||
|
|
|
||
|
|
# ─────────────────────────────────────────────────────────────
|
||
|
|
# 2. Predict y filtrar tracks muertos por Kalman
|
||
|
|
# ─────────────────────────────────────────────────────────────
|
||
|
|
self.trackers = [t for t in self.trackers if t.predict(turno_activo) is not None]
|
||
|
|
if not turno_activo:
|
||
|
|
return self.trackers
|
||
|
|
|
||
|
|
# ─────────────────────────────────────────────────────────────
|
||
|
|
# 3. Asignar detecciones a tracks existentes
|
||
|
|
# ─────────────────────────────────────────────────────────────
|
||
|
|
matched, new_dets, _, firmas = self._asignar(boxes, confidences, frame_hd, now, keypoints)
|
||
|
|
active_gids = {t.gid for t in self.trackers if t.gid is not None}
|
||
|
|
fh, fw = frame_hd.shape[:2]
|
||
|
|
|
||
|
|
# ─────────────────────────────────────────────────────────────
|
||
|
|
# 4. DETECCIÓN DE CRUCES + Registro para recuperación post-cruce
|
||
|
|
# ─────────────────────────────────────────────────────────────
|
||
|
|
tracks_en_cruce_locals = set()
|
||
|
|
for i in range(len(self.trackers)):
|
||
|
|
for j in range(i + 1, len(self.trackers)):
|
||
|
|
trk_i, trk_j = self.trackers[i], self.trackers[j]
|
||
|
|
if trk_i.gid is None or trk_j.gid is None or trk_i.box is None or trk_j.box is None: continue
|
||
|
|
|
||
|
|
iou = iou_overlap(trk_i.box, trk_j.box)
|
||
|
|
if iou > 0.30:
|
||
|
|
tracks_en_cruce_locals.add(trk_i.local_id)
|
||
|
|
tracks_en_cruce_locals.add(trk_j.local_id)
|
||
|
|
if not hasattr(self, 'tracks_post_cruce'): self.tracks_post_cruce = {}
|
||
|
|
self.tracks_post_cruce[trk_i.local_id] = {'gid_antes': trk_i.gid, 'ts_cruce': now}
|
||
|
|
self.tracks_post_cruce[trk_j.local_id] = {'gid_antes': trk_j.gid, 'ts_cruce': now}
|
||
|
|
|
||
|
|
|
||
|
|
# ─────────────────────────────────────────────────────────────
|
||
|
|
# 5. PROCESAR MATCHES
|
||
|
|
# ─────────────────────────────────────────────────────────────
|
||
|
|
for t_idx, d_idx in matched:
|
||
|
|
trk, box = self.trackers[t_idx], boxes[d_idx]
|
||
|
|
|
||
|
|
# Asegurar firma visual REAL (mitigación del bug de contaminación en _asignar)
|
||
|
|
if firmas[d_idx] is None:
|
||
|
|
kpts = keypoints[d_idx] if keypoints and d_idx < len(keypoints) else None
|
||
|
|
firmas[d_idx] = extraer_firma_hibrida(frame_hd, box, kpts)
|
||
|
|
|
||
|
|
firma_det = firmas[d_idx]
|
||
|
|
|
||
|
|
# Debug
|
||
|
|
if trk.gid is None:
|
||
|
|
print(f" [DEBUG] LOCAL Trk {trk.local_id}: listo={trk.listo_para_id}, cap_locales={getattr(trk, 'muestras_capturadas', 0)}")
|
||
|
|
else:
|
||
|
|
with self.global_mem.lock:
|
||
|
|
db_act = self.global_mem.db.get(trk.gid, {}).get('actualizaciones_globales', 0)
|
||
|
|
print(f" [MEMORIA] ID {trk.gid} ha sido actualizado {db_act} veces en la red global.")
|
||
|
|
|
||
|
|
# Detección de grupo
|
||
|
|
es_grupo = self._detectar_grupo(trk, box, self.trackers)
|
||
|
|
if not trk.en_grupo and es_grupo:
|
||
|
|
with self.global_mem.lock:
|
||
|
|
trk.firma_pre_grupo = self.global_mem.db.get(trk.gid, {}).get('ema')
|
||
|
|
trk.ts_salio_grupo = 0.0
|
||
|
|
elif trk.en_grupo and not es_grupo:
|
||
|
|
trk.ts_salio_grupo = now
|
||
|
|
trk.en_grupo = es_grupo
|
||
|
|
|
||
|
|
# Update Kalman
|
||
|
|
trk.update(box, es_grupo, now, kpts=(keypoints[d_idx] if keypoints else None), firma_actual=firma_det)
|
||
|
|
|
||
|
|
# Geometría de frame
|
||
|
|
margen_x = fw * 0.05
|
||
|
|
esta_en_centro = (box[0] > margen_x and box[2] < (fw - margen_x))
|
||
|
|
|
||
|
|
# ─────────────────────────────────────────────────────────
|
||
|
|
# 5a. APRENDIZAJE EMA (zona segura, centro del frame)
|
||
|
|
# ─────────────────────────────────────────────────────────
|
||
|
|
if firma_det is not None and not es_grupo and esta_en_centro:
|
||
|
|
trk.actualizar_ema(firma_det)
|
||
|
|
|
||
|
|
# Nutrir memoria global cada 2 segundos
|
||
|
|
if trk.gid is not None:
|
||
|
|
if not hasattr(trk, 'ultimo_update_global'):
|
||
|
|
trk.ultimo_update_global = 0
|
||
|
|
if (now - trk.ultimo_update_global) > 2.0:
|
||
|
|
with self.global_mem.lock:
|
||
|
|
if trk.gid in self.global_mem.db:
|
||
|
|
self.global_mem.db[trk.gid]['ema'] = trk.firma_ema.copy()
|
||
|
|
self.global_mem.db[trk.gid]['ts'] = now
|
||
|
|
self.global_mem.db[trk.gid]['actualizaciones_globales'] = \
|
||
|
|
self.global_mem.db[trk.gid].get('actualizaciones_globales', 0) + 1
|
||
|
|
trk.ultimo_update_global = now
|
||
|
|
|
||
|
|
# ⚡ INYECCIÓN EXACTA AQUÍ: Disparar el Auto-Merge
|
||
|
|
self.global_mem.consolidar_clones()
|
||
|
|
|
||
|
|
# ─────────────────────────────────────────────────────────
|
||
|
|
# 5b. RESCATE DE BORDE (Acelerado)
|
||
|
|
# ─────────────────────────────────────────────────────────
|
||
|
|
elif firma_det is not None and not es_grupo and not esta_en_centro:
|
||
|
|
muestras_act = getattr(trk, 'muestras_capturadas', 0)
|
||
|
|
if muestras_act < 3:
|
||
|
|
trk.actualizar_ema(firma_det)
|
||
|
|
elif muestras_act < 5 and trk.frames_observados % 3 == 0:
|
||
|
|
trk.actualizar_ema(firma_det)
|
||
|
|
|
||
|
|
# ─────────────────────────────────────────────────────────
|
||
|
|
# 5c. IDENTIFICACIÓN CON PROTECCIÓN
|
||
|
|
# ─────────────────────────────────────────────────────────
|
||
|
|
if trk.gid is None and trk.listo_para_id and firma_det is not None:
|
||
|
|
if getattr(trk, 'muestras_capturadas', 0) >= 4:
|
||
|
|
if trk.local_id in tracks_en_cruce_locals:
|
||
|
|
print(f" [CRUCE] Track {trk.local_id} en cruce físico. Bloqueando identificación.")
|
||
|
|
else:
|
||
|
|
tiempo_enfriamiento = now - getattr(trk, 'ultimo_cambio_id', 0)
|
||
|
|
if tiempo_enfriamiento < 0.6:
|
||
|
|
print(f" [HYSTERESIS] Track {trk.local_id} en enfriamiento ({tiempo_enfriamiento:.2f}s)")
|
||
|
|
else:
|
||
|
|
gid_c, es_reid = self.global_mem.identificar_candidato(
|
||
|
|
firma_det, self.cam_id, now, active_gids,
|
||
|
|
en_borde=not esta_en_centro,
|
||
|
|
firma_ema_local=trk.firma_ema,
|
||
|
|
confianza_fisica=trk.confianza_fisica
|
||
|
|
)
|
||
|
|
if gid_c:
|
||
|
|
active_gids.add(gid_c)
|
||
|
|
trk.gid = gid_c
|
||
|
|
trk.origen_global = es_reid
|
||
|
|
trk.ultimo_cambio_id = now
|
||
|
|
|
||
|
|
# ─────────────────────────────────────────────────────────────
|
||
|
|
# 6. VERIFICACIÓN POST-CRUCE (recuperar ID original tras separarse)
|
||
|
|
# ─────────────────────────────────────────────────────────────
|
||
|
|
if hasattr(self, 'tracks_post_cruce') and self.tracks_post_cruce:
|
||
|
|
tracks_post_cruce_nuevos = {}
|
||
|
|
|
||
|
|
# ⚡ CAMBIO CRÍTICO: Iterar por local_id, no por índice
|
||
|
|
for local_id, info in list(self.tracks_post_cruce.items()):
|
||
|
|
# Buscar el track con este local_id (único e inmutable)
|
||
|
|
trk = next((t for t in self.trackers if t.local_id == local_id), None)
|
||
|
|
if trk is None:
|
||
|
|
continue # El track ya murió, ignorar
|
||
|
|
|
||
|
|
# Solo actuar si ya pasaron >2 segundos desde el cruce
|
||
|
|
if now - info['ts_cruce'] > 2.0:
|
||
|
|
# Si el ID cambió durante el cruce (o se asignó uno nuevo incorrecto)
|
||
|
|
if trk.gid is not None and trk.gid != info['gid_antes']:
|
||
|
|
if trk.firma_ema is not None and info['gid_antes'] in self.global_mem.db:
|
||
|
|
ema_original = self.global_mem.db[info['gid_antes']].get('ema')
|
||
|
|
if ema_original is not None:
|
||
|
|
sim_recuperacion = similitud_hibrida(
|
||
|
|
trk.firma_ema, ema_original, cross_cam=False
|
||
|
|
)
|
||
|
|
if sim_recuperacion > 0.85:
|
||
|
|
# Verificar que el ID original no esté activo en otro track
|
||
|
|
if info['gid_antes'] not in active_gids:
|
||
|
|
print(f" [POST-CRUCE] Track {trk.local_id} recuperó ID {info['gid_antes']} (sim={sim_recuperacion:.2f})")
|
||
|
|
active_gids.discard(trk.gid)
|
||
|
|
trk.gid = info['gid_antes']
|
||
|
|
active_gids.add(trk.gid)
|
||
|
|
# No guardamos este registro más (ya cumplió su ciclo)
|
||
|
|
continue
|
||
|
|
|
||
|
|
# Aún no pasan 2 segundos, mantener registro (usando local_id)
|
||
|
|
tracks_post_cruce_nuevos[local_id] = info
|
||
|
|
|
||
|
|
self.tracks_post_cruce = tracks_post_cruce_nuevos
|
||
|
|
|
||
|
|
# ─────────────────────────────────────────────────────────────
|
||
|
|
# 7. CREAR NUEVOS TRACKERS para detecciones sin match
|
||
|
|
# ─────────────────────────────────────────────────────────────
|
||
|
|
for d_idx in new_dets:
|
||
|
|
nt = KalmanTrack(boxes[d_idx], now)
|
||
|
|
f = firmas[d_idx]
|
||
|
|
if f:
|
||
|
|
nt.actualizar_ema(f)
|
||
|
|
self.trackers.append(nt)
|
||
|
|
|
||
|
|
# ─────────────────────────────────────────────────────────────
|
||
|
|
# 8. LIMPIEZA: matar tracks sin detección por >3 segundos
|
||
|
|
# ─────────────────────────────────────────────────────────────
|
||
|
|
tracks_vivos = []
|
||
|
|
for t in self.trackers:
|
||
|
|
if (now - t.ts_ultima_deteccion) < 3.0:
|
||
|
|
tracks_vivos.append(t)
|
||
|
|
self.trackers = tracks_vivos
|
||
|
|
|
||
|
|
# ⚡ FIX P0: Purga de memory leak en tracks_post_cruce
|
||
|
|
if hasattr(self, 'tracks_post_cruce'):
|
||
|
|
local_ids_vivos = {t.local_id for t in self.trackers}
|
||
|
|
self.tracks_post_cruce = {lid: info for lid, info in self.tracks_post_cruce.items() if lid in local_ids_vivos}
|
||
|
|
|
||
|
|
# ─────────────────────────────────────────────────────────────
|
||
|
|
# 9. Gestión post-grupo (recuperación de firma tras salir de multitud)
|
||
|
|
# ─────────────────────────────────────────────────────────────
|
||
|
|
self._gestionar_post_grupo(now, frame_hd)
|
||
|
|
|
||
|
|
return self.trackers
|
||
|
|
|
||
|
|
class CamStream:
|
||
|
|
def __init__(self, url):
|
||
|
|
self.url = url; self.cap = cv2.VideoCapture(url)
|
||
|
|
self.q = queue.Queue(maxsize=1); self.stopped = False
|
||
|
|
threading.Thread(target=self._run, daemon=True).start()
|
||
|
|
def _run(self):
|
||
|
|
while not self.stopped:
|
||
|
|
ret, f = self.cap.read()
|
||
|
|
if not ret: time.sleep(2); self.cap.open(self.url); continue
|
||
|
|
if self.q.full():
|
||
|
|
try: self.q.get_nowait()
|
||
|
|
except queue.Empty: pass
|
||
|
|
self.q.put(f)
|
||
|
|
@property
|
||
|
|
def frame(self):
|
||
|
|
try: return self.q.get(timeout=0.5)
|
||
|
|
except queue.Empty: return None
|
||
|
|
def stop(self): self.stopped = True; self.cap.release()
|
||
|
|
|
||
|
|
def dibujar_track(frame_show, trk):
|
||
|
|
try: x1,y1,x2,y2 = map(int,trk.box)
|
||
|
|
except: return
|
||
|
|
if trk.gid is None: color,label = C_CANDIDATO, f"?{trk.local_id}"
|
||
|
|
elif getattr(trk,'en_grupo',False): color,label = C_GRUPO, f"ID:{trk.gid}[G]"
|
||
|
|
elif getattr(trk,'aprendiendo',False): color,label = C_APRENDIZAJE, f"ID:{trk.gid}[+]"
|
||
|
|
elif getattr(trk,'origen_global',False): color,label = C_GLOBAL, f"ID:{trk.gid}[R]"
|
||
|
|
else: color,label = C_LOCAL, f"ID:{trk.gid}"
|
||
|
|
cv2.rectangle(frame_show,(x1,y1),(x2,y2),color,2)
|
||
|
|
(tw,th),_ = cv2.getTextSize(label,FUENTE,0.55,1)
|
||
|
|
cv2.rectangle(frame_show,(x1,y1-th-6),(x1+tw+2,y1),color,-1)
|
||
|
|
cv2.putText(frame_show,label,(x1+1,y1-4),FUENTE,0.55,(0,0,0),1)
|
||
|
|
|
||
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
|
# MAIN
|
||
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
|
def main():
|
||
|
|
print("\n=== Tracker Autónomo Definitivo ===")
|
||
|
|
print("Persistencia activa. Los IDs se recuerdan entre reinicios.")
|
||
|
|
print("Tecla [q] para salir (la memoria se guarda automáticamente).\n")
|
||
|
|
|
||
|
|
model = YOLO("yolov8n-pose.pt")
|
||
|
|
global_mem = GlobalMemory()
|
||
|
|
managers = {str(c): CamManager(c, global_mem) for c in SECUENCIA}
|
||
|
|
cams = [CamStream(u) for u in URLS]
|
||
|
|
cv2.namedWindow("SmartSoft", cv2.WINDOW_AUTOSIZE)
|
||
|
|
|
||
|
|
idx = 0
|
||
|
|
ultimo_guardado = time.time()
|
||
|
|
|
||
|
|
try:
|
||
|
|
while True:
|
||
|
|
now, tiles = time.time(), []
|
||
|
|
cam_ia = idx % len(cams)
|
||
|
|
|
||
|
|
for i, cam_obj in enumerate(cams):
|
||
|
|
frame = cam_obj.frame
|
||
|
|
cid = str(SECUENCIA[i])
|
||
|
|
if frame is None:
|
||
|
|
tiles.append(np.zeros((270,480,3),np.uint8)); continue
|
||
|
|
|
||
|
|
frame_show = cv2.resize(frame.copy(),(480,270))
|
||
|
|
boxes, confs, kpts = [], [], []
|
||
|
|
turno_activo = (i == cam_ia)
|
||
|
|
|
||
|
|
if turno_activo:
|
||
|
|
res = model.predict(frame_show, conf=0.50, iou=0.40, classes=[0], verbose=False, imgsz=480, device='cpu')
|
||
|
|
|
||
|
|
if res[0].boxes:
|
||
|
|
raw_boxes = res[0].boxes.xyxy.cpu().numpy().tolist()
|
||
|
|
raw_confs = res[0].boxes.conf.cpu().numpy().tolist()
|
||
|
|
raw_kpts = res[0].keypoints.data.cpu().numpy().tolist() if res[0].keypoints is not None else []
|
||
|
|
|
||
|
|
for d_idx, (box, conf) in enumerate(zip(raw_boxes, raw_confs)):
|
||
|
|
kpt_persona = raw_kpts[d_idx] if d_idx < len(raw_kpts) else None
|
||
|
|
|
||
|
|
if es_humano_valido(kpt_persona, box, conf):
|
||
|
|
boxes.append(box)
|
||
|
|
confs.append(conf)
|
||
|
|
kpts.append(kpt_persona)
|
||
|
|
|
||
|
|
ESQUELETO = [(0,1),(0,2),(1,3),(2,4),(5,6),(5,7),(7,9),(6,8),(8,10),(5,11),(6,12),(11,12),(11,13),(13,15),(12,14),(14,16)]
|
||
|
|
for pk in kpts:
|
||
|
|
if pk is None: continue
|
||
|
|
for a,b in ESQUELETO:
|
||
|
|
if pk[a][2]>0.40 and pk[b][2]>0.40:
|
||
|
|
cv2.line(frame_show, (int(pk[a][0]),int(pk[a][1])), (int(pk[b][0]),int(pk[b][1])), (255,0,255), 2)
|
||
|
|
for kx, ky, kc in pk:
|
||
|
|
if kc > 0.40: cv2.circle(frame_show, (int(kx), int(ky)), 3, (0, 255, 255), -1)
|
||
|
|
|
||
|
|
tracks = managers[cid].update(boxes, confs, kpts, frame_show, frame, now, turno_activo)
|
||
|
|
|
||
|
|
for trk in tracks:
|
||
|
|
if trk.time_since_update <= 1: dibujar_track(frame_show, trk)
|
||
|
|
if turno_activo: cv2.circle(frame_show,(460,20),6,(0,0,255),-1)
|
||
|
|
|
||
|
|
con_id = sum(1 for t in tracks if getattr(t,'gid',None) and t.time_since_update==0)
|
||
|
|
cv2.putText(frame_show,f"CAM {cid} [{con_id} ID]",(10,28),FUENTE,0.7,(255,255,255),2)
|
||
|
|
tiles.append(frame_show)
|
||
|
|
|
||
|
|
if len(tiles)==6:
|
||
|
|
cv2.imshow("SmartSoft", np.vstack([np.hstack(tiles[:3]),np.hstack(tiles[3:])]))
|
||
|
|
|
||
|
|
idx += 1
|
||
|
|
|
||
|
|
if now - ultimo_guardado > 30:
|
||
|
|
global_mem.guardar_memoria()
|
||
|
|
ultimo_guardado = now
|
||
|
|
|
||
|
|
if cv2.waitKey(1) == ord('q'): break
|
||
|
|
|
||
|
|
finally:
|
||
|
|
global_mem.guardar_memoria()
|
||
|
|
print("[Memoria] Guardada antes de salir.")
|
||
|
|
cv2.destroyAllWindows()
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|
||
|
|
"""
|