Files
ausboss-ComfyUI-AusBoss/tests/test_resize_helpers.py
ausboss 8cc220df6c Image Resize: pad adds no bar when width or height is 0
A width+height box with a 0 in it takes its shape from the source, so only rounding and Multiple separate the two. Pad answered that with a bar 1 or 2 px wide: a 1000x700 picture with Multiple 16 came out 1008x704 with a 2 px bar. The picture now fills the box. The output size is unchanged, and a box with both sides set pads as before.
2026-10-06 12:57:32 -05:00

480 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"]
from nodes._resize_helpers import (
INTERPOLATION_MODES,
PROPORTION_MODES,
TARGET_MODES,
apply_resize,
plan_resize,
resample_batch,
resolve_target,
snap_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)
class SnapToMultipleTests(unittest.TestCase):
def test_snaps_to_the_nearest_multiple_half_up(self):
self.assertEqual(snap_to_multiple(1000, 16), 1008) # 62.5 rounds up
self.assertEqual(snap_to_multiple(23, 16), 16)
self.assertEqual(snap_to_multiple(24, 16), 32) # 1.5 rounds up
self.assertEqual(snap_to_multiple(16, 16), 16)
def test_never_returns_below_one_step(self):
self.assertEqual(snap_to_multiple(0, 16), 16)
self.assertEqual(snap_to_multiple(3, 16), 16)
def test_multiple_one_is_a_passthrough(self):
self.assertEqual(snap_to_multiple(7, 1), 7)
self.assertEqual(snap_to_multiple(7, 0), 7) # normalized to 1
class ResolveTargetTests(unittest.TestCase):
def test_width_height_zero_keeps_the_source(self):
self.assertEqual(resolve_target(1920, 1080, "width+height", 0, 0), (1920, 1080))
def test_width_height_one_value_preserves_aspect(self):
self.assertEqual(resolve_target(1920, 1080, "width+height", 960, 0), (960, 540))
self.assertEqual(resolve_target(1920, 1080, "width+height", 0, 540), (960, 540))
def test_width_height_both_values_are_exact(self):
self.assertEqual(resolve_target(1920, 1080, "width+height", 512, 512), (512, 512))
def test_longest_edge_lands_exactly_on_the_request(self):
self.assertEqual(
resolve_target(1920, 1080, "longest_edge", edge_length=960), (960, 540)
)
self.assertEqual(
resolve_target(1080, 1920, "longest_edge", edge_length=960), (540, 960)
)
self.assertEqual(
resolve_target(1920, 1080, "longest_edge", edge_length=0), (1920, 1080)
)
def test_shortest_edge_lands_exactly_on_the_request(self):
self.assertEqual(
resolve_target(1920, 1080, "shortest_edge", edge_length=540), (960, 540)
)
self.assertEqual(
resolve_target(1080, 1920, "shortest_edge", edge_length=540), (540, 960)
)
self.assertEqual(
resolve_target(1920, 1080, "shortest_edge", edge_length=0), (1920, 1080)
)
def test_megapixels_hits_the_pixel_budget(self):
width, height = resolve_target(1920, 1080, "megapixels", megapixels=1.0)
self.assertEqual((width, height), (1333, 750))
self.assertLess(abs(width * height / 1e6 - 1.0), 0.01)
# Aspect survives the scale.
self.assertAlmostEqual(width / height, 1920 / 1080, places=2)
self.assertEqual(
resolve_target(1920, 1080, "megapixels", megapixels=0.0), (1920, 1080)
)
def test_scale_factor_multiplies_and_zero_keeps_the_source(self):
self.assertEqual(
resolve_target(1920, 1080, "scale_factor", scale_factor=0.5), (960, 540)
)
self.assertEqual(
resolve_target(1920, 1080, "scale_factor", scale_factor=2.0), (3840, 2160)
)
self.assertEqual(
resolve_target(1920, 1080, "scale_factor", scale_factor=0.0), (1920, 1080)
)
def test_dimensions_never_collapse_to_zero(self):
self.assertEqual(
resolve_target(2000, 10, "scale_factor", scale_factor=0.01), (20, 1)
)
def test_unknown_mode_and_empty_source_are_rejected(self):
with self.assertRaises(ValueError):
resolve_target(64, 64, "diagonal")
with self.assertRaises(ValueError):
resolve_target(0, 64, "width+height")
class PlanResizeTests(unittest.TestCase):
def test_stretch_resizes_straight_to_the_target(self):
plan = plan_resize(1920, 1080, 512, 512, "stretch")
self.assertEqual(
plan,
{
"resize_width": 512,
"resize_height": 512,
"canvas_width": 512,
"canvas_height": 512,
"crop_x": 0,
"crop_y": 0,
"offset_x": 0,
"offset_y": 0,
},
)
def test_fit_shrinks_the_target_box_to_the_source_aspect(self):
plan = plan_resize(1920, 1080, 512, 512, "fit")
self.assertEqual((plan["canvas_width"], plan["canvas_height"]), (512, 288))
self.assertEqual((plan["resize_width"], plan["resize_height"]), (512, 288))
self.assertEqual((plan["offset_x"], plan["offset_y"]), (0, 0))
def test_fit_snaps_the_fitted_size_to_the_multiple(self):
plan = plan_resize(1920, 1080, 500, 500, "fit", divisible_by=16)
self.assertEqual((plan["canvas_width"], plan["canvas_height"]), (496, 288))
self.assertEqual(plan["canvas_width"] % 16, 0)
self.assertEqual(plan["canvas_height"] % 16, 0)
def test_cover_crop_overshoots_and_centers_the_crop(self):
plan = plan_resize(1920, 1080, 512, 512, "cover_crop")
self.assertEqual((plan["canvas_width"], plan["canvas_height"]), (512, 512))
self.assertEqual((plan["resize_width"], plan["resize_height"]), (911, 512))
self.assertEqual((plan["crop_x"], plan["crop_y"]), (199, 0))
def test_cover_crop_exact_scale_does_not_overshoot_by_a_pixel(self):
# scale is exactly 0.5; floating-point noise must not ceil past it.
plan = plan_resize(100, 50, 50, 25, "cover_crop")
self.assertEqual((plan["resize_width"], plan["resize_height"]), (50, 25))
self.assertEqual((plan["crop_x"], plan["crop_y"]), (0, 0))
def test_pad_fits_inside_and_centers_the_bars(self):
plan = plan_resize(1920, 1080, 512, 512, "pad")
self.assertEqual((plan["canvas_width"], plan["canvas_height"]), (512, 512))
self.assertEqual((plan["resize_width"], plan["resize_height"]), (512, 288))
self.assertEqual((plan["offset_x"], plan["offset_y"]), (0, 112))
def test_pad_snaps_the_canvas_then_refits_the_source(self):
plan = plan_resize(1920, 1080, 500, 500, "pad", divisible_by=16)
self.assertEqual((plan["canvas_width"], plan["canvas_height"]), (496, 496))
self.assertEqual((plan["resize_width"], plan["resize_height"]), (496, 279))
self.assertEqual((plan["offset_x"], plan["offset_y"]), (0, 108))
def test_matching_target_is_an_identity_plan(self):
for mode in PROPORTION_MODES:
with self.subTest(mode=mode):
plan = plan_resize(640, 480, 640, 480, mode)
self.assertEqual((plan["resize_width"], plan["resize_height"]), (640, 480))
self.assertEqual((plan["canvas_width"], plan["canvas_height"]), (640, 480))
self.assertEqual((plan["crop_x"], plan["crop_y"]), (0, 0))
self.assertEqual((plan["offset_x"], plan["offset_y"]), (0, 0))
def test_divisible_by_applies_even_when_the_target_is_the_source(self):
plan = plan_resize(1000, 750, 1000, 750, "stretch", divisible_by=16)
self.assertEqual((plan["canvas_width"], plan["canvas_height"]), (1008, 752))
def test_invalid_inputs_are_rejected(self):
with self.assertRaises(ValueError):
plan_resize(640, 480, 512, 512, "letterbox")
with self.assertRaises(ValueError):
plan_resize(640, 480, 0, 512, "fit")
class ResampleBatchTests(unittest.TestCase):
def test_every_mode_returns_the_requested_shape(self):
image = rand_image(2, 16, 16, seed=1)
for mode in INTERPOLATION_MODES:
for width, height in ((24, 20), (8, 8)):
with self.subTest(mode=mode, size=(width, height)):
out = resample_batch(image, width, height, mode)
self.assertEqual(tuple(out.shape), (2, height, width, 3))
self.assertFalse(bool(out.isnan().any()))
def test_matching_size_returns_the_input_untouched(self):
image = rand_image(1, 12, 10, seed=2)
self.assertIs(resample_batch(image, 10, 12, "lanczos"), image)
def test_nearest_invents_no_new_values(self):
image = torch.tensor(
[[[0.0, 0.25], [0.5, 1.0]]], dtype=torch.float32
).unsqueeze(-1)
out = resample_batch(image, 8, 8, "nearest")
self.assertEqual(
set(torch.unique(out).tolist()), set(torch.unique(image).tolist())
)
def test_overshooting_filters_stay_inside_the_value_range(self):
# A hard checkerboard makes lanczos and bicubic ring past 0..1.
checker = torch.zeros((1, 16, 16, 3), dtype=torch.float32)
checker[:, ::2, ::2, :] = 1.0
checker[:, 1::2, 1::2, :] = 1.0
for mode in ("lanczos", "bicubic"):
with self.subTest(mode=mode):
out = resample_batch(checker, 24, 24, mode)
self.assertGreaterEqual(float(out.min()), 0.0)
self.assertLessEqual(float(out.max()), 1.0)
def test_lanczos_keeps_a_constant_image_constant(self):
image = torch.full((1, 10, 14, 3), 0.375, dtype=torch.float32)
out = resample_batch(image, 7, 5, "lanczos")
self.assertTrue(torch.allclose(out, torch.full_like(out, 0.375), atol=1e-5))
def test_area_downscale_averages_exactly(self):
checker = torch.zeros((1, 4, 4, 1), dtype=torch.float32)
checker[:, ::2, ::2] = 1.0
checker[:, 1::2, 1::2] = 1.0
out = resample_batch(checker, 2, 2, "area")
self.assertTrue(torch.allclose(out, torch.full_like(out, 0.5)))
def test_bad_arguments_are_rejected(self):
image = rand_image(1, 8, 8)
with self.assertRaises(ValueError):
resample_batch(image, 8, 8, "mitchell")
with self.assertRaises(ValueError):
resample_batch(image, 0, 8, "bilinear")
with self.assertRaises(ValueError):
resample_batch(image[0], 8, 8, "bilinear")
def run_resize(image, mask=None, **overrides):
values = {
"target_mode": "width+height",
"width": 0,
"height": 0,
"edge_length": 0,
"megapixels": 0.0,
"scale_factor": 0.0,
"keep_proportion": "fit",
"fill_color": "#000000",
"divisible_by": 1,
"interpolation": "nearest",
}
values.update(overrides)
return apply_resize(image, mask, **values)
class ApplyResizeTests(unittest.TestCase):
def test_stretch_hits_the_exact_target_and_reports_it(self):
image = rand_image(2, 24, 32, seed=3)
out, mask, width, height = run_resize(
image, width=16, height=12, keep_proportion="stretch"
)
self.assertEqual(tuple(out.shape), (2, 12, 16, 3))
self.assertEqual(tuple(mask.shape), (2, 12, 16))
self.assertEqual((width, height), (16, 12))
self.assertEqual(float(mask.sum()), 0.0)
def test_fit_keeps_the_aspect_instead_of_the_box(self):
image = rand_image(1, 24, 32, seed=4)
out, _, width, height = run_resize(image, width=16, height=16)
self.assertEqual((width, height), (16, 12))
self.assertEqual(tuple(out.shape), (1, 12, 16, 3))
def test_zero_widgets_are_a_bit_identical_passthrough(self):
image = rand_image(1, 24, 32, seed=5)
out, mask, width, height = run_resize(image)
self.assertIs(out, image)
self.assertEqual((width, height), (32, 24))
self.assertEqual(tuple(mask.shape), (1, 24, 32))
self.assertEqual(float(mask.sum()), 0.0)
def test_zero_widgets_still_respect_divisible_by(self):
image = rand_image(1, 20, 30, seed=6)
out, _, width, height = run_resize(
image, keep_proportion="stretch", divisible_by=8
)
self.assertEqual((width, height), (32, 24))
self.assertEqual(tuple(out.shape), (1, 24, 32, 3))
def test_scale_derived_modes_never_invent_pixels(self):
# 855x480 at longest_edge 512 fits to 512x287 but snaps to 512x288 —
# the case where pad used to answer divisible_by with an invented
# bottom row. The box is derived from the source's own aspect, so
# every proportion mode must resolve the snap like fit does: a
# sub-half-step resize, a black mask, and not one invented pixel.
image = rand_image(1, 480, 855, seed=9)
for proportion in ("stretch", "fit", "cover_crop", "pad"):
out, mask, width, height = run_resize(
image,
target_mode="longest_edge",
edge_length=512,
keep_proportion=proportion,
divisible_by=16,
)
self.assertEqual((width, height), (512, 288), proportion)
self.assertEqual(tuple(out.shape), (1, 288, 512, 3), proportion)
self.assertEqual(float(mask.max()), 0.0, proportion)
def test_pad_adds_no_bar_to_a_box_with_a_zero_side(self):
# A 1000x700 source: Multiple 16 on a 0/0 box used to give 1008x704
# with a 2 px bar, and width 512 alone 512x358 with a 1 px bar. A box
# with a 0 in it takes its shape from the source, so only rounding
# and the snap move it: same size as before, no bar, a black mask.
image = rand_image(1, 700, 1000, seed=14)
cases = (
({"divisible_by": 16}, (1008, 704)),
({"width": 512}, (512, 358)),
({"height": 358}, (511, 358)),
({"width": 520, "divisible_by": 16}, (528, 368)),
)
for overrides, size in cases:
with self.subTest(**overrides):
out, mask, width, height = run_resize(
image, keep_proportion="pad", fill_color="#ff0000", **overrides
)
self.assertEqual((width, height), size)
self.assertEqual(tuple(out.shape), (1, size[1], size[0], 3))
self.assertEqual(float(mask.max()), 0.0)
# Both sides set is a box of its own: pad still fills what is left.
_, mask, width, height = run_resize(
image, width=512, height=512, keep_proportion="pad"
)
self.assertEqual((width, height), (512, 512))
self.assertEqual(float(mask.max()), 1.0)
def test_pad_fills_the_bars_and_marks_them_in_the_mask(self):
image = rand_image(1, 24, 32, seed=7)
out, mask, width, height = run_resize(
image, width=32, height=32, keep_proportion="pad", fill_color="#ff0000"
)
self.assertEqual((width, height), (32, 32))
self.assertEqual(tuple(out.shape), (1, 32, 32, 3))
# The source resamples to itself, so it lands bit-identical at y=4.
self.assertTrue(torch.equal(out[:, 4:28, :, :], image))
red = torch.tensor([1.0, 0.0, 0.0])
self.assertTrue(torch.allclose(out[0, 0, 0], red))
self.assertTrue(torch.allclose(out[0, -1, -1], red))
# Generated-area contract: bars 1.0, source region 0.0.
self.assertEqual(float(mask[:, 4:28, :].sum()), 0.0)
self.assertEqual(float(mask.sum()), float(2 * 4 * 32))
def test_pad_carries_the_input_mask_inside_the_bars(self):
image = rand_image(1, 24, 32, seed=8)
mask = torch.full((1, 24, 32), 0.5, dtype=torch.float32)
_, out_mask, _, _ = run_resize(
image, mask, width=32, height=32, keep_proportion="pad"
)
self.assertTrue(
torch.allclose(out_mask[:, 4:28, :], torch.full((1, 24, 32), 0.5))
)
self.assertEqual(float(out_mask[:, :4, :].min()), 1.0)
self.assertEqual(float(out_mask[:, 28:, :].min()), 1.0)
def test_cover_crop_slices_image_and_mask_identically(self):
image = rand_image(1, 24, 32, seed=9)
mask = torch.zeros((1, 24, 32), dtype=torch.float32)
mask[:, :, :16] = 1.0
out, out_mask, width, height = run_resize(
image, mask, width=24, height=24, keep_proportion="cover_crop"
)
self.assertEqual((width, height), (24, 24))
# Cover scale is exactly 1.0 here, so the crop is a pure slice.
self.assertTrue(torch.equal(out, image[:, :, 4:28, :]))
self.assertTrue(torch.equal(out_mask, mask[:, :, 4:28]))
def test_mask_keeps_its_own_batch_size(self):
image = rand_image(3, 24, 32, seed=10)
mask = torch.ones((1, 24, 32), dtype=torch.float32)
out, out_mask, _, _ = run_resize(
image, mask, width=32, height=32, keep_proportion="pad"
)
self.assertEqual(out.shape[0], 3)
self.assertEqual(out_mask.shape[0], 1)
def test_scaling_modes_flow_through_end_to_end(self):
image = rand_image(2, 24, 32, seed=11)
_, _, width, height = run_resize(
image, target_mode="longest_edge", edge_length=16
)
self.assertEqual((width, height), (16, 12))
_, _, width, height = run_resize(
image, target_mode="shortest_edge", edge_length=12
)
self.assertEqual((width, height), (16, 12))
_, _, width, height = run_resize(
image, target_mode="scale_factor", scale_factor=0.5
)
self.assertEqual((width, height), (16, 12))
def test_bad_tensors_are_rejected(self):
with self.assertRaises(ValueError):
run_resize(torch.zeros((24, 32, 3)))
with self.assertRaises(ValueError):
run_resize(rand_image(1, 8, 8), torch.zeros((1, 8, 8, 1)))
class ImageResizeNodeTests(unittest.TestCase):
def make_node(self):
from nodes.node_image_resize import (
NODE_CLASS_MAPPINGS,
NODE_DISPLAY_NAME_MAPPINGS,
)
self.assertIn("AUSBOSS_NODES_ImageResize", NODE_CLASS_MAPPINGS)
self.assertEqual(
NODE_DISPLAY_NAME_MAPPINGS["AUSBOSS_NODES_ImageResize"],
"Image Resize 🆎",
)
return NODE_CLASS_MAPPINGS["AUSBOSS_NODES_ImageResize"]
def test_contract(self):
cls = self.make_node()
self.assertIn("AusBoss/Image", cls.CATEGORY)
self.assertIn("ausboss", cls.SEARCH_ALIASES)
self.assertTrue(cls.DESCRIPTION)
self.assertEqual(cls.RETURN_TYPES, ("IMAGE", "MASK", "INT", "INT"))
self.assertEqual(cls.RETURN_NAMES, ("image", "mask", "width", "height"))
self.assertEqual(len(cls.OUTPUT_TOOLTIPS), 4)
def test_every_input_is_tooltipped_and_combos_match_the_helpers(self):
cls = self.make_node()
inputs = cls.INPUT_TYPES()
for section in ("required", "optional"):
for name, (kind, options) in inputs[section].items():
with self.subTest(input=name):
self.assertTrue(options.get("tooltip"))
self.assertEqual(inputs["required"]["target_mode"][0], list(TARGET_MODES))
self.assertEqual(inputs["required"]["keep_proportion"][0], list(PROPORTION_MODES))
self.assertEqual(inputs["required"]["interpolation"][0], list(INTERPOLATION_MODES))
self.assertIn("mask", inputs["optional"])
def run_node(self, cls, image, mask=None, **overrides):
values = {
"target_mode": "width+height",
"width": 0,
"height": 0,
"edge_length": 0,
"megapixels": 0.0,
"scale_factor": 0.0,
"keep_proportion": "fit",
"fill_color": "#000000",
"divisible_by": 1,
"interpolation": "bilinear",
}
values.update(overrides)
return getattr(cls(), cls.FUNCTION)(image=image, mask=mask, **values)
def test_resizes_by_longest_edge(self):
cls = self.make_node()
image = rand_image(1, 24, 32, seed=12)
out, mask, width, height = self.run_node(
cls, image, target_mode="longest_edge", edge_length=16
)
self.assertEqual((width, height), (16, 12))
self.assertEqual(tuple(out.shape), (1, 12, 16, 3))
self.assertEqual(tuple(mask.shape), (1, 12, 16))
def test_defaults_keep_the_source(self):
cls = self.make_node()
image = rand_image(1, 24, 32, seed=13)
out, _, width, height = self.run_node(cls, image)
self.assertIs(out, image)
self.assertEqual((width, height), (32, 24))
if __name__ == "__main__":
unittest.main()