Outputs (was: only prompt):
- preview (IMAGE): boxes + numbered tags + desc/text rendered over the canvas
or the dimmed reference image.
- bboxes (BOUNDING_BOX): pixel-space, nested [[{x,y,width,height}]] for SAM3 /
crop / mask consumers.
- width / height (INT): resolved canvas size (passthrough).
Inputs:
- image (optional): reference image, dimmed behind the preview and shown as the
editor backdrop (via ui.bg_image on run).
- import_json (force_input): caption JSON loaded into the editor on run; output
always reflects the editor.
Optional style_description (v14): off by default; toggle in the editor with a
photo/art_style kind, aesthetics/lighting/medium and a #hex color_palette. Emitted
with the canonical key order; validation stays silent. Importing a caption with
style_description loads it back. Drops the old "legacy format" warning.
nodes.py refactored into pure helpers (_resolve_canvas, _pixel_bboxes, _elements)
+ a thin build(); torch/PIL/numpy imported lazily so the module/tests stay light.
Tests rewritten to cover the pure helpers (11 passing). Stays on the v1 node API
(search_aliases is v3-only and omitted). README + design doc updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
89 lines
3.5 KiB
Python
89 lines
3.5 KiB
Python
import json
|
|
import importlib.util
|
|
from pathlib import Path
|
|
|
|
# Load nodes.py directly without importing the package __init__ (which pulls in
|
|
# ComfyUI-only web glue). Heavy deps (torch/PIL) are lazy, so the module imports
|
|
# fine here; we only exercise the pure helpers, never the preview render.
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
spec = importlib.util.spec_from_file_location("ig4_nodes", ROOT / "nodes.py")
|
|
ig4 = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(ig4)
|
|
Node = ig4.Ideogram4BboxEditor
|
|
|
|
|
|
# ---- caption parsing -------------------------------------------------------
|
|
def test_parse_valid():
|
|
cap = {"high_level_description": "a cat",
|
|
"compositional_deconstruction": {"background": "sky", "elements": []}}
|
|
assert ig4._parse_caption(json.dumps(cap)) == cap
|
|
|
|
|
|
def test_parse_empty_and_garbage():
|
|
assert ig4._parse_caption("") == {}
|
|
assert ig4._parse_caption("not json {{{") == {}
|
|
assert ig4._parse_caption("[1,2,3]") == {} # non-dict JSON -> {}
|
|
|
|
|
|
def test_unicode_preserved():
|
|
obj = ig4._parse_caption(json.dumps({"high_level_description": "zdjęcie quz0"}, ensure_ascii=False))
|
|
out = json.dumps(obj, ensure_ascii=False)
|
|
assert "quz0" in out and "zdjęcie" in out
|
|
|
|
|
|
# ---- aspect ratio from width/height ---------------------------------------
|
|
def test_size_overrides_aspect_ratio():
|
|
obj = ig4._apply_size({"aspect_ratio": "1:1"}, 1024, 1280)
|
|
assert obj["aspect_ratio"] == "4:5"
|
|
|
|
|
|
def test_zero_size_keeps_aspect_ratio():
|
|
obj = ig4._apply_size({"aspect_ratio": "16:9"}, 0, 0)
|
|
assert obj["aspect_ratio"] == "16:9"
|
|
|
|
|
|
# ---- canvas resolution -----------------------------------------------------
|
|
def test_resolve_canvas_explicit():
|
|
assert ig4._resolve_canvas({}, 832, 1216) == (832, 1216)
|
|
|
|
|
|
def test_resolve_canvas_from_ratio():
|
|
assert ig4._resolve_canvas({"aspect_ratio": "1:1"}, 0, 0) == (1024, 1024)
|
|
w, h = ig4._resolve_canvas({"aspect_ratio": "4:5"}, 0, 0) # portrait, long edge 1024
|
|
assert h == 1024 and w == ig4._round16(1024 * 4 / 5)
|
|
w, h = ig4._resolve_canvas({"aspect_ratio": "16:9"}, 0, 0) # landscape
|
|
assert w == 1024 and h == ig4._round16(1024 * 9 / 16)
|
|
|
|
|
|
# ---- pixel bboxes ----------------------------------------------------------
|
|
def test_pixel_bboxes_conversion():
|
|
obj = {"compositional_deconstruction": {"elements": [
|
|
{"type": "obj", "bbox": [0, 0, 1000, 500], "desc": "x"}, # left half
|
|
{"type": "text", "bbox": [100, 200, 300, 700], "text": "Q"},
|
|
]}}
|
|
out = ig4._pixel_bboxes(obj, 1000, 1000)
|
|
assert out == [[
|
|
{"x": 0, "y": 0, "width": 500, "height": 1000},
|
|
{"x": 200, "y": 100, "width": 500, "height": 200},
|
|
]]
|
|
|
|
|
|
def test_pixel_bboxes_skips_missing_and_empty():
|
|
assert ig4._pixel_bboxes({}, 1024, 1024) == []
|
|
obj = {"compositional_deconstruction": {"elements": [{"type": "obj", "desc": "no bbox"}]}}
|
|
assert ig4._pixel_bboxes(obj, 1024, 1024) == [] # bbox-less -> no frame
|
|
|
|
|
|
# ---- node interface --------------------------------------------------------
|
|
def test_input_types():
|
|
t = Node.INPUT_TYPES()
|
|
assert t["required"]["caption_json"][0] == "STRING"
|
|
assert t["required"]["width"][0] == "INT" and t["required"]["height"][0] == "INT"
|
|
assert t["optional"]["image"][0] == "IMAGE"
|
|
assert t["optional"]["import_json"][0] == "STRING"
|
|
|
|
|
|
def test_return_signature():
|
|
assert Node.RETURN_TYPES == ("STRING", "IMAGE", "BOUNDING_BOX", "INT", "INT")
|
|
assert Node.RETURN_NAMES == ("prompt", "preview", "bboxes", "width", "height")
|