Registro de personas a traves de un archivo csv cuando ya fueron detectadas, coneccion con la ESP32 y modificacion del hilo de audio para que suene igual que como se tenia

This commit is contained in:
rodrigo 2026-04-13 12:46:22 -06:00
parent 6bc9a5cb44
commit 86d2e8fc9f
10 changed files with 2126 additions and 138 deletions

77
audio_iot.py Normal file
View File

@ -0,0 +1,77 @@
import asyncio
import websockets
import json
from pydub import AudioSegment
import os
# Configuración de tu Bocina IP
IP_BOCINA = "192.168.1.142"
PUERTO_WS = 81
async def _enviar_audio_ws(pcm_data):
url = f"ws://{IP_BOCINA}:{PUERTO_WS}"
chunk_size = 1024
try:
async with websockets.connect(url, ping_timeout=None) as websocket:
# Esperamos el mensaje de bienvenida
await websocket.recv()
chunks_enviados = 0
for i in range(0, len(pcm_data), chunk_size):
chunk = pcm_data[i:i + chunk_size]
await websocket.send(chunk)
chunks_enviados += 1
# ⚡ EL TRUCO MAESTRO: CONTROL DE FLUJO
if chunks_enviados % 10 == 0:
try:
# Python se pausa hasta que el ESP32 mande el "ACK"
await asyncio.wait_for(websocket.recv(), timeout=1.0)
except asyncio.TimeoutError:
pass # Si hay lag en la red, forzamos a seguir para no cortar
else:
await asyncio.sleep(0.002) # Micro-pausa entre paquetes individuales
# Le damos 1 segundo al ESP32 para reproducir los últimos datos en su memoria
await asyncio.sleep(1.0)
await websocket.send(json.dumps({"cmd": "STOP"}))
return True
except Exception as e:
print(f" Error conectando a la bocina IP {IP_BOCINA}: {e}")
return False
def enviar_archivos_bocina(lista_archivos):
"""
Recibe una lista de rutas de archivos MP3, los une en un solo track,
los convierte al formato del ESP32 y los transmite.
"""
if not lista_archivos:
return
try:
audio_final = AudioSegment.empty()
for archivo in lista_archivos:
if os.path.exists(archivo):
segmento = AudioSegment.from_file(archivo)
audio_final += segmento
audio_mono = audio_final.split_to_mono()[0]
# ⚡ 2. SUBIMOS LA CALIDAD (A 22050 Hz)
audio_final = audio_mono.set_frame_rate(22050).set_sample_width(2)
# 3. Quitamos el clipping (saturación).
# Como ya no estamos sumando canales, podemos dejarle más volumen. Le bajaremos solo 2 dB.
audio_final = audio_final - 2
# 4. Obtenemos los datos puros
pcm_data = audio_final.raw_data
# ⚡ 4. TRANSMITIMOS A LA BOCINA (¡Te faltaba esta parte!)
print(f" Transmitiendo audio a {IP_BOCINA}...")
asyncio.run(_enviar_audio_ws(pcm_data))
except Exception as e:
print(f"Error procesando el audio para la bocina: {e}")

Binary file not shown.

View File

@ -0,0 +1,4 @@
Fecha_Hora,Nombre,Origen,Destino,Segundos_en_Origen
2026-04-13 10:40:23,Emanuel Flores,8,5,0.0
Fecha_Hora,Nombre,Camara_Origen,Camara_Destino
2026-04-13 10:40:23,Emanuel Flores,8,5
1 Fecha_Hora,Nombre,Origen,Destino,Segundos_en_Origen
2 2026-04-13 10:40:23,Emanuel Flores,8,5,0.0
3 Fecha_Hora,Nombre,Camara_Origen,Camara_Destino
4 2026-04-13 10:40:23,Emanuel Flores,8,5

View File

@ -0,0 +1 @@
{"Emanuel Flores": {"fecha": "2026-04-13", "timestamp": 1776101179.9010606}, "Rodrigo Cahuantzi C": {"fecha": "2026-04-13", "timestamp": 1776105668.9753058}}

View File

