Spaces:
Sleeping
Sleeping
Commit ·
2f19736
1
Parent(s): 3b57e9a
Replace the page with a Rerun-only viewer
Browse filesDrops 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>
- README.md +1 -1
- app.py +126 -549
- 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.
|
| 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 |
-
|
| 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 |
-
|
| 14 |
-
|
| 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
|
| 19 |
-
|
| 20 |
-
|
| 21 |
|
| 22 |
-
|
| 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
|
| 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
|
| 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 |
-
"
|
| 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
|
| 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 "
|
| 355 |
|
| 356 |
|
| 357 |
-
def filter_rows(session: Session, query: str,
|
| 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 |
-
|
| 384 |
-
|
| 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(
|
| 681 |
def fmt(v, unit=""):
|
| 682 |
-
return f"{v:.2f}{unit}" if isinstance(v, (int, float)) else "
|
| 683 |
-
|
| 684 |
-
out = [
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
]
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 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("
|
| 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
|
| 731 |
return int(crit) - int(start)
|
| 732 |
|
| 733 |
|
| 734 |
def on_load(repo_text, sid):
|
| 735 |
-
"""Resolve a repo and load only its index
|
| 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"
|
| 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,
|
| 756 |
session = get_session(sid)
|
| 757 |
if not session.rows:
|
| 758 |
-
return gr.update(choices=[], value=None), []
|
| 759 |
-
rows = filter_rows(session, query,
|
| 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"
|
| 765 |
-
table_rows(rows),
|
| 766 |
ids,
|
| 767 |
)
|
| 768 |
|
| 769 |
|
| 770 |
-
def
|
| 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
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
i = int(min(frame_idx, len(frames) - 1))
|
| 799 |
-
return frames[i], plot_actions(session, episode_id, crit, i)
|
| 800 |
|
|
|
|
|
|
|
| 801 |
|
| 802 |
-
|
|
|
|
|
|
|
|
|
|
| 803 |
session = get_session(sid)
|
| 804 |
if not session.rows or not episode_id:
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
|
| 808 |
|
|
|
|
|
|
|
|
|
|
| 809 |
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 843 |
-
|
| 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 |
-
|
| 857 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 858 |
sort = gr.Dropdown(SORTS, value=SORTS[0], label="Sort")
|
| 859 |
-
picker = gr.Dropdown([], label="
|
| 860 |
with gr.Row():
|
| 861 |
-
prev_btn = gr.Button("
|
| 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=
|
| 870 |
-
|
| 871 |
-
|
| 872 |
-
|
| 873 |
-
|
| 874 |
-
|
| 875 |
-
|
| 876 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
| 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.
|
| 2 |
-
gradio_rerun
|
| 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
|
|
|
|
|
|