Files
fn_registry/python/functions/ml/comfyui_pixelize_image.py
T
egutierrez e57da2f6d5 feat(gamedev): ronda 1 — pixelize + luma→alpha + export-godot (grupo gamedev)
Tres funciones CPU-only del lote gamedev 2D + 2 helpers puros + grupo de capacidad:

- comfyui_pixelize_image_py_ml (impure): Fase 2 pixelart — downscale nearest +
  cuantizacion a N colores / paleta fija (game-boy/pico-8/nes) + re-upscale nearest.
- comfyui_matting_luma_to_alpha_py_ml (impure): frame VFX sobre negro -> RGBA por
  luminancia ponderada (translucidos con additive blend).
- comfyui_export_asset_to_godot_py_pipelines (impure): puente ComfyUI -> Godot 4 —
  copia a res://assets/<dir> por kind + .import por tipo + filtro Nearest si pixelart
  + reimport headless best-effort. Compone los 2 helpers puros.
- godot_map_asset_dir_py_core, godot_clean_asset_name_py_core (pure): nucleos
  reutilizables del pipeline.
- docs/capabilities/gamedev-2d.md + INDEX: grupo nuevo gamedev.

Tests 33/33 verdes (offline PIL/numpy). Golden real verificado: asset de
~/ComfyUI/output -> /tmp/godot_test_proj con .import correcto y reimport headless
real de Godot 4.7. Sin GPU, sin red, sin tocar proyectos del usuario.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 19:43:47 +02:00

207 lines
7.8 KiB
Python

