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>
390 lines
15 KiB
Python
Executable file
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)
|