VeuReu commited on
Commit
6747dad
·
verified ·
1 Parent(s): e55c3f5

Upload 4 files

Browse files
Files changed (4) hide show
  1. .gitignore +40 -0
  2. api.py +143 -16
  3. character_detection.py +360 -0
  4. requirements.txt +1 -1
.gitignore ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Documentación local (no subir a HF)
2
+ docs/
3
+
4
+ # Python
5
+ __pycache__/
6
+ *.py[cod]
7
+ *$py.class
8
+ *.so
9
+ .Python
10
+
11
+ # Entornos virtuales
12
+ venv/
13
+ env/
14
+ ENV/
15
+
16
+ # Base de datos local
17
+ *.db
18
+
19
+ # Archivos temporales
20
+ temp/
21
+ tmp/
22
+ *.tmp
23
+
24
+ # Variables de entorno
25
+ .env
26
+ .env.local
27
+
28
+ # IDE
29
+ .vscode/
30
+ .idea/
31
+ *.swp
32
+ *.swo
33
+
34
+ # Sistema
35
+ .DS_Store
36
+ Thumbs.db
37
+
38
+ # Datos temporales
39
+ data/
40
+ videos/
api.py CHANGED
@@ -1,16 +1,21 @@
1
  from __future__ import annotations
2
- from fastapi import FastAPI, UploadFile, File, Form
3
  from fastapi.responses import JSONResponse
4
  from fastapi.middleware.cors import CORSMiddleware
5
  from pathlib import Path
6
  import shutil
7
  import uvicorn
8
  import json
 
 
 
 
9
 
10
  from video_processing import process_video_pipeline
11
  from casting_loader import ensure_chroma, build_faces_index, build_voices_index
12
  from narration_system import NarrationSystem
13
  from llm_router import load_yaml, LLMRouter
 
14
 
15
  app = FastAPI(title="Veureu Engine API", version="0.2.0")
