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>
113 lines
4.0 KiB
Python
113 lines
4.0 KiB
Python
"""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)
|