File size: 17,279 Bytes
1728ab9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8d0f669
 
 
 
 
 
 
 
 
 
 
 
 
1728ab9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8d0f669
 
 
1728ab9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8d0f669
1728ab9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
#!/usr/bin/env python3
"""Open-loop comparison of DreamZero and RAPTOR/FACT on the same T-Rex episodes, in physical units.

Written 2026-10-02 for this comparison; it is NOT part of the upstream dreamzero repo. It reuses
  * RAPTOR side   : ChunkBuilder / load_pipeline / _sample from scripts/eval/trex_ablation_eval.py
  * DreamZero side: GrootSimPolicy (upstream) + TrexEpisode / build_obs from scripts/inference_trex.py
  * FK            : joint58_to_eef62_batch from scripts/data/build_trex_track_force_v2.py — the same FK
                    that produced the dataset's ``observation.state_eef62`` / ``action.eef62_absolute``

Protocol. For every (episode, anchor) both models get ONE current frame per camera and the current
state (RAPTOR additionally its 16 past track frames and force history) and predict the next 0.8 s.
Predictions are compared with the recorded teleop targets on a common 20 Hz grid (16 steps):
    pos_err  [m]   per arm  = || p_pred - p_gt ||
    rot_err  [deg] per arm  = geodesic angle of R_pred^T R_gt
    hand_err [rad] per hand = RMSE over the 22 hand joints
DreamZero predicts 24 joint-space actions at 30 Hz; they are linearly interpolated onto the 20 Hz grid
and passed through FK, so arms are compared in the same end-effector space as RAPTOR.

Usage:
    python scripts/eval/openloop_compare.py run --model raptor    --checkpoint <dir> --out <dir>
    python scripts/eval/openloop_compare.py run --model dreamzero --checkpoint <dir> --out <dir>
    python scripts/eval/openloop_compare.py summarize --out <dir>
"""
from __future__ import annotations

import argparse
import json
import os
import sys
import time
from pathlib import Path

import numpy as np
import torch

ROOT = Path(__file__).resolve().parents[2]
for sub in ("scripts", "scripts/eval", "scripts/data"):
    sys.path.insert(0, str(ROOT / sub))

import trex_ablation_eval as tae  # noqa: E402  (RAPTOR helpers)
from groot.vla.model.trex_track_force.dataset import (  # noqa: E402
    eef62_delta_base,
    rotation_6d_to_matrix,
)
from groot.vla.model.trex_track_force.runtime import delta_base_to_absolute  # noqa: E402

STEPS = 16
RATE = 20.0
ARM = {"left": slice(0, 9), "right": slice(31, 40)}
HAND = {"left": slice(9, 31), "right": slice(40, 62)}
DZ_ACTION_KEYS = ("action.left_arm", "action.left_hand", "action.right_arm", "action.right_hand")


# ----------------------------------------------------------------------------- anchors / ground truth
def select_anchors(dataset_root: str, n_episodes: int, n_anchors: int, n_total: int = 100):
    episodes = sorted(set(int(round(e)) for e in np.linspace(0, n_total - 1, n_episodes)))
    anchors = []
    for ep in episodes:
        builder = tae.ChunkBuilder(dataset_root, ep)
        valid = builder.valid_anchor_times(1)
        ts = builder.timestamps
        valid = [t for t in valid if int(np.argmin(np.abs(ts - t))) + 24 <= len(ts) - 1]
        picks = np.linspace(0, len(valid) - 1, n_anchors + 2)[1:-1]
        anchors += [(ep, float(valid[int(round(i))])) for i in picks]
    return anchors


def episode_task(builder) -> str:
    try:
        return str(builder.episode.table.column("annotation.task")[0].as_py())
    except Exception:
        with open(Path(builder.root) / "meta" / "tasks.jsonl") as handle:
            return json.loads(handle.readline())["task"]


