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
+112
View File
@@ -0,0 +1,112 @@
"""Tests de crop_to_content (offline, sin red ni GPU; PIL/numpy)."""
import os
import sys
import numpy as np
from PIL import Image
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from ml.crop_to_content import crop_to_content # noqa: E402
def _rgba_subject_in_corner(canvas=256, box=40, ox=8, oy=8):
"""RGBA con un rectangulo opaco rojo en una esquina, resto transparente."""
arr = np.zeros((canvas, canvas, 4), dtype=np.uint8)
arr[oy:oy + box, ox:ox + box, 0] = 220 # R
arr[oy:oy + box, ox:ox + box, 3] = 255 # alpha opaco
return Image.fromarray(arr, "RGBA")
def _rgba_subject_centered(canvas=256, fill_ratio=0.9):
"""RGBA con un rectangulo opaco que llena ~fill_ratio del lienzo, centrado."""
arr = np.zeros((canvas, canvas, 4), dtype=np.uint8)
side = int(canvas * fill_ratio)
o = (canvas - side) // 2
arr[o:o + side, o:o + side, 1] = 200 # G
arr[o:o + side, o:o + side, 3] = 255
return Image.fromarray(arr, "RGBA")
def _rgb_subject_on_bg(canvas=200, box=50, ox=10, oy=10, bg=(255, 255, 255)):
"""RGB con un cuadrado de color sobre fondo plano (sin alpha)."""
arr = np.zeros((canvas, canvas, 3), dtype=np.uint8)
arr[:, :] = bg
arr[oy:oy + box, ox:ox + box] = (0, 0, 200) # sujeto azul
return Image.fromarray(arr, "RGB")
def _alpha_bbox_coverage(img, threshold=10):
"""Fraccion del lado que ocupa el bbox del contenido (alpha>threshold)."""
a = np.asarray(img.convert("RGBA"))[..., 3]
ys, xs = np.where(a > threshold)
if xs.size == 0:
return 0.0
bw = xs.max() - xs.min() + 1
bh = ys.max() - ys.min() + 1
return max(bw, bh) / max(img.size)
def test_golden_corner_subject_fills_frame():
"""Sujeto en la esquina -> tras crop ocupa casi todo el frame (square)."""
img = _rgba_subject_in_corner()
before = _alpha_bbox_coverage(img)
out = crop_to_content(img, pad_ratio=0.06, square=True)
after = _alpha_bbox_coverage(out)
assert out.mode == "RGBA"
assert out.size[0] == out.size[1] # cuadrado
assert before < 0.25 # antes diminuto
assert after >= 0.80 # despues llena el frame
def test_edge_centered_subject_not_overcropped():
"""Sujeto ya centrado que llena ~90%: la cobertura se mantiene alta, no se rompe."""
img = _rgba_subject_centered(fill_ratio=0.9)
out = crop_to_content(img, pad_ratio=0.06, square=True)
assert out.size[0] == out.size[1]
assert _alpha_bbox_coverage(out) >= 0.80
def test_edge_rgb_background_bbox():
"""RGB con fondo plano: detecta el sujeto por diff-fondo y lo cuadra."""
img = _rgb_subject_on_bg()
out = crop_to_content(img, pad_ratio=0.05, square=True)
assert out.mode == "RGB"
assert out.size[0] == out.size[1]
# El sujeto azul debe ocupar buena parte del lienzo recortado.
arr = np.asarray(out)
is_subject = (arr[..., 2] > 120) & (arr[..., 0] < 80)
cov = is_subject.sum() / (out.size[0] * out.size[1])
assert cov >= 0.4
def test_edge_no_square_only_crops():
"""square=False: recorta al bbox + margen, sin forzar cuadrado."""
img = _rgba_subject_in_corner(box=40)
out = crop_to_content(img, pad_ratio=0.0, square=False)
# bbox del sujeto es 40x40 -> sin pad ni cuadrar, sale 40x40.
assert out.size == (40, 40)
def test_error_all_transparent_returns_copy():
"""Imagen toda transparente: no crashea, devuelve copia intacta (mismo tamano)."""
arr = np.zeros((128, 128, 4), dtype=np.uint8) # alpha 0 en todo
img = Image.fromarray(arr, "RGBA")
out = crop_to_content(img)
assert out.size == (128, 128)
assert np.asarray(out)[..., 3].max() == 0
def test_error_none_raises():
try:
crop_to_content(None)
assert False, "deberia lanzar ValueError"
except ValueError as e:
assert "None" in str(e)
def test_does_not_mutate_input():
img = _rgba_subject_in_corner()
snapshot = np.asarray(img).copy()
crop_to_content(img)
assert np.array_equal(np.asarray(img), snapshot)