From ccdd529bdcb6a2879f6c72bb1fd6514f45ca9bf7 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 28 Jun 2026 15:24:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(comfyui):=20pipeline=20comfyui=5Fpixelart?= =?UTF-8?q?=5Freal=5Foneshot=20=E2=80=94=20pixelart=20REAL=20(PixelOE=20+?= =?UTF-8?q?=20cuantizacion=20dura)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- python/functions/ml/pixeloe_downscale.md | 92 +++++ python/functions/ml/pixeloe_downscale.py | 322 ++++++++++++++++++ python/functions/ml/pixeloe_downscale_test.py | 122 +++++++ .../comfyui_pixelart_real_oneshot.md | 133 ++++++++ .../comfyui_pixelart_real_oneshot.py | 312 +++++++++++++++++ 5 files changed, 981 insertions(+) create mode 100644 python/functions/ml/pixeloe_downscale.md create mode 100644 python/functions/ml/pixeloe_downscale.py create mode 100644 python/functions/ml/pixeloe_downscale_test.py create mode 100644 python/functions/pipelines/comfyui_pixelart_real_oneshot.md create mode 100644 python/functions/pipelines/comfyui_pixelart_real_oneshot.py diff --git a/python/functions/ml/pixeloe_downscale.md b/python/functions/ml/pixeloe_downscale.md new file mode 100644 index 00000000..e1a40a0e --- /dev/null +++ b/python/functions/ml/pixeloe_downscale.md @@ -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. diff --git a/python/functions/ml/pixeloe_downscale.py b/python/functions/ml/pixeloe_downscale.py new file mode 100644 index 00000000..e4d3f50c --- /dev/null +++ b/python/functions/ml/pixeloe_downscale.py @@ -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 `) 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 --bridge ` 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 [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)) diff --git a/python/functions/ml/pixeloe_downscale_test.py b/python/functions/ml/pixeloe_downscale_test.py new file mode 100644 index 00000000..fbf4eaa2 --- /dev/null +++ b/python/functions/ml/pixeloe_downscale_test.py @@ -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"] != "" diff --git a/python/functions/pipelines/comfyui_pixelart_real_oneshot.md b/python/functions/pipelines/comfyui_pixelart_real_oneshot.md new file mode 100644 index 00000000..b35f90e2 --- /dev/null +++ b/python/functions/pipelines/comfyui_pixelart_real_oneshot.md @@ -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 `_px__.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). diff --git a/python/functions/pipelines/comfyui_pixelart_real_oneshot.py b/python/functions/pipelines/comfyui_pixelart_real_oneshot.py new file mode 100644 index 00000000..64508f25 --- /dev/null +++ b/python/functions/pipelines/comfyui_pixelart_real_oneshot.py @@ -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))