1. 从一次“点云翻车”说起:为什么我要死磕 scanRegistration
去年帮一个做园区巡检机器人的朋友调一套激光雷达里程计,设备是常见的机械式多线激光雷达,跑的是 LOAM 那一套经典框架。当时遇到一个特别诡异的现象:机器人在直道上跑得好好的,一进到有玻璃幕墙和长走廊的区域,建出来的地图就开始“糊”,墙面像被揉皱的纸一样分层,回环也接不上。我一开始怀疑是后端优化的问题,折腾了两天,最后定位到根子上——scanRegistration 这一环的特征点提取出了问题。
这个模块是 LOAM 整条流水线的第一道工序,也是决定后面所有事情质量的地基。它干的事情说白了就三件:把原始点云按线束拆开、算出每个点的曲率、根据曲率把点分成“边缘点”和“平面点”两类特征。听起来简单,但里面藏着一堆工程细节:曲率怎么算才稳、遮挡点怎么剔、特征点怎么均匀分布、强度信息怎么用。这些细节处理不好,后面 scan-to-scan 匹配、scan-to-map 优化全都会跟着遭殃。
这篇笔记就是把我啃 LOAM 源码里 scanRegistration 这个文件的过程完整记录下来。我会从整体设计思路讲到每一行关键代码的意图,把参数选择的计算过程摊开说,再把我踩过的坑和排查方法整理成速查表。适合正在读 LOAM 源码、想自己复现一套激光里程计、或者单纯想搞明白“点云特征提取到底在提什么”的朋友。不管你是刚接触 SLAM 的新手,还是已经跑通过框架但没细看源码的老手,应该都能从里面捞到点东西。
2. scanRegistration 的整体设计与思路拆解
2.1 它在 LOAM 流水线里到底站在什么位置
LOAM 的整体架构是“特征提取 + 双线程优化”的经典设计。scanRegistration 是整条链路的入口,输入是一帧原始点云加上对应的雷达参数,输出是四组点:尖锐边缘点、次尖锐边缘点、平面点、次平面点。后面 scan-to-scan 的里程计线程和 scan-to-map 的建图线程,都是拿这四组点去做匹配的。
为什么要把特征分成“尖锐”和“次尖锐”、“平面”和“次平面”?这是为了保证匹配的鲁棒性。尖锐边缘点数量少但定位精度高,用来做主要约束;次尖锐点数量多,用来在特征稀疏的区域兜底。平面点同理。这种分级策略本质上是给优化器提供了不同置信度的观测,让它在不同场景下都有足够的约束可用。
理解这一点很关键:scanRegistration 不是一个“预处理”那么简单,它其实是在做信息的压缩和结构化。一帧点云动辄几万个点,直接拿去匹配计算量大得离谱,而且大部分点(比如平坦地面上的点)提供的信息是冗余的。特征提取就是把这些冗余去掉,只留下对位姿估计最有价值的那些点。
2.2 为什么按线束拆点是第一步
机械式多线激光雷达的原始点云是一个无序的点集合,每个点带着 xyz 坐标、强度、还有相对时间戳。但雷达的物理结构决定了它是“一圈一圈”扫描的,每一圈就是一个线束(scan line)。同一个线束上的点,在垂直方向上是同一个俯仰角,水平方向上是连续变化的方位角。
按线束拆点的目的,是把三维问题降维成“每个线束内部的一维序列问题”。因为特征提取的核心是算曲率,而曲率是沿扫描方向计算的——你得知道一个点的前后邻居是谁。如果不拆线束,你根本不知道哪个点是哪个点的邻居。拆完之后,每个线束内部就是一个按方位角排序的序列,前后邻居关系一目了然。
这里有个工程细节:不同雷达的线束数量、每束点数、垂直角度分布都不一样。代码里通常用一个参数表来配置,比如 16 线、32 线、64 线各有各的配置。我见过有人直接拿 64 线的配置去跑 16 线的数据,结果点云被切得乱七八糟,特征全错。这个坑后面会细说。
2.3 曲率计算的本质:用前后邻居的“离散程度”衡量局部形状
曲率这个概念在连续几何里是描述曲线弯曲程度的,但点云是离散的,没法直接求导。LOAM 用的是一种近似方法:对每个点,取它前后各若干个邻居点,计算这些点到该点距离的某种“离散程度”,用这个值来近似曲率。
具体来说,代码里会计算一个类似“邻居点相对该点的向量模长之和”的量。如果这个点在一个平面上,前后邻居基本在同一个平面内,向量模长之和就小;如果这个点在一条棱上,前后邻居分布在棱的两侧,向量模长之和就大。这个值越大,说明局部越“尖锐”,越可能是边缘特征。
这个近似方法的好处是计算量小、对噪声有一定容忍度。但它也有软肋:如果邻居点里混进了遮挡点或者噪声点,曲率就会被算歪。所以后面必须有一套剔除机制,把不可靠的点干掉。这就是为什么 scanRegistration 里有一大堆判断条件,不是单纯算个曲率就完事。
2.4 特征分级与均匀化:避免“特征扎堆”
算完曲率之后,理论上按曲率排序,取最大的那些当边缘点、最小的那些当平面点就行了。但实际场景里,特征分布是极不均匀的。比如一面墙的角落,曲率大的点会扎堆;一片空旷地面,曲率小的点也会扎堆。如果直接取 top N,结果就是特征全集中在少数区域,其他区域一个特征都没有,匹配的时候约束严重失衡。
LOAM 的解法是分区域提取。把每个线束再切成若干个子区域,每个子区域里最多取一个边缘点和一个平面点。这样保证特征在空间上均匀分布,不会出现“一边倒”的情况。这个思路在工程上非常实用,我后来自己做别的特征提取任务时也经常借用。
3. 核心细节解析与实操要点
3.1 点云拆线束的具体实现与参数陷阱
拆线束的核心是判断每个点属于哪一条扫描线。机械式雷达的点云里,每个点通常带一个 ring 字段(或者叫 scan ID),直接读就行。但有些雷达驱动不提供这个字段,那就得靠垂直角度来推算:用点的 z 坐标和 xy 平面距离算出俯仰角,再跟雷达的垂直角度表比对,找最接近的那条线。
这里第一个坑就是垂直角度表的精度。如果雷达实际的角度分布和配置表有偏差,或者点云有畸变,俯仰角算出来就会落在两条线之间,导致点被分错线。我遇到过一种情况:雷达安装时稍微有点俯仰倾斜,导致所有点的俯仰角整体偏移,拆线束时大量点被分到相邻线束,特征提取直接崩掉。解决办法是在拆线束之前先做一次姿态粗校正,或者用自适应的角度聚类来动态确定线束边界。
第二个坑是每束点数的上限。代码里通常会预分配一个固定大小的数组来存每条线束的点,如果实际点数超过这个上限,就会溢出或者截断。高分辨率雷达一帧点数很多,这个上限要设够。我一般会按“雷达水平分辨率 × 一圈最大点数 × 1.2”来估算,留 20% 余量。
3.2 曲率计算的邻居窗口选择:前后各 5 个点是怎么来的
LOAM 源码里计算曲率时,对每个点取前后各 5 个邻居,一共 10 个点参与计算。这个 5 不是随便定的,它跟雷达的水平角分辨率有关。以常见的水平分辨率 0.2 度为例,5 个点大约覆盖 1 度的方位角范围。这个范围既要足够大,能反映出局部形状;又不能太大,否则跨过了特征边界,曲率就被平均掉了。
如果雷达水平分辨率更高,比如 0.1 度,那 5 个点只覆盖 0.5 度,窗口偏小,曲率对噪声更敏感。这时候可以适当增大到 7 或 9。反过来,分辨率低的时候要减小窗口。我的经验是:让窗口覆盖的方位角在 0.8 到 1.2 度之间比较合适,可以根据实际数据微调。
还有一个细节:窗口内的点必须和当前点在同一条线束上,而且方位角要连续。如果中间有缺失点(比如被遮挡),窗口就不能跨过缺失区域。代码里通常会检查邻居点的索引连续性,不连续就跳过。这个检查很重要,否则遮挡边界处的曲率会算得非常大,产生大量假边缘点。
3.3 遮挡点与平行光束点的剔除逻辑
遮挡点是激光雷达的天然缺陷。当激光打到物体边缘时,一部分光束打在近处物体上,一部分打在远处背景上,两者在点云里表现为距离突变。这种突变点的曲率会很大,容易被误判为边缘特征,但实际上它只是遮挡造成的,不是真实的几何边缘。
LOAM 的剔除逻辑是:对每个点,检查它和邻居点的距离。如果当前点到雷达的距离明显小于邻居点到雷达的距离(比如小于邻居距离的某个比例),说明当前点可能遮挡了后面的点,把它标记为不可靠。具体阈值我一般设 0.8 左右,也就是当前点距离小于邻居距离的 80% 就剔除。
平行光束点是另一种情况:激光束几乎平行于物体表面,打出来的点距离变化很大,但实际几何是平滑的。这种点的曲率也会偏大。剔除方法是检查邻居点之间的距离变化率,如果变化太剧烈且不连续,就认为是平行光束造成的,剔除掉。
注意:这两类剔除都会损失一些真实特征,所以阈值不能设得太激进。我一般先用宽松阈值跑一遍,看看特征点数量和分布,再逐步收紧,找到一个平衡点。
3.4 特征分级的数量控制与均匀化策略
前面说了要分区域提取,具体怎么分?LOAM 的做法是把每个线束分成 6 个子区域,每个子区域里按曲率排序,取最大的若干个作为边缘点候选,最小的若干个作为平面点候选。然后在这些候选里再做一次筛选,保证相邻特征点之间有一定的间隔。
这个“间隔”很关键。如果两个特征点挨得太近,它们提供的信息是高度冗余的,而且容易在匹配时产生冲突。代码里通常要求相邻特征点之间至少隔几个点(比如 3 到 5 个)。这个间隔值跟雷达分辨率有关,分辨率高就设大一点。
数量控制方面,每个线束提取的边缘点和平面点数量是有上限的。上限设多少取决于你对实时性和精度的权衡。边缘点太少,匹配约束不够;太多,计算量上去了,而且可能引入更多噪声。我一般让每个线束的边缘点不超过 2 个、平面点不超过 4 个,具体看场景调整。
4. 实操过程与核心环节实现
4.1 从原始点云到线束序列的完整处理链
假设我们拿到一帧原始点云,数据格式是每个点带 xyz、强度、ring、时间戳。第一步是遍历所有点,按 ring 字段把它们塞进对应的线束数组里。如果 ring 字段缺失,就用俯仰角推算。塞进去的同时记录每个点的方位角,方便后面排序。
第二步是对每个线束内部的点按方位角排序。机械式雷达的点云通常已经是按扫描顺序排列的,但不同驱动可能不一样,保险起见还是排一次。排序之后,每个线束就是一个有序的点序列,前后邻居关系确定。
第三步是遍历每个线束的每个点,计算曲率。计算时取前后各 5 个邻居,检查索引连续性和距离合理性,把不可靠的点标记出来。曲率值存到一个数组里,后面排序用。
第四步是分区域提取特征。把每个线束分成 6 段,每段内按曲率排序,选边缘点和平面点候选,再做间隔筛选和数量控制。最后把四组特征点输出。
整个流程听起来线性,但实际写代码时有很多边界情况要处理:线束开头和结尾的点没有完整的前后邻居,要特殊处理;点云有缺失时要跳过;曲率计算时的浮点精度问题也要注意。
4.2 曲率计算的具体公式与参数选择过程
曲率计算的公式大致是这样的:对点 i,取邻居集合 S(前后各 5 个点),计算每个邻居点到点 i 的向量,然后求这些向量模长的平方和,再除以邻居数量。这个值越大,曲率越大。
用数学表达就是:c = (1/|S|) * Σ(||p_j - p_i||²),其中 j 遍历 S。有的实现会用距离而不是距离平方,效果类似,但平方对异常值更敏感,我倾向于用距离本身,更稳一些。
参数选择上,邻居数量 5 是一个经验值。我试过 3、5、7、9,实测下来 5 在大多数场景下表现最均衡。3 太敏感,噪声点容易产生假特征;9 太迟钝,小尺度的边缘会被平滑掉。当然这跟雷达分辨率有关,高分辨率雷达可以适当增大。
还有一个隐藏参数是曲率计算的权重。有的实现会给近处的邻居更大的权重,因为近处点更可靠。这个做法在点云密度随距离衰减明显时很有用。我一般用简单的反距离权重,效果比均匀权重好一些。
4.3 特征点筛选的阈值设定与实测调整
筛选边缘点的阈值是曲率的最大值。但这个阈值不是固定的,因为不同场景的曲率分布差异很大。室内场景曲率普遍偏小,室外场景因为物体边缘更锐利,曲率偏大。如果用一个固定阈值,室内可能提取不到足够的边缘点,室外可能提取太多。
我的做法是动态阈值:先算出当前帧所有点曲率的统计分布,取某个百分位数作为阈值。比如取 90 分位数作为边缘点的初始阈值,再根据数量上限做调整。这样能自适应不同场景。
平面点的阈值是曲率的最小值,逻辑类似。但平面点对阈值不那么敏感,因为平面区域的曲率普遍很小,取一个较小的固定值也能用。
实测调整时,我会把提取出来的特征点可视化出来,叠加在原始点云上,肉眼检查。边缘点应该落在墙角、物体轮廓上;平面点应该落在墙面、地面上。如果发现特征点跑到了不该在的地方,就回去检查曲率计算和剔除逻辑。
4.4 强度信息的辅助使用
很多激光雷达除了 xyz 还提供反射强度。强度信息在特征提取里可以起到辅助作用。比如路面上的车道线、墙面上的标识,强度会有明显变化,这些地方往往也是几何特征所在。把强度变化和曲率结合起来,能提高特征提取的准确性。
具体做法是:在计算曲率的同时,也算一个强度变化率。如果某个点曲率大且强度变化也大,那它作为边缘点的置信度就更高。反过来,如果曲率大但强度没变化,可能是噪声或遮挡,可以降权。
不过强度信息不是所有雷达都有,而且不同材质的反射特性差异很大,所以只能作为辅助,不能替代几何特征。我一般把强度作为一个加分项,而不是决定项。
5. 常见问题与排查技巧实录
5.1 特征点数量异常的原因排查
特征点数量突然变多或变少,是最常见的现象。变多通常是阈值太松或者剔除逻辑失效,导致噪声点被当成特征。变少可能是阈值太紧、点云质量差、或者拆线束出错导致有效点被丢弃。
排查顺序我一般是这样:先看原始点云是否正常,有没有大面积缺失或畸变;再看拆线束的结果,每条线束的点数是否合理;然后看曲率分布,是不是整体偏移了;最后看筛选逻辑,阈值和数量上限是否匹配当前场景。
有一次我遇到特征点数量骤降,查了半天发现是雷达的 ring 字段在某次固件更新后变了含义,导致拆线束全错。这种驱动层面的问题最隐蔽,只能靠对比原始数据和中间结果来定位。
5.2 建图“糊”与特征提取的关联分析
前面提到的玻璃幕墙场景,建图糊掉的根因是玻璃对激光的反射特性特殊,一部分光束穿透玻璃打到后面,一部分反射回来,导致点云在玻璃位置出现两层甚至多层。曲率计算时,这些层之间的点距离突变,产生大量假边缘点。这些假特征进入匹配后,把位姿估计带偏了。
解决办法是在特征提取阶段识别这种“多层”结构,把不可靠的点剔除。具体做法是检查同一方位角上是否有多个距离差异很大的点,如果有,只保留最近的那个,或者全部标记为不可靠。这个逻辑在标准 LOAM 里没有,需要自己加。
5.3 实时性瓶颈的定位与优化
scanRegistration 的计算量主要在三块:拆线束的遍历、曲率计算、特征筛选排序。拆线束和曲率计算是 O(n) 的,特征筛选因为要排序,是 O(n log n) 的。点数多的时候,排序可能成为瓶颈。
优化手段有几个:一是减少参与排序的点,在算曲率之前先用距离和强度做一次粗筛,把明显不可靠的点去掉;二是用部分排序而不是全排序,只需要 top N 的话用堆或者快速选择算法;三是并行化,每个线束独立处理,可以用多线程。
我实测下来,16 线雷达一帧大约 3 万点,单线程处理在 20 毫秒左右,满足 10Hz 实时性没问题。64 线雷达一帧十几万点,就得做优化了,不然会拖慢整个流水线。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决思路 |
|---|---|---|---|
| 特征点数量骤降 | 拆线束错误、阈值过紧 | 检查每条线束点数、曲率分布 | 修正 ring 解析、放宽阈值 |
| 特征点扎堆 | 均匀化逻辑失效 | 可视化特征分布 | 检查分区域和间隔筛选 |
| 建图分层 | 遮挡点/多层反射未剔除 | 检查遮挡边界点云 | 加多层检测和剔除 |
| 实时性不足 | 排序或遍历耗时 | 分段计时 | 粗筛、部分排序、并行化 |
| 边缘点跑到平面上 | 曲率计算窗口跨边界 | 检查邻居连续性 | 加索引连续性检查 |
| 平面点跑到边缘上 | 阈值设置不当 | 看曲率直方图 | 调整平面点阈值 |
提示:这张表是我自己排查时总结的,实际问题的原因往往不是单一的,可能是多个因素叠加。建议按“原始数据→中间结果→最终输出”的顺序逐层排查,不要跳步。
6. 我踩过的几个坑和一点个人心得
第一个坑是过度依赖默认参数。LOAM 源码里的参数是针对特定雷达调的,换个雷达直接跑,效果可能差很远。我一开始偷懒没调,结果特征提取一塌糊涂。后来老老实实按雷达参数重新算了一遍窗口大小、阈值、数量上限,效果立马不一样。参数这东西,宁可花时间调,也不要将就。
第二个坑是忽视点云的时间戳。机械式雷达一帧点云不是同一时刻采集的,每个点有自己的时间戳。如果机器人运动快,一帧内点云会有明显畸变。scanRegistration 本身不做去畸变,但特征提取时会受影响。我的做法是在特征提取之前先做一次简单的运动补偿,用里程计或 IMU 的短时预测把点云校正到同一时刻。这个预处理对特征质量提升很明显。
第三个坑是可视化不够。光看数字和日志很难发现问题,把特征点叠加到点云上、把曲率用颜色映射出来,一眼就能看出哪里不对。我现在养成了习惯,每调一个参数就可视化一次,比看日志快多了。
最后分享一个小技巧:如果你的场景里特征特别稀疏(比如空旷大厅),可以适当降低边缘点的曲率阈值,同时增大平面点的数量上限,让匹配有足够的约束。反过来,特征密集的场景(比如杂乱的室内),就收紧阈值、减少数量,避免冗余和冲突。这个平衡点没有标准答案,只能根据实际数据试出来。