ADD 增加两个图像编辑节点

This commit is contained in:
2025-07-11 16:52:46 +08:00
parent c91e506fd5
commit 1b0f7ea4ae
4 changed files with 295 additions and 75 deletions

View File

@@ -1,7 +1,8 @@
from .nodes.image_face_nodes import FaceDetect, FaceExtract from .nodes.image_face_nodes import FaceDetect, FaceExtract
from .nodes.image_gesture_nodes import JMGestureCorrect from .nodes.image_gesture_nodes import JMGestureCorrect
from .nodes.image_nodes import SaveImagePath, LoadNetImg, SaveImageWithOutput from .nodes.image_nodes import SaveImagePath, LoadNetImg, SaveImageWithOutput
from .nodes.llm_nodes import LLMChat, LLMChatMultiModalImageUpload, LLMChatMultiModalImageTensor, Jinja2RenderTemplate from .nodes.llm_nodes import LLMChat, LLMChatMultiModalImageUpload, LLMChatMultiModalImageTensor, Jinja2RenderTemplate, \
ModalClothesMask, ModalEditCustom
from .nodes.object_storage_nodes import COSUpload, COSDownload, S3Download, S3Upload, S3UploadURL from .nodes.object_storage_nodes import COSUpload, COSDownload, S3Download, S3Upload, S3UploadURL
from .nodes.text_nodes import StringEmptyJudgement, LoadTextLocal, LoadTextOnline, RandomLineSelector from .nodes.text_nodes import StringEmptyJudgement, LoadTextLocal, LoadTextOnline, RandomLineSelector
from .nodes.util_nodes import LogToDB, TaskIdGenerate, TraverseFolder, UnloadAllModels, VodToLocalNode, \ from .nodes.util_nodes import LogToDB, TaskIdGenerate, TraverseFolder, UnloadAllModels, VodToLocalNode, \
@@ -40,7 +41,9 @@ NODE_CLASS_MAPPINGS = {
"LLMChatMultiModalImageUpload": LLMChatMultiModalImageUpload, "LLMChatMultiModalImageUpload": LLMChatMultiModalImageUpload,
"LLMChatMultiModalImageTensor": LLMChatMultiModalImageTensor, "LLMChatMultiModalImageTensor": LLMChatMultiModalImageTensor,
"Jinja2RenderTemplate": Jinja2RenderTemplate, "Jinja2RenderTemplate": Jinja2RenderTemplate,
"JMGestureCorrect": JMGestureCorrect "JMGestureCorrect": JMGestureCorrect,
"ModalClothesMask": ModalClothesMask,
"ModalEditCustom": ModalEditCustom
} }
NODE_DISPLAY_NAME_MAPPINGS = { NODE_DISPLAY_NAME_MAPPINGS = {
@@ -74,5 +77,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"LLMChatMultiModalImageUpload": "多模态LLM调用-图片Path", "LLMChatMultiModalImageUpload": "多模态LLM调用-图片Path",
"LLMChatMultiModalImageTensor": "多模态LLM调用-图片Tensor", "LLMChatMultiModalImageTensor": "多模态LLM调用-图片Tensor",
"Jinja2RenderTemplate": "Jinja2格式Prompt模板渲染", "Jinja2RenderTemplate": "Jinja2格式Prompt模板渲染",
"JMGestureCorrect": "人物侧身图片转为正面图-即梦" "JMGestureCorrect": "人物侧身图片转为正面图-即梦",
"ModalClothesMask": "模特指定衣服替换为指定颜色",
"ModalEditCustom": "自定义Prompt修改图片"
} }

View File

