test(comfyui): reubicar tests del sprite-fix a tests/
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
"""Tests offline de comfyui_build_pixelart_workflow (sin red ni GPU; estructura del dict)."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from ml.comfyui_build_pixelart_workflow import comfyui_build_pixelart_workflow # noqa: E402
|
||||
|
||||
|
||||
def _classes(wf):
|
||||
return [n["class_type"] for n in wf.values()]
|
||||
|
||||
|
||||
def _ksampler(wf):
|
||||
return next(n for n in wf.values() if n["class_type"] == "KSampler")
|
||||
|
||||
|
||||
def test_golden_lcm_two_loras():
|
||||
wf = comfyui_build_pixelart_workflow("isometric house, pixel, 32x32 style", use_lcm=True)
|
||||
cls = _classes(wf)
|
||||
# Dos LoraLoader: SDXL_pixel-art + SDXL_lcm-lora.
|
||||
loras = [n for n in wf.values() if n["class_type"] == "LoraLoader"]
|
||||
assert len(loras) == 2
|
||||
names = {n["inputs"]["lora_name"] for n in loras}
|
||||
assert names == {"SDXL_pixel-art.safetensors", "SDXL_lcm-lora.safetensors"}
|
||||
px = next(n for n in loras if n["inputs"]["lora_name"] == "SDXL_pixel-art.safetensors")
|
||||
assert px["inputs"]["strength_model"] == 1.2
|
||||
# KSampler con defaults LCM.
|
||||
ks = _ksampler(wf)["inputs"]
|
||||
assert ks["steps"] == 8 and ks["cfg"] == 1.5
|
||||
assert ks["sampler_name"] == "lcm" and ks["scheduler"] == "sgm_uniform"
|
||||
assert "CheckpointLoaderSimple" in cls and "SaveImage" in cls
|
||||
|
||||
|
||||
def test_edge_no_lcm_single_lora():
|
||||
wf = comfyui_build_pixelart_workflow("a pixel sword", use_lcm=False)
|
||||
loras = [n for n in wf.values() if n["class_type"] == "LoraLoader"]
|
||||
assert len(loras) == 1
|
||||
assert loras[0]["inputs"]["lora_name"] == "SDXL_pixel-art.safetensors"
|
||||
ks = _ksampler(wf)["inputs"]
|
||||
assert ks["steps"] == 25 and ks["cfg"] == 7.0
|
||||
assert ks["sampler_name"] == "euler" and ks["scheduler"] == "normal"
|
||||
|
||||
|
||||
def test_edge_overrides_and_clamp():
|
||||
wf = comfyui_build_pixelart_workflow(
|
||||
"pixel knight", use_lcm=True, steps=12, cfg=2.0, lora_strength=5.0
|
||||
)
|
||||
ks = _ksampler(wf)["inputs"]
|
||||
assert ks["steps"] == 12 and ks["cfg"] == 2.0
|
||||
px = next(
|
||||
n for n in wf.values()
|
||||
if n["class_type"] == "LoraLoader" and n["inputs"]["lora_name"] == "SDXL_pixel-art.safetensors"
|
||||
)
|
||||
assert px["inputs"]["strength_model"] == 2.0 # clamp a [0,2]
|
||||
|
||||
|
||||
def test_error_empty_prompt():
|
||||
try:
|
||||
comfyui_build_pixelart_workflow(" ")
|
||||
assert False, "deberia lanzar ValueError"
|
||||
except ValueError as e:
|
||||
assert "positive" in str(e)
|
||||
|
||||
|
||||
def test_determinism():
|
||||
a = comfyui_build_pixelart_workflow("pixel cat", seed=3)
|
||||
b = comfyui_build_pixelart_workflow("pixel cat", seed=3)
|
||||
assert a == b
|
||||
|
||||
|
||||
def test_transparent_default_injects_rembg():
|
||||
"""transparent default True -> nodo Image Rembg y SaveImage repuntado a el."""
|
||||
wf = comfyui_build_pixelart_workflow("pixel knight, full body")
|
||||
rembg = [n for n in wf.values() if n["class_type"] == "Image Rembg (Remove Background)"]
|
||||
assert len(rembg) == 1
|
||||
assert rembg[0]["inputs"]["transparency"] is True
|
||||
assert rembg[0]["inputs"]["model"] == "u2net"
|
||||
# SaveImage debe leer de la salida del Rembg, no del VAEDecode.
|
||||
rembg_id = next(k for k, n in wf.items() if n["class_type"] == "Image Rembg (Remove Background)")
|
||||
save = next(n for n in wf.values() if n["class_type"] == "SaveImage")
|
||||
assert save["inputs"]["images"][0] == rembg_id
|
||||
|
||||
|
||||
def test_transparent_false_no_rembg():
|
||||
"""transparent=False -> sin nodo Rembg (tiles/fondos opacos)."""
|
||||
wf = comfyui_build_pixelart_workflow("seamless grass tile", transparent=False)
|
||||
rembg = [n for n in wf.values() if n["class_type"] == "Image Rembg (Remove Background)"]
|
||||
assert len(rembg) == 0
|
||||
# SaveImage lee directo del VAEDecode.
|
||||
vae_id = next(k for k, n in wf.items() if n["class_type"] == "VAEDecode")
|
||||
save = next(n for n in wf.values() if n["class_type"] == "SaveImage")
|
||||
assert save["inputs"]["images"][0] == vae_id
|
||||
|
||||
|
||||
def test_rembg_model_override():
|
||||
wf = comfyui_build_pixelart_workflow("anime hero", rembg_model="isnet-anime")
|
||||
rembg = next(n for n in wf.values() if n["class_type"] == "Image Rembg (Remove Background)")
|
||||
assert rembg["inputs"]["model"] == "isnet-anime"
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Tests de comfyui_pixelize_image (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.comfyui_pixelize_image import comfyui_pixelize_image # noqa: E402
|
||||
|
||||
|
||||
def _noisy_png(path, w=256, h=256):
|
||||
"""PNG ruidoso con cientos de colores (simula crudo borroso de IA)."""
|
||||
rng = np.random.default_rng(7)
|
||||
arr = rng.integers(0, 256, size=(h, w, 3), dtype=np.uint8)
|
||||
Image.fromarray(arr, "RGB").save(path)
|
||||
return path
|
||||
|
||||
|
||||
def test_golden_downscale_quantize(tmp_path):
|
||||
src = _noisy_png(str(tmp_path / "raw.png"))
|
||||
dst = str(tmp_path / "pixel.png")
|
||||
res = comfyui_pixelize_image(src, dst, downscale=8, colors=16)
|
||||
assert res["ok"] is True, res["error"]
|
||||
assert os.path.isfile(dst)
|
||||
assert res["size"] == [256, 256] # upscale_back=True conserva tamano
|
||||
assert res["n_colors_final"] <= 16 # cuantizado a <=16 colores
|
||||
|
||||
|
||||
def test_no_upscale_back_keeps_small(tmp_path):
|
||||
src = _noisy_png(str(tmp_path / "raw.png"))
|
||||
dst = str(tmp_path / "small.png")
|
||||
res = comfyui_pixelize_image(src, dst, downscale=8, colors=16, upscale_back=False)
|
||||
assert res["ok"] is True
|
||||
assert res["size"] == [32, 32] # 256//8
|
||||
|
||||
|
||||
def test_edge_fixed_palette_game_boy(tmp_path):
|
||||
src = _noisy_png(str(tmp_path / "raw.png"))
|
||||
dst = str(tmp_path / "gb.png")
|
||||
res = comfyui_pixelize_image(src, dst, palette="game-boy")
|
||||
assert res["ok"] is True, res["error"]
|
||||
assert res["n_colors_final"] <= 4 # paleta Game Boy = 4 colores
|
||||
|
||||
|
||||
def test_edge_palette_list_hex(tmp_path):
|
||||
src = _noisy_png(str(tmp_path / "raw.png"))
|
||||
dst = str(tmp_path / "pal.png")
|
||||
res = comfyui_pixelize_image(src, dst, palette=["#000000", "#ffffff", "#ff0000"])
|
||||
assert res["ok"] is True
|
||||
assert res["n_colors_final"] <= 3
|
||||
|
||||
|
||||
def test_edge_downscale_1_only_quantizes(tmp_path):
|
||||
src = _noisy_png(str(tmp_path / "raw.png"))
|
||||
dst = str(tmp_path / "q.png")
|
||||
res = comfyui_pixelize_image(src, dst, downscale=1, colors=8)
|
||||
assert res["ok"] is True
|
||||
assert res["size"] == [256, 256]
|
||||
assert res["n_colors_final"] <= 8
|
||||
|
||||
|
||||
def test_error_missing_src(tmp_path):
|
||||
res = comfyui_pixelize_image(str(tmp_path / "nope.png"), str(tmp_path / "o.png"))
|
||||
assert res["ok"] is False
|
||||
assert "no existe" in res["error"]
|
||||
|
||||
|
||||
def test_error_downscale_zero(tmp_path):
|
||||
src = _noisy_png(str(tmp_path / "raw.png"))
|
||||
res = comfyui_pixelize_image(src, str(tmp_path / "o.png"), downscale=0)
|
||||
assert res["ok"] is False
|
||||
assert "downscale" in res["error"]
|
||||
|
||||
|
||||
def test_error_bad_palette(tmp_path):
|
||||
src = _noisy_png(str(tmp_path / "raw.png"))
|
||||
res = comfyui_pixelize_image(src, str(tmp_path / "o.png"), palette="not-a-palette")
|
||||
assert res["ok"] is False
|
||||
assert "paleta" in res["error"].lower()
|
||||
|
||||
|
||||
# --- alpha-aware (sprites con fondo transparente) ---
|
||||
|
||||
def _rgba_subject_png(path, canvas=256, box=120):
|
||||
"""RGBA: sujeto opaco de colores variados centrado, fondo transparente."""
|
||||
rng = np.random.default_rng(3)
|
||||
arr = np.zeros((canvas, canvas, 4), dtype=np.uint8)
|
||||
o = (canvas - box) // 2
|
||||
arr[o:o + box, o:o + box, :3] = rng.integers(0, 256, size=(box, box, 3), dtype=np.uint8)
|
||||
arr[o:o + box, o:o + box, 3] = 255 # sujeto opaco
|
||||
Image.fromarray(arr, "RGBA").save(path)
|
||||
return path
|
||||
|
||||
|
||||
def test_alpha_preserved_transparent_corners(tmp_path):
|
||||
"""RGBA in -> RGBA out con esquinas transparentes y paleta limitada en lo opaco."""
|
||||
src = _rgba_subject_png(str(tmp_path / "sprite.png"))
|
||||
dst = str(tmp_path / "px.png")
|
||||
res = comfyui_pixelize_image(src, dst, downscale=4, colors=16, upscale_back=False)
|
||||
assert res["ok"] is True, res["error"]
|
||||
assert res["has_alpha"] is True
|
||||
out = Image.open(dst).convert("RGBA")
|
||||
a = np.asarray(out)[..., 3]
|
||||
w, h = out.size
|
||||
# Las 4 esquinas deben ser transparentes (alpha == 0).
|
||||
assert a[0, 0] == 0 and a[0, w - 1] == 0
|
||||
assert a[h - 1, 0] == 0 and a[h - 1, w - 1] == 0
|
||||
# Centro opaco.
|
||||
assert a[h // 2, w // 2] == 255
|
||||
# Colores limitados en la zona opaca.
|
||||
assert res["n_colors_final"] <= 16
|
||||
|
||||
|
||||
def test_alpha_off_flattens_to_rgb(tmp_path):
|
||||
"""keep_alpha=False sobre RGBA -> sale RGB (sin canal alpha)."""
|
||||
src = _rgba_subject_png(str(tmp_path / "sprite.png"))
|
||||
dst = str(tmp_path / "flat.png")
|
||||
res = comfyui_pixelize_image(src, dst, downscale=4, colors=16, keep_alpha=False)
|
||||
assert res["ok"] is True
|
||||
assert res["has_alpha"] is False
|
||||
assert Image.open(dst).mode != "RGBA"
|
||||
|
||||
|
||||
def test_rgb_input_unaffected_by_keep_alpha(tmp_path):
|
||||
"""Imagen RGB (sin alpha) con keep_alpha=True sigue saliendo RGB, sin romper."""
|
||||
src = _noisy_png(str(tmp_path / "raw.png"))
|
||||
dst = str(tmp_path / "rgb.png")
|
||||
res = comfyui_pixelize_image(src, dst, downscale=8, colors=16) # keep_alpha default True
|
||||
assert res["ok"] is True
|
||||
assert res["has_alpha"] is False
|
||||
assert res["n_colors_final"] <= 16
|
||||
|
||||
|
||||
def test_error_all_transparent_no_crash(tmp_path):
|
||||
"""RGBA toda transparente (rembg sin sujeto): no crashea, 0 colores opacos."""
|
||||
arr = np.zeros((64, 64, 4), dtype=np.uint8) # alpha 0 en todo
|
||||
src = str(tmp_path / "empty.png")
|
||||
Image.fromarray(arr, "RGBA").save(src)
|
||||
dst = str(tmp_path / "out.png")
|
||||
res = comfyui_pixelize_image(src, dst, downscale=1, colors=16)
|
||||
assert res["ok"] is True, res["error"]
|
||||
assert res["has_alpha"] is True
|
||||
assert res["n_colors_final"] == 0
|
||||
out = np.asarray(Image.open(dst).convert("RGBA"))
|
||||
assert out[..., 3].max() == 0 # sigue toda transparente
|
||||
@@ -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