File size: 7,042 Bytes
24e6dbf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
"""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()