typoverflow Claude Opus 5 (1M context) commited on
Commit
2f19736
·
1 Parent(s): 3b57e9a

Replace the page with a Rerun-only viewer

Browse files

Drops the composed-video, frame-stepper and action-plot tabs. The page is now
an episode list with search, sort and PSNR/MSE, next to an embedded Rerun
viewer showing ground truth and generated side by side on one frame timeline.

Starts empty and assumes no dataset: the repo is typed in, nothing is loaded
until Load is pressed.

Two viewer bugs fixed along the way: ssr_mode must be off, since hydration
re-mounts the wasm canvas, and .change() defaults to trigger_mode
"always_last", which let two runs write into one viewer concurrently.

Pins gradio 6.5.1 to match rerun/gradio-rerun-viewer, the reference Space
known to work with gradio_rerun 0.35.0.

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

Files changed (3) hide show
  1. README.md +1 -1
  2. app.py +126 -549
  3. requirements.txt +2 -4
README.md CHANGED
@@ -4,7 +4,7 @@ emoji: 🎬
4
  colorFrom: green
5
  colorTo: purple
6
  sdk: gradio
7
- sdk_version: 6.22.0
8
  python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
 
4
  colorFrom: green
5
  colorTo: purple
6
  sdk: gradio
7
+ sdk_version: 6.5.1
8
  python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
app.py CHANGED
@@ -1,5 +1,5 @@
1
  """
2
- Side-by-side viewer for forward-dynamics rollouts vs. ground truth.
3
 
4
  Point it at any HF dataset repo laid out as:
5
 
@@ -7,20 +7,17 @@ Point it at any HF dataset repo laid out as:
7
  metrics_summary.json # optional: per-window MSE / PSNR
8
  windows/<episode_id>/gt.mp4 # ground-truth window
9
  windows/<episode_id>/generated.mp4 # model rollout for the same slice
10
- windows/<episode_id>/actions.json # optional: [T, D] driving actions
11
- windows/<episode_id>/meta.json # optional: per-window metadata
12
 
13
- Only `windows/<id>/{gt,generated}.mp4` is required. Without a manifest the
14
- window list is discovered by listing `windows/` one level deep. Videos may
15
- stack N cameras vertically; N is inferred from the frame aspect ratio.
16
 
17
  Nothing is downloaded at build time and no repo is ever pulled whole. Each
18
- browser session gets its own scratch directory; window files arrive only when
19
- that window is opened, at most MAX_WINDOWS_ON_DISK are kept, and the whole
20
- directory is deleted when the session ends.
21
 
22
- DATASET_REPO sets the repo prefilled at startup. Private repos need an
23
- HF_TOKEN secret with read access.
24
  """
25
 
26
  from __future__ import annotations
@@ -38,15 +35,12 @@ from pathlib import Path
38
 
39
  import cv2
40
  import gradio as gr
41
- import imageio_ffmpeg
42
  import numpy as np
43
  import rerun as rr
44
  import rerun.blueprint as rrb
45
  from gradio_rerun import Rerun
46
  from huggingface_hub import HfApi, RepoFolder, hf_hub_download
47
- from matplotlib.figure import Figure
48
 
49
- DEFAULT_REPO = os.environ.get("DATASET_REPO", "OneWorld-AI/abc-critical-window-rollout")
50
  HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN") or None
51
 
52
  BASE_DIR = Path(tempfile.gettempdir()) / "wmviz"
@@ -54,29 +48,14 @@ BASE_DIR.mkdir(parents=True, exist_ok=True)
54
 
55
  MAX_WINDOWS_ON_DISK = int(os.environ.get("MAX_WINDOWS_ON_DISK", 12))
56
  MAX_PAIRS_IN_RAM = 2
57
- MAX_COMPOSED_IN_RAM = 1
58
  MAX_DISCOVER = int(os.environ.get("MAX_DISCOVER", 5000))
59
  SESSION_TTL = int(os.environ.get("SESSION_TTL", 3600))
60
-
61
- # Conventional names when a video stacks exactly three cameras.
62
- THREE_CAM_NAMES = ["Top", "Left wrist", "Right wrist"]
63
- ALL_CAMS = -1
64
- NO_CRIT = -1
65
-
66
- FONT = cv2.FONT_HERSHEY_SIMPLEX
67
- HDR, FTR, SEP = 26, 26, 4
68
- SCALE = 2 # source frames are small; upscale so overlays stay legible
69
- JPEG_QUALITY = 90 # raw frames make a ~16 MB recording; JPEG brings it to ~1.5 MB
70
-
71
- GT_COLOR = (130, 225, 140)
72
- GEN_COLOR = (120, 180, 255)
73
- DIFF_COLOR = (215, 215, 215)
74
- CRIT_COLOR = (255, 95, 95)
75
 
76
  SORTS = [
77
- "Hardest first (critical PSNR ↑)",
78
- "Easiest first (critical PSNR ↓)",
79
- "Task A→Z",
80
  "Manifest order",
81
  ]
82
 
@@ -109,15 +88,10 @@ class Session:
109
  self.repo = ""
110
  self.revision: str | None = None
111
  self.rows: list[dict] = []
112
- self.note = ""
113
  self._on_disk: OrderedDict[str, bool] = OrderedDict()
114
  self._pairs: OrderedDict[str, tuple[np.ndarray, np.ndarray]] = OrderedDict()
115
- self._composed: OrderedDict[tuple, np.ndarray] = OrderedDict()
116
- self._meta: dict[str, dict] = {}
117
  self.touched = time.time()
118
 
119
- # -- fetching ----------------------------------------------------------
120
-
121
  def fetch(self, rel: str, optional: bool = False) -> str | None:
122
  """Download one repo file into this session's directory."""
123
  self.touched = time.time()
@@ -149,16 +123,12 @@ class Session:
149
  old, _ = self._on_disk.popitem(last=False)
150
  shutil.rmtree(self.data / "windows" / old, ignore_errors=True)
151
  self._pairs.pop(old, None)
152
- for key in [k for k in self._composed if k[0] == old]:
153
- self._composed.pop(key, None)
154
  for f in self.render.glob(f"{old}_*"):
155
  f.unlink(missing_ok=True)
156
 
157
  def disk_windows(self) -> int:
158
  return len(self._on_disk)
159
 
160
- # -- bounded RAM caches ------------------------------------------------
161
-
162
  def pair(self, episode_id: str) -> tuple[np.ndarray, np.ndarray]:
163
  hit = self._pairs.get(episode_id)
164
  if hit is not None:
@@ -173,29 +143,6 @@ class Session:
173
  self._pairs.popitem(last=False)
174
  return value
