teawhite's picture
add docs+code
880dff9 verified
Raw History Blame Contribute Delete
19.7 kB
"""ARoPE:世界坐标 RoPE + known/new 支持掩码 + 逐 cell 文本 cross-attn。
为什么动作走坐标而不是走文本
-----------------------------
动作(玩家/相机位移)本质上是几何量:玩家向右走 D px ⇒ 背景整体左移 D px ⇒
现在屏幕位置 s 上的内容,在首帧位于 s + D。文本只能给模型一个离散标签
("moving right"),模型得自己从数据里反推"右移多少像素";而 RoPE 是相对位置编码,
把每帧 token 的 (h, w) 位置写成 **世界坐标**(窗口内格坐标 + 该帧相机累计偏移,
以 token 为单位),两帧里同一世界位置的 token 就拿到同一编码,注意力天然按世界
位置对齐。这一步不需要任何新参数(`mask_channel=False` 时 ckpt 键集与原模型完全一致)。
符号约定(与 SPEC.md 一致)
--------------------------
* 屏幕坐标 u 向右、v 向下,单位 px(832×480 画幅);token = 32 px(latent 16 px × patch 2)。
* `offset_px[b, k] = (dx, dy)`:cell k 相对 cell 0 的玩家屏幕投影位移,cell 0 恒为 0。
* cell k 中屏幕格 (i_h, j_w) 的 token 世界坐标 = `(i_h + dy/32, j_w + dx/32)`(允许小数)。
* known / new:世界坐标落在首帧足迹 `[-0.5, h-0.5] × [-0.5, w-0.5]` 内(含边界)⇒ known,
否则 new。latent 分辨率同理(30×52,偏移除以 16)。new 区 loss × `new_weight`。
与 `model_fn_wan_video` 的对应关系
--------------------------------
`arope_forward` 是 `diffsynth.pipelines.wan_video.model_fn_wan_video` 在
`seperated_timestep=True and fuse_vae_embedding_in_latents=True` 分支上的复刻,
只改了三处(其它每一步按原函数逐行照抄,`offset_px=None, mask_input=None, context 整条`
时输出与原函数一致):
1. RoPE 表:原函数把 `dit.freqs` 按 (f, h, w) 查表拼成 `[f*h*w, 1, 64]`;
这里 f 轴照旧查表,h/w 轴按世界坐标用 `polar(1, pos * inv_freq)` 现算,
得到逐样本的 `[b, f*h*w, 1, 64]`(`rope_apply` 里 complex 乘法可直接广播)。
2. patch embedding:`x = patch_embedding(latents) + arope_mask_embedding(mask_input)`,
后者零初始化,所以刚装上时不改变输出。
3. cross-attn:context 为 `[b, f, L, 4096]` 时每帧 token 只 attend 自己 cell 的文本
(q 重排成 `(b f) (h w) c`,k/v 为 `(b f) L c`)。cell 文本相同 ⇒ 与整条模式等价
(SDPA 的 batch 切分方式不同,只有 bf16 级别的舍入差)。
block 内部的计算(norm / modulate / self_attn / cross_attn / ffn / gate)照抄
`DiTBlock.forward`,不改 DiffSynth 源码;梯度检查点用 DiffSynth 自己的
`gradient_checkpoint_forward`(`diffsynth/core/gradient/gradient_checkpoint.py`),
所以 DeepSpeed / 非重入 checkpoint / CPU offload 三条路径与原模型一致。
"""
from __future__ import annotations
import functools
import torch
import torch.nn as nn
from einops import rearrange
from diffsynth.core.gradient import gradient_checkpoint_forward
from diffsynth.models.wan_video_dit import (
WanModel,
modulate,
sinusoidal_embedding_1d,
)
# token = 32 px:Wan2.2 VAE 空间压缩 16×,DiT patch (1,2,2) 再 2×。latent 格 = 16 px。
PX_PER_TOKEN = 32
PX_PER_LATENT = 16
# RoPE 的 theta,与 precompute_freqs_cis 的默认值一致
ROPE_THETA = 10000.0
# --------------------------------------------------------------------------
# 世界坐标 RoPE
# --------------------------------------------------------------------------
def _inv_freq(dim: int, device=None) -> torch.Tensor:
"""与 precompute_freqs_cis(dim) 完全相同的逆频率:1/theta^(arange(0,dim,2)/dim),float64。"""
return 1.0 / (ROPE_THETA ** (torch.arange(0, dim, 2, device=device)[: (dim // 2)].double() / dim))
def _axis_freqs(pos: torch.Tensor, dim: int) -> torch.Tensor:
"""任意(可小数)位置的 1D RoPE:polar(1, pos * inv_freq)。
pos 为整数时与 precompute_freqs_cis 查表逐位相同:两边都是 float64 位置乘
float64 逆频率再取 polar,单次乘法在 IEEE 下与 torch.outer 一致。
返回 complex128(precompute_freqs_cis 的真实 dtype;源码注释写 complex64 是错的)。
"""
angle = pos.double().unsqueeze(-1) * _inv_freq(dim, pos.device)
return torch.polar(torch.ones_like(angle), angle)
def build_world_freqs(dit: WanModel, f: int, h: int, w: int, offset_tok=None, device=None) -> torch.Tensor:
"""把 (frame_idx, i_h + dy_tok[f], j_w + dx_tok[f]) 编成 rope_apply 可用的 freqs。
offset_tok: [b, f, 2] = (dx_tok, dy_tok),单位 token;None ⇒ 普通 RoPE,
此时返回 [f*h*w, 1, 64] 且与 model_fn_wan_video 的查表结果逐位相同。
否则返回 [b, f*h*w, 1, 64]。dtype 跟随 dit.freqs(complex128)。
"""
f_table, h_table, w_table = dit.freqs
if device is None:
device = offset_tok.device if offset_tok is not None else f_table.device
d_h, d_w = h_table.shape[1] * 2, w_table.shape[1] * 2 # 42, 42
if offset_tok is None:
# 走与原函数一样的查表,保证等价性;不重新算 h/w 轴
return torch.cat([
f_table[:f].view(f, 1, 1, -1).expand(f, h, w, -1),
h_table[:h].view(1, h, 1, -1).expand(f, h, w, -1),
w_table[:w].view(1, 1, w, -1).expand(f, h, w, -1),
], dim=-1).reshape(f * h * w, 1, -1).to(device)
offset_tok = offset_tok.to(device=device, dtype=torch.float64)
b = offset_tok.shape[0]
assert offset_tok.shape == (b, f, 2), f"offset_tok 形状应为 [b, {f}, 2],得到 {tuple(offset_tok.shape)}"
dx = offset_tok[..., 0] # [b, f]
dy = offset_tok[..., 1]
pos_h = torch.arange(h, device=device, dtype=torch.float64).view(1, 1, h) + dy.view(b, f, 1) # [b, f, h]
pos_w = torch.arange(w, device=device, dtype=torch.float64).view(1, 1, w) + dx.view(b, f, 1) # [b, f, w]
fh = _axis_freqs(pos_h, d_h) # [b, f, h, 21]
fw = _axis_freqs(pos_w, d_w) # [b, f, w, 21]
freqs = torch.cat([
f_table[:f].to(device).view(1, f, 1, 1, -1).expand(b, f, h, w, -1),
fh.view(b, f, h, 1, -1).expand(b, f, h, w, -1),
fw.view(b, f, 1, w, -1).expand(b, f, h, w, -1),
], dim=-1)
return freqs.reshape(b, f * h * w, 1, -1).to(f_table.dtype)
# --------------------------------------------------------------------------
# 支持掩码
# --------------------------------------------------------------------------
def _known_from_offset(offset: torch.Tensor, h: int, w: int) -> torch.Tensor:
"""通用版:offset [b, f, 2] 已换算成当前格子的单位(token 或 latent)。
格 (i, j) 的世界坐标 (i + dy, j + dx) 落在首帧足迹 [-0.5, h-0.5] × [-0.5, w-0.5]
内(含边界)⇒ known。边界取闭区间:偏移恰好半格时那一行/列仍算 known。
"""
offset = offset.to(torch.float64)
b, f = offset.shape[:2]
dx = offset[..., 0].view(b, f, 1, 1)
dy = offset[..., 1].view(b, f, 1, 1)
wi = torch.arange(h, device=offset.device, dtype=torch.float64).view(1, 1, h, 1) + dy
wj = torch.arange(w, device=offset.device, dtype=torch.float64).view(1, 1, 1, w) + dx
in_h = (wi >= -0.5) & (wi <= h - 0.5)
in_w = (wj >= -0.5) & (wj <= w - 0.5)
return in_h & in_w # [b, f, h, w]
def known_mask(offset_tok: torch.Tensor, h: int, w: int) -> torch.Tensor:
"""token 分辨率的 known 掩码。offset_tok [b, f, 2](token 单位)→ bool [b, f, h, w]。"""
return _known_from_offset(offset_tok, h, w)
def _dist_outside_footprint(offset: torch.Tensor, h: int, w: int) -> torch.Tensor:
"""每个格子的世界坐标到首帧足迹矩形的欧氏距离(格子单位),足迹内为 0。offset 已换算成格子单位。"""
offset = offset.to(torch.float64)
b, f = offset.shape[:2]
dx = offset[..., 0].view(b, f, 1, 1)
dy = offset[..., 1].view(b, f, 1, 1)
wi = torch.arange(h, device=offset.device, dtype=torch.float64).view(1, 1, h, 1) + dy
wj = torch.arange(w, device=offset.device, dtype=torch.float64).view(1, 1, 1, w) + dx
dh = torch.clamp(torch.maximum(-0.5 - wi, wi - (h - 0.5)), min=0.0)
dw = torch.clamp(torch.maximum(-0.5 - wj, wj - (w - 0.5)), min=0.0)
return torch.sqrt(dh * dh + dw * dw) # [b, f, h, w]
def loss_weight_map(offset_px: torch.Tensor, lat_h: int = 30, lat_w: int = 52, new_weight: float = 2.0,
ramp_latent: float = 0.0, sigma=None) -> torch.Tensor:
"""latent 分辨率的 loss 权重。offset_px [b, f, 2] → float32 [b, 1, f, lat_h, lat_w]。
ramp_latent=0(默认,flat):known=1,new=new_weight,一刀切。
ramp_latent>0(dist):new 区权重按离首帧足迹边界的距离线性爬升,紧贴边界 ≈1(几乎是已知内容的延伸),
距离 ≥ ramp_latent 格时到 new_weight(纯发明区)。
sigma(0..1,None 不用):额外部分 (w−1) 再乘 (0.5+σ)——高噪声步决定布局,new 区多给;低噪声步只剩纹理,少给。
"""
off = offset_px.to(torch.float64) / PX_PER_LATENT
if ramp_latent > 0:
d = _dist_outside_footprint(off, lat_h, lat_w)
extra = (float(new_weight) - 1.0) * torch.clamp(d / float(ramp_latent), max=1.0)
else:
known = _known_from_offset(off, lat_h, lat_w)
extra = torch.where(known, 0.0, float(new_weight) - 1.0)
if sigma is not None:
s = torch.as_tensor(sigma, dtype=torch.float64, device=extra.device).reshape(-1, 1, 1, 1)
extra = extra * (0.5 + s)
return (1.0 + extra).to(torch.float32).unsqueeze(1)
# --------------------------------------------------------------------------
# 安装:可选的 mask 输入通道(零初始化 conv)
# --------------------------------------------------------------------------
MASK_EMBEDDING_NAME = "arope_mask_embedding"
def install_arope(dit: WanModel, mask_channel: bool = True) -> WanModel:
"""给 DiT 装 ARoPE。不改任何已有参数名。
mask_channel=True ⇒ 新增 dit.arope_mask_embedding = Conv3d(1, dim, patch, stride=patch),
权重与偏置零初始化:装上的瞬间输出与原模型完全相同,训练时再学会用 known/new 信息。
mask_channel=False ⇒ 严格零参数版(若之前装过则拆掉),state_dict 键集与原模型完全一致。
"""
if mask_channel:
if not hasattr(dit, MASK_EMBEDDING_NAME):
ref = dit.patch_embedding
conv = nn.Conv3d(1, dit.dim, kernel_size=dit.patch_size, stride=dit.patch_size)
nn.init.zeros_(conv.weight)
nn.init.zeros_(conv.bias)
conv.to(device=ref.weight.device, dtype=ref.weight.dtype)
dit.add_module(MASK_EMBEDDING_NAME, conv)
elif hasattr(dit, MASK_EMBEDDING_NAME):
delattr(dit, MASK_EMBEDDING_NAME)
return dit
# --------------------------------------------------------------------------
# 前向
# --------------------------------------------------------------------------
def _per_token_timestep(latents: torch.Tensor, timestep: torch.Tensor, n_tok_per_frame: int, first_frame_cond: bool) -> torch.Tensor:
"""照抄 model_fn_wan_video 的 seperated_timestep 分支:帧 0 的 t=0,其余帧 t=timestep。
原函数只支持 timestep 形状 [1](t_mod 的 batch 维恒为 1,靠广播作用到 b 个样本)。
这里放宽到 [b]:每个样本一个 timestep。dtype 跟 latents(训练链路里 timestep 本来就是 bf16)。
返回 [b, f * n_tok_per_frame]。
"""
b, f = latents.shape[0], latents.shape[2]
timestep = torch.as_tensor(timestep, device=latents.device).to(latents.dtype).reshape(-1)
if timestep.numel() == 1:
timestep = timestep.expand(b)
assert timestep.numel() == b, f"timestep 应为标量或 [b={b}],得到 {timestep.numel()} 个"
t_tok = timestep.view(b, 1, 1).expand(b, f, n_tok_per_frame).clone()
if first_frame_cond:
t_tok[:, 0] = 0
return t_tok.reshape(b, f * n_tok_per_frame)
def _dedup_rows(rows: torch.Tensor, ids=None):
"""把 [N, L, C] 里内容相同的行去重。返回 (unique_index [U], inverse [N])。
有 ids([N] 整数,相同 id ⇒ 同串)时直接按 id 分组;否则先用一个便宜的指纹分桶,
桶内再用 torch.equal 确认,避免对 512×4096 的串做全量排序。
"""
n = rows.shape[0]
if ids is not None:
keys = [int(k) for k in torch.as_tensor(ids).reshape(-1).tolist()]
assert len(keys) == n
else:
fp = rows[:, :, ::61].float().sum(dim=(1, 2)).tolist() # 61 与 4096 互素,采样均匀
keys = []
buckets: dict[float, list[int]] = {}
for i in range(n):
key = None
for j in buckets.get(fp[i], []):
if torch.equal(rows[i], rows[j]):
key = j
break
if key is None:
key = i
buckets.setdefault(fp[i], []).append(i)
keys.append(key)
first: dict = {}
for i, k in enumerate(keys):
first.setdefault(k, i)
uniq_keys = list(first)
key_to_u = {k: u for u, k in enumerate(uniq_keys)}
unique_index = torch.tensor([first[k] for k in uniq_keys], device=rows.device)
inverse = torch.tensor([key_to_u[k] for k in keys], device=rows.device)
return unique_index, inverse
def _embed_context(dit: WanModel, context: torch.Tensor, context_ids=None):
"""context [b, L, 4096] → [b, L, dim];[b, f, L, 4096] → [(b f), L, dim],text_embedding 只对唯一串算一次。"""
if context.dim() == 3:
return dit.text_embedding(context)
assert context.dim() == 4, f"context 应为 [b, L, 4096] 或 [b, f, L, 4096],得到 {tuple(context.shape)}"
b, f, L, c = context.shape
rows = context.reshape(b * f, L, c)
unique_index, inverse = _dedup_rows(rows, context_ids)
emb = dit.text_embedding(rows[unique_index]) # [U, L, dim]
return emb[inverse] # [(b f), L, dim]
def _block_forward(block, x, context, t_mod, freqs, feats=None, *, f: int, arm=None, idx: int = 0, grid=None):
"""照抄 DiTBlock.forward,只把 cross_attn 换成按 cell 分组的版本。
context 为 [b, L, dim](整条)时与 DiTBlock.forward 完全一样;
为 [(b f), L, dim](逐 cell)时 q 重排成 (b f) (h w) c 与之配对。
arm / feats / idx / grid:baseline 动作臂的钩子(见 baseline/SPEC.md)。arm=None 时与原实现逐位相同。
feats 作为张量参数传进来(而不是闭包)是为了梯度检查点下动作编码器的梯度能回传。
"""
if arm is not None:
x = arm.block_pre(idx, x, feats, grid)
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, block.self_attn(input_x, freqs))
if arm is not None:
x = arm.block_mid(idx, x, feats, grid) # 自注意力之后、文本 cross-attn 之前(Matrix-Game 式动作 cross-attn 放这里)
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)
if arm is not None:
x = arm.block_pre_ffn(idx, x, feats, grid) # 文本 cross-attn 之后、FFN 之前(Matrix-Game 3 上游的注入位置)
input_x = modulate(block.norm2(x), shift_mlp, scale_mlp)
x = block.gate(x, gate_mlp, block.ffn(input_x))
if arm is not None:
x = arm.block_post(idx, x, feats, grid)
return x
def arope_forward(
dit: WanModel,
latents: torch.Tensor,
timestep: torch.Tensor,
context: torch.Tensor,
*,
offset_px=None,
first_frame_cond: bool = True,
mask_input=None,
use_gradient_checkpointing: bool = False,
use_gradient_checkpointing_offload: bool = False,
context_ids=None,
arm=None,
action_inputs=None,
) -> torch.Tensor:
"""ARoPE 版 DiT 前向。latents [b, 48, f, 30, 52] → [b, 48, f, 30, 52]。
offset_px: [b, f, 2] (dx, dy) px;None ⇒ 普通 RoPE。
first_frame_cond: 帧 0 为干净条件帧,其 timestep 置 0(TI2V 的 seperated_timestep 路径)。
mask_input: [b, 1, f, 30, 52] float,known=1 / new=0;None ⇒ 不加。给了但没 install ⇒ 报错,
避免"以为用了掩码其实没用"这种静默错误。
context: [b, 512, 4096](整条)或 [b, f, 512, 4096](逐 cell)。
context_ids: [b, f] 整数,相同 id ⇒ 同串,用于逐 cell 模式下去重;None ⇒ 按内容去重。
arm / action_inputs: baseline 动作臂(baseline/SPEC.md 的 ActionArm)与它的输入 dict;None ⇒ 不注入。
"""
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(照抄 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))
grid = None
feats = None
if arm is not None:
grid = dict(b=b, f=f, h=lat_h // p_h, w=lat_w // p_w, n_tok_per_frame=n_tok_per_frame)
feats = arm.encode(action_inputs, grid)
t_mod = arm.modify_t_mod(t_mod, feats, grid)
# 文本
context = _embed_context(dit, context, context_ids)
if arm is not None:
context = arm.extra_context(context, feats, grid)
# patch embedding(+ 零初始化的 mask 通道)
x = dit.patch_embedding(latents)
if mask_input is not None:
if not hasattr(dit, MASK_EMBEDDING_NAME):
raise ValueError("给了 mask_input 但 dit 没有 arope_mask_embedding,请先 install_arope(dit, mask_channel=True)")
x = x + getattr(dit, MASK_EMBEDDING_NAME)(mask_input.to(device=x.device, dtype=x.dtype))
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()
if arm is not None:
x = arm.after_patch(x, feats, grid)
# RoPE:普通查表 或 世界坐标现算
if offset_px is None:
freqs = build_world_freqs(dit, f, h, w, None, device=x.device)
else:
freqs = build_world_freqs(dit, f, h, w, offset_px.to(torch.float64) / PX_PER_TOKEN, device=x.device)
# blocks(梯度检查点走 DiffSynth 自己的封装;f 用 partial 绑定,checkpoint 只收张量参数)
for i, block in enumerate(dit.blocks):
x = gradient_checkpoint_forward(
functools.partial(_block_forward, block, f=f, arm=arm, idx=i, grid=grid),
use_gradient_checkpointing,
use_gradient_checkpointing_offload,
x, context, t_mod, freqs, feats,
)
x = dit.head(x, t)
return dit.unpatchify(x, (f, h, w))