文章

FIELD NOTE

ComfyUI 工具箱下篇:OpenAI 兼容 API、Web 界面与局部重绘排障

把本机 ComfyUI 包装成 OpenAI Images API,工具箱从桌面搬进浏览器,并修掉一个让局部重绘几乎失效的关键缺陷:涂抹区差异 3.71 到 113.70 的完整排障过程。

13 分钟读完技术AI

字号

文章字号:当前标准档

上篇记到四类工作流全部跑通、桌面工具箱能出图为止。这篇是下篇,覆盖之后的事:把 ComfyUI 包装成任何 Agent 框架都能调的 OpenAI 兼容接口,给工具箱换上浏览器界面,顺带挖出并修掉了一个会让局部重绘几乎失效的关键缺陷。照旧,所有结论都在那台 RTX 5060 Laptop 8GB 上实测。

这一阶段结束时,工具箱从「双击图标出图的桌面程序」变成了「一个端口、三套入口」:浏览器界面、OpenAI API、已退役但可救回的桌面版。中间最有价值的部分是局部重绘的排障过程,放在中间章节细说。

OpenAI 兼容 API:让程序把本机当成普通图像模型

目标是任何会说 OpenAI 协议的客户端——openai-python、LangChain、Dify 这类——都能把本机 ComfyUI 当成一个普通图像模型用,不需要了解节点、显存和采样器。

实现守着三条原则:

原则做法为什么
零新增依赖只用 ComfyUI venv 里已有的 aiohttp / Pillow不 pip install,绝不碰刚升级好的环境
单一事实来源直接 import comfy_core.build_workflowWeb、API、旧 GUI 共用一套工作流构造
默认不改写提示词prompt 原样生效,要改写需显式开启符合 OpenAI 语义,接口不偷偷加工输入

对外只有三个核心端点:/v1/images/generations(文生图)、/v1/images/edits(改图、合成、去背景、局部重绘)、/v1/models(模型列表)。一共暴露 20 个模型名,其中大半是兼容别名:

模型名实际行为
qwen-image-2.1 / -t2i文生图
qwen-image-2.1-edit改图 / 多图合成
qwen-image-2.1-inpaint别名:等价于 -edit 加 mask 参数
qwen-image-2.1-remove-bg / -bg-remove别名:等价于 -edit 加固定指令,输出 RGBA
dall-e-3 / dall-e-2 / gpt-image-1兼容旧代码的别名,全部指向文生图

老代码里写 dall-e-3 也能直接换成这套后端,不用改一行。

用官方 openai SDK 调用本地 /v1/images/generations 生成的测试图:木纹桌面上的红苹果
图 1:openai_client_demo.py 七个场景之一(文生图)的输出。客户端只用了标准 OpenAI SDK,不知道背后是 ComfyUI。

size 参数的真实语义

这是实测里最有用的一个发现:size 在文生图和改图里的语义完全不同。

场景请求实际输出结论
文生图1000x7001008×704先做 16 像素对齐,静默取整
文生图128x128HTTP 400越界直接报错,不钳制
改图(1:1 输入)1536x1024(3:2)1248×1248比例仍是 1:1,请求的 3:2 被忽略
改图(3:2 输入)1024x1024(1:1)1248×832比例仍是 3:2,请求的 1:1 被忽略
改图(多参考图)auto取第 1 张的比例3:2 在前 → 896×608

也就是说:改图的输出比例由输入图硬性决定,size 只决定像素面积。想改比例,先改输入图。这个行为和 OpenAI 官方语义并不完全一致,但底层模型就是按输入分辨率工作的,硬掰只会更慢。

其他参数走常规映射:quality 映射到采样步数(low=15、standard=25、hd=40),n 收敛到 1~4 并在返回的 _meta.warnings 里说明,steps 和 seed 作为扩展字段透传。

并发:排队而不是失败