175
 
176
- def composed(self, episode_id: str, view: int, show_diff: bool, crit: int) -> np.ndarray:
177
- key = (episode_id, view, show_diff, crit)
178
- hit = self._composed.get(key)
179
- if hit is not None:
180
- self._composed.move_to_end(key)
181
- return hit
182
- value = compose(self.pair(episode_id), view, show_diff, crit)
183
- self._composed[key] = value
184
- while len(self._composed) > MAX_COMPOSED_IN_RAM:
185
- self._composed.popitem(last=False)
186
- return value
187
-
188
- def meta(self, episode_id: str) -> dict:
189
- if episode_id not in self._meta:
190
- path = self.window_file(episode_id, "meta.json", optional=True)
191
- try:
192
- self._meta[episode_id] = json.load(open(path)) if path else {}
193
- except Exception:
194
- self._meta[episode_id] = {}
195
- return self._meta[episode_id]
196
-
197
- # -- lifecycle ---------------------------------------------------------
198
-
199
  def set_repo(self, repo: str, revision: str | None) -> None:
200
  if (repo, revision) != (self.repo, self.revision):
201
  self.reset()
@@ -204,8 +151,6 @@ class Session:
204
  def reset(self) -> None:
205
  self._on_disk.clear()
206
  self._pairs.clear()
207
- self._composed.clear()
208
- self._meta.clear()
209
  self.rows = []
210
  for d in (self.data, self.render):
211
  shutil.rmtree(d, ignore_errors=True)
@@ -213,7 +158,6 @@ class Session:
213
 
214
  def close(self) -> None:
215
  self._pairs.clear()
216
- self._composed.clear()
217
  shutil.rmtree(self.root, ignore_errors=True)
218
 
219
 
@@ -249,7 +193,7 @@ def get_session(sid: str | None) -> Session:
249
 
250
 
251
  def release(sid: str | None) -> None:
252
- """gr.State delete_callback — fires when the session ends or its TTL expires."""
253
  with _LOCK:
254
  s = SESSIONS.pop(sid or "", None)
255
  if s is not None:
@@ -266,11 +210,8 @@ def _row(order: int, episode_id: str, w: dict, m: dict) -> dict:
266
  "task": w.get("task") or "?",
267
  "edge": w.get("edge_type") or "?",
268
  "arm": w.get("arm") or "?",
269
- "amplitude": w.get("amplitude"),
270
- "start_frame": w.get("start_frame"),
271
- "window": w.get("window"),
272
  "critical_frame": w.get("critical_frame"),
273
- "n_frames": w.get("n_frames"),
274
  "full_psnr": m.get("full_psnr"),
275
  "crit_psnr": m.get("crit_psnr"),
276
  "full_mse": m.get("full_mse"),
@@ -330,7 +271,7 @@ def build_index(session: Session) -> str:
330
  eps, truncated = discover_episodes(session)
331
  if not eps:
332
  raise FileNotFoundError(
333
- "no windows/<episode_id>/ directories found — is this the right repo?"
334
  )
335
  session.rows = [_row(i, ep, {}, by_id.get(ep, {})) for i, ep in enumerate(eps)]
336
  note = "directory listing (no `windows_manifest.json`)"
@@ -351,18 +292,14 @@ def label_for(row: dict) -> str:
351
  head = f"{psnr:5.1f} dB" if isinstance(psnr, (int, float)) else " -- dB"
352
  tags = " ".join(t for t in (row["edge"], row["arm"]) if t and t != "?")
353
  parts = [head] + ([tags] if tags else []) + [row["task"], short_id(row["episode_id"])]
354
- return " · ".join(parts)
355
 
356
 
357
- def filter_rows(session: Session, query: str, edge: str, arm: str, sort: str) -> list[dict]:
358
  rows = session.rows
359
  q = (query or "").strip().lower()
360
  if q:
361
  rows = [r for r in rows if q in r["task"].lower() or q in r["episode_id"].lower()]
362
- if edge != "any":
363
- rows = [r for r in rows if r["edge"] == edge]
364
- if arm != "any":
365
- rows = [r for r in rows if r["arm"] == arm]
366
 
367
  def psnr(r, missing):
368
  v = r["crit_psnr"]
@@ -380,19 +317,8 @@ def filter_rows(session: Session, query: str, edge: str, arm: str, sort: str) ->
380
  def row_by_id(session: Session, episode_id: str) -> dict:
381
  for r in session.rows:
382
  if r["episode_id"] == episode_id:
383
- row = dict(r)
384
- break
385
- else:
386
- row = _row(0, episode_id, {}, {})
387
- # backfill whatever the manifest did not carry from the window's own meta.json
388
- if row["task"] == "?" or row["critical_frame"] is None:
389
- meta = session.meta(episode_id)
390
- if meta:
391
- merged = _row(row["order"], episode_id, meta, {})
392
- for k, v in merged.items():
393
- if row.get(k) in (None, "?"):
394
- row[k] = v
395
- return row
396
 
397
 
398
  # --------------------------------------------------------------------------- video
@@ -413,326 +339,41 @@ def read_video(path: str) -> np.ndarray:
413
  return np.stack(frames)
414
 
415
 
416
- def n_cameras(frames: np.ndarray) -> int:
417
- """How many roughly-square cameras are stacked vertically in the frame."""
418
- h, w = frames.shape[1], frames.shape[2]
419
- return max(1, min(6, int(round(h / w))))
420
-
421
-
422
- def camera_choices(n: int) -> list[tuple[str, int]]:
423
- choices = [(name, i) for i, name in enumerate(camera_names(n))]
424
- return ([("All cameras", ALL_CAMS)] + choices) if n > 1 else choices
425
-
426
-
427
- def camera_names(n: int) -> list[str]:
428
- return THREE_CAM_NAMES if n == 3 else [f"Camera {i + 1}" for i in range(n)]
429
-
430
-
431
- def selected_cameras(n: int, view: int) -> list[tuple[int, str, str]]:
432
- """(band index, entity-path key, display label) for the cameras `view` selects."""
433
- names = camera_names(n)
434
- idxs = range(n) if view == ALL_CAMS or not 0 <= view < n else [view]
435
- return [(i, re.sub(r"[^a-z0-9]+", "_", names[i].lower()), names[i]) for i in idxs]
436
-
437
-
438
- def cam_slices(frames: np.ndarray, view: int) -> list[np.ndarray]:
439
- n = n_cameras(frames)
440
- h = frames.shape[1] // n
441
- rows = range(n) if view == ALL_CAMS or not 0 <= view < n else [view]
442
- return [frames[:, i * h : (i + 1) * h] for i in rows]
443
-
444
-
445
- def letterbox_rows(*bands: np.ndarray, thresh: int = 16) -> tuple[int, int]:
446
- """Rows to keep after dropping the black bars shared by every given band."""
447
- h = bands[0].shape[1]
448
- lit = np.zeros(h, bool)
449
- for b in bands:
450
- lit |= b.reshape(b.shape[0], h, -1).max(axis=(0, 2)) > thresh
451
- idx = np.flatnonzero(lit)
452
- if idx.size < h * 0.1: # nothing meaningful to trim
453
- return 0, h
454
- return int(idx[0]), int(idx[-1]) + 1
455
-
456
-
457
- def crop_view(gt: np.ndarray, gen: np.ndarray, view: int) -> tuple[np.ndarray, np.ndarray]:
458
- """Select cameras and trim the letterbox, identically for both videos."""
459
- gt_out, gen_out = [], []
460
- for g, p in zip(cam_slices(gt, view), cam_slices(gen, view)):
461
- lo, hi = letterbox_rows(g, p)
462
- gt_out.append(g[:, lo:hi])
463
- gen_out.append(p[:, lo:hi])
464
- return np.concatenate(gt_out, axis=1), np.concatenate(gen_out, axis=1)
465
-
466
-
467
- def upscale(frames: np.ndarray, scale: int) -> np.ndarray:
468
- if scale == 1:
469
- return frames
470
- return np.stack(
471
- [cv2.resize(f, None, fx=scale, fy=scale, interpolation=cv2.INTER_NEAREST) for f in frames]
472
- )
473
-
474
-
475
- def panel(frames: np.ndarray, title: str, color: tuple[int, int, int]) -> np.ndarray:
476
- t, h, w, _ = frames.shape
477
- out = np.zeros((t, HDR + h, w, 3), np.uint8)
478
- out[:, HDR:] = frames
479
- bar = np.full((HDR, w, 3), 22, np.uint8)
480
- cv2.putText(bar, title, (8, HDR - 8), FONT, 0.52, color, 1, cv2.LINE_AA)
481
- out[:, :HDR] = bar
482
- return out
483
-
484
-
485
- def diff_frames(gt: np.ndarray, gen: np.ndarray, gain: float = 3.0) -> np.ndarray:
486
- d = np.abs(gt.astype(np.int16) - gen.astype(np.int16)).mean(axis=-1)
487
- d = np.clip(d * gain, 0, 255).astype(np.uint8)
488
- return np.stack([cv2.applyColorMap(f, cv2.COLORMAP_INFERNO)[:, :, ::-1] for f in d])
489
-
490
-
491
- def compose(pair: tuple[np.ndarray, np.ndarray], view: int, show_diff: bool, crit_idx: int) -> np.ndarray:
492
- """Build the labelled side-by-side strip: [T, H, W, 3] RGB."""
493
- gt_c, gen_c = crop_view(pair[0], pair[1], view)
494
- gt_v, gen_v = upscale(gt_c, SCALE), upscale(gen_c, SCALE)
495
-
496
- panels = [panel(gt_v, "GROUND TRUTH", GT_COLOR), panel(gen_v, "GENERATED", GEN_COLOR)]
497
- if show_diff:
498
- panels.append(panel(diff_frames(gt_v, gen_v), "|DIFF| x3", DIFF_COLOR))
499
-
500
- t, ph, _, _ = panels[0].shape
501
- gap = np.zeros((t, ph, SEP, 3), np.uint8)
502
- stitched = [panels[0]]
503
- for p in panels[1:]:
504
- stitched += [gap, p]
505
- comp = np.concatenate(stitched, axis=2)
506
-
507
- t, h, w, _ = comp.shape
508
- out = np.zeros((t, h + FTR, w, 3), np.uint8)
509
- out[:, :h] = comp
510
- has_crit = 0 <= crit_idx < t
511
- tl0, tl1, y = int(w * 0.45), w - 14, FTR // 2
512
- for i in range(t):
513
- bar = np.full((FTR, w, 3), 22, np.uint8)
514
- is_crit = has_crit and i == crit_idx
515
- text = f"frame {i:02d}/{t - 1}" + (" CRITICAL FRAME" if is_crit else "")
516
- cv2.putText(bar, text, (8, FTR - 8), FONT, 0.5, CRIT_COLOR if is_crit else (200, 200, 200), 1, cv2.LINE_AA)
517
- cv2.line(bar, (tl0, y), (tl1, y), (70, 70, 70), 2, cv2.LINE_AA)
518
- if has_crit:
519
- cx = int(tl0 + (tl1 - tl0) * crit_idx / max(t - 1, 1))
520
- cv2.line(bar, (cx, y - 7), (cx, y + 7), CRIT_COLOR, 2, cv2.LINE_AA)
521
- px = int(tl0 + (tl1 - tl0) * i / max(t - 1, 1))
522
- cv2.circle(bar, (px, y), 4, (255, 255, 255), -1, cv2.LINE_AA)
523
- out[i, h:] = bar
524
- if is_crit:
525
- cv2.rectangle(out[i], (0, 0), (w - 1, h + FTR - 1), CRIT_COLOR, 3)
526
-
527
- # yuv420p needs even dimensions
528
- ph, pw = out.shape[1] % 2, out.shape[2] % 2
529
- if ph or pw:
530
- out = np.pad(out, ((0, 0), (0, ph), (0, pw), (0, 0)))
531
- return out
532
-
533
-
534
- def write_mp4(frames: np.ndarray, path: Path, fps: float) -> str:
535
- _, h, w, _ = frames.shape
536
- writer = imageio_ffmpeg.write_frames(
537
- str(path),
538
- size=(w, h),
539
- fps=fps,
540
- codec="libx264",
541
- pix_fmt_in="rgb24",
542
- pix_fmt_out="yuv420p",
543
- macro_block_size=1,
544
- output_params=["-crf", "16", "-preset", "veryfast", "-movflags", "+faststart"],
545
- )
546
- writer.send(None)
547
- for f in frames:
548
- writer.send(np.ascontiguousarray(f).tobytes())
549
- writer.close()
550
- return str(path)
551
-
552
-
553
- def render_video(session: Session, episode_id: str, view: int, show_diff: bool, fps: float, crit: int) -> str:
554
- path = session.render / f"{episode_id}_{view}_{int(show_diff)}_{fps:g}.mp4"
555
- if not path.exists():
556
- write_mp4(session.composed(episode_id, view, show_diff, crit), path, fps)
557
- return str(path)
558
-
559
-
560
- # --------------------------------------------------------------------------- actions plot
561
-
562
-
563
- def load_actions(session: Session, episode_id: str) -> np.ndarray | None:
564
- path = session.window_file(episode_id, "actions.json", optional=True)
565
- if not path:
566
- return None
567
- try:
568
- return np.atleast_2d(np.asarray(json.load(open(path)), dtype=float))
569
- except Exception:
570
- return None
571
-
572
-
573
- def plot_actions(session: Session, episode_id: str, crit_idx: int, cursor: int | None) -> Figure | None:
574
- actions = load_actions(session, episode_id)
575
- if actions is None:
576
- return None
577
-
578
- t = np.arange(len(actions))
579
- dim = actions.shape[1]
580
- if dim == 14: # bimanual: [L arm 6, L grip, R arm 6, R grip]
581
- specs = [
582
- ("Grippers (1.0 = open, 0.0 = closed)", [(6, "L gripper"), (13, "R gripper")]),
583
- ("Left arm joints", [(i, f"L{i}") for i in range(6)]),
584
- ("Right arm joints", [(i, f"R{i - 7}") for i in range(7, 13)]),
585
- ]
586
- else:
587
- specs = [(f"Action channels [{len(actions)} x {dim}]", [(i, f"a{i}") for i in range(dim)])]
588
-
589
- fig = Figure(figsize=(7.2, 1.8 * len(specs) + 0.6), dpi=110, layout="constrained")
590
- axes = np.atleast_1d(fig.subplots(len(specs), 1, sharex=True))
591
- for ax, (title, channels) in zip(axes, specs):
592
- for idx, name in channels:
593
- ax.plot(t, actions[:, idx], lw=1.4, label=name)
594
- ax.set_title(title, fontsize=9, loc="left")
595
- if crit_idx >= 0:
596
- ax.axvline(crit_idx, color="crimson", ls="--", lw=1.2, zorder=0)
597
- if cursor is not None:
598
- ax.axvline(cursor, color="0.35", ls=":", lw=1.0, zorder=0)
599
- ax.grid(alpha=0.25, lw=0.5)
600
- ax.tick_params(labelsize=8)
601
- if len(channels) <= 8:
602
- ax.legend(fontsize=7, ncol=6, loc="upper right", framealpha=0.7)
603
- tail = " (dashed red = critical frame)" if crit_idx >= 0 else ""
604
- axes[-1].set_xlabel(f"frame in window{tail}", fontsize=8)
605
- return fig
606
-
607
-
608
- # --------------------------------------------------------------------------- rerun recording
609
-
610
-
611
- def action_channels(dim: int) -> list[tuple[str, int]]:
612
- """(entity path, column) for each action channel."""
613
- if dim == 14: # bimanual: [L arm 6, L grip, R arm 6, R grip]
614
- return (
615
- [("actions/gripper/L", 6), ("actions/gripper/R", 13)]
616
- + [(f"actions/arm_L/j{i}", i) for i in range(6)]
617
- + [(f"actions/arm_R/j{i - 7}", i) for i in range(7, 13)]
618
- )
619
- return [(f"actions/a{i}", i) for i in range(dim)]
620
-
621
-
622
- def blueprint_for(cams: list[tuple[int, str, str]], show_diff: bool, actions_origin: str | None):
623
- """One row per camera (GT | generated | diff), with the actions plot underneath."""
624
- rows, shares = [], []
625
- for _, key, label in cams:
626
- views = [
627
- rrb.Spatial2DView(origin=f"gt/{key}", name=f"GT · {label}"),
628
- rrb.Spatial2DView(origin=f"generated/{key}", name=f"Generated · {label}"),
629
- ]
630
- if show_diff:
631
- views.append(rrb.Spatial2DView(origin=f"diff/{key}", name=f"|diff| · {label}"))
632
- rows.append(rrb.Horizontal(*views))
633
- shares.append(3)
634
- if actions_origin:
635
- rows.append(rrb.TimeSeriesView(origin=actions_origin, name="Driving actions"))
636
- shares.append(2)
637
- return rrb.Blueprint(rrb.Vertical(*rows, row_shares=shares), collapse_panels=True)
638
-
639
-
640
- def build_rrd(session: Session, episode_id: str, view: int, show_diff: bool, crit: int) -> str:
641
- """Log the window to a .rrd: every stream on one shared frame timeline."""
642
- path = session.render / f"{episode_id}_{view}_{int(show_diff)}.rrd"
643
- if path.exists():
644
- return str(path)
645
-
646
- gt, gen = session.pair(episode_id)
647
- n = n_cameras(gt)
648
- band = gt.shape[1] // n
649
- cams = selected_cameras(n, view)
650
- actions = load_actions(session, episode_id)
651
- channels = action_channels(actions.shape[1]) if actions is not None else []
652
- actions_origin = None
653
- if actions is not None:
654
- actions_origin = "actions/gripper" if actions.shape[1] == 14 else "actions"
655
-
656
- rec = rr.RecordingStream("wmviz", recording_id=path.stem)
657
- for i in range(len(gt)):
658
- rr.set_time("frame", sequence=i, recording=rec)
659
- for c, key, _ in cams:
660
- g = gt[i, c * band : (c + 1) * band]
661
- p = gen[i, c * band : (c + 1) * band]
662
- rr.log(f"gt/{key}", rr.Image(g).compress(jpeg_quality=JPEG_QUALITY), recording=rec)
663
- rr.log(f"generated/{key}", rr.Image(p).compress(jpeg_quality=JPEG_QUALITY), recording=rec)
664
- if show_diff:
665
- d = diff_frames(g[None], p[None])[0]
666
- rr.log(f"diff/{key}", rr.Image(d).compress(jpeg_quality=JPEG_QUALITY), recording=rec)
667
- if actions is not None and i < len(actions):
668
- for entity, col in channels:
669
- rr.log(entity, rr.Scalars(float(actions[i, col])), recording=rec)
670
- if i == crit:
671
- rr.log("critical_frame", rr.TextLog("critical frame"), recording=rec)
672
-
673
- rec.save(str(path), default_blueprint=blueprint_for(cams, show_diff, actions_origin))
674
- return str(path)
675
-
676
-
677
  # --------------------------------------------------------------------------- ui glue
