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
+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))