| """Perception encoder β Module A. |
| |
| Converts a raw 64Γ64 grid of 4-bit color indices (0β15) into a compact |
| latent representation using a small from-scratch CNN. |
| |
| The encoder also computes a binary diff mask against the previous frame, |
| which is used by the world model to predict frame changes. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import logging |
|
|
| import numpy as np |
| import torch |
| import torch.nn as nn |
|
|
| logger = logging.getLogger(__name__) |
|
|
| GRID_SIZE = 64 |
| NUM_COLORS = 16 |
| LATENT_DIM_DEFAULT = 256 |
|
|
|
|
| class PerceptionEncoder(nn.Module): |
| """CNN encoder for 64Γ64Γ16 one-hot frames. |
| |
| Architecture (3 conv layers + 1 FC head): |
| Conv2d(16, 32, 3, padding=1) β ReLU β MaxPool2d(2) # 64β32 |
| Conv2d(32, 64, 3, padding=1) β ReLU β MaxPool2d(2) # 32β16 |
| Conv2d(64, 128, 3, padding=1) β ReLU β MaxPool2d(2) # 16β8 |
| Flatten β Linear(128*8*8, latent_dim) |
| |
| No pretrained weights exist for this domain β trained from scratch. |
| |
| Attributes: |
| latent_dim: Output latent vector dimensionality. |
| device: Torch device for inference. |
| """ |
|
|
| def __init__(self, latent_dim: int = LATENT_DIM_DEFAULT, device: str = "cpu") -> None: |
| """Initialize the encoder. |
| |
| Args: |
| latent_dim: Output latent dimension. |
| device: Torch device ("cpu" or "cuda"). |
| """ |
| super().__init__() |
| self.latent_dim = latent_dim |
| self.device = torch.device(device) |
|
|
| self.conv = nn.Sequential( |
| nn.Conv2d(NUM_COLORS, 32, kernel_size=3, padding=1), |
| nn.ReLU(inplace=True), |
| nn.MaxPool2d(2), |
| nn.Conv2d(32, 64, kernel_size=3, padding=1), |
| nn.ReLU(inplace=True), |
| nn.MaxPool2d(2), |
| nn.Conv2d(64, 128, kernel_size=3, padding=1), |
| nn.ReLU(inplace=True), |
| nn.MaxPool2d(2), |
| ) |
| self.fc = nn.Sequential( |
| nn.Flatten(), |
| nn.Linear(128 * 8 * 8, 512), |
| nn.ReLU(inplace=True), |
| nn.Linear(512, latent_dim), |
| ) |
|
|
| self.to(self.device) |
| self.eval() |
| logger.info("PerceptionEncoder initialized (latent_dim=%d, device=%s)", latent_dim, device) |
|
|
| def forward(self, x: torch.Tensor) -> torch.Tensor: |
| """Forward pass: one-hot frame β latent vector. |
| |
| Args: |
| x: Tensor of shape (B, 16, 64, 64) β one-hot encoded frames. |
| |
| Returns: |
| Latent tensor of shape (B, latent_dim). |
| """ |
| features = self.conv(x) |
| latent = self.fc(features) |
| return latent |
|
|
| def encode(self, frame: np.ndarray) -> tuple[np.ndarray, np.ndarray]: |
| """Encode a single frame into a latent vector + diff mask. |
| |
| Args: |
| frame: 64Γ64 numpy array of uint8 color indices (0β15). |
| |
| Returns: |
| Tuple of (latent, diff_mask): |
| - latent: float32 array of shape (latent_dim,). |
| - diff_mask: 64Γ64 bool array β True where frame differs from previous. |
| """ |
| one_hot = self._to_one_hot(frame) |
| with torch.no_grad(): |
| tensor = torch.from_numpy(one_hot).unsqueeze(0).to(self.device) |
| latent = self.forward(tensor).squeeze(0).cpu().numpy() |
|
|
| |
| diff_mask = self._compute_diff(frame) |
|
|
| |
| self._prev_frame = frame.copy() |
|
|
| return latent.astype(np.float32), diff_mask |
|
|
| def _to_one_hot(self, frame: np.ndarray) -> np.ndarray: |
| """Convert a 64Γ64 integer frame to 16Γ64Γ64 one-hot float. |
| |
| Args: |
| frame: 64Γ64 uint8 array with values in [0, 15]. |
| |
| Returns: |
| 16Γ64Γ64 float32 one-hot array. |
| """ |
| one_hot = np.zeros((NUM_COLORS, GRID_SIZE, GRID_SIZE), dtype=np.float32) |
| for c in range(NUM_COLORS): |
| one_hot[c] = (frame == c).astype(np.float32) |
| return one_hot |
|
|
| def _compute_diff(self, frame: np.ndarray) -> np.ndarray: |
| """Compute binary diff mask against the previous frame. |
| |
| Args: |
| frame: Current 64Γ64 frame. |
| |
| Returns: |
| 64Γ64 bool array β True where pixels changed. |
| """ |
| if not hasattr(self, "_prev_frame"): |
| return np.zeros((GRID_SIZE, GRID_SIZE), dtype=bool) |
| return frame != self._prev_frame |
|
|
| def encode_batch(self, frames: np.ndarray) -> np.ndarray: |
| """Encode a batch of frames into latent vectors. |
| |
| Used during offline training of the world model. |
| |
| Args: |
| frames: (B, 64, 64) uint8 array. |
| |
| Returns: |
| (B, latent_dim) float32 array. |
| """ |
| one_hot = np.zeros((len(frames), NUM_COLORS, GRID_SIZE, GRID_SIZE), dtype=np.float32) |
| for i, frame in enumerate(frames): |
| for c in range(NUM_COLORS): |
| one_hot[i, c] = (frame == c).astype(np.float32) |
|
|
| with torch.no_grad(): |
| tensor = torch.from_numpy(one_hot).to(self.device) |
| latents = self.forward(tensor).cpu().numpy() |
|
|
| return latents.astype(np.float32) |
|
|