Skip to content

Commit 9ace41c

Browse files
committed
Add digging despike filters and bounded inpainting
1 parent 2c81a04 commit 9ace41c

11 files changed

Lines changed: 950 additions & 45 deletions

docs/TEST_DATA_DIG3D_2026-03-30.md

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
# DIG3D Test Data For `elevation_mapping_cupy`
2+
3+
This note points to the current March 26 DIG3D real bags that are useful when
4+
changing `elevation_mapping_cupy`.
5+
6+
## Current local digging chain
7+
8+
When replaying the split DIG bags with the current local digging profile, the
9+
effective height-processing chain is:
10+
11+
- `elevation -> near_base_filtered -> despiked -> inpaint -> excavation_mapping`
12+
13+
Important detail:
14+
15+
- excavation mapping currently patches from `inpaint`, not directly from
16+
`despiked`
17+
- so a despike change should usually be checked in both
18+
`/mole/elevation_map_filter` and `/mole/excavation_mapping/grid_map`
19+
20+
## Existing dataset docs
21+
22+
- Main bag manifest:
23+
- `/home/lorenzo/mcap/dig3d_2026-03-26/bag_manifest_initial_2026-03-28.md`
24+
- Single-scoop playback priority:
25+
- `/home/lorenzo/mcap/dig3d_2026-03-26/split_single_scoops_obs_2026-03-28/PLAYBACK_PRIORITY_2026-03-28.md`
26+
- Single-scoop manifest:
27+
- `/home/lorenzo/mcap/dig3d_2026-03-26/split_single_scoops_obs_2026-03-28/single_scoop_manifest_2026-03-28.csv`
28+
- Current despike validation summary:
29+
- `/home/lorenzo/mcap/dig3d_2026-03-26/analysis/elevation_despike_2026-03-29/README.md`
30+
31+
## Bag layouts
32+
33+
There are two layouts in this dataset.
34+
35+
### 1. Split full-run layout
36+
37+
Use this when you want to replay raw lidar and run the perception stack live.
38+
39+
Example:
40+
41+
- `/home/lorenzo/mcap/dig3d_2026-03-26/dig3d_real_run_2026-03-26_21-09-18/`
42+
43+
Expected structure:
44+
45+
- `sensors/`
46+
- `state/`
47+
- `commands/`
48+
- `lidar/`
49+
- `elevation_map/`
50+
- optional `camera/`
51+
52+
This is the cleanest layout for:
53+
54+
- `robot_self_filter`
55+
- live `elevation_mapping_cupy`
56+
- live excavation mapping on top of the `inpaint` layer built from the despiked
57+
elevation-map chain
58+
59+
### 2. Monolithic bag layout
60+
61+
Use this when you want a short repro bag or a single whole-run bag in one
62+
folder.
63+
64+
Examples:
65+
66+
- `/home/lorenzo/mcap/dig3d_2026-03-26/trenching_single_2026-03-26_21-23-04/`
67+
- `/home/lorenzo/mcap/dig3d_2026-03-26/analysis/single_scoop_splits_2026-03-28/trenching_single_2026-03-26_21-39-24/trenching_single_2026-03-26_21-39-24__scoop_03/`
68+
69+
Expected structure:
70+
71+
- `<name>.mcap`
72+
- `metadata.yaml`
73+
74+
This is the easiest layout for:
75+
76+
- short Foxglove review
77+
- targeted repro of one failure case
78+
- offline analysis scripts
79+
80+
## Recommended test data
81+
82+
### Clean baseline full replay
83+
84+
Use:
85+
86+
- `/home/lorenzo/mcap/dig3d_2026-03-26/dig3d_real_run_2026-03-26_21-09-18/`
87+
88+
Why:
89+
90+
- split layout is already clean
91+
- short controlled run
92+
- good reference when checking that a filter does not damage reasonable terrain
93+
94+
### Spike-heavy full-run repro
95+
96+
Use:
97+
98+
- `/home/lorenzo/mcap/dig3d_2026-03-26/trenching_single_2026-03-26_21-23-04/`
99+
100+
Why:
101+
102+
- strongest near-machine positive spike behavior
103+
- primary bag for validating spike rejection
104+
105+
Related metrics:
106+
107+
- `/home/lorenzo/mcap/dig3d_2026-03-26/analysis/elevation_despike_2026-03-29/despike_summary_212304.json`
108+
109+
### Short focused spike repro
110+
111+
Use:
112+
113+
- `/home/lorenzo/mcap/dig3d_2026-03-26/analysis/single_scoop_splits_2026-03-28/trenching_single_2026-03-26_21-23-04/trenching_single_2026-03-26_21-23-04__scoop_05/`
114+
115+
Why:
116+
117+
- short repro bag
118+
- good when iterating quickly in Foxglove
119+
120+
### Short mixed-quality comparison case
121+
122+
Use:
123+
124+
- `/home/lorenzo/mcap/dig3d_2026-03-26/analysis/single_scoop_splits_2026-03-28/trenching_single_2026-03-26_21-39-24/trenching_single_2026-03-26_21-39-24__scoop_03/`
125+
126+
Why:
127+
128+
- useful comparison case after the spike-heavy repro
129+
- also contains the pitch-jerk behavior investigated elsewhere
130+
131+
## Fast guidance for future agents
132+
133+
If the task is "change `elevation_mapping_cupy` and validate on real DIG data",
134+
point the agent to:
135+
136+
- clean reference:
137+
- `/home/lorenzo/mcap/dig3d_2026-03-26/dig3d_real_run_2026-03-26_21-09-18/`
138+
- spike repro:
139+
- `/home/lorenzo/mcap/dig3d_2026-03-26/trenching_single_2026-03-26_21-23-04/`
140+
- short repro:
141+
- `/home/lorenzo/mcap/dig3d_2026-03-26/analysis/single_scoop_splits_2026-03-28/trenching_single_2026-03-26_21-23-04/trenching_single_2026-03-26_21-23-04__scoop_05/`
142+
143+
The full-run split layout is the best target when the agent needs live replay
144+
with:
145+
146+
- bag TF
147+
- raw lidar replay
148+
- `robot_self_filter`
149+
- local `elevation_mapping_cupy`
150+
151+
The monolithic bags are better when the agent only needs one concise repro case.

