diff --git a/README-EN.md b/README-EN.md index d533fe6..bb9312f 100644 --- a/README-EN.md +++ b/README-EN.md @@ -25,7 +25,6 @@ | **DD Sampling Optimizer** | Model first-time sampling speed optimizer that effectively eliminates first sampling delay, improving workflow response speed and user experience | ![Sampling Optimizer](https://github.com/user-attachments/assets/53a74ea8-2a37-479c-8ff9-99c9ceac345c) | | **DD Image To Video** | Efficient image to video frame converter supporting batch processing and multiple output formats, providing convenience for video generation workflows | ![Image To Video Interface](https://github.com/user-attachments/assets/66c05a9c-c33b-4813-b434-d3c5928067c5) | | **DD Advanced Fusion** | Powerful image and video fusion processor supporting multiple fusion algorithms and parameter adjustments for professional-grade image composition effects | ![Advanced Fusion Demo](https://github.com/user-attachments/assets/2a50614f-1911-4fd8-bc2e-8d2bece91e73) | -| **DD Color Background Generator** | Advanced color background generator supporting various color modes, gradient effects, and layer controls, providing rich background options for image creation | ![Color Background Generator Interface](https://github.com/user-attachments/assets/141b1585-0d02-47f1-9d51-2d12eccc6403) | | **DD Dimension Calculator** | Minimalist image dimension calculator providing precise dimension calculation and ratio adjustment functions, ensuring output images meet expected specifications | ![Dimension Calculator](https://github.com/user-attachments/assets/f3b670d6-a471-4851-a2bf-49b8f174d83e) | | **DD Simple Latent** | Simplified latent space generator providing quick and convenient latent space creation functionality, optimizing workflow node connections | ![Simple Latent](https://github.com/user-attachments/assets/ca00fb32-aa48-4e18-9a60-56b1c6cbda9c) | | **DD Image Uniform Size** | Multi-functional image and video size unification processor supporting batch processing and intelligent scaling, ensuring output content size consistency | ![Image Uniform Size](https://github.com/user-attachments/assets/c96fbfa0-9da4-4641-a08b-6ce5699dfae3) | @@ -165,13 +164,11 @@ This extension now supports ComfyUI's native internationalization (i18n) system - Added simple latent node - **v1.0.1** (2025-02-19) - - Optimized color background generator node - Added layer control features - Improved mask blending system - **v1.0.0** (2025-02-17) - Initial release - - Added color background generator node - Added dimension calculator node - Added image to video frame node - Full Chinese interface support diff --git a/README.md b/README.md index b22ee0d..b2d82d4 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,6 @@ | **DD 采样优化器** | 模型首次采样速度优化器,有效消除首次采样延迟,提升工作流响应速度和用户体验 | ![采样优化](https://github.com/user-attachments/assets/53a74ea8-2a37-479c-8ff9-99c9ceac345c) | | **DD 图片转视频帧** | 高效的图片转视频帧转换器,支持批量处理和多种输出格式,为视频生成工作流提供便利 | ![图片转视频帧界面](https://github.com/user-attachments/assets/66c05a9c-c33b-4813-b434-d3c5928067c5) | | **DD 高级融合** | 强大的图像和视频融合处理器,支持多种融合算法和参数调节,实现专业级的图像合成效果 | ![高级融合效果展示](https://github.com/user-attachments/assets/2a50614f-1911-4fd8-bc2e-8d2bece91e73) | -| **DD 颜色背景生成器** | 高级颜色背景生成器,支持多种颜色模式、渐变效果和图层控制,为图像创作提供丰富的背景选择 | ![颜色背景生成器界面](https://github.com/user-attachments/assets/141b1585-0d02-47f1-9d51-2d12eccc6403) | | **DD 尺寸计算器** | 极简的图像尺寸计算器,提供精确的尺寸计算和比例调整功能,确保输出图像符合预期规格 | ![尺寸](https://github.com/user-attachments/assets/f3b670d6-a471-4851-a2bf-49b8f174d83e) | | **DD 极简Latent** | 简化的Latent空间生成器,提供快速便捷的潜在空间创建功能,优化工作流节点连接 | ![极简](https://github.com/user-attachments/assets/ca00fb32-aa48-4e18-9a60-56b1c6cbda9c) | | **DD 图像统一尺寸** | 多功能图像和视频尺寸统一处理器,支持批量处理和智能缩放,确保输出内容尺寸一致性 | ![图像](https://github.com/user-attachments/assets/c96fbfa0-9da4-4641-a08b-6ce5699dfae3) | @@ -33,6 +32,7 @@ | **DD 限制图像大小** | 智能图像尺寸限制器,确保图像在指定的最大和最小尺寸范围内,防止内存溢出和性能问题 | ![限制图像大小界面](https://github.com/user-attachments/assets/d2fac125-fad3-4f51-9b91-39d0be4c7753) | | **DD 切换器系列** | 包含条件切换器、Latent切换器、模型切换器等多种切换节点,简化工作流程,提高处理灵活性 | ![切换器系列界面](https://github.com/user-attachments/assets/54690c0c-3627-4970-9bc0-ef58ca4be2f7) | | **DD 视频首尾帧输出** | 专业的视频帧提取工具,可以精确提取视频的第一帧和最后一帧,为视频处理工作流提供便利 | ![首尾](https://github.com/user-attachments/assets/243c4809-8c83-43a3-9c2b-768f16644ded) | +| **DD 图片描边** | 智能图片描边工具,支持透明图和普通图片的描边处理。提供外描边、内描边、居中描边三种模式,支持自定义颜色、大小、透明度和遮罩功能 | ![图片描边效果](图片描边截图链接) | ## 🎯 扩展功能一览 @@ -64,11 +64,15 @@ ## 📈 版本历史 ### 最新版本 +- **v2.7.0**(2025-07-26) + - 🎨 新增DD图片描边节点,支持透明图和普通图片的智能描边处理 + - 🎯 支持外描边、内描边、居中描边三种模式,适应不同使用场景 + - 🎮 支持自定义描边大小、透明度,完美兼容遮罩功能 + +### 重要更新 - **v2.6.0**(2025-07-06) - 🔧 尝试修复界面布局面板的多系统兼容性BUG - ➕ 添加了视频首尾帧输出节点 - -### 重要更新 - **v2.5.1**(2025-07-02) - 🎯 提示词管理面板完美嵌入大部分官方CLIP节点与第三方CLIP节点 - 🔗 完美兼容 ComfyUI-Easy-Use 与 ComfyUI-Fast-Use @@ -154,13 +158,11 @@ - 添加极简Latent节点 - **v1.0.1**(2025-02-19) - - 优化颜色背景生成器节点 - 添加图层控制功能 - 改进遮罩混合系统 - **v1.0.0**(2025-02-17) - 初始发布 - - 添加颜色背景生成器节点 - 添加尺寸计算器节点 - 添加图片转视频帧节点 - 完整中文界面支持 diff --git a/__init__.py b/__init__.py index 8988201..140c5b3 100644 --- a/__init__.py +++ b/__init__.py @@ -1,4 +1,3 @@ -from .node.color_generator import NODE_CLASS_MAPPINGS as COLOR_NODES from .node.dimension_calculator import NODE_CLASS_MAPPINGS as DIMENSION_NODES from .node.image_to_video import NODE_CLASS_MAPPINGS as VIDEO_NODES from .node.video_frame_extractor import NODE_CLASS_MAPPINGS as VIDEO_FRAME_EXTRACTOR_NODES @@ -12,13 +11,13 @@ from .node.image_size_limiter import NODE_CLASS_MAPPINGS as SIZE_LIMITER_NODES from .node.model_switcher import NODE_CLASS_MAPPINGS as MODEL_SWITCHER_NODES from .node.condition_switcher import NODE_CLASS_MAPPINGS as CONDITION_SWITCHER_NODES from .node.latent_switcher import NODE_CLASS_MAPPINGS as LATENT_SWITCHER_NODES +from .node.image_stroke import NODE_CLASS_MAPPINGS as IMAGE_STROKE_NODES # 导入扩展功能API from .extensions.Prompt_Manager.prompt_api import prompt_manager_api # 节点类映射 NODE_CLASS_MAPPINGS = { - **COLOR_NODES, **DIMENSION_NODES, **VIDEO_NODES, **VIDEO_FRAME_EXTRACTOR_NODES, @@ -32,11 +31,11 @@ NODE_CLASS_MAPPINGS = { **MODEL_SWITCHER_NODES, **CONDITION_SWITCHER_NODES, **LATENT_SWITCHER_NODES, + **IMAGE_STROKE_NODES, } # 节点显示名称映射(使用英文作为默认,中文通过locales提供) NODE_DISPLAY_NAME_MAPPINGS = { - "DD-ColorBackgroundGenerator": "DD Color Background Generator", "DD-DimensionCalculator": "DD Dimension Calculator", "DD-ImageToVideo": "DD Image To Video", "DD-VideoFrameExtractor": "DD Video Frame Extractor", @@ -50,6 +49,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "DD-ModelSwitcher": "DD Model Switcher", "DD-ConditionSwitcher": "DD Condition Switcher", "DD-LatentSwitcher": "DD Latent Switcher", + "DD-ImageStroke": "DD Image Stroke", } __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'prompt_manager_api'] diff --git a/locales/en/nodeDefs.json b/locales/en/nodeDefs.json index 774a70b..6fc0946 100644 --- a/locales/en/nodeDefs.json +++ b/locales/en/nodeDefs.json @@ -1,32 +1,4 @@ { - "DD-ColorBackgroundGenerator": { - "display_name": "DD Color Background Generator", - "description": "Generate solid color background images", - "inputs": { - "图层设置": { - "name": "Layer Settings", - "tooltip": "Control layer properties" - }, - "宽度": { - "name": "Width", - "tooltip": "Image width in pixels" - }, - "高度": { - "name": "Height", - "tooltip": "Image height in pixels" - }, - "颜色模式": { - "name": "Color Mode", - "tooltip": "Select color representation mode (HSLA, HSVA, CMYK)" - } - }, - "outputs": { - "0": { - "name": "IMAGE", - "tooltip": "Generated background image" - } - } - }, "DD-DimensionCalculator": { "display_name": "DD Dimension Calculator", "description": "Calculate and adjust image dimension ratios", @@ -335,6 +307,46 @@ } } }, + "DD-ImageStroke": { + "display_name": "DD Image Stroke", + "description": "Add stroke effects to images, supports transparent and regular images", + "inputs": { + "图片": { + "name": "Image", + "tooltip": "Input image to add stroke effect" + }, + "反转遮罩": { + "name": "Invert Mask", + "tooltip": "Whether to invert mask effect, enabled by default" + }, + "位置": { + "name": "Position", + "tooltip": "Stroke position: Outer stroke, Inner stroke, or Center stroke" + }, + "大小": { + "name": "Size", + "tooltip": "Stroke size in pixels (1-200)" + }, + "不透明度": { + "name": "Opacity", + "tooltip": "Stroke opacity (1-100)" + }, + "描边颜色": { + "name": "Stroke Color", + "tooltip": "Stroke color, click to open color picker" + }, + "遮罩": { + "name": "Mask", + "tooltip": "Optional mask to define transparent areas of the image" + } + }, + "outputs": { + "0": { + "name": "Stroked Image", + "tooltip": "Image with stroke effect applied" + } + } + }, "DD-VideoFrameExtractor": { "display_name": "DD Video Frame Extractor", "description": "Extract first or last frame from video", diff --git a/locales/zh/nodeDefs.json b/locales/zh/nodeDefs.json index 608b995..d7b3177 100644 --- a/locales/zh/nodeDefs.json +++ b/locales/zh/nodeDefs.json @@ -1,32 +1,4 @@ { - "DD-ColorBackgroundGenerator": { - "display_name": "DD 颜色背景生成器", - "description": "生成纯色背景图像", - "inputs": { - "图层设置": { - "name": "图层设置", - "tooltip": "控制图层的属性设置" - }, - "宽度": { - "name": "宽度", - "tooltip": "图像宽度(像素)" - }, - "高度": { - "name": "高度", - "tooltip": "图像高度(像素)" - }, - "颜色模式": { - "name": "颜色模式", - "tooltip": "选择颜色表示模式(HSLA, HSVA, CMYK)" - } - }, - "outputs": { - "0": { - "name": "图像", - "tooltip": "生成的背景图像" - } - } - }, "DD-DimensionCalculator": { "display_name": "DD 尺寸计算器", "description": "计算和调整图像尺寸比例", @@ -335,6 +307,46 @@ } } }, + "DD-ImageStroke": { + "display_name": "DD 图片描边", + "description": "为图片添加描边效果,支持透明图和普通图片", + "inputs": { + "图片": { + "name": "图片", + "tooltip": "需要添加描边的输入图片" + }, + "反转遮罩": { + "name": "反转遮罩", + "tooltip": "是否反转遮罩效果,默认开启" + }, + "位置": { + "name": "位置", + "tooltip": "描边位置:外描边、内描边或居中描边" + }, + "大小": { + "name": "大小", + "tooltip": "描边的像素大小(1-200)" + }, + "不透明度": { + "name": "不透明度", + "tooltip": "描边的不透明度(1-100)" + }, + "描边颜色": { + "name": "描边颜色", + "tooltip": "描边的颜色,点击可打开颜色选择器" + }, + "遮罩": { + "name": "遮罩", + "tooltip": "可选的遮罩,用于定义图片的透明区域" + } + }, + "outputs": { + "0": { + "name": "描边图片", + "tooltip": "添加描边效果后的图片" + } + } + }, "DD-VideoFrameExtractor": { "display_name": "DD 视频首尾帧输出", "description": "从视频中提取首帧或尾帧图像", diff --git a/node/color_generator.py b/node/color_generator.py deleted file mode 100644 index 7cc1435..0000000 --- a/node/color_generator.py +++ /dev/null @@ -1,239 +0,0 @@ -import torch -import numpy as np -from enum import Enum - -class ColorMode(Enum): - HSLA = "HSLA颜色" # 色相-饱和度-亮度-透明度 - HSVA = "HSVA颜色" # 色相-饱和度-明度-透明度 - CMYK = "CMYK颜色" # 青-品红-黄-黑 - -class ColorBackgroundGenerator: - """Current Date and Time (UTC): 2025-02-19 13:59:10 - Current User's Login: 1761696257""" - - @classmethod - def INPUT_TYPES(s): - return { - "required": { - "图层设置": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 1.0}), - "宽度": ("INT", {"default": 512, "min": 64, "max": 8192}), - "高度": ("INT", {"default": 512, "min": 64, "max": 8192}), - "颜色模式": (list(mode.value for mode in ColorMode), {"default": "HSLA颜色"}), - # 通用透明度 - "透明度": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.1}), - # HSLA inputs - "HSLA色相": ("INT", {"default": 0, "min": 0, "max": 360}), - "HSLA饱和度": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.1}), - "HSLA亮度": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.1}), - # HSVA inputs - "HSVA色相": ("INT", {"default": 0, "min": 0, "max": 360}), - "HSVA饱和度": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.1}), - "HSVA明度": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.1}), - # CMYK inputs - "CMYK青色": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.1}), - "CMYK品红": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.1}), - "CMYK黄色": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.1}), - "CMYK黑色": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.1}), - # 颜色选择器控制 - "使用取色器": ("BOOLEAN", {"default": False}), - "颜色选择器": ("COLOR", {"default": "#FFFFFF"}), - }, - "optional": { - # 图片输入 - "输入图片": ("IMAGE",), - "遮罩": ("MASK",), - } - } - - RETURN_TYPES = ("IMAGE", "INT", "INT", "INT", "FLOAT") - RETURN_NAMES = ("图像", "红", "绿", "蓝", "透明度") - FUNCTION = "generate_background" - CATEGORY = "🍺DD系列节点" - - def hex_to_rgba(self, hex_color): - """将十六进制颜色转换为RGBA,忽略原始透明度""" - hex_color = hex_color.lstrip('#') - if len(hex_color) == 8: - r, g, b, _ = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4, 6)) - return r, g, b - else: - r, g, b = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) - return r, g, b - - def hsla_to_rgba(self, h, s, l, a=1.0): - h = h/360 - def hue_to_rgb(p, q, t): - if t < 0: t += 1 - if t > 1: t -= 1 - if t < 1/6: return p + (q - p) * 6 * t - if t < 1/2: return q - if t < 2/3: return p + (q - p) * (2/3 - t) * 6 - return p - - if s == 0: - r = g = b = l - else: - q = l * (1 + s) if l < 0.5 else l + s - l * s - p = 2 * l - q - r = hue_to_rgb(p, q, h + 1/3) - g = hue_to_rgb(p, q, h) - b = hue_to_rgb(p, q, h - 1/3) - - return int(r * 255), int(g * 255), int(b * 255), a - - def hsva_to_rgba(self, h, s, v, a=1.0): - h = h/360 - i = int(h*6) - f = h*6 - i - p = v * (1-s) - q = v * (1-f*s) - t = v * (1-(1-f)*s) - - if i % 6 == 0: - r, g, b = v, t, p - elif i % 6 == 1: - r, g, b = q, v, p - elif i % 6 == 2: - r, g, b = p, v, t - elif i % 6 == 3: - r, g, b = p, q, v - elif i % 6 == 4: - r, g, b = t, p, v - else: - r, g, b = v, p, q - - return int(r * 255), int(g * 255), int(b * 255), a - - def cmyk_to_rgba(self, c, m, y, k, a=1.0): - r = int(255 * (1-c) * (1-k)) - g = int(255 * (1-m) * (1-k)) - b = int(255 * (1-y) * (1-k)) - return r, g, b, a - - def ensure_rgba(self, image): - """确保图像为RGBA格式""" - if image.shape[-1] == 3: # RGB格式 - # 添加alpha通道(完全不透明) - alpha = torch.ones((*image.shape[:-1], 1), device=image.device) - return torch.cat([image, alpha], dim=-1) - return image - - def adjust_mask_size(self, mask, height, width): - """调整遮罩大小的辅助函数""" - if mask.shape[-2:] != (height, width): # 使用最后两个维度比较尺寸 - # ComfyUI中的MASK通常是2D或3D的,需要正确处理维度 - if len(mask.shape) == 2: - mask = mask.unsqueeze(0).unsqueeze(0) # (H,W) -> (1,1,H,W) - elif len(mask.shape) == 3: - mask = mask.unsqueeze(1) # (B,H,W) -> (B,1,H,W) - - # 调整大小 - mask = torch.nn.functional.interpolate( - mask, - size=(height, width), - mode='nearest' - ) - - # 恢复原始维度 - if len(mask.shape) == 4: - mask = mask.squeeze(0).squeeze(0) # (1,1,H,W) -> (H,W) - return mask - - def get_output_size(self, 宽度, 高度, 输入图片=None): - """确定输出尺寸""" - output_height, output_width = 高度, 宽度 - - # 如果有输入图片,使用输入图片的尺寸 - if 输入图片 is not None: - if len(输入图片.shape) == 4: # BCHW或BHWC格式 - if 输入图片.shape[1] == 3 or 输入图片.shape[1] == 4: # BCHW格式 - output_height = 输入图片.shape[2] - output_width = 输入图片.shape[3] - else: # BHWC格式 - output_height = 输入图片.shape[1] - output_width = 输入图片.shape[2] - - return output_height, output_width - - def generate_background(self, 图层设置, 宽度, 高度, 颜色模式, - 透明度, HSLA色相, HSLA饱和度, HSLA亮度, - HSVA色相, HSVA饱和度, HSVA明度, - CMYK青色, CMYK品红, CMYK黄色, CMYK黑色, - 使用取色器, 颜色选择器, - 输入图片=None, 遮罩=None): - - # 确定输出尺寸 - output_height, output_width = self.get_output_size(宽度, 高度, 输入图片) - - # 生成颜色值 - if 使用取色器: - r, g, b = self.hex_to_rgba(颜色选择器) - a = 透明度 - else: - if 颜色模式 == "HSLA颜色": - r, g, b, a = self.hsla_to_rgba(HSLA色相, HSLA饱和度, HSLA亮度, 透明度) - elif 颜色模式 == "HSVA颜色": - r, g, b, a = self.hsva_to_rgba(HSVA色相, HSVA饱和度, HSVA明度, 透明度) - elif 颜色模式 == "CMYK颜色": - r, g, b, a = self.cmyk_to_rgba(CMYK青色, CMYK品红, CMYK黄色, CMYK黑色, 透明度) - - # 确保颜色值在有效范围内 - r = max(0, min(255, r)) - g = max(0, min(255, g)) - b = max(0, min(255, b)) - a = max(0.0, min(1.0, a)) - - # 创建颜色图层 (BHWC格式) - color = np.array([r, g, b, int(a * 255)]) / 255.0 - color_image = np.ones((1, output_height, output_width, 4)) * color - color_image = torch.from_numpy(color_image).float() - - # 如果没有输入图片,直接返回颜色图层 - if 输入图片 is None: - return (color_image, r, g, b, a) - - # 处理输入图片 - if 输入图片.shape[1] == 3 or 输入图片.shape[1] == 4: # 如果是BCHW格式 - 输入图片 = 输入图片.permute(0, 2, 3, 1) - 输入图片 = self.ensure_rgba(输入图片) - - # 调整输入图片大小 - if 输入图片.shape[1:3] != (output_height, output_width): - 输入图片 = torch.nn.functional.interpolate( - 输入图片.permute(0, 3, 1, 2), - size=(output_height, output_width), - mode='bilinear', - align_corners=False - ).permute(0, 2, 3, 1) - - # 处理遮罩 - if 遮罩 is not None: - 遮罩 = self.adjust_mask_size(遮罩, output_height, output_width) - 遮罩 = 遮罩.view(1, output_height, output_width, 1).expand(-1, -1, -1, 4) - - # 根据图层设置决定混合方式 - if 图层设置 == 0: # 输入图片在底层 - # 遮罩区域显示颜色图层,非遮罩区域显示输入图片 - result = 输入图片 * (1 - 遮罩) + color_image * 遮罩 - else: # 输入图片在顶层 - # 遮罩区域显示输入图片,非遮罩区域显示颜色图层 - result = color_image * (1 - 遮罩) + 输入图片 * 遮罩 - else: - # 如果没有遮罩,根据图层设置决定叠加顺序 - if 图层设置 == 0: # 输入图片在底层 - alpha = 输入图片[..., 3:4] - result = color_image * alpha + 输入图片 * (1 - alpha) - else: # 输入图片在顶层 - alpha = color_image[..., 3:4] - result = 输入图片 * alpha + color_image * (1 - alpha) - - return (result, r, g, b, a) - -NODE_CLASS_MAPPINGS = { - "DD-ColorBackgroundGenerator": ColorBackgroundGenerator -} - -# 节点显示名称映射 - 使用英文(中文通过locales提供) -NODE_DISPLAY_NAME_MAPPINGS = { - "DD-ColorBackgroundGenerator": "DD Color Background Generator" -} diff --git a/node/image_stroke.py b/node/image_stroke.py new file mode 100644 index 0000000..451dccc --- /dev/null +++ b/node/image_stroke.py @@ -0,0 +1,431 @@ +import torch +import numpy as np +from PIL import Image, ImageOps, ImageFilter, ImageDraw +import cv2 + +class DDImageStroke: + """ + DD 图片描边 - 为图片添加描边效果 + 支持透明图和普通图片的边缘描边,可自定义颜色、大小、位置和透明度 + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "图片": ("IMAGE",), + "反转遮罩": ("BOOLEAN", {"default": True}), + "位置": (["外描边", "内描边", "居中描边"], {"default": "外描边"}), + "大小": ("INT", {"default": 5, "min": 1, "max": 200, "step": 1}), + "不透明度": ("INT", {"default": 100, "min": 1, "max": 100, "step": 1}), + "描边颜色": ("COLOR", {"default": "#FFFFFF"}), + }, + "optional": { + "遮罩": ("MASK",), + } + } + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("描边图片",) + FUNCTION = "add_stroke" + CATEGORY = "🍺DD系列节点" + + def hex_to_rgb(self, hex_color): + """将十六进制颜色转换为RGB""" + try: + hex_color = hex_color.lstrip('#') + if len(hex_color) == 6: + return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) + elif len(hex_color) == 3: + # 支持短格式的十六进制颜色,如 #FFF + return tuple(int(hex_color[i], 16) * 17 for i in range(3)) + else: + return (255, 255, 255) # 格式错误时返回白色 + except: + return (255, 255, 255) # 解析失败时返回白色 + + def int_to_rgb(self, color_int): + """将整数颜色值转换为RGB""" + try: + # 确保颜色值在有效范围内 + color_int = max(0, min(0xFFFFFF, int(color_int))) + + # 提取RGB分量 + r = (color_int >> 16) & 0xFF + g = (color_int >> 8) & 0xFF + b = color_int & 0xFF + + return (r, g, b) + except: + return (255, 255, 255) # 解析失败时返回白色 + + def tensor_to_pil(self, tensor, mask=None, invert_mask=True): + """将tensor转换为PIL图像,支持遮罩""" + # tensor格式: [batch, height, width, channels] + if len(tensor.shape) == 4: + tensor = tensor[0] # 取第一张图片 + + # 转换为numpy数组并调整范围到0-255 + np_image = (tensor.cpu().numpy() * 255).astype(np.uint8) + + # 创建PIL图像 + if np_image.shape[2] == 3: # RGB + pil_image = Image.fromarray(np_image, 'RGB') + elif np_image.shape[2] == 4: # RGBA + pil_image = Image.fromarray(np_image, 'RGBA') + else: # 灰度图 + pil_image = Image.fromarray(np_image[:, :, 0], 'L') + + # 判断图像类型以决定处理方式 + is_transparent_image = False + + # 如果提供了遮罩,将RGB转换为RGBA(这是透明图逻辑) + if mask is not None: + is_transparent_image = True + if pil_image.mode == 'RGB': + # 遮罩格式: [batch, height, width] 或 [height, width] + if len(mask.shape) == 3: + mask = mask[0] # 取第一个遮罩 + + # 转换遮罩为numpy数组 + mask_np = (mask.cpu().numpy() * 255).astype(np.uint8) + + # 根据反转设置处理遮罩 + if invert_mask: + mask_np = 255 - mask_np # 反转遮罩 + + # 创建RGBA图像 + pil_image = pil_image.convert('RGBA') + + # 应用遮罩作为alpha通道 + pil_image.putalpha(Image.fromarray(mask_np, 'L')) + elif pil_image.mode == 'RGBA': + # 原本就是RGBA图像(透明图) + is_transparent_image = True + else: + # 普通RGB图片,保持RGB格式 + is_transparent_image = False + + return pil_image, is_transparent_image + + def pil_to_tensor(self, pil_image): + """将PIL图像转换为tensor""" + # 根据图像模式转换 + if pil_image.mode == 'RGB': + # 普通图片保持RGB格式,添加alpha通道用于输出 + pil_image = pil_image.convert('RGBA') + elif pil_image.mode != 'RGBA': + pil_image = pil_image.convert('RGBA') + + # 转换为numpy数组 + np_image = np.array(pil_image).astype(np.float32) / 255.0 + + # 转换为tensor格式: [1, height, width, channels] + tensor = torch.from_numpy(np_image).unsqueeze(0) + + return tensor + + def create_stroke_mask_cv2(self, image, stroke_size, position): + """使用OpenCV创建描边遮罩,性能更好""" + height, width = image.size[1], image.size[0] + + # 获取alpha通道作为遮罩 + if image.mode == 'RGBA': + alpha = np.array(image.split()[-1]) + has_transparency = True + else: + # 对于普通图片,创建一个全白的遮罩代表整个图像区域 + alpha = np.full((height, width), 255, dtype=np.uint8) + has_transparency = False + + # 创建结构元素 + kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (stroke_size * 2 + 1, stroke_size * 2 + 1)) + + if position == "外描边": + if has_transparency: + # 透明图:膨胀后减去原图(传统外描边) + dilated = cv2.dilate(alpha, kernel, iterations=1) + stroke_mask = cv2.subtract(dilated, alpha) + else: + # 普通图片:外描边没有意义,返回空遮罩 + stroke_mask = np.zeros((height, width), dtype=np.uint8) + + elif position == "内描边": + if has_transparency: + # 透明图:原图减去腐蚀后的结果(传统内描边) + eroded = cv2.erode(alpha, kernel, iterations=1) + stroke_mask = cv2.subtract(alpha, eroded) + else: + # 普通图片:从边缘向内创建描边区域 + # 创建边缘遮罩:整个图像减去内缩区域 + inner_mask = np.zeros((height, width), dtype=np.uint8) + margin = stroke_size + if margin * 2 < min(width, height): # 确保有足够空间 + inner_mask[margin:height-margin, margin:width-margin] = 255 + stroke_mask = cv2.subtract(alpha, inner_mask) + + else: # 居中描边 + if has_transparency: + # 透明图:膨胀后减去腐蚀后的结果(传统居中描边) + half_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, + (max(1, stroke_size) + 1, max(1, stroke_size) + 1)) + dilated = cv2.dilate(alpha, half_kernel, iterations=1) + eroded = cv2.erode(alpha, half_kernel, iterations=1) + stroke_mask = cv2.subtract(dilated, eroded) + else: + # 普通图片:在边缘位置创建描边 + # 创建两个不同大小的内缩区域,取差值 + outer_mask = np.zeros((height, width), dtype=np.uint8) + inner_mask = np.zeros((height, width), dtype=np.uint8) + + outer_margin = max(1, stroke_size // 2) + inner_margin = stroke_size + + if outer_margin * 2 < min(width, height): + outer_mask[outer_margin:height-outer_margin, outer_margin:width-outer_margin] = 255 + if inner_margin * 2 < min(width, height): + inner_mask[inner_margin:height-inner_margin, inner_margin:width-inner_margin] = 255 + + stroke_mask = cv2.subtract(outer_mask, inner_mask) + + return Image.fromarray(stroke_mask, 'L') + + def create_stroke_for_normal_image(self, image, stroke_size, position, stroke_color): + """为普通图片(非透明图)创建描边效果""" + width, height = image.size + + # 创建一个新的RGBA图像用于绘制描边 + result = Image.new('RGBA', (width, height), (0, 0, 0, 0)) + + # 将原图粘贴到结果图像上 + if image.mode == 'RGB': + # 将RGB图像转换为RGBA并设置为完全不透明 + rgba_image = image.convert('RGBA') + result.paste(rgba_image, (0, 0)) + else: + result.paste(image, (0, 0)) + + # 根据描边位置创建描边 + draw = ImageDraw.Draw(result) + + if position == "外描边": + # 普通图片的外描边:在图像边界外绘制(这里我们扩展画布) + # 创建扩展的画布 + new_width = width + stroke_size * 2 + new_height = height + stroke_size * 2 + extended_result = Image.new('RGBA', (new_width, new_height), (0, 0, 0, 0)) + + # 先绘制描边(整个扩展区域) + extended_draw = ImageDraw.Draw(extended_result) + extended_draw.rectangle([0, 0, new_width-1, new_height-1], fill=stroke_color) + + # 再粘贴原图到中心位置 + extended_result.paste(result, (stroke_size, stroke_size)) + + return extended_result + + elif position == "内描边": + # 普通图片的内描边:在图像内部边缘绘制描边 + # 绘制描边矩形框 + for i in range(stroke_size): + draw.rectangle([i, i, width-1-i, height-1-i], outline=stroke_color, width=1) + + else: # 居中描边 + # 普通图片的居中描边:一半在内,一半在外 + half_size = stroke_size // 2 + + # 创建稍微扩展的画布 + new_width = width + half_size * 2 + new_height = height + half_size * 2 + extended_result = Image.new('RGBA', (new_width, new_height), (0, 0, 0, 0)) + + # 先粘贴原图到中心 + extended_result.paste(result, (half_size, half_size)) + + # 绘制描边 + extended_draw = ImageDraw.Draw(extended_result) + for i in range(stroke_size): + extended_draw.rectangle([i, i, new_width-1-i, new_height-1-i], outline=stroke_color, width=1) + + return extended_result + + return result + + def add_stroke(self, 图片, 反转遮罩, 位置, 大小, 不透明度, 描边颜色, 遮罩=None): + """ + 为图片添加描边效果 + + Args: + 图片: 输入图片tensor + 反转遮罩: 是否反转遮罩(True/False) + 位置: 描边位置("外描边", "内描边", "居中描边") + 大小: 描边大小(像素) + 不透明度: 描边不透明度(1-100) + 描边颜色: 描边颜色(COLOR类型,如"#FFFFFF") + 遮罩: 可选的遮罩tensor,用于定义透明区域 + + Returns: + 描边后的图片tensor + """ + try: + # 批处理 + batch_size = 图片.shape[0] + results = [] + + for i in range(batch_size): + # 获取对应的遮罩(如果有) + current_mask = None + if 遮罩 is not None: + if len(遮罩.shape) == 3 and 遮罩.shape[0] > i: + current_mask = 遮罩[i:i+1] + elif len(遮罩.shape) == 2: + current_mask = 遮罩.unsqueeze(0) + elif len(遮罩.shape) == 3 and 遮罩.shape[0] == 1: + current_mask = 遮罩 + + # 转换为PIL图像,应用遮罩(支持反转) + pil_image, is_transparent_image = self.tensor_to_pil(图片[i:i+1], current_mask, 反转遮罩) + + # 解析描边颜色(支持COLOR类型、整数和字符串格式) + if isinstance(描边颜色, str): + # COLOR类型会传入字符串格式的十六进制颜色 + stroke_rgb = self.hex_to_rgb(描边颜色) + elif isinstance(描边颜色, (int, float)): + # 向后兼容整数格式 + stroke_rgb = self.int_to_rgb(描边颜色) + else: + # 默认使用白色 + stroke_rgb = (255, 255, 255) + + # 创建描边颜色(包含透明度) + # 将1-100的不透明度转换为0-255的alpha值 + alpha_value = int((不透明度 / 100.0) * 255) + stroke_color = stroke_rgb + (alpha_value,) + + # 根据图像类型使用不同的描边逻辑 + if is_transparent_image: + # 透明图:使用传统的遮罩+合成方法 + # 确保图像有alpha通道 + if pil_image.mode != 'RGBA': + pil_image = pil_image.convert('RGBA') + + # 使用OpenCV创建描边遮罩(性能更好) + try: + stroke_mask = self.create_stroke_mask_cv2(pil_image, 大小, 位置) + except: + # 如果OpenCV方法失败,使用PIL方法作为后备 + stroke_mask = self.create_stroke_mask_pil(pil_image, 大小, 位置) + + # 创建描边图层 + stroke_layer = Image.new('RGBA', pil_image.size, (0, 0, 0, 0)) + + # 创建纯色图层 + color_layer = Image.new('RGBA', pil_image.size, stroke_color) + + # 应用描边遮罩 + stroke_layer = Image.composite(color_layer, stroke_layer, stroke_mask) + + # 根据位置决定图层顺序 + if 位置 == "外描边": + # 外描边在底层 + result = Image.alpha_composite(stroke_layer, pil_image) + else: + # 内描边和居中描边在顶层 + result = Image.alpha_composite(pil_image, stroke_layer) + else: + # 普通图片:使用直接绘制方法 + result = self.create_stroke_for_normal_image(pil_image, 大小, 位置, stroke_color) + + # 转换回tensor + result_tensor = self.pil_to_tensor(result) + results.append(result_tensor) + + # 合并批处理结果 + final_result = torch.cat(results, dim=0) + + return (final_result,) + + except Exception as e: + print(f"DD图片描边节点错误: {str(e)}") + # 出错时返回原图 + return (图片,) + + def create_stroke_mask_pil(self, image, stroke_size, position): + """使用PIL创建描边遮罩(备用方法)""" + width, height = image.size + + # 获取alpha通道作为遮罩 + if image.mode == 'RGBA': + alpha = image.split()[-1] + has_transparency = True + else: + # 对于普通图片,创建一个全白的遮罩代表整个图像区域 + alpha = Image.new('L', image.size, 255) + has_transparency = False + + # 根据位置类型创建描边遮罩 + if position == "外描边": + if has_transparency: + # 透明图:扩展原图像边界 + dilated = alpha.filter(ImageFilter.MaxFilter(stroke_size * 2 + 1)) + stroke_mask = Image.new('L', image.size, 0) + stroke_mask.paste(dilated, (0, 0)) + # 减去原图像区域 + stroke_mask = Image.composite(Image.new('L', image.size, 0), stroke_mask, alpha) + else: + # 普通图片:外描边没有意义,返回空遮罩 + stroke_mask = Image.new('L', image.size, 0) + + elif position == "内描边": + if has_transparency: + # 透明图:在原图像内部创建描边 + eroded = alpha.filter(ImageFilter.MinFilter(stroke_size * 2 + 1)) + stroke_mask = Image.composite(alpha, Image.new('L', image.size, 0), eroded) + else: + # 普通图片:从边缘向内创建描边区域 + # 创建边缘遮罩:整个图像减去内缩区域 + inner_mask = Image.new('L', image.size, 0) + margin = stroke_size + if margin * 2 < min(width, height): # 确保有足够空间 + bbox = (margin, margin, width - margin, height - margin) + inner_mask.paste(255, bbox) + # 整个图像减去内部区域 = 边缘区域 + stroke_mask = Image.composite(alpha, Image.new('L', image.size, 0), inner_mask) + + else: # 居中描边 + if has_transparency: + # 透明图:一半在内,一半在外 + half_size = max(1, stroke_size // 2) + dilated = alpha.filter(ImageFilter.MaxFilter(half_size * 2 + 1)) + eroded = alpha.filter(ImageFilter.MinFilter(half_size * 2 + 1)) + stroke_mask = Image.composite(dilated, Image.new('L', image.size, 0), eroded) + else: + # 普通图片:在边缘位置创建描边 + # 创建两个不同大小的内缩区域,取差值 + outer_mask = Image.new('L', image.size, 0) + inner_mask = Image.new('L', image.size, 0) + + outer_margin = max(1, stroke_size // 2) + inner_margin = stroke_size + + if outer_margin * 2 < min(width, height): + bbox = (outer_margin, outer_margin, width - outer_margin, height - outer_margin) + outer_mask.paste(255, bbox) + if inner_margin * 2 < min(width, height): + bbox = (inner_margin, inner_margin, width - inner_margin, height - inner_margin) + inner_mask.paste(255, bbox) + + # outer_mask - inner_mask = 边缘环形区域 + stroke_mask = Image.composite(outer_mask, Image.new('L', image.size, 0), inner_mask) + + return stroke_mask + +# 节点类映射 +NODE_CLASS_MAPPINGS = { + "DD-ImageStroke": DDImageStroke +} + +# 节点显示名称映射 +NODE_DISPLAY_NAME_MAPPINGS = { + "DD-ImageStroke": "DD Image Stroke" +} diff --git a/pyproject.toml b/pyproject.toml index f6f976f..9979eb7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "comfyui-dd-nodes" description = "Advanced utility nodes for ComfyUI with intelligent layout and animation features." -version = "2.5.1" +version = "2.7.0" license = {file = "LICENSE"} dependencies = ["git+https://github.com/Dontdrunk/ComfyUI-DD-Nodes.git"]