From 2dface4604ac2248ae3a683a2fe05ed68119a40c Mon Sep 17 00:00:00 2001 From: Cyber Dick Lang <286878701@qq.com> Date: Mon, 22 Sep 2025 13:48:04 +0800 Subject: [PATCH] =?UTF-8?q?v1.3.7:=20=E6=96=B0=E5=A2=9EImage=20Blend=20Adv?= =?UTF-8?q?ance=20V3=E8=8A=82=E7=82=B9=E5=92=8CCrop=20By=20Mask=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增Image Blend Advance V3 (UTK)节点:高级图像混合与变换功能 - 支持17种混合模式:normal、multiply、screen、overlay等专业混合效果 - 完整变换功能:位置、缩放、宽高比、旋转、镜像翻转控制 - 6种插值方法:lanczos、bicubic、bilinear等高质量缩放算法 - 智能边界处理:自动处理图层超出画布的情况 - 自动背景生成:未提供背景时自动创建透明背景 - Alpha通道支持:自动提取和处理RGBA图像的透明度 - 增强Crop By Mask (UTK)节点:新增remaining_area输出 - remaining_area显示被裁剪区域:白色矩形标记已处理区域 --- __init__.py | 27 ++- nodes/image/crop_by_mask.py | 17 +- nodes/image/image_blend_advance_v3.py | 324 ++++++++++++++++++++++++++ pyproject.toml | 2 +- 4 files changed, 367 insertions(+), 3 deletions(-) create mode 100644 nodes/image/image_blend_advance_v3.py diff --git a/__init__.py b/__init__.py index 0ae5e5a..870cdd6 100644 --- a/__init__.py +++ b/__init__.py @@ -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", diff --git a/nodes/image/crop_by_mask.py b/nodes/image/crop_by_mask.py index 0e820a2..ca64fdb 100644 --- a/nodes/image/crop_by_mask.py +++ b/nodes/image/crop_by_mask.py @@ -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), ) diff --git a/nodes/image/image_blend_advance_v3.py b/nodes/image/image_blend_advance_v3.py new file mode 100644 index 0000000..45baf79 --- /dev/null +++ b/nodes/image/image_blend_advance_v3.py @@ -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)", +} diff --git a/pyproject.toml b/pyproject.toml index f2e2a46..fb31f47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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",