merge(comfyui): comfyui_pixelart_real_oneshot + pixeloe_downscale (pixelart real: PixelOE + cuantización dura)
This commit is contained in:
@@ -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.
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user