* docs(nodes): add user guides and tooltips * chore: clean up node preflight nits
291 lines
14 KiB
Python
291 lines
14 KiB
Python
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
#
|
|
# ComfyUI-Koolook — Easy Resize (Koolook variant)
|
|
# Copyright (C) 2026 ComfyUI-Koolook contributors (kforgelabs).
|
|
#
|
|
# This file is part of ComfyUI-Koolook, licensed under GPL-3.0-or-later.
|
|
# See the LICENSE file at the repo root for the full text.
|
|
#
|
|
# Originally inspired by `Resize Image V2` from kijai/ComfyUI-KJNodes
|
|
# (GPL-3.0). This implementation has been substantially extended beyond
|
|
# the inspiration with: aspect-ratio parsing, divisible_by enforcement
|
|
# for AI model compatibility, multiple keep_proportion modes
|
|
# (stretch/letterbox/pillarbox), padding color + crop position controls,
|
|
# device selection, mask + composed-image outputs, target W/H + original
|
|
# aspect-ratio reporting, and color-panel passthrough. See
|
|
# forks/THIRD_PARTY.md for the full attribution + change log.
|
|
#
|
|
# Modified by ComfyUI-Koolook on 2026-05-03 (renamed and re-attributed).
|
|
|
|
import torch
|
|
from comfy.utils import common_upscale
|
|
from nodes import MAX_RESOLUTION
|
|
import math
|
|
|
|
class EasyResize:
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
return {
|
|
"required": {
|
|
"image": ("IMAGE",),
|
|
"base_on": (["Width", "Height"], {
|
|
"default": "Width",
|
|
"tooltip": "Choose which target dimension is driven by base_size; the other is computed from aspect_ratio.",
|
|
}),
|
|
"base_size": ("INT", {
|
|
"default": 512,
|
|
"min": 1,
|
|
"max": MAX_RESOLUTION,
|
|
"step": 1,
|
|
"tooltip": "Target width or height before snapping to divisible_by.",
|
|
}),
|
|
"aspect_ratio": ("STRING", {
|
|
"default": "16:9",
|
|
"tooltip": "Target ratio as W:H, e.g. 16:9, 1:1, or 9:16.",
|
|
}),
|
|
"divisible_by": ("INT", {
|
|
"default": 32,
|
|
"min": 1,
|
|
"max": 128,
|
|
"step": 1,
|
|
"tooltip": "Snap final width and height to a multiple; use the model's latent/grid requirement.",
|
|
}),
|
|
"upscale_method": (["nearest-exact", "bilinear", "area", "bicubic", "lanczos"], {
|
|
"default": "nearest-exact",
|
|
"tooltip": "ComfyUI resize filter used for the image and mask.",
|
|
}),
|
|
"keep_proportion": (["stretch", "letterbox", "pillarbox"], {
|
|
"default": "stretch",
|
|
"tooltip": "stretch fills the target exactly; letterbox/pillarbox preserve proportions and add bars.",
|
|
}),
|
|
"crop_position": (["top", "bottom", "left", "right", "center"], {
|
|
"default": "center",
|
|
"tooltip": "Placement of the preserved image when bars are added. Top/bottom affect letterbox; left/right affect pillarbox.",
|
|
}),
|
|
"pad_color_mode": (["White", "Black", "Gray", "Custom"], {
|
|
"default": "Black",
|
|
"tooltip": "Color for letterbox/pillarbox bars. Custom reads pad_color.",
|
|
}),
|
|
"panel_color_mode": (["White", "Black", "Gray", "Custom"], {
|
|
"default": "Black",
|
|
"tooltip": "Color for COLOR_PANEL and masked composed_IMAGE background. Custom reads panel_color.",
|
|
}),
|
|
"device": (["cpu", "cuda"], {
|
|
"default": "cpu",
|
|
"tooltip": "Device used for resize/composite tensors.",
|
|
}),
|
|
},
|
|
"optional": {
|
|
"mask": ("MASK",),
|
|
"pad_color": ("STRING", {
|
|
"default": "0, 0, 0",
|
|
"tooltip": "Custom RGB bar color in 0..1 floats, e.g. 0, 0, 0 or 1, 0.5, 0.",
|
|
}),
|
|
"panel_color": ("STRING", {
|
|
"default": "0, 0, 0",
|
|
"tooltip": "Custom RGB color for COLOR_PANEL and mask composition background.",
|
|
}),
|
|
"invert_composed_MASK": ("BOOLEAN", {
|
|
"default": False,
|
|
"tooltip": "Flip which side of the resized mask shows IMAGE vs COLOR_PANEL in composed_IMAGE.",
|
|
}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "IMAGE", "MASK", "MASK", "INT", "INT", "IMAGE", "INT", "INT", "STRING")
|
|
RETURN_NAMES = ("IMAGE", "composed_IMAGE", "MASK", "inverted_MASK", "width", "height", "COLOR_PANEL", "original_width", "original_height", "original_aspect_ratio")
|
|
FUNCTION = "adjust_to_aspect"
|
|
CATEGORY = "Koolook/Image"
|
|
DESCRIPTION = """
|
|
Resize an image to a target aspect ratio and model-friendly divisible size.
|
|
Use stretch, letterbox, or pillarbox; optional masks can produce a composed
|
|
image, inverted mask, color panel, and final/original size outputs.
|
|
"""
|
|
|
|
def adjust_to_aspect(self, image, base_on, base_size, aspect_ratio, upscale_method, keep_proportion, pad_color_mode, crop_position, divisible_by, device, panel_color_mode, mask=None, pad_color="0, 0, 0", panel_color="0, 0, 0", invert_composed_MASK=False):
|
|
def round_to_nearest_multiple(number, multiple):
|
|
return multiple * round(number / multiple)
|
|
|
|
if divisible_by < 1:
|
|
divisible_by = 1
|
|
|
|
# Move to specified device
|
|
image = image.to(device)
|
|
if mask is not None:
|
|
mask = mask.unsqueeze(1) # Simplified: Add channel dim for interpolation [B, 1, H, W]
|
|
|
|
# Image processing (inspired by ResizeImage logic in image_nodes.py)
|
|
B, H, W, C = image.shape
|
|
original_B, original_H, original_W, original_C = B, H, W, C
|
|
image = image.movedim(-1, 1) # Channels-first
|
|
|
|
has_alpha = (C == 4)
|
|
|
|
# Compute original aspect ratio
|
|
if original_W == 0 or original_H == 0:
|
|
original_aspect_ratio = "1:1" # Default if zero dimensions
|
|
else:
|
|
gcd_val = math.gcd(original_W, original_H)
|
|
ar_w = original_W // gcd_val
|
|
ar_h = original_H // gcd_val
|
|
original_aspect_ratio = f"{ar_w}:{ar_h}"
|
|
|
|
# Parse aspect ratio
|
|
try:
|
|
ar_parts = [int(part.strip()) for part in aspect_ratio.split(':')]
|
|
if len(ar_parts) != 2 or ar_parts[0] <= 0 or ar_parts[1] <= 0:
|
|
raise ValueError("Aspect ratio must be 'w:h' with positive integers, e.g., '16:9'.")
|
|
ar_width, ar_height = ar_parts
|
|
ratio = ar_width / ar_height
|
|
except ValueError as e:
|
|
raise ValueError(str(e))
|
|
|
|
# Make base_size divisible by 'divisible_by'
|
|
base_rounded = max(divisible_by, round_to_nearest_multiple(base_size, divisible_by))
|
|
|
|
# Calculate target dimensions
|
|
if base_on == "Width":
|
|
target_width = base_rounded
|
|
computed_height = target_width / ratio
|
|
target_height = max(divisible_by, round_to_nearest_multiple(computed_height, divisible_by))
|
|
else: # Height
|
|
target_height = base_rounded
|
|
computed_width = target_height * ratio
|
|
target_width = max(divisible_by, round_to_nearest_multiple(computed_width, divisible_by))
|
|
|
|
# Color map for modes
|
|
color_map = {
|
|
"White": [1.0, 1.0, 1.0],
|
|
"Black": [0.0, 0.0, 0.0],
|
|
"Gray": [0.5, 0.5, 0.5],
|
|
}
|
|
|
|
# Determine pad_color_list
|
|
if pad_color_mode == "Custom":
|
|
pad_color_list = [float(x.strip()) for x in pad_color.split(',')]
|
|
else:
|
|
pad_color_list = color_map.get(pad_color_mode, [0.0, 0.0, 0.0]) # Default to black if invalid
|
|
|
|
if len(pad_color_list) != 3:
|
|
raise ValueError("Pad color must be three comma-separated floats, e.g., '0, 0, 0'.")
|
|
|
|
# Determine panel_color_list
|
|
if panel_color_mode == "Custom":
|
|
panel_color_list = [float(x.strip()) for x in panel_color.split(',')]
|
|
else:
|
|
panel_color_list = color_map.get(panel_color_mode, [0.0, 0.0, 0.0]) # Default to black if invalid
|
|
|
|
if len(panel_color_list) != 3:
|
|
raise ValueError("Panel color must be three comma-separated floats, e.g., '0, 0, 0'.")
|
|
|
|
if keep_proportion == "stretch":
|
|
# Simple resize
|
|
out_image = common_upscale(image, target_width, target_height, upscale_method, "disabled")
|
|
if mask is not None:
|
|
out_mask = torch.nn.functional.interpolate(mask, size=(target_height, target_width), mode="nearest")
|
|
else:
|
|
out_mask = None
|
|
else:
|
|
# Preserve aspect with pad/crop (letterbox or pillarbox)
|
|
scale = min(target_width / W, target_height / H)
|
|
new_w = round_to_nearest_multiple(W * scale, divisible_by)
|
|
new_h = round_to_nearest_multiple(H * scale, divisible_by)
|
|
|
|
resized_image = common_upscale(image, new_w, new_h, upscale_method, "disabled")
|
|
if mask is not None:
|
|
resized_mask = torch.nn.functional.interpolate(mask, size=(new_h, new_w), mode="nearest")
|
|
|
|
# Determine padding based on mode and position
|
|
pad_l = pad_r = pad_t = pad_b = 0
|
|
if keep_proportion == "letterbox": # Add bars top/bottom (for wider target)
|
|
total_pad_h = target_height - new_h
|
|
if crop_position == "top":
|
|
pad_b = total_pad_h
|
|
elif crop_position == "bottom":
|
|
pad_t = total_pad_h
|
|
else: # center, left, right (left/right not affecting vertical)
|
|
pad_t = total_pad_h // 2
|
|
pad_b = total_pad_h - pad_t
|
|
elif keep_proportion == "pillarbox": # Add bars left/right (for taller target)
|
|
total_pad_w = target_width - new_w
|
|
if crop_position == "left":
|
|
pad_r = total_pad_w
|
|
elif crop_position == "right":
|
|
pad_l = total_pad_w
|
|
else: # center, top, bottom
|
|
pad_l = total_pad_w // 2
|
|
pad_r = total_pad_w - pad_l
|
|
|
|
# Create background with pad color (RGB or RGBA)
|
|
pad_channels = 4 if has_alpha else 3
|
|
pad_color_tensor = torch.zeros((1, pad_channels, 1, 1), dtype=image.dtype, device=device)
|
|
pad_color_tensor[0, :3, 0, 0] = torch.tensor(pad_color_list, dtype=image.dtype, device=device)
|
|
if has_alpha:
|
|
# Default alpha to 1 (opaque) for padding
|
|
pad_color_tensor[0, 3, 0, 0] = 1.0
|
|
out_image = pad_color_tensor.expand(B, pad_channels, target_height, target_width).clone()
|
|
out_image[:, :, pad_t:pad_t + new_h, pad_l:pad_l + new_w] = resized_image
|
|
|
|
if mask is not None:
|
|
out_mask = torch.zeros((B, 1, target_height, target_width), dtype=mask.dtype, device=device)
|
|
out_mask[:, :, pad_t:pad_t + new_h, pad_l:pad_l + new_w] = resized_mask
|
|
else:
|
|
out_mask = None
|
|
|
|
out_image = out_image.movedim(1, -1)
|
|
if out_mask is not None:
|
|
out_mask = out_mask.movedim(1, -1).squeeze(-1) # Squeeze last dim (channel=1) -> [B, H', W']
|
|
|
|
# Create color panel
|
|
# Create panel tensor (B, H, W, 3)
|
|
panel_channels = 3 # RGB, no alpha
|
|
color_panel = torch.zeros((1, panel_channels, target_height, target_width), dtype=image.dtype, device=device)
|
|
color_panel[0, :, :, :] = torch.tensor(panel_color_list, dtype=image.dtype, device=device).view(panel_channels, 1, 1)
|
|
color_panel = color_panel.expand(B, -1, -1, -1) # Expand to match batch size B
|
|
color_panel = color_panel.movedim(1, -1) # to [B, H, W, 3]
|
|
|
|
# Compute composed_IMAGE if mask is provided
|
|
composed_image_out = out_image.clone() # Default to out_image
|
|
if out_mask is not None:
|
|
# Composite: out_image * mask + color_panel * (1 - mask)
|
|
# Ensure color_panel has same channels as out_image (add alpha if needed)
|
|
if has_alpha:
|
|
color_panel_alpha = torch.cat([color_panel, torch.ones((B, target_height, target_width, 1), dtype=image.dtype, device=device)], dim=-1)
|
|
else:
|
|
color_panel_alpha = color_panel
|
|
# Expand mask to match channels
|
|
expanded_mask = out_mask.unsqueeze(-1).expand(-1, -1, -1, out_image.shape[-1])
|
|
if invert_composed_MASK:
|
|
composed_image_out = out_image * (1.0 - expanded_mask) + color_panel_alpha * expanded_mask
|
|
else:
|
|
composed_image_out = out_image * expanded_mask + color_panel_alpha * (1.0 - expanded_mask)
|
|
|
|
# Compute inverted_MASK if mask is provided
|
|
inverted_mask_out = None
|
|
if out_mask is not None:
|
|
inverted_mask_out = 1.0 - out_mask
|
|
|
|
return (out_image, composed_image_out, out_mask, inverted_mask_out, target_width, target_height, color_panel, original_W, original_H, original_aspect_ratio)
|
|
|
|
# Node mappings.
|
|
#
|
|
# v0.1.6 rename: the canonical ID is now "EasyResize_Koolook". The old
|
|
# bare-name "EasyResize" remains as a backward-compatible alias so that
|
|
# saved workflows continue to load — it'll be removed in a future major
|
|
# release once the deprecation has had time to propagate.
|
|
#
|
|
# Why renamed: the bare "EasyResize" ID collides with at least one other
|
|
# pack in the ComfyUI ecosystem (ComfyUI-EasyFilePaths registers the same
|
|
# bare name). The "_Koolook" suffix follows the same family pattern as
|
|
# Easy_hdr_VAE_encode/decode and removes the conflict.
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"EasyResize_Koolook": EasyResize, # canonical ID (new)
|
|
"EasyResize": EasyResize, # legacy alias (deprecated, kept for workflow compat)
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"EasyResize_Koolook": "Easy Resize (Koolook)",
|
|
"EasyResize": "Easy Resize (deprecated, use 'Easy Resize (Koolook)')",
|
|
}
|