diff --git a/README.md b/README.md index d8f116e..309fc2b 100644 --- a/README.md +++ b/README.md @@ -22,57 +22,62 @@ Tips: base64格式字符串比较长,会导致界面卡顿,接口请求带 ``` ## 节点 -| 名称 | 说明 | -|------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------| -| LoadImageFromURL | 从网络地址加载图片,一行代表一个图片 | -| LoadMaskFromURL | 从网络地址加载遮罩,一行代表一个 | -| Base64ToImage | 把图片base64字符串转成图片 | -| Base64ToMask | 把遮罩图片base64字符串转成遮罩 | -| ImageToBase64Advanced | 把图片转成base64字符串, 可以选择图片类型(image, mask) ,方便接口调用判断 | -| ImageToBase64 | 把图片转成base64字符串(imageType=["image"]) | -| MaskToBase64Image | 把遮罩转成对应图片的base64字符串(imageType=["mask"]) | -| MaskImageToBase64 | 把遮罩图片转成base64字符串(imageType=["mask"]) | -| LoadImageToBase64 | 加载本地图片转成base64字符串 | -| SamAutoMaskSEGS | 得到图片所有语义分割的coco或uncompress_rle格式。
配合ComfyUI-Impact-Pack的SAMLoader或comfyui_segment_anything的SAMModelLoader。
但是如果使用hq模型,必须使用comfyui_segment_anything | -| InsightFaceBBOXDetect | 为图片中的人脸添加序号和区域框 | -| ColorPicker | 颜色选择器 | -| IntToNumber | 整型转数字 | -| StringToList | 字符串转列表 | -| IntToList | 整型转列表 | -| ListMerge | 列表合并 | -| JoinList | 列表根据指定分隔符连接 | -| ShowString | 显示字符串(可指定消息中key值) | -| ShowInt | 显示整型(可指定消息中key值) | -| ShowFloat | 显示浮点型(可指定消息中key值) | -| ShowNumber | 显示数字(可指定消息中key值) | -| ShowBoolean | 显示布尔值(可指定消息中key值) | -| ImageEqual | 图片是否相等(可用于通过判断遮罩图是否全黑来判定是否有遮罩) | -| SDBaseVerNumber | 判断SD大模型版本是1.5还是xl | -| ListWrapper | 包装成列表(任意类型) | -| ListUnWrapper | 转成输出列表,后面连接的节点会把每个元素执行一遍,实现类似遍历效果 | -| BboxToCropData | bbox转cropData,方便接入was节点使用 | -| BboxToBbox | bbox两种格式(x,y,w,h)和(x1,y1,x2,y2)的相互转换 | -| BboxesToBboxes | BboxToBbox节点的列表版本 | -| SelectBbox | 从Bbox列表中选择一个 | -| SelectBboxes | 从Bbox列表中选择多个 | -| CropImageByBbox | 根据Bbox区域裁剪图片 | -| MaskByBboxes | 根据Bbox列表画遮罩 | -| SplitStringToList | 根据分隔符把字符串拆分为某种数据类型(str/int/float/bool)的列表 | -| IndexOfList | 从列表中获取指定位置的元素 | -| IndexesOfList | 从列表中筛选出指定位置的元素列表 | -| StringArea | 字符串文本框(多行输入区域) | -| ForEachOpen | 循环开始节点 | -| ForEachClose | 循环结束节点 | -| LoadJsonStrToList | json字符串转换为对象列表 | -| GetValueFromJsonObj | 从对象中获取指定key的值 | -| FilterValueForList | 根据指定值过滤列表中元素 | -| SliceList | 列表切片 | -| LoadLocalFilePath | 列出给定路径下的文件列表 | -| LoadImageFromLocalPath | 根据图片全路径加载图片 | -| LoadMaskFromLocalPath | 根据遮罩全路径加载遮罩 | | -| IsNoneOrEmpty | 判断是否为空或空字符串或空列表或空字典 | -| IsNoneOrEmptyOptional | 为空时返回指定值(惰性求值),否则返回原值 | -| EmptyOutputNode | 空的输出类型节点 | +| 名称 | 说明 | +|------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------| +| LoadImageFromURL | 从网络地址加载图片,一行代表一个图片 | +| LoadMaskFromURL | 从网络地址加载遮罩,一行代表一个 | +| Base64ToImage | 把图片base64字符串转成图片 | +| Base64ToMask | 把遮罩图片base64字符串转成遮罩 | +| ImageToBase64Advanced | 把图片转成base64字符串, 可以选择图片类型(image, mask) ,方便接口调用判断 | +| ImageToBase64 | 把图片转成base64字符串(imageType=["image"]) | +| MaskToBase64Image | 把遮罩转成对应图片的base64字符串(imageType=["mask"]) | +| MaskImageToBase64 | 把遮罩图片转成base64字符串(imageType=["mask"]) | +| LoadImageToBase64 | 加载本地图片转成base64字符串 | +| SamAutoMaskSEGS | 得到图片所有语义分割的coco或uncompress_rle格式。
配合ComfyUI-Impact-Pack的SAMLoader或comfyui_segment_anything的SAMModelLoader。
但是如果使用hq模型,必须使用comfyui_segment_anything | +| InsightFaceBBOXDetect | 为图片中的人脸添加序号和区域框 | +| ColorPicker | 颜色选择器 | +| IntToNumber | 整型转数字 | +| StringToList | 字符串转列表 | +| IntToList | 整型转列表 | +| ListMerge | 列表合并 | +| JoinList | 列表根据指定分隔符连接 | +| ShowString | 显示字符串(可指定消息中key值) | +| ShowInt | 显示整型(可指定消息中key值) | +| ShowFloat | 显示浮点型(可指定消息中key值) | +| ShowNumber | 显示数字(可指定消息中key值) | +| ShowBoolean | 显示布尔值(可指定消息中key值) | +| ImageEqual | 图片是否相等(可用于通过判断遮罩图是否全黑来判定是否有遮罩) | +| SDBaseVerNumber | 判断SD大模型版本是1.5还是xl | +| ListWrapper | 包装成列表(任意类型) | +| ListUnWrapper | 转成输出列表,后面连接的节点会把每个元素执行一遍,实现类似遍历效果 | +| BboxToCropData | bbox转cropData,方便接入was节点使用 | +| BboxToBbox | bbox两种格式(x,y,w,h)和(x1,y1,x2,y2)的相互转换 | +| BboxesToBboxes | BboxToBbox节点的列表版本 | +| SelectBbox | 从Bbox列表中选择一个 | +| SelectBboxes | 从Bbox列表中选择多个 | +| CropImageByBbox | 根据Bbox区域裁剪图片 | +| MaskByBboxes | 根据Bbox列表画遮罩 | +| SplitStringToList | 根据分隔符把字符串拆分为某种数据类型(str/int/float/bool)的列表 | +| IndexOfList | 从列表中获取指定位置的元素 | +| IndexesOfList | 从列表中筛选出指定位置的元素列表 | +| StringArea | 字符串文本框(多行输入区域) | +| ForEachOpen | 循环开始节点 | +| ForEachClose | 循环结束节点 | +| LoadJsonStrToList | json字符串转换为对象列表 | +| GetValueFromJsonObj | 从对象中获取指定key的值 | +| FilterValueForList | 根据指定值过滤列表中元素 | +| SliceList | 列表切片 | +| LoadLocalFilePath | 列出给定路径下的文件列表 | +| LoadImageFromLocalPath | 根据图片全路径加载图片 | +| LoadMaskFromLocalPath | 根据遮罩全路径加载遮罩 | +| IsNoneOrEmpty | 判断是否为空或空字符串或空列表或空字典 | +| IsNoneOrEmptyOptional | 为空时返回指定值(惰性求值),否则返回原值 | +| EmptyOutputNode | 空的输出类型节点 | +| SaveTextToFileByImagePath | 保存文本到图片路径,以图片名作为文件名 | +| CopyAndRenameFiles | 复制或重命名文件 | +| SaveImagesWithoutOutput | 保存图像到指定目录,不是输出类型节点,可用于循环批量跑图和作为惰性求值的前置节点 | +| SaveSingleImageWithoutOutput | 保存单个图像到指定目录,不是输出类型节点,可用于循环批量跑图和作为惰性求值的前置节点 | +| CropTargetSizeImageByBbox | 以bbox的区域中心裁剪指定大小图片 | ### 示例 ![save api extended](docs/example_note.png) @@ -81,8 +86,12 @@ Tips: base64格式字符串比较长,会导致界面卡顿,接口请求带 ![save api extended](example/example_1.png) ![save api extended](example/example_2.png) ![save api extended](example/example_3.png) + ![批量裁剪打标](example/example_image_crop_tag.png) ## 更新记录 +### 2024-10-24 (v1.0.7) +- 新增节点:SaveTextToFileByImagePath、 CopyAndRenameFiles、 SaveImagesWithoutOutput、 SaveSingleImageWithoutOutput、 CropTargetSizeImageByBbox + ### 2024-10-18 - 新增节点:SliceList、LoadLocalFilePath、LoadImageFromLocalPath、LoadMaskFromLocalPath、IsNoneOrEmpty、IsNoneOrEmptyOptional、EmptyOutputNode diff --git a/easyapi/BboxNode.py b/easyapi/BboxNode.py index ac3cefd..b3f8de2 100644 --- a/easyapi/BboxNode.py +++ b/easyapi/BboxNode.py @@ -2,6 +2,7 @@ from json import JSONDecoder import torch +import nodes from .util import any_type @@ -221,12 +222,12 @@ class CropImageByBbox: "required": { "image": ("IMAGE",), "bbox": ("BBOX",), - "margin": ("INT", {"default": 16}), + "margin": ("INT", {"default": 16, "tooltip": "bbox矩形区域向外扩张的像素距离"}), } } - RETURN_TYPES = ("IMAGE", "MASK", "BBOX") - RETURN_NAMES = ("crop_image", "mask", "crop_bbox") + RETURN_TYPES = ("IMAGE", "MASK", "BBOX", "INT", "INT") + RETURN_NAMES = ("crop_image", "mask", "crop_bbox", "w", "h") FUNCTION = "crop" CATEGORY = "EasyApi/Bbox" DESCRIPTION = "根据bbox区域裁剪图片。 bbox的格式是左上角和右下角坐标: [x,y,x1,y1]" @@ -257,7 +258,99 @@ class CropImageByBbox: mask[new_bbox[1]:new_bbox[3], new_bbox[0]:new_bbox[2]] = 1 # 如果需要转换为浮点数,并且增加一个通道维度, 形状变为 (1, height, width) mask_tensor = mask.unsqueeze(0) - return crop_img, mask_tensor, new_bbox, + return crop_img, mask_tensor, new_bbox, to_x - x, to_y - y, + + +class CropTargetSizeImageByBbox: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "bbox": ("BBOX",{"forceInput": True, "tooltip": "参考区域坐标"}), + "width": ("INT", {"default": 512, "min": 1, "max": nodes.MAX_RESOLUTION, "step": 1, "tooltip": "目标宽度"}), + "height": ("INT", {"default": 512, "min": 1, "max": nodes.MAX_RESOLUTION, "step": 1, "tooltip": "目标高度"}), + "contain": ("BOOLEAN", {"default": False, "tooltip": "是否始终包含bbox完整区域"}), + } + } + + RETURN_TYPES = ("IMAGE", "MASK", "BBOX", "INT", "INT") + RETURN_NAMES = ("crop_image", "mask", "crop_bbox", "w", "h") + FUNCTION = "crop" + CATEGORY = "EasyApi/Bbox" + DESCRIPTION = "根据bbox区域中心裁剪指定大小图片。 bbox的格式是左上角和右下角坐标: [x,y,x1,y1]" + + def calc_area(self, image_width, image_height, rect_top_left, rect_bottom_right, w, h): + """ + 以给定的矩形中心点为中心计算指定宽高的矩形框坐标 + Args: + image_width: 图片高度 + image_height: 图片宽度 + rect_top_left: 矩形框左上角坐标 + rect_bottom_right: 矩形框右下角坐标 + w: 目标宽度 + h: 目标高度 + + Returns: + + """ + # 计算矩形的宽和高 + x, y = rect_top_left + x1, y1 = rect_bottom_right + + # 否则,计算矩形的中心(取整) + center_x = (x + x1) // 2 + center_y = (y + y1) // 2 + left_w = w // 2 + right_w = w - left_w + top_h = h // 2 + bottom_h = h - top_h + + # 计算新的坐标 + new_top_left_x = max(0, center_x - left_w) + new_top_left_y = max(0, center_y - top_h) + new_bottom_right_x = min(image_width, center_x + right_w) + new_bottom_right_y = min(image_height, center_y + bottom_h) + + # 如果坐标越界,调整坐标 + if new_top_left_x == 0: + # 左边可能超过边界了,尝试把左边超出部分加到右边 + new_bottom_right_x = min(image_width, new_bottom_right_x + (left_w - center_x)) + elif new_bottom_right_x == image_width: + # 右边可能超过边界了,尝试把右边超出部分加到左边 + new_top_left_x = max(0, new_top_left_x - (center_x + left_w - image_width)) + + if new_top_left_y == 0: + # 上边可能超过边界了,尝试把上边超出部分加到下边 + new_bottom_right_y = min(image_height, new_bottom_right_y + (top_h - center_y)) + elif new_bottom_right_y == image_height: + # 下边可能超过边界了,尝试把下边超出部分加到上边 + new_top_left_y = max(0, new_top_left_y - (center_y + top_h - image_height)) + + return new_top_left_x, new_top_left_y, new_bottom_right_x, new_bottom_right_y + + def crop(self, image: torch.Tensor, bbox, width, height, contain): + x, y, x1, y1 = bbox + image_height = image.shape[1] + image_width = image.shape[2] + + new_x, new_y, to_x, to_y = self.calc_area(image_width, image_height, (x, y), (x1, y1), width, height) + + if contain: + new_x = min(new_x, x) + new_y = min(new_y, y) + to_x = max(to_x, x1) + to_y = max(to_y, y1) + # 按区域截取图片 + crop_img = image[:, new_y:to_y, new_x:to_x, :] + new_bbox = (new_x, new_y, to_x, to_y) + # 创建与image相同大小的全零张量作为遮罩 + mask = torch.zeros((image_height, image_width), dtype=torch.uint8) # 使用uint8类型 + # 在mask上设置new_bbox区域为1 + mask[new_bbox[1]:new_bbox[3], new_bbox[0]:new_bbox[2]] = 1 + # 如果需要转换为浮点数,并且增加一个通道维度, 形状变为 (1, height, width) + mask_tensor = mask.unsqueeze(0) + return crop_img, mask_tensor, new_bbox, to_x - new_x, to_y - new_y, class MaskByBboxes: @@ -299,6 +392,7 @@ NODE_CLASS_MAPPINGS = { "SelectBboxes": SelectBboxes, "CropImageByBbox": CropImageByBbox, "MaskByBboxes": MaskByBboxes, + "CropTargetSizeImageByBbox": CropTargetSizeImageByBbox, } @@ -310,4 +404,5 @@ NODE_DISPLAY_NAME_MAPPINGS = { "SelectBboxes": "SelectBboxes", "CropImageByBbox": "CropImageByBbox", "MaskByBboxes": "MaskByBboxes", + "CropTargetSizeImageByBbox": "CropTargetSizeImageByBbox", } diff --git a/easyapi/ImageNode.py b/easyapi/ImageNode.py index c75eaa5..1feb264 100644 --- a/easyapi/ImageNode.py +++ b/easyapi/ImageNode.py @@ -7,6 +7,7 @@ import numpy as np import torch from PIL import ImageOps, Image, ImageSequence +import folder_paths import node_helpers from nodes import LoadImage from comfy.cli_args import args @@ -446,6 +447,158 @@ class LoadMaskFromLocalPath: return (mask.unsqueeze(0),) +class SaveImagesWithoutOutput: + """ + 保存图片,非输出节点 + """ + + def __init__(self): + self.compress_level = 4 + + @classmethod + def INPUT_TYPES(self): + return { + "required": { + "images": ("IMAGE",), + "filename_prefix": ("STRING", {"default": "ComfyUI", + "tooltip": "要保存的文件的前缀。可以使用格式化信息,如%date:yyyy-MM-dd%或%Empty Latent Image.width%"}), + "output_dir": ("STRING", {"default": "", "tooltip": "若为空,存放到output目录"}), + }, + "optional": { + "addMetadata": ("BOOLEAN", {"default": False, "label_on": "True", "label_off": "False"}), + }, + "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}, + } + + RETURN_TYPES = ("STRING", ) + RETURN_NAMES = ("file_paths",) + OUTPUT_TOOLTIPS = ("保存的图片路径列表",) + + FUNCTION = "save_images" + + CATEGORY = "EasyApi/Image" + + DESCRIPTION = "保存图像到指定目录,可根据返回的文件路径进行后续操作,此节点为非输出节点,适合批量处理和用于惰性求值的前置节点" + OUTPUT_NODE = False + + def save_images(self, images, output_dir, filename_prefix="ComfyUI", addMetadata=False, prompt=None, extra_pnginfo=None): + imageList = list() + if not isinstance(images, list): + imageList.append(images) + else: + imageList = images + + if output_dir is None or len(output_dir.strip()) == 0: + output_dir = folder_paths.get_output_directory() + + results = list() + for (index, images) in enumerate(imageList): + for (batch_number, image) in enumerate(images): + full_output_folder, filename, counter, subfolder, curr_filename_prefix = folder_paths.get_save_image_path( + filename_prefix, output_dir, image.shape[1], image.shape[0]) + img = tensor_to_pil(image) + metadata = None + if not args.disable_metadata and addMetadata: + metadata = PngInfo() + if prompt is not None: + metadata.add_text("prompt", json.dumps(prompt)) + if extra_pnginfo is not None: + for x in extra_pnginfo: + metadata.add_text(x, json.dumps(extra_pnginfo[x])) + + filename_with_batch_num = filename.replace("%batch_num%", str(batch_number)) + file = f"{filename_with_batch_num}_{counter:05}_.png" + image_save_path = os.path.join(full_output_folder, file) + img.save(image_save_path, pnginfo=metadata, compress_level=self.compress_level) + results.append(image_save_path) + counter += 1 + + return (results,) + + +class SaveSingleImageWithoutOutput: + """ + 保存图片,非输出节点 + """ + + def __init__(self): + self.compress_level = 4 + + @classmethod + def INPUT_TYPES(self): + return { + "required": { + "image": ("IMAGE",), + "filename_prefix": ("STRING", {"default": "ComfyUI", "tooltip": "要保存的文件的前缀。可以使用格式化信息,如%date:yyyy-MM-dd%或%Empty Latent Image.width%"}), + "full_file_name": ("STRING", {"default": "", "tooltip": "完整的相对路径文件名,包括扩展名。若为空,则使用filename_prefix生成带序号的文件名"}), + "output_dir": ("STRING", {"default": "", "tooltip": "目标目录(绝对路径),不会自动创建。若为空,存放到output目录"}), + }, + "optional": { + "addMetadata": ("BOOLEAN", {"default": False, "label_on": "True", "label_off": "False"}), + }, + "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}, + } + + RETURN_TYPES = ("STRING", ) + RETURN_NAMES = ("file_path",) + + FUNCTION = "save_image" + + CATEGORY = "EasyApi/Image" + + DESCRIPTION = "保存图像到指定目录,可根据返回的文件路径进行后续操作,此节点为非输出节点,适合循环批处理和用于惰性求值的前置节点。只会处理一个" + OUTPUT_NODE = False + + def save_image(self, image, full_file_name, output_dir, filename_prefix="ComfyUI", addMetadata=False, prompt=None, extra_pnginfo=None): + imageList = list() + if not isinstance(image, list): + imageList.append(image) + else: + imageList = image + + if output_dir is None or len(output_dir.strip()) == 0: + output_dir = folder_paths.get_output_directory() + + if not os.path.isdir(output_dir) or not os.path.isabs(output_dir): + raise RuntimeError(f"目录 {output_dir} 不存在") + + if len(imageList) > 0: + image = imageList[0] + for (batch_number, image) in enumerate(image): + img = tensor_to_pil(image) + metadata = None + if not args.disable_metadata and addMetadata: + metadata = PngInfo() + if prompt is not None: + metadata.add_text("prompt", json.dumps(prompt)) + if extra_pnginfo is not None: + for x in extra_pnginfo: + metadata.add_text(x, json.dumps(extra_pnginfo[x])) + + if full_file_name is not None and len(full_file_name.strip()) > 0: + # full_file_name是相对路径,添加校验,并自动创建子目录 + full_path = os.path.join(output_dir, full_file_name) + full_normpath_name = os.path.normpath(full_path) + file_dir = os.path.dirname(full_normpath_name) + # 确保路径是out_dir 的子目录 + if not os.path.isabs(file_dir) or not file_dir.startswith(output_dir): + raise RuntimeError(f"文件 {full_file_name} 不在 {output_dir} 目录下") + if not os.path.isdir(file_dir): + os.makedirs(file_dir, exist_ok=True) + image_save_path = full_normpath_name + else: + full_output_folder, filename, counter, subfolder, curr_filename_prefix = folder_paths.get_save_image_path( + filename_prefix, output_dir, image.shape[1], image.shape[0]) + filename_with_batch_num = filename.replace("%batch_num%", str(batch_number)) + file = f"{filename_with_batch_num}_{counter:05}_.png" + image_save_path = os.path.join(full_output_folder, file) + + img.save(image_save_path, pnginfo=metadata, compress_level=self.compress_level) + return image_save_path, + + return (None,) + + NODE_CLASS_MAPPINGS = { "Base64ToImage": Base64ToImage, "LoadImageFromURL": LoadImageFromURL, @@ -459,6 +612,8 @@ NODE_CLASS_MAPPINGS = { "LoadImageToBase64": LoadImageToBase64, "LoadImageFromLocalPath": LoadImageFromLocalPath, "LoadMaskFromLocalPath": LoadMaskFromLocalPath, + "SaveImagesWithoutOutput": SaveImagesWithoutOutput, + "SaveSingleImageWithoutOutput": SaveSingleImageWithoutOutput, } # A dictionary that contains the friendly/humanly readable titles for the nodes @@ -475,4 +630,6 @@ NODE_DISPLAY_NAME_MAPPINGS = { "LoadImageToBase64": "Load Image To Base64", "LoadImageFromLocalPath": "Load Image From Local Path", "LoadMaskFromLocalPath": "Load Mask From Local Path", + "SaveImagesWithoutOutput": "Save Images Without Output", + "SaveSingleImageWithoutOutput": "Save Single Image Without Output", } diff --git a/easyapi/UtilNode.py b/easyapi/UtilNode.py index 87f7910..c0f5048 100644 --- a/easyapi/UtilNode.py +++ b/easyapi/UtilNode.py @@ -1,5 +1,7 @@ import mimetypes import os +import shutil +from collections import defaultdict import simplejson import torch @@ -188,6 +190,8 @@ class ListMerge: OUTPUT_NODE = False CATEGORY = "EasyApi/List" + DESCRIPTION = "合并两个列表。如 [1,2] 和 [3,4] => [1,2,3,4]" + def convert(self, list_a, list_b=None): list = [] + list_a if list_b: @@ -679,9 +683,9 @@ class LoadLocalFilePath: } } - RETURN_TYPES = ("LIST", "INT",) - RETURN_NAMES = ("paths", "count",) - OUTPUT_TOOLTIPS = ("文件路径列表,若过滤不到文件返回空列表", "文件个数",) + RETURN_TYPES = ("LIST", "INT", "LIST", "STRING", ) + RETURN_NAMES = ("paths", "count", "relative_path", "base_dir", ) + OUTPUT_TOOLTIPS = ("文件路径列表,若过滤不到文件返回空列表", "文件个数", "文件相对路径列表", "保存的根目录", ) FUNCTION = "get_paths" @@ -696,23 +700,31 @@ class LoadLocalFilePath: } @classmethod - def recursive_file_paths(cls, directory, max_depth, file_type, file_suffix, current_depth=1): + def recursive_file_paths(cls, directory, max_depth, file_type, file_suffix, base_directory=None, current_depth=1): """ 获取指定目录及其子目录中的图片文件路径(深度优先遍历) 参数: directory (str): 要遍历的目录路径 max_depth (int): 最大遍历层级 + file_type (str): 文件类型(如 'image') + file_suffix (str): 文件后缀(多个后缀用 '|' 分隔) + base_directory (str): 根目录路径(用于计算相对路径,默认为 None) current_depth (int): 当前遍历层级(默认值为1) 返回: List[str]: 图片文件路径列表 + List[str]: 图片文件相对路径列表 """ image_paths = [] + relative_image_paths = [] + + if base_directory is None: + base_directory = directory if current_depth > max_depth: - return image_paths + return image_paths, relative_image_paths with os.scandir(directory) as it: for item in it: @@ -721,20 +733,26 @@ class LoadLocalFilePath: suffixes = [s.strip().lower() for s in file_suffix.split('|')] if any(item.name.lower().endswith(suffix) for suffix in suffixes): image_paths.append(item.path) + relative_image_paths.append(os.path.relpath(item.path, base_directory)) elif file_type: mime_type, _ = mimetypes.guess_type(item.path) if mime_type in cls.mime_types_dict.get(file_type, set()): image_paths.append(item.path) + relative_image_paths.append(os.path.relpath(item.path, base_directory)) elif item.is_dir(): - image_paths.extend(cls.recursive_file_paths(item.path, max_depth, file_type, file_suffix, current_depth + 1)) - return image_paths + sub_image_paths, sub_relative_image_paths = cls.recursive_file_paths( + item.path, max_depth, file_type, file_suffix, base_directory, current_depth + 1 + ) + image_paths.extend(sub_image_paths) + relative_image_paths.extend(sub_relative_image_paths) + return image_paths, relative_image_paths def get_paths(self, directory, max_depth, file_type, file_suffix): if directory is None or len(directory.strip()) == 0: directory = folder_paths.get_input_directory() - image_paths = self.recursive_file_paths(directory, max_depth, file_type, file_suffix) + image_paths, relative_image_paths = self.recursive_file_paths(directory, max_depth, file_type, file_suffix) - return image_paths, len(image_paths), + return image_paths, len(image_paths), relative_image_paths, directory, class IsNoneOrEmpty: @@ -822,6 +840,112 @@ class EmptyOutputNode: return () +class SaveTextToFileByImagePath: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "image_path": ("STRING", {"forceInput": False}), + "text": ("STRING", {"forceInput": False, "dynamicPrompts": False, "multiline": True}), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("text_path",) + FUNCTION = "execute" + CATEGORY = "EasyApi/Utils" + DESCRIPTION = "把文本内容保存到图片路径同名的txt文件中" + + def execute(self, image_path, text): + # 校验图片路径是否存在 + if not os.path.isfile(image_path): + raise FileNotFoundError(f"Image file not found: {image_path}") + + # 校验文本内容是否为空 + if not text: + raise ValueError("The text cannot be empty") + + # 获取图片文件名,不包括扩展名 + base_name = os.path.splitext(os.path.basename(image_path))[0] + # 创建txt文件路径 + dir_name = os.path.dirname(image_path) + # 创建txt文件路径 + txt_path = os.path.join(dir_name, f"{base_name}.txt") + + # 写入文本内容到txt文件 + with open(txt_path, 'w', encoding='utf-8') as file: + file.write(text) + + return (txt_path,) + + +class CopyAndRenameFiles: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "directory": ("STRING", {"forceInput": False, "tooltip": "源目录"}), + "save_directory": ("STRING", {"default": "", "tooltip": "目标目录,为空时重命名原文件"}), + "prefix": ("STRING", {"default": "", "tooltip": "新文件名前缀"}), + "name_to_num": ("BOOLEAN", {"default": True, "tooltip": "后缀是否使用在对应目录的序号"}), + } + } + + RETURN_TYPES = ("STRING", ) + RETURN_NAMES = ("save_directory", ) + FUNCTION = "execute" + CATEGORY = "EasyApi/Utils" + DESCRIPTION = "把给定目录下的文件复制到指定目录" + + def execute(self, directory, save_directory, prefix, name_to_num): + """ + 递归遍历目录及其子目录中的文件,重命名或复制并重命名文件,添加自定义前缀。 + 如果 save_directory 为空,重命名原文件;否则,复制并重命名到目标目录,并保持层级结构,文件名为所在目录的计数编号。 + + :param directory: 需要重命名文件的目录路径 + :param prefix: 自定义前缀 + :param save_directory: 保存重命名文件的目录路径(可以为空) + :param name_to_num: 重命名文件名为数字 + """ + count_dict = defaultdict(int) # 用于存储每个目录的计数器 + + for root, _, files in os.walk(directory): + for filename in files: + old_path = os.path.join(root, filename) + if os.path.isfile(old_path): + count_dict[root] += 1 # 增加当前目录的计数器 + ext = os.path.splitext(filename)[1] + if prefix and len(prefix.strip()) > 0: + if name_to_num: + new_filename = f"{prefix.strip()}_{count_dict[root]}" + else: + new_filename = f"{prefix.strip()}_{filename}" + + else: + if name_to_num: + new_filename = f"{count_dict[root]}{ext}" + else: + new_filename = filename + + # 如果 save_directory 不为空,复制并重命名到目标目录,并保持层级结构 + if save_directory and len(save_directory.strip()) > 0: + # 计算保存文件的目标目录 + relative_path = os.path.relpath(root, directory) + target_dir = os.path.join(save_directory, relative_path) + if not os.path.exists(target_dir): + os.makedirs(target_dir) + new_path = os.path.join(target_dir, new_filename) + shutil.copyfile(old_path, new_path) + print(f"复制并重命名: {old_path} -> {new_path}") + else: + # 否则重命名原文件 + new_path = os.path.join(root, new_filename) + os.rename(old_path, new_path) + print(f"重命名: {old_path} -> {new_path}") + + return (save_directory, ) + + NODE_CLASS_MAPPINGS = { "GetImageBatchSize": GetImageBatchSize, "JoinList": JoinList, @@ -852,6 +976,8 @@ NODE_CLASS_MAPPINGS = { "IsNoneOrEmpty": IsNoneOrEmpty, "IsNoneOrEmptyOptional": IsNoneOrEmptyOptional, "EmptyOutputNode": EmptyOutputNode, + "SaveTextToFileByImagePath": SaveTextToFileByImagePath, + "CopyAndRenameFiles": CopyAndRenameFiles, } # A dictionary that contains the friendly/humanly readable titles for the nodes @@ -885,4 +1011,6 @@ NODE_DISPLAY_NAME_MAPPINGS = { "IsNoneOrEmpty": "IsNoneOrEmpty", "IsNoneOrEmptyOptional": "IsNoneOrEmptyOptional", "EmptyOutputNode": "EmptyOutputNode", + "SaveTextToFileByImagePath": "SaveTextToFileByImagePath", + "CopyAndRenameFiles": "CopyAndRenameFiles", } diff --git a/example/example_image_crop_tag.png b/example/example_image_crop_tag.png new file mode 100644 index 0000000..cc3da7c Binary files /dev/null and b/example/example_image_crop_tag.png differ diff --git a/pyproject.toml b/pyproject.toml index 4c5ae33..a89d0f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "comfyui-easyapi-nodes" description = "Provides some features and nodes related to API calls." -version = "1.0.6" +version = "1.0.7" license = { file = "LICENSE" } dependencies = ["segment_anything", "simple_lama_inpainting", "insightface"]