8GB 显存同时只能容纳一个任务,所以并发请求用信号量排队。实测两个请求同时发出,墙钟耗时约等于两者执行时间之和,没有重叠。有个容易写错的断言细节:客户端耗时里含排队等待,两个 client 时间相加会重复计数——我第一版测试就栽在这里。

实现过程中修掉的坑

挑几个有代表性的:

问题表现与修法
aiohttp 默认请求体上限 1 MiB一张 2.26 MB 的照片直接 413。真实照片普遍超过 1 MiB,放宽到 64 MiB
鉴权漏了 /v1/models只在生成端点加了检查,模型列表无密钥可访问。改成中间件统一拦截
无 alpha 通道的 mask 静默失效ComfyUI 的 LoadImage 在无 alpha 时返回全零 mask,接口照常 200 但什么都不改。入口处显式校验
测试自己污染了测试环境测试上传的中间文件落进参考图目录被当成基准图,断言失真。改为测试自建基准数据

Web 工具箱:界面不重复实现任何出图逻辑

浏览器界面和 API 是同一个进程、同一个端口(默认 8010),双击 start_api.bat 自动打开浏览器。

Web 工具箱深色主题下的局部重绘界面:五个步骤卡片,从选任务、传图、涂抹到描述改动和质量选项
图 2:Web 工具箱的局部重绘任务(深色主题)。五个步骤卡片自上而下:选任务、选图、涂抹、描述改动、质量与选项;常用指令一键填入,进度条由 SSE 实时驱动。

设计上有一条很省心的原则:界面调的是本服务自己的 OpenAI 接口。所以界面能出的图 = API 能出的图,不存在两套行为,界面每次使用顺带也在验证 API 本身。浏览器需要的额外通道只有两条:页面本身,和一条 SSE 通道把 ComfyUI 的实时进度转发给界面。

界面上新做的能力:

能力说明
拖拽上传图片直接拖进页面
真实进度条经 SSE 转发 ComfyUI 的逐步进度,不是假装转圈
局部重绘鼠标直接在图上涂抹,含橡皮 / 撤销 / 羽化 / 重绘强度
比例 / 清晰度 / 轮次文生图三项独立可调,实时显示换算后的真实尺寸
深色模式默认跟随系统,选择记忆在本地
局域网可用设 COMFY_API_HOST=0.0.0.0 后手机、平板连同一网络即可用

SSE 通道在后端没起来时也不会放弃:持续发心跳并每 2 秒重试,后端一就绪进度立刻接上。实测「先开页面、后启动后端」的场景:触发一次出图后后端被自动拉起(端到端 36.6 秒),SSE 完整收到 1/8 到 8/8 八条进度事件。没有这个重试逻辑,这种启动顺序就永远收不到进度。

涂抹交互的一个前端坑

局部重绘的视觉反馈用半透明荧光笔:底图始终看得清,不挡正在涂的细节。但有个反直觉的地方——显示时的半透明和导出的 mask 必须是两回事。mask 导出靠 destination-out 消耗笔迹的 alpha,如果笔迹本身只有 0.85 的不透明度,导出的 mask 就是半透明的,服务端只会做部分重绘,涂抹处几乎纹丝不动。所以半透明只在显示时用 globalAlpha 合成,内部画布上的笔迹必须完全不透明。

局部重绘涂抹预览:左边是原图红苹果,右边苹果中央被半透明荧光笔涂了一个圆,右上角还有一个淡黄色涂抹块
图 3:荧光笔涂抹的视觉反馈(左=原图,右=涂抹后)。半透明只存在于显示层——底图始终看得清,不挡正在涂的细节。

文生图拆成三个独立控件

以前是尺寸和步数绑死的「质量档位」,现在拆成比例、清晰度、轮次三项。清晰度按长边像素给(768 到 2048),短边由比例推出,换比例时清晰度不用重调,界面实时显示算出的尺寸(如「实际 1024 × 576,已按 16 对齐」)。这些换算做过端到端抽验:前端算出的宽高原样到达服务端,输出一致。

