Files
hao-ai-lab-FastVideo/fastvideo/models/dits/wan_animate_face.py
T
2026-10-07 04:19:37 -07:00

335 lines
16 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Face-driving modules for Wan2.2-Animate.
These are the parts of the Animate checkpoint that plain Wan-I2V does not
have: the LIA-style motion encoder that turns 512x512 face crops into
identity-free motion vectors, the causal-conv face encoder that funnels those
vectors down to the latent frame rate, and the face-adapter cross-attention
blocks bolted onto the 40-block tower (one after every
``inject_face_latents_blocks``-th block).
Ported against BOTH references -- the official ``Wan-Video/Wan2.2``
(``wan/modules/animate/{motion_encoder,face_blocks}.py``) and diffusers'
``transformer_wan_animate.py``. The official Diffusers-format checkpoint
follows the diffusers naming, which this module reproduces exactly so every
tensor loads verbatim.
**The StyleGAN2 weight-scaling contract.** The motion encoder is LIA's
appearance/motion network, built from StyleGAN2 ``EqualConv2d``/``EqualLinear``
layers: the checkpoint stores weights **unit-scale** and the layer multiplies
by ``1 / sqrt(fan_in)`` at *forward time* (plus a fused leaky-ReLU that adds a
channel bias and scales by ``sqrt(2)``). Loading these tensors into vanilla
``nn.Conv2d``/``nn.Linear`` forwards succeeds and is silently wrong by a
per-layer constant -- the classic "loads cleanly, generates garbage" failure.
``MotionConv2d``/``MotionLinear`` below reproduce the runtime scaling exactly.
Verified checkpoint shapes (Wan-AI/Wan2.2-Animate-14B-Diffusers):
motion_encoder.conv_in.weight (32, 3, 1, 1)
motion_encoder.res_blocks.0.conv1.weight (32, 32, 3, 3)
motion_encoder.conv_out.weight (512, 512, 4, 4)
motion_encoder.motion_network.4.weight (20, 512)
motion_encoder.motion_synthesis_weight (512, 20)
face_encoder.conv1_local.weight (4096, 512, 3)
face_encoder.padding_tokens (1, 1, 1, 5120)
face_adapter.0.to_q.weight (5120, 5120)
face_adapter.0.norm_q.weight (128,) # per-head RMSNorm
"""
import math
import torch
import torch.nn.functional as F
from torch import nn
from fastvideo.attention import LocalAttention
from fastvideo.configs.models.dits.wan_animate import WanAnimateArchConfig
from fastvideo.layers.layernorm import RMSNorm
from fastvideo.layers.linear import ReplicatedLinear
# Single source of truth is the arch config; the DiT in wan_animate.py reads
# the same tuple through its config class attributes.
ANIMATE_ATTENTION_BACKENDS = WanAnimateArchConfig()._supported_attention_backends
# LIA appearance-encoder channel width per feature-map size. config.json ships
# motion_encoder_channel_sizes: null, which means "use this table" (it is the
# table LIA and the official code hardcode; diffusers names it the same).
WAN_ANIMATE_MOTION_ENCODER_CHANNEL_SIZES = {
"4": 512,
"8": 512,
"16": 512,
"32": 512,
"64": 256,
"128": 128,
"256": 64,
"512": 32,
"1024": 16,
}
class FusedLeakyReLU(nn.Module):
"""StyleGAN2's activation: add a channel bias, leaky-ReLU(0.2), scale by sqrt(2).
The sqrt(2) keeps activation variance roughly constant through the leaky
ReLU; the bias lives here (``act_fn.bias`` in the checkpoint) rather than
on the conv because StyleGAN2 fuses bias-add into the activation kernel.
"""
def __init__(self, bias_channels: int) -> None:
super().__init__()
self.bias = nn.Parameter(torch.zeros(bias_channels))
def forward(self, x: torch.Tensor) -> torch.Tensor:
expanded_shape = [1] * x.ndim
expanded_shape[1] = self.bias.shape[0]
return F.leaky_relu(x + self.bias.reshape(*expanded_shape), 0.2) * 2**0.5
class MotionConv2d(nn.Module):
"""StyleGAN2 EqualConv2d: unit-scale stored weight, ``1/sqrt(fan_in)`` at forward.
``blur_kernel`` implements the anti-aliasing FIR filter StyleGAN2 applies
before strided (downsampling) convs. It is derived from a python tuple and
registered non-persistent -- absent from the checkpoint, so it must be
rebuilt after meta-device construction (see the model's
``materialize_non_persistent_buffers``).
"""
def __init__(self, in_channels: int, out_channels: int, kernel_size: int, stride: int = 1,
padding: int = 0, bias: bool = True, blur_kernel: tuple[int, ...] | None = None,
use_activation: bool = True) -> None:
super().__init__()
self.in_channels = in_channels
self.stride = stride
self.padding = padding
self.blur = blur_kernel is not None
self._blur_kernel_taps = tuple(blur_kernel) if blur_kernel is not None else None
if self.blur:
p = (len(blur_kernel) - stride) + (kernel_size - 1)
self.blur_padding = ((p + 1) // 2, p // 2)
self.register_buffer("blur_kernel", self._build_blur_kernel(), persistent=False)
self.weight = nn.Parameter(torch.randn(out_channels, in_channels, kernel_size, kernel_size))
# The load-bearing constant: applied to the *stored* weight every forward.
self.scale = 1 / math.sqrt(in_channels * kernel_size**2)
# With an activation the bias is fused into it (act_fn.bias); without,
# it lives on the conv. Mutually exclusive, exactly like the checkpoint.
self.bias = nn.Parameter(torch.zeros(out_channels)) if (bias and not use_activation) else None
self.act_fn = FusedLeakyReLU(bias_channels=out_channels) if use_activation else None
def _build_blur_kernel(self) -> torch.Tensor:
kernel = torch.tensor(self._blur_kernel_taps, dtype=torch.float32)
kernel = kernel[None, :] * kernel[:, None]
return kernel / kernel.sum()
def forward(self, x: torch.Tensor) -> torch.Tensor:
if self.blur:
expanded = self.blur_kernel[None, None, :, :].expand(self.in_channels, 1, -1, -1)
x = F.conv2d(x, expanded.to(x.dtype), padding=self.blur_padding, groups=self.in_channels)
# Cast activations to the weight dtype: face crops arrive fp32 from the
# pipeline while the loaded weights are bf16, and this module also runs
# outside autocast (raw forwards, CPU tests).
x = x.to(self.weight.dtype)
x = F.conv2d(x, self.weight * self.scale, bias=self.bias, stride=self.stride, padding=self.padding)
if self.act_fn is not None:
x = self.act_fn(x)
return x
class MotionLinear(nn.Module):
"""StyleGAN2 EqualLinear: unit-scale stored weight, ``1/sqrt(fan_in)`` at forward."""
def __init__(self, in_dim: int, out_dim: int) -> None:
super().__init__()
self.weight = nn.Parameter(torch.randn(out_dim, in_dim))
self.scale = 1 / math.sqrt(in_dim)
self.bias = nn.Parameter(torch.zeros(out_dim))
def forward(self, x: torch.Tensor) -> torch.Tensor:
return F.linear(x.to(self.weight.dtype), self.weight * self.scale, bias=self.bias)
class MotionEncoderResBlock(nn.Module):
"""One 2x-downsampling step of the LIA appearance encoder.
Main path: 3x3 conv + 3x3 strided conv; skip path: 1x1 strided conv (no
bias); both blurred before the stride. The sum is divided by sqrt(2) to
keep variance flat -- the same convention as the fused activation.
"""
def __init__(self, in_channels: int, out_channels: int, kernel_size: int = 3,
kernel_size_skip: int = 1, blur_kernel: tuple[int, ...] = (1, 3, 3, 1),
downsample_factor: int = 2) -> None:
super().__init__()
self.conv1 = MotionConv2d(in_channels, in_channels, kernel_size, stride=1,
padding=kernel_size // 2, use_activation=True)
self.conv2 = MotionConv2d(in_channels, out_channels, kernel_size, stride=downsample_factor,
padding=0, blur_kernel=blur_kernel, use_activation=True)
self.conv_skip = MotionConv2d(in_channels, out_channels, kernel_size_skip, stride=downsample_factor,
padding=0, bias=False, blur_kernel=blur_kernel, use_activation=False)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return (self.conv2(self.conv1(x)) + self.conv_skip(x)) / math.sqrt(2)
class WanAnimateMotionEncoder(nn.Module):
"""Face crop -> identity-free 512-d motion vector (LIA linear motion decomposition).
A ``size``x``size`` RGB crop is squeezed to a ``style_dim`` appearance
vector, bottlenecked to ``motion_dim`` (=20) coefficients -- too narrow to
carry identity, wide enough to carry expression/pose -- and re-expanded as
a linear combination of ``motion_dim`` learned direction vectors that are
QR-orthonormalised at forward time (in fp32; upstream and diffusers both
upcast, because the orthogonalisation is precision-sensitive).
"""
def __init__(self, size: int = 512, style_dim: int = 512, motion_dim: int = 20,
out_dim: int = 512, motion_blocks: int = 5,
channels: dict[str, int] | None = None) -> None:
super().__init__()
self.size = size
if channels is None:
channels = WAN_ANIMATE_MOTION_ENCODER_CHANNEL_SIZES
self.conv_in = MotionConv2d(3, channels[str(size)], 1, use_activation=True)
self.res_blocks = nn.ModuleList()
in_channels = channels[str(size)]
# bit_length is exact where float log2 can land just below the integer;
# a size missing from the channel table fails loudly on the lookup.
for i in range(size.bit_length() - 1, 2, -1):
out_channels = channels[str(2**(i - 1))]
self.res_blocks.append(MotionEncoderResBlock(in_channels, out_channels))
in_channels = out_channels
self.conv_out = MotionConv2d(in_channels, style_dim, 4, padding=0, bias=False, use_activation=False)
# No activations between these linears -- LIA's design, kept verbatim.
linears = [MotionLinear(style_dim, style_dim) for _ in range(motion_blocks - 1)]
linears.append(MotionLinear(style_dim, motion_dim))
self.motion_network = nn.ModuleList(linears)
self.motion_synthesis_weight = nn.Parameter(torch.randn(out_dim, motion_dim))
def forward(self, face_image: torch.Tensor) -> torch.Tensor:
if face_image.shape[-2] != self.size or face_image.shape[-1] != self.size:
raise ValueError(f"face crops are {tuple(face_image.shape[-2:])} but the motion encoder "
f"was trained on ({self.size}, {self.size}); resize the face video "
"before the pipeline, do not let it silently rescale")
x = self.conv_in(face_image)
for block in self.res_blocks:
x = block(x)
motion_feat = self.conv_out(x).squeeze(-1).squeeze(-1)
for linear in self.motion_network:
motion_feat = linear(motion_feat)
# Linear motion decomposition: m = sum_i alpha_i * d_i with D orthonormal.
weight = (self.motion_synthesis_weight + 1e-8).to(torch.float32)
original_dtype = motion_feat.dtype
motion_feat = motion_feat.to(torch.float32)
q = torch.linalg.qr(weight)[0]
motion_vec = torch.matmul(torch.diag_embed(motion_feat), q.T).sum(dim=1)
return motion_vec.to(original_dtype)
class WanAnimateFaceEncoder(nn.Module):
"""Per-frame motion vectors -> per-latent-frame face tokens.
The two stride-2 causal convs downsample time 4x -- exactly the VAE's
temporal compression, so one token group lines up with one latent frame.
Emits ``num_heads`` content tokens + 1 learned padding token per frame.
Convs are plain ``nn.Conv1d`` with the causal left-pad applied inline:
the checkpoint keys are ``face_encoder.conv2.weight`` with no wrapper
sublevel, and a test pins that naming.
"""
def __init__(self, in_dim: int, out_dim: int, hidden_dim: int = 1024, num_heads: int = 4,
kernel_size: int = 3, eps: float = 1e-6, pad_mode: str = "replicate") -> None:
super().__init__()
self.num_heads = num_heads
self.time_causal_padding = (kernel_size - 1, 0)
self.pad_mode = pad_mode
self.act = nn.SiLU()
self.conv1_local = nn.Conv1d(in_dim, hidden_dim * num_heads, kernel_size, stride=1)
self.conv2 = nn.Conv1d(hidden_dim, hidden_dim, kernel_size, stride=2)
self.conv3 = nn.Conv1d(hidden_dim, hidden_dim, kernel_size, stride=2)
self.norm1 = nn.LayerNorm(hidden_dim, eps, elementwise_affine=False)
self.norm2 = nn.LayerNorm(hidden_dim, eps, elementwise_affine=False)
self.norm3 = nn.LayerNorm(hidden_dim, eps, elementwise_affine=False)
self.out_proj = nn.Linear(hidden_dim, out_dim)
self.padding_tokens = nn.Parameter(torch.zeros(1, 1, 1, out_dim))
def _causal(self, conv: nn.Conv1d, x: torch.Tensor) -> torch.Tensor:
"""Channels-last in/out; left-pad so frame t depends only on frames <= t."""
x = F.pad(x.permute(0, 2, 1), self.time_causal_padding, mode=self.pad_mode)
return conv(x).permute(0, 2, 1)
def forward(self, x: torch.Tensor) -> torch.Tensor:
batch_size = x.shape[0]
x = self._causal(self.conv1_local, x) # [B, T, hidden * N]
x = x.unflatten(2, (self.num_heads, -1)).permute(0, 2, 1, 3).flatten(0, 1) # [B*N, T, hidden]
x = self.act(self.norm1(x))
x = self.act(self.norm2(self._causal(self.conv2, x)))
x = self.act(self.norm3(self._causal(self.conv3, x)))
x = self.out_proj(x)
x = x.unflatten(0, (batch_size, -1)).permute(0, 2, 1, 3) # [B, T/4, N, out]
padding = self.padding_tokens.repeat(batch_size, x.shape[1], 1, 1).to(x.device, x.dtype)
return torch.cat([x, padding], dim=-2) # [B, T/4, N + 1, out]
class WanAnimateFaceCrossAttention(nn.Module):
"""Face cross-attention: each latent frame's video tokens query that frame's 5 face tokens.
The per-frame confinement (reshape ``[B, T*S_f] -> [(B*T), S_f]``) is the
entire expression-sync mechanism -- frame i can only see frame i's face.
Applied residually *outside* this module, after every
``inject_face_latents_blocks``-th transformer block. The caller guarantees
one face-token group per latent frame (checked loudly in the model).
qk-norm here is per-head RMSNorm over ``head_dim`` (checkpoint:
``norm_q.weight`` of shape (128,)) -- NOT the across-heads norm the main
tower uses. Pre-norms are affine-free and contribute no tensors. The
reference's optional multiplicative motion mask is not ported: the Animate
pipeline never supplies one.
"""
def __init__(self, dim: int, num_heads: int, eps: float = 1e-6) -> None:
super().__init__()
assert dim % num_heads == 0
self.num_heads = num_heads
self.head_dim = dim // num_heads
self.pre_norm_q = nn.LayerNorm(dim, eps, elementwise_affine=False)
self.pre_norm_kv = nn.LayerNorm(dim, eps, elementwise_affine=False)
self.to_q = ReplicatedLinear(dim, dim)
self.to_k = ReplicatedLinear(dim, dim)
self.to_v = ReplicatedLinear(dim, dim)
self.to_out = ReplicatedLinear(dim, dim)
self.norm_q = RMSNorm(self.head_dim, eps=eps)
self.norm_k = RMSNorm(self.head_dim, eps=eps)
self.attn = LocalAttention(num_heads=num_heads, head_size=self.head_dim, dropout_rate=0,
softmax_scale=None, causal=False,
supported_attention_backends=ANIMATE_ATTENTION_BACKENDS)
def forward(self, hidden_states: torch.Tensor, motion_vec: torch.Tensor) -> torch.Tensor:
"""hidden_states [B, S, C]; motion_vec [B, T, N, C]. Returns the residual (not added)."""
b, s, _ = hidden_states.shape
t, n = motion_vec.shape[1], motion_vec.shape[2]
q_in = self.pre_norm_q(hidden_states)
kv_in = self.pre_norm_kv(motion_vec).flatten(1, 2) # [B, T*N, C]
q = self.norm_q(self.to_q(q_in)[0].view(b, s, self.num_heads, self.head_dim))
k = self.norm_k(self.to_k(kv_in)[0].view(b, t, n, self.num_heads, self.head_dim))
v = self.to_v(kv_in)[0].view(b, t, n, self.num_heads, self.head_dim)
# Per-frame confinement: [B, S] -> [(B T), S/T] queries against that
# frame's [(B T), N] keys/values.
q = q.unflatten(1, (t, s // t)).flatten(0, 1)
k = k.flatten(0, 1)
v = v.flatten(0, 1)
out = self.attn(q, k, v).flatten(2) # [(B T), S/T, C]
out = out.unflatten(0, (b, t)).flatten(1, 2) # [B, S, C]
return self.to_out(out)[0]