#!/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 -> _silueta-.mp4 (ya recortado) # recortar.py -> _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)