Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cc90558d4 | |||
| 36a725ba10 | |||
| 1dd6c889e5 | |||
| 7aaac44a49 | |||
| ffcb69ce02 |
File diff suppressed because one or more lines are too long
@@ -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))
|
||||
@@ -77,7 +77,7 @@ def _content_bbox(img, alpha_threshold: int, bg_tolerance: int):
|
||||
def crop_to_content(
|
||||
img,
|
||||
*,
|
||||
pad_ratio: float = 0.06,
|
||||
pad_ratio: float = 0.02,
|
||||
square: bool = True,
|
||||
alpha_threshold: int = 10,
|
||||
bg_tolerance: int = 16,
|
||||
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user