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,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)
|
||||
Reference in New Issue
Block a user