maliang / code /gc25d /model.py
teawhite's picture
Add files using upload-large-folder tool
bcdd8fb verified
Raw History Blame Contribute Delete
41.5 kB
"""gc25d 模型:Wan2.2-TI2V-5B DiT + 逐像素数字语义图条件(SPEC §6)。
x = dit.patch_embedding(latents) + dit.gc_sem_cond(sem_ids) # [b, 3072, 21, H/32, W/32]
形状对分辨率通用(全部从输入张量取);下面的数是 832×480(geometry 默认 GC_RES)的例子,1280×704 时
H/4×W/4 = 176×320、latent 44×80、token 22×40。
`gc_sem_cond`(SemCondEncoder)把 81×480×832 的 uint8 语义 id 视频压到 latent 网格:
1. LUT:raw 0..6 → 0..6,10 → 7,11 → 8,其余(含 UNKNOWN=255)→ 9(K=10);
2. 逐类 4×4 平均池化 one-hot → [b, K, 81, 120, 208](一类一类算,不物化全分辨率 one-hot);
3. 时间分组同 VAE:帧 0 重复 4 次 + 帧 1..80 → [b, 4K, 21, 120, 208];
4. pixel-unshuffle ×4 → [b, 64K=640, 21, 30, 52];
5. Conv3d(640, 512, 1) → SiLU → Conv3d(512, 512, (1,3,3), pad (0,1,1)) → SiLU →
零初始化 Conv3d(512, dim, (1,2,2), stride (1,2,2))。
刚装上时最后一层权重与偏置全 0 ⇒ 输出恒为 0 ⇒ 前向与原模型逐位相同(tests/test_model.py::test_a)。
`gc_forward` 是 `actionrope.arope.arope_forward` 普通路径(offset_px=None, mask_input=None, arm=None)的照抄,
只在 patch embedding 后多加一项 sem 条件;block / RoPE 查表 / 逐 cell 文本 / 逐 token timestep 全部直接
import `actionrope.arope` 的函数,不复制。
实现上的取舍(SPEC 没写死、这里定下来的约定,infer 侧必须一致 —— 它直接 import 本文件即可)
------------------------------------------------------------------------------------------
* 通道顺序:时间分组后通道 = k·4 + τ(k 类别,τ 组内帧 0..3),unshuffle 后通道 = (k·4 + τ)·16 + p·4 + q
(p 行内偏移、q 列内偏移),即 einops "b k (f t) (h p) (w q) -> b (k t p q) f h w"。
* 第 9 类("其它 / UNKNOWN")的池化值 = 1 − Σ_{k<9} 池化值。池化值都是 1/16 的整数倍,fp32 下精确,
所以与逐像素判断"不属于前 9 类"再池化逐位相同,而且省一次全分辨率比较。
* one-hot 池化不依赖参数,放在 no_grad 里算([b,10,81,120,208] bf16 ≈ 40 MB/样本,1280×704 ≈ 2.26×),再 cast 到模块 dtype。
* 前两层 conv 用 PyTorch 默认初始化,但在 fork_rng + 固定种子下建:各 rank、各次运行初始化相同,
不吃全局 RNG(DeepSpeed 也会从 rank 0 广播,这里是双保险,且单卡 / 多卡结果可比)。
白模条件(cond_type="whitebox",2026-10-12,GameCrafter-Parkour;见文件中部"白模条件"一节)
--------------------------------------------------------------------------------------
另一种控制视频:pk 渲染的白模(灰度,像素对齐 RGB,255 留作 UNKNOWN)。模块挂在**另一个名字** `dit.gc_wb_cond`
(WhiteboxCondEncoder),结构与 SemCondEncoder 同构(逐帧 4×4 块特征 → 同一套 4 帧分组 + unshuffle → 1×1 → SiLU →
1×3×3 → SiLU → 零初始化 patch-stride conv),只有输入特征不同(5 通道:RGB 块均值、亮度块内标准差、已知像素比例)。
默认一律是 "ids":不传 cond_type、dit 上没有 gc_wb_cond 时,所有函数的行为与加白模之前逐位相同。
"""
from __future__ import annotations
import functools
import math
import os
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from einops import rearrange
from safetensors import safe_open
from safetensors.torch import load_file
from actionrope.arope import (
_block_forward,
_embed_context,
_per_token_timestep,
build_world_freqs,
)
from diffsynth.core.gradient import gradient_checkpoint_forward
from diffsynth.models.wan_video_dit import WanModel, modulate, rope_apply, sinusoidal_embedding_1d
UNKNOWN_ID = 255
SEM_COND_NAME = "gc_sem_cond"
# raw id → 类别下标(K=10)。0..6 原样,10 玩家 → 7,11 boss → 8,其余(含 255)→ 9
RAW_TO_INDEX = {0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 10: 7, 11: 8}
NUM_CLASSES = 10
OTHER_INDEX = 9
# ----------------------------------------------------------------------------
# 词表(maliang / v10,2026-10-22)
# ----------------------------------------------------------------------------
# "ids" = 上面的 K=10(v3/v4/v8 的 gc25d,逐位不变)。
# "parts" = v10 胶囊白模角色按部位拆开(数值代理,不过 VAE):玩家 / Boss 各 6 个部位 raw id,K=20。
# raw 10 / 11 = 玩家 / Boss 躯干(pelvis_spine, spine_neck, head),保持与 "ids" 相同的值;
# raw 12..16 = 玩家 左臂 / 右臂 / 左腿 / 右腿 / 武器,raw 17..21 = Boss 同序;左右按骨骼名(l_* / r_*),不按屏幕。
# 部位由代码对 json['v9']['characters'] 的胶囊逐像素射线求交得到(capsules.py),推理时代码世界用同一函数画。
# collapse_parts():12..16 → 10,17..21 → 11,即退回 "ids" 词表。
PART_NAMES = ("core", "l_arm", "r_arm", "l_leg", "r_leg", "weapon")
PLAYER_PART_IDS = (10, 12, 13, 14, 15, 16)
BOSS_PART_IDS = (11, 17, 18, 19, 20, 21)
RAW_TO_INDEX_PARTS = {**{i: i for i in range(7)},
**{raw: 7 + j for j, raw in enumerate(PLAYER_PART_IDS)},
**{raw: 13 + j for j, raw in enumerate(BOSS_PART_IDS)}}
VOCABS = {"ids": (RAW_TO_INDEX, NUM_CLASSES), "parts": (RAW_TO_INDEX_PARTS, 20)}
DEFAULT_VOCAB = "ids"
assert all(len(m) == k - 1 and sorted(m.values()) == list(range(k - 1)) for m, k in VOCABS.values())
def vocab_spec(vocab: str) -> tuple[dict, int]:
if vocab not in VOCABS:
raise ValueError(f"vocab 只能是 {tuple(VOCABS)},得到 {vocab!r}")
return VOCABS[vocab]
def valid_raw_ids(vocab: str) -> frozenset:
"""条件图里允许出现的 raw 值(含 UNKNOWN)。其它值模型会静默当成 "other",prep / 代码世界应断言。"""
return frozenset(vocab_spec(vocab)[0]) | {UNKNOWN_ID}
def collapse_parts(ids):
"""parts 词表的 raw id → ids 词表(12..16 → 10,17..21 → 11);numpy / torch 都行,返回同类型新数组。"""
lut = np.arange(256, dtype=np.uint8)
lut[list(PLAYER_PART_IDS)] = 10
lut[list(BOSS_PART_IDS)] = 11
if isinstance(ids, torch.Tensor):
return torch.from_numpy(lut).to(ids.device)[ids.long()]
return lut[ids]
def vocab_from_in_channels(in_channels: int) -> str:
for name, (_, k) in VOCABS.items():
if k * T_GROUP * UNSHUFFLE * UNSHUFFLE == in_channels:
return name
raise ValueError(f"gc_sem_cond.proj_in 输入通道 {in_channels} 不对应任何词表 {tuple(VOCABS)}")
# 空间:池化 4× + unshuffle 4× = 16× = VAE 空间压缩;时间:VAE 4×(帧 0 单独成组,这里重复 4 次补齐)
POOL = 4
UNSHUFFLE = 4
T_GROUP = 4
HIDDEN = 512
SEM_COND_INIT_SEED = 20261010
def _lut(vocab: str = DEFAULT_VOCAB) -> torch.Tensor:
m, k = vocab_spec(vocab)
lut = torch.full((256,), k - 1, dtype=torch.uint8)
for raw, i in m.items():
lut[raw] = i
return lut
def sem_ids_to_index(sem_ids: torch.Tensor, vocab: str = DEFAULT_VOCAB) -> torch.Tensor:
"""raw id → 类别下标(uint8,同形状)。只给测试 / 可视化用;前向不走这条(见 pooled_onehot)。"""
ids = sem_ids.long()
if ids.min() < 0 or ids.max() > 255:
raise ValueError(f"sem_ids 超出 uint8 范围:[{int(ids.min())}, {int(ids.max())}]")
return _lut(vocab).to(sem_ids.device)[ids]
@torch.no_grad()
def pooled_onehot(sem_ids: torch.Tensor, dtype=torch.float32, vocab: str = DEFAULT_VOCAB) -> torch.Tensor:
"""sem_ids [b, T, H, W](uint8 或整数)→ 逐类 4×4 平均池化 one-hot [b, K, T, H/4, W/4](dtype)。
一类一类算:每次只有一张 [b·T, 1, H, W] 的 0/1 图(fp32 ≈ 128 MB/样本,用完即弃),
从不物化 [b, K, T, H, W] 的全分辨率 one-hot。"""
assert sem_ids.dim() == 4, f"sem_ids 应为 [b, T, H, W],得到 {tuple(sem_ids.shape)}"
if sem_ids.dtype.is_floating_point or sem_ids.dtype == torch.bool:
raise TypeError(f"sem_ids 必须是整数 id(uint8 / long),得到 {sem_ids.dtype}")
b, T, H, W = sem_ids.shape
assert H % POOL == 0 and W % POOL == 0, (H, W)
flat = sem_ids.reshape(b * T, 1, H, W)
raw_to_index, num_classes = vocab_spec(vocab)
raw_of = {k: raw for raw, k in raw_to_index.items()}
planes = []
for k in range(num_classes - 1):
m = (flat == raw_of[k]).to(torch.float32)
planes.append(F.avg_pool2d(m, POOL).view(b, T, H // POOL, W // POOL))
del m
# 最后一类("other",ids 词表是第 9 类)= 1 − 其余之和(池化值是 1/16 的整数倍,fp32 精确,见文件头)
other = 1.0 - torch.stack(planes, 0).sum(0)
planes.append(other)
out = torch.stack(planes, 1) # [b, K, T, H/4, W/4]
return out.to(dtype)
def group_and_unshuffle(pooled: torch.Tensor) -> torch.Tensor:
"""[b, K, T=4f−3, h4, w4] → 帧 0 重复 4 次后按 4 帧分组 → pixel-unshuffle ×4 → [b, K·4·16, f, h4/4, w4/4]。"""
b, K, T, h4, w4 = pooled.shape
assert (T - 1) % T_GROUP == 0, f"帧数应为 4k+1,得到 {T}"
x = torch.cat([pooled[:, :, :1].expand(b, K, T_GROUP - 1, h4, w4), pooled], dim=2) # [b, K, 4f, h4, w4]
return rearrange(x, "b k (f t) (h p) (w q) -> b (k t p q) f h w", t=T_GROUP, p=UNSHUFFLE, q=UNSHUFFLE)
class SemCondEncoder(nn.Module):
"""语义 id 视频 → patch-embedding 空间的加性条件。见文件头。"""
def __init__(self, dim: int, patch_size=(1, 2, 2), hidden: int = HIDDEN, vocab: str = DEFAULT_VOCAB):
super().__init__()
_, num_classes = vocab_spec(vocab)
self.vocab = vocab
self.num_classes = num_classes
self.in_channels = num_classes * T_GROUP * UNSHUFFLE * UNSHUFFLE # ids 640 / parts 1280
self.dim = dim
self.proj_in = nn.Conv3d(self.in_channels, hidden, kernel_size=1)
self.conv_mid = nn.Conv3d(hidden, hidden, kernel_size=(1, 3, 3), padding=(0, 1, 1))
self.proj_out = nn.Conv3d(hidden, dim, kernel_size=tuple(patch_size), stride=tuple(patch_size))
nn.init.zeros_(self.proj_out.weight)
nn.init.zeros_(self.proj_out.bias)
def features(self, sem_ids: torch.Tensor) -> torch.Tensor:
"""sem_ids [b, 81, H, W] → 卷积前的输入 [b, 64K, 21, H/16, W/16](模块 dtype;832×480:[b, 640, 21, 30, 52])。"""
dtype = self.proj_in.weight.dtype
x = group_and_unshuffle(pooled_onehot(sem_ids.to(self.proj_in.weight.device), torch.float32, self.vocab))
return x.to(dtype)
def forward(self, sem_ids: torch.Tensor) -> torch.Tensor:
x = self.features(sem_ids)
assert x.shape[1] == self.in_channels, (x.shape, self.in_channels)
x = F.silu(self.proj_in(x))
x = F.silu(self.conv_mid(x))
return self.proj_out(x)
def forward_pooled(self, pooled: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
"""pooled one-hot [b, K, T, H/4, W/4] (fp32) → (patch-embedding term [b, dim, f, h, w], trunk features
[b, 512, f, 2h, 2w]). Same numbers as forward(); the trunk features feed the v2 deep adapter."""
dtype = self.proj_in.weight.dtype
x = group_and_unshuffle(pooled).to(dtype)
x = F.silu(self.proj_in(x))
h = F.silu(self.conv_mid(x))
return self.proj_out(h), h
def is_zero_output(self) -> bool:
"""最后一层权重与偏置全 0(刚装上 / 从无此模块的 ckpt 热启动)⇒ 输出恒为 0。"""
return bool((self.proj_out.weight == 0).all() and (self.proj_out.bias == 0).all())
# ----------------------------------------------------------------------------
# 白模条件(cond_type="whitebox",2026-10-12)
# ----------------------------------------------------------------------------
# GameCrafter-Parkour 的控制视频是 pk 渲染的白模:只有方块 / 圆柱 / 胶囊、固定光照(亮度 ∝ 面朝向)、黑天空、1–3 px 的
# 暗轮廓线(深度 / 法线突变处;窗框、窗台、障碍物顶面与屋顶的分界只靠它)。数据里是灰度(R=G=B),prep 存 uint8
# [81,480,832] 并把亮度截到 ≤254,255 留作 UNKNOWN(整段条件 dropout / CFG 负支 = 全 255,与语义 id 同一约定)。
#
# 编码器与 SemCondEncoder 同构,只换"逐帧 4×4 块特征"这一步(K=10 的池化 one-hot → WB_FEATURES=5 个通道):
# 0..2 R, G, B 块均值(x/127.5 − 1);3 亮度块内标准差(轮廓线在 4×4 均值里几乎消失,在标准差里是强信号);
# 4 已知像素比例(全 255 ⇒ 0;没有它,"未知"会与均值 ≈ 0 的中灰立面混在一起)。
# 均值 / 标准差只在已知像素上统计。之后帧 0 重复 4 次 + 4 帧分组 + unshuffle ×4(group_and_unshuffle,对通道数通用)
# → [b, 5·4·16 = 320, 21, 30, 52] → 1×1 → SiLU → 1×3×3 → SiLU → 零初始化 Conv3d(512, dim, (1,2,2), stride (1,2,2))。
# 时间方向的核全是 1 ⇒ cell j 的条件只依赖 cell j 自己的帧(逐 latent cell 因果,可蒸馏成因果学生,
# tests/test_wb_cond.py 验证)。输入也接受 RGB uint8 [b,T,H,W,3](三通道都是 255 才算 UNKNOWN),给以后的彩色控制视频用。
WB_COND_NAME = "gc_wb_cond"
WB_COND_INIT_SEED = 20261012
WB_FEATURES = 5
WB_FEATURE_NAMES = ("mean_r", "mean_g", "mean_b", "luma_std", "known_frac")
LUMA_WEIGHTS = (0.299, 0.587, 0.114)
COND_TYPES = ("ids", "whitebox")
COND_MODULE = {"ids": SEM_COND_NAME, "whitebox": WB_COND_NAME}
@torch.no_grad()
def wb_pooled_features(wb: torch.Tensor, dtype=torch.float32) -> torch.Tensor:
"""白模视频 uint8 [b, T, H, W](灰度)或 [b, T, H, W, 3](RGB)→ 逐帧 4×4 块特征 [b, 5, T, H/4, W/4](dtype)。
通道见上(WB_FEATURE_NAMES)。255 = UNKNOWN:灰度看该像素,RGB 看三通道是否都是 255。块内一个已知像素都没有时
前 4 个通道为 0、第 5 个为 0。逐帧独立计算(不跨帧),fp32。"""
if wb.dtype != torch.uint8:
raise TypeError(f"白模必须是 uint8 像素值(255 = UNKNOWN),得到 {wb.dtype}")
if wb.dim() == 4:
rgb = False
elif wb.dim() == 5 and wb.shape[-1] == 3:
rgb = True
else:
raise AssertionError(f"白模应为 [b, T, H, W] 或 [b, T, H, W, 3],得到 {tuple(wb.shape)}")
b, T, H, W = wb.shape[:4]
assert H % POOL == 0 and W % POOL == 0, (H, W)
h, w = H // POOL, W // POOL
if rgb:
x = wb.reshape(b * T, H, W, 3).permute(0, 3, 1, 2) # [bT, 3, H, W]
known = (x != UNKNOWN_ID).all(1, keepdim=True)
v = x.float().div_(127.5).sub_(1.0).mul_(known)
luma = sum(c * v[:, i:i + 1] for i, c in enumerate(LUMA_WEIGHTS))
else:
x = wb.reshape(b * T, 1, H, W)
known = x != UNKNOWN_ID
v = x.float().div_(127.5).sub_(1.0).mul_(known)
luma = v
kf = F.avg_pool2d(known.float(), POOL) # [bT, 1, h, w],1/16 的整数倍
denom = kf.clamp_min(1.0 / (POOL * POOL))
mean = F.avg_pool2d(v, POOL) / denom # [bT, 1 或 3, h, w]
lmean = mean if not rgb else F.avg_pool2d(luma, POOL) / denom
dev = (luma - lmean.repeat_interleave(POOL, 2).repeat_interleave(POOL, 3)).mul_(known)
std = (F.avg_pool2d(dev.square_(), POOL) / denom).sqrt_() # 两遍法:常数块的 std 恰为 0
if not rgb:
mean = mean.expand(-1, 3, -1, -1)
out = torch.cat([mean, std, kf], 1) # [bT, 5, h, w]
return out.view(b, T, WB_FEATURES, h, w).transpose(1, 2).to(dtype)
class WhiteboxCondEncoder(nn.Module):
"""白模视频 → patch-embedding 空间的加性条件(与 SemCondEncoder 同构、同层名,只换输入特征,见上)。"""
def __init__(self, dim: int, patch_size=(1, 2, 2), hidden: int = HIDDEN):
super().__init__()
self.n_features = WB_FEATURES
self.in_channels = WB_FEATURES * T_GROUP * UNSHUFFLE * UNSHUFFLE # 320
self.dim = dim
self.proj_in = nn.Conv3d(self.in_channels, hidden, kernel_size=1)
self.conv_mid = nn.Conv3d(hidden, hidden, kernel_size=(1, 3, 3), padding=(0, 1, 1))
self.proj_out = nn.Conv3d(hidden, dim, kernel_size=tuple(patch_size), stride=tuple(patch_size))
nn.init.zeros_(self.proj_out.weight)
nn.init.zeros_(self.proj_out.bias)
def features(self, wb: torch.Tensor) -> torch.Tensor:
"""白模 [b, 81, H, W(, 3)] uint8 → 卷积前的输入 [b, 320, 21, H/16, W/16](模块 dtype)。"""
x = group_and_unshuffle(wb_pooled_features(wb.to(self.proj_in.weight.device), torch.float32))
return x.to(self.proj_in.weight.dtype)
def forward(self, wb: torch.Tensor) -> torch.Tensor:
x = self.features(wb)
assert x.shape[1] == self.in_channels, (x.shape, self.in_channels)
x = F.silu(self.proj_in(x))
x = F.silu(self.conv_mid(x))
return self.proj_out(x)
def forward_pooled(self, pooled: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
"""wb_pooled_features 的输出 [b, 5, T, H/4, W/4] → (patch-embedding 项, trunk)。与 forward() 同一组数。"""
x = group_and_unshuffle(pooled).to(self.proj_in.weight.dtype)
x = F.silu(self.proj_in(x))
h = F.silu(self.conv_mid(x))
return self.proj_out(h), h
def is_zero_output(self) -> bool:
return bool((self.proj_out.weight == 0).all() and (self.proj_out.bias == 0).all())
# ----------------------------------------------------------------------------
# v2 architecture (2026-10-10): deep semantic injection + semantic attention bias. Both zero-initialised ⇒ a model with
# them freshly installed computes exactly what v1 computes (warm start from any v1 export).
# ----------------------------------------------------------------------------
ADAPTER_NAME = "adapter" # dit.gc_sem_cond.adapter
ATTN_GAIN_NAME = "attn_qk" # dit.gc_sem_cond.attn_qk [n_blocks, 2, K, dim]
ADAPTER_HIDDEN = 1024
class SemAdapter(nn.Module):
"""Deep injection (VACE / ControlNet-style, but light): the encoder's trunk features are patchified to the token grid,
mixed by a per-cell 3×3 conv, and added to the residual stream before each block in `layers` through a per-layer
zero-init Linear. In v1 the ids enter only once at the patch embedding (sem term ≈ 12 % of its RMS) and the
cond / no-cond val gap stays tiny, i.e. the network barely relies on the map deeper in."""
def __init__(self, dim: int, layers, hidden_in: int = HIDDEN, hidden: int = ADAPTER_HIDDEN):
super().__init__()
self.patch = nn.Conv3d(hidden_in, hidden, kernel_size=(1, 2, 2), stride=(1, 2, 2))
# spatial only: every latent cell sees only its own ids ⇒ the condition stays causal / chunk-local, so the model
# can be distilled to an autoregressive (chunked, KV-cached) student where future ids are not known yet
self.mix = nn.Conv3d(hidden, hidden, kernel_size=(1, 3, 3), padding=(0, 1, 1))
self.inj = nn.ModuleDict({str(int(i)): nn.Linear(hidden, dim) for i in layers})
for lin in self.inj.values():
nn.init.zeros_(lin.weight)
nn.init.zeros_(lin.bias)
@property
def layers(self) -> list[int]:
return sorted(int(k) for k in self.inj.keys())
def forward(self, h: torch.Tensor) -> torch.Tensor:
"""trunk [b, 512, f, H/16, W/16] → token features [b, f·(H/32)·(W/32), hidden](832×480:f·15·26)."""
t = F.silu(self.patch(h))
t = F.silu(self.mix(t))
t = rearrange(t, "b c f h w -> b (f h w) c")
return F.layer_norm(t, (t.shape[-1],)) # unit-RMS tokens: the zero-init injections become effective quickly
def is_zero_output(self) -> bool:
return all(bool((l.weight == 0).all() and (l.bias == 0).all()) for l in self.inj.values())
def token_class_dist(pooled: torch.Tensor) -> torch.Tensor:
"""pooled one-hot [b, K, 4f−3, H/4, W/4] → per-token class fractions [b, f·(H/32)·(W/32), K] (fp32), the
'other / UNKNOWN' class zeroed (unknown tokens neither give nor get the same-class bonus)."""
b, K, T, h4, w4 = pooled.shape
x = torch.cat([pooled[:, :, :1].expand(b, K, T_GROUP - 1, h4, w4), pooled], dim=2) # [b, K, 4f, h4, w4]
f = x.shape[2] // T_GROUP
x = x.view(b, K, f, T_GROUP, h4, w4).mean(3) # [b, K, f, h4, w4]
tok = UNSHUFFLE * 2 # a DiT token = 2×2 latent px = 8×8 pooled px = 32×32 image px
x = F.avg_pool2d(x.reshape(b, K * f, h4, w4), tok)
x = x.view(b, K, f, h4 // tok, w4 // tok).clone()
x[:, K - 1] = 0
return rearrange(x, "b k f h w -> b (f h w) k")
def _self_attn(sa, x, freqs, p_tok=None, e_qk=None):
"""WanSelfAttention.forward; with (p_tok [b, L, K], e_qk [2, K, dim]) learned class embeddings are added to q and k
after RoPE (q += p·E_q, k += p·E_k). Δq·Δk is a learnable class-pair attention bias (per head a rank-≤128 K×K
matrix), q·Δk lets content look for a class. Head dim stays 128 ⇒ same SDPA kernel as v1; E = 0 ⇒ bit-identical."""
if e_qk is None:
return sa(x, freqs)
n = sa.num_heads
q = rope_apply(sa.norm_q(sa.q(x)), freqs, n)
k = rope_apply(sa.norm_k(sa.k(x)), freqs, n)
p = p_tok.to(q.dtype)
q = q + p @ e_qk[0].to(q.dtype)
k = k + p @ e_qk[1].to(k.dtype)
return sa.o(sa.attn(q, k, sa.v(x)))
def gc_block_forward(block, x, context, t_mod, freqs, p_tok=None, e_qk=None, *, f: int):
"""actionrope.arope._block_forward with arm=None (DiTBlock.forward + per-cell text cross-attn), self-attention
through _self_attn. e_qk=None ⇒ identical to _block_forward."""
has_seq = len(t_mod.shape) == 4
chunk_dim = 2 if has_seq else 1
shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp = (
block.modulation.to(dtype=t_mod.dtype, device=t_mod.device) + t_mod).chunk(6, dim=chunk_dim)
if has_seq:
shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp = (
shift_msa.squeeze(2), scale_msa.squeeze(2), gate_msa.squeeze(2),
shift_mlp.squeeze(2), scale_mlp.squeeze(2), gate_mlp.squeeze(2),
)
input_x = modulate(block.norm1(x), shift_msa, scale_msa)
x = block.gate(x, gate_msa, _self_attn(block.self_attn, input_x, freqs, p_tok, e_qk))
q_in = block.norm3(x)
if context.shape[0] == x.shape[0]:
x = x + block.cross_attn(q_in, context)
else:
b = x.shape[0]
assert context.shape[0] == b * f, f"逐 cell context 应有 b*f={b * f} 行,得到 {context.shape[0]}"
q_in = rearrange(q_in, "b (f n) c -> (b f) n c", f=f)
y = block.cross_attn(q_in, context)
x = x + rearrange(y, "(b f) n c -> b (f n) c", f=f)
input_x = modulate(block.norm2(x), shift_mlp, scale_mlp)
x = block.gate(x, gate_mlp, block.ffn(input_x))
return x
def install_v2(dit: WanModel, adapter_every: int = 0, sem_attn: bool = False) -> WanModel:
"""Add the zero-init v2 modules under dit.gc_sem_cond (idempotent). adapter_every=k ⇒ inject before blocks 0, k, 2k…"""
if not hasattr(dit, SEM_COND_NAME):
install_sem_cond(dit)
enc = getattr(dit, SEM_COND_NAME)
ref = dit.patch_embedding.weight
with torch.random.fork_rng(devices=[]):
torch.manual_seed(SEM_COND_INIT_SEED + 1)
if adapter_every and not hasattr(enc, ADAPTER_NAME):
ad = SemAdapter(dit.dim, range(0, len(dit.blocks), int(adapter_every)))
enc.add_module(ADAPTER_NAME, ad.to(device=ref.device, dtype=ref.dtype))
if sem_attn and not hasattr(enc, ATTN_GAIN_NAME):
enc.register_parameter(ATTN_GAIN_NAME, nn.Parameter(
torch.zeros(len(dit.blocks), 2, enc.num_classes, dit.dim, device=ref.device, dtype=ref.dtype)))
return dit
def v2_config_from_keys(keys) -> dict:
"""Which v2 modules a checkpoint carries (from its key names)."""
pre = SEM_COND_NAME + "."
inj = sorted({int(k.split(".")[3]) for k in keys if k.startswith(f"{pre}{ADAPTER_NAME}.inj.")})
every = (inj[1] - inj[0]) if len(inj) > 1 else (1 if inj else 0)
return {"adapter_every": every, "sem_attn": f"{pre}{ATTN_GAIN_NAME}" in set(keys)}
# ----------------------------------------------------------------------------
# 加载 / 安装
# ----------------------------------------------------------------------------
def load_dit(model_dir: str, device: str = "cpu") -> WanModel:
"""只加载 DiT(bf16),与 actionrope/train.py::load_dit 同一条路径(逐行照抄,免得 import actionrope.train 拖进
baseline / dataset;maliang 不带 actionrope/train.py)。"""
from diffsynth.pipelines.wan_video import ModelConfig, WanVideoPipeline
files = [os.path.join(model_dir, f"diffusion_pytorch_model-0000{i}-of-00003.safetensors") for i in (1, 2, 3)]
pipe = WanVideoPipeline.from_pretrained(
torch_dtype=torch.bfloat16, device=device, model_configs=[ModelConfig(path=files)],
tokenizer_config=None, redirect_common_files=False,
)
return pipe.dit
def install_sem_cond(dit: WanModel, vocab: str = DEFAULT_VOCAB) -> WanModel:
"""给 DiT 装零初始化的 `dit.gc_sem_cond`(幂等:已装则原样返回,不重置权重;已装的词表与 vocab 不同则报错)。"""
if hasattr(dit, SEM_COND_NAME):
enc = getattr(dit, SEM_COND_NAME)
assert isinstance(enc, SemCondEncoder), f"dit.{SEM_COND_NAME} 不是 SemCondEncoder:{type(enc)}"
if enc.vocab != vocab:
raise ValueError(f"dit.{SEM_COND_NAME} 已按词表 {enc.vocab!r} 安装,要求的是 {vocab!r}")
return dit
ref = dit.patch_embedding.weight
# 固定种子建模块:不消耗、也不依赖全局 RNG(各 rank 一致,见文件头)
with torch.random.fork_rng(devices=[]):
torch.manual_seed(SEM_COND_INIT_SEED)
enc = SemCondEncoder(dit.dim, patch_size=tuple(dit.patch_size), vocab=vocab)
enc.to(device=ref.device, dtype=ref.dtype)
dit.add_module(SEM_COND_NAME, enc)
return dit
def install_wb_cond(dit: WanModel) -> WanModel:
"""给 DiT 装零初始化的 `dit.gc_wb_cond`(白模条件;幂等)。与 gc_sem_cond 互斥(双条件以后再说)。"""
if hasattr(dit, SEM_COND_NAME):
raise ValueError(f"dit 上已有 {SEM_COND_NAME}(语义 id 条件);白模模型只装 {WB_COND_NAME}")
if hasattr(dit, WB_COND_NAME):
enc = getattr(dit, WB_COND_NAME)
assert isinstance(enc, WhiteboxCondEncoder), f"dit.{WB_COND_NAME} 不是 WhiteboxCondEncoder:{type(enc)}"
return dit
ref = dit.patch_embedding.weight
with torch.random.fork_rng(devices=[]):
torch.manual_seed(WB_COND_INIT_SEED)
enc = WhiteboxCondEncoder(dit.dim, patch_size=tuple(dit.patch_size))
enc.to(device=ref.device, dtype=ref.dtype)
dit.add_module(WB_COND_NAME, enc)
return dit
def install_cond(dit: WanModel, cond_type: str = "ids") -> WanModel:
"""按 cond_type 装条件模块:ids ⇒ install_sem_cond,whitebox ⇒ install_wb_cond。"""
if cond_type not in COND_TYPES:
raise ValueError(f"cond_type 只能是 {COND_TYPES},得到 {cond_type!r}")
return install_wb_cond(dit) if cond_type == "whitebox" else install_sem_cond(dit)
def resolve_cond_type(dit, cond_type: str | None = None) -> str:
"""显式给了就用(并核对合法);None ⇒ 看 dit 上装了哪个:只有 gc_wb_cond ⇒ "whitebox",否则 "ids"(旧行为)。"""
if cond_type is not None:
if cond_type not in COND_TYPES:
raise ValueError(f"cond_type 只能是 {COND_TYPES},得到 {cond_type!r}")
return cond_type
has_sem, has_wb = hasattr(dit, SEM_COND_NAME), hasattr(dit, WB_COND_NAME)
if has_sem and has_wb:
raise ValueError(f"dit 上同时有 {SEM_COND_NAME} 和 {WB_COND_NAME},请显式传 cond_type")
return "whitebox" if has_wb else "ids"
def sem_vocab(dit) -> str:
"""dit 上已装的 gc_sem_cond 的词表。"""
return getattr(dit, SEM_COND_NAME).vocab
def vocab_from_state(sd_or_header) -> str | None:
"""state dict / safetensors 头(键 → 张量或 {'shape': ...})→ gc_sem_cond 的词表;没有 gc_sem_cond 键 ⇒ None。"""
for k in (f"{SEM_COND_NAME}.proj_in.weight", f"dit.{SEM_COND_NAME}.proj_in.weight"):
if k in sd_or_header:
v = sd_or_header[k]
shape = v["shape"] if isinstance(v, dict) else tuple(v.shape)
return vocab_from_in_channels(int(shape[1]))
return None
def cond_type_from_keys(keys) -> str:
"""ckpt 的键名 → 它是哪种条件的导出(有 gc_wb_cond.* / dit.gc_wb_cond.* ⇒ whitebox,否则 ids)。"""
pre = (WB_COND_NAME + ".", "dit." + WB_COND_NAME + ".")
return "whitebox" if any(k.startswith(pre) for k in keys) else "ids"
def _read_safetensors(path) -> tuple[dict, dict]:
paths = [path] if isinstance(path, (str, os.PathLike)) else list(path)
sd, meta = {}, {}
for p in paths:
part = load_file(str(p))
dup = set(part) & set(sd)
if dup:
raise RuntimeError(f"{p} 与前面的分片有重复键:{sorted(dup)[:5]}")
sd.update(part)
with safe_open(str(p), framework="pt") as fh:
meta.update(fh.metadata() or {})
return sd, meta
def load_gc_weights(dit: WanModel, path, cond_type: str = "ids") -> dict:
"""从导出的 safetensors(或分片列表)加载 DiT 权重。先 install_sem_cond(幂等),再按 ckpt 里出现的键装上 v2 模块
(gc_sem_cond.adapter.* / gc_sem_cond.attn_gain)。
严格匹配 dit.state_dict() 的键集;放行的缺失只有两类,且对应模块必须仍是零输出(刚装上):
* 全部 `gc_sem_cond.*`(从普通 Wan / ActionRoPE plain 导出热启动);
* v2 模块(调用方先 install_v2 再从 v1 导出热启动)。
返回 info:missing / n_tensors / has_sem_cond / v2 / metadata。
cond_type="whitebox" ⇒ 走 _load_wb_weights(gc_wb_cond,见那里);默认 "ids" 的行为与原来完全相同。"""
if cond_type != "ids":
if cond_type != "whitebox":
raise ValueError(f"cond_type 只能是 {COND_TYPES},得到 {cond_type!r}")
return _load_wb_weights(dit, path)
sd, meta = _read_safetensors(path)
# 词表:ckpt 里有 gc_sem_cond ⇒ 按它的输入通道定;没有(Wan / plain 热启动)⇒ 用 dit 上已装的,未装则默认 ids
ck_vocab = vocab_from_state(sd)
have = sem_vocab(dit) if hasattr(dit, SEM_COND_NAME) else None
if ck_vocab and have and ck_vocab != have:
raise RuntimeError(f"{path} 的 gc_sem_cond 是 {ck_vocab!r} 词表,dit 上装的是 {have!r}")
install_sem_cond(dit, ck_vocab or have or DEFAULT_VOCAB)
v2 = v2_config_from_keys(sd.keys())
install_v2(dit, **v2)
missing, unexpected = dit.load_state_dict(sd, strict=False)
prefix = SEM_COND_NAME + "."
v2_prefixes = (f"{prefix}{ADAPTER_NAME}.", f"{prefix}{ATTN_GAIN_NAME}")
bad_missing = [k for k in missing if not k.startswith(prefix)]
if bad_missing or unexpected:
raise RuntimeError(f"{path} 与模型键集不符:缺 {bad_missing[:5]}…({len(bad_missing)}),"
f"多 {list(unexpected)[:5]}…({len(unexpected)})")
enc = getattr(dit, SEM_COND_NAME)
sem_missing = [k for k in missing if k.startswith(prefix) and not k.startswith(v2_prefixes)]
v2_missing = [k for k in missing if k.startswith(v2_prefixes)]
n_sem_total = sum(1 for k in dit.state_dict() if k.startswith(prefix) and not k.startswith(v2_prefixes))
if sem_missing and len(sem_missing) != n_sem_total:
raise RuntimeError(f"{path} 只含部分 {prefix}* 键(缺 {sem_missing}),ckpt 不完整")
if sem_missing and not enc.is_zero_output():
raise RuntimeError(f"{path} 没有 {prefix}* 键,但 dit 上的 {SEM_COND_NAME} 已不是零初始化状态;"
f"请在全新 install 的模型上热启动")
if v2_missing:
ad, g = getattr(enc, ADAPTER_NAME, None), getattr(enc, ATTN_GAIN_NAME, None)
if (ad is not None and not ad.is_zero_output()) or (g is not None and bool((g != 0).any())):
raise RuntimeError(f"{path} 缺 v2 键 {v2_missing[:3]}…,但 v2 模块已不是零初始化状态")
return {"missing": list(missing), "n_tensors": len(sd), "has_sem_cond": not sem_missing, "v2": v2, "vocab": enc.vocab,
"v2_missing": len(v2_missing), "metadata": meta, "path": str(path)}
def _load_wb_weights(dit: WanModel, path) -> dict:
"""白模模型(dit.gc_wb_cond)的加载:install_wb_cond(幂等)后严格匹配键集。
* ckpt 里的 `gc_sem_cond.*`(含 v2 子模块)一律丢弃并记进 info["dropped_sem_keys"] —— 从语义 id 模型
(如 gc25d_v3v4/step-6000,825 个 Wan 键 + 6 个 gc_sem_cond 键)热启动时只取 825 个 DiT 键;
* 唯一放行的缺失:6 个 `gc_wb_cond.*` 全缺(热启动),且模块仍是零输出(刚装上);只缺一部分 ⇒ 报错;
* 缺任何 DiT 键 / 有其它多余键 ⇒ 报错;同时带 gc_sem_cond.* 与 gc_wb_cond.* 的 ckpt ⇒ 报错。"""
install_wb_cond(dit)
sd, meta = _read_safetensors(path)
n_file = len(sd)
sem_prefix, prefix = SEM_COND_NAME + ".", WB_COND_NAME + "."
dropped = sorted(k for k in sd if k.startswith(sem_prefix))
has_wb = any(k.startswith(prefix) for k in sd)
if dropped and has_wb:
raise RuntimeError(f"{path} 同时有 {sem_prefix}* 和 {prefix}* 键,不知道该用哪个")
for k in dropped:
del sd[k]
missing, unexpected = dit.load_state_dict(sd, strict=False)
bad_missing = [k for k in missing if not k.startswith(prefix)]
if bad_missing or unexpected:
raise RuntimeError(f"{path} 与白模模型键集不符:缺 {bad_missing[:5]}…({len(bad_missing)}),"
f"多 {list(unexpected)[:5]}…({len(unexpected)})")
enc = getattr(dit, WB_COND_NAME)
wb_missing = [k for k in missing if k.startswith(prefix)]
n_wb_total = sum(1 for k in dit.state_dict() if k.startswith(prefix))
if wb_missing and len(wb_missing) != n_wb_total:
raise RuntimeError(f"{path} 只含部分 {prefix}* 键(缺 {wb_missing}),ckpt 不完整")
if wb_missing and not enc.is_zero_output():
raise RuntimeError(f"{path} 没有 {prefix}* 键,但 dit 上的 {WB_COND_NAME} 已不是零初始化状态;"
f"请在全新 install 的模型上热启动")
return {"missing": list(missing), "n_tensors": n_file, "n_loaded": len(sd), "cond_type": "whitebox",
"has_wb_cond": not wb_missing, "has_sem_cond": False, "dropped_sem_keys": dropped,
"v2": {"adapter_every": 0, "sem_attn": False}, "v2_missing": 0, "metadata": meta, "path": str(path)}
# ----------------------------------------------------------------------------
# 前向
# ----------------------------------------------------------------------------
def gc_forward(
dit: WanModel,
latents: torch.Tensor,
timestep: torch.Tensor,
context: torch.Tensor,
sem_ids,
*,
first_frame_cond: bool = True,
context_ids=None,
use_gradient_checkpointing: bool = False,
use_gradient_checkpointing_offload: bool = False,
aux: dict | None = None,
cond_type: str | None = None,
) -> torch.Tensor:
"""gc25d 的 DiT 前向。latents [b, 48, f, H/16, W/16] → v_pred 同形(832×480:[b, 48, f, 30, 52])。
cond_type:"ids"(gc_sem_cond,语义 id)/ "whitebox"(gc_wb_cond,sem_ids 参数里放白模 uint8 [b, 4f−3, H, W(, 3)]);
None ⇒ resolve_cond_type(只装了 gc_wb_cond ⇒ whitebox,否则 ids —— 不传时与原来逐位相同)。
= arope_forward(offset_px=None, mask_input=None, arm=None) 的逐行照抄 + patch embedding 后的 sem 条件项。
sem_ids: [b, 4f−3, 16·lat_h, 16·lat_w] uint8 / 整数 raw id(255 = UNKNOWN);None ⇒ 不加 sem 项(≡ 原模型)。
给了但 dit 没装 gc_sem_cond ⇒ 报错(避免"以为用了条件其实没用")。
context: [b, 512, 4096](整条)或 [b, f, 512, 4096](逐 cell);context_ids [b, f] 用于逐 cell 去重。
timestep: 标量 / [1] / [b](1000 制);first_frame_cond ⇒ 帧 0 的 t=0。
aux: 给一个 dict ⇒ 填入诊断量(sem 项与 patch embedding 的 RMS),不影响计算。
"""
b, _, f, lat_h, lat_w = latents.shape
p_f, p_h, p_w = dit.patch_size
n_tok_per_frame = (lat_h // p_h) * (lat_w // p_w)
# 时间步:逐 token,帧 0 为 0(照抄 arope_forward / model_fn_wan_video)
t_tok = _per_token_timestep(latents, timestep, n_tok_per_frame, first_frame_cond)
t = dit.time_embedding(sinusoidal_embedding_1d(dit.freq_dim, t_tok.reshape(-1)).view(b, t_tok.shape[1], -1))
t_mod = dit.time_projection(t).unflatten(2, (6, dit.dim))
# 文本(逐 cell 时只对唯一串算一次 text_embedding)
context = _embed_context(dit, context, context_ids)
# patch embedding + 语义条件
x = dit.patch_embedding(latents)
adapter = attn_gain = tok = p_tok = None
if sem_ids is not None and resolve_cond_type(dit, cond_type) == "whitebox":
enc = getattr(dit, WB_COND_NAME, None)
if enc is None:
raise ValueError(f"cond_type=whitebox 但 dit 没有 {WB_COND_NAME},请先 install_wb_cond(dit) / "
f"load_gc_weights(..., cond_type='whitebox')")
exp = (b, T_GROUP * (f - 1) + 1, lat_h * POOL * UNSHUFFLE, lat_w * POOL * UNSHUFFLE)
assert tuple(sem_ids.shape[:4]) == exp and sem_ids.dim() in (4, 5), \
f"白模形状应为 {exp}(灰度)或 {exp + (3,)}(RGB),得到 {tuple(sem_ids.shape)}"
s, _ = enc.forward_pooled(wb_pooled_features(sem_ids.to(enc.proj_in.weight.device), torch.float32))
assert s.shape == x.shape, f"白模项 {tuple(s.shape)} ≠ patch embedding {tuple(x.shape)}"
if aux is not None:
aux["sem_rms"] = s.detach().float().pow(2).mean().sqrt()
aux["x_rms"] = x.detach().float().pow(2).mean().sqrt()
x = x + s.to(x.dtype)
elif sem_ids is not None:
enc = getattr(dit, SEM_COND_NAME, None)
if enc is None:
raise ValueError(f"给了 sem_ids 但 dit 没有 {SEM_COND_NAME},请先 install_sem_cond(dit) / load_gc_weights")
exp = (b, T_GROUP * (f - 1) + 1, lat_h * POOL * UNSHUFFLE, lat_w * POOL * UNSHUFFLE)
assert tuple(sem_ids.shape) == exp, f"sem_ids 形状应为 {exp},得到 {tuple(sem_ids.shape)}"
adapter = getattr(enc, ADAPTER_NAME, None)
attn_gain = getattr(enc, ATTN_GAIN_NAME, None)
pooled = pooled_onehot(sem_ids.to(enc.proj_in.weight.device), torch.float32, enc.vocab)
s, trunk = enc.forward_pooled(pooled)
assert s.shape == x.shape, f"sem 项 {tuple(s.shape)} ≠ patch embedding {tuple(x.shape)}"
if aux is not None:
aux["sem_rms"] = s.detach().float().pow(2).mean().sqrt()
aux["x_rms"] = x.detach().float().pow(2).mean().sqrt()
x = x + s.to(x.dtype)
if adapter is not None:
tok = adapter(trunk) # [b, L, 1024]
if attn_gain is not None:
p_tok = token_class_dist(pooled) # [b, L, K] fp32
del pooled
f, h, w = x.shape[2:]
assert f * h * w == t_tok.shape[1]
x = rearrange(x, "b c f h w -> b (f h w) c").contiguous()
# 普通 RoPE 查表(与 model_fn_wan_video 逐位相同)
freqs = build_world_freqs(dit, f, h, w, None, device=x.device)
# blocks:v1 modules only ⇒ actionrope._block_forward (bit-identical to v1); v2 ⇒ gc_block_forward + injections
inj_rms = []
for i, block in enumerate(dit.blocks):
if adapter is not None and str(i) in adapter.inj:
r = adapter.inj[str(i)](tok)
if aux is not None:
inj_rms.append(r.detach().float().pow(2).mean())
x = x + r.to(x.dtype)
if attn_gain is not None:
x = gradient_checkpoint_forward(
functools.partial(gc_block_forward, block, f=f),
use_gradient_checkpointing, use_gradient_checkpointing_offload,
x, context, t_mod, freqs, p_tok, attn_gain[i],
)
else:
x = gradient_checkpoint_forward(
functools.partial(_block_forward, block, f=f, arm=None, idx=i, grid=None),
use_gradient_checkpointing,
use_gradient_checkpointing_offload,
x, context, t_mod, freqs, None,
)
if aux is not None and inj_rms:
aux["inj_rms"] = torch.stack(inj_rms).mean().sqrt()
if aux is not None and attn_gain is not None:
aux["attn_bias_absmax"] = attn_gain.detach().float().pow(2).mean().sqrt() # RMS of the class embeddings
x = dit.head(x, t)
return dit.unpatchify(x, (f, h, w))