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();
104  node->declare_or_get_parameter("transform_staleness_threshold", 1.0);
105  transform_staleness_threshold_ = desc.transform_staleness_threshold;
106 
107  enabled_ = desc.enabled;
108  visualize_ = desc.visualize;
109  min_height_ = desc.min_height;
110  max_height_ = desc.max_height;
111 
112  frame_id_ = desc.frame_id.empty() ? base_frame_id_ : desc.frame_id;
113 
114  if (desc.type == "circle") {
115  is_circle_ = true;
116  radius_ = desc.radius;
117  if (radius_ <= 0.0) {
118  RCLCPP_ERROR(
119  logger_, "[%s]: circle exclusion zone requires a positive 'radius'",
120  zone_name_.c_str());
121  return false;
122  }
124  } else if (desc.type == "polygon") {
125  is_circle_ = false;
126  if (desc.points.size() < 3) {
127  RCLCPP_ERROR(
128  logger_, "[%s]: polygon exclusion zone requires at least 3 points, got %zu",
129  zone_name_.c_str(), desc.points.size());
130  return false;
131  }
132  poly_.clear();
133  poly_.reserve(desc.points.size());
134  for (const auto & p : desc.points) {
135  Point pt;
136  pt.x = static_cast<double>(p.x);
137  pt.y = static_cast<double>(p.y);
138  poly_.push_back(pt);
139  }
140  } else {
141  RCLCPP_ERROR(
142  logger_, "[%s]: unknown exclusion zone type: %s",
143  zone_name_.c_str(), desc.type.c_str());
144  return false;
145  }
146 
147  if (visualize_) {
148  zone_pub_ = node->create_publisher<geometry_msgs::msg::PolygonStamped>(
149  "~/" + zone_name_);
150  }
151 
152  return true;
153 }
154 
156 {
157  auto node = node_.lock();
158  if (!node) {
159  throw std::runtime_error{"Failed to lock node"};
160  }
161 
162  enabled_ = node->declare_or_get_parameter(zone_name_ + ".enabled", false);
163  visualize_ = node->declare_or_get_parameter(zone_name_ + ".visualize", false);
164  const double default_transform_staleness_threshold =
165  node->declare_or_get_parameter("transform_staleness_threshold", 1.0);
166  base_transform_staleness_threshold_ = default_transform_staleness_threshold;
167  transform_staleness_threshold_ = node->declare_or_get_parameter(
168  zone_name_ + ".transform_staleness_threshold", default_transform_staleness_threshold);
169 
170  // Frame the zone is anchored to. Empty -> static zone in the robot base frame.
171  frame_id_ = node->declare_or_get_parameter(
172  zone_name_ + ".frame_id", base_frame_id_);
173  if (frame_id_.empty()) {
175  }
176 
177  // Optional height band (in base frame). Unbounded by default so 2D sources are covered.
178  min_height_ = node->declare_or_get_parameter(
179  zone_name_ + ".min_height", -std::numeric_limits<double>::max());
180  max_height_ = node->declare_or_get_parameter(
181  zone_name_ + ".max_height", std::numeric_limits<double>::max());
182 
183  const std::string type = node->declare_or_get_parameter(
184  zone_name_ + ".type", std::string("polygon"));
185 
186  if (type == "circle") {
187  is_circle_ = true;
188  radius_ = node->declare_or_get_parameter(zone_name_ + ".radius", -1.0);
189  if (radius_ <= 0.0) {
190  RCLCPP_ERROR(
191  logger_, "[%s]: circle exclusion zone requires a positive 'radius'",
192  zone_name_.c_str());
193  return false;
194  }
196  } else if (type == "polygon") {
197  is_circle_ = false;
198  // Polygon vertices as a VVF string "[[x1, y1], [x2, y2], ...]" expressed in
199  // frame_id_, matching the format used by the collision-monitor action polygons.
200  const std::string points_str = node->declare_or_get_parameter(
201  zone_name_ + ".points", std::string());
202  std::string error;
203  if (!parsePolygonPoints(points_str, 3, poly_, error)) {
204  RCLCPP_ERROR(
205  logger_, "[%s]: %s", zone_name_.c_str(), error.c_str());
206  return false;
207  }
208  } else {
209  RCLCPP_ERROR(
210  logger_, "[%s]: unknown exclusion zone type: %s",
211  zone_name_.c_str(), type.c_str());
212  return false;
213  }
214 
215  return true;
216 }
217 
218 void ExclusionZone::apply(const rclcpp::Time & curr_time, std::vector<Point> & data) const
219 {
220  std::lock_guard<std::mutex> lock(mutex_);
221 
222  if (!enabled_ || data.empty()) {
223  return;
224  }
225 
226  // Resolve the zone-frame -> base-frame transform for this cycle. A flaky zone
227  // frame is held at its last known world pose until the hold timeout expires,
228  // after which we fail safe and keep all points.
229  tf2::Transform tf_zone_to_base;
230  if (!getZoneToBaseTransform(curr_time, tf_zone_to_base)) {
231  return;
232  }
233 
234  if (is_circle_) {
235  // Circle center is the origin of the zone frame, expressed in base frame.
236  const tf2::Vector3 center = tf_zone_to_base.getOrigin();
237  const double cx = center.x();
238  const double cy = center.y();
239  data.erase(
240  std::remove_if(
241  data.begin(), data.end(),
242  [&](const Point & p) {
243  if (p.z < min_height_ || p.z > max_height_) {
244  return false;
245  }
246  const double dx = p.x - cx;
247  const double dy = p.y - cy;
248  return (dx * dx + dy * dy) <= radius_squared_;
249  }),
250  data.end());
251  } else {
252  // Transform the polygon vertices into the base frame once.
253  std::vector<Point> poly_base;
254  transformPolygonPoints(tf_zone_to_base, poly_, poly_base);
255  data.erase(
256  std::remove_if(
257  data.begin(), data.end(),
258  [&](const Point & p) {
259  if (p.z < min_height_ || p.z > max_height_) {
260  return false;
261  }
262  return nav2_util::geometry_utils::isPointInsidePolygon(p.x, p.y, poly_base);
263  }),
264  data.end());
265  }
266 }
267 
268 bool ExclusionZone::getZoneToBaseTransform(
269  const rclcpp::Time & curr_time, tf2::Transform & tf_zone_to_base) const
270 {
271  // Zone anchored to the robot base: it rides with the robot, nothing to bridge.
272  if (frame_id_ == base_frame_id_) {
273  tf_zone_to_base.setIdentity();
274  return true;
275  }
276 
277  // Runs on the collision monitor thread, so all lookups are NON-BLOCKING (zero
278  // timeout): they read the cached TF buffer and fail immediately. A blocking
279  // lookup on a flaky zone frame would stall the monitor loop, make healthy sources look
280  // stale and trip the source_timeout watchdog.
281  const tf2::Duration non_blocking = tf2::Duration::zero();
282 
283  // Look up the zone frame at the latest available time using this zone's
284  // transform age limit.
285  tf2::Transform tf_zone_to_global;
286  tf_zone_to_global.setIdentity();
287  if (frame_id_ != global_frame_id_) {
288  geometry_msgs::msg::TransformStamped zone_to_global_msg;
289  if (!nav2_util::lookupTransformWithStalenessCheck(
290  *tf_buffer_, global_frame_id_, frame_id_, curr_time,
291  transform_staleness_threshold_, zone_to_global_msg))
292  {
293  return false;
294  }
295  // Freeze the pose in the smooth global frame so a held zone remains fixed
296  // in the world while the robot continues moving.
297  tf2::fromMsg(zone_to_global_msg.transform, tf_zone_to_global);
298  }
299 
300  // Re-project the world pose into the current base frame.
301  tf2::Transform tf_global_to_base;
302  bool got_base;
303  if (base_shift_correction_) {
304  got_base = nav2_util::getTransform(
305  global_frame_id_, curr_time, base_frame_id_, curr_time, global_frame_id_,
306  non_blocking, tf_buffer_, tf_global_to_base);
307  } else {
308  geometry_msgs::msg::TransformStamped global_to_base_msg;
309  got_base = nav2_util::lookupTransformWithStalenessCheck(
310  *tf_buffer_, base_frame_id_, global_frame_id_, curr_time,
311  base_transform_staleness_threshold_, global_to_base_msg);
312  if (got_base) {
313  tf2::fromMsg(global_to_base_msg.transform, tf_global_to_base);
314  }
315  }
316  if (!got_base) {
317  RCLCPP_WARN_THROTTLE(
318  logger_, *node_clock_, 2000,
319  "[%s]: cannot transform '%s' -> '%s'; not excluding any points",
320  zone_name_.c_str(), global_frame_id_.c_str(), base_frame_id_.c_str());
321  return false;
322  }
323 
324  tf_zone_to_base = tf_global_to_base * tf_zone_to_global;
325  return true;
326 }
327 
328 std::string ExclusionZone::getName() const
329 {
330  return zone_name_;
331 }
332 
333 bool ExclusionZone::getEnabled() const
334 {
335  std::lock_guard<std::mutex> lock(mutex_);
336  return enabled_;
337 }
338 
339 void ExclusionZone::activate()
340 {
341  if (zone_pub_) {
342  zone_pub_->on_activate();
343  }
344 }
345 
346 void ExclusionZone::deactivate()
347 {
348  if (zone_pub_) {
349  zone_pub_->on_deactivate();
350  }
351 }
352 
353 void ExclusionZone::publish() const
354 {
355  std::lock_guard<std::mutex> lock(mutex_);
356  if (!zone_pub_ || !enabled_) {
357  return;
358  }
359 
360  // Resolve the same (possibly held) zone -> base transform the mask uses and
361  // publish the polygon in the smooth base frame. Publishing in the raw zone
362  // frame_id_ is unreliable during a dropout (it rides with the robot while
363  // frozen); failing here also mirrors the mask going inactive.
364  tf2::Transform tf_zone_to_base;
365  if (!getZoneToBaseTransform(node_clock_->now(), tf_zone_to_base)) {
366  return;
367  }
368 
369  const std::vector<Point> & vertices = is_circle_ ? circleToPolygon(radius_) : poly_;
370  std::vector<Point> vertices_base;
371  transformPolygonPoints(tf_zone_to_base, vertices, vertices_base);
372 
373  auto msg = std::make_unique<geometry_msgs::msg::PolygonStamped>();
374  msg->header.frame_id = base_frame_id_;
375  msg->header.stamp = node_clock_->now();
376  for (const Point & v : vertices_base) {
377  geometry_msgs::msg::Point32 p;
378  p.x = static_cast<float>(v.x);
379  p.y = static_cast<float>(v.y);
380  msg->polygon.points.push_back(p);
381  }
382 
383  zone_pub_->publish(std::move(msg));
384 }
385 
386 rcl_interfaces::msg::SetParametersResult ExclusionZone::validateParameterUpdatesCallback(
387  const std::vector<rclcpp::Parameter> & parameters)
388 {
389  rcl_interfaces::msg::SetParametersResult result;
390  result.successful = true;
391  for (const auto & parameter : parameters) {
392  const auto & param_name = parameter.get_name();
393  if (param_name.find(zone_name_ + ".") != 0) {
394  continue;
395  }
396  if (param_name == zone_name_ + ".radius" && is_circle_ &&
397  parameter.get_type() == rclcpp::ParameterType::PARAMETER_DOUBLE &&
398  parameter.as_double() <= 0.0)
399  {
400  result.successful = false;
401  result.reason = "radius must be > 0";
402  } else if (param_name == zone_name_ + ".transform_staleness_threshold" && // NOLINT
403  parameter.get_type() == rclcpp::ParameterType::PARAMETER_DOUBLE &&
404  parameter.as_double() < 0.0)
405  {
406  result.successful = false;
407  result.reason = "transform_staleness_threshold must be >= 0";
408  } else if (param_name == zone_name_ + ".points" && !is_circle_ && // NOLINT
409  parameter.get_type() == rclcpp::ParameterType::PARAMETER_STRING)
410  {
411  // Reject a live polygon update that cannot be parsed into a valid polygon
412  // so the running zone is never left with a malformed shape.
413  std::vector<Point> parsed;
414  std::string error;
415  if (!parsePolygonPoints(parameter.as_string(), 3, parsed, error)) {
416  result.successful = false;
417  result.reason = error;
418  }
419  }
420  }
421  return result;
422 }
423 
424 void ExclusionZone::updateParametersCallback(
425  const std::vector<rclcpp::Parameter> & parameters)
426 {
427  std::lock_guard<std::mutex> lock(mutex_);
428  for (const auto & parameter : parameters) {
429  const auto & param_name = parameter.get_name();
430  if (param_name.find(zone_name_ + ".") != 0) {
431  continue;
432  }
433  if (param_name == zone_name_ + ".enabled" &&
434  parameter.get_type() == rclcpp::ParameterType::PARAMETER_BOOL)
435  {
436  enabled_ = parameter.as_bool();
437  } else if (param_name == zone_name_ + ".radius" && is_circle_ && // NOLINT
438  parameter.get_type() == rclcpp::ParameterType::PARAMETER_DOUBLE)
439  {
440  radius_ = parameter.as_double();
441  radius_squared_ = radius_ * radius_;
442  } else if (param_name == zone_name_ + ".min_height" && // NOLINT
443  parameter.get_type() == rclcpp::ParameterType::PARAMETER_DOUBLE)
444  {
445  min_height_ = parameter.as_double();
446  } else if (param_name == zone_name_ + ".max_height" && // NOLINT
447  parameter.get_type() == rclcpp::ParameterType::PARAMETER_DOUBLE)
448  {
449  max_height_ = parameter.as_double();
450  } else if (param_name == zone_name_ + ".transform_staleness_threshold" && // NOLINT
451  parameter.get_type() == rclcpp::ParameterType::PARAMETER_DOUBLE)
452  {
453  transform_staleness_threshold_ = parameter.as_double();
454  } else if (param_name == zone_name_ + ".points" && !is_circle_ && // NOLINT
455  parameter.get_type() == rclcpp::ParameterType::PARAMETER_STRING)
456  {
457  // The update callback runs only after validation succeeded, so the string
458  // is guaranteed to parse into a valid polygon here.
459  std::vector<Point> parsed;
460  std::string error;
461  if (parsePolygonPoints(parameter.as_string(), 3, parsed, error)) {
462  poly_ = parsed;
463  }
464  }
465  }
466 }
467 
468 } // 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.
double base_transform_staleness_threshold_
Maximum age in seconds for the robot's global-frame transform.
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)
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.
double transform_staleness_threshold_
Maximum age in seconds for the zone-frame transform.
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