"""comfyui_pixelize_image — post-proceso pixel-perfect de una imagen (Fase 2 pixelart).
Convierte una imagen "pixelart borroso de IA" (o cualquier PNG) en pixelart de
verdad: downscale nearest-neighbor por factor (colapsa cada bloque borroso a un
pixel duro) -> cuantizacion a N colores o a una paleta fija (NES / Game Boy /
PICO-8) -> opcional re-upscale nearest conservando los pixeles duros.
Es la Fase 2 del pipeline pixelart (la Fase 1, generar con SDXL + pixel-art-xl
LoRA, vive en otra funcion). Determinista y CPU-only: el nucleo `_pixelize_pil`
es puro (PIL), no toca la GPU ni la red. La funcion publica es impura solo por la
lectura/escritura de disco (mismo patron que comfyui_build_grid).
Por que nearest y no lanczos/cubic: el downscale tiene que colapsar cada bloque
borroso a UN pixel; cualquier interpolacion suave re-difumina el grid. La
cuantizacion (Image.quantize) limita la paleta, que es lo que da la identidad
retro y elimina el ruido de cientos de colores de la difusion.
"""
import os
# Paletas retro fijas (hex sin '#'). Embebidas: cero red, deterministas.
# Fuentes: lospec.com (game-boy, pico-8) + paleta NES clasica reducida.
_BUILTIN_PALETTES = {
"game-boy": ["0f380f", "306230", "8bac0f", "9bbc0f"],
"pico-8": [
"000000", "1d2b53", "7e2553", "008751", "ab5236", "5f574f",
"c2c3c7", "fff1e8", "ff004d", "ffa300", "ffec27", "00e436",
"29adff", "83769c", "ff77a8", "ffccaa",
],
# NES: subconjunto representativo de 16 colores de la paleta 2C02.
"nes": [
"000000", "fcfcfc", "f8f8f8", "bcbcbc", "7c7c7c", "0000fc",
"0078f8", "00b800", "b8f818", "f83800", "e40058", "f878f8",
"fca044", "f8b800", "503000", "00a800",
],
}
def _hex_to_rgb(h: str) -> tuple:
"""'1a2b3c' o '#1a2b3c' -> (26, 43, 60)."""
h = h.strip().lstrip("#")
if len(h) != 6:
raise ValueError(f"hex de color invalido: {h!r} (esperado rrggbb)")
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
def _normalize_palette(palette):
"""palette: None | nombre builtin (str) | lista de hex -> list[(r,g,b)] | None."""
if palette is None:
return None
if isinstance(palette, str):
key = palette.strip().lower().replace("_", "-")
if key not in _BUILTIN_PALETTES:
raise ValueError(
f"paleta builtin desconocida: {palette!r}. "
f"Disponibles: {sorted(_BUILTIN_PALETTES)} o pasa una lista de hex."
)
hexes = _BUILTIN_PALETTES[key]
else:
hexes = list(palette)
if not hexes:
raise ValueError("lista de paleta vacia")
return [_hex_to_rgb(h) for h in hexes]
def _pixelize_pil(img, downscale, colors, palette_rgb, dither, upscale_back):
"""Nucleo puro PIL: imagen RGB -> imagen RGB pixelizada.
Args:
img: PIL.Image de entrada.
downscale: factor entero de reduccion nearest (>=1).
colors: numero de colores objetivo si no hay paleta fija.
palette_rgb: lista [(r,g,b), ...] o None (cuantizacion automatica).
dither: aplica Floyd-Steinberg al cuantizar si True.
upscale_back: re-escala nearest al tamano original si True.
Returns:
PIL.Image RGB pixelizada.
"""
from PIL import Image
img = img.convert("RGB")
w, h = img.size
# 1. downscale nearest -> grid real (colapsa bloques borrosos a 1 pixel).
sw, sh = max(1, w // downscale), max(1, h // downscale)
small = img.resize((sw, sh), Image.NEAREST)
d = Image.Dither.FLOYDSTEINBERG if dither else Image.Dither.NONE
# 2. cuantizar la paleta.
if palette_rgb:
pal_img = Image.new("P", (1, 1))
flat = [c for rgb in palette_rgb for c in rgb][:768]
# Rellena las 256 entradas repitiendo el ultimo color real (no ceros): asi
# quantize no puede introducir un color extra (negro) por las entradas vacias.
if flat:
last = flat[-3:]
flat += last * ((768 - len(flat)) // 3)
flat += [0] * (768 - len(flat))
pal_img.putpalette(flat)
small = small.quantize(palette=pal_img, dither=d)
else:
n = max(2, min(256, int(colors)))
small = small.quantize(colors=n, method=Image.Quantize.MEDIANCUT, dither=d)
out = small.convert("RGB")
# 3. opcional: re-upscale nearest para preview/entrega (pixeles duros).
if upscale_back:
out = out.resize((w, h), Image.NEAREST)
return out
def comfyui_pixelize_image(
src_path: str,
dst_path: str,
*,
downscale: int = 8,
colors: int = 16,
palette=None,
dither: bool = False,
upscale_back: bool = True,
) -> dict:
"""Pixeliza una imagen y la guarda como PNG.
Args:
src_path: ruta de la imagen de entrada (PNG/JPG/...).
dst_path: ruta del PNG de salida.
downscale: factor entero de reduccion nearest-neighbor; cada bloque de
downscale x downscale px colapsa a 1 pixel. 1 = solo cuantiza, sin
colapsar el grid. keyword-only.
colors: numero de colores objetivo (2..256) cuando palette es None;
cuantizacion MEDIANCUT determinista. keyword-only.
palette: None (cuantizacion automatica a `colors`), nombre de paleta
fija builtin ("game-boy", "pico-8", "nes") o lista de hex
("#rrggbb"/"rrggbb"). Una paleta fija ignora `colors`. keyword-only.
dither: aplica Floyd-Steinberg al cuantizar (por defecto off, pixelart
limpio). keyword-only.
upscale_back: re-escala nearest al tamano original (preview con pixeles
duros). False deja la imagen pequena (sw x sh). keyword-only.
Returns:
dict con:
- ok (bool): True si se pixelizo y guardo.
- out_path (str): ruta del PNG generado.
- size (list[int]): [w, h] de la imagen final.
- n_colors_final (int): numero de colores distintos en el resultado.
- error (str): mensaje de error; cadena vacia si todo OK.
"""
out = {"ok": False, "out_path": "", "size": [0, 0], "n_colors_final": 0, "error": ""}
try:
from PIL import Image
except ImportError:
out["error"] = "PIL (Pillow) no esta instalado en este interprete"
return out
if not os.path.isfile(src_path):
out["error"] = f"src_path no existe: {src_path!r}"
return out
if int(downscale) < 1:
out["error"] = f"downscale debe ser >= 1, recibido {downscale!r}"
return out
try:
palette_rgb = _normalize_palette(palette)
except ValueError as exc:
out["error"] = f"paleta invalida: {exc}"
return out
try:
with Image.open(src_path) as src:
result = _pixelize_pil(
src, int(downscale), colors, palette_rgb, bool(dither), bool(upscale_back)
)
except OSError as exc:
out["error"] = f"no se pudo leer/decodificar {src_path!r}: {exc}"
return out
try:
dst_dir = os.path.dirname(os.path.abspath(dst_path))
os.makedirs(dst_dir, exist_ok=True)
result.save(dst_path)
except OSError as exc:
out["error"] = f"no se pudo escribir {dst_path!r}: {exc}"
return out
colors_found = result.getcolors(maxcolors=1 << 20)
n_final = len(colors_found) if colors_found is not None else -1
out.update(
ok=True, out_path=dst_path, size=list(result.size), n_colors_final=n_final
)
return out
if __name__ == "__main__":
import json
import sys
if len(sys.argv) < 3:
print("uso: comfyui_pixelize_image.py <src> <dst> [downscale] [colors] [palette]",
file=sys.stderr)
sys.exit(2)
src, dst = sys.argv[1], sys.argv[2]
ds = int(sys.argv[3]) if len(sys.argv) > 3 else 8
col = int(sys.argv[4]) if len(sys.argv) > 4 else 16
pal = sys.argv[5] if len(sys.argv) > 5 else None
print(json.dumps(comfyui_pixelize_image(src, dst, downscale=ds, colors=col, palette=pal),
indent=2))