Nearest-multiple rounding could drop a dimension below min_res in min-res mode (e.g. min_res=1100, multiple_of=256 -> 1024); bump such dimensions to the next multiple instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
461 lines
19 KiB
Python
461 lines
19 KiB
Python
import logging
|
|
import math
|
|
from enum import Enum
|
|
from typing import Tuple
|
|
|
|
import torch
|
|
import torch.nn.functional as F
|
|
from comfy_api.latest import ComfyExtension, io
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# (str, Enum) instead of StrEnum keeps the pack importable on Python 3.10,
|
|
# which ComfyUI still supports.
|
|
class ConstraintMode(str, Enum):
|
|
"""Constraint mode for handling extreme aspect ratios"""
|
|
MIN_RES = "Prioritize Min Resolution"
|
|
MAX_RES_STRICT = "Prioritize Max Resolution (Strict)"
|
|
|
|
|
|
class CropPosition(str, Enum):
|
|
"""Position for cropping when aspect ratios don't match"""
|
|
CENTER = "center"
|
|
TOP = "top"
|
|
BOTTOM = "bottom"
|
|
LEFT = "left"
|
|
RIGHT = "right"
|
|
|
|
|
|
class ResizeMethod(str, Enum):
|
|
"""Interpolation method used for resizing"""
|
|
BILINEAR = "bilinear"
|
|
BICUBIC = "bicubic"
|
|
LANCZOS = "lanczos"
|
|
NEAREST_EXACT = "nearest-exact"
|
|
AREA = "area"
|
|
|
|
|
|
class ConstrainResolution(io.ComfyNode):
|
|
"""
|
|
A ComfyUI node that analyzes and resizes images to optimal dimensions
|
|
while preserving or constraining aspect ratio based on resolution limits.
|
|
"""
|
|
|
|
@classmethod
|
|
def define_schema(cls) -> io.Schema:
|
|
"""Define the node schema with inputs and outputs"""
|
|
return io.Schema(
|
|
node_id="ConstrainResolution",
|
|
display_name="Constrain Resolution",
|
|
category="image/resolution",
|
|
description=(
|
|
"Intelligently resizes images to fit within resolution constraints while preserving aspect ratio. "
|
|
"Perfect for preparing images for AI models with specific dimension requirements. "
|
|
"\n\n"
|
|
"💡 USAGE TIPS:\n"
|
|
"• Use 'Prioritize Min Resolution' to ensure images are never too small (may exceed max on one dimension)\n"
|
|
"• Use 'Prioritize Max Resolution (Strict)' for hard VRAM limits (may go below min on one dimension)\n"
|
|
"• Set 'Multiple Of' to 2 for most models, or higher values (8, 16, 32, 64) for optimal performance\n"
|
|
"• 'lanczos' or 'bicubic' resize methods give the sharpest results; 'bilinear' is the fastest\n"
|
|
"• 'Crop as Required' is enabled by default for immediate compatibility with strict dimension requirements\n"
|
|
"• The node outputs both the resized image and the original for workflow flexibility"
|
|
),
|
|
inputs=[
|
|
# Image input
|
|
io.Image.Input(
|
|
"image",
|
|
tooltip="Input image to analyze and resize"
|
|
),
|
|
|
|
# Resolution constraints
|
|
io.Int.Input(
|
|
"min_res",
|
|
default=704,
|
|
min=1,
|
|
max=65536,
|
|
tooltip="Minimum resolution in pixels for width and height. Images smaller than this will be upscaled."
|
|
),
|
|
io.Int.Input(
|
|
"max_res",
|
|
default=1280,
|
|
min=1,
|
|
max=65536,
|
|
tooltip="Maximum resolution in pixels for width and height. Images larger than this will be downscaled."
|
|
),
|
|
io.Int.Input(
|
|
"multiple_of",
|
|
default=2,
|
|
min=1,
|
|
max=256,
|
|
tooltip=(
|
|
"Ensures output dimensions are multiples of this number. "
|
|
"Common values: 2 (most models), 8, 16, 32, or 64 (optimal performance). "
|
|
"Set to 1 to disable rounding."
|
|
)
|
|
),
|
|
io.Combo.Input(
|
|
"resize_method",
|
|
options=[e.value for e in ResizeMethod],
|
|
default=ResizeMethod.LANCZOS.value,
|
|
tooltip=(
|
|
"Interpolation method used when resizing.\n"
|
|
"• lanczos: Sharpest results, best overall quality (default)\n"
|
|
"• bicubic: High quality, slightly softer than lanczos\n"
|
|
"• bilinear: Fast, slightly soft\n"
|
|
"• nearest-exact: No interpolation — for pixel art or masks\n"
|
|
"• area: Good for large downscales"
|
|
)
|
|
),
|
|
|
|
# Constraint behavior
|
|
io.Combo.Input(
|
|
"constraint_mode",
|
|
options=[e.value for e in ConstraintMode],
|
|
default=ConstraintMode.MIN_RES.value,
|
|
tooltip=(
|
|
"How to handle conflicts when extreme aspect ratios make it impossible to satisfy both min and max.\n"
|
|
"• Prioritize Min Resolution: Ensures neither dimension falls below min_res (may exceed max_res)\n"
|
|
"• Prioritize Max Resolution (Strict): Strictly enforces max_res limit (may go below min_res)"
|
|
)
|
|
),
|
|
|
|
# Crop options
|
|
io.Boolean.Input(
|
|
"crop_as_required",
|
|
default=True,
|
|
tooltip=(
|
|
"Enable cropping to achieve exact target dimensions when rounding causes aspect ratio changes. "
|
|
"Disable if preserving the entire image is more important than exact dimensions."
|
|
)
|
|
),
|
|
io.Combo.Input(
|
|
"crop_position",
|
|
options=[e.value for e in CropPosition],
|
|
default=CropPosition.CENTER.value,
|
|
tooltip=(
|
|
"Where to crop from when 'Crop as Required' is enabled.\n"
|
|
"• center: Crop equally from all sides\n"
|
|
"• top: Keep top portion, crop from bottom\n"
|
|
"• bottom: Keep bottom portion, crop from top\n"
|
|
"• left: Keep left portion, crop from right\n"
|
|
"• right: Keep right portion, crop from left"
|
|
)
|
|
),
|
|
],
|
|
outputs=[
|
|
io.Image.Output(
|
|
display_name="resized_image",
|
|
tooltip="Image resized to the constrained dimensions"
|
|
),
|
|
io.Image.Output(
|
|
display_name="original_image",
|
|
tooltip="Original image passed through unchanged for workflow flexibility"
|
|
),
|
|
io.Int.Output(
|
|
display_name="width",
|
|
tooltip="Final width after constraints and rounding"
|
|
),
|
|
io.Int.Output(
|
|
display_name="height",
|
|
tooltip="Final height after constraints and rounding"
|
|
),
|
|
io.Float.Output(
|
|
display_name="final_aspect_ratio",
|
|
tooltip="Aspect ratio of the output image (width/height)"
|
|
),
|
|
io.Float.Output(
|
|
display_name="original_aspect_ratio",
|
|
tooltip="Aspect ratio of the input image for comparison"
|
|
),
|
|
],
|
|
is_output_node=False,
|
|
is_deprecated=False,
|
|
is_experimental=False
|
|
)
|
|
|
|
@classmethod
|
|
def validate_inputs(cls, min_res, max_res, multiple_of, **kwargs):
|
|
"""Validate input parameters"""
|
|
if max_res < min_res:
|
|
return f"max_res ({max_res}) must be greater than or equal to min_res ({min_res})"
|
|
|
|
if multiple_of < 1:
|
|
return f"multiple_of must be at least 1, got {multiple_of}"
|
|
|
|
if min_res < 1:
|
|
return f"min_res must be at least 1, got {min_res}"
|
|
|
|
return True
|
|
|
|
@staticmethod
|
|
def calculate_aspect_ratio(width: int, height: int) -> float:
|
|
"""Calculate aspect ratio from width and height"""
|
|
if height == 0:
|
|
return 0.0
|
|
return round(width / height, 4)
|
|
|
|
@staticmethod
|
|
def round_to_multiple(value: int, multiple: int) -> int:
|
|
"""Round a value to the nearest multiple of the specified number"""
|
|
if multiple == 1:
|
|
return value
|
|
return max(multiple, multiple * round(value / multiple))
|
|
|
|
@staticmethod
|
|
def calculate_optimal_dimensions(
|
|
width: int,
|
|
height: int,
|
|
min_res: int,
|
|
max_res: int,
|
|
multiple_of: int,
|
|
constraint_mode: str
|
|
) -> Tuple[int, int]:
|
|
"""Calculate optimal dimensions based on constraints"""
|
|
if height == 0 or width == 0:
|
|
return 0, 0
|
|
|
|
aspect_ratio = width / height
|
|
|
|
# 1. Initial scaling to fit the longest side to max_res
|
|
if aspect_ratio >= 1: # Landscape or square
|
|
new_width = max_res
|
|
new_height = new_width / aspect_ratio
|
|
else: # Portrait
|
|
new_height = max_res
|
|
new_width = new_height * aspect_ratio
|
|
|
|
# 2. Apply constraint logic based on user's choice
|
|
if constraint_mode == ConstraintMode.MIN_RES.value:
|
|
# If a dimension is below min_res, scale the entire image up to meet it
|
|
scale_factor = 1.0
|
|
if new_width < min_res:
|
|
scale_factor = max(scale_factor, min_res / new_width)
|
|
if new_height < min_res:
|
|
scale_factor = max(scale_factor, min_res / new_height)
|
|
|
|
new_width *= scale_factor
|
|
new_height *= scale_factor
|
|
|
|
# If mode is "Prioritize Max Resolution (Strict)", we do nothing here.
|
|
# The initial scaling keeps us within max_res before rounding, but rounding
|
|
# can push a dimension above max_res — the clamp below handles that.
|
|
|
|
# 3. Round final dimensions to the nearest multiple
|
|
final_width = ConstrainResolution.round_to_multiple(int(new_width), multiple_of)
|
|
final_height = ConstrainResolution.round_to_multiple(int(new_height), multiple_of)
|
|
|
|
# Nearest-multiple rounding can drop a dimension back below min_res
|
|
# (e.g. min_res=1100, multiple_of=256 -> 1024). In min-res mode, bump
|
|
# such a dimension up to the next multiple so the guarantee holds.
|
|
if constraint_mode == ConstraintMode.MIN_RES.value:
|
|
if final_width < min_res:
|
|
final_width = math.ceil(min_res / multiple_of) * multiple_of
|
|
if final_height < min_res:
|
|
final_height = math.ceil(min_res / multiple_of) * multiple_of
|
|
|
|
# 4. In strict mode, rounding can exceed max_res (e.g. round_to_multiple(2160, 32) = 2176).
|
|
# Clamp to the largest valid multiple of multiple_of that is <= max_res.
|
|
if constraint_mode == ConstraintMode.MAX_RES_STRICT.value:
|
|
max_allowed = (max_res // multiple_of) * multiple_of
|
|
final_width = min(final_width, max_allowed)
|
|
final_height = min(final_height, max_allowed)
|
|
|
|
return final_width, final_height
|
|
|
|
@staticmethod
|
|
def resize_image(
|
|
image: torch.Tensor,
|
|
target_width: int,
|
|
target_height: int,
|
|
method: str = ResizeMethod.BILINEAR.value
|
|
) -> torch.Tensor:
|
|
"""
|
|
Resize image tensor to target dimensions.
|
|
|
|
Args:
|
|
image: Input tensor in format [batch, height, width, channels]
|
|
target_width: Target width in pixels
|
|
target_height: Target height in pixels
|
|
method: Interpolation method (see ResizeMethod)
|
|
|
|
Returns:
|
|
Resized tensor in same format as input
|
|
"""
|
|
# ComfyUI images are [batch, height, width, channels];
|
|
# resizing needs [batch, channels, height, width]
|
|
image_permuted = image.permute(0, 3, 1, 2)
|
|
|
|
try:
|
|
# Prefer ComfyUI's resizer (adds lanczos, matches core node behavior)
|
|
from comfy.utils import common_upscale
|
|
resized = common_upscale(image_permuted, target_width, target_height, method, "disabled")
|
|
except ImportError:
|
|
# Outside ComfyUI (e.g. tests): torch has no lanczos, use bicubic
|
|
if method == ResizeMethod.LANCZOS.value:
|
|
method = ResizeMethod.BICUBIC.value
|
|
kwargs = {"align_corners": False} if method in ("bilinear", "bicubic") else {}
|
|
resized = F.interpolate(
|
|
image_permuted,
|
|
size=(target_height, target_width),
|
|
mode=method,
|
|
**kwargs
|
|
)
|
|
|
|
# bicubic/lanczos kernels can overshoot the valid [0, 1] range
|
|
if method in (ResizeMethod.BICUBIC.value, ResizeMethod.LANCZOS.value):
|
|
resized = resized.clamp(0.0, 1.0)
|
|
|
|
return resized.permute(0, 2, 3, 1)
|
|
|
|
@staticmethod
|
|
def crop_image(
|
|
image: torch.Tensor,
|
|
target_width: int,
|
|
target_height: int,
|
|
position: str
|
|
) -> torch.Tensor:
|
|
"""
|
|
Crop image to exact target dimensions from specified position.
|
|
|
|
Args:
|
|
image: Input tensor in format [batch, height, width, channels]
|
|
target_width: Target width in pixels
|
|
target_height: Target height in pixels
|
|
position: One of "center", "top", "bottom", "left", "right"
|
|
|
|
Returns:
|
|
Cropped tensor
|
|
"""
|
|
batch, height, width, channels = image.shape
|
|
|
|
# Calculate crop amounts
|
|
width_diff = width - target_width
|
|
height_diff = height - target_height
|
|
|
|
# No crop needed if dimensions match
|
|
if width_diff == 0 and height_diff == 0:
|
|
return image
|
|
|
|
# Calculate crop coordinates based on position
|
|
if position == CropPosition.CENTER.value:
|
|
left = width_diff // 2
|
|
top = height_diff // 2
|
|
elif position == CropPosition.TOP.value:
|
|
left = width_diff // 2
|
|
top = 0
|
|
elif position == CropPosition.BOTTOM.value:
|
|
left = width_diff // 2
|
|
top = height_diff
|
|
elif position == CropPosition.LEFT.value:
|
|
left = 0
|
|
top = height_diff // 2
|
|
elif position == CropPosition.RIGHT.value:
|
|
left = width_diff
|
|
top = height_diff // 2
|
|
else:
|
|
# Default to center
|
|
left = width_diff // 2
|
|
top = height_diff // 2
|
|
|
|
# Ensure we don't go out of bounds
|
|
left = max(0, min(left, width_diff))
|
|
top = max(0, min(top, height_diff))
|
|
|
|
# Crop the image
|
|
right = left + target_width
|
|
bottom = top + target_height
|
|
|
|
cropped = image[:, top:bottom, left:right, :]
|
|
|
|
return cropped
|
|
|
|
@classmethod
|
|
def execute(
|
|
cls,
|
|
image,
|
|
min_res,
|
|
max_res,
|
|
multiple_of,
|
|
resize_method,
|
|
constraint_mode,
|
|
crop_as_required,
|
|
crop_position
|
|
) -> io.NodeOutput:
|
|
"""Execute the node logic"""
|
|
# Get original dimensions
|
|
batch, height, width, channels = image.shape
|
|
|
|
original_aspect_ratio = cls.calculate_aspect_ratio(width, height)
|
|
|
|
# Calculate optimal dimensions
|
|
target_width, target_height = cls.calculate_optimal_dimensions(
|
|
width, height, min_res, max_res, multiple_of, constraint_mode
|
|
)
|
|
|
|
# Resize image to target dimensions
|
|
resized_image = cls.resize_image(image, target_width, target_height, resize_method)
|
|
|
|
# Calculate aspect ratio after resize
|
|
final_aspect_ratio = cls.calculate_aspect_ratio(target_width, target_height)
|
|
|
|
# Check if cropping is needed and enabled
|
|
if crop_as_required and original_aspect_ratio > 0:
|
|
# Determine if we need to crop
|
|
# After rounding to multiples, the aspect ratio might have changed slightly
|
|
# We'll resize to maintain aspect ratio on the larger dimension, then crop
|
|
|
|
aspect_ratio_deviation = abs((final_aspect_ratio - original_aspect_ratio) / original_aspect_ratio * 100)
|
|
|
|
if aspect_ratio_deviation > 0.1: # If more than 0.1% deviation
|
|
# Resize to preserve aspect ratio, making one dimension larger than target, then crop
|
|
# ceil + max keep the intermediate dimension at or above the target,
|
|
# so the crop below never has to grow the image
|
|
if width / height > target_width / target_height:
|
|
# Original is wider (more landscape), resize based on height and crop width
|
|
# This ensures height matches target, width will be larger and cropped
|
|
intermediate_width = max(target_width, math.ceil(target_height * width / height))
|
|
resized_image = cls.resize_image(image, intermediate_width, target_height, resize_method)
|
|
else:
|
|
# Original is taller (more portrait), resize based on width and crop height
|
|
# This ensures width matches target, height will be larger and cropped
|
|
intermediate_height = max(target_height, math.ceil(target_width * height / width))
|
|
resized_image = cls.resize_image(image, target_width, intermediate_height, resize_method)
|
|
|
|
# Crop to exact target dimensions
|
|
resized_image = cls.crop_image(resized_image, target_width, target_height, crop_position)
|
|
|
|
logger.debug("Image cropped to achieve exact dimensions %dx%d", target_width, target_height)
|
|
|
|
# Log aspect ratio deviation warning if significant
|
|
if original_aspect_ratio > 0 and not crop_as_required:
|
|
aspect_ratio_deviation = abs((final_aspect_ratio - original_aspect_ratio) / original_aspect_ratio * 100)
|
|
if aspect_ratio_deviation > 1: # 1% tolerance for rounding
|
|
logger.info(
|
|
"Aspect ratio changed by %.2f%% due to rounding. "
|
|
"Enable 'Crop as Required' to preserve exact aspect ratio.",
|
|
aspect_ratio_deviation
|
|
)
|
|
|
|
return io.NodeOutput(
|
|
resized_image, # resized image
|
|
image, # original image passthrough
|
|
target_width, # final width
|
|
target_height, # final height
|
|
final_aspect_ratio, # final aspect ratio
|
|
original_aspect_ratio # original aspect ratio
|
|
)
|
|
|
|
|
|
class ConstrainResolutionExtension(ComfyExtension):
|
|
"""Extension class for registering nodes"""
|
|
|
|
async def get_node_list(self) -> list[type[io.ComfyNode]]:
|
|
"""Return list of nodes provided by this extension"""
|
|
return [ConstrainResolution]
|
|
|
|
|
|
async def comfy_entrypoint() -> ComfyExtension:
|
|
"""Entry point for ComfyUI v3"""
|
|
return ConstrainResolutionExtension()
|