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

# ──────────────────────────────────────────────────────────────────────────────
#  CONFIGURACIÓN DEL SISTEMA
# ──────────────────────────────────────────────────────────────────────────────
USUARIO, PASSWORD, IP_DVR = "admin", "TCA200503", "192.168.1.65"
SECUENCIA = [1, 7, 5, 8, 3, 6]
os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = "rtsp_transport;tcp"
URLS = [f"rtsp://{USUARIO}:{PASSWORD}@{IP_DVR}:554/Streaming/Channels/{i}02" for i in SECUENCIA]
ONNX_MODEL_PATH = "osnet_x0_25_msmt17.onnx"

VECINOS = {
    "1": ["7"], "7": ["1", "5"], "5": ["7", "8"],
    "8": ["5", "3"], "3": ["8", "6"], "6": ["3"]
}

# ─── PARÁMETROS TÉCNICOS
ASPECT_RATIO_MIN  = 0.5
ASPECT_RATIO_MAX  = 4.0
AREA_MIN_CALIDAD  = 1200
FRAMES_CALIDAD    = 2
TIEMPO_MAX_AUSENCIA = 800.0

# ─── TIEMPOS DE VIDA DE TRACKERS
TIEMPO_PACIENCIA_BORDE_CON_ID  = 5.0   # Segundos antes de matar un ID conocido en borde
TIEMPO_PACIENCIA_BORDE_SIN_ID  = 1.0   # Segundos antes de matar un candidato en borde
TIEMPO_PACIENCIA_INTERIOR      = 8.0   # Vida para ID conocido en el interior
TIEMPO_PACIENCIA_INTERIOR_NUEVO = 1.5  # Vida para candidato sin ID en interior

# ─── RE-ADQUISICIÓN RÁPIDA (persona que sale y entra por la misma puerta)
RADIO_REENTRADA   = 80    # Píxeles: si reaparece a menos de esta distancia, re-adquirir
TIEMPO_REENTRADA  = 15.0  # Segundos máximos para considerar una reentrada

# ─── UMBRALES DE RE-ID
# ⚡ UMBRAL_REID_MISMA_CAM ajustado a 0.65 para recuperar estabilidad en sombras y giros
UMBRAL_REID_MISMA_CAM  = 0.65
UMBRAL_REID_VECINO     = 0.62   
UMBRAL_REID_NO_VECINO  = 0.80
MARGEN_MINIMO_REID     = 0.07   
MAX_FIRMAS_MEMORIA     = 15

# ─── TIEMPO ENTRE TURNOS ACTIVOS (para el anti-fantasma dinámico)
TIEMPO_TURNO_ROTATIVO = len(SECUENCIA) * 0.035

# ─── COLORES Y FUENTE
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

# ──────────────────────────────────────────────────────────────────────────────
#  INICIALIZACIÓN DEL MOTOR DEEP LEARNING (ONNX)
# ──────────────────────────────────────────────────────────────────────────────
print("Cargando motor de Re-Identificación (OSNet ONNX en CPU)...")
try:
    ort_session = ort.InferenceSession(ONNX_MODEL_PATH, providers=['CPUExecutionProvider'])
    input_name  = ort_session.get_inputs()[0].name
    print("OSNet cargado.")
except Exception as e:
    print(f"ERROR FATAL: No se pudo cargar {ONNX_MODEL_PATH}. {e}")
    exit()

MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32).reshape(1, 3, 1, 1)
STD  = np.array([0.229, 0.224, 0.225], dtype=np.float32).reshape(1, 3, 1, 1)

# ──────────────────────────────────────────────────────────────────────────────
#  1. MOTOR HÍBRIDO DE FIRMA
# ──────────────────────────────────────────────────────────────────────────────
def analizar_calidad(box):
    x1, y1, x2, y2 = box
    w, h = x2 - x1, y2 - y1
    if w <= 0 or h <= 0:
        return False
    return (ASPECT_RATIO_MIN < (h / w) < ASPECT_RATIO_MAX) and ((w * h) > AREA_MIN_CALIDAD)

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
    img = np.expand_dims(img, axis=0)
    img = (img - MEAN) / STD
    return img