@@ -1,4 +1,5 @@
# 文件名 comfyui_v2.py # 文件名 comfyui_v2.py
import os
import subprocess import subprocess
import modal import modal
@@ -13,7 +14,7 @@ image = (
.run_commands( .run_commands(
"comfy --skip-prompt install --fast-deps --nvidia --version 0.3.40" "comfy --skip-prompt install --fast-deps --nvidia --version 0.3.40"
) )
.pip_install_from_pyproject("./pyproject.toml") .pip_install_from_pyproject(os.path.join(os.path.dirname(__file__),"pyproject.toml"))
.run_commands("comfy node install https://e.coding.net/g-ldyi2063/dev/ComfyUI-CustomNode.git", force_build=True) .run_commands("comfy node install https://e.coding.net/g-ldyi2063/dev/ComfyUI-CustomNode.git", force_build=True)
.run_commands("comfy node install https://github.com/yolain/ComfyUI-Easy-Use.git") .run_commands("comfy node install https://github.com/yolain/ComfyUI-Easy-Use.git")
.run_commands("cp -f /root/comfy/ComfyUI/custom_nodes/ComfyUI-CustomNode/ext/nodes_bfl.py /root/comfy/ComfyUI/comfy_api_nodes/nodes_bfl.py") .run_commands("cp -f /root/comfy/ComfyUI/custom_nodes/ComfyUI-CustomNode/ext/nodes_bfl.py /root/comfy/ComfyUI/comfy_api_nodes/nodes_bfl.py")

View File

@@ -5,18 +5,23 @@ import json
import os import os
import re import re
from mimetypes import guess_type from mimetypes import guess_type
from time import sleep
from typing import Any, Union from typing import Any, Union
import folder_paths import folder_paths
import httpx import httpx
import numpy as np import numpy as np
import requests
import torch import torch
from PIL import Image from PIL import Image
from jinja2 import Template, StrictUndefined from jinja2 import Template, StrictUndefined
from loguru import logger
from retry import retry from retry import retry
from ..utils.image_utils import tensor_to_image_bytes, base64_to_tensor
def find_value_recursive(key:str, data:Union[dict, list]) -> str | None | Any:
def find_value_recursive(key: str, data: Union[dict, list]) -> str | None | Any:
if isinstance(data, dict): if isinstance(data, dict):
if key in data: if key in data:
return data[key] return data[key]
@@ -31,6 +36,7 @@ def find_value_recursive(key:str, data:Union[dict, list]) -> str | None | Any:
if result is not None: if result is not None:
return result return result
def image_tensor_to_base64(image): def image_tensor_to_base64(image):
pil_image = Image.fromarray(np.clip(255. * image.cpu().numpy().squeeze(), 0, 255).astype(np.uint8)) pil_image = Image.fromarray(np.clip(255. * image.cpu().numpy().squeeze(), 0, 255).astype(np.uint8))
# 创建一个BytesIO对象用于临时存储图像数据 # 创建一个BytesIO对象用于临时存储图像数据
@@ -47,6 +53,7 @@ def image_tensor_to_base64(image):
return encoded_image return encoded_image
class LLMChat: class LLMChat:
"""llm chat""" """llm chat"""
@@ -63,8 +70,8 @@ class LLMChat:
"deepseek-v3", "deepseek-v3",
"deepseek-r1"],), "deepseek-r1"],),
"prompt": ("STRING", {"multiline": True}), "prompt": ("STRING", {"multiline": True}),
"temperature": ("FLOAT",{"default": 0.7, "min": 0.0, "max": 1.0}), "temperature": ("FLOAT", {"default": 0.7, "min": 0.0, "max": 1.0}),
"max_tokens": ("INT",{"default": 4096, "min":1, "max":65535}), "max_tokens": ("INT", {"default": 4096, "min": 1, "max": 65535}),
"timeout": ("INT", {"default": 120, "min": 30, "max": 900}), "timeout": ("INT", {"default": 120, "min": 30, "max": 900}),
} }
} }
@@ -74,28 +81,28 @@ class LLMChat:
FUNCTION = "chat" FUNCTION = "chat"
CATEGORY = "不忘科技-自定义节点🚩/LLM" CATEGORY = "不忘科技-自定义节点🚩/LLM"
def chat(self, llm_provider:str, prompt:str, temperature:float, max_tokens:int, timeout:int): def chat(self, llm_provider: str, prompt: str, temperature: float, max_tokens: int, timeout: int):
@retry(Exception, tries=3, delay=1) @retry(Exception, tries=3, delay=1)
def _chat(): def _chat():
try: try:
with httpx.Client(timeout=httpx.Timeout(timeout, connect=15)) as session: with httpx.Client(timeout=httpx.Timeout(timeout, connect=15)) as session:
resp = session.post("https://gateway.bowong.cc/chat/completions", resp = session.post("https://gateway.bowong.cc/chat/completions",
headers={ headers={
"Content-Type": "application/json", "Content-Type": "application/json",
"Accept": "application/json", "Accept": "application/json",
"Authorization": "Bearer auth-bowong7777" "Authorization": "Bearer auth-bowong7777"
}, },
json={ json={
"model": llm_provider, "model": llm_provider,
"messages": [ "messages": [
{ {
"role": "user", "role": "user",
"content": prompt "content": prompt
} }
], ],
"temperature": temperature, "temperature": temperature,
"max_tokens": max_tokens "max_tokens": max_tokens
}) })
resp.raise_for_status() resp.raise_for_status()
resp = resp.json() resp = resp.json()
content = find_value_recursive("content", resp) content = find_value_recursive("content", resp)
@@ -103,8 +110,10 @@ class LLMChat:
except Exception as e: except Exception as e:
raise Exception("llm调用失败 {}".format(e)) raise Exception("llm调用失败 {}".format(e))
return (content,) return (content,)
return _chat() return _chat()
class LLMChatMultiModalImageUpload: class LLMChatMultiModalImageUpload:
"""llm chat""" """llm chat"""
@@ -119,8 +128,8 @@ class LLMChatMultiModalImageUpload:
"gpt-4.1"],), "gpt-4.1"],),
"prompt": ("STRING", {"multiline": True}), "prompt": ("STRING", {"multiline": True}),
"image": (sorted(files), {"image_upload": True}), "image": (sorted(files), {"image_upload": True}),
"temperature": ("FLOAT",{"default": 0.7, "min": 0.0, "max": 1.0}), "temperature": ("FLOAT", {"default": 0.7, "min": 0.0, "max": 1.0}),
"max_tokens": ("INT",{"default": 4096, "min":1, "max":65535}), "max_tokens": ("INT", {"default": 4096, "min": 1, "max": 65535}),
"timeout": ("INT", {"default": 120, "min": 30, "max": 900}), "timeout": ("INT", {"default": 120, "min": 30, "max": 900}),
} }
} }
@@ -130,7 +139,7 @@ class LLMChatMultiModalImageUpload:
FUNCTION = "chat" FUNCTION = "chat"
CATEGORY = "不忘科技-自定义节点🚩/LLM" CATEGORY = "不忘科技-自定义节点🚩/LLM"
def chat(self, llm_provider:str, prompt:str, image, temperature:float, max_tokens:int, timeout:int): def chat(self, llm_provider: str, prompt: str, image, temperature: float, max_tokens: int, timeout: int):
@retry(Exception, tries=3, delay=1) @retry(Exception, tries=3, delay=1)
def _chat(): def _chat():
try: try:
@@ -140,28 +149,29 @@ class LLMChatMultiModalImageUpload:
base64_encoded_data = base64.b64encode(image_file.read()).decode('utf-8') base64_encoded_data = base64.b64encode(image_file.read()).decode('utf-8')
with httpx.Client(timeout=httpx.Timeout(timeout, connect=15)) as session: with httpx.Client(timeout=httpx.Timeout(timeout, connect=15)) as session:
resp = session.post("https://gateway.bowong.cc/chat/completions", resp = session.post("https://gateway.bowong.cc/chat/completions",
headers={ headers={
"Content-Type": "application/json", "Content-Type": "application/json",
"Accept": "application/json", "Accept": "application/json",
"Authorization": "Bearer auth-bowong7777" "Authorization": "Bearer auth-bowong7777"
}, },
json={ json={
"model": llm_provider, "model": llm_provider,
"messages": [ "messages": [
{ {
"role": "user", "role": "user",
"content": [ "content": [
{"type": "text", "text": prompt}, {"type": "text", "text": prompt},
{ {
"type": "image_url", "type": "image_url",
"image_url": {"url":f"data:{mime_type};base64,{base64_encoded_data}"}, "image_url": {
}, "url": f"data:{mime_type};base64,{base64_encoded_data}"},
] },
} ]
], }
"temperature": temperature, ],
"max_tokens": max_tokens "temperature": temperature,
}) "max_tokens": max_tokens
})
resp.raise_for_status() resp.raise_for_status()
resp = resp.json() resp = resp.json()
content = find_value_recursive("content", resp) content = find_value_recursive("content", resp)
@@ -170,8 +180,10 @@ class LLMChatMultiModalImageUpload:
# logger.exception("llm调用失败 {}".format(e)) # logger.exception("llm调用失败 {}".format(e))
raise Exception("llm调用失败 {}".format(e)) raise Exception("llm调用失败 {}".format(e))
return (content,) return (content,)
return _chat() return _chat()
class LLMChatMultiModalImageTensor: class LLMChatMultiModalImageTensor:
"""llm chat""" """llm chat"""
@@ -183,8 +195,8 @@ class LLMChatMultiModalImageTensor:
"gpt-4.1"],), "gpt-4.1"],),
"prompt": ("STRING", {"multiline": True}), "prompt": ("STRING", {"multiline": True}),
"image": ("IMAGE",), "image": ("IMAGE",),
"temperature": ("FLOAT",{"default": 0.7, "min": 0.0, "max": 1.0}), "temperature": ("FLOAT", {"default": 0.7, "min": 0.0, "max": 1.0}),
"max_tokens": ("INT",{"default": 4096, "min":1, "max":65535}), "max_tokens": ("INT", {"default": 4096, "min": 1, "max": 65535}),
"timeout": ("INT", {"default": 120, "min": 30, "max": 900}), "timeout": ("INT", {"default": 120, "min": 30, "max": 900}),
} }
} }
@@ -194,34 +206,35 @@ class LLMChatMultiModalImageTensor:
FUNCTION = "chat" FUNCTION = "chat"
CATEGORY = "不忘科技-自定义节点🚩/LLM" CATEGORY = "不忘科技-自定义节点🚩/LLM"
def chat(self, llm_provider:str, prompt:str, image:torch.Tensor, temperature:float, max_tokens:int, timeout:int): def chat(self, llm_provider: str, prompt: str, image: torch.Tensor, temperature: float, max_tokens: int,
timeout: int):
@retry(Exception, tries=3, delay=1) @retry(Exception, tries=3, delay=1)
def _chat(): def _chat():
try: try:
with httpx.Client(timeout=httpx.Timeout(timeout, connect=15)) as session: with httpx.Client(timeout=httpx.Timeout(timeout, connect=15)) as session:
resp = session.post("https://gateway.bowong.cc/chat/completions", resp = session.post("https://gateway.bowong.cc/chat/completions",
headers={ headers={
"Content-Type": "application/json", "Content-Type": "application/json",
"Accept": "application/json", "Accept": "application/json",
"Authorization": "Bearer auth-bowong7777" "Authorization": "Bearer auth-bowong7777"
}, },
json={ json={
"model": llm_provider, "model": llm_provider,
"messages": [ "messages": [
{ {
"role": "user", "role": "user",
"content": [ "content": [
{"type": "text", "text": prompt}, {"type": "text", "text": prompt},
{ {
"type": "image_url", "type": "image_url",
"image_url": {"url":image_tensor_to_base64(image)}, "image_url": {"url": image_tensor_to_base64(image)},
}, },
] ]
} }
], ],
"temperature": temperature, "temperature": temperature,
"max_tokens": max_tokens "max_tokens": max_tokens
}) })
resp.raise_for_status() resp.raise_for_status()
resp = resp.json() resp = resp.json()
content = find_value_recursive("content", resp) content = find_value_recursive("content", resp)
@@ -230,8 +243,10 @@ class LLMChatMultiModalImageTensor:
# logger.exception("llm调用失败 {}".format(e)) # logger.exception("llm调用失败 {}".format(e))
raise Exception("llm调用失败 {}".format(e)) raise Exception("llm调用失败 {}".format(e))
return (content,) return (content,)
return _chat() return _chat()
class Jinja2RenderTemplate: class Jinja2RenderTemplate:
@classmethod @classmethod
def INPUT_TYPES(s): def INPUT_TYPES(s):
@@ -266,4 +281,130 @@ class Jinja2RenderTemplate:
template = Template(template, undefined=StrictUndefined) template = Template(template, undefined=StrictUndefined)
# 渲染模板 # 渲染模板
return (template.render(kv_map),) return (template.render(kv_map),)
class ModalClothesMask:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"mask_color": ("STRING", {"default": "绿色"}),
"clothes_type": ("STRING", {"default": "裤子"}),
"endpoint": ("STRING", {"default": "bowongai-dev--bowong-ai-video-gemini-fastapi-webapp.modal.run"}),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "process"
OUTPUT_NODE = False
CATEGORY = "不忘科技-自定义节点🚩/图片/Gemini图像编辑"
def process(self, image: torch.Tensor, mask_color: str, clothes_type: str, endpoint: str):
try:
timeout = 60
logger.info("获取token")
api_key = requests.get(f"https://{endpoint}/google/access-token",
headers={'Authorization': 'Bearer bowong7777'}, timeout=timeout).json()[
"access_token"]
format = "PNG"
logger.info("请求图像编辑")
job_resp = requests.post(f"https://{endpoint}/google/image/clothes_mark",
headers={'x-google-api-key': api_key},
data={
"mark_clothes_type": clothes_type,
"mark_color": mask_color,
},
files={"origin_image": (
'image.' + format.lower(), tensor_to_image_bytes(image, format),
f'image/{format.lower()}')},
timeout=timeout)
job_resp.raise_for_status()
job_resp = job_resp.json()
if not job_resp["success"]:
raise Exception("请求Modal API失败")
job_id = job_resp["taskId"]
wait_time = 240
interval = 3
logger.info("开始轮询任务状态")
for _ in range(0, wait_time, interval):
logger.info("查询任务状态")
result = requests.get(f"https://{endpoint}/google/{job_id}", timeout=timeout)
if result.status_code == 200:
result = result.json()
if result["status"] == "success":
logger.success("任务成功")
image_b64 = json.loads(result["result"])[0]["image_b64"]
image_tensor = base64_to_tensor(image_b64)
return (image_tensor,)
elif "fail" in result["status"].lower():
raise Exception("任务失败")
sleep(interval)
raise Exception("查询任务状态超时")
except Exception as e:
raise Exception(e)
class ModalEditCustom:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"prompt": ("STRING", {"default": "将背景去除,输出原尺寸图片"}),
"endpoint": ("STRING", {"default": "bowongai-dev--bowong-ai-video-gemini-fastapi-webapp.modal.run"}),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "process"
OUTPUT_NODE = False
CATEGORY = "不忘科技-自定义节点🚩/图片/Gemini图像编辑"
def process(self, image: torch.Tensor, prompt: str, endpoint: str):
try:
timeout = 60
logger.info("获取token")
api_key = requests.get(f"https://{endpoint}/google/access-token",
headers={'Authorization': 'Bearer bowong7777'}, timeout=timeout).json()[
"access_token"]
format = "PNG"
logger.info("请求图像编辑")
job_resp = requests.post(f"https://{endpoint}/google/image/edit_custom",
headers={'x-google-api-key': api_key},
data={
"prompt": prompt
},
files={"origin_image": (
'image.' + format.lower(), tensor_to_image_bytes(image, format),
f'image/{format.lower()}')},
timeout=timeout)
job_resp.raise_for_status()
job_resp = job_resp.json()
if not job_resp["success"]:
raise Exception("请求Modal API失败")
job_id = job_resp["taskId"]
wait_time = 240
interval = 3
logger.info("开始轮询任务状态")
for _ in range(0, wait_time, interval):
logger.info("查询任务状态")
result = requests.get(f"https://{endpoint}/google/{job_id}", timeout=timeout)
if result.status_code == 200:
result = result.json()
if result["status"] == "success":
logger.success("任务成功")
image_b64 = json.loads(result["result"])[0]["image_b64"]
image_tensor = base64_to_tensor(image_b64)
return (image_tensor,)
elif "fail" in result["status"].lower():
raise Exception("任务失败")
sleep(interval)
raise Exception("查询任务状态超时")
except Exception as e:
raise Exception(e)

