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:
+26
-1
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user