视频隐空间基础
TL;DR
视频扩散不在像素空间运行------原始视频太大,一帧 480×832 就有约 40 万像素,直接扩散不可行。解决方案是先用 VAE 把视频压缩到低维 latent(空间 64x 压缩),在 latent 空间跑流匹配,推理后再用 VAE 解码回像素。Wan2.1 VAE 使用因果 3D 卷积,时间维度只看当前帧及过去帧(不看未来),这是支持流式生成的关键。训练时 latent 预先编码存入 LMDB,不需要在线跑 VAE encode,节省训练时的显存和计算。
核心概念
为什么要用 Latent 空间
以典型推理配置为例(Wan2.1-T2V-1.3B,21 latent 帧,480×832 分辨率):
| 空间分辨率 | 帧数 | 通道 | 每帧元素数 | |
|---|---|---|---|---|
| 原始像素 | 480 × 832 | 21 | 3 | ≈ 399,360 |
| VAE latent | 60 × 104 | 21 | 16 | ≈ 99,840 |
空间压缩比 480/60×832/104=8×8=64480/60 \times 832/104 = 8 \times 8 = 64480/60×832/104=8×8=64。扩散模型在 60×104 的 latent 上运行,计算量对比像素空间缩减约 64 倍。代价是最终需要一次 VAE decode,但 decode 只运行一次,不在推理循环内。
Wan2.1 VAE 结构
Wan2.1 VAE 是一个因果 3D VAE ,核心模块是 CausalConv3d------标准 3D 卷积的变体,时间维度通过单侧 padding(只 pad 过去,不 pad 未来)实现因果性。
压缩参数(对应配置文件 wan/configs/):
- 空间压缩:stride=(1,8,8),即高/宽各 8x 下采样,时间不压缩
- 输出通道:Clatent=16C_{\text{latent}} = 16Clatent=16(16 维 latent,每维捕捉不同视觉特征)
VAE 权重冻结(requires_grad_(False)),训练过程中不更新。
Shape 全链路
从原始视频到扩散、再到解码的完整 tensor 变换:
原始像素: [B, 3, F, H, W ] # VAE 期望的输入格式
↓ WanVAEWrapper.encode_to_latent()
VAE 输出: [B, 16, F, H/8, W/8] # VAE 内部格式(通道优先)
↓ permute(0,2,1,3,4)
latent: [B, F, 16, H/8, W/8] # 扩散模型使用的格式(帧优先)
↓ 流匹配扩散(N 步去噪)
denoised: [B, F, 16, H/8, W/8]
↓ permute(0,2,1,3,4) # 还原回通道优先
↓ WanVAEWrapper.decode_to_pixel()
像素输出: [B, F, 3, H, W ] # clamp(-1, 1), float32
注意 permute(0, 2, 1, 3, 4) 在 encode 和 decode 中各出现一次------encode 后帧维度从第 2 维移到第 1 维(通道优先→帧优先),decode 前再还原。这是 VAE 和扩散模型之间的约定差异。
标准化
VAE encode 输出后,latent 还需要进行逐通道标准化:减去均值、除以标准差。16 个通道各有独立的均值和标准差参数,硬编码在 WanVAEWrapper.__init__() 中:
python
# 来自 Wan21/wan_utils/wan_wrapper.py
mean = [-0.7571, -0.7089, ..., -0.2921] # shape [16]
std = [2.8184, 1.4541, ..., 1.9160] # shape [16]
scale = [mean, 1.0 / std]
标准化把 latent 拉到大致零均值、单位方差,使流匹配的高斯噪声假设更接近实际分布。
LMDB 预编码数据集
训练时不需要在线跑 VAE encode。数据预处理阶段会将所有视频编码成 latent 并存入 LMDB 数据库(./dataset/Wan21/Action2V/data/)。训练时 LatentLMDBDataset 直接从 LMDB 读取已编码的 latent,不再调用 VAE:
python
# wan_utils/dataset.py:LatentLMDBDataset.__getitem__()
latents = retrieve_row_from_lmdb(env, "latents", np.float16, idx)
return {"clean_latent": torch.tensor(latents, dtype=torch.float32)[-1]}
[-1] 取最后一维是因为 LMDB 存储格式兼容 ODE trajectory(多步 latent),[-1] 对应最干净的那步(即 x0x_0x0)。
数学细节(可选)
VAE 的训练目标(KL 散度)
VAE 的 encoder 输出的是分布参数 (μ,logσ2)(\mu, \log\sigma^2)(μ,logσ2),通过 reparameterization trick 采样得到 latent zzz。训练目标是 ELBO:
LVAE=Ez∼qϕ(z∣x)logpθ(x∣z)−DKL(qϕ(z∣x)∥N(0,I))\mathcal{L}{\text{VAE}} = \mathbb{E}{z \sim q_\phi(z|x)}\left\\log p_\\theta(x\|z)\\right - D_{\text{KL}}(q_\phi(z|x) \| \mathcal{N}(0,I))LVAE=Ez∼qϕ(z∣x)logpθ(x∣z)−DKL(qϕ(z∣x)∥N(0,I))
第一项是重建质量(像素 MSE 或感知损失),第二项是正则化(使 latent 分布接近标准高斯)。Wan2.1 VAE 在大规模视频数据上预训练完毕,使用时完全冻结。
为什么因果 3D Conv 对视频生成重要
标准 3D Conv 在计算某一帧的特征时,会同时 attend 该帧的前后帧(双向)。这在 VAE encode(离线处理完整视频)时没有问题,但在 VAE decode(流式生成场景)时,要解码第 ttt 帧,需要第 t+kt+kt+k 帧的特征------这意味着必须等到整段视频生成完毕才能开始解码,无法流式。
CausalConv3d 通过将时间维度的 padding 放在序列开头(而非对称 padding)解决这一问题:
python
# wan/modules/vae.py:CausalConv3d.__init__()
self._padding = (self.padding[2], self.padding[2], # W 方向对称
self.padding[1], self.padding[1], # H 方向对称
2 * self.padding[0], 0) # T 方向只 pad 左侧(过去)
解码时,配合 cache_x(上一个 chunk 的最后几帧特征),实现逐块流式解码,无需等待未来帧。
代码对应
| 文件 | 类/函数 | 功能 |
|---|---|---|
Wan21/wan_utils/wan_wrapper.py:WanVAEWrapper |
encode_to_latent() |
像素→latent,含标准化和 permute |
Wan21/wan_utils/wan_wrapper.py:WanVAEWrapper |
decode_to_pixel() |
latent→像素,含反 permute 和 clamp |
Wan21/wan/modules/vae.py:CausalConv3d |
forward() |
因果时间卷积,左侧 padding + 可选 cache_x 前向传递 |
Wan21/wan_utils/dataset.py:LatentLMDBDataset |
__getitem__() |
从 LMDB 读取预编码 latent,返回 clean_latent |
关键提示 :encode_to_latent 的输入期望 [B, 3, F, H, W](通道在第 1 维),输出是 [B, F, 16, H/8, W/8](帧在第 1 维)。扩散训练循环中的 clean_latent 形状始终是后者;如果你自己传入 pixel tensor,需要确认维度顺序正确。