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>
This commit is contained in:
2026-06-28 15:59:26 +02:00
parent 31c2f6ac7f
commit c79f33265e
11 changed files with 836 additions and 73 deletions
+128 -14
View File
@@ -64,8 +64,60 @@ def _normalize_palette(palette):
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.
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.
@@ -74,22 +126,39 @@ def _pixelize_pil(img, downscale, colors, palette_rgb, dither, upscale_back):
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 RGB pixelizada.
PIL.Image pixelizada: RGB, o RGBA si se preservo la transparencia.
"""
from PIL import Image
img = img.convert("RGB")
w, h = img.size
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 = img.resize((sw, sh), Image.NEAREST)
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.
# 2. cuantizar la paleta (siempre sobre RGB).
if palette_rgb:
pal_img = Image.new("P", (1, 1))
flat = [c for rgb in palette_rgb for c in rgb][:768]
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:
@@ -102,12 +171,42 @@ def _pixelize_pil(img, downscale, colors, palette_rgb, dither, upscale_back):
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,
@@ -117,6 +216,8 @@ def comfyui_pixelize_image(
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.
@@ -135,16 +236,28 @@ def comfyui_pixelize_image(
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 distintos en el resultado.
- 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, "error": ""}
out = {
"ok": False, "out_path": "", "size": [0, 0], "n_colors_final": 0,
"has_alpha": False, "error": "",
}
try:
from PIL import Image
@@ -168,7 +281,8 @@ def comfyui_pixelize_image(
try:
with Image.open(src_path) as src:
result = _pixelize_pil(
src, int(downscale), colors, palette_rgb, bool(dither), bool(upscale_back)
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}"
@@ -182,10 +296,10 @@ def comfyui_pixelize_image(
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
n_final = _count_colors(result)
out.update(
ok=True, out_path=dst_path, size=list(result.size), n_colors_final=n_final
ok=True, out_path=dst_path, size=list(result.size), n_colors_final=n_final,
has_alpha=(result.mode == "RGBA"),
)
return out