├── requirements.txt ├── __init__.py ├── pyproject.toml ├── .github └── workflows │ └── publish.yml ├── LICENSE ├── README.md ├── js ├── CanvasBlendMode.js ├── CanvasUtils.js ├── LassoTool.js └── Canvas_view.js └── canvas_node.py /requirements.txt: -------------------------------------------------------------------------------- 1 | torch 2 | torchvision 3 | transformers 4 | aiohttp 5 | numpy 6 | tqdm 7 | Pillow 8 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | from .canvas_node import CanvasNode 2 | 3 | # 设置路由 4 | CanvasNode.setup_routes() 5 | 6 | NODE_CLASS_MAPPINGS = { 7 | "CanvasNode": CanvasNode 8 | } 9 | 10 | NODE_DISPLAY_NAME_MAPPINGS = { 11 | "CanvasNode": "Canvas Node" 12 | } 13 | 14 | WEB_DIRECTORY = "./js" 15 | 16 | __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] 17 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "comfyui-ycanvas" 3 | description = "" 4 | version = "1.0.0" 5 | license = {file = "LICENSE"} 6 | dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"] 7 | 8 | [project.urls] 9 | Repository = "https://github.com/yichengup/Comfyui-Ycanvas" 10 | # Used by Comfy Registry https://comfyregistry.org 11 | 12 | [tool.comfy] 13 | PublisherId = "" 14 | DisplayName = "Comfyui-Ycanvas" 15 | Icon = "" 16 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to Comfy registry 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | paths: 9 | - "pyproject.toml" 10 | 11 | permissions: 12 | issues: write 13 | 14 | jobs: 15 | publish-node: 16 | name: Publish Custom Node to registry 17 | runs-on: ubuntu-latest 18 | if: ${{ github.repository_owner == 'yichengup' }} 19 | steps: 20 | - name: Check out code 21 | uses: actions/checkout@v4 22 | - name: Publish Custom Node 23 | uses: Comfy-Org/publish-node-action@v1 24 | with: 25 | personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 tanglup 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ComfyUI-Ycanvas 插件 2 | 3 | ## 📝 简介 4 | 5 | ### 2025/07/02 更新了画布,在原有的一键自动抠图基础上,增加了套索工具抠图和钢笔路径抠图,总共有三种抠图模式。 6 | ### 钢笔路径模式下,按d可以回退节点,按e进去编辑模式 7 | 8 | 9 | https://github.com/user-attachments/assets/b2c9941e-1252-40fa-a9fa-30f0897a27c3 10 | 11 | 12 | 13 | ComfyUI-Ycanvas 是一个功能强大的 ComfyUI 画布节点插件,提供了完整的图像编辑和合成功能。该插件集成了多种图像处理工具,包括高级套索工具、钢笔路径抠图、AI 抠图、图层管理等,特别适用于图像合成、编辑和工作流处理。 14 | 15 | ## ✨ 主要特性 16 | 17 | ### 🎨 画布功能 18 | - **可调节画布尺寸**:支持 1×1 到 2048x2048 像素的自定义画布尺寸 19 | - **多图层支持**:无限制的图层数量,支持图层重排序和透明度调节 20 | - **高性能渲染**:针对大尺寸画布优化的渲染引擎,支持离屏渲染 21 | 22 | ### 🖱️ 图像操作 23 | - **导入图片**:支持使用按钮添加 24 | - **精确变换**:旋转、缩放、镜像、移动等基础变换操作 25 | - **快捷键支持**: 26 | - 鼠标滚轮:缩放图像 27 | - Shift + 鼠标滚轮:旋转图像 28 | - 自由变形 29 | 30 | ### 🔧 高级套索工具 31 | - **多种模式**: 32 | - 新建:创建新的选区遮罩 33 | - 添加:向现有遮罩添加区域 34 | - 减去:从现有遮罩减去区域 必需有新建后才能用这个功能 35 | - 恢复原图:一键恢复到原始状态 36 | - **性能优化**:针对大尺寸画布的点抽样和渲染优化 37 | - **实时预览**:绘制过程中的绿色虚线预览 38 | - **撤销功能**:保存原始状态,支持快速恢复 39 | 40 | ### 🤖 AI 抠图功能 41 | - **BiRefNet 模型**:集成先进的 AI 抠图算法 42 | - **自动下载**:首次使用时自动下载所需模型 43 | - **高质量结果**:支持细节保留和边缘优化 44 | - **批处理支持**:可处理多个图层 45 | 46 | 47 | ## 🚀 安装说明 48 | 49 | ### 方法:自动安装 50 | 1. 克隆仓库到 ComfyUI 的 custom_nodes 目录: 51 | ```bash 52 | cd ComfyUI/custom_nodes 53 | git clone https://github.com/your-repo/Comfyui-Ycanvas.git 54 | ``` 55 | 56 | 2. 安装依赖: 57 | ```bash 58 | cd Comfyui-Ycanvas 59 | pip install -r requirements.txt 60 | ``` 61 | 62 | 3. 重启 ComfyUI 63 | 64 | 65 | 66 | ### 模型下载 67 | 插件会自动下载所需的 BiRefNet 模型。如果自动下载失败,可手动下载: 68 | 69 | **模型名称**:BiRefNet 70 | **下载链接**: 71 | - 百度网盘:https://pan.baidu.com/s/1PiZvuHcdlcZGoL7WDYnMkA?pwd=nt76 72 | - Google Drive:https://drive.google.com/drive/folders/1BCLInCLH89fmTpYoP8Sgs_Eqww28f_wq 73 | 74 | **安装路径**:`ComfyUI/models/BiRefNet/` 75 | 76 | ## 📖 使用指南 77 | 78 | ### 基础操作 79 | 80 | #### 1. 添加图像 81 | - **Add Image**:点击按钮选择本地图片文件 82 | - **Import Input**:导入从其他节点传递的图像数据 必须执行一次,才能点击导入 83 | - 支持拖拽多个图片同时添加 84 | 85 | #### 2. 画布操作 86 | - **Canvas Size**:调整画布尺寸(建议尺寸:512×512 到 2048×2048) 87 | - **选择图像**:单击图像进行选择 88 | - **取消选择**:双击图像或点击空白区域 89 | 90 | #### 3. 图像变换 91 | - **旋转**:Rotate +90° 按钮或 Shift + 鼠标滚轮 92 | - **缩放**:Scale +5%/-5% 按钮或鼠标滚轮 93 | - **镜像**:Mirror H(水平)/ Mirror V(垂直)按钮 94 | - **移动**:直接拖拽图像 95 | 96 | #### 4. 图层管理 97 | - **Layer Up/Down**:调整图层顺序 98 | - **Remove Layer**:删除选中图层 99 | - 支持多图层叠加和混合 100 | 101 | ### 高级功能 102 | 103 | #### 套索工具使用 104 | 1. 选择要编辑的图层 105 | 2. 点击 **"套索工具"** 激活 106 | 3. 选择模式: 107 | - **新建**:创建全新选区 108 | - **添加**:扩展现有选区 109 | - **减去**:缩小现有选区 110 | - **恢复原图**:撤销所有修改 111 | 4. 在图像上绘制选区路径 112 | 5. 完成绘制后自动应用遮罩 113 | 6. 绘制后,不要移动图层,否则会丢失选区,导致图片遮罩错误合成。 114 | 115 | #### AI 抠图功能 116 | 1. 选择需要抠图的图层 117 | 2. 点击 **"Matting"** 按钮 118 | 3. 等待 AI 处理完成 119 | 4. 系统会创建新的透明背景图层 120 | 121 | 122 | 123 | ## ⚙️ 技术特性 124 | 125 | ### 性能优化 126 | - **大画布支持**:针对 1024×1024 以上画布的专项优化 127 | - **内存管理**:智能缓存机制,减少内存占用 128 | - **渲染优化**:离屏渲染技术,提升绘制性能 129 | - **事件节流**:防止频繁操作导致的性能问题 130 | 131 | ### 兼容性 132 | - **ComfyUI 版本**:支持最新版本的 ComfyUI 133 | - **操作系统**:Windows、macOS、Linux 134 | - **GPU 支持**:NVIDIA CUDA、AMD ROCm 135 | - **数据格式**:支持标准的 IMAGE 和 MASK 张量格式 136 | 137 | ### 扩展性 138 | - **插件架构**:模块化设计,易于扩展 139 | - **API 接口**:提供丰富的 REST API 140 | - **事件系统**:支持自定义事件处理 141 | - **配置选项**:灵活的参数配置 142 | 143 | ## 🔧 配置选项 144 | 145 | ### 画布设置 146 | - `canvas_image`:输出文件名 147 | - `trigger`:触发器参数(用于工作流同步) 148 | - `output_switch`:输出开关 149 | - `cache_enabled`:缓存启用开关 150 | 151 | ### 输入接口 152 | - `input_image`:可选的输入图像 153 | - `input_mask`:可选的输入遮罩 154 | 155 | ### 输出接口 156 | - `image`:处理后的图像张量 157 | - `mask`:生成的遮罩张量 158 | 159 | ## 🐛 故障排除 160 | 161 | ### 常见问题 162 | 163 | **Q: 套索工具绘制时出现虚线消失** 164 | A: 这通常发生在大尺寸画布上。请确保: 165 | - 绘制速度不要过快 166 | - 避免快速移动鼠标 167 | - 使用较小的画布尺寸进行测试 168 | 169 | **Q: AI 抠图功能无法使用** 170 | A: 请检查: 171 | - 模型是否正确下载到 `models/BiRefNet/` 目录 172 | - 是否有足够的 GPU 内存 173 | - 网络连接是否正常 174 | 175 | **Q: 画布显示异常或消失** 176 | A: 尝试: 177 | - 重新调整节点大小 178 | - 刷新浏览器页面 179 | - 检查浏览器控制台错误信息 180 | 181 | ### 性能建议 182 | 1. **推荐画布尺寸**:512×512 到 1024×1024 183 | 2. **图层数量**:建议不超过 20 个图层 184 | 3. **内存使用**:大画布时建议关闭不必要的功能 185 | 4. **浏览器优化**:使用 Chrome 或 Edge 浏览器以获得最佳性能 186 | 187 | ## 📄 许可证 188 | 189 | 本项目采用 MIT 许可证。详见 [LICENSE](LICENSE) 文件。 190 | 191 | ## 🙏 致谢 192 | 193 | - [ComfyUI](https://github.com/comfyanonymous/ComfyUI) - 强大的 AI 图像生成界面 194 | - [BiRefNet](https://github.com/ZhengPeng7/BiRefNet) - 高质量图像抠图模型 195 | - [Comfyui_three_js](https://github.com/lo-th/Comfyui_three_js) - 基于这个原始3d模型展示画布进行改造,感谢作者 196 | - 所有贡献者和用户的支持 197 | 198 | 199 | 200 | -------------------------------------------------------------------------------- /js/CanvasBlendMode.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Canvas混合模式管理器 3 | * 负责处理图层混合模式和透明度相关功能 4 | */ 5 | export class CanvasBlendMode { 6 | 7 | /** 8 | * 获取混合模式列表 9 | * @returns {Array} 混合模式列表 10 | */ 11 | static getBlendModes() { 12 | return [ 13 | { name: 'normal', label: '正常' }, 14 | { name: 'multiply', label: '正片叠底' }, 15 | { name: 'screen', label: '滤色' }, 16 | { name: 'overlay', label: '叠加' }, 17 | { name: 'darken', label: '变暗' }, 18 | { name: 'lighten', label: '变亮' }, 19 | { name: 'color-dodge', label: '颜色减淡' }, 20 | { name: 'color-burn', label: '颜色加深' }, 21 | { name: 'hard-light', label: '强光' }, 22 | { name: 'soft-light', label: '柔光' }, 23 | { name: 'difference', label: '差值' }, 24 | { name: 'exclusion', label: '排除' } 25 | ]; 26 | } 27 | 28 | /** 29 | * 显示混合模式菜单 30 | * @param {Canvas} canvas - Canvas实例 31 | * @param {number} x - 菜单X坐标 32 | * @param {number} y - 菜单Y坐标 33 | */ 34 | static showBlendModeMenu(canvas, x, y) { 35 | // 移除已存在的菜单 36 | const existingMenu = document.getElementById('blend-mode-menu'); 37 | if (existingMenu) { 38 | document.body.removeChild(existingMenu); 39 | } 40 | 41 | const menu = document.createElement('div'); 42 | menu.id = 'blend-mode-menu'; 43 | menu.style.cssText = ` 44 | position: fixed; 45 | left: ${x}px; 46 | top: ${y}px; 47 | background: #2a2a2a; 48 | border: 1px solid #3a3a3a; 49 | border-radius: 4px; 50 | padding: 5px; 51 | z-index: 1000; 52 | box-shadow: 0 2px 10px rgba(0,0,0,0.3); 53 | `; 54 | 55 | const blendModes = this.getBlendModes(); 56 | 57 | blendModes.forEach(mode => { 58 | const container = document.createElement('div'); 59 | container.className = 'blend-mode-container'; 60 | container.style.cssText = ` 61 | margin-bottom: 5px; 62 | `; 63 | 64 | const option = document.createElement('div'); 65 | option.style.cssText = ` 66 | padding: 5px 10px; 67 | color: white; 68 | cursor: pointer; 69 | transition: background-color 0.2s; 70 | `; 71 | option.textContent = `${mode.label} (${mode.name})`; 72 | 73 | // 创建滑动条,使用当前图层的透明度值 74 | const slider = document.createElement('input'); 75 | slider.type = 'range'; 76 | slider.min = '0'; 77 | slider.max = '100'; 78 | // 使用当前图层的透明度值,如果存在的话 79 | slider.value = canvas.selectedLayer.opacity ? Math.round(canvas.selectedLayer.opacity * 100) : 100; 80 | slider.style.cssText = ` 81 | width: 100%; 82 | margin: 5px 0; 83 | display: none; 84 | `; 85 | 86 | // 如果是当前图层的混合模式,显示滑动条 87 | if (canvas.selectedLayer.blendMode === mode.name) { 88 | slider.style.display = 'block'; 89 | option.style.backgroundColor = '#3a3a3a'; 90 | } 91 | 92 | // 修改点击事件 93 | option.onclick = () => { 94 | // 隐藏所有其他滑动条 95 | menu.querySelectorAll('input[type="range"]').forEach(s => { 96 | s.style.display = 'none'; 97 | }); 98 | menu.querySelectorAll('.blend-mode-container div').forEach(d => { 99 | d.style.backgroundColor = ''; 100 | }); 101 | 102 | // 显示当前选项的滑动条 103 | slider.style.display = 'block'; 104 | option.style.backgroundColor = '#3a3a3a'; 105 | 106 | // 设置当前选中的混合模式 107 | if (canvas.selectedLayer) { 108 | canvas.selectedLayer.blendMode = mode.name; 109 | canvas.render(); 110 | } 111 | }; 112 | 113 | // 添加滑动条的input事件(实时更新) 114 | slider.addEventListener('input', () => { 115 | if (canvas.selectedLayer) { 116 | canvas.selectedLayer.opacity = slider.value / 100; 117 | canvas.render(); 118 | } 119 | }); 120 | 121 | // 添加滑动条的change事件(结束拖动时保存状态) 122 | slider.addEventListener('change', async () => { 123 | if (canvas.selectedLayer) { 124 | canvas.selectedLayer.opacity = slider.value / 100; 125 | canvas.render(); 126 | // 保存到服务器并更新节点 127 | await canvas.saveToServer(canvas.widget.value); 128 | if (canvas.node) { 129 | app.graph.runStep(); 130 | } 131 | } 132 | }); 133 | 134 | container.appendChild(option); 135 | container.appendChild(slider); 136 | menu.appendChild(container); 137 | }); 138 | 139 | document.body.appendChild(menu); 140 | 141 | // 点击其他地方关闭菜单 142 | const closeMenu = (e) => { 143 | if (!menu.contains(e.target)) { 144 | document.body.removeChild(menu); 145 | document.removeEventListener('mousedown', closeMenu); 146 | } 147 | }; 148 | setTimeout(() => { 149 | document.addEventListener('mousedown', closeMenu); 150 | }, 0); 151 | } 152 | 153 | /** 154 | * 处理混合模式选择 155 | * @param {Canvas} canvas - Canvas实例 156 | * @param {string} mode - 混合模式名称 157 | */ 158 | static handleBlendModeSelection(canvas, mode) { 159 | if (canvas.selectedBlendMode === mode && !canvas.isAdjustingOpacity) { 160 | // 第二次点击,应用效果 161 | this.applyBlendMode(canvas, mode, canvas.blendOpacity); 162 | this.closeBlendModeMenu(); 163 | } else { 164 | // 第一次点击,显示透明度调整器 165 | canvas.selectedBlendMode = mode; 166 | canvas.isAdjustingOpacity = true; 167 | this.showOpacitySlider(canvas, mode); 168 | } 169 | } 170 | 171 | /** 172 | * 显示透明度滑动条 173 | * @param {Canvas} canvas - Canvas实例 174 | * @param {string} mode - 混合模式名称 175 | */ 176 | static showOpacitySlider(canvas, mode) { 177 | // 创建滑动条 178 | const slider = document.createElement('input'); 179 | slider.type = 'range'; 180 | slider.min = '0'; 181 | slider.max = '100'; 182 | slider.value = canvas.blendOpacity; 183 | slider.className = 'blend-opacity-slider'; 184 | 185 | slider.addEventListener('input', (e) => { 186 | canvas.blendOpacity = parseInt(e.target.value); 187 | // 可以添加实时预览效果 188 | }); 189 | 190 | // 将滑动条添加到对应的混合模式选项下 191 | const modeElement = document.querySelector(`[data-blend-mode="${mode}"]`); 192 | if (modeElement) { 193 | modeElement.appendChild(slider); 194 | } 195 | } 196 | 197 | /** 198 | * 应用混合模式和透明度 199 | * @param {Canvas} canvas - Canvas实例 200 | * @param {string} mode - 混合模式名称 201 | * @param {number} opacity - 透明度(0-100) 202 | */ 203 | static applyBlendMode(canvas, mode, opacity) { 204 | if (canvas.selectedLayer) { 205 | // 应用混合模式和透明度 206 | canvas.selectedLayer.blendMode = mode; 207 | canvas.selectedLayer.opacity = opacity / 100; 208 | 209 | // 重新渲染画布 210 | canvas.render(); 211 | } 212 | 213 | // 清理状态 214 | canvas.selectedBlendMode = null; 215 | canvas.isAdjustingOpacity = false; 216 | } 217 | 218 | /** 219 | * 关闭混合模式菜单 220 | */ 221 | static closeBlendModeMenu() { 222 | const menu = document.getElementById('blend-mode-menu'); 223 | if (menu) { 224 | document.body.removeChild(menu); 225 | } 226 | } 227 | 228 | /** 229 | * 重置图层混合模式 230 | * @param {Object} layer - 图层对象 231 | */ 232 | static resetLayerBlendMode(layer) { 233 | if (layer) { 234 | layer.blendMode = 'normal'; 235 | layer.opacity = 1; 236 | } 237 | } 238 | 239 | /** 240 | * 获取图层当前混合模式信息 241 | * @param {Object} layer - 图层对象 242 | * @returns {Object} 混合模式信息 243 | */ 244 | static getLayerBlendInfo(layer) { 245 | if (!layer) return null; 246 | 247 | return { 248 | blendMode: layer.blendMode || 'normal', 249 | opacity: layer.opacity !== undefined ? layer.opacity : 1, 250 | opacityPercent: Math.round((layer.opacity !== undefined ? layer.opacity : 1) * 100) 251 | }; 252 | } 253 | 254 | /** 255 | * 设置图层混合模式 256 | * @param {Object} layer - 图层对象 257 | * @param {string} blendMode - 混合模式名称 258 | * @param {number} opacity - 透明度(0-1) 259 | */ 260 | static setLayerBlendMode(layer, blendMode, opacity) { 261 | if (!layer) return; 262 | 263 | layer.blendMode = blendMode || 'normal'; 264 | if (opacity !== undefined) { 265 | layer.opacity = Math.max(0, Math.min(1, opacity)); 266 | } 267 | } 268 | 269 | /** 270 | * 复制图层混合模式设置 271 | * @param {Object} sourceLayer - 源图层 272 | * @param {Object} targetLayer - 目标图层 273 | */ 274 | static copyBlendModeSettings(sourceLayer, targetLayer) { 275 | if (!sourceLayer || !targetLayer) return; 276 | 277 | targetLayer.blendMode = sourceLayer.blendMode || 'normal'; 278 | targetLayer.opacity = sourceLayer.opacity !== undefined ? sourceLayer.opacity : 1; 279 | } 280 | 281 | /** 282 | * 预览混合模式效果(不实际应用) 283 | * @param {Canvas} canvas - Canvas实例 284 | * @param {string} mode - 混合模式名称 285 | * @param {number} opacity - 透明度(0-1) 286 | */ 287 | static previewBlendMode(canvas, mode, opacity) { 288 | if (!canvas.selectedLayer) return; 289 | 290 | // 保存原始设置 291 | const originalBlendMode = canvas.selectedLayer.blendMode; 292 | const originalOpacity = canvas.selectedLayer.opacity; 293 | 294 | // 临时应用新设置 295 | canvas.selectedLayer.blendMode = mode; 296 | canvas.selectedLayer.opacity = opacity; 297 | 298 | // 渲染预览 299 | canvas.render(); 300 | 301 | // 还原原始设置(如果需要) 302 | // canvas.selectedLayer.blendMode = originalBlendMode; 303 | // canvas.selectedLayer.opacity = originalOpacity; 304 | } 305 | 306 | /** 307 | * 获取混合模式的描述信息 308 | * @param {string} mode - 混合模式名称 309 | * @returns {string} 描述信息 310 | */ 311 | static getBlendModeDescription(mode) { 312 | const descriptions = { 313 | 'normal': '正常混合,不改变颜色', 314 | 'multiply': '正片叠底,颜色变暗', 315 | 'screen': '滤色,颜色变亮', 316 | 'overlay': '叠加,增强对比度', 317 | 'darken': '变暗,选择较暗的颜色', 318 | 'lighten': '变亮,选择较亮的颜色', 319 | 'color-dodge': '颜色减淡,增亮底色', 320 | 'color-burn': '颜色加深,加深底色', 321 | 'hard-light': '强光,强烈的对比效果', 322 | 'soft-light': '柔光,柔和的对比效果', 323 | 'difference': '差值,颜色相减', 324 | 'exclusion': '排除,类似差值但对比度更低' 325 | }; 326 | 327 | return descriptions[mode] || '未知混合模式'; 328 | } 329 | } -------------------------------------------------------------------------------- /js/CanvasUtils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Canvas工具函数集合 3 | * 包含文件保存、数据转换、图像处理等低频调用的工具方法 4 | */ 5 | export class CanvasUtils { 6 | 7 | /** 8 | * 保存画布到服务器 9 | * @param {Canvas} canvas - Canvas实例 10 | * @param {string} fileName - 文件名 11 | * @returns {Promise} 保存是否成功 12 | */ 13 | static async saveToServer(canvas, fileName) { 14 | return new Promise((resolve) => { 15 | // 创建临时画布 16 | const tempCanvas = document.createElement('canvas'); 17 | const maskCanvas = document.createElement('canvas'); 18 | tempCanvas.width = canvas.width; 19 | tempCanvas.height = canvas.height; 20 | maskCanvas.width = canvas.width; 21 | maskCanvas.height = canvas.height; 22 | 23 | const tempCtx = tempCanvas.getContext('2d'); 24 | const maskCtx = maskCanvas.getContext('2d'); 25 | 26 | // 填充白色背景 27 | tempCtx.fillStyle = '#ffffff'; 28 | tempCtx.fillRect(0, 0, canvas.width, canvas.height); 29 | 30 | // 填充黑色背景作为遮罩的基础 - 保持黑色背景 31 | maskCtx.fillStyle = '#000000'; 32 | maskCtx.fillRect(0, 0, canvas.width, canvas.height); 33 | 34 | // 按照zIndex顺序绘制所有图层 35 | canvas.layers.sort((a, b) => a.zIndex - b.zIndex).forEach(layer => { 36 | // 绘制主图像 37 | tempCtx.save(); 38 | tempCtx.globalCompositeOperation = layer.blendMode || 'normal'; 39 | tempCtx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1; 40 | 41 | // 应用变换 42 | tempCtx.translate(layer.x + layer.width/2, layer.y + layer.height/2); 43 | tempCtx.rotate(layer.rotation * Math.PI / 180); 44 | tempCtx.drawImage( 45 | layer.image, 46 | -layer.width/2, 47 | -layer.height/2, 48 | layer.width, 49 | layer.height 50 | ); 51 | tempCtx.restore(); 52 | 53 | // 处理图层遮罩和透明度 54 | maskCtx.save(); 55 | maskCtx.translate(layer.x + layer.width/2, layer.y + layer.height/2); 56 | maskCtx.rotate(layer.rotation * Math.PI / 180); 57 | 58 | // 创建临时图层画布和它的遮罩 - 用于分析透明度和存在的遮罩 59 | const layerCanvas = document.createElement('canvas'); 60 | layerCanvas.width = layer.width; 61 | layerCanvas.height = layer.height; 62 | const layerCtx = layerCanvas.getContext('2d'); 63 | 64 | // 绘制图层到临时画布,以捕获其透明度信息 65 | layerCtx.drawImage( 66 | layer.image, 67 | 0, 0, 68 | layer.width, layer.height 69 | ); 70 | 71 | // 获取图层像素数据(包含透明度信息) 72 | const layerImageData = layerCtx.getImageData(0, 0, layer.width, layer.height); 73 | 74 | // 创建临时蒙版画布 75 | const layerMaskCanvas = document.createElement('canvas'); 76 | layerMaskCanvas.width = layer.width; 77 | layerMaskCanvas.height = layer.height; 78 | const layerMaskCtx = layerMaskCanvas.getContext('2d'); 79 | 80 | // 创建遮罩图像数据 81 | const maskImageData = layerMaskCtx.createImageData(layer.width, layer.height); 82 | 83 | // 处理遮罩数据 84 | if (layer.mask) { 85 | // 使用显式定义的遮罩 86 | const maskArray = layer.mask; 87 | for (let i = 0; i < maskArray.length; i++) { 88 | const index = i * 4; 89 | // 获取原始图像的透明度 90 | const imageAlpha = layerImageData.data[index + 3] / 255; 91 | // 混合层遮罩值和图像透明度 92 | const alpha = maskArray[i] * imageAlpha; 93 | // 白色表示非透明区域 94 | const value = Math.round(alpha * 255); 95 | maskImageData.data[index] = value; 96 | maskImageData.data[index + 1] = value; 97 | maskImageData.data[index + 2] = value; 98 | maskImageData.data[index + 3] = 255; // 全不透明 99 | } 100 | } else { 101 | // 只使用图层自身的透明度 102 | for (let i = 0; i < layerImageData.data.length / 4; i++) { 103 | const index = i * 4; 104 | // 使用图像的alpha通道作为遮罩值 105 | const alpha = layerImageData.data[index + 3] / 255; 106 | // 白色表示非透明区域 107 | const value = Math.round(alpha * 255); 108 | maskImageData.data[index] = value; 109 | maskImageData.data[index + 1] = value; 110 | maskImageData.data[index + 2] = value; 111 | maskImageData.data[index + 3] = 255; // 全不透明 112 | } 113 | } 114 | 115 | // 放入临时遮罩画布 116 | layerMaskCtx.putImageData(maskImageData, 0, 0); 117 | 118 | // 使用lighter模式将此图层的遮罩添加到主遮罩 119 | maskCtx.globalCompositeOperation = 'lighter'; 120 | maskCtx.drawImage( 121 | layerMaskCanvas, 122 | -layer.width/2, 123 | -layer.height/2, 124 | layer.width, 125 | layer.height 126 | ); 127 | maskCtx.restore(); 128 | }); 129 | 130 | // 保存主图像和遮罩 131 | tempCanvas.toBlob(async (blob) => { 132 | const formData = new FormData(); 133 | formData.append("image", blob, fileName); 134 | formData.append("overwrite", "true"); 135 | 136 | try { 137 | const resp = await fetch("/upload/image", { 138 | method: "POST", 139 | body: formData, 140 | }); 141 | 142 | if (resp.status === 200) { 143 | // 保存遮罩图像 144 | maskCanvas.toBlob(async (maskBlob) => { 145 | const maskFormData = new FormData(); 146 | const maskFileName = fileName.replace(/\.[^/.]+$/, '') + '_mask.png'; 147 | maskFormData.append("image", maskBlob, maskFileName); 148 | maskFormData.append("overwrite", "true"); 149 | 150 | try { 151 | const maskResp = await fetch("/upload/image", { 152 | method: "POST", 153 | body: maskFormData, 154 | }); 155 | 156 | if (maskResp.status === 200) { 157 | const data = await resp.json(); 158 | canvas.widget.value = data.name; 159 | // 触发节点更新 160 | if (canvas.node) { 161 | canvas.node.setDirtyCanvas(true); 162 | app.graph.runStep(); 163 | } 164 | resolve(true); 165 | } else { 166 | console.error("Error saving mask: " + maskResp.status); 167 | resolve(false); 168 | } 169 | } catch (error) { 170 | console.error("Error saving mask:", error); 171 | resolve(false); 172 | } 173 | }, "image/png"); 174 | } else { 175 | console.error(resp.status + " - " + resp.statusText); 176 | resolve(false); 177 | } 178 | } catch (error) { 179 | console.error(error); 180 | resolve(false); 181 | } 182 | }, "image/png"); 183 | }); 184 | } 185 | 186 | /** 187 | * 转换张量为图像数据 188 | * @param {Object} tensor - 张量数据 189 | * @returns {ImageData|null} 图像数据 190 | */ 191 | static convertTensorToImageData(tensor) { 192 | try { 193 | const shape = tensor.shape; 194 | const height = shape[1]; 195 | const width = shape[2]; 196 | const channels = shape[3]; 197 | 198 | console.log("Converting tensor:", { 199 | shape: shape, 200 | dataRange: { 201 | min: tensor.min_val, 202 | max: tensor.max_val 203 | } 204 | }); 205 | 206 | // 创建图像数据 207 | const imageData = new ImageData(width, height); 208 | const data = new Uint8ClampedArray(width * height * 4); 209 | 210 | // 重建数据结构 211 | const flatData = tensor.data; 212 | const pixelCount = width * height; 213 | 214 | for (let i = 0; i < pixelCount; i++) { 215 | const pixelIndex = i * 4; 216 | const tensorIndex = i * channels; 217 | 218 | // 正确处理RGB通道 219 | for (let c = 0; c < channels; c++) { 220 | const value = flatData[tensorIndex + c]; 221 | // 根据实际值范围进行映射 222 | const normalizedValue = (value - tensor.min_val) / (tensor.max_val - tensor.min_val); 223 | data[pixelIndex + c] = Math.round(normalizedValue * 255); 224 | } 225 | 226 | // Alpha通道 227 | data[pixelIndex + 3] = 255; 228 | } 229 | 230 | imageData.data.set(data); 231 | return imageData; 232 | } catch (error) { 233 | console.error("Error converting tensor:", error); 234 | return null; 235 | } 236 | } 237 | 238 | /** 239 | * 从图像数据创建图像对象 240 | * @param {ImageData} imageData - 图像数据 241 | * @returns {Promise} 图像对象 242 | */ 243 | static async createImageFromData(imageData) { 244 | return new Promise((resolve, reject) => { 245 | const canvas = document.createElement('canvas'); 246 | canvas.width = imageData.width; 247 | canvas.height = imageData.height; 248 | const ctx = canvas.getContext('2d'); 249 | ctx.putImageData(imageData, 0, 0); 250 | 251 | const img = new Image(); 252 | img.onload = () => resolve(img); 253 | img.onerror = reject; 254 | img.src = canvas.toDataURL(); 255 | }); 256 | } 257 | 258 | /** 259 | * 从缓存加载图像 260 | * @param {string} base64Data - base64图像数据 261 | * @returns {Promise} 图像对象 262 | */ 263 | static async loadImageFromCache(base64Data) { 264 | return new Promise((resolve, reject) => { 265 | const img = new Image(); 266 | img.onload = () => resolve(img); 267 | img.onerror = reject; 268 | img.src = base64Data; 269 | }); 270 | } 271 | 272 | /** 273 | * 转换张量为图像 274 | * @param {Object} tensor - 张量数据 275 | * @returns {Promise} 图像对象 276 | */ 277 | static async convertTensorToImage(tensor) { 278 | try { 279 | console.log("Converting tensor to image:", tensor); 280 | 281 | if (!tensor || !tensor.data || !tensor.width || !tensor.height) { 282 | throw new Error("Invalid tensor data"); 283 | } 284 | 285 | // 创建临时画布 286 | const canvas = document.createElement('canvas'); 287 | const ctx = canvas.getContext('2d'); 288 | canvas.width = tensor.width; 289 | canvas.height = tensor.height; 290 | 291 | // 创建像素数据 292 | const imageData = new ImageData( 293 | new Uint8ClampedArray(tensor.data), 294 | tensor.width, 295 | tensor.height 296 | ); 297 | 298 | // 将数据绘制到画布 299 | ctx.putImageData(imageData, 0, 0); 300 | 301 | // 创建新图像 302 | return new Promise((resolve, reject) => { 303 | const img = new Image(); 304 | img.onload = () => resolve(img); 305 | img.onerror = (e) => reject(new Error("Failed to load image: " + e)); 306 | img.src = canvas.toDataURL(); 307 | }); 308 | } catch (error) { 309 | console.error("Error converting tensor to image:", error); 310 | throw error; 311 | } 312 | } 313 | 314 | /** 315 | * 转换张量为遮罩 316 | * @param {Object} tensor - 张量数据 317 | * @returns {Promise} 遮罩数据 318 | */ 319 | static async convertTensorToMask(tensor) { 320 | if (!tensor || !tensor.data) { 321 | throw new Error("Invalid mask tensor"); 322 | } 323 | 324 | try { 325 | // 确保数据是Float32Array 326 | return new Float32Array(tensor.data); 327 | } catch (error) { 328 | throw new Error(`Mask conversion failed: ${error.message}`); 329 | } 330 | } 331 | 332 | /** 333 | * 验证图像数据 334 | * @param {Object} data - 图像数据 335 | * @returns {boolean} 是否有效 336 | */ 337 | static validateImageData(data) { 338 | console.log("Validating data structure:", { 339 | hasData: !!data, 340 | type: typeof data, 341 | isArray: Array.isArray(data), 342 | keys: data ? Object.keys(data) : null, 343 | shape: data?.shape, 344 | dataType: data?.data ? data.data.constructor.name : null, 345 | fullData: data 346 | }); 347 | 348 | if (!data) { 349 | console.log("No data provided"); 350 | return false; 351 | } 352 | 353 | if (Array.isArray(data)) { 354 | console.log("Data is array, checking first element"); 355 | data = data[0]; 356 | } 357 | 358 | const hasValidShape = data.shape && Array.isArray(data.shape) && data.shape.length >= 3; 359 | const hasValidData = data.data && (data.data instanceof Float32Array || 360 | data.data instanceof Uint8Array || 361 | data.data instanceof Array); 362 | 363 | console.log("Validation result:", { 364 | hasValidShape, 365 | hasValidData, 366 | shape: data.shape, 367 | dataLength: data.data ? data.data.length : 0 368 | }); 369 | 370 | return hasValidShape && hasValidData; 371 | } 372 | 373 | /** 374 | * 转换图像数据格式 375 | * @param {Object} data - 原始数据 376 | * @returns {Object} 转换后的数据 377 | */ 378 | static convertImageData(data) { 379 | try { 380 | if (Array.isArray(data)) { 381 | data = data[0]; 382 | } 383 | 384 | if (!this.validateImageData(data)) { 385 | throw new Error("Invalid image data structure"); 386 | } 387 | 388 | const shape = data.shape; 389 | const width = shape[2]; 390 | const height = shape[1]; 391 | const channels = shape[3] || shape[0]; 392 | 393 | console.log("Converting image data:", { 394 | width, height, channels, 395 | dataType: data.data.constructor.name, 396 | dataLength: data.data.length 397 | }); 398 | 399 | return { 400 | width, 401 | height, 402 | channels, 403 | data: data.data, 404 | shape: shape, 405 | min_val: data.min_val || 0, 406 | max_val: data.max_val || 1 407 | }; 408 | } catch (error) { 409 | console.error("Error converting image data:", error); 410 | throw error; 411 | } 412 | } 413 | 414 | /** 415 | * 应用遮罩到图像数据 416 | * @param {ImageData} imageData - 图像数据 417 | * @param {Float32Array} maskData - 遮罩数据 418 | * @returns {ImageData} 应用遮罩后的图像数据 419 | */ 420 | static applyMaskToImageData(imageData, maskData) { 421 | if (!imageData || !maskData) { 422 | throw new Error("Missing image or mask data"); 423 | } 424 | 425 | const result = new ImageData(imageData.width, imageData.height); 426 | result.data.set(imageData.data); 427 | 428 | for (let i = 0; i < maskData.length; i++) { 429 | const pixelIndex = i * 4; 430 | if (pixelIndex + 3 < result.data.length) { 431 | result.data[pixelIndex + 3] = Math.round(maskData[i] * 255); 432 | } 433 | } 434 | 435 | return result; 436 | } 437 | 438 | /** 439 | * 准备图像用于画布 440 | * @param {Object} inputImage - 输入图像数据 441 | * @returns {Object} 处理后的图像数据 442 | */ 443 | static prepareImageForCanvas(inputImage) { 444 | try { 445 | console.log("Preparing image for canvas:", inputImage); 446 | 447 | if (!inputImage) { 448 | throw new Error("No input image provided"); 449 | } 450 | 451 | // 处理不同的输入格式 452 | let processedData; 453 | 454 | if (Array.isArray(inputImage)) { 455 | processedData = inputImage[0]; 456 | } else { 457 | processedData = inputImage; 458 | } 459 | 460 | if (!this.validateImageData(processedData)) { 461 | console.error("Invalid image data format"); 462 | return null; 463 | } 464 | 465 | return this.convertImageData(processedData); 466 | } catch (error) { 467 | console.error("Error preparing image for canvas:", error); 468 | return null; 469 | } 470 | } 471 | } -------------------------------------------------------------------------------- /canvas_node.py: -------------------------------------------------------------------------------- 1 | from PIL import Image, ImageOps 2 | import hashlib 3 | import torch 4 | import numpy as np 5 | import folder_paths 6 | from server import PromptServer 7 | from aiohttp import web 8 | import os 9 | from tqdm import tqdm 10 | from torchvision import transforms 11 | from transformers import AutoModelForImageSegmentation, PretrainedConfig 12 | import torch.nn.functional as F 13 | import traceback 14 | import uuid 15 | import time 16 | import base64 17 | from PIL import Image 18 | import io 19 | 20 | # 设置高精度计算 21 | torch.set_float32_matmul_precision('high') 22 | 23 | # 定义配置类 24 | class BiRefNetConfig(PretrainedConfig): 25 | model_type = "BiRefNet" 26 | def __init__(self, bb_pretrained=False, **kwargs): 27 | self.bb_pretrained = bb_pretrained 28 | super().__init__(**kwargs) 29 | 30 | # 定义模型类 31 | class BiRefNet(torch.nn.Module): 32 | def __init__(self, config): 33 | super().__init__() 34 | # 基本网络结构 35 | self.encoder = torch.nn.Sequential( 36 | torch.nn.Conv2d(3, 64, kernel_size=3, padding=1), 37 | torch.nn.ReLU(inplace=True), 38 | torch.nn.Conv2d(64, 64, kernel_size=3, padding=1), 39 | torch.nn.ReLU(inplace=True) 40 | ) 41 | 42 | self.decoder = torch.nn.Sequential( 43 | torch.nn.Conv2d(64, 32, kernel_size=3, padding=1), 44 | torch.nn.ReLU(inplace=True), 45 | torch.nn.Conv2d(32, 1, kernel_size=1) 46 | ) 47 | 48 | def forward(self, x): 49 | features = self.encoder(x) 50 | output = self.decoder(features) 51 | return [output] 52 | 53 | class CanvasNode: 54 | _canvas_cache = { 55 | 'image': None, 56 | 'mask': None, 57 | 'cache_enabled': True, 58 | 'data_flow_status': {}, 59 | 'persistent_cache': {}, 60 | 'last_execution_id': None 61 | } 62 | 63 | def __init__(self): 64 | super().__init__() 65 | self.flow_id = str(uuid.uuid4()) 66 | # 从持久化缓存恢复数据 67 | if self.__class__._canvas_cache['persistent_cache']: 68 | self.restore_cache() 69 | 70 | def restore_cache(self): 71 | """从持久化缓存恢复数据,除非是新的执行""" 72 | try: 73 | persistent = self.__class__._canvas_cache['persistent_cache'] 74 | current_execution = self.get_execution_id() 75 | 76 | # 只有在新的执行ID时才清除缓存 77 | if current_execution != self.__class__._canvas_cache['last_execution_id']: 78 | print(f"New execution detected: {current_execution}") 79 | self.__class__._canvas_cache['image'] = None 80 | self.__class__._canvas_cache['mask'] = None 81 | self.__class__._canvas_cache['last_execution_id'] = current_execution 82 | else: 83 | # 否则保留现有缓存 84 | if persistent.get('image') is not None: 85 | self.__class__._canvas_cache['image'] = persistent['image'] 86 | print("Restored image from persistent cache") 87 | if persistent.get('mask') is not None: 88 | self.__class__._canvas_cache['mask'] = persistent['mask'] 89 | print("Restored mask from persistent cache") 90 | except Exception as e: 91 | print(f"Error restoring cache: {str(e)}") 92 | 93 | def get_execution_id(self): 94 | """获取当前工作流执行ID""" 95 | try: 96 | # 可以使用时间戳或其他唯一标识 97 | return str(int(time.time() * 1000)) 98 | except Exception as e: 99 | print(f"Error getting execution ID: {str(e)}") 100 | return None 101 | 102 | def update_persistent_cache(self): 103 | """更新持久化缓存""" 104 | try: 105 | self.__class__._canvas_cache['persistent_cache'] = { 106 | 'image': self.__class__._canvas_cache['image'], 107 | 'mask': self.__class__._canvas_cache['mask'] 108 | } 109 | print("Updated persistent cache") 110 | except Exception as e: 111 | print(f"Error updating persistent cache: {str(e)}") 112 | 113 | def track_data_flow(self, stage, status, data_info=None): 114 | """追踪数据流状态""" 115 | flow_status = { 116 | 'timestamp': time.time(), 117 | 'stage': stage, 118 | 'status': status, 119 | 'data_info': data_info 120 | } 121 | print(f"Data Flow [{self.flow_id}] - Stage: {stage}, Status: {status}") 122 | if data_info: 123 | print(f"Data Info: {data_info}") 124 | 125 | self.__class__._canvas_cache['data_flow_status'][self.flow_id] = flow_status 126 | 127 | @classmethod 128 | def INPUT_TYPES(cls): 129 | return { 130 | "required": { 131 | "canvas_image": ("STRING", {"default": "canvas_image.png"}), 132 | "trigger": ("INT", {"default": 0, "min": 0, "max": 99999999, "step": 1, "hidden": True}), 133 | "output_switch": ("BOOLEAN", {"default": True}), 134 | "cache_enabled": ("BOOLEAN", {"default": True, "label": "Enable Cache"}) 135 | }, 136 | "optional": { 137 | "input_image": ("IMAGE",), 138 | "input_mask": ("MASK",) 139 | } 140 | } 141 | 142 | RETURN_TYPES = ("IMAGE", "MASK") 143 | RETURN_NAMES = ("image", "mask") 144 | FUNCTION = "process_canvas_image" 145 | CATEGORY = "Ycanvas" 146 | 147 | def add_image_to_canvas(self, input_image): 148 | """处理输入图像""" 149 | try: 150 | # 确保输入图像是正确的格式 151 | if not isinstance(input_image, torch.Tensor): 152 | raise ValueError("Input image must be a torch.Tensor") 153 | 154 | # 处理图像维度 155 | if input_image.dim() == 4: 156 | input_image = input_image.squeeze(0) 157 | 158 | # 转换为标准格式 159 | if input_image.dim() == 3 and input_image.shape[0] in [1, 3]: 160 | input_image = input_image.permute(1, 2, 0) 161 | 162 | # 确保RGB格式 163 | if input_image.shape[2] == 1: # 灰度图扩展为RGB 164 | input_image = input_image.repeat(1, 1, 3) 165 | elif input_image.shape[2] == 4: # RGBA格式处理alpha通道 166 | # 分离RGB和alpha通道 167 | rgb = input_image[:, :, :3] 168 | alpha = input_image[:, :, 3:4] 169 | # 处理透明区域 170 | input_image = rgb 171 | # 如果需要,可以在这里处理alpha通道 172 | 173 | return input_image 174 | 175 | except Exception as e: 176 | print(f"Error in add_image_to_canvas: {str(e)}") 177 | return None 178 | 179 | def add_mask_to_canvas(self, input_mask, input_image): 180 | """处理输入遮罩""" 181 | try: 182 | # 确保输入遮罩是正确的格式 183 | if not isinstance(input_mask, torch.Tensor): 184 | raise ValueError("Input mask must be a torch.Tensor") 185 | 186 | # 处理遮罩维度 187 | if input_mask.dim() == 4: 188 | input_mask = input_mask.squeeze(0) 189 | if input_mask.dim() == 3 and input_mask.shape[0] == 1: 190 | input_mask = input_mask.squeeze(0) 191 | 192 | # 确保遮罩尺寸与图像匹配 193 | if input_image is not None: 194 | expected_shape = input_image.shape[:2] 195 | if input_mask.shape != expected_shape: 196 | input_mask = F.interpolate( 197 | input_mask.unsqueeze(0).unsqueeze(0), 198 | size=expected_shape, 199 | mode='bilinear', 200 | align_corners=False 201 | ).squeeze() 202 | 203 | # 翻转遮罩值,使黑色表示透明区域(值=0),白色表示不透明区域(值=1) 204 | # 这确保了与前端遮罩处理逻辑的一致性 205 | return input_mask 206 | 207 | except Exception as e: 208 | print(f"Error in add_mask_to_canvas: {str(e)}") 209 | return None 210 | 211 | def process_canvas_image(self, canvas_image, trigger, output_switch, cache_enabled, input_image=None, input_mask=None): 212 | try: 213 | current_execution = self.get_execution_id() 214 | print(f"Processing canvas image, execution ID: {current_execution}") 215 | 216 | # 检查是否是新的执行 217 | if current_execution != self.__class__._canvas_cache['last_execution_id']: 218 | print(f"New execution detected: {current_execution}") 219 | # 清除旧的缓存 220 | self.__class__._canvas_cache['image'] = None 221 | self.__class__._canvas_cache['mask'] = None 222 | self.__class__._canvas_cache['last_execution_id'] = current_execution 223 | 224 | # 处理输入图像 225 | if input_image is not None: 226 | print("Input image received, converting to PIL Image...") 227 | # 将tensor转换为PIL Image并存储到缓存 228 | if isinstance(input_image, torch.Tensor): 229 | if input_image.dim() == 4: 230 | input_image = input_image.squeeze(0) # 移除batch维度 231 | 232 | # 确保图像格式为[H, W, C] 233 | if input_image.shape[0] == 3: # 如果是[C, H, W]格式 234 | input_image = input_image.permute(1, 2, 0) 235 | 236 | # 转换为numpy数组并确保值范围在0-255 237 | image_array = (input_image.cpu().numpy() * 255).astype(np.uint8) 238 | 239 | # 确保数组形状正确 240 | if len(image_array.shape) == 2: # 如果是灰度图 241 | image_array = np.stack([image_array] * 3, axis=-1) 242 | elif len(image_array.shape) == 3 and image_array.shape[-1] != 3: 243 | image_array = np.transpose(image_array, (1, 2, 0)) 244 | 245 | try: 246 | # 转换为PIL Image 247 | pil_image = Image.fromarray(image_array, 'RGB') 248 | print("Successfully converted to PIL Image") 249 | # 存储PIL Image到缓存 250 | self.__class__._canvas_cache['image'] = pil_image 251 | print(f"Image stored in cache with size: {pil_image.size}") 252 | except Exception as e: 253 | print(f"Error converting to PIL Image: {str(e)}") 254 | print(f"Array shape: {image_array.shape}, dtype: {image_array.dtype}") 255 | raise 256 | 257 | # 处理输入遮罩 258 | if input_mask is not None: 259 | print("Input mask received, converting to PIL Image...") 260 | if isinstance(input_mask, torch.Tensor): 261 | if input_mask.dim() == 4: 262 | input_mask = input_mask.squeeze(0) 263 | if input_mask.dim() == 3 and input_mask.shape[0] == 1: 264 | input_mask = input_mask.squeeze(0) 265 | 266 | # 转换为PIL Image 267 | mask_array = (input_mask.cpu().numpy() * 255).astype(np.uint8) 268 | pil_mask = Image.fromarray(mask_array, 'L') 269 | print("Successfully converted mask to PIL Image") 270 | # 存储遮罩到缓存 271 | self.__class__._canvas_cache['mask'] = pil_mask 272 | print(f"Mask stored in cache with size: {pil_mask.size}") 273 | 274 | # 更新缓存开关状态 275 | self.__class__._canvas_cache['cache_enabled'] = cache_enabled 276 | 277 | try: 278 | # 尝试读取画布图像 279 | path_image = folder_paths.get_annotated_filepath(canvas_image) 280 | i = Image.open(path_image) 281 | i = ImageOps.exif_transpose(i) 282 | if i.mode not in ['RGB', 'RGBA']: 283 | i = i.convert('RGB') 284 | image = np.array(i).astype(np.float32) / 255.0 285 | if i.mode == 'RGBA': 286 | rgb = image[..., :3] 287 | alpha = image[..., 3:] 288 | image = rgb * alpha + (1 - alpha) * 0.5 289 | processed_image = torch.from_numpy(image)[None,] 290 | except Exception as e: 291 | # 如果读取失败,创建白色画布 292 | processed_image = torch.ones((1, 512, 512, 3), dtype=torch.float32) 293 | 294 | try: 295 | # 尝试读取遮罩图像 296 | path_mask = path_image.replace('.png', '_mask.png') 297 | if os.path.exists(path_mask): 298 | # 读取遮罩图像并转换为灰度 299 | mask = Image.open(path_mask).convert('L') 300 | 301 | # 转换为numpy数组并归一化 (0-1范围) 302 | mask_array = np.array(mask).astype(np.float32) / 255.0 303 | 304 | # 读取的遮罩是RGBA中A通道的可视化: 305 | # 白色(255/255=1.0)表示不透明区域 306 | # 黑色(0/255=0.0)表示透明区域 307 | # 因此不需要额外处理 308 | 309 | # 转换为PyTorch张量 310 | processed_mask = torch.from_numpy(mask_array)[None,] 311 | print(f"Loaded mask with shape: {processed_mask.shape}") 312 | else: 313 | # 如果没有遮罩文件,创建全黑遮罩(全透明) 314 | processed_mask = torch.zeros((1, processed_image.shape[1], processed_image.shape[2]), dtype=torch.float32) 315 | print("Created black mask (transparent) as default") 316 | except Exception as e: 317 | print(f"Error loading mask: {str(e)}") 318 | # 创建默认黑色遮罩 319 | processed_mask = torch.zeros((1, processed_image.shape[1], processed_image.shape[2]), dtype=torch.float32) 320 | print("Created black mask after error") 321 | 322 | # 输出处理 323 | if not output_switch: 324 | return () 325 | 326 | # 更新持久化缓存 327 | self.update_persistent_cache() 328 | 329 | # 返回处理后的图像和遮罩 330 | return (processed_image, processed_mask) 331 | 332 | except Exception as e: 333 | print(f"Error in process_canvas_image: {str(e)}") 334 | traceback.print_exc() 335 | return () 336 | 337 | # 添加获取缓存数据的方法 338 | def get_cached_data(self): 339 | return { 340 | 'image': self.__class__._canvas_cache['image'], 341 | 'mask': self.__class__._canvas_cache['mask'] 342 | } 343 | 344 | # 添加API路由处理器 345 | @classmethod 346 | def api_get_data(cls, node_id): 347 | try: 348 | return { 349 | 'success': True, 350 | 'data': cls._canvas_cache 351 | } 352 | except Exception as e: 353 | return { 354 | 'success': False, 355 | 'error': str(e) 356 | } 357 | 358 | @classmethod 359 | def get_flow_status(cls, flow_id=None): 360 | """获取数据流状态""" 361 | if flow_id: 362 | return cls._canvas_cache['data_flow_status'].get(flow_id) 363 | return cls._canvas_cache['data_flow_status'] 364 | 365 | @classmethod 366 | def setup_routes(cls): 367 | @PromptServer.instance.routes.get("/ycnode/get_canvas_data/{node_id}") 368 | async def get_canvas_data(request): 369 | try: 370 | node_id = request.match_info["node_id"] 371 | print(f"Received request for node: {node_id}") 372 | 373 | cache_data = cls._canvas_cache 374 | print(f"Cache content: {cache_data}") 375 | print(f"Image in cache: {cache_data['image'] is not None}") 376 | 377 | response_data = { 378 | 'success': True, 379 | 'data': { 380 | 'image': None, 381 | 'mask': None 382 | } 383 | } 384 | 385 | if cache_data['image'] is not None: 386 | pil_image = cache_data['image'] 387 | buffered = io.BytesIO() 388 | pil_image.save(buffered, format="PNG") 389 | img_str = base64.b64encode(buffered.getvalue()).decode() 390 | response_data['data']['image'] = f"data:image/png;base64,{img_str}" 391 | 392 | if cache_data['mask'] is not None: 393 | pil_mask = cache_data['mask'] 394 | mask_buffer = io.BytesIO() 395 | pil_mask.save(mask_buffer, format="PNG") 396 | mask_str = base64.b64encode(mask_buffer.getvalue()).decode() 397 | response_data['data']['mask'] = f"data:image/png;base64,{mask_str}" 398 | 399 | return web.json_response(response_data) 400 | 401 | except Exception as e: 402 | print(f"Error in get_canvas_data: {str(e)}") 403 | return web.json_response({ 404 | 'success': False, 405 | 'error': str(e) 406 | }) 407 | 408 | def store_image(self, image_data): 409 | # 将base64数据转换为PIL Image并存储 410 | if isinstance(image_data, str) and image_data.startswith('data:image'): 411 | image_data = image_data.split(',')[1] 412 | image_bytes = base64.b64decode(image_data) 413 | self.cached_image = Image.open(io.BytesIO(image_bytes)) 414 | else: 415 | self.cached_image = image_data 416 | 417 | def get_cached_image(self): 418 | # 将PIL Image转换为base64 419 | if self.cached_image: 420 | buffered = io.BytesIO() 421 | self.cached_image.save(buffered, format="PNG") 422 | img_str = base64.b64encode(buffered.getvalue()).decode() 423 | return f"data:image/png;base64,{img_str}" 424 | return None 425 | 426 | class BiRefNetMatting: 427 | def __init__(self): 428 | self.model = None 429 | self.model_path = None 430 | self.model_cache = {} 431 | # 使用 ComfyUI models 目录 432 | self.base_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "models") 433 | 434 | def load_model(self, model_path): 435 | try: 436 | if model_path not in self.model_cache: 437 | # 使用 ComfyUI models 目录下的 BiRefNet 路径 438 | full_model_path = os.path.join(self.base_path, "BiRefNet") 439 | 440 | print(f"Loading BiRefNet model from {full_model_path}...") 441 | 442 | try: 443 | # 直接从Hugging Face加载 444 | self.model = AutoModelForImageSegmentation.from_pretrained( 445 | "ZhengPeng7/BiRefNet", 446 | trust_remote_code=True, 447 | cache_dir=full_model_path # 使用本地缓存目录 448 | ) 449 | 450 | # 设置为评估模式并移动到GPU 451 | self.model.eval() 452 | if torch.cuda.is_available(): 453 | self.model = self.model.cuda() 454 | 455 | self.model_cache[model_path] = self.model 456 | print("Model loaded successfully from Hugging Face") 457 | print(f"Model type: {type(self.model)}") 458 | print(f"Model device: {next(self.model.parameters()).device}") 459 | 460 | except Exception as e: 461 | print(f"Failed to load model: {str(e)}") 462 | raise 463 | 464 | else: 465 | self.model = self.model_cache[model_path] 466 | print("Using cached model") 467 | 468 | return True 469 | 470 | except Exception as e: 471 | print(f"Error loading model: {str(e)}") 472 | traceback.print_exc() 473 | return False 474 | 475 | def preprocess_image(self, image): 476 | """预处理输入图像""" 477 | try: 478 | # 转换为PIL图像 479 | if isinstance(image, torch.Tensor): 480 | if image.dim() == 4: 481 | image = image.squeeze(0) 482 | if image.dim() == 3: 483 | image = transforms.ToPILImage()(image) 484 | 485 | # 参考nodes.py的预处理 486 | transform_image = transforms.Compose([ 487 | transforms.Resize((1024, 1024)), 488 | transforms.ToTensor(), 489 | transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) 490 | ]) 491 | 492 | # 转换为tensor并添加batch维度 493 | image_tensor = transform_image(image).unsqueeze(0) 494 | 495 | if torch.cuda.is_available(): 496 | image_tensor = image_tensor.cuda() 497 | 498 | return image_tensor 499 | except Exception as e: 500 | print(f"Error preprocessing image: {str(e)}") 501 | return None 502 | 503 | def execute(self, image, model_path, threshold=0.5, refinement=1): 504 | try: 505 | # 发送开始状态 506 | PromptServer.instance.send_sync("matting_status", {"status": "processing"}) 507 | 508 | # 加载模型 509 | if not self.load_model(model_path): 510 | raise RuntimeError("Failed to load model") 511 | 512 | # 获取原始尺寸 513 | if isinstance(image, torch.Tensor): 514 | original_size = image.shape[-2:] if image.dim() == 4 else image.shape[-2:] 515 | else: 516 | original_size = image.size[::-1] 517 | 518 | print(f"Original size: {original_size}") 519 | 520 | # 预处理图像 521 | processed_image = self.preprocess_image(image) 522 | if processed_image is None: 523 | raise Exception("Failed to preprocess image") 524 | 525 | print(f"Processed image shape: {processed_image.shape}") 526 | 527 | # 执行推理 528 | with torch.no_grad(): 529 | outputs = self.model(processed_image) 530 | result = outputs[-1].sigmoid().cpu() 531 | print(f"Model output shape: {result.shape}") 532 | 533 | # 确保结果有正的维度格式 [B, C, H, W] 534 | if result.dim() == 3: 535 | result = result.unsqueeze(1) # 添加通道维度 536 | elif result.dim() == 2: 537 | result = result.unsqueeze(0).unsqueeze(0) # 添加batch和通道维度 538 | 539 | print(f"Reshaped result shape: {result.shape}") 540 | 541 | # 调整大小 542 | result = F.interpolate( 543 | result, 544 | size=(original_size[0], original_size[1]), # 明确指定高度和宽度 545 | mode='bilinear', 546 | align_corners=True 547 | ) 548 | print(f"Resized result shape: {result.shape}") 549 | 550 | # 归一化 551 | result = result.squeeze() # 移除多余的维度 552 | ma = torch.max(result) 553 | mi = torch.min(result) 554 | result = (result-mi)/(ma-mi) 555 | 556 | # 应用阈值 557 | if threshold > 0: 558 | result = (result > threshold).float() 559 | 560 | # 创建mask和结果图像 561 | alpha_mask = result.unsqueeze(0).unsqueeze(0) # 确保mask是 [1, 1, H, W] 562 | if isinstance(image, torch.Tensor): 563 | if image.dim() == 3: 564 | image = image.unsqueeze(0) 565 | masked_image = image * alpha_mask 566 | else: 567 | image_tensor = transforms.ToTensor()(image).unsqueeze(0) 568 | masked_image = image_tensor * alpha_mask 569 | 570 | # 发送完成状态 571 | PromptServer.instance.send_sync("matting_status", {"status": "completed"}) 572 | 573 | return (masked_image, alpha_mask) 574 | 575 | except Exception as e: 576 | # 发送错误状态 577 | PromptServer.instance.send_sync("matting_status", {"status": "error"}) 578 | raise e 579 | 580 | @classmethod 581 | def IS_CHANGED(cls, image, model_path, threshold, refinement): 582 | """检查输入是否改变""" 583 | m = hashlib.md5() 584 | m.update(str(image).encode()) 585 | m.update(str(model_path).encode()) 586 | m.update(str(threshold).encode()) 587 | m.update(str(refinement).encode()) 588 | return m.hexdigest() 589 | 590 | @PromptServer.instance.routes.post("/matting") 591 | async def matting(request): 592 | try: 593 | print("Received matting request") 594 | data = await request.json() 595 | 596 | # 取BiRefNet实例 597 | matting = BiRefNetMatting() 598 | 599 | # 处理图像数据,现在返回图像tensor和alpha通道 600 | image_tensor, original_alpha = convert_base64_to_tensor(data["image"]) 601 | print(f"Input image shape: {image_tensor.shape}") 602 | 603 | # 执行抠图 604 | matted_image, alpha_mask = matting.execute( 605 | image_tensor, 606 | "BiRefNet/model.safetensors", 607 | threshold=data.get("threshold", 0.5), 608 | refinement=data.get("refinement", 1) 609 | ) 610 | 611 | # 转换结果为base64,包含原始alpha信息 612 | result_image = convert_tensor_to_base64(matted_image, alpha_mask, original_alpha) 613 | result_mask = convert_tensor_to_base64(alpha_mask) 614 | 615 | return web.json_response({ 616 | "matted_image": result_image, 617 | "alpha_mask": result_mask 618 | }) 619 | 620 | except Exception as e: 621 | print(f"Error in matting endpoint: {str(e)}") 622 | import traceback 623 | traceback.print_exc() 624 | return web.json_response({ 625 | "error": str(e), 626 | "details": traceback.format_exc() 627 | }, status=500) 628 | 629 | def convert_base64_to_tensor(base64_str): 630 | """将base64图像数据转换为tensor,保留alpha通道""" 631 | import base64 632 | import io 633 | 634 | try: 635 | # 解码base64数据 636 | img_data = base64.b64decode(base64_str.split(',')[1]) 637 | img = Image.open(io.BytesIO(img_data)) 638 | 639 | # 保存原始alpha通道 640 | has_alpha = img.mode == 'RGBA' 641 | alpha = None 642 | if has_alpha: 643 | alpha = img.split()[3] 644 | # 创建白色背景 645 | background = Image.new('RGB', img.size, (255, 255, 255)) 646 | background.paste(img, mask=alpha) 647 | img = background 648 | elif img.mode != 'RGB': 649 | img = img.convert('RGB') 650 | 651 | # 转换为tensor 652 | transform = transforms.ToTensor() 653 | img_tensor = transform(img).unsqueeze(0) # [1, C, H, W] 654 | 655 | if has_alpha: 656 | # 将alpha转换为tensor并保存 657 | alpha_tensor = transforms.ToTensor()(alpha).unsqueeze(0) # [1, 1, H, W] 658 | return img_tensor, alpha_tensor 659 | 660 | return img_tensor, None 661 | 662 | except Exception as e: 663 | print(f"Error in convert_base64_to_tensor: {str(e)}") 664 | raise 665 | 666 | def convert_tensor_to_base64(tensor, alpha_mask=None, original_alpha=None): 667 | """将tensor转换为base64图像数据,支持alpha通道""" 668 | import base64 669 | import io 670 | 671 | try: 672 | # 确保tensor在CPU上 673 | tensor = tensor.cpu() 674 | 675 | # 处理维度 676 | if tensor.dim() == 4: 677 | tensor = tensor.squeeze(0) # 移除batch维度 678 | if tensor.dim() == 3 and tensor.shape[0] in [1, 3]: 679 | tensor = tensor.permute(1, 2, 0) 680 | 681 | # 转换为numpy数组并调整值范围到0-255 682 | img_array = (tensor.numpy() * 255).astype(np.uint8) 683 | 684 | # 如果有alpha遮罩和原始alpha 685 | if alpha_mask is not None and original_alpha is not None: 686 | # 将alpha_mask转换为正确的格式 687 | alpha_mask = alpha_mask.cpu().squeeze().numpy() 688 | alpha_mask = (alpha_mask * 255).astype(np.uint8) 689 | 690 | # 将原始alpha转换为正确的格式 691 | original_alpha = original_alpha.cpu().squeeze().numpy() 692 | original_alpha = (original_alpha * 255).astype(np.uint8) 693 | 694 | # 组合alpha_mask和original_alpha 695 | combined_alpha = np.minimum(alpha_mask, original_alpha) 696 | 697 | # 创建RGBA图像 698 | img = Image.fromarray(img_array, mode='RGB') 699 | alpha_img = Image.fromarray(combined_alpha, mode='L') 700 | img.putalpha(alpha_img) 701 | else: 702 | # 处理没有alpha通道的情况 703 | if img_array.shape[-1] == 1: 704 | img_array = img_array.squeeze(-1) 705 | img = Image.fromarray(img_array, mode='L') 706 | else: 707 | img = Image.fromarray(img_array, mode='RGB') 708 | 709 | # 转换为base64 710 | buffer = io.BytesIO() 711 | img.save(buffer, format='PNG') 712 | img_str = base64.b64encode(buffer.getvalue()).decode() 713 | 714 | return f"data:image/png;base64,{img_str}" 715 | 716 | except Exception as e: 717 | print(f"Error in convert_tensor_to_base64: {str(e)}") 718 | print(f"Tensor shape: {tensor.shape}, dtype: {tensor.dtype}") 719 | raise 720 | -------------------------------------------------------------------------------- /js/LassoTool.js: -------------------------------------------------------------------------------- 1 | // LassoTool.js - 封装套索工具功能 2 | 3 | export class LassoTool { 4 | constructor(canvas) { 5 | this.canvas = canvas; 6 | this.isActive = false; 7 | this.mode = 'new'; // 'new', 'add', 'subtract', 'restore' 8 | this.path = new Path2D(); 9 | this.points = []; 10 | this.isDrawing = false; 11 | this.targetLayer = null; // 存储当前作用的目标图层 12 | this.hasTempMask = false; // 标记是否有临时遮罩需要应用 13 | 14 | // 新增:图层锁定管理(参考钢笔工具) 15 | this.lockedLayer = null; // 锁定的图层 16 | this.originalSetSelectedLayer = null; // 原始图层选择方法 17 | 18 | // 临时画布用于预览 19 | this.tempCanvas = document.createElement('canvas'); 20 | this.tempCtx = this.tempCanvas.getContext('2d'); 21 | 22 | // 初始化临时画布大小 23 | this.updateCanvasSize(canvas.width, canvas.height); 24 | 25 | // 性能优化参数 26 | this.lastPointTime = 0; 27 | this.pointThrottleInterval = 10; // 毫秒,控制点的采样率 28 | this.minPointDistance = 5; // 最小点距离,用于抽样 29 | this.lastPoint = null; 30 | this.renderRequestId = null; 31 | this.pendingRender = false; 32 | 33 | // 防止意外合并 34 | this.autoMergeDisabled = true; // 默认禁用自动合并 35 | this.minPointsForValidPath = 5; // 有效路径的最小点数 36 | this.lastMouseMoveTime = 0; 37 | this.mouseMoveTimeout = null; 38 | this.mouseInactivityThreshold = 500; // 毫秒,鼠标不活动阈值 39 | 40 | // 撤销功能 - 保存原始状态 41 | this.originalStates = new Map(); // 存储每个图层的原始状态 42 | } 43 | 44 | // 保存图层的原始状态 45 | saveOriginalState(layer) { 46 | if (!layer || !layer.image) return; 47 | 48 | const layerId = this.getLayerId(layer); 49 | if (this.originalStates.has(layerId)) { 50 | return; // 已经保存过原始状态 51 | } 52 | 53 | console.log("保存图层原始状态:", layerId); 54 | 55 | // 保存原始图像和遮罩 56 | const originalState = { 57 | image: layer.image, 58 | mask: layer.mask ? new Float32Array(layer.mask) : null, 59 | maskCanvas: layer.maskCanvas, 60 | timestamp: Date.now() 61 | }; 62 | 63 | this.originalStates.set(layerId, originalState); 64 | } 65 | 66 | // 获取图层唯一ID 67 | getLayerId(layer) { 68 | // 使用图层在数组中的索引和一些属性来生成唯一ID 69 | const index = this.canvas.layers.indexOf(layer); 70 | return `layer_${index}_${layer.x}_${layer.y}_${layer.width}_${layer.height}`; 71 | } 72 | 73 | // 恢复图层到原始状态 74 | restoreOriginalState(layer) { 75 | if (!layer) return false; 76 | 77 | const layerId = this.getLayerId(layer); 78 | const originalState = this.originalStates.get(layerId); 79 | 80 | if (!originalState) { 81 | console.log("没有找到原始状态:", layerId); 82 | return false; 83 | } 84 | 85 | console.log("恢复图层到原始状态:", layerId); 86 | 87 | // 恢复原始图像 88 | layer.image = originalState.image; 89 | 90 | // 恢复原始遮罩 91 | if (originalState.mask) { 92 | layer.mask = new Float32Array(originalState.mask); 93 | layer.maskCanvas = originalState.maskCanvas; 94 | } else { 95 | // 移除遮罩 96 | delete layer.mask; 97 | if (layer.maskCanvas) { 98 | delete layer.maskCanvas; 99 | } 100 | } 101 | 102 | // 重新渲染 103 | this.canvas.render(); 104 | 105 | // 保存到服务器并更新节点 106 | this.canvas.saveToServer(this.canvas.widget.value).then(() => { 107 | if (this.canvas.node) { 108 | this.canvas.node.setDirtyCanvas(true); 109 | if (typeof app !== 'undefined') { 110 | app.graph.runStep(); 111 | } 112 | } 113 | }); 114 | 115 | return true; 116 | } 117 | 118 | // 清理过期的原始状态(可选,防止内存泄漏) 119 | cleanupOldStates(maxAge = 300000) { // 5分钟 120 | const now = Date.now(); 121 | for (const [layerId, state] of this.originalStates.entries()) { 122 | if (now - state.timestamp > maxAge) { 123 | this.originalStates.delete(layerId); 124 | console.log("清理过期状态:", layerId); 125 | } 126 | } 127 | } 128 | 129 | // 更新画布大小 130 | updateCanvasSize(width, height) { 131 | // 如果尺寸没有变化,不需要重新创建 132 | if (this.tempCanvas.width === width && this.tempCanvas.height === height) { 133 | return; 134 | } 135 | 136 | // 设置临时画布大小 137 | this.tempCanvas.width = width; 138 | this.tempCanvas.height = height; 139 | 140 | // 对于大尺寸画布,使用更高效的渲染设置 141 | if (width * height > 1000000) { // 例如 1000x1000 以上 142 | this.tempCtx.imageSmoothingEnabled = false; // 禁用抗锯齿提高性能 143 | this.pointThrottleInterval = 15; // 增加点采样间隔 144 | this.minPointDistance = 8; // 增加最小点距离 145 | } else { 146 | this.tempCtx.imageSmoothingEnabled = true; 147 | this.pointThrottleInterval = 10; 148 | this.minPointDistance = 5; 149 | } 150 | } 151 | 152 | // 启用/禁用套索工具 153 | toggle(active) { 154 | // 检查是否有选中的有效图层 155 | const selectedLayer = this.canvas.selectedLayer; 156 | if (active && (!selectedLayer || !selectedLayer.image)) { 157 | console.log("请先选择一个图层再使用套索工具"); 158 | return false; 159 | } 160 | 161 | // 如果要激活套索工具,先锁定当前图层 162 | if (active) { 163 | if (!this.lockCurrentLayer()) { 164 | return false; // 锁定失败,取消激活 165 | } 166 | } 167 | 168 | // 如果当前正在绘制且要关闭工具,先应用遮罩 169 | if (this.isActive && !active && this.isDrawing && this.points.length > this.minPointsForValidPath) { 170 | this.completeSelection(); 171 | this.isDrawing = false; 172 | } 173 | // 如果有临时遮罩并且要关闭工具,确保应用遮罩 174 | else if (this.isActive && !active && this.hasTempMask && this.targetLayer) { 175 | // 这里可以执行自定义逻辑来确保遮罩被应用,如果需要的话 176 | this.hasTempMask = false; 177 | } 178 | 179 | // 如果正在关闭套索工具,且目标图层有遮罩,则合并遮罩到图像 180 | if (this.isActive && !active && this.targetLayer && this.targetLayer.mask) { 181 | this.mergeLayerMask(this.targetLayer); 182 | } 183 | 184 | this.isActive = active; 185 | if (active) { 186 | // 记录当前作用的目标图层 187 | this.targetLayer = this.canvas.selectedLayer; 188 | // 保存目标图层的原始状态 189 | this.saveOriginalState(this.targetLayer); 190 | // 重置路径和点 191 | this.clearPath(); 192 | 193 | // 显示激活指示器 194 | this.showActivationIndicator(); 195 | } else { 196 | // 清除路径和点 197 | this.clearPath(); 198 | // 解锁图层 199 | this.unlockLayer(); 200 | // 保存当前作用的目标图层(这样在切换到其他图层时不会丢失) 201 | this.targetLayer = null; 202 | } 203 | 204 | // 清除任何待处理的超时 205 | if (this.mouseMoveTimeout) { 206 | clearTimeout(this.mouseMoveTimeout); 207 | this.mouseMoveTimeout = null; 208 | } 209 | 210 | return this.isActive; 211 | } 212 | 213 | // 新增:锁定当前图层(参考钢笔工具) 214 | lockCurrentLayer() { 215 | if (this.canvas.selectedLayer && this.canvas.selectedLayer.image) { 216 | this.lockedLayer = this.canvas.selectedLayer; 217 | 218 | // 使用简单有效的事件拦截方案 219 | this.interceptCanvasEvents(); 220 | 221 | console.log('🔒 Layer locked for lasso tool:', this.lockedLayer); 222 | return true; 223 | } else { 224 | alert('请先选择一个图像图层再激活套索工具'); 225 | return false; 226 | } 227 | } 228 | 229 | // 新增:解锁图层(参考钢笔工具) 230 | unlockLayer() { 231 | if (this.lockedLayer) { 232 | // 恢复Canvas的正常事件处理 233 | this.restoreCanvasEvents(); 234 | 235 | console.log('🔓 Layer unlocked by lasso tool:', this.lockedLayer); 236 | this.lockedLayer = null; 237 | } 238 | } 239 | 240 | // 新增:拦截Canvas事件(参考钢笔工具) 241 | interceptCanvasEvents() { 242 | // 保存Canvas原始的setSelectedLayer方法 243 | this.originalSetSelectedLayer = this.canvas.setSelectedLayer.bind(this.canvas); 244 | 245 | // 临时替换setSelectedLayer方法 246 | this.canvas.setSelectedLayer = (layer) => { 247 | // 如果套索工具激活且请求选择的不是锁定图层,忽略 248 | if (this.isActive && layer !== this.lockedLayer && layer !== null) { 249 | console.log('🚫 Layer selection blocked by lasso tool'); 250 | return; 251 | } 252 | 253 | // 允许选择锁定图层或清除选择 254 | this.originalSetSelectedLayer(layer); 255 | }; 256 | 257 | console.log('🛡️ Canvas events intercepted - lasso tool protected'); 258 | } 259 | 260 | // 新增:恢复Canvas事件(参考钢笔工具) 261 | restoreCanvasEvents() { 262 | if (this.originalSetSelectedLayer) { 263 | this.canvas.setSelectedLayer = this.originalSetSelectedLayer; 264 | this.originalSetSelectedLayer = null; 265 | } 266 | 267 | console.log('✅ Canvas events restored by lasso tool'); 268 | } 269 | 270 | // 新增:显示激活指示器(参考钢笔工具) 271 | showActivationIndicator() { 272 | const ctx = this.canvas.ctx; 273 | 274 | // 保存当前画布状态 275 | const imageData = ctx.getImageData(0, 0, this.canvas.width, this.canvas.height); 276 | 277 | // 绘制简单的激活提示 278 | ctx.save(); 279 | ctx.fillStyle = '#00ff00'; 280 | ctx.font = 'bold 16px Arial'; 281 | 282 | const layerName = this.lockedLayer ? this.lockedLayer.name || '图层' : '未知'; 283 | const message = `🎯 套索工具已激活 - 图层"${layerName}"已锁定`; 284 | 285 | // 靠左显示 286 | const x = 10; 287 | const y = 20; // 避免与钢笔工具提示重叠 288 | 289 | // 直接绘制文本 290 | ctx.fillText(message, x, y); 291 | 292 | ctx.restore(); 293 | 294 | console.log('🎯 Lasso tool activation indicator with layer lock info shown'); 295 | 296 | // 2秒后恢复原始画布状态 297 | setTimeout(() => { 298 | if (this.isActive) { 299 | ctx.putImageData(imageData, 0, 0); 300 | } 301 | }, 2000); 302 | } 303 | 304 | // 清除套索路径 305 | clearPath() { 306 | this.path = new Path2D(); 307 | this.points = []; 308 | this.lastPoint = null; 309 | this.tempCtx.clearRect(0, 0, this.tempCanvas.width, this.tempCanvas.height); 310 | 311 | // 取消任何待处理的渲染请求 312 | if (this.renderRequestId) { 313 | cancelAnimationFrame(this.renderRequestId); 314 | this.renderRequestId = null; 315 | } 316 | this.pendingRender = false; 317 | 318 | return true; 319 | } 320 | 321 | // 设置套索模式 322 | setMode(mode) { 323 | if (['new', 'add', 'subtract', 'restore'].includes(mode)) { 324 | this.mode = mode; 325 | 326 | // 如果选择恢复模式,立即执行恢复操作 327 | if (mode === 'restore' && this.targetLayer) { 328 | if (this.restoreOriginalState(this.targetLayer)) { 329 | console.log("已恢复到原始状态"); 330 | } else { 331 | console.log("无法恢复:没有保存的原始状态"); 332 | } 333 | // 恢复后重置模式为新建 334 | this.mode = 'new'; 335 | // 更新UI中的选择器 336 | const modeSelect = this.canvas.lassoModeSelect; 337 | if (modeSelect) { 338 | modeSelect.value = 'new'; 339 | } 340 | } 341 | 342 | return true; 343 | } 344 | return false; 345 | } 346 | 347 | // 开始绘制 348 | startDrawing(x, y) { 349 | if (!this.isActive) return false; 350 | 351 | // 确保有选中的有效图层 352 | if (!this.targetLayer || !this.targetLayer.image) { 353 | console.log("目标图层无效,无法使用套索工具"); 354 | return false; 355 | } 356 | 357 | // 确保已保存原始状态 358 | this.saveOriginalState(this.targetLayer); 359 | 360 | this.isDrawing = true; 361 | this.path = new Path2D(); 362 | this.points = [{x, y}]; 363 | this.lastPoint = {x, y}; 364 | this.path.moveTo(x, y); 365 | this.lastPointTime = Date.now(); 366 | this.lastMouseMoveTime = Date.now(); 367 | 368 | // 重置防止意外合并的状态 369 | if (this.mouseMoveTimeout) { 370 | clearTimeout(this.mouseMoveTimeout); 371 | } 372 | 373 | return true; 374 | } 375 | 376 | // 计算两点之间的距离 377 | calculateDistance(point1, point2) { 378 | return Math.sqrt( 379 | Math.pow(point2.x - point1.x, 2) + 380 | Math.pow(point2.y - point1.y, 2) 381 | ); 382 | } 383 | 384 | // 绘制过程 385 | continueDrawing(x, y) { 386 | if (!this.isActive || !this.isDrawing) return false; 387 | 388 | const now = Date.now(); 389 | this.lastMouseMoveTime = now; 390 | 391 | // 清除任何现有的超时 392 | if (this.mouseMoveTimeout) { 393 | clearTimeout(this.mouseMoveTimeout); 394 | } 395 | 396 | // 设置新的超时,如果鼠标停止移动超过阈值时间,自动完成选择 397 | this.mouseMoveTimeout = setTimeout(() => { 398 | if (this.isDrawing && this.points.length > this.minPointsForValidPath) { 399 | console.log("检测到鼠标不活动,自动完成选择"); 400 | this.endDrawing(); 401 | } 402 | }, this.mouseInactivityThreshold); 403 | 404 | // 点采样 - 基于时间和距离 405 | if (this.lastPoint && 406 | (now - this.lastPointTime < this.pointThrottleInterval || 407 | this.calculateDistance(this.lastPoint, {x, y}) < this.minPointDistance)) { 408 | return true; // 跳过这个点,但返回true表示继续绘制 409 | } 410 | 411 | // 更新最后点的时间和位置 412 | this.lastPointTime = now; 413 | this.lastPoint = {x, y}; 414 | 415 | // 添加点并更新路径 416 | this.path.lineTo(x, y); 417 | this.points.push({x, y}); 418 | 419 | // 使用请求动画帧优化渲染 420 | if (!this.pendingRender) { 421 | this.pendingRender = true; 422 | this.renderRequestId = requestAnimationFrame(() => { 423 | this.drawPreview(); 424 | this.pendingRender = false; 425 | this.renderRequestId = null; 426 | }); 427 | } 428 | 429 | this.hasTempMask = true; // 标记有临时遮罩需要应用 430 | return true; 431 | } 432 | 433 | // 结束绘制 434 | endDrawing() { 435 | if (!this.isActive || !this.isDrawing) return false; 436 | 437 | // 清除鼠标超时 438 | if (this.mouseMoveTimeout) { 439 | clearTimeout(this.mouseMoveTimeout); 440 | this.mouseMoveTimeout = null; 441 | } 442 | 443 | this.isDrawing = false; 444 | 445 | // 只有当点数足够时才完成选择 446 | if (this.points.length > this.minPointsForValidPath) { 447 | // 确保路径闭合 448 | if (this.lastPoint && this.points[0]) { 449 | this.path.lineTo(this.points[0].x, this.points[0].y); 450 | } 451 | 452 | // 完成最后一次渲染 453 | if (this.renderRequestId) { 454 | cancelAnimationFrame(this.renderRequestId); 455 | this.renderRequestId = null; 456 | } 457 | this.drawPreview(true); // 强制立即渲染 458 | 459 | // 应用选择 460 | this.completeSelection(); 461 | this.hasTempMask = false; // 已经应用了遮罩,重置标记 462 | return true; 463 | } else { 464 | // 点数不够,清除路径 465 | this.clearPath(); 466 | return false; 467 | } 468 | } 469 | 470 | // 绘制预览 471 | drawPreview(forceRender = false) { 472 | // 确保有选中的有效图层 473 | if (!this.targetLayer || !this.targetLayer.image) return; 474 | 475 | // 清除临时画布 476 | this.tempCtx.clearRect(0, 0, this.tempCanvas.width, this.tempCanvas.height); 477 | 478 | // 如果有现有蒙版且不是新建模式,先绘制现有蒙版 479 | if (this.targetLayer.mask && this.mode !== 'new') { 480 | this.drawExistingMask(); 481 | } 482 | 483 | // 设置套索路径样式 484 | this.tempCtx.strokeStyle = '#00ff00'; 485 | this.tempCtx.lineWidth = 1; 486 | this.tempCtx.setLineDash([5, 5]); 487 | 488 | // 创建并闭合套索路径 489 | const lassoPath = new Path2D(this.path); 490 | if (this.points.length > 2) { 491 | // 只在结束绘制时闭合路径,否则保持开放状态 492 | if (!this.isDrawing || forceRender) { 493 | lassoPath.closePath(); 494 | } 495 | } 496 | 497 | // 根据不同模式设置不同的预览效果 498 | this.tempCtx.save(); 499 | switch (this.mode) { 500 | case 'new': 501 | // 新建模式:简单显示选区 502 | this.tempCtx.fillStyle = 'rgba(0, 255, 0, 0.2)'; 503 | this.tempCtx.fill(lassoPath); 504 | break; 505 | 506 | case 'add': 507 | // 添加模式:显示绿色半透明 508 | this.tempCtx.fillStyle = 'rgba(0, 255, 0, 0.3)'; 509 | this.tempCtx.globalCompositeOperation = 'source-over'; 510 | this.tempCtx.fill(lassoPath); 511 | break; 512 | 513 | case 'subtract': 514 | // 减去模式:显示红色半透明 515 | this.tempCtx.fillStyle = 'rgba(255, 0, 0, 0.3)'; 516 | this.tempCtx.globalCompositeOperation = 'source-over'; 517 | this.tempCtx.fill(lassoPath); 518 | break; 519 | } 520 | 521 | // 绘制路径轮廓 522 | this.tempCtx.strokeStyle = this.mode === 'subtract' ? '#ff0000' : '#00ff00'; 523 | this.tempCtx.stroke(lassoPath); 524 | this.tempCtx.restore(); 525 | 526 | // 触发画布重绘 527 | this.canvas.render(); 528 | } 529 | 530 | // 绘制现有蒙版 531 | drawExistingMask() { 532 | const layer = this.targetLayer; 533 | if (!layer || !layer.mask) return; 534 | 535 | // 使用缓存的maskCanvas如果存在 536 | if (layer.maskCanvas) { 537 | this.tempCtx.save(); 538 | this.tempCtx.globalAlpha = 0.5; 539 | this.tempCtx.drawImage( 540 | layer.maskCanvas, 541 | layer.x, 542 | layer.y, 543 | layer.width, 544 | layer.height 545 | ); 546 | this.tempCtx.restore(); 547 | return; 548 | } 549 | 550 | // 否则创建新的maskCanvas 551 | const maskCanvas = document.createElement('canvas'); 552 | maskCanvas.width = layer.width; 553 | maskCanvas.height = layer.height; 554 | const maskCtx = maskCanvas.getContext('2d'); 555 | 556 | // 将Float32Array蒙版数据转换为ImageData - 使用更高效的批处理 557 | const imageData = maskCtx.createImageData(maskCanvas.width, maskCanvas.height); 558 | const mask = layer.mask; 559 | const data = imageData.data; 560 | 561 | // 批量处理数据 562 | const length = Math.min(mask.length, data.length / 4); 563 | for (let i = 0; i < length; i++) { 564 | const index = i * 4; 565 | const alpha = Math.round(mask[i] * 255); 566 | data[index] = 255; 567 | data[index + 1] = 255; 568 | data[index + 2] = 255; 569 | data[index + 3] = alpha; 570 | } 571 | 572 | maskCtx.putImageData(imageData, 0, 0); 573 | 574 | // 缓存maskCanvas 575 | layer.maskCanvas = maskCanvas; 576 | 577 | // 将蒙版绘制到临时画布上 578 | this.tempCtx.save(); 579 | this.tempCtx.globalAlpha = 0.5; 580 | this.tempCtx.drawImage( 581 | maskCanvas, 582 | layer.x, 583 | layer.y, 584 | layer.width, 585 | layer.height 586 | ); 587 | this.tempCtx.restore(); 588 | } 589 | 590 | // 检查图层选择变化并更新目标图层 591 | checkLayerChange() { 592 | // 在锁定模式下,检查是否试图选择其他图层 593 | if (this.isActive && this.lockedLayer && this.canvas.selectedLayer !== this.lockedLayer) { 594 | // 如果当前还有绘制中的遮罩,先完成它 595 | if (this.isDrawing && this.points.length > this.minPointsForValidPath) { 596 | this.completeSelection(); 597 | } 598 | 599 | // 由于图层已锁定,不应该发生图层切换 600 | // 如果发生了,说明锁定机制被绕过,强制恢复到锁定图层 601 | console.log("🚫 Attempt to change layer blocked - restoring locked layer"); 602 | this.canvas.setSelectedLayer(this.lockedLayer); 603 | return false; 604 | } 605 | 606 | // 如果工具激活但没有锁定图层(异常情况),关闭工具 607 | if (this.isActive && !this.lockedLayer) { 608 | console.log("⚠️ Lasso tool active but no locked layer - deactivating"); 609 | this.toggle(false); 610 | return true; 611 | } 612 | 613 | // 正常情况下,锁定的图层应该保持为目标图层 614 | if (this.isActive && this.lockedLayer) { 615 | this.targetLayer = this.lockedLayer; 616 | } 617 | 618 | return false; 619 | } 620 | 621 | // 完成选择并应用蒙版 622 | completeSelection() { 623 | // 确保使用存储的目标图层,而不是当前选中的图层 624 | const layer = this.targetLayer; 625 | if (!layer || !layer.image) return; 626 | 627 | console.log(`完成选择,处理 ${this.points.length} 个点`); 628 | 629 | try { 630 | // 创建临时画布,大小与图层一致 631 | const tempCanvas = document.createElement('canvas'); 632 | tempCanvas.width = layer.width; 633 | tempCanvas.height = layer.height; 634 | const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); 635 | 636 | // 创建用于变换的临时画布 637 | const transformCanvas = document.createElement('canvas'); 638 | transformCanvas.width = this.canvas.width; 639 | transformCanvas.height = this.canvas.height; 640 | const transformCtx = transformCanvas.getContext('2d', { willReadFrequently: true }); 641 | 642 | // 绘制套索路径 643 | transformCtx.save(); 644 | transformCtx.fillStyle = '#ffffff'; 645 | const closedPath = new Path2D(this.path); 646 | closedPath.closePath(); 647 | transformCtx.fill(closedPath); 648 | transformCtx.restore(); 649 | 650 | // 获取变换后的蒙版数据 651 | const transformedMask = transformCtx.getImageData(0, 0, this.canvas.width, this.canvas.height); 652 | 653 | // 将蒙版转换到图层坐标系 654 | tempCtx.save(); 655 | tempCtx.clearRect(0, 0, tempCanvas.width, tempCanvas.height); 656 | 657 | // 应用反向变换 658 | const centerX = layer.x + layer.width/2; 659 | const centerY = layer.y + layer.height/2; 660 | 661 | tempCtx.translate(tempCanvas.width/2, tempCanvas.height/2); 662 | if (layer.rotation) { 663 | tempCtx.rotate(-layer.rotation * Math.PI / 180); 664 | } 665 | 666 | // 计算缩放比例 667 | const scaleX = tempCanvas.width / layer.width; 668 | const scaleY = tempCanvas.height / layer.height; 669 | tempCtx.scale(scaleX, scaleY); 670 | 671 | // 绘制变换后的蒙版 672 | tempCtx.drawImage( 673 | transformCanvas, 674 | -this.canvas.width/2 + (this.canvas.width/2 - centerX), 675 | -this.canvas.height/2 + (this.canvas.height/2 - centerY), 676 | this.canvas.width, 677 | this.canvas.height 678 | ); 679 | tempCtx.restore(); 680 | 681 | // 获取图层坐标系下的蒙版数据 682 | const layerMaskData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height); 683 | 684 | // 创建或获取现有蒙版 685 | let currentMask; 686 | if (!layer.mask || this.mode === 'new') { 687 | // 新建模式或没有现有蒙版时,创建新的蒙版 688 | currentMask = new Float32Array(tempCanvas.width * tempCanvas.height).fill(0); 689 | } else { 690 | // 添加或减去模式,复制现有蒙版 691 | currentMask = new Float32Array(layer.mask); 692 | } 693 | 694 | // 合并蒙版 - 使用批处理优化 695 | const maskData = layerMaskData.data; 696 | const length = Math.min(maskData.length / 4, currentMask.length); 697 | 698 | switch (this.mode) { 699 | case 'new': 700 | for (let i = 0; i < length; i++) { 701 | currentMask[i] = maskData[i * 4 + 3] / 255; 702 | } 703 | break; 704 | case 'add': 705 | for (let i = 0; i < length; i++) { 706 | const newAlpha = maskData[i * 4 + 3] / 255; 707 | currentMask[i] = Math.min(1, currentMask[i] + newAlpha); 708 | } 709 | break; 710 | case 'subtract': 711 | for (let i = 0; i < length; i++) { 712 | const newAlpha = maskData[i * 4 + 3] / 255; 713 | currentMask[i] = Math.max(0, currentMask[i] - newAlpha); 714 | } 715 | break; 716 | } 717 | 718 | // 更新图层蒙版 719 | layer.mask = currentMask; 720 | 721 | // 创建并保存蒙版画布 722 | const maskCanvas = document.createElement('canvas'); 723 | maskCanvas.width = tempCanvas.width; 724 | maskCanvas.height = tempCanvas.height; 725 | const maskCtx = maskCanvas.getContext('2d'); 726 | 727 | // 将Float32Array转换为ImageData - 使用批处理 728 | const maskImageData = maskCtx.createImageData(maskCanvas.width, maskCanvas.height); 729 | const imgData = maskImageData.data; 730 | 731 | for (let i = 0; i < currentMask.length; i++) { 732 | const index = i * 4; 733 | const alpha = Math.round(currentMask[i] * 255); 734 | imgData[index] = 255; 735 | imgData[index + 1] = 255; 736 | imgData[index + 2] = 255; 737 | imgData[index + 3] = alpha; 738 | } 739 | 740 | maskCtx.putImageData(maskImageData, 0, 0); 741 | layer.maskCanvas = maskCanvas; 742 | 743 | console.log("选择完成,蒙版已应用"); 744 | } catch (error) { 745 | console.error("套索工具应用选择时出错:", error); 746 | } 747 | 748 | // 清除临时路径 749 | this.clearPath(); 750 | 751 | // 强制重新渲染 752 | this.canvas.render(); 753 | } 754 | 755 | // 清除当前图层的遮罩/透明度 756 | clearMask() { 757 | // 先尝试使用目标图层,如果不存在则使用当前选中的图层 758 | const layer = this.targetLayer || this.canvas.selectedLayer; 759 | if (!layer || !layer.image) return false; 760 | 761 | // 检查是否有旧式mask属性 762 | if (layer.mask) { 763 | // 移除遮罩数据 764 | delete layer.mask; 765 | 766 | // 移除遮罩画布 767 | if (layer.maskCanvas) { 768 | delete layer.maskCanvas; 769 | } 770 | 771 | // 重新渲染画布 772 | this.canvas.render(); 773 | 774 | // 保存到服务器并更新节点 775 | this.canvas.saveToServer(this.canvas.widget.value).then(() => { 776 | if (this.canvas.node) { 777 | this.canvas.node.setDirtyCanvas(true); 778 | if (typeof app !== 'undefined') { 779 | app.graph.runStep(); 780 | } 781 | } 782 | }); 783 | 784 | return true; 785 | } 786 | 787 | // 处理图像中的透明度 788 | // 创建临时画布,用于移除图像透明度 789 | const tempCanvas = document.createElement('canvas'); 790 | tempCanvas.width = layer.width; 791 | tempCanvas.height = layer.height; 792 | const tempCtx = tempCanvas.getContext('2d'); 793 | 794 | // 首先设置白色背景(可选) 795 | tempCtx.fillStyle = '#ffffff'; 796 | tempCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height); 797 | 798 | // 绘制图像 799 | tempCtx.drawImage(layer.image, 0, 0, tempCanvas.width, tempCanvas.height); 800 | 801 | // 获取图像数据 802 | const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height); 803 | 804 | // 移除透明度,将所有像素的Alpha通道设置为255(完全不透明) 805 | const data = imageData.data; 806 | for (let i = 3; i < data.length; i += 4) { 807 | data[i] = 255; // 设置为完全不透明 808 | } 809 | 810 | // 将修改后的图像数据放回画布 811 | tempCtx.putImageData(imageData, 0, 0); 812 | 813 | // 创建新图像对象 814 | const newImage = new Image(); 815 | newImage.onload = () => { 816 | // 替换图层的图像 817 | layer.image = newImage; 818 | 819 | // 重新渲染画布 820 | this.canvas.render(); 821 | 822 | // 保存到服务器并更新节点 823 | this.canvas.saveToServer(this.canvas.widget.value).then(() => { 824 | if (this.canvas.node) { 825 | this.canvas.node.setDirtyCanvas(true); 826 | if (typeof app !== 'undefined') { 827 | app.graph.runStep(); 828 | } 829 | } 830 | }); 831 | }; 832 | 833 | // 将画布转换为数据URL并加载到新图像 834 | newImage.src = tempCanvas.toDataURL('image/png'); 835 | 836 | return true; 837 | } 838 | 839 | // 获取临时画布,用于在主画布上绘制 840 | getTempCanvas() { 841 | return this.tempCanvas; 842 | } 843 | 844 | // 将图层遮罩合并到图像的Alpha通道中 845 | mergeLayerMask(layer) { 846 | if (!layer || !layer.image || !layer.mask) return; 847 | 848 | // 创建一个新的画布用于合并图像和遮罩 849 | const mergedCanvas = document.createElement('canvas'); 850 | mergedCanvas.width = layer.width; 851 | mergedCanvas.height = layer.height; 852 | const mergedCtx = mergedCanvas.getContext('2d'); 853 | 854 | // 首先绘制原始图像 855 | mergedCtx.drawImage( 856 | layer.image, 857 | 0, 0, 858 | layer.width, layer.height 859 | ); 860 | 861 | // 获取图像数据以修改alpha通道 862 | const imageData = mergedCtx.getImageData(0, 0, layer.width, layer.height); 863 | 864 | // 应用遮罩到alpha通道 - 使用批处理优化 865 | const data = imageData.data; 866 | const mask = layer.mask; 867 | const length = Math.min(mask.length, data.length / 4); 868 | 869 | for (let i = 0; i < length; i++) { 870 | const pixelIndex = i * 4 + 3; // alpha通道索引 871 | // 确保遮罩值在0-1范围内 872 | const maskValue = Math.max(0, Math.min(1, mask[i])); 873 | // 使用遮罩值和原始alpha值相乘,维持透明度 874 | data[pixelIndex] = Math.round(maskValue * data[pixelIndex]); 875 | } 876 | 877 | // 将修改后的图像数据放回画布 878 | mergedCtx.putImageData(imageData, 0, 0); 879 | 880 | // 创建一个新的Image对象并设置为带Alpha通道的图像 881 | const newImage = new Image(); 882 | newImage.onload = () => { 883 | // 替换图层的原始图像 884 | layer.image = newImage; 885 | 886 | // 清除遮罩数据,因为它已经合并到图像中 887 | delete layer.mask; 888 | if (layer.maskCanvas) { 889 | delete layer.maskCanvas; 890 | } 891 | 892 | // 强制重新渲染 893 | this.canvas.render(); 894 | 895 | // 保存到服务器并更新节点 896 | this.canvas.saveToServer(this.canvas.widget.value).then(() => { 897 | if (this.canvas.node) { 898 | this.canvas.node.setDirtyCanvas(true); 899 | if (typeof app !== 'undefined') { 900 | app.graph.runStep(); 901 | } 902 | } 903 | }); 904 | }; 905 | 906 | // 将合并后的画布转换为数据URL并加载到新图像 907 | newImage.src = mergedCanvas.toDataURL('image/png'); 908 | } 909 | } -------------------------------------------------------------------------------- /js/Canvas_view.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../scripts/app.js"; 2 | import { api } from "../../scripts/api.js"; 3 | import { $el } from "../../scripts/ui.js"; 4 | import { Canvas } from "./Canvas.js"; 5 | import { LassoTool } from "./LassoTool.js"; 6 | import { CanvasBlendMode } from "./CanvasBlendMode.js"; 7 | import { PenTool } from "./PenTool.js"; 8 | 9 | async function createCanvasWidget(node, widget, app) { 10 | const canvas = new Canvas(node, widget); 11 | 12 | // 初始化钢笔工具 13 | const penTool = new PenTool(canvas); 14 | canvas.penTool = penTool; 15 | 16 | // 添加全局样式 17 | const style = document.createElement('style'); 18 | style.textContent = ` 19 | .painter-button { 20 | background: linear-gradient(to bottom, #4a4a4a, #3a3a3a); 21 | border: 1px solid #2a2a2a; 22 | border-radius: 4px; 23 | color: #ffffff; 24 | padding: 6px 12px; 25 | font-size: 12px; 26 | cursor: pointer; 27 | transition: all 0.2s ease; 28 | min-width: 80px; 29 | text-align: center; 30 | margin: 2px; 31 | text-shadow: 0 1px 1px rgba(0,0,0,0.2); 32 | } 33 | 34 | .painter-button:hover { 35 | background: linear-gradient(to bottom, #5a5a5a, #4a4a4a); 36 | box-shadow: 0 1px 3px rgba(0,0,0,0.2); 37 | } 38 | 39 | .painter-button:active { 40 | background: linear-gradient(to bottom, #3a3a3a, #4a4a4a); 41 | transform: translateY(1px); 42 | } 43 | 44 | .painter-button.primary { 45 | background: linear-gradient(to bottom, #4a6cd4, #3a5cc4); 46 | border-color: #2a4cb4; 47 | } 48 | 49 | .painter-button.primary:hover { 50 | background: linear-gradient(to bottom, #5a7ce4, #4a6cd4); 51 | } 52 | 53 | .painter-controls { 54 | background: linear-gradient(to bottom, #404040, #383838); 55 | border-bottom: 1px solid #2a2a2a; 56 | box-shadow: 0 2px 4px rgba(0,0,0,0.1); 57 | padding: 8px; 58 | display: flex; 59 | gap: 6px; 60 | flex-wrap: wrap; 61 | align-items: center; 62 | } 63 | 64 | .painter-container { 65 | background: #607080; /* 带蓝色的灰色背景 */ 66 | border: 1px solid #4a5a6a; 67 | border-radius: 6px; 68 | box-shadow: inset 0 0 10px rgba(0,0,0,0.1); 69 | } 70 | 71 | .painter-dialog { 72 | background: #404040; 73 | border-radius: 8px; 74 | box-shadow: 0 4px 12px rgba(0,0,0,0.3); 75 | padding: 20px; 76 | color: #ffffff; 77 | } 78 | 79 | .painter-dialog input { 80 | background: #303030; 81 | border: 1px solid #505050; 82 | border-radius: 4px; 83 | color: #ffffff; 84 | padding: 4px 8px; 85 | margin: 4px; 86 | width: 80px; 87 | } 88 | 89 | .painter-dialog button { 90 | background: #505050; 91 | border: 1px solid #606060; 92 | border-radius: 4px; 93 | color: #ffffff; 94 | padding: 4px 12px; 95 | margin: 4px; 96 | cursor: pointer; 97 | } 98 | 99 | .painter-dialog button:hover { 100 | background: #606060; 101 | } 102 | 103 | .blend-opacity-slider { 104 | width: 100%; 105 | margin: 5px 0; 106 | display: none; 107 | } 108 | 109 | .blend-mode-active .blend-opacity-slider { 110 | display: block; 111 | } 112 | 113 | .blend-mode-item { 114 | padding: 5px; 115 | cursor: pointer; 116 | position: relative; 117 | } 118 | 119 | .blend-mode-item.active { 120 | background-color: rgba(0,0,0,0.1); 121 | } 122 | 123 | .painter-button.active { 124 | background: #5a5a5a !important; 125 | box-shadow: inset 0 1px 3px rgba(0,0,0,0.2); 126 | } 127 | 128 | .lasso-mode-select { 129 | min-width: 80px; 130 | font-size: 12px; 131 | } 132 | 133 | .lasso-mode-select option { 134 | background: #3a3a3a; 135 | color: white; 136 | } 137 | 138 | /* 等比例缩放开关样式 */ 139 | #proportional-scale-toggle { 140 | position: relative; 141 | transition: all 0.3s ease; 142 | } 143 | 144 | #proportional-scale-toggle.active { 145 | background: linear-gradient(to bottom, #4a8c4a, #3a7c3a) !important; 146 | border-color: #2a6c2a !important; 147 | color: #ffffff !important; 148 | box-shadow: 0 0 8px rgba(74, 140, 74, 0.3); 149 | } 150 | 151 | #proportional-scale-toggle:hover::after { 152 | content: "开启后拖拽控制框将等比例缩放"; 153 | position: absolute; 154 | bottom: 100%; 155 | left: 50%; 156 | transform: translateX(-50%); 157 | background: #2a2a2a; 158 | color: white; 159 | padding: 4px 8px; 160 | border-radius: 4px; 161 | font-size: 11px; 162 | white-space: nowrap; 163 | z-index: 1000; 164 | margin-bottom: 5px; 165 | } 166 | `; 167 | document.head.appendChild(style); 168 | 169 | // 修改控制面板,使其高度自适应 170 | const controlPanel = $el("div.painterControlPanel", {}, [ 171 | $el("div.controls.painter-controls", { 172 | style: { 173 | position: "absolute", 174 | top: "0", 175 | left: "0", 176 | right: "0", 177 | minHeight: "50px", 178 | zIndex: "10", 179 | background: "linear-gradient(to bottom, #404040, #383838)", 180 | borderBottom: "1px solid #2a2a2a", 181 | boxShadow: "0 2px 4px rgba(0,0,0,0.1)", 182 | padding: "8px", 183 | display: "flex", 184 | gap: "6px", 185 | flexWrap: "wrap", 186 | alignItems: "center" 187 | }, 188 | onresize: (entries) => { 189 | const controlsHeight = entries[0].target.offsetHeight; 190 | canvasContainer.style.top = (controlsHeight + 10) + "px"; 191 | } 192 | }, [ 193 | $el("button.painter-button.primary", { 194 | textContent: "Add Image", 195 | onclick: () => { 196 | const input = document.createElement('input'); 197 | input.type = 'file'; 198 | input.accept = 'image/*'; 199 | input.multiple = true; 200 | input.onchange = async (e) => { 201 | for (const file of e.target.files) { 202 | // 创建图片对象 203 | const img = new Image(); 204 | img.onload = async () => { 205 | // 计算适当的缩放比例 206 | const scale = Math.min( 207 | canvas.width / img.width * 0.8, 208 | canvas.height / img.height * 0.8 209 | ); 210 | 211 | // 创建新图层 212 | const layer = { 213 | image: img, 214 | x: (canvas.width - img.width * scale) / 2, 215 | y: (canvas.height - img.height * scale) / 2, 216 | width: img.width * scale, 217 | height: img.height * scale, 218 | rotation: 0, 219 | zIndex: canvas.layers.length 220 | }; 221 | 222 | // 添加图层并选中 223 | canvas.layers.push(layer); 224 | canvas.selectedLayer = layer; 225 | 226 | // 渲染画布 227 | canvas.render(); 228 | 229 | // 立即保存并触发输出更新 230 | await canvas.saveToServer(widget.value); 231 | 232 | // 触发节点更新 233 | app.graph.runStep(); 234 | }; 235 | img.src = URL.createObjectURL(file); 236 | } 237 | }; 238 | input.click(); 239 | } 240 | }), 241 | $el("button.painter-button.primary", { 242 | textContent: "Import Input", 243 | onclick: async () => { 244 | try { 245 | console.log("Import Input clicked"); 246 | console.log("Node ID:", node.id); 247 | 248 | const response = await fetch(`/ycnode/get_canvas_data/${node.id}`); 249 | console.log("Response status:", response.status); 250 | 251 | const result = await response.json(); 252 | console.log("Full response data:", result); 253 | 254 | if (result.success && result.data) { 255 | if (result.data.image) { 256 | console.log("Found image data, importing..."); 257 | await canvas.importImage(result.data); 258 | await canvas.saveToServer(widget.value); 259 | app.graph.runStep(); 260 | } else { 261 | throw new Error("No image data found in cache"); 262 | } 263 | } else { 264 | throw new Error("Invalid response format"); 265 | } 266 | 267 | } catch (error) { 268 | console.error("Error importing input:", error); 269 | alert(`Failed to import input: ${error.message}`); 270 | } 271 | } 272 | }), 273 | $el("button.painter-button", { 274 | textContent: "Canvas Size", 275 | onclick: () => { 276 | const dialog = $el("div.painter-dialog", { 277 | style: { 278 | position: 'fixed', 279 | left: '50%', 280 | top: '50%', 281 | transform: 'translate(-50%, -50%)', 282 | zIndex: '1000' 283 | } 284 | }, [ 285 | $el("div", { 286 | style: { 287 | color: "white", 288 | marginBottom: "10px" 289 | } 290 | }, [ 291 | $el("label", { 292 | style: { 293 | marginRight: "5px" 294 | } 295 | }, [ 296 | $el("span", {}, ["Width: "]) 297 | ]), 298 | $el("input", { 299 | type: "number", 300 | id: "canvas-width", 301 | value: canvas.width, 302 | min: "1", 303 | max: "4096" 304 | }) 305 | ]), 306 | $el("div", { 307 | style: { 308 | color: "white", 309 | marginBottom: "10px" 310 | } 311 | }, [ 312 | $el("label", { 313 | style: { 314 | marginRight: "5px" 315 | } 316 | }, [ 317 | $el("span", {}, ["Height: "]) 318 | ]), 319 | $el("input", { 320 | type: "number", 321 | id: "canvas-height", 322 | value: canvas.height, 323 | min: "1", 324 | max: "4096" 325 | }) 326 | ]), 327 | $el("div", { 328 | style: { 329 | textAlign: "right" 330 | } 331 | }, [ 332 | $el("button", { 333 | id: "cancel-size", 334 | textContent: "Cancel" 335 | }), 336 | $el("button", { 337 | id: "confirm-size", 338 | textContent: "OK" 339 | }) 340 | ]) 341 | ]); 342 | document.body.appendChild(dialog); 343 | 344 | document.getElementById('confirm-size').onclick = () => { 345 | const width = parseInt(document.getElementById('canvas-width').value) || canvas.width; 346 | const height = parseInt(document.getElementById('canvas-height').value) || canvas.height; 347 | canvas.updateCanvasSize(width, height); 348 | document.body.removeChild(dialog); 349 | }; 350 | 351 | document.getElementById('cancel-size').onclick = () => { 352 | document.body.removeChild(dialog); 353 | }; 354 | } 355 | }), 356 | $el("button.painter-button", { 357 | textContent: "Remove Layer", 358 | onclick: () => { 359 | const index = canvas.layers.indexOf(canvas.selectedLayer); 360 | canvas.removeLayer(index); 361 | } 362 | }), 363 | $el("button.painter-button", { 364 | textContent: "Rotate +90°", 365 | onclick: () => canvas.rotateLayer(90) 366 | }), 367 | $el("button.painter-button", { 368 | textContent: "Scale +5%", 369 | onclick: () => canvas.resizeLayer(1.05) 370 | }), 371 | $el("button.painter-button", { 372 | textContent: "Scale -5%", 373 | onclick: () => canvas.resizeLayer(0.95) 374 | }), 375 | // 添加等比例缩放开关 376 | $el("button.painter-button", { 377 | textContent: "等比例", 378 | id: "proportional-scale-toggle", 379 | onclick: function() { 380 | const isEnabled = canvas.toggleProportionalScaling(); 381 | this.textContent = "等比例"; 382 | this.classList.toggle('active', isEnabled); 383 | 384 | // 添加视觉反馈 385 | if (isEnabled) { 386 | this.style.background = 'linear-gradient(to bottom, #4a8c4a, #3a7c3a)'; 387 | this.style.borderColor = '#2a6c2a'; 388 | } else { 389 | this.style.background = ''; 390 | this.style.borderColor = ''; 391 | } 392 | } 393 | }), 394 | $el("button.painter-button", { 395 | textContent: "Layer Up", 396 | onclick: async () => { 397 | canvas.moveLayerUp(); 398 | await canvas.saveToServer(widget.value); 399 | app.graph.runStep(); 400 | } 401 | }), 402 | $el("button.painter-button", { 403 | textContent: "Layer Down", 404 | onclick: async () => { 405 | canvas.moveLayerDown(); 406 | await canvas.saveToServer(widget.value); 407 | app.graph.runStep(); 408 | } 409 | }), 410 | // 添加水平镜像按钮 411 | $el("button.painter-button", { 412 | textContent: "Mirror H", 413 | onclick: () => { 414 | canvas.mirrorHorizontal(); 415 | } 416 | }), 417 | // 添加垂直镜像按钮 418 | $el("button.painter-button", { 419 | textContent: "Mirror V", 420 | onclick: () => { 421 | canvas.mirrorVertical(); 422 | } 423 | }), 424 | // 新增:复制图层按钮 425 | $el("button.painter-button", { 426 | textContent: "Copy Layer", 427 | style: { 428 | background: "linear-gradient(to bottom, #4a6c4a, #3a5c3a)", 429 | borderColor: "#2a4c2a" 430 | }, 431 | onclick: async () => { 432 | try { 433 | if (!canvas.selectedLayer) { 434 | alert("请先选择一个图层再进行复制"); 435 | return; 436 | } 437 | 438 | console.log("Duplicating selected layer..."); 439 | const duplicatedLayer = canvas.duplicateSelectedLayer(); 440 | 441 | if (duplicatedLayer) { 442 | // 保存并更新 443 | await canvas.saveToServer(widget.value); 444 | app.graph.runStep(); 445 | 446 | console.log("Layer duplicated and saved successfully"); 447 | } else { 448 | throw new Error("Failed to duplicate layer"); 449 | } 450 | 451 | } catch (error) { 452 | console.error("Copy layer error:", error); 453 | alert(`复制图层失败: ${error.message}`); 454 | } 455 | } 456 | }), 457 | // 新增:清除缓存按钮 458 | $el("button.painter-button", { 459 | textContent: "Clear Cache", 460 | style: { 461 | background: "linear-gradient(to bottom, #c44a4a, #b43a3a)", 462 | borderColor: "#a42a2a", 463 | color: "#ffffff" 464 | }, 465 | onclick: async () => { 466 | try { 467 | console.log("Clearing all cache data..."); 468 | canvas.clearAllCache(); 469 | 470 | // 保存空画布状态并更新 471 | await canvas.saveToServer(widget.value); 472 | app.graph.runStep(); 473 | 474 | console.log("Cache cleared and saved successfully"); 475 | 476 | } catch (error) { 477 | console.error("Clear cache error:", error); 478 | alert(`清除缓存失败: ${error.message}`); 479 | } 480 | } 481 | }), 482 | // 在控制面板中添加抠图按钮 483 | $el("button.painter-button", { 484 | textContent: "Matting", 485 | onclick: async () => { 486 | try { 487 | if (!canvas.selectedLayer) { 488 | throw new Error("Please select an image first"); 489 | } 490 | 491 | // 获取或创建状态指示器 492 | const statusIndicator = MattingStatusIndicator.getInstance(controlPanel.querySelector('.controls')); 493 | 494 | // 添加状态监听 495 | const updateStatus = (event) => { 496 | const {status} = event.detail; 497 | statusIndicator.setStatus(status); 498 | }; 499 | 500 | api.addEventListener("matting_status", updateStatus); 501 | 502 | try { 503 | // 获取图像据 504 | const imageData = await canvas.getLayerImageData(canvas.selectedLayer); 505 | console.log("Sending image to server..."); 506 | 507 | // 发送请求 508 | const response = await fetch("/matting", { 509 | method: "POST", 510 | headers: { 511 | "Content-Type": "application/json", 512 | }, 513 | body: JSON.stringify({ 514 | image: imageData, 515 | threshold: 0.5, 516 | refinement: 1 517 | }) 518 | }); 519 | 520 | if (!response.ok) { 521 | throw new Error(`Server error: ${response.status}`); 522 | } 523 | 524 | const result = await response.json(); 525 | console.log("Creating new layer with matting result..."); 526 | 527 | // 创建新图层 528 | const mattedImage = new Image(); 529 | mattedImage.onload = async () => { 530 | // 创建临时画布来处理透明度 531 | const tempCanvas = document.createElement('canvas'); 532 | const tempCtx = tempCanvas.getContext('2d'); 533 | tempCanvas.width = canvas.selectedLayer.width; 534 | tempCanvas.height = canvas.selectedLayer.height; 535 | 536 | // 绘制原始图像 537 | tempCtx.drawImage( 538 | mattedImage, 539 | 0, 0, 540 | tempCanvas.width, tempCanvas.height 541 | ); 542 | 543 | // 创建新图层 544 | const newImage = new Image(); 545 | newImage.onload = async () => { 546 | const newLayer = { 547 | image: newImage, 548 | x: canvas.selectedLayer.x, 549 | y: canvas.selectedLayer.y, 550 | width: canvas.selectedLayer.width, 551 | height: canvas.selectedLayer.height, 552 | rotation: canvas.selectedLayer.rotation, 553 | zIndex: canvas.layers.length + 1 554 | }; 555 | 556 | canvas.layers.push(newLayer); 557 | canvas.selectedLayer = newLayer; 558 | canvas.render(); 559 | 560 | // 保存并更新 561 | await canvas.saveToServer(widget.value); 562 | app.graph.runStep(); 563 | 564 | // 显示抠图完成提示 565 | alert("抠图完成!新图层已创建"); 566 | }; 567 | 568 | // 转换为PNG并保持透明度 569 | newImage.src = tempCanvas.toDataURL('image/png'); 570 | }; 571 | 572 | mattedImage.src = result.matted_image; 573 | console.log("Matting result applied successfully"); 574 | 575 | } finally { 576 | api.removeEventListener("matting_status", updateStatus); 577 | } 578 | 579 | } catch (error) { 580 | console.error("Matting error:", error); 581 | alert(`Error during matting process: ${error.message}`); 582 | } 583 | } 584 | }), 585 | // 添加套索工具按钮组 586 | $el("div.lasso-tools", { 587 | style: { 588 | display: "flex", 589 | gap: "4px", 590 | alignItems: "center", 591 | padding: "0 8px", 592 | borderLeft: "1px solid #505050" 593 | } 594 | }, [ 595 | // 套索工具按钮 596 | $el("button.painter-button", { 597 | textContent: "套索工具", 598 | style: { 599 | background: "#3a3a3a" 600 | }, 601 | onclick: async () => { 602 | const button = event.target; 603 | 604 | // 检查是否有选中的有效图层 605 | if (!canvas.selectedLayer || !canvas.selectedLayer.image) { 606 | alert("请先选择一个图层再使用套索工具"); 607 | return; 608 | } 609 | 610 | const isActive = button.classList.toggle('active'); 611 | button.style.background = isActive ? '#5a5a5a' : '#3a3a3a'; 612 | 613 | // 使用 toggle 方法激活套索工具 614 | if (canvas.lassoTool) { 615 | const result = canvas.lassoTool.toggle(isActive); 616 | // 显示/隐藏模式选择器 617 | const modeSelect = button.parentElement.querySelector('.lasso-mode-select'); 618 | if (modeSelect) { 619 | modeSelect.style.display = result ? 'block' : 'none'; 620 | } 621 | 622 | // 使用toggle方法时的遮罩自动合并和保存已经在LassoTool内部处理了 623 | // 不需要额外的处理逻辑 624 | } 625 | } 626 | }), 627 | 628 | // 套索模式选择器 629 | $el("select.lasso-mode-select.painter-button", { 630 | style: { 631 | display: "none", 632 | padding: "4px", 633 | background: "#3a3a3a", 634 | border: "1px solid #4a4a4a", 635 | color: "white", 636 | borderRadius: "3px", 637 | cursor: "pointer" 638 | }, 639 | onchange: (e) => { 640 | if (canvas.lassoTool) { 641 | canvas.lassoTool.setMode(e.target.value); 642 | } 643 | } 644 | }, [ 645 | $el("option", { value: "new", textContent: "新建" }), 646 | $el("option", { value: "add", textContent: "添加" }), 647 | $el("option", { value: "subtract", textContent: "减去" }), 648 | $el("option", { value: "restore", textContent: "恢复原图" }) 649 | ]), 650 | 651 | // 钢笔工具按钮(移动到套索工具组内部) 652 | $el("button.painter-button", { 653 | textContent: "钢笔工具", 654 | style: { 655 | background: "#3a3a3a", 656 | marginLeft: "8px" // 与套索工具保持一定间距 657 | }, 658 | onclick: async () => { 659 | const button = event.target; 660 | const isActive = button.classList.toggle('active'); 661 | button.style.background = isActive ? '#5a5a5a' : '#3a3a3a'; 662 | 663 | // 停用其他工具 664 | if (isActive) { 665 | // 停用套索工具 666 | const lassoButton = button.parentElement.querySelector('button.painter-button'); 667 | if (lassoButton && lassoButton !== button && lassoButton.classList.contains('active')) { 668 | lassoButton.click(); 669 | } 670 | 671 | // 激活钢笔工具 672 | canvas.penTool.activate(); 673 | 674 | // 显示钢笔工具设置 675 | const penSettings = button.parentElement.querySelector('.pen-tool-panel'); 676 | if (penSettings) { 677 | penSettings.style.display = 'flex'; 678 | } 679 | 680 | // 显示路径状态面板 681 | const statusPanel = button.parentElement.querySelector('.pen-status-panel'); 682 | if (statusPanel) { 683 | statusPanel.style.display = 'block'; 684 | } 685 | 686 | // 设置状态更新回调 687 | const updatePathStatus = () => { 688 | const status = canvas.penTool.getPathStatus(); 689 | const currentPathElement = statusPanel.querySelector('.current-path-status'); 690 | const brokenPathsElement = statusPanel.querySelector('.broken-paths-status'); 691 | 692 | // 更新当前路径状态 693 | if (status.currentPath) { 694 | const modeText = { 695 | 'add': '添加', 696 | 'subtract': '减去', 697 | 'intersect': '相交', 698 | 'replace': '替换' 699 | }[status.currentPath.blendMode] || status.currentPath.blendMode; 700 | 701 | currentPathElement.textContent = `当前路径: ${status.currentPath.points}点 (${modeText}模式)`; 702 | } else { 703 | currentPathElement.textContent = "当前路径: 无"; 704 | } 705 | 706 | // 更新断开路径状态 707 | if (status.brokenPaths.length > 0) { 708 | const pathsText = status.brokenPaths.map(p => { 709 | const modeText = { 710 | 'add': '添加', 711 | 'subtract': '减去', 712 | 'intersect': '相交', 713 | 'replace': '替换' 714 | }[p.blendMode] || p.blendMode; 715 | return `${p.name}(${p.points}点,${modeText})`; 716 | }).join(', '); 717 | brokenPathsElement.textContent = `断开路径: ${pathsText}`; 718 | } else { 719 | brokenPathsElement.textContent = "断开路径: 无"; 720 | } 721 | }; 722 | 723 | // 设置回调并立即更新 724 | canvas.penTool.setPathStatusChangeCallback(updatePathStatus); 725 | updatePathStatus(); 726 | 727 | } else { 728 | // 停用钢笔工具 729 | canvas.penTool.deactivate(); 730 | 731 | // 隐藏钢笔工具设置 732 | const penSettings = button.parentElement.querySelector('.pen-tool-panel'); 733 | if (penSettings) { 734 | penSettings.style.display = 'none'; 735 | } 736 | 737 | // 隐藏路径状态面板 738 | const statusPanel = button.parentElement.querySelector('.pen-status-panel'); 739 | if (statusPanel) { 740 | statusPanel.style.display = 'none'; 741 | } 742 | 743 | // 清除状态更新回调 744 | canvas.penTool.setPathStatusChangeCallback(null); 745 | } 746 | } 747 | }), 748 | 749 | // 钢笔工具设置面板 750 | $el("div.pen-tool-panel", { 751 | style: { 752 | display: "none", 753 | marginLeft: "10px", 754 | padding: "2px 8px", 755 | border: "1px solid #4a4a4a", 756 | borderRadius: "4px", 757 | backgroundColor: "#3a3a3a", 758 | height: "32px", 759 | alignItems: "center", 760 | gap: "8px" 761 | } 762 | }, [ 763 | $el("label", { textContent: "颜色:", style: { color: "white", fontSize: "12px" } }), 764 | $el("input", { 765 | type: "color", 766 | value: "#ff0000", 767 | style: { width: "24px", height: "24px", border: "none", borderRadius: "2px" }, 768 | onchange: function(e) { 769 | canvas.penTool.setStrokeColor(e.target.value); 770 | } 771 | }), 772 | $el("label", { textContent: "宽度:", style: { color: "white", fontSize: "12px", marginLeft: "8px" } }), 773 | $el("input", { 774 | type: "range", 775 | min: "1", 776 | max: "10", 777 | value: "2", 778 | style: { width: "60px" }, 779 | onchange: function(e) { 780 | canvas.penTool.setStrokeWidth(parseInt(e.target.value)); 781 | } 782 | }), 783 | $el("label", { textContent: "模式:", style: { color: "white", fontSize: "12px", marginLeft: "8px" } }), 784 | $el("select", { 785 | style: { 786 | fontSize: "11px", 787 | padding: "2px 4px", 788 | backgroundColor: "#2a2a2a", 789 | color: "white", 790 | border: "1px solid #4a4a4a", 791 | borderRadius: "2px" 792 | }, 793 | onchange: function(e) { 794 | canvas.penTool.setBlendMode(e.target.value); 795 | } 796 | }, [ 797 | $el("option", { value: "add", textContent: "添加" }), 798 | $el("option", { value: "subtract", textContent: "减去" }), 799 | $el("option", { value: "intersect", textContent: "相交" }), 800 | $el("option", { value: "replace", textContent: "替换" }) 801 | ]), 802 | $el("button.painter-button", { 803 | textContent: "断开", 804 | style: { fontSize: "11px", padding: "4px 8px", marginLeft: "8px", height: "24px" }, 805 | onclick: function() { 806 | // 断开当前路径 807 | canvas.penTool.breakCurrentPath(); 808 | // 重新渲染画布 809 | canvas.render(); 810 | } 811 | }), 812 | $el("button.painter-button", { 813 | textContent: "清除", 814 | style: { fontSize: "11px", padding: "4px 8px", marginLeft: "2px", height: "24px" }, 815 | onclick: function() { 816 | // 清除钢笔工具的所有路径 817 | canvas.penTool.clearAllPaths(); 818 | // 重新渲染画布 819 | canvas.render(); 820 | } 821 | }) 822 | ]), 823 | 824 | // 路径状态显示面板 825 | $el("div.pen-status-panel", { 826 | style: { 827 | display: "none", 828 | marginLeft: "10px", 829 | marginTop: "5px", 830 | padding: "4px 8px", 831 | border: "1px solid #4a4a4a", 832 | borderRadius: "4px", 833 | backgroundColor: "#2a2a2a", 834 | fontSize: "11px", 835 | color: "#cccccc", 836 | maxWidth: "400px" 837 | } 838 | }, [ 839 | $el("div.current-path-status", { 840 | textContent: "当前路径: 无", 841 | style: { marginBottom: "2px" } 842 | }), 843 | $el("div.broken-paths-status", { 844 | textContent: "断开路径: 无" 845 | }) 846 | ]) 847 | ]) 848 | ]) 849 | ]); 850 | 851 | // 创建ResizeObserver来监控控制面板的高度变化 852 | const resizeObserver = new ResizeObserver((entries) => { 853 | const controlsHeight = entries[0].target.offsetHeight; 854 | const newTop = controlsHeight + 10; 855 | canvasContainer.style.top = newTop + "px"; 856 | 857 | // 同时调整底部边距,确保大尺寸画布有足够空间 858 | const minBottomMargin = canvas.height >= 1024 ? 20 : 15; 859 | canvasContainer.style.bottom = minBottomMargin + "px"; 860 | 861 | console.log(`Controls height: ${controlsHeight}px, Canvas container top: ${newTop}px`); 862 | }); 863 | 864 | // 监控控制面板的大小变化 865 | resizeObserver.observe(controlPanel.querySelector('.controls')); 866 | 867 | // 获取触发器widget 868 | const triggerWidget = node.widgets.find(w => w.name === "trigger"); 869 | 870 | // 创建更新函数 871 | const updateOutput = async () => { 872 | // 保存画布 873 | await canvas.saveToServer(widget.value); 874 | // 更新触发器值 875 | triggerWidget.value = (triggerWidget.value + 1) % 99999999; 876 | // 触发节点更新 877 | app.graph.runStep(); 878 | }; 879 | 880 | // 修改所有可能触发更新的操作 881 | const addUpdateToButton = (button) => { 882 | const origClick = button.onclick; 883 | button.onclick = async (...args) => { 884 | await origClick?.(...args); 885 | await updateOutput(); 886 | }; 887 | }; 888 | 889 | // 为所有按钮添加更新逻辑 890 | controlPanel.querySelectorAll('button').forEach(addUpdateToButton); 891 | 892 | // 修改画布容器样式,使用动态top值 893 | const canvasContainer = $el("div.painterCanvasContainer.painter-container", { 894 | style: { 895 | position: "absolute", 896 | top: "60px", // 初始值,会被动态调整 897 | left: "10px", 898 | right: "10px", 899 | bottom: "15px", // 减少底部边距,为画布提供更多空间 900 | display: "flex", 901 | justifyContent: "center", 902 | alignItems: "center", 903 | overflow: "hidden", 904 | minHeight: "400px" // 确保最小高度 905 | } 906 | }, [canvas.canvas]); 907 | 908 | // 修改节点大小调整逻辑 909 | node.onResize = function() { 910 | const minSize = 300; 911 | const controlsElement = controlPanel.querySelector('.controls'); 912 | const controlPanelHeight = controlsElement ? controlsElement.offsetHeight : 60; // 默认高度 913 | const padding = 20; 914 | const extraPadding = 40; // 额外边距确保画布完整显示 915 | 916 | // 获取当前节点宽度,但确保有最小值 917 | const nodeWidth = Math.max(this.size[0], minSize); 918 | 919 | // 为大尺寸画布(如1024x1024)计算更合适的节点尺寸 920 | let targetNodeWidth, targetNodeHeight; 921 | 922 | if (canvas.width >= 1024 || canvas.height >= 1024) { 923 | // 大尺寸画布:确保节点足够大以完整显示画布 924 | const aspectRatio = canvas.height / canvas.width; 925 | 926 | // 设置最小节点尺寸以适应大画布 927 | const minNodeWidth = Math.max(600, nodeWidth); 928 | const minNodeHeight = Math.max(600, minNodeWidth * aspectRatio + controlPanelHeight + padding * 2 + extraPadding); 929 | 930 | targetNodeWidth = minNodeWidth; 931 | targetNodeHeight = minNodeHeight; 932 | } else { 933 | // 普通尺寸画布:使用原来的逻辑但增加额外边距 934 | targetNodeWidth = nodeWidth; 935 | targetNodeHeight = Math.max( 936 | nodeWidth * (canvas.height / canvas.width) + controlPanelHeight + padding * 2 + extraPadding, 937 | minSize + controlPanelHeight + extraPadding 938 | ); 939 | } 940 | 941 | // 应用计算出的尺寸 942 | this.size[0] = targetNodeWidth; 943 | this.size[1] = targetNodeHeight; 944 | 945 | // 计算画布的实际可用空间(留出更多边距) 946 | const availableWidth = targetNodeWidth - padding * 2; 947 | const availableHeight = targetNodeHeight - controlPanelHeight - padding * 2 - extraPadding; 948 | 949 | // 更新画布尺寸,保持比例,但确保不会超出可用空间 950 | const scaleX = availableWidth / canvas.width; 951 | const scaleY = availableHeight / canvas.height; 952 | const scale = Math.min(scaleX, scaleY, 1); // 限制最大缩放为1:1 953 | 954 | // 确保画布不会太小 955 | const minScale = 0.3; 956 | const finalScale = Math.max(scale, minScale); 957 | 958 | canvas.canvas.style.width = (canvas.width * finalScale) + "px"; 959 | canvas.canvas.style.height = (canvas.height * finalScale) + "px"; 960 | 961 | // 强制重新渲染 962 | canvas.render(); 963 | 964 | console.log(`Canvas size adjusted: ${canvas.width}x${canvas.height}, Node: ${targetNodeWidth}x${targetNodeHeight}, Scale: ${finalScale}`); 965 | }; 966 | 967 | // 添加拖拽事件监听 968 | canvas.canvas.addEventListener('mouseup', updateOutput); 969 | canvas.canvas.addEventListener('mouseleave', updateOutput); 970 | 971 | // 创建一个包含控制面板和画布的容器 972 | const mainContainer = $el("div.painterMainContainer", { 973 | style: { 974 | position: "relative", 975 | width: "100%", 976 | height: "100%" 977 | } 978 | }, [controlPanel, canvasContainer]); 979 | 980 | // 将主容器添加到节点 981 | const mainWidget = node.addDOMWidget("mainContainer", "widget", mainContainer); 982 | 983 | // 设置节点的默认大小,根据画布尺寸调整 984 | const defaultWidth = canvas.width >= 1024 ? 700 : 500; 985 | const defaultHeight = canvas.height >= 1024 ? 700 : 500; 986 | node.size = [defaultWidth, defaultHeight]; 987 | 988 | // 立即触发一次大小调整,确保画布正确显示 989 | setTimeout(() => { 990 | if (node.onResize) { 991 | node.onResize(); 992 | } 993 | }, 100); 994 | 995 | // 在执行开始时保存数据 996 | api.addEventListener("execution_start", async () => { 997 | // 保存画布 998 | await canvas.saveToServer(widget.value); 999 | 1000 | // 保存当前节点的输入数据 1001 | if (node.inputs[0].link) { 1002 | const linkId = node.inputs[0].link; 1003 | const inputData = app.nodeOutputs[linkId]; 1004 | if (inputData) { 1005 | ImageCache.set(linkId, inputData); 1006 | } 1007 | } 1008 | }); 1009 | 1010 | // 移除原来在 saveToServer 中的缓存清理 1011 | const originalSaveToServer = canvas.saveToServer; 1012 | canvas.saveToServer = async function(fileName) { 1013 | const result = await originalSaveToServer.call(this, fileName); 1014 | // 移除这里的缓存清理 1015 | // ImageCache.clear(); 1016 | return result; 1017 | }; 1018 | 1019 | return { 1020 | canvas: canvas, 1021 | panel: controlPanel 1022 | }; 1023 | } 1024 | 1025 | // 修改状态指示器类,确保单例模式 1026 | class MattingStatusIndicator { 1027 | static instance = null; 1028 | 1029 | static getInstance(container) { 1030 | if (!MattingStatusIndicator.instance) { 1031 | MattingStatusIndicator.instance = new MattingStatusIndicator(container); 1032 | } 1033 | return MattingStatusIndicator.instance; 1034 | } 1035 | 1036 | constructor(container) { 1037 | this.indicator = document.createElement('div'); 1038 | this.indicator.style.cssText = ` 1039 | width: 10px; 1040 | height: 10px; 1041 | border-radius: 50%; 1042 | background-color: #808080; 1043 | margin-left: 10px; 1044 | display: inline-block; 1045 | transition: background-color 0.3s; 1046 | `; 1047 | 1048 | const style = document.createElement('style'); 1049 | style.textContent = ` 1050 | .processing { 1051 | background-color: #2196F3; 1052 | animation: blink 1s infinite; 1053 | } 1054 | .completed { 1055 | background-color: #4CAF50; 1056 | } 1057 | .error { 1058 | background-color: #f44336; 1059 | } 1060 | @keyframes blink { 1061 | 0% { opacity: 1; } 1062 | 50% { opacity: 0.4; } 1063 | 100% { opacity: 1; } 1064 | } 1065 | `; 1066 | document.head.appendChild(style); 1067 | 1068 | container.appendChild(this.indicator); 1069 | } 1070 | 1071 | setStatus(status) { 1072 | this.indicator.className = ''; // 清除所有状态 1073 | if (status) { 1074 | this.indicator.classList.add(status); 1075 | } 1076 | if (status === 'completed') { 1077 | setTimeout(() => { 1078 | this.indicator.classList.remove('completed'); 1079 | }, 2000); 1080 | } 1081 | } 1082 | } 1083 | 1084 | // 验证 ComfyUI 的图像数据格式 1085 | function validateImageData(data) { 1086 | // 打印完整的输入数据结构 1087 | console.log("Validating data structure:", { 1088 | hasData: !!data, 1089 | type: typeof data, 1090 | isArray: Array.isArray(data), 1091 | keys: data ? Object.keys(data) : null, 1092 | shape: data?.shape, 1093 | dataType: data?.data ? data.data.constructor.name : null, 1094 | fullData: data // 打印完整数据 1095 | }); 1096 | 1097 | // 检查是否为空 1098 | if (!data) { 1099 | console.log("Data is null or undefined"); 1100 | return false; 1101 | } 1102 | 1103 | // 如果是数组,获取第一个元素 1104 | if (Array.isArray(data)) { 1105 | console.log("Data is array, getting first element"); 1106 | data = data[0]; 1107 | } 1108 | 1109 | // 检查数据结构 1110 | if (!data || typeof data !== 'object') { 1111 | console.log("Invalid data type"); 1112 | return false; 1113 | } 1114 | 1115 | // 检查是否有数据属性 1116 | if (!data.data) { 1117 | console.log("Missing data property"); 1118 | return false; 1119 | } 1120 | 1121 | // 检查数据类型 1122 | if (!(data.data instanceof Float32Array)) { 1123 | // 如果不是 Float32Array,尝试转换 1124 | try { 1125 | data.data = new Float32Array(data.data); 1126 | } catch (e) { 1127 | console.log("Failed to convert data to Float32Array:", e); 1128 | return false; 1129 | } 1130 | } 1131 | 1132 | return true; 1133 | } 1134 | 1135 | // 转换 ComfyUI 图像数据为画布可用格式 1136 | function convertImageData(data) { 1137 | console.log("Converting image data:", data); 1138 | 1139 | // 如果是数组,获取第一个元素 1140 | if (Array.isArray(data)) { 1141 | data = data[0]; 1142 | } 1143 | 1144 | // 获取维度信息 [batch, height, width, channels] 1145 | const shape = data.shape; 1146 | const height = shape[1]; // 1393 1147 | const width = shape[2]; // 1393 1148 | const channels = shape[3]; // 3 1149 | const floatData = new Float32Array(data.data); 1150 | 1151 | console.log("Processing dimensions:", { height, width, channels }); 1152 | 1153 | // 创建画布格式的数据 (RGBA) 1154 | const rgbaData = new Uint8ClampedArray(width * height * 4); 1155 | 1156 | // 转换数据格式 [batch, height, width, channels] -> RGBA 1157 | for (let h = 0; h < height; h++) { 1158 | for (let w = 0; w < width; w++) { 1159 | const pixelIndex = (h * width + w) * 4; 1160 | const tensorIndex = (h * width + w) * channels; 1161 | 1162 | // 复制 RGB 通道并转换值范围 (0-1 -> 0-255) 1163 | for (let c = 0; c < channels; c++) { 1164 | const value = floatData[tensorIndex + c]; 1165 | rgbaData[pixelIndex + c] = Math.max(0, Math.min(255, Math.round(value * 255))); 1166 | } 1167 | 1168 | // 设置 alpha 通道为完全不透明 1169 | rgbaData[pixelIndex + 3] = 255; 1170 | } 1171 | } 1172 | 1173 | // 返回画布可用的格式 1174 | return { 1175 | data: rgbaData, // Uint8ClampedArray 格式的 RGBA 数据 1176 | width: width, // 图像宽度 1177 | height: height // 图像高度 1178 | }; 1179 | } 1180 | 1181 | // 处理遮罩数据 1182 | function applyMaskToImageData(imageData, maskData) { 1183 | console.log("Applying mask to image data"); 1184 | 1185 | const rgbaData = new Uint8ClampedArray(imageData.data); 1186 | const width = imageData.width; 1187 | const height = imageData.height; 1188 | 1189 | // 获取遮罩数据 [batch, height, width] 1190 | const maskShape = maskData.shape; 1191 | const maskFloatData = new Float32Array(maskData.data); 1192 | 1193 | console.log(`Applying mask of shape: ${maskShape}`); 1194 | 1195 | // 将遮罩数据应用到 alpha 通道 1196 | for (let h = 0; h < height; h++) { 1197 | for (let w = 0; w < width; w++) { 1198 | const pixelIndex = (h * width + w) * 4; 1199 | const maskIndex = h * width + w; 1200 | // 使遮罩值作为 alpha 值,转换值范围从 0-1 到 0-255 1201 | const alpha = maskFloatData[maskIndex]; 1202 | rgbaData[pixelIndex + 3] = Math.max(0, Math.min(255, Math.round(alpha * 255))); 1203 | } 1204 | } 1205 | 1206 | console.log("Mask application completed"); 1207 | 1208 | return { 1209 | data: rgbaData, 1210 | width: width, 1211 | height: height 1212 | }; 1213 | } 1214 | 1215 | // 修改缓存管理 1216 | const ImageCache = { 1217 | cache: new Map(), 1218 | 1219 | // 存储图像数据 1220 | set(key, imageData) { 1221 | console.log("Caching image data for key:", key); 1222 | this.cache.set(key, imageData); 1223 | }, 1224 | 1225 | // 获取图像数据 1226 | get(key) { 1227 | const data = this.cache.get(key); 1228 | console.log("Retrieved cached data for key:", key, !!data); 1229 | return data; 1230 | }, 1231 | 1232 | // 检查是否存在 1233 | has(key) { 1234 | return this.cache.has(key); 1235 | }, 1236 | 1237 | // 清除缓存 1238 | clear() { 1239 | console.log("Clearing image cache"); 1240 | this.cache.clear(); 1241 | } 1242 | }; 1243 | 1244 | // 改进数据准备函数 1245 | function prepareImageForCanvas(inputImage) { 1246 | console.log("Preparing image for canvas:", inputImage); 1247 | 1248 | try { 1249 | // 如果是数组,获取第一个元素 1250 | if (Array.isArray(inputImage)) { 1251 | inputImage = inputImage[0]; 1252 | } 1253 | 1254 | if (!inputImage || !inputImage.shape || !inputImage.data) { 1255 | throw new Error("Invalid input image format"); 1256 | } 1257 | 1258 | // 获取维度信息 [batch, height, width, channels] 1259 | const shape = inputImage.shape; 1260 | const height = shape[1]; 1261 | const width = shape[2]; 1262 | const channels = shape[3]; 1263 | const floatData = new Float32Array(inputImage.data); 1264 | 1265 | console.log("Image dimensions:", { height, width, channels }); 1266 | 1267 | // 创建 RGBA 格式数据 1268 | const rgbaData = new Uint8ClampedArray(width * height * 4); 1269 | 1270 | // 转换数据格式 [batch, height, width, channels] -> RGBA 1271 | for (let h = 0; h < height; h++) { 1272 | for (let w = 0; w < width; w++) { 1273 | const pixelIndex = (h * width + w) * 4; 1274 | const tensorIndex = (h * width + w) * channels; 1275 | 1276 | // 转换 RGB 通道 (0-1 -> 0-255) 1277 | for (let c = 0; c < channels; c++) { 1278 | const value = floatData[tensorIndex + c]; 1279 | rgbaData[pixelIndex + c] = Math.max(0, Math.min(255, Math.round(value * 255))); 1280 | } 1281 | 1282 | // 设置 alpha 通道 1283 | rgbaData[pixelIndex + 3] = 255; 1284 | } 1285 | } 1286 | 1287 | // 返回画布需要的格式 1288 | return { 1289 | data: rgbaData, 1290 | width: width, 1291 | height: height 1292 | }; 1293 | } catch (error) { 1294 | console.error("Error preparing image:", error); 1295 | throw new Error(`Failed to prepare image: ${error.message}`); 1296 | } 1297 | } 1298 | 1299 | app.registerExtension({ 1300 | name: "Comfy.CanvasNode", 1301 | async beforeRegisterNodeDef(nodeType, nodeData, app) { 1302 | if (nodeType.comfyClass === "CanvasNode") { 1303 | const onNodeCreated = nodeType.prototype.onNodeCreated; 1304 | nodeType.prototype.onNodeCreated = async function() { 1305 | const r = onNodeCreated?.apply(this, arguments); 1306 | 1307 | const widget = this.widgets.find(w => w.name === "canvas_image"); 1308 | await createCanvasWidget(this, widget, app); 1309 | 1310 | return r; 1311 | }; 1312 | } 1313 | } 1314 | }); 1315 | 1316 | async function handleImportInput(data) { 1317 | if (data && data.image) { 1318 | const imageData = data.image; 1319 | await importImage(imageData); 1320 | } 1321 | } 1322 | 1323 | async function importImage(cacheData) { 1324 | try { 1325 | console.log("Starting image import with cache data"); 1326 | const img = await this.loadImageFromCache(cacheData.image); 1327 | const mask = cacheData.mask ? await this.loadImageFromCache(cacheData.mask) : null; 1328 | 1329 | // 计算缩放比例 1330 | const scale = Math.min( 1331 | this.width / img.width * 0.8, 1332 | this.height / img.height * 0.8 1333 | ); 1334 | 1335 | // 获取图层图像,并保留透明度信息 1336 | const finalImage = new Image(); 1337 | 1338 | if (mask) { 1339 | // 创建临时画布来合并图像和遮罩 1340 | const tempCanvas = document.createElement('canvas'); 1341 | tempCanvas.width = img.width; 1342 | tempCanvas.height = img.height; 1343 | const tempCtx = tempCanvas.getContext('2d'); 1344 | 1345 | // 绘制图像 1346 | tempCtx.drawImage(img, 0, 0); 1347 | 1348 | // 获取图像数据 1349 | const imageData = tempCtx.getImageData(0, 0, img.width, img.height); 1350 | 1351 | // 获取遮罩数据 1352 | const maskCanvas = document.createElement('canvas'); 1353 | maskCanvas.width = img.width; 1354 | maskCanvas.height = img.height; 1355 | const maskCtx = maskCanvas.getContext('2d'); 1356 | maskCtx.drawImage(mask, 0, 0); 1357 | const maskData = maskCtx.getImageData(0, 0, img.width, img.height); 1358 | 1359 | // 应用遮罩到alpha通道 1360 | for (let i = 0; i < imageData.data.length; i += 4) { 1361 | // 使用遮罩的亮度值(假设是灰度图)作为alpha值 1362 | // 通常取第一个通道值作为亮度 1363 | const maskValue = maskData.data[i]; 1364 | imageData.data[i + 3] = maskValue; 1365 | } 1366 | 1367 | // 将合并后的数据放回画布 1368 | tempCtx.putImageData(imageData, 0, 0); 1369 | 1370 | // 设置最终图像 1371 | await new Promise((resolve) => { 1372 | finalImage.onload = resolve; 1373 | finalImage.src = tempCanvas.toDataURL('image/png'); 1374 | }); 1375 | } else { 1376 | // 如果没有遮罩,直接使用原始图像 1377 | finalImage.src = img.src; 1378 | await new Promise(resolve => { 1379 | if (finalImage.complete) { 1380 | resolve(); 1381 | } else { 1382 | finalImage.onload = resolve; 1383 | } 1384 | }); 1385 | } 1386 | 1387 | // 创建新图层 1388 | const layer = { 1389 | image: finalImage, 1390 | x: (this.width - img.width * scale) / 2, 1391 | y: (this.height - img.height * scale) / 2, 1392 | width: img.width * scale, 1393 | height: img.height * scale, 1394 | rotation: 0, 1395 | zIndex: this.layers.length 1396 | }; 1397 | 1398 | this.layers.push(layer); 1399 | this.selectedLayer = layer; 1400 | this.render(); 1401 | 1402 | console.log("Layer imported with mask information"); 1403 | 1404 | } catch (error) { 1405 | console.error('Error importing image:', error); 1406 | } 1407 | } --------------------------------------------------------------------------------