@ -10,10 +10,10 @@ import cv2
import numpy as np
import time
import threading
import queue
from queue import Queue
from ultralytics import YOLO
import warnings
import json
warnings.filterwarnings("ignore")
@ -73,97 +73,116 @@ def procesar_rostro_async(roi_cabeza, gid, cam_id, global_mem, trk):
votos_finales = 0
# ──────────────────────────────────────────────────────────────────
# ⚡ LÓGICA DE VOTACIÓN PONDERADA INYECTADA
# ⚡ LÓGICA DE VOTACIÓN PONDERADA (Con tus umbrales)
# ──────────────────────────────────────────────────────────────────
# Aceptamos rostros de CCTV desde 0.38 (ajustado según tu código a 0.35)
if max_sim >= 0.35 and mejor_match:
if max_sim >= 0.18 and mejor_match:
nombre_limpio = mejor_match.split('_')[0]
# Variable para rastrear quién es el ID definitivo al final del proceso
id_definitivo = gid
with global_mem.lock:
datos_id = global_mem.db.get(gid)
if not datos_id: return
if datos_id.get('candidato_nombre') == nombre_limpio:
dueno_actual = datos_id.get('nombre')
candidato_actual = datos_id.get('candidato_nombre')
# ⚡ REBELIÓN DE IDENTIDAD:
if max_sim >= 0.56 and dueno_actual is not None and dueno_actual != nombre_limpio:
print(f"[REBELIÓN] Cara de {nombre_limpio} clarísima ({max_sim:.2f}). Destronando a {dueno_actual}.")
datos_id['candidato_nombre'] = nombre_limpio
datos_id['votos_nombre'] = 3
else:
if candidato_actual == nombre_limpio:
datos_id['votos_nombre'] = datos_id.get('votos_nombre', 0) + 1
else:
datos_id['candidato_nombre'] = nombre_limpio
datos_id['votos_nombre'] = 1
# Sistema de Puntos
if max_sim >= 0.50:
datos_id['votos_nombre'] += 3 # Pase VIP por buena foto
datos_id['votos_nombre'] += 3
elif max_sim >= 0.40:
datos_id['votos_nombre'] += 2 # Voto extra por foto decente
datos_id['votos_nombre'] += 2
votos_finales = datos_id['votos_nombre']
# ⚡ Exigimos 3 votos (según tu condicional)
# ⚡ Exigimos 3 votos
if votos_finales >= 3:
nombre_actual = datos_id.get('nombre')
# 1. Buscamos si ese nombre ya lo tiene un veterano en la memoria
# 1. Buscamos al veterano
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
# 2. FUSIÓN MÁGICA: Si ya existe, lo fusionamos
# 2. FUSIÓN MÁGICA
if id_veterano is not None:
print(f"[FUSIÓN FACIAL] ID {gid} es el clon de la espalda. Fusionando con el veterano ID {id_veterano} ({nombre_limpio}).")
# Pasamos la memoria de ropa al veterano
print(f" [FUSIÓN FACIAL] ID {gid} es clon. Fusionando con veterano ID {id_veterano} ({nombre_limpio}).")
global_mem.db[id_veterano]['firmas'].extend(datos_id.get('firmas', []))
if len(global_mem.db[id_veterano]['firmas']) > 15:
global_mem.db[id_veterano]['firmas'] = global_mem.db[id_veterano]['firmas'][-15:]
# Vaciamos al perdedor y redireccionamos
datos_id['firmas'] = []
datos_id['fusionado_con'] = id_veterano
# Actualizamos el tracker y el ID definitivo
trk.gid = id_veterano
id_definitivo = id_veterano
# 3. BAUTIZO NORMAL: Si no hay clones, procedemos con tu lógica de protección VIP
# 3. BAUTIZO NORMAL O CORRECCIÓN
else:
if nombre_actual is not None and nombre_actual != nombre_limpio:
# Si ya tiene nombre y quieren robárselo, exigimos 0.56 mínimo
if dueno_actual is not None and dueno_actual != nombre_limpio:
if max_sim < 0.56:
print(f"[RECHAZO VIP] {nombre_actual} protegido de {nombre_limpio} ({max_sim:.2f})")
print(f"[RECHAZO VIP] {dueno_actual} protegido de {nombre_limpio} ({max_sim:.2f})")
return
else:
print(f" [CORRECCIÓN VIP] Renombrando a {nombre_limpio} ({max_sim:.2f})")
if nombre_actual != nombre_limpio:
if dueno_actual != nombre_limpio:
datos_id['nombre'] = nombre_limpio
print(f"[BAUTIZO] ID {gid} confirmado como {nombre_limpio}")
# Actualizamos el tiempo de vida del ID ganador
global_mem.db[id_definitivo]['ts'] = time.time()
# 🔊 AUDIO DE BIENVENIDA (Funciona tanto para bautizos como para fusiones)
if str(cam_id) == "7":
if not hasattr(global_mem, 'ultimos_saludos'):
global_mem.ultimos_saludos = {}
ultimo = global_mem.ultimos_saludos.get(nombre_limpio, 0)
# ──────────────────────────────────────────────────────────────────
# 🔊 AUDIO DE BIENVENIDA (DISPARADOR CORREGIDO)
# ──────────────────────────────────────────────────────────────────
# Verificamos si es la cámara 7 y si la persona actual ya tiene nombre confirmado
if str(cam_id) == "7" and nombre_limpio:
ahora = time.time()
TIEMPO_COOLDOWN = 60 # 1 minuto para pruebas
# Aquí asumo que tienes COOLDOWN_TIME definido globalmente en tu archivo
if (time.time() - ultimo) > COOLDOWN_TIME:
global_mem.ultimos_saludos[nombre_limpio] = time.time()
threading.Thread(target=hilo_bienvenida, args=(nombre_limpio, genero_detectado), daemon=True).start()
# Revisamos el historial de saludos en la memoria global (que ya carga el JSON)
info_saludo = global_mem.ultimos_saludos.get(nombre_limpio, {})
ultimo_ts = info_saludo.get('timestamp', 0) if isinstance(info_saludo, dict) else 0
# Blindaje OSNet fuera del lock (Usando el id_definitivo por si hubo fusión)
if (ahora - ultimo_ts) > TIEMPO_COOLDOWN:
# 1. Bloqueamos inmediatamente para evitar repeticiones
global_mem.guardar_saludo(nombre_limpio)
# 2. Determinar género
genero_oficial = genero_detectado
try:
import json, os
ruta_gen = os.path.join("cache_nombres", "generos.json")
if os.path.exists(ruta_gen):
with open(ruta_gen, 'r') as f:
dic_gen = json.load(f)
if nombre_limpio in dic_gen:
genero_oficial = dic_gen[nombre_limpio]
except Exception: pass
print(f"📢 [AUDIO] {nombre_limpio} reconocido en Cam 7. Saludando...")
import threading
threading.Thread(target=hilo_bienvenida, args=(nombre_limpio, genero_oficial), daemon=True).start()
else:
# Opcional: print de debug para saber que el cooldown bloqueó el audio
pass
# Blindaje OSNet
if max_sim > 0.50 and votos_finales >= 3:
global_mem.confirmar_firma_vip(id_definitivo, time.time())
except Exception as e:
print(f"Error en InsightFace asíncrono: {e}")
print(f" Error en InsightFace asíncrono: {e}")
finally:
# ⚡ EL LIBERADOR (Vital para que el tracker no se quede bloqueado)
trk.procesando_rostro = False
def worker_rostros(global_mem):

