File size: 9,384 Bytes
e0db531 | 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 | """VIPER visual physics encoder.
Sec. 3.2 of the paper: a frozen MLLM (Qwen3-VL-4B-Instruct) reads the reference
video together with a set of *learnable query tokens* `q_learnable`. The query
tokens are concatenated after the system-prompt tokens and the reference visual
tokens; after the MLLM forward pass their final hidden states are taken as the
reference physics representation ``v_ref``. A 3-layer connector ``g_theta``
projects ``v_ref`` into physics condition tokens ``c_p`` that live in the DiT
context space (5120-d for Wan2.2-A14B).
Only ``q_learnable`` and ``g_theta`` are trainable here -- the MLLM stays frozen
in all three stages.
"""
from __future__ import annotations
import torch
import torch.nn as nn
# The physics-probing system prompt. The paper describes the MLLM as extracting
# "material response, contact interaction, deformation and motion trajectory";
# we phrase the prompt around exactly those axes.
PHYSICS_SYSTEM_PROMPT = (
"You are a physics analyst. Watch the video and reason about its physical "
"process: the material properties of the objects, how they deform under "
"force, the contact and collision interactions between them, and the motion "
"trajectory over time. Summarize the underlying physical dynamics, ignoring "
"the specific appearance, identity and background of the objects."
)
class Connector(nn.Module):
"""3-layer MLP connector g_theta: MLLM hidden dim -> DiT context dim."""
def __init__(self, in_dim: int, out_dim: int, hidden_dim: int | None = None):
super().__init__()
hidden_dim = hidden_dim or out_dim
# NOTE on ordering: the LayerNorm sits *before* the final projection, not
# after. Normalising after a zero-initialised output layer would be a
# degenerate fixed point -- LayerNorm(0) is 0 with a 1/sqrt(eps) Jacobian,
# and it also destroys the scale that makes zero-init a no-op in the first
# place. With the norm first, the output layer starts as an exact no-op
# yet still receives healthy gradients (dL/dW = dL/dy . x^T with x != 0).
self.net = nn.Sequential(
nn.Linear(in_dim, hidden_dim),
nn.GELU(approximate="tanh"),
nn.Linear(hidden_dim, hidden_dim),
nn.GELU(approximate="tanh"),
nn.LayerNorm(hidden_dim),
nn.Linear(hidden_dim, out_dim),
)
# Zero-init the output projection so that at step 0 the physics tokens are
# exactly zero and the frozen DiT reproduces the base model's behaviour --
# stage 1 then starts from "does not disturb the visual prior".
nn.init.zeros_(self.net[-1].weight)
nn.init.zeros_(self.net[-1].bias)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.net(x)
class VisualPhysicsEncoder(nn.Module):
"""Frozen Qwen3-VL + learnable query tokens + connector.
Parameters
----------
mllm:
A ``Qwen3VLForConditionalGeneration`` (or the inner model). Kept frozen.
num_queries:
Number of learnable query tokens ``|q_learnable|``.
out_dim:
DiT context dimension (5120 for Wan2.2-A14B).
"""
def __init__(self, mllm, num_queries: int = 64, out_dim: int = 5120):
super().__init__()
self.mllm = mllm
self.num_queries = num_queries
cfg = mllm.config
text_cfg = getattr(cfg, "text_config", cfg)
self.hidden_size = text_cfg.hidden_size
# q_learnable -- initialised small so the frozen MLLM sees an in-distribution
# perturbation rather than noise.
self.query_tokens = nn.Parameter(
torch.randn(num_queries, self.hidden_size) * 0.02
)
self.connector = Connector(self.hidden_size, out_dim)
# Freeze the MLLM.
self.mllm.requires_grad_(False)
self.mllm.eval()
self._hook_handle = None
self._install_query_hook()
# ------------------------------------------------------------------
# Query injection
# ------------------------------------------------------------------
def _embed_tokens_module(self):
"""Locate the text embedding module across transformers layouts."""
m = self.mllm
for path in (
"model.language_model.embed_tokens", # Qwen3VLModel -> Qwen3VLTextModel
"model.model.embed_tokens",
"language_model.model.embed_tokens",
"model.embed_tokens",
):
obj = m
try:
for part in path.split("."):
obj = getattr(obj, part)
if isinstance(obj, nn.Embedding):
return obj
except AttributeError:
continue
raise RuntimeError("Could not locate embed_tokens on the MLLM")
def _install_query_hook(self):
"""Overwrite the last ``num_queries`` token embeddings with q_learnable.
Injecting at the embedding output (rather than passing ``inputs_embeds``)
keeps all of Qwen3-VL's native vision-token merging and 3D-mRoPE index
computation intact, while still letting gradients reach ``query_tokens``.
The queries are appended at the very end of the prompt, so they occupy
the final ``num_queries`` positions and are never vision positions.
"""
emb = self._embed_tokens_module()
def hook(module, args, output):
if not self._inject:
return output
n = self.num_queries
q = self.query_tokens.to(output.dtype)
out = output.clone()
out[:, -n:, :] = q.unsqueeze(0).expand(output.shape[0], -1, -1)
return out
self._inject = False
self._hook_handle = emb.register_forward_hook(hook)
# ------------------------------------------------------------------
def forward(self, mllm_inputs: dict) -> torch.Tensor:
"""Encode a reference video into physics condition tokens.
``mllm_inputs`` is the processor output for
(system prompt + reference video), already extended with ``num_queries``
placeholder tokens at the end (see ``build_mllm_inputs``).
Returns ``c_p`` of shape ``[B, num_queries, out_dim]``.
"""
self._inject = True
try:
out = self.mllm.model(
**mllm_inputs,
output_hidden_states=True,
return_dict=True,
use_cache=False,
)
finally:
self._inject = False
hidden = out.last_hidden_state # [B, L, H]
v_ref = hidden[:, -self.num_queries:, :] # [B, Nq, H]
return self.connector(v_ref.to(self.connector.net[0].weight.dtype))
# ------------------------------------------------------------------
def trainable_parameters(self):
yield self.query_tokens
yield from self.connector.parameters()
def build_mllm_inputs(processor, video_frames, num_queries: int, device,
system_prompt: str = PHYSICS_SYSTEM_PROMPT,
max_pixels: int = 200704):
"""Build processor inputs for (system prompt + video) + query placeholders.
``video_frames``: uint8 numpy array [T, H, W, 3] (a single reference video).
The learnable queries need real sequence slots so that attention and mRoPE
treat them as ordinary trailing text positions. We append ``num_queries``
copies of a harmless token; the embedding hook then overwrites their vectors.
``max_pixels`` caps the per-frame visual token count. This matters because,
unlike ordinary MLLM inference, we backpropagate through the MLLM to reach
``q_learnable`` -- so the video token count directly drives training memory.
"""
messages = [
{"role": "system", "content": [{"type": "text", "text": system_prompt}]},
{
"role": "user",
"content": [
{"type": "video"},
{"type": "text", "text": "Describe the physical process in this video."},
],
},
]
text = processor.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
# Cap visual tokens on the video processor itself; passing max_pixels through
# processor(**kwargs) is silently ignored by this transformers version.
vp = getattr(processor, "video_processor", None)
if vp is not None and getattr(vp, "max_pixels", None) != max_pixels:
vp.max_pixels = max_pixels
if hasattr(vp, "size") and isinstance(vp.size, dict):
vp.size = {**vp.size, "longest_edge": max_pixels}
inputs = processor(text=[text], videos=[video_frames], return_tensors="pt")
# Append placeholder slots for the learnable queries.
tok = processor.tokenizer
pad_id = tok.pad_token_id if tok.pad_token_id is not None else tok.eos_token_id
b = inputs["input_ids"].shape[0]
pad_block = torch.full((b, num_queries), pad_id, dtype=inputs["input_ids"].dtype)
inputs["input_ids"] = torch.cat([inputs["input_ids"], pad_block], dim=1)
if "attention_mask" in inputs:
inputs["attention_mask"] = torch.cat(
[inputs["attention_mask"], torch.ones_like(pad_block)], dim=1
)
return {k: (v.to(device) if torch.is_tensor(v) else v) for k, v in inputs.items()}
|