李群李代数

导数的定义是这样的

SLAM问题会构建一个优化问题,求解最优的R t,使误差最小化。这必然涉及求导,SLAM的运动过程是连续的,所以一定是可以求导的。在SLAM中最常见的变换是四维变换矩阵T以及三维旋转矩阵R。但是它们对加法并不封闭: 两个变换矩阵之和明显不是变换矩阵;而旋转矩阵是正交矩阵,但两个正交矩阵之和不是正交矩阵。需要把函数f换成旋转矩阵R,对自变量添加一个微小值来进行,但是前面我们说了,旋转矩阵做加减法运算之后就不再满足旋转矩阵的形式了,没有加减法就意味着不能求导了。

为什么会出现李群李代数的概念,原因就在于此。SLAM应当能求导,只是我们不该用这两种矩阵的形式,而是用李代数。

另外两个原因:

  1. 旋转矩阵都是正交矩阵且行列式为1,它们作为优化变量时,会引入额外的约束,使优化更困难了。
  2. 优化变量的迭代阶段: 不能直接用两种矩阵相加

引入李群和李代数,就可以解决旋转矩阵求导问题,也就是说李代数se(3)和so(3)对加法封闭,所以用李代数表示机器人的旋转和位移。求导结束后,再对数映射为李群,这个过程都是由优化器完成的,无需我们手动。

李群和李代数

具有连续(光滑)性质的群。整数群是离散的,没有连续性质。SO(3)和SE(3)都是连续的,空间中的机器人的姿态是连续变化的,而不是磕磕绊绊地运动,所以都是李群。

机器人的旋转是一个随时间连续变化的过程,函数是R(t),根据旋转矩阵的性质

经过一系列推导,发现 是一个反对称矩阵,因此写做

这里的 是一个三维向量,对应到 SO(3) 的李代数 ,李群和李代数直接是指数映射。

假设在很小的时间范围内, 是一个恒定值,因为实际中机器人的姿态并不会突变。再经过一系列推导得到

李代数包括一个集合,一个数域和一个二元运算符,它们满足四条性质:封闭性、双线性、自反性、雅克比等价。

在不引起歧义的情况下,我们说李代数的元素是三维向量或3维反对称矩阵,不做区别。

对于李群SE(3),对应的李代数 每个元素是一个6维向量,前三维是平移,后三维是旋转,实际就是 元素。 此时的 符号是把6维向量转换为四维的变换矩阵 T

对于李代数 的元素 ,由于它是3维向量,写成 是和 方向相同的单位向量,的模长。

经过一连串推导,获得

这实际就是罗德里格斯公式 3.14.

旋转矩阵的李代数实际上就是旋转向量组成的空间。指数映射其实就是通过罗德里格斯公式变换的。

BCH公式


根据BCH公式,第2个公式右边还有一些余项

李代数的求导问题

对于公式 4.39,需要计算目标函数J关于变换矩阵T的导数。我们经常构建与位姿有关的函数,然后讨论该函数关于位姿的导数,以调整当前的估计值。但是旋转矩阵和变换矩阵,它们对加法都没有良好的定义,所以对姿态有关的函数求导,只能通过李代数进行。关于用李代数解决求导问题,有两种思路:

  1. 用李代数表示姿态,然后根据李代数加法对李代数进行求导
  2. 对李群左乘或者右乘微小扰动,然后对该扰动求导。即左扰动和右扰动模型。

因为旋转矩阵没有加法,所以我们就对旋转矩阵的李代数就行求导,也就是李代数的局部坐标上添加扰动,由于李代数本身对应旋转向量,因此对旋转向量添加扰动相当于同时改变旋转轴和旋转角度。

对一个空间点进行旋转,得到,现在计算旋转后的点坐标相对旋转的导数,不严谨地记做

由于没有加法,假设对应的李代数为,于是我们计算出

平时不用这种方法,而是用扰动模型法。


不管是李代数求导还是扰动模型,都是旋转矩阵对李代数的求导

对R进行一次扰动 ,这个扰动可以乘在 左边,也可以是右边。假设 对应的李代数为 ,然后对 求导

  • 左扰动模型
  • 右扰动模型

扰动模型,显然计算量上要少非常多。

旋转连乘的求导的推导

逆旋转点的求导

逆旋转函数的求导


高斯牛顿法

推导过程


有些人可能产生疑问,为什么通过最小二乘而不是其他方式获得最优函数,比如说最小化误差的绝对值的和?这里的推导使用最大似然估计推导出 就提供了概率学基础


或者简单的推导
推导 1.png
推导 2.png

特点:

  • 要求H正定,但实际只保证半正定。如果觉得太抽象,可以把 理解为 ,把正定理解为正数,半正定理解为非负。
  • 可能出现H为奇异矩阵或病态矩阵,此时增量的稳定性较差,算法会不收敛。
  • 即使H非奇异非病态,如果步长 太大,也会导致不收敛。
  • 只能在展开点附近有好的近似效果

根据十四讲的推导过程,牛顿法是对 的二阶展开,高斯牛顿法是对 的展开

参考:
graph slam tutorial :从推导到应用2
牛顿法 高斯牛顿法
非线性优化整理-2.高斯牛顿法


牛顿法 梯度下降法

非线性最小二乘

非线性优化问题就是针对一个非线性函数求最值的问题,但很难通过求导数的方式求解。
非线性最小二乘问题的形式如下:

自变量 ,f 是任意的非线性函数,我们可以假设它有m维:

我们一般用李代数表示机器人的平移和旋转,常常无法对f进行直接求导,所以使用迭代的方式求解,非线性优化的几个解法都是使用迭代

  1. 给定某个初始值
  2. 对于第k次迭代,寻找一个增量, 使得 达到极小值
  3. 足够小,则停止
  4. 否则,令 , 返回第2步

非线性最小二乘问题就转为寻找 的问题,直到满足迭代次数或者目标函数变化非常小为止。此时认为算法收敛,目标函数达到极小值。

非线性优化中的牛顿法

普通的函数求极值就是对f’(x)=0进行求解,但是有时导数难以获得,所以把f(x)进行二阶泰勒展开

右侧对 求导并令其为0,可以获得

也就是

现在放到高维情况下,把最开始的式子在x附近二阶泰勒展开:

变量的增量就可以获得:

J是关于x的一阶导数,雅克比矩阵;H是二阶导数,海森矩阵。

牛顿法的思路是将函数 f 在 x 处展开为多元二次函数,再通过求解二次函数最小值的方法得到本次迭代的下降方向。那么问题来了,多元二次函数在梯度为0的地方一定存在最小值么?直觉告诉我们是不一定的。以一元二次函数 g(x)=ax2+bx+c为例,我们知道当a>0时,g(x)可以取得最小值,否则g(x)不存在最小值。