def ground_truth(builder, anchor: float):
    sel = tae._sample(builder.timestamps, anchor, range(STEPS), RATE)
    joints = tae._column(builder.episode, "action")
    return builder.action_abs[sel.indices].astype(np.float64), joints[sel.indices].astype(np.float64)


def load_or_make_anchors(args):
    path = Path(args.out) / "anchors.json"
    if path.exists():
        return [(int(e), float(t)) for e, t in json.load(open(path))["anchors"]]
    anchors = select_anchors(args.dataset_root, args.episodes, args.anchors)
    path.parent.mkdir(parents=True, exist_ok=True)
    json.dump({"dataset_root": args.dataset_root, "anchors": anchors}, open(path, "w"), indent=1)
    return anchors


# ----------------------------------------------------------------------------- RAPTOR
def run_raptor(args, anchors):
    from groot.vla.model.trex_track_force.vla import TrexTrackForceVLA

    device = torch.device("cuda")
    ckpt = Path(args.checkpoint)
    model = TrexTrackForceVLA.load_lora(str(ckpt)).to(device=device, dtype=torch.bfloat16).eval()
    policy = model.action_head
    transform_eval, _ = tae.load_pipeline(ckpt, training=False)
    modes = [("cascade", True), ("coarse_only", False)] if policy.config.use_force else [("coarse_only", False)]
    builders: dict[int, object] = {}
    preds = {m: [] for m, _ in modes}
    gts, gtj, times = [], [], []
    for i, (ep, anchor) in enumerate(anchors):
        if ep not in builders:
            builders[ep] = tae.ChunkBuilder(args.dataset_root, ep)
        builder = builders[ep]
        gt_eef, gt_joint = ground_truth(builder, anchor)
        raw = builder.build(anchor, blocks=1, prompt=episode_task(builder), with_deform=False,
                            history_only_video=True)
        reference = np.asarray(raw["state.eef62"], np.float64).reshape(-1, 62)[0]
        if i == 0:  # round-trip check of the delta-base math used below
            rt = delta_base_to_absolute(reference, eef62_delta_base(reference, gt_eef))
            print(f"delta-base round trip max |err| = {np.abs(rt - gt_eef).max():.2e}", flush=True)
        raw.pop("action.eef62")
        collated = transform_eval(tae.unsqueeze_dict_values(dict(raw)))
        batch = {}
        for key, value in collated.items():
            if torch.is_tensor(value):
                value = value.to(device=device, dtype=torch.bfloat16) if value.is_floating_point() else value.to(device)
            batch[key] = value
        t0 = time.perf_counter()
        for mode, refine in modes:
            with torch.inference_mode():
                result = policy.sample(batch, seed=500 + i, run_force_refinement=refine)
            pred_norm = result["action_pred"][0, :STEPS, :62].float().cpu().numpy()
            delta = builder.stats.denormalize_action(pred_norm)
            preds[mode].append(delta_base_to_absolute(reference, delta))
        times.append(time.perf_counter() - t0)
        gts.append(gt_eef); gtj.append(gt_joint)
        print(f"[raptor] {i + 1}/{len(anchors)} ep{ep} t={anchor:.2f}s  {times[-1]:.1f}s", flush=True)
    out = {f"pred_{m}": np.stack(v) for m, v in preds.items()}
    np.savez(Path(args.out) / "pred_raptor.npz", gt=np.stack(gts), gt_joint=np.stack(gtj),
             time=np.asarray(times), **out)


