# InpaintRegionEditor 最终实现方案 > 版本:1.0 | 日期:2026-03-02 > 基于用户需求最终确认 --- ## 一、核心需求 ### 1.1 功能定位 **InpaintRegionEditor = LoadImage + 蒙版编辑 + 选区调整** - 替代系统 LoadImage 节点 - 支持图像上传 - 支持蒙版编辑(Photopea 集成) - 支持选区调整(拖动 + 扩散像素) - 输出:IMAGE, MASK, region_* ### 1.2 蒙版来源(三种) 1. **用户上传**:带 Alpha 通道的 PNG 2. **Photopea 编辑**:在 Photopea 里画蒙版后保存 3. **系统工具**:ComfyUI 自带蒙版工具绘制后保存 ### 1.3 核心逻辑 ``` if 没有蒙版: 执行时报错:"请先创建蒙版(局部重绘需要蒙版)" else: 自动计算选区 = 蒙版边界 + padding 在预览上绘制遮罩 + 选区框 用户可以拖动选区 输出 region_* 坐标 ``` --- ## 二、文件结构 ``` ComfyUI-InpaintRegionEditor/ ├── __init__.py # 节点注册 ├── nodes.py # 后端节点逻辑 ├── requirements.txt # 无额外依赖 │ ├── web/ │ ├── extension.js # 扩展入口 + 右键菜单 │ ├── photopea-bridge.js # Photopea 通信桥接 │ ├── photopea-modal.js # Photopea 全屏对话框 │ ├── region-box-editor.js # 选区框编辑器(Canvas + 拖动) │ └── style.css # 样式(可选) │ └── docs/ ├── technical-validation.md # 技术验证清单 └── implementation-plan.md # 本方案 ``` --- ## 三、后端实现(nodes.py) ### 3.1 节点定义 ```python import torch import numpy as np import folder_paths from PIL import Image import os import json class InpaintRegionEditor: """ 增强版遮罩编辑器,支持 Photopea 集成和可拖动选区 概念说明: - 遮罩 (Mask): 定义重绘区域,AI 在这个区域内生成新内容 - 选区 (Region): 定义参考区域,AI 参考这个区域内的原图内容 约束:选区必须 >= 遮罩 """ @classmethod def INPUT_TYPES(s): input_dir = folder_paths.get_input_directory() files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))] files = folder_paths.filter_files_content_types(files, ["image"]) return { "required": { # 图像输入 "image": (sorted(files), {"image_upload": True}), # 扩散像素数(选区 = 遮罩边界 + padding) "padding": ("INT", { "default": 64, "min": 0, "max": 512, "step": 32 }), }, "hidden": { # 前端传递的选区坐标(用户拖动后更新) "region_coords": "STRING", } } RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT", "INT", "INT") RETURN_NAMES = ("image", "mask", "region_top", "region_left", "region_width", "region_height") FUNCTION = "process" CATEGORY = "image/inpaint" def process(self, image, padding, region_coords=None): """ 处理图像和遮罩,计算选区位置 Args: image: 图像文件名 padding: 扩散像素数 region_coords: 前端传递的选区坐标 JSON(用户拖动后) """ # 加载图像 image_path = folder_paths.get_annotated_filepath(image) img = Image.open(image_path) img_width, img_height = img.size # 提取 MASK(从 Alpha 通道) if 'A' in img.getbands(): mask = np.array(img.getchannel('A')).astype(np.float32) / 255.0 mask = 1.0 - mask # ComfyUI 约定:1=遮罩区域,0=背景 else: # 没有蒙版 → 报错 raise ValueError( "请先创建蒙版(局部重绘需要蒙版)\n" "可以通过以下方式创建:\n" "1. 上传带 Alpha 通道的 PNG\n" "2. 使用 Photopea 编辑后保存蒙版\n" "3. 使用 ComfyUI 系统蒙版工具绘制" ) # 计算蒙版边界框 mask_bounds = self._calculate_mask_bounds(mask) # 解析前端传递的选区坐标(用户拖动后) region_rect = None if region_coords: try: region_rect = json.loads(region_coords) except: pass # 如果没有前端坐标,自动计算 if not region_rect: region_rect = self._calculate_region_rect(mask_bounds, padding) # 验证:选区必须 >= 遮罩 if (region_rect['width'] < mask_bounds['width'] or region_rect['height'] < mask_bounds['height']): raise ValueError( f"选区太小!必须 >= 遮罩\n" f"选区:{region_rect['width']}×{region_rect['height']}\n" f"遮罩:{mask_bounds['width']}×{mask_bounds['height']}" ) # 确保选区在图像范围内 region_rect = self._clamp_to_image(region_rect, img_width, img_height) # 转换为 tensor image_tensor = torch.from_numpy( np.array(img.convert("RGB")).astype(np.float32) / 255.0 ).unsqueeze(0) mask_tensor = torch.from_numpy(mask).unsqueeze(0) return ( image_tensor, mask_tensor, int(region_rect['y']), # top int(region_rect['x']), # left int(region_rect['width']), # width int(region_rect['height']) # height ) def _calculate_mask_bounds(self, mask): """计算遮罩的边界框""" rows = np.any(mask > 0.5, axis=1) cols = np.any(mask > 0.5, axis=0) if not np.any(rows) or not np.any(cols): # 没有遮罩 return {'x': 0, 'y': 0, 'width': 0, 'height': 0} rmin, rmax = np.where(rows)[0][[0, -1]] cmin, cmax = np.where(cols)[0][[0, -1]] return { 'x': int(cmin), 'y': int(rmin), 'width': int(cmax - cmin + 1), 'height': int(rmax - rmin + 1) } def _calculate_region_rect(self, mask_bounds, padding): """根据遮罩边界 + padding 计算选区""" return { 'x': mask_bounds['x'] - padding, 'y': mask_bounds['y'] - padding, 'width': mask_bounds['width'] + padding * 2, 'height': mask_bounds['height'] + padding * 2 } def _clamp_to_image(self, rect, img_width, img_height): """确保矩形在图像范围内""" x = max(0, min(rect['x'], img_width - rect['width'])) y = max(0, min(rect['y'], img_height - rect['height'])) return { 'x': x, 'y': y, 'width': rect['width'], 'height': rect['height'] } ``` --- ## 四、前端实现 ### 4.1 extension.js(扩展入口) ```javascript import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; import { PhotopeaModal } from "./photopea-modal.js"; import { RegionBoxEditor } from "./region-box-editor.js"; app.registerExtension({ name: "InpaintRegionEditor", async beforeRegisterNodeDef(nodeType, nodeData, app) { if (nodeData.name !== "InpaintRegionEditor") return; // 添加 Photopea 右键菜单 const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions; nodeType.prototype.getExtraMenuOptions = function(canvas, options) { getExtraMenuOptions?.apply(this, arguments); const node = this; const imageWidget = node.widgets?.find(w => w.name === "image"); options.push(null); // 分隔线 options.push({ content: "🎨 打开 Photopea 编辑", callback: () => { if (imageWidget?.value) { PhotopeaModal.show(node, imageWidget.value); } else { alert("请先加载图像"); } } }); return options; }; // 添加选区框编辑器 const onNodeCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function() { const result = onNodeCreated?.apply(this, arguments); // 隐藏的区域坐标 widget(存储用户拖动后的坐标) const coordsWidget = this.addWidget("STRING", "region_coords", "{}", () => {}, { serialize: true, hidden: true }); // 添加选区框 Canvas this.regionEditor = new RegionBoxEditor(this, app.canvas); return result; }; // 节点执行时更新选区 const onExecuted = nodeType.prototype.onExecuted; nodeType.prototype.onExecuted = function(message) { onExecuted?.apply(this, arguments); // 更新选区框(使用后端返回的坐标) if (this.regionEditor && message.region_coords) { this.regionEditor.updateRegion(message.region_coords); } }; // 节点绘制时渲染选区框 const onDrawBackground = nodeType.prototype.onDrawBackground; nodeType.prototype.onDrawBackground = function(ctx, nodeWidget, nodeWidth, nodeHeight) { onDrawBackground?.apply(this, arguments); // 在图像预览上绘制选区框 if (this.regionEditor) { this.regionEditor.draw(ctx, nodeWidth, nodeHeight); } }; } }); ``` ### 4.2 region-box-editor.js(选区框编辑器) ```javascript /** * 选区框编辑器 * 在节点图像预览上绘制可拖动的选区框 */ export class RegionBoxEditor { constructor(node, canvas) { this.node = node; this.canvas = canvas; // 选区矩形 this.regionRect = { x: 0, y: 0, width: 200, height: 200 }; // 遮罩边界(自动计算) this.maskBounds = { x: 0, y: 0, width: 100, height: 100 }; // 交互状态 this.isDragging = false; this.dragStart = { x: 0, y: 0 }; // 样式配置 this.config = { maskColor: "rgba(255, 0, 0, 0.5)", // 遮罩:半透明红 regionColor: "rgba(255, 165, 0, 0.3)", // 选区:半透明橙 regionBorder: "rgb(255, 165, 0)", // 选区边框:橙色 regionDash: [5, 5], // 虚线 handleSize: 8, // 拖动手柄大小 }; this.setupEventListeners(); } setupEventListeners() { // 监听鼠标事件(拖动选区) const canvasElement = this.canvas.canvas; canvasElement.addEventListener('mousedown', (e) => this.onMouseDown(e)); canvasElement.addEventListener('mousemove', (e) => this.onMouseMove(e)); canvasElement.addEventListener('mouseup', () => this.onMouseUp()); } onMouseDown(e) { const rect = this.canvas.canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; // 检查是否点击在选区框内 if (this.isPointInRect(x, y, this.regionRect)) { this.isDragging = true; this.dragStart = { x, y }; this.canvas.canvas.style.cursor = 'move'; } } onMouseMove(e) { if (!this.isDragging) return; const rect = this.canvas.canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; const dx = x - this.dragStart.x; const dy = y - this.dragStart.y; // 更新选区位置 this.regionRect.x += dx; this.regionRect.y += dy; this.dragStart = { x, y }; // 同步到节点 widget this.syncToNode(); // 重绘 this.canvas.setDirty(true); } onMouseUp() { this.isDragging = false; this.canvas.canvas.style.cursor = 'default'; } isPointInRect(x, y, rect) { return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height; } draw(ctx, nodeWidth, nodeHeight) { // 绘制遮罩预览(半透明红) if (this.maskBounds.width > 0) { ctx.fillStyle = this.config.maskColor; ctx.fillRect( this.maskBounds.x, this.maskBounds.y, this.maskBounds.width, this.maskBounds.height ); } // 绘制选区框(橙色虚线) ctx.fillStyle = this.config.regionColor; ctx.fillRect( this.regionRect.x, this.regionRect.y, this.regionRect.width, this.regionRect.height ); ctx.strokeStyle = this.config.regionBorder; ctx.lineWidth = 2; ctx.setLineDash(this.config.regionDash); ctx.strokeRect( this.regionRect.x, this.regionRect.y, this.regionRect.width, this.regionRect.height ); ctx.setLineDash([]); // 绘制尺寸标签 ctx.fillStyle = 'white'; ctx.font = '12px sans-serif'; ctx.fillText( `${this.regionRect.width}×${this.regionRect.height}`, this.regionRect.x + 5, this.regionRect.y + 15 ); } updateRegion(regionRect) { // 更新选区坐标(从后端返回) this.regionRect = regionRect; this.canvas.setDirty(true); } updateMaskBounds(maskBounds) { // 更新遮罩边界(自动计算选区) this.maskBounds = maskBounds; // 自动计算选区(如果还没手动拖动过) if (!this.userDragged) { const padding = this.node.widgets?.find(w => w.name === "padding")?.value || 64; this.regionRect = { x: maskBounds.x - padding, y: maskBounds.y - padding, width: maskBounds.width + padding * 2, height: maskBounds.height + padding * 2 }; } this.canvas.setDirty(true); } syncToNode() { // 同步选区坐标到节点 widget const coordsWidget = this.node.widgets?.find(w => w.name === "region_coords"); if (coordsWidget) { coordsWidget.value = JSON.stringify(this.regionRect); } this.userDragged = true; } } ``` ### 4.3 photopea-modal.js(Photopea 对话框) ```javascript import { PhotopeaBridge } from "./photopea-bridge.js"; export class PhotopeaModal { static instance = null; static node = null; static show(node, imagePath) { this.node = node; this.createUI(); this.loadImage(imagePath); } static createUI() { // 创建全屏对话框 this.modal = document.createElement('div'); this.modal.className = 'photopea-modal'; this.modal.innerHTML = `