elevation_mapping_cupy/config/core/plugin_config.yaml

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,63 @@ smooth_filter:
1515
layer_name: "smooth"
1616
extra_params:
1717
input_layer_name: "min_filter"
18-
# Apply inpainting using opencv
18+
# Reject close-range above-base returns before despiking. This is meant for
19+
# BASE-centered digging maps where weather/dust can create floating points that
20+
# block later ground observations.
21+
near_base_height_filter:
22+
enable: True
23+
fill_nan: False
24+
is_height_layer: True
25+
layer_name: "near_base_filtered"
26+
extra_params:
27+
input_layer_name: "elevation"
28+
radius_m: 4.0
29+
max_allowed_height_m: 0.5
30+
# Remove isolated positive spikes before inpainting. This is intentionally
31+
# asymmetric: upward one/few-cell towers are clipped, trench drops are kept.
32+
positive_spike_filter:
33+
enable: True
34+
fill_nan: False
35+
is_height_layer: True
36+
layer_name: "despiked_coarse"
37+
extra_params:
38+
input_layer_name: "near_base_filtered"
39+
median_filter_size: 5
40+
spike_height_diff_m: 0.35
41+
support_height_tolerance_m: 0.12
42+
max_support_neighbor_count: 1
43+
max_component_cells: 4
44+
# A second narrower cleanup pass catches the smaller shovel-local residual bumps
45+
# that survive the coarse tower filter while still preserving supported walls.
46+
positive_spike_filter_cleanup:
47+
type: "positive_spike_filter"
48+
enable: True
49+
fill_nan: False
50+
is_height_layer: True
51+
layer_name: "despiked"
52+
extra_params:
53+
input_layer_name: "despiked_coarse"
54+
median_filter_size: 3
55+
spike_height_diff_m: 0.08
56+
support_height_tolerance_m: 0.02
57+
max_support_neighbor_count: 1
58+
max_component_cells: 2
59+
edge_invalid_neighbor_count_min: 3
60+
edge_peak_diff_m: 0.12
61+
edge_similarity_tolerance_m: 0.05
62+
edge_max_similar_neighbor_count: 1
63+
# Inpaint the cleaned surface so downstream digging keeps hole filling without
64+
# reintroducing the rejected spikes.
1965
inpainting:
2066
enable: True
2167
fill_nan: False
2268
is_height_layer: True
2369
layer_name: "inpaint"
2470
extra_params:
2571
method: "telea" # telea or ns
26-
max_hole_area: 64 # fill only small invalid components; <=0 disables the size bound
72+
input_layer_name: "despiked"
73+
max_hole_area: 25 # 5x5 cells at 0.1m resolution
2774
fill_border_holes: False # boundary-connected invalid regions stay unknown
28-
# Apply smoothing for inpainted layer
2975
erosion:
3076
enable: True
3177
fill_nan: False

