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

Add a Rerun viewer tab

Browse files

Logs the window to an .rrd and renders it with gradio_rerun: each camera's
GT/generated/diff as image streams and every action channel as a scalar
series, all on one shared frame timeline. This is the thing the composed mp4
could not do — the action plots were a static PNG that could not be scrubbed
against the video.

Frames are JPEG-compressed into the recording (~1.5 MB rather than ~16 MB for
a 3-camera window). The recording is built only when the tab is opened, and
is evicted along with its window like the composed mp4.

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

Files changed (3) hide show
  1. README.md +11 -0
  2. app.py +142 -16
  3. requirements.txt +1 -0
README.md CHANGED
@@ -29,6 +29,10 @@ URL. `DATASET_REPO` sets what is prefilled at startup.
29
  frame when the dataset marks one.
30
  - **Driving actions** — the action chunk fed to the model. A 14-D chunk is split into grippers /
31
  left-arm / right-arm; any other width is plotted as raw channels.
 
 
 
 
32
  - **All windows** — sortable table of every window; click a row to load it.
33
 
34
  Filter by task, gripper edge and arm; sort by critical-frame PSNR to jump to the hardest or
@@ -69,6 +73,13 @@ too large to fit on the Space.
69
  `SESSION_TTL` and directories orphaned by an earlier process, so nothing accumulates if a tab
70
  dies without a clean shutdown.
71
 
 
 
 
 
 
 
 
72
  The sidebar shows live occupancy (`cached this session: n/N windows`).
73
 
74
  ## Configuration
 
29
  frame when the dataset marks one.
30
  - **Driving actions** — the action chunk fed to the model. A 14-D chunk is split into grippers /
31
  left-arm / right-arm; any other width is plotted as raw channels.
