From 7e60424416ef8f59a6204b1f34d2af42a2bc53b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Nov 2025 13:06:46 +0000 Subject: [PATCH] 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 --- nodes.py | 296 +++++++++++++++++++++++++++++++++++++++++++------ pyproject.toml | 2 +- 2 files changed, 261 insertions(+), 37 deletions(-) diff --git a/nodes.py b/nodes.py index a66cd6a..f211f46 100644 --- a/nodes.py +++ b/nodes.py @@ -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 ) diff --git a/pyproject.toml b/pyproject.toml index b6535db..52eebcb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"]