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
@@ -47,6 +47,7 @@ from ml.comfyui_fetch_output_image import comfyui_fetch_output_image
from ml.comfyui_pixelize_image import comfyui_pixelize_image
from ml.comfyui_submit_workflow import comfyui_submit_workflow
from ml.comfyui_wait_result import comfyui_wait_result
from ml.crop_to_content import crop_to_content
from ml.pixeloe_downscale import pixeloe_downscale
# Sufijo de encuadre: empuja al sujeto a llenar el frame para que tras el
@@ -80,6 +81,10 @@ def comfyui_pixelart_real_oneshot(
patch_size: int = 16,
thickness: int = 2,
fill_frame: bool = True,
transparent: bool = True,
autocrop: bool = True,
crop_pad_ratio: float = 0.06,
rembg_model: str = "u2net",
upscale_preview: int = 512,
keep_base: bool = True,
comfy_python: str | None = None,
@@ -118,6 +123,18 @@ def comfyui_pixelart_real_oneshot(
fill_frame: si True, anade un hint de encuadre al subject para que el
sujeto llene el frame (mejor detalle por pixel tras el downscale).
keyword-only.
transparent: si True (default) genera con fondo recortado (rembg en el
workflow) y produce un sprite RGBA con transparencia real. Para
tiles/texturas que NO quieren alpha, pasar transparent=False (el sprite
sale RGB sobre fondo opaco). keyword-only.
autocrop: si True (default) recorta la imagen base al bounding box de su
contenido y la cuadra antes del downscale, para que el sujeto llene el
frame (evita el sprite diminuto). Usa el alpha si transparent, o el color
de fondo si no. keyword-only.
crop_pad_ratio: margen relativo que deja el autocrop alrededor del sujeto
(0.06 = 6% del lado). keyword-only.
rembg_model: modelo Rembg para recortar el fondo ('u2net' general,
'isnet-anime' para anime). Solo aplica si transparent. keyword-only.
upscale_preview: si > 0, escribe ademas un PNG re-escalado nearest a
ese lado (preview con pixeles duros, p.ej. 512). 0 lo desactiva.
keyword-only.
@@ -137,14 +154,18 @@ def comfyui_pixelart_real_oneshot(
- out_path_upscaled (str): ruta del preview re-escalado, o "" si off.
- base_path (str): ruta del PNG base de alta resolucion (o "" si se borro).
- size (int): lado real del PNG final.
- colors_final (int): numero de colores distintos en el resultado.
- colors_final (int): numero de colores distintos en el resultado (en la
zona opaca si es RGBA).
- engine_used (str): "pixeloe" o "nearest" (refleja el fallback).
- has_alpha (bool): True si el PNG final es RGBA con transparencia.
- autocrop_applied (bool): True si el autocrop recorto la imagen base.
- prompt_id (str): id del trabajo en ComfyUI.
- error (str): mensaje de error; vacio si OK.
"""
out = {
"ok": False, "out_path": "", "out_path_upscaled": "", "base_path": "",
"size": int(size), "colors_final": 0, "engine_used": engine,
"has_alpha": False, "autocrop_applied": False,
"prompt_id": "", "error": "",
}
@@ -170,12 +191,14 @@ def comfyui_pixelart_real_oneshot(
try:
if negative is None:
workflow = comfyui_build_pixelart_workflow(
positive, seed=seed, filename_prefix=f"{filename_prefix}_base",
**gen_kwargs,
positive, seed=seed, transparent=bool(transparent),
rembg_model=rembg_model,
filename_prefix=f"{filename_prefix}_base", **gen_kwargs,
)
else:
workflow = comfyui_build_pixelart_workflow(
positive, negative, seed=seed,
positive, negative, seed=seed, transparent=bool(transparent),
rembg_model=rembg_model,
filename_prefix=f"{filename_prefix}_base", **gen_kwargs,
)
except (ValueError, TypeError) as exc:
@@ -216,13 +239,37 @@ def comfyui_pixelart_real_oneshot(
base_path = fetched["path"]
out["base_path"] = base_path
# --- Fase 1b (opcional): autocrop al contenido + cuadrar (sujeto llena el frame). ---
# La imagen sobre la que se hace el downscale: la recortada si autocrop, o la base.
pre_ds_path = base_path
crop_path = ""
if autocrop:
crop_path = os.path.join(dest, f"{filename_prefix}_{size}px_crop.png")
try:
from PIL import Image
with Image.open(base_path) as base_im:
src_im = base_im.convert("RGBA") if transparent else base_im.convert("RGB")
before = src_im.size
cropped = crop_to_content(
src_im, pad_ratio=float(crop_pad_ratio), square=True,
)
cropped.save(crop_path)
pre_ds_path = crop_path
out["autocrop_applied"] = cropped.size != before
except (ImportError, OSError, ValueError) as exc:
# Autocrop es best-effort: si falla, se sigue con la base sin recortar.
crop_path = ""
pre_ds_path = base_path
if not out["error"]:
out["error"] = f"autocrop fallo (no critico): {exc}"
# --- Fase 2a: downscale a un grid `size` x `size` (mid). ---
mid_path = os.path.join(dest, f"{filename_prefix}_{size}px_mid.png")
engine_used = engine
if engine == "pixeloe":
ds = pixeloe_downscale(
base_path, mid_path, mode=mode, target_size=int(size),
pre_ds_path, mid_path, mode=mode, target_size=int(size),
patch_size=patch_size, thickness=thickness, no_upscale=True,
comfy_python=comfy_python,
)
@@ -235,10 +282,14 @@ def comfyui_pixelart_real_oneshot(
if engine_used == "nearest":
# Downscale nearest simple a size x size (PIL en el venv del registry).
# nearest preserva el alpha por canal: si transparent, conserva la silueta.
try:
from PIL import Image
with Image.open(base_path) as src:
small = src.convert("RGB").resize((int(size), int(size)), Image.NEAREST)
with Image.open(pre_ds_path) as src:
target_mode = "RGBA" if transparent else "RGB"
small = src.convert(target_mode).resize(
(int(size), int(size)), Image.NEAREST
)
small.save(mid_path)
except (ImportError, OSError) as exc:
out["error"] = f"downscale nearest fallo: {exc}"
@@ -248,6 +299,25 @@ def comfyui_pixelart_real_oneshot(
out["error"] = "no se genero la imagen intermedia (mid)"
return out
# --- Fase 2a-bis: recombinar alpha tras pixeloe (PixelOE trabaja en RGB). ---
# El nucleo de PixelOE convierte a RGB: el grid `mid` sale sin transparencia. Se
# downscalea el alpha de la imagen pre-downscale por separado (nearest al mismo
# size) y se reaplica al grid para no perder el recorte ni la transparencia.
if transparent and engine_used == "pixeloe":
try:
from PIL import Image
with Image.open(pre_ds_path) as src_im:
alpha = src_im.convert("RGBA").getchannel("A").resize(
(int(size), int(size)), Image.NEAREST
)
with Image.open(mid_path) as mid_im:
mid_rgba = mid_im.convert("RGBA")
mid_rgba.putalpha(alpha)
mid_rgba.save(mid_path)
except (ImportError, OSError) as exc:
if not out["error"]:
out["error"] = f"recombinacion de alpha fallo (no critico): {exc}"
# --- Fase 2b: cuantizacion dura (paleta exacta) sobre el grid ya hecho. ---
final_tag = palette if isinstance(palette, str) else f"q{colors}"
final_path = os.path.join(
@@ -255,7 +325,7 @@ def comfyui_pixelart_real_oneshot(
)
quant = comfyui_pixelize_image(
mid_path, final_path, downscale=1, colors=int(colors),
palette=palette, upscale_back=False,
palette=palette, upscale_back=False, keep_alpha=bool(transparent),
)
if not quant.get("ok"):
out["error"] = f"cuantizacion fallo: {quant.get('error')}"
@@ -264,6 +334,7 @@ def comfyui_pixelart_real_oneshot(
out["out_path"] = final_path
out["size"] = quant["size"][0] if quant.get("size") else int(size)
out["colors_final"] = quant.get("n_colors_final", 0)
out["has_alpha"] = bool(quant.get("has_alpha", False))
out["engine_used"] = engine_used
# --- Fase 3 (opcional): preview re-escalado nearest a pixeles duros. ---
@@ -274,7 +345,8 @@ def comfyui_pixelart_real_oneshot(
try:
from PIL import Image
with Image.open(final_path) as fin:
up = fin.convert("RGB").resize(
prev_mode = "RGBA" if transparent else "RGB"
up = fin.convert(prev_mode).resize(
(int(upscale_preview), int(upscale_preview)), Image.NEAREST
)
up.save(up_path)
@@ -285,11 +357,13 @@ def comfyui_pixelart_real_oneshot(
if not out["error"]:
out["error"] = f"preview upscale fallo (no critico): {exc}"
# Limpieza opcional de la base y del intermedio.
try:
os.remove(mid_path)
except OSError:
pass
# Limpieza de intermedios (mid + crop temporal).
for tmp in (mid_path, crop_path):
if tmp:
try:
os.remove(tmp)
except OSError:
pass
if not keep_base:
try:
os.remove(base_path)
@@ -305,8 +379,9 @@ if __name__ == "__main__":
import json
res = comfyui_pixelart_real_oneshot(
"pixel art knight, full body, side view, game sprite",
"pixel art knight, full body, centered, game sprite",
size=64, colors=16, engine="pixeloe", seed=42,
transparent=True, autocrop=True,
dest_dir="/tmp/comfy_pixelart_real",
)
print(json.dumps(res, indent=2))