v1.3.7: 新增Image Blend Advance V3节点和Crop By Mask功能增强

- 新增Image Blend Advance V3 (UTK)节点:高级图像混合与变换功能
- 支持17种混合模式:normal、multiply、screen、overlay等专业混合效果
- 完整变换功能:位置、缩放、宽高比、旋转、镜像翻转控制
- 6种插值方法:lanczos、bicubic、bilinear等高质量缩放算法
- 智能边界处理:自动处理图层超出画布的情况
- 自动背景生成:未提供背景时自动创建透明背景
- Alpha通道支持:自动提取和处理RGBA图像的透明度
- 增强Crop By Mask (UTK)节点:新增remaining_area输出
- remaining_area显示被裁剪区域:白色矩形标记已处理区域
This commit is contained in:
Cyber Dick Lang
2025-09-22 13:48:04 +08:00
parent 8fdd59fdf2
commit 2dface4604
4 changed files with 367 additions and 3 deletions
+26 -1
View File
@@ -8,13 +8,26 @@ A comprehensive toolkit for ComfyUI that provides various utility nodes for imag
:license: MIT, see LICENSE for more details.
"""
__version__ = "1.3.6"
__version__ = "1.3.7"
__author__ = "CyberDickLang"
__email__ = "286878701@qq.com"
__url__ = "https://github.com/whmc76"
# 更新日志
CHANGELOG = {
"1.3.7": [
"新增Image Blend Advance V3节点和Crop By Mask功能增强:",
"- 新增Image Blend Advance V3 (UTK)节点:高级图像混合与变换功能",
"- 支持17种混合模式:normal、multiply、screen、overlay等专业混合效果",
"- 完整变换功能:位置、缩放、宽高比、旋转、镜像翻转控制",
"- 6种插值方法:lanczos、bicubic、bilinear等高质量缩放算法",
"- 智能边界处理:自动处理图层超出画布的情况",
"- 自动背景生成:未提供背景时自动创建透明背景",
"- Alpha通道支持:自动提取和处理RGBA图像的透明度",
"- 增强Crop By Mask (UTK)节点:新增remaining_area输出",
"- remaining_area显示被裁剪区域:白色矩形标记已处理区域",
"- 完善批处理支持:智能处理多图像和蒙版的对应关系",
],
"1.3.6": [
"增强Image Crop By Mask And Resize节点功能:",
"- 新增4种resize方法:fill、crop、letterbox、stretch",
@@ -589,6 +602,15 @@ except ImportError:
IMAGE_CROP_RESIZE_MAPPINGS = {}
IMAGE_CROP_RESIZE_DISPLAY = {}
try:
from .nodes.image.image_blend_advance_v3 import \
NODE_CLASS_MAPPINGS as IMAGE_BLEND_V3_MAPPINGS
from .nodes.image.image_blend_advance_v3 import \
NODE_DISPLAY_NAME_MAPPINGS as IMAGE_BLEND_V3_DISPLAY
except ImportError:
IMAGE_BLEND_V3_MAPPINGS = {}
IMAGE_BLEND_V3_DISPLAY = {}
try:
from .nodes.tools.think_remover_node import \
NODE_CLASS_MAPPINGS as THINK_REMOVER_MAPPINGS
@@ -670,6 +692,7 @@ NODE_CLASS_MAPPINGS.update(RESTORE_CROP_MAPPINGS)
NODE_CLASS_MAPPINGS.update(COLOR_MATCH_MAPPINGS)
NODE_CLASS_MAPPINGS.update(BBOX_VISUALIZE_MAPPINGS)
NODE_CLASS_MAPPINGS.update(IMAGE_CROP_RESIZE_MAPPINGS)
NODE_CLASS_MAPPINGS.update(IMAGE_BLEND_V3_MAPPINGS)
NODE_CLASS_MAPPINGS.update(FILL_MASKED_MAPPINGS)
NODE_CLASS_MAPPINGS.update(MASK_MAPPINGS)
NODE_CLASS_MAPPINGS.update(LOAD_AUDIO_MAPPINGS)
@@ -706,6 +729,7 @@ NODE_DISPLAY_NAME_MAPPINGS.update(RESTORE_CROP_DISPLAY)
NODE_DISPLAY_NAME_MAPPINGS.update(COLOR_MATCH_DISPLAY)
NODE_DISPLAY_NAME_MAPPINGS.update(BBOX_VISUALIZE_DISPLAY)
NODE_DISPLAY_NAME_MAPPINGS.update(IMAGE_CROP_RESIZE_DISPLAY)
NODE_DISPLAY_NAME_MAPPINGS.update(IMAGE_BLEND_V3_DISPLAY)
NODE_DISPLAY_NAME_MAPPINGS.update(FILL_MASKED_DISPLAY_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(LOAD_AUDIO_DISPLAY_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(AUDIO_CROP_DISPLAY_MAPPINGS)
@@ -748,6 +772,7 @@ NODE_CATEGORIES = {
"ColorMatch_UTK",
"BboxVisualize_UTK",
"ImageCropByMaskAndResize_UTK",
"ImageBlendAdvanceV3_UTK",
"TextboxNode_UTK",
"TextConcatenate_UTK",
"MathExpression_UTK",
+16 -1
View File
@@ -111,8 +111,9 @@ class CropByMask_UTK:
"MASK",
"BOX",
"IMAGE",
"MASK",
)
RETURN_NAMES = ("croped_image", "croped_mask", "crop_box", "box_preview")
RETURN_NAMES = ("croped_image", "croped_mask", "crop_box", "box_preview", "remaining_area")
FUNCTION = "crop_by_mask"
def crop_by_mask(
@@ -202,11 +203,24 @@ class CropByMask_UTK:
line_width=(width + height) // 200,
)
crop_box = (x1, y1, x2, y2)
remaining_masks = []
for i in range(len(l_images)):
_canvas = tensor2pil(l_images[i]).convert("RGB")
_mask = l_masks[0]
ret_images.append(pil2tensor(_canvas.crop(crop_box)))
ret_masks.append(image2mask(_mask.crop(crop_box)))
# 计算remaining area:被裁剪掉的区域显示为白色
original_mask_tensor = mask_for_crop[0] if i == 0 else mask_for_crop[min(i, mask_for_crop.shape[0]-1)]
# 创建裁剪区域的mask(被裁剪的部分)
crop_region_mask = torch.zeros_like(original_mask_tensor)
crop_region_mask[y1:y2, x1:x2] = 1.0
# remaining area显示被裁剪掉的部分:裁剪区域为白色
remaining_mask = crop_region_mask
remaining_masks.append(remaining_mask.unsqueeze(0))
log(
f"CropByMask_UTK Processed {len(ret_images)} image(s).",
@@ -217,6 +231,7 @@ class CropByMask_UTK:
torch.cat(ret_masks, dim=0),
list(crop_box),
pil2tensor(preview_image),
torch.cat(remaining_masks, dim=0),
)
+324
View File
@@ -0,0 +1,324 @@
"""
Image Blend Advance V3 Node for ComfyUI Universal Toolkit
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Advanced image blending functionality adapted from LayerStyle.
Provides sophisticated layer compositing with transforms and blend modes.
:copyright: (c) 2024 by May
:license: MIT, see LICENSE for more details.
"""
import torch
import copy
import numpy as np
from PIL import Image, ImageOps
from ..image_utils import tensor2pil, pil2tensor, image2mask
class ImageBlendAdvanceV3_UTK:
"""
Advanced image blending node with transforms and multiple blend modes.
This node provides sophisticated layer compositing capabilities similar to
Photoshop, including scaling, rotation, positioning, and various blend modes.
"""
def __init__(self):
self.NODE_NAME = 'ImageBlendAdvanceV3_UTK'
@classmethod
def INPUT_TYPES(cls):
# 基础混合模式列表
blend_modes = [
'normal', 'multiply', 'screen', 'overlay', 'soft_light', 'hard_light',
'color_dodge', 'color_burn', 'darken', 'lighten', 'difference', 'exclusion',
'hue', 'saturation', 'color', 'luminosity', 'addition', 'subtract'
]
mirror_modes = ['None', 'horizontal', 'vertical']
transform_methods = ['lanczos', 'bicubic', 'hamming', 'bilinear', 'box', 'nearest']
return {
"required": {
"layer_image": ("IMAGE",),
"invert_mask": ("BOOLEAN", {"default": True}),
"blend_mode": (blend_modes, {"default": "normal"}),
"opacity": ("INT", {"default": 100, "min": 0, "max": 100, "step": 1}),
"x_percent": ("FLOAT", {"default": 50, "min": -999, "max": 999, "step": 0.01}),
"y_percent": ("FLOAT", {"default": 50, "min": -999, "max": 999, "step": 0.01}),
"mirror": (mirror_modes, {"default": "None"}),
"scale": ("FLOAT", {"default": 1, "min": 0.01, "max": 100, "step": 0.01}),
"aspect_ratio": ("FLOAT", {"default": 1, "min": 0.01, "max": 100, "step": 0.01}),
"rotate": ("FLOAT", {"default": 0, "min": -999999, "max": 999999, "step": 0.01}),
"transform_method": (transform_methods, {"default": "lanczos"}),
"anti_aliasing": ("INT", {"default": 0, "min": 0, "max": 16, "step": 1}),
},
"optional": {
"background_image": ("IMAGE",),
"layer_mask": ("MASK",),
}
}
RETURN_TYPES = ("IMAGE", "MASK")
RETURN_NAMES = ("image", "mask")
FUNCTION = 'image_blend_advance_v3'
CATEGORY = 'UniversalToolkit/Image'
DESCRIPTION = """
Advanced image blending with transforms and multiple blend modes.
This node provides sophisticated layer compositing capabilities:
**Transform Features:**
- Position control via x/y percentage
- Scale and aspect ratio adjustment
- Rotation with anti-aliasing
- Horizontal/vertical mirroring
- High-quality interpolation methods
**Blend Modes:**
- Normal, Multiply, Screen, Overlay
- Soft Light, Hard Light, Color Dodge, Color Burn
- Darken, Lighten, Difference, Exclusion
- Hue, Saturation, Color, Luminosity
- Addition, Subtract
**Advanced Features:**
- Automatic background generation if not provided
- Alpha channel support for layer images
- Batch processing support
- Flexible mask handling with invert option
- Anti-aliasing for smooth rotations
Perfect for creating complex compositions, photo manipulations,
and artistic effects with precise control over blending.
"""
def apply_blend_mode(self, background, layer, blend_mode, opacity):
"""
Apply blend mode between background and layer images.
Simplified implementation of common blend modes.
"""
# Convert to numpy arrays for processing
bg_array = np.array(background.convert('RGBA'), dtype=np.float32) / 255.0
layer_array = np.array(layer.convert('RGBA'), dtype=np.float32) / 255.0
# Apply opacity
alpha = opacity / 100.0
if blend_mode == "normal":
result = bg_array * (1 - alpha) + layer_array * alpha
elif blend_mode == "multiply":
result = bg_array * layer_array * alpha + bg_array * (1 - alpha)
elif blend_mode == "screen":
result = 1 - (1 - bg_array) * (1 - layer_array) * alpha + bg_array * (1 - alpha)
elif blend_mode == "overlay":
# Simplified overlay
mask = bg_array < 0.5
result = np.where(mask,
2 * bg_array * layer_array * alpha + bg_array * (1 - alpha),
1 - 2 * (1 - bg_array) * (1 - layer_array) * alpha + bg_array * (1 - alpha))
elif blend_mode == "addition":
result = np.clip(bg_array + layer_array * alpha, 0, 1)
elif blend_mode == "subtract":
result = np.clip(bg_array - layer_array * alpha, 0, 1)
elif blend_mode == "difference":
result = np.abs(bg_array - layer_array) * alpha + bg_array * (1 - alpha)
elif blend_mode == "darken":
result = np.minimum(bg_array, layer_array) * alpha + bg_array * (1 - alpha)
elif blend_mode == "lighten":
result = np.maximum(bg_array, layer_array) * alpha + bg_array * (1 - alpha)
else:
# Default to normal blend for unsupported modes
result = bg_array * (1 - alpha) + layer_array * alpha
# Convert back to PIL image
result = np.clip(result * 255, 0, 255).astype(np.uint8)
return Image.fromarray(result, 'RGBA')
def transform_image_with_rotation(self, image, mask, rotate, transform_method, anti_aliasing):
"""
Apply rotation and other transforms to image and mask.
"""
if rotate == 0:
return image, mask
# Convert transform method to PIL format
resample_map = {
'nearest': Image.NEAREST,
'bilinear': Image.BILINEAR,
'bicubic': Image.BICUBIC,
'lanczos': Image.LANCZOS,
'box': Image.BOX,
'hamming': Image.HAMMING,
}
resample = resample_map.get(transform_method, Image.LANCZOS)
# Apply rotation
rotated_image = image.rotate(rotate, resample=resample, expand=True)
rotated_mask = mask.rotate(rotate, resample=Image.BILINEAR, expand=True)
return rotated_image, rotated_mask
def image_blend_advance_v3(self, layer_image, invert_mask, blend_mode, opacity,
x_percent, y_percent, mirror, scale, aspect_ratio, rotate,
transform_method, anti_aliasing, background_image=None, layer_mask=None):
"""
Advanced image blending with transforms and blend modes.
"""
# If background image is empty, create transparent background for each layer image
if background_image is None:
background_image = []
for l in layer_image:
layer_pil = tensor2pil(l)
bg = Image.new('RGBA', (layer_pil.width, layer_pil.height), (0, 0, 0, 0))
background_image.append(pil2tensor(bg))
b_images = []
l_images = []
l_masks = []
ret_images = []
ret_masks = []
# Prepare background images
for b in background_image:
b_images.append(torch.unsqueeze(b, 0))
# Prepare layer images and extract alpha masks
for l in layer_image:
l_images.append(torch.unsqueeze(l, 0))
layer_pil = tensor2pil(l)
if layer_pil.mode == 'RGBA':
l_masks.append(layer_pil.split()[-1])
else:
l_masks.append(Image.new('L', layer_pil.size, 'white'))
# Use provided layer masks if available
if layer_mask is not None:
if layer_mask.dim() == 2:
layer_mask = torch.unsqueeze(layer_mask, 0)
l_masks = []
for m in layer_mask:
if invert_mask:
m = 1 - m
l_masks.append(tensor2pil(torch.unsqueeze(m, 0)).convert('L'))
# Process each image in the batch
max_batch = max(len(b_images), len(l_images), len(l_masks))
for i in range(max_batch):
background = b_images[i] if i < len(b_images) else b_images[-1]
layer = l_images[i] if i < len(l_images) else l_images[-1]
mask = l_masks[i] if i < len(l_masks) else l_masks[-1]
# Convert to PIL images
canvas = tensor2pil(background).convert('RGBA')
layer_pil = tensor2pil(layer)
# Ensure mask matches layer size
if mask.size != layer_pil.size:
mask = Image.new('L', layer_pil.size, 'white')
print(f"Warning: {self.NODE_NAME} mask size mismatch, using white mask!")
# Store original dimensions
orig_layer_width = layer_pil.width
orig_layer_height = layer_pil.height
mask = mask.convert("L")
# Apply transforms
target_layer_width = int(orig_layer_width * scale)
target_layer_height = int(orig_layer_height * scale * aspect_ratio)
# Apply mirroring
if mirror == 'horizontal':
layer_pil = layer_pil.transpose(Image.FLIP_LEFT_RIGHT)
mask = mask.transpose(Image.FLIP_LEFT_RIGHT)
elif mirror == 'vertical':
layer_pil = layer_pil.transpose(Image.FLIP_TOP_BOTTOM)
mask = mask.transpose(Image.FLIP_TOP_BOTTOM)
# Apply scaling
if target_layer_width != orig_layer_width or target_layer_height != orig_layer_height:
resample_map = {
'nearest': Image.NEAREST,
'bilinear': Image.BILINEAR,
'bicubic': Image.BICUBIC,
'lanczos': Image.LANCZOS,
'box': Image.BOX,
'hamming': Image.HAMMING,
}
resample = resample_map.get(transform_method, Image.LANCZOS)
layer_pil = layer_pil.resize((target_layer_width, target_layer_height), resample)
mask = mask.resize((target_layer_width, target_layer_height), Image.BILINEAR)
# Apply rotation
if rotate != 0:
layer_pil, mask = self.transform_image_with_rotation(
layer_pil, mask, rotate, transform_method, anti_aliasing
)
# Calculate position
x = int(canvas.width * x_percent / 100 - layer_pil.width / 2)
y = int(canvas.height * y_percent / 100 - layer_pil.height / 2)
# Create composition
comp_canvas = copy.copy(canvas)
comp_mask = Image.new("L", comp_canvas.size, color='black')
# Paste layer at calculated position
if x >= 0 and y >= 0 and x + layer_pil.width <= canvas.width and y + layer_pil.height <= canvas.height:
# Layer fits completely within canvas
comp_canvas.paste(layer_pil, (x, y))
comp_mask.paste(mask, (x, y))
else:
# Handle partial overlap or out-of-bounds
# Create a temporary canvas to handle positioning
temp_canvas = Image.new('RGBA', canvas.size, (0, 0, 0, 0))
temp_mask = Image.new('L', canvas.size, 0)
# Calculate clipping bounds
paste_x = max(0, x)
paste_y = max(0, y)
crop_x = max(0, -x)
crop_y = max(0, -y)
crop_w = min(layer_pil.width - crop_x, canvas.width - paste_x)
crop_h = min(layer_pil.height - crop_y, canvas.height - paste_y)
if crop_w > 0 and crop_h > 0:
layer_cropped = layer_pil.crop((crop_x, crop_y, crop_x + crop_w, crop_y + crop_h))
mask_cropped = mask.crop((crop_x, crop_y, crop_x + crop_w, crop_y + crop_h))
temp_canvas.paste(layer_cropped, (paste_x, paste_y))
temp_mask.paste(mask_cropped, (paste_x, paste_y))
comp_canvas = temp_canvas
comp_mask = temp_mask
# Apply blend mode
if blend_mode != "normal":
comp_canvas = self.apply_blend_mode(canvas, comp_canvas, blend_mode, opacity)
else:
# Simple alpha blending for normal mode
alpha = opacity / 100.0
comp_canvas = Image.blend(canvas, comp_canvas, alpha)
# Final composition with mask
canvas.paste(comp_canvas, mask=comp_mask)
ret_images.append(pil2tensor(canvas))
ret_masks.append(image2mask(comp_mask))
print(f"{self.NODE_NAME} Processed {len(ret_images)} image(s).")
return (torch.cat(ret_images, dim=0), torch.cat(ret_masks, dim=0))
# Node registration
NODE_CLASS_MAPPINGS = {
"ImageBlendAdvanceV3_UTK": ImageBlendAdvanceV3_UTK,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ImageBlendAdvanceV3_UTK": "Image Blend Advance V3 (UTK)",
}
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "universaltoolkit"
description = "A comprehensive toolkit based on ComfyUI, providing image, mask, audio, and tools nodes, fully modular and v3 compatible."
version = "1.3.6"
version = "1.3.7"
license = {file = "LICENSE"}
dependencies = [
"torch",