- Reject invalid configs in validate_inputs instead of mid-execution: Strict-mode multiple_of above max_res, out-of-range min_res/max_res/ multiple_of, and unknown constraint modes - Share the resolution and multiple_of bounds between define_schema() and validate_inputs() so the widget and the queue-time check cannot drift - Cap the min-res pixel budget at an absolute 64 MP, floored at max_res squared; the multiplier alone permitted ~12 GB outputs at max_res=8192 - Warn on large upscales in Strict mode as well as min-res mode - Pin GitHub Actions by commit SHA on node24-native majors; drop issues:write from the publish workflow and set contents:read on both - Pin Comfy-Org/publish-node-action to the commit @v1 resolves to; the 1.0.1 tag calls `comfy tracking disable`, removed from comfy-cli - Add requirements-ci.txt with pip caching; add ruff config and a CI lint step - Add schema-contract, tooltip, budget, crop-guard and validation tests - Replace typing.Tuple with tuple - Bump version to 2.4.1
595 lines
26 KiB
Python
595 lines
26 KiB
Python
import logging
|
|
import math
|
|
from enum import Enum
|
|
|
|
import torch
|
|
import torch.nn.functional as F
|
|
from comfy_api.latest import ComfyExtension, io
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# "Prioritize Min Resolution" has no natural upper bound: holding both sides at
|
|
# or above min_res on an extreme aspect ratio scales the long side without
|
|
# limit. A 1x690 input at the default settings asks for 704x485760 — over 4 GB
|
|
# per batch item, enough to OOM the whole ComfyUI process. Refuse to allocate
|
|
# more than this many max_res-sized boxes and tell the user to switch modes.
|
|
MAX_OUTPUT_PIXEL_BUDGET_FACTOR = 16
|
|
|
|
# The relative budget above is a multiplier, so it grows with the square of
|
|
# max_res: at max_res=8192 it would permit a 1-gigapixel output (~12 GB per
|
|
# batch item), which is the very thing it exists to prevent. Cap it in absolute
|
|
# terms too. 64 MP is ~768 MB per batch item at float32 RGB.
|
|
MAX_OUTPUT_PIXELS = 64 * 1024 * 1024
|
|
|
|
# Upscales beyond this factor are legal but worth flagging in the log.
|
|
UPSCALE_WARN_FACTOR = 4
|
|
|
|
# Bounds shared by define_schema() and validate_inputs(). ComfyUI applies a
|
|
# node's declared min/max only to inputs that validate_inputs does NOT name
|
|
# (execution.py: `if x not in validate_function_inputs`), so every input named
|
|
# there must have its range re-checked by hand or it goes unvalidated.
|
|
MIN_RESOLUTION = 1
|
|
MAX_RESOLUTION = 65536
|
|
MIN_MULTIPLE_OF = 1
|
|
MAX_MULTIPLE_OF = 256
|
|
|
|
|
|
# (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(str, Enum):
|
|
"""Position for cropping when aspect ratios don't match"""
|
|
CENTER = "center"
|
|
TOP = "top"
|
|
BOTTOM = "bottom"
|
|
LEFT = "left"
|
|
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
|
|
while preserving or constraining aspect ratio based on resolution limits.
|
|
"""
|
|
|
|
@classmethod
|
|
def define_schema(cls) -> io.Schema:
|
|
"""Define the node schema with inputs and outputs"""
|
|
return io.Schema(
|
|
node_id="ConstrainResolution",
|
|
display_name="Constrain Resolution",
|
|
category="image/resolution",
|
|
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 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"
|
|
),
|
|
inputs=[
|
|
# Image input
|
|
io.Image.Input(
|
|
"image",
|
|
tooltip="Input image to analyze and resize"
|
|
),
|
|
|
|
# Resolution constraints
|
|
io.Int.Input(
|
|
"min_res",
|
|
default=704,
|
|
min=MIN_RESOLUTION,
|
|
max=MAX_RESOLUTION,
|
|
tooltip=(
|
|
"Minimum resolution in pixels for width and height. "
|
|
"Neither output dimension will fall below this."
|
|
)
|
|
),
|
|
io.Int.Input(
|
|
"max_res",
|
|
default=1280,
|
|
min=MIN_RESOLUTION,
|
|
max=MAX_RESOLUTION,
|
|
tooltip=(
|
|
"Maximum resolution in pixels. Every image is rescaled so its longest side "
|
|
"lands here, so images already in range are resized too. In "
|
|
"'Prioritize Min Resolution' mode the long side may exceed this on extreme "
|
|
"aspect ratios."
|
|
)
|
|
),
|
|
io.Int.Input(
|
|
"multiple_of",
|
|
default=2,
|
|
min=MIN_MULTIPLE_OF,
|
|
max=MAX_MULTIPLE_OF,
|
|
tooltip=(
|
|
"Ensures output dimensions are multiples of this number. "
|
|
"Common values: 2 (most models), 8, 16, 32, or 64 (optimal performance). "
|
|
"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(
|
|
"constraint_mode",
|
|
options=[e.value for e in ConstraintMode],
|
|
default=ConstraintMode.MIN_RES.value,
|
|
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=True,
|
|
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."
|
|
)
|
|
),
|
|
io.Combo.Input(
|
|
"crop_position",
|
|
options=[e.value for e in CropPosition],
|
|
default=CropPosition.CENTER.value,
|
|
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(
|
|
display_name="resized_image",
|
|
tooltip="Image resized to the constrained dimensions"
|
|
),
|
|
io.Image.Output(
|
|
display_name="original_image",
|
|
tooltip="Original image passed through unchanged for workflow flexibility"
|
|
),
|
|
io.Int.Output(
|
|
display_name="width",
|
|
tooltip="Final width after constraints and rounding"
|
|
),
|
|
io.Int.Output(
|
|
display_name="height",
|
|
tooltip="Final height after constraints and rounding"
|
|
),
|
|
io.Float.Output(
|
|
display_name="final_aspect_ratio",
|
|
tooltip="Aspect ratio of the output image (width/height)"
|
|
),
|
|
io.Float.Output(
|
|
display_name="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, constraint_mode):
|
|
"""Validate input parameters.
|
|
|
|
The signature deliberately declares only the values checked here and
|
|
takes no **kwargs: ComfyUI skips its own range and combo-option
|
|
validation for every input once this function accepts **kwargs
|
|
(see ``validate_inputs`` in ComfyUI's execution.py). Listing just these
|
|
four leaves the other inputs to ComfyUI's built-in checks.
|
|
|
|
The flip side is that ComfyUI also skips its checks for the four names
|
|
listed here, so their schema bounds are re-asserted below. The widgets
|
|
already clamp in the UI; a hand-written or generated API workflow does
|
|
not, and an unbounded max_res is what makes the pixel budget in
|
|
``calculate_optimal_dimensions`` meaningless.
|
|
"""
|
|
for name, value in (("min_res", min_res), ("max_res", max_res)):
|
|
if not MIN_RESOLUTION <= value <= MAX_RESOLUTION:
|
|
return (
|
|
f"{name} must be between {MIN_RESOLUTION} and {MAX_RESOLUTION}, got {value}"
|
|
)
|
|
|
|
if not MIN_MULTIPLE_OF <= multiple_of <= MAX_MULTIPLE_OF:
|
|
return (
|
|
f"multiple_of must be between {MIN_MULTIPLE_OF} and {MAX_MULTIPLE_OF}, "
|
|
f"got {multiple_of}"
|
|
)
|
|
|
|
valid_modes = [mode.value for mode in ConstraintMode]
|
|
if constraint_mode not in valid_modes:
|
|
return f"constraint_mode must be one of {valid_modes}, got {constraint_mode!r}"
|
|
|
|
if max_res < min_res:
|
|
return f"max_res ({max_res}) must be greater than or equal to min_res ({min_res})"
|
|
|
|
if (
|
|
constraint_mode == ConstraintMode.MAX_RES_STRICT.value
|
|
and multiple_of > max_res
|
|
):
|
|
# Same failure calculate_optimal_dimensions raises at execution time;
|
|
# catching it here rejects the workflow at queue time instead.
|
|
return (
|
|
f"multiple_of ({multiple_of}) is larger than max_res ({max_res}), so no valid "
|
|
f"output size exists in '{ConstraintMode.MAX_RES_STRICT.value}' mode. "
|
|
f"Lower multiple_of or raise max_res."
|
|
)
|
|
|
|
return True
|
|
|
|
@staticmethod
|
|
def calculate_aspect_ratio(width: int, height: int) -> float:
|
|
"""Calculate aspect ratio from width and height"""
|
|
if height == 0:
|
|
return 0.0
|
|
return round(width / height, 4)
|
|
|
|
@staticmethod
|
|
def round_to_multiple(value: int, multiple: int) -> int:
|
|
"""Round a value to the nearest multiple, never dropping below one pixel"""
|
|
if multiple < 1:
|
|
raise ValueError(f"multiple_of must be at least 1, got {multiple}")
|
|
if multiple == 1:
|
|
return max(1, value)
|
|
return max(multiple, multiple * round(value / multiple))
|
|
|
|
@staticmethod
|
|
def calculate_optimal_dimensions(
|
|
width: int,
|
|
height: int,
|
|
min_res: int,
|
|
max_res: int,
|
|
multiple_of: int,
|
|
constraint_mode: str
|
|
) -> tuple[int, int]:
|
|
"""Calculate optimal dimensions based on constraints"""
|
|
if multiple_of < 1:
|
|
raise ValueError(f"multiple_of must be at least 1, got {multiple_of}")
|
|
if constraint_mode not in (ConstraintMode.MIN_RES.value, ConstraintMode.MAX_RES_STRICT.value):
|
|
# Silently falling through would apply neither the min_res floor nor
|
|
# the max_res clamp, quietly violating both limits.
|
|
raise ValueError(f"Unknown constraint_mode: {constraint_mode!r}")
|
|
if height == 0 or width == 0:
|
|
return 0, 0
|
|
|
|
aspect_ratio = width / height
|
|
|
|
# 1. Initial scaling to fit the longest side to max_res
|
|
if aspect_ratio >= 1: # Landscape or square
|
|
new_width = max_res
|
|
new_height = new_width / aspect_ratio
|
|
else: # Portrait
|
|
new_height = max_res
|
|
new_width = new_height * aspect_ratio
|
|
|
|
# 2. Apply constraint logic based on user's choice
|
|
if constraint_mode == ConstraintMode.MIN_RES.value:
|
|
# If a dimension is below min_res, scale the entire image up to meet it
|
|
scale_factor = 1.0
|
|
if new_width < min_res:
|
|
scale_factor = max(scale_factor, min_res / new_width)
|
|
if new_height < min_res:
|
|
scale_factor = max(scale_factor, min_res / new_height)
|
|
|
|
new_width *= scale_factor
|
|
new_height *= scale_factor
|
|
|
|
# If mode is "Prioritize Max Resolution (Strict)", we do nothing here.
|
|
# The initial scaling keeps us within max_res before rounding, but rounding
|
|
# can push a dimension above max_res — the clamp below handles that.
|
|
|
|
# 3. Round final dimensions to the nearest multiple
|
|
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:
|
|
max_allowed = (max_res // multiple_of) * multiple_of
|
|
if max_allowed == 0:
|
|
# No positive multiple of multiple_of fits inside max_res, so the
|
|
# request is unsatisfiable. Clamping anyway would return 0 and
|
|
# silently pass the image through untouched.
|
|
raise ValueError(
|
|
f"multiple_of ({multiple_of}) is larger than max_res ({max_res}), so no valid "
|
|
f"output size exists in '{ConstraintMode.MAX_RES_STRICT.value}' mode. "
|
|
f"Lower multiple_of or raise max_res."
|
|
)
|
|
final_width = min(final_width, max_allowed)
|
|
final_height = min(final_height, max_allowed)
|
|
|
|
# 5. Refuse absurd allocations rather than letting the resize OOM the
|
|
# process. Only reachable in min-res mode, which is the only mode
|
|
# without an upper bound.
|
|
if constraint_mode == ConstraintMode.MIN_RES.value:
|
|
# Allow the user's own max_res box unconditionally, a multiple of it
|
|
# for moderately extreme ratios, and never more than the absolute
|
|
# ceiling — so the guard stays meaningful at every max_res.
|
|
box = max(min_res, max_res) ** 2
|
|
budget = max(box, min(box * MAX_OUTPUT_PIXEL_BUDGET_FACTOR, MAX_OUTPUT_PIXELS))
|
|
if final_width * final_height > budget:
|
|
raise ValueError(
|
|
f"'{ConstraintMode.MIN_RES.value}' needs {final_width}x{final_height} "
|
|
f"({final_width * final_height / 1e6:.1f} MP) to hold both sides at or above "
|
|
f"min_res ({min_res}) for a {width}x{height} input. That exceeds the "
|
|
f"{budget / 1e6:.1f} MP safety limit and would likely exhaust memory. "
|
|
f"Switch to '{ConstraintMode.MAX_RES_STRICT.value}' to cap the output size, "
|
|
f"or lower min_res."
|
|
)
|
|
|
|
return final_width, final_height
|
|
|
|
@staticmethod
|
|
def resize_image(
|
|
image: torch.Tensor,
|
|
target_width: int,
|
|
target_height: int,
|
|
method: str = ResizeMethod.LANCZOS.value
|
|
) -> torch.Tensor:
|
|
"""
|
|
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];
|
|
# resizing needs [batch, channels, height, width]
|
|
image_permuted = image.permute(0, 3, 1, 2)
|
|
|
|
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
|
|
)
|
|
|
|
# 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.permute(0, 2, 3, 1)
|
|
|
|
@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
|
|
|
|
if target_width > width or target_height > height:
|
|
# Slicing past the end silently yields a smaller tensor than the
|
|
# width/height this node reports, so refuse instead.
|
|
raise ValueError(
|
|
f"crop target {target_width}x{target_height} is larger than the source "
|
|
f"image {width}x{height}; crop_image cannot grow an image."
|
|
)
|
|
|
|
# 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 == CropPosition.CENTER.value:
|
|
left = width_diff // 2
|
|
top = height_diff // 2
|
|
elif position == CropPosition.TOP.value:
|
|
left = width_diff // 2
|
|
top = 0
|
|
elif position == CropPosition.BOTTOM.value:
|
|
left = width_diff // 2
|
|
top = height_diff
|
|
elif position == CropPosition.LEFT.value:
|
|
left = 0
|
|
top = height_diff // 2
|
|
elif position == CropPosition.RIGHT.value:
|
|
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,
|
|
resize_method,
|
|
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)
|
|
|
|
# Calculate optimal dimensions
|
|
target_width, target_height = cls.calculate_optimal_dimensions(
|
|
width, height, min_res, max_res, multiple_of, constraint_mode
|
|
)
|
|
|
|
# Degenerate input (empty/malformed tensor) collapses to a 0 target,
|
|
# which would crash the resizer. Pass the image through untouched instead.
|
|
if target_width == 0 or target_height == 0:
|
|
logger.warning(
|
|
"Input has a zero dimension (%dx%d); passing image through unchanged.",
|
|
width, height
|
|
)
|
|
return io.NodeOutput(
|
|
image, image, width, height,
|
|
original_aspect_ratio, original_aspect_ratio
|
|
)
|
|
|
|
# Warn when a resize forces a large upscale (>4x): legal, but tiny sources
|
|
# produce visibly soft results, and min-res mode can also blow up memory
|
|
# on extreme aspect ratios. Fires in both modes — strict mode bounds the
|
|
# size, not the blur.
|
|
upscale_factor = max(target_width / width, target_height / height)
|
|
if upscale_factor > UPSCALE_WARN_FACTOR:
|
|
if constraint_mode == ConstraintMode.MIN_RES.value:
|
|
logger.warning(
|
|
"Upscaling by %.1fx to %dx%d to satisfy min_res on an extreme aspect ratio. "
|
|
"Use 'Prioritize Max Resolution (Strict)' to cap output size and avoid large upscales.",
|
|
upscale_factor, target_width, target_height
|
|
)
|
|
else:
|
|
logger.warning(
|
|
"Upscaling by %.1fx to %dx%d from a %dx%d source; the result will look soft. "
|
|
"A larger source image will give better quality.",
|
|
upscale_factor, target_width, target_height, width, height
|
|
)
|
|
|
|
final_aspect_ratio = cls.calculate_aspect_ratio(target_width, target_height)
|
|
|
|
# Decide on cropping before resizing, so only one resize is ever done.
|
|
# Compare exact ratios rather than the 4-decimal rounded outputs: a very
|
|
# tall image rounds to 0.0, which would skip the crop and distort it.
|
|
exact_original_ratio = width / height
|
|
exact_final_ratio = target_width / target_height
|
|
aspect_ratio_deviation = abs(
|
|
(exact_final_ratio - exact_original_ratio) / exact_original_ratio * 100
|
|
)
|
|
|
|
if crop_as_required and aspect_ratio_deviation > 0.1:
|
|
# Resize preserving aspect ratio so one side overshoots, then crop back.
|
|
# ceil + max keep the intermediate dimension at or above the target,
|
|
# so the crop below never has to grow the image.
|
|
if exact_original_ratio > exact_final_ratio:
|
|
# Original is wider: match height, overshoot and crop width
|
|
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: match width, overshoot and crop height
|
|
intermediate_height = max(target_height, math.ceil(target_width * height / width))
|
|
resized_image = cls.resize_image(image, target_width, intermediate_height, resize_method)
|
|
|
|
resized_image = cls.crop_image(resized_image, target_width, target_height, crop_position)
|
|
logger.debug("Image cropped to achieve exact dimensions %dx%d", target_width, target_height)
|
|
else:
|
|
resized_image = cls.resize_image(image, target_width, target_height, resize_method)
|
|
|
|
if not crop_as_required and aspect_ratio_deviation > 1: # 1% tolerance for rounding
|
|
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(
|
|
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
|
|
)
|
|
|
|
|
|
class ConstrainResolutionExtension(ComfyExtension):
|
|
"""Extension class for registering nodes"""
|
|
|
|
async def get_node_list(self) -> list[type[io.ComfyNode]]:
|
|
"""Return list of nodes provided by this extension"""
|
|
return [ConstrainResolution]
|
|
|
|
|
|
async def comfy_entrypoint() -> ComfyExtension:
|
|
"""Entry point for ComfyUI v3"""
|
|
return ConstrainResolutionExtension()
|