Skip to content

Commit 6a4183e

Browse files
committed
Added masked_replace, save_map and load_map services. Also inpainting plugin updates with elevation updates so it registers the masked_replace updates
1 parent f78fbfc commit 6a4183e

File tree

9 files changed

+1006
-19
lines changed

9 files changed

+1006
-19
lines changed

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,22 @@ crucial for efficient and accurate robotic movement, particularly in legged robo
2727

2828
- **GPU-Enhanced Efficiency**: Facilitates rapid processing of large data structures, crucial for real-time applications.
2929

30+
## Map Editing & Persistence Services (ROS 2)
31+
32+
- `/elevation_mapping_cupy/masked_replace` (`grid_map_msgs/srv/SetGridMap`): Patch specific regions of the live CuPy map. Provide one or more layers plus an optional `mask` layer; cells with non-NaN mask values are overwritten, NaNs are ignored.
33+
- `/elevation_mapping_cupy/save_map` (`grid_map_msgs/srv/ProcessFile`): Persist the current state to `<file_path>` (published layers) and `<file_path>_raw` (all internal layers) rosbag2 files. Bags are written with the configurable `save_map_storage_id` (defaults to `mcap`).
34+
- `/elevation_mapping_cupy/load_map` (`grid_map_msgs/srv/ProcessFile`): Restore a previously saved map by pointing at the fused bag path; the node reloads both fused/raw data, refreshes caches, and republishes immediately.
35+
36+
Example usage:
37+
38+
```bash
39+
ros2 service call /elevation_mapping_cupy/masked_replace grid_map_msgs/srv/SetGridMap "{map: ...}"
40+
ros2 service call /elevation_mapping_cupy/save_map grid_map_msgs/srv/ProcessFile "{file_path: '/tmp/em_map', topic_name: ''}"
41+
ros2 service call /elevation_mapping_cupy/load_map grid_map_msgs/srv/ProcessFile "{file_path: '/tmp/em_map', topic_name: ''}"
42+
```
43+
44+
Parameters such as `masked_replace_service_mask_layer_name`, `save_map_default_topic`, `save_map_storage_id`, and `service_namespace` can be set via the usual ROS 2 parameter mechanisms to adapt the services to different deployments.
45+
3046
## Overview
3147

3248
![Overview of multi-modal elevation map structure](docs/media/overview.png)

elevation_mapping_cupy/config/core/core_param.yaml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ elevation_mapping_node:
3838
recordable_fps: 3.0 # Recordable fps for pointcloud.
3939
enable_normal_arrow_publishing: false
4040

41+
masked_replace_service_mask_layer_name: 'mask'
42+
save_map_default_topic: 'elevation_map'
43+
save_map_storage_id: 'mcap'
44+
service_namespace: '/elevation_mapping_cupy'
45+
4146
max_ray_length: 10.0 # maximum length for ray tracing.
4247
cleanup_step: 0.1 # subtitute this value from validity layer at visibiltiy cleanup.
4348
cleanup_cos_thresh: 0.1 # subtitute this value from validity layer at visibiltiy cleanup.
@@ -79,4 +84,4 @@ elevation_mapping_node:
7984
use_initializer_at_start: true # Use initializer when the node starts.
8085

8186
#### Default Plugins ########
82-
plugin_config_file: '$(find-pkg-share elevation_mapping_cupy)/config/core/plugin_config.yaml'
87+
plugin_config_file: '$(find-pkg-share elevation_mapping_cupy)/config/core/plugin_config.yaml'

elevation_mapping_cupy/elevation_mapping_cupy/elevation_mapping.py

Lines changed: 287 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
# Copyright (c) 2022, Takahiro Miki. All rights reserved.
33
# Licensed under the MIT license. See LICENSE file in the project root for details.
44
#
5+
import math
56
import os
6-
from typing import List, Any, Tuple, Union
7+
from dataclasses import dataclass
8+
from typing import Dict, List, Any, Tuple, Union, Optional
79

810
import numpy as np
911
import threading
@@ -48,6 +50,50 @@
4850
cp.cuda.set_allocator(pool.malloc)
4951

