| """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 |
|
|
| |
| 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 |
| 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") |
| |
| 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) |
| |
| 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) |
|
|
| |
| 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 |
|
|
| 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) |
|
|
| |
| if len(xs) > 0: |
| cx = float(np.mean(xs)) / w |
| cy = float(np.mean(ys)) / h |
| else: |
| cx, cy = 0.5, 0.5 |
|
|
| |
| 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) |
|
|
| |
| |
| |
| 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) |
|
|
| |
| 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) |
| |
| balance_score = 1.0 - min((lr + ud) / 2 / 0.60, 1.0) |
| else: |
| balance_score = 0.3 |
|
|
| |
| 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 |
|
|
| out = frame.copy() |
| h, w = out.shape[:2] |
| color_grid = (200, 200, 60) |
| color_golden = (90, 180, 255) |
| color_inter = (60, 200, 255) |
| color_centroid = (50, 80, 255) |
|
|
| |
| 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) |
| |
| 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) |
|
|
| |
| 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) |
| |
| 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) |
|
|
| |
| 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." |
| ) |
|
|
| |
| return {k: v for k, v in out.items() if v} |
|
|