推广到多元的情况,可以得出二次项矩阵(Hessian矩阵)必须是正定的,函数f(x)的最小值才存在。因此, 牛顿法首先需要计算海森矩阵并且判断其正定性,这在问题规模很大时非常困难,我们希望避免计算海森矩阵。



牛顿法的特点

  • 在极小值点附近,牛顿法的收敛速度比梯度下降法 快很多
  • 需要计算海森矩阵,但问题规模大时,计算难度很大
  • 牛顿法经常会因为海森矩阵不正定而发散,因此牛顿法并不是非常的稳定

梯度下降法

对上面公式(1)进行展开时,如果只保留一阶项,对应一阶梯度法,

我们希望迭代过程中的函数值逐步减小,也就是。 可以让 由于,所以实现了函数值下降

也就是沿着反向梯度的方向,还要取一个步长,这就是最速下降法。 特点: 最速下降法过于贪心,越靠近极小值速度越慢,容易走出锯齿路线,反而增加迭代次数。

牛顿法是二阶收敛,梯度下降是一阶收敛,所以牛顿法就更快。如果更通俗地说的话,比如你想找一条最短的路径走到一个盆地的最底部,梯度下降法每次只从你当前所处位置选一个坡度最大的方向走一步,牛顿法在选择方向时,不仅会考虑坡度是否够大,还会考虑你走了一步之后,坡度是否会变得更大。可以说牛顿法比梯度下降法看得更远一点,能更快地走到最底部。(牛顿法目光更加长远,所以少走弯路;相对而言,梯度下降法只考虑了局部的最优 ,没有全局思想。)

从几何上说,牛顿法就是用一个二次曲面去拟合你当前所处位置的局部曲面,而梯度下降法是用一个平面去拟合当前的局部曲面。通常情况下,二次曲面的拟合会比平面更好,所以牛顿法选择的下降路径会更符合真实的最优下降路径。

数值分析中的牛顿法

牛顿迭代法最常见的一个应用就是求方根

牛顿法是一种在实数域和复数域上近似求解方程的方法。方法使用函数f(x)的泰勒级数的前面几项来寻找方程f(x) = 0的根。牛顿法最大的特点就在于它的收敛速度很快。

首先,选择一个接近函数f(x)零点的 x0,计算相应的f(x0) 和切线斜率f’(x0)。然后我们计算穿过点(x0, f(x0) )并且斜率为f’(x0)的直线和 x 轴的交点的x坐标,也就是求如下方程的解:
f’(x0)(x-x0) + f(x0) = 0

这样将新求得的点的x坐标命名为x1,通常x1会比x0更接近方程f(x) = 0的解。因此可以利用x1开始下一轮迭代,迭代公式如下所示:

也就是知道曲线在某个点x0的切线l,用l和x轴的交点作为下一个迭代点x1,如此迭代来逼近曲线和x轴的交点。

已经证明,如果f'是连续的,并且待求的零点x是孤立的,那么在零点x周围存在一个区域,只要初始值x0位于这个邻近区域内,那么牛顿法必定收敛。下图为一个牛顿法执行过程的例子

参考:
牛顿法 高斯牛顿法
梯度下降法(gradient descent)与牛顿法


处理子图 2. 理论,hit表和miss表

接上一篇,把激光雷达的扫描数据插入子图,是通过ActiveSubmaps2D所提供的插入器对象range_data_inserter_完成的,也就是ProbabilityGridRangeDataInserter2D::Insert函数,用到了激光扫描的RayCasting模型。这里就涉及到论文的内容了

三维激光SLAM形成的点云地图不需要自己手动实现点云的数据结构, PCL中有写好的数据类型, 直接调用就行. 视觉SLAM形成的点云地图也可以用PCL来实现。

但是二维激光SLAM的栅格地图需要自己手动实现, 目前所有的二维激光SLAM的栅格地图都是SLAM作者自己写的,没有通用的数据结构,最后会转成ROS的地图数据结构。cartogrpaher,gmapping,hector皆是如此

理论基础

首先明确几个概念:

  • Probability: 占据概率,下面用 p 表示
  • CorrespondenceCost: 空闲概率(浮点型),对应的Value为线性映射到 [1-32767] 整型范围
  • Odds:

hit_tablemiss_table存储的是free概率,但设置和获取均为hit概率,为何中间多一个转换步骤,因为匹配操作时采用类似最小二乘的优化思想。

在占据栅格地图中,对于一个点,我们用 来表示它是Free状态的概率,用 来表示它是Occupied状态的概率,两者的和为1。

默认情况下,对于一个cell,无论hit或miss事件, 都是0.5。 scan经过cell后,观测值z会更新cell的状态,这显然是个条件概率的问题,现在要考虑的不是 了,而是观测的条件概率:
所以一个栅格实际为 hit 的概率和 miss 的概率,因此可以用贝叶斯公式进行求取

我们希望一个栅格中仅有一个值,因此引入 这一概念(两者的比值)来衡量cell的状态:

设置一个概率的最大最小值,防止分母出现0的情况,比如[0.1,0.9]。
初始情况下,一个cell的状态没有任何信息,

cell原来的Odd是 ,现在就变成了 ,再使用贝叶斯公式变换得到

也就是说

其中k-1为上一时刻的 (cell第一次被scan更新),而k为经过一次观测后的 (cell再次被更新),显然仅是相差了 这个系数。由于栅格表示是否为障碍物,观测采用激光测距是否障碍,地图中点的更新状态有三种:hit、miss、free(不更新)。

其中表示的是当cell为障碍时,传感器认为是障碍的概率,这显然是传感器本身属性决定的,因此为常数。同样其他3种组合也均为常数,因此系数 是根据观测hit和miss决定的两个常数,即 两个常数。当传感器测到此cell为 hit 时,使用 ,反之是

论文中的栅格 hit 概率更新公式是

clamp是c++中的函数:将结果限制在[0, 1]。 odds运算是从占用概率计算odds,逆运算就是反过来。 我理解的是:先不看clamp,两边进行odds运算,公式就变成了这样

与上面的公式(3) 应该是一样的

注意: lua配置中的 hit_probability = 0.55miss_probability = 0.49指的是

其他大部分SLAM实现地图更新采用对数加减的方式:

举例如下

对数形式的栅格更新过程,栅格经多次扫描后的概率会累计
同样地,ProbabilityGrid在将点云插入到栅格地图时, 即使这个栅格被之前的点云更新过, 当前帧的点云还是会对这个栅格进行更新

第二次观测会在第一次的基础上累计,其实这里只是个举例,还不太准确,对于图中圈出的栅格,第一次是从栅格中心射过,但第二次是从角落射过, 从对角穿过对这个栅格的空闲概率的提升显然作用更大, 而只穿过一个小角落显然贡献很小,所以第二次不应该是 -0.7, 0.9的栅格也是同样道理