1733
mejor.txt Normal file

File diff suppressed because it is too large Load Diff

BIN
osnet_dinamico.onnx Normal file

Binary file not shown.

28
osnet_dinamico.py Normal file
View File

@ -0,0 +1,28 @@
import onnx
# 1. Rutas de archivos
modelo_estatico = "osnet_x0_25_msmt17.onnx"
modelo_dinamico = "osnet_dinamico.onnx"
print(f"Abriendo {modelo_estatico}...")
model = onnx.load(modelo_estatico)
# 2. Modificar todas las entradas (Inputs)
for input_proto in model.graph.input:
# Accedemos a la primera dimensión (índice 0), que es el Batch
dim = input_proto.type.tensor_type.shape.dim[0]
# IMPORTANTE: Debemos borrar el valor fijo (ej. 16) antes de asignar el nombre dinámico
# Esto evita conflictos en ciertas versiones de la librería ONNX
dim.ClearField('dim_value')
dim.dim_param = 'batch_size'
# 3. Modificar todas las salidas (Outputs)
for output_proto in model.graph.output:
dim = output_proto.type.tensor_type.shape.dim[0]
dim.ClearField('dim_value')
dim.dim_param = 'batch_size'
# 4. Guardar el nuevo modelo
onnx.save(model, modelo_dinamico)
print(f"¡Éxito! El nuevo modelo se ha guardado como: {modelo_dinamico}")

View File