678
 
679
 
680
- def metrics_md(session: Session, row: dict) -> str:
681
  def fmt(v, unit=""):
682
- return f"{v:.2f}{unit}" if isinstance(v, (int, float)) else "—"
683
-
684
- out = [f"### {row['task'].replace('_', ' ')}", f"`{row['episode_id']}`", ""]
685
- if any(isinstance(row[k], (int, float)) for k in ("full_psnr", "crit_psnr", "full_mse", "crit_mse")):
686
- out += [
687
- "| | full window | critical frame |",
688
- "|---|---|---|",
689
- f"| **PSNR** | {fmt(row['full_psnr'], ' dB')} | {fmt(row['crit_psnr'], ' dB')} |",
690
- f"| **MSE** | {fmt(row['full_mse'])} | {fmt(row['crit_mse'])} |",
691
- "",
692
- ]
693
- bits = []
694
- if row["edge"] != "?":
695
- bits.append(f"**{row['edge']}**")
696
- if row["arm"] != "?":
697
- bits.append(f"arm **{row['arm']}**")
698
- if isinstance(row["amplitude"], (int, float)):
699
- bits.append(f"amplitude {fmt(row['amplitude'])}")
700
- win = row.get("window")
701
- if isinstance(win, (list, tuple)) and len(win) == 2:
702
- bits.append(f"window frames {win[0]}–{win[1]}")
703
- if row["n_frames"]:
704
- bits.append(f"of {row['n_frames']}")
705
  if row["critical_frame"] is not None:
