Nav2 Navigation Stack - rolling  main
ROS 2 Navigation Stack
exclusion_zone.cpp
1 // Copyright (c) 2026 Dexory
2 //
3 // Licensed under the Apache License, Version 2.0 (the "License");
4 // you may not use this file except in compliance with the License.
5 // You may obtain a copy of the License at
6 //
7 // http://www.apache.org/licenses/LICENSE-2.0
8 //
9 // Unless required by applicable law or agreed to in writing, software
10 // distributed under the License is distributed on an "AS IS" BASIS,
11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 // See the License for the specific language governing permissions and
13 // limitations under the License.
14 
15 #include "nav2_collision_monitor/exclusion_zone.hpp"
16 
17 #include <algorithm>
18 #include <cmath>
19 #include <limits>
20 
21 #include "geometry_msgs/msg/point32.hpp"
22 #include "geometry_msgs/msg/transform_stamped.hpp"
23 #include "tf2/transform_datatypes.hpp"
24 #include "tf2_geometry_msgs/tf2_geometry_msgs.hpp"
25 
26 #include "nav2_ros_common/node_utils.hpp"
27 #include "nav2_util/geometry_utils.hpp"
28 #include "nav2_util/robot_utils.hpp"
29 
30 #include "nav2_collision_monitor/polygon_utils.hpp"
31 
32 namespace nav2_collision_monitor
33 {
34 
36  const nav2::LifecycleNode::WeakPtr & node,
37  const std::string & zone_name,
38  const nav2::TransformBuffer::SharedPtr tf_buffer,
39  const std::string & base_frame_id,
40  const std::string & global_frame_id,
41  const tf2::Duration & transform_tolerance,
42  const bool base_shift_correction)
43 : node_(node), zone_name_(zone_name), tf_buffer_(tf_buffer),
44  base_frame_id_(base_frame_id), global_frame_id_(global_frame_id),
45  transform_tolerance_(transform_tolerance), base_shift_correction_(base_shift_correction),
46  min_height_(-std::numeric_limits<double>::max()),
47  max_height_(std::numeric_limits<double>::max())
48 {
49  RCLCPP_INFO(logger_, "[%s]: Creating ExclusionZone", zone_name_.c_str());
50 }
51 
53 {
54  RCLCPP_INFO(logger_, "[%s]: Destroying ExclusionZone", zone_name_.c_str());
55  zone_pub_.reset();
56  poly_.clear();
57  node_clock_.reset();
58  auto node = node_.lock();
59  if (post_set_params_handler_ && node) {
60  node->remove_post_set_parameters_callback(post_set_params_handler_.get());
61  }
62  post_set_params_handler_.reset();
63  if (on_set_params_handler_ && node) {
64  node->remove_on_set_parameters_callback(on_set_params_handler_.get());
65  }
66  on_set_params_handler_.reset();
67 }
68 
70 {
71  auto node = node_.lock();
72  if (!node) {
73  throw std::runtime_error{"Failed to lock node"};
74  }
75 
76  node_clock_ = node->get_clock();
77 
78  if (!getParameters()) {
79  return false;
80  }
81 
82  if (visualize_) {
83  zone_pub_ = node->create_publisher<geometry_msgs::msg::PolygonStamped>(
84  "~/" + zone_name_);
85  }
86 
87  post_set_params_handler_ = node->add_post_set_parameters_callback(
88  std::bind(&ExclusionZone::updateParametersCallback, this, std::placeholders::_1));
89  on_set_params_handler_ = node->add_on_set_parameters_callback(
90  std::bind(&ExclusionZone::validateParameterUpdatesCallback, this, std::placeholders::_1));
91 
92  return true;
93 }
94 
95 bool ExclusionZone::configure(const nav2_msgs::msg::ExclusionZoneDescription & desc)
96 {
97  auto node = node_.lock();
98  if (!node) {
99  throw std::runtime_error{"Failed to lock node"};
100  }
101 
102  node_clock_ = node->get_clock();
103 
104  enabled_ = desc.enabled;
105  visualize_ = desc.visualize;
106  frame_hold_timeout_ = desc.frame_hold_timeout;
107  min_height_ = desc.min_height;
108  max_height_ = desc.max_height;
109 
110  frame_id_ = desc.frame_id.empty() ? base_frame_id_ : desc.frame_id;
111 
112  if (desc.type == "circle") {
113  is_circle_ = true;
114  radius_ = desc.radius;
115  if (radius_ <= 0.0) {
116  RCLCPP_ERROR(
117  logger_, "[%s]: circle exclusion zone requires a positive 'radius'",
118  zone_name_.c_str());
119  return false;
120  }
122  } else if (desc.type == "polygon") {
123  is_circle_ = false;
124  if (desc.points.size() < 3) {
125  RCLCPP_ERROR(
126  logger_, "[%s]: polygon exclusion zone requires at least 3 points, got %zu",
127  zone_name_.c_str(), desc.points.size());
128  return false;
129  }
130  poly_.clear();
131  poly_.reserve(desc.points.size());
132  for (const auto & p : desc.points) {
133  Point pt;
134  pt.x = static_cast<double>(p.x);
135  pt.y = static_cast<double>(p.y);
136  poly_.push_back(pt);
137  }
138  } else {
139  RCLCPP_ERROR(
140  logger_, "[%s]: unknown exclusion zone type: %s",
141  zone_name_.c_str(), desc.type.c_str());
142  return false;
143  }
144 
145  if (visualize_) {
146  zone_pub_ = node->create_publisher<geometry_msgs::msg::PolygonStamped>(
147  "~/" + zone_name_);
148  }
149 
150  return true;
151 }
152 
154 {
155  auto node = node_.lock();
156  if (!node) {
157  throw std::runtime_error{"Failed to lock node"};
158  }
159 
160  enabled_ = node->declare_or_get_parameter(zone_name_ + ".enabled", false);
161  visualize_ = node->declare_or_get_parameter(zone_name_ + ".visualize", false);
162 
163  // Frame the zone is anchored to. Empty -> static zone in the robot base frame.
164  frame_id_ = node->declare_or_get_parameter(
165  zone_name_ + ".frame_id", base_frame_id_);
166  if (frame_id_.empty()) {
168  }
169 
170  frame_hold_timeout_ = node->declare_or_get_parameter(
171  zone_name_ + ".frame_hold_timeout", 0.0);
172  // Optional height band (in base frame). Unbounded by default so 2D sources are covered.
173  min_height_ = node->declare_or_get_parameter(
174  zone_name_ + ".min_height", -std::numeric_limits<double>::max());
175  max_height_ = node->declare_or_get_parameter(
176  zone_name_ + ".max_height", std::numeric_limits<double>::max());
177 
178  const std::string type = node->declare_or_get_parameter(
179  zone_name_ + ".type", std::string("polygon"));
180 
181  if (type == "circle") {
182  is_circle_ = true;
183  radius_ = node->declare_or_get_parameter(zone_name_ + ".radius", -1.0);
184  if (radius_ <= 0.0) {
185  RCLCPP_ERROR(
186  logger_, "[%s]: circle exclusion zone requires a positive 'radius'",
187  zone_name_.c_str());
188  return false;
189  }
191  } else if (type == "polygon") {
192  is_circle_ = false;
193  // Polygon vertices as a VVF string "[[x1, y1], [x2, y2], ...]" expressed in
194  // frame_id_, matching the format used by the collision-monitor action polygons.
195  const std::string points_str = node->declare_or_get_parameter(
196  zone_name_ + ".points", std::string());
197  std::string error;
198  if (!parsePolygonPoints(points_str, 3, poly_, error)) {
199  RCLCPP_ERROR(
200  logger_, "[%s]: %s", zone_name_.c_str(), error.c_str());
201  return false;
202  }
203  } else {
204  RCLCPP_ERROR(
205  logger_, "[%s]: unknown exclusion zone type: %s",
206  zone_name_.c_str(), type.c_str());
207  return false;
208  }
209 
210  return true;
211 }
212 
213 void ExclusionZone::apply(const rclcpp::Time & curr_time, std::vector<Point> & data) const
214 {
215  std::lock_guard<std::mutex> lock(mutex_);
216 
217  if (!enabled_ || data.empty()) {
218  return;
219  }
220 
221  // Resolve the zone-frame -> base-frame transform for this cycle. A flaky zone
222  // frame is held at its last known world pose until the hold timeout expires,
223  // after which we fail safe and keep all points.
224  tf2::Transform tf_zone_to_base;
225  if (!getZoneToBaseTransform(curr_time, tf_zone_to_base)) {
226  return;
227  }
228 
229  if (is_circle_) {
230  // Circle center is the origin of the zone frame, expressed in base frame.
231  const tf2::Vector3 center = tf_zone_to_base.getOrigin();
232  const double cx = center.x();
233  const double cy = center.y();
234  data.erase(
235  std::remove_if(
236  data.begin(), data.end(),
237  [&](const Point & p) {
238  if (p.z < min_height_ || p.z > max_height_) {
239  return false;
240  }
241  const double dx = p.x - cx;
242  const double dy = p.y - cy;
243  return (dx * dx + dy * dy) <= radius_squared_;
244  }),
245  data.end());
246  } else {
247  // Transform the polygon vertices into the base frame once.
248  std::vector<Point> poly_base;
249  transformPolygonPoints(tf_zone_to_base, poly_, poly_base);
250  data.erase(
251  std::remove_if(
252  data.begin(), data.end(),
253  [&](const Point & p) {
254  if (p.z < min_height_ || p.z > max_height_) {
255  return false;
256  }
257  return nav2_util::geometry_utils::isPointInsidePolygon(p.x, p.y, poly_base);
258  }),
259  data.end());
260  }
261 }
262 
263 bool ExclusionZone::getZoneToBaseTransform(
264  const rclcpp::Time & curr_time, tf2::Transform & tf_zone_to_base) const
265 {
266  // Zone anchored to the robot base: it rides with the robot, nothing to bridge.
267  if (frame_id_ == base_frame_id_) {
268  tf_zone_to_base.setIdentity();
269  return true;
270  }
271 
272  // Runs on the collision monitor thread, so all lookups are NON-BLOCKING (zero
273  // timeout): they read the cached TF buffer and fail immediately. A blocking
274  // lookup on a flaky zone frame would stall the monitor loop, make healthy sources look
275  // stale and trip the source_timeout watchdog. transform_tolerance_ is used only
276  // as the staleness allowance below, never as a wait.
277  const tf2::Duration non_blocking = tf2::Duration::zero();
278 
279  // Look up the zone frame at the *latest* available time (never curr_time) so a
280  // slowly published frame is not extrapolated; its stamp tells us how stale it is.
281  rclcpp::Time zone_stamp = curr_time;
282  tf2::Transform tf_zone_to_global;
283  tf_zone_to_global.setIdentity();
284  if (frame_id_ != global_frame_id_) {
285  geometry_msgs::msg::TransformStamped zone_to_global_msg;
286  if (!nav2_util::getTransform(
287  frame_id_, global_frame_id_, non_blocking, tf_buffer_, zone_to_global_msg))
288  {
289  RCLCPP_WARN_THROTTLE(
290  logger_, *node_clock_, 2000,
291  "[%s]: no transform available for exclusion zone frame '%s'; not excluding any points",
292  zone_name_.c_str(), frame_id_.c_str());
293  return false;
294  }
295  zone_stamp = rclcpp::Time(zone_to_global_msg.header.stamp, curr_time.get_clock_type());
296 
297  // Accept the pose only within the hold window: at least the transform tolerance
298  // (so a healthy frame always passes), extended by frame_hold_timeout_ to ride
299  // out brief dropouts. Anything older is dropped so we are never blinded by a
300  // frame that may have moved.
301  const double age = (curr_time - zone_stamp).seconds();
302  const double max_age =
303  std::max(frame_hold_timeout_, tf2::durationToSec(transform_tolerance_));
304  if (age > max_age) {
305  RCLCPP_WARN_THROTTLE(
306  logger_, *node_clock_, 2000,
307  "[%s]: exclusion zone frame '%s' stale for %.2fs (> %.2fs hold window); "
308  "not excluding any points",
309  zone_name_.c_str(), frame_id_.c_str(), age, max_age);
310  return false;
311  }
312 
313  // Freeze the pose in the smooth global (odom) frame -- where the last valid
314  // detection placed the zone in the WORLD. The charger frame is a child of a
315  // robot lidar frame, so a held zone-to-base transform would otherwise ride
316  // with the robot and sweep the mask off the world-fixed charger.
317  tf2::fromMsg(zone_to_global_msg.transform, tf_zone_to_global);
318  }
319 
320  // Re-project the (possibly held) world pose into the current base frame by
321  // advancing only the global -> base leg. base_shift_correction samples the base
322  // at curr_time; otherwise the latest base pose is used. Both stay non-blocking.
323  tf2::Transform tf_global_to_base;
324  bool got_base;
325  if (base_shift_correction_) {
326  got_base = nav2_util::getTransform(
327  global_frame_id_, curr_time, base_frame_id_, curr_time, global_frame_id_,
328  non_blocking, tf_buffer_, tf_global_to_base);
329  } else {
330  got_base = nav2_util::getTransform(
331  global_frame_id_, base_frame_id_, non_blocking, tf_buffer_, tf_global_to_base);
332  }
333  if (!got_base) {
334  RCLCPP_WARN_THROTTLE(
335  logger_, *node_clock_, 2000,
336  "[%s]: cannot transform '%s' -> '%s'; not excluding any points",
337  zone_name_.c_str(), global_frame_id_.c_str(), base_frame_id_.c_str());
338  return false;
339  }
340 
341  tf_zone_to_base = tf_global_to_base * tf_zone_to_global;
342  return true;
343 }
344 
345 std::string ExclusionZone::getName() const
346 {
347  return zone_name_;
348 }
349 
350 bool ExclusionZone::getEnabled() const
351 {
352  std::lock_guard<std::mutex> lock(mutex_);
353  return enabled_;
354 }
355 
356 void ExclusionZone::activate()
357 {
358  if (zone_pub_) {
359  zone_pub_->on_activate();
360  }
361 }
362 
363 void ExclusionZone::deactivate()
364 {
365  if (zone_pub_) {
366  zone_pub_->on_deactivate();
367  }
368 }
369 
370 void ExclusionZone::publish() const
371 {
372  std::lock_guard<std::mutex> lock(mutex_);
373  if (!zone_pub_ || !enabled_) {
374  return;
375  }
376 
377  // Resolve the same (possibly held) zone -> base transform the mask uses and
378  // publish the polygon in the smooth base frame. Publishing in the raw zone
379  // frame_id_ is unreliable during a dropout (it rides with the robot while
380  // frozen); failing here also mirrors the mask going inactive.
381  tf2::Transform tf_zone_to_base;
382  if (!getZoneToBaseTransform(node_clock_->now(), tf_zone_to_base)) {
383  return;
384  }
385 
386  const std::vector<Point> & vertices = is_circle_ ? circleToPolygon(radius_) : poly_;
387  std::vector<Point> vertices_base;
388  transformPolygonPoints(tf_zone_to_base, vertices, vertices_base);
389 
390  auto msg = std::make_unique<geometry_msgs::msg::PolygonStamped>();
391  msg->header.frame_id = base_frame_id_;
392  msg->header.stamp = node_clock_->now();
393  for (const Point & v : vertices_base) {
394  geometry_msgs::msg::Point32 p;
395  p.x = static_cast<float>(v.x);
396  p.y = static_cast<float>(v.y);
397  msg->polygon.points.push_back(p);
398  }
399 
400  zone_pub_->publish(std::move(msg));
401 }
402 
403 rcl_interfaces::msg::SetParametersResult ExclusionZone::validateParameterUpdatesCallback(
404  const std::vector<rclcpp::Parameter> & parameters)
405 {
406  rcl_interfaces::msg::SetParametersResult result;
407  result.successful = true;
408  for (const auto & parameter : parameters) {
409  const auto & param_name = parameter.get_name();
410  if (param_name.find(zone_name_ + ".") != 0) {
411  continue;
412  }
413  if (param_name == zone_name_ + ".radius" && is_circle_ &&
414  parameter.get_type() == rclcpp::ParameterType::PARAMETER_DOUBLE &&
415  parameter.as_double() <= 0.0)
416  {
417  result.successful = false;
418  result.reason = "radius must be > 0";
419  } else if (param_name == zone_name_ + ".frame_hold_timeout" && // NOLINT
420  parameter.get_type() == rclcpp::ParameterType::PARAMETER_DOUBLE &&
421  parameter.as_double() < 0.0)
422  {
423  result.successful = false;
424  result.reason = "frame_hold_timeout must be >= 0";
425  } else if (param_name == zone_name_ + ".points" && !is_circle_ && // NOLINT
426  parameter.get_type() == rclcpp::ParameterType::PARAMETER_STRING)
427  {
428  // Reject a live polygon update that cannot be parsed into a valid polygon
429  // so the running zone is never left with a malformed shape.
430  std::vector<Point> parsed;
431  std::string error;
432  if (!parsePolygonPoints(parameter.as_string(), 3, parsed, error)) {
433  result.successful = false;
434  result.reason = error;
435  }
436  }
437  }
438  return result;
439 }
440 
441 void ExclusionZone::updateParametersCallback(
442  const std::vector<rclcpp::Parameter> & parameters)
443 {
444  std::lock_guard<std::mutex> lock(mutex_);
445  for (const auto & parameter : parameters) {
446  const auto & param_name = parameter.get_name();
447  if (param_name.find(zone_name_ + ".") != 0) {
448  continue;
449  }
450  if (param_name == zone_name_ + ".enabled" &&
451  parameter.get_type() == rclcpp::ParameterType::PARAMETER_BOOL)
452  {
453  enabled_ = parameter.as_bool();
454  } else if (param_name == zone_name_ + ".radius" && is_circle_ && // NOLINT
455  parameter.get_type() == rclcpp::ParameterType::PARAMETER_DOUBLE)
456  {
457  radius_ = parameter.as_double();
458  radius_squared_ = radius_ * radius_;
459  } else if (param_name == zone_name_ + ".min_height" && // NOLINT
460  parameter.get_type() == rclcpp::ParameterType::PARAMETER_DOUBLE)
461  {
462  min_height_ = parameter.as_double();
463  } else if (param_name == zone_name_ + ".max_height" && // NOLINT
464  parameter.get_type() == rclcpp::ParameterType::PARAMETER_DOUBLE)
465  {
466  max_height_ = parameter.as_double();
467  } else if (param_name == zone_name_ + ".frame_hold_timeout" && // NOLINT
468  parameter.get_type() == rclcpp::ParameterType::PARAMETER_DOUBLE)
469  {
470  frame_hold_timeout_ = parameter.as_double();
471  } else if (param_name == zone_name_ + ".points" && !is_circle_ && // NOLINT
472  parameter.get_type() == rclcpp::ParameterType::PARAMETER_STRING)
473  {
474  // The update callback runs only after validation succeeded, so the string
475  // is guaranteed to parse into a valid polygon here.
476  std::vector<Point> parsed;
477  std::string error;
478  if (parsePolygonPoints(parameter.as_string(), 3, parsed, error)) {
479  poly_ = parsed;
480  }
481  }
482  }
483 }
484 
485 } // namespace nav2_collision_monitor
double min_height_
Lower bound of the height band (base-frame z) a point must be within to be excluded.
void apply(const rclcpp::Time &curr_time, std::vector< Point > &data) const
Removes from data all points that fall inside the (enabled) zone. No-op when the zone is disabled....
rclcpp::Logger logger_
Collision monitor node logger.
bool enabled_
Whether the zone is currently active.
double radius_
Circle radius (for circle type)
double radius_squared_
radius squared, cached
~ExclusionZone()
ExclusionZone destructor.
bool getParameters()
Reads ROS parameters for the zone.
ExclusionZone(const nav2::LifecycleNode::WeakPtr &node, const std::string &zone_name, const nav2::TransformBuffer::SharedPtr tf_buffer, const std::string &base_frame_id, const std::string &global_frame_id, const tf2::Duration &transform_tolerance, const bool base_shift_correction)
ExclusionZone constructor.
bool is_circle_
Whether the zone shape is a circle (otherwise polygon)
double frame_hold_timeout_
Extra time (s) beyond the transform tolerance that a stale zone-frame pose may keep being used before...
bool getZoneToBaseTransform(const rclcpp::Time &curr_time, tf2::Transform &tf_zone_to_base) const
Resolve the zone-frame -> base-frame transform for this cycle.
std::vector< Point > poly_
Zone polygon vertices, expressed in frame_id_ (for polygon type)
bool visualize_
Whether to publish the zone footprint for visualization.
std::string zone_name_
Name of the zone.
bool configure()
Reads ROS parameters and configures the zone.
std::string frame_id_
Frame the zone shape is anchored to (tracked via TF). Defaults to base_frame_id_.
std::string base_frame_id_
Robot base frame ID.
nav2::LifecycleNode::WeakPtr node_
Collision Monitor node.
rclcpp::Clock::SharedPtr node_clock_
Node clock (for throttled logging and message stamps)
void updateParametersCallback(const std::vector< rclcpp::Parameter > &parameters)
Apply parameter updates after validation (dynamic reconfigure)
std::mutex mutex_
Dynamic parameters handlers.
double max_height_
Upper bound of the height band (base-frame z) a point must be within to be excluded.
rcl_interfaces::msg::SetParametersResult validateParameterUpdatesCallback(const std::vector< rclcpp::Parameter > &parameters)
Validate incoming parameter updates before applying them.
nav2::Publisher< geometry_msgs::msg::PolygonStamped >::SharedPtr zone_pub_
Zone footprint publisher.
Point with 2D collision-check coordinates and optional z from the source.
Definition: types.hpp:52