个人想将96线的激光雷达点云压缩成2d激光,于是使用到了ros的开源功能包pointcloud_to_laserscan,这几天的使用过程中也是遇到了一些小坑,于是对功能包中点云转激光部分进行了详细学习(该功能包中还有激光转点云但我暂时用不到所以没看)。
以下先附上pointcloud_to_laserscan的源码地址(这里我放的是humble版本的,其他版本的请点击进去后自己选择版本分支):
https://github.com/ros-perception/pointcloud_to_laserscan/tree/humble

我先从launch文件开始讲起,下面是我修改后的launch文件sample_pointcloud_to_laserscan_launch.py

其中注释掉的两个Node节点分别是发布模拟激光点云的节点和发布静态tf坐标的节点,这里我都不需要所以注释掉了

from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import Node


def generate_launch_description():
    return LaunchDescription([
        DeclareLaunchArgument(
            name='scanner', default_value='',
            description='Namespace for sample topics'
        ),
        # Node(
        #     package='pointcloud_to_laserscan', executable='dummy_pointcloud_publisher',
        #     remappings=[('cloud', [LaunchConfiguration(variable_name='scanner'), '/cloud'])],
        #     parameters=[{'cloud_frame_id': 'cloud', 'cloud_extent': 2.0, 'cloud_size': 500}],
        #     name='cloud_publisher'
        # ),
        # Node(
        #     package='tf2_ros',
        #     executable='static_transform_publisher',
        #     name='static_transform_publisher',
        #     arguments=[
        #         '--x', '0', '--y', '0', '--z', '0',
        #         '--qx', '0', '--qy', '0', '--qz', '0', '--qw', '1',
        #         '--frame-id', 'map', '--child-frame-id', 'cloud'
        #     ]
        # ),
        Node(
            package='pointcloud_to_laserscan', executable='pointcloud_to_laserscan_node',
            remappings=[('cloud_in', [LaunchConfiguration(variable_name='scanner'), '/rslidar_points']),
                        ('scan', [LaunchConfiguration(variable_name='scanner'), '/scan'])],
            parameters=[{
                'target_frame': 'cloud',
                'transform_tolerance': 0.01,
                'min_height': 0.0,
                'max_height': 1.0,
                'angle_min': -3.1415926,  # -M_PI
                'angle_max': 3.1415926,  # M_PI
                'angle_increment': 0.007,  # (2*M_PI/360.0)*0.4
                'time_increment': 0.000111, # (1/10)/(360/0.4)
                'scan_time': 0.1, #1s/频率
                'range_min': 0.1,
                'range_max': 60.0,
                'use_inf': True,
                'inf_epsilon': 1.0,
                'queue_size': 10,
                'target_frame':''
            }],
            name='pointcloud_to_laserscan'
        )
    ])

下面这段是启动点云转激光的节点,对应的源文件是pointcloud_to_laserscan.cpp

