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>
176 lines
7.4 KiB
Python
176 lines
7.4 KiB
Python
"""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",
|
||
}
|