Skip to content

Commit 2f8c0c7

Browse files
committed
fix: transform robot-local map regions
Define robot_pose in the observation header frame, transform supported region geometry before rasterization, and reject updates whose frame does not match the static map. Signed-off-by: SamuelFoo <fooenzesamuel@gmail.com>
1 parent 4e20799 commit 2f8c0c7

7 files changed

Lines changed: 184 additions & 30 deletions

File tree

discourse/3-layered-global-occupancy-map.md

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,16 @@ describes where an observation came from.
4848

4949
Important fields:
5050

51-
* `header`: frame for the observation and its required, non-zero timestamp
51+
* `header`: global frame for the observation and its required, non-zero
52+
timestamp
5253
* `source_id`: stable source identifier, usually the robot namespace plus the
5354
local source name
5455
* `robot_name`: robot that produced this observation, if the source is mounted
5556
on a robot. This can be empty for fixed sensors or synthetic map layers
5657
* `map_name`: map or level that the observations belong to
57-
* `robot_pose`: robot pose when the observation was produced, so consumers do
58-
not need to reconstruct this from a separate TF-like lookup
58+
* `robot_pose`: pose of the robot-local observation frame in `header.frame_id`
59+
when the observation was produced, so consumers do not need to reconstruct
60+
it from a separate TF-like lookup
5961
* `default_ttl_sec`: fallback TTL in seconds for patches from this source
6062

6163
## `MapRegionUpdate`
@@ -96,15 +98,19 @@ obstacle patches, which gives deterministic "clear then mark" behavior without
9698
relying on the arrival order of separate ROS messages.
9799

98100
The first prototype uses `rmf_prototype_msgs/Region` for sparse 2D geometry so
99-
robots can publish compact patches. The central map service is responsible for
100-
rasterizing those regions into its internal representation. The first
101-
implementation accepts point and axis-aligned rectangle regions.
101+
robots can publish compact patches. Region coordinates are robot-local. The map
102+
service transforms them through `source.robot_pose` into
103+
`source.header.frame_id` before rasterizing them. Fixed sensors can publish
104+
sensor-local regions with their sensor pose, while synthetic sources whose
105+
regions are already global can use the identity pose. The first implementation
106+
accepts point and axis-aligned rectangle regions.
102107

103108
# Example Flow
104109

105110
A local costmap or LiDAR observation node can publish a replacement snapshot by:
106111

107-
1. Stamping the observation source with the sensor frame and observation time.
112+
1. Stamping the observation source with the global frame and observation time,
113+
and including the pose of the robot-local observation frame.
108114
2. Setting `reset_source` if the new snapshot replaces the source's previous
109115
temporary observations.
110116
3. Adding one or more clear patches for free space seen by the sensor.
@@ -123,8 +129,9 @@ The first server tests cover:
123129

124130
* composing obstacle regions over a static planning grid
125131
* point regions with non-zero map origins and non-1.0 resolutions
126-
* rejecting updates without a timestamp
132+
* transforming robot-local regions into the global map frame
127133
* out-of-bounds regions, malformed point arrays, and unsupported region types
134+
* rejecting updates without a timestamp
128135
* pruning expired observations by TTL
129136
* clear and obstacle patches in the same update
130137
* late older snapshots being ignored

map_server/rmf_layered_map_server/README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,11 @@ patches. If the snapshot replaces the source's previous observation state, set
2020
`map_name` before adding the new patches. Resetting has no TTL. Clear and
2121
obstacle patches both use TTLs in seconds.
2222

23-
Updates with a zero source timestamp are rejected.
24-
25-
The current implementation accepts point and axis-aligned rectangle regions;
26-
it logs and ignores other region types.
23+
Patch regions are expressed in the robot-local observation frame. The server
24+
uses `source.robot_pose` to transform them into `source.header.frame_id`, which
25+
must match the static occupancy grid frame. Updates need a non-zero source
26+
timestamp. The current implementation accepts point and axis-aligned rectangle
27+
regions. It logs and ignores other region types.
2728

2829
## Visualization Smoke Test
2930

map_server/rmf_layered_map_server/src/lib.rs

