Files
ausboss-ComfyUI-AusBoss/tests/test_krea2_helpers.py
ausboss 5ec02ef356 Divisible by: no strip of fill on an edge without padding
Image Crop + Rotate + Pad and both Video Crop + Rotate + Pad nodes rounded
the canvas up to Divisible by and put the pixels left over on the right and
bottom, whatever was padded. A 1916 x 821 picture with 3 px of padding on
the left and Divisible by 24 came to 1920 wide, with 1 px of fill on the
right edge, which had no padding. With Resize on, that strip was smeared
into the picture's last column, and the stitched result kept it as a light
line.

The canvas now rounds one axis at a time by Load Image + Pad's rule
(_pad_helpers._axis_layout): the leftover joins a side that was padded, the
right or bottom one when both are, and an axis nobody padded has its crop
trimmed evenly to the multiple below. A picture smaller than one multiple
still grows on the far side. With no padding on an axis the size therefore
rounds down, not up: 1000 x 750 at 64 gives 960 x 704, was 1024 x 768.
Inputs and outputs are unchanged.

The editor uses the same rule (axisLayout, resolvePadding and keptCrop in
js/shared/transform_geometry.mjs), so the picture on the node, the size
line and the editor match the run. The size line names fill added on any
side and pixels trimmed, and the centre buttons count fill that sits on the
left or top. The Divisible by tooltip and help pages describe the rule, and
the Krea 2 Outpaint Model Patch warning no longer says Divisible by rounds
the other axis up.

tests/test_transform_engine.py and tests/transform_geometry.test.mjs share
one fixture table, so the editor and the run are held to the same numbers.
2026-10-11 20:41:52 -05:00

396 lines
16 KiB
Python

