feat(comfyui): pipeline comfyui_pixelart_real_oneshot — pixelart REAL (PixelOE + cuantizacion dura)

Materializa el metodo ganador del report 0215: generar a alta-res con SDXL +
LoRA SDXL_pixel-art, downscale contrast-aware con PixelOE (engine=pixeloe para
sprites/personajes) o nearest (tiles), y cuantizacion dura con
comfyui_pixelize_image (16 colores libres o paleta fija pico-8/nes/game-boy).

- pixeloe_downscale_py_ml: downscale contrast-aware via lib pixeloe con bridge
  de interprete (la lib vive en el venv de ComfyUI, no en el del registry).
  No-throw, fallback limpio si pixeloe no disponible.
- comfyui_pixelart_real_oneshot_py_pipelines: one-shot que compone build_pixelart
  + submit + wait + fetch + pixeloe_downscale + pixelize_image. Fallback
  automatico pixeloe->nearest. Sweet-spot 64px personajes, 32px iconos.

Verificado por PIL: personaje 64x64=16 colores, icono 32x32=16 colores (vs ~33k
de la imagen de difusion cruda). 100% grid duro + outline nitido.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 15:24:15 +02:00
parent 741724f633
commit ccdd529bdc
5 changed files with 981 additions and 0 deletions
@@ -0,0 +1,133 @@
---
name: comfyui_pixelart_real_oneshot
kind: pipeline
lang: py
domain: pipelines
version: "1.0.0"
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"
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."
tags: [comfyui, gamedev-2d, pixelart, pipelines, sprite, launcher]
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_types: []
returns: []
returns_optional: false
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]
params:
- name: subject
desc: "Prompt positivo (lo que se quiere ver: 'pixel art knight, full body, side view'). No puede estar vacio."
- name: size
desc: "Lado del grid final en pixeles. 64 personajes/sprites, 32 iconos/objetos simples. keyword-only."
- name: colors
desc: "Numero de colores de la paleta libre (MEDIANCUT) cuando palette es None. keyword-only."
- name: engine
desc: "'pixeloe' (downscale contrast-aware, sujetos con silueta) o 'nearest' (downscale simple, tiles/texturas). Fallback automatico a nearest si pixeloe falla. 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: server
desc: "host:port del servidor ComfyUI (sin esquema). keyword-only."
- name: dest_dir
desc: "Directorio donde guardar los PNG (se expande ~). keyword-only."
- name: seed
desc: "Semilla del KSampler. keyword-only."
- name: negative
desc: "Prompt negativo; None usa el default de build_pixelart (evita blur/gradientes/anti-alias). keyword-only."
- name: mode
desc: "Modo de downscale de PixelOE ('contrast' SOTA, 'k-centroid', 'nearest', 'center', 'bicubic'); solo 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: 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."
- 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."
- name: keep_base
desc: "Si True conserva el PNG base de alta resolucion; si False lo borra tras pixelizar. keyword-only."
- name: comfy_python
desc: "Ruta al interprete de ComfyUI (con la lib pixeloe); None autodetecta. keyword-only."
- name: wait_timeout
desc: "Segundos maximos esperando al server. keyword-only."
- name: filename_prefix
desc: "Prefijo de los archivos de salida. keyword-only."
- 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)."
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."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/pipelines/comfyui_pixelart_real_oneshot.py"
---
## Ejemplo
```bash
# Personaje 64px, 16 colores, motor pixeloe (sprites con silueta).
./fn run comfyui_pixelart_real_oneshot "pixel art knight, full body, side view, game sprite"
```
```python
import sys, os
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
# (a) Personaje 64px, paleta libre 16 colores, PixelOE contrast.
res = comfyui_pixelart_real_oneshot(
"pixel art knight, full body, side view, game sprite",
size=64, colors=16, engine="pixeloe", seed=42,
dest_dir="~/ComfyUI/output",
)
print(res["out_path"], res["colors_final"], res["engine_used"]) # ~16 colores, pixeloe
# (b) Icono 32px de un item.
res = comfyui_pixelart_real_oneshot(
"pixel art sword icon, single object",
size=32, colors=16, engine="pixeloe", seed=7,
)
# (c) Tile sin silueta -> nearest (mas barato) + paleta fija PICO-8.
res = comfyui_pixelart_real_oneshot(
"pixel art grass texture tile, top down, seamless",
size=64, engine="nearest", palette="pico-8", fill_frame=False,
)
```
## Cuando usarla
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
pero tiene decenas de miles de colores y bordes con anti-aliasing). Una sola
llamada hace generar -> downscale -> cuantizar. Usa `engine="pixeloe"` para
personajes/criaturas/iconos con silueta (conserva el contorno) y
`engine="nearest"` para tiles/texturas/fondos sin contorno (mas barato, CPU puro).
64px es el sweet-spot de personajes; 32px solo para iconos/objetos simples.
## Gotchas
- Impuro: requiere el **servidor ComfyUI vivo** en `server` (default
`127.0.0.1:8188`) y los modelos instalados (SDXL Juggernaut + LoRA
`SDXL_pixel-art` + `SDXL_lcm-lora`). Si esta caido, falla en submit con
`ok=False` y el error de conexion (nunca lanza).
- `engine="pixeloe"` necesita la lib `pixeloe`, que vive en el venv de ComfyUI
(no en el del registry). `pixeloe_downscale` hace el puente de interprete
automaticamente; si no la encuentra, el pipeline **cae a `nearest`** y lo
reporta en `engine_used` + `error` (no aborta).
- El nodo `PixelOEPixelize+` de ComfyUI_essentials estaba **roto** por un import
obsoleto (`pixeloe.pixelize` -> ahora `pixeloe.legacy.pixelize`); por eso el
pipeline usa la lib directa via `pixeloe_downscale`, no el nodo del server.
- `dest_dir` es un **directorio** (se crea si no existe). Los nombres de salida
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
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.
`fill_frame=True` (default) empuja al sujeto a llenar el frame; aun asi, para
sprites conviene un subject que pida "full body, centered".
- No reintenta el sampler: para mejor toma, varia `seed`.
## Capability growth log
- v1.0.0 (2026-06-28) — pipeline inicial. Materializa el metodo ganador del
report 0215 (PixelOE contrast downscale -> cuantizacion dura). Compone
build_pixelart + submit + wait + fetch + pixeloe_downscale + pixelize_image
(issue 0087).
@@ -0,0 +1,312 @@
"""comfyui_pixelart_real_oneshot — prompt de texto -> sprite pixel-art REAL en disco.
Pipeline one-shot (issue 0087) que materializa el metodo ganador de la
investigacion (report 0215): la difusion NO sabe pintar pixel-perfect (su salida
tiene decenas de miles de colores y bordes con anti-aliasing — pixel-art FALSO),
asi que el pixel-art de verdad es siempre post-proceso en dos ejes: colapsar a un
grid duro y limitar la paleta. El metodo ganador combina:
1. Generar a alta resolucion con el look pixel-art (SDXL Juggernaut + LoRA
SDXL_pixel-art), via comfyui_build_pixelart_workflow.
2. Downscale contrast-aware con PixelOE (pixeloe_downscale): elige el pixel mas
representativo de cada zona y engrosa contornos -> silueta legible. Es lo que
distingue un sprite reconocible de una mancha. Solo para sujetos con silueta
(engine="pixeloe"); para tiles/texturas sin contorno, un downscale nearest
simple basta (engine="nearest") y es mas barato.
3. Cuantizacion dura con 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 -> 16 colores exactos + 100% grid duro.
Resultado del combo verificado por PIL: grid duro perfecto + paleta limitada +
outline nitido. Sweet-spot: 64px personajes/sprites, 32px iconos/objetos simples.
Compone funciones del registry, no reescribe su logica:
comfyui_build_pixelart_workflow_py_ml (workflow SDXL + LoRA pixel-art)
comfyui_submit_workflow_py_ml (POST /prompt)
comfyui_wait_result_py_ml (poll /history)
comfyui_fetch_output_image_py_ml (GET /view -> disco, imagen base)
pixeloe_downscale_py_ml (downscale contrast-aware, engine pixeloe)
comfyui_pixelize_image_py_ml (cuantizacion dura + nearest fallback)
Pipeline impuro: red (HTTP a ComfyUI) + escritura en disco. No-throw: cualquier
fallo se captura y se devuelve en el dict de estado (campo error).
"""
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.comfyui_build_pixelart_workflow import comfyui_build_pixelart_workflow
from ml.comfyui_fetch_output_image import comfyui_fetch_output_image
from ml.comfyui_pixelize_image import comfyui_pixelize_image
from ml.comfyui_submit_workflow import comfyui_submit_workflow
from ml.comfyui_wait_result import comfyui_wait_result
from ml.pixeloe_downscale import pixeloe_downscale
# Sufijo de encuadre: empuja al sujeto a llenar el frame para que tras el
# downscale conserve detalle por pixel (gotcha del report: un sujeto que ocupa el
# 25% del frame queda diminuto a 64px). Solo se anade si no esta ya presente.
_FRAME_HINT = "full body, centered, fills frame, no margins"
def _frame_subject(subject: str, fill_frame: bool) -> str:
"""Anade el hint de encuadre al subject si fill_frame y no esta ya."""
if not fill_frame:
return subject
low = subject.lower()
if "fills frame" in low or "full body" in low or "centered" in low:
return subject
return f"{subject}, {_FRAME_HINT}"
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:
"""Genera un sprite pixel-art REAL desde un prompt de texto, end-to-end.
Args:
subject: prompt positivo (lo que se quiere ver: "pixel art knight, full
body, side view", etc.). No puede estar vacio.
size: lado del grid final en pixeles (64 personajes/sprites, 32 iconos).
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 reporta 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.
server: host:port del servidor ComfyUI (sin esquema). keyword-only.
dest_dir: directorio donde guardar los PNG (se expande ~). keyword-only.
seed: semilla del KSampler. keyword-only.
negative: prompt negativo; None usa el default de build_pixelart
(evita blur/gradientes/anti-alias). 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.
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).
keyword-only.
upscale_preview: si > 0, escribe ademas un PNG re-escalado nearest a
ese lado (preview con pixeles duros, p.ej. 512). 0 lo desactiva.
keyword-only.
keep_base: si True conserva el PNG base de alta resolucion; si False lo
borra tras pixelizar. keyword-only.
comfy_python: ruta al interprete de ComfyUI (con la lib pixeloe); None
autodetecta. keyword-only.
wait_timeout: segundos maximos esperando al server. keyword-only.
filename_prefix: prefijo de los archivos de salida. keyword-only.
**gen_kwargs: params extra para comfyui_build_pixelart_workflow
(width, height, ckpt_name, lora_strength, use_lcm, steps, cfg, ...).
Returns:
dict con:
- ok (bool): True si se produjo el PNG final pixelizado.
- out_path (str): ruta del PNG final size x size.
- 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).
- size (int): lado real del PNG final.
- colors_final (int): numero de colores distintos en el resultado.
- engine_used (str): "pixeloe" o "nearest" (refleja el fallback).
- prompt_id (str): id del trabajo en ComfyUI.
- error (str): mensaje de error; vacio si OK.
"""
out = {
"ok": False, "out_path": "", "out_path_upscaled": "", "base_path": "",
"size": int(size), "colors_final": 0, "engine_used": engine,
"prompt_id": "", "error": "",
}
if not subject or not subject.strip():
out["error"] = "subject vacio"
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
dest = os.path.expanduser(dest_dir)
try:
os.makedirs(dest, exist_ok=True)
except OSError as exc:
out["error"] = f"no se pudo crear dest_dir {dest!r}: {exc}"
return out
# --- Fase 1: generar la imagen base de alta resolucion (look pixel-art) ---
positive = _frame_subject(subject, fill_frame)
try:
if negative is None:
workflow = comfyui_build_pixelart_workflow(
positive, seed=seed, filename_prefix=f"{filename_prefix}_base",
**gen_kwargs,
)
else:
workflow = comfyui_build_pixelart_workflow(
positive, negative, seed=seed,
filename_prefix=f"{filename_prefix}_base", **gen_kwargs,
)
except (ValueError, TypeError) as exc:
out["error"] = f"build workflow fallo: {exc}"
return out
try:
sub = comfyui_submit_workflow(workflow, server=server)
prompt_id = sub["prompt_id"]
out["prompt_id"] = prompt_id
except (RuntimeError, KeyError, OSError) as exc:
out["error"] = f"submit fallo (server {server} responde?): {exc}"
return out
try:
outputs = comfyui_wait_result(prompt_id, server=server, timeout=wait_timeout)
except (TimeoutError, RuntimeError, OSError) as exc:
out["error"] = f"wait fallo: {exc}"
return out
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["error"] = f"el workflow no produjo imagenes (outputs={list(outputs)})"
return out
fetched = comfyui_fetch_output_image(
img["filename"], subfolder=img.get("subfolder", ""),
type_=img.get("type", "output"), server=server, dest_dir=dest,
)
if not fetched.get("ok"):
out["error"] = f"fetch de imagen base fallo: {fetched.get('error')}"
return out
base_path = fetched["path"]
out["base_path"] = base_path
# --- Fase 2a: downscale a un grid `size` x `size` (mid). ---
mid_path = os.path.join(dest, f"{filename_prefix}_{size}px_mid.png")
engine_used = engine
if engine == "pixeloe":
ds = pixeloe_downscale(
base_path, mid_path, mode=mode, target_size=int(size),
patch_size=patch_size, thickness=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 en el venv del registry).
try:
from PIL import Image
with Image.open(base_path) as src:
small = src.convert("RGB").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 2b: cuantizacion dura (paleta exacta) sobre el grid ya hecho. ---
final_tag = palette if isinstance(palette, str) else f"q{colors}"
final_path = os.path.join(
dest, f"{filename_prefix}_{size}px_{engine_used}_{final_tag}.png"
)
quant = comfyui_pixelize_image(
mid_path, final_path, downscale=1, colors=int(colors),
palette=palette, upscale_back=False,
)
if not quant.get("ok"):
out["error"] = f"cuantizacion fallo: {quant.get('error')}"
return out
out["out_path"] = final_path
out["size"] = quant["size"][0] if quant.get("size") else int(size)
out["colors_final"] = quant.get("n_colors_final", 0)
out["engine_used"] = engine_used
# --- Fase 3 (opcional): preview re-escalado nearest a pixeles duros. ---
if int(upscale_preview) > 0:
up_path = os.path.join(
dest, f"{filename_prefix}_{size}px_{engine_used}_{final_tag}_up.png"
)
try:
from PIL import Image
with Image.open(final_path) as fin:
up = fin.convert("RGB").resize(
(int(upscale_preview), int(upscale_preview)), Image.NEAREST
)
up.save(up_path)
out["out_path_upscaled"] = up_path
except (ImportError, OSError) as exc:
# El preview es opcional: no invalida el resultado.
out["out_path_upscaled"] = ""
if not out["error"]:
out["error"] = f"preview upscale fallo (no critico): {exc}"
# Limpieza opcional de la base y del intermedio.
try:
os.remove(mid_path)
except OSError:
pass
if not keep_base:
try:
os.remove(base_path)
out["base_path"] = ""
except OSError:
pass
out["ok"] = True
return out
if __name__ == "__main__":
import json
res = comfyui_pixelart_real_oneshot(
"pixel art knight, full body, side view, game sprite",
size=64, colors=16, engine="pixeloe", seed=42,
dest_dir="/tmp/comfy_pixelart_real",
)
print(json.dumps(res, indent=2))