Youtube-AI-Recomendations / src /visual_composition.py
jesuszambrano13's picture
Upload 158 files
84ec0e0 verified
Raw
History Blame Contribute Delete
20 kB
"""Análisis visual con principios clásicos de composición sobre 10 frames del video.
Cubre teorías de composición visual útiles para marketing y pauta:
1. **Regla de tercios**: dividir el frame en 3×3 y colocar el punto de interés
sobre intersecciones o líneas. Puntaje basado en distancia al cruce de tercios
más cercano.
2. **Composición áurea**: usa la proporción 1.618 para evaluar si el centro de
masa visual cae cerca de líneas/intersecciones 0.382 y 0.618. Es una guía más
fina que tercios para ubicar sujeto, producto, rostro o CTA con mayor armonía.
3. **Composición geométrica / balance**: distribución del peso visual entre las
mitades izquierda-derecha y superior-inferior. Puntaje basado en simetría
aceptable (≤30% de desbalance se considera bueno).
4. **Centro de masa visual / foco**: ubicación del punto focal estimado por el
centroide de los bordes detectados.
Además calcula contraste, brillo, densidad de bordes, complejidad visual y
movimiento entre frames, y genera **una imagen anotada** por frame con tercios,
retícula áurea y centroide focal.
El análisis es liviano (no depende de modelos ML, solo OpenCV) para correr en
Hugging Face Spaces CPU.
"""
from __future__ import annotations
import math
import tempfile
from pathlib import Path
from typing import Any, Dict, List, Optional
import numpy as np
from .video_processing import extract_frames, get_video_duration_seconds, select_frame_timestamps
# Número de frames extraídos para el análisis composicional.
FRAMES_FOR_VISUAL_ANALYSIS = 10
def analyze_visual_composition(
video_path: Optional[str],
ocr_text: str = "",
output_dir: Optional[Path] = None,
n_frames: int = FRAMES_FOR_VISUAL_ANALYSIS,
) -> Dict[str, Any]:
"""Análisis visual completo a partir de hasta ``n_frames`` (default 10) frames.
Args:
video_path: ruta al MP4. Si no hay video, retorna estructura vacía.
ocr_text: texto OCR detectado, usado para complejidad visual.
output_dir: directorio donde guardar los frames anotados (PNG). Si es
``None`` se crea un temporal y se mantiene mientras el proceso vive.
n_frames: cuántos frames extraer (10 por defecto).
Returns:
dict con todas las métricas, recomendaciones por teoría y rutas a los
frames anotados.
"""
if not video_path:
return _empty("No se recibió video para análisis visual.")
p = Path(str(video_path))
if not p.exists():
return _empty(f"El archivo de video no existe: {p}")
try:
import cv2 # type: ignore
except Exception as exc:
return _empty(f"OpenCV no está disponible: {type(exc).__name__}: {exc}")
duration = get_video_duration_seconds(str(p)) or 0
if duration <= 0:
return _empty("No se pudo determinar la duración del video.")
timestamps = select_frame_timestamps(duration, video_type="auto")
# Forzar hasta n_frames timestamps espaciados uniformemente.
timestamps = _evenly_spaced_timestamps(duration, n_frames)
if output_dir is None:
output_dir = Path(tempfile.mkdtemp(prefix="ytboost_visual_"))
else:
output_dir.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory() as tmp:
raw_frame_paths = extract_frames(str(p), timestamps, tmp)
analyses: List[Dict[str, Any]] = []
annotated_paths: List[str] = []
prev_gray = None
motion_values: List[float] = []
for idx, fp in enumerate(raw_frame_paths):
frame = cv2.imread(fp)
if frame is None:
continue
ts = timestamps[idx] if idx < len(timestamps) else timestamps[-1]
a = _analyze_frame(frame, idx=idx, timestamp=ts)
analyses.append(a)
# Frame anotado con grid de tercios + centroide
annotated = _draw_overlay(frame, a)
out_path = Path(output_dir) / f"frame_{idx:02d}_t{int(ts):03d}s.png"
cv2.imwrite(str(out_path), annotated)
annotated_paths.append(str(out_path))
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
if prev_gray is not None:
h = min(gray.shape[0], prev_gray.shape[0])
w = min(gray.shape[1], prev_gray.shape[1])
diff = cv2.absdiff(gray[:h, :w], prev_gray[:h, :w])
motion_values.append(float(np.mean(diff)) / 255.0)
prev_gray = gray
if not analyses:
return _empty("No se pudieron extraer frames útiles para análisis visual.")
avg = lambda k: float(np.mean([a[k] for a in analyses]))
rule_thirds = avg("rule_thirds_score")
golden_ratio = avg("golden_ratio_score")
geometric_balance = avg("geometric_balance_score")
visual_focus = avg("focal_clarity_score")
contrast = avg("contrast_score")
brightness = avg("brightness_score")
edge_density = avg("edge_density")
motion = float(np.mean(motion_values)) if motion_values else 0.0
complexity = _complexity_score(edge_density=edge_density, ocr_text=ocr_text)
# Puntaje global compuesto.
composition_score = round(
0.22 * rule_thirds
+ 0.18 * golden_ratio
+ 0.22 * geometric_balance
+ 0.18 * visual_focus
+ 0.12 * contrast
+ 0.08 * (1 - complexity),
3,
)
recommendations_by_theory = _theory_based_recommendations(
rule_thirds=rule_thirds,
golden_ratio=golden_ratio,
geometric_balance=geometric_balance,
visual_focus=visual_focus,
contrast=contrast,
brightness=brightness,
edge_density=edge_density,
complexity=complexity,
motion=motion,
ocr_text=ocr_text,
)
flat_recommendations = []
for items in recommendations_by_theory.values():
flat_recommendations.extend(items)
return {
"visual_ok": True,
"frames_analyzed": len(analyses),
"duration_seconds": round(duration, 2),
"composition_score": composition_score,
"rule_of_thirds_score": round(rule_thirds, 3),
"golden_ratio_score": round(golden_ratio, 3),
"geometric_balance_score": round(geometric_balance, 3),
"focal_clarity_score": round(visual_focus, 3),
"contrast_score": round(contrast, 3),
"brightness_score": round(brightness, 3),
"edge_density": round(edge_density, 3),
"motion_score": round(motion, 3),
"visual_complexity_score": round(complexity, 3),
"composition_principles": [
"regla de tercios",
"composición áurea / proporción 1.618",
"composición geométrica / balance",
"centro de masa visual / foco",
"contraste",
"carga cognitiva visual",
],
"frame_diagnostics": analyses,
"annotated_frame_paths": annotated_paths,
"visual_recommendations": flat_recommendations[:8],
"recommendations_by_theory": recommendations_by_theory,
"golden_ratio_theory": {
"name": "Composición áurea",
"phi": 1.618,
"guide_lines": [0.382, 0.618],
"description": (
"Evalúa si el centro de masa visual se aproxima a las líneas e intersecciones "
"de la proporción áurea. En piezas de pauta ayuda a ubicar rostro, producto, "
"beneficio o CTA en zonas armónicas sin saturar el centro."
),
},
"visual_summary": _visual_summary(rule_thirds, golden_ratio, geometric_balance, visual_focus, complexity),
"visual_conclusion": _visual_conclusion(composition_score, rule_thirds, golden_ratio, geometric_balance, visual_focus, complexity),
"warning": "",
}
def _evenly_spaced_timestamps(duration: float, n: int) -> List[float]:
"""``n`` timestamps uniformemente espaciados, evitando los primeros y últimos 0.5s."""
if duration <= 0:
return []
if n <= 1:
return [duration / 2.0]
start = min(0.5, duration * 0.05)
end = max(duration - 0.5, duration * 0.95)
if end <= start:
return [duration / 2.0] * n
step = (end - start) / (n - 1)
return [round(start + i * step, 2) for i in range(n)]
def _empty(warning: str) -> Dict[str, Any]:
return {
"visual_ok": False,
"frames_analyzed": 0,
"composition_score": 0.0,
"rule_of_thirds_score": 0.0,
"golden_ratio_score": 0.0,
"geometric_balance_score": 0.0,
"focal_clarity_score": 0.0,
"contrast_score": 0.0,
"brightness_score": 0.0,
"edge_density": 0.0,
"motion_score": 0.0,
"visual_complexity_score": 0.0,
"composition_principles": [],
"frame_diagnostics": [],
"annotated_frame_paths": [],
"visual_recommendations": [
"No se pudo ejecutar el análisis visual. La recomendación se apoyará en transcripción, OCR y métricas."
],
"recommendations_by_theory": {},
"golden_ratio_theory": {},
"visual_summary": "Análisis visual no disponible.",
"visual_conclusion": "No se pudo generar conclusión visual por falta de frames analizables.",
"warning": warning,
}
def _analyze_frame(frame: np.ndarray, idx: int, timestamp: float) -> Dict[str, Any]:
import cv2 # type: ignore
h, w = frame.shape[:2]
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
edges = cv2.Canny(gray, 80, 180)
ys, xs = np.nonzero(edges)
edge_density = float(len(xs)) / float(max(w * h, 1))
brightness = float(np.mean(gray)) / 255.0
contrast = max(0.0, min(1.0, float(np.std(gray)) / 80.0))
brightness_score = 1.0 - min(abs(brightness - 0.52) / 0.52, 1.0)
# Centroide de bordes (proxy del punto focal).
if len(xs) > 0:
cx = float(np.mean(xs)) / w
cy = float(np.mean(ys)) / h
else:
cx, cy = 0.5, 0.5
# Regla de tercios: cercanía al cruce más próximo.
intersections = [(1 / 3, 1 / 3), (2 / 3, 1 / 3), (1 / 3, 2 / 3), (2 / 3, 2 / 3)]
dist = min(math.dist((cx, cy), pt) for pt in intersections)
rule_score = 1.0 - min(dist / 0.55, 1.0)
# Composición áurea: cercanía a líneas/intersecciones 0.382 / 0.618.
# La proporción áurea se usa aquí como retícula compositiva práctica:
# permite ubicar el peso visual en zonas armónicas sin obligarlo al centro.
golden_lines = (0.382, 0.618)
golden_intersections = [(gx, gy) for gx in golden_lines for gy in golden_lines]
golden_dist = min(math.dist((cx, cy), pt) for pt in golden_intersections)
golden_score = 1.0 - min(golden_dist / 0.52, 1.0)
# Balance geométrico (izq vs der, sup vs inf en cantidad de píxeles de borde).
if len(xs) > 0:
left = float((xs < w / 2).sum())
right = float((xs >= w / 2).sum())
up = float((ys < h / 2).sum())
down = float((ys >= h / 2).sum())
lr = abs(left - right) / max(left + right, 1.0)
ud = abs(up - down) / max(up + down, 1.0)
# Buen balance ≈ desbalance < 0.30. Convertimos a puntaje 0-1.
balance_score = 1.0 - min((lr + ud) / 2 / 0.60, 1.0)
else:
balance_score = 0.3
# Claridad de foco: cuán concentrado está el centroide (varianza inversa).
if len(xs) > 100:
var_x = float(np.var(xs)) / (w * w)
var_y = float(np.var(ys)) / (h * h)
focal_clarity = 1.0 - min((var_x + var_y) / 0.18, 1.0)
else:
focal_clarity = 0.2
return {
"frame_index": idx,
"timestamp": round(float(timestamp), 2),
"saliency_center_x": round(cx, 3),
"saliency_center_y": round(cy, 3),
"rule_thirds_score": round(rule_score, 3),
"golden_ratio_score": round(golden_score, 3),
"geometric_balance_score": round(balance_score, 3),
"focal_clarity_score": round(focal_clarity, 3),
"contrast_score": round(contrast, 3),
"brightness_score": round(brightness_score, 3),
"edge_density": round(edge_density, 3),
}
def _draw_overlay(frame: np.ndarray, analysis: Dict[str, Any]) -> np.ndarray:
"""Dibuja tercios, retícula áurea, intersecciones y centroide focal."""
import cv2 # type: ignore
out = frame.copy()
h, w = out.shape[:2]
color_grid = (200, 200, 60) # tercios
color_golden = (90, 180, 255) # retícula áurea
color_inter = (60, 200, 255)
color_centroid = (50, 80, 255) # rojo cálido
# Líneas tercios
for i in (1, 2):
cv2.line(out, (int(w * i / 3), 0), (int(w * i / 3), h), color_grid, 1, cv2.LINE_AA)
cv2.line(out, (0, int(h * i / 3)), (w, int(h * i / 3)), color_grid, 1, cv2.LINE_AA)
# Retícula áurea 0.382 / 0.618
for f in (0.382, 0.618):
cv2.line(out, (int(w * f), 0), (int(w * f), h), color_golden, 1, cv2.LINE_AA)
cv2.line(out, (0, int(h * f)), (w, int(h * f)), color_golden, 1, cv2.LINE_AA)
# Intersecciones de tercios y puntos áureos
for (fx, fy) in [(1 / 3, 1 / 3), (2 / 3, 1 / 3), (1 / 3, 2 / 3), (2 / 3, 2 / 3)]:
cv2.circle(out, (int(w * fx), int(h * fy)), 6, color_inter, 2, cv2.LINE_AA)
for (fx, fy) in [(0.382, 0.382), (0.618, 0.382), (0.382, 0.618), (0.618, 0.618)]:
cv2.circle(out, (int(w * fx), int(h * fy)), 4, color_golden, 1, cv2.LINE_AA)
# Centroide
cx = int(analysis["saliency_center_x"] * w)
cy = int(analysis["saliency_center_y"] * h)
cv2.drawMarker(out, (cx, cy), color_centroid, markerType=cv2.MARKER_CROSS, markerSize=22, thickness=3)
cv2.circle(out, (cx, cy), 14, color_centroid, 2, cv2.LINE_AA)
# Etiqueta superior
label = f"t={analysis['timestamp']}s | tercios={analysis['rule_thirds_score']:.2f} | aurea={analysis.get('golden_ratio_score',0):.2f} | foco=({analysis['saliency_center_x']:.2f},{analysis['saliency_center_y']:.2f})"
cv2.rectangle(out, (0, 0), (w, 26), (15, 15, 25), -1)
cv2.putText(out, label, (8, 18), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (235, 235, 245), 1, cv2.LINE_AA)
return out
def _complexity_score(edge_density: float, ocr_text: str = "") -> float:
text_words = len((ocr_text or "").split())
text_factor = min(text_words / 45.0, 1.0)
edge_factor = min(edge_density / 0.18, 1.0)
return max(0.0, min(1.0, 0.65 * edge_factor + 0.35 * text_factor))
def _visual_summary(rule_thirds: float, golden_ratio: float, balance: float, focal: float, complexity: float) -> str:
parts = []
parts.append("composición alineada con tercios" if rule_thirds >= 0.55 else "composición centrada o con foco fuera de tercios")
parts.append("buena aproximación a proporción áurea" if golden_ratio >= 0.55 else "aprovechamiento áureo mejorable")
parts.append("buen balance geométrico" if balance >= 0.55 else "balance geométrico mejorable")
parts.append("foco visual claro" if focal >= 0.40 else "foco visual difuso")
parts.append("carga visual controlada" if complexity < 0.55 else "alta carga visual")
return "; ".join(parts) + "."
def _visual_conclusion(score: float, rule_thirds: float, golden_ratio: float, balance: float, focal: float, complexity: float) -> str:
score100 = int(round(score * 100))
if score >= 0.70:
verdict = "La pieza tiene una base visual sólida para sostener atención."
elif score >= 0.50:
verdict = "La pieza es usable, pero requiere ajustes de encuadre o claridad antes de escalar pauta."
else:
verdict = "La pieza necesita correcciones visuales antes de invertir presupuesto de forma agresiva."
weakest = sorted([
(rule_thirds, "encuadre por tercios"),
(golden_ratio, "composición áurea"),
(balance, "balance geométrico"),
(focal, "claridad del foco"),
(1 - complexity, "carga visual"),
], key=lambda x: x[0])[0][1]
return f"Score visual {score100}/100. {verdict} El área que más conviene revisar es {weakest}."
def _theory_based_recommendations(
*, rule_thirds: float, golden_ratio: float, geometric_balance: float, visual_focus: float,
contrast: float, brightness: float, edge_density: float,
complexity: float, motion: float, ocr_text: str,
) -> Dict[str, List[str]]:
"""Recomendaciones agrupadas por teoría de composición."""
out: Dict[str, List[str]] = {
"regla_de_tercios": [],
"composicion_aurea": [],
"composicion_geometrica": [],
"foco_visual": [],
"iluminacion_y_contraste": [],
"carga_cognitiva": [],
}
if rule_thirds < 0.45:
out["regla_de_tercios"].append(
"Reposicionar el sujeto principal sobre una intersección de tercios (no en el centro). "
"Mover la cámara o reencuadrar para que rostros, productos o texto clave caigan en los cruces."
)
elif rule_thirds < 0.65:
out["regla_de_tercios"].append(
"El foco está cerca de los tercios pero podría afinarse: ajustar levemente el encuadre."
)
else:
out["regla_de_tercios"].append(
"Buena aplicación de la regla de tercios; mantener este encuadre como referencia."
)
if golden_ratio < 0.45:
out["composicion_aurea"].append(
"La proporción áurea no se aprovecha bien. Ubicar rostro, producto o CTA cerca de las líneas 0.382/0.618 para crear una lectura más armónica y menos rígida que un centro exacto."
)
elif golden_ratio < 0.65:
out["composicion_aurea"].append(
"La composición se aproxima a zonas áureas, pero puede afinarse moviendo el punto focal ligeramente hacia una intersección 0.382/0.618."
)
else:
out["composicion_aurea"].append(
"Buena alineación con proporción áurea; conservar esta distribución para escenas donde producto, rostro o CTA son protagonistas."
)
if geometric_balance < 0.45:
out["composicion_geometrica"].append(
"El peso visual está desbalanceado. Redistribuir elementos para equilibrar mitades "
"izquierda/derecha o superior/inferior, o añadir un elemento de contrapeso."
)
elif geometric_balance >= 0.65:
out["composicion_geometrica"].append(
"La composición presenta buen balance geométrico; reforzarlo con líneas guía consistentes."
)
if visual_focus < 0.35:
out["foco_visual"].append(
"El foco visual está disperso. Concentrar el interés en un único elemento por escena: "
"rostro, producto, demostración o titular en pantalla."
)
if edge_density < 0.025:
out["foco_visual"].append(
"Las escenas tienen pocos bordes/elementos visibles. Agregar un punto focal claro en los primeros segundos."
)
if contrast < 0.40:
out["iluminacion_y_contraste"].append(
"Contraste bajo entre sujeto y fondo. Aumentar iluminación clave, separar el sujeto del fondo "
"con color o profundidad de campo."
)
if brightness < 0.35:
out["iluminacion_y_contraste"].append(
"Frames demasiado oscuros/sobreexpuestos. Calibrar exposición y balance de blancos."
)
if complexity > 0.65:
out["carga_cognitiva"].append(
"Carga visual alta: demasiados elementos simultáneos. Reducir gráficos, texto y elementos "
"secundarios para que el mensaje principal se lea en menos de 2 segundos."
)
if motion > 0.28:
out["carga_cognitiva"].append(
"Cortes y movimiento muy rápidos. Mantener planos clave 1.5–3 s para mejorar comprensión."
)
if not (ocr_text or "").strip() and complexity < 0.35:
out["carga_cognitiva"].append(
"No se detectó texto en pantalla. Si el video será anuncio, agregar una frase breve de beneficio o un CTA visible."
)
# Eliminar listas vacías.
return {k: v for k, v in out.items() if v}