From ea5a653d260f614eff092b55bb47687dbd77d281 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=86=E8=B5=9B?= <> Date: Sun, 5 Jan 2025 23:51:09 +0800 Subject: [PATCH] no message --- __init__.py | 10 +- nodes/AD_ImageResize.py | 153 ++++++++++++ nodes/AD_MockupMaker.py | 317 +++++++++++++++++++++++++ nodes/AD_PosterMaker.py | 201 ++++++++++++++++ nodes/AD_PromptSaver.py | 119 ++++++++++ nodes/AddPaddingAdvanced.py | 435 +++++++++++++++++++++++++++++++++++ nodes/AddPaddingBase.py | 99 ++++++++ nodes/ComfyUI-FofrToolkit.py | 126 ++++++++++ nodes/ComfyUI-imageResize.py | 172 ++++++++++++++ nodes/__init__.py | 62 +++++ 10 files changed, 1691 insertions(+), 3 deletions(-) create mode 100644 nodes/AD_ImageResize.py create mode 100644 nodes/AD_MockupMaker.py create mode 100644 nodes/AD_PosterMaker.py create mode 100644 nodes/AD_PromptSaver.py create mode 100644 nodes/AddPaddingAdvanced.py create mode 100644 nodes/AddPaddingBase.py create mode 100644 nodes/ComfyUI-FofrToolkit.py create mode 100644 nodes/ComfyUI-imageResize.py create mode 100644 nodes/__init__.py diff --git a/__init__.py b/__init__.py index 2263342..bf5e3ea 100644 --- a/__init__.py +++ b/__init__.py @@ -1,10 +1,14 @@ from .ad_door import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS from .rp import NODE_CLASS_MAPPINGS as RHAPI_NODE_CLASS_MAPPINGS from .rp import NODE_DISPLAY_NAME_MAPPINGS as RHAPI_NODE_DISPLAY_NAME_MAPPINGS +from .nodes import NODE_CLASS_MAPPINGS as NODES_CLASS_MAPPINGS +from .nodes import NODE_DISPLAY_NAME_MAPPINGS as NODES_DISPLAY_NAME_MAPPINGS -# Update mappings to include RHAPI mappings +# Update mappings to include RHAPI mappings and nodes mappings NODE_CLASS_MAPPINGS.update(RHAPI_NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(NODES_CLASS_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(RHAPI_NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(NODES_DISPLAY_NAME_MAPPINGS) -__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] - +# WEB_DIRECTORY = "./web" +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY'] diff --git a/nodes/AD_ImageResize.py b/nodes/AD_ImageResize.py new file mode 100644 index 0000000..828325c --- /dev/null +++ b/nodes/AD_ImageResize.py @@ -0,0 +1,153 @@ +import torch +import torch.nn.functional as F +import comfy.utils + +MAX_RESOLUTION = 8192 + +class AD_ImageResize: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "image": ("IMAGE",), + "width": ("INT", { + "default": 512, + "min": 0, + "max": MAX_RESOLUTION, + "step": 1, + }), + "height": ("INT", { + "default": 512, + "min": 0, + "max": MAX_RESOLUTION, + "step": 1, + }), + "interpolation": (["nearest", "bilinear", "bicubic", "area", "nearest-exact", "lanczos"],), + "method": (["stretch", "keep proportion", "fill / crop", "pad"],), + "condition": (["always", "downscale if bigger", "upscale if smaller", "if bigger area", "if smaller area"],), + "multiple_of": ("INT", { + "default": 0, + "min": 0, + "max": 512, + "step": 1, + }), + }, + "optional": { + "reference_image": ("IMAGE",), + } + } + + RETURN_TYPES = ("IMAGE", "INT", "INT",) + RETURN_NAMES = ("IMAGE", "width", "height",) + FUNCTION = "execute" + CATEGORY = "🌻 Addoor/image" + + def execute(self, image, width, height, method="stretch", interpolation="nearest", condition="always", multiple_of=0, reference_image=None): + # 如果有参考图,使用其尺寸 + if reference_image is not None: + _, ref_h, ref_w, _ = reference_image.shape + width = ref_w + height = ref_h + print(f"Using reference image size: {ref_w}x{ref_h}") + + _, oh, ow, _ = image.shape + x = y = x2 = y2 = 0 + pad_left = pad_right = pad_top = pad_bottom = 0 + + if multiple_of > 1: + width = width - (width % multiple_of) + height = height - (height % multiple_of) + + if method == 'keep proportion' or method == 'pad': + if width == 0 and oh < height: + width = MAX_RESOLUTION + elif width == 0 and oh >= height: + width = ow + + if height == 0 and ow < width: + height = MAX_RESOLUTION + elif height == 0 and ow >= width: + height = oh + + ratio = min(width / ow, height / oh) + new_width = round(ow*ratio) + new_height = round(oh*ratio) + + if method == 'pad': + pad_left = (width - new_width) // 2 + pad_right = width - new_width - pad_left + pad_top = (height - new_height) // 2 + pad_bottom = height - new_height - pad_top + + width = new_width + height = new_height + elif method.startswith('fill'): + width = width if width > 0 else ow + height = height if height > 0 else oh + + ratio = max(width / ow, height / oh) + new_width = round(ow*ratio) + new_height = round(oh*ratio) + x = (new_width - width) // 2 + y = (new_height - height) // 2 + x2 = x + width + y2 = y + height + if x2 > new_width: + x -= (x2 - new_width) + if x < 0: + x = 0 + if y2 > new_height: + y -= (y2 - new_height) + if y < 0: + y = 0 + width = new_width + height = new_height + else: + width = width if width > 0 else ow + height = height if height > 0 else oh + + if "always" in condition \ + or ("downscale if bigger" == condition and (oh > height or ow > width)) \ + or ("upscale if smaller" == condition and (oh < height or ow < width)) \ + or ("bigger area" in condition and (oh * ow > height * width)) \ + or ("smaller area" in condition and (oh * ow < height * width)): + + outputs = image.permute(0,3,1,2) + + if interpolation == "lanczos": + outputs = comfy.utils.lanczos(outputs, width, height) + else: + outputs = F.interpolate(outputs, size=(height, width), mode=interpolation) + + if method == 'pad': + if pad_left > 0 or pad_right > 0 or pad_top > 0 or pad_bottom > 0: + outputs = F.pad(outputs, (pad_left, pad_right, pad_top, pad_bottom), value=0) + + outputs = outputs.permute(0,2,3,1) + + if method.startswith('fill'): + if x > 0 or y > 0 or x2 > 0 or y2 > 0: + outputs = outputs[:, y:y2, x:x2, :] + else: + outputs = image + + if multiple_of > 1 and (outputs.shape[2] % multiple_of != 0 or outputs.shape[1] % multiple_of != 0): + width = outputs.shape[2] + height = outputs.shape[1] + x = (width % multiple_of) // 2 + y = (height % multiple_of) // 2 + x2 = width - ((width % multiple_of) - x) + y2 = height - ((height % multiple_of) - y) + outputs = outputs[:, y:y2, x:x2, :] + + outputs = torch.clamp(outputs, 0, 1) + + return (outputs, outputs.shape[2], outputs.shape[1],) + +NODE_CLASS_MAPPINGS = { + "AD_image-resize": AD_ImageResize, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "AD_image-resize": "AD Image Resize", +} \ No newline at end of file diff --git a/nodes/AD_MockupMaker.py b/nodes/AD_MockupMaker.py new file mode 100644 index 0000000..9502421 --- /dev/null +++ b/nodes/AD_MockupMaker.py @@ -0,0 +1,317 @@ +import PIL.Image as Image +import PIL.ImageDraw as ImageDraw +import PIL.ImageFilter as ImageFilter +import numpy as np +import torch +import torchvision.transforms as t +import math + +class AD_MockupMaker: + """Create mockup with scaled image overlay and blurred background.""" + + def __init__(self): + pass + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("image",) + FUNCTION = "create_mockup" + CATEGORY = "🌻 Addoor/image" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "overlay_image": ("IMAGE",), + "background_image": ("IMAGE",), + "target_width": ("INT", { + "default": 512, + "min": 64, + "max": 4096, + "step": 8, + "description": "Target width for the overlay" + }), + "target_height": ("INT", { + "default": 512, + "min": 64, + "max": 4096, + "step": 8, + "description": "Target height for the overlay" + }), + "corner_radius": ("INT", { + "default": 0, + "min": 0, + "max": 500, + "step": 1, + "description": "Radius for rounded corners" + }), + "offset_x": ("INT", { + "default": 0, + "min": -1000, + "max": 1000, + "step": 1, + "description": "Horizontal offset in pixels" + }), + "offset_y": ("INT", { + "default": 0, + "min": -1000, + "max": 1000, + "step": 1, + "description": "Vertical offset in pixels" + }), + "blur_radius": ("FLOAT", { + "default": 10.0, + "min": 0.0, + "max": 50.0, + "step": 0.5, + "description": "Gaussian blur radius for background" + }), + "SSAA": ("INT", { + "default": 2, + "min": 1, + "max": 4, + "step": 1, + "description": "Super Sampling Anti-Aliasing factor" + }), + "method": (["lanczos", "bicubic", "bilinear"], { + "default": "lanczos", + "description": "Resampling method" + }), + }, + "optional": { + "watermark": ("IMAGE",), + "mask": ("MASK",), + } + } + + def add_corners(self, image, radius, ssaa=1): + """Add rounded corners to an image.""" + if radius <= 0: + return image + + # 调整圆角半径以适应SSAA + working_radius = radius * ssaa + + # 创建圆角蒙版 + mask = Image.new('L', image.size, 0) + draw = ImageDraw.Draw(mask) + draw.rounded_rectangle( + [(0, 0), (image.width, image.height)], + radius=working_radius, + fill=255 + ) + + # 确保图像为RGBA模式 + output = image.convert('RGBA') + output.putalpha(mask) + + return output + + def fit_and_crop(self, image, target_width, target_height, method, ssaa=1): + """Fit image to target size maintaining aspect ratio and crop if necessary.""" + # 计算SSAA尺寸 + ssaa_width = target_width * ssaa + ssaa_height = target_height * ssaa + + # 计算目标尺寸与原始尺寸的比例 + width_ratio = ssaa_width / image.width + height_ratio = ssaa_height / image.height + + # 使用较大的比例来确保填充目标区域 + scale = max(width_ratio, height_ratio) + + # 缩放图像 + new_width = int(image.width * scale) + new_height = int(image.height * scale) + resized = image.resize((new_width, new_height), method) + + # 计算裁切区域 + left = (new_width - ssaa_width) // 2 + top = (new_height - ssaa_height) // 2 + right = left + ssaa_width + bottom = top + ssaa_height + + # 裁切到目标尺寸 + cropped = resized.crop((left, top, right, bottom)) + return cropped + + def create_mockup( + self, + overlay_image, + background_image, + target_width: int, + target_height: int, + corner_radius: int, + offset_x: int, + offset_y: int, + blur_radius: float, + SSAA: int, + method: str, + watermark = None, + mask = None, + ): + try: + print("Starting mockup creation...") + + # 1. 初始化图像 + overlay = tensor_to_image(overlay_image[0]) + background = tensor_to_image(background_image[0]) + + print(f"Original sizes - Overlay: {overlay.size}, Background: {background.size}") + + # 2. 调整背景图尺寸并模糊 + background = background.resize( + (overlay.width, overlay.height), + get_sampler_by_name(method) + ) + + if blur_radius > 0: + background = background.filter(ImageFilter.GaussianBlur(radius=blur_radius)) + + # 3. 缩放并裁切主图 + scaled_overlay = self.fit_and_crop( + overlay, + target_width, + target_height, + get_sampler_by_name(method), + SSAA + ) + + print(f"After scaling - Overlay: {scaled_overlay.size}") + + # 4. 转换为RGBA模式 + scaled_overlay = scaled_overlay.convert('RGBA') + + # 5. 添加圆角 + if corner_radius > 0: + scaled_overlay = self.add_corners(scaled_overlay, corner_radius, SSAA) + + # 6. 如果使用了SSAA,缩小到目标尺寸 + if SSAA > 1: + scaled_overlay = scaled_overlay.resize( + (target_width, target_height), + get_sampler_by_name(method) + ) + + # 7. 应用mask遮罩(如果有) + if mask is not None: + try: + # 处理mask维度 + if len(mask.shape) == 3: + mask = mask.squeeze(0) + if len(mask.shape) == 3: + mask = mask.squeeze(-1) + + # 转换mask为PIL Image + mask_array = mask.cpu().numpy() + mask_array = (mask_array * 255).astype(np.uint8) + mask_img = Image.fromarray(mask_array, mode='L') + + # 首先将mask调整到原图尺寸 + mask_img = mask_img.resize( + (background.width, background.height), + get_sampler_by_name(method) + ) + + # 然后裁切出需要的部分(与scaled_overlay相同大小的区域) + crop_x = (background.width - scaled_overlay.width) // 2 + offset_x + crop_y = (background.height - scaled_overlay.height) // 2 + offset_y + mask_img = mask_img.crop(( + crop_x, + crop_y, + crop_x + scaled_overlay.width, + crop_y + scaled_overlay.height + )) + + print(f"Mask size: {mask_img.size}, Overlay size: {scaled_overlay.size}") + + # 获取当前alpha通道 + r, g, b, a = scaled_overlay.split() + + # 合并mask和现有alpha通道 + if corner_radius > 0: + # 如果有圆角,将mask与现有alpha通道相乘 + combined_alpha = Image.fromarray( + (np.array(mask_img) * np.array(a) / 255).astype(np.uint8) + ) + else: + # 如果没有圆角,直接使用mask + combined_alpha = mask_img + + # 更新alpha通道 + scaled_overlay.putalpha(combined_alpha) + print("Mask applied successfully") + + except Exception as e: + print(f"Error processing mask: {str(e)}") + import traceback + traceback.print_exc() + + print(f"Final overlay size: {scaled_overlay.size}") + + # 8. 准备最终合成 + result = background.copy() + result = result.convert('RGBA') + + # 9. 计算粘贴位置并合成主图 + paste_x = (background.width - scaled_overlay.width) // 2 + offset_x + paste_y = (background.height - scaled_overlay.height) // 2 + offset_y + + temp = Image.new('RGBA', result.size, (0, 0, 0, 0)) + temp.paste(scaled_overlay, (paste_x, paste_y), scaled_overlay) + result = Image.alpha_composite(result, temp) + + # 10. 添加水印(如果有) + if watermark is not None: + try: + watermark_img = tensor_to_image(watermark[0]) + watermark_img = watermark_img.convert('RGBA') + watermark_img = watermark_img.resize( + (background.width, background.height), + get_sampler_by_name(method) + ) + result = Image.alpha_composite(result, watermark_img) + except Exception as e: + print(f"Error processing watermark: {str(e)}") + + # 11. 最终转换 + result = result.convert('RGB') + tensor = image_to_tensor(result) + tensor = tensor.unsqueeze(0) + tensor = tensor.permute(0, 2, 3, 1) + + print("Mockup creation completed successfully") + return (tensor,) + + except Exception as e: + print(f"Error in create_mockup: {str(e)}") + import traceback + traceback.print_exc() + return (overlay_image,) + +def get_sampler_by_name(method: str) -> int: + """Get PIL resampling method by name.""" + samplers = { + "lanczos": Image.LANCZOS, + "bicubic": Image.BICUBIC, + "bilinear": Image.BILINEAR, + } + return samplers.get(method, Image.LANCZOS) + +def tensor_to_image(tensor): + """Convert tensor to PIL Image.""" + if len(tensor.shape) == 4: + tensor = tensor.squeeze(0) + return t.ToPILImage()(tensor.permute(2, 0, 1)) + +def image_to_tensor(image): + """Convert PIL Image to tensor.""" + tensor = t.ToTensor()(image) + return tensor + +NODE_CLASS_MAPPINGS = { + "AD_mockup-maker": AD_MockupMaker, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "AD_mockup-maker": "AD Mockup Maker", +} \ No newline at end of file diff --git a/nodes/AD_PosterMaker.py b/nodes/AD_PosterMaker.py new file mode 100644 index 0000000..73af15f --- /dev/null +++ b/nodes/AD_PosterMaker.py @@ -0,0 +1,201 @@ +import PIL.Image as Image +import PIL.ImageDraw as ImageDraw +import numpy as np +import torch +import torchvision.transforms as t + +class AD_PosterMaker: + """Advanced poster maker with scaling, border, background and composition.""" + + COLOR_PRESETS = { + "white": "#FFFFFF", + "black": "#000000", + "gray": "#808080", + "custom": "custom" + } + + def __init__(self): + pass + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("image",) + FUNCTION = "create_poster" + CATEGORY = "🌻 Addoor/image" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "scale": ("FLOAT", { + "default": 0.8, + "min": 0.1, + "max": 1.0, + "step": 0.05, + "description": "Scale factor for the inner image" + }), + "border_size": ("INT", { + "default": 32, + "min": 0, + "max": 256, + "step": 4, + "description": "Border size in pixels" + }), + "border_color": (list(cls.COLOR_PRESETS.keys()), { + "default": "white" + }), + "border_hex": ("STRING", { + "default": "#FFFFFF", + "multiline": False + }), + "background_color": (list(cls.COLOR_PRESETS.keys()), { + "default": "white" + }), + "background_hex": ("STRING", { + "default": "#FFFFFF", + "multiline": False + }), + "position": (["left", "right", "top", "bottom"], { + "default": "right", + "description": "Position of the original image" + }), + "method": (["lanczos", "bicubic", "bilinear"], { + "default": "lanczos", + "description": "Resampling method" + }), + }, + "optional": { + "watermark": ("IMAGE",), + "original_image": ("IMAGE",), + } + } + + def hex_to_rgb(self, hex_color: str) -> tuple: + """Convert hex color to RGB tuple.""" + hex_color = hex_color.lstrip('#') + return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) + + def create_poster( + self, + image, + scale: float, + border_size: int, + border_color: str, + border_hex: str, + background_color: str, + background_hex: str, + position: str, + method: str, + watermark = None, + original_image = None, + ): + try: + # 转换输入图像为PIL + original = tensor_to_image(image[0]) + + # 创建带边框的图 + bg_rgb = self.hex_to_rgb(self.COLOR_PRESETS[background_color] if background_color != "custom" else background_hex) + background = Image.new( + "RGB", + (original.width + border_size * 2, original.height + border_size * 2), + bg_rgb + ) + background.paste(original, (border_size, border_size)) + + # 处理水印 + if watermark is not None: + watermark_img = tensor_to_image(watermark[0]) + if watermark_img.mode != 'RGBA': + watermark_img = watermark_img.convert('RGBA') + watermark_img = watermark_img.resize(background.size, get_sampler_by_name(method)) + background = background.convert('RGBA') + background = Image.alpha_composite(background, watermark_img) + background = background.convert('RGB') + + # 获取目标尺寸(根据original_image或原始image) + if original_image is not None: + _, target_h, target_w, _ = original_image.shape + target_img = tensor_to_image(original_image[0]) + else: + target_w, target_h = original.size + target_img = original + + # 根据拼接方向调整边框图尺寸 + is_horizontal = position in ["left", "right"] + if is_horizontal: + # 横排,高度需要匹配 + ratio = target_h / background.height + new_width = int(background.width * ratio) + new_height = target_h + else: + # 竖排,宽度需要匹配 + ratio = target_w / background.width + new_width = target_w + new_height = int(background.height * ratio) + + # 调整边框图尺寸 + background = background.resize((new_width, new_height), get_sampler_by_name(method)) + print(f"Adjusted background size: {new_width}x{new_height}") + + # 创建最终图像并拼接 + if is_horizontal: + final_width = new_width + target_w + final_height = max(new_height, target_h) + else: + final_width = max(new_width, target_w) + final_height = new_height + target_h + + final_image = Image.new("RGB", (final_width, final_height)) + + # 根据位置拼接 + if position == "left": + final_image.paste(target_img, (0, 0)) + final_image.paste(background, (target_w, 0)) + elif position == "right": + final_image.paste(background, (0, 0)) + final_image.paste(target_img, (new_width, 0)) + elif position == "top": + final_image.paste(target_img, (0, 0)) + final_image.paste(background, (0, target_h)) + else: # bottom + final_image.paste(background, (0, 0)) + final_image.paste(target_img, (0, new_height)) + + # 转换回tensor + tensor = image_to_tensor(final_image) + tensor = tensor.unsqueeze(0) + tensor = tensor.permute(0, 2, 3, 1) + + return (tensor,) + + except Exception as e: + print(f"Error creating poster: {str(e)}") + return (image,) + +def get_sampler_by_name(method: str) -> int: + """Get PIL resampling method by name.""" + samplers = { + "lanczos": Image.LANCZOS, + "bicubic": Image.BICUBIC, + "bilinear": Image.BILINEAR, + } + return samplers.get(method, Image.LANCZOS) + +def tensor_to_image(tensor): + """Convert tensor to PIL Image.""" + if len(tensor.shape) == 4: + tensor = tensor.squeeze(0) + return t.ToPILImage()(tensor.permute(2, 0, 1)) + +def image_to_tensor(image): + """Convert PIL Image to tensor.""" + tensor = t.ToTensor()(image) + return tensor + +NODE_CLASS_MAPPINGS = { + "AD_poster-maker": AD_PosterMaker, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "AD_poster-maker": "AD Poster Maker", +} \ No newline at end of file diff --git a/nodes/AD_PromptSaver.py b/nodes/AD_PromptSaver.py new file mode 100644 index 0000000..8494559 --- /dev/null +++ b/nodes/AD_PromptSaver.py @@ -0,0 +1,119 @@ +""" +@author: ComfyUI Addoor +@title: ComfyUI-PromptSaver +@description: Save prompts to CSV file with customizable naming pattern +@version: 1.0.0 +""" + +import os +import csv +import folder_paths +from typing import Dict, Any + +class AD_PromptSaver: + """Save prompts to CSV file with customizable naming pattern""" + + def __init__(self): + self.output_dir = folder_paths.get_output_directory() + + @classmethod + def INPUT_TYPES(cls) -> Dict[str, Any]: + return { + "required": { + "prompt": ("STRING", {"multiline": True}), + "csv_filename": ("STRING", {"default": "prompts.csv"}), + "folder": ("STRING", {"default": ""}), + "filename_prefix": ("STRING", {"default": "Image"}), + "filename_delimiter": ("STRING", {"default": "_"}), + "filename_number_padding": ("INT", {"default": 4, "min": 0, "max": 9, "step": 1}), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("status",) + FUNCTION = "save_prompt" + CATEGORY = "🌻 Addoor/prompt" + + def generate_entry_name(self, filename_prefix: str, filename_delimiter: str, folder: str, filename_number_padding: int) -> str: + """Generate the entry name using the specified pattern""" + if folder: + # 如果提供了文件夹路径,从 ComfyUI 根目录开始 + comfy_path = os.path.dirname(self.output_dir) + full_output_folder = os.path.join(comfy_path, folder) + else: + # 如果没有提供,使用默认输出目录 + full_output_folder = self.output_dir + + # 确保目录存在 + os.makedirs(full_output_folder, exist_ok=True) + + # 获取目录中现有的文件数量 + counter = 1 + pattern = f"{filename_prefix}{filename_delimiter}" + existing_files = [f for f in os.listdir(full_output_folder) if f.startswith(pattern)] + + if existing_files: + numbers = [] + for f in existing_files: + try: + num = int(f[len(pattern):].split('.')[0]) + numbers.append(num) + except ValueError: + continue + if numbers: + counter = max(numbers) + 1 + + # 使用指定的填充长度 + if filename_number_padding > 0: + return f"{filename_prefix}{filename_delimiter}{counter:0{filename_number_padding}d}" + else: + return f"{filename_prefix}" + + def save_prompt(self, prompt: str, csv_filename: str, folder: str, + filename_prefix: str, filename_delimiter: str, filename_number_padding: int) -> tuple: + """Save prompt to CSV file""" + try: + # 处理保存路径 + if folder: + # 如果提供了文件夹路径,从 ComfyUI 根目录开始 + comfy_path = os.path.dirname(self.output_dir) + full_output_folder = os.path.join(comfy_path, folder) + else: + # 如果没有提供,使用默认输出目录 + full_output_folder = self.output_dir + + os.makedirs(full_output_folder, exist_ok=True) + + # CSV文件完整路径 + csv_path = os.path.join(full_output_folder, csv_filename) + + # 生成条目名称 + entry_name = self.generate_entry_name(filename_prefix, filename_delimiter, folder, filename_number_padding) + + # 准备要写入的行 + new_row = [entry_name, prompt] + + # 检查文件是否存在并写入 + file_exists = os.path.exists(csv_path) + + mode = 'a' if file_exists else 'w' + with open(csv_path, mode, newline='', encoding='utf-8') as f: + writer = csv.writer(f) + # 如果是新文件,写入标题行 + if not file_exists: + writer.writerow(['Name', 'Prompt']) + writer.writerow(new_row) + + return (f"Successfully saved prompt for {entry_name}",) + + except Exception as e: + return (f"Error saving prompt: {str(e)}",) + +# 节点注册 +NODE_CLASS_MAPPINGS = { + "AD_prompt-saver": AD_PromptSaver +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "AD_prompt-saver": "AD Prompt Saver" +} \ No newline at end of file diff --git a/nodes/AddPaddingAdvanced.py b/nodes/AddPaddingAdvanced.py new file mode 100644 index 0000000..e32dacd --- /dev/null +++ b/nodes/AddPaddingAdvanced.py @@ -0,0 +1,435 @@ +""" +@author: ComfyNodePRs +@title: ComfyUI Advanced Padding +@description: Advanced padding node with scaling capabilities +@version: 1.0.0 +@project: https://github.com/ComfyNodePRs/advanced-padding +@author: https://github.com/ComfyNodePRs +""" + +import PIL.Image as Image +import PIL.ImageDraw as ImageDraw +import numpy as np +import torch +import torchvision.transforms as t + + +class AD_PaddingAdvanced: + def __init__(self): + pass + + FUNCTION = "process_image" + CATEGORY = "🌻 Addoor/image" + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "image": ("IMAGE",), + "scale_by": ("FLOAT", { + "default": 1.0, + "min": 0.1, + "max": 8.0, + "step": 0.05, + "display": "number" + }), + "upscale_method": (["nearest-exact", "bilinear", "bicubic", "lanczos"], {"default": "lanczos"}), + "left": ("INT", {"default": 0, "step": 1, "min": 0, "max": 4096}), + "top": ("INT", {"default": 0, "step": 1, "min": 0, "max": 4096}), + "right": ("INT", {"default": 0, "step": 1, "min": 0, "max": 4096}), + "bottom": ("INT", {"default": 0, "step": 1, "min": 0, "max": 4096}), + "color": ("STRING", {"default": "#ffffff"}), + "transparent": ("BOOLEAN", {"default": False}), + }, + "optional": { + "background": ("IMAGE",), + } + } + + RETURN_TYPES = ("IMAGE", "MASK") + RETURN_NAMES = ("image", "mask") + + def add_padding(self, image, left, top, right, bottom, color="#ffffff", transparent=False): + padded_images = [] + image = [self.tensor2pil(img) for img in image] + for img in image: + padded_image = Image.new("RGBA" if transparent else "RGB", + (img.width + left + right, img.height + top + bottom), + (0, 0, 0, 0) if transparent else self.hex_to_tuple(color)) + padded_image.paste(img, (left, top)) + padded_images.append(self.pil2tensor(padded_image)) + return torch.cat(padded_images, dim=0) + + def create_mask(self, image, left, top, right, bottom): + masks = [] + image = [self.tensor2pil(img) for img in image] + for img in image: + shape = (left, top, img.width + left, img.height + top) + mask_image = Image.new("L", (img.width + left + right, img.height + top + bottom), 255) + draw = ImageDraw.Draw(mask_image) + draw.rectangle(shape, fill=0) + masks.append(self.pil2tensor(mask_image)) + return torch.cat(masks, dim=0) + + def scale_image(self, image, scale_by, method): + scaled_images = [] + image = [self.tensor2pil(img) for img in image] + + resampling_methods = { + "nearest-exact": Image.Resampling.NEAREST, + "bilinear": Image.Resampling.BILINEAR, + "bicubic": Image.Resampling.BICUBIC, + "lanczos": Image.Resampling.LANCZOS, + } + + for img in image: + # 计算新尺寸 + new_width = int(img.width * scale_by) + new_height = int(img.height * scale_by) + + # 使用选定的方法进行缩放 + scaled_img = img.resize( + (new_width, new_height), + resampling_methods.get(method, Image.Resampling.LANCZOS) + ) + scaled_images.append(self.pil2tensor(scaled_img)) + + return torch.cat(scaled_images, dim=0) + + def hex_to_tuple(self, color): + if not isinstance(color, str): + raise ValueError("Color must be a hex string") + color = color.strip("#") + return tuple([int(color[i:i + 2], 16) for i in range(0, len(color), 2)]) + + def tensor2pil(self, image): + return Image.fromarray(np.clip(255. * image.cpu().numpy().squeeze(), 0, 255).astype(np.uint8)) + + def pil2tensor(self, image): + return torch.from_numpy(np.array(image).astype(np.float32) / 255.0).unsqueeze(0) + + def composite_with_background(self, image, background): + """将图片居中合成到背景上""" + image_pil = self.tensor2pil(image[0]) # 获取第一帧 + bg_pil = self.tensor2pil(background[0]) + + # 创建新的景图像 + result = bg_pil.copy() + + # 计算居中位置 + x = (bg_pil.width - image_pil.width) // 2 + y = (bg_pil.height - image_pil.height) // 2 + + # 如果前景图比背景大,需要裁剪 + if image_pil.width > bg_pil.width or image_pil.height > bg_pil.height: + # 计算裁剪区域 + crop_left = max(0, (image_pil.width - bg_pil.width) // 2) + crop_top = max(0, (image_pil.height - bg_pil.height) // 2) + crop_right = min(image_pil.width, crop_left + bg_pil.width) + crop_bottom = min(image_pil.height, crop_top + bg_pil.height) + + # 裁剪图片 + image_pil = image_pil.crop((crop_left, crop_top, crop_right, crop_bottom)) + + # 更新粘贴位置 + x = max(0, (bg_pil.width - image_pil.width) // 2) + y = max(0, (bg_pil.height - image_pil.height) // 2) + + # 如果前景图有透明通道,使用alpha通道合成 + if image_pil.mode == 'RGBA': + result.paste(image_pil, (x, y), image_pil) + else: + result.paste(image_pil, (x, y)) + + return self.pil2tensor(result) + + def process_image(self, image, scale_by, upscale_method, left, top, right, bottom, color, transparent, background=None): + # 首先进行缩放 + if scale_by != 1.0: + image = self.scale_image(image, scale_by, upscale_method) + + # 添加padding + padded_image = self.add_padding(image, left, top, right, bottom, color, transparent) + + # 如果有背景图,进行合成 + if background is not None: + result = [] + for i in range(len(padded_image)): + # 处理每一帧 + frame = padded_image[i:i+1] + composited = self.composite_with_background(frame, background) + result.append(composited) + padded_image = torch.cat(result, dim=0) + + # 创建mask + mask = self.create_mask(image, left, top, right, bottom) + + return (padded_image, mask) + + +class AD_ImageConcat: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image1": ("IMAGE",), + "image2": ("IMAGE",), + "direction": (["horizontal", "vertical"], {"default": "horizontal"}), + "match_size": ("BOOLEAN", {"default": True}), + "method": (["lanczos", "bicubic", "bilinear", "nearest"], {"default": "lanczos"}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "concat_images" + CATEGORY = "AD/image" + + def concat_images(self, image1, image2, direction="horizontal", match_size=False, method="lanczos"): + try: + # 转换为 PIL 图像 + img1 = tensor_to_image(image1[0]) + img2 = tensor_to_image(image2[0]) + + # 确保两张图片的模式相同 + if img1.mode != img2.mode: + if 'A' in img1.mode or 'A' in img2.mode: + img1 = img1.convert('RGBA') + img2 = img2.convert('RGBA') + else: + img1 = img1.convert('RGB') + img2 = img2.convert('RGB') + + # 如果需要匹配尺寸 + if match_size: + if direction == "horizontal": + # 横向拼接,匹配高度 + if img1.height != img2.height: + new_height = img2.height + new_width = int(img1.width * (new_height / img1.height)) + img1 = img1.resize( + (new_width, new_height), + get_sampler_by_name(method) + ) + else: # vertical + # 纵向拼接,匹配宽度 + if img1.width != img2.width: + new_width = img2.width + new_height = int(img1.height * (new_width / img1.width)) + img1 = img1.resize( + (new_width, new_height), + get_sampler_by_name(method) + ) + + # 创建新图像 + if direction == "horizontal": + new_width = img1.width + img2.width + new_height = max(img1.height, img2.height) + else: # vertical + new_width = max(img1.width, img2.width) + new_height = img1.height + img2.height + + # 创建新的画布 + mode = img1.mode + new_image = Image.new(mode, (new_width, new_height)) + + # 计算粘贴位置(居中对齐) + if direction == "horizontal": + y1 = (new_height - img1.height) // 2 + y2 = (new_height - img2.height) // 2 + new_image.paste(img1, (0, y1)) + new_image.paste(img2, (img1.width, y2)) + else: # vertical + x1 = (new_width - img1.width) // 2 + x2 = (new_width - img2.width) // 2 + new_image.paste(img1, (x1, 0)) + new_image.paste(img2, (x2, img1.height)) + + # 转换回 tensor + tensor = image_to_tensor(new_image) + tensor = tensor.unsqueeze(0) + tensor = tensor.permute(0, 2, 3, 1) + + return (tensor,) + + except Exception as e: + print(f"Error concatenating images: {str(e)}") + return (image1,) + + +# 添加颜色常量 +COLORS = [ + "white", "black", "red", "green", "blue", "yellow", "purple", "orange", + "gray", "brown", "pink", "cyan", "custom" +] + +# 颜色映射 +color_mapping = { + "white": "#FFFFFF", + "black": "#000000", + "red": "#FF0000", + "green": "#00FF00", + "blue": "#0000FF", + "yellow": "#FFFF00", + "purple": "#800080", + "orange": "#FFA500", + "gray": "#808080", + "brown": "#A52A2A", + "pink": "#FFC0CB", + "cyan": "#00FFFF" +} + +def get_color_values(color_name, color_hex, mapping): + """获取颜色值""" + if color_name == "custom": + return color_hex + return mapping.get(color_name, "#000000") + +# 添加图像处理工具函数 +def get_sampler_by_name(method: str) -> int: + """Get PIL resampling method by name.""" + samplers = { + "lanczos": Image.LANCZOS, + "bicubic": Image.BICUBIC, + "hamming": Image.HAMMING, + "bilinear": Image.BILINEAR, + "box": Image.BOX, + "nearest": Image.NEAREST + } + return samplers.get(method, Image.LANCZOS) + +class AD_ColorImage: + """Create a solid color image with advanced options.""" + + def __init__(self): + pass + + # 预定义颜色映射 + COLOR_PRESETS = { + "white": "#FFFFFF", + "black": "#000000", + "red": "#FF0000", + "green": "#00FF00", + "blue": "#0000FF", + "yellow": "#FFFF00", + "purple": "#800080", + "orange": "#FFA500", + "gray": "#808080", + "custom": "custom" + } + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "width": ("INT", { + "default": 512, + "min": 1, + "max": 8192, + "step": 1 + }), + "height": ("INT", { + "default": 512, + "min": 1, + "max": 8192, + "step": 1 + }), + "color": (list(cls.COLOR_PRESETS.keys()), { + "default": "white" + }), + "hex_color": ("STRING", { + "default": "#FFFFFF", + "multiline": False + }), + "alpha": ("FLOAT", { + "default": 1.0, + "min": 0.0, + "max": 1.0, + "step": 0.01 + }), + }, + "optional": { + "reference_image": ("IMAGE",), + } + } + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("image",) + FUNCTION = "create_color_image" + CATEGORY = "AD/image" + + def hex_to_rgb(self, hex_color: str) -> tuple: + """Convert hex color to RGB tuple.""" + hex_color = hex_color.lstrip('#') + return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) + + def create_color_image( + self, + width: int, + height: int, + color: str, + hex_color: str, + alpha: float, + reference_image = None + ): + try: + # 如果有参考图片,使用其尺寸 + if reference_image is not None: + _, height, width, _ = reference_image.shape + + # 获取颜色值 + if color == "custom": + rgb_color = self.hex_to_rgb(hex_color) + else: + rgb_color = self.hex_to_rgb(self.COLOR_PRESETS[color]) + + # 创建图像 + canvas = Image.new( + "RGBA", + (width, height), + (*rgb_color, int(alpha * 255)) + ) + + # 转换为 tensor + tensor = image_to_tensor(canvas) + tensor = tensor.unsqueeze(0) + tensor = tensor.permute(0, 2, 3, 1) + + return (tensor,) + + except Exception as e: + print(f"Error creating color image: {str(e)}") + canvas = Image.new( + "RGB", + (width, height), + (0, 0, 0) + ) + tensor = image_to_tensor(canvas) + tensor = tensor.unsqueeze(0) + tensor = tensor.permute(0, 2, 3, 1) + return (tensor,) + +def tensor_to_image(tensor): + """Convert tensor to PIL Image.""" + if len(tensor.shape) == 4: + tensor = tensor.squeeze(0) # 移除 batch 维度 + return t.ToPILImage()(tensor.permute(2, 0, 1)) + +def image_to_tensor(image): + """Convert PIL Image to tensor.""" + tensor = t.ToTensor()(image) + return tensor + +NODE_CLASS_MAPPINGS = { + "AD_advanced-padding": AD_PaddingAdvanced, + "AD_image-concat": AD_ImageConcat, + "AD_color-image": AD_ColorImage, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "AD_advanced-padding": "AD Advanced Padding", + "AD_image-concat": "AD Image Concatenation", + "AD_color-image": "AD Color Image", +} \ No newline at end of file diff --git a/nodes/AddPaddingBase.py b/nodes/AddPaddingBase.py new file mode 100644 index 0000000..553f5ae --- /dev/null +++ b/nodes/AddPaddingBase.py @@ -0,0 +1,99 @@ +""" +@author: ealkanat +@title: ComfyUI Easy Padding +@description: A simple custom node for creates padding for given image +@version: 1.0.2 +@project: https://github.com/erkana/comfyui_easy_padding +@author: https://github.com/erkana +""" + +import PIL.Image as Image +import PIL.ImageDraw as ImageDraw +import numpy as np +import torch + + +class AddPaddingBase: + def __init__(self): + pass + + FUNCTION = "resize" + CATEGORY = "🌻 Addoor/image" + + def add_padding(self, image, left, top, right, bottom, color="#ffffff", transparent=False): + padded_images = [] + image = [self.tensor2pil(img) for img in image] + for img in image: + padded_image = Image.new("RGBA" if transparent else "RGB", + (img.width + left + right, img.height + top + bottom), + (0, 0, 0, 0) if transparent else self.hex_to_tuple(color)) + padded_image.paste(img, (left, top)) + padded_images.append(self.pil2tensor(padded_image)) + return torch.cat(padded_images, dim=0) + + def create_mask(self, image, left, top, right, bottom): + masks = [] + image = [self.tensor2pil(img) for img in image] + for img in image: + shape = (left, top, img.width + left, img.height + top) + mask_image = Image.new("L", (img.width + left + right, img.height + top + bottom), 255) + draw = ImageDraw.Draw(mask_image) + draw.rectangle(shape, fill=0) + masks.append(self.pil2tensor(mask_image)) + return torch.cat(masks, dim=0) + + def hex_to_float(self, color): + if not isinstance(color, str): + raise ValueError("Color must be a hex string") + color = color.strip("#") + return int(color, 16) / 255.0 + + def hex_to_tuple(self, color): + if not isinstance(color, str): + raise ValueError("Color must be a hex string") + color = color.strip("#") + return tuple([int(color[i:i + 2], 16) for i in range(0, len(color), 2)]) + + # Tensor to PIL + def tensor2pil(self, image): + return Image.fromarray(np.clip(255. * image.cpu().numpy().squeeze(), 0, 255).astype(np.uint8)) + + # PIL to Tensor + def pil2tensor(self, image): + return torch.from_numpy(np.array(image).astype(np.float32) / 255.0).unsqueeze(0) + + + +class AddPadding(AddPaddingBase): + + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "image": ("IMAGE",), + "left": ("INT", {"default": 0, "step": 1, "min": 0, "max": 4096}), + "top": ("INT", {"default": 0, "step": 1, "min": 0, "max": 4096}), + "right": ("INT", {"default": 0, "step": 1, "min": 0, "max": 4096}), + "bottom": ("INT", {"default": 0, "step": 1, "min": 0, "max": 4096}), + "color": ("STRING", {"default": "#ffffff"}), + "transparent": ("BOOLEAN", {"default": False}), + }, + } + + RETURN_TYPES = ("IMAGE", "MASK") + + def resize(self, image, left, top, right, bottom, color, transparent): + return (self.add_padding(image, left, top, right, bottom, color, transparent), + self.create_mask(image, left, top, right, bottom),) + + +NODE_CLASS_MAPPINGS = { + "comfyui-easy-padding": AddPadding, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "comfyui-easy-padding": "ComfyUI Easy Padding", +} \ No newline at end of file diff --git a/nodes/ComfyUI-FofrToolkit.py b/nodes/ComfyUI-FofrToolkit.py new file mode 100644 index 0000000..7bba0b7 --- /dev/null +++ b/nodes/ComfyUI-FofrToolkit.py @@ -0,0 +1,126 @@ +""" +@name: "ComfyUI FofrToolkit", +@version: (1,0,0), +@author: "fofr", +@description: "Experimental toolkit for comfyui.", +@project: "https://github.com/fofr/comfyui-fofr-toolkit", +@url: "https://github.com/fofr", +""" + + +class ToolkitIncrementer: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "current_index": ( + "INT", + { + "default": 0, + "min": 0, + "max": 0xFFFFFFFFFFFFFFFF, + "control_after_generate": True, + }, + ), + }, + "optional": { + "max": ( + "INT", + {"default": 10, "min": 0, "max": 0xFFFFFFFFFFFFFFFF}, + ), + }, + } + + RETURN_TYPES = ("INT", "STRING") + RETURN_NAMES = ("INT", "STRING") + FUNCTION = "increment" + CATEGORY = "🌻 Addoor/Utilities" + + def increment(self, current_index, max=0): + if max == 0: + result = current_index + else: + result = current_index % (max + 1) + + return (result, str(result)) + + +class ToolkitWidthAndHeightFromAspectRatio: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "aspect_ratio": ( + [ + "1:1", + "1:2", + "2:1", + "2:3", + "3:2", + "3:4", + "4:3", + "4:5", + "5:4", + "9:16", + "16:9", + "9:21", + "21:9", + ], + {"default": "1:1"}, + ), + "target_size": ("INT", {"default": 1024, "min": 64, "max": 8192}), + }, + "optional": { + "multiple_of": ("INT", {"default": 8, "min": 1, "max": 1024}), + }, + } + + RETURN_TYPES = ("INT", "INT") + RETURN_NAMES = ("width", "height") + FUNCTION = "width_and_height_from_aspect_ratio" + CATEGORY = "🌻 Addoor/Utilities" + + def width_and_height_from_aspect_ratio( + self, aspect_ratio, target_size, multiple_of=8 + ): + w, h = map(int, aspect_ratio.split(":")) + scale = (target_size**2 / (w * h)) ** 0.5 + width = round(w * scale / multiple_of) * multiple_of + height = round(h * scale / multiple_of) * multiple_of + return (width, height) + + +class ToolkitWidthAndHeightForImageScaling: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "target_size": ( + "INT", + {"default": 1024, "min": 64, "max": 8192}, + ), + }, + "optional": { + "multiple_of": ("INT", {"default": 8, "min": 1, "max": 1024}), + }, + } + + RETURN_TYPES = ("INT", "INT") + RETURN_NAMES = ("width", "height") + FUNCTION = "scale_image_to_target" + CATEGORY = "🌻 Addoor/Utilities" + + def scale_image_to_target(self, image, target_size, multiple_of=8): + h, w = image.shape[1:3] + scale = (target_size**2 / (w * h)) ** 0.5 + width = round(w * scale / multiple_of) * multiple_of + height = round(h * scale / multiple_of) * multiple_of + return (width, height) + + +NODE_CLASS_MAPPINGS = { + "Incrementer 🪴": ToolkitIncrementer, + "Width and height from aspect ratio 🪴": ToolkitWidthAndHeightFromAspectRatio, + "Width and height for scaling image to ideal resolution 🪴": ToolkitWidthAndHeightForImageScaling, +} diff --git a/nodes/ComfyUI-imageResize.py b/nodes/ComfyUI-imageResize.py new file mode 100644 index 0000000..01f9157 --- /dev/null +++ b/nodes/ComfyUI-imageResize.py @@ -0,0 +1,172 @@ +""" +@author: palant +@title: ComfyUI-imageResize +@description: Custom node for image resizing. +@version: 1.0.0 +@project: https://github.com/palant/image-resize-comfyui +@author: https://github.com/palant +""" + +import torch + +class ImageResize: + def __init__(self): + pass + + + ACTION_TYPE_RESIZE = "resize only" + ACTION_TYPE_CROP = "crop to ratio" + ACTION_TYPE_PAD = "pad to ratio" + RESIZE_MODE_DOWNSCALE = "reduce size only" + RESIZE_MODE_UPSCALE = "increase size only" + RESIZE_MODE_ANY = "any" + RETURN_TYPES = ("IMAGE", "MASK",) + FUNCTION = "resize" + CATEGORY = "🌻 Addoor/image" + + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "pixels": ("IMAGE",), + "action": ([s.ACTION_TYPE_RESIZE, s.ACTION_TYPE_CROP, s.ACTION_TYPE_PAD],), + "smaller_side": ("INT", {"default": 0, "min": 0, "max": 8192, "step": 8}), + "larger_side": ("INT", {"default": 0, "min": 0, "max": 8192, "step": 8}), + "scale_factor": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 10.0, "step": 0.1}), + "resize_mode": ([s.RESIZE_MODE_DOWNSCALE, s.RESIZE_MODE_UPSCALE, s.RESIZE_MODE_ANY],), + "side_ratio": ("STRING", {"default": "4:3"}), + "crop_pad_position": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "pad_feathering": ("INT", {"default": 20, "min": 0, "max": 8192, "step": 1}), + }, + "optional": { + "mask_optional": ("MASK",), + }, + } + + + @classmethod + def VALIDATE_INPUTS(s, action, smaller_side, larger_side, scale_factor, resize_mode, side_ratio, **_): + if side_ratio is not None: + if action != s.ACTION_TYPE_RESIZE and s.parse_side_ratio(side_ratio) is None: + return f"Invalid side ratio: {side_ratio}" + + if smaller_side is not None and larger_side is not None and scale_factor is not None: + if int(smaller_side > 0) + int(larger_side > 0) + int(scale_factor > 0) > 1: + return f"At most one scaling rule (smaller_side, larger_side, scale_factor) should be enabled by setting a non-zero value" + + if scale_factor is not None: + if resize_mode == s.RESIZE_MODE_DOWNSCALE and scale_factor > 1.0: + return f"For resize_mode {s.RESIZE_MODE_DOWNSCALE}, scale_factor should be less than one but got {scale_factor}" + if resize_mode == s.RESIZE_MODE_UPSCALE and scale_factor > 0.0 and scale_factor < 1.0: + return f"For resize_mode {s.RESIZE_MODE_UPSCALE}, scale_factor should be larger than one but got {scale_factor}" + + return True + + + @classmethod + def parse_side_ratio(s, side_ratio): + try: + x, y = map(int, side_ratio.split(":", 1)) + if x < 1 or y < 1: + raise Exception("Ratio factors have to be positive numbers") + return float(x) / float(y) + except: + return None + + + def resize(self, pixels, action, smaller_side, larger_side, scale_factor, resize_mode, side_ratio, crop_pad_position, pad_feathering, mask_optional=None): + validity = self.VALIDATE_INPUTS(action, smaller_side, larger_side, scale_factor, resize_mode, side_ratio) + if validity is not True: + raise Exception(validity) + + height, width = pixels.shape[1:3] + if mask_optional is None: + mask = torch.zeros(1, height, width, dtype=torch.float32) + else: + mask = mask_optional + if mask.shape[1] != height or mask.shape[2] != width: + mask = torch.nn.functional.interpolate(mask.unsqueeze(0), size=(height, width), mode="bicubic").squeeze(0).clamp(0.0, 1.0) + + crop_x, crop_y, pad_x, pad_y = (0.0, 0.0, 0.0, 0.0) + if action == self.ACTION_TYPE_CROP: + target_ratio = self.parse_side_ratio(side_ratio) + if height * target_ratio < width: + crop_x = width - height * target_ratio + else: + crop_y = height - width / target_ratio + elif action == self.ACTION_TYPE_PAD: + target_ratio = self.parse_side_ratio(side_ratio) + if height * target_ratio > width: + pad_x = height * target_ratio - width + else: + pad_y = width / target_ratio - height + + if smaller_side > 0: + if width + pad_x - crop_x > height + pad_y - crop_y: + scale_factor = float(smaller_side) / (height + pad_y - crop_y) + else: + scale_factor = float(smaller_side) / (width + pad_x - crop_x) + if larger_side > 0: + if width + pad_x - crop_x > height + pad_y - crop_y: + scale_factor = float(larger_side) / (width + pad_x - crop_x) + else: + scale_factor = float(larger_side) / (height + pad_y - crop_y) + + if (resize_mode == self.RESIZE_MODE_DOWNSCALE and scale_factor >= 1.0) or (resize_mode == self.RESIZE_MODE_UPSCALE and scale_factor <= 1.0): + scale_factor = 0.0 + + if scale_factor > 0.0: + pixels = torch.nn.functional.interpolate(pixels.movedim(-1, 1), scale_factor=scale_factor, mode="bicubic", antialias=True).movedim(1, -1).clamp(0.0, 1.0) + mask = torch.nn.functional.interpolate(mask.unsqueeze(0), scale_factor=scale_factor, mode="bicubic", antialias=True).squeeze(0).clamp(0.0, 1.0) + height, width = pixels.shape[1:3] + + crop_x *= scale_factor + crop_y *= scale_factor + pad_x *= scale_factor + pad_y *= scale_factor + + if crop_x > 0.0 or crop_y > 0.0: + remove_x = (round(crop_x * crop_pad_position), round(crop_x * (1 - crop_pad_position))) if crop_x > 0.0 else (0, 0) + remove_y = (round(crop_y * crop_pad_position), round(crop_y * (1 - crop_pad_position))) if crop_y > 0.0 else (0, 0) + pixels = pixels[:, remove_y[0]:height - remove_y[1], remove_x[0]:width - remove_x[1], :] + mask = mask[:, remove_y[0]:height - remove_y[1], remove_x[0]:width - remove_x[1]] + elif pad_x > 0.0 or pad_y > 0.0: + add_x = (round(pad_x * crop_pad_position), round(pad_x * (1 - crop_pad_position))) if pad_x > 0.0 else (0, 0) + add_y = (round(pad_y * crop_pad_position), round(pad_y * (1 - crop_pad_position))) if pad_y > 0.0 else (0, 0) + + new_pixels = torch.zeros(pixels.shape[0], height + add_y[0] + add_y[1], width + add_x[0] + add_x[1], pixels.shape[3], dtype=torch.float32) + new_pixels[:, add_y[0]:height + add_y[0], add_x[0]:width + add_x[0], :] = pixels + pixels = new_pixels + + new_mask = torch.ones(mask.shape[0], height + add_y[0] + add_y[1], width + add_x[0] + add_x[1], dtype=torch.float32) + new_mask[:, add_y[0]:height + add_y[0], add_x[0]:width + add_x[0]] = mask + mask = new_mask + + if pad_feathering > 0: + for i in range(mask.shape[0]): + for j in range(pad_feathering): + feather_strength = (1 - j / pad_feathering) * (1 - j / pad_feathering) + if add_x[0] > 0 and j < width: + for k in range(height): + mask[i, k, add_x[0] + j] = max(mask[i, k, add_x[0] + j], feather_strength) + if add_x[1] > 0 and j < width: + for k in range(height): + mask[i, k, width + add_x[0] - j - 1] = max(mask[i, k, width + add_x[0] - j - 1], feather_strength) + if add_y[0] > 0 and j < height: + for k in range(width): + mask[i, add_y[0] + j, k] = max(mask[i, add_y[0] + j, k], feather_strength) + if add_y[1] > 0 and j < height: + for k in range(width): + mask[i, height + add_y[0] - j - 1, k] = max(mask[i, height + add_y[0] - j - 1, k], feather_strength) + + return (pixels, mask) + + +NODE_CLASS_MAPPINGS = { + "ImageResize": ImageResize +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "ImageResize": "Image Resize" +} diff --git a/nodes/__init__.py b/nodes/__init__.py new file mode 100644 index 0000000..0d5f960 --- /dev/null +++ b/nodes/__init__.py @@ -0,0 +1,62 @@ +""" +Addoor Nodes for ComfyUI +Provides nodes for image processing and other utilities +""" + +import os +import glob +import logging +import importlib + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# 首先定义映射字典 +NODE_CLASS_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS = {} + +# 获取当前目录下所有的 .py 文件 +current_dir = os.path.dirname(os.path.abspath(__file__)) +py_files = glob.glob(os.path.join(current_dir, "*.py")) + +# 直接注册所有节点 +for file_path in py_files: + # 跳过 __init__.py + if "__init__.py" in file_path: + continue + + try: + # 获取模块名(不含.py) + module_name = os.path.basename(file_path)[:-3] + # 使用相对导入 + module = importlib.import_module(f".{module_name}", package=__package__) + + # 如果模块有节点映射,则更新 + if hasattr(module, 'NODE_CLASS_MAPPINGS'): + NODE_CLASS_MAPPINGS.update(module.NODE_CLASS_MAPPINGS) + if hasattr(module, 'NODE_DISPLAY_NAME_MAPPINGS'): + NODE_DISPLAY_NAME_MAPPINGS.update(module.NODE_DISPLAY_NAME_MAPPINGS) + + logger.info(f"Imported {module_name} successfully") + except ImportError as e: + logger.error(f"Error importing {module_name}: {str(e)}") + except Exception as e: + logger.error(f"Error processing {module_name}: {str(e)}") + +# 如果允许测试节点,导入测试节点 +allow_test_nodes = True +if allow_test_nodes: + try: + from .excluded.experimental_nodes import * + # 更新映射 + if 'NODE_CLASS_MAPPINGS' in locals(): + NODE_CLASS_MAPPINGS.update(locals().get('NODE_CLASS_MAPPINGS', {})) + if 'NODE_DISPLAY_NAME_MAPPINGS' in locals(): + NODE_DISPLAY_NAME_MAPPINGS.update(locals().get('NODE_DISPLAY_NAME_MAPPINGS', {})) + except ModuleNotFoundError: + pass + +logger.debug(f"Registered nodes: {list(NODE_CLASS_MAPPINGS.keys())}") + +WEB_DIRECTORY = "./web" +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] \ No newline at end of file