Skip to content

Commit 4966ed1

Browse files
committed
Fix column-major GridMap input for masked_replace and align patch orientation
1 parent 806b7a8 commit 4966ed1

File tree

3 files changed

+204
-39
lines changed

3 files changed

+204
-39
lines changed

elevation_mapping_cupy/elevation_mapping_cupy/elevation_mapping.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1092,12 +1092,13 @@ def apply_masked_replace(
10921092
if np.any(valid_mask):
10931093
vals = incoming_slice[valid_mask]
10941094
min_max = (float(np.nanmin(vals)), float(np.nanmax(vals)))
1095-
map_extent = self._map_extent_from_slices(map_rows, map_cols)
1095+
map_extent = self._map_extent_from_mask(map_rows, map_cols, valid_mask) or self._map_extent_from_slices(map_rows, map_cols)
10961096
print(
10971097
f"[ElevationMap] masked_replace layer '{name}': wrote {written} cells, "
10981098
f"X∈[{map_extent['x_min']:.2f},{map_extent['x_max']:.2f}], "
10991099
f"Y∈[{map_extent['y_min']:.2f},{map_extent['y_max']:.2f}], "
1100-
f"values {min_max if min_max else 'n/a'}"
1100+
f"values {min_max if min_max else 'n/a'}",
1101+
flush=True
11011102
)
11021103

11031104
self._invalidate_caches()
@@ -1245,6 +1246,27 @@ def _map_extent_from_slices(self, rows: slice, cols: slice) -> Dict[str, float]:
12451246
y_max = map_min_y + (rows.stop - 0.5) * self.resolution
12461247
return {"x_min": x_min, "x_max": x_max, "y_min": y_min, "y_max": y_max}
12471248

1249+
def _map_extent_from_mask(self, rows: slice, cols: slice, valid_mask: np.ndarray) -> Optional[Dict[str, float]]:
1250+
"""Compute extent based on the actual mask footprint; returns None if mask is empty."""
1251+
if valid_mask is None or not np.any(valid_mask):
1252+
return None
1253+
row_idx, col_idx = np.nonzero(valid_mask)
1254+
row_min = rows.start + int(row_idx.min())
1255+
row_max = rows.start + int(row_idx.max())
1256+
col_min = cols.start + int(col_idx.min())
1257+
col_max = cols.start + int(col_idx.max())
1258+
1259+
map_length = (self.cell_n - 2) * self.resolution
1260+
center_cpu = np.asarray(cp.asnumpy(self.center))
1261+
map_min_x = center_cpu[0] - map_length / 2.0
1262+
map_min_y = center_cpu[1] - map_length / 2.0
1263+
1264+
x_min = map_min_x + (col_min + 0.5) * self.resolution
1265+
x_max = map_min_x + (col_max + 0.5) * self.resolution
1266+
y_min = map_min_y + (row_min + 0.5) * self.resolution
1267+
y_max = map_min_y + (row_max + 0.5) * self.resolution
1268+
return {"x_min": x_min, "x_max": x_max, "y_min": y_min, "y_max": y_max}
1269+
12481270
def _invalidate_caches(self, reset_plugins: bool = True):
12491271
self.traversability_buffer[...] = cp.nan
12501272
if reset_plugins:

elevation_mapping_cupy/scripts/elevation_mapping_node.py

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -469,17 +469,51 @@ def handle_load_map(self, request, response):
469469
response.success = False
470470
return response
471471

472+
def _float32_multiarray_to_numpy(self, name: str, array_msg: Float32MultiArray) -> np.ndarray:
473+
"""Convert a Float32MultiArray to a numpy array according to the layout labels."""
474+
data_np = np.asarray(array_msg.data, dtype=np.float32)
475+
dims = array_msg.layout.dim
476+
477+
if len(dims) >= 2 and dims[0].label and dims[1].label:
478+
label0 = dims[0].label
479+
label1 = dims[1].label
480+
# self.get_logger().info(f"Layer '{name}' has labels: {label0} and {label1}")
481+
if label0 == "row_index" and label1 == "column_index":
482+
# Data is in row-major order
483+
rows = dims[0].size or 1
484+
cols = dims[1].size or (len(data_np) // rows if rows else 0)
485+
expected = rows * cols
486+
if expected != data_np.size:
487+
raise ValueError(f"Layer '{name}' has inconsistent layout metadata.")
488+
return data_np.reshape((rows, cols), order="C")
489+
if label0 == "column_index" and label1 == "row_index":
490+
# Data is in column-major order
491+
# We need to flip both axes, then transpose to swap X/Y into our row-major (row=Y, col=X) expectation.
492+
cols = dims[0].size or 1
493+
rows = dims[1].size or (len(data_np) // cols if cols else 0)
494+
expected = rows * cols
495+
if expected != data_np.size:
496+
raise ValueError(f"Layer '{name}' has inconsistent layout metadata.")
497+
array = data_np.reshape((rows, cols), order="F")
498+
# Align to internal row-major convention:
499+
# Flip both axes, then transpose to swap X/Y into our row-major (row=Y, col=X) expectation.
500+
array = np.flip(array, axis=0)
501+
array = np.flip(array, axis=1)
502+
array = array.T
503+
return array
504+
505+
cols, rows = self._extract_layout_shape(array_msg)
506+
if data_np.size != rows * cols:
507+
raise ValueError(f"Layer '{name}' has inconsistent layout metadata.")
508+
return data_np.reshape((rows, cols))
509+
472510
def _grid_map_to_numpy(self, grid_map_msg: GridMap):
473511
if len(grid_map_msg.layers) != len(grid_map_msg.data):
474512
raise ValueError("Mismatch between GridMap layers and data arrays.")
475513

476514
arrays: Dict[str, np.ndarray] = {}
477515
for name, array_msg in zip(grid_map_msg.layers, grid_map_msg.data):
478-
cols, rows = self._extract_layout_shape(array_msg)
479-
data_np = np.asarray(array_msg.data, dtype=np.float32)
480-
if data_np.size != rows * cols:
481-
raise ValueError(f"Layer '{name}' has inconsistent layout metadata.")
482-
arrays[name] = data_np.reshape((rows, cols))
516+
arrays[name] = self._float32_multiarray_to_numpy(name, array_msg)
483517

484518
center = np.array(
485519
[
@@ -593,16 +627,15 @@ def _build_grid_map_message(
593627
return gm
594628

595629
def _numpy_to_multiarray(self, data: np.ndarray) -> Float32MultiArray:
630+
"""Convert a 2D numpy array to Float32MultiArray honoring row-major layout labels."""
596631
array = np.asarray(data, dtype=np.float32)
632+
rows, cols = array.shape
597633
msg = Float32MultiArray()
598634
msg.layout = MAL()
599-
msg.layout.dim.append(
600-
MAD(label="column_index", size=array.shape[1], stride=array.shape[0] * array.shape[1])
601-
)
602-
msg.layout.dim.append(
603-
MAD(label="row_index", size=array.shape[0], stride=array.shape[0])
604-
)
605-
msg.data = array.flatten().tolist()
635+
# Internal representation is always row-major: first dim is row_index, second is column_index.
636+
msg.layout.dim.append(MAD(label="row_index", size=rows, stride=rows * cols))
637+
msg.layout.dim.append(MAD(label="column_index", size=cols, stride=cols))
638+
msg.data = array.flatten(order="C").tolist()
606639
return msg
607640

608641
def _resolve_service_name(self, suffix: str) -> str:

scripts/masked_replace_tool.py

Lines changed: 135 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,30 @@ def build_parser() -> argparse.ArgumentParser:
4646
parser.add_argument("--center-z", type=float, default=0.0, help="Patch center Z coordinate (meters).")
4747
parser.add_argument("--size-x", type=positive_float, default=1.0, help="Patch length in X (meters).")
4848
parser.add_argument("--size-y", type=positive_float, default=1.0, help="Patch length in Y (meters).")
49+
parser.add_argument(
50+
"--full-length-x",
51+
type=positive_float,
52+
default=None,
53+
help="Optional total GridMap length in X (meters). If set, a full-size map is sent and only the patch region is marked in the mask."
54+
)
55+
parser.add_argument(
56+
"--full-length-y",
57+
type=positive_float,
58+
default=None,
59+
help="Optional total GridMap length in Y (meters). If set, a full-size map is sent and only the patch region is marked in the mask."
60+
)
61+
parser.add_argument(
62+
"--full-center-x",
63+
type=float,
64+
default=0.0,
65+
help="GridMap center X (meters) to use when sending a full-size map. Defaults to 0."
66+
)
67+
parser.add_argument(
68+
"--full-center-y",
69+
type=float,
70+
default=0.0,
71+
help="GridMap center Y (meters) to use when sending a full-size map. Defaults to 0."
72+
)
4973
parser.add_argument("--resolution", type=positive_float, default=0.1, help="Grid resolution (meters per cell).")
5074
parser.add_argument("--elevation", type=float, default=0.1, help="Elevation value to set (meters).")
5175
parser.add_argument("--variance", type=non_negative_float, default=0.05, help="Variance value to set.")
@@ -84,6 +108,10 @@ class PatchConfig:
84108
mask_value: float
85109
add_valid_layer: bool
86110
invalidate_first: bool
111+
full_length_x: Optional[float] = None
112+
full_length_y: Optional[float] = None
113+
full_center_x: float = 0.0
114+
full_center_y: float = 0.0
87115

88116
@property
89117
def shape(self) -> Dict[str, int]:
@@ -139,10 +167,17 @@ def _base_grid_map(self) -> GridMap:
139167
gm.header.frame_id = cfg.frame_id
140168
gm.header.stamp = self.get_clock().now().to_msg()
141169
gm.info.resolution = cfg.resolution
142-
gm.info.length_x = cfg.actual_length_x
143-
gm.info.length_y = cfg.actual_length_y
144-
gm.info.pose.position.x = cfg.center_x
145-
gm.info.pose.position.y = cfg.center_y
170+
# If full map was requested, use the full lengths and center the GridMap at the full-map center.
171+
if cfg.full_length_x or cfg.full_length_y:
172+
gm.info.length_x = cfg.full_length_x or cfg.actual_length_x
173+
gm.info.length_y = cfg.full_length_y or cfg.actual_length_y
174+
gm.info.pose.position.x = cfg.full_center_x
175+
gm.info.pose.position.y = cfg.full_center_y
176+
else:
177+
gm.info.length_x = cfg.actual_length_x
178+
gm.info.length_y = cfg.actual_length_y
179+
gm.info.pose.position.x = cfg.center_x
180+
gm.info.pose.position.y = cfg.center_y
146181
gm.info.pose.position.z = cfg.center_z
147182
gm.info.pose.orientation.w = 1.0
148183
gm.basic_layers = ["elevation"]
@@ -157,45 +192,116 @@ def _mask_array(self, force_value: Optional[float] = None) -> np.ndarray:
157192
mask_value = 1.0
158193
return np.full((rows, cols), mask_value, dtype=np.float32)
159194

195+
def _make_full_arrays(self) -> Dict[str, np.ndarray]:
196+
"""Create full-size arrays (possibly larger than the patch) and place the patch in them."""
197+
cfg = self._config
198+
length_x = cfg.full_length_x or cfg.length_x
199+
length_y = cfg.full_length_y or cfg.length_y
200+
cols_full = max(1, ceil(length_x / cfg.resolution))
201+
rows_full = max(1, ceil(length_y / cfg.resolution))
202+
203+
# Base arrays
204+
mask_full = np.full((rows_full, cols_full), np.nan, dtype=np.float32)
205+
elev_full = np.full((rows_full, cols_full), np.nan, dtype=np.float32)
206+
var_full = np.full((rows_full, cols_full), np.nan, dtype=np.float32)
207+
valid_full = np.zeros((rows_full, cols_full), dtype=np.float32)
208+
209+
# Patch arrays
210+
patch_rows = cfg.shape["rows"]
211+
patch_cols = cfg.shape["cols"]
212+
row_offset = int(round(cfg.center_y / cfg.resolution))
213+
col_offset = int(round(cfg.center_x / cfg.resolution))
214+
row_start = rows_full // 2 + row_offset - patch_rows // 2
215+
col_start = cols_full // 2 + col_offset - patch_cols // 2
216+
row_end = row_start + patch_rows
217+
col_end = col_start + patch_cols
218+
219+
# Safety: clamp if window would exceed bounds
220+
if row_start < 0 or col_start < 0 or row_end > rows_full or col_end > cols_full:
221+
raise ValueError("Patch exceeds full map bounds; adjust center/size or full map length.")
222+
223+
mask_val = cfg.mask_value
224+
if np.isnan(mask_val):
225+
mask_val = 1.0
226+
mask_full[row_start:row_end, col_start:col_end] = mask_val
227+
elev_full[row_start:row_end, col_start:col_end] = cfg.elevation
228+
var_full[row_start:row_end, col_start:col_end] = cfg.variance
229+
if cfg.add_valid_layer:
230+
valid_full[row_start:row_end, col_start:col_end] = 1.0
231+
232+
return {
233+
"mask": mask_full,
234+
"elevation": elev_full,
235+
"variance": var_full,
236+
"is_valid": valid_full,
237+
"rows_full": rows_full,
238+
"cols_full": cols_full,
239+
}
240+
160241
def _build_validity_message(self, value: float) -> GridMap:
161242
gm = self._base_grid_map()
162-
mask = self._mask_array()
163-
rows, cols = mask.shape
164-
gm.layers = [self._config.mask_layer, "is_valid"]
165-
arrays = {
166-
self._config.mask_layer: mask,
167-
"is_valid": np.full((rows, cols), value, dtype=np.float32),
168-
}
243+
if self._config.full_length_x or self._config.full_length_y:
244+
arrays_full = self._make_full_arrays()
245+
gm.info.length_x = self._config.full_length_x or self._config.length_x
246+
gm.info.length_y = self._config.full_length_y or self._config.length_y
247+
gm.layers = [self._config.mask_layer, "is_valid"]
248+
arrays = {
249+
self._config.mask_layer: arrays_full["mask"],
250+
"is_valid": np.full((arrays_full["rows_full"], arrays_full["cols_full"]), value, dtype=np.float32),
251+
}
252+
else:
253+
mask = self._mask_array()
254+
rows, cols = mask.shape
255+
gm.layers = [self._config.mask_layer, "is_valid"]
256+
arrays = {
257+
self._config.mask_layer: mask,
258+
"is_valid": np.full((rows, cols), value, dtype=np.float32),
259+
}
169260
for layer in gm.layers:
170261
gm.data.append(self._numpy_to_multiarray(arrays[layer]))
171262
return gm
172263

173264
def _build_data_message(self, valid_value: Optional[float]) -> GridMap:
174265
gm = self._base_grid_map()
175-
mask = self._mask_array()
176-
rows, cols = mask.shape
177-
gm.layers = [self._config.mask_layer, "elevation", "variance"]
178-
arrays = {
179-
self._config.mask_layer: mask,
180-
"elevation": np.full((rows, cols), self._config.elevation, dtype=np.float32),
181-
"variance": np.full((rows, cols), self._config.variance, dtype=np.float32),
182-
}
183-
if valid_value is not None:
184-
gm.layers.append("is_valid")
185-
arrays["is_valid"] = np.full((rows, cols), valid_value, dtype=np.float32)
266+
if self._config.full_length_x or self._config.full_length_y:
267+
arrays_full = self._make_full_arrays()
268+
gm.info.length_x = self._config.full_length_x or self._config.length_x
269+
gm.info.length_y = self._config.full_length_y or self._config.length_y
270+
gm.layers = [self._config.mask_layer, "elevation", "variance"]
271+
arrays = {
272+
self._config.mask_layer: arrays_full["mask"],
273+
"elevation": arrays_full["elevation"],
274+
"variance": arrays_full["variance"],
275+
}
276+
if valid_value is not None:
277+
gm.layers.append("is_valid")
278+
arrays["is_valid"] = arrays_full["is_valid"]
279+
else:
280+
mask = self._mask_array()
281+
rows, cols = mask.shape
282+
gm.layers = [self._config.mask_layer, "elevation", "variance"]
283+
arrays = {
284+
self._config.mask_layer: mask,
285+
"elevation": np.full((rows, cols), self._config.elevation, dtype=np.float32),
286+
"variance": np.full((rows, cols), self._config.variance, dtype=np.float32),
287+
}
288+
if valid_value is not None:
289+
gm.layers.append("is_valid")
290+
arrays["is_valid"] = np.full((rows, cols), valid_value, dtype=np.float32)
186291
for layer in gm.layers:
187292
gm.data.append(self._numpy_to_multiarray(arrays[layer]))
188293
return gm
189294

190295
@staticmethod
191296
def _numpy_to_multiarray(array: np.ndarray) -> Float32MultiArray:
297+
"""Build a Float32MultiArray with explicit row-major layout labels."""
192298
msg = Float32MultiArray()
193299
layout = MultiArrayLayout()
194300
rows, cols = array.shape
195-
layout.dim.append(MultiArrayDimension(label="column_index", size=cols, stride=rows * cols))
196-
layout.dim.append(MultiArrayDimension(label="row_index", size=rows, stride=rows))
301+
layout.dim.append(MultiArrayDimension(label="row_index", size=rows, stride=rows * cols))
302+
layout.dim.append(MultiArrayDimension(label="column_index", size=cols, stride=cols))
197303
msg.layout = layout
198-
msg.data = array.flatten().tolist()
304+
msg.data = array.flatten(order="C").tolist()
199305
return msg
200306

201307

@@ -216,6 +322,10 @@ def main() -> None:
216322
mask_value=args.mask_value,
217323
add_valid_layer=args.valid_layer,
218324
invalidate_first=args.invalidate_first,
325+
full_length_x=args.full_length_x,
326+
full_length_y=args.full_length_y,
327+
full_center_x=args.full_center_x,
328+
full_center_y=args.full_center_y,
219329
)
220330

221331
rclpy.init()

0 commit comments

Comments
 (0)