深色模式不是把背景涂黑

纯黑配纯白反而更刺眼。深色主题的取值:

项取值说明
底色#16181b不是纯黑
正文#d7dbe0不是纯白,减少眩光
强调色#6ba1ff浅色主题的 #2563eb 在深底上偏暗

两套主题的对比度都按 WCAG AA 验过,六组文字组合全部高于 4.5:1 门槛(正文对底色浅色 14.6:1、深色 12.8:1)。顺带修了一个先前就存在的问题:浅色主题的警告文字原本只有 4.36:1,低于门槛,调深一档解决。

文生图界面的浅色与深色主题对比:同样的比例、清晰度、轮次控件,深色底色为深灰而非纯黑
图 4:文生图界面的浅色(左)与深色(右)主题,控件布局完全一致,只有配色令牌不同。

局部重绘的三个关键发现

这是本阶段最重要的部分。

局部重绘效果:左边是木纹桌面上的红苹果原图,右边背景被换成蓝天大海,竖直分界线是遮罩边界,苹果本身保持不变
图 5:局部重绘前后。只有涂抹区域被重绘,其余区域保持原样——这条纪律怎么验证的,见本节末尾。

第一:必须去掉「原图参考条件」

改图流程会把原图作为参考图接进 TextEncodeQwenImage21,这对整图改图是对的。但局部重绘时,它会让模型把原主体一直画回来——你涂了半天那块区域,模型坚持「这里是个苹果」。

同一张图、同一种子、同一个涂抹区、同一句提示词,只改这一个条件:

编码器是否带原图参考涂抹区差异涂抹区外差异
带(原本的行为)3.71 / 2550.46 / 255
不带(现在的行为)113.70 / 2550.47 / 255

33 倍差距,而涂抹区外一样保持原样。所以带 mask 时会传 no_ref_cond=True:原图仍走 LoadImage 和 VAEEncode,但不接进文本编码器。画面上下文并不缺——未遮罩区每一步都被钉在原始 latent 上。

参考条件消融对比,三格从左到右:原图红苹果;带原图参考条件时涂抹区毫无变化;去掉参考条件后苹果中央被重绘成蓝天色块
图 6:同一张图、同一种子、同一个涂抹区、同一句提示词,只改「编码器是否带原图参考」。中格涂抹区差异 3.71/255,右格 113.70/255,而涂抹区外两者都不变。

这个问题差点被我漏掉。最初用「中央小圆」做测试,只看到「几乎没变」,差点当成「小面积遮罩本来就不行」。把同尺寸的圆挪到背景上一试,差异立刻变成 31.8——说明不是面积问题,而是「涂在主体上」才失效,这才逼出真正的变量。之前也排除过两个猜想:换 4 种提示词写法没用(差异 3.7~5.7),cfg 从 1.0 提到 3.0 也没用(3.50 / 3.59 / 3.72)。

第二:mask_denoise 有个很陡的阈值

mask_denoise20 步60 步100 步实际变化
0.52.17——几乎不动
0.852.352.132.06几乎不动
0.9—8.41—只改一点
1.0(默认)—96.95—完整重绘

0.85 以下那 2.1 的差异值就是 VAE 往返噪声底,等于什么都没改。更重要的是加步数救不回来——20、60、100 步都试过,不是步数预算问题。这个阈值比直觉陡得多,参数必须贴着 1.0 用。

mask_denoise 五档对比,从左到右为 0.5、0.7、0.85、0.95、1.0:前三档画面完全没变,0.95 只重绘了一半,1.0 完整生成
图 7:同一涂抹区、同一提示词、20 步,mask_denoise 从 0.5 扫到 1.0。前三档肉眼完全无法区分,0.95 只转了一半,1.0 才完整生成。

第三:边缘羽化和遮罩外回贴