Node(
            package='pointcloud_to_laserscan', executable='pointcloud_to_laserscan_node',
            remappings=[('cloud_in', [LaunchConfiguration(variable_name='scanner'), '/rslidar_points']),
                        ('scan', [LaunchConfiguration(variable_name='scanner'), '/scan'])],

其中remapping是将该cpp文件中原来订阅的点云话题/cloud_in重映射为/‘scanner’/rslidar_points,其中’scanner’是launch文件上面声明的一个参数,默认值default_value我设置成了’',也就是空值,所以最终/cloud_in被重映射为的话题名为/rslidar_points,即我们要转换的点云数据所在的话题。同理第二行是将输出的话题重映射为/scan

DeclareLaunchArgument(
            name='scanner', default_value='',
            description='Namespace for sample topics'
        ),

下面这些是可以传入pointcloud_to_laserscan的参数,主要解释下每个参数的含义

parameters=[{
                'min_height': 0.0,
                 #点云过滤:选择要转换的点云的最低高度
                'max_height': 1.0,
                 #点云过滤:选择要转换点云的最高高度
                'angle_min': -3.1415926, 
                 # -M_PI #要转换的点云的角度范围最小值
                'angle_max': 3.1415926,  
                # M_PI	#要转换的点云的角度范围最大值(-π~π即360度,因为cpp中用的是atan2()函数计算的点云坐标对应的弧度,而这个函数的弧度范围为-π~π)
                'angle_increment': 0.007,  
                # (2*M_PI/360.0)*0.4 #转换后激光的角度分辨率,这个数值需要按照待转换的点云的实际水平角度分辨率来设置(单位是弧度),我这里的角度分辨率是0.4度因此转换为弧度制为0.007
                'time_increment': 0.000111, 
                # (1/10)/(360/0.4) #即同一个采样周期下每一个激光点的采样时间间隔,即(1s/雷达扫描频率)/(360/角度分辨率(单位是度))
                'scan_time': 0.1, 
                #1s/频率	#即采样周期,扫描一圈所花的时间,1s/扫描频率
                'range_min': 0.1,	
                #即雷达的最近可识别距离,一般按照雷达的最近视野盲区设置
                'range_max': 60.0,
                #即雷达最大的量程
                'use_inf': True,	
                #启用inf替换那些超出雷达扫描范围的值
                'inf_epsilon': 1.0, 
                #这个是当不启动use_inf时才会生效的参数,具体意思在cpp中是用range_max+inf_epsilon来表示超出测量范围的值
                'queue_size': 10,
                #设置tf2_ros::message_filters及订阅者sub的qos.keep_last的缓存队列长度,'queue_size':10即最多缓存10帧完整的点云数据
                'target_frame':'', 
                #要转换的目标tf坐标,这里一般设置为空即可,除非你需要将输出的激光坐标从原来的点云坐标转换到其他坐标系下(这个我试过处理时间很长!如果真要转换建议用CUDA处理这段转换tf的代码,cpu跑这个我只能说延迟很高)
                'transform_tolerance': 0.01, 
                #tf坐标转换的是兼容差,如果上面target_frame设置为空则这个值不会被真正使用
            }],

下面对pointcloud_to_laserscan.cpp进行解析

这里是对构造函数的定义声明,创建了一个名为"pointcloud_to_laserscan"的节点,其中options的含义如下

功能示例
设置参数文件加载 .yaml 参数
设置命名空间robot1/scan
Remap topic话题重映射
是否启用参数服务allow_undeclared_parameters = true
自动声明参数在构造函数中自动声明参数

总的来说就是允许我们进行外部参数传入,话题重映射等功能

PointCloudToLaserScanNode::PointCloudToLaserScanNode(const rclcpp::NodeOptions & options)
: rclcpp::Node("pointcloud_to_laserscan", options)

下面这段就是参数的声明和传入,这里传入的参数就是我们launch.py中设置的parameters里的各种参数,每一行都是先声明参数名然后给他一个默认值,如果launch.py中有这个参数则会用launch.py中的参数值替换对应的默认值

target_frame_ = this->declare_parameter("target_frame", "");
  tolerance_ = this->declare_parameter("transform_tolerance", 0.01);
  // TODO(hidmic): adjust default input queue size based on actual concurrency levels
  // achievable by the associated executor
  input_queue_size_ = this->declare_parameter(
    "queue_size", static_cast<int>(std::thread::hardware_concurrency()));
  min_height_ = this->declare_parameter("min_height", std::numeric_limits<double>::min());
  max_height_ = this->declare_parameter("max_height", std::numeric_limits<double>::max());
  angle_min_ = this->declare_parameter("angle_min", -M_PI);
  angle_max_ = this->declare_parameter("angle_max", M_PI);
  angle_increment_ = this->declare_parameter("angle_increment", M_PI / 180.0);
  time_increment_ = this->declare_parameter("time_increment",0.0);
  scan_time_ = this->declare_parameter("scan_time", 1.0 / 30.0);
  range_min_ = this->declare_parameter("range_min", 0.0);
  range_max_ = this->declare_parameter("range_max", std::numeric_limits<double>::max());
  inf_epsilon_ = this->declare_parameter("inf_epsilon", 1.0);
  use_inf_ = this->declare_parameter("use_inf", true);

这里就是创建一个名为/scan的laserscan消息类型的发布者,当然这里的/scan会被我们在launch.py中remappings里重映射的话题名取代,不过我remappings里重映射后的话题名的还是是/scan(你们可以根据自己需求重映射为其他话题名)

pub_ = this->create_publisher<sensor_msgs::msg::LaserScan>("scan", rclcpp::SensorDataQoS());

launch.py中对应的重映射代码段

remappings=[('cloud_in', [LaunchConfiguration(variable_name='scanner'), '/rslidar_points']),
                        ('scan', [LaunchConfiguration(variable_name='scanner'), '/scan'])],

允许我们直接使用_1占位符

using std::placeholders::_1;

下面这段就是一个message_filters和tf2_ros::MessageFilter的消息过滤处理,主要为了同步tf坐标、消息时间等信息,只有当target_frame这个参数非空的时候才会进入这个if语句调用消息过滤器

// if pointcloud target frame specified, we need to filter by transform availability
  if (!target_frame_.empty()) {

    //debug0耗时检测
    auto start0 = std::chrono::high_resolution_clock::now();

    tf2_ = std::make_unique<tf2_ros::Buffer>(this->get_clock());

    auto timer_interface = std::make_shared<tf2_ros::CreateTimerROS>(
      this->get_node_base_interface(), this->get_node_timers_interface());
    
    tf2_->setCreateTimerInterface(timer_interface);
    
    tf2_listener_ = std::make_unique<tf2_ros::TransformListener>(*tf2_);
    
    message_filter_ = std::make_unique<MessageFilter>(
      sub_, *tf2_, target_frame_, input_queue_size_,
      this->get_node_logging_interface(),
      this->get_node_clock_interface());
    
      message_filter_->registerCallback(
      std::bind(&PointCloudToLaserScanNode::cloudCallback, this, _1));

    //debug0耗时检测
    auto end0 = std::chrono::high_resolution_clock::now();
    std::chrono::duration<double> transform_time = end0 - start0;
    RCLCPP_INFO(this->get_logger(), "Transform0 took %f seconds", transform_time.count());

  } 

其中下面这两段代码是我自己添加进去用来测试代码执行所耗时间的,因为我在使用target_frame时发现转换后的laserscan数据延迟很高且频率很低,但不设置target_frame即不进行tf坐标转换则数据延迟很低且频率与需要被转换的点云频率相当,不过这段测试了下所耗时间并不长,所以造成数据延迟频率低的原因不在这(后面我还有第二个耗时检测点之后讲到那了我再说)

 //debug0耗时检测
    auto start0 = std::chrono::high_resolution_clock::now();
//debug0耗时检测
    auto end0 = std::chrono::high_resolution_clock::now();
    std::chrono::duration<double> transform_time = end0 - start0;
    RCLCPP_INFO(this->get_logger(), "Transform0 took %f seconds", transform_time.count());

下面对if语句里的内容进行逐行解释(嘿嘿,我这里用ai解释的,我感觉很形象)

作用:创建一个 “坐标变换缓冲区”(tf2_ros::Buffer),用来存储机器人各个坐标系之间的位置关系(比如camera_link到base_link的平移和旋转)。
类比:就像一个 “仓库”,专门存放各种 “坐标变换” 的数据,需要时可以随时从中查询。
std::make_unique:一种安全管理内存的方式,不用手动释放这个 “仓库”,程序会自动处理。
tf2_ = std::make_unique<tf2_ros::Buffer>(this->get_clock());
作用:创建一个 “定时器工具”,帮助 “坐标变换缓冲区” 处理超时、过期的数据。
细节:括号里的参数是节点的基础接口,用来让定时器能和 ROS 2 的时间系统联动(比如知道当前时间,判断数据是否过期)。
类比:仓库里的 “管理员”,定期检查库存,清理过期的 “坐标变换” 数据。
auto timer_interface = std::make_shared<tf2_ros::CreateTimerROS>(
      this->get_node_base_interface(), this->get_node_timers_interface());
作用:把上面创建的 “定时器工具” 交给 “坐标变换缓冲区”(tf2_)。
效果:让缓冲区能自己管理数据的有效期(比如超过一定时间没更新的变换会被自动丢弃)。
tf2_->setCreateTimerInterface(timer_interface);
作用:创建一个 “坐标变换监听器”(tf2_ros::TransformListener),用来实时接收并更新 “坐标变换缓冲区” 的数据。
类比:就像一个 “快递员”,不断从机器人系统中接收新的 “坐标变换” 消息(比如机器人移动时,camera_link和base_link的关系变了),并把这些新消息存到 “仓库”(tf2_)里。
tf2_listener_ = std::make_unique<tf2_ros::TransformListener>(*tf2_);
作用:创建一个 “消息过滤器”(MessageFilter),用来 “拦截” 点云消息,确保只有当对应的坐标变换存在时,才让点云进入后续处理。
参数解释:
    sub_:点云消息的订阅者(即从哪里接收点云数据);
    *tf2_:刚才创建的 “坐标变换缓冲区”(用来查是否有需要的变换);
    target_frame_:目标坐标系(比如base_link,即我们希望点云最终转换到的坐标);
    input_queue_size_:最多缓存多少个点云消息(防止消息堆积);
    后面两个参数是日志和时间接口,用于打印信息和处理时间。
类比:就像一个 “门卫”,收到点云消息后,先去 “仓库”(tf2_)查有没有 “点云坐标→目标坐标” 的变换。如果有,就放消息进去处理;如果没有,就暂时把消息存起来等,超时了就扔掉。
message_filter_ = std::make_unique<MessageFilter>(
      sub_, *tf2_, target_frame_, input_queue_size_,
      this->get_node_logging_interface(),
      this->get_node_clock_interface());

其中sub_在头文件hpp里的定义如下,并非传统的rclcpp::Subscription<> sub_定义的订阅者sub_

普通订阅者(rclcpp::Subscription):
是 ROS 2 中最基础的消息订阅方式,仅负责独立接收单个话题的消息,不处理多话题间的同步问题。

message_filters::Subscriber:
是 message_filters 库提供的订阅者,本身不直接处理消息,而是作为 多话题消息同步的 “输入源”。它需要与同步器(如 TimeSynchronizer、ApproximateTimeSynchronizer)配合使用,实现多个话题消息的时间同步。

message_filters::Subscriber<sensor_msgs::msg::PointCloud2> sub_;
作用:给 “消息过滤器” 注册一个 “处理函数”(即cloudCallback)。
效果:当 “门卫”(message_filter_)确认点云和坐标变换都准备好了,就会调用cloudCallback函数,让点云进入下一步处理(比如转换为/scan)。
message_filter_->registerCallback(
      std::bind(&PointCloudToLaserScanNode::cloudCallback, this, _1));

总结一下if语句里到底干了啥事

当你设置了target_frame_(比如希望/scan的坐标是base_link),这段代码会:

建一个 “仓库”(tf2_)存坐标变换;
派一个 “快递员”(tf2_listener_)不断更新仓库的变换数据;
设一个 “门卫”(message_filter_),只有当点云对应的变换存在时,才让点云进入处理流程;
最后告诉 “门卫”:“数据准备好了就交给cloudCallback函数处理”。

这样就能保证后续转换出的/scan数据,坐标是正确的(和target_frame_一致)。

好了,else里的语句就比较简单了,就是直接给订阅者绑定一个回调函数PointCloudToLaserScanNode::cloudCallback,这里就不用经过消息过滤器的各种筛选等待了

else {  // otherwise setup direct subscription
    sub_.registerCallback(std::bind(&PointCloudToLaserScanNode::cloudCallback, this, _1));
  }

这里开启了一个独立线程,该线程会不断运行subscriptionListenerThreadLoop函数,那么这个函数是干嘛的呢,下面就解释下这段函数的具体定义内容

subscription_listener_thread_ = std::thread(
    std::bind(&PointCloudToLaserScanNode::subscriptionListenerThreadLoop, this));

这段代码是一个后台线程循环,作用是:根据激光雷达数据(/scan)的订阅情况,动态开启或关闭点云数据(cloud_in)的订阅,避免在没有订阅者时浪费资源接收和处理点云数据。
核心逻辑简单解释:

循环条件:只要节点正常运行(rclcpp::ok)且线程未被终止(alive_.load()),就持续执行。
检查订阅者数量:
    计算当前有多少节点在订阅pub_发布的/scan话题(包括进程内和进程外的订阅者)。
动态管理点云订阅:
    如果有订阅者(subscription_count > 0):
        若还没订阅点云数据(!sub_.getSubscriber()),就启动点云订阅(sub_.subscribe(...)),开始接收cloud_in话题的点云数据。
    如果没有订阅者(subscription_count == 0):
        若正在订阅点云数据(sub_.getSubscriber()),就关闭点云订阅(sub_.unsubscribe()),停止接收点云数据。
等待与超时:
    每次循环后等待 100 毫秒(timeout),或等待话题订阅关系变化(wait_for_graph_change),避免线程空转占用 CPU。

总结:
这个线程的作用是 “按需订阅”:只有当有人需要/scan数据时,才去接收cloud_in点云并处理;没人需要时就停止接收点云,节省带宽和计算资源。这是一种常见的资源优化策略。

void PointCloudToLaserScanNode::subscriptionListenerThreadLoop()
{
  rclcpp::Context::SharedPtr context = this->get_node_base_interface()->get_context();

  const std::chrono::milliseconds timeout(100);
  while (rclcpp::ok(context) && alive_.load()) {

    //get_subscription_count获取当前这个话题在其他节点(跨进程)中的订阅者数量 + get_intra_process_subscription_count获取当前这个话题在同一个进程内部的订阅者数量
    int subscription_count = pub_->get_subscription_count() +
      pub_->get_intra_process_subscription_count();
    
    //如果发布的/scan话题被订阅了
    if (subscription_count > 0) {
      if (!sub_.getSubscriber()) {
        RCLCPP_INFO(
          this->get_logger(),
          "Got a subscriber to laserscan, starting pointcloud subscriber");
        rclcpp::SensorDataQoS qos;
        qos.keep_last(input_queue_size_);
        sub_.subscribe(this, "cloud_in", qos.get_rmw_qos_profile());
      }
    } 
    
    else if (sub_.getSubscriber()) {
      RCLCPP_INFO(
        this->get_logger(),
        "No subscribers to laserscan, shutting down pointcloud subscriber");
      sub_.unsubscribe();
    }
    rclcpp::Event::SharedPtr event = this->get_graph_event();
    this->wait_for_graph_change(event, timeout);
  }
  sub_.unsubscribe();
}

下面开始对cloudCallback()这个核心的点云数据处理和laserscan消息发布的回调函数进行解析

回调函数传入的消息类型是sensor_msgs::msg::PointCloud2::ConstSharedPtr 即点云消息类型的常量共享指针

scan_msg->header = cloud_msg->header;将点云的header信息复制给激光数据帧
当target_frame非空时候会修改header里的frame_id属性为target_frame_,这里就是将数据帧和tf坐标联系了起来

void PointCloudToLaserScanNode::cloudCallback(
  sensor_msgs::msg::PointCloud2::ConstSharedPtr cloud_msg)
{
  // build laserscan output
  auto scan_msg = std::make_unique<sensor_msgs::msg::LaserScan>();
  scan_msg->header = cloud_msg->header;
  if (!target_frame_.empty()) {
    scan_msg->header.frame_id = target_frame_;
  }

下面我们先看下laserscan的sensor_msgs::msg::LaserScan消息类型的结构

Header header            # timestamp in the header is the acquisition time of 
                         # the first ray in the scan.
                         #
                         # in frame frame_id, angles are measured around 
                         # the positive Z axis (counterclockwise, if Z is up)
                         # with zero angle being forward along the x axis
                         
float32 angle_min        # start angle of the scan [rad]
float32 angle_max        # end angle of the scan [rad]
float32 angle_increment  # angular distance between measurements [rad]

float32 time_increment   # time between measurements [seconds] - if your scanner
                         # is moving, this will be used in interpolating position
                         # of 3d points
float32 scan_time        # time between scans [seconds]

float32 range_min        # minimum range value [m]
float32 range_max        # maximum range value [m]

float32[] ranges         # range data [m] (Note: values < range_min or > range_max should be discarded)
float32[] intensities    # intensity data [device-specific units].  If your
                         # device does not provide intensities, please leave
                         # the array empty.

其中Header的结构如下

# sequence ID: consecutively increasing ID 
uint32 seq
#Two-integer timestamp that is expressed as:
# * stamp.sec: seconds (stamp_secs) since epoch (in Python the variable is called 'secs')
# * stamp.nsec: nanoseconds since stamp_secs (in Python the variable is called 'nsecs')
# time-handling sugar is provided by the client library
time stamp
#Frame this data is associated with
string frame_id

继续回到我们cloudCallback()函数的讲解

看了上面的消息结构,我们就很清楚了这里就是一系列的赋值操作

scan_msg->angle_min = angle_min_;
  scan_msg->angle_max = angle_max_;
  scan_msg->angle_increment = angle_increment_;
  scan_msg->time_increment = time_increment_ ; 
  scan_msg->scan_time = scan_time_;
  scan_msg->range_min = range_min_;
  scan_msg->range_max = range_max_;

这里通过最大角度-最小角度再除以角度分辨率算出一帧数据中ranges[]数组该设置为多大

之后就是一个条件判断是否用inf来给ranges[]数组赋初始值,在后面的遍历点云的过程中初始值会慢慢的被真实值所取代,所以剩下的数据位都是空值,这些空值用inf来代替或者用range_max + inf_epsilon_来代替

// determine amount of rays to create
  uint32_t ranges_size = std::ceil(
    (scan_msg->angle_max - scan_msg->angle_min) / scan_msg->angle_increment);

  // determine if laserscan rays with no obstacle data will evaluate to infinity or max_range
  if (use_inf_) {
    scan_msg->ranges.assign(ranges_size, std::numeric_limits<double>::infinity());
  } else {
    scan_msg->ranges.assign(ranges_size, scan_msg->range_max + inf_epsilon_);
  }

**

这里就是一段很关键的代码了,也是我们这整个节点中运行时最耗时间和算力资源的地方

**
这里的条件判断语句只有当设置了target_frame且target_frame和点云的frame_id不同时才会进入,而其中target_frame就是要转换到的tf坐标系

我通过debug1计算出来的每帧点云的坐标转换需要花费0.25s左右,这也是为什么最终10hz的点云数据转换出了4hz的激光数据

建议真的要进行坐标转换的请将这段代码用cuda实现,让他跑在gpu上而非cpu
(当然我不会cuda所以还没改过,要是有哪位大佬改了cuda版本的请一定要@小弟我!!!)

tf2_->transform()是使用 tf2 将点云消息 cloud_msg 转换到 target_frame_ 坐标系,结果存储在cloud里,

其中tolerance_是当请求变换的时间点数据不存在,它允许在 [time - tolerance, time + tolerance] 范围内插值或找到最接近的变换。

cloud_msg = cloud 是将结果重新赋值给 cloud_msg

// Transform cloud if necessary
  if (scan_msg->header.frame_id != cloud_msg->header.frame_id) {
    try {

      //debug1耗时检测
      auto start = std::chrono::high_resolution_clock::now();

      auto cloud = std::make_shared<sensor_msgs::msg::PointCloud2>();
      tf2_->transform(*cloud_msg, *cloud, target_frame_, tf2::durationFromSec(tolerance_));
      cloud_msg = cloud;

      //debug1耗时检测
      auto end = std::chrono::high_resolution_clock::now();
      std::chrono::duration<double> transform_time = end - start;
      RCLCPP_INFO(this->get_logger(), "Transform1 took %f seconds", transform_time.count());

    } catch (tf2::TransformException & ex) {
      RCLCPP_ERROR_STREAM(this->get_logger(), "Transform failure: " << ex.what());
      return;
    }
  }

下面这段就是核心的点云转激光的过程了,下面我们分段讲解

// Iterate through pointcloud
  for (sensor_msgs::PointCloud2ConstIterator<float> iter_x(*cloud_msg, "x"),
    iter_y(*cloud_msg, "y"), iter_z(*cloud_msg, "z");
    iter_x != iter_x.end(); ++iter_x, ++iter_y, ++iter_z)
  {
    if (std::isnan(*iter_x) || std::isnan(*iter_y) || std::isnan(*iter_z)) {
      RCLCPP_DEBUG(
        this->get_logger(),
        "rejected for nan in point(%f, %f, %f)\n",
        *iter_x, *iter_y, *iter_z);
      continue;
    }

    if (*iter_z > max_height_ || *iter_z < min_height_) {
      RCLCPP_DEBUG(
        this->get_logger(),
        "rejected for height %f not in range (%f, %f)\n",
        *iter_z, min_height_, max_height_);
      continue;
    }

    double range = hypot(*iter_x, *iter_y);
    if (range < range_min_) {
      RCLCPP_DEBUG(
        this->get_logger(),
        "rejected for range %f below minimum value %f. Point: (%f, %f, %f)",
        range, range_min_, *iter_x, *iter_y, *iter_z);
      continue;
    }
    if (range > range_max_) {
      RCLCPP_DEBUG(
        this->get_logger(),
        "rejected for range %f above maximum value %f. Point: (%f, %f, %f)",
        range, range_max_, *iter_x, *iter_y, *iter_z);
      continue;
    }

    double angle = atan2(*iter_y, *iter_x);
    if (angle < scan_msg->angle_min || angle > scan_msg->angle_max) {
      RCLCPP_DEBUG(
        this->get_logger(),
        "rejected for angle %f not in range (%f, %f)\n",
        angle, scan_msg->angle_min, scan_msg->angle_max);
      continue;
    }

    // overwrite range at laserscan ray if new range is smaller
    int index = (angle - scan_msg->angle_min) / scan_msg->angle_increment;
    if (range < scan_msg->ranges[index]) {
      scan_msg->ranges[index] = range;
    }
  }
  pub_->publish(std::move(scan_msg));
}

这段代码使用了 ROS 2 提供的 sensor_msgs::PointCloud2ConstIterator 来遍历点云数据中的每个点的 x, y, z 坐标,创建了 3 个迭代器,分别指向点云中每个点的 “x”, “y”, “z” 字段

iter_x != iter_x.end(); 这句是当迭代到最后一个点之后时终止循环的意思,iter_x.end()指的是最后一个点之后的位置,为开区间

++iter_x, ++iter_y, ++iter_z就是递增迭代后面的点

for (sensor_msgs::PointCloud2ConstIterator<float> iter_x(*cloud_msg, "x"),
    iter_y(*cloud_msg, "y"), iter_z(*cloud_msg, "z"); //初始化参数
    iter_x != iter_x.end(); //循环终止条件
    ++iter_x, ++iter_y, ++iter_z) //递增迭代

点云只要有一个轴坐标不完整则跳过这个点的处理并报DEBUG

if (std::isnan(*iter_x) || std::isnan(*iter_y) || std::isnan(*iter_z)) {
      RCLCPP_DEBUG(
        this->get_logger(),
        "rejected for nan in point(%f, %f, %f)\n",
        *iter_x, *iter_y, *iter_z);
      continue;
    }

这里默认你的点云数据的z轴是垂直地面的,即z轴直接表示高度,如果这里你点云的z轴不垂直地面一定要先用target_frame转换到一个z轴垂直于地面的tf坐标系下!!

这句的逻辑判断就是高度超出范围的点不做处理直接跳过

if (*iter_z > max_height_ || *iter_z < min_height_) {
      RCLCPP_DEBUG(
        this->get_logger(),
        "rejected for height %f not in range (%f, %f)\n",
        *iter_z, min_height_, max_height_);
      continue;
    }

这里的hypot(x,y)就是利用x和y勾股定理计算欧几里德距离的,当然这里也是预先设想了(x,y)平面是平行于地面的,然后进行简单的逻辑判断该距离是否在采样距离范围内,不在范围内则直接跳过该点云的处理

double range = hypot(*iter_x, *iter_y);
    if (range < range_min_) {
      RCLCPP_DEBUG(
        this->get_logger(),
        "rejected for range %f below minimum value %f. Point: (%f, %f, %f)",
        range, range_min_, *iter_x, *iter_y, *iter_z);
      continue;
    }
    if (range > range_max_) {
      RCLCPP_DEBUG(
        this->get_logger(),
        "rejected for range %f above maximum value %f. Point: (%f, %f, %f)",
        range, range_max_, *iter_x, *iter_y, *iter_z);
      continue;
    }

这里是利用atan2(y,x)计算点(x,y)相对于x轴的极角(方向角),单位是弧度(radians),范围是 −π 到 +π

当角度不在采样范围内则该点云不做处理直接跳过

double angle = atan2(*iter_y, *iter_x);
    if (angle < scan_msg->angle_min || angle > scan_msg->angle_max) {
      RCLCPP_DEBUG(
        this->get_logger(),
        "rejected for angle %f not in range (%f, %f)\n",
        angle, scan_msg->angle_min, scan_msg->angle_max);
      continue;
    }

index是点云转换后的laserscan距离信息插入ranges[]数组的索引号,这是通过
(该点云所处极角-最小角度)/角度分辨率 得到的,

因为ranges[0]默认是存储angle_min角度的距离数据,每比angle_min大一个角度分辨率(angle_increment)的点的索引号就是0+1即ranges[1],大n个角度分辨率则是ranges[n]

之后if条件判断语句里判断当前距离值是否比已经存储在该数据位里的距离小,如果更小则替代原来的距离值

这个判断用于保留最小的距离值,因为激光雷达在每个方向上只返回最近的物体的距离

// overwrite range at laserscan ray if new range is smaller
    int index = (angle - scan_msg->angle_min) / scan_msg->angle_increment;
    if (range < scan_msg->ranges[index]) {
      scan_msg->ranges[index] = range;
    }

使用 std::move(scan_msg),目的是把 scan_msg 的资源直接交给 publish(),避免复制,提高性能。
std::move(x) 不会真的移动内存

它只是告诉编译器:“我不再用 x 了,你可以偷它的资源”

一般用在临时对象、一次性对象、大对象传输

被 std::move() 后的变量,不能再使用(状态可能已被清空)

pub_->publish(std::move(scan_msg));

这两行代码是 ROS 2 中用于将节点注册为可动态加载组件的标准写法,主要用于支持 “组件式节点”(通过 rclcpp_components 机制动态加载,而非作为独立进程运行)

在 ROS 2 中,节点有两种运行方式:

独立进程:编译为可执行文件,通过 ros2 run 启动(需要在 CMakeLists.txt 中定义 add_executable)。
组件式:通过上述宏注册为组件,编译为共享库(.so/.dll),可被 component_container 动态加载(多个组件可运行在同一进程中)。

这两行代码正是实现 “组件式节点” 的关键,让 PointCloudToLaserScanNode 支持动态加载,提升节点部署的灵活性。

#include "rclcpp_components/register_node_macro.hpp"

RCLCPP_COMPONENTS_REGISTER_NODE(pointcloud_to_laserscan::PointCloudToLaserScanNode)

最后,就只剩下析构函数了!
这段的作用是在节点对象销毁时,安全地终止后台线程并释放资源,避免线程残留导致的程序异常。
alive_.store(false);

alive_ 是一个原子布尔变量(通常为 std::atomic<bool>),用于控制后台线程的循环是否继续。
store(false) 将其设为 false,会让 subscriptionListenerThreadLoop 线程中的循环条件(while (rclcpp::ok(context) && alive_.load()))不满足,从而退出循环,终止线程的核心逻辑。

subscription_listener_thread_.join();

subscription_listener_thread_ 是之前创建的后台线程对象(std::thread 类型)。
join() 函数会阻塞当前线程(通常是主线程),等待 subscription_listener_thread_ 线程完全执行完毕后再继续,确保线程资源被正确回收,避免出现 “僵尸线程” 或资源泄漏。

核心目的:

先 “通知” 后台线程退出循环(alive_.store(false));
再等待线程彻底结束(join())。

保证线程安全退出,避免线程在对象已销毁后仍访问其成员变量(可能导致内存错误或程序崩溃)。

PointCloudToLaserScanNode::~PointCloudToLaserScanNode()
{
  alive_.store(false);
  subscription_listener_thread_.join();
}

pointcloud_to_laserscan的解析就先到这了,最后,请有用cuda加速tf坐标变换那一步的大佬一定要@我!!!

Logo

立足具身智能前沿赛道,致力于搭建全球化、开源化、全栈式技术交流与实践共创平台。

更多推荐