diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d587d4b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,27 @@ +# Changelog + +## 0.2.0 - 2026 July 31 + +### Changed + +- Rebuilt Image Autosize with ComfyUI's V3 node API. +- Renamed the node display name to `Image/Mask Autosize`. +- Accept `IMAGE` or `MASK` inputs and return the matching type. +- Add `constraint_priority` with `min_size` and `max_size` policies. +- Use ComfyUI's shared resize implementation to preserve device, dtype, and + tensor precision for native interpolation modes. +- Replace the `multiplier` output with `scale_x` and append `scale_y`. +- Report scales from the actual intermediate resize dimensions before an + anchored crop. +- Prevent divisible-dimension rounding from producing a zero-sized output. +- Replace the Pillow interpolation list with `nearest-exact`, `bilinear`, + `area`, `bicubic`, and `lanczos`. + +### Compatibility + +- Existing output index 3 now returns `scale_x`; `scale_y` is output index 4. +- The default `min_size` priority preserves the previous sizing order. +- Workflows using `nearest`, `box`, or `hamming` must select a supported + interpolation mode. +- Existing `IMAGE` workflows, input names, node ID, and the first three output + positions remain unchanged. diff --git a/README.md b/README.md index 8c1bd3c..d8b5971 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,101 @@ # ComfyUI-ImageAutosize -A node for [ComfyUI](https://github.com/comfyanonymous/ComfyUI) that provides a convenient way of resizing or cropping an image for diffusion tasks. +A set-and-forget image and mask resizer for diffusion workflows in +[ComfyUI](https://github.com/Comfy-Org/ComfyUI). -This node was designed with the goal of being easy to "set and forget," allowing you to pre-process any input image without having to frequently adjust your parameters. +Image/Mask Autosize combines a longer-dimension target, a shorter-dimension +target, configurable constraint priority, and divisible output dimensions in +one operation. It also provides anchored cropping and reports the exact +horizontal and vertical resize scales. -![workflow_image_autotone](example_workflows/workflow.png) +![workflow](example_workflows/workflow.png) -### Installation +## Sizing behavior -Simply drag the image above into ComfyUI and use [ComfyUI Manager ยป Install Missing Custom Nodes](https://github.com/ltdrdata/ComfyUI-Manager). +The node calculates two aspect-preserving resize scales: -### Inputs +- The scale that makes the longer dimension equal `max_size`. +- The scale that makes the shorter dimension equal `min_size`. -- `image`: The image to modify. -- `max_size` (int): The node will resize the **larger dimension** of the `image` to this size in pixels, preserving aspect ratio. -- `min_size` (int): After evaluating `max_size`, the node will check the **smaller dimension** of the `image`. If it's less than `min_size`, the smaller dimension will be upscaled to this value, preserving aspect ratio. -- `divisible_by` (int): After evaluating both `max_size` and `min_size`, the dimensions of the image will be rounded to values that are multiples of `divisible_by`. This is useful for diffusion models, which often expect sizes that are divisible by 32. -- `interpolation_mode` (string): Name of the resizing method. -- `crop_mode` (string): Cropping is often needed to satisfy `divisble_by`, and this input lets you choose the origin of the crop. Set it to `none` to disable cropping altogether. +`constraint_priority` determines which scale is used: -### Outputs +- `min_size`: Uses the larger scale. This is equivalent to applying `max_size` + first and then enlarging as needed to enforce `min_size`. The shorter + dimension wins when the constraints are incompatible. +- `max_size`: Uses the smaller scale. This is equivalent to applying `min_size` + first and then shrinking as needed to enforce `max_size`. The longer + dimension wins when the constraints are incompatible. -- `image` (image): The modified image. -- `width` (int): The resulting image width in pixels. -- `height` (int): The resulting image height in pixels. -- `multiplier` (float): The multiplier applied to the original image's dimensions. Useful if you want to resize another image or mask by the same amount. +Both dimensions are then rounded to the nearest multiple of `divisible_by`. +This final rounding can move a dimension slightly beyond its nominal +constraint. + +When `crop_mode` is an anchor such as `center` or `top_left`, the input is +resized to cover the calculated dimensions and then cropped from that +position. The aspect ratio is preserved. + +When `crop_mode` is `none`, the input is resized directly to the calculated +dimensions. Divisibility rounding can therefore produce slightly different +horizontal and vertical scales. + +## Inputs + +- `image`: The `IMAGE` or `MASK` to resize. +- `max_size`: Target for the longer dimension. +- `min_size`: Target for the shorter dimension. +- `constraint_priority`: Selects whether `min_size` or `max_size` wins and + whether the larger or smaller candidate resize scale is used. +- `divisible_by`: Rounds both output dimensions to the nearest multiple of this + value. +- `interpolation_mode`: `nearest-exact`, `bilinear`, `area`, `bicubic`, or + `lanczos`. +- `crop_mode`: `none`, center, an edge, or a corner. + +## Outputs + +- `resized`: The resized value, with the same ComfyUI type as the input. +- `width`: Final output width. +- `height`: Final output height. +- `scale_x`: Actual intermediate resize width divided by the original width. +- `scale_y`: Actual intermediate resize height divided by the original height. + +For anchored cropping, `scale_x` and `scale_y` describe the resize performed +before the crop. A crop also introduces an offset, so apply the same settings +to related images and masks when their pixels must remain aligned. + +## Image quality and device behavior + +Image/Mask Autosize uses ComfyUI's shared resize implementation. Tensor-native +interpolation modes preserve the input device and dtype. ComfyUI's Lanczos +implementation uses Pillow internally but returns the result to the original +device and dtype. + +## Relationship to Resize Image/Mask + +ComfyUI's native `Resize Image/Mask` is the better choice for exact dimensions, +fixed multipliers, megapixel targets, or matching another input. Image/Mask +Autosize is focused on automatic diffusion sizing where longer-edge targeting, +shorter-edge constraints, explicit constraint priority, divisible dimensions, +anchored cropping, and scale metadata should happen together. + +## Requirements + +A recent ComfyUI build with V3 `MatchType` support is required. The extension +has no third-party dependencies beyond ComfyUI's existing requirements. + +## Installation + +Clone the repository into `ComfyUI/custom_nodes` and restart ComfyUI: + +```bash +git clone https://github.com/SparknightLLC/ComfyUI-ImageAutosize.git +``` + +The node appears under `image`. + +See [CHANGELOG.md](CHANGELOG.md) for migration notes. --- -This node was adapted from the `[img2img_autosize]` shortcode of [Unprompted](https://github.com/ThereforeGames/unprompted), my Automatic1111 extension. \ No newline at end of file +This node was adapted from the `[img2img_autosize]` shortcode in +[Unprompted](https://github.com/ThereforeGames/unprompted). diff --git a/__init__.py b/__init__.py index 48f2485..2eba9e8 100644 --- a/__init__.py +++ b/__init__.py @@ -1,138 +1,217 @@ -import numpy as np -from PIL import Image -import torch +import comfy.utils +from comfy_api.latest import ComfyExtension, io -class ImageAutosize: +SCALE_METHODS = ["nearest-exact", "bilinear", "area", "bicubic", "lanczos"] +CONSTRAINT_PRIORITIES = ["min_size", "max_size"] +CROP_MODES = [ + "none", + "center", + "top", + "bottom", + "left", + "right", + "top_left", + "top_right", + "bottom_left", + "bottom_right", +] + + +def _calculate_target_dimensions( + width: int, + height: int, + max_size: int, + min_size: int, + constraint_priority: str, + divisible_by: int, +) -> tuple[int, int]: + max_scale = max_size / max(width, height) + min_scale = min_size / min(width, height) + if constraint_priority == "min_size": + scale = max(max_scale, min_scale) + else: + scale = min(min_scale, max_scale) + + target_width = max(divisible_by, round(width * scale / divisible_by) * divisible_by) + target_height = max(divisible_by, round(height * scale / divisible_by) * divisible_by) + return target_width, target_height + + +def _get_crop_origin( + width: int, + height: int, + target_width: int, + target_height: int, + crop_mode: str, +) -> tuple[int, int]: + if crop_mode in ("left", "top_left", "bottom_left"): + x = 0 + elif crop_mode in ("right", "top_right", "bottom_right"): + x = width - target_width + else: + x = (width - target_width) // 2 + + if crop_mode in ("top", "top_left", "top_right"): + y = 0 + elif crop_mode in ("bottom", "bottom_left", "bottom_right"): + y = height - target_height + else: + y = (height - target_height) // 2 + + return x, y + + +class ImageAutosize(io.ComfyNode): + @classmethod + def define_schema(cls) -> io.Schema: + image_type = io.MatchType.Template("image_type", [io.Image, io.Mask]) + + return io.Schema( + node_id="ImageAutosize", + display_name="Image/Mask Autosize", + category="image", + description="Automatically resizes an image or mask for diffusion workflows.", + search_aliases=[ + "autosize", + "image mask autosize", + "auto resize", + "resize to multiple", + "resize image", + "resize mask", + ], + inputs=[ + io.MatchType.Input( + "image", + template=image_type, + tooltip="The image or mask to resize.", + ), + io.Int.Input( + "max_size", + default=1280, + min=1, + max=8192, + step=1, + tooltip="Longer-dimension target used to calculate one candidate resize scale.", + ), + io.Int.Input( + "min_size", + default=512, + min=1, + max=4096, + step=1, + tooltip="Shorter-dimension target used to calculate one candidate resize scale.", + ), + io.Int.Input( + "divisible_by", + default=32, + min=1, + max=8192, + step=1, + tooltip="Rounds both output dimensions to the nearest multiple of this value.", + ), + io.Combo.Input( + "interpolation_mode", + options=SCALE_METHODS, + default="lanczos", + tooltip="Interpolation algorithm used for resizing.", + ), + io.Combo.Input( + "crop_mode", + options=CROP_MODES, + default="center", + tooltip="Preserves aspect ratio by cropping from this position. None stretches to the output dimensions.", + ), + io.Combo.Input( + "constraint_priority", + options=CONSTRAINT_PRIORITIES, + default="min_size", + tooltip="Chooses the candidate scale. min_size uses the larger scale; max_size uses the smaller scale. Divisibility rounding runs afterward.", + ), + ], + outputs=[ + io.MatchType.Output(template=image_type, display_name="resized"), + io.Int.Output(display_name="width"), + io.Int.Output(display_name="height"), + io.Float.Output(display_name="scale_x"), + io.Float.Output(display_name="scale_y"), + ], + ) @classmethod - def INPUT_TYPES(s): - return { - "required": { - "image": ("IMAGE", ), - "max_size": ("INT", { - "default": 1280, - "min": 1, - "max": 8192, - "step": 1 - }), - "min_size": ("INT", { - "default": 512, - "max": 4096, - "min": 1, - "step": 1 - }), - "divisible_by": ("INT", { - "default": 32, - "min": 1 - }), - "interpolation_mode": (["nearest", "lanczos", "bilinear", "bicubic", "box", "hamming"], { - "default": "lanczos", - }), - "crop_mode": (["none", "center", "top", "bottom", "left", "right", "top_left", "top_right", "bottom_left", "bottom_right"], { - "default": "center", - }) - } - } + def execute( + cls, + image: io.Image.Type | io.Mask.Type, + max_size: int, + min_size: int, + constraint_priority: str, + divisible_by: int, + interpolation_mode: str, + crop_mode: str, + ) -> io.NodeOutput: + is_image = len(image.shape) == 4 + if is_image: + _, height, width, _ = image.shape + samples = image.movedim(-1, 1) + else: + _, height, width = image.shape + samples = image.unsqueeze(1) - RETURN_TYPES = ("IMAGE", "INT", "INT", "FLOAT") - RETURN_NAMES = ("image", "width", "height", "multiplier") - FUNCTION = "op" - CATEGORY = "image" - DESCRIPTION = """Resizes the input image""" + target_width, target_height = _calculate_target_dimensions( + width, + height, + max_size, + min_size, + constraint_priority, + divisible_by, + ) - def get_crop_coords(self, img_width, img_height, target_width, target_height, crop_mode): - if crop_mode == "center": - left = (img_width - target_width) // 2 - top = (img_height - target_height) // 2 - elif crop_mode == "top": - left = (img_width - target_width) // 2 - top = 0 - elif crop_mode == "bottom": - left = (img_width - target_width) // 2 - top = img_height - target_height - elif crop_mode == "left": - left = 0 - top = (img_height - target_height) // 2 - elif crop_mode == "right": - left = img_width - target_width - top = (img_height - target_height) // 2 - elif crop_mode == "top_left": - left = 0 - top = 0 - elif crop_mode == "top_right": - left = img_width - target_width - top = 0 - elif crop_mode == "bottom_left": - left = 0 - top = img_height - target_height - elif crop_mode == "bottom_right": - left = img_width - target_width - top = img_height - target_height - else: # fallback to center - left = (img_width - target_width) // 2 - top = (img_height - target_height) // 2 + resize_width = target_width + resize_height = target_height + if crop_mode != "none": + scale = max(target_width / width, target_height / height) + resize_width = max(target_width, round(width * scale)) + resize_height = max(target_height, round(height * scale)) - right = left + target_width - bottom = top + target_height - return left, top, right, bottom + if resize_width != width or resize_height != height: + samples = comfy.utils.common_upscale( + samples, + resize_width, + resize_height, + interpolation_mode, + "disabled", + ) + if len(samples.shape) == 3: + samples = samples.unsqueeze(1) - def op(self, image, max_size, min_size, divisible_by, interpolation_mode, crop_mode): - total_images = image.shape[0] - out_images = [] - interpolation_mode = getattr(Image, interpolation_mode.upper(), Image.LANCZOS) + if crop_mode != "none": + x, y = _get_crop_origin( + resize_width, + resize_height, + target_width, + target_height, + crop_mode, + ) + samples = samples[:, :, y:y + target_height, x:x + target_width] - for i in range(total_images): - img_array = 255. * image[i].cpu().numpy() - width = img_array.shape[1] - height = img_array.shape[0] + if is_image: + output = samples.movedim(1, -1) + else: + output = samples.squeeze(1) - larger_dimension = max(width, height) - multiplier = max_size / larger_dimension - width *= multiplier - height *= multiplier - - smaller_dimension = min(width, height) - if (smaller_dimension < min_size): - multiplier = min_size / smaller_dimension - width *= multiplier - height *= multiplier - - width = int(round(width / divisible_by) * divisible_by) - height = int(round(height / divisible_by) * divisible_by) - - final_multiplier = width / img_array.shape[1] - - img = Image.fromarray(img_array.astype(np.uint8)) - - if crop_mode != "none": - scale = max(width / img.width, height / img.height) - new_size = (int(round(img.width * scale)), int(round(img.height * scale))) - img = img.resize(new_size, interpolation_mode) - - left, top, right, bottom = self.get_crop_coords(img.width, img.height, width, height, crop_mode) - img = img.crop((left, top, right, bottom)) - else: - img = img.resize((width, height), interpolation_mode) - - img_array = np.clip(np.array(img), 0, 255).astype(np.uint8) - out_images.append(img_array) - - restored_img_np = np.array(out_images).astype(np.float32) / 255.0 - restored_img_tensor = torch.from_numpy(restored_img_np) - - return ( - restored_img_tensor, - int(width), - int(height), - float(final_multiplier), + return io.NodeOutput( + output, + target_width, + target_height, + resize_width / width, + resize_height / height, ) -NODE_CLASS_MAPPINGS = { - "ImageAutosize": ImageAutosize, -} +class ImageAutosizeExtension(ComfyExtension): + async def get_node_list(self) -> list[type[io.ComfyNode]]: + return [ImageAutosize] -NODE_DISPLAY_NAME_MAPPINGS = { - "ImageAutosize": "Image Autosize", -} + +async def comfy_entrypoint() -> ImageAutosizeExtension: + return ImageAutosizeExtension() diff --git a/example_workflows/workflow.png b/example_workflows/workflow.png index 5fa50c0..6b4c1ed 100644 Binary files a/example_workflows/workflow.png and b/example_workflows/workflow.png differ diff --git a/pyproject.toml b/pyproject.toml index c09ce82..52d61cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ [project] name = "comfyui-imageautosize" # Unique identifier for your node. Immutable after creation. description = "A node for ComfyUI that provides a convenient way of resizing or cropping an image for diffusion tasks." -version = "0.0.2" # Custom node version. Must be semantically versioned. +version = "0.2.0" # Custom node version. Must be semantically versioned. license = { file = "LICENSE.txt" } dependencies = [] # Filled in from requirements.txt diff --git a/tests/test_image_autosize.py b/tests/test_image_autosize.py new file mode 100644 index 0000000..5c019d4 --- /dev/null +++ b/tests/test_image_autosize.py @@ -0,0 +1,212 @@ +import asyncio +import importlib.util +import sys +import unittest +from pathlib import Path + +import torch + + +PACKAGE_ROOT = Path(__file__).parents[1] +COMFYUI_ROOT = PACKAGE_ROOT.parents[1] +sys.path.insert(0, str(COMFYUI_ROOT)) +SPEC = importlib.util.spec_from_file_location("image_autosize", PACKAGE_ROOT / "__init__.py") +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class ImageAutosizeTests(unittest.TestCase): + def test_extension_registers_node(self): + extension = asyncio.run(MODULE.comfy_entrypoint()) + nodes = asyncio.run(extension.get_node_list()) + + self.assertEqual(nodes, [MODULE.ImageAutosize]) + + def test_schema_accepts_images_and_masks(self): + inputs = MODULE.ImageAutosize.INPUT_TYPES() + image_options = inputs["required"]["image"][1] + priority_options = inputs["required"]["constraint_priority"][1] + + self.assertEqual(image_options["template"]["allowed_types"], "IMAGE,MASK") + self.assertEqual( + list(inputs["required"]), + [ + "image", + "max_size", + "min_size", + "divisible_by", + "interpolation_mode", + "crop_mode", + "constraint_priority", + ], + ) + self.assertEqual(priority_options["options"], ["min_size", "max_size"]) + self.assertEqual(priority_options["default"], "min_size") + self.assertEqual(MODULE.ImageAutosize.define_schema().display_name, "Image/Mask Autosize") + self.assertEqual( + MODULE.ImageAutosize.RETURN_TYPES, + ["COMFY_MATCHTYPE_V3", "INT", "INT", "FLOAT", "FLOAT"], + ) + + def test_autosizes_image_and_reports_pre_crop_scales(self): + image = torch.zeros((1, 5, 3, 3), dtype=torch.float32) + + output = MODULE.ImageAutosize.execute( + image=image, + max_size=8, + min_size=1, + constraint_priority="min_size", + divisible_by=4, + interpolation_mode="nearest-exact", + crop_mode="center", + ) + + resized, width, height, scale_x, scale_y = output.args + self.assertEqual(resized.shape, (1, 8, 4, 3)) + self.assertEqual((width, height), (4, 8)) + self.assertAlmostEqual(scale_x, 5 / 3) + self.assertAlmostEqual(scale_y, 8 / 5) + + def test_no_crop_reports_independent_stretch_scales(self): + image = torch.zeros((1, 5, 3, 3), dtype=torch.float32) + + output = MODULE.ImageAutosize.execute( + image=image, + max_size=8, + min_size=1, + constraint_priority="min_size", + divisible_by=4, + interpolation_mode="nearest-exact", + crop_mode="none", + ) + + resized, width, height, scale_x, scale_y = output.args + self.assertEqual(resized.shape, (1, 8, 4, 3)) + self.assertEqual((width, height), (4, 8)) + self.assertAlmostEqual(scale_x, 4 / 3) + self.assertAlmostEqual(scale_y, 8 / 5) + + def test_resizes_mask_without_adding_image_channels(self): + mask = torch.zeros((2, 5, 3), dtype=torch.float32) + + output = MODULE.ImageAutosize.execute( + image=mask, + max_size=8, + min_size=1, + constraint_priority="min_size", + divisible_by=4, + interpolation_mode="bilinear", + crop_mode="center", + ) + + resized = output.args[0] + self.assertEqual(resized.shape, (2, 8, 4)) + self.assertEqual(resized.dtype, mask.dtype) + self.assertEqual(resized.device, mask.device) + + def test_crops_lanczos_mask_after_grayscale_resize(self): + mask = torch.zeros((1, 5, 3), dtype=torch.float32) + + output = MODULE.ImageAutosize.execute( + image=mask, + max_size=8, + min_size=1, + constraint_priority="min_size", + divisible_by=4, + interpolation_mode="lanczos", + crop_mode="center", + ) + + self.assertEqual(output.args[0].shape, (1, 8, 4)) + + def test_avoids_resampling_when_dimensions_are_unchanged(self): + image = torch.tensor( + [ + [ + [[0.123456, 0.234567, 0.345678], [0.456789, 0.567891, 0.678912]], + [[0.789123, 0.891234, 0.912345], [0.135791, 0.246802, 0.357913]], + ], + ], + dtype=torch.float32, + ) + + output = MODULE.ImageAutosize.execute( + image=image, + max_size=2, + min_size=1, + constraint_priority="min_size", + divisible_by=1, + interpolation_mode="lanczos", + crop_mode="center", + ) + + self.assertTrue(torch.equal(output.args[0], image)) + self.assertEqual(output.args[3:], (1.0, 1.0)) + + def test_minimum_shorter_dimension_overrides_longer_target(self): + self.assertEqual( + MODULE._calculate_target_dimensions( + width=10, + height=100, + max_size=50, + min_size=10, + constraint_priority="min_size", + divisible_by=1, + ), + (10, 100), + ) + + def test_target_dimensions_cannot_round_to_zero(self): + width, height = MODULE._calculate_target_dimensions( + width=1, + height=100, + max_size=1, + min_size=1, + constraint_priority="min_size", + divisible_by=32, + ) + + self.assertGreaterEqual(width, 32) + self.assertGreaterEqual(height, 32) + + def test_maximum_constraint_priority_prefers_smaller_scale(self): + self.assertEqual( + MODULE._calculate_target_dimensions( + width=10, + height=100, + max_size=50, + min_size=10, + constraint_priority="max_size", + divisible_by=1, + ), + (5, 50), + ) + + def test_maximum_constraint_priority_controls_resize(self): + image = torch.zeros((1, 100, 10, 3), dtype=torch.float32) + + output = MODULE.ImageAutosize.execute( + image=image, + max_size=50, + min_size=10, + constraint_priority="max_size", + divisible_by=1, + interpolation_mode="nearest-exact", + crop_mode="none", + ) + + resized, width, height, scale_x, scale_y = output.args + self.assertEqual(resized.shape, (1, 50, 5, 3)) + self.assertEqual((width, height), (5, 50)) + self.assertEqual((scale_x, scale_y), (0.5, 0.5)) + + def test_crop_origins_follow_selected_anchor(self): + self.assertEqual(MODULE._get_crop_origin(7, 9, 4, 6, "top_left"), (0, 0)) + self.assertEqual(MODULE._get_crop_origin(7, 9, 4, 6, "bottom_right"), (3, 3)) + self.assertEqual(MODULE._get_crop_origin(7, 9, 4, 6, "top"), (1, 0)) + self.assertEqual(MODULE._get_crop_origin(7, 9, 4, 6, "right"), (3, 1)) + self.assertEqual(MODULE._get_crop_origin(7, 9, 4, 6, "center"), (1, 1)) + + +if __name__ == "__main__": + unittest.main()