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_合并批次遮罩", }