Spaces:
Runtime error
Runtime error
upload files
Browse files- .dockerignore +8 -0
- Dockerfile +29 -0
- app.py +716 -0
.dockerignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__
|
| 2 |
+
*.pyc
|
| 3 |
+
*.pyo
|
| 4 |
+
*.pyd
|
| 5 |
+
.Python
|
| 6 |
+
env/
|
| 7 |
+
venv/
|
| 8 |
+
*.txt
|
Dockerfile
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Usa una imagen base de Python
|
| 2 |
+
FROM python:3.10-slim
|
| 3 |
+
|
| 4 |
+
# Crea un usuario no root
|
| 5 |
+
RUN useradd -m -u 1000 user
|
| 6 |
+
|
| 7 |
+
# Instala dependencias del sistema, incluyendo ffmpeg
|
| 8 |
+
RUN apt-get update && apt-get install -y \
|
| 9 |
+
ffmpeg \
|
| 10 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 11 |
+
|
| 12 |
+
# Establece el directorio de trabajo
|
| 13 |
+
WORKDIR /code
|
| 14 |
+
|
| 15 |
+
# Copia los archivos de requisitos
|
| 16 |
+
COPY requirements.txt .
|
| 17 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 18 |
+
|
| 19 |
+
# Copia el resto de los archivos del proyecto
|
| 20 |
+
COPY . .
|
| 21 |
+
|
| 22 |
+
# Cambia al usuario no root
|
| 23 |
+
USER user
|
| 24 |
+
|
| 25 |
+
# Expone el puerto 5000 (el que usa Flask)
|
| 26 |
+
EXPOSE 5000
|
| 27 |
+
|
| 28 |
+
# Ejecuta la aplicación
|
| 29 |
+
CMD ["python", "app.py"]
|
app.py
ADDED
|
@@ -0,0 +1,716 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import cv2
|
| 3 |
+
import mediapipe as mp
|
| 4 |
+
import pandas as pd
|
| 5 |
+
import numpy as np
|
| 6 |
+
import logging
|
| 7 |
+
import subprocess
|
| 8 |
+
import tempfile
|
| 9 |
+
from flask import Flask, render_template, request, jsonify
|
| 10 |
+
from werkzeug.utils import secure_filename
|
| 11 |
+
import atexit
|
| 12 |
+
import shutil
|
| 13 |
+
|
| 14 |
+
# Configuración de logging
|
| 15 |
+
logging.basicConfig(level=logging.INFO)
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
# Inicializa la aplicación de Flask
|
| 19 |
+
app = Flask(__name__)
|
| 20 |
+
|
| 21 |
+
# Configuración de Flask
|
| 22 |
+
app.config['SECRET_KEY'] = 'facial-analyzer-demo-2024-super-secret-key-12345'
|
| 23 |
+
app.config['WTF_CSRF_ENABLED'] = False # Deshabilitar CSRF para este ejemplo
|
| 24 |
+
|
| 25 |
+
# Configura las rutas para guardar los archivos temporales
|
| 26 |
+
UPLOAD_FOLDER = 'temp_files'
|
| 27 |
+
if not os.path.exists(UPLOAD_FOLDER):
|
| 28 |
+
os.makedirs(UPLOAD_FOLDER)
|
| 29 |
+
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
|
| 30 |
+
app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024 # 100MB max
|
| 31 |
+
|
| 32 |
+
# Inicializa MediaPipe Face Mesh
|
| 33 |
+
mp_face_mesh = mp.solutions.face_mesh
|
| 34 |
+
face_mesh = mp_face_mesh.FaceMesh(
|
| 35 |
+
static_image_mode=False,
|
| 36 |
+
max_num_faces=1,
|
| 37 |
+
min_detection_confidence=0.5,
|
| 38 |
+
min_tracking_confidence=0.5
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def download_youtube_video(youtube_url, output_path):
|
| 43 |
+
"""
|
| 44 |
+
Descarga video de YouTube usando múltiples métodos (PyTube, yt-dlp).
|
| 45 |
+
|
| 46 |
+
Args:
|
| 47 |
+
youtube_url (str): URL del video de YouTube.
|
| 48 |
+
output_path (str): Carpeta donde guardar el video.
|
| 49 |
+
|
| 50 |
+
Returns:
|
| 51 |
+
tuple: (video_path, video_info) o (None, error_message).
|
| 52 |
+
"""
|
| 53 |
+
logger.info(f"Intentando descargar: {youtube_url}")
|
| 54 |
+
|
| 55 |
+
# Convertir URLs de Shorts a formato normal
|
| 56 |
+
if '/shorts/' in youtube_url:
|
| 57 |
+
video_id = youtube_url.split('/shorts/')[-1].split('?')[0]
|
| 58 |
+
youtube_url = f"https://www.youtube.com/watch?v={video_id}"
|
| 59 |
+
logger.info(f"Convertido de Shorts a: {youtube_url}")
|
| 60 |
+
|
| 61 |
+
# Método 1: Intentar con PyTube
|
| 62 |
+
try:
|
| 63 |
+
# Intentar importar PyTube (múltiples versiones)
|
| 64 |
+
try:
|
| 65 |
+
from pytubefix import YouTube, exceptions
|
| 66 |
+
logger.info("📦 Usando PyTubeFix")
|
| 67 |
+
except ImportError:
|
| 68 |
+
from pytube import YouTube, exceptions
|
| 69 |
+
logger.info("📦 Usando PyTube estándar")
|
| 70 |
+
|
| 71 |
+
logger.info("Probando método 1: PyTube")
|
| 72 |
+
yt = YouTube(
|
| 73 |
+
youtube_url,
|
| 74 |
+
use_oauth=False,
|
| 75 |
+
allow_oauth_cache=False
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
# Obtener información del video
|
| 79 |
+
video_info = {
|
| 80 |
+
'title': yt.title,
|
| 81 |
+
'duration': yt.length,
|
| 82 |
+
'author': yt.author
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
logger.info(f"Video encontrado: {video_info['title']} ({video_info['duration']}s)")
|
| 86 |
+
|
| 87 |
+
# Obtener stream
|
| 88 |
+
stream = None
|
| 89 |
+
streams = yt.streams.filter(progressive=True, file_extension='mp4')
|
| 90 |
+
if streams:
|
| 91 |
+
stream = streams.order_by('resolution').desc().first()
|
| 92 |
+
|
| 93 |
+
if not stream:
|
| 94 |
+
streams = yt.streams.filter(adaptive=True, file_extension='mp4', only_video=True)
|
| 95 |
+
if streams:
|
| 96 |
+
stream = streams.order_by('resolution').desc().first()
|
| 97 |
+
|
| 98 |
+
if not stream:
|
| 99 |
+
raise Exception("No se encontraron streams compatibles")
|
| 100 |
+
|
| 101 |
+
# Descargar
|
| 102 |
+
video_id = yt.video_id
|
| 103 |
+
safe_filename = secure_filename(f"{video_id}.mp4")
|
| 104 |
+
video_path = os.path.join(output_path, safe_filename)
|
| 105 |
+
|
| 106 |
+
stream.download(output_path=output_path, filename=safe_filename)
|
| 107 |
+
|
| 108 |
+
if os.path.exists(video_path):
|
| 109 |
+
logger.info("✅ Descarga exitosa con PyTube")
|
| 110 |
+
return video_path, video_info
|
| 111 |
+
else:
|
| 112 |
+
raise Exception("Archivo no se creó correctamente")
|
| 113 |
+
|
| 114 |
+
except Exception as e:
|
| 115 |
+
logger.warning(f"PyTube falló: {e}")
|
| 116 |
+
|
| 117 |
+
# Método 2: Intentar con yt-dlp (más robusto)
|
| 118 |
+
try:
|
| 119 |
+
logger.info("Probando método 2: yt-dlp")
|
| 120 |
+
|
| 121 |
+
# Extraer ID del video para nombre de archivo
|
| 122 |
+
if 'watch?v=' in youtube_url:
|
| 123 |
+
video_id = youtube_url.split('watch?v=')[-1].split('&')[0]
|
| 124 |
+
elif 'youtu.be/' in youtube_url:
|
| 125 |
+
video_id = youtube_url.split('youtu.be/')[-1].split('?')[0]
|
| 126 |
+
else:
|
| 127 |
+
video_id = 'video'
|
| 128 |
+
|
| 129 |
+
safe_filename = secure_filename(f"{video_id}.mp4")
|
| 130 |
+
video_path = os.path.join(output_path, safe_filename)
|
| 131 |
+
|
| 132 |
+
# Comando yt-dlp
|
| 133 |
+
cmd = [
|
| 134 |
+
'yt-dlp',
|
| 135 |
+
'--format', 'mp4[height<=720]/best[height<=720]/best',
|
| 136 |
+
'--output', video_path,
|
| 137 |
+
'--no-playlist',
|
| 138 |
+
youtube_url
|
| 139 |
+
]
|
| 140 |
+
logger.info(f"Ejecutando: {' '.join(cmd)}")
|
| 141 |
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
| 142 |
+
|
| 143 |
+
if result.returncode == 0 and os.path.exists(video_path):
|
| 144 |
+
# Obtener información del video
|
| 145 |
+
info_cmd = [
|
| 146 |
+
'yt-dlp',
|
| 147 |
+
'--print', 'title',
|
| 148 |
+
'--print', 'duration',
|
| 149 |
+
'--print', 'uploader',
|
| 150 |
+
'--no-download',
|
| 151 |
+
youtube_url
|
| 152 |
+
]
|
| 153 |
+
|
| 154 |
+
info_result = subprocess.run(info_cmd, capture_output=True, text=True, timeout=30)
|
| 155 |
+
|
| 156 |
+
if info_result.returncode == 0:
|
| 157 |
+
lines = info_result.stdout.strip().split('\n')
|
| 158 |
+
video_info = {
|
| 159 |
+
'title': lines[0] if len(lines) > 0 else 'Título desconocido',
|
| 160 |
+
'duration': int(float(lines[1])) if len(lines) > 1 and lines[1].replace('.', '').isdigit() else 0,
|
| 161 |
+
'author': lines[2] if len(lines) > 2 else 'Autor desconocido'
|
| 162 |
+
}
|
| 163 |
+
else:
|
| 164 |
+
video_info = {
|
| 165 |
+
'title': 'Video de YouTube',
|
| 166 |
+
'duration': 0,
|
| 167 |
+
'author': 'Desconocido'
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
logger.info("✅ Descarga exitosa con yt-dlp")
|
| 171 |
+
return video_path, video_info
|
| 172 |
+
else:
|
| 173 |
+
raise Exception(f"yt-dlp falló: {result.stderr}")
|
| 174 |
+
|
| 175 |
+
except subprocess.TimeoutExpired:
|
| 176 |
+
logger.warning("yt-dlp timeout")
|
| 177 |
+
except FileNotFoundError:
|
| 178 |
+
logger.warning("yt-dlp no está instalado")
|
| 179 |
+
except Exception as e:
|
| 180 |
+
logger.warning(f"yt-dlp falló: {e}")
|
| 181 |
+
|
| 182 |
+
# Si todos los métodos fallan
|
| 183 |
+
return None, "No se pudo descargar el video. Verifica que la URL sea correcta y el video sea público."
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
# Puntos clave específicos que necesitamos (optimización)
|
| 187 |
+
REQUIRED_LANDMARKS = {
|
| 188 |
+
'mouth': [13, 14, 61, 291],
|
| 189 |
+
'eyes': [386, 374, 362, 263],
|
| 190 |
+
'head': [4, 152, 234, 454],
|
| 191 |
+
'eyebrows': [107, 336, 17, 268] # Añadimos puntos para cejas
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def cleanup_temp_files():
|
| 196 |
+
"""Limpia archivos temporales al cerrar la aplicación."""
|
| 197 |
+
if os.path.exists(UPLOAD_FOLDER):
|
| 198 |
+
try:
|
| 199 |
+
shutil.rmtree(UPLOAD_FOLDER)
|
| 200 |
+
logger.info("Archivos temporales limpiados")
|
| 201 |
+
except Exception as e:
|
| 202 |
+
logger.error(f"Error limpiando archivos temporales: {e}")
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
atexit.register(cleanup_temp_files)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def get_video_info(video_path):
|
| 209 |
+
"""
|
| 210 |
+
Obtiene información básica del video como FPS y duración.
|
| 211 |
+
|
| 212 |
+
Args:
|
| 213 |
+
video_path (str): Ruta al archivo de video.
|
| 214 |
+
|
| 215 |
+
Returns:
|
| 216 |
+
tuple: (fps, frame_count, duration)
|
| 217 |
+
"""
|
| 218 |
+
cap = cv2.VideoCapture(video_path)
|
| 219 |
+
if not cap.isOpened():
|
| 220 |
+
return None, None, None
|
| 221 |
+
|
| 222 |
+
fps = cap.get(cv2.CAP_PROP_FPS)
|
| 223 |
+
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
| 224 |
+
duration = frame_count / fps if fps > 0 else 0
|
| 225 |
+
|
| 226 |
+
cap.release()
|
| 227 |
+
return fps, frame_count, duration
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def extract_required_landmarks(landmarks):
|
| 231 |
+
"""
|
| 232 |
+
Extrae solo los landmarks necesarios para optimizar memoria.
|
| 233 |
+
|
| 234 |
+
Args:
|
| 235 |
+
landmarks: Landmarks de MediaPipe.
|
| 236 |
+
|
| 237 |
+
Returns:
|
| 238 |
+
dict: Diccionario con coordenadas de puntos específicos.
|
| 239 |
+
"""
|
| 240 |
+
result = {}
|
| 241 |
+
all_points = []
|
| 242 |
+
for category, indices in REQUIRED_LANDMARKS.items():
|
| 243 |
+
all_points.extend(indices)
|
| 244 |
+
|
| 245 |
+
for i in all_points:
|
| 246 |
+
if i < len(landmarks.landmark):
|
| 247 |
+
landmark = landmarks.landmark[i]
|
| 248 |
+
result[f'x_{i}'] = landmark.x
|
| 249 |
+
result[f'y_{i}'] = landmark.y
|
| 250 |
+
|
| 251 |
+
return result
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def get_video_landmarks(video_path):
|
| 255 |
+
"""
|
| 256 |
+
Procesa un video y extrae los puntos faciales clave (optimizado).
|
| 257 |
+
|
| 258 |
+
Args:
|
| 259 |
+
video_path (str): Ruta al archivo de video.
|
| 260 |
+
|
| 261 |
+
Returns:
|
| 262 |
+
pd.DataFrame: Un DataFrame de pandas con los puntos clave por fotograma.
|
| 263 |
+
"""
|
| 264 |
+
cap = cv2.VideoCapture(video_path)
|
| 265 |
+
if not cap.isOpened():
|
| 266 |
+
logger.error(f"No se pudo abrir el video: {video_path}")
|
| 267 |
+
return None
|
| 268 |
+
|
| 269 |
+
# Obtener información del video
|
| 270 |
+
fps = cap.get(cv2.CAP_PROP_FPS)
|
| 271 |
+
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
| 272 |
+
|
| 273 |
+
logger.info(f"Procesando video: {total_frames} frames a {fps} FPS")
|
| 274 |
+
|
| 275 |
+
data = []
|
| 276 |
+
frame_count = 0
|
| 277 |
+
processed_frames = 0
|
| 278 |
+
|
| 279 |
+
# Procesar cada frame
|
| 280 |
+
while cap.isOpened():
|
| 281 |
+
success, image = cap.read()
|
| 282 |
+
if not success:
|
| 283 |
+
break
|
| 284 |
+
|
| 285 |
+
# Preprocesar imagen
|
| 286 |
+
image.flags.writeable = False
|
| 287 |
+
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
| 288 |
+
results = face_mesh.process(image)
|
| 289 |
+
|
| 290 |
+
# Extraer datos del frame
|
| 291 |
+
row_data = {'frame': frame_count, 'fps': fps}
|
| 292 |
+
|
| 293 |
+
if results.multi_face_landmarks:
|
| 294 |
+
landmarks_data = extract_required_landmarks(results.multi_face_landmarks[0])
|
| 295 |
+
row_data.update(landmarks_data)
|
| 296 |
+
processed_frames += 1
|
| 297 |
+
else:
|
| 298 |
+
# Llenar con NaN si no se detecta cara
|
| 299 |
+
for category, indices in REQUIRED_LANDMARKS.items():
|
| 300 |
+
for i in indices:
|
| 301 |
+
row_data[f'x_{i}'] = np.nan
|
| 302 |
+
row_data[f'y_{i}'] = np.nan
|
| 303 |
+
|
| 304 |
+
data.append(row_data)
|
| 305 |
+
frame_count += 1
|
| 306 |
+
|
| 307 |
+
# Log progreso cada 100 frames
|
| 308 |
+
if frame_count % 100 == 0:
|
| 309 |
+
logger.info(f"Procesados {frame_count}/{total_frames} frames")
|
| 310 |
+
|
| 311 |
+
cap.release()
|
| 312 |
+
|
| 313 |
+
if processed_frames == 0:
|
| 314 |
+
logger.warning("No se detectaron caras en ningún frame")
|
| 315 |
+
return None
|
| 316 |
+
|
| 317 |
+
logger.info(f"Análisis completado: {processed_frames}/{frame_count} frames con detección facial")
|
| 318 |
+
return pd.DataFrame(data)
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def calculate_metrics(df):
|
| 322 |
+
"""
|
| 323 |
+
Calcula las métricas de comportamiento a partir del DataFrame de puntos.
|
| 324 |
+
|
| 325 |
+
Args:
|
| 326 |
+
df (pd.DataFrame): DataFrame con los puntos faciales por fotograma.
|
| 327 |
+
|
| 328 |
+
Returns:
|
| 329 |
+
dict: Diccionario con todas las métricas calculadas.
|
| 330 |
+
"""
|
| 331 |
+
if df.empty or 'fps' not in df.columns:
|
| 332 |
+
return {'error': 'DataFrame vacío o sin información de FPS'}
|
| 333 |
+
|
| 334 |
+
total_frames = len(df)
|
| 335 |
+
video_fps = df['fps'].iloc[0] if not df['fps'].isna().all() else 30
|
| 336 |
+
total_video_time_seconds = total_frames / video_fps if total_frames > 0 else 0
|
| 337 |
+
|
| 338 |
+
# Filtrar frames válidos (con detección facial)
|
| 339 |
+
valid_frames = df.dropna()
|
| 340 |
+
if valid_frames.empty:
|
| 341 |
+
return {'error': 'No hay frames válidos con detección facial'}
|
| 342 |
+
|
| 343 |
+
# === ANÁLISIS DE SONRISAS ===
|
| 344 |
+
try:
|
| 345 |
+
y1, y2 = valid_frames['y_13'], valid_frames['y_14']
|
| 346 |
+
x1, x2 = valid_frames['x_61'], valid_frames['x_291']
|
| 347 |
+
|
| 348 |
+
# Calcular MAR (Mouth Aspect Ratio)
|
| 349 |
+
distancia_vertical = np.abs(y1 - y2)
|
| 350 |
+
distancia_horizontal = np.abs(x1 - x2)
|
| 351 |
+
mar = distancia_vertical / (distancia_horizontal + 1e-6)
|
| 352 |
+
|
| 353 |
+
# Detectar sonrisas
|
| 354 |
+
smiles = []
|
| 355 |
+
is_smiling = False
|
| 356 |
+
start_frame = 0
|
| 357 |
+
smile_threshold = 0.2
|
| 358 |
+
|
| 359 |
+
for i, (idx, row) in enumerate(valid_frames.iterrows()):
|
| 360 |
+
current_mar = mar.iloc[i]
|
| 361 |
+
current_frame = row['frame']
|
| 362 |
+
|
| 363 |
+
if current_mar > smile_threshold and not is_smiling:
|
| 364 |
+
is_smiling = True
|
| 365 |
+
start_frame = current_frame
|
| 366 |
+
elif current_mar <= smile_threshold and is_smiling:
|
| 367 |
+
is_smiling = False
|
| 368 |
+
end_frame = current_frame
|
| 369 |
+
duration = (end_frame - start_frame) / video_fps
|
| 370 |
+
if duration > 0.1: # Filtrar sonrisas muy cortas
|
| 371 |
+
smiles.append(duration)
|
| 372 |
+
|
| 373 |
+
total_smiles = len(smiles)
|
| 374 |
+
avg_smile_duration = np.mean(smiles) if smiles else 0
|
| 375 |
+
total_smile_time = sum(smiles) if smiles else 0
|
| 376 |
+
|
| 377 |
+
except Exception as e:
|
| 378 |
+
logger.error(f"Error en análisis de sonrisas: {e}")
|
| 379 |
+
total_smiles = 0
|
| 380 |
+
avg_smile_duration = 0
|
| 381 |
+
total_smile_time = 0
|
| 382 |
+
|
| 383 |
+
# === ANÁLISIS DE PARPADEOS ===
|
| 384 |
+
try:
|
| 385 |
+
# Calcular EAR (Eye Aspect Ratio)
|
| 386 |
+
dist_vert = np.abs(valid_frames['y_386'] - valid_frames['y_374'])
|
| 387 |
+
dist_horiz = np.abs(valid_frames['x_362'] - valid_frames['x_263'])
|
| 388 |
+
ear = dist_vert / (dist_horiz + 1e-6)
|
| 389 |
+
|
| 390 |
+
# Detectar parpadeos
|
| 391 |
+
blinks = 0
|
| 392 |
+
blink_threshold = 0.25
|
| 393 |
+
min_blink_frames = 2
|
| 394 |
+
blinks_consecutive_frames = 0
|
| 395 |
+
|
| 396 |
+
for value in ear:
|
| 397 |
+
if value < blink_threshold:
|
| 398 |
+
blinks_consecutive_frames += 1
|
| 399 |
+
else:
|
| 400 |
+
if blinks_consecutive_frames >= min_blink_frames:
|
| 401 |
+
blinks += 1
|
| 402 |
+
blinks_consecutive_frames = 0
|
| 403 |
+
|
| 404 |
+
blink_rate_per_minute = (blinks / total_video_time_seconds) * 60 if total_video_time_seconds > 0 else 0
|
| 405 |
+
|
| 406 |
+
except Exception as e:
|
| 407 |
+
logger.error(f"Error en análisis de parpadeos: {e}")
|
| 408 |
+
blinks = 0
|
| 409 |
+
blink_rate_per_minute = 0
|
| 410 |
+
|
| 411 |
+
# === ANÁLISIS DE MOVIMIENTO DE CABEZA ===
|
| 412 |
+
try:
|
| 413 |
+
movimiento_vertical_cabeza = valid_frames['y_4'] - valid_frames['y_152']
|
| 414 |
+
movimiento_horizontal_cabeza = valid_frames['x_234'] - valid_frames['x_454']
|
| 415 |
+
|
| 416 |
+
std_vertical_head = np.std(movimiento_vertical_cabeza)
|
| 417 |
+
std_horizontal_head = np.std(movimiento_horizontal_cabeza)
|
| 418 |
+
|
| 419 |
+
# Calcular movimiento total
|
| 420 |
+
total_head_movement = np.sqrt(std_vertical_head**2 + std_horizontal_head**2)
|
| 421 |
+
|
| 422 |
+
except Exception as e:
|
| 423 |
+
logger.error(f"Error en análisis de movimiento de cabeza: {e}")
|
| 424 |
+
std_vertical_head = 0
|
| 425 |
+
std_horizontal_head = 0
|
| 426 |
+
total_head_movement = 0
|
| 427 |
+
|
| 428 |
+
# === ANÁLISIS DE EMOCIONES NEGATIVAS ===
|
| 429 |
+
try:
|
| 430 |
+
# Cejas (diferencia vertical entre 107 y 336 para enojo/furioso)
|
| 431 |
+
brow_diff = np.abs(valid_frames['y_107'] - valid_frames['y_336'])
|
| 432 |
+
# Boca (usamos MAR ajustado para tensión)
|
| 433 |
+
mar_tension = mar # Reusamos MAR pero con umbral diferente
|
| 434 |
+
# Gruñón (diferencia vertical entre 17 y 268, boca neutral)
|
| 435 |
+
mouth_neutral = np.abs(valid_frames['y_17'] - valid_frames['y_268'])
|
| 436 |
+
|
| 437 |
+
# Detectar enojado (cejas fruncidas + boca tensa)
|
| 438 |
+
angry_events = []
|
| 439 |
+
is_angry = False
|
| 440 |
+
start_frame = 0
|
| 441 |
+
angry_brow_threshold = 0.05 # Umbral para cejas fruncidas
|
| 442 |
+
angry_mar_threshold = 0.15 # Umbral para boca tensa
|
| 443 |
+
for i, (idx, row) in enumerate(valid_frames.iterrows()):
|
| 444 |
+
current_brow = brow_diff.iloc[i]
|
| 445 |
+
current_mar = mar_tension.iloc[i]
|
| 446 |
+
current_frame = row['frame']
|
| 447 |
+
if current_brow < angry_brow_threshold and current_mar < angry_mar_threshold and not is_angry:
|
| 448 |
+
is_angry = True
|
| 449 |
+
start_frame = current_frame
|
| 450 |
+
elif (current_brow >= angry_brow_threshold or current_mar >= angry_mar_threshold) and is_angry:
|
| 451 |
+
is_angry = False
|
| 452 |
+
end_frame = current_frame
|
| 453 |
+
duration = (end_frame - start_frame) / video_fps
|
| 454 |
+
if duration > 0.1:
|
| 455 |
+
angry_events.append(duration)
|
| 456 |
+
|
| 457 |
+
total_angry = len(angry_events)
|
| 458 |
+
avg_angry_duration = np.mean(angry_events) if angry_events else 0
|
| 459 |
+
total_angry_time = sum(angry_events) if angry_events else 0
|
| 460 |
+
|
| 461 |
+
# Detectar gruñón (cejas ligeramente fruncidas + boca neutral)
|
| 462 |
+
grumpy_events = []
|
| 463 |
+
is_grumpy = False
|
| 464 |
+
start_frame = 0
|
| 465 |
+
grumpy_brow_threshold = 0.08 # Umbral más suave
|
| 466 |
+
grumpy_mouth_threshold = 0.03 # Diferencia vertical baja para neutral
|
| 467 |
+
for i, (idx, row) in enumerate(valid_frames.iterrows()):
|
| 468 |
+
current_brow = brow_diff.iloc[i]
|
| 469 |
+
current_mouth = mouth_neutral.iloc[i]
|
| 470 |
+
current_frame = row['frame']
|
| 471 |
+
if current_brow < grumpy_brow_threshold and current_mouth < grumpy_mouth_threshold and not is_grumpy:
|
| 472 |
+
is_grumpy = True
|
| 473 |
+
start_frame = current_frame
|
| 474 |
+
elif (current_brow >= grumpy_brow_threshold or current_mouth >= grumpy_mouth_threshold) and is_grumpy:
|
| 475 |
+
is_grumpy = False
|
| 476 |
+
end_frame = current_frame
|
| 477 |
+
duration = (end_frame - start_frame) / video_fps
|
| 478 |
+
if duration > 0.1:
|
| 479 |
+
grumpy_events.append(duration)
|
| 480 |
+
|
| 481 |
+
total_grumpy = len(grumpy_events)
|
| 482 |
+
avg_grumpy_duration = np.mean(grumpy_events) if grumpy_events else 0
|
| 483 |
+
total_grumpy_time = sum(grumpy_events) if grumpy_events else 0
|
| 484 |
+
|
| 485 |
+
# Detectar furioso (cejas muy fruncidas + boca tensa intensa)
|
| 486 |
+
furious_events = []
|
| 487 |
+
is_furious = False
|
| 488 |
+
start_frame = 0
|
| 489 |
+
furious_brow_threshold = 0.03 # Umbral más estricto
|
| 490 |
+
furious_mar_threshold = 0.10 # Umbral más bajo para tensión fuerte
|
| 491 |
+
for i, (idx, row) in enumerate(valid_frames.iterrows()):
|
| 492 |
+
current_brow = brow_diff.iloc[i]
|
| 493 |
+
current_mar = mar_tension.iloc[i]
|
| 494 |
+
current_frame = row['frame']
|
| 495 |
+
if current_brow < furious_brow_threshold and current_mar < furious_mar_threshold and not is_furious:
|
| 496 |
+
is_furious = True
|
| 497 |
+
start_frame = current_frame
|
| 498 |
+
elif (current_brow >= furious_brow_threshold or current_mar >= furious_mar_threshold) and is_furious:
|
| 499 |
+
is_furious = False
|
| 500 |
+
end_frame = current_frame
|
| 501 |
+
duration = (end_frame - start_frame) / video_fps
|
| 502 |
+
if duration > 0.1:
|
| 503 |
+
furious_events.append(duration)
|
| 504 |
+
|
| 505 |
+
total_furious = len(furious_events)
|
| 506 |
+
avg_furious_duration = np.mean(furious_events) if furious_events else 0
|
| 507 |
+
total_furious_time = sum(furious_events) if furious_events else 0
|
| 508 |
+
|
| 509 |
+
except Exception as e:
|
| 510 |
+
logger.error(f"Error en análisis de emociones negativas: {e}")
|
| 511 |
+
total_angry = 0
|
| 512 |
+
avg_angry_duration = 0
|
| 513 |
+
total_angry_time = 0
|
| 514 |
+
total_grumpy = 0
|
| 515 |
+
avg_grumpy_duration = 0
|
| 516 |
+
total_grumpy_time = 0
|
| 517 |
+
total_furious = 0
|
| 518 |
+
avg_furious_duration = 0
|
| 519 |
+
total_furious_time = 0
|
| 520 |
+
|
| 521 |
+
# === MÉTRICAS ADICIONALES ===
|
| 522 |
+
face_detection_rate = len(valid_frames) / total_frames * 100
|
| 523 |
+
|
| 524 |
+
return {
|
| 525 |
+
'video_info': {
|
| 526 |
+
'duracion_total': round(total_video_time_seconds, 2),
|
| 527 |
+
'fps': round(video_fps, 1),
|
| 528 |
+
'frames_totales': total_frames,
|
| 529 |
+
'frames_con_deteccion': len(valid_frames),
|
| 530 |
+
'tasa_deteccion_facial': round(face_detection_rate, 2)
|
| 531 |
+
},
|
| 532 |
+
'sonrisas': {
|
| 533 |
+
'total_sonrisas': total_smiles,
|
| 534 |
+
'duracion_promedio_sonrisas': round(avg_smile_duration, 2),
|
| 535 |
+
'tiempo_total_sonriendo': round(total_smile_time, 2),
|
| 536 |
+
'porcentaje_tiempo_sonriendo': round((total_smile_time / total_video_time_seconds) * 100, 2) if total_video_time_seconds > 0 else 0
|
| 537 |
+
},
|
| 538 |
+
'parpadeos': {
|
| 539 |
+
'total_parpadeos': blinks,
|
| 540 |
+
'tasa_parpadeo_por_minuto': round(blink_rate_per_minute, 2)
|
| 541 |
+
},
|
| 542 |
+
'movimiento_cabeza': {
|
| 543 |
+
'estabilidad_vertical': round(std_vertical_head, 4),
|
| 544 |
+
'estabilidad_horizontal': round(std_horizontal_head, 4),
|
| 545 |
+
'movimiento_total': round(total_head_movement, 4)
|
| 546 |
+
},
|
| 547 |
+
'emociones_negativas': {
|
| 548 |
+
'total_enojado': total_angry,
|
| 549 |
+
'duracion_promedio_enojado': round(avg_angry_duration, 2),
|
| 550 |
+
'porcentaje_tiempo_enojado': round((total_angry_time / total_video_time_seconds) * 100, 2) if total_video_time_seconds > 0 else 0,
|
| 551 |
+
'total_grunon': total_grumpy,
|
| 552 |
+
'duracion_promedio_grunon': round(avg_grumpy_duration, 2),
|
| 553 |
+
'porcentaje_tiempo_grunon': round((total_grumpy_time / total_video_time_seconds) * 100, 2) if total_video_time_seconds > 0 else 0,
|
| 554 |
+
'total_furioso': total_furious,
|
| 555 |
+
'duracion_promedio_furioso': round(avg_furious_duration, 2),
|
| 556 |
+
'porcentaje_tiempo_furioso': round((total_furious_time / total_video_time_seconds) * 100, 2) if total_video_time_seconds > 0 else 0
|
| 557 |
+
}
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
|
| 561 |
+
@app.route('/', methods=['GET'])
|
| 562 |
+
def index():
|
| 563 |
+
return render_template('index.html')
|
| 564 |
+
|
| 565 |
+
|
| 566 |
+
@app.route('/test', methods=['GET', 'POST'])
|
| 567 |
+
def test():
|
| 568 |
+
"""Ruta de prueba para debugging."""
|
| 569 |
+
if request.method == 'GET':
|
| 570 |
+
return jsonify({'status': 'success', 'message': 'Test endpoint working'})
|
| 571 |
+
else:
|
| 572 |
+
data = request.get_json() if request.is_json else request.form.to_dict()
|
| 573 |
+
return jsonify({'status': 'success', 'received_data': data})
|
| 574 |
+
|
| 575 |
+
|
| 576 |
+
@app.route('/health', methods=['GET'])
|
| 577 |
+
def health():
|
| 578 |
+
"""Health check endpoint."""
|
| 579 |
+
return jsonify({'status': 'healthy', 'message': 'Server is running'})
|
| 580 |
+
|
| 581 |
+
|
| 582 |
+
@app.before_request
|
| 583 |
+
def log_request_info():
|
| 584 |
+
"""Log información de cada request."""
|
| 585 |
+
logger.info(f"Request: {request.method} {request.url}")
|
| 586 |
+
logger.info(f"Headers: {dict(request.headers)}")
|
| 587 |
+
if request.method == 'POST':
|
| 588 |
+
logger.info(f"Content-Type: {request.content_type}")
|
| 589 |
+
logger.info(f"Content-Length: {request.content_length}")
|
| 590 |
+
|
| 591 |
+
|
| 592 |
+
@app.route('/analyze', methods=['POST'])
|
| 593 |
+
def analyze():
|
| 594 |
+
try:
|
| 595 |
+
# Intentar obtener datos del form primero, luego JSON
|
| 596 |
+
if request.content_type and 'application/json' in request.content_type:
|
| 597 |
+
data = request.get_json()
|
| 598 |
+
youtube_url = data.get('youtube_url', '').strip() if data else ''
|
| 599 |
+
else:
|
| 600 |
+
youtube_url = request.form.get('youtube_url', '').strip()
|
| 601 |
+
|
| 602 |
+
logger.info(f"Received URL: {youtube_url}")
|
| 603 |
+
logger.info(f"Content-Type: {request.content_type}")
|
| 604 |
+
|
| 605 |
+
# Validaciones de entrada
|
| 606 |
+
if not youtube_url:
|
| 607 |
+
return jsonify({
|
| 608 |
+
'status': 'error',
|
| 609 |
+
'message': 'Por favor, introduce una URL de YouTube válida.'
|
| 610 |
+
}), 400
|
| 611 |
+
|
| 612 |
+
if 'youtube.com' not in youtube_url and 'youtu.be' not in youtube_url:
|
| 613 |
+
return jsonify({
|
| 614 |
+
'status': 'error',
|
| 615 |
+
'message': 'La URL debe ser de YouTube.'
|
| 616 |
+
}), 400
|
| 617 |
+
|
| 618 |
+
except Exception as e:
|
| 619 |
+
logger.error(f"Error procesando request: {e}")
|
| 620 |
+
return jsonify({
|
| 621 |
+
'status': 'error',
|
| 622 |
+
'message': 'Error procesando la petición.'
|
| 623 |
+
}), 400
|
| 624 |
+
|
| 625 |
+
video_path = None
|
| 626 |
+
try:
|
| 627 |
+
logger.info(f"Iniciando análisis de: {youtube_url}")
|
| 628 |
+
|
| 629 |
+
# Descargar video usando método robusto
|
| 630 |
+
video_path, video_info = download_youtube_video(youtube_url, app.config['UPLOAD_FOLDER'])
|
| 631 |
+
|
| 632 |
+
if video_path is None:
|
| 633 |
+
return jsonify({
|
| 634 |
+
'status': 'error',
|
| 635 |
+
'message': video_info # video_info contiene el mensaje de error
|
| 636 |
+
})
|
| 637 |
+
|
| 638 |
+
logger.info(f"Video descargado: {video_info['title']}")
|
| 639 |
+
|
| 640 |
+
# Validar duración
|
| 641 |
+
if video_info['duration'] > 600: # 10 minutos
|
| 642 |
+
return jsonify({
|
| 643 |
+
'status': 'error',
|
| 644 |
+
'message': f'El video es demasiado largo ({video_info["duration"]//60} min). Máximo 10 minutos permitidos.'
|
| 645 |
+
})
|
| 646 |
+
|
| 647 |
+
# Verificar que el archivo se descargó correctamente
|
| 648 |
+
if not os.path.exists(video_path):
|
| 649 |
+
return jsonify({
|
| 650 |
+
'status': 'error',
|
| 651 |
+
'message': 'Error al descargar el video.'
|
| 652 |
+
})
|
| 653 |
+
|
| 654 |
+
logger.info("Video descargado. Iniciando análisis facial...")
|
| 655 |
+
|
| 656 |
+
# Procesar video
|
| 657 |
+
landmarks_df = get_video_landmarks(video_path)
|
| 658 |
+
|
| 659 |
+
if landmarks_df is None or landmarks_df.empty:
|
| 660 |
+
return jsonify({
|
| 661 |
+
'status': 'error',
|
| 662 |
+
'message': 'No se pudieron extraer los puntos faciales. Asegúrate de que el video muestre claramente una cara.'
|
| 663 |
+
})
|
| 664 |
+
|
| 665 |
+
logger.info("Análisis facial completado. Calculando métricas...")
|
| 666 |
+
|
| 667 |
+
# Calcular métricas
|
| 668 |
+
report_data = calculate_metrics(landmarks_df)
|
| 669 |
+
|
| 670 |
+
if 'error' in report_data:
|
| 671 |
+
return jsonify({
|
| 672 |
+
'status': 'error',
|
| 673 |
+
'message': f'Error en el cálculo de métricas: {report_data["error"]}'
|
| 674 |
+
})
|
| 675 |
+
|
| 676 |
+
# Agregar información del video
|
| 677 |
+
report_data['video_title'] = video_info['title']
|
| 678 |
+
report_data['video_url'] = youtube_url
|
| 679 |
+
|
| 680 |
+
logger.info("Análisis completado exitosamente")
|
| 681 |
+
return jsonify({'status': 'success', 'data': report_data})
|
| 682 |
+
|
| 683 |
+
except Exception as e:
|
| 684 |
+
logger.error(f"Error inesperado: {e}")
|
| 685 |
+
return jsonify({
|
| 686 |
+
'status': 'error',
|
| 687 |
+
'message': f'Error inesperado: {str(e)}'
|
| 688 |
+
})
|
| 689 |
+
finally:
|
| 690 |
+
# Limpiar archivo temporal
|
| 691 |
+
if video_path and os.path.exists(video_path):
|
| 692 |
+
try:
|
| 693 |
+
os.remove(video_path)
|
| 694 |
+
logger.info("Archivo temporal eliminado")
|
| 695 |
+
except Exception as e:
|
| 696 |
+
logger.error(f"Error eliminando archivo temporal: {e}")
|
| 697 |
+
|
| 698 |
+
|
| 699 |
+
@app.errorhandler(413)
|
| 700 |
+
def too_large(e):
|
| 701 |
+
return jsonify({
|
| 702 |
+
'status': 'error',
|
| 703 |
+
'message': 'El archivo es demasiado grande.'
|
| 704 |
+
}), 413
|
| 705 |
+
|
| 706 |
+
|
| 707 |
+
@app.errorhandler(500)
|
| 708 |
+
def internal_error(e):
|
| 709 |
+
return jsonify({
|
| 710 |
+
'status': 'error',
|
| 711 |
+
'message': 'Error interno del servidor.'
|
| 712 |
+
}), 500
|
| 713 |
+
|
| 714 |
+
|
| 715 |
+
if __name__ == '__main__':
|
| 716 |
+
app.run(debug=True, host='0.0.0.0', port=5000)
|