def extraer_color_zonas(roi):
    h_roi = roi.shape[0]
    t1 = int(h_roi * 0.15)
    t2 = int(h_roi * 0.55)
    zonas = [roi[:t1, :], roi[t1:t2, :], roi[t2:, :]]

    def hist_zona(z):
        if z.size == 0:
            return np.zeros(16 * 8)
        hsv  = cv2.cvtColor(z, cv2.COLOR_BGR2HSV)
        hist = cv2.calcHist([hsv], [0, 1], None, [16, 8], [0, 180, 0, 256])
        cv2.normalize(hist, hist)
        return hist.flatten()

    return np.concatenate([hist_zona(z) for z in zonas])

def extraer_firma_hibrida(frame, box):
    try:
        x1, y1, x2, y2 = map(int, box)
        fh, fw = frame.shape[:2]
        x1_c = max(0, x1); y1_c = max(0, y1)
        x2_c = min(fw, x2); y2_c = min(fh, y2)
        roi   = frame[y1_c:y2_c, x1_c:x2_c]

        if roi.size == 0 or roi.shape[0] < 20 or roi.shape[1] < 10:
            return None

        calidad_area = (x2_c - x1_c) * (y2_c - y1_c)

        # ⚡ ROI CRUDA: Pasamos la imagen tal cual, sin alterar su contraste artificialmente
        blob = preprocess_onnx(roi)
        blob_16 = np.zeros((16, 3, 256, 128), dtype=np.float32)
        blob_16[0] = blob[0]
        deep_feat = ort_session.run(None, {input_name: blob_16})[0][0].flatten()
        norma = np.linalg.norm(deep_feat)
        if norma > 0:
            deep_feat = deep_feat / norma

        color_feat = extraer_color_zonas(roi)   

        return {'deep': deep_feat, 'color': color_feat, 'calidad': calidad_area}

    except Exception:
        return None

def similitud_hibrida(f1, f2, cross_cam=False):
    if f1 is None or f2 is None:
        return 0.0

    sim_deep = max(0.0, 1.0 - cosine(f1['deep'], f2['deep']))

    if f1['color'].shape == f2['color'].shape and f1['color'].size > 1:
        L = len(f1['color']) // 3
        sim_head  = max(0.0, float(cv2.compareHist(
            f1['color'][:L].astype(np.float32),
            f2['color'][:L].astype(np.float32), cv2.HISTCMP_CORREL)))
        sim_torso = max(0.0, float(cv2.compareHist(
            f1['color'][L:2*L].astype(np.float32),
            f2['color'][L:2*L].astype(np.float32), cv2.HISTCMP_CORREL)))
        sim_legs  = max(0.0, float(cv2.compareHist(
            f1['color'][2*L:].astype(np.float32),
            f2['color'][2*L:].astype(np.float32), cv2.HISTCMP_CORREL)))
        sim_color = (0.10 * sim_head) + (0.60 * sim_torso) + (0.30 * sim_legs)
    else:
        sim_color = 0.0

    if cross_cam:
        return (sim_deep * 0.75) + (sim_color * 0.25)
    else:
        return (sim_deep * 0.85) + (sim_color * 0.15)