73
utils/image_utils.py Normal file
View File

@@ -0,0 +1,73 @@
import base64
import io
import torch
from PIL import Image
from torchvision import transforms
def base64_to_tensor(base64_data: str) -> torch.Tensor:
"""
"data:image/xxx;base64,xxx"格式的图像数据转换为PyTorch张量
参数:
base64_data: 图像的Base64编码字符串
返回:
torch.Tensor: 形状为[C, H, W]的张量,取值范围为[0, 1]
"""
# 分离数据前缀和实际Base64编码部分
if ';base64,' in base64_data:
_, encoded = base64_data.split(';base64,', 1)
else:
encoded = base64_data # 假设直接提供了Base64编码部分
# 解码Base64数据
decoded_data = base64.b64decode(encoded)
# 使用PIL打开图像
image = Image.open(io.BytesIO(decoded_data))
# 转换为RGB模式处理PNG的Alpha通道和WebP格式
if image.mode != 'RGB':
image = image.convert('RGB')
# 转换为PyTorch张量
from torchvision import transforms
transform = transforms.Compose([
transforms.ToTensor() # [H, W, C] -> [C, H, W],并归一化到[0, 1]
])
tensor = transform(image)
return tensor.unsqueeze(0).permute(0, 2, 3, 1)
def tensor_to_image_bytes(tensor: torch.Tensor, format: str = 'PNG') -> bytes:
"""
将PyTorch张量转换为图像字节流
参数:
tensor: 形状为[C, H, W]的图像张量,取值范围为[0, 1]
format: 图像格式,可选'PNG''JPEG'
返回:
bytes: 图像的字节流数据
"""
if tensor.dim() == 4:
if tensor.shape[0] > 1:
print("警告:输入张量包含多个图像,仅使用第一个")
tensor = tensor[0] # 取批量中的第一张图像
tensor = tensor.permute(2, 0, 1)
# 确保张量在[0, 255]范围内
if tensor.max() <= 1.0:
tensor = tensor * 255
# 转换为PIL图像
image = transforms.ToPILImage()(tensor.byte())
# 保存为字节流
buffer = io.BytesIO()
image.save(buffer, format=format)
buffer.seek(0) # 重置指针到开始位置
return buffer.getvalue()