File size: 8,393 Bytes
5a2e445
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
#!/usr/bin/env python
"""Closed-loop LIBERO evaluation for canonical-schema TinyVLA checkpoints.

Adapts env observations to the canonical schema the policy was trained on
(cam0/cam1, padded normalized state, embodiment_id) and unnormalizes the
predicted actions with the LIBERO dataset stats.

Usage:
    python scripts/eval_libero.py --checkpoint outputs/libero_ft/final \
        --suite libero_spatial --episodes 20 [--refresh-s 1.0]
"""

from __future__ import annotations

import argparse
from pathlib import Path

import torch


def make_normalizer(repo_id="HuggingFaceVLA/libero"):
    from lerobot.datasets.lerobot_dataset import LeRobotDatasetMetadata

    meta = LeRobotDatasetMetadata(repo_id)
    stats = meta.stats

    def norm(key, x):
        s = stats[key]
        mean = torch.as_tensor(s["mean"], dtype=torch.float32, device=x.device)
        std = torch.as_tensor(s["std"], dtype=torch.float32, device=x.device).clamp(min=1e-6)
        return (x - mean) / std

    def unnorm_action(x):
        s = stats["action"]
        mean = torch.as_tensor(s["mean"], dtype=torch.float32, device=x.device)
        std = torch.as_tensor(s["std"], dtype=torch.float32, device=x.device)
        return x * std + mean

    return norm, unnorm_action, meta