# ----------------------------------------------------------------------------- DreamZero
def run_dreamzero(args, anchors):
    import torch.distributed as dist
    import inference_trex as it
    from build_trex_track_force_v2 import joint58_to_eef62_batch
    from groot.vla.data.schema import EmbodimentTag
    from groot.vla.model.n1_5.sim_policy import GrootSimPolicy

    if not dist.is_initialized():
        os.environ.setdefault("MASTER_ADDR", "localhost")
        os.environ.setdefault("MASTER_PORT", str(29500 + os.getpid() % 1000))
        dist.init_process_group(backend="gloo", world_size=1, rank=0)
    policy = GrootSimPolicy(embodiment_tag=EmbodimentTag.TREX, model_path=args.checkpoint, device="cuda:0")
    height, width = it.get_expected_video_resolution(policy)
    print(f"DreamZero input video {height}x{width}", flush=True)
    builders: dict[int, object] = {}
    episodes: dict[int, object] = {}
    preds, preds_joint, gts, gtj, times = [], [], [], [], []
    for i, (ep, anchor) in enumerate(anchors):
        if ep not in builders:
            builders[ep] = tae.ChunkBuilder(args.dataset_root, ep)
            episodes[ep] = it.TrexEpisode(args.dataset_root, ep)
        builder, episode = builders[ep], episodes[ep]
        gt_eef, gt_joint = ground_truth(builder, anchor)
        ts = builder.timestamps
        row = int(np.argmin(np.abs(ts - anchor)))
        obs = it.build_obs(episode, [row], episode_task(builder), height, width)  # 1 frame -> causal reset
        torch.manual_seed(500 + i)
        t0 = time.perf_counter()
        with torch.inference_mode():
            result, _ = policy.lazy_joint_forward_causal(it.Batch(obs=obs))
        times.append(time.perf_counter() - t0)
        act = result.act
        joints24 = np.concatenate(
            [np.asarray(act[k], dtype=np.float64).reshape(-1, len(range(*{"action.left_arm": (0, 7), "action.left_hand": (7, 29), "action.right_arm": (29, 36), "action.right_hand": (36, 58)}[k])))
             for k in DZ_ACTION_KEYS], axis=-1)
        n = joints24.shape[0]
        t_pred = ts[row:row + n]
        if len(t_pred) < n:  # episode tail: extend with the nominal 30 Hz spacing
            t_pred = np.concatenate([t_pred, t_pred[-1] + (np.arange(n - len(t_pred)) + 1) / 30.0])
        t_grid = anchor + np.arange(STEPS) / RATE
        joints16 = np.stack([np.interp(t_grid, t_pred, joints24[:, d]) for d in range(58)], axis=-1)
        preds.append(joint58_to_eef62_batch(joints16))
        preds_joint.append(joints16)
        gts.append(gt_eef); gtj.append(gt_joint)
        print(f"[dreamzero] {i + 1}/{len(anchors)} ep{ep} t={anchor:.2f}s  {times[-1]:.1f}s  "
              f"(model returned {n} steps)", flush=True)
    np.savez(Path(args.out) / "pred_dreamzero.npz", gt=np.stack(gts), gt_joint=np.stack(gtj),
             time=np.asarray(times), pred=np.stack(preds), pred_joint=np.stack(preds_joint))


# ----------------------------------------------------------------------------- metrics
def errors(pred: np.ndarray, gt: np.ndarray) -> dict[str, np.ndarray]:
    """pred, gt: [N, 16, 62] -> per-anchor, per-step error arrays."""
    out = {}
    for side, sl in ARM.items():
        p, g = pred[..., sl], gt[..., sl]
        out[f"pos_{side}"] = np.linalg.norm(p[..., :3] - g[..., :3], axis=-1)
        rp = rotation_6d_to_matrix(p[..., 3:9]); rg = rotation_6d_to_matrix(g[..., 3:9])
        tr = np.einsum("...ji,...ji->...", rp, rg)  # trace(Rp^T Rg)
        out[f"rot_{side}"] = np.degrees(np.arccos(np.clip((tr - 1.0) / 2.0, -1.0, 1.0)))
    for side, sl in HAND.items():
        out[f"hand_{side}"] = np.sqrt(((pred[..., sl] - gt[..., sl]) ** 2).mean(-1))
    out["pos"] = 0.5 * (out["pos_left"] + out["pos_right"])
    out["rot"] = 0.5 * (out["rot_left"] + out["rot_right"])
    out["hand"] = 0.5 * (out["hand_left"] + out["hand_right"])
    return out


