commit 7c107018e6aa6cdd4ff3ac0c14911fd28d098efe Author: eroncero Date: Thu Jan 22 18:54:53 2026 +0100 First commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f446e5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,219 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml + +# Current project +data/* diff --git a/config/fluxus_config.json b/config/fluxus_config.json new file mode 100644 index 0000000..3abef2d --- /dev/null +++ b/config/fluxus_config.json @@ -0,0 +1,50 @@ +{ + "line": { + "p1": [ + 134, + 500 + ], + "p2": [ + 802, + 331 + ] + }, + "rois": [ + [ + 55, + 63, + 869, + 531 + ] + ], + "calibration": { + "active": false, + "points": [ + [ + 76, + 581 + ], + [ + 314, + 503 + ], + [ + 588, + 619 + ], + [ + 245, + 750 + ] + ], + "height_ref_p1": [ + 1152, + -4 + ], + "height_ref_p2": [ + 985, + 719 + ] + }, + "person_conf": 0.5 +} \ No newline at end of file diff --git a/docs/Linea de tiempo SmartAforo AI.docx b/docs/Linea de tiempo SmartAforo AI.docx new file mode 100644 index 0000000..898d1be Binary files /dev/null and b/docs/Linea de tiempo SmartAforo AI.docx differ diff --git a/models/yolov8m.pt b/models/yolov8m.pt new file mode 100644 index 0000000..f764f63 Binary files /dev/null and b/models/yolov8m.pt differ diff --git a/models/yolov8n-face.pt b/models/yolov8n-face.pt new file mode 100644 index 0000000..53d296d Binary files /dev/null and b/models/yolov8n-face.pt differ diff --git a/models/yolov8n.pt b/models/yolov8n.pt new file mode 100644 index 0000000..d61ef50 Binary files /dev/null and b/models/yolov8n.pt differ diff --git a/models/yolov8s-head.pt b/models/yolov8s-head.pt new file mode 100644 index 0000000..f4f9cae Binary files /dev/null and b/models/yolov8s-head.pt differ diff --git a/models/yolov8s.pt b/models/yolov8s.pt new file mode 100644 index 0000000..90b4a2c Binary files /dev/null and b/models/yolov8s.pt differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a5fd923 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +opencv-python +ultralytics +pillow +openpyxl +pandas +matplotlib diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..a22af37 --- /dev/null +++ b/src/main.py @@ -0,0 +1,1210 @@ +# -*- coding: utf-8 -*- +import cv2 +from ultralytics import YOLO +import tkinter as tk +from tkinter import scrolledtext, messagebox +from PIL import ImageTk, Image as PILImage +import os +from datetime import datetime +import openpyxl +from openpyxl.drawing.image import Image as OpenpyxlImage +import pandas as pd +import matplotlib.pyplot as plt +import time +import math +import numpy as np +import json + +# =========================== +# CONFIGURACIÓN +# =========================== +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# Modelos +MODEL_PERSON_PATH = os.path.join(BASE_DIR, 'models', 'yolov8n.pt') # Revertido a Nano por velocidad +MODEL_FACE_PATH = os.path.join(BASE_DIR, 'models', 'yolov8n-face.pt') + +# Umbrales +PERSON_CONF = 0.45 # Balance: 0.45 detecta oclusiones parciales sin demasiados fantasmas +FACE_CONF = 0.25 # Bajado a 0.25 para detectar caras "difíciles" (perfil/lejos) + +# Tracker +TRACK_IOU_MATCH = 0.45 # MÁS ESTRICTO (era 0.35) para evitar saltar a predicciones lejanas +TRACK_MAX_MISSES = 10 +TRACK_MIN_HITS = 3 # Bajado a 3 para detectar personas rápidas (era 5) + +# Excel +today_date_str = datetime.now().strftime("%Y-%m-%d") +EXCEL_FILENAME = os.path.join(BASE_DIR, 'data', f"registro_personas_{today_date_str}.xlsx") + +# Config Persistence +CONFIG_FILENAME = os.path.join(BASE_DIR, 'config', "fluxus_config.json") + +# UI Colors +BG_COLOR = "#282c34" +TEXT_COLOR = "#abb2bf" +ACCENT_COLOR = "#61afef" + +# =========================== +# CLASES DE UTILIDAD +# =========================== + +class Track: + __slots__ = ("id", "box", "hits", "misses", "has_face", "face_buffer", "centroid", "prev_centroid", "last_crossing_time", "kf", "prediction", "line_side") + def __init__(self, tid, box): + self.id = tid + self.box = box + self.hits = 1 + self.misses = 0 + self.has_face = False + self.face_buffer = 0 + self.centroid = self._calc_centroid(box) + self.prev_centroid = None + self.last_crossing_time = 0 + self.line_side = 0 # -1=below line, 0=unknown, 1=above line + + # Filtro de Kalman (Estado: [x, y, dx, dy]) + # OPTIMIZADO PARA BAJA VELOCIDAD DE CUADROS (10-20 FPS en Raspberry Pi) + self.kf = cv2.KalmanFilter(4, 2) + + # Matriz de medición: Solo observamos posición (x, y) + self.kf.measurementMatrix = np.array([[1,0,0,0],[0,1,0,0]], np.float32) + + # Matriz de transición: x' = x + dx, y' = y + dy (modelo de velocidad constante) + self.kf.transitionMatrix = np.array([[1,0,1,0],[0,1,0,1],[0,0,1,0],[0,0,0,1]], np.float32) + + # RUIDO DE PROCESO (Q): Qué tan rápido puede cambiar la velocidad + # A bajo FPS, hay más movimiento entre frames -> más incertidumbre en velocidad + # Valores altos en dx,dy (índices 2,3) permiten cambios rápidos de dirección + self.kf.processNoiseCov = np.array([ + [1, 0, 0, 0], # x: poca incertidumbre + [0, 1, 0, 0], # y: poca incertidumbre + [0, 0, 25, 0], # dx: ALTA incertidumbre (puede acelerar/frenar bastante) + [0, 0, 0, 25] # dy: ALTA incertidumbre + ], np.float32) * 0.1 # Factor de escala global + + # RUIDO DE MEDICIÓN (R): Qué tan ruidosa es la detección de YOLO + # Valor bajo = confiamos mucho en YOLO (más reactivo, menos suavizado) + # Valor alto = confiamos menos (más suave, ignora pequeños saltos) + self.kf.measurementNoiseCov = np.array([[5, 0], [0, 5]], np.float32) + + # Inicializar covarianza del error (P): Empezamos con incertidumbre media + self.kf.errorCovPost = np.eye(4, dtype=np.float32) * 10 + + # Inicializar estado + self.kf.statePre = np.array([[self.centroid[0]], [self.centroid[1]], [0], [0]], np.float32) + self.kf.statePost = np.array([[self.centroid[0]], [self.centroid[1]], [0], [0]], np.float32) + self.prediction = self.centroid + + def _calc_centroid(self, box): + x1, y1, x2, y2 = box + return ((x1 + x2) // 2, (y1 + y2) // 2) + + def predict(self): + # Predicción del siguiente estado + p = self.kf.predict() + self.prediction = (int(p[0].item()), int(p[1].item())) + return self.prediction + + def update_box(self, box): + self.prev_centroid = self.centroid + self.box = box + # Medición real (Centro del detector) + measured_centroid = self._calc_centroid(box) + + # Corrección Kalman + e = self.kf.correct(np.array([[np.float32(measured_centroid[0])], [np.float32(measured_centroid[1])]])) + + # Usamos la POSICIÓN FILTRADA como la verdad (Super estable) + self.centroid = (int(e[0].item()), int(e[1].item())) + +class Tracker: + def __init__(self, iou_match=0.5, max_misses=10, min_hits=3): + self.iou_match = iou_match + self.max_misses = max_misses + self.min_hits = min_hits + self._tracks = [] + self._next_id = 1 + + def update(self, dets): + # 1. PREDICT: Mover todos los tracks según su velocidad (Kalman) + for tr in self._tracks: + tr.predict() + + assigned = set() + for tr in self._tracks: + best_iou = 0.0 + best_j = -1 + for j, db in enumerate(dets): + if j in assigned: continue + v = iou(tr.box, db) + if v > best_iou: + best_iou = v + best_j = j + if best_j >= 0 and best_iou >= self.iou_match: + tr.update_box(dets[best_j]) + tr.hits += 1 + tr.misses = 0 + assigned.add(best_j) + else: + tr.misses += 1 + + for j, db in enumerate(dets): + if j not in assigned: + t = Track(self._next_id, db) + self._next_id += 1 + self._tracks.append(t) + + self._tracks = [t for t in self._tracks if t.misses <= self.max_misses] + + def confirmed_tracks(self): + # Permitir tracks con hasta 2 frames perdidos (oclusiones breves) + return [t for t in self._tracks if t.hits >= self.min_hits and t.misses <= 2] + +class LineCounter: + def __init__(self): + self.p1 = None # (x, y) + self.p2 = None # (x, y) + self.total_in = 0 + self.total_out = 0 + self.manual_offset = 0 + + def set_line(self, p1, p2): + self.p1 = p1 + self.p2 = p2 + # No resetear contadores + + def reset_counts(self): + self.total_in = 0 + self.total_out = 0 + self.manual_offset = 0 + + def set_manual_count(self, target_count): + # CORREGIDO: Usar offset en lugar de modificar total_in + # Esto evita que "bajar el conteo" aumente las entradas + current_net = self.total_in - self.total_out + self.manual_offset = target_count - current_net + + def is_set(self): + return self.p1 is not None and self.p2 is not None + + def check_crossing(self, track, calibration=None): + if not self.is_set() or track.prev_centroid is None: + return None + + # Determinar puntos de cruce (Pantalla o Proyectados) + A, B = self.p1, self.p2 + P_prev = track.prev_centroid + P_curr = track.centroid + + # Si hay calibración activa, proyectamos todo al suelo virtual + if calibration and calibration.active and calibration.matrix is not None: + tA = calibration.transform_point(A) + tB = calibration.transform_point(B) + + h_curr = track.box[3] - track.box[1] + ground_offset = int(h_curr * 0.35) + feet_prev = (track.prev_centroid[0], track.prev_centroid[1] + ground_offset) + feet_curr = (track.centroid[0], track.centroid[1] + ground_offset) + tP_prev = calibration.transform_point(feet_prev) + tP_curr = calibration.transform_point(feet_curr) + + if tA is not None and tB is not None and tP_prev is not None and tP_curr is not None: + A, B = tA, tB + P_prev, P_curr = tP_prev, tP_curr + + # === MÉTODO HÍBRIDO: Intersección de segmentos + Estado de lado === + + # 1. Calcular qué lado de la línea está el punto actual + line_vec = (B[0]-A[0], B[1]-A[1]) + normal = (-line_vec[1], line_vec[0]) + normal_len = math.hypot(normal[0], normal[1]) + + if normal_len < 1: + return None + + to_curr = (P_curr[0] - A[0], P_curr[1] - A[1]) + signed_dist = (normal[0]*to_curr[0] + normal[1]*to_curr[1]) / normal_len + current_side = 1 if signed_dist > 0 else -1 + + # 2. Inicializar lado si el track es nuevo + if track.line_side == 0: + track.line_side = current_side + return None # Primera vez, no contar + + # 3. Verificar intersección de segmentos (método original - confiable) + def ccw(A, B, C): + return (C[1]-A[1]) * (B[0]-A[0]) > (B[1]-A[1]) * (C[0]-A[0]) + + crossed = (ccw(A, P_prev, P_curr) != ccw(B, P_prev, P_curr)) and (ccw(A, B, P_prev) != ccw(A, B, P_curr)) + + if not crossed: + # No hubo cruce, pero actualizamos el lado para mantener estado + track.line_side = current_side + return None + + # 4. HUBO CRUCE - Aplicar filtros anti-jitter + + # Cooldown temporal (evita contar la misma persona varias veces) + if time.time() - track.last_crossing_time < 1.5: # 1.5 segundos cooldown + return None + + # Movimiento mínimo (evita jitter de box estático) + dist_moved = math.hypot(P_curr[0] - P_prev[0], P_curr[1] - P_prev[1]) + if dist_moved < 8: # Mínimo 8 píxeles + return None + + # 5. CRUCE CONFIRMADO + track.last_crossing_time = time.time() + prev_side = track.line_side + track.line_side = current_side + + # Determinar dirección basada en cambio de lado + if prev_side == -1 and current_side == 1: + self.total_in += 1 + return "Entrada" + elif prev_side == 1 and current_side == -1: + self.total_out += 1 + return "Salida" + + return None + +# =========================== +# HELPERS +# =========================== +def iou(a, b): + x1 = max(a[0], b[0]); y1 = max(a[1], b[1]) + x2 = min(a[2], b[2]); y2 = min(a[3], b[3]) + inter = max(0, x2 - x1) * max(0, y2 - y1) + if inter == 0: return 0.0 + area_a = max(0, a[2] - a[0]) * max(0, a[3] - a[1]) + area_b = max(0, b[2] - b[0]) * max(0, b[3] - b[1]) + return inter / float(area_a + area_b - inter + 1e-9) + +def nms(boxes, iou_th=0.6): + out = [] + boxes = sorted(boxes, key=lambda b: (b[2]-b[0])*(b[3]-b[1]), reverse=True) + while boxes: + base = boxes.pop(0) + out.append(base) + boxes = [b for b in boxes if iou(base, b) < iou_th] + return out + +def ensure_excel(): + if not os.path.exists(EXCEL_FILENAME): + wb = openpyxl.Workbook() + sh = wb.active + sh.title = "Registro" + sh.append(["Fecha", "Hora", "Evento", "Orientacion", "Total Dentro"]) + wb.save(EXCEL_FILENAME) + +def log_to_excel(evento, orientacion, total_dentro, widget=None): + now = datetime.now() + fecha = now.strftime("%Y-%m-%d") + hora = now.strftime("%H:%M:%S") + + try: + wb = openpyxl.load_workbook(EXCEL_FILENAME) + sh = wb.active + sh.append([fecha, hora, evento, orientacion, total_dentro]) + try: + wb.save(EXCEL_FILENAME) + except PermissionError: + # Excel probablemente abierto en Office - guardar backup + backup_name = f"backup_{fecha}_{hora.replace(':', '-')}_{EXCEL_FILENAME}" + wb.save(backup_name) + print(f"Excel abierto, guardado backup: {backup_name}") + + msg = f"[{hora}] {evento} ({orientacion}) - Total: {total_dentro}" + if widget and widget.winfo_exists(): + widget.configure(state='normal') + widget.insert(tk.END, msg + "\n") + widget.see(tk.END) + widget.configure(state='disabled') + print(msg) + except Exception as e: + print(f"Error Excel: {e}") + +def generate_graph(): + print("Iniciando generación de gráfico...") + try: + if not os.path.exists(EXCEL_FILENAME): + print(f"Archivo Excel no encontrado: {EXCEL_FILENAME}") + return + + # Leer datos + df = pd.read_excel(EXCEL_FILENAME) + print(f"Datos leídos: {len(df)} filas.") + if len(df) < 2: + print("No hay suficientes datos para graficar (< 2 filas).") + return + + # Convertir hora a datetime para el eje X + df['Hora_DT'] = pd.to_datetime(df['Fecha'].astype(str) + ' ' + df['Hora'].astype(str)) + + # Asegurar numérico + df['Total Dentro'] = pd.to_numeric(df['Total Dentro'], errors='coerce').fillna(0) + + # Crear gráfica + plt.figure(figsize=(12, 6)) + plt.plot(df['Hora_DT'], df['Total Dentro'], marker='o', linestyle='-', color='#61afef', linewidth=2, markersize=4) + + plt.title(f"Afluencia de Personas - {today_date_str}", fontsize=14, fontweight='bold') + plt.xlabel("Hora", fontsize=12) + plt.ylabel("Personas Dentro", fontsize=12) + plt.grid(True, linestyle='--', alpha=0.7) + plt.xticks(rotation=45) + + # Forzar enteros en eje Y + from matplotlib.ticker import MaxNLocator + plt.gca().yaxis.set_major_locator(MaxNLocator(integer=True)) + + plt.tight_layout() + + # Guardar imagen temporal + img_path = "temp_graph.png" + plt.savefig(img_path) + plt.close() + + # Insertar en Excel + wb = openpyxl.load_workbook(EXCEL_FILENAME) + if "Resumen Gráfico" in wb.sheetnames: + del wb["Resumen Gráfico"] + ws = wb.create_sheet("Resumen Gráfico") + + img = OpenpyxlImage(img_path) + ws.add_image(img, 'A1') + wb.save(EXCEL_FILENAME) + + # Limpiar + if os.path.exists(img_path): + os.remove(img_path) + + print("Gráfica generada y guardada en Excel.") + + except Exception as e: + print(f"Error generando gráfica: {e}") + +class PerspectiveCalibration: + def __init__(self): + self.active = False + self.points = [] # [(x,y), (x,y), (x,y), (x,y)] + self.matrix = None + self.width = 500 # Metros virtuales * escala (ej: 5m * 100px) + self.height = 500 + self.dragging_idx = -1 + self.height_ref_p1 = None + self.height_ref_p2 = None + self.pixels_per_meter = 100 # Valor por defecto + + def set_default_points(self, w, h): + # Cuadrado central por defecto + cx, cy = w//2, h//2 + d = 100 + self.points = [ + (cx-d, cy-d), # TL + (cx+d, cy-d), # TR + (cx+d, cy+d), # BR + (cx-d, cy+d) # BL + ] + self.update_matrix() + + # Inicializar línea de altura (vertical por defecto) + self.height_ref_p1 = (cx, cy-50) + self.height_ref_p2 = (cx, cy+50) + + def update_matrix(self): + if len(self.points) != 4: return + pts1 = np.float32(self.points) + pts2 = np.float32([[0,0], [self.width,0], [self.width,self.height], [0,self.height]]) + self.matrix = cv2.getPerspectiveTransform(pts1, pts2) + + def transform_point(self, point): + if self.matrix is None: return None + pts = np.float32([[point]]).reshape(-1,1,2) + trans = cv2.perspectiveTransform(pts, self.matrix) + return trans[0][0] # (x, y) en plano transformado + + def handle_click(self, x, y): + # Check si clic cerca de un punto de la malla + for i, p in enumerate(self.points): + if math.hypot(p[0]-x, p[1]-y) < 20: + self.dragging_idx = i + return True + + # Check si clic cerca de la línea de altura + if self.height_ref_p1 and math.hypot(self.height_ref_p1[0]-x, self.height_ref_p1[1]-y) < 20: + self.dragging_idx = 10 # ID especial para P1 altura + return True + if self.height_ref_p2 and math.hypot(self.height_ref_p2[0]-x, self.height_ref_p2[1]-y) < 20: + self.dragging_idx = 11 # ID especial para P2 altura + return True + + return False + + def handle_drag(self, x, y): + if self.dragging_idx != -1: + if self.dragging_idx == 10: + self.height_ref_p1 = (x, y) + elif self.dragging_idx == 11: + self.height_ref_p2 = (x, y) + else: + self.points[self.dragging_idx] = (x, y) + self.update_matrix() + return True + return False + + def handle_release(self): + self.dragging_idx = -1 + + def move_grid(self, dx, dy): + self.points = [(x+dx, y+dy) for x,y in self.points] + self.update_matrix() + + def scale_grid(self, factor): + cx = sum(p[0] for p in self.points) / 4 + cy = sum(p[1] for p in self.points) / 4 + new_points = [] + for x, y in self.points: + nx = cx + (x - cx) * factor + ny = cy + (y - cy) * factor + new_points.append((nx, ny)) + self.points = new_points + self.update_matrix() + + def rotate_grid(self, angle_deg): + cx = sum(p[0] for p in self.points) / 4 + cy = sum(p[1] for p in self.points) / 4 + rad = math.radians(angle_deg) + cos_a = math.cos(rad) + sin_a = math.sin(rad) + new_points = [] + for x, y in self.points: + tx, ty = x - cx, y - cy + nx = cx + tx * cos_a - ty * sin_a + ny = cy + tx * sin_a + ty * cos_a + new_points.append((nx, ny)) + self.points = new_points + self.update_matrix() + +# =========================== +# UI & APP +# =========================== +class App: + def __init__(self, window, window_title): + self.window = window + self.window.title(window_title) + self.window.configure(bg=BG_COLOR) + self.window.resizable(True, True) # Permitir redimensionar + + # Layout + self.top_frame = tk.Frame(window, bg=BG_COLOR) + self.top_frame.pack(fill="x", padx=10, pady=5) + + self.lbl_stats = tk.Label(self.top_frame, text="Dentro (Calc): 0 | Entradas: 0 | Salidas: 0", font=("Helvetica", 14, "bold"), bg=BG_COLOR, fg=ACCENT_COLOR) + self.lbl_stats.pack(side="left") + + # Frame para el video - Usamos pack_propagate(False) para que el contenido no cambie el tamaño del frame + self.video_frame = tk.Frame(window, bg="black") + self.video_frame.pack(fill="both", expand=True, padx=10, pady=5) + self.video_frame.pack_propagate(False) + self.video_frame.bind('', self.on_resize) + + self.lbl_video = tk.Label(self.video_frame, bg="black") + self.lbl_video.pack(fill="both", expand=True) + self.lbl_video.bind("", self.on_video_click) + self.lbl_video.bind("", self.on_video_drag) + self.lbl_video.bind("", self.on_video_release) + + self.controls_frame = tk.Frame(window, bg=BG_COLOR) + self.controls_frame.pack(fill="x", padx=10, pady=10) + + self.btn_draw = tk.Button(self.controls_frame, text="Dibujar Línea", command=self.start_drawing, bg=ACCENT_COLOR, fg="white", font=("Helvetica", 10, "bold")) + self.btn_draw.pack(side="left", padx=5) + + self.btn_reset_line = tk.Button(self.controls_frame, text="Borrar Línea", command=self.reset_line, bg="#e06c75", fg="white", font=("Helvetica", 10)) + self.btn_reset_line.pack(side="left", padx=5) + + self.btn_settings = tk.Button(self.controls_frame, text="Ajustes Cámara", command=self.open_settings_window, bg="#e5c07b", fg="black", font=("Helvetica", 10)) + self.btn_settings.pack(side="left", padx=5) + + self.btn_720p = tk.Button(self.controls_frame, text="720p", command=lambda: self.change_resolution(1280, 720), bg="#61afef", fg="white", font=("Helvetica", 10)) + self.btn_720p.pack(side="left", padx=5) + + self.btn_1080p = tk.Button(self.controls_frame, text="1080p", command=lambda: self.change_resolution(1920, 1080), bg="#61afef", fg="white", font=("Helvetica", 10)) + self.btn_1080p.pack(side="left", padx=5) + + self.btn_correct = tk.Button(self.controls_frame, text="Corregir Contador", command=self.open_correction_dialog, bg="#d19a66", fg="white", font=("Helvetica", 10, "bold")) + self.btn_correct.pack(side="left", padx=5) + + self.btn_calib = tk.Button(self.controls_frame, text="Calibrar 3D", command=self.toggle_calibration, bg="#98c379", fg="white", font=("Helvetica", 10, "bold")) + self.btn_calib.pack(side="left", padx=5) + + # Estado + + self.calibration = PerspectiveCalibration() # Nueva clase de calibración + + self.btn_roi = tk.Button(self.controls_frame, text="Reforzar Zona", command=self.start_roi_selection, bg="#c678dd", fg="white", font=("Helvetica", 10)) + self.btn_roi.pack(side="left", padx=5) + + self.btn_graph = tk.Button(self.controls_frame, text="Generar Gráfico", command=generate_graph, bg="#98c379", fg="black", font=("Helvetica", 10)) + self.btn_graph.pack(side="left", padx=5) + + self.log_text = scrolledtext.ScrolledText(window, height=8, bg="#21252b", fg="white", font=("Consolas", 9)) + self.log_text.pack(fill="x", padx=10, pady=5) + + # Logic + # Usamos CAP_DSHOW en Windows para compatibilidad "Universal" (Mejor acceso a ajustes) + self.cap = cv2.VideoCapture(0, cv2.CAP_DSHOW) + # Configurar 1080p por defecto + self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1920) + self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 1080) + + self.tracker = Tracker(iou_match=TRACK_IOU_MATCH, max_misses=TRACK_MAX_MISSES, min_hits=TRACK_MIN_HITS) + self.line_counter = LineCounter() + + self.drawing_mode = False + self.draw_points = [] + self.frame_count = 0 # Contador para optimización + self.current_frame_size = None # Inicializar explícitamente + + # Dimensiones del frame para resize + self.frame_w = 640 + self.frame_h = 480 + + # ROI (Reforzar Zona) + self.roi_mode = False + self.roi_points = [] + self.roi_rects = [] # Lista de ROIs [(x, y, w, h), ...] + + self.changing_resolution = False # Flag para evitar crash al cambiar res + + self.person_conf = PERSON_CONF # Sensibilidad dinámica + + # Models + self.load_models() + + # Cargar configuración guardada (si existe) + self.load_config() + + ensure_excel() + self.update() + + def on_resize(self, event): + # Actualizamos las dimensiones disponibles cuando cambia el tamaño de la ventana + self.frame_w = event.width + self.frame_h = event.height + + def load_models(self): + print("Cargando modelos...") + try: + self.model_person = YOLO(MODEL_PERSON_PATH) + self.model_face = YOLO(MODEL_FACE_PATH) + print("Modelos cargados.") + except Exception as e: + messagebox.showerror("Error", f"No se pudieron cargar los modelos YOLO.\nAsegúrate de que {MODEL_PERSON_PATH} y {MODEL_FACE_PATH} estén en la carpeta.\n\nError: {e}") + self.window.destroy() + + def save_config(self): + """Guarda la configuración actual (Línea, ROIs, Calibración) en JSON.""" + config = { + "line": { + "p1": list(self.line_counter.p1) if self.line_counter.p1 else None, + "p2": list(self.line_counter.p2) if self.line_counter.p2 else None, + }, + "rois": [list(r) for r in self.roi_rects], + "calibration": { + "active": self.calibration.active, + "points": [list(p) for p in self.calibration.points] if self.calibration.points else [], + "height_ref_p1": list(self.calibration.height_ref_p1) if self.calibration.height_ref_p1 else None, + "height_ref_p2": list(self.calibration.height_ref_p2) if self.calibration.height_ref_p2 else None, + }, + "person_conf": self.person_conf, + } + try: + with open(CONFIG_FILENAME, 'w', encoding='utf-8') as f: + json.dump(config, f, indent=2, ensure_ascii=False) + print(f"Configuración guardada en {CONFIG_FILENAME}") + except Exception as e: + print(f"Error guardando configuración: {e}") + + def load_config(self): + """Carga la configuración desde JSON si existe.""" + if not os.path.exists(CONFIG_FILENAME): + print("No se encontró archivo de configuración previo. Usando valores por defecto.") + return + + try: + with open(CONFIG_FILENAME, 'r', encoding='utf-8') as f: + config = json.load(f) + + # Línea de conteo + if config.get("line"): + p1 = config["line"].get("p1") + p2 = config["line"].get("p2") + if p1 and p2: + self.line_counter.set_line(tuple(p1), tuple(p2)) + print(f"Línea cargada: {p1} -> {p2}") + + # ROIs + if config.get("rois"): + self.roi_rects = [tuple(r) for r in config["rois"]] + print(f"ROIs cargadas: {len(self.roi_rects)}") + + # Calibración 3D + if config.get("calibration"): + cal = config["calibration"] + if cal.get("points") and len(cal["points"]) == 4: + self.calibration.points = [tuple(p) for p in cal["points"]] + self.calibration.update_matrix() + self.calibration.active = cal.get("active", False) + if cal.get("height_ref_p1"): + self.calibration.height_ref_p1 = tuple(cal["height_ref_p1"]) + if cal.get("height_ref_p2"): + self.calibration.height_ref_p2 = tuple(cal["height_ref_p2"]) + print(f"Calibración 3D cargada. Activa: {self.calibration.active}") + + # Sensibilidad + if config.get("person_conf"): + self.person_conf = config["person_conf"] + + print("Configuración cargada correctamente.") + except Exception as e: + print(f"Error cargando configuración: {e}") + + def start_drawing(self): + self.drawing_mode = True + self.roi_mode = False + self.draw_points = [] + self.btn_draw.config(text="Haz clic en 2 puntos...", state="disabled") + + def start_roi_selection(self): + self.roi_mode = True + self.drawing_mode = False + self.roi_points = [] + self.btn_roi.config(text="Marca esq. Sup-Izq y Inf-Der", state="disabled") + + def reset_line(self): + self.line_counter.p1 = None + self.line_counter.p2 = None + self.draw_points = [] + self.roi_rects = [] # Borrar todas las zonas + self.btn_draw.config(text="Dibujar Línea", state="normal") + self.btn_roi.config(text="Reforzar Zona", state="normal") + + def set_camera_property(self, prop_id, value): + if self.cap and self.cap.isOpened(): + try: + self.cap.set(prop_id, float(value)) + except Exception: + pass + + def open_settings_window(self): + if hasattr(self, 'settings_window') and self.settings_window is not None and self.settings_window.winfo_exists(): + self.settings_window.lift() + return + + self.settings_window = tk.Toplevel(self.window) + self.settings_window.title("Control Avanzado de Cámara") + self.settings_window.geometry("400x650") # Más alto para más controles + self.settings_window.configure(bg=BG_COLOR) + + # === HELPER: Slider con Valor Actual === + def add_control(label_text, prop_id, min_val, max_val, step=1, default=0, is_auto_chk=False, auto_prop_id=None): + frame = tk.Frame(self.settings_window, bg=BG_COLOR) + frame.pack(fill="x", padx=10, pady=5) + + # Label + tk.Label(frame, text=label_text, bg=BG_COLOR, fg="white", width=15, anchor="w").pack(side="left") + + # Logic for Auto Checkbox + if is_auto_chk and auto_prop_id is not None: + var_auto = tk.IntVar() + try: + val = self.cap.get(auto_prop_id) + # Logitech: 1=Manual, 3=Auto usually. Or 0/1. Detect logic heavily depends on driver. + if val > 0: var_auto.set(1) + except: pass + + def toggle_auto(): + # Logitech C920: AutoFocus=1 (On)? No, 1=Off, 0=On sometimes, or 1=Manual, 3=Auto + # Standard UVC: 1=Manual, 3=Auto for Exposure. + # Focus: 0=Auto Off (Manual), 1=Auto On. + v = var_auto.get() + # Try setting strictly + self.set_camera_property(auto_prop_id, 1 if v else 0) # Generic toggle attempt + # Re-enable/Disable slider if needed (omitted for simplicity, just letting user force it) + + chk = tk.Checkbutton(frame, text="Auto", variable=var_auto, command=toggle_auto, + bg=BG_COLOR, fg="white", selectcolor="#282c34", activebackground=BG_COLOR) + chk.pack(side="right") + + # Slider + is_supported = True + try: + curr = self.cap.get(prop_id) + if curr == -1: + curr = default + is_supported = False + except: + curr = default + is_supported = False + + scale = tk.Scale(frame, from_=min_val, to=max_val, resolution=step, orient="horizontal", + command=lambda v: self.set_camera_property(prop_id, v), + fg="white", bg=BG_COLOR, highlightthickness=0, length=200) + scale.set(curr) + + if not is_supported: + scale.config(state='disabled', fg='#5c6370', label=f"{label_text} (N/A)") + else: + scale.pack(side="right", expand=True, fill="x") + + if not is_supported: + tk.Label(frame, text="(No Soportado)", bg=BG_COLOR, fg="#5c6370", font=("Arial", 8)).pack(side="right", padx=5) + + # === CONTROLES === + tk.Label(self.settings_window, text="--- Imagen ---", bg=BG_COLOR, fg="#abb2bf").pack(pady=5) + add_control("Brillo", cv2.CAP_PROP_BRIGHTNESS, 0, 255, 1, 128) + add_control("Contraste", cv2.CAP_PROP_CONTRAST, 0, 255, 1, 128) + add_control("Saturación", cv2.CAP_PROP_SATURATION, 0, 255, 1, 128) + add_control("Nitidez", cv2.CAP_PROP_SHARPNESS, 0, 255, 1, 128) + add_control("Gamma", cv2.CAP_PROP_GAMMA, 0, 500, 1, 100) + + tk.Label(self.settings_window, text="--- Exposición & Foco ---", bg=BG_COLOR, fg="#abb2bf").pack(pady=5) + # Exposición: -13 a 0 (powers of 2) usually for Logitech. -6 is typical. + # Auto Exposure: 1=Manual, 3=Auto (Standard UVC). + # Trick: To disable Auto Exp on C920, usually set CAP_PROP_AUTO_EXPOSURE to 1 (Manual) or 0.25 (cv2 quirk). + + # Exposure Logic is tricky in OpenCV + Windows. We provide range -15 to 0. + add_control("Exposición", cv2.CAP_PROP_EXPOSURE, -13, 0, 1, -5) + + # Ganancia + add_control("Ganancia", cv2.CAP_PROP_GAIN, 0, 255, 1, 0) + + # Focus: 0 to 255 (mm or logic units). Auto Focus toggle. + add_control("Foco (0=Inf)", cv2.CAP_PROP_FOCUS, 0, 255, 5, 0, is_auto_chk=True, auto_prop_id=cv2.CAP_PROP_AUTOFOCUS) + + tk.Label(self.settings_window, text="--- Detección ---", bg=BG_COLOR, fg="#abb2bf").pack(pady=5) + + # Slider de Sensibilidad (Confianza) reutilizado + # Slider de Sensibilidad IA (Personas) + lbl_conf = tk.Label(self.settings_window, text=f"Sensibilidad Personas ({int(self.person_conf*100)}%)", bg=BG_COLOR, fg="white") + lbl_conf.pack(pady=(5,0)) + def update_conf(v): + self.person_conf = float(v) + lbl_conf.config(text=f"Sensibilidad Personas ({int(self.person_conf*100)}%)") + s_conf = tk.Scale(self.settings_window, from_=0.1, to=1.0, resolution=0.05, orient="horizontal", + command=update_conf, fg="white", bg=BG_COLOR, highlightthickness=0, length=300) + s_conf.set(self.person_conf) + s_conf.pack(pady=5) + + # Slider de Sensibilidad IA (Caras) + # Permite al usuario "reforzar" la detección de caras + # Definimos FACE_CONF globalmente, pero aquí la haremos dinámica (truco: modificar global o self?) + # Mejor modificar una variable de clase o self.face_conf si la tuviera. + # Como FACE_CONF es global, la modificaremos con 'global' en el callback o añadiremos self.face_conf + + # Primero, aseguremos que usamos self.face_conf en main.py en vez de la constante global + # (Para este parche rápido, modificaremos la global Face Conf usando un wrapper) + + lbl_face = tk.Label(self.settings_window, text=f"Sensibilidad Cara ({int(FACE_CONF*100)}%)", bg=BG_COLOR, fg="white") + lbl_face.pack(pady=(5,0)) + def update_face_conf(v): + global FACE_CONF + FACE_CONF = float(v) + lbl_face.config(text=f"Sensibilidad Cara ({int(FACE_CONF*100)}%)") + + s_face = tk.Scale(self.settings_window, from_=0.1, to=1.0, resolution=0.05, orient="horizontal", + command=update_face_conf, fg="white", bg=BG_COLOR, highlightthickness=0, length=300) + s_face.set(FACE_CONF) + s_face.pack(pady=5) + + tk.Label(self.settings_window, text="Nota: Desactiva 'Auto' para usar control manual.", bg=BG_COLOR, fg="#7f848e", font=("Arial", 8)).pack(pady=10) + + def change_resolution(self, width, height): + print(f"Cambiando resolución a {width}x{height}...") + self.changing_resolution = True + + # Liberar y reabrir para asegurar cambio limpio + if self.cap.isOpened(): + self.cap.release() + + # Reabrir con DirectShow para mantener compatibilidad universal + self.cap = cv2.VideoCapture(0, cv2.CAP_DSHOW) + self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, width) + self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height) + + # Verificar cambio + w = self.cap.get(cv2.CAP_PROP_FRAME_WIDTH) + h = self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT) + print(f"Resolución actual: {int(w)}x{int(h)}") + + self.changing_resolution = False + + def toggle_calibration(self): + self.calibration.active = not self.calibration.active + if self.calibration.active: + self.btn_calib.config(bg="#e5c07b", text="Terminar Calib.") + if not self.calibration.points: + # Inicializar puntos si no existen + if self.current_frame_size is not None: + self.calibration.set_default_points(self.current_frame_size[0], self.current_frame_size[1]) + self.open_calibration_controls() + else: + self.btn_calib.config(bg="#98c379", text="Calibrar 3D") + if hasattr(self, 'calib_window') and self.calib_window.winfo_exists(): + self.calib_window.destroy() + + def open_calibration_controls(self): + if hasattr(self, 'calib_window') and self.calib_window is not None and self.calib_window.winfo_exists(): + self.calib_window.lift() + return + + self.calib_window = tk.Toplevel(self.window) + self.calib_window.title("Controles 3D") + self.calib_window.geometry("300x400") + self.calib_window.configure(bg=BG_COLOR) + + tk.Label(self.calib_window, text="Mover Malla", bg=BG_COLOR, fg="white").pack(pady=5) + frm_move = tk.Frame(self.calib_window, bg=BG_COLOR) + frm_move.pack() + tk.Button(frm_move, text="↑", command=lambda: self.calibration.move_grid(0, -10)).grid(row=0, column=1) + tk.Button(frm_move, text="←", command=lambda: self.calibration.move_grid(-10, 0)).grid(row=1, column=0) + tk.Button(frm_move, text="↓", command=lambda: self.calibration.move_grid(0, 10)).grid(row=1, column=1) + tk.Button(frm_move, text="→", command=lambda: self.calibration.move_grid(10, 0)).grid(row=1, column=2) + + tk.Label(self.calib_window, text="Rotar", bg=BG_COLOR, fg="white").pack(pady=5) + frm_rot = tk.Frame(self.calib_window, bg=BG_COLOR) + frm_rot.pack() + tk.Button(frm_rot, text="↺ -5°", command=lambda: self.calibration.rotate_grid(-5)).pack(side="left", padx=5) + tk.Button(frm_rot, text="↻ +5°", command=lambda: self.calibration.rotate_grid(5)).pack(side="left", padx=5) + + tk.Label(self.calib_window, text="Escalar", bg=BG_COLOR, fg="white").pack(pady=5) + frm_scale = tk.Frame(self.calib_window, bg=BG_COLOR) + frm_scale.pack() + tk.Button(frm_scale, text="-", command=lambda: self.calibration.scale_grid(0.9)).pack(side="left", padx=5) + tk.Button(frm_scale, text="+", command=lambda: self.calibration.scale_grid(1.1)).pack(side="left", padx=5) + + tk.Label(self.calib_window, text="Altura Referencia (m)", bg=BG_COLOR, fg="white").pack(pady=10) + # Aquí iría el control de altura, por ahora solo visual + tk.Label(self.calib_window, text="(Usa la regla vertical en pantalla)", bg=BG_COLOR, fg="gray").pack() + + def open_correction_dialog(self): + win = tk.Toplevel(self.window) + win.title("Corregir Contador") + win.geometry("300x200") + win.configure(bg=BG_COLOR) + + tk.Label(win, text="Número Real de Personas Dentro:", bg=BG_COLOR, fg="white").pack(pady=10) + + entry = tk.Entry(win) + entry.pack(pady=5) + # Valor actual + current = self.line_counter.total_in - self.line_counter.total_out + self.line_counter.manual_offset + entry.insert(0, str(current)) + + def apply(): + try: + val = int(entry.get()) + self.line_counter.set_manual_count(val) + log_to_excel("Correccion Manual", "Manual", val, self.log_text) + win.destroy() + except ValueError: + messagebox.showerror("Error", "Introduce un número válido") + + def reset_all(): + if messagebox.askyesno("Resetear", "¿Poner Entradas y Salidas a 0?"): + self.line_counter.reset_counts() + log_to_excel("Reset", "Manual", 0, self.log_text) + win.destroy() + + tk.Button(win, text="Aplicar", command=apply, bg=ACCENT_COLOR, fg="white").pack(pady=5) + tk.Button(win, text="Resetear Contadores a 0", command=reset_all, bg="#e06c75", fg="white").pack(pady=20) + + def get_img_coords(self, event): + if self.current_frame_size is None: return 0, 0 + fw, fh = self.current_frame_size + lw, lh = self.frame_w, self.frame_h + scale = min(lw/fw, lh/fh) + nw, nh = int(fw*scale), int(fh*scale) + dx, dy = (lw-nw)//2, (lh-nh)//2 + x_img = int((event.x - dx) / scale) + y_img = int((event.y - dy) / scale) + return x_img, y_img + + def on_video_drag(self, event): + if not self.calibration.active: return + x, y = self.get_img_coords(event) + self.calibration.handle_drag(x, y) + + def on_video_release(self, event): + if not self.calibration.active: return + self.calibration.handle_release() + + def on_video_click(self, event): + # Prioridad a calibración + if self.calibration.active: + x, y = self.get_img_coords(event) + if self.calibration.handle_click(x, y): + return + + if not self.drawing_mode and not self.roi_mode: return + if self.current_frame_size is None: return + + # Usamos las dimensiones cacheadas del frame + fw, fh = self.current_frame_size + lw, lh = self.frame_w, self.frame_h + + # Calcular escala y offsets (letterbox) + scale = min(lw/fw, lh/fh) + nw, nh = int(fw*scale), int(fh*scale) + dx, dy = (lw-nw)//2, (lh-nh)//2 + + # Coordenadas relativas a la imagen + x_img = int((event.x - dx) / scale) + y_img = int((event.y - dy) / scale) + + # Clamp + x_img = max(0, min(fw-1, x_img)) + y_img = max(0, min(fh-1, y_img)) + + if self.drawing_mode: + self.draw_points.append((x_img, y_img)) + if len(self.draw_points) == 2: + self.line_counter.set_line(self.draw_points[0], self.draw_points[1]) + self.drawing_mode = False + self.btn_draw.config(text="Dibujar Línea", state="normal") + print(f"Línea establecida: {self.draw_points}") + + elif self.roi_mode: + self.roi_points.append((x_img, y_img)) + if len(self.roi_points) == 2: + x1, y1 = self.roi_points[0] + x2, y2 = self.roi_points[1] + # Asegurar orden + rx, ry = min(x1, x2), min(y1, y2) + rw, rh = abs(x2 - x1), abs(y2 - y1) + + if rw > 10 and rh > 10: + self.roi_rects.append((rx, ry, rw, rh)) + print(f"ROI añadida: {(rx, ry, rw, rh)}") + else: + print("ROI muy pequeña, ignorada.") + + self.roi_mode = False + self.btn_roi.config(text="Reforzar Zona", state="normal") + + def update(self): + if self.changing_resolution: + self.window.after(50, self.update) + return + + try: + ret, frame = self.cap.read() + except Exception as e: + print(f"Error leyendo cámara: {e}") + ret = False + + if not ret: + # Intentar reconectar cámara si falla + print("Cámara desconectada o error. Intentando reconectar...") + try: + if self.cap.isOpened(): + self.cap.release() + self.cap = cv2.VideoCapture(0, cv2.CAP_DSHOW) + self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1920) + self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 1080) + except: + pass + self.window.after(1000, self.update) # Reintentar en 1 segundo + return + + H, W = frame.shape[:2] + self.current_frame_size = (W, H) + + # 1. Detección Global + results_p = self.model_person(frame, classes=[0], conf=self.person_conf, verbose=False) + person_boxes = [list(map(int, b.xyxy[0])) for b in results_p[0].boxes] + + # Inicializar lista de caras (Global) + face_boxes = [] + + # 1.5 Detección ROI (Múltiples Zonas) - Prioridad Máxima (Cada frame) + for roi_rect in self.roi_rects: + rx, ry, rw, rh = roi_rect + # Recorte seguro + roi_frame = frame[ry:ry+rh, rx:rx+rw] + if roi_frame.size > 0: + # A) Personas en ROI + results_roi = self.model_person(roi_frame, classes=[0], conf=self.person_conf, verbose=False) + for b in results_roi[0].boxes: + bx1, by1, bx2, by2 = map(int, b.xyxy[0]) + global_box = [bx1 + rx, by1 + ry, bx2 + rx, by2 + ry] + person_boxes.append(global_box) + + # B) Caras en ROI + # Chequeo de caras cada 4 frames para no saturar (orientación no necesita tantos FPS) + if (self.frame_count % 4 == 0): + results_roi_face = self.model_face(roi_frame, conf=FACE_CONF, verbose=False) + for b in results_roi_face[0].boxes: + bx1, by1, bx2, by2 = map(int, b.xyxy[0]) + global_face_box = [bx1 + rx, by1 + ry, bx2 + rx, by2 + ry] + face_boxes.append(global_face_box) + + # Aplicar NMS para fusionar duplicados (Global + ROI) + # BALANCE: 0.55 fusiona partes del mismo cuerpo pero mantiene 2 personas cercanas + if len(person_boxes) > 0: + # FILTRO DE TAMAÑO MÍNIMO: Descartar detecciones muy pequeñas (cabezas solas, brazos, objetos) + # REDUCIDO para detectar personas parcialmente ocluidas (llevando objetos) + MIN_BOX_WIDTH = 30 # Era 40 + MIN_BOX_HEIGHT = 50 # Era 80 - personas con cajas/maderas pueden verse más bajas + person_boxes = [b for b in person_boxes if (b[2]-b[0]) >= MIN_BOX_WIDTH and (b[3]-b[1]) >= MIN_BOX_HEIGHT] + + person_boxes = nms(person_boxes, iou_th=0.55) # Balanceado: fusiona partes, separa personas + + # 2. Tracking + self.tracker.update(person_boxes) + confirmed = self.tracker.confirmed_tracks() + + # 3. Detección de Orientación (CADA FRAME en PC potente) + if confirmed: + # Detectar caras en todo el frame (Global) + res_f = self.model_face(frame, conf=FACE_CONF, verbose=False) + # Añadir a la lista existente (que ya puede tener caras del ROI) + for b in res_f[0].boxes: + face_boxes.append(list(map(int, b.xyxy[0]))) + + for tr in confirmed: + # Lógica de Buffer (Histéresis) para estabilidad de cara + # Decrementamos buffer cada frame + tr.face_buffer = max(0, tr.face_buffer - 1) + + found_face_this_frame = False + + # Chequear si alguna cara está dentro o cerca del box de la persona + for fb in face_boxes: + # Centro de la cara + fcx, fcy = (fb[0]+fb[2])//2, (fb[1]+fb[3])//2 + if tr.box[0] <= fcx <= tr.box[2] and tr.box[1] <= fcy <= tr.box[3]: + found_face_this_frame = True + break + + if found_face_this_frame: + tr.face_buffer = 5 # Buffer de 0.5s para estabilidad de orientación + + tr.has_face = (tr.face_buffer > 0) + + self.frame_count += 1 + + # 4. Lógica de Línea y Dibujo + if self.line_counter.is_set(): + p1, p2 = self.line_counter.p1, self.line_counter.p2 + cv2.line(frame, p1, p2, (0, 255, 255), 2) + + # Dibujar flecha indicativa de "Entrada" + mx, my = (p1[0]+p2[0])//2, (p1[1]+p2[1])//2 + # Revertido al original (L->R = Abajo/Adentro) + nx, ny = -(p2[1]-p1[1]), (p2[0]-p1[0]) + n_len = math.hypot(nx, ny) + if n_len > 0: + nx, ny = int(nx/n_len * 30), int(ny/n_len * 30) + cv2.arrowedLine(frame, (mx, my), (mx+nx, my+ny), (0, 255, 0), 2, tipLength=0.3) + cv2.putText(frame, "Entrada", (mx+nx, my+ny), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0,255,0), 1) + + # Chequear cruces + for tr in confirmed: + event = self.line_counter.check_crossing(tr, self.calibration) + if event: + orientacion = "Frente" if tr.has_face else "Espalda" + total = self.line_counter.total_in - self.line_counter.total_out + self.line_counter.manual_offset # Include offset + log_to_excel(event, orientacion, total, self.log_text) + + # Efecto visual + color = (0, 255, 0) if event == "Entrada" else (0, 0, 255) + cv2.circle(frame, tr.centroid, 10, color, -1) + + # 5. Dibujar Tracks + for tr in confirmed: + color = (0, 255, 0) if tr.has_face else (0, 165, 255) # Verde=Cara, Naranja=Espalda + label = f"ID {tr.id} {'(F)' if tr.has_face else '(E)'}" + cv2.rectangle(frame, (tr.box[0], tr.box[1]), (tr.box[2], tr.box[3]), color, 2) + cv2.putText(frame, label, (tr.box[0], tr.box[1]-5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2) + cv2.circle(frame, tr.centroid, 4, (255, 0, 0), -1) + + # Visualizar Vector de Movimiento (Predicción Kalman) + if hasattr(tr, 'prediction'): + # Dibujar línea hacia donde el filtro predice que irá + cv2.line(frame, tr.centroid, tr.prediction, (255, 255, 0), 2) + cv2.circle(frame, tr.prediction, 3, (0, 255, 255), -1) + + # Dibujar ROIs + for i, roi_rect in enumerate(self.roi_rects): + rx, ry, rw, rh = roi_rect + cv2.rectangle(frame, (rx, ry), (rx+rw, ry+rh), (255, 0, 255), 2) + cv2.putText(frame, f"Zona {i+1}", (rx, ry-5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 0, 255), 1) + + # Dibujar Calibración 3D + if self.calibration.active and len(self.calibration.points) == 4: + pts = np.array(self.calibration.points, np.int32) + pts = pts.reshape((-1, 1, 2)) + cv2.polylines(frame, [pts], True, (0, 255, 255), 2) + + for i, p in enumerate(self.calibration.points): + color = (0, 0, 255) if i == self.calibration.dragging_idx else (0, 255, 255) + cv2.circle(frame, (int(p[0]), int(p[1])), 8, color, -1) + cv2.putText(frame, str(i+1), (int(p[0])+10, int(p[1])), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255,255,255), 1) + + # Dibujar Línea de Altura (Referencia) + if self.calibration.height_ref_p1 and self.calibration.height_ref_p2: + hp1 = (int(self.calibration.height_ref_p1[0]), int(self.calibration.height_ref_p1[1])) + hp2 = (int(self.calibration.height_ref_p2[0]), int(self.calibration.height_ref_p2[1])) + cv2.line(frame, hp1, hp2, (255, 0, 0), 3) # Azul + + # Puntos extremos + c1 = (0, 0, 255) if self.calibration.dragging_idx == 10 else (255, 0, 0) + c2 = (0, 0, 255) if self.calibration.dragging_idx == 11 else (255, 0, 0) + cv2.circle(frame, hp1, 6, c1, -1) + cv2.circle(frame, hp2, 6, c2, -1) + cv2.putText(frame, "Altura Ref.", (hp1[0]+10, hp1[1]), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255,0,0), 1) + + # Actualizar stats + net_inside = self.line_counter.total_in - self.line_counter.total_out + self.line_counter.manual_offset + self.lbl_stats.config(text=f"Dentro (Calc): {net_inside} | Entradas: {self.line_counter.total_in} | Salidas: {self.line_counter.total_out}") + + # Mostrar en Tkinter + # Usamos las dimensiones cacheadas del frame (self.frame_w, self.frame_h) + # Esto evita el bucle de retroalimentación donde el contenido empuja el tamaño del frame + w_label = self.frame_w + h_label = self.frame_h + + if w_label > 10 and h_label > 10: + scale = min(w_label/W, h_label/H) + nw, nh = int(W*scale), int(H*scale) + frame_resized = cv2.resize(frame, (nw, nh)) + + # Crear imagen negra de fondo + bg = PILImage.new('RGB', (w_label, h_label), (0,0,0)) + img_pil = PILImage.fromarray(cv2.cvtColor(frame_resized, cv2.COLOR_BGR2RGB)) + bg.paste(img_pil, ((w_label-nw)//2, (h_label-nh)//2)) + + imgtk = ImageTk.PhotoImage(image=bg) + self.lbl_video.imgtk = imgtk + self.lbl_video.configure(image=imgtk) + + self.window.after(30, self.update) + + def on_closing(self): + # Guardar configuración antes de cerrar + self.save_config() + + self.cap.release() + self.window.destroy() + generate_graph() + +if __name__ == "__main__": + root = tk.Tk() + root.geometry("1000x700") + app = App(root, "Sistema de Conteo de Personas - IA") + root.protocol("WM_DELETE_WINDOW", app.on_closing) + root.mainloop()