☰
ROS激光雷达点云地面分割:ray filter节点实现与调参避坑
2026/11/4 18:45:22 网站建设 项目流程

简介:面向激光雷达感知方向的 ROS 开发者与学习者,这套代码给出一个用 Ray Filter 完成点云地面分割的可编译节点示例:订阅雷达点云话题,转为 PCL 格式后滤除地面点,再发布非地面点云,可用于自动驾驶与移动机器人的环境感知环节,适合已具备 ROS 基础、想理解地面过滤实现细节的中级读者。压缩包共 8 个文件、约 4KB,由 2 个 cpp 源文件与 1 个头文件承载节点逻辑与算法核心,CMakeLists.txt 与 package.xml 负责 catkin 编译与依赖声明,launch 文件与 txt 说明给出启动与参数配置入口,另有 json 文件保存编辑器环境配置。已有 7715 人学习。节点与算法分层清晰,便于替换射线长度、地面阈值等参数做对比实验,也可直接嵌入自有工程验证过滤效果。

1. 从一帧雷达点云说起:这个 ROS 节点到底在过滤什么

把小车停在坡道边,雷达扫出去的第一帧点云里,地面、路沿和轮胎几乎连成一片,直接拿去做障碍物检测,十有八九会把坡面当成墙。这个 ROS 节点干的事很具体:订阅一帧 PointCloud2,用 PCL 做坐标转换和点云解析,再按 ray filter 的思路把地面点剥出来,分别发布地面点和障碍物点。它适合正在做低速无人车、巡检机器人或雷达感知原型的开发者,尤其是想先跑通地面分割、再慢慢替换成自己算法的人。

2. 节点架构与数据流:从 PointCloud2 到地面点/障碍物点

这个节点不是一个孤立的算法文件,而是一个标准的 catkin 功能包。它的输入是雷达驱动或 rosbag 发布的sensor_msgs/PointCloud2,输出通常有两路:地面点云和非地面点云。中间还要经过 TF 坐标变换、PCL 点云转换、ray filter 过滤、再转回 ROS 消息。链路不长,但每一段都有容易忽略的细节,尤其是坐标系和点云字段。

2.1 功能包目录与依赖

我一般会把功能包拆成下面这样,方便后面加参数和调试工具:

ray_ground_filter/ ├── CMakeLists.txt ├── package.xml ├── config/ │ └── ray_filter_params.yaml ├── include/ │ └── ray_ground_filter/ │ ├── ray_filter.h │ └── ray_filter_node.h ├── launch/ │ └── ray_ground_filter.launch └── src/ ├── ray_filter.cpp └── ray_filter_node.cpp

include/放算法类和节点类的头文件,src/放实现,config/放可调参数,launch/负责把节点、参数和话题 remap 串起来。常见做法是把 ray filter 单独封装成一个类,节点类只负责 ROS 通信,这样以后换算法不用动订阅发布逻辑。

依赖上至少要有roscpp、sensor_msgs、pcl_ros、pcl_conversions、tf或tf2_ros。如果要做在线调参,再加dynamic_reconfigure。CMakeLists.txt里对应写:

find_package(catkin REQUIRED COMPONENTS roscpp sensor_msgs pcl_ros pcl_conversions tf ) find_package(PCL REQUIRED) catkin_package( INCLUDE_DIRS include CATKIN_DEPENDS roscpp sensor_msgs pcl_ros pcl_conversions tf DEPENDS PCL ) include_directories( include ${catkin_INCLUDE_DIRS} ${PCL_INCLUDE_DIRS} ) add_executable(ray_ground_filter_node src/ray_ground_filter_node.cpp src/ray_filter.cpp ) target_link_libraries(ray_ground_filter_node ${catkin_LIBRARIES} ${PCL_LIBRARIES} )

这段 CMake 的关键点不是语法,而是把 PCL 和 catkin 的依赖同时链上。pcl_ros负责 ROS 消息和 PCL 点云之间的转换,pcl_conversions负责 header 和字段的兼容处理。少一个,编译时可能只报一个很模糊的链接错误,排查起来很费时间。

2.2 回调链路:订阅、转换、滤波、发布

