typoverflow Claude Opus 5 (1M context) commited on
Commit
738325f
·
1 Parent(s): 61d3beb

Add side-by-side rollout vs. ground-truth viewer

Browse files

Gradio app that reads 33-frame critical-window rollouts from a separate HF
dataset repo (default OneWorld-AI/abc-critical-window-rollout).

GT and generated are composed into a single frame-locked video rather than
shown in two independent players, which drift apart and cannot be scrubbed
together. Adds an optional absolute-difference panel, a frame stepper, the
driving action chunk, and filtering/sorting by task, gripper edge, arm and
critical-frame PSNR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Files changed (4) hide show
  1. .gitignore +3 -0
  2. README.md +58 -3
  3. app.py +491 -0
  4. requirements.txt +6 -0
.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ .venv/
2
+ __pycache__/
3
+ .omc/
README.md CHANGED
@@ -1,6 +1,6 @@
1
  ---
2
- title: Test
3
- emoji: 👀
4
  colorFrom: green
5
  colorTo: purple
6
  sdk: gradio
@@ -8,6 +8,61 @@ sdk_version: 6.22.0
8
  python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Rollout vs Ground Truth
3
+ emoji: 🎬
4
  colorFrom: green
5
  colorTo: purple
6
  sdk: gradio
 
8
  python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
11
+ short_description: Frame-locked viewer for rollouts vs. ground truth
12
  ---
13
 
