Files
yorkane-ComfyUI-KYNode/KY_Image.py
T
2025-06-13 00:13:03 +08:00

380 lines
14 KiB
Python

import os
import re
from pathlib import Path
import numpy as np
import torch
from PIL import Image, ImageOps
import cv2
import base64
import comfy.utils
import folder_paths
from .utils.net_io import load_images_from_url
from .utils.image_convert import pil2tensor, tensor2pil
_CATEGORY = 'KYNode/image'
class ReadImage:
@classmethod
def INPUT_TYPES(cls):
return {
'required': {
'image_path': ('STRING', {'default': 'images'}),
}
}
RETURN_TYPES = ('IMAGE', 'STRING')
RETURN_NAMES = ('image', 'file_stem')
FUNCTION = 'execute'
CATEGORY = _CATEGORY
DESCRIPTION = 'Read image from path'
def execute(self, image_path):
# 去掉可能存在的双引号
image_path = image_path.strip('"')
if not os.path.exists(image_path):
raise FileNotFoundError(f'文件未找到: {image_path}')
file_stem = str(Path(image_path).stem)
img = Image.open(image_path)
img = ImageOps.exif_transpose(img)
if img is None:
raise ValueError(f'无法从文件中读取有效图像: {image_path}')
if img.mode == 'I':
img = img.point(lambda i: i * (1 / 255))
img = img.convert('RGB')
image = np.array(img).astype(np.float32) / 255.0
image = torch.from_numpy(image)[None,]
return (image, file_stem)
class LoadImagesFromFolder:
@classmethod
def INPUT_TYPES(cls):
return {
'required': {
'input_path': ('STRING', {'default': '', 'multiline': False}),
'start_index': ('INT', {'default': 0, 'min': 0, 'max': 9999}),
'max_index': ('INT', {'default': 1, 'min': 1, 'max': 9999}),
'step': ('INT', {'default': 1, 'min': 1, 'max': 100}),
}
}
RETURN_TYPES = (
'IMAGE',
'STRING',
)
RETURN_NAMES = (
'image_batch',
'file_names',
)
OUTPUT_IS_LIST = (
False,
True,
)
FUNCTION = 'make_list'
CATEGORY = _CATEGORY
DESCRIPTION = 'read images from folder and return image batch and file names'
def make_list(self, start_index, max_index, input_path, step):
# 判断是否为绝对路径
if os.path.isabs(input_path):
full_input_dir = input_path
else:
# 相对路径则基于输出目录
full_input_dir = os.path.join(folder_paths.get_output_directory(), input_path)
# 检查输入路径是否存在
if not os.path.exists(full_input_dir):
raise FileNotFoundError(f'文件夹未找到: {full_input_dir}')
# 检查文件夹是否为空
if not os.listdir(full_input_dir):
raise ValueError(f'文件夹为空: {full_input_dir}')
# 对文件列表进行排序
file_list = sorted(
os.listdir(full_input_dir),
key=lambda s: sum(((s, int(n)) for s, n in re.findall(r'(\D+)(\d+)', 'a%s0' % s)), ()),
)
image_list = []
file_names = [] # 新增文件名列表
# 确保 start_index 在列表范围内
start_index = max(0, min(start_index, len(file_list) - 1))
# 计算结束索引
end_index = min(start_index + max_index, len(file_list))
ref_image = None
for num in range(start_index, end_index, step):
fname = os.path.join(full_input_dir, file_list[num])
file_names.append(file_list[num]) # 添加文件名到列表
img = Image.open(fname)
img = ImageOps.exif_transpose(img)
if img is None:
raise ValueError(f'无法从文件中读取有效图像: {fname}')
image = img.convert('RGB')
t_image = pil2tensor(image)
# 确保所有图像的尺寸相同
if ref_image is None:
ref_image = t_image
else:
if t_image.shape[1:] != ref_image.shape[1:]:
t_image = comfy.utils.common_upscale(
t_image.movedim(-1, 1),
ref_image.shape[2],
ref_image.shape[1],
'lanczos',
'center',
).movedim(1, -1)
image_list.append(t_image)
if not image_list:
raise ValueError('未找到有效图像')
image_batch = torch.cat(image_list, dim=0)
return (
image_batch,
file_names,
)
class KY_SaveImageToPath:
@classmethod
def INPUT_TYPES(s):
return {"required":
{"images": ("IMAGE",),
"img_template": ("STRING", {"default": "IMG-######.png"}),
"start_index": ("INT", {"default": 1, "min": 0, "max": 999999}),
"quality": ("INT", {"default": 95, "min": 50, "max": 100}),
"extension": (["png", "webp", "jpg"],)},
"optional": {
"lossless": ("BOOLEAN", {"default": False}),
"optimize": ("BOOLEAN", {"default": False}),
"overwrite": ("BOOLEAN", {"default": True}),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "save_image_to_path"
OUTPUT_NODE = True
CATEGORY = _CATEGORY
DESCRIPTION = """save images to path, accept image batch and file template
Default template: IMG-xx-######.png will save to output/IMG-xx-######.png
##### will be replaced by start_index auto-increment (only if # exists in filename)
If no # in filename, will use original filename without sequence processing
If not overwrite, it will add suffix [####] format (4-digit zero-padded, max 9999)
"""
def save_image_to_path(self, images, img_template="IMG-#####.png",
start_index=1, quality=95, extension="png",
lossless=False, optimize=False, overwrite=True,
prompt=None, extra_pnginfo=None):
saved_paths = []
# 分离目录和文件名模板
template_dir = os.path.dirname(img_template)
template_filename = os.path.basename(img_template)
# 判断是否为绝对路径
if os.path.isabs(template_dir):
full_output_dir = template_dir
else:
# 相对路径则基于输出目录
full_output_dir = os.path.join(folder_paths.get_output_directory(), template_dir)
# 确保目录存在
os.makedirs(full_output_dir, exist_ok=True)
# 计算需要补零的位数
zero_count = template_filename.count('#')
name, ext = os.path.splitext(template_filename)
# 保存所有图片
for i, image in enumerate(images):
# 只有当文件名中包含#占位符时才进行补零和序列处理
if zero_count > 0:
# 生成文件名
current_index = start_index + i
number_str = str(current_index).zfill(zero_count)
# 替换模板中的 # 为实际数字
current_name = name.replace('#' * zero_count, number_str)
current_filename = f"{current_name}.{extension}"
else:
# 如果没有占位符,直接使用原始文件名
current_name = name
current_filename = f"{current_name}.{extension}"
current_path = os.path.join(full_output_dir, current_filename)
# 检查文件是否存在且不允许覆盖
if not overwrite and os.path.exists(current_path):
# 自动为文件名添加后缀,格式为[####],4位0填充
counter = 1
file_name_base, file_ext = os.path.splitext(current_filename)
while os.path.exists(current_path):
if counter > 9999:
raise ValueError(f'已经存在文件后缀超过最大值9999: {current_path}')
current_filename = f"{file_name_base}-[{str(counter).zfill(4)}]{file_ext}"
current_path = os.path.join(full_output_dir, current_filename)
counter += 1
# 转换并保存图片
img = Image.fromarray((image.cpu().numpy() * 255).astype(np.uint8))
img.save(current_path,
quality=quality,
lossless=lossless if extension == "webp" else None,
optimize=optimize)
# 生成相对路径用于返回
relative_path = os.path.join(template_dir, current_filename)
saved_paths.append(relative_path)
return {
"ui": {"images": saved_paths},
"result": (images,)
}
class KY_LoadImageFrom:
_CATEGORY = 'KYNode/image' # Using the common category from the file
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"input_string": ("STRING", {"default": "", "multiline": True, "dynamicPrompts": False, "placeholder": "Enter path, URL, or Base64 data (supports multiple URLs separated by newlines)"}),
},
"optional": {
"input_image": ("IMAGE",),
"input_mask": ("MASK",),
}
}
RETURN_TYPES = ("IMAGE", "MASK")
RETURN_NAMES = ("image", "mask")
OUTPUT_IS_LIST = (True, True)
FUNCTION = "execute_load"
CATEGORY = _CATEGORY
OUTPUT_NODE = True
def _process_pil_image_to_tensors(self, pil_image):
if pil_image is None:
return None, None
mask_tensor = None
if pil_image.mode == 'RGBA':
alpha = pil_image.split()[-1]
mask_pil = Image.new('L', pil_image.size)
mask_pil.putalpha(alpha)
# pil2tensor for 'L' mode gives (1, H, W, 1). We want (1, H, W) for the mask.
mask_tensor = pil2tensor(mask_pil)[0, ..., 0].unsqueeze(0) # Results in (1, H, W)
img_pil_rgb = pil_image.convert('RGB')
image_tensor = pil2tensor(img_pil_rgb) # Results in (1, H, W, C) for RGB
return image_tensor, mask_tensor
def execute_load(self, input_string="", input_image=None, input_mask=None):
loaded_images = []
loaded_masks = []
if input_string and input_string.strip():
# 支持多行输入,每行一个URL/路径/Base64
lines = [line.strip() for line in input_string.strip().split('\n') if line.strip()]
try:
# 使用net_io中的load_images_from_url方法统一处理各种输入类型
pil_images, pil_masks = load_images_from_url(lines, keep_alpha_channel=True)
for i, pil_image in enumerate(pil_images):
if pil_image is not None:
image_tensor, mask_tensor = self._process_pil_image_to_tensors(pil_image)
if image_tensor is not None:
loaded_images.append(image_tensor)
loaded_masks.append(mask_tensor)
except Exception as e:
print(f"Warning: Failed to load from input_string: {e}")
# Fallback to input_image if string loading failed or no string provided
if not loaded_images and input_image is not None:
loaded_images.append(input_image)
loaded_masks.append(None)
if not loaded_images:
raise ValueError("KY_LoadImageFrom: No valid image. Provide path, URL, Base64, or connect image.")
# 处理mask
final_masks = []
for i, loaded_mask in enumerate(loaded_masks):
final_mask = None
if loaded_mask is not None:
final_mask = loaded_mask
elif input_mask is not None:
final_mask = input_mask
# 如果输入mask只有一个但图片有多个,需要复制mask
if i > 0 and input_mask.shape[0] == 1:
if (input_mask.shape[1] == loaded_images[i].shape[1] and
input_mask.shape[2] == loaded_images[i].shape[2]):
final_mask = input_mask
else:
print(f"Warning: KY_LoadImageFrom - input_mask dimensions mismatch with image {i} dimensions.")
final_masks.append(final_mask)
# 处理多个图片:确保每个图片都有正确的维度
processed_images = []
processed_masks = []
for img_tensor in loaded_images:
# 确保张量维度正确:应该是 (1, H, W, C)
while img_tensor.dim() > 4:
img_tensor = img_tensor.squeeze(0)
if img_tensor.dim() == 3: # (H, W, C)
img_tensor = img_tensor.unsqueeze(0) # -> (1, H, W, C)
elif img_tensor.dim() == 4 and img_tensor.shape[0] != 1:
# 如果第一个维度不是1,可能需要重新整理
img_tensor = img_tensor.squeeze(0).unsqueeze(0)
processed_images.append(img_tensor)
for mask_tensor in final_masks:
if mask_tensor is not None:
# 确保mask维度正确:应该是 (1, H, W)
while mask_tensor.dim() > 3:
mask_tensor = mask_tensor.squeeze(0)
if mask_tensor.dim() == 2: # (H, W)
mask_tensor = mask_tensor.unsqueeze(0) # -> (1, H, W)
elif mask_tensor.dim() == 3 and mask_tensor.shape[0] != 1:
mask_tensor = mask_tensor.squeeze(0).unsqueeze(0)
processed_masks.append(mask_tensor)
else:
processed_masks.append(None)
# 始终返回列表格式,匹配OUTPUT_IS_LIST配置
return (processed_images, processed_masks)
IMG_CLASS_MAPPINGS = {
"KY_ReadImage": ReadImage,
"KY_LoadImagesFromFolder": LoadImagesFromFolder,
"KY_SaveImageToPath": KY_SaveImageToPath,
"KY_LoadImageFrom": KY_LoadImageFrom
}
IMG_NAME_MAPPINGS = {
"KY_ReadImage": "Read Image from Path",
"KY_LoadImagesFromFolder": "Load Images From Folder",
"KY_SaveImageToPath": "Save Images To Path with sequence number",
"KY_LoadImageFrom": "Load Image (Path/URL/Base64/Input)"
}