# ──────────────────────────────────────────────────────────────────────────────
#  2. KALMAN TRACKER
# ──────────────────────────────────────────────────────────────────────────────
class KalmanTrack:
    _count = 0

    def __init__(self, box, now):
        self.kf = cv2.KalmanFilter(7, 4)
        self.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)
        self.kf.transitionMatrix = np.eye(7, dtype=np.float32)
        self.kf.transitionMatrix[0,4] = 1
        self.kf.transitionMatrix[1,5] = 1
        self.kf.transitionMatrix[2,6] = 1
        self.kf.processNoiseCov *= 0.03
        self.kf.statePost = np.zeros((7, 1), np.float32)
        self.kf.statePost[:4] = self._convert_bbox_to_z(box)

        self.local_id    = KalmanTrack._count
        KalmanTrack._count += 1

        self.gid             = None
        self.origen_global   = False
        self.aprendiendo     = False
        self.box             = list(box)
        self.ts_creacion     = now
        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.area_referencia      = 0.0
        self.ultimo_aprendizaje   = 0.0

    def _convert_bbox_to_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/float(h+1e-6)]]).astype(np.float32)

    def _convert_x_to_bbox(self, x):
        cx = float(x[0].item()); cy = float(x[1].item())
        s  = float(x[2].item()); r  = float(x[3].item())
        w  = np.sqrt(s * r);     h  = s / (w + 1e-6)
        return [cx-w/2., cy-h/2., cx+w/2., cy+h/2.]

    def predict(self, turno_activo=True):
        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._convert_x_to_bbox(self.kf.statePre)
        return self.box

    def update(self, box, en_grupo, now):
        self.ts_ultima_deteccion = now
        self.time_since_update   = 0
        self.box     = list(box)
        self.en_grupo = en_grupo
        self.kf.correct(self._convert_bbox_to_z(box))

        if analizar_calidad(box) and not en_grupo:
            self.frames_buena_calidad += 1
            if self.frames_buena_calidad >= FRAMES_CALIDAD:
                self.listo_para_id = True
        elif self.gid is None:
            self.frames_buena_calidad = max(0, self.frames_buena_calidad - 1)

# ──────────────────────────────────────────────────────────────────────────────
#  3. MEMORIA GLOBAL
# ──────────────────────────────────────────────────────────────────────────────
class GlobalMemory:
    def __init__(self):
        self.db       = {}
        self.next_gid = 100
        self.lock     = threading.Lock()

    def _es_transito_posible(self, data, cam_destino, now):
        ultima_cam  = str(data['last_cam'])
        cam_destino = str(cam_destino)
        dt          = now - data['ts']
        if ultima_cam == cam_destino:
            return True
        vecinos = VECINOS.get(ultima_cam, [])
        if cam_destino in vecinos:
            return dt >= -0.5  
        return dt >= 4.0        

    def _sim_robusta(self, firma_nueva, firmas_guardadas, cross_cam=False):
        if not firmas_guardadas:
            return 0.0
        sims = sorted(
            [similitud_hibrida(firma_nueva, f, cross_cam=cross_cam)
             for f in firmas_guardadas],
            reverse=True
        )
        if len(sims) == 1:
            return sims[0]
        elif len(sims) <= 3:
            return (sims[0] * 0.60) + (sims[1] * 0.40)
        else:
            return (sims[0] * 0.50) + (sims[1] * 0.30) + (sims[2] * 0.20)

    def identificar_candidato(self, firma_hibrida, cam_id, now, active_gids):
        with self.lock:
            candidatos = []  
            vecinos    = VECINOS.get(str(cam_id), [])

            for gid, data in self.db.items():
                if gid in active_gids:
                    continue
                dt = now - data['ts']
                if dt > TIEMPO_MAX_AUSENCIA:
                    continue
                if not self._es_transito_posible(data, cam_id, now):
                    continue
                if not data['firmas']:
                    continue

                cross_cam = str(data['last_cam']) != str(cam_id)
                sim = self._sim_robusta(firma_hibrida, data['firmas'], cross_cam=cross_cam)

                misma_cam = str(data['last_cam']) == str(cam_id)
                es_vecino = str(data['last_cam']) in vecinos

                if misma_cam:
                    umbral = UMBRAL_REID_MISMA_CAM
                elif es_vecino:
                    umbral = UMBRAL_REID_VECINO
                else:
                    umbral = UMBRAL_REID_NO_VECINO

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

            if not candidatos:
                nid = self.next_gid
                self.next_gid += 1
                self._actualizar_sin_lock(nid, firma_hibrida, cam_id, now)
                return nid, False

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

            if len(candidatos) >= 2:
                sim_2  = candidatos[1][0]
                margen = sim_1 - sim_2
                if margen < MARGEN_MINIMO_REID:
                    nid = self.next_gid
                    self.next_gid += 1
                    self._actualizar_sin_lock(nid, firma_hibrida, cam_id, now)
                    return nid, False

            self._actualizar_sin_lock(best_gid, firma_hibrida, cam_id, now)
            return best_gid, True

    def _actualizar_sin_lock(self, gid, firma_dict, cam_id, now):
        if gid not in self.db:
            self.db[gid] = {'firmas': [], 'last_cam': cam_id, 'ts': now}

        if firma_dict is not None:
            firmas_list = self.db[gid]['firmas']

            if not firmas_list:
                firmas_list.append(firma_dict)
            else:
                if firma_dict['calidad'] > (firmas_list[0]['calidad'] * 1.50):
                    vieja_ancla    = firmas_list[0]
                    firmas_list[0] = firma_dict
                    firma_dict     = vieja_ancla

                if len(firmas_list) >= MAX_FIRMAS_MEMORIA:
                    max_sim_interna = -1.0
                    idx_redundante  = 1
                    for i in range(1, len(firmas_list)):
                        sims_con_otras = [
                            similitud_hibrida(firmas_list[i], firmas_list[j])
                            for j in range(1, len(firmas_list)) if j != i
                        ]
                        sim_promedio = float(np.mean(sims_con_otras)) if sims_con_otras else 0.0
                        if sim_promedio > max_sim_interna:
                            max_sim_interna = sim_promedio
                            idx_redundante  = i
                    firmas_list[idx_redundante] = firma_dict
                else:
                    firmas_list.append(firma_dict)

        self.db[gid]['last_cam'] = cam_id
        self.db[gid]['ts']       = now

    def actualizar(self, gid, firma, cam_id, now):
        with self.lock:
            self._actualizar_sin_lock(gid, firma, cam_id, now)

