orbis2_test / app2.py
Silvio Galesso
traj drawing look
c12a4ea
Raw
History Blame Contribute Delete
64.8 kB
"""
Orbis 2 World Model — interactive rollout demo for Hugging Face Spaces.
This is the Orbis 2 counterpart to app.py (which drives the single-level Orbis 1
model). Orbis 2 is a hierarchical L1-L2 world model whose rollout script takes an
mp4 directly, so the flow differs from app.py:
1. User uploads a short driving clip.
2. We re-encode it to a constant CONTEXT_FPS (= the L1 frame rate) so the
rollout logic's exact integer-multiple frame-rate check passes.
3. We call into orbis2_app_engine.RolloutEngine (in-process, not a subprocess —
see that module's docstring), which samples the L1 (high-rate) and L2
(low-rate, further back) context windows from the tail of that video.
4. The context is tiled NUM_VIDEOS times so one minibatch rolls out that many
independently sampled futures under a single seed.
5. Generated frames (fake_images/sequence_XXXX/*.jpg) are encoded back into mp4s.
Checkpoint / config resolution (see resolve_exp_dir / download_checkpoints):
* If ORBIS2_EXP_DIR is set (and contains the config + checkpoint), it is used
as-is — this is the path that works today against a local training run.
* Otherwise, if ORBIS2_HF_REPO is set, the config + checkpoint are pulled from
that Hub repo into a local exp dir. Orbis 2 is not on the Hub yet; set these
env vars once the checkpoint is uploaded.
"""
import argparse
import hashlib
import json
import os
import random
import re
import subprocess
import tempfile
import traceback
import uuid
from pathlib import Path
import cv2
import gradio as gr
import numpy as np
import spaces
from huggingface_hub import snapshot_download
from orbis2_app_engine import get_engine
import torch
print(f"torch version: {torch.__version__}, cuda available: {torch.cuda.is_available()}, cuda version: {torch.version.cuda}")
# ----------------------------------------------------------------------------
# Workaround for gradio 5.9.1 / gradio_client bug (gradio-app/gradio#11722):
# get_api_info() walks the app's JSON schema and crashes with
# "TypeError: argument of type 'bool' is not iterable"
# when a schema node is a boolean (e.g. "additionalProperties": true), because
# get_type()/_json_schema_to_python_type() assume every schema is a dict. The
# main page route calls api_info() on every load, so without this the endpoint
# 500s continuously. Make the walker tolerate boolean schemas.
# ----------------------------------------------------------------------------
import gradio_client.utils as _gc_utils
_orig_get_type = _gc_utils.get_type
_orig_json_to_py = _gc_utils._json_schema_to_python_type
def _safe_get_type(schema):
if isinstance(schema, bool):
return "bool"
return _orig_get_type(schema)
def _safe_json_to_py(schema, defs=None):
if isinstance(schema, bool):
return "Any"
return _orig_json_to_py(schema, defs)
_gc_utils.get_type = _safe_get_type
_gc_utils._json_schema_to_python_type = _safe_json_to_py
# ----------------------------------------------------------------------------
# CLI args
# ----------------------------------------------------------------------------
def _parse_args():
parser = argparse.ArgumentParser(description="Orbis 2 hierarchical world model demo")
parser.add_argument(
"--ui-only", action="store_true",
help="Launch only the Gradio UI, without downloading or loading any "
"model/checkpoint. For UI/UX testing on a machine without a GPU: "
"'Generate rollouts' will show a placeholder error instead of "
"actually running inference.",
)
return parser.parse_args()
ARGS = _parse_args()
UI_ONLY = ARGS.ui_only
# ----------------------------------------------------------------------------
# Configuration — adjust these to your setup
# ----------------------------------------------------------------------------
# Persistent-storage location. Prefer the Space's persistent /data volume so large
# downloads/caches survive restarts; fall back to the equivalent path under the home
# dir if /data isn't writable (e.g. persistent storage not enabled).
def _pick_cache_dir(subpath: str = ".cache/huggingface") -> str:
for base in ("/data", os.path.expanduser("~")):
candidate = os.path.join(base, subpath)
try:
probe = Path(candidate)
probe.mkdir(parents=True, exist_ok=True)
test = probe / ".write_probe"
test.touch()
test.unlink()
return candidate
except OSError:
continue
return os.path.join(os.path.expanduser("~"), subpath)
HF_CACHE_DIR = os.path.abspath(os.environ.get("HF_HOME") or _pick_cache_dir())
os.environ["HF_HOME"] = HF_CACHE_DIR
HF_HUB_CACHE_DIR = str(Path(HF_CACHE_DIR) / "hub")
print(f"[startup] HF cache dir: {HF_CACHE_DIR}"
f"{'' if HF_CACHE_DIR.startswith('/data') else ' (ephemeral — /data not writable)'}")
# torch.compile artifacts (Inductor/Triton-compiled kernels) are tied to the exact
# torch/CUDA/cuDNN build and GPU model they were compiled on -- loading a cache built
# on a different combination can silently miscompile or just fail to load. So instead
# of one fixed filename, compile_cache_path() below fingerprints the current
# environment into the filename, and this dir (persistent across restarts, same as
# HF_CACHE_DIR above) is where those per-environment files accumulate.
COMPILE_CACHE_DIR = Path(os.path.abspath(_pick_cache_dir(".cache/orbis2_compile_cache")))
print(f"[startup] Compile cache dir: {COMPILE_CACHE_DIR}"
f"{'' if str(COMPILE_CACHE_DIR).startswith('/data') else ' (ephemeral — /data not writable)'}")
def compile_cache_path() -> Path:
"""Path to this exact environment's torch.compile cache file. Must be called with
CUDA already attached to the process (i.e. from inside a @spaces.GPU-decorated
call, not at module import time) -- HF Spaces ZeroGPU only attaches a GPU to that
call, not to the main process, so torch.cuda.* would report nothing useful before
then."""
parts = [f"torch{torch.__version__}", f"cu{torch.version.cuda or 'none'}"]
if torch.cuda.is_available():
parts.append(f"cudnn{torch.backends.cudnn.version()}")
parts.append(torch.cuda.get_device_name(0))
parts.append("sm%d%d" % torch.cuda.get_device_capability(0))
fingerprint = re.sub(r"[^A-Za-z0-9]+", "-", "_".join(parts)).strip("-")
return COMPILE_CACHE_DIR / f"compile_cache_{fingerprint}.pkl"
# --- Orbis 2 models (configs + checkpoints) ----------------------------------
# Three models are needed, laid out as <models>/{L1,L2,tok}/{config,checkpoints/last.ckpt}:
# L1 — detail predictor (the world model rollout_demo_v2.py loads directly)
# L2 — abstract predictor (frozen; referenced by L1's condition_preprocessor)
# tok — tokenizer (referenced by BOTH L1's and L2's tokenizer_config)
#
# L1's and L2's configs locate their dependencies via $ORBIS2_MODELS_DIR, which we
# export below so OmegaConf's expandvars resolves them wherever the tree lands.
CONFIG_NAME = "config_distill.yaml" # L1's config, relative to EXP_DIR
CKPT_NAME = "checkpoints/last.ckpt" # every model stores its weights here
#: (sub-dir, config filename) for each of the required models.
MODEL_SPECS = [
("L1", CONFIG_NAME),
("L2", "config.yaml"),
# L1's config_distill.yaml conditions on this frozen, distilled L2 predictor
# (a separate Hub folder from "L2") -- must be downloaded too, or L1 fails to
# build its condition_preprocessor with a FileNotFoundError on config.yaml.
("L2_distilled", "config.yaml"),
("tok", "config.yaml"),
]
# Hub repo holding the three models (root contains L1/, L2/, tok/).
HF_CKPT_REPO = os.environ.get("ORBIS2_HF_REPO", "sud0301/orbis2")
def _missing_models(root: Path) -> list[str]:
"""Return the models whose config or checkpoint is absent under `root`."""
return [sub for sub, cfg in MODEL_SPECS
if not (root / sub / cfg).exists() or not (root / sub / CKPT_NAME).exists()]
def resolve_models_dir() -> Path:
"""Return a directory containing L1/, L2/ and tok/, downloading it if needed.
A local tree wins if it's complete (a dev checkout, or ORBIS2_MODELS_DIR pointing
at one). Otherwise we snapshot the Hub repo and use the *cache* path directly
rather than copying into the Space — `local_dir=` would duplicate ~20 GB on disk.
The cache lives on /data when persistent storage is enabled, so the download
survives restarts.
"""
local = Path(os.environ.get("ORBIS2_MODELS_DIR") or (Path(__file__).parent / "models")).resolve()
if not _missing_models(local):
print(f"[startup] Using local Orbis 2 models: {local}")
return local
print(f"[startup] Fetching Orbis 2 models from {HF_CKPT_REPO} (~20 GB, first run only)…")
snapshot = Path(snapshot_download(
repo_id=HF_CKPT_REPO,
allow_patterns=[f"{sub}/**" for sub, _ in MODEL_SPECS],
cache_dir=HF_HUB_CACHE_DIR,
))
still_missing = _missing_models(snapshot)
if still_missing:
raise RuntimeError(
f"Downloaded {HF_CKPT_REPO} but these models are incomplete: "
f"{', '.join(still_missing)}. Each of L1/, L2/, tok/ must contain its "
f"config and '{CKPT_NAME}'."
)
print(f"[startup] Orbis 2 models ready: {snapshot}")
return snapshot
FRAME_H, FRAME_W = 288, 512
L1_FRAME_RATE = 10 # Hz to sample L1 context/rollout frames (and output fps)
CONTEXT_FPS = L1_FRAME_RATE # we re-encode uploads to this exact fps for the rollout script
if UI_ONLY:
print("[startup] --ui-only: skipping model/checkpoint download and load (UI/UX test mode).")
MODELS_DIR = None
EXP_DIR = None
ENGINE = None
# No model to introspect for the real lookback requirement -- 0 lets the player
# auto-seek stay a no-op instead of erroring.
MIN_CONTEXT_END_FRAME = 0
MIN_CONTEXT_TIME_S = 0.0
# No model to read model.num_pred_frames from -- 1 is just a placeholder so the
# UI-only slider below has *some* seconds-per-step to show; it's never used for
# an actual rollout (run_rollout raises before reaching the model in this mode).
FRAMES_PER_GEN_STEP = 1
else:
MODELS_DIR = resolve_models_dir()
os.environ["ORBIS2_MODELS_DIR"] = str(MODELS_DIR) # L1/L2 configs expandvars this
EXP_DIR = MODELS_DIR / "L1" # rollout_demo_v2.py --exp_dir
# Load the checkpoint once, at Space startup, on CPU (no GPU is attached outside a
# @spaces.GPU call). run_rollout() below only moves it to CUDA / compiles it, and
# only does that once too -- see orbis2_app_engine.py for why this avoids paying the
# multi-minute load+compile cost on every single generation request.
ENGINE = get_engine(EXP_DIR, CONFIG_NAME, CKPT_NAME)
# SPACES_ZERO_GPU is set by the platform only on ZeroGPU Spaces, where no GPU is
# attached to this (the main) process -- only to a @spaces.GPU-decorated call. On
# dedicated hardware CUDA is attached for the process's whole lifetime, so move the
# model there and compile it once now instead of paying that cost on the first
# request (run_rollout's own ENGINE.ensure_ready() call becomes a no-op once this
# has already run -- see RolloutEngine.ensure_ready's idempotency).
ON_ZERO_GPU = bool(os.environ.get("SPACES_ZERO_GPU"))
if not ON_ZERO_GPU:
print("[startup] not running on ZeroGPU (SPACES_ZERO_GPU unset) -- "
"moving model to CUDA and compiling now…")
ENGINE.ensure_ready(device="cuda", compile=True,
compile_artifacts=str(compile_cache_path()))
# Earliest point (in a CONTEXT_FPS-encoded upload) that has enough L1+L2 lookback to
# serve as the end of a valid context window -- the player auto-seeks here on load
# instead of sitting at time 0 (never valid: there's nothing before it). CPU-only,
# so safe to compute once here, right after the CPU-loaded ENGINE above.
MIN_CONTEXT_END_FRAME = ENGINE.min_context_end_frame(L1_FRAME_RATE)
MIN_CONTEXT_TIME_S = MIN_CONTEXT_END_FRAME / CONTEXT_FPS
FRAMES_PER_GEN_STEP = ENGINE.frames_per_rollout_step
# Seconds of output video one rollout step yields, at L1_FRAME_RATE fps -- lets the
# UI expose rollout length in seconds while the model itself still steps in whole
# rollout steps (see GEN_SECONDS_STEP / run_rollout's seconds -> steps conversion below).
SECONDS_PER_GEN_STEP = FRAMES_PER_GEN_STEP / L1_FRAME_RATE
DEFAULT_GEN_STEPS = 10 # rollout steps; each yields model.num_pred_frames frames
GEN_STEPS_MIN, GEN_STEPS_MAX, GEN_STEPS_STEP = 4, 30, 2 # bounds on the underlying step count
# The slider itself is in seconds (see build_demo()); these are its bounds, derived
# from the step-count bounds above via SECONDS_PER_GEN_STEP.
GEN_SECONDS_MIN = GEN_STEPS_MIN * SECONDS_PER_GEN_STEP
GEN_SECONDS_MAX = GEN_STEPS_MAX * SECONDS_PER_GEN_STEP
GEN_SECONDS_STEP = GEN_STEPS_STEP * SECONDS_PER_GEN_STEP
DEFAULT_GEN_SECONDS = DEFAULT_GEN_STEPS * SECONDS_PER_GEN_STEP
# L1 and L2 are sampled at different step counts. L1 (detail) needs more steps for
# image fidelity; L2 (abstract, consistency-distilled) is designed for very few.
DEFAULT_L1_STEPS = 4 # L1 sampler steps (NFE)
DEFAULT_L2_STEPS = 4 # L2 sampler steps (NFE) — matches the config's l2_pred_NFE
OUTPUT_FPS = L1_FRAME_RATE
NUM_VIDEOS = 3 # futures rolled out per run, batched into one minibatch
DEFAULT_ETA = 0.0 # 0 = deterministic ODE; >0 injects noise each solver step
# Very light cornflower-blue background + one darker cornflower-blue accent used
# consistently for the title/subtitle and interactive widgets (see build_demo()'s css=).
THEME_BG = "#eef3fb"
THEME_ACCENT = "#2f56a3"
TITLE_HTML = f"""
<div style="text-align:center; margin-bottom:8px;">
<div style="font-size:2.75rem; font-weight:800; color:{THEME_ACCENT}; line-height:1.1;">
Orbis 2
</div>
<div style="font-size:1.15rem; font-weight:500; color:{THEME_ACCENT}; margin-top:4px;">
A Hierarchical World Model for Driving
</div>
</div>
"""
AUTHOR_HTML = """
<div style="text-align:center; margin-bottom:4px;">
<a href="https://lmb.informatik.uni-freiburg.de/people/mittal/">Sudhanshu Mittal</a>*,
<a href="https://lmb.informatik.uni-freiburg.de/people/mousakha/">Arian Mousakhan</a>*,
<a href="https://lmb.informatik.uni-freiburg.de/people/galessos/">Silvio Galesso</a>*,
<a href="https://lmb.informatik.uni-freiburg.de/people/faridk/">Karim Farid</a>,
<a href="https://lmb.informatik.uni-freiburg.de/people/dienertj/">Johannes Dienert</a>,
<a href="https://lmb.informatik.uni-freiburg.de/people/sahayr/">Rajat Sahay</a>,
<a href="https://lmb.informatik.uni-freiburg.de/people/brox/index.html">Thomas Brox</a> — *main contributors<br>
University of Freiburg, Germany
</div>
<div style="text-align:center; margin-bottom:8px;">
<a href="https://lmb-freiburg.github.io/orbis2.github.io/">Project page</a> ·
<a href="https://github.com/lmb-freiburg/orbis">Code</a> ·
<a href="https://lmb-freiburg.github.io/orbis.github.io/">Orbis 1</a>
</div>
"""
DESCRIPTION = f"""
Upload a short driving clip (a few seconds is enough), or select one of the example
clips. The hierarchical model takes
the tail of the clip as L1 (high-rate) and L2 (low-rate, further back) context and
autoregressively predicts future frames.
Each run samples **{NUM_VIDEOS} rollouts** from the same context in a single
minibatch, so you see several plausible futures diverge from identical starting
conditions.
"""
PAPER_MD = """
### Abstract
> Current world models typically operate at a single abstraction level, favoring
> perceptual fidelity but lacking the spatial and semantic reasoning needed for
> downstream driving tasks. We propose a hierarchical driving world model that
> separates prediction into a high-level long-horizon scene forecaster and a
> low-level detail generator conditioned on it. This design improves both visual
> fidelity and spatial-semantic representation quality. We also introduce a
> two-stage training strategy: diffusion-forcing pretraining for richer
> representations, followed by teacher-forcing fine-tuning for stable
> autoregressive rollouts. Our method achieves state-of-the-art results on
> standard driving world model benchmarks, including long-horizon fidelity,
> counterfactual steering responsiveness, and internal representation quality.
### Method
Orbis 2 splits future prediction across two levels of abstraction:
- **Abstract predictor (L2)** — operates over a long temporal context to forecast a
future state in latent space, capturing abstract scene dynamics over long
horizons and enabling steering control.
- **Detail predictor (L1)** — conditioned on that abstract prediction, generates
fine-grained short-horizon frames, so high-fidelity local prediction stays
grounded in long-range temporal context.
Training runs in two stages: **diffusion-forcing pretraining** for richer
representations, followed by **teacher-forcing fine-tuning** for stable
autoregressive rollouts.
### Links & citation
```bibtex
@article{orbis2_2026,
author = {Mittal, Sudhanshu and Mousakhan, Arian and Galesso, Silvio and
Farid, Karim and Dienert, Johannes and Sahay, Rajat and Brox, Thomas},
title = {Orbis 2: A Hierarchical World Model for Driving},
journal = {arXiv preprint arXiv:2607.15898},
year = {2026},
}
```
"""
DEMO_MD = f"""
**Pipeline.** Your clip is re-encoded to a constant {CONTEXT_FPS} fps. The rollout
script samples two context windows from the tail of that video: an **L1** window at
{L1_FRAME_RATE} Hz (fine, recent frames) and an **L2** window sampled further back at
the frozen L2 predictor's own trained rate (coarse, long-horizon context). Each frame
is resized to {FRAME_H}×{FRAME_W}. The **L2 abstract predictor** forecasts a latent
future, and the **L1 detail predictor**, conditioned on it, generates the next frames
autoregressively.
**Rollout length.** *Generated video length (s)* sets how much video to generate; under
the hood this is rounded to a whole number of autoregressive steps, each of which emits
{FRAMES_PER_GEN_STEP} image frame{"s" if FRAMES_PER_GEN_STEP != 1 else ""} ({SECONDS_PER_GEN_STEP:.2g}s of video per step).
**Sampling.** The two levels are sampled independently, with their own step counts.
*L1 sampler steps* controls the detail predictor, which integrates the flow-matching
ODE and benefits from more steps (sharper frames, slower). *L2 sampler steps* controls
the abstract predictor, which is **consistency-distilled** and so is designed to run in
very few steps — raising it buys little.
**Randomness.** The {NUM_VIDEOS} rollouts share one context and one seed, but the
solver draws independent initial noise per batch element, so the futures diverge.
Leave *Seed* at -1 for a fresh draw each run, or set it explicitly to reproduce a run
exactly.
**Steering (optional).** Expand "Draw a steering trajectory" to freehand a path;
it's resampled and smoothed, then used to condition the rollout instead of running
unconditionally. Leave it untouched, or hit its Clear button, to roll out without
steering, as before.
**Note.** The clip must be long enough for the L2 look-back window; very short clips
are rejected with an explicit error. Frame and step counts are kept modest to fit the
ZeroGPU time budget.
"""
# ----------------------------------------------------------------------------
# Trajectory canvas (freehand steering input) — copied from app_traj.py, whose
# header documents these pieces as copy-paste-ready: the pure Python functions
# have no Gradio dependency, and CANVAS_HTML_JS / HEAD_SCRIPT are self-contained.
#
# Canvas coordinate convention: x = horizontal/lateral (X_MIN_M..X_MAX_M), y =
# vertical/forward (Y_MIN_M..Y_MAX_M), so points come back as [lateral, forward].
# rollout_demo_v2.py's --trajectory_file expects the opposite, [forward, lateral]
# (see trajectory_to_speed_yawrate in orbis2/evaluate/rollout_demo_v2.py) — the
# columns are swapped where the file is written, in run_rollout below.
# ----------------------------------------------------------------------------
# Both axes span the same number of meters and the canvas is square with the same
# px/meter on both axes, so a shape drawn on screen matches its real-world proportions
# instead of being stretched. X is centered on 0 (lateral: left/right of start); Y
# starts at 0 (forward: trajectory starts at the bottom, drawn upward).
TRAJ_EXTENT_M = 8 # meters spanned by each axis
TRAJ_X_MIN_M, TRAJ_X_MAX_M = -TRAJ_EXTENT_M / 2, TRAJ_EXTENT_M / 2
TRAJ_Y_MIN_M, TRAJ_Y_MAX_M = 0, TRAJ_EXTENT_M
TRAJ_PX_PER_M = 35
# Shrinks the on-screen canvas to 2/3 of its nominal (TRAJ_EXTENT_M * TRAJ_PX_PER_M)
# size. Safe to change independently of TRAJ_PX_PER_M: the JS below (metersToPixel /
# pixelToMeters) always normalizes by canvas.width/height, never a hardcoded pixel
# density, so it scales cleanly with whatever this canvas's actual size ends up being.
TRAJ_CANVAS_SCALE = 1.05 #2 / 3
TRAJ_CANVAS_SIZE_PX = int(TRAJ_EXTENT_M * TRAJ_PX_PER_M * TRAJ_CANVAS_SCALE)
# Canvas drawing units (comfortable -5..5m / 0..20m) are not calibrated to this
# model's dt=1/L1_FRAME_RATE=0.1s; scale positions up before the model sees them
# so a drawn stroke implies a realistic speed. Derived empirically: a near-full-
# height canvas stroke (~11.5m arc length over 200 samples) implied ~2.9 m/s at
# this model's dt, vs. ~24.2 m/s for a known-good reference trajectory (both
# figures computed with the same, since-corrected dt assumption — the 8.4x
# ratio between them is dt-invariant) — ratio ~8.4x.
TRAJ_REAL_WORLD_SCALE = 8.4
TRAJ_N_SAMPLES = 200 # resolution of the authoritative curve sent to the model
TRAJ_SMOOTHING_PASSES = 4 # more passes = smoother; 1 pass is a gentle nudge
TRAJ_SMOOTHING_WINDOW_FRAC = 0.15 # smoothing window as a fraction of TRAJ_N_SAMPLES
# TRAJ_N_SAMPLES points spread evenly over the calibrated dt=1/L1_FRAME_RATE pace
# span TRAJ_N_SAMPLES/L1_FRAME_RATE seconds of implied travel time, so point index
# i represents i/L1_FRAME_RATE seconds -- used to mark one point per second on the
# canvas overlay instead of all TRAJ_N_SAMPLES (too dense to read).
TRAJ_SAMPLES_PER_SECOND = L1_FRAME_RATE
def resample_by_arclength(points: np.ndarray, n_samples: int) -> np.ndarray:
"""Resample a raw (possibly unevenly-spaced) path to `n_samples` points
evenly spaced by arc length. Freehand strokes have point density that
depends on how fast the user moved the mouse, so this normalizes that
out before smoothing -- otherwise slow/fast sections would get smoothed
by different effective amounts.
"""
pts = np.asarray(points, dtype=float)
if len(pts) < 2:
return np.repeat(pts, max(n_samples, 1), axis=0)[:n_samples]
deltas = np.diff(pts, axis=0)
seg_lengths = np.sqrt((deltas ** 2).sum(axis=1))
cum_len = np.concatenate([[0.0], np.cumsum(seg_lengths)])
total_len = cum_len[-1]
if total_len == 0:
# User clicked without dragging: no real path, just repeat the point.
return np.repeat(pts[:1], n_samples, axis=0)
targets = np.linspace(0, total_len, n_samples)
x = np.interp(targets, cum_len, pts[:, 0])
y = np.interp(targets, cum_len, pts[:, 1])
return np.stack([x, y], axis=1)
def smooth_trajectory(
points: np.ndarray,
n_samples: int = TRAJ_N_SAMPLES,
passes: int = TRAJ_SMOOTHING_PASSES,
window_frac: float = TRAJ_SMOOTHING_WINDOW_FRAC,
) -> np.ndarray:
"""Turn a raw freehand stroke into a smooth model-facing trajectory:
resample to even spacing, then repeatedly box-filter both coordinate
channels (more passes ~ approximates a Gaussian blur, i.e. "heavy"
smoothing). Finally re-anchors the curve so it starts exactly at
(0, 0), since smoothing can nudge the very first point slightly, and
rotates it about that origin so the initial segment points straight
up (+y, zero lateral/x component) regardless of which direction the
stroke was actually drawn in.
"""
pts = resample_by_arclength(points, n_samples)
if len(pts) < 2:
return pts
window = max(int(n_samples * window_frac), 3)
if window % 2 == 0:
window += 1 # odd window keeps the filter centered
kernel = np.ones(window) / window
smoothed = pts.copy()
for _ in range(passes):
# Edge-pad so the curve doesn't shrink/shorten at the endpoints.
pad = window // 2
padded_x = np.pad(smoothed[:, 0], pad, mode="edge")
padded_y = np.pad(smoothed[:, 1], pad, mode="edge")
smoothed = np.stack(
[np.convolve(padded_x, kernel, mode="valid"),
np.convolve(padded_y, kernel, mode="valid")],
axis=1,
)
smoothed -= smoothed[0] # force the path to start exactly at (0, 0)
# Rotate about the origin so the initial segment points straight up (+y,
# zero lateral/x component) -- mirrors the heading-alignment rotation
# trajectory_to_model_frame() applies downstream, but at the canvas-space
# level so the on-screen overlay/plot already reflects it, not just the
# file eventually written for the model.
heading0 = np.arctan2(smoothed[1, 1] - smoothed[0, 1], smoothed[1, 0] - smoothed[0, 0])
delta = np.pi / 2 - heading0
cos_d, sin_d = np.cos(delta), np.sin(delta)
rotation = np.array([[cos_d, -sin_d], [sin_d, cos_d]])
smoothed = smoothed @ rotation.T
return smoothed
def parse_points_json(raw_json: str) -> np.ndarray:
"""Parse the JSON string written by JS (list of [x, y] in real meters,
x in roughly [TRAJ_X_MIN_M, TRAJ_X_MAX_M], y in roughly [TRAJ_Y_MIN_M,
TRAJ_Y_MAX_M]) into an (N, 2) numpy array. Returns an empty (0, 2) array
if the input is empty/invalid, so callers can handle the "nothing drawn
yet" state without try/except everywhere.
"""
try:
data = json.loads(raw_json) if raw_json else []
pts = np.array(data, dtype=float)
return pts.reshape(-1, 2) if pts.size else np.empty((0, 2))
except (json.JSONDecodeError, ValueError):
return np.empty((0, 2))
def points_to_trajectory(raw_json: str, n_samples: int = TRAJ_N_SAMPLES) -> np.ndarray:
"""End-to-end: raw freehand stroke JSON -> authoritative smoothed
(n_samples, 2) trajectory array. This is the function whose output
should actually be fed into the model.
"""
pts = parse_points_json(raw_json)
if len(pts) < 2:
return np.empty((0, 2))
return smooth_trajectory(pts, n_samples)
def trajectory_to_model_frame(trajectory: np.ndarray) -> np.ndarray:
"""Convert a canvas-drawn trajectory ([x=lateral(right+), y=forward], in canvas
meters) into the [forward, lateral] array rollout_demo_v2.py's --trajectory_file
expects, applying three corrections the canvas alone doesn't account for:
1. Column swap: canvas is [lateral, forward]; the model wants [forward, lateral].
2. Sign flip on lateral: the canvas's x-axis is positive-right (screen convention),
but rollout_demo_v2.py's own overlay rendering (_panel_coords_fit_trajectory:
`px = margin + (lateral_max - lateral) * scale`) shows increasing lateral moving
LEFT on screen, i.e. this codebase's convention is positive-lateral = left.
Without negating, a rightward-drawn stroke would condition/render as a left turn.
3. Heading alignment: trajectory_to_speed_yawrate/its reconstruction always treats
the trajectory's own first segment as pointing exactly along local +forward. A
freehand stroke's first smoothed segment is essentially never *exactly* aligned
with forward (mouse imprecision, smoothing edge effects) -- even a couple of
degrees of initial misalignment, left uncorrected, gets amplified into meters of
apparent lateral drift by the far end of a long trajectory once scaled up by
TRAJ_REAL_WORLD_SCALE. Rotating so segment 0 already points along +forward before
conversion matches what the reconstruction assumes, eliminating that drift.
Verified against a real canvas-drawn+smoothed trajectory: this reduces the
round-trip (speed/yaw_rate -> reconstructed position) error from several meters
down to ~0.16m over a ~97m trajectory.
"""
traj = np.asarray(trajectory)[:, [1, 0]] * TRAJ_REAL_WORLD_SCALE # [forward, lateral(right+)]
traj = traj.copy()
traj[:, 1] *= -1 # canvas right+ -> model left+
traj -= traj[0]
heading0 = np.arctan2(traj[1, 1] - traj[0, 1], traj[1, 0] - traj[0, 0])
cos_h, sin_h = np.cos(-heading0), np.sin(-heading0)
rotation = np.array([[cos_h, -sin_h], [sin_h, cos_h]])
return traj @ rotation.T
def render_trajectory_plot(raw_json: str):
"""Return the dense trajectory array into a gr.State for downstream use,
plus that same trajectory as a JSON string -- the JSON string is consumed
client-side to draw the smoothed curve back onto the canvas (see the
`smoothed_points_json` wiring in build_demo()).
"""
trajectory = points_to_trajectory(raw_json)
smoothed_json = json.dumps(trajectory.tolist()) if len(trajectory) else "[]"
return trajectory, smoothed_json
# --------------------------------------------------------------------------
# Canvas markup only -- NO <script> here. gr.HTML renders its content via
# innerHTML, and browsers do not execute <script> tags inserted that way.
# The actual JS lives in TRAJ_HEAD_SCRIPT below instead, which runs normally
# because it's inserted as real page <head> content.
# --------------------------------------------------------------------------
TRAJ_CANVAS_HTML_JS = f"""
<div style="position:relative; width:{TRAJ_CANVAS_SIZE_PX}px; margin:0 auto;">
<canvas id="traj-canvas" width="{TRAJ_CANVAS_SIZE_PX}" height="{TRAJ_CANVAS_SIZE_PX}"
style="display:block; border:1px solid #999; border-radius:8px; cursor:crosshair; background:#fafafa;">
</canvas>
<div style="position:absolute; top:8px; left:8px; display:flex; align-items:center; gap:8px;
background:rgba(255,255,255,0.85); border-radius:6px; padding:6px 8px;
font-size:11px; color:#555; line-height:1.4; pointer-events:none;">
<button id="traj-clear-btn"
style="font-size:13px; font-weight:600; padding:5px 14px; cursor:pointer;
background:#dc2626; color:#fff; border:none; border-radius:6px;
box-shadow:0 1px 3px rgba(0,0,0,0.35); pointer-events:auto; white-space:nowrap;">
Clear
</button>
<div style="text-align:left;">
<div><span style="color:#dc2626;">Red</span> = what you drew</div>
<div><span style="color:#4f46e5;">Blue</span> = model input (smoothed and aligned)</div>
</div>
</div>
</div>
"""
# --------------------------------------------------------------------------
# JS canvas logic: freehand paint a stroke, translate it to start at the
# fixed origin, and send the raw points to Python once the stroke finishes.
# All smoothing happens server-side (see smooth_trajectory above) -- this
# script only ever renders the raw, unsmoothed stroke for feedback.
#
# Placed in gr.Blocks(head=...) rather than inside the gr.HTML block above
# so the browser actually executes it (see comment above). Because gr.HTML
# content can render slightly after the head script runs,
# initTrajectoryCanvas() is invoked via a short poll that waits for the
# canvas element to exist in the DOM, then attaches listeners exactly once.
# --------------------------------------------------------------------------
TRAJ_HEAD_SCRIPT = f"""
<script>
function initTrajectoryCanvas() {{
const canvas = document.getElementById("traj-canvas");
const ctx = canvas.getContext("2d");
const clearBtn = document.getElementById("traj-clear-btn");
let isDrawing = false;
let rawStroke = []; // pixel-space points recorded during the current/last stroke
let smoothedOverlay = []; // pixel-space smoothed curve, received back from Python
const MIN_POINT_DIST = 3; // px; throttles how densely we sample while dragging
// Coordinate helpers: canvas pixel space has y=0 at the TOP, but
// trajectories are usually thought of with y=0 at the BOTTOM ("up" is
// positive) -- these flip that axis. They also map to/from real-world
// meters using the physical extent [X_MIN_M, X_MAX_M] x [Y_MIN_M, Y_MAX_M].
const X_MIN_M = {TRAJ_X_MIN_M}, X_MAX_M = {TRAJ_X_MAX_M};
const Y_MIN_M = {TRAJ_Y_MIN_M}, Y_MAX_M = {TRAJ_Y_MAX_M};
const SAMPLES_PER_SECOND = {TRAJ_SAMPLES_PER_SECOND}; // mark one overlay point per second, not all of them
function metersToPixel(mx, my) {{
const px = (mx - X_MIN_M) / (X_MAX_M - X_MIN_M) * canvas.width;
const py = canvas.height - (my - Y_MIN_M) / (Y_MAX_M - Y_MIN_M) * canvas.height;
return {{ x: px, y: py }};
}}
function pixelToMeters(px, py) {{
const mx = X_MIN_M + (px / canvas.width) * (X_MAX_M - X_MIN_M);
const my = Y_MIN_M + ((canvas.height - py) / canvas.height) * (Y_MAX_M - Y_MIN_M);
return {{ x: mx, y: my }};
}}
const ORIGIN_PX = metersToPixel(0, 0); // fixed start point, in pixel space
// Called from the `smoothed_points_json.change(..., js=...)` listener
// below whenever Python sends back a newly-smoothed curve. Converts it
// to pixel space and stores it for draw() to render as an overlay.
window.trajUpdateOverlay = function (jsonStr) {{
try {{
const data = JSON.parse(jsonStr || "[]");
smoothedOverlay = data.map(([mx, my]) => metersToPixel(mx, my));
}} catch (e) {{
smoothedOverlay = [];
}}
draw();
}};
// Redraw the meter-scale grid (with axis labels), reference axis,
// the raw stroke, the smoothed overlay, and the origin marker.
function draw() {{
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Grid lines every 1m in x, every 2m in y, with tick labels along
// the bottom/left edges so the scale is readable at a glance.
ctx.strokeStyle = "#eee";
ctx.fillStyle = "#888";
ctx.font = "10px sans-serif";
for (let mx = X_MIN_M; mx <= X_MAX_M; mx += 1) {{
const px = metersToPixel(mx, 0).x;
ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, canvas.height); ctx.stroke();
ctx.fillText(mx.toFixed(0), px + 2, canvas.height - 4);
}}
for (let my = Y_MIN_M; my <= Y_MAX_M; my += 2) {{
const py = metersToPixel(0, my).y;
ctx.beginPath(); ctx.moveTo(0, py); ctx.lineTo(canvas.width, py); ctx.stroke();
ctx.fillText(my.toFixed(0), 2, py - 2);
}}
ctx.fillText("x (m)", canvas.width - 32, canvas.height - 4);
ctx.fillText("y (m)", 2, 10);
// Highlight x=0: the reference axis for left vs. right turns, won't
// generally line up with the meter grid's tick spacing above.
const xZeroPx = metersToPixel(0, 0).x;
ctx.strokeStyle = "#94a3b8";
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(xZeroPx, 0);
ctx.lineTo(xZeroPx, canvas.height);
ctx.stroke();
ctx.lineWidth = 1;
if (rawStroke.length >= 2) {{
ctx.strokeStyle = "#dc2626";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(rawStroke[0].x, rawStroke[0].y);
for (const pt of rawStroke.slice(1)) ctx.lineTo(pt.x, pt.y);
ctx.stroke();
}}
if (smoothedOverlay.length >= 2) {{
ctx.strokeStyle = "#4f46e5";
ctx.lineWidth = 2.5;
ctx.setLineDash([7, 5]);
ctx.beginPath();
ctx.moveTo(smoothedOverlay[0].x, smoothedOverlay[0].y);
for (const pt of smoothedOverlay.slice(1)) ctx.lineTo(pt.x, pt.y);
ctx.stroke();
ctx.setLineDash([]); // reset so it doesn't leak into other strokes
// Mark one point per second of implied travel time (not every one of the
// TRAJ_N_SAMPLES resampled points -- that's too dense to read), so the
// pacing along the curve is visible.
ctx.fillStyle = "#4f46e5";
for (let i = 0; i < smoothedOverlay.length; i += SAMPLES_PER_SECOND) {{
const pt = smoothedOverlay[i];
ctx.beginPath();
ctx.arc(pt.x, pt.y, 3, 0, 2 * Math.PI);
ctx.fill();
}}
}}
// Fixed origin marker, drawn last so it's always on top of everything.
ctx.fillStyle = "#16a34a";
ctx.fillRect(ORIGIN_PX.x - 6, ORIGIN_PX.y - 6, 12, 12);
}}
// Send the current raw stroke (in meters) to Python and trigger its
// .change() listener, which resamples + heavily smooths it there.
function syncToGradio() {{
const inMeters = rawStroke.map(p => {{
const m = pixelToMeters(p.x, p.y);
return [m.x, m.y];
}});
const hidden = document.querySelector("#raw_points_json textarea");
if (hidden) {{
hidden.value = JSON.stringify(inMeters);
hidden.dispatchEvent(new Event("input", {{ bubbles: true }}));
}}
}}
function getMousePos(evt) {{
const rect = canvas.getBoundingClientRect();
return {{ x: evt.clientX - rect.left, y: evt.clientY - rect.top }};
}}
function distance(a, b) {{
return Math.hypot(a.x - b.x, a.y - b.y);
}}
// Shift every point in the stroke so it starts exactly at the fixed
// origin, preserving the drawn shape relative to that anchor.
function anchorStrokeToOrigin() {{
if (rawStroke.length === 0) return;
const dx = ORIGIN_PX.x - rawStroke[0].x;
const dy = ORIGIN_PX.y - rawStroke[0].y;
rawStroke = rawStroke.map(p => ({{ x: p.x + dx, y: p.y + dy }}));
}}
canvas.addEventListener("mousedown", (evt) => {{
isDrawing = true;
rawStroke = [getMousePos(evt)]; // start a fresh stroke, discard the old one
draw();
}});
canvas.addEventListener("mousemove", (evt) => {{
if (!isDrawing) return;
const pos = getMousePos(evt);
const last = rawStroke[rawStroke.length - 1];
if (!last || distance(last, pos) >= MIN_POINT_DIST) {{
rawStroke.push(pos);
draw(); // live feedback while painting, no server round-trip
}}
}});
function finishStroke() {{
if (!isDrawing) return;
isDrawing = false;
if (rawStroke.length >= 2) {{
anchorStrokeToOrigin();
draw();
syncToGradio(); // only sync once the stroke is complete
}}
}}
canvas.addEventListener("mouseup", finishStroke);
canvas.addEventListener("mouseleave", finishStroke); // in case the drag exits the canvas
clearBtn.addEventListener("click", () => {{
rawStroke = [];
draw();
syncToGradio();
}});
draw();
// Deliberately not calling syncToGradio() here (unlike the standalone
// app_traj.py demo this was copied from): "Generate rollouts" must stay
// unconditional until the user actually draws or clears the canvas.
canvas.dataset.trajInitialized = "true"; // guard against double-init
}}
// Poll for the canvas element since gr.HTML content can render slightly
// after this head script runs. Stops as soon as it's found and initialized.
const trajPollInterval = setInterval(() => {{
const canvas = document.getElementById("traj-canvas");
if (canvas && !canvas.dataset.trajInitialized) {{
clearInterval(trajPollInterval);
initTrajectoryCanvas();
}}
}}, 200);
</script>
"""
# ----------------------------------------------------------------------------
# Input-video cursor tracking. Rather than physically cropping the upload,
# "Generate rollouts" uses wherever the playhead is left as the end of the
# context window (see run_rollout / RolloutEngine.roll_out's context_end_frame).
#
# Gradio tears down and recreates the <video> element inside #input_video on
# every upload, so listeners are attached once at the document level in the
# capture phase (video events like `seeked`/`pause` don't bubble) instead of
# polling for the element like the trajectory canvas above does -- capture-phase
# delegation keeps working across re-renders with no re-attachment needed.
# ----------------------------------------------------------------------------
CURSOR_HEAD_SCRIPT = f"""
<script>
const MIN_CONTEXT_TIME_S = {MIN_CONTEXT_TIME_S};
function isInputVideo(target) {{
return target instanceof HTMLVideoElement
&& target.closest("#input_video") !== null;
}}
function writeCursorTime(seconds) {{
const hidden = document.querySelector("#cursor_time_s textarea");
if (hidden) {{
hidden.value = String(seconds);
hidden.dispatchEvent(new Event("input", {{ bubbles: true }}));
}}
}}
document.addEventListener("loadedmetadata", (evt) => {{
if (!isInputVideo(evt.target)) return;
// Auto-seek away from time 0 (never a valid context end) to the earliest
// frame with enough lookback. If this seek has no effect for some reason
// (e.g. a non-seekable stream), no "seeked" event follows and the hidden
// field is simply never written -- run_rollout then falls back to using
// the tail of the whole video, same as before this feature existed.
evt.target.currentTime = Math.min(MIN_CONTEXT_TIME_S, evt.target.duration || MIN_CONTEXT_TIME_S);
}}, true);
document.addEventListener("seeked", (evt) => {{
if (!isInputVideo(evt.target)) return;
writeCursorTime(evt.target.currentTime);
}}, true);
document.addEventListener("timeupdate", (evt) => {{
if (!isInputVideo(evt.target)) return;
writeCursorTime(evt.target.currentTime);
}}, true);
</script>
"""
# ----------------------------------------------------------------------------
# Video helpers (CPU)
# ----------------------------------------------------------------------------
def reencode_to_fps(src_path: str, out_path: Path, fps: int = CONTEXT_FPS):
"""Re-encode a clip to a constant `fps` H.264 mp4.
The rollout script requires the video's native frame rate to be an exact integer
multiple of the L1/L2 sampling rates. Forcing a constant fps here (via -vsync cfr)
makes an arbitrary upload satisfy that check.
"""
out_path.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(
["ffmpeg", "-y", "-i", str(src_path),
"-vsync", "cfr", "-r", str(fps),
"-an", "-c:v", "libx264", "-pix_fmt", "yuv420p",
"-movflags", "+faststart", str(out_path)],
check=True, capture_output=True,
)
EXAMPLES_DIR = Path(__file__).parent / "examples"
EXAMPLE_THUMB_DIR = EXAMPLES_DIR / ".thumbs"
def example_thumbnail(video_path: Path) -> str:
"""First-frame jpg thumbnail for an example clip, generated once and cached
alongside it (regenerated if the source clip is newer than the cached thumb)."""
thumb_path = EXAMPLE_THUMB_DIR / f"{video_path.stem}.jpg"
if not thumb_path.exists() or thumb_path.stat().st_mtime < video_path.stat().st_mtime:
thumb_path.parent.mkdir(parents=True, exist_ok=True)
cap = cv2.VideoCapture(str(video_path))
ok, frame = cap.read()
cap.release()
if not ok:
raise RuntimeError(f"Could not read a frame from example clip {video_path}")
cv2.imwrite(str(thumb_path), frame)
return str(thumb_path)
def discover_examples() -> list[Path]:
"""Example clips to show as clickable thumbnails next to the upload box.
Returns whatever's in examples/ (possibly none) rather than a fixed list, so
clips can be dropped in/removed without a code change."""
return sorted(EXAMPLES_DIR.glob("*.mp4"))
def frames_to_mp4(frame_paths: list[Path], out_path: Path, fps: int = OUTPUT_FPS):
first = cv2.imread(str(frame_paths[0]))
h, w = first.shape[:2]
# mp4v then re-encode with ffmpeg for browser-compatible H.264
tmp = out_path.with_suffix(".raw.mp4")
writer = cv2.VideoWriter(str(tmp), cv2.VideoWriter_fourcc(*"mp4v"), fps, (w, h))
for p in frame_paths:
writer.write(cv2.imread(str(p)))
writer.release()
subprocess.run(
["ffmpeg", "-y", "-i", str(tmp), "-c:v", "libx264",
"-pix_fmt", "yuv420p", "-movflags", "+faststart", str(out_path)],
check=True, capture_output=True,
)
tmp.unlink(missing_ok=True)
def _parse_cursor_time_s(raw) -> float | None:
"""Parse the hidden cursor_time_s textbox. Returns None for the "-1" sentinel
(cursor never moved, or the browser couldn't seek/report it) and for anything
unparsable, so callers can fall back to the tail-of-video default."""
try:
t = float(raw)
except (TypeError, ValueError):
return None
return t if t >= 0 else None
# ----------------------------------------------------------------------------
# GPU inference
# ----------------------------------------------------------------------------
@spaces.GPU(duration=60)
def run_rollout(video_path: str, gen_seconds: float, l1_steps: int, l2_steps: int,
seed: int, trajectory, cursor_time_s: str, progress=gr.Progress()):
if video_path is None:
raise gr.Error("Please upload a short mp4 clip first.")
if UI_ONLY:
print(f"[ui-only] cursor_time_s = {cursor_time_s!r}")
raise gr.Error(
"Running with --ui-only: no model is loaded, so rollouts can't be "
"generated. Restart without --ui-only (on a GPU machine) to run inference."
)
def say(frac, msg):
# progress=None hides the bar (and, it turns out, the desc text along with it --
# Gradio falls back to its generic loading spinner instead), so always pass a
# real fraction: 0.0 through "Loading", the real per-step fraction through
# "Rolling out", 1.0 through "Saving"/"Done".
progress(frac, desc=msg)
try:
# seed < 0 means "surprise me" — draw a fresh positive seed. The rollout script
# only seeds when seed > 0, hence the clamp. Within a batch each sample still
# draws its own noise, so one seed yields NUM_VIDEOS distinct rollouts; setting
# it explicitly lets a run be reproduced exactly.
seed = random.randrange(1, 2**31 - 1) if int(seed) < 0 else max(1, int(seed))
# eta is not exposed: 0.0 keeps sampling on the deterministic ODE.
eta = DEFAULT_ETA
# The model itself only understands whole rollout steps -- convert the
# seconds the user picked back into a step count (see SECONDS_PER_GEN_STEP).
num_gen_steps = max(1, round(gen_seconds / SECONDS_PER_GEN_STEP))
job = Path(tempfile.gettempdir()) / f"orbis2_{uuid.uuid4().hex[:8]}"
say(0.0, "⏳ Loading…")
print(f"[load] re-encoding uploaded clip to {CONTEXT_FPS} fps…")
ctx_video = job / "context.mp4"
reencode_to_fps(video_path, ctx_video, CONTEXT_FPS)
print(f"[load] context video ready at {ctx_video}")
# The playhead position (wherever the user left it) marks where the context
# window ends -- convert it from seconds to a frame index in the re-encoded
# (CONTEXT_FPS) video. The "-1" sentinel (cursor never moved / not reported)
# falls back to None, which makes RolloutEngine.roll_out use the tail of the
# whole video, same as before this feature existed.
print("[load] resolving context window from cursor position…")
parsed_cursor_s = _parse_cursor_time_s(cursor_time_s)
context_end_frame = None
if parsed_cursor_s is not None:
cap = cv2.VideoCapture(str(ctx_video))
ctx_frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
cap.release()
context_end_frame = max(0, min(round(parsed_cursor_s * CONTEXT_FPS), ctx_frame_count - 1))
print(f"context @ {CONTEXT_FPS} fps | cursor {parsed_cursor_s} s -> "
f"end frame {context_end_frame} | seed {seed} | "
f"{int(num_gen_steps)} rollout steps | L1 {int(l1_steps)} / L2 {int(l2_steps)} "
f"sampler steps | eta {eta}")
output_dir = job / "rollout"
traj_path = None
if trajectory is not None and len(trajectory) >= 2:
print("[load] preparing trajectory file…")
traj_path = job / "trajectory.npy"
np.save(traj_path, trajectory_to_model_frame(trajectory))
# First request in this worker moves the model to CUDA and compiles it, loading
# this environment's cached artifacts if they're already there from a previous
# run, or compiling from scratch and saving them if not; every later request
# just reuses that already-ready model (see orbis2_app_engine.py).
cache_path = compile_cache_path()
print(f"[load] ensure_ready: moving model to GPU / compiling if needed… (compile cache: {cache_path})")
ENGINE.ensure_ready(device="cuda", compile=True,
compile_artifacts=str(cache_path))
# Deliberately no say(..., "Rolling out…") here: between this point and the
# first real tick below, roll_out is still doing L2's upfront forecast (and,
# on a fresh environment, a possibly multi-minute from-scratch torch.compile)
# with no progress signal of its own -- showing "Rolling out" for that silent
# stretch would be misleading, so the bar just stays on "Loading…" until
# progress_cb's first call proves a rollout step has actually completed.
#
# ENGINE.roll_out reports one tick per autoregressive rollout step (out of
# num_gen_steps total) via progress_cb -- the bar climbs 0 -> 1 across those
# ticks, so it's already sitting at 100% by the time "Saving" replaces the text
# (decode + trajectory overlay + frame writing, which happen after the last
# tick with no progress signal of their own, don't need their own fraction).
def _rollout_progress(step, total):
frac = step / total if total else 1.0
say(frac, f"🚗 Rolling out… ({step}/{total})")
ENGINE.roll_out(
video_path=str(ctx_video),
output_dir=str(output_dir),
num_gen_frames=int(num_gen_steps),
# L1 and L2 sample at different step counts; num_steps is L1's NFE,
# l2_nfe overrides the config's l2_pred_NFE for the L2 predictor.
num_steps=int(l1_steps),
l2_nfe=int(l2_steps),
l1_frame_rate=L1_FRAME_RATE,
# Given explicitly since roll_out() no longer resolves size from a
# training config -- it always uses the height/width passed in here.
height=FRAME_H,
width=FRAME_W,
eta=float(eta),
seed=seed,
num_videos=NUM_VIDEOS,
trajectory_file=str(traj_path) if traj_path is not None else None,
vis_mode="trajectory_ego",
device="cuda",
context_end_frame=context_end_frame,
progress_cb=_rollout_progress,
)
say(1.0, "💾 Saving…")
out_mp4s = []
for i in range(NUM_VIDEOS):
seq_dir = output_dir / "fake_images" / f"sequence_{i:04d}"
gen_frames = sorted(seq_dir.glob("*.jpg"))
if not gen_frames:
raise gr.Error(f"No generated frames found in {seq_dir} — "
"check rollout output path.")
out_mp4 = job / f"rollout_{i}.mp4"
frames_to_mp4(gen_frames, out_mp4)
out_mp4s.append(str(out_mp4))
say(1.0, f"✅ Done — {NUM_VIDEOS} rollouts of {int(num_gen_steps)} steps.")
return tuple(out_mp4s)
except gr.Error:
raise
except Exception as e:
tb = traceback.format_exc()
print(tb)
raise gr.Error(f"Rollout failed: {e}\n\n{tb[-1500:]}")
# ----------------------------------------------------------------------------
# UI
# ----------------------------------------------------------------------------
def build_demo():
with gr.Blocks(title="Orbis 2: A Hierarchical World Model for Driving",
head=TRAJ_HEAD_SCRIPT + CURSOR_HEAD_SCRIPT,
# The UI is designed light-only (forced light .gradio-container
# background, hardcoded dark hint text, light canvas). A visitor
# whose browser is in dark mode would otherwise get Gradio's light
# text on our forced-light background -> invisible text. Force the
# page to always load in light theme via ?__theme=light.
js="""
() => {
const url = new URL(window.location.href);
if (url.searchParams.get('__theme') !== 'light') {
url.searchParams.set('__theme', 'light');
window.location.replace(url.href);
}
}
""",
css=f"""
/* Fixes the upload box's height so it doesn't jump between the empty
dropzone and a loaded video of some other aspect ratio; object-fit
letterboxes videos that don't match instead of growing the box. */
#input_video {{ height: 320px !important; }}
#input_video video {{ height: 100% !important; object-fit: contain !important; }}
/* Vertical rail of example-clip thumbnails to the left of the input
video; height matches #input_video above so the two line up (the
"Examples" label below it, like the hint text below the video,
sits outside this fixed height). */
#example_rail {{ display: flex !important; flex-direction: column !important;
flex-wrap: nowrap !important;
justify-content: space-between; height: 320px; gap: 6px; }}
#example_rail > div {{ flex: 1 1 0 !important; min-height: 0; }}
#example_label {{ text-align: center; }}
.example-thumb {{ cursor: pointer; border-radius: 6px; overflow: hidden;
height: 100% !important; }}
.example-thumb img {{ height: 100% !important; width: 100% !important;
object-fit: cover !important; }}
.example-thumb:hover {{ outline: 2px solid {THEME_ACCENT}; }}
/* Blue theme: very light cornflower page background, one darker
cornflower blue reused for interactive widgets (native range/number/
checkbox accent color covers sliders without fragile DOM targeting)
and the primary action button.
We force a LIGHT page background but Gradio still picks its light/dark
*theme* from the visitor's browser. In dark mode it paints panels
(Groups, Video, Accordions, inputs) with dark fills + light text while
our page stays light -> mismatched panels and invisible text. Rather
than depend on the ?__theme=light redirect firing, pin Gradio's own
theme CSS variables to light values for BOTH the light and .dark
container scopes, so every panel is light with dark text regardless of
the browser's mode. Because these are the same variables Gradio's
theme sets, everything (blocks, labels, inputs, borders) stays
consistent instead of being patched element by element. */
.gradio-container, .gradio-container.dark {{
--body-background-fill: {THEME_BG};
--background-fill-primary: #ffffff;
--background-fill-secondary: {THEME_BG};
--block-background-fill: #ffffff;
--panel-background-fill: #ffffff;
--input-background-fill: #ffffff;
--block-label-background-fill: #ffffff;
--block-title-background-fill: #ffffff;
--body-text-color: #111827;
--body-text-color-subdued: #4b5563;
--block-label-text-color: #111827;
--block-title-text-color: #111827;
--block-info-text-color: #4b5563;
--code-background-fill: #f3f4f6;
--border-color-primary: #d0d7e6;
--link-text-color: #2563eb;
--link-text-color-hover: #1d4ed8;
--link-text-color-active: #1d4ed8;
--link-text-color-visited: #2563eb;
background: {THEME_BG} !important;
color: #111827 !important;
}}
/* Belt-and-suspenders for text nodes that read a hardcoded color rather
than the variables above; links stay blue (rule listed last, same
specificity, so it wins). */
.gradio-container p, .gradio-container li, .gradio-container span,
.gradio-container .prose, .gradio-container .prose p,
.gradio-container .prose li, .gradio-container .prose span {{
color: #111827 !important;
}}
.gradio-container a, .gradio-container .prose a {{
color: #2563eb !important;
}}
/* Code / bibtex blocks: Gradio's dark-theme code fill leaks through in
dark mode. Pin a light fill + dark text directly on pre/code too, in
case the block reads a hardcoded color instead of --code-background-fill. */
.gradio-container pre, .gradio-container code,
.gradio-container .prose pre, .gradio-container .prose code {{
background: #f3f4f6 !important;
color: #111827 !important;
}}
/* Video player controls sit on a dark bar; our forced-dark body text
above turns the timer text and play/fullscreen icons black-on-black.
Force just the timer text + icon SVGs white. Deliberately scoped to
.time and svg only (color/fill, never background) so the seek/progress
bar -- which is already white -- is left untouched. */
.gradio-container .controls .time {{ color: #fff !important; }}
.gradio-container .controls svg {{
fill: #fff !important; color: #fff !important;
}}
input[type="range"], input[type="number"], input[type="checkbox"] {{
accent-color: {THEME_ACCENT};
}}
#generate_btn {{ background: {THEME_ACCENT} !important; border-color: {THEME_ACCENT} !important; }}
"""
) as demo:
gr.HTML(TITLE_HTML)
gr.HTML(AUTHOR_HTML)
gr.Markdown(DESCRIPTION)
with gr.Row():
with gr.Column(scale=2):
with gr.Group():
n_seconds_gen = gr.Slider(GEN_SECONDS_MIN, GEN_SECONDS_MAX, value=DEFAULT_GEN_SECONDS,
step=GEN_SECONDS_STEP,
label="Generated video length (s)",
info=f"Generated in chunks of {SECONDS_PER_GEN_STEP:.2g}s each")
l1_steps = gr.Slider(5, 20, value=DEFAULT_L1_STEPS, step=1,
label="L1 sampler steps",
info="Detail predictor — more steps, sharper frames")
with gr.Row():
l2_steps = gr.Slider(4, 8, value=DEFAULT_L2_STEPS, step=1, scale=2,
label="L2 sampler steps",
info="Abstract predictor — distilled, needs very few")
seed_in = gr.Number(value=-1, precision=0, label="Seed", scale=1,
info="-1 draws a fresh seed each run")
with gr.Column(scale=4):
with gr.Row():
example_paths = discover_examples()
example_thumbs = []
if example_paths:
with gr.Column(scale=1, min_width=90):
with gr.Column(elem_id="example_rail"):
for p in example_paths:
# Each thumbnail gets its own Row so Gradio can't auto-group
# these bare sibling Images into a 2-column form grid (the
# cause of the "two side by side, one below" layout bug).
with gr.Row():
example_thumbs.append((
gr.Image(value=example_thumbnail(p), interactive=False,
show_label=False,
elem_classes=["example-thumb"]),
str(p),
))
gr.Markdown("**Examples**", elem_id="example_label")
with gr.Column(scale=3):
inp = gr.Video(label="Upload a short mp4 clip", sources=["upload"],
elem_id="input_video")
cursor_time_s = gr.Textbox(elem_id="cursor_time_s", value="-1", visible=False)
gr.Markdown(
"Use the **video cursor** to pick where the model generation should "
"start (last context frame)."
)
btn = gr.Button("Generate rollouts", variant="primary", elem_id="generate_btn")
# Wired up here, after `inp` exists, so clicking a thumbnail loads that
# clip into the upload box exactly like gr.Examples would.
for thumb, path in example_thumbs:
thumb.select(lambda path=path: path, outputs=inp)
with gr.Column(scale=3):
gr.HTML(TRAJ_CANVAS_HTML_JS)
gr.HTML(
'<p style="margin:0 0 4px 0;"><strong>Draw a steering trajectory '
"(optional).</strong> Freehand-draw a path on the canvas (starts at "
"the green square, forward is up). Leave it untouched, or hit Clear, "
"to run the rollout unconditionally.</p>"
'<p style="font-size:14px; color:#555; margin:0;">'
"Trajectories are smoothed, centered, and heading-aligned. "
"Hand-drawn trajectories are out-of-distribution for the model, "
"so expect erratic outputs. Have fun crashing."
"</p>"
)
raw_points_json = gr.Textbox(elem_id="raw_points_json", visible=False)
smoothed_points_json = gr.Textbox(elem_id="smoothed_points_json", visible=False)
trajectory_state = gr.State()
raw_points_json.change(
fn=render_trajectory_plot,
inputs=raw_points_json,
outputs=[trajectory_state, smoothed_points_json],
)
smoothed_points_json.change(
fn=None,
inputs=[smoothed_points_json],
outputs=[],
js="(v) => { if (window.trajUpdateOverlay) { window.trajUpdateOverlay(v); } }",
)
with gr.Row():
outs = [gr.Video(label=f"Rollout {i + 1}", autoplay=True, loop=True)
for i in range(NUM_VIDEOS)]
with gr.Accordion("About this demo", open=False):
gr.Markdown(DEMO_MD)
with gr.Accordion("About the paper", open=False):
gr.Markdown(PAPER_MD)
btn.click(
run_rollout,
inputs=[inp, n_seconds_gen, l1_steps, l2_steps, seed_in, trajectory_state, cursor_time_s],
outputs=outs,
concurrency_limit=1,
)
return demo
if __name__ == "__main__":
build_demo().queue(max_size=8).launch()