Download eval_so101_open_loop.py from twanghcmut/TurboVLA-SO101-LeftArm-Multitask: direct link, hf CLI and curl.
- Browser
- Download file 7.04 kB
-
https://huggingface.co/twanghcmut/TurboVLA-SO101-LeftArm-Multitask/resolve/main/eval_so101_open_loop.py
- Command line
-
hf download hf://twanghcmut/TurboVLA-SO101-LeftArm-Multitask/eval_so101_open_loop.py
-
curl -L -o eval_so101_open_loop.py https://huggingface.co/twanghcmut/TurboVLA-SO101-LeftArm-Multitask/resolve/main/eval_so101_open_loop.py
7.04 kB
| """Open-loop evaluation of a trained TurboVLA checkpoint on the SO-101 left-arm set. | |
| Same protocol as openpi's scripts/eval_so101_open_loop.py, GR00T's open_loop_eval.py and | |
| the SmolVLA script, so the four numbers mean the same thing: walk each trajectory in | |
| strides of the scored horizon, take the predicted chunk at each anchor, compare against | |
| the ground-truth chunk, report MSE/MAE in the dataset's own action units. | |
| The model is loaded in-process with baseframework.from_pretrained, the same call the | |
| websocket policy server makes. predict_action returns NORMALIZED actions, so they are | |
| unnormalized here with the run's own dataset_statistics.json, mirroring | |
| experiments/robotwin/evaluation/model2robotwin_interface.py. | |
| One thing is deliberately NOT copied from that interface: its `continuous_mask` is | |
| hardcoded to RoboTwin's 14-dim layout (12 continuous + 2 binary). SO-101 is 5 continuous | |
| arm joints plus 1 binary gripper, matching the normalization_modes declared in | |
| experiments/so101/data_registry/data_config.py. | |
| Usage: | |
| python eval_so101_open_loop.py \ | |
| --ckpt results/Checkpoints/turbovla_so101_2gpu \ | |
| --traj-ids 0 50 100 150 200 251 301 351 401 451 501 \ | |
| --eval-horizon 16 --norm-std 28.67 | |
| """ | |
| import argparse | |
| import json | |
| import pathlib | |
| import numpy as np | |
| import pandas as pd | |
| import torch | |
| from PIL import Image | |
| from starVLA.model.framework.base_framework import baseframework | |
| # Which action/state dimensions were normalized continuously (min_max) rather than | |
| # binarized. This MUST match normalization_modes in | |
| # experiments/so101/data_registry/data_config.py: the mask is applied both to the state | |
| # fed in and to the actions read back, so a stale value corrupts the model's INPUT as well | |
| # as its output -- which is how a gripper-only mismatch degraded every joint, not just the | |
| # gripper. All six SO-101 dimensions are continuous; the gripper is an aperture, not a | |
| # switch. | |
| CONTINUOUS_MASK = np.array([True] * 6, dtype=bool) | |
| BINARY_THRESHOLD = 0.49 | |
| def parse_args(): | |
| p = argparse.ArgumentParser() | |
| p.add_argument("--ckpt", required=True, help="Run directory or a checkpoints/*.pt file") | |
| p.add_argument( | |
| "--data-root", | |
| default="/mnt/data/sftp/data/hunght23/DATA_ZOO/so101_leftarm_multitask_v21/" | |
| "hungho77/so101-left-arm-multitask", | |
| ) | |
| p.add_argument("--traj-ids", type=int, nargs="+", | |
| default=[0, 50, 100, 150, 200, 251, 301, 351, 401, 451, 501]) | |
| p.add_argument("--steps", type=int, default=400) | |
| p.add_argument("--eval-horizon", type=int, default=16) | |
| p.add_argument("--norm-std", type=float, default=None, | |
| help="ABSOLUTE action std; 28.67 for this dataset.") | |
| p.add_argument("--embodiment", default="new_embodiment") | |
| return p.parse_args() | |
| def bounds(stats): | |
| return np.asarray(stats["max"], dtype=np.float64), np.asarray(stats["min"], dtype=np.float64) | |
| def normalize_state(state, stats): | |
| hi, lo = bounds(stats) | |
| valid = CONTINUOUS_MASK & (hi != lo) | |
| out = np.where(valid, (state - lo) / np.where(hi == lo, 1.0, hi - lo) * 2 - 1, state) | |
| return np.where(~CONTINUOUS_MASK, (out > BINARY_THRESHOLD).astype(out.dtype), out) | |
| def unnormalize_actions(norm_actions, stats): | |
| hi, lo = bounds(stats) | |
| norm_actions = np.clip(norm_actions, -1, 1) | |
| return np.where( | |
| CONTINUOUS_MASK, | |
| 0.5 * (norm_actions + 1) * (hi - lo) + lo, | |
| (norm_actions > BINARY_THRESHOLD).astype(norm_actions.dtype), | |
| ) | |
| def main(): | |
| args = parse_args() | |
| ckpt = pathlib.Path(args.ckpt) | |
| run_dir = ckpt if ckpt.is_dir() else ckpt.parent.parent | |
| stats_all = json.loads((run_dir / "dataset_statistics.json").read_text())[args.embodiment] | |
| a_stats, s_stats = stats_all["action"], stats_all["state"] | |
| model = baseframework.from_pretrained(str(ckpt)).to("cuda").eval() | |
| horizon = int(getattr(model, "action_horizon", args.eval_horizon)) | |
| score = min(args.eval_horizon or horizon, horizon) | |
| root = pathlib.Path(args.data_root) | |
| episodes = [json.loads(l) for l in (root / "meta" / "episodes.jsonl").open()] | |
| tasks = {t["task_index"]: t["task"] for t in | |
| (json.loads(l) for l in (root / "meta" / "tasks.jsonl").open())} | |
| lengths = {e["episode_index"]: e["length"] for e in episodes} | |
| per_traj = {} | |
| for tid in args.traj_ids: | |
| if tid not in lengths: | |
| print(f" traj {tid}: khong ton tai, bo qua") | |
| continue | |
| df = pd.read_parquet(root / "data" / "chunk-000" / f"episode_{tid:06d}.parquet") | |
| task = tasks[int(df["task_index"].iloc[0])] | |
| actions = np.stack(df["action"].to_numpy()) # (L, 6) absolute | |
| states = np.stack(df["observation.state"].to_numpy()) | |
| n = min(args.steps, lengths[tid] - score) | |
| if n <= 0: | |
| print(f" traj {tid}: qua ngan, bo qua") | |
| continue | |
| # PyAV, not torchcodec: av==12.3.0 is what this repo's own dataloader uses | |
| # (video_backend: pyav) and torchcodec is not in its environment. The whole | |
| # episode is decoded once into memory -- a few hundred 360x640 frames -- rather | |
| # than seeking per anchor, which with inter-frame compression is both slower and | |
| # easy to get subtly wrong. | |
| import av | |
| frames = {} | |
| for key in ("top", "wrist"): | |
| path = root / "videos" / "chunk-000" / f"observation.images.{key}" / f"episode_{tid:06d}.mp4" | |
| with av.open(str(path)) as container: | |
| frames[key] = [f.to_image() for f in container.decode(video=0)] | |
| errs = [] | |
| for t in range(0, n, score): | |
| imgs = [frames[key][min(t, len(frames[key]) - 1)] for key in ("top", "wrist")] | |
| example = { | |
| "image": imgs, | |
| "lang": task, | |
| "state": normalize_state(states[t].astype(np.float64), s_stats), | |
| } | |
| with torch.no_grad(): | |
| out = model.predict_action([example]) | |
| pred = unnormalize_actions(out["normalized_actions"][0], a_stats)[:score] | |
| gt = actions[t : t + score] | |
| errs.append(pred[: len(gt)] - gt) | |
| e = np.concatenate(errs, axis=0) | |
| mse, mae = float((e**2).mean()), float(np.abs(e).mean()) | |
| per_traj[tid] = (mse, mae) | |
| per_dim = np.abs(e).mean(axis=0) | |
| print(f" traj {tid:3d}: MSE {mse:.6f} MAE {mae:.6f} ({len(e)} steps compared)") | |
| print(f" MAE theo chieu: {np.round(per_dim, 3)}") | |
| if not per_traj: | |
| print("nothing evaluated") | |
| return | |
| maes = [v[1] for v in per_traj.values()] | |
| mses = [v[0] for v in per_traj.values()] | |
| print(f"\nScored horizon: {score} of {horizon} predicted steps") | |
| print(f"Average MSE: {np.mean(mses):.6f}") | |
| print(f"Average MAE: {np.mean(maes):.6f}") | |
| if args.norm_std: | |
| print(f"Normalized MAE: {np.mean(maes) / args.norm_std * 100:.2f}% of action scale " | |
| f"(divided by absolute action std {args.norm_std})") | |
| if __name__ == "__main__": | |
| main() | |