| """Latent-to-pixel decoder — a diagnostic, never part of the control loop.
|
|
|
| Reimplements the decoder described in LeWM appendix D ("Decoder (Visualization
|
| Only)", arXiv:2603.19312). **It is not shipped with the model**: the released
|
| ``quentinll/lewm-pusht`` checkpoint holds 303 tensors under
|
| ``encoder/predictor/projector/pred_proj/action_encoder`` and nothing decoder
|
| shaped, and neither ``stable-worldmodel`` nor ``lucas-maes/le-wm`` contains the
|
| code. So it has to be trained here — see ``scripts/train_decoder.py``.
|
|
|
| The paper's description, restated as shapes::
|
|
|
| z (B, 192) one latent vector, the whole input
|
| -> to_hidden (B, 1, W) memory: keys/values
|
| queries (B, P, W) P learned tokens, one per output patch
|
| -> depth x [cross-attn(queries <- memory); residual MLP]
|
| -> to_pixels (B, P, p*p*3)
|
| -> unpatchify (B, 3, 224, 224)
|
|
|
| ``P = (image_size / patch_size)^2``. The paper's worked example is
|
| ``(224/16)^2 = 196``. That 16 is the **decoder's own output tiling** and is
|
| unrelated to the encoder's patch size (which is 14 for this checkpoint): the
|
| decoder never sees patch tokens, only the single pooled vector, so it is free
|
| to carve its output image however it likes.
|
|
|
| ### One thing worth knowing before you read the attention maps
|
|
|
| With ``memory_tokens=1`` — exactly what the paper describes — the cross
|
| attention has a single key. ``softmax`` over one element is identically 1, so
|
| every query gets the same value vector and the layer reduces to
|
|
|
| x <- x + W_o V(z) (the same increment added to all P queries)
|
|
|
| i.e. FiLM-style global conditioning, not a spatial lookup. That is fine for the
|
| job (all spatial structure lives in the learned queries), but attention weights
|
| carry no information and plotting them would be meaningless. Set
|
| ``memory_tokens > 1`` to expand ``z`` into several distinct memory slots if you
|
| want attention that actually selects.
|
| """
|
|
|
| import torch
|
| from torch import nn
|
|
|
|
|
| class CrossAttentionBlock(nn.Module):
|
| """Pre-norm cross-attention to a fixed memory, then a residual MLP."""
|
|
|
| def __init__(self, dim, heads=6, mlp_ratio=4.0, dropout=0.0):
|
| super().__init__()
|
| self.norm_q = nn.LayerNorm(dim)
|
| self.norm_kv = nn.LayerNorm(dim)
|
| self.attn = nn.MultiheadAttention(
|
| dim, heads, dropout=dropout, batch_first=True
|
| )
|
| self.norm_mlp = nn.LayerNorm(dim)
|
| hidden = int(dim * mlp_ratio)
|
| self.mlp = nn.Sequential(
|
| nn.Linear(dim, hidden),
|
| nn.GELU(),
|
| nn.Dropout(dropout),
|
| nn.Linear(hidden, dim),
|
| nn.Dropout(dropout),
|
| )
|
|
|
| def forward(self, x, memory):
|
| q = self.norm_q(x)
|
| kv = self.norm_kv(memory)
|
| x = x + self.attn(q, kv, kv, need_weights=False)[0]
|
| return x + self.mlp(self.norm_mlp(x))
|
|
|
|
|
| class LatentDecoder(nn.Module):
|
| """Decode one latent vector into a 224x224 RGB image.
|
|
|
| Args:
|
| latent_dim: Width of the latent being decoded (192 here).
|
| hidden_dim: Decoder working width.
|
| image_size / patch_size: Output tiling; ``P = (image_size/patch_size)^2``
|
| learned query tokens, each emitting one ``patch_size^2 * 3`` patch.
|
| depth: Number of cross-attention + MLP blocks.
|
| heads: Attention heads.
|
| memory_tokens: Slots the latent is expanded into. 1 reproduces the
|
| paper (and makes attention degenerate — see the module docstring).
|
| out_range: ``'unit'`` clamps nothing and expects targets in ``[0, 1]``
|
| (the paper's "linearly projected to pixels"); ``'sigmoid'`` bounds
|
| the output instead. Linear is the faithful default.
|
| """
|
|
|
| def __init__(
|
| self,
|
| latent_dim=192,
|
| hidden_dim=384,
|
| image_size=224,
|
| patch_size=16,
|
| depth=4,
|
| heads=6,
|
| mlp_ratio=4.0,
|
| dropout=0.0,
|
| memory_tokens=1,
|
| out_range='unit',
|
| ):
|
| super().__init__()
|
| assert image_size % patch_size == 0, (
|
| f'image_size {image_size} not divisible by patch_size {patch_size}'
|
| )
|
| self.image_size = image_size
|
| self.patch_size = patch_size
|
| self.grid = image_size // patch_size
|
| self.num_patches = self.grid ** 2
|
| self.memory_tokens = memory_tokens
|
| self.out_range = out_range
|
|
|
|
|
|
|
| self.to_hidden = nn.Linear(latent_dim, hidden_dim * memory_tokens)
|
|
|
|
|
|
|
| self.queries = nn.Parameter(
|
| torch.randn(1, self.num_patches, hidden_dim) * 0.02
|
| )
|
|
|
| self.blocks = nn.ModuleList(
|
| CrossAttentionBlock(hidden_dim, heads, mlp_ratio, dropout)
|
| for _ in range(depth)
|
| )
|
| self.norm = nn.LayerNorm(hidden_dim)
|
| self.to_pixels = nn.Linear(hidden_dim, patch_size * patch_size * 3)
|
|
|
| @torch.no_grad()
|
| def init_output_at(self, mean_pixel):
|
| """Start the decoder at "always predict the mean colour".
|
|
|
| Default init emits roughly zero-mean patches against targets in
|
| ``[0, 1]``, so step 0 sits ~300x worse than simply predicting the mean
|
| image and the first few hundred steps are spent rediscovering the
|
| background. Zeroing the output weight and parking the bias on the mean
|
| colour starts training at that baseline instead, so every subsequent
|
| step is spent on structure. Gradients are unaffected — the weight is
|
| zero, not frozen.
|
|
|
| Args:
|
| mean_pixel: Per-channel mean in ``[0, 1]``, shape ``(3,)``.
|
| """
|
| mean_pixel = torch.as_tensor(mean_pixel, dtype=torch.float32).view(3)
|
| patch = mean_pixel.repeat(self.patch_size * self.patch_size)
|
| self.to_pixels.weight.zero_()
|
| self.to_pixels.bias.copy_(patch)
|
|
|
| def unpatchify(self, patches):
|
| """``(B, P, p*p*3)`` -> ``(B, 3, H, W)``, row-major over the grid."""
|
| B = patches.size(0)
|
| g, p = self.grid, self.patch_size
|
| x = patches.view(B, g, g, p, p, 3)
|
| x = x.permute(0, 5, 1, 3, 2, 4)
|
| return x.reshape(B, 3, g * p, g * p)
|
|
|
| def patchify(self, images):
|
| """``(B, 3, H, W)`` -> ``(B, P, p*p*3)``; inverse of :meth:`unpatchify`."""
|
| B = images.size(0)
|
| g, p = self.grid, self.patch_size
|
| x = images.view(B, 3, g, p, g, p)
|
| x = x.permute(0, 2, 4, 3, 5, 1)
|
| return x.reshape(B, self.num_patches, p * p * 3)
|
|
|
| def forward(self, z):
|
| """Decode ``(B, D)`` or ``(B, T, D)`` latents to images.
|
|
|
| A ``(B, T, D)`` input returns ``(B, T, 3, H, W)``, which is what you
|
| want for a plan rollout: one frame per horizon step.
|
| """
|
| squeeze_time = z.dim() == 2
|
| if squeeze_time:
|
| z = z.unsqueeze(1)
|
| B, T, _ = z.shape
|
| z = z.reshape(B * T, -1)
|
|
|
| memory = self.to_hidden(z).view(B * T, self.memory_tokens, -1)
|
| x = self.queries.expand(B * T, -1, -1)
|
| for block in self.blocks:
|
| x = block(x, memory)
|
|
|
| images = self.unpatchify(self.to_pixels(self.norm(x)))
|
| if self.out_range == 'sigmoid':
|
| images = torch.sigmoid(images)
|
|
|
| images = images.view(B, T, 3, self.image_size, self.image_size)
|
| return images.squeeze(1) if squeeze_time else images
|
|
|
|
|
| def reconstruction_loss(pred, target):
|
| """Plain pixel MSE.
|
|
|
| The paper never writes the reconstruction objective down — it only says a
|
| decoder was "trained to reconstruct pixel observations" and, in the
|
| appendix G ablation, that adding "a reconstruction loss" to LeWM training
|
| *hurt* control (PushT 96.0 -> 86.0 SR). MSE is the default reading and the
|
| one used here; nothing downstream depends on the choice, since this decoder
|
| never touches the world model's gradients.
|
| """
|
| return torch.nn.functional.mse_loss(pred, target)
|
|
|
|
|
| def load_decoder(path, device='cpu'):
|
| """Rebuild a decoder from a training checkpoint."""
|
| ckpt = torch.load(path, map_location=device, weights_only=False)
|
| decoder = LatentDecoder(**ckpt['config'])
|
| decoder.load_state_dict(ckpt['state_dict'])
|
| return decoder.to(device).eval(), ckpt
|
|
|