def summarize(args):
    out = Path(args.out)
    runs = {}
    r = np.load(out / "pred_raptor.npz")
    for key in r.files:
        if key.startswith("pred_"):
            runs["RAPTOR " + key[5:]] = (r[key], r["gt"], r["time"])
    d = np.load(out / "pred_dreamzero.npz")
    runs["DreamZero"] = (d["pred"], d["gt"], d["time"])
    assert np.allclose(r["gt"], d["gt"]), "both runs must use the same anchors"
    for spec in (args.extra or []):                      # NAME=path.npz[:key]
        name, _, rest = spec.partition("="); path, _, key = rest.partition(":")
        x = np.load(path); key = key or ("pred_cascade" if "pred_cascade" in x.files else "pred")
        assert np.allclose(x["gt"], r["gt"]), f"{path} was evaluated on different anchors"
        runs[name] = (x[key], x["gt"], x["time"])
    # baseline: hold the current measured pose for the whole chunk
    anchors_list = json.load(open(out / "anchors.json"))["anchors"]; root = json.load(open(out / "anchors.json"))["dataset_root"]
    hold = []; cache = {}
    for ep, t in anchors_list:
        if ep not in cache:
            b_ = tae.ChunkBuilder(root, ep); cache[ep] = (b_.timestamps, b_.state)
        ts, st = cache[ep]; hold.append(np.repeat(st[int(np.argmin(np.abs(ts - t)))][None], STEPS, 0))
    runs["hold current pose"] = (np.stack(hold).astype(np.float64), r["gt"], np.zeros(len(hold)))
    n_anchors, n_steps = r["gt"].shape[:2]
    anchors = json.load(open(out / "anchors.json"))["anchors"]
    n_eps = len({e for e, _ in anchors})

    table = {}
    per_anchor = {}
    for name, (pred, gt, tm) in runs.items():
        e = errors(pred, gt)
        row = {}
        for m in ("pos", "rot", "hand", "pos_left", "pos_right", "rot_left", "rot_right", "hand_left", "hand_right"):
            row[m] = float(e[m].mean())
            row[f"{m}@k0"] = float(e[m][:, 0].mean()); row[f"{m}@k8"] = float(e[m][:, 8].mean()); row[f"{m}@k15"] = float(e[m][:, 15].mean())
            row[f"{m}_curve"] = e[m].mean(0).tolist()
        row["sec_per_anchor"] = float(np.mean(tm))
        table[name] = row
        per_anchor[name] = {m: e[m].mean(1) for m in ("pos", "rot", "hand")}
    # extra: DreamZero's own joint-space arm error (not comparable to RAPTOR, for reference)
    dj = d["pred_joint"]; gj = d["gt_joint"]
    arm_idx = list(range(0, 7)) + list(range(29, 36))
    table["DreamZero"]["arm_joint_rmse_rad"] = float(np.sqrt(((dj[..., arm_idx] - gj[..., arm_idx]) ** 2).mean(-1)).mean())

    def paired(a, b):
        diff = per_anchor[a]["pos"] * 0 + 0  # placeholder for shape
        res = {}
        for m, unit in (("pos", "m"), ("rot", "deg"), ("hand", "rad")):
            diff = per_anchor[a][m] - per_anchor[b][m]
            res[m] = {"mean": float(diff.mean()), "sem": float(diff.std(ddof=1) / np.sqrt(len(diff))),
                      "win_rate_a": float((diff < 0).mean()), "unit": unit}
        return res
    comparisons = {}
    if "RAPTOR cascade" in runs:
        comparisons["RAPTOR cascade - DreamZero"] = paired("RAPTOR cascade", "DreamZero")
        comparisons["RAPTOR cascade - RAPTOR coarse_only"] = paired("RAPTOR cascade", "RAPTOR coarse_only")
    comparisons["RAPTOR coarse_only - DreamZero"] = paired("RAPTOR coarse_only", "DreamZero")
    for name in runs:
        if name not in ("RAPTOR cascade", "RAPTOR coarse_only", "DreamZero") and "RAPTOR cascade" in runs:
            comparisons[f"RAPTOR cascade - {name}"] = paired("RAPTOR cascade", name)

    lines = [f"# Open-loop comparison on {args.out}", "",
             f"{n_anchors} anchors from {n_eps} episodes of trex_small_force; 16-step (0.8 s, 20 Hz) chunk; one conditioning frame per camera.", "",
             "| model | EEF pos err [cm] | EEF rot err [deg] | hand RMSE [rad] | pos@0.05s | pos@0.45s | pos@0.8s | hand@0.05s | hand@0.8s | s/anchor |",
             "|---|---|---|---|---|---|---|---|---|---|"]
    for name, row in table.items():
        lines.append(f"| {name} | {100 * row['pos']:.2f} | {row['rot']:.2f} | {row['hand']:.4f} | {100 * row['pos@k0']:.2f} | {100 * row['pos@k8']:.2f} | {100 * row['pos@k15']:.2f} | {row['hand@k0']:.4f} | {row['hand@k15']:.4f} | {row['sec_per_anchor']:.1f} |")
    lines += ["", "Paired differences over anchors (negative = first model better):", ""]
    for name, res in comparisons.items():
        parts = [f"{m}: {v['mean'] * (100 if m == 'pos' else 1):+.3f} ± {v['sem'] * (100 if m == 'pos' else 1):.3f} {'cm' if m == 'pos' else v['unit']} (first better on {100 * v['win_rate_a']:.0f}% of anchors)" for m, v in res.items()]
        lines.append(f"- **{name}**: " + "; ".join(parts))
    lines += ["", f"DreamZero arm error in its own joint space: {table['DreamZero']['arm_joint_rmse_rad']:.4f} rad RMSE (reference only).",
              "", "Caveat: both checkpoints saw these episodes in training (trex_small is a subset of trex_full); this measures fit on the training distribution, not generalization."]
    text = "\n".join(lines)
    print(text)
    (out / "summary.md").write_text(text + "\n")
    json.dump({"table": table, "comparisons": comparisons, "n_anchors": n_anchors, "n_episodes": n_eps},
              open(out / "summary.json", "w"), indent=1)


