Skip to content

Commit 4e54a37

Browse files
amacatiratheron
andauthored
Add Gaussian Splatting for visualizations (#89)
Co-authored-by: ratheron <marcel.rath@gmx.de>
1 parent 800a4e4 commit 4e54a37

36 files changed

Lines changed: 4832 additions & 1862 deletions

.github/workflows/docs.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ jobs:
3030
- name: Setup pixi
3131
uses: prefix-dev/setup-pixi@v0.9.3
3232
with:
33-
pixi-version: v0.70.0
33+
pixi-version: v0.76.0
3434
cache: true
3535
environments: docs
3636
activate-environment: false

.github/workflows/testing.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616
- name: Setup Pixi
1717
uses: prefix-dev/setup-pixi@v0.9.3
1818
with:
19-
pixi-version: v0.70.0
19+
pixi-version: v0.76.0
2020
cache: true
2121
cache-write: ${{ github.event_name == 'push' && github.ref_name == 'main' }}
2222
environments: tests
@@ -39,7 +39,7 @@ jobs:
3939
- name: Setup Pixi
4040
uses: prefix-dev/setup-pixi@v0.9.3
4141
with:
42-
pixi-version: v0.70.0
42+
pixi-version: v0.76.0
4343
cache: true
4444
cache-write: ${{ github.event_name == 'push' && github.ref_name == 'main' }}
4545
environments: tests

benchmark/plot.py

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,35 @@
1+
from datetime import datetime
12
from pathlib import Path
23

34
import matplotlib.pyplot as plt
45
import numpy as np
56
import pandas as pd
67

78

9+
def collect_csv_files(*paths: Path) -> list[Path]:
10+
"""Collect CSV files from paths or from the default data directory.
11+
12+
Args:
13+
paths: CSV files or directories containing CSV files.
14+
15+
Returns:
16+
List of CSV file paths.
17+
"""
18+
default_data_folder = Path(__file__).parent / "data"
19+
inputs = [Path(p) for p in paths] if paths else [default_data_folder]
20+
csv_files: list[Path] = []
21+
for input_path in inputs:
22+
if input_path.is_dir():
23+
csv_files.extend(sorted(input_path.glob("*.csv")))
24+
elif input_path.is_file() and input_path.suffix.lower() == ".csv":
25+
csv_files.append(input_path)
26+
else:
27+
raise ValueError(f"Expected a CSV file or directory, got: {input_path}")
28+
if not csv_files:
29+
raise ValueError("No CSV files found for plotting")
30+
return csv_files
31+
32+
833
def plot_fps_data(data_folder: Path):
934
"""Read all CSVs from the data folder and plot the latest gym and sim fps by device.
1035
@@ -92,6 +117,67 @@ def plot_fps_data(data_folder: Path):
92117
print(f"Plot saved to {output_path}")
93118

94119

120+
def plot_splat_data(*paths: Path):
121+
"""Plot splat rendering throughput and save render.png.
122+
123+
Args:
124+
paths: CSV files or directories containing CSV files. If empty, uses benchmark/data.
125+
"""
126+
csv_files = collect_csv_files(*paths)
127+
required = {"test_type", "n_worlds", "fps", "device"}
128+
series: list[dict[str, str | pd.DataFrame]] = []
129+
130+
for csv_file in csv_files:
131+
df = pd.read_csv(csv_file)
132+
if not required.issubset(df.columns):
133+
missing = sorted(required.difference(df.columns))
134+
raise ValueError(f"CSV {csv_file} is missing required columns: {missing}")
135+
splat = df[df["test_type"] == "splat"].copy()
136+
if splat.empty:
137+
continue
138+
splat = splat.sort_values("n_worlds")
139+
device = str(splat["device"].iloc[-1]).lower()
140+
date_token = csv_file.stem.split("_")[-2]
141+
date_label = datetime.strptime(date_token, "%Y%m%d").strftime("%d.%m.%y")
142+
series.append({"device": device, "date": date_label, "df": splat})
143+
144+
if not series:
145+
raise ValueError("No splat benchmark data found in provided CSV files")
146+
147+
colors = {"cpu": "#0000AA", "gpu": "#76B900"}
148+
device_names = {"cpu": "Intel Core i9-13900KF", "gpu": "NVIDIA RTX 4090"}
149+
fig, ax = plt.subplots(1, 1, figsize=(7, 5))
150+
fig.suptitle("Crazyflow Splat Rendering", fontsize=16, fontweight="bold", y=0.98)
151+
152+
for item in series:
153+
device = str(item["device"])
154+
date_label = str(item["date"])
155+
df = item["df"]
156+
label = f"{device_names.get(device, device.upper())} ({date_label})"
157+
ax.plot(
158+
df["n_worlds"],
159+
df["fps"],
160+
marker="o",
161+
linestyle="-",
162+
color=colors.get(device),
163+
label=label,
164+
)
165+
166+
ax.set_title("Images per second: Splat renderer")
167+
ax.set_xlabel("Number of Worlds")
168+
ax.set_xscale("log")
169+
ax.set_yscale("log")
170+
ax.grid(True)
171+
ax.legend(loc="upper left")
172+
format_log_axes(ax, {f"splat_{idx}": item["df"] for idx, item in enumerate(series)}, "splat_")
173+
plt.tight_layout()
174+
175+
output_dir = csv_files[0].parent
176+
output_path = output_dir / "render.png"
177+
plt.savefig(output_path, dpi=300, bbox_inches="tight")
178+
print(f"Plot saved to {output_path}")
179+
180+
95181
def format_log_axes(ax: plt.Axes, dfs: dict[str, pd.DataFrame], prefix: str):
96182
"""Format logarithmic axes with nice labels.
97183
@@ -136,4 +222,6 @@ def format_log_axes(ax: plt.Axes, dfs: dict[str, pd.DataFrame], prefix: str):
136222

137223

138224
if __name__ == "__main__":
139-
plot_fps_data(Path(__file__).parent / "data")
225+
data_folder = Path(__file__).parent / "data"
226+
plot_fps_data(data_folder)
227+
plot_splat_data(data_folder)

benchmark/splat.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
"""Benchmark gaussian splat rendering throughput inside a jax.lax.scan rollout.
2+
3+
Loads a splat scene and a drone splat, then renders RGB images from the drone camera for a fixed
4+
number of frames inside a single scanned rollout, doubling the number of parallel worlds each run
5+
(1, 2, 4, 8, ...). The whole rollout is jitted, so the loop runs entirely on device and the frame
6+
count reported is the number of images XLA actually rasterizes.
7+
8+
Requires splax and a CUDA-capable GPU because the splat camera sensor uses splax's GPU rasterizer.
9+
10+
Run with::
11+
12+
pixi run -e benchmark python benchmark/splat.py --resolution "(64,64)" --n_frames 100
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import logging
18+
import os
19+
import time
20+
from datetime import datetime
21+
from pathlib import Path
22+
from typing import TYPE_CHECKING, Callable
23+
24+
# splax rasterizes with warp, which needs GPU memory outside JAX's pool. Disable JAX preallocation
25+
# before it initializes so both share the device. Must run before the first jax import.
26+
os.environ["XLA_PYTHON_CLIENT_PREALLOCATE"] = "false"
27+
28+
import fire
29+
import jax
30+
import jax.numpy as jnp
31+
import numpy as np
32+
from jax.errors import JaxRuntimeError
33+
from splax.io import fetch
34+
35+
from crazyflow.sim import Sim
36+
from crazyflow.sim.sensors.splat import build_render_splat_fn
37+
from crazyflow.sim.splat import attach_splats
38+
39+
if TYPE_CHECKING:
40+
from jax import Array
41+
42+
from crazyflow.sim.data import SimData
43+
44+
ASSETS_URL = "https://huggingface.co/datasets/amacati/splats/resolve/main"
45+
46+
47+
def build_rollout(
48+
sim: Sim, resolution: tuple[int, int], n_frames: int, steps_per_frame: int
49+
) -> Callable[[SimData], Array]:
50+
"""Build a jitted rollout that steps the sim and renders one image per frame.
51+
52+
Each frame advances the simulation, rasterizes the splats from the first drone's camera for
53+
every world, and reduces the image to a scalar sum. Reducing inside the loop keeps XLA from
54+
eliminating the render as dead code while avoiding materializing the full
55+
(n_frames, n_worlds, H, W, 3) stack.
56+
"""
57+
step_fn = sim.build_step_fn()
58+
render = build_render_splat_fn(sim, drones=0, resolution=resolution)
59+
60+
@jax.jit
61+
def rollout(data: SimData) -> Array:
62+
def frame(data: SimData, _: None) -> tuple[SimData, Array]:
63+
data = step_fn(data, n_steps=steps_per_frame)
64+
return data, render(data).sum()
65+
66+
data, sums = jax.lax.scan(frame, data, length=n_frames)
67+
return sums.sum()
68+
69+
return rollout
70+
71+
72+
def benchmark(
73+
resolution: tuple[int, int] = (64, 64),
74+
n_frames: int = 100,
75+
max_worlds_exp: int = 12,
76+
fps: int = 30,
77+
n_repeats: int = 3,
78+
scene_ply: str = "robot_hall.ply",
79+
drone_ply: str = "cf21B_500.ply",
80+
):
81+
"""Benchmark splat rendering throughput for a growing number of parallel worlds.
82+
83+
Args:
84+
resolution: Rendered image resolution as (width, height).
85+
n_frames: Number of frames rendered per scanned rollout.
86+
max_worlds_exp: Largest world count is ``2 ** max_worlds_exp``.
87+
fps: Camera frame rate. Determines the physics steps taken between frames.
88+
n_repeats: Number of timed rollouts per world count. The fastest run is reported.
89+
scene_ply: Scene splat file name on the assets host.
90+
drone_ply: Drone splat file name on the assets host.
91+
"""
92+
logging.info("Fetching splat assets...")
93+
scene = fetch(f"{ASSETS_URL}/{scene_ply}")
94+
drone = fetch(f"{ASSETS_URL}/{drone_ply}")
95+
96+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
97+
csv_file = Path(__file__).parent / "data" / f"benchmark_results_{timestamp}.csv"
98+
csv_file.parent.mkdir(exist_ok=True)
99+
with open(csv_file, "w", newline="") as f:
100+
f.write(
101+
"test_type,n_drones,n_worlds,n_steps,total_time_s,avg_step_time_s,"
102+
"fps,real_time_factor,device\n"
103+
)
104+
105+
print(
106+
f"\nSplat rendering benchmark, resolution {resolution[0]}x{resolution[1]}, {n_frames} "
107+
f"frames per rollout"
108+
)
109+
print("-" * 80)
110+
print(f"{'n_worlds':>10} {'rollout_s':>12} {'frame_ms':>12} {'fps':>14}")
111+
print("-" * 80)
112+
113+
for n_worlds in [2**i for i in range(max_worlds_exp + 1)]:
114+
try:
115+
sim = Sim(n_worlds=n_worlds, control="state", device="gpu")
116+
attach_splats(sim, scene=scene, drone=drone)
117+
steps_per_frame = max(1, sim.freq // fps)
118+
119+
# Hold a constant target so the drone keeps moving and each frame renders a distinct
120+
# pose. A static scene would let XLA hoist the render out of the loop.
121+
cmd = np.zeros((sim.n_worlds, sim.n_drones, 13), dtype=np.float32)
122+
cmd[..., 2] = 0.5
123+
sim.reset()
124+
sim.state_control(jnp.asarray(cmd, device=sim.device))
125+
126+
rollout = build_rollout(sim, resolution, n_frames, steps_per_frame)
127+
128+
# Warmup triggers JIT compilation of the full rollout.
129+
jax.block_until_ready(rollout(sim.data))
130+
131+
times = []
132+
for _ in range(n_repeats):
133+
tstart = time.perf_counter()
134+
jax.block_until_ready(rollout(sim.data))
135+
times.append(time.perf_counter() - tstart)
136+
137+
assert rollout._cache_size() == 1, "rollout must only be jitted once"
138+
139+
rollout_s = min(times)
140+
frame_ms = rollout_s / n_frames * 1e3
141+
images_per_s = n_frames * n_worlds / rollout_s
142+
print(f"{n_worlds:>10} {rollout_s:>12.4f} {frame_ms:>12.4f} {images_per_s:>14.3e}")
143+
sim.close()
144+
145+
real_time_factor = (n_frames / fps) * n_worlds / rollout_s
146+
with open(csv_file, "a", newline="") as f:
147+
f.write(
148+
f"splat,{sim.n_drones},{n_worlds},{n_frames},{rollout_s},"
149+
f"{rollout_s / n_frames},{images_per_s},{real_time_factor},gpu\n"
150+
)
151+
except (JaxRuntimeError, MemoryError) as e:
152+
print(f"{n_worlds:>10} out of memory, stopping ({type(e).__name__})")
153+
break
154+
155+
print("-" * 80)
156+
print("fps is total images rendered per second across all parallel worlds.\n")
157+
print(f"Benchmark results saved to {csv_file}")
158+
159+
160+
if __name__ == "__main__":
161+
logging.basicConfig(level=logging.INFO)
162+
logging.getLogger("jax").setLevel(logging.WARNING)
163+
fire.Fire(benchmark)

crazyflow/sim/sensors/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Sensors for the simulation.
2+
3+
:mod:`crazyflow.sim.sensors.depth` renders depth images with MuJoCo raycasting and is always
4+
available. :mod:`crazyflow.sim.sensors.splat` renders photorealistic RGB(-D) images from gaussian
5+
splats and requires the optional ``splats`` extra.
6+
"""

0 commit comments

Comments
 (0)