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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user