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("", 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('', 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("", 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("", 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()