VeuReu commited on
Commit
c73374c
·
verified ·
1 Parent(s): 46cf14b

Upload 2 files

Browse files
Files changed (2) hide show
  1. api.py +0 -0
  2. character_detection.py +477 -476
api.py CHANGED
The diff for this file is too large to render. See raw diff
 
character_detection.py CHANGED
@@ -1,476 +1,477 @@
1
- """
2
- Character Detection Module
3
- Integra el trabajo de Ana para detección de personajes mediante:
4
- 1. Extracción de caras y embeddings
5
- 2. Extracción de voces y embeddings
6
- 3. Clustering jerárquico aglomerativo
7
- 4. Generación de carpetas por personaje
8
- """
9
- import cv2
10
- import os
11
- import json
12
- import logging
13
- import shutil
14
- from pathlib import Path
15
- import numpy as np
16
- from scipy.cluster.hierarchy import linkage, fcluster
17
- from collections import Counter
18
- from typing import List, Dict, Any, Tuple
19
-
20
- # Imports de las herramientas de vision y audio desde los módulos de la raíz
21
- try:
22
- # DeepFace para detección y embeddings de caras
23
- from deepface import DeepFace
24
- DEEPFACE_AVAILABLE = True
25
- except Exception as e:
26
- DEEPFACE_AVAILABLE = False
27
- logging.warning(f"DeepFace no disponible: {e}")
28
-
29
- logging.basicConfig(level=logging.INFO)
30
- logger = logging.getLogger(__name__)
31
-
32
-
33
- class CharacterDetector:
34
- """
35
- Detector de personajes que integra el trabajo de Ana.
36
- """
37
-
38
- def __init__(self, video_path: str, output_base: Path, video_name: str = None):
39
- """
40
- Args:
41
- video_path: Ruta al archivo de vídeo
42
- output_base: Directorio base para guardar resultados (ej: /tmp/temp/video_name)
43
- video_name: Nombre del vídeo (para construir URLs)
44
- """
45
- self.video_path = video_path
46
- self.output_base = Path(output_base)
47
- self.output_base.mkdir(parents=True, exist_ok=True)
48
- self.video_name = video_name or self.output_base.name
49
-
50
- # Crear subdirectorios
51
- self.faces_dir = self.output_base / "faces"
52
- self.voices_dir = self.output_base / "voices"
53
- self.scenes_dir = self.output_base / "scenes"
54
-
55
- for d in [self.faces_dir, self.voices_dir, self.scenes_dir]:
56
- d.mkdir(parents=True, exist_ok=True)
57
-
58
- def extract_faces_embeddings(self, *, start_offset_sec: float = 3.0, extract_every_sec: float = 0.5,
59
- detector_backend: str = 'retinaface', min_face_area: int = 100,
60
- enforce_detection: bool = False) -> List[Dict[str, Any]]:
61
- """
62
- Extrae caras del vídeo y calcula sus embeddings usando DeepFace directamente.
63
-
64
- Returns:
65
- Lista de dicts con {"embeddings": [...], "path": "..."}
66
- """
67
- if not DEEPFACE_AVAILABLE:
68
- logger.warning("DeepFace no disponible, retornando lista vacía")
69
- return []
70
-
71
- logger.info("Extrayendo caras del vídeo con DeepFace...")
72
-
73
- extract_every = float(extract_every_sec)
74
- video = cv2.VideoCapture(self.video_path)
75
- fps = int(video.get(cv2.CAP_PROP_FPS))
76
- total_frames = int(video.get(cv2.CAP_PROP_FRAME_COUNT))
77
- frame_interval = int(fps * extract_every)
78
- frame_count = 0
79
- saved_count = 0
80
- start_frame = int(max(0.0, start_offset_sec) * (fps if fps > 0 else 25))
81
-
82
- embeddings_caras = []
83
-
84
- logger.info(f"Total frames: {total_frames}, FPS: {fps}, Procesando cada {frame_interval} frames")
85
-
86
- while True:
87
- ret, frame = video.read()
88
- if not ret:
89
- break
90
-
91
- if frame_count < start_frame:
92
- frame_count += 1
93
- continue
94
-
95
- if frame_count % frame_interval == 0:
96
- temp_path = self.faces_dir / "temp_frame.jpg"
97
- cv2.imwrite(str(temp_path), frame)
98
-
99
- try:
100
- # Extraer embeddings con DeepFace
101
- # represent() devuelve una lista de dicts, uno por cada cara detectada
102
- face_objs = DeepFace.represent(
103
- img_path=str(temp_path),
104
- model_name='Facenet512',
105
- detector_backend=detector_backend,
106
- enforce_detection=enforce_detection
107
- )
108
-
109
- if face_objs:
110
- for i, face_obj in enumerate(face_objs):
111
- embedding = face_obj['embedding']
112
- facial_area = face_obj.get('facial_area', {})
113
- try:
114
- w = int(facial_area.get('w', 0))
115
- h = int(facial_area.get('h', 0))
116
- if w * h < int(min_face_area):
117
- continue
118
- except Exception:
119
- pass
120
-
121
- # Guardar recorte de la cara (mejor para UI y clustering visual)
122
- x = int(facial_area.get('x', 0)); y = int(facial_area.get('y', 0))
123
- w = int(facial_area.get('w', 0)); h = int(facial_area.get('h', 0))
124
- x2 = max(0, x); y2 = max(0, y)
125
- x3 = min(frame.shape[1], x + w); y3 = min(frame.shape[0], y + h)
126
- crop = frame[y2:y3, x2:x3] if (x3 > x2 and y3 > y2) else frame
127
- save_path = self.faces_dir / f"face_{saved_count:04d}.jpg"
128
- cv2.imwrite(str(save_path), crop)
129
-
130
- embeddings_caras.append({
131
- "embeddings": embedding,
132
- "path": str(save_path),
133
- "frame": frame_count,
134
- "facial_area": facial_area
135
- })
136
- saved_count += 1
137
-
138
- if frame_count % (frame_interval * 10) == 0:
139
- logger.info(f"Progreso: frame {frame_count}/{total_frames}, caras detectadas: {saved_count}")
140
-
141
- except Exception as e:
142
- logger.debug(f"No se detectaron caras en frame {frame_count}: {e}")
143
-
144
- if temp_path.exists():
145
- os.remove(temp_path)
146
-
147
- frame_count += 1
148
-
149
- video.release()
150
- logger.info(f"✓ Caras extraídas: {len(embeddings_caras)}")
151
- return embeddings_caras
152
-
153
- def extract_voices_embeddings(self) -> List[Dict[str, Any]]:
154
- """
155
- Extrae voces del vídeo y calcula sus embeddings.
156
- Por ahora retorna lista vacía (funcionalidad opcional).
157
-
158
- Returns:
159
- Lista de dicts con {"embeddings": [...], "path": "..."}
160
- """
161
- logger.info("Extracción de voces deshabilitada temporalmente")
162
- return []
163
-
164
- def extract_scenes_embeddings(self) -> List[Dict[str, Any]]:
165
- """
166
- Extrae escenas clave del vídeo.
167
- Por ahora retorna lista vacía (funcionalidad opcional).
168
-
169
- Returns:
170
- Lista de dicts con {"embeddings": [...], "path": "..."}
171
- """
172
- logger.info("Extracción de escenas deshabilitada temporalmente")
173
- return []
174
-
175
- def cluster_faces(self, embeddings_caras: List[Dict], max_groups: int, min_samples: int) -> np.ndarray:
176
- """
177
- Agrupa caras similares usando clustering jerárquico aglomerativo con selección óptima.
178
- Selecciona automáticamente el mejor número de clusters usando silhouette score.
179
-
180
- Args:
181
- embeddings_caras: Lista de embeddings de caras
182
- max_groups: Número máximo de clusters a formar
183
- min_samples: Tamaño mínimo de cluster válido
184
-
185
- Returns:
186
- Array de labels (cluster asignado a cada cara, -1 para ruido)
187
- """
188
- if not embeddings_caras:
189
- return np.array([])
190
-
191
- logger.info(f"Clustering {len(embeddings_caras)} caras con max_groups={max_groups}, min_samples={min_samples}")
192
-
193
- # Extraer solo los embeddings
194
- X = np.array([cara['embeddings'] for cara in embeddings_caras])
195
-
196
- if len(X) < min_samples:
197
- # Si hay menos muestras que el mínimo, todo es ruido
198
- return np.full(len(X), -1, dtype=int)
199
-
200
- # Linkage usando average linkage (más flexible que ward, menos sensible a outliers)
201
- # Esto ayuda a agrupar mejor la misma persona con diferentes ángulos/expresiones
202
- Z = linkage(X, method='average', metric='cosine') # Cosine similarity para embeddings
203
-
204
- # Encontrar el número óptimo de clusters usando silhouette score
205
- from sklearn.metrics import silhouette_score
206
- best_n_clusters = 2
207
- best_score = -1
208
-
209
- max_to_try = min(max_groups, len(X) - 1)
210
-
211
- if max_to_try >= 2:
212
- for n_clusters in range(2, max_to_try + 1):
213
- trial_labels = fcluster(Z, t=n_clusters, criterion='maxclust') - 1
214
-
215
- trial_counts = Counter(trial_labels)
216
- valid_clusters = sum(1 for count in trial_counts.values() if count >= min_samples)
217
-
218
- if valid_clusters >= 2:
219
- try:
220
- score = silhouette_score(X, trial_labels, metric='cosine')
221
- # Penalización MUY fuerte para reducir duplicados de la misma persona
222
- # Valores: 0.05 = fuerte, 0.07 = muy fuerte, 0.10 = extremo
223
- adjusted_score = score - (n_clusters * 0.07)
224
-
225
- if adjusted_score > best_score:
226
- best_score = adjusted_score
227
- best_n_clusters = n_clusters
228
- except:
229
- pass
230
-
231
- logger.info(f"Clustering óptimo: {best_n_clusters} clusters (de máximo {max_groups}), silhouette: {best_score:.3f}")
232
- labels = fcluster(Z, t=best_n_clusters, criterion='maxclust') - 1
233
-
234
- # Filtrar clusters pequeños
235
- label_counts = Counter(labels)
236
- filtered_labels = []
237
- for lbl in labels:
238
- if label_counts[lbl] >= min_samples:
239
- filtered_labels.append(lbl)
240
- else:
241
- filtered_labels.append(-1)
242
- labels = np.array(filtered_labels, dtype=int)
243
-
244
- # Contar clusters (excluyendo ruido -1)
245
- n_clusters = len(set(labels)) - (1 if -1 in labels else 0)
246
- n_noise = list(labels).count(-1)
247
-
248
- logger.info(f"Clusters válidos encontrados: {n_clusters}, Ruido: {n_noise}")
249
- return labels
250
-
251
- def create_character_folders(self, embeddings_caras: List[Dict], labels: np.ndarray) -> List[Dict[str, Any]]:
252
- """
253
- Crea carpetas para cada personaje detectado, valida caras y guarda metadata.
254
- Integra validación con DeepFace para filtrar falsos positivos y detectar género.
255
-
256
- Args:
257
- embeddings_caras: Lista de embeddings de caras
258
- labels: Array de labels de clustering
259
-
260
- Returns:
261
- Lista de personajes detectados con metadata (solo clusters válidos)
262
- """
263
- from face_classifier import validate_and_classify_face, get_random_catalan_name_by_gender, FACE_CONFIDENCE_THRESHOLD
264
-
265
- characters_validated = []
266
-
267
- # Agrupar caras por cluster
268
- clusters = {}
269
- for idx, label in enumerate(labels):
270
- if label == -1: # Ignorar ruido
271
- continue
272
- if label not in clusters:
273
- clusters[label] = []
274
- clusters[label].append(idx)
275
-
276
- logger.info(f"Procesando {len(clusters)} clusters detectados...")
277
- original_cluster_count = len(clusters)
278
-
279
- # Procesar cada cluster
280
- for cluster_id, face_indices in clusters.items():
281
- char_id = f"char_{cluster_id:02d}"
282
-
283
- # PASO 1: Ordenar caras por score (usar área como proxy de calidad)
284
- # Caras más grandes = mejor detección
285
- face_detections = []
286
- for face_idx in face_indices:
287
- face_data = embeddings_caras[face_idx]
288
- facial_area = face_data.get('facial_area', {})
289
- w = facial_area.get('w', 0)
290
- h = facial_area.get('h', 0)
291
- area_score = w * h # Score basado en área
292
-
293
- face_detections.append({
294
- 'index': face_idx,
295
- 'score': area_score,
296
- 'facial_area': facial_area,
297
- 'path': face_data['path']
298
- })
299
-
300
- # Ordenar por score descendente (mejores primero)
301
- face_detections_sorted = sorted(
302
- face_detections,
303
- key=lambda x: x['score'],
304
- reverse=True
305
- )
306
-
307
- if not face_detections_sorted:
308
- logger.info(f"[VALIDATION] ✗ Cluster {char_id}: sense deteccions, eliminant")
309
- continue
310
-
311
- # PASO 2: Validar SOLO la mejor cara del cluster
312
- best_face = face_detections_sorted[0]
313
- best_face_path = best_face['path']
314
-
315
- logger.info(f"[VALIDATION] Cluster {char_id}: validant millor cara (score={best_face['score']:.0f}px²)")
316
-
317
- validation = validate_and_classify_face(best_face_path)
318
-
319
- if not validation:
320
- logger.info(f"[VALIDATION] ✗ Cluster {char_id}: error en validació, eliminant")
321
- continue
322
-
323
- # PASO 3: Verificar si és una cara vàlida
324
- if not validation['is_valid_face'] or validation['face_confidence'] < FACE_CONFIDENCE_THRESHOLD:
325
- logger.info(f"[VALIDATION] ✗ Cluster {char_id}: score baix ({validation['face_confidence']:.2f}), eliminant tot el clúster")
326
- continue
327
-
328
- # PASO 4: És una cara vàlida! Crear carpeta
329
- char_dir = self.output_base / char_id
330
- char_dir.mkdir(parents=True, exist_ok=True)
331
-
332
- # PASO 5: Limitar caras a mostrar (primera meitat + 1)
333
- total_faces = len(face_detections_sorted)
334
- max_faces_to_show = (total_faces // 2) + 1
335
- face_detections_limited = face_detections_sorted[:max_faces_to_show]
336
-
337
- # Copiar solo las caras limitadas
338
- face_files = []
339
- for i, face_det in enumerate(face_detections_limited):
340
- src_path = Path(face_det['path'])
341
- dst_path = char_dir / f"face_{i:03d}.jpg"
342
- if src_path.exists():
343
- shutil.copy(src_path, dst_path)
344
- face_files.append(f"/files/{self.video_name}/{char_id}/face_{i:03d}.jpg")
345
-
346
- # Imagen representativa (la mejor)
347
- representative_src = Path(best_face_path)
348
- representative_dst = char_dir / "representative.jpg"
349
- if representative_src.exists():
350
- shutil.copy(representative_src, representative_dst)
351
-
352
- # PASO 6: Generar nombre según género
353
- gender = validation['gender']
354
- character_name = get_random_catalan_name_by_gender(gender, char_id)
355
-
356
- # Metadata del personaje
357
- image_url = f"/files/{self.video_name}/{char_id}/representative.jpg"
358
-
359
- character_data = {
360
- "id": char_id,
361
- "name": character_name,
362
- "gender": gender,
363
- "gender_confidence": validation['gender_confidence'],
364
- "face_confidence": validation['face_confidence'],
365
- "man_prob": validation['man_prob'],
366
- "woman_prob": validation['woman_prob'],
367
- "image_path": str(representative_dst),
368
- "image_url": image_url,
369
- "face_files": face_files,
370
- "num_faces": len(face_detections_limited),
371
- "total_faces_detected": total_faces,
372
- "folder": str(char_dir)
373
- }
374
-
375
- characters_validated.append(character_data)
376
-
377
- logger.info(f"[VALIDATION] ✓ Cluster {char_id}: cara vàlida! "
378
- f"Nom={character_name}, Gender={gender} (conf={validation['gender_confidence']:.2f}), "
379
- f"Mostrant {len(face_detections_limited)}/{total_faces} cares")
380
-
381
- # Estadístiques finals
382
- eliminated_count = original_cluster_count - len(characters_validated)
383
- logger.info(f"[VALIDATION] Total: {len(characters_validated)} clústers vàlids "
384
- f"(eliminats {eliminated_count} falsos positius)")
385
-
386
- return characters_validated
387
-
388
- def save_analysis_json(self, embeddings_caras: List[Dict], embeddings_voices: List[Dict],
389
- embeddings_escenas: List[Dict]) -> Path:
390
- """
391
- Guarda el análisis completo en un archivo JSON.
392
- Similar al analysis.json de Ana.
393
-
394
- Returns:
395
- Path al archivo JSON guardado
396
- """
397
- analysis_data = {
398
- "caras": embeddings_caras,
399
- "voices": embeddings_voices,
400
- "escenas": embeddings_escenas
401
- }
402
-
403
- analysis_path = self.output_base / "analysis.json"
404
-
405
- try:
406
- with open(analysis_path, "w", encoding="utf-8") as f:
407
- json.dump(analysis_data, f, indent=2, ensure_ascii=False)
408
- logger.info(f"Analysis JSON guardado: {analysis_path}")
409
- except Exception as e:
410
- logger.warning(f"Error al guardar analysis JSON: {e}")
411
-
412
- return analysis_path
413
-
414
- def detect_characters(self, max_groups: int = 3, min_cluster_size: int = 3,
415
- *, start_offset_sec: float = 3.0, extract_every_sec: float = 0.5) -> Tuple[List[Dict], Path, np.ndarray, List[Dict[str, Any]]]:
416
- """
417
- Pipeline completo de detección de personajes con clustering jerárquico.
418
-
419
- Args:
420
- max_groups: Número máximo de clusters a formar
421
- min_cluster_size: Tamaño mínimo de cluster
422
-
423
- Returns:
424
- Tuple de (lista de personajes, path al analysis.json)
425
- """
426
- # 1. Extraer caras y embeddings
427
- embeddings_caras = self.extract_faces_embeddings(start_offset_sec=start_offset_sec, extract_every_sec=extract_every_sec)
428
-
429
- # 2. Extraer voces y embeddings (opcional, por ahora)
430
- embeddings_voices = self.extract_voices_embeddings()
431
-
432
- # 3. Extraer escenas y embeddings (opcional, por ahora)
433
- embeddings_escenas = self.extract_scenes_embeddings()
434
-
435
- # 4. Guardar análisis completo
436
- analysis_path = self.save_analysis_json(embeddings_caras, embeddings_voices, embeddings_escenas)
437
-
438
- # 5. Clustering de caras
439
- labels = self.cluster_faces(embeddings_caras, max_groups, min_cluster_size)
440
-
441
- # 6. Crear carpetas de personajes
442
- characters = self.create_character_folders(embeddings_caras, labels)
443
-
444
- return characters, analysis_path, labels, embeddings_caras
445
-
446
-
447
- # Función de conveniencia para usar en el API
448
- def detect_characters_from_video(video_path: str, output_base: str,
449
- max_groups: int = 3, min_cluster_size: int = 3,
450
- video_name: str = None,
451
- *, start_offset_sec: float = 3.0, extract_every_sec: float = 0.5) -> Dict[str, Any]:
452
- """
453
- Función de alto nivel para detectar personajes en un vídeo usando clustering jerárquico.
454
-
455
- Args:
456
- video_path: Ruta al vídeo
457
- output_base: Directorio base para guardar resultados
458
- max_groups: Número máximo de clusters a formar
459
- min_cluster_size: Tamaño mínimo de cluster
460
- video_name: Nombre del vídeo (para construir URLs)
461
-
462
- Returns:
463
- Dict con resultados: {"characters": [...], "analysis_path": "..."}
464
- """
465
- detector = CharacterDetector(video_path, Path(output_base), video_name=video_name)
466
- characters, analysis_path, labels, embeddings_caras = detector.detect_characters(max_groups, min_cluster_size,
467
- start_offset_sec=start_offset_sec,
468
- extract_every_sec=extract_every_sec)
469
-
470
- return {
471
- "characters": characters,
472
- "analysis_path": str(analysis_path),
473
- "num_characters": len(characters),
474
- "face_labels": labels.tolist() if isinstance(labels, np.ndarray) else list(labels),
475
- "num_face_embeddings": len(embeddings_caras)
476
- }
 
 
1
+ """
2
+ Character Detection Module
3
+ Integra el trabajo de Ana para detección de personajes mediante:
4
+ 1. Extracción de caras y embeddings
5
+ 2. Extracción de voces y embeddings
6
+ 3. Clustering jerárquico aglomerativo
7
+ 4. Generación de carpetas por personaje
8
+ """
9
+ import cv2
10
+ import os
11
+ import json
12
+ import logging
13
+ import shutil
14
+ from pathlib import Path
15
+ import numpy as np
16
+ from scipy.cluster.hierarchy import linkage, fcluster
17
+ from collections import Counter
18
+ from typing import List, Dict, Any, Tuple
19
+
20
+ # Imports de las herramientas de vision y audio desde los módulos de la raíz
21
+ try:
22
+ # DeepFace para detección y embeddings de caras
23
+ from deepface import DeepFace
24
+ DEEPFACE_AVAILABLE = True
25
+ except Exception as e:
26
+ DEEPFACE_AVAILABLE = False
27
+ logging.warning(f"DeepFace no disponible: {e}")
28
+
29
+ logging.basicConfig(level=logging.INFO)
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ class CharacterDetector:
34
+ """
35
+ Detector de personajes que integra el trabajo de Ana.
36
+ """
37
+
38
+ def __init__(self, video_path: str, output_base: Path, video_name: str = None):
39
+ """
40
+ Args:
41
+ video_path: Ruta al archivo de vídeo
42
+ output_base: Directorio base para guardar resultados (ej: /tmp/temp/video_name)
43
+ video_name: Nombre del vídeo (para construir URLs)
44
+ """
45
+ self.video_path = video_path
46
+ self.output_base = Path(output_base)
47
+ self.output_base.mkdir(parents=True, exist_ok=True)
48
+ self.video_name = video_name or self.output_base.name
49
+
50
+ # Crear subdirectorios
51
+ self.faces_dir = self.output_base / "faces"
52
+ self.voices_dir = self.output_base / "voices"
53
+ self.scenes_dir = self.output_base / "scenes"
54
+
55
+ for d in [self.faces_dir, self.voices_dir, self.scenes_dir]:
56
+ d.mkdir(parents=True, exist_ok=True)
57
+
58
+ def extract_faces_embeddings(self, *, start_offset_sec: float = 3.0, extract_every_sec: float = 0.5,
59
+ detector_backend: str = 'retinaface', min_face_area: int = 100,
60
+ enforce_detection: bool = False) -> List[Dict[str, Any]]:
61
+ """
62
+ Extrae caras del vídeo y calcula sus embeddings usando DeepFace directamente.
63
+
64
+ Returns:
65
+ Lista de dicts con {"embeddings": [...], "path": "..."}
66
+ """
67
+ if not DEEPFACE_AVAILABLE:
68
+ logger.warning("DeepFace no disponible, retornando lista vacía")
69
+ return []
70
+
71
+ logger.info("Extrayendo caras del vídeo con DeepFace...")
72
+
73
+ extract_every = float(extract_every_sec)
74
+ video = cv2.VideoCapture(self.video_path)
75
+ fps = int(video.get(cv2.CAP_PROP_FPS))
76
+ total_frames = int(video.get(cv2.CAP_PROP_FRAME_COUNT))
77
+ frame_interval = int(fps * extract_every)
78
+ frame_count = 0
79
+ saved_count = 0
80
+ start_frame = int(max(0.0, start_offset_sec) * (fps if fps > 0 else 25))
81
+
82
+ embeddings_caras = []
83
+
84
+ logger.info(f"Total frames: {total_frames}, FPS: {fps}, Procesando cada {frame_interval} frames")
85
+
86
+ while True:
87
+ ret, frame = video.read()
88
+ if not ret:
89
+ break
90
+
91
+ if frame_count < start_frame:
92
+ frame_count += 1
93
+ continue
94
+
95
+ if frame_count % frame_interval == 0:
96
+ temp_path = self.faces_dir / "temp_frame.jpg"
97
+ cv2.imwrite(str(temp_path), frame)
98
+
99
+ try:
100
+ # Extraer embeddings con DeepFace
101
+ # represent() devuelve una lista de dicts, uno por cada cara detectada
102
+ face_objs = DeepFace.represent(
103
+ img_path=str(temp_path),
104
+ model_name='Facenet512',
105
+ detector_backend=detector_backend,
106
+ enforce_detection=enforce_detection
107
+ )
108
+
109
+ if face_objs:
110
+ for i, face_obj in enumerate(face_objs):
111
+ embedding = face_obj['embedding']
112
+ facial_area = face_obj.get('facial_area', {})
113
+ try:
114
+ w = int(facial_area.get('w', 0))
115
+ h = int(facial_area.get('h', 0))
116
+ if w * h < int(min_face_area):
117
+ continue
118
+ except Exception:
119
+ pass
120
+
121
+ # Guardar recorte de la cara (mejor para UI y clustering visual)
122
+ x = int(facial_area.get('x', 0)); y = int(facial_area.get('y', 0))
123
+ w = int(facial_area.get('w', 0)); h = int(facial_area.get('h', 0))
124
+ x2 = max(0, x); y2 = max(0, y)
125
+ x3 = min(frame.shape[1], x + w); y3 = min(frame.shape[0], y + h)
126
+ crop = frame[y2:y3, x2:x3] if (x3 > x2 and y3 > y2) else frame
127
+ save_path = self.faces_dir / f"face_{saved_count:04d}.jpg"
128
+ cv2.imwrite(str(save_path), crop)
129
+
130
+ embeddings_caras.append({
131
+ "embeddings": embedding,
132
+ "path": str(save_path),
133
+ "frame": frame_count,
134
+ "facial_area": facial_area
135
+ })
136
+ saved_count += 1
137
+
138
+ if frame_count % (frame_interval * 10) == 0:
139
+ logger.info(f"Progreso: frame {frame_count}/{total_frames}, caras detectadas: {saved_count}")
140
+
141
+ except Exception as e:
142
+ logger.debug(f"No se detectaron caras en frame {frame_count}: {e}")
143
+
144
+ if temp_path.exists():
145
+ os.remove(temp_path)
146
+
147
+ frame_count += 1
148
+
149
+ video.release()
150
+ logger.info(f"✓ Caras extraídas: {len(embeddings_caras)}")
151
+ return embeddings_caras
152
+
153
+ def extract_voices_embeddings(self) -> List[Dict[str, Any]]:
154
+ """
155
+ Extrae voces del vídeo y calcula sus embeddings.
156
+ Por ahora retorna lista vacía (funcionalidad opcional).
157
+
158
+ Returns:
159
+ Lista de dicts con {"embeddings": [...], "path": "..."}
160
+ """
161
+ logger.info("Extracción de voces deshabilitada temporalmente")
162
+ return []
163
+
164
+ def extract_scenes_embeddings(self) -> List[Dict[str, Any]]:
165
+ """
166
+ Extrae escenas clave del vídeo.
167
+ Por ahora retorna lista vacía (funcionalidad opcional).
168
+
169
+ Returns:
170
+ Lista de dicts con {"embeddings": [...], "path": "..."}
171
+ """
172
+ logger.info("Extracción de escenas deshabilitada temporalmente")
173
+ return []
174
+
175
+ def cluster_faces(self, embeddings_caras: List[Dict], max_groups: int, min_samples: int) -> np.ndarray:
176
+ """
177
+ Agrupa caras similares usando clustering jerárquico aglomerativo con selección óptima.
178
+ Selecciona automáticamente el mejor número de clusters usando silhouette score.
179
+
180
+ Args:
181
+ embeddings_caras: Lista de embeddings de caras
182
+ max_groups: Número máximo de clusters a formar
183
+ min_samples: Tamaño mínimo de cluster válido
184
+
185
+ Returns:
186
+ Array de labels (cluster asignado a cada cara, -1 para ruido)
187
+ """
188
+ if not embeddings_caras:
189
+ return np.array([])
190
+
191
+ logger.info(f"Clustering {len(embeddings_caras)} caras con max_groups={max_groups}, min_samples={min_samples}")
192
+
193
+ # Extraer solo los embeddings
194
+ X = np.array([cara['embeddings'] for cara in embeddings_caras])
195
+
196
+ if len(X) < min_samples:
197
+ # Si hay menos muestras que el mínimo, todo es ruido
198
+ return np.full(len(X), -1, dtype=int)
199
+
200
+ # Linkage usando average linkage (más flexible que ward, menos sensible a outliers)
201
+ # Esto ayuda a agrupar mejor la misma persona con diferentes ángulos/expresiones
202
+ Z = linkage(X, method='average', metric='cosine') # Cosine similarity para embeddings
203
+
204
+ # Encontrar el número óptimo de clusters usando silhouette score
205
+ from sklearn.metrics import silhouette_score
206
+ best_n_clusters = 2
207
+ best_score = -1
208
+
209
+ max_to_try = min(max_groups, len(X) - 1)
210
+
211
+ if max_to_try >= 2:
212
+ for n_clusters in range(2, max_to_try + 1):
213
+ trial_labels = fcluster(Z, t=n_clusters, criterion='maxclust') - 1
214
+
215
+ trial_counts = Counter(trial_labels)
216
+ valid_clusters = sum(1 for count in trial_counts.values() if count >= min_samples)
217
+
218
+ if valid_clusters >= 2:
219
+ try:
220
+ score = silhouette_score(X, trial_labels, metric='cosine')
221
+ # Penalización MUY fuerte para reducir duplicados de la misma persona
222
+ # Valores: 0.05 = fuerte, 0.07 = muy fuerte, 0.10 = extremo
223
+ adjusted_score = score - (n_clusters * 0.07)
224
+
225
+ if adjusted_score > best_score:
226
+ best_score = adjusted_score
227
+ best_n_clusters = n_clusters
228
+ except:
229
+ pass
230
+
231
+ logger.info(f"Clustering óptimo: {best_n_clusters} clusters (de máximo {max_groups}), silhouette: {best_score:.3f}")
232
+ labels = fcluster(Z, t=best_n_clusters, criterion='maxclust') - 1
233
+
234
+ # Filtrar clusters pequeños
235
+ label_counts = Counter(labels)
236
+ filtered_labels = []
237
+ for lbl in labels:
238
+ if label_counts[lbl] >= min_samples:
239
+ filtered_labels.append(lbl)
240
+ else:
241
+ filtered_labels.append(-1)
242
+ labels = np.array(filtered_labels, dtype=int)
243
+
244
+ # Contar clusters (excluyendo ruido -1)
245
+ n_clusters = len(set(labels)) - (1 if -1 in labels else 0)
246
+ n_noise = list(labels).count(-1)
247
+
248
+ logger.info(f"Clusters válidos encontrados: {n_clusters}, Ruido: {n_noise}")
249
+ return labels
250
+
251
+ def create_character_folders(self, embeddings_caras: List[Dict], labels: np.ndarray) -> List[Dict[str, Any]]:
252
+ """
253
+ Crea carpetas para cada personaje detectado, valida caras y guarda metadata.
254
+ Integra validación con DeepFace para filtrar falsos positivos y detectar género.
255
+
256
+ Args:
257
+ embeddings_caras: Lista de embeddings de caras
258
+ labels: Array de labels de clustering
259
+
260
+ Returns:
261
+ Lista de personajes detectados con metadata (solo clusters válidos)
262
+ """
263
+ from face_classifier import validate_and_classify_face, FACE_CONFIDENCE_THRESHOLD
264
+
265
+ characters_validated = []
266
+
267
+ # Agrupar caras por cluster
268
+ clusters = {}
269
+ for idx, label in enumerate(labels):
270
+ if label == -1: # Ignorar ruido
271
+ continue
272
+ if label not in clusters:
273
+ clusters[label] = []
274
+ clusters[label].append(idx)
275
+
276
+ logger.info(f"Procesando {len(clusters)} clusters detectados...")
277
+ original_cluster_count = len(clusters)
278
+
279
+ # Procesar cada cluster
280
+ for cluster_id, face_indices in clusters.items():
281
+ char_id = f"char_{cluster_id:02d}"
282
+
283
+ # PASO 1: Ordenar caras por score (usar área como proxy de calidad)
284
+ # Caras más grandes = mejor detección
285
+ face_detections = []
286
+ for face_idx in face_indices:
287
+ face_data = embeddings_caras[face_idx]
288
+ facial_area = face_data.get('facial_area', {})
289
+ w = facial_area.get('w', 0)
290
+ h = facial_area.get('h', 0)
291
+ area_score = w * h # Score basado en área
292
+
293
+ face_detections.append({
294
+ 'index': face_idx,
295
+ 'score': area_score,
296
+ 'facial_area': facial_area,
297
+ 'path': face_data['path']
298
+ })
299
+
300
+ # Ordenar por score descendente (mejores primero)
301
+ face_detections_sorted = sorted(
302
+ face_detections,
303
+ key=lambda x: x['score'],
304
+ reverse=True
305
+ )
306
+
307
+ if not face_detections_sorted:
308
+ logger.info(f"[VALIDATION] ✗ Cluster {char_id}: sense deteccions, eliminant")
309
+ continue
310
+
311
+ # PASO 2: Validar SOLO la mejor cara del cluster
312
+ best_face = face_detections_sorted[0]
313
+ best_face_path = best_face['path']
314
+
315
+ logger.info(f"[VALIDATION] Cluster {char_id}: validant millor cara (score={best_face['score']:.0f}px²)")
316
+
317
+ validation = validate_and_classify_face(best_face_path)
318
+
319
+ if not validation:
320
+ logger.info(f"[VALIDATION] ✗ Cluster {char_id}: error en validació, eliminant")
321
+ continue
322
+
323
+ # PASO 3: Verificar si és una cara vàlida
324
+ if not validation['is_valid_face'] or validation['face_confidence'] < FACE_CONFIDENCE_THRESHOLD:
325
+ logger.info(f"[VALIDATION] ✗ Cluster {char_id}: score baix ({validation['face_confidence']:.2f}), eliminant tot el clúster")
326
+ continue
327
+
328
+ # PASO 4: És una cara vàlida! Crear carpeta
329
+ char_dir = self.output_base / char_id
330
+ char_dir.mkdir(parents=True, exist_ok=True)
331
+
332
+ # PASO 5: Limitar caras a mostrar (primera meitat + 1)
333
+ total_faces = len(face_detections_sorted)
334
+ max_faces_to_show = (total_faces // 2) + 1
335
+ face_detections_limited = face_detections_sorted[:max_faces_to_show]
336
+
337
+ # Copiar solo las caras limitadas
338
+ face_files = []
339
+ for i, face_det in enumerate(face_detections_limited):
340
+ src_path = Path(face_det['path'])
341
+ dst_path = char_dir / f"face_{i:03d}.jpg"
342
+ if src_path.exists():
343
+ shutil.copy(src_path, dst_path)
344
+ face_files.append(f"/files/{self.video_name}/{char_id}/face_{i:03d}.jpg")
345
+
346
+ # Imagen representativa (la mejor)
347
+ representative_src = Path(best_face_path)
348
+ representative_dst = char_dir / "representative.jpg"
349
+ if representative_src.exists():
350
+ shutil.copy(representative_src, representative_dst)
351
+
352
+ # PASO 6: Generar nombre de clúster
353
+ cluster_number = int(char_id.split('_')[1]) + 1
354
+ character_name = f"Cluster {cluster_number}"
355
+ gender = validation['gender']
356
+
357
+ # Metadata del personaje
358
+ image_url = f"/files/{self.video_name}/{char_id}/representative.jpg"
359
+
360
+ character_data = {
361
+ "id": char_id,
362
+ "name": character_name,
363
+ "gender": gender,
364
+ "gender_confidence": validation['gender_confidence'],
365
+ "face_confidence": validation['face_confidence'],
366
+ "man_prob": validation['man_prob'],
367
+ "woman_prob": validation['woman_prob'],
368
+ "image_path": str(representative_dst),
369
+ "image_url": image_url,
370
+ "face_files": face_files,
371
+ "num_faces": len(face_detections_limited),
372
+ "total_faces_detected": total_faces,
373
+ "folder": str(char_dir)
374
+ }
375
+
376
+ characters_validated.append(character_data)
377
+
378
+ logger.info(f"[VALIDATION] Cluster {char_id}: cara vàlida! "
379
+ f"Nom={character_name}, Gender={gender} (conf={validation['gender_confidence']:.2f}), "
380
+ f"Mostrant {len(face_detections_limited)}/{total_faces} cares")
381
+
382
+ # Estadístiques finals
383
+ eliminated_count = original_cluster_count - len(characters_validated)
384
+ logger.info(f"[VALIDATION] Total: {len(characters_validated)} clústers vàlids "
385
+ f"(eliminats {eliminated_count} falsos positius)")
386
+
387
+ return characters_validated
388
+
389
+ def save_analysis_json(self, embeddings_caras: List[Dict], embeddings_voices: List[Dict],
390
+ embeddings_escenas: List[Dict]) -> Path:
391
+ """
392
+ Guarda el análisis completo en un archivo JSON.
393
+ Similar al analysis.json de Ana.
394
+
395
+ Returns:
396
+ Path al archivo JSON guardado
397
+ """
398
+ analysis_data = {
399
+ "caras": embeddings_caras,
400
+ "voices": embeddings_voices,
401
+ "escenas": embeddings_escenas
402
+ }
403
+
404
+ analysis_path = self.output_base / "analysis.json"
405
+
406
+ try:
407
+ with open(analysis_path, "w", encoding="utf-8") as f:
408
+ json.dump(analysis_data, f, indent=2, ensure_ascii=False)
409
+ logger.info(f"Analysis JSON guardado: {analysis_path}")
410
+ except Exception as e:
411
+ logger.warning(f"Error al guardar analysis JSON: {e}")
412
+
413
+ return analysis_path
414
+
415
+ def detect_characters(self, max_groups: int = 3, min_cluster_size: int = 3,
416
+ *, start_offset_sec: float = 3.0, extract_every_sec: float = 0.5) -> Tuple[List[Dict], Path, np.ndarray, List[Dict[str, Any]]]:
417
+ """
418
+ Pipeline completo de detección de personajes con clustering jerárquico.
419
+
420
+ Args:
421
+ max_groups: Número máximo de clusters a formar
422
+ min_cluster_size: Tamaño mínimo de cluster
423
+
424
+ Returns:
425
+ Tuple de (lista de personajes, path al analysis.json)
426
+ """
427
+ # 1. Extraer caras y embeddings
428
+ embeddings_caras = self.extract_faces_embeddings(start_offset_sec=start_offset_sec, extract_every_sec=extract_every_sec)
429
+
430
+ # 2. Extraer voces y embeddings (opcional, por ahora)
431
+ embeddings_voices = self.extract_voices_embeddings()
432
+
433
+ # 3. Extraer escenas y embeddings (opcional, por ahora)
434
+ embeddings_escenas = self.extract_scenes_embeddings()
435
+
436
+ # 4. Guardar análisis completo
437
+ analysis_path = self.save_analysis_json(embeddings_caras, embeddings_voices, embeddings_escenas)
438
+
439
+ # 5. Clustering de caras
440
+ labels = self.cluster_faces(embeddings_caras, max_groups, min_cluster_size)
441
+
442
+ # 6. Crear carpetas de personajes
443
+ characters = self.create_character_folders(embeddings_caras, labels)
444
+
445
+ return characters, analysis_path, labels, embeddings_caras
446
+
447
+
448
+ # Función de conveniencia para usar en el API
449
+ def detect_characters_from_video(video_path: str, output_base: str,
450
+ max_groups: int = 3, min_cluster_size: int = 3,
451
+ video_name: str = None,
452
+ *, start_offset_sec: float = 3.0, extract_every_sec: float = 0.5) -> Dict[str, Any]:
453
+ """
454
+ Función de alto nivel para detectar personajes en un vídeo usando clustering jerárquico.
455
+
456
+ Args:
457
+ video_path: Ruta al vídeo
458
+ output_base: Directorio base para guardar resultados
459
+ max_groups: Número máximo de clusters a formar
460
+ min_cluster_size: Tamaño mínimo de cluster
461
+ video_name: Nombre del vídeo (para construir URLs)
462
+
463
+ Returns:
464
+ Dict con resultados: {"characters": [...], "analysis_path": "..."}
465
+ """
466
+ detector = CharacterDetector(video_path, Path(output_base), video_name=video_name)
467
+ characters, analysis_path, labels, embeddings_caras = detector.detect_characters(max_groups, min_cluster_size,
468
+ start_offset_sec=start_offset_sec,
469
+ extract_every_sec=extract_every_sec)
470
+
471
+ return {
472
+ "characters": characters,
473
+ "analysis_path": str(analysis_path),
474
+ "num_characters": len(characters),
475
+ "face_labels": labels.tolist() if isinstance(labels, np.ndarray) else list(labels),
476
+ "num_face_embeddings": len(embeddings_caras)
477
+ }