VeuReu commited on
Commit
bd1e4fb
·
verified ·
1 Parent(s): a65d799

Upload 3 files

Browse files
Files changed (3) hide show
  1. api.py +99 -7
  2. character_detection.py +101 -24
  3. face_classifier.py +158 -0
api.py CHANGED
@@ -520,8 +520,10 @@ def process_video_job(job_id: str):
520
  else:
521
  labels = []
522
 
523
- # Construir carpetas por clúster y representative
524
- characters = []
 
 
525
  cluster_map: dict[int, list[int]] = {}
526
  for i, lbl in enumerate(labels):
527
  if isinstance(lbl, int) and lbl >= 0:
@@ -530,20 +532,85 @@ def process_video_job(job_id: str):
530
  chars_dir = base / "characters"
531
  chars_dir.mkdir(parents=True, exist_ok=True)
532
  import shutil as _sh
 
 
 
 
533
  for ci, idxs in sorted(cluster_map.items(), key=lambda x: x[0]):
534
  char_id = f"char_{ci:02d}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
535
  out_dir = chars_dir / char_id
536
  out_dir.mkdir(parents=True, exist_ok=True)
 
 
 
 
 
 
 
537
  files = []
538
- for k, j in enumerate(idxs[:24]): # limitar a 24
539
- fname = crops_meta[j]["file"]
 
540
  src = faces_root / fname
541
  dst = out_dir / fname
542
  try:
543
  _sh.copy2(src, dst)
544
  files.append(fname)
 
545
  except Exception:
546
  pass
 
 
547
  rep = files[0] if files else None
548
  if rep:
549
  rep_src = out_dir / rep
@@ -552,13 +619,38 @@ def process_video_job(job_id: str):
552
  _sh.copy2(rep_src, rep_dst)
553
  except Exception:
554
  pass
555
- characters.append({
 
 
 
 
 
556
  "id": char_id,
557
- "name": f"Personatge {ci+1}",
 
 
 
 
 
558
  "folder": str(out_dir),
559
  "num_faces": len(files),
 
560
  "image_url": f"/files/{video_name}/{char_id}/representative.jpg" if rep else "",
561
- })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
562
 
563
  # Escribir analysis.json compatible con 'originales'
