This commit is contained in:
smthemex
2026-11-06 13:47:26 +08:00
parent a5b97bb7ce
commit 343b27520a
45 changed files with 1822 additions and 199653 deletions
+4
View File
@@ -0,0 +1,4 @@
"""Native MS-Diffusion nodes: pure torch + ComfyUI core."""
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+546
View File
@@ -0,0 +1,546 @@
"""MS-Diffusion native core.
Pure torch + ComfyUI core APIs. No diffusers, no transformers, no einops,
no omegaconf, no PIL. Everything here is a line-by-line port of the upstream
MS-Diffusion inference path (D:/Downloads/MS-Diffusion-main):
msdiffusion/models/projection.py -> FourierEmbedder / Resampler
msdiffusion/models/attention_processor.py-> MaskedIPAttnProcessor2_0
msdiffusion/models/model.py -> MSAdapter.generate (conditioning assembly)
The SDXL UNet itself, text encoding, CLIP-Vision, KSampler and VAE are all
left to ComfyUI native nodes. This file only supplies the *patch* that turns
native attn2 into the MS-Diffusion dual-branch cross-attention.
Key semantic facts (verified against real weights on 2026-11-06):
*_ms_adapter.bin* has exactly 70 (to_k_ip, to_v_ip) pairs, indexed
1,3,...,139 in diffusers attn_processors order. That order is
down_blocks -> up_blocks -> mid_block (module *registration* order),
whereas ComfyUI *executes* input_blocks -> middle_block -> output_blocks.
The two must be permuted, see build_block_weights().
* diffusers and ComfyUI use the SAME head partition for SDXL:
inner_dim 640 -> 10 heads, 1280 -> 20 heads, head_dim 64.
"""
import math
import os
import uuid
import torch
import torch.nn as nn
import torch.nn.functional as F
NUM_TOKENS = 16 # image tokens per subject
NUM_DUMMY_TOKENS = 4 # learnable "background" image tokens
TEXT_TOKENS = 77 # upstream hard-codes this split point
VISION_DIM = 1664 # CLIP-ViT-bigG/14 penultimate hidden size
CROSS_DIM = 2048 # SDXL cross_attention_dim
PHRASE_DIM = 768 # CLIP-L pooled output
RESAMPLER_DIM = 1280
FOURIER_FREQS = 8
HEAD_DIM = 64 # SDXL attention head dim (both frameworks)
# ---------------------------------------------------------------------------
# projection.py port (einops removed; runtime never touches masked_mean/Rearrange)
# ---------------------------------------------------------------------------
class FourierEmbedder(nn.Module):
def __init__(self, num_freqs=64, temperature=100):
super().__init__()
self.num_freqs = num_freqs
self.temperature = temperature
freq_bands = temperature ** (torch.arange(num_freqs) / num_freqs)
self.register_buffer("freq_bands", freq_bands[None, None], persistent=False)
def __call__(self, x):
x = self.freq_bands * x.unsqueeze(-1)
return torch.stack((x.sin(), x.cos()), dim=-1).permute(0, 2, 3, 1).reshape(x.shape[0], -1)
def reshape_tensor(x, heads):
bs, length, width = x.shape
x = x.view(bs, length, heads, -1).transpose(1, 2)
return x.reshape(bs, heads, length, -1)
class PerceiverAttention(nn.Module):
def __init__(self, dim, dim_head=64, heads=8):
super().__init__()
self.scale = dim_head ** -0.5
self.dim_head = dim_head
self.heads = heads
inner_dim = dim_head * heads
self.norm1 = nn.LayerNorm(dim)
self.norm2 = nn.LayerNorm(dim)
self.to_q = nn.Linear(dim, inner_dim, bias=False)
self.to_kv = nn.Linear(dim, inner_dim * 2, bias=False)
self.to_out = nn.Linear(inner_dim, dim, bias=False)
def forward(self, x, latents):
x = self.norm1(x)
latents = self.norm2(latents)
b, l, _ = latents.shape
q = self.to_q(latents)
kv_input = torch.cat((x, latents), dim=-2)
k, v = self.to_kv(kv_input).chunk(2, dim=-1)
q = reshape_tensor(q, self.heads)
k = reshape_tensor(k, self.heads)
v = reshape_tensor(v, self.heads)
# upstream stability trick: scale BOTH operands, float32 softmax
scale = 1 / math.sqrt(math.sqrt(self.dim_head))
weight = (q * scale) @ (k * scale).transpose(-2, -1)
weight = torch.softmax(weight.float(), dim=-1).type(weight.dtype)
out = weight @ v
out = out.permute(0, 2, 1, 3).reshape(b, l, -1)
return self.to_out(out)
def feed_forward(dim, mult=4):
inner_dim = int(dim * mult)
return nn.Sequential(
nn.LayerNorm(dim),
nn.Linear(dim, inner_dim, bias=False),
nn.GELU(),
nn.Linear(inner_dim, dim, bias=False),
)
class Resampler(nn.Module):
"""Grounding-mode Resampler, key-for-key identical to upstream projection.py."""
def __init__(self, dim=RESAMPLER_DIM, depth=4, dim_head=64, heads=20,
num_queries=NUM_TOKENS, embedding_dim=VISION_DIM,
output_dim=CROSS_DIM, ff_mult=4, max_seq_len=257,
phrase_embeddings_dim=PHRASE_DIM, fourier_freqs=FOURIER_FREQS):
super().__init__()
self.num_queries = num_queries
self.grounding_token_num = num_queries
self.dim = dim
self.latent_init_mode = "grounding"
self.grounding_latents = nn.Parameter(torch.randn(1, num_queries, dim) / dim ** 0.5)
self.fourier_embedder = FourierEmbedder(num_freqs=fourier_freqs)
grounding_embedding_dim = phrase_embeddings_dim + fourier_freqs * 2 * 4
self.latent_proj = nn.Sequential(
nn.Linear(grounding_embedding_dim, grounding_embedding_dim * 2),
nn.GELU(),
nn.Linear(grounding_embedding_dim * 2, dim * num_queries),
)
self.latent_norm = nn.LayerNorm(dim)
self.proj_in = nn.Linear(embedding_dim, dim)
self.attention_norm = nn.LayerNorm(dim)
self.proj_out = nn.Linear(dim, output_dim)
self.norm_out = nn.LayerNorm(output_dim)
self.layers = nn.ModuleList([])
for _ in range(depth):
self.layers.append(nn.ModuleList([
PerceiverAttention(dim=dim, dim_head=dim_head, heads=heads),
feed_forward(dim=dim, mult=ff_mult),
]))
def forward(self, x, grounding_kwargs=None, shortcut=False, scale=1.0):
boxes = grounding_kwargs["boxes"]
phrase_embeds = grounding_kwargs["phrase_embeds"]
fourier_embeds = self.fourier_embedder(boxes)
grounding_embeds = torch.cat((phrase_embeds, fourier_embeds), dim=-1)
drop_grounding_tokens = grounding_kwargs["drop_grounding_tokens"]
num_ref = x.shape[0] // len(drop_grounding_tokens)
drop_grounding_tokens = [item for item in drop_grounding_tokens for _ in range(num_ref)]
latents = self.latent_proj(grounding_embeds)
latents = latents.view(-1, self.grounding_token_num, self.dim)
latents = self.latent_norm(latents)
drop_num = len([item for item in drop_grounding_tokens if item == 1])
if drop_num > 0:
latents_ = []
learnable_latents = self.grounding_latents.repeat(drop_num, 1, 1)
cur_idx = 0
for latent, flag in zip(latents, drop_grounding_tokens):
if flag == 1:
latent = learnable_latents[cur_idx]
cur_idx += 1
latents_.append(latent)
latents = torch.stack(latents_)
x = self.proj_in(x)
init_latents = latents
for attn, ff in self.layers:
latents = attn(x, latents) + latents
latents = ff(latents) + latents
latents = self.attention_norm(latents)
latents = self.proj_out(latents)
if shortcut:
latents = init_latents + latents * scale
return self.norm_out(latents)
class IPProjection(nn.Module):
def __init__(self, hidden_size):
super().__init__()
self.to_k_ip = nn.Linear(CROSS_DIM, hidden_size, bias=False)
self.to_v_ip = nn.Linear(CROSS_DIM, hidden_size, bias=False)
class MSAdapter(nn.Module):
"""Self-contained adapter. Keys are 1:1 with ms_adapter.bin after index remap."""
def __init__(self, hidden_sizes):
super().__init__()
self.hidden_sizes = list(hidden_sizes)
self.image_proj = Resampler()
self.adapters = nn.ModuleList([IPProjection(h) for h in self.hidden_sizes])
self.dummy_image_tokens = nn.Parameter(torch.randn(1, NUM_DUMMY_TOKENS, CROSS_DIM))
self._placed = None
def load(self, path):
if os.path.isdir(path):
path = os.path.join(path, "ms_adapter.bin")
sd = torch.load(path, map_location="cpu", weights_only=True)
missing_top = {"image_proj", "ms_adapter", "dummy_image_tokens"} - set(sd)
if missing_top:
raise RuntimeError("MS-Diffusion checkpoint missing groups: %s" % sorted(missing_top))
self.image_proj.load_state_dict(sd["image_proj"], strict=True)
proj_sd = {}
for i in range(len(self.hidden_sizes)):
src = 2 * i + 1 # 1,3,...,139
for name in ("to_k_ip", "to_v_ip"):
proj_sd["%d.%s.weight" % (i, name)] = sd["ms_adapter"]["%d.%s.weight" % (src, name)]
self.adapters.load_state_dict(proj_sd, strict=True)
assert sd["dummy_image_tokens"].shape == self.dummy_image_tokens.shape
with torch.no_grad():
self.dummy_image_tokens.copy_(sd["dummy_image_tokens"])
for i, h in enumerate(self.hidden_sizes):
w = self.adapters[i].to_k_ip.weight
if w.shape != (h, CROSS_DIM):
raise RuntimeError("adapter %d shape %s != (%d,%d)" % (i, tuple(w.shape), h, CROSS_DIM))
return self
@torch.no_grad()
def ensure_on(self, device, dtype):
tag = (str(device), str(dtype))
if self._placed == tag:
return
self.to(device=device, dtype=dtype)
self._placed = tag
# ---------------------------------------------------------------------------
# Conditioning assembly (port of MSAdapter.generate, without diffusers pipeline)
# ---------------------------------------------------------------------------
class MSEmbeds:
"""Everything the attn2 patch needs, independent of CONDITIONING."""
def __init__(self, image_prompt_embeds, boxes, phrase_idxes, num_ref,
scale, mask_threshold, start_step, subject_scales=None):
self.image_prompt_embeds = image_prompt_embeds # [1, 4+16*num_ref, 2048] fp32
self.boxes = boxes # [1, num_ref, 4] fp32
self.phrase_idxes = phrase_idxes # [1, num_ref, 2] int
self.num_ref = num_ref
self.scale = float(scale)
self.mask_threshold = mask_threshold # None disables pseudo mask
self.start_step = int(start_step)
self.subject_scales = subject_scales
def minmax_normalize(batch_maps):
min_val = batch_maps.min(dim=-1, keepdim=True)[0].min(dim=-2, keepdim=True)[0]
max_val = batch_maps.max(dim=-1, keepdim=True)[0].max(dim=-2, keepdim=True)[0]
return (batch_maps - min_val) / (max_val - min_val + 1e-5)
def build_box_masks(boxes, seq_len_q, dtype, device):
"""Port of MaskedIPAttnProcessor2_0.prepare_attention_mask_qk (boxes path)."""
if boxes is None:
return None, None, None
batch_size, num_ref, _ = boxes.shape
nrow = int(seq_len_q ** 0.5)
box_idxes_start = torch.floor(boxes[:, :, 0:2] * nrow)
box_idxes_end = torch.ceil(boxes[:, :, 2:4] * nrow)
box_idxes = torch.cat([box_idxes_start, box_idxes_end], dim=-1)
box_masks = []
dummy_attention_mask = torch.ones((batch_size, seq_len_q), dtype=dtype, device=device)
for box_idx in box_idxes.unbind(dim=1):
xs, ys, xe, ye = box_idx.unbind(dim=1)
x_indices = torch.arange(nrow).unsqueeze(0).expand(batch_size, -1).to(device)
y_indices = torch.arange(nrow).unsqueeze(0).expand(batch_size, -1).to(device)
x_mask = ((x_indices >= xs.unsqueeze(1)) & (x_indices < xe.unsqueeze(1))).to(dtype)
y_mask = ((y_indices >= ys.unsqueeze(1)) & (y_indices < ye.unsqueeze(1))).to(dtype)
box_mask = torch.bmm(y_mask.unsqueeze(2), x_mask.unsqueeze(1)).reshape(batch_size, -1)
box_masks.append(box_mask)
dummy_attention_mask = torch.clamp(dummy_attention_mask - box_mask, min=0)
post_dummy = dummy_attention_mask.to(torch.bool)
image_mask = torch.stack(box_masks, dim=-1)
image_mask = image_mask.repeat_interleave(NUM_TOKENS, dim=-1)
image_mask = (1 - image_mask.to(dtype)) * -10000.0
dummy_mask = dummy_attention_mask.unsqueeze(-1).repeat_interleave(NUM_DUMMY_TOKENS, dim=-1)
dummy_mask = (1 - dummy_mask) * -10000.0
image_mask = torch.cat([dummy_mask, image_mask], dim=-1)
return image_mask, post_dummy, box_masks
def build_pseudo_masks(maps, mask_threshold, seq_len_q, dtype, device, num_ref):
"""Port of get_psuedo_attention_mask(). `maps` is a list of [B,num_ref,h,w]."""
text_maps = minmax_normalize(torch.stack(maps).mean(dim=0))
bsz, nr, h, w = text_maps.shape
seq_len_q = h * w
text_maps = text_maps.view(bsz, nr, -1).transpose(1, 2)
pseudo = (text_maps > mask_threshold).to(dtype)
pseudo_dummy = torch.ones((bsz, seq_len_q), dtype=dtype, device=device)
for i in range(nr):
pseudo_dummy = torch.clamp(pseudo_dummy - pseudo[..., i], min=0)
post_dummy = pseudo_dummy.to(torch.bool)
pseudo = pseudo.repeat_interleave(NUM_TOKENS, dim=-1)
pseudo = (1 - pseudo) * -10000.0
pseudo_dummy = pseudo_dummy.unsqueeze(-1).repeat_interleave(NUM_DUMMY_TOKENS, dim=-1)
pseudo_dummy = (1 - pseudo_dummy) * -10000.0
return torch.cat([pseudo_dummy, pseudo], dim=-1), post_dummy
# ---------------------------------------------------------------------------
# attn2 patch
# ---------------------------------------------------------------------------
# Module-level registry: ComfyUI's ModelPatcher.clone() deep-copies dict/list in
# model_options but passes arbitrary objects (functions, dicts of state) through
# unchanged. Closures capture only a string id, so cloned patchers share state.
_STATES = {}
def new_state(adapter, embeds):
sid = uuid.uuid4().hex
_STATES[sid] = {
"adapter": adapter,
"embeds": embeds,
"maps": {}, # per layer index -> list of text attention maps
"last_sigma": -1.0,
"calls": 0,
}
# keep the registry bounded across many node executions
if len(_STATES) > 64:
for k in list(_STATES)[:-32]:
_STATES.pop(k, None)
return sid
def _select_per_batch(t, batch, cond_or_uncond, zero_for_uncond=True):
"""Expand a [1,...] tensor to `batch` rows, zeroing uncond rows.
Upstream builds `cat([zeros_like(x), x])` for negative/positive. ComfyUI
instead reports the role of every batch chunk in transformer_options
["cond_or_uncond"] (0=cond, 1=uncond), so we build the same layout without
relying on a fixed chunk order.
"""
t = t.expand(batch, *t.shape[1:])
if not zero_for_uncond or cond_or_uncond is None:
return t
labels = list(cond_or_uncond)
if len(labels) == 0:
return t
if len(labels) != batch:
repeat = batch // len(labels)
if repeat * len(labels) != batch:
return t
labels = [x for x in labels for _ in range(repeat)]
keep = torch.tensor([1.0 if int(c) == 0 else 0.0 for c in labels],
device=t.device, dtype=t.dtype).view(batch, *([1] * (t.dim() - 1)))
return t * keep
def make_attn2_patch(state_id, layer_index, is_sentinel=False):
"""Return a ComfyUI `patches_replace["attn2"][(block, n, depth)]` callable.
Signature is (q, k, v, extra_options); q/k/v are already projected flat
tensors [B, L, inner_dim]. The enclosing BasicTransformerBlock applies
attn2.to_out and the residual, exactly like diffusers' processor.
"""
def attn2_patch(q, k, v, extra_options):
st = _STATES[state_id]
adapter = st["adapter"]
emb = st["embeds"]
# Detect a fresh sampling run by the (monotonically decreasing) sigma.
# All 70 patches see this; the first one resets, the rest no-op.
sigmas = extra_options.get("sigmas")
if sigmas is not None:
try:
sig = float(torch.as_tensor(sigmas).flatten()[0])
if sig > st["last_sigma"] + 1e-6:
st["maps"] = {}
st["calls"] = 0
st["last_sigma"] = sig
except Exception:
pass
if is_sentinel:
st["calls"] += 1
device, dtype = q.device, q.dtype
adapter.ensure_on(device, dtype)
bsz, seq_q, inner_dim = q.shape
heads = inner_dim // HEAD_DIM
head_dim = inner_dim // heads
scale_q = head_dim ** -0.5
use_pseudo = emb.mask_threshold is not None
def split_heads(t):
return t.view(t.shape[0], -1, heads, head_dim).transpose(1, 2)
qt = split_heads(q)
# ---- text branch -------------------------------------------------
kt, vt = split_heads(k), split_heads(v)
if not use_pseudo:
text_out = F.scaled_dot_product_attention(qt, kt, vt)
else:
scores = torch.matmul(qt * scale_q, kt.transpose(-1, -2))
text_probs = torch.softmax(scores.float(), dim=-1).to(dtype)
# upstream get_text_attention_maps() works on [B*heads, Lq, Lk]
flat = text_probs.reshape(bsz * heads, seq_q, -1).view(bsz, heads, seq_q, -1)
# Upstream builds cat([zeros, real]) for uncond/cond: uncond boxes
# and phrase indices are all zero. Reproduce that exactly.
boxes = _select_per_batch(emb.boxes.to(device=device, dtype=dtype), bsz,
extra_options.get("cond_or_uncond"))
pidx = _select_per_batch(emb.phrase_idxes.to(device=device, dtype=dtype), bsz,
extra_options.get("cond_or_uncond")).to(torch.int)
num_ref = boxes.shape[1]
h = w = int(seq_q ** 0.5)
maps = []
for i in range(bsz):
per_ref = []
for j in range(num_ref):
start_idx = int(pidx[i, j, 0].item())
end_idx = int(pidx[i, j, 1].item())
if start_idx == 0 and end_idx == 0:
per_ref.append(torch.zeros(seq_q, dtype=dtype, device=device))
else:
amap = flat[i, :, :, start_idx:end_idx]
per_ref.append(torch.mean(torch.mean(amap, dim=-1), dim=0))
maps.append(torch.stack(per_ref))
st["maps"].setdefault(layer_index, []).append(
torch.stack(maps).reshape(bsz, num_ref, h, w))
text_out = torch.matmul(text_probs, vt)
text_out = text_out.transpose(1, 2).reshape(bsz, seq_q, inner_dim).to(dtype)
# ---- image branch ------------------------------------------------
ip = emb.image_prompt_embeds.to(device=device, dtype=dtype)
ip = _select_per_batch(ip, bsz, extra_options.get("cond_or_uncond"))
ipk = split_heads(F.linear(ip, adapter.adapters[layer_index].to_k_ip.weight))
ipv = split_heads(F.linear(ip, adapter.adapters[layer_index].to_v_ip.weight))
boxes = _select_per_batch(emb.boxes.to(device=device, dtype=dtype), bsz,
extra_options.get("cond_or_uncond"))
rf_mask, dummy_mask, _ = build_box_masks(boxes, seq_q, dtype, device)
if use_pseudo:
maps_here = st["maps"].get(layer_index, [])
if len(maps_here) >= emb.start_step:
pm, pd = build_pseudo_masks(maps_here, emb.mask_threshold,
seq_q, dtype, device, boxes.shape[1])
rf_mask, dummy_mask = pm, pd
# [B, Lq, N] -> [B*heads, Lq, N]
if rf_mask is not None:
rf_mask = rf_mask.repeat_interleave(heads, dim=0).reshape(bsz * heads, seq_q, -1)
if dummy_mask is not None:
dummy_mask = dummy_mask.repeat_interleave(heads, dim=0)
qf = qt.reshape(bsz * heads, seq_q, head_dim)
kf = ipk.reshape(bsz * heads, -1, head_dim)
vf = ipv.reshape(bsz * heads, -1, head_dim)
scores = torch.bmm(qf * scale_q, kf.transpose(-1, -2))
if rf_mask is not None:
scores = scores + rf_mask
ip_probs = torch.softmax(scores.float(), dim=-1).to(dtype)
if dummy_mask is not None:
ip_probs = torch.where(dummy_mask.unsqueeze(-1), torch.zeros_like(ip_probs), ip_probs)
if emb.subject_scales is not None:
ss = torch.tensor(emb.subject_scales, dtype=dtype, device=device)
ss = ss.unsqueeze(0).unsqueeze(0).repeat_interleave(NUM_TOKENS, dim=-1)
dss = torch.ones((1, 1, 1), dtype=dtype, device=device).repeat_interleave(NUM_DUMMY_TOKENS, dim=-1)
ip_probs = ip_probs * torch.cat([dss, ss], dim=-1)
ip_out = torch.bmm(ip_probs, vf)
ip_out = ip_out.reshape(bsz, heads, seq_q, head_dim).transpose(1, 2).reshape(bsz, seq_q, inner_dim)
if emb.subject_scales is None:
return text_out + emb.scale * ip_out
return text_out + ip_out
return attn2_patch
# ---------------------------------------------------------------------------
# Weight -> ComfyUI block mapping
# ---------------------------------------------------------------------------
def enumerate_attn2(diffusion_model):
"""Return attn2 descriptors in ComfyUI *execution* order.
Each item: (key3, inner_dim, section) where key3 is the exact
(block_name, block_number, transformer_index) key ComfyUI looks up in
patches_replace["attn2"].
"""
import re
out = []
for name, module in diffusion_model.named_modules():
if not name.endswith(".attn2"):
continue
m = re.match(r"^(input_blocks|middle_block|output_blocks)\.(\d+)\..*?transformer_blocks\.(\d+)\.attn2$", name)
if m is None:
continue
section = {"input_blocks": "input", "middle_block": "middle", "output_blocks": "output"}[m.group(1)]
block_name = section
block_number = 0 if section == "middle" else int(m.group(2))
depth = int(m.group(3))
inner_dim = module.to_q.out_features
out.append(((block_name, block_number, depth), inner_dim, section))
# named_modules() already yields registration order == execution order
return out
def build_block_weights(attn2_desc, hidden_sizes):
"""Permute the 70 checkpoint groups into ComfyUI execution order.
Checkpoint order (diffusers registration): input | output | middle
ComfyUI execution order: input | middle | output
"""
n_input = sum(1 for x in attn2_desc if x[2] == "input")
n_middle = sum(1 for x in attn2_desc if x[2] == "middle")
n_output = sum(1 for x in attn2_desc if x[2] == "output")
total = n_input + n_middle + n_output
if total != len(hidden_sizes):
raise RuntimeError(
"MS-Diffusion: UNet has %d attn2 modules (%d input / %d middle / %d output) "
"but the checkpoint has %d adapter groups. This adapter only supports "
"stock SDXL (70 cross-attention layers)." % (total, n_input, n_middle, n_output, len(hidden_sizes)))
reordered = (list(range(0, n_input))
+ list(range(n_input + n_output, n_input + n_output + n_middle))
+ list(range(n_input, n_input + n_output)))
for pos, ckpt_idx in enumerate(reordered):
want = attn2_desc[pos][1]
got = hidden_sizes[ckpt_idx]
if want != got:
raise RuntimeError(
"MS-Diffusion: adapter mapping mismatch at attn2 #%d: UNet inner_dim=%d "
"but checkpoint group %d has hidden_size=%d. Refusing to run with wrong weights."
% (pos, want, ckpt_idx, got))
return reordered
def adapter_hidden_sizes(checkpoint_path):
"""Read just the 70 to_k_ip shapes so the loader node can build the module."""
sd = torch.load(checkpoint_path, map_location="cpu", weights_only=True)
if not isinstance(sd, dict) or "ms_adapter" not in sd or "image_proj" not in sd:
raise RuntimeError(
"MS-Diffusion: %s is not an ms_adapter.bin checkpoint (needs "
"'image_proj', 'ms_adapter' and 'dummy_image_tokens' groups). "
"Pick ms_adapter.bin, not a generic IP-Adapter file." % checkpoint_path)
group = sd["ms_adapter"]
idx = sorted({int(k.split(".")[0]) for k in group})
try:
return [group["%d.to_k_ip.weight" % i].shape[0] for i in idx]
except KeyError as e:
raise RuntimeError("MS-Diffusion: unexpected ms_adapter key layout (%s). "
"Expected 70 to_k_ip/to_v_ip pairs." % e) from e
+369
View File
@@ -0,0 +1,369 @@
"""ComfyUI nodes for the native MS-Diffusion chain.
Intended graph (everything except the three MS nodes is a stock ComfyUI node):
CheckpointLoaderSimple -> MODEL / CLIP / VAE
LoadImage xN -> native ImageBatch (chained N-1 times) -> CLIPVisionEncode
-> CLIP_VISION_OUTPUT
CLIPTextEncode x2 -> positive / negative CONDITIONING
MSDiffusionLoader ------------------------------> MS_ADAPTER
MSDiffusionConditioning ------------------------> MS_EMBEDS (+ clean prompt)
MSDiffusionApply(MODEL, MS_ADAPTER, MS_EMBEDS) --> patched MODEL
EmptyLatentImage -> KSampler -> VAEDecode -> SaveImage
No diffusers / transformers / omegaconf / einops / PIL imports anywhere.
"""
import os
import re
import torch
import folder_paths
from .core import (MSAdapter, MSEmbeds, adapter_hidden_sizes, build_block_weights,
enumerate_attn2, make_attn2_patch, new_state, NUM_TOKENS,
NUM_DUMMY_TOKENS)
ADAPTER_FOLDER = "ms_adapter"
_ADAPTER_EXTS = (".bin", ".safetensors", ".pt", ".pth", ".ckpt")
def _register_folders():
"""Register models/ipadapter and models/photomaker as one virtual folder.
The upstream plugin hard-coded models/photomaker; the user's actual file is
in models/ipadapter. Support both without touching ComfyUI source.
"""
try:
if ADAPTER_FOLDER not in folder_paths.folder_names_and_paths:
folder_paths.folder_names_and_paths[ADAPTER_FOLDER] = ([], set())
for sub in ("ipadapter", "photomaker"):
p = os.path.join(folder_paths.models_dir, sub)
if os.path.isdir(p):
folder_paths.add_model_folder_path(ADAPTER_FOLDER, p)
except Exception:
pass
_register_folders()
def _adapter_files():
try:
files = [f for f in folder_paths.get_filename_list(ADAPTER_FOLDER)
if f.lower().endswith(_ADAPTER_EXTS)]
except Exception:
files = []
if not files:
# fallback scan, keeps the node usable even if registration was blocked
for sub in ("ipadapter", "photomaker"):
d = os.path.join(folder_paths.models_dir, sub)
if os.path.isdir(d):
files += [sub + "/" + f for f in sorted(os.listdir(d))
if f.lower().endswith(_ADAPTER_EXTS)]
files = sorted(set(files)) or ["none"]
# models/ipadapter often holds many unrelated IP-Adapter weights; put the
# real MS-Diffusion checkpoint first so the default is the correct file.
preferred = [f for f in files if os.path.basename(f).lower().startswith("ms_adapter")]
return preferred + [f for f in files if f not in preferred]
def _adapter_path(name):
try:
return folder_paths.get_full_path_or_raise(ADAPTER_FOLDER, name)
except Exception:
return os.path.join(folder_paths.models_dir, name.replace("/", os.sep))
# ---------------------------------------------------------------------------
# phrase token position (port of msdiffusion/utils.py, no transformers import)
# ---------------------------------------------------------------------------
def _token_encoder(clip):
"""Return f(word) -> len(HF tokenizer.encode(word)).
ComfyUI already owns a CLIP tokenizer instance; we just borrow it, so this
plugin adds no transformers import of its own.
"""
try:
tk = clip.tokenizer.clip_l.tokenizer
return lambda w: len(tk.encode(w))
except Exception:
pass
# fallback: derive from ComfyUI's own token/weight pairs
def from_comfy(w):
pairs = clip.tokenize(w)["l"][0]
n = 0
for tid, _ in pairs:
if int(tid) == 49407: # EOS == pad token
break
n += 1
return max(n, 1) + 1 # [BOS] + word tokens => n-1 + 2
return from_comfy
def _is_equal_words(pr_words, ph_words):
if len(pr_words) != len(ph_words):
return False
for pr, ph in zip(pr_words, ph_words):
if "-" + ph not in pr and ph != re.sub(r'[.!?,:]$', '', pr):
return False
return True
def _get_phrase_idx(enc_len, phrase, prompt, num=0):
phrase_words = phrase.split()
if len(phrase_words) == 0:
return [0, 0]
prompt_words = prompt.split()
start, end = 1, 0
for i in range(len(prompt_words)):
if _is_equal_words(prompt_words[i:i + len(phrase_words)], phrase_words):
if num != 0:
num -= 1
continue
end = start
prompt_words[i + len(phrase_words) - 1] = re.sub(
r'[.!?,:]$', '', prompt_words[i + len(phrase_words) - 1])
for j in range(i, i + len(phrase_words)):
end += enc_len(prompt_words[j]) - 2
break
else:
start += enc_len(prompt_words[i]) - 2
if end == 0:
return [0, 0]
return [start, end]
def _parse_boxes(text, num_ref, enabled):
"""Upstream: mask_threshold == 0 disables layout guidance -> all-zero boxes."""
zeros = [[0.0, 0.0, 0.0, 0.0] for _ in range(num_ref)]
if not enabled or not text.strip():
return zeros
groups = [g.strip() for g in text.strip().split(";") if g.strip()]
parsed = []
for g in groups:
vals = [float(x.strip()) for x in g.split(",")]
if len(vals) != 4:
raise ValueError("MS-Diffusion: each box must have 4 floats (x1,y1,x2,y2); got %r" % g)
parsed.append(vals)
if len(parsed) < num_ref:
# A zero box masks that subject's 16 image tokens completely (-10000 bias),
# silently dropping it. Upstream raises here instead of guessing; do the same.
raise ValueError(
"MS-Diffusion: %d subjects but only %d boxes. Provide one "
"'x1,y1,x2,y2' per subject separated by ';' (or set mask_threshold=0 "
"to disable layout guidance)." % (num_ref, len(parsed)))
return parsed[:num_ref]
def _clip_l_pooled(clip, phrase):
"""CLIP-L 768-d pooled output (the grounding phrase embedding).
SDXL's CONDITIONING pooled_output is clip_g (1280) and cannot be used here.
"""
clip.load_model()
dev = clip.patcher.load_device
try:
clip.cond_stage_model.clip_l.set_clip_options({"execution_device": dev})
except Exception:
pass
tokens = clip.tokenize(phrase)["l"]
with torch.no_grad():
out = clip.cond_stage_model.clip_l.encode_token_weights(tokens)
pooled = out[1]
return pooled.to(torch.float32).cpu()
# ---------------------------------------------------------------------------
# Nodes
# ---------------------------------------------------------------------------
class MSDiffusionLoader:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"ms_adapter": (_adapter_files(),)}}
RETURN_TYPES = ("MS_ADAPTER",)
RETURN_NAMES = ("ms_adapter",)
FUNCTION = "load"
CATEGORY = "MSdiffusion/Native"
DESCRIPTION = ("Load ms_adapter.bin from models/ipadapter or models/photomaker. "
"Pure torch; no diffusers/transformers needed.")
def load(self, ms_adapter):
if ms_adapter == "none":
raise RuntimeError("MS-Diffusion: no ms_adapter.bin found in models/ipadapter or models/photomaker")
path = _adapter_path(ms_adapter)
sizes = adapter_hidden_sizes(path)
adapter = MSAdapter(sizes).load(path)
print("[MS-Diffusion] loaded %s (%d cross-attn groups)" % (path, len(sizes)))
return (adapter,)
class MSDiffusionConditioning:
@classmethod
def INPUT_TYPES(cls):
return {"required": {
"ms_adapter": ("MS_ADAPTER",),
"clip": ("CLIP",),
"clip_vision_output": ("CLIP_VISION_OUTPUT",),
"prompt": ("STRING", {"multiline": True,
"default": "best quality, high quality, a [dog] on the beach"}),
"boxes": ("STRING", {"multiline": True,
"default": "0.25, 0.25, 0.75, 0.75",
"tooltip": "one 'x1,y1,x2,y2' per subject, separated by ';'. "
"Ignored when mask_threshold is 0."}),
"mask_threshold": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.05,
"tooltip": "0 disables layout guidance entirely (upstream behaviour). "
">0 enables box guidance + pseudo attention mask."}),
"drop_grounding_tokens": ("BOOLEAN", {"default": False}),
}}
RETURN_TYPES = ("MS_EMBEDS", "STRING")
RETURN_NAMES = ("ms_embeds", "prompt_clean")
FUNCTION = "encode"
CATEGORY = "MSdiffusion/Native"
DESCRIPTION = ("Build MS-Diffusion image prompt tokens from CLIP-Vision penultimate "
"hidden states + CLIP-L phrase pooled embeddings + layout boxes. "
"Connect prompt_clean to your positive CLIPTextEncode so the two match.")
def encode(self, ms_adapter, clip, clip_vision_output, prompt, boxes,
mask_threshold, drop_grounding_tokens):
import comfy.model_management as mm
phrases = [p.strip() for p in re.findall(r'\[(.*?)\]', prompt)]
if not phrases:
raise ValueError("MS-Diffusion: subject names must be enclosed in [ ] inside the prompt")
for p in phrases:
if " " in p:
raise ValueError("MS-Diffusion: subject %r must be a single word" % p)
phrases = list(dict.fromkeys(phrases))
clean_prompt = prompt.replace("]", " ").replace("[", " ")
hidden = clip_vision_output["penultimate_hidden_states"]
if hidden.dim() != 3:
raise ValueError("MS-Diffusion: CLIP-Vision output must be penultimate_hidden_states [B,257,1664]")
num_ref = hidden.shape[0]
if num_ref != len(phrases):
raise ValueError(
"MS-Diffusion: got %d subject images but %d [subject] names in prompt (%s)"
% (num_ref, len(phrases), phrases))
if hidden.shape[-1] != 1664:
raise ValueError("MS-Diffusion: expected CLIP-ViT-bigG hidden size 1664, got %d. "
"Use clip_vision_g.safetensors." % hidden.shape[-1])
layout_on = mask_threshold > 0.0
box_vals = _parse_boxes(boxes, num_ref, layout_on)
boxes_t = torch.tensor([box_vals], dtype=torch.float32) # [1, num_ref, 4]
batch_boxes = boxes_t.view(num_ref, 4) # Resampler grounding input
phrase_embeds = torch.cat([_clip_l_pooled(clip, p) for p in phrases], dim=0)
if phrase_embeds.shape != (num_ref, 768):
raise RuntimeError("MS-Diffusion: CLIP-L pooled shape %s, expected (%d,768)"
% (tuple(phrase_embeds.shape), num_ref))
enc_len = _token_encoder(clip)
seen = {}
pidx = []
for p in phrases:
n = seen.get(p, 0)
seen[p] = n + 1
pidx.append(_get_phrase_idx(enc_len, p, clean_prompt, num=n))
phrase_idxes = torch.tensor([pidx], dtype=torch.int32) # [1, num_ref, 2]
dev = mm.get_torch_device()
dtype = torch.float16
with torch.no_grad():
ms_adapter.image_proj.to(device=dev, dtype=dtype)
x = hidden.to(device=dev, dtype=dtype)
grounding = {
"boxes": batch_boxes.to(device=dev, dtype=dtype),
"phrase_embeds": phrase_embeds.to(device=dev, dtype=dtype),
"drop_grounding_tokens": [1 if drop_grounding_tokens else 0],
}
ip_tokens = ms_adapter.image_proj(x, grounding_kwargs=grounding)
ip_tokens = ip_tokens.view(1, -1, ip_tokens.shape[-2], ip_tokens.shape[-1])
ip_tokens = ip_tokens.view(1, ip_tokens.shape[-3] * ip_tokens.shape[-2], ip_tokens.shape[-1])
dummy = ms_adapter.dummy_image_tokens.to(device=dev, dtype=dtype)
image_prompt_embeds = torch.cat([dummy, ip_tokens], dim=1).float().cpu()
ms_adapter.image_proj.to(device=torch.device("cpu"))
ms_adapter._placed = None
assert image_prompt_embeds.shape == (1, NUM_DUMMY_TOKENS + NUM_TOKENS * num_ref, 2048)
embeds = MSEmbeds(
image_prompt_embeds=image_prompt_embeds,
boxes=boxes_t,
phrase_idxes=phrase_idxes,
num_ref=num_ref,
scale=1.0, # set by MSDiffusionApply
mask_threshold=None if not layout_on else float(mask_threshold),
start_step=5, # set by MSDiffusionApply
)
return (embeds, clean_prompt)
class MSDiffusionApply:
@classmethod
def INPUT_TYPES(cls):
return {"required": {
"model": ("MODEL",),
"ms_adapter": ("MS_ADAPTER",),
"ms_embeds": ("MS_EMBEDS",),
"scale": ("FLOAT", {"default": 0.8, "min": 0.0, "max": 2.0, "step": 0.05}),
"start_step": ("INT", {"default": 5, "min": 0, "max": 1024,
"tooltip": "pseudo attention mask kicks in after this many "
"denoising steps (only when mask_threshold > 0)"}),
}}
RETURN_TYPES = ("MODEL",)
FUNCTION = "apply"
CATEGORY = "MSdiffusion/Native"
DESCRIPTION = ("Patch every native SDXL attn2 with MS-Diffusion dual-branch "
"cross-attention. Output is a normal MODEL: use stock KSampler / "
"ControlNet / FreeU / LoRA nodes downstream.")
def apply(self, model, ms_adapter, ms_embeds, scale, start_step):
import copy
m = model.clone()
desc = enumerate_attn2(m.model.diffusion_model)
if not desc:
raise RuntimeError("MS-Diffusion: no attn2 modules found. This adapter requires a stock SDXL UNet.")
perm = build_block_weights(desc, ms_adapter.hidden_sizes)
emb = copy.copy(ms_embeds)
emb.scale = float(scale)
emb.start_step = int(start_step)
sid = new_state(ms_adapter, emb)
# IMPORTANT: set_model_patch_replace() REPLACES the whole
# transformer_options dict and silently drops previously attached keys.
# Build patches_replace in place on the cloned options instead.
to = m.model_options["transformer_options"]
pr = dict(to.get("patches_replace", {}))
a2 = dict(pr.get("attn2", {}))
for pos, (key3, _inner, _section) in enumerate(desc):
a2[key3] = make_attn2_patch(sid, perm[pos], is_sentinel=(pos == 0))
pr["attn2"] = a2
to["patches_replace"] = pr
print("[MS-Diffusion] patched %d attn2 layers (scale=%.2f, layout=%s)"
% (len(desc), emb.scale, "on" if emb.mask_threshold is not None else "off"))
return (m,)
# No custom image-batch node: ComfyUI's native `ImageBatch` already stacks
# images into [N,H,W,3] and aligns size/channels. Chain it for N > 2.
# (The old `MS_Object_img_Batch` / `MSDiffusionImageBatch` ids are intentionally
# gone; replace them with the native ImageBatch node in old graphs.)
NODE_CLASS_MAPPINGS = {
"MSDiffusionLoader": MSDiffusionLoader,
"MSDiffusionConditioning": MSDiffusionConditioning,
"MSDiffusionApply": MSDiffusionApply,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"MSDiffusionLoader": "MS-Diffusion Loader (Native)",
"MSDiffusionConditioning": "MS-Diffusion Conditioning (Native)",
"MSDiffusionApply": "MS-Diffusion Apply to MODEL (Native)",
}