Skip to content

Run the dilation filter regardless of the traversability filter - #4

Merged
deepanaishtaweera merged 2 commits into
mainfrom
fix/normals-without-traversability-filter
Aug 10, 2026
Merged

Run the dilation filter regardless of the traversability filter#4
deepanaishtaweera merged 2 commits into
mainfrom
fix/normals-without-traversability-filter

Conversation

@deepanaishtaweera

Copy link
Copy Markdown
Collaborator

The problem

update_map_with_kernel ends with:

self.update_normal(self.traversability_input)

but the dilation that fills traversability_input sits inside the if self.traversability_filter is not None: guard immediately above it. That buffer is not the filter's private input — it holds the dilated upper-bound surface, and update_normal consumes it too. update_normal's parameter is named dilated_map, which is the honest name for it.

So when the traversability filter fails to load — weight_file empty, weights.dat missing, or torch absent / not CUDA-capable — traversability_input keeps the all-zero contents it got from compile_kernels(). update_normal still runs, because it was never guarded, and computes normals over a flat zero plane. Every cell comes out (0, 0, 1) regardless of terrain.

Nothing surfaces this. The only log line mentions the traversability filter, giving no hint the normal layers went with it, and (0, 0, 1) is a plausible normal — on flat ground it is even the correct answer. So normal_x/normal_y/normal_z are indistinguishable from genuinely flat terrain.

The fix

The dilation is plain CUDA arithmetic with no dependency on the learned weights, so it moves back out of the guard; only the traversability_filter(...) call and its elevation_map[3] write stay behind it.

This restores upstream behaviour rather than inventing new semantics — two independent confirmations:

  • git log -L on those lines shows the block arrived unconditional in 0f2f505 (port from release/jazzy); the guard was added locally in b496fd4 when the filter was made optional.
  • Upstream leggedrobotics/elevation_mapping_cupy main still runs the dilation unconditionally, under the comment # dilation before traversability_filter — i.e. the dilation is a precondition of the filter, not a part of it.

Verification

Measured with the MuJoCo harness from #3, forcing the broken path via weight_file="":

flat-ground normals 15° ramp normals
before pass fail — tilt -0.0°
after pass pass — tilt ~15°

The flat-ground case passes even with the bug, since (0, 0, 1) is genuinely right there; the ramp is what discriminates, and -0.0° is the unmistakable signature of normals taken over a zero plane.

With the filter enabled (the normal configuration) behaviour is unchanged: elevation_mapping_cupy/tests 87 passed, and the full harness suite 107 passed.

Note on sequencing

The regression test for this lives in sim/tests/test_map_layers.py, which only exists on #3sim/ is not on dev yet. That PR is updated to drop its _require_working_normals skip guard, since the normals are now correct with the filter disabled too. Merging this first is the cleaner order: if #3 lands first and its CI runs without a CUDA-capable torch, those two normals tests will fail until this is in.

Related

Spotted in passing, not addressed here: traversability (layer 3) is initialized to 1.0 and never written when the filter is disabled, so it reports fully-traversable everywhere rather than unknown. test_erosion_layer_is_bounded cannot catch that either — a constant 1.0 is within [0, 1].

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.
deepanaishtaweera added a commit that referenced this pull request Aug 6, 2026
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.
@deepanaishtaweera
deepanaishtaweera requested a lite review from Copilot August 6, 2026 13:33

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes an implicit dependency between the optional traversability filter and normal-layer computation by ensuring the upper-bound dilation step always runs, so update_normal(...) receives a correctly populated dilated surface even when the learned filter is unavailable.

Changes:

  • Run the dilation filter unconditionally to populate traversability_input for downstream consumers (including normals).
  • Keep the learned traversability_filter(...) invocation and traversability-layer write guarded behind self.traversability_filter is not None.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread elevation_mapping_cupy/elevation_mapping.py
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.
@deepanaishtaweera

Copy link
Copy Markdown
Collaborator Author

Good catch on the missing regression test — that was a real gap. sim/ is not on dev yet, so the harness coverage in #3 would not have guarded this branch, and the only existing normals test here (test_update_normal) calls update_normal directly with elevation_map[0], bypassing the pipeline entirely.

Added elevation_mapping_cupy/tests/test_normal_layers.py in 3745c70. It does what you suggested: builds an ElevationMap with weight_file="" (the supported way to disable the filter, and what a host with no CUDA-capable torch falls back to), pushes a synthetic 15° plane through input_pointcloud, and asserts the normal layers recover the slope. The plane is generated directly, so there are no new dependencies — it runs anywhere the existing cupy tests do.

Verified it actually discriminates, by reverting the source fix and re-running:

with fix against bug
test_normals_tilt_on_a_slope_without_the_traversability_filter pass fail — assert -0.0 == 15.0 ± 5
test_normals_point_up_on_flat_ground_without_the_traversability_filter pass pass
test_traversability_filter_is_actually_disabled pass pass

The flat-ground case passing in both columns is expected, and worth being explicit about: (0, 0, 1) is genuinely correct on flat ground, so it cannot detect the bug alone — it pins the sign convention that makes the slope number meaningful. The slope test is the detector, and -0.0° is the signature of normals taken over an all-zero surface.

One thing I tried and deliberately dropped: asserting the filter-enabled and filter-disabled configurations produce identical normals. That looks like the tightest possible check, but add_points_kernel accumulates with atomics, so upper_bound depends on the order points land in a cell. Two runs of the same configuration already differ by up to 0.29 in normal_x (~32% of interior cells beyond 1e-5), so such a test would measure determinism rather than this fix.

Full unit suite on this branch: 90 passed.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

@deepanaishtaweera
deepanaishtaweera changed the base branch from dev to main August 6, 2026 16:38
@deepanaishtaweera
deepanaishtaweera merged commit 68154ce into main Aug 10, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants