Files
ausboss-ComfyUI-AusBoss/tests/test_layout_boxes_helpers.py
ausboss 8d94da719e Layout Boxes: boxes can be turned, to replace words at a slant
A round knob above the chosen box turns it (Shift: 5 degree steps); a Turn
field under the canvas types the angle and a double-click levels it. The
mask follows the turned box exactly, bboxes and elements keep the straight
box around it, and a turned text box adds how it is tilted to its
description. A box that is not turned saves and outputs exactly as before.
2026-10-19 19:00:42 -05:00

366 lines
21 KiB
Python

"""Layout Boxes: reading the boxes and working out the outputs.
Standard library only (the preview is drawn elsewhere). The same outputs from
ComfyUI's own Create Bounding Boxes, for the same boxes, are checked in
tests/test_layout_boxes_core_parity.py.
"""
from __future__ import annotations
from pathlib import Path
import json
import math
import sys
import unittest
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
if "nodes" in sys.modules and not hasattr(sys.modules["nodes"], "__path__"):
del sys.modules["nodes"]
from nodes._layout_boxes_helpers import (
EMPTY_LAYOUT,
LayoutError,
angle_of,
bounding_boxes,
box_corners,
box_words,
boxes_from_input,
canvas_boxes,
element_desc,
hex_color,
input_fingerprint,
mask_rects,
parse_layout,
prompt_elements,
read_angle,
regions_from_pixel_boxes,
tilt_phrase,
turned_mask_rows,
)
W, H = 1344, 768
def layout(*boxes, from_input=None):
data = {"v": 1, "boxes": list(boxes)}
if from_input:
data["from_input"] = from_input
return json.dumps(data)
def box(x, y, w, h, kind="obj", text="", desc="", palette=()):
"""A box as the canvas writes it: whole canvas pixels divided by the side."""
return {"x": x / W, "y": y / H, "w": w / W, "h": h / H, "type": kind, "text": text, "desc": desc, "palette": list(palette)}
class ReadingTheBoxes(unittest.TestCase):
def test_the_canvas_form(self):
regions, applied = parse_layout(layout(box(320, 96, 512, 640, desc="a cat"), from_input="abc"), W, H)
self.assertEqual(applied, "abc")
self.assertEqual(len(regions), 1)
self.assertEqual(regions[0]["x"], 320 / W)
self.assertEqual(regions[0]["desc"], "a cat")
self.assertEqual(regions[0]["type"], "obj")
def test_empty_and_missing(self):
for value in (None, "", " ", EMPTY_LAYOUT, '{"v": 1}'):
self.assertEqual(parse_layout(value, W, H), ([], ""), value)
def test_core_form_is_read_against_the_canvas(self):
core = [{"x": 544, "y": 128, "width": 416, "height": 192, "metadata": {"type": "text", "text": "OPEN", "desc": "neon", "palette": ["#ff0000"]}}]
regions, applied = parse_layout(core, 1024, 1536)
self.assertEqual(applied, "")
self.assertEqual((regions[0]["x"], regions[0]["h"]), (544 / 1024, 192 / 1536))
self.assertEqual((regions[0]["type"], regions[0]["text"]), ("text", "OPEN"))
self.assertEqual(parse_layout(json.dumps(core), 1024, 1536)[0], regions)
def test_a_box_without_four_numbers_is_left_out(self):
regions, _ = parse_layout(layout({"x": 0.1, "y": 0.1, "w": 0.5}, {"x": "a", "y": 0, "w": 1, "h": 1}, box(0, 0, 64, 64)), W, H)
self.assertEqual(len(regions), 1)
def test_broken_text_stops_with_a_plain_message(self):
with self.assertRaisesRegex(LayoutError, "not valid JSON"):
parse_layout("{oops", W, H)
with self.assertRaisesRegex(LayoutError, "should be"):
parse_layout("42", W, H)
class TheWire(unittest.TestCase):
def test_nothing_comes_in(self):
for value in (None, "", " ", [], "[]"):
self.assertEqual(boxes_from_input(value, W, H), [], value)
def test_prompt_elements_become_canvas_pixels(self):
elements = [{"type": "obj", "bbox": [100, 100, 600, 450], "desc": "a dog"},
{"type": "text", "bbox": [700, 900, 900, 100], "text": "HI", "desc": "chalk", "color_palette": ["#fff"]}]
boxes = boxes_from_input(elements, 1024, 1024)
self.assertEqual(boxes[0], {"x": 102, "y": 102, "width": 358, "height": 512,
"metadata": {"type": "obj", "text": "", "desc": "a dog", "palette": []}})
self.assertEqual((boxes[1]["x"], boxes[1]["width"]), (102, 819), "corners in either order")
self.assertEqual(boxes[1]["metadata"]["text"], "HI")
self.assertEqual(boxes_from_input(json.dumps(elements), 1024, 1024), boxes, "the same as JSON text")
self.assertEqual(boxes_from_input(elements[0], 1024, 1024), boxes[:1], "one element")
def test_pixel_boxes_in_every_shape(self):
one = {"x": 10, "y": 20, "width": 30, "height": 40}
meta = {"x": 1, "y": 2, "width": 3, "height": 4, "metadata": {"desc": "m"}}
self.assertEqual(boxes_from_input(one, W, H), [one])
self.assertEqual(boxes_from_input([one, meta], W, H), [one, meta])
self.assertEqual(boxes_from_input([[one], [meta]], W, H), [one], "a BOUNDING_BOX value: the first frame")
self.assertEqual(boxes_from_input(json.dumps([one]), W, H), [one])
def test_what_cannot_be_read_stops_the_run(self):
for value, words in (("{oops", "not valid JSON"), ({"a": 1}, "needs a box"), (5, "not int"),
([5], "not a list of int"), ([{"bbox": [1, 2]}], "needs boxes"),
([{"bbox": [1, 2, "x", 4]}], "four numbers")):
with self.assertRaisesRegex(LayoutError, words, msg=repr(value)):
boxes_from_input(value, W, H)
def test_the_fingerprint_follows_the_value_not_the_canvas(self):
elements = [{"type": "obj", "bbox": [0, 0, 500, 500], "desc": "a cat"}]
self.assertEqual(input_fingerprint(elements), input_fingerprint(json.loads(json.dumps(elements))))
self.assertNotEqual(input_fingerprint(elements), input_fingerprint([dict(elements[0], desc="a dog")]))
self.assertEqual(len(input_fingerprint(elements)), 16)
class TheOutputs(unittest.TestCase):
def test_pixel_boxes_come_back_exactly(self):
regions, _ = parse_layout(layout(box(320, 96, 512, 640), box(0, 0, W, H)), W, H)
self.assertEqual(bounding_boxes(regions, W, H), [[{"x": 320, "y": 96, "width": 512, "height": 640},
{"x": 0, "y": 0, "width": W, "height": H}]])
self.assertEqual(bounding_boxes([], W, H), [], "no boxes, no frame")
def test_a_box_drawn_backwards_is_turned_round(self):
region = {"x": 0.5, "y": 0.5, "w": -0.25, "h": -0.5, "type": "obj", "text": "", "desc": "", "palette": []}
self.assertEqual(bounding_boxes([region], 1000, 1000), [[{"x": 250, "y": 0, "width": 250, "height": 500}]])
self.assertEqual(prompt_elements([region])[0]["bbox"], [0, 250, 500, 500])
def test_prompt_elements(self):
regions, _ = parse_layout(layout(
box(320, 96, 512, 640, desc="a cat", text="kept out", palette=["#ff3399"]),
box(0, 0, W, 64, kind="text", text="OPEN", desc="neon", palette=["#a1", "#111111", "#222222", "#333333", "#444444", "#555555", "#666666"]),
), W, H)
cat, sign = prompt_elements(regions)
self.assertEqual(cat, {"type": "obj", "bbox": [125, 238, 958, 619], "desc": "a cat", "color_palette": ["#FF3399"]})
self.assertNotIn("text", cat, "only a text box carries text")
self.assertEqual(list(sign), ["type", "bbox", "text", "desc", "color_palette"], "the order Create Bounding Boxes writes")
self.assertEqual(sign["bbox"], [0, 0, 83, 1000])
self.assertEqual(len(sign["color_palette"]), 5)
def test_places_off_the_canvas_are_held_to_the_grid(self):
region = {"x": -0.2, "y": 0.9, "w": 1.5, "h": 0.5, "type": "obj", "text": "", "desc": "", "palette": []}
self.assertEqual(prompt_elements([region])[0]["bbox"], [900, 0, 1000, 1000])
def test_pixel_boxes_read_into_fractions(self):
regions = regions_from_pixel_boxes([{"x": 64, "y": 32, "width": 128, "height": 256}, "junk"], 1024, 512)
self.assertEqual(regions, [{"x": 0.0625, "y": 0.0625, "w": 0.125, "h": 0.5, "type": "obj", "text": "", "desc": "", "palette": []}])
self.assertEqual(canvas_boxes(regions), regions)
class TheMask(unittest.TestCase):
def test_on_the_canvas_it_is_white_inside_the_bboxes(self):
regions, _ = parse_layout(layout(box(320, 96, 512, 640), box(0, 0, W, 64, kind="text", text="OPEN")), W, H)
rects = mask_rects(regions, W, H)
self.assertEqual(rects, [(320, 96, 832, 736), (0, 0, W, 64)])
frames = bounding_boxes(regions, W, H)[0]
self.assertEqual(rects, [(b["x"], b["y"], b["x"] + b["width"], b["y"] + b["height"]) for b in frames],
"the same pixels as the bboxes output, object and text boxes alike")
def test_on_a_picture_of_another_size_the_boxes_keep_their_place(self):
# The canvas is the picture's size on 16 px steps (1008 x 752 for a
# 1000 x 750 photo); the mask has the photo's own size.
regions, _ = parse_layout(layout({"x": 0.5, "y": 0.25, "w": 0.25, "h": 0.5}), W, H)
self.assertEqual(mask_rects(regions, 1000, 750), [(500, 188, 750, 563)])
self.assertEqual(mask_rects(regions, 4000, 3000), [(2000, 750, 3000, 2250)])
def test_boxes_are_cut_to_the_picture(self):
regions = [
{"x": -0.25, "y": 0.5, "w": 0.5, "h": 1.0}, # off the left and the bottom
{"x": 0.5, "y": 0.5, "w": -0.25, "h": -0.5}, # drawn backwards
{"x": 1.2, "y": 0.1, "w": 0.3, "h": 0.3}, # beyond the right edge: no area
{"x": 0.1, "y": 0.1, "w": 0.0, "h": 0.5}, # a line: no area
"junk",
]
self.assertEqual(mask_rects(regions, 1000, 1000), [(0, 500, 250, 1000), (250, 0, 500, 500)])
def test_no_boxes_no_white(self):
self.assertEqual(mask_rects([], W, H), [])
self.assertEqual(mask_rects(parse_layout(EMPTY_LAYOUT, W, H)[0], W, H), [])
def turned(x, y, w, h, angle, kind="obj", text="", desc="", width=W, height=H):
"""A box as the canvas writes a turned one: its level place in fractions, and its angle."""
return {"x": x / width, "y": y / height, "w": w / width, "h": h / height, "type": kind, "text": text,
"desc": desc, "palette": [], "angle": angle}
def inside(region, width, height, px, py, margin=0.0):
"""Whether the point lies inside the turned box, `margin` pixels in from its sides."""
(x0, y0), (x1, y1), _, (x3, y3) = box_corners(region, width, height)
along = math.hypot(x1 - x0, y1 - y0)
down = math.hypot(x3 - x0, y3 - y0)
u = ((px - x0) * (x1 - x0) + (py - y0) * (y1 - y0)) / along
v = ((px - x0) * (x3 - x0) + (py - y0) * (y3 - y0)) / down
return margin <= u <= along - margin and margin <= v <= down - margin
# The tilt words both halves say (js/shared/layout_boxes.mjs tiltPhrase, tests/layout_boxes.test.mjs).
TILT_WORDS = [
(25.25, "tilted about 25 degrees, running downhill to the right"),
(-54.5, "tilted about 55 degrees, running uphill to the right"),
(2, "tilted about 2 degrees, running downhill to the right"),
(-2, "tilted about 2 degrees, running uphill to the right"),
(90, "tilted about 90 degrees, running downhill to the right"),
(1.99, ""),
(-1.5, ""),
(0, ""),
]
class TurnedBoxes(unittest.TestCase):
def test_a_turn_is_kept_in_degrees_from_minus_90_to_90(self):
for value, angle in ((25, 25.0), (25.2, 25.2), (-55, -55.0), (90, 90.0), (-90, -90.0), (120, -60.0), (-120, 60.0),
(270, -90.0), (-270, 90.0), (450, 90.0), ("12.5", 12.5), (None, 0.0), ("x", 0.0), (True, 0.0),
(float("nan"), 0.0), (float("inf"), 0.0)):
self.assertEqual(read_angle(value), angle, value)
self.assertEqual(math.copysign(1.0, read_angle(-0.0)), 1.0, "no minus zero")
self.assertEqual(angle_of({"x": 0}), 0.0)
self.assertEqual(angle_of("junk"), 0.0)
def test_the_saved_form_keeps_a_turn_and_an_old_layout_reads_level(self):
regions, _ = parse_layout(layout(turned(320, 96, 512, 64, 25.2, "text", "OPEN"), box(0, 0, 64, 64)), W, H)
self.assertEqual(regions[0]["angle"], 25.2)
self.assertNotIn("angle", regions[1], "a level box gets no angle key")
for angle in (0, 0.0, -0.0, "0", None, "level"):
level, _ = parse_layout(layout(dict(box(320, 96, 512, 640), angle=angle)), W, H)
self.assertNotIn("angle", level[0], angle)
regions, _ = parse_layout(layout(dict(box(0, 0, 64, 64), angle=200)), W, H)
self.assertEqual(regions[0]["angle"], 20.0, "a turn past half a circle is the same rectangle")
# A layout saved before boxes could turn gives exactly what it gave: the same regions as with angle 0.
old = layout(box(320, 96, 512, 640, desc="a cat"), box(0, 0, W, 64, "text", "OPEN", "neon"))
zero = layout(dict(box(320, 96, 512, 640, desc="a cat"), angle=0), dict(box(0, 0, W, 64, "text", "OPEN", "neon"), angle=0))
self.assertEqual(parse_layout(old, W, H), parse_layout(zero, W, H))
regions, _ = parse_layout(old, W, H)
self.assertEqual(turned_mask_rows(regions, W, H), [], "level boxes have no turned mask")
def test_the_face_written_form_round_trips(self):
# What js/shared/layout_boxes.mjs serializeLayout writes for a turned text box.
text = ('{"v":1,"boxes":[{"x":0.6179435483870968,"y":0.5181451612903226,"w":0.3719758064516129,"h":0.06854838709677419,'
'"type":"text","text":"No Phones","desc":"bold black letters on a white sign","palette":[],"angle":25.2}]}')
regions, _ = parse_layout(text, 992, 1488)
self.assertEqual(regions[0]["angle"], 25.2)
self.assertEqual(round(regions[0]["w"] * 992), 369)
self.assertEqual(canvas_boxes(regions)[0].get("angle"), None, "the wire's boxes are level: canvas_boxes carries no angle")
def test_corners_at_0_25_minus_55_and_90_degrees(self):
level = turned(300, 250, 400, 100, 0, width=1000, height=1000)
self.assertEqual(box_corners(level, 1000, 1000), [(300.0, 250.0), (700.0, 250.0), (700.0, 350.0), (300.0, 350.0)])
quarter = turned(300, 250, 400, 100, 90, width=1000, height=1000)
self.assertEqual(box_corners(quarter, 1000, 1000), [(550.0, 100.0), (550.0, 500.0), (450.0, 500.0), (450.0, 100.0)],
"a quarter turn is exact: the top edge runs straight down")
for angle in (25, -55, 25.2, -89.9):
corners = box_corners(turned(300, 250, 400, 100, angle, width=1000, height=1000), 1000, 1000)
(x0, y0), (x1, y1), (x2, y2), (x3, y3) = corners
self.assertAlmostEqual(math.hypot(x1 - x0, y1 - y0), 400, places=9)
self.assertAlmostEqual(math.hypot(x3 - x0, y3 - y0), 100, places=9)
self.assertAlmostEqual((x1 - x0) * (x3 - x0) + (y1 - y0) * (y3 - y0), 0, places=6, msg="square corners")
self.assertAlmostEqual((x0 + x2) / 2, 500, places=9, msg="turned about its centre")
self.assertAlmostEqual((y0 + y2) / 2, 300, places=9)
self.assertAlmostEqual(math.degrees(math.atan2(y1 - y0, x1 - x0)), angle, places=9, msg="clockwise on the screen")
down, up = (box_corners(turned(300, 250, 400, 100, a, width=1000, height=1000), 1000, 1000) for a in (25, -55))
self.assertGreater(down[1][1], down[0][1], "plus runs downhill to the right")
self.assertLess(up[1][1], up[0][1], "minus runs uphill to the right")
# On a canvas that is not square the box is turned in pixels, so it stays a true rectangle.
(x0, y0), (x1, y1), _, (x3, y3) = box_corners(turned(320, 96, 512, 64, 30), W, H)
self.assertAlmostEqual((x1 - x0) * (x3 - x0) + (y1 - y0) * (y3 - y0), 0, places=6)
self.assertAlmostEqual(math.hypot(x1 - x0, y1 - y0), 512, places=6)
def test_the_mask_is_the_turned_rectangle(self):
quarter = turned(300, 250, 400, 100, 90, width=1000, height=1000)
rows = turned_mask_rows([quarter], 1000, 1000)
self.assertEqual(rows, [(row, 450, 550) for row in range(100, 500)], "90 degrees: exactly the upright box")
for angle, (width, height) in ((25, (1000, 1000)), (-55, (1000, 1000)), (25.25, (992, 1488)), (-10.25, (720, 1080))):
region = turned(300, 250, 369, 103, angle, width=width, height=height)
rows = turned_mask_rows([region], width, height)
area = sum(end - first for _, first, end in rows)
self.assertLess(abs(area - 369 * 103), 2 * (369 + 103), f"{angle}: the area of the box ({area})")
white = {(row, x) for row, first, end in rows for x in range(first, end)}
for row, x in white:
self.assertTrue(inside(region, width, height, x + 0.5, row + 0.5, margin=-1e-6), (angle, row, x))
lo_y = min(y for _, y in box_corners(region, width, height))
hi_y = max(y for _, y in box_corners(region, width, height))
for row in range(int(lo_y), int(hi_y) + 1):
for x in range(0, width, 7):
if inside(region, width, height, x + 0.5, row + 0.5, margin=0.01):
self.assertIn((row, x), white, (angle, row, x))
self.assertEqual(len(rows), len({row for row, _, _ in rows}), "one run per row")
def test_the_mask_is_cut_to_the_picture_and_level_boxes_keep_their_rects(self):
hanging = turned(900, 900, 300, 80, 30, width=1000, height=1000)
rows = turned_mask_rows([hanging], 1000, 1000)
self.assertTrue(rows)
self.assertTrue(all(0 <= row < 1000 and 0 <= first < end <= 1000 for row, first, end in rows))
level = box(320, 96, 512, 640)
regions = [level, turned(100, 100, 200, 50, 25)]
self.assertEqual(mask_rects(regions, W, H), [(320, 96, 832, 736)], "a turned box is not a rect")
self.assertEqual(turned_mask_rows(regions, W, H), turned_mask_rows(regions[1:], W, H), "a level box is not in the turned rows")
def test_bboxes_and_elements_get_the_straight_box_around_it(self):
quarter = turned(300, 250, 400, 100, 90, width=1000, height=1000)
self.assertEqual(bounding_boxes([quarter], 1000, 1000), [[{"x": 450, "y": 100, "width": 100, "height": 400}]])
self.assertEqual(prompt_elements([quarter], (1000, 1000))[0]["bbox"], [100, 450, 500, 550])
slanted = turned(613, 771, 369, 102, 25, "text", "No Phones", "bold black letters", width=992, height=1488)
corners = box_corners(slanted, 992, 1488)
xs, ys = [x for x, _ in corners], [y for _, y in corners]
(frame,) = bounding_boxes([slanted], 992, 1488)
self.assertEqual(frame, [{"x": round(min(xs)), "y": round(min(ys)), "width": round(max(xs)) - round(min(xs)),
"height": round(max(ys)) - round(min(ys))}])
element = prompt_elements([slanted], (992, 1488))[0]
self.assertEqual(element["bbox"], [round(min(ys) / 1488 * 1000), round(min(xs) / 992 * 1000),
round(max(ys) / 1488 * 1000), round(max(xs) / 992 * 1000)])
# Off the canvas: cut to it, as elements are held to the grid.
hanging = turned(900, 0, 300, 80, 30, width=1000, height=1000)
(frame,) = bounding_boxes([hanging], 1000, 1000)
self.assertEqual((frame[0]["x"] + frame[0]["width"], frame[0]["y"]), (1000, 0))
self.assertEqual(prompt_elements([hanging], (1000, 1000))[0]["bbox"][3], 1000)
# Turned on a picture of another shape than the canvas: the box is turned on the picture,
# then counted in canvas pixels (bboxes) and on the grid (elements).
on_picture = turned(400, 300, 200, 60, 40, width=1000, height=750)
(frame,) = bounding_boxes([on_picture], 1024, 1536, (1000, 750))
corners = box_corners(on_picture, 1000, 750)
left, right = min(x for x, _ in corners) / 1000, max(x for x, _ in corners) / 1000
top, bottom = min(y for _, y in corners) / 750, max(y for _, y in corners) / 750
self.assertEqual(frame, [{"x": round(left * 1024), "y": round(top * 1536), "width": round(right * 1024) - round(left * 1024),
"height": round(bottom * 1536) - round(top * 1536)}])
def test_a_turned_text_box_says_its_slant(self):
for angle, words in TILT_WORDS:
self.assertEqual(tilt_phrase(angle), words, angle)
slant = "tilted about 25 degrees, running downhill to the right"
for desc, want in (("", slant), (" ", slant), ("white letters on a red sign", f"white letters on a red sign, {slant}"),
("white letters,", f"white letters, {slant}"), ("White letters.", f"White letters. {slant[0].upper()}{slant[1:]}."),
("neon, " + slant, "neon, " + slant), ("Neon, " + slant.upper(), "Neon, " + slant.upper())):
region = turned(0, 0, 64, 32, 25, "text", "OPEN", desc)
self.assertEqual(element_desc(region), want, desc)
self.assertEqual(prompt_elements([region])[0]["desc"], want)
self.assertEqual(element_desc(turned(0, 0, 64, 32, -40, "text", "OPEN", "chalk")), "chalk, tilted about 40 degrees, running uphill to the right")
self.assertEqual(element_desc(turned(0, 0, 64, 32, 25, "obj", "", "a bicycle")), "a bicycle", "an object box gets no slant")
self.assertEqual(element_desc(turned(0, 0, 64, 32, 1.5, "text", "OPEN", "chalk")), "chalk", "under 2 degrees is level enough")
self.assertEqual(element_desc(box(0, 0, 64, 32, "text", "OPEN", "chalk")), "chalk")
class ThePreviewWords(unittest.TestCase):
def test_words(self):
self.assertEqual(box_words({"type": "obj", "desc": "a cat", "text": "x"}), "a cat")
self.assertEqual(box_words({"type": "text", "text": "OPEN", "desc": "neon"}), '"OPEN" - neon')
def test_colors(self):
self.assertEqual(hex_color("#ABC"), (170, 187, 204))
self.assertEqual(hex_color("#a1b2c3ff"), (161, 178, 195))
self.assertIsNone(hex_color("teal"))
if __name__ == "__main__":
unittest.main()