矩形硬边 mask 会在边界留下一条直缝。加羽化后,边界处的单列梯度峰值从 7.621.6 降到 4.9,峰值位置也从紧贴边界移开 21 列,视觉上就是软过渡。真实使用建议羽化 1224。

遮罩边缘羽化对比,从左到右为 mask_feather 0、12、24:无羽化时边界有硬缝,羽化后过渡自然
图 8:边界附近的放大对比,mask_feather = 0 / 12 / 24。硬缝的梯度峰值正好贴在边界上,羽化后峰值下降且离开边界。

真 inpainting 已经把未遮罩区钉在原始 latent 上,但经过 VAE 编解码一个来回,像素级仍有细微偏差。所以默认再做一次「遮罩外回贴原图」,遮罩外差异从 2.25 降到 0.000。

光验证「遮罩外没变」是不够的——回贴自己就能做到零差异。真正的判据是:不回贴的前提下遮罩外仍然不变(2.25 ≈ VAE 往返误差),且把 mask 反转后两侧角色干净地对调(左 107.3 / 右 2.3 变成 左 2.3 / 右 98.9)。这招「反转对照」比找另一张图做对照组稳得多。

改写模型:切到 Agnes 3.0 Flash

上篇对比过的本地 9B 改写器(每张约 6 分钟)在 Web 版里退役了,改写任务交给外部 LLM,做了提供方抽象,用环境变量 PE_PROVIDER 切换:

提供方模型费用改写耗时
agnes(默认)agnes-3.0-flash限时免费中位 1720 秒(3.836.7 秒)
deepseekdeepseek-chat约 0.002 元/次中位 2~4 秒

代价很明确:免费换来约 5 倍延迟和更大波动。出图本身 30 秒不变,端到端从约 34 秒变成约 40 秒,能接受。

网络链路也有讲究。Agnes 的接口直连会 TLS 中断,必须经本地代理,但直连一旦偶发失败退回代理,后续请求就被钉在慢线路上(实测直连 5.5 秒,代理 16~29 秒)。最终策略是「直连永远优先、代理只做兜底」:先试直连,失败才探测常见代理端口,缓存线路给 3 次重试,单次成功率约 85%,重试后约 99.7%。这个「可用线路缓存后优先使用」的反模式值得记一笔——缓存的是线路,钉死的却是延迟。

看图改写:把参考图一并交给模型

改图任务里用户往往只说「把背景换成深红色渐变」。改写器看不到画面,只能猜。Agnes 支持图像输入,于是改图时把参考图一起送进去。同一句话、同一张图(浅灰背景上的红苹果):

改写时改写结果
不看图「保持前景中所有人物、物体、文字和装饰元素……完全不变」——在猜
看图「位于苹果后方的浅灰白纯色背景……保持顶部绿色叶片与棕色果梗细节完全不变」——看见了

局部重绘时更进一步:发给改写模型的是底图加荧光笔标记的渲染图,并附一句说明「半透明黄色高亮 = 要改的区域」。模型连荧光笔的颜色都看出来了,改写结果精确到「苹果正中央、绿色叶片与棕色果柄正下方的那个浅黄色圆形色块」。

实现细节:传图用 image_url 加 data URI(官方文档只写了公开 URL,实测 data URI 可用);图片先压缩到 768 长边转 JPEG,同一张图 1352 KB 缩到 58 KB,图像 token 从 1024 降到 576;最多附 3 张;提供方不支持视觉时(如 DeepSeek)自动退回纯文本,不报错。

至于改写质量,同一种子同 25 步对比过 Agnes 和 DeepSeek:两者都准确还原了「雪山 + 雪地 + 阳光 + 红衣女孩」,Agnes 的成片饱和度更高、构图更宽,DeepSeek 加了针织帽、色调更柔。单样本不能断言谁更好,只能说免费的 Agnes 不拖后腿。

