Files
roboimi/roboimi/vla/agent_act.py
T
2026-07-31 10:11:04 +08:00

215 lines
9.7 KiB
Python

"""ACT agent wrapper compatible with RoboIMI VLA training/eval scripts."""
from __future__ import annotations
from collections import deque
from typing import Dict, Optional, Tuple
import torch
import torch.nn as nn
import torch.nn.functional as F
from roboimi.vla.models.normalization import NormalizationModule
class ACTAgent(nn.Module):
def __init__(
self,
vision_backbone,
head,
action_dim: int,
obs_dim: int,
pred_horizon: int = 16,
obs_horizon: int = 1,
num_cams: int = 3,
camera_names: Optional[Tuple[str, ...]] = None,
dataset_stats=None,
normalization_type: str = "min_max",
num_action_steps: int = 8,
**_: object,
) -> None:
super().__init__()
self.action_dim = int(action_dim)
self.obs_dim = int(obs_dim)
self.pred_horizon = int(pred_horizon)
self.obs_horizon = int(obs_horizon)
self.num_cams = int(num_cams)
self.num_action_steps = int(num_action_steps)
self.vision_encoder = vision_backbone
self.normalization = NormalizationModule(
stats=dataset_stats,
normalization_type=normalization_type,
)
agent_camera_names = tuple(camera_names) if camera_names is not None else None
backbone_camera_names = getattr(self.vision_encoder, "camera_names", None)
backbone_camera_names = tuple(backbone_camera_names) if backbone_camera_names is not None else None
backbone_num_cameras = getattr(self.vision_encoder, "num_cameras", None)
if backbone_num_cameras is not None and int(backbone_num_cameras) != self.num_cams:
raise ValueError(
f"agent.num_cams({self.num_cams}) 与 vision_backbone.num_cameras({backbone_num_cameras}) 不一致"
)
if agent_camera_names is not None and backbone_camera_names is not None and agent_camera_names != backbone_camera_names:
raise ValueError(
f"agent.camera_names({list(agent_camera_names)}) 与 vision_backbone.camera_names({list(backbone_camera_names)}) 不一致"
)
self.camera_names = agent_camera_names if agent_camera_names is not None else backbone_camera_names
if self.camera_names is not None and len(self.camera_names) != self.num_cams:
raise ValueError(f"camera_names 长度({len(self.camera_names)})与 num_cams({self.num_cams})不一致")
if self.camera_names is not None:
self.vision_encoder.camera_names = self.camera_names
self.tokens_per_step = int(getattr(self.vision_encoder, "tokens_per_step", 1))
base_vision_dim = int(getattr(self.vision_encoder, "output_dim"))
self.vision_dim = base_vision_dim if self.tokens_per_step > 1 else base_vision_dim * self.num_cams
if isinstance(head, nn.Module):
self.policy_head = head
else:
self.policy_head = head(
action_dim=self.action_dim,
obs_dim=self.obs_dim,
vision_dim=self.vision_dim,
num_cams=self.num_cams,
pred_horizon=self.pred_horizon,
obs_horizon=self.obs_horizon,
)
# Alias so train_vla.py optimizer grouping can find head groups if added later.
self.noise_pred_net = self.policy_head
self.reset()
def _get_model_device(self) -> torch.device:
return next(self.parameters()).device
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:
camera_names = tuple(sorted(images.keys()))
if len(camera_names) != self.num_cams:
raise ValueError(f"图像条件相机数量({len(camera_names)})与 num_cams({self.num_cams})不一致")
return {cam_name: images[cam_name] for cam_name in camera_names}
missing = [cam_name for cam_name in self.camera_names if cam_name not in images]
if missing:
raise ValueError(f"图像条件缺少必需相机。missing={missing}, expected={list(self.camera_names)}")
return {cam_name: images[cam_name] for cam_name in self.camera_names}
def _build_visual_tokens(self, images: Dict[str, torch.Tensor]) -> torch.Tensor:
ordered_images = self._order_images(images)
visual = self.vision_encoder(ordered_images)
if visual.ndim == 3:
if visual.shape[1] < 1:
raise RuntimeError("视觉特征时间维为空")
return visual[:, -1:, :]
if visual.ndim == 4:
if visual.shape[1] < 1:
raise RuntimeError("视觉特征时间维为空")
return visual[:, -1, :, :]
raise RuntimeError(f"不支持的视觉特征形状: {tuple(visual.shape)}")
@staticmethod
def _current_qpos(states: torch.Tensor) -> torch.Tensor:
if states.ndim == 2:
return states
if states.ndim == 3:
return states[:, -1]
raise ValueError(f"qpos must have shape (B,D) or (B,T,D), got {tuple(states.shape)}")
def compute_loss(self, batch) -> torch.Tensor:
actions = batch["action"]
states = batch["qpos"]
images = batch["images"]
action_is_pad = batch.get("action_is_pad", None)
states = self.normalization.normalize_qpos(states)
actions = self.normalization.normalize_action(actions)
qpos = self._current_qpos(states)
actions = actions[:, : self.pred_horizon]
if action_is_pad is not None:
action_is_pad = action_is_pad[:, : self.pred_horizon].to(torch.bool)
visual_tokens = self._build_visual_tokens(images)
pred_actions, latent_info = self.policy_head(qpos, visual_tokens, actions, action_is_pad)
l1 = F.l1_loss(pred_actions, actions, reduction="none")
if action_is_pad is not None:
mask = (~action_is_pad).unsqueeze(-1).to(l1.dtype)
valid_count = (mask.sum() * l1.shape[-1]).clamp_min(1.0)
l1_loss = (l1 * mask).sum() / valid_count
else:
l1_loss = l1.mean()
kl = latent_info.get("kl")
if kl is None:
kl = torch.zeros(1, device=l1_loss.device, dtype=l1_loss.dtype)
kl_weight = float(latent_info.get("kl_weight", getattr(self.policy_head, "kl_weight", 0.0)))
return l1_loss + kl_weight * kl.squeeze()
@torch.no_grad()
def predict_action(self, images, proprioception):
proprioception = self.normalization.normalize_qpos(proprioception)
qpos = self._current_qpos(proprioception)
visual_tokens = self._build_visual_tokens(images)
actions, _ = self.policy_head(qpos, visual_tokens)
return self.normalization.denormalize_action(actions)
def reset(self):
self._queues = {
"qpos": deque(maxlen=self.obs_horizon),
"images": deque(maxlen=self.obs_horizon),
"action": deque(maxlen=max(1, self.pred_horizon - self.obs_horizon + 1)),
}
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()})
def _prepare_observation_batch(self) -> Dict[str, torch.Tensor]:
qpos_list = list(self._queues["qpos"])
if not qpos_list:
raise ValueError("观测队列为空,请先调用 _populate_queues 添加观测")
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("图像队列为空,请先调用 _populate_queues 添加观测")
while len(images_list) < self.obs_horizon:
images_list.append(images_list[-1])
camera_names = self.camera_names if self.camera_names is not None else tuple(sorted(images_list[0].keys()))
batch_images = {
cam_name: torch.stack([img[cam_name] for img in images_list], dim=0).unsqueeze(0)
for cam_name in camera_names
}
return {"qpos": batch_qpos, "images": batch_images}
@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.obs_horizon - 1
end = start + self.num_action_steps
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()
@torch.no_grad()
def predict_action_chunk(self, batch: Dict[str, torch.Tensor]) -> torch.Tensor:
return self.predict_action(batch["images"], batch["qpos"])
def get_normalization_stats(self):
return self.normalization.get_stats()