如果栅格地图以 odds 的形式表示,那么更新过程就只是一个乘法运算,效率也还可以。但Cartographer还是想要进一步的提高效率,它以内存空间换取时间,构建了hit表和miss表。 实际上是两个 vector<uint16>保存的是空闲概率映射后的索引值。 如果发生了hit事件,就从hit表中查找更新后的数据。发生了miss事件则查找miss表。

表的更新过程
ProbabilityGrid在将点云插入到栅格地图时, 新的栅格值是预先通过查找表计算好的, 直接查表就行了, 不用再重新计算.

ProbabilityGridRangeDataInserter2D 和 两个表的构建

ProbabilityGridRangeDataInserter2D值得关注的成员函数只有Insert,只有三个成员变量:

1
2
3
4
const proto::ProbabilityGridRangeDataInserterOptions2D  options_;
// 用于更新栅格单元的占用概率的查找表
const std::vector<uint16> hit_table_;
const std::vector<uint16> miss_table_;

构造函数

1
2
3
4
5
6
7
ProbabilityGridRangeDataInserter2D::ProbabilityGridRangeDataInserter2D(
const proto::ProbabilityGridRangeDataInserterOptions2D& options)
: options_(options),
hit_table_(ComputeLookupTableToApplyCorrespondenceCostOdds(
Odds(options.hit_probability()) ) ),
miss_table_(ComputeLookupTableToApplyCorrespondenceCostOdds(
Odds(options.miss_probability()) )) {}

其中调用的名字很长的函数在probability_values.cc文件中,它完成查找表hit_table_miss_table_的初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 构建查找表, 处理某一个cell的 CorrespondenceCostValue 已知时  如何更新的情况
std::vector<uint16> ComputeLookupTableToApplyCorrespondenceCostOdds(float odds)
{
std::vector<uint16> result;
result.reserve(kValueCount); // 32768
/* 三个函数从里到外依次为:
对odds转换成占据概率p;
求空闲概率 1-p;
转换为[1, 32767]的存储值*/

// kUpdateMarker表示此栅格的更新
result.push_back(CorrespondenceCostToValue(ProbabilityToCorrespondenceCost(
ProbabilityFromOdds(odds) )) + kUpdateMarker);
// 计算存储值从1到32767所对应的更新值并塞进result中
for (int cell = 1; cell != kValueCount; ++cell)
{
result.push_back(
CorrespondenceCostToValue(
ProbabilityToCorrespondenceCost(ProbabilityFromOdds(
odds * Odds(CorrespondenceCostToProbability(
(*kValueToCorrespondenceCost)[cell]) ) ) )) +
kUpdateMarker );
}
return result;
}

uint16的范围是[0, 65535],定义的vector有 32768 个元素,先根据参数 odds插入了一个元素,然后在for循环里又插入了剩下的32767个元素。

对第一个元素,把参数odds(lua中配置的hit和miss概率)转为空闲概率对应的[1, 32767]的索引值,然后再加上32768,结果的范围是[32768+1, 65535]CorrespondenceCostToValue函数极为复杂,不用研究细节了。

对剩下的元素,所有odds均乘以更新系数()并放入表格中

对于参数hit_probability=0.55, hit_table_第一个成员是47104。 对于参数miss_probability=0.49, miss_table_第一个成员是49562。 两个表大不相同。

变量kValueToCorrespondenceCost的定义:const std::vector<float>* const kValueToCorrespondenceCost = PrecomputeValueToCorrespondenceCost().release();,其实是一系列的浮点数,查看定义会发现相应函数的参数全都写死了。我运行了一下,发现结果是0和0.1到0.9的32768个数,其中0.1到0.9之间的一大堆数,间隔大约是0.24。 其实是建立了如下的关系
PrecomputeValueToCorrespondenceCost.png

两个表的数据只取决于参数 hit_probability 和 miss_probability

代码里加上了kUpdateMarker,所以Grid2D的成员函数FinishUpdate里会将存储值都减去一个kUpdateMarker

kUpdateMarker 为已更新后的标志量,是为了防止miss和hit重复对地图更新,其值为32768,为uint16的最高位,其操作的方式是,更新时加上,更新完了后减去。

Submaps2D 和 ProbabilityGrid 都有成员变量 conversion_tables_

          补充 ,看函数GetProbability获取占用概率

1
2
3
4
5
6
float ProbabilityGrid::GetProbability(const Eigen::Array2i& cell_index) const
{
if (!limits().Contains(cell_index)) return kMinProbability;
return CorrespondenceCostToProbability(ValueToCorrespondenceCost(
correspondence_cost_cells()[ToFlatIndex(cell_index)]) );
}

correspondence_cost_cells就是栅格的索引值,保存的是空闲概率转成uint16后的[0, 32767]的值,0代表未知。

ValueToCorrespondenceCost是索引值转成空闲概率

参考:
查找表与占用栅格更新


处理子图 1. 插入子图的流程
1
2
3
4
5
6
7
8
struct InsertionResult
{
// 插入的节点数据,T子图更新时在局部地图中的位姿,以及有传感器原始数据转换之后的点云信息
// 记录了更新子图的时刻和重力方向
std::shared_ptr<const TrajectoryNode::Data> constant_data;
//已经建立起来的子图列表
std::vector<std::shared_ptr<const Submap2D>> insertion_submaps;
};

保存插入Local Slam的一个节点的数据结构,也就是下面这个东西

在类LocalTrajectoryBuilder2D中,通过对象active_submaps_来维护子图,它是一个ActiveSubmaps2D类型的数据。根据最新版本的代码,
除了刚开始构建的时候,没有子图(Submap2D),其他时候它都维护着两个子图对象。 一个子图用于进行扫描匹配,称为旧图。另一个子图用于插入扫描数据,作为储备,被称为新图。 当新图中插入一定数量的数据完成了初始化操作之后,它就会被当作旧图,用于扫描匹配。 对象active_submaps_将抛弃原来的旧图,并重新构建一个新图。下表中列出了所有成员变量
变量

ActiveSubmaps2D的构造函数:

1
2
3
4
5
// std::unique_ptr<RangeDataInserterInterface>   range_data_inserter_;
// 构建了插入器对象,代码只是根据配置对 ProbabilityGridRangeDataInserter2D 类的赋值
ActiveSubmaps2D::ActiveSubmaps2D(const proto::SubmapsOptions2D& options)
: options_(options), range_data_inserter_(CreateRangeDataInserter() )
{}

RangeDataInserterInterface明显是个抽象类,它的派生类就是TSDFRangeDataInserter2DProbabilityGridRangeDataInserter2D,根据配置,我们一般用后者。 CreateRangeDataInserter()其实就是
1
2
3
return absl::make_unique<ProbabilityGridRangeDataInserter2D>(
options_.range_data_inserter_options()
.probability_grid_range_data_inserter_options_2d());


