Files
hao-ai-lab-FastVideo/scripts/checkpoint_conversion/wan_fun_control_to_trackwan.py
T
shaoxiongduan ac919b1c87 [feat] WanTrack: OpenVid preprocess + Wan2.2 synthetic-gen pipeline, training configs, first-frame saturation fix
- data_pipeline: SAM+CoTracker preprocessing, sharded Slurm launchers, OpenVid download/filter,
  and a data-parallel Wan2.2-T2V-A14B synthetic video generator with the first-frame
  over-brightness fix (over-generate 129 -> drop first 8 -> keep 121).
- examples/train: OpenVid bidir/stage2/stage3 launchers + finetune YAMLs (sparse, merged,
  merged_bias, stage2v2 hi-lr, stage3A/B/C).
- research_log: first-frame saturation root-cause writeup (DiT position-0 latent, not VAE decode).
- fastvideo: trackwan model / track-encoder / preprocessing updates, dcp_to_diffusers export, FA4.
2026-07-14 08:58:22 +00:00

252 lines
13 KiB
Python

# 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 or default per TRACK_CONFIG['zero_init_head'])."""
temporal_conv = nn.Conv3d(ID_DIM, TRACK_CHANNELS, kernel_size=(VAE_T_COMP, 1, 1), stride=(VAE_T_COMP, 1, 1), bias=False)
proj = nn.Conv3d(TRACK_CHANNELS, TRACK_CHANNELS, kernel_size=1, bias=False)
if TRACK_CONFIG.get("zero_init_head", True):
nn.init.zeros_(proj.weight)
return {
"track_encoder.temporal_conv.weight": temporal_conv.weight.detach().clone(),
"track_encoder.proj.weight": proj.weight.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.")
p.add_argument("--id-dim", type=int, default=None,
help="Override track_encoder id_dim (default TRACK_CONFIG value, currently 128).")
p.add_argument("--zero-init-head", action="store_true", default=None,
help="Force zero-init the track_encoder.proj head.")
p.add_argument("--no-zero-init-head", action="store_true", default=None,
help="Force NON-zero (random) init the track_encoder.proj head.")
args = p.parse_args()
global ID_DIM
if args.id_dim is not None:
ID_DIM = int(args.id_dim); TRACK_CONFIG["id_dim"] = ID_DIM
if args.no_zero_init_head: TRACK_CONFIG["zero_init_head"] = False
elif args.zero_init_head: TRACK_CONFIG["zero_init_head"] = True
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()