merge(comfyui): comfyui_pixelart_real_oneshot + pixeloe_downscale (pixelart real: PixelOE + cuantización dura)

This commit is contained in:
2026-06-28 15:27:08 +02:00
5 changed files with 981 additions and 0 deletions
+92
View File
@@ -0,0 +1,92 @@
---
name: pixeloe_downscale
kind: function
lang: py
domain: ml
version: "1.0.0"
purity: impure
signature: "def pixeloe_downscale(src_path: str, dst_path: str, *, mode: str = 'contrast', target_size: int = 64, patch_size: int = 16, thickness: int = 2, color_matching: bool = True, no_upscale: bool = True, comfy_python: str | None = None) -> dict"
description: "Downscale contrast-aware (Contrast-Aware Outline Expansion de Kohaku, lib `pixeloe`) que colapsa una ilustracion a un grid de pixel-art pequeno (64 personajes, 32 iconos) conservando contornos/silueta. Es la etapa de downscale del metodo SOTA de pixel-art (report 0215). NO cuantiza la paleta (eso lo hace despues comfyui_pixelize_image). Resuelve el gotcha de que `pixeloe` solo vive en el venv de ComfyUI con un 'bridge' de interprete: si falta en el interprete actual, re-ejecuta su nucleo por subprocess con el python de ComfyUI. No-throw: todo error viaja en `error`. Determinista; impura por I/O de disco + subprocess. Devuelve {ok, out_path, size, mode, target_size, via, error}."
tags: [comfyui, gamedev-2d, pixelart, ml, pixeloe, downscale, contrast-aware, image, bridge]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_py_core"
imports: []
params:
- name: src_path
desc: "ruta de la imagen de entrada (PNG/JPG/...). Si no existe -> ok=False con error."
- name: dst_path
desc: "ruta del PNG de salida; se crea el directorio padre si falta."
- name: mode
desc: "algoritmo de downscale de pixeloe: 'contrast' (SOTA, conserva silueta), 'bicubic', 'nearest', 'center' o 'k-centroid'. keyword-only."
- name: target_size
desc: "lado del grid resultante en pixeles (64 para personajes, 32 para iconos). keyword-only."
- name: patch_size
desc: "tamano del patch que pixeloe colapsa por celda del grid. keyword-only."
- name: thickness
desc: "grosor de la expansion de contorno (outline expansion). keyword-only."
- name: color_matching
desc: "corrige el color de cada celda contra el original si True. keyword-only."
- name: no_upscale
desc: "True devuelve el grid real target_size x target_size (lo habitual, para luego cuantizar); False re-escala al tamano original con pixeles duros (preview). keyword-only."
- name: comfy_python
desc: "ruta a un interprete con `pixeloe` para el bridge cuando el actual no la tiene. Si None: COMFY_PYTHON y luego ~/ComfyUI/.venv/bin/python3. keyword-only."
output: "dict con ok (bool), out_path (str), size ([w,h] de la imagen escrita), mode (str usado), target_size (int pedido), via ('inproc' si pixeloe estaba en este interprete, 'bridge' si se delego por subprocess) y error (str, vacio si OK). No lanza excepciones."
tested: true
tests: [test_golden_downscale_64_or_clean_degrade, test_edge_target_size_32, test_edge_mode_nearest_no_color_matching, test_error_missing_src_no_throw, test_error_no_interpreter_with_pixeloe]
test_file_path: "python/functions/ml/pixeloe_downscale_test.py"
file_path: "python/functions/ml/pixeloe_downscale.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
from ml.pixeloe_downscale import pixeloe_downscale
# Colapsa el render del caballero (1024x1024) a un grid de pixel-art 64x64
# conservando la silueta. NO cuantiza paleta todavia.
res = pixeloe_downscale(
os.path.expanduser("~/ComfyUI/output/pixel_compare/knight_base_00001_.png"),
"/tmp/knight_grid64.png",
mode="contrast", target_size=64, no_upscale=True,
)
# {'ok': True, 'out_path': '/tmp/knight_grid64.png', 'size': [64, 64],
# 'mode': 'contrast', 'target_size': 64, 'via': 'bridge', 'error': ''}
# Despues: dureza de color (cuantizacion) con la funcion hermana.
from ml.comfyui_pixelize_image import comfyui_pixelize_image
comfyui_pixelize_image("/tmp/knight_grid64.png", "/tmp/knight_q16.png",
downscale=1, colors=16, upscale_back=False)
```
## Cuando usarla
Primera etapa del metodo SOTA de pixel-art: cuando ya tienes una ilustracion (render
SDXL/Flux, sprite, foto) y quieres reducirla a un grid de pixel-art chico **sin perder
los contornos** (lo que arruina un resize NEAREST/lanczos normal). Usala **antes** de
la cuantizacion dura de paleta con `comfyui_pixelize_image` (paso de color). `target_size`
64 para personajes, 32 para iconos. Si solo necesitas el resize+cuantizado rapido sin
contornos finos, `comfyui_pixelize_image` sola basta; para el resultado ganador, encadena
`pixeloe_downscale` -> `comfyui_pixelize_image`.
## Gotchas
- **`pixeloe` solo esta en el venv de ComfyUI** (`~/ComfyUI/.venv`), no en el del registry.
La funcion lo resuelve con un *bridge*: si `import pixeloe` falla, re-ejecuta su nucleo
por subprocess con el python de ComfyUI. El campo `via` dice si fue `inproc` o `bridge`.
- **El modulo es `pixeloe.legacy.pixelize`**, no `pixeloe.pixelize` (ruta vieja eliminada).
- **El nodo `PixelOEPixelize+` de ComfyUI_essentials estaba roto** por ese cambio de import;
por eso aqui se llama la lib directa (numpy + PIL, sin cv2).
- **NO cuantiza la paleta**: el resultado conserva muchos colores; la dureza retro la aplica
despues `comfyui_pixelize_image`. No esperes pocos colores en la salida.
- **No-throw**: src inexistente, pixeloe ausente en todos los interpretes, o subprocess
caido -> `ok=False` con `error` explicado, nunca excepcion. El pipeline llamante hace
fallback mirando `ok`.
- Resolucion del interprete del bridge: arg `comfy_python` -> env `COMFY_PYTHON` ->
`~/ComfyUI/.venv/bin/python3` (el primero que exista como archivo).
- `no_upscale=True` (default) devuelve el grid real `target_size x target_size`; con `False`
vuelve al tamano original con pixeles duros (preview), no el grid pequeno.
+322
View File
@@ -0,0 +1,322 @@
"""pixeloe_downscale — downscale contrast-aware a un grid de pixel-art (etapa SOTA).
Colapsa una ilustracion a un grid de pixel-art pequeno (p.ej. 64x64) usando la
libreria `pixeloe` de Kohaku (Contrast-Aware Outline Expansion), el metodo SOTA
para preservar contornos/silueta al reducir. Es la etapa de *downscale* del
metodo ganador de pixel-art (ver report 0215): NO cuantiza la paleta — esa dureza
de color la aplica despues otra funcion (`comfyui_pixelize_image`).
Gotcha de entorno (resuelto con un "bridge" de interprete): la lib `pixeloe` solo
esta instalada en el venv de ComfyUI (`~/ComfyUI/.venv`), no en el venv del
registry, y su modulo vive en `pixeloe.legacy.pixelize` (la ruta vieja
`pixeloe.pixelize` ya no existe). Por eso la funcion:
1. Intenta `import pixeloe` en el interprete actual y ejecuta el nucleo directo.
2. Si falta (`ModuleNotFoundError`), re-ejecuta este mismo archivo como subprocess
(`python pixeloe_downscale.py --bridge <json>`) con un interprete que SI la
tenga, parseando la unica linea JSON que ese hijo imprime a stdout.
3. Si no hay ningun interprete con pixeloe, devuelve ok=False (sin excepcion);
el pipeline que la llama hara fallback.
La funcion es no-throw: cualquier error se captura y viaja en el campo `error`.
Determinista; impura solo por la lectura/escritura de disco y el subprocess.
"""
import os
def _resolve_comfy_python(comfy_python):
"""Devuelve el primer interprete candidato que exista como archivo, o None.
Orden: arg comfy_python -> env COMFY_PYTHON -> ~/ComfyUI/.venv/bin/python3.
"""
candidates = []
if comfy_python:
candidates.append(comfy_python)
env = os.environ.get("COMFY_PYTHON")
if env:
candidates.append(env)
candidates.append(os.path.expanduser("~/ComfyUI/.venv/bin/python3"))
for c in candidates:
if c and os.path.isfile(c):
return c
return None
def _run_core(src_path, dst_path, mode, target_size, patch_size, thickness,
color_matching, no_upscale):
"""Nucleo no-throw: requiere `pixeloe` importable EN ESTE interprete.
Lee src como RGB uint8 (numpy + PIL, sin cv2), llama
`pixeloe.legacy.pixelize.pixelize` y guarda el resultado como PNG. Devuelve el
dict de resultado. NO lanza excepciones: las captura en `error`.
"""
out = {
"ok": False,
"out_path": "",
"size": [0, 0],
"mode": mode,
"target_size": int(target_size),
"via": "inproc",
"error": "",
}
try:
import numpy as np
from PIL import Image
except Exception as exc: # noqa: BLE001 - degradacion limpia, no relanzar
out["error"] = f"numpy/PIL no disponible en este interprete: {exc}"
return out
if not os.path.isfile(src_path):
out["error"] = f"src_path no existe: {src_path!r}"
return out
try:
from pixeloe.legacy.pixelize import pixelize
except Exception as exc: # noqa: BLE001
out["error"] = f"no se pudo importar pixeloe.legacy.pixelize: {exc}"
return out
try:
img = np.array(Image.open(src_path).convert("RGB")) # HxWx3 uint8
except Exception as exc: # noqa: BLE001
out["error"] = f"no se pudo leer/decodificar {src_path!r}: {exc}"
return out
try:
res = pixelize(
img,
mode=mode,
target_size=int(target_size),
patch_size=int(patch_size),
thickness=int(thickness),
contrast=1.0,
saturation=1.0,
color_matching=bool(color_matching),
no_upscale=bool(no_upscale),
)
except TypeError as exc:
# Firma de pixelize distinta a la esperada: reseñar, no relanzar.
out["error"] = f"pixelize rechazo los kwargs (firma distinta?): {exc}"
return out
except Exception as exc: # noqa: BLE001
out["error"] = f"pixelize fallo: {exc}"
return out
try:
arr = np.asarray(res)
result_img = Image.fromarray(arr)
dst_dir = os.path.dirname(os.path.abspath(dst_path))
os.makedirs(dst_dir, exist_ok=True)
result_img.save(dst_path)
except Exception as exc: # noqa: BLE001
out["error"] = f"no se pudo escribir {dst_path!r}: {exc}"
return out
out.update(ok=True, out_path=dst_path, size=list(result_img.size), error="")
return out
def _run_via_bridge(interp, src_path, dst_path, mode, target_size, patch_size,
thickness, color_matching, no_upscale):
"""Ejecuta el nucleo en otro interprete (que tiene pixeloe) via subprocess.
Corre `interp <este_archivo> --bridge <json_args>` y parsea la ultima linea de
stdout que sea JSON valido (pixeloe puede emitir ruido antes). No-throw.
"""
import json
import subprocess
out = {
"ok": False,
"out_path": "",
"size": [0, 0],
"mode": mode,
"target_size": int(target_size),
"via": "bridge",
"error": "",
}
args = {
"src_path": src_path,
"dst_path": dst_path,
"mode": mode,
"target_size": int(target_size),
"patch_size": int(patch_size),
"thickness": int(thickness),
"color_matching": bool(color_matching),
"no_upscale": bool(no_upscale),
}
try:
proc = subprocess.run(
[interp, os.path.abspath(__file__), "--bridge", json.dumps(args)],
capture_output=True,
text=True,
timeout=600,
)
except Exception as exc: # noqa: BLE001
out["error"] = f"fallo el subprocess bridge ({interp}): {exc}"
return out
if proc.returncode != 0:
tail = (proc.stderr or "").strip()[-500:]
out["error"] = f"bridge salio con codigo {proc.returncode}: {tail}"
return out
# Parsea de atras hacia delante la primera linea que sea JSON valido.
parsed = None
for ln in reversed((proc.stdout or "").splitlines()):
ln = ln.strip()
if not ln:
continue
try:
parsed = json.loads(ln)
break
except Exception: # noqa: BLE001 - linea de ruido, sigue probando
continue
if parsed is None:
tail = (proc.stderr or "").strip()[-300:]
out["error"] = f"bridge no produjo salida JSON. stderr: {tail}"
return out
parsed["via"] = "bridge"
return parsed
def pixeloe_downscale(
src_path: str,
dst_path: str,
*,
mode: str = "contrast",
target_size: int = 64,
patch_size: int = 16,
thickness: int = 2,
color_matching: bool = True,
no_upscale: bool = True,
comfy_python: str | None = None,
) -> dict:
"""Downscale contrast-aware de una imagen a un grid de pixel-art (no cuantiza).
Args:
src_path: ruta de la imagen de entrada (PNG/JPG/...).
dst_path: ruta del PNG de salida (se crea el directorio si falta).
mode: algoritmo de downscale de pixeloe: "contrast" (SOTA, conserva
silueta), "bicubic", "nearest", "center" o "k-centroid". keyword-only.
target_size: lado del grid resultante en pixeles (64 personajes, 32
iconos). keyword-only.
patch_size: tamano del patch que pixeloe colapsa por celda. keyword-only.
thickness: grosor de la expansion de contorno (outline). keyword-only.
color_matching: corrige el color de cada celda contra el original si True.
keyword-only.
no_upscale: True devuelve el grid real target_size x target_size (lo
habitual para luego cuantizar); False re-escala al tamano original con
pixeles duros (preview). keyword-only.
comfy_python: ruta a un interprete con `pixeloe` para el bridge cuando el
actual no la tiene. Si None, se prueba COMFY_PYTHON y luego
~/ComfyUI/.venv/bin/python3. keyword-only.
Returns:
dict con:
- ok (bool): True si se hizo el downscale y se guardo el PNG.
- out_path (str): ruta del PNG generado.
- size (list[int]): [w, h] de la imagen escrita.
- mode (str): modo de downscale usado.
- target_size (int): lado del grid pedido.
- via (str): "inproc" si pixeloe estaba en este interprete, "bridge" si se
delego a otro interprete por subprocess.
- error (str): mensaje de error; cadena vacia si todo OK.
"""
out = {
"ok": False,
"out_path": "",
"size": [0, 0],
"mode": mode,
"target_size": int(target_size),
"via": "",
"error": "",
}
try:
if not os.path.isfile(src_path):
out["error"] = f"src_path no existe: {src_path!r}"
return out
# 1. pixeloe disponible en el interprete actual -> nucleo directo.
has_local = True
try:
import pixeloe # noqa: F401
except ModuleNotFoundError:
has_local = False
except Exception: # noqa: BLE001 - pixeloe presente pero roto -> bridge
has_local = False
if has_local:
res = _run_core(
src_path, dst_path, mode, int(target_size), int(patch_size),
int(thickness), bool(color_matching), bool(no_upscale),
)
res["via"] = "inproc"
return res
# 2. Bridge a un interprete que tenga pixeloe.
interp = _resolve_comfy_python(comfy_python)
if interp is None:
out["error"] = (
"pixeloe no disponible: no se encontro ningun interprete con "
"pixeloe (pasa comfy_python, define COMFY_PYTHON, o instala "
"~/ComfyUI/.venv)"
)
return out
return _run_via_bridge(
interp, src_path, dst_path, mode, int(target_size), int(patch_size),
int(thickness), bool(color_matching), bool(no_upscale),
)
except Exception as exc: # noqa: BLE001 - contrato no-throw
out["error"] = f"error inesperado: {exc}"
return out
if __name__ == "__main__":
import json
import sys
if "--bridge" in sys.argv:
# Modo bridge: ejecuta el nucleo y emite UNA linea JSON a stdout.
_idx = sys.argv.index("--bridge")
_payload = sys.argv[_idx + 1] if len(sys.argv) > _idx + 1 else "{}"
try:
_a = json.loads(_payload)
except Exception as _exc: # noqa: BLE001
print(json.dumps({
"ok": False, "out_path": "", "size": [0, 0], "mode": "",
"target_size": 0, "via": "inproc",
"error": f"payload --bridge invalido: {_exc}",
}))
sys.exit(0)
_res = _run_core(
_a.get("src_path", ""),
_a.get("dst_path", ""),
_a.get("mode", "contrast"),
_a.get("target_size", 64),
_a.get("patch_size", 16),
_a.get("thickness", 2),
_a.get("color_matching", True),
_a.get("no_upscale", True),
)
print(json.dumps(_res))
sys.exit(0)
# Modo CLI normal.
if len(sys.argv) < 3:
print("uso: pixeloe_downscale.py <src> <dst> [target_size] [mode]",
file=sys.stderr)
sys.exit(2)
_src, _dst = sys.argv[1], sys.argv[2]
_ts = int(sys.argv[3]) if len(sys.argv) > 3 else 64
_md = sys.argv[4] if len(sys.argv) > 4 else "contrast"
print(json.dumps(pixeloe_downscale(_src, _dst, target_size=_ts, mode=_md),
indent=2))
@@ -0,0 +1,122 @@
"""Tests de pixeloe_downscale — tolerantes al entorno.
El venv del registry NO trae `pixeloe`, asi que estas pruebas ejercitan el
"bridge" de interprete (subprocess al python de ComfyUI, que si la tiene). Si
tampoco hay ningun interprete con pixeloe disponible, la funcion debe degradar
limpiamente: ok=False con error no vacio y SIN lanzar excepcion.
Por eso cada test PASA en los dos escenarios:
- pixeloe disponible (inproc o via bridge): assert sobre el resultado real.
- pixeloe ausente en todos lados: assert sobre la degradacion no-throw.
Asi la suite es verde tanto en este PC (ComfyUI presente) como en uno sin ComfyUI,
y el contrato "no-throw" queda cubierto en ambos.
"""
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.pixeloe_downscale import pixeloe_downscale # noqa: E402
def _shapes_png(path, w=256, h=256):
"""PNG 256x256 RGB con un gradiente + formas (contraste con silueta clara)."""
yy, xx = np.mgrid[0:h, 0:w]
arr = np.zeros((h, w, 3), dtype=np.uint8)
arr[..., 0] = (xx * 255 // max(1, w - 1)).astype(np.uint8) # gradiente rojo
arr[..., 1] = (yy * 255 // max(1, h - 1)).astype(np.uint8) # gradiente verde
# Bloque azul central: borde duro para que el modo "contrast" tenga silueta.
arr[h // 4:3 * h // 4, w // 4:3 * w // 4, 2] = 255
Image.fromarray(arr, "RGB").save(path)
return path
def test_golden_downscale_64_or_clean_degrade(tmp_path):
"""Golden: 256x256 -> grid 64x64 (no_upscale). Si pixeloe no esta -> ok=False limpio."""
src = _shapes_png(str(tmp_path / "raw.png"))
dst = str(tmp_path / "grid64.png")
res = pixeloe_downscale(src, dst, target_size=64, no_upscale=True)
assert isinstance(res, dict)
if res["ok"]:
assert os.path.isfile(dst)
assert res["size"] == [64, 64] # no_upscale=True -> grid real
assert res["error"] == ""
assert res["via"] in ("inproc", "bridge")
assert res["mode"] == "contrast"
assert res["target_size"] == 64
else:
# Degradacion limpia: sin pixeloe en ningun interprete.
assert res["error"] != ""
assert res["via"] in ("", "bridge", "inproc")
def test_edge_target_size_32(tmp_path):
"""Edge: grid de 32 (iconos). size==[32,32] cuando pixeloe esta presente."""
src = _shapes_png(str(tmp_path / "raw.png"))
dst = str(tmp_path / "grid32.png")
res = pixeloe_downscale(src, dst, target_size=32, no_upscale=True)
if res["ok"]:
assert res["size"] == [32, 32]
assert res["target_size"] == 32
assert os.path.isfile(dst)
else:
assert res["error"] != ""
def test_edge_mode_nearest_no_color_matching(tmp_path):
"""Edge: otro modo + color_matching off; debe seguir produciendo el grid o degradar."""
src = _shapes_png(str(tmp_path / "raw.png"))
dst = str(tmp_path / "near.png")
res = pixeloe_downscale(
src, dst, mode="nearest", target_size=64,
color_matching=False, no_upscale=True,
)
assert isinstance(res, dict)
if res["ok"]:
assert res["mode"] == "nearest"
assert res["size"] == [64, 64]
else:
assert res["error"] != ""
def test_error_missing_src_no_throw(tmp_path):
"""Error path: src inexistente -> ok=False, error explica, sin excepcion."""
res = pixeloe_downscale(
str(tmp_path / "nope.png"), str(tmp_path / "o.png"), target_size=64,
)
assert res["ok"] is False
assert "no existe" in res["error"]
assert res["size"] == [0, 0]
def test_error_no_interpreter_with_pixeloe(tmp_path):
"""Error path: forzar comfy_python invalido cuando el actual no tiene pixeloe.
Si el interprete que corre el test YA tiene pixeloe (inproc), el comfy_python
invalido se ignora y la llamada puede salir ok=True; el test sigue siendo
valido (no-throw). Si NO lo tiene, no hay ningun interprete con pixeloe y debe
devolver ok=False con error, nunca lanzar.
"""
src = _shapes_png(str(tmp_path / "raw.png"))
dst = str(tmp_path / "o.png")
res = pixeloe_downscale(
src, dst, target_size=64, comfy_python="/no/such/python-interpreter",
)
assert isinstance(res, dict)
try:
import pixeloe # noqa: F401
has_local = True
except Exception: # noqa: BLE001
has_local = False
if has_local:
# pixeloe en el interprete del test -> ruta inproc, comfy_python ignorado.
assert res["ok"] is True
else:
# comfy_python invalido + env vacio: si ~/ComfyUI/.venv existe, puede
# bridgear y salir ok; si no, ok=False con error. Ambos no-throw.
assert res["ok"] in (True, False)
if not res["ok"]:
assert res["error"] != ""
@@ -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))