Lines changed: 153 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
use rclrs::{IntoPrimitiveOptions, Node, PrimitiveOptions};
1616
use ros_env::{
17+
geometry_msgs::msg::Pose,
1718
nav_msgs::msg::OccupancyGrid,
1819
rmf_layered_map_msgs::msg::{MapRegionPatch, MapRegionUpdate},
1920
rmf_prototype_msgs::msg::Region,
@@ -33,6 +34,7 @@ enum DynamicUpdateType {
3334
struct DynamicObservation {
3435
source_id: String,
3536
map_name: String,
37+
frame_id: String,
3638
update_type: DynamicUpdateType,
3739
occupancy_value: i8,
3840
regions: Vec<Region>,
@@ -80,7 +82,7 @@ impl LayeredMap {
8082
}
8183

8284
pub fn ingest_region_update(&mut self, update: MapRegionUpdate, now_nsec: i128) -> bool {
83-
if update.source.header.stamp.sec == 0 && update.source.header.stamp.nanosec == 0 {
85+
if source_validation_error(&update, self.static_frame_id()).is_some() {
8486
return false;
8587
}
8688

@@ -110,6 +112,8 @@ impl LayeredMap {
110112
}
111113

112114
let default_ttl_sec = update.source.default_ttl_sec;
115+
let frame_id = update.source.header.frame_id;
116+
let robot_pose = update.source.robot_pose;
113117
let mut patches = update.patches;
114118
patches.sort_by_key(|patch| match patch.update_type {
115119
MapRegionPatch::UPDATE_CLEAR => 0,
@@ -128,6 +132,7 @@ impl LayeredMap {
128132
.regions
129133
.into_iter()
130134
.filter(|region| region_validation_error(region).is_none())
135+
.map(|region| transform_region(region, &robot_pose))
131136
.collect();
132137
if regions.is_empty() {
133138
continue;
@@ -155,6 +160,7 @@ impl LayeredMap {
155160
self.dynamic_observations.push(DynamicObservation {
156161
source_id: key.source_id.clone(),
157162
map_name: key.map_name.clone(),
163+
frame_id: frame_id.clone(),
158164
update_type,
159165
occupancy_value,
160166
regions,
@@ -187,25 +193,30 @@ impl LayeredMap {
187193
pub fn compose(&self) -> Option<OccupancyGrid> {
188194
let mut composed = self.static_grid.clone()?;
189195
normalize_grid_data(&mut composed);
196+
let frame_id = composed.header.frame_id.clone();
190197

191198
for observation in self
192199
.dynamic_observations
193200
.iter()
194-
.filter(|obs| obs.update_type == DynamicUpdateType::Clear)
201+
.filter(|obs| obs.frame_id == frame_id && obs.update_type == DynamicUpdateType::Clear)
195202
{
196203
rasterize_observation(&mut composed, observation);
197204
}
198205

199-
for observation in self
200-
.dynamic_observations
201-
.iter()
202-
.filter(|obs| obs.update_type == DynamicUpdateType::Obstacle)
203-
{
206+
for observation in self.dynamic_observations.iter().filter(|obs| {
207+
obs.frame_id == frame_id && obs.update_type == DynamicUpdateType::Obstacle
208+
}) {
204209
rasterize_observation(&mut composed, observation);
205210
}
206211

207212
Some(composed)
208213
}
214+
215+
fn static_frame_id(&self) -> Option<&str> {
216+
self.static_grid
217+
.as_ref()
218+
.map(|grid| grid.header.frame_id.as_str())
219+
}
209220
}
210221

211222
impl Default for LayeredMap {
@@ -269,7 +280,7 @@ impl LayeredMapServer {
269280
}
270281

271282
fn handle_region_update(&mut self, msg: MapRegionUpdate) {
272-
log_region_update_errors(&self.node, &msg);
283+
log_region_update_errors(&self.node, &msg, self.map.static_frame_id());
273284
let now_nsec = self.now_nsec();
274285
if self.map.ingest_region_update(msg, now_nsec) {
275286
rclrs::log!(
@@ -386,7 +397,16 @@ fn region_update_qos(topic: &str) -> PrimitiveOptions<'_> {
386397
topic.keep_last(MAP_QOS_DEPTH).reliable()
387398
}
388399

389-
fn log_region_update_errors(node: &Node, update: &MapRegionUpdate) {
400+
fn log_region_update_errors(node: &Node, update: &MapRegionUpdate, expected_frame: Option<&str>) {
401+
if let Some(error) = source_validation_error(update, expected_frame) {
402+
rclrs::log_error!(
403+
node.logger(),
404+
"Ignoring map region update from '{}': {}",
405+
update.source.source_id,
406+
error
407+
);
408+
}
409+
390410
for patch in &update.patches {
391411
if !matches!(
392412
patch.update_type,
@@ -408,6 +428,56 @@ fn log_region_update_errors(node: &Node, update: &MapRegionUpdate) {
408428
}
409429
}
410430

431+
fn source_validation_error(
432+
update: &MapRegionUpdate,
433+
expected_frame: Option<&str>,
434+
) -> Option<String> {
435+
let stamp = &update.source.header.stamp;
436+
if stamp.sec == 0 && stamp.nanosec == 0 {
437+
return Some("source timestamp must be non-zero".to_string());
438+
}
439+
440+
let frame_id = update.source.header.frame_id.as_str();
441+
if frame_id.is_empty() {
442+
return Some("source frame must not be empty".to_string());
443+
}
444+
445+
if let Some(expected) = expected_frame {
446+
if !expected.is_empty() && expected != frame_id {
447+
return Some(format!(
448+
"source frame '{}' does not match map frame '{}'",
449+
frame_id, expected
450+
));
451+
}
452+
}
453+
454+
let pose = &update.source.robot_pose;
455+
if ![
456+
pose.position.x,
457+
pose.position.y,
458+
pose.position.z,
459+
pose.orientation.x,
460+
pose.orientation.y,
461+
pose.orientation.z,
462+
pose.orientation.w,
463+
]
464+
.into_iter()
465+
.all(f64::is_finite)
466+
{
467+
return Some("source pose must contain finite values".to_string());
468+
}
469+
470+
let orientation_norm = pose.orientation.x * pose.orientation.x
471+
+ pose.orientation.y * pose.orientation.y
472+
+ pose.orientation.z * pose.orientation.z
473+
+ pose.orientation.w * pose.orientation.w;
474+
if orientation_norm <= f64::EPSILON {
475+
return Some("source pose must contain a valid orientation".to_string());
476+
}
477+
478+
None
479+
}
480+
411481
fn region_validation_error(region: &Region) -> Option<String> {
412482
if region.points.len() < 2 {
413483
return Some("region must contain at least one x/y point pair".to_string());
@@ -432,13 +502,49 @@ fn region_validation_error(region: &Region) -> Option<String> {
432502
}
433503
}
434504

435-
fn is_supported_region_hint(hint: u8) -> bool {
505+
fn is_rasterizable_region_hint(hint: u8) -> bool {
436506
matches!(
437507
hint,
438-
Region::HINT_POINT | Region::HINT_AXIS_ALIGNED_RECTANGLE
508+
Region::HINT_POINT | Region::HINT_AXIS_ALIGNED_RECTANGLE | Region::HINT_CONVEX_POLYGON
439509
)
440510
}
441511

512+
fn transform_region(mut region: Region, pose: &Pose) -> Region {
513+
let points = point_pairs(&region);
514+
let points = if region.hint == Region::HINT_AXIS_ALIGNED_RECTANGLE {
515+
let (min_x, min_y, max_x, max_y) = bounds(&points);
516+
region.hint = Region::HINT_CONVEX_POLYGON;
517+
vec![
518+
(min_x, min_y),
519+
(max_x, min_y),
520+
(max_x, max_y),
521+
(min_x, max_y),
522+
]
523+
} else {
524+
points
525+
};
526+
527+
let (cos_yaw, sin_yaw) = planar_rotation(pose);
528+
region.points = points
529+
.into_iter()
530+
.flat_map(|(x, y)| {
531+
let map_x = pose.position.x + cos_yaw * x - sin_yaw * y;
532+
let map_y = pose.position.y + sin_yaw * x + cos_yaw * y;
533+
[map_x as f32, map_y as f32]
534+
})
535+
.collect();
536+
region
537+
}
538+
539+
fn planar_rotation(pose: &Pose) -> (f64, f64) {
540+
let q = &pose.orientation;
541+
let norm = q.x * q.x + q.y * q.y + q.z * q.z + q.w * q.w;
542+
let sin_yaw = 2.0 * (q.w * q.z + q.x * q.y) / norm;
543+
let cos_yaw = 1.0 - 2.0 * (q.y * q.y + q.z * q.z) / norm;
544+
let yaw = sin_yaw.atan2(cos_yaw);
545+
(yaw.cos(), yaw.sin())
546+
}
547+
442548
fn normalize_grid_data(grid: &mut OccupancyGrid) {
443549
let Some(expected_len) = grid_len(grid) else {
444550
grid.data.clear();
@@ -477,7 +583,7 @@ fn rasterized_indices(grid: &OccupancyGrid, region: &Region) -> Vec<usize> {
477583
return Vec::new();
478584
}
479585

480-
if !is_supported_region_hint(region.hint) {
586+
if !is_rasterizable_region_hint(region.hint) {
481587
return Vec::new();
482588
}
483589

@@ -666,6 +772,10 @@ mod tests {
666772

667773
fn static_grid(width: u32, height: u32, value: i8) -> OccupancyGrid {
668774
OccupancyGrid {
775+
header: Header {
776+
frame_id: "map".to_string(),
777+
..Default::default()
778+
},
669779
info: MapMetaData {
670780
resolution: 1.0,
671781
width,
@@ -705,6 +815,7 @@ mod tests {
705815
..Default::default()
706816
};
707817
source.header.stamp.sec = 1;
818+
source.robot_pose.orientation.w = 1.0;
708819
source
709820
}
710821

@@ -785,6 +896,24 @@ mod tests {
785896
assert_eq!(composed.data[0], 0);
786897
}
787898

899+
#[test]
900+
fn robot_local_regions_are_transformed_into_the_map_frame() {
901+
let mut map = LayeredMap::default();
902+
map.set_static_map(static_grid(5, 5, 0));
903+
904+
let mut update = update(MapRegionPatch::UPDATE_OBSTACLE, vec![point(1.0, 0.0)]);
905+
update.source.robot_pose.position.x = 2.25;
906+
update.source.robot_pose.position.y = 1.25;
907+
update.source.robot_pose.orientation.z = std::f64::consts::FRAC_1_SQRT_2;
908+
update.source.robot_pose.orientation.w = std::f64::consts::FRAC_1_SQRT_2;
909+
910+
assert!(map.ingest_region_update(update, 0));
911+
912+
let composed = map.compose().unwrap();
913+
assert_eq!(composed.data[2 * 5 + 2], 100);
914+
assert_eq!(composed.data[0], 0);
915+
}
916+
788917
#[test]
789918
fn out_of_bounds_regions_do_not_touch_the_grid() {
790919
let mut map = LayeredMap::default();
@@ -852,6 +981,18 @@ mod tests {
852981
assert_eq!(map.dynamic_observation_count(), 0);
853982
}
854983

984+
#[test]
985+
fn updates_in_a_different_global_frame_are_rejected() {
986+
let mut map = LayeredMap::default();
987+
map.set_static_map(static_grid(3, 3, 0));
988+
989+
let mut update = update(MapRegionPatch::UPDATE_OBSTACLE, vec![point(1.0, 1.0)]);
990+
update.source.header.frame_id = "odom".to_string();
991+
992+
assert!(!map.ingest_region_update(update, NANOS_PER_SECOND));
993+
assert_eq!(map.dynamic_observation_count(), 0);
994+
}
995+
855996
#[test]
856997
fn expired_observations_are_removed() {
857998
let mut map = LayeredMap::default();

map_server/rmf_layered_map_server_demo/rmf_layered_map_server_demo/demo_publisher.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ def obstacle_update():
8888
msg = MapRegionUpdate()
8989
msg.source = MapObservationSource()
9090
msg.source.header.frame_id = 'map'
91+
msg.source.robot_pose.orientation.w = 1.0
9192
msg.source.source_id = 'demo/temporary_obstacle'
9293
msg.source.robot_name = 'demo_robot'
9394
msg.source.map_name = 'demo_map'

map_server/rmf_layered_map_server_test/test/test_layered_map_server.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ def static_map():
182182
def map_source():
183183
msg = MapObservationSource()
184184
msg.header.frame_id = 'map'
185+
msg.robot_pose.orientation.w = 1.0
185186
msg.source_id = 'test/obstacle'
186187
msg.robot_name = 'test_robot'
187188
msg.map_name = 'test_map'

0 commit comments

Comments
 (0)