|
2 | 2 | # Copyright (c) 2022, Takahiro Miki. All rights reserved. |
3 | 3 | # Licensed under the MIT license. See LICENSE file in the project root for details. |
4 | 4 | # |
| 5 | +import math |
5 | 6 | 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 |
7 | 9 |
|
8 | 10 | import numpy as np |
9 | 11 | import threading |
|
48 | 50 | cp.cuda.set_allocator(pool.malloc) |
49 | 51 |
|
50 | 52 |
|
| 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 | + |
51 | 97 | class ElevationMap: |
52 | 98 | """Core elevation mapping class.""" |
53 | 99 |
|
@@ -752,6 +798,8 @@ def get_map_with_name_ref(self, name, data): |
752 | 798 | use_stream = False |
753 | 799 | elif name == "variance": |
754 | 800 | m = self.get_variance() |
| 801 | + elif name == "is_valid": |
| 802 | + m = self.elevation_map[2].copy()[1:-1, 1:-1] |
755 | 803 | elif name == "traversability": |
756 | 804 | m = self.get_traversability() |
757 | 805 | elif name == "time": |
@@ -960,6 +1008,244 @@ def initialize_map(self, points, method="cubic"): |
960 | 1008 | ) |
961 | 1009 | self.update_upper_bound_with_valid_elevation() |
962 | 1010 |
|
| 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 | + |
963 | 1249 |
|
964 | 1250 | if __name__ == "__main__": |
965 | 1251 | # Test script for profiling. |
|
0 commit comments