706
  bits.append(f"critical frame {row['critical_frame']}")
707
  if bits:
708
- out.append(" · ".join(bits))
709
- out.append(
710
- f"\n<sub>cached this session: {session.disk_windows()}/{MAX_WINDOWS_ON_DISK} windows "
711
- "— evicted least-recently-used, all deleted when you close the tab</sub>"
712
- )
713
  return "\n".join(out)
714
 
715
 
716
- def table_rows(rows: list[dict]) -> list[list]:
717
- def num(v):
718
- return round(v, 2) if isinstance(v, (int, float)) else None
719
-
720
- return [
721
- [short_id(r["episode_id"]), r["task"], r["edge"], r["arm"], num(r["crit_psnr"]), num(r["full_psnr"])]
722
- for r in rows
723
- ]
724
-
725
-
726
  def critical_index(row: dict) -> int:
727
- """Offset of the critical frame inside the window, or NO_CRIT."""
728
  start, crit = row.get("start_frame"), row.get("critical_frame")
729
  if start is None or crit is None:
730
- return NO_CRIT
731
  return int(crit) - int(start)
732
 
733
 
734
  def on_load(repo_text, sid):
735
- """Resolve a repo and load only its index — no window data is pulled here."""
736
  sweep()
737
  session = get_session(sid)
738
  repo, revision = parse_repo(repo_text)
@@ -744,74 +385,69 @@ def on_load(repo_text, sid):
744
  except Exception as exc:
745
  session.rows = []
746
  gr.Warning(f"Could not load {repo}: {exc}")
747
- return session.sid, f"❌ **{repo}** — {exc}"
748
  at = f"@{revision}" if revision else ""
749
- return session.sid, (
750
- f"✅ **{repo}{at}** — {len(session.rows)} windows from {note}. "
751
- "Window files download only when you open them."
752
- )
753
 
754
 
755
- def on_filter(sid, query, edge, arm, sort, current):
756
  session = get_session(sid)
757
  if not session.rows:
758
- return gr.update(choices=[], value=None), [], []
759
- rows = filter_rows(session, query, edge, arm, sort)
760
  choices = [(label_for(r), r["episode_id"]) for r in rows]
761
  ids = [r["episode_id"] for r in rows]
762
  value = current if current in ids else (ids[0] if ids else None)
763
  return (
764
- gr.update(choices=choices, value=value, label=f"Window ({len(ids)} of {len(session.rows)})"),
765
- table_rows(rows),
766
  ids,
767
  )
768
 
769
 
770
- def on_select(sid, episode_id, view, show_diff, fps, frame_idx):
771
- session = get_session(sid)
772
- if not session.rows or not episode_id:
773
- return None, None, "*No window selected.*", None, gr.update(), gr.update()
774
- row = row_by_id(session, episode_id)
775
- crit = critical_index(row)
776
-
777
- cams = n_cameras(session.pair(episode_id)[0])
778
- choices = camera_choices(cams)
779
- valid = [v for _, v in choices]
780
- view = view if view in valid else choices[0][1]
781
- cam_update = gr.update(choices=choices, value=view)
782
-
783
- video = render_video(session, episode_id, view, show_diff, fps, crit)
784
- frames = session.composed(episode_id, view, show_diff, crit)
785
- last = len(frames) - 1
786
- i = int(min(frame_idx, last))
787
- plot = plot_actions(session, episode_id, crit, i)
788
- slider = gr.update(maximum=last, value=i, label=f"Frame in window (0–{last})")
789
- return video, frames[i], metrics_md(session, row), plot, slider, cam_update
790
-
791
-
792
- def on_frame(sid, episode_id, view, show_diff, frame_idx):
793
  session = get_session(sid)
794
  if not session.rows or not episode_id:
795
- return None, None
796
- crit = critical_index(row_by_id(session, episode_id))
797
- frames = session.composed(episode_id, view, show_diff, crit)
798
- i = int(min(frame_idx, len(frames) - 1))
799
- return frames[i], plot_actions(session, episode_id, crit, i)
800
 
 
 
801
 
802
- def on_rerun(sid, episode_id, view, show_diff):
 
 
 
803
  session = get_session(sid)
804
  if not session.rows or not episode_id:
805
- return None
806
- crit = critical_index(row_by_id(session, episode_id))
807
- return build_rrd(session, episode_id, view, show_diff, crit)
808
 
 
 
 
809
 
810
- def on_rerun_if_active(active, sid, episode_id, view, show_diff):
811
- """Only build a recording while the Rerun tab is the one on screen."""
812
- if active != "Rerun":
813
- return gr.skip()
814
- return on_rerun(sid, episode_id, view, show_diff)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
815
 
816
 
817
  def step(ids, current, delta):
@@ -826,124 +462,65 @@ def step(ids, current, delta):
826
 
827
  # --------------------------------------------------------------------------- app
828
 
829
- CSS = """
830
- .window-video video { max-height: 78vh; object-fit: contain; background: #111; }
831
- .window-still img { max-height: 78vh; object-fit: contain; background: #111; }
832
- """
833
-
834
  with gr.Blocks(title="Rollout vs. ground truth", fill_width=True, delete_cache=(600, 600)) as demo:
835
- gr.Markdown("# Forward-dynamics rollout vs. ground truth")
836
-
837
  sid_state = gr.State("", time_to_live=SESSION_TTL, delete_callback=release)
838
  ids_state = gr.State([])
839
- active_tab = gr.State("Playback")
840
 
841
  with gr.Row():
