Signed-off-by: Zhengyuan Su <su.zhengyuan@u.nus.edu> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
435 lines
20 KiB
Python
435 lines
20 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import numpy as np
|
|
import torch
|
|
from transformers import AutoModel, AutoProcessor
|
|
|
|
from vllm_omni.diffusion.models.gr00t.dataio.embodiment_tags import FINETUNE_ONLY_TAGS, POSTTRAIN_TAGS, EmbodimentTag
|
|
from vllm_omni.diffusion.models.gr00t.dataio.types import MessageType, VLAStepData
|
|
from vllm_omni.diffusion.models.gr00t.modeling.gr00t_n1d7 import Gr00tN1d7
|
|
from vllm_omni.diffusion.models.gr00t.modeling.processing_gr00t_n1d7 import Gr00tN1d7Processor
|
|
|
|
|
|
def _rec_to_dtype(value: Any, dtype: torch.dtype) -> Any:
|
|
"""Recursively convert floating-point tensors in nested collator output."""
|
|
if isinstance(value, torch.Tensor):
|
|
return value.to(dtype=dtype) if torch.is_floating_point(value) else value
|
|
if isinstance(value, dict) or hasattr(value, "items"):
|
|
return {key: _rec_to_dtype(item, dtype) for key, item in value.items()}
|
|
if isinstance(value, list):
|
|
return [_rec_to_dtype(item, dtype) for item in value]
|
|
return value
|
|
|
|
|
|
class Gr00tPolicy:
|
|
"""Core policy class for Gr00t model inference.
|
|
|
|
This policy handles the end-to-end inference pipeline:
|
|
1. Validates input observations
|
|
2. Processes observations with pretrained VLA processor
|
|
3. Runs model inference
|
|
4. Decodes and returns actions
|
|
|
|
The policy expects observations with specific modalities (video, state, language)
|
|
and returns actions in the format defined by the model's modality configuration.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
embodiment_tag: EmbodimentTag | str,
|
|
model_path: str,
|
|
*,
|
|
device: int | str,
|
|
strict: bool = True,
|
|
):
|
|
"""Initialize the Gr00t Policy.
|
|
|
|
Args:
|
|
embodiment_tag: The embodiment tag defining the robot/environment type.
|
|
Accepts an EmbodimentTag enum or a string (resolved case-insensitively).
|
|
model_path: Path to the pretrained model checkpoint directory
|
|
device: Device to run the model on (e.g., 'cuda:0', 0, 'cpu')
|
|
strict: Whether to enforce strict input validation (default: True)
|
|
"""
|
|
self.strict = strict
|
|
if isinstance(embodiment_tag, str):
|
|
embodiment_tag = EmbodimentTag.resolve(embodiment_tag)
|
|
model_dir = Path(model_path)
|
|
|
|
# Load directly into bfloat16 to avoid a transient fp32 copy at load time.
|
|
self.model: Gr00tN1d7 = AutoModel.from_pretrained(model_dir, torch_dtype=torch.bfloat16)
|
|
self.model.eval()
|
|
self.model.to(device=device)
|
|
|
|
# Training saves processor files under a "processor/" subdirectory, but
|
|
# AutoProcessor expects them at the model root. Fall back to the
|
|
# subdirectory when the root lacks a processor_config.json.
|
|
processor_dir = (
|
|
model_dir / "processor"
|
|
if (model_dir / "processor").is_dir() and not (model_dir / "processor_config.json").exists()
|
|
else model_dir
|
|
)
|
|
self.processor: Gr00tN1d7Processor = AutoProcessor.from_pretrained(processor_dir)
|
|
|
|
# Store embodiment-specific configurations
|
|
self.embodiment_tag = embodiment_tag
|
|
all_modality_configs = self.processor.modality_configs
|
|
if self.embodiment_tag.value not in all_modality_configs:
|
|
# Map raw checkpoint tag values to user-friendly enum names where possible.
|
|
supported_lines = []
|
|
for tag_value in sorted(all_modality_configs.keys()):
|
|
enum_name = EmbodimentTag.reverse_lookup(tag_value)
|
|
if enum_name != tag_value:
|
|
supported_lines.append(f" {enum_name:30s} (--embodiment-tag {enum_name})")
|
|
else:
|
|
supported_lines.append(f" {tag_value:30s} (internal, no public enum)")
|
|
supported_str = "\n".join(supported_lines)
|
|
|
|
hint = ""
|
|
if self.embodiment_tag in POSTTRAIN_TAGS:
|
|
hint = (
|
|
f"\n\nHint: '{self.embodiment_tag.name}' is a posttrain tag that requires "
|
|
f"a finetuned checkpoint, not the base model. "
|
|
f"See the example READMEs for how to finetune and download checkpoints."
|
|
)
|
|
elif self.embodiment_tag in FINETUNE_ONLY_TAGS:
|
|
hint = (
|
|
f"\n\nHint: '{self.embodiment_tag.name}' is for finetuning custom robots. "
|
|
f"Use it with launch_finetune.py, not with the base model directly."
|
|
)
|
|
|
|
raise ValueError(
|
|
f"Embodiment tag '{self.embodiment_tag.name}' "
|
|
f"(value='{self.embodiment_tag.value}') is not supported "
|
|
f"by this checkpoint.\n\n"
|
|
f"Supported tags in this checkpoint:\n{supported_str}"
|
|
f"{hint}"
|
|
)
|
|
self.modality_configs = {
|
|
k: v for k, v in all_modality_configs[self.embodiment_tag.value].items() if k != "rl_info"
|
|
}
|
|
self.collate_fn = self.processor.collator
|
|
|
|
# Extract and validate language configuration
|
|
# Embodiments may define multiple language keys (e.g. paraphrases); use only the first at inference.
|
|
language_keys = self.modality_configs["language"].modality_keys
|
|
language_delta_indices = self.modality_configs["language"].delta_indices
|
|
assert len(language_keys) >= 1, "At least one language key is required"
|
|
assert len(language_delta_indices) == 1, "Only one language delta index is supported"
|
|
self.language_key = language_keys[0]
|
|
|
|
def _unbatch_observation(self, value: dict[str, Any]) -> list[dict[str, Any]]:
|
|
"""Unbatch a batched observation into a list of single observations.
|
|
|
|
Args:
|
|
value: Batched observation with shape (B, ...) for each modality
|
|
|
|
Returns:
|
|
List of B observations, each with the batch dimension removed
|
|
"""
|
|
unbatched_obs = []
|
|
# Infer batch size from the first video key
|
|
batch_size = value["video"][list(value["video"].keys())[0]].shape[0]
|
|
|
|
# Split each modality along the batch dimension
|
|
for i in range(batch_size):
|
|
unbatched_value = {
|
|
"video": {k: v[i] for k, v in value["video"].items()},
|
|
"state": {k: v[i] for k, v in value["state"].items()},
|
|
"language": {k: v[i] for k, v in value["language"].items()},
|
|
}
|
|
unbatched_obs.append(unbatched_value)
|
|
return unbatched_obs
|
|
|
|
def _to_vla_step_data(self, observation: dict[str, Any]) -> VLAStepData:
|
|
"""Convert a single observation into a VLAStepData object for processing.
|
|
|
|
Args:
|
|
observation: Single observation dict with video, state, and language
|
|
|
|
Returns:
|
|
VLAStepData object ready for processor input
|
|
"""
|
|
return VLAStepData(
|
|
images=observation["video"],
|
|
states=observation["state"],
|
|
actions={}, # No ground truth actions during inference
|
|
text=observation["language"][self.language_key][0],
|
|
embodiment=self.embodiment_tag,
|
|
)
|
|
|
|
def check_observation(self, observation: dict[str, Any]) -> None:
|
|
"""Validate that the observation has the correct structure and types.
|
|
|
|
This method ensures that all required modalities are present and that their
|
|
data types, shapes, and dimensions match the model's expectations.
|
|
|
|
Expected observation structure:
|
|
- video: dict[str, np.ndarray[np.uint8, (B, T, H, W, C)]]
|
|
- B: batch size
|
|
- T: temporal horizon (number of frames)
|
|
- H, W: image height and width
|
|
- C: number of channels (must be 3 for RGB)
|
|
- state: dict[str, np.ndarray[np.float32, (B, T, D)]]
|
|
- B: batch size
|
|
- T: temporal horizon (number of state observations)
|
|
- D: state dimension
|
|
- language: dict[str, list[list[str]]]
|
|
- Shape: (B, T) where each element is a string
|
|
- T: temporal horizon (typically 1 for language)
|
|
|
|
Args:
|
|
observation: Dictionary containing video, state, and language modalities
|
|
|
|
Raises:
|
|
AssertionError: If any validation check fails
|
|
"""
|
|
# Check that observation contains all required top-level modality keys
|
|
for modality in ["video", "state", "language"]:
|
|
assert modality in observation, f"Observation must contain a '{modality}' key"
|
|
assert isinstance(observation[modality], dict), (
|
|
f"Observation '{modality}' must be a dictionary. "
|
|
f"Got {type(observation[modality])}: {observation[modality]}"
|
|
)
|
|
|
|
# Track batch size across modalities to ensure consistency
|
|
bs = -1
|
|
|
|
for video_key in self.modality_configs["video"].modality_keys:
|
|
assert video_key in observation["video"], f"Video key '{video_key}' must be in observation"
|
|
|
|
# Set or verify batch size consistency across all video keys
|
|
if bs == -1:
|
|
bs = len(observation["video"][video_key])
|
|
else:
|
|
assert len(observation["video"][video_key]) == bs, (
|
|
f"Video key '{video_key}' must have batch size {bs}. Got {len(observation['video'][video_key])}"
|
|
)
|
|
|
|
batched_video = observation["video"][video_key]
|
|
|
|
# Verify data type is numpy array
|
|
assert isinstance(batched_video, np.ndarray), (
|
|
f"Video key '{video_key}' must be a numpy array. Got {type(batched_video)}"
|
|
)
|
|
|
|
# Verify dtype is uint8 (standard for image data, range 0-255)
|
|
assert batched_video.dtype == np.uint8, (
|
|
f"Video key '{video_key}' must be a numpy array of type np.uint8. Got {batched_video.dtype}"
|
|
)
|
|
|
|
# Verify shape has 5 dimensions: (B, T, H, W, C)
|
|
assert batched_video.ndim == 5, (
|
|
f"Video key '{video_key}' must be a numpy array of shape (B, T, H, W, C), got {batched_video.shape}"
|
|
)
|
|
|
|
# Verify temporal dimension matches the expected horizon from config
|
|
assert batched_video.shape[1] == len(self.modality_configs["video"].delta_indices), (
|
|
f"Video key '{video_key}'s horizon must be "
|
|
f"{len(self.modality_configs['video'].delta_indices)}. Got {batched_video.shape[1]}"
|
|
)
|
|
|
|
# Verify channel dimension is 3 (RGB images)
|
|
assert batched_video.shape[-1] == 3, (
|
|
f"Video key '{video_key}'s channel 'C' must be 3. Got {batched_video.shape[-1]}"
|
|
)
|
|
|
|
for state_key in self.modality_configs["state"].modality_keys:
|
|
# Check that the expected state key exists in the observation
|
|
# Must happen before indexing; see video validation above.
|
|
assert state_key in observation["state"], f"State key '{state_key}' must be in observation"
|
|
|
|
# Set or verify batch size consistency across all state keys
|
|
if bs == -1:
|
|
bs = len(observation["state"][state_key])
|
|
else:
|
|
assert len(observation["state"][state_key]) == bs, (
|
|
f"State key '{state_key}' must have batch size {bs}. Got {len(observation['state'][state_key])}"
|
|
)
|
|
|
|
batched_state = observation["state"][state_key]
|
|
|
|
# Verify data type is numpy array
|
|
assert isinstance(batched_state, np.ndarray), (
|
|
f"State key '{state_key}' must be a numpy array. Got {type(batched_state)}"
|
|
)
|
|
|
|
# Verify dtype is float32 (standard for continuous state values)
|
|
assert batched_state.dtype == np.float32, (
|
|
f"State key '{state_key}' must be a numpy array of type np.float32. Got {batched_state.dtype}"
|
|
)
|
|
|
|
# Verify shape has 3 dimensions: (B, T, D)
|
|
assert batched_state.ndim == 3, (
|
|
f"State key '{state_key}' must be a numpy array of shape (B, T, D), got {batched_state.shape}"
|
|
)
|
|
|
|
# Verify temporal dimension matches the expected horizon from config
|
|
assert batched_state.shape[1] == len(self.modality_configs["state"].delta_indices), (
|
|
f"State key '{state_key}'s horizon must be "
|
|
f"{len(self.modality_configs['state'].delta_indices)}. Got {batched_state.shape[1]}"
|
|
)
|
|
|
|
for language_key in self.modality_configs["language"].modality_keys:
|
|
# Check that the expected language key exists in the observation
|
|
# Must happen before indexing; see video validation above.
|
|
assert language_key in observation["language"], f"Language key '{language_key}' must be in observation"
|
|
|
|
# Set or verify batch size consistency (language uses len instead of .shape)
|
|
if bs == -1:
|
|
bs = len(observation["language"][language_key])
|
|
else:
|
|
assert len(observation["language"][language_key]) == bs, (
|
|
f"Language key '{language_key}' must have batch size {bs}. "
|
|
f"Got {len(observation['language'][language_key])}"
|
|
)
|
|
|
|
batched_language: list[list[str]] = observation["language"][language_key]
|
|
|
|
# Verify outer structure is a list (batch dimension)
|
|
assert isinstance(batched_language, list), (
|
|
f"Language key '{language_key}' must be a list. Got {type(batched_language)}"
|
|
)
|
|
|
|
# Validate each batch item
|
|
for batch_item in batched_language:
|
|
# Verify temporal dimension matches expected horizon
|
|
assert len(batch_item) == len(self.modality_configs["language"].delta_indices), (
|
|
f"Language key '{language_key}'s horizon must be "
|
|
f"{len(self.modality_configs['language'].delta_indices)}. Got {len(batched_language)}"
|
|
)
|
|
|
|
# Verify inner structure is also a list (temporal dimension)
|
|
assert isinstance(batch_item, list), f"Language batch item must be a list. Got {type(batch_item)}"
|
|
|
|
# Current implementation expects exactly one language instruction per timestep
|
|
assert len(batch_item) == 1, f"Language batch item must have exactly one item. Got {len(batch_item)}"
|
|
|
|
# Verify the instruction itself is a string
|
|
assert isinstance(batch_item[0], str), (
|
|
f"Language batch item must be a string. Got {type(batch_item[0])}"
|
|
)
|
|
|
|
def _get_action(
|
|
self, observation: dict[str, Any], options: dict[str, Any] | None = None
|
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
"""Internal method to compute actions from observations.
|
|
|
|
Pipeline:
|
|
1. Unbatch observations into individual samples
|
|
2. Convert each to VLAStepData and process
|
|
3. Collate into model input batch
|
|
4. Run model inference
|
|
5. Decode and unnormalize actions
|
|
|
|
Args:
|
|
observation: Batched observation dictionary
|
|
options: Optional parameters (currently unused)
|
|
|
|
Returns:
|
|
Tuple of (actions_dict, info_dict)
|
|
"""
|
|
# Step 1: Split batched observation into individual observations
|
|
unbatched_observations = self._unbatch_observation(observation)
|
|
processed_inputs = []
|
|
|
|
# Step 2: Process each observation through the VLA processor
|
|
states = []
|
|
for obs in unbatched_observations:
|
|
vla_step_data = self._to_vla_step_data(obs)
|
|
states.append(vla_step_data.states) # dict[str, np.ndarray[np.float32, (T, D)]]
|
|
messages = [{"type": MessageType.EPISODE_STEP.value, "content": vla_step_data}]
|
|
processed_inputs.append(self.processor(messages))
|
|
|
|
# Step 3: Collate processed inputs into a single batch for model
|
|
collated_inputs = self.collate_fn(processed_inputs)
|
|
collated_inputs = _rec_to_dtype(collated_inputs, dtype=torch.bfloat16)
|
|
|
|
# Step 4: Run model inference to predict actions
|
|
with torch.inference_mode():
|
|
model_pred = self.model.get_action(**collated_inputs)
|
|
normalized_action = model_pred["action_pred"].float()
|
|
|
|
# Step 5: Decode actions from normalized space back to physical units
|
|
batched_states = {}
|
|
for k in self.modality_configs["state"].modality_keys:
|
|
batched_states[k] = np.stack([s[k] for s in states], axis=0) # (B, T, D)
|
|
unnormalized_action = self.processor.decode_action(
|
|
normalized_action.cpu().numpy(), self.embodiment_tag, batched_states
|
|
)
|
|
|
|
# Cast all actions to float32 for consistency
|
|
casted_action = {key: value.astype(np.float32) for key, value in unnormalized_action.items()}
|
|
return casted_action, {}
|
|
|
|
def check_action(self, action: dict[str, Any]) -> None:
|
|
"""Validate that the action has the correct structure and types.
|
|
|
|
This method ensures that all required action keys are present and that their
|
|
data types, shapes, and dimensions match the model's action space.
|
|
|
|
Expected action structure:
|
|
- action: dict[str, np.ndarray[np.float32, (B, T, D)]]
|
|
- B: batch size
|
|
- T: action horizon (number of future action steps)
|
|
- D: action dimension (e.g., joint positions, velocities, gripper state)
|
|
|
|
Args:
|
|
action: Dictionary containing action arrays for each action key
|
|
|
|
Raises:
|
|
AssertionError: If any validation check fails
|
|
"""
|
|
# Validate each action key defined in the modality config
|
|
for action_key in self.modality_configs["action"].modality_keys:
|
|
# Check that the expected action key exists
|
|
assert action_key in action, f"Action key '{action_key}' must be in action"
|
|
|
|
action_arr = action[action_key]
|
|
|
|
# Verify data type is numpy array
|
|
assert isinstance(action_arr, np.ndarray), (
|
|
f"Action key '{action_key}' must be a numpy array. Got {type(action_arr)}"
|
|
)
|
|
|
|
# Verify dtype is float32 (standard for continuous actions)
|
|
assert action_arr.dtype == np.float32, (
|
|
f"Action key '{action_key}' must be a numpy array of type np.float32. Got {action_arr.dtype}"
|
|
)
|
|
|
|
# Verify shape has 3 dimensions: (B, T, D)
|
|
assert action_arr.ndim == 3, (
|
|
f"Action key '{action_key}' must be a numpy array of shape (B, T, D), got {action_arr.shape}"
|
|
)
|
|
|
|
# Verify action horizon matches the expected temporal dimension from config
|
|
assert action_arr.shape[1] == len(self.modality_configs["action"].delta_indices), (
|
|
f"Action key '{action_key}'s horizon must be "
|
|
f"{len(self.modality_configs['action'].delta_indices)}. Got {action_arr.shape[1]}"
|
|
)
|
|
|
|
def get_action(
|
|
self, observation: dict[str, Any], options: dict[str, Any] | None = None
|
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
if self.strict:
|
|
self.check_observation(observation)
|
|
action, info = self._get_action(observation, options)
|
|
if self.strict:
|
|
self.check_action(action)
|
|
return action, info
|
|
|
|
def reset(self, options: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
"""Reset the policy to its initial state.
|
|
|
|
Args:
|
|
options: Dictionary containing the options for the reset
|
|
|
|
Returns:
|
|
Dictionary containing the info after resetting the policy
|
|
"""
|
|
return {}
|