1335 lines
70 KiB
Python
1335 lines
70 KiB
Python
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() |