842
- repo_box = gr.Textbox(
843
- DEFAULT_REPO,
844
- label="Dataset repo",
845
- info="owner/name, optionally owner/name@revision. A repo URL also works.",
846
- scale=5,
847
- submit_btn=True,
848
- )
849
- load_btn = gr.Button("Load", variant="primary", scale=1)
850
- status = gr.Markdown()
851
-
852
- with gr.Row():
853
- with gr.Column(scale=1, min_width=320):
854
- query = gr.Textbox(label="Search task or episode", placeholder="e.g. napkin, pillowcase…")
855
  with gr.Row():
856
- edge = gr.Radio(["any", "grasp", "release"], value="any", label="Edge")
857
- arm = gr.Radio(["any", "L", "R"], value="any", label="Arm")
 
 
 
 
 
 
858
  sort = gr.Dropdown(SORTS, value=SORTS[0], label="Sort")
859
- picker = gr.Dropdown([], label="Window", filterable=True)
860
  with gr.Row():
861
- prev_btn = gr.Button("← Prev")
862
- next_btn = gr.Button("Next →")
863
- gr.Markdown("---")
864
- view = gr.Radio(camera_choices(3), value=0, label="Camera")
865
- show_diff = gr.Checkbox(False, label="Add |GT − generated| difference panel")
866
- fps = gr.Slider(2, 30, value=8, step=1, label="Playback fps")
867
  info = gr.Markdown()
868
 
869
- with gr.Column(scale=2):
870
- with gr.Tabs():
871
- with gr.Tab("Playback") as tab_playback:
872
- video = gr.Video(
873
- label="GT vs generated",
874
- autoplay=True,
875
- loop=True,
876
- buttons=["download", "fullscreen"],
877
- elem_classes="window-video",
878
- )
879
- with gr.Tab("Frame stepper") as tab_stepper:
880
- frame_idx = gr.Slider(0, 32, value=16, step=1, label="Frame in window")
881
- still = gr.Image(label="Frame comparison", elem_classes="window-still")
882
- with gr.Tab("Driving actions") as tab_actions:
883
- actions_plot = gr.Plot(label="Driving action chunk")
884
- with gr.Tab("Rerun") as tab_rerun:
885
- gr.Markdown(
886
- "Every stream on one shared timeline — scrub once and the cameras and "
887
- "action plots move together. Frames are JPEG-compressed for transfer."
888
- )
889
- rerun_view = Rerun(height=720, streaming=False)
890
- with gr.Tab("All windows") as tab_table:
891
- table = gr.Dataframe(
892
- headers=["episode", "task", "edge", "arm", "crit PSNR", "full PSNR"],
893
- datatype=["str", "str", "str", "str", "number", "number"],
894
- interactive=False,
895
- wrap=True,
896
- max_height=650,
897
- )
898
-
899
- filter_inputs = [sid_state, query, edge, arm, sort]
900
- filter_outputs = [picker, table, ids_state]
901
- view_inputs = [sid_state, picker, view, show_diff, fps, frame_idx]
902
- view_outputs = [video, still, info, actions_plot, frame_idx, view]
903
- rerun_inputs = [sid_state, picker, view, show_diff]
904
-
905
- def wire_select(event):
906
- """Render the window, then refresh the recording only if that tab is open."""
907
- return event(on_select, view_inputs, view_outputs).then(
908
- on_rerun_if_active, [active_tab] + rerun_inputs, rerun_view
909
- )
910
-
911
- def wire_load(event):
912
- return wire_select(
913
- event(on_load, [repo_box, sid_state], [sid_state, status])
914
- .then(on_filter, filter_inputs + [picker], filter_outputs)
915
- .then
916
- )
917
-
918
- wire_load(load_btn.click)
919
- wire_load(repo_box.submit)
920
- wire_load(demo.load)
921
-
922
- for control in (query, edge, arm, sort):
923
- wire_select(control.change(on_filter, filter_inputs + [picker], filter_outputs).then)
924
-
925
- wire_select(picker.change)
926
- for control in (view, show_diff, fps):
927
- wire_select(control.change)
928
-
929
- for tab, name in (
930
- (tab_playback, "Playback"),
931
- (tab_stepper, "Frame stepper"),
932
- (tab_actions, "Driving actions"),
933
- (tab_table, "All windows"),
934
- ):
935
- tab.select(lambda name=name: name, None, active_tab)
936
- # opening the Rerun tab is what triggers the first build for a window
937
- tab_rerun.select(lambda: "Rerun", None, active_tab).then(on_rerun, rerun_inputs, rerun_view)
938
- frame_idx.change(on_frame, [sid_state, picker, view, show_diff, frame_idx], [still, actions_plot])
939
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
940
  prev_btn.click(lambda i, c: step(i, c, -1), [ids_state, picker], picker)
941
  next_btn.click(lambda i, c: step(i, c, +1), [ids_state, picker], picker)
942
 
943
- def on_table_select(ids, evt: gr.SelectData):
944
- return ids[evt.index[0]] if ids and evt.index[0] < len(ids) else gr.update()
945
-
946
- table.select(on_table_select, ids_state, picker)
947
-
948
  if __name__ == "__main__":
949
- demo.queue(max_size=16).launch(css=CSS)
 
 
 
1
  """
2
+ Rerun viewer for forward-dynamics rollouts vs. ground truth.
3
 
4
  Point it at any HF dataset repo laid out as:
5
 
 
7
  metrics_summary.json # optional: per-window MSE / PSNR
8
  windows/<episode_id>/gt.mp4 # ground-truth window
9
  windows/<episode_id>/generated.mp4 # model rollout for the same slice
 
 
10
 
11
+ Pick an episode and its two videos stream into an embedded Rerun viewer, side
12
+ by side on a shared frame timeline.
 
13
 
14
  Nothing is downloaded at build time and no repo is ever pulled whole. Each
15
+ browser session gets its own scratch directory, keeps at most
16
+ MAX_WINDOWS_ON_DISK windows, and is deleted when the session ends. Recordings
17
+ go straight to the viewer as a binary stream, so no .rrd files are written.
18
 
19
+ The app starts empty and assumes no dataset: type a repo id and press Load.
20
+ Private repos need an HF_TOKEN secret with read access.
21
  """
22
 
23
  from __future__ import annotations
 
35
 
36
  import cv2
37
  import gradio as gr
 
38
  import numpy as np
39
  import rerun as rr
40
  import rerun.blueprint as rrb
41
  from gradio_rerun import Rerun
42
  from huggingface_hub import HfApi, RepoFolder, hf_hub_download
 
43
 
 
44
  HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN") or None
45
 
46
  BASE_DIR = Path(tempfile.gettempdir()) / "wmviz"
 
48
 
49
  MAX_WINDOWS_ON_DISK = int(os.environ.get("MAX_WINDOWS_ON_DISK", 12))
