Files
fn_registry/python/functions/ml/comfyui_pixelize_image.py
T
egutierrez c79f33265e fix(comfyui): pixelart_real_oneshot — sprite llena el frame + fondo transparente
Arregla los dos defectos reportados del pipeline comfyui_pixelart_real_oneshot:
el sujeto salía diminuto respecto al frame y siempre traía fondo (sin opción de
transparencia).

Causa raíz: comfyui_pixelize_image hacía convert("RGB") y descartaba el alpha;
comfyui_build_pixelart_workflow no inyectaba rembg (a diferencia de sus hermanos
item_icon/enemy_creature); y no había ningún paso de auto-crop al contenido.

Orden correcto del pipeline ahora:
generar (rembg) -> autocrop al bbox + cuadrar -> downscale (alpha aparte por
PixelOE) -> cuantización alpha-aware -> PNG RGBA transparente.

Piezas:
- comfyui_pixelize_image (1.1.0): keep_alpha/alpha_threshold. Con RGBA cuantiza
  solo el RGB (fondo transparente relleno con la moda del sujeto, fuera de la
  paleta) y preserva/binariza el alpha aparte. RGB sin alpha intacto.
- crop_to_content (NUEVA, pura PIL): bbox del contenido (alpha o diff-fondo) ->
  recorta -> margen -> cuadra centrando. No-throw; imagen vacía -> copia intacta.
- comfyui_build_pixelart_workflow (1.1.0): transparent=True + rembg_model.
  Inyecta nodo Image Rembg tras VAEDecode (patrón de item_icon).
- comfyui_pixelart_real_oneshot (1.1.0): transparent + autocrop + crop_pad_ratio
  + rembg_model. Recombina el alpha aparte tras PixelOE (trabaja en RGB). Campos
  nuevos: has_alpha, autocrop_applied.

Verificado en GPU (knight 64px): RGBA con 4 esquinas alpha==0, contenido cubre
88% del frame (antes 48%), 16 colores, 64x64. 32 tests offline en verde.
Report: reports/0218-2026-06-28-pixelart-sprite-fix.md

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

