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": False}), "位置": (["外描边", "内描边", "居中描边"], {"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): """将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) # 默认反转遮罩(遮罩接入时默认就是反转状态) 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时忽略遮罩输入) 位置: 描边位置("外描边", "内描边", "居中描边") 大小: 描边大小(像素) 不透明度: 描边不透明度(1-100) 描边颜色: 描边颜色(COLOR类型,如"#FFFFFF") 遮罩: 可选的遮罩tensor,用于定义透明区域 Returns: 描边后的图片tensor """ try: # 批处理 batch_size = 图片.shape[0] results = [] for i in range(batch_size): # 获取对应的遮罩(如果有且未关闭遮罩功能) current_mask = None if not 关闭遮罩 and 遮罩 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" }