Add MuJoCo simulation harness for elevation mapping validation - #3
Add MuJoCo simulation harness for elevation mapping validation#3deepanaishtaweera wants to merge 10 commits into
Conversation
Builds procedural terrain in MuJoCo, feeds ray-cast depth frames into ElevationMap, and scores the resulting map cell-for-cell against a top-down ray-cast ground-truth height map. Nothing renders: both the sensor and the ground truth are ray casts, so the harness runs headless. - sim/emsim/scenes.py procedural terrain (8 scenes) with closed-form surfaces - sim/emsim/heightmap.py top-down ray-cast ground truth, aligned to the map grid - sim/emsim/sensor.py pinhole depth camera via mj_multiRay, optional noise - sim/emsim/runner.py drives ElevationMap over a trajectory, collects timings - sim/emsim/metrics.py RMSE / MAE / bias / p95 / coverage - sim/emsim/cli.py `pixi run sim --all --out sim/report` 104 new tests covering accuracy per scene, gradient and step-height recovery, convergence, occlusion, robot-centric map shifting, layer semantics, plugins, and throughput. Ground truth is validated against each scene's independent analytic surface before any accuracy test relies on it. pixi.toml pins the toolchain (CuPy, MuJoCo, pytest) so `pixi run test-all` runs both the existing unit tests and the simulation suite.
Stock PyPI has no CUDA-capable aarch64 torch wheel, so the traversability filter was silently disabled and three tests skipped. Take torch from NVIDIA's Jetson index instead, scoped to linux-aarch64 so the manifest still resolves on x86. That wheel links against cuDSS, which JetPack does not ship. Pulling nvidia-cudss-cu12 from PyPI and putting its lib directory on LD_LIBRARY_PATH keeps the environment self-contained -- no sudo, no writing into /usr/local/cuda. All 194 tests now run with nothing skipped. This also corrects the severity of the normal-map finding: with the filter loaded the normals are correct (15.18 deg recovered on a 15 deg ramp, normal_z = 0.9999 on flat ground). The defect is real but confined to the filter-disabled fallback path, where the layers silently go all-vertical with no warning.
7f376cc to
87005dd
Compare
Two ways to see what a run did, both writing PNGs headless:
pixi run sim --scene mixed --trajectory line --plots all --out sim/report
pixi run pytest sim/tests --viz-dir sim/report
The pytest option adds a `viz` fixture that is a no-op unless --viz-dir is
passed, so tests can carry a figure of exactly what they assert without
costing anything on a normal run.
Five plot kinds in emsim/plotting.py:
- comparison ground truth, estimate, signed error side by side
- layers every exported layer on one sheet
- surface ground truth vs estimate as 3D surfaces
- convergence coverage and error against frame number
- filmstrip the robot-centric window sliding across a fixed world,
drawn in one shared world frame so the shift is visible
Wired into the accuracy, shifting and layer tests.
A background agent worktree under .claude/worktrees/ was picked up by a git add -A. Remove it from the index and ignore the directory.
The two normals tests skipped whenever the traversability filter was unavailable, because the normal layers were fed from traversability_input, which only got filled inside the filter's guard -- so with the filter off every normal was (0, 0, 1) and the tests would have asserted against known-bad output. That is fixed in #4 (the dilation now runs unconditionally), so the skip guard has nothing left to protect against: the normals are correct with the filter enabled or disabled. Drop _require_working_normals and let both tests run in either configuration, which turns them into the regression test for #4 -- the ramp test is the one that discriminates, reading -0.0 deg of tilt against the bug and ~15 deg once fixed. test_traversability_layer keeps its skip; it genuinely needs the learned weights. Update the README section that documented this as a live defect.
|
Pushed Context: the harness caught a real regression — With that fix the normals are correct in both configurations, so the skip guard had nothing left to protect against and the two normals tests become the regression test for #4. Merge order: #4 should go in first. It is independent of this branch (it applies cleanly to
|
The harness in #3 covers this, but sim/ is not on dev yet, so nothing here would catch the dilation moving back inside the filter's guard. Drive the real input path with weight_file="" -- the supported way to run without the learned filter, and what a host with no CUDA-capable torch falls back to -- over a synthetic 15 degree plane, and assert the normal layers recover the slope. Against the bug the dilated surface stays all zeros and the recovered tilt reads -0.0 deg instead of ~15 deg, so the slope test fails; with the fix it passes. The flat-ground case cannot discriminate on its own, since (0, 0, 1) is the right answer there, but it pins the sign convention that makes the slope number meaningful. No new dependencies: the plane is generated directly, so this runs anywhere the existing cupy tests do. Deliberately not asserting that the enabled and disabled configurations agree cell-for-cell. add_points_kernel accumulates with atomics, so upper_bound depends on the order points land and two identical runs already differ by up to 0.29 in normal_x -- such a test would measure determinism, not this fix.
The depth camera samples the ground densely and uniformly; a real LiDAR
does not. emsim/lidar.py wraps mujoco-lidar to feed the map real scan
patterns -- Velodyne vlp32/hdl64, Ouster os128, Livox mid360/avia/mid70/
horizon (non-repetitive), airy96 and a plain grid.
It presents the same interface as DepthSensor (pose_for then capture ->
DepthCapture), so the run loop is unchanged; pick with
RunConfig(sensor="lidar") or --sensor lidar.
Notes on the integration:
- mujoco-lidar takes the sensor pose from a site in the model, so scenes
now carry a geom-free "lidar_mount" mocap body. It holds no geoms, so
it cannot occlude anything or perturb the camera and ground-truth paths.
- get_hit_points returns points in the sensor frame; verified against a
yawed sensor, where only R @ p + t lands on the terrain.
- bodyexclude goes through args={} or the robot shell swallows every ray
from the inside.
- A level-mounted spinning unit covers only ~12% of a 2.5 m disc, since
most rings sit above the horizon. Default tilt is 20 deg -> ~85%.
- The cpu backend is mj_multiRay underneath, the same call the camera
uses, so it buys scan patterns rather than speed. warp/taichi/jax are
selectable via lidar_backend for large scans.
Also adds `sim --preview`, drawing each scene as a 3D surface and a
top-down height map with one frame from each sensor overlaid. Drawn from
the scene geometry, not OpenGL: neither the Jetson's EGL vendor driver
nor Mesa's software EGL works in this environment.
221 tests pass (27 new).
Warp ray casting for the LiDAR backend, measured on an Orin against the CPU path at identical point counts: vlp32 mixed 120k rays 145 ms -> 12.8 ms (11x) os128 mixed 260k rays 269 ms -> 30.0 ms ( 9x) vlp32 rough 120k rays 237 ms -> 13.4 ms (18x) Results agree with the CPU reference to 1.5e-5, i.e. float32 precision. Kernels compile once (~14 s) and are cached in ~/.cache/warp; after that they load in under 2 ms. lidar_backend now defaults to "auto", which uses Warp when CUDA is present and falls back to cpu. warp-lang ships manylinux_2_34 wheels, so the platform entries declare glibc 2.34 (JetPack 6 is Ubuntu 22.04 / 2.35); uv otherwise assumes 2.28 and rejects them. Also adds emsim/render.py and `pixi run render`, which renders scenes through MuJoCo's own rasteriser. Getting that working headless here took some doing: - Tegra's EGL exposes no usable EGL_PLATFORM_DEVICE_EXT display and is GLES-only, while MuJoCo asks for desktop EGL_OPENGL_BIT. Rendering goes through Mesa's software rasteriser with GALLIUM_DRIVER=llvmpipe; otherwise Mesa tries the Tegra KMS nodes and reports "kmsro: driver missing". - MuJoCo caches its EGL display on the first context, so a failed device cannot be retried in-process. pick_egl_device() runs the handshake itself first and sets MUJOCO_EGL_DEVICE_ID. - LD_LIBRARY_PATH must point at Mesa before the process starts, hence the dedicated pixi task. Scenes gain a skybox, checker materials and directional lights so the terrain is legible. Materials and lights play no part in ray casting, so the sensor and ground-truth paths are unaffected. Rendered PNGs are gitignored. 226 tests pass.
Measured VLP-32 scan cost across the catalogue. Warp is 2.5-13x faster on most scenes, but on 'slope' it is 884x: 11 s per scan on CPU against 12.5 ms. MuJoCo's CPU height-field ray cast walks the grid cell by cell, so a ray skimming along a flat height field crosses thousands of cells before it exits -- and a 360-degree LiDAR aims a whole ring that way. Warp builds a BVH and barely notices. That makes the GPU backend effectively mandatory for LiDAR on height-field scenes, not just an optimisation.
The tests already ran on Warp -- LidarSensor.backend and RunConfig.lidar_backend both default to "auto", which resolves to warp whenever warp-lang reports a CUDA device -- but nothing said so, and a fallback to cpu would have passed quietly while running up to 884x slower on height-field scenes. RunResult now records the concrete backend, the end-to-end LiDAR accuracy tests assert the run used the resolved default, and a new test fails (rather than skips) if CUDA is present but the default did not pick warp. The backend in use is printed once per run.
Every accuracy test drove a level base: nine of twelve trajectory uses were 'spin' (rotate in place), two were 'static', one was 'line', and 'circle' was implemented but never used. Nothing combined translation with rotation, and nothing moved the base in z, roll or pitch at all -- so a pose-handling error could hide behind a constant offset. Trajectories now produce full 6-DoF poses: - BodyMotion adds vertical bob, body-frame lateral sway and roll/pitch on top of any path, quarter-cycle out of phase so the attitude traces a loop rather than heaving. All amplitudes default to zero, so existing runs are unchanged. BodyMotion.walking() uses trotting-quadruped amplitudes: 4 cm bob, 3 cm sway, 4 deg roll, 3 deg pitch. - New 'figure8' path, whose yaw rate reverses sign twice per lap. - Sensors take a full body rotation rather than a yaw angle (as_rotation_matrix accepts a yaw, an rpy triple or a matrix), so base roll and pitch carry through to the camera and LiDAR on top of their own mount tilt. - move_to now receives the real body attitude, not just the heading. 29 new tests: path shapes, body-motion phase and amplitude bounds, sway staying perpendicular to the heading, and end-to-end accuracy under circle and figure8 with walking motion. 255 pass. Also fixes the height-field scenes z-fighting against the ground plane in renders, and adds 'side' and 'graze' camera presets -- the slope and rough terrains do not read from an elevated camera.
* Run the dilation filter regardless of the traversability filter update_map_with_kernel ends with update_normal(self.traversability_input), but the dilation that fills that buffer sat inside the `if self.traversability_filter is not None:` guard added when the filter was made optional. The buffer is not the filter's private input: it holds the dilated upper-bound surface, which update_normal consumes too (hence its `dilated_map` parameter name). When the filter fails to load -- no weights.dat, or torch missing or not CUDA-capable -- traversability_input therefore kept the all-zero contents from compile_kernels(). update_normal still ran, computing normals over a flat zero plane, so normal_x/normal_y/normal_z silently became (0, 0, 1) everywhere regardless of terrain. Nothing surfaced it: the only log line mentions the traversability filter, and (0, 0, 1) is a plausible normal -- on flat ground it is even correct. The dilation does not depend on the learned weights, and upstream runs it unconditionally, so move it back out of the guard and keep only the traversability_filter() call and its elevation_map[3] write behind it. Measured on a 15 degree ramp with the filter disabled: normal tilt was -0.0 deg before, ~15 deg after. * Add a regression test for normals without the traversability filter The harness in #3 covers this, but sim/ is not on dev yet, so nothing here would catch the dilation moving back inside the filter's guard. Drive the real input path with weight_file="" -- the supported way to run without the learned filter, and what a host with no CUDA-capable torch falls back to -- over a synthetic 15 degree plane, and assert the normal layers recover the slope. Against the bug the dilated surface stays all zeros and the recovered tilt reads -0.0 deg instead of ~15 deg, so the slope test fails; with the fix it passes. The flat-ground case cannot discriminate on its own, since (0, 0, 1) is the right answer there, but it pins the sign convention that makes the slope number meaningful. No new dependencies: the plane is generated directly, so this runs anywhere the existing cupy tests do. Deliberately not asserting that the enabled and disabled configurations agree cell-for-cell. add_points_kernel accumulates with atomics, so upper_bound depends on the order points land and two identical runs already differ by up to 0.29 in normal_x -- such a test would measure determinism, not this fix.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 25 changed files in this pull request and generated no new comments.
Suppressed comments (2)
sim/emsim/sensor.py:238
- Noise is applied after the initial min/max-range filter, but
validdoes not re-check the noisy ranges against[min_range, max_range]. This can yield returns whose storedcap.rangesexceedmax_range(or fall belowmin_range) once noise is enabled, which breaks the sensor contract and makes downstream code/tests sensitive to out-of-range values.
hit = (self._geomid >= 0) & (ranges >= self.min_range) & (ranges <= self.max_range)
ranges, keep = self.noise.apply(ranges)
valid = hit & keep & (ranges > 0)
points = self._dirs_cam[valid] * ranges[valid, None]
sim/emsim/lidar.py:210
- Like the depth camera, the LiDAR applies noise after range-gating but does not re-check the noisy ranges against
[min_range, max_range]. With noise enabled, this can emit points whoseDepthCapture.rangesexceedmax_range(or fall belowmin_range), even though the sensor is documented as dropping those returns.
hit = (ranges >= self.min_range) & (ranges <= self.max_range)
ranges_noisy, keep = self.noise.apply(ranges)
valid = hit & keep & (ranges_noisy > 0)
Builds procedural terrain in MuJoCo, feeds ray-cast depth frames into
ElevationMap, and scores the resulting map cell-for-cell against a top-down ray-cast ground-truth height map.Nothing renders — both the sensor and the ground truth are ray casts, so the whole harness runs headless on CPU apart from the mapping itself.
Running it
pixi run test-all # existing unit tests + simulation suite pixi run sim --all --out sim/reportWhat's here
sim/emsim/scenes.pysim/emsim/heightmap.pysim/emsim/sensor.pymj_multiRay, optional range noise / dropoutsim/emsim/runner.pyElevationMapover a trajectory, collects timingssim/emsim/metrics.pysim/emsim/cli.pyOnly
runner.pyneeds CuPy, so the scene, sensor and ground-truth tests run on any machine.Tests
168 new tests on top of the 87 existing ones. All 255 pass, nothing skipped.
test_scenes.pytest_heightmap.pytest_sensor.pytest_lidar.pytest_elevation_accuracy.pytest_trajectories.pytest_map_shifting_sim.pytest_map_layers.pyis_valid,variance,time,upper_bound, normals, traversability, plugins,cleartest_performance.pyThe ray-cast sampler is validated against each scene's independently derived analytic surface before any accuracy test relies on it. Accuracy thresholds sit well above what the pipeline currently achieves — they catch regressions rather than pinning down today's numbers.
Current results
Stationary 360° sweep, 24 frames, 8 m map at 0.04 m, scored within r ≤ 2.5 m, on Jetson Orin:
High RMSE with low p95 is the signature of cells straddling a vertical face (
wall,gap,boxes) — genuinely ambiguous, so both are reported. Throughput onmixedwith the traversability filter running: ~100 Hz point-cloud ingestion at 19k points/frame, 3 ms layer export.CUDA setup
Self-contained: no
sudo, no writing into/usr/local/cuda. The only external prerequisite is JetPack itself (developed against JP6 / R36.4.7, CUDA 12.6).cupy-cuda12x); its aarch64 wheels work against the JetPack runtime..cuda()— has no CUDA-capable aarch64 wheel on stock PyPI, so it comes from NVIDIA's Jetson indexhttps://pypi.jetson-ai-lab.io/jp6/cu126, scoped tolinux-aarch64so the manifest still resolves on x86. That wheel links against cuDSS, which JetPack does not ship, sonvidia-cudss-cu12is pulled from PyPI and its lib directory added toLD_LIBRARY_PATH.One latent defect found
The normal layers have an undeclared dependency on the traversability filter.
update_map_with_kernelends withself.update_normal(self.traversability_input), buttraversability_inputis only ever populated inside theif self.traversability_filter is not None:branch immediately above.With the filter loaded — the configuration this environment sets up — the normals are correct: the harness recovers 15.18° on a 15° ramp and
normal_z = 0.9999on flat ground. Buttraversability_filteris set toNonewheneverweights.datfails to load, and in that state the buffer stays all zeros, sonormal_x/normal_y/normal_zsilently become(0, 0, 1)everywhere regardless of terrain — with no warning beyond the one about the filter itself.Not fixed here: the dilation step doesn't depend on the learned filter, so the likely fix is to run it unconditionally and keep only the
self.traversability_filter(...)call behind the guard — butupdate_normal's parameter is nameddilated_map, so that is a call about the upstream algorithm and deserves its own review.tests/test_map_layers.pyskips its normals tests in that configuration rather than asserting against known-bad output.Notes on the ground-truth sampler
(k + 0.5) * resolutionlattice, so the sampler builds its grid there too and window comparison is integer indexing — no interpolation between estimate and truth. Verified end-to-end against a real map export.x == ysample on such a diagonal (2 cells in 40,000 onrough). The sampler casts a second ray at a 10 µm asymmetric offset and keeps the higher hit.Sensors
Two backends behind one interface (
pose_forthencapture->DepthCapture), so the run loop does not care which it holds. Select withRunConfig(sensor=...)or--sensor.camera(default) — a pinhole depth camera: dense, short-range, every one of its 19 200 rays returning within about 3 m.lidar— real scan patterns via MuJoCo-LiDAR (MIT):vlp32,hdl64,os128,airy96, Livoxmid360/avia/mid70/horizon, and a plaingrid. Sparse, ring-structured, long-range — a VLP-32 returns ~53 000 of 120 000 rays spread over tens of metres, and the Livox patterns are non-repetitive so successive scans sample different points. That is the input the package actually receives on a robot, and it loads the map very differently from a dense camera patch.A level-mounted spinning unit puts most of its rings above the horizon and covers only ~12% of a 2.5 m disc;
lidar_tilt_down_degfixes that, and the 20° default takes it to ~85%:vlp32vlp32os128mid360aviaIntegration notes:
mujoco-lidarreads the sensor pose from a site in the model, so scenes carry a geom-freelidar_mountmocap body (no geoms, so it cannot occlude anything or perturb the camera and ground-truth paths).get_hit_pointsreturns points in the sensor frame — verified against a yawed sensor, where onlyR @ p + tlands on the terrain.bodyexcludehas to go throughargs={}or the robot shell swallows every ray from the inside.Ray-cast backends: Warp
lidar_backenddefaults to"auto"— Warp whenwarp-langreports a CUDA device,cpuotherwise. Thecpupath ismj_multiRayunderneath, the same call the depth camera uses, so it buys scan patterns rather than speed. Per-scan cost of a VLP-32 (120 000 rays) on an Orin:flatboxeswallgapstepsmixedroughslopeslopeis why this is not merely an optimisation. MuJoCo's CPU height-field ray cast walks the grid cell by cell, so a ray skimming along a flat height field crosses thousands of cells before it exits — and a 360° LiDAR aims an entire ring exactly that way. A 24-framesloperun is four and a half minutes on CPU and under a second on Warp. Warp builds a BVH and barely notices.Maps come out identical between backends to float32 precision (1.5e-5 on ranges and points), and there are tests asserting both that parity and the speedup.
warp-langshipsmanylinux_2_34wheels, so the platform entries declare glibc 2.34 — uv otherwise assumes 2.28 and rejects them.Since everything defaults to
auto, a silent fall back to CPU would pass quietly while running up to 884x slower.RunResult.sensor_backendrecords what actually ran, the end-to-end LiDAR tests assert the run used the resolved default, and a test fails (rather than skips) if CUDA is present butautodid not pick Warp. The backend in use is printed once per test run.All scenes on the LiDAR
VLP-32 at 20° tilt, 24-frame sweep, 10 m map at 0.04 m, scored within r ≤ 2.5 m:
Accuracy holds up under sparse ring sampling. Note
gapcovers 83.6% here against 70.2% for the depth camera: the LiDAR sees into the gap from a distance and at a shallower angle than a short-range downward-tilted camera can.Trajectories and body motion
RunConfig.trajectorysets the nominal path —static,spin(rotate in place),line,circle(translate and rotate together),figure8(a lemniscate whose yaw rate reverses sign twice per lap).RunConfig.body_motionthen adds what a legged base actually does on top of that path: vertical bob, lateral sway in the body frame so it follows the heading, and roll/pitch. Amplitudes default to zero, so nothing changes unless asked for:BodyMotion.walking()uses trotting-quadruped amplitudes — 4 cm bob, 3 cm sway, 4° roll, 3° pitch, six cycles over the run — with the four terms quarter-cycle out of phase so the attitude traces a loop rather than heaving in lockstep.This closed a real hole. Before it, nine of twelve trajectory uses in the suite were
spin, two werestatic, one wasline, andcirclewas implemented but never used: nothing combined translation with rotation, and nothing moved the base in z, roll or pitch at all — so a pose-handling error could have hidden behind a constant offset in every accuracy number here.Sensors now take a full body rotation rather than a yaw angle (
as_rotation_matrixaccepts a yaw, an(r, p, y)triple or a 3×3 matrix), so base roll and pitch carry through to the camera and LiDAR on top of their own mount tilt, andmove_toreceives the real body attitude instead of just the heading.One caveat worth knowing when reading amplitudes back: a sinusoid sampled at
n_stepspoints does not generally land on its peaks — six cycles over 24 steps samples every 90°, so a term offset by 45° only ever reaches 0.707 of its amplitude. The tests bound the observed span rather than asserting the nominal value.Visualisation
Figures are written as PNGs, headless. From the CLI with
--out DIR --plots {comparison,layers,surface,convergence,filmstrip,all}, or from the tests with--viz-dir DIR, where every test that has something worth seeing emits a figure named after itself:pixi run pytest sim/tests --viz-dir sim/report pixi run sim --all --preview --out sim/report # the scenes themselves--previewdraws each scene as a 3D surface and a top-down height map with one depth frame and one LiDAR scan overlaid — the clearest side-by-side of how differently the two sensors sample the same ground.Rendered views
Renders through MuJoCo's own rasteriser —
oblique,front,top,sideandgrazepresets, about a second per frame. The cameras sit deliberately low, andside/grazeexist becauseslopeandrough(~0.12 m peak-to-peak over 8 m) do not read from an elevated camera at all. Getting that working headless on a Jetson took some doing, which is why it has its own module and pixi task:EGL_PLATFORM_DEVICE_EXTdisplay and is GLES-only, while MuJoCo's context asks for desktopEGL_OPENGL_BIT. Rendering therefore goes through Mesa's software rasteriser (mesalib) withGALLIUM_DRIVER=llvmpipe; otherwise Mesa tries the Tegra KMS nodes and reportskmsro: driver missing.emsim.render.pick_egl_deviceruns the whole handshake itself first and setsMUJOCO_EGL_DEVICE_ID.LD_LIBRARY_PATHmust point at Mesa before the process starts, since the dynamic loader reads it once. That is what therendertask is for.Scenes gained a skybox, checker materials and directional lights so the terrain is legible, and height-field scenes drop the ground plane 1 cm so the plane and the flat parts of the field stop z-fighting (applied to the analytic surface too, so ground truth stays consistent). Materials and lights play no part in ray casting, so the sensor and ground-truth paths are untouched. Rendered PNGs are gitignored.
Rendering is only ever used for looking at scenes — the sensors and the ground truth are ray casts and never touch OpenGL.
Known inefficiency
The depth camera ray casts on the CPU via
mj_multiRay, and MuJoCo's CPU height-field cast walks the grid cell by cell. Measured cost of one 160×120 frame:slopeis 100× the box scenes and accounts for the two slowest tests in the suite (~6.4 s each, 16 frames apiece). The LiDAR path already avoids this through Warp; the camera does not, because it predates that backend and is deliberately dependency-free.Routing the camera's rays through the same Warp backend would fix it — a pinhole bundle is rays from a single origin, and
MjLidarWrapper.trace_raysaccepts arbitrary(theta, phi), so the pinhole directions convert exactly. Not done here: it would couple the default sensor to the optionalmujoco-lidardependency, which is a design call worth making deliberately rather than folding into this PR.