InsertIntoSubmap函数开始,就是向子图插入传感器数据的过程,总流程:
插入子图的总流程

InsertIntoSubmap

1
2
3
4
5
6
7
8
9
struct RangeData {
Eigen::Vector3f origin; // 当次扫描测量时激光雷达的位置
// PointCloud就是 vector<Eigen::Vector3f>
// 扫描到的hit点与miss点
PointCloud returns;
PointCloud misses;
};
// 对2D SLAM, 第三个元素为0
// typedef std::vector<Eigen::Vector3f> PointCloud;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/*InsertIntoSubmap(
time, range_data_in_local, filtered_gravity_aligned_point_cloud,
pose_estimate, gravity_alignment.rotation() );*/
std::unique_ptr<LocalTrajectoryBuilder2D::InsertionResult>
LocalTrajectoryBuilder2D::InsertIntoSubmap(
const common::Time time, const sensor::RangeData& range_data_in_local,
const sensor::PointCloud& filtered_gravity_aligned_point_cloud,
const transform::Rigid3d& pose_estimate,
const Eigen::Quaterniond& gravity_alignment)
{
if (motion_filter_.IsSimilar(time, pose_estimate) )
return nullptr;

std::vector<std::shared_ptr<const Submap2D>> insertion_submaps =
active_submaps_.InsertRangeData(range_data_in_local);

return absl::make_unique<InsertionResult>( InsertionResult{
std::make_shared<const TrajectoryNode::Data>(TrajectoryNode::Data{
time,
gravity_alignment,
filtered_gravity_aligned_point_cloud,
{},
{},
{},
pose_estimate}),
std::move(insertion_submaps)} );
}

MotionFilter

motion_filter部分的IsSimilar是这个类唯一值得关注的函数,内容很简单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
bool MotionFilter::IsSimilar(const common::Time time,
const transform::Rigid3d& pose)
{
LOG_IF_EVERY_N(INFO, num_total_ >= 500, 500)
<< "Motion filter reduced the number of nodes to "
<< 100. * num_different_ / num_total_ << "%.";
++num_total_;
if (num_total_ > 1 &&
time - last_time_ <= common::FromSeconds(options_.max_time_seconds()) &&
(pose.translation() - last_pose_.translation()).norm() <=
options_.max_distance_meters() &&
transform::GetAngle(pose.inverse() * last_pose_) <=
options_.max_angle_radians() )
{
return true;
}
last_time_ = time;
last_pose_ = pose;
++num_different_;
return false;
}

判断位姿和上一次插入scan的位姿是否超过一定的范围,然后再决定是否插入scan。 这个位姿就是ceres返回的位姿做重力对齐

ActiveSubmaps2D::InsertRangeData

这里是 插入子图的核心函数,有子图插入点云数据和终结子图两条线
InsertRangeData

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
std::vector<std::shared_ptr<const Submap2D> > 
ActiveSubmaps2D::InsertRangeData(const sensor::RangeData& range_data)
{
// vector<std::shared_ptr<Submap2D> > submaps_;
// 如果当前一个子图也没有,或最后一个子图的num_range_data达到配置值(抛弃旧图)
if (submaps_.empty() ||
submaps_.back()->num_range_data() == options_.num_range_data() )
// 删除旧子图,加入新子图。 参数是局部坐标系中的位姿 x y
// 新子图的初始位置,为起始range的激光坐标
// 也就是说num_range_data个scan,只取第1个scan的初始位置
AddSubmap(range_data.origin.head<2>());

for (auto& submap : submaps_ )
submap->InsertRangeData(range_data, range_data_inserter_.get() );

if (submaps_.front()->num_range_data() == 2 * options_.num_range_data())
submaps_.front()->Finish();
return submaps();
}

先看AddSubmap

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void ActiveSubmaps2D::AddSubmap(const Eigen::Vector2f& origin)
{
if (submaps_.size() >= 2)
{
// 在插入新子图之前,crop the finished Submap
// 以减少内存的占用。 保证只有两个子图
CHECK(submaps_.front()->insertion_finished());
submaps_.erase(submaps_.begin() ); // 删除旧子图,但对象没有销毁
}
submaps_.push_back( absl::make_unique<Submap2D>(
origin,
std::unique_ptr<Grid2D>(
static_cast<Grid2D*>(CreateGrid(origin).release()) ),
&conversion_tables_) );
}

注意删除的旧子图是begin()头,保留的是back()尾。 此时头有2N个scan,尾有N个scan。erase头之后,因为调用的是push_back,所以之前的尾变成了新的头,新的尾此时没有scan。 添加submap时记录了它在local map坐标系下的位置local_pose_以及Grid的限制条件,比如分辨率,长宽,最大值。 CreateGrid在下面讲解。

也就是逻辑如下:

在拥有两个子图后,头部的子图总比尾部多N个scan。 ScanMatch里会看到,头部的子图(旧图)用于 real_time_correlative_scan_matcher

Submap2D 和 Grid2D

它是ActiveSubmaps2D中维护的子图数据类型, 该数据类型继承自Submap。父类Submap定义了2D和3D子图的一些共有的属性,该类主要描述了子图在local坐标系下的相对位姿,记录插入的数据数量以及是否构建完成等基本信息。

Submap的成员变量如下:

1
2
3
const  transform::Rigid3d   local_pose_;  // 子图在local坐标系的位姿 
int num_range_data_ = 0; // 子图中插入的数据数量
bool insertion_finished_ = false; // 标志着子图是否已经构建完成

Submap2D类增加一个很重要的成员变量grid_类型Grid2D,用于保存子图的详细信息,比如概率栅格的占用情况,子图的大小等。成员变量如下:
类Grid2D
最重要的是 correspondence_cost_cells_:地图栅格值,存储free概率转成uint16后的[0, 32767]范围的值,0代表未知

value_to_correspondence_cost_table_: 将[0, 1~32767] 映射到 [0, 0.1~0.9] 的转换表
update_indices_记录已经更新过的索引

函数CreateGrid用于为子图创建栅格信息存储结构。在获取了子图尺寸和分辨率信息之后,就构建了一个ProbabilityGrid类型的栅格存储。(一般不用TSDF格式)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
std::unique_ptr<GridInterface> ActiveSubmaps2D::CreateGrid(
const Eigen::Vector2f& origin)
{
constexpr int kInitialSubmapSize = 100;
float resolution = options_.grid_options_2d().resolution();
// 省略TSDF格式
// 构建了一个ProbabilityGrid 类型的栅格存储
return absl::make_unique<ProbabilityGrid>(
MapLimits(resolution,
origin.cast<double>() + 0.5 * kInitialSubmapSize * resolution *
Eigen::Vector2d::Ones(),
// CellLimits函数只有赋值给成员 num_x_cells和num_y_cells
// 只保证num_x_cells和num_y_cells大于0
CellLimits(kInitialSubmapSize, kInitialSubmapSize) ),
&conversion_tables_ );
}

