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
+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"] != ""