1. 为什么自定义激光雷达接进 LIO-SAM 总是卡在 ring 和 time 上
LIO-SAM 这套激光惯性里程计方案,在开源社区里的口碑一直很稳,很多人拿它做室外建图、园区巡检、机器人导航的底座。但只要你手里的激光雷达不是官方示例里那几款(比如 Velodyne、Ouster 的常见型号),第一次跑起来大概率会撞上两个报错:一个是点云里找不到ring字段,另一个是time字段缺失或者时间戳对不上。这两个问题看起来只是“字段没填”,实际上牵扯到点云结构、驱动输出格式、LIO-SAM 内部对点云预处理逻辑的一整套假设。
我自己第一次把一台国产 16 线机械式激光雷达接到 LIO-SAM 上时,就卡了整整两天。驱动输出的PointCloud2里只有x y z intensity,既没有ring也没有time,LIO-SAM 的imageProjection节点一启动就报Failed to find match for field 'ring',直接退出。后来把字段补齐了,又遇到时间戳单位不对导致运动畸变校正完全失效,建出来的图飘得没法看。所以这篇内容就是把这套适配过程完整拆开讲清楚,从点云字段的含义、为什么 LIO-SAM 非要这两个字段、到具体怎么在驱动层或者中间层补上它们,再到参数怎么调、怎么验证,全部落到可复现的操作上。
适合谁看?如果你正在用非官方支持的激光雷达跑 LIO-SAM,或者你打算自己写驱动对接 LIO-SAM,又或者你只是想知道ring和time到底在 SLAM 里起什么作用,这篇都能直接拿去用。下面所有操作都基于 ROS1 环境(LIO-SAM 主流分支仍是 ROS1),ROS2 的适配思路一致,只是消息接口和 launch 写法不同,我会在关键处点出来。
2. 先把 ring 和 time 这两个字段的本质讲透
2.1 ring 到底是什么,为什么 LIO-SAM 离不开它
ring这个字段,直译是“环号”,它标识的是当前这个点属于激光雷达的哪一根线束。以常见的 16 线雷达为例,ring的取值范围就是 0 到 15,每个点都会带上自己所属线束的编号。机械式多线雷达在扫描时,16 个激光器同时旋转,每个激光器负责一个固定的俯仰角,扫出来的点自然就分属不同的“环”。
LIO-SAM 为什么非要这个字段?核心原因在于它的特征提取逻辑。LIO-SAM 的前端imageProjection会把一帧点云按ring分组,然后在每个 ring 内部计算点的曲率,据此判断这个点是边缘特征还是平面特征。如果你没有ring,它就没法把点正确地分配到各个线束上,曲率计算会跨线束乱算,特征提取直接失效。更直白地说,ring是 LIO-SAM 做“按线束组织点云”这个动作的前提。
很多人会问,那我能不能不要 ring,直接让它按角度分?理论上可以改代码,但工作量不小,而且会破坏 LIO-SAM 后续的很多假设。所以最省事、最稳的做法,还是在点云进 LIO-SAM 之前把ring补上。
2.2 time 字段的作用远不止“时间戳”三个字
time字段容易被误解成“这帧点云的时间戳”,其实不是。它记录的是单个点相对于这一帧起始时刻的偏移量,单位通常是秒。一帧点云不是瞬间采集完的,雷达转一圈需要时间,比如 10Hz 的雷达转一圈是 100ms,那么这一帧里第一个点和最后一个点的采集时刻差了接近 100ms。如果机器人在这 100ms 里移动了,点云就会产生运动畸变——近处的物体会被“拉斜”。
LIO-SAM 用time字段配合 IMU 数据做运动畸变校正(也就是去畸变)。它知道每个点的精确采集时刻,就能用 IMU 积分出来的位姿把每个点补偿到同一时刻的坐标系下。如果time缺失,LIO-SAM 要么直接报错,要么退化成不做去畸变,建图质量在机器人运动时就会明显下降。
这里有个关键细节:time的数值必须是相对于帧起始的偏移,不是绝对时间戳。有些驱动直接塞绝对时间戳进去,数值巨大,LIO-SAM 拿去算的时候会溢出或者算出离谱的补偿量。这一点后面会专门讲怎么处理。
2.3 两个字段缺失时 LIO-SAM 的具体报错表现
把常见报错整理成一张表,方便你对号入座:
| 报错信息 | 触发位置 | 根本原因 |
|---|---|---|
Failed to find match for field 'ring' | imageProjection 初始化 | 点云没有 ring 字段 |
Failed to find match for field 'time' | imageProjection 初始化 | 点云没有 time 字段 |
time field not found, skip deskew | 去畸变环节 | time 缺失,跳过校正 |
| 建图整体飘移、回环对不上 | 后端优化 | time 单位错误导致去畸变失效 |
| 点云呈放射状拉伸 | 可视化 | 运动畸变未校正 |
看到这些报错,基本就能定位到是字段问题,而不是算法本身的问题。
3. 适配方案的整体设计思路
3.1 三条可选路线:改驱动、加中间层、改 LIO-SAM
适配自定义雷达,本质上就是让进入 LIO-SAM 的点云带上正确的ring和time。实现路径有三条,各有取舍。
第一条是直接改雷达驱动,在驱动输出PointCloud2的时候就带上这两个字段。这是最干净的方案,因为字段在源头就对了,后续所有节点都能用。缺点是有些厂商驱动是闭源的,或者改起来涉及底层 SDK,门槛高。
第二条是加一个中间转换节点,订阅原始点云,补上字段后再转发给 LIO-SAM。这是最通用的方案,不动驱动也不动 LIO-SAM,适合绝大多数人。缺点是多一个节点,有轻微延迟,但实际影响可以忽略。
第三条是改 LIO-SAM 源码,让它接受没有这两个字段的点云。这是最不推荐的,因为会破坏 LIO-SAM 的核心逻辑,后续升级也麻烦。
我的建议是:能改驱动就改驱动,改不了就用中间层。下面重点讲中间层方案,因为它适用面最广,也最容易复现。
3.2 中间层方案的核心逻辑
中间层节点的逻辑其实不复杂:订阅原始PointCloud2,把它解析成点数组,为每个点计算ring和time,再打包成新的PointCloud2发布出去。难点在于怎么算这两个值。
ring的计算:如果雷达是机械式多线,每个点的俯仰角(垂直角度)和线束编号有固定对应关系。你可以根据点的z和水平距离sqrt(x²+y²)算出俯仰角,再对照雷达的线束角度表,反推出ring。如果是固态雷达或者非均匀线束,就得用雷达厂商提供的角度映射,或者用聚类的方式把点分到不同环上。
time的计算:机械式雷达通常是按方位角顺序出点的,一帧内点的顺序基本就是采集顺序。你可以用点的索引除以总点数,再乘以帧周期,得到相对时间偏移。更精确的做法是用方位角来算,因为方位角是单调递增的,用(azimuth - start_azimuth) / (2π) * frame_period更准。固态雷达没有旋转概念,time往往需要驱动直接提供,或者按点索引均匀分配。
3.3 方案选型的判断依据
怎么判断自己该用哪种方式算ring和time?看几个关键点:
- 雷达是不是机械旋转式?是的话,方位角单调,
time可以用方位角算。 - 线束是否均匀分布?是的话,
ring可以用俯仰角阈值划分。 - 驱动有没有暴露每个点的采集时刻?有的话直接用,最准。
- 帧率是否稳定?稳定的话,按索引分配
time误差可接受。
把这些判断清楚,方案就定了。
4. 手把手实现点云字段补全节点
4.1 环境准备与依赖确认
先确认你的环境。ROS1 下需要ros-noetic(或对应版本)的pcl_ros、sensor_msgs、pcl_conversions。如果你用 ROS2,对应的是pcl_conversions和sensor_msgs的 ROS2 版本,接口略有差异。
# ROS1 检查依赖 rospack find pcl_ros rospack find sensor_msgs如果缺,直接装:
sudo apt install ros-noetic-pcl-ros ros-noetic-pcl-conversions建一个工作空间和包:
mkdir -p ~/lio_adapt/src cd ~/lio_adapt/src catkin_create_pkg lidar_adapter roscpp sensor_msgs pcl_ros pcl_conversions4.2 定义带 ring 和 time 的点类型
PCL 自带的PointXYZI没有ring和time,所以要自定义点结构。新建头文件include/lidar_adapter/point_xyzi_ring_time.h:
#ifndef LIDAR_ADAPTER_POINT_XYZI_RING_TIME_H #define LIDAR_ADAPTER_POINT_XYZI_RING_TIME_H #define PCL_NO_PRECOMPILE #include <pcl/point_types.h> #include <pcl/point_cloud.h> #include <pcl/impl/point_types.hpp> struct EIGEN_ALIGN16 PointXYZIRingTime { PCL_ADD_POINT4D; float intensity; uint16_t ring; float time; EIGEN_MAKE_ALIGNED_OPERATOR_NEW }; POINT_CLOUD_REGISTER_POINT_STRUCT(PointXYZIRingTime, (float, x, x) (float, y, y) (float, z, z) (float, intensity, intensity) (uint16_t, ring, ring) (float, time, time) ) #endif这里有个坑要注意:PCL_ADD_POINT4D会引入x y z和一个 padding 字段,注册的时候只注册x y z就行,别把 padding 也注册进去,否则字段偏移会错。另外ring用uint16_t是为了和 LIO-SAM 内部期望的类型对齐,LIO-SAM 里ring就是uint16_t。
4.3 核心转换逻辑:从原始点云到带字段点云
主节点代码src/lidar_adapter_node.cpp,核心是订阅、转换、发布三段。先看转换函数:
void cloudCallback(const sensor_msgs::PointCloud2ConstPtr& msg) { // 1. 原始点云转 PCL pcl::PointCloud<pcl::PointXYZI>::Ptr raw(new pcl::PointCloud<pcl::PointXYZI>); pcl::fromROSMsg(*msg, *raw); // 2. 构造带字段的点云 pcl::PointCloud<PointXYZIRingTime>::Ptr out(new pcl::PointCloud<PointXYZIRingTime>); out->reserve(raw->size()); double frame_period = 0.1; // 10Hz 雷达,一帧 100ms size_t total = raw->size(); for (size_t i = 0; i < total; ++i) { const auto& p = raw->points[i]; PointXYZIRingTime q; q.x = p.x; q.y = p.y; q.z = p.z; q.intensity = p.intensity; // 计算 ring:按俯仰角划分 double r = std::sqrt(p.x*p.x + p.y*p.y); double pitch = std::atan2(p.z, r) * 180.0 / M_PI; q.ring = pitchToRing(pitch); // 计算 time:按索引均匀分配 q.time = static_cast<float>(frame_period * i / total); out->push_back(q); } // 3. 发布 sensor_msgs::PointCloud2 out_msg; pcl::toROSMsg(*out, out_msg); out_msg.header = msg->header; pub_.publish(out_msg); }pitchToRing是俯仰角到线束号的映射,机械式 16 线雷达的典型俯仰角范围是 -15° 到 +15°,每线间隔 2°。可以这样写:
uint16_t pitchToRing(double pitch_deg) { // 16 线,从 -15 到 +15,间隔 2 度 int ring = static_cast<int>((pitch_deg + 15.0) / 2.0); if (ring < 0) ring = 0; if (ring > 15) ring = 15; return static_cast<uint16_t>(ring); }这个映射表一定要按你雷达的实际角度来,不能照抄。雷达手册里会给出每线的精确俯仰角,照着填最准。如果线束不是均匀的,就用查表加最近邻的方式。
4.4 time 计算的两种精度取舍
上面用的是按索引均匀分配,简单但有个前提:点云里点的顺序必须和采集顺序一致。机械式雷达通常满足这个条件,因为它是按方位角顺序输出的。但如果驱动做了重排,这个假设就不成立了。
更精确的做法是用方位角算:
double azimuth = std::atan2(p.y, p.x); // -π 到 π // 归一化到 0 到 2π if (azimuth < 0) azimuth += 2 * M_PI; q.time = static_cast<float>(frame_period * azimuth / (2 * M_PI));这个方式对机械式雷达更准,因为它直接反映了旋转角度。但要注意方位角的起始点,如果一帧不是从 0 度开始,得先减去起始方位角。实际用的时候,可以先扫一遍点云找到最小方位角作为起点。
固态雷达没有方位角单调性,time只能靠驱动提供,或者按索引分配。如果固态雷达帧率很高(比如 20Hz 以上),一帧时间很短,按索引分配的误差也能接受。
4.5 launch 文件与话题重映射
写个 launch 把节点跑起来,同时把 LIO-SAM 的输入话题重映射到这个节点的输出:
<launch> <node pkg="lidar_adapter" type="lidar_adapter_node" name="lidar_adapter" output="screen"> <param name="frame_period" value="0.1"/> <param name="input_topic" value="/raw_points"/> <param name="output_topic" value="/points_with_ring_time"/> </node> <!-- LIO-SAM 的输入重映射 --> <remap from="/points_raw" to="/points_with_ring_time"/> </launch>LIO-SAM 默认订阅/points_raw,把它重映射到你的输出话题就行。注意frame_id要保持一致,否则 TF 会断。
5. 参数调优与验证:怎么确认字段真的对了
5.1 用 rostopic 检查字段是否齐全
节点跑起来后,第一件事是确认输出点云的字段:
rostopic echo /points_with_ring_time/fields正常应该看到x y z intensity ring time六个字段。如果少了,说明注册点类型的时候漏了,或者toROSMsg没带上。
再看一帧数据的实际值:
rostopic echo /points_with_ring_time -n 1 | head -50重点看ring是不是在 0 到 15 之间,time是不是在 0 到 0.1 之间。如果time出现很大的数(比如 1.6e9),说明你把绝对时间戳塞进去了,得改成相对偏移。
5.2 用 rviz 直观判断 ring 和 time 是否正确
rviz 里把点云按ring着色,如果颜色是分层的、一圈一圈的,说明ring分对了。如果颜色杂乱无章,说明俯仰角映射有问题,得回去核对雷达角度表。
time的验证稍微麻烦点,可以按time着色,正常应该看到颜色沿旋转方向渐变。如果颜色跳变,说明time计算有断层。
5.3 建图效果对比:去畸变前后的差异
最直接的验证还是看建图。让机器人做一段有明显旋转或平移的运动,分别用带time和不带time的点云跑 LIO-SAM,对比建图结果。带time的版本,墙面应该是平的,转角应该是直的;不带time的版本,墙面会有明显弯曲,快速运动时更明显。
我实测下来,在机器人以 0.5m/s 速度移动、同时以 30°/s 旋转的场景下,不做去畸变的建图误差能到十几厘米,做了之后能压到两三厘米。这个差距在回环检测时特别明显,不做去畸变的话回环经常对不上。
6. 踩过的坑与常见问题速查
6.1 ring 映射错误的典型表现
最常见的坑是俯仰角映射表用错。比如雷达实际是 -16° 到 +14°,你按 -15° 到 +15° 算,边缘几线的点会被分到错误的 ring 上。表现是点云边缘出现“错层”,建图时远处墙面有重影。
解决办法:拿雷达手册里的精确角度表,或者用一张已知距离的标定板,实测每线的俯仰角。别嫌麻烦,这一步做对了后面省很多事。
6.2 time 单位与符号的坑
time必须是相对偏移,单位秒,且非负。我见过有人把ros::Time::now()直接塞进去,数值是 1.6e9 量级,LIO-SAM 拿去算补偿量直接溢出,建图完全乱掉。还有人用了毫秒,数值大了 1000 倍,去畸变补偿过头,点云被反向拉伸。
注意:
time字段的数值范围应该和帧周期同量级,10Hz 雷达就是 0 到 0.1 之间。超出这个范围基本就是单位或基准错了。
6.3 点云顺序与 time 单调性的关系
按索引分配time的前提是点云顺序等于采集顺序。有些驱动会把点云按距离排序,或者做降采样,顺序就乱了。这时候按索引算的time完全错误。判断方法:看相邻两点的方位角是不是单调递增,如果不是,就得改用方位角算time,或者干脆在驱动层解决。
6.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 启动报 ring 字段缺失 | 点类型没注册 ring | 检查 POINT_CLOUD_REGISTER |
| 启动报 time 字段缺失 | 点类型没注册 time | 同上 |
| 建图飘、回环对不上 | time 单位或基准错误 | 检查 time 数值范围 |
| 点云边缘错层 | ring 映射表不准 | 核对雷达俯仰角 |
| 去畸变无效 | time 全为 0 或缺失 | 检查 time 是否被填充 |
| rviz 里点云颜色杂乱 | ring 分配错误 | 按 ring 着色检查 |
| 节点延迟高 | 点云太大、逐点处理慢 | 用 reserve、避免拷贝 |
6.5 性能优化的几个实操技巧
逐点处理大点云会慢,几个优化点:一是out->reserve(raw->size())预分配,避免反复扩容;二是用pcl::fromROSMsg的原地版本,减少拷贝;三是如果雷达帧率高,可以考虑只对关键帧做转换,但 LIO-SAM 需要每帧都有字段,所以这条不适用。实测 16 线雷达一帧约 3 万点,逐点处理在普通工控机上耗时约 5 到 8ms,对 10Hz 的帧率完全够用。
如果点云超过 10 万点,建议用 OpenMP 并行化循环:
#pragma omp parallel for for (size_t i = 0; i < total; ++i) { ... }编译时加-fopenmp,速度能提升 3 到 4 倍。
7. 从适配到落地:一些延伸经验
7.1 ROS2 下的适配差异
ROS2 里PointCloud2的接口变了,pcl_conversions的用法也有调整。核心逻辑一样,但订阅发布要用rclcpp的 API,fromROSMsg和toROSMsg在pcl_conversions的 ROS2 版本里仍然可用。launch 文件从 XML 换成 Python 或 YAML。如果你在 ROS2 上跑 LIO-SAM 的移植版,字段补全的思路完全一致,只是代码骨架要换。
7.2 多雷达融合时的字段一致性
如果你有多台雷达,每台的ring编号要统一规划,不能各自从 0 开始,否则 LIO-SAM 会把不同雷达的同一 ring 混在一起。常见做法是给第二台雷达的 ring 加一个偏移,比如第一台 0 到 15,第二台 16 到 31。time也要统一到同一帧起始时刻,否则去畸变会错乱。
7.3 长期运行的稳定性考虑
中间层节点长期跑,要注意内存泄漏和话题积压。点云消息很大,如果发布频率高于 LIO-SAM 处理速度,队列会堆积。把发布队列设小一点,比如pub_.setQueueSize(2),宁可丢帧也别积压。另外加个看门狗,检测输入话题是否断流,断流时打日志,方便排查。
7.4 标定与验证的闭环
字段补全只是第一步,真正建图准不准还依赖 IMU 和雷达的外参标定。ring和time对了,但外参错了,建图照样飘。建议用一段已知轨迹的数据做验证,比如让机器人沿直线走 10 米,看建图轨迹的直线度。如果直线走成了弧线,先查外参,再查time。
我个人在实际操作中的体会是,ring和time这两个字段的适配,难点不在代码,而在对雷达本身输出特性的理解。把雷达手册翻透,把每线的角度、帧周期、点云输出顺序搞清楚,代码就是水到渠成的事。反过来,如果跳过这一步直接抄代码,大概率会在某个细节上卡住,而且报错信息往往不会直接指向根因。所以我的建议是,动手写代码之前,先用rostopic echo把原始点云的结构、字段、数值范围摸清楚,再决定ring和time怎么算。这一步花的时间,后面都会省回来。