Merge audit/v2.3.0: resize method selection, crop fix, dependency cleanup (#8)

This commit is contained in:
EnragedAntelope
2026-07-16 17:27:05 -04:00
7 changed files with 179 additions and 44 deletions
-7
View File
@@ -1,7 +0,0 @@
{
"permissions": {
"allow": [
"Bash(python -m pytest tests/test_nodes.py -v)"
]
}
}
+3
View File
@@ -35,3 +35,6 @@ env/
# OS
.DS_Store
Thumbs.db
# Claude Code local settings
.claude/settings.local.json
+12 -3
View File
@@ -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
+85 -30
View File
@@ -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(
@@ -215,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:
@@ -225,37 +264,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 +377,7 @@ class ConstrainResolution(io.ComfyNode):
min_res,
max_res,
multiple_of,
resize_method,
constraint_mode,
crop_as_required,
crop_position
@@ -342,7 +394,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 +409,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(
+3 -2
View File
@@ -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"
+1 -1
View File
@@ -1 +1 @@
numpy
# No dependencies beyond ComfyUI's own environment (torch is provided by ComfyUI).
+75 -1
View File
@@ -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
@@ -81,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)
@@ -106,3 +120,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)