merge(gamedev-2d): comfyui_walk_cycle_oneshot — walk cycle pose-driven + pixelart animado (5 funciones)

This commit is contained in:
2026-06-28 18:16:18 +02:00
10 changed files with 1960 additions and 0 deletions
@@ -0,0 +1,92 @@
---
name: assemble_animated_sprite
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "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"
description: "Ensambla N frames PNG RGBA (p.ej. los frames de un walk cycle ya pixelizados a 32x32 con alpha) en DOS entregables: un sprite sheet horizontal (1 fila x N columnas) PNG RGBA con la transparencia intacta, y una animacion en loop WEBP lossless o GIF animado. Es la pieza de ensamblado final de cualquier animacion de sprite. Salta frames que falten o no abran (aviso en error, no aborta); normaliza tamano al primer frame valido reescalando con NEAREST. Solo PIL. No-throw. Devuelve {ok, spritesheet_path, animation_path, n_frames, frame_size, fmt, error}."
tags: [gamedev-2d, comfyui, sprite, animation]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: frame_paths
desc: "lista de rutas a PNG RGBA en orden de reproduccion; los que falten o no abran se saltan (aviso en error)."
- name: out_dir
desc: "directorio de salida; se crea si no existe. Se escriben '<name>_sheet.png' y '<name>.<ext>' dentro."
- name: name
desc: "nombre base de los ficheros generados (keyword-only, default 'anim')."
- name: fps
desc: "frames por segundo de la animacion; duration_ms = round(1000/max(1,fps)) por frame (keyword-only, default 8)."
- name: fmt
desc: "formato de la animacion: 'webp' (recomendado, lossless, alpha completo) o 'gif' (alpha binario) (keyword-only)."
- name: loop
desc: "si True la animacion se repite indefinidamente (loop=0); si False una sola vez (keyword-only, default True)."
- name: spritesheet
desc: "si True genera tambien el sprite sheet horizontal PNG RGBA (keyword-only, default True)."
- name: pad
desc: "pixeles de separacion transparente entre columnas del sheet (keyword-only, default 0)."
output: "dict con ok (bool, True si se produjo la animacion con >=1 frame valido), spritesheet_path (str, '' si spritesheet=False o fallo), animation_path (str, '' si fallo), n_frames (int, frames validos usados), frame_size ([w,h] del frame normalizado), fmt (str, 'webp'|'gif'), error (str, avisos y/o error; '' si limpio)."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/assemble_animated_sprite.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.assemble_animated_sprite import assemble_animated_sprite
# Frames de un walk cycle ya pixelizados a 32x32 RGBA (p.ej. salida del pipeline ComfyUI):
frames = [
"/tmp/walk/frame_00.png",
"/tmp/walk/frame_01.png",
"/tmp/walk/frame_02.png",
"/tmp/walk/frame_03.png",
]
res = assemble_animated_sprite(frames, "/tmp/walk_out", name="hero_walk", fps=8, fmt="webp")
# {'ok': True,
# 'spritesheet_path': '/tmp/walk_out/hero_walk_sheet.png',
# 'animation_path': '/tmp/walk_out/hero_walk.webp',
# 'n_frames': 4, 'frame_size': [32, 32], 'fmt': 'webp', 'error': ''}
```
## Cuando usarla
Al final de cualquier pipeline de animacion de sprite, cuando ya tienes los frames
sueltos (pixelizados, con alpha) y necesitas (a) verlos animados en bucle para validar
el ciclo a ojo y (b) un sprite sheet horizontal listo para que un motor de juego lo
trocee por columnas. Tipico despues de generar un walk cycle frame a frame con ComfyUI
y pasarlo por el pixelizado: este es el paso de "juntarlo todo". Usa `fmt="webp"` por
defecto; `fmt="gif"` solo si necesitas compatibilidad con visores que no abren WEBP.
## Gotchas
- **GIF solo tiene alpha binario** (1 bit): cada pixel es opaco o totalmente
transparente, los pixeles con `alpha < 128` se vuelven transparentes y se pierde el
anti-aliasing del borde. **WEBP (lossless) es el formato recomendado** para sprites con
alpha — conserva el canal alpha completo y no ensucia el pixel-art. Usa GIF solo por
compatibilidad.
- Al guardar GIF, PIL **reoptimiza la paleta** y el indice de transparencia puede
cambiar (p.ej. de 255 a 1 al releer): es normal, los pixeles transparentes se
preservan (verificable convirtiendo el frame a RGBA y mirando el canal alpha).
- **Frames que faltan o no abren se SALTAN** (se anota en `error`), no se aborta: la
animacion se monta con los frames validos. Si quedan **0 frames validos**`ok=False`.
- El campo `error` puede venir **no vacio aunque `ok=True`**: ahi van los avisos de
frames saltados. `ok` refleja si se genero la animacion, no la ausencia de avisos.
- El tamano se normaliza al **primer frame valido**; los frames de tamano distinto se
reescalan con **NEAREST** (sin interpolacion, preserva el pixel-art duro), lo que puede
deformarlos si su aspect ratio difiere. Asegurate de que todos los frames ya vienen al
mismo tamano.
- Escribe en disco: crea `out_dir` si no existe; si no hay permiso de escritura, el
fallo del sheet va a `error` como aviso y el de la animacion pone `ok=False`.
- `disposal=2` limpia el lienzo entre frames (transparencia correcta en cada paso); sin
el, los frames se acumularian unos sobre otros.
@@ -0,0 +1,221 @@
"""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))
@@ -0,0 +1,121 @@
---
name: comfyui_build_walk_cycle_workflow
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: pure
signature: "def comfyui_build_walk_cycle_workflow(subject: str, pose_skeletons: list, *, ckpt_name: str = \"IMG_dreamshaper_8.safetensors\", char_lora: str | None = None, lora_strength: float = 1.0, controlnet_name: str = \"control_v11p_sd15_openpose_fp16.safetensors\", controlnet_strength: float = 0.7, controlnet_start: float = 0.0, controlnet_end: float = 0.8, transparent: bool = True, rembg_model: str = \"u2net\", negative: str = \"blurry, lowres, extra limbs, deformed\", width: int = 512, height: int = 768, steps: int = 24, cfg: float = 7.0, seed: int = 0, sampler_name: str = \"dpmpp_2m\", scheduler: str = \"karras\", fps: int = 8, filename_prefix: str = \"walk_cycle\") -> dict"
description: "Construye el dict (API format) del workflow de un WALK CYCLE animado: genera N frames de un personaje en N poses OpenPose con la MISMA seed (identidad consistente), los combina en un batch encadenando ImageBatch, recorta el fondo a alpha con Rembg y los exporta como WEBP animado con SaveAnimatedWEBP. Caso 1 del report 0217 (animacion de sprite frame-by-frame pose-driven). Hermano animado de comfyui_build_sprite_sheet_workflow (frame estatico) y de comfyui_build_directional_sprite_workflow (rotacion 3D). Pura, sin red ni I/O. class_types e inputs verificados contra /object_info."
tags: [gamedev-2d, comfyui, sprite, animation, walk-cycle, controlnet, openpose]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: subject
desc: "Descripcion del personaje (ej. 'pixel art knight'). Se completa con ', full body, game sprite, simple background, walking'. No puede estar vacio."
- name: pose_skeletons
desc: "Lista (no vacia) de nombres de archivo de esqueletos OpenPose en el dir input/ del servidor, uno por frame del ciclo en orden de animacion. Cada uno debe ser string no vacio. La lista no se muta."
- name: ckpt_name
desc: "Checkpoint SD1.5 (OpenPose solo instalado en SD1.5; default 'IMG_dreamshaper_8.safetensors'). keyword-only."
- name: char_lora
desc: "LoRA de personaje/estilo opcional en models/loras (refuerza consistencia de ropa/cuerpo entre frames). None = sin LoRA. keyword-only."
- name: lora_strength
desc: "Fuerza del char_lora sobre model y clip. keyword-only."
- name: controlnet_name
desc: "ControlNet OpenPose (default SD1.5 'control_v11p_sd15_openpose_fp16.safetensors'). keyword-only."
- name: controlnet_strength
desc: "Fuerza del OpenPose (default 0.7). keyword-only."
- name: controlnet_start
desc: "Inicio de aplicacion del OpenPose (fraccion 0..1). keyword-only."
- name: controlnet_end
desc: "Fin de aplicacion del OpenPose (end<1.0 deja libres los ultimos pasos para pelo/ropa; default 0.8). keyword-only."
- name: transparent
desc: "Si True inyecta Rembg para alpha (recomendado para sprites de juego). False = fondo opaco. keyword-only."
- name: rembg_model
desc: "Modelo Rembg ('u2net' general, 'isnet-anime' para anime). keyword-only."
- name: negative
desc: "Prompt negativo. keyword-only."
- name: width
desc: "Ancho en px (512). keyword-only."
- name: height
desc: "Alto en px (768, vertical, encuadra cuerpo entero). keyword-only."
- name: steps
desc: "Pasos del KSampler. keyword-only."
- name: cfg
desc: "CFG del KSampler. keyword-only."
- name: seed
desc: "Semilla del KSampler, FIJA e identica para todos los frames (identidad consistente). keyword-only."
- name: sampler_name
desc: "Sampler del KSampler (default 'dpmpp_2m'). keyword-only."
- name: scheduler
desc: "Scheduler del KSampler (default 'karras'). keyword-only."
- name: fps
desc: "Frames por segundo del WEBP animado (default 8). keyword-only."
- name: filename_prefix
desc: "Prefijo del archivo WEBP en output/ (default 'walk_cycle'). keyword-only."
output: "dict en API format listo para comfyui_submit_workflow. Claves = node_ids (string); cada valor tiene class_type + inputs. Estructura: CheckpointLoaderSimple (+ LoraLoader si char_lora) + 2x CLIPTextEncode + ControlNetLoader compartido + N x (LoadImage + ControlNetApplyAdvanced + EmptyLatentImage + KSampler + VAEDecode) + cadena de ImageBatch que une los N frames + Rembg (si transparent) + SaveAnimatedWEBP."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/comfyui_build_walk_cycle_workflow.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_build_walk_cycle_workflow import comfyui_build_walk_cycle_workflow
# Ciclo de andar de 4 frames: 4 esqueletos OpenPose (en input/ del servidor),
# misma seed -> el mismo personaje caminando, no 4 personajes distintos.
wf = comfyui_build_walk_cycle_workflow(
"pixel art knight",
pose_skeletons=[
"walk_pose_00.png",
"walk_pose_01.png",
"walk_pose_02.png",
"walk_pose_03.png",
],
transparent=True,
fps=8,
seed=0,
)
# wf es API format -> comfyui_submit_workflow(wf) genera el WEBP animado en output/.
```
O lanzable directo con: `./fn run comfyui_build_walk_cycle_workflow` (imprime nodos + class_types del ejemplo).
## Cuando usarla
Cuando necesites una **animacion** de un sprite de personaje 2D (no un frame suelto):
ciclo de andar, correr, atacar, idle... — cualquier secuencia donde el personaje conserva
su identidad y solo cambia la postura. Dibuja los N esqueletos OpenPose de la secuencia,
pasalos en orden, fija la `seed` y obtienes un WEBP animado de una sola pasada. Para UN
frame estatico usa `comfyui_build_sprite_sheet_workflow`; para rotar el personaje en 3D
(vistas direccionales) usa `comfyui_build_directional_sprite_workflow`.
## Gotchas
- **Solo SD1.5 hoy**: el ControlNet OpenPose esta instalado solo en SD1.5. Usa
`IMG_dreamshaper_8` u otro checkpoint SD1.5.
- **`pose_skeletons` son nombres de archivo en el dir `input/` del servidor**, no rutas
locales. Subelas antes (cada LoadImage las lee de ahi). El orden de la lista = el orden
de los frames de la animacion.
- **La `seed` es FIJA para todos los frames a proposito**: compartir seed + prompt +
checkpoint y variar solo el OpenPose es lo que mantiene al mismo personaje entre
fotogramas. Una seed por frame haria "parpadear" la identidad (cara/ropa/paleta derivan).
- **`ControlNetApplyAdvanced` con `end_percent` 0.8** deja los ultimos pasos libres para
que pelo/ropa no queden aplastados contra el esqueleto.
- **El batch se construye encadenando `ImageBatch`** (toma 2 imagenes): para N frames hay
N-1 nodos ImageBatch. Con N=1 no hay ImageBatch (el unico frame va directo al Save).
- `Image Rembg` da matting binario (silueta solida) — ideal para personajes, NO para
efectos translucidos (humo/fuego). Con `transparent=False` se omite (fondo opaco).
- **El WEBP animado** usa `lossless=True`, `quality=90`, `method="default"`; sube/baja
`fps` para la velocidad del ciclo. Verificado que `method` admite `default/fastest/slowest`.
- Funcion pura: construye el grafo, NO valida contra el server ni envia nada. El coste GPU
esta al enviar con `comfyui_submit_workflow`.
@@ -0,0 +1,299 @@
"""Construye el workflow ComfyUI de un WALK CYCLE animado (N frames pose-driven -> WEBP).
Caso 1 del report 0217 ("animacion de sprite frame-by-frame pose-driven"): a partir de
N esqueletos OpenPose que describen las poses sucesivas de un ciclo de andar, construye el
dict (API format) de un workflow que:
1. genera un frame por pose con la MISMA seed y el MISMO prompt/checkpoint/LoRA, de modo
que el personaje conserva su identidad de un frame al siguiente (la unica variable es
el esqueleto OpenPose que dicta la postura);
2. combina los N frames en un unico batch encadenando `ImageBatch`;
3. recorta el fondo a alpha con `Image Rembg (Remove Background)` (transparencia para el
motor del juego);
4. los exporta como WEBP animado con `SaveAnimatedWEBP` (un solo archivo reproducible).
Es el builder PURO equivalente a `comfyui_build_sprite_sheet_workflow` (que produce UN
frame estatico) pero orientado a ANIMACION: en vez de devolver un sprite suelto por pose y
montar un contact-sheet a posteriori, este grafo produce de una sola pasada el WEBP animado
del ciclo. Hermano direccional: `comfyui_build_directional_sprite_workflow` (rota el
personaje en 3D); aqui el personaje no rota, camina (mismo angulo de camara, poses 2D).
Por que ControlNetApplyAdvanced (y no el legacy ControlNetApply): `end_percent` < 1.0 deja
los ultimos pasos del sampler libres para que pelo y ropa no queden aplastados contra el
esqueleto OpenPose (mismo razonamiento que el sprite sheet, report 0137).
Por que la seed es FIJA para todos los frames: una seed distinta por frame haria que el
personaje "parpadee" de identidad entre fotogramas (ropa/cara/paleta derivan). Compartir la
seed + prompt + checkpoint y variar solo el OpenPose es lo que hace que sea el mismo
personaje andando, no N personajes distintos en N posturas.
Funcion PURA: sin red, sin I/O. No muta las entradas (no recibe dicts; copia la lista de
poses). Todos los class_types y sus inputs estan verificados contra /object_info del server
8GB (CheckpointLoaderSimple, LoraLoader, CLIPTextEncode, ControlNetLoader, LoadImage,
ControlNetApplyAdvanced, EmptyLatentImage, KSampler, VAEDecode, ImageBatch,
'Image Rembg (Remove Background)', SaveAnimatedWEBP).
"""
from __future__ import annotations
def comfyui_build_walk_cycle_workflow(
subject: str,
pose_skeletons: list,
*,
ckpt_name: str = "IMG_dreamshaper_8.safetensors",
char_lora: str | None = None,
lora_strength: float = 1.0,
controlnet_name: str = "control_v11p_sd15_openpose_fp16.safetensors",
controlnet_strength: float = 0.7,
controlnet_start: float = 0.0,
controlnet_end: float = 0.8,
transparent: bool = True,
rembg_model: str = "u2net",
negative: str = "blurry, lowres, extra limbs, deformed",
width: int = 512,
height: int = 768,
steps: int = 24,
cfg: float = 7.0,
seed: int = 0,
sampler_name: str = "dpmpp_2m",
scheduler: str = "karras",
fps: int = 8,
filename_prefix: str = "walk_cycle",
) -> dict:
"""Construye el dict (API format) del workflow de un walk cycle animado.
Genera un frame por cada esqueleto OpenPose de ``pose_skeletons`` con identidad
consistente (misma seed/prompt/checkpoint), los combina en un batch, los recorta a
alpha (Rembg) y los guarda como WEBP animado.
Args:
subject: descripcion del personaje (ej. "pixel art knight"). Se completa con
", full body, game sprite, simple background, walking". No puede estar vacio.
pose_skeletons: lista (no vacia) de nombres de archivo de esqueletos OpenPose en el
dir ``input/`` del servidor (uno por frame del ciclo, en orden de animacion). El
grafo crea un LoadImage por entrada; cada uno debe ser un string no vacio. La
lista no se muta.
ckpt_name: checkpoint SD1.5 (OpenPose solo instalado en SD1.5; default
'IMG_dreamshaper_8.safetensors'). keyword-only.
char_lora: LoRA de personaje/estilo opcional en models/loras (refuerza la
consistencia de ropa/cuerpo entre frames). None = sin LoRA. keyword-only.
lora_strength: fuerza del char_lora sobre model y clip. keyword-only.
controlnet_name: ControlNet OpenPose (default SD1.5). keyword-only.
controlnet_strength: fuerza del OpenPose (default 0.7). keyword-only.
controlnet_start: inicio de aplicacion del OpenPose (fraccion 0..1). keyword-only.
controlnet_end: fin de aplicacion del OpenPose (end<1.0 deja libres los ultimos
pasos para pelo/ropa; default 0.8). keyword-only.
transparent: si True inyecta Rembg para alpha (recomendado para sprites de juego).
False = fondo opaco. keyword-only.
rembg_model: modelo Rembg ('u2net' general, 'isnet-anime' para anime). keyword-only.
negative: prompt negativo. keyword-only.
width: ancho en px (512). keyword-only.
height: alto en px (768, vertical, encuadra cuerpo entero). keyword-only.
steps: pasos del KSampler. keyword-only.
cfg: CFG del KSampler. keyword-only.
seed: semilla del KSampler, FIJA e identica para todos los frames (identidad
consistente). keyword-only.
sampler_name: sampler del KSampler (default 'dpmpp_2m'). keyword-only.
scheduler: scheduler del KSampler (default 'karras'). keyword-only.
fps: frames por segundo del WEBP animado (default 8). keyword-only.
filename_prefix: prefijo del archivo WEBP en output/ (default 'walk_cycle').
keyword-only.
Returns:
dict en API format listo para comfyui_submit_workflow. Las claves son node_ids
(string) y cada valor tiene class_type + inputs. Estructura: CheckpointLoaderSimple
(+ LoraLoader si char_lora) + 2x CLIPTextEncode + ControlNetLoader compartido +
N x (LoadImage + ControlNetApplyAdvanced + EmptyLatentImage + KSampler + VAEDecode)
+ cadena de ImageBatch que une los N frames + Rembg (si transparent) +
SaveAnimatedWEBP.
Raises:
ValueError: si subject esta vacio, pose_skeletons esta vacio, o alguna pose no es un
string no vacio.
"""
if not subject or not subject.strip():
raise ValueError("comfyui_build_walk_cycle_workflow: 'subject' no puede estar vacio")
if not isinstance(pose_skeletons, (list, tuple)) or len(pose_skeletons) == 0:
raise ValueError(
"comfyui_build_walk_cycle_workflow: 'pose_skeletons' debe ser una lista no vacia "
"de nombres de esqueletos OpenPose en input/ (uno por frame del ciclo)."
)
poses = list(pose_skeletons)
for i, p in enumerate(poses):
if not isinstance(p, str) or not p.strip():
raise ValueError(
"comfyui_build_walk_cycle_workflow: pose_skeletons["
f"{i}] debe ser un string no vacio (nombre de archivo en input/)."
)
positive = f"{subject}, full body, game sprite, simple background, walking"
wf: dict = {}
counter = [0]
def nid() -> str:
counter[0] += 1
return str(counter[0])
# 1. Checkpoint -> MODEL(0), CLIP(1), VAE(2).
ckpt_id = nid()
wf[ckpt_id] = {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": ckpt_name},
}
model_src = [ckpt_id, 0]
clip_src = [ckpt_id, 1]
vae_src = [ckpt_id, 2]
# 2. LoRA opcional -> reapunta MODEL/CLIP a su salida.
if char_lora:
lora_id = nid()
wf[lora_id] = {
"class_type": "LoraLoader",
"inputs": {
"model": model_src,
"clip": clip_src,
"lora_name": char_lora,
"strength_model": lora_strength,
"strength_clip": lora_strength,
},
}
model_src = [lora_id, 0]
clip_src = [lora_id, 1]
# 3. Prompts positivo y negativo (compartidos por todos los frames).
pos_clip_id = nid()
wf[pos_clip_id] = {
"class_type": "CLIPTextEncode",
"inputs": {"text": positive, "clip": clip_src},
}
neg_clip_id = nid()
wf[neg_clip_id] = {
"class_type": "CLIPTextEncode",
"inputs": {"text": negative, "clip": clip_src},
}
# 4. ControlNetLoader compartido (uno solo para todas las poses).
cn_loader_id = nid()
wf[cn_loader_id] = {
"class_type": "ControlNetLoader",
"inputs": {"control_net_name": controlnet_name},
}
# 5. Por cada pose: LoadImage -> ControlNetApplyAdvanced -> EmptyLatent -> KSampler -> VAEDecode.
frame_image_srcs: list = []
for pose in poses:
load_id = nid()
wf[load_id] = {"class_type": "LoadImage", "inputs": {"image": pose}}
apply_id = nid()
wf[apply_id] = {
"class_type": "ControlNetApplyAdvanced",
"inputs": {
"positive": [pos_clip_id, 0],
"negative": [neg_clip_id, 0],
"control_net": [cn_loader_id, 0],
"image": [load_id, 0],
"strength": controlnet_strength,
"start_percent": controlnet_start,
"end_percent": controlnet_end,
},
}
latent_id = nid()
wf[latent_id] = {
"class_type": "EmptyLatentImage",
"inputs": {"width": width, "height": height, "batch_size": 1},
}
ksampler_id = nid()
wf[ksampler_id] = {
"class_type": "KSampler",
"inputs": {
"seed": seed, # FIJA: misma seed para todos los frames (identidad consistente).
"steps": steps,
"cfg": cfg,
"sampler_name": sampler_name,
"scheduler": scheduler,
"denoise": 1.0,
"model": model_src,
"positive": [apply_id, 0],
"negative": [apply_id, 1],
"latent_image": [latent_id, 0],
},
}
vae_id = nid()
wf[vae_id] = {
"class_type": "VAEDecode",
"inputs": {"samples": [ksampler_id, 0], "vae": vae_src},
}
frame_image_srcs.append([vae_id, 0])
# 6. Combinar los N frames en un solo batch encadenando ImageBatch.
if len(frame_image_srcs) == 1:
batch_src = frame_image_srcs[0]
else:
batch_src = frame_image_srcs[0]
for next_src in frame_image_srcs[1:]:
ib_id = nid()
wf[ib_id] = {
"class_type": "ImageBatch",
"inputs": {"image1": batch_src, "image2": next_src},
}
batch_src = [ib_id, 0]
# 7. Rembg opcional sobre el batch (alpha para el motor del juego).
save_images_src = batch_src
if transparent:
rembg_id = nid()
wf[rembg_id] = {
"class_type": "Image Rembg (Remove Background)",
"inputs": {
"images": batch_src,
"transparency": True,
"model": rembg_model,
"post_processing": False,
"only_mask": False,
"alpha_matting": False,
"alpha_matting_foreground_threshold": 240,
"alpha_matting_background_threshold": 10,
"alpha_matting_erode_size": 10,
"background_color": "none",
},
}
save_images_src = [rembg_id, 0]
# 8. Exportar el ciclo como WEBP animado.
save_id = nid()
wf[save_id] = {
"class_type": "SaveAnimatedWEBP",
"inputs": {
"images": save_images_src,
"filename_prefix": filename_prefix,
"fps": float(fps),
"lossless": True,
"quality": 90,
"method": "default",
},
}
return wf
if __name__ == "__main__":
import json
wf = comfyui_build_walk_cycle_workflow(
"pixel art knight",
pose_skeletons=[
"walk_pose_00.png",
"walk_pose_01.png",
"walk_pose_02.png",
"walk_pose_03.png",
],
)
print(json.dumps({
"nodes": list(wf),
"classes": sorted({n["class_type"] for n in wf.values()}),
}, indent=2))
@@ -0,0 +1,118 @@
---
name: comfyui_pixelize_sprite_png
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "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"
description: "Pixeliza un PNG existente (un render a alta resolucion, p.ej. 512x768 RGBA con fondo transparente) a un sprite pixel-art REAL de size x size RGBA. Extrae la logica de pixelizado de un PNG existente: la misma secuencia que comfyui_pixelart_real_oneshot aplica internamente (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. Compone tres funciones del registry: crop_to_content (autocrop al contenido + cuadrar para llenar el frame) -> pixeloe_downscale (downscale contrast-aware que conserva la silueta, engine='pixeloe', con fallback automatico a nearest) -> comfyui_pixelize_image (cuantizacion dura a N colores libres o paleta fija pico-8/nes/game-boy, alpha-aware). PixelOE trabaja en RGB y pierde el alpha, asi que se downscalea el canal alpha aparte (nearest) y se reaplica al grid antes de cuantizar. Impura: lectura/escritura de disco + subprocess del bridge de pixeloe. No-throw: todo error viaja en el campo error del dict. Devuelve {ok, out_path, size, colors_final, has_alpha, engine_used, autocrop_applied, error}."
tags: [gamedev-2d, comfyui, pixelart, sprite, ml, downscale, quantize, palette, alpha, transparent, animation]
uses_functions: [crop_to_content_py_ml, pixeloe_downscale_py_ml, comfyui_pixelize_image_py_ml]
uses_types: []
returns: []
returns_optional: false
error_type: "error_py_core"
imports: []
params:
- name: src_path
desc: "ruta del PNG de entrada (un render a alta resolucion, p.ej. 512x768 RGBA con fondo transparente). Debe existir."
- name: dst_path
desc: "ruta del PNG de salida size x size (se crea el directorio si falta)."
- name: size
desc: "lado del grid final en pixeles (32 iconos/objetos simples, 64 personajes/sprites). Debe ser >= 1. keyword-only."
- name: colors
desc: "numero de colores de la paleta libre cuando palette es None (cuantizacion MEDIANCUT). keyword-only."
- name: engine
desc: "'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."
- name: palette
desc: "None (paleta libre a 'colors'), nombre builtin ('pico-8','nes','game-boy') o lista de hex. Una paleta fija ignora 'colors'. keyword-only."
- name: transparent
desc: "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."
- name: autocrop
desc: "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."
- name: crop_pad_ratio
desc: "margen relativo que deja el autocrop alrededor del sujeto (0.02 = 2% del lado). keyword-only."
- name: mode
desc: "modo de downscale de PixelOE ('contrast' SOTA, 'k-centroid', 'nearest', 'center', 'bicubic'); solo aplica con engine='pixeloe'. keyword-only."
- name: patch_size
desc: "tamano de patch de PixelOE (default 16). keyword-only."
- name: thickness
desc: "grosor del outline expansion de PixelOE (default 2). keyword-only."
- name: alpha_threshold
desc: "umbral (0..255) para binarizar el alpha en opaco (255) o transparente (0) en la cuantizacion final. Solo aplica si transparent. keyword-only."
- name: comfy_python
desc: "ruta al interprete de ComfyUI (con la lib pixeloe) para el bridge; None autodetecta COMFY_PYTHON y luego ~/ComfyUI/.venv/bin/python3. keyword-only."
output: "dict con ok (bool, True si se produjo el PNG final), out_path (str, ruta del PNG final size x size; vacio si fallo), size (int, lado real del PNG final), colors_final (int, colores distintos en el resultado; en la zona opaca si es RGBA), has_alpha (bool, True si el PNG es RGBA con transparencia), engine_used (str, 'pixeloe' o 'nearest' reflejando el fallback real), autocrop_applied (bool, True si el autocrop recorto/cuadro la imagen), error (str, vacio si todo OK)."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/comfyui_pixelize_sprite_png.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.comfyui_pixelize_sprite_png import comfyui_pixelize_sprite_png
# Un render existente de 512x768 RGBA con fondo transparente -> sprite pixel-art 32x32
res = comfyui_pixelize_sprite_png(
os.path.expanduser("~/ComfyUI/output/knight_hi_res.png"),
"/tmp/knight_32.png",
size=32, colors=16, transparent=True,
)
# {'ok': True, 'out_path': '/tmp/knight_32.png', 'size': 32, 'colors_final': 16,
# 'has_alpha': True, 'engine_used': 'pixeloe', 'autocrop_applied': True, 'error': ''}
# Pixelizar cada frame de una animacion a 48px con paleta fija PICO-8
for i, frame in enumerate(["walk_0.png", "walk_1.png", "walk_2.png", "walk_3.png"]):
comfyui_pixelize_sprite_png(
f"/tmp/anim/{frame}", f"/tmp/anim/px_{i}.png",
size=48, palette="pico-8", transparent=True,
)
# Un tile/textura sin silueta -> downscale nearest barato, sin transparencia
comfyui_pixelize_sprite_png(
"/tmp/grass_tile.png", "/tmp/grass_16.png",
size=16, colors=8, engine="nearest", transparent=False,
)
```
## Cuando usarla
Cuando ya tienes un PNG renderizado a alta resolucion y necesitas su version
pixel-art REAL (grid duro + paleta limitada) **sin regenerar** con la difusion: cada
frame de una animacion, una hoja de sprites entera, un render externo, o el resultado
de cualquier otra funcion que produzca PNGs. Es la pieza desacoplada del pixelizado
que `comfyui_pixelart_real_oneshot` usa por dentro tras generar — usala directamente
cuando la generacion no es parte del trabajo. Usa `engine="pixeloe"` para sujetos con
silueta (personajes, criaturas, iconos con contorno) y `engine="nearest"` para
tiles/texturas/fondos planos sin contorno (mas barato). Para llevar el resultado a
Godot con filtro Nearest, encadena con `comfyui_export_asset_to_godot`.
## Gotchas
- **Necesita la lib `pixeloe`** (en `~/ComfyUI/.venv`) para `engine="pixeloe"`: se
invoca via bridge de subprocess (`pixeloe_downscale`). Si la lib no esta o falla,
cae automaticamente a `engine="nearest"` y lo refleja en `engine_used` + deja la
nota del fallo en `error` (el resultado sigue siendo valido). Pasa `comfy_python`
para apuntar a otro interprete con pixeloe.
- **Todo error es dict `ok=False`** (no excepcion): `src_path` inexistente, `size < 1`,
`engine` distinto de pixeloe/nearest -> `error` lo explica. No crashea ni borra nada.
- **`autocrop` es best-effort**: si el recorte falla (PIL/lectura), se sigue con el PNG
original sin recortar, `autocrop_applied=False` y la nota va en `error` (no critico).
`crop_to_content` cuadra el sujeto para que llene el frame — sin esto un sujeto que
ocupa el 25% del lienzo queda diminuto a 32px.
- **`transparent` espera entrada RGBA**: con `transparent=True` el alpha se preserva y
el fondo transparente NO entra en la paleta. PixelOE trabaja en RGB y perderia el
alpha, asi que se downscalea el canal alpha aparte (nearest) y se reaplica al grid
antes de cuantizar (fase 2a-bis). Con `transparent=False` la salida es RGB opaca.
- **`palette` fija (pico-8/nes/game-boy o lista de hex) ignora `colors`**. `colors_final`
cuenta colores RGB distintos REALES de la zona opaca: puede ser **menor** que `colors`
o que el tamano de la paleta si el sprite no usa todos (un sprite de un solo color
solido devuelve `colors_final=1`, correcto).
- **CPU-only en la cuantizacion**; el unico coste GPU/red es nulo (PixelOE es CPU via
bridge). Los intermedios (crop, mid) se escriben en un directorio temporal y se
limpian siempre, incluso si la cuantizacion falla.
@@ -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))
@@ -0,0 +1,93 @@
---
name: render_openpose_walk_skeletons
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def render_openpose_walk_skeletons(out_dir: str, *, frames: int = 4, width: int = 512, height: int = 768, facing: str = 'right', line_width: int = 4, point_radius: int = 6, filename_prefix: str = 'walk_pose') -> dict"
description: "Dibuja con PIL una secuencia de N esqueletos OpenPose COCO-18 (18 keypoints, 17 limbs, colores canonicos) de un ciclo de caminar lateral, una fase del paso por frame, sobre fondo negro, y los guarda como PNG. Son la ENTRADA fija del ControlNet OpenPose (control_v11p_sd15_openpose_fp16) para animar un personaje frame-by-frame: el esqueleto NO lo genera la IA, lo aportas dibujado. Para frames=4 produce las 4 fases canonicas (contact-izq, passing, contact-der, passing); para mas frames muestrea el ciclo parametrico continuo. Piernas en oposicion a los brazos + rebote vertical del cuerpo (walk cycle de manual de animacion). facing='right'|'left' espeja en X. Impura: escribe N PNGs. Devuelve {ok, skeleton_paths, frames, width, height, error}."
tags: [gamedev-2d, comfyui, controlnet, openpose, pose, animation]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: out_dir
desc: "directorio destino de los PNG; se crea si no existe. None lanza ValueError (unico caso que lanza)."
- name: frames
desc: "numero de fases del ciclo a renderizar (default 4 = las 4 fases canonicas contact/passing/contact/passing); con mas frames se muestrea el ciclo parametrico de forma continua interpolando las fases intermedias down/up. keyword-only."
- name: width
desc: "ancho en pixeles de cada PNG (default 512, el tamaño nativo de SD1.5). keyword-only."
- name: height
desc: "alto en pixeles de cada PNG (default 768, retrato para personaje de cuerpo entero). keyword-only."
- name: facing
desc: "'right' (el personaje mira a +x) o 'left' (espeja el esqueleto en X). Cualquier otro valor devuelve ok=False con error. keyword-only."
- name: line_width
desc: "grosor en pixeles de las lineas de los limbs (default 4). keyword-only."
- name: point_radius
desc: "radio en pixeles de los circulos rellenos de cada keypoint (default 6). keyword-only."
- name: filename_prefix
desc: "prefijo de los archivos; se nombran '<prefix>_<NN>.png' con NN de dos digitos en orden de fase (default 'walk_pose'). keyword-only."
output: "dict con ok (bool, True si todos los PNG se generaron), skeleton_paths (list[str], rutas de los PNG en orden de fase), frames (int, frames generados), width (int), height (int), error (str, vacio si OK)."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/ml/render_openpose_walk_skeletons.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.render_openpose_walk_skeletons import render_openpose_walk_skeletons
res = render_openpose_walk_skeletons("/tmp/walk_skeletons_demo", frames=4)
# {'ok': True,
# 'skeleton_paths': ['/tmp/walk_skeletons_demo/walk_pose_00.png', ..._03.png],
# 'frames': 4, 'width': 512, 'height': 768, 'error': ''}
# 8 fases mirando a la izquierda, lineas/puntos mas finos:
res8 = render_openpose_walk_skeletons(
"/tmp/walk_poses_left", frames=8, facing="left",
line_width=3, point_radius=5,
)
```
Los PNG resultantes se conectan luego con `comfyui_build_controlnet_workflow`
(uno por frame, `control_net_name="control_v11p_sd15_openpose_fp16.safetensors"`)
para generar el personaje animado fotograma a fotograma.
## Cuando usarla
Usala cuando vayas a animar un sprite/personaje 2D con ComfyUI + ControlNet
OpenPose y necesites el insumo que la IA NO genera: la pose-map del esqueleto.
Llamala ANTES de montar el workflow ControlNet — produce las N pose-maps del
walk cycle (el caso mas comun de animacion de personaje) que el modelo seguira
frame a frame. Tambien sirve como base para otras acciones ciclicas si ajustas
las fases. Si necesitas una pose suelta (idle, ataque) en vez de un ciclo,
extrae el patron a una funcion hermana — esta es especifica de caminar.
## Gotchas
- Escribe N PNGs en disco (impura): si `out_dir` no es escribible devuelve
`ok=False` con el error; si `out_dir` es `None` lanza `ValueError` (unico caso
que lanza — el resto de fallos se capturan en `error`).
- El orden de los 18 keypoints es COCO-18 EXACTO (0 nose ... 17 left_ear) y los
colores son los canonicos de OpenPose/controlnet_aux. NO cambies el orden ni la
paleta: el preprocesador/ControlNet identifica las articulaciones por color y
posicion; alterarlos degrada o rompe el guiado de pose.
- Es un esqueleto sintetico parametrico, no una captura real: las proporciones
son humanas estandar y la vista es estrictamente lateral. Para vistas 3/4 o
proporciones no humanas (chibi, criaturas) habria que reparametrizar.
- Fondo NEGRO solido (RGB 0,0,0) por diseño — es lo que el ControlNet OpenPose
espera como lienzo. No lo compongas sobre otra imagen.
- `frames=4` da exactamente las 4 fases canonicas; valores que no dividan bien el
ciclo (p.ej. 3, 5) siguen muestreando t=i/frames de forma uniforme y producen
fases validas pero no necesariamente las "de manual". Para animacion fluida usa
multiplos de 4 (8, 12, 16).
- Necesita Pillow (PIL); si no esta instalado devuelve `ok=False` con error en vez
de lanzar.
@@ -0,0 +1,260 @@
"""Dibuja una secuencia de esqueletos OpenPose (COCO-18) de un ciclo de caminar.
Funcion impura: rasteriza con PIL (Pillow) N frames de un walk cycle lateral y
escribe cada uno como PNG sobre fondo negro. Cada PNG es una pose-map valida para
el ControlNet OpenPose de SD1.5 (control_v11p_sd15_openpose_fp16): el esqueleto NO
lo genera la IA, lo aportas tu dibujado y el modelo anima el personaje sobre el.
El formato es el render clasico de OpenPose: 18 keypoints (COCO-18), 17 limbs,
colores canonicos por articulacion/par. Las piernas oscilan en oposicion a los
brazos y el cuerpo "bota" (sube en passing, baja en contact) — un walk cycle de
manual de animacion. Para frames=4 produce las 4 fases canonicas
(contact-izq, passing, contact-der, passing); para mas frames muestrea el ciclo
parametrico de forma continua (interpolando las fases intermedias down/up).
"""
import json
import math
import os
# Orden de indice COCO-18 que espera el ControlNet OpenPose.
COCO18_NAMES = [
"nose", "neck", "right_shoulder", "right_elbow", "right_wrist",
"left_shoulder", "left_elbow", "left_wrist", "right_hip", "right_knee",
"right_ankle", "left_hip", "left_knee", "left_ankle",
"right_eye", "left_eye", "right_ear", "left_ear",
]
# Pares (limbs) — formato OpenPose clasico (17 limbs sobre 18 keypoints).
LIMB_SEQ = [
(1, 2), (1, 5), (2, 3), (3, 4), (5, 6), (6, 7), (1, 8), (8, 9), (9, 10),
(1, 11), (11, 12), (12, 13), (1, 0), (0, 14), (14, 16), (0, 15), (15, 17),
]
# Colores canonicos (RGB) por indice, identicos a los del render original de
# OpenPose / controlnet_aux. El keypoint i usa COLORS[i]; el limb i usa COLORS[i].
COLORS = [
[255, 0, 0], [255, 85, 0], [255, 170, 0], [255, 255, 0], [170, 255, 0],
[85, 255, 0], [0, 255, 0], [0, 255, 85], [0, 255, 170], [0, 255, 255],
[0, 170, 255], [0, 85, 255], [0, 0, 255], [85, 0, 255], [170, 0, 255],
[255, 0, 255], [255, 0, 170], [255, 0, 85],
]
def _walk_pose_norm(t: float) -> dict:
"""Devuelve los 18 keypoints normalizados (0..1, y hacia abajo) de un walk
cycle lateral mirando a la derecha, para la fase t in [0, 1).
t=0.0 -> contact (pierna izq adelante), t=0.25 -> passing (cuerpo arriba),
t=0.5 -> contact (pierna der adelante), t=0.75 -> passing.
"""
two_pi = 2.0 * math.pi
cx = 0.50
neck_y = 0.245
sh_y = 0.265
hip_y0 = 0.55
ground_y = 0.885
thigh_drop = 0.165 # caida vertical hip->knee
stride = 0.105 # desplazamiento horizontal max del pie respecto al hip
bob_amp = 0.025 # rebote vertical del cuerpo
lift_amp = 0.05 # cuanto se levanta el pie en swing
sh_dx = 0.024 # media separacion horizontal de hombros (profundidad)
hip_dx = 0.026 # media separacion horizontal de caderas
# Rebote: bajo en contact (t=0, .5), alto en passing (t=.25, .75).
rise = bob_amp * (1.0 - math.cos(2.0 * two_pi * t)) / 2.0
nx = cx
ny = neck_y - rise
hip_cy = hip_y0 - rise
pts = {}
pts[1] = (nx, ny) # neck
pts[0] = (nx + 0.055, ny - 0.105) # nose (mira a la derecha)
pts[14] = (nx + 0.045, ny - 0.120) # right_eye
pts[15] = (nx + 0.026, ny - 0.120) # left_eye
pts[16] = (nx + 0.002, ny - 0.103) # right_ear (atras)
pts[17] = (nx - 0.012, ny - 0.097) # left_ear (atras)
pts[2] = (nx - sh_dx, sh_y - rise) # right_shoulder
pts[5] = (nx + sh_dx, sh_y - rise) # left_shoulder
r_hip = (cx - hip_dx, hip_cy)
l_hip = (cx + hip_dx, hip_cy)
pts[8] = r_hip
pts[11] = l_hip
# Factores de oscilacion de las piernas (opuestas entre si).
fwd_l = math.cos(two_pi * t) # pierna izq adelante en t=0
fwd_r = -fwd_l # pierna der opuesta
lift_l = lift_amp * max(0.0, -math.sin(two_pi * t)) # izq levanta t in (.5,1)
lift_r = lift_amp * max(0.0, math.sin(two_pi * t)) # der levanta t in (0,.5)
def _leg(hip, fwd, lift):
hx, hy = hip
ax = hx + stride * fwd
ay = ground_y - lift
bend = 0.012 + 0.06 * (lift / lift_amp if lift_amp else 0.0)
kx = (hx + ax) / 2.0 + bend # rodilla apunta hacia delante
ky = hy + thigh_drop
return (kx, ky), (ax, ay)
rk, ra = _leg(r_hip, fwd_r, lift_r)
lk, la = _leg(l_hip, fwd_l, lift_l)
pts[9], pts[10] = rk, ra # right_knee, right_ankle
pts[12], pts[13] = lk, la # left_knee, left_ankle
# Brazos en oposicion: brazo der adelante cuando pierna izq adelante.
arm_fwd_r = fwd_l
arm_fwd_l = fwd_r
sh_to_el = 0.105
el_to_wr = 0.110
arm_sw = 0.05
def _arm(sh, fwd):
sx, sy = sh
ex = sx + arm_sw * fwd + 0.008
ey = sy + sh_to_el
wx = sx + arm_sw * 1.7 * fwd + 0.016
wy = ey + el_to_wr
return (ex, ey), (wx, wy)
re, rw = _arm(pts[2], arm_fwd_r)
le, lw = _arm(pts[5], arm_fwd_l)
pts[3], pts[4] = re, rw # right_elbow, right_wrist
pts[6], pts[7] = le, lw # left_elbow, left_wrist
return pts
def render_openpose_walk_skeletons(
out_dir: str,
*,
frames: int = 4,
width: int = 512,
height: int = 768,
facing: str = "right",
line_width: int = 4,
point_radius: int = 6,
filename_prefix: str = "walk_pose",
) -> dict:
"""Dibuja N esqueletos OpenPose COCO-18 de un walk cycle y los guarda como PNG.
Args:
out_dir: directorio destino de los PNG; se crea si no existe.
frames: numero de fases del ciclo a renderizar (default 4 = las 4
fases canonicas contact/passing/contact/passing). keyword-only.
width: ancho de cada PNG en pixeles (default 512). keyword-only.
height: alto de cada PNG en pixeles (default 768, retrato). keyword-only.
facing: "right" (mira a +x) o "left" (espeja en X). keyword-only.
line_width: grosor en px de las lineas de los limbs (default 4).
keyword-only.
point_radius: radio en px de los circulos de cada keypoint (default 6).
keyword-only.
filename_prefix: prefijo de los archivos; se nombran
"<prefix>_<NN>.png" con NN de dos digitos. keyword-only.
Returns:
dict con:
- ok (bool): True si todos los PNG se generaron.
- skeleton_paths (list[str]): rutas de los PNG creados, en orden de fase.
- frames (int): numero de frames generados.
- width (int): ancho usado.
- height (int): alto usado.
- error (str): mensaje de error; cadena vacia si todo OK.
No lanza salvo que out_dir sea None: cualquier otro fallo se captura en
el campo "error" con ok=False.
"""
if out_dir is None:
raise ValueError("out_dir no puede ser None")
out = {
"ok": False, "skeleton_paths": [], "frames": int(frames or 0),
"width": int(width or 0), "height": int(height or 0), "error": "",
}
try:
from PIL import Image, ImageDraw
except ImportError:
out["error"] = "PIL (Pillow) no esta instalado en este interprete"
return out
try:
frames = int(frames)
width = int(width)
height = int(height)
line_width = max(1, int(line_width))
point_radius = max(1, int(point_radius))
except (TypeError, ValueError) as exc:
out["error"] = f"argumento numerico invalido: {exc}"
return out
if frames <= 0:
out["error"] = "frames debe ser >= 1"
return out
if width < 16 or height < 16:
out["error"] = "width y height deben ser >= 16"
return out
if facing not in ("right", "left"):
out["error"] = f"facing debe ser 'right' o 'left', no {facing!r}"
return out
out["frames"] = frames
out["width"] = width
out["height"] = height
try:
os.makedirs(out_dir, exist_ok=True)
except OSError as exc:
out["error"] = f"no se pudo crear out_dir {out_dir!r}: {exc}"
return out
paths = []
try:
for i in range(frames):
t = i / float(frames)
norm = _walk_pose_norm(t)
# Mirror en X para facing izquierda (el esqueleto sigue siendo valido).
kp = {}
for idx, (x, y) in norm.items():
if facing == "left":
x = 1.0 - x
kp[idx] = (x * width, y * height)
img = Image.new("RGB", (width, height), (0, 0, 0))
draw = ImageDraw.Draw(img)
# Limbs primero (lineas gruesas del color del par).
for li, (a, b) in enumerate(LIMB_SEQ):
if a in kp and b in kp:
col = tuple(COLORS[li % len(COLORS)])
draw.line([kp[a], kp[b]], fill=col, width=line_width)
# Keypoints encima (circulos rellenos del color de la articulacion).
for idx in range(len(COCO18_NAMES)):
if idx not in kp:
continue
cx, cy = kp[idx]
col = tuple(COLORS[idx % len(COLORS)])
draw.ellipse(
[cx - point_radius, cy - point_radius,
cx + point_radius, cy + point_radius],
fill=col,
)
path = os.path.join(out_dir, f"{filename_prefix}_{i:02d}.png")
img.save(path)
paths.append(path)
except (OSError, ValueError) as exc:
out["error"] = f"fallo al rasterizar/guardar el frame {len(paths)}: {exc}"
out["skeleton_paths"] = paths
return out
out["ok"] = True
out["skeleton_paths"] = paths
return out
if __name__ == "__main__":
res = render_openpose_walk_skeletons("/tmp/walk_skeletons_demo", frames=4)
print(json.dumps(res, indent=2))
@@ -0,0 +1,110 @@
---
name: comfyui_walk_cycle_oneshot
kind: pipeline
lang: py
domain: pipelines
version: "1.0.0"
purity: impure
signature: "def comfyui_walk_cycle_oneshot(character: str, *, frames: int = 4, size: int = 32, colors: int = 16, fps: int = 8, checkpoint: str = 'IMG_dreamshaper_8.safetensors', ref_image: str | None = None, server: str = '127.0.0.1:8188', dest_dir: str = '~/ComfyUI/output', seed: int = 0, pose_method: str = 'auto', controlnet_strength: float = 0.7, engine: str = 'pixeloe', palette=None, fmt: str = 'webp', **gen_kwargs) -> dict"
description: "Pipeline one-shot: de un prompt de personaje a una animacion de walk cycle en pixel-art (sprite sheet + GIF/WEBP en loop). Genera N frames frame-by-frame dirigidos por pose (ControlNet OpenPose con esqueletos del walk cycle, o fase del paso por prompt como fallback), con seed fija para identidad consistente y Rembg para alpha, y pixeliza cada frame a un grid duro size x size RGBA. Materializa el caso 1 de la investigacion de animacion de sprites (report 0217): personaje = frame-by-frame pose-driven, NUNCA modelos de video. Compone render_openpose_walk_skeletons + comfyui_build_sprite_sheet_workflow + submit/wait/fetch + comfyui_pixelize_sprite_png + assemble_animated_sprite. Impuro: red + GPU + disco. No-throw, salta frames que fallan."
tags: [gamedev-2d, comfyui, pixelart, sprite, animation, walk-cycle, controlnet, openpose, launcher]
uses_functions: ["render_openpose_walk_skeletons_py_ml", "comfyui_build_sprite_sheet_workflow_py_ml", "comfyui_submit_workflow_py_ml", "comfyui_wait_result_py_ml", "comfyui_fetch_output_image_py_ml", "comfyui_pixelize_sprite_png_py_ml", "assemble_animated_sprite_py_ml"]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["os", "sys"]
params:
- name: character
desc: "Prompt del personaje (ej. 'pixel art knight, full body, side view'). No vacio. La identidad se mantiene entre frames con seed fija."
- name: frames
desc: "Numero de frames del ciclo (>=2, recomendado 4-8). 4 = las 4 fases canonicas contact-L / passing / contact-R / passing."
- name: size
desc: "Lado del grid pixel-art final por frame en pixeles (32 sprites pequenos, 64 personajes con mas detalle)."
- name: colors
desc: "Numero de colores de la paleta libre por frame (cuantizacion MEDIANCUT) cuando palette es None."
- name: fps
desc: "Cadencia de la animacion en frames por segundo (duration = 1000/fps ms por frame)."
- name: checkpoint
desc: "Checkpoint SD1.5 (ControlNet OpenPose + IPAdapter-FaceID solo instalados en SD1.5; default 'IMG_dreamshaper_8.safetensors')."
- name: ref_image
desc: "Imagen de cara de referencia en el input/ del servidor para IPAdapter-FaceID (segunda ancla de identidad). None = solo seed + prompt."
- name: server
desc: "host:port del servidor ComfyUI (sin esquema). Default 127.0.0.1:8188."
- name: dest_dir
desc: "Directorio donde guardar frames + sprite sheet + animacion (se expande ~)."
- name: seed
desc: "Semilla FIJA del KSampler para TODOS los frames (identidad estable entre poses)."
- name: pose_method
desc: "'openpose' (esqueletos OpenPose -> ControlNet, control exacto), 'prompt' (fase del paso descrita en el prompt, sin esqueletos) o 'auto' (intenta openpose, cae a prompt si el render falla)."
- name: controlnet_strength
desc: "Fuerza del ControlNet OpenPose (0.7 da buen control sin aplastar el estilo). Solo aplica en modo openpose."
- name: engine
desc: "Motor de downscale del pixelizado: 'pixeloe' (contrast-aware, conserva silueta) o 'nearest'."
- name: palette
desc: "None (paleta libre a colors), nombre builtin ('pico-8','nes','game-boy') o lista de hex. Fija ignora colors."
- name: fmt
desc: "Formato de la animacion: 'webp' (recomendado, alpha real) o 'gif' (alpha binario)."
output: "dict {ok, frames:[paths], spritesheet_path, animation_path, size, n_frames, seed, pose_method_used, skipped:[idx], error}. ok=True si se produjo la animacion con >=1 frame. n_frames puede ser < frames si alguno fallo (se salta y se sigue)."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/pipelines/comfyui_walk_cycle_oneshot.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from pipelines.comfyui_walk_cycle_oneshot import comfyui_walk_cycle_oneshot
# Requiere el servidor ComfyUI vivo en 127.0.0.1:8188 (GPU).
res = comfyui_walk_cycle_oneshot(
"pixel art knight, full body, side view",
frames=4, size=32, colors=16, fps=8, seed=42,
dest_dir="/tmp/comfy_walk_cycle",
)
print(res["ok"], res["n_frames"], res["animation_path"], res["pose_method_used"])
# -> True 4 /tmp/comfy_walk_cycle/walk_cycle.webp openpose
```
## Cuando usarla
Cuando quieras una animacion de un personaje en pixel-art (caminar, correr) lista
para un juego 2D, en un solo paso: das el prompt del personaje y recibes el sprite
sheet + el GIF/WEBP en loop. Es la promocion a pipeline (issue 0087) de la receta
del caso 1 del report 0217 — el camino correcto para sprites limpios de personaje
con alpha, frente a AnimateDiff o modelos de video (que ensucian el alpha y no
clavan la pose). Para una sola pose estatica usa `comfyui_pixelart_real_oneshot`;
para varias vistas direccionales (8-way) usa
`comfyui_build_directional_sprite_workflow`.
## Gotchas
- **Server vivo + GPU**: requiere ComfyUI en `server` con la GPU libre. El report
recomienda `POST /free` antes de cargas pesadas de modelo. Cada frame reusa el
mismo checkpoint, asi que el modelo solo se carga una vez.
- **Poses OpenPose**: en modo `openpose` los esqueletos se escriben en el `input/`
del servidor (asume server local; para un server remoto haria falta subirlos con
`POST /upload/image`). Si el ControlNet no produce variacion de piernas
reconocible, usa `pose_method="prompt"`: a 32x32 el detalle de pose se simplifica
y la fase del paso por prompt + seed fija da un walk reconocible.
- **Identidad**: la `seed` es FIJA para todos los frames — esa es la ancla de
identidad. Cambiar la seed entre frames rompe la consistencia del personaje.
`ref_image` (IPAdapter-FaceID) es una segunda ancla opcional; sobre un sprite de
cuerpo entero pequeno aporta sobre todo paleta/ropa (ver report 0217).
- **No-throw, salta frames**: si un frame falla (red, GPU, build) se anade a
`skipped` y la animacion se monta con los que queden. ok=False solo si NINGUN
frame sale.
- **Loop suave**: con `frames=4` el ciclo (contact-L, passing, contact-R, passing)
ya cierra el bucle — el frame siguiente al ultimo vuelve a la primera fase.
- **WEBP vs GIF**: `fmt="webp"` conserva alpha real (lossless); `fmt="gif"` solo
tiene alpha binario (1 bit). Para sprites con transparencia, usa WEBP.
## Capability growth log
- v1.0.0 (2026-06-28) — version inicial. Caso 1 del report 0217 promovido a
pipeline one-shot: walk cycle pixel-art con poses OpenPose (o fallback prompt),
seed fija para identidad, Rembg para alpha, pixelizado a NxN RGBA, sprite sheet +
WEBP/GIF en loop.
@@ -0,0 +1,362 @@
"""comfyui_walk_cycle_oneshot — personaje andando -> sprite pixel-art animado (GIF/WEBP).
Pipeline one-shot (issue 0087) que materializa el caso 1 de la investigacion de
animacion de sprites (report 0217): un ciclo de caminar de personaje se anima
**frame-by-frame dirigido por pose**, NO con modelos de video. Por cada fase del
paso se genera el MISMO personaje cambiando solo la pose (ControlNet OpenPose +
seed fija + IPAdapter opcional), se recorta el fondo a alpha (Rembg) y se pixeliza
a un grid duro de `size` x `size` RGBA. Los N frames se ensamblan en un sprite
sheet horizontal + una animacion en loop (WEBP/GIF).
Dos metodos para las poses, seleccionables con `pose_method`:
- "openpose" (preferido): se dibujan N esqueletos OpenPose del walk cycle
(render_openpose_walk_skeletons) y se alimentan al ControlNet OpenPose, un
frame por pose. El timing del paso es exactamente el dibujado.
- "prompt" (fallback): sin esqueletos, la fase del paso se describe en el prompt
("left leg forward", "passing pose", ...) con seed fija. A 32x32 el detalle de
pose se simplifica, asi que un walk de 4 frames reconocible basta. Se usa
cuando el ControlNet no da poses utilizables o el caller lo pide.
- "auto" (default): intenta "openpose"; si el render de esqueletos falla, cae a
"prompt" y lo refleja en pose_method_used.
La identidad consistente entre frames se ancla con **seed fija** (mismo personaje,
misma semilla) + prompt base fijo, y opcionalmente con `ref_image` (IPAdapter-FaceID
en comfyui_build_sprite_sheet_workflow).
Compone funciones del registry, no reescribe su logica:
render_openpose_walk_skeletons_py_ml (esqueletos OpenPose del walk cycle)
comfyui_build_sprite_sheet_workflow_py_ml(1 frame: identidad + pose + alpha)
comfyui_submit_workflow_py_ml (POST /prompt)
comfyui_wait_result_py_ml (poll /history)
comfyui_fetch_output_image_py_ml (GET /view -> disco)
comfyui_pixelize_sprite_png_py_ml (PNG alta-res -> NxN RGBA pixel-art)
assemble_animated_sprite_py_ml (frames -> sprite sheet + WEBP/GIF loop)
Pipeline impuro: red (HTTP a ComfyUI), GPU (generacion), escritura en disco.
No-throw: cualquier fallo se captura y viaja en el dict de estado (campo error).
Si un frame concreto falla se salta y la animacion se monta con los que haya.
"""
from __future__ import annotations
import os
import sys
# 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.assemble_animated_sprite import assemble_animated_sprite
from ml.comfyui_build_sprite_sheet_workflow import comfyui_build_sprite_sheet_workflow
from ml.comfyui_fetch_output_image import comfyui_fetch_output_image
from ml.comfyui_pixelize_sprite_png import comfyui_pixelize_sprite_png
from ml.comfyui_submit_workflow import comfyui_submit_workflow
from ml.comfyui_wait_result import comfyui_wait_result
from ml.render_openpose_walk_skeletons import render_openpose_walk_skeletons
# Descriptores de fase del paso para el modo "prompt" (y como apoyo). El ciclo
# canonico de 4 fases de un walk lateral: contacto pierna izquierda, paso (piernas
# juntas, cuerpo arriba), contacto pierna derecha, paso. Para N != 4 se reparten
# ciclicamente sobre estas cuatro.
_WALK_PHASES = [
"walking, left leg forward, mid stride, dynamic walk pose",
"walking, legs together passing position, standing tall",
"walking, right leg forward, mid stride, dynamic walk pose",
"walking, legs together passing position, standing tall",
]
def _phase_for(index: int, total: int) -> str:
"""Devuelve el descriptor de fase del paso para el frame `index` de `total`.
Mapea el frame al ciclo de 4 fases base de forma uniforme, de modo que el
primer y el ultimo frame cierren un loop continuo (el frame `total` volveria a
la fase del frame 0).
"""
pos = (index / max(1, total)) * len(_WALK_PHASES)
return _WALK_PHASES[int(pos) % len(_WALK_PHASES)]
def comfyui_walk_cycle_oneshot(
character: str,
*,
frames: int = 4,
size: int = 32,
colors: int = 16,
fps: int = 8,
checkpoint: str = "IMG_dreamshaper_8.safetensors",
ref_image: str | None = None,
server: str = "127.0.0.1:8188",
dest_dir: str = "~/ComfyUI/output",
seed: int = 0,
pose_method: str = "auto",
comfy_input_dir: str = "~/ComfyUI/input",
controlnet_strength: float = 0.7,
controlnet_end: float = 0.8,
facing: str = "right",
engine: str = "pixeloe",
palette=None,
fmt: str = "webp",
negative: str = "blurry, lowres, extra limbs, deformed, multiple characters",
width: int = 512,
height: int = 768,
steps: int = 24,
cfg: float = 7.0,
comfy_python: str | None = None,
wait_timeout: float = 300.0,
filename_prefix: str = "walk_cycle",
keep_frames: bool = True,
**gen_kwargs,
) -> dict:
"""Genera una animacion de personaje andando en pixel-art, end-to-end.
Args:
character: prompt del personaje ("pixel art knight, full body, side view").
No puede estar vacio. La identidad se mantiene entre frames con seed fija.
frames: numero de frames del ciclo (>=2, recomendado 4-8). keyword-only.
size: lado del grid pixel-art final por frame en pixeles (32 sprites
pequenos, 64 personajes con mas detalle). keyword-only.
colors: numero de colores de la paleta libre por frame (cuantizacion
MEDIANCUT) cuando palette es None. keyword-only.
fps: cadencia de la animacion en frames por segundo. keyword-only.
checkpoint: checkpoint SD1.5 (ControlNet OpenPose + IPAdapter-FaceID solo
instalados en SD1.5; default 'IMG_dreamshaper_8.safetensors'). keyword-only.
ref_image: imagen de cara de referencia en el input/ del servidor para
IPAdapter-FaceID (segunda ancla de identidad). None usa solo seed +
prompt. keyword-only.
server: host:port del servidor ComfyUI (sin esquema). keyword-only.
dest_dir: directorio donde guardar frames + sprite sheet + animacion.
keyword-only.
seed: semilla FIJA del KSampler para todos los frames (identidad estable).
keyword-only.
pose_method: "openpose" (esqueletos OpenPose -> ControlNet, control exacto),
"prompt" (fase del paso descrita en el prompt, sin esqueletos), o "auto"
(intenta openpose, cae a prompt si el render falla). keyword-only.
comfy_input_dir: directorio input/ del servidor ComfyUI donde se escriben
los esqueletos para que el ControlNet los lea (server local).
keyword-only.
controlnet_strength: fuerza del ControlNet OpenPose (0.7 da buen control de
la pose sin aplastar el estilo). keyword-only.
controlnet_end: fraccion de pasos en que el OpenPose deja de aplicarse
(end<1.0 libera los ultimos pasos para pelo/ropa). keyword-only.
facing: direccion a la que mira el personaje en los esqueletos ("right" o
"left"). keyword-only.
engine: motor de downscale del pixelizado ("pixeloe" contrast-aware o
"nearest"). keyword-only.
palette: None (paleta libre a `colors`), nombre builtin ("pico-8", "nes",
"game-boy") o lista de hex. keyword-only.
fmt: formato de la animacion ("webp" recomendado para alpha, o "gif").
keyword-only.
negative: prompt negativo de generacion. keyword-only.
width, height: resolucion de generacion por frame (512x768 vertical encuadra
cuerpo entero). keyword-only.
steps, cfg: parametros del KSampler. keyword-only.
comfy_python: interprete con pixeloe para el pixelizado (None autodetecta
~/ComfyUI/.venv). keyword-only.
wait_timeout: segundos maximos esperando cada frame al server. keyword-only.
filename_prefix: prefijo de los archivos de salida. keyword-only.
keep_frames: si True conserva los PNG de cada frame pixelizado; si False los
borra tras montar la animacion. keyword-only.
**gen_kwargs: params extra para comfyui_build_sprite_sheet_workflow
(sampler_name, scheduler, char_lora, lora_strength, weight, ...).
Returns:
dict con:
- ok (bool): True si se produjo la animacion con >=1 frame.
- frames (list[str]): rutas de los PNG pixelizados (size x size RGBA).
- spritesheet_path (str): ruta del sprite sheet horizontal.
- animation_path (str): ruta de la animacion WEBP/GIF en loop.
- size (int): lado real de cada frame pixelizado.
- n_frames (int): numero de frames producidos (puede ser < `frames` si
alguno fallo y se salto).
- seed (int): semilla usada.
- pose_method_used (str): "openpose" o "prompt" (refleja el fallback).
- skipped (list[int]): indices de frames que fallaron y se saltaron.
- error (str): mensaje de error; vacio si todo OK.
"""
out = {
"ok": False, "frames": [], "spritesheet_path": "", "animation_path": "",
"size": int(size), "n_frames": 0, "seed": int(seed),
"pose_method_used": "", "skipped": [], "error": "",
}
if not character or not character.strip():
out["error"] = "character vacio"
return out
if int(frames) < 2:
out["error"] = f"frames debe ser >= 2, recibido {frames!r}"
return out
if int(size) < 1:
out["error"] = f"size debe ser >= 1, recibido {size!r}"
return out
if pose_method not in ("auto", "openpose", "prompt"):
out["error"] = f"pose_method invalido: {pose_method!r}"
return out
n = int(frames)
dest = os.path.expanduser(dest_dir)
frames_dir = os.path.join(dest, f"{filename_prefix}_frames")
try:
os.makedirs(frames_dir, exist_ok=True)
except OSError as exc:
out["error"] = f"no se pudo crear dest_dir {frames_dir!r}: {exc}"
return out
# --- Fase 0: resolver el metodo de poses + esqueletos OpenPose si aplica. ---
skeleton_names: list[str | None] = [None] * n
method = "prompt" if pose_method == "prompt" else "openpose"
if method == "openpose":
input_dir = os.path.expanduser(comfy_input_dir)
sk = render_openpose_walk_skeletons(
input_dir, frames=n, width=int(width), height=int(height),
facing=facing, filename_prefix=f"{filename_prefix}_pose",
)
if sk.get("ok") and sk.get("skeleton_paths"):
paths = sk["skeleton_paths"]
# El builder espera el basename relativo al input/ del servidor.
skeleton_names = [os.path.basename(p) for p in paths][:n]
# Si se generaron menos esqueletos que frames, rellena con None.
while len(skeleton_names) < n:
skeleton_names.append(None)
else:
# Fallback limpio: no hay esqueletos -> modo prompt.
method = "prompt"
out["error"] = (
f"render de esqueletos OpenPose fallo ({sk.get('error')}); "
f"fallback a pose por prompt"
)
if pose_method == "openpose":
# El caller forzo openpose y no hay esqueletos: aun asi seguimos en
# prompt para no abortar, pero queda anotado en el error.
pass
out["pose_method_used"] = method
# --- Fase 1..N: generar + pixelizar cada frame del ciclo. ---
pixel_frames: list[str] = []
for i in range(n):
pose_skeleton = skeleton_names[i] if method == "openpose" else None
# En modo openpose la pose la fija el esqueleto: no se mete la fase en el
# prompt para no competir con el ControlNet. En modo prompt, la fase guia.
if method == "prompt":
subject_i = f"{character}, {_phase_for(i, n)}"
else:
subject_i = f"{character}, walking"
try:
wf = comfyui_build_sprite_sheet_workflow(
subject_i,
ref_image=ref_image,
pose_skeleton=pose_skeleton,
ckpt_name=checkpoint,
controlnet_strength=controlnet_strength,
controlnet_end=controlnet_end,
transparent=True,
negative=negative,
width=int(width),
height=int(height),
steps=int(steps),
cfg=float(cfg),
seed=int(seed), # FIJA: identidad consistente entre frames.
filename_prefix=f"{filename_prefix}_f{i}",
**gen_kwargs,
)
except (ValueError, TypeError) as exc:
out["skipped"].append(i)
if not out["error"]:
out["error"] = f"build workflow frame {i} fallo: {exc}"
continue
# submit -> wait -> fetch (alta resolucion RGBA). Cualquier fallo de red/
# GPU salta este frame y sigue (error path del DoD).
try:
sub = comfyui_submit_workflow(wf, server=server)
prompt_id = sub["prompt_id"]
outputs = comfyui_wait_result(prompt_id, server=server, timeout=wait_timeout)
except (RuntimeError, KeyError, OSError, TimeoutError) as exc:
out["skipped"].append(i)
if not out["error"]:
out["error"] = f"frame {i} fallo en submit/wait: {exc}"
continue
img = None
for node_out in outputs.values():
images = node_out.get("images") if isinstance(node_out, dict) else None
if images:
img = images[0]
break
if img is None:
out["skipped"].append(i)
continue
fetched = comfyui_fetch_output_image(
img["filename"], subfolder=img.get("subfolder", ""),
type_=img.get("type", "output"), server=server, dest_dir=frames_dir,
)
if not fetched.get("ok"):
out["skipped"].append(i)
if not out["error"]:
out["error"] = f"frame {i} fetch fallo: {fetched.get('error')}"
continue
# Pixelizar el PNG alta-res a un grid duro size x size RGBA.
frame_px = os.path.join(frames_dir, f"{filename_prefix}_px_{i:02d}.png")
px = comfyui_pixelize_sprite_png(
fetched["path"], frame_px, size=int(size), colors=int(colors),
engine=engine, palette=palette, transparent=True, autocrop=True,
comfy_python=comfy_python,
)
if not px.get("ok"):
out["skipped"].append(i)
if not out["error"]:
out["error"] = f"frame {i} pixelizado fallo: {px.get('error')}"
continue
pixel_frames.append(frame_px)
if not pixel_frames:
if not out["error"]:
out["error"] = "ningun frame se genero correctamente"
return out
# --- Ensamblado: sprite sheet horizontal + animacion en loop. ---
asm = assemble_animated_sprite(
pixel_frames, dest, name=filename_prefix, fps=int(fps), fmt=fmt,
loop=True, spritesheet=True,
)
if not asm.get("ok"):
out["frames"] = pixel_frames
out["n_frames"] = len(pixel_frames)
if not out["error"]:
out["error"] = f"ensamblado fallo: {asm.get('error')}"
return out
out["frames"] = pixel_frames
out["spritesheet_path"] = asm.get("spritesheet_path", "")
out["animation_path"] = asm.get("animation_path", "")
out["n_frames"] = len(pixel_frames)
# El size real lo confirma el primer frame ensamblado.
fs = asm.get("frame_size") or [int(size), int(size)]
out["size"] = int(fs[0])
if not keep_frames:
for fp in pixel_frames:
try:
os.remove(fp)
except OSError:
pass
out["ok"] = True
# El error de fallback de poses (si lo hubo) es informativo, no invalida el ok.
return out
if __name__ == "__main__":
import json
res = comfyui_walk_cycle_oneshot(
"pixel art knight, full body, side view",
frames=4, size=32, colors=16, fps=8, seed=42,
dest_dir="/tmp/comfy_walk_cycle",
)
print(json.dumps(res, indent=2))