Files
electricty00-ComfyUI-Toggle…/pose_reference.py
T
GBrain af57d757eb feat: PoseReference node, sampler presets, model-type rename, auto clip_type rework, output reorder
新增 / New
- PoseReference 节点:姿势素材库(线稿 / 深度两套)+ 节点内缩略图,选中素材以 data URI 内嵌进工作流
- 采样器「⚡预设」下拉:一键填 采样器/调度器/步数/CFG/Sigma曲线;预设表只有一份在 sampler.py,
  前端走 /toggle_pass/sampler_presets 拉取;手改任一参数自动退回「手动(不套用)」
- ToggleEmptyLatent 新增 latent类型(4 档:SD 4ch/8x、SD3-Flux-Krea2 16ch/8x、Qwen2.1 64ch/16x、Flux2 128ch/16x)
- DynamicRefImageEncode 新增专门的 negative 输出(内部编空文本),替代 ConditioningZeroOut

变更 / Changed
- 模型类型:SD / SDXL → SD / SDXL / Z-Image(前后端双层别名迁移,旧工作流自动改写)
- DynamicRefImageEncode 输出口顺序 → conditioning / negative / latent,老工作流按 link 类型自动迁移连线
- clip_type 从 14 项补到 40 项;auto 重做为「精确表 → MRO 继承链 → 前缀规则」三层
- CLIP 校验加固:手工表未登记的模型改问 model_config.clip_target(),覆盖 106/108 个模型类
- 统一加载器支持 FIBO 2.0 / MOSS-Image;采样器预设新增 FIBO 2.0 turbo(4步)

修复 / Fixed
- Krea2 的 reference_latents_method: index_timestep_zero → index(前者会把参考线稿「描」进画面)
- Krea2 参考图 latent fit 到出图网格
- 修正三处 Flux2 空 latent 的通道 / 尺寸(Ref Image Encode / OneImageMultiPrompt / Ref Independent)
- 广播:槽位小点点亮、端点圆点改到节点下层、修「紫线伸向空白处断掉」(零尺寸 boundingRect 误判)
- 输出口顺序迁移只在 onConfigure 跑一次太早(graph.links 要几百毫秒才建好)⇒ 改为阶梯重试,
  否则会出现「槽位改名了、连线没搬」

其他 / Misc
- 新增诊断命令 __tpAudit():打印每条广播线两端坐标 / 归属节点 / 是否悬空
- 画布菜单新增「🔄 刷新姿势素材库」
- 移除示例 Krea2 Pose Editing (Ostris).json(硬依赖外部节点包且会换人)

版本 / Version
- 新增 pyproject.toml(ComfyUI-Manager / Registry 元数据入口),版本号 v1.0.0
- __init__.py 增加 __version__,启动打印 [Toggle-Pass] v1.0.0 loaded — 13 nodes
- README 顶部加版本标识
2026-11-07 21:29:09 +08:00

