Spaces:
Running
Running
| """Unit tests for the bound functions β no network, runs in a second. | |
| python apps/05_workflow1111/test_nodes.py | |
| """ | |
| import base64 | |
| import io | |
| import os | |
| import sys | |
| import tempfile | |
| import traceback | |
| from PIL import Image | |
| # Windows consoles default to cp1252 and would die on the box-drawing output. | |
| for _stream in (sys.stdout, sys.stderr): | |
| try: | |
| _stream.reconfigure(encoding="utf-8", errors="replace") | |
| except (AttributeError, ValueError): | |
| pass | |
| sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) | |
| import nodes as N # noqa: E402 | |
| PASS = FAIL = 0 | |
| FAILURES = [] | |
| def check(name, fn): | |
| global PASS, FAIL | |
| try: | |
| fn() | |
| except Exception as e: | |
| FAIL += 1 | |
| FAILURES.append((name, traceback.format_exc())) | |
| print(f" FAIL {name}: {type(e).__name__}: {e}") | |
| else: | |
| PASS += 1 | |
| print(f" ok {name}") | |
| def sample(w=256, h=192, mode="RGB"): | |
| img = Image.new(mode, (w, h), (40, 90, 160) if mode == "RGB" else 128) | |
| d = __import__("PIL.ImageDraw", fromlist=["ImageDraw"]).Draw(img) | |
| d.ellipse([w // 5, h // 5, w * 4 // 5, h * 4 // 5], fill=(230, 200, 60)) | |
| d.rectangle([10, 10, w // 3, h // 3], fill=(20, 20, 20)) | |
| return img | |
| def as_data_uri(img, fmt="PNG"): | |
| buf = io.BytesIO() | |
| img.save(buf, format=fmt) | |
| mime = "image/png" if fmt == "PNG" else "image/jpeg" | |
| return f"data:{mime};base64," + base64.b64encode(buf.getvalue()).decode() | |
| def is_img(v): | |
| """An image port value must carry BOTH a readable file (for the REST API / | |
| gradio Image component) and a data URI (for the canvas and model nodes).""" | |
| assert isinstance(v, dict), f"image output must be a dict, got {type(v).__name__}: {str(v)[:70]}" | |
| assert set(v) >= {"path", "url"}, f"missing keys: {sorted(v)}" | |
| assert os.path.isfile(v["path"]), f"path does not exist: {v['path']}" | |
| Image.open(v["path"]).load() | |
| assert v["url"].startswith("data:image/"), f"url is not a data URI: {v['url'][:60]}" | |
| Image.open(io.BytesIO(base64.b64decode(v["url"].partition(",")[2]))).load() | |
| return v | |
| TMP = tempfile.gettempdir() | |
| img_path = os.path.join(TMP, "wf1111_test_src.png") | |
| sample().save(img_path) | |
| DATA_URI = as_data_uri(sample()) | |
| print("\nββ image loading βββββββββββββββββββββββββββββββββββββββββββββ") | |
| check("load from data URI", lambda: N._load_image(DATA_URI).size) | |
| check("load from plain path", lambda: N._load_image(img_path).size) | |
| check("load from {'path'} dict", lambda: N._load_image({"path": img_path}).size) | |
| check("load from model-node dict (url wins)", | |
| lambda: N._load_image({"path": img_path, "url": f"/gradio_api/file={img_path}", | |
| "is_file": True}).size) | |
| check("load from /gradio_api/file= string", | |
| lambda: N._load_image(f"/gradio_api/file={img_path}").size) | |
| check("load from ImageSlider-style list", | |
| lambda: N._load_image([{"path": img_path}, {"path": img_path}]).size) | |
| check("load from PIL Image", lambda: N._load_image(sample()).size) | |
| def _rejects_empty(): | |
| for bad in (None, "", {}): | |
| try: | |
| N._load_image(bad) | |
| except ValueError: | |
| continue | |
| raise AssertionError(f"should have rejected {bad!r}") | |
| check("rejects empty input", _rejects_empty) | |
| print("\nββ coercion ββββββββββββββββββββββββββββββββββββββββββββββββββ") | |
| check("_num clamps + coerces strings", | |
| lambda: (N._num("7.5", 1, 1, 5) == 5.0 and N._num("junk", 3) == 3.0 | |
| and N._num(None, 2, integer=True) == 2 and N._num(True, 0) == 1.0) | |
| or (_ for _ in ()).throw(AssertionError("bad _num"))) | |
| check("_flag parses truthy words", | |
| lambda: (N._flag("yes") and N._flag(True) and not N._flag("off") | |
| and N._flag("", True)) | |
| or (_ for _ in ()).throw(AssertionError("bad _flag"))) | |
| check("_choice is lenient", | |
| lambda: (N._choice("photorealistic", list(N.STYLE_PRESETS), "None") == "Photorealistic" | |
| and N._choice("", list(N.STYLE_PRESETS), "None") == "None" | |
| and N._choice("zzz", list(N.STYLE_PRESETS), "None") == "None") | |
| or (_ for _ in ()).throw(AssertionError("bad _choice"))) | |
| check("_as_list handles json strings", | |
| lambda: (N._as_list('[{"a":1}]') == [{"a": 1}] and N._as_list(None) == [] | |
| and N._as_list("garbage") == []) | |
| or (_ for _ in ()).throw(AssertionError("bad _as_list"))) | |
| print("\nββ prompt / sampler ββββββββββββββββββββββββββββββββββββββββββ") | |
| def t_style(): | |
| out = N.apply_style("a fox", "Cinematic", "golden hour", True) | |
| assert "a fox" in out and "cinematic film still" in out | |
| assert out.count("masterpiece") == 1 | |
| # dedupe across sources | |
| dup = N.apply_style("sharp focus", "Photorealistic", "sharp focus", True) | |
| assert dup.lower().count("sharp focus") == 1, dup | |
| check("apply_style composes + dedupes", t_style) | |
| def t_style_blank(): | |
| try: | |
| N.apply_style(" ", "None", "", False) | |
| except ValueError: | |
| return | |
| raise AssertionError("blank prompt should raise") | |
| check("apply_style raises on blank", t_style_blank) | |
| def t_neg(): | |
| out = N.build_negative("ugly", "Anime", True, True) | |
| assert "ugly" in out and "lowres" in out and "photorealistic" in out and "nsfw" in out | |
| bare = N.build_negative("ugly", "None", False, False) | |
| assert bare == "ugly", bare | |
| check("build_negative layers correctly", t_neg) | |
| def t_sampler(): | |
| steps, cfg, seed, w, h = N.sampler_settings(4, 1.0, 42, "Custom", 1000, 1000) | |
| assert (steps, cfg, seed) == (4, 1.0, 42) | |
| assert w % 16 == 0 and h % 16 == 0, (w, h) | |
| # cfg is clamped to >= 1.0 β fal-ai 422s below that | |
| assert N.sampler_settings(4, 0.0, 1, "Custom", 512, 512)[1] == 1.0 | |
| assert N.sampler_settings(999, 99, 1, "Custom", 9999, 9999)[0] == 50 | |
| # aspect preset overrides w/h | |
| assert N.sampler_settings(4, 1, 1, "16:9 Widescreen", 512, 512)[3:] == (1344, 768) | |
| # -1 randomizes into range | |
| s1 = N.sampler_settings(4, 1, -1, "Custom", 512, 512)[2] | |
| assert 0 <= s1 <= 2**31 - 1 | |
| # blank/garbage seed falls back to the default (-1 β random), never crashes | |
| assert 0 <= N.sampler_settings(4, 1, "", "Custom", 512, 512)[2] <= 2**31 - 1 | |
| check("sampler_settings clamps/validates", t_sampler) | |
| def t_info(): | |
| txt = N.generation_info("a fox", "ugly", 4, 1.0, 42, 1024, 768, "m/x") | |
| assert txt.startswith("a fox") | |
| assert "Negative prompt: ugly" in txt | |
| assert "Seed: 42" in txt and "Size: 1024x768" in txt | |
| check("generation_info matches A1111 format", t_info) | |
| def t_matrix(): | |
| p1, p2, p3, p4, labels = N.prompt_matrix("a cat", "in snow|on mars|at night|in a forest", "8k") | |
| assert p1.startswith("a cat") and "in snow" in p1 and "8k" in p1 | |
| assert "on mars" in p2 and "at night" in p3 and "in a forest" in p4 | |
| assert labels.count("|") == 3 | |
| # fewer than four variants still fills four slots | |
| r = N.prompt_matrix("a cat", "red", "") | |
| assert all(isinstance(x, str) and x for x in r[:4]) | |
| check("prompt_matrix expands to 4", t_matrix) | |
| def t_clean(): | |
| messy = ('Sure! Here is your prompt:\n\n"a fox, golden hour, cinematic, bokeh"\n\n' | |
| "Let me know if you want changes.") | |
| out = N.clean_prompt(messy, 40) | |
| assert out.startswith("a fox"), out | |
| assert "Sure" not in out and '"' not in out and "Let me know" not in out | |
| assert len(N.clean_prompt("a, b, c, d, e, f", 3).split(",")) == 3 | |
| assert N.clean_prompt("<think>hmm</think>\na fox, bokeh", 40).startswith("a fox") | |
| check("clean_prompt strips LLM chatter", t_clean) | |
| def t_labels(): | |
| text, rows_json = N.top_labels( | |
| [{"label": "tiger", "score": 0.88}, {"label": "cat", "score": 0.10}, | |
| {"label": "dog", "score": 0.001}], 2, 0.05) | |
| # second output is JSON *text* β a json port arrives as "[object Object]" | |
| rows = __import__("json").loads(rows_json) | |
| assert rows[0]["label"] == "tiger" and len(rows) == 2 | |
| # and it must survive a round trip through a text port | |
| assert N.top_labels(rows_json, 2, 0.05)[0].startswith("tiger") | |
| assert "tiger" in text and "%" in text | |
| empty, _ = N.top_labels([], 5, 0.5) | |
| assert "No labels" in empty | |
| check("top_labels formats + filters", t_labels) | |
| print("\nββ image operators βββββββββββββββββββββββββββββββββββββββββββ") | |
| check("postprocess full chain", | |
| lambda: is_img(N.postprocess(DATA_URI, 1.5, "Lanczos", 0.6, 1.2, 1.1, 1.0, | |
| 0.35, 0.15, 0.02, "Β© test", ""))) | |
| check("postprocess neutral settings", | |
| lambda: is_img(N.postprocess(DATA_URI, 1, "Lanczos", 0, 1, 1, 1, 0, 0, 0, "", ""))) | |
| check("prep_image: Fit", lambda: is_img(N.prep_image(DATA_URI, 512, "Fit", True))) | |
| check("prep_image: Pad to square", | |
| lambda: is_img(N.prep_image(DATA_URI, 384, "Pad to square", True))) | |
| check("prep_image: Cover", lambda: is_img(N.prep_image(DATA_URI, 384, "Cover (crop)", True))) | |
| check("prep_image: Stretch", lambda: is_img(N.prep_image(DATA_URI, 384, "Stretch", False))) | |
| def t_prep_square(): | |
| out = N.prep_image(DATA_URI, 320, "Pad to square", True) | |
| img = N._load_image(out) | |
| assert img.size == (320, 320), img.size | |
| check("prep_image pads to exact square", t_prep_square) | |
| def t_upscale(): | |
| out, report = N.extras_upscale(DATA_URI, 2.0, "Lanczos", 0.5, True, True) | |
| is_img(out) | |
| img = N._load_image(out) | |
| assert img.size == (512, 384), img.size | |
| assert "512Γ384" in report and "Lanczos" in report | |
| check("extras_upscale doubles + reports", t_upscale) | |
| for mode in N.CONTROL_MODES: | |
| check(f"controlnet: {mode}", | |
| lambda m=mode: is_img(N.controlnet_preprocess(DATA_URI, m, 60, 160, False, 0))) | |
| check("controlnet: inverted canny", | |
| lambda: is_img(N.controlnet_preprocess(DATA_URI, "Canny edges", 40, 120, True, 1.0))) | |
| def t_canny_real(): | |
| """Canny must actually find the disc/rect edges β and nothing in flat areas.""" | |
| out = N.controlnet_preprocess(DATA_URI, "Canny edges", 50, 140, False, 0) | |
| arr = __import__("numpy").asarray(N._load_image(out).convert("L")) | |
| white = (arr > 200).mean() | |
| assert 0.001 < white < 0.30, f"implausible edge density {white:.4f}" | |
| flat = N.controlnet_preprocess(as_data_uri(Image.new("RGB", (128, 128), (90, 90, 90))), | |
| "Canny edges", 50, 140, False, 0) | |
| flat_arr = __import__("numpy").asarray(N._load_image(flat).convert("L")) | |
| assert (flat_arr > 200).mean() < 0.01, "flat image should yield no edges" | |
| check("canny finds real edges, not noise", t_canny_real) | |
| DETS = [ | |
| {"box": {"xmin": 20, "ymin": 20, "xmax": 120, "ymax": 120}, "label": "cat", "score": 0.93}, | |
| {"box": {"xmin": 60, "ymin": 40, "xmax": 200, "ymax": 150}, "label": "dog", "score": 0.61}, | |
| {"box": {"xmin": 0, "ymin": 0, "xmax": 30, "ymax": 30}, "label": "bird", "score": 0.11}, | |
| ] | |
| check("draw_detections", lambda: is_img(N.draw_detections(DATA_URI, DETS, 0.5, True)[0])) | |
| check("draw_detections from JSON string", | |
| lambda: is_img(N.draw_detections(DATA_URI, __import__("json").dumps(DETS), 0.5, True)[0])) | |
| def t_det_summary(): | |
| _, summary = N.draw_detections(DATA_URI, DETS, 0.5, True) | |
| assert "2 object(s)" in summary, summary | |
| _, none = N.draw_detections(DATA_URI, DETS, 0.99, True) | |
| assert "No objects" in none | |
| check("draw_detections summary counts", t_det_summary) | |
| check("mask_from_detections", lambda: is_img(N.mask_from_detections( | |
| DATA_URI, DETS, "", 0.5, 6, False, False))) | |
| check("mask filtered by label", lambda: is_img(N.mask_from_detections( | |
| DATA_URI, DETS, "cat", 0.5, 4, False, False))) | |
| check("mask preview overlay", lambda: is_img(N.mask_from_detections( | |
| DATA_URI, DETS, "", 0.5, 4, False, True))) | |
| def t_mask_white(): | |
| out = N.mask_from_detections(DATA_URI, DETS, "cat", 0.5, 0, False, False) | |
| arr = __import__("numpy").asarray(N._load_image(out).convert("L")) | |
| assert arr[70, 70] > 200, "inside the cat box should be white" | |
| assert arr[180, 240] < 60, "outside every box should be black" | |
| check("mask geometry is correct", t_mask_white) | |
| def t_mask_nohit(): | |
| try: | |
| N.mask_from_detections(DATA_URI, DETS, "elephant", 0.5, 4, False, False) | |
| except ValueError: | |
| return | |
| raise AssertionError("unmatched label filter should raise") | |
| check("mask raises when nothing matches", t_mask_nohit) | |
| check("contact_sheet 4-up", lambda: is_img(N.contact_sheet( | |
| DATA_URI, DATA_URI, DATA_URI, DATA_URI, "a|b|c|d", 2, 14, "Prompt matrix"))) | |
| check("contact_sheet tolerates gaps", lambda: is_img(N.contact_sheet( | |
| DATA_URI, None, "", DATA_URI, "a|d", 2, 10, ""))) | |
| check("contact_sheet single column", lambda: is_img(N.contact_sheet( | |
| DATA_URI, None, None, None, "solo", 1, 0, "One"))) | |
| def t_sheet_empty(): | |
| try: | |
| N.contact_sheet(None, None, "", None, "", 2, 10, "") | |
| except ValueError: | |
| return | |
| raise AssertionError("empty contact sheet should raise") | |
| check("contact_sheet raises when empty", t_sheet_empty) | |
| print("\nββ PNG info round trip βββββββββββββββββββββββββββββββββββββββ") | |
| def t_png_roundtrip(): | |
| info = N.generation_info("a fox in snow", "ugly, blurry", 8, 3.5, 12345, | |
| 1024, 768, "black-forest-labs/FLUX.1-schnell") | |
| stamped = N.postprocess(DATA_URI, 1, "Lanczos", 0, 1, 1, 1, 0, 0, 0, "", info) | |
| assert stamped["url"].startswith("data:image/png"), "metadata must force PNG" | |
| report, fields_json = N.png_info(stamped) | |
| fields = __import__("json").loads(fields_json) | |
| assert "Generation parameters" in report | |
| assert fields["prompt"] == "a fox in snow", fields.get("prompt") | |
| assert fields["negative_prompt"] == "ugly, blurry", fields.get("negative_prompt") | |
| assert str(fields.get("seed")) == "12345", fields.get("seed") | |
| assert fields.get("size") == "1024x768", fields.get("size") | |
| assert str(fields.get("cfg_scale")) == "3.5", fields.get("cfg_scale") | |
| assert str(fields.get("steps")) == "8", fields.get("steps") | |
| check("generation params survive the round trip", t_png_roundtrip) | |
| def t_png_bare(): | |
| report, fields_json = N.png_info(DATA_URI) | |
| fields = __import__("json").loads(fields_json) | |
| assert "No generation parameters" in report | |
| assert fields["width"] == 256 and fields["height"] == 192 | |
| check("png_info on a bare image", t_png_bare) | |
| print("\nββ output sizing βββββββββββββββββββββββββββββββββββββββββββββ") | |
| def t_jpeg_switch(): | |
| big = Image.new("RGB", (2000, 1400), (100, 120, 140)) | |
| assert N._emit(big)["url"].startswith("data:image/jpeg"), "large images should be JPEG" | |
| small = Image.new("RGB", (400, 400), (100, 120, 140)) | |
| assert N._emit(small)["url"].startswith("data:image/png") | |
| alpha = Image.new("RGBA", (2000, 1400), (100, 120, 140, 128)) | |
| assert N._emit(alpha)["url"].startswith("data:image/png"), "alpha must stay PNG" | |
| assert N._emit_uri(small).startswith("data:image/png") | |
| check("emit picks PNG/JPEG sensibly", t_jpeg_switch) | |
| def t_bind_complete(): | |
| import inspect | |
| for name, fn in N.BIND.items(): | |
| assert callable(fn), name | |
| assert getattr(fn, "__name__", None) == name, f"{name} bound to {fn}" | |
| assert not str(inspect.signature(fn)).startswith("(self"), name | |
| check("BIND keys match function names", t_bind_complete) | |
| print("\n" + "=" * 62) | |
| print(f" {PASS} passed, {FAIL} failed") | |
| print("=" * 62) | |
| if FAILURES: | |
| for name, tb in FAILURES: | |
| print(f"\n--- {name} ---\n{tb}") | |
| sys.exit(1 if FAIL else 0) | |