Rework ImageAutosize for ComfyUI v3 API

Rebuild ImageAutosize to use ComfyUI V3 node API: add a proper ComfyExtension entrypoint and io.Schema-based node. Rename display to "Image/Mask Autosize", accept IMAGE or MASK inputs, and replace multiplier output with scale_x/scale_y. Add constraint_priority (min_size/max_size), anchored crop handling, divisible-by rounding protection, and new interpolation modes. Update README, add CHANGELOG, bump package version to 0.2.0, refresh workflow image, and include comprehensive unit tests for sizing, cropping, and mask behavior.
This commit is contained in:
ThereforeGames
2026-07-31 03:10:07 -04:00
parent 5da7e06bc9
commit 892f24b5cc
6 changed files with 531 additions and 143 deletions
+27
View File
@@ -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.
+88 -18
View File
@@ -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.
This node was adapted from the `[img2img_autosize]` shortcode in
[Unprompted](https://github.com/ThereforeGames/unprompted).
+203 -124
View File
@@ -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()
Binary file not shown.

Before

Width:  |  Height:  |  Size: 344 KiB

After

Width:  |  Height:  |  Size: 418 KiB

+1 -1
View File
@@ -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
+212
View File
@@ -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()