赞
踩
lio-livox是livox官方在2021年开源的一款Lidar+IMU算法,可以用于livox mid360, horizon, HAP等不同型号的激光雷达运行LIO算法。然而,笔者并没有在网上找到详细的原理说明文档,因此尝试通过代码分析算法原理。
之前一次的推送介绍了特征提取部分,本次介绍建图和地图管理部分。由于能力有限,难免解读出现错误,请读者即使批评指正。
还是再看一下Lio-Livox整个代码的rqt_graph,可以看到在特征提取之后就进行了姿态估计PoseEstimation
。这个节点接收特征提取后带特征标签信息的完整点云/livox_full_cloud
和IMU的原始数据/livox/imu
,然后处理后输出里程计和点云。
在介绍后面细节之前,先谈一下前面的特征提取、IMU数据和输出点云。
在特征提取部分,将一次扫描到的点云的点分成了:角点、平面点和非特征点三种,类别放在了每个点的normal_z
字段,因此并不需要姿态估计节点单独接收角点、平面点等点云,只是收到完整点云后读取normal_z
字段后判断特征类别种类即可。
Lio-livox根据IMU的使用方式,分为三种运行模式(如下图),分别是:不用IMU、仅用IMU做去畸变、紧耦合模式,这三种由IMU_Mode
的值决定。本次推文不展开介绍紧耦合模式的处理,以IMU去畸变模式为主。
在姿态估计输出中,仅有livox_full_cloud_mapped
这一个关于地图的数据,即仅有将当次扫描scan的数据映射到当前lidar坐标系后的输出,并没有输出完整的点云。而在官方可视化的rivz界面中,这个输出设置了decay即延迟消失的时间,因此看起来是有一个较为丰富的地图。所以如果需要完整地图的发布和报错,需要自己修改源码。
在PoseEstimation.cpp
中的main
函数,可以大致了解程序运行流程。
- int main(){
- ... // 省略一些初始化、参数读取与ros的消息收发
- laserCloudFullRes.reset(new pcl::PointCloud<PointType>);
- estimator = new Estimator(filter_parameter_corner, filter_parameter_surf);
- lidarFrameList.reset(new std::list<Estimator::LidarFrame>);
- std::thread thread_process{process};
- ros::spin();
- }
首先初始化了一个Estimator
类,在这个类的构造函数结束时,创建了一个地图更新线程。这是第一个线程。这个线程负责地图大小等维护。
- Estimator::Estimator(const float& filter_corner, const float& filter_surf){
- ... // 省略部分内容
- threadMap = std::thread(&Estimator::threadMapIncrement, this);
- }
除此之外,main
的结尾启动了一个process
线程,这个线程负责了建图部分。
下面详细介绍两个线程的具体工作。
建图线程位于process()
函数,整个建图线程的代码精简逻辑如下:
- // 关于IMU不同模式下的不同操作
- if(!vimMsg.empty()){ // 有IMU数据,是IMU去畸变模式,或紧耦合模式。
- if(!LidarIMUInited){} // 有IMU数据,没有初始化,仅用IMU的gyro积分得到旋转,平移用上次的推测,服务后面的去畸变。 - 此时的lidar的位姿用IMU的gyro积分得到旋转,用之前的平移做位置更新
- else{} // 有IMU数据, 已经初始化了,走紧耦合这一步。 - 此时的lidar用IMU的预积分做更新
- else{ // 没有IMU数据
- if(LidarIMUInited) break; // IMU和LidAR已经初始化了(只能是紧耦合模式),但没有数据,则啥也不干。
- else{} // 没有IMU则仅执行LiDAR的LO - 此时的lidar位姿用上次运动增量做预测;
- }
-
- RemoveLidarDistortion(); // 去畸变
- EstimateLidarPose(); // 估计雷达姿态
- // update delta transformation
-
- if(IMU_Mode > 1 && !LidarIMUInited){ // 紧耦合且没有初始化时,进行初始化操作
- if(!LidarIMUInited && ...)
- LidarIMUInited = ture
- }
最开始是根据IMU模式和数据状态进行一些操作,首先根据是否有IMU数据进行处理。注意有无IMU数据并不是指lidar端是否发了IMU,而是这个姿态节点在IMU模式是1和2时才会接收IMU数据,因此有IMU意味着此时为1或2模式(IMU去畸变或紧耦合)。
如果有IMU数据,则根据一个状态量LidarIMUInited
进行处理,这个初始化只有在紧耦合模式下初始化完成后才会设定为true。因此,我们在意的IMU去畸变模式(模式1)下这个值一直是false的。此时通过IMU的积分得到lidar的旋转和平移增量:
- lidarFrame.imuIntegrator.PushIMUMsg(vimuMsg);
- lidarFrame.imuIntegrator.GyroIntegration(time_last_lidar);
- delta_Rb = lidarFrame.imuIntegrator.GetDeltaQ().toRotationMatrix();
- delta_Rl = exTlb.topLeftCorner(3, 3) * delta_Rb * exTlb.topLeftCorner(3, 3).transpose
- // predict current lidar pose
- lidarFrame.P = transformAftMapped.topLeftCorner(3,3) * delta_tb + transformAftMapped.topRightCorner(3,1);
- Eigen::Matrix3d m3d = transformAftMapped.topLeftCorner(3,3) * delta_Rb;
- lidarFrame.Q = m3d;
注意到这里的delta_Rb
,delta_Rl
指的是一次scan时body系(IMU)和lidar系的旋转增量。IMU的旋转增量由imuIntegrator.GyroIntegration
实现。考虑到旋转运动对畸变的影响远大于平移,因此此时并没有做平移的相应计算。在获取一次scan的IMU旋转增量后,转到lidar系,并预测下一时刻的位姿。从代码中可以推测,lidarFrame的P字段表示的应该是body系在world得平移。所以可以得出结论,在1模式下,IMU的数据主要是计算了旋转增量。
紧接着是进行点云去畸变操作RemoveLidarDistortion
,输入的变量是delta_Rl
和delta_tl
,即这次scan的估计旋转和平移。如果是0模式,即仅利用lidar做odometry,则这两个参数是通过下方代码计算的:
- // update delta transformation
- delta_Rb = transformAftMapped.topLeftCorner(3, 3).transpose() * lidar_list->front().Q.toRotationMatrix(); //~ 在纯lidar时没有用到。得到的是IMU的旋转。
- delta_tb = transformAftMapped.topLeftCorner(3, 3).transpose() * (lidar_list->front().P - transformAftMapped.topRightCorner(3, 1));
- Eigen::Matrix3d Rwlpre = transformAftMapped.topLeftCorner(3, 3) * exRbl;
- Eigen::Vector3d Pwlpre = transformAftMapped.topLeftCorner(3, 3) * exPbl + transformAftMapped.topRightCorner(3, 1);
- delta_Rl = Rwlpre.transpose() * transformTobeMapped.topLeftCorner(3,3); //~ 计算激光雷达两次之间的增量,用于下次去畸变;带入 Rwlpre和ToBeMap的表达式,应该为:exRbl'*AftMaped'*ToBeMap*exRbl,中间为Rb的增量,这里为增量的旋转到lidar系。
- delta_tl = Rwlpre.transpose() * (transformTobeMapped.topRightCorner(3,1) - Pwlpre); //~ 最终结果是Pre到当前ToBeMapped的lidar的平移。
即通过上上次和上一次间的位姿变换,转到lidar系,得到的,即我们常说的匀速运动模型去畸变。但对于IMU=1模式,旋转增量是上一小节IMU数据陀螺仪积分得到的,但平移量依旧是利用这段匀速模型代码处理的,delta_tl
并没有被更新,应该是担心IMU的加速度计有漂移积分,加速度积分得到的平移并不准确。如果再回过头关注IMU=2即紧耦合模式,可以看到紧耦合下通过预积分得到的平移量是更新了delta_tl
的,因为紧耦合IMU的预积分能够处理噪声。
再看具体的去畸变代码,比较简单不展开介绍:每个点的normal_x
存储了这个点位于这圈scan的百分比,再根据这个比例对旋转和平移量进行插值后坐标变换即可。
现在进入最复杂的雷达姿态估计函数,estimator->EstimateLidarPose()
。这个函数干了三件事:1.根据特征类别分类存放,2.迭代优化计算位姿,3.更新局部地图
- for(const auto& l : lidarFrameList){
- laserCloudCornerLast[stack_count]->clear();
- for(const auto& p : l.laserCloud->points){
- if(std::fabs(p.normal_z - 1.0) < 1e-5)
- laserCloudCornerLast[stack_count]->push_back(p);
- }
- ...
- }
即根据每个点的normal_z
字段,是1/2/3分别判定为角点、平面和非特征点,然后分类存储即可,不复杂。
获得各种特征后,在Estimate()
函数中计算里程计。
首先从map_manager
获取每个分块区域patch的角点、平面点、非特征点的kd树地图(用于寻找近邻)与完整点云地图。
接下来采用ceres
优化库对姿态进行求解运算。ceres优化的内容在这里只是简单带过:首先构建参数块AddParameterBlock
包括6维姿态和IMU的bias,然后在IMU模式为0或1时不需要考虑边缘化等,因此直接开辟了3个线程同时寻找点-线、点-面、点-点ICP的近邻关系,保存到对应的kdtree中。这一步同时构建了不同类型配准时的代价函数。
- threads[0] = std::thread(&Estimator::processPointToLine, this,// 寻找点到线的匹配关系,将结果存到 vLineFeatures 中,edgesLine 是误差函数。
- std::ref(edgesLine[f]),
- std::ref(vLineFeatures[f]),
- std::ref(laserCloudCornerStack[f]),
- std::ref(laserCloudCornerFromLocal),
- std::ref(kdtreeCornerFromLocal),
- std::ref(exTlb),
- std::ref(transformTobeMapped));
-
- threads[1] = std::thread(&Estimator::processPointToPlanVec, this,
- ...
- threads[2] = std::thread(&Estimator::processNonFeatureICP, this,
- ...
构建对应关系后,就开始构造残差AddResidualBlock
(残差的计算方式在上面构建损失函数时已经实现),最后对ceres优化问题进行求解ceres::Solve
。
上述过程在循环中进行,直到达到迭代次数或收敛(旋转平移增量很小)后结束迭代。至此,得到了这次扫描在整个地图中的位姿信息。
在EstimateLidarPose
函数的最后,调用了MapIncrementLocal()
,用于更新局部地图。这个函数就是把角点、平面、非特征点通过坐标变换到world系,降采样然后存到laserCloudXXXFromLocal
这个变量中,XXX
为Corner/Surf/NonFeature。用于2.3.2中构建kdtree与计算ICP配准。
地图管理线程在PoseEstimation
节点中创建Estimator
时的构造函数里面,启动了名为threadMapIncrement
线程。该函数定义为了[noreturn]
函数,即告诉编译器该函数不会返回任何值。这个函数里面在while中不断执行两个操作:将lidar系得地图点投影到map当中、更新map。
投影部分是函数featureAssociateToMap
,只是将每个点通过当前计算的姿态变换坐标投影到world系然后保存。
地图更新是函数MapIncrement
,将投影后保存的各种点更新地图。整个地图采用了LOAM算法的地图存储策略,划分为了21*21*11大小的地图块,如果lidar的位置位于地图的边缘,则进行MapMove
重新调整整个地图的索引坐标。之后,根据需要新增地图点的坐标判定处于哪个地图块,存入对应地图块并对有更新的地图块进行下采样滤波。
由于这部分内容借鉴的是LOAM算法,网上也有很多详细的介绍,因此这里不做展开。
Lio-livox在完成特征提取后,通过一个建图线程,完整特征与地图的ICP配准,获得姿态参数,再通过一个地图维护线程,不断更新地图。
—END—目前工坊已经建立了3D视觉方向多个社群,包括SLAM、工业3D视觉、自动驾驶方向,细分群包括:
[工业方向]三维点云、结构光、机械臂、缺陷检测、三维测量、TOF、相机标定、综合群;
[SLAM方向]多传感器融合、ORB-SLAM、激光SLAM、机器人导航、RTK|GPS|UWB等传感器交流群、SLAM综合讨论群;
[自动驾驶方向]深度估计、Transformer、毫米波|激光雷达|视觉摄像头传感器讨论群、多传感器标定、自动驾驶综合群等。
[三维重建方向]NeRF、colmap、OpenMVS、MVSNet等。
[无人机方向]四旋翼建模、无人机飞控等。
除了这些,还有求职、硬件选型、视觉产品落地等交流群。
大家可以添加小助理微信: dddvisiona,备注:加群+方向+学校|公司, 小助理会拉你入群。
针对3D视觉领域的视频课程(三维重建、三维点云、结构光、手眼标定、相机标定、激光/视觉SLAM、自动驾驶等)、源码分享、知识点汇总、入门进阶学习路线、最新paper分享、疑问解答等进行深耕,更有各类大厂的算法工程人员进行技术指导。与此同时,星球将联合知名企业发布3D视觉相关算法开发岗位以及项目对接信息,打造成集技术与就业、项目对接为一体的铁杆粉丝聚集区,6000+星球成员为创造更好的AI世界共同进步,知识星球入口:「3D视觉从入门到精通」
学习3D视觉核心技术,扫描查看,3天内无条件退款本星球:3D视觉岗求职星球 依托于公众号「3D视觉工坊」和「计算机视觉工坊」、「3DCV」,旨在发布3D视觉项目、3D视觉产品、3D视觉算法招聘信息,具体内容主要包括:
收集汇总并发布3D视觉领域优秀企业的最新招聘信息。
发布项目需求,包括2D、3D视觉、深度学习、VSLAM,自动驾驶、三维重建、结构光、机械臂位姿估计与抓取、光场重建、无人机、AR/VR等。
分享3D视觉算法岗的秋招、春招准备攻略,心得体会,内推机会、实习机会等,涉及计算机视觉、SLAM、深度学习、自动驾驶、大数据等方向。
星球内含有多家企业HR及猎头提供就业机会。群主和嘉宾既有21届/22届/23届参与招聘拿到算法offer(含有海康威视、阿里、美团、华为等大厂offer)。
发布3D视觉行业新科技产品,触及行业新动向。
如果大家对3D视觉某一个细分方向想系统学习[从理论、代码到实战],推荐3D视觉精品课程学习网址:www.3dcver.com
科研论文写作:
基础课程:
[1]面向三维视觉算法的C++重要模块精讲:从零基础入门到进阶
[2]面向三维视觉的Linux嵌入式系统教程[理论+代码+实战]
工业3D视觉方向课程:
[1](第二期)从零搭建一套结构光3D重建系统[理论+源码+实践]
SLAM方向课程:
[1]深度剖析面向机器人领域的3D激光SLAM技术原理、代码与实战
[2]彻底剖析激光-视觉-IMU-GPS融合SLAM算法:理论推导、代码讲解和实战
[3](第二期)彻底搞懂基于LOAM框架的3D激光SLAM:源码剖析到算法优化
[4]彻底搞懂视觉-惯性SLAM:VINS-Fusion原理精讲与源码剖析
[5]彻底剖析室内、室外激光SLAM关键算法和实战(cartographer+LOAM+LIO-SAM)
视觉三维重建:
[2]基于深度学习的三维重建MVSNet系列 [论文+源码+应用+科研]
自动驾驶方向课程:
[1] 深度剖析面向自动驾驶领域的车载传感器空间同步(标定)
[2] 国内首个面向自动驾驶目标检测领域的Transformer原理与实战课程
[4]面向自动驾驶领域的3D点云目标检测全栈学习路线!(单模态+多模态/数据+代码)
[5]如何将深度学习模型部署到实际工程中?(分类+检测+分割)
[1] 零基础入门四旋翼建模与控制(MATLAB仿真)[理论+实战]
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。