321 lines
13 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 + SDXL_pixel-art
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 _img_has_alpha(img) -> bool:
"""True si la imagen lleva transparencia (RGBA, LA o P con transparency)."""
return img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info)
def _fill_transparent_with_mode(small_rgb, small_alpha, threshold):
"""Rellena los pixeles transparentes con el color opaco mas frecuente (moda).
Asi el fondo transparente NO aporta colores nuevos a la cuantizacion: las zonas
con alpha <= threshold toman un color que ya esta en el sujeto (y por tanto en la
paleta resultante), sin gastar entradas de la paleta en el color de fondo. El
color real de esas zonas es irrelevante para la salida porque luego reciben
alpha 0. Si no hay numpy, cae a no rellenar (degradacion limpia).
Args:
small_rgb: PIL.Image RGB ya reducida.
small_alpha: PIL.Image 'L' del alpha ya reducido (mismo tamano).
threshold: umbral de alpha (0..255); <= threshold = transparente.
Returns:
PIL.Image RGB con el fondo transparente relleno con la moda del sujeto.
"""
from PIL import Image
rgb = small_rgb.convert("RGB")
mask = small_alpha.point(lambda p: 255 if p > threshold else 0).convert("L")
try:
import numpy as np
except ImportError:
return rgb
arr = np.asarray(rgb).reshape(-1, 3)
opaque = np.asarray(mask).reshape(-1) > 0
if not opaque.any():
return rgb # nada opaco: caso degenerado, deja igual
op_pixels = arr[opaque]
colors, counts = np.unique(op_pixels, axis=0, return_counts=True)
fill = tuple(int(x) for x in colors[counts.argmax()])
bg = Image.new("RGB", rgb.size, fill)
bg.paste(rgb, (0, 0), mask) # rgb donde mask=255, fill (moda) donde mask=0
return bg
def _pixelize_pil(img, downscale, colors, palette_rgb, dither, upscale_back,
keep_alpha, alpha_threshold):
"""Nucleo puro PIL: imagen -> imagen pixelizada (RGB, o RGBA si keep_alpha).
Si la imagen de entrada tiene canal alpha y keep_alpha es True, la cuantizacion
de color se hace SOLO sobre el RGB (con el fondo transparente relleno con la moda
del sujeto para que no entre en la paleta) y el alpha se downscalea nearest por
separado y se binariza por `alpha_threshold`, recombinando a RGBA. Asi se
preserva la transparencia sin que las zonas transparentes contaminen la paleta.
Para imagenes sin alpha (o keep_alpha False) el comportamiento RGB es identico al
de antes.
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.
keep_alpha: si True y la imagen tiene alpha, preserva la transparencia.
alpha_threshold: umbral (0..255) para binarizar el alpha (opaco/transparente).
Returns:
PIL.Image pixelizada: RGB, o RGBA si se preservo la transparencia.
"""
from PIL import Image
has_alpha = bool(keep_alpha) and _img_has_alpha(img)
if has_alpha:
rgba = img.convert("RGBA")
alpha_full = rgba.getchannel("A")
rgb = rgba.convert("RGB")
else:
rgb = img.convert("RGB")
alpha_full = None
w, h = rgb.size
# 1. downscale nearest -> grid real (colapsa bloques borrosos a 1 pixel).
sw, sh = max(1, w // downscale), max(1, h // downscale)
small = rgb.resize((sw, sh), Image.NEAREST)
small_alpha = (
alpha_full.resize((sw, sh), Image.NEAREST) if alpha_full is not None else None
)
# 1b. con alpha: el fondo transparente no debe entrar en la paleta.
if small_alpha is not None:
small = _fill_transparent_with_mode(small, small_alpha, int(alpha_threshold))
d = Image.Dither.FLOYDSTEINBERG if dither else Image.Dither.NONE
# 2. cuantizar la paleta (siempre sobre RGB).
if palette_rgb:
pal_img = Image.new("P", (1, 1))
flat = [c for rgb_c in palette_rgb for c in rgb_c][: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")
# 2b. recombinar el alpha (binarizado) -> RGBA con transparencia dura.
if small_alpha is not None:
out = out.convert("RGBA")
hard_alpha = small_alpha.point(lambda p: 255 if p > int(alpha_threshold) else 0)
out.putalpha(hard_alpha)
# 3. opcional: re-upscale nearest para preview/entrega (pixeles duros).
if upscale_back:
out = out.resize((w, h), Image.NEAREST)
return out
def _count_colors(result) -> int:
"""Numero de colores RGB distintos en el resultado.
Para salida RGBA cuenta solo los colores de la zona opaca (alpha > 0), que es lo
que define el sprite; el transparente no es un "color" del pixel-art. Para RGB
cuenta todos los colores. Devuelve -1 si no se pudo contar.
"""
if result.mode == "RGBA":
try:
import numpy as np
except ImportError:
colors_found = result.convert("RGB").getcolors(maxcolors=1 << 20)
return len(colors_found) if colors_found is not None else -1
arr = np.asarray(result)
opaque = arr[..., 3] > 0
rgb_op = arr[..., :3][opaque]
if rgb_op.size == 0:
return 0
return int(len(np.unique(rgb_op.reshape(-1, 3), axis=0)))
colors_found = result.getcolors(maxcolors=1 << 20)
return len(colors_found) if colors_found is not None else -1
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,
keep_alpha: bool = True,
alpha_threshold: int = 128,
) -> 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.
keep_alpha: si True (default) y la imagen de entrada tiene canal alpha,
preserva la transparencia: cuantiza solo el RGB y downscalea/binariza el
alpha por separado, devolviendo PNG RGBA. Las zonas transparentes no
entran en la paleta de color. Si la imagen no tiene alpha, no tiene
efecto (sale RGB igual que antes). keyword-only.
alpha_threshold: umbral (0..255) para binarizar el alpha en opaco (255) o
transparente (0). Solo aplica cuando se preserva el alpha. 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 RGB distintos en el resultado
(en la zona opaca si la salida es RGBA).
- has_alpha (bool): True si la salida es RGBA con transparencia preservada.
- error (str): mensaje de error; cadena vacia si todo OK.
"""
out = {
"ok": False, "out_path": "", "size": [0, 0], "n_colors_final": 0,
"has_alpha": False, "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), bool(keep_alpha), int(alpha_threshold),
)
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
n_final = _count_colors(result)
out.update(
ok=True, out_path=dst_path, size=list(result.size), n_colors_final=n_final,
has_alpha=(result.mode == "RGBA"),
)
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))