564
  analysis = {
 
520
  else:
521
  labels = []
522
 
523
+ # Construir carpetas por clúster con validación DeepFace
524
+ from face_classifier import validate_and_classify_face, get_random_catalan_name_by_gender, FACE_CONFIDENCE_THRESHOLD
525
+
526
+ characters_validated = []
527
  cluster_map: dict[int, list[int]] = {}
528
  for i, lbl in enumerate(labels):
529
  if isinstance(lbl, int) and lbl >= 0:
 
532
  chars_dir = base / "characters"
533
  chars_dir.mkdir(parents=True, exist_ok=True)
534
  import shutil as _sh
535
+
536
+ original_cluster_count = len(cluster_map)
537
+ print(f"[{job_id}] Procesando {original_cluster_count} clusters detectados...")
538
+
539
  for ci, idxs in sorted(cluster_map.items(), key=lambda x: x[0]):
540
  char_id = f"char_{ci:02d}"
541
+
542
+ # PASO 1: Ordenar caras por área del bounding box (mejor calidad)
543
+ face_detections = []
544
+ for j in idxs:
545
+ meta = crops_meta[j]
546
+ box = meta.get("box", [0, 0, 0, 0])
547
+ if len(box) >= 4:
548
+ top, right, bottom, left = box
549
+ w = abs(right - left)
550
+ h = abs(bottom - top)
551
+ area_score = w * h
552
+ else:
553
+ area_score = 0
554
+
555
+ face_detections.append({
556
+ 'index': j,
557
+ 'score': area_score,
558
+ 'file': meta['file'],
559
+ 'box': box
560
+ })
561
+
562
+ # Ordenar por score descendente
563
+ face_detections_sorted = sorted(
564
+ face_detections,
565
+ key=lambda x: x['score'],
566
+ reverse=True
567
+ )
568
+
569
+ if not face_detections_sorted:
570
+ print(f"[{job_id}] [VALIDATION] ✗ Cluster {char_id}: sense deteccions, eliminant")
571
+ continue
572
+
573
+ # PASO 2: Validar SOLO la mejor cara del cluster
574
+ best_face = face_detections_sorted[0]
575
+ best_face_path = faces_root / best_face['file']
576
+
577
+ print(f"[{job_id}] [VALIDATION] Cluster {char_id}: validant millor cara (score={best_face['score']:.0f}px²)")
578
+
579
+ validation = validate_and_classify_face(str(best_face_path))
580
+
581
+ if not validation:
582
+ print(f"[{job_id}] [VALIDATION] ✗ Cluster {char_id}: error en validació, eliminant")
583
+ continue
584
+
585
+ # PASO 3: Verificar si és una cara vàlida
586
+ if not validation['is_valid_face'] or validation['face_confidence'] < FACE_CONFIDENCE_THRESHOLD:
587
+ print(f"[{job_id}] [VALIDATION] ✗ Cluster {char_id}: score baix ({validation['face_confidence']:.2f}), eliminant tot el clúster")
588
+ continue
589
+
590
+ # PASO 4: És una cara vàlida! Crear carpeta
591
  out_dir = chars_dir / char_id
592
  out_dir.mkdir(parents=True, exist_ok=True)
593
+
594
+ # PASO 5: Limitar caras a mostrar (primera meitat + 1)
595
+ total_faces = len(face_detections_sorted)
596
+ max_faces_to_show = (total_faces // 2) + 1
597
+ face_detections_limited = face_detections_sorted[:max_faces_to_show]
598
+
599
+ # Copiar solo las caras limitadas
600
  files = []
601
+ face_files_urls = []
602
+ for k, face_det in enumerate(face_detections_limited):
603
+ fname = face_det['file']
604
  src = faces_root / fname
605
  dst = out_dir / fname
606
  try:
607
  _sh.copy2(src, dst)
608
  files.append(fname)
609
+ face_files_urls.append(f"/files/{video_name}/{char_id}/{fname}")
610
  except Exception:
611
  pass
612
+
613
+ # Imagen representativa (la mejor)
614
  rep = files[0] if files else None
615
  if rep:
616
  rep_src = out_dir / rep
 
619
  _sh.copy2(rep_src, rep_dst)
620
  except Exception:
621
  pass
622
+
623
+ # PASO 6: Generar nombre según género
624
+ gender = validation['gender']
625
+ character_name = get_random_catalan_name_by_gender(gender, char_id)
626
+
627
+ character_data = {
628
  "id": char_id,
629
+ "name": character_name,
630
+ "gender": gender,
631
+ "gender_confidence": validation['gender_confidence'],
632
+ "face_confidence": validation['face_confidence'],
633
+ "man_prob": validation['man_prob'],
634
+ "woman_prob": validation['woman_prob'],
635
  "folder": str(out_dir),
636
  "num_faces": len(files),
637
+ "total_faces_detected": total_faces,
638
  "image_url": f"/files/{video_name}/{char_id}/representative.jpg" if rep else "",
639
+ "face_files": face_files_urls,
640
+ }
641
+
642
+ characters_validated.append(character_data)
643
+
644
+ print(f"[{job_id}] [VALIDATION] ✓ Cluster {char_id}: cara vàlida! "
645
+ f"Nom={character_name}, Gender={gender} (conf={validation['gender_confidence']:.2f}), "
646
+ f"Mostrant {len(files)}/{total_faces} cares")
647
+
648
+ # Estadístiques finals
649
+ eliminated_count = original_cluster_count - len(characters_validated)
650
+ print(f"[{job_id}] [VALIDATION] Total: {len(characters_validated)} clústers vàlids "
651
+ f"(eliminats {eliminated_count} falsos positius)")
652
+
653
+ characters = characters_validated
654
 
655
  # Escribir analysis.json compatible con 'originales'
656
  analysis = {
character_detection.py CHANGED
@@ -247,16 +247,19 @@ class CharacterDetector:
247
 
248
  def create_character_folders(self, embeddings_caras: List[Dict], labels: np.ndarray) -> List[Dict[str, Any]]:
249
  """
250
- Crea carpetas para cada personaje detectado y guarda las caras.
 
251
 
252
  Args:
253
  embeddings_caras: Lista de embeddings de caras
254
  labels: Array de labels de clustering
255
 
256
  Returns:
257
- Lista de personajes detectados con metadata
258
  """
259
- characters = []
 
 
260
 
261
  # Agrupar caras por cluster
262
  clusters = {}
@@ -267,43 +270,117 @@ class CharacterDetector:
267
  clusters[label] = []
268
  clusters[label].append(idx)
269
 
270
- logger.info(f"Creando carpetas para {len(clusters)} personajes...")
 
271
 
272
- # Crear carpeta para cada personaje
273
  for cluster_id, face_indices in clusters.items():
274
- char_id = f"char{cluster_id + 1}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
  char_dir = self.output_base / char_id
276
  char_dir.mkdir(parents=True, exist_ok=True)
277
 
278
- # Copiar todas las caras del personaje
279
- for i, face_idx in enumerate(face_indices):
280
- src_path = Path(embeddings_caras[face_idx]['path'])
 
 
 
 
 
 
281
  dst_path = char_dir / f"face_{i:03d}.jpg"
282
  if src_path.exists():
283
  shutil.copy(src_path, dst_path)
 
284
 
285
- # Seleccionar imagen representativa (primera cara)
286
- if face_indices:
287
- representative_src = Path(embeddings_caras[face_indices[0]]['path'])
288
- representative_dst = char_dir / "representative.jpg"
289
- if representative_src.exists():
290
- shutil.copy(representative_src, representative_dst)
 
 
 
291
 
292
  # Metadata del personaje
293
- # Construir URL relativa para la imagen
294
  image_url = f"/files/{self.video_name}/{char_id}/representative.jpg"
295
 
296
- characters.append({
297
  "id": char_id,
298
- "name": f"Personatge {cluster_id + 1}",
299
- "image_path": str(char_dir / "representative.jpg"), # Ruta local
300
- "image_url": image_url, # URL para el API
301
- "num_faces": len(face_indices),
 
 
 
 
 
 
 
302
  "folder": str(char_dir)
303
- })
 
 
 
 
 
 
 
 
 
 
 
304
 
305
- logger.info(f"Carpetas creadas para {len(characters)} personajes")
306
- return characters
307
 
308
  def save_analysis_json(self, embeddings_caras: List[Dict], embeddings_voices: List[Dict],
309
  embeddings_escenas: List[Dict]) -> Path:
 
247
 
248
  def create_character_folders(self, embeddings_caras: List[Dict], labels: np.ndarray) -> List[Dict[str, Any]]:
249
  """
250
+ Crea carpetas para cada personaje detectado, valida caras y guarda metadata.
251
+ Integra validación con DeepFace para filtrar falsos positivos y detectar género.
252
 
253
  Args:
254
  embeddings_caras: Lista de embeddings de caras
255
  labels: Array de labels de clustering
256
 
257
  Returns:
258
+ Lista de personajes detectados con metadata (solo clusters válidos)
259
  """
260
+ from face_classifier import validate_and_classify_face, get_random_catalan_name_by_gender, FACE_CONFIDENCE_THRESHOLD
261
+
262
+ characters_validated = []
263
 
264
  # Agrupar caras por cluster
265
  clusters = {}
 
270
  clusters[label] = []
271
  clusters[label].append(idx)
272
 
273
+ logger.info(f"Procesando {len(clusters)} clusters detectados...")
274
+ original_cluster_count = len(clusters)
275
 
276
+ # Procesar cada cluster
277
  for cluster_id, face_indices in clusters.items():
278
+ char_id = f"char_{cluster_id:02d}"
279
+
280
+ # PASO 1: Ordenar caras por score (usar área como proxy de calidad)
281
+ # Caras más grandes = mejor detección
282
+ face_detections = []
283
+ for face_idx in face_indices:
284
+ face_data = embeddings_caras[face_idx]
285
+ facial_area = face_data.get('facial_area', {})
286
+ w = facial_area.get('w', 0)
287
+ h = facial_area.get('h', 0)
288
+ area_score = w * h # Score basado en área
289
+
290
+ face_detections.append({
291
+ 'index': face_idx,
292
+ 'score': area_score,
293
+ 'facial_area': facial_area,
294
+ 'path': face_data['path']
295
+ })
296
+
297
+ # Ordenar por score descendente (mejores primero)
298
+ face_detections_sorted = sorted(
299
+ face_detections,
300
+ key=lambda x: x['score'],
301
+ reverse=True
302
+ )
303
+
304
+ if not face_detections_sorted:
305
+ logger.info(f"[VALIDATION] ✗ Cluster {char_id}: sense deteccions, eliminant")
306
+ continue
307
+
308
+ # PASO 2: Validar SOLO la mejor cara del cluster
309
+ best_face = face_detections_sorted[0]
310
+ best_face_path = best_face['path']
311
+
312
+ logger.info(f"[VALIDATION] Cluster {char_id}: validant millor cara (score={best_face['score']:.0f}px²)")
313
+
314
+ validation = validate_and_classify_face(best_face_path)
315
+
316
+ if not validation:
317
+ logger.info(f"[VALIDATION] ✗ Cluster {char_id}: error en validació, eliminant")
318
+ continue
319
+
320
+ # PASO 3: Verificar si és una cara vàlida
321
+ if not validation['is_valid_face'] or validation['face_confidence'] < FACE_CONFIDENCE_THRESHOLD:
322
+ logger.info(f"[VALIDATION] ✗ Cluster {char_id}: score baix ({validation['face_confidence']:.2f}), eliminant tot el clúster")
323
+ continue
324
+
325
+ # PASO 4: És una cara vàlida! Crear carpeta
326
  char_dir = self.output_base / char_id
327
  char_dir.mkdir(parents=True, exist_ok=True)
328
 
329
+ # PASO 5: Limitar caras a mostrar (primera meitat + 1)
330
+ total_faces = len(face_detections_sorted)
331
+ max_faces_to_show = (total_faces // 2) + 1
332
+ face_detections_limited = face_detections_sorted[:max_faces_to_show]
333
+
334
+ # Copiar solo las caras limitadas
335
+ face_files = []
336
+ for i, face_det in enumerate(face_detections_limited):
337
+ src_path = Path(face_det['path'])
338
  dst_path = char_dir / f"face_{i:03d}.jpg"
339
  if src_path.exists():
340
  shutil.copy(src_path, dst_path)
341
+ face_files.append(f"/files/{self.video_name}/{char_id}/face_{i:03d}.jpg")
342
 
343
+ # Imagen representativa (la mejor)
344
+ representative_src = Path(best_face_path)
345
+ representative_dst = char_dir / "representative.jpg"
346
+ if representative_src.exists():
347
+ shutil.copy(representative_src, representative_dst)
348
+
349
+ # PASO 6: Generar nombre según género
350
+ gender = validation['gender']
351
+ character_name = get_random_catalan_name_by_gender(gender, char_id)
352
 
353
  # Metadata del personaje
 
354
  image_url = f"/files/{self.video_name}/{char_id}/representative.jpg"
355
 
356
+ character_data = {
357
  "id": char_id,
358
+ "name": character_name,
359
+ "gender": gender,
360
+ "gender_confidence": validation['gender_confidence'],
361
+ "face_confidence": validation['face_confidence'],
362
+ "man_prob": validation['man_prob'],
363
+ "woman_prob": validation['woman_prob'],
364
+ "image_path": str(representative_dst),
365
+ "image_url": image_url,
366
+ "face_files": face_files,
367
+ "num_faces": len(face_detections_limited),
368
+ "total_faces_detected": total_faces,
369
  "folder": str(char_dir)
370
+ }
371
+
372
+ characters_validated.append(character_data)
373
+
374
+ logger.info(f"[VALIDATION] ✓ Cluster {char_id}: cara vàlida! "
375
+ f"Nom={character_name}, Gender={gender} (conf={validation['gender_confidence']:.2f}), "
376
+ f"Mostrant {len(face_detections_limited)}/{total_faces} cares")
377
+
378
+ # Estadístiques finals
379
+ eliminated_count = original_cluster_count - len(characters_validated)
380
+ logger.info(f"[VALIDATION] Total: {len(characters_validated)} clústers vàlids "
381
+ f"(eliminats {eliminated_count} falsos positius)")
382
 
383
+ return characters_validated
 
384
 
385
  def save_analysis_json(self, embeddings_caras: List[Dict], embeddings_voices: List[Dict],
386
  embeddings_escenas: List[Dict]) -> Path:
face_classifier.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Face Classifier Module
3
+ Valida caras y detecta género usando DeepFace para filtrar falsos positivos
4
+ y asignar nombres automáticos según el género detectado.
5
+ """
6
+ import logging
7
+ from typing import Optional, Dict, Any
8
+
9
+ logging.basicConfig(level=logging.INFO)
10
+ logger = logging.getLogger(__name__)
11
+
12
+ # Configuración de thresholds
13
+ FACE_CONFIDENCE_THRESHOLD = 0.3 # Mínimo para considerar cara válida
14
+ GENDER_NEUTRAL_THRESHOLD = 0.2 # Diferencia mínima para género neutro
15
+
16
+
17
+ def validate_and_classify_face(image_path: str) -> Optional[Dict[str, Any]]:
18
+ """
19
+ Valida si és una cara real i detecta el gènere usant DeepFace.
20
+
21
+ Args:
22
+ image_path: Ruta a la imagen de la cara
23
+
24
+ Returns:
25
+ Dict amb: {
26
+ 'is_valid_face': bool, # True si és una cara amb confiança alta
27
+ 'face_confidence': float, # Score de detecció de cara (0-1)
28
+ 'gender': 'Man' | 'Woman' | 'Neutral',
29
+ 'gender_confidence': float, # Score de confiança del gènere (0-1)
30
+ 'man_prob': float,
31
+ 'woman_prob': float
32
+ }
33
+ o None si falla completament
34
+ """
35
+ try:
36
+ from deepface import DeepFace
37
+
38
+ logger.info(f"[DeepFace] Analitzant: {image_path}")
39
+
40
+ # Analitzar gènere amb detecció de cara
41
+ result = DeepFace.analyze(
42
+ img_path=image_path,
43
+ actions=['gender'],
44
+ enforce_detection=True, # Intentar detectar cara
45
+ detector_backend='opencv',
46
+ silent=True
47
+ )
48
+
49
+ # DeepFace pot retornar llista si detecta múltiples cares
50
+ if isinstance(result, list):
51
+ result = result[0] if result else None
52
+
53
+ if not result:
54
+ logger.info(f"[DeepFace] No s'ha detectat cap cara")
55
+ return {
56
+ 'is_valid_face': False,
57
+ 'face_confidence': 0.0,
58
+ 'gender': 'Neutral',
59
+ 'gender_confidence': 0.0,
60
+ 'man_prob': 0.0,
61
+ 'woman_prob': 0.0
62
+ }
63
+
64
+ # Extreure informació de gènere
65
+ gender_info = result.get('gender', {})
66
+
67
+ if isinstance(gender_info, dict):
68
+ # DeepFace retorna percentatges, convertir a 0-1
69
+ man_prob = gender_info.get('Man', 0) / 100.0
70
+ woman_prob = gender_info.get('Woman', 0) / 100.0
71
+ else:
72
+ # Fallback si el format és diferent
73
+ man_prob = 0.5
74
+ woman_prob = 0.5
75
+
76
+ # Determinar gènere basat en les probabilitats
77
+ gender_diff = abs(man_prob - woman_prob)
78
+
79
+ # Si la diferència és petita (< threshold), considerar neutre
80
+ if gender_diff < GENDER_NEUTRAL_THRESHOLD:
81
+ gender = 'Neutral'
82
+ gender_confidence = 0.5
83
+ else:
84
+ gender = 'Man' if man_prob > woman_prob else 'Woman'
85
+ gender_confidence = max(man_prob, woman_prob)
86
+
87
+ # Confiança de detecció de cara
88
+ # DeepFace no proporciona score directament, però si va retornar resultat
89
+ # assumim que és cara vàlida amb confiança alta
90
+ face_confidence = result.get('face_confidence', 0.9) # Default alt si detecta
91
+
92
+ # Si DeepFace va retornar resultat, assumir que és cara vàlida
93
+ is_valid_face = True
94
+
95
+ logger.info(f"[DeepFace] Resultat: gender={gender}, confidence={gender_confidence:.2f}, "
96
+ f"man={man_prob:.2f}, woman={woman_prob:.2f}")
97
+
98
+ return {
99
+ 'is_valid_face': is_valid_face,
100
+ 'face_confidence': face_confidence,
101
+ 'gender': gender,
102
+ 'gender_confidence': gender_confidence,
103
+ 'man_prob': man_prob,
104
+ 'woman_prob': woman_prob
105
+ }
106
+
107
+ except ValueError as e:
108
+ # ValueError significa que no es va detectar cara
109
+ logger.info(f"[DeepFace] No s'ha detectat cara (ValueError): {e}")
110
+ return {
111
+ 'is_valid_face': False,
112
+ 'face_confidence': 0.0,
113
+ 'gender': 'Neutral',
114
+ 'gender_confidence': 0.0,
115
+ 'man_prob': 0.0,
116
+ 'woman_prob': 0.0
117
+ }
118
+ except Exception as e:
119
+ logger.warning(f"[DeepFace] Error validant cara: {e}")
120
+ return None
121
+
122
+
123
+ def get_random_catalan_name_by_gender(gender: str, seed_value: str = "") -> str:
124
+ """
125
+ Genera un nom català aleatori basat en el gènere.
126
+
127
+ Args:
128
+ gender: 'Man', 'Woman', o 'Neutral'
129
+ seed_value: Valor per fer el random determinista (opcional)
130
+
131
+ Returns:
132
+ Nom català
133
+ """
134
+ noms_home = [
135
+ "Jordi", "Marc", "Pau", "Pere", "Joan", "Josep", "David", "Guillem", "Albert",
136
+ "Arnau", "Martí", "Bernat", "Oriol", "Roger", "Pol", "Lluís", "Sergi", "Carles", "Xavier"
137
+ ]
138
+ noms_dona = [
139
+ "Maria", "Anna", "Laura", "Marta", "Cristina", "Núria", "Montserrat", "Júlia", "Sara", "Carla",
140
+ "Alba", "Elisabet", "Rosa", "Gemma", "Sílvia", "Teresa", "Irene", "Laia", "Marina", "Bet"
141
+ ]
142
+ noms_neutre = ["Àlex", "Andrea", "Francis", "Cris", "Noa"]
143
+
144
+ # Seleccionar llista segons gènere
145
+ if gender == 'Woman':
146
+ noms = noms_dona
147
+ elif gender == 'Man':
148
+ noms = noms_home
149
+ else: # Neutral
150
+ noms = noms_neutre
151
+
152
+ # Usar hash del seed per seleccionar nom de forma determinista
153
+ if seed_value:
154
+ hash_val = hash(seed_value)
155
+ return noms[abs(hash_val) % len(noms)]
156
+ else:
157
+ import random
158
+ return random.choice(noms)