50
  MAX_PAIRS_IN_RAM = 2
 
51
  MAX_DISCOVER = int(os.environ.get("MAX_DISCOVER", 5000))
52
  SESSION_TTL = int(os.environ.get("SESSION_TTL", 3600))
53
+ JPEG_QUALITY = 90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
 
55
  SORTS = [
56
+ "Hardest first (critical PSNR up)",
57
+ "Easiest first (critical PSNR down)",
58
+ "Task A-Z",
59
  "Manifest order",
60
  ]
61
 
 
88
  self.repo = ""
89
  self.revision: str | None = None
90
  self.rows: list[dict] = []
 
91
  self._on_disk: OrderedDict[str, bool] = OrderedDict()
92
  self._pairs: OrderedDict[str, tuple[np.ndarray, np.ndarray]] = OrderedDict()
 
 
93
  self.touched = time.time()
94
 
 
 
95
  def fetch(self, rel: str, optional: bool = False) -> str | None:
96
  """Download one repo file into this session's directory."""
97
  self.touched = time.time()
 
123
  old, _ = self._on_disk.popitem(last=False)
124
  shutil.rmtree(self.data / "windows" / old, ignore_errors=True)
125
  self._pairs.pop(old, None)
 
 
126
  for f in self.render.glob(f"{old}_*"):
127
  f.unlink(missing_ok=True)
128
 
129
  def disk_windows(self) -> int:
130
  return len(self._on_disk)
131
 
 
 
132
  def pair(self, episode_id: str) -> tuple[np.ndarray, np.ndarray]:
133
  hit = self._pairs.get(episode_id)
134
  if hit is not None:
 
143
  self._pairs.popitem(last=False)
144
  return value
145
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  def set_repo(self, repo: str, revision: str | None) -> None:
147
  if (repo, revision) != (self.repo, self.revision):
148
  self.reset()
 
151
  def reset(self) -> None:
152
  self._on_disk.clear()
153
  self._pairs.clear()
 
 
154
  self.rows = []
155
  for d in (self.data, self.render):
156
  shutil.rmtree(d, ignore_errors=True)
 
158
 
159
  def close(self) -> None:
160
  self._pairs.clear()
 
161
  shutil.rmtree(self.root, ignore_errors=True)
162
 
163
 
 
193
 
194
 
195
  def release(sid: str | None) -> None:
196
+ """gr.State delete_callback - fires when the session ends or its TTL expires."""
197
  with _LOCK:
198
  s = SESSIONS.pop(sid or "", None)
199
  if s is not None:
 
210
  "task": w.get("task") or "?",
211
  "edge": w.get("edge_type") or "?",
212
  "arm": w.get("arm") or "?",
 
 
 
213
  "critical_frame": w.get("critical_frame"),
214
+ "start_frame": w.get("start_frame"),
215
  "full_psnr": m.get("full_psnr"),
216
  "crit_psnr": m.get("crit_psnr"),
217
  "full_mse": m.get("full_mse"),
 
271
  eps, truncated = discover_episodes(session)
272
  if not eps:
273
  raise FileNotFoundError(
274
+ "no windows/<episode_id>/ directories found - is this the right repo?"
275
  )
276
  session.rows = [_row(i, ep, {}, by_id.get(ep, {})) for i, ep in enumerate(eps)]
277
  note = "directory listing (no `windows_manifest.json`)"
 
292
  head = f"{psnr:5.1f} dB" if isinstance(psnr, (int, float)) else " -- dB"
293
  tags = " ".join(t for t in (row["edge"], row["arm"]) if t and t != "?")
294
  parts = [head] + ([tags] if tags else []) + [row["task"], short_id(row["episode_id"])]
295
+ return " | ".join(parts)
296
 
297
 
298
+ def filter_rows(session: Session, query: str, sort: str) -> list[dict]:
299
  rows = session.rows
300
  q = (query or "").strip().lower()
301
  if q:
302
  rows = [r for r in rows if q in r["task"].lower() or q in r["episode_id"].lower()]
 
 
 
 
303
 
304
  def psnr(r, missing):
305
  v = r["crit_psnr"]
 
317
  def row_by_id(session: Session, episode_id: str) -> dict:
318
  for r in session.rows:
319
  if r["episode_id"] == episode_id:
320
+ return r
321
+ return _row(0, episode_id, {}, {})
 
 
 
 
 
 
 
 
 
 
 
322
 
323
 
324
  # --------------------------------------------------------------------------- video
 
339
  return np.stack(frames)
340
 
341
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
342
  # --------------------------------------------------------------------------- ui glue
343
 
344
 
345
+ def metrics_md(row: dict) -> str:
346
  def fmt(v, unit=""):
347
+ return f"{v:.2f}{unit}" if isinstance(v, (int, float)) else "-"
348
+
349
+ out = [
350
+ f"**{row['task'].replace('_', ' ')}**",
351
+ "",
352
+ f"`{row['episode_id']}`",
353
+ "",
354
+ "| | full window | critical frame |",
355
+ "|---|---|---|",
356
+ f"| **PSNR** | {fmt(row['full_psnr'], ' dB')} | {fmt(row['crit_psnr'], ' dB')} |",
357
+ f"| **MSE** | {fmt(row['full_mse'])} | {fmt(row['crit_mse'])} |",
358
+ "",
359
+ ]
360
+ bits = [b for b in (row["edge"], row["arm"]) if b and b != "?"]
 
 
 
 
 
 
 
 
 
361
  if row["critical_frame"] is not None:
362
  bits.append(f"critical frame {row['critical_frame']}")
363
  if bits:
364
+ out.append(" | ".join(bits))
 
 
 
 
365
  return "\n".join(out)
366
 
367
 
 
 
 
 
 
 
 
 
 
 
368
  def critical_index(row: dict) -> int:
 
369
  start, crit = row.get("start_frame"), row.get("critical_frame")
370
  if start is None or crit is None:
371
+ return -1
372
  return int(crit) - int(start)
373
 
374
 
375
  def on_load(repo_text, sid):
376
+ """Resolve a repo and load only its index - no window data is pulled here."""
377
  sweep()
378
  session = get_session(sid)
379
  repo, revision = parse_repo(repo_text)
 
385
  except Exception as exc:
386
  session.rows = []
387
  gr.Warning(f"Could not load {repo}: {exc}")
388
+ return session.sid, f"**{repo}** failed: {exc}"
389
  at = f"@{revision}" if revision else ""
390
+ return session.sid, f"**{repo}{at}** - {len(session.rows)} episodes from {note}"
 
 
 
391
 
392
 
