diff --git a/python/functions/ml/tests/test_comfyui_build_pixelart_workflow.py b/python/functions/ml/tests/test_comfyui_build_pixelart_workflow.py new file mode 100644 index 00000000..d5b88ebc --- /dev/null +++ b/python/functions/ml/tests/test_comfyui_build_pixelart_workflow.py @@ -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" diff --git a/python/functions/ml/tests/test_comfyui_pixelize_image.py b/python/functions/ml/tests/test_comfyui_pixelize_image.py new file mode 100644 index 00000000..d5de7853 --- /dev/null +++ b/python/functions/ml/tests/test_comfyui_pixelize_image.py @@ -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 diff --git a/python/functions/ml/tests/test_crop_to_content.py b/python/functions/ml/tests/test_crop_to_content.py new file mode 100644 index 00000000..d22ad6ff --- /dev/null +++ b/python/functions/ml/tests/test_crop_to_content.py @@ -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)