5052

53+
@dataclass
54+
class GridGeometry:
55+
"""Lightweight holder describing a GridMap's geometry."""
56+
57+
length_x: float
58+
length_y: float
59+
resolution: float
60+
center: np.ndarray
61+
orientation: np.ndarray
62+
63+
@property
64+
def bounds_x(self) -> Tuple[float, float]:
65+
half = self.length_x / 2.0
66+
return self.center[0] - half, self.center[0] + half
67+
68+
@property
69+
def bounds_y(self) -> Tuple[float, float]:
70+
half = self.length_y / 2.0
71+
return self.center[1] - half, self.center[1] + half
72+
73+
@property
74+
def shape(self) -> Tuple[int, int]:
75+
cols = int(round(self.length_x / self.resolution))
76+
rows = int(round(self.length_y / self.resolution))
77+
return rows, cols
78+
79+
80+
BASE_LAYER_TO_INDEX = {
81+
"elevation": 0,
82+
"variance": 1,
83+
"is_valid": 2,
84+
"traversability": 3,
85+
"time": 4,
86+
"upper_bound": 5,
87+
"is_upper_bound": 6,
88+
}
89+
90+
NORMAL_LAYER_TO_INDEX = {
91+
"normal_x": 0,
92+
"normal_y": 1,
93+
"normal_z": 2,
94+
}
95+
96+
5197
class ElevationMap:
5298
"""Core elevation mapping class."""
5399

@@ -752,6 +798,8 @@ def get_map_with_name_ref(self, name, data):
752798
use_stream = False
753799
elif name == "variance":
754800
m = self.get_variance()
801+
elif name == "is_valid":
802+
m = self.elevation_map[2].copy()[1:-1, 1:-1]
755803
elif name == "traversability":
756804
m = self.get_traversability()
757805
elif name == "time":
@@ -960,6 +1008,244 @@ def initialize_map(self, points, method="cubic"):
9601008
)
9611009
self.update_upper_bound_with_valid_elevation()
9621010

