from __future__ import annotations import hashlib import json import math import re from pathlib import Path import numpy as np import torch import torch.nn as nn import torch.nn.functional as F VARIANTS = { "small_cnn": {"width": 32, "depth": 3, "chunk": 1, "dropout": 0.05}, "base_cnn": {"width": 64, "depth": 4, "chunk": 1, "dropout": 0.10}, "chunk_cnn": {"width": 48, "depth": 4, "chunk": 8, "dropout": 0.10}, "ensemble_cnn": {"width": 64, "depth": 5, "chunk": 8, "dropout": 0.15}, } class VisionBackbone(nn.Module): def __init__(self, width, depth): super().__init__() layers = [nn.Conv2d(3, width, 5, 2, 2), nn.GroupNorm(max(1, width // 8), width), nn.SiLU()] channels = width for index in range(depth - 1): next_channels = min(width * (2 ** min(index + 1, 2)), 256) layers += [nn.Conv2d(channels, next_channels, 3, 2, 1), nn.GroupNorm(max(1, next_channels // 8), next_channels), nn.SiLU()] channels = next_channels layers += [nn.AdaptiveAvgPool2d((1, 1)), nn.Flatten(), nn.Linear(channels, 384), nn.SiLU()] self.net = nn.Sequential(*layers) def forward(self, images): return self.net(images) class PolicyNet(nn.Module): def __init__(self, config): super().__init__() variant = config["variant"] cfg = VARIANTS[variant] self.chunk = int(cfg["chunk"]) self.vision = VisionBackbone(int(cfg["width"]), int(cfg["depth"])) self.proprio = nn.Sequential(nn.Linear(int(config["proprio_dim"]), 128), nn.LayerNorm(128), nn.SiLU()) self.text = nn.Sequential(nn.Linear(int(config["text_dim"]), 128), nn.LayerNorm(128), nn.SiLU()) self.task = nn.Embedding(int(config["task_count"]), 32) self.difficulty = nn.Embedding(int(config["difficulty_count"]), 16) self.head = nn.Sequential( nn.Linear(384 + 128 + 128 + 32 + 16, 512), nn.LayerNorm(512), nn.SiLU(), nn.Dropout(float(cfg["dropout"])), nn.Linear(512, 256), nn.SiLU(), nn.Linear(256, self.chunk * 7)) def forward(self, images, proprio, text, task_id, difficulty_id): if images.ndim != 4: raise ValueError("image batch must have four dimensions") if images.shape[-1] == 3: images = images.permute(0, 3, 1, 2) images = images.float() if images.max() > 2: images = images / 255.0 images = F.interpolate(images, size=(96, 96), mode="bilinear", align_corners=False) fused = torch.cat([ self.vision(images), self.proprio(proprio.float()), self.text(text.float()), self.task(task_id.clamp(0, self.task.num_embeddings - 1)), self.difficulty(difficulty_id.clamp(0, self.difficulty.num_embeddings - 1)), ], dim=-1) return torch.tanh(self.head(fused)).reshape(-1, self.chunk, 7) def _text_vector(text, dim): result = np.zeros(dim, dtype=np.float32) for token in re.findall(r"[a-z0-9_]+", str(text).lower()): value = int.from_bytes(hashlib.blake2b(token.encode(), digest_size=8).digest(), "little") result[value % dim] += 1.0 if value & 1 else -1.0 norm = float(np.linalg.norm(result)) return result / norm if norm else result def _proprio(obs, config): value = np.asarray(obs.get("proprio", np.zeros(25, dtype=np.float32)), dtype=np.float32).reshape(-1) if value.size == 21: value = np.pad(value, (0, 4)) if value.size != 25: raise ValueError(f"proprio must have 25 values, got {value.size}") step = float(obs.get("step", 0)) horizon = float(obs.get("horizon", 320) or 320) value = np.concatenate([value, np.asarray([step / max(horizon, 1.0)], dtype=np.float32)]) if value.size < int(config["proprio_dim"]): value = np.pad(value, (0, int(config["proprio_dim"]) - value.size)) value = value[: int(config["proprio_dim"])] mean = np.asarray(config["proprio_mean"], dtype=np.float32) std = np.asarray(config["proprio_std"], dtype=np.float32) return ((value - mean) / np.maximum(std, 1e-4)).astype(np.float32) class Policy: def __init__(self, model, config, device): self.model, self.config, self.device = model, config, device self.chunk = int(config["chunk"]) self.last_step = None self.chunks = {} self.last_action = np.zeros(7, dtype=np.float32) self.task_to_id = config["task_to_id"] self.difficulty_to_id = config["difficulty_to_id"] @torch.inference_mode() def act(self, obs): image = np.asarray(obs.get("image", np.zeros((96, 96, 3), dtype=np.uint8))) if image.ndim == 2: image = np.repeat(image[..., None], 3, axis=-1) if image.shape[-1] == 4: image = image[..., :3] if image.shape[-1] != 3: raise ValueError("image must have 3 or 4 channels") step = int(obs.get("step", 0)) if self.last_step is None or step == 0 or step != self.last_step + 1: self.chunks = {} if step == self.last_step: return self.last_action.copy() task = str(obs.get("task", "")) difficulty = str(obs.get("difficulty", "") or "") text = f"task {task} difficulty {difficulty} instruction {obs.get('instruction', '')}" text_feature = _text_vector(text, int(self.config["text_dim"])) proprio = _proprio(obs, self.config) task_id = self.task_to_id.get(task, len(self.task_to_id)) difficulty_id = self.difficulty_to_id.get(difficulty, len(self.difficulty_to_id)) raw = self.model( torch.from_numpy(image).unsqueeze(0).to(self.device), torch.from_numpy(proprio).unsqueeze(0).to(self.device), torch.from_numpy(text_feature).unsqueeze(0).to(self.device), torch.tensor([task_id], device=self.device), torch.tensor([difficulty_id], device=self.device), )[0].float().cpu().numpy() self.chunks[step] = raw candidates, weights = [], [] for start, chunk in list(self.chunks.items()): offset = step - start if 0 <= offset < self.chunk: candidates.append(chunk[offset]) weights.append(math.exp(-0.55 * offset)) else: del self.chunks[start] action = np.average(np.asarray(candidates), axis=0, weights=np.asarray(weights)) action = np.clip(action, -1.0, 1.0).astype(np.float32) if action[6] > 0.12: action[6] = 1.0 elif action[6] < -0.12: action[6] = -1.0 self.last_step, self.last_action = step, action.copy() return action def load_policy(model_dir: str, device: str, dtype: str): root = Path(model_dir) config = json.loads((root / "vla_config.json").read_text()) selected = torch.device(device if str(device).startswith("cuda") and torch.cuda.is_available() else "cpu") model = PolicyNet(config).to(selected) checkpoint = torch.load(root / "model.pt", map_location=selected, weights_only=True) state = checkpoint["state_dict"] if "state_dict" in checkpoint else checkpoint model.load_state_dict(state, strict=True) model.eval() return Policy(model, config, selected)