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>
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
"""comfyui_pixelize_sprite_png — pixeliza un PNG existente a un sprite pixel-art REAL.
|
||||
|
||||
Toma un PNG YA renderizado a alta resolucion (p.ej. 512x768 RGBA con fondo
|
||||
transparente) y lo convierte en un sprite pixel-art de verdad de `size` x `size`.
|
||||
Es la pieza reutilizable que extrae la logica de pixelizado de un PNG existente: la
|
||||
MISMA secuencia que `comfyui_pixelart_real_oneshot` aplica internamente en sus fases
|
||||
1b/2a/2a-bis/2b, pero desacoplada de la generacion. Sirve para pixelizar cada frame
|
||||
de una animacion, una hoja de sprites, o cualquier render existente sin volver a
|
||||
pasar por la difusion.
|
||||
|
||||
El metodo (report 0215) tiene tres etapas de post-proceso encadenadas:
|
||||
|
||||
1. crop al contenido (`crop_to_content`): recorta al bounding box del sujeto y lo
|
||||
cuadra para que llene el frame; si el sujeto ocupa el 25% del lienzo, a 32px
|
||||
quedaria diminuto. Best-effort: si falla, se sigue con el PNG original.
|
||||
2. downscale a un grid `size` x `size`:
|
||||
- engine="pixeloe": downscale contrast-aware (`pixeloe_downscale`, no_upscale)
|
||||
que conserva la silueta — para sujetos con contorno (personajes, iconos).
|
||||
Si la lib no esta o falla, cae automaticamente a "nearest".
|
||||
- engine="nearest": downscale nearest simple (PIL) — mas barato, para
|
||||
tiles/texturas sin contorno.
|
||||
PixelOE trabaja en RGB y pierde el alpha, asi que tras el (fase 2a-bis) se
|
||||
downscalea el canal alpha por separado (nearest) y se reaplica al grid.
|
||||
3. cuantizacion dura (`comfyui_pixelize_image`, downscale=1): clava la paleta
|
||||
exacta (N colores libres MEDIANCUT, o paleta fija pico-8 / nes / game-boy) sobre
|
||||
el grid ya hecho -> N colores + 100% grid duro, preservando el alpha.
|
||||
|
||||
Compone funciones del registry, no reescribe su logica:
|
||||
crop_to_content_py_ml (autocrop al contenido + cuadrar; pura)
|
||||
pixeloe_downscale_py_ml (downscale contrast-aware, engine pixeloe)
|
||||
comfyui_pixelize_image_py_ml (cuantizacion dura + alpha-aware)
|
||||
|
||||
Funcion impura: lectura/escritura de disco (+ subprocess del bridge de pixeloe).
|
||||
No-throw: cualquier fallo se captura y viaja en el campo `error` del dict.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# Importa las funciones del registry (mismo arbol python/functions).
|
||||
_FUNCTIONS_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if _FUNCTIONS_ROOT not in sys.path:
|
||||
sys.path.insert(0, _FUNCTIONS_ROOT)
|
||||
|
||||
from ml.comfyui_pixelize_image import comfyui_pixelize_image
|
||||
from ml.crop_to_content import crop_to_content
|
||||
from ml.pixeloe_downscale import pixeloe_downscale
|
||||
|
||||
|
||||
def comfyui_pixelize_sprite_png(
|
||||
src_path: str,
|
||||
dst_path: str,
|
||||
*,
|
||||
size: int = 32,
|
||||
colors: int = 16,
|
||||
engine: str = "pixeloe",
|
||||
palette=None,
|
||||
transparent: bool = True,
|
||||
autocrop: bool = True,
|
||||
crop_pad_ratio: float = 0.02,
|
||||
mode: str = "contrast",
|
||||
patch_size: int = 16,
|
||||
thickness: int = 2,
|
||||
alpha_threshold: int = 128,
|
||||
comfy_python: str | None = None,
|
||||
) -> dict:
|
||||
"""Pixeliza un PNG existente a un sprite pixel-art REAL de `size` x `size`.
|
||||
|
||||
Args:
|
||||
src_path: ruta del PNG de entrada (un render a alta resolucion, p.ej.
|
||||
512x768 RGBA con fondo transparente). Debe existir.
|
||||
dst_path: ruta del PNG de salida size x size (se crea el directorio si falta).
|
||||
size: lado del grid final en pixeles (32 iconos/objetos simples, 64
|
||||
personajes/sprites). Debe ser >= 1. keyword-only.
|
||||
colors: numero de colores de la paleta libre cuando palette es None
|
||||
(cuantizacion MEDIANCUT). keyword-only.
|
||||
engine: "pixeloe" (downscale contrast-aware, para sujetos con silueta:
|
||||
personajes/criaturas/iconos) o "nearest" (downscale nearest simple, mas
|
||||
barato, para tiles/texturas/fondos sin contorno). Si "pixeloe" falla o la
|
||||
lib no esta disponible, cae automaticamente a "nearest" y lo refleja en
|
||||
engine_used. keyword-only.
|
||||
palette: None (paleta libre a `colors`), nombre builtin ("pico-8", "nes",
|
||||
"game-boy") o lista de hex. Una paleta fija ignora `colors`. keyword-only.
|
||||
transparent: si True (default) trata la entrada como RGBA y produce un sprite
|
||||
RGBA con transparencia real (el fondo transparente no entra en la paleta).
|
||||
Para tiles/texturas opacas, False produce salida RGB. keyword-only.
|
||||
autocrop: si True (default) recorta el PNG al bounding box de su contenido y lo
|
||||
cuadra antes del downscale, para que el sujeto llene el frame (evita el
|
||||
sprite diminuto). Usa el alpha si transparent, o el color de fondo si RGB.
|
||||
keyword-only.
|
||||
crop_pad_ratio: margen relativo que deja el autocrop alrededor del sujeto
|
||||
(0.02 = 2% del lado). keyword-only.
|
||||
mode: modo de downscale de PixelOE ("contrast" SOTA, "k-centroid", "nearest",
|
||||
"center", "bicubic"); solo aplica con engine="pixeloe". keyword-only.
|
||||
patch_size: tamano de patch de PixelOE (default 16). keyword-only.
|
||||
thickness: grosor del outline expansion de PixelOE (default 2). keyword-only.
|
||||
alpha_threshold: umbral (0..255) para binarizar el alpha en opaco (255) o
|
||||
transparente (0) en la cuantizacion final. Solo aplica si transparent.
|
||||
keyword-only.
|
||||
comfy_python: ruta al interprete de ComfyUI (con la lib pixeloe) para el
|
||||
bridge; None autodetecta COMFY_PYTHON y luego ~/ComfyUI/.venv/bin/python3.
|
||||
keyword-only.
|
||||
|
||||
Returns:
|
||||
dict con:
|
||||
- ok (bool): True si se produjo el PNG final pixelizado.
|
||||
- out_path (str): ruta del PNG final size x size (vacio si fallo).
|
||||
- size (int): lado real del PNG final.
|
||||
- colors_final (int): numero de colores distintos en el resultado (en la zona
|
||||
opaca si es RGBA).
|
||||
- has_alpha (bool): True si el PNG final es RGBA con transparencia.
|
||||
- engine_used (str): "pixeloe" o "nearest" (refleja el fallback real).
|
||||
- autocrop_applied (bool): True si el autocrop recorto/cuadro la imagen.
|
||||
- error (str): mensaje de error; cadena vacia si todo OK.
|
||||
"""
|
||||
out = {
|
||||
"ok": False,
|
||||
"out_path": "",
|
||||
"size": int(size),
|
||||
"colors_final": 0,
|
||||
"has_alpha": False,
|
||||
"engine_used": engine,
|
||||
"autocrop_applied": False,
|
||||
"error": "",
|
||||
}
|
||||
|
||||
# --- Validaciones (no-throw). ---
|
||||
if not src_path or not os.path.isfile(src_path):
|
||||
out["error"] = f"src_path no existe: {src_path!r}"
|
||||
return out
|
||||
if int(size) < 1:
|
||||
out["error"] = f"size debe ser >= 1, recibido {size!r}"
|
||||
return out
|
||||
if engine not in ("pixeloe", "nearest"):
|
||||
out["error"] = f"engine invalido: {engine!r} (usa 'pixeloe' o 'nearest')"
|
||||
return out
|
||||
|
||||
# Directorio temporal para los intermedios (crop + mid); se limpia al final.
|
||||
try:
|
||||
tmp_dir = tempfile.mkdtemp(prefix="pixelize_sprite_")
|
||||
except OSError as exc:
|
||||
out["error"] = f"no se pudo crear directorio temporal: {exc}"
|
||||
return out
|
||||
|
||||
crop_path = os.path.join(tmp_dir, "crop.png")
|
||||
mid_path = os.path.join(tmp_dir, "mid.png")
|
||||
|
||||
try:
|
||||
# --- Fase 1b (opcional): autocrop al contenido + cuadrar. ---
|
||||
# La imagen sobre la que se hace el downscale: la recortada si autocrop, o la
|
||||
# original sin tocar.
|
||||
pre_ds_path = src_path
|
||||
if autocrop:
|
||||
try:
|
||||
from PIL import Image
|
||||
with Image.open(src_path) as base_im:
|
||||
src_im = (
|
||||
base_im.convert("RGBA") if transparent else base_im.convert("RGB")
|
||||
)
|
||||
before = src_im.size
|
||||
cropped = crop_to_content(
|
||||
src_im, pad_ratio=float(crop_pad_ratio), square=True,
|
||||
)
|
||||
cropped.save(crop_path)
|
||||
pre_ds_path = crop_path
|
||||
out["autocrop_applied"] = cropped.size != before
|
||||
except (ImportError, OSError, ValueError) as exc:
|
||||
# Autocrop es best-effort: si falla, se sigue con el src sin recortar.
|
||||
pre_ds_path = src_path
|
||||
out["autocrop_applied"] = False
|
||||
if not out["error"]:
|
||||
out["error"] = f"autocrop fallo (no critico): {exc}"
|
||||
|
||||
# --- Fase 2a: downscale a un grid `size` x `size` (mid). ---
|
||||
engine_used = engine
|
||||
if engine == "pixeloe":
|
||||
ds = pixeloe_downscale(
|
||||
pre_ds_path, mid_path, mode=mode, target_size=int(size),
|
||||
patch_size=int(patch_size), thickness=int(thickness),
|
||||
no_upscale=True, comfy_python=comfy_python,
|
||||
)
|
||||
if not ds.get("ok"):
|
||||
# Fallback limpio: PixelOE no disponible / fallo -> nearest.
|
||||
engine_used = "nearest"
|
||||
out["error"] = f"pixeloe fallo ({ds.get('error')}); fallback a nearest"
|
||||
|
||||
if engine_used == "nearest":
|
||||
# Downscale nearest simple a size x size (PIL del venv del registry).
|
||||
# nearest preserva el alpha por canal: si transparent, conserva la silueta.
|
||||
try:
|
||||
from PIL import Image
|
||||
with Image.open(pre_ds_path) as src:
|
||||
target_mode = "RGBA" if transparent else "RGB"
|
||||
small = src.convert(target_mode).resize(
|
||||
(int(size), int(size)), Image.NEAREST
|
||||
)
|
||||
small.save(mid_path)
|
||||
except (ImportError, OSError) as exc:
|
||||
out["error"] = f"downscale nearest fallo: {exc}"
|
||||
return out
|
||||
|
||||
if not os.path.isfile(mid_path):
|
||||
out["error"] = "no se genero la imagen intermedia (mid)"
|
||||
return out
|
||||
|
||||
# --- Fase 2a-bis: recombinar alpha tras pixeloe (PixelOE trabaja en RGB). ---
|
||||
# El nucleo de PixelOE convierte a RGB: el grid `mid` sale sin transparencia.
|
||||
# Se downscalea el alpha de la imagen pre-downscale por separado (nearest al
|
||||
# mismo size) y se reaplica al grid para no perder el recorte ni la
|
||||
# transparencia. (engine="nearest" ya conserva su alpha, no hace falta.)
|
||||
if transparent and engine_used == "pixeloe":
|
||||
try:
|
||||
from PIL import Image
|
||||
with Image.open(pre_ds_path) as src_im:
|
||||
alpha = src_im.convert("RGBA").getchannel("A").resize(
|
||||
(int(size), int(size)), Image.NEAREST
|
||||
)
|
||||
with Image.open(mid_path) as mid_im:
|
||||
mid_rgba = mid_im.convert("RGBA")
|
||||
mid_rgba.putalpha(alpha)
|
||||
mid_rgba.save(mid_path)
|
||||
except (ImportError, OSError) as exc:
|
||||
if not out["error"]:
|
||||
out["error"] = f"recombinacion de alpha fallo (no critico): {exc}"
|
||||
|
||||
# --- Fase 2b: cuantizacion dura (paleta exacta) sobre el grid ya hecho. ---
|
||||
quant = comfyui_pixelize_image(
|
||||
mid_path, dst_path, downscale=1, colors=int(colors), palette=palette,
|
||||
upscale_back=False, keep_alpha=bool(transparent),
|
||||
alpha_threshold=int(alpha_threshold),
|
||||
)
|
||||
if not quant.get("ok"):
|
||||
out["error"] = f"cuantizacion fallo: {quant.get('error')}"
|
||||
return out
|
||||
|
||||
out["out_path"] = dst_path
|
||||
out["size"] = quant["size"][0] if quant.get("size") else int(size)
|
||||
out["colors_final"] = quant.get("n_colors_final", 0)
|
||||
out["has_alpha"] = bool(quant.get("has_alpha", False))
|
||||
out["engine_used"] = engine_used
|
||||
out["ok"] = True
|
||||
return out
|
||||
finally:
|
||||
# Limpieza de intermedios + directorio temporal.
|
||||
for tmp in (crop_path, mid_path):
|
||||
try:
|
||||
os.remove(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.rmdir(tmp_dir)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
|
||||
# Demo autosuficiente: genera un PNG de prueba (circulo de color sobre fondo
|
||||
# transparente 512x512) y lo pixeliza a 32x32 con 16 colores y transparencia.
|
||||
demo_dir = tempfile.mkdtemp(prefix="pixelize_sprite_demo_")
|
||||
demo_src = os.path.join(demo_dir, "demo_src.png")
|
||||
demo_dst = os.path.join(demo_dir, "demo_sprite.png")
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw
|
||||
im = Image.new("RGBA", (512, 512), (0, 0, 0, 0)) # fondo transparente
|
||||
draw = ImageDraw.Draw(im)
|
||||
draw.ellipse((96, 96, 416, 416), fill=(220, 60, 60, 255)) # circulo rojo
|
||||
draw.ellipse((176, 160, 256, 240), fill=(250, 230, 120, 255)) # ojo amarillo
|
||||
draw.ellipse((280, 160, 360, 240), fill=(60, 120, 220, 255)) # ojo azul
|
||||
im.save(demo_src)
|
||||
except ImportError as exc:
|
||||
print(json.dumps({"ok": False, "error": f"PIL no disponible: {exc}"}))
|
||||
raise SystemExit(0)
|
||||
|
||||
res = comfyui_pixelize_sprite_png(
|
||||
demo_src, demo_dst, size=32, colors=16, engine="pixeloe",
|
||||
transparent=True, autocrop=True,
|
||||
)
|
||||
print(json.dumps(res, indent=2))
|
||||
Reference in New Issue
Block a user