同一句提示词、同一种子下 Agnes 与 DeepSeek 改写器的出图对比,两幅红衣女孩雪山图在饱和度、构图和服装细节上不同
图 9:同种子、同 25 步、同一句中文,只有改写器不同。Agnes 的成片饱和度更高、构图更宽;DeepSeek 给女孩加了针织帽、色调更柔。

任务合并:4 个板块收敛到 3 个

实测发现两个「独立任务」其实是同一件事:

  • 去背景和改图:两者产出的工作流 JSON 逐字节相同(只差输出文件名前缀),输出像素完全一致。去背景降级为「改图 + 一句固定指令」的兼容别名。
  • 文字海报和文生图:走的是同一个代码分支,只是默认提示词强调「要出现的文字」。海报降级为文生图里的一键预置指令。

能力没有丢:界面在改图里提供一键指令(去背景 / 换背景 / 改画面文字),API 保留模型名别名,老代码不用改。最终只剩三个板块:改图/合成、局部重绘、文字生成图片。

去背景别名的输出:红苹果主体保留,背景为透明通道,在查看器中以紫色显示
图 10:qwen-image-2.1-remove-bg 别名的输出,RGBA 透明底(透明区在查看器里显示为垫底色)。该别名等价于改图加固定指令——实测工作流 JSON 逐字节相同。

这里的等价性判断是有纪律的:先确认两者工作流逐字节相同,再比对输出像素,最后才敢合并。判断「像素一致」而不是「字节一致」——PNG 里嵌的工作流元数据含输出文件名,那是无关紧要的差异,不该让断言变脆。

桌面 GUI 退役:拆出 comfy_core.py

桌面版(Tkinter)在 Web 版面前退役,但它不能直接删——API 依赖里面的工作流构造和后端管理。所以先拆分:

文件内容
comfy_core.py(新)工作流构造 + 任务定义 + 质量档位 + 后端管理,全入口共用的唯一事实来源
legacy/comfy_toolbox.py只剩界面代码,从 core 导入

拆分后验证过 core 不再拖入 tkinter(import 之后 sys.modules 里没有它),4 个任务的工作流结构正确。桌面版唯一不可替代的能力是「本地 9B 改写器」档位——完全离线,约 6 分钟一张。其余功能 Web 版都覆盖且更好,不值得维护两个界面。

测试怎么兜底

这套东西改起来很凶,全靠测试兜住:

套件覆盖内容结果
test_openai_api.py --full契约 / 出图 / 尺寸映射 / 真 inpainting / 看图改写(69 项子检查)72 / 72
test_openai_api.py --token鉴权(含免鉴权路径)19 / 19
test_toolbox.py核心工作流结构 + 真实提交给 ComfyUIsuccess
openai_client_demo.py官方 openai SDK 七个场景(含局部重绘)7 / 7
对比度检查两套主题 × 六组文字组合全部 ≥ 4.5:1

测试设计上有三条经验:

  1. 判据用像素一致而不是字节一致,无关紧要的元数据差异不该让等价性断言变脆。
  2. 对照实验要自控制。验证 mask 是否真在模型层起作用,用「反转 mask 看两侧角色是否对调」,比「不传 mask 做对照」稳得多——后者依赖基准图内容,换一张图结论就翻转。
  3. 等价性要能证伪。「去背景等于改图加同一句指令」这个结论,是先证工作流逐字节相同、再证输出像素一致之后才下的。

最后:交付形态

入口启动方式适合
Web 工具箱(推荐)双击 start_api.bat日常出图,拖拽上传、真实进度条
OpenAI 兼容 API与 Web 界面同一服务让程序 / Agent 框架调用本机出图
桌面图形界面已退役,legacy 目录可跑需要完全离线的本地 9B 改写器档位

到这里,这套东西算稳定下来了:8GB 显存的笔记本跑 Qwen-Image 2.1,上篇解决了「能不能跑」,下篇解决了「怎么被人和程序舒服地用」。下一个值得试的方向,是让 Agent 拿着这套 API 自己出图。