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:
@@ -0,0 +1,162 @@
|
||||
"""crop_to_content — recorta una imagen PIL al bounding box de su contenido y la cuadra.
|
||||
|
||||
Quita el aire alrededor del sujeto para que llene el frame antes de un downscale a
|
||||
pixel-art: si el sujeto ocupa el 25% del lienzo, al bajar a 64px queda diminuto y
|
||||
tosco (pocos pixeles para el detalle). Esta funcion calcula el bounding box del
|
||||
contenido, recorta a ese bbox, anade un margen relativo y, opcionalmente, rellena a
|
||||
cuadrado sin deformar para que el sujeto llene el frame.
|
||||
|
||||
Como detecta el contenido:
|
||||
- Si la imagen tiene canal alpha (RGBA / LA / P con transparencia): el bbox es la
|
||||
region con `alpha > alpha_threshold` (lo opaco es el sujeto, lo transparente es
|
||||
fondo). Es el caso tras pasar la imagen por rembg.
|
||||
- Si no tiene alpha (RGB): el bbox es la region que difiere del color de fondo,
|
||||
estimado como la moda de los cuatro pixeles de esquina. Sirve para imagenes con
|
||||
fondo plano sin recortar todavia.
|
||||
|
||||
Relleno a cuadrado (`square=True`): el lado del lienzo final es `max(w, h) + 2*pad`
|
||||
y el sujeto se centra. El fondo del lienzo es transparente si la imagen tiene alpha,
|
||||
o el color de fondo estimado si es RGB. Asi no se deforma el sujeto.
|
||||
|
||||
Funcion pura: opera sobre el objeto PIL.Image y devuelve uno nuevo; no toca disco ni
|
||||
red y no muta la imagen de entrada. Si no encuentra contenido (lienzo vacio o todo
|
||||
transparente), devuelve una copia intacta de la entrada — nunca lanza por una imagen
|
||||
sin sujeto (contrato no-throw salvo `img` None).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
|
||||
|
||||
def _as_rgb_tuple(c) -> tuple:
|
||||
"""Normaliza un pixel (int de modo L, o tupla RGB/RGBA) a una 3-tupla RGB."""
|
||||
if isinstance(c, (tuple, list)):
|
||||
return tuple(int(x) for x in c[:3])
|
||||
return (int(c), int(c), int(c))
|
||||
|
||||
|
||||
def _corner_bg_color(img) -> tuple:
|
||||
"""Color de fondo estimado: la moda de los cuatro pixeles de esquina (RGB)."""
|
||||
rgb = img.convert("RGB")
|
||||
w, h = rgb.size
|
||||
corners = [
|
||||
rgb.getpixel((0, 0)),
|
||||
rgb.getpixel((w - 1, 0)),
|
||||
rgb.getpixel((0, h - 1)),
|
||||
rgb.getpixel((w - 1, h - 1)),
|
||||
]
|
||||
corners = [_as_rgb_tuple(c) for c in corners]
|
||||
return Counter(corners).most_common(1)[0][0]
|
||||
|
||||
|
||||
def _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 _content_bbox(img, alpha_threshold: int, bg_tolerance: int):
|
||||
"""Devuelve (l, t, r, b) del contenido o None si no hay.
|
||||
|
||||
Por alpha si la imagen lo tiene; si no, por diferencia contra el color de fondo
|
||||
de las esquinas con tolerancia `bg_tolerance`.
|
||||
"""
|
||||
from PIL import Image, ImageChops
|
||||
|
||||
if _has_alpha(img):
|
||||
alpha = img.convert("RGBA").getchannel("A")
|
||||
mask = alpha.point(lambda p: 255 if p > alpha_threshold else 0)
|
||||
return mask.getbbox()
|
||||
|
||||
rgb = img.convert("RGB")
|
||||
bg = Image.new("RGB", rgb.size, _corner_bg_color(rgb))
|
||||
diff = ImageChops.difference(rgb, bg).convert("L")
|
||||
mask = diff.point(lambda p: 255 if p > bg_tolerance else 0)
|
||||
return mask.getbbox()
|
||||
|
||||
|
||||
def crop_to_content(
|
||||
img,
|
||||
*,
|
||||
pad_ratio: float = 0.06,
|
||||
square: bool = True,
|
||||
alpha_threshold: int = 10,
|
||||
bg_tolerance: int = 16,
|
||||
):
|
||||
"""Recorta una imagen PIL al bbox de su contenido, con margen y cuadrado opcional.
|
||||
|
||||
Args:
|
||||
img: PIL.Image de entrada (cualquier modo). No se muta.
|
||||
pad_ratio: margen anadido alrededor del sujeto como fraccion del lado mayor
|
||||
del bbox recortado (0.06 = 6%). 0 = sin margen. keyword-only.
|
||||
square: si True rellena a un lienzo cuadrado de lado `max(w,h)+2*pad` con el
|
||||
sujeto centrado (fondo transparente si hay alpha, color de fondo si RGB);
|
||||
si False solo recorta al bbox + margen sin cuadrar. keyword-only.
|
||||
alpha_threshold: umbral de alpha (0..255) para considerar un pixel "contenido"
|
||||
cuando la imagen tiene canal alpha. keyword-only.
|
||||
bg_tolerance: tolerancia (0..255) de diferencia contra el color de fondo de
|
||||
las esquinas para imagenes sin alpha (RGB). keyword-only.
|
||||
|
||||
Returns:
|
||||
PIL.Image nueva recortada (y cuadrada si square). Si la imagen no tiene
|
||||
contenido detectable (todo transparente o todo del color de fondo), devuelve
|
||||
una copia intacta de la entrada.
|
||||
|
||||
Raises:
|
||||
ValueError: si img es None.
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
if img is None:
|
||||
raise ValueError("crop_to_content: img es None")
|
||||
|
||||
bbox = _content_bbox(img, int(alpha_threshold), int(bg_tolerance))
|
||||
if bbox is None:
|
||||
return img.copy()
|
||||
|
||||
left, top, right, bottom = bbox
|
||||
cropped = img.crop((left, top, right, bottom))
|
||||
cw, ch = cropped.size
|
||||
pad = int(round(max(cw, ch) * float(pad_ratio)))
|
||||
has_alpha = _has_alpha(img)
|
||||
|
||||
if has_alpha:
|
||||
base = cropped.convert("RGBA")
|
||||
bg_fill = (0, 0, 0, 0)
|
||||
mode = "RGBA"
|
||||
else:
|
||||
base = cropped.convert("RGB")
|
||||
bg_fill = _corner_bg_color(img)
|
||||
mode = "RGB"
|
||||
|
||||
if square:
|
||||
side = max(cw, ch) + 2 * pad
|
||||
canvas = Image.new(mode, (side, side), bg_fill)
|
||||
ox = (side - cw) // 2
|
||||
oy = (side - ch) // 2
|
||||
else:
|
||||
if pad <= 0:
|
||||
return base
|
||||
canvas = Image.new(mode, (cw + 2 * pad, ch + 2 * pad), bg_fill)
|
||||
ox = oy = pad
|
||||
|
||||
if has_alpha:
|
||||
canvas.paste(base, (ox, oy), base) # usa el alpha del sujeto como mascara
|
||||
else:
|
||||
canvas.paste(base, (ox, oy))
|
||||
return canvas
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from PIL import Image
|
||||
|
||||
if len(sys.argv) < 3:
|
||||
print("uso: crop_to_content.py <src> <dst> [pad_ratio]", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
src, dst = sys.argv[1], sys.argv[2]
|
||||
pr = float(sys.argv[3]) if len(sys.argv) > 3 else 0.06
|
||||
with Image.open(src) as im:
|
||||
out = crop_to_content(im, pad_ratio=pr)
|
||||
out.save(dst)
|
||||
print(f"ok: {src} -> {dst} {out.size} {out.mode}")
|
||||
Reference in New Issue
Block a user