Spaces:
Sleeping
Sleeping
Commit ·
a9ea39e
1
Parent(s): 498277b
Fall back to the composed side-by-side video
Browse filesRerun could not be made to work: the gradio_rerun viewer intermittently
stops mid-update, reproducible in a 40-line app with synthetic data, and
pinning the reference Space's gradio 6.5.1 broke the page outright.
Keeps the simple single-view layout -- episode list, search, sort, PSNR/MSE --
and puts back the composed mp4, which frame-locks ground truth against the
generated rollout in one player. Camera selector, optional difference panel
and playback fps are retained; drops rerun-sdk and gradio_rerun.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- app.py +206 -75
- requirements.txt +1 -1
app.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
|
| 4 |
Point it at any HF dataset repo laid out as:
|
| 5 |
|
|
@@ -8,15 +8,16 @@ Point it at any HF dataset repo laid out as:
|
|
| 8 |
windows/<episode_id>/gt.mp4 # ground-truth window
|
| 9 |
windows/<episode_id>/generated.mp4 # model rollout for the same slice
|
| 10 |
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
| 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.
|
| 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 |
|
|
@@ -35,10 +36,8 @@ from pathlib import Path
|
|
| 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
|
|
@@ -48,9 +47,22 @@ BASE_DIR.mkdir(parents=True, exist_ok=True)
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
SORTS = [
|
| 56 |
"Hardest first (critical PSNR up)",
|
|
@@ -90,6 +102,7 @@ class Session:
|
|
| 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:
|
|
@@ -123,6 +136,8 @@ class Session:
|
|
| 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 |
|
|
@@ -143,6 +158,18 @@ class Session:
|
|
| 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,6 +178,7 @@ class Session:
|
|
| 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,6 +186,7 @@ class Session:
|
|
| 158 |
|
| 159 |
def close(self) -> None:
|
| 160 |
self._pairs.clear()
|
|
|
|
| 161 |
shutil.rmtree(self.root, ignore_errors=True)
|
| 162 |
|
| 163 |
|
|
@@ -339,6 +368,143 @@ def read_video(path: str) -> np.ndarray:
|
|
| 339 |
return np.stack(frames)
|
| 340 |
|
| 341 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
# --------------------------------------------------------------------------- ui glue
|
| 343 |
|
| 344 |
|
|
@@ -368,7 +534,7 @@ def metrics_md(row: dict) -> str:
|
|
| 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
|
| 372 |
return int(crit) - int(start)
|
| 373 |
|
| 374 |
|
|
@@ -404,50 +570,19 @@ def on_filter(sid, query, sort, current):
|
|
| 404 |
)
|
| 405 |
|
| 406 |
|
| 407 |
-
def
|
| 408 |
session = get_session(sid)
|
| 409 |
if not session.rows or not episode_id:
|
| 410 |
-
return "*No episode selected.*"
|
| 411 |
-
|
|
|
|
| 412 |
|
|
|
|
|
|
|
|
|
|
| 413 |
|
| 414 |
-
|
| 415 |
-
|
| 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,6 +597,10 @@ 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([])
|
|
@@ -483,44 +622,36 @@ with gr.Blocks(title="Rollout vs. ground truth", fill_width=True, delete_cache=(
|
|
| 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 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
)
|
| 496 |
|
| 497 |
filter_io = ([sid_state, query, sort, picker], [picker, ids_state])
|
|
|
|
| 498 |
|
| 499 |
-
#
|
| 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(
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 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 |
-
|
| 525 |
-
# hydration, which kills the wasm instance mid-stream.
|
| 526 |
-
demo.queue(max_size=16).launch(ssr_mode=False)
|
|
|
|
| 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 |
|
|
|
|
| 8 |
windows/<episode_id>/gt.mp4 # ground-truth window
|
| 9 |
windows/<episode_id>/generated.mp4 # model rollout for the same slice
|
| 10 |
|
| 11 |
+
Ground truth and generated are composed into a single video so the two panels
|
| 12 |
+
stay frame-locked; two independent players drift apart and cannot be scrubbed
|
| 13 |
+
together. Videos may stack N cameras vertically; N is inferred from the frame
|
| 14 |
+
aspect ratio and the camera selector adapts.
|
| 15 |
|
| 16 |
+
The app starts empty and assumes no dataset: type a repo id and press Load.
|
| 17 |
Nothing is downloaded at build time and no repo is ever pulled whole. Each
|
| 18 |
browser session gets its own scratch directory, keeps at most
|
| 19 |
+
MAX_WINDOWS_ON_DISK windows, and is deleted when the session ends.
|
|
|
|
| 20 |
|
|
|
|
| 21 |
Private repos need an HF_TOKEN secret with read access.
|
| 22 |
"""
|
| 23 |
|
|
|
|
| 36 |
|
| 37 |
import cv2
|
| 38 |
import gradio as gr
|
| 39 |
+
import imageio_ffmpeg
|
| 40 |
import numpy as np
|
|
|
|
|
|
|
|
|
|
| 41 |
from huggingface_hub import HfApi, RepoFolder, hf_hub_download
|
| 42 |
|
| 43 |
HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN") or None
|
|
|
|
| 47 |
|
| 48 |
MAX_WINDOWS_ON_DISK = int(os.environ.get("MAX_WINDOWS_ON_DISK", 12))
|
| 49 |
MAX_PAIRS_IN_RAM = 2
|
| 50 |
+
MAX_COMPOSED_IN_RAM = 1
|
| 51 |
MAX_DISCOVER = int(os.environ.get("MAX_DISCOVER", 5000))
|
| 52 |
SESSION_TTL = int(os.environ.get("SESSION_TTL", 3600))
|
| 53 |
+
|
| 54 |
+
THREE_CAM_NAMES = ["Top", "Left wrist", "Right wrist"]
|
| 55 |
+
ALL_CAMS = -1
|
| 56 |
+
NO_CRIT = -1
|
| 57 |
+
|
| 58 |
+
FONT = cv2.FONT_HERSHEY_SIMPLEX
|
| 59 |
+
HDR, FTR, SEP = 26, 26, 4
|
| 60 |
+
SCALE = 2 # source frames are small; upscale so overlays stay legible
|
| 61 |
+
|
| 62 |
+
GT_COLOR = (130, 225, 140)
|
| 63 |
+
GEN_COLOR = (120, 180, 255)
|
| 64 |
+
DIFF_COLOR = (215, 215, 215)
|
| 65 |
+
CRIT_COLOR = (255, 95, 95)
|
| 66 |
|
| 67 |
SORTS = [
|
| 68 |
"Hardest first (critical PSNR up)",
|
|
|
|
| 102 |
self.rows: list[dict] = []
|
| 103 |
self._on_disk: OrderedDict[str, bool] = OrderedDict()
|
| 104 |
self._pairs: OrderedDict[str, tuple[np.ndarray, np.ndarray]] = OrderedDict()
|
| 105 |
+
self._composed: OrderedDict[tuple, np.ndarray] = OrderedDict()
|
| 106 |
self.touched = time.time()
|
| 107 |
|
| 108 |
def fetch(self, rel: str, optional: bool = False) -> str | None:
|
|
|
|
| 136 |
old, _ = self._on_disk.popitem(last=False)
|
| 137 |
shutil.rmtree(self.data / "windows" / old, ignore_errors=True)
|
| 138 |
self._pairs.pop(old, None)
|
| 139 |
+
for key in [k for k in self._composed if k[0] == old]:
|
| 140 |
+
self._composed.pop(key, None)
|
| 141 |
for f in self.render.glob(f"{old}_*"):
|
| 142 |
f.unlink(missing_ok=True)
|
| 143 |
|
|
|
|
| 158 |
self._pairs.popitem(last=False)
|
| 159 |
return value
|
| 160 |
|
| 161 |
+
def composed(self, episode_id: str, view: int, show_diff: bool, crit: int) -> np.ndarray:
|
| 162 |
+
key = (episode_id, view, show_diff, crit)
|
| 163 |
+
hit = self._composed.get(key)
|
| 164 |
+
if hit is not None:
|
| 165 |
+
self._composed.move_to_end(key)
|
| 166 |
+
return hit
|
| 167 |
+
value = compose(self.pair(episode_id), view, show_diff, crit)
|
| 168 |
+
self._composed[key] = value
|
| 169 |
+
while len(self._composed) > MAX_COMPOSED_IN_RAM:
|
| 170 |
+
self._composed.popitem(last=False)
|
| 171 |
+
return value
|
| 172 |
+
|
| 173 |
def set_repo(self, repo: str, revision: str | None) -> None:
|
| 174 |
if (repo, revision) != (self.repo, self.revision):
|
| 175 |
self.reset()
|
|
|
|
| 178 |
def reset(self) -> None:
|
| 179 |
self._on_disk.clear()
|
| 180 |
self._pairs.clear()
|
| 181 |
+
self._composed.clear()
|
| 182 |
self.rows = []
|
| 183 |
for d in (self.data, self.render):
|
| 184 |
shutil.rmtree(d, ignore_errors=True)
|
|
|
|
| 186 |
|
| 187 |
def close(self) -> None:
|
| 188 |
self._pairs.clear()
|
| 189 |
+
self._composed.clear()
|
| 190 |
shutil.rmtree(self.root, ignore_errors=True)
|
| 191 |
|
| 192 |
|
|
|
|
| 368 |
return np.stack(frames)
|
| 369 |
|
| 370 |
|
| 371 |
+
def n_cameras(frames: np.ndarray) -> int:
|
| 372 |
+
"""How many roughly-square cameras are stacked vertically in the frame."""
|
| 373 |
+
h, w = frames.shape[1], frames.shape[2]
|
| 374 |
+
return max(1, min(6, int(round(h / w))))
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
def camera_names(n: int) -> list[str]:
|
| 378 |
+
return THREE_CAM_NAMES if n == 3 else [f"Camera {i + 1}" for i in range(n)]
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
def camera_choices(n: int) -> list[tuple[str, int]]:
|
| 382 |
+
choices = [(name, i) for i, name in enumerate(camera_names(n))]
|
| 383 |
+
return ([("All cameras", ALL_CAMS)] + choices) if n > 1 else choices
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
def cam_slices(frames: np.ndarray, view: int) -> list[np.ndarray]:
|
| 387 |
+
n = n_cameras(frames)
|
| 388 |
+
h = frames.shape[1] // n
|
| 389 |
+
rows = range(n) if view == ALL_CAMS or not 0 <= view < n else [view]
|
| 390 |
+
return [frames[:, i * h : (i + 1) * h] for i in rows]
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
def letterbox_rows(*bands: np.ndarray, thresh: int = 16) -> tuple[int, int]:
|
| 394 |
+
"""Rows to keep after dropping the black bars shared by every given band."""
|
| 395 |
+
h = bands[0].shape[1]
|
| 396 |
+
lit = np.zeros(h, bool)
|
| 397 |
+
for b in bands:
|
| 398 |
+
lit |= b.reshape(b.shape[0], h, -1).max(axis=(0, 2)) > thresh
|
| 399 |
+
idx = np.flatnonzero(lit)
|
| 400 |
+
if idx.size < h * 0.1: # nothing meaningful to trim
|
| 401 |
+
return 0, h
|
| 402 |
+
return int(idx[0]), int(idx[-1]) + 1
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
def crop_view(gt: np.ndarray, gen: np.ndarray, view: int) -> tuple[np.ndarray, np.ndarray]:
|
| 406 |
+
"""Select cameras and trim the letterbox, identically for both videos."""
|
| 407 |
+
gt_out, gen_out = [], []
|
| 408 |
+
for g, p in zip(cam_slices(gt, view), cam_slices(gen, view)):
|
| 409 |
+
lo, hi = letterbox_rows(g, p)
|
| 410 |
+
gt_out.append(g[:, lo:hi])
|
| 411 |
+
gen_out.append(p[:, lo:hi])
|
| 412 |
+
return np.concatenate(gt_out, axis=1), np.concatenate(gen_out, axis=1)
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
def upscale(frames: np.ndarray, scale: int) -> np.ndarray:
|
| 416 |
+
if scale == 1:
|
| 417 |
+
return frames
|
| 418 |
+
return np.stack(
|
| 419 |
+
[cv2.resize(f, None, fx=scale, fy=scale, interpolation=cv2.INTER_NEAREST) for f in frames]
|
| 420 |
+
)
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
def panel(frames: np.ndarray, title: str, color: tuple[int, int, int]) -> np.ndarray:
|
| 424 |
+
t, h, w, _ = frames.shape
|
| 425 |
+
out = np.zeros((t, HDR + h, w, 3), np.uint8)
|
| 426 |
+
out[:, HDR:] = frames
|
| 427 |
+
bar = np.full((HDR, w, 3), 22, np.uint8)
|
| 428 |
+
cv2.putText(bar, title, (8, HDR - 8), FONT, 0.52, color, 1, cv2.LINE_AA)
|
| 429 |
+
out[:, :HDR] = bar
|
| 430 |
+
return out
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
def diff_frames(gt: np.ndarray, gen: np.ndarray, gain: float = 3.0) -> np.ndarray:
|
| 434 |
+
d = np.abs(gt.astype(np.int16) - gen.astype(np.int16)).mean(axis=-1)
|
| 435 |
+
d = np.clip(d * gain, 0, 255).astype(np.uint8)
|
| 436 |
+
return np.stack([cv2.applyColorMap(f, cv2.COLORMAP_INFERNO)[:, :, ::-1] for f in d])
|
| 437 |
+
|
| 438 |
+
|
| 439 |
+
def compose(pair: tuple[np.ndarray, np.ndarray], view: int, show_diff: bool, crit_idx: int) -> np.ndarray:
|
| 440 |
+
"""Build the labelled side-by-side strip: [T, H, W, 3] RGB."""
|
| 441 |
+
gt_c, gen_c = crop_view(pair[0], pair[1], view)
|
| 442 |
+
gt_v, gen_v = upscale(gt_c, SCALE), upscale(gen_c, SCALE)
|
| 443 |
+
|
| 444 |
+
panels = [panel(gt_v, "GROUND TRUTH", GT_COLOR), panel(gen_v, "GENERATED", GEN_COLOR)]
|
| 445 |
+
if show_diff:
|
| 446 |
+
panels.append(panel(diff_frames(gt_v, gen_v), "|DIFF| x3", DIFF_COLOR))
|
| 447 |
+
|
| 448 |
+
t, ph, _, _ = panels[0].shape
|
| 449 |
+
gap = np.zeros((t, ph, SEP, 3), np.uint8)
|
| 450 |
+
stitched = [panels[0]]
|
| 451 |
+
for p in panels[1:]:
|
| 452 |
+
stitched += [gap, p]
|
| 453 |
+
comp = np.concatenate(stitched, axis=2)
|
| 454 |
+
|
| 455 |
+
t, h, w, _ = comp.shape
|
| 456 |
+
out = np.zeros((t, h + FTR, w, 3), np.uint8)
|
| 457 |
+
out[:, :h] = comp
|
| 458 |
+
has_crit = 0 <= crit_idx < t
|
| 459 |
+
tl0, tl1, y = int(w * 0.45), w - 14, FTR // 2
|
| 460 |
+
for i in range(t):
|
| 461 |
+
bar = np.full((FTR, w, 3), 22, np.uint8)
|
| 462 |
+
is_crit = has_crit and i == crit_idx
|
| 463 |
+
text = f"frame {i:02d}/{t - 1}" + (" CRITICAL FRAME" if is_crit else "")
|
| 464 |
+
cv2.putText(bar, text, (8, FTR - 8), FONT, 0.5, CRIT_COLOR if is_crit else (200, 200, 200), 1, cv2.LINE_AA)
|
| 465 |
+
cv2.line(bar, (tl0, y), (tl1, y), (70, 70, 70), 2, cv2.LINE_AA)
|
| 466 |
+
if has_crit:
|
| 467 |
+
cx = int(tl0 + (tl1 - tl0) * crit_idx / max(t - 1, 1))
|
| 468 |
+
cv2.line(bar, (cx, y - 7), (cx, y + 7), CRIT_COLOR, 2, cv2.LINE_AA)
|
| 469 |
+
px = int(tl0 + (tl1 - tl0) * i / max(t - 1, 1))
|
| 470 |
+
cv2.circle(bar, (px, y), 4, (255, 255, 255), -1, cv2.LINE_AA)
|
| 471 |
+
out[i, h:] = bar
|
| 472 |
+
if is_crit:
|
| 473 |
+
cv2.rectangle(out[i], (0, 0), (w - 1, h + FTR - 1), CRIT_COLOR, 3)
|
| 474 |
+
|
| 475 |
+
# yuv420p needs even dimensions
|
| 476 |
+
ph, pw = out.shape[1] % 2, out.shape[2] % 2
|
| 477 |
+
if ph or pw:
|
| 478 |
+
out = np.pad(out, ((0, 0), (0, ph), (0, pw), (0, 0)))
|
| 479 |
+
return out
|
| 480 |
+
|
| 481 |
+
|
| 482 |
+
def write_mp4(frames: np.ndarray, path: Path, fps: float) -> str:
|
| 483 |
+
_, h, w, _ = frames.shape
|
| 484 |
+
writer = imageio_ffmpeg.write_frames(
|
| 485 |
+
str(path),
|
| 486 |
+
size=(w, h),
|
| 487 |
+
fps=fps,
|
| 488 |
+
codec="libx264",
|
| 489 |
+
pix_fmt_in="rgb24",
|
| 490 |
+
pix_fmt_out="yuv420p",
|
| 491 |
+
macro_block_size=1,
|
| 492 |
+
output_params=["-crf", "16", "-preset", "veryfast", "-movflags", "+faststart"],
|
| 493 |
+
)
|
| 494 |
+
writer.send(None)
|
| 495 |
+
for f in frames:
|
| 496 |
+
writer.send(np.ascontiguousarray(f).tobytes())
|
| 497 |
+
writer.close()
|
| 498 |
+
return str(path)
|
| 499 |
+
|
| 500 |
+
|
| 501 |
+
def render_video(session: Session, episode_id: str, view: int, show_diff: bool, fps: float, crit: int) -> str:
|
| 502 |
+
path = session.render / f"{episode_id}_{view}_{int(show_diff)}_{fps:g}.mp4"
|
| 503 |
+
if not path.exists():
|
| 504 |
+
write_mp4(session.composed(episode_id, view, show_diff, crit), path, fps)
|
| 505 |
+
return str(path)
|
| 506 |
+
|
| 507 |
+
|
| 508 |
# --------------------------------------------------------------------------- ui glue
|
| 509 |
|
| 510 |
|
|
|
|
| 534 |
def critical_index(row: dict) -> int:
|
| 535 |
start, crit = row.get("start_frame"), row.get("critical_frame")
|
| 536 |
if start is None or crit is None:
|
| 537 |
+
return NO_CRIT
|
| 538 |
return int(crit) - int(start)
|
| 539 |
|
| 540 |
|
|
|
|
| 570 |
)
|
| 571 |
|
| 572 |
|
| 573 |
+
def on_select(sid, episode_id, view, show_diff, fps):
|
| 574 |
session = get_session(sid)
|
| 575 |
if not session.rows or not episode_id:
|
| 576 |
+
return None, "*No episode selected.*", gr.update()
|
| 577 |
+
row = row_by_id(session, episode_id)
|
| 578 |
+
crit = critical_index(row)
|
| 579 |
|
| 580 |
+
choices = camera_choices(n_cameras(session.pair(episode_id)[0]))
|
| 581 |
+
valid = [v for _, v in choices]
|
| 582 |
+
view = view if view in valid else choices[0][1]
|
| 583 |
|
| 584 |
+
video = render_video(session, episode_id, view, show_diff, fps, crit)
|
| 585 |
+
return video, metrics_md(row), gr.update(choices=choices, value=view)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 586 |
|
| 587 |
|
| 588 |
def step(ids, current, delta):
|
|
|
|
| 597 |
|
| 598 |
# --------------------------------------------------------------------------- app
|
| 599 |
|
| 600 |
+
CSS = """
|
| 601 |
+
.window-video video { max-height: 82vh; object-fit: contain; background: #111; }
|
| 602 |
+
"""
|
| 603 |
+
|
| 604 |
with gr.Blocks(title="Rollout vs. ground truth", fill_width=True, delete_cache=(600, 600)) as demo:
|
| 605 |
sid_state = gr.State("", time_to_live=SESSION_TTL, delete_callback=release)
|
| 606 |
ids_state = gr.State([])
|
|
|
|
| 622 |
with gr.Row():
|
| 623 |
prev_btn = gr.Button("Prev")
|
| 624 |
next_btn = gr.Button("Next")
|
| 625 |
+
view = gr.Radio(camera_choices(3), value=0, label="Camera")
|
| 626 |
+
show_diff = gr.Checkbox(False, label="Add |GT - generated| panel")
|
| 627 |
+
fps = gr.Slider(2, 30, value=8, step=1, label="Playback fps")
|
| 628 |
info = gr.Markdown()
|
| 629 |
|
| 630 |
with gr.Column(scale=4):
|
| 631 |
+
video = gr.Video(
|
| 632 |
+
show_label=False, # the panels are captioned in-frame; the chip covers them
|
| 633 |
+
autoplay=True,
|
| 634 |
+
loop=True,
|
| 635 |
+
buttons=["download", "fullscreen"],
|
| 636 |
+
elem_classes="window-video",
|
| 637 |
)
|
| 638 |
|
| 639 |
filter_io = ([sid_state, query, sort, picker], [picker, ids_state])
|
| 640 |
+
select_io = ([sid_state, picker, view, show_diff, fps], [video, info, view])
|
| 641 |
|
| 642 |
+
# Load and filter only repopulate the episode list; selecting an episode is what renders.
|
|
|
|
|
|
|
|
|
|
| 643 |
for event in (load_btn.click, repo_box.submit):
|
| 644 |
event(on_load, [repo_box, sid_state], [sid_state, status]).then(on_filter, *filter_io)
|
| 645 |
|
| 646 |
for control in (query, sort):
|
| 647 |
control.change(on_filter, *filter_io)
|
| 648 |
|
| 649 |
+
picker.change(on_select, *select_io)
|
| 650 |
+
for control in (view, show_diff, fps):
|
| 651 |
+
control.change(on_select, *select_io)
|
| 652 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 653 |
prev_btn.click(lambda i, c: step(i, c, -1), [ids_state, picker], picker)
|
| 654 |
next_btn.click(lambda i, c: step(i, c, +1), [ids_state, picker], picker)
|
| 655 |
|
| 656 |
if __name__ == "__main__":
|
| 657 |
+
demo.queue(max_size=16).launch(css=CSS)
|
|
|
|
|
|
requirements.txt
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
gradio==6.22.0
|
| 2 |
-
gradio_rerun==0.35.0
|
| 3 |
huggingface_hub>=0.28
|
| 4 |
numpy
|
| 5 |
opencv-python-headless
|
|
|
|
|
|
| 1 |
gradio==6.22.0
|
|
|
|
| 2 |
huggingface_hub>=0.28
|
| 3 |
numpy
|
| 4 |
opencv-python-headless
|
| 5 |
+
imageio-ffmpeg
|