MapLimits用于描述地图的范围,它有三个成员变量

MapLimits第2个参数是max,记录了地图的x和y方向上的最大值( 地图坐标系的左上角 )。这样origin就在submap的中心,保证该submap和上一个submap有足够重合的部分,最终建成的子图列表是这样的

Submap2D::InsertRangeData

回头还看InsertRangeData,发现出现了一个Submap2D::InsertRangeData

1
2
3
4
5
6
7
8
9
void Submap2D::InsertRangeData(
const sensor::RangeData& range_data,
const RangeDataInserterInterface* range_data_inserter)
{
CHECK(grid_);
CHECK(!insertion_finished());
range_data_inserter->Insert(range_data, grid_.get());
set_num_range_data(num_range_data() + 1);
}

它是将激光的扫描数据插入到grid_对象中,在调用该函数之前需要确保子图更新还没有结束。其实有用的就一句:range_data_inserter->Insert(range_data, grid_.get());,实际调用的是ProbabilityGridRangeDataInserter2D::Insert,这个函数看另一篇文章:处理子图 3. CastRays和更新栅格概率


前端 6. AddAccumulatedRangeData(3) 加入位姿估计器和插入子图
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// pose_estimate_2d 是 scanMatch返回的
// cres计算的位姿需要再跟重力方向对齐 才是LocalSLAM的位姿
const transform::Rigid3d pose_estimate = transform::Embed3D(*pose_estimate_2d) *
gravity_alignment;
// 对应流程图,反馈给 extrapolator_ 以修正估计值
// 添加到PoseExtrapolator的timed_pose_queue_队列
extrapolator_->AddPose(time, pose_estimate);

// 下面使用的还是ceres返回的位姿
// 把点云数据转化为在 local map 坐标系 中的点云数据
/* 注意这里的点云 range_data_in_local 来自 gravity_aligned_range_data
后者是没有经过voxel filter的点云,否则加入地图的点就太少了 */
// gravity_aligned_range_data的 origin,return,miss都左乘 pose_estimate_2d
sensor::RangeData range_data_in_local =
TransformRangeData( gravity_aligned_range_data,
transform::Embed3D(pose_estimate_2d->cast<float>()) );
// 更新Submap, 同时返回插入结果 将LocalSLAM坐标系下的点云插入 active_submap
// filtered_gravity_aligned_point_cloud 实际没参与 插入子图,只作为insert result的成员返回
std::unique_ptr<InsertionResult> insertion_result = InsertIntoSubmap(
time, range_data_in_local, filtered_gravity_aligned_point_cloud,
pose_estimate, gravity_alignment.rotation() );
// ......时间处理部分

// 传入后端
return absl::make_unique<MatchingResult>(
MatchingResult{ time, pose_estimate, std::move(range_data_in_local),
std::move(insertion_result) } );

时间处理部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const auto wall_time = std::chrono::steady_clock::now();
if (last_wall_time_.has_value())
{
const auto wall_time_duration = wall_time - last_wall_time_.value();
// 用来监视延迟
kLocalSlamLatencyMetric->Set(common::ToSeconds(wall_time_duration));
if (sensor_duration.has_value())
{
kLocalSlamRealTimeRatio->Set(
common::ToSeconds(sensor_duration.value()) /
common::ToSeconds(wall_time_duration) );
}
}
const double thread_cpu_time_seconds = common::GetThreadCpuTimeSeconds();
if (last_thread_cpu_time_seconds_.has_value())
{
const double thread_cpu_duration_seconds =
thread_cpu_time_seconds - last_thread_cpu_time_seconds_.value();
if (sensor_duration.has_value() )
{
kLocalSlamCpuRealTimeRatio->Set(
common::ToSeconds(sensor_duration.value()) /
thread_cpu_duration_seconds );
}
}
last_wall_time_ = wall_time;
last_thread_cpu_time_seconds_ = thread_cpu_time_seconds;

坐标系之间的关系和 InsertionResult

Frames(坐标系)

  • global map frame

这是表示全局 SLAM 结果的坐标系。它是包含所有闭环和优化结果的固定地图坐标系。 当新的优化结果出现时,该帧与其他帧之间的转换可以跳转。 它的 z 轴指向上方,也就是重力加速度矢量指向-z 方向,也就是说加速计测量的重力分量是 + z 方向

  • local map frame

局部SLAM结果的坐标系,不包括闭环以及位姿BA优化,对于给定某个时间点,该帧与全局地图之间的变换可能会发生变化,但是该帧和其他帧的变换不会发生变化。

  • submap frame

每个子图都有一个单独的坐标系

  • tracking frame

传感器数据表示的坐标系,自定义

  • gravity-aligned frame

重力对准坐标系,只在2D中使用,与跟踪坐标系有相同位置,但是坐标系方向不一致,类似于导航坐标系

Transforms (变换)

  • local_pose (局部姿态)

将数据从 Tracking frame 或者子图坐标系 转换到 local map frame 的变换

  • global_pose (全局位姿)

将数据从Tracking frame或者子图坐标系 转换到global map frame的变换

关系图

前端的MatchingResult包含:

1
2
3
4
time          此次matching的scan时间。
local_pose 此次matching的scan的位姿。
range_data_in_local 未过滤的距离数据。
insertion_result range_data_in_local插入submap返回的结果。

local map frame 和 submap的坐标系不是一回事。

子图的坐标系原点来自 range_data_in_local.origin 也就是ActiveSubmaps2D::InsertRangeData中的AddSubmap(range_data.origin.head<2>());

InsertionResult中的constant_data是此次range插入submap包含的主要内容,类型为TrajectoryNode::Data的共享指针。 constant_data其中有个成员const transform::Rigid3d local_pose,表示submap(node)在local map frame中的位姿,用于这两个坐标系之间的变换, 对每个子图,local_pose在不断更新 第1个local_pose是子图坐标系的原点,也就是上面的 range_data_in_local.origin,也是MotionFilter的判断依据,判断位姿和上一次插入scan的位姿有一定差别

MatchingResult里也有一个local_poseconstant_data 的那个local_pose是相同的,赋值用的都是下一帧位姿的观测值。不过在后端里用的是InsertionResultlocal_pose,源码在PoseGraph2D::AddNode。这个是在AddSubmap函数中创建新的submap的时候加入的,是创建submap时的第一个ranges的原点。

constant_data还有一个成员global_pose,这是submap(node)在global map frame的位姿。