# ----------------------------------------------------------------------------- main
def main():
    parser = argparse.ArgumentParser()
    sub = parser.add_subparsers(dest="cmd", required=True)
    run = sub.add_parser("run")
    run.add_argument("--model", choices=["raptor", "dreamzero"], required=True)
    run.add_argument("--checkpoint", required=True)
    run.add_argument("--dataset-root", default="data/trex_small_force")
    run.add_argument("--episodes", type=int, default=12)
    run.add_argument("--anchors", type=int, default=8)
    run.add_argument("--out", required=True)
    run.add_argument("--dry-run", action="store_true", help="only select anchors and check ground truth")
    summ = sub.add_parser("summarize")
    summ.add_argument("--out", required=True)
    summ.add_argument("--extra", action="append", help="NAME=path/pred.npz[:key] evaluated on the same anchors")
    args = parser.parse_args()
    if args.cmd == "summarize":
        return summarize(args)
    anchors = load_or_make_anchors(args)
    print(f"{len(anchors)} anchors from {len({e for e, _ in anchors})} episodes", flush=True)
    if args.dry_run:
        builder = tae.ChunkBuilder(args.dataset_root, anchors[0][0])
        gt_eef, gt_joint = ground_truth(builder, anchors[0][1])
        assert np.allclose(gt_eef[:, 9:31], gt_joint[:, 7:29]) and np.allclose(gt_eef[:, 40:62], gt_joint[:, 36:58])
        print("ground-truth hand dims consistent between eef62 and joint58; dry run OK")
        return
    (run_raptor if args.model == "raptor" else run_dreamzero)(args, anchors)


if __name__ == "__main__":
    main()