
SLAM经典|Fast-Lio2论文与代码详解
参考Fast-Lio及误差卡尔曼滤波(ESKF)相关资料,本文对Fast-Lio2代码及论文中相关重难点部分进行了解析,详细关注论文到代码的实现过程,对于部分推导,若与引用资料基本一致,本文将给出引用位置,对于论文与代码的重难点及跳跃较大的地方本文将详细对照说明,论文与代码之间存在些许变量定义差异,行文风格上,本文将主要以代码为主,文中的运算符号与论文一致。
1.系统框架
图1 Fast-Lio2系统框架
系统输入为IMU测量数据和LiDAR点云数据,分别用作于系统的状态预测与更新阶段,通常点云数据频率较低而IMU为高频数据,IMU测量数据到来后,系统状态根据IMU运动微分方程不断向前传播,由于载体处于运动状态,在一帧点云积累完毕发出后,并非所有点云都是在数据发出时刻采集到的,此时我们可以根据IMU向前传播的计算结果,将所有点云都校正到点云数据发出的时刻;
随后,根据流形卡尔曼滤波(ESKF),将观测模型线性化后得到误差观测方程,计算残差与对应的雅可比矩阵,在先验与观测的基础上采用最大后验估计进行迭代卡尔曼滤波(IEKF),直到满足迭代终止条件后输出后验估计,并更新地图,关于ikd-tree与地图管理模块本文将不做讨论。
2.状态方程
在ESKF中,我们通常需要维护原状态变量与误差状态变量,原状态变量通常又称为名义状态(nominal state),为系统实际的状态,在滤波器中待估计的随机变量称为误差状态(error state)。预测时,我们将IMU测量值进行积分后放入名义状态,该部分是未考虑各项噪声干扰的,可以理解为在预测阶段名义状态中是不包含系统的不确定性的,而这部分噪声干扰与不确定性都将被考虑进误差状态放入ESKF中进行估计,误差状态随名义状态同步传播。
基于预测结果,对激光雷达点云数据进行观测,采用最大后验估计的方式计算出后验误差状态分布,将误差状态后验“加回”名义状态中,对名义状态进行修正;通过重复“观测-估计-加回”的迭代操作,尽可能消除由于线性化近似带来的误差影响。完成以上操作后,将ESKF成本次循环。
名义状态的先验估计值定义如下:
误差状态形式如下:
与论文不同的是,此处的变量顺序与代码保持一致,误差状态的广义加法关系如下:
输入变量为:
测量噪声为:
名义状态的连续时间微分方程和误差状态的连续时间微分方程分别如下:
名义状态离散时间状态方程如下,我们省去噪声项:
误差状态离散时间的状态方程如下,此处等式右边省略了(t):
根据式2.2可写出误差状态转移矩阵
3.向前传播
系统的预测过程为名义与误差状态将不断的根据IMU测量数据向前传播,借用Fast-Lio的示意图,如下图红色方框内所示,当每一帧IMU测量数据到达后,我们都需要用式2.1与2.3分别对名义与误差状态进行预测。
图2向前传播示意图
接下来看对应代码,向前传播和反向传播两个过程都是在UndistortPcl函数进行,首先我们来看名义状态的向前传播,在第二节中我们提到过在向前传播时,名义状态部分的积分(预测)是不考虑各项噪声干扰的,结合式2.1可知,名义状态中的位置p,旋转R和速度v是向前传播时需要计算的三个量。
UndistortPcl(meas, kf_state, *cur_pcl_un_);
在该函数中,向前传播是在predict函数中进行的
kf_state.predict(dt, Q, in);
以下代码完成了式2.1的过程,名义状态的向前传播
flatted_state f_ = f(x_, i_in);
//f()即get_f()
Eigen::Matrix<double, 24, 1> get_f(state_ikfom &s, const input_ikfom &in)
{
Eigen::Matrix<double, 24, 1> res = Eigen::Matrix<double, 24, 1>::Zero();
vect3 omega;
in.gyro.boxminus(omega, s.bg); // 得到修正后的imu的角速度
vect3 a_inertial = s.rot * (in.acc-s.ba);
for(int i = 0; i < 3; i++ ){
res(i) = s.vel[i]; //对应p=p+vdt中的v
res(i + 3) = omega[i]; //对应R=R*Exp(w-bg)dt中的w-bg
res(i + 12) = a_inertial[i] + s.grav[i]; //v=v+[R(a-ba)+g]dt中的R(a-ba)+g
}
return res;
}
x_.oplus(f_, dt);
//x_.oplus(f_, dt)完成了乘dt并广义自加
代码中名义状态中的旋转均采用四元数的形式,旋转向量转四元数我们采用如下公式:
转化部分计算过程如代码所示:
//代码有所简化,变量定义与上式不尽相同,但实际上cos_sinc_sqrt提供了两种计算方法,
//当1/2theta较大时,直接调用库函数求sin和cos即可
//因为这里都是小量,一般采用另外一种基于泰勒展开的数值计算方法,有兴趣的读者可以自己算一遍
std::pair<scalar, scalar> cos_sinc_sqrt(const scalar &x2){
using std::sqrt;
using std::cos;
using std::sin;
if(x2>=taylor_n_bound) {
// slow fall-back solution
scalar x = sqrt(x2);
return std::make_pair(cos(x), sin(x)/x); // x is greater than 0.
}
static scalar const inv[] = {1/3., 1/4., 1/5., 1/6., 1/7., 1/8., 1/9.};
scalar cosi = 1., sinc=1;
scalar term = -1/2. * x2;
for(int i=0; i<3; ++i) {
cosi += term;
term *= inv[2*i];
sinc += term;
term *= -inv[2*i+1] * x2;
}
return std::make_pair(cosi, sinc);
}
以上计算都是针对名义状态的,在向前传播的时候,还要同时向前不断预测 ESKF中误差状态分布,即其均值与协方差。
// F_x1,f_w_final的计算与式2.3是一致的
P_ = (F_x1) * P_ * (F_x1).transpose() + (dt * f_w_final) * Q * (dt * f_w_final).transpose();
至此,我们便完成了向前传播过程,即在每一帧IMU测量下得到了系统名义状态的先验和误差状态的先验分布。
4.反向传播
点云数据的采集是一个累积的过程,通常lidar将一段时间内的采集点累积后统一发出,形成该帧点云数据,当系统处于运动状态时,点云势必存在由运动带来的畸变,而IMU相对lidar是一个更为高频的传感器,在lidar累积时间内有多帧IMU测量,在向前传播时系统获得了每一帧IMU所对应得状态,在反向传播中,系统将根据向前传播时相对高频IMU的状态对点云进行运动补偿,完成点云畸变校正。
图3点云运动补偿
如图3所示:
M3D R_i(R_imu * Exp(angvel_avr, dt));//点所在时刻imu的旋转
V3D P_i(it_pcl->x, it_pcl->y, it_pcl->z);//点所在时刻的位置(雷达坐标系下)
V3D T_ei(pos_imu + vel_imu * dt + 0.5 * acc_imu * dt * dt - imu_state.pos);
//P_compensate:补偿后点的坐标
//R_i,pos_imu,为点所在时刻imu的旋转与位置
V3D P_compensate = imu_state.offset_R_L_I.conjugate() *
(imu_state.rot.conjugate() * (R_i * (imu_state.offset_R_L_I * P_i + imu_state.offset_T_L_I) + T_ei)
- imu_state.offset_T_L_I);
//P_compensate为补偿后t_e时刻点的lidar系坐标,我们直接来看P_compensate的计算过程
//1:P_I=imu_state.offset_R_L_I * P_i + imu_state.offset_T_L_I->将t_n时刻雷达系下的点P_i 转换到imu系,得到点P_I
//2:P_w=R_i *P_I+pos_imu + vel_imu * dt + 0.5 * acc_imu * dt * dt->在t_n时刻下将点P_I转到世界得到点P_w
//3:P_ie=imu_state.rot.conjugate()*P_w-- imu_state.pos ->将将t_n时刻的点P_w转到最后时刻t_e下的imu坐标系得到点P_ie
//4:P_compensate=imu_state.offset_R_L_I.conjugate() *P_ie -imu_state.offset_T_L_I ->在t_e时刻下,将点P_ie转的雷达坐标系得到点P
5.观测模型
图4观测模型
将其根据状态量改写一下,为避免歧义这里用t代替了状态量中的p。
(式5.2)
(式5.3)
(式5.4)
看一下代码,这块的求解没有困难,整个观测在函数h_share_model()中
void h_share_model(state_ikfom &s, esekfom::dyn_share_datastruct<double> &ekfom_data)
将点从lidar系转global //lidar坐标系下的点
//lidar坐标系下的点
V3D p_body(point_body.x, point_body.y, point_body.z);
//将点转换至世界坐标系下
V3D p_global(s.rot * (s.offset_R_L_I * p_body + s.offset_T_L_I) + s.pos)
在ikd-tree中寻找最近的五个点
//point_world=p_global
ikdtree.Nearest_Search(point_world, NUM_MATCH_POINTS, points_near, po intSearchSqDis);
拟合平面
esti_plane(pabcd, points_near, 0.1f)
计算残差
//对比式5.4,注意这个式子pd2在最后是加了负号的
float pd2 = pabcd(0) * point_world.x + pabcd(1) * point_world.y + pabcd(2) * point_world.z + pabcd(3);
至此,每一个点的残差怎么求我们介绍完了,式5.2中还有一个H是待求的,观测方程雅可比的求解是一个重点问题。在Fast-Lio中,状态量不包含IMU与lidar的外参,代码中也对无外参下的雅可比求解进行了保留,此处我们对有无外参的情况都分析一下:
无外参时主要参考了这篇文章的推导《FAST-LIO推导》,我们将式5.1改写成无外参的形式,简写一下符号
(式5.5)
根据式(5.3),此处我们定义的旋转为IMU-Global,采用右扰动。
(式5.6)由于代码里状态量中的旋转量均是四元数形式,注意到上式如果R是四元数是不方便直接计算的。当然,可以将四元数转化成旋转矩阵的形式再进行计算,也可以将5.6的结果进一步转化成代码的形式再代入四元数进行运算:
(式5.7)
读者可自行进行验算,两种形式的计算结果是一致的,我们再来看看有外参时,先看代码
//代码放在中间方便读者对照
//point_crossmat:点在imu坐标系下的坐标变成反对称^形式
//point_be_crossmat:点在lidar坐标系下点的反对称^形式
//norm_vec为法向量
//VEC_FROM_ARRAY----求向量的转置
V3D C(s.rot.conjugate() * norm_vec);
V3D A(point_crossmat * C);
V3D B(point_be_crossmat * s.offset_R_L_I.conjugate() * C);
//无外参下的H,VEC_FROM_ARRAY(A)-------->(式5.7)
ekfom_data.h_x.block<1, 12>(i, 0) << norm_p.x, norm_p.y, norm_p.z, VEC_FROM_ARRAY(A), 0.0, 0.0, 0.0, 0.0, 0.0, 0.0;
//有外参下的H
//VEC_FROM_ARRAY(A)-------->(式5.9)
//VEC_FROM_ARRAY(B)-------->(式5.10)
//VEC_FROM_ARRAY(C)-------->(式5.8)
ekfom_data.h_x.block<1, 12>(i, 0) << norm_p.x, norm_p.y, norm_p.z, VEC_FROM_ARRAY(A),VEC_FROM_ARRAY(B), VEC_FROM_ARRAY(C);
先对于两个平移量求偏导:
(式5.8)
(式5.9)
(式5.10)
对于每一个筛选合格的点,我们都能求出其观测残差及对应的雅可比,将每个合格观测点的对应的残差与雅可比求出,组成整体的观测残差为z与雅可比矩阵为,整理一下式5.2,得到了本节我们想要的结果:
(式5.11)
6.迭代更新
Fast-Lio2采用IEKF进行更新,由于观测模型中通过一阶泰勒展开进行了线性化近似(式5.2),当线性化点与真值点距离较远时,线性化误差较大,当线性化点离真值越近时,线性化误差越小,缓解这一问题的方法就是在更新过程中,通过迭代进行重复观测,逐渐逼近最优线性化点,降低非线性带来的误差,提高估计精度。
(式6.1)
(式6.2)
(式6.3)
计算过程代码如下
//A_matrix()求J
Eigen::Matrix<typename Base::scalar, 3, 3> A_matrix(const Base & v){
Eigen::Matrix<typename Base::scalar, 3, 3> res;
double squaredNorm = v[0] * v[0] + v[1] * v[1] + v[2] * v[2];
double norm = std::sqrt(squaredNorm);
if(norm < MTK::tolerance<typename Base::scalar>()){
res = Eigen::Matrix<typename Base::scalar, 3, 3>::Identity();
}
else{
//注意这里squaredNorm是模的平方,v是没有单位化的向量,和式6.6是一致的
res = Eigen::Matrix<typename Base::scalar, 3, 3>::Identity() +
(1 - std::cos(norm)) / squaredNorm * hat(v) +
(1 - std::sin(norm) / norm) / squaredNorm * hat(v) * hat(v);
}
return res;
}
我们可以写出第k次迭代时有:
其中,卡尔曼增益为:
在Fast-Lio2提供的基于livox-horizon数据中,一般来说,观测点有接近1千个,因此 是一个具有接近1千行的矩阵,对其求逆的代价非常大,Fast-Lio2通过矩阵求逆引理给出了K的另一种形式,引用《Fast-Lio公式推导》
讨论一下协方差的变换问题,对于名义状态中处于流形上的旋转量而言,其对应误差状态的协方差实际上处于名义状态的切空间,在迭代过程中,误差状态“”回名义状态,名义状态被修正后,对应的切空间也随之发生变化。因此我们需要将误差状态对应的协方差投影变换(重置)到新的切空间,我们再次将式6.5搬出来:
7.主要参考
[1]. Xu W, Zhang F. Fast-lio: A fast, robust lidar-inertial odometry package by tightly-coupled iterated kalman filter[J]. IEEE Robotics and Automation Letters, 2021, 6(2): 3317-3324.
[2]. Xu W, Cai Y, He D, et al. Fast-lio2: Fast direct lidar-inertial odometry[J]. arXiv preprint arXiv:2107.06829, 2021.
[3] https://github.com/hku-mars/FAST_LIO
[4] 半闲居士:简明ESKF推导
https://zhuanlan.zhihu.com/p/441182819
[5] 鬼木士:FAST-LIO公式推导
https://zhuanlan.zhihu.com/p/561877392
[6] Quaternion kinematics for the error-state Kalman filter
文章转载自公众号:深蓝AI
