diff --git a/python/functions/ml/assemble_animated_sprite.md b/python/functions/ml/assemble_animated_sprite.md new file mode 100644 index 00000000..ebe04e9d --- /dev/null +++ b/python/functions/ml/assemble_animated_sprite.md @@ -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 '_sheet.png' y '.' 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. diff --git a/python/functions/ml/assemble_animated_sprite.py b/python/functions/ml/assemble_animated_sprite.py new file mode 100644 index 00000000..93038f7f --- /dev/null +++ b/python/functions/ml/assemble_animated_sprite.py @@ -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 (``_sheet.png`` y + ``.``). 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)) diff --git a/python/functions/ml/comfyui_build_walk_cycle_workflow.md b/python/functions/ml/comfyui_build_walk_cycle_workflow.md new file mode 100644 index 00000000..f2e6b3fa --- /dev/null +++ b/python/functions/ml/comfyui_build_walk_cycle_workflow.md @@ -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`. diff --git a/python/functions/ml/comfyui_build_walk_cycle_workflow.py b/python/functions/ml/comfyui_build_walk_cycle_workflow.py new file mode 100644 index 00000000..7955b638 --- /dev/null +++ b/python/functions/ml/comfyui_build_walk_cycle_workflow.py @@ -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)) diff --git a/python/functions/ml/comfyui_pixelize_sprite_png.md b/python/functions/ml/comfyui_pixelize_sprite_png.md new file mode 100644 index 00000000..be9383e4 --- /dev/null +++ b/python/functions/ml/comfyui_pixelize_sprite_png.md @@ -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. diff --git a/python/functions/ml/comfyui_pixelize_sprite_png.py b/python/functions/ml/comfyui_pixelize_sprite_png.py new file mode 100644 index 00000000..9673bc76 --- /dev/null +++ b/python/functions/ml/comfyui_pixelize_sprite_png.py @@ -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)) diff --git a/python/functions/ml/render_openpose_walk_skeletons.md b/python/functions/ml/render_openpose_walk_skeletons.md new file mode 100644 index 00000000..d2cb488e --- /dev/null +++ b/python/functions/ml/render_openpose_walk_skeletons.md @@ -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 '_.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. diff --git a/python/functions/ml/render_openpose_walk_skeletons.py b/python/functions/ml/render_openpose_walk_skeletons.py new file mode 100644 index 00000000..9c3681f4 --- /dev/null +++ b/python/functions/ml/render_openpose_walk_skeletons.py @@ -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 + "_.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)) diff --git a/python/functions/pipelines/comfyui_walk_cycle_oneshot.md b/python/functions/pipelines/comfyui_walk_cycle_oneshot.md new file mode 100644 index 00000000..bba51496 --- /dev/null +++ b/python/functions/pipelines/comfyui_walk_cycle_oneshot.md @@ -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. diff --git a/python/functions/pipelines/comfyui_walk_cycle_oneshot.py b/python/functions/pipelines/comfyui_walk_cycle_oneshot.py new file mode 100644 index 00000000..f5dae9a4 --- /dev/null +++ b/python/functions/pipelines/comfyui_walk_cycle_oneshot.py @@ -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))