11 KiB
InpaintRegionEditor 开发日志
2026-03-03 选区交互完善
完成的工作
-
选区调整大小
- 支持拖动边缘/角落调整选区大小
- 边缘检测阈值优化(不超过选区 15%)
- 约束:选区必须 >= 蒙版边界框
-
光标变化
- 中间区域:
grab(移动) - 拖动时:
grabbing - 边缘/角落:调整大小箭头
- 使用
requestAnimationFrame延迟设置,确保在原生代码之后执行
- 中间区域:
-
UI 优化
- 标签改为"参考区",字号缩小
- 像素数显示整数
- 图像左下角显示原图尺寸
-
Ctrl+V 粘贴支持
- 全局粘贴事件监听(捕获阶段)
- 只处理选中的 InpaintRegionEditor 节点
- 阻止系统创建新 LoadImage 节点
遇到的问题
-
光标设置被覆盖:原生
onMouseMove在我们之后执行,覆盖光标- 解决:使用
requestAnimationFrame延迟设置
- 解决:使用
-
系统菜单 "Open in MaskEditor | Image Canvas" 出现:设置
previewMediaType = 'image'后自动添加- 解决:不设置
previewMediaType,改用全局粘贴监听
- 解决:不设置
-
粘贴时创建新节点:系统也处理了粘贴事件
- 解决:捕获阶段 +
stopImmediatePropagation()
- 解决:捕获阶段 +
代码结构
extension.js (820 行):
├── PhotopeaBridge - Photopea iframe 通信
├── showPhotopeaModal() - Photopea 弹窗
├── loadImageAndDetectMask() - 加载图像并检测蒙版
├── constrainRegion() - 选区约束(框住蒙版 + 不超边界)
├── drawNode() - 绘制图像、尺寸标签、参考区框
├── getResizeHandle() - 检测鼠标位置(中间/边缘/角落)
├── onMouseDown/Move/Up() - 拖动和调整大小交互
└── init() - 全局粘贴事件监听
2026-03-03 选区约束 + MaskEditor 集成
完成的工作
-
选区约束功能
- 选区必须框住蒙版区域
- 选区不能超出图像边界
- padding 改变时自动调整选区位置和大小
- 统一
constrainRegion()函数处理所有约束逻辑
-
MaskEditor 集成
- 右键菜单添加 "Open in MaskEditor" 选项
- 使用
app.extensionManager.command.execute("Comfy.MaskEditor.OpenMaskEditor")调用系统命令 - 解决 MaskEditor 返回后双图像问题(清除
node.imgs) - 解决 blob URL 不被 MaskEditor 接受的问题(使用原始 ComfyUI URL)
-
UI 优化
- 移除红色蒙版区域显示(真实蒙版已在图像上显示)
- 只保留橙色选区框
遇到的问题
-
变量名冲突:
beforeRegisterNodeDef(nodeType, nodeData)的参数nodeData覆盖了全局的nodeDataMap- 解决:全局变量改名为
nodeImageData
- 解决:全局变量改名为
-
app.commandsundefined:新版本 ComfyUI 命令系统变了- 解决:使用
app.extensionManager.command.execute()
- 解决:使用
-
MaskEditor 不接受 blob URL:
- 解决:保存原始 ComfyUI URL (
/view?filename=...&type=...),点击菜单时用这个 URL 创建图像
- 解决:保存原始 ComfyUI URL (
-
MaskEditor 返回后双图像:
- 解决:在
onDrawBackground中检测并清除node.imgs
- 解决:在
关键代码
// 统一的选区约束函数
function constrainRegion(data, padding) {
// 1. 计算期望大小
// 2. 约束不超过图像大小
// 3. 约束不超出边界
// 4. 优先框住蒙版
}
// 调用 MaskEditor
app.extensionManager.command.execute("Comfy.MaskEditor.OpenMaskEditor");
待解决问题
- 选区大小调整(目前只能拖动位置,不能调整大小)
- 多选区支持
2026-03-02 重大进展
完成的工作
-
完全重写前端图像渲染
- 不再依赖 ComfyUI 的
image_upload和node.imgs - 自己加载图像、自己绘制
- 去掉了
nodes.py中的image_upload: True
- 不再依赖 ComfyUI 的
-
选区框绘制正常工作
- 橙色选区框 + 红色遮罩区域
- 标签显示尺寸
-
拖动功能实现
- 关键发现:
onMouseDown(e, pos, canvas)的pos参数直接就是节点本地坐标 - 不需要手动转换
e.canvasX - 返回
true可以阻止节点被拖动
- 关键发现:
遇到的问题
-
双图像问题:ComfyUI 自动画了一个图,我又画了一个 → 去掉
image_upload: True -
黑边问题:用黑色覆盖图像区域导致边框难看 → 直接画图像,不覆盖
-
拖动不工作:鼠标事件参数格式错误
- 错误:
onMouseDown(e)以为e.canvasX是画布坐标 - 正确:
onMouseDown(e, pos, canvas),pos[0], pos[1]是节点本地坐标
- 错误:
待解决问题
- 拖动体验优化(边界检测、视觉反馈)
- 缩放/放大图像查看
- Photopea 编辑后的图像刷新
代码结构
extension.js:
- loadImageAndDetectMask() - 加载图像并检测 Alpha 通道
- drawNode() - 绘制图像和选区框
- getImageDrawParams() - 计算图像绘制参数
- onMouseDown/Move/Up() - 拖动交互
nodes.py:
- INPUT_TYPES 去掉 image_upload
- process() 从 Alpha 提取 MASK
2026-02-28 研究结论(重要!续)
问题:发送给 Photopea 的图片带透明通道,导致遮罩混乱
现象:
- 发送给 Photopea 的图片有透明区域
- Photopea 保存后,LoadImage 节点的 MASK 输出变成乱码
根因分析:
-
LoadImage 节点处理流程(nodes.py:1732):
image = i.convert("RGB") # 丢弃 Alpha,返回纯 RGB→ 这时候的 IMAGE 是纯 RGB,没有透明!
-
但
/viewAPI(server.py:528-546)默认行为:if 'channel' not in request.rel_url.query: channel = 'rgba' # 默认返回 RGBA!→ 即使 LoadImage 输出的是纯 RGB,前端用
/view?filename=...获取时,默认返回的是原始文件(可能带 Alpha) -
解决方案:加
&channel=rgb参数/view?filename=xxx&type=input&channel=rgb→ 服务器会用 Pillow 处理:
if channel == 'rgb': with Image.open(file) as img: if img.mode == "RGBA": r, g, b, a = img.split() new_img = Image.merge('RGB', (r, g, b)) # 丢弃 Alpha else: new_img = img.convert("RGB") new_img.save(buffer, format='PNG')
最终代码修改(extension.js):
// 错误的做法(默认返回 RGBA):
var url = "/view?filename=" + encodeURIComponent(filename) + "&type=" + encodeURIComponent(type);
// 正确的做法(返回纯 RGB):
var url = "/view?filename=" + encodeURIComponent(filename) + "&type=" + encodeURIComponent(type) + "&channel=rgb";
重要教训
- LoadImage 输出的 IMAGE 确实是纯 RGB(Pillow convert("RGB") 处理过)
- 但是
/viewAPI 默认返回原始文件(可能带 Alpha) - 必须加
&channel=rgb参数 才能获取纯 RGB 图片
2026-02-28 研究结论(重要!)
核心发现:ComfyUI 蒙版数据格式
2026-02-28 研究结论(重要!)
核心发现:ComfyUI 蒙版数据格式
通过分析 ComfyUI 源码,彻底理解了蒙版格式:
IMAGE 类型
# nodes.py LoadImage.load_image()
image = i.convert("RGB") # IMAGE 不含 Alpha!
# Shape: (B, H, W, 3), 值 [0, 1]
MASK 类型
# nodes.py:1743-1745
if 'A' in i.getbands():
mask = np.array(i.getchannel('A')).astype(np.float32) / 255.0
mask = 1. - torch.from_numpy(mask) # 反转!
# Shape: (B, H, W), 值 [0, 1]
# 1 = 遮罩区域, 0 = 背景
PNG Alpha → MASK 转换
PNG Alpha = 0 (透明) → mask = 1.0 (遮罩区域)
PNG Alpha = 255 (不透明) → mask = 0.0 (背景)
/upload/mask API (server.py:462-474)
original_pil = original_pil.convert('RGBA')
mask_pil = Image.open(image.file).convert('RGBA')
new_alpha = mask_pil.getchannel('A') # 取上传图像的 Alpha
original_pil.putalpha(new_alpha) # 替换原图 Alpha
MaskToImage / ImageToMask 源码 (comfy_extras/nodes_mask.py)
# MaskToImage: MASK → IMAGE
result = mask.reshape((-1, 1, H, W)).movedim(1, -1).expand(-1, -1, -1, 3)
# ImageToMask: IMAGE → MASK
mask = image[:, :, :, channels.index(channel)]
Photopea 关键发现
maskFromTransparency() 有 BUG - 会丢失透明区域的 RGB 数据
正确方法:loadTransparentPixels() + addMask()
doc.selection.loadTransparentPixels(); // 选择非透明区域
layer.addMask(true); // 创建图层蒙版
Photopea 蒙版语义 vs ComfyUI
| 系统 | 白色 | 黑色 |
|---|---|---|
| Photopea 蒙版 | 显示(非遮罩) | 隐藏(遮罩) |
| ComfyUI Mask | 遮罩区域 | 背景 |
完整数据流
传入图像 → 直接打开PNG → loadTransparentPixels + addMask → 图像完整 + 蒙版在图层右侧
导出蒙版 → 复制蒙版数据 → 反转(蒙版黑色=遮罩 → Alpha=0)→ PNG(RGB=0, Alpha)
上传蒙版 → POST /upload/mask → 服务端用蒙版Alpha替换原图Alpha
代码位置
- ComfyUI 核心:
E:\ComfyUI\nodes.py,E:\ComfyUI\server.py - 蒙版节点:
E:\ComfyUI\comfy_extras\nodes_mask.py - 参考实现:
E:\ComfyUI\custom_nodes\ComfyUI-Impact-Pack\js\impact-sam-editor.js
2026-02-27
已完成
- 基础架构:Python 后端 + JS 前端
- Photopea iframe 集成
- 右键菜单 → Open in Photopea
技术参考
- Photopea Masks: https://www.photopea.com/learn/masks
Photopea Layer Mask API (2026-02-28 更新)
关键发现:layer.mask 返回 null - 已知限制
Issue #7341 (2024年11月) 确认:layer masks 无法通过脚本 API 访问
// 永远返回 null
var mask = layer.mask;
但有 Workaround!
用户手动测试发现:doc.activeChannel = layer.mask 实际上能工作!
// 虽然 layer.mask 返回 null,但这行代码实际能工作
doc.activeChannel = layer.mask;
正确方案
用 try-catch 包裹,直接尝试导出:
var exportScript =
'(function(){' +
' try {' +
' var doc = app.activeDocument;' +
' var layer = doc.activeLayer;' +
' var origChannel = doc.activeChannel;' +
' ' +
' doc.activeChannel = layer.mask;' +
' doc.selection.selectAll();' +
' doc.selection.copy();' +
' var newDoc = app.documents.add(doc.width, doc.height);' +
' app.activeDocument = newDoc;' +
' newDoc.paste();' +
' newDoc.saveToOE("png");' +
' newDoc.close(SaveOptions.DONOTSAVECHANGES);' +
' ' +
' app.activeDocument = doc;' +
' doc.activeChannel = origChannel;' +
' ' +
' app.echoToOE("mask_exported");' +
' } catch(e) {' +
' app.echoToOE("no_mask:" + e.message);' +
' }' +
'})();';
替代方案(如果上面不行)
用 visibility 切换导出带蒙版的图层:
// 隐藏所有图层,只显示当前层(蒙版会自动应用)
for (var i = 0; i < app.activeDocument.layers.length; i++) {
app.activeDocument.layers[i].visible = false;
}
app.activeDocument.activeLayer.visible = true;
// 导出 - 蒙版已经渲染进 PNG
app.activeDocument.saveToOE("png");