后端1 AddSensorData的后端部分

MapBuilder 在创建GlobalTrajectoryBuilder时中会实例化 PoseGraph 对象, 并作为 GlobalTrajectoryBuilder 的成员。

接着看GlobalTrajectoryBuilder::AddSensorData的后端部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/* 将前端的输出结果喂给后端进行闭环检测和全局优化  */
kLocalSlamMatchingResults->Increment();
// 前端匹配的结果,即submap位置和submap
std::unique_ptr<InsertionResult> insertion_result;
// 判定前端是否将传感器的数据插入到子图中
if (matching_result->insertion_result != nullptr)
{
kLocalSlamInsertionResults->Increment();
// 将匹配后的结果加入图优化节点中
// 把前端的输出结果喂给后端,即后端增加一个新的节点
// 后端优化位姿图节点ID,记录在临时对象 node_id
auto node_id = pose_graph_->AddNode(
matching_result->insertion_result->constant_data,
trajectory_id_,
matching_result->insertion_result->insertion_submaps );
CHECK_EQ(node_id.trajectory_id, trajectory_id_);

// 重新封装前端传来的 InsertionResult,增加了一个node_id
insertion_result = absl::make_unique<InsertionResult>( InsertionResult{
node_id, matching_result->insertion_result->constant_data,
std::vector<std::shared_ptr<const Submap>>(
matching_result->insertion_result->insertion_submaps.begin(),
matching_result->insertion_result->insertion_submaps.end() ) } );
}

PoseGraph2D__AddNode 流程图.png

PoseGraph2D类的以FromProto结尾的函数, 全是在 MapBuilder::LoadState 里调用的,也就是纯定位的用途。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
NodeId PoseGraph2D::AddNode(
std::shared_ptr<const TrajectoryNode::Data> constant_data,
const int trajectory_id,
const std::vector<std::shared_ptr<const Submap2D>>& insertion_submaps)
{
// 将点云的原点坐标从local map坐标系转换为global map坐标系
const transform::Rigid3d optimized_pose(
GetLocalToGlobalTransform(trajectory_id) * constant_data->local_pose);
// 添加轨迹节点 子图节点
const NodeId node_id = AppendNode(constant_data, trajectory_id,
insertion_submaps, optimized_pose);
// 查看维护的两个submap的front是否插入完成(两个submap,front用于匹配和更新的)
// 如果一个submap finished,则需要进行闭环检测,完成会调用 Submap2D::Finish()
const bool newly_finished_submap =
insertion_submaps.front()->insertion_finished();
// 添加计算约束的任务,加入 work_queue队列,实际在DrainWorkQueue函数执行
AddWorkItem( [=]() LOCKS_EXCLUDED(mutex_)
{
return ComputeConstraintsForNode(node_id, insertion_submaps,
newly_finished_submap);
} );
return node_id;
}

后端增加一个位姿图节点需要传递匹配后的结果包括submap和点云数据,最后返回一个位姿图节点ID。其中前端处理后的点云数据,包括水平投影的点云,重力方向和在local submap的位置。

GetLocalToGlobalTransform

获取激光点云当前local map下的pose,转换为全局绝对pose的转移矩阵。也就是从local map坐标系转到global map坐标系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
transform::Rigid3d PoseGraph2D::GetLocalToGlobalTransform(
const int trajectory_id) const
{
absl::MutexLock locker(&mutex_);

return ComputeLocalToGlobalTransform(
data_.global_submap_poses_2d,
trajectory_id );
}

transform::Rigid3d PoseGraph2D::ComputeLocalToGlobalTransform(
const MapById<SubmapId, optimization::SubmapSpec2D>& global_submap_poses,
const int trajectory_id) const
{
// 查找已经优化过的trajectory id中的submap的位置,如果有可返回存储的值
auto begin_it = global_submap_poses.BeginOfTrajectory(trajectory_id);
auto end_it = global_submap_poses.EndOfTrajectory(trajectory_id);
if (begin_it == end_it)
{
const auto it = data_.initial_trajectory_poses.find(trajectory_id);
// 位姿插值
if (it != data_.initial_trajectory_poses.end())
{
return GetInterpolatedGlobalTrajectoryPose(it->second.to_trajectory_id,
it->second.time) *
it->second.relative_pose;
}
// 仅是唯一个,表明为初始submap,转移矩阵为单位矩阵
else
return transform::Rigid3d::Identity();
}
const SubmapId last_optimized_submap_id = std::prev(end_it)->id;
// 如果不存在,则返回由上个优化后的 submap_id 的位置的相对位置换算出的位置
return transform::Embed3D(
global_submap_poses.at(last_optimized_submap_id).global_pose) *
data_.submap_data.at(last_optimized_submap_id)
.submap->local_pose()
.inverse();
}

既然这个函数是返回一个transform,所以是左乘变换: global = transform * local,因此transform = global * inv(local)。 初始global为单位矩阵,也就是产生子图前,后续通过相对位置推导每个位置

InitializeGlobalSubmapPoses里可以看到获得转换矩阵的逻辑:
首先从global_submap_poses_2d中找出属于该轨迹的所有submap信息,那就找出上一个优化过的子图的全局位姿global_pose和局部位姿local_pose,通过这两个位姿就可以得到 local和global坐标系 的转换矩阵。

如果在global_submap_poses_2d没有找到该轨迹的submap信息,那就说明该轨迹还没有产生submap,那就从data_.initial_trajectory_poses中查找该轨迹的初始位姿,如果找到 了初始位姿,就根据该轨迹初始位姿的时间去节点列表中找改时间点最近的节点,根据该节点的global_pose找到 local 和 global 的转换信息。

initial_trajectory_poses来自SetInitialTrajectoryPose,根源在MapBuilder::AddTrajectoryBuildertrajectory_options.has_initial_trajectory_pose(),但是没找到函数has_initial_trajectory_pose()。怀疑这是纯定位的部分

如果initial_trajectory_poses中也没有找到该轨迹的初始位姿,就直接返回单位转换矩阵,认为二者重合。


cartographer 常用的类型

Rigid3类型在rigid_transform.h,很有学习价值

1
2
3
4
5
Vector translation_;
Quaternion rotation_;
// using Vector = Eigen::Matrix<FloatType, 3, 1>;
// using Quaternion = Eigen::Quaternion<FloatType>;
// using AngleAxis = Eigen::AngleAxis<FloatType>;

Rigid3d类型实质是包含了一个3行1列的Eigen矩阵,一个Eigen::Quaternion,可以直接用 cout
值得注意的运算符:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 参见 SLAM 十四讲
Rigid3 inverse() const
{
const Quaternion rotation = rotation_.conjugate();
const Vector translation = -(rotation * translation_);
return Rigid3(translation, rotation);
}

