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的区域中心裁剪指定大小图片 |
### 示例

@@ -81,8 +86,12 @@ Tips: base64格式字符串比较长,会导致界面卡顿,接口请求带



+ 
## 更新记录
+### 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"]