1604 lines
63 KiB
Python
1604 lines
63 KiB
Python
from .md import *
|
||
import json
|
||
|
||
CATEGORY_TYPE = "🎈LAOGOU/Utils"
|
||
|
||
def execute_command_with_realtime_output(cmd, cwd, message_path, clear_first=True, start_message=""):
|
||
"""通用的实时命令执行方法"""
|
||
import subprocess
|
||
import sys
|
||
from server import PromptServer
|
||
import comfy.model_management as model_management
|
||
|
||
try:
|
||
# 发送开始消息
|
||
if start_message:
|
||
PromptServer.instance.send_sync(message_path, {
|
||
"text": start_message,
|
||
"clear": clear_first
|
||
})
|
||
|
||
# 执行命令,实时输出
|
||
process = subprocess.Popen(
|
||
cmd,
|
||
cwd=cwd,
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.STDOUT,
|
||
text=True,
|
||
bufsize=1,
|
||
universal_newlines=True
|
||
)
|
||
|
||
# 实时读取输出
|
||
while True:
|
||
# 检查是否被中断
|
||
if model_management.processing_interrupted():
|
||
process.terminate()
|
||
PromptServer.instance.send_sync(message_path, {
|
||
"text": "🛑 用户中断了操作",
|
||
"clear": False
|
||
})
|
||
return False, "🛑 用户中断了操作"
|
||
|
||
output = process.stdout.readline()
|
||
if output == '' and process.poll() is not None:
|
||
break
|
||
if output:
|
||
line = output.strip()
|
||
if line:
|
||
PromptServer.instance.send_sync(message_path, {
|
||
"text": line,
|
||
"clear": False
|
||
})
|
||
|
||
# 检查返回码
|
||
return_code = process.poll()
|
||
return return_code == 0, return_code
|
||
|
||
except Exception as e:
|
||
error_msg = f"💥 执行异常: {str(e)}"
|
||
PromptServer.instance.send_sync(message_path, {
|
||
"text": error_msg,
|
||
"clear": False
|
||
})
|
||
return False, str(e)
|
||
|
||
# 缓存相关的全局变量
|
||
preview_bridge_cache = {}
|
||
preview_bridge_image_hashes = {}
|
||
|
||
|
||
class CachePreviewBridge:
|
||
@classmethod
|
||
def INPUT_TYPES(s):
|
||
return {"required": {
|
||
"image": ("STRING", {"default": ""}),
|
||
},
|
||
"optional": {
|
||
"images": ("IMAGE",),
|
||
},
|
||
"hidden": {"unique_id": "UNIQUE_ID", "extra_pnginfo": "EXTRA_PNGINFO"},
|
||
}
|
||
|
||
RETURN_TYPES = ("IMAGE", "MASK")
|
||
FUNCTION = "doit"
|
||
OUTPUT_NODE = True
|
||
CATEGORY = CATEGORY_TYPE
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.output_dir = folder_paths.get_temp_directory()
|
||
self.type = "temp"
|
||
self.prev_hash = None
|
||
|
||
@staticmethod
|
||
def calculate_image_hash(images):
|
||
"""计算图片的哈希值用于检测是否改变"""
|
||
try:
|
||
if images is None:
|
||
return None
|
||
np_images = images.cpu().numpy()
|
||
image_bytes = np_images.tobytes()
|
||
return hashlib.md5(image_bytes).hexdigest()
|
||
except:
|
||
return None
|
||
@staticmethod
|
||
def load_image_from_fileinfo(file_info_json):
|
||
"""从 JSON 文件信息或字符串路径加载图片"""
|
||
# 初始化默认值(64*64 遮罩表示无效遮罩)
|
||
image = torch.zeros((1, 512, 512, 3), dtype=torch.float32, device="cpu")
|
||
mask = torch.zeros((64, 64), dtype=torch.float32, device="cpu")
|
||
ui_item = {
|
||
"filename": 'empty.png',
|
||
"subfolder": '',
|
||
"type": 'temp'
|
||
}
|
||
|
||
if not file_info_json:
|
||
final_mask = mask.unsqueeze(0) if len(mask.shape) == 2 else mask
|
||
return image, final_mask, ui_item
|
||
|
||
filename = None
|
||
subfolder = ''
|
||
file_type = 'input'
|
||
|
||
try:
|
||
# 首先尝试作为 JSON 解析(旧版本格式)
|
||
file_info = json.loads(file_info_json)
|
||
filename = file_info.get('filename')
|
||
subfolder = file_info.get('subfolder', '')
|
||
file_type = file_info.get('type', 'input')
|
||
|
||
except json.JSONDecodeError:
|
||
# JSON 解析失败,尝试解析新版本字符串格式
|
||
# 新版本格式: "path/filename.ext [type]" 或 "filename.ext [type]"
|
||
file_info_str = str(file_info_json).strip()
|
||
|
||
# 查找最后的 [type] 部分
|
||
if '[' in file_info_str and file_info_str.endswith(']'):
|
||
# 分离路径和类型
|
||
last_bracket = file_info_str.rfind('[')
|
||
path_part = file_info_str[:last_bracket].strip()
|
||
type_part = file_info_str[last_bracket+1:-1].strip()
|
||
|
||
# 解析路径部分
|
||
if '/' in path_part:
|
||
# 包含子文件夹
|
||
path_parts = path_part.split('/')
|
||
filename = path_parts[-1] # 最后一部分是文件名
|
||
subfolder = '/'.join(path_parts[:-1]) # 前面的部分是子文件夹
|
||
else:
|
||
# 只有文件名
|
||
filename = path_part
|
||
subfolder = ''
|
||
|
||
# 解析类型
|
||
file_type = type_part.lower() if type_part else 'input'
|
||
else:
|
||
# 没有类型信息,直接作为路径处理
|
||
if '/' in file_info_str:
|
||
path_parts = file_info_str.split('/')
|
||
filename = path_parts[-1]
|
||
subfolder = '/'.join(path_parts[:-1])
|
||
else:
|
||
filename = file_info_str
|
||
subfolder = ''
|
||
file_type = 'input' # 默认类型
|
||
|
||
if not filename:
|
||
final_mask = mask.unsqueeze(0) if len(mask.shape) == 2 else mask
|
||
return image, final_mask, ui_item
|
||
|
||
try:
|
||
# 构建文件路径
|
||
if file_type == 'input':
|
||
base_dir = folder_paths.get_input_directory()
|
||
elif file_type == 'output':
|
||
base_dir = folder_paths.get_output_directory()
|
||
elif file_type == 'temp':
|
||
base_dir = folder_paths.get_temp_directory()
|
||
else:
|
||
base_dir = folder_paths.get_input_directory()
|
||
|
||
if subfolder:
|
||
file_path = os.path.join(base_dir, subfolder, filename)
|
||
else:
|
||
file_path = os.path.join(base_dir, filename)
|
||
|
||
# 更新 ui_item
|
||
ui_item = {
|
||
"filename": filename,
|
||
"subfolder": subfolder,
|
||
"type": file_type
|
||
}
|
||
|
||
# 加载图片
|
||
if os.path.exists(file_path):
|
||
i = Image.open(file_path)
|
||
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((image.shape[1], image.shape[2]), dtype=torch.float32, device="cpu")
|
||
|
||
except Exception as e:
|
||
print(f"[CachePreviewBridge] 加载图片失败: {e}")
|
||
|
||
final_mask = mask.unsqueeze(0) if len(mask.shape) == 2 else mask
|
||
return image, final_mask, ui_item
|
||
|
||
def doit(self, image, unique_id, images=None, extra_pnginfo=None):
|
||
"""
|
||
简化逻辑:
|
||
1. 如果有 images 输入,保存并返回
|
||
2. 如果没有 images 输入但有 image (文件信息),从文件加载
|
||
3. 否则返回空图像
|
||
"""
|
||
# 情况1: 有图像输入 - 从 clipspace 加载遮罩,或使用输入的图像
|
||
if images is not None:
|
||
# 更新 hash
|
||
current_hash = self.calculate_image_hash(images)
|
||
preview_bridge_image_hashes[unique_id] = current_hash
|
||
|
||
# 默认遮罩(64*64 表示无效遮罩)
|
||
mask = torch.zeros((1, 64, 64), dtype=torch.float32, device="cpu")
|
||
ui_item = None
|
||
|
||
# 如果有文件信息,尝试从文件加载遮罩
|
||
if image and image.strip():
|
||
_, loaded_mask, path_item = CachePreviewBridge.load_image_from_fileinfo(image)
|
||
|
||
# 如果成功加载了遮罩,使用它
|
||
if path_item["filename"] != 'empty.png':
|
||
# 调整遮罩尺寸以匹配图像
|
||
if loaded_mask.shape[-2:] == (images.shape[1], images.shape[2]):
|
||
mask = loaded_mask
|
||
ui_item = path_item # 保存 clipspace 文件信息用于 UI 预览
|
||
|
||
# 如果有 clipspace 文件,直接返回 clipspace 的预览
|
||
if ui_item:
|
||
return {
|
||
"ui": {"images": [ui_item]},
|
||
"result": (images, mask),
|
||
}
|
||
|
||
# 否则保存新预览图并返回默认的 64*64 遮罩
|
||
res = PreviewImage().save_images(
|
||
images,
|
||
filename_prefix=f"PreviewBridge/PB-{unique_id}-",
|
||
extra_pnginfo=extra_pnginfo
|
||
)
|
||
|
||
# 缓存结果
|
||
preview_bridge_cache[unique_id] = (images, res['ui']['images'])
|
||
|
||
return {
|
||
"ui": {"images": res['ui']['images']},
|
||
"result": (images, mask),
|
||
}
|
||
|
||
# 情况2: 没有图像输入,但有文件信息 - 从文件加载
|
||
if image and image.strip():
|
||
pixels, mask, path_item = CachePreviewBridge.load_image_from_fileinfo(image)
|
||
|
||
# 检查是否成功加载
|
||
if path_item["filename"] != 'empty.png':
|
||
return {
|
||
"ui": {"images": [path_item]},
|
||
"result": (pixels, mask),
|
||
}
|
||
|
||
# 情况3: 检查是否有缓存
|
||
if unique_id in preview_bridge_cache:
|
||
cached_images, cached_ui = preview_bridge_cache[unique_id]
|
||
# 返回 64*64 遮罩表示无效遮罩
|
||
mask = torch.zeros((1, 64, 64), dtype=torch.float32, device="cpu")
|
||
return {
|
||
"ui": {"images": cached_ui},
|
||
"result": (cached_images, mask),
|
||
}
|
||
|
||
# 情况4: 没有任何数据 - 返回空图像和 64*64 遮罩
|
||
empty_image = torch.zeros((1, 512, 512, 3), dtype=torch.float32, device="cpu")
|
||
empty_mask = torch.zeros((1, 64, 64), dtype=torch.float32, device="cpu")
|
||
return {
|
||
"ui": {"images": []},
|
||
"result": (empty_image, empty_mask),
|
||
}
|
||
|
||
class LG_Noise:
|
||
@classmethod
|
||
def INPUT_TYPES(s):
|
||
return {
|
||
"required": {
|
||
"type": (["fade", "dissolve", "gaussian"], ),
|
||
"opacity": ("FLOAT", { "default": 1.0, "min": 0, "max": 1, "step": 0.01 }),
|
||
"strength": ("INT", { "default": 1, "min": 1, "max": 32, "step": 1 }),
|
||
"density": ("FLOAT", { "default": 1.0, "min": 0, "max": 1, "step": 0.05 }),
|
||
"sharpen": ("INT", { "default": 0, "min": -32, "max": 32, "step": 1 }),
|
||
"brightness": ("FLOAT", { "default": 1.0, "min": 0, "max": 3, "step": 0.05 }),
|
||
"random_color": ("BOOLEAN", {"default": True}),
|
||
"color": ("LGCOLOR", {"default": "#808080"}),
|
||
"seed": ("INT", {"default": -1, "min": -1, "max": 0x7fffffff}),
|
||
},
|
||
"optional": {
|
||
"image_optional": ("IMAGE",),
|
||
"mask_optional": ("MASK",),
|
||
}
|
||
}
|
||
RETURN_TYPES = ("IMAGE",)
|
||
FUNCTION = "make_noise"
|
||
CATEGORY = CATEGORY_TYPE
|
||
def hex_to_rgb(self, hex_color):
|
||
hex_color = hex_color.lstrip('#')
|
||
r = int(hex_color[0:2], 16) / 255.0
|
||
g = int(hex_color[2:4], 16) / 255.0
|
||
b = int(hex_color[4:6], 16) / 255.0
|
||
return torch.tensor([r, g, b])
|
||
def make_noise(self, type, opacity, density, strength, sharpen, random_color, color, brightness, seed,
|
||
image_optional=None, mask_optional=None):
|
||
if image_optional is None:
|
||
image = torch.zeros([1, 1, 1, 3])
|
||
else:
|
||
image = image_optional
|
||
h, w = image.shape[1:3]
|
||
if h == 1 and w == 1:
|
||
image = image.repeat(1, 512, 512, 1)
|
||
h, w = 512, 512
|
||
if mask_optional is not None:
|
||
mask = mask_optional.unsqueeze(-1).repeat(1, 1, 1, 3)
|
||
else:
|
||
mask = torch.ones((1, h, w, 1), device=image.device).repeat(1, 1, 1, 3)
|
||
if seed == -1:
|
||
seed = torch.randint(0, 0x7fffffff, (1,)).item()
|
||
torch.manual_seed(seed)
|
||
print(f"[LG_Noise] Using seed: {seed}")
|
||
color_rgb = self.hex_to_rgb(color).to(image.device)
|
||
def generate_noise(size_h, size_w, is_gaussian=False):
|
||
"""统一的噪声生成函数"""
|
||
if random_color:
|
||
if is_gaussian:
|
||
noise = torch.randn(1, size_h, size_w, 3, device=image.device)
|
||
return torch.clamp(noise * 0.5 + 0.5, 0, 1)
|
||
else:
|
||
return torch.rand(1, size_h, size_w, 3, device=image.device)
|
||
else:
|
||
# 单色噪声:生成灰度随机值,RGB通道相同
|
||
if is_gaussian:
|
||
gray = torch.randn(1, size_h, size_w, 1, device=image.device)
|
||
gray = torch.clamp(gray * 0.5 + 0.5, 0, 1)
|
||
else:
|
||
gray = torch.rand(1, size_h, size_w, 1, device=image.device)
|
||
return gray.repeat(1, 1, 1, 3)
|
||
def generate_density_mask(size_h, size_w):
|
||
"""统一的密度遮罩生成函数"""
|
||
return (torch.rand(1, size_h, size_w, 1, device=image.device) < density).float()
|
||
if strength > 1:
|
||
small_h, small_w = h // strength, w // strength
|
||
density_mask = generate_density_mask(small_h, small_w)
|
||
noise = generate_noise(small_h, small_w) * density_mask
|
||
noise = torch.nn.functional.interpolate(
|
||
noise.permute(0, 3, 1, 2),
|
||
size=(h, w),
|
||
mode='nearest'
|
||
).permute(0, 2, 3, 1)
|
||
if type != "fade":
|
||
density_mask = torch.nn.functional.interpolate(
|
||
density_mask.permute(0, 3, 1, 2),
|
||
size=(h, w),
|
||
mode='nearest'
|
||
).permute(0, 2, 3, 1)
|
||
else:
|
||
density_mask = generate_density_mask(h, w)
|
||
noise = generate_noise(h, w) * density_mask
|
||
colored_noise = noise * color_rgb.view(1, 1, 1, 3) * brightness
|
||
if type == "fade":
|
||
result = image * (1 - opacity) + colored_noise * opacity
|
||
elif type == "dissolve":
|
||
dissolve_mask = (torch.rand(1, h//strength if strength > 1 else h,
|
||
w//strength if strength > 1 else w, 1,
|
||
device=image.device) < opacity).float()
|
||
density_mask = (torch.rand(1, h//strength if strength > 1 else h,
|
||
w//strength if strength > 1 else w, 1,
|
||
device=image.device) < density).float()
|
||
dissolve_mask = dissolve_mask * density_mask
|
||
if strength > 1:
|
||
dissolve_mask = torch.nn.functional.interpolate(
|
||
dissolve_mask.permute(0, 3, 1, 2),
|
||
size=(h, w),
|
||
mode='nearest'
|
||
).permute(0, 2, 3, 1)
|
||
dissolve_mask = dissolve_mask.repeat(1, 1, 1, 3)
|
||
if random_color:
|
||
noise = torch.rand(1, h, w, 3, device=image.device)
|
||
else:
|
||
noise = torch.ones(1, h, w, 3, device=image.device)
|
||
noise = noise * dissolve_mask
|
||
colored_noise = noise * color_rgb.view(1, 1, 1, 3) * brightness
|
||
result = image * (1-dissolve_mask) + colored_noise * dissolve_mask
|
||
elif type == "gaussian":
|
||
noise = generate_noise(h//strength if strength > 1 else h,
|
||
w//strength if strength > 1 else w,
|
||
is_gaussian=True)
|
||
if strength > 1:
|
||
noise = torch.nn.functional.interpolate(
|
||
noise.permute(0, 3, 1, 2),
|
||
size=(h, w),
|
||
mode='nearest'
|
||
).permute(0, 2, 3, 1)
|
||
noise = (noise - 0.5) * opacity * 2
|
||
noise = noise * density_mask
|
||
colored_noise = noise * color_rgb.view(1, 1, 1, 3) * brightness
|
||
result = torch.clamp(image + colored_noise, 0, 1)
|
||
result = torch.clamp(result, 0, 1)
|
||
if sharpen != 0:
|
||
kernel_size = abs(sharpen) * 2 + 1
|
||
noise_part = result * mask - image * mask
|
||
if sharpen < 0:
|
||
blurred_noise = T.functional.gaussian_blur(
|
||
noise_part.permute([0,3,1,2]),
|
||
kernel_size
|
||
).permute([0,2,3,1])
|
||
result = image + blurred_noise
|
||
else:
|
||
blurred = T.functional.gaussian_blur(
|
||
noise_part.permute([0,3,1,2]),
|
||
kernel_size
|
||
).permute([0,2,3,1])
|
||
sharpened_noise = noise_part + (noise_part - blurred) * (sharpen / 8)
|
||
result = image + torch.clamp(sharpened_noise, 0, 1)
|
||
result = torch.clamp(result, 0, 1)
|
||
result = image * (1 - mask) + result * mask
|
||
return (result,)
|
||
|
||
|
||
|
||
class LG_LoadImage(LoadImage):
|
||
@classmethod
|
||
def INPUT_TYPES(s):
|
||
input_dir = folder_paths.get_input_directory()
|
||
files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))]
|
||
files = folder_paths.filter_files_content_types(files, ["image"])
|
||
return {"required":
|
||
{"image": (sorted(files), {"image_upload": True}),
|
||
},
|
||
}
|
||
|
||
RETURN_TYPES = ("IMAGE", "MASK", "STRING")
|
||
RETURN_NAMES = ("image", "mask", "filename")
|
||
DESCRIPTION = "从temp或output文件夹加载最新图片并复制到input文件夹。点击刷新按钮时,节点将更新图片列表并自动选择第一张图片,方便快速迭代。"
|
||
CATEGORY = CATEGORY_TYPE
|
||
FUNCTION = "load_image"
|
||
|
||
@classmethod
|
||
def IS_CHANGED(s, image):
|
||
# 调用父类的IS_CHANGED方法
|
||
return LoadImage.IS_CHANGED(image)
|
||
|
||
def load_image(self, image):
|
||
# 调用父类方法获取完整的图像和遮罩
|
||
image_tensor, mask_tensor = super().load_image(image)
|
||
|
||
# 返回图像、遮罩和文件名
|
||
return (image_tensor, mask_tensor, image)
|
||
|
||
|
||
# API路由:删除文件
|
||
@PromptServer.instance.routes.delete("/lg/delete_image")
|
||
async def delete_image(request):
|
||
try:
|
||
json_data = await request.json()
|
||
filename = json_data.get("filename")
|
||
|
||
if not filename:
|
||
return web.json_response({"error": "未指定文件名"}, status=400)
|
||
|
||
# 删除input目录中的文件
|
||
input_dir = folder_paths.get_input_directory()
|
||
file_path = os.path.join(input_dir, filename)
|
||
|
||
if not os.path.exists(file_path):
|
||
return web.json_response({"error": f"文件不存在: {filename}"}, status=404)
|
||
|
||
# 删除文件
|
||
os.remove(file_path)
|
||
|
||
return web.json_response({
|
||
"success": True,
|
||
"message": f"文件 {filename} 已删除"
|
||
})
|
||
except Exception as e:
|
||
print(f"[LG_LoadImage] 删除文件失败: {str(e)}")
|
||
traceback.print_exc()
|
||
return web.json_response({"error": str(e)}, status=500)
|
||
|
||
# API路由:获取最新图片
|
||
@PromptServer.instance.routes.get("/lg/get/latest_image")
|
||
async def get_latest_image(request):
|
||
try:
|
||
folder_type = request.query.get("type", "temp")
|
||
|
||
# 根据类型获取对应的目录
|
||
if folder_type == "temp":
|
||
base_dir = folder_paths.get_temp_directory()
|
||
elif folder_type == "output":
|
||
base_dir = folder_paths.get_output_directory()
|
||
elif folder_type == "input":
|
||
base_dir = folder_paths.get_input_directory()
|
||
else:
|
||
return web.json_response({"error": f"不支持的文件夹类型: {folder_type}"}, status=400)
|
||
|
||
if not os.path.exists(base_dir):
|
||
return web.json_response({"error": f"目录不存在: {base_dir}"}, status=404)
|
||
|
||
# 获取所有图片文件
|
||
image_files = []
|
||
for root, dirs, files in os.walk(base_dir):
|
||
for file in files:
|
||
if file.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp')):
|
||
full_path = os.path.join(root, file)
|
||
rel_path = os.path.relpath(full_path, base_dir)
|
||
subfolder = os.path.dirname(rel_path) if os.path.dirname(rel_path) != '.' else ''
|
||
image_files.append({
|
||
'filename': file,
|
||
'subfolder': subfolder,
|
||
'full_path': full_path,
|
||
'mtime': os.path.getmtime(full_path)
|
||
})
|
||
|
||
if not image_files:
|
||
return web.json_response({"error": "没有找到图片文件"}, status=404)
|
||
|
||
# 按修改时间排序,获取最新的
|
||
latest_image = max(image_files, key=lambda x: x['mtime'])
|
||
|
||
return web.json_response({
|
||
"filename": latest_image['filename'],
|
||
"subfolder": latest_image['subfolder'],
|
||
"type": folder_type
|
||
})
|
||
|
||
except Exception as e:
|
||
print(f"[get_latest_image] 获取最新图片失败: {str(e)}")
|
||
traceback.print_exc()
|
||
return web.json_response({"error": str(e)}, status=500)
|
||
|
||
# 注意:不再需要复制文件到input目录,ComfyUI可以直接使用temp和output目录的文件
|
||
|
||
class LG_LatentBatchToList:
|
||
@classmethod
|
||
def INPUT_TYPES(s):
|
||
return {
|
||
"required": {
|
||
"latent": ("LATENT",),
|
||
}
|
||
}
|
||
|
||
RETURN_TYPES = ("LATENT",)
|
||
OUTPUT_IS_LIST = (True,) # 表示输出是列表
|
||
FUNCTION = "batch_to_list"
|
||
CATEGORY = CATEGORY_TYPE
|
||
|
||
def batch_to_list(self, latent):
|
||
"""将latent batch转换为latent列表"""
|
||
samples = latent["samples"]
|
||
batch_size = samples.shape[0]
|
||
|
||
# 将batch分离为单独的latent
|
||
latent_list = []
|
||
for i in range(batch_size):
|
||
single_latent = {"samples": samples[i:i+1]} # 保持4维,但batch_size=1
|
||
latent_list.append(single_latent)
|
||
|
||
return (latent_list,)
|
||
|
||
class LG_PipManager:
|
||
@classmethod
|
||
def INPUT_TYPES(s):
|
||
return {
|
||
"required": {
|
||
"package_name": ("STRING", {
|
||
"default": "",
|
||
"multiline": True,
|
||
"placeholder": "支持多种格式:\nnumpy==1.21.0 # 指定版本\ntorch>=1.9.0 # 版本范围\nrequests[security] # 额外依赖\nnumpy torch pandas # 多个包",
|
||
"tooltip": "包名支持版本号、额外依赖、多包安装等pip标准格式"
|
||
}),
|
||
"operation": (["install", "uninstall", "upgrade", "list"], {
|
||
"default": "install",
|
||
"tooltip": "选择操作类型:安装、卸载、升级或列出已安装包"
|
||
}),
|
||
}
|
||
}
|
||
|
||
RETURN_TYPES = ()
|
||
FUNCTION = "manage_package"
|
||
OUTPUT_NODE = True
|
||
CATEGORY = CATEGORY_TYPE
|
||
|
||
def manage_package(self, package_name, operation):
|
||
import sys
|
||
import re
|
||
|
||
# 如果是 list 操作,忽略输入直接列出已安装包
|
||
if operation == "list":
|
||
cmd = [sys.executable, "-m", "pip", "list"]
|
||
emoji = "📋"
|
||
action = "列出已安装包"
|
||
start_message = f"{emoji} 正在{action}..."
|
||
|
||
# 使用通用方法执行命令
|
||
success, result = execute_command_with_realtime_output(
|
||
cmd, None, "/lg/pip_manager", True, start_message
|
||
)
|
||
|
||
if isinstance(result, str): # 中断或异常
|
||
return {"ui": {"text": (result,)}}
|
||
|
||
if success:
|
||
success_msg = "✅ 已列出所有已安装的包"
|
||
PromptServer.instance.send_sync("/lg/pip_manager", {
|
||
"text": success_msg,
|
||
"clear": False
|
||
})
|
||
return {"ui": {"text": (success_msg,)}}
|
||
else:
|
||
error_msg = f"❌ 列出包失败 (退出码: {result})"
|
||
PromptServer.instance.send_sync("/lg/pip_manager", {
|
||
"text": error_msg,
|
||
"clear": False
|
||
})
|
||
return {"ui": {"text": (error_msg,)}}
|
||
|
||
# 其他操作需要检查包名输入
|
||
if not package_name.strip():
|
||
return {"ui": {"text": ("❌ 请输入包名",)}}
|
||
|
||
# 处理多行输入,过滤注释和空行
|
||
lines = []
|
||
for line in package_name.strip().split('\n'):
|
||
line = line.split('#')[0].strip() # 移除注释
|
||
if line: # 忽略空行
|
||
lines.append(line)
|
||
|
||
if not lines:
|
||
return {"ui": {"text": ("❌ 请输入有效的包名",)}}
|
||
|
||
# 构建pip命令
|
||
if operation == "install":
|
||
cmd = [sys.executable, "-m", "pip", "install"] + lines
|
||
emoji = "📦"
|
||
action = "安装"
|
||
packages_text = " ".join(lines)
|
||
elif operation == "uninstall":
|
||
# 对于卸载,需要提取基础包名(去掉版本号和额外依赖)
|
||
base_packages = []
|
||
for line in lines:
|
||
for pkg in line.split():
|
||
# 提取基础包名:移除版本号、额外依赖等
|
||
base_name = re.split(r'[><=!~\[]', pkg)[0].strip()
|
||
if base_name:
|
||
base_packages.append(base_name)
|
||
|
||
if not base_packages:
|
||
return {"ui": {"text": ("❌ 无法提取有效的包名",)}}
|
||
|
||
cmd = [sys.executable, "-m", "pip", "uninstall", "-y"] + base_packages
|
||
emoji = "🗑️"
|
||
action = "卸载"
|
||
packages_text = " ".join(base_packages)
|
||
else: # upgrade
|
||
cmd = [sys.executable, "-m", "pip", "install", "--upgrade"] + lines
|
||
emoji = "⬆️"
|
||
action = "升级"
|
||
packages_text = " ".join(lines)
|
||
|
||
# 添加卸载警告
|
||
warning_msg = ""
|
||
if operation == "uninstall":
|
||
warning_msg = "\n⚠️ 注意:如果包正在使用中,卸载可能失败。建议重启ComfyUI后再尝试。"
|
||
|
||
start_message = f"{emoji} 开始{action} {packages_text}...{warning_msg}\n📝 执行命令: {' '.join(cmd)}"
|
||
|
||
# 使用通用方法执行命令
|
||
success, result = execute_command_with_realtime_output(
|
||
cmd, None, "/lg/pip_manager", True, start_message
|
||
)
|
||
|
||
if isinstance(result, str): # 中断或异常
|
||
return {"ui": {"text": (result,)}}
|
||
|
||
if success:
|
||
success_msg = f"✅ 成功{action} {packages_text}"
|
||
PromptServer.instance.send_sync("/lg/pip_manager", {
|
||
"text": success_msg,
|
||
"clear": False
|
||
})
|
||
return {"ui": {"text": (success_msg,)}}
|
||
else:
|
||
error_msg = f"❌ {action}失败 (退出码: {result})"
|
||
PromptServer.instance.send_sync("/lg/pip_manager", {
|
||
"text": error_msg,
|
||
"clear": False
|
||
})
|
||
return {"ui": {"text": (error_msg,)}}
|
||
|
||
class LG_SaveImage:
|
||
@classmethod
|
||
def INPUT_TYPES(s):
|
||
return {
|
||
"required": {
|
||
"images": ("IMAGE",),
|
||
"filename_prefix": ("STRING", {
|
||
"default": "ComfyUI_{timestamp}",
|
||
"multiline": False,
|
||
"tooltip": "文件名前缀,支持表达式:{timestamp}时间戳、{date}日期、{time}时间、{datetime}日期时间、{batch}批次号、{counter}计数器"
|
||
}),
|
||
"path": ("STRING", {
|
||
"default": "",
|
||
"multiline": False,
|
||
"placeholder": "留空使用默认输出目录",
|
||
"tooltip": "保存路径,支持绝对路径和相对路径,不存在时自动创建"
|
||
}),
|
||
"format": (["png", "jpg", "webp"], {
|
||
"default": "png",
|
||
"tooltip": "图像保存格式:PNG无损、JPG/WebP有损压缩"
|
||
}),
|
||
"quality": ("INT", {
|
||
"default": 95,
|
||
"min": 1,
|
||
"max": 100,
|
||
"step": 1,
|
||
"tooltip": "图像质量(1-100),仅对JPG和WebP格式有效,PNG格式忽略此参数"
|
||
}),
|
||
},
|
||
}
|
||
|
||
RETURN_TYPES = ()
|
||
FUNCTION = "save_images"
|
||
OUTPUT_NODE = True
|
||
CATEGORY = CATEGORY_TYPE
|
||
|
||
def save_images(self, images, filename_prefix="ComfyUI_{timestamp}", path="", format="png", quality=95):
|
||
# 确定保存路径
|
||
if path:
|
||
# 支持相对路径和绝对路径
|
||
if os.path.isabs(path):
|
||
save_dir = path
|
||
else:
|
||
# 相对路径基于ComfyUI根目录
|
||
save_dir = os.path.join(os.getcwd(), path)
|
||
else:
|
||
# 使用默认输出目录
|
||
save_dir = folder_paths.get_output_directory()
|
||
|
||
# 创建目录(如果不存在)
|
||
os.makedirs(save_dir, exist_ok=True)
|
||
|
||
# 一次性获取时间信息(避免重复计算)
|
||
import datetime
|
||
now = datetime.datetime.now()
|
||
timestamp = str(int(now.timestamp()))
|
||
date_str = now.strftime("%Y%m%d")
|
||
time_str = now.strftime("%H%M%S")
|
||
datetime_str = now.strftime("%Y%m%d_%H%M%S")
|
||
|
||
# 预处理文件名前缀,只替换非批次相关的变量
|
||
base_prefix = filename_prefix.replace("{timestamp}", timestamp)
|
||
base_prefix = base_prefix.replace("{date}", date_str)
|
||
base_prefix = base_prefix.replace("{time}", time_str)
|
||
base_prefix = base_prefix.replace("{datetime}", datetime_str)
|
||
|
||
file_extension = f".{format}"
|
||
|
||
# 使用类似系统的计数器逻辑
|
||
full_output_folder, filename, counter, subfolder, _ = folder_paths.get_save_image_path(base_prefix, save_dir, images[0].shape[1], images[0].shape[0])
|
||
|
||
for batch_number, image in enumerate(images):
|
||
# 转换tensor为PIL图像
|
||
i = 255. * image.cpu().numpy()
|
||
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
|
||
|
||
# 处理批次和计数器变量
|
||
processed_prefix = base_prefix
|
||
if "{batch}" in filename_prefix:
|
||
processed_prefix = processed_prefix.replace("{batch}", f"{batch_number:05d}")
|
||
if "{counter}" in filename_prefix:
|
||
processed_prefix = processed_prefix.replace("{counter}", f"{counter:05d}")
|
||
|
||
# 生成文件名
|
||
final_filename = f"{processed_prefix}{file_extension}"
|
||
|
||
# 如果没有使用批次或计数器变量,且有多张图片,需要避免重名
|
||
if len(images) > 1 and "{batch}" not in filename_prefix and "{counter}" not in filename_prefix:
|
||
name_without_ext = os.path.splitext(final_filename)[0]
|
||
final_filename = f"{name_without_ext}_{batch_number:05d}{file_extension}"
|
||
|
||
file_path = os.path.join(full_output_folder, final_filename)
|
||
counter += 1
|
||
|
||
# 根据格式保存图像,移除optimize减少处理时间
|
||
if format == "png":
|
||
# 使用与系统相同的compress_level
|
||
img.save(file_path, format='PNG', compress_level=4)
|
||
|
||
elif format == "jpg":
|
||
# 确保RGB模式(JPEG不支持透明度)
|
||
if img.mode in ('RGBA', 'LA', 'P'):
|
||
img = img.convert('RGB')
|
||
img.save(file_path, format='JPEG', quality=quality)
|
||
|
||
elif format == "webp":
|
||
img.save(file_path, format='WebP', quality=quality)
|
||
|
||
|
||
return {}
|
||
|
||
class LG_InstallDependencies:
|
||
@classmethod
|
||
def INPUT_TYPES(s):
|
||
# 获取custom_nodes目录
|
||
custom_nodes_paths = folder_paths.get_folder_paths("custom_nodes")
|
||
custom_nodes_dir = custom_nodes_paths[0] if custom_nodes_paths else None
|
||
|
||
# 获取所有包含requirements.txt的插件文件夹
|
||
plugin_folders = []
|
||
if custom_nodes_dir and os.path.exists(custom_nodes_dir):
|
||
for item in os.listdir(custom_nodes_dir):
|
||
item_path = os.path.join(custom_nodes_dir, item)
|
||
if os.path.isdir(item_path):
|
||
requirements_path = os.path.join(item_path, "requirements.txt")
|
||
if os.path.exists(requirements_path):
|
||
plugin_folders.append(item)
|
||
|
||
# 如果没有找到包含requirements.txt的插件,添加一个提示项
|
||
if not plugin_folders:
|
||
plugin_folders = ["未找到包含requirements.txt的插件"]
|
||
|
||
return {
|
||
"required": {
|
||
"plugin_folder": (sorted(plugin_folders), {"tooltip": "选择要安装依赖的插件文件夹"}),
|
||
}
|
||
}
|
||
|
||
RETURN_TYPES = ()
|
||
FUNCTION = "install_dependencies"
|
||
OUTPUT_NODE = True
|
||
CATEGORY = CATEGORY_TYPE
|
||
|
||
def install_dependencies(self, plugin_folder):
|
||
if plugin_folder == "未找到包含requirements.txt的插件":
|
||
return {"ui": {"text": ("错误: 没有找到包含requirements.txt的插件文件夹",)}}
|
||
|
||
# 获取custom_nodes目录和插件路径
|
||
custom_nodes_paths = folder_paths.get_folder_paths("custom_nodes")
|
||
custom_nodes_dir = custom_nodes_paths[0] if custom_nodes_paths else None
|
||
|
||
if not custom_nodes_dir:
|
||
return {"ui": {"text": ("❌ 无法获取custom_nodes目录路径",)}}
|
||
|
||
plugin_path = os.path.join(custom_nodes_dir, plugin_folder)
|
||
requirements_path = os.path.join(plugin_path, "requirements.txt")
|
||
|
||
if not os.path.exists(plugin_path):
|
||
return {"ui": {"text": (f"❌ 插件目录不存在",)}}
|
||
|
||
if not os.path.exists(requirements_path):
|
||
return {"ui": {"text": (f"❌ requirements.txt不存在",)}}
|
||
|
||
import sys
|
||
|
||
# 构建pip命令
|
||
cmd = [sys.executable, "-m", "pip", "install", "-r", requirements_path]
|
||
start_message = f"🚀 开始安装 {plugin_folder} 的依赖包...\n📝 执行命令: {' '.join(cmd)}"
|
||
|
||
# 使用通用方法执行命令
|
||
success, result = execute_command_with_realtime_output(
|
||
cmd, plugin_path, "/lg/install_dependencies", True, start_message
|
||
)
|
||
|
||
if isinstance(result, str): # 中断或异常
|
||
return {"ui": {"text": (result,)}}
|
||
|
||
if success:
|
||
success_msg = f"✅ 成功安装 {plugin_folder} 的依赖包"
|
||
PromptServer.instance.send_sync("/lg/install_dependencies", {
|
||
"text": success_msg,
|
||
"clear": False
|
||
})
|
||
return {"ui": {"text": (success_msg,)}}
|
||
else:
|
||
error_msg = f"❌ 安装失败 (退出码: {result})"
|
||
PromptServer.instance.send_sync("/lg/install_dependencies", {
|
||
"text": error_msg,
|
||
"clear": False
|
||
})
|
||
return {"ui": {"text": (error_msg,)}}
|
||
|
||
class LG_FloatRange:
|
||
@classmethod
|
||
def INPUT_TYPES(s):
|
||
return {
|
||
"required": {
|
||
"value": ("FLOAT", {
|
||
"default": 0.00,
|
||
"min": 0.00,
|
||
"max": 1.00,
|
||
"step": 0.01,
|
||
"round": 0.01,
|
||
"tooltip": "0-1范围的浮点数,精度控制在小数点后2位"
|
||
}),
|
||
}
|
||
}
|
||
|
||
RETURN_TYPES = ("FLOAT",)
|
||
RETURN_NAMES = ("float",)
|
||
FUNCTION = "get_float_value"
|
||
CATEGORY = CATEGORY_TYPE
|
||
|
||
def get_float_value(self, value):
|
||
rounded_value = round(value, 2)
|
||
return (rounded_value,)
|
||
|
||
|
||
|
||
# 计数器节点状态存储
|
||
counter_states = {}
|
||
|
||
# 图片加载器计数器状态存储
|
||
image_loader_counter_states = {}
|
||
|
||
class LG_ImageLoaderWithCounter:
|
||
"""
|
||
带计数器的图片加载器节点
|
||
从文件夹加载图片,内置计数器功能和多种排序模式
|
||
"""
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(s):
|
||
return {
|
||
"required": {
|
||
"folder_path": ("STRING", {
|
||
"default": "",
|
||
"multiline": False,
|
||
"placeholder": "图片文件夹路径(支持相对路径和绝对路径)"
|
||
}),
|
||
"mode": (["increase", "decrease", "all"], {
|
||
"default": "increase",
|
||
"tooltip": "increase: 递增模式,decrease: 递减模式,all: 加载所有图像"
|
||
}),
|
||
"sort_mode": (["Alphabetical (ASC)", "Alphabetical (DESC)", "Numerical (ASC)", "Numerical (DESC)", "Datetime (ASC)", "Datetime (DESC)"], {
|
||
"default": "Alphabetical (ASC)",
|
||
"tooltip": "图片排序方式"
|
||
}),
|
||
"keep_index": ("BOOLEAN", {
|
||
"default": False,
|
||
"tooltip": "开启时保持当前索引不变,关闭时恢复正常递增/递减"
|
||
}),
|
||
},
|
||
"hidden": {
|
||
"unique_id": "UNIQUE_ID"
|
||
}
|
||
}
|
||
|
||
RETURN_TYPES = ("IMAGE", "MASK", "STRING", "INT", "INT")
|
||
RETURN_NAMES = ("images", "masks", "filenames", "current_index", "total_images")
|
||
OUTPUT_IS_LIST = (True, True, True, False, False)
|
||
FUNCTION = "load_image_with_counter"
|
||
CATEGORY = CATEGORY_TYPE
|
||
DESCRIPTION = "从文件夹加载图片,内置计数器自动获取文件夹总数,支持递增/递减/全部加载模式和多种排序方式"
|
||
|
||
@classmethod
|
||
def IS_CHANGED(s, folder_path, mode, sort_mode, keep_index, unique_id):
|
||
"""判断节点是否需要重新执行"""
|
||
# all模式或keep_index开启时不触发自动更新,只在参数改变时更新
|
||
if mode == "all" or keep_index:
|
||
return f"{folder_path}_{sort_mode}_{keep_index}"
|
||
# 其他模式每次都返回不同的浮点数,确保每次都触发执行
|
||
import time
|
||
return float(time.time())
|
||
|
||
def get_image_files(self, folder_path, sort_mode):
|
||
"""
|
||
获取文件夹中的所有图片文件并排序
|
||
|
||
Args:
|
||
folder_path: 文件夹路径
|
||
sort_mode: 排序模式
|
||
|
||
Returns:
|
||
排序后的图片文件列表
|
||
"""
|
||
import re
|
||
|
||
# 支持的图片格式
|
||
image_extensions = {'.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp', '.tiff', '.tif'}
|
||
|
||
# 处理路径
|
||
if not os.path.isabs(folder_path):
|
||
# 相对路径:相对于ComfyUI根目录
|
||
folder_path = os.path.join(os.getcwd(), folder_path)
|
||
|
||
if not os.path.exists(folder_path):
|
||
print(f"[ImageLoaderCounter] 文件夹不存在: {folder_path}")
|
||
return []
|
||
|
||
# 获取所有图片文件
|
||
files = []
|
||
for f in os.listdir(folder_path):
|
||
file_path = os.path.join(folder_path, f)
|
||
if os.path.isfile(file_path):
|
||
ext = os.path.splitext(f)[1].lower()
|
||
if ext in image_extensions:
|
||
files.append(f)
|
||
|
||
if not files:
|
||
print(f"[ImageLoaderCounter] 文件夹中没有图片: {folder_path}")
|
||
return []
|
||
|
||
# 根据排序模式排序
|
||
if sort_mode == "Alphabetical (ASC)":
|
||
files.sort()
|
||
elif sort_mode == "Alphabetical (DESC)":
|
||
files.sort(reverse=True)
|
||
elif sort_mode == "Numerical (ASC)":
|
||
# 提取数字排序
|
||
def extract_number(filename):
|
||
numbers = re.findall(r'\d+', filename)
|
||
return int(numbers[0]) if numbers else 0
|
||
files.sort(key=extract_number)
|
||
elif sort_mode == "Numerical (DESC)":
|
||
def extract_number(filename):
|
||
numbers = re.findall(r'\d+', filename)
|
||
return int(numbers[0]) if numbers else 0
|
||
files.sort(key=extract_number, reverse=True)
|
||
elif sort_mode == "Datetime (ASC)":
|
||
# 按文件修改时间排序
|
||
files.sort(key=lambda f: os.path.getmtime(os.path.join(folder_path, f)))
|
||
elif sort_mode == "Datetime (DESC)":
|
||
files.sort(key=lambda f: os.path.getmtime(os.path.join(folder_path, f)), reverse=True)
|
||
|
||
return files
|
||
|
||
def load_image_with_counter(self, folder_path, mode, sort_mode, keep_index, unique_id):
|
||
"""
|
||
加载图片并应用计数器逻辑
|
||
"""
|
||
try:
|
||
# 处理路径
|
||
if not os.path.isabs(folder_path):
|
||
folder_path = os.path.join(os.getcwd(), folder_path)
|
||
|
||
# 获取排序后的图片列表
|
||
image_files = self.get_image_files(folder_path, sort_mode)
|
||
total_images = len(image_files)
|
||
|
||
if total_images == 0:
|
||
print(f"[ImageLoaderCounter] 文件夹中没有图片")
|
||
# 返回空图像列表
|
||
empty_image = torch.zeros((1, 512, 512, 3), dtype=torch.float32, device="cpu")
|
||
empty_mask = torch.zeros((1, 64, 64), dtype=torch.float32, device="cpu")
|
||
return ([empty_image], [empty_mask], [""], 0, 0)
|
||
|
||
# all模式:加载所有图片
|
||
if mode == "all":
|
||
print(f"[ImageLoaderCounter] 节点 {unique_id} all模式,加载所有图片: {total_images}张")
|
||
|
||
images_list = []
|
||
masks_list = []
|
||
filenames_list = []
|
||
|
||
for idx, filename in enumerate(image_files):
|
||
image_path = os.path.join(folder_path, filename)
|
||
|
||
# 使用PIL加载图片
|
||
img = Image.open(image_path)
|
||
img = ImageOps.exif_transpose(img)
|
||
|
||
# 转换为RGB
|
||
if img.mode == 'I':
|
||
img = img.point(lambda i: i * (1 / 255))
|
||
image = img.convert("RGB")
|
||
|
||
# 转换为tensor
|
||
image_np = np.array(image).astype(np.float32) / 255.0
|
||
image_tensor = torch.from_numpy(image_np)[None,]
|
||
|
||
# 处理mask
|
||
if 'A' in img.getbands():
|
||
mask = np.array(img.getchannel('A')).astype(np.float32) / 255.0
|
||
mask = 1. - torch.from_numpy(mask)
|
||
else:
|
||
mask = torch.zeros((image_tensor.shape[1], image_tensor.shape[2]), dtype=torch.float32, device="cpu")
|
||
|
||
# 确保mask是正确的维度
|
||
if len(mask.shape) == 2:
|
||
mask = mask.unsqueeze(0)
|
||
|
||
images_list.append(image_tensor)
|
||
masks_list.append(mask)
|
||
# 移除文件扩展名
|
||
filename_without_ext = os.path.splitext(filename)[0]
|
||
filenames_list.append(filename_without_ext)
|
||
|
||
print(f"[ImageLoaderCounter] 加载 {idx+1}/{total_images}: {filename}")
|
||
|
||
# 发送状态到前端
|
||
PromptServer.instance.send_sync("counter_update", {
|
||
"node_id": unique_id,
|
||
"count": total_images,
|
||
"total": total_images
|
||
})
|
||
|
||
return (images_list, masks_list, filenames_list, total_images, total_images)
|
||
|
||
# 确定当前索引(递增/递减模式)
|
||
current_index = 0
|
||
|
||
# 初始化或获取当前节点的计数状态
|
||
if unique_id not in image_loader_counter_states:
|
||
image_loader_counter_states[unique_id] = {
|
||
"current": 0 if mode == "increase" else total_images - 1,
|
||
"total": total_images,
|
||
"mode": mode,
|
||
"sort_mode": sort_mode,
|
||
"folder_path": folder_path,
|
||
"first_run": True
|
||
}
|
||
print(f"[ImageLoaderCounter] 初始化节点 {unique_id}, 模式: {mode}, 总数: {total_images}")
|
||
|
||
state = image_loader_counter_states[unique_id]
|
||
|
||
# 如果参数改变了,重置计数器
|
||
if (state["total"] != total_images or
|
||
state["mode"] != mode or
|
||
state["sort_mode"] != sort_mode or
|
||
state["folder_path"] != folder_path):
|
||
state["total"] = total_images
|
||
state["mode"] = mode
|
||
state["sort_mode"] = sort_mode
|
||
state["folder_path"] = folder_path
|
||
state["current"] = 0 if mode == "increase" else total_images - 1
|
||
state["first_run"] = True
|
||
print(f"[ImageLoaderCounter] 参数改变,重置节点 {unique_id}, 新模式: {mode}, 新总数: {total_images}")
|
||
|
||
current_index = state["current"]
|
||
|
||
# 如果开启了keep_index,不更新计数器,保持当前索引
|
||
if keep_index:
|
||
print(f"[ImageLoaderCounter] 节点 {unique_id} keep_index开启,保持索引: {current_index}/{total_images}")
|
||
# 如果不是第一次运行且没有开启keep_index,则更新计数器
|
||
elif not state["first_run"]:
|
||
if mode == "increase":
|
||
# 递增模式:0 -> total-1, 然后循环回0
|
||
state["current"] += 1
|
||
if state["current"] >= total_images:
|
||
state["current"] = 0
|
||
else: # decrease
|
||
# 递减模式:total-1 -> 0, 然后循环回total-1
|
||
state["current"] -= 1
|
||
if state["current"] < 0:
|
||
state["current"] = total_images - 1
|
||
|
||
current_index = state["current"]
|
||
print(f"[ImageLoaderCounter] 节点 {unique_id} 执行, 返回索引: {current_index}, 下次: {state['current']}")
|
||
else:
|
||
# 第一次运行,标记为非首次
|
||
state["first_run"] = False
|
||
print(f"[ImageLoaderCounter] 节点 {unique_id} 首次执行, 索引: {current_index}/{total_images}")
|
||
|
||
# 加载对应索引的图片
|
||
filename = image_files[current_index]
|
||
image_path = os.path.join(folder_path, filename)
|
||
|
||
# 使用PIL加载图片
|
||
img = Image.open(image_path)
|
||
img = ImageOps.exif_transpose(img)
|
||
|
||
# 转换为RGB
|
||
if img.mode == 'I':
|
||
img = img.point(lambda i: i * (1 / 255))
|
||
image = img.convert("RGB")
|
||
|
||
# 转换为tensor
|
||
image_np = np.array(image).astype(np.float32) / 255.0
|
||
image_tensor = torch.from_numpy(image_np)[None,]
|
||
|
||
# 处理mask
|
||
if 'A' in img.getbands():
|
||
mask = np.array(img.getchannel('A')).astype(np.float32) / 255.0
|
||
mask = 1. - torch.from_numpy(mask)
|
||
else:
|
||
mask = torch.zeros((image_tensor.shape[1], image_tensor.shape[2]), dtype=torch.float32, device="cpu")
|
||
|
||
# 确保mask是正确的维度
|
||
if len(mask.shape) == 2:
|
||
mask = mask.unsqueeze(0)
|
||
|
||
print(f"[ImageLoaderCounter] 加载图片: {filename}, 索引: {current_index}/{total_images-1} (第{current_index+1}/{total_images}张)")
|
||
|
||
# 发送状态到前端,显示"第几张/总数"(索引+1)
|
||
PromptServer.instance.send_sync("counter_update", {
|
||
"node_id": unique_id,
|
||
"count": current_index + 1, # 显示第几张(1-based)
|
||
"total": total_images
|
||
})
|
||
|
||
# 返回列表格式(与all模式保持一致)
|
||
# 移除文件扩展名
|
||
filename_without_ext = os.path.splitext(filename)[0]
|
||
return ([image_tensor], [mask], [filename_without_ext], current_index, total_images)
|
||
|
||
except Exception as e:
|
||
print(f"[ImageLoaderCounter] 加载图片失败: {str(e)}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
# 返回空图像列表
|
||
empty_image = torch.zeros((1, 512, 512, 3), dtype=torch.float32, device="cpu")
|
||
empty_mask = torch.zeros((1, 64, 64), dtype=torch.float32, device="cpu")
|
||
return ([empty_image], [empty_mask], [""], 0, 0)
|
||
|
||
class LG_Counter:
|
||
"""计数器节点"""
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(s):
|
||
return {
|
||
"required": {
|
||
"total": ("INT", {
|
||
"default": 10,
|
||
"min": 1,
|
||
"max": 10000,
|
||
"step": 1
|
||
}),
|
||
"mode": (["increase", "decrease", "None"], {
|
||
"default": "increase"
|
||
}),
|
||
},
|
||
"hidden": {
|
||
"unique_id": "UNIQUE_ID"
|
||
}
|
||
}
|
||
|
||
RETURN_TYPES = ("INT",)
|
||
RETURN_NAMES = ("count",)
|
||
FUNCTION = "count"
|
||
CATEGORY = CATEGORY_TYPE
|
||
|
||
@classmethod
|
||
def IS_CHANGED(s, total, mode, unique_id):
|
||
# None模式不触发自动更新,只在参数改变时更新
|
||
if mode == "None":
|
||
return f"{total}_{mode}"
|
||
# 其他模式每次都返回不同的浮点数,确保每次都触发执行
|
||
import time
|
||
return float(time.time())
|
||
|
||
def count(self, total, mode, unique_id):
|
||
try:
|
||
# None模式:直接返回total-1的索引值,不进行计数
|
||
if mode == "None":
|
||
index_value = total - 1
|
||
print(f"[Counter] 节点 {unique_id} None模式,返回索引值: {index_value} (total={total})")
|
||
# 发送计数到前端
|
||
PromptServer.instance.send_sync("counter_update", {
|
||
"node_id": unique_id,
|
||
"count": index_value
|
||
})
|
||
return (index_value,)
|
||
|
||
# 初始化或获取当前节点的计数状态
|
||
if unique_id not in counter_states:
|
||
counter_states[unique_id] = {
|
||
"current": 0 if mode == "increase" else total,
|
||
"total": total,
|
||
"mode": mode,
|
||
"first_run": True
|
||
}
|
||
print(f"[Counter] 初始化节点 {unique_id}, 模式: {mode}, 总数: {total}")
|
||
|
||
state = counter_states[unique_id]
|
||
|
||
# 如果total或mode改变了,重置计数器
|
||
if state["total"] != total or state["mode"] != mode:
|
||
state["total"] = total
|
||
state["mode"] = mode
|
||
state["current"] = 0 if mode == "increase" else total
|
||
state["first_run"] = True
|
||
print(f"[Counter] 参数改变,重置节点 {unique_id}, 新模式: {mode}, 新总数: {total}")
|
||
|
||
current_count = state["current"]
|
||
|
||
# 如果不是第一次运行,则先更新计数器再返回
|
||
if not state["first_run"]:
|
||
if mode == "increase":
|
||
# 递增模式:0 -> total-1, 然后循环回0
|
||
state["current"] += 1
|
||
if state["current"] >= total:
|
||
state["current"] = 0
|
||
else:
|
||
# 递减模式:total -> 0, 然后循环回total
|
||
state["current"] -= 1
|
||
if state["current"] < 0:
|
||
state["current"] = total
|
||
|
||
current_count = state["current"]
|
||
else:
|
||
# 第一次运行,标记为非首次
|
||
state["first_run"] = False
|
||
|
||
print(f"[Counter] 节点 {unique_id} 执行, 返回: {current_count}, 下次: {state['current']}")
|
||
|
||
# 发送计数到前端
|
||
PromptServer.instance.send_sync("counter_update", {
|
||
"node_id": unique_id,
|
||
"count": current_count
|
||
})
|
||
|
||
return (current_count,)
|
||
|
||
except Exception as e:
|
||
print(f"[Counter] 计数器执行错误: {str(e)}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return (0,)
|
||
|
||
# API端点:重置计数器
|
||
@routes.post('/counter/reset')
|
||
async def reset_counter(request):
|
||
try:
|
||
data = await request.json()
|
||
node_id = data.get('node_id')
|
||
|
||
if not node_id:
|
||
return web.json_response({"status": "error", "message": "节点ID不能为空"}, status=400)
|
||
|
||
print(f"[Counter] 重置请求 - node_id: {node_id}, 类型: {type(node_id)}")
|
||
print(f"[Counter] 当前存储的keys: {list(counter_states.keys())}")
|
||
|
||
# 重置计数器状态 - 检查字符串和整数格式
|
||
found = False
|
||
target_key = None
|
||
|
||
# 先直接查找
|
||
if node_id in counter_states:
|
||
target_key = node_id
|
||
found = True
|
||
# 尝试整数格式
|
||
elif str(node_id) in counter_states:
|
||
target_key = str(node_id)
|
||
found = True
|
||
# 尝试字符串转整数
|
||
else:
|
||
try:
|
||
int_id = int(node_id)
|
||
if int_id in counter_states:
|
||
target_key = int_id
|
||
found = True
|
||
except (ValueError, TypeError):
|
||
pass
|
||
|
||
if found:
|
||
state = counter_states[target_key]
|
||
if state["mode"] == "increase":
|
||
state["current"] = 0
|
||
else:
|
||
state["current"] = state["total"]
|
||
|
||
state["first_run"] = True
|
||
|
||
print(f"[Counter] 重置成功 - 当前值: {state['current']}")
|
||
|
||
return web.json_response({
|
||
"status": "success",
|
||
"current": state["current"],
|
||
"message": "计数器已重置"
|
||
})
|
||
else:
|
||
print(f"[Counter] 未找到计数器状态")
|
||
return web.json_response({
|
||
"status": "success",
|
||
"message": "计数器状态不存在,将在下次执行时初始化"
|
||
})
|
||
|
||
except Exception as e:
|
||
print(f"[Counter] 重置计数器失败: {str(e)}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||
|
||
# API端点:重置图片加载器计数器
|
||
@routes.post('/image_loader_counter/reset')
|
||
async def reset_image_loader_counter(request):
|
||
"""
|
||
重置图片加载器计数器状态
|
||
"""
|
||
try:
|
||
data = await request.json()
|
||
node_id = data.get('node_id')
|
||
|
||
if not node_id:
|
||
return web.json_response({"status": "error", "message": "节点ID不能为空"}, status=400)
|
||
|
||
print(f"[ImageLoaderCounter] 重置请求 - node_id: {node_id}, 类型: {type(node_id)}")
|
||
print(f"[ImageLoaderCounter] 当前存储的keys: {list(image_loader_counter_states.keys())}")
|
||
|
||
# 重置计数器状态 - 检查字符串和整数格式
|
||
found = False
|
||
target_key = None
|
||
|
||
# 先直接查找
|
||
if node_id in image_loader_counter_states:
|
||
target_key = node_id
|
||
found = True
|
||
# 尝试整数格式
|
||
elif str(node_id) in image_loader_counter_states:
|
||
target_key = str(node_id)
|
||
found = True
|
||
# 尝试字符串转整数
|
||
else:
|
||
try:
|
||
int_id = int(node_id)
|
||
if int_id in image_loader_counter_states:
|
||
target_key = int_id
|
||
found = True
|
||
except (ValueError, TypeError):
|
||
pass
|
||
|
||
if found:
|
||
state = image_loader_counter_states[target_key]
|
||
if state["mode"] == "increase":
|
||
state["current"] = 0
|
||
else:
|
||
state["current"] = state["total"] - 1
|
||
|
||
state["first_run"] = True
|
||
|
||
print(f"[ImageLoaderCounter] 重置成功 - 当前索引: {state['current']}/{state['total']}")
|
||
|
||
return web.json_response({
|
||
"status": "success",
|
||
"current": state["current"],
|
||
"total": state["total"],
|
||
"message": "图片加载器计数器已重置"
|
||
})
|
||
else:
|
||
print(f"[ImageLoaderCounter] 未找到计数器状态")
|
||
return web.json_response({
|
||
"status": "success",
|
||
"current": 0,
|
||
"message": "计数器状态不存在,将在下次执行时初始化"
|
||
})
|
||
|
||
except Exception as e:
|
||
print(f"[ImageLoaderCounter] 重置计数器失败: {str(e)}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||
|
||
|
||
|
||
class LG_MaskHoleFiller:
|
||
def __init__(self):
|
||
pass
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"mask": ("MASK",), # 输入维度: (B, H, W)
|
||
}
|
||
}
|
||
|
||
RETURN_TYPES = ("MASK",)
|
||
RETURN_NAMES = ("filled_mask",)
|
||
FUNCTION = "fill_mask_holes"
|
||
CATEGORY = "mask"
|
||
|
||
def fill_mask_holes(self, mask):
|
||
filled_masks = []
|
||
for i in range(mask.shape[0]):
|
||
mask_np = (mask[i].cpu().numpy() > 0.5).astype(np.uint8)
|
||
h, w = mask_np.shape
|
||
|
||
# 在遮罩周围添加1像素的边框(值为0),确保外部区域连通
|
||
padded_mask = np.pad(mask_np, pad_width=1, mode='constant', constant_values=0)
|
||
|
||
# 创建floodFill所需的mask,必须比padded_mask大2
|
||
floodfill = padded_mask.copy()
|
||
mask_ff = np.zeros((h + 4, w + 4), np.uint8)
|
||
|
||
# 从(0,0)点进行floodFill,填充外部区域
|
||
cv2.floodFill(floodfill, mask_ff, (0, 0), 1)
|
||
|
||
# floodfill==0的地方是内部的空洞
|
||
holes = (floodfill == 0).astype(np.uint8)
|
||
|
||
# 移除边框,恢复原始尺寸
|
||
holes = holes[1:-1, 1:-1]
|
||
|
||
# 原mask加上空洞部分
|
||
filled = np.clip(mask_np + holes, 0, 1)
|
||
filled_tensor = torch.from_numpy(filled).float()
|
||
filled_masks.append(filled_tensor)
|
||
|
||
filled_mask = torch.stack(filled_masks)
|
||
return (filled_mask,)
|
||
|
||
class LG_MaskBatchMerge:
|
||
"""合并批次遮罩节点 - 将批次中的多个遮罩合并为单个遮罩"""
|
||
|
||
def __init__(self):
|
||
pass
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"masks": ("MASK",), # 输入维度: (B, H, W)
|
||
"merge_mode": (["max", "min", "average", "sum"], {
|
||
"default": "max"
|
||
}),
|
||
}
|
||
}
|
||
|
||
RETURN_TYPES = ("MASK",)
|
||
RETURN_NAMES = ("merged_mask",)
|
||
FUNCTION = "merge_masks"
|
||
CATEGORY = CATEGORY_TYPE
|
||
DESCRIPTION = "合并批次遮罩为单个遮罩。支持最大值、最小值、平均值和求和四种合并模式。"
|
||
|
||
def merge_masks(self, masks, merge_mode):
|
||
"""
|
||
合并批次遮罩
|
||
|
||
参数:
|
||
masks: 输入的批次遮罩,形状为 (B, H, W)
|
||
merge_mode: 合并模式
|
||
- "max": 取所有遮罩的最大值(并集)
|
||
- "min": 取所有遮罩的最小值(交集)
|
||
- "average": 取所有遮罩的平均值
|
||
- "sum": 取所有遮罩的和(会被裁剪到0-1范围)
|
||
|
||
返回:
|
||
merged_mask: 合并后的遮罩,形状为 (1, H, W)
|
||
"""
|
||
if masks.shape[0] == 0:
|
||
raise ValueError("输入的遮罩批次为空")
|
||
|
||
# 根据合并模式处理
|
||
if merge_mode == "max":
|
||
# 取最大值 - 相当于遮罩的并集
|
||
merged = torch.max(masks, dim=0, keepdim=True)[0]
|
||
elif merge_mode == "min":
|
||
# 取最小值 - 相当于遮罩的交集
|
||
merged = torch.min(masks, dim=0, keepdim=True)[0]
|
||
elif merge_mode == "average":
|
||
# 取平均值
|
||
merged = torch.mean(masks, dim=0, keepdim=True)
|
||
elif merge_mode == "sum":
|
||
# 求和并裁剪到0-1范围
|
||
merged = torch.sum(masks, dim=0, keepdim=True)
|
||
merged = torch.clamp(merged, 0.0, 1.0)
|
||
else:
|
||
# 默认使用最大值
|
||
merged = torch.max(masks, dim=0, keepdim=True)[0]
|
||
|
||
return (merged,)
|
||
NODE_CLASS_MAPPINGS = {
|
||
"CachePreviewBridge": CachePreviewBridge,
|
||
"LG_Noise": LG_Noise,
|
||
"LG_LoadImage": LG_LoadImage,
|
||
"LG_LatentBatchToList": LG_LatentBatchToList,
|
||
"LG_SaveImage": LG_SaveImage,
|
||
"LG_InstallDependencies": LG_InstallDependencies,
|
||
"LG_PipManager": LG_PipManager,
|
||
"LG_FloatRange": LG_FloatRange,
|
||
"LG_Counter": LG_Counter,
|
||
"LG_ImageLoaderWithCounter": LG_ImageLoaderWithCounter,
|
||
"LG_MaskHoleFiller": LG_MaskHoleFiller,
|
||
"LG_MaskBatchMerge": LG_MaskBatchMerge,
|
||
}
|
||
|
||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||
"CachePreviewBridge": "🎈LG_PreviewBridge",
|
||
"LG_Noise": "🎈LG_Noise",
|
||
"LG_LoadImage": "🎈LG_LoadImage",
|
||
"LG_LatentBatchToList": "🎈LG_Latent批次转列表",
|
||
"LG_SaveImage": "🎈LG_SaveImage",
|
||
"LG_InstallDependencies": "🎈LG_安装依赖",
|
||
"LG_PipManager": "🎈LG_Pip管理器",
|
||
"LG_FloatRange": "🎈LG_浮点数[0-1]",
|
||
"LG_Counter": "🎈LG_计数器",
|
||
"LG_ImageLoaderWithCounter": "🎈LG_图片列表加载器",
|
||
"LG_MaskHoleFiller": "🎈LG_填充空洞",
|
||
"LG_MaskBatchMerge": "🎈LG_合并批次遮罩",
|
||
}
|
||
|
||
|
||
|