c79f33265e
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>
321 lines
13 KiB
Python
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))
|