diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..ee6ef4e --- /dev/null +++ b/Readme.md @@ -0,0 +1,226 @@ +Comfyui-ImageAB-Compare - ComfyUI 二图对比查看插件 + +一个功能完善的 ComfyUI 节点插件,支持图片左右 / 上下分割对比、鼠标实时交互调整分割比例、滑块与鼠标双向联动,可导出带精准分割线的对比图。 + +功能特性 + +✅ 双模式分割对比:支持垂直(左右)/ 水平(上下)两种分割方向✅ 实时交互调整:鼠标移动精准控制分割线位置,支持像素级微调✅ 双向参数联动:分割比例滑块与鼠标操作实时同步,参数无缝衔接✅ 精准尺寸适配:基于基准图片尺寸等比例渲染,预览与导出效果 1:1 匹配✅ 自定义视觉样式:可调整分割线粗细、颜色,适配不同图片预览需求✅ 独立节点布局:方向与比例控件同行展示,节省垂直空间 + +安装方法 + +方法 1:手动安装(推荐) + + + +下载插件文件: + + + +将 image\_ab\_compare.py 放入 ComfyUI/custom\_nodes/ 目录 + +将 tupian\_duibi.js 放入 ComfyUI/custom/js/ 目录 + +(可选)如需自定义样式,将 custom.css 放入 ComfyUI/custom/css/ 目录 + + + + + +重启 ComfyUI 服务 + + + + + +方法 2:通过管理器安装 + +支持 ComfyUI Manager 的用户可直接在管理器中搜索 ImageABCompare 安装(需先发布到管理器仓库) + +使用教程 + +基础使用 + + + +在 ComfyUI 编辑器中,找到「🎨 ImageAB / 对比工具」分类下的「Image 图片 AB 对比」节点 + +连接需要对比的两张图片到 image1 和 image2 输入口 + +运行节点,即可在节点内看到实时对比预览: + +鼠标移动:在预览区域拖动,分割线会跟随鼠标位置实时调整 + +切换方向:通过下拉框选择「vertical」(左右)/「horizontal」(上下) + +精准调整:通过比例滑块(0.0-1.0)精确控制分割位置 + + + + + + + + + +导出对比图 + + + +将节点的「对比图」输出口连接到「Save Image」节点 + +运行整个工作流,即可导出带红色分割线的最终对比图(尺寸与 image1 保持一致) + + + +参数说明 + +参数名类型默认值说明image1IMAGE-基准图片(对比图尺寸以此图片为准)image2IMAGE-对比图片(自动适配基准图片尺寸)split\_direction下拉框vertical分割方向:vertical(左右)/horizontal(上下)split\_ratio\_output滑块0.5分割比例:0.0-1.0(0.5 为居中分割) + +自定义配置 + +调整分割线样式 + +修改 tupian\_duibi.js 中 onDrawForeground 方法的以下参数:javascript运行ctx.strokeStyle = "#00ff00"; // 分割线颜色(支持十六进制/RGB) + +ctx.lineWidth = 1; // 分割线粗细(1为极细,2为中等,4为默认) + + + +调整节点布局 + +修改 image\_ab\_compare.py 中 INPUT\_TYPES 的 UI 配置:python运行"width": "40%", # 调整方向控件宽度占比 + +"width": "60%", # 调整比例控件宽度占比 + +"spacing": "8px", # 调整控件间距 + + + +调整节点尺寸 + +修改 tupian\_duibi.js 中 initState 方法:javascript运行this.setSize(\[500, 450]); // \[宽度, 高度],按需调整 + + + +常见问题解决 + +Q1:节点内无预览图 + + + +检查控制台(F12)是否有报错信息 + +确认图片已正确加载(临时目录是否生成 ab\_img1\_\*.png 和 ab\_img2\_\*.png) + +重启 ComfyUI 并强制刷新浏览器(Ctrl+F5) + + + +Q2:鼠标移动与分割线位置不匹配 + + + +确保使用最新版的 tupian\_duibi.js(已修复坐标适配问题) + +检查图片是否为异常尺寸(如极长 / 极扁),插件已适配所有尺寸图片 + + + +Q3:方向切换后分割线不生效 + + + +确认方向切换后控制台输出 \[ImageABCompare] 方向强制切换为:xxx + +检查是否有其他插件样式冲突,可暂时禁用其他自定义 CSS + + + +Q4:控件未同行显示 + + + +确认使用 ComfyUI v0.1.2 + 版本(支持 container 布局) + +备用方案:添加自定义 CSS(见安装方法 - 方案 2) + + + +技术细节 + +核心逻辑 + + + +图片转换:通过 tensor2pil/pil2tensor 实现 Tensor 与 PIL 图片的双向转换 + +实时渲染:基于 Canvas 绘制,鼠标位置实时计算分割比例 + +参数同步:通过 isUpdating 锁机制避免鼠标与滑块联动死循环 + +尺寸适配:基于基准图片尺寸等比例缩放,保证预览与导出一致性 + + + +目录结构plaintextComfyUI/ + +├── custom\_nodes/ + +│ └── image\_ab\_compare.py # 后端节点逻辑 + +├── custom/js/ + +│ └── tupian\_duibi.js # 前端交互逻辑 + +└── custom/css/ + +  └── custom.css # 自定义样式(可选) + + + +兼容性 + + + +ComfyUI 版本:v0.1.2+(推荐最新版) + +浏览器:Chrome/Firefox/Edge(不建议使用 Safari) + +系统:Windows/macOS/Linux + + + +更新日志 + +v1.0.0 + +初始版本,支持基础的图片 AB 对比功能 + + + +许可证 + +本插件采用 MIT 许可证开源,可自由修改、分发和商用,保留原作者信息即可。 + +反馈与建议 + +如遇问题或有功能建议,可通过以下方式反馈: + + + +GitHub Issues(推荐) + +提交 PR 参与功能改进 + +技术交流群(如有) + + + +捐赠 + +如果觉得本工具还不错,你可以捐赠我一杯咖啡。 + +![捐赠图](images/donate.png) + +  + diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..157d1c3 --- /dev/null +++ b/__init__.py @@ -0,0 +1,8 @@ +# ComfyUI/custom_nodes/Comfyui-ImageAB-Compare/__init__.py + +from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS + +# 告诉 ComfyUI 前端资源位置 +WEB_DIRECTORY = "./js" + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] \ No newline at end of file diff --git a/images/after_run.png b/images/after_run.png new file mode 100644 index 0000000..ada91f7 Binary files /dev/null and b/images/after_run.png differ diff --git a/images/before_run.png b/images/before_run.png new file mode 100644 index 0000000..e21f8e8 Binary files /dev/null and b/images/before_run.png differ diff --git a/images/donate.png b/images/donate.png new file mode 100644 index 0000000..7d84617 Binary files /dev/null and b/images/donate.png differ diff --git a/images/girl.png b/images/girl.png new file mode 100644 index 0000000..876052e Binary files /dev/null and b/images/girl.png differ diff --git a/js/compare_imageab_see.js b/js/compare_imageab_see.js new file mode 100644 index 0000000..85c2a9d --- /dev/null +++ b/js/compare_imageab_see.js @@ -0,0 +1,291 @@ +// js/compare_imageab_see.js +import { app } from "../../../scripts/app.js"; +import { api } from "../../../scripts/api.js"; + +console.log("✅ [ImageABCompare] V1.0 QQ:2540968810"); + +app.registerExtension({ + name: "Comfy.ImageABCompare", + + async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeType.comfyClass !== "ImageABCompare") return; + + // 初始化核心状态 + nodeType.prototype.initState = function () { + this.imgA = null; + this.imgB = null; + this.pointerOverPos = [0, 0]; + this.isPointerOver = false; + this.splitRatio = 0.5; + this.splitDirection = "vertical"; // 默认左右 + this.setSize([500, 550]); + this.imagesLoaded = false; + this.baseSize = [0, 0]; // 缓存图1基准尺寸 + }; + + // 精准绑定方向控件 + nodeType.prototype.bindDirectionControl = function () { + const nodeEl = document.querySelector(`[data-node-id="${this.id}"]`); + if (!nodeEl) return; + + // 精准匹配split_direction控件 + const directionWidget = nodeEl.querySelector('.widget[data-name="split_direction"]'); + if (!directionWidget) return; + const directionSelect = directionWidget.querySelector('select'); + if (!directionSelect) return; + + // 强制同步当前方向值 + directionSelect.value = this.splitDirection; + + // 方向切换核心逻辑(强制刷新所有状态) + directionSelect.addEventListener("change", (e) => { + // 1. 强制更新方向 + this.splitDirection = e.target.value; + // 2. 重置比例为中间值 + this.splitRatio = 0.5; + // 3. 强制清空图片缓存(重新渲染) + this.imagesLoaded = false; + // 4. 强制触发重绘 + this.setDirtyCanvas(true, true); + app.canvas.draw(true); + + console.log(`[ImageABCompare] 方向强制切换为:${this.splitDirection}`); + }); + + // 监听节点参数更新,强制同步 + this.on("paramsUpdated", () => { + if (directionSelect.value !== this.splitDirection) { + this.splitDirection = directionSelect.value; + this.splitRatio = 0.5; + this.setDirtyCanvas(true, true); + } + }); + }; + + // 鼠标进入 + nodeType.prototype.onMouseEnter = function () { + this.isPointerOver = true; + this.setDirtyCanvas(true, true); + }; + + // 鼠标离开 + nodeType.prototype.onMouseLeave = function () { + this.isPointerOver = false; + this.setDirtyCanvas(true, true); + }; + + // 鼠标移动(核心修复:按方向重新计算比例) + nodeType.prototype.onMouseMove = function (e, pos) { + if (!this.imgA || !this.imgB) return; + + const [nodeW, nodeH] = this.size; + const margin = 10; + const widgetAreaHeight = 111; + + // 绘制区域 + const drawArea = { + x: margin, + y: widgetAreaHeight, + width: nodeW - margin * 2, + height: nodeH - widgetAreaHeight - margin + }; + + // 计算图片渲染参数(与绘制逻辑保持一致) + const [baseW, baseH] = this.baseSize; + const scaleW = drawArea.width / baseW; + const scaleH = drawArea.height / baseH; + const scale = Math.min(scaleW, scaleH); + const renderW = baseW * scale; + const renderH = baseH * scale; + const renderX = drawArea.x + (drawArea.width - renderW) / 2; // 图片居中X坐标 + const renderY = drawArea.y + (drawArea.height - renderH) / 2; // 图片居中Y坐标 + + // 计算鼠标在图片渲染区域内的相对坐标(关键修复) + const imgRelX = Math.max(0, Math.min(pos[0] - renderX, renderW)); // 相对于图片左边缘的X + const imgRelY = Math.max(0, Math.min(pos[1] - renderY, renderH)); // 相对于图片上边缘的Y + + // 基于图片实际渲染尺寸计算比例(而非节点绘制区域) + if (this.splitDirection === "vertical") { + this.splitRatio = imgRelX / renderW; // 左右分割:使用图片宽度计算 + } else { + this.splitRatio = imgRelY / renderH; // 上下分割:使用图片高度计算 + } + this.splitRatio = Math.max(0, Math.min(this.splitRatio, 1)); + + this.pointerOverPos = [imgRelX, imgRelY]; + this.setDirtyCanvas(true, true); + }; + + // 加载图片(核心修复:同步后端方向+基准尺寸) + nodeType.prototype.onExecuted = async function (output) { + try { + const img1Name = output?.img1_filename?.[0]; + const img2Name = output?.img2_filename?.[0]; + if (!img1Name || !img2Name) return; + + // 加载图片 + const loadImg = (name) => new Promise((resolve) => { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => resolve(img); + img.onerror = () => resolve(null); + img.src = `/view?filename=${encodeURIComponent(name)}&type=temp&subfolder=&t=${Date.now()}`; + }); + + // 加载图1和图2 + const [imgA, imgB] = await Promise.all([loadImg(img1Name), loadImg(img2Name)]); + if (!imgA || !imgB) return; + + // 1. 缓存图片和基准尺寸(图1尺寸) + this.imgA = imgA; + this.imgB = imgB; + this.baseSize = [imgA.width, imgA.height]; // 图1基准尺寸 + // 2. 强制同步后端方向(核心:覆盖前端默认值) + this.splitDirection = output?.split_direction?.[0] || "vertical"; + // 3. 重置比例 + this.splitRatio = output?.split_ratio?.[0] || 0.5; + // 4. 标记加载完成 + this.imagesLoaded = true; + + console.log(`[ImageABCompare] 图片加载完成 - 后端方向:${this.splitDirection},图1尺寸:${this.baseSize[0]}x${this.baseSize[1]}`); + + // 强制触发重绘 + this.setDirtyCanvas(true, true); + app.canvas.draw(true); + } catch (err) { + console.error("[ImageABCompare] 图片加载失败:", err); + } + }; + + // 绘制逻辑(核心修复:上下分割裁剪+适配图1尺寸) + nodeType.prototype.onDrawForeground = function (ctx) { + const [nodeW, nodeH] = this.size; + const margin = 10; + const widgetAreaHeight = 111; + const drawArea = { + x: margin, + y: widgetAreaHeight, + width: nodeW - margin * 2, + height: nodeH - widgetAreaHeight - margin + }; + + // 绘制背景(区分方向) + ctx.fillStyle = this.splitDirection === "vertical" ? "#222" : "#111"; + ctx.fillRect(drawArea.x, drawArea.y, drawArea.width, drawArea.height); + + // 无图片提示 + if (!this.imgA || !this.imgB) { + ctx.fillStyle = "#666"; + ctx.font = "9px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("支持左右 / 上下分割对比查看、移动鼠标实时调整分割比例", drawArea.x + drawArea.width/2, drawArea.y + drawArea.height/2 - 130); + ctx.fillText("Supports left-right/top-bottom split comparison view, ", drawArea.x + drawArea.width/2, drawArea.y + drawArea.height/2 - 118); + ctx.fillText("and real-time adjustment of split ratio by moving the mouse", drawArea.x + drawArea.width/2, drawArea.y + drawArea.height/2 - 106); + ctx.fillText("可导出带精准分割线的对比图。", drawArea.x + drawArea.width/2, drawArea.y + drawArea.height/2 - 80); + ctx.fillText("A comparison chart with precise segmentation lines can be exported.", drawArea.x + drawArea.width/2, drawArea.y + drawArea.height/2 - 68); + ctx.fillText("输入两张图片按不同分割比例并列查看", drawArea.x + drawArea.width/2, drawArea.y + drawArea.height/2 - 50); + ctx.fillText("Input two images and view them side by side at different split ratios", drawArea.x + drawArea.width/2, drawArea.y + drawArea.height/2 - 38); + ctx.fillText(`当前模式:${this.splitDirection === "vertical" ? "左右分割" : "上下分割"}`, drawArea.x + drawArea.width/2, drawArea.y + drawArea.height/2 - 17); + ctx.fillText(`Current Mode: ${this.splitDirection === "vertical" ? "left-right" : "top-bottom"}`, drawArea.x + drawArea.width/2, drawArea.y + drawArea.height/2 - 5); + ctx.fillText("改变分割方向后,应重新运行至本节点", drawArea.x + drawArea.width/2, drawArea.y + drawArea.height - 50); + ctx.fillText("You should rerun the process up to this node", drawArea.x + drawArea.width/2, drawArea.y + drawArea.height - 40); + ctx.fillText("after changing the segmentation direction", drawArea.x + drawArea.width/2, drawArea.y + drawArea.height - 30); + return; + } + + + // 核心:基于图1尺寸适配渲染(保证导出和预览尺寸一致) + const [baseW, baseH] = this.baseSize; + // 计算渲染缩放比例(适配节点画布) + const scaleW = drawArea.width / baseW; + const scaleH = drawArea.height / baseH; + const scale = Math.min(scaleW, scaleH); // 等比例缩放 + const renderW = baseW * scale; + const renderH = baseH * scale; + // 居中渲染 + const renderX = drawArea.x + (drawArea.width - renderW) / 2; + const renderY = drawArea.y + (drawArea.height - renderH) / 2; + + // 绘制图B(背景) + ctx.drawImage(this.imgB, renderX, renderY, renderW, renderH); + + // 绘制图A(前景,核心修复裁剪逻辑) + ctx.save(); + if (this.splitDirection === "vertical") { + // 左右分割:裁剪X轴(宽度比例) + const clipWidth = renderW * this.splitRatio; + ctx.rect(renderX, renderY, clipWidth, renderH); + } else { + // 上下分割:裁剪Y轴(高度比例,强制生效) + const clipHeight = renderH * this.splitRatio; + ctx.rect(renderX, renderY, renderW, clipHeight); + } + ctx.clip(); + ctx.drawImage(this.imgA, renderX, renderY, renderW, renderH); + ctx.restore(); + + // 绘制分割线(区分方向) + ctx.strokeStyle = this.splitDirection === "vertical" ? "#f4f4f4" : "#f0f0f0"; + ctx.lineWidth = 1; + if (this.splitDirection === "vertical") { + const splitX = renderX + renderW * this.splitRatio; + ctx.beginPath(); + ctx.moveTo(splitX, renderY); + ctx.lineTo(splitX, renderY + renderH); + } else { + const splitY = renderY + renderH * this.splitRatio; + ctx.beginPath(); + ctx.moveTo(renderX, splitY); + ctx.lineTo(renderX + renderW, splitY); + } + ctx.stroke(); + + // 绘制标签(区分方向) + ctx.fillStyle = "rgba(0,0,0,0.4)"; + if (this.splitDirection === "vertical") { + ctx.fillRect(renderX + 8, renderY + 10, 16, 16); + ctx.fillRect(renderX + renderW - 23, renderY + 10, 16, 16); + ctx.fillStyle = "#f4f4f4"; + ctx.font = "10px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("A", renderX + 16, renderY + 19); + ctx.fillText("B", renderX + renderW - 15, renderY + 19); + } else { + ctx.fillRect(renderX + renderW - 23, renderY + 10, 16, 16); + ctx.fillRect(renderX + renderW - 23, renderY + renderH - 35, 16, 16); + ctx.fillStyle = "#f0f0f0"; + ctx.font = "10px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("A", renderX + renderW - 15, renderY + 19); + ctx.fillText("B", renderX + renderW - 15, renderY + renderH - 26); + } + + // 绘制调试信息 + ctx.fillStyle = "#fff"; + ctx.font = "12px sans-serif"; + ctx.textAlign = "left"; + ctx.textBaseline = "bottom"; + ctx.fillText(`方向:${this.splitDirection} | 比例:${this.splitRatio.toFixed(2)} | 图1尺寸:${baseW}x${baseH}`, + drawArea.x + 10, drawArea.y + drawArea.height - 10); + }; + + // 隐藏后端默认图 + nodeType.prototype.onDrawBackground = function (ctx) {}; + }, + + async nodeCreated(node) { + if (node.comfyClass === "ImageABCompare") { + node.initState(); + // 立即绑定控件(缩短延迟) + setTimeout(() => node.bindDirectionControl(), 100); + setTimeout(() => node.bindDirectionControl(), 500); + } + }, + + async setup() { + console.log("✅ [ImageABCompare] 扩展初始化完成 V1.0"); + } +}); \ No newline at end of file diff --git a/nodes.py b/nodes.py new file mode 100644 index 0000000..ea70ec0 --- /dev/null +++ b/nodes.py @@ -0,0 +1,120 @@ +import torch +import numpy as np +from PIL import Image, ImageDraw +import folder_paths +import os +import uuid + + +def tensor2pil(image): + """将 torch tensor 转换为 PIL Image""" + if len(image.shape) == 4: + image = image[0] + image_np = image.cpu().numpy() + if image_np.dtype != np.uint8: + if image_np.max() <= 1.0: + image_np = (image_np * 255).astype(np.uint8) + else: + image_np = np.clip(image_np, 0, 255).astype(np.uint8) + if len(image_np.shape) == 3 and image_np.shape[2] == 1: + image_np = image_np.squeeze(2) + return Image.fromarray(image_np) + + +def pil2tensor(image): + """将 PIL Image 转换为 torch tensor""" + return torch.from_numpy(np.array(image).astype(np.float32) / 255.0).unsqueeze(0) + + +class ImageABCompare: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "ImageA": ("IMAGE",), + "ImageB": ("IMAGE",), + "split_direction": (["vertical", "horizontal"], {"default": "vertical"}), # 左右/上下 + "split_ratio_when_output": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), # 分割比例 + } + } + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("Image_output",) + FUNCTION = "compare_images" + CATEGORY = "🎨 图形比比看--ImageABCompareSee" + OUTPUT_NODE = True + + def compare_images(self, ImageA, ImageB, split_direction="vertical", split_ratio_when_output=0.5): + print(f"[ImageABCompare] 生成导出用对比图 - 方向: {split_direction}, 比例: {split_ratio_when_output}") + + # 转换图片(以image1尺寸为基准) + img1 = tensor2pil(ImageA) + img2 = tensor2pil(ImageB) + base_w, base_h = img1.size # 以图1尺寸为最终保存尺寸 + print(f"[ImageABCompare] 基准尺寸(图1): {base_w}x{base_h},图2原始尺寸: {img2.size}") + + # 保存原始图片到临时目录(供前端加载实时预览) + unique_id = str(uuid.uuid4())[:8] + img1_filename = f"ab_img1_{unique_id}.png" + img2_filename = f"ab_img2_{unique_id}.png" + output_dir = folder_paths.get_temp_directory() + img1.save(os.path.join(output_dir, img1_filename), format="PNG") + img2.save(os.path.join(output_dir, img2_filename), format="PNG") + + # 按规则调整图2尺寸(用于导出) + if split_direction == "vertical": + img2_resized = img2.resize((base_w, base_h), Image.Resampling.LANCZOS) + else: + img2_resized = img2.resize((base_w, base_h), Image.Resampling.LANCZOS) + + # 创建最终对比图画布(用于导出) + result_img = Image.new("RGB", (base_w, base_h)) + draw = ImageDraw.Draw(result_img) + + # 绘制对比图(用于导出) + if split_direction == "vertical": + split_x = int(base_w * split_ratio_when_output) + result_img.paste(img1.crop((0, 0, split_x, base_h)), (0, 0)) + result_img.paste(img2_resized.crop((split_x, 0, base_w, base_h)), (split_x, 0)) + draw.line([(split_x, 0), (split_x, base_h)], fill=(255, 0, 0), width=3) + else: + split_y = int(base_h * split_ratio_when_output) + result_img.paste(img1.crop((0, 0, base_w, split_y)), (0, 0)) + result_img.paste(img2_resized.crop((0, split_y, base_w, base_h)), (0, split_y)) + draw.line([(0, split_y), (base_w, split_y)], fill=(255, 0, 0), width=3) + + # 生成导出用tensor + result_tensor = pil2tensor(result_img) + + # 返回前端需要的所有参数(含实时比例) + return { + "ui": { + "images": [], # 不显示后端生成的静态图,避免覆盖前端画布 + "img1_filename": [img1_filename], # 前端加载图1用 + "img2_filename": [img2_filename], # 前端加载图2用 + "split_direction": [split_direction], + "split_ratio_when_output": [split_ratio_when_output], # 实时返回滑块值 + "base_size": [base_w, base_h] + }, + "result": (result_tensor,) # 输出tensor可连接保存图像节点 + } + +NODE_CLASS_MAPPINGS = { + "ImageABCompare": ImageABCompare +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "ImageABCompare": "图形比比看--ImageABCompareSee" +} + +if __name__ == "__main__": + print("=== 节点注册验证 ===") + print("注册的节点类:", NODE_CLASS_MAPPINGS.keys()) + print("显示名称映射:", NODE_DISPLAY_NAME_MAPPINGS) + + try: + node = ImageABCompare() + print("✅ 节点类实例化成功") + print("输入类型:", node.INPUT_TYPES()) + except Exception as e: + print("❌ 节点类实例化失败:", str(e)) \ No newline at end of file