14
+ # Rollout vs. ground truth
15
+
16
+ Side-by-side viewer for forward-dynamics world-model rollouts. Reads windows straight from a
17
+ Hugging Face **dataset** repo — the Space holds no data of its own.
18
+
19
+ Default dataset: [`OneWorld-AI/abc-critical-window-rollout`](https://huggingface.co/datasets/OneWorld-AI/abc-critical-window-rollout)
20
+ (300 held-out ABC bimanual-YAM windows, each a 33-frame slice centred on a gripper critical frame).
21
+
22
+ ## What it shows
23
+
24
+ - **Playback** — GT and generated rendered into one video, so the two panels stay frame-locked
25
+ instead of drifting apart the way two independent players do. Optional `|GT − generated|`
26
+ difference panel. Playback fps is adjustable (the 33-frame window is only ~1 s at native 30 fps).
27
+ - **Frame stepper** — a single frame from both videos side by side, defaulting to frame 16
28
+ (the critical frame).
29
+ - **Driving actions** — the `[33, 14]` action chunk fed to the model, split into grippers /
30
+ left-arm joints / right-arm joints, with the critical frame marked.
31
+ - **All windows** — sortable table of every window with its PSNR; click a row to load it.
32
+
33
+ Filter by task, gripper edge (grasp / release) and arm; sort by critical-frame PSNR to jump
34
+ straight to the hardest or easiest cases.
35
+
36
+ ## Expected dataset layout
37
+
38
+ ```
39
+ windows_manifest.json # list of window dicts: episode_id, task, edge_type, arm,
40
+ # start_frame, critical_frame, amplitude, n_frames, ...
41
+ metrics_summary.json # list of {id, task, edge, arm, full_mse, full_psnr,
42
+ # crit_mse, crit_psnr}
43
+ windows/<episode_id>/gt.mp4 # 33 frames, 3 cameras vstacked (top / L wrist / R wrist)
44
+ windows/<episode_id>/generated.mp4 # model rollout for the same slice, same geometry
45
+ windows/<episode_id>/actions.json # [33, 14] = [L arm 6, L grip, R arm 6, R grip]
46
+ windows/<episode_id>/meta.json
47
+ ```
48
+
49
+ Any dataset matching this layout works. Black letterbox bars are trimmed automatically, and the
50
+ per-camera split is derived from the frame height, so other stacked-camera resolutions are fine.
51
+
52
+ ## Configuration
53
+
54
+ | Variable | Purpose |
55
+ |---|---|
56
+ | `DATASET_REPO` | Dataset repo to read (default `OneWorld-AI/abc-critical-window-rollout`). |
57
+ | `HF_TOKEN` | **Required if the dataset is private.** Add it under *Settings → Variables and secrets → Secrets*. |
58
+
59
+ Only `windows_manifest.json` and `metrics_summary.json` are fetched at startup; videos and actions
60
+ are downloaded per window on demand and cached, as are the composed comparison videos.
61
+
62
+ ## Running locally
63
+
64
+ ```bash
65
+ pip install -r requirements.txt
66
+ export HF_TOKEN=hf_... # if the dataset is private
67
+ python app.py
68
+ ```
app.py ADDED
@@ -0,0 +1,491 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Side-by-side viewer for Cosmos forward-dynamics rollouts vs. ground truth.
3
+
4
+ Data lives in a separate HF dataset repo laid out as:
5
+
6
+ windows_manifest.json # 300 window definitions
7
+ metrics_summary.json # per-window GT-vs-generated MSE / PSNR
8
+ windows/<episode_id>/gt.mp4 # 33 frames, 224x672 (3 cams vstacked)
9
+ windows/<episode_id>/generated.mp4 # same slice, model rollout
10
+ windows/<episode_id>/actions.json # [33, 14] driving action chunk
11
+ windows/<episode_id>/meta.json
12
+
13
+ Set DATASET_REPO to point at a different dataset. If the dataset is private,
14
+ the Space needs an HF_TOKEN secret with read access to it.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import functools
20
+ import json
21
+ import os
22
+ import tempfile
23
+ from pathlib import Path
24
+
25
+ import cv2
26
+ import gradio as gr
27
+ import imageio_ffmpeg
28
+ import numpy as np
29
+ from huggingface_hub import hf_hub_download
30
+ from matplotlib.figure import Figure
31
+
32
+ DATASET_REPO = os.environ.get("DATASET_REPO", "OneWorld-AI/abc-critical-window-rollout")
33
+ HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN") or None
34
+
35
+ CACHE_DIR = Path(tempfile.gettempdir()) / "wmviz_render"
36
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
37
+
38
+ # Each video stacks three 224x224 camera feeds vertically.
39
+ VIEWS = ["All cameras", "Top", "Left wrist", "Right wrist"]
40
+ VIEW_ROW = {"Top": 0, "Left wrist": 1, "Right wrist": 2}
41
+
42
+ FONT = cv2.FONT_HERSHEY_SIMPLEX
43
+ HDR, FTR, SEP = 26, 26, 4
44
+ SCALE = 2 # native frames are only 224 wide; upscale so overlays stay legible
45
+
46
+ GT_COLOR = (130, 225, 140)
47
+ GEN_COLOR = (120, 180, 255)
48
+ DIFF_COLOR = (215, 215, 215)
49
+ CRIT_COLOR = (255, 95, 95)
50
+
51
+ SORTS = [
52
+ "Hardest first (critical PSNR ↑)",
53
+ "Easiest first (critical PSNR ↓)",
54
+ "Task A→Z",
55
+ "Manifest order",
56
+ ]
57
+
58
+
59
+ # --------------------------------------------------------------------------- data
60
+
61
+
62
+ def fetch(rel_path: str) -> str:
63
+ """Download one file from the dataset repo (hf_hub_download caches on disk)."""
64
+ return hf_hub_download(
65
+ repo_id=DATASET_REPO,
66
+ filename=rel_path,
67
+ repo_type="dataset",
68
+ token=HF_TOKEN,
69
+ )
70
+
71
+
72
+ @functools.lru_cache(maxsize=1)
73
+ def load_index() -> list[dict]:
74
+ """Manifest rows joined with their metrics, in manifest order."""
75
+ manifest = json.load(open(fetch("windows_manifest.json")))
76
+ try:
77
+ metrics = json.load(open(fetch("metrics_summary.json")))
78
+ except Exception:
79
+ metrics = []
80
+ by_id = {m["id"]: m for m in metrics}
81
+
82
+ rows = []
83
+ for i, w in enumerate(manifest):
84
+ ep = w["episode_id"]
85
+ m = by_id.get(ep, {})
86
+ rows.append(
87
+ {
88
+ "order": i,
89
+ "episode_id": ep,
90
+ "task": w.get("task", "?"),
91
+ "edge": w.get("edge_type", "?"),
92
+ "arm": w.get("arm", "?"),
93
+ "amplitude": w.get("amplitude"),
94
+ "start_frame": w.get("start_frame"),
95
+ "window": w.get("window"),
96
+ "critical_frame": w.get("critical_frame"),
97
+ "n_frames": w.get("n_frames"),
98
+ "full_psnr": m.get("full_psnr"),
99
+ "crit_psnr": m.get("crit_psnr"),
100
+ "full_mse": m.get("full_mse"),
101
+ "crit_mse": m.get("crit_mse"),
102
+ }
103
+ )
104
+ return rows
105
+
106
+
107
+ def label_for(row: dict) -> str:
108
+ psnr = row["crit_psnr"]
109
+ head = f"{psnr:5.1f} dB" if psnr is not None else " -- dB"
110
+ return f"{head} · {row['edge']} {row['arm']} · {row['task']} · {row['episode_id'][8:16]}"
111
+
112
+
113
+ def filter_rows(query: str, edge: str, arm: str, sort: str) -> list[dict]:
114
+ rows = load_index()
115
+ q = (query or "").strip().lower()
116
+ if q:
117
+ rows = [r for r in rows if q in r["task"].lower() or q in r["episode_id"].lower()]
118
+ if edge != "any":
119
+ rows = [r for r in rows if r["edge"] == edge]
120
+ if arm != "any":
121
+ rows = [r for r in rows if r["arm"] == arm]
122
+
123
+ worst = float("inf")
124
+ if sort == SORTS[0]:
125
+ rows = sorted(rows, key=lambda r: r["crit_psnr"] if r["crit_psnr"] is not None else worst)
126
+ elif sort == SORTS[1]:
127
+ rows = sorted(rows, key=lambda r: -(r["crit_psnr"] if r["crit_psnr"] is not None else -worst))
128
+ elif sort == SORTS[2]:
129
+ rows = sorted(rows, key=lambda r: (r["task"], r["episode_id"]))
130
+ else:
131
+ rows = sorted(rows, key=lambda r: r["order"])
132
+ return rows
133
+
134
+
135
+ @functools.lru_cache(maxsize=64)
136
+ def row_by_id(episode_id: str) -> dict:
137
+ for r in load_index():
138
+ if r["episode_id"] == episode_id:
139
+ return r
140
+ raise KeyError(episode_id)
141
+
142
+
143
+ # --------------------------------------------------------------------------- video
144
+
145
+
146
+ def read_video(path: str) -> np.ndarray:
147
+ """Decode an mp4 into a uint8 [T, H, W, 3] RGB array."""
148
+ cap = cv2.VideoCapture(path)
149
+ frames = []
150
+ while True:
151
+ ok, frame = cap.read()
152
+ if not ok:
153
+ break
154
+ frames.append(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
155
+ cap.release()
156
+ if not frames:
157
+ raise RuntimeError(f"no frames decoded from {path}")
158
+ return np.stack(frames)
159
+
160
+
161
+ @functools.lru_cache(maxsize=4)
162
+ def load_pair(episode_id: str) -> tuple[np.ndarray, np.ndarray]:
163
+ gt = read_video(fetch(f"windows/{episode_id}/gt.mp4"))
164
+ gen = read_video(fetch(f"windows/{episode_id}/generated.mp4"))
165
+ t = min(len(gt), len(gen))
166
+ return gt[:t], gen[:t]
167
+
168
+
169
+ def cam_slices(frames: np.ndarray, view: str) -> list[np.ndarray]:
170
+ """Split the vstacked video into the camera bands requested by `view`."""
171
+ h = frames.shape[1] // 3
172
+ rows = [VIEW_ROW[view]] if view in VIEW_ROW else [0, 1, 2]
173
+ return [frames[:, i * h : (i + 1) * h] for i in rows]
174
+
175
+
176
+ def letterbox_rows(*bands: np.ndarray, thresh: int = 16) -> tuple[int, int]:
177
+ """Rows to keep after dropping the black bars shared by every given band."""
178
+ h = bands[0].shape[1]
179
+ lit = np.zeros(h, bool)
180
+ for b in bands:
181
+ lit |= b.reshape(b.shape[0], h, -1).max(axis=(0, 2)) > thresh
182
+ idx = np.flatnonzero(lit)
183
+ if idx.size < h * 0.1: # nothing meaningful to trim
184
+ return 0, h
185
+ return int(idx[0]), int(idx[-1]) + 1
186
+
187
+
188
+ def crop_view(gt: np.ndarray, gen: np.ndarray, view: str) -> tuple[np.ndarray, np.ndarray]:
189
+ """Select cameras and trim the letterbox, identically for both videos."""
190
+ gt_out, gen_out = [], []
191
+ for g, p in zip(cam_slices(gt, view), cam_slices(gen, view)):
192
+ lo, hi = letterbox_rows(g, p)
193
+ gt_out.append(g[:, lo:hi])
194
+ gen_out.append(p[:, lo:hi])
195
+ return np.concatenate(gt_out, axis=1), np.concatenate(gen_out, axis=1)
196
+
197
+
198
+ def upscale(frames: np.ndarray, scale: int) -> np.ndarray:
199
+ if scale == 1:
200
+ return frames
201
+ return np.stack(
202
+ [cv2.resize(f, None, fx=scale, fy=scale, interpolation=cv2.INTER_NEAREST) for f in frames]
203
+ )
204
+
205
+
206
+ def panel(frames: np.ndarray, title: str, color: tuple[int, int, int]) -> np.ndarray:
207
+ t, h, w, _ = frames.shape
208
+ out = np.zeros((t, HDR + h, w, 3), np.uint8)
209
+ out[:, HDR:] = frames
210
+ bar = np.full((HDR, w, 3), 22, np.uint8)
211
+ cv2.putText(bar, title, (8, HDR - 8), FONT, 0.52, color, 1, cv2.LINE_AA)
212
+ out[:, :HDR] = bar
213
+ return out
214
+
215
+
216
+ def diff_frames(gt: np.ndarray, gen: np.ndarray, gain: float = 3.0) -> np.ndarray:
217
+ d = np.abs(gt.astype(np.int16) - gen.astype(np.int16)).mean(axis=-1)
218
+ d = np.clip(d * gain, 0, 255).astype(np.uint8)
219
+ return np.stack([cv2.applyColorMap(f, cv2.COLORMAP_INFERNO)[:, :, ::-1] for f in d])
220
+
221
+
222
+ @functools.lru_cache(maxsize=2)
223
+ def compose(episode_id: str, view: str, show_diff: bool, crit_idx: int) -> np.ndarray:
224
+ """Build the labelled side-by-side strip: [T, H, W, 3] RGB."""
225
+ gt, gen = load_pair(episode_id)
226
+ gt_c, gen_c = crop_view(gt, gen, view)
227
+ gt_v, gen_v = upscale(gt_c, SCALE), upscale(gen_c, SCALE)
228
+
229
+ panels = [panel(gt_v, "GROUND TRUTH", GT_COLOR), panel(gen_v, "GENERATED", GEN_COLOR)]
230
+ if show_diff:
231
+ panels.append(panel(diff_frames(gt_v, gen_v), "|DIFF| x3", DIFF_COLOR))
232
+
233
+ t, ph, _, _ = panels[0].shape
234
+ gap = np.zeros((t, ph, SEP, 3), np.uint8)
235
+ stitched = [panels[0]]
236
+ for p in panels[1:]:
237
+ stitched += [gap, p]
238
+ comp = np.concatenate(stitched, axis=2)
239
+
240
+ t, h, w, _ = comp.shape
241
+ out = np.zeros((t, h + FTR, w, 3), np.uint8)
242
+ out[:, :h] = comp
243
+ tl0, tl1, y = int(w * 0.45), w - 14, FTR // 2
244
+ for i in range(t):
245
+ bar = np.full((FTR, w, 3), 22, np.uint8)
246
+ is_crit = i == crit_idx
247
+ text = f"frame {i:02d}/{t - 1}" + (" CRITICAL FRAME" if is_crit else "")
248
+ cv2.putText(bar, text, (8, FTR - 8), FONT, 0.5, CRIT_COLOR if is_crit else (200, 200, 200), 1, cv2.LINE_AA)
249
+ cv2.line(bar, (tl0, y), (tl1, y), (70, 70, 70), 2, cv2.LINE_AA)
250
+ if 0 <= crit_idx < t:
251
+ cx = int(tl0 + (tl1 - tl0) * crit_idx / max(t - 1, 1))
252
+ cv2.line(bar, (cx, y - 7), (cx, y + 7), CRIT_COLOR, 2, cv2.LINE_AA)
253
+ px = int(tl0 + (tl1 - tl0) * i / max(t - 1, 1))
254
+ cv2.circle(bar, (px, y), 4, (255, 255, 255), -1, cv2.LINE_AA)
255
+ out[i, h:] = bar
256
+ if is_crit:
257
+ cv2.rectangle(out[i], (0, 0), (w - 1, h + FTR - 1), CRIT_COLOR, 3)
258
+
259
+ # yuv420p needs even dimensions
260
+ ph, pw = out.shape[1] % 2, out.shape[2] % 2
261
+ if ph or pw:
262
+ out = np.pad(out, ((0, 0), (0, ph), (0, pw), (0, 0)))
263
+ return out
264
+
265
+
266
+ def write_mp4(frames: np.ndarray, path: Path, fps: float) -> str:
267
+ t, h, w, _ = frames.shape
268
+ writer = imageio_ffmpeg.write_frames(
269
+ str(path),
270
+ size=(w, h),
271
+ fps=fps,
272
+ codec="libx264",
273
+ pix_fmt_in="rgb24",
274
+ pix_fmt_out="yuv420p",
275
+ macro_block_size=1,
276
+ output_params=["-crf", "16", "-preset", "veryfast", "-movflags", "+faststart"],
277
+ )
278
+ writer.send(None)
279
+ for f in frames:
280
+ writer.send(np.ascontiguousarray(f).tobytes())
281
+ writer.close()
282
+ return str(path)
283
+
284
+
285
+ def render_video(episode_id: str, view: str, show_diff: bool, fps: float, crit_idx: int) -> str:
286
+ key = f"{episode_id}_{view.replace(' ', '')}_{int(show_diff)}_{fps:g}"
287
+ path = CACHE_DIR / f"{key}.mp4"
288
+ if not path.exists():
289
+ write_mp4(compose(episode_id, view, show_diff, crit_idx), path, fps)
290
+ return str(path)
291
+
292
+
293
+ # --------------------------------------------------------------------------- actions plot
294
+
295
+
296
+ def plot_actions(episode_id: str, crit_idx: int, cursor: int | None = None) -> Figure:
297
+ actions = np.asarray(json.load(open(fetch(f"windows/{episode_id}/actions.json"))), dtype=float)
298
+ t = np.arange(len(actions))
299
+
300
+ fig = Figure(figsize=(7.2, 5.4), dpi=110, layout="constrained")
301
+ axes = fig.subplots(3, 1, sharex=True)
302
+ specs = [
303
+ ("Grippers (1.0 = open, 0.0 = closed)", [(6, "L gripper"), (13, "R gripper")]),
304
+ ("Left arm joints", [(i, f"L{i}") for i in range(6)]),
305
+ ("Right arm joints", [(i, f"R{i - 7}") for i in range(7, 13)]),
306
+ ]
307
+ for ax, (title, channels) in zip(axes, specs):
308
+ for idx, name in channels:
309
+ ax.plot(t, actions[:, idx], lw=1.4, label=name)
310
+ ax.set_title(title, fontsize=9, loc="left")
311
+ ax.axvline(crit_idx, color="crimson", ls="--", lw=1.2, zorder=0)
312
+ if cursor is not None:
313
+ ax.axvline(cursor, color="0.35", ls=":", lw=1.0, zorder=0)
314
+ ax.grid(alpha=0.25, lw=0.5)
315
+ ax.tick_params(labelsize=8)
316
+ ax.legend(fontsize=7, ncol=6, loc="upper right", framealpha=0.7)
317
+ axes[-1].set_xlabel("frame in window (dashed red = critical frame)", fontsize=8)
318
+ return fig
319
+
320
+
321
+ # --------------------------------------------------------------------------- ui glue
322
+
323
+
324
+ def metrics_md(row: dict) -> str:
325
+ def fmt(v, unit=""):
326
+ return f"{v:.2f}{unit}" if isinstance(v, (int, float)) else "—"
327
+
328
+ win = row.get("window") or [row["start_frame"], (row["start_frame"] or 0) + 32]
329
+
330
+ return (
331
+ f"### {row['task'].replace('_', ' ')}\n"
332
+ f"`{row['episode_id']}`\n\n"
333
+ f"| | full window | critical frame |\n|---|---|---|\n"
334
+ f"| **PSNR** | {fmt(row['full_psnr'], ' dB')} | {fmt(row['crit_psnr'], ' dB')} |\n"
335
+ f"| **MSE** | {fmt(row['full_mse'])} | {fmt(row['crit_mse'])} |\n\n"
336
+ f"**{row['edge']}** · arm **{row['arm']}** · amplitude {fmt(row['amplitude'])} · "
337
+ f"window frames {win[0]}–{win[1]} of {row['n_frames']} · "
338
+ f"critical frame {row['critical_frame']}"
339
+ )
340
+
341
+
342
+ def table_rows(rows: list[dict]) -> list[list]:
343
+ return [
344
+ [
345
+ r["episode_id"][8:16],
346
+ r["task"],
347
+ r["edge"],
348
+ r["arm"],
349
+ round(r["crit_psnr"], 2) if r["crit_psnr"] is not None else None,
350
+ round(r["full_psnr"], 2) if r["full_psnr"] is not None else None,
351
+ ]
352
+ for r in rows
353
+ ]
354
+
355
+
356
+ def on_filter(query, edge, arm, sort, current):
357
+ rows = filter_rows(query, edge, arm, sort)
358
+ choices = [(label_for(r), r["episode_id"]) for r in rows]
359
+ ids = [r["episode_id"] for r in rows]
360
+ value = current if current in ids else (ids[0] if ids else None)
361
+ return (
362
+ gr.update(choices=choices, value=value, label=f"Window ({len(ids)} of 300)"),
363
+ table_rows(rows),
364
+ ids,
365
+ )
366
+
367
+
368
+ def critical_index(row: dict) -> int:
369
+ """Offset of the critical frame inside the window (16 for 33-frame windows)."""
370
+ start, crit = row.get("start_frame"), row.get("critical_frame")
371
+ if start is None or crit is None:
372
+ return 16
373
+ return int(crit) - int(start)
374
+
375
+
376
+ def on_select(episode_id, view, show_diff, fps, frame_idx):
377
+ if not episode_id:
378
+ return None, None, "*No window matches the current filters.*", None
379
+ row = row_by_id(episode_id)
380
+ crit = critical_index(row)
381
+ video = render_video(episode_id, view, show_diff, fps, crit)
382
+ frames = compose(episode_id, view, show_diff, crit)
383
+ i = int(min(frame_idx, len(frames) - 1))
384
+ return video, frames[i], metrics_md(row), plot_actions(episode_id, crit, i)
385
+
386
+
387
+ def on_frame(episode_id, view, show_diff, frame_idx):
388
+ if not episode_id:
389
+ return None, None
390
+ crit = critical_index(row_by_id(episode_id))
391
+ frames = compose(episode_id, view, show_diff, crit)
392
+ i = int(min(frame_idx, len(frames) - 1))
393
+ return frames[i], plot_actions(episode_id, crit, i)
394
+
395
+
396
+ def step(ids, current, delta):
397
+ if not ids:
398
+ return None
399
+ try:
400
+ i = ids.index(current)
401
+ except ValueError:
402
+ i = 0
403
+ return ids[(i + delta) % len(ids)]
404
+
405
+
406
+ # --------------------------------------------------------------------------- app
407
+
408
+ CSS = """
409
+ .window-video video { max-height: 78vh; object-fit: contain; background: #111; }
410
+ .window-still img { max-height: 78vh; object-fit: contain; background: #111; }
411
+ """
412
+
413
+ with gr.Blocks(title="Rollout vs. ground truth", fill_width=True) as demo:
414
+ gr.Markdown(
415
+ "# Forward-dynamics rollout vs. ground truth\n"
416
+ f"Ground-truth and generated 33-frame windows from **`{DATASET_REPO}`**, "
417
+ "each centred on a gripper critical frame (frame 16). "
418
+ "Cameras are top / left-wrist / right-wrist."
419
+ )
420
+ ids_state = gr.State([])
421
+
422
+ with gr.Row():
423
+ with gr.Column(scale=1, min_width=320):
424
+ query = gr.Textbox(label="Search task or episode", placeholder="e.g. napkin, pillowcase…")
425
+ with gr.Row():
426
+ edge = gr.Radio(["any", "grasp", "release"], value="any", label="Edge")
427
+ arm = gr.Radio(["any", "L", "R"], value="any", label="Arm")
428
+ sort = gr.Dropdown(SORTS, value=SORTS[0], label="Sort")
429
+ picker = gr.Dropdown([], label="Window", filterable=True)
430
+ with gr.Row():
431
+ prev_btn = gr.Button("← Prev")
432
+ next_btn = gr.Button("Next →")
433
+ gr.Markdown("---")
434
+ view = gr.Radio(VIEWS, value="Top", label="Camera")
435
+ show_diff = gr.Checkbox(False, label="Add |GT − generated| difference panel")
436
+ fps = gr.Slider(2, 30, value=8, step=1, label="Playback fps (source is 30 fps)")
437
+ info = gr.Markdown()
438
+
439
+ with gr.Column(scale=2):
440
+ with gr.Tabs():
441
+ with gr.Tab("Playback"):
442
+ video = gr.Video(
443
+ label="GT vs generated",
444
+ autoplay=True,
445
+ loop=True,
446
+ buttons=["download", "fullscreen"],
447
+ elem_classes="window-video",
448
+ )
449
+ with gr.Tab("Frame stepper"):
450
+ frame_idx = gr.Slider(0, 32, value=16, step=1, label="Frame in window (16 = critical)")
451
+ still = gr.Image(label="Frame comparison", elem_classes="window-still")
452
+ with gr.Tab("Driving actions"):
453
+ actions_plot = gr.Plot(label="Action chunk fed to the model [33 x 14]")
454
+ with gr.Tab("All windows"):
455
+ table = gr.Dataframe(
456
+ headers=["episode", "task", "edge", "arm", "crit PSNR", "full PSNR"],
457
+ datatype=["str", "str", "str", "str", "number", "number"],
458
+ interactive=False,
459
+ wrap=True,
460
+ max_height=650,
461
+ )
462
+
463
+ filter_inputs = [query, edge, arm, sort]
464
+ filter_outputs = [picker, table, ids_state]
465
+ view_inputs = [picker, view, show_diff, fps, frame_idx]
466
+ view_outputs = [video, still, info, actions_plot]
467
+
468
+ for control in filter_inputs:
469
+ control.change(on_filter, filter_inputs + [picker], filter_outputs).then(
470
+ on_select, view_inputs, view_outputs
471
+ )
472
+
473
+ picker.change(on_select, view_inputs, view_outputs)
474
+ for control in (view, show_diff, fps):
475
+ control.change(on_select, view_inputs, view_outputs)
476
+ frame_idx.change(on_frame, [picker, view, show_diff, frame_idx], [still, actions_plot])
477
+
478
+ prev_btn.click(lambda i, c: step(i, c, -1), [ids_state, picker], picker)
479
+ next_btn.click(lambda i, c: step(i, c, +1), [ids_state, picker], picker)
480
+
481
+ def on_table_select(ids, evt: gr.SelectData):
482
+ return ids[evt.index[0]] if ids and evt.index[0] < len(ids) else gr.update()
483
+
484
+ table.select(on_table_select, ids_state, picker)
485
+
486
+ demo.load(on_filter, filter_inputs + [picker], filter_outputs).then(
487
+ on_select, view_inputs, view_outputs
488
+ )
489
+
490
+ if __name__ == "__main__":
491
+ demo.queue(max_size=16).launch(css=CSS)
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ gradio==6.22.0
2
+ huggingface_hub>=0.28
3
+ numpy
4
+ opencv-python-headless
5
+ imageio-ffmpeg
6
+ matplotlib