Files
comfyui_o1key/nodes/remove_metadata.py
T
o1keyandClaude Sonnet 4.5 9ab209b2b7 feat: sync latest local version as authoritative codebase
Complete rewrite/sync of comfyui_o1key custom nodes.
Treat this commit as the current canonical version.

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
2026-04-03 16:18:45 +08:00

372 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
图像元数据去除节点
替代 ComfyUI 原生"保存图像"节点,保存时不写入提示词、工作流等 AI 元数据
提供两种节点:
1. SaveCleanImage - 接收 IMAGE 张量,去除元数据后直接保存到 output 目录
2. BatchCleanMetadata - 指定文件夹路径,批量去除已有图片中的元数据
"""
import os
from datetime import datetime
import random
from typing import List
import numpy as np
import torch
from PIL import Image
from PIL.PngImagePlugin import PngInfo
from ..utils.image_utils import tensor_to_pil
from ..utils.file_utils import _get_port_suffix
# 尝试导入 ComfyUI 的 folder_paths
try:
import folder_paths
FOLDER_PATHS_AVAILABLE = True
except ImportError:
FOLDER_PATHS_AVAILABLE = False
# 支持的图片格式
SUPPORTED_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.bmp', '.tiff', '.tif'}
def _get_output_dir() -> str:
"""
获取 ComfyUI output 目录
Returns:
output 目录的绝对路径
"""
if FOLDER_PATHS_AVAILABLE:
return folder_paths.get_output_directory()
# fallback: 相对于插件目录推断
plugin_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
return os.path.join(os.path.dirname(os.path.dirname(plugin_dir)), "output")
def _get_next_counter(directory: str, prefix: str) -> int:
"""
扫描目录,获取下一个可用的文件计数器
Args:
directory: 目标目录
prefix: 文件名前缀
Returns:
下一个计数器值
"""
if not os.path.exists(directory):
return 1
if prefix:
pattern = re.compile(rf'^{re.escape(prefix)}_(\d+)')
else:
pattern = re.compile(rf'^(\d+)\.')
max_counter = 0
for f in os.listdir(directory):
m = pattern.match(f)
if m:
counter = int(m.group(1))
max_counter = max(max_counter, counter)
return max_counter + 1
def _save_image_clean(image: Image.Image, path: str, fmt: str = None, quality: int = 95) -> None:
"""
保存图像,不包含任何元数据
通过提取纯像素数据并重建全新的 Image 对象,确保没有任何元数据残留。
Args:
image: PIL Image 对象
path: 保存路径
fmt: 图像格式(PNG/JPEG/WEBP),为 None 时根据扩展名推断
quality: JPEG/WEBP 质量(1-100)
"""
# 确保 RGB 模式
if image.mode != 'RGB':
image = image.convert('RGB')
# 提取纯像素数据,重建全新的 Image 对象
# 使用 tobytes() + frombytes() 确保只保留像素数据,彻底断开与原图像的关联
pixel_data = image.tobytes()
clean = Image.frombytes('RGB', image.size, pixel_data)
# 显式清空 info 字典,确保不会有任何残留元数据
clean.info = {}
# 推断格式
if fmt is None:
ext = os.path.splitext(path)[1].lower()
format_map = {
'.png': 'PNG',
'.jpg': 'JPEG',
'.jpeg': 'JPEG',
'.webp': 'WEBP',
'.bmp': 'BMP',
'.tiff': 'TIFF',
'.tif': 'TIFF',
}
fmt = format_map.get(ext, 'PNG')
# 构建保存参数(确保不写入任何元数据)
save_kwargs = {}
if fmt == 'PNG':
save_kwargs['pnginfo'] = PngInfo() # 空的 PngInfo,不包含任何文本块
elif fmt == 'JPEG':
save_kwargs['quality'] = quality
# 不传 exif 参数,自然不会写入 EXIF 数据
elif fmt == 'WEBP':
save_kwargs['quality'] = quality
save_kwargs['exif'] = b"" # 显式清空 EXIF
clean.save(path, format=fmt, **save_kwargs)
# ============================================================================
# 节点 1:保存干净图像
# ============================================================================
class SaveCleanImage:
"""
保存干净图像节点(不含元数据)
功能:
- 接收 IMAGE 张量(支持单图和批次)
- 去除所有元数据后保存到 ComfyUI/output 目录
- 文件名自动添加 nometa 标识,方便辨认
- 支持 PNG/JPEG/WEBP 格式
- 作为终端节点,替代 ComfyUI 原生"保存图像"节点
使用场景:
- 生图完成后,直接保存不含 AI 元数据的干净图像
- 分享图像时不暴露提示词和工作流
"""
SAVE_FORMATS = ["PNG", "JPEG", "WEBP"]
@classmethod
def INPUT_TYPES(cls):
"""
定义输入参数
Returns:
输入参数配置字典
"""
return {
"required": {
"图像": ("IMAGE",),
"文件名前缀": ("STRING", {"default": "ComfyUI_nometa"}),
"保存格式": (cls.SAVE_FORMATS, {"default": "PNG"}),
},
"optional": {
"JPEG/WEBP质量": ("INT", {
"default": 95,
"min": 1,
"max": 100,
"step": 1
}),
}
}
RETURN_TYPES = ()
OUTPUT_NODE = True
FUNCTION = "save_clean"
CATEGORY = "image"
DESCRIPTION = (
"保存干净图像(不含元数据)。\n"
"替代 ComfyUI 原生'保存图像'节点,保存时不写入提示词、工作流等 AI 元数据。\n"
"文件保存到 ComfyUI/output 目录。"
)
def save_clean(
self,
图像: torch.Tensor,
文件名前缀: str = "ComfyUI_nometa",
保存格式: str = "PNG",
**kwargs
) -> dict:
"""
去除元数据并保存图像
Args:
图像: ComfyUI 图像张量 [B, H, W, C]
文件名前缀: 保存文件名前缀
保存格式: 图像格式(PNG/JPEG/WEBP)
**kwargs: 可选参数(JPEG/WEBP质量)
Returns:
UI 结果字典,包含保存的图像信息用于前端预览
"""
quality = kwargs.get("JPEG/WEBP质量", 95)
output_dir = _get_output_dir()
port_suffix = _get_port_suffix()
os.makedirs(output_dir, exist_ok=True)
# 格式与扩展名映射
ext_map = {"PNG": ".png", "JPEG": ".jpg", "WEBP": ".webp"}
ext = ext_map.get(保存格式, ".png")
# 转换为 PIL 图像
pil_images = tensor_to_pil(图像)
results = []
saved_paths = []
for img in pil_images:
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
ms = random.randint(0, 999)
while True:
if 文件名前缀:
filename = f"{文件名前缀}_{ts}_{ms:03d}{port_suffix}{ext}"
else:
filename = f"{ts}_{ms:03d}{port_suffix}{ext}"
filepath = os.path.join(output_dir, filename)
if not os.path.exists(filepath):
break
ms = (ms + 1) % 1000
_save_image_clean(img, filepath, fmt=保存格式, quality=quality)
results.append({
"filename": filename,
"subfolder": "",
"type": "output"
})
saved_paths.append(filepath)
# 打印详细日志,方便用户定位保存的文件
print(f"保存干净图像: 已保存 {len(pil_images)} 张无元数据图像 (格式: {保存格式})")
for p in saved_paths:
print(f" → {p}")
return {"ui": {"images": results}}
# ============================================================================
# 节点 2:批量去除元数据
# ============================================================================
class BatchCleanMetadata:
"""
批量去除文件夹中图片元数据的节点
功能:
- 指定文件夹路径,批量处理其中所有图片
- 去除 EXIF、PNG tEXt 块、ComfyUI 工作流等所有元数据
- 支持保存到原目录(添加 _nometa 后缀)或覆盖原文件
- 支持 PNG/JPG/JPEG/WEBP/BMP/TIFF 格式
使用场景:
- 已经保存了一批含有 AI 元数据的图片,需要批量清理
- 批量处理指定文件夹中的所有图片
"""
@classmethod
def INPUT_TYPES(cls):
"""
定义输入参数
Returns:
输入参数配置字典
"""
return {
"required": {
"文件夹路径": ("STRING", {"default": ""}),
"覆盖原文件": ("BOOLEAN", {"default": False}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("处理结果",)
OUTPUT_NODE = True
FUNCTION = "batch_clean"
CATEGORY = "image"
DESCRIPTION = (
"批量去除文件夹中图片的元数据。\n"
"支持 PNG/JPG/JPEG/WEBP/BMP/TIFF 格式。\n"
"默认在原文件名后添加 _nometa 后缀保存,也可选择覆盖原文件。"
)
def batch_clean(
self,
文件夹路径: str,
覆盖原文件: bool = False,
) -> tuple:
"""
批量去除文件夹中图片的元数据
Args:
文件夹路径: 待处理图片所在的文件夹路径
覆盖原文件: 是否覆盖原文件(False 则添加 _nometa 后缀)
Returns:
处理结果字符串
Raises:
ValueError: 文件夹路径无效
"""
if not 文件夹路径 or not 文件夹路径.strip():
raise ValueError("请输入文件夹路径")
folder = 文件夹路径.strip()
if not os.path.isdir(folder):
raise ValueError(f"文件夹路径无效或不存在: {folder}")
# 扫描支持的图片文件
files = []
for f in sorted(os.listdir(folder)):
ext = os.path.splitext(f)[1].lower()
if ext in SUPPORTED_EXTENSIONS:
files.append(f)
if not files:
msg = f"文件夹中未找到支持的图片文件 ({', '.join(SUPPORTED_EXTENSIONS)})"
print(f"批量去除元数据: {msg}")
return (msg,)
print(f"批量去除元数据: 找到 {len(files)} 张图片,开始处理...")
success_count = 0
fail_count = 0
for f in files:
try:
src_path = os.path.join(folder, f)
img = Image.open(src_path)
if 覆盖原文件:
dst_path = src_path
else:
name, ext = os.path.splitext(f)
dst_path = os.path.join(folder, f"{name}_nometa{ext}")
_save_image_clean(img, dst_path)
success_count += 1
except Exception as e:
print(f"批量去除元数据: 处理 {f} 失败 - {str(e)}")
fail_count += 1
# 构建结果消息
if fail_count > 0:
msg = f"处理完成: 成功 {success_count} 张, 失败 {fail_count} 张"
else:
msg = f"处理完成: 全部 {success_count} 张成功"
if not 覆盖原文件:
msg += " (已添加 _nometa 后缀)"
else:
msg += " (已覆盖原文件)"
print(f"批量去除元数据: {msg}")
return (msg,)