节点主循环其实就是一个回调。订阅输入话题,收到点云后先转成 PCL 类型,再统一到base_frame,然后调用 ray filter,最后把地面点和障碍物点分别发布出去。

void RayGroundFilterNode::cloudCallback( const sensor_msgs::PointCloud2ConstPtr& msg) { pcl::PointCloud<pcl::PointXYZI>::Ptr cloud( new pcl::PointCloud<pcl::PointXYZI>); pcl::fromROSMsg(*msg, *cloud); // 统一到车体坐标系,避免雷达安装俯仰角影响坡度判断 pcl::PointCloud<pcl::PointXYZI>::Ptr cloud_base( new pcl::PointCloud<pcl::PointXYZI>); if (!pcl_ros::transformPointCloud(base_frame_, *cloud, *cloud_base, tf_listener_)) { ROS_WARN_THROTTLE(1.0, "TF transform failed: %s -> %s", msg->header.frame_id.c_str(), base_frame_.c_str()); return; } pcl::PointCloud<pcl::PointXYZI>::Ptr ground( new pcl::PointCloud<pcl::PointXYZI>); pcl::PointCloud<pcl::PointXYZI>::Ptr obstacle( new pcl::PointCloud<pcl::PointXYZI>); ray_filter_.filter(cloud_base, ground, obstacle); sensor_msgs::PointCloud2 ground_msg; sensor_msgs::PointCloud2 obstacle_msg; pcl::toROSMsg(*ground, ground_msg); pcl::toROSMsg(*obstacle, obstacle_msg); // header 沿用输入,保证 RViz 里和原始点云对齐 ground_msg.header = msg->header; obstacle_msg.header = msg->header; ground_pub_.publish(ground_msg); obstacle_pub_.publish(obstacle_msg); }

这段代码有几个参数要注意:base_frame_是车体坐标系,常见是base_link;tf_listener_要在节点初始化时构造好;ROS_WARN_THROTTLE是为了避免 TF 失败时刷屏。回调里没有做降采样,如果雷达点云很大,后面要么在回调里加体素滤波,要么在 ray filter 内部做抽稀,不然高频雷达很容易把单核 CPU 吃满。

2.3 核心数据结构与参数表

ray filter 的参数不多,但每一个都直接影响过滤效果。下面这张表是我在实际调试时最常改的几项:

参数含义常见取值影响
sensor_height雷达安装高度1.5~2.0 m用于估计地面起始点,偏差大时地面残留明显
min_range最小处理半径0.5~1.0 m太近的点容易受车体遮挡,通常直接丢弃
max_range最大处理半径30~50 m远处点稀疏,坡度判断不稳定,限制范围能减少误判
angle_resolution_deg射线角度分辨率0.2~1.0°越小射线越密,计算量越大,但同一射线内点更连续
max_slope_deg最大地面坡度5~15°越大越容易把坡面当地面,越小越容易把地面当障碍
radial_resolution径向分段分辨率0.1~0.5 m用于控制同一射线内相邻点的距离间隔

input_topic和base_frame一般放在 launch 文件里作为 arg 传入,方便换雷达时不用改代码。sensor_height不是所有 ray filter 实现都强制使用,但如果有这个参数,最好让它的符号和坐标系一致:雷达在车体上方,sensor_height通常取正值,判断地面时再用z的负方向做参考。

3. ray filter 算法拆解:按射线排序后怎么判断地面

ray filter 的核心思想不复杂:把三维点云按水平方位角切成一条条射线,在每条射线内部按距离从近到远排序,然后看相邻两个点之间的高度差和水平距离。如果坡度足够小,就认为后一个点仍然在地面上;如果坡度突然变大,就认为遇到了障碍物。它比直接按高度阈值切更稳,因为坡道和地面起伏不会被一刀切掉。

3.1 极坐标分箱:把点云切成一条条射线

先算每个点的水平半径和方位角:

float radius = std::sqrt(p.x * p.x + p.y * p.y); float azimuth = std::atan2(p.y, p.x); if (azimuth < 0) azimuth += 2 * M_PI; int ray_id = static_cast<int>(azimuth / angle_res) % ray_num;

ray_num通常等于2π / angle_res。比如角度分辨率取 0.5°,大约会得到 720 条射线。每条射线内部再按radius排序,这样相邻点就是同一方向上由近及远的点。这里的关键参数是angle_resolution_deg:太大时一条射线里混入不同方向的点,坡度判断会失真;太小时很多射线只有一两个点,反而无法形成连续判断。

分箱时还要过滤无效点和超范围点:

if (!std::isfinite(p.x) || !std::isfinite(p.y) || !std::isfinite(p.z)) { continue; } if (radius < min_range_ || radius > max_range_) { continue; }

雷达点云里出现NaN或Inf并不罕见,尤其是雨雾、镜面反射或驱动解析异常时。如果不先过滤,后面排序和坡度计算都会出问题。

3.2 相邻点斜率判据与地面标记

同一射线内,相邻两点的坡度可以写成:

float dr = curr.radius - prev.radius; float dz = curr.z - prev.z; float slope = std::atan2(std::abs(dz), dr) * 180.0f / M_PI;

如果slope < max_slope_deg_,就把当前点标记为地面。这个判据的物理含义很直接:地面在径向上是连续缓变的,障碍物会在某个距离处突然抬高。实际实现里通常会把每条射线的第一个点默认当作地面种子点,或者用sensor_height估计一个初始地面高度。种子点选错,后面整条射线都会跟着错,所以第一条射线的处理要额外小心。

一个简化版的射线过滤函数如下:

void RayFilter::filter(const pcl::PointCloud<pcl::PointXYZI>::Ptr& cloud, pcl::PointCloud<pcl::PointXYZI>::Ptr& ground, pcl::PointCloud<pcl::PointXYZI>::Ptr& obstacle) { const float angle_res = M_PI / 180.0f * angle_resolution_deg_; const int ray_num = static_cast<int>(std::ceil(2 * M_PI / angle_res)); std::vector<std::vector<RayPoint>> rays(ray_num); for (size_t i = 0; i < cloud->size(); ++i) { const auto& p = cloud->points[i]; if (!std::isfinite(p.x) || !std::isfinite(p.y) || !std::isfinite(p.z)) { continue; } float radius = std::sqrt(p.x * p.x + p.y * p.y); if (radius < min_range_ || radius > max_range_) { continue; } float azimuth = std::atan2(p.y, p.x); if (azimuth < 0) azimuth += 2 * M_PI; int ray_id = static_cast<int>(azimuth / angle_res) % ray_num; rays[ray_id].push_back({radius, p.z, static_cast<int>(i)}); } std::vector<bool> is_ground(cloud->size(), false); for (auto& ray : rays) { if (ray.size() < 2) { continue; } std::sort(ray.begin(), ray.end(), [](const RayPoint& a, const RayPoint& b) { return a.radius < b.radius; }); // 每条射线的第一个点作为地面种子 is_ground[ray.front().index] = true; for (size_t j = 1; j < ray.size(); ++j) { float dr = ray[j].radius - ray[j - 1].radius; float dz = ray[j].z - ray[j - 1].z; if (dr < 1e-3) { continue; } float slope = std::atan2(std::abs(dz), dr) * 180.0f / M_PI; if (slope < max_slope_deg_) { is_ground[ray[j].index] = true; } } } for (size_t i = 0; i < cloud->size(); ++i) { if (is_ground[i]) { ground->push_back(cloud->points[i]); } else { obstacle->push_back(cloud->points[i]); } } }

这段代码里,RayPoint只存了半径、高度和原始索引,避免在排序时搬动整个点。is_ground用vector<bool>按原始点索引标记,最后再一次性分流。这样写比在射线内部直接push_back更清晰,也方便后面加“地面点二次校验”或“障碍物聚类”。

3.3 参数怎么调:坡度阈值、径向分辨率和角度分辨率

max_slope_deg是最敏感的参数。坡道场景下,如果设得太小,坡面会被切成障碍物,表现为前方出现一堵“虚拟墙”;如果设得太大,路沿和低矮障碍物又会被当成地面,导致漏检。我一般会从 8° 左右开始,用 rosbag 回放,观察坡道和路沿两处的分流效果,再小步调整。

radial_resolution更多是性能参数。它不直接参与上面的简化代码,但在一些实现里会把同一射线按径向距离再分段,避免相邻点距离太远导致坡度计算失真。雷达线束越稀,径向分辨率越要保守,不能为了省计算量把分段拉得太大。

angle_resolution_deg和雷达的水平角分辨率有关。机械式雷达常用 0.2°~0.4°,如果节点里设成 1°,一条射线里会混入多个水平方向的点,地面判断会变得粗糙。反过来,设得比雷达本身还细,只会产生大量空射线,白白增加遍历开销。

4. 跑起来:编译、launch 文件与 rosbag 验证

拿到功能包后,最省事的验证方式不是直接上车,而是先放一段 rosbag。bag 里要有原始点云和 TF,最好还有车辆静止、坡道、路沿、行人经过几段场景。先确保节点能稳定输出两路点云,再谈参数调优。

4.1 依赖安装与 catkin 编译

在 ROS1 环境里,先装 PCL 相关依赖:

sudo apt update sudo apt install ros-$ROS_DISTRO-pcl-ros \ ros-$ROS_DISTRO-pcl-conversions \ ros-$ROS_DISTRO-tf

然后把功能包放进工作空间:

mkdir -p ~/catkin_ws/src cd ~/catkin_ws/src # 把 ray_ground_filter 功能包放到这里 cd ~/catkin_ws catkin_make -DCMAKE_BUILD_TYPE=Release source devel/setup.bash

Release编译很重要。ray filter 里有点云遍历和排序,Debug 模式下帧率可能直接掉一半。编译完成后先用rospack find ray_ground_filter确认包路径,再检查rosrun ray_ground_filter ray_ground_filter_node能不能启动。如果启动就报找不到动态库,多半是CMakeLists.txt里 PCL 库没链全。

4.2 launch 文件关键配置

一个够用的 launch 文件如下:

<launch> <arg name="input_topic" default="/lidar_points" /> <arg name="base_frame" default="base_link" /> <node pkg="ray_ground_filter" type="ray_ground_filter_node" name="ray_ground_filter" output="screen"> <param name="input_topic" value="$(arg input_topic)" /> <param name="base_frame" value="$(arg base_frame)" /> <param name="sensor_height" value="1.8" /> <param name="min_range" value="0.8" /> <param name="max_range" value="30.0" /> <param name="angle_resolution_deg" value="0.5" /> <param name="max_slope_deg" value="8.0" /> <param name="radial_resolution" value="0.2" /> <remap from="~input" to="$(arg input_topic)" /> </node> </launch>

input_topic默认写成/lidar_points,实际使用时通过命令行覆盖,不要硬编码在代码里。base_frame要和 URDF 或 TF 树里的车体坐标系一致。sensor_height按实际安装高度填,不要照抄示例值。remap那行是为了让节点内部订阅~input,外部换话题时只改 arg 就行。

4.3 用 rosbag 和 RViz 看效果

启动顺序建议是:先roscore,再放 bag,最后启动节点。这样 TF 和点云都能及时进来。

roscore rosbag play your_bag.bag --clock roslaunch ray_ground_filter ray_ground_filter.launch \ input_topic:=/lidar_points base_frame:=base_link

RViz 里把 Fixed Frame 设成base_link,添加两个 PointCloud2:一个订阅地面点话题,一个订阅障碍物点话题。常见命名是/ground_cloud和/obstacle_cloud。把颜色设成绿色和红色,一眼就能看出坡面有没有被误切。如果点云在 RViz 里位置对不上,先别怀疑算法,先看 TF 和 header 时间戳。

4.4 TF 与话题检查

几个命令很实用:

rostopic hz /lidar_points rostopic hz /obstacle_cloud rosrun tf tf_echo base_link lidar_link rosrun tf view_frames

rostopic hz看输入输出频率是否匹配。如果输入 10 Hz、输出只有 3 Hz,说明处理太慢。tf_echo看雷达到车体的变换是否存在,尤其是sensor_height和俯仰角。view_frames会生成 TF 树 PDF,适合排查“点云飘到车顶”这类问题。TF 一旦缺失,节点里的transformPointCloud会直接失败,现象就是有输入没输出,日志里只有 throttled 的警告。

5. 避坑与排查:地面过滤翻车的几个常见现场

ray filter 本身不复杂,真正耗时间的是调参和坐标系。下面这几类问题我反复遇到过,基本都能按“现象 → 原因 → 解决”的顺序定位。

5.1 地面残留一大片,坡面却被削掉

现象:RViz 里地面点云只滤掉近处一小圈,远处地面还留在障碍物里;同时坡道被切成了一堵墙。

原因:max_slope_deg太小,坡道的坡度超过了阈值,被当成障碍物;而远处地面点稀疏,相邻点距离太大,坡度计算不稳定,反而没被标记为地面。

解决:先把max_range收到 20~30 m,避免远处稀疏点干扰;再把max_slope_deg从 8° 慢慢往上试,每次加 1°~2°。如果坡面仍然被切,检查sensor_height和 TF 的俯仰角,雷达安装低头时,地面在点云里本身就是斜的。

5.2 整帧点云几乎全被滤成地面

现象:障碍物话题几乎没点,地面话题里却包含了车辆、行人和路沿。

原因:种子点选错,或者max_slope_deg太大。每条射线第一个点如果落在障碍物上,后面所有点都可能被连续标记为地面;坡度阈值太大时,低矮障碍物的高度变化也被当成缓坡。

解决:不要无条件把第一个点当地面。可以用sensor_height估算初始地面高度,只把接近该高度的点作为种子;或者对第一条射线做人工检查。调参时先把max_slope_deg降到 5° 左右,确认障碍物能分出来,再逐步放宽。

5.3 TF 报错,点云位置飘忽

现象:节点日志里反复出现TF transform failed,RViz 里点云偶尔出现在车顶或地下。

原因:base_frame写错,或者雷达和车体之间的 TF 没有发布;也有可能是 bag 里的 TF 时间戳和点云时间戳差太多。

解决:用rosrun tf tf_echo base_link lidar_link确认变换存在;检查 launch 里的base_frame是否和 URDF 一致;播放 bag 时加--clock,并确认/use_sim_time为 true。如果 TF 是静态的,可以用static_transform_publisher先补一条,但上车前一定要换成真实的标定结果。

5.4 大点云掉帧,输出频率只有输入的一半

现象:rostopic hz显示输入 10 Hz,输出只有 4~5 Hz,rviz 里点云一顿一顿。

原因:回调里做了全量点云转换和排序,没有降采样;vector<vector<RayPoint>>频繁分配内存;单线程处理大点云。

解决:先在回调里加体素滤波,把 64 线雷达降采样到 0.2~0.3 m;在 ray filter 里对rays的每个 vector 预留容量;把发布队列设小,避免积压。如果仍然不够,把过滤放到独立线程,回调只做浅拷贝。

5.5 多线雷达同一射线里的点太稀

现象:同一方位角上相邻点距离很大,坡度判断时有时无,地面点断断续续。

原因:ray filter 只按水平角分箱,没有利用雷达线束 ring 信息。多线雷达在远处同一方位角上的点可能来自不同线束,径向间隔很大。

解决:如果点云里保留了ring字段,可以按 ring 分别做射线过滤,再把结果合并;或者把angle_resolution_deg调细,让同一射线内的点更连续。常见做法是先用 ring 做粗分组,再在每组内按半径排序,这样比单纯调角度分辨率更稳。

6. 二次开发:把 ray filter 接到你的感知链路里

如果只是跑通,这个节点已经够用;真要接到项目里,下一步通常是加动态参数和双话题可视化。我一般会先加dynamic_reconfigure,把max_slope_deg、sensor_height、max_range暴露出来,用rqt_reconfigure在线拖,省得每次改 launch 再重启。代码上就是注册一个回调,在回调里更新RayFilter的参数,注意加锁,避免回调线程和点云回调同时改参数。

然后是把地面点和障碍物点分开发布,并在 RViz 里用不同颜色显示。地面点可以降采样后发布,障碍物点保留原始分辨率,方便后面做聚类。验证时不要只看一帧,用 bag 循环播放坡道、路沿、行人三段场景,分别记录误滤和漏滤的点数。我的习惯是每次改完参数,先跑一遍固定 bag,截三张 RViz 图对比,再决定要不要上车。从那以后我每次接新的雷达,都先把 TF、安装高度和坡度阈值这三件事过一遍,再谈调参。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询