e57da2f6d5
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>
207 lines
7.8 KiB
Python
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))
|