Skip to content

Commit 354f65b

Browse files
committed
Add digging despike filters and bounded inpainting
1 parent 3c52e60 commit 354f65b

11 files changed

Lines changed: 996 additions & 51 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: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +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-
# Apply smoothing for inpainted layer
72+
input_layer_name: "despiked"
73+
max_hole_size_m: 0.5 # only fill holes up to ~0.5m x 0.5m (5x5 cells at 0.1m)
74+
max_hole_area_m2: 0.25 # explicit 0.5m x 0.5m cap so large dust/rain gaps stay invalid
2775
erosion:
2876
enable: True
2977
fill_nan: False
@@ -33,4 +81,4 @@ erosion:
3381
input_layer_name: "traversability"
3482
dilation_size: 3
3583
iteration_n: 20
36-
reverse: True
84+
reverse: True

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
@@ -414,6 +415,7 @@ def update_map_with_kernel(self, points_all, channels, R, t, position_noise, ori
414415
self.elevation_map[3][3:-3, 3:-3] = traversability.reshape(
415416
(traversability.shape[2], traversability.shape[3])
416417
)
418+
self.plugin_manager.reset_layers()
417419

418420
# Log final state
419421
self.update_normal(self.traversability_input)
@@ -916,6 +918,7 @@ def initialize_map(self, points, method="cubic"):
916918
size=(self.cell_n * self.cell_n),
917919
)
918920
self.update_upper_bound_with_valid_elevation()
921+
self.plugin_manager.reset_layers()
919922

920923
def list_layers(self) -> List[str]:
921924
ordered: List[str] = []

elevation_mapping_cupy/elevation_mapping_cupy/plugins/inpainting.py

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,21 @@ class Inpainting(PluginBase):
2424
**kwargs (): Additional keyword arguments.
2525
"""
2626

27-
def __init__(self, cell_n: int = 100, method: str = "telea", **kwargs):
27+
def __init__(
28+
self,
29+
cell_n: int = 100,
30+
method: str = "telea",
31+
input_layer_name: str = "elevation",
32+
resolution: float = 0.1,
33+
max_hole_size_m: float = -1.0,
34+
max_hole_area_m2: float = -1.0,
35+
**kwargs,
36+
):
2837
super().__init__()
38+
self.input_layer_name = input_layer_name
39+
self.resolution = float(resolution)
40+
self.max_hole_size_m = float(max_hole_size_m)
41+
self.max_hole_area_m2 = float(max_hole_area_m2)
2942
if method == "telea":
3043
self.method = cv.INPAINT_TELEA
3144
elif method == "ns": # Navier-Stokes
@@ -54,12 +67,41 @@ def __call__(
5467
cupy._core.core.ndarray:
5568
"""
5669
valid_layer = elevation_map[2]
57-
mask_np = cp.asnumpy((valid_layer < 0.5).astype("uint8"))
58-
elevation = elevation_map[0]
70+
if self.input_layer_name in layer_names:
71+
elevation = elevation_map[layer_names.index(self.input_layer_name)]
72+
elif self.input_layer_name in plugin_layer_names:
73+
elevation = plugin_layers[plugin_layer_names.index(self.input_layer_name)]
74+
else:
75+
raise ValueError(f"Inpainting could not find layer '{self.input_layer_name}'")
5976
finite_elevation = cp.isfinite(elevation)
6077
valid_mask = cp.logical_and(valid_layer > 0.5, finite_elevation)
78+
invalid_mask = cp.logical_not(valid_mask)
79+
mask_np = cp.asnumpy(invalid_mask.astype("uint8"))
80+
81+
max_hole_size_cells = np.inf
82+
if self.max_hole_size_m > 0.0:
83+
max_hole_size_cells = self.max_hole_size_m / max(self.resolution, 1e-6)
84+
85+
if self.max_hole_area_m2 > 0.0:
86+
max_hole_area_cells = self.max_hole_area_m2 / max(self.resolution * self.resolution, 1e-6)
87+
elif np.isfinite(max_hole_size_cells):
88+
max_hole_area_cells = max_hole_size_cells * max_hole_size_cells
89+
else:
90+
max_hole_area_cells = np.inf
91+
92+
if np.isfinite(max_hole_size_cells) or np.isfinite(max_hole_area_cells):
93+
num_labels, labels, stats, _ = cv.connectedComponentsWithStats(mask_np, connectivity=8)
94+
inpaint_mask_np = np.zeros_like(mask_np, dtype="uint8")
95+
for label in range(1, num_labels):
96+
width = float(stats[label, cv.CC_STAT_WIDTH])
97+
height = float(stats[label, cv.CC_STAT_HEIGHT])
98+
area = float(stats[label, cv.CC_STAT_AREA])
99+
if width <= max_hole_size_cells and height <= max_hole_size_cells and area <= max_hole_area_cells:
100+
inpaint_mask_np[labels == label] = 1
101+
else:
102+
inpaint_mask_np = mask_np
61103

62-
if (mask_np < 1).any():
104+
if (inpaint_mask_np > 0).any():
63105
if not cp.any(valid_mask):
64106
return elevation
65107

@@ -78,12 +120,16 @@ def __call__(
78120
# Replace NaNs with the minimum elevation value.
79121
safe_elevation = cp.where(finite_elevation, elevation, h_min)
80122
scaled = cp.asnumpy((safe_elevation - h_min) * 255.0 / denom).astype("uint8")
81-
dst = cv.inpaint(scaled, mask_np, 1, self.method)
123+
dst = cv.inpaint(scaled, inpaint_mask_np, 1, self.method)
82124
h_inpainted = dst.astype(np.float32) * denom / 255.0 + h_min
83125
filled = cp.asarray(h_inpainted, dtype=cp.float32)
84126

85-
# Ensure already-valid cells mirror the authoritative elevation layer.
86-
filled = cp.where(valid_mask, elevation, filled)
87-
return filled.astype(cp.float64)
127+
# Already-valid cells stay authoritative, supported small holes get filled,
128+
# and deeper unsupported gaps remain invalid (NaN) instead of being invented away.
129+
inpaint_mask = cp.asarray(inpaint_mask_np.astype(bool))
130+
output = cp.full(elevation.shape, cp.nan, dtype=cp.float32)
131+
output = cp.where(inpaint_mask, filled, output)
132+
output = cp.where(valid_mask, elevation, output)
133+
return output.astype(cp.float64)
88134
else:
89-
return elevation_map[0]
135+
return cp.where(valid_mask, elevation, cp.nan).astype(cp.float64)

0 commit comments

Comments
 (0)