From df2f96303a244f39126fddd434b575beb8331e5f Mon Sep 17 00:00:00 2001 From: EnragedAntelope Date: Thu, 16 Jul 2026 14:15:28 -0400 Subject: [PATCH 1/2] v2.3.0: resize method selection, crop fix, dependency cleanup - Add resize_method input (lanczos default, bicubic, bilinear, nearest-exact, area) backed by comfy.utils.common_upscale, with F.interpolate fallback outside ComfyUI; clamp bicubic/lanczos overshoot to [0, 1] - Fix rare off-by-one where the crop-path intermediate resize could truncate below the target dimension, yielding output 1px smaller than the reported width/height - Replace StrEnum with (str, Enum) so the pack imports on Python 3.10 - Remove unused numpy dependency from requirements.txt and pyproject - Switch print() to logging (crop notices at debug level) - Bump version to 2.3.0, sync README (inputs, technical details, version history), add requires-python - Stop tracking .claude/settings.local.json; expand tests to 36 covering execute() output shapes, all resize methods, crop positions Co-Authored-By: Claude Fable 5 --- .claude/settings.local.json | 7 --- .gitignore | 3 + README.md | 15 ++++- nodes.py | 106 ++++++++++++++++++++++++++---------- pyproject.toml | 5 +- requirements.txt | 2 +- tests/test_nodes.py | 65 +++++++++++++++++++++- 7 files changed, 159 insertions(+), 44 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 4da9ded..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(python -m pytest tests/test_nodes.py -v)" - ] - } -} diff --git a/.gitignore b/.gitignore index c6f9226..7f19cb9 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ env/ # OS .DS_Store Thumbs.db + +# Claude Code local settings +.claude/settings.local.json diff --git a/README.md b/README.md index 8bf3d10..4e00cd1 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A [ComfyUI](https://github.com/comfyanonymous/ComfyUI) node that intelligently r ## Features -- **Intelligent Image Resizing**: Automatically resizes images using high-quality bilinear interpolation +- **Intelligent Image Resizing**: Automatically resizes images with your choice of interpolation (lanczos, bicubic, bilinear, nearest-exact, area) - **Aspect Ratio Preservation**: Maintains original aspect ratio through smart cropping when needed - **Flexible Constraint Modes**: Choose between prioritizing minimum resolution or strictly enforcing maximum limits - **Multiple Alignment**: Ensures dimensions are multiples of specified values (e.g., 2, 8, 16, 32, 64 for performance) @@ -39,6 +39,12 @@ This node handles all these requirements intelligently, ensuring your images are - `8` or `16` - Some models with stricter alignment requirements - `32` or `64` - Optimal for certain architectures and performance - `1` - Disable rounding (use exact calculated dimensions) +- **resize_method** (default: "lanczos"): Interpolation method used when resizing. + - `lanczos` - Sharpest results, best overall quality (default) + - `bicubic` - High quality, slightly softer than lanczos + - `bilinear` - Fast, slightly soft + - `nearest-exact` - No interpolation; best for pixel art and masks + - `area` - Good for large downscales ### Constraint Behavior - **constraint_mode** (default: "Prioritize Min Resolution"): How to handle conflicts when extreme aspect ratios make it impossible to satisfy both min and max constraints. @@ -126,7 +132,8 @@ Intelligently crops portraits to keep faces (typically in upper portion). ## Technical Details ### Resizing Algorithm -- Uses PyTorch's `F.interpolate` with bilinear interpolation and `align_corners=False` +- Uses ComfyUI's own resizer (`comfy.utils.common_upscale`) so results match core resize nodes, with selectable interpolation (lanczos by default) +- Bicubic/lanczos output is clamped to the valid [0, 1] range to prevent kernel overshoot artifacts - Maintains proper tensor shape handling: `[batch, height, width, channels]` - High-quality resizing suitable for AI model inputs @@ -160,7 +167,7 @@ The node validates: ``` 3. Restart ComfyUI -The node will automatically install its dependencies (`numpy`). +The node has no dependencies beyond ComfyUI's own environment. Requires Python 3.10+. ## ComfyUI v3 Compatibility @@ -174,6 +181,8 @@ This node is built using the **ComfyUI v3 specification** with the following mod ## Version History +- **v2.3.0**: Added `resize_method` selection (lanczos default, bicubic, bilinear, nearest-exact, area) using ComfyUI's core resizer; fixed a rare off-by-one where the cropped output could be 1px smaller than the reported dimensions; removed unused numpy dependency; Python 3.10 compatibility; switched console output to proper logging +- **v2.2**: Fixed strict max-resolution mode exceeding `max_res` after rounding to multiples; added test suite - **v2.1.0**: Added image resizing, intelligent cropping, crop position control, comprehensive tooltips, input validation, and 65k resolution support - **v2.0.0**: Migrated to ComfyUI v3 specification - **v1.1**: Initial release with resolution analysis and constraint calculation diff --git a/nodes.py b/nodes.py index 05c85d1..c53bcdf 100644 --- a/nodes.py +++ b/nodes.py @@ -1,17 +1,24 @@ +import logging +import math +from enum import Enum +from typing import Tuple + import torch import torch.nn.functional as F -from typing import Tuple from comfy_api.latest import ComfyExtension, io -from enum import StrEnum + +logger = logging.getLogger(__name__) -class ConstraintMode(StrEnum): +# (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(StrEnum): +class CropPosition(str, Enum): """Position for cropping when aspect ratios don't match""" CENTER = "center" TOP = "top" @@ -20,6 +27,15 @@ class CropPosition(StrEnum): 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 @@ -41,6 +57,7 @@ class ConstrainResolution(io.ComfyNode): "• 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" ), @@ -77,6 +94,19 @@ class ConstrainResolution(io.ComfyNode): "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( @@ -225,37 +255,49 @@ class ConstrainResolution(io.ComfyNode): return final_width, final_height @staticmethod - def resize_image(image: torch.Tensor, target_width: int, target_height: int) -> torch.Tensor: + 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 using bilinear interpolation. + 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] - # torch needs [batch, channels, height, width] for interpolate - batch, height, width, channels = image.shape - - # Permute to [batch, channels, height, width] + # ComfyUI images are [batch, height, width, channels]; + # resizing needs [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 - ) + 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 + ) - # Permute back to [batch, height, width, channels] - resized = resized.permute(0, 2, 3, 1) + # 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 + return resized.permute(0, 2, 3, 1) @staticmethod def crop_image( @@ -326,6 +368,7 @@ class ConstrainResolution(io.ComfyNode): min_res, max_res, multiple_of, + resize_method, constraint_mode, crop_as_required, crop_position @@ -342,7 +385,7 @@ class ConstrainResolution(io.ComfyNode): ) # Resize image to target dimensions - resized_image = cls.resize_image(image, target_width, target_height) + 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) @@ -357,29 +400,32 @@ class ConstrainResolution(io.ComfyNode): 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 = int(target_height * (width / height)) - resized_image = cls.resize_image(image, intermediate_width, target_height) + 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 = int(target_width / (width / height)) - resized_image = cls.resize_image(image, target_width, intermediate_height) + 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) - print(f"Image cropped to achieve exact dimensions {target_width}x{target_height}") + 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 - print( - f"ℹ️ Aspect ratio changed by {aspect_ratio_deviation:.2f}% due to rounding. " - f"Enable 'Crop as Required' to preserve exact aspect ratio." + 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( diff --git a/pyproject.toml b/pyproject.toml index d7e0d53..4f8af4b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,10 @@ [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.2" +version = "2.3.0" license = {file = "LICENSE"} -dependencies = ["numpy"] +requires-python = ">=3.10" +dependencies = [] [project.urls] Repository = "https://github.com/EnragedAntelope/ComfyUI-ConstrainResolution" diff --git a/requirements.txt b/requirements.txt index 24ce15a..5ce24e0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -numpy +# No dependencies beyond ComfyUI's own environment (torch is provided by ComfyUI). diff --git a/tests/test_nodes.py b/tests/test_nodes.py index 6dfbf1c..734a420 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -1,5 +1,8 @@ +import sys + import pytest -from nodes import ConstrainResolution, ConstraintMode +import torch +from nodes import ConstrainResolution, ConstraintMode, ResizeMethod STRICT = ConstraintMode.MAX_RES_STRICT.value MIN = ConstraintMode.MIN_RES.value @@ -106,3 +109,63 @@ class TestRoundToMultiple: def test_minimum_floor(self): assert ConstrainResolution.round_to_multiple(1, 32) == 32 + + +def run_node(image, **overrides): + """Call execute() and return the positional outputs captured by the mocked io.NodeOutput.""" + mock_io = sys.modules['comfy_api.latest'].io + params = dict( + min_res=704, + max_res=1280, + multiple_of=2, + resize_method=ResizeMethod.BILINEAR.value, + constraint_mode=MIN, + crop_as_required=True, + crop_position="center", + ) + params.update(overrides) + mock_io.NodeOutput.reset_mock() + ConstrainResolution.execute(image=image, **params) + return mock_io.NodeOutput.call_args.args + + +class TestExecuteShapes: + @pytest.mark.parametrize("size", [ + (500, 333), (333, 500), (1000, 1000), (1920, 1080), + (101, 997), (2543, 1071), (1001, 1000), (703, 704), + ]) + def test_output_shape_matches_reported_dims(self, size): + """The resized tensor must exactly match the reported width/height outputs.""" + w, h = size + image = torch.rand(1, h, w, 3) + resized, original, out_w, out_h, _, _ = run_node(image, multiple_of=32) + assert resized.shape == (1, out_h, out_w, 3) + assert original.shape == image.shape + + def test_no_crop_shape_still_matches(self): + image = torch.rand(1, 333, 500, 3) + resized, _, out_w, out_h, _, _ = run_node(image, crop_as_required=False, multiple_of=32) + assert resized.shape == (1, out_h, out_w, 3) + + +class TestResizeMethods: + @pytest.mark.parametrize("method", [e.value for e in ResizeMethod]) + def test_all_methods_produce_target_shape(self, method): + image = torch.rand(2, 100, 150, 3) + out = ConstrainResolution.resize_image(image, 300, 200, method) + assert out.shape == (2, 200, 300, 3) + + @pytest.mark.parametrize("method", ["bicubic", "lanczos"]) + def test_overshoot_is_clamped(self, method): + image = torch.rand(1, 64, 64, 3) + out = ConstrainResolution.resize_image(image, 128, 128, method) + assert out.min() >= 0.0 + assert out.max() <= 1.0 + + +class TestCropImage: + @pytest.mark.parametrize("position", ["center", "top", "bottom", "left", "right"]) + def test_crop_positions_yield_exact_dims(self, position): + image = torch.rand(1, 120, 200, 3) + out = ConstrainResolution.crop_image(image, 100, 100, position) + assert out.shape == (1, 100, 100, 3) From 499e5a0078e975b171dfd235b2672bceeb48be6a Mon Sep 17 00:00:00 2001 From: EnragedAntelope Date: Thu, 16 Jul 2026 17:25:38 -0400 Subject: [PATCH 2/2] Enforce min_res guarantee after nearest-multiple rounding 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 --- nodes.py | 9 +++++++++ tests/test_nodes.py | 11 +++++++++++ 2 files changed, 20 insertions(+) diff --git a/nodes.py b/nodes.py index c53bcdf..d9dd8e1 100644 --- a/nodes.py +++ b/nodes.py @@ -245,6 +245,15 @@ class ConstrainResolution(io.ComfyNode): 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: diff --git a/tests/test_nodes.py b/tests/test_nodes.py index 734a420..6ae40bd 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -84,6 +84,17 @@ class TestMinRes: # The short dimension should be at least min_res assert min(w, h) >= 704 + def test_rounding_never_drops_below_min_res(self): + """Nearest-multiple rounding must not violate the min_res guarantee. + + min_res=1100 with multiple_of=256: 1100 would round to 1024 (< min_res); + it must be bumped up to 1280 instead. + """ + w, h = calc(1000, 1000, 1100, 4096, 256, MIN) + assert min(w, h) >= 1100 + assert w % 256 == 0 + assert h % 256 == 0 + def test_min_res_normal_case(self): """In MIN_RES mode with no extreme ratio, both dims should stay within normal range.""" w, h = calc(500, 333, 704, 2048, 32, MIN)