IdentificacionIA/interfaz.py

1335 lines
70 KiB
Python
Raw Permalink Normal View History

2026-06-25 17:06:15 +00:00
import customtkinter as ctk
from tkinter import ttk
from PIL import Image
import cv2
import json
import os
import threading
import csv
from datetime import datetime
from tkinter import filedialog
import time
# Importamos el motor de IA
import fision1
ctk.set_appearance_mode("Dark")
ctk.set_default_color_theme("blue")
CONFIG_FILE = "config.json"
DB_PATH = "db_institucion"
class SmartSoftApp(ctk.CTk):
def __init__(self):
super().__init__()
self.title("SmartSoft IA - Centro de Control")
self.geometry("1250x800")
self.minsize(1100, 700)
self.grid_columnconfigure(1, weight=1)
self.grid_rowconfigure(0, weight=1)
self.motor_corriendo = False
self.construir_login()
def log_evento(self, mensaje, tipo="INFO"):
"""Registra el evento en la terminal interna y lanza una notificación flotante (Toast)"""
# 1. Escribir en la Terminal Interna (Registro permanente)
if hasattr(self, 'terminal_texto'):
hora = datetime.now().strftime('%H:%M:%S')
self.terminal_texto.configure(state="normal")
self.terminal_texto.insert("end", f"[{hora}] [{tipo}] {mensaje}\n")
self.terminal_texto.see("end") # Auto-scroll hacia abajo
self.terminal_texto.configure(state="disabled")
# 2. Notificación Flotante (Toast)
if hasattr(self, 'toast_frame') and self.toast_frame.winfo_exists():
self.toast_frame.destroy()
color_fondo = "#27ae60" if tipo == "ÉXITO" else ("#c0392b" if tipo == "ERROR" else "#2980b9")
self.toast_frame = ctk.CTkFrame(self, fg_color=color_fondo, corner_radius=8)
self.toast_frame.place(relx=0.98, rely=0.92, anchor="se") # Esquina inferior derecha
ctk.CTkLabel(self.toast_frame, text=mensaje, font=ctk.CTkFont(weight="bold", size=13), text_color="white").pack(padx=25, pady=12)
# El toast se autodestruye suavemente tras 3.5 segundos
self.after(3500, self.destruir_toast)
def destruir_toast(self):
if hasattr(self, 'toast_frame') and self.toast_frame.winfo_exists():
self.toast_frame.destroy()
# ──────────────────────────────────────────────────────────────────────────
# PANTALLA 1: LOGIN
# ──────────────────────────────────────────────────────────────────────────
def construir_login(self):
self.frame_fondo = ctk.CTkFrame(self, fg_color="transparent")
self.frame_fondo.grid(row=0, column=0, columnspan=2, sticky="nsew")
self.frame_login = ctk.CTkFrame(self.frame_fondo, width=400, height=400, corner_radius=15)
self.frame_login.place(relx=0.5, rely=0.5, anchor="center")
ctk.CTkLabel(self.frame_login, text="SmartSoft IA", font=ctk.CTkFont(size=32, weight="bold")).pack(pady=(40, 5))
ctk.CTkLabel(self.frame_login, text="Code Innovations", text_color="#2ecc71", font=ctk.CTkFont(size=14)).pack(pady=(0, 40))
self.entry_user = ctk.CTkEntry(self.frame_login, placeholder_text="Usuario Operador", width=280, height=40)
self.entry_user.pack(pady=10)
self.entry_user.insert(0, "admin")
self.entry_pass = ctk.CTkEntry(self.frame_login, placeholder_text="Contraseña", width=280, height=40, show="")
self.entry_pass.pack(pady=10)
self.entry_pass.insert(0, "1234")
self.lbl_error = ctk.CTkLabel(self.frame_login, text="", text_color="#e74c3c")
self.lbl_error.pack(pady=5)
btn_login = ctk.CTkButton(self.frame_login, text="Acceder al Sistema", command=self.validar_login, width=280, height=45, font=ctk.CTkFont(weight="bold"))
btn_login.pack(pady=20)
def validar_login(self):
if self.entry_user.get() == "admin" and self.entry_pass.get() == "1234":
self.frame_fondo.destroy()
self.construir_app_principal()
else:
self.lbl_error.configure(text="Acceso denegado. Verifique sus credenciales.")
# ──────────────────────────────────────────────────────────────────────────
# PANTALLA 2: APLICACIÓN PRINCIPAL
# ──────────────────────────────────────────────────────────────────────────
def construir_app_principal(self):
self.ip_var = ctk.StringVar()
self.user_var = ctk.StringVar()
self.pass_var = ctk.StringVar()
self.secuencia_var = ctk.StringVar()
self.ignoradas_var = ctk.StringVar()
self.vecinos_var = ctk.StringVar()
self.cams_por_pantalla_var = ctk.StringVar(value="4") # ⚡ VARIABLE RECUPERADA
self.cargar_configuracion()
# SIDEBAR
self.sidebar_frame = ctk.CTkFrame(self, width=250, corner_radius=0)
self.sidebar_frame.grid(row=0, column=0, sticky="nsew")
self.sidebar_frame.grid_rowconfigure(5, weight=1)
ctk.CTkLabel(self.sidebar_frame, text="SmartSoft IA", font=ctk.CTkFont(size=24, weight="bold")).grid(row=0, column=0, padx=20, pady=(30, 5))
ctk.CTkLabel(self.sidebar_frame, text="Centro de Control", text_color="#2ecc71", font=ctk.CTkFont(size=12)).grid(row=1, column=0, padx=20, pady=(0, 30))
self.btn_iniciar = ctk.CTkButton(self.sidebar_frame, text="▶ Iniciar Motor IA", fg_color="#27ae60", hover_color="#2ecc71", height=45, command=self.iniciar_sistema)
self.btn_iniciar.grid(row=2, column=0, padx=20, pady=10)
self.lbl_estado = ctk.CTkLabel(self.sidebar_frame, text="Estado: EN ESPERA", text_color="gray")
self.lbl_estado.grid(row=3, column=0, padx=20, pady=5)
# ⚡ CONTENEDOR DE BOTONES DESLIZANTES
self.frame_paginas_control = ctk.CTkFrame(self.sidebar_frame, fg_color="transparent")
self.frame_paginas_control.grid(row=4, column=0, padx=20, pady=15, sticky="ew")
# TABS
self.tabview = ctk.CTkTabview(self, corner_radius=10)
self.tabview.grid(row=0, column=1, padx=20, pady=20, sticky="nsew")
self.tab_monitor = self.tabview.add("📷 Monitor en Vivo")
self.tab_reporte = self.tabview.add("📊 Auditoría de Accesos")
self.tab_registro = self.tabview.add("👤 Gestión de Personal")
self.tab_config = self.tabview.add("⚙️ Parámetros")
self.crear_tab_monitor()
self.crear_tab_reporte()
self.crear_tab_registro()
self.crear_tab_configuracion()
# ⚡ EVENTO: Doble Clic para Zoom
self.lbl_video.bind("<Double-Button-1>", self.zoom_tactico_camara)
# TABS
self.tabview = ctk.CTkTabview(self, corner_radius=10)
self.tabview.grid(row=0, column=1, padx=20, pady=20, sticky="nsew")
self.tab_monitor = self.tabview.add("📷 Monitor en Vivo")
self.tab_reporte = self.tabview.add("📊 Auditoría de Accesos")
self.tab_registro = self.tabview.add("👤 Gestión de Personal")
self.tab_config = self.tabview.add("⚙️ Parámetros")
self.sidebar_visible = True
# ⚡ EVENTO: Capturar la tecla 'Enter' globalmente
self.bind('<Return>', self.atajo_teclado_enter)
self.crear_tab_monitor()
self.crear_tab_reporte()
self.crear_tab_registro()
self.crear_tab_configuracion()
# ─────────────────────────────────────────────────────────────
# ⚡ BARRA DE TELEMETRÍA (HUD INFERIOR)
# ─────────────────────────────────────────────────────────────
self.hud_frame = ctk.CTkFrame(self, height=30, fg_color="#1a1a1a", corner_radius=0)
self.hud_frame.grid(row=1, column=1, sticky="ew")
self.lbl_hud_fps = ctk.CTkLabel(self.hud_frame, text="⚡ Motor: OFF", font=ctk.CTkFont(size=11, weight="bold"), text_color="#7f8c8d")
self.lbl_hud_fps.pack(side="left", padx=20)
self.lbl_hud_mem = ctk.CTkLabel(self.hud_frame, text="🧠 Memoria RAM: 0 IDs", font=ctk.CTkFont(size=11, weight="bold"), text_color="#7f8c8d")
self.lbl_hud_mem.pack(side="left", padx=20)
self.lbl_hud_alertas = ctk.CTkLabel(self.hud_frame, text="🚨 Alertas: 0", font=ctk.CTkFont(size=11, weight="bold"), text_color="#7f8c8d")
self.lbl_hud_alertas.pack(side="right", padx=20)
# ─────────────────────────────────────────────────────────────
# ⚡ LIMPIEZA AUTOMÁTICA DE CACHÉ (Mantenimiento Silencioso)
# ─────────────────────────────────────────────────────────────
self.limpiar_basura_temporal()
def atajo_teclado_enter(self, event):
"""Ejecuta la acción principal de la pestaña en la que esté el usuario"""
pestana_actual = self.tabview.get()
if pestana_actual == "📊 Auditoría de Accesos":
self.cargar_datos_csv()
self.log_evento("Búsqueda ejecutada por teclado", "INFO")
elif pestana_actual == "👤 Gestión de Personal":
if self.buscar_empleado_var.get().strip() != "":
self.buscar_registro_visual()
def toggle_modo_enfoque(self):
"""Colapsa el menú lateral para dar 100% de pantalla a la videovigilancia"""
if self.sidebar_visible:
self.sidebar_frame.grid_remove() # Oculta la columna
self.btn_focus.configure(text="▶ Mostrar Menú", fg_color="#34495e", hover_color="#2c3e50")
self.sidebar_visible = False
self.log_evento("Modo Enfoque Activado", "INFO")
else:
self.sidebar_frame.grid() # Restaura la columna
self.btn_focus.configure(text="⛶ Modo Enfoque", fg_color="#8e44ad", hover_color="#9b59b6")
self.sidebar_visible = True
def limpiar_basura_temporal(self):
"""Elimina recortes de rostros temporales antiguos para no saturar el disco"""
try:
if not os.path.exists("cache_nombres"): os.makedirs("cache_nombres")
for archivo in os.listdir("cache_nombres"):
if archivo.startswith("alerta_") and archivo.endswith(".jpg"):
ruta = os.path.join("cache_nombres", archivo)
# Si la alerta tiene más de 24 horas, se elimina
if time.time() - os.path.getmtime(ruta) > 86400:
os.remove(ruta)
except Exception: pass
def generar_botonera_deslizante(self):
for widget in self.frame_paginas_control.winfo_children(): widget.destroy()
camaras_disponibles = [c.strip() for c in self.secuencia_var.get().split(',') if c.strip()]
total_cams = len(camaras_disponibles)
try: cams_por_pagina = max(1, int(self.cams_por_pantalla_var.get()))
except ValueError: cams_por_pagina = 4
import math
total_paginas = math.ceil(total_cams / cams_por_pagina)
if total_paginas <= 1: return
ctk.CTkLabel(self.frame_paginas_control, text="PANTALLAS DISPONIBLES", font=ctk.CTkFont(size=10, weight="bold"), text_color="gray").pack(pady=5)
self.botones_paginas = []
for i in range(total_paginas):
btn = ctk.CTkButton(self.frame_paginas_control, text=f"📺 Sección {i+1}", height=32,
fg_color="#34495e" if i != 0 else "#2980b9",
command=lambda idx=i: self.cambiar_seccion_mosaico(idx))
btn.pack(fill="x", pady=4)
self.botones_paginas.append(btn)
def cambiar_seccion_mosaico(self, indice_pagina):
# ⚡ FIX: Obligamos al motor de IA a cambiar la página globalmente
fision1.PAGINA_ACTUAL = indice_pagina
# Limpiamos temporalmente la pantalla para forzar el redibujado
try:
self.lbl_video.configure(image=self.img_ctk_vacia)
except Exception: pass
# Actualizamos los colores de la botonera
for idx, btn in enumerate(self.botones_paginas):
if idx == indice_pagina:
btn.configure(fg_color="#2980b9", font=ctk.CTkFont(weight="bold"))
else:
btn.configure(fg_color="#34495e", font=ctk.CTkFont(weight="normal"))
self.log_evento(f"Visualizando Sección {indice_pagina + 1}", "INFO")
# ──────────────────────────────────────────────────────────────────────────
# PESTAÑA 1: MONITOR EN VIVO Y ALERTAS DE INTRUSIÓN
# ──────────────────────────────────────────────────────────────────────────
def crear_tab_monitor(self):
self.tab_monitor.grid_columnconfigure(0, weight=1)
self.tab_monitor.grid_columnconfigure(1, weight=0)
self.tab_monitor.grid_rowconfigure(1, weight=1)
# --- BARRA DE HERRAMIENTAS TÁCTICA ---
frame_toolbar = ctk.CTkFrame(self.tab_monitor, fg_color="transparent", height=40)
frame_toolbar.grid(row=0, column=0, columnspan=2, sticky="ew")
self.btn_toggle_alertas = ctk.CTkButton(frame_toolbar, text="👁 Mostrar Alertas (0)", fg_color="#34495e", hover_color="#2c3e50", command=self.toggle_panel_alertas, width=150)
self.btn_toggle_alertas.pack(side="right", padx=10, pady=5)
# (Dentro de crear_tab_monitor, donde está btn_toggle_alertas)
self.btn_focus = ctk.CTkButton(frame_toolbar, text="⛶ Modo Enfoque", fg_color="#8e44ad", hover_color="#9b59b6", command=self.toggle_modo_enfoque, width=130)
self.btn_focus.pack(side="left", padx=10, pady=5)
# --- PANEL IZQUIERDO (VIDEO) ---
self.frame_video_container = ctk.CTkFrame(self.tab_monitor, fg_color="black", corner_radius=10)
self.frame_video_container.grid(row=1, column=0, sticky="nsew", padx=(0, 5))
self.frame_video_container.grid_rowconfigure(0, weight=1)
self.frame_video_container.grid_columnconfigure(0, weight=1)
self.lbl_video = ctk.CTkLabel(self.frame_video_container, text="Inicie el Motor IA desde el panel lateral.")
self.lbl_video.grid(row=0, column=0, sticky="nsew")
# ⚡ EVENTO: Doble Clic para Zoom Táctico
self.lbl_video.bind("<Double-Button-1>", self.zoom_tactico_camara)
# --- PANEL DERECHO (ALERTAS) ---
self.panel_alertas = ctk.CTkScrollableFrame(self.tab_monitor, fg_color="#1a1a1a", corner_radius=10, width=300)
self.alertas_visibles = False
header_alertas = ctk.CTkFrame(self.panel_alertas, fg_color="#c0392b", corner_radius=5)
header_alertas.pack(fill="x", pady=(0, 10))
ctk.CTkLabel(header_alertas, text="🚨 NO IDENTIFICADOS", font=ctk.CTkFont(weight="bold", size=13), text_color="white").pack(pady=8)
self.lbl_no_alertas = ctk.CTkLabel(self.panel_alertas, text="Área segura.\nSin detecciones pendientes.", text_color="gray")
self.lbl_no_alertas.pack(pady=30)
self.alertas_ui_activas = {}
def toggle_panel_alertas(self):
if self.alertas_visibles:
self.panel_alertas.grid_forget()
self.btn_toggle_alertas.configure(text=f"👁 Mostrar Alertas ({len(self.alertas_ui_activas)})")
self.alertas_visibles = False
else:
self.panel_alertas.grid(row=1, column=1, sticky="nsew", padx=(5, 0))
self.btn_toggle_alertas.configure(text="🙈 Ocultar Panel")
self.alertas_visibles = True
def procesar_intruso(self, id_rastreo, camara, imagen_bgr):
"""Filtro de calidad y generación de alerta"""
if id_rastreo in self.alertas_ui_activas: return
# ⚡ FILTRO DE CALIDAD CORREGIDO (Solo validamos que la foto no esté corrupta)
h, w = imagen_bgr.shape[:2]
if h < 20 or w < 20: return
# Guardado temporal en caché y envío al panel lateral
if not os.path.exists("cache_nombres"): os.makedirs("cache_nombres")
ruta_alerta = os.path.join("cache_nombres", f"alerta_{id_rastreo}.jpg")
cv2.imwrite(ruta_alerta, imagen_bgr)
self.inyectar_alerta_ui(id_rastreo, camara, ruta_alerta, imagen_bgr)
def inyectar_alerta_ui(self, id_rastreo, camara, ruta_rostro, img_bgr):
self.lbl_no_alertas.pack_forget()
tarjeta = ctk.CTkFrame(self.panel_alertas, fg_color="#2b2b2b", corner_radius=8)
tarjeta.pack(fill="x", pady=5, padx=5)
try:
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
img_pil = Image.fromarray(img_rgb)
img_ctk = ctk.CTkImage(light_image=img_pil, size=(70, 70))
lbl_foto = ctk.CTkLabel(tarjeta, image=img_ctk, text="")
lbl_foto.image_ref = img_ctk
lbl_foto.pack(side="left", padx=10, pady=10)
except Exception: pass
info_frame = ctk.CTkFrame(tarjeta, fg_color="transparent")
info_frame.pack(side="left", fill="both", expand=True)
ctk.CTkLabel(info_frame, text=f"Sujeto ID: {id_rastreo}", font=ctk.CTkFont(weight="bold", size=12), text_color="#e74c3c").pack(anchor="w", pady=(10, 0))
ctk.CTkLabel(info_frame, text=f"Visto en: Cam {camara}", font=ctk.CTkFont(size=11), text_color="gray").pack(anchor="w")
# ⚡ LOS DOS BOTONES TÁCTICOS
btn_frame = ctk.CTkFrame(info_frame, fg_color="transparent")
btn_frame.pack(fill="x", pady=(5, 10))
btn_registrar = ctk.CTkButton(btn_frame, text="✚ Registrar", width=70, height=24, fg_color="#27ae60", hover_color="#2ecc71",
command=lambda: self.quick_enroll(id_rastreo, img_bgr, tarjeta))
btn_registrar.pack(side="left", padx=(0, 5))
btn_descartar = ctk.CTkButton(btn_frame, text="✖ Descartar", width=70, height=24, fg_color="#7f8c8d", hover_color="#95a5a6",
command=lambda: self.descartar_alerta(tarjeta, id_rastreo))
btn_descartar.pack(side="left")
self.alertas_ui_activas[id_rastreo] = tarjeta
# Actualizar contador HUD
if self.alertas_visibles:
self.btn_toggle_alertas.configure(text="🙈 Ocultar Panel")
else:
self.btn_toggle_alertas.configure(text=f"👁 Mostrar Alertas ({len(self.alertas_ui_activas)})")
self.lbl_hud_alertas.configure(text=f"🚨 Alertas: {len(self.alertas_ui_activas)}", text_color="#e74c3c")
def descartar_alerta(self, tarjeta_widget, id_rastreo):
tarjeta_widget.destroy()
if id_rastreo in self.alertas_ui_activas:
del self.alertas_ui_activas[id_rastreo]
total = len(self.alertas_ui_activas)
if total == 0:
self.lbl_no_alertas.pack(pady=30)
self.lbl_hud_alertas.configure(text="🚨 Alertas: 0", text_color="#7f8c8d")
if not self.alertas_visibles:
self.btn_toggle_alertas.configure(text=f"👁 Mostrar Alertas ({total})")
def quick_enroll(self, id_rastreo, img_bgr, tarjeta_widget):
"""Puente directo desde la Alerta hasta el Formulario de Registro"""
self.descartar_alerta(tarjeta_widget, id_rastreo)
# Cambiamos a la pestaña de registro
self.tabview.set("👤 Gestión de Personal")
# Inyectamos la foto y habilitamos el campo de nombre
self.face_temporal = img_bgr
self.genero_temporal = "Desconocido" # El guardia lo verificará visualmente
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
img_pil = Image.fromarray(img_rgb)
img_ctk = ctk.CTkImage(light_image=img_pil, dark_image=img_pil, size=(200, 200))
self.lbl_cam_registro.image_ref = img_ctk
self.lbl_cam_registro.configure(image=img_ctk, text="")
self.lbl_feedback_reg.configure(text="✅ Intruso importado. Ingrese el nombre para enrolar.", text_color="#f1c40f")
self.entry_nombre_reg.configure(state="normal")
self.btn_guardar_nuevo.configure(state="normal")
self.entry_nombre_reg.focus()
def guardar_configuracion(self):
data = {
"dvr_ip": self.ip_var.get(), "dvr_user": self.user_var.get(),
"dvr_pass": self.pass_var.get(), "secuencia_total": self.secuencia_var.get(),
"ignoradas": self.ignoradas_var.get(), "vecinos": self.vecinos_var.get(),
"cams_por_pantalla": self.cams_por_pantalla_var.get()
}
with open(CONFIG_FILE, 'w') as f: json.dump(data, f, indent=4)
# ⚡ FIX ABSOLUTO: Inyección (Hot-Reload) de la cuadrícula
if self.motor_corriendo:
try:
# 1. Actualizamos el número en el cerebro del motor
fision1.CAMS_POR_PANTALLA = max(1, int(self.cams_por_pantalla_var.get()))
# 2. Obligamos al motor a regresar a la página 0 para evitar errores de índice
fision1.PAGINA_ACTUAL = 0
# 3. Redibujamos los botones azules de la izquierda
self.generar_botonera_deslizante()
# 4. Forzamos visualmente el clic en el botón 1 para que la pantalla "parpadee" y se ajuste
if hasattr(self, 'botones_paginas') and self.botones_paginas:
self.cambiar_seccion_mosaico(0)
self.log_evento("Cuadrícula actualizada dinámicamente", "ÉXITO")
except Exception as e: pass
self.btn_guardar.configure(text="¡Configuración Actualizada!", fg_color="green")
self.after(2000, lambda: self.btn_guardar.configure(text="Guardar Parámetros", fg_color="#1f538d"))
def iniciar_sistema(self):
if not self.motor_corriendo:
self.guardar_configuracion()
# ⚡ FIX CRÍTICO: El motor DEBE obedecer a la interfaz, no a su código duro interno
sec_str = self.secuencia_var.get()
fision1.SECUENCIA = [int(x.strip()) for x in sec_str.split(',') if x.strip()]
fision1.URLS = [f"rtsp://{self.user_var.get()}:{self.pass_var.get()}@{self.ip_var.get()}:554/Streaming/Channels/{i}02" for i in fision1.SECUENCIA]
try: fision1.CAMS_POR_PANTALLA = max(1, int(self.cams_por_pantalla_var.get()))
except: fision1.CAMS_POR_PANTALLA = 4
fision1.PAGINA_ACTUAL = 0
# Encendemos el botón de detener
self.btn_iniciar.configure(state="normal", text="⏹ DETENER MOTOR IA", fg_color="#c0392b", hover_color="#e74c3c")
self.lbl_estado.configure(text="Estado: EN LÍNEA", text_color="#2ecc71")
self.motor_corriendo = True
self.generar_botonera_deslizante()
self.tabview.set("📷 Monitor en Vivo")
threading.Thread(target=fision1.main, daemon=True).start()
self.after(45, self.actualizar_video)
self.log_evento("Sistema de inferencia iniciado", "ÉXITO")
else:
# --- RUTINA DE APAGADO SEGURO ---
fision1.MOTOR_ACTIVO = False
self.motor_corriendo = False
self.btn_iniciar.configure(state="normal", text="▶ Iniciar Motor IA", fg_color="#27ae60", hover_color="#2ecc71")
self.lbl_estado.configure(text="Estado: DETENIDO", text_color="#7f8c8d")
try: self.lbl_video.configure(image=self.img_ctk_vacia, text="Motor IA Detenido. Esperando conexión...")
except Exception: pass
for widget in self.frame_paginas_control.winfo_children(): widget.destroy()
self.log_evento("Motor IA detenido por el usuario", "INFO")
def zoom_tactico_camara(self, event):
if not hasattr(fision1, 'CAMARA_MAXIMIZADA'): fision1.CAMARA_MAXIMIZADA = None
# Si ya hay zoom, el doble clic lo desactiva
if fision1.CAMARA_MAXIMIZADA is not None:
fision1.CAMARA_MAXIMIZADA = None
self.log_evento("Zoom táctico desactivado", "INFO")
return
ancho_lbl = self.lbl_video.winfo_width()
alto_lbl = self.lbl_video.winfo_height()
try: cams_actuales = int(self.cams_por_pantalla_var.get())
except: cams_actuales = 4
if cams_actuales <= 1: return
import math
cols = math.ceil(math.sqrt(cams_actuales))
rows = math.ceil(cams_actuales / cols)
col_click = int(event.x // (ancho_lbl / cols))
row_click = int(event.y // (alto_lbl / rows))
idx_relativo = (row_click * cols) + col_click
try:
# ⚡ FIX: Calculamos la cámara real basada en la Secuencia Inyectada
secuencia = getattr(fision1, 'SECUENCIA', [])
idx_global = (fision1.PAGINA_ACTUAL * cams_actuales) + idx_relativo
if idx_global < len(secuencia):
fision1.CAMARA_MAXIMIZADA = secuencia[idx_global]
self.log_evento(f"Zoom activado en Cámara {fision1.CAMARA_MAXIMIZADA}", "INFO")
except Exception: pass
def actualizar_video(self):
if not self.motor_corriendo: return
frame_bgr = fision1.obtener_ultimo_frame()
if frame_bgr is not None:
ancho = self.tab_monitor.winfo_width() - 10
alto = self.tab_monitor.winfo_height() - 10
if ancho > 200 and alto > 200:
frame_resized = cv2.resize(frame_bgr, (ancho, alto), interpolation=cv2.INTER_LINEAR)
frame_rgb = cv2.cvtColor(frame_resized, cv2.COLOR_BGR2RGB)
img_pil = Image.fromarray(frame_rgb)
img_ctk = ctk.CTkImage(light_image=img_pil, dark_image=img_pil, size=(ancho, alto))
self.lbl_video.image_ref = img_ctk
try:
self.lbl_video.configure(image=img_ctk, text="")
except Exception: pass
# ⚡ ACTUALIZAR LA BARRA DE TELEMETRÍA EN TIEMPO REAL
try:
if hasattr(fision1, 'TELEMETRIA'):
fps = fision1.TELEMETRIA.get('fps', 0)
ids_ram = fision1.TELEMETRIA.get('ids_activos', 0)
color_fps = "#2ecc71" if fps > 15 else ("#f1c40f" if fps > 8 else "#e74c3c")
self.lbl_hud_fps.configure(text=f"⚡ Motor: {fps:.1f} FPS", text_color=color_fps)
self.lbl_hud_mem.configure(text=f"🧠 Memoria RAM: {ids_ram} IDs activos", text_color="#3498db")
except Exception: pass
# ─────────────────────────────────────────────────────────────
# ⚡ PUENTE DE ALERTAS: Leer Desconocidos desde el Motor IA
# ─────────────────────────────────────────────────────────────
try:
if hasattr(fision1, 'NUEVAS_ALERTAS') and len(fision1.NUEVAS_ALERTAS) > 0:
alerta = fision1.NUEVAS_ALERTAS.pop(0) # Extrae: (gid, cam_id, roi_cabeza)
self.procesar_intruso(alerta[0], alerta[1], alerta[2])
except Exception: pass
self.after(45, self.actualizar_video)
# ──────────────────────────────────────────────────────────────────────────
# PESTAÑA 2: AUDITORÍA (REPORTES)
# ──────────────────────────────────────────────────────────────────────────
def crear_tab_reporte(self):
# ⚡ DIVISIÓN DE PANTALLA: 70% Tabla, 30% Timeline
self.tab_reporte.grid_columnconfigure(0, weight=1)
# Columna 1 (Panel) es rígida, NUNCA se hará más pequeña de 320 píxeles
self.tab_reporte.grid_columnconfigure(1, weight=0, minsize=320)
self.tab_reporte.grid_rowconfigure(2, weight=1)
# 1. FILA SUPERIOR: Buscador y Filtros
frame_ctrl = ctk.CTkFrame(self.tab_reporte, fg_color="transparent")
frame_ctrl.grid(row=0, column=0, columnspan=2, sticky="ew", pady=(0,5))
ctk.CTkLabel(frame_ctrl, text="Nombre:").pack(side="left", padx=(5,2))
self.search_var = ctk.StringVar()
ctk.CTkEntry(frame_ctrl, textvariable=self.search_var, width=150).pack(side="left", padx=(0,15))
ctk.CTkLabel(frame_ctrl, text="Fecha:").pack(side="left", padx=(5,2))
self.date_var = ctk.StringVar()
ctk.CTkEntry(frame_ctrl, textvariable=self.date_var, width=110, placeholder_text="YYYY-MM-DD").pack(side="left", padx=(0,15))
ctk.CTkButton(frame_ctrl, text="🔍 Buscar", command=self.cargar_datos_csv, width=100).pack(side="left", padx=5)
ctk.CTkButton(frame_ctrl, text="🔄 Limpiar", fg_color="#d35400", hover_color="#e67e22", command=self.recargar_completo, width=100).pack(side="left", padx=10)
# 2. FILA INTERMEDIA: Controles de Vista y Exportación Ejecutiva
frame_modo = ctk.CTkFrame(self.tab_reporte, fg_color="#2b2b2b", corner_radius=8)
frame_modo.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(5,15), ipadx=5, ipady=5)
self.modo_vista_var = ctk.StringVar(value="asistencia")
rb_asistencia = ctk.CTkRadioButton(frame_modo, text="Control de Asistencia", variable=self.modo_vista_var, value="asistencia", command=self.cargar_datos_csv, text_color="#2ecc71", font=ctk.CTkFont(weight="bold"))
rb_asistencia.pack(side="left", padx=15, pady=5)
rb_crudo = ctk.CTkRadioButton(frame_modo, text="Auditoría Forense", variable=self.modo_vista_var, value="crudo", command=self.cargar_datos_csv)
rb_crudo.pack(side="left", padx=15, pady=5)
btn_exportar = ctk.CTkButton(frame_modo, text="📄 Exportar a Excel", fg_color="#27ae60", hover_color="#2ecc71", command=self.exportar_reporte)
btn_exportar.pack(side="right", padx=15)
# 3. ÁREA IZQUIERDA: LA TABLA
style = ttk.Style()
style.theme_use("default")
style.configure("Treeview", background="#2b2b2b", foreground="white", fieldbackground="#2b2b2b", rowheight=28, borderwidth=0, font=("Arial", 9))
style.configure("Treeview.Heading", background="#1a1a1a", foreground="white", relief="flat", font=("Arial", 10, "bold"))
style.map("Treeview", background=[("selected", "#2980b9")])
# Frame contenedor para la tabla y su scroll
frame_tabla = ctk.CTkFrame(self.tab_reporte, fg_color="transparent")
frame_tabla.grid(row=2, column=0, sticky="nsew", padx=(0, 10))
frame_tabla.grid_columnconfigure(0, weight=1)
frame_tabla.grid_rowconfigure(0, weight=1)
self.tabla = ttk.Treeview(frame_tabla, show="headings", style="Treeview")
self.tabla.tag_configure('bautizo', background="#154360", foreground="#AED6F1")
self.tabla.tag_configure('normal', background="#2b2b2b", foreground="white")
self.tabla.tag_configure('presente', background="#1e8449", foreground="white")
scroll = ttk.Scrollbar(frame_tabla, orient="vertical", command=self.tabla.yview)
self.tabla.configure(yscrollcommand=scroll.set)
self.tabla.grid(row=0, column=0, sticky="nsew")
scroll.grid(row=0, column=1, sticky="ns")
# ⚡ EVENTO: Detectar clics en la tabla para armar la ruta visual
self.tabla.bind("<ButtonRelease-1>", self.dibujar_hilo_ariadna)
# 4. ÁREA DERECHA: PANEL TIMELINE (HILO DE ARIADNA)
self.panel_timeline = ctk.CTkScrollableFrame(self.tab_reporte, fg_color="#1a1a1a", corner_radius=10)
self.panel_timeline.grid(row=2, column=1, sticky="nsew")
self.header_timeline = ctk.CTkLabel(self.panel_timeline, text="TRAZABILIDAD", font=ctk.CTkFont(weight="bold", size=14), text_color="gray")
self.header_timeline.pack(pady=(10, 20))
self.lbl_vacio_timeline = ctk.CTkLabel(self.panel_timeline, text="Seleccione un registro\npara rastrear la ruta.", text_color="#555555")
self.lbl_vacio_timeline.pack(pady=50)
# Carga inicial
self.cargar_datos_csv()
def configurar_columnas_tabla(self, modo):
self.tabla.delete(*self.tabla.get_children())
if modo == "crudo":
columnas = ("Fecha/Hora", "Nombre del Empleado", "Origen", "Destino", "Tiempo (s)", "Re-ID Corporal", "Certeza Facial")
anchos = [150, 200, 70, 70, 80, 110, 110]
else:
columnas = ("Fecha", "Nombre del Empleado", "Hora Llegada", "Punto Llegada", "Última Vista", "Punto Salida", "Permanencia Total", "Estado")
anchos = [100, 200, 100, 100, 100, 100, 130, 90]
self.tabla["columns"] = columnas
for i, col in enumerate(columnas):
self.tabla.heading(col, text=col)
self.tabla.column(col, anchor="center", width=anchos[i])
def dibujar_hilo_ariadna(self, event):
# Limpiar el panel actual
for widget in self.panel_timeline.winfo_children():
if widget != self.header_timeline:
widget.destroy()
seleccion = self.tabla.selection()
if not seleccion:
self.lbl_vacio_timeline = ctk.CTkLabel(self.panel_timeline, text="Seleccione un registro\npara rastrear la ruta.", text_color="#555555")
self.lbl_vacio_timeline.pack(pady=50)
return
item = self.tabla.item(seleccion[0])
valores = item['values']
# Extraer Fecha y Nombre según el modo de vista actual
modo = self.modo_vista_var.get()
if modo == "asistencia":
fecha_buscada = valores[0]
nombre_buscado = valores[1]
else: # modo crudo
fecha_buscada = valores[0].split(" ")[0] # Extraer solo el YYYY-MM-DD
nombre_buscado = valores[1]
self.header_timeline.configure(text=f"RUTA: {nombre_buscado.upper()}", text_color="white")
ctk.CTkLabel(self.panel_timeline, text=f"Fecha: {fecha_buscada}", font=ctk.CTkFont(size=11), text_color="#3498db").pack(pady=(0, 15))
# ⚡ 1. LEER CSV Y FILTRAR LA RUTA (AHORA EXTRALLENDO LA IMAGEN)
ruta_csv = os.path.join("cache_nombres", "registro_movimientos.csv")
pasos = []
try:
with open(ruta_csv, 'r', encoding='utf-8') as f:
reader = csv.reader(f)
next(reader, None)
for row in reader:
if len(row) >= 4:
if fecha_buscada in row[0] and str(nombre_buscado).lower() == str(row[1]).lower():
hora = row[0].split(" ")[1]
origen = row[2]
destino = row[3]
duracion = row[4]
# La columna 8 (índice 7) tiene la ruta de la foto que guardó seguimiento2.py
ruta_img = row[7] if len(row) >= 8 else ""
# Es un bautizo (entrada inicial)
if origen == destino and float(duracion) == 0.0:
pasos.append({"hora": hora, "cam": origen, "tipo": "entrada", "img": ruta_img})
else:
pasos.append({"hora": hora, "cam": destino, "tipo": "cruce", "img": ruta_img})
except Exception as e:
print(f"Error trazando ruta: {e}")
return
# ⚡ 2. DIBUJAR LOS NODOS Y SUS IMÁGENES
if not pasos:
ctk.CTkLabel(self.panel_timeline, text="Sin datos de ruta", text_color="#e74c3c").pack()
return
for i, paso in enumerate(pasos):
frame_nodo = ctk.CTkFrame(self.panel_timeline, fg_color="transparent")
frame_nodo.pack(fill="x", padx=10, pady=5) # Padding extendido para dar espacio a la foto
# Columna Izquierda: El Dibujo del Nodo
frame_dibujo = ctk.CTkFrame(frame_nodo, fg_color="transparent", width=30)
frame_dibujo.pack(side="left", fill="y")
if i == 0:
color_nodo, texto_bold = "#2ecc71", True
elif i == len(pasos) - 1:
color_nodo, texto_bold = "#e67e22", True
else:
color_nodo, texto_bold = "#3498db", False
dot = ctk.CTkFrame(frame_dibujo, width=14, height=14, corner_radius=7, fg_color=color_nodo)
dot.pack(pady=(15, 0)) # Centramos el nodo con la imagen
if i < len(pasos) - 1:
linea = ctk.CTkFrame(frame_dibujo, width=2, height=45, fg_color="#333333")
linea.pack(pady=2)
# Columna Central: Textos
frame_info = ctk.CTkFrame(frame_nodo, fg_color="transparent")
frame_info.pack(side="left", fill="both", expand=True, padx=10)
font_principal = ctk.CTkFont(weight="bold", size=13) if texto_bold else ctk.CTkFont(size=13)
ctk.CTkLabel(frame_info, text=f"Cámara {paso['cam']}", font=font_principal, text_color="white" if texto_bold else "#cccccc").pack(anchor="w", pady=(10, 0))
ctk.CTkLabel(frame_info, text=f"{paso['hora']}", font=ctk.CTkFont(size=11), text_color="gray").pack(anchor="w")
# ⚡ SOLUCIÓN PARTE 1: Columna Derecha (RENDER DE LA IMAGEN)
ruta_imagen = paso.get("img", "")
if ruta_imagen and os.path.exists(ruta_imagen):
try:
img_pil = Image.open(ruta_imagen)
# Forzamos un tamaño cuadrado uniforme de 50x50 para la máquina de estados
img_ctk = ctk.CTkImage(light_image=img_pil, dark_image=img_pil, size=(50, 50))
lbl_img = ctk.CTkLabel(frame_nodo, text="", image=img_ctk, corner_radius=5)
lbl_img.image_ref = img_ctk # Previene que Python borre la imagen
lbl_img.pack(side="right", padx=5)
except Exception as e:
pass # Si la foto se corrompió, simplemente no dibuja nada y no crashea
def exportar_reporte(self):
filepath = filedialog.asksaveasfilename(
defaultextension=".csv",
filetypes=[("Archivo CSV (Excel)", "*.csv"), ("Todos los archivos", "*.*")],
title="Exportar Reporte de Asistencia"
)
if not filepath: return
try:
with open(filepath, 'w', newline='', encoding='utf-8-sig') as f:
writer = csv.writer(f)
# Extraer y escribir cabeceras
columnas = [self.tabla.heading(c, 'text') for c in self.tabla["columns"]]
writer.writerow(columnas)
# Escribir filas
for item in self.tabla.get_children():
writer.writerow(self.tabla.item(item)['values'])
print(f"Reporte exportado a {filepath}")
except Exception as e:
print(f"Error exportando: {e}")
def recargar_completo(self):
self.search_var.set("")
self.date_var.set("")
self.cargar_datos_csv()
def cargar_datos_csv(self):
modo = self.modo_vista_var.get()
self.configurar_columnas_tabla(modo)
ruta_csv = os.path.join("cache_nombres", "registro_movimientos.csv")
busqueda_n = self.search_var.get().lower().strip()
busqueda_f = self.date_var.get().strip()
if not os.path.exists(ruta_csv): return
try:
with open(ruta_csv, 'r', encoding='utf-8') as f:
reader = csv.reader(f)
next(reader, None) # Saltar cabecera
filas = list(reader)
if modo == "crudo":
# LÓGICA DE AUDITORÍA FORENSE (Cruce por Cruce)
for row in reversed(filas):
if len(row) < 8: row.extend(["0.0", "0.0", "0"] * (8 - len(row)))
f_csv, n_csv = row[0], row[1].lower()
origen, destino, tiempo = row[2], row[3], row[4]
es_bautizo = (origen == destino and float(tiempo) == 0.0)
if es_bautizo:
row[2], row[3], row[4] = f"Cam {origen}", "Detección", "INICIAL"
tag = 'bautizo'
else:
row[2], row[3], row[4] = f"Cam {origen}", f"Cam {destino}", f"{tiempo}s"
tag = 'normal'
row[5] = f"{row[5]}%" if float(row[5]) > 0 else "N/A"
row[6] = f"{row[6]}%" if float(row[6]) > 0 else "N/A"
if (busqueda_n == "" or busqueda_n in n_csv) and (busqueda_f == "" or busqueda_f in f_csv):
self.tabla.insert("", "end", values=row[:7], tags=(tag,))
else:
# LÓGICA DE CONTROL DE ASISTENCIA (Agrupado por Día y Persona)
asistencia = {}
for row in filas:
if len(row) < 4: continue
dt_str, n_original, origen, destino = row[0], row[1], row[2], row[3]
n_csv = n_original.lower()
if (busqueda_n != "" and busqueda_n not in n_csv) or (busqueda_f != "" and busqueda_f not in dt_str):
continue
try:
dt_obj = datetime.strptime(dt_str, '%Y-%m-%d %H:%M:%S')
fecha_str = dt_obj.strftime('%Y-%m-%d')
hora_str = dt_obj.strftime('%H:%M:%S')
except Exception: continue
key = (fecha_str, n_original.title().replace('_', ' '))
if key not in asistencia:
asistencia[key] = {
'llegada_dt': dt_obj, 'llegada_str': hora_str, 'cam_llegada': origen,
'salida_dt': dt_obj, 'salida_str': hora_str, 'cam_salida': destino
}
else:
# Evaluar si es un registro más antiguo (Llegada) o más nuevo (Salida)
if dt_obj < asistencia[key]['llegada_dt']:
asistencia[key]['llegada_dt'] = dt_obj
asistencia[key]['llegada_str'] = hora_str
asistencia[key]['cam_llegada'] = origen
if dt_obj > asistencia[key]['salida_dt']:
asistencia[key]['salida_dt'] = dt_obj
asistencia[key]['salida_str'] = hora_str
asistencia[key]['cam_salida'] = destino
fecha_hoy = datetime.now().strftime('%Y-%m-%d')
# Ordenar por fecha más reciente primero y poblar tabla
for key, data in sorted(asistencia.items(), reverse=True):
fecha, nombre = key
t_perm = data['salida_dt'] - data['llegada_dt']
segundos = t_perm.total_seconds()
horas, resto = divmod(segundos, 3600)
minutos, _ = divmod(resto, 60)
if segundos == 0:
# Solo se pinta de verde si el registro ocurrió el día de HOY
if fecha == fecha_hoy:
perm_str = "Recién Llegado"
estado = "En Instalaciones"
tag = "presente"
else:
# Si es de días pasados y solo hubo una vista, es un cruce único
perm_str = "Cruce Único"
estado = "Registrado"
tag = "normal"
else:
perm_str = f"{int(horas)}h {int(minutos)}m"
estado = "Registrado"
tag = "normal"
valores = (fecha, nombre, data['llegada_str'], f"Cam {data['cam_llegada']}",
data['salida_str'], f"Cam {data['cam_salida']}", perm_str, estado)
self.tabla.insert("", "end", values=valores, tags=(tag,))
except Exception as e:
print(f"Error procesando reporte: {e}")
# ──────────────────────────────────────────────────────────────────────────
# PESTAÑA 3: GESTIÓN DE PERSONAL (MÁQUINA DE ESTADOS + CRUD)
# ──────────────────────────────────────────────────────────────────────────
def crear_tab_registro(self):
self.tab_registro.grid_columnconfigure((0, 1), weight=1)
self.tab_registro.grid_rowconfigure(0, weight=1)
self.face_temporal = None
self.genero_temporal = None
# ======================================================================
# --- PANEL IZQUIERDO: NUEVO ENROLAMIENTO ---
# ======================================================================
frame_nuevo = ctk.CTkFrame(self.tab_registro)
frame_nuevo.grid(row=0, column=0, padx=10, pady=10, sticky="nsew")
ctk.CTkLabel(frame_nuevo, text="Paso 1: Captura Biométrica", font=ctk.CTkFont(weight="bold", size=16)).pack(pady=(15, 5))
self.lbl_cam_registro = ctk.CTkLabel(frame_nuevo, text="Monitor inactivo", bg_color="black", width=360, height=202)
self.lbl_cam_registro.pack(pady=10)
self.cam_reg_activa = False
self.cap_registro = None
frame_ctrl_cam = ctk.CTkFrame(frame_nuevo, fg_color="transparent")
frame_ctrl_cam.pack(fill="x", padx=20, pady=5)
ctk.CTkLabel(frame_ctrl_cam, text="Seleccionar Cámara:").pack(side="left", padx=5)
camaras_disponibles = [c.strip() for c in self.secuencia_var.get().split(',') if c.strip()]
if not camaras_disponibles: camaras_disponibles = ["7"]
self.cam_id_reg_var = ctk.StringVar(value=camaras_disponibles[0])
ctk.CTkOptionMenu(frame_ctrl_cam, variable=self.cam_id_reg_var, values=camaras_disponibles, width=80).pack(side="left", padx=5)
self.btn_encender_cam = ctk.CTkButton(frame_ctrl_cam, text="Conectar", command=self.toggle_camara_registro, width=100)
self.btn_encender_cam.pack(side="left", padx=15)
self.btn_capturar = ctk.CTkButton(frame_nuevo, text="📸 Extraer y Validar Rostro", fg_color="#27ae60", hover_color="#2ecc71", command=self.validar_rostro_ia, state="disabled", height=40)
self.btn_capturar.pack(pady=(15, 5))
self.lbl_feedback_reg = ctk.CTkLabel(frame_nuevo, text="Esperando captura...", text_color="gray")
self.lbl_feedback_reg.pack(pady=(0, 15))
ctk.CTkLabel(frame_nuevo, text="Paso 2: Asignación de Identidad", font=ctk.CTkFont(weight="bold", size=16)).pack(pady=(10, 5))
ctk.CTkLabel(frame_nuevo, text="Nombre y Apellidos (Sin acentos):").pack(anchor="w", padx=20)
self.nombre_reg_var = ctk.StringVar()
self.entry_nombre_reg = ctk.CTkEntry(frame_nuevo, textvariable=self.nombre_reg_var, width=300, state="disabled")
self.entry_nombre_reg.pack(pady=5, padx=20)
self.btn_guardar_nuevo = ctk.CTkButton(frame_nuevo, text="💾 Guardar Registro en DB", fg_color="#2980b9", hover_color="#3498db", command=self.guardar_nuevo_registro, state="disabled", height=40)
self.btn_guardar_nuevo.pack(pady=20)
# ======================================================================
# --- PANEL DERECHO: AUDITORÍA VISUAL (CRUD) ---
# ======================================================================
frame_gestion = ctk.CTkFrame(self.tab_registro)
frame_gestion.grid(row=0, column=1, padx=10, pady=10, sticky="nsew")
ctk.CTkLabel(frame_gestion, text="Auditoría y Edición de DB", font=ctk.CTkFont(weight="bold", size=16)).pack(pady=15)
frame_busqueda = ctk.CTkFrame(frame_gestion, fg_color="transparent")
frame_busqueda.pack(fill="x", padx=20, pady=(10, 5))
self.buscar_empleado_var = ctk.StringVar()
ctk.CTkEntry(frame_busqueda, textvariable=self.buscar_empleado_var, placeholder_text="Nombre a buscar...").pack(side="left", fill="x", expand=True, padx=(0, 10))
ctk.CTkButton(frame_busqueda, text="🔍 Buscar", command=self.buscar_registro_visual, width=80).pack(side="left")
# MENÚ MULTI-BÚSQUEDA INTEGRAD0
self.resultados_var = ctk.StringVar(value="Resultados...")
self.menu_resultados = ctk.CTkOptionMenu(frame_gestion, variable=self.resultados_var, values=["Resultados..."], command=self.cargar_empleado_seleccionado, state="disabled")
self.menu_resultados.pack(fill="x", padx=40, pady=(0, 15))
self.lbl_foto_empleado = ctk.CTkLabel(frame_gestion, text="Sin imagen seleccionada", bg_color="#1a1a1a", width=200, height=200)
self.lbl_foto_empleado.pack(pady=10)
ctk.CTkLabel(frame_gestion, text="Modificar Nombre:").pack(anchor="w", padx=40)
self.nuevo_nombre_var = ctk.StringVar()
self.entry_nuevo_nom = ctk.CTkEntry(frame_gestion, textvariable=self.nuevo_nombre_var, width=250, state="disabled")
self.entry_nuevo_nom.pack(pady=5)
self.ruta_imagen_actual = None
self.nombre_actual_seleccionado = None
frame_acciones = ctk.CTkFrame(frame_gestion, fg_color="transparent")
frame_acciones.pack(fill="x", padx=40, pady=20)
self.btn_actualizar_reg = ctk.CTkButton(frame_acciones, text="💾 Actualizar", fg_color="#f39c12", hover_color="#e67e22", command=self.actualizar_registro, state="disabled", width=120)
self.btn_actualizar_reg.pack(side="left", padx=5)
self.btn_eliminar_reg = ctk.CTkButton(frame_acciones, text="🗑️ Eliminar", fg_color="#c0392b", hover_color="#e74c3c", command=self.eliminar_registro, state="disabled", width=120)
self.btn_eliminar_reg.pack(side="right", padx=5)
# --- LÓGICA DE CÁMARA ---
def toggle_camara_registro(self):
if not self.cam_reg_activa:
cam_id = self.cam_id_reg_var.get().strip()
url = f"rtsp://{self.user_var.get()}:{self.pass_var.get()}@{self.ip_var.get()}:554/Streaming/Channels/{cam_id}02"
# ⚡ FIX 1: Obligamos a usar TCP para evitar que el DVR colapse por doble petición UDP
os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = "rtsp_transport;tcp"
self.cap_registro = cv2.VideoCapture(url)
self.cap_registro.set(cv2.CAP_PROP_BUFFERSIZE, 1)
if self.cap_registro.isOpened():
self.cam_reg_activa = True
self.btn_encender_cam.configure(text="Desconectar", fg_color="#c0392b", hover_color="#e74c3c")
self.btn_capturar.configure(state="normal")
self.lbl_feedback_reg.configure(text="Enfoque el rostro y presione Validar", text_color="white")
self.actualizar_camara_registro()
else:
self.cam_reg_activa = False
if self.cap_registro:
self.cap_registro.release()
self.lbl_cam_registro.image_ref = None
try: self.lbl_cam_registro.configure(image=None, text="Monitor inactivo")
except Exception: pass
self.btn_encender_cam.configure(text="Conectar", fg_color="#1f538d", hover_color="#14375e")
self.btn_capturar.configure(state="disabled")
self.resetear_formulario_nuevo()
def actualizar_camara_registro(self):
if self.cam_reg_activa and self.cap_registro and self.cap_registro.isOpened():
ret, frame = self.cap_registro.read()
if ret:
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
img_pil = Image.fromarray(frame_rgb)
img_ctk = ctk.CTkImage(light_image=img_pil, dark_image=img_pil, size=(360, 202))
# ⚡ Protección estricta de memoria RAM
self.lbl_cam_registro.image_ref = img_ctk
try:
self.lbl_cam_registro.configure(image=img_ctk, text="")
except Exception: pass
self.after(30, self.actualizar_camara_registro)
# --- LÓGICA DE VALIDACIÓN Y GUARDADO ---
def validar_rostro_ia(self):
if self.cam_reg_activa and self.cap_registro:
ret, frame = self.cap_registro.read()
if ret:
faces = fision1.app.get(frame)
if len(faces) == 0:
self.lbl_feedback_reg.configure(text="❌ Rostro no detectado o muy borroso. Intente de nuevo.", text_color="#e74c3c")
self.resetear_formulario_nuevo(solo_datos=True)
return
face_registro = max(faces, key=lambda f: (f.bbox[2]-f.bbox[0]) * (f.bbox[3]-f.bbox[1]))
box = face_registro.bbox.astype(int)
fx, fy, x2, y2 = box[0], box[1], box[2], box[3]
fw, fh = x2 - fx, y2 - fy
h, w = frame.shape[:2]
# ⚡ FIX 2: Recorte tipo pasaporte perfecto (Solución a image_2d722c.png)
m_x = int(fw * 0.50)
m_y_top = int(fh * 0.50)
m_y_bot = int(fh * 0.85) # Margen gigante hacia abajo para incluir cuello y barbilla
y1, y2 = max(0, fy - m_y_top), min(h, y2 + m_y_bot)
x1, x2 = max(0, fx - m_x), min(w, x2 + m_x)
face_roi = frame[y1:y2, x1:x2]
if face_roi.size > 0:
self.face_temporal = face_roi
self.genero_temporal = "Man" if face_registro.sex == 1 else "Woman"
self.lbl_feedback_reg.configure(text=f"Verifique la foto capturada ({self.genero_temporal}) e ingrese el nombre.", text_color="#2ecc71")
self.entry_nombre_reg.configure(state="normal")
self.btn_guardar_nuevo.configure(state="normal")
self.entry_nombre_reg.focus()
self.cam_reg_activa = False
self.btn_encender_cam.configure(text="Reintentar (Descartar)", fg_color="#f39c12", hover_color="#e67e22")
face_rgb = cv2.cvtColor(face_roi, cv2.COLOR_BGR2RGB)
img_pil = Image.fromarray(face_rgb)
img_ctk = ctk.CTkImage(light_image=img_pil, dark_image=img_pil, size=(200, 200))
self.lbl_cam_registro.image_ref = img_ctk
self.lbl_cam_registro.configure(image=img_ctk, text="")
def guardar_nuevo_registro(self):
nombre = self.nombre_reg_var.get().strip()
if not nombre or self.face_temporal is None: return
if not os.path.exists("db_institucion"): os.makedirs("db_institucion")
# ⚡ FIX: Todo lo que va a la IA debe llevar guiones bajos
nombre_str = nombre.replace(' ', '_')
nombre_archivo = f"{nombre_str}.jpg"
ruta = os.path.join("db_institucion", nombre_archivo)
cv2.imwrite(ruta, self.face_temporal)
ruta_gen = os.path.join("cache_nombres", "generos.json")
dic_gen = {}
if os.path.exists(ruta_gen):
try:
with open(ruta_gen, 'r') as f: dic_gen = json.load(f)
except: pass
# Guardamos usando la llave con guiones bajos para que la IA la encuentre
dic_gen[nombre_str] = self.genero_temporal
with open(ruta_gen, 'w') as f: json.dump(dic_gen, f)
self.reconstruir_base_datos()
self.lbl_feedback_reg.configure(text=f"🚀 ¡{nombre} registrado exitosamente en el sistema!", text_color="#3498db")
self.resetear_formulario_nuevo(solo_datos=True)
def resetear_formulario_nuevo(self, solo_datos=False):
self.face_temporal = None
self.genero_temporal = None
self.nombre_reg_var.set("")
self.entry_nombre_reg.configure(state="disabled")
self.btn_guardar_nuevo.configure(state="disabled")
if not solo_datos:
self.lbl_feedback_reg.configure(text="Esperando captura...", text_color="gray")
def reconstruir_base_datos(self):
try:
# ⚡ FIX: fision1.gestionar_vectores se importa directo, sin el ".reconocimiento"
fision1.BASE_DATOS_ROSTROS = fision1.gestionar_vectores(actualizar=True)
print("[INFO] DB Sincronizada exitosamente.")
except Exception as e:
print(f"Error sincronizando base de datos: {e}")
# =========================================================================
# LÓGICA DE AUDITORÍA VISUAL (CRUD Y MULTI-BÚSQUEDA)
# =========================================================================
# =========================================================================
# LÓGICA DE AUDITORÍA VISUAL (CRUD Y MULTI-BÚSQUEDA)
# =========================================================================
def limpiar_imagen_visor(self, texto="Sin imagen seleccionada"):
"""⚡ FIX: Evita el bug pyimage de CustomTkinter usando una imagen transparente"""
img_vacia_pil = Image.new("RGBA", (200, 200), (0,0,0,0))
self.img_ctk_vacia = ctk.CTkImage(light_image=img_vacia_pil, size=(200,200))
try:
self.lbl_foto_empleado.configure(image=self.img_ctk_vacia, text=texto)
except Exception: pass
def buscar_registro_visual(self):
nombre_buscado = self.buscar_empleado_var.get().strip().lower()
if not nombre_buscado: return
coincidencias = []
self.diccionario_archivos = getattr(self, 'diccionario_archivos', {})
if os.path.exists("db_institucion"):
for archivo in os.listdir("db_institucion"):
if nombre_buscado in archivo.lower() and archivo.endswith(('.jpg', '.png', '.jpeg')):
nombre_limpio = os.path.splitext(archivo)[0].replace('_', ' ')
coincidencias.append(nombre_limpio)
self.diccionario_archivos[nombre_limpio] = archivo
if coincidencias:
self.menu_resultados.configure(state="normal", values=coincidencias)
self.resultados_var.set(coincidencias[0])
self.cargar_empleado_seleccionado(coincidencias[0])
else:
self.limpiar_panel_edicion()
self.limpiar_imagen_visor("Persona no encontrada")
def cargar_empleado_seleccionado(self, nombre_seleccionado):
self.nombre_actual_seleccionado = nombre_seleccionado
nombre_archivo = self.diccionario_archivos.get(nombre_seleccionado)
if not nombre_archivo:
nombre_archivo = f"{nombre_seleccionado.replace(' ', '_')}.jpg"
self.ruta_imagen_actual = os.path.join("db_institucion", nombre_archivo)
if os.path.exists(self.ruta_imagen_actual):
try:
# ⚡ FIX: Usamos copy() para no bloquear el archivo en el disco duro
with Image.open(self.ruta_imagen_actual) as img_temp:
img_pil = img_temp.copy()
self.img_ctk_actual = ctk.CTkImage(light_image=img_pil, dark_image=img_pil, size=(200, 200))
self.lbl_foto_empleado.configure(image=self.img_ctk_actual, text="")
self.nuevo_nombre_var.set(self.nombre_actual_seleccionado)
self.entry_nuevo_nom.configure(state="normal")
self.btn_eliminar_reg.configure(state="normal")
self.btn_actualizar_reg.configure(state="normal")
except Exception as e:
self.limpiar_imagen_visor("Error cargando foto")
else:
self.limpiar_imagen_visor("Foto no encontrada en disco")
def actualizar_registro(self):
if not self.ruta_imagen_actual: return
nuevo_nombre = self.nuevo_nombre_var.get().strip()
if nuevo_nombre and nuevo_nombre != self.nombre_actual_seleccionado:
viejo_str = self.nombre_actual_seleccionado.replace(' ', '_')
nuevo_str = nuevo_nombre.replace(' ', '_')
extension = os.path.splitext(self.ruta_imagen_actual)[1]
nuevo_archivo = f"{nuevo_str}{extension}"
nueva_ruta = os.path.join("db_institucion", nuevo_archivo)
# 1. Renombrar archivo
try:
os.rename(self.ruta_imagen_actual, nueva_ruta)
except Exception as e:
print(f"Error renombrando archivo: {e}")
return
# 2. ⚡ FIX: Traspaso de género blindado
ruta_gen = os.path.join("cache_nombres", "generos.json")
dic_gen = {}
if os.path.exists(ruta_gen):
try:
with open(ruta_gen, 'r') as f: dic_gen = json.load(f)
except Exception: pass
genero_original = dic_gen.pop(viejo_str, "Man") # Por defecto asume hombre si no hay registro
dic_gen[nuevo_str] = genero_original
os.makedirs(os.path.dirname(ruta_gen), exist_ok=True)
with open(ruta_gen, 'w') as f: json.dump(dic_gen, f)
# 3. Actualizar diccionarios y UI dinámicamente
self.diccionario_archivos[nuevo_nombre] = nuevo_archivo
if self.nombre_actual_seleccionado in self.diccionario_archivos:
del self.diccionario_archivos[self.nombre_actual_seleccionado]
opciones_actuales = self.menu_resultados.cget("values")
nuevas_opciones = [nuevo_nombre if opt == self.nombre_actual_seleccionado else opt for opt in opciones_actuales]
self.menu_resultados.configure(values=nuevas_opciones)
self.resultados_var.set(nuevo_nombre)
self.nombre_actual_seleccionado = nuevo_nombre
self.ruta_imagen_actual = nueva_ruta
try:
self.lbl_foto_empleado.configure(text="¡Registro Actualizado!")
except Exception: pass
self.reconstruir_base_datos()
def eliminar_registro(self):
if self.ruta_imagen_actual and os.path.exists(self.ruta_imagen_actual):
try:
os.remove(self.ruta_imagen_actual)
except Exception: pass
ruta_gen = os.path.join("cache_nombres", "generos.json")
viejo_str = self.nombre_actual_seleccionado.replace(' ', '_')
if os.path.exists(ruta_gen):
try:
with open(ruta_gen, 'r') as f: dic_gen = json.load(f)
if viejo_str in dic_gen:
del dic_gen[viejo_str]
with open(ruta_gen, 'w') as f: json.dump(dic_gen, f)
except Exception: pass
self.limpiar_panel_edicion()
self.limpiar_imagen_visor("¡Registro Eliminado!")
self.reconstruir_base_datos()
self.buscar_empleado_var.set("")
def limpiar_panel_edicion(self):
self.nuevo_nombre_var.set("")
self.entry_nuevo_nom.configure(state="disabled")
self.btn_eliminar_reg.configure(state="disabled")
self.btn_actualizar_reg.configure(state="disabled")
self.ruta_imagen_actual = None
self.nombre_actual_seleccionado = None
self.menu_resultados.configure(state="disabled", values=["Resultados..."])
self.resultados_var.set("Resultados...")
# ──────────────────────────────────────────────────────────────────────────
# PESTAÑA 4: CONFIGURACIÓN Y TERMINAL
# ──────────────────────────────────────────────────────────────────────────
def crear_tab_configuracion(self):
self.tab_config.grid_columnconfigure((0, 1), weight=1)
# 1. Panel de Conexión DVR
frame_dvr = ctk.CTkFrame(self.tab_config)
frame_dvr.grid(row=0, column=0, padx=10, pady=10, sticky="nsew")
ctk.CTkLabel(frame_dvr, text="Conexión DVR", font=ctk.CTkFont(weight="bold")).pack(pady=15)
ctk.CTkEntry(frame_dvr, textvariable=self.ip_var, placeholder_text="IP DVR").pack(pady=10, padx=20, fill="x")
ctk.CTkEntry(frame_dvr, textvariable=self.user_var, placeholder_text="Usuario").pack(pady=10, padx=20, fill="x")
ctk.CTkEntry(frame_dvr, textvariable=self.pass_var, placeholder_text="Contraseña", show="*").pack(pady=10, padx=20, fill="x")
# 2. Panel de Topología IA
frame_top = ctk.CTkFrame(self.tab_config)
frame_top.grid(row=0, column=1, padx=10, pady=10, sticky="nsew")
ctk.CTkLabel(frame_top, text="Topología de Red Neural", font=ctk.CTkFont(weight="bold")).pack(pady=15)
ctk.CTkLabel(frame_top, text="Secuencia Total:").pack(anchor="w", padx=20)
ctk.CTkEntry(frame_top, textvariable=self.secuencia_var).pack(pady=(0,10), padx=20, fill="x")
ctk.CTkLabel(frame_top, text="Cámaras permitidas por pantalla:").pack(anchor="w", padx=20)
ctk.CTkEntry(frame_top, textvariable=self.cams_por_pantalla_var, placeholder_text="Ej. 1, 4, 6...").pack(pady=(0,10), padx=20, fill="x")
ctk.CTkLabel(frame_top, text="Cámaras Bypass (Ignoradas por IA):").pack(anchor="w", padx=20)
ctk.CTkEntry(frame_top, textvariable=self.ignoradas_var).pack(pady=(0,10), padx=20, fill="x")
ctk.CTkLabel(frame_top, text="Matriz de Adyacencia (JSON):").pack(anchor="w", padx=20)
ctk.CTkEntry(frame_top, textvariable=self.vecinos_var).pack(pady=(0,10), padx=20, fill="x")
# ⚡ 3. CREACIÓN DEL BOTÓN GUARDAR (Debe existir antes que la Terminal)
self.btn_guardar = ctk.CTkButton(self.tab_config, text="Guardar Parámetros", width=200)
self.btn_guardar.grid(row=1, column=0, columnspan=2, pady=10)
# ⚡ 4. TERMINAL DE OPERADOR (Historial de Sistema)
frame_terminal = ctk.CTkFrame(self.tab_config)
frame_terminal.grid(row=2, column=0, columnspan=2, padx=10, pady=(10, 10), sticky="nsew")
self.tab_config.grid_rowconfigure(2, weight=1) # Permite que la terminal crezca
ctk.CTkLabel(frame_terminal, text="Terminal de Eventos y Auditoría del Sistema", font=ctk.CTkFont(weight="bold"), text_color="gray").pack(anchor="w", padx=15, pady=5)
self.terminal_texto = ctk.CTkTextbox(frame_terminal, fg_color="#000000", text_color="#2ecc71", font=ctk.CTkFont(family="Consolas", size=12))
self.terminal_texto.pack(fill="both", expand=True, padx=15, pady=(0, 15))
self.terminal_texto.configure(state="disabled")
# ⚡ 5. ASIGNACIÓN DEL COMANDO MÚLTIPLE (Guardado + Toast Flotante)
self.btn_guardar.configure(command=lambda: [self.guardar_configuracion(), self.log_evento("Parámetros actualizados en disco", "ÉXITO")])
def cargar_configuracion(self):
config = {
"dvr_ip": "192.168.1.244", "dvr_user": "admin", "dvr_pass": "TCA200503",
"secuencia_total": "2, 1, 7, 5, 8, 4, 3, 6", "ignoradas": "2, 4",
"vecinos": '{"1":["7"], "7":["1","5"], "5":["7","8"], "8":["5","3"], "3":["8","6"], "6":["3"]}',
"cams_por_pantalla": "4" # Por defecto 4 cuadrículas
}
if os.path.exists(CONFIG_FILE):
try:
with open(CONFIG_FILE, 'r') as f: config.update(json.load(f))
except: pass
self.ip_var.set(config.get("dvr_ip", ""))
self.user_var.set(config.get("dvr_user", ""))
self.pass_var.set(config.get("dvr_pass", ""))
self.secuencia_var.set(config.get("secuencia_total", ""))
self.ignoradas_var.set(config.get("ignoradas", ""))
self.vecinos_var.set(config.get("vecinos", ""))
self.cams_por_pantalla_var.set(config.get("cams_por_pantalla", "4"))
def guardar_configuracion(self):
data = {
"dvr_ip": self.ip_var.get(), "dvr_user": self.user_var.get(),
"dvr_pass": self.pass_var.get(), "secuencia_total": self.secuencia_var.get(),
"ignoradas": self.ignoradas_var.get(), "vecinos": self.vecinos_var.get(),
"cams_por_pantalla": self.cams_por_pantalla_var.get()
}
with open(CONFIG_FILE, 'w') as f: json.dump(data, f, indent=4)
self.btn_guardar.configure(text="¡Configuración Actualizada!", fg_color="green")
self.after(2000, lambda: self.btn_guardar.configure(text="Guardar Parámetros", fg_color="#1f538d"))
if __name__ == "__main__":
app = SmartSoftApp()
app.mainloop()