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()}