写在前面
用 ComfyUI 的人应该都有过这种体验------工作流搭到一半,发现"这个操作为什么没有现成的节点?"
比如生成完图片想自动加水印,或者统一 resize 到某个尺寸再保存。标准节点库里确实没有,等别人做又不一定等到。最直接的办法是自己写一个。
这篇文章会带你从零写一个 ComfyUI 自定义节点,功能是「给生成的图片自动加上半透明文字水印」。功能不复杂,但覆盖了自定义节点开发的全部基础------输入输出定义、图像数据转换、节点注册。读完就能上手改。
自定义节点的原理
ComfyUI 启动时会扫描 custom_nodes/ 目录,自动加载其中的 Python 模块。你只要按约定定义好类,它就会出现在节点菜单里。
核心就三步:
- 定义输入参数和输出类型
- 实现处理逻辑
- 注册节点名称和分类
UI 部分完全不用管,ComfyUI 会根据你的定义自动生成参数面板和连接点。
环境说明
- ComfyUI(最新 portable 版)
- Python 3.10+
- Pillow(通常已经装了)
如果你用 ComfyUI Manager 装过一些社区节点,环境就满足要求。
目录结构
markdown
ComfyUI/
└── custom_nodes/
└── comfyui-watermark-node/
├── __init__.py
└── watermark_node.py
两个文件就够了。__init__.py 负责注册,watermark_node.py 写逻辑。
动手实现
1. 定义节点类
先创建 watermark_node.py:
python
import torch
import numpy as np
from PIL import Image, ImageDraw, ImageFont
import os
class WatermarkNode:
"""
给生成图片添加半透明文字水印的节点
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"text": ("STRING", {
"default": "Generated by AI",
"multiline": False,
}),
"opacity": ("FLOAT", {
"default": 0.3,
"min": 0.0,
"max": 1.0,
"step": 0.05,
}),
"font_size": ("INT", {
"default": 36,
"min": 8,
"max": 200,
"step": 1,
}),
"position": (["bottom-right", "bottom-left", "top-right", "top-left", "center"],),
},
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "add_watermark"
CATEGORY = "image/watermark"
INPUT_TYPES 里的 "required" 字典定义了节点面板上会出现哪些参数。"IMAGE" 是 ComfyUI 内置的数据类型,用于节点间传递图像张量。字符串、浮点数、整数、下拉选择框------这些 UI 控件都是自动生成的,不需要写任何前端代码。
RETURN_TYPES 是输出类型,这里只输出一张图。FUNCTION 指定被执行的方法名。CATEGORY 控制节点在右键菜单里出现在哪个分类下。
2. 图像格式转换
这里容易踩坑。ComfyUI 里的图像张量 shape 是 (batch, height, width, channels),值是 0.0~1.0 的 float32。而 Pillow 期望的是 0~255 的 uint8。两边需要互转:
python
def tensor_to_pil(self, img_tensor):
"""ComfyUI 张量 -> Pillow Image(取 batch 中第一张)"""
img = img_tensor[0].cpu().numpy()
img = (img * 255).astype(np.uint8)
return Image.fromarray(img)
def pil_to_tensor(self, pil_img):
"""Pillow Image -> ComfyUI 张量"""
img = np.array(pil_img).astype(np.float32) / 255.0
img = torch.from_numpy(img).unsqueeze(0)
return img
注意 unsqueeze(0) 这一步------不加 batch 维度的话,下游节点的 shape 校验会报错,而且错误信息通常不怎么友好。
3. 水印绘制逻辑
用 Pillow 的 ImageDraw 画文字:
python
def add_watermark(self, image, text, opacity, font_size, position):
# 张量 -> PIL
pil_img = self.tensor_to_pil(image)
img_w, img_h = pil_img.size
# 转 RGBA 以支持透明合成
if pil_img.mode != "RGBA":
pil_img = pil_img.convert("RGBA")
# 创建透明覆盖层
overlay = Image.new("RGBA", pil_img.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
font = self._get_font(font_size)
# 计算文字尺寸
bbox = draw.textbbox((0, 0), text, font=font)
text_w = bbox[2] - bbox[0]
text_h = bbox[3] - bbox[1]
margin = 20
# 定位
if position == "bottom-right":
x = img_w - text_w - margin
y = img_h - text_h - margin
elif position == "bottom-left":
x = margin
y = img_h - text_h - margin
elif position == "top-right":
x = img_w - text_w - margin
y = margin
elif position == "top-left":
x = margin
y = margin
else: # center
x = (img_w - text_w) // 2
y = (img_h - text_h) // 2
# 半透明白色文字
alpha = int(255 * opacity)
draw.text((x, y), text, font=font, fill=(255, 255, 255, alpha))
# 合成并转回 RGB
pil_img = Image.alpha_composite(pil_img, overlay)
pil_img = pil_img.convert("RGB")
return (self.pil_to_tensor(pil_img),)
def _get_font(self, size):
"""获取字体,找不到就用 Pillow 默认字体"""
font_paths = [
"/System/Library/Fonts/ヒラギノ角ゴシック W4.ttc", # macOS
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", # Linux
"C:\\Windows\\Fonts\\msgothic.ttc", # Windows
]
for path in font_paths:
if os.path.exists(path):
return ImageFont.truetype(path, size)
return ImageFont.load_default()
字体路径按主流 OS 做了 fallback。如果要求稳,把字体文件直接放在节点目录里打包分发是最靠谱的做法。
4. 注册节点
__init__.py 负责把类暴露给 ComfyUI:
python
# custom_nodes/comfyui-watermark-node/__init__.py
from .watermark_node import WatermarkNode
NODE_CLASS_MAPPINGS = {
"WatermarkNode": WatermarkNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"WatermarkNode": "Add Watermark",
}
NODE_CLASS_MAPPINGS 是必须的,没有这个字典 ComfyUI 不会识别节点。NODE_DISPLAY_NAME_MAPPINGS 是可选的,控制菜单里的显示名,不写就用类名。
测试
- 把两个文件放到
custom_nodes/comfyui-watermark-node/ - 重启 ComfyUI
- 右键节点列表 →
image/watermark→Add Watermark - 连在 Save Image 节点前面试试
参数说明:
- text:水印文字(中文也可以,前提是系统有对应字体)
- opacity:透明度,0.2~0.3 比较自然
- font_size:按图片分辨率调整
- position:默认右下角
踩坑备忘
节点列表里找不到 → __init__.py 里的 NODE_CLASS_MAPPINGS 键名有没有写错。这个 typo 概率最高。
输出图片全黑 → 张量的值域不对。确认是 0.0~1.0 的 float32。
中文显示成方块 → _get_font() 用的字体不支持中文。改字体路径,或者把字体文件打包进节点目录。
进阶技巧
常用输入类型速查
| 类型 | 说明 | 写法 |
|---|---|---|
IMAGE |
图像张量 | "image": ("IMAGE",) |
LATENT |
潜空间张量 | "latent": ("LATENT",) |
STRING |
文本输入 | "prompt": ("STRING", {"multiline": True}) |
FLOAT |
浮点数滑块 | "strength": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0}) |
INT |
整数 | "steps": ("INT", {"default": 20, "min": 1, "max": 100}) |
可选参数
把参数放在 "optional" 而不是 "required" 里,输入端口就变成可选的了:
python
"optional": {
"mask": ("MASK",),
},
多输出
返回元组就行:
python
RETURN_TYPES = ("IMAGE", "INT",)
def add_watermark(self, image, text, opacity, font_size, position):
# ...
return (output_tensor, len(text))
调试
print() 的输出会出现在启动 ComfyUI 的终端窗口里。大多数情况下够用了。复杂场景可以上 logging 模块。
收尾
ComfyUI 自定义节点的门槛其实不高。核心记住三点:
INPUT_TYPES管参数面板,FUNCTION管执行逻辑- 张量 ↔ PIL 转换时注意值域和 shape
- 别忘了在
NODE_CLASS_MAPPINGS里注册
这次做的水印节点是个起点。同样的模式可以扩展出很多实用功能------自动 resize、嵌入元数据、多图拼接成网格,思路都一样。
工欲善其事,不如自己造轮子。