# ──────────────────────────────────────────────────────────────────────────────
#  4. GESTOR LOCAL
# ──────────────────────────────────────────────────────────────────────────────
def iou_overlap(boxA, boxB):
    xA = max(boxA[0], boxB[0]); yA = max(boxA[1], boxB[1])
    xB = min(boxA[2], boxB[2]); yB = min(boxA[3], boxB[3])
    inter  = max(0, xB-xA) * max(0, yB-yA)
    areaA  = (boxA[2]-boxA[0]) * (boxA[3]-boxA[1])
    areaB  = (boxB[2]-boxB[0]) * (boxB[3]-boxB[1])
    return inter / (areaA + areaB - inter + 1e-6)

class CamManager:
    def __init__(self, cam_id, global_mem):
        self.cam_id     = cam_id
        self.global_mem = global_mem
        self.trackers   = []

    def update(self, boxes, frame, now, turno_activo):
        for trk in self.trackers:
            trk.predict(turno_activo=turno_activo)
        if not turno_activo:
            return self.trackers

        matched, unmatched_dets, _ = self._asignar(boxes, now)
        fh, fw = frame.shape[:2]

        for t_idx, d_idx in matched:
            trk = self.trackers[t_idx]
            box = boxes[d_idx]

            en_grupo = any(
                other is not trk and iou_overlap(box, other.box) > 0.10
                for other in self.trackers
            )
            trk.update(box, en_grupo, now)

            active_gids_local = {t.gid for t in self.trackers if t.gid is not None}
            area_actual = (box[2] - box[0]) * (box[3] - box[1])

            if trk.gid is None and trk.listo_para_id:
                firma = extraer_firma_hibrida(frame, box)
                if firma is not None:
                    gid, es_reid = self.global_mem.identificar_candidato(
                        firma, self.cam_id, now, active_gids_local
                    )
                    trk.gid          = gid
                    trk.origen_global = es_reid
                    trk.area_referencia = area_actual

            elif trk.gid is None and not trk.listo_para_id:
                firma_rapida = extraer_firma_hibrida(frame, box)
                if firma_rapida is not None:
                    cx_nuevo = (box[0] + box[2]) / 2
                    cy_nuevo = (box[1] + box[3]) / 2

                    with self.global_mem.lock:
                        for gid_cand, data_cand in self.global_mem.db.items():
                            if gid_cand in active_gids_local:
                                continue
                            if str(data_cand.get('last_cam', '')) != str(self.cam_id):
                                continue
                            if (now - data_cand['ts']) > TIEMPO_REENTRADA:
                                continue
                            if not data_cand['firmas']:
                                continue

                            ultima_box = data_cand.get('last_box')
                            if ultima_box is None:
                                continue

                            cx_ult = (ultima_box[0] + ultima_box[2]) / 2
                            cy_ult = (ultima_box[1] + ultima_box[3]) / 2
                            distancia = np.sqrt((cx_nuevo-cx_ult)**2 + (cy_nuevo-cy_ult)**2)

                            if distancia < RADIO_REENTRADA:
                                sim = similitud_hibrida(firma_rapida, data_cand['firmas'][0])
                                if sim > UMBRAL_REID_MISMA_CAM:
                                    trk.gid           = gid_cand
                                    trk.origen_global = True
                                    trk.area_referencia = area_actual
                                    trk.listo_para_id = True
                                    self.global_mem._actualizar_sin_lock(
                                        gid_cand, firma_rapida, self.cam_id, now
                                    )
                                    break

            elif trk.gid is not None and not trk.en_grupo:
                tiempo_ultima_firma = trk.ultimo_aprendizaje
                if (now - tiempo_ultima_firma) > 1.5 and analizar_calidad(box):
                    x1b, y1b, x2b, y2b = map(int, box)
                    en_borde = (x1b < 15 or y1b < 15 or x2b > fw-15 or y2b > fh-15)

                    if not en_borde:
                        firma_nueva = extraer_firma_hibrida(frame, box)
                        if firma_nueva is not None:
                            with self.global_mem.lock:
                                if (trk.gid in self.global_mem.db
                                        and self.global_mem.db[trk.gid]['firmas']):

                                    firma_ancla    = self.global_mem.db[trk.gid]['firmas'][0]
                                    sim_coherencia = similitud_hibrida(firma_nueva, firma_ancla)

                                    if sim_coherencia > 0.55:
                                        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
                                            sim_intruso = similitud_hibrida(
                                                firma_nueva, otro_data['firmas'][0]
                                            )
                                            if sim_intruso > 0.55:
                                                es_coherente = False
                                                break

                                        if es_coherente:
                                            self.global_mem._actualizar_sin_lock(
                                                trk.gid, firma_nueva, self.cam_id, now
                                            )
                                            trk.ultimo_aprendizaje = now
                                            trk.aprendiendo = True

        for trk in self.trackers:
            if trk.time_since_update == 0 and trk.gid is not None:
                with self.global_mem.lock:
                    if trk.gid in self.global_mem.db:
                        self.global_mem.db[trk.gid]['last_box'] = list(trk.box)

        for d_idx in unmatched_dets:
            self.trackers.append(KalmanTrack(boxes[d_idx], now))

        vivos = []
        for t in self.trackers:
            x1, y1, x2, y2 = t.box
            toca_borde    = (x1 < 5 or y1 < 5 or x2 > fw-5 or y2 > fh-5)
            tiempo_oculto = now - t.ts_ultima_deteccion

            if toca_borde:
                limite = TIEMPO_PACIENCIA_BORDE_CON_ID if t.gid else TIEMPO_PACIENCIA_BORDE_SIN_ID
            else:
                limite = TIEMPO_PACIENCIA_INTERIOR if t.gid else TIEMPO_PACIENCIA_INTERIOR_NUEVO

            if tiempo_oculto < limite:
                vivos.append(t)

        self.trackers = vivos
        return self.trackers

    def _asignar(self, boxes, now):
        n_trk = len(self.trackers)
        n_det = len(boxes)

        if n_trk == 0: return [], list(range(n_det)), []
        if n_det == 0: return [], [], list(range(n_trk))

        cost_mat = np.zeros((n_trk, n_det), dtype=np.float32)

        for t, trk in enumerate(self.trackers):
            for d, det in enumerate(boxes):
                iou = iou_overlap(trk.box, det)
                cx_t = (trk.box[0]+trk.box[2]) / 2
                cy_t = (trk.box[1]+trk.box[3]) / 2
                cx_d = (det[0]+det[2]) / 2
                cy_d = (det[1]+det[3]) / 2
                dist_norm = np.sqrt((cx_t-cx_d)**2 + (cy_t-cy_d)**2) / 550.0

                area_trk    = (trk.box[2]-trk.box[0]) * (trk.box[3]-trk.box[1])
                area_det    = (det[2]-det[0]) * (det[3]-det[1])
                ratio_area  = max(area_trk, area_det) / (min(area_trk, area_det) + 1e-6)
                castigo_tam = (ratio_area - 1.0) * 0.4

                tiempo_oculto = now - trk.ts_ultima_deteccion
                if tiempo_oculto > (TIEMPO_TURNO_ROTATIVO * 2) and iou < 0.10:
                    fantasma_penalty = 5.0  
                else:
                    fantasma_penalty = 0.0

                if iou >= 0.05 or dist_norm < 0.50:
                    cost_mat[t, d] = ((1.0 - iou)
                                      + (dist_norm * 2.0)
                                      + fantasma_penalty
                                      + castigo_tam)
                else:
                    cost_mat[t, d] = 100.0

        row_ind, col_ind = linear_sum_assignment(cost_mat)
        matched, unmatched_dets, unmatched_trks = [], [], []

        for r, c in zip(row_ind, col_ind):
            if cost_mat[r, c] > 4.0:
                unmatched_trks.append(r)
                unmatched_dets.append(c)
            else:
                matched.append((r, c))

        matched_t = {m[0] for m in matched}
        matched_d = {m[1] for m in matched}
        for t in range(n_trk):
            if t not in matched_t: unmatched_trks.append(t)
        for d in range(n_det):
            if d not in matched_d: unmatched_dets.append(d)

        return matched, unmatched_dets, unmatched_trks

