FOSFENO/scripts/catalogar-visuales.py
hacklab f017246f0e Capas independientes, projectM en el mapping y catalogo de la galeria
Mapping
- Los clips salian del reves: mapper.js activaba UNPACK_FLIP_Y_WEBGL, pero el
  modelo va con origen arriba-izquierda y texImage2D ya sube la primera fila
  en t=0, asi que el flip la mandaba al final. Comprobado renderizando el
  mapper real en Chromium headless con una fuente mitad roja / mitad azul.
- projectM se puede usar YA en una capa: los .milk de data/presets-projectm se
  traducen a Butterchurn en el navegador al elegirlos (milkdrop-preset-
  converter). La capa MilkDrop gana 'biblioteca' (butter | projectm) sin
  cambiar su firma, para no recrear el contexto WebGL al cambiar de una a otra.
  Medido: 100/100 presets de una muestra convierten, 84/84 de los que llevan
  shaders warp/comp; ~7 ms por preset.
- Cada capa tiene su pestana arriba y su propia vista, y '+ CAPA' crea una y
  entra en ella: con varias capas, ir y volver a MAPPING no era viable.
- Dos capas MilkDrop sin preset ya no salen identicas (cogian el indice 0):
  cada instancia elige uno al azar y lo escribe en el estado.
- Una capa nueva ya no nace en la fuente "motor", que es el lienzo del motor
  activo y hacia que dos capas ensenaran lo mismo.

Galeria de visuales
- scripts/catalogar-visuales.py: ficha de cada clip (pelicula, personajes,
  duracion) y, sobre todo, si es una silueta de verdad y cuanta figura tiene.
  Distingue silueta de corte crudo por el negro puro del fondo: 0,63-0,90
  frente a 0,04-0,06, sin zona gris.
- El panel lista los clips agrupados por pelicula, con nombre legible y aviso
  de los que casi no tienen figura, en vez del nombre del archivo.
- Soporte de siluetas con canal alfa (.webm VP9): transparencia de verdad, sin
  recorte por luminancia. El shader del mapper ya la respeta.

Documentacion
- docs/visuales.md nuevo; pendiente.md al dia con lo hecho y lo que queda.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 14:56:01 +02:00

390 lines
15 KiB
Python
Executable file

