Files
fn_registry/python/functions/ml/assemble_animated_sprite.py
T
egutierrez 6cc90558d4 feat(gamedev-2d): pipeline walk_cycle_oneshot — personaje andando en pixel-art animado
Promueve el caso 1 del report 0217 (animacion de sprites de personaje) a un
pipeline one-shot: de un prompt de personaje a un sprite sheet + GIF/WEBP en loop,
frame-by-frame dirigido por pose (ControlNet OpenPose + seed fija + Rembg) con cada
frame pixelizado a NxN RGBA.

Nuevas funciones reutilizables (issue 0087, crecimiento por composicion):
- comfyui_walk_cycle_oneshot (pipeline): orquesta poses -> generacion -> pixelizado
  -> ensamblado. No-throw, salta frames que fallan. Modo openpose (esqueletos reales)
  con fallback prompt-pose.
- render_openpose_walk_skeletons: dibuja N esqueletos OpenPose COCO-18 del walk cycle
  (el insumo que el report 0217 marco como faltante).
- comfyui_pixelize_sprite_png: PNG existente -> NxN RGBA pixel-art real (compone
  crop_to_content + pixeloe_downscale + comfyui_pixelize_image).
- assemble_animated_sprite: frames RGBA -> sprite sheet horizontal + WEBP/GIF loop.
- comfyui_build_walk_cycle_workflow (pura): grafo API del workflow animado para la UI
  (ControlNet OpenPose -> KSampler xN seed fija -> ImageBatch -> Rembg -> SaveAnimatedWEBP).

Verificado en GPU: GIF/WEBP de caballero andando, 4 frames 32x32 (y 64x64) RGBA con
fondo transparente y 16 colores, identidad de silueta consistente, piernas que cambian.
Metodo de poses usado: OpenPose real (sin fallback). Evidencia en report 0221.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 18:14:46 +02:00

222 lines
8.2 KiB
Python

