Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02301aaed3 | |||
| 2729629f0a | |||
| 6cc90558d4 | |||
| 36a725ba10 | |||
| 1dd6c889e5 | |||
| 7aaac44a49 | |||
| ffcb69ce02 | |||
| c79f33265e | |||
| 31c2f6ac7f | |||
| 3bc97828e3 |
File diff suppressed because one or more lines are too long
@@ -44,8 +44,10 @@ from .trend_slope import trend_slope
|
|||||||
from .run_eda_models import run_eda_models
|
from .run_eda_models import run_eda_models
|
||||||
from .eda_llm_insights import eda_llm_insights
|
from .eda_llm_insights import eda_llm_insights
|
||||||
from .build_eda_notebook import build_eda_notebook
|
from .build_eda_notebook import build_eda_notebook
|
||||||
|
from .decode_qr_image import decode_qr_image
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"decode_qr_image",
|
||||||
"summarize_table_duckdb",
|
"summarize_table_duckdb",
|
||||||
"summarize_table_pg",
|
"summarize_table_pg",
|
||||||
"spearman_corr",
|
"spearman_corr",
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
"""
|
||||||
|
Decodificación robusta de códigos QR desde una imagen en disco.
|
||||||
|
|
||||||
|
Función del registry (grupo de capacidad `qr`, dominio `datascience`). Pensada para el caso real
|
||||||
|
en el que un lector básico (pyzbar, `cv2.QRCodeDetector` sobre la imagen cruda) NO capta el QR:
|
||||||
|
screenshots de pantalla con QR pálidos (bajo contraste) o pequeños. En vez de un único intento,
|
||||||
|
genera varias variantes preprocesadas de la imagen y prueba cada detector disponible sobre cada
|
||||||
|
variante, parando al primer acierto.
|
||||||
|
|
||||||
|
Impura: lee un archivo de disco y depende de OpenCV (`opencv-contrib-python-headless`). Degrada
|
||||||
|
limpio (devuelve `[]`) si la imagen no se puede leer o si ningún QR se decodifica; no lanza.
|
||||||
|
|
||||||
|
Detectores (se usan los que estén instalados; el import se envuelve en try/except para degradar):
|
||||||
|
- `cv2.QRCodeDetectorAruco` (preferido — OpenCV puro, sin libs de sistema)
|
||||||
|
- `cv2.QRCodeDetector` (fallback OpenCV puro)
|
||||||
|
- `cv2.wechat_qrcode.WeChatQRCode` (excelente con bajo contraste; SOLO si los modelos cargan)
|
||||||
|
- `pyzbar` (bonus opcional; requiere la lib de sistema `libzbar0`)
|
||||||
|
|
||||||
|
Cero dependencias de sistema obligatorias: con solo OpenCV la función ya funciona.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------------------------
|
||||||
|
# Detectores. Cada uno se normaliza a una función run(img) -> list[str] que nunca lanza.
|
||||||
|
# --------------------------------------------------------------------------------------------
|
||||||
|
def _make_opencv_runner(detector):
|
||||||
|
"""Envuelve un cv2.QRCodeDetector(Aruco) en run(img) -> list[str]."""
|
||||||
|
|
||||||
|
def run(img):
|
||||||
|
out: list[str] = []
|
||||||
|
# detectAndDecodeMulti: capta varios QR en la misma imagen.
|
||||||
|
try:
|
||||||
|
ok, decoded, _points, _ = detector.detectAndDecodeMulti(img)
|
||||||
|
if ok and decoded:
|
||||||
|
out = [s for s in decoded if s]
|
||||||
|
except cv2.error:
|
||||||
|
pass
|
||||||
|
if not out:
|
||||||
|
# Fallback al decodificador de un solo QR.
|
||||||
|
try:
|
||||||
|
s, _pts, _ = detector.detectAndDecode(img)
|
||||||
|
if s:
|
||||||
|
out = [s]
|
||||||
|
except cv2.error:
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
def _make_wechat_runner(wd):
|
||||||
|
"""Envuelve un cv2.wechat_qrcode.WeChatQRCode en run(img) -> list[str]."""
|
||||||
|
|
||||||
|
def run(img):
|
||||||
|
try:
|
||||||
|
texts, _points = wd.detectAndDecode(img)
|
||||||
|
return [t for t in texts if t]
|
||||||
|
except Exception:
|
||||||
|
# Si los modelos no están cargados o el detector falla, degradar sin romper.
|
||||||
|
return []
|
||||||
|
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
def _make_pyzbar_runner(zbar_decode):
|
||||||
|
"""Envuelve pyzbar.decode en run(img) -> list[str]."""
|
||||||
|
|
||||||
|
def run(img):
|
||||||
|
out: list[str] = []
|
||||||
|
try:
|
||||||
|
for sym in zbar_decode(img):
|
||||||
|
try:
|
||||||
|
out.append(sym.data.decode("utf-8", "replace"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
return out
|
||||||
|
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
def _build_detectors(debug=False):
|
||||||
|
"""Construye la lista de (nombre, runner) de detectores disponibles, en orden de preferencia."""
|
||||||
|
detectors = []
|
||||||
|
|
||||||
|
# OpenCV Aruco (preferido): no requiere libs de sistema ni descarga de modelos.
|
||||||
|
if hasattr(cv2, "QRCodeDetectorAruco"):
|
||||||
|
try:
|
||||||
|
detectors.append(("opencv_aruco", _make_opencv_runner(cv2.QRCodeDetectorAruco())))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# OpenCV clásico (fallback puro).
|
||||||
|
if hasattr(cv2, "QRCodeDetector"):
|
||||||
|
try:
|
||||||
|
detectors.append(("opencv", _make_opencv_runner(cv2.QRCodeDetector())))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# WeChat QR (excelente con bajo contraste) — SOLO si los modelos cargan; opcional.
|
||||||
|
if hasattr(cv2, "wechat_qrcode"):
|
||||||
|
try:
|
||||||
|
wd = cv2.wechat_qrcode.WeChatQRCode()
|
||||||
|
detectors.append(("wechat", _make_wechat_runner(wd)))
|
||||||
|
except Exception:
|
||||||
|
# Modelos no presentes / build sin soporte → saltar sin romper.
|
||||||
|
pass
|
||||||
|
|
||||||
|
# pyzbar (bonus): requiere libzbar0 (lib de sistema). Degrada si falta.
|
||||||
|
try:
|
||||||
|
from pyzbar.pyzbar import decode as _zbar_decode # type: ignore
|
||||||
|
|
||||||
|
detectors.append(("pyzbar", _make_pyzbar_runner(_zbar_decode)))
|
||||||
|
except (ImportError, OSError, Exception): # noqa: B014 - OSError = libzbar0 ausente
|
||||||
|
pass
|
||||||
|
|
||||||
|
if debug:
|
||||||
|
print(
|
||||||
|
f"[decode_qr_image] detectores disponibles: {[n for n, _ in detectors]}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return detectors
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------------------------
|
||||||
|
# Variantes preprocesadas de la imagen. Orden = prioridad; se para en el primer acierto.
|
||||||
|
# --------------------------------------------------------------------------------------------
|
||||||
|
def _load_bgr(image_path):
|
||||||
|
"""Carga la imagen como BGR (uint8). Devuelve None si no se puede leer."""
|
||||||
|
bgr = cv2.imread(image_path, cv2.IMREAD_COLOR)
|
||||||
|
if bgr is not None:
|
||||||
|
return bgr
|
||||||
|
# Fallback PIL para formatos que cv2.imread no maneja en esta build.
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
pil = Image.open(image_path).convert("RGB")
|
||||||
|
return cv2.cvtColor(np.asarray(pil), cv2.COLOR_RGB2BGR)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_variants(image_path, upscale):
|
||||||
|
"""Genera (nombre, ndarray) de variantes preprocesadas, en orden de prioridad."""
|
||||||
|
bgr = _load_bgr(image_path)
|
||||||
|
if bgr is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
|
||||||
|
|
||||||
|
# Contrast stretch (NORM_MINMAX): clave para QR de bajo contraste (gris sobre gris).
|
||||||
|
stretch = cv2.normalize(gray, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
|
||||||
|
|
||||||
|
# CLAHE: realce de contraste local.
|
||||||
|
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)).apply(gray)
|
||||||
|
|
||||||
|
# Upscale del stretch: QR pequeño es la causa #1 de fallo.
|
||||||
|
if upscale and upscale > 1:
|
||||||
|
up = cv2.resize(stretch, None, fx=upscale, fy=upscale, interpolation=cv2.INTER_CUBIC)
|
||||||
|
else:
|
||||||
|
up = stretch
|
||||||
|
|
||||||
|
# Binarizaciones sobre el stretch (mejor base que el gris crudo).
|
||||||
|
_, otsu = cv2.threshold(stretch, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
||||||
|
adaptive = cv2.adaptiveThreshold(
|
||||||
|
stretch, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 31, 5
|
||||||
|
)
|
||||||
|
|
||||||
|
variants = [
|
||||||
|
("original", bgr),
|
||||||
|
("gray", gray),
|
||||||
|
("contrast_stretch", stretch),
|
||||||
|
("clahe", clahe),
|
||||||
|
("upscale", up),
|
||||||
|
("otsu", otsu),
|
||||||
|
("adaptive_gaussian", adaptive),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Rotaciones sobre la mejor variante binarizada (Otsu).
|
||||||
|
for name, rot in (
|
||||||
|
("rot90", cv2.ROTATE_90_CLOCKWISE),
|
||||||
|
("rot180", cv2.ROTATE_180),
|
||||||
|
("rot270", cv2.ROTATE_90_COUNTERCLOCKWISE),
|
||||||
|
):
|
||||||
|
variants.append((f"otsu_{name}", cv2.rotate(otsu, rot)))
|
||||||
|
|
||||||
|
return variants
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------------------------
|
||||||
|
# API pública.
|
||||||
|
# --------------------------------------------------------------------------------------------
|
||||||
|
def decode_qr_image(image_path: str, upscale: int = 2, debug: bool = False) -> list[str]:
|
||||||
|
"""Decodifica los códigos QR de una imagen, robusto a bajo contraste y QR pequeños.
|
||||||
|
|
||||||
|
Genera varias variantes preprocesadas de la imagen (escala de grises, contrast stretch,
|
||||||
|
CLAHE, upscale, binarización Otsu/adaptativa, rotaciones) y prueba cada detector disponible
|
||||||
|
(OpenCV Aruco/clásico, WeChat si hay modelos, pyzbar si hay libzbar0) sobre cada variante,
|
||||||
|
parando al primer acierto.
|
||||||
|
|
||||||
|
Parámetros (`upscale` y `debug` pensados como opciones keyword):
|
||||||
|
image_path: ruta del archivo de imagen a leer (png/jpg/...).
|
||||||
|
upscale: factor de ampliación (INTER_CUBIC) aplicado a la variante de contraste estirado
|
||||||
|
para rescatar QR pequeños. Default 2. <=1 desactiva el upscale.
|
||||||
|
debug: si True, imprime a stderr qué variante/detector acertó (o que no se detectó nada).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Lista de payloads de texto de los QR detectados (deduplicada, preservando orden). Lista
|
||||||
|
vacía si no se detecta ninguno o si la imagen no se puede leer. No lanza.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
variants = _build_variants(image_path, upscale)
|
||||||
|
except Exception as exc: # pragma: no cover - defensa ante imágenes corruptas
|
||||||
|
if debug:
|
||||||
|
print(f"[decode_qr_image] fallo construyendo variantes: {exc}", file=sys.stderr)
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not variants:
|
||||||
|
if debug:
|
||||||
|
print(f"[decode_qr_image] no se pudo leer la imagen: {image_path}", file=sys.stderr)
|
||||||
|
return []
|
||||||
|
|
||||||
|
detectors = _build_detectors(debug=debug)
|
||||||
|
if not detectors:
|
||||||
|
if debug:
|
||||||
|
print("[decode_qr_image] ningún detector QR disponible", file=sys.stderr)
|
||||||
|
return []
|
||||||
|
|
||||||
|
for vname, vimg in variants:
|
||||||
|
for dname, drun in detectors:
|
||||||
|
payloads = drun(vimg)
|
||||||
|
uniq = list(dict.fromkeys(p for p in payloads if p))
|
||||||
|
if uniq:
|
||||||
|
if debug:
|
||||||
|
print(
|
||||||
|
f"[decode_qr_image] acierto variante={vname} detector={dname} "
|
||||||
|
f"n={len(uniq)}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return uniq
|
||||||
|
|
||||||
|
if debug:
|
||||||
|
print("[decode_qr_image] ningún QR decodificado en ninguna variante", file=sys.stderr)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Demo CLI para `python3 decode_qr_image.py <image_path> [upscale] [debug]`.
|
||||||
|
# (fn run usa su propio runner generado; este bloque es para invocación manual directa.)
|
||||||
|
import json
|
||||||
|
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print(json.dumps({"error": "uso: <image_path> [upscale] [debug]"}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
_path = sys.argv[1]
|
||||||
|
_upscale = int(sys.argv[2]) if len(sys.argv) > 2 else 2
|
||||||
|
_debug = (sys.argv[3].lower() in ("1", "true", "yes")) if len(sys.argv) > 3 else False
|
||||||
|
|
||||||
|
_result = decode_qr_image(_path, upscale=_upscale, debug=_debug)
|
||||||
|
print(json.dumps(_result))
|
||||||
@@ -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))
|
||||||
@@ -3,11 +3,11 @@ name: comfyui_build_pixelart_workflow
|
|||||||
kind: function
|
kind: function
|
||||||
lang: py
|
lang: py
|
||||||
domain: ml
|
domain: ml
|
||||||
version: "1.0.0"
|
version: "1.1.0"
|
||||||
purity: pure
|
purity: pure
|
||||||
signature: "def comfyui_build_pixelart_workflow(positive: str, negative: str = \"blurry, jpeg artifacts, gradient, smooth shading, anti-aliasing\", *, ckpt_name: str = \"IMG_juggernaut_xl_v11.safetensors\", pixel_lora: str = \"SDXL_pixel-art.safetensors\", lora_strength: float = 1.2, use_lcm: bool = True, lcm_lora: str = \"SDXL_lcm-lora.safetensors\", lcm_strength: float = 1.0, steps: int | None = None, cfg: float | None = None, width: int = 1024, height: int = 1024, seed: int = 0, sampler_name: str | None = None, scheduler: str | None = None, filename_prefix: str = \"pixelart\") -> dict"
|
signature: "def comfyui_build_pixelart_workflow(positive: str, negative: str = \"blurry, jpeg artifacts, gradient, smooth shading, anti-aliasing\", *, ckpt_name: str = \"IMG_juggernaut_xl_v11.safetensors\", pixel_lora: str = \"SDXL_pixel-art.safetensors\", lora_strength: float = 1.2, use_lcm: bool = True, lcm_lora: str = \"SDXL_lcm-lora.safetensors\", lcm_strength: float = 1.0, steps: int | None = None, cfg: float | None = None, width: int = 1024, height: int = 1024, seed: int = 0, sampler_name: str | None = None, scheduler: str | None = None, transparent: bool = True, rembg_model: str = \"u2net\", filename_prefix: str = \"pixelart\") -> dict"
|
||||||
description: "Construye el dict (API format) del workflow ComfyUI de pixel-art Fase 1: SDXL base + LoRA SDXL_pixel-art (nerijs), opcionalmente con LCM-LoRA para 8 steps. Compone comfyui_build_txt2img_workflow + comfyui_inject_multi_lora. El pixel-perfect (Fase 2) lo hace comfyui_pixelize_image, no este workflow. Pura, sin red ni I/O. class_types verificados contra /object_info (8GB lowvram)."
|
description: "Construye el dict (API format) del workflow ComfyUI de pixel-art Fase 1: SDXL base + LoRA SDXL_pixel-art (nerijs), opcionalmente con LCM-LoRA para 8 steps. Si transparent (default), inyecta un nodo 'Image Rembg' tras el VAEDecode para recortar el fondo -> sprite con alpha (mismo patron que comfyui_build_item_icon_workflow); transparent=False para tiles/fondos opacos. Compone comfyui_build_txt2img_workflow + comfyui_inject_multi_lora. El pixel-perfect (Fase 2) lo hace comfyui_pixelize_image, no este workflow. Pura, sin red ni I/O. class_types verificados contra /object_info (8GB lowvram)."
|
||||||
tags: [comfyui, ml, gamedev-2d, pixelart, workflow, stable-diffusion, sdxl]
|
tags: [comfyui, ml, gamedev-2d, pixelart, workflow, stable-diffusion, sdxl, rembg, transparent]
|
||||||
uses_functions: [comfyui_build_txt2img_workflow_py_ml, comfyui_inject_multi_lora_py_ml]
|
uses_functions: [comfyui_build_txt2img_workflow_py_ml, comfyui_inject_multi_lora_py_ml]
|
||||||
uses_types: []
|
uses_types: []
|
||||||
returns: []
|
returns: []
|
||||||
@@ -45,11 +45,15 @@ params:
|
|||||||
desc: "Sampler del KSampler. None = default del modo ('lcm' con LCM, 'euler' sin). keyword-only."
|
desc: "Sampler del KSampler. None = default del modo ('lcm' con LCM, 'euler' sin). keyword-only."
|
||||||
- name: scheduler
|
- name: scheduler
|
||||||
desc: "Scheduler del KSampler. None = default del modo ('sgm_uniform' con LCM, 'normal' sin). keyword-only."
|
desc: "Scheduler del KSampler. None = default del modo ('sgm_uniform' con LCM, 'normal' sin). keyword-only."
|
||||||
|
- name: transparent
|
||||||
|
desc: "si True (default) inyecta 'Image Rembg' tras VAEDecode y el PNG sale con alpha (fondo recortado) — para sprites de sujeto (personajes/objetos). False deja fondo opaco — para tiles/texturas/fondos. keyword-only."
|
||||||
|
- name: rembg_model
|
||||||
|
desc: "modelo Rembg ('u2net' general, 'isnet-anime' anime). Solo se usa si transparent=True. keyword-only."
|
||||||
- name: filename_prefix
|
- name: filename_prefix
|
||||||
desc: "Prefijo del PNG que SaveImage escribe en output/. keyword-only."
|
desc: "Prefijo del PNG que SaveImage escribe en output/. keyword-only."
|
||||||
output: "dict en API format listo para comfyui_submit_workflow: CheckpointLoaderSimple + 1 LoraLoader (SDXL_pixel-art) o 2 (+ SDXL_lcm-lora si use_lcm) + KSampler con params del modo + SaveImage."
|
output: "dict en API format listo para comfyui_submit_workflow: CheckpointLoaderSimple + 1 LoraLoader (SDXL_pixel-art) o 2 (+ SDXL_lcm-lora si use_lcm) + KSampler con params del modo + nodo 'Image Rembg' antes del SaveImage si transparent + SaveImage."
|
||||||
tested: true
|
tested: true
|
||||||
tests: ["golden use_lcm=True: 2 LoraLoader (SDXL_pixel-art@1.2, lcm@1.0) + KSampler steps 8/cfg 1.5/sampler lcm/sgm_uniform", "edge use_lcm=False: 1 LoraLoader + KSampler steps 25/cfg 7/euler/normal", "edge overrides steps/cfg + clamp lora_strength a 2.0", "error positive vacio -> ValueError", "determinismo"]
|
tests: ["golden use_lcm=True: 2 LoraLoader (SDXL_pixel-art@1.2, lcm@1.0) + KSampler steps 8/cfg 1.5/sampler lcm/sgm_uniform", "edge use_lcm=False: 1 LoraLoader + KSampler steps 25/cfg 7/euler/normal", "edge overrides steps/cfg + clamp lora_strength a 2.0", "error positive vacio -> ValueError", "determinismo", "transparent default inyecta Image Rembg + repunta SaveImage", "transparent=False sin Rembg (SaveImage lee del VAEDecode)", "rembg_model override"]
|
||||||
test_file_path: "python/functions/ml/comfyui_build_pixelart_workflow_test.py"
|
test_file_path: "python/functions/ml/comfyui_build_pixelart_workflow_test.py"
|
||||||
file_path: "python/functions/ml/comfyui_build_pixelart_workflow.py"
|
file_path: "python/functions/ml/comfyui_build_pixelart_workflow.py"
|
||||||
---
|
---
|
||||||
@@ -94,3 +98,15 @@ Para tilesets, genera cada tile por separado y ensambla con `comfyui_build_grid`
|
|||||||
`--lowvram`; la Fase 2 es CPU y no toca VRAM.
|
`--lowvram`; la Fase 2 es CPU y no toca VRAM.
|
||||||
- Función pura: no valida contra el server. Si una LoRA/checkpoint falta, el HTTP
|
- Función pura: no valida contra el server. Si una LoRA/checkpoint falta, el HTTP
|
||||||
400 salta al enviar con `comfyui_submit_workflow`.
|
400 salta al enviar con `comfyui_submit_workflow`.
|
||||||
|
- **transparent=True (default, v1.1.0)**: inyecta el nodo `Image Rembg (Remove
|
||||||
|
Background)`. Requiere el custom node `ComfyUI-Image-Background-Remove` (o equiv.)
|
||||||
|
instalado en el server; si falta, el `submit` devuelve error en el dict (no crashea).
|
||||||
|
El sprite sale RGBA con fondo recortado — ideal para personajes/objetos. Para
|
||||||
|
tiles/texturas/fondos sin contorno usar `transparent=False` (PNG opaco).
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
- v1.1.0 (2026-06-28) — `transparent`/`rembg_model`: inyecta `Image Rembg` tras el
|
||||||
|
VAEDecode (mismo patron que `comfyui_build_item_icon_workflow`) para producir
|
||||||
|
sprites con fondo transparente. Cierra el bug del pipeline pixelart que no podia
|
||||||
|
generar sprites sin fondo (issue sprite-fix).
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -29,6 +30,44 @@ _LCM_DEFAULTS = {"steps": 8, "cfg": 1.5, "sampler_name": "lcm", "scheduler": "sg
|
|||||||
_PLAIN_DEFAULTS = {"steps": 25, "cfg": 7.0, "sampler_name": "euler", "scheduler": "normal"}
|
_PLAIN_DEFAULTS = {"steps": 25, "cfg": 7.0, "sampler_name": "euler", "scheduler": "normal"}
|
||||||
|
|
||||||
|
|
||||||
|
def _inject_rembg(workflow: dict, model: str) -> dict:
|
||||||
|
"""Inserta 'Image Rembg (Remove Background)' (transparency=True) entre VAEDecode y SaveImage.
|
||||||
|
|
||||||
|
Mismo helper que comfyui_build_item_icon_workflow / comfyui_build_sprite_sheet_workflow:
|
||||||
|
el nodo recorta la silueta del sujeto dejando alpha, y se repunta SaveImage.images a
|
||||||
|
la salida del Rembg para que el PNG salga con fondo transparente. No muta el dict de
|
||||||
|
entrada (copia profunda).
|
||||||
|
"""
|
||||||
|
wf = copy.deepcopy(workflow)
|
||||||
|
vaedecode_id = next(
|
||||||
|
(nid for nid, n in wf.items() if n.get("class_type") == "VAEDecode"), None
|
||||||
|
)
|
||||||
|
save_id = next((nid for nid, n in wf.items() if n.get("class_type") == "SaveImage"), None)
|
||||||
|
if vaedecode_id is None or save_id is None:
|
||||||
|
raise ValueError(
|
||||||
|
"comfyui_build_pixelart_workflow: no se encontro VAEDecode/SaveImage para Rembg"
|
||||||
|
)
|
||||||
|
numeric = [int(k) for k in wf.keys() if str(k).isdigit()]
|
||||||
|
rembg_id = str((max(numeric) + 1) if numeric else len(wf) + 1)
|
||||||
|
wf[rembg_id] = {
|
||||||
|
"class_type": "Image Rembg (Remove Background)",
|
||||||
|
"inputs": {
|
||||||
|
"images": [vaedecode_id, 0],
|
||||||
|
"transparency": True,
|
||||||
|
"model": 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",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
wf[save_id]["inputs"]["images"] = [rembg_id, 0]
|
||||||
|
return wf
|
||||||
|
|
||||||
|
|
||||||
def comfyui_build_pixelart_workflow(
|
def comfyui_build_pixelart_workflow(
|
||||||
positive: str,
|
positive: str,
|
||||||
negative: str = "blurry, jpeg artifacts, gradient, smooth shading, anti-aliasing",
|
negative: str = "blurry, jpeg artifacts, gradient, smooth shading, anti-aliasing",
|
||||||
@@ -46,6 +85,8 @@ def comfyui_build_pixelart_workflow(
|
|||||||
seed: int = 0,
|
seed: int = 0,
|
||||||
sampler_name: str | None = None,
|
sampler_name: str | None = None,
|
||||||
scheduler: str | None = None,
|
scheduler: str | None = None,
|
||||||
|
transparent: bool = True,
|
||||||
|
rembg_model: str = "u2net",
|
||||||
filename_prefix: str = "pixelart",
|
filename_prefix: str = "pixelart",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Construye el dict (API format) del workflow pixel-art SDXL + LoRA.
|
"""Construye el dict (API format) del workflow pixel-art SDXL + LoRA.
|
||||||
@@ -70,15 +111,24 @@ def comfyui_build_pixelart_workflow(
|
|||||||
width, height: resolucion base (1024x1024 SDXL; luego downscale x8 -> 128
|
width, height: resolucion base (1024x1024 SDXL; luego downscale x8 -> 128
|
||||||
en la Fase 2 con comfyui_pixelize_image).
|
en la Fase 2 con comfyui_pixelize_image).
|
||||||
seed: semilla del KSampler.
|
seed: semilla del KSampler.
|
||||||
|
transparent: si True (default) inyecta 'Image Rembg' tras el VAEDecode y el
|
||||||
|
PNG sale con alpha (fondo recortado) — lo habitual para sprites de sujeto
|
||||||
|
(personajes, criaturas, objetos). Si False deja la imagen opaca sobre
|
||||||
|
fondo plano, para tiles/texturas/fondos que no quieren transparencia.
|
||||||
|
keyword-only.
|
||||||
|
rembg_model: modelo Rembg ('u2net' general, 'isnet-anime' para anime). Solo
|
||||||
|
se usa si transparent=True. keyword-only.
|
||||||
filename_prefix: prefijo del PNG en output/.
|
filename_prefix: prefijo del PNG en output/.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict en API format listo para comfyui_submit_workflow, con el
|
dict en API format listo para comfyui_submit_workflow, con el
|
||||||
CheckpointLoaderSimple, 1 LoraLoader (SDXL_pixel-art) o 2 (SDXL_pixel-art +
|
CheckpointLoaderSimple, 1 LoraLoader (SDXL_pixel-art) o 2 (SDXL_pixel-art +
|
||||||
SDXL_lcm-lora si use_lcm), KSampler con los params del modo y SaveImage.
|
SDXL_lcm-lora si use_lcm), KSampler con los params del modo, un nodo
|
||||||
|
'Image Rembg' antes del SaveImage si transparent, y SaveImage.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: si positive esta vacio.
|
ValueError: si positive esta vacio, o si la base no tiene VAEDecode/SaveImage
|
||||||
|
donde inyectar el Rembg (propagado por el helper, solo si transparent).
|
||||||
"""
|
"""
|
||||||
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
|
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
|
||||||
from ml.comfyui_inject_multi_lora import comfyui_inject_multi_lora
|
from ml.comfyui_inject_multi_lora import comfyui_inject_multi_lora
|
||||||
@@ -117,7 +167,12 @@ def comfyui_build_pixelart_workflow(
|
|||||||
{"name": lcm_lora, "strength_model": lcm_strength, "strength_clip": lcm_strength}
|
{"name": lcm_lora, "strength_model": lcm_strength, "strength_clip": lcm_strength}
|
||||||
)
|
)
|
||||||
|
|
||||||
return comfyui_inject_multi_lora(base, loras)
|
wf = comfyui_inject_multi_lora(base, loras)
|
||||||
|
|
||||||
|
if transparent:
|
||||||
|
wf = _inject_rembg(wf, rembg_model)
|
||||||
|
|
||||||
|
return wf
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -3,11 +3,11 @@ name: comfyui_pixelize_image
|
|||||||
kind: function
|
kind: function
|
||||||
lang: py
|
lang: py
|
||||||
domain: ml
|
domain: ml
|
||||||
version: "1.0.0"
|
version: "1.1.0"
|
||||||
purity: impure
|
purity: impure
|
||||||
signature: "def comfyui_pixelize_image(src_path: str, dst_path: str, *, downscale: int = 8, colors: int = 16, palette=None, dither: bool = False, upscale_back: bool = True) -> dict"
|
signature: "def comfyui_pixelize_image(src_path: str, dst_path: str, *, downscale: int = 8, colors: int = 16, palette=None, dither: bool = False, upscale_back: bool = True, keep_alpha: bool = True, alpha_threshold: int = 128) -> dict"
|
||||||
description: "Post-proceso pixel-perfect (Fase 2 pixelart): imagen -> downscale nearest-neighbor por factor (colapsa cada bloque borroso a un pixel duro) -> cuantizacion a N colores (MEDIANCUT) o a una paleta fija embebida (game-boy / pico-8 / nes / lista de hex) -> opcional re-upscale nearest conservando los pixeles duros. Convierte el 'pixelart borroso de IA' en pixelart de verdad. Nucleo PIL puro, CPU-only: sin GPU, sin red. Devuelve {ok, out_path, size, n_colors_final, error}. Impura solo por la lectura/escritura de disco."
|
description: "Post-proceso pixel-perfect (Fase 2 pixelart): imagen -> downscale nearest-neighbor por factor (colapsa cada bloque borroso a un pixel duro) -> cuantizacion a N colores (MEDIANCUT) o a una paleta fija embebida (game-boy / pico-8 / nes / lista de hex) -> opcional re-upscale nearest conservando los pixeles duros. Alpha-aware: si la entrada es RGBA y keep_alpha, cuantiza SOLO el RGB (el fondo transparente no entra en la paleta) y preserva/binariza el alpha por separado -> PNG RGBA con transparencia real. Convierte el 'pixelart borroso de IA' en pixelart de verdad. Nucleo PIL puro, CPU-only: sin GPU, sin red. Devuelve {ok, out_path, size, n_colors_final, has_alpha, error}. Impura solo por la lectura/escritura de disco."
|
||||||
tags: [comfyui, gamedev-2d, pixelart, ml, pil, quantize, palette, image]
|
tags: [comfyui, gamedev-2d, pixelart, ml, pil, quantize, palette, image, alpha, transparent]
|
||||||
uses_functions: []
|
uses_functions: []
|
||||||
uses_types: []
|
uses_types: []
|
||||||
returns: []
|
returns: []
|
||||||
@@ -29,9 +29,13 @@ params:
|
|||||||
desc: "aplica Floyd-Steinberg al cuantizar (off por defecto = pixelart limpio). keyword-only."
|
desc: "aplica Floyd-Steinberg al cuantizar (off por defecto = pixelart limpio). keyword-only."
|
||||||
- name: upscale_back
|
- name: upscale_back
|
||||||
desc: "re-escala nearest al tamano original (preview con pixeles duros). False deja la imagen pequena. keyword-only."
|
desc: "re-escala nearest al tamano original (preview con pixeles duros). False deja la imagen pequena. keyword-only."
|
||||||
output: "dict con ok (bool), out_path (str), size ([w,h] de la imagen final), n_colors_final (int, colores distintos del resultado), error (str, vacio si OK)."
|
- name: keep_alpha
|
||||||
|
desc: "si True (default) y la entrada tiene canal alpha, preserva la transparencia: cuantiza solo el RGB y downscalea/binariza el alpha aparte -> PNG RGBA. Sin efecto si la imagen no tiene alpha (sale RGB igual que antes). keyword-only."
|
||||||
|
- name: alpha_threshold
|
||||||
|
desc: "umbral (0..255) para binarizar el alpha en opaco (255) o transparente (0). Solo aplica cuando se preserva el alpha. keyword-only."
|
||||||
|
output: "dict con ok (bool), out_path (str), size ([w,h] de la imagen final), n_colors_final (int, colores RGB distintos; en la zona opaca si es RGBA), has_alpha (bool, True si la salida es RGBA), error (str, vacio si OK)."
|
||||||
tested: true
|
tested: true
|
||||||
tests: [test_golden_downscale_quantize, test_no_upscale_back_keeps_small, test_edge_fixed_palette_game_boy, test_edge_palette_list_hex, test_edge_downscale_1_only_quantizes, test_error_missing_src, test_error_downscale_zero, test_error_bad_palette]
|
tests: [test_golden_downscale_quantize, test_no_upscale_back_keeps_small, test_edge_fixed_palette_game_boy, test_edge_palette_list_hex, test_edge_downscale_1_only_quantizes, test_error_missing_src, test_error_downscale_zero, test_error_bad_palette, test_alpha_preserved_transparent_corners, test_alpha_off_flattens_to_rgb, test_rgb_input_unaffected_by_keep_alpha, test_error_all_transparent_no_crash]
|
||||||
test_file_path: "python/functions/ml/comfyui_pixelize_image_test.py"
|
test_file_path: "python/functions/ml/comfyui_pixelize_image_test.py"
|
||||||
file_path: "python/functions/ml/comfyui_pixelize_image.py"
|
file_path: "python/functions/ml/comfyui_pixelize_image.py"
|
||||||
---
|
---
|
||||||
@@ -54,14 +58,21 @@ res = comfyui_pixelize_image(
|
|||||||
# Forzar la paleta retro Game Boy (4 colores) y dejar la imagen pequena (sin upscale)
|
# Forzar la paleta retro Game Boy (4 colores) y dejar la imagen pequena (sin upscale)
|
||||||
comfyui_pixelize_image("/tmp/hero_pixel.png", "/tmp/hero_gb.png",
|
comfyui_pixelize_image("/tmp/hero_pixel.png", "/tmp/hero_gb.png",
|
||||||
palette="game-boy", upscale_back=False)
|
palette="game-boy", upscale_back=False)
|
||||||
|
|
||||||
|
# Sprite RGBA (tras rembg): preserva la transparencia, cuantiza solo el sujeto
|
||||||
|
res = comfyui_pixelize_image("/tmp/knight_rgba.png", "/tmp/knight_px.png",
|
||||||
|
downscale=1, colors=16, keep_alpha=True)
|
||||||
|
# {'ok': True, 'has_alpha': True, 'n_colors_final': 16, ...} -> fondo transparente intacto
|
||||||
```
|
```
|
||||||
|
|
||||||
## Cuando usarla
|
## Cuando usarla
|
||||||
|
|
||||||
Fase 2 del pipeline pixelart: tras generar el crudo (SDXL + LoRA `SDXL_pixel-art`),
|
Fase 2 del pipeline pixelart: tras generar el crudo (SDXL + LoRA `SDXL_pixel-art`),
|
||||||
para colapsar el grid borroso a pixeles duros y limitar la paleta. Tambien sirve
|
para colapsar el grid borroso a pixeles duros y limitar la paleta. Si la imagen
|
||||||
para "pixelizar" cualquier imagen (sprite, render, foto) a estetica retro sin
|
viene de `rembg` con fondo recortado (RGBA), `keep_alpha=True` mantiene la
|
||||||
tocar la GPU. Para llevar el resultado a Godot con filtro Nearest:
|
transparencia y deja el fondo fuera de la paleta. Tambien sirve para "pixelizar"
|
||||||
|
cualquier imagen (sprite, render, foto) a estetica retro sin tocar la GPU. Para
|
||||||
|
llevar el resultado a Godot con filtro Nearest:
|
||||||
`comfyui_export_asset_to_godot(out, "pixelart", proj)`.
|
`comfyui_export_asset_to_godot(out, "pixelart", proj)`.
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
@@ -76,7 +87,22 @@ tocar la GPU. Para llevar el resultado a Godot con filtro Nearest:
|
|||||||
duros (preview).
|
duros (preview).
|
||||||
- Todo error es **dict `ok=False`** (no excepcion): `src_path` inexistente,
|
- Todo error es **dict `ok=False`** (no excepcion): `src_path` inexistente,
|
||||||
`downscale<1`, paleta desconocida -> `error` explica. No crashea ni borra nada.
|
`downscale<1`, paleta desconocida -> `error` explica. No crashea ni borra nada.
|
||||||
- `n_colors_final` cuenta colores distintos reales del PNG escrito; con paleta fija
|
- `n_colors_final` cuenta colores RGB distintos reales del PNG escrito; con salida
|
||||||
puede ser **menor** que el tamano de la paleta si la imagen no usa todos.
|
RGBA cuenta **solo la zona opaca** (el transparente no es un color del pixel-art);
|
||||||
|
con paleta fija puede ser **menor** que el tamano de la paleta si la imagen no usa todos.
|
||||||
|
- **alpha-aware (v1.1.0)**: con entrada RGBA y `keep_alpha=True` (default), el fondo
|
||||||
|
transparente se rellena internamente con la moda del sujeto antes de cuantizar, asi
|
||||||
|
NO gasta una entrada de la paleta; el alpha se downscalea nearest aparte y se
|
||||||
|
binariza por `alpha_threshold` (0/255 = bordes duros pixel-art). Entrada sin alpha
|
||||||
|
-> comportamiento RGB identico al de antes (retrocompatible).
|
||||||
|
- Si la entrada RGBA esta **toda transparente** (rembg sin sujeto), no crashea:
|
||||||
|
devuelve `ok=True`, `has_alpha=True`, `n_colors_final=0` y el PNG sigue transparente.
|
||||||
- CPU-only: no toca la GPU ni el servidor ComfyUI; corre en cualquier interprete
|
- CPU-only: no toca la GPU ni el servidor ComfyUI; corre en cualquier interprete
|
||||||
con Pillow.
|
con Pillow (numpy acelera el relleno alpha; sin numpy degrada limpio).
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
- v1.1.0 (2026-06-28) — alpha-aware: `keep_alpha`/`alpha_threshold`. Si la entrada
|
||||||
|
es RGBA, cuantiza solo el RGB (fondo transparente fuera de la paleta) y preserva el
|
||||||
|
alpha binarizado -> PNG RGBA con transparencia real. Cierra el bug del pipeline
|
||||||
|
pixelart que perdia el fondo transparente por el `convert("RGB")` (issue sprite-fix).
|
||||||
|
|||||||
@@ -64,8 +64,60 @@ def _normalize_palette(palette):
|
|||||||
return [_hex_to_rgb(h) for h in hexes]
|
return [_hex_to_rgb(h) for h in hexes]
|
||||||
|
|
||||||
|
|
||||||
def _pixelize_pil(img, downscale, colors, palette_rgb, dither, upscale_back):
|
def _img_has_alpha(img) -> bool:
|
||||||
"""Nucleo puro PIL: imagen RGB -> imagen RGB pixelizada.
|
"""True si la imagen lleva transparencia (RGBA, LA o P con transparency)."""
|
||||||
|
return img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info)
|
||||||
|
|
||||||
|
|
||||||
|
def _fill_transparent_with_mode(small_rgb, small_alpha, threshold):
|
||||||
|
"""Rellena los pixeles transparentes con el color opaco mas frecuente (moda).
|
||||||
|
|
||||||
|
Asi el fondo transparente NO aporta colores nuevos a la cuantizacion: las zonas
|
||||||
|
con alpha <= threshold toman un color que ya esta en el sujeto (y por tanto en la
|
||||||
|
paleta resultante), sin gastar entradas de la paleta en el color de fondo. El
|
||||||
|
color real de esas zonas es irrelevante para la salida porque luego reciben
|
||||||
|
alpha 0. Si no hay numpy, cae a no rellenar (degradacion limpia).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
small_rgb: PIL.Image RGB ya reducida.
|
||||||
|
small_alpha: PIL.Image 'L' del alpha ya reducido (mismo tamano).
|
||||||
|
threshold: umbral de alpha (0..255); <= threshold = transparente.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PIL.Image RGB con el fondo transparente relleno con la moda del sujeto.
|
||||||
|
"""
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
rgb = small_rgb.convert("RGB")
|
||||||
|
mask = small_alpha.point(lambda p: 255 if p > threshold else 0).convert("L")
|
||||||
|
try:
|
||||||
|
import numpy as np
|
||||||
|
except ImportError:
|
||||||
|
return rgb
|
||||||
|
|
||||||
|
arr = np.asarray(rgb).reshape(-1, 3)
|
||||||
|
opaque = np.asarray(mask).reshape(-1) > 0
|
||||||
|
if not opaque.any():
|
||||||
|
return rgb # nada opaco: caso degenerado, deja igual
|
||||||
|
op_pixels = arr[opaque]
|
||||||
|
colors, counts = np.unique(op_pixels, axis=0, return_counts=True)
|
||||||
|
fill = tuple(int(x) for x in colors[counts.argmax()])
|
||||||
|
bg = Image.new("RGB", rgb.size, fill)
|
||||||
|
bg.paste(rgb, (0, 0), mask) # rgb donde mask=255, fill (moda) donde mask=0
|
||||||
|
return bg
|
||||||
|
|
||||||
|
|
||||||
|
def _pixelize_pil(img, downscale, colors, palette_rgb, dither, upscale_back,
|
||||||
|
keep_alpha, alpha_threshold):
|
||||||
|
"""Nucleo puro PIL: imagen -> imagen pixelizada (RGB, o RGBA si keep_alpha).
|
||||||
|
|
||||||
|
Si la imagen de entrada tiene canal alpha y keep_alpha es True, la cuantizacion
|
||||||
|
de color se hace SOLO sobre el RGB (con el fondo transparente relleno con la moda
|
||||||
|
del sujeto para que no entre en la paleta) y el alpha se downscalea nearest por
|
||||||
|
separado y se binariza por `alpha_threshold`, recombinando a RGBA. Asi se
|
||||||
|
preserva la transparencia sin que las zonas transparentes contaminen la paleta.
|
||||||
|
Para imagenes sin alpha (o keep_alpha False) el comportamiento RGB es identico al
|
||||||
|
de antes.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
img: PIL.Image de entrada.
|
img: PIL.Image de entrada.
|
||||||
@@ -74,22 +126,39 @@ def _pixelize_pil(img, downscale, colors, palette_rgb, dither, upscale_back):
|
|||||||
palette_rgb: lista [(r,g,b), ...] o None (cuantizacion automatica).
|
palette_rgb: lista [(r,g,b), ...] o None (cuantizacion automatica).
|
||||||
dither: aplica Floyd-Steinberg al cuantizar si True.
|
dither: aplica Floyd-Steinberg al cuantizar si True.
|
||||||
upscale_back: re-escala nearest al tamano original si True.
|
upscale_back: re-escala nearest al tamano original si True.
|
||||||
|
keep_alpha: si True y la imagen tiene alpha, preserva la transparencia.
|
||||||
|
alpha_threshold: umbral (0..255) para binarizar el alpha (opaco/transparente).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
PIL.Image RGB pixelizada.
|
PIL.Image pixelizada: RGB, o RGBA si se preservo la transparencia.
|
||||||
"""
|
"""
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
img = img.convert("RGB")
|
has_alpha = bool(keep_alpha) and _img_has_alpha(img)
|
||||||
w, h = img.size
|
if has_alpha:
|
||||||
|
rgba = img.convert("RGBA")
|
||||||
|
alpha_full = rgba.getchannel("A")
|
||||||
|
rgb = rgba.convert("RGB")
|
||||||
|
else:
|
||||||
|
rgb = img.convert("RGB")
|
||||||
|
alpha_full = None
|
||||||
|
|
||||||
|
w, h = rgb.size
|
||||||
# 1. downscale nearest -> grid real (colapsa bloques borrosos a 1 pixel).
|
# 1. downscale nearest -> grid real (colapsa bloques borrosos a 1 pixel).
|
||||||
sw, sh = max(1, w // downscale), max(1, h // downscale)
|
sw, sh = max(1, w // downscale), max(1, h // downscale)
|
||||||
small = img.resize((sw, sh), Image.NEAREST)
|
small = rgb.resize((sw, sh), Image.NEAREST)
|
||||||
|
small_alpha = (
|
||||||
|
alpha_full.resize((sw, sh), Image.NEAREST) if alpha_full is not None else None
|
||||||
|
)
|
||||||
|
# 1b. con alpha: el fondo transparente no debe entrar en la paleta.
|
||||||
|
if small_alpha is not None:
|
||||||
|
small = _fill_transparent_with_mode(small, small_alpha, int(alpha_threshold))
|
||||||
|
|
||||||
d = Image.Dither.FLOYDSTEINBERG if dither else Image.Dither.NONE
|
d = Image.Dither.FLOYDSTEINBERG if dither else Image.Dither.NONE
|
||||||
# 2. cuantizar la paleta.
|
# 2. cuantizar la paleta (siempre sobre RGB).
|
||||||
if palette_rgb:
|
if palette_rgb:
|
||||||
pal_img = Image.new("P", (1, 1))
|
pal_img = Image.new("P", (1, 1))
|
||||||
flat = [c for rgb in palette_rgb for c in rgb][:768]
|
flat = [c for rgb_c in palette_rgb for c in rgb_c][:768]
|
||||||
# Rellena las 256 entradas repitiendo el ultimo color real (no ceros): asi
|
# Rellena las 256 entradas repitiendo el ultimo color real (no ceros): asi
|
||||||
# quantize no puede introducir un color extra (negro) por las entradas vacias.
|
# quantize no puede introducir un color extra (negro) por las entradas vacias.
|
||||||
if flat:
|
if flat:
|
||||||
@@ -102,12 +171,42 @@ def _pixelize_pil(img, downscale, colors, palette_rgb, dither, upscale_back):
|
|||||||
n = max(2, min(256, int(colors)))
|
n = max(2, min(256, int(colors)))
|
||||||
small = small.quantize(colors=n, method=Image.Quantize.MEDIANCUT, dither=d)
|
small = small.quantize(colors=n, method=Image.Quantize.MEDIANCUT, dither=d)
|
||||||
out = small.convert("RGB")
|
out = small.convert("RGB")
|
||||||
|
|
||||||
|
# 2b. recombinar el alpha (binarizado) -> RGBA con transparencia dura.
|
||||||
|
if small_alpha is not None:
|
||||||
|
out = out.convert("RGBA")
|
||||||
|
hard_alpha = small_alpha.point(lambda p: 255 if p > int(alpha_threshold) else 0)
|
||||||
|
out.putalpha(hard_alpha)
|
||||||
|
|
||||||
# 3. opcional: re-upscale nearest para preview/entrega (pixeles duros).
|
# 3. opcional: re-upscale nearest para preview/entrega (pixeles duros).
|
||||||
if upscale_back:
|
if upscale_back:
|
||||||
out = out.resize((w, h), Image.NEAREST)
|
out = out.resize((w, h), Image.NEAREST)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _count_colors(result) -> int:
|
||||||
|
"""Numero de colores RGB distintos en el resultado.
|
||||||
|
|
||||||
|
Para salida RGBA cuenta solo los colores de la zona opaca (alpha > 0), que es lo
|
||||||
|
que define el sprite; el transparente no es un "color" del pixel-art. Para RGB
|
||||||
|
cuenta todos los colores. Devuelve -1 si no se pudo contar.
|
||||||
|
"""
|
||||||
|
if result.mode == "RGBA":
|
||||||
|
try:
|
||||||
|
import numpy as np
|
||||||
|
except ImportError:
|
||||||
|
colors_found = result.convert("RGB").getcolors(maxcolors=1 << 20)
|
||||||
|
return len(colors_found) if colors_found is not None else -1
|
||||||
|
arr = np.asarray(result)
|
||||||
|
opaque = arr[..., 3] > 0
|
||||||
|
rgb_op = arr[..., :3][opaque]
|
||||||
|
if rgb_op.size == 0:
|
||||||
|
return 0
|
||||||
|
return int(len(np.unique(rgb_op.reshape(-1, 3), axis=0)))
|
||||||
|
colors_found = result.getcolors(maxcolors=1 << 20)
|
||||||
|
return len(colors_found) if colors_found is not None else -1
|
||||||
|
|
||||||
|
|
||||||
def comfyui_pixelize_image(
|
def comfyui_pixelize_image(
|
||||||
src_path: str,
|
src_path: str,
|
||||||
dst_path: str,
|
dst_path: str,
|
||||||
@@ -117,6 +216,8 @@ def comfyui_pixelize_image(
|
|||||||
palette=None,
|
palette=None,
|
||||||
dither: bool = False,
|
dither: bool = False,
|
||||||
upscale_back: bool = True,
|
upscale_back: bool = True,
|
||||||
|
keep_alpha: bool = True,
|
||||||
|
alpha_threshold: int = 128,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Pixeliza una imagen y la guarda como PNG.
|
"""Pixeliza una imagen y la guarda como PNG.
|
||||||
|
|
||||||
@@ -135,16 +236,28 @@ def comfyui_pixelize_image(
|
|||||||
limpio). keyword-only.
|
limpio). keyword-only.
|
||||||
upscale_back: re-escala nearest al tamano original (preview con pixeles
|
upscale_back: re-escala nearest al tamano original (preview con pixeles
|
||||||
duros). False deja la imagen pequena (sw x sh). keyword-only.
|
duros). False deja la imagen pequena (sw x sh). keyword-only.
|
||||||
|
keep_alpha: si True (default) y la imagen de entrada tiene canal alpha,
|
||||||
|
preserva la transparencia: cuantiza solo el RGB y downscalea/binariza el
|
||||||
|
alpha por separado, devolviendo PNG RGBA. Las zonas transparentes no
|
||||||
|
entran en la paleta de color. Si la imagen no tiene alpha, no tiene
|
||||||
|
efecto (sale RGB igual que antes). keyword-only.
|
||||||
|
alpha_threshold: umbral (0..255) para binarizar el alpha en opaco (255) o
|
||||||
|
transparente (0). Solo aplica cuando se preserva el alpha. keyword-only.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict con:
|
dict con:
|
||||||
- ok (bool): True si se pixelizo y guardo.
|
- ok (bool): True si se pixelizo y guardo.
|
||||||
- out_path (str): ruta del PNG generado.
|
- out_path (str): ruta del PNG generado.
|
||||||
- size (list[int]): [w, h] de la imagen final.
|
- size (list[int]): [w, h] de la imagen final.
|
||||||
- n_colors_final (int): numero de colores distintos en el resultado.
|
- n_colors_final (int): numero de colores RGB distintos en el resultado
|
||||||
|
(en la zona opaca si la salida es RGBA).
|
||||||
|
- has_alpha (bool): True si la salida es RGBA con transparencia preservada.
|
||||||
- error (str): mensaje de error; cadena vacia si todo OK.
|
- error (str): mensaje de error; cadena vacia si todo OK.
|
||||||
"""
|
"""
|
||||||
out = {"ok": False, "out_path": "", "size": [0, 0], "n_colors_final": 0, "error": ""}
|
out = {
|
||||||
|
"ok": False, "out_path": "", "size": [0, 0], "n_colors_final": 0,
|
||||||
|
"has_alpha": False, "error": "",
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@@ -168,7 +281,8 @@ def comfyui_pixelize_image(
|
|||||||
try:
|
try:
|
||||||
with Image.open(src_path) as src:
|
with Image.open(src_path) as src:
|
||||||
result = _pixelize_pil(
|
result = _pixelize_pil(
|
||||||
src, int(downscale), colors, palette_rgb, bool(dither), bool(upscale_back)
|
src, int(downscale), colors, palette_rgb, bool(dither),
|
||||||
|
bool(upscale_back), bool(keep_alpha), int(alpha_threshold),
|
||||||
)
|
)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
out["error"] = f"no se pudo leer/decodificar {src_path!r}: {exc}"
|
out["error"] = f"no se pudo leer/decodificar {src_path!r}: {exc}"
|
||||||
@@ -182,10 +296,10 @@ def comfyui_pixelize_image(
|
|||||||
out["error"] = f"no se pudo escribir {dst_path!r}: {exc}"
|
out["error"] = f"no se pudo escribir {dst_path!r}: {exc}"
|
||||||
return out
|
return out
|
||||||
|
|
||||||
colors_found = result.getcolors(maxcolors=1 << 20)
|
n_final = _count_colors(result)
|
||||||
n_final = len(colors_found) if colors_found is not None else -1
|
|
||||||
out.update(
|
out.update(
|
||||||
ok=True, out_path=dst_path, size=list(result.size), n_colors_final=n_final
|
ok=True, out_path=dst_path, size=list(result.size), n_colors_final=n_final,
|
||||||
|
has_alpha=(result.mode == "RGBA"),
|
||||||
)
|
)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
---
|
||||||
|
name: comfyui_pixelize_sprite_png
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: ml
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def comfyui_pixelize_sprite_png(src_path: str, dst_path: str, *, size: int = 32, colors: int = 16, engine: str = 'pixeloe', palette=None, transparent: bool = True, autocrop: bool = True, crop_pad_ratio: float = 0.02, mode: str = 'contrast', patch_size: int = 16, thickness: int = 2, alpha_threshold: int = 128, comfy_python: str | None = None) -> dict"
|
||||||
|
description: "Pixeliza un PNG existente (un render a alta resolucion, p.ej. 512x768 RGBA con fondo transparente) a un sprite pixel-art REAL de size x size RGBA. Extrae la logica de pixelizado de un PNG existente: la misma secuencia que comfyui_pixelart_real_oneshot aplica internamente (fases 1b/2a/2a-bis/2b), pero desacoplada de la generacion -> sirve para pixelizar cada frame de una animacion, una hoja de sprites o cualquier render existente sin volver a pasar por la difusion. Compone tres funciones del registry: crop_to_content (autocrop al contenido + cuadrar para llenar el frame) -> pixeloe_downscale (downscale contrast-aware que conserva la silueta, engine='pixeloe', con fallback automatico a nearest) -> comfyui_pixelize_image (cuantizacion dura a N colores libres o paleta fija pico-8/nes/game-boy, alpha-aware). PixelOE trabaja en RGB y pierde el alpha, asi que se downscalea el canal alpha aparte (nearest) y se reaplica al grid antes de cuantizar. Impura: lectura/escritura de disco + subprocess del bridge de pixeloe. No-throw: todo error viaja en el campo error del dict. Devuelve {ok, out_path, size, colors_final, has_alpha, engine_used, autocrop_applied, error}."
|
||||||
|
tags: [gamedev-2d, comfyui, pixelart, sprite, ml, downscale, quantize, palette, alpha, transparent, animation]
|
||||||
|
uses_functions: [crop_to_content_py_ml, pixeloe_downscale_py_ml, comfyui_pixelize_image_py_ml]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_py_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: src_path
|
||||||
|
desc: "ruta del PNG de entrada (un render a alta resolucion, p.ej. 512x768 RGBA con fondo transparente). Debe existir."
|
||||||
|
- name: dst_path
|
||||||
|
desc: "ruta del PNG de salida size x size (se crea el directorio si falta)."
|
||||||
|
- name: size
|
||||||
|
desc: "lado del grid final en pixeles (32 iconos/objetos simples, 64 personajes/sprites). Debe ser >= 1. keyword-only."
|
||||||
|
- name: colors
|
||||||
|
desc: "numero de colores de la paleta libre cuando palette es None (cuantizacion MEDIANCUT). keyword-only."
|
||||||
|
- name: engine
|
||||||
|
desc: "'pixeloe' (downscale contrast-aware, para sujetos con silueta: personajes/criaturas/iconos) o 'nearest' (downscale nearest simple, mas barato, para tiles/texturas/fondos sin contorno). Si 'pixeloe' falla o la lib no esta disponible, cae automaticamente a 'nearest' y lo refleja en engine_used. keyword-only."
|
||||||
|
- name: palette
|
||||||
|
desc: "None (paleta libre a 'colors'), nombre builtin ('pico-8','nes','game-boy') o lista de hex. Una paleta fija ignora 'colors'. keyword-only."
|
||||||
|
- name: transparent
|
||||||
|
desc: "si True (default) trata la entrada como RGBA y produce un sprite RGBA con transparencia real (el fondo transparente no entra en la paleta). Para tiles/texturas opacas, False produce salida RGB. keyword-only."
|
||||||
|
- name: autocrop
|
||||||
|
desc: "si True (default) recorta el PNG al bounding box de su contenido y lo cuadra antes del downscale, para que el sujeto llene el frame (evita el sprite diminuto). Usa el alpha si transparent, o el color de fondo si RGB. keyword-only."
|
||||||
|
- name: crop_pad_ratio
|
||||||
|
desc: "margen relativo que deja el autocrop alrededor del sujeto (0.02 = 2% del lado). keyword-only."
|
||||||
|
- name: mode
|
||||||
|
desc: "modo de downscale de PixelOE ('contrast' SOTA, 'k-centroid', 'nearest', 'center', 'bicubic'); solo aplica con engine='pixeloe'. keyword-only."
|
||||||
|
- name: patch_size
|
||||||
|
desc: "tamano de patch de PixelOE (default 16). keyword-only."
|
||||||
|
- name: thickness
|
||||||
|
desc: "grosor del outline expansion de PixelOE (default 2). keyword-only."
|
||||||
|
- name: alpha_threshold
|
||||||
|
desc: "umbral (0..255) para binarizar el alpha en opaco (255) o transparente (0) en la cuantizacion final. Solo aplica si transparent. keyword-only."
|
||||||
|
- name: comfy_python
|
||||||
|
desc: "ruta al interprete de ComfyUI (con la lib pixeloe) para el bridge; None autodetecta COMFY_PYTHON y luego ~/ComfyUI/.venv/bin/python3. keyword-only."
|
||||||
|
output: "dict con ok (bool, True si se produjo el PNG final), out_path (str, ruta del PNG final size x size; vacio si fallo), size (int, lado real del PNG final), colors_final (int, colores distintos en el resultado; en la zona opaca si es RGBA), has_alpha (bool, True si el PNG es RGBA con transparencia), engine_used (str, 'pixeloe' o 'nearest' reflejando el fallback real), autocrop_applied (bool, True si el autocrop recorto/cuadro la imagen), error (str, vacio si todo OK)."
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "python/functions/ml/comfyui_pixelize_sprite_png.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||||
|
from ml.comfyui_pixelize_sprite_png import comfyui_pixelize_sprite_png
|
||||||
|
|
||||||
|
# Un render existente de 512x768 RGBA con fondo transparente -> sprite pixel-art 32x32
|
||||||
|
res = comfyui_pixelize_sprite_png(
|
||||||
|
os.path.expanduser("~/ComfyUI/output/knight_hi_res.png"),
|
||||||
|
"/tmp/knight_32.png",
|
||||||
|
size=32, colors=16, transparent=True,
|
||||||
|
)
|
||||||
|
# {'ok': True, 'out_path': '/tmp/knight_32.png', 'size': 32, 'colors_final': 16,
|
||||||
|
# 'has_alpha': True, 'engine_used': 'pixeloe', 'autocrop_applied': True, 'error': ''}
|
||||||
|
|
||||||
|
# Pixelizar cada frame de una animacion a 48px con paleta fija PICO-8
|
||||||
|
for i, frame in enumerate(["walk_0.png", "walk_1.png", "walk_2.png", "walk_3.png"]):
|
||||||
|
comfyui_pixelize_sprite_png(
|
||||||
|
f"/tmp/anim/{frame}", f"/tmp/anim/px_{i}.png",
|
||||||
|
size=48, palette="pico-8", transparent=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Un tile/textura sin silueta -> downscale nearest barato, sin transparencia
|
||||||
|
comfyui_pixelize_sprite_png(
|
||||||
|
"/tmp/grass_tile.png", "/tmp/grass_16.png",
|
||||||
|
size=16, colors=8, engine="nearest", transparent=False,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando ya tienes un PNG renderizado a alta resolucion y necesitas su version
|
||||||
|
pixel-art REAL (grid duro + paleta limitada) **sin regenerar** con la difusion: cada
|
||||||
|
frame de una animacion, una hoja de sprites entera, un render externo, o el resultado
|
||||||
|
de cualquier otra funcion que produzca PNGs. Es la pieza desacoplada del pixelizado
|
||||||
|
que `comfyui_pixelart_real_oneshot` usa por dentro tras generar — usala directamente
|
||||||
|
cuando la generacion no es parte del trabajo. Usa `engine="pixeloe"` para sujetos con
|
||||||
|
silueta (personajes, criaturas, iconos con contorno) y `engine="nearest"` para
|
||||||
|
tiles/texturas/fondos planos sin contorno (mas barato). Para llevar el resultado a
|
||||||
|
Godot con filtro Nearest, encadena con `comfyui_export_asset_to_godot`.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Necesita la lib `pixeloe`** (en `~/ComfyUI/.venv`) para `engine="pixeloe"`: se
|
||||||
|
invoca via bridge de subprocess (`pixeloe_downscale`). Si la lib no esta o falla,
|
||||||
|
cae automaticamente a `engine="nearest"` y lo refleja en `engine_used` + deja la
|
||||||
|
nota del fallo en `error` (el resultado sigue siendo valido). Pasa `comfy_python`
|
||||||
|
para apuntar a otro interprete con pixeloe.
|
||||||
|
- **Todo error es dict `ok=False`** (no excepcion): `src_path` inexistente, `size < 1`,
|
||||||
|
`engine` distinto de pixeloe/nearest -> `error` lo explica. No crashea ni borra nada.
|
||||||
|
- **`autocrop` es best-effort**: si el recorte falla (PIL/lectura), se sigue con el PNG
|
||||||
|
original sin recortar, `autocrop_applied=False` y la nota va en `error` (no critico).
|
||||||
|
`crop_to_content` cuadra el sujeto para que llene el frame — sin esto un sujeto que
|
||||||
|
ocupa el 25% del lienzo queda diminuto a 32px.
|
||||||
|
- **`transparent` espera entrada RGBA**: con `transparent=True` el alpha se preserva y
|
||||||
|
el fondo transparente NO entra en la paleta. PixelOE trabaja en RGB y perderia el
|
||||||
|
alpha, asi que se downscalea el canal alpha aparte (nearest) y se reaplica al grid
|
||||||
|
antes de cuantizar (fase 2a-bis). Con `transparent=False` la salida es RGB opaca.
|
||||||
|
- **`palette` fija (pico-8/nes/game-boy o lista de hex) ignora `colors`**. `colors_final`
|
||||||
|
cuenta colores RGB distintos REALES de la zona opaca: puede ser **menor** que `colors`
|
||||||
|
o que el tamano de la paleta si el sprite no usa todos (un sprite de un solo color
|
||||||
|
solido devuelve `colors_final=1`, correcto).
|
||||||
|
- **CPU-only en la cuantizacion**; el unico coste GPU/red es nulo (PixelOE es CPU via
|
||||||
|
bridge). Los intermedios (crop, mid) se escriben en un directorio temporal y se
|
||||||
|
limpian siempre, incluso si la cuantizacion falla.
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
"""comfyui_pixelize_sprite_png — pixeliza un PNG existente a un sprite pixel-art REAL.
|
||||||
|
|
||||||
|
Toma un PNG YA renderizado a alta resolucion (p.ej. 512x768 RGBA con fondo
|
||||||
|
transparente) y lo convierte en un sprite pixel-art de verdad de `size` x `size`.
|
||||||
|
Es la pieza reutilizable que extrae la logica de pixelizado de un PNG existente: la
|
||||||
|
MISMA secuencia que `comfyui_pixelart_real_oneshot` aplica internamente en sus fases
|
||||||
|
1b/2a/2a-bis/2b, pero desacoplada de la generacion. Sirve para pixelizar cada frame
|
||||||
|
de una animacion, una hoja de sprites, o cualquier render existente sin volver a
|
||||||
|
pasar por la difusion.
|
||||||
|
|
||||||
|
El metodo (report 0215) tiene tres etapas de post-proceso encadenadas:
|
||||||
|
|
||||||
|
1. crop al contenido (`crop_to_content`): recorta al bounding box del sujeto y lo
|
||||||
|
cuadra para que llene el frame; si el sujeto ocupa el 25% del lienzo, a 32px
|
||||||
|
quedaria diminuto. Best-effort: si falla, se sigue con el PNG original.
|
||||||
|
2. downscale a un grid `size` x `size`:
|
||||||
|
- engine="pixeloe": downscale contrast-aware (`pixeloe_downscale`, no_upscale)
|
||||||
|
que conserva la silueta — para sujetos con contorno (personajes, iconos).
|
||||||
|
Si la lib no esta o falla, cae automaticamente a "nearest".
|
||||||
|
- engine="nearest": downscale nearest simple (PIL) — mas barato, para
|
||||||
|
tiles/texturas sin contorno.
|
||||||
|
PixelOE trabaja en RGB y pierde el alpha, asi que tras el (fase 2a-bis) se
|
||||||
|
downscalea el canal alpha por separado (nearest) y se reaplica al grid.
|
||||||
|
3. cuantizacion dura (`comfyui_pixelize_image`, downscale=1): clava la paleta
|
||||||
|
exacta (N colores libres MEDIANCUT, o paleta fija pico-8 / nes / game-boy) sobre
|
||||||
|
el grid ya hecho -> N colores + 100% grid duro, preservando el alpha.
|
||||||
|
|
||||||
|
Compone funciones del registry, no reescribe su logica:
|
||||||
|
crop_to_content_py_ml (autocrop al contenido + cuadrar; pura)
|
||||||
|
pixeloe_downscale_py_ml (downscale contrast-aware, engine pixeloe)
|
||||||
|
comfyui_pixelize_image_py_ml (cuantizacion dura + alpha-aware)
|
||||||
|
|
||||||
|
Funcion impura: lectura/escritura de disco (+ subprocess del bridge de pixeloe).
|
||||||
|
No-throw: cualquier fallo se captura y viaja en el campo `error` del dict.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
# Importa las funciones del registry (mismo arbol python/functions).
|
||||||
|
_FUNCTIONS_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
if _FUNCTIONS_ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, _FUNCTIONS_ROOT)
|
||||||
|
|
||||||
|
from ml.comfyui_pixelize_image import comfyui_pixelize_image
|
||||||
|
from ml.crop_to_content import crop_to_content
|
||||||
|
from ml.pixeloe_downscale import pixeloe_downscale
|
||||||
|
|
||||||
|
|
||||||
|
def comfyui_pixelize_sprite_png(
|
||||||
|
src_path: str,
|
||||||
|
dst_path: str,
|
||||||
|
*,
|
||||||
|
size: int = 32,
|
||||||
|
colors: int = 16,
|
||||||
|
engine: str = "pixeloe",
|
||||||
|
palette=None,
|
||||||
|
transparent: bool = True,
|
||||||
|
autocrop: bool = True,
|
||||||
|
crop_pad_ratio: float = 0.02,
|
||||||
|
mode: str = "contrast",
|
||||||
|
patch_size: int = 16,
|
||||||
|
thickness: int = 2,
|
||||||
|
alpha_threshold: int = 128,
|
||||||
|
comfy_python: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Pixeliza un PNG existente a un sprite pixel-art REAL de `size` x `size`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
src_path: ruta del PNG de entrada (un render a alta resolucion, p.ej.
|
||||||
|
512x768 RGBA con fondo transparente). Debe existir.
|
||||||
|
dst_path: ruta del PNG de salida size x size (se crea el directorio si falta).
|
||||||
|
size: lado del grid final en pixeles (32 iconos/objetos simples, 64
|
||||||
|
personajes/sprites). Debe ser >= 1. keyword-only.
|
||||||
|
colors: numero de colores de la paleta libre cuando palette es None
|
||||||
|
(cuantizacion MEDIANCUT). keyword-only.
|
||||||
|
engine: "pixeloe" (downscale contrast-aware, para sujetos con silueta:
|
||||||
|
personajes/criaturas/iconos) o "nearest" (downscale nearest simple, mas
|
||||||
|
barato, para tiles/texturas/fondos sin contorno). Si "pixeloe" falla o la
|
||||||
|
lib no esta disponible, cae automaticamente a "nearest" y lo refleja en
|
||||||
|
engine_used. keyword-only.
|
||||||
|
palette: None (paleta libre a `colors`), nombre builtin ("pico-8", "nes",
|
||||||
|
"game-boy") o lista de hex. Una paleta fija ignora `colors`. keyword-only.
|
||||||
|
transparent: si True (default) trata la entrada como RGBA y produce un sprite
|
||||||
|
RGBA con transparencia real (el fondo transparente no entra en la paleta).
|
||||||
|
Para tiles/texturas opacas, False produce salida RGB. keyword-only.
|
||||||
|
autocrop: si True (default) recorta el PNG al bounding box de su contenido y lo
|
||||||
|
cuadra antes del downscale, para que el sujeto llene el frame (evita el
|
||||||
|
sprite diminuto). Usa el alpha si transparent, o el color de fondo si RGB.
|
||||||
|
keyword-only.
|
||||||
|
crop_pad_ratio: margen relativo que deja el autocrop alrededor del sujeto
|
||||||
|
(0.02 = 2% del lado). keyword-only.
|
||||||
|
mode: modo de downscale de PixelOE ("contrast" SOTA, "k-centroid", "nearest",
|
||||||
|
"center", "bicubic"); solo aplica con engine="pixeloe". keyword-only.
|
||||||
|
patch_size: tamano de patch de PixelOE (default 16). keyword-only.
|
||||||
|
thickness: grosor del outline expansion de PixelOE (default 2). keyword-only.
|
||||||
|
alpha_threshold: umbral (0..255) para binarizar el alpha en opaco (255) o
|
||||||
|
transparente (0) en la cuantizacion final. Solo aplica si transparent.
|
||||||
|
keyword-only.
|
||||||
|
comfy_python: ruta al interprete de ComfyUI (con la lib pixeloe) para el
|
||||||
|
bridge; None autodetecta COMFY_PYTHON y luego ~/ComfyUI/.venv/bin/python3.
|
||||||
|
keyword-only.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con:
|
||||||
|
- ok (bool): True si se produjo el PNG final pixelizado.
|
||||||
|
- out_path (str): ruta del PNG final size x size (vacio si fallo).
|
||||||
|
- size (int): lado real del PNG final.
|
||||||
|
- colors_final (int): numero de colores distintos en el resultado (en la zona
|
||||||
|
opaca si es RGBA).
|
||||||
|
- has_alpha (bool): True si el PNG final es RGBA con transparencia.
|
||||||
|
- engine_used (str): "pixeloe" o "nearest" (refleja el fallback real).
|
||||||
|
- autocrop_applied (bool): True si el autocrop recorto/cuadro la imagen.
|
||||||
|
- error (str): mensaje de error; cadena vacia si todo OK.
|
||||||
|
"""
|
||||||
|
out = {
|
||||||
|
"ok": False,
|
||||||
|
"out_path": "",
|
||||||
|
"size": int(size),
|
||||||
|
"colors_final": 0,
|
||||||
|
"has_alpha": False,
|
||||||
|
"engine_used": engine,
|
||||||
|
"autocrop_applied": False,
|
||||||
|
"error": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Validaciones (no-throw). ---
|
||||||
|
if not src_path or not os.path.isfile(src_path):
|
||||||
|
out["error"] = f"src_path no existe: {src_path!r}"
|
||||||
|
return out
|
||||||
|
if int(size) < 1:
|
||||||
|
out["error"] = f"size debe ser >= 1, recibido {size!r}"
|
||||||
|
return out
|
||||||
|
if engine not in ("pixeloe", "nearest"):
|
||||||
|
out["error"] = f"engine invalido: {engine!r} (usa 'pixeloe' o 'nearest')"
|
||||||
|
return out
|
||||||
|
|
||||||
|
# Directorio temporal para los intermedios (crop + mid); se limpia al final.
|
||||||
|
try:
|
||||||
|
tmp_dir = tempfile.mkdtemp(prefix="pixelize_sprite_")
|
||||||
|
except OSError as exc:
|
||||||
|
out["error"] = f"no se pudo crear directorio temporal: {exc}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
crop_path = os.path.join(tmp_dir, "crop.png")
|
||||||
|
mid_path = os.path.join(tmp_dir, "mid.png")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# --- Fase 1b (opcional): autocrop al contenido + cuadrar. ---
|
||||||
|
# La imagen sobre la que se hace el downscale: la recortada si autocrop, o la
|
||||||
|
# original sin tocar.
|
||||||
|
pre_ds_path = src_path
|
||||||
|
if autocrop:
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
with Image.open(src_path) as base_im:
|
||||||
|
src_im = (
|
||||||
|
base_im.convert("RGBA") if transparent else base_im.convert("RGB")
|
||||||
|
)
|
||||||
|
before = src_im.size
|
||||||
|
cropped = crop_to_content(
|
||||||
|
src_im, pad_ratio=float(crop_pad_ratio), square=True,
|
||||||
|
)
|
||||||
|
cropped.save(crop_path)
|
||||||
|
pre_ds_path = crop_path
|
||||||
|
out["autocrop_applied"] = cropped.size != before
|
||||||
|
except (ImportError, OSError, ValueError) as exc:
|
||||||
|
# Autocrop es best-effort: si falla, se sigue con el src sin recortar.
|
||||||
|
pre_ds_path = src_path
|
||||||
|
out["autocrop_applied"] = False
|
||||||
|
if not out["error"]:
|
||||||
|
out["error"] = f"autocrop fallo (no critico): {exc}"
|
||||||
|
|
||||||
|
# --- Fase 2a: downscale a un grid `size` x `size` (mid). ---
|
||||||
|
engine_used = engine
|
||||||
|
if engine == "pixeloe":
|
||||||
|
ds = pixeloe_downscale(
|
||||||
|
pre_ds_path, mid_path, mode=mode, target_size=int(size),
|
||||||
|
patch_size=int(patch_size), thickness=int(thickness),
|
||||||
|
no_upscale=True, comfy_python=comfy_python,
|
||||||
|
)
|
||||||
|
if not ds.get("ok"):
|
||||||
|
# Fallback limpio: PixelOE no disponible / fallo -> nearest.
|
||||||
|
engine_used = "nearest"
|
||||||
|
out["error"] = f"pixeloe fallo ({ds.get('error')}); fallback a nearest"
|
||||||
|
|
||||||
|
if engine_used == "nearest":
|
||||||
|
# Downscale nearest simple a size x size (PIL del venv del registry).
|
||||||
|
# nearest preserva el alpha por canal: si transparent, conserva la silueta.
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
with Image.open(pre_ds_path) as src:
|
||||||
|
target_mode = "RGBA" if transparent else "RGB"
|
||||||
|
small = src.convert(target_mode).resize(
|
||||||
|
(int(size), int(size)), Image.NEAREST
|
||||||
|
)
|
||||||
|
small.save(mid_path)
|
||||||
|
except (ImportError, OSError) as exc:
|
||||||
|
out["error"] = f"downscale nearest fallo: {exc}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
if not os.path.isfile(mid_path):
|
||||||
|
out["error"] = "no se genero la imagen intermedia (mid)"
|
||||||
|
return out
|
||||||
|
|
||||||
|
# --- Fase 2a-bis: recombinar alpha tras pixeloe (PixelOE trabaja en RGB). ---
|
||||||
|
# El nucleo de PixelOE convierte a RGB: el grid `mid` sale sin transparencia.
|
||||||
|
# Se downscalea el alpha de la imagen pre-downscale por separado (nearest al
|
||||||
|
# mismo size) y se reaplica al grid para no perder el recorte ni la
|
||||||
|
# transparencia. (engine="nearest" ya conserva su alpha, no hace falta.)
|
||||||
|
if transparent and engine_used == "pixeloe":
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
with Image.open(pre_ds_path) as src_im:
|
||||||
|
alpha = src_im.convert("RGBA").getchannel("A").resize(
|
||||||
|
(int(size), int(size)), Image.NEAREST
|
||||||
|
)
|
||||||
|
with Image.open(mid_path) as mid_im:
|
||||||
|
mid_rgba = mid_im.convert("RGBA")
|
||||||
|
mid_rgba.putalpha(alpha)
|
||||||
|
mid_rgba.save(mid_path)
|
||||||
|
except (ImportError, OSError) as exc:
|
||||||
|
if not out["error"]:
|
||||||
|
out["error"] = f"recombinacion de alpha fallo (no critico): {exc}"
|
||||||
|
|
||||||
|
# --- Fase 2b: cuantizacion dura (paleta exacta) sobre el grid ya hecho. ---
|
||||||
|
quant = comfyui_pixelize_image(
|
||||||
|
mid_path, dst_path, downscale=1, colors=int(colors), palette=palette,
|
||||||
|
upscale_back=False, keep_alpha=bool(transparent),
|
||||||
|
alpha_threshold=int(alpha_threshold),
|
||||||
|
)
|
||||||
|
if not quant.get("ok"):
|
||||||
|
out["error"] = f"cuantizacion fallo: {quant.get('error')}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
out["out_path"] = dst_path
|
||||||
|
out["size"] = quant["size"][0] if quant.get("size") else int(size)
|
||||||
|
out["colors_final"] = quant.get("n_colors_final", 0)
|
||||||
|
out["has_alpha"] = bool(quant.get("has_alpha", False))
|
||||||
|
out["engine_used"] = engine_used
|
||||||
|
out["ok"] = True
|
||||||
|
return out
|
||||||
|
finally:
|
||||||
|
# Limpieza de intermedios + directorio temporal.
|
||||||
|
for tmp in (crop_path, mid_path):
|
||||||
|
try:
|
||||||
|
os.remove(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
os.rmdir(tmp_dir)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Demo autosuficiente: genera un PNG de prueba (circulo de color sobre fondo
|
||||||
|
# transparente 512x512) y lo pixeliza a 32x32 con 16 colores y transparencia.
|
||||||
|
demo_dir = tempfile.mkdtemp(prefix="pixelize_sprite_demo_")
|
||||||
|
demo_src = os.path.join(demo_dir, "demo_src.png")
|
||||||
|
demo_dst = os.path.join(demo_dir, "demo_sprite.png")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
im = Image.new("RGBA", (512, 512), (0, 0, 0, 0)) # fondo transparente
|
||||||
|
draw = ImageDraw.Draw(im)
|
||||||
|
draw.ellipse((96, 96, 416, 416), fill=(220, 60, 60, 255)) # circulo rojo
|
||||||
|
draw.ellipse((176, 160, 256, 240), fill=(250, 230, 120, 255)) # ojo amarillo
|
||||||
|
draw.ellipse((280, 160, 360, 240), fill=(60, 120, 220, 255)) # ojo azul
|
||||||
|
im.save(demo_src)
|
||||||
|
except ImportError as exc:
|
||||||
|
print(json.dumps({"ok": False, "error": f"PIL no disponible: {exc}"}))
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
res = comfyui_pixelize_sprite_png(
|
||||||
|
demo_src, demo_dst, size=32, colors=16, engine="pixeloe",
|
||||||
|
transparent=True, autocrop=True,
|
||||||
|
)
|
||||||
|
print(json.dumps(res, indent=2))
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
name: crop_to_content
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: ml
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def crop_to_content(img, *, pad_ratio: float = 0.06, square: bool = True, alpha_threshold: int = 10, bg_tolerance: int = 16)"
|
||||||
|
description: "Recorta una imagen PIL al bounding box de su contenido y la cuadra, para que el sujeto llene el frame antes de un downscale a pixel-art. Detecta el contenido por alpha (region con alpha > alpha_threshold) si la imagen es RGBA/LA, o por diferencia contra el color de fondo de las esquinas (con bg_tolerance) si es RGB. Recorta al bbox, anade un margen pad_ratio y, si square, rellena a cuadrado centrando el sujeto sin deformar (fondo transparente si RGBA, color de fondo si RGB). Pura PIL (opera sobre el objeto PIL.Image, no toca disco ni red, no muta la entrada). Si no hay contenido (todo transparente o todo fondo) devuelve una copia intacta — no crashea."
|
||||||
|
tags: [pil, image, crop, bbox, pixelart, gamedev-2d, ml, alpha, sprite]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: img
|
||||||
|
desc: "PIL.Image de entrada (cualquier modo). No se muta. None lanza ValueError."
|
||||||
|
- name: pad_ratio
|
||||||
|
desc: "Margen anadido alrededor del sujeto como fraccion del lado mayor del bbox recortado (0.06 = 6%). 0 = sin margen. keyword-only."
|
||||||
|
- name: square
|
||||||
|
desc: "Si True rellena a un lienzo cuadrado de lado max(w,h)+2*pad con el sujeto centrado (fondo transparente si hay alpha, color de fondo si RGB); si False solo recorta al bbox + margen sin cuadrar. keyword-only."
|
||||||
|
- name: alpha_threshold
|
||||||
|
desc: "Umbral de alpha (0..255) para considerar un pixel 'contenido' cuando la imagen tiene canal alpha. keyword-only."
|
||||||
|
- name: bg_tolerance
|
||||||
|
desc: "Tolerancia (0..255) de diferencia contra el color de fondo de las esquinas para imagenes sin alpha (RGB). keyword-only."
|
||||||
|
output: "PIL.Image nueva recortada (y cuadrada si square) con el sujeto llenando el frame. Si la imagen no tiene contenido detectable, devuelve una copia intacta de la entrada (mismo tamano)."
|
||||||
|
tested: true
|
||||||
|
tests: [test_golden_corner_subject_fills_frame, test_edge_centered_subject_not_overcropped, test_edge_rgb_background_bbox, test_edge_no_square_only_crops, test_error_all_transparent_returns_copy, test_error_none_raises, test_does_not_mutate_input]
|
||||||
|
test_file_path: "python/functions/ml/crop_to_content_test.py"
|
||||||
|
file_path: "python/functions/ml/crop_to_content.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||||
|
from PIL import Image
|
||||||
|
from ml.crop_to_content import crop_to_content
|
||||||
|
|
||||||
|
# Sprite RGBA tras rembg: el sujeto ocupa una esquina -> recortar al bbox y cuadrar.
|
||||||
|
with Image.open("/tmp/knight_rgba.png") as im:
|
||||||
|
out = crop_to_content(im, pad_ratio=0.06, square=True)
|
||||||
|
out.save("/tmp/knight_cropped.png") # RGBA cuadrada, sujeto centrado llenando el frame
|
||||||
|
|
||||||
|
# CLI directo:
|
||||||
|
# ./fn run crop_to_content (corre los tests)
|
||||||
|
# python3 crop_to_content.py /tmp/in.png /tmp/out.png 0.06
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Antes de bajar una imagen a pixel-art (32/64px): si el sujeto ocupa poca area del
|
||||||
|
lienzo, al downscalear queda diminuto y tosco. `crop_to_content` recorta el aire
|
||||||
|
alrededor y cuadra para que el sujeto aproveche todos los pixeles del grid. Es el
|
||||||
|
paso de encuadre del pipeline `comfyui_pixelart_real_oneshot` (autocrop). Funciona
|
||||||
|
con sprites recortados por rembg (detecta por alpha) o con imagenes de fondo plano
|
||||||
|
(detecta por diferencia contra el color de esquina).
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Pura sobre PIL.Image**: recibe y devuelve un objeto `PIL.Image`, NO rutas. El
|
||||||
|
caller hace el `Image.open` / `.save`. No muta la imagen de entrada.
|
||||||
|
- Deteccion del contenido: con **alpha** usa `alpha > alpha_threshold`; sin alpha
|
||||||
|
usa la **moda de las 4 esquinas** como color de fondo y `bg_tolerance` de
|
||||||
|
diferencia. Si el fondo no es uniforme (gradiente) la deteccion RGB puede fallar;
|
||||||
|
para esos casos pasa la imagen ya recortada por rembg (RGBA).
|
||||||
|
- Si no hay contenido (todo transparente o todo del color de fondo) devuelve una
|
||||||
|
**copia intacta** del original (mismo tamano), nunca lanza por una imagen vacia.
|
||||||
|
Solo lanza `ValueError` si `img` es `None`.
|
||||||
|
- `square=True` (default) cuadra a `max(w,h)+2*pad`: si el sujeto es muy alargado el
|
||||||
|
lienzo crece al lado mayor y el sujeto queda centrado con barras transparentes (o
|
||||||
|
de color de fondo) a los lados — sin deformar.
|
||||||
|
- `pad_ratio` es relativo al lado **mayor del bbox**, no del lienzo original.
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""crop_to_content — recorta una imagen PIL al bounding box de su contenido y la cuadra.
|
||||||
|
|
||||||
|
Quita el aire alrededor del sujeto para que llene el frame antes de un downscale a
|
||||||
|
pixel-art: si el sujeto ocupa el 25% del lienzo, al bajar a 64px queda diminuto y
|
||||||
|
tosco (pocos pixeles para el detalle). Esta funcion calcula el bounding box del
|
||||||
|
contenido, recorta a ese bbox, anade un margen relativo y, opcionalmente, rellena a
|
||||||
|
cuadrado sin deformar para que el sujeto llene el frame.
|
||||||
|
|
||||||
|
Como detecta el contenido:
|
||||||
|
- Si la imagen tiene canal alpha (RGBA / LA / P con transparencia): el bbox es la
|
||||||
|
region con `alpha > alpha_threshold` (lo opaco es el sujeto, lo transparente es
|
||||||
|
fondo). Es el caso tras pasar la imagen por rembg.
|
||||||
|
- Si no tiene alpha (RGB): el bbox es la region que difiere del color de fondo,
|
||||||
|
estimado como la moda de los cuatro pixeles de esquina. Sirve para imagenes con
|
||||||
|
fondo plano sin recortar todavia.
|
||||||
|
|
||||||
|
Relleno a cuadrado (`square=True`): el lado del lienzo final es `max(w, h) + 2*pad`
|
||||||
|
y el sujeto se centra. El fondo del lienzo es transparente si la imagen tiene alpha,
|
||||||
|
o el color de fondo estimado si es RGB. Asi no se deforma el sujeto.
|
||||||
|
|
||||||
|
Funcion pura: opera sobre el objeto PIL.Image y devuelve uno nuevo; no toca disco ni
|
||||||
|
red y no muta la imagen de entrada. Si no encuentra contenido (lienzo vacio o todo
|
||||||
|
transparente), devuelve una copia intacta de la entrada — nunca lanza por una imagen
|
||||||
|
sin sujeto (contrato no-throw salvo `img` None).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
|
||||||
|
def _as_rgb_tuple(c) -> tuple:
|
||||||
|
"""Normaliza un pixel (int de modo L, o tupla RGB/RGBA) a una 3-tupla RGB."""
|
||||||
|
if isinstance(c, (tuple, list)):
|
||||||
|
return tuple(int(x) for x in c[:3])
|
||||||
|
return (int(c), int(c), int(c))
|
||||||
|
|
||||||
|
|
||||||
|
def _corner_bg_color(img) -> tuple:
|
||||||
|
"""Color de fondo estimado: la moda de los cuatro pixeles de esquina (RGB)."""
|
||||||
|
rgb = img.convert("RGB")
|
||||||
|
w, h = rgb.size
|
||||||
|
corners = [
|
||||||
|
rgb.getpixel((0, 0)),
|
||||||
|
rgb.getpixel((w - 1, 0)),
|
||||||
|
rgb.getpixel((0, h - 1)),
|
||||||
|
rgb.getpixel((w - 1, h - 1)),
|
||||||
|
]
|
||||||
|
corners = [_as_rgb_tuple(c) for c in corners]
|
||||||
|
return Counter(corners).most_common(1)[0][0]
|
||||||
|
|
||||||
|
|
||||||
|
def _has_alpha(img) -> bool:
|
||||||
|
"""True si la imagen lleva transparencia (RGBA, LA o P con transparency)."""
|
||||||
|
return img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info)
|
||||||
|
|
||||||
|
|
||||||
|
def _content_bbox(img, alpha_threshold: int, bg_tolerance: int):
|
||||||
|
"""Devuelve (l, t, r, b) del contenido o None si no hay.
|
||||||
|
|
||||||
|
Por alpha si la imagen lo tiene; si no, por diferencia contra el color de fondo
|
||||||
|
de las esquinas con tolerancia `bg_tolerance`.
|
||||||
|
"""
|
||||||
|
from PIL import Image, ImageChops
|
||||||
|
|
||||||
|
if _has_alpha(img):
|
||||||
|
alpha = img.convert("RGBA").getchannel("A")
|
||||||
|
mask = alpha.point(lambda p: 255 if p > alpha_threshold else 0)
|
||||||
|
return mask.getbbox()
|
||||||
|
|
||||||
|
rgb = img.convert("RGB")
|
||||||
|
bg = Image.new("RGB", rgb.size, _corner_bg_color(rgb))
|
||||||
|
diff = ImageChops.difference(rgb, bg).convert("L")
|
||||||
|
mask = diff.point(lambda p: 255 if p > bg_tolerance else 0)
|
||||||
|
return mask.getbbox()
|
||||||
|
|
||||||
|
|
||||||
|
def crop_to_content(
|
||||||
|
img,
|
||||||
|
*,
|
||||||
|
pad_ratio: float = 0.02,
|
||||||
|
square: bool = True,
|
||||||
|
alpha_threshold: int = 10,
|
||||||
|
bg_tolerance: int = 16,
|
||||||
|
):
|
||||||
|
"""Recorta una imagen PIL al bbox de su contenido, con margen y cuadrado opcional.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
img: PIL.Image de entrada (cualquier modo). No se muta.
|
||||||
|
pad_ratio: margen anadido alrededor del sujeto como fraccion del lado mayor
|
||||||
|
del bbox recortado (0.06 = 6%). 0 = sin margen. keyword-only.
|
||||||
|
square: si True rellena a un lienzo cuadrado de lado `max(w,h)+2*pad` con el
|
||||||
|
sujeto centrado (fondo transparente si hay alpha, color de fondo si RGB);
|
||||||
|
si False solo recorta al bbox + margen sin cuadrar. keyword-only.
|
||||||
|
alpha_threshold: umbral de alpha (0..255) para considerar un pixel "contenido"
|
||||||
|
cuando la imagen tiene canal alpha. keyword-only.
|
||||||
|
bg_tolerance: tolerancia (0..255) de diferencia contra el color de fondo de
|
||||||
|
las esquinas para imagenes sin alpha (RGB). keyword-only.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PIL.Image nueva recortada (y cuadrada si square). Si la imagen no tiene
|
||||||
|
contenido detectable (todo transparente o todo del color de fondo), devuelve
|
||||||
|
una copia intacta de la entrada.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: si img es None.
|
||||||
|
"""
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
if img is None:
|
||||||
|
raise ValueError("crop_to_content: img es None")
|
||||||
|
|
||||||
|
bbox = _content_bbox(img, int(alpha_threshold), int(bg_tolerance))
|
||||||
|
if bbox is None:
|
||||||
|
return img.copy()
|
||||||
|
|
||||||
|
left, top, right, bottom = bbox
|
||||||
|
cropped = img.crop((left, top, right, bottom))
|
||||||
|
cw, ch = cropped.size
|
||||||
|
pad = int(round(max(cw, ch) * float(pad_ratio)))
|
||||||
|
has_alpha = _has_alpha(img)
|
||||||
|
|
||||||
|
if has_alpha:
|
||||||
|
base = cropped.convert("RGBA")
|
||||||
|
bg_fill = (0, 0, 0, 0)
|
||||||
|
mode = "RGBA"
|
||||||
|
else:
|
||||||
|
base = cropped.convert("RGB")
|
||||||
|
bg_fill = _corner_bg_color(img)
|
||||||
|
mode = "RGB"
|
||||||
|
|
||||||
|
if square:
|
||||||
|
side = max(cw, ch) + 2 * pad
|
||||||
|
canvas = Image.new(mode, (side, side), bg_fill)
|
||||||
|
ox = (side - cw) // 2
|
||||||
|
oy = (side - ch) // 2
|
||||||
|
else:
|
||||||
|
if pad <= 0:
|
||||||
|
return base
|
||||||
|
canvas = Image.new(mode, (cw + 2 * pad, ch + 2 * pad), bg_fill)
|
||||||
|
ox = oy = pad
|
||||||
|
|
||||||
|
if has_alpha:
|
||||||
|
canvas.paste(base, (ox, oy), base) # usa el alpha del sujeto como mascara
|
||||||
|
else:
|
||||||
|
canvas.paste(base, (ox, oy))
|
||||||
|
return canvas
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print("uso: crop_to_content.py <src> <dst> [pad_ratio]", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
src, dst = sys.argv[1], sys.argv[2]
|
||||||
|
pr = float(sys.argv[3]) if len(sys.argv) > 3 else 0.06
|
||||||
|
with Image.open(src) as im:
|
||||||
|
out = crop_to_content(im, pad_ratio=pr)
|
||||||
|
out.save(dst)
|
||||||
|
print(f"ok: {src} -> {dst} {out.size} {out.mode}")
|
||||||
@@ -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))
|
||||||
+30
@@ -67,3 +67,33 @@ def test_determinism():
|
|||||||
a = comfyui_build_pixelart_workflow("pixel cat", seed=3)
|
a = comfyui_build_pixelart_workflow("pixel cat", seed=3)
|
||||||
b = comfyui_build_pixelart_workflow("pixel cat", seed=3)
|
b = comfyui_build_pixelart_workflow("pixel cat", seed=3)
|
||||||
assert a == b
|
assert a == b
|
||||||
|
|
||||||
|
|
||||||
|
def test_transparent_default_injects_rembg():
|
||||||
|
"""transparent default True -> nodo Image Rembg y SaveImage repuntado a el."""
|
||||||
|
wf = comfyui_build_pixelart_workflow("pixel knight, full body")
|
||||||
|
rembg = [n for n in wf.values() if n["class_type"] == "Image Rembg (Remove Background)"]
|
||||||
|
assert len(rembg) == 1
|
||||||
|
assert rembg[0]["inputs"]["transparency"] is True
|
||||||
|
assert rembg[0]["inputs"]["model"] == "u2net"
|
||||||
|
# SaveImage debe leer de la salida del Rembg, no del VAEDecode.
|
||||||
|
rembg_id = next(k for k, n in wf.items() if n["class_type"] == "Image Rembg (Remove Background)")
|
||||||
|
save = next(n for n in wf.values() if n["class_type"] == "SaveImage")
|
||||||
|
assert save["inputs"]["images"][0] == rembg_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_transparent_false_no_rembg():
|
||||||
|
"""transparent=False -> sin nodo Rembg (tiles/fondos opacos)."""
|
||||||
|
wf = comfyui_build_pixelart_workflow("seamless grass tile", transparent=False)
|
||||||
|
rembg = [n for n in wf.values() if n["class_type"] == "Image Rembg (Remove Background)"]
|
||||||
|
assert len(rembg) == 0
|
||||||
|
# SaveImage lee directo del VAEDecode.
|
||||||
|
vae_id = next(k for k, n in wf.items() if n["class_type"] == "VAEDecode")
|
||||||
|
save = next(n for n in wf.values() if n["class_type"] == "SaveImage")
|
||||||
|
assert save["inputs"]["images"][0] == vae_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_rembg_model_override():
|
||||||
|
wf = comfyui_build_pixelart_workflow("anime hero", rembg_model="isnet-anime")
|
||||||
|
rembg = next(n for n in wf.values() if n["class_type"] == "Image Rembg (Remove Background)")
|
||||||
|
assert rembg["inputs"]["model"] == "isnet-anime"
|
||||||
+66
@@ -79,3 +79,69 @@ def test_error_bad_palette(tmp_path):
|
|||||||
res = comfyui_pixelize_image(src, str(tmp_path / "o.png"), palette="not-a-palette")
|
res = comfyui_pixelize_image(src, str(tmp_path / "o.png"), palette="not-a-palette")
|
||||||
assert res["ok"] is False
|
assert res["ok"] is False
|
||||||
assert "paleta" in res["error"].lower()
|
assert "paleta" in res["error"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
# --- alpha-aware (sprites con fondo transparente) ---
|
||||||
|
|
||||||
|
def _rgba_subject_png(path, canvas=256, box=120):
|
||||||
|
"""RGBA: sujeto opaco de colores variados centrado, fondo transparente."""
|
||||||
|
rng = np.random.default_rng(3)
|
||||||
|
arr = np.zeros((canvas, canvas, 4), dtype=np.uint8)
|
||||||
|
o = (canvas - box) // 2
|
||||||
|
arr[o:o + box, o:o + box, :3] = rng.integers(0, 256, size=(box, box, 3), dtype=np.uint8)
|
||||||
|
arr[o:o + box, o:o + box, 3] = 255 # sujeto opaco
|
||||||
|
Image.fromarray(arr, "RGBA").save(path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def test_alpha_preserved_transparent_corners(tmp_path):
|
||||||
|
"""RGBA in -> RGBA out con esquinas transparentes y paleta limitada en lo opaco."""
|
||||||
|
src = _rgba_subject_png(str(tmp_path / "sprite.png"))
|
||||||
|
dst = str(tmp_path / "px.png")
|
||||||
|
res = comfyui_pixelize_image(src, dst, downscale=4, colors=16, upscale_back=False)
|
||||||
|
assert res["ok"] is True, res["error"]
|
||||||
|
assert res["has_alpha"] is True
|
||||||
|
out = Image.open(dst).convert("RGBA")
|
||||||
|
a = np.asarray(out)[..., 3]
|
||||||
|
w, h = out.size
|
||||||
|
# Las 4 esquinas deben ser transparentes (alpha == 0).
|
||||||
|
assert a[0, 0] == 0 and a[0, w - 1] == 0
|
||||||
|
assert a[h - 1, 0] == 0 and a[h - 1, w - 1] == 0
|
||||||
|
# Centro opaco.
|
||||||
|
assert a[h // 2, w // 2] == 255
|
||||||
|
# Colores limitados en la zona opaca.
|
||||||
|
assert res["n_colors_final"] <= 16
|
||||||
|
|
||||||
|
|
||||||
|
def test_alpha_off_flattens_to_rgb(tmp_path):
|
||||||
|
"""keep_alpha=False sobre RGBA -> sale RGB (sin canal alpha)."""
|
||||||
|
src = _rgba_subject_png(str(tmp_path / "sprite.png"))
|
||||||
|
dst = str(tmp_path / "flat.png")
|
||||||
|
res = comfyui_pixelize_image(src, dst, downscale=4, colors=16, keep_alpha=False)
|
||||||
|
assert res["ok"] is True
|
||||||
|
assert res["has_alpha"] is False
|
||||||
|
assert Image.open(dst).mode != "RGBA"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rgb_input_unaffected_by_keep_alpha(tmp_path):
|
||||||
|
"""Imagen RGB (sin alpha) con keep_alpha=True sigue saliendo RGB, sin romper."""
|
||||||
|
src = _noisy_png(str(tmp_path / "raw.png"))
|
||||||
|
dst = str(tmp_path / "rgb.png")
|
||||||
|
res = comfyui_pixelize_image(src, dst, downscale=8, colors=16) # keep_alpha default True
|
||||||
|
assert res["ok"] is True
|
||||||
|
assert res["has_alpha"] is False
|
||||||
|
assert res["n_colors_final"] <= 16
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_all_transparent_no_crash(tmp_path):
|
||||||
|
"""RGBA toda transparente (rembg sin sujeto): no crashea, 0 colores opacos."""
|
||||||
|
arr = np.zeros((64, 64, 4), dtype=np.uint8) # alpha 0 en todo
|
||||||
|
src = str(tmp_path / "empty.png")
|
||||||
|
Image.fromarray(arr, "RGBA").save(src)
|
||||||
|
dst = str(tmp_path / "out.png")
|
||||||
|
res = comfyui_pixelize_image(src, dst, downscale=1, colors=16)
|
||||||
|
assert res["ok"] is True, res["error"]
|
||||||
|
assert res["has_alpha"] is True
|
||||||
|
assert res["n_colors_final"] == 0
|
||||||
|
out = np.asarray(Image.open(dst).convert("RGBA"))
|
||||||
|
assert out[..., 3].max() == 0 # sigue toda transparente
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Tests de crop_to_content (offline, sin red ni GPU; PIL/numpy)."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
from ml.crop_to_content import crop_to_content # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _rgba_subject_in_corner(canvas=256, box=40, ox=8, oy=8):
|
||||||
|
"""RGBA con un rectangulo opaco rojo en una esquina, resto transparente."""
|
||||||
|
arr = np.zeros((canvas, canvas, 4), dtype=np.uint8)
|
||||||
|
arr[oy:oy + box, ox:ox + box, 0] = 220 # R
|
||||||
|
arr[oy:oy + box, ox:ox + box, 3] = 255 # alpha opaco
|
||||||
|
return Image.fromarray(arr, "RGBA")
|
||||||
|
|
||||||
|
|
||||||
|
def _rgba_subject_centered(canvas=256, fill_ratio=0.9):
|
||||||
|
"""RGBA con un rectangulo opaco que llena ~fill_ratio del lienzo, centrado."""
|
||||||
|
arr = np.zeros((canvas, canvas, 4), dtype=np.uint8)
|
||||||
|
side = int(canvas * fill_ratio)
|
||||||
|
o = (canvas - side) // 2
|
||||||
|
arr[o:o + side, o:o + side, 1] = 200 # G
|
||||||
|
arr[o:o + side, o:o + side, 3] = 255
|
||||||
|
return Image.fromarray(arr, "RGBA")
|
||||||
|
|
||||||
|
|
||||||
|
def _rgb_subject_on_bg(canvas=200, box=50, ox=10, oy=10, bg=(255, 255, 255)):
|
||||||
|
"""RGB con un cuadrado de color sobre fondo plano (sin alpha)."""
|
||||||
|
arr = np.zeros((canvas, canvas, 3), dtype=np.uint8)
|
||||||
|
arr[:, :] = bg
|
||||||
|
arr[oy:oy + box, ox:ox + box] = (0, 0, 200) # sujeto azul
|
||||||
|
return Image.fromarray(arr, "RGB")
|
||||||
|
|
||||||
|
|
||||||
|
def _alpha_bbox_coverage(img, threshold=10):
|
||||||
|
"""Fraccion del lado que ocupa el bbox del contenido (alpha>threshold)."""
|
||||||
|
a = np.asarray(img.convert("RGBA"))[..., 3]
|
||||||
|
ys, xs = np.where(a > threshold)
|
||||||
|
if xs.size == 0:
|
||||||
|
return 0.0
|
||||||
|
bw = xs.max() - xs.min() + 1
|
||||||
|
bh = ys.max() - ys.min() + 1
|
||||||
|
return max(bw, bh) / max(img.size)
|
||||||
|
|
||||||
|
|
||||||
|
def test_golden_corner_subject_fills_frame():
|
||||||
|
"""Sujeto en la esquina -> tras crop ocupa casi todo el frame (square)."""
|
||||||
|
img = _rgba_subject_in_corner()
|
||||||
|
before = _alpha_bbox_coverage(img)
|
||||||
|
out = crop_to_content(img, pad_ratio=0.06, square=True)
|
||||||
|
after = _alpha_bbox_coverage(out)
|
||||||
|
assert out.mode == "RGBA"
|
||||||
|
assert out.size[0] == out.size[1] # cuadrado
|
||||||
|
assert before < 0.25 # antes diminuto
|
||||||
|
assert after >= 0.80 # despues llena el frame
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_centered_subject_not_overcropped():
|
||||||
|
"""Sujeto ya centrado que llena ~90%: la cobertura se mantiene alta, no se rompe."""
|
||||||
|
img = _rgba_subject_centered(fill_ratio=0.9)
|
||||||
|
out = crop_to_content(img, pad_ratio=0.06, square=True)
|
||||||
|
assert out.size[0] == out.size[1]
|
||||||
|
assert _alpha_bbox_coverage(out) >= 0.80
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_rgb_background_bbox():
|
||||||
|
"""RGB con fondo plano: detecta el sujeto por diff-fondo y lo cuadra."""
|
||||||
|
img = _rgb_subject_on_bg()
|
||||||
|
out = crop_to_content(img, pad_ratio=0.05, square=True)
|
||||||
|
assert out.mode == "RGB"
|
||||||
|
assert out.size[0] == out.size[1]
|
||||||
|
# El sujeto azul debe ocupar buena parte del lienzo recortado.
|
||||||
|
arr = np.asarray(out)
|
||||||
|
is_subject = (arr[..., 2] > 120) & (arr[..., 0] < 80)
|
||||||
|
cov = is_subject.sum() / (out.size[0] * out.size[1])
|
||||||
|
assert cov >= 0.4
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_no_square_only_crops():
|
||||||
|
"""square=False: recorta al bbox + margen, sin forzar cuadrado."""
|
||||||
|
img = _rgba_subject_in_corner(box=40)
|
||||||
|
out = crop_to_content(img, pad_ratio=0.0, square=False)
|
||||||
|
# bbox del sujeto es 40x40 -> sin pad ni cuadrar, sale 40x40.
|
||||||
|
assert out.size == (40, 40)
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_all_transparent_returns_copy():
|
||||||
|
"""Imagen toda transparente: no crashea, devuelve copia intacta (mismo tamano)."""
|
||||||
|
arr = np.zeros((128, 128, 4), dtype=np.uint8) # alpha 0 en todo
|
||||||
|
img = Image.fromarray(arr, "RGBA")
|
||||||
|
out = crop_to_content(img)
|
||||||
|
assert out.size == (128, 128)
|
||||||
|
assert np.asarray(out)[..., 3].max() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_none_raises():
|
||||||
|
try:
|
||||||
|
crop_to_content(None)
|
||||||
|
assert False, "deberia lanzar ValueError"
|
||||||
|
except ValueError as e:
|
||||||
|
assert "None" in str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def test_does_not_mutate_input():
|
||||||
|
img = _rgba_subject_in_corner()
|
||||||
|
snapshot = np.asarray(img).copy()
|
||||||
|
crop_to_content(img)
|
||||||
|
assert np.array_equal(np.asarray(img), snapshot)
|
||||||
@@ -3,17 +3,17 @@ name: comfyui_pixelart_real_oneshot
|
|||||||
kind: pipeline
|
kind: pipeline
|
||||||
lang: py
|
lang: py
|
||||||
domain: pipelines
|
domain: pipelines
|
||||||
version: "1.0.0"
|
version: "1.1.0"
|
||||||
purity: impure
|
purity: impure
|
||||||
signature: "def comfyui_pixelart_real_oneshot(subject: str, *, size: int = 64, colors: int = 16, engine: str = \"pixeloe\", palette=None, server: str = \"127.0.0.1:8188\", dest_dir: str = \"~/ComfyUI/output\", seed: int = 0, negative: str | None = None, mode: str = \"contrast\", patch_size: int = 16, thickness: int = 2, fill_frame: bool = True, upscale_preview: int = 512, keep_base: bool = True, comfy_python: str | None = None, wait_timeout: float = 300.0, filename_prefix: str = \"pixelart_real\", **gen_kwargs) -> dict"
|
signature: "def comfyui_pixelart_real_oneshot(subject: str, *, size: int = 64, colors: int = 16, engine: str = \"pixeloe\", palette=None, server: str = \"127.0.0.1:8188\", dest_dir: str = \"~/ComfyUI/output\", seed: int = 0, negative: str | None = None, mode: str = \"contrast\", patch_size: int = 16, thickness: int = 2, fill_frame: bool = True, transparent: bool = True, autocrop: bool = True, crop_pad_ratio: float = 0.06, rembg_model: str = \"u2net\", upscale_preview: int = 512, keep_base: bool = True, comfy_python: str | None = None, wait_timeout: float = 300.0, filename_prefix: str = \"pixelart_real\", **gen_kwargs) -> dict"
|
||||||
description: "Pipeline one-shot prompt de texto -> sprite pixel-art REAL (grid duro + paleta limitada) en disco. Materializa el metodo ganador del report 0215: generar a alta-res con SDXL + LoRA SDXL_pixel-art, downscale contrast-aware con PixelOE (engine=pixeloe, sprites) o nearest (tiles), y cuantizacion dura con comfyui_pixelize_image (16 colores libres o paleta fija pico-8/nes/game-boy). Sweet-spot 64px personajes, 32px iconos. Fallback automatico pixeloe->nearest. Compone build_pixelart + submit + wait + fetch + pixeloe_downscale + pixelize_image. Impuro: HTTP + disco."
|
description: "Pipeline one-shot prompt de texto -> sprite pixel-art REAL (grid duro + paleta limitada) en disco, con fondo transparente y sujeto que llena el frame. Materializa el metodo ganador del report 0215, ahora alpha-aware: generar a alta-res con SDXL + LoRA SDXL_pixel-art (rembg recorta el fondo si transparent), AUTOCROP al bbox del contenido + cuadrado (el sujeto llena el frame, no diminuto), downscale contrast-aware con PixelOE (engine=pixeloe, sprites; alpha recombinado aparte porque PixelOE trabaja en RGB) o nearest (tiles), y cuantizacion dura alpha-aware con comfyui_pixelize_image (16 colores libres o paleta fija pico-8/nes/game-boy). Salida PNG RGBA con transparencia real. Sweet-spot 64px personajes, 32px iconos. Fallback automatico pixeloe->nearest. Compone build_pixelart + submit + wait + fetch + crop_to_content + pixeloe_downscale + pixelize_image. Impuro: HTTP + disco."
|
||||||
tags: [comfyui, gamedev-2d, pixelart, pipelines, sprite, launcher]
|
tags: [comfyui, gamedev-2d, pixelart, pipelines, sprite, launcher, alpha, transparent, autocrop]
|
||||||
uses_functions: [comfyui_build_pixelart_workflow_py_ml, comfyui_submit_workflow_py_ml, comfyui_wait_result_py_ml, comfyui_fetch_output_image_py_ml, pixeloe_downscale_py_ml, comfyui_pixelize_image_py_ml]
|
uses_functions: [comfyui_build_pixelart_workflow_py_ml, comfyui_submit_workflow_py_ml, comfyui_wait_result_py_ml, comfyui_fetch_output_image_py_ml, crop_to_content_py_ml, pixeloe_downscale_py_ml, comfyui_pixelize_image_py_ml]
|
||||||
uses_types: []
|
uses_types: []
|
||||||
returns: []
|
returns: []
|
||||||
returns_optional: false
|
returns_optional: false
|
||||||
error_type: error_py_core
|
error_type: error_py_core
|
||||||
imports: [comfyui_build_pixelart_workflow_py_ml, comfyui_submit_workflow_py_ml, comfyui_wait_result_py_ml, comfyui_fetch_output_image_py_ml, pixeloe_downscale_py_ml, comfyui_pixelize_image_py_ml]
|
imports: [comfyui_build_pixelart_workflow_py_ml, comfyui_submit_workflow_py_ml, comfyui_wait_result_py_ml, comfyui_fetch_output_image_py_ml, crop_to_content_py_ml, pixeloe_downscale_py_ml, comfyui_pixelize_image_py_ml]
|
||||||
params:
|
params:
|
||||||
- name: subject
|
- name: subject
|
||||||
desc: "Prompt positivo (lo que se quiere ver: 'pixel art knight, full body, side view'). No puede estar vacio."
|
desc: "Prompt positivo (lo que se quiere ver: 'pixel art knight, full body, side view'). No puede estar vacio."
|
||||||
@@ -41,6 +41,14 @@ params:
|
|||||||
desc: "Grosor del outline expansion de PixelOE (default 2). keyword-only."
|
desc: "Grosor del outline expansion de PixelOE (default 2). keyword-only."
|
||||||
- name: fill_frame
|
- name: fill_frame
|
||||||
desc: "Si True anade un hint de encuadre al subject para que el sujeto llene el frame (mejor detalle por pixel tras el downscale). keyword-only."
|
desc: "Si True anade un hint de encuadre al subject para que el sujeto llene el frame (mejor detalle por pixel tras el downscale). keyword-only."
|
||||||
|
- name: transparent
|
||||||
|
desc: "Si True (default) genera con fondo recortado (rembg en el workflow) y produce sprite RGBA con transparencia real. False para tiles/texturas sin alpha (PNG opaco). keyword-only."
|
||||||
|
- name: autocrop
|
||||||
|
desc: "Si True (default) recorta la imagen base al bbox del contenido + cuadrado 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 no. keyword-only."
|
||||||
|
- name: crop_pad_ratio
|
||||||
|
desc: "Margen relativo que deja el autocrop alrededor del sujeto (0.06 = 6% del lado). keyword-only."
|
||||||
|
- name: rembg_model
|
||||||
|
desc: "Modelo Rembg para recortar el fondo ('u2net' general, 'isnet-anime' anime). Solo aplica si transparent. keyword-only."
|
||||||
- name: upscale_preview
|
- name: upscale_preview
|
||||||
desc: "Si > 0 escribe ademas un PNG re-escalado nearest a ese lado (preview con pixeles duros, p.ej. 512). 0 lo desactiva. keyword-only."
|
desc: "Si > 0 escribe ademas un PNG re-escalado nearest a ese lado (preview con pixeles duros, p.ej. 512). 0 lo desactiva. keyword-only."
|
||||||
- name: keep_base
|
- name: keep_base
|
||||||
@@ -53,7 +61,7 @@ params:
|
|||||||
desc: "Prefijo de los archivos de salida. keyword-only."
|
desc: "Prefijo de los archivos de salida. keyword-only."
|
||||||
- name: gen_kwargs
|
- name: gen_kwargs
|
||||||
desc: "Params extra para comfyui_build_pixelart_workflow (width, height, ckpt_name, lora_strength, use_lcm, steps, cfg, ...). keyword-only (**gen_kwargs)."
|
desc: "Params extra para comfyui_build_pixelart_workflow (width, height, ckpt_name, lora_strength, use_lcm, steps, cfg, ...). keyword-only (**gen_kwargs)."
|
||||||
output: "dict {ok, out_path, out_path_upscaled, base_path, size, colors_final, engine_used, prompt_id, error}. out_path = PNG final size x size; out_path_upscaled = preview re-escalado; engine_used refleja el fallback (pixeloe->nearest). Si falla, ok=False y error explica en que paso. No-throw."
|
output: "dict {ok, out_path, out_path_upscaled, base_path, size, colors_final, engine_used, has_alpha, autocrop_applied, prompt_id, error}. out_path = PNG final size x size (RGBA si transparent); out_path_upscaled = preview re-escalado; has_alpha = True si lleva transparencia; autocrop_applied = True si el autocrop recorto la base; engine_used refleja el fallback (pixeloe->nearest). Si falla, ok=False y error explica en que paso. No-throw."
|
||||||
tested: false
|
tested: false
|
||||||
tests: []
|
tests: []
|
||||||
test_file_path: ""
|
test_file_path: ""
|
||||||
@@ -63,8 +71,8 @@ file_path: "python/functions/pipelines/comfyui_pixelart_real_oneshot.py"
|
|||||||
## Ejemplo
|
## Ejemplo
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Personaje 64px, 16 colores, motor pixeloe (sprites con silueta).
|
# Sprite de personaje 64px: RGBA transparente + autocrop (sujeto llena el frame).
|
||||||
./fn run comfyui_pixelart_real_oneshot "pixel art knight, full body, side view, game sprite"
|
./fn run comfyui_pixelart_real_oneshot "pixel art knight, full body, centered"
|
||||||
```
|
```
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@@ -72,24 +80,26 @@ import sys, os
|
|||||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||||
from pipelines.comfyui_pixelart_real_oneshot import comfyui_pixelart_real_oneshot
|
from pipelines.comfyui_pixelart_real_oneshot import comfyui_pixelart_real_oneshot
|
||||||
|
|
||||||
# (a) Personaje 64px, paleta libre 16 colores, PixelOE contrast.
|
# (a) Sprite personaje 64px: fondo transparente + autocrop (defaults).
|
||||||
res = comfyui_pixelart_real_oneshot(
|
res = comfyui_pixelart_real_oneshot(
|
||||||
"pixel art knight, full body, side view, game sprite",
|
"pixel art knight, full body, centered",
|
||||||
size=64, colors=16, engine="pixeloe", seed=42,
|
size=64, colors=16, engine="pixeloe", seed=42,
|
||||||
dest_dir="~/ComfyUI/output",
|
transparent=True, autocrop=True, dest_dir="~/ComfyUI/output",
|
||||||
)
|
)
|
||||||
print(res["out_path"], res["colors_final"], res["engine_used"]) # ~16 colores, pixeloe
|
print(res["out_path"], res["colors_final"], res["has_alpha"], res["engine_used"])
|
||||||
|
# -> 64px RGBA, ~16 colores, has_alpha=True, esquinas transparentes, sujeto ~88% del frame
|
||||||
|
|
||||||
# (b) Icono 32px de un item.
|
# (b) Icono 32px de un item (sprite con alpha).
|
||||||
res = comfyui_pixelart_real_oneshot(
|
res = comfyui_pixelart_real_oneshot(
|
||||||
"pixel art sword icon, single object",
|
"pixel art sword icon, single object",
|
||||||
size=32, colors=16, engine="pixeloe", seed=7,
|
size=32, colors=16, engine="pixeloe", seed=7,
|
||||||
)
|
)
|
||||||
|
|
||||||
# (c) Tile sin silueta -> nearest (mas barato) + paleta fija PICO-8.
|
# (c) Tile sin silueta -> nearest + paleta fija PICO-8, SIN transparencia.
|
||||||
res = comfyui_pixelart_real_oneshot(
|
res = comfyui_pixelart_real_oneshot(
|
||||||
"pixel art grass texture tile, top down, seamless",
|
"pixel art grass texture tile, top down, seamless",
|
||||||
size=64, engine="nearest", palette="pico-8", fill_frame=False,
|
size=64, engine="nearest", palette="pico-8",
|
||||||
|
transparent=False, autocrop=False, fill_frame=False,
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -98,10 +108,13 @@ res = comfyui_pixelart_real_oneshot(
|
|||||||
Cuando quieres pixel-art **de verdad** (grid duro + paleta limitada, verificable
|
Cuando quieres pixel-art **de verdad** (grid duro + paleta limitada, verificable
|
||||||
por conteo de colores), no la salida cruda de la difusion (que parece pixelada
|
por conteo de colores), no la salida cruda de la difusion (que parece pixelada
|
||||||
pero tiene decenas de miles de colores y bordes con anti-aliasing). Una sola
|
pero tiene decenas de miles de colores y bordes con anti-aliasing). Una sola
|
||||||
llamada hace generar -> downscale -> cuantizar. Usa `engine="pixeloe"` para
|
llamada hace generar -> recortar -> downscale -> cuantizar. Para **sprites de
|
||||||
personajes/criaturas/iconos con silueta (conserva el contorno) y
|
sujeto** (personajes, criaturas, objetos) deja los defaults `transparent=True` +
|
||||||
`engine="nearest"` para tiles/texturas/fondos sin contorno (mas barato, CPU puro).
|
`autocrop=True`: salen RGBA con fondo transparente y el sujeto llena el frame. Usa
|
||||||
64px es el sweet-spot de personajes; 32px solo para iconos/objetos simples.
|
`engine="pixeloe"` para conservar la silueta. Para **tiles/texturas/fondos** sin
|
||||||
|
contorno usa `engine="nearest"`, `transparent=False`, `autocrop=False` (mas barato,
|
||||||
|
CPU puro, sin alpha). 64px es el sweet-spot de personajes; 32px solo para
|
||||||
|
iconos/objetos simples.
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
@@ -120,13 +133,31 @@ personajes/criaturas/iconos con silueta (conserva el contorno) y
|
|||||||
son `<prefix>_<size>px_<engine>_<paleta|qN>.png` y `..._up.png` (preview).
|
son `<prefix>_<size>px_<engine>_<paleta|qN>.png` y `..._up.png` (preview).
|
||||||
- Una **paleta fija** (`pico-8`/`nes`/`game-boy`/lista hex) ignora `colors` y
|
- Una **paleta fija** (`pico-8`/`nes`/`game-boy`/lista hex) ignora `colors` y
|
||||||
puede dar menos colores que `colors` si el sujeto no cubre toda la paleta.
|
puede dar menos colores que `colors` si el sujeto no cubre toda la paleta.
|
||||||
- Encuadre: si el sujeto ocupa poca area del frame, a 64/32px queda diminuto.
|
- Encuadre: si el sujeto ocupa poca area del frame, a 64/32px queda diminuto. Dos
|
||||||
`fill_frame=True` (default) empuja al sujeto a llenar el frame; aun asi, para
|
mecanismos lo evitan: `fill_frame=True` (hint al prompt) y, sobre todo,
|
||||||
sprites conviene un subject que pida "full body, centered".
|
`autocrop=True` (default) que recorta al bbox real del contenido + cuadrado tras
|
||||||
|
generar. Con autocrop el sujeto llena ~85-90% del frame aunque el prompt no lo
|
||||||
|
encuadre perfecto.
|
||||||
|
- **transparencia (v1.1.0)**: `transparent=True` (default) mete el nodo `Image
|
||||||
|
Rembg` en el workflow (requiere ese custom node en el server) y produce PNG
|
||||||
|
**RGBA**. Las 4 esquinas salen `alpha==0`. Para tiles/fondos opacos: `transparent=False`.
|
||||||
|
- **alpha a traves de PixelOE**: PixelOE trabaja en RGB y pierde el alpha; el
|
||||||
|
pipeline downscalea el alpha del recorte por separado (nearest al mismo `size`) y
|
||||||
|
lo recombina sobre el grid antes de cuantizar. Por eso el sprite final conserva la
|
||||||
|
transparencia con `engine="pixeloe"`.
|
||||||
|
- Si la generacion sale **toda transparente** (rembg no detecto sujeto), no crashea:
|
||||||
|
el autocrop deja la imagen sin recortar y el resto del pipeline sigue (sprite
|
||||||
|
vacio, `colors_final` bajo). Revisa el `subject` en ese caso.
|
||||||
- No reintenta el sampler: para mejor toma, varia `seed`.
|
- No reintenta el sampler: para mejor toma, varia `seed`.
|
||||||
|
|
||||||
## Capability growth log
|
## Capability growth log
|
||||||
|
|
||||||
|
- v1.1.0 (2026-06-28) — sprite-fix: `transparent`/`autocrop`/`crop_pad_ratio`/
|
||||||
|
`rembg_model`. Arregla los 2 bugs reportados: (1) sprite diminuto -> autocrop al
|
||||||
|
bbox del contenido + cuadrado antes del downscale (sujeto pasa de ~48% a ~88% del
|
||||||
|
frame); (2) sin transparencia -> rembg en el workflow + cuantizacion alpha-aware +
|
||||||
|
alpha recombinado tras PixelOE -> PNG RGBA con esquinas alpha==0. Anade
|
||||||
|
`crop_to_content` a la composicion. Verificado en GPU (knight 64px).
|
||||||
- v1.0.0 (2026-06-28) — pipeline inicial. Materializa el metodo ganador del
|
- v1.0.0 (2026-06-28) — pipeline inicial. Materializa el metodo ganador del
|
||||||
report 0215 (PixelOE contrast downscale -> cuantizacion dura). Compone
|
report 0215 (PixelOE contrast downscale -> cuantizacion dura). Compone
|
||||||
build_pixelart + submit + wait + fetch + pixeloe_downscale + pixelize_image
|
build_pixelart + submit + wait + fetch + pixeloe_downscale + pixelize_image
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ from ml.comfyui_fetch_output_image import comfyui_fetch_output_image
|
|||||||
from ml.comfyui_pixelize_image import comfyui_pixelize_image
|
from ml.comfyui_pixelize_image import comfyui_pixelize_image
|
||||||
from ml.comfyui_submit_workflow import comfyui_submit_workflow
|
from ml.comfyui_submit_workflow import comfyui_submit_workflow
|
||||||
from ml.comfyui_wait_result import comfyui_wait_result
|
from ml.comfyui_wait_result import comfyui_wait_result
|
||||||
|
from ml.crop_to_content import crop_to_content
|
||||||
from ml.pixeloe_downscale import pixeloe_downscale
|
from ml.pixeloe_downscale import pixeloe_downscale
|
||||||
|
|
||||||
# Sufijo de encuadre: empuja al sujeto a llenar el frame para que tras el
|
# Sufijo de encuadre: empuja al sujeto a llenar el frame para que tras el
|
||||||
@@ -80,6 +81,10 @@ def comfyui_pixelart_real_oneshot(
|
|||||||
patch_size: int = 16,
|
patch_size: int = 16,
|
||||||
thickness: int = 2,
|
thickness: int = 2,
|
||||||
fill_frame: bool = True,
|
fill_frame: bool = True,
|
||||||
|
transparent: bool = True,
|
||||||
|
autocrop: bool = True,
|
||||||
|
crop_pad_ratio: float = 0.06,
|
||||||
|
rembg_model: str = "u2net",
|
||||||
upscale_preview: int = 512,
|
upscale_preview: int = 512,
|
||||||
keep_base: bool = True,
|
keep_base: bool = True,
|
||||||
comfy_python: str | None = None,
|
comfy_python: str | None = None,
|
||||||
@@ -118,6 +123,18 @@ def comfyui_pixelart_real_oneshot(
|
|||||||
fill_frame: si True, anade un hint de encuadre al subject para que el
|
fill_frame: si True, anade un hint de encuadre al subject para que el
|
||||||
sujeto llene el frame (mejor detalle por pixel tras el downscale).
|
sujeto llene el frame (mejor detalle por pixel tras el downscale).
|
||||||
keyword-only.
|
keyword-only.
|
||||||
|
transparent: si True (default) genera con fondo recortado (rembg en el
|
||||||
|
workflow) y produce un sprite RGBA con transparencia real. Para
|
||||||
|
tiles/texturas que NO quieren alpha, pasar transparent=False (el sprite
|
||||||
|
sale RGB sobre fondo opaco). keyword-only.
|
||||||
|
autocrop: si True (default) recorta la imagen base al bounding box de su
|
||||||
|
contenido y la 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 no. keyword-only.
|
||||||
|
crop_pad_ratio: margen relativo que deja el autocrop alrededor del sujeto
|
||||||
|
(0.06 = 6% del lado). keyword-only.
|
||||||
|
rembg_model: modelo Rembg para recortar el fondo ('u2net' general,
|
||||||
|
'isnet-anime' para anime). Solo aplica si transparent. keyword-only.
|
||||||
upscale_preview: si > 0, escribe ademas un PNG re-escalado nearest a
|
upscale_preview: si > 0, escribe ademas un PNG re-escalado nearest a
|
||||||
ese lado (preview con pixeles duros, p.ej. 512). 0 lo desactiva.
|
ese lado (preview con pixeles duros, p.ej. 512). 0 lo desactiva.
|
||||||
keyword-only.
|
keyword-only.
|
||||||
@@ -137,14 +154,18 @@ def comfyui_pixelart_real_oneshot(
|
|||||||
- out_path_upscaled (str): ruta del preview re-escalado, o "" si off.
|
- out_path_upscaled (str): ruta del preview re-escalado, o "" si off.
|
||||||
- base_path (str): ruta del PNG base de alta resolucion (o "" si se borro).
|
- base_path (str): ruta del PNG base de alta resolucion (o "" si se borro).
|
||||||
- size (int): lado real del PNG final.
|
- size (int): lado real del PNG final.
|
||||||
- colors_final (int): numero de colores distintos en el resultado.
|
- colors_final (int): numero de colores distintos en el resultado (en la
|
||||||
|
zona opaca si es RGBA).
|
||||||
- engine_used (str): "pixeloe" o "nearest" (refleja el fallback).
|
- engine_used (str): "pixeloe" o "nearest" (refleja el fallback).
|
||||||
|
- has_alpha (bool): True si el PNG final es RGBA con transparencia.
|
||||||
|
- autocrop_applied (bool): True si el autocrop recorto la imagen base.
|
||||||
- prompt_id (str): id del trabajo en ComfyUI.
|
- prompt_id (str): id del trabajo en ComfyUI.
|
||||||
- error (str): mensaje de error; vacio si OK.
|
- error (str): mensaje de error; vacio si OK.
|
||||||
"""
|
"""
|
||||||
out = {
|
out = {
|
||||||
"ok": False, "out_path": "", "out_path_upscaled": "", "base_path": "",
|
"ok": False, "out_path": "", "out_path_upscaled": "", "base_path": "",
|
||||||
"size": int(size), "colors_final": 0, "engine_used": engine,
|
"size": int(size), "colors_final": 0, "engine_used": engine,
|
||||||
|
"has_alpha": False, "autocrop_applied": False,
|
||||||
"prompt_id": "", "error": "",
|
"prompt_id": "", "error": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,12 +191,14 @@ def comfyui_pixelart_real_oneshot(
|
|||||||
try:
|
try:
|
||||||
if negative is None:
|
if negative is None:
|
||||||
workflow = comfyui_build_pixelart_workflow(
|
workflow = comfyui_build_pixelart_workflow(
|
||||||
positive, seed=seed, filename_prefix=f"{filename_prefix}_base",
|
positive, seed=seed, transparent=bool(transparent),
|
||||||
**gen_kwargs,
|
rembg_model=rembg_model,
|
||||||
|
filename_prefix=f"{filename_prefix}_base", **gen_kwargs,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
workflow = comfyui_build_pixelart_workflow(
|
workflow = comfyui_build_pixelart_workflow(
|
||||||
positive, negative, seed=seed,
|
positive, negative, seed=seed, transparent=bool(transparent),
|
||||||
|
rembg_model=rembg_model,
|
||||||
filename_prefix=f"{filename_prefix}_base", **gen_kwargs,
|
filename_prefix=f"{filename_prefix}_base", **gen_kwargs,
|
||||||
)
|
)
|
||||||
except (ValueError, TypeError) as exc:
|
except (ValueError, TypeError) as exc:
|
||||||
@@ -216,13 +239,37 @@ def comfyui_pixelart_real_oneshot(
|
|||||||
base_path = fetched["path"]
|
base_path = fetched["path"]
|
||||||
out["base_path"] = base_path
|
out["base_path"] = base_path
|
||||||
|
|
||||||
|
# --- Fase 1b (opcional): autocrop al contenido + cuadrar (sujeto llena el frame). ---
|
||||||
|
# La imagen sobre la que se hace el downscale: la recortada si autocrop, o la base.
|
||||||
|
pre_ds_path = base_path
|
||||||
|
crop_path = ""
|
||||||
|
if autocrop:
|
||||||
|
crop_path = os.path.join(dest, f"{filename_prefix}_{size}px_crop.png")
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
with Image.open(base_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 la base sin recortar.
|
||||||
|
crop_path = ""
|
||||||
|
pre_ds_path = base_path
|
||||||
|
if not out["error"]:
|
||||||
|
out["error"] = f"autocrop fallo (no critico): {exc}"
|
||||||
|
|
||||||
# --- Fase 2a: downscale a un grid `size` x `size` (mid). ---
|
# --- Fase 2a: downscale a un grid `size` x `size` (mid). ---
|
||||||
mid_path = os.path.join(dest, f"{filename_prefix}_{size}px_mid.png")
|
mid_path = os.path.join(dest, f"{filename_prefix}_{size}px_mid.png")
|
||||||
engine_used = engine
|
engine_used = engine
|
||||||
|
|
||||||
if engine == "pixeloe":
|
if engine == "pixeloe":
|
||||||
ds = pixeloe_downscale(
|
ds = pixeloe_downscale(
|
||||||
base_path, mid_path, mode=mode, target_size=int(size),
|
pre_ds_path, mid_path, mode=mode, target_size=int(size),
|
||||||
patch_size=patch_size, thickness=thickness, no_upscale=True,
|
patch_size=patch_size, thickness=thickness, no_upscale=True,
|
||||||
comfy_python=comfy_python,
|
comfy_python=comfy_python,
|
||||||
)
|
)
|
||||||
@@ -235,10 +282,14 @@ def comfyui_pixelart_real_oneshot(
|
|||||||
|
|
||||||
if engine_used == "nearest":
|
if engine_used == "nearest":
|
||||||
# Downscale nearest simple a size x size (PIL en el venv del registry).
|
# Downscale nearest simple a size x size (PIL en el venv del registry).
|
||||||
|
# nearest preserva el alpha por canal: si transparent, conserva la silueta.
|
||||||
try:
|
try:
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
with Image.open(base_path) as src:
|
with Image.open(pre_ds_path) as src:
|
||||||
small = src.convert("RGB").resize((int(size), int(size)), Image.NEAREST)
|
target_mode = "RGBA" if transparent else "RGB"
|
||||||
|
small = src.convert(target_mode).resize(
|
||||||
|
(int(size), int(size)), Image.NEAREST
|
||||||
|
)
|
||||||
small.save(mid_path)
|
small.save(mid_path)
|
||||||
except (ImportError, OSError) as exc:
|
except (ImportError, OSError) as exc:
|
||||||
out["error"] = f"downscale nearest fallo: {exc}"
|
out["error"] = f"downscale nearest fallo: {exc}"
|
||||||
@@ -248,6 +299,25 @@ def comfyui_pixelart_real_oneshot(
|
|||||||
out["error"] = "no se genero la imagen intermedia (mid)"
|
out["error"] = "no se genero la imagen intermedia (mid)"
|
||||||
return out
|
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.
|
||||||
|
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. ---
|
# --- Fase 2b: cuantizacion dura (paleta exacta) sobre el grid ya hecho. ---
|
||||||
final_tag = palette if isinstance(palette, str) else f"q{colors}"
|
final_tag = palette if isinstance(palette, str) else f"q{colors}"
|
||||||
final_path = os.path.join(
|
final_path = os.path.join(
|
||||||
@@ -255,7 +325,7 @@ def comfyui_pixelart_real_oneshot(
|
|||||||
)
|
)
|
||||||
quant = comfyui_pixelize_image(
|
quant = comfyui_pixelize_image(
|
||||||
mid_path, final_path, downscale=1, colors=int(colors),
|
mid_path, final_path, downscale=1, colors=int(colors),
|
||||||
palette=palette, upscale_back=False,
|
palette=palette, upscale_back=False, keep_alpha=bool(transparent),
|
||||||
)
|
)
|
||||||
if not quant.get("ok"):
|
if not quant.get("ok"):
|
||||||
out["error"] = f"cuantizacion fallo: {quant.get('error')}"
|
out["error"] = f"cuantizacion fallo: {quant.get('error')}"
|
||||||
@@ -264,6 +334,7 @@ def comfyui_pixelart_real_oneshot(
|
|||||||
out["out_path"] = final_path
|
out["out_path"] = final_path
|
||||||
out["size"] = quant["size"][0] if quant.get("size") else int(size)
|
out["size"] = quant["size"][0] if quant.get("size") else int(size)
|
||||||
out["colors_final"] = quant.get("n_colors_final", 0)
|
out["colors_final"] = quant.get("n_colors_final", 0)
|
||||||
|
out["has_alpha"] = bool(quant.get("has_alpha", False))
|
||||||
out["engine_used"] = engine_used
|
out["engine_used"] = engine_used
|
||||||
|
|
||||||
# --- Fase 3 (opcional): preview re-escalado nearest a pixeles duros. ---
|
# --- Fase 3 (opcional): preview re-escalado nearest a pixeles duros. ---
|
||||||
@@ -274,7 +345,8 @@ def comfyui_pixelart_real_oneshot(
|
|||||||
try:
|
try:
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
with Image.open(final_path) as fin:
|
with Image.open(final_path) as fin:
|
||||||
up = fin.convert("RGB").resize(
|
prev_mode = "RGBA" if transparent else "RGB"
|
||||||
|
up = fin.convert(prev_mode).resize(
|
||||||
(int(upscale_preview), int(upscale_preview)), Image.NEAREST
|
(int(upscale_preview), int(upscale_preview)), Image.NEAREST
|
||||||
)
|
)
|
||||||
up.save(up_path)
|
up.save(up_path)
|
||||||
@@ -285,11 +357,13 @@ def comfyui_pixelart_real_oneshot(
|
|||||||
if not out["error"]:
|
if not out["error"]:
|
||||||
out["error"] = f"preview upscale fallo (no critico): {exc}"
|
out["error"] = f"preview upscale fallo (no critico): {exc}"
|
||||||
|
|
||||||
# Limpieza opcional de la base y del intermedio.
|
# Limpieza de intermedios (mid + crop temporal).
|
||||||
try:
|
for tmp in (mid_path, crop_path):
|
||||||
os.remove(mid_path)
|
if tmp:
|
||||||
except OSError:
|
try:
|
||||||
pass
|
os.remove(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
if not keep_base:
|
if not keep_base:
|
||||||
try:
|
try:
|
||||||
os.remove(base_path)
|
os.remove(base_path)
|
||||||
@@ -305,8 +379,9 @@ if __name__ == "__main__":
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
res = comfyui_pixelart_real_oneshot(
|
res = comfyui_pixelart_real_oneshot(
|
||||||
"pixel art knight, full body, side view, game sprite",
|
"pixel art knight, full body, centered, game sprite",
|
||||||
size=64, colors=16, engine="pixeloe", seed=42,
|
size=64, colors=16, engine="pixeloe", seed=42,
|
||||||
|
transparent=True, autocrop=True,
|
||||||
dest_dir="/tmp/comfy_pixelart_real",
|
dest_dir="/tmp/comfy_pixelart_real",
|
||||||
)
|
)
|
||||||
print(json.dumps(res, indent=2))
|
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))
|
||||||
@@ -19,7 +19,9 @@ dependencies = [
|
|||||||
"google-cloud-storage>=3.10.1",
|
"google-cloud-storage>=3.10.1",
|
||||||
"httpx",
|
"httpx",
|
||||||
"matplotlib>=3.10.9",
|
"matplotlib>=3.10.9",
|
||||||
|
"opencv-contrib-python-headless>=4.13.0.92",
|
||||||
"openpyxl>=3.1.5",
|
"openpyxl>=3.1.5",
|
||||||
|
"pillow>=12.2.0",
|
||||||
"polars>=1.40.1",
|
"polars>=1.40.1",
|
||||||
"pymeshlab>=2025.7.post1",
|
"pymeshlab>=2025.7.post1",
|
||||||
"pymssql>=2.3.13",
|
"pymssql>=2.3.13",
|
||||||
@@ -27,6 +29,7 @@ dependencies = [
|
|||||||
"pyproj>=3.7.2",
|
"pyproj>=3.7.2",
|
||||||
"python-docx>=1.2.0",
|
"python-docx>=1.2.0",
|
||||||
"pyyaml>=6.0.3",
|
"pyyaml>=6.0.3",
|
||||||
|
"qrcode[pil]>=8.2",
|
||||||
"rapidfuzz>=3.14.5",
|
"rapidfuzz>=3.14.5",
|
||||||
"reportlab>=4.5.0",
|
"reportlab>=4.5.0",
|
||||||
"scikit-image>=0.26.0",
|
"scikit-image>=0.26.0",
|
||||||
|
|||||||
Generated
+41
@@ -900,7 +900,9 @@ dependencies = [
|
|||||||
{ name = "google-cloud-storage" },
|
{ name = "google-cloud-storage" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "matplotlib" },
|
{ name = "matplotlib" },
|
||||||
|
{ name = "opencv-contrib-python-headless" },
|
||||||
{ name = "openpyxl" },
|
{ name = "openpyxl" },
|
||||||
|
{ name = "pillow" },
|
||||||
{ name = "polars" },
|
{ name = "polars" },
|
||||||
{ name = "pymeshlab" },
|
{ name = "pymeshlab" },
|
||||||
{ name = "pymssql" },
|
{ name = "pymssql" },
|
||||||
@@ -908,6 +910,7 @@ dependencies = [
|
|||||||
{ name = "pyproj" },
|
{ name = "pyproj" },
|
||||||
{ name = "python-docx" },
|
{ name = "python-docx" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
|
{ name = "qrcode", extra = ["pil"] },
|
||||||
{ name = "rapidfuzz" },
|
{ name = "rapidfuzz" },
|
||||||
{ name = "reportlab" },
|
{ name = "reportlab" },
|
||||||
{ name = "scikit-image" },
|
{ name = "scikit-image" },
|
||||||
@@ -956,7 +959,9 @@ requires-dist = [
|
|||||||
{ name = "jupyter-mcp-server", marker = "extra == 'jupyter'" },
|
{ name = "jupyter-mcp-server", marker = "extra == 'jupyter'" },
|
||||||
{ name = "jupyterlab", marker = "extra == 'jupyter'", specifier = ">=4.0" },
|
{ name = "jupyterlab", marker = "extra == 'jupyter'", specifier = ">=4.0" },
|
||||||
{ name = "matplotlib", specifier = ">=3.10.9" },
|
{ name = "matplotlib", specifier = ">=3.10.9" },
|
||||||
|
{ name = "opencv-contrib-python-headless", specifier = ">=4.13.0.92" },
|
||||||
{ name = "openpyxl", specifier = ">=3.1.5" },
|
{ name = "openpyxl", specifier = ">=3.1.5" },
|
||||||
|
{ name = "pillow", specifier = ">=12.2.0" },
|
||||||
{ name = "polars", specifier = ">=1.40.1" },
|
{ name = "polars", specifier = ">=1.40.1" },
|
||||||
{ name = "pymeshlab", specifier = ">=2025.7.post1" },
|
{ name = "pymeshlab", specifier = ">=2025.7.post1" },
|
||||||
{ name = "pymssql", specifier = ">=2.3.13" },
|
{ name = "pymssql", specifier = ">=2.3.13" },
|
||||||
@@ -964,6 +969,7 @@ requires-dist = [
|
|||||||
{ name = "pyproj", specifier = ">=3.7.2" },
|
{ name = "pyproj", specifier = ">=3.7.2" },
|
||||||
{ name = "python-docx", specifier = ">=1.2.0" },
|
{ name = "python-docx", specifier = ">=1.2.0" },
|
||||||
{ name = "pyyaml", specifier = ">=6.0.3" },
|
{ name = "pyyaml", specifier = ">=6.0.3" },
|
||||||
|
{ name = "qrcode", extras = ["pil"], specifier = ">=8.2" },
|
||||||
{ name = "rapidfuzz", specifier = ">=3.14.5" },
|
{ name = "rapidfuzz", specifier = ">=3.14.5" },
|
||||||
{ name = "reportlab", specifier = ">=4.5.0" },
|
{ name = "reportlab", specifier = ">=4.5.0" },
|
||||||
{ name = "scikit-image", specifier = ">=0.26.0" },
|
{ name = "scikit-image", specifier = ">=0.26.0" },
|
||||||
@@ -2945,6 +2951,24 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/df/4e/1c9df57496409dc86b320bd38f29ad7a34b7115e4f35b8fca44a827568a7/onnxruntime-1.25.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e79fd5ce7db10ebcc24e020e2ed0159476e69e2326b9b7828e5aadcf6184212", size = 18021249, upload-time = "2026-04-27T22:00:18.954Z" },
|
{ url = "https://files.pythonhosted.org/packages/df/4e/1c9df57496409dc86b320bd38f29ad7a34b7115e4f35b8fca44a827568a7/onnxruntime-1.25.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e79fd5ce7db10ebcc24e020e2ed0159476e69e2326b9b7828e5aadcf6184212", size = 18021249, upload-time = "2026-04-27T22:00:18.954Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opencv-contrib-python-headless"
|
||||||
|
version = "4.13.0.92"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "numpy" },
|
||||||
|
]
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/b5/9af5b81d9279e9982e21dad52f8a6aec10f7c891ae1e3d3d1b3ce111f8e7/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:b0467988c2d56c283b00fb808e0b57f5db2e3ca7743164a3b3efc733bfa03d3a", size = 52041681, upload-time = "2026-02-05T07:01:39.651Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c5/42/f0aef27baf1f376007b018b00f6c304c42c20d31aa8491633c53b18912cb/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:79e503b77880d806a1b106ff8182c6f898347ccfd1db58ffc9a6369acc236c4c", size = 38830456, upload-time = "2026-02-05T07:01:56.47Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/84/e6b3568f9147b4f114e881fb0e733fd97bdca15452feba78b510351584d1/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:449c1f00a685a3a7dff8d6fa93a70fbfe0de5537c24358ea03a1d996d12b33e8", size = 39355323, upload-time = "2026-02-05T10:17:31.671Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/09/89714580c617cf6e9f66eed9137759fc017ab6ab093c2a03227e8ee19578/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c9b028adc04f6579f37227eb1d648bead14fd6fefc58da86df37c8320351f7bd", size = 62147375, upload-time = "2026-02-05T10:20:03.076Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/29/abdd2ff2f8f07e9aa37c70edc9987b8aa63730ae70957c378f6f2e9d72d2/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:cdd974b34801f24735d18b1057cfaab1698d5cb02c9bba01dab7dc47201f2ef6", size = 40840722, upload-time = "2026-02-05T10:21:27.877Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/0a/3194fdf035ef5123bd8cc3e3ad1a96c1ddeeedd0fdd12aaa0d2cfeb1649a/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9e26469baed9069f627ea56fa46819690c4545580362071dd09f1dcf47a40f2f", size = 66610130, upload-time = "2026-02-05T10:23:32.427Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/4b/afe9b43c02b86b675a3d3ac6fc220473a88016e1acb487f5138efd2d2630/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:696a6dd84d309a499efc63644e375f035447b1da777faa2954f2dea7626cc0e7", size = 36708602, upload-time = "2026-02-05T07:02:36.041Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/22/9fdc70520eb915b46d816f9cc5415458b1bd114a65d7a7e657cbd9b863e5/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:dcbb12d04ae74f5dcd782e3b166e1894c6fbdfaaf30866588746205d2a0cde5a", size = 46345416, upload-time = "2026-02-05T07:02:33.446Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpyxl"
|
name = "openpyxl"
|
||||||
version = "3.1.5"
|
version = "3.1.5"
|
||||||
@@ -4020,6 +4044,23 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" },
|
{ url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "qrcode"
|
||||||
|
version = "8.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
pil = [
|
||||||
|
{ name = "pillow" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rapidfuzz"
|
name = "rapidfuzz"
|
||||||
version = "3.14.5"
|
version = "3.14.5"
|
||||||
|
|||||||
Reference in New Issue
Block a user