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>
163 lines
5.9 KiB
Python
163 lines
5.9 KiB
Python
"""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}")
|