#!/usr/bin/env python3
"""
FOSFENO :: Catalogo de visuales
==========================================================================
Recorre data/videos y escribe data/visuales.json: la ficha de cada clip de
la galeria (de que peli sale, quien aparece, cuanto dura, si es una silueta
de verdad y como de llena esta) mas una miniatura en data/miniaturas.
El panel lo usa para listar los clips por pelicula y con nombre legible en
vez del nombre del archivo, y para avisar de los que casi no tienen imagen.
scripts/catalogar-visuales.py # cataloga y escribe el JSON
scripts/catalogar-visuales.py --limpiar # ademas aparta los que no son
# siluetas (se ve el fondo)
Como distingue una silueta de un trozo de pelicula tal cual: en un clip
siluetado el fondo es negro PURO (valor 0), asi que basta medir que
porcentaje del fotograma esta a cero. En los clips siluetados de la galeria
sale 0,63-0,90; en los recortes sin siluetear, 0,04-0,06. No hay zona gris.
Necesita ffmpeg/ffprobe y numpy.
"""
import argparse
import json
import re
import shutil
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
try:
import numpy as np
except ImportError:
sys.exit("Falta numpy: pip install numpy (o usa el .venv de DETEKTION)")
BASE = Path(__file__).resolve().parent.parent
VIDEOS = BASE / "data" / "videos"
MINIS = BASE / "data" / "miniaturas"
SALIDA = BASE / "data" / "visuales.json"
DESCARTES = VIDEOS / "_descartados"
VIDEO_EXT = (".mp4", ".webm", ".mov", ".m4v", ".ogv")
IMAGE_EXT = (".jpg", ".jpeg", ".png", ".gif", ".webp")
# Muestreo para las metricas: 2 fotogramas por segundo a 160x90 en gris.
FPS_MUESTRA = 2
MW, MH = 160, 90
# Umbrales (ver cabecera: medidos sobre la galeria real, no inventados)
UMBRAL_SILUETA = 0.30 # % de negro puro a partir del cual es una silueta
UMBRAL_FIGURA = 32 # gris a partir del cual cuenta como figura
VACIA_FIGURA = 0.005 # menos del 0,5% del cuadro = fotograma sin nada
CALIDAD_VACIA = 0.005 # mediana de figura por debajo => clip sin contenido
CALIDAD_FLOJA = 0.03 # mediana por debajo => clip flojo (poca imagen)
MUERTOS_VACIA = 0.50 # mas de la mitad de fotogramas vacios => sin contenido
# --------------------------------------------------------------------------
# Nombres: de que obra sale cada clip y quien aparece
# --------------------------------------------------------------------------
OBRAS = {
"p1": ("Piratas del Caribe: La maldicion de la Perla Negra", 2003),
"p2": ("Piratas del Caribe: El cofre del hombre muerto", 2006),
"p3": ("Piratas del Caribe: En el fin del mundo", 2007),
"p4": ("Piratas del Caribe: En mareas misteriosas", 2011),
"p5": ("Piratas del Caribe: La venganza de Salazar", 2017),
"hackers": ("Hackers", 1995),
"tron": ("Tron", 1982),
"devs": ("Devs", 2020),
"avatar": ("Avatar", 2009),
# Claves de varias palabras: se prueban de la mas larga a la mas corta
"rick-and-morty": ("Rick and Morty", 2013),
"rickmorty": ("Rick and Morty", 2013),
"los-pinguinos-de-madagascar": ("Los pinguinos de Madagascar", 2014),
"pinguinos": ("Los pinguinos de Madagascar", 2014),
"el-club-de-la-lucha": ("El club de la lucha", 1999),
"fightclub": ("El club de la lucha", 1999),
"alien-earth": ("Alien Earth", 2025),
}
# Personajes reconocidos. Las claves con guion son de dos palabras y se
# prueban antes que las sueltas.
PERSONAJES = {
"bootstrap-bill": "Bootstrap Bill Turner",
"davy-jones": "Davy Jones",
"jack": "Jack Sparrow",
"will": "Will Turner",
"elizabeth": "Elizabeth Swann",
"barbossa": "Hector Barbossa",
"barbanegra": "Barbanegra",
"angelica": "Angelica",
"carina": "Carina Smyth",
"henry": "Henry Turner",
"salazar": "Capitan Salazar",
"gibbs": "Joshamee Gibbs",
"pintel": "Pintel",
"ragetti": "Ragetti",
"norrington": "James Norrington",
"beckett": "Cutler Beckett",
"calypso": "Tia Dalma",
}
# Lo que no es un personaje describe la escena. Igual que PERSONAJES, las
# claves con guion son de dos palabras y se prueban antes que las sueltas.
ESCENAS = {
"consejo-piratas": "consejo de piratas",
"nina": "de nina",
"tripulacion": "y su tripulacion",
"pirata": "pirata",
"cielo": "contra el cielo",
"tormenta": "en la tormenta",
"consejo": "consejo",
"piratas": "de piratas",
"duelo": "duelo",
"holandes": "el Holandes Errante",
"singapur": "Singapur",
"sirenas": "sirenas",
}
MODOS = {"negro": "silueta sobre negro", "croma": "croma verde",
"sombra": "sombra solida",
"alfa": "transparencia real (canal alfa)"}
# Morralla de los nombres de descarga: no describe nada del clip.
RUIDO = {"remastered", "bluray", "brrip", "webrip", "web", "webdl", "hdtv",
"extended", "proper", "repack", "x264", "x265", "hevc", "h264",
"aac", "ac3", "dts", "10bit", "1080p", "720p", "2160p", "4k",
"yify", "yts", "eztv", "rarbg", "sample"}
def bonito(txt):
return txt.replace("-", " ").replace("_", " ").strip().capitalize()
def analizar_nombre(stem):
"""Del nombre de archivo saca obra, personajes, etiquetas y titulo."""
# Las dos convenciones de DETEKTION:
# siluetear.py -> <algo>_silueta-<modo>.mp4 (ya recortado)
# recortar.py -> <peli>_13m26s.mp4 (corte crudo, sin siluetear)
tipo, modo, momento, origen = "clip", "", "", "manual"
resto = stem
if "_silueta-" in stem:
resto, modo = stem.split("_silueta-", 1)
tipo, origen = "silueta", "detektion"
elif "_" in stem:
resto, cola = stem.split("_", 1)
if re.fullmatch(r"\d+m\d+s", cola):
momento, tipo, origen = cola, "corte", "detektion"
trozos = resto.split("-")
# Se prueba el prefijo mas largo primero: "rick-and-morty" antes que
# "rick", que si no cada serie de varias palabras se parte mal.
obra = anio = None
for n in range(min(5, len(trozos)), 0, -1):
clave = "-".join(trozos[:n]).lower()
if clave in OBRAS:
obra, anio = OBRAS[clave]
trozos = trozos[n:]
break
if not obra:
# Nombre largo tipo "hackers-1995-remastered-1080p-bluray": busca la
# obra por cualquiera de sus trozos antes de rendirse.
for i, t in enumerate(trozos):
if t.lower() in OBRAS:
obra, anio = OBRAS[t.lower()]
trozos = trozos[i + 1:]
break
if not obra:
obra, anio = bonito(resto), None
personajes, etiquetas = [], []
i = 0
while i < len(trozos):
par = "-".join(trozos[i:i + 2]).lower()
uno = trozos[i].lower()
if par in PERSONAJES:
personajes.append(PERSONAJES[par]); i += 2; continue
if par in ESCENAS:
etiquetas.append(ESCENAS[par]); i += 2; continue
if uno in PERSONAJES:
personajes.append(PERSONAJES[uno])
elif uno in ESCENAS:
etiquetas.append(ESCENAS[uno])
elif re.fullmatch(r"s\d{1,2}e\d{1,2}", uno):
etiquetas.append(uno.upper()) # capitulo: S09E01
elif re.fullmatch(r"\d+m\d+s", uno):
etiquetas.append("min " + uno.replace("m", ":").rstrip("s"))
elif uno and uno not in RUIDO and not uno.isdigit():
etiquetas.append(uno)
i += 1
if personajes:
titulo = " y ".join(personajes)
if etiquetas:
titulo += " (" + ", ".join(etiquetas) + ")"
elif etiquetas:
titulo = ", ".join(etiquetas).capitalize()
elif momento:
titulo = "Fragmento en " + momento.replace("m", " min ").replace("s", " s")
else:
titulo = bonito(resto)
return {"obra": obra, "anio": anio, "titulo": titulo, "tipo": tipo,
"modo": MODOS.get(modo, modo), "momento": momento,
"origen": origen, "personajes": personajes, "etiquetas": etiquetas}
# --------------------------------------------------------------------------
# Medidas
# --------------------------------------------------------------------------
def ffprobe(path):
cmd = ["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=width,height,r_frame_rate",
"-show_entries", "format=duration", "-of", "json", str(path)]
try:
out = json.loads(subprocess.run(cmd, capture_output=True,
text=True, check=True).stdout)
except (subprocess.CalledProcessError, json.JSONDecodeError):
return None
st = (out.get("streams") or [{}])[0]
fps = 0.0
try:
num, den = st.get("r_frame_rate", "0/1").split("/")
fps = round(float(num) / float(den), 2) if float(den) else 0.0
except (ValueError, ZeroDivisionError):
pass
return {"ancho": st.get("width", 0), "alto": st.get("height", 0),
"fps": fps,
"duracion": round(float(out.get("format", {}).get("duration") or 0), 2)}
def muestrear(path, canal_alfa=False):
"""Fotogramas muestreados en gris, o None si no decodifica.
Con canal_alfa mide la TRANSPARENCIA en vez de la luminancia: hace falta
para los .webm de silueta, donde lo que separa figura de fondo es el
canal alfa y no el negro. Ojo con el decodificador: el vp9 nativo de
ffmpeg se come el alfa en silencio (devuelve todo opaco), asi que hay
que pedir libvpx-vp9 a mano.
"""
cmd = ["ffmpeg", "-v", "error"]
if canal_alfa:
cmd += ["-c:v", "libvpx-vp9"]
cmd += ["-i", str(path), "-vf",
f"fps={FPS_MUESTRA},scale={MW}:{MH},"
# alphaextract necesita que le llegue un formato CON alfa: sin el
# format=rgba delante, el filtro no negocia y ffmpeg aborta
+ ("format=rgba,alphaextract" if canal_alfa else "format=gray"),
"-pix_fmt", "gray", "-f", "rawvideo", "-"]
raw = subprocess.run(cmd, capture_output=True).stdout
n = len(raw) // (MW * MH)
if not n:
return None
return np.frombuffer(raw[:n * MW * MH], dtype=np.uint8).reshape(n, MH, MW)
def medir(fr):
negro = float((fr <= 2).mean())
figura = (fr > UMBRAL_FIGURA).mean(axis=(1, 2))
return {
"negroPuro": round(negro, 3),
"figura": round(float(np.median(figura)), 4),
"figuraPico": round(float(np.percentile(figura, 90)), 4),
"brillo": round(float(fr.mean()), 2),
"vacios": round(float((figura < VACIA_FIGURA).mean()), 2),
"_mejor": int(np.argmax(figura)),
}
def clasificar(m, es_alfa=False):
"""(clase, calidad, aviso). clase: silueta | con-fondo.
Con es_alfa las metricas vienen del canal alfa, no de la luminancia:
'negroPuro' es entonces el porcentaje de cuadro transparente. Un clip
con alfa nunca puede ser 'con-fondo' — como mucho, opaco entero.
"""
if m["negroPuro"] < UMBRAL_SILUETA:
if es_alfa:
return ("silueta", "vacia",
"Lleva canal alfa pero casi no hay zona transparente: "
"el recorte no separo la figura del fondo.")
return ("con-fondo", "descartable",
"No esta siluetado: se ve el fondo entero de la pelicula. "
"Pasalo por DETEKTION (siluetear.py) antes de usarlo.")
if m["figura"] < CALIDAD_VACIA or m["vacios"] > MUERTOS_VACIA:
return ("silueta", "vacia",
"Silueta correcta pero casi siempre vacia: apenas hay figura.")
if m["figura"] < CALIDAD_FLOJA:
return ("silueta", "floja",
"Figura pequena o muy oscura: se vera poco sobre el fondo.")
return ("silueta", "buena", "")
def miniatura(path, segundo, destino):
destino.parent.mkdir(parents=True, exist_ok=True)
cmd = ["ffmpeg", "-v", "error", "-ss", f"{segundo:.2f}", "-i", str(path),
"-frames:v", "1", "-vf", "scale=320:-2", "-q:v", "4",
str(destino), "-y"]
return subprocess.run(cmd, capture_output=True).returncode == 0
# --------------------------------------------------------------------------
def catalogar(limpiar=False):
if not VIDEOS.is_dir():
sys.exit(f"No existe {VIDEOS}")
fichas, apartados = [], []
archivos = sorted(f for f in VIDEOS.iterdir()
if f.is_file()
and f.suffix.lower() in VIDEO_EXT + IMAGE_EXT)
for f in archivos:
ficha = {"archivo": f.name, "peso": f.stat().st_size}
ficha.update(analizar_nombre(f.stem))
if f.suffix.lower() in IMAGE_EXT:
ficha.update({"clase": "imagen", "calidad": "buena", "aviso": "",
"tipo": "imagen"})
info = ffprobe(f) or {}
ficha.update(info)
mini = MINIS / (f.stem + ".jpg")
if miniatura(f, 0, mini):
ficha["miniatura"] = "miniaturas/" + mini.name
fichas.append(ficha)
print(f" imagen {f.name}")
continue
info = ffprobe(f)
if not info:
print(f" ILEGIBLE {f.name}")
ficha.update({"clase": "roto", "calidad": "descartable",
"aviso": "ffprobe no puede leerlo."})
fichas.append(ficha)
continue
ficha.update(info)
# Las siluetas con canal alfa se miden por la transparencia
es_alfa = f.name.endswith("_silueta-alfa.webm")
fr = muestrear(f, canal_alfa=es_alfa)
if fr is None:
ficha.update({"clase": "roto", "calidad": "descartable",
"aviso": "No se pudo decodificar ningun fotograma."})
fichas.append(ficha)
print(f" ILEGIBLE {f.name}")
continue
m = medir(fr)
mejor = m.pop("_mejor") / FPS_MUESTRA
clase, calidad, aviso = clasificar(m, es_alfa)
ficha.update({"metricas": m, "clase": clase, "calidad": calidad,
"aviso": aviso})
mini = MINIS / (f.stem + ".jpg")
if miniatura(f, mejor, mini):
ficha["miniatura"] = "miniaturas/" + mini.name
if limpiar and clase == "con-fondo":
DESCARTES.mkdir(parents=True, exist_ok=True)
shutil.move(str(f), str(DESCARTES / f.name))
if mini.exists():
mini.unlink()
apartados.append(f.name)
print(f" APARTADO {f.name} ({aviso})")
continue
fichas.append(ficha)
print(f" {calidad:<10} {f.name[:44]:<44} "
f"negro={m['negroPuro']:.2f} figura={m['figura']:.3f}")
datos = {
"generado": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
"total": len(fichas),
"clips": fichas,
}
SALIDA.parent.mkdir(parents=True, exist_ok=True)
SALIDA.write_text(json.dumps(datos, indent=2, ensure_ascii=False),
encoding="utf-8")
print(f"\n{len(fichas)} fichas -> {SALIDA.relative_to(BASE)}")
if apartados:
print(f"{len(apartados)} apartados -> {DESCARTES.relative_to(BASE)}/")
porcal = {}
for x in fichas:
porcal[x.get("calidad", "?")] = porcal.get(x.get("calidad", "?"), 0) + 1
print("Calidad: " + ", ".join(f"{k}={v}" for k, v in sorted(porcal.items())))
if __name__ == "__main__":
ap = argparse.ArgumentParser(description="Cataloga la galeria de FOSFENO")
ap.add_argument("--limpiar", action="store_true",
help="aparta en _descartados/ los clips sin siluetear")
catalogar(ap.parse_args().limpiar)