Módulo 7: Casos de Uso
4. Video: Frames + LLM
Descripción
Los LLMs multimodales no procesan video directamente (todavía). La técnica estándar es extraer frames representativos del video, enviarlos como imágenes a una Vision API, y combinar los análisis individuales en un resumen coherente. En esta cápsula construyes un pipeline completo de análisis de video: extracción de frames con OpenCV, selección inteligente de frames clave, análisis con Vision API, y síntesis con LLM.
Por qué importa: Video es la modalidad más rica en información y la más difícil de procesar. Plataformas educativas analizan clases grabadas, sistemas de seguridad revisan cámaras, equipos de marketing analizan contenido, y plataformas de e-commerce procesan video-reviews. El patrón frames → Vision → LLM es la base de todos estos casos.
Conexión con el módulo: Este pipeline es otro destino del Use Case Selector (cápsula 08). Cuando el router detecta un archivo de video, aplica la extracción de frames y el análisis que construyes aquí. Los patrones de batch y rate limit de la cápsula 06 son críticos para procesar muchos frames.
Pipeline Visual
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Video │────▶│ Extraer │────▶│ Seleccionar │
│ (.mp4) │ │ frames │ │ key frames │
└──────────────┘ └──────────────┘ └──────┬───────┘
│
▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Resumen │◀────│ Sintetizar │◀────│ Vision API │
│ final │ │ con LLM │ │ por frame │
└──────────────┘ └──────────────┘ └──────────────┘
Etapas:
- Cargar video — Abrir con OpenCV
- Extraer frames — Por intervalo fijo o detección de cambio de escena
- Seleccionar key frames — Filtrar duplicados y frames irrelevantes
- Analizar con Vision — Enviar cada frame a GPT-4o para descripción
- Sintetizar — LLM combina descripciones en resumen temporal
Paso 1: Información del Video
Antes de extraer frames, obtener metadata del video:
import cv2
from pathlib import Path
def get_video_info(video_path: str) -> dict:
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
return {"error": f"No se puede abrir: {video_path}"}
fps = cap.get(cv2.CAP_PROP_FPS)
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
duration = total_frames / fps if fps > 0 else 0
cap.release()
return {
"path": video_path,
"fps": round(fps, 2),
"total_frames": total_frames,
"width": width,
"height": height,
"duration_seconds": round(duration, 2),
"duration_formatted": format_duration(duration),
"size_mb": round(Path(video_path).stat().st_size / (1024 * 1024), 2)
}
def format_duration(seconds: float) -> str:
minutes = int(seconds // 60)
secs = int(seconds % 60)
return f"{minutes}:{secs:02d}"
Paso 2: Extracción de Frames por Intervalo
Extracción básica
import os
def extract_frames_by_interval(
video_path: str,
interval_seconds: float = 5.0,
output_dir: str = "/tmp/video_frames",
max_frames: int = 50
) -> list[dict]:
os.makedirs(output_dir, exist_ok=True)
cap = cv2.VideoCapture(video_path)
fps = cap.get(cv2.CAP_PROP_FPS)
interval_frames = int(fps * interval_seconds)
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
frames = []
frame_id = 0
while True:
ret, frame = cap.read()
if not ret:
break
if frame_id % interval_frames == 0:
timestamp = frame_id / fps
frame_path = os.path.join(output_dir, f"frame_{frame_id:06d}.jpg")
cv2.imwrite(frame_path, frame)
frames.append({
"path": frame_path,
"frame_id": frame_id,
"timestamp": round(timestamp, 2),
"timestamp_formatted": format_duration(timestamp)
})
if len(frames) >= max_frames:
break
frame_id += 1
cap.release()
return frames
Cálculo de intervalo óptimo
def calculate_optimal_interval(
duration_seconds: float,
target_frames: int = 20,
min_interval: float = 2.0,
max_interval: float = 30.0
) -> float:
if duration_seconds <= 0 or target_frames <= 0:
return min_interval
interval = duration_seconds / target_frames
return max(min_interval, min(interval, max_interval))
Paso 3: Detección de Cambio de Escena
En lugar de extraer a intervalo fijo (que puede capturar frames redundantes), detectar cambios visuales significativos:
import numpy as np
def extract_frames_by_scene_change(
video_path: str,
threshold: float = 30.0,
min_gap_seconds: float = 2.0,
output_dir: str = "/tmp/video_frames",
max_frames: int = 30
) -> list[dict]:
os.makedirs(output_dir, exist_ok=True)
cap = cv2.VideoCapture(video_path)
fps = cap.get(cv2.CAP_PROP_FPS)
min_gap_frames = int(fps * min_gap_seconds)
frames = []
prev_gray = None
frame_id = 0
last_captured = -min_gap_frames
ret, first_frame = cap.read()
if ret:
frame_path = os.path.join(output_dir, f"frame_{0:06d}.jpg")
cv2.imwrite(frame_path, first_frame)
frames.append({
"path": frame_path,
"frame_id": 0,
"timestamp": 0.0,
"timestamp_formatted": "0:00",
"trigger": "first_frame"
})
prev_gray = cv2.cvtColor(first_frame, cv2.COLOR_BGR2GRAY)
last_captured = 0
while True:
ret, frame = cap.read()
if not ret:
break
frame_id += 1
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
if prev_gray is not None and (frame_id - last_captured) >= min_gap_frames:
diff = cv2.absdiff(prev_gray, gray)
mean_diff = diff.mean()
if mean_diff > threshold:
timestamp = frame_id / fps
frame_path = os.path.join(output_dir, f"frame_{frame_id:06d}.jpg")
cv2.imwrite(frame_path, frame)
frames.append({
"path": frame_path,
"frame_id": frame_id,
"timestamp": round(timestamp, 2),
"timestamp_formatted": format_duration(timestamp),
"trigger": "scene_change",
"diff_score": round(mean_diff, 2)
})
last_captured = frame_id
if len(frames) >= max_frames:
break
prev_gray = gray
cap.release()
return frames
Estrategia híbrida
def extract_frames_hybrid(
video_path: str,
interval_seconds: float = 10.0,
scene_threshold: float = 30.0,
max_frames: int = 25,
output_dir: str = "/tmp/video_frames"
) -> list[dict]:
interval_frames = extract_frames_by_interval(
video_path,
interval_seconds=interval_seconds,
output_dir=output_dir + "/interval",
max_frames=max_frames
)
scene_frames = extract_frames_by_scene_change(
video_path,
threshold=scene_threshold,
output_dir=output_dir + "/scene",
max_frames=max_frames
)
all_frames = interval_frames + scene_frames
all_frames.sort(key=lambda f: f["timestamp"])
deduplicated = []
last_ts = -5.0
for frame in all_frames:
if frame["timestamp"] - last_ts >= 2.0:
deduplicated.append(frame)
last_ts = frame["timestamp"]
return deduplicated[:max_frames]
Paso 4: Análisis de Frames con Vision API
from openai import OpenAI
import base64
client = OpenAI()
def analyze_single_frame(frame_path: str, context: str = "") -> dict:
with open(frame_path, "rb") as f:
b64 = base64.b64encode(f.read()).decode()
prompt = "Describe esta escena de video en 2-3 oraciones. Incluye: personas visibles, acciones, objetos, texto en pantalla, ambiente."
if context:
prompt += f"\nContexto adicional: {context}"
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}}
]
}],
max_tokens=200,
temperature=0.2
)
return {
"description": response.choices[0].message.content,
"tokens_used": response.usage.total_tokens
}
def analyze_frames_batch(frames: list[dict], context: str = "") -> list[dict]:
analyzed = []
total_tokens = 0
for i, frame in enumerate(frames):
try:
result = analyze_single_frame(frame["path"], context)
analyzed.append({
**frame,
"description": result["description"],
"status": "success"
})
total_tokens += result["tokens_used"]
except Exception as e:
analyzed.append({
**frame,
"description": None,
"status": "error",
"error": str(e)
})
return analyzed
Análisis multi-frame en un solo request
Para reducir llamadas, enviar múltiples frames en un request:
def analyze_frame_group(frames: list[dict], max_per_request: int = 5) -> str:
content = [{
"type": "text",
"text": (
f"Estos son {len(frames)} frames de un video, en orden cronológico. "
"Para cada frame, describe la escena en 1-2 oraciones. "
"Formato: 'Frame N (MM:SS): descripción'"
)
}]
for frame in frames[:max_per_request]:
with open(frame["path"], "rb") as f:
b64 = base64.b64encode(f.read()).decode()
content.append({
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{b64}"}
})
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": content}],
max_tokens=500
)
return response.choices[0].message.content
def analyze_all_frames_grouped(frames: list[dict], group_size: int = 5) -> list[str]:
descriptions = []
for i in range(0, len(frames), group_size):
group = frames[i:i + group_size]
group_desc = analyze_frame_group(group)
descriptions.append(group_desc)
return descriptions
Paso 5: Síntesis con LLM
def synthesize_video_summary(
video_info: dict,
frame_descriptions: list[dict]
) -> dict:
successful = [f for f in frame_descriptions if f.get("status") == "success"]
timeline = []
for f in successful:
timeline.append(f"{f['timestamp_formatted']}: {f['description']}")
timeline_text = "\n".join(timeline)
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "user",
"content": (
f"Información del video: duración {video_info['duration_formatted']}, "
f"{video_info['total_frames']} frames totales.\n\n"
f"Análisis de {len(successful)} frames clave:\n\n"
f"{timeline_text}\n\n"
"Genera:\n"
"1. Resumen general (3-5 oraciones)\n"
"2. Secciones principales con timestamps\n"
"3. Temas o acciones clave detectadas\n"
"4. Observaciones relevantes"
)
}],
max_tokens=600,
temperature=0.3
)
return {
"summary": response.choices[0].message.content,
"frames_analyzed": len(successful),
"frames_failed": len(frame_descriptions) - len(successful),
"video_duration": video_info["duration_formatted"]
}
Pipeline Completo
def analyze_video_pipeline(
video_path: str,
method: str = "hybrid",
target_frames: int = 20,
context: str = ""
) -> dict:
video_info = get_video_info(video_path)
if "error" in video_info:
return video_info
interval = calculate_optimal_interval(
video_info["duration_seconds"],
target_frames=target_frames
)
if method == "interval":
frames = extract_frames_by_interval(
video_path, interval_seconds=interval, max_frames=target_frames
)
elif method == "scene":
frames = extract_frames_by_scene_change(
video_path, max_frames=target_frames
)
else:
frames = extract_frames_hybrid(
video_path,
interval_seconds=interval,
max_frames=target_frames
)
analyzed = analyze_frames_batch(frames, context=context)
summary = synthesize_video_summary(video_info, analyzed)
summary["video_info"] = video_info
summary["method"] = method
summary["frames_extracted"] = len(frames)
return summary
Uso:
result = analyze_video_pipeline(
"clase_grabada.mp4",
method="hybrid",
target_frames=15,
context="Video de una clase universitaria sobre machine learning"
)
print(result["summary"])
Detección de Acciones Específicas
Para buscar momentos específicos en el video:
def detect_action_in_video(
video_path: str,
action_description: str,
check_interval: float = 3.0,
max_frames: int = 40
) -> list[dict]:
frames = extract_frames_by_interval(
video_path,
interval_seconds=check_interval,
max_frames=max_frames
)
detections = []
for frame in frames:
with open(frame["path"], "rb") as f:
b64 = base64.b64encode(f.read()).decode()
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "user",
"content": [
{
"type": "text",
"text": (
f"¿Esta imagen muestra: '{action_description}'?\n"
"Responde en JSON: {\"detected\": true/false, \"confidence\": 0.0-1.0, \"details\": \"...\"}"
)
},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}}
]
}],
max_tokens=100,
temperature=0,
response_format={"type": "json_object"}
)
import json
result = json.loads(response.choices[0].message.content)
if result.get("detected"):
detections.append({
**frame,
"confidence": result["confidence"],
"details": result["details"]
})
return detections
Uso:
moments = detect_action_in_video(
"presentacion.mp4",
"persona mostrando un gráfico o diagrama"
)
for m in moments:
print(f"{m['timestamp_formatted']}: {m['details']} (conf: {m['confidence']})")
Troubleshooting
Problema 1: Demasiados frames, costo alto
Síntoma: Un video de 1 hora genera 720 frames a 1 frame/5s.
Solución: Calcular intervalo óptimo antes de extraer:
info = get_video_info("video_largo.mp4")
interval = calculate_optimal_interval(info["duration_seconds"], target_frames=20)
Para videos largos (>30 min), usar scene detection para capturar solo cambios significativos.
Problema 2: Frames borrosos o redundantes
Síntoma: Muchos frames son casi idénticos o están borrosos.
Solución:
def filter_blurry_frames(frames: list[dict], blur_threshold: float = 100.0) -> list[dict]:
filtered = []
for frame in frames:
img = cv2.imread(frame["path"])
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
laplacian_var = cv2.Laplacian(gray, cv2.CV_64F).var()
if laplacian_var >= blur_threshold:
frame["sharpness"] = round(laplacian_var, 2)
filtered.append(frame)
return filtered
Problema 3: OpenCV no abre el video
Síntoma: cap.isOpened() retorna False.
Causa: Codec no soportado o ffmpeg no instalado.
Solución:
# macOS
brew install ffmpeg
# Linux
sudo apt-get install ffmpeg libavcodec-extra
Verificar:
def verify_video_support(video_path: str) -> dict:
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
return {
"supported": False,
"suggestion": "Instala ffmpeg: brew install ffmpeg (macOS) o apt-get install ffmpeg (Linux)"
}
cap.release()
return {"supported": True}
Problema 4: Límite de imágenes por request
Síntoma: Error al enviar más de 10 imágenes en un request a OpenAI.
Solución: Dividir en grupos (ya implementado en analyze_frame_group con max_per_request=5). Para combinar resultados de múltiples grupos:
def combine_group_descriptions(group_results: list[str]) -> str:
combined = "\n\n".join(group_results)
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "user",
"content": f"Combina estas descripciones de frames en un resumen cronológico coherente:\n\n{combined}"
}],
max_tokens=500
)
return response.choices[0].message.content
Problema 5: Videos con audio relevante
Síntoma: El análisis de frames pierde el contexto del audio (narración, diálogos).
Solución: Combinar con transcripción (ver cápsula 05 — Combinaciones Multi-Modalidad):
def analyze_video_with_audio(video_path: str) -> dict:
video_summary = analyze_video_pipeline(video_path, target_frames=15)
audio_path = extract_audio_from_video(video_path)
transcript = transcribe(audio_path)
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "user",
"content": (
f"Análisis visual del video:\n{video_summary['summary']}\n\n"
f"Transcripción del audio:\n{transcript[:4000]}\n\n"
"Genera un resumen completo que integre lo visual con lo hablado."
)
}],
max_tokens=600
)
return {
"integrated_summary": response.choices[0].message.content,
"visual_summary": video_summary["summary"],
"transcript_length": len(transcript)
}
Ejercicios
Ejercicio 1: Extractor de timestamps clave
Crea una función que analice un video y devuelva una lista de timestamps donde ocurren cambios importantes, con descripción de cada cambio.
Ver solución
def extract_key_timestamps(video_path: str, max_events: int = 10) -> list[dict]:
frames = extract_frames_by_scene_change(video_path, max_frames=max_events * 2)
analyzed = analyze_frames_batch(frames)
successful = [f for f in analyzed if f.get("status") == "success"]
events = []
for f in successful:
events.append({
"timestamp": f["timestamp_formatted"],
"seconds": f["timestamp"],
"description": f["description"],
"trigger": f.get("trigger", "interval")
})
return events[:max_events]
Ejercicio 2: Comparar dos segmentos de video
Extrae frames de dos rangos temporales del mismo video y compara qué cambió.
Ver solución
def compare_video_segments(
video_path: str,
segment_a: tuple[float, float],
segment_b: tuple[float, float],
frames_per_segment: int = 5
) -> dict:
cap = cv2.VideoCapture(video_path)
fps = cap.get(cv2.CAP_PROP_FPS)
def extract_segment(start_sec, end_sec):
interval = (end_sec - start_sec) / frames_per_segment
frames = []
for i in range(frames_per_segment):
ts = start_sec + i * interval
cap.set(cv2.CAP_PROP_POS_FRAMES, int(ts * fps))
ret, frame = cap.read()
if ret:
path = f"/tmp/segment_{start_sec}_{i}.jpg"
cv2.imwrite(path, frame)
frames.append({"path": path, "timestamp": round(ts, 2)})
return frames
frames_a = extract_segment(*segment_a)
frames_b = extract_segment(*segment_b)
cap.release()
desc_a = analyze_frames_batch(frames_a)
desc_b = analyze_frames_batch(frames_b)
text_a = "\n".join(f["description"] for f in desc_a if f.get("description"))
text_b = "\n".join(f["description"] for f in desc_b if f.get("description"))
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "user",
"content": (
f"Segmento A ({segment_a[0]}s - {segment_a[1]}s):\n{text_a}\n\n"
f"Segmento B ({segment_b[0]}s - {segment_b[1]}s):\n{text_b}\n\n"
"¿Qué diferencias principales hay entre los dos segmentos?"
)
}],
max_tokens=400
)
return {
"comparison": response.choices[0].message.content,
"segment_a_frames": len(frames_a),
"segment_b_frames": len(frames_b)
}
Ejercicio 3: Video Q&A
Implementa un sistema donde el usuario hace preguntas sobre un video. Extrae frames, analiza, y responde la pregunta específica.
Ver solución
def video_qa(video_path: str, question: str, target_frames: int = 15) -> dict:
frames = extract_frames_hybrid(video_path, max_frames=target_frames)
analyzed = analyze_frames_batch(frames, context=question)
successful = [f for f in analyzed if f.get("status") == "success"]
timeline = "\n".join(
f"{f['timestamp_formatted']}: {f['description']}"
for f in successful
)
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": "Responde preguntas sobre videos basándote SOLO en el análisis de frames proporcionado. Si no puedes responder con certeza, dilo."
},
{
"role": "user",
"content": (
f"Análisis de frames del video:\n\n{timeline}\n\n"
f"Pregunta: {question}"
)
}
],
max_tokens=400,
temperature=0.2
)
return {
"answer": response.choices[0].message.content,
"frames_analyzed": len(successful),
"video_path": video_path
}
Recursos Adicionales
- OpenCV Documentation — Procesamiento de video
- OpenAI Vision Guide — Múltiples imágenes por request
- FFmpeg Documentation — Manipulación avanzada de video
- Scene Detection Libraries — Detección de escenas con PySceneDetect