template <typename FloatType>
Rigid3<FloatType> operator*(const Rigid3<FloatType>& lhs,
const Rigid3<FloatType>& rhs) {
return Rigid3<FloatType>(
lhs.rotation() * rhs.translation() + lhs.translation(),
(lhs.rotation() * rhs.rotation()).normalized());
}

OptimizationProblem2D类的成员

struct NodeId,成员只有int trajectory_id;int node_index;,后者是从0开始的

struct SubmapId,成员只有int trajectory_id;int submap_index;,后者是从0开始的

class MapById是对std::map的一个封装,其实是模板<typename IdType, typename DataType>,前者可以是NodeId 或者 SubmapId

// Submaps get assigned an ID and state as soon as they are seen, even
// before they take part in the background computations.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct NodeSpec2D
{
common::Time time;
transform::Rigid2d local_pose_2d;
transform::Rigid2d global_pose_2d;
Eigen::Quaterniond gravity_alignment;
};

struct SubmapSpec2D
{
transform::Rigid2d global_pose;
};
// optimization_problem_->submap_data() 就是 SubmapSpec2D
MapById<SubmapId, optimization::SubmapSpec2D> global_submap_poses_2d;

data_是类PoseGraph2D中的成员变量,类型 PoseGraphData

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
struct PoseGraphData
{
/* struct InternalSubmapData
{
std::shared_ptr<const Submap> submap;
SubmapState state = SubmapState::kNoConstraintSearch;

// IDs of the nodes that were inserted into this map together with
// constraints for them. They are not to be matched again when this submap
// becomes 'kFinished'.
std::set<NodeId> node_ids;
}; */

// Submaps get assigned an ID and state as soon as they are seen, even
// before they take part in the background computations.
MapById<SubmapId, InternalSubmapData> submap_data;

// Global submap poses currently used for displaying data.
MapById<SubmapId, optimization::SubmapSpec2D> global_submap_poses_2d;
MapById<SubmapId, optimization::SubmapSpec3D> global_submap_poses_3d;

// Data that are currently being shown.
MapById<NodeId, TrajectoryNode> trajectory_nodes;

// Global landmark poses with all observations.
std::map<std::string /* landmark ID */, PoseGraphInterface::LandmarkNode>
landmark_nodes;
// How our various trajectories are related.
TrajectoryConnectivityState trajectory_connectivity_state;
int num_trajectory_nodes = 0;
std::map<int, InternalTrajectoryState> trajectories_state;

// Set of all initial trajectory poses.
std::map<int, PoseGraph::InitialTrajectoryPose> initial_trajectory_poses;

std::vector<PoseGraphInterface::Constraint> constraints;
};