@torch.no_grad()
def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--checkpoint", type=Path, required=True)
    parser.add_argument("--suite", default="libero_spatial",
                        choices=["libero_spatial", "libero_object", "libero_goal", "libero_10", "libero_90"])
    parser.add_argument("--episodes", type=int, default=20)
    parser.add_argument("--embodiment-id", type=int, default=2)
    parser.add_argument("--refresh-s", type=float, default=None,
                        help="if set, refresh the semantic latent only every N seconds (dual-rate mode)")
    parser.add_argument("--max-steps", type=int, default=520)
    parser.add_argument("--n-action-steps", type=int, default=None,
                        help="execute only first N actions of each chunk before re-planning")
    parser.add_argument("--action-repeat", type=int, default=1,
                        help="env steps per predicted action (dataset 10fps vs env 20Hz -> 2)")
    parser.add_argument("--save-video-dir", type=Path, default=None,
                        help="save per-episode mp4s of the agentview camera here")
    args = parser.parse_args()

    import numpy as np
    from lerobot.envs.factory import make_env, make_env_config
    from transformers import AutoTokenizer
    from tinyvla.modeling_tinyvla import TinyVLAPolicy

    policy = TinyVLAPolicy.from_pretrained(args.checkpoint).cuda().eval()
    cfg = policy.config
    if args.n_action_steps:
        cfg.n_action_steps = args.n_action_steps
    tok = AutoTokenizer.from_pretrained(cfg.lm_model_name)
    norm, unnorm_action, meta = make_normalizer()
    fps = meta.fps

    env_cfg = make_env_config("libero", task=args.suite)
    envs_dict = make_env(env_cfg, n_envs=1)
    task_envs = envs_dict[args.suite]  # {task_id: vec_env}

    from scipy.spatial.transform import Rotation

    def to_canonical(obs, task_text, latent=None):
        imgs = {}
        for slot, key in (("cam0", "image"), ("cam1", "image2")):
            x = torch.as_tensor(np.asarray(obs["pixels"][key]))
            if x.dim() == 4:  # (1, H, W, C)
                x = x[0]
            # robosuite renders 180-degree rotated relative to the recorded dataset
            x = x.flip(0).flip(1)
            x = x.permute(2, 0, 1).float() / 255.0
            if x.shape[-2:] != (cfg.image_size, cfg.image_size):
                x = torch.nn.functional.interpolate(
                    x[None], size=(cfg.image_size, cfg.image_size), mode="bilinear", align_corners=False
                )[0]
            imgs[slot] = x

        # dataset convention (OpenVLA-style): [eef pos(3), eef axis-angle(3), gripper qpos(2)]
        rs = obs["robot_state"]
        pos = np.asarray(rs["eef"]["pos"]).flatten()
        quat = np.asarray(rs["eef"]["quat"]).flatten()  # robosuite: (x, y, z, w)
        rotvec = Rotation.from_quat(quat).as_rotvec()
        # canonicalize antipodal representation to match dataset convention
        # (dataset uses rotvec with positive x-component, ~+pi for downward gripper)
        if rotvec[0] < 0:
            theta = np.linalg.norm(rotvec)
            if theta > 1e-6:
                rotvec = rotvec * (theta - 2 * np.pi) / theta
        grip = np.asarray(rs["gripper"]["qpos"]).flatten()
        state = torch.tensor(np.concatenate([pos, rotvec, grip]), dtype=torch.float32)
        state = norm("observation.state", state)
        state = torch.nn.functional.pad(state, (0, cfg.max_state_dim - state.shape[-1]))

        t = tok([task_text], padding=True, truncation=True,
                max_length=cfg.tokenizer_max_length, return_tensors="pt")
        batch = {
            "observation.images.cam0": imgs["cam0"][None].cuda(),
            "observation.images.cam1": imgs["cam1"][None].cuda(),
            "observation.state": state[None].cuda(),
            "observation.language.tokens": t["input_ids"].cuda(),
            "observation.language.attention_mask": t["attention_mask"].bool().cuda(),
            "embodiment_id": torch.tensor([args.embodiment_id], device="cuda"),
        }
        if latent is not None:
            batch["semantic_latent"] = latent
        return batch

    action_dim = meta.features["action"]["shape"][0]
    total, succ = 0, 0
    per_task = {}

    task_ids = sorted(task_envs.keys())
    ep_plan = [(tid, i) for i in range((args.episodes + len(task_ids) - 1) // len(task_ids)) for tid in task_ids]
    ep_plan = ep_plan[: args.episodes]

    if args.save_video_dir:
        args.save_video_dir.mkdir(parents=True, exist_ok=True)

    for ep, (tid, rep) in enumerate(ep_plan):
        env = task_envs[tid]
        obs, info = env.reset(seed=1000 + rep)
        frames = [] if args.save_video_dir else None
        try:
            task_text = env.get_attr("task_description")[0]
        except Exception:
            task_text = getattr(getattr(env, "envs", [None])[0], "task_description", "")
        policy.reset()
        latent = None
        last_refresh = -1e9
        done = False
        step_i = 0
        ep_succ = False
        while not done and step_i < args.max_steps:
            t_now = step_i / fps
            batch = to_canonical(obs, task_text)
            if args.refresh_s is not None:
                if t_now - last_refresh >= args.refresh_s:
                    latent = policy._semantic_latent(batch)
                    last_refresh = t_now
                batch["semantic_latent"] = latent
            act_norm = policy.select_action(batch)  # (1, max_action_dim) normalized padded
            act = unnorm_action(act_norm[0, :action_dim].cpu()).clamp(-1, 1)
            for _ in range(args.action_repeat):
                try:
                    obs, reward, terminated, truncated, info = env.step(act.numpy()[None])
                except ValueError:  # stepped into env's internal horizon
                    done = True
                    break
                done = bool(terminated[0] or truncated[0])
                if info.get("is_success") is not None:
                    ep_succ = ep_succ or bool(np.asarray(info["is_success"]).flatten()[0])
                if frames is not None:
                    frames.append(np.asarray(obs["pixels"]["image"])[0][::-1, ::-1])
                step_i += 1
                if done or step_i >= args.max_steps:
                    done = done or step_i >= args.max_steps
                    break
        total += 1
        succ += int(ep_succ)
        per_task.setdefault(task_text[:50], []).append(int(ep_succ))
        print(f"ep {ep}: {'SUCCESS' if ep_succ else 'fail'} ({step_i} steps) | {task_text[:60]}")
        if frames:
            import imageio.v2 as imageio

            tag = "succ" if ep_succ else "fail"
            path = args.save_video_dir / f"ep{ep:02d}_task{tid}_{tag}.mp4"
            imageio.mimwrite(path, frames, fps=20, quality=7)

    print(f"\n=== {args.suite} | {args.checkpoint} | refresh={args.refresh_s} ===")
    print(f"success rate: {succ}/{total} = {succ/total:.1%}")
    for t, v in per_task.items():
        print(f"  {sum(v)}/{len(v)}  {t}")


if __name__ == "__main__":
    main()