Files
lldacing-comfyui-easyapi-nodes/easyapi/ImageNode.py
T
2024-11-04 20:17:49 +08:00

636 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import base64
import copy
import io
import os
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
from PIL.PngImagePlugin import PngInfo
import json
from json import JSONEncoder, JSONDecoder
from .util import tensor_to_pil, pil_to_tensor, base64_to_image, image_to_base64, read_image_from_url
class LoadImageFromURL:
"""
从远程地址读取图片
"""
@classmethod
def INPUT_TYPES(self):
return {"required": {
"urls": ("STRING", {"multiline": True, "default": "", "dynamicPrompts": False}),
},
}
RETURN_TYPES = ("IMAGE", "MASK")
RETURN_NAMES = ("images", "masks")
FUNCTION = "convert"
CATEGORY = "EasyApi/Image"
# INPUT_IS_LIST = False
OUTPUT_IS_LIST = (True, True,)
def convert(self, urls):
urls = urls.splitlines()
images = []
masks = []
for url in urls:
if not url.strip().isspace():
i = read_image_from_url(url.strip())
i = ImageOps.exif_transpose(i)
if i.mode == 'I':
i = i.point(lambda i: i * (1 / 255))
image = i.convert("RGB")
image = pil_to_tensor(image)
images.append(image)
if 'A' in i.getbands():
mask = np.array(i.getchannel('A')).astype(np.float32) / 255.0
mask = 1. - torch.from_numpy(mask)
else:
mask = torch.zeros((64, 64), dtype=torch.float32, device="cpu")
masks.append(mask.unsqueeze(0))
return (images, masks, )
class LoadMaskFromURL:
"""
从远程地址读取图片
"""
_color_channels = ["red", "green", "blue", "alpha"]
@classmethod
def INPUT_TYPES(self):
return {
"required": {
"urls": ("STRING", {"multiline": True, "default": "", "dynamicPrompts": False}),
"channel": (self._color_channels, {"default": self._color_channels[0]}),
},
}
RETURN_TYPES = ("MASK", )
RETURN_NAMES = ("masks", )
FUNCTION = "convert"
CATEGORY = "EasyApi/Image"
# INPUT_IS_LIST = False
OUTPUT_IS_LIST = (True, True,)
def convert(self, urls, channel=_color_channels[0]):
urls = urls.splitlines()
masks = []
for url in urls:
if not url.strip().isspace():
i = read_image_from_url(url.strip())
# 下面代码参考LoadImage
i = ImageOps.exif_transpose(i)
if i.getbands() != ("R", "G", "B", "A"):
i = i.convert("RGBA")
c = channel[0].upper()
if c in i.getbands():
mask = np.array(i.getchannel(c)).astype(np.float32) / 255.0
mask = torch.from_numpy(mask)
if c == 'A':
mask = 1. - mask
else:
mask = torch.zeros((64, 64), dtype=torch.float32, device="cpu")
masks.append(mask.unsqueeze(0))
return (masks,)
class Base64ToImage:
"""
图片的base64格式还原成图片的张量
"""
@classmethod
def INPUT_TYPES(self):
return {"required": {
"base64Images": ("STRING", {"multiline": True, "default": "[\"\"]", "dynamicPrompts": False}),
},
}
RETURN_TYPES = ("IMAGE", "MASK")
# RETURN_NAMES = ("image", "mask")
FUNCTION = "convert"
CATEGORY = "EasyApi/Image"
# INPUT_IS_LIST = False
OUTPUT_IS_LIST = (True, True)
def convert(self, base64Images):
# print(base64Image)
base64ImageJson = JSONDecoder().decode(s=base64Images)
images = []
masks = []
for base64Image in base64ImageJson:
i = base64_to_image(base64Image)
# 下面代码参考LoadImage
i = ImageOps.exif_transpose(i)
image = i.convert("RGB")
image = np.array(image).astype(np.float32) / 255.0
image = torch.from_numpy(image)[None, ]
if 'A' in i.getbands():
mask = np.array(i.getchannel('A')).astype(np.float32) / 255.0
mask = 1. - torch.from_numpy(mask)
else:
mask = torch.zeros((64, 64), dtype=torch.float32, device="cpu")
images.append(image)
masks.append(mask.unsqueeze(0))
return (images, masks,)
class ImageToBase64Advanced:
def __init__(self):
self.imageType = "image"
@classmethod
def INPUT_TYPES(self):
return {"required": {
"images": ("IMAGE",),
"imageType": (["image", "mask"], {"default": "image"}),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("base64Images",)
FUNCTION = "convert"
# 作为输出节点,返回数据格式是{"ui": {output_name:value}, "result": (value,)}
# ui中是websocket返回给前端的内容,result是py执行传给下个节点用的
OUTPUT_NODE = True
CATEGORY = "EasyApi/Image"
# INPUT_IS_LIST = False
# OUTPUT_IS_LIST = (False,False,)
def convert(self, images, imageType=None, prompt=None, extra_pnginfo=None):
if imageType is None:
imageType = self.imageType
result = list()
for i in images:
img = tensor_to_pil(i)
metadata = None
if not args.disable_metadata:
metadata = PngInfo()
if prompt is not None:
newPrompt = copy.deepcopy(prompt)
for idx in newPrompt:
node = newPrompt[idx]
if node['class_type'] == 'Base64ToImage' or node['class_type'] == 'Base64ToMask':
node['inputs']['base64Images'] = ""
metadata.add_text("prompt", json.dumps(newPrompt))
if extra_pnginfo is not None:
for x in extra_pnginfo:
metadata.add_text(x, json.dumps(extra_pnginfo[x]))
# 将图像数据编码为Base64字符串
encoded_image = image_to_base64(img, pnginfo=metadata)
result.append(encoded_image)
base64Images = JSONEncoder().encode(result)
# print(images)
return {"ui": {"base64Images": result, "imageType": [imageType]}, "result": (base64Images,)}
class ImageToBase64(ImageToBase64Advanced):
def __init__(self):
self.imageType = "image"
@classmethod
def INPUT_TYPES(self):
return {"required": {
"images": ("IMAGE",),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
class MaskImageToBase64(ImageToBase64):
def __init__(self):
self.imageType = "mask"
class MaskToBase64Image(MaskImageToBase64):
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"mask": ("MASK",),
}
}
CATEGORY = "EasyApi/Image"
RETURN_TYPES = ("STRING",)
FUNCTION = "mask_to_base64image"
def mask_to_base64image(self, mask):
"""将一个二维的掩码张量扩展为一个四维的彩色图像张量。具体的步骤如下:
第一行,使用 torch.reshape 函数,将掩码张量的形状改变为(-1, 1, mask.shape[-2], mask.shape[-1]),
其中 - 1 表示自动推断该维度的大小,1 表示增加一个新的维度,mask.shape[-2] 和 mask.shape[-1] 表示保持原来的最后两个维度不变。
这样,掩码张量就变成了一个四维的张量,其中第二个维度只有一个通道。
第二行,使用 torch.movedim 函数,将掩码张量的第二个维度(通道维度)移动到最后一个维度的位置,即将形状为(-1, 1, mask.shape[-2], mask.shape[-1])
的张量变为(-1, mask.shape[-2], mask.shape[-1], 1) 的张量。这样,掩码张量就变成了一个符合图像格式的张量,其中最后一个维度表示通道数。
第三行,使用 torch.Tensor.expand 函数,将掩码张量的最后一个维度(通道维度)扩展为 3,即将形状为(-1, mask.shape[-2], mask.shape[-1], 1) 的张量变为(-1, mask.shape[-2], mask.shape[-1], 3) 的张量。这样,掩码张量就变成了一个彩色图像张量,其中最后一个维度表示红、绿、蓝三个通道。
这段代码的结果是一个与原来的掩码张量相同元素的彩色图像张量,表示掩码的颜色
"""
images = mask.reshape((-1, 1, mask.shape[-2], mask.shape[-1])).movedim(1, -1).expand(-1, -1, -1, 3)
return super().convert(images)
class MaskToBase64(MaskImageToBase64):
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"mask": ("MASK",),
}
}
CATEGORY = "EasyApi/Image"
RETURN_TYPES = ("STRING",)
FUNCTION = "mask_to_base64image"
def mask_to_base64image(self, mask):
return super().convert(mask)
class Base64ToMask:
"""
mask的base64图片还原成mask的张量
"""
_color_channels = ["red", "green", "blue", "alpha"]
@classmethod
def INPUT_TYPES(s):
return {
"required": {
# "base64Images": ("STRING", {"forceInput": True}),
"base64Images": ("STRING", {"multiline": True, "default": "[\"\"]", "dynamicPrompts": False}),
"channel": (s._color_channels, {"default": s._color_channels[0]}), }
}
CATEGORY = "EasyApi/Image"
RETURN_TYPES = ("MASK",)
FUNCTION = "base64image_to_mask"
def base64image_to_mask(self, base64Images, channel=_color_channels[0]):
base64ImageJson = JSONDecoder().decode(s=base64Images)
for base64Image in base64ImageJson:
i = base64_to_image(base64Image)
# 下面代码参考LoadImage
i = ImageOps.exif_transpose(i)
if i.getbands() != ("R", "G", "B", "A"):
i = i.convert("RGBA")
mask = None
c = channel[0].upper()
if c in i.getbands():
mask = np.array(i.getchannel(c)).astype(np.float32) / 255.0
mask = torch.from_numpy(mask)
if c == 'A':
mask = 1. - mask
else:
mask = torch.zeros((64,64), dtype=torch.float32, device="cpu")
return (mask.unsqueeze(0),)
class LoadImageToBase64(LoadImage):
RETURN_TYPES = ("STRING", "IMAGE", "MASK", )
RETURN_NAMES = ("base64Images", "IMAGE", "MASK", )
FUNCTION = "convert"
OUTPUT_NODE = True
CATEGORY = "EasyApi/Image"
# INPUT_IS_LIST = False
# OUTPUT_IS_LIST = (False,False,)
def convert(self, image):
img, mask = self.load_image(image)
i = tensor_to_pil(img)
# 创建一个BytesIO对象,用于临时存储图像数据
image_data = io.BytesIO()
# 将图像保存到BytesIO对象中,格式为PNG
i.save(image_data, format='PNG')
# 将BytesIO对象的内容转换为字节串
image_data_bytes = image_data.getvalue()
# 将图像数据编码为Base64字符串
encoded_image = "[\"data:image/png;base64," + base64.b64encode(image_data_bytes).decode('utf-8') + "\"]"
return encoded_image, img, mask
class LoadImageFromLocalPath:
@classmethod
def INPUT_TYPES(s):
return {"required":
{
"image_path": ("STRING", {"default": ""},)
},
}
CATEGORY = "EasyApi/Image"
RETURN_TYPES = ("IMAGE", "MASK")
FUNCTION = "load_image"
def load_image(self, image_path):
img = node_helpers.pillow(Image.open, image_path)
output_images = []
output_masks = []
w, h = None, None
excluded_formats = ['MPO']
# 遍历图像的每一帧
for i in ImageSequence.Iterator(img):
# 旋转图像
i = node_helpers.pillow(ImageOps.exif_transpose, i)
if i.mode == 'I':
i = i.point(lambda i: i * (1 / 255))
# 将图像转换为RGB格式
image = i.convert("RGB")
if len(output_images) == 0:
w = image.size[0]
h = image.size[1]
if image.size[0] != w or image.size[1] != h:
continue
# 将图像转换为浮点数组 (H,W,Channel)
image = np.array(image).astype(np.float32) / 255.0
# 先把图片转成3维张量,并再在最前面添加一个维度,变成4维(1, H, W,Channel)
image = torch.from_numpy(image)[None,]
# 如果图像包含alpha通道,则将其转换为掩码
if 'A' in i.getbands():
# 计算后结果数组中透明像素会是0
mask = np.array(i.getchannel('A')).astype(np.float32) / 255.0
# 把数组中透明像素设为1
mask = 1. - torch.from_numpy(mask)
else:
# 否则,创建一个64x64的零张量作为掩码
mask = torch.zeros((64, 64,), dtype=torch.float32, device="cpu")
# 将图像和掩码添加到输出列表中
output_images.append(image)
output_masks.append(mask.unsqueeze(0))
if len(output_images) > 1 and img.format not in excluded_formats:
# 如果有多个图像,则将它们按维度0拼接在一起
output_image = torch.cat(output_images, dim=0)
output_mask = torch.cat(output_masks, dim=0)
# 否则,返回单个图像和掩码
else:
output_image = output_images[0]
output_mask = output_masks[0]
# 返回输出图像和掩码
return (output_image, output_mask)
class LoadMaskFromLocalPath:
_color_channels = ["alpha", "red", "green", "blue"]
@classmethod
def INPUT_TYPES(s):
return {"required":
{
"image_path": ("STRING", {"default": ""}),
"channel": (s._color_channels, ),
}
}
CATEGORY = "EasyApi/Image"
RETURN_TYPES = ("MASK",)
FUNCTION = "load_mask"
def load_mask(self, image_path, channel):
i = node_helpers.pillow(Image.open, image_path)
i = node_helpers.pillow(ImageOps.exif_transpose, i)
if i.getbands() != ("R", "G", "B", "A"):
if i.mode == 'I':
i = i.point(lambda i: i * (1 / 255))
i = i.convert("RGBA")
mask = None
c = channel[0].upper()
if c in i.getbands():
mask = np.array(i.getchannel(c)).astype(np.float32) / 255.0
mask = torch.from_numpy(mask)
if c == 'A':
mask = 1. - mask
else:
mask = torch.zeros((64, 64), dtype=torch.float32, device="cpu")
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": "要保存的文件的前缀。支持的占位符:%width% %height% %year% %month% %day% %hour% %minute% %second%"}),
"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,
"LoadMaskFromURL": LoadMaskFromURL,
"ImageToBase64": ImageToBase64,
# "MaskToBase64": MaskToBase64,
"Base64ToMask": Base64ToMask,
"ImageToBase64Advanced": ImageToBase64Advanced,
"MaskToBase64Image": MaskToBase64Image,
"MaskImageToBase64": MaskImageToBase64,
"LoadImageToBase64": LoadImageToBase64,
"LoadImageFromLocalPath": LoadImageFromLocalPath,
"LoadMaskFromLocalPath": LoadMaskFromLocalPath,
"SaveImagesWithoutOutput": SaveImagesWithoutOutput,
"SaveSingleImageWithoutOutput": SaveSingleImageWithoutOutput,
}
# A dictionary that contains the friendly/humanly readable titles for the nodes
NODE_DISPLAY_NAME_MAPPINGS = {
"Base64ToImage": "Base64 To Image",
"LoadImageFromURL": "Load Image From Url",
"LoadMaskFromURL": "Load Image From Url (As Mask)",
"ImageToBase64": "Image To Base64",
# "MaskToBase64": "Mask To Base64",
"Base64ToMask": "Base64 To Mask",
"ImageToBase64Advanced": "Image To Base64 (Advanced)",
"MaskToBase64Image": "Mask To Base64 Image",
"MaskImageToBase64": "Mask Image To Base64",
"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",
}