1
2
3
4
5
6
7
8
9
struct InternalSubmapData
{
std::shared_ptr<const Submap> submap;
SubmapState state = SubmapState::kNoConstraintSearch;
// IDs of the nodes that were inserted into this map together with
// constraints for them. They are not to be matched again when this submap
// becomes 'kFinished'.
std::set<NodeId> node_ids;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Constraint {
struct Pose {
transform::Rigid3d zbar_ij;
double translation_weight;
double rotation_weight;
};
SubmapId submap_id; // 'i' in the paper.
NodeId node_id; // 'j' in the paper.
// Pose of the node 'j' relative to submap 'i'.
Pose pose;
/* Differentiates between intra-submap (where node 'j' was inserted into
submap 'i') and inter-submap constraints (where node 'j' was not inserted
into submap 'i').*/
enum Tag { INTRA_SUBMAP, INTER_SUBMAP } tag;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class OptimizationProblem2D
: public OptimizationProblemInterface<NodeSpec2D, SubmapSpec2D,
transform::Rigid2d>
{
public:
void Solve(
const std::vector<Constraint>& constraints,
const std::map<int, PoseGraphInterface::TrajectoryState>&
trajectories_state,
const std::map<std::string, LandmarkNode>& landmark_nodes) override;
// 省略很多函数
private:
optimization::proto::OptimizationProblemOptions options_;
MapById<NodeId, NodeSpec2D> node_data_;
MapById<SubmapId, SubmapSpec2D> submap_data_;
sensor::MapByTime<sensor::ImuData> imu_data_;
sensor::MapByTime<sensor::OdometryData> odometry_data_;
std::map<int, PoseGraphInterface::TrajectoryData> trajectory_data_;

sensor::MapByTime<sensor::FixedFramePoseData> fixed_frame_pose_data_;
std::map<std::string, transform::Rigid3d> landmark_data_;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
// 结果、时间、函数
struct WorkItem
{
enum class Result {
kDoNotRunOptimization,
kRunOptimization,
};

std::chrono::steady_clock::time_point time;
std::function<Result()> task;
};

using WorkQueue = std::deque<WorkItem>;

ConstraintBuilder2D::Result 其实是 vector<Constraint>

PoseExtrapolator

1
2
3
std::deque<TimedPose> timed_pose_queue_;
std::deque<sensor::ImuData> imu_data_;
std::deque<sensor::OdometryData> odometry_data_;
cartographer里凡是变量名包含queue的,类型都是deque

插入子图

sensor::RangeData

local坐标系下的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Rays begin at 'origin'. 'returns' are the points where obstructions were
// detected. 'misses' are points in the direction of rays for which no return
// was detected, and were inserted at a configured distance
// It is assumed that between the 'origin' and 'misses' is free space
struct RangeData {
Eigen::Vector3f origin;
PointCloud returns;
PointCloud misses;
};

using PointCloud = std::vector<RangefinderPoint>;
// Stores 3D position of a point observed by a rangefinder sensor.
struct RangefinderPoint
{
Eigen::Vector3f position;
};

TimedRangeData

1
2
3
4
5
struct TimedPointCloudData {
common::Time time;
Eigen::Vector3f origin;
TimedPointCloud ranges;
};
1
2
3
4
5
6
7
8
9
struct TimedPointCloudOriginData {
struct RangeMeasurement {
TimedRangefinderPoint point_time;
size_t origin_index;
};
common::Time time;
std::vector<Eigen::Vector3f> origins;
std::vector<RangeMeasurement> ranges;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
// Like 'RangeData', but with 'TimedPointClouds'.
struct TimedRangeData {
Eigen::Vector3f origin;
TimedPointCloud returns;
TimedPointCloud misses;
};

using TimedPointCloud = std::vector<TimedRangefinderPoint>;

struct TimedRangefinderPoint {
Eigen::Vector3f position;
float time;
};
1
2
3
4
5
6
7
8
9
10
11
struct SubmapPose
{
int version;
transform::Rigid3d pose;
};

struct SubmapData
{
std::shared_ptr<const Submap> submap;
transform::Rigid3d pose;
};

LocalTrajectoryBuilder2D

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
struct MatchingResult
{
common::Time time; // 扫描匹配发生的时间
// tracking 坐标系下的位姿(Local SLAM坐标系下的位姿), 需要乘以
// tracking frame in map的转换(Local SLAM坐标系到Global SLAM坐标系的转换)才能转到全局坐标系
transform::Rigid3d local_pose;
// tracking 坐标系下的点云(即LocalSLAM坐标系下的点云)
sensor::RangeData range_data_in_local;
// 'nullptr' if dropped by the motion filter.
std::unique_ptr<const InsertionResult> insertion_result;
};

struct InsertionResult
{
// Node数据 插入的节点数据,TrajectoryNode::Data包含了处理之后的点云数据
std::shared_ptr<const TrajectoryNode::Data> constant_data;
//该Node数据插入的两个submap
std::vector<std::shared_ptr<const Submap2D>> insertion_submaps;
};

struct TrajectoryNode
{
struct Data
{
common::Time time;
// Transform to approximately gravity align the tracking frame as
// determined by local SLAM.
Eigen::Quaterniond gravity_alignment;
// Used for loop closure in 2D: voxel filtered returns in the
// 'gravity_alignment' frame.
sensor::PointCloud filtered_gravity_aligned_point_cloud;
// 省略 3D.

// The node pose in the local SLAM frame.
transform::Rigid3d local_pose;
};
common::Time time() const { return constant_data->time; }
// This must be a shared_ptr. If the data is used for visualization while the
// node is being trimmed, it must survive until all use finishes.
std::shared_ptr<const Data> constant_data;
// The node pose in the global SLAM frame.
transform::Rigid3d global_pose;
};

MatchingResult里有一个local_poseInsertionResultconstant_data成员里也有一个local_pose 这两个是相同的,赋值用的都是下一帧位姿的观测值。不过在后端里用的是InsertionResultlocal_pose,源码在PoseGraph2D::AddNode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// An individual submap, which has a 'local_pose' in the local map frame, 
// keeps track of how many range data were inserted into it
// and sets 'insertion_finished' when the map no longer changes
// and is ready for loop closing
class Submap
{
public:
Submap(const transform::Rigid3d& local_submap_pose)
: local_pose_(local_submap_pose) {}

private:
// Pose of this submap in the local map frame
const transform::Rigid3d local_pose_;
// Number of RangeData inserted.
int num_range_data_ = 0;
bool insertion_finished_ = false;
};

常用函数

1
2
3
4
5
6
7
8
9
10
11
12
// 对点云中每一个点做变换, 左乘 transform
PointCloud TransformPointCloud(const PointCloud& point_cloud,
const transform::Rigid3f& transform)
{
PointCloud result;
result.reserve(point_cloud.size() );
for (const RangefinderPoint& point : point_cloud)
{
result.emplace_back(transform * point);
}
return result;
}
1
2
3
4
5
6
7
8
9
RangeData TransformRangeData(const RangeData& range_data,
const transform::Rigid3f& transform)
{
return RangeData{
transform * range_data.origin,
TransformPointCloud(range_data.returns, transform),
TransformPointCloud(range_data.misses, transform),
};
}

Trajectory

enum class TrajectoryState { ACTIVE, FINISHED, FROZEN, DELETED };


前端 2. 传感器数据的同步及融合-RangeDataCollator
如果只用一个雷达,可以不看这段

激光雷达点云数据均为作为主要输入,使用时无需考虑具体几个传感器和类型,可认为是一个雷达产生的点云数据。但实际cartographer通过RangeDataCollator类将多种传感器进行了融合,并进行了时间同步,最后形成对应的时间戳,pose和点云集合。在真正使用时通过畸变校准,并构建scan match和插入submap的传感器数据rangedata。

有多个雷达的点云信息,它们的各个点可能时间会重合,因此需要将所有雷达的点云信息进行时间的整理,保证所有点的时间是单调的。多个雷达数据合并到一起,它们的原点可能不一样,因此要保存各自的原点。

头文件部分:

1
2
3
4
5
6
7
8
9
10
11
12
  // 插入集合
sensor::TimedPointCloudOriginData AddRangeData(
const std::string& sensor_id,
const sensor::TimedPointCloudData& timed_point_cloud_data);

private:
// 融合
sensor::TimedPointCloudOriginData CropAndMerge();
// 期望处理传感器类型清单
const std::set<std::string> expected_sensor_ids_;
// 同步和融合后集合,每种传感器至多一帧点云数据
std::map<std::string, sensor::TimedPointCloudData> id_to_pending_data_;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
sensor::TimedPointCloudOriginData RangeDataCollator::AddRangeData(
const std::string& sensor_id,
const sensor::TimedPointCloudData& timed_point_cloud_data)
{
// 检测该点云数据 sensor_id 是否在期望的sensor_ids里面
CHECK_NE(expected_sensor_ids_.count(sensor_id), 0);
// TODO(gaschler): These two cases can probably be one.
// 此传感器类型数据已有
if (id_to_pending_data_.count(sensor_id) != 0)
{
current_start_ = current_end_;
// When we have two messages of the same sensor, move forward the older of
// the two (do not send out current).
/* 如果当前融合时间段内融合结果中已包含同一ID的传感器数据,则应采用最新的点云数据进行替换
但是结束的时间戳仍然采用原来的时间刻度,开始进行数据截取合并CropAndMerge()并返回 */
// std::map<std::string, sensor::TimedPointCloudData> id_to_pending_data_;
current_end_ = id_to_pending_data_.at(sensor_id).time;
auto result = CropAndMerge();
id_to_pending_data_.emplace(sensor_id, timed_point_cloud_data);
return result;
}
id_to_pending_data_.emplace(sensor_id, timed_point_cloud_data);
// 若现在收到的数据类型未全,即期望收到种类未全,直接退出,无需融合
if (expected_sensor_ids_.size() != id_to_pending_data_.size()) {
return {};
}
current_start_ = current_end_;
// We have messages from all sensors, move forward to oldest.
common::Time oldest_timestamp = common::Time::max();
// 找传感器数据中最早的时间戳
for (const auto& pair : id_to_pending_data_) {
oldest_timestamp = std::min(oldest_timestamp, pair.second.time);
}
/* current_end_是下次融合的开始时间,是本次融合的最后时间刻度
但其实是此次融合所有传感器中最早的时间戳*/
current_end_ = oldest_timestamp;
return CropAndMerge();
}

进一步处理在CropAndMerge,这个函数十分复杂。 简单理解如果有一个传感器频率较高,已经来过一帧并进行了缓存,另外一个未来,
这个传感器又来一帧,则先进行截取合并送出结果(实际上就是上帧缓存的点云直接发出),
然后将新来的一帧替换换来的buffer。

参考: cartographer 代码思想解读 RangeDataCollator