Files
speedyrulzandClaude Fable 5 1c35317466 Resize to Multiple v1.2.0
ComfyUI custom node: resize an image to the closest standard aspect
ratio at a target megapixel count (or a custom resolution), snapped
down to a selectable pixel multiple, with image, VAE latent, empty
latent (SDXL / Flux.2 Klein), width and height outputs and an on-node
resolution readout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 20:50:07 -04:00

176 lines
7.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Resize an image to the closest standard aspect ratio at a target megapixel
count, snapped down to a selectable pixel multiple, with image / VAE latent /
empty latent / width / height outputs."""
import math
import torch
import comfy.model_management
import comfy.utils
import nodes
try:
from comfy_execution.graph_utils import ExecutionBlocker
except ImportError: # ComfyUI versions before ExecutionBlocker existed
ExecutionBlocker = None
ASPECT_RATIOS = (
("1:1", 1, 1),
("2:3", 2, 3),
("3:2", 3, 2),
("3:4", 3, 4),
("4:3", 4, 3),
("4:5", 4, 5),
("5:4", 5, 4),
("16:9", 16, 9),
("9:16", 9, 16),
)
MEGAPIXEL_CHOICES = ["%.1f" % (i / 10.0) for i in range(1, 41)]
MULTIPLE_CHOICES = ["2", "4", "8", "16", "32", "64"]
LATENT_SDXL = "SDXL (and compatible)"
LATENT_FLUX2 = "Flux.2 Klein (and compatible)"
class ResizeToMultiple:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE", {"tooltip": "The image to resize."}),
"megapixels": (MEGAPIXEL_CHOICES, {
"default": "1.0",
"tooltip": "Target size in megapixels. The final image is slightly smaller after edges are rounded down to the selected multiple.",
}),
"empty_latent_type": ([LATENT_SDXL, LATENT_FLUX2], {
"default": LATENT_SDXL,
"tooltip": "Latent format for the empty_latent output. SDXL: 4 channels, 8x downscale. Flux.2 Klein: 128 channels, 16x downscale.",
}),
# New widgets are appended after the older ones so the
# positionally-applied saved values of earlier versions keep
# mapping to the right widgets on load.
"multiple": (MULTIPLE_CHOICES, {
"default": "16",
"tooltip": "Each edge of the final image is rounded down to a multiple of this value. Use 16 for Flux.2, 8 or more for SDXL.",
}),
"use_custom_resolution": ("BOOLEAN", {
"default": False,
"tooltip": "Ignore megapixels and aspect matching; use custom_width x custom_height (each rounded down to the multiple) as the target size.",
}),
"custom_width": ("INT", {
"default": 1024, "min": 16, "max": nodes.MAX_RESOLUTION,
"tooltip": "Target width when use_custom_resolution is enabled.",
}),
"custom_height": ("INT", {
"default": 1024, "min": 16, "max": nodes.MAX_RESOLUTION,
"tooltip": "Target height when use_custom_resolution is enabled.",
}),
},
"optional": {
"vae": ("VAE", {
"tooltip": "Optional. Connect a VAE to get the resized image encoded on the latent output. Leave unconnected if you only use the image or empty_latent outputs.",
}),
},
}
RETURN_TYPES = ("IMAGE", "LATENT", "LATENT", "INT", "INT")
RETURN_NAMES = ("image", "latent", "empty_latent", "width", "height")
OUTPUT_TOOLTIPS = (
"The resized image.",
"The resized image encoded by the input VAE (requires the vae input).",
"An empty latent in the selected format at the final resolution.",
"Width of the final image in pixels.",
"Height of the final image in pixels.",
)
FUNCTION = "resize"
CATEGORY = "image/transform"
DESCRIPTION = (
"Matches the input image to the closest aspect ratio out of 1:1, 2:3, 3:2, 3:4, "
"4:3, 4:5, 5:4, 16:9 and 9:16, resizes it with Lanczos to the target megapixels, "
"rounds each edge down to the selected multiple and center-crops to those exact "
"dimensions. Outputs the image, a VAE-encoded latent, a matching empty latent "
"and the final width/height."
)
def resize(self, image, megapixels, empty_latent_type, multiple,
use_custom_resolution=False, custom_width=1024, custom_height=1024, vae=None):
batch_size, in_height, in_width, _ = image.shape
snap = int(multiple)
if use_custom_resolution:
ratio_name = "custom"
ideal_width = max(16, int(custom_width))
ideal_height = max(16, int(custom_height))
else:
target_pixels = float(megapixels) * 1_000_000.0
ratio_name, ratio_w, ratio_h = min(
ASPECT_RATIOS,
key=lambda r: abs(math.log((in_width / in_height) * (r[2] / r[1]))),
)
# Flooring (not rounding) keeps width*height <= target_pixels; the
# 1e-9 guards against float dips just below exact integer square
# roots.
ideal_width = max(1, int(math.sqrt(target_pixels * ratio_w / ratio_h) + 1e-9))
ideal_height = max(1, int(math.sqrt(target_pixels * ratio_h / ratio_w) + 1e-9))
final_width = max(snap, (ideal_width // snap) * snap)
final_height = max(snap, (ideal_height // snap) * snap)
ideal_width = max(ideal_width, final_width)
ideal_height = max(ideal_height, final_height)
samples = image.movedim(-1, 1)
samples = comfy.utils.common_upscale(samples, ideal_width, ideal_height, "lanczos", "center")
crop_x = (ideal_width - final_width) // 2
crop_y = (ideal_height - final_height) // 2
samples = samples.narrow(-2, crop_y, final_height).narrow(-1, crop_x, final_width)
out_image = samples.movedim(1, -1)
empty_latent = self._empty_latent(empty_latent_type, final_width, final_height, batch_size)
if vae is not None:
latent = {"samples": vae.encode(out_image[:, :, :, :3])}
elif ExecutionBlocker is not None:
# Blocks (with this message) only if a downstream node actually
# consumes the latent output; unused slots are unaffected and the
# blocker survives ComfyUI's output caching across graph edits.
latent = ExecutionBlocker(
"ResizeToMultiple: the 'latent' output requires a VAE. Connect a "
"VAE to the vae input, or use the 'empty_latent' output instead."
)
else:
latent = empty_latent
ui_text = [
"%d×%d → %d×%d" % (in_width, in_height, final_width, final_height),
"%s · %.2f MP" % (ratio_name, final_width * final_height / 1_000_000.0),
]
return {
"ui": {"text": ui_text},
"result": (out_image, latent, empty_latent, final_width, final_height),
}
@staticmethod
def _empty_latent(latent_type, width, height, batch_size):
device = comfy.model_management.intermediate_device()
if latent_type == LATENT_FLUX2:
samples = torch.zeros([batch_size, 128, height // 16, width // 16], device=device)
return {"samples": samples}
kwargs = {}
# intermediate_dtype() does not exist in older ComfyUI versions
if hasattr(comfy.model_management, "intermediate_dtype"):
kwargs["dtype"] = comfy.model_management.intermediate_dtype()
samples = torch.zeros([batch_size, 4, height // 8, width // 8], device=device, **kwargs)
return {"samples": samples, "downscale_ratio_spacial": 8}
NODE_CLASS_MAPPINGS = {
"ResizeToMultiple": ResizeToMultiple,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ResizeToMultiple": "Resize to Multiple",
}