Add files via upload

This commit is contained in:
shmbatom
2026-01-05 14:24:51 +08:00
committed by GitHub
parent 73096675a3
commit 0b33406d57
8 changed files with 645 additions and 0 deletions
+226
View File
@@ -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)
 
+8
View File
@@ -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"]
Binary file not shown.

After

Width:  |  Height:  |  Size: 1019 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 527 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+291
View File
@@ -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");
}
});
+120
View File
@@ -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))