point_cloud_fusion v1.4.0
Loading...
Searching...
No Matches
point_cloud_fusion.cpp
Go to the documentation of this file.
1// Copyright Institute for Automotive Engineering (ika), RWTH Aachen University
2// SPDX-License-Identifier: Apache-2.0
3
4#include <algorithm>
5#include <cctype>
6#include <chrono>
7#include <cmath>
8#include <cstdlib>
9#include <cstring>
10#include <functional>
11#include <iterator>
12#include <limits>
13#include <numeric>
14#include <sstream>
15#include <tuple>
16#include <type_traits>
17#include <utility>
18
20
21#include <tf2/LinearMath/Transform.h>
22#include <tf2/time.h>
23#include <tracetools/tracetools.h>
24#include <tf2_geometry_msgs/tf2_geometry_msgs.hpp>
25#include <tf2_sensor_msgs/tf2_sensor_msgs.hpp>
26
27#include <rmw/qos_profiles.h>
28#include <rclcpp_components/register_node_macro.hpp>
29RCLCPP_COMPONENTS_REGISTER_NODE(point_cloud_fusion::PointCloudFusion)
30
31namespace {
32
33inline std::size_t pointFieldDatatypeSize(uint8_t datatype) {
34 using sensor_msgs::msg::PointField;
35 switch (datatype) {
36 case PointField::INT8:
37 case PointField::UINT8:
38 return 1;
39 case PointField::INT16:
40 case PointField::UINT16:
41 return 2;
42 case PointField::INT32:
43 case PointField::UINT32:
44 case PointField::FLOAT32:
45 return 4;
46 case PointField::FLOAT64:
47 return 8;
48 default:
49 return 0;
50 }
51}
52
67inline bool pointWithinRange(
68 float x, float y, float z, float x_min, float x_max, float y_min, float y_max, float z_min, float z_max) {
69 return x >= x_min && x <= x_max && y >= y_min && y <= y_max && z >= z_min && z <= z_max;
70}
71
80template <typename Byte>
81inline Byte* byteOffset(Byte* data, std::size_t offset) {
82 return std::next(data, static_cast<std::ptrdiff_t>(offset));
83}
84
92inline float loadFloat(const uint8_t* data, std::size_t offset) {
93 float value = 0.0F;
94 std::memcpy(&value, byteOffset(data, offset), sizeof(value));
95 return value;
96}
97
105inline void storeFloat(uint8_t* data, std::size_t offset, float value) {
106 std::memcpy(byteOffset(data, offset), &value, sizeof(value));
107}
108
109} // namespace
110
111namespace point_cloud_fusion {
112
113// clang-format off
114PointCloudFusion::PointCloudFusion(const rclcpp::NodeOptions& options) : Node("point_cloud_fusion", options) {
115 this->declareAndLoadParameter("target_frame", target_frame_, // name
116 "Frame into which all input point clouds are transformed before fusion", // description
117 false, // add_to_auto_reconfigurable_params
118 true, // is_required
119 true, // read_only
120 std::nullopt, std::nullopt, std::nullopt, // from_value, to_value, step_value
121 "Must be set."); // additional_constraints
122 this->declareAndLoadParameter("input_topics", input_topics_, // name
123 "Point-cloud topics to fuse", // description
124 false, // add_to_auto_reconfigurable_params
125 true, // is_required
126 true, // read_only
127 std::nullopt, std::nullopt, std::nullopt, // from_value, to_value, step_value
128 "Must configure between 1 and " + std::to_string(kMaxInputTopics) + " topics");
130 this->declareAndLoadParameter("input_transport_hints", input_transport_hints_, // name
131 "Transport hint for each input topic; unspecified entries use raw", // description
132 false, // add_to_auto_reconfigurable_params
133 false, // is_required
134 true, // read_only
135 std::nullopt, std::nullopt, std::nullopt, // from_value, to_value, step_value
136 "Length must be zero or match input_topics; unspecified entries default to '" +
137 std::string(kDefaultTransportHint) + "'."); // additional_constraints
138 this->declareAndLoadParameter("sync_queue_size", sync_queue_size_, // name
139 "Queue depth for approximate-time synchronization", // description
140 false, // add_to_auto_reconfigurable_params
141 false, // is_required
142 true, // read_only
143 kMinSyncQueueSize, // from_value
144 kMaxSyncQueueSize, // to_value
145 kStepSizeSyncQueueSize, // step_value
146 std::string("Must be >= ") + std::to_string(kMinSyncQueueSize)); // additional_constraints
147 this->declareAndLoadParameter("output_queue_size", output_queue_size_, // name
148 "Queue depth for the fused output publisher", // description
149 false, // add_to_auto_reconfigurable_params
150 false, // is_required
151 true, // read_only
152 kMinOutputQueueSize, // from_value
153 kMaxOutputQueueSize, // to_value
154 kStepSizeOutputQueueSize, // step_value
155 std::string("Must be >= ") + std::to_string(kMinOutputQueueSize)); // additional_constraints
157 "output_fields", output_fields_, // name
158 "Fields retained in the fused output; an empty list retains all input fields", // description
159 true, // add_to_auto_reconfigurable_params
160 false, // is_required
161 false, // read_only
162 std::nullopt, std::nullopt, std::nullopt, // from_value, to_value, step_value
163 "Typical fields include: x, y, z, intensity, t, reflectivity, ring, ambient, range."); // additional_constraints
164 this->declareAndLoadParameter("output_stamp_mode", output_stamp_mode_param_, // name
165 "Fused timestamp selection: earliest, latest, mean, or input0",
166 false, // add_to_auto_reconfigurable_params
167 false, // is_required
168 true, // read_only
169 std::nullopt, std::nullopt, std::nullopt, // from_value, to_value, step_value
170 std::string("Allowed values: ") + kAllowedOutputStampModes); // additional_constraints
171 // Allow user to optionally limit the per-cloud point count to a maximum
172 this->declareAndLoadParameter("fixed_points_per_input_cloud", fixed_points_per_input_cloud_,
173 "Runtime-reconfigurable maximum valid point count per input cloud; 0 disables the limit",
174 true, // add_to_auto_reconfigurable_params
175 false, // is_required
176 false, // read_only
177 kMinFixedPointsPerInputCloud, // from_value
180 "0 = disabled; reasonable range is 0 to 10,000,000 points per input cloud");
181 this->declareAndLoadParameter("use_cuda", use_cuda_, // name
182 "Runtime-reconfigurable backend selection; true uses CUDA and false uses CPU",
183 true, // add_to_auto_reconfigurable_params
184 false, // is_required
185 false, // read_only
186 std::nullopt, std::nullopt, std::nullopt, // from_value, to_value, step_value
187 "Runtime changes apply between fusion batches."); // additional_constraints
188 this->declareAndLoadParameter("range_limits.enable", range_limits_enable_, // name
189 "Enable XYZ range filtering after transformation into target_frame",
190 true, // add_to_auto_reconfigurable_params
191 false, // is_required
192 false, // read_only
193 std::nullopt, std::nullopt, std::nullopt, // from_value, to_value, step_value
194 "When false, no range filtering is applied."); // additional_constraints
195 this->declareAndLoadParameter("range_limits.x_min", range_limits_x_min_, // name
196 "Minimum x coordinate in target_frame to keep [m]", // description
197 true, // add_to_auto_reconfigurable_params
198 false, // is_required
199 false, // read_only
200 kMinRangeXY, // from_value
201 kMaxRangeXY, // to_value
202 std::nullopt, // step_value
203 "Must be less than range_limits.x_max."); // additional_constraints
204 this->declareAndLoadParameter("range_limits.x_max", range_limits_x_max_, // name
205 "Maximum x coordinate in target_frame to keep [m]", // description
206 true, // add_to_auto_reconfigurable_params
207 false, // is_required
208 false, // read_only
209 kMinRangeXY, // from_value
210 kMaxRangeXY, // to_value
211 std::nullopt, // step_value
212 "Must be greater than range_limits.x_min."); // additional_constraints
213 this->declareAndLoadParameter("range_limits.y_min", range_limits_y_min_, // name
214 "Minimum y coordinate in target_frame to keep [m]", // description
215 true, // add_to_auto_reconfigurable_params
216 false, // is_required
217 false, // read_only
218 kMinRangeXY, // from_value
219 kMaxRangeXY, // to_value
220 std::nullopt, // step_value
221 "Must be less than range_limits.y_max."); // additional_constraints
222 this->declareAndLoadParameter("range_limits.y_max", range_limits_y_max_, // name
223 "Maximum y coordinate in target_frame to keep [m]", // description
224 true, // add_to_auto_reconfigurable_params
225 false, // is_required
226 false, // read_only
227 kMinRangeXY, // from_value
228 kMaxRangeXY, // to_value
229 std::nullopt, // step_value
230 "Must be greater than range_limits.y_min."); // additional_constraints
231 this->declareAndLoadParameter("range_limits.z_min", range_limits_z_min_, // name
232 "Minimum z coordinate in target_frame to keep [m]", // description
233 true, // add_to_auto_reconfigurable_params
234 false, // is_required
235 false, // read_only
236 kMinRangeZ, // from_value
237 kMaxRangeZ, // to_value
238 std::nullopt, // step_value
239 "Must be less than range_limits.z_max."); // additional_constraints
240 this->declareAndLoadParameter("range_limits.z_max", range_limits_z_max_, // name
241 "Maximum z coordinate in target_frame to keep [m]", // description
242 true, // add_to_auto_reconfigurable_params
243 false, // is_required
244 false, // read_only
245 kMinRangeZ, // from_value
246 kMaxRangeZ, // to_value
247 std::nullopt, // step_value
248 "Must be greater than range_limits.z_min."); // additional_constraints
250 this->declareAndLoadParameter("max_time_diff_sec", max_time_diff_sec_, // name
251 "Maximum timestamp spread across a synchronized input batch in seconds",
252 false, // add_to_auto_reconfigurable_params
253 false, // is_required
254 true, // read_only
255 0.0, std::nullopt, std::nullopt, // from_value, to_value, step_value
256 "Must be non-negative"); // additional_constraints
257 this->declareAndLoadParameter("age_penalty", age_penalty_, // name
258 "Age penalty used by the approximate-time synchronizer",
259 false, // add_to_auto_reconfigurable_params
260 false, // is_required
261 true, // read_only
262 0.0, // from_value
263 100.0, // to_value
264 std::nullopt, // step_value
265 "Valid range is [0, 100]."); // additional_constraints
267
268#ifdef ENABLE_CUDA
269 // Keep the CUDA context available even when starting in CPU mode so the
270 // backend can be switched safely at runtime.
271 try {
272 cuda_context_ = std::make_unique<cuda::CudaTransformContext>();
273 if (use_cuda_) {
274 RCLCPP_INFO(this->get_logger(), "CUDA acceleration enabled");
275 } else {
276 RCLCPP_INFO(this->get_logger(), "CUDA context initialized; using CPU backend by parameter");
277 }
278 } catch (const std::exception& e) {
279 RCLCPP_ERROR(this->get_logger(), "Failed to initialize CUDA context: %s", e.what());
280 RCLCPP_WARN(this->get_logger(), "Falling back to CPU-only implementation");
281 cuda_context_.reset();
282 use_cuda_ = false;
283 this->set_parameter(rclcpp::Parameter("use_cuda", false));
284 }
285#else
286 RCLCPP_INFO(this->get_logger(), "CUDA support not compiled, using CPU-only implementation");
287 use_cuda_ = false;
288 this->set_parameter(rclcpp::Parameter("use_cuda", false));
289#endif
290
291 // run setup after constructor has finished to enable shared_from_this()
292 setup_timer_ = this->create_wall_timer(std::chrono::milliseconds(1), [this]() {
293 setup();
294 setup_timer_->cancel();
295 });
296}
297// clang-format on
298
299template <typename T>
300void PointCloudFusion::declareAndLoadParameter(const std::string& name,
301 T& param,
302 const std::string& description,
303 const bool add_to_auto_reconfigurable_params,
304 const bool is_required,
305 const bool read_only,
306 const std::optional<double>& from_value,
307 const std::optional<double>& to_value,
308 const std::optional<double>& step_value,
309 const std::string& additional_constraints) {
310 rcl_interfaces::msg::ParameterDescriptor param_desc;
311 param_desc.description = description;
312 param_desc.additional_constraints = additional_constraints;
313 param_desc.read_only = read_only;
314
315 auto type = rclcpp::ParameterValue(param).get_type();
316
317 if (from_value.has_value() && to_value.has_value()) {
318 if constexpr (std::is_integral_v<T>) {
319 rcl_interfaces::msg::IntegerRange range;
320 range.set__from_value(static_cast<T>(from_value.value())).set__to_value(static_cast<T>(to_value.value()));
321 if (step_value.has_value()) range.set__step(static_cast<T>(step_value.value()));
322 param_desc.integer_range = {range};
323 } else if constexpr (std::is_floating_point_v<T>) {
324 rcl_interfaces::msg::FloatingPointRange range;
325 range.set__from_value(static_cast<T>(from_value.value())).set__to_value(static_cast<T>(to_value.value()));
326 if (step_value.has_value()) range.set__step(static_cast<T>(step_value.value()));
327 param_desc.floating_point_range = {range};
328 } else {
329 RCLCPP_WARN(this->get_logger(),
330 "Parameter type of parameter '%s' does not support "
331 "specifying a range",
332 name.c_str());
333 }
334 }
335
336 this->declare_parameter(name, type, param_desc);
337
338 try {
339 param = this->get_parameter(name).get_value<T>();
340 std::stringstream ss;
341 ss << "Loaded parameter '" << name << "': ";
342 if constexpr (is_vector_v<T>) {
343 ss << "[";
344 for (const auto& element : param) ss << element << (&element != &param.back() ? ", " : "");
345 ss << "]";
346 } else {
347 ss << param;
348 }
349 RCLCPP_INFO_STREAM(this->get_logger(), ss.str());
350 } catch (rclcpp::exceptions::ParameterUninitializedException&) {
351 if (is_required) {
352 RCLCPP_FATAL_STREAM(this->get_logger(), "Missing required parameter '" << name << "', exiting");
353 exit(EXIT_FAILURE);
354 } else {
355 std::stringstream ss;
356 ss << "Missing parameter '" << name << "', using default value: ";
357 if constexpr (is_vector_v<T>) {
358 ss << "[";
359 for (const auto& element : param) ss << element << (&element != &param.back() ? ", " : "");
360 ss << "]";
361 } else {
362 ss << param;
363 }
364 RCLCPP_WARN_STREAM(this->get_logger(), ss.str());
365 this->set_parameters({rclcpp::Parameter(name, rclcpp::ParameterValue(param))});
366 }
367 }
368
369 if (add_to_auto_reconfigurable_params) {
370 std::function<void(const rclcpp::Parameter&)> setter = [&param](const rclcpp::Parameter& p) { param = p.get_value<T>(); };
371 auto_reconfigurable_params_.push_back(std::make_tuple(name, setter));
372 }
373}
374
375rcl_interfaces::msg::SetParametersResult PointCloudFusion::parametersCallback(const std::vector<rclcpp::Parameter>& parameters) {
376 std::unique_lock<std::shared_mutex> config_lock(config_mutex_);
377
378 // Pre-validate interdependent and runtime-sensitive parameters before
379 // applying any changes.
380 // Build the prospective state: current values overridden by incoming changes.
381 bool any_range_param = false;
382 bool prospective_use_cuda = use_cuda_;
383 int64_t prospective_fixed_points_per_input_cloud = fixed_points_per_input_cloud_;
384 double prospective_x_min = range_limits_x_min_;
385 double prospective_x_max = range_limits_x_max_;
386 double prospective_y_min = range_limits_y_min_;
387 double prospective_y_max = range_limits_y_max_;
388 double prospective_z_min = range_limits_z_min_;
389 double prospective_z_max = range_limits_z_max_;
390
391 for (const auto& param : parameters) {
392 const auto& name = param.get_name();
393 if (name == "fixed_points_per_input_cloud") {
394 prospective_fixed_points_per_input_cloud = param.as_int();
395 } else if (name == "use_cuda") {
396 prospective_use_cuda = param.as_bool();
397 } else if (name == "range_limits.x_min") {
398 prospective_x_min = param.as_double();
399 any_range_param = true;
400 } else if (name == "range_limits.x_max") {
401 prospective_x_max = param.as_double();
402 any_range_param = true;
403 } else if (name == "range_limits.y_min") {
404 prospective_y_min = param.as_double();
405 any_range_param = true;
406 } else if (name == "range_limits.y_max") {
407 prospective_y_max = param.as_double();
408 any_range_param = true;
409 } else if (name == "range_limits.z_min") {
410 prospective_z_min = param.as_double();
411 any_range_param = true;
412 } else if (name == "range_limits.z_max") {
413 prospective_z_max = param.as_double();
414 any_range_param = true;
415 }
416 }
417
418 if (prospective_fixed_points_per_input_cloud < kMinFixedPointsPerInputCloud ||
419 prospective_fixed_points_per_input_cloud > kMaxFixedPointsPerInputCloud) {
420 rcl_interfaces::msg::SetParametersResult result;
421 result.successful = false;
422 result.reason = "fixed_points_per_input_cloud must be in [" + std::to_string(kMinFixedPointsPerInputCloud) + ", " +
423 std::to_string(kMaxFixedPointsPerInputCloud) + "]";
424 RCLCPP_ERROR(this->get_logger(), "Rejecting parameter update: %s", result.reason.c_str());
425 return result;
426 }
427
428 if (prospective_use_cuda) {
429#ifdef ENABLE_CUDA
430 if (!cuda_context_) {
431 rcl_interfaces::msg::SetParametersResult result;
432 result.successful = false;
433 result.reason =
434 "CUDA backend is unavailable because CUDA context "
435 "initialization failed";
436 RCLCPP_ERROR(this->get_logger(), "Rejecting parameter update: %s", result.reason.c_str());
437 return result;
438 }
439#else
440 rcl_interfaces::msg::SetParametersResult result;
441 result.successful = false;
442 result.reason = "CUDA backend is unavailable because CUDA support was not compiled";
443 RCLCPP_ERROR(this->get_logger(), "Rejecting parameter update: %s", result.reason.c_str());
444 return result;
445#endif
446 }
447
448 if (any_range_param) {
449 rcl_interfaces::msg::SetParametersResult result;
450 std::string reason;
451 if (prospective_x_min >= prospective_x_max) {
452 reason += "range_limits.x_min (" + std::to_string(prospective_x_min) + ") must be less than range_limits.x_max (" +
453 std::to_string(prospective_x_max) + "). ";
454 }
455 if (prospective_y_min >= prospective_y_max) {
456 reason += "range_limits.y_min (" + std::to_string(prospective_y_min) + ") must be less than range_limits.y_max (" +
457 std::to_string(prospective_y_max) + "). ";
458 }
459 if (prospective_z_min >= prospective_z_max) {
460 reason += "range_limits.z_min (" + std::to_string(prospective_z_min) + ") must be less than range_limits.z_max (" +
461 std::to_string(prospective_z_max) + "). ";
462 }
463 if (!reason.empty()) {
464 result.successful = false;
465 result.reason = reason;
466 RCLCPP_ERROR(this->get_logger(), "Rejecting range_limits parameter update: %s", reason.c_str());
467 return result;
468 }
469 }
470
471 // All validations passed — apply changes.
472 const bool previous_use_cuda = use_cuda_;
473 for (const auto& param : parameters) {
474 for (auto& auto_reconfigurable_param : auto_reconfigurable_params_) {
475 if (param.get_name() == std::get<0>(auto_reconfigurable_param)) {
476 std::get<1>(auto_reconfigurable_param)(param);
477 RCLCPP_INFO(this->get_logger(), "Reconfigured parameter '%s' to: %s", param.get_name().c_str(),
478 param.value_to_string().c_str());
479 break;
480 }
481 }
482 }
483 if (use_cuda_ != previous_use_cuda) {
484 RCLCPP_INFO(this->get_logger(), "Fusion backend switched to %s", use_cuda_ ? "CUDA" : "CPU");
485 }
486
487 rcl_interfaces::msg::SetParametersResult result;
488 result.successful = true;
489
490 return result;
491}
492
493namespace detail {
494
495template <std::size_t N>
497
498template <>
500 using Policy = message_filters::sync_policies::ApproximateTime<sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2>;
501};
502
503template <>
505 using Policy = message_filters::sync_policies::
506 ApproximateTime<sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2>;
507};
508
509template <>
511 using Policy = message_filters::sync_policies::ApproximateTime<sensor_msgs::msg::PointCloud2,
512 sensor_msgs::msg::PointCloud2,
513 sensor_msgs::msg::PointCloud2,
514 sensor_msgs::msg::PointCloud2>;
515};
516
517template <>
519 using Policy = message_filters::sync_policies::ApproximateTime<sensor_msgs::msg::PointCloud2,
520 sensor_msgs::msg::PointCloud2,
521 sensor_msgs::msg::PointCloud2,
522 sensor_msgs::msg::PointCloud2,
523 sensor_msgs::msg::PointCloud2>;
524};
525
526template <>
528 using Policy = message_filters::sync_policies::ApproximateTime<sensor_msgs::msg::PointCloud2,
529 sensor_msgs::msg::PointCloud2,
530 sensor_msgs::msg::PointCloud2,
531 sensor_msgs::msg::PointCloud2,
532 sensor_msgs::msg::PointCloud2,
533 sensor_msgs::msg::PointCloud2>;
534};
535
536template <>
538 using Policy = message_filters::sync_policies::ApproximateTime<sensor_msgs::msg::PointCloud2,
539 sensor_msgs::msg::PointCloud2,
540 sensor_msgs::msg::PointCloud2,
541 sensor_msgs::msg::PointCloud2,
542 sensor_msgs::msg::PointCloud2,
543 sensor_msgs::msg::PointCloud2,
544 sensor_msgs::msg::PointCloud2>;
545};
546
547template <>
549 using Policy = message_filters::sync_policies::ApproximateTime<sensor_msgs::msg::PointCloud2,
550 sensor_msgs::msg::PointCloud2,
551 sensor_msgs::msg::PointCloud2,
552 sensor_msgs::msg::PointCloud2,
553 sensor_msgs::msg::PointCloud2,
554 sensor_msgs::msg::PointCloud2,
555 sensor_msgs::msg::PointCloud2,
556 sensor_msgs::msg::PointCloud2>;
557};
558
559template <>
561 using Policy = message_filters::sync_policies::ApproximateTime<sensor_msgs::msg::PointCloud2,
562 sensor_msgs::msg::PointCloud2,
563 sensor_msgs::msg::PointCloud2,
564 sensor_msgs::msg::PointCloud2,
565 sensor_msgs::msg::PointCloud2,
566 sensor_msgs::msg::PointCloud2,
567 sensor_msgs::msg::PointCloud2,
568 sensor_msgs::msg::PointCloud2,
569 sensor_msgs::msg::PointCloud2>;
570};
571
572template <std::size_t N>
574
575template <std::size_t N>
576using SyncType = message_filters::Synchronizer<SyncPolicy<N>>;
577
587template <std::size_t N, std::size_t... Is>
589 const std::vector<std::shared_ptr<point_cloud_transport::SubscriberFilter>>& subs,
590 std::index_sequence<Is...> /*unused*/) {
591 // Fan the subscriber filters into the synchronizer inputs.
592 sync.connectInput(*subs[Is]...);
593}
594
602template <std::size_t N>
603void connectInputs(SyncType<N>& sync, const std::vector<std::shared_ptr<point_cloud_transport::SubscriberFilter>>& subs) {
604 connectInputsImpl<N>(sync, subs, std::make_index_sequence<N>{});
605}
606
607} // namespace detail
608
610 // callback for dynamic parameter configuration
612 this->add_on_set_parameters_callback(std::bind(&PointCloudFusion::parametersCallback, this, std::placeholders::_1));
613
614 // create transform buffer and listener
615 tf_buffer_ = std::make_shared<tf2_ros::Buffer>(this->get_clock());
616 tf_listener_ = std::make_shared<tf2_ros::TransformListener>(*tf_buffer_);
617
618 // validate inputs
619 if (!input_transport_hints_.empty() && input_transport_hints_.size() != input_topics_.size()) {
620 RCLCPP_WARN(this->get_logger(),
621 "'input_transport_hints' length (%zu) does not match "
622 "'input_topics' (%zu). Missing hints default to "
623 "'%s'",
625 }
626
627 // create subscribers
628 cloud_subscribers_.clear();
630 cloud_subscribers_.reserve(input_topics_.size());
632 for (size_t i = 0; i < input_topics_.size(); ++i) {
633 const std::string configured = input_topics_[i];
634 const std::string resolved = this->get_node_topics_interface()->resolve_topic_name(configured);
635 const std::string hint = (i < input_transport_hints_.size() && !input_transport_hints_[i].empty())
637 : std::string(kDefaultTransportHint);
638
639 auto callback_group = this->create_callback_group(rclcpp::CallbackGroupType::MutuallyExclusive);
640 rclcpp::SubscriptionOptions subscription_options;
641 subscription_options.callback_group = callback_group;
642
643 auto subscriber = std::make_shared<point_cloud_transport::SubscriberFilter>();
644 subscriber->subscribe(this->shared_from_this(), resolved, hint, rmw_qos_profile_default, subscription_options);
645 RCLCPP_INFO(this->get_logger(), "Subscribed to '%s' (hint=%s)", subscriber->getTopic().c_str(), hint.c_str());
646 cloud_subscriber_callback_groups_.push_back(std::move(callback_group));
647 cloud_subscribers_.push_back(std::move(subscriber));
648 }
649
650 RCLCPP_INFO(this->get_logger(), "Configured %zu input subscriber callback groups for %zu input topics",
652
653 synchronizer_.reset();
654
655 // configure synchronization or direct passthrough
656 if (cloud_subscribers_.size() == 1) {
657 cloud_subscribers_.front()->registerCallback([this](const PointCloudMsg::ConstSharedPtr msg) {
658 std::vector<PointCloudMsg::ConstSharedPtr> batch;
659 batch.reserve(1);
660 batch.emplace_back(msg);
662 });
663 RCLCPP_INFO(this->get_logger(), "Configured single-input mode for topic '%s'",
664 cloud_subscribers_.front()->getTopic().c_str());
665 } else if (cloud_subscribers_.size() <= 9) {
666 switch (cloud_subscribers_.size()) {
667 case 2:
669 break;
670 case 3:
672 break;
673 case 4:
675 break;
676 case 5:
678 break;
679 case 6:
681 break;
682 case 7:
684 break;
685 case 8:
687 break;
688 case 9:
690 break;
691 default:
692 RCLCPP_FATAL(this->get_logger(), "Unsupported number of input topics: %zu", cloud_subscribers_.size());
693 exit(EXIT_FAILURE);
694 }
695 }
696
697 // create publisher
698 point_cloud_transport::PointCloudTransport pct(this->shared_from_this());
699 std::string point_cloud_topic_name = this->get_node_topics_interface()->resolve_topic_name("~/point_cloud");
700 cloud_publisher_ = std::make_shared<point_cloud_transport::Publisher>(
701 pct.advertise(point_cloud_topic_name, static_cast<uint32_t>(output_queue_size_)));
702 RCLCPP_INFO(this->get_logger(), "Publishing to '%s'", cloud_publisher_->getTopic().c_str());
703
704 // Annotate message links for tracing: Each publisher (for raw and compressed
705 // point clouds) depends an all input point clouds.
706 std::vector<const void*> link_subs;
707 std::vector<const void*> link_pubs;
708 for (const auto& sub_filter : cloud_subscribers_) {
709 auto sub_base = sub_filter->getSubscriber().getSubscription();
710 if (sub_base) {
711 link_subs.push_back(static_cast<const void*>(sub_base->get_subscription_handle().get()));
712 }
713 }
714 if (cloud_publisher_) {
715 std::map<std::string, rclcpp::PublisherBase::SharedPtr> pubs_base = cloud_publisher_->getPublishers();
716 for (const auto& [transport, pub_base] : pubs_base) {
717 link_pubs.push_back(static_cast<const void*>(pub_base->get_publisher_handle().get()));
718 }
719 }
720 TRACETOOLS_TRACEPOINT(message_link_partial_sync, link_subs.data(), link_subs.size(), link_pubs.data(), link_pubs.size());
721}
722
723template <std::size_t N>
725 static_assert(N >= 2 && N <= 9, "Supported synchronizer size is between 2 and 9");
726
727 using Policy = detail::SyncPolicy<N>;
728 using Sync = detail::SyncType<N>;
729
730 // Instantiate ApproximateTime policy tuned to the active input count.
731 auto sync = std::make_shared<Sync>(Policy(static_cast<size_t>(sync_queue_size_)));
732
733 // Wire the configured subscribers into the synchronizer slots.
735
736 sync->setMaxIntervalDuration(rclcpp::Duration::from_seconds(max_time_diff_sec_));
737 sync->setAgePenalty(age_penalty_);
738 sync->registerCallback([this](auto&&... msgs) {
739 std::vector<PointCloudMsg::ConstSharedPtr> batch;
740 batch.reserve(sizeof...(msgs));
741 // Extract only valid PointCloud2 pointers from the variadic callback.
742 auto append = [&batch](auto&& msg) {
743 using ArgT = std::decay_t<decltype(msg)>;
744 if constexpr (std::is_same_v<ArgT, PointCloudMsg::ConstSharedPtr>) {
745 if (msg) {
746 batch.emplace_back(std::forward<decltype(msg)>(msg));
747 }
748 }
749 };
750 (append(std::forward<decltype(msgs)>(msgs)), ...);
751 if (!batch.empty()) {
753 } else {
754 RCLCPP_WARN(this->get_logger(),
755 "ApproximateTime synchronizer yielded no "
756 "valid point clouds; skipping fusion.");
757 }
758 });
759
760 synchronizer_ = sync;
761
762 RCLCPP_INFO(this->get_logger(),
763 "Configured approximate time synchronizer for %zu inputs "
764 "(queue=%zu, max_dt=%.3f s, age_penalty=%.6f)",
765 static_cast<size_t>(N), static_cast<size_t>(sync_queue_size_), max_time_diff_sec_, age_penalty_);
766}
767
768void PointCloudFusion::handleSynchronizedPointClouds(const std::vector<sensor_msgs::msg::PointCloud2::ConstSharedPtr>& msgs) {
769 if (msgs.empty()) {
770 return;
771 }
772
773 // Protect runtime-configurable parameter reads against concurrent parameter
774 // updates.
775 std::shared_lock<std::shared_mutex> config_lock(config_mutex_);
776
777 const auto callback_start = std::chrono::steady_clock::now();
778
779 FusionTiming timing;
780 if (!collectTimingInfo(msgs, timing)) {
781 return;
782 }
783
784#ifdef ENABLE_CUDA
785 std::size_t cuda_valid_count = 0;
786 auto processing_start = std::chrono::steady_clock::time_point{};
787 auto processing_end = std::chrono::steady_clock::time_point{};
788 PointCloudMsg::UniquePtr cuda_result;
789 bool used_cuda = false;
790 {
791 // Guard shared CUDA pipeline state against concurrent synchronized
792 // callbacks.
793 std::lock_guard<std::mutex> cuda_lock(cuda_context_mutex_);
794 // Run either CPU or CUDA implementation based on parameter.
795 if (cuda_context_ && use_cuda_) {
796 used_cuda = true;
797 processing_start = std::chrono::steady_clock::now();
798 cuda_result = fusePointCloudBatchCUDA(msgs, timing, cuda_valid_count);
799 processing_end = std::chrono::steady_clock::now();
800 }
801 }
802
803 if (used_cuda) {
804 if (cuda_result) {
805 publishFusedCloud(std::move(cuda_result), timing, msgs.size(), cuda_valid_count, callback_start, processing_start,
806 processing_end, "cuda_fusion_complete");
807 } else {
808 RCLCPP_WARN(this->get_logger(), "CUDA processing failed");
809 }
810 return;
811 }
812#endif
813
814 // CPU-only path
815 std::size_t valid_count = 0;
816 const auto cpu_processing_start = std::chrono::steady_clock::now();
817 auto fused_point_cloud = fusePointCloudBatch(msgs, timing, valid_count);
818
819 if (!fused_point_cloud) {
820 RCLCPP_WARN(this->get_logger(), "All points are invalid, skipping fusion");
821 return;
822 }
823
824 const auto cpu_processing_end = std::chrono::steady_clock::now();
825 publishFusedCloud(std::move(fused_point_cloud), timing, msgs.size(), valid_count, callback_start, cpu_processing_start,
826 cpu_processing_end, "cpu_fusion_complete");
827}
828
829bool PointCloudFusion::collectTimingInfo(const std::vector<PointCloudMsg::ConstSharedPtr>& msgs, FusionTiming& timing) const {
830 if (msgs.empty()) {
831 return false;
832 }
833
834 const auto input0_stamp = rclcpp::Time(msgs.front()->header.stamp);
835 bool first_stamp = true;
836 double max_dt_from_input0_sec = 0.0;
837 rclcpp::Time earliest_stamp;
838 rclcpp::Time latest_stamp;
839
840 // Walk every cloud once to gather min/max stamps and the largest skew from
841 // input0.
842 for (const auto& pc_msg : msgs) {
843 if (!pc_msg) {
844 RCLCPP_WARN(this->get_logger(),
845 "Received null point cloud pointer in "
846 "synchronized batch, skipping fusion");
847 return false;
848 }
849
850 const rclcpp::Time current_stamp(pc_msg->header.stamp);
851 if (first_stamp) {
852 earliest_stamp = current_stamp;
853 latest_stamp = current_stamp;
854 first_stamp = false;
855 } else {
856 if (current_stamp < earliest_stamp) earliest_stamp = current_stamp;
857 if (current_stamp > latest_stamp) latest_stamp = current_stamp;
858 }
859
860 const double dt_sec = std::fabs((current_stamp - input0_stamp).seconds());
861 if (dt_sec > max_dt_from_input0_sec) {
862 max_dt_from_input0_sec = dt_sec;
863 }
864 }
865
866 timing.earliest_stamp = earliest_stamp;
867 timing.latest_stamp = latest_stamp;
868 timing.input0_stamp = input0_stamp;
869 timing.max_dt_from_input0_sec = max_dt_from_input0_sec;
870 return true;
871}
872
873PointCloudFusion::PointCloudMsg::UniquePtr PointCloudFusion::fusePointCloudBatch(
874 const std::vector<PointCloudMsg::ConstSharedPtr>& msgs, const FusionTiming& timing, std::size_t& valid_point_count) const {
875 if (msgs.empty()) {
876 return nullptr;
877 }
878
879 const auto& input0_msg = msgs.front();
880 if (!input0_msg) {
881 return nullptr;
882 }
883
884 std::optional<uint32_t> x_offset;
885 std::optional<uint32_t> y_offset;
886 std::optional<uint32_t> z_offset;
887 for (const auto& field : input0_msg->fields) {
888 if (field.name == "x") {
889 x_offset = field.offset;
890 } else if (field.name == "y") {
891 y_offset = field.offset;
892 } else if (field.name == "z") {
893 z_offset = field.offset;
894 }
895 }
896
897 if (!x_offset || !y_offset || !z_offset) {
898 RCLCPP_WARN(this->get_logger(), "Point cloud lacks x/y/z fields; skipping fusion for this batch.");
899 valid_point_count = 0;
900 return nullptr;
901 }
902
903 const size_t point_step = input0_msg->point_step;
904 const auto& input0_fields = input0_msg->fields;
905 const bool is_bigendian = input0_msg->is_bigendian;
906
907 struct FieldCopyPlan {
908 const sensor_msgs::msg::PointField* source;
909 sensor_msgs::msg::PointField destination;
910 std::size_t byte_length;
911 };
912
913 bool use_all_fields = output_fields_.empty();
914 std::vector<FieldCopyPlan> copy_plan;
915 std::vector<sensor_msgs::msg::PointField> fused_fields;
916 fused_fields.reserve(input0_fields.size());
917
918 std::size_t fused_point_step = point_step;
919 std::optional<uint32_t> fused_x_offset = x_offset;
920 std::optional<uint32_t> fused_y_offset = y_offset;
921 std::optional<uint32_t> fused_z_offset = z_offset;
922
923 if (!use_all_fields) {
924 fused_x_offset.reset();
925 fused_y_offset.reset();
926 fused_z_offset.reset();
927 bool selection_valid = true;
928 fused_point_step = 0;
929 fused_fields.clear();
930 copy_plan.reserve(output_fields_.size());
931
932 for (const auto& requested_name : output_fields_) {
933 auto iter =
934 std::find_if(input0_fields.begin(), input0_fields.end(),
935 [&requested_name](const sensor_msgs::msg::PointField& field) { return field.name == requested_name; });
936 if (iter == input0_fields.end()) {
937 RCLCPP_WARN(this->get_logger(),
938 "Requested output field '%s' not present in incoming point "
939 "cloud; publishing full field set instead.",
940 requested_name.c_str());
941 selection_valid = false;
942 break;
943 }
944
945 const std::size_t datatype_size = pointFieldDatatypeSize(iter->datatype);
946 if (datatype_size == 0) {
947 RCLCPP_WARN(this->get_logger(),
948 "Point field '%s' uses unsupported datatype %u; publishing "
949 "full field set instead.",
950 requested_name.c_str(), static_cast<unsigned int>(iter->datatype));
951 selection_valid = false;
952 break;
953 }
954
955 FieldCopyPlan plan;
956 plan.source = &(*iter);
957 plan.destination = *iter;
958 plan.destination.offset = static_cast<uint32_t>(fused_point_step);
959 plan.byte_length = datatype_size * static_cast<std::size_t>(iter->count);
960 fused_point_step += plan.byte_length;
961
962 if (requested_name == "x") {
963 fused_x_offset = plan.destination.offset;
964 } else if (requested_name == "y") {
965 fused_y_offset = plan.destination.offset;
966 } else if (requested_name == "z") {
967 fused_z_offset = plan.destination.offset;
968 }
969
970 copy_plan.push_back(plan);
971 fused_fields.push_back(plan.destination);
972 }
973
974 if (!selection_valid || !fused_x_offset || !fused_y_offset || !fused_z_offset) {
975 if (selection_valid) {
976 RCLCPP_ERROR(this->get_logger(),
977 "Output field selection must include x, y, and z; "
978 "publishing full field set instead.");
979 }
980 use_all_fields = true;
981 copy_plan.clear();
982 fused_point_step = point_step;
983 fused_fields.assign(input0_fields.begin(), input0_fields.end());
984 fused_x_offset = x_offset;
985 fused_y_offset = y_offset;
986 fused_z_offset = z_offset;
987 }
988 }
989
990 if (use_all_fields) {
991 fused_fields.assign(input0_fields.begin(), input0_fields.end());
992 fused_point_step = point_step;
993 fused_x_offset = x_offset;
994 fused_y_offset = y_offset;
995 fused_z_offset = z_offset;
996 }
997
998 // Reserve enough space once so the fusion loop only appends into a pre-sized
999 // buffer.
1000 const size_t max_capacity = std::accumulate(
1001 msgs.begin(), msgs.end(), static_cast<size_t>(0), [this](size_t sum, const PointCloudMsg::ConstSharedPtr& cloud) {
1002 if (!cloud) {
1003 return sum;
1004 }
1005 size_t cloud_size = static_cast<size_t>(cloud->width) * static_cast<size_t>(cloud->height);
1006 // If user set a cap, limit each cloud to that size
1008 cloud_size = std::min(cloud_size, static_cast<size_t>(fixed_points_per_input_cloud_));
1009 }
1010 return sum + cloud_size;
1011 });
1012
1013 if (max_capacity == 0) {
1014 valid_point_count = 0;
1015 return nullptr;
1016 }
1017
1018 auto output = std::make_unique<PointCloudMsg>();
1019 output->header.frame_id = target_frame_;
1020 rclcpp::Time chosen_stamp;
1021 switch (output_stamp_mode_) {
1022 case OutputStampMode::Earliest:
1023 chosen_stamp = timing.earliest_stamp;
1024 break;
1025 case OutputStampMode::Mean: {
1026 const auto delta = timing.latest_stamp - timing.earliest_stamp;
1027 chosen_stamp = timing.earliest_stamp + rclcpp::Duration::from_nanoseconds(delta.nanoseconds() / 2);
1028 break;
1029 }
1030 case OutputStampMode::Input0:
1031 chosen_stamp = timing.input0_stamp;
1032 break;
1033 case OutputStampMode::Latest:
1034 default:
1035 chosen_stamp = timing.latest_stamp;
1036 break;
1037 }
1038 output->header.stamp = chosen_stamp;
1039 output->height = 1;
1040 output->is_bigendian = is_bigendian;
1041 output->point_step = fused_point_step;
1042 output->fields = fused_fields;
1043 output->is_dense = true;
1044 output->data.resize(max_capacity * fused_point_step);
1045
1046 uint8_t* dest_ptr = output->data.data();
1047 valid_point_count = 0;
1048 std::size_t skipped_inputs = 0;
1049
1050 // Pre-cache range limits as float to avoid per-point double→float conversion.
1051 const bool check_range = range_limits_enable_;
1052 const float rl_x_min = static_cast<float>(range_limits_x_min_);
1053 const float rl_x_max = static_cast<float>(range_limits_x_max_);
1054 const float rl_y_min = static_cast<float>(range_limits_y_min_);
1055 const float rl_y_max = static_cast<float>(range_limits_y_max_);
1056 const float rl_z_min = static_cast<float>(range_limits_z_min_);
1057 const float rl_z_max = static_cast<float>(range_limits_z_max_);
1058
1059 for (const auto& msg : msgs) {
1060 if (!msg) {
1061 continue;
1062 }
1063
1064 if (msg->point_step != point_step || msg->fields != input0_fields) {
1065 RCLCPP_WARN(this->get_logger(), "Skipping point cloud '%s' due to incompatible field layout.",
1066 msg->header.frame_id.c_str());
1067 ++skipped_inputs;
1068 continue;
1069 }
1070
1071 // Cache the frame transform once per cloud to avoid repeated TF queries
1072 // inside the point loop.
1073 const bool apply_transform = msg->header.frame_id != target_frame_;
1074 tf2::Vector3 translation;
1075 tf2::Matrix3x3 rotation;
1076 if (apply_transform) {
1077 tf2::Transform tf_transform;
1078 geometry_msgs::msg::TransformStamped tf_stamped;
1079 try {
1080 tf_stamped = tf_buffer_->lookupTransform(target_frame_, msg->header.frame_id, msg->header.stamp,
1081 rclcpp::Duration::from_seconds(0.1));
1082 } catch (const tf2::TransformException& ex) {
1083 RCLCPP_ERROR(this->get_logger(), "Cannot transform point cloud from %s to %s: %s", msg->header.frame_id.c_str(),
1084 target_frame_.c_str(), ex.what());
1085 ++skipped_inputs;
1086 continue;
1087 }
1088 tf2::fromMsg(tf_stamped.transform, tf_transform);
1089 translation = tf_transform.getOrigin();
1090 rotation = tf_transform.getBasis();
1091 }
1092
1093 const auto* src_data = msg->data.data();
1094 const size_t total_points = static_cast<size_t>(msg->width) * static_cast<size_t>(msg->height);
1095
1096 auto emit_point = [&](const uint8_t* point_ptr, float x, float y, float z, bool overwrite_xyz) {
1097 if (use_all_fields) {
1098 std::memcpy(dest_ptr, point_ptr, point_step);
1099 } else {
1100 for (const auto& plan : copy_plan) {
1101 std::memcpy(byteOffset(dest_ptr, plan.destination.offset), byteOffset(point_ptr, plan.source->offset),
1102 plan.byte_length);
1103 }
1104 }
1105
1106 if (overwrite_xyz) {
1107 storeFloat(dest_ptr, *fused_x_offset, x);
1108 storeFloat(dest_ptr, *fused_y_offset, y);
1109 storeFloat(dest_ptr, *fused_z_offset, z);
1110 }
1111
1112 dest_ptr = byteOffset(dest_ptr, fused_point_step);
1113 ++valid_point_count;
1114 };
1115
1116 if (fixed_points_per_input_cloud_ <= 0 || static_cast<size_t>(fixed_points_per_input_cloud_) >= total_points) {
1117 // Fast path: when no downsampling is requested.
1118 for (size_t idx = 0; idx < total_points; ++idx) {
1119 const auto* point_ptr = byteOffset(src_data, idx * point_step);
1120 const float x = loadFloat(point_ptr, *x_offset);
1121 const float y = loadFloat(point_ptr, *y_offset);
1122 const float z = loadFloat(point_ptr, *z_offset);
1123
1124 if (!std::isfinite(x) || !std::isfinite(y) || !std::isfinite(z)) {
1125 continue;
1126 }
1127
1128 if (!apply_transform) {
1129 if (check_range && !pointWithinRange(x, y, z, rl_x_min, rl_x_max, rl_y_min, rl_y_max, rl_z_min, rl_z_max)) {
1130 continue;
1131 }
1132 emit_point(point_ptr, x, y, z, false);
1133 continue;
1134 }
1135
1136 const tf2::Vector3 rotated = rotation * tf2::Vector3(x, y, z) + translation;
1137 const float transformed_x = static_cast<float>(rotated.x());
1138 const float transformed_y = static_cast<float>(rotated.y());
1139 const float transformed_z = static_cast<float>(rotated.z());
1140
1141 if (check_range && !pointWithinRange(transformed_x, transformed_y, transformed_z, rl_x_min, rl_x_max, rl_y_min, rl_y_max,
1142 rl_z_min, rl_z_max)) {
1143 continue;
1144 }
1145
1146 emit_point(point_ptr, transformed_x, transformed_y, transformed_z, true);
1147 }
1148 continue;
1149 }
1150
1151 // Downsample path: strided sampling for uniform spatial distribution.
1152 const size_t desired_points = static_cast<size_t>(fixed_points_per_input_cloud_);
1153 const double stride = static_cast<double>(total_points) / static_cast<double>(desired_points);
1154 const size_t num_samples = desired_points;
1155
1156 for (size_t i = 0; i < num_samples; ++i) {
1157 const size_t idx = std::min(static_cast<size_t>(static_cast<double>(i) * stride), total_points - 1);
1158
1159 const auto* point_ptr = byteOffset(src_data, idx * point_step);
1160 const float x = loadFloat(point_ptr, *x_offset);
1161 const float y = loadFloat(point_ptr, *y_offset);
1162 const float z = loadFloat(point_ptr, *z_offset);
1163
1164 if (!std::isfinite(x) || !std::isfinite(y) || !std::isfinite(z)) {
1165 continue;
1166 }
1167
1168 if (!apply_transform) {
1169 if (check_range && !pointWithinRange(x, y, z, rl_x_min, rl_x_max, rl_y_min, rl_y_max, rl_z_min, rl_z_max)) {
1170 continue;
1171 }
1172 emit_point(point_ptr, x, y, z, false);
1173 continue;
1174 }
1175
1176 const tf2::Vector3 rotated = rotation * tf2::Vector3(x, y, z) + translation;
1177 const float transformed_x = static_cast<float>(rotated.x());
1178 const float transformed_y = static_cast<float>(rotated.y());
1179 const float transformed_z = static_cast<float>(rotated.z());
1180
1181 if (check_range && !pointWithinRange(transformed_x, transformed_y, transformed_z, rl_x_min, rl_x_max, rl_y_min, rl_y_max,
1182 rl_z_min, rl_z_max)) {
1183 continue;
1184 }
1185
1186 emit_point(point_ptr, transformed_x, transformed_y, transformed_z, true);
1187 }
1188 }
1189
1190 if (valid_point_count == 0) {
1191 if (skipped_inputs == msgs.size()) {
1192 RCLCPP_WARN(this->get_logger(), "Skipped all point clouds in synchronized batch; no data fused.");
1193 }
1194 return nullptr;
1195 }
1196
1197 output->width = valid_point_count;
1198 output->row_step = output->point_step * output->width;
1199 output->data.resize(valid_point_count * fused_point_step);
1200
1201 output->is_dense = true;
1202 return output;
1203}
1204
1205void PointCloudFusion::publishFusedCloud(PointCloudMsg::UniquePtr cloud,
1206 const FusionTiming& timing,
1207 std::size_t input_count,
1208 std::size_t total_points,
1209 std::chrono::steady_clock::time_point callback_start,
1210 std::chrono::steady_clock::time_point processing_start,
1211 std::chrono::steady_clock::time_point processing_end,
1212 const char* event_name) {
1213 // Publish the fused cloud and emit a compact timing summary for
1214 // observability.
1215 cloud_publisher_->publish(std::move(cloud));
1216 const auto publish_end = std::chrono::steady_clock::now();
1217
1218 const double prep_duration_ms = std::chrono::duration<double, std::milli>(processing_start - callback_start).count();
1219 const double processing_duration_ms = std::chrono::duration<double, std::milli>(processing_end - processing_start).count();
1220 const double publish_duration_ms = std::chrono::duration<double, std::milli>(publish_end - processing_end).count();
1221 const double e2e_duration_ms = prep_duration_ms + processing_duration_ms + publish_duration_ms;
1222 const double batch_dt_ms = (timing.latest_stamp - timing.earliest_stamp).seconds() * 1000.0;
1223
1224 RCLCPP_DEBUG(this->get_logger(),
1225 "%s inputs=%zu points=%zu e2e_ms=%.3f prep_ms=%.3f "
1226 "process_ms=%.3f publish_ms=%.3f batch_dt_ms=%.3f "
1227 "max_dt_ms=%.3f",
1228 event_name, input_count, total_points, e2e_duration_ms, prep_duration_ms, processing_duration_ms,
1229 publish_duration_ms, batch_dt_ms, timing.max_dt_from_input0_sec * 1000.0);
1230}
1231
1232void PointCloudFusion::configureOutputStampMode(const std::string& mode) {
1233 std::string lowered = mode;
1234 std::transform(lowered.begin(), lowered.end(), lowered.begin(),
1235 [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
1236
1237 if (lowered == "earliest") {
1238 output_stamp_mode_ = OutputStampMode::Earliest;
1239 } else if (lowered == "mean" || lowered == "mid" || lowered == "midpoint") {
1240 output_stamp_mode_ = OutputStampMode::Mean;
1241 } else if (lowered == "input0") {
1242 output_stamp_mode_ = OutputStampMode::Input0;
1243 } else if (lowered == "latest") {
1244 output_stamp_mode_ = OutputStampMode::Latest;
1245 } else {
1246 RCLCPP_WARN(this->get_logger(), "Invalid output_stamp_mode '%s'; defaulting to 'earliest'.", mode.c_str());
1247 output_stamp_mode_ = OutputStampMode::Earliest;
1248 }
1249}
1250
1251void PointCloudFusion::validateInputTopicsParameter() const {
1252 if (input_topics_.empty()) {
1253 RCLCPP_FATAL(this->get_logger(), "No input topics configured (parameter 'input_topics'). Exiting");
1254 exit(EXIT_FAILURE);
1255 }
1256 if (input_topics_.size() > kMaxInputTopics) {
1257 RCLCPP_FATAL(this->get_logger(),
1258 "Configured with %zu input topics, but only up to %zu inputs "
1259 "are supported",
1260 input_topics_.size(), kMaxInputTopics);
1261 exit(EXIT_FAILURE);
1262 }
1263}
1264
1265void PointCloudFusion::validateRangeLimits() {
1266 bool valid = true;
1267 if (range_limits_x_min_ >= range_limits_x_max_) {
1268 RCLCPP_ERROR(this->get_logger(),
1269 "range_limits.x_min (%.3f) must be less than "
1270 "range_limits.x_max (%.3f); disabling range filtering",
1271 range_limits_x_min_, range_limits_x_max_);
1272 valid = false;
1273 }
1274 if (range_limits_y_min_ >= range_limits_y_max_) {
1275 RCLCPP_ERROR(this->get_logger(),
1276 "range_limits.y_min (%.3f) must be less than "
1277 "range_limits.y_max (%.3f); disabling range filtering",
1278 range_limits_y_min_, range_limits_y_max_);
1279 valid = false;
1280 }
1281 if (range_limits_z_min_ >= range_limits_z_max_) {
1282 RCLCPP_ERROR(this->get_logger(),
1283 "range_limits.z_min (%.3f) must be less than "
1284 "range_limits.z_max (%.3f); disabling range filtering",
1285 range_limits_z_min_, range_limits_z_max_);
1286 valid = false;
1287 }
1288 if (!valid) {
1289 range_limits_enable_ = false;
1290 }
1291}
1292
1293#ifdef ENABLE_CUDA
1294PointCloudFusion::PointCloudMsg::UniquePtr PointCloudFusion::fusePointCloudBatchCUDA(
1295 const std::vector<PointCloudMsg::ConstSharedPtr>& msgs, const FusionTiming& timing, std::size_t& valid_point_count) const {
1296 if (msgs.empty() || !cuda_context_) {
1297 return nullptr;
1298 }
1299
1300 const auto& input0_msg = msgs.front();
1301 if (!input0_msg) {
1302 return nullptr;
1303 }
1304
1305 // Find x, y, z field offsets
1306 int x_offset = -1, y_offset = -1, z_offset = -1;
1307 for (const auto& field : input0_msg->fields) {
1308 if (field.name == "x")
1309 x_offset = field.offset;
1310 else if (field.name == "y")
1311 y_offset = field.offset;
1312 else if (field.name == "z")
1313 z_offset = field.offset;
1314 }
1315
1316 if (x_offset < 0 || y_offset < 0 || z_offset < 0) {
1317 RCLCPP_WARN(this->get_logger(), "Point cloud lacks x/y/z fields; skipping CUDA fusion");
1318 valid_point_count = 0;
1319 return nullptr;
1320 }
1321
1322 const size_t point_step = input0_msg->point_step;
1323 const auto& input0_fields = input0_msg->fields;
1324 const bool is_bigendian = input0_msg->is_bigendian;
1325
1326 // Determine output fields and copy plan
1327 bool use_all_fields = output_fields_.empty();
1328 std::vector<sensor_msgs::msg::PointField> fused_fields;
1329 fused_fields.reserve(input0_fields.size());
1330 std::vector<cuda::CudaFieldCopy> copy_plan;
1331
1332 std::size_t fused_point_step = point_step;
1333 int fused_x_offset = x_offset;
1334 int fused_y_offset = y_offset;
1335 int fused_z_offset = z_offset;
1336
1337 if (!use_all_fields) {
1338 bool selection_valid = true;
1339 fused_point_step = 0;
1340 fused_fields.clear();
1341 copy_plan.reserve(output_fields_.size());
1342
1343 for (const auto& requested_name : output_fields_) {
1344 auto iter =
1345 std::find_if(input0_fields.begin(), input0_fields.end(),
1346 [&requested_name](const sensor_msgs::msg::PointField& field) { return field.name == requested_name; });
1347 if (iter == input0_fields.end()) {
1348 RCLCPP_WARN(this->get_logger(),
1349 "Requested output field '%s' not present in incoming point "
1350 "cloud; publishing full field set instead.",
1351 requested_name.c_str());
1352 selection_valid = false;
1353 break;
1354 }
1355
1356 const std::size_t datatype_size = pointFieldDatatypeSize(iter->datatype);
1357 if (datatype_size == 0) {
1358 RCLCPP_WARN(this->get_logger(),
1359 "Point field '%s' uses unsupported datatype %u; publishing "
1360 "full field set instead.",
1361 requested_name.c_str(), static_cast<unsigned int>(iter->datatype));
1362 selection_valid = false;
1363 break;
1364 }
1365
1366 cuda::CudaFieldCopy plan;
1367 plan.src_offset = static_cast<int>(iter->offset);
1368 plan.dst_offset = static_cast<int>(fused_point_step);
1369 plan.size = static_cast<int>(datatype_size * iter->count);
1370
1371 sensor_msgs::msg::PointField dest_field = *iter;
1372 dest_field.offset = static_cast<uint32_t>(fused_point_step);
1373
1374 fused_point_step += plan.size;
1375
1376 if (requested_name == "x")
1377 fused_x_offset = dest_field.offset;
1378 else if (requested_name == "y")
1379 fused_y_offset = dest_field.offset;
1380 else if (requested_name == "z")
1381 fused_z_offset = dest_field.offset;
1382
1383 copy_plan.push_back(plan);
1384 fused_fields.push_back(dest_field);
1385 }
1386
1387 if (!selection_valid || fused_x_offset < 0 || fused_y_offset < 0 || fused_z_offset < 0) {
1388 if (selection_valid) {
1389 RCLCPP_ERROR(this->get_logger(),
1390 "Output field selection must include x, y, and z; "
1391 "publishing full field set instead.");
1392 }
1393 use_all_fields = true;
1394 copy_plan.clear();
1395 fused_point_step = point_step;
1396 fused_fields.assign(input0_fields.begin(), input0_fields.end());
1397 fused_x_offset = x_offset;
1398 fused_y_offset = y_offset;
1399 fused_z_offset = z_offset;
1400 }
1401 }
1402
1403 if (use_all_fields) {
1404 fused_fields.assign(input0_fields.begin(), input0_fields.end());
1405 fused_point_step = point_step;
1406 fused_x_offset = x_offset;
1407 fused_y_offset = y_offset;
1408 fused_z_offset = z_offset;
1409
1410 // If using all fields, we can just copy the whole point step as one chunk
1411 // But we still need to overwrite XYZ.
1412 // Actually, if we use all fields, we can just create a single copy op for
1413 // the whole point OR we can iterate over fields if we want to be precise,
1414 // but copying the whole struct is faster. Let's just create one copy op for
1415 // the whole point.
1416 cuda::CudaFieldCopy plan;
1417 plan.src_offset = 0;
1418 plan.dst_offset = 0;
1419 plan.size = static_cast<int>(point_step);
1420 copy_plan.push_back(plan);
1421 }
1422
1423 // Calculate max points for batch reset. For strided sampling, slots must hold
1424 // the full input cloud because the kernel samples across the original range.
1425 size_t max_single_cloud_points = 0; // Max points in any single input cloud (for slot sizing)
1426 for (const auto& msg : msgs) {
1427 if (msg) {
1428 size_t cloud_size = msg->width * msg->height;
1429 max_single_cloud_points = std::max(max_single_cloud_points, cloud_size);
1430 }
1431 }
1432
1433 if (max_single_cloud_points == 0) {
1434 valid_point_count = 0;
1435 return nullptr;
1436 }
1437
1438 // slot_size is the FULL max cloud size (not capped) for strided sampling to
1439 // work
1440 size_t slot_size = max_single_cloud_points;
1441
1442 // Total input capacity is slot_size * number_of_inputs
1443 const size_t num_inputs = msgs.size();
1444 size_t total_input_capacity = slot_size * num_inputs;
1445
1446 // Reset batch with fixed slots.
1447 if (!cuda_context_->resetBatch(total_input_capacity, slot_size, point_step, fused_point_step, x_offset, y_offset, z_offset,
1448 fused_x_offset, fused_y_offset, fused_z_offset, copy_plan,
1449 static_cast<float>(range_limits_x_min_), static_cast<float>(range_limits_x_max_),
1450 static_cast<float>(range_limits_y_min_), static_cast<float>(range_limits_y_max_),
1451 static_cast<float>(range_limits_z_min_), static_cast<float>(range_limits_z_max_),
1452 range_limits_enable_)) {
1453 RCLCPP_ERROR(this->get_logger(), "CUDA resetBatch failed");
1454 return nullptr;
1455 }
1456
1457 // Process each cloud and write into its fixed slot index (preserve input
1458 // order)
1459 for (size_t i = 0; i < msgs.size(); ++i) {
1460 const auto& msg = msgs[i];
1461 if (!msg || msg->point_step != point_step || msg->fields != input0_fields) {
1462 continue;
1463 }
1464
1465 const bool apply_transform = (msg->header.frame_id != target_frame_);
1466 float rotation_matrix[9] = {1, 0, 0, 0, 1, 0, 0, 0, 1};
1467 float translation[3] = {0, 0, 0};
1468
1469 if (apply_transform) {
1470 try {
1471 auto tf_stamped = tf_buffer_->lookupTransform(target_frame_, msg->header.frame_id, msg->header.stamp,
1472 rclcpp::Duration::from_seconds(0.1));
1473 tf2::Transform tf_transform;
1474 tf2::fromMsg(tf_stamped.transform, tf_transform);
1475 auto rot = tf_transform.getBasis();
1476 auto trans = tf_transform.getOrigin();
1477
1478 rotation_matrix[0] = rot[0][0];
1479 rotation_matrix[1] = rot[0][1];
1480 rotation_matrix[2] = rot[0][2];
1481 rotation_matrix[3] = rot[1][0];
1482 rotation_matrix[4] = rot[1][1];
1483 rotation_matrix[5] = rot[1][2];
1484 rotation_matrix[6] = rot[2][0];
1485 rotation_matrix[7] = rot[2][1];
1486 rotation_matrix[8] = rot[2][2];
1487 translation[0] = trans.x();
1488 translation[1] = trans.y();
1489 translation[2] = trans.z();
1490 } catch (const tf2::TransformException& ex) {
1491 RCLCPP_ERROR(this->get_logger(), "CUDA: Cannot transform %s to %s: %s", msg->header.frame_id.c_str(),
1492 target_frame_.c_str(), ex.what());
1493 continue;
1494 }
1495 }
1496
1497 // Send ALL points to GPU - strided sampling happens in the kernel
1498 size_t num_points = msg->width * msg->height;
1499 const uint8_t* data_ptr = msg->data.data();
1500
1501 // Pass desired_points for strided sampling (0 = all points)
1502 const int desired_points = (fixed_points_per_input_cloud_ > 0) ? static_cast<int>(fixed_points_per_input_cloud_) : 0;
1503
1504 if (!cuda_context_->addCloud(data_ptr, num_points, rotation_matrix, translation, apply_transform, i, desired_points)) {
1505 RCLCPP_ERROR(this->get_logger(), "CUDA addCloud failed");
1506 continue;
1507 }
1508 }
1509
1510 // Get result directly into output vector
1511 auto output = std::make_unique<PointCloudMsg>();
1512 output->header.frame_id = target_frame_;
1513 rclcpp::Time chosen_stamp;
1514 switch (output_stamp_mode_) {
1515 case OutputStampMode::Earliest:
1516 chosen_stamp = timing.earliest_stamp;
1517 break;
1518 case OutputStampMode::Mean: {
1519 const auto delta = timing.latest_stamp - timing.earliest_stamp;
1520 chosen_stamp = timing.earliest_stamp + rclcpp::Duration::from_nanoseconds(delta.nanoseconds() / 2);
1521 break;
1522 }
1523 case OutputStampMode::Input0:
1524 chosen_stamp = timing.input0_stamp;
1525 break;
1526 case OutputStampMode::Latest:
1527 default:
1528 chosen_stamp = timing.latest_stamp;
1529 break;
1530 }
1531 output->header.stamp = chosen_stamp;
1532 output->height = 1;
1533 output->is_bigendian = is_bigendian;
1534 output->point_step = fused_point_step;
1535 output->fields = fused_fields;
1536 output->is_dense = true;
1537
1538 if (!cuda_context_->getBatchOutput(output->data, valid_point_count)) {
1539 RCLCPP_ERROR(this->get_logger(), "CUDA getBatchOutput failed");
1540 return nullptr;
1541 }
1542
1543 if (valid_point_count == 0) {
1544 return nullptr;
1545 }
1546
1547 output->width = valid_point_count;
1548 output->row_step = output->point_step * output->width;
1549
1550 return output;
1551}
1552#endif // ENABLE_CUDA
1553
1554} // namespace point_cloud_fusion
void validateInputTopicsParameter() const
Validate that the configured input topic list is usable.
static constexpr int32_t kStepSizeOutputQueueSize
static constexpr std::size_t kMaxInputTopics
static constexpr const char * kAllowedOutputStampModes
PointCloudMsg::UniquePtr fusePointCloudBatch(const std::vector< PointCloudMsg::ConstSharedPtr > &msgs, const FusionTiming &timing, std::size_t &valid_point_count) const
Fuse a synchronized point-cloud batch using the CPU path.
void validateRangeLimits()
Validate configured XYZ range limits and disable filtering when invalid.
std::vector< std::string > output_fields_
void handleSynchronizedPointClouds(const std::vector< sensor_msgs::msg::PointCloud2::ConstSharedPtr > &msgs)
Process synchronized point clouds.
void configureOutputStampMode(const std::string &mode)
Parse and store the configured output timestamp mode.
void declareAndLoadParameter(const std::string &name, T &param, const std::string &description, const bool add_to_auto_reconfigurable_params=true, const bool is_required=false, const bool read_only=false, const std::optional< double > &from_value=std::nullopt, const std::optional< double > &to_value=std::nullopt, const std::optional< double > &step_value=std::nullopt, const std::string &additional_constraints="")
Declares and loads a ROS parameter.
void setup()
Sets up subscribers, publishers, etc. to configure the node.
rclcpp::TimerBase::SharedPtr setup_timer_
Timer to delay setup.
OnSetParametersCallbackHandle::SharedPtr parameters_callback_
Callback handle for dynamic parameter reconfiguration.
static constexpr const char * kDefaultTransportHint
rcl_interfaces::msg::SetParametersResult parametersCallback(const std::vector< rclcpp::Parameter > &parameters)
Validate and apply dynamic parameter updates.
std::vector< std::string > input_transport_hints_
static constexpr int64_t kMaxFixedPointsPerInputCloud
static constexpr int32_t kStepSizeSyncQueueSize
std::shared_ptr< tf2_ros::Buffer > tf_buffer_
TF2 buffer and transform listener.
std::shared_ptr< tf2_ros::TransformListener > tf_listener_
static constexpr int64_t kMinFixedPointsPerInputCloud
bool collectTimingInfo(const std::vector< PointCloudMsg::ConstSharedPtr > &msgs, FusionTiming &timing) const
Collect timestamp statistics for synchronized input clouds.
void publishFusedCloud(PointCloudMsg::UniquePtr cloud, const FusionTiming &timing, std::size_t input_count, std::size_t total_points, std::chrono::steady_clock::time_point callback_start, std::chrono::steady_clock::time_point processing_start, std::chrono::steady_clock::time_point processing_end, const char *event_name)
Publish a fused point cloud and emit tracing/timing diagnostics.
static constexpr int64_t kStepSizeFixedPointsPerInputCloud
void setupSynchronizer()
Create the approximate-time synchronizer for a fixed number of input topics.
std::vector< std::tuple< std::string, std::function< void(const rclcpp::Parameter &)> > > auto_reconfigurable_params_
Auto-reconfigurable parameters for dynamic reconfiguration.
std::vector< rclcpp::CallbackGroup::SharedPtr > cloud_subscriber_callback_groups_
std::shared_ptr< point_cloud_transport::Publisher > cloud_publisher_
Publisher.
std::vector< std::shared_ptr< point_cloud_transport::SubscriberFilter > > cloud_subscribers_
PointCloudFusion(const rclcpp::NodeOptions &options)
Constructor.
void connectInputsImpl(SyncType< N > &sync, const std::vector< std::shared_ptr< point_cloud_transport::SubscriberFilter > > &subs, std::index_sequence< Is... >)
Connect subscriber filters to the synchronizer through an index sequence.
typename SyncPolicyTraits< N >::Policy SyncPolicy
message_filters::Synchronizer< SyncPolicy< N > > SyncType
void connectInputs(SyncType< N > &sync, const std::vector< std::shared_ptr< point_cloud_transport::SubscriberFilter > > &subs)
Connect all subscriber filters to the synchronizer for N inputs.
Timing metadata for one synchronized fusion batch.
message_filters::sync_policies::ApproximateTime< sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2 > Policy
message_filters::sync_policies:: ApproximateTime< sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2 > Policy
message_filters::sync_policies::ApproximateTime< sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2 > Policy
message_filters::sync_policies::ApproximateTime< sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2 > Policy
message_filters::sync_policies::ApproximateTime< sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2 > Policy
message_filters::sync_policies::ApproximateTime< sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2 > Policy
message_filters::sync_policies::ApproximateTime< sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2 > Policy
message_filters::sync_policies::ApproximateTime< sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2, sensor_msgs::msg::PointCloud2 > Policy