1011+
def list_layers(self) -> List[str]:
1012+
ordered: List[str] = []
1013+
for container in (
1014+
self.layer_names,
1015+
getattr(self.semantic_map, "layer_names", []),
1016+
getattr(self.plugin_manager, "layer_names", []),
1017+
):
1018+
for name in container:
1019+
if name and name not in ordered:
1020+
ordered.append(name)
1021+
return ordered
1022+
1023+
def export_layers(self, layer_names: List[str]) -> Dict[str, np.ndarray]:
1024+
exported: Dict[str, np.ndarray] = {}
1025+
buffer = np.zeros((self.cell_n - 2, self.cell_n - 2), dtype=np.float32)
1026+
for name in layer_names:
1027+
if not self.exists_layer(name):
1028+
continue
1029+
self.get_map_with_name_ref(name, buffer)
1030+
exported[name] = buffer.copy()
1031+
return exported
1032+
1033+
def apply_masked_replace(
1034+
self,
1035+
layer_data: Dict[str, np.ndarray],
1036+
mask: Optional[np.ndarray],
1037+
geometry: GridGeometry,
1038+
) -> None:
1039+
if not layer_data:
1040+
raise ValueError("No layer data provided for masked replace.")
1041+
1042+
sample_shape: Optional[Tuple[int, int]] = None
1043+
for array in layer_data.values():
1044+
if sample_shape is None:
1045+
sample_shape = array.shape
1046+
elif sample_shape != array.shape:
1047+
raise ValueError("All incoming layers must share the same shape.")
1048+
1049+
if sample_shape is None:
1050+
raise ValueError("Unable to infer incoming layer shape.")
1051+
1052+
if mask is None:
1053+
mask = np.ones(sample_shape, dtype=np.float32)
1054+
if mask.shape != sample_shape:
1055+
raise ValueError("Mask shape does not match provided layers.")
1056+
1057+
self._validate_geometry_against_shape(sample_shape, geometry)
1058+
overlap = self._compute_overlap_indices(sample_shape, geometry)
1059+
if overlap is None:
1060+
raise ValueError("Incoming grid does not overlap with the active map.")
1061+
1062+
map_rows = overlap["map"][0]
1063+
map_cols = overlap["map"][1]
1064+
patch_rows = overlap["patch"][0]
1065+
patch_cols = overlap["patch"][1]
1066+
1067+
mask_slice = mask[patch_rows, patch_cols]
1068+
valid_mask = np.isfinite(mask_slice)
1069+
if not np.any(valid_mask):
1070+
return
1071+
1072+
cp_mask = cp.asarray(valid_mask)
1073+
center_z = float(cp.asnumpy(self.center)[2])
1074+
1075+
with self.map_lock:
1076+
for name, array in layer_data.items():
1077+
target = self._resolve_layer_target(name)
1078+
if target is None:
1079+
raise ValueError(f"Layer '{name}' does not exist in the map.")
1080+
incoming_slice = array[patch_rows, patch_cols]
1081+
if incoming_slice.shape != mask_slice.shape:
1082+
raise ValueError("Mismatch between mask and incoming slice dimensions.")
1083+
if name in ("elevation", "upper_bound"):
1084+
incoming_slice = incoming_slice - center_z
1085+
incoming_cp = cp.asarray(incoming_slice, dtype=self.data_type)
1086+
target_region = target[map_rows, map_cols]
1087+
before = target_region[cp_mask].copy()
1088+
target_region[cp_mask] = incoming_cp[cp_mask]
1089+
written = int(cp_mask.sum())
1090+
# Debug diagnostics for field coverage
1091+
min_max = None
1092+
if np.any(valid_mask):
1093+
vals = incoming_slice[valid_mask]
1094+
min_max = (float(np.nanmin(vals)), float(np.nanmax(vals)))
1095+
map_extent = self._map_extent_from_slices(map_rows, map_cols)
1096+
print(
1097+
f"[ElevationMap] masked_replace layer '{name}': wrote {written} cells, "
1098+
f"X∈[{map_extent['x_min']:.2f},{map_extent['x_max']:.2f}], "
1099+
f"Y∈[{map_extent['y_min']:.2f},{map_extent['y_max']:.2f}], "
1100+
f"values {min_max if min_max else 'n/a'}"
1101+
)
1102+
1103+
self._invalidate_caches()
1104+
1105+
def set_full_map(
1106+
self,
1107+
fused_layers: Dict[str, np.ndarray],
1108+
raw_layers: Dict[str, np.ndarray],
1109+
geometry: GridGeometry,
1110+
) -> None:
1111+
if not raw_layers:
1112+
raise ValueError("Raw layer data required to restore the map.")
1113+
sample_shape = next(iter(raw_layers.values())).shape
1114+
self._validate_geometry_against_shape(sample_shape, geometry)
1115+
1116+
center_np = np.asarray(geometry.center, dtype=np.float32)
1117+
provided_plugin_layers = set()
1118+
1119+
with self.map_lock:
1120+
self.center[:] = cp.asarray(center_np, dtype=self.data_type)
1121+
for name, data in raw_layers.items():
1122+
target = self._resolve_layer_target(name, allow_semantic_creation=True)
1123+
if target is None:
1124+
continue
1125+
incoming = data
1126+
if name in ("elevation", "upper_bound"):
1127+
incoming = incoming - center_np[2]
1128+
incoming_cp = cp.asarray(incoming, dtype=self.data_type)
1129+
target[...] = incoming_cp
1130+
if name in getattr(self.plugin_manager, "layer_names", []):
1131+
provided_plugin_layers.add(name)
1132+
1133+
if len(provided_plugin_layers) != len(getattr(self.plugin_manager, "layer_names", [])):
1134+
self.plugin_manager.reset_layers()
1135+
1136+
self._invalidate_caches(reset_plugins=False)
1137+
1138+
def _resolve_layer_target(self, name: str, allow_semantic_creation: bool = False):
1139+
if name in BASE_LAYER_TO_INDEX:
1140+
idx = BASE_LAYER_TO_INDEX[name]
1141+
return self.elevation_map[idx, 1:-1, 1:-1]
1142+
if name in NORMAL_LAYER_TO_INDEX:
1143+
idx = NORMAL_LAYER_TO_INDEX[name]
1144+
return self.normal_map[idx, 1:-1, 1:-1]
1145+
if name in getattr(self.semantic_map, "layer_names", []):
1146+
idx = self.semantic_map.layer_names.index(name)
1147+
return self.semantic_map.semantic_map[idx, 1:-1, 1:-1]
1148+
if allow_semantic_creation and hasattr(self.semantic_map, "add_layer"):
1149+
self.semantic_map.add_layer(name)
1150+
idx = self.semantic_map.layer_names.index(name)
1151+
return self.semantic_map.semantic_map[idx, 1:-1, 1:-1]
1152+
if name in getattr(self.plugin_manager, "layer_names", []):
1153+
idx = self.plugin_manager.layer_names.index(name)
1154+
return self.plugin_manager.layers[idx, 1:-1, 1:-1]
1155+
return None
1156+
1157+
def _validate_geometry_against_shape(self, shape: Tuple[int, int], geometry: GridGeometry) -> None:
1158+
expected_shape = geometry.shape
1159+
if shape != expected_shape:
1160+
raise ValueError(
1161+
f"Grid shape mismatch: expected {expected_shape}, received {shape}."
1162+
)
1163+
if not math.isclose(float(geometry.resolution), float(self.resolution), rel_tol=1e-6, abs_tol=1e-6):
1164+
raise ValueError(
1165+
f"Resolution mismatch: map uses {self.resolution}, incoming grid uses {geometry.resolution}."
1166+
)
1167+
1168+
def _compute_overlap_indices(
1169+
self, incoming_shape: Tuple[int, int], geometry: GridGeometry
1170+
) -> Optional[Dict[str, Tuple[slice, slice]]]:
1171+
map_length = (self.cell_n - 2) * self.resolution
1172+
center_cpu = np.asarray(cp.asnumpy(self.center))
1173+
map_min_x = center_cpu[0] - map_length / 2.0
1174+
map_max_x = center_cpu[0] + map_length / 2.0
1175+
map_min_y = center_cpu[1] - map_length / 2.0
1176+
map_max_y = center_cpu[1] + map_length / 2.0
1177+
1178+
patch_min_x, patch_max_x = geometry.bounds_x
1179+
patch_min_y, patch_max_y = geometry.bounds_y
1180+
1181+
overlap_min_x = max(map_min_x, patch_min_x)
1182+
overlap_max_x = min(map_max_x, patch_max_x)
1183+
overlap_min_y = max(map_min_y, patch_min_y)
1184+
overlap_max_y = min(map_max_y, patch_max_y)
1185+
1186+
if overlap_max_x <= overlap_min_x or overlap_max_y <= overlap_min_y:
1187+
return None
1188+
1189+
map_width = self.cell_n - 2
1190+
patch_rows, patch_cols = incoming_shape
1191+
1192+
map_origin_x = map_min_x
1193+
map_origin_y = map_min_y
1194+
patch_origin_x = patch_min_x
1195+
patch_origin_y = patch_min_y
1196+
1197+
map_start_x = int(np.clip(np.floor((overlap_min_x - map_origin_x) / self.resolution), 0, map_width))
1198+
map_end_x = int(
1199+
np.clip(np.ceil((overlap_max_x - map_origin_x) / self.resolution), 0, map_width)
1200+
)
1201+
map_start_y = int(np.clip(np.floor((overlap_min_y - map_origin_y) / self.resolution), 0, map_width))
1202+
map_end_y = int(
1203+
np.clip(np.ceil((overlap_max_y - map_origin_y) / self.resolution), 0, map_width)
1204+
)
1205+
1206+
patch_start_x = int(
1207+
np.clip(np.floor((overlap_min_x - patch_origin_x) / geometry.resolution), 0, patch_cols)
1208+
)
1209+
patch_end_x = int(
1210+
np.clip(np.ceil((overlap_max_x - patch_origin_x) / geometry.resolution), 0, patch_cols)
1211+
)
1212+
patch_start_y = int(
1213+
np.clip(np.floor((overlap_min_y - patch_origin_y) / geometry.resolution), 0, patch_rows)
1214+
)
1215+
patch_end_y = int(
1216+
np.clip(np.ceil((overlap_max_y - patch_origin_y) / geometry.resolution), 0, patch_rows)
1217+
)
1218+
1219+
width = min(map_end_x - map_start_x, patch_end_x - patch_start_x)
1220+
height = min(map_end_y - map_start_y, patch_end_y - patch_start_y)
1221+
1222+
if width <= 0 or height <= 0:
1223+
return None
1224+
1225+
return {
1226+
"map": (slice(map_start_y, map_start_y + height), slice(map_start_x, map_start_x + width)),
1227+
"patch": (
1228+
slice(patch_start_y, patch_start_y + height),
1229+
slice(patch_start_x, patch_start_x + width),
1230+
),
1231+
}
1232+
1233+
def _map_extent_from_slices(self, rows: slice, cols: slice) -> Dict[str, float]:
1234+
map_length = (self.cell_n - 2) * self.resolution
1235+
center_cpu = np.asarray(cp.asnumpy(self.center))
1236+
map_min_x = center_cpu[0] - map_length / 2.0
1237+
map_min_y = center_cpu[1] - map_length / 2.0
1238+
x_min = map_min_x + (cols.start + 0.5) * self.resolution
1239+
x_max = map_min_x + (cols.stop - 0.5) * self.resolution
1240+
y_min = map_min_y + (rows.start + 0.5) * self.resolution
1241+
y_max = map_min_y + (rows.stop - 0.5) * self.resolution
1242+
return {"x_min": x_min, "x_max": x_max, "y_min": y_min, "y_max": y_max}
1243+
1244+
def _invalidate_caches(self, reset_plugins: bool = True):
1245+
self.traversability_buffer[...] = cp.nan
1246+
if reset_plugins:
1247+
self.plugin_manager.reset_layers()
1248+
9631249