16
  app.add_middleware(
@@ -28,6 +33,15 @@ TEMP_ROOT.mkdir(parents=True, exist_ok=True)
28
  VIDEOS_ROOT = Path("/tmp/data/videos")
29
  VIDEOS_ROOT.mkdir(parents=True, exist_ok=True)
30
 
 
 
 
 
 
 
 
 
 
31
  @app.get("/")
32
  def root():
33
  return {"ok": True, "service": "veureu-engine"}
@@ -47,34 +61,147 @@ async def process_video(
47
 
48
  @app.post("/create_initial_casting")
49
  async def create_initial_casting(
 
50
  video: UploadFile = File(...),
51
  epsilon: float = Form(...),
52
  min_cluster_size: int = Form(...),
53
  ):
 
 
 
 
54
  # Guardar vídeo en carpeta de datos
55
  video_name = Path(video.filename).stem
56
  dst_video = VIDEOS_ROOT / f"{video_name}.mp4"
57
  with dst_video.open("wb") as f:
58
  shutil.copyfileobj(video.file, f)
59
 
60
- # Crear estructura de carpetas en temp/<uploaded-video>/...
61
- base = TEMP_ROOT / video_name
62
- for sub in ("sources", "faces", "voices", "backgrounds"):
63
- (base / sub).mkdir(parents=True, exist_ok=True)
64
-
65
- # Aquí en el futuro se puede disparar la lógica real de detección
66
- return {
67
- "ok": True,
68
- "video": str(dst_video),
69
  "epsilon": float(epsilon),
70
  "min_cluster_size": int(min_cluster_size),
71
- "temp_dirs": {
72
- "sources": str(base / "sources"),
73
- "faces": str(base / "faces"),
74
- "voices": str(base / "voices"),
75
- "backgrounds": str(base / "backgrounds"),
76
- },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
 
79
  @app.post("/load_casting")
80
  async def load_casting(
 
1
  from __future__ import annotations
2
+ from fastapi import FastAPI, UploadFile, File, Form, BackgroundTasks, HTTPException
3
  from fastapi.responses import JSONResponse
4
  from fastapi.middleware.cors import CORSMiddleware
5
  from pathlib import Path
6
  import shutil
7
  import uvicorn
8
  import json
9
+ import uuid
10
+ from datetime import datetime
11
+ from typing import Dict
12
+ from enum import Enum
13
 
14
  from video_processing import process_video_pipeline
15
  from casting_loader import ensure_chroma, build_faces_index, build_voices_index
16
  from narration_system import NarrationSystem
17
  from llm_router import load_yaml, LLMRouter
18
+ from character_detection import detect_characters_from_video
19
 
20
  app = FastAPI(title="Veureu Engine API", version="0.2.0")
21
  app.add_middleware(
 
33
  VIDEOS_ROOT = Path("/tmp/data/videos")
34
  VIDEOS_ROOT.mkdir(parents=True, exist_ok=True)
35
 
36
+ # Sistema de jobs asíncronos
37
+ class JobStatus(str, Enum):
38
+ QUEUED = "queued"
39
+ PROCESSING = "processing"
40
+ DONE = "done"
41
+ FAILED = "failed"
42
+
43
+ jobs: Dict[str, dict] = {}
44
+
45
  @app.get("/")
46
  def root():
47
  return {"ok": True, "service": "veureu-engine"}
 
61
 
62
  @app.post("/create_initial_casting")
63
  async def create_initial_casting(
64
+ background_tasks: BackgroundTasks,
65
  video: UploadFile = File(...),
66
  epsilon: float = Form(...),
67
  min_cluster_size: int = Form(...),
68
  ):
69
+ """
70
+ Crea un job para procesar el vídeo de forma asíncrona.
71
+ Devuelve un job_id inmediatamente.
72
+ """
73
  # Guardar vídeo en carpeta de datos
74
  video_name = Path(video.filename).stem
75
  dst_video = VIDEOS_ROOT / f"{video_name}.mp4"
76
  with dst_video.open("wb") as f:
77
  shutil.copyfileobj(video.file, f)
78
 
79
+ # Crear job_id único
80
+ job_id = str(uuid.uuid4())
81
+
82
+ # Inicializar el job
83
+ jobs[job_id] = {
84
+ "id": job_id,
85
+ "status": JobStatus.QUEUED,
86
+ "video_path": str(dst_video),
87
+ "video_name": video_name,
88
  "epsilon": float(epsilon),
89
  "min_cluster_size": int(min_cluster_size),
90
+ "created_at": datetime.now().isoformat(),
91
+ "results": None,
92
+ "error": None
93
+ }
94
+
95
+ print(f"[{job_id}] Job creado para vídeo: {video_name}")
96
+
97
+ # Iniciar procesamiento en background
98
+ background_tasks.add_task(process_video_job, job_id)
99
+
100
+ # Devolver job_id inmediatamente
101
+ return {"job_id": job_id}
102
+
103
+ @app.get("/jobs/{job_id}/status")
104
+ def get_job_status(job_id: str):
105
+ """
106
+ Devuelve el estado actual de un job.
107
+ El UI hace polling de este endpoint cada 5 segundos.
108
+ """
109
+ if job_id not in jobs:
110
+ raise HTTPException(status_code=404, detail="Job not found")
111
+
112
+ job = jobs[job_id]
113
+
114
+ response = {
115
+ "status": job["status"]
116
  }
117
+
118
+ # Si está completado, incluir resultados
119
+ if job["status"] == JobStatus.DONE:
120
+ response["results"] = job["results"]
121
+
122
+ # Si falló, incluir error
123
+ elif job["status"] == JobStatus.FAILED:
124
+ response["error"] = job["error"]
125
+
126
+ return response
127
+
128
+ def process_video_job(job_id: str):
129
+ """
130
+ Procesa el vídeo de forma asíncrona.
131
+ Esta función se ejecuta en background.
132
+ """
133
+ try:
134
+ job = jobs[job_id]
135
+ print(f"[{job_id}] Iniciando procesamiento...")
136
+
137
+ # Cambiar estado a processing
138
+ job["status"] = JobStatus.PROCESSING
139
+
140
+ video_path = job["video_path"]
141
+ video_name = job["video_name"]
142
+ epsilon = job["epsilon"]
143
+ min_cluster_size = job["min_cluster_size"]
144
+
145
+ # Crear estructura de carpetas
146
+ base = TEMP_ROOT / video_name
147
+ base.mkdir(parents=True, exist_ok=True)
148
+
149
+ print(f"[{job_id}] Directorio base: {base}")
150
+
151
+ # Detección real de personajes usando el código de Ana
152
+ try:
153
+ print(f"[{job_id}] Iniciando detección de personajes...")
154
+ result = detect_characters_from_video(
155
+ video_path=video_path,
156
+ output_base=str(base),
157
+ epsilon=epsilon,
158
+ min_cluster_size=min_cluster_size
159
+ )
160
+
161
+ characters = result.get("characters", [])
162
+ analysis_path = result.get("analysis_path", "")
163
+
164
+ print(f"[{job_id}] Personajes detectados: {len(characters)}")
165
+ for char in characters:
166
+ print(f"[{job_id}] - {char['name']}: {char['num_faces']} caras")
167
+
168
+ # Marcar como completado
169
+ job["status"] = JobStatus.DONE
170
+ job["results"] = {
171
+ "characters": characters,
172
+ "num_characters": len(characters),
173
+ "analysis_path": analysis_path,
174
+ "base_dir": str(base)
175
+ }
176
+
177
+ except Exception as e_detect:
178
+ # Si falla la detección, intentar modo fallback
179
+ print(f"[{job_id}] Error en detección: {e_detect}")
180
+ print(f"[{job_id}] Usando modo fallback (carpetas vacías)")
181
+
182
+ # Crear carpetas básicas como fallback
183
+ for sub in ("sources", "faces", "voices", "backgrounds"):
184
+ (base / sub).mkdir(parents=True, exist_ok=True)
185
+
186
+ job["status"] = JobStatus.DONE
187
+ job["results"] = {
188
+ "characters": [],
189
+ "num_characters": 0,
190
+ "temp_dirs": {
191
+ "sources": str(base / "sources"),
192
+ "faces": str(base / "faces"),
193
+ "voices": str(base / "voices"),
194
+ "backgrounds": str(base / "backgrounds"),
195
+ },
196
+ "warning": f"Detección falló, usando modo fallback: {str(e_detect)}"
197
+ }
198
+
199
+ print(f"[{job_id}] ✓ Job completado exitosamente")
200
+
201
+ except Exception as e:
202
+ print(f"[{job_id}] ✗ Error en el procesamiento: {e}")
203
+ jobs[job_id]["status"] = JobStatus.FAILED
204
+ jobs[job_id]["error"] = str(e)
205
 
206
  @app.post("/load_casting")
207
  async def load_casting(
character_detection.py ADDED
@@ -0,0 +1,360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 con DBSCAN
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
+ from sklearn.cluster import DBSCAN
16
+ import numpy as np
17
+ from typing import List, Dict, Any, Tuple
18
+
19
+ # Imports de las herramientas de vision y audio
20
+ # Nota: Estos imports asumen que los archivos están en originales/
21
+ # y que tienen las dependencias necesarias instaladas
22
+ try:
23
+ import sys
24
+ sys.path.insert(0, str(Path(__file__).parent / "originales"))
25
+ from vision_tools_salamandra_2 import FaceOfImageEmbedding_video_nuevo, ImageEmbedding, keyframe_conditional_extraction_ana
26
+ from audio_tools_ana_2 import extract_audio_ffmpeg, diarize_audio, embed_voice_segments
27
+ TOOLS_AVAILABLE = True
28
+ except Exception as e:
29
+ TOOLS_AVAILABLE = False
30
+ logging.warning(f"No se pudieron importar las herramientas de Ana: {e}")
31
+
32
+ logging.basicConfig(level=logging.INFO)
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ class CharacterDetector:
37
+ """
38
+ Detector de personajes que integra el trabajo de Ana.
39
+ """
40
+
41
+ def __init__(self, video_path: str, output_base: Path):
42
+ """
43
+ Args:
44
+ video_path: Ruta al archivo de vídeo
45
+ output_base: Directorio base para guardar resultados (ej: /tmp/temp/video_name)
46
+ """
47
+ self.video_path = video_path
48
+ self.output_base = Path(output_base)
49
+ self.output_base.mkdir(parents=True, exist_ok=True)
50
+
51
+ # Crear subdirectorios
52
+ self.faces_dir = self.output_base / "faces"
53
+ self.voices_dir = self.output_base / "voices"
54
+ self.scenes_dir = self.output_base / "scenes"
55
+
56
+ for d in [self.faces_dir, self.voices_dir, self.scenes_dir]:
57
+ d.mkdir(parents=True, exist_ok=True)
58
+
59
+ def extract_faces_embeddings(self) -> List[Dict[str, Any]]:
60
+ """
61
+ Extrae caras del vídeo y calcula sus embeddings.
62
+ Basado en faces_embedding_extraction de Ana.
63
+
64
+ Returns:
65
+ Lista de dicts con {"embeddings": [...], "path": "..."}
66
+ """
67
+ if not TOOLS_AVAILABLE:
68
+ logger.warning("Herramientas no disponibles, retornando lista vacía")
69
+ return []
70
+
71
+ logger.info("Extrayendo caras del vídeo...")
72
+ extract_every = 1.0 # segundos
73
+ embedder = FaceOfImageEmbedding_video_nuevo()
74
+ video = cv2.VideoCapture(self.video_path)
75
+ fps = int(video.get(cv2.CAP_PROP_FPS))
76
+ frame_interval = int(fps * extract_every)
77
+ frame_count = 0
78
+ saved_count = 0
79
+
80
+ embeddings_caras = []
81
+
82
+ while True:
83
+ ret, frame = video.read()
84
+ if not ret:
85
+ break
86
+
87
+ if frame_count % frame_interval == 0:
88
+ temp_path = self.faces_dir / "temp_frame.jpg"
89
+ cv2.imwrite(str(temp_path), frame)
90
+ resultados = embedder.encode_image(temp_path)
91
+
92
+ if resultados:
93
+ for i, r in enumerate(resultados):
94
+ embedding = r['embedding']
95
+ cara = r['face_crop']
96
+ save_path = self.faces_dir / f"frame_{saved_count:04d}.jpg"
97
+ cv2.imwrite(str(save_path), cv2.cvtColor(cara, cv2.COLOR_RGB2BGR))
98
+ embeddings_caras.append({
99
+ "embeddings": embedding,
100
+ "path": str(save_path),
101
+ "frame": frame_count
102
+ })
103
+ saved_count += 1
104
+
105
+ if temp_path.exists():
106
+ os.remove(temp_path)
107
+
108
+ frame_count += 1
109
+
110
+ video.release()
111
+ logger.info(f"Caras extraídas: {len(embeddings_caras)}")
112
+ return embeddings_caras
113
+
114
+ def extract_voices_embeddings(self) -> List[Dict[str, Any]]:
115
+ """
116
+ Extrae voces del vídeo y calcula sus embeddings.
117
+ Basado en voices_embedding_extraction de Ana.
118
+
119
+ Returns:
120
+ Lista de dicts con {"embeddings": [...], "path": "..."}
121
+ """
122
+ if not TOOLS_AVAILABLE:
123
+ logger.warning("Herramientas no disponibles, retornando lista vacía")
124
+ return []
125
+
126
+ logger.info("Extrayendo voces del vídeo...")
127
+ sr = 16000
128
+ fmt = "wav"
129
+
130
+ wav_path = extract_audio_ffmpeg(
131
+ self.video_path,
132
+ self.voices_dir / f"{Path(self.video_path).stem}.{fmt}",
133
+ sr=sr
134
+ )
135
+
136
+ min_dur = 0.5
137
+ max_dur = 10.0
138
+
139
+ clip_paths, diar_segs = diarize_audio(
140
+ wav_path,
141
+ self.voices_dir,
142
+ "clips",
143
+ min_dur,
144
+ max_dur
145
+ )
146
+
147
+ embeddings_voices = []
148
+ embeddings = embed_voice_segments(clip_paths)
149
+
150
+ for i, emb in enumerate(embeddings):
151
+ embeddings_voices.append({
152
+ "embeddings": emb,
153
+ "path": str(clip_paths[i])
154
+ })
155
+
156
+ logger.info(f"Voces extraídas: {len(embeddings_voices)}")
157
+ return embeddings_voices
158
+
159
+ def extract_scenes_embeddings(self) -> List[Dict[str, Any]]:
160
+ """
161
+ Extrae escenas clave del vídeo y calcula sus embeddings.
162
+ Basado en scenes_embedding_extraction de Ana.
163
+
164
+ Returns:
165
+ Lista de dicts con {"embeddings": [...], "path": "..."}
166
+ """
167
+ if not TOOLS_AVAILABLE:
168
+ logger.warning("Herramientas no disponibles, retornando lista vacía")
169
+ return []
170
+
171
+ logger.info("Extrayendo escenas del vídeo...")
172
+ keyframes_final = keyframe_conditional_extraction_ana(
173
+ video_path=self.video_path,
174
+ output_dir=self.scenes_dir,
175
+ threshold=30.0,
176
+ )
177
+
178
+ image_embedder = ImageEmbedding()
179
+ embeddings_escenas = []
180
+
181
+ for keyframe in keyframes_final:
182
+ frame_path = keyframe["path"]
183
+ embedding = image_embedder.encode_image(frame_path)
184
+ embeddings_escenas.append({
185
+ "embeddings": embedding,
186
+ "path": str(frame_path)
187
+ })
188
+
189
+ logger.info(f"Escenas extraídas: {len(embeddings_escenas)}")
190
+ return embeddings_escenas
191
+
192
+ def cluster_faces(self, embeddings_caras: List[Dict], epsilon: float, min_samples: int) -> np.ndarray:
193
+ """
194
+ Agrupa caras similares usando DBSCAN.
195
+ Basado en get_face_clusters de Ana.
196
+
197
+ Args:
198
+ embeddings_caras: Lista de embeddings de caras
199
+ epsilon: Parámetro eps de DBSCAN
200
+ min_samples: Parámetro min_samples de DBSCAN
201
+
202
+ Returns:
203
+ Array de labels (cluster asignado a cada cara)
204
+ """
205
+ if not embeddings_caras:
206
+ return np.array([])
207
+
208
+ logger.info(f"Clustering {len(embeddings_caras)} caras con eps={epsilon}, min_samples={min_samples}")
209
+
210
+ # Extraer solo los embeddings
211
+ X = np.array([cara['embeddings'] for cara in embeddings_caras])
212
+
213
+ # DBSCAN clustering
214
+ clustering = DBSCAN(eps=epsilon, min_samples=min_samples, metric='euclidean').fit(X)
215
+ labels = clustering.labels_
216
+
217
+ # Contar clusters (excluyendo ruido -1)
218
+ n_clusters = len(set(labels)) - (1 if -1 in labels else 0)
219
+ n_noise = list(labels).count(-1)
220
+
221
+ logger.info(f"Clusters encontrados: {n_clusters}, Ruido: {n_noise}")
222
+ return labels
223
+
224
+ def create_character_folders(self, embeddings_caras: List[Dict], labels: np.ndarray) -> List[Dict[str, Any]]:
225
+ """
226
+ Crea carpetas para cada personaje detectado y guarda las caras.
227
+
228
+ Args:
229
+ embeddings_caras: Lista de embeddings de caras
230
+ labels: Array de labels de clustering
231
+
232
+ Returns:
233
+ Lista de personajes detectados con metadata
234
+ """
235
+ characters = []
236
+
237
+ # Agrupar caras por cluster
238
+ clusters = {}
239
+ for idx, label in enumerate(labels):
240
+ if label == -1: # Ignorar ruido
241
+ continue
242
+ if label not in clusters:
243
+ clusters[label] = []
244
+ clusters[label].append(idx)
245
+
246
+ logger.info(f"Creando carpetas para {len(clusters)} personajes...")
247
+
248
+ # Crear carpeta para cada personaje
249
+ for cluster_id, face_indices in clusters.items():
250
+ char_id = f"char{cluster_id + 1}"
251
+ char_dir = self.output_base / char_id
252
+ char_dir.mkdir(parents=True, exist_ok=True)
253
+
254
+ # Copiar todas las caras del personaje
255
+ for i, face_idx in enumerate(face_indices):
256
+ src_path = Path(embeddings_caras[face_idx]['path'])
257
+ dst_path = char_dir / f"face_{i:03d}.jpg"
258
+ if src_path.exists():
259
+ shutil.copy(src_path, dst_path)
260
+
261
+ # Seleccionar imagen representativa (primera cara)
262
+ if face_indices:
263
+ representative_src = Path(embeddings_caras[face_indices[0]]['path'])
264
+ representative_dst = char_dir / "representative.jpg"
265
+ if representative_src.exists():
266
+ shutil.copy(representative_src, representative_dst)
267
+
268
+ # Metadata del personaje
269
+ characters.append({
270
+ "id": char_id,
271
+ "name": f"Personatge {cluster_id + 1}",
272
+ "image_path": str(char_dir / "representative.jpg"),
273
+ "num_faces": len(face_indices),
274
+ "folder": str(char_dir)
275
+ })
276
+
277
+ logger.info(f"Carpetas creadas para {len(characters)} personajes")
278
+ return characters
279
+
280
+ def save_analysis_json(self, embeddings_caras: List[Dict], embeddings_voices: List[Dict],
281
+ embeddings_escenas: List[Dict]) -> Path:
282
+ """
283
+ Guarda el análisis completo en un archivo JSON.
284
+ Similar al analysis.json de Ana.
285
+
286
+ Returns:
287
+ Path al archivo JSON guardado
288
+ """
289
+ analysis_data = {
290
+ "caras": embeddings_caras,
291
+ "voices": embeddings_voices,
292
+ "escenas": embeddings_escenas
293
+ }
294
+
295
+ analysis_path = self.output_base / "analysis.json"
296
+
297
+ try:
298
+ with open(analysis_path, "w", encoding="utf-8") as f:
299
+ json.dump(analysis_data, f, indent=2, ensure_ascii=False)
300
+ logger.info(f"Analysis JSON guardado: {analysis_path}")
301
+ except Exception as e:
302
+ logger.warning(f"Error al guardar analysis JSON: {e}")
303
+
304
+ return analysis_path
305
+
306
+ def detect_characters(self, epsilon: float = 0.5, min_cluster_size: int = 2) -> Tuple[List[Dict], Path]:
307
+ """
308
+ Pipeline completo de detección de personajes.
309
+
310
+ Args:
311
+ epsilon: Parámetro epsilon para DBSCAN
312
+ min_cluster_size: Tamaño mínimo de cluster
313
+
314
+ Returns:
315
+ Tuple de (lista de personajes, path al analysis.json)
316
+ """
317
+ # 1. Extraer caras y embeddings
318
+ embeddings_caras = self.extract_faces_embeddings()
319
+
320
+ # 2. Extraer voces y embeddings (opcional, por ahora)
321
+ embeddings_voices = self.extract_voices_embeddings()
322
+
323
+ # 3. Extraer escenas y embeddings (opcional, por ahora)
324
+ embeddings_escenas = self.extract_scenes_embeddings()
325
+
326
+ # 4. Guardar análisis completo
327
+ analysis_path = self.save_analysis_json(embeddings_caras, embeddings_voices, embeddings_escenas)
328
+
329
+ # 5. Clustering de caras
330
+ labels = self.cluster_faces(embeddings_caras, epsilon, min_cluster_size)
331
+
332
+ # 6. Crear carpetas de personajes
333
+ characters = self.create_character_folders(embeddings_caras, labels)
334
+
335
+ return characters, analysis_path
336
+
337
+
338
+ # Función de conveniencia para usar en el API
339
+ def detect_characters_from_video(video_path: str, output_base: str,
340
+ epsilon: float = 0.5, min_cluster_size: int = 2) -> Dict[str, Any]:
341
+ """
342
+ Función de alto nivel para detectar personajes en un vídeo.
343
+
344
+ Args:
345
+ video_path: Ruta al vídeo
346
+ output_base: Directorio base para guardar resultados
347
+ epsilon: Parámetro epsilon para DBSCAN
348
+ min_cluster_size: Tamaño mínimo de cluster
349
+
350
+ Returns:
351
+ Dict con resultados: {"characters": [...], "analysis_path": "..."}
352
+ """
353
+ detector = CharacterDetector(video_path, Path(output_base))
354
+ characters, analysis_path = detector.detect_characters(epsilon, min_cluster_size)
355
+
356
+ return {
357
+ "characters": characters,
358
+ "analysis_path": str(analysis_path),
359
+ "num_characters": len(characters)
360
+ }
requirements.txt CHANGED
@@ -33,7 +33,7 @@ pytesseract>=0.3
33
  easyocr>=1.7
34
  Pillow>=10.4
35
  # face-recognition>=1.3.0 # Requires dlib/cmake - optional, handled gracefully in code
36
- # deepface>=0.0.79 # Heavy dependency - optional, handled gracefully in code
37
 
38
  # Embeddings / ML
39
  scikit-learn==1.4.2
 
33
  easyocr>=1.7
34
  Pillow>=10.4
35
  # face-recognition>=1.3.0 # Requires dlib/cmake - optional, handled gracefully in code
36
+ deepface>=0.0.79 # Necesario para detección de personajes
37
 
38
  # Embeddings / ML
39
  scikit-learn==1.4.2