Add image resizing with intelligent cropping support

Features:
- Actual image resizing using torch bilinear interpolation
- Optional cropping to preserve aspect ratio when rounding causes deviations
- Configurable crop position (center, top, bottom, left, right)
- Resized image now output first, followed by original passthrough
- Input validation with detailed error messages
- Comprehensive tooltips on all inputs and outputs
- Reorganized UI with logical grouping of parameters
- Enhanced description with usage tips
- Max value constraints added to prevent unreasonable inputs

Technical improvements:
- Use torch.nn.functional.interpolate for high-quality resizing
- Smart crop algorithm that resizes first then crops to preserve quality
- Proper tensor shape handling [batch, height, width, channels]
- Aspect ratio deviation warnings when crop is disabled
- min/max bounds on all numeric inputs

Breaking changes:
- Output order changed: resized_image is now first output
- Output names simplified (width/height instead of constrained_width/constrained_height)
- execute() signature expanded with crop parameters

Bump version to 2.1.0
This commit is contained in:
Claude
2025-11-08 13:06:46 +00:00
parent de432c6fbe
commit 7e60424416
2 changed files with 261 additions and 37 deletions
+260 -36
View File
@@ -1,17 +1,23 @@
import numpy as np
import torch
import torch.nn.functional as F
from typing import Tuple
from comfy_api.latest import ComfyExtension, io
class ConstraintModeEnum(io.ComboInput):
"""Combo input for constraint mode selection"""
"""Constraint mode for handling extreme aspect ratios"""
OPTIONS = ["Prioritize Min Resolution", "Prioritize Max Resolution (Strict)"]
class CropPositionEnum(io.ComboInput):
"""Position for cropping when aspect ratios don't match"""
OPTIONS = ["center", "top", "bottom", "left", "right"]
class ConstrainResolution(io.ComfyNode):
"""
A ComfyUI node that analyzes images and suggests optimal dimensions
while preserving aspect ratio.
A ComfyUI node that analyzes and resizes images to optimal dimensions
while preserving or constraining aspect ratio based on resolution limits.
"""
@classmethod
@@ -21,48 +27,129 @@ class ConstrainResolution(io.ComfyNode):
node_id="ConstrainResolution",
display_name="Constrain Resolution",
category="image/resolution",
description="Analyzes images and suggests optimal dimensions while preserving aspect ratio",
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 8 for SDXL, 16 for some models, or 64 for optimal performance\n"
"• Enable 'Crop as Required' when you need exact dimensions and aspect ratio preservation isn't critical\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 for resolution constraints"
),
ConstraintModeEnum.Input(
"constraint_mode",
default="Prioritize Min Resolution",
tooltip="Choose how to handle conflicting constraints for extreme aspect ratios"
tooltip="Input image to analyze and resize"
),
# Resolution constraints
io.Int.Input(
"min_res",
default=704,
min=1,
tooltip="Minimum resolution in pixels for both width and height"
max=16384,
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,
tooltip="Maximum resolution in pixels for both width and height"
max=16384,
tooltip="Maximum resolution in pixels for width and height. Images larger than this will be downscaled."
),
io.Int.Input(
"multiple_of",
default=8,
min=1,
tooltip="Ensure dimensions are multiples of this number (e.g., 8 for SDXL)"
max=256,
tooltip=(
"Ensures output dimensions are multiples of this number. "
"Common values: 8 (SDXL), 16 (some models), 32 or 64 (optimal performance). "
"Set to 1 to disable rounding."
)
),
# Constraint behavior
ConstraintModeEnum.Input(
"constraint_mode",
default="Prioritize Min Resolution",
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=False,
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."
)
),
CropPositionEnum.Input(
"crop_position",
default="center",
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("image_passthrough"),
io.Int.Output("constrained_width"),
io.Int.Output("constrained_height"),
io.Float.Output("constrained_aspect_ratio"),
io.Float.Output("original_aspect_ratio"),
io.Image.Output(
"resized_image",
tooltip="Image resized to the constrained dimensions"
),
io.Image.Output(
"original_image",
tooltip="Original image passed through unchanged for workflow flexibility"
),
io.Int.Output(
"width",
tooltip="Final width after constraints and rounding"
),
io.Int.Output(
"height",
tooltip="Final height after constraints and rounding"
),
io.Float.Output(
"final_aspect_ratio",
tooltip="Aspect ratio of the output image (width/height)"
),
io.Float.Output(
"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"""
@@ -73,7 +160,9 @@ class ConstrainResolution(io.ComfyNode):
@staticmethod
def round_to_multiple(value: int, multiple: int) -> int:
"""Round a value to the nearest multiple of the specified number"""
return multiple * round(value / multiple)
if multiple == 1:
return value
return max(multiple, multiple * round(value / multiple))
@staticmethod
def calculate_optimal_dimensions(
@@ -120,34 +209,169 @@ class ConstrainResolution(io.ComfyNode):
return final_width, final_height
@classmethod
def execute(cls, image, constraint_mode, min_res, max_res, multiple_of) -> io.NodeOutput:
"""Execute the node logic"""
if max_res < min_res:
raise ValueError(f"max_res ({max_res}) must be greater than or equal to min_res ({min_res})")
@staticmethod
def resize_image(image: torch.Tensor, target_width: int, target_height: int) -> torch.Tensor:
"""
Resize image tensor to target dimensions using bilinear interpolation.
height = image.shape[1]
width = image.shape[2]
Args:
image: Input tensor in format [batch, height, width, channels]
target_width: Target width in pixels
target_height: Target height in pixels
Returns:
Resized tensor in same format as input
"""
# ComfyUI images are [batch, height, width, channels]
# torch needs [batch, channels, height, width] for interpolate
batch, height, width, channels = image.shape
# Permute to [batch, channels, height, width]
image_permuted = image.permute(0, 3, 1, 2)
# Resize using bilinear interpolation
resized = F.interpolate(
image_permuted,
size=(target_height, target_width),
mode='bilinear',
align_corners=False
)
# Permute back to [batch, height, width, channels]
resized = resized.permute(0, 2, 3, 1)
return resized
@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 == "center":
left = width_diff // 2
top = height_diff // 2
elif position == "top":
left = width_diff // 2
top = 0
elif position == "bottom":
left = width_diff // 2
top = height_diff
elif position == "left":
left = 0
top = height_diff // 2
elif position == "right":
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,
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)
suggested_width, suggested_height = cls.calculate_optimal_dimensions(
# Calculate optimal dimensions
target_width, target_height = cls.calculate_optimal_dimensions(
width, height, min_res, max_res, multiple_of, constraint_mode
)
final_aspect_ratio = cls.calculate_aspect_ratio(suggested_width, suggested_height)
# Resize image to target dimensions
resized_image = cls.resize_image(image, target_width, target_height)
if original_aspect_ratio > 0:
# 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 the dimension that needs to be larger, then crop
if width / height > target_width / target_height:
# Original is wider, resize based on width and crop height
intermediate_height = int(target_width / (width / height))
resized_image = cls.resize_image(image, target_width, intermediate_height)
else:
# Original is taller, resize based on height and crop width
intermediate_width = int(target_height * (width / height))
resized_image = cls.resize_image(image, intermediate_width, target_height)
# Crop to exact target dimensions
resized_image = cls.crop_image(resized_image, target_width, target_height, crop_position)
print(f"Image cropped from center to achieve exact dimensions {target_width}x{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
print(f"Warning: Aspect ratio deviation of {aspect_ratio_deviation:.2f}% detected due to rounding.")
print(
f"ℹ️ Aspect ratio changed by {aspect_ratio_deviation:.2f}% due to rounding. "
f"Enable 'Crop as Required' to preserve exact aspect ratio."
)
return io.NodeOutput(
image,
suggested_width,
suggested_height,
final_aspect_ratio,
original_aspect_ratio
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
)
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "constrainresolution"
description = "Given min/max resolution constraints, this automatically suggests optimal dimensions while preserving aspect ratio. Ideal for Image to Image (I2I) and Image to Video (I2V) workflows!"
version = "2.0.0"
version = "2.1.0"
license = {file = "LICENSE"}
dependencies = ["numpy", "comfy_api"]