Files
vllm-omni/examples/offline_inference/internvla_a1/internvla_a1_common.py
yangkun.zhuandwangbolun 0016006974 [Model] Add InternVLA-A1 offline inference support (#2737)
Signed-off-by: zhuyangkun <zyk_120403228@163.com>
Signed-off-by: bl-wang1 <918395762@qq.com>
Co-authored-by: wangbolun <918395762@qq.com>
2026-04-27 02:28:07 -07:00

472 lines
17 KiB
Python

from __future__ import annotations
import hashlib
import json
import math
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import numpy as np
import pyarrow.parquet as pq
import torch
import torch.nn.functional as F
import torchvision
from vllm_omni.diffusion.models.internvla_a1.config import ( # noqa: E402
OBS_IMAGES,
OBS_STATE,
OBS_TASK,
InternVLAA1Config,
)
def tensor_dtype(name: str) -> torch.dtype:
return torch.bfloat16 if name == "bfloat16" else torch.float32
def select_indices(dataset, num_samples: int) -> list[int]:
indices: list[int] = []
for _, episode_indices in dataset.episode_start_indices(max_episodes=num_samples):
if episode_indices:
indices.append(episode_indices[0])
return indices[:num_samples]
def make_shared_noise(seed: int, sample_index: int, shape: tuple[int, ...], device: str) -> torch.Tensor:
generator = torch.Generator(device="cpu")
generator.manual_seed(seed + sample_index)
noise = torch.randn(shape, generator=generator, dtype=torch.float32)
return noise.to(device=device)
def tensor_sha256(tensor: torch.Tensor) -> str:
array = tensor.detach().contiguous().cpu().numpy()
return hashlib.sha256(array.tobytes()).hexdigest()
def _clamp_index(index: int, start: int, end: int) -> int:
return max(start, min(end - 1, index))
def _load_parquet_rows(path: Path) -> list[dict[str, Any]]:
return pq.read_table(path).to_pylist()
def _load_json(path: Path) -> dict[str, Any]:
with open(path, encoding="utf-8") as f:
return json.load(f)
def _stack_stats(stats: dict[str, Any], keys: list[str]) -> dict[str, torch.Tensor]:
result = {}
for stat_name in ("mean", "std"):
values = []
for key in keys:
values.extend(stats[key][stat_name])
result[stat_name] = torch.tensor(values, dtype=torch.float32)
return result
def normalize_vector(values: torch.Tensor, stats: dict[str, torch.Tensor]) -> torch.Tensor:
denom = torch.where(stats["std"] == 0, torch.ones_like(stats["std"]), stats["std"])
return (values - stats["mean"]) / denom
def unnormalize_vector(values: torch.Tensor, stats: dict[str, torch.Tensor]) -> torch.Tensor:
return values * stats["std"] + stats["mean"]
class TorchvisionVideoReaderCache:
def __init__(self, backend: str = "pyav") -> None:
self.backend = backend
self._readers: dict[str, Any] = {}
torchvision.set_video_backend(backend)
def get(self, path: str) -> Any:
reader = self._readers.get(path)
if reader is None:
reader = torchvision.io.VideoReader(path, "video")
self._readers[path] = reader
return reader
def decode_frames(self, path: str, timestamps: list[float], tolerance_s: float = 1e-4) -> torch.Tensor:
reader = self.get(path)
first_ts = min(timestamps)
last_ts = max(timestamps)
reader.seek(first_ts, keyframes_only=self.backend == "pyav")
loaded_frames: list[torch.Tensor] = []
loaded_ts: list[float] = []
for frame in reader:
current_ts = float(frame["pts"])
loaded_frames.append(frame["data"])
loaded_ts.append(current_ts)
if current_ts >= last_ts:
break
query_ts = torch.tensor(timestamps, dtype=torch.float32)
loaded_ts_tensor = torch.tensor(loaded_ts, dtype=torch.float32)
distances = torch.cdist(query_ts[:, None], loaded_ts_tensor[:, None], p=1)
min_dist, argmin = distances.min(dim=1)
if not torch.all(min_dist < tolerance_s):
raise RuntimeError(
f"Video timestamps are outside tolerance: query={query_ts.tolist()} "
f"loaded={loaded_ts_tensor.tolist()} path={path}"
)
return torch.stack([loaded_frames[i] for i in argmin]).float() / 255.0
@dataclass
class A2DOpenLoopSample:
index: int
episode_index: int
task: str
state_raw: torch.Tensor
action_raw: torch.Tensor
inputs: dict[str, torch.Tensor]
class A2DOpenLoopDataset:
image_keys = [
"observation.images.head",
"observation.images.hand_left",
"observation.images.hand_right",
]
state_keys = [
"observation.states.joint.position",
"observation.states.effector.position",
]
action_keys = [
"actions.joint.position",
"actions.effector.position",
]
def __init__(
self,
dataset_root: str | Path,
*,
config: InternVLAA1Config,
train_stats: dict[str, Any],
image_offsets: tuple[int, int] = (-15, 0),
tolerance_s: float = 1e-4,
) -> None:
self.root = Path(dataset_root)
self.config = config
self.info = _load_json(self.root / "meta" / "info.json")
self.dataset_stats = _load_json(self.root / "meta" / "stats.json")
self.data_rows = _load_parquet_rows(self.root / "data" / "chunk-000" / "file-000.parquet")
self.episode_rows = _load_parquet_rows(self.root / "meta" / "episodes" / "chunk-000" / "file-000.parquet")
self.task_rows = _load_parquet_rows(self.root / "meta" / "tasks.parquet")
self.state_stats = _stack_stats(train_stats, self.state_keys)
self.action_stats = _stack_stats(train_stats, self.action_keys)
self.image_offsets = image_offsets
self.tolerance_s = tolerance_s
self.video_reader = TorchvisionVideoReaderCache(backend="pyav")
@property
def num_episodes(self) -> int:
return len(self.episode_rows)
@property
def physical_action_dim(self) -> int:
return 16
def episode_start_indices(self, max_episodes: int | None = None) -> list[tuple[int, list[int]]]:
rows = self.episode_rows if max_episodes is None else self.episode_rows[:max_episodes]
result = []
for ep in rows:
start = int(ep["dataset_from_index"])
end = int(ep["dataset_to_index"])
result.append((int(ep["episode_index"]), list(range(start, end, self.config.chunk_size))))
return result
def _task_text(self, task_index: int) -> str:
return self.task_rows[task_index]["__index_level_0__"]
def _episode_for_index(self, row: dict[str, Any]) -> dict[str, Any]:
return self.episode_rows[int(row["episode_index"])]
def _state_vector(self, row: dict[str, Any]) -> torch.Tensor:
return torch.tensor(row[self.state_keys[0]] + row[self.state_keys[1]], dtype=torch.float32)
def _action_vector(self, row: dict[str, Any]) -> torch.Tensor:
return torch.tensor(row[self.action_keys[0]] + row[self.action_keys[1]], dtype=torch.float32)
def _query_rows(self, idx: int, deltas: list[int]) -> list[dict[str, Any]]:
row = self.data_rows[idx]
episode = self._episode_for_index(row)
start = int(episode["dataset_from_index"])
end = int(episode["dataset_to_index"])
return [self.data_rows[_clamp_index(idx + delta, start, end)] for delta in deltas]
def _decode_camera_history(
self, episode: dict[str, Any], camera_key: str, rows: list[dict[str, Any]]
) -> torch.Tensor:
timestamps = [float(r["timestamp"]) for r in rows]
shifted = [float(episode[f"videos/{camera_key}/from_timestamp"]) + ts for ts in timestamps]
chunk_idx = int(episode[f"videos/{camera_key}/chunk_index"])
file_idx = int(episode[f"videos/{camera_key}/file_index"])
path = self.root / self.info["video_path"].format(
video_key=camera_key,
chunk_index=chunk_idx,
file_index=file_idx,
)
frames = self.video_reader.decode_frames(str(path), shifted, tolerance_s=self.tolerance_s)
return frames
def get_sample(self, idx: int) -> A2DOpenLoopSample:
row = self.data_rows[idx]
episode = self._episode_for_index(row)
image_rows = self._query_rows(idx, list((-15, 0)))
action_rows = self._query_rows(idx, list(range(self.config.chunk_size)))
camera_images = [self._decode_camera_history(episode, camera_key, image_rows) for camera_key in self.image_keys]
state_raw = self._state_vector(row)
state_norm = normalize_vector(state_raw, self.state_stats)
action_raw = torch.stack([self._action_vector(action_row) for action_row in action_rows], dim=0)
task = self._task_text(int(row["task_index"]))
inputs = {
OBS_STATE: state_norm,
OBS_TASK: task,
f"{OBS_IMAGES}.image0": camera_images[0],
f"{OBS_IMAGES}.image1": camera_images[1],
f"{OBS_IMAGES}.image2": camera_images[2],
f"{OBS_IMAGES}.image0_mask": torch.tensor(True),
f"{OBS_IMAGES}.image1_mask": torch.tensor(True),
f"{OBS_IMAGES}.image2_mask": torch.tensor(True),
}
return A2DOpenLoopSample(
index=idx,
episode_index=int(row["episode_index"]),
task=task,
state_raw=state_raw,
action_raw=action_raw,
inputs=inputs,
)
def collate_open_loop_samples(
samples: list[A2DOpenLoopSample],
*,
device: str,
dtype: torch.dtype,
) -> tuple[dict[str, torch.Tensor], dict[str, Any]]:
first = samples[0]
batch_inputs: dict[str, torch.Tensor] = {}
for key in first.inputs:
values = [sample.inputs[key] for sample in samples]
if isinstance(values[0], torch.Tensor):
tensor = torch.stack(values, dim=0)
if tensor.dtype in (torch.int64, torch.bool):
batch_inputs[key] = tensor.to(device=device)
else:
batch_inputs[key] = tensor.to(device=device, dtype=dtype)
else:
batch_inputs[key] = values
metadata = {
"indices": [sample.index for sample in samples],
"episode_indices": [sample.episode_index for sample in samples],
"tasks": [sample.task for sample in samples],
"state_raw": torch.stack([sample.state_raw for sample in samples], dim=0),
"action_raw": torch.stack([sample.action_raw for sample in samples], dim=0),
}
return batch_inputs, metadata
def plot_prediction_series(
*,
series: dict[str, np.ndarray],
output_path: Path,
title: str,
) -> None:
import matplotlib.pyplot as plt
first_series = next(iter(series.values()))
num_dims = int(first_series.shape[1])
num_cols = 2
num_rows = math.ceil(num_dims / num_cols)
fig, axs = plt.subplots(num_rows, num_cols, figsize=(16, max(6, 3 * num_rows)))
axs = np.array(axs).reshape(-1)
x_values = np.arange(first_series.shape[0])
styles = [
("Ground Truth", "blue", "-", 1.5),
("VLLM", "green", "-.", 1.4),
("Predicted", "red", "--", 1.4),
]
style_map = {name: (color, linestyle, linewidth) for name, color, linestyle, linewidth in styles}
for dim in range(num_dims):
ax = axs[dim]
for name, values in series.items():
color, linestyle, linewidth = style_map.get(name, (None, "-", 1.2))
ax.plot(
x_values,
values[:, dim],
label=name,
color=color,
linestyle=linestyle,
linewidth=linewidth,
)
ax.set_title(f"Dimension {dim + 1}")
ax.set_xlabel("Time Step / Sample Index")
ax.set_ylabel(f"Value Dim {dim + 1}")
ax.legend(loc="upper right")
ax.grid(True, linestyle="--", alpha=0.7)
for dim in range(num_dims, len(axs)):
axs[dim].axis("off")
fig.suptitle(title, fontsize=16)
plt.tight_layout(rect=(0, 0, 1, 0.97))
output_path.parent.mkdir(parents=True, exist_ok=True)
plt.savefig(output_path)
plt.close(fig)
def summarize_prediction_metrics(
*,
gt_tensor: torch.Tensor,
pred_tensor: torch.Tensor,
joint_dims: int = 14,
) -> dict[str, float]:
return {
"mse": float(F.mse_loss(gt_tensor, pred_tensor, reduction="mean").item()),
"mae": float(F.l1_loss(gt_tensor, pred_tensor, reduction="mean").item()),
"mse_joint": float(F.mse_loss(gt_tensor[:, :joint_dims], pred_tensor[:, :joint_dims], reduction="mean").item()),
"mae_joint": float(F.l1_loss(gt_tensor[:, :joint_dims], pred_tensor[:, :joint_dims], reduction="mean").item()),
"mse_gripper": float(
F.mse_loss(gt_tensor[:, joint_dims:], pred_tensor[:, joint_dims:], reduction="mean").item()
),
"mae_gripper": float(
F.l1_loss(gt_tensor[:, joint_dims:], pred_tensor[:, joint_dims:], reduction="mean").item()
),
}
def summarize_metric_list(values: list[float]) -> float | None:
return float(np.mean(values)) if values else None
def run_open_loop_evaluation(
*,
mode: str,
policy,
config,
dataset,
train_meta,
collate_samples: Callable[..., tuple[dict[str, torch.Tensor], dict[str, Any]]],
run_sample_actions: Callable[[Any, dict[str, torch.Tensor], torch.Tensor], torch.Tensor],
num_episodes: int,
seed: int,
device: str,
dtype: torch.dtype,
output_dir: str | Path,
skip_plots: bool,
) -> dict[str, Any]:
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
plots_dir = output_dir / "plots"
plots_dir.mkdir(parents=True, exist_ok=True)
mse_values: list[float] = []
mae_values: list[float] = []
mse_joint_values: list[float] = []
mae_joint_values: list[float] = []
mse_gripper_values: list[float] = []
mae_gripper_values: list[float] = []
per_episode: list[dict[str, Any]] = []
plotting_available = True
if not skip_plots:
try:
import matplotlib.pyplot as _plt # noqa: F401
except ModuleNotFoundError:
plotting_available = False
print("[warning] matplotlib is not installed; plots will be skipped.")
episode_specs = dataset.episode_start_indices(max_episodes=num_episodes)
for episode_index, indices in episode_specs:
print(f"[{mode}] episode: {episode_index}")
pred_chunks = []
gt_chunks = []
visited_indices = []
task = None
for index in indices:
sample = dataset.get_sample(index)
task = sample.task
batch_inputs, meta = collate_samples([sample], device=device, dtype=dtype)
noise = make_shared_noise(
seed,
index,
(batch_inputs[OBS_STATE].shape[0], config.chunk_size, config.max_action_dim),
device,
)
with torch.no_grad():
pred = run_sample_actions(policy, batch_inputs, noise)
pred = pred[:, :, : dataset.physical_action_dim].to(torch.float32).cpu()
pred_phys = unnormalize_vector(pred, dataset.action_stats)
if train_meta.action_mode == "delta":
pred_phys[:, :, :14] += meta["state_raw"][:, None, :14]
gt_phys = meta["action_raw"][:, :, : dataset.physical_action_dim].to(torch.float32).cpu()
pred_chunks.append(pred_phys[0])
gt_chunks.append(gt_phys[0])
visited_indices.append(index)
pred_tensor = torch.cat(pred_chunks, dim=0)
gt_tensor = torch.cat(gt_chunks, dim=0)
metrics = summarize_prediction_metrics(gt_tensor=gt_tensor, pred_tensor=pred_tensor)
mse_values.append(metrics["mse"])
mae_values.append(metrics["mae"])
mse_joint_values.append(metrics["mse_joint"])
mae_joint_values.append(metrics["mae_joint"])
mse_gripper_values.append(metrics["mse_gripper"])
mae_gripper_values.append(metrics["mae_gripper"])
if not skip_plots and plotting_available:
plot_prediction_series(
series={
"Ground Truth": gt_tensor.numpy(),
"VLLM": pred_tensor.numpy(),
},
output_path=plots_dir / f"{mode}_open_loop_ep{episode_index}.jpg",
title=f"{mode} Ground Truth vs VLLM",
)
episode_log = {
"episode_id": int(episode_index),
"task": task,
"visited_indices": visited_indices,
"num_pred_steps": int(pred_tensor.shape[0]),
**metrics,
}
per_episode.append(episode_log)
print(json.dumps({"mode": mode, **episode_log}, ensure_ascii=False))
summary = {
"mode": mode,
"num_episodes": len(per_episode),
"mse": mse_values,
"mae": mae_values,
"average_mse": summarize_metric_list(mse_values),
"average_mae": summarize_metric_list(mae_values),
"mse_joint": mse_joint_values,
"mae_joint": mae_joint_values,
"average_mse_joint": summarize_metric_list(mse_joint_values),
"average_mae_joint": summarize_metric_list(mae_joint_values),
"mse_gripper": mse_gripper_values,
"mae_gripper": mae_gripper_values,
"average_mse_gripper": summarize_metric_list(mse_gripper_values),
"average_mae_gripper": summarize_metric_list(mae_gripper_values),
"episodes": per_episode,
}
with open(output_dir / "log.json", "w", encoding="utf-8") as f:
json.dump(summary, f, indent=2, ensure_ascii=False)
return summary