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:
@@ -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
@@ -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"]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user