ComfyUI 自定义节点开发:从零扩展你的图像生成工作流
英文标题:ComfyUI 自定义节点开发:从零扩展你的图像生成工作流
本文以“为生成图片自动添加半透明文字水印”为例,完整演示如何从零开发 ComfyUI 自定义节点。内容覆盖节点输入输出定义、ComfyUI 图像张量与 Pillow 图像的转换、水印绘制逻辑,以及通过 `NODE_CLASS_MAPPINGS` 注册节点的方法。文章特别指出,ComfyUI 图像张量采用 `(batch, height, width, channels)` 形状与 0.0~1.0 的 `float32` 值域,转换错误可能导致黑图或下游节点报错。开发者掌握这一模式后,可进一步扩展自动 resize、元数据嵌入、多图拼接等工作流能力。
5.3 KB
中文
写在前面
使用 ComfyUI 的人应该都有过这种体验:工作流搭到一半,发现“这个操作为什么没有现成的节点?”
例如,生成图片后想自动添加水印,或者统一 resize 到某个尺寸后再保存。标准节点库里确实没有,等别人来做也不一定等得到。最直接的办法就是自己写一个。
本文会带你从零编写一个 ComfyUI 自定义节点,功能是“为生成图片自动添加半透明文字水印”。功能并不复杂,但覆盖了自定义节点开发的全部基础:输入输出定义、图像数据转换、节点注册。读完后即可上手修改并扩展。
自定义节点的原理
ComfyUI 启动时会扫描 custom_nodes/ 目录,并自动加载其中的 Python 模块。只要按照约定定义好类,节点就会出现在节点菜单中。
核心分为三步:
- 定义输入参数和输出类型
- 实现处理逻辑
- 注册节点名称和分类
UI 部分完全不需要手动处理,ComfyUI 会依据定义自动生成参数面板和连接点。
环境说明
- ComfyUI(最新版 portable 版)
- Python 3.10+
- Pillow(通常已预装)
如果你曾通过 ComfyUI Manager 安装过一些社区节点,环境通常已经满足要求。
目录结构
ComfyUI/
└── custom_nodes/
└── comfyui-watermark-node/
├── __init__.py
└── watermark_node.py
两个文件即可。__init__.py 负责注册,watermark_node.py 负责实现逻辑。
动手实现
1. 定义节点类
先创建 watermark_node.py:
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。两者之间需要进行转换:
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 绘制文字:
def add_watermark(self, image, text, opacity, font_size, position):
pil_img = self.tensor_to_pil(image)
img_w, img_h = pil_img.size
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:
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))
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",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"C:\\Windows\\Fonts\\msgothic.ttc",
]
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:
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" 中,输入端口就会变成可选:
"optional": {
"mask": ("MASK",),
},
多输出
只需要返回一个元组:
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、嵌入元数据、将多张图片拼接为网格,整体思路都是一致的。
工欲善其事,不如自己造轮子。
English / 原文
写在前面
用 ComfyUI 的人应该都有过这种体验——工作流搭到一半,发现"这个操作为什么没有现成的节点?"
比如生成完图片想自动加水印,或者统一 resize 到某个尺寸再保存。标准节点库里确实没有,等别人做又不一定等到。最直接的办法是自己写一个。
这篇文章会带你从零写一个 ComfyUI 自定义节点,功能是「给生成的图片自动加上半透明文字水印」。功能不复杂,但覆盖了自定义节点开发的全部基础——输入输出定义、图像数据转换、节点注册。读完就能上手改。
自定义节点的原理
ComfyUI 启动时会扫描 custom_nodes/ 目录,自动加载其中的 Python 模块。你只要按约定定义好类,它就会出现在节点菜单里。
核心就三步:
- 定义输入参数和输出类型
- 实现处理逻辑
- 注册节点名称和分类
UI 部分完全不用管,ComfyUI 会根据你的定义自动生成参数面板和连接点。
环境说明
- ComfyUI(最新 portable 版)
- Python 3.10+
- Pillow(通常已经装了)
如果你用 ComfyUI Manager 装过一些社区节点,环境就满足要求。
目录结构
ComfyUI/
└── custom_nodes/
└── comfyui-watermark-node/
├── __init__.py
└── watermark_node.py
两个文件就够了。__init__.py 负责注册,watermark_node.py 写逻辑。
动手实现
1. 定义节点类
先创建 watermark_node.py:
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.01.0 的 float32。而 Pillow 期望的是 0255 的 uint8。两边需要互转:
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 画文字:
def add_watermark(self, image, text, opacity, font_size, position):
pil_img = self.tensor_to_pil(image)
img_w, img_h = pil_img.size
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:
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))
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",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"C:\\Windows\\Fonts\\msgothic.ttc",
]
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:
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" 里,输入端口就变成可选的了:
"optional": {
"mask": ("MASK",),
},
多输出
返回元组就行:
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、嵌入元数据、多图拼接成网格,思路都一样。
工欲善其事,不如自己造轮子。