532 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""参考姿势(Pose Reference)—— 从姿势素材库里取一张参考图,喂给编辑模型。
素材分两层,运行时合并成一个索引:
1. **插件自带** `pose_assets/` —— 随插件分发,只放非敏感大类(上半身单人、
全身单人・标准系),别人装完节点就能用。
2. **本机外挂** —— 默认 `D:\\pose`,可用环境变量 `TP_POSE_ROOT` 改(也可放
`ComfyUI/models/pose_reference`)。敏感或自建素材丢进去,刷新即出现,不进 git。
查找顺序是「本机外挂 → models → 插件自带」,所以同名素材以本机为准。
素材目录结构(每一层根目录都一样):
<root>/01_AI线稿姿势/<大类>/<姿势组>/<编号_姿势名>/<编号_视角>.webp
<root>/02_AI深度姿势库/<同上>/
线稿与深度两套的文件名、目录结构一一对应,所以只需扫一套来建索引,
取图时按 source 决定去哪个目录拿。
选线稿还是深度:实测(FIBO 2.0 / Qwen-Image-2.1 各两组对照)深度图因为
没有边界线,脚底和地面的亮度几乎相同,模型会把亮部补成不存在的鞋或护具
(3/3 复现);线稿有明确黑线,1/3 才出现小瑕疵。默认用线稿。
深度的唯一优势是遮挡关系(手臂在腿前还是后),复杂交叠姿势可以切过去。
"""
import base64
import hashlib
import io
import json
import mimetypes
import os
import re
import numpy as np
import torch
from PIL import Image, ImageFile, ImageOps
import folder_paths
ImageFile.LOAD_TRUNCATED_IMAGES = True
DEFAULT_ROOT = r"D:\pose"
BUILTIN_ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "pose_assets")
IMAGE_EXT = (".webp", ".png", ".jpg", ".jpeg", ".bmp")
# 视角按常见构图顺序排,不跟着中文编码字典序走
VIEW_ORDER = ["正面", "侧面", "斜侧", "俯视", "仰视"]
_cache = {"stamp": None, "tree": None}
def _roots():
"""按优先级返回可用的素材根目录(前面的优先)。
顺序:TP_POSE_ROOT(或默认 D:\\pose)→ models/pose_reference → 插件自带。
本机目录在前,所以自己放的素材会覆盖插件自带里的同名额。
"""
cands = []
env = os.environ.get("TP_POSE_ROOT")
if env:
cands.append(env)
elif DEFAULT_ROOT:
cands.append(DEFAULT_ROOT)
try:
cands.append(os.path.join(folder_paths.models_dir, "pose_reference"))
except Exception:
pass
cands.append(BUILTIN_ROOT)
out = []
for d in cands:
if d and os.path.isdir(d) and os.path.abspath(d) not in [os.path.abspath(x) for x in out]:
out.append(d)
return out
def _root():
"""兼容旧调用:返回主素材目录(没有可用的就返回默认路径,方便报错信息)。"""
roots = _roots()
return roots[0] if roots else DEFAULT_ROOT
def _mtime(path):
try:
return os.stat(path).st_mtime
except OSError:
return 0.0
def _stamp(root):
"""素材指纹,扫到「大类/姿势组」这一层。
只看根目录一层不够:往已有大类里新放一个姿势组,根目录 mtime 不会变,
缓存就永远不刷新。所以按 套/大类/组 逐层取 mtime,外加每组里的条目数。
"""
if not os.path.isdir(root):
return None
parts = []
for set_name in sorted(os.listdir(root)):
set_path = os.path.join(root, set_name)
if not os.path.isdir(set_path):
continue
parts.append("{0}:{1:.0f}".format(set_name, _mtime(set_path)))
for cat in sorted(os.listdir(set_path)):
cat_path = os.path.join(set_path, cat)
if not os.path.isdir(cat_path):
continue
parts.append(" {0}:{1:.0f}".format(cat, _mtime(cat_path)))
for grp in sorted(os.listdir(cat_path)):
grp_path = os.path.join(cat_path, grp)
if not os.path.isdir(grp_path):
continue
try:
n = len(os.listdir(grp_path))
except OSError:
n = -1
parts.append(" {0}:{1:.0f}:{2}".format(grp, _mtime(grp_path), n))
return "|".join(parts)
def _stamp_all():
"""所有根目录的总指纹(前端轮询它判断要不要重建下拉)。"""
roots = _roots()
return "||".join("{0}#{1}".format(r, _stamp(r) or "-") for r in roots), roots
def _pick_sets(root):
"""按目录名里的关键词区分线稿套与深度套,认不出就退回「前两个目录」。"""
lineart = depth = None
subs = []
if os.path.isdir(root):
subs = [d for d in sorted(os.listdir(root)) if os.path.isdir(os.path.join(root, d))]
for d in subs:
low = d.lower()
if lineart is None and ("线稿" in d or "lineart" in low or low.startswith("01")):
lineart = os.path.join(root, d)
elif depth is None and ("深度" in d or "depth" in low or low.startswith("02")):
depth = os.path.join(root, d)
if lineart is None and subs:
lineart = os.path.join(root, subs[0])
if depth is None and len(subs) > 1:
depth = os.path.join(root, subs[1])
return lineart, depth
def _view_of(filename):
stem = os.path.splitext(filename)[0]
return re.sub(r"^P\d+_", "", stem)
def _view_key(view):
try:
return (VIEW_ORDER.index(view), "")
except ValueError:
return (len(VIEW_ORDER), view)
def _scan(base):
"""扫一套素材,返回 {大类: {姿势组: {姿势目录: [视角...]}}}。"""
tree = {}
if not base or not os.path.isdir(base):
return tree
for cat in sorted(os.listdir(base)):
cat_path = os.path.join(base, cat)
if not os.path.isdir(cat_path):
continue
groups = {}
for group in sorted(os.listdir(cat_path)):
group_path = os.path.join(cat_path, group)
if not os.path.isdir(group_path):
continue
poses = {}
for pose in sorted(os.listdir(group_path)):
pose_path = os.path.join(group_path, pose)
if not os.path.isdir(pose_path):
continue
try:
names = os.listdir(pose_path)
except OSError:
continue
views = sorted(
{_view_of(f) for f in names if f.lower().endswith(IMAGE_EXT)},
key=_view_key,
)
if views:
poses[pose] = views
if poses:
groups[group] = poses
if groups:
tree[cat] = groups
return tree
def _merge_into(dst, src):
"""把 src 的 大类/组/姿势/视角 并进 dst:取并集,已有顺序不动。"""
for cat, group_map in src.items():
dcat = dst.setdefault(cat, {})
for group, pose_map in group_map.items():
dgrp = dcat.setdefault(group, {})
for pose, views in pose_map.items():
dviews = dgrp.setdefault(pose, [])
for v in views:
if v not in dviews:
dviews.append(v)
dviews.sort(key=_view_key)
def pose_tree(force=False):
"""把各根目录的线稿套合并成一份带缓存的索引(本机目录在前)。"""
stamp, roots = _stamp_all()
if not force and _cache["stamp"] == stamp and _cache["tree"] is not None:
return _cache["tree"]
tree = {}
for root in roots:
lineart, _depth = _pick_sets(root)
if lineart and os.path.isdir(lineart):
_merge_into(tree, _scan(lineart))
_cache["stamp"] = stamp
_cache["tree"] = tree
return tree
def flatten(tree):
"""下拉框用的三个平铺清单:大类 / 姿势组 / 姿势全名。"""
cats, groups, poses, views = [], [], [], []
for cat, group_map in tree.items():
cats.append(cat)
for group, pose_map in group_map.items():
if group not in groups:
groups.append(group)
for pose, view_list in pose_map.items():
if pose not in poses:
poses.append(pose)
for v in view_list:
if v not in views:
views.append(v)
views.sort(key=_view_key)
return cats, groups, poses, views
def locate(category, group, pose, view):
"""定位到具体文件用的四元组,容错到「只要姿势名对」也能找到。"""
tree = pose_tree()
if category in tree and group in tree[category] and pose in tree[category][group]:
views = tree[category][group][pose]
if view in views:
return category, group, pose, view
if views:
return category, group, pose, views[0]
for cat, group_map in tree.items():
for grp, pose_map in group_map.items():
if pose in pose_map:
views = pose_map[pose]
return cat, grp, pose, (view if view in views else views[0])
raise ValueError(
"在姿势素材库里找不到 {0} / {1} / {2}。已搜索:{3}".format(
category, group, pose, ";".join(_roots()) or "无可用素材目录")
)
def build_path(category, group, pose, view, source):
"""按根目录优先级找文件:本机放的同名素材会盖过插件自带的。"""
tried = []
for root in _roots():
lineart, depth = _pick_sets(root)
base = depth if source == "深度" else lineart
if not base or not os.path.isdir(base):
tried.append("{0}(没有{1}套)".format(root, source))
continue
folder = os.path.join(base, category, group, pose)
if not os.path.isdir(folder):
tried.append(folder)
continue
for fn in sorted(os.listdir(folder)):
if fn.lower().endswith(IMAGE_EXT) and _view_of(fn) == view:
return os.path.join(folder, fn)
tried.append("{0}(无 {1} 视角)".format(folder, view))
raise ValueError(
"找不到 {0} / {1} / {2} / {3}。已搜索:{4}".format(
category, group, pose, view, ";".join(tried) or "无可用素材目录")
)
# 视觉编码器 / VAE 的下采样倍数是 8,加上 patch 2 ⇒ 实际要求 16 的倍数。
# 不满足时 VAEEncode 会自己 pad 或 crop,边缘发生轻微位移 —— 线稿/深度图对位移特别敏感,
# 会表现成「人物整体歪了一点点」。
ALIGN = 16
def _align_down(v, m=ALIGN):
"""向下取到 m 的倍数。比 m 还小就不动 —— 把 8px 硬拉到 16px 只会更糊。"""
if v < m:
return max(1, v)
return v - (v % m)
def _resize(img, long_edge):
"""按长边等比缩放,再把宽高对齐到 ALIGN 的倍数。"""
if long_edge and long_edge > 0:
w, h = img.size
scale = float(long_edge) / max(w, h)
if abs(scale - 1.0) > 1e-3:
img = img.resize((max(1, round(w * scale)), max(1, round(h * scale))), Image.LANCZOS)
w, h = img.size
aw, ah = _align_down(w), _align_down(h)
if (aw, ah) != (w, h):
# 用缩放而不是裁切:1% 以内的形变看不出来,裁切却可能切掉脚尖或手指
img = img.resize((aw, ah), Image.LANCZOS)
return img
def _load(path, long_edge, flip):
img = Image.open(path)
img = ImageOps.exif_transpose(img).convert("RGB")
if flip:
img = ImageOps.mirror(img)
return _resize(img, long_edge)
# ---- 工作流内嵌图:让别人下载工作流后,没有素材库也能看见图、也能出图 ----
EMBED_PROP = "tp_pose"
EMBED_MAX_BYTES = 2 * 1024 * 1024 # 防御:内嵌字段异常大就不解析
def embed_key(category, group, pose, view, source):
"""前端与后端必须用同一套拼法,回退时才对得上。"""
return "|".join(str(x) for x in (category, group, pose, view, source))
def _node_properties(extra_pnginfo, unique_id):
"""从工作流 JSON 里捞出本节点的 properties(properties 不会进 prompt inputs)。"""
try:
wf = (extra_pnginfo or {}).get("workflow") or {}
for node in wf.get("nodes") or []:
if str(node.get("id")) == str(unique_id):
return node.get("properties") or {}
except Exception:
pass
return {}
def _decode_data_uri(data_uri):
"""把前端存进 properties 的 data URI 解回 PIL 图。"""
if not isinstance(data_uri, str) or "," not in data_uri:
return None
head, _, payload = data_uri.partition(",")
if "base64" not in head or len(payload) > EMBED_MAX_BYTES:
return None
try:
raw = base64.b64decode(payload)
img = Image.open(io.BytesIO(raw))
return ImageOps.exif_transpose(img).convert("RGB")
except Exception:
return None
def _png_bytes(img):
buf = io.BytesIO()
img.save(buf, format="PNG", compress_level=1)
return buf.getvalue()
class PoseReference:
"""从本地姿势素材库取一张参考图。"""
@classmethod
def INPUT_TYPES(cls):
tree = pose_tree()
cats, groups, poses, views = flatten(tree)
if not cats:
cats, groups, poses, views = ["(未找到素材)"], ["—"], ["—"], ["—"]
return {
"required": {
"category": (cats, {"tooltip": "素材大类"}),
"group": (groups, {"tooltip": "姿势组,如 站姿 / 坐姿"}),
"pose": (poses, {"tooltip": "具体姿势,编号_名称"}),
"view": (views, {"tooltip": "机位视角"}),
},
"optional": {
"source": (["线稿", "深度"], {"default": "线稿", "tooltip": "线稿更稳;深度适合肢体交叠多、需要遮挡关系的姿势"}),
"long_edge": ("INT", {"default": 1024, "min": 256, "max": 4096, "step": 64,
"tooltip": "输出图长边像素,素材原始只有 300x400"}),
# 用下拉而不是 BOOL:1.57 会把 BOOL 渲染成可接线插槽,节点上看不到勾选框
"flip": (["关", "开"], {"default": "关", "tooltip": "水平翻转,左右镜像的姿势不用另找"}),
},
# properties 不会进 prompt inputs,只能靠 extra_pnginfo 把整份工作流带过来,
# 从里面按 unique_id 找到本节点,读内嵌图
"hidden": {
"unique_id": "UNIQUE_ID",
"extra_pnginfo": "EXTRA_PNGINFO",
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "load"
CATEGORY = "Toggle-Pass"
DESCRIPTION = ("从本地姿势素材库取参考图,输出 IMAGE。接 Qwen-Image-2.1 / MOSS / FIBO 的 images 输入;"
"接 2511 / FLUX.2 这类编辑模型时必须多图:原图接 image1,本节点接 image2,"
"否则模型会把线稿当成待修改的原图,出图但姿势不变。"
"Krea 2 只有 Ostris 节点(TextEncodeKrea2OstrisEdit)能吃骨架图,接 image2;"
"krea2Edit 那套的 image_b 是「第二个主体」,接进来线稿会被画进画面。")
def load(self, category, group, pose, view, source="线稿", long_edge=1024, flip="关",
unique_id=None, extra_pnginfo=None):
want = embed_key(category, group, pose, view, source)
embed = (_node_properties(extra_pnginfo, unique_id) or {}).get(EMBED_PROP) or {}
origin = "素材库"
try:
cat, grp, ps, vw = locate(category, group, pose, view)
path = build_path(cat, grp, ps, vw, source)
img = _load(path, long_edge, flip in (True, "开"))
except Exception as exc:
# 素材库里没有 → 用工作流内嵌的那张。别人下载工作流就是走这条路
img0 = _decode_data_uri(embed.get("img"))
if img0 is None:
raise
print("[PoseReference] 素材库取图失败({0}),改用工作流内嵌图".format(str(exc)[:140]))
if embed.get("key") != want:
print("[PoseReference] 提示:内嵌图对应「{0}」,与当前所选「{1}」不同".format(
embed.get("key"), want))
if flip in (True, "开"):
img0 = ImageOps.mirror(img0)
img = _resize(img0, long_edge)
cat, grp, ps, vw = category, group, pose, view
origin = "内嵌"
arr = np.array(img).astype(np.float32) / 255.0
tensor = torch.from_numpy(arr)[None,]
# 这里**故意不返回 ui.images**。一旦返回,ComfyUI 1.57 前端就会给节点设
# node.imgs,进而挂一个 `$$canvas-image-preview` 的官方预览 widget:它占掉
# 节点底部一行,并把这张执行结果画在预览区上面。于是「生图一开始」画面就被
# 一张定死的图盖住 —— 下拉照点、节点自己画的预览在背面其实也换了,但看起来
# 就是再也切不动。本节点的预览由前端 onDrawForeground 自绘,不需要这一份。
return {"ui": {"text": ["{0}/{1}/{2}/{3} · {4} · {5}".format(
cat, grp, ps, vw, source, origin)]},
"result": (tensor,)}
NODE_CLASS_MAPPINGS = {
"PoseReference": PoseReference,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"PoseReference": "参考姿势 Pose Reference",
}
# ---- 给前端联动用的目录树接口 ----
def _register_routes():
try:
from aiohttp import web
from server import PromptServer
instance = getattr(PromptServer, "instance", None)
routes = getattr(instance, "routes", None) if instance is not None else None
if routes is None:
return
@routes.get("/toggle_pass/pose_tree")
async def _pose_tree(request):
try:
force = request.rel_url.query.get("refresh") in ("1", "true")
tree = pose_tree(force=force)
cats, groups, poses, views = flatten(tree)
return web.json_response({"tree": tree, "categories": cats,
"groups": groups, "poses": poses,
"views": views, "root": _root(),
"roots": _roots()})
except Exception as exc:
return web.json_response({"error": str(exc), "tree": {}}, status=500)
@routes.get("/toggle_pass/pose_stamp")
async def _pose_stamp(request):
"""轻量指纹:前端每隔十几秒问一次,变了才重新拉整个 tree。
往素材目录里丢新文件(尤其是敏感那几类放本机)后,不用刷新浏览器,
下拉自己就会长出来。
"""
try:
stamp, roots = _stamp_all()
return web.json_response({"stamp": hashlib.md5(stamp.encode("utf-8")).hexdigest(),
"roots": roots, "count": len(roots)})
except Exception as exc:
return web.json_response({"error": str(exc)}, status=500)
@routes.get("/toggle_pass/pose_image")
async def _pose_image(request):
"""给前端节点内缩略图 + 内嵌用:按参数把选中的那张姿势吐回去。
参数全在 query 里,同一个 URL 永远对应同一张图,所以放开浏览器缓存,
来回切下拉不重复解码。
`raw=1` 时直接返回素材原文件(webp,约 5KB),不缩放不翻转 —— 前端拿它
当缩略图显示、同时 base64 内嵌进工作流;翻转由前端绘制时镜像。
"""
try:
q = request.rel_url.query
source = q.get("source", "线稿")
try:
long_edge = int(q.get("long_edge", "0") or 0)
except ValueError:
long_edge = 0
flip = q.get("flip") in ("开", "1", "true", "True")
cat, grp, ps, vw = locate(q.get("category", ""), q.get("group", ""),
q.get("pose", ""), q.get("view", ""))
path = build_path(cat, grp, ps, vw, source)
# 不能强缓存(max-age):同一个 URL 对应的素材文件可能被同名替换,
# 强缓存会让浏览器 24 小时都拿旧图,换了素材看起来像"切换不了"。
# 改用 ETag 逐次校验:没变返回 304(几乎零成本),变了立刻给新内容。
mtime_ms = int(os.path.getmtime(path) * 1000)
size = os.path.getsize(path)
etag = '"{0}-{1}-{2}-{3}"'.format(mtime_ms, size, long_edge, int(flip))
headers = {"Cache-Control": "no-cache", "ETag": etag}
if request.headers.get("If-None-Match") == etag:
return web.Response(status=304, headers=headers)
if q.get("raw") in ("1", "true", "开"):
with open(path, "rb") as fh:
body = fh.read()
mime = mimetypes.guess_type(path)[0] or "image/webp"
return web.Response(body=body, content_type=mime, headers=headers)
img = _load(path, long_edge, flip)
return web.Response(body=_png_bytes(img), content_type="image/png", headers=headers)
except Exception as exc:
return web.json_response({"error": str(exc)}, status=404)
except Exception:
# 独立导入 / 无 server 模块时静默跳过,不影响节点本身
return
_register_routes()