Files
roboimi/roboimi/vla/agent_smolvla_native.py
T
2026-05-25 23:24:36 +08:00

396 lines
18 KiB
Python

from __future__ import annotations
from collections import deque
from dataclasses import fields
from typing import Dict, Optional, Sequence
import torch
import torch.nn as nn
from roboimi.vla.models.normalization import NormalizationModule
class SmolVLANativeAgent(nn.Module):
"""RoboIMI wrapper for the native SmolVLA flow-matching model.
The wrapper owns RoboIMI-facing concerns only: dataset normalization,
camera ordering, language fallback/tokenization, rollout queues, and action
denormalization. The native SmolVLA model itself is imported lazily so unit
tests can inject fakes without downloading or loading a real VLM.
"""
def __init__(
self,
model: Optional[nn.Module] = None,
tokenizer=None,
action_dim: int = 16,
obs_dim: int = 16,
chunk_size: int = 16,
n_action_steps: int = 8,
obs_horizon: int = 1,
action_chunk_start: int = 0,
num_cams: int = 3,
camera_names: Optional[Sequence[str]] = None,
dataset_stats=None,
normalization_type: str = 'min_max',
task_description: Optional[str] = None,
model_config: Optional[dict] = None,
tokenizer_name: Optional[str] = None,
**kwargs,
) -> None:
super().__init__()
del kwargs
self.action_dim = int(action_dim)
self.obs_dim = int(obs_dim)
self.chunk_size = int(chunk_size)
self.pred_horizon = self.chunk_size
self.n_action_steps = int(n_action_steps)
self.num_action_steps = self.n_action_steps
self.obs_horizon = int(obs_horizon)
self.action_chunk_start = int(action_chunk_start)
self.num_cams = int(num_cams)
self.camera_names = tuple(camera_names) if camera_names is not None else None
if self.camera_names is not None and len(self.camera_names) != self.num_cams:
raise ValueError(f'camera_names length({len(self.camera_names)}) does not match num_cams({self.num_cams})')
if self.n_action_steps < 1:
raise ValueError('n_action_steps must be >= 1')
if self.action_chunk_start < 0:
raise ValueError('action_chunk_start must be >= 0')
if self.action_chunk_start + self.n_action_steps > self.chunk_size:
raise ValueError('action_chunk_start + n_action_steps must be <= chunk_size')
self.normalization = NormalizationModule(stats=dataset_stats, normalization_type=normalization_type)
self.task_description = task_description
self.model_config = dict(model_config or {})
self.max_state_dim = int(self.model_config.get('max_state_dim', self.obs_dim))
self.max_action_dim = int(self.model_config.get('max_action_dim', self.action_dim))
self.resize_imgs_with_padding = self._normalize_resize_shape(
self.model_config.get('resize_imgs_with_padding', self.model_config.get('image_resize_shape', (512, 512)))
)
self.tokenizer_max_length = int(self.model_config.get('tokenizer_max_length', 48))
self.pad_language_to = str(self.model_config.get('pad_language_to', 'longest'))
self.tokenizer_name = tokenizer_name
self.tokenizer = tokenizer if tokenizer is not None else self._build_tokenizer(tokenizer_name)
self.model = model if model is not None else self._build_model()
self.reset()
@staticmethod
def _normalize_resize_shape(shape):
if shape is None:
return None
normalized = tuple(int(v) for v in shape)
if len(normalized) != 2:
raise ValueError(f'resize_imgs_with_padding must contain exactly two values, got {normalized}')
return normalized
def _build_tokenizer(self, tokenizer_name):
if tokenizer_name is None:
tokenizer_name = self.model_config.get('tokenizer_name') or self.model_config.get('vlm_model_name')
if tokenizer_name is None:
raise ImportError('A tokenizer or tokenizer_name/model_config.vlm_model_name is required for SmolVLANativeAgent')
try:
from transformers import AutoTokenizer
except ImportError as exc:
raise ImportError('SmolVLANativeAgent requires transformers to construct the real tokenizer') from exc
return AutoTokenizer.from_pretrained(tokenizer_name)
def _native_config_kwargs(self) -> dict:
from roboimi.vla.models.smolvla.configuration import NativeSmolVLAConfig
cfg_kwargs = dict(self.model_config)
# Backwards-compatible aliases from earlier RoboIMI-facing drafts. The
# native model core intentionally keeps only SmolVLA model fields.
if 'image_resize_shape' in cfg_kwargs and 'resize_imgs_with_padding' not in cfg_kwargs:
cfg_kwargs['resize_imgs_with_padding'] = cfg_kwargs['image_resize_shape']
if 'freeze_vlm' in cfg_kwargs and 'train_expert_only' not in cfg_kwargs:
cfg_kwargs['train_expert_only'] = bool(cfg_kwargs['freeze_vlm'])
for wrapper_only_key in (
'state_dim',
'action_dim',
'tokenizer_name',
'freeze_vlm',
'image_resize_shape',
'num_cameras',
):
cfg_kwargs.pop(wrapper_only_key, None)
cfg_kwargs.setdefault('max_state_dim', self.max_state_dim)
cfg_kwargs.setdefault('max_action_dim', self.max_action_dim)
cfg_kwargs.setdefault('chunk_size', self.chunk_size)
cfg_kwargs.setdefault('n_action_steps', self.n_action_steps)
cfg_kwargs['resize_imgs_with_padding'] = self._normalize_resize_shape(
cfg_kwargs.get('resize_imgs_with_padding', self.resize_imgs_with_padding)
)
allowed_keys = {field.name for field in fields(NativeSmolVLAConfig)}
return {key: value for key, value in cfg_kwargs.items() if key in allowed_keys}
def _build_model(self):
try:
from roboimi.vla.models.smolvla.configuration import NativeSmolVLAConfig
from roboimi.vla.models.smolvla.modeling import VLAFlowMatching
except ImportError as exc:
raise ImportError(
'Native SmolVLA model modules are unavailable. Pass injected model/tokenizer fakes in tests, '
'or add roboimi.vla.models.smolvla.configuration/modeling for real construction.'
) from exc
config = NativeSmolVLAConfig(**self._native_config_kwargs())
return VLAFlowMatching(config=config)
def _get_model_device(self) -> torch.device:
try:
return next(self.model.parameters()).device
except (StopIteration, AttributeError):
return torch.device('cpu')
def _move_to_device(self, data, device: torch.device):
if torch.is_tensor(data):
return data.to(device)
if isinstance(data, dict):
return {k: self._move_to_device(v, device) for k, v in data.items()}
if isinstance(data, list):
return [self._move_to_device(v, device) for v in data]
if isinstance(data, tuple):
return tuple(self._move_to_device(v, device) for v in data)
return data
def _order_images(self, images: Dict[str, torch.Tensor]) -> Dict[str, torch.Tensor]:
if self.camera_names is None:
names = tuple(sorted(images.keys()))
if len(names) != self.num_cams:
raise ValueError(f'image camera count({len(names)}) does not match num_cams({self.num_cams})')
return {name: images[name] for name in names}
missing = [name for name in self.camera_names if name not in images]
if missing:
raise ValueError(f'image batch missing required cameras. missing={missing}, expected={list(self.camera_names)}')
return {name: images[name] for name in self.camera_names}
@staticmethod
def _is_missing_task(item) -> bool:
return item is None or (isinstance(item, str) and item.strip().lower() in {'', 'unknown'})
def _resolve_task(self, task, batch_size: int):
def fallback_or(item):
if self._is_missing_task(item):
if self.task_description is not None:
return self.task_description
if item is None:
return ''
return item
if task is None:
task = self.task_description
if task is None:
return [''] * batch_size
if isinstance(task, str):
return [fallback_or(task)] * batch_size
task = list(task)
if len(task) != batch_size:
raise ValueError(f'task batch size ({len(task)}) must match batch size ({batch_size})')
return [fallback_or(item) for item in task]
def _tokenize_tasks(self, task, batch_size: int, device: torch.device) -> dict:
tasks = [str(text) + '\n' for text in self._resolve_task(task, batch_size)]
old_padding_side = getattr(self.tokenizer, 'padding_side', None)
if old_padding_side is not None:
self.tokenizer.padding_side = 'right'
try:
tokenized = self.tokenizer(
tasks,
padding=self.pad_language_to,
max_length=self.tokenizer_max_length,
truncation=True,
return_tensors='pt',
)
finally:
if old_padding_side is not None:
self.tokenizer.padding_side = old_padding_side
return {
'lang_tokens': tokenized['input_ids'].to(device=device),
'lang_masks': tokenized['attention_mask'].to(device=device, dtype=torch.bool),
}
@staticmethod
def _pad_vector(vector: torch.Tensor, new_dim: int) -> torch.Tensor:
current_dim = vector.shape[-1]
if current_dim == new_dim:
return vector
if current_dim > new_dim:
raise ValueError(f'cannot pad vector with dim {current_dim} to smaller dim {new_dim}')
padded_shape = list(vector.shape)
padded_shape[-1] = int(new_dim)
padded = torch.zeros(*padded_shape, dtype=vector.dtype, device=vector.device)
padded[..., :current_dim] = vector
return padded
@staticmethod
def _resize_with_pad(img: torch.Tensor, width: int, height: int, pad_value: float = 0.0) -> torch.Tensor:
if img.ndim != 4:
raise ValueError(f'expected image tensor shaped (B,C,H,W), got {tuple(img.shape)}')
import torch.nn.functional as F
cur_height, cur_width = img.shape[2:]
ratio = max(cur_width / width, cur_height / height)
resized_height = int(cur_height / ratio)
resized_width = int(cur_width / ratio)
resized = F.interpolate(img, size=(resized_height, resized_width), mode='bilinear', align_corners=False)
pad_height = max(0, int(height - resized_height))
pad_width = max(0, int(width - resized_width))
return F.pad(resized, (pad_width, 0, pad_height, 0), value=pad_value)
def _prepare_native_images(self, images: Dict[str, torch.Tensor]) -> tuple[list[torch.Tensor], list[torch.Tensor]]:
ordered_images = self._order_images(images)
prepared_images: list[torch.Tensor] = []
img_masks: list[torch.Tensor] = []
reference_batch_size: int | None = None
for camera_name, image in ordered_images.items():
if image.ndim == 5:
image = image[:, -1]
elif image.ndim != 4:
raise ValueError(
f'image for camera {camera_name!r} must be shaped (B,T,C,H,W) or (B,C,H,W), got {tuple(image.shape)}'
)
if reference_batch_size is None:
reference_batch_size = int(image.shape[0])
elif int(image.shape[0]) != reference_batch_size:
raise ValueError(f'image batch size mismatch for camera {camera_name!r}')
image = image.contiguous().float().clamp(0.0, 1.0)
if self.resize_imgs_with_padding is not None:
image = self._resize_with_pad(image, *self.resize_imgs_with_padding, pad_value=0.0)
image = image * 2.0 - 1.0
prepared_images.append(image)
img_masks.append(torch.ones(image.shape[0], dtype=torch.bool, device=image.device))
return prepared_images, img_masks
def _prepare_native_state(self, states: torch.Tensor) -> torch.Tensor:
states = self.normalization.normalize_qpos(states)
if states.ndim > 2:
states = states[:, -1, :]
return self._pad_vector(states.float(), self.max_state_dim)
def _prepare_native_actions(self, actions: torch.Tensor) -> torch.Tensor:
actions = self.normalization.normalize_action(actions)
return self._pad_vector(actions.float(), self.max_action_dim)
def _prepare_model_inputs(self, batch: Dict[str, torch.Tensor], include_actions: bool = False) -> dict:
states = batch['qpos']
batch_size = states.shape[0]
device = states.device
images, img_masks = self._prepare_native_images(batch['images'])
inputs = {
'images': images,
'img_masks': img_masks,
'state': self._prepare_native_state(states),
}
inputs.update(self._tokenize_tasks(batch.get('task', None), batch_size, device))
if include_actions:
inputs['actions'] = self._prepare_native_actions(batch['action'])
return inputs
def _reduce_native_losses(self, losses: torch.Tensor, action_is_pad: torch.Tensor | None = None) -> torch.Tensor:
if losses.ndim == 0:
return losses
if losses.shape[-1] < self.action_dim:
raise RuntimeError(f'loss action dim mismatch: got {losses.shape[-1]}, expected at least {self.action_dim}')
losses = losses[..., : self.action_dim]
if action_is_pad is None:
return losses.mean()
if losses.ndim != 3:
raise RuntimeError(f'action padding mask requires per-element losses shaped (B,H,A), got {tuple(losses.shape)}')
if tuple(action_is_pad.shape) != tuple(losses.shape[:2]):
raise RuntimeError(
f'action_is_pad shape {tuple(action_is_pad.shape)} does not match loss prefix {tuple(losses.shape[:2])}'
)
mask = (~action_is_pad).to(device=losses.device, dtype=losses.dtype).unsqueeze(-1)
denom = (mask.sum() * losses.shape[-1]).clamp_min(1.0)
return (losses * mask).sum() / denom
def compute_loss(self, batch):
inputs = self._prepare_model_inputs(batch, include_actions=True)
action_is_pad = batch.get('action_is_pad', None)
output = self.model(**inputs)
if isinstance(output, dict):
if 'loss' not in output:
raise RuntimeError('SmolVLA model forward returned a dict without loss')
return output['loss']
if torch.is_tensor(output):
return self._reduce_native_losses(output, action_is_pad=action_is_pad)
if hasattr(output, 'loss'):
return output.loss
raise RuntimeError(f'Unsupported SmolVLA forward output type: {type(output)!r}')
@torch.no_grad()
def predict_action_chunk(self, batch: Dict[str, torch.Tensor]) -> torch.Tensor:
inputs = self._prepare_model_inputs(batch, include_actions=False)
if not hasattr(self.model, 'sample_actions'):
raise RuntimeError('SmolVLA model must implement sample_actions for inference')
actions = self.model.sample_actions(**inputs)
if not torch.is_tensor(actions) or actions.ndim != 3:
raise RuntimeError(f'sample_actions must return (B,H,A), got {type(actions)!r} {getattr(actions, "shape", None)}')
if actions.shape[-1] < self.action_dim:
raise RuntimeError(f'action dim mismatch: got {actions.shape[-1]}, expected at least {self.action_dim}')
actions = actions[..., : self.action_dim]
return self.normalization.denormalize_action(actions)
def reset(self):
self._queues = {
'qpos': deque(maxlen=self.obs_horizon),
'images': deque(maxlen=self.obs_horizon),
'task': deque(maxlen=self.obs_horizon),
'action': deque(maxlen=self.n_action_steps),
}
def _populate_queues(self, observation: Dict[str, torch.Tensor]) -> None:
if 'qpos' in observation:
self._queues['qpos'].append(observation['qpos'].clone())
if 'images' in observation:
ordered_images = self._order_images(observation['images'])
self._queues['images'].append({k: v.clone() for k, v in ordered_images.items()})
if 'task' in observation:
self._queues['task'].append(observation['task'])
def _prepare_observation_batch(self) -> Dict[str, torch.Tensor]:
qpos_list = list(self._queues['qpos'])
if not qpos_list:
raise ValueError('observation queue is empty.')
while len(qpos_list) < self.obs_horizon:
qpos_list.append(qpos_list[-1])
batch_qpos = torch.stack(qpos_list, dim=0).unsqueeze(0)
images_list = list(self._queues['images'])
if not images_list:
raise ValueError('image queue is empty.')
while len(images_list) < self.obs_horizon:
images_list.append(images_list[-1])
names = self.camera_names if self.camera_names is not None else tuple(sorted(images_list[0].keys()))
batch_images = {name: torch.stack([item[name] for item in images_list], dim=0).unsqueeze(0) for name in names}
batch = {'qpos': batch_qpos, 'images': batch_images}
if self._queues['task']:
batch['task'] = [list(self._queues['task'])[-1]]
elif self.task_description is not None:
batch['task'] = [self.task_description]
return batch
@torch.no_grad()
def select_action(self, observation: Dict[str, torch.Tensor]) -> torch.Tensor:
device = self._get_model_device()
observation = self._move_to_device(observation, device)
self._populate_queues(observation)
if len(self._queues['action']) == 0:
batch = self._prepare_observation_batch()
actions = self.predict_action_chunk(batch)
start = self.action_chunk_start
end = start + self.n_action_steps
if actions.shape[1] < end:
raise RuntimeError(f'action chunk too short: got {actions.shape[1]}, need at least {end}')
executable_actions = actions[:, start:end]
for i in range(executable_actions.shape[1]):
self._queues['action'].append(executable_actions[:, i].squeeze(0))
return self._queues['action'].popleft()
def get_normalization_stats(self):
return self.normalization.get_stats()