9641250
if __name__ == "__main__":
9651251
# Test script for profiling.

elevation_mapping_cupy/elevation_mapping_cupy/plugins/inpainting.py

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,27 @@ def __call__(
5050
Returns:
5151
cupy._core.core.ndarray:
5252
"""
53-
mask = cp.asnumpy((elevation_map[2] < 0.5).astype("uint8"))
54-
if (mask < 1).any():
55-
h = elevation_map[0]
56-
h_max = float(h[mask < 1].max())
57-
h_min = float(h[mask < 1].min())
58-
h = cp.asnumpy((elevation_map[0] - h_min) * 255 / (h_max - h_min)).astype("uint8")
59-
dst = np.array(cv.inpaint(h, mask, 1, self.method))
60-
h_inpainted = dst.astype(np.float32) * (h_max - h_min) / 255 + h_min
61-
return cp.asarray(h_inpainted).astype(np.float64)
53+
mask_np = cp.asnumpy((elevation_map[2] < 0.5).astype("uint8"))
54+
if (mask_np < 1).any():
55+
elevation = elevation_map[0]
56+
valid_mask = elevation_map[2] > 0.5
57+
if not cp.any(valid_mask):
58+
return elevation
59+
60+
h_valid = elevation[valid_mask]
61+
h_max = float(cp.asnumpy(h_valid.max()))
62+
h_min = float(cp.asnumpy(h_valid.min()))
63+
denom = h_max - h_min
64+
if denom <= 1e-6:
65+
filled = elevation.copy()
66+
else:
67+
scaled = cp.asnumpy((elevation - h_min) * 255.0 / denom).astype("uint8")
68+
dst = cv.inpaint(scaled, mask_np, 1, self.method)
69+
h_inpainted = dst.astype(np.float32) * denom / 255.0 + h_min
70+
filled = cp.asarray(h_inpainted, dtype=cp.float32)
71+
72+
# Ensure already-valid cells mirror the authoritative elevation layer.
73+
filled = cp.where(valid_mask, elevation, filled)
74+
return filled.astype(cp.float64)
6275
else:
6376
return elevation_map[0]

0 commit comments

Comments
 (0)