Files
Hasasasa-ComfyUI_DashuaiTools/Image_Nodes/XY_Image.py
T
2025-12-03 20:00:17 +08:00

547 lines
25 KiB
Python

import json
import copy
import traceback
import re
from typing import Any, Dict, List
import sys
import os
import uuid
import hashlib
import gc
import torch
import numpy as np
from PIL import Image, ImageDraw, ImageFont
import nodes as comfy_nodes
import execution
import comfy.model_management
# --- 全局缓存 ---
# 结构: { "content_hash_string": [tensor1, tensor2, ...] }
_XY_GLOBAL_CACHE = {}
class XYImage:
"""
独立实现的 XY Plot 节点。
支持:
1. 智能缓存:只重跑参数变化的节点(极大提升速度)。
2. 样式缓存:只改网格/字体时不重跑。
3. 多种布局:XY Plot, X:Y, Y:X。
4. 显存优化:主动释放显存,防止 LoRA 切换时卡顿。
5. [修复] 能够从 SaveImage/PreviewImage 等非 Tensor 输出节点的上游抓取图片。
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
"gap_size": ("INT", {"default": 10, "min": 5, "max": 1000, "step": 5}),
"font_size": ("INT", {"default": 50, "min": 50, "max": 250, "step": 1}),
"panel_mode": (["XY Plot", "X:Y", "Y:X"],),
"x_attr": ("STRING", {"default": "", "multiline": True, "placeholder": "右键唤起参数浮窗\nX轴配置: <x:Label>[node_id:input]='value'"}),
"y_attr": ("STRING", {"default": "", "multiline": True, "placeholder": "右键唤起参数浮窗\nY轴配置: <y:Label>[node_id:input]='value'"}),
},
"hidden": {
"prompt": "PROMPT",
"extra_pnginfo": "EXTRA_PNGINFO",
"my_unique_id": "UNIQUE_ID",
},
}
RETURN_TYPES = ("IMAGE", "IMAGE")
RETURN_NAMES = ("images", "xyimage")
FUNCTION = "build_grid"
CATEGORY = "Image/XYZ"
# --- 智能检测:是否需要响应 ComfyUI 的执行请求 ---
@classmethod
def IS_CHANGED(cls, gap_size, font_size, panel_mode, x_attr, y_attr, images=None, **kwargs):
prompt = kwargs.get("prompt", None)
my_unique_id = kwargs.get("my_unique_id", None)
content_hash = cls._calculate_workflow_fingerprint(x_attr, y_attr, prompt, my_unique_id)
return f"{content_hash}_{gap_size}_{font_size}_{panel_mode}"
@classmethod
def _calculate_workflow_fingerprint(cls, x_attr, y_attr, prompt, ignore_id=None):
"""计算整个工作流的指纹,用于判断是否需要重跑"""
content_str = f"{x_attr}_{y_attr}"
if prompt:
try:
node_ids = sorted(list(prompt.keys()))
for nid in node_ids:
if ignore_id is not None and str(nid) == str(ignore_id): continue
node = prompt[nid]
inputs = node.get("inputs", {})
# [Opt] 使用 json.dumps 保证字典顺序一致,避免哈希抖动
try: inputs_str = json.dumps(inputs, sort_keys=True, default=str)
except: inputs_str = str(inputs)
content_str += f"|{nid}:{node.get('class_type','')}:{inputs_str}"
except: pass
return hashlib.md5(content_str.encode('utf-8')).hexdigest()
# --- 执行引擎核心方法 ---
def _extract_value(self, outputs_tuple, slot_index):
"""从节点输出元组中提取特定插槽的值"""
if outputs_tuple is None: return None
# 智能识别 WrappedTuple (某些 ComfyUI 环境)
is_container = isinstance(outputs_tuple, tuple)
if not is_container:
if hasattr(outputs_tuple, 'args') and hasattr(outputs_tuple, '__getitem__') and not isinstance(outputs_tuple, (dict, torch.Tensor)):
is_container = True
if is_container:
try:
# 获取长度
length = len(outputs_tuple) if isinstance(outputs_tuple, tuple) else len(outputs_tuple.args)
if slot_index < length:
return outputs_tuple[slot_index]
return outputs_tuple[0] # Fallback
except:
return outputs_tuple
return outputs_tuple
def _safe_to_cpu(self, obj):
"""[Opt] 递归将对象中的 Tensor 移动到 CPU 以节省显存"""
if isinstance(obj, torch.Tensor):
return obj.cpu()
elif isinstance(obj, dict):
# 处理 Latent 字典
return {k: self._safe_to_cpu(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [self._safe_to_cpu(v) for v in obj]
elif isinstance(obj, tuple):
return tuple(self._safe_to_cpu(v) for v in obj)
return obj
def _execute_single_node(self, node_class_mappings, class_type, func_inputs):
"""实例化并执行单个节点"""
node_cls = node_class_mappings.get(class_type)
if not node_cls: return None
try:
obj = node_cls()
func = getattr(obj, obj.FUNCTION)
res = func(**func_inputs)
# 统一包装为 tuple 以便后续处理
is_container = isinstance(res, tuple)
if not is_container:
if hasattr(res, 'args') and hasattr(res, '__getitem__') and not isinstance(res, (dict, torch.Tensor)):
is_container = True
if not is_container: res = (res,)
return res
except Exception as e:
# [Opt] 捕获错误但不崩溃,允许部分失败 (如 ShowText 节点报错)
print(f"[XY_Image] Warning: Node {class_type} execution failed: {e}")
return None
def _get_node_hash_and_output(self, node_id, prompt, batch_cache, current_step_cache, node_class_mappings):
"""
递归获取节点的 Hash 和 Output。
如果 Hash 在 batch_cache 中存在,则直接返回缓存结果(跳过执行)。
"""
node_id = str(node_id)
# 1. 检查本次迭代的递归缓存 (避免环路和重复计算)
if node_id in current_step_cache:
return current_step_cache[node_id]
if node_id not in prompt:
return None, None
node_data = prompt[node_id]
class_type = node_data.get("class_type")
inputs = node_data.get("inputs", {})
# 2. 递归处理依赖,并计算当前节点的输入指纹
input_hashes = []
func_inputs = {}
# 排序以保证 Hash 确定性
sorted_keys = sorted(inputs.keys())
for k in sorted_keys:
v = inputs[k]
# 检查是否为链接依赖: ["node_id", slot_index]
if isinstance(v, list) and len(v) == 2 and (isinstance(v[0], str) or isinstance(v[0], int)):
dep_id = str(v[0])
dep_slot = int(v[1])
# 递归调用
dep_hash, dep_outputs = self._get_node_hash_and_output(dep_id, prompt, batch_cache, current_step_cache, node_class_mappings)
if dep_hash is None:
# 上游失败
return None, None
# 将上游的 Hash 加入输入指纹 (而不是上游的值,值可能很大)
input_hashes.append(f"{k}:{dep_hash}:{dep_slot}")
# 提取实际值传给函数
val = self._extract_value(dep_outputs, dep_slot)
func_inputs[k] = val
else:
# 字面量输入
# [Opt] 使用 json.dumps 确保复杂结构(如List/Dict)的Hash稳定性
try: val_str = json.dumps(v, sort_keys=True, default=str)
except: val_str = str(v)
# 简易压缩长字符串
if len(val_str) > 100: val_str = hashlib.md5(val_str.encode()).hexdigest()
input_hashes.append(f"{k}:{val_str}")
func_inputs[k] = v
# 3. 计算当前节点的唯一指纹 (Class + Inputs Hash)
# 只要上游任何节点的参数变了,dep_hash 就会变,进而导致当前 node_hash 变,触发重跑
hash_src = f"{class_type}|{','.join(input_hashes)}"
node_hash = hashlib.md5(hash_src.encode('utf-8')).hexdigest()
# 4. 检查全局 Batch 缓存
if node_hash in batch_cache:
# 【缓存命中】直接使用之前算好的结果!
output = batch_cache[node_hash]
else:
# 【缓存未命中】必须执行
output = self._execute_single_node(node_class_mappings, class_type, func_inputs)
if output is not None:
# [Opt] 关键优化:存入缓存前将 Tensor 移至 CPU,防止 VRAM 挤兑
# 注意:ModelPatcher 等对象不受此影响,因为它们不是 Tensor
cached_output = self._safe_to_cpu(output)
batch_cache[node_hash] = cached_output
output = cached_output
# 5. 存入步骤缓存
current_step_cache[node_id] = (node_hash, output)
return node_hash, output
# --- 辅助方法 ---
def _parse_axis_config(self, text: str) -> Dict[str, Any]:
if not text or not isinstance(text, str): return None
result = {}
try:
text_clean = text.strip()
parts = text_clean.split('<')
current_axis_id = None
for part in parts:
part = part.strip()
if not part: continue
if '>' in part:
header, body = part.split('>', 1)
if ':' in header:
axis_id, label = header.split(':', 1)
axis_id = axis_id.strip(); label = label.strip()
current_axis_id = axis_id
result[axis_id] = {"label": label}
self._parse_params(body, result, axis_id)
else:
if current_axis_id: self._parse_params(part, result, current_axis_id)
return result if result else None
except: return None
def _parse_params(self, text, result, axis_id):
pattern = r'\[(\d+):([^\]]+)\]\s*=\s*["\']?([^\r\n"\']*)["\'"]?'
matches = re.findall(pattern, text)
for node_id, input_name, value in matches:
node_id = node_id.strip(); input_name = input_name.strip(); value = value.strip()
if node_id not in result[axis_id]: result[axis_id][node_id] = {}
result[axis_id][node_id][input_name] = value
def _convert_type(self, value, type_name):
try:
if type_name == 'INT': return int(float(str(value)))
elif type_name == 'FLOAT': return float(value)
elif type_name in ['BOOL', 'BOOLEAN']:
if isinstance(value, str): return value.lower() in ('true', '1', 'yes', 'on')
return bool(value)
return value
except: return value
def _apply_modifications(self, p, modifications, node_class_mappings):
if not modifications: return p
for node_id_str, inputs_dict in modifications.items():
if node_id_str == 'label': continue
target_key = None
if node_id_str in p: target_key = node_id_str
else:
for k in p.keys():
if str(k) == str(node_id_str): target_key = k; break
if not target_key: continue
try:
node = p[target_key]
class_type = node.get("class_type", "")
node_inputs = node.get("inputs", {})
class_def = node_class_mappings.get(class_type)
input_types = class_def.INPUT_TYPES() if class_def else {}
for input_name, value in inputs_dict.items():
if input_name == 'label': continue
type_name = None
if input_types:
for cat in ['required', 'optional']:
if input_name in input_types.get(cat, {}):
val_def = input_types[cat][input_name]
if isinstance(val_def, tuple) and len(val_def) > 0: type_name = val_def[0]; break
if type_name: node_inputs[input_name] = self._convert_type(value, type_name)
else: node_inputs[input_name] = value
except: pass
return p
def _create_grid_image(self, tensor_list, x_labels, y_labels, grid_spacing=10, font_size=32, layout="XY Plot"):
if not tensor_list: return None
pil_images = []
for t in tensor_list:
if t.ndim == 4: t = t.squeeze(0)
np_img = (t.cpu().numpy() * 255.0).clip(0, 255).astype(np.uint8)
pil_images.append(Image.fromarray(np_img))
if not pil_images: return None
w, h = pil_images[0].size
cols = len(x_labels); rows = len(y_labels)
font = None
try:
font_paths = ["simhei.ttf", "Microsoft YaHei.ttf", "arial.ttf", "/System/Library/Fonts/PingFang.ttc", "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc"]
for path in font_paths:
try: font = ImageFont.truetype(path, font_size); break
except: continue
if font is None: font = ImageFont.load_default()
except: font = ImageFont.load_default()
outer_margin = 20
if layout == "XY Plot":
header_h = int(font_size * 2) + 10
max_y_len = max([len(str(l)) for l in y_labels]) if y_labels else 0
sidebar_w = int(max_y_len * font_size * 1.0) + 40
if sidebar_w < 80: sidebar_w = 80
grid_w = sidebar_w + cols * w + (cols - 1) * grid_spacing + outer_margin * 2
grid_h = header_h + rows * h + (rows - 1) * grid_spacing + outer_margin * 2
else:
label_height = int(font_size * 1.5) + 10
grid_w = cols * w + (cols - 1) * grid_spacing + outer_margin * 2
grid_h = rows * (h + label_height) + (rows - 1) * grid_spacing + outer_margin * 2
grid_img = Image.new('RGB', (grid_w, grid_h), color='white')
draw = ImageDraw.Draw(grid_img)
def draw_text(draw, text, cx, cy, font):
try:
left, top, right, bottom = draw.textbbox((0, 0), str(text), font=font)
w, h = right - left, bottom - top
except: w, h = draw.textsize(str(text), font=font)
draw.text((cx - w / 2, cy - h / 2), str(text), fill="black", font=font)
if layout == "XY Plot":
for i, l in enumerate(x_labels):
cx = sidebar_w + outer_margin + i * (w + grid_spacing) + w // 2
draw_text(draw, l, cx, outer_margin + header_h // 2, font)
for j, l in enumerate(y_labels):
cy = header_h + outer_margin + j * (h + grid_spacing) + h // 2
draw_text(draw, l, (sidebar_w + outer_margin) // 2, cy, font)
for idx, img in enumerate(pil_images):
r = idx // cols; c = idx % cols
if r >= rows: break
if img.size != (w, h): img = img.resize((w, h))
grid_img.paste(img, (sidebar_w + outer_margin + c * (w + grid_spacing), header_h + outer_margin + r * (h + grid_spacing)))
else:
for idx, img in enumerate(pil_images):
r = idx // cols; c = idx % cols
if r >= rows: break
x = outer_margin + c * (w + grid_spacing)
y = outer_margin + r * (h + label_height + grid_spacing)
if img.size != (w, h): img = img.resize((w, h))
grid_img.paste(img, (x, y))
lbl = f"{x_labels[c]} : {y_labels[r]}" if layout == "X:Y" else f"{y_labels[r]} : {x_labels[c]}"
draw_text(draw, lbl, x + w // 2, y + h + label_height // 2, font)
np_grid = np.array(grid_img).astype(np.float32) / 255.0
return torch.from_numpy(np_grid).unsqueeze(0)
# --- 主入口 ---
def build_grid(self, images, gap_size, font_size, panel_mode, x_attr, y_attr, prompt=None, extra_pnginfo=None, my_unique_id=None):
global _XY_GLOBAL_CACHE
print(f"\n[XY_Image] ========== START ==========")
if comfy_nodes is None or not isinstance(prompt, dict):
return images, images
# 1. 计算 Content Hash
current_content_hash = self._calculate_workflow_fingerprint(x_attr, y_attr, prompt, my_unique_id)
x_points = self._parse_axis_config(x_attr) if x_attr else None
y_points = self._parse_axis_config(y_attr) if y_attr else None
x_keys = list(x_points.keys()) if x_points else [None]
y_keys = list(y_points.keys()) if y_points else [None]
x_labels = [str(x_points[k].get("label", k)) if x_points and k in x_points else "" for k in x_keys]
y_labels = [str(y_points[k].get("label", k)) if y_points and k in y_points else "" for k in y_keys]
result_tensors = None
# 2. 检查缓存是否命中
if current_content_hash in _XY_GLOBAL_CACHE:
print(f"[XY_Image] Content Cache HIT! Skipping workflow execution.")
result_tensors = _XY_GLOBAL_CACHE[current_content_hash]
else:
print(f"[XY_Image] Content Cache MISS. Starting Smart Execution...")
result_tensors = []
# 这是一个仅存在于本次 build_grid 执行周期的缓存
# 用于在 X/Y 循环之间共享节点结果 (如模型加载)
# Key: Node_Hash, Value: Output
batch_execution_cache = {}
NODE_CLASS_MAPPINGS = comfy_nodes.NODE_CLASS_MAPPINGS
total = len(x_keys) * len(y_keys)
iteration = 0
# 初始化历史记录,用于过滤重复日志
last_mods_log = {'x': None, 'y': None}
for yi, y_key in enumerate(y_keys):
y_mod = y_points.get(y_key, {}) if y_points else {}
for xi, x_key in enumerate(x_keys):
iteration += 1
x_mod = x_points.get(x_key, {}) if x_points else {}
print(f"[XY_Image] Iteration {iteration}/{total} (Y={yi+1}, X={xi+1})")
# --- 新增:打印本次运行变动的参数 (优化格式 & 去重) ---
current_log_parts = []
def get_clean_mod_str(mod_dict):
items = []
if not mod_dict: return ""
# 排序保证一致性
for nid in sorted(mod_dict.keys()):
if nid == 'label': continue
params = mod_dict[nid]
if isinstance(params, dict):
for k, v in params.items():
items.append(f"[{nid}].{k}={v}")
return ", ".join(items)
x_str = get_clean_mod_str(x_mod)
y_str = get_clean_mod_str(y_mod)
# 对比上次打印的内容,只有变动了才打印
# 分别跟踪 X 和 Y,避免同一行内 Y 参数重复刷屏
if x_str and x_str != last_mods_log['x']:
current_log_parts.append(f"X: {x_str}")
last_mods_log['x'] = x_str
if y_str and y_str != last_mods_log['y']:
current_log_parts.append(f"Y: {y_str}")
last_mods_log['y'] = y_str
if current_log_parts:
print(f"[XY_Image] Params: {' | '.join(current_log_parts)}")
# ------------------------------------
try:
p_copy = copy.deepcopy(prompt)
if x_mod: p_copy = self._apply_modifications(p_copy, x_mod, NODE_CLASS_MAPPINGS)
if y_mod: p_copy = self._apply_modifications(p_copy, y_mod, NODE_CLASS_MAPPINGS)
# 查找输出节点 (Leaves)
all_ids = set(p_copy.keys())
used_ids = set()
for n in p_copy.values():
for inp in n.get("inputs", {}).values():
if isinstance(inp, list) and len(inp) > 0: used_ids.add(str(inp[0]))
# 排除自己(my_unique_id)以免死循环
if my_unique_id: used_ids.add(str(my_unique_id))
target_nodes = list(all_ids - used_ids)
# 递归执行,带有 batch_execution_cache
current_step_cache = {} # 防止单次图中的环路
# 执行所有叶子节点(这将触发整图执行)
for t in target_nodes:
self._get_node_hash_and_output(t, p_copy, batch_execution_cache, current_step_cache, NODE_CLASS_MAPPINGS)
# --- 搜刮图片 (FIXED LOGIC) ---
# 原版代码只在 iter_outputs (叶子节点输出) 中找图片,导致 SaveImage (返回 Dict) 无法提供图片。
# 现在我们在 current_step_cache (所有已执行节点) 中找图片。
found_img = None
# 按 Node ID 倒序搜索 (通常后面的 ID 是生成结果)
sorted_cache_ids = sorted(list(current_step_cache.keys()), key=lambda x: int(x) if str(x).isdigit() else 0, reverse=True)
def get_items(c):
if isinstance(c, tuple): return list(c)
if hasattr(c, 'args'): return list(c.args) if hasattr(c.args, '__iter__') else [c.args]
return [c]
for nid in sorted_cache_ids:
# cache 结构: { node_id: (hash, output_tuple) }
_, outs = current_step_cache[nid]
try:
candidates = get_items(outs)
for item in candidates:
if isinstance(item, torch.Tensor):
# 检查是否为图片张量 [B, H, W, C] 或 [B, C, H, W]
# ComfyUI 标准图片是 [B, H, W, 3]
if item.ndim == 4 and item.shape[-1] in [3, 4]:
found_img = item; break
# 某些特殊节点可能是 [C, H, W] ? 兼容性处理
if item.ndim == 3 and item.shape[-1] in [3, 4]:
found_img = item.unsqueeze(0); break
if found_img is not None: break
except: pass
if found_img is not None:
# [Opt] 将搜刮到的最终图片也放到 CPU
result_tensors.append(found_img.cpu())
else:
print(f"[XY_Image] Warning: No image found in iteration {iteration}")
# Fallback: 使用输入占位图
if isinstance(images, torch.Tensor):
result_tensors.append(images[0].unsqueeze(0).cpu())
except Exception as e:
print(f"[XY_Image] Iteration failed: {e}")
traceback.print_exc()
# [Crucial Optimization] 每次循环结束后,主动清理显存
# 这会移除当前循环中加载的临时模型(如被替换掉的 LoRA),防止显存挤兑
try:
gc.collect()
comfy.model_management.soft_empty_cache()
except:
pass
if result_tensors:
_XY_GLOBAL_CACHE.clear()
_XY_GLOBAL_CACHE[current_content_hash] = result_tensors
# 3. 拼图
grid_tensor = None
try:
if result_tensors:
print(f"[XY_Image] Generating grid: {panel_mode}...")
grid_tensor = self._create_grid_image(result_tensors, x_labels, y_labels, gap_size, font_size, panel_mode)
except Exception as e:
print(f"[XY_Image] Grid generation failed: {e}")
traceback.print_exc()
if grid_tensor is None: grid_tensor = images
try:
final_batch = torch.cat(result_tensors, dim=0) if result_tensors else images
except:
final_batch = result_tensors[0] if result_tensors else images
print(f"[XY_Image] Done.")
return final_batch, grid_tensor
NODE_CLASS_MAPPINGS = {
"XY_Image": XYImage
}
NODE_DISPLAY_NAME_MAPPINGS = {
"XY_Image": "XYImage☀"
}