393
+ def on_filter(sid, query, sort, current):
394
  session = get_session(sid)
395
  if not session.rows:
396
+ return gr.update(choices=[], value=None), []
397
+ rows = filter_rows(session, query, sort)
398
  choices = [(label_for(r), r["episode_id"]) for r in rows]
399
  ids = [r["episode_id"] for r in rows]
400
  value = current if current in ids else (ids[0] if ids else None)
401
  return (
402
+ gr.update(choices=choices, value=value, label=f"Episode ({len(ids)} of {len(session.rows)})"),
 
403
  ids,
404
  )
405
 
406
 
407
+ def on_metrics(sid, episode_id):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
408
  session = get_session(sid)
409
  if not session.rows or not episode_id:
410
+ return "*No episode selected.*"
411
+ return metrics_md(row_by_id(session, episode_id))
412
+
 
 
413
 
414
+ def render_episode(sid, episode_id):
415
+ """Write the episode to an .rrd: both videos side by side on one frame timeline.
416
 
417
+ Handing the viewer a finished file rather than a live binary stream: streaming
418
+ races the viewer's wasm boot, and chunks that land before it is ready are dropped
419
+ against a stopped instance.
420
+ """
421
  session = get_session(sid)
422
  if not session.rows or not episode_id:
423
+ # Never hand the viewer a null: that resets and stops the wasm instance, and the
424
+ # next real value then tries to open a channel on a dead viewer.
425
+ return gr.skip()
426
 
427
+ path = session.render / f"{episode_id}.rrd"
428
+ if path.exists():
429
+ return str(path)
430
 
431
+ gt, gen = session.pair(episode_id)
432
+ crit = critical_index(row_by_id(session, episode_id))
433
+ rec = rr.RecordingStream("wmviz", recording_id=episode_id)
434
+ for i in range(len(gt)):
435
+ rec.set_time("frame", sequence=i)
436
+ rec.log("ground_truth", rr.Image(gt[i]).compress(jpeg_quality=JPEG_QUALITY))
437
+ rec.log("generated", rr.Image(gen[i]).compress(jpeg_quality=JPEG_QUALITY))
438
+ if i == crit:
439
+ rec.log("critical_frame", rr.TextLog("critical frame"))
440
+ rec.save(
441
+ str(path),
442
+ default_blueprint=rrb.Blueprint(
443
+ rrb.Horizontal(
444
+ rrb.Spatial2DView(origin="ground_truth", name="Ground truth"),
445
+ rrb.Spatial2DView(origin="generated", name="Generated"),
446
+ ),
447
+ collapse_panels=True,
448
+ ),
449
+ )
450
+ return str(path)
451
 
452
 
453
  def step(ids, current, delta):
 
462
 
463
  # --------------------------------------------------------------------------- app
464
 
 
 
 
 
 
465
  with gr.Blocks(title="Rollout vs. ground truth", fill_width=True, delete_cache=(600, 600)) as demo:
 
 
466
  sid_state = gr.State("", time_to_live=SESSION_TTL, delete_callback=release)
467
  ids_state = gr.State([])
 
468
 
469
  with gr.Row():
470
+ with gr.Column(scale=1, min_width=340):
471
+ gr.Markdown("### Rollout vs. ground truth")
 
 
 
 
 
 
 
 
 
 
 
472
  with gr.Row():
473
+ repo_box = gr.Textbox(
474
+ label="Dataset repo",
475
+ placeholder="owner/name (or owner/name@revision, or a dataset URL)",
476
+ scale=4,
477
+ )
478
+ load_btn = gr.Button("Load", variant="primary", scale=1, min_width=70)
479
+ status = gr.Markdown("Enter a dataset repo id above and press **Load**.")
480
+ query = gr.Textbox(label="Search", placeholder="task or episode id")
481
  sort = gr.Dropdown(SORTS, value=SORTS[0], label="Sort")
482
+ picker = gr.Dropdown([], label="Episode", filterable=True)
483
  with gr.Row():
484
+ prev_btn = gr.Button("Prev")
485
+ next_btn = gr.Button("Next")
 
 
 
 
486
  info = gr.Markdown()
487
 
488
+ with gr.Column(scale=4):
489
+ # Config matched to rerun/gradio-rerun-viewer: a CSS-string height leaves the
490
+ # container at zero height while the viewer re-initialises, and it never recovers.
491
+ viewer = Rerun(
492
+ streaming=True,
493
+ height=820,
494
+ panel_states={"time": "expanded", "blueprint": "hidden", "selection": "hidden"},
495
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
496
 
497
+ filter_io = ([sid_state, query, sort, picker], [picker, ids_state])
498
+
499
+ # Loading and filtering only repopulate the episode list. Selecting an episode is the
500
+ # single trigger that streams into the viewer -- if these chains also streamed, setting
501
+ # the dropdown value here would fire picker.change too and run two generators
502
+ # concurrently into one viewer, which crashes it.
503
+ for event in (load_btn.click, repo_box.submit):
504
+ event(on_load, [repo_box, sid_state], [sid_state, status]).then(on_filter, *filter_io)
505
+
506
+ for control in (query, sort):
507
+ control.change(on_filter, *filter_io)
508
+
509
+ picker.change(on_metrics, [sid_state, picker], info)
510
+ # trigger_mode="once" is load-bearing: .change() defaults to "always_last", which lets a
511
+ # second run queue up behind the pending one. Two generators streaming into one viewer
512
+ # stops the first viewer mid-write, which is what left it stuck on "Loading Rerun...".
513
+ picker.change(
514
+ render_episode,
515
+ [sid_state, picker],
516
+ viewer,
517
+ concurrency_limit=1,
518
+ trigger_mode="once",
519
+ )
520
  prev_btn.click(lambda i, c: step(i, c, -1), [ids_state, picker], picker)
521
  next_btn.click(lambda i, c: step(i, c, +1), [ids_state, picker], picker)
522
 
 
 
 
 
 
523
  if __name__ == "__main__":
524
+ # ssr_mode must stay off: server-side rendering re-mounts the viewer canvas on
525
+ # hydration, which kills the wasm instance mid-stream.
526
+ demo.queue(max_size=16).launch(ssr_mode=False)
requirements.txt CHANGED
@@ -1,7 +1,5 @@
1
- gradio==6.22.0
2
- gradio_rerun>=0.35.0
3
  huggingface_hub>=0.28
4
  numpy
5
  opencv-python-headless
6
- imageio-ffmpeg
7
- matplotlib
 
1
+ gradio==6.5.1
2
+ gradio_rerun==0.35.0
3
  huggingface_hub>=0.28
4
  numpy
5
  opencv-python-headless