32
+ - **Rerun** — the same window in a [Rerun](https://rerun.io) viewer: every camera and every
33
+ action channel on one shared frame timeline, so scrubbing moves all of them together. One row
34
+ per camera (GT | generated | optional diff) over a time-series plot of the actions, with the
35
+ critical frame marked. Built only when you open the tab, and only for the cameras selected.
36
  - **All windows** — sortable table of every window; click a row to load it.
37
 
38
  Filter by task, gripper edge and arm; sort by critical-frame PSNR to jump to the hardest or
 
73
  `SESSION_TTL` and directories orphaned by an earlier process, so nothing accumulates if a tab
74
  dies without a clean shutdown.
75
 
76
+ Rerun recordings follow the same rule: built on first view of the tab, cached beside the
77
+ composed mp4, and evicted with their window. Frames go into the recording as JPEG (quality 90) —
78
+ a raw recording of one 3-camera window is ~16 MB against ~1.5 MB compressed, which is the
79
+ difference between usable and not over a browser connection. The trade-off is that extreme zoom
80
+ in the Rerun viewer shows JPEG artifacts; the **Frame stepper** tab renders from the raw decoded
81
+ frames if you need a pixel-exact look.
82
+
83
  The sidebar shows live occupancy (`cached this session: n/N windows`).
84
 
85
  ## Configuration
app.py CHANGED
@@ -40,6 +40,9 @@ import cv2
40
  import gradio as gr
41
  import imageio_ffmpeg
42
  import numpy as np
 
 
 
43
  from huggingface_hub import HfApi, RepoFolder, hf_hub_download
44
  from matplotlib.figure import Figure
45
 
@@ -63,6 +66,7 @@ NO_CRIT = -1
63
  FONT = cv2.FONT_HERSHEY_SIMPLEX
64
  HDR, FTR, SEP = 26, 26, 4
65
  SCALE = 2 # source frames are small; upscale so overlays stay legible
 
66
 
67
  GT_COLOR = (130, 225, 140)
68
  GEN_COLOR = (120, 180, 255)
@@ -147,7 +151,7 @@ class Session:
147
  self._pairs.pop(old, None)
148
  for key in [k for k in self._composed if k[0] == old]:
149
  self._composed.pop(key, None)
150
- for f in self.render.glob(f"{old}_*.mp4"):
151
  f.unlink(missing_ok=True)
152
 
153
  def disk_windows(self) -> int:
@@ -416,11 +420,21 @@ def n_cameras(frames: np.ndarray) -> int:
416
 
417
 
418
  def camera_choices(n: int) -> list[tuple[str, int]]:
419
- names = THREE_CAM_NAMES if n == 3 else [f"Camera {i + 1}" for i in range(n)]
420
- choices = [(name, i) for i, name in enumerate(names)]
421
  return ([("All cameras", ALL_CAMS)] + choices) if n > 1 else choices
422
 
423
 
 
 
 
 
 
 
 
 
 
 
 
424
  def cam_slices(frames: np.ndarray, view: int) -> list[np.ndarray]:
425
  n = n_cameras(frames)
426
  h = frames.shape[1] // n
@@ -546,15 +560,21 @@ def render_video(session: Session, episode_id: str, view: int, show_diff: bool,
546
  # --------------------------------------------------------------------------- actions plot
547
 
548
 
549
- def plot_actions(session: Session, episode_id: str, crit_idx: int, cursor: int | None) -> Figure | None:
550
  path = session.window_file(episode_id, "actions.json", optional=True)
551
  if not path:
552
  return None
553
  try:
554
- actions = np.atleast_2d(np.asarray(json.load(open(path)), dtype=float))
555
  except Exception:
556
  return None
557
 
 
 
 
 
 
 
558
  t = np.arange(len(actions))
559
  dim = actions.shape[1]
560
  if dim == 14: # bimanual: [L arm 6, L grip, R arm 6, R grip]
@@ -585,6 +605,75 @@ def plot_actions(session: Session, episode_id: str, crit_idx: int, cursor: int |
585
  return fig
586
 
587
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
588
  # --------------------------------------------------------------------------- ui glue
589
 
590
 
@@ -710,6 +799,21 @@ def on_frame(sid, episode_id, view, show_diff, frame_idx):
710
  return frames[i], plot_actions(session, episode_id, crit, i)
711
 
712
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
713
  def step(ids, current, delta):
714
  if not ids:
715
  return None
@@ -732,6 +836,7 @@ with gr.Blocks(title="Rollout vs. ground truth", fill_width=True, delete_cache=(
732
 
733
  sid_state = gr.State("", time_to_live=SESSION_TTL, delete_callback=release)
734
  ids_state = gr.State([])
 
735
 
736
  with gr.Row():
737
  repo_box = gr.Textbox(
@@ -763,7 +868,7 @@ with gr.Blocks(title="Rollout vs. ground truth", fill_width=True, delete_cache=(
763
 
764
  with gr.Column(scale=2):
765
  with gr.Tabs():
766
- with gr.Tab("Playback"):
767
  video = gr.Video(
768
  label="GT vs generated",
769
  autoplay=True,
@@ -771,12 +876,18 @@ with gr.Blocks(title="Rollout vs. ground truth", fill_width=True, delete_cache=(
771
  buttons=["download", "fullscreen"],
772
  elem_classes="window-video",
773
  )
774
- with gr.Tab("Frame stepper"):
775
  frame_idx = gr.Slider(0, 32, value=16, step=1, label="Frame in window")
776
  still = gr.Image(label="Frame comparison", elem_classes="window-still")
777
- with gr.Tab("Driving actions"):
778
  actions_plot = gr.Plot(label="Driving action chunk")
779
- with gr.Tab("All windows"):
 
 
 
 
 
 
780
  table = gr.Dataframe(
781
  headers=["episode", "task", "edge", "arm", "crit PSNR", "full PSNR"],
782
  datatype=["str", "str", "str", "str", "number", "number"],
@@ -789,12 +900,19 @@ with gr.Blocks(title="Rollout vs. ground truth", fill_width=True, delete_cache=(
789
  filter_outputs = [picker, table, ids_state]
790
  view_inputs = [sid_state, picker, view, show_diff, fps, frame_idx]
791
  view_outputs = [video, still, info, actions_plot, frame_idx, view]
 
 
 
 
 
 
 
792
 
793
  def wire_load(event):
794
- return (
795
  event(on_load, [repo_box, sid_state], [sid_state, status])
796
  .then(on_filter, filter_inputs + [picker], filter_outputs)
797
- .then(on_select, view_inputs, view_outputs)
798
  )
799
 
800
  wire_load(load_btn.click)
@@ -802,13 +920,21 @@ with gr.Blocks(title="Rollout vs. ground truth", fill_width=True, delete_cache=(
802
  wire_load(demo.load)
803
 
804
  for control in (query, edge, arm, sort):
805
- control.change(on_filter, filter_inputs + [picker], filter_outputs).then(
806
- on_select, view_inputs, view_outputs
807
- )
808
 
809
- picker.change(on_select, view_inputs, view_outputs)
810
  for control in (view, show_diff, fps):
811
- control.change(on_select, view_inputs, view_outputs)
 
 
 
 
 
 
 
 
 
 
812
  frame_idx.change(on_frame, [sid_state, picker, view, show_diff, frame_idx], [still, actions_plot])
813
 
814
  prev_btn.click(lambda i, c: step(i, c, -1), [ids_state, picker], picker)
 
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
 
 
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)
 
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:
 
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
 
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]
 
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
 
 
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):
818
  if not ids:
819
  return None
 
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(
 
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,
 
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"],
 
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)
 
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)
requirements.txt CHANGED
@@ -1,4 +1,5 @@
1
  gradio==6.22.0
 
2
  huggingface_hub>=0.28
3
  numpy
4
  opencv-python-headless
 
1
  gradio==6.22.0
2
+ gradio_rerun>=0.35.0
3
  huggingface_hub>=0.28
4
  numpy
5
  opencv-python-headless