Spaces:
Running on Zero
Running on Zero
File size: 9,476 Bytes
9368ee7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 | # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""
OmniAvatar Diffusion Forcing model for Stage 1 initialization.
Alternative to ODE-based KD (CausalKDModel). Instead of pre-computing
ODE trajectories from the teacher, this adds Gaussian noise to real data at
inhomogeneous block-wise timesteps and trains the student to denoise with L2 loss.
No teacher model or ODE generation needed.
"""
from __future__ import annotations
import os
from typing import Any, Dict, TYPE_CHECKING, Callable
from functools import partial
import torch
import torch.nn.functional as F
from lipforcing.methods.knowledge_distillation.KD import KDModel
from lipforcing.methods.distribution_matching.causvid import CausVidModel
import lipforcing.utils.logging_utils as logger
if TYPE_CHECKING:
from lipforcing.configs.config import BaseModelConfig as ModelConfig
class OmniAvatarDiffusionForcingModel(KDModel):
"""Diffusion Forcing on real data — alternative to ODE KD for Stage 1.
Adds noise to real data at inhomogeneous block-wise timesteps.
Student denoises -> L2 loss vs clean data. No teacher ODE needed.
Inheritance: OmniAvatarDiffusionForcingModel -> KDModel -> FastGenModel
"""
def __init__(self, config: ModelConfig):
super().__init__(config)
def build_model(self):
"""Build model and optionally load VAE for visual logging."""
super().build_model()
# FastGenModel.build_model (model.py:260) unconditionally does
# `self.net.train().requires_grad_(True)` after instantiating the
# network, which destroys the LoRA freeze that PEFT's
# inject_adapter_in_model set up inside _load_weights. Re-apply
# the freeze here so the optimizer (constructed downstream by
# init_optimizers, which filters on requires_grad) only sees the
# intended trainable subset (LoRA A/B + user-listed unfreeze
# modules). No-op when merge_lora=True or when PEFT injection
# didn't actually run (apply_lora_freeze handles both).
if hasattr(self.net, "apply_lora_freeze"):
self.net.apply_lora_freeze()
vae_path = getattr(self.config, "vae_path", "") or ""
if vae_path and os.path.exists(vae_path):
self._load_vae(vae_path)
def init_optimizers(self):
"""Initialize optimizers, with a defensive LoRA freeze re-apply.
Belt-and-suspenders against any post-build_model code path that
might also reset requires_grad (e.g., FSDP wrap converting params
to DTensors and not preserving requires_grad in some PyTorch
versions). apply_lora_freeze is idempotent.
"""
if hasattr(self.net, "apply_lora_freeze"):
self.net.apply_lora_freeze()
super().init_optimizers()
def _load_vae(self, vae_path: str):
"""Load WanVideoVAE for decoding generated samples in wandb visual logging.
The wandb callback calls model.net.vae.decode(tensor) with a single [B,C,T,H,W] tensor.
WanVideoVAE.decode expects (hidden_states_list, device). We wrap it for compatibility.
"""
from OmniAvatar.models.wan_video_vae import WanVideoVAE
# Load VAE weights — checkpoint keys lack "model." prefix, use converter
raw_vae = WanVideoVAE(z_dim=16)
vae_state = torch.load(vae_path, map_location="cpu", weights_only=False)
# Add "model." prefix to match WanVideoVAE's self.model attribute
if any(k.startswith("encoder.") for k in vae_state):
vae_state = {f"model.{k}": v for k, v in vae_state.items()}
raw_vae.load_state_dict(vae_state)
device_str = f"cuda:{self.device}" if isinstance(self.device, int) else str(self.device)
raw_vae = raw_vae.to(device_str).eval()
# Wrap decode to match wandb callback's expected interface: decode(tensor) -> tensor
class VAEWrapper:
def __init__(self, vae, device):
self._vae = vae
self._device = device
def decode(self, x):
"""Decode [B, C, T, H, W] latent to [B, 3, T*4, H*8, W*8] pixel video."""
with torch.no_grad():
# WanVideoVAE.decode expects list of [C,T,H,W] tensors in float32
return self._vae.decode([xi.float() for xi in x], self._device)
def to(self, *args, **kwargs):
return self
self.net.vae = VAEWrapper(raw_vae, device_str)
logger.info(f"Loaded WanVideoVAE from {vae_path} for visual logging")
# Use CausVidModel's AR sample loop for visualization (chunk-by-chunk with KV cache).
# Without this, FastGenModel._student_sample_loop processes the entire video as one
# bidirectional pass, which doesn't reflect actual AR inference behavior.
_student_sample_loop = CausVidModel._student_sample_loop
def _build_condition(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Build OmniAvatar condition dict from data batch.
Expected shapes (after collation, with batch dim):
text_embeds: [B, 1, 512, 4096] or [B, 512, 4096]
audio_emb: [B, 81, audio_dim]
mask: [B, H, W] or [H, W]
masked_video: [B, 16, T, H, W]
ref_sequence: [B, 16, T, H, W] (optional)
Args:
data: Batch from OmniAvatarDataset.
Returns:
Condition dict for OmniAvatar networks.
"""
for key in ("real", "text_embeds", "audio_emb", "mask", "masked_video"):
assert key in data, f"Missing required key '{key}' in data batch"
real_data = data["real"]
ref_latent = real_data[:, :, :1, :, :] # [B, 16, 1, H, W]
mask = data["mask"]
if mask.dim() == 3:
mask = mask[0]
text_embeds = data["text_embeds"]
if text_embeds.dim() == 4:
assert text_embeds.shape[1] == 1, (
f"text_embeds dim 1 must be 1 for squeeze, got shape {list(text_embeds.shape)}"
)
text_embeds = text_embeds.squeeze(1)
condition = {
"text_embeds": text_embeds,
"audio_emb": data["audio_emb"],
"ref_latent": ref_latent,
"mask": mask,
"masked_video": data["masked_video"],
}
if "ref_sequence" in data:
condition["ref_sequence"] = data["ref_sequence"]
return condition
def _get_outputs(
self,
gen_data: torch.Tensor,
input_student: torch.Tensor = None,
condition: Any = None,
) -> Dict[str, torch.Tensor | Callable]:
has_vae = hasattr(self.net, "vae")
if not has_vae:
logger.debug("No VAE loaded on net — visual logging disabled")
if has_vae and condition is not None:
noise = torch.randn_like(gen_data, dtype=self.precision)
context_noise = getattr(self.config, "context_noise", 0)
gen_rand_func = partial(
CausVidModel.generator_fn,
net=self.net_inference,
noise=noise,
condition=condition,
student_sample_steps=self.config.student_sample_steps,
student_sample_type=self.config.student_sample_type,
t_list=self.config.sample_t_cfg.t_list,
context_noise=context_noise,
precision_amp=self.precision_amp_infer,
)
return {"gen_rand": gen_rand_func, "input_rand": noise, "gen_rand_train": gen_data.detach()}
return {"gen_rand_train": gen_data.detach()}
def single_train_step(
self, data: Dict[str, Any], iteration: int
) -> tuple[dict[str, torch.Tensor], dict[str, torch.Tensor | Callable]]:
"""Single training step using diffusion forcing on real data.
Instead of gathering from pre-computed ODE trajectories (as in CausalKDModel),
this adds Gaussian noise to real data at inhomogeneous block-wise timesteps.
"""
real_data = data["real"] # [B, 16, 21, 64, 64]
condition = self._build_condition(data)
batch_size, num_frames = real_data.shape[0], real_data.shape[2]
chunk_size = self.net.chunk_size
# Sample inhomogeneous block-wise timesteps
t_inhom, _ = self.net.noise_scheduler.sample_t_inhom(
batch_size,
num_frames,
chunk_size,
sample_steps=self.config.student_sample_steps,
t_list=self.config.sample_t_cfg.t_list,
device=self.device,
dtype=real_data.dtype,
) # [B, T]
# Diffusion forcing: add noise to real data at sampled timesteps
eps = torch.randn_like(real_data)
t_inhom_expanded = t_inhom[:, None, :, None, None] # [B, 1, T, 1, 1]
noisy_data = self.net.noise_scheduler.forward_process(real_data, eps, t_inhom_expanded)
# Student denoise
gen_data = self.gen_data_from_net(noisy_data, t_inhom, condition=condition)
# L2 loss
loss = 0.5 * F.mse_loss(gen_data, real_data, reduction="mean")
# Outputs for logging (detached to avoid holding autograd references)
outputs = self._get_outputs(gen_data.detach(), condition=condition)
loss_map = {"total_loss": loss, "recon_loss": loss.detach()}
return loss_map, outputs
|