from __future__ import annotations
from pathlib import Path
import sys
import unittest
import torch
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._inpaint_crop_helpers import build_canvas_stitcher
from nodes._krea2_helpers import (
REFERENCE_MAX_EDGE,
build_reference_image,
extract_bbox_norm,
fill_reference_holes,
placement_warning,
reference_size,
snap16,
source_pixel_bbox,
)
class Snap16Tests(unittest.TestCase):
"""The VAE downsamples by 8 and the DiT patchifies by 2, so a reference
edge off a multiple of 16 lands on a partial patch."""
def test_rounds_to_the_nearest_multiple(self):
self.assertEqual(snap16(16), 16)
self.assertEqual(snap16(23), 16)
self.assertEqual(snap16(24), 32)
self.assertEqual(snap16(384), 384)
def test_never_returns_zero(self):
# A zero-size reference would encode to an empty latent.
self.assertEqual(snap16(0), 16)
self.assertEqual(snap16(1), 16)
self.assertEqual(snap16(-40), 16)
class ReferenceSizeTests(unittest.TestCase):
def test_a_small_reference_is_only_snapped(self):
self.assertEqual(reference_size(64, 48), (64, 48))
self.assertEqual(reference_size(70, 50), (64, 48))
def test_the_long_edge_is_capped_and_aspect_roughly_kept(self):
width, height = reference_size(1000, 500)
self.assertLessEqual(max(width, height), REFERENCE_MAX_EDGE)
self.assertEqual(width % 16, 0)
self.assertEqual(height % 16, 0)
self.assertAlmostEqual(width / height, 2.0, delta=0.15)
def test_a_tall_source_caps_on_height(self):
width, height = reference_size(500, 1000)
self.assertEqual(height, 384)
self.assertLess(width, height)
def test_zero_max_edge_means_snap_only(self):
# 1000/16 is exactly 62.5, and round() breaks ties to even: 62 * 16.
self.assertEqual(reference_size(1000, 500, 0), (992, 496))
class BuildReferenceImageTests(unittest.TestCase):
def test_an_already_fitting_image_comes_back_unchanged(self):
image = torch.rand(1, 48, 64, 3)
out = build_reference_image(image)
self.assertTrue(torch.equal(out, image))
def test_it_returns_a_copy_not_the_input(self):
# Two consumers must not be able to mutate each other's reference.
image = torch.rand(1, 48, 64, 3)
out = build_reference_image(image)
out[0, 0, 0, 0] = 0.5
self.assertNotEqual(float(image[0, 0, 0, 0]), 0.5)
def test_a_large_image_is_downscaled_to_multiples_of_16(self):
out = build_reference_image(torch.rand(1, 500, 1000, 3))
_batch, height, width, channels = out.shape
self.assertLessEqual(max(width, height), REFERENCE_MAX_EDGE)
self.assertEqual((height % 16, width % 16), (0, 0))
self.assertEqual(channels, 3)
def test_the_batch_survives(self):
out = build_reference_image(torch.rand(4, 500, 1000, 3))
self.assertEqual(out.shape[0], 4)
def test_a_non_bhwc_tensor_is_rejected_by_name(self):
with self.assertRaises(ValueError) as caught:
build_reference_image(torch.rand(48, 64, 3))
self.assertIn("BHWC", str(caught.exception))
class FillReferenceHolesTests(unittest.TestCase):
"""AnyPaint was trained with the new area of its reference in the
picture's own median colour. A gray pad next to a dark picture is not
that, and the model paints it back as a gray frame."""
def dark_canvas(self):
# A dark teal picture on the left, the gray pad on the right.
image = torch.full((1, 48, 96, 3), 0.5)
image[:, :, :64] = torch.tensor([0.1, 0.2, 0.25])
image[:, :4, :8] = torch.tensor([0.9, 0.9, 0.9]) # a highlight
mask = torch.zeros(1, 48, 96)
mask[:, :, 64:] = 1.0
return image, mask
def test_the_new_area_takes_the_median_of_the_picture(self):
image, mask = self.dark_canvas()
out = fill_reference_holes(image, mask)
expected = torch.tensor([0.1, 0.2, 0.25])
self.assertTrue(torch.allclose(out[0, :, 64:], expected.expand(48, 32, 3)))
def test_the_picture_itself_is_untouched(self):
image, mask = self.dark_canvas()
out = fill_reference_holes(image, mask)
self.assertTrue(torch.equal(out[:, :, :64], image[:, :, :64]))
def test_it_returns_a_copy(self):
image, mask = self.dark_canvas()
out = fill_reference_holes(image, mask)
self.assertEqual(float(image[0, 0, 80, 0]), 0.5)
self.assertIsNot(out, image)
def test_a_feathered_edge_below_half_stays_picture(self):
# The pad mask ramps a few pixels into the picture; only the part
# above 0.5 is new area.
image, mask = self.dark_canvas()
mask[:, :, 60:64] = 0.4
out = fill_reference_holes(image, mask)
self.assertTrue(torch.equal(out[:, :, 60:64], image[:, :, 60:64]))
def test_nothing_marked_changes_nothing(self):
image, _mask = self.dark_canvas()
out = fill_reference_holes(image, torch.zeros(1, 48, 96))
self.assertTrue(torch.equal(out, image))
def test_everything_marked_falls_back_to_mid_gray(self):
image, _mask = self.dark_canvas()
out = fill_reference_holes(image, torch.ones(1, 48, 96))
self.assertTrue(torch.allclose(out, torch.full_like(image, 0.5)))
def test_a_mask_of_another_size_is_fitted(self):
image, _mask = self.dark_canvas()
small = torch.zeros(1, 24, 48)
small[:, :, 32:] = 1.0
out = fill_reference_holes(image, small)
expected = torch.tensor([0.1, 0.2, 0.25])
self.assertTrue(torch.allclose(out[0, :, 66:], expected.expand(48, 30, 3)))
def test_a_plain_hw_mask_serves_the_whole_batch(self):
image, mask = self.dark_canvas()
batch = torch.cat([image, image * 0.5])
out = fill_reference_holes(batch, mask[0])
self.assertTrue(torch.allclose(out[0, 0, 90], torch.tensor([0.1, 0.2, 0.25])))
self.assertTrue(torch.allclose(out[1, 0, 90], torch.tensor([0.05, 0.1, 0.125])))
def test_junk_is_rejected_by_name(self):
image, mask = self.dark_canvas()
with self.assertRaises(ValueError):
fill_reference_holes(image[0], mask)
with self.assertRaises(ValueError):
fill_reference_holes(image, "mask")
class ExtractBboxNormTests(unittest.TestCase):
def setUp(self):
self.canvas = torch.zeros(1, 1024, 2048, 3)
self.mask = torch.zeros(1, 1024, 2048)
def test_it_reads_the_recorded_bbox(self):
stitcher = build_canvas_stitcher(
self.canvas, self.mask, bbox=(0, 128, 1024, 896)
)
self.assertEqual(
extract_bbox_norm(stitcher), [0.0, 128 / 1024, 0.5, 896 / 1024]
)
def test_it_falls_back_to_the_crop_rectangle(self):
# A stitcher from before the bbox existed, or one from Crop For
# Inpaint, still says where its region sits.
stitcher = build_canvas_stitcher(self.canvas, self.mask)
stitcher["crop_to_canvas"] = (256, 128, 1024, 768)
self.assertEqual(
extract_bbox_norm(stitcher),
[256 / 2048, 128 / 1024, 1280 / 2048, 896 / 1024],
)
def test_source_bbox_alone_is_enough(self):
stitcher = build_canvas_stitcher(self.canvas, self.mask)
stitcher["source_bbox"] = (256, 128, 1280, 896)
self.assertEqual(
extract_bbox_norm(stitcher),
[256 / 2048, 128 / 1024, 1280 / 2048, 896 / 1024],
)
def test_a_full_canvas_stitcher_reports_the_unit_square(self):
self.assertEqual(
extract_bbox_norm(build_canvas_stitcher(self.canvas, self.mask)),
[0.0, 0.0, 1.0, 1.0],
)
def test_junk_falls_back_to_the_full_canvas(self):
# The full frame is what an unpatched model already assumes, so a
# bad stitcher degrades instead of raising mid-sample.
for value in (None, {}, {"kind": "something else"}, "not a stitcher", 42):
self.assertEqual(extract_bbox_norm(value), [0.0, 0.0, 1.0, 1.0])
class PlacementWarningTests(unittest.TestCase):
"""The reference implementation spans one whole canvas axis and splits
anything else into two passes. One pass on a two-axis canvas is outside
what the weights do, so it has to be said out loud."""
def stitcher(self, canvas_w, canvas_h, bbox):
canvas = torch.zeros(1, canvas_h, canvas_w, 3)
mask = torch.zeros(1, canvas_h, canvas_w)
return build_canvas_stitcher(canvas, mask, bbox=bbox)
def test_a_horizontal_extend_is_fine(self):
# Source spans the full height: pad left and right only.
s = self.stitcher(1416, 1024, (231, 0, 1255, 1024))
self.assertIsNone(placement_warning(s))
def test_a_vertical_extend_is_fine(self):
# Source spans the full width: pad top and bottom only.
s = self.stitcher(720, 1568, (0, 0, 720, 1280))
self.assertIsNone(placement_warning(s))
def test_padding_both_axes_warns(self):
s = self.stitcher(864, 1760, (29, 0, 833, 1429))
message = placement_warning(s)
self.assertIsNotNone(message)
self.assertIn("BOTH", message)
self.assertIn("60px spare width", message) # 864 - (833 - 29)
self.assertIn("331px spare height", message) # 1760 - 1429
def test_a_sliver_still_warns(self):
# Padded at the bottom, with 11px of padding left on the width. A
# sliver breaks the span exactly like a deliberate pad does, which is
# the trap the message has to name.
s = self.stitcher(832, 1792, (0, 0, 821, 1459))
message = placement_warning(s)
self.assertIsNotNone(message)
self.assertIn("11px spare width", message)
self.assertIn("left over from a drag", message)
def test_a_sliver_inside_the_tolerance_is_ignored(self):
s = self.stitcher(728, 1568, (0, 0, 720, 1280)) # 8px spare width
self.assertIsNone(placement_warning(s))
def test_the_message_stays_ascii(self):
# Import-time and console output on a cp1252 Windows console.
s = self.stitcher(864, 1760, (29, 0, 833, 1429))
placement_warning(s).encode("ascii")
def test_no_bbox_means_no_opinion(self):
canvas = torch.zeros(1, 64, 64, 3)
plain = build_canvas_stitcher(canvas, torch.zeros(1, 64, 64))
self.assertIsNone(placement_warning(plain))
for junk in (None, {}, {"kind": "other"}, 7):
self.assertIsNone(placement_warning(junk))
def test_source_pixel_bbox_rebuilds_from_normalized_only(self):
canvas = torch.zeros(1, 1024, 1416, 3)
s = build_canvas_stitcher(canvas, torch.zeros(1, 1024, 1416), bbox=(231, 0, 1255, 1024))
del s["source_bbox"]
self.assertEqual(source_pixel_bbox(s), (231, 0, 1255, 1024, 1416, 1024))
class Krea2NodeContractTests(unittest.TestCase):
def test_model_patch_contract(self):
from nodes.node_krea2_model_patch import (
NODE_CLASS_MAPPINGS,
NODE_DISPLAY_NAME_MAPPINGS,
)
key = "AUSBOSS_NODES_Krea2OutpaintModelPatch"
self.assertIn(key, NODE_CLASS_MAPPINGS)
self.assertEqual(
NODE_DISPLAY_NAME_MAPPINGS[key], "Krea 2 Outpaint Model Patch 🆎"
)
cls = NODE_CLASS_MAPPINGS[key]
self.assertEqual(cls.CATEGORY, "🆎 AusBoss/Krea2")
self.assertEqual(cls.RETURN_TYPES, ("MODEL",))
required = cls.INPUT_TYPES()["required"]
self.assertEqual(required["model"][0], "MODEL")
self.assertEqual(required["stitcher"][0], "AUSBOSS_STITCHER")
self.assertTrue(required["kv_cache"][1]["default"])
def test_encode_contract(self):
from nodes.node_krea2_encode import (
NODE_CLASS_MAPPINGS,
NODE_DISPLAY_NAME_MAPPINGS,
)
key = "AUSBOSS_NODES_Krea2Encode"
self.assertIn(key, NODE_CLASS_MAPPINGS)
self.assertEqual(NODE_DISPLAY_NAME_MAPPINGS[key], "Krea 2 Encode 🆎")
cls = NODE_CLASS_MAPPINGS[key]
self.assertEqual(cls.CATEGORY, "🆎 AusBoss/Krea2")
self.assertEqual(cls.RETURN_TYPES, ("CONDITIONING", "CONDITIONING"))
self.assertEqual(cls.RETURN_NAMES, ("positive", "negative"))
# Everything but clip and prompt is optional, so the node drops into a
# plain two-prompt graph with no VAE and no reference wired.
self.assertEqual(set(cls.INPUT_TYPES()["required"]), {"clip", "prompt"})
def test_encode_without_a_vae_skips_the_reference(self):
from nodes.node_krea2_encode import NODE_CLASS_MAPPINGS
class StubClip:
def tokenize(self, text, **_kwargs):
return {"text": text}
def encode_from_tokens_scheduled(self, tokens):
return [[tokens["text"], {}]]
cls = NODE_CLASS_MAPPINGS["AUSBOSS_NODES_Krea2Encode"]
positive, negative = cls().encode(
StubClip(), "a house", "blurry", reference=torch.rand(1, 48, 64, 3)
)
self.assertEqual(positive[0][0], "a house")
self.assertEqual(negative[0][0], "blurry")
self.assertNotIn("reference_latents", positive[0][1])
def encode_with_stubs(self, **kwargs):
"""Run the encode with a stub CLIP, VAE and node_helpers; return what
the vision tower and the VAE were handed."""
from nodes import node_krea2_encode as module
seen = {"vision": [], "vae": []}
class StubClip:
def tokenize(self, text, images=None, **_kwargs):
if images:
seen["vision"].extend(images)
return {"text": text}
def encode_from_tokens_scheduled(self, tokens):
return [[tokens["text"], {}]]
class StubVae:
def encode(self, pixels):
seen["vae"].append(pixels)
return torch.zeros(1, 16, pixels.shape[1] // 8, pixels.shape[2] // 8)
class StubHelpers:
@staticmethod
def conditioning_set_values(conditioning, values, append=False):
return [[text, {**extra, **values}] for text, extra in conditioning]
saved = module.node_helpers
module.node_helpers = StubHelpers
try:
module.NODE_CLASS_MAPPINGS["AUSBOSS_NODES_Krea2Encode"]().encode(
StubClip(), "a room", vae=StubVae(), vlm_reference=True, **kwargs
)
finally:
module.node_helpers = saved
return seen
def test_a_wired_mask_refills_the_reference_for_the_vae_and_the_vision_tower(self):
canvas = torch.full((1, 48, 96, 3), 0.5)
canvas[:, :, :64] = torch.tensor([0.1, 0.2, 0.25])
mask = torch.zeros(1, 48, 96)
mask[:, :, 64:] = 1.0
seen = self.encode_with_stubs(reference=canvas, mask=mask)
dark = torch.tensor([0.1, 0.2, 0.25])
self.assertTrue(torch.allclose(seen["vision"][0][0, 10, 90], dark))
self.assertTrue(torch.allclose(seen["vae"][0][0, 10, 90], dark))
# The canvas the rest of the graph uses keeps its gray.
self.assertEqual(float(canvas[0, 10, 90, 0]), 0.5)
def test_without_a_mask_the_reference_is_used_as_given(self):
canvas = torch.full((1, 48, 96, 3), 0.5)
canvas[:, :, :64] = 0.1
seen = self.encode_with_stubs(reference=canvas)
self.assertTrue(torch.allclose(seen["vae"][0][0, 10, 90], torch.full((3,), 0.5)))
self.assertTrue(torch.allclose(seen["vision"][0][0, 10, 90], torch.full((3,), 0.5)))
def test_the_mask_is_the_last_optional_input(self):
# Saved workflows keep links by slot: a new input only goes at the end.
from nodes.node_krea2_encode import NODE_CLASS_MAPPINGS
optional = list(NODE_CLASS_MAPPINGS["AUSBOSS_NODES_Krea2Encode"].INPUT_TYPES()["optional"])
self.assertEqual(optional[-1], "mask")
self.assertEqual(
optional[:-1], ["negative_prompt", "vae", "reference", "extra_image", "vlm_reference"]
)
if __name__ == "__main__":
unittest.main()