Files
ausboss-ComfyUI-AusBoss/tests/test_pad_helpers.py
T
ausboss ec50d3acfe Promote the Krea 2 outpaint pair from the lab
Krea 2 accepts reference latents, but they arrive with no position, so the
model treats them as a loose style hint: it borrows the look and reinvents
the content. That is the whole reason an outpaint drifts instead of
continuing the photo.

Krea 2 Encode attaches the reference latents and emits the negative from
the same node, so a turbo graph at CFG 1.0 stops carrying a second text
encode that does nothing. Krea 2 Outpaint Model Patch registers those
reference tokens into the target grid at the rectangle the stitcher
records, on frame axis 1.

Load Image + Pad gained a `reference` output - the unpadded source, fitted
to a 384px long edge and a multiple of 16, because the VAE downsamples by
8 and the DiT patchifies by 2. It is APPENDED after `stitcher`, never
inserted: a workflow stores links by output slot index, so any other
position silently rewires every saved graph.

The stitcher now records where the source sits, as source_bbox in pixels
and bbox_normalized in 0..1. Padding knows the rectangle exactly, so it
rides with the canvas instead of a parallel wire that can be left
unplugged. apply_stitch reads neither key, so an older stitcher stitches
identically.

Ported from the lab rather than copied: the lab's _krea2_helpers had its
own pad_image, feather_pad_mask, plan_pad_canvas, parse_fill_color and
build_canvas_stitcher. Duplicating the padding engine here would let the
outpaint path and the inpaint path disagree about what a stitcher is, so
the public module keeps only what is specific to Krea 2 and uses the
existing helpers for the rest.

Two departures from the lab version. The model patch declared a kv_cache
input and never read it - the cache was always on - so it is wired up now.
And it imports comfy's flux internals when you run it rather than at
startup: a core release that moves one then surfaces as an error on that
node, instead of deleting it from the menu and leaving saved workflows
reporting it missing.
2026-08-17 22:17:19 -05:00

