Files
ausboss-ComfyUI-AusBoss/tests/test_mask_by_touch.py
ausboss 69fbceb0a1 Mask by Touch: say when the picks were made on another picture
When the picture coming into the node changed after picking, the run
stopped with "nothing is picked yet" while the picks still showed on the
node. It now says the picks were made on another picture. If nothing set
to empty mask still lets the run go on.
2026-10-26 18:05:42 -05:00

2091 lines
113 KiB
Python

"""Mask by Touch: what the node does with clicks, names, strokes and what
SAM 3 answers. ComfyUI's own detector and text reader are stood in for by
fakes with a few things in them, so these run without ComfyUI and without a
model."""
from __future__ import annotations
import json
import math
from pathlib import Path
import shutil
import sys
import tempfile
import types
import unittest
import numpy
import torch
from PIL import Image
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 import node_mask_by_touch as node_module # noqa: E402
from nodes._mask_by_name_helpers import IF_NOTHING # noqa: E402
from nodes._mask_by_touch_helpers import ( # noqa: E402
NEEDS_SAM3,
NOTHING_PICKED,
NOT_A_PICTURE,
NOT_SAM3,
NOT_TOUCHES,
NO_TEXT_READER,
OTHER_PICTURE,
WORDS_FIRST,
WORDS_MORE,
picture_key,
scope_points,
stroke_area,
touch_stamp,
unpack_planes,
)
H, W = 120, 160
UI = "ausboss_mask_by_touch"
def block(top, left, tall, wide):
mask = torch.zeros(H, W)
mask[top:top + tall, left:left + wide] = 1.0
return mask
def share(thing):
return round(float(thing.sum()) / (H * W), 5)
GLASSES = block(30, 60, 8, 30)
FACE = block(20, 55, 30, 40)
PERSON = block(15, 40, 100, 70)
MUG = block(80, 120, 25, 25)
# The mug comes back with a far-off speck, as SAM 3's answers often do.
MUG_AS_ANSWERED = MUG.clone()
MUG_AS_ANSWERED[2:4, 2:4] = 1.0
POSTER = block(5, 120, 12, 30)
# What a point finds, smallest first: the smallest thing that holds every point asked.
SCENE = [("glasses", GLASSES, GLASSES), ("poster", POSTER, POSTER), ("mug", MUG, MUG_AS_ANSWERED), ("face", FACE, FACE), ("person", PERSON, PERSON)]
WALL = (5, 5) # nothing is there
HAND_LEFT = block(70, 42, 8, 8)
HAND_RIGHT = block(70, 100, 8, 8)
SHIRT = block(60, 50, 40, 50)
SHIRT_AGAIN = block(60, 50, 40, 48) # nearly the same area
WINDOW = block(60, 5, 50, 30)
PANE = block(70, 10, 20, 20) # a window inside the window
EARRING = block(52, 57, 3, 3)
# What a word finds: every thing of that name, the surest first.
NAMED = {
"person": [(PERSON, 0.98)],
"face": [(FACE, 0.9)],
"hand": [(HAND_LEFT, 0.9), (HAND_RIGHT, 0.85)],
"shirt": [(SHIRT, 0.7)],
"eyeglasses": [(GLASSES, 0.97)],
"cup": [(MUG_AS_ANSWERED, 0.96)],
"window": [(WINDOW, 0.9), (PANE, 0.8)],
"blouse": [(SHIRT_AGAIN, 0.95)], # asked later, and surer than "shirt"
"earring": [(EARRING, 0.75)],
"sunglasses": [(GLASSES, 0.6)], # asked later, and less sure than "eyeglasses"
}
# The pieces a whole look ends with, smallest first.
LOOKED = [
("earring", EARRING, 0.75), ("hand", HAND_LEFT, 0.9), ("hand", HAND_RIGHT, 0.85), ("eyeglasses", GLASSES, 0.97),
(None, POSTER, None), ("window", PANE, 0.8), ("cup", MUG, 0.96), ("face", FACE, 0.9), ("window", WINDOW, 0.9),
("blouse", SHIRT_AGAIN, 0.95), ("person", PERSON, 0.98),
]
def lowered(frame):
"""A picture whose first pixel is white shows the scene 10 px lower."""
return 10 if float(frame[0, 0, 0]) > 0.99 else 0
class FakeSam:
"""Stands in for ComfyUI's SAM3_Detect, asked about points or a word."""
calls: list = []
@classmethod
def execute(cls, model, image, conditioning=None, positive_coords=None, negative_coords=None, threshold=0.5, refine_iterations=2, individual_masks=False):
call = {"frames": int(image.shape[0]), "threshold": threshold, "passes": refine_iterations, "individual": individual_masks, "negative": negative_coords, "model": model}
cls.calls.append(call)
if conditioning is not None:
_, call["clip"], call["text"] = conditioning[0]
call["word"] = call["text"].rpartition(":")[0]
stacks, boxes = [], []
for frame in image:
found = NAMED.get(call["word"], [])
stacks.append(torch.stack([torch.roll(thing, lowered(frame), 0) for thing, _ in found]) if found else torch.zeros(0, H, W))
boxes.append([{"x": 0.0, "y": 0.0, "width": 1.0, "height": 1.0, "score": sure} for _, sure in found])
return types.SimpleNamespace(result=(torch.cat(stacks), boxes))
call["points"] = points = [(point["x"], point["y"]) for point in json.loads(positive_coords)]
masks = []
for frame in image:
down = lowered(frame)
answer = torch.zeros(H, W)
for _name, where, answered in SCENE:
if all(0 <= y - down < H and float(where[y - down, x]) > 0 for x, y in points):
answer = torch.roll(answered, shifts=down, dims=0)
break
masks.append(answer)
return types.SimpleNamespace(result=(torch.stack(masks), [[] for _ in image]))
class FakeEncode:
"""Stands in for ComfyUI's CLIPTextEncode."""
calls: list = []
def encode(self, clip, text):
FakeEncode.calls.append(text)
return ([["read by", clip, text]],)
class FakeFolders:
"""Stands in for ComfyUI's folder_paths: a temp folder and numbered names."""
def __init__(self, folder):
self.folder = folder
def get_temp_directory(self):
return self.folder
def get_save_image_path(self, prefix, folder, _width, _height):
counter = 1 + sum(1 for name in Path(folder).iterdir() if name.name.startswith(prefix + "_"))
return folder, prefix, counter, "", prefix
def touches(*items, key=None):
data = {"v": 1, "items": list(items)}
if key is not None:
data["key"] = key
return json.dumps(data)
def tap(x, y, op=1):
return {"t": "tap", "op": op, "x": x, "y": y}
def name(word, x, y, op=1):
return {"t": "name", "op": op, "w": word, "x": x, "y": y}
def every(word, op=1):
return {"t": "all", "op": op, "w": word}
def find(pts, r=4, op=1):
return {"t": "find", "op": op, "r": r, "pts": pts}
def paint(pts, r=4, op=1):
return {"t": "paint", "op": op, "r": r, "pts": pts}
def asked(kind):
"""The words, or the lists of points, SAM 3 was asked about, in order."""
return [call[kind] for call in FakeSam.calls if kind in call]
class TouchCase(unittest.TestCase):
detector = FakeSam
encoder = FakeEncode
def setUp(self):
FakeSam.calls = []
FakeEncode.calls = []
node_module._MEMORY.clear()
node_module._WORDS.clear()
node_module._TRUST.clear()
node_module._UNSTEADY.clear()
node_module._NOTED.clear()
self.swap("_core_nodes", lambda: {"SAM3_Detect": self.detector, "CLIPTextEncode": self.encoder})
# The test picture is small: let the look ask as many points as a big one gets.
self.swap("SCOPE_SPACING", 1)
# A look here ends when everything is asked, unless a test cuts it short.
self.swap("SCOPE_SECONDS_BLIND", 1e9)
folder = tempfile.mkdtemp(prefix="ausboss_touch_test_")
self.addCleanup(shutil.rmtree, folder, True)
self.folder = Path(folder)
self.swap("folder_paths", FakeFolders(folder))
self.picture = torch.rand(1, H, W, 3) * 0.9
self.node = node_module.AusBossMaskByTouch()
def swap(self, attribute, value):
self.addCleanup(setattr, node_module, attribute, getattr(node_module, attribute))
setattr(node_module, attribute, value)
def run_node(self, text="", node=None, image=None, model="sam3 model", clip="sam3 clip", **overrides):
values = {"grow": 0, "soften": 0.0, "smooth": 0, "fill_holes": False, "if_nothing": IF_NOTHING[0]}
values.update(overrides)
picture = self.picture if image is None else image
return (node or self.node).pick(picture, model, clip, text, **values)
def note(self, out):
return out["ui"][UI][0]
def area(self, out):
return out["result"][0][0] > 0.5
def short_looks(self, questions):
"""From here on a look asks this many questions and hands back, as
where ComfyUI's queue cannot be read and its short time is over."""
self.swap("_jobs_waiting", lambda: None)
self.swap("SCOPE_SECONDS_BLIND", -1.0)
self.swap("SCOPE_FLOOR", questions)
def look(self, **overrides):
"""Looks until everything is asked; the last note."""
while True:
note = self.note(self.run_node("", ask="scope", **overrides))
if not note["pieces"]["more"]:
return note
def opened(self, entry):
"""A file the note names, read whole: (format, mode, size, pixels)."""
with Image.open(self.folder / entry["subfolder"] / entry["filename"]) as picture:
return types.SimpleNamespace(
format=picture.format, mode=picture.mode, size=picture.size, info=dict(picture.info),
pixels=torch.from_numpy(numpy.asarray(picture).copy()),
)
def pieces_of(self, note):
planes = [self.opened(entry).pixels for entry in note["pieces"]["planes"]]
return unpack_planes(planes, len(note["pieces"]["list"]))
def touch_for(self, entry):
"""What the node's face stores for a click on a lit piece."""
if entry["kind"] == "name":
return name(entry["name"], entry["x"], entry["y"])
return tap(entry["x"], entry["y"])
class PickTests(TouchCase):
def test_a_tap_a_stroke_and_paint_give_the_area_and_the_note(self):
stroke = [[125, 85], [140, 100]]
text = touches(tap(75, 34), find([[50, 80], [100, 80]]), paint(stroke, r=3, op=0), key=picture_key(self.picture))
out = self.run_node(text)
mask, cut, names = out["result"]
self.assertEqual(names, "", "a tap, a stroke and paint have no name")
expected = torch.maximum(GLASSES, PERSON).bool() & ~stroke_area(stroke, 3, H, W)
self.assertEqual((mask.dtype, tuple(mask.shape)), (torch.float32, (1, H, W)))
self.assertTrue(torch.equal(mask[0] > 0.5, expected))
self.assertEqual(tuple(cut.shape), (1, H, W, 4))
self.assertTrue(torch.equal(cut[..., :3], self.picture))
self.assertTrue(torch.equal(cut[0, ..., 3], mask[0]))
note = self.note(out)
self.assertEqual(sorted(note), ["areas", "fine", "height", "items", "key", "picture", "pieces", "scale", "selection", "share", "stamp", "width"])
self.assertIsNone(note["fine"], "no fine edge while Fine edges is off")
self.assertEqual((note["key"], note["width"], note["height"], note["scale"]), (picture_key(self.picture), W, H, 1.0))
self.assertEqual(note["stamp"], touch_stamp(text))
self.assertEqual(
note["items"],
[
{"ok": True, "share": share(GLASSES), "as": "thing"},
{"ok": True, "share": share(PERSON), "as": "thing"},
{"ok": True, "share": round(float(stroke_area(stroke, 3, H, W).sum()) / (H * W), 5), "as": "paint", "did": False},
],
)
self.assertEqual(note["share"], round(float(mask[0].mean()), 5))
self.assertIsNone(note["pieces"], "not looked at yet")
self.assertNotIn("images", out["ui"], "the face draws its own picture")
self.assertEqual(FakeEncode.calls, [], "points need no text reader")
def test_sam3_is_asked_about_a_point_the_way_a_workflow_would_ask_it(self):
self.run_node(touches(tap(75, 34)))
(call,) = FakeSam.calls
self.assertEqual(call["points"], [(75, 34)])
self.assertEqual((call["threshold"], call["passes"], call["individual"], call["negative"]), (0.5, 2, False, None))
self.assertEqual((call["frames"], call["model"]), (1, "sam3 model"))
def test_the_face_gets_the_picture_and_the_raw_selection_as_files(self):
big = torch.rand(1, 300, 2048, 3) * 0.9
text = touches(paint([[100, 50], [1900, 250]], r=40))
out = self.run_node(text, image=big, grow=20, soften=6.0)
note = self.note(out)
self.assertEqual((note["width"], note["height"], note["scale"]), (2048, 300, 0.5))
picture = self.opened(note["picture"])
self.assertEqual((picture.format, picture.size, picture.mode), ("JPEG", (1024, 150), "RGB"))
selection = self.opened(note["selection"])
self.assertEqual((selection.format, selection.size, selection.mode), ("PNG", (1024, 150), "L"))
self.assertEqual(torch.unique(selection.pixels).tolist(), [0, 255], "255 inside, and nothing in between")
self.assertNotIn("icc_profile", selection.info)
self.assertEqual(note["picture"]["type"], "temp")
# The selection is the raw one: before grow and soften.
raw = stroke_area([[100, 50], [1900, 250]], 40, 300, 2048)
self.assertLess(float(raw.float().mean()), note["share"])
self.assertAlmostEqual(float((selection.pixels > 0).float().mean()), float(raw.float().mean()), places=2)
def test_the_picture_is_written_once_and_each_selection_gets_a_new_name(self):
first = self.note(self.run_node(touches(tap(75, 34))))
second = self.note(self.run_node(touches(tap(130, 90))))
self.assertEqual(first["picture"], second["picture"])
self.assertNotEqual(first["selection"]["filename"], second["selection"]["filename"])
other = node_module.AusBossMaskByTouch()
third = self.note(self.run_node(touches(tap(75, 34)), node=other))
self.assertNotEqual(third["picture"]["filename"], first["picture"]["filename"], "two nodes never share a file")
(self.folder / first["picture"]["filename"]).unlink()
again = self.note(self.run_node(touches(tap(75, 34)), grow=1))
self.assertTrue((self.folder / again["picture"]["filename"]).is_file(), "a picture that is gone is written again")
def test_without_a_temp_folder_the_mask_still_comes_back(self):
self.swap("folder_paths", None)
out = self.run_node(touches(tap(75, 34)))
self.assertTrue(torch.equal(self.area(out), GLASSES.bool()))
note = self.note(out)
self.assertEqual((note["picture"], note["selection"]), (None, None))
self.assertEqual(note["items"], [{"ok": True, "share": share(GLASSES), "as": "thing"}])
def test_a_taps_answer_loses_its_far_off_speck(self):
out = self.run_node(touches(tap(130, 90)))
self.assertTrue(torch.equal(self.area(out), MUG.bool()))
self.assertEqual(float(out["result"][0][0, 2, 2]), 0.0)
def test_a_tap_that_finds_nothing_changes_nothing(self):
out = self.run_node(touches(tap(75, 34), tap(*WALL), tap(900, 900), {"t": "lasso"}))
self.assertTrue(torch.equal(self.area(out), GLASSES.bool()))
nothing = {"ok": False, "share": 0.0, "as": "none"}
self.assertEqual(self.note(out)["items"][1:], [nothing, nothing, nothing])
self.assertEqual(len(FakeSam.calls), 2, "a tap off the picture and a kind not known ask nothing")
def test_a_find_over_empty_space_is_the_paint_itself(self):
stroke = [[45, 5], [80, 8]]
found = self.run_node(touches(find(stroke, r=5)))
painted = self.run_node(touches(paint(stroke, r=5)))
self.assertTrue(torch.equal(found["result"][0], painted["result"][0]))
self.assertGreater(float(found["result"][0].sum()), 0.0)
self.assertEqual(self.note(found)["items"], self.note(painted)["items"])
self.assertEqual(self.note(found)["items"][0]["as"], "paint")
self.assertIs(self.note(found)["items"][0]["ok"], True, "it still does something")
def test_a_stroke_takes_the_thing_under_it_not_the_paint(self):
out = self.run_node(touches(find([[122, 82], [140, 100]], r=2)))
self.assertTrue(torch.equal(self.area(out), MUG.bool()), "the whole mug, and its speck is not touched")
self.assertEqual(asked("points"), [[(124, 84), (129, 89), (133, 93), (138, 98)], [(126, 86), (136, 96)]])
def test_take_away_removes_and_the_order_counts(self):
person_without_face = self.run_node(touches(tap(50, 100), tap(60, 45, op=0)))
self.assertTrue(torch.equal(self.area(person_without_face), PERSON.bool() & ~FACE.bool()))
face_back = self.run_node(touches(tap(60, 45, op=0), tap(50, 100)))
self.assertTrue(torch.equal(self.area(face_back), PERSON.bool()))
def test_the_edge_settings_shape_the_mask(self):
exact = self.run_node(touches(tap(130, 90)))["result"][0][0]
grown = self.run_node(touches(tap(130, 90)), grow=3)["result"][0][0]
self.assertEqual((float(exact[78, 130]), float(grown[78, 130]), float(grown[76, 130])), (0.0, 1.0, 0.0))
self.assertEqual(float(self.run_node(touches(tap(130, 90)), grow=-3)["result"][0][0, 81, 130]), 0.0)
soft = self.run_node(touches(tap(130, 90)), soften=3.0)["result"][0][0]
self.assertTrue(0.05 < float(soft[80, 130]) < 0.95)
ring = touches(tap(50, 100), tap(60, 45, op=0), paint([[52, 90]], r=1, op=0))
open_ring = self.run_node(ring, fill_holes=True)["result"][0][0]
self.assertEqual(float(open_ring[45, 60]), 0.0, "the face is a big opening: it stays open")
self.assertEqual(float(open_ring[90, 52]), 1.0, "the pinhole is closed")
self.assertEqual(float(self.run_node(ring)["result"][0][0, 90, 52]), 0.0)
class NameTests(TouchCase):
"""A `name` touch: the thing called by a word at a point."""
def test_a_name_takes_the_thing_of_that_name_at_the_point(self):
text = touches(name("shirt", 75, 80), key=picture_key(self.picture))
out = self.run_node(text)
self.assertTrue(torch.equal(self.area(out), SHIRT.bool()), "the shirt, where a tap there takes the whole person")
self.assertEqual(self.note(out)["items"], [{"ok": True, "share": share(SHIRT), "as": "thing"}])
self.assertTrue(torch.equal(self.area(self.run_node(touches(tap(75, 80)))), PERSON.bool()))
def test_sam3_is_asked_about_a_word_the_way_mask_by_name_asks(self):
self.run_node(touches(name("hand", 45, 73)))
self.assertEqual(FakeEncode.calls, ["hand:16"], "up to 16 matches: a bare word returns one")
(call,) = FakeSam.calls
self.assertEqual((call["text"], call["clip"], call["model"]), ("hand:16", "sam3 clip", "sam3 model"))
self.assertEqual((call["threshold"], call["passes"], call["individual"], call["frames"]), (0.5, 2, True, 1))
def test_of_two_of_a_kind_the_one_at_the_point_is_taken(self):
left = self.run_node(touches(name("hand", 45, 73)))
right = self.run_node(touches(name("hand", 104, 75)))
self.assertTrue(torch.equal(self.area(left), HAND_LEFT.bool()))
self.assertTrue(torch.equal(self.area(right), HAND_RIGHT.bool()))
both = self.run_node(touches(name("hand", 45, 73), name("hand", 104, 75)))
self.assertTrue(torch.equal(self.area(both), (HAND_LEFT + HAND_RIGHT).bool()))
def test_of_a_thing_inside_a_thing_of_the_same_name_the_smallest_at_the_point_is_taken(self):
self.assertTrue(torch.equal(self.area(self.run_node(touches(name("window", 15, 75)))), PANE.bool()))
self.assertTrue(torch.equal(self.area(self.run_node(touches(name("window", 30, 100)))), WINDOW.bool()))
def test_a_name_that_is_not_at_the_point_changes_nothing(self):
out = self.run_node(touches(tap(75, 34), name("hand", 75, 80), name("unicorn", 75, 80), name("hand", 900, 900)))
self.assertTrue(torch.equal(self.area(out), GLASSES.bool()))
nothing = {"ok": False, "share": 0.0, "as": "none"}
self.assertEqual(self.note(out)["items"][1:], [nothing, nothing, nothing])
self.assertEqual(asked("word"), ["hand", "unicorn"], "a point off the picture asks nothing, and hand is asked once")
def test_an_empty_word_does_nothing_and_a_word_is_cleaned_before_it_is_asked(self):
with self.assertRaises(RuntimeError) as stopped:
self.run_node(touches(name("", 75, 80), name(" ,:() ", 75, 80)))
self.assertEqual(str(stopped.exception), NOTHING_PICKED)
self.assertEqual((FakeSam.calls, FakeEncode.calls), ([], []))
out = self.run_node(touches(name(" ( eyeglasses ), ", 75, 34)))
self.assertTrue(torch.equal(self.area(out), GLASSES.bool()))
self.assertEqual(FakeEncode.calls, ["eyeglasses:16"], "the text reader's own signs are gone")
self.run_node(touches(name("x" * 200, 75, 34)), if_nothing="empty mask")
self.assertEqual(FakeEncode.calls[-1], "x" * 60 + ":16")
def test_a_name_can_take_away(self):
out = self.run_node(touches(tap(50, 100), name("face", 60, 45, op=0), name("hand", 45, 73, op=0)))
self.assertTrue(torch.equal(self.area(out), PERSON.bool() & ~FACE.bool() & ~HAND_LEFT.bool()))
def test_the_matches_of_a_word_are_remembered_per_picture_and_the_word_is_read_once(self):
first = self.run_node(touches(name("hand", 45, 73)))
self.run_node(touches(name("hand", 104, 75)), grow=2)
self.run_node(touches(name("hand", 45, 73)), node=node_module.AusBossMaskByTouch())
self.assertEqual(asked("word"), ["hand"], "the other hand, new settings and another node ask nothing")
self.run_node(touches(name("hand", 45, 73)), image=self.picture.flip(1), if_nothing="empty mask")
self.assertEqual(asked("word"), ["hand", "hand"], "another picture is asked anew")
self.assertEqual(FakeEncode.calls, ["hand:16"], "but the word reads the same for every picture")
self.run_node(touches(name("hand", 45, 73)), clip="another text reader")
self.run_node(touches(name("hand", 45, 73)), model=types.SimpleNamespace(model=object()))
self.assertEqual((len(asked("word")), FakeEncode.calls), (4, ["hand:16", "hand:16"]), "another reader or model is asked anew")
self.assertTrue(torch.equal(self.area(first), HAND_LEFT.bool()))
def test_a_batch_asks_the_word_of_every_picture(self):
lower = self.picture.clone()
lower[0, 0, 0] = 1.0 # the fake shows this one's scene 10 px lower
out = self.run_node(touches(name("hand", 45, 82)), image=torch.cat([self.picture, lower]))
mask = out["result"][0]
self.assertTrue(torch.equal(mask[0] > 0.5, torch.zeros(H, W, dtype=torch.bool)), "no hand there in the first picture")
self.assertTrue(torch.equal(mask[1] > 0.5, torch.roll(HAND_LEFT, 10, 0).bool()))
self.assertEqual(self.note(out)["items"], [{"ok": False, "share": 0.0, "as": "none"}], "the note describes the first picture")
self.assertEqual([call["frames"] for call in FakeSam.calls], [1, 1])
def test_a_name_without_a_text_reader_says_what_to_wire_and_points_need_none(self):
with self.assertRaises(RuntimeError) as needs:
self.run_node(touches(name("hand", 45, 73)), clip=None)
self.assertEqual(str(needs.exception), NO_TEXT_READER)
self.assertIn("Nothing is wired into clip", NO_TEXT_READER)
self.assertTrue(torch.equal(self.area(self.run_node(touches(tap(75, 34)), clip=None)), GLASSES.bool()))
def test_a_text_reader_that_is_not_sam3s_gets_wiring_advice(self):
class CannotRead(FakeEncode):
def encode(self, clip, text):
raise RuntimeError("clip input is invalid")
self.swap("_core_nodes", lambda: {"SAM3_Detect": FakeSam, "CLIPTextEncode": CannotRead})
with self.assertRaises(RuntimeError) as advice:
self.run_node(touches(name("hand", 45, 73)))
self.assertTrue(str(advice.exception).startswith(NOT_SAM3))
self.assertIn("Wire model and clip from a Load Checkpoint that loads the SAM 3 file", str(advice.exception))
self.assertIn("clip input is invalid", str(advice.exception))
class CannotUse(FakeSam):
@classmethod
def execute(cls, conditioning=None, **kwargs):
if conditioning is not None:
raise RuntimeError("mat1 and mat2 shapes cannot be multiplied (77x768 and 1024x256)")
return FakeSam.execute(**kwargs)
self.swap("_core_nodes", lambda: {"SAM3_Detect": CannotUse, "CLIPTextEncode": FakeEncode})
with self.assertRaises(RuntimeError) as advice:
self.run_node(touches(name("hand", 45, 73)))
self.assertTrue(str(advice.exception).startswith(NOT_SAM3))
self.assertIn("77x768", str(advice.exception))
self.assertTrue(torch.equal(self.area(self.run_node(touches(tap(75, 34)))), GLASSES.bool()), "a point still works")
self.swap("_core_nodes", lambda: {"SAM3_Detect": FakeSam})
with self.assertRaises(RuntimeError) as needs:
self.run_node(touches(name("face", 60, 45)))
self.assertEqual(str(needs.exception), NEEDS_SAM3)
def test_an_answer_that_does_not_add_up_is_not_used(self):
class OneScoreShort(FakeSam):
@classmethod
def execute(cls, **kwargs):
out = FakeSam.execute(**kwargs)
return types.SimpleNamespace(result=(out.result[0], [out.result[1][0][:-1]]))
self.swap("_core_nodes", lambda: {"SAM3_Detect": OneScoreShort, "CLIPTextEncode": FakeEncode})
with self.assertRaises(RuntimeError) as advice:
self.run_node(touches(name("hand", 45, 73)))
self.assertIn("does not add up", str(advice.exception))
class NothingPickedTests(TouchCase):
def test_nothing_picked_stops_the_run_with_a_plain_message(self):
for text in ("", touches(), touches(tap(*WALL)), touches(tap(75, 34), tap(75, 34, op=0)), touches(name("hand", 75, 80))):
with self.assertRaises(RuntimeError) as stopped:
self.run_node(text)
self.assertEqual(str(stopped.exception), NOTHING_PICKED)
def test_nothing_picked_can_pass_an_empty_mask_on_instead(self):
out = self.run_node("", if_nothing="empty mask")
mask, cut, _ = out["result"]
self.assertEqual((float(mask.sum()), tuple(mask.shape)), (0.0, (1, H, W)))
self.assertEqual(float(cut[..., 3].sum()), 0.0)
note = self.note(out)
self.assertEqual((note["selection"], note["items"], note["share"]), (None, [], 0.0))
self.assertEqual(note["stamp"], "811c9dc5")
def test_a_request_from_the_face_never_stops_the_run(self):
self.swap("SCOPE_SECONDS_BLIND", -1.0)
for ask in ("look", "scope", "scope:3,3", "anything"):
note = self.note(self.run_node("", ask=ask))
self.assertEqual(self.opened(note["picture"]).size, (W, H))
self.assertIsNone(note["selection"])
def test_picks_made_on_another_picture_are_not_this_pictures(self):
text = touches(tap(75, 34), name("face", 60, 45), key="160x120-0000000000000000")
with self.assertRaises(RuntimeError) as stopped:
self.run_node(text)
self.assertEqual(str(stopped.exception), OTHER_PICTURE, "the picks are there, on another picture")
out = self.run_node(text, if_nothing="empty mask")
self.assertEqual(float(out["result"][0].sum()), 0.0, "empty mask still lets the run go on")
note = self.note(self.run_node(text, ask="look"))
self.assertEqual((note["items"], note["key"], note["stamp"]), ([], picture_key(self.picture), touch_stamp(text)))
self.assertEqual(FakeSam.calls, [])
self.assertEqual(len(self.note(self.run_node(touches(tap(75, 34), key=picture_key(self.picture))))["items"]), 1)
def test_text_that_is_not_touches_stops_before_any_search(self):
for ask in ("", "look"):
with self.assertRaises(ValueError) as stopped:
self.run_node("{not json", ask=ask)
self.assertEqual(str(stopped.exception), NOT_TOUCHES)
with self.assertRaises(ValueError) as stopped:
self.run_node(touches(tap(1, 1)), image=torch.zeros(H, W, 3))
self.assertEqual(str(stopped.exception), NOT_A_PICTURE)
self.assertEqual(FakeSam.calls, [])
class MemoryTests(TouchCase):
def test_the_same_question_is_asked_once(self):
text = touches(tap(75, 34), find([[50, 80], [100, 80]]))
first = self.run_node(text)
count = len(FakeSam.calls)
self.assertEqual(count, 3, "one tap, and a stroke's two questions")
again = self.run_node(text, grow=4, soften=1.0)
self.assertEqual(len(FakeSam.calls), count, "new edge settings ask SAM 3 nothing")
self.assertTrue(torch.equal(first["result"][0], self.run_node(text)["result"][0]))
self.assertGreater(float(again["result"][0].sum()), float(first["result"][0].sum()))
self.run_node(touches(tap(75, 34), find([[50, 80], [100, 80]]), tap(130, 90)))
self.assertEqual(len(FakeSam.calls), count + 1, "only the new pick is asked")
def test_answers_are_shared_by_nodes_and_kept_apart_by_picture_and_model(self):
text = touches(tap(75, 34))
self.run_node(text)
self.run_node(text, node=node_module.AusBossMaskByTouch())
self.run_node(text, clip="another text reader")
self.assertEqual(len(FakeSam.calls), 1, "a second node on the same picture asks nothing, whatever reads its words")
self.run_node(text, image=self.picture.flip(2))
self.assertEqual(len(FakeSam.calls), 2, "another picture is asked anew")
self.run_node(text, model=types.SimpleNamespace(model=object()))
self.assertEqual(len(FakeSam.calls), 3, "another model is asked anew")
def test_a_batch_asks_the_same_picks_of_every_picture(self):
lower = self.picture.clone()
lower[0, 0, 0] = 1.0 # the fake shows this one's scene 10 px lower
batch = torch.cat([self.picture, lower])
out = self.run_node(touches(tap(130, 95), paint([[10, 10]], r=2)), image=batch)
mask = out["result"][0]
self.assertEqual(tuple(mask.shape), (2, H, W))
dot = stroke_area([[10, 10]], 2, H, W)
self.assertTrue(torch.equal(mask[0] > 0.5, MUG.bool() | dot))
self.assertTrue(torch.equal(mask[1] > 0.5, torch.roll(MUG, 10, 0).bool() | dot), "found where it is in that picture")
self.assertEqual([call["frames"] for call in FakeSam.calls], [1, 1], "one picture a question")
note = self.note(out)
self.assertEqual(note["key"], picture_key(self.picture), "the note describes the first picture")
self.assertEqual(note["items"][0]["share"], share(MUG))
self.run_node(touches(tap(130, 95), paint([[10, 10]], r=2)), image=batch, grow=2)
self.assertEqual(len(FakeSam.calls), 2, "each picture's answer is remembered")
def test_a_batch_stops_only_when_no_picture_has_anything(self):
lower = self.picture.clone()
lower[0, 0, 0] = 1.0
batch = torch.cat([self.picture, lower])
mask = self.run_node(touches(tap(130, 112)), image=batch)["result"][0]
self.assertEqual((float(mask[0].sum()), float(mask[1].sum())), (0.0, float(MUG.sum())))
with self.assertRaises(RuntimeError):
self.run_node(touches(tap(*WALL)), image=batch)
class PiecesTests(TouchCase):
def test_a_look_names_the_things_and_points_find_what_no_word_found(self):
out = self.run_node("", ask="scope")
note = self.note(out)
pieces = note["pieces"]
self.assertEqual(sorted(pieces), ["list", "more", "planes", "words"])
self.assertIs(pieces["more"], False)
self.assertEqual(pieces["words"], [104, 104], "every word of the list was asked")
self.assertEqual(
[(entry["name"], entry["share"], entry["sure"], entry["kind"]) for entry in pieces["list"]],
[(word, share(thing), sure, "point" if word is None else "name") for word, thing, sure in LOOKED],
"smallest first: each thing once, by the word SAM 3 was surest of; the poster by a point",
)
masks = self.pieces_of(note)
for mask, entry, (_, thing, _) in zip(masks, pieces["list"], LOOKED):
self.assertEqual(sorted(entry), ["box", "kind", "name", "share", "sure", "x", "y"])
self.assertTrue(torch.equal(mask, thing.bool()), "the mug without its speck too")
self.assertTrue(bool(thing[entry["y"], entry["x"]]), "its own point lies on it")
rows, columns = torch.nonzero(thing.sum(1)).flatten(), torch.nonzero(thing.sum(0)).flatten()
self.assertEqual(entry["box"], [int(columns[0]), int(rows[0]), int(columns[-1]) + 1, int(rows[-1]) + 1])
plane = self.opened(pieces["planes"][0])
self.assertEqual((plane.format, plane.mode, plane.size), ("PNG", "RGB", (W, H)), "no see-through channel")
self.assertNotIn("icc_profile", plane.info)
self.assertEqual(float(out["result"][0].sum()), 0.0)
def test_the_questions_go_words_points_more_words_the_other_points(self):
self.run_node("", ask="scope")
points = node_module._scope_plan(W, H)
self.assertGreater(len(points), 350)
kinds = ["word" if "word" in call else "point" for call in FakeSam.calls]
self.assertEqual(kinds, ["word"] * 40 + ["point"] * 48 + ["word"] * 64 + ["point"] * (len(points) - 48))
self.assertEqual(asked("word"), list(WORDS_FIRST) + list(WORDS_MORE))
self.assertEqual(asked("points"), [[point] for point in points])
self.assertEqual(len(node_module._scope_queue(W, H)), 104 + len(points))
self.assertEqual((len(WORDS_FIRST), len(WORDS_MORE)), (40, 64))
self.assertEqual(len(set(WORDS_FIRST) | set(WORDS_MORE)), 104, "no word is asked twice")
def test_the_text_reader_reads_the_next_words_together(self):
order = []
class Encode(FakeEncode):
def encode(self, clip, text):
order.append(("read", text.rpartition(":")[0]))
return super().encode(clip, text)
class Sam(FakeSam):
@classmethod
def execute(cls, conditioning=None, **kwargs):
if conditioning is not None:
order.append(("ask", conditioning[0][2].rpartition(":")[0]))
return FakeSam.execute(conditioning=conditioning, **kwargs)
self.swap("_core_nodes", lambda: {"SAM3_Detect": Sam, "CLIPTextEncode": Encode})
self.run_node("", ask="scope")
ahead = node_module.WORDS_AHEAD
self.assertEqual(order[:ahead], [("read", word) for word in WORDS_FIRST[:ahead]])
self.assertEqual(order[ahead:2 * ahead], [("ask", word) for word in WORDS_FIRST[:ahead]])
self.assertEqual(order[2 * ahead], ("read", WORDS_FIRST[ahead]))
self.assertEqual(len(FakeEncode.calls), 104)
# Another picture: the words are read already.
self.run_node("", ask="scope", image=self.picture.flip(2))
self.assertEqual(len(FakeEncode.calls), 104)
def test_a_touch_on_a_piece_that_was_shown_gives_exactly_that_piece_and_asks_nothing(self):
note = self.note(self.run_node("", ask="scope"))
shown = self.pieces_of(note)
count = len(FakeSam.calls)
for index, entry in enumerate(note["pieces"]["list"]):
out = self.run_node(touches(self.touch_for(entry), key=note["key"]))
self.assertTrue(torch.equal(self.area(out), shown[index]), entry)
selection = self.opened(self.note(out)["selection"]).pixels > 0
self.assertTrue(torch.equal(selection, shown[index]), "the face's copy is the same, pixel for pixel")
self.assertEqual(self.note(out)["items"], [{"ok": True, "share": entry["share"], "as": "thing"}])
self.assertEqual(len(FakeSam.calls), count, "the touch costs nothing after the look")
def test_a_named_piece_has_a_point_that_means_that_piece(self):
note = self.note(self.run_node("", ask="scope"))
entries = {(entry["name"], entry["share"]): entry for entry in note["pieces"]["list"]}
big, pane = entries[("window", share(WINDOW))], entries[("window", share(PANE))]
self.assertTrue(bool(WINDOW[big["y"], big["x"]]) and not bool(PANE[big["y"], big["x"]]), "the big window's point is not in the pane")
self.assertTrue(abs(pane["x"] - 19.5) <= 1 and abs(pane["y"] - 79.5) <= 1, "in the middle of the pane, away from its edge")
left, right = (entry for entry in note["pieces"]["list"] if entry["name"] == "hand")
self.assertTrue(abs(left["x"] - 45.5) <= 1 and abs(left["y"] - 73.5) <= 1 and abs(right["x"] - 103.5) <= 1 and abs(right["y"] - 73.5) <= 1)
self.assertEqual((left["sure"], right["sure"]), (0.9, 0.85))
# The same word at each point means that piece and no other.
for entry, thing in ((big, WINDOW), (pane, PANE), (left, HAND_LEFT), (right, HAND_RIGHT)):
self.assertTrue(torch.equal(self.area(self.run_node(touches(self.touch_for(entry)))), thing.bool()))
def test_two_words_for_one_area_are_one_piece_by_the_surer_word(self):
note = self.look()
names = [entry["name"] for entry in note["pieces"]["list"]]
self.assertNotIn("sunglasses", names, "less sure than eyeglasses for the same area")
self.assertIn("eyeglasses", names)
self.assertNotIn("shirt", names, "blouse was surer for nearly the same area")
self.assertIn("blouse", names)
self.assertEqual(names.count(None), 1, "points that found the person, the face, the mug or the glasses again add no piece")
def test_a_surer_word_takes_the_place_of_a_piece_that_was_shown(self):
self.short_looks(60)
first = self.note(self.run_node("", ask="scope"))
self.assertIs(first["pieces"]["more"], True)
self.assertEqual(first["pieces"]["words"], [40, 104], "the first words, and 20 points")
shirt = next(entry for entry in first["pieces"]["list"] if entry["name"] == "shirt")
self.assertEqual((shirt["share"], shirt["sure"]), (share(SHIRT), 0.7))
last = self.look()
blouse = next(entry for entry in last["pieces"]["list"] if entry["name"] == "blouse")
self.assertEqual((blouse["share"], blouse["sure"]), (share(SHIRT_AGAIN), 0.95))
self.assertNotIn("shirt", [entry["name"] for entry in last["pieces"]["list"]])
# What was clicked while "shirt" was lit is still the shirt.
out = self.run_node(touches(self.touch_for(shirt)))
self.assertTrue(torch.equal(self.area(out), SHIRT.bool()))
def test_a_named_piece_is_kept_however_small_or_big_and_a_point_piece_is_not(self):
everything = torch.ones(H, W)
everything[:, :10] = 0.0
self.addCleanup(NAMED.pop, "wall")
NAMED["wall"] = [(everything, 0.96)]
dot = block(100, 150, 3, 3)
for position, thing in ((0, ("dot", dot, dot)), (len(SCENE), ("backdrop", everything, everything))):
self.addCleanup(SCENE.remove, thing)
SCENE.insert(position, thing)
note = self.look()
by_name = {entry["name"]: entry for entry in note["pieces"]["list"]}
self.assertEqual(by_name["earring"]["share"], share(EARRING), "9 pixels, and kept: a word pointed at it")
self.assertEqual(by_name["wall"]["share"], share(everything), "nearly the whole picture, and kept")
points = [entry for entry in note["pieces"]["list"] if entry["kind"] == "point"]
self.assertEqual([entry["share"] for entry in points], [share(POSTER)], "no 9-pixel dot and no backdrop from a point")
def test_a_name_for_nearly_the_whole_picture_is_shown_only_when_sam3_is_sure(self):
# "The whole picture is a mirror", and SAM 3 only fairly sure: the
# pointer must not say "mirror" over everything nothing else covers.
everything = torch.ones(H, W)
everything[:, :10] = 0.0
self.addCleanup(NAMED.pop, "mirror")
NAMED["mirror"] = [(everything, 0.81)]
note = self.look()
self.assertNotIn("mirror", [entry["name"] for entry in note["pieces"]["list"]])
# Typed, it is what the user asked for, and is found.
out = self.run_node(json.dumps({"v": 1, "items": [{"t": "all", "w": "mirror", "op": 1}]}))
self.assertTrue(torch.equal(self.area(out), everything.bool()))
def test_a_tap_made_before_the_look_is_the_piece_the_look_shows(self):
on_the_poster = next(point for point in node_module._scope_plan(W, H) if float(POSTER[point[1], point[0]]) > 0)
tapped = self.area(self.run_node(touches(tap(*on_the_poster))))
note = self.note(self.run_node("", ask="scope"))
poster = next(index for index, entry in enumerate(note["pieces"]["list"]) if (entry["x"], entry["y"]) == on_the_poster)
self.assertTrue(torch.equal(self.pieces_of(note)[poster], tapped))
self.assertEqual(asked("points").count([on_the_poster]), 1, "asked once, by the tap")
def test_a_small_picture_is_asked_about_fewer_points(self):
self.swap("SCOPE_SPACING", 16)
plan = node_module._scope_plan(W, H)
self.assertEqual(plan, scope_points(W, H, (W * H) // 256), "about one point for every 16 x 16 pixels")
self.assertTrue(70 <= len(plan) <= 85)
self.assertEqual(node_module._scope_plan(912, 1632), scope_points(912, 1632, node_module.SCOPE_POINTS))
self.assertEqual(len(node_module._scope_plan(8, 8)), 1)
self.assertEqual(len(node_module._scope_queue(8, 8)), 105, "every word, and the one point")
note = self.note(self.run_node("", ask="scope"))
self.assertEqual(len(asked("points")), len(plan))
self.assertIs(note["pieces"]["more"], False)
def test_an_ordinary_run_returns_the_pieces_found_so_far(self):
self.assertIsNone(self.note(self.run_node("", ask="look"))["pieces"], "null means: not looked at yet")
scoped = self.note(self.run_node("", ask="scope"))["pieces"]
plain = self.note(self.run_node(touches(tap(75, 34))))["pieces"]
self.assertEqual(plain, scoped, "the same files, not written again")
other = self.note(self.run_node(touches(tap(75, 34)), node=node_module.AusBossMaskByTouch()))["pieces"]
self.assertEqual(other["list"], scoped["list"], "another node on the same picture has them too")
self.assertNotEqual(other["planes"], scoped["planes"])
def test_a_picture_with_nothing_in_it_has_no_pieces_which_is_not_null(self):
class Blind(FakeSam):
@classmethod
def execute(cls, model, image, conditioning=None, **_):
if conditioning is not None:
return types.SimpleNamespace(result=(torch.zeros(0, H, W), [[]]))
return types.SimpleNamespace(result=(torch.zeros(image.shape[0], H, W), [[]]))
self.swap("_core_nodes", lambda: {"SAM3_Detect": Blind, "CLIPTextEncode": FakeEncode})
self.assertEqual(self.note(self.run_node("", ask="scope"))["pieces"], {"planes": [], "list": [], "more": False, "words": [104, 104]})
def test_over_120_pieces_the_point_pieces_go_first_and_five_planes_hold_the_rest(self):
saved = dict(NAMED)
self.addCleanup(NAMED.update, saved)
self.addCleanup(NAMED.clear)
NAMED.clear()
tiles = [block(row * 6, column * 6, 4, 4) for row in range(20) for column in range(8)] # 160 small things
for number, word in enumerate(WORDS_FIRST[:10]):
NAMED[word] = [(tile, 0.99 - 0.001 * (number * 16 + slot)) for slot, tile in enumerate(tiles[number * 16:number * 16 + 16])]
note = self.look()
pieces = note["pieces"]
self.assertEqual((len(pieces["list"]), len(pieces["planes"])), (120, 5))
self.assertEqual({entry["kind"] for entry in pieces["list"]}, {"name"}, "the point pieces went first")
self.assertEqual(min(entry["sure"] for entry in pieces["list"]), 0.871, "then the names SAM 3 was least sure of")
shown = self.pieces_of(note)
for index in (0, 23, 24, 119):
entry = pieces["list"][index]
self.assertTrue(torch.equal(self.area(self.run_node(touches(self.touch_for(entry)))), shown[index]))
class RoundTests(TouchCase):
"""A look goes through the whole list, gives way to any job that waits,
and the next look goes on where it stopped."""
def test_one_look_asks_everything_while_no_job_waits(self):
self.swap("_jobs_waiting", lambda: 0)
clock = [0.0]
self.swap("time", types.SimpleNamespace(perf_counter=lambda: clock[0]))
class Slow(FakeSam):
@classmethod
def execute(cls, **kwargs):
clock[0] += 1.0 # far longer than a round used to be
return FakeSam.execute(**kwargs)
self.swap("_core_nodes", lambda: {"SAM3_Detect": Slow, "CLIPTextEncode": FakeEncode})
note = self.note(self.run_node("", ask="scope"))
self.assertIs(note["pieces"]["more"], False)
self.assertEqual(len(FakeSam.calls), len(node_module._scope_queue(W, H)), "no time limit: one run, the whole list")
self.assertEqual([(entry["name"], entry["share"]) for entry in note["pieces"]["list"]], [(word, share(thing)) for word, thing, _ in LOOKED])
def test_a_look_cut_short_hands_back_and_the_next_goes_on(self):
self.short_looks(50)
total = len(node_module._scope_queue(W, H))
seen, rounds = {}, 0
while True:
before = len(FakeSam.calls)
note = self.note(self.run_node("", ask="scope"))
rounds += 1
pieces = note["pieces"]
self.assertLessEqual(len(FakeSam.calls) - before, 50, "a look that is cut short hands back")
self.assertEqual(pieces["words"], [sum(1 for call in FakeSam.calls if "word" in call), 104])
for mask, entry in zip(self.pieces_of(note), pieces["list"]):
ident = (entry["name"], entry["x"], entry["y"])
if ident in seen:
self.assertTrue(torch.equal(seen[ident], mask), "a piece keeps its point and its shape")
seen[ident] = mask
self.assertEqual([entry["share"] for entry in pieces["list"]], sorted(entry["share"] for entry in pieces["list"]))
if not pieces["more"]:
break
self.assertEqual(rounds, math.ceil(total / 50))
self.assertEqual(len(FakeSam.calls), total, "every question is asked once over the rounds")
self.assertEqual([(entry["name"], entry["share"]) for entry in pieces["list"]], [(word, share(thing)) for word, thing, _ in LOOKED])
self.run_node("", ask="scope")
self.assertEqual(len(FakeSam.calls), total, "nothing is left to ask")
def test_a_round_stops_as_soon_as_another_job_waits(self):
# A click arrives while the 6th question is asked, and waits from then on.
self.swap("_jobs_waiting", lambda: 1 if len(FakeSam.calls) >= 6 else 0)
first = self.note(self.run_node("", ask="scope"))
self.assertEqual(asked("word"), list(WORDS_FIRST[:6]), "the question in hand is finished, then the round ends")
self.assertIs(first["pieces"]["more"], True)
self.assertEqual([entry["name"] for entry in first["pieces"]["list"]], ["hand", "hand", "face", "shirt", "person"], "what was found so far comes back")
# While something waits, every round still asks one question, so the list always ends.
self.note(self.run_node("", ask="scope"))
self.assertEqual(asked("word"), list(WORDS_FIRST[:7]))
self.note(self.run_node("", ask="scope:130,90"))
self.assertEqual(asked("points"), [[(130, 90)]], "the pointer's place first")
self.assertEqual(asked("word"), list(WORDS_FIRST[:8]), "and one question of the list")
def test_reading_words_ahead_stops_when_a_job_waits(self):
self.swap("_jobs_waiting", lambda: 1)
self.note(self.run_node("", ask="scope"))
self.assertEqual((len(FakeEncode.calls), asked("word")), (1, ["person"]), "the word in hand is read and asked, no more")
def test_where_the_queue_cannot_be_read_rounds_are_short(self):
self.swap("_jobs_waiting", lambda: None)
self.swap("SCOPE_SECONDS_BLIND", -1.0)
note = self.note(self.run_node("", ask="scope"))
self.assertEqual(len(FakeSam.calls), 1, "its short time is over after the first question")
self.assertIs(note["pieces"]["more"], True)
self.swap("SCOPE_SECONDS_BLIND", 1e9)
self.assertIs(self.note(self.run_node("", ask="scope"))["pieces"]["more"], False)
def test_the_real_settings(self):
self.doCleanups() # what setUp changed for the tests is put back
self.assertFalse(hasattr(node_module, "SCOPE_SECONDS"), "a look has no time limit where the queue can be read")
self.assertEqual((node_module.SCOPE_SECONDS_BLIND, node_module.SCOPE_FLOOR), (1.0, 1))
self.assertEqual((node_module.LOOK_TELL_SECONDS, node_module.LOOK_EVENT), (1.5, "ausboss.mask_by_touch.look"))
self.assertIs(node_module.QUIET, True)
self.assertEqual((node_module.SCOPE_POINTS, node_module.SCOPE_FIRST_POINTS, node_module.WORDS_AHEAD), (400, 48, 16))
def test_a_slow_first_answer_does_not_use_up_a_look_that_is_timed(self):
# Where the queue cannot be read a look is timed. Loading SAM 3 takes
# longer than that: the time starts after it.
clock = [0.0]
self.swap("time", types.SimpleNamespace(perf_counter=lambda: clock[0]))
self.swap("_jobs_waiting", lambda: None)
self.swap("SCOPE_SECONDS_BLIND", 3.0)
class Slow(FakeSam):
@classmethod
def execute(cls, **kwargs):
clock[0] += 5.0 if not FakeSam.calls else 0.5
return FakeSam.execute(**kwargs)
self.swap("_core_nodes", lambda: {"SAM3_Detect": Slow, "CLIPTextEncode": FakeEncode})
self.note(self.run_node("", ask="scope"))
self.assertEqual(len(FakeSam.calls), 8, "the loading answer, then three seconds of half-second answers")
self.note(self.run_node("", ask="scope"))
self.assertEqual(len(FakeSam.calls), 15, "a warm look: three seconds from its start")
def test_the_queue_is_only_looked_at(self):
class Queue:
def __init__(self):
self.queue = [("a job",), ("another",)]
def __setattr__(self, key, value):
if key != "queue" or hasattr(self, "queue"):
raise AssertionError("the queue was changed")
object.__setattr__(self, key, value)
server = types.ModuleType("server")
server.PromptServer = types.SimpleNamespace(instance=types.SimpleNamespace(prompt_queue=Queue()))
self.addCleanup(sys.modules.pop, "server", None)
sys.modules["server"] = server
self.assertEqual(node_module._jobs_waiting(), 2)
self.assertEqual(server.PromptServer.instance.prompt_queue.queue, [("a job",), ("another",)])
server.PromptServer = types.SimpleNamespace(instance=None)
self.assertIsNone(node_module._jobs_waiting(), "another ComfyUI: it cannot be read")
del sys.modules["server"]
self.assertIsNone(node_module._jobs_waiting())
def test_the_server_is_only_asked_to_send(self):
sent = []
server = types.ModuleType("server")
instance = types.SimpleNamespace(client_id="a page", send_sync=lambda *args: sent.append(args))
server.PromptServer = types.SimpleNamespace(instance=instance)
self.addCleanup(sys.modules.pop, "server", None)
sys.modules["server"] = server
self.assertIs(node_module._tell(5, {"key": "k"}, True), True)
self.assertEqual(sent, [("ausboss.mask_by_touch.look", {"node": "5", "note": {"key": "k"}, "end": True}, "a page")], "to the page that asked")
self.assertIs(node_module._tell(None, {}, True), False, "no id: nobody to tell")
server.PromptServer = types.SimpleNamespace(instance=None)
self.assertIs(node_module._tell(5, {}, True), False, "no page to tell")
del sys.modules["server"]
self.assertIs(node_module._tell(5, {}, True), False, "outside ComfyUI")
self.assertEqual(len(sent), 1)
def test_a_first_look_stopped_half_way_is_gone_on_with(self):
class Stopped(BaseException):
pass
left = [30]
def check():
left[0] -= 1
if left[0] < 0:
raise Stopped()
self.swap("raise_if_interrupted", check)
with self.assertRaises(Stopped):
self.run_node("", ask="scope")
self.swap("raise_if_interrupted", lambda: None)
self.assertEqual(len(FakeSam.calls), 30)
waiting = self.note(self.run_node("", ask="look"))["pieces"]
self.assertEqual((waiting["list"], waiting["more"], waiting["words"]), ([], True, [30, 104]), "nothing to show yet, and it says more is left")
last = self.note(self.run_node("", ask="scope"))
self.assertEqual([(entry["name"], entry["share"]) for entry in last["pieces"]["list"]], [(word, share(thing)) for word, thing, _ in LOOKED])
self.assertEqual(len(FakeSam.calls), len(node_module._scope_queue(W, H)), "the 30 words are not asked again")
def test_a_round_stopped_half_way_loses_nothing(self):
class Stopped(BaseException):
pass
self.short_looks(20)
first = self.note(self.run_node("", ask="scope"))
self.assertIs(first["pieces"]["more"], True)
# The user stops the second round after 250 more questions.
left = [250]
def check():
left[0] -= 1
if left[0] < 0:
raise Stopped()
self.swap("SCOPE_FLOOR", 1000)
self.swap("raise_if_interrupted", check)
with self.assertRaises(Stopped):
self.run_node("", ask="scope")
self.swap("raise_if_interrupted", lambda: None)
last = self.note(self.run_node("", ask="scope"))
self.assertIs(last["pieces"]["more"], False)
self.assertEqual([(entry["name"], entry["share"]) for entry in last["pieces"]["list"]], [(word, share(thing)) for word, thing, _ in LOOKED])
for mask, (_, thing, _) in zip(self.pieces_of(last), LOOKED):
self.assertTrue(torch.equal(mask, thing.bool()))
self.assertGreater(len(last["pieces"]["list"]), len(first["pieces"]["list"]), "the stopped round had found something")
total = len(node_module._scope_queue(W, H))
self.assertTrue(total <= len(FakeSam.calls) <= total + 2, "only a point piece the stopped round kept is asked for again")
def test_the_pointers_place_is_asked_first(self):
self.short_looks(3)
note = self.note(self.run_node("", ask="scope:130,8"))
self.assertEqual(FakeSam.calls[0]["points"], [(130, 8)])
self.assertEqual(asked("word"), list(WORDS_FIRST[:3]))
poster = next(entry for entry in note["pieces"]["list"] if entry["kind"] == "point")
self.assertEqual((poster["x"], poster["y"], poster["share"], poster["name"], poster["sure"]), (130, 8, share(POSTER), None, None))
self.assertIs(note["pieces"]["more"], True)
before = len(FakeSam.calls)
self.run_node(touches(tap(130, 8)))
self.assertEqual(len(FakeSam.calls), before, "a click there afterwards costs nothing")
def test_a_small_thing_under_the_pointer_is_a_piece_and_one_point_is_asked_once_all_is_looked_at(self):
dot = block(100, 150, 2, 2)
self.addCleanup(SCENE.remove, ("dot", dot, dot))
SCENE.insert(0, ("dot", dot, dot))
note = self.note(self.run_node("", ask="scope"))
self.assertIs(note["pieces"]["more"], False)
self.assertEqual(len(note["pieces"]["list"]), len(LOOKED), "too small for a point piece unless it is pointed at")
before = len(FakeSam.calls)
pointed = self.note(self.run_node("", ask="scope:151,101"))
self.assertEqual(len(FakeSam.calls), before + 1, "just that one point")
self.assertEqual(
pointed["pieces"]["list"][0],
{"x": 151, "y": 101, "share": round(4 / (H * W), 5), "box": [150, 100, 152, 102], "kind": "point", "name": None, "sure": None},
)
self.assertEqual(pointed["pieces"]["words"], [104, 104])
self.assertIs(pointed["pieces"]["more"], False)
self.assertTrue(torch.equal(self.pieces_of(pointed)[0], dot.bool()))
self.assertNotEqual(pointed["pieces"]["planes"], note["pieces"]["planes"], "a new list is a new file")
# A point outside the picture, or not two whole numbers, is a plain look.
for ask in ("scope:999,5", "scope:a,b"):
self.assertEqual(self.note(self.run_node("", ask=ask))["pieces"], pointed["pieces"])
# The pointer on a named piece: the point's answer is the same area, so nothing is added.
again = self.note(self.run_node("", ask="scope:75,34"))
self.assertEqual(again["pieces"], pointed["pieces"])
self.assertEqual(len(FakeSam.calls), before + 2)
class ModelTests(TouchCase):
CLIP = types.SimpleNamespace(patcher="sam3 text reader")
def use_memory(self, management):
self.swap("_model_management", lambda: management)
def test_free_vram_unloads_sam3_and_its_text_reader_and_only_when_asked(self):
unloaded = []
self.use_memory(types.SimpleNamespace(unload_model_and_clones=unloaded.append))
self.run_node(touches(tap(75, 34)), clip=self.CLIP)
self.assertEqual(unloaded, [], "off by default: SAM 3 stays loaded for the next pick")
mask = self.run_node(touches(tap(75, 34)), clip=self.CLIP, free_vram=True)["result"][0]
self.assertEqual(unloaded, ["sam3 model", "sam3 text reader"], "only the two it was given")
self.assertTrue(torch.equal(mask[0] > 0.5, GLASSES.bool()))
self.swap("SCOPE_SECONDS_BLIND", -1.0)
for ask in ("scope", "scope:3,3", "look"):
self.run_node(touches(tap(75, 34)), clip=self.CLIP, free_vram=True, ask=ask)
self.assertEqual(len(unloaded), 2, "a run the face asked for keeps SAM 3 for the next click")
spec = node_module.AusBossMaskByTouch.INPUT_TYPES()["optional"]["free_vram"][1]
self.assertIs(spec["default"], False)
def test_free_vram_also_unloads_when_the_run_stops_and_never_fails_a_run(self):
unloaded = []
self.use_memory(types.SimpleNamespace(unload_model_and_clones=unloaded.append))
with self.assertRaises(RuntimeError):
self.run_node("", clip=self.CLIP, free_vram=True)
self.assertEqual(unloaded, ["sam3 model", "sam3 text reader"])
def refuses(_patcher):
raise RuntimeError("cannot unload")
for management in (types.SimpleNamespace(), None, types.SimpleNamespace(unload_model_and_clones=refuses)):
self.use_memory(management)
for clip in (self.CLIP, None, "a text reader without a patcher"):
mask = self.run_node(touches(tap(75, 34)), clip=clip, free_vram=True)["result"][0]
self.assertTrue(torch.equal(mask[0] > 0.5, GLASSES.bool()))
def test_without_comfyuis_sam3_it_says_what_it_needs(self):
self.swap("_core_nodes", lambda: {})
for text in (touches(tap(75, 34)), touches(name("face", 60, 45))):
with self.assertRaises(RuntimeError) as needs:
self.run_node(text)
self.assertEqual(str(needs.exception), NEEDS_SAM3)
self.assertEqual(float(self.run_node(touches(paint([[10, 10]], r=3)))["result"][0].sum()), float(stroke_area([[10, 10]], 3, H, W).sum()), "paint needs no model")
def test_a_model_that_is_not_sam3_gets_wiring_advice_not_a_stack_trace(self):
class Broken(FakeSam):
@classmethod
def execute(cls, **_kwargs):
raise AttributeError("'Flux' object has no attribute 'forward_segment'")
self.swap("_core_nodes", lambda: {"SAM3_Detect": Broken, "CLIPTextEncode": FakeEncode})
for text in (touches(tap(75, 34)), touches(name("face", 60, 45))):
with self.assertRaises(RuntimeError) as advice:
self.run_node(text)
self.assertIn("Load Checkpoint that loads the SAM 3 file", str(advice.exception))
self.assertIn("forward_segment", str(advice.exception))
self.assertIn("Mask by Touch", str(advice.exception))
class WrongShape(FakeSam):
@classmethod
def execute(cls, **_kwargs):
return types.SimpleNamespace(result=(torch.zeros(1, 7, 7), [[]]))
self.swap("_core_nodes", lambda: {"SAM3_Detect": WrongShape, "CLIPTextEncode": FakeEncode})
with self.assertRaises(RuntimeError) as advice:
self.run_node(touches(tap(75, 34)))
self.assertIn("not one mask the size of the picture", str(advice.exception))
def test_a_stop_by_the_user_and_a_full_gpu_are_passed_on_as_they_are(self):
class InterruptProcessingException(BaseException):
pass
for error in (InterruptProcessingException(), RuntimeError("CUDA out of memory")):
class Raises(FakeSam):
@classmethod
def execute(cls, **_kwargs):
raise error
class RaisesToo(FakeEncode):
def encode(self, clip, text):
raise error
for core in ({"SAM3_Detect": Raises, "CLIPTextEncode": FakeEncode}, {"SAM3_Detect": FakeSam, "CLIPTextEncode": RaisesToo}):
self.swap("_core_nodes", lambda core=core: core)
node_module._WORDS.clear() # the word is read anew
with self.assertRaises(type(error)) as passed:
self.run_node(touches(name("face", 60, 45)))
self.assertIs(passed.exception, error)
class Reader:
"""Stands in for SAM 3's picture reader: counts how often it reads."""
def __init__(self):
self.reads = []
def forward(self, images, tracker_mode=None):
self.reads.append(tracker_mode)
return [images.mean()]
class QuickTests(TouchCase):
"""Asking SAM 3 the quick way: the picture is read once and the model's
load is checked once per run, and only after both are proven harmless,
for points and for words apart."""
def setUp(self):
super().setUp()
self.reader = Reader()
detector = types.SimpleNamespace(backbone={"vision_backbone": self.reader})
self.model = types.SimpleNamespace(model=types.SimpleNamespace(diffusion_model=types.SimpleNamespace(detector=detector)))
self.clip = types.SimpleNamespace(patcher="the text reader", cond_stage_model=object())
self.loads = []
self.management = types.SimpleNamespace(current_loaded_models=[], load_model_gpu=self.load)
self.swap("_model_management", lambda: self.management)
test = self
class AsCore(FakeSam):
"""Loads the model and reads the picture, as ComfyUI does: twice
for a point; for a word once, and twice the cut-out of each match."""
spoiled = False
unsteady = 0
@classmethod
def execute(cls, model, image, conditioning=None, **kwargs):
test.management.load_model_gpu(model)
quick = "forward" in vars(test.reader)
out = FakeSam.execute(model, image, conditioning=conditioning, **kwargs)
if conditioning is None:
for _ in range(2):
test.reader.forward(image, tracker_mode="interactive")
else:
test.reader.forward(image, tracker_mode="propagation")
for match in out.result[0]:
for _ in range(2):
test.reader.forward(image * match[None, :, :, None], tracker_mode="interactive")
if (cls.spoiled and quick) or cls.unsteady:
cls.unsteady += 1 if cls.unsteady else 0
if out.result[0].shape[0]:
out.result[0][:, 0, (cls.unsteady + 1) % W] = 1.0
return out
class ReadsAsCore(FakeEncode):
"""Loading the text reader puts it in front of SAM 3."""
def encode(self, clip, text):
test.management.current_loaded_models.insert(0, types.SimpleNamespace(model=getattr(clip, "patcher", clip)))
return super().encode(clip, text)
self.detector, self.encoder = AsCore, ReadsAsCore
def load(self, model):
self.loads.append(model)
self.management.current_loaded_models.insert(0, types.SimpleNamespace(model=model))
def quick(self, **overrides):
return self.run_node(model=self.model, clip=self.clip, **overrides)
def trust(self):
return {kind: value for (_, kind), value in node_module._TRUST.items()}
def three_taps(self):
return touches(tap(75, 34), tap(130, 90), tap(50, 100))
def three_names(self):
return touches(name("face", 60, 45), name("cup", 130, 90), name("person", 50, 100))
def test_the_first_run_proves_it_for_points_and_later_runs_use_it(self):
out = self.quick(text=self.three_taps())
self.assertTrue(torch.equal(self.area(out), (GLASSES + MUG + PERSON).bool()))
self.assertEqual(self.trust(), {"point": True})
# 1st question plain (a load, two reads); 2nd with it on (a load, one
# read); the 1st again as the proof and the 3rd: nothing new.
self.assertEqual((len(self.loads), len(self.reader.reads), len(FakeSam.calls)), (2, 3, 4))
self.loads.clear()
self.reader.reads.clear()
self.quick(text=touches(tap(60, 45), tap(131, 91), tap(51, 101)))
self.assertEqual((len(self.loads), len(self.reader.reads)), (1, 1), "one load and one read for a whole run")
def test_words_are_proven_apart_from_points(self):
self.quick(text=self.three_taps())
self.loads.clear()
self.reader.reads.clear()
out = self.quick(text=self.three_names())
self.assertTrue(torch.equal(self.area(out), (FACE + MUG + PERSON).bool()))
self.assertEqual(self.trust(), {"point": True, "word": True})
# face the plain way: a load, the picture, its cut-out twice. Then the
# quick way: a load, the picture, the cup's cut-out; face again for
# the proof: its cut-out; person: its cut-out, and a load, because
# reading the word "person" put the text reader in front.
self.assertEqual(len(self.loads), 3)
self.assertEqual(self.reader.reads, ["propagation", "interactive", "interactive", "propagation", "interactive", "interactive", "interactive"])
self.assertEqual(asked("word"), ["face", "cup", "face", "person"])
def test_a_question_that_finds_nothing_proves_nothing(self):
out = self.quick(text=touches(tap(*WALL), tap(6, 6), tap(75, 34), tap(130, 90)), if_nothing="empty mask")
self.assertTrue(torch.equal(self.area(out), (GLASSES + MUG).bool()))
self.assertEqual(self.trust(), {"point": True})
# Three plain questions until one finds something, then the proof.
self.assertEqual((len(self.loads), len(FakeSam.calls)), (4, 5))
def test_a_whole_look_costs_a_load_for_each_batch_of_words_and_a_read_for_each_match(self):
# Both kinds of question are proven first, on words the look does not ask.
for word, thing in (("one", FACE), ("two", MUG)):
self.addCleanup(NAMED.pop, word)
NAMED[word] = [(thing, 0.9)]
self.quick(text=self.three_taps())
self.quick(text=touches(name("one", 60, 45), name("two", 130, 90)), image=self.picture.flip(1))
self.assertEqual(self.trust(), {"point": True, "word": True})
self.loads.clear()
self.reader.reads.clear()
note = self.note(self.quick(ask="scope"))
self.assertEqual([entry["name"] for entry in note["pieces"]["list"]], [word for word, _, _ in LOOKED])
# SAM 3 is loaded again after each batch of words the text reader read
# (it came in front), not for each of the 503 questions.
batches = math.ceil(len(WORDS_FIRST) / node_module.WORDS_AHEAD) + math.ceil(len(WORDS_MORE) / node_module.WORDS_AHEAD)
self.assertEqual(len(self.loads), batches)
matches = sum(len(found) for found in NAMED.values())
self.assertEqual(self.reader.reads.count("propagation"), 1, "the whole picture is read once for all the words")
self.assertLessEqual(self.reader.reads.count("interactive"), matches + 3, "a cut-out is read once, not twice; the picture once more for points")
def test_everything_is_put_back_after_a_run_also_one_that_stops(self):
plain_load = self.management.load_model_gpu
self.quick(text=self.three_taps())
self.quick(text=self.three_names())
self.assertNotIn("forward", vars(self.reader))
self.assertIs(self.management.load_model_gpu.__func__, plain_load.__func__)
with self.assertRaises(RuntimeError):
self.quick(text=touches(tap(*WALL), tap(6, 6), tap(7, 7)))
self.assertNotIn("forward", vars(self.reader))
self.assertIs(self.management.load_model_gpu.__func__, plain_load.__func__)
def test_another_picture_is_read_again_and_another_model_is_loaded_as_always(self):
self.quick(text=self.three_taps())
self.reader.reads.clear()
lower = self.picture.clone()
lower[0, 0, 0] = 1.0
self.quick(text=self.three_taps(), image=torch.cat([self.picture.flip(2), lower]))
self.assertEqual(len(self.reader.reads), 2, "once for each of the two pictures")
with node_module._loaded_once(self.model):
self.loads.clear()
for _ in range(3):
self.management.load_model_gpu("another model")
self.management.load_model_gpu(self.model)
self.assertEqual(self.loads.count("another model"), 3)
self.assertEqual(self.loads.count(self.model), 3, "loaded again each time another model came in front")
def test_if_it_changes_an_answer_it_is_never_used_again(self):
self.detector.spoiled = True
out = self.quick(text=self.three_taps())
self.assertTrue(torch.equal(self.area(out), (GLASSES + MUG + PERSON).bool()), "the answers are the plain ones")
self.assertEqual(self.trust(), {"point": False, "word": False}, "for words too: something is wrong here")
self.loads.clear()
self.reader.reads.clear()
self.quick(text=touches(tap(60, 45), tap(131, 91), tap(51, 101)))
self.assertEqual((len(self.loads), len(self.reader.reads)), (3, 6), "every question the plain way")
def test_if_it_changes_a_words_answer_it_is_never_used_again(self):
self.quick(text=self.three_taps())
self.detector.spoiled = True
out = self.quick(text=self.three_names())
self.assertTrue(torch.equal(self.area(out), (FACE + MUG + PERSON).bool()), "the answers are the plain ones")
self.assertEqual(self.trust(), {"point": False, "word": False})
def test_a_sam3_that_never_repeats_an_answer_is_asked_the_plain_way(self):
self.detector.unsteady = 1
for run in range(3):
self.quick(text=touches(tap(75 + run, 34), tap(130 + run, 90), tap(50 + run, 100)))
self.assertNotIn("forward", vars(self.reader))
self.assertEqual(self.trust(), {"point": False}, "after three tries it is left off")
def test_a_question_that_fails_with_it_on_is_asked_again_the_plain_way(self):
self.quick(text=self.three_taps())
plain_execute = self.detector.execute.__func__
failed = []
def fails_when_quick(cls, model, image, **kwargs):
if "forward" in vars(self.reader):
failed.append(1)
raise RuntimeError("Expected all tensors to be on the same device")
return plain_execute(cls, model, image, **kwargs)
self.detector.execute = classmethod(fails_when_quick)
out = self.quick(text=touches(tap(60, 45), tap(131, 91)))
self.assertTrue(torch.equal(self.area(out), (FACE + MUG).bool()))
self.assertEqual((len(failed), self.trust()), (1, {"point": False, "word": False}))
def test_a_wrong_text_reader_does_not_cost_the_quick_way(self):
self.quick(text=self.three_taps())
self.quick(text=self.three_names())
plain_execute = self.detector.execute.__func__
def cannot_use(cls, model, image, conditioning=None, **kwargs):
if conditioning is not None and conditioning[0][1] == "wrong":
raise RuntimeError("mat1 and mat2 shapes cannot be multiplied")
return plain_execute(cls, model, image, conditioning=conditioning, **kwargs)
self.detector.execute = classmethod(cannot_use)
with self.assertRaises(RuntimeError) as advice:
self.run_node(touches(tap(61, 46), name("hand", 45, 73)), model=self.model, clip="wrong")
self.assertTrue(str(advice.exception).startswith(NOT_SAM3))
self.assertEqual(self.trust(), {"point": True, "word": True}, "the wiring was wrong, not the quick way")
self.assertNotIn("forward", vars(self.reader))
def test_it_can_be_switched_off(self):
self.swap("QUICK", False)
self.quick(text=self.three_taps())
self.assertEqual((len(self.loads), len(self.reader.reads), node_module._TRUST), (3, 6, {}))
def test_without_the_parts_it_needs_it_asks_the_plain_way(self):
self.swap("_model_management", lambda: None)
bare = types.SimpleNamespace(model=object())
out = self.run_node(self.three_taps(), model=bare)
self.assertTrue(torch.equal(self.area(out), (GLASSES + MUG + PERSON).bool()))
self.assertEqual(self.trust(), {"point": False})
class AllTests(TouchCase):
"""An `all` touch: every thing SAM 3 calls by a word, joined."""
def test_all_takes_every_thing_of_that_name(self):
out = self.run_node(touches(every("hand"), key=picture_key(self.picture)))
self.assertTrue(torch.equal(self.area(out), (HAND_LEFT + HAND_RIGHT).bool()))
self.assertEqual(self.note(out)["items"], [{"ok": True, "share": share(HAND_LEFT + HAND_RIGHT), "as": "thing", "count": 2}])
(call,) = FakeSam.calls
self.assertEqual((call["text"], call["threshold"], call["passes"], call["individual"]), ("hand:16", 0.5, 2, True), "asked as the list's words are")
def test_things_inside_each_other_are_joined_and_counted(self):
out = self.run_node(touches(every("window")))
self.assertTrue(torch.equal(self.area(out), WINDOW.bool()), "the pane lies inside the window")
self.assertEqual(self.note(out)["items"][0]["count"], 2)
def test_all_can_take_away(self):
out = self.run_node(touches(tap(50, 100), every("hand", op=0)))
self.assertTrue(torch.equal(self.area(out), PERSON.bool() & ~(HAND_LEFT + HAND_RIGHT).bool()))
self.assertEqual(self.note(out)["items"][1], {"ok": True, "share": share(HAND_LEFT + HAND_RIGHT), "as": "thing", "count": 2, "did": True})
def test_a_word_nothing_is_called_by_does_nothing(self):
out = self.run_node(touches(tap(75, 34), every("unicorn"), every(""), every(" ,:() ")))
self.assertTrue(torch.equal(self.area(out), GLASSES.bool()))
nothing = {"ok": False, "share": 0.0, "as": "none", "count": 0}
self.assertEqual(self.note(out)["items"][1:], [nothing, nothing, nothing])
self.assertEqual(asked("word"), ["unicorn"], "an empty word asks nothing")
with self.assertRaises(RuntimeError) as stopped:
self.run_node(touches(every("unicorn")))
self.assertEqual(str(stopped.exception), NOTHING_PICKED)
def test_the_word_is_cleaned_and_only_all_has_a_count(self):
out = self.run_node(touches(every(" ( hand ), "), tap(75, 34), name("face", 60, 45), paint([[10, 10]], r=2)))
self.assertEqual(FakeEncode.calls, ["hand:16", "face:16"])
self.assertEqual(["count" in item for item in self.note(out)["items"]], [True, False, False, False])
def test_all_shares_what_a_name_and_a_look_asked(self):
self.run_node(touches(name("hand", 45, 73)))
self.run_node(touches(every("hand")))
self.assertEqual(asked("word"), ["hand"], "asked once for both kinds of touch")
self.look()
count = len(FakeSam.calls)
out = self.run_node(touches(every("window"), every("cup")))
self.assertEqual(len(FakeSam.calls), count, "after a look it costs nothing")
self.assertTrue(torch.equal(self.area(out), (WINDOW + MUG).bool()), "the cup without its speck, as the piece shows it")
def test_a_batch_asks_the_word_of_every_picture(self):
lower = self.picture.clone()
lower[0, 0, 0] = 1.0 # the fake shows this one's scene 10 px lower
mask = self.run_node(touches(every("hand")), image=torch.cat([self.picture, lower]))["result"][0]
both = (HAND_LEFT + HAND_RIGHT).bool()
self.assertTrue(torch.equal(mask[0] > 0.5, both))
self.assertTrue(torch.equal(mask[1] > 0.5, torch.roll(both, 10, 0)))
def test_all_without_a_text_reader_says_what_to_wire(self):
with self.assertRaises(RuntimeError) as needs:
self.run_node(touches(every("hand")), clip=None)
self.assertEqual(str(needs.exception), NO_TEXT_READER)
class InvertTests(TouchCase):
def test_invert_turns_the_mask_round(self):
text = touches(tap(130, 90))
plain = self.run_node(text)
out = self.run_node(text, invert=True)
mask, cut, _ = out["result"]
self.assertTrue(torch.equal(mask, 1.0 - plain["result"][0]), "white where nothing was picked")
self.assertTrue(torch.equal(cut[0, ..., 3], mask[0]), "the cut-out follows the mask")
note = self.note(out)
self.assertEqual(note["share"], round(1.0 - share(MUG), 5), "the share is the mask's")
self.assertEqual(note["items"], self.note(plain)["items"])
selection = self.opened(note["selection"]).pixels > 0
self.assertTrue(torch.equal(selection, MUG.bool()), "the face's selection stays what was picked")
def test_it_is_turned_round_after_the_edge_steps(self):
text = touches(tap(130, 90))
grown = self.run_node(text, grow=3)["result"][0]
turned = self.run_node(text, grow=3, invert=True)["result"][0]
self.assertTrue(torch.equal(turned, 1.0 - grown))
self.assertEqual((float(turned[0, 78, 130]), float(turned[0, 76, 130])), (0.0, 1.0), "the grown edge is left out too")
soft = self.run_node(text, soften=3.0)["result"][0]
self.assertTrue(torch.allclose(self.run_node(text, soften=3.0, invert=True)["result"][0], 1.0 - soft))
def test_invert_does_not_make_everything_out_of_nothing(self):
with self.assertRaises(RuntimeError) as stopped:
self.run_node("", invert=True)
self.assertEqual(str(stopped.exception), NOTHING_PICKED)
out = self.run_node(touches(tap(*WALL)), invert=True, if_nothing="empty mask")
self.assertEqual(float(out["result"][0].sum()), 0.0, "an empty mask stays empty")
self.assertEqual(float(out["result"][1][..., 3].sum()), 0.0)
self.assertEqual(float(self.run_node("", invert=True, ask="look")["result"][0].sum()), 0.0)
def test_in_a_batch_only_a_picture_with_something_picked_is_turned_round(self):
lower = self.picture.clone()
lower[0, 0, 0] = 1.0
mask = self.run_node(touches(tap(130, 112)), image=torch.cat([self.picture, lower]), invert=True)["result"][0]
self.assertEqual(float(mask[0].sum()), 0.0, "nothing there in the first picture")
self.assertTrue(torch.equal(mask[1] > 0.5, ~torch.roll(MUG, 10, 0).bool()))
def test_it_is_off_unless_asked_for(self):
spec = node_module.AusBossMaskByTouch.INPUT_TYPES()["optional"]["invert"]
self.assertEqual((spec[0], spec[1]["default"]), ("BOOLEAN", False))
self.assertTrue(torch.equal(self.area(self.run_node(touches(tap(130, 90)))), MUG.bool()))
class NamesTests(TouchCase):
"""The third output: the names of what is picked."""
def names(self, *items, **overrides):
return self.run_node(touches(*items), **{"if_nothing": "empty mask", **overrides})["result"][2]
def test_the_names_come_in_the_order_they_were_picked_each_once(self):
self.assertEqual(
self.names(name("shirt", 75, 80), tap(130, 90), name("hand", 45, 73), name("hand", 104, 75), every("window"), name("shirt", 75, 80)),
"shirt, hand, window",
)
def test_taps_strokes_and_paint_add_no_name(self):
self.assertEqual(self.names(tap(75, 34), find([[122, 82], [140, 100]], r=2), paint([[10, 10]], r=3)), "")
self.assertEqual(self.names(), "")
def test_a_name_that_found_nothing_is_not_a_name_of_what_is_picked(self):
self.assertEqual(self.names(tap(75, 34), name("hand", 75, 80), every("unicorn"), name("", 75, 80)), "")
def test_a_name_taken_away_again_is_left_out(self):
self.assertEqual(self.names(name("shirt", 75, 80), name("face", 60, 45), name("shirt", 75, 80, op=0)), "face")
self.assertEqual(self.names(every("hand"), name("face", 60, 45), every("hand", op=0)), "face")
self.assertEqual(self.names(name("hand", 45, 73), every("hand", op=0), tap(75, 34)), "")
# Picked again afterwards: it is the last thing picked.
self.assertEqual(self.names(name("shirt", 75, 80), name("face", 60, 45), name("shirt", 75, 80, op=0), name("shirt", 75, 80)), "face, shirt")
def test_taking_one_of_two_away_keeps_the_name(self):
self.assertEqual(self.names(name("hand", 45, 73), name("hand", 104, 75), name("hand", 104, 75, op=0)), "hand", "the left hand is still picked")
self.assertEqual(self.names(every("hand"), name("hand", 45, 73, op=0)), "hand")
self.assertEqual(self.names(every("hand"), name("hand", 45, 73, op=0), name("hand", 104, 75, op=0)), "")
# Taking away a hand that was never picked changes nothing.
self.assertEqual(self.names(name("hand", 45, 73), name("hand", 104, 75, op=0)), "hand")
def test_taking_away_by_paint_or_by_another_word_leaves_the_name(self):
self.assertEqual(self.names(name("face", 60, 45), paint([[75, 34]], r=30, op=0), name("eyeglasses", 75, 34, op=0)), "face")
def test_a_look_and_a_batch_return_the_names_too(self):
self.assertEqual(self.run_node(touches(name("face", 60, 45)), ask="scope")["result"][2], "face")
lower = self.picture.clone()
lower[0, 0, 0] = 1.0
self.assertEqual(self.names(name("hand", 45, 82), every("cup"), image=torch.cat([self.picture, lower])), "cup", "the names are the first picture's")
class Bar:
"""Stands in for ComfyUI's progress bar: it picks up the function that
reports its steps when it is made, as comfy.utils.ProgressBar does."""
def __init__(self, total, utils):
self.total, self.hook = total, utils.PROGRESS_BAR_HOOK
def update_absolute(self, value, total=None):
if self.hook is not None:
self.hook(value, self.total if total is None else total, None)
class QuietTests(TouchCase):
"""A look sends the page no progress reports; any other run does."""
def setUp(self):
super().setUp()
self.sent = []
test = self
webui = types.SimpleNamespace(enabled=True, name="webui")
other = types.SimpleNamespace(enabled=True, name="cli")
class Registry:
handlers = {"webui": webui, "cli": other}
def update_progress(self, node_id, value, total, image=None):
if webui.enabled:
test.sent.append("progress_state")
def disable_handler(self, name):
self.handlers[name].enabled = False
def enable_handler(self, name):
self.handlers[name].enabled = True
self.registry, self.webui, self.other = Registry(), webui, other
def hook(value, total, preview, prompt_id=None, node_id=None):
node_module.raise_if_interrupted()
test.registry.update_progress(node_id, value, total, preview)
test.sent.append("progress")
self.hook = hook
self.utils = types.ModuleType("comfy.utils")
self.utils.PROGRESS_BAR_HOOK = hook
progress = types.ModuleType("comfy_execution.progress")
progress.get_progress_state = lambda: self.registry
for module_name, module in (("comfy.utils", self.utils), ("comfy_execution.progress", progress)):
self.addCleanup(sys.modules.pop, module_name, None)
sys.modules[module_name] = module
self.swap("progress_bar", lambda total: Bar(total, self.utils))
class Reports(FakeSam):
"""Reports "done" after every question, as SAM 3 Detect does."""
@classmethod
def execute(cls, **kwargs):
out = FakeSam.execute(**kwargs)
Bar(1, test.utils).update_absolute(1)
return out
self.swap("_core_nodes", lambda: {"SAM3_Detect": Reports, "CLIPTextEncode": FakeEncode})
def as_it_was(self):
return self.utils.PROGRESS_BAR_HOOK is self.hook and self.webui.enabled and self.other.enabled
def test_a_look_sends_no_progress_and_an_ordinary_run_does(self):
note = self.note(self.run_node("", ask="scope"))
self.assertIs(note["pieces"]["more"], False)
self.assertGreater(len(FakeSam.calls), 400)
self.assertEqual(self.sent, [], "hundreds of questions, and not one report")
self.assertTrue(self.as_it_was(), "put back on the way out")
self.run_node(touches(tap(3, 3), tap(4, 4)), if_nothing="empty mask")
self.assertEqual(self.sent, ["progress_state", "progress"] * 2, "an ordinary run reports as SAM 3 Detect does")
def test_a_look_with_the_pointers_place_is_quiet_too(self):
self.short_looks(5)
self.run_node("", ask="scope:130,8")
self.assertEqual((len(FakeSam.calls), self.sent), (6, []))
self.assertTrue(self.as_it_was())
def test_only_the_pages_reports_are_held_back(self):
seen = []
class Watches(FakeSam):
@classmethod
def execute(cls, **kwargs):
seen.append((self.utils.PROGRESS_BAR_HOOK is self.hook, self.webui.enabled, self.other.enabled))
return FakeSam.execute(**kwargs)
self.swap("_core_nodes", lambda: {"SAM3_Detect": Watches, "CLIPTextEncode": FakeEncode})
self.short_looks(2)
self.run_node("", ask="scope")
self.assertEqual(set(seen), {(False, False, True)}, "the page's handler is off, another handler is left alone")
def test_everything_is_put_back_when_the_look_fails(self):
class Fails(FakeSam):
@classmethod
def execute(cls, **kwargs):
if len(FakeSam.calls) >= 3:
raise RuntimeError("CUDA out of memory")
return FakeSam.execute(**kwargs)
self.swap("_core_nodes", lambda: {"SAM3_Detect": Fails, "CLIPTextEncode": FakeEncode})
with self.assertRaises(RuntimeError):
self.run_node("", ask="scope")
self.assertTrue(self.as_it_was())
self.assertEqual(self.sent, [])
def test_a_handler_that_was_off_stays_off(self):
self.webui.enabled = False
self.short_looks(2)
self.run_node("", ask="scope")
self.assertIs(self.webui.enabled, False)
self.assertIs(self.utils.PROGRESS_BAR_HOOK, self.hook)
def test_a_stop_by_the_user_still_stops_a_quiet_look(self):
class Stopped(BaseException):
pass
stop = [False]
def check():
if stop[0]:
raise Stopped()
class StopsInside(FakeSam):
@classmethod
def execute(cls, **kwargs):
out = FakeSam.execute(**kwargs)
stop[0] = len(FakeSam.calls) >= 4
Bar(1, self.utils).update_absolute(1) # where ComfyUI's report checks for a stop
return out
self.swap("raise_if_interrupted", check)
self.swap("_core_nodes", lambda: {"SAM3_Detect": StopsInside, "CLIPTextEncode": FakeEncode})
with self.assertRaises(Stopped):
self.run_node("", ask="scope")
self.assertEqual((len(FakeSam.calls), self.sent), (4, []))
self.assertTrue(self.as_it_was())
def test_it_can_be_switched_off(self):
self.swap("QUIET", False)
self.short_looks(4)
self.run_node("", ask="scope")
total = len(node_module._scope_queue(W, H))
self.assertEqual(self.sent, ["progress_state", "progress"] * 8, "SAM 3 Detect's report and the look's own bar, for each question")
reports = []
self.swap("progress_bar", lambda _total: types.SimpleNamespace(update_absolute=lambda value, total: reports.append((value, total))))
self.run_node("", ask="scope")
self.assertEqual(reports, [(number, total) for number in range(5, 9)], "the bar says how far the list is")
def test_another_comfyui_without_these_parts_just_looks(self):
for module_name in ("comfy.utils", "comfy_execution.progress"):
del sys.modules[module_name]
self.assertIs(self.note(self.run_node("", ask="scope"))["pieces"]["more"], False)
# A ComfyUI whose parts are not the ones this was written for.
sys.modules["comfy.utils"] = types.ModuleType("comfy.utils")
odd = types.ModuleType("comfy_execution.progress")
odd.get_progress_state = lambda: types.SimpleNamespace(handlers=None)
sys.modules["comfy_execution.progress"] = odd
with node_module._quiet() as held:
self.assertIs(held, False, "nothing to hold back, and nothing breaks")
self.assertIs(self.note(self.run_node("", ask="scope", image=self.picture.flip(1)))["pieces"]["more"], False)
class TellTests(TouchCase):
"""While it looks, the node tells its face what it has found."""
def setUp(self):
super().setUp()
self.swap("SCOPE_SPACING", 16) # fewer points: a shorter list
self.sent = []
self.server = types.SimpleNamespace(client_id="the page that asked", send_sync=lambda event, data, sid=None: self.sent.append((event, data, sid)))
self.swap("_server", lambda: self.server)
self.swap("_jobs_waiting", lambda: 0)
# Every question takes half a second.
self.clock = [0.0]
self.swap("time", types.SimpleNamespace(perf_counter=lambda: self.clock[0]))
test = self
class Slow(FakeSam):
@classmethod
def execute(cls, **kwargs):
test.clock[0] += 0.5
return FakeSam.execute(**kwargs)
self.swap("_core_nodes", lambda: {"SAM3_Detect": Slow, "CLIPTextEncode": FakeEncode})
def notes(self):
return [data["note"] for _, data, _ in self.sent]
def test_a_look_tells_about_every_second_and_a_half_and_at_its_end(self):
asked_at = []
send = self.server.send_sync
self.server.send_sync = lambda *args: (asked_at.append(len(FakeSam.calls)), send(*args))
out = self.run_node(touches(tap(130, 90)), ask="scope", unique_id=7)
total = len(node_module._scope_queue(W, H))
self.assertEqual(len(FakeSam.calls), total + 1, "the tap, then the whole list in one run")
self.assertEqual(asked_at, list(range(4, total + 1, 3)) + [total + 1], "after every third half-second question, and at the end")
for event, data, sid in self.sent:
self.assertEqual((event, sid), ("ausboss.mask_by_touch.look", "the page that asked"))
self.assertEqual(sorted(data), ["end", "node", "note"])
self.assertEqual(data["node"], "7", "the node's id, as text")
json.dumps(data) # it has to travel as JSON
self.assertEqual([data["end"] for _, data, _ in self.sent], [False] * (len(self.sent) - 1) + [True])
final = self.note(out)
self.assertEqual(self.sent[-1][1]["note"], final, "the last one is the note the run returns")
self.assertIs(final["pieces"]["more"], False)
self.assertEqual([entry["name"] for entry in final["pieces"]["list"]], [word for word, _, _ in LOOKED])
notes = self.notes()
for note in notes:
self.assertEqual(sorted(note), sorted(final))
for field in ("key", "width", "height", "scale", "stamp", "picture", "selection", "items", "share"):
self.assertEqual(note[field], final[field], "what is picked is the same in every one")
self.assertEqual(len(self.pieces_of(note)), len(note["pieces"]["list"]), "its planes are written")
self.assertEqual([note["pieces"]["more"] for note in notes], [True] * (len(notes) - 1) + [False])
words = [note["pieces"]["words"] for note in notes]
self.assertEqual(words[0], [3, 104], "three words asked when the first one goes out")
self.assertEqual(words, sorted(words))
self.assertEqual(words[-1], [104, 104])
def test_what_is_told_is_what_was_found_by_then(self):
self.run_node("", ask="scope", unique_id="12")
first, second = self.notes()[:2]
self.assertEqual([entry["name"] for entry in first["pieces"]["list"]], ["face", "person"], "person, face, hair were asked")
self.assertEqual([entry["name"] for entry in second["pieces"]["list"]], ["hand", "hand", "face", "shirt", "person"], "and hand, shirt, t-shirt")
shown = self.pieces_of(first)
self.assertTrue(torch.equal(shown[0], FACE.bool()) and torch.equal(shown[1], PERSON.bool()))
# A piece that was told keeps its point and its shape.
last = self.notes()[-1]
face = next(entry for entry in last["pieces"]["list"] if entry["name"] == "face")
self.assertEqual(face, first["pieces"]["list"][0])
def test_the_piece_under_the_pointer_is_told_at_once(self):
self.run_node("", ask="scope:130,8", unique_id=7)
_, data, _ = self.sent[0]
self.assertEqual((data["end"], data["note"]["pieces"]["words"], data["note"]["pieces"]["more"]), (False, [0, 104], True))
(poster,) = data["note"]["pieces"]["list"]
self.assertEqual((poster["x"], poster["y"], poster["kind"], poster["share"]), (130, 8, "point", share(POSTER)))
self.assertTrue(torch.equal(self.pieces_of(data["note"])[0], POSTER.bool()))
def test_a_look_that_gives_way_to_a_job_still_ends_with_the_event(self):
self.swap("_jobs_waiting", lambda: 1 if len(FakeSam.calls) >= 5 else 0)
out = self.run_node("", ask="scope", unique_id=7)
self.assertEqual(len(FakeSam.calls), 5)
self.assertEqual([(data["end"], data["note"]["pieces"]["words"]) for _, data, _ in self.sent], [(False, [3, 104]), (True, [5, 104])])
self.assertIs(self.sent[-1][1]["note"]["pieces"]["more"], True, "more is left: the face asks again")
self.assertEqual(self.sent[-1][1]["note"], self.note(out))
def test_a_look_with_nothing_left_to_ask_ends_with_the_event_too(self):
self.run_node("", ask="scope", unique_id=7)
count = len(FakeSam.calls)
self.sent.clear()
out = self.run_node("", ask="scope", unique_id=7)
self.assertEqual(len(FakeSam.calls), count)
self.assertEqual([(event, data["end"], data["note"] == self.note(out)) for event, data, _ in self.sent], [("ausboss.mask_by_touch.look", True, True)])
def test_only_a_look_tells(self):
self.run_node(touches(tap(130, 90)), unique_id=7)
self.run_node("", ask="look", unique_id=7)
self.assertEqual(self.sent, [])
def test_without_its_id_or_a_page_the_look_runs_and_tells_nobody(self):
told = self.note(self.run_node("", ask="scope", unique_id=7))
self.sent.clear()
node_module._MEMORY.clear()
untold = self.note(self.run_node("", ask="scope"))
self.assertEqual(self.sent, [], "no id: nobody to tell")
self.assertEqual(untold["pieces"]["list"], told["pieces"]["list"])
node_module._MEMORY.clear()
self.swap("_server", lambda: None)
self.assertEqual(self.note(self.run_node("", ask="scope", unique_id=7))["pieces"]["list"], told["pieces"]["list"])
def test_a_page_that_cannot_be_told_never_stops_the_look(self):
def broken(*_args):
raise ConnectionError("the page is gone")
self.server.send_sync = broken
note = self.note(self.run_node("", ask="scope", unique_id=7))
self.assertIs(note["pieces"]["more"], False)
self.assertEqual([entry["name"] for entry in note["pieces"]["list"]], [word for word, _, _ in LOOKED])
class FineEdgeTests(TouchCase):
"""Fine edges in the node: the helper is a stand-in here (its own tests
are tests/test_fine_edge_helpers.py). What the node asks of it and does
with its answer is what these hold."""
FILE = "birefnet.safetensors"
def setUp(self):
super().setUp()
self.calls = []
self.fail_with = None
def stand_in(image, picked, choice, turned=False, keep_hard=None, keys=None):
self.calls.append({
"picked": picked.clone(), "model": choice, "turned": turned, "frames": tuple(image.shape),
"keep_hard": None if keep_hard is None else keep_hard.clone(), "keys": keys,
})
if self.fail_with is not None:
raise self.fail_with
# A soft answer that is easy to tell: the picked area at 0.75, and red for the clean colours.
clean = torch.zeros(*picked.shape, 3)
clean[..., 0] = 1.0
return picked.float() * 0.75, clean
self.swap("find_fine_edges", stand_in)
self.text = touches(tap(75, 34), key=picture_key(self.picture))
def test_off_never_asks_and_the_note_has_no_fine_edge(self):
for value in ("off", "", None):
out = self.run_node(self.text, **({} if value is None else {"fine_edges": value}))
self.assertIsNone(self.note(out)["fine"])
self.assertEqual(self.calls, [])
def test_the_mask_is_the_fine_edge_and_the_cut_out_wears_the_clean_colours(self):
out = self.run_node(self.text, fine_edges=self.FILE)
mask, cut, _names = out["result"]
self.assertEqual(len(self.calls), 1)
self.assertEqual((self.calls[0]["model"], self.calls[0]["turned"]), (self.FILE, False))
self.assertEqual(self.calls[0]["keys"], [picture_key(self.picture)], "so each picture's subject is asked for once")
self.assertTrue(torch.equal(self.calls[0]["picked"][0], GLASSES.bool()), "asked about the tidied pick, on or off")
self.assertTrue(torch.allclose(mask[0], GLASSES * 0.75), "the mask is the soft answer")
self.assertEqual(tuple(cut.shape), (1, H, W, 4))
inside = GLASSES.bool()
self.assertTrue(torch.allclose(cut[0][inside][:, :3], torch.tensor([1.0, 0.0, 0.0]).expand(int(inside.sum()), 3)), "clean colours, not the picture's")
self.assertTrue(torch.equal(cut[0, ..., 3], mask[0]))
def test_grow_moves_the_plain_area_and_lays_it_over_the_fine_edge(self):
plain = self.run_node(self.text, fine_edges=self.FILE)["result"][0][0]
grown = self.run_node(self.text, fine_edges=self.FILE, grow=3)["result"][0][0]
hard_grown = self.run_node(self.text, grow=3)["result"][0][0]
self.assertTrue(torch.equal(grown, hard_grown), "solid out to the new line: no soft values pushed about")
shrunk = self.run_node(self.text, fine_edges=self.FILE, grow=-2)["result"][0][0]
hard_shrunk = self.run_node(self.text, grow=-2)["result"][0][0]
self.assertTrue(torch.allclose(shrunk, hard_shrunk * 0.75), "cut back to the new line, as soft as it was inside")
self.assertEqual(sorted(set(plain.unique().tolist())), [0.0, 0.75])
soft = self.run_node(self.text, fine_edges=self.FILE, soften=2.0)["result"][0][0]
self.assertGreater(int(((soft > 0) & (soft < 0.7)).sum()), 0)
def test_what_was_painted_is_marked_to_stay_exactly_as_painted(self):
stroke = [[125, 85], [140, 100]]
text = touches(tap(75, 34), paint(stroke, r=3), key=picture_key(self.picture))
self.run_node(text, fine_edges=self.FILE)
keep = self.calls[0]["keep_hard"]
self.assertEqual(tuple(keep.shape), (1, H, W))
self.assertTrue(torch.equal(keep[0], stroke_area(stroke, 3, H, W)), "the paint, and nothing SAM 3 found")
# A thing found after the paint, on the same spot, is a found thing again.
again = touches(paint([[75, 34], [80, 36]], r=3), tap(75, 34), key=picture_key(self.picture))
self.run_node(again, fine_edges=self.FILE)
self.assertFalse(bool((self.calls[1]["keep_hard"][0] & GLASSES.bool()).any()))
# Paint that takes away is paint too.
away = touches(tap(75, 34), paint([[75, 34], [80, 36]], r=3, op=0), key=picture_key(self.picture))
self.run_node(away, fine_edges=self.FILE)
self.assertTrue(bool(self.calls[2]["keep_hard"][0][34, 75]))
def test_the_note_carries_the_fine_edge_for_the_nodes_own_picture(self):
note = self.note(self.run_node(self.text, fine_edges=self.FILE, grow=2, soften=1.5))
self.assertEqual(sorted(note["fine"]), ["file", "made"])
self.assertEqual(note["fine"]["made"], "2|1.500|0|0|0|birefnet.safetensors", "the line the face writes too")
picture = self.opened(note["fine"]["file"])
self.assertEqual((picture.format, picture.mode, picture.size), ("PNG", "RGBA", (W, H)))
mask = self.run_node(self.text, fine_edges=self.FILE, grow=2, soften=1.5)["result"][0][0]
self.assertTrue(torch.equal(picture.pixels[..., 3], mask.mul(255.0).round().to(torch.uint8)), "its see-through channel is the mask")
def test_invert_asks_for_the_colours_of_everything_else(self):
out = self.run_node(self.text, fine_edges=self.FILE, invert=True)
self.assertTrue(self.calls[0]["turned"])
mask = out["result"][0][0]
self.assertTrue(torch.allclose(mask, 1.0 - GLASSES * 0.75))
self.assertEqual(self.note(out)["fine"]["made"].split("|")[4], "1")
def test_a_look_for_pieces_makes_no_fine_edge(self):
note = self.note(self.run_node(self.text, fine_edges=self.FILE, ask="scope"))
self.assertEqual(self.calls, [], "a look returns no mask anyone uses")
self.assertIsNone(note["fine"])
def test_nothing_picked_makes_no_fine_edge(self):
note = self.note(self.run_node("", fine_edges=self.FILE, if_nothing=IF_NOTHING[1]))
self.assertEqual(self.calls, [])
self.assertIsNone(note["fine"])
def test_a_run_of_the_workflow_stops_with_the_reason(self):
self.fail_with = FileNotFoundError("This needs ComfyUI's background-removal model.")
with self.assertRaisesRegex(FileNotFoundError, "background-removal model"):
self.run_node(self.text, fine_edges=self.FILE)
def test_a_look_from_the_nodes_own_picture_goes_on_with_the_plain_edge_and_says_why(self):
self.fail_with = FileNotFoundError("This needs ComfyUI's background-removal model.")
out = self.run_node(self.text, fine_edges=self.FILE, ask="look")
self.assertTrue(torch.equal(self.area(out), GLASSES.bool()), "the plain edge")
self.assertEqual(self.note(out)["fine"], {"trouble": "This needs ComfyUI's background-removal model."})
def test_the_line_of_settings_is_the_one_the_face_writes(self):
self.assertEqual(node_module.fine_made(0, 0.0, 0, True, False, "birefnet.safetensors"), "0|0.000|0|1|0|birefnet.safetensors")
self.assertEqual(node_module.fine_made(12, 0.5, 3, False, True, "a/b.safetensors"), "12|0.500|3|0|1|a/b.safetensors")
class SubjectTests(TouchCase):
"""The picture's main subject and its background as picks. ComfyUI's
background-removal model is a stand-in: it calls the person the subject."""
def setUp(self):
super().setUp()
self.asked_of = []
self.fail_with = None
def stand_in(frame, choice, key=None):
self.asked_of.append((choice, key, tuple(frame.shape)))
if self.fail_with is not None:
raise self.fail_with
return PERSON * 0.9
self.swap("subject_matte", stand_in)
def test_the_subject_is_a_pick_like_any_other(self):
out = self.run_node(touches({"t": "subject", "op": 1}, key=picture_key(self.picture)))
self.assertTrue(torch.equal(self.area(out), PERSON.bool()))
self.assertEqual(out["result"][2], "", "it has no name to give out")
note = self.note(out)
self.assertEqual(note["items"], [{"ok": True, "share": share(PERSON), "as": "thing"}])
self.assertEqual(self.asked_of, [("off", picture_key(self.picture), (1, H, W, 3))], "asked with the picture's key, of the model Fine edges names")
def test_the_background_is_everything_but_the_subject(self):
out = self.run_node(touches({"t": "background", "op": 1}, key=picture_key(self.picture)))
self.assertTrue(torch.equal(self.area(out), ~PERSON.bool()))
def test_they_add_and_take_away_with_the_other_picks(self):
text = touches({"t": "subject", "op": 1}, tap(75, 34, op=0), key=picture_key(self.picture))
self.assertTrue(torch.equal(self.area(self.run_node(text)), PERSON.bool() & ~GLASSES.bool()))
text = touches(tap(75, 34), {"t": "subject", "op": 0}, key=picture_key(self.picture))
self.assertTrue(torch.equal(self.area(self.run_node(text, if_nothing=IF_NOTHING[1])), GLASSES.bool() & ~PERSON.bool()))
def test_the_model_fine_edges_names_is_the_one_asked(self):
self.swap("find_fine_edges", lambda image, picked, choice, **more: (picked.float(), image[..., :3].float()))
self.run_node(touches({"t": "subject", "op": 1}, key=picture_key(self.picture)), fine_edges="other.safetensors")
self.assertEqual(self.asked_of[0][0], "other.safetensors")
def test_without_the_model_a_run_stops_with_the_reason(self):
self.fail_with = FileNotFoundError("This needs ComfyUI's background-removal model.")
with self.assertRaisesRegex(FileNotFoundError, "background-removal model"):
self.run_node(touches({"t": "subject", "op": 1}, key=picture_key(self.picture)))
def test_without_the_model_a_look_goes_on_without_that_pick_and_says_why(self):
self.fail_with = FileNotFoundError("This needs ComfyUI's background-removal model.")
out = self.run_node(touches(tap(75, 34), {"t": "subject", "op": 1}, key=picture_key(self.picture)), ask="look")
self.assertTrue(torch.equal(self.area(out), GLASSES.bool()), "the other pick is still there")
note = self.note(out)
self.assertEqual(note["items"][1], {"ok": False, "share": 0.0, "as": "none"})
self.assertEqual(note["fine"], {"trouble": "This needs ComfyUI's background-removal model."})
class QuietNameTests(TouchCase):
def test_a_word_the_face_did_not_show_is_picked_but_not_given_out(self):
look = self.look()
entry = next(piece for piece in look["pieces"]["list"] if piece["kind"] == "name")
shown = name(entry["name"], entry["x"], entry["y"])
quiet = dict(shown, q=1)
loud = self.run_node(touches(shown, key=picture_key(self.picture)))
hush = self.run_node(touches(quiet, key=picture_key(self.picture)))
self.assertEqual(loud["result"][2], entry["name"])
self.assertEqual(hush["result"][2], "", "no name is given out")
self.assertTrue(torch.equal(self.area(loud), self.area(hush)), "the same thing is picked")
class BrushTests(TouchCase):
"""The brush of the node's Paint and Erase: a soft edge, and what the face is told."""
LINE = [[40, 60], [120, 60]]
def test_a_soft_mark_fades_and_a_hard_one_stays_exact(self):
hard = self.run_node(touches(paint(self.LINE, r=10)), if_nothing=IF_NOTHING[1])["result"][0][0]
self.assertTrue(torch.equal(hard > 0.5, stroke_area(self.LINE, 10, H, W)), "a hard brush is exactly as before")
self.assertTrue(bool(((hard == 0) | (hard == 1)).all()))
soft_mark = dict(paint(self.LINE, r=10), s=100)
soft = self.run_node(touches(soft_mark))["result"][0][0]
self.assertGreater(float(soft[60, 80]), 0.9, "solid along the middle")
self.assertTrue(0.2 < float(soft[54, 80]) < 0.85, "fading half way out")
self.assertLess(float(soft[49, 80]), 0.1, "little left at the ring")
self.assertEqual(float(soft[20, 80]), 0.0)
def test_a_brush_too_hard_to_blur_is_the_hard_stroke(self):
mark = dict(paint(self.LINE, r=2), s=1)
out = self.run_node(touches(mark))["result"][0][0]
self.assertTrue(torch.equal(out > 0.5, stroke_area(self.LINE, 2, H, W)))
def test_a_soft_erase_thins_what_is_picked(self):
text = touches(paint([[20, 60], [140, 60]], r=30), dict(paint([[30, 60], [130, 60]], r=10, op=0), s=100))
out = self.run_node(text)["result"][0][0]
self.assertLess(float(out[60, 80]), 0.1, "rubbed out along the stroke")
self.assertTrue(0.05 < float(out[66, 80]) < 0.95, "thinned at its soft edge")
self.assertEqual(float(out[80, 80]), 1.0, "and whole further off")
def test_the_selection_the_face_reads_is_grey_where_a_soft_brush_painted(self):
hard = self.note(self.run_node(touches(paint(self.LINE, r=10))))
values = set(self.opened(hard["selection"]).pixels.unique().tolist())
self.assertEqual(values, {0, 255})
soft = self.note(self.run_node(touches(dict(paint(self.LINE, r=10), s=100))))
values = self.opened(soft["selection"]).pixels
self.assertTrue(bool(((values > 10) & (values < 245)).any()))
def test_the_chip_a_touch_is_in_changes_nothing(self):
plain = self.run_node(touches(tap(75, 34), paint(self.LINE, r=6)))["result"][0]
numbered = self.run_node(touches(dict(tap(75, 34), g=1), dict(paint(self.LINE, r=6), g=1)))["result"][0]
self.assertTrue(torch.equal(plain, numbered))
def test_a_take_away_says_whether_it_took_anything(self):
note = self.note(self.run_node(touches(tap(75, 34), paint([[5, 110], [20, 110]], op=0), paint([[60, 31], [90, 31]], r=2, op=0))))
self.assertEqual([item.get("did") for item in note["items"]], [None, False, True])
def test_each_picked_thing_comes_with_its_own_area_for_the_face(self):
text = touches(tap(75, 34), paint(self.LINE, r=4), tap(30, 70, op=0), find([[50, 80], [100, 80]]))
note = self.note(self.run_node(text))
# Paint the face draws itself, and what takes away is no chip: neither is sent.
self.assertEqual(note["areas"]["items"], [0, 3])
planes = [self.opened(entry).pixels for entry in note["areas"]["planes"]]
areas = unpack_planes(planes, 2)
self.assertTrue(torch.equal(areas[0], GLASSES.bool()))
self.assertTrue(torch.equal(areas[1], PERSON.bool()))
# The same touches again: the same files.
self.assertEqual(self.note(self.run_node(text))["areas"], note["areas"])
self.assertIsNone(self.note(self.run_node(touches(paint(self.LINE))))["areas"])
class ContractTests(unittest.TestCase):
cls = node_module.AusBossMaskByTouch
def test_inputs_and_outputs_are_the_contracts(self):
inputs = self.cls.INPUT_TYPES()
self.assertEqual(list(inputs["required"]), ["image", "model", "clip", "touches", "grow", "soften", "smooth", "fill_holes", "if_nothing"])
self.assertEqual(list(inputs["optional"]), ["free_vram", "invert", "ask", "fine_edges"])
# Off until the user picks a model file: an API prompt without it runs as before.
self.assertEqual((optional_first := inputs["optional"]["fine_edges"])[1]["default"], "off")
self.assertEqual(optional_first[0][0], "off", "off is always offered, and comes first")
self.assertEqual(inputs["hidden"], {"unique_id": "UNIQUE_ID"}, "ComfyUI hands the node its own id")
required, optional = inputs["required"], inputs["optional"]
self.assertEqual((required["image"][0], required["model"][0], required["clip"][0]), ("IMAGE", "MODEL", "CLIP"))
self.assertEqual(required["clip"][1]["tooltip"], "SAM 3's text reader: CLIP from the same Load Checkpoint.")
self.assertEqual((required["touches"][0], required["touches"][1]["default"], required["touches"][1]["multiline"]), ("STRING", "", False))
grow = required["grow"][1]
self.assertEqual((required["grow"][0], grow["default"], grow["min"], grow["max"]), ("INT", 0, -128, 256))
soften = required["soften"][1]
self.assertEqual((required["soften"][0], soften["default"], soften["min"], soften["max"], soften["step"]), ("FLOAT", 0.0, 0.0, 100.0, 0.1))
smooth = required["smooth"][1]
self.assertEqual((required["smooth"][0], smooth["default"], smooth["min"], smooth["max"]), ("INT", 0, 0, 64))
self.assertEqual((required["fill_holes"][0], required["fill_holes"][1]["default"]), ("BOOLEAN", True))
self.assertEqual((required["if_nothing"][0], required["if_nothing"][1]["default"]), (list(IF_NOTHING), "stop the run"))
self.assertEqual((optional["ask"][0], optional["ask"][1]["default"]), ("STRING", ""))
self.assertEqual((optional["invert"][0], optional["invert"][1]["default"]), ("BOOLEAN", False))
self.assertEqual((self.cls.RETURN_TYPES, self.cls.RETURN_NAMES), (("MASK", "IMAGE", "STRING"), ("mask", "cut_out", "names")))
self.assertEqual(self.cls.CATEGORY, "\U0001f18e AusBoss/Mask")
def test_the_function_takes_the_inputs_in_their_order(self):
import inspect
names = list(inspect.signature(self.cls.pick).parameters)[1:]
inputs = self.cls.INPUT_TYPES()
self.assertEqual(names, list(inputs["required"]) + list(inputs["optional"]) + list(inputs["hidden"]))
def test_every_input_and_output_says_what_it_is(self):
inputs = self.cls.INPUT_TYPES()
for group in ("required", "optional"):
for input_name, entry in inputs[group].items():
self.assertTrue(entry[1].get("tooltip"), input_name)
self.assertEqual(len(self.cls.OUTPUT_TOOLTIPS), len(self.cls.RETURN_TYPES))
self.assertTrue(self.cls.DESCRIPTION.startswith("Click a thing on the picture, or paint over it"))
self.assertIn("wire model and clip from a Load Checkpoint that loads the SAM 3 file", self.cls.DESCRIPTION)
for alias in ("ausboss", "quick select", "magic wand", "click to mask", "segment", "sam", "sam3", "select", "pick", "brush mask", "inpaint mask"):
self.assertIn(alias, self.cls.SEARCH_ALIASES)
def test_it_is_an_output_node_and_a_request_from_its_face_always_runs(self):
self.assertIs(self.cls.OUTPUT_NODE, True)
for ask in ("scope", "scope:1,2", "look"):
value = self.cls.IS_CHANGED(ask=ask, touches="", grow=0)
self.assertNotEqual(value, value, "never equal to itself: ComfyUI runs the node again")
self.assertEqual(self.cls.IS_CHANGED(ask="", touches="x"), self.cls.IS_CHANGED(touches="y"), "an ordinary run is remembered by its inputs")
self.assertFalse(hasattr(self.cls, "VALIDATE_INPUTS"))
def test_the_key_is_a_literal_and_the_name_carries_the_pack_signature(self):
self.assertEqual(list(node_module.NODE_CLASS_MAPPINGS), ["AUSBOSS_NODES_MaskByTouch"])
self.assertEqual(node_module.NODE_DISPLAY_NAME_MAPPINGS, {"AUSBOSS_NODES_MaskByTouch": "Mask by Touch \U0001f18e"})
if __name__ == "__main__":
unittest.main()