[feat] WanTrack: Fun-Control init to break the track-encoder deadlock

Initialize the 52ch TrackWan from Wan2.1-Fun-1.3B-Control so the track-slot
patch-embed channels start from Fun-Control's PRETRAINED control channels
(non-zero). Combined with the zero-init track head this gives step-0 == teacher
while still letting gradient reach the track encoder -- avoiding the double
zero-init deadlock of the from-scratch Fun-InP path (where both the track
patch-embed channels and the track-head proj are zero, so the track pathway
never trains). Adds the converter, the matching track_config channel handling,
and the InP init updates.
This commit is contained in:
shaoxiongduan
2026-07-01 06:36:42 +00:00
parent 8af0c43a65
commit 7349e07cec
3 changed files with 262 additions and 7 deletions
+18 -6
View File
@@ -15,7 +15,9 @@ It produces a diffusers transformer dir whose weights load *strictly* into
- ``patch_embedding.weight`` zero-padded base_in -> 52 input channels (pretrained
weights occupy the first base_in channels; the new track channels start at
zero, so a freshly converted model reproduces the base at step 0),
- ``track_encoder.*`` added (proj zero-init -> zero track contribution at step 0),
- ``track_encoder.*`` added (proj normal-init; the patch-embed track channels are the
single zero-conv -> zero track contribution at step 0, but gradient still flows so the
track pathway can learn -- see build_track_encoder_state for the deadlock rationale),
- ``config.json`` gets ``in_channels=52`` + ``track_config`` (CLIP ``image_dim``
is inherited from the base config when present).
@@ -48,16 +50,26 @@ TRACK_CONFIG = {
"vae_spatial_compression": 8,
"vae_temporal_compression": VAE_T_COMP,
"max_track_id": 100_000,
"zero_init_head": True,
# The single zero-conv is the patch-embed track channels (zero-padded below), NOT the
# track head -> proj is normal-init so the track pathway actually receives gradient.
"zero_init_head": False,
}
def build_track_encoder_state() -> dict[str, torch.Tensor]:
"""Match TrackEncoder's params: temporal_conv (default init) + proj (zero)."""
"""Match TrackEncoder's params: temporal_conv + proj, BOTH normal-initialized.
IMPORTANT (deadlock fix): the track signal passes through two layers in series --
``track_encoder.proj`` then the patch-embed track channels [36:52]. The single
ControlNet-style zero-conv is the *patch-embed track channels* (kept at 0 by the
zero-pad in main()), which already guarantees zero track contribution at step 0
(teacher behavior). ``proj`` must therefore be NON-zero so gradient can reach the
patch-embed track channels (grad ∝ proj output): if BOTH were zero, each layer's
gradient is gated by the other being nonzero -> both stay exactly 0 forever and the
track pathway never learns (observed: proj & patch-embed[36:52] frozen at 0.0 after
4000 steps). So leave proj at its default Conv init here."""
temporal_conv = nn.Conv3d(ID_DIM, TRACK_CHANNELS, kernel_size=(VAE_T_COMP, 1, 1), stride=(VAE_T_COMP, 1, 1))
proj = nn.Conv3d(TRACK_CHANNELS, TRACK_CHANNELS, kernel_size=1)
nn.init.zeros_(proj.weight)
nn.init.zeros_(proj.bias)
proj = nn.Conv3d(TRACK_CHANNELS, TRACK_CHANNELS, kernel_size=1) # default init (NOT zero) -> breaks the deadlock
return {
"track_encoder.temporal_conv.weight": temporal_conv.weight.detach().clone(),
"track_encoder.temporal_conv.bias": temporal_conv.bias.detach().clone(),
+2 -1
View File
@@ -39,7 +39,8 @@ class TrackWanVideoArchConfig(WanVideoArchConfig):
"vae_spatial_compression": 8, # Wan VAE spatial downsample (latent grid = H/8 x W/8)
"vae_temporal_compression": 4, # Wan VAE temporal downsample
"max_track_id": 100_000, # range of random per-track IDs
"zero_init_head": True, # zero-init final conv so step-0 == teacher
"zero_init_head":
False, # patch-embed track channels are the zero-conv; proj must stay non-zero (deadlock fix)
})
@@ -0,0 +1,242 @@
# SPDX-License-Identifier: Apache-2.0
"""Build a WanTrack init from the official Wan2.1-Fun-1.3B-Control (VideoX-Fun format).
MotionStream-style init. Instead of zero-padding fresh track channels onto a Fun-InP
base (which trains the track injection FROM SCRATCH and -- with a naive double zero-init --
deadlocks), we inherit Fun-Control's *pretrained* control channels, which already know how
to route a dense spatial control latent into the diffusion. This is exactly what MotionStream
does ("initialize from VideoX-Fun's Wan variants which extend Wan I2V with additional control
channels ... accelerates convergence").
Fun-Control's DiT input is 48ch, assembled by the VideoX-Fun pipeline as
x = cat([ noisy(16) | control(16) | start_image/ref(16) ])
We remap those pretrained input channels into our EXISTING 52ch TrackWan layout so the model
definition is untouched (the wrapper builds [noisy|mask|first-frame], the transformer appends
the track map):
our [0:16] noisy <- control [0:16]
our [16:20] mask <- 0 (Fun-Control has no mask; the wrapper still builds one,
projected by these zero weights -> ignored at init,
trainable thereafter)
our [20:36] first-frame <- control [32:48] (ref / start-image slot)
our [36:52] track <- control [16:32] (PRETRAINED control channels -> warm start)
The track head ``proj`` is zero-init (``zero_init_head=True``): step-0 track map cm=0 -> the
track slot contributes nothing -> the model reproduces Fun-Control-as-I2V (teacher) exactly.
Gradient still flows because the track-slot patch-embed weights are Control's pretrained
control channels (grad(proj) ~ those weights != 0) -- so NO deadlock, unlike the from-scratch
Fun-InP path.
The source DiT uses VideoX-Fun naming; it is renamed to diffusers naming with the same map as
``scripts/checkpoint_conversion/wan_to_diffusers.py`` so the result loads *strictly* into
``TrackWanTransformer3DModel``. Non-transformer components (vae / text_encoder / tokenizer /
image_encoder / scheduler) and the diffusers arch config are taken from a Fun-InP *diffusers*
base -- identical 1.3B arch, same VAE / T5 / CLIP / scheduler. The converted body keys are
validated against that base's key set so a missing/extra key fails loudly here, not at train.
Usage (no GPU / no fastvideo import needed):
python scripts/checkpoint_conversion/wan_fun_control_to_trackwan.py \
--inp-base <Fun-InP diffusers dir> \
--control-ckpt <alibaba-pai Fun-Control diffusion_pytorch_model.safetensors> \
--out <output dir>
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
from pathlib import Path
import torch
from safetensors.torch import load_file, save_file
from torch import nn
NEW_IN_CHANNELS = 52
TRACK_CHANNELS = 16
ID_DIM = 128
VAE_T_COMP = 4
TRACK_CONFIG = {
"id_dim": ID_DIM,
"track_channels": TRACK_CHANNELS,
"vae_spatial_compression": 8,
"vae_temporal_compression": VAE_T_COMP,
"max_track_id": 100_000,
# Safe to zero-init the track head here: the track-slot patch-embed channels are
# Fun-Control's PRETRAINED control channels (non-zero), so gradient reaches proj even
# with cm=0 at step 0. zero-init proj => step-0 == teacher (Fun-Control as pure I2V).
"zero_init_head": True,
}
# VideoX-Fun (alibaba-pai) -> diffusers WanTransformer3DModel naming.
# Copied from scripts/checkpoint_conversion/wan_to_diffusers.py (that module runs conversion
# code at import time, so it cannot be imported). ``patch_embedding`` keeps its name and is
# handled separately (channel remap), so it is intentionally absent here.
_PARAM_NAMES_MAPPING: dict = {
r"^text_embedding\.0\.(.*)$": r"condition_embedder.text_embedder.linear_1.\1",
r"^text_embedding\.2\.(.*)$": r"condition_embedder.text_embedder.linear_2.\1",
r"^time_embedding\.0\.(.*)$": r"condition_embedder.time_embedder.linear_1.\1",
r"^time_embedding\.2\.(.*)$": r"condition_embedder.time_embedder.linear_2.\1",
r"^time_projection\.1\.(.*)$": r"condition_embedder.time_proj.\1",
r"^img_emb\.proj\.0\.(.*)$": r"condition_embedder.image_embedder.norm1.\1",
r"^img_emb\.proj\.1\.(.*)$": r"condition_embedder.image_embedder.ff.net.0.proj.\1",
r"^img_emb\.proj\.3\.(.*)$": r"condition_embedder.image_embedder.ff.net.2.\1",
r"^img_emb\.proj\.4\.(.*)$": r"condition_embedder.image_embedder.norm2.\1",
r"^head\.modulation": r"scale_shift_table",
r"^head\.head\.(.*)$": r"proj_out.\1",
r"^blocks\.(\d+)\.self_attn\.q\.(.*)$": r"blocks.\1.attn1.to_q.\2",
r"^blocks\.(\d+)\.self_attn\.k\.(.*)$": r"blocks.\1.attn1.to_k.\2",
r"^blocks\.(\d+)\.self_attn\.v\.(.*)$": r"blocks.\1.attn1.to_v.\2",
r"^blocks\.(\d+)\.self_attn\.o\.(.*)$": r"blocks.\1.attn1.to_out.0.\2",
r"^blocks\.(\d+)\.self_attn\.norm_q\.(.*)$": r"blocks.\1.attn1.norm_q.\2",
r"^blocks\.(\d+)\.self_attn\.norm_k\.(.*)$": r"blocks.\1.attn1.norm_k.\2",
r"^blocks\.(\d+)\.cross_attn\.q\.(.*)$": r"blocks.\1.attn2.to_q.\2",
r"^blocks\.(\d+)\.cross_attn\.k\.(.*)$": r"blocks.\1.attn2.to_k.\2",
r"^blocks\.(\d+)\.cross_attn\.k_img\.(.*)$": r"blocks.\1.attn2.add_k_proj.\2",
r"^blocks\.(\d+)\.cross_attn\.v\.(.*)$": r"blocks.\1.attn2.to_v.\2",
r"^blocks\.(\d+)\.cross_attn\.v_img\.(.*)$": r"blocks.\1.attn2.add_v_proj.\2",
r"^blocks\.(\d+)\.cross_attn\.o\.(.*)$": r"blocks.\1.attn2.to_out.0.\2",
r"^blocks\.(\d+)\.cross_attn\.norm_q\.(.*)$": r"blocks.\1.attn2.norm_q.\2",
r"^blocks\.(\d+)\.cross_attn\.norm_k\.(.*)$": r"blocks.\1.attn2.norm_k.\2",
r"^blocks\.(\d+)\.cross_attn\.norm_k_img\.(.*)$": r"blocks.\1.attn2.norm_added_k.\2",
r"^blocks\.(\d+)\.ffn\.0\.(.*)$": r"blocks.\1.ffn.net.0.proj.\2",
r"^blocks\.(\d+)\.ffn\.2\.(.*)$": r"blocks.\1.ffn.net.2.\2",
r"^blocks\.(\d+)\.modulation": r"blocks.\1.scale_shift_table",
r"^blocks\.(\d+)\.norm3\.(.*)$": r"blocks.\1.norm2.\2",
}
def convert_names(src: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]:
"""Rename VideoX-Fun keys -> diffusers keys. patch_embedding passes through verbatim."""
out: dict[str, torch.Tensor] = {}
for k, v in src.items():
if k.startswith("patch_embedding."):
out[k] = v
continue
for pat, repl in _PARAM_NAMES_MAPPING.items():
if re.match(pat, k):
out[re.sub(pat, repl, k)] = v
break
else:
raise ValueError(f"No diffusers-name mapping for source key: {k}")
return out
def build_track_encoder_state() -> dict[str, torch.Tensor]:
"""temporal_conv (default init) + proj (ZERO). Zero proj is safe here -- see TRACK_CONFIG."""
temporal_conv = nn.Conv3d(ID_DIM, TRACK_CHANNELS, kernel_size=(VAE_T_COMP, 1, 1), stride=(VAE_T_COMP, 1, 1))
proj = nn.Conv3d(TRACK_CHANNELS, TRACK_CHANNELS, kernel_size=1)
nn.init.zeros_(proj.weight)
nn.init.zeros_(proj.bias)
return {
"track_encoder.temporal_conv.weight": temporal_conv.weight.detach().clone(),
"track_encoder.temporal_conv.bias": temporal_conv.bias.detach().clone(),
"track_encoder.proj.weight": proj.weight.detach().clone(),
"track_encoder.proj.bias": proj.bias.detach().clone(),
}
def remap_patch_embedding(ctrl_pe: torch.Tensor) -> torch.Tensor:
"""Fun-Control patch-embed [out,48,1,2,2] -> our [out,52,1,2,2] layout.
Control order : [ noisy 0:16 | control 16:32 | ref 32:48 ]
Our (52) order : [ noisy 0:16 | mask 16:20 | first-frame 20:36 | track 36:52 ]
"""
if ctrl_pe.shape[1] != 48:
raise ValueError(f"expected Fun-Control patch_embedding in_ch=48, got {ctrl_pe.shape[1]}")
out = torch.zeros((ctrl_pe.shape[0], NEW_IN_CHANNELS, *ctrl_pe.shape[2:]), dtype=ctrl_pe.dtype)
out[:, 0:16] = ctrl_pe[:, 0:16] # noisy <- control noisy
# out[:, 16:20] mask -> stays zero (Fun-Control has no mask channels)
out[:, 20:36] = ctrl_pe[:, 32:48] # first-frame <- control ref / start-image
out[:, 36:52] = ctrl_pe[:, 16:32] # track (pretrained) <- control control-slot
return out
def main() -> None:
p = argparse.ArgumentParser()
p.add_argument("--inp-base", required=True,
help="Fun-InP *diffusers* dir (config template + vae/text/clip/scheduler to symlink).")
p.add_argument("--control-ckpt", required=True,
help="alibaba-pai Wan2.1-Fun-1.3B-Control diffusion_pytorch_model.safetensors (VideoX-Fun fmt).")
p.add_argument("--out", required=True, help="Output WanTrack(Control) init dir.")
args = p.parse_args()
base = Path(args.inp_base)
out = Path(args.out)
if not (base / "transformer" / "config.json").exists():
raise FileNotFoundError(f"{base}/transformer/config.json not found")
out.mkdir(parents=True, exist_ok=True)
# 1) Reference key set from the Fun-InP diffusers base (guarantees a strict load).
base_sf = sorted((base / "transformer").glob("*.safetensors"))
if not base_sf:
raise FileNotFoundError(f"No transformer safetensors under {base}/transformer")
base_state: dict[str, torch.Tensor] = {}
for sf in base_sf:
base_state.update(load_file(str(sf)))
ref_keys = set(base_state)
has_norm_added_q = any(k.endswith("attn2.norm_added_q.weight") for k in ref_keys)
# 2) Load + rename the Fun-Control DiT.
ctrl_src = load_file(args.control_ckpt)
conv = convert_names(ctrl_src)
# diffusers WanAttention pairs norm_added_k with norm_added_q; VideoX-Fun has no norm_q_img,
# so synthesize zeros to match the base key set (only if the base actually has it).
if has_norm_added_q:
for k, v in list(conv.items()):
if k.endswith("attn2.norm_added_k.weight"):
conv[k.replace("norm_added_k", "norm_added_q")] = torch.zeros_like(v)
# 3) Remap patch-embed 48 -> 52 (our layout); bias is per-output-channel -> unchanged.
conv["patch_embedding.weight"] = remap_patch_embedding(conv["patch_embedding.weight"])
# 4) Validate body keys == base body keys (ignore patch_embedding shape; track_encoder is new).
conv_body = {k for k in conv}
missing = ref_keys - conv_body
extra = conv_body - ref_keys
if missing or extra:
raise RuntimeError(f"converted body keys != base keys.\n missing={sorted(missing)[:8]}\n "
f"extra={sorted(extra)[:8]}\n (#missing={len(missing)} #extra={len(extra)})")
for k in ref_keys:
if k == "patch_embedding.weight":
continue
if conv[k].shape != base_state[k].shape:
raise RuntimeError(f"shape mismatch {k}: control {tuple(conv[k].shape)} vs base {tuple(base_state[k].shape)}")
print(f"[convert] body keys validated against base ({len(ref_keys)} keys); norm_added_q={'yes' if has_norm_added_q else 'no'}")
# 5) Add the track encoder.
dtype = conv["patch_embedding.weight"].dtype
for k, v in build_track_encoder_state().items():
conv[k] = v.to(dtype)
print(f"[convert] patch_embedding -> {tuple(conv['patch_embedding.weight'].shape)}; +4 track_encoder tensors")
# 6) Symlink every base entry except transformer/.
for entry in os.listdir(base):
if entry == "transformer":
continue
src = (base / entry).resolve()
dst = out / entry
if dst.is_symlink() or dst.exists():
if dst.is_dir() and not dst.is_symlink():
shutil.rmtree(dst)
else:
dst.unlink()
os.symlink(src, dst)
# 7) Write transformer config (Fun-InP diffusers arch == Fun-Control arch) + our fields.
tdir = out / "transformer"
tdir.mkdir(exist_ok=True)
cfg = json.loads((base / "transformer" / "config.json").read_text())
cfg["in_channels"] = NEW_IN_CHANNELS
cfg["out_channels"] = 16
cfg["track_config"] = TRACK_CONFIG
(tdir / "config.json").write_text(json.dumps(cfg, indent=2))
save_file(conv, str(tdir / "diffusion_pytorch_model.safetensors"), metadata={"format": "pt"})
print(f"[convert] wrote {tdir/'diffusion_pytorch_model.safetensors'} ({len(conv)} keys)")
print(f"[convert] done -> {out}")
if __name__ == "__main__":
main()