457 lines
20 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"]
import math
from nodes._pad_helpers import (
PAD_MODES,
_LOWRES_BLUR_MIN_SIGMA,
_SIGMA_DIVISOR,
_blur_image,
_resize_image,
feather_pad_mask,
pad_image,
plan_pad_canvas,
resolve_pad_geometry,
round_up_to_multiple,
)
def rand_image(batch: int, height: int, width: int, seed: int = 0) -> torch.Tensor:
generator = torch.Generator().manual_seed(seed)
return torch.rand((batch, height, width, 3), generator=generator, dtype=torch.float32)
PADS = (3, 5, 2, 4) # left, top, right, bottom
class MaskContractTests(unittest.TestCase):
def test_every_mode_shares_the_geometry_and_mask_contract(self):
image = rand_image(2, 16, 20, seed=1)
left, top, right, bottom = PADS
for mode in PAD_MODES:
with self.subTest(mode=mode):
out, mask = pad_image(image, left, top, right, bottom, mode)
self.assertEqual(out.shape, (2, 16 + top + bottom, 20 + left + right, 3))
self.assertEqual(mask.shape, (2, 16 + top + bottom, 20 + left + right))
# Original region: bit-identical image, zero mask.
self.assertTrue(
torch.equal(out[:, top : top + 16, left : left + 20, :], image)
)
inner = mask[:, top : top + 16, left : left + 20]
self.assertEqual(float(inner.sum()), 0.0)
# Padding: mask is exactly 1 everywhere else.
total = mask.numel() - inner.numel()
self.assertEqual(float(mask.sum()), float(total))
def test_zero_padding_is_a_passthrough_with_an_empty_mask(self):
image = rand_image(1, 8, 8, seed=2)
out, mask = pad_image(image, 0, 0, 0, 0, "edge")
self.assertTrue(torch.equal(out, image))
self.assertEqual(float(mask.sum()), 0.0)
def test_unknown_mode_is_rejected(self):
with self.assertRaises(ValueError):
pad_image(rand_image(1, 8, 8), 1, 1, 1, 1, "mirror")
class ColorModeTests(unittest.TestCase):
def test_padding_takes_the_parsed_fill_color(self):
image = rand_image(1, 8, 8, seed=3)
out, _ = pad_image(image, 2, 2, 2, 2, "color", fill_color="teal")
expected = torch.tensor([0.0, 128 / 255.0, 128 / 255.0])
self.assertTrue(torch.allclose(out[0, 0, 0], expected))
self.assertTrue(torch.allclose(out[0, -1, -1], expected))
self.assertTrue(torch.allclose(out[0, 0, 5], expected))
class EdgeModeTests(unittest.TestCase):
def test_sides_take_the_edge_average_and_corners_blend(self):
image = rand_image(1, 10, 12, seed=4)
left, top, right, bottom = PADS
out, _ = pad_image(image, left, top, right, bottom, "edge")
top_color = image[0, 0, :, :].mean(dim=0)
left_color = image[0, :, 0, :].mean(dim=0)
bottom_color = image[0, -1, :, :].mean(dim=0)
right_color = image[0, :, -1, :].mean(dim=0)
# Side bands are flat fills of the adjacent edge average.
self.assertTrue(torch.allclose(out[0, 0, left + 3], top_color))
self.assertTrue(torch.allclose(out[0, -1, left + 3], bottom_color))
self.assertTrue(torch.allclose(out[0, top + 3, 0], left_color))
self.assertTrue(torch.allclose(out[0, top + 3, -1], right_color))
# Corner quadrants blend their two adjoining sides.
self.assertTrue(torch.allclose(out[0, 0, 0], (top_color + left_color) / 2))
self.assertTrue(torch.allclose(out[0, -1, -1], (bottom_color + right_color) / 2))
class EdgePixelModeTests(unittest.TestCase):
def test_rows_and_cols_replicate_and_corners_take_the_corner_pixel(self):
image = rand_image(1, 10, 12, seed=5)
left, top, right, bottom = PADS
out, _ = pad_image(image, left, top, right, bottom, "edge pixel")
# Above the image, each column repeats the top source pixel of it.
for j in (0, 5, 11):
self.assertTrue(torch.equal(out[0, 0, left + j], image[0, 0, j]))
# Left of the image, each row repeats its leftmost source pixel.
for i in (0, 4, 9):
self.assertTrue(torch.equal(out[0, top + i, 0], image[0, i, 0]))
# Corner quadrants are the corner pixel.
self.assertTrue(torch.equal(out[0, 0, 0], image[0, 0, 0]))
self.assertTrue(torch.equal(out[0, 0, -1], image[0, 0, -1]))
self.assertTrue(torch.equal(out[0, -1, 0], image[0, -1, 0]))
self.assertTrue(torch.equal(out[0, -1, -1], image[0, -1, -1]))
class PillarboxBlurModeTests(unittest.TestCase):
def test_backdrop_is_derived_from_the_image_not_a_flat_fill(self):
rows = torch.linspace(0.0, 1.0, 24).view(1, 24, 1, 1)
cols = torch.linspace(0.0, 1.0, 24).view(1, 1, 24, 1)
image = (0.5 * rows + 0.5 * cols).expand(1, 24, 24, 3).clone()
out, mask = pad_image(image, 12, 0, 12, 0, "pillarbox blur", backdrop_blur=0.5)
self.assertEqual(out.shape, (1, 24, 48, 3))
band = out[0, :, :12, :]
# The band varies (it is image content), and stays dimmer than the
# brightest source content because of the dim factor.
self.assertGreater(float(band.max() - band.min()), 0.05)
self.assertLessEqual(float(band.max()), 1.0 - 0.5 * 0.5 + 1e-4)
self.assertFalse(bool(out.isnan().any()))
self.assertEqual(float(mask[0, :, 12:36].sum()), 0.0)
def test_zero_strength_keeps_the_backdrop_sharp_and_undimmed(self):
image = rand_image(1, 16, 16, seed=6)
out, _ = pad_image(image, 8, 0, 8, 0, "pillarbox blur", backdrop_blur=0.0)
self.assertFalse(bool(out.isnan().any()))
# No dimming at strength 0: the padding can reach source brightness.
self.assertGreater(float(out[0, :, :8, :].max()), 0.5)
def test_stronger_setting_blurs_more(self):
generator = torch.Generator().manual_seed(7)
image = torch.rand((1, 32, 32, 3), generator=generator)
soft, _ = pad_image(image, 16, 0, 16, 0, "pillarbox blur", backdrop_blur=0.2)
hard, _ = pad_image(image, 16, 0, 16, 0, "pillarbox blur", backdrop_blur=1.0)
# More blur = less local variation in the backdrop band.
self.assertLess(
float(hard[0, :, :16, :].std()), float(soft[0, :, :16, :].std())
)
if __name__ == "__main__":
unittest.main()
class LowResBackdropBlurTests(unittest.TestCase):
"""A heavy pillarbox blur runs at quarter resolution; a light one stays on
the exact full-resolution path."""
def test_heavy_blur_matches_the_full_resolution_reference(self):
torch.manual_seed(0)
image = torch.rand((2, 96, 160, 3))
out, _ = pad_image(image, 0, 120, 0, 120, "pillarbox blur", backdrop_blur=1.0)
# Rebuild the old full-resolution backdrop for the padded band.
canvas_h, canvas_w = 96 + 240, 160
scale = max(canvas_w / 160, canvas_h / 96)
sw = max(canvas_w, math.ceil(160 * scale))
sh = max(canvas_h, math.ceil(96 * scale))
backdrop = _resize_image(image, sw, sh)
cx, cy = (sw - canvas_w) // 2, (sh - canvas_h) // 2
backdrop = backdrop[:, cy : cy + canvas_h, cx : cx + canvas_w, :]
sigma = min(canvas_h, canvas_w) / _SIGMA_DIVISOR
self.assertGreaterEqual(sigma, _LOWRES_BLUR_MIN_SIGMA) # heavy path taken
reference = _blur_image(backdrop, sigma) * 0.5
band = out[:, :120, :, :]
self.assertLess(float((band - reference[:, :120, :, :]).abs().mean()), 0.01)
def test_light_blur_stays_on_the_exact_path(self):
torch.manual_seed(1)
image = torch.rand((1, 40, 64, 3))
# sigma = 0.2 * 56 / 16 = 0.7, far below the low-res threshold.
sigma = 0.2 * min(40 + 16, 64) / _SIGMA_DIVISOR
self.assertLess(sigma, _LOWRES_BLUR_MIN_SIGMA)
out, _ = pad_image(image, 0, 8, 0, 8, "pillarbox blur", backdrop_blur=0.2)
self.assertEqual(tuple(out.shape), (1, 56, 64, 3))
# The padded band still derives from the picture, not a flat fill.
band = out[:, :8, :, :]
self.assertGreater(float(band.std()), 0.0)
class ResolvePadGeometryTests(unittest.TestCase):
def test_multiple_remainder_lands_on_right_and_bottom(self):
geometry = resolve_pad_geometry(10, 8, 1, 2, 3, 4, 16)
self.assertEqual(
geometry,
{"left": 1, "top": 2, "right": 5, "bottom": 6, "width": 16, "height": 16},
)
def test_multiple_one_and_negatives_are_normalized(self):
geometry = resolve_pad_geometry(10, 8, -5, 0, 3, 0, 1)
self.assertEqual(
geometry,
{"left": 0, "top": 0, "right": 3, "bottom": 0, "width": 13, "height": 8},
)
self.assertEqual(round_up_to_multiple(0, 8), 0)
self.assertEqual(round_up_to_multiple(1, 8), 8)
self.assertEqual(round_up_to_multiple(16, 8), 16)
def test_geometry_matches_the_frontend_pin(self):
# The same numbers are pinned in tests/pad_canvas.test.mjs — the JS
# mirror and this implementation must drift together or not at all.
geometry = resolve_pad_geometry(800, 600, 10, 20, 30, 40, 8)
self.assertEqual(
geometry,
{"left": 10, "top": 20, "right": 30, "bottom": 44, "width": 840, "height": 664},
)
class PlanPadCanvasTests(unittest.TestCase):
def test_target_off_is_a_passthrough(self):
plan = plan_pad_canvas(800, 600, 10, 20, 30, 40, 8, 0.0)
self.assertEqual(plan["scale"], 1.0)
self.assertEqual((plan["source_width"], plan["source_height"]), (800, 600))
self.assertEqual((plan["width"], plan["height"]), (840, 664))
def test_megapixel_target_rescales_the_source_first(self):
# Pinned against tests/pad_canvas.test.mjs finalOutputSize.
plan = plan_pad_canvas(800, 600, 10, 20, 30, 40, 8, 1.0)
self.assertAlmostEqual(plan["scale"], 1.3389868666385072, places=12)
self.assertEqual((plan["source_width"], plan["source_height"]), (1071, 803))
self.assertEqual((plan["width"], plan["height"]), (1128, 888))
# The plan lands within multiple-rounding distance of the target.
self.assertLess(abs(plan["width"] * plan["height"] / 1e6 - 1.0), 0.02)
# Everything still rounds to the multiple after scaling.
self.assertEqual(plan["width"] % 8, 0)
self.assertEqual(plan["height"] % 8, 0)
# The source was scaled, and the scaled pads add up to the canvas.
self.assertEqual(plan["source_width"] + plan["left"] + plan["right"], plan["width"])
self.assertEqual(plan["source_height"] + plan["top"] + plan["bottom"], plan["height"])
class FeatherPadMaskTests(unittest.TestCase):
def build_mask(self):
_, mask = pad_image(rand_image(1, 16, 20, seed=9), 3, 5, 2, 4, "color")
return mask # canvas 25 x 25, image at (3, 5) sized 20 x 16
def test_zero_feather_returns_the_mask_untouched(self):
mask = self.build_mask()
self.assertTrue(torch.equal(feather_pad_mask(mask, 3, 5, 2, 4, 0), mask))
def test_ramp_runs_inward_and_padding_stays_solid(self):
mask = self.build_mask()
out = feather_pad_mask(mask, 3, 5, 2, 4, 3)
# Padding is untouched: fully 1 in the bands and corners.
self.assertEqual(float(out[0, 0, 0]), 1.0)
self.assertEqual(float(out[0, 2, 12]), 1.0)
self.assertEqual(float(out[0, -1, -1]), 1.0)
# Top ramp descends 0.75 / 0.5 / 0.25 into the image (col 12 is clear
# of the left/right ramps).
self.assertAlmostEqual(float(out[0, 5, 12]), 0.75, places=5)
self.assertAlmostEqual(float(out[0, 6, 12]), 0.5, places=5)
self.assertAlmostEqual(float(out[0, 7, 12]), 0.25, places=5)
self.assertEqual(float(out[0, 8, 12]), 0.0)
# Left ramp likewise, on a row clear of the top/bottom ramps.
self.assertAlmostEqual(float(out[0, 10, 3]), 0.75, places=5)
self.assertAlmostEqual(float(out[0, 10, 5]), 0.25, places=5)
self.assertEqual(float(out[0, 10, 6]), 0.0)
# Bottom ramp ascends back toward the padding.
self.assertAlmostEqual(float(out[0, 20, 12]), 0.75, places=5)
self.assertAlmostEqual(float(out[0, 18, 12]), 0.25, places=5)
# The far interior stays clean.
self.assertEqual(float(out[0, 12, 12]), 0.0)
def test_unpadded_sides_are_not_feathered(self):
_, mask = pad_image(rand_image(1, 16, 20, seed=10), 0, 0, 0, 6, "color")
out = feather_pad_mask(mask, 0, 0, 0, 6, 4)
# Only the bottom ramp exists: top rows and side columns stay 0.
self.assertEqual(float(out[0, 0, :].sum()), 0.0)
self.assertEqual(float(out[0, 2:12, 0].sum()), 0.0)
self.assertGreater(float(out[0, 15, 10]), 0.0)
def test_ramp_width_is_capped_by_the_image_dimension(self):
mask = self.build_mask()
out = feather_pad_mask(mask, 3, 5, 2, 4, 500)
self.assertEqual(out.shape, mask.shape)
# The whole image column is ramped but values stay in (0, 1].
self.assertGreater(float(out[0, 12, 12]), 0.0)
self.assertLessEqual(float(out.max()), 1.0)
class LoadImagePadNodeTests(unittest.TestCase):
def make_node(self):
from nodes.node_load_image_pad import (
NODE_CLASS_MAPPINGS,
NODE_DISPLAY_NAME_MAPPINGS,
)
self.assertIn("AUSBOSS_NODES_LoadImagePad", NODE_CLASS_MAPPINGS)
self.assertEqual(
NODE_DISPLAY_NAME_MAPPINGS["AUSBOSS_NODES_LoadImagePad"],
"Load Image + Pad 🆎",
)
return NODE_CLASS_MAPPINGS["AUSBOSS_NODES_LoadImagePad"]
def write_image(self, directory: str, width: int = 64, height: int = 48) -> str:
from PIL import Image
path = Path(directory) / "source.png"
Image.new("RGB", (width, height), (30, 180, 90)).save(path)
return str(path)
def run_node(self, cls, image_path: str, **overrides):
values = {
"pad_left": 2,
"pad_top": 3,
"pad_right": 4,
"pad_bottom": 5,
"mode": "color",
"fill_color": "#000000",
"backdrop_blur": 0.5,
"feather": 0,
"canvas_multiple": 8,
"target_megapixels": 0.0,
}
values.update(overrides)
return getattr(cls(), cls.FUNCTION)(image=image_path, **values), values
def test_contract_and_plain_padding(self):
import tempfile
cls = self.make_node()
self.assertIn("AusBoss/Image", cls.CATEGORY)
self.assertEqual(
cls.RETURN_TYPES,
("IMAGE", "MASK", "INT", "INT", "AUSBOSS_STITCHER", "IMAGE"),
)
self.assertEqual(
cls.RETURN_NAMES,
("image", "mask", "width", "height", "stitcher", "reference"),
)
with tempfile.TemporaryDirectory() as tmp:
path = self.write_image(tmp)
(image, mask, width, height, _, _), _ = self.run_node(cls, path)
self.assertEqual((width, height), (72, 56)) # 70x56 ceiled to 8
self.assertEqual(tuple(image.shape), (1, 56, 72, 3))
self.assertEqual(tuple(mask.shape), (1, 56, 72))
# Hard mask, and the original lands intact at (2, 3).
self.assertEqual(set(torch.unique(mask).tolist()), {0.0, 1.0})
self.assertEqual(float(mask[0, 3 : 3 + 48, 2 : 2 + 64].sum()), 0.0)
def test_megapixel_target_resizes_source_first_and_keeps_the_seam_hard(self):
import tempfile
cls = self.make_node()
with tempfile.TemporaryDirectory() as tmp:
path = self.write_image(tmp)
(image, mask, width, height, _, _), values = self.run_node(
cls, path, target_megapixels=0.05
)
plan = plan_pad_canvas(64, 48, 2, 3, 4, 5, 8, 0.05)
self.assertEqual((width, height), (plan["width"], plan["height"]))
self.assertEqual(tuple(image.shape), (1, height, width, 3))
self.assertLess(abs(width * height / 1e6 - 0.05), 0.01)
# Source-first order: the mask's zero region is exactly the
# RESIZED source rect, and the seam is still binary because the
# padding happened after the resize.
self.assertEqual(set(torch.unique(mask).tolist()), {0.0, 1.0})
inner = mask[
0,
plan["top"] : plan["top"] + plan["source_height"],
plan["left"] : plan["left"] + plan["source_width"],
]
self.assertEqual(float(inner.sum()), 0.0)
self.assertEqual(
float(mask.sum()), float(mask.numel() - inner.numel())
)
def test_feather_softens_the_seam_but_not_the_padding(self):
import tempfile
cls = self.make_node()
with tempfile.TemporaryDirectory() as tmp:
path = self.write_image(tmp)
(_, mask, _, _, _, _), _ = self.run_node(cls, path, feather=6)
values = torch.unique(mask).tolist()
self.assertTrue(any(0.0 < value < 1.0 for value in values))
self.assertEqual(float(mask[0, 0, 0]), 1.0) # padding stays solid
def test_reference_is_the_unpadded_source_snapped_to_16(self):
import tempfile
cls = self.make_node()
with tempfile.TemporaryDirectory() as tmp:
path = self.write_image(tmp)
(_, _, _, _, _, reference), _ = self.run_node(cls, path)
# 64x48 is already under the 384 cap and already /16.
self.assertEqual(tuple(reference.shape), (1, 48, 64, 3))
# It is the SOURCE, not the canvas: no padding colour in it.
self.assertGreater(float(reference.min()), 0.0)
def test_reference_downscales_a_large_source_to_a_multiple_of_16(self):
import tempfile
cls = self.make_node()
with tempfile.TemporaryDirectory() as tmp:
path = self.write_image(tmp, width=1000, height=750)
(_, _, _, _, _, reference), _ = self.run_node(cls, path)
_batch, height, width, _channels = reference.shape
self.assertLessEqual(max(width, height), 384)
self.assertEqual(width % 16, 0)
self.assertEqual(height % 16, 0)
def test_stitcher_carries_where_the_source_sits(self):
import tempfile
cls = self.make_node()
with tempfile.TemporaryDirectory() as tmp:
path = self.write_image(tmp)
(_, mask, width, height, stitcher, _), _ = self.run_node(cls, path)
# pad_left=2, pad_top=3 on a 64x48 source.
self.assertEqual(stitcher["source_bbox"], (2, 3, 66, 51))
x0, y0, x1, y1 = stitcher["bbox_normalized"]
self.assertAlmostEqual(x0, 2 / width)
self.assertAlmostEqual(y0, 3 / height)
self.assertAlmostEqual(x1, 66 / width)
self.assertAlmostEqual(y1, 51 / height)
# The bbox is exactly the region the mask protects.
inner = mask[0, 3:51, 2:66]
self.assertEqual(float(inner.sum()), 0.0)
self.assertEqual(float(mask.sum()), float(mask.numel() - inner.numel()))
def test_bbox_tracks_the_megapixel_resize(self):
import tempfile
cls = self.make_node()
with tempfile.TemporaryDirectory() as tmp:
path = self.write_image(tmp)
(_, mask, _, _, stitcher, _), _ = self.run_node(
cls, path, target_megapixels=0.05
)
plan = plan_pad_canvas(64, 48, 2, 3, 4, 5, 8, 0.05)
x0, y0, x1, y1 = stitcher["source_bbox"]
self.assertEqual((x0, y0), (plan["left"], plan["top"]))
self.assertEqual(x1 - x0, plan["source_width"])
self.assertEqual(y1 - y0, plan["source_height"])
self.assertEqual(float(mask[0, y0:y1, x0:x1].sum()), 0.0)
def test_validation_and_fingerprint(self):
import tempfile
cls = self.make_node()
with tempfile.TemporaryDirectory() as tmp:
path = self.write_image(tmp)
self.assertIs(cls.VALIDATE_INPUTS(image=path), True)
missing = str(Path(tmp) / "gone.png")
self.assertIn("Load Image + Pad", cls.VALIDATE_INPUTS(image=missing))
first = cls.IS_CHANGED(image=path, pad_left=1, feather=0)
second = cls.IS_CHANGED(image=path, pad_left=2, feather=0)
self.assertIsInstance(first, str)
self.assertNotEqual(first, second)