"""Ensambla frames PNG RGBA en un sprite sheet horizontal + una animacion en loop.
Funcion impura: lee N frames de disco (los frames ya pixelizados de un walk cycle,
por ejemplo) y escribe DOS entregables:
1. Un sprite sheet horizontal (1 fila x N columnas) PNG RGBA, con la transparencia
de cada frame intacta.
2. Una animacion en bucle (WEBP lossless o GIF animado) que reproduce los frames.
Es la pieza de ensamblado final de cualquier animacion de sprite: convierte una lista
de frames sueltos en algo que se ve animado (la .webp/.gif) y algo que un motor de
juego puede trocear (el sheet). Solo depende de PIL (Pillow), presente en el venv del
registry. No lanza excepciones: cualquier problema se reporta en el campo "error".
"""
from __future__ import annotations
import os
def assemble_animated_sprite(
frame_paths: list,
out_dir: str,
*,
name: str = "anim",
fps: int = 8,
fmt: str = "webp",
loop: bool = True,
spritesheet: bool = True,
pad: int = 0,
) -> dict:
"""Monta un sprite sheet horizontal y una animacion en loop a partir de N frames.
Carga cada ruta de ``frame_paths`` como RGBA. Los frames que falten o no abran se
SALTAN (se anota un aviso en ``error``, no se aborta): se anima con los que haya.
El tamano se normaliza al del primer frame valido; los frames de tamano distinto se
reescalan con NEAREST a ese tamano (preserva el pixel-art duro, sin interpolacion).
Args:
frame_paths: lista de rutas a PNG RGBA, en orden de reproduccion.
out_dir: directorio de salida; se crea si no existe.
name: nombre base de los ficheros generados (``<name>_sheet.png`` y
``<name>.<ext>``). keyword-only.
fps: frames por segundo de la animacion; duration_ms = round(1000/max(1,fps)).
keyword-only.
fmt: formato de la animacion, "webp" (recomendado) o "gif". keyword-only.
loop: si True la animacion se repite indefinidamente; si False se reproduce una
sola vez. keyword-only.
spritesheet: si True genera tambien el sprite sheet horizontal PNG RGBA.
keyword-only.
pad: pixeles de separacion transparente entre columnas del sheet (default 0).
keyword-only.
Returns:
dict con:
- ok (bool): True si se produjo al menos la animacion con >=1 frame valido.
- spritesheet_path (str): ruta del PNG del sheet ("" si spritesheet=False o fallo).
- animation_path (str): ruta de la animacion WEBP/GIF ("" si fallo).
- n_frames (int): numero de frames validos efectivamente usados.
- frame_size ([w, h]): tamano del frame normalizado.
- fmt (str): formato real de la animacion ("webp" o "gif").
- error (str): avisos y/o mensaje de error; "" si todo fue limpio.
"""
out = {
"ok": False,
"spritesheet_path": "",
"animation_path": "",
"n_frames": 0,
"frame_size": [0, 0],
"fmt": "",
"error": "",
}
warnings: list = []
try:
from PIL import Image
except ImportError:
out["error"] = "PIL (Pillow) no esta instalado en este interprete"
return out
if not frame_paths:
out["error"] = "frame_paths vacio: no hay nada que ensamblar"
return out
fmt = str(fmt).lower().strip()
if fmt not in ("webp", "gif"):
out["error"] = f"fmt invalido {fmt!r}: usa 'webp' o 'gif'"
return out
out["fmt"] = fmt
# --- Cargar y normalizar frames (saltando los invalidos) ---
frames: list = []
target = None # (w, h) del primer frame valido
for path in frame_paths:
if not os.path.isfile(path):
warnings.append(f"falta: {path}")
continue
try:
with Image.open(path) as src:
im = src.convert("RGBA")
except (OSError, ValueError) as exc:
warnings.append(f"no abre {path}: {exc}")
continue
if target is None:
target = (im.width, im.height)
elif (im.width, im.height) != target:
im = im.resize(target, Image.NEAREST)
frames.append(im)
if not frames:
out["error"] = "; ".join(["0 frames validos"] + warnings)
return out
w, h = target
out["frame_size"] = [w, h]
out["n_frames"] = len(frames)
n = len(frames)
try:
os.makedirs(out_dir, exist_ok=True)
except OSError as exc:
out["error"] = "; ".join([f"no se pudo crear out_dir {out_dir!r}: {exc}"] + warnings)
return out
# --- Sprite sheet horizontal (1 fila x N columnas), RGBA transparente ---
if spritesheet:
pad = max(0, int(pad))
sheet_w = n * w + (n - 1) * pad if n > 0 else 0
sheet = Image.new("RGBA", (sheet_w, h), (0, 0, 0, 0))
for i, im in enumerate(frames):
x = i * (w + pad)
# Tercer arg = mascara alpha del propio frame: respeta su transparencia.
sheet.paste(im, (x, 0), im)
sheet_path = os.path.join(out_dir, f"{name}_sheet.png")
try:
sheet.save(sheet_path, format="PNG")
out["spritesheet_path"] = sheet_path
except OSError as exc:
warnings.append(f"sheet no guardado: {exc}")
# --- Animacion en loop (WEBP lossless o GIF con alpha binario) ---
duration_ms = round(1000 / max(1, int(fps)))
loop_count = 0 if loop else 1 # 0 = infinito
ext = fmt
anim_path = os.path.join(out_dir, f"{name}.{ext}")
try:
if fmt == "webp":
frames[0].save(
anim_path,
save_all=True,
append_images=frames[1:],
duration=duration_ms,
loop=loop_count,
format="WEBP",
lossless=True, # no ensucia el pixel-art
disposal=2, # limpia entre frames -> transparencia correcta
)
else: # gif
pal_frames = [_rgba_to_p_transparent(im) for im in frames]
pal_frames[0].save(
anim_path,
save_all=True,
append_images=pal_frames[1:],
duration=duration_ms,
loop=loop_count,
format="GIF",
transparency=255, # indice reservado para el pixel transparente
disposal=2,
)
out["animation_path"] = anim_path
out["ok"] = True
except (OSError, ValueError) as exc:
warnings.append(f"animacion no guardada: {exc}")
out["ok"] = False
out["error"] = "; ".join(warnings)
return out
def _rgba_to_p_transparent(im, alpha_threshold: int = 128):
"""Convierte un frame RGBA a modo P reservando el indice 255 como transparente.
GIF solo soporta 1 bit de alpha: cada pixel es opaco o totalmente transparente.
Los pixeles con alpha < alpha_threshold se mapean al indice 255 (transparente);
el resto se cuantiza a 255 colores (indices 0..254).
"""
from PIL import Image
alpha = im.getchannel("A")
# Cuantiza el RGB a 255 colores -> indices 0..254 libres, 255 para transparencia.
p = im.convert("RGB").quantize(colors=255, method=Image.Quantize.MEDIANCUT)
# Mascara de los pixeles "transparentes" (alpha por debajo del umbral).
mask = alpha.point(lambda a: 255 if a < alpha_threshold else 0)
p.paste(255, (0, 0), mask)
return p
if __name__ == "__main__":
import json
import tempfile
from PIL import Image as _Image, ImageDraw as _ImageDraw
# --- Genera 4 frames de prueba: un cuadrado de color que se mueve de izquierda a
# derecha sobre un lienzo RGBA transparente de 32x32. ---
tmp = tempfile.mkdtemp(prefix="assemble_sprite_demo_")
demo_frames: list = []
box = 10
for i in range(4):
frame = _Image.new("RGBA", (32, 32), (0, 0, 0, 0)) # fondo transparente
d = _ImageDraw.Draw(frame)
x0 = 1 + i * 6 # se desplaza hacia la derecha cada frame
d.rectangle([x0, 11, x0 + box, 11 + box], fill=(40, 180, 230, 255))
fpath = os.path.join(tmp, f"frame_{i:02d}.png")
frame.save(fpath)
demo_frames.append(fpath)
result = assemble_animated_sprite(
demo_frames, tmp, name="walk_demo", fps=8, fmt="webp"
)
print(json.dumps(result, indent=2))