Denisijcu commited on
Commit
b83c09e
·
verified ·
1 Parent(s): d8d9acc

upload files

Browse files
Files changed (3) hide show
  1. .dockerignore +8 -0
  2. Dockerfile +29 -0
  3. 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)