elevation_mapping_cupy/config/setups/menzi/base.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,6 @@
1111
basic_layers: ['elevation']
1212
fps: 5.0
1313
elevation_map_filter:
14-
layers: ['min_filter', 'smooth', 'inpaint', 'elevation']
15-
basic_layers: ['min_filter']
14+
layers: ['min_filter', 'smooth', 'near_base_filtered', 'despiked', 'inpaint', 'elevation']
15+
basic_layers: ['min_filter', 'smooth', 'near_base_filtered', 'despiked', 'inpaint']
1616
fps: 3.0

elevation_mapping_cupy/elevation_mapping_cupy/elevation_mapping.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ def __init__(self, param: Parameter):
148148
self.untraversable_polygon = xp.zeros((1, 2))
149149

150150
# Plugins
151-
self.plugin_manager = PluginManager(cell_n=self.cell_n)
151+
self.plugin_manager = PluginManager(cell_n=self.cell_n, resolution=self.resolution)
152152
self.plugin_manager.load_plugin_settings(param.plugin_config_file)
153153

154154
self.map_initializer = MapInitializer(self.initial_variance, param.initialized_variance, xp=cp, method="points")
@@ -159,6 +159,7 @@ def clear(self):
159159
self.elevation_map *= 0.0
160160
# Initial variance
161161
self.elevation_map[1] += self.initial_variance
162+
self.plugin_manager.reset_layers()
162163

163164
self.mean_error = 0.0
164165
self.additive_mean_error = 0.0
@@ -407,6 +408,7 @@ def update_map_with_kernel(self, points_all, channels, R, t, position_noise, ori
407408
self.elevation_map[3][3:-3, 3:-3] = traversability.reshape(
408409
(traversability.shape[2], traversability.shape[3])
409410
)
411+
self.plugin_manager.reset_layers()
410412

411413
self.update_normal(self.traversability_input)
412414

@@ -977,6 +979,7 @@ def initialize_map(self, points, method="cubic"):
977979
size=(self.cell_n * self.cell_n),
978980
)
979981
self.update_upper_bound_with_valid_elevation()
982+
self.plugin_manager.reset_layers()
980983

981984
def list_layers(self) -> List[str]:
982985
ordered: List[str] = []

elevation_mapping_cupy/elevation_mapping_cupy/plugins/inpainting.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@
99
import cv2 as cv
1010
import numpy as np
1111

12-
_LOGGER = logging.getLogger(__name__)
13-
1412
from .plugin_manager import PluginBase
1513

14+
_LOGGER = logging.getLogger(__name__)
15+
1616

