Merge audit/v2.3.0: resize method selection, crop fix, dependency cleanup (#8)
This commit is contained in:
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(python -m pytest tests/test_nodes.py -v)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -35,3 +35,6 @@ env/
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Claude Code local settings
|
||||
.claude/settings.local.json
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -1 +1 @@
|
||||
numpy
|
||||
# No dependencies beyond ComfyUI's own environment (torch is provided by ComfyUI).
|
||||
|
||||
+75
-1
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user