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:
2026-06-28 18:14:46 +02:00
parent 36a725ba10
commit 6cc90558d4
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))