# ──────────────────────────────────────────────────────────────────────────────
#  5. STREAM DE CÁMARA
# ──────────────────────────────────────────────────────────────────────────────
class CamStream:
    def __init__(self, url):
        self.url  = url
        self.cap  = cv2.VideoCapture(url)
        self.frame = None
        self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
        threading.Thread(target=self._run, daemon=True).start()

    def _run(self):
        while True:
            ret, f = self.cap.read()
            if ret:
                self.frame = f
                time.sleep(0.01)   
            else:
                time.sleep(2)
                self.cap.open(self.url)

# ──────────────────────────────────────────────────────────────────────────────
#  6. DIBUJADO
# ──────────────────────────────────────────────────────────────────────────────
def dibujar_track(frame_show, trk):
    try:
        x1, y1, x2, y2 = map(int, trk.box)
    except Exception:
        return

    if trk.gid is None:
        color, label = C_CANDIDATO, f"?{trk.local_id}"
    elif trk.en_grupo:
        color, label = C_GRUPO, f"ID:{trk.gid} [grp]"
    elif trk.aprendiendo:
        color, label = C_APRENDIZAJE, f"ID:{trk.gid} [++]"
    elif trk.origen_global:
        color, label = C_GLOBAL, f"ID:{trk.gid} [re-id]"
    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)

    if trk.gid is None:
        pct = min(trk.frames_buena_calidad / FRAMES_CALIDAD, 1.0)
        bw  = x2 - x1
        cv2.rectangle(frame_show, (x1, y2+2), (x2, y2+7), (50, 50, 50), -1)
        cv2.rectangle(frame_show, (x1, y2+2), (x1+int(bw*pct), y2+7), (0, 220, 220), -1)

# ──────────────────────────────────────────────────────────────────────────────
#  7. MAIN LOOP (para pruebas standalone)
# ──────────────────────────────────────────────────────────────────────────────
def main():
    print("SmartSoft")
    model      = YOLO("yolov8n.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
    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        = []
            turno_activo = (i == cam_ia)

            if turno_activo:
                res = model.predict(
                    frame_show, conf=0.40, iou=0.50,
                    classes=[0], verbose=False, imgsz=480
                )
                if res[0].boxes:
                    boxes = res[0].boxes.xyxy.cpu().numpy().tolist()

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

            for trk in tracks:
                if trk.time_since_update == 0:
                    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 t.gid 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[0:3]),
                np.hstack(tiles[3:6])
            ]))

        idx += 1
        if cv2.waitKey(1) == ord('q'):
            break

    cv2.destroyAllWindows()

if __name__ == "__main__":
    main()