代码实战 | 用LeGO-LOAM实现BFS点云聚类和噪点剔除
时间:2023-12-06 08:37:01
原文链接:实战代码 | 用LeGO-LOAM实现BFS消除点云聚类和噪声
作者介绍:Zach,移动机器人从业者热爱移动机器人行业,决心通过科技帮助更好的生活。他也是我们课程的学生:基于LiDAR多传感器融合SLAM:LOAM、LeGO-LOAM、LIO-SAM
LeGO-LOAM软件框架分为五部分:
-
分割聚类:这部分的主要操作是分离地面点云;同时,对剩余的点云进行聚类,消除噪声(少量点云簇,标记为噪声);
-
特征提取:对分割后的点云(排除地面点云部分)进行边缘点和面点特征提取;
-
Lidar里程计:匹配连续帧之间的位置变换矩阵(边缘点和面点);
-
Lidar Mapping:进一步处理特征,然后全局 Point Cloud Map 配准中;
-
Transform Integration:Transform Integration 融合了来自 Lidar Odometry 和 Lidar Mapping 的 pose estimation 最终输出 pose estimate。
上一章代码实战 | 用LeGO-LOAM我们分析了地面提取的实现LeGO-LOAM本章将详细介绍如何提取和分离地面点云LeGO-LOAM为了消除噪声点,如何将地表上的点云分类。
LeGO-LOAM论文中对地表上点云聚类分割参考了论文《Fast Range Image-based Segmentation of Sparse 3D Laser Scans for Online Operation》,这篇文章的作者声称分割速度非常快,有多快!你可以看看原来的论文。
让我们先学习这篇论文,实现它地表点云分割原理,然后详细分析LeGO-LOAM框架是如何实现的。
地表点云聚类分割的原理
作者首先将激光雷达扫面的数据投影到2D深度图(和LeGO-LOAM的 Range Map 同样),深度图长/横向是激光雷达单条 scan 扫描点云,深度图高/纵向是激光雷达的线束。需要注意的是,作者压缩了水平数据。例如,870*32 横向870点,32点scan。
图1 Rang Map. 网格中的值表示聚类值, -1表示无返回值.
需要注意的是,2D的Range Map 由于激光雷达的扫描线是环形的,左边界与右边界相连。
图2 点云快速分割原理示意图
上述方法也有一些缺陷。当激光雷达靠近墙时,墙上远离激光雷达的点很容易被判定为另一个目标。作者认为,这并不影响上述算法的实际有效性。直观地说,该方法确实能区分深度差异较大的点。
作者在整个分割和聚类过程中使用了它 邻居的 BFS 搜索大大加快了聚类分割的速度,伪代码如下:
图3 Range Image Label
-
遍历Rang Map 上所有点(Line 1–8)
-
遍历的方式是逐行遍历,先行后列(Line 4–9);
-
如果遇到未标记的像素,则执行BFS操作并标记(Line 6–8);
-
四个领域的每一点BFS搜索(Line 9–19)
-
建立一个队列,push种子点 (Line 10)
-
从队列中取出一个点和标签,判断点是否与其四个领域相同(Line 12–17);如果是同类点,则填写队列并标记(Line 18)
-
从队列中删除刚取出的点(Line 19)
如果我们深入思考,我们会发现BFS起到了在Rang Map 聚类,这样聚集的点云,要么是同类簇点云,要么只是深度距离值差异明显的点云;然后进一步使用角度阈值不同类别的云在深度距离上有明显差异;最后,对点云起到了很好的聚类分割作用。
LeGO-LOAM实现地表点云聚类分割
点云分割的主要过程是先提取地面(在上一篇文章中说明),然后对剩余的点云进行分割和聚类,最后对分割的点云进行进一步的特征提取。过程结束后,只保留大物体的点云,如地面和树干(不包括叶子和树枝)进一步处理。如下图所示:
图4 点云聚类分割效果图
上图(a) 是原点云,图(b)聚类分割后的点云,红点表示地面点,剩下的点是分割后的点云。
下面对官方代码详细说明这个过程是如何实现的。
函数 void cloudSegmentation()
实现
点云分割代码主要由:LEGO-LOAM/src/imageProjection.cpp
文件中的函数void cloudSegmentation()
实现。该函数的核心流程和功能如下图所示:
图5 函数void cloudSegmentation()
流程
我们首先分析函数的作用,然后仔细分析具体细节。
- Step 1: 按行遍历 RangMap 对所有点进行聚类分割,并存储分割结果
labelMat
中,这部分基本实现了图3中的算法流程,稍后会对这部分进行详细的分析和说明。
// 1. 按行遍历所有点进行分类,更新labelMat for (size_t i = 0; i < N_SCAN; i) { for (size_t j = 0; j < Horizon_SCAN; j) { // labelMat 存储每个点的聚类标记 if (labelMat.at(i,j) == 0) { // 执行未标记的点BFS, 同时进行聚类 labelComponents(i, j); } } }
- Step 2: 经过 Step 1,获得了点云分割的结果,其结果存储在
labelMat
同类点云标号相同,噪声标号为999999
。代码如下:
// 2. 对噪声/地面点/分隔点进一步处理 int sizeOfSegCloud = 0; // extract segmented cloud for lidar odometry for (size_t i = 0; i < N_SCAN; i) { // 记录每一条scan从中间分割的有效点开始index和终止index segMsg.startRingIndex[i] = sizeOfSegCloud-1 5; for (size_t j = 0; j < Horizon_SCAN; j) { // 只处理有效的聚类点或地面点 if (labelMat.at(i, j) > 0 || groundMat.at(i, j) == 1) { // outliers that will not be used for optimization (always continue) 不需要优化异常值 // 2.1. 过滤掉所有噪声点, 另作存储; if (labelMat.at(i, j) == 999999) { // 异常点 // 是噪点,噪声横轴坐标以5倍数存储 if (i > groundScanInd && j % 5 == 0) { outlierCloud->push_back(fullCoud->points[j + i*Horizon_SCAN]);
continue;
} else {
continue;
}
}
// majority of ground points are skipped
// 2.2. 压缩地面点, 只取五分之一的地面点进行存储;
if (groundMat.at(i, j) == 1) {
if (j%5 !=0 && j>5 && j(i,j) == 1);
// mark the points' column index for marking occlusion later
// 标记点的列索引, 稍后标记遮挡
segMsg.segmentedCloudColInd[sizeOfSegCloud] = j;
// save range info 保存范围信息
segMsg.segmentedCloudRange[sizeOfSegCloud] = rangeMat.at(i,j);
// save seg cloud 保存分割点云
segmentedCloud->push_back(fullCloud->points[j + i*Horizon_SCAN]); // 既有分割的地表点云,又有地面点云
// size of seg cloud 更新分割点云数量
++sizeOfSegCloud;
}
}
segMsg.endRingIndex[i] = sizeOfSegCloud-1 - 5;
}
代码采取按行遍历,通过条件语句 if (labelMat.at
限制了处理对象只包括地面点云 和 分割点云。
2.1 过滤掉所有的噪声点
if (labelMat.at(i, j) == 999999) { // 异常点
// 是噪点,噪点横轴坐标是5的倍数就进行存储
if (i > groundScanInd && j % 5 == 0) {
outlierCloud->push_back(fullCloud->points[j + i*Horizon_SCAN]);
continue;
} else {
continue;
}
}
上述代码表面,如果是噪声点,则不执行双层for
循环的余下代码,相当于过滤掉这些噪声点。把噪声点另外存储在 outlierCloud
中,存储的时候时压缩存储的,只有遇到点的横轴坐标遇 5整除才进行存储。
2.2 压缩地面点
// majority of ground points are skipped
// 2.2. 压缩地面点, 只取五分之一的地面点进行存储;
if (groundMat.at(i, j) == 1) {
if (j%5 !=0 && j>5 && j
如果检测到的是地面点,会过滤掉绝大部分的地面点,只保留横轴遇5整除的点作进一步处理。
2.3 保留所有有效分割点和压缩后的地面点
// 2.3. 保留所有的有效分割点;
// mark ground points so they will not be considered as edge features later
// 标记地面点,以便以后不会将其视为边缘特征
segMsg.segmentedCloudGroundFlag[sizeOfSegCloud] = (groundMat.at(i,j) == 1);
// mark the points' column index for marking occlusion later
// 标记点的列索引, 稍后标记遮挡
segMsg.segmentedCloudColInd[sizeOfSegCloud] = j;
// save range info 保存范围信息
segMsg.segmentedCloudRange[sizeOfSegCloud] = rangeMat.at(i,j);
// save seg cloud 保存分割点云
segmentedCloud->push_back(fullCloud->points[j + i*Horizon_SCAN]); // 既有分割的地表点云,又有地面点云
// size of seg cloud 更新分割点云数量
++sizeOfSegCloud;
凡是没有过滤掉的点,都执行上述的数据保存工作。最后的 segmentedCloud
保存的既有稳定的分割点(剔除噪声点),又有压缩的地面点。
- Step 3: 给分割的点进行着色,同类点云的强度值相同。
// 3. 可视化分割后的点云(同类点云分配相同的强度值),不包括地面点云
// extract segmented cloud for visualization
if (pubSegmentedCloudPure.getNumSubscribers() != 0) {
// 遍历range map 中的点云
for (size_t i = 0; i < N_SCAN; ++i){
for (size_t j = 0; j < Horizon_SCAN; ++j) {
if (labelMat.at(i,j) > 0 && labelMat.at(i,j) != 999999){
// 给点云分配强度值: 同类点云, 强度值相同
segmentedCloudPure->push_back(fullCloud->points[j + i*Horizon_SCAN]);
segmentedCloudPure->points.back().intensity = labelMat.at(i,j);
}
}
}
}
通过限制条件可知,上述代码只给分割点进行着色,不包括地面点云。
在函数 void cloudSegmentation()
中,除了 Step 1 部分外,其他的都很好理解。Step 1 里面的核心部分是函数void labelComponents(int row, int col)
,其包含了使用BFS进行聚类,使用角度判断是否同属一类,还涉及到一些经验参数的设置。但是,大体上该函数的实现是依据论文《Fast Range Image-based Segmentation of Sparse 3D Laser Scans for Online Operation》而来。如果文章的前半部分看懂了,理解这部分代码应该没有什么问题。
函数 void labelComponents(int row, int col)
实现
函数 void labelComponents(int row, int col)
的形参是一个点在 range map 上的横纵坐标,该函数就是以点[row, col]
为中心,进行BFS(广度优先搜索),在BFS的基础之上进行角度对比以进一步判断两点是否为同类点。最终的结果是,获得点[row, col]
所在区域的同类点,结果更新在 labelMat
上。
在详细分析代码之前,为了缕清结构,我们先看一下大致的代码流程:
图6 函数void labelComponents()
流程
- Step 1: 初始化队列信息
// 1. 使用C++ 容器会导致计算速度变慢, 作者使用数组模拟了一个队列
float d1, d2, alpha, angle;
int fromIndX, fromIndY, thisIndX, thisIndY;
bool lineCountFlag[N_SCAN] = {false};
// 使用数组模拟队列
queueIndX[0] = row;
queueIndY[0] = col;
int queueSize = 1;
int queueStartInd = 0; // pop 数据的哨兵
int queueEndInd = 1; // push 数据的哨兵
// 存储分割的点云
allPushedIndX[0] = row;
allPushedIndY[0] = col;
int allPushedIndSize = 1; // 记录分割点云的点数量
作者在此处使用了两个数组来模拟队列的功能,不使用C++容器的原因是,当容器容量不够是,C++会进行两倍扩容;同时存储效率也没有数组高。这部分代码把形参当作第一个点传入队列中,设置好队列的 pop 哨兵和 push 哨兵,并初始化数组allPushedIndX
和 allPushedIndY
。
- Step 2 使用BFS进行点云分割 这段代码最好对照图3**的伪代码流程进行查看。
// 2. 进行BFS点云分割
while(queueSize > 0) {
// Pop point 提取一个数据
fromIndX = queueIndX[queueStartInd];
fromIndY = queueIndY[queueStartInd];
--queueSize;
++queueStartInd;
// Mark popped point
labelMat.at(fromIndX, fromIndY) = labelCount;
// Loop through all the neighboring grids of popped grid
for (auto iter = neighborIterator.begin(); iter != neighborIterator.end(); ++iter){
// 2.1. 提供有效的邻居点(正确的坐标和未被标记)
// new index
thisIndX = fromIndX + (*iter).first; // 纵轴坐标
thisIndY = fromIndY + (*iter).second; // 横轴坐标
// index should be within the boundary
// 避免纵轴坐标正确
if (thisIndX < 0 || thisIndX >= N_SCAN)
continue;
// at range image margin (left or right side)
// 调整横轴边界数据是相连的
if (thisIndY < 0)
thisIndY = Horizon_SCAN - 1;
if (thisIndY >= Horizon_SCAN)
thisIndY = 0;
// prevent infinite loop (caused by put already examined point back)
// 保证所取点的有效性:必须是未被标记的点
if (labelMat.at(thisIndX, thisIndY) != 0)
continue;
// 2.2. 计算d1(长边)和d2(短边)
d1 = std::max(rangeMat.at(fromIndX, fromIndY),
rangeMat.at(thisIndX, thisIndY));
d2 = std::min(rangeMat.at(fromIndX, fromIndY),
rangeMat.at(thisIndX, thisIndY));
// 2.3. 根据所取点的位置设置alpha值
if ((*iter).first == 0) // 邻居点与中心点同一条扫描线上
alpha = segmentAlphaX;
else // 邻居点与中心点在相连的扫描线上
alpha = segmentAlphaY;
// 2.4. 计算角度, 并根据阈值分类
angle = atan2(d2*sin(alpha), (d1 -d2*cos(alpha)));
if (angle > segmentTheta){ // 此处segmentTheta = 60.0
// 把此有效点push入队列中
queueIndX[queueEndInd] = thisIndX;
queueIndY[queueEndInd] = thisIndY;
++queueSize;
++queueEndInd;
// 标记此点与中心点同样的label
labelMat.at(thisIndX, thisIndY) = labelCount;
lineCountFlag[thisIndX] = true;
//追踪分割的点云
allPushedIndX[allPushedIndSize] = thisIndX;
allPushedIndY[allPushedIndSize] = thisIndY;
++allPushedIndSize;
}
}
}
代码:
// Pop point 提取一个数据
fromIndX = queueIndX[queueStartInd];
fromIndY = queueIndY[queueStartInd];
--queueSize;
++queueStartInd;
// Mark popped point
labelMat.at(fromIndX, fromIndY) = labelCount;
从队列中 pop 出一个点,并给该点标记 labelCount
。
代码 for (auto iter = neighborIterator.begin(); iter != neighborIterator.end(); ++iter)
是以给定的形参点 [row, col]
为中心,进行上下左右四邻居遍历。
2.1 提供有效的邻居点
// 2.1. 提供有效的邻居点(正确的坐标和未被标记)
// new index
thisIndX = fromIndX + (*iter).first; // 纵轴坐标
thisIndY = fromIndY + (*iter).second; // 横轴坐标
// index should be within the boundary
// 避免纵轴坐标正确
if (thisIndX < 0 || thisIndX >= N_SCAN)
continue;
// at range image margin (left or right side)
// 调整横轴边界数据是相连的
if (thisIndY < 0)
thisIndY = Horizon_SCAN - 1;
if (thisIndY >= Horizon_SCAN)
thisIndY = 0;
// prevent infinite loop (caused by put already examined point back)
// 保证所取点的有效性:必须是未被标记的点
if (labelMat.at(thisIndX, thisIndY) != 0)
continue;
遍历上下左右的邻居点,有可能出现以下情况:1. 纵轴坐标越界,则跳过该点;2. 横轴坐标越界,则调整横轴坐标,因为range map 的左右边界是相连的;3. 所取得点如果已经被标记,则跳过该点。
2.2 计算d1和d2的边长
图7 计算的d1和d2
计算 d1和d2 代码实现如下:
// 2.2. 计算d1(长边)和d2(短边)
d1 = std::max(rangeMat.at(fromIndX, fromIndY),
rangeMat.at(thisIndX, thisIndY));
d2 = std::min(rangeMat.at(fromIndX, fromIndY),
rangeMat.at(thisIndX, thisIndY));
2.3 根据所取点的位置设置alpha值
// 2.3. 根据所取点的位置设置alpha值
if ((*iter).first == 0) // 邻居点与中心点同一条扫描线上
alpha = segmentAlphaX;
else // 邻居点与中心点在相连的扫描线上
alpha = segmentAlphaY;
2.4. 计算角度, 并根据阈值分类
// 2.4. 计算角度, 并根据阈值分类
angle = atan2(d2*sin(alpha), (d1 -d2*cos(alpha)));
if (angle > segmentTheta){ // 此处segmentTheta = 60.0
// 把此有效点push入队列中
queueIndX[queueEndInd] = thisIndX;
queueIndY[queueEndInd] = thisIndY;
++queueSize;
++queueEndInd;
// 标记此点与中心点同样的label
labelMat.at(thisIndX, thisIndY) = labelCount;
lineCountFlag[thisIndX] = true;
//追踪分割的点云
allPushedIndX[allPushedIndSize] = thisIndX;
allPushedIndY[allPushedIndSize] = thisIndY;
++allPushedIndSize;
}
最后,计算角度值,并与角度阈值作比较。如果大于角度阈值,则表示所遍历的邻居点与所选取的点是同类点云,标记此邻居点为 labelCount
,并填充到 allPushedIndX/Y
中。
直到所构建的队列为空为止。
- Step 3: 检测分类结果是否正确
// 3. 判断分隔是否有效
// check if this segment is valid
bool feasibleSegment = false;
// 如果分割出的点云个数大于30个则认为是此分割是有效的
if (allPushedIndSize >= 30)
feasibleSegment = true;
else if (allPushedIndSize >= segmentValidPointNum){ // 如果分割的点云数量不满足30且大于最小点数要求,则进一步分析
int lineCount = 0;
// 统计行数
for (size_t i = 0; i < N_SCAN; ++i)
if (lineCountFlag[i] == true)
++lineCount;
// 如果行数大于3,则认为也是有效的点云分割
if (lineCount >= segmentValidLineNum)
feasibleSegment = true;
}
获取分割结果之后,我们还需要作进一步的验证,如果所分割的点大于30个,则认为此次分割结果是有效的;如果分割的点数小于30 ,大于5,则要作进一步的分析;如果,分割的点所占据的行数大于3 ,也认为此次分割是有效的。因为像树干这类目标,水平点的分布很少,但是竖直点的分布较多,激光雷达在垂直方向的分辨率又比较低,所以,这个地方的线束阈值设为3 。这样可以有效排除掉树叶,树枝等特征不稳定的点云。
- Step 4: 后续处理
// 4. 如果分隔有效, 则更新labelCount; 如果无效, 则标记为噪声
// segment is valid, mark these points
if (feasibleSegment == true){ // 如果是有效分割, 则更新labelCount
++labelCount;
}else{ // segment is invalid, mark these points, 如果分割是无效的, 则标记为无效标签值(很大的标签值, 即位噪点)
for (size_t i = 0; i < allPushedIndSize; ++i){
labelMat.at(allPushedIndX[i], allPushedIndY[i]) = 999999; //
}
}
如果分割结果是有效的,则累加 labelCount
;如果分割结果是无效的,则把此处分割的点标记为噪点。
最终呈现出来的效果图如下:
我们只勾选左侧的 Segmentation Pure
,右侧就呈现出来了分割点云(不包括地面点)不同的聚类效果,不同的颜色代表不同的类别。
参考资料
-
https://github.com/RobustFieldAutonomyLab/LeGO-LOAM
-
《Fast Range Image-based Segmentation of Sparse 3D Laser Scans for Online Operation》
-
基于LiDAR的多传感器融合SLAM:LOAM、LeGO-LOAM、LIO-SAM
独家重磅课程!
1、VIO课程:VIO最佳开源算法:ORB-SLAM3超全解析课程重磅升级!
2、图像三维重建课程(第2期):视觉几何三维重建教程(第2期):稠密重建,曲面重建,点云融合,纹理贴图
3、重磅来袭!基于LiDAR的多传感器融合SLAM 系列教程:LOAM、LeGO-LOAM、LIO-SAM
4、系统全面的相机标定课程:单目/鱼眼/双目/阵列 相机标定:原理与实战
5、视觉SLAM必备基础(第2期):视觉SLAM必学基础:ORB-SLAM2源码详解
6、深度学习三维重建课程:基于深度学习的三维重建学习路线
7、激光定位+建图课程:详解最常用的激光SLAM框架Cartographer(Google团队开源)
链接:代码实战 | 用LeGO-LOAM实现BFS点云聚类和噪点剔除
全国最棒的SLAM、三维视觉学习社区↓
链接:代码实战 | 用LeGO-LOAM实现BFS点云聚类和噪点剔除
技术交流微信群
欢迎加入公众号读者群一起和同行交流,目前有SLAM、三维视觉、传感器****、自动驾驶、计算摄影、检测、分割、识别、医学影像、GAN、****算法竞赛****等微信群,请添加微信号 chichui502 或扫描下方加群,备注:”名字/昵称+学校/公司+研究方向“。请按照格式备注,否则不予通过。添加成功后会根据研究方向邀请进入相关微信群。请勿在群内发送广告,否则会请出群,谢谢理解~
投稿、合作也欢迎联系:simiter@126.com
链接:代码实战 | 用LeGO-LOAM实现BFS点云聚类和噪点剔除
扫描关注视频号,看最新技术落地及开源方案视频秀 ↓
链接:代码实战 | 用LeGO-LOAM实现BFS点云聚类和噪点剔除
— 版权声明 —
本公众号原创内容版权属计算机视觉life所有;从公开渠道收集、整理及授权转载的非原创文字、图片和音视频资料,版权属原作者。如果侵权,请联系我们,会及时删除。