LeGo-LOAM中的featureAssociation节点
LeGo-LOAM是在LOAM基础上改进的激光雷达定位与建图方法,它对地面点进行了优化、提出了两步LM优化算法,加入了闭环检测和全局因子图优化等。
本系列博客只说明了关键代码的功能实现,以及一些关键地方的数学推导,详细的解释都写在了代码注释中,注释后的代码在融合相机后会传到github。同时也列出来一些问题提醒自己有条件时实际去验证。
这个模块接收四种消息:
- 从”/segmented_cloud”话题接收到的分割点云(1/5地面点和有效分割点),保存到成员变量segmentedCloud中;
- 从”/segmented_cloud_info”话题接收到的描述segmentedCloud点云的附加信息,保存到成员变量segInfo中;
- 从”/outlier_cloud”话题接收到的外点(5倍降采样后的非地面无效分割点云),保存到outlierCloud中;
- 从”/imu/data”话题接收原始IMU数据,将数据存入缓存队列,并进行惯性积分;
Lego-LOAM可选择使用高频的IMU来辅助进行运动补偿,去除点云的畸变。辅助去除运动中的非线性项,使得帧间运动符合匀速运动假设是IMU最大的作用,IMU并未用来紧耦合或者松耦合位姿。IMU的处理在featureAssociation.cpp中,从代码中来看,Lego-LOAM要求IMU能够提供方位角orientation、加速度linear_acceleration、角速度angular_velocity,且IMU的频率至少是要高于Lidar的频率的。这个cpp文件中隐含了传感器间的坐标关系,它与KITTI数据集中IMU与Lidar的坐标关系保持一致:
featureAssociation.cpp中有两个重要的接口函数,其中一个就是FeatureAssociation的构造函数,重点在它定义了接收消息的回调函数:
1 | FeatureAssociation() : nh("~") { |
除了IMU消息的回调函数,其他消息的回调函数功能都比较单一,就是简单的将接收到的消息转存到对应的成员变量中。
1、回调函数imuHandler()中完成的处理
我们必须明确IMU的原始测量值是相对于惯性空间的测量在载体坐标系(body系)下的投影!IMU数据通过imuHandler()
函数读入,每来一帧IMU数据,首先要完成以下几件事情:
- 根据方位角信息去除重力对加速度的影响,计算得到IMU的真实加速度(body系下);
- 进行坐标轴交换,统一到z轴向前,x轴向左,y轴向上的右手坐标系;
- 将当前帧IMU消息的时间戳、方位角、真实加速度(body系下)、角速度(body系下),保存到imuTime[],imuRoll、Pitch、Yaw[],imuAccX、Y、Z[],imuAngularVeloX、Y、Z[]缓存队列中;
- 调用函数AccumulateIMUShiftAndRotation()进行速度、位移、角度积分。
2、AccumulateIMUShiftAndRotation()函数完成惯性积分:
- 缓存队列中存储的IMU测量值是在body系下的投影,这里计算速度积分、位置积分时先把加速度投影到惯性系下;
- 只有当IMU的频率高于Lidar的频率,才进行惯性积分,惯性积分得到当前IMU帧在惯性系下的速度、惯性系下的位移、以及相对于首帧IMU的旋转;这三种信息分别保存在队列imuVeloX、Y、Z[],imuShiftX、Y、Z[],imuAngularRotationX、Y、Z[]中;
这里需要注意的是原始IMU消息所在的坐标系是x轴向前,y轴向左,z轴向上的右手坐标系,Lego-LOAM对坐标系进行了统一,统一转换到z轴向前,x轴向左,y轴向上的右手坐标系。在imuHandler()
函数中,只对加速度做了这个转换,方位角、角速度还在原来x轴向前的坐标系下。所以这里在后续计算的时候需要注意,例如我们从一帧IMU消息中读取到方位角orientation,根据这个方位角计算出来roll、pitch、yaw,则$Rz(yaw)Ry(pitch)Rx(roll)$表示的是原始IMU坐标系到惯性系的旋转变换,表示为$R_{xyz}^{w}$;那么新的IMU坐标系到惯性系的旋转变换$R_{zxy}^{w}$是这样计算的:$Ry(yaw)Rx(pitch)Rz(roll)$。
另外一个接口函数是在接收到消息后才被使用的,它是runFeatureAssociation()
——FeatureAssociation节点真正处理数据的函数:
1 | void runFeatureAssociation() |
IMU的作用主要体现在adjustDistortion()函数中,若不使用IMU,这个函数的功能很简单,可以参考A-LOAM的代码。下面我会说明使用IMU和不使用IMU的差别体现在哪里。以下是我对代码的理解,不保证完全正确。
左边这幅图来自LOAM的原论文,它清楚的说明了如何estimate lidar motion。稍微解释一下,$t_k$表示第一帧点云的采样时刻,$t_{k+1}$表示第二帧点云的采样时刻,若Lidar的采样频率是10Hz,那么$t_{k+1} - t_{k} = 100ms$。蓝色的线表示这100ms内一帧点云中点的数量是线性增长的,绿色的线表示将一帧点云去畸变后投影到当前帧结束点时刻的Lidar坐标系下。同时这幅图默认第一帧点云结束点时刻等于第二帧点云起始点时刻,若按LOAM中的硬件(使用单线雷达来模拟3D雷达)能较好的符合这种假设,使用3D雷达可不会是这种情况,大部分会是右边的图描述的情况。使用右边的图来解释代码中IMU的部分会更好理解。
3、运动补偿函数adjustDistortion()中完成的处理
1、若不使用IMU,这个函数就是计算一帧segmented_cloud点云中每一点的时间比例因子s,并存储起来给后续函数使用:
$$
s = \frac{t_i - t_{start}}{scanPeriod}
$$
2、若使用IMU,除了第1步,这里还会去除点云加减速产生的位移畸变以及旋转畸变:
对于点云中的激光点,首先在时间轴上找到其左右两侧最近的IMU数据,进行一个线性插值来近似该时刻Lidar坐标系在惯性系下的方位角、速度以及位移。根据第j个激光点时刻lidar坐标系的方位角,可以计算此时Lidar系到惯性系的旋转变换$R_{lidar_j}^{n}$。由此可将一帧点云的旋转部分的畸变去除,即对应上图中粉色的线,旋转部分去掉畸变投影到起始点时刻Lidar坐标系下:
$$
P^{lidar_{start}} = (R_{lidar_{start}}^{n})^{-1} R_{lidar_j}^{n} P^{lidar_j}
$$
根据第j个激光点时刻lidar坐标系在惯性系下的位移,以及第0个激光点时刻lidar坐标系在惯性系下的位移和速度,可以计算由于加减速产生的位移:
$$
\Delta p = P_j^{n} - P_{0}^{n} - V_{0}^{n} \times \Delta t
$$
这个位移是在惯性坐标系下的,将这个位移根据第0个激光点时刻lidar坐标系的方位角,转换到第0个激光点时刻lidar坐标系下:
$$
\Delta p_{lidar_j}^{lidar_{start}} = R_{n}^{lidar_{start}} \Delta p
$$
如此去除非线性运动造成的点云畸变。经过这步处理,一帧点云可以看做投影到了起始点时刻Lidar坐标系下(旋转部分),位移部分从此符合匀速运动假设。
时间戳的说明:可以看出来若不使用IMU,点云的时间戳在一帧点云的起始、中间、结束时刻都不要紧,因为这里只计算时间比例因子供给后续函数使用,它与时间戳无关!但是使用IMU后,若还想保持原有点云的去畸变方式,就需要知道点云的起始时刻以及一帧点云的采样时间(用来寻找时间范围内的IMU数据),若点云的时间戳在一帧点云的中间,如KITTI数据集,就得根据这个时间戳近似一个起始时刻的时间戳来使用。(PS:KITTI数据集的IMU频率与雷达频率一致,不做额外努力也用不了IMU)。
4、计算曲率、提取特征点
calculateSmoothness()
函数计算点云segmentedCloud中每个点的曲率,这里曲率的计算与LOAM中的不同,Lego-LOAM中第i个点的曲率按照如下公式计算:
$$
\begin{aligned}
&d_i = sqrt(x_i^2+y_i^2+z_i^2)\\
&diffRange_i = d_{i-5}+d_{i-4}+d_{i-3}+d_{i-2}+d_{i-1}+d_{i+1}+d_{i+2}+d_{i+3}+d_{i+4}+d_{i+5}-10d_{i}\\
&Curvature_i = diffRange_i * diffRange_i
\end{aligned}
$$markOccludedPoints()
函数对不可靠的点进行去除,不可靠的点就是LOAM论文中提及的(a)激光点平行于激光线、(b)遮挡区域后方的点这两种情况。
判断的代码如下:
1 | /** |
extractFeatures()
函数用来提取特征点,Lego-LOAM自称是Ground-Optimized,在这个函数里就可以体现。遍历一帧点云中的每条scan,为了使特征点均匀分布,将一条scan分成6片。每片扇区在非地面分割点上选取2个曲率最大的点作为极大边线点集,在非地面分割点上选取20个曲率最大点作为次极大边线点集;每片扇区在地面点上选取4个曲率最小的点作为极小平面点集,除去次极大边线点集后剩余的点作为次极小平面点集。这个次极小平面点集比较大,进行了一次降采样。
5、checkSystemInitialization()初始系统
此函数主要用来跳过第一帧点云,将第一帧点云设为source点云,同时还有一个小细节:
1 | // 从IMU消息中保存初始帧在惯性系下的Roll和Pitch角,作为累积位姿的初始值 |
6、updateInitialGuess()给优化变量赋予初始值
transformCur[]是本模块的待优化变量,在这个函数中给他赋予初始值。transformCur[0~2]是欧拉角、tansformCur[3~5]是平移项,是否加入IMU,在给transformCur[0~2]赋予初始值时应该体现出来:
若使用IMU,旋转项transformCur[0~2]应该表示粉色的点云到绿色点云的位姿变换,它是一个小量,旋转项对应的欧拉角直接赋值为0比较好;若不使用IMU,点云的旋转畸变没有得到补偿,旋转项transformCur[0~2]表示的是当前帧最后一点到当前帧点云初始点的位姿变换$R_{end}^{start}$,实际应该是当前帧结束点到上一帧点云结束点的位姿变换,有差别,但是不大,置于为什么是这样,稍后在函数integrateTransformation()
中说明,原始代码中旋转项transformCur[0~2]赋予的初始值为:
1 | // 使用上一帧点云转过的角度给当前帧点云转过的角度赋一个初始值,即给transformCur中的旋转项赋一个初始值 |
代码中的roll、pitch、yaw还是在原始x轴向前,y轴向左,z轴向上的IMU系下,注意这里有一个负号,所以TransformToStart()
函数对应的变换是这个if语句上面注释的旋转变换。
transformCur[3~5]是平移项,平移项和旋转项表示的含义不相同,平移表示当前帧起始点到当前帧点云结束点的位置变换,实际应该是上一帧结束点到当前帧点云结束点的位置变换,$t_{start}^{end}$,原始代码中平移项transformCur[3~5]赋予的初始值为:
1 | // 根据速度畸变,为transformCur的平移项赋一个初始值,(对于匀速运动假设的一个补偿) |
7、updateTransformation()构建距离约束,求解帧间位姿
这个函数里使用LOAM中的方法构建约束关系,并求解。此函数分为两部分,一是在次极大边线点集中寻找极大边线点的匹配线,建立点到直线的距离约束;在次极小平面点集中寻找极小平面点的匹配面,建立点到平面的距离约束。二是使用Gauss-Newton优化算法迭代求解(论文中说用的LM算法,代码中并不是)。Lego-LOAM提出来two-step L-M优化算法,第一步,根据平面点的约束求解[t_z, roll, pitch],对应函数calculateTransformationSurf()
,第二步,根据边线点的约束求解[t_x, t_y, yaw],对应函数calculateTransformationCorner()。
按照LOAM中的方法,在建立距离约束的过程中,点云会被投影到一帧的开始点时刻,即对应TransformToStart()
函数,在不使用IMU的时候,原始代码是没有问题的,这个函数完成的功能如下:
$$
\begin{aligned}
p^{start} &= R_{cur}^{start}p^{cur} + t_{cur}^{start} \\
&= R_{cur}^{start}p^{cur} - R_{cur}^{start}t_{start}^{cur} \\
&= R_{cur}^{start}(p^{cur} - t_{start}^{cur})
\end{aligned}
$$
其中:
但是使用IMU时,这里出现了问题!在adjustDistortion()
函数调用TransformToStartIMU()
函数时,点云的旋转畸变已经去掉了,transformCur[]的含义已经发生了改变,原始代码中这样处理不是很合理。推导如下:去畸变的过程就是将100ms内采集到的激光点都统一到某一时刻,LOAM以及LeGo-LOAM都统一到一帧点云起始点时刻的Lidar坐标系下,对应的公式是:
$$
\begin{aligned}
P^{start} &= T_{cur}^{start}P^{cur} \\
&=(T_{start}^{n})^{-1}T_{cur}^{n}P^{cur} \\
&=
\left[
\begin{array}{cc}
R_{start}^{n} & t_{start}^{n} \\
0 & 1
\end{array}
\right]^{-1}
\left[
\begin{array}{cc}
R_{cur}^{n} & t_{cur}^{n} \\
0 & 1
\end{array}
\right]
P^{cur} \\
&=(R_{start}^{n})^{-1}R_{cur}^{n}P^{cur}+(R_{start}^{n})^{-1}(t_{cur}^{n} - t_{start}^{n})
\end{aligned}
$$
推导过程中隐含了一次齐次坐标到非齐次坐标的转换,这个公式的前半部分在adjustDistortion()
函数中已经实现,只差后半部分。后半部分表示的就是$t_{cur}^{start}$,即transformCur[3~5]的相反数。所以使用IMU后,这个函数这样写就可以了:
1 | void TransformToStart(PointType const *const pi, PointType *const po) { |
在原始代码中,使用IMU去掉旋转畸变后,transformCur[0~2]就是一个小量,我们将rx、ry、rz统统当做0带入原始的TransformToStart()
函数中,得到的就是推导出来的结果。所以这里即使不改动代码也能凑合着用。
Gauss-Newton优化算法迭代求解需要的Jacobian矩阵在另一节给出,原始代码对Jacobian矩阵的求解不准确,是否使用IMU也会导致Jacobian矩阵发生变化。
8、integrateTransformation()累计相对位姿
本节点的优化变量是transformSum[],它是一个帧间相对量,本函数将相对量累积,获得第一帧或者说world系下该帧的位姿。先考虑不使用IMU的情况,根据估计出来的transformCur[]可以获得当前帧结束时刻到当前帧起始时刻的变换$T_{cur-end}^{cur-start}$,$T_{pre-end}^{world}$是上一帧点云结束点时刻到world坐标系的位姿变换,这个函数将其累积:
$$
T_{cur-end}^{world} = T_{pre-end}^{world}T_{cur-end}^{cur-start}
$$
若假设当期帧起始点时刻就是上一帧结束点时刻,这个变换就是完全正确的,实际上这个存在误差:
看过LOAM的论文我们知道,上一帧点云处理完毕后被投影到了上一帧点云结束点时刻,就是上图中的绿色的点云,当前帧点云是以绿色的点云为参考的,所以本来求解的就是当前帧结束点时刻到上一帧结束点时刻(不是当前帧起始点时刻)的位姿变换,这样看我们应该这样计算时间比例因子:
$$
s = \frac{t-t_{pre-last}}{t_{cur-last}-t_{pre-last}}
$$
只是有时候我们只有点云的时间戳,没有一帧点云的起始时刻、终止时刻的具体信息,LeGo-LOAM做了个折中吧。
考虑使用IMU的情况,transformCur[]中的旋转是一个小量,优化出来的旋转实际上是将粉色的点云向绿色的点云对齐,这时候经过AccumulateRotation()
函数,我们将这个相对旋转变换到了全局坐标系下。但是,这时的旋转是当前帧的起始点到全局坐标系的变换,而我们要得到的是当前帧的结束点到全局坐标系的变换,这也就是PluginIMURotation()
函数完成的功能。
PluginIMURotation()
函数使用到了IMU的数据,它需要当前帧点云起始激光点相对于惯性系的orientation、当前帧点云最后一个激光点相对于惯性系的orientation。所以这里的公式是:
$$
T_{cur-end}^{world} = T_{pink}^{world}(T_{cur-start}^{n})^{-1}T_{cur-end}^{n}
$$
给我的感觉是,使用IMU后,旋转部分激光只是计算一丢丢,其他都是靠IMU补偿的。
9、发布里程计信息、点云信息
publishOdometry()
函数将累积的相对姿态,也就是world坐标系下的姿态publish出去。
publishCloudsLast()
函数将本帧处理完的点云投影到结束点时刻,构建kd-tree供给下一帧查询最近点使用,并将去畸变后的点云发布出去。这个函数中的TransformToEnd()
在使用IMU时又会出现问题!
TransformToEnd()
函数前半部分是实现TransformToStart()
的功能,即先将点云变换到一帧点云的起始点时刻、再从起始点时刻变换到结束点时刻。不使用IMU,一切都没问题,若使用IMU,上面已经分析了TransformToStart()
函数(即TransformToend()
函数的前半段可以凑合着用),但是从起始点时刻再变换到结束点时刻就不对了。出问题的代码如下:
1 | rx = transformCur[0]; |
还是那句话,使用IMU时transformCur[]中的旋转项是一个小量,这里用到的旋转应该是$(T_{cur-end}^{n})^{-1}T_{cur-start}^{n}$(参考PluginIMURotation()
函数)中的旋转项。
10、总结
这里我按照论文中的思路分析代码,我能保证的是不使用IMU时的分析没有问题,使用IMU时按照当期帧点云投影到当前帧结束点时刻,发现有些函数不是那么正确。也可能代码不是按照论文中的思路来理解,例如一帧点云中去掉旋转的部分投影到一帧的起始时刻,平移的部分投影到一帧的结束时刻等,若有小伙伴分析的明明白白,能和代码一一对应,还请指点一二。后续我会一一验证,暂时罗列出来给自己做一个标记。
- 点云时间戳不在点云起始点怎么办?
若不使用IMU,点云的时间戳在一帧点云的起始、中间、结束时刻都不要紧,因为时间比例因子与时间戳无关!但是使用IMU后,若还想保持原有点云的去畸变方式,就需要知道点云的起始时刻以及一帧点云的采样时间(用来寻找时间范围内的IMU数据),若点云的时间戳在一帧点云的中间,如KITTI数据集,就得根据这个时间戳近似一个起始时刻的时间戳来使用。这只要更改adjustDistortion()
函数中比较时间戳的那段代码。
- IMU如何使用?
- IMU频率必须大于Lidar频率,IMU必须可以直接提供orientation;
- IMU和Lidar的坐标系和KITTI数据集中的保持一致;
- Lidar的时间戳是在一帧点云的起始点处;(不使用IMU,Lidar时间戳可以不满足这个要求);
- 更改
TransformToStart()
、TransformToEnd()
函数; - 更改
calculateTransformation()
中Jacobian的求解;
- 可优化的点?
- 若准确知道一帧点云的起始时刻和结束时刻,可按照第8节中的公式求解时间比例因子,真正求解当前帧点云结束点到上一帧点云结束点时刻的位姿变换;
updateInitialGuess()
中初始值的赋予。
11、不解之谜
发布Odomntry时候,为什么要取两次负号?
1 | // 发布transformSum到里程计消息 |
退化运动的处理?
1 | // 求解对称矩阵matAtA的特征值/特征向量,特征值按照降序排序 |