Files
hao-ai-lab-FastVideo/scripts/checkpoint_conversion/wan_to_diffusers.py
T
SolitaryThinker d965f578c9 t2v to i2v finetune
checkpoint ode

checkpoint

fix t2v to i2v

lint

chekpt

checkpoint

hacked but working

ode_init scripts

WIP fixing time embedding

WIP fixing time embedding

checkpoint

update

fix

revert

revert

revert

update

update

visualize
2025-09-20 04:57:52 +00:00

414 lines
17 KiB
Python

# pyright: reportMissingImports=false
from safetensors.torch import save_file, load_file as safe_load_file
import argparse
import os
import re
import sys
from collections import OrderedDict
from typing import Any, Dict, Mapping, Tuple
import torch
try:
from huggingface_hub import save_torch_state_dict, load_state_dict_from_file # type: ignore
except Exception:
save_torch_state_dict = None # type: ignore[assignment]
load_state_dict_from_file = None # type: ignore[assignment]
_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",
}
# The following mapping has an extra 'patch_embedding' field and also contains
# the 'model' prefixes
_self_forcing_to_diffusers_param_names_mapping: dict = {
r"^model.patch_embedding\.(.*)$":
r"patch_embedding.\1",
r"^model.text_embedding\.0\.(.*)$":
r"condition_embedder.text_embedder.linear_1.\1",
r"^model.text_embedding\.2\.(.*)$":
r"condition_embedder.text_embedder.linear_2.\1",
r"^model.time_embedding\.0\.(.*)$":
r"condition_embedder.time_embedder.linear_1.\1",
r"^model.time_embedding\.2\.(.*)$":
r"condition_embedder.time_embedder.linear_2.\1",
r"^model.time_projection\.1\.(.*)$":
r"condition_embedder.time_proj.\1",
r"^model.img_emb\.proj\.0\.(.*)$":
r"condition_embedder.image_embedder.norm1.\1",
r"^model.img_emb\.proj\.1\.(.*)$":
r"condition_embedder.image_embedder.ff.net.0.proj.\1",
r"^model.img_emb\.proj\.3\.(.*)$":
r"condition_embedder.image_embedder.ff.net.2.\1",
r"^model.img_emb\.proj\.4\.(.*)$":
r"condition_embedder.image_embedder.norm2.\1",
r"^model.head\.modulation":
r"scale_shift_table",
r"^model.head\.head\.(.*)$":
r"proj_out.\1",
r"^model.blocks\.(\d+)\.self_attn\.q\.(.*)$":
r"blocks.\1.attn1.to_q.\2",
r"^model.blocks\.(\d+)\.self_attn\.k\.(.*)$":
r"blocks.\1.attn1.to_k.\2",
r"^model.blocks\.(\d+)\.self_attn\.v\.(.*)$":
r"blocks.\1.attn1.to_v.\2",
r"^model.blocks\.(\d+)\.self_attn\.o\.(.*)$":
r"blocks.\1.attn1.to_out.0.\2",
r"^model.blocks\.(\d+)\.self_attn\.norm_q\.(.*)$":
r"blocks.\1.attn1.norm_q.\2",
r"^model.blocks\.(\d+)\.self_attn\.norm_k\.(.*)$":
r"blocks.\1.attn1.norm_k.\2",
r"^model.blocks\.(\d+)\.cross_attn\.q\.(.*)$":
r"blocks.\1.attn2.to_q.\2",
r"^model.blocks\.(\d+)\.cross_attn\.k\.(.*)$":
r"blocks.\1.attn2.to_k.\2",
r"^model.blocks\.(\d+)\.cross_attn\.k_img\.(.*)$":
r"blocks.\1.attn2.add_k_proj.\2",
r"^model.blocks\.(\d+)\.cross_attn\.v\.(.*)$":
r"blocks.\1.attn2.to_v.\2",
r"^model.blocks\.(\d+)\.cross_attn\.v_img\.(.*)$":
r"blocks.\1.attn2.add_v_proj.\2",
r"^model.blocks\.(\d+)\.cross_attn\.o\.(.*)$":
r"blocks.\1.attn2.to_out.0.\2",
r"^model.blocks\.(\d+)\.cross_attn\.norm_q\.(.*)$":
r"blocks.\1.attn2.norm_q.\2",
r"^model.blocks\.(\d+)\.cross_attn\.norm_k\.(.*)$":
r"blocks.\1.attn2.norm_k.\2",
r"^model.blocks\.(\d+)\.cross_attn\.norm_k_img\.(.*)$":
r"blocks.\1.attn2.norm_added_k.\2",
r"^model.blocks\.(\d+)\.ffn\.0\.(.*)$":
r"blocks.\1.ffn.net.0.proj.\2",
r"^model.blocks\.(\d+)\.ffn\.2\.(.*)$":
r"blocks.\1.ffn.net.2.\2",
r"^model.blocks\.(\d+)\.modulation":
r"blocks.\1.scale_shift_table",
r"^model.blocks\.(\d+)\.norm3\.(.*)$":
r"blocks.\1.norm2.\2",
}
def _replacement_to_regex_template(replacement: str) -> Tuple[str, int]:
r"""
Convert a replacement template like "blocks.\1.attn2.to_q.\2" into a regex pattern
that can be used to match in the reverse direction: "^blocks\.(.*)\.attn2\.to_q\.(.*)$".
Returns the regex template and the number of capture groups.
"""
# First, protect placeholders \1..\9
placeholder_tokens: Dict[str, str] = {}
group_count = 0
def _token_for(idx: int) -> str:
return f"__CAP_{idx}__"
out = replacement
for i in range(1, 10):
token = _token_for(i)
if f"\\{i}" in out:
out = out.replace(f"\\{i}", token)
placeholder_tokens[token] = f"\\{i}"
group_count = max(group_count, i)
# Escape all regex meta in the literal parts
out = re.escape(out)
# Restore placeholders as (.*)
for token in placeholder_tokens.keys():
out = out.replace(re.escape(token), "(.*)")
return out, group_count
def invert_mapping(forward_mapping: Mapping[str, str]) -> OrderedDict:
"""Create a reverse regex mapping by inverting pattern→replacement pairs.
- Maintains order from the forward mapping
- If the forward pattern is anchored with '$', the reverse is anchored as well
- If forward pattern is prefix-only (no '$'), reverse is also prefix-only
"""
reversed_mapping: "OrderedDict[str, str]" = OrderedDict()
for pattern, replacement in forward_mapping.items():
# Build reverse pattern from replacement template
reverse_pat_core, _ = _replacement_to_regex_template(replacement)
# Respect anchoring: keep '^' always; add '$' only if original had it
anchored_end = pattern.endswith('$')
reverse_pattern = f"^{reverse_pat_core}" + ("$" if anchored_end else "")
# Reverse replacement must be a literal template with backrefs (\1, \2, ...)
reverse_replacement = _pattern_to_replacement_template(pattern)
reversed_mapping[reverse_pattern] = reverse_replacement
return reversed_mapping
def _pattern_to_replacement_template(pattern: str) -> str:
r"""
Convert a regex pattern like "^model.blocks\.(\d+)\.self_attn\.q\.(.*)$" into a replacement
template suitable for re.sub, e.g., "model.blocks.\1.self_attn.q.\2".
Only supports simple capturing groups of the form (.*) or (\d+), which
matches the patterns used in the forward mapping.
"""
# strip anchors
core = pattern
if core.startswith('^'):
core = core[1:]
if core.endswith('$'):
core = core[:-1]
# replace groups (.*) or (\d+) with backref tokens in increasing order
group_index = 0
def repl(_m: "re.Match[str]") -> str:
nonlocal group_index
group_index += 1
return f"\\{group_index}"
core = re.sub(r"\((?:\.\*|\\d\+)\)", repl, core)
# unescape literal dots
core = core.replace(r"\.", ".")
return core
def select_inner_state_dict(loaded: Mapping[str, Any], key: str = "") -> Tuple[Mapping[str, Any], str]:
if key:
if key not in loaded:
raise KeyError(f"Key '{key}' not found in loaded object. Available keys: {list(loaded.keys())[:20]}")
return loaded[key], key
# If looks like a state dict (all tensors)
if len(loaded) > 0 and all(torch.is_tensor(v) for v in loaded.values()):
return loaded, "<root>"
# Common containers
for candidate in ("state_dict", "generator_ema", "model", "ema", "module"):
if candidate in loaded and isinstance(loaded[candidate], Mapping):
inner = loaded[candidate]
if len(inner) > 0 and all(torch.is_tensor(v) for v in inner.values()):
return inner, candidate
# Fallback: first tensor-dict value
for v in loaded.values():
if isinstance(v, Mapping) and len(v) > 0 and all(torch.is_tensor(t) for t in v.values()):
return v, "<auto>"
raise ValueError("Could not locate a state_dict (mapping of tensor parameters) in the loaded file.")
def convert_state_dict(state_dict: Mapping[str, torch.Tensor],
mapping: Mapping[str, str],
*,
strict: bool = True,
add_norm_added_q_dummy: bool = False) -> Tuple[OrderedDict, Dict[str, int]]:
new_state_dict: "OrderedDict[str, torch.Tensor]" = OrderedDict()
matched_count = 0
unmatched_count = 0
dummy_added = 0
examples = [] # type: ignore[var-annotated]
for k, v in state_dict.items():
new_key = None
for pattern, replacement in mapping.items():
if re.match(pattern, k):
new_key = re.sub(pattern, replacement, k)
break
if new_key is None:
if strict:
raise ValueError(f"No mapping rule matched for key: {k}")
else:
new_key = k # keep original
unmatched_count += 1
else:
matched_count += 1
new_state_dict[new_key] = v
if len(examples) < 5:
examples.append((k, new_key))
if add_norm_added_q_dummy and "norm_added_k" in new_key:
dummy_key = new_key.replace("norm_added_k", "norm_added_q")
dummy_value = torch.zeros_like(v)
new_state_dict[dummy_key] = dummy_value
dummy_added += 1
stats = {"matched": matched_count, "unmatched": unmatched_count, "dummy_added": dummy_added}
# store examples count-wise in stats by encoding as counts in print time (examples returned separately not typed)
new_state_dict.__dict__["_examples"] = examples # lightweight attach for printing
return new_state_dict, stats
def save_output(new_state_dict: Mapping[str, torch.Tensor],
output: str,
*,
shard: bool = True,
max_shard_size: str = "10GB",
wrapper_key: str = "",
force_pt: bool = False) -> None:
if force_pt:
out_path = coerce_pt_output_path(output)
obj: Dict[str, Any]
if wrapper_key:
obj = {wrapper_key: OrderedDict(new_state_dict)}
else:
# Save raw state_dict mapping
obj = OrderedDict(new_state_dict) # type: ignore[assignment]
torch.save(obj, out_path)
return
if shard or output.endswith('/') or os.path.isdir(output):
if save_torch_state_dict is None:
raise RuntimeError("Saving shards requires 'huggingface_hub'. Install it or use --single-file.")
out_dir = output if output.endswith('/') else output + '/'
os.makedirs(out_dir, exist_ok=True)
save_torch_state_dict(OrderedDict(new_state_dict), out_dir, max_shard_size=max_shard_size)
else:
# Save a single safetensors file
save_file(OrderedDict(new_state_dict), output)
def coerce_pt_output_path(output: str) -> str:
"""Ensure output path is a .pt/.pth/.bin file. If a directory or unknown ext, coerce to .pt."""
if output.endswith('/') or os.path.isdir(output):
os.makedirs(output, exist_ok=True)
return os.path.join(output, 'converted_wan.pt')
lower = output.lower()
if lower.endswith('.pt') or lower.endswith('.pth') or lower.endswith('.bin'):
return output
return output + '.pt'
def load_checkpoint(input_path: str) -> Mapping[str, Any]:
if load_state_dict_from_file is not None:
return load_state_dict_from_file(input_path)
# Fallbacks by extension
lower = input_path.lower()
if lower.endswith('.safetensors'):
return safe_load_file(input_path)
# torch serialized
obj = torch.load(input_path, map_location='cpu')
if isinstance(obj, Mapping):
return obj
raise TypeError("Unsupported checkpoint format without huggingface_hub. Provide a mapping-like object.")
def parse_args(argv=None):
p = argparse.ArgumentParser(description="Convert WAN <-> Diffusers state_dict key names.")
p.add_argument("--input", "-i", required=True, help="Path to input checkpoint file (.pt/.bin/.safetensors)")
p.add_argument("--output", "-o", required=True, help="Output directory (for shards) or .safetensors file")
p.add_argument("--direction", "-d", choices=["wan-to-diffusers", "diffusers-to-wan"], default="wan-to-diffusers",
help="Conversion direction")
p.add_argument("--inner-key", "-k", default="", help="WAN->Diffusers: unwrap this key. Diffusers->WAN: wrap output under this key.")
p.add_argument("--max-shard-size", default="10GB", help="Shard size when saving to a directory")
p.add_argument("--keep-unmatched", action="store_true", help="Keep keys with no mapping instead of failing")
p.add_argument("--single-file", action="store_true", help="Save a single .safetensors file instead of shards")
return p.parse_args(argv)
def main(argv=None):
args = parse_args(argv)
print(f"[conversion] Direction: {args.direction}")
print(f"[conversion] Input: {args.input}")
save_mode = "torch .pt (forced)" if args.direction == "diffusers-to-wan" else ("single safetensors" if args.single_file else f"sharded (max_shard_size={args.max_shard_size})")
print(f"[conversion] Output: {args.output} [{save_mode}]")
loaded = load_checkpoint(args.input)
if not isinstance(loaded, Mapping):
raise TypeError("Loaded checkpoint is not a mapping.")
# Behavior of --inner-key differs by direction
if args.direction == "wan-to-diffusers":
inner, inner_source = select_inner_state_dict(loaded, key=args.inner_key)
print(f"[conversion] Using inner state_dict: {inner_source}")
else:
inner, inner_source = select_inner_state_dict(loaded, key="") # do not unwrap; wrap later if -k is provided
print(f"[conversion] Using inner state_dict: {inner_source} (ignoring --inner-key for unwrap; will wrap on save)")
print(f"[conversion] Parameters found: {len(inner)}")
if args.direction == "wan-to-diffusers":
mapping = _self_forcing_to_diffusers_param_names_mapping
add_dummy = True
else:
mapping = invert_mapping(_self_forcing_to_diffusers_param_names_mapping)
add_dummy = False
print(f"[conversion] Mapping rules: {len(mapping)}")
new_state, stats = convert_state_dict(inner, mapping, strict=not args.keep_unmatched, add_norm_added_q_dummy=add_dummy)
examples = getattr(new_state, "_examples", [])
if examples:
print("[conversion] Sample key mappings:")
for old_k, new_k in examples[:5]:
print(f" - {old_k} -> {new_k}")
print(f"[conversion] Converted parameters: {len(new_state)} (matched={stats['matched']}, unmatched_kept={stats['unmatched']})")
if add_dummy:
print(f"[conversion] Added dummy norm_added_q tensors: {stats['dummy_added']}")
print("[conversion] Saving...")
wrapper_key = args.inner_key if (args.direction == "diffusers-to-wan" and args.inner_key) else ""
if args.direction == "diffusers-to-wan":
if wrapper_key:
print(f"[conversion] Wrapping output under key: {wrapper_key}")
out_path = coerce_pt_output_path(args.output)
print(f"[conversion] Final output path: {out_path}")
save_output(new_state, out_path, shard=False, max_shard_size=args.max_shard_size, wrapper_key=wrapper_key, force_pt=True)
else:
save_output(new_state, args.output, shard=not args.single_file, max_shard_size=args.max_shard_size, wrapper_key=wrapper_key)
print("[conversion] Done.")
if __name__ == "__main__":
try:
main()
except Exception as e:
print(f"[conversion] Error: {e}", file=sys.stderr)
sys.exit(1)