373 lines
11 KiB
Markdown
373 lines
11 KiB
Markdown
# InpaintRegionEditor 开发日志
|
||
|
||
## 2026-03-03 选区交互完善
|
||
|
||
### 完成的工作
|
||
|
||
1. **选区调整大小**
|
||
- 支持拖动边缘/角落调整选区大小
|
||
- 边缘检测阈值优化(不超过选区 15%)
|
||
- 约束:选区必须 >= 蒙版边界框
|
||
|
||
2. **光标变化**
|
||
- 中间区域:`grab`(移动)
|
||
- 拖动时:`grabbing`
|
||
- 边缘/角落:调整大小箭头
|
||
- 使用 `requestAnimationFrame` 延迟设置,确保在原生代码之后执行
|
||
|
||
3. **UI 优化**
|
||
- 标签改为"参考区",字号缩小
|
||
- 像素数显示整数
|
||
- 图像左下角显示原图尺寸
|
||
|
||
4. **Ctrl+V 粘贴支持**
|
||
- 全局粘贴事件监听(捕获阶段)
|
||
- 只处理选中的 InpaintRegionEditor 节点
|
||
- 阻止系统创建新 LoadImage 节点
|
||
|
||
### 遇到的问题
|
||
|
||
1. **光标设置被覆盖**:原生 `onMouseMove` 在我们之后执行,覆盖光标
|
||
- 解决:使用 `requestAnimationFrame` 延迟设置
|
||
|
||
2. **系统菜单 "Open in MaskEditor | Image Canvas" 出现**:设置 `previewMediaType = 'image'` 后自动添加
|
||
- 解决:不设置 `previewMediaType`,改用全局粘贴监听
|
||
|
||
3. **粘贴时创建新节点**:系统也处理了粘贴事件
|
||
- 解决:捕获阶段 + `stopImmediatePropagation()`
|
||
|
||
### 代码结构
|
||
|
||
```
|
||
extension.js (820 行):
|
||
├── PhotopeaBridge - Photopea iframe 通信
|
||
├── showPhotopeaModal() - Photopea 弹窗
|
||
├── loadImageAndDetectMask() - 加载图像并检测蒙版
|
||
├── constrainRegion() - 选区约束(框住蒙版 + 不超边界)
|
||
├── drawNode() - 绘制图像、尺寸标签、参考区框
|
||
├── getResizeHandle() - 检测鼠标位置(中间/边缘/角落)
|
||
├── onMouseDown/Move/Up() - 拖动和调整大小交互
|
||
└── init() - 全局粘贴事件监听
|
||
```
|
||
|
||
---
|
||
|
||
## 2026-03-03 选区约束 + MaskEditor 集成
|
||
|
||
### 完成的工作
|
||
|
||
1. **选区约束功能**
|
||
- 选区必须框住蒙版区域
|
||
- 选区不能超出图像边界
|
||
- padding 改变时自动调整选区位置和大小
|
||
- 统一 `constrainRegion()` 函数处理所有约束逻辑
|
||
|
||
2. **MaskEditor 集成**
|
||
- 右键菜单添加 "Open in MaskEditor" 选项
|
||
- 使用 `app.extensionManager.command.execute("Comfy.MaskEditor.OpenMaskEditor")` 调用系统命令
|
||
- 解决 MaskEditor 返回后双图像问题(清除 `node.imgs`)
|
||
- 解决 blob URL 不被 MaskEditor 接受的问题(使用原始 ComfyUI URL)
|
||
|
||
3. **UI 优化**
|
||
- 移除红色蒙版区域显示(真实蒙版已在图像上显示)
|
||
- 只保留橙色选区框
|
||
|
||
### 遇到的问题
|
||
|
||
1. **变量名冲突**:`beforeRegisterNodeDef(nodeType, nodeData)` 的参数 `nodeData` 覆盖了全局的 `nodeData` Map
|
||
- 解决:全局变量改名为 `nodeImageData`
|
||
|
||
2. **`app.commands` undefined**:新版本 ComfyUI 命令系统变了
|
||
- 解决:使用 `app.extensionManager.command.execute()`
|
||
|
||
3. **MaskEditor 不接受 blob URL**:
|
||
- 解决:保存原始 ComfyUI URL (`/view?filename=...&type=...`),点击菜单时用这个 URL 创建图像
|
||
|
||
4. **MaskEditor 返回后双图像**:
|
||
- 解决:在 `onDrawBackground` 中检测并清除 `node.imgs`
|
||
|
||
### 关键代码
|
||
|
||
```javascript
|
||
// 统一的选区约束函数
|
||
function constrainRegion(data, padding) {
|
||
// 1. 计算期望大小
|
||
// 2. 约束不超过图像大小
|
||
// 3. 约束不超出边界
|
||
// 4. 优先框住蒙版
|
||
}
|
||
|
||
// 调用 MaskEditor
|
||
app.extensionManager.command.execute("Comfy.MaskEditor.OpenMaskEditor");
|
||
```
|
||
|
||
### 待解决问题
|
||
|
||
1. 选区大小调整(目前只能拖动位置,不能调整大小)
|
||
2. 多选区支持
|
||
|
||
---
|
||
|
||
## 2026-03-02 重大进展
|
||
|
||
### 完成的工作
|
||
|
||
1. **完全重写前端图像渲染**
|
||
- 不再依赖 ComfyUI 的 `image_upload` 和 `node.imgs`
|
||
- 自己加载图像、自己绘制
|
||
- 去掉了 `nodes.py` 中的 `image_upload: True`
|
||
|
||
2. **选区框绘制正常工作**
|
||
- 橙色选区框 + 红色遮罩区域
|
||
- 标签显示尺寸
|
||
|
||
3. **拖动功能实现**
|
||
- 关键发现:`onMouseDown(e, pos, canvas)` 的 `pos` 参数直接就是节点本地坐标
|
||
- 不需要手动转换 `e.canvasX`
|
||
- 返回 `true` 可以阻止节点被拖动
|
||
|
||
### 遇到的问题
|
||
|
||
1. **双图像问题**:ComfyUI 自动画了一个图,我又画了一个 → 去掉 `image_upload: True`
|
||
|
||
2. **黑边问题**:用黑色覆盖图像区域导致边框难看 → 直接画图像,不覆盖
|
||
|
||
3. **拖动不工作**:鼠标事件参数格式错误
|
||
- 错误:`onMouseDown(e)` 以为 `e.canvasX` 是画布坐标
|
||
- 正确:`onMouseDown(e, pos, canvas)`,`pos[0], pos[1]` 是节点本地坐标
|
||
|
||
### 待解决问题
|
||
|
||
1. 拖动体验优化(边界检测、视觉反馈)
|
||
2. 缩放/放大图像查看
|
||
3. 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 输出变成乱码
|
||
|
||
**根因分析**:
|
||
|
||
1. LoadImage 节点处理流程(nodes.py:1732):
|
||
```python
|
||
image = i.convert("RGB") # 丢弃 Alpha,返回纯 RGB
|
||
```
|
||
→ 这时候的 IMAGE 是**纯 RGB**,没有透明!
|
||
|
||
2. 但 `/view` API(server.py:528-546)默认行为:
|
||
```python
|
||
if 'channel' not in request.rel_url.query:
|
||
channel = 'rgba' # 默认返回 RGBA!
|
||
```
|
||
→ 即使 LoadImage 输出的是纯 RGB,前端用 `/view?filename=...` 获取时,默认返回的是**原始文件**(可能带 Alpha)
|
||
|
||
3. 解决方案:加 `&channel=rgb` 参数
|
||
```
|
||
/view?filename=xxx&type=input&channel=rgb
|
||
```
|
||
→ 服务器会用 Pillow 处理:
|
||
```python
|
||
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):
|
||
```javascript
|
||
// 错误的做法(默认返回 RGBA):
|
||
var url = "/view?filename=" + encodeURIComponent(filename) + "&type=" + encodeURIComponent(type);
|
||
|
||
// 正确的做法(返回纯 RGB):
|
||
var url = "/view?filename=" + encodeURIComponent(filename) + "&type=" + encodeURIComponent(type) + "&channel=rgb";
|
||
```
|
||
|
||
### 重要教训
|
||
|
||
1. **LoadImage 输出的 IMAGE 确实是纯 RGB**(Pillow convert("RGB") 处理过)
|
||
2. **但是 `/view` API 默认返回原始文件**(可能带 Alpha)
|
||
3. **必须加 `&channel=rgb` 参数** 才能获取纯 RGB 图片
|
||
|
||
---
|
||
|
||
## 2026-02-28 研究结论(重要!)
|
||
|
||
### 核心发现:ComfyUI 蒙版数据格式
|
||
|
||
## 2026-02-28 研究结论(重要!)
|
||
|
||
### 核心发现:ComfyUI 蒙版数据格式
|
||
|
||
通过分析 ComfyUI 源码,彻底理解了蒙版格式:
|
||
|
||
#### IMAGE 类型
|
||
```python
|
||
# nodes.py LoadImage.load_image()
|
||
image = i.convert("RGB") # IMAGE 不含 Alpha!
|
||
# Shape: (B, H, W, 3), 值 [0, 1]
|
||
```
|
||
|
||
#### MASK 类型
|
||
```python
|
||
# 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)
|
||
```python
|
||
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)
|
||
```python
|
||
# 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()
|
||
```javascript
|
||
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 访问
|
||
|
||
```javascript
|
||
// 永远返回 null
|
||
var mask = layer.mask;
|
||
```
|
||
|
||
#### 但有 Workaround!
|
||
|
||
用户手动测试发现:`doc.activeChannel = layer.mask` 实际上能工作!
|
||
|
||
```javascript
|
||
// 虽然 layer.mask 返回 null,但这行代码实际能工作
|
||
doc.activeChannel = layer.mask;
|
||
```
|
||
|
||
#### 正确方案
|
||
|
||
用 try-catch 包裹,直接尝试导出:
|
||
|
||
```javascript
|
||
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 切换导出带蒙版的图层:
|
||
|
||
```javascript
|
||
// 隐藏所有图层,只显示当前层(蒙版会自动应用)
|
||
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");
|
||
```
|