1717
class Inpainting(PluginBase):
1818
"""
@@ -28,12 +28,14 @@ def __init__(
2828
self,
2929
cell_n: int = 100,
3030
method: str = "telea",
31+
input_layer_name: str = "elevation",
3132
max_hole_area: int = 64,
3233
fill_border_holes: bool = False,
3334
inpaint_radius: float = 1.0,
3435
**kwargs,
3536
):
3637
super().__init__()
38+
self.input_layer_name = input_layer_name
3739
if method == "telea":
3840
self.method = cv.INPAINT_TELEA
3941
elif method == "ns": # Navier-Stokes
@@ -91,8 +93,14 @@ def __call__(
9193
Returns:
9294
cupy._core.core.ndarray:
9395
"""
94-
elevation = elevation_map[0]
9596
valid_layer = elevation_map[2]
97+
if self.input_layer_name in layer_names:
98+
elevation = elevation_map[layer_names.index(self.input_layer_name)]
99+
elif self.input_layer_name in plugin_layer_names:
100+
elevation = plugin_layers[plugin_layer_names.index(self.input_layer_name)]
101+
else:
102+
raise ValueError(f"Inpainting could not find layer '{self.input_layer_name}'")
103+
96104
finite_elevation = cp.isfinite(elevation)
97105
valid_mask = cp.logical_and(valid_layer > 0.5, finite_elevation)
98106
output = cp.full(elevation.shape, cp.nan, dtype=cp.float32)
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import cupy as cp
2+
from typing import List
3+
4+
from .plugin_manager import PluginBase
5+
6+
7+
class NearBaseHeightFilter(PluginBase):
8+
"""Invalidate close-range cells that sit above a fixed base-frame height threshold.
9+
10+
This is intended for BASE-centered digging maps, where spurious close lidar returns
11+
from dust, rain, or snow can float above the real ground and then persist because
12+
later rays no longer see through them. Cells that violate the near-base gate are
13+
marked invalid (NaN) so downstream despiking/inpainting can treat them as holes.
14+
"""
15+
16+
def __init__(
17+
self,
18+
cell_n: int = 100,
19+
input_layer_name: str = "elevation",
20+
resolution: float = 0.1,
21+
radius_m: float = 3.5,
22+
max_allowed_height_m: float = 0.0,
23+
**kwargs,
24+
):
25+
super().__init__()
26+
self.input_layer_name = input_layer_name
27+
self.radius_m = float(radius_m)
28+
self.max_allowed_height_m = float(max_allowed_height_m)
29+
30+
center = (float(cell_n) - 1.0) / 2.0
31+
coords = (cp.arange(cell_n, dtype=cp.float32) - center) * float(resolution)
32+
yy, xx = cp.meshgrid(coords, coords, indexing="ij")
33+
self.radial_mask = (xx * xx + yy * yy) < (self.radius_m * self.radius_m)
34+
35+
def _get_input_layer(
36+
self,
37+
elevation_map: cp.ndarray,
38+
layer_names: List[str],
39+
plugin_layers: cp.ndarray,
40+
plugin_layer_names: List[str],
41+
) -> cp.ndarray:
42+
if self.input_layer_name in layer_names:
43+
layer = elevation_map[layer_names.index(self.input_layer_name)].copy()
44+
if self.input_layer_name == "elevation":
45+
valid_mask = elevation_map[2] > 0.5
46+
layer = cp.where(valid_mask & cp.isfinite(layer), layer, cp.nan)
47+
return layer
48+
if self.input_layer_name in plugin_layer_names:
49+
return plugin_layers[plugin_layer_names.index(self.input_layer_name)].copy()
50+
return elevation_map[0].copy()
51+
52+
def __call__(
53+
self,
54+
elevation_map: cp.ndarray,
55+
layer_names: List[str],
56+
plugin_layers: cp.ndarray,
57+
plugin_layer_names: List[str],
58+
*args,
59+
) -> cp.ndarray:
60+
height_map = self._get_input_layer(elevation_map, layer_names, plugin_layers, plugin_layer_names)
61+
reject_mask = cp.isfinite(height_map) & self.radial_mask & (height_map > self.max_allowed_height_m)
62+
return cp.where(reject_mask, cp.nan, height_map)

0 commit comments

Comments
 (0)