@ -36,6 +36,8 @@ COOLDOWN_TIME = 15 # Segundos entre saludos
USUARIO, PASSWORD, IP_DVR = "admin", "TCA200503", "192.168.1.200"
RTSP_URL = f"rtsp://{USUARIO}:{PASSWORD}@{IP_DVR}:554/Streaming/Channels/702"
from audio_iot import enviar_archivos_bocina
for path in [DB_PATH, CACHE_PATH]:
os.makedirs(path, exist_ok=True)
@ -81,24 +83,30 @@ def reproducir(archivo):
stderr=subprocess.DEVNULL
)
def hilo_bienvenida(nombre, genero):
archivo_nombre = os.path.join(CACHE_PATH, f"nombre_{nombre}.mp3")
# 1. Sintetiza el nombre si no existe en la caché
if not os.path.exists(archivo_nombre):
try:
asyncio.run(sintetizar_nombre(nombre, archivo_nombre))
except Exception:
except Exception as e:
print(f"Error sintetizando voz: {e}")
pass
# 2. Busca los audios institucionales según el género y la hora
intro, cierre = obtener_audios_humanos(genero)
# 3. Filtra solo los archivos que realmente existen en el disco
archivos = [f for f in [intro, archivo_nombre, cierre] if os.path.exists(f)]
# 4. Transmite a la ESP32 (¡Adiós a los parlantes locales!)
if archivos:
subprocess.Popen(
["mpv", "--no-video", "--volume=100"] + archivos,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
try:
enviar_archivos_bocina(archivos)
except Exception as e:
print(f"Error en streaming a ESP32: {e}")
# ──────────────────────────────────────────────────────────────────────────────
# GESTIÓN DE BASE DE DATOS (AHORA CON INSIGHTFACE)

View File

@ -7,6 +7,11 @@ 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
# ──────────────────────────────────────────────────────────────────────────────
# CONFIGURACIÓN DEL SISTEMA
@ -16,7 +21,7 @@ SECUENCIA = [1, 7, 5, 8, 3, 6]
# RED ESTABILIZADA (Timeout de 3s para evitar congelamientos de FFmpeg)
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_25_msmt17.onnx"
ONNX_MODEL_PATH = "osnet_dinamico.onnx"
VECINOS = {
"1": ["7"], "7": ["1", "5"], "5": ["7", "8"],
@ -62,9 +67,11 @@ def analizar_calidad(box):
if w <= 0 or h <= 0: return False
return (ASPECT_RATIO_MIN < (h / w) < ASPECT_RATIO_MAX) and ((w * h) > AREA_MIN_CALIDAD)
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)
def preprocess_onnx(roi):
# ⚡ ESCUDO ANTI-SOMBRAS (CLAHE)
# Convertimos a LAB para ecualizar solo la luz (L) sin deformar los colores reales (A y B)
lab = cv2.cvtColor(roi, cv2.COLOR_BGR2LAB)
l, a, b = cv2.split(lab)
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))
@ -78,7 +85,7 @@ def preprocess_onnx(roi):
img = img.transpose(2,0,1).astype(np.float32) / 255.0
img = (img - MEAN) / STD
# Devolvemos el tensor correcto de 1 sola imagen
# ⚡ Ahora sí devolverá exactamente (1, 3, 256, 128)
return np.expand_dims(img, axis=0)
def extraer_color_zonas(img):
@ -123,16 +130,11 @@ def extraer_firma_hibrida(frame_hd, box_480):
calidad_area = (x2_c - x1_c) * (y2_c - y1_c)
# ⚡ VOLVEMOS AL BATCH DE 16 PARA EVITAR EL CRASH FATAL
blob = preprocess_onnx(roi) # Esto devuelve (1, 3, 256, 128)
# Creamos el contenedor de 16 espacios que el modelo ONNX exige
blob_16 = np.zeros((16, 3, 256, 128), dtype=np.float32)
blob_16[0] = blob[0] # Metemos nuestra imagen en el primer espacio
# Ejecutamos la inferencia
outputs = ort_session.run(None, {input_name: blob_16})
deep_feat = outputs[0][0].flatten() # Extraemos solo el primer resultado
# ⚡ OPTIMIZACIÓN OSNET DINÁMICO
# Pasamos el tensor directo (1, 3, 256, 128) sin rellenar con ceros
blob = preprocess_onnx(roi)
outputs = ort_session.run(None, {input_name: blob})
deep_feat = outputs[0][0].flatten()
norma = np.linalg.norm(deep_feat)
if norma > 0: deep_feat = deep_feat / norma
@ -259,6 +261,66 @@ class GlobalMemory:
self.db = {}
self.next_gid = 100
self.lock = threading.RLock()
self.ruta_saludos = os.path.join("cache_nombres", "registro_saludos.json")
self.ultimos_saludos = self._cargar_y_limpiar_saludos()
def registrar_movimiento(self, nombre, cam_origen, cam_destino, duracion_seg):
"""Guarda el movimiento y la duración en la cámara anterior en un CSV."""
directorio = "cache_nombres"
if not os.path.exists(directorio):
os.makedirs(directorio)
ruta_csv = os.path.join(directorio, "registro_movimientos.csv")
ahora = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
archivo_nuevo = not os.path.exists(ruta_csv)
try:
with open(ruta_csv, 'a', newline='', encoding='utf-8') as f:
writer = csv.writer(f)
if archivo_nuevo:
writer.writerow(["Fecha_Hora", "Nombre", "Origen", "Destino", "Segundos_en_Origen"])
# Redondeamos la duración a 2 decimales
writer.writerow([ahora, nombre, cam_origen, cam_destino, round(duracion_seg, 2)])
f.flush() # Forzamos la escritura física en el disco
print(f" [LOG] {nombre} estuvo {duracion_seg:.1f}s en Cam {cam_origen} antes de pasar a {cam_destino}")
except Exception as e:
print(f"Error al guardar CSV: {e}")
try:
with open(ruta_csv, 'a', newline='', encoding='utf-8') as f:
writer = csv.writer(f)
if archivo_nuevo:
writer.writerow(["Fecha_Hora", "Nombre", "Camara_Origen", "Camara_Destino"])
writer.writerow([ahora, nombre, cam_origen, cam_destino])
print(f" [LOG MOVIMIENTO] {nombre} pasó de Cam {cam_origen} a Cam {cam_destino}")
except Exception as e:
print(f"Error al guardar movimiento: {e}")
def _cargar_y_limpiar_saludos(self):
"""Carga el historial y elimina registros que no sean de hoy."""
hoy = datetime.now().strftime("%Y-%m-%d")
if os.path.exists(self.ruta_saludos):
try:
with open(self.ruta_saludos, 'r') as f:
datos = json.load(f)
# Solo mantenemos los saludos cuya fecha sea 'hoy'
return {nombre: info for nombre, info in datos.items()
if info.get('fecha') == hoy}
except Exception as e:
print(f"Error cargando saludos: {e}")
return {}
def guardar_saludo(self, nombre):
"""Registra el saludo con la fecha actual en disco."""
hoy = datetime.now().strftime("%Y-%m-%d")
self.ultimos_saludos[nombre] = {
'fecha': hoy,
'timestamp': time.time()
}
with open(self.ruta_saludos, 'w') as f:
json.dump(self.ultimos_saludos, f)
def _es_transito_posible(self, data, cam_id, now):
cam_origen = str(data['last_cam'])
@ -310,7 +372,19 @@ class GlobalMemory:
sim = self._sim_robusta(firma_hibrida, data['firmas'], cross_cam=es_cross_cam)
# UMBRALES DINÁMICOS INTELIGENTES
penalizacion_multitud = 0.0
if len(active_gids) > 15: penalizacion_multitud = 0.01
if len(active_gids) > 30: penalizacion_multitud = 0.02
if len(active_gids) > 50: penalizacion_multitud = 0.03
if misma_cam:
if dt < 2.0: umbral = 0.58 + penalizacion_multitud
else: umbral = 0.60 + penalizacion_multitud
elif es_vecino:
umbral = 0.60 + penalizacion_multitud
else:
umbral = 0.66 + penalizacion_multitud
"""# UMBRALES DINÁMICOS INTELIGENTES
if misma_cam:
if dt < 2.0: umbral = 0.60 # Para parpadeos rápidos de YOLO
else: umbral = 0.63 # Si desapareció un rato en la misma cámara
@ -318,12 +392,12 @@ class GlobalMemory:
# ⚡ EL PUNTO DULCE: 0.62.
# Está por encima del ruido máximo (0.57) y por debajo de las
# caídas de Omar en el cluster 7-5-8 (0.64).
umbral = 0.61
umbral = 0.62
else:
# ⚡ LEJANOS: 0.66.
# Si salta de la Cam 1 a la Cam 8, exigimos seguridad alta
# para no robarle el ID a alguien que acaba de entrar por la otra puerta.
umbral = 0.66
umbral = 0.70"""
"""
@ -342,7 +416,7 @@ class GlobalMemory:
if data.get('nombre') is not None:
umbral += 0.05 if misma_cam else 0.02
# 👁️ EL CHIVATO DE OSNET (Debug crucial)
# EL CHIVATO DE OSNET (Debug crucial)
# Esto imprimirá en tu consola qué similitud real de ropa detectó al cruzar de cámara
if sim > 0.35 and not misma_cam:
print(f" [OSNet] Cam {cam_id} evaluando ID {gid} (Viene de Cam {data['last_cam']}) -> Similitud Ropa: {sim:.2f} (Umbral exigido: {umbral:.2f})")
@ -442,7 +516,7 @@ class GlobalMemory:
# 2. Vaciamos al perdedor y le ponemos un "Redireccionamiento"
self.db[id_elimina]['firmas'] = []
self.db[id_elimina]['fusionado_con'] = id_mantiene
print(f"🧬 [FUSIÓN MÁGICA] Las firmas del ID {id_elimina} fueron absorbidas por el ID {id_mantiene}.")
print(f"[FUSIÓN MÁGICA] Las firmas del ID {id_elimina} fueron absorbidas por el ID {id_mantiene}.")
return True
return False
@ -467,8 +541,8 @@ class CamManager:
estado_actual = getattr(trk, 'en_grupo', False)
# Si están a menos de medio cuerpo de distancia lateral, es un grupo.
factor_x = 1.0 if estado_actual else 1.0
factor_y = 0.4 if estado_actual else 0.2
factor_x = 0.9 if estado_actual else 0.7
factor_y = 0.35 if estado_actual else 0.2
for other in todos_los_tracks:
if other is trk: continue
@ -524,32 +598,34 @@ class CamManager:
def update(self, boxes, frame_show, frame_hd, now, turno_activo):
# 1. MUTACIÓN FUSIÓN
for trk in self.trackers:
if trk.gid is not None:
with self.global_mem.lock:
if trk.gid in self.global_mem.db and 'fusionado_con' in self.global_mem.db[trk.gid]:
print(f"🔗 [HILO CAM {self.cam_id}] Tracker mutando de ID {trk.gid} a {self.global_mem.db[trk.gid]['fusionado_con']}")
print(f" Tracker mutando de ID {trk.gid} a {self.global_mem.db[trk.gid]['fusionado_con']}")
trk.gid = self.global_mem.db[trk.gid]['fusionado_con']
# ──────────────────────────────────────────────────────────
# ──────────────────────────────────────────────────────────
# ⚡ FILTRO ANTI-FANTASMAS
vivos_predict = []
for trk in self.trackers:
caja_predicha = trk.predict(turno_activo=turno_activo)
# Si el tracker lleva más de 5 frames ciego, devuelve None. Lo ignoramos.
if caja_predicha is not None:
vivos_predict.append(trk)
self.trackers = vivos_predict # Actualizamos la lista solo con los vivos
self.trackers = vivos_predict
if not turno_activo: return self.trackers
# ──────────────────────────────────────────────────────────
# Aquí sigue tu código normal:
matched, unmatched_dets, unmatched_trks = self._asignar(boxes, now)
active_gids = {t.gid for t in self.trackers if t.gid is not None}
# El Salvavidas OSNet
extracciones_hoy = 0
for t_idx, d_idx in matched:
trk = self.trackers[t_idx]
box = boxes[d_idx]
@ -563,74 +639,111 @@ class CamManager:
if firmas:
trk.firma_pre_grupo = firmas[-1]
trk.validado_post_grupo = False
print(f" [GRUPO] ID {trk.gid} entró a grupo. Protegiendo firma.")
print(f" ID {trk.gid} entró a grupo. Protegiendo firma.")
trk.ts_salio_grupo = 0.0
elif getattr(trk, 'en_grupo', False) and not es_grupo_ahora:
trk.ts_salio_grupo = now
print(f" [GRUPO] ID {trk.gid} salió de grupo. Cuarentena de 3s.")
print(f" ID {trk.gid} salió de grupo. Cuarentena de 3s.")
trk.en_grupo = es_grupo_ahora
trk.update(box, es_grupo_ahora, now)
# ──────────────────────────────────────────────────────────
# 📝 REGISTRO DE MOVIMIENTO (Anti Ping-Pong Dinámico)
# ──────────────────────────────────────────────────────────
if trk.gid is not None:
registro_pendiente = None
with self.global_mem.lock:
datos = self.global_mem.db.get(trk.gid, {})
nombre_oficial = datos.get('nombre')
cam_anterior = datos.get('last_cam')
ultimo_salto_ts = datos.get('last_jump_ts', 0.0)
# Inicialización
if cam_anterior is None:
datos['last_cam'] = self.cam_id
datos['last_cam_entry_ts'] = now
datos['last_jump_ts'] = now
# Detección de salto
elif nombre_oficial is not None and str(cam_anterior) != str(self.cam_id):
cam_origen = str(cam_anterior)
cam_destino = str(self.cam_id)
cluster_interseccion = {'5', '7', '8'}
# ⚡ COOLDOWN INTELIGENTE POR TOPOLOGÍA
if cam_origen in cluster_interseccion and cam_destino in cluster_interseccion:
cooldown_exigido = 4.0
else:
cooldown_exigido = 1.0
if (now - ultimo_salto_ts) > cooldown_exigido:
entrada_ts = datos.get('last_cam_entry_ts', now)
duracion = now - entrada_ts
registro_pendiente = (nombre_oficial, cam_anterior, self.cam_id, duracion)
datos['last_cam'] = self.cam_id
datos['last_cam_entry_ts'] = now
datos['last_jump_ts'] = now
# Escribimos el CSV fuera del lock de memoria
if registro_pendiente:
self.global_mem.registrar_movimiento(*registro_pendiente)
# ──────────────────────────────────────────────────────────
# LÓGICA DE BAUTIZO Y APRENDIZAJE
# ──────────────────────────────────────────────────────────
area_actual = (box[2] - box[0]) * (box[3] - box[1])
tiempo_desde_separacion = now - getattr(trk, 'ts_salio_grupo', 0)
en_cuarentena = (tiempo_desde_separacion < 3.0) and (getattr(trk, 'ts_salio_grupo', 0) > 0)
extracciones_hoy = 0 # ⚡ EL SALVAVIDAS: Contador para el frame actual
if not trk.en_grupo and not en_cuarentena:
# A) Bautizo de IDs Nuevos (Con Aduana Temporal)
if trk.gid is None and trk.listo_para_id:
firma = extraer_firma_hibrida(frame_hd, box)
if firma is not None:
fh, fw = frame_hd.shape[:2]
fh, fw = frame_hd.shape[:2] # ⚡ FIX: Agregado [:2]
bx1, by1, bx2, by2 = map(int, box)
nace_en_borde = (bx1 < 25 or by1 < 25 or bx2 > fw - 25 or by2 > fh - 25)
candidato_gid, es_reid = self.global_mem.identificar_candidato(firma, self.cam_id, now, active_gids, en_borde=nace_en_borde)
if candidato_gid is not None:
# ⚡ BLOQUEO INMEDIATO: Reservamos el ID en este milisegundo
# para que ninguna otra persona en esta cámara pueda evaluarlo.
active_gids.add(candidato_gid)
# Si la memoria dice "Es un desconocido nuevo", lo bautizamos al instante
if not es_reid:
trk.gid, trk.origen_global, trk.area_referencia = candidato_gid, False, area_actual
# ⚡ ADUANA TEMPORAL
else:
if getattr(trk, 'candidato_temporal', None) == candidato_gid:
trk.votos_reid = getattr(trk, 'votos_reid', 0) + 1
else:
# Si cambia de opinión, reiniciamos sin restar
trk.candidato_temporal = candidato_gid
trk.votos_reid = 1
if trk.votos_reid >= 2:
trk.gid, trk.origen_global, trk.area_referencia = candidato_gid, True, area_actual
print(f" [ADUANA] ID {candidato_gid} validado.")
trk.gid, trk.origen_global = candidato_gid, True
# B) Aprendizaje Continuo (Captura de Ángulos)
# B) Aprendizaje Continuo OSNet
elif trk.gid is not None:
tiempo_ultima_firma = getattr(trk, 'ultimo_aprendizaje', 0)
# ⚡ APRENDIZAJE ESCALONADO:
# Mantenemos tus 0.5s perfectos, pero solo 1 persona por frame puede saturar la CPU.
if (now - tiempo_ultima_firma) > 0.5 and analizar_calidad(box) and extracciones_hoy < 1:
fh, fw = frame_hd.shape[:2]
fh, fw = frame_hd.shape[:2] # ⚡ FIX: Agregado [:2]
x1, y1, x2, y2 = map(int, box)
en_borde = (x1 < 15 or y1 < 15 or x2 > fw - 15 or y2 > fh - 15)
if not en_borde:
firma_nueva = extraer_firma_hibrida(frame_hd, box)
if firma_nueva is not None:
extracciones_hoy += 1 # ⚡ Cerramos la compuerta para los demás en este frame
extracciones_hoy += 1
with self.global_mem.lock:
if trk.gid in self.global_mem.db and self.global_mem.db[trk.gid]['firmas']:
if trk.gid in self.global_mem.db and self.global_mem.db[trk.gid].get('firmas'):
firma_reciente = self.global_mem.db[trk.gid]['firmas'][-1]
firma_original = self.global_mem.db[trk.gid]['firmas'][0]
@ -638,15 +751,13 @@ class CamManager:
sim_coherencia = similitud_hibrida(firma_nueva, firma_reciente)
sim_raiz = similitud_hibrida(firma_nueva, firma_original)
# ⚡ EL BOTÓN DE PÁNICO (Anti ID-Switch)
# Si la ropa de la caja actual no se parece en NADA a la original (< 0.35),
# significa que Kalman le pegó el ID a la persona equivocada en un cruce.
# ⚡ BOTÓN DE PÁNICO (Anti ID-Switch)
if sim_raiz < 0.35:
print(f"[ID SWITCH] Ropa de ID {trk.gid} cambió drásticamente. Revocando ID.")
print(f" Ropa de ID {trk.gid} cambió drásticamente. Revocando ID.")
trk.gid = None
trk.listo_para_id = False
trk.frames_buena_calidad = 0
continue # Rompemos el ciclo para que nazca como alguien nuevo
continue
ya_bautizado = self.global_mem.db[trk.gid].get('nombre') is not None
umbral_raiz = 0.52 if ya_bautizado else 0.62
@ -654,7 +765,7 @@ class CamManager:
if sim_coherencia > 0.60 and sim_raiz > umbral_raiz:
es_coherente = True
for otro_gid, otro_data in self.global_mem.db.items():
if otro_gid == trk.gid or not otro_data['firmas']: continue
if otro_gid == trk.gid or not otro_data.get('firmas'): continue
sim_intruso = similitud_hibrida(firma_nueva, otro_data['firmas'][0])
if sim_intruso > sim_raiz:
es_coherente = False
@ -666,26 +777,25 @@ class CamManager:
for d_idx in unmatched_dets: self.trackers.append(KalmanTrack(boxes[d_idx], now))
# ──────────────────────────────────────────────────────────
# ⚡ LIMPIEZA AGRESIVA
# ──────────────────────────────────────────────────────────
vivos = []
fh, fw = frame_show.shape[:2]
fh, fw = frame_show.shape[:2] # ⚡ FIX: Agregado [:2]
for t in self.trackers:
x1, y1, x2, y2 = t.box
toca_borde = (x1 < 20 or y1 < 20 or x2 > fw - 20 or y2 > fh - 20)
tiempo_oculto = now - t.ts_ultima_deteccion
# ⚡ LIMPIEZA AGRESIVA PARA PROTEGER LA CPU DE MEMORY LEAKS
if t.gid is None:
# Si es un ID gris (no bautizado), lo matamos rápido si YOLO lo pierde
limite_vida = 1.0 if toca_borde else 2.5
else:
# Si es VIP, le damos paciencia para que OSNet no se fragmente
limite_vida = 3.0 if toca_borde else 8.0
if tiempo_oculto < limite_vida:
vivos.append(t)
self.trackers = vivos
self._gestionar_aprendizaje_post_grupo(now, frame_hd)
return self.trackers
@ -701,14 +811,10 @@ class CamManager:
for t, trk in enumerate(self.trackers):
tiempo_oculto = now - trk.ts_ultima_deteccion
# ⚡ 2A. Aumentamos el radio dinámico mínimo para no perder gente rápida
radio_dinamico = min(350.0, max(150.0, 300.0 * tiempo_oculto))
# La incertidumbre crece con el tiempo, pero la topamos en 0.5
# ⚡ INCERTIDUMBRE CUADRÁTICA DE KALMAN
radio_dinamico = min(400.0, max(150.0, 300.0 * math.sqrt(max(0.1, tiempo_oculto))))
incertidumbre = min(0.5, tiempo_oculto * 0.2)
es_fantasma = getattr(trk, 'time_since_update', 0) > 1
for d, det in enumerate(boxes):
iou = iou_overlap(trk.box, det)
cx_t, cy_t = (trk.box[0]+trk.box[2])/2, (trk.box[1]+trk.box[3])/2
@ -723,32 +829,18 @@ class CamManager:
cost_mat[t, d] = 100.0
continue
# ⚡ 2B. REDUCIMOS CASTIGOS INJUSTOS
# Eliminamos el "castigo_secuestro" que penalizaba excesivamente a los trackers sin IOU.
# Reducimos la penalización por cambio de tamaño de 0.4 a 0.2.
dist_norm = dist_pixel / radio_dinamico
ratio_area = max(area_trk, area_det) / (min(area_trk, area_det) + 1e-6)
# Si la caja de YOLO es mucho MÁS PEQUEÑA que la predicción de Kalman,
# penalizamos fuerte (evita que la caja encoja mágicamente y pierda a la persona).
if area_det < area_trk:
castigo_tam = (ratio_area - 1.0) * 0.5
else:
# Si la caja de YOLO es MÁS GRANDE (ej. persona acercándose/subiendo escaleras),
# somos muy permisivos para que Kalman acepte el nuevo tamaño sin soltarse.
castigo_tam = (ratio_area - 1.0) * 0.15
if area_det < area_trk: castigo_tam = (ratio_area - 1.0) * 0.5
else: castigo_tam = (ratio_area - 1.0) * 0.15
# Fórmula equilibrada
cost_mat[t, d] = (1.0 - iou) + (0.5 * dist_norm) + castigo_tam + incertidumbre
from scipy.optimize import linear_sum_assignment
row_ind, col_ind = linear_sum_assignment(cost_mat)
matched, unmatched_dets, unmatched_trks = [], [], []
for r, c in zip(row_ind, col_ind):
# ⚡ UMBRAL RELAJADO: De 2.5 a 3.5.
# Esto evita que rechace la caja por la latencia del procesador.
if cost_mat[r, c] > 3.5:
unmatched_trks.append(r); unmatched_dets.append(c)
else:
@ -766,17 +858,41 @@ class CamManager:
# ──────────────────────────────────────────────────────────────────────────────
class CamStream:
def __init__(self, url):
self.url, self.cap = url, cv2.VideoCapture(url)
self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1); self.frame = None
self.url = url
self.cap = cv2.VideoCapture(url)
# ⚡ COLA DE TAMAÑO 1 PARA PARALELISMO REAL (Sin frames del pasado)
self.q = queue.Queue(maxsize=1)
self.stopped = False
threading.Thread(target=self._run, daemon=True).start()
def _run(self):
while True:
while not self.stopped:
ret, f = self.cap.read()
if ret:
self.frame = f; time.sleep(0.01)
else:
time.sleep(2); self.cap.open(self.url)
if not ret:
time.sleep(2)
self.cap.open(self.url)
continue
# Si la cola está llena, descartamos el frame viejo
if self.q.full():
try:
self.q.get_nowait()
except queue.Empty:
pass
self.q.put(f)
@property
def frame(self):
# ⚡ Extrae el frame fresco de la cola, si está vacía espera medio segundo
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)
@ -828,3 +944,5 @@ def main():
if __name__ == "__main__":
main()