牛顿法 梯度下降法

非线性最小二乘

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

自变量 ,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
{
// 插入的节点数据,包括:子图更新时,节点在局部地图中的位姿,以及有传感器原始数据转换之后的点云信息
// 记录了更新子图的时刻和重力方向
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返回的
// ceres计算的位姿需要再跟重力方向对齐,才是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 结果的坐标系。它是包含所有闭环和优化结果的固定地图坐标系。 当新的优化结果出现时,该帧与其他帧之间的转换可以跳转。

  • local map frame

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

  • submap frame

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

  • tracking frame

传感器数据表示的坐标系,自定义,可能是imu, laser, base_footprint

  • gravity-aligned frame

重力对准坐标系,只在2D中使用,与tracking坐标系有相同位置,但是坐标系方向不一致

Transforms (变换)

  • local_pose (局部姿态)

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

  • global_pose (全局位姿)

将数据从tracking坐标系或者子图坐标系 转换到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用于匹配和更新的)
// 如果 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


前端 4. Ceres scan matcher

原理

在把一帧的scan插入到子图之前,scan 相对子图的位姿是由 ceres scan matcher 优化获得的。由于这是一个实时的局部优化,需要一个好的初始位姿估计。前面的real time CSM就是把位姿估计器传来的预测值更新为一个好的初值,如果没有real time CSM,就还用位姿估计器传来的预测值作初值。Ceres Scan Matcher以初值作为先验,并找到最佳的点,该点就是通过scan match获得的在子地图中的位置,实现方式是 interpolating the submap and sub-pixel aligning the scan. 前端的两个scan matcher其实都是 scan to map 问题,让当前观测和已知环境最匹配

scan matcher 负责在地图中找到一个scan pose,使得在该位姿下所有 hit 点在 local map中的占用概率之和最大,于是转化为一个最小二乘问题:

式中的 就是我们需要求的位姿,它将scan坐标系中的点 (hk)全部转化到 Submap坐标系下, 让点 (hk)最大概率地匹配到Submap。 由于栅格中原本存储的就是空闲的概率,其实从栅格查出来的就是 。 相对地,real time scan match是占据概率之和最大,其实二者本质差不多,只是CSM换成最小二乘形式了,为了更精确。

在代码里实现的时候,又加上了旋转和平移的残差,防止点云匹配的结果和初值差太多。其实只要初值够好,这两个残差可以去掉。

这种方式的速度很快,计算复杂度高,鲁棒性好,但不能修正比子图分辨率大很多的误差。如果传感器和时间戳是合理的,只使用Ceres Scan Matcher通常是最好的选择。

Msmooth

Msmooth是一个将local子图中的各点概率进行平滑处理的函数,能够提供比栅格分辨率更好的精度。实质是双三次插值算法,该函数的输出结果为(0, 1)以内的数,实际返回了Submap中的占用概率,如果趋近1,那是很好了。

提高Scan matcher的频率可以获得更好优化结果,但计算量加大;或者用IMU来估计 scan match 中的旋转角度

ceres匹配对初值要求相当高,匹配后的结果会考虑其与初始位置偏差进行权重化,说明 cartographer认为其匹配后的值应该与初值相差不大。 一旦初值离真实值过远,很容易陷入局部最优解,导致优化失败。这也是为什么ceres对线速度与角速度有限制。

1
2
3
4
5
6
7
8
9
auto pose_observation = absl::make_unique<transform::Rigid2d>();
ceres::Solver::Summary summary;
ceres_scan_matcher_.Match(pose_prediction.translation(),
initial_ceres_pose,
filtered_gravity_aligned_point_cloud,
*matching_submap->grid(), pose_observation.get(),
&summary);
// 省略: 统计残差, 观测值 - 预测值
return pose_observation;

Match 函数

CeresScanMatcher2D类主要函数只有Match:根据初值,将点云hit点和栅格对齐,返回观测值

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
44
45
46
47
48
49
50
51
52
/*
input:
1. 上一个 scan 的位姿 target_translation
2. 经过 RealTimeCorrelativeScanMatcher2D 处理过的
当前的 scan 的位姿的初始估计 initial_pose_estimate
3. 当前 scan 点云(2D) point_cloud
4. local map 概率分布栅格图 grid

output: 更新的位姿估计 pose_estimate
*/
void CeresScanMatcher2D::Match(
const Eigen::Vector2d& target_translation,
const transform::Rigid2d& initial_pose_estimate,
const sensor::PointCloud& point_cloud,
const Grid2D& grid,
transform::Rigid2d* const pose_estimate,
ceres::Solver::Summary* const summary) const
{
// 优化的变量(3维)
double ceres_pose_estimate[3] = {initial_pose_estimate.translation().x(),
initial_pose_estimate.translation().y(),
initial_pose_estimate.rotation().angle() };
ceres::Problem problem;
CHECK_GT(options_.occupied_space_weight(), 0.);
// 点云匹配残差块
problem.AddResidualBlock(
CreateOccupiedSpaceCostFunction2D(
options_.occupied_space_weight() /
std::sqrt(static_cast<double>(point_cloud.size())),
point_cloud, grid ),
nullptr /* loss function */, ceres_pose_estimate );

// 省略 GridType::TSDF
CHECK_GT(options_.translation_weight(), 0.);
// ceres 的套路都在函数里定义,很简单 AutoDiff
problem.AddResidualBlock(
TranslationDeltaCostFunctor2D::CreateAutoDiffCostFunction(
options_.translation_weight(), target_translation),
nullptr, ceres_pose_estimate);

CHECK_GT(options_.rotation_weight(), 0.);
// ceres 的套路都在函数里定义,很简单
problem.AddResidualBlock(
RotationDeltaCostFunctor2D::CreateAutoDiffCostFunction(
options_.rotation_weight(), ceres_pose_estimate[2]),
nullptr, ceres_pose_estimate);
// ceres_solver_options_.linear_solver_type = ceres::DENSE_QR;
ceres::Solve(ceres_solver_options_, &problem, summary);

*pose_estimate = transform::Rigid2d(
{ceres_pose_estimate[0], ceres_pose_estimate[1]}, ceres_pose_estimate[2]);
}

ceres scan matcher 将上面的最小二乘问题分解成了三个代价函数:点云与栅格的匹配残差、平移的残差、角度的残差。其中,点云匹配的权重总和为1,平移的权重和旋转的权重都是手动给的。

三个对应的核函数都是 nullptr ceres的linear_solver_typeDENSE_QR

TranslationDeltaCostFunctor2D

两个Cost Function的实现,就是保证平移和旋转最小的代价函数

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
// Computes the cost of translating 'pose' to 'target_translation'.
// Cost increases with the solution's distance from 'target_translation'.
class TranslationDeltaCostFunctor2D {
public:
static ceres::CostFunction* CreateAutoDiffCostFunction(
const double scaling_factor, const Eigen::Vector2d& target_translation)
{
return new ceres::AutoDiffCostFunction<TranslationDeltaCostFunctor2D,
2 /* residuals */,
3 /* pose variables */>(
new TranslationDeltaCostFunctor2D(scaling_factor, target_translation));
}
template <typename T>
bool operator()(const T* const pose, T* residual) const {
residual[0] = scaling_factor_ * (pose[0] - x_);
residual[1] = scaling_factor_ * (pose[1] - y_);
return true;
}
private:
// Constructs a new TranslationDeltaCostFunctor2D from the given
// 'target_translation' (x, y).
explicit TranslationDeltaCostFunctor2D(
const double scaling_factor, const Eigen::Vector2d& target_translation)
: scaling_factor_(scaling_factor),
x_(target_translation.x()),
y_(target_translation.y()) {}
// 省略: 禁止拷贝构造函数 和 赋值运算符
const double scaling_factor_;
const double x_;
const double y_;
};

这个代价函数比较简单,都是套路,RotationDeltaCostFunctor2D也是同理

  • 平移量的残差计算,当前位姿逼近目标位姿 (位姿估计器输出的预测值xy)。目标函数
    residual[0] = scaling_factor_ * (pose[0] - x_); residual[1] = scaling_factor_ * (pose[1] - y_);

  • 逼近目标角度,目标函数scaling_factor_ * (pose[2] - angle_)

  • 这里是逼近之前的位姿,所以要求先验位姿比较准确,经试验发现ceres输出的位姿和预测值相差极小,疑似存在问题,可以将这两个ceres优化去掉,但地图匹配必须保留

统计残差

观测值 - 预测值

1
2
3
4
5
6
7
8
9
10
11
12
13
if (pose_observation)
{
kCeresScanMatcherCostMetric->Observe(summary.final_cost);
const double residual_distance =
(pose_observation->translation() - pose_prediction.translation())
.norm();
kScanMatcherResidualDistanceMetric->Observe(residual_distance);

const double residual_angle =
std::abs(pose_observation->rotation().angle() -
pose_prediction.rotation().angle());
kScanMatcherResidualAngleMetric->Observe(residual_angle);
}

参考:
无处不在的小土
jiajiading的分析
叶落寒蝉的分析
Ceres的使用


前端 3. Real Time Correlative Scan Matcher

AddAccumulatedRangeData

现在进入AddAccumulatedRangeData函数,返回类型为 std::unique_ptr<LocalTrajectoryBuilder2D::MatchingResult>

所用参数为

  • const common::Time time     当前同步时间
  • const sensor::RangeData& gravity_aligned_range_data     经过重力修正后的点云returns和miss
  • const transform::Rigid3d& gravity_alignment,     重力向量
  • const absl::optional<common::Duration>& sensor_duration

先看开头,我交换了部分代码顺序,并不影响。这里实际是获得scanMatch的两个参数

1
2
3
4
5
6
7
8
9
10
11
if (gravity_aligned_range_data.returns.empty())
{
LOG(WARNING) << "Dropped empty horizontal range data.";
return nullptr;
}
const sensor::PointCloud& filtered_gravity_aligned_point_cloud =
sensor::AdaptiveVoxelFilter(options_.adaptive_voxel_filter_options())
.Filter(gravity_aligned_range_data.returns);

if (filtered_gravity_aligned_point_cloud.empty())
return nullptr;

这一段就是流程图的Adaptive Voxel Filter环节,解释参考(九) Voxel Filter和Adaptive Voxel Filter

然后是估计位姿部分

1
2
3
4
5
const transform::Rigid3d  non_gravity_aligned_pose_prediction =
extrapolator_->ExtrapolatePose(time);
// 经过重力方向计算投影后的2d位置
const transform::Rigid2d pose_prediction = transform::Project2D(
non_gravity_aligned_pose_prediction * gravity_alignment.inverse() );

返回的pose_prediction和上面的filtered_gravity_aligned_point_cloud一起进入 Scan Mathching 环节,这个在流程图上很明显。

表面上只是重力对齐以后再处理,其实这里面还为后续的匹配与查找减小了查找范围,因为* gravity_alignment.inverse()这一乘使得与上一帧数据的角度变化缩小了(基本是同一角度),所以 为后面的ScanMatch减小了角度的查找范围。

在后面再把角度乘回来:

1
2
3
4
5
pose_estimate_2d =
ScanMatch(time, pose_prediction, filtered_gravity_aligned_point_cloud);

transform::Rigid3d pose_estimate =
transform::Embed3D(*pose_estimate_2d) * gravity_alignment;

这样就实现了角度的估计,gravity_alignment上下是同一数值,只是为了减少计算量,点云数据也按这个gravity_alignment做了处理

scan match

1
2
3
4
5
6
7
8
9
// local map frame <--- gravity-aligned frame
// 求 Scan 插入Submap的最优 Pose
std::unique_ptr<transform::Rigid2d> pose_estimate_2d =
ScanMatch(time, pose_prediction, filtered_gravity_aligned_point_cloud);
if (pose_estimate_2d == nullptr)
{
LOG(WARNING) << "Scan matching failed.";
return nullptr;
}

这里的ScanMatch包含了real time correlative scan matcherceres scan match两部分,前者为后者提供好的初值

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
// 输出是对该帧传感器数据的最优pose
std::unique_ptr<transform::Rigid2d> LocalTrajectoryBuilder2D::ScanMatch(
const common::Time time, const transform::Rigid2d& pose_prediction,
const sensor::PointCloud& filtered_gravity_aligned_point_cloud)
{
// 如果还没有子图,返回 PoseExtrapolator 的预测值
if (active_submaps_.submaps().empty() )
return absl::make_unique<transform::Rigid2d>(pose_prediction);

// 永远是头部的子图做匹配 SCAN-to-MAP
std::shared_ptr<const Submap2D> matching_submap =
active_submaps_.submaps().front();

transform::Rigid2d initial_ceres_pose = pose_prediction;

// 函数最终更新 initial_ceres_pose,返回匹配的最高分数
if (options_.use_online_correlative_scan_matching())
{
const double score = real_time_correlative_scan_matcher_.Match(
pose_prediction, filtered_gravity_aligned_point_cloud,
// Grid2D类型
*matching_submap->grid(), &initial_ceres_pose );
kRealTimeCorrelativeScanMatcherScoreMetric->Observe(score);
}
// ceres scan matcher 部分
}

Real Time Correlative Scan Matcher

这里借用了karto的做法,即本质是暴力匹配,而hector是用了高斯牛顿法和多分辨率地图。但是karto还是用了多分辨率地图,而cartographer这里没有。

它使用类似于在回环检测中激光与子地图匹配的方式,然后最好的匹配用作Ceres Scan Matcher的先验。这个scan matcher非常消耗资源,但它在特征丰富的环境中是很鲁棒。

我们要找到机器人位姿的后验概率分布,使用贝叶斯公式得到

运动模型是已知的多元高斯分布, 算法只研究观测模型。观测模型难以计算,一般具有多极值。 雷达观测数据 里包含了很多激光点数据,假设他们是完全独立的

将一帧观测数据的概率拆分成了当前帧的每一个点的概率,在计算这个概率之前,我们需要对数据帧以及地图进行栅格化。

位姿估计器传来的预测值不适合做初值,真正初值是在它附近。

实时相关性scan match 以预测值为中心,在搜索窗口内枚举出所有候选位姿,并生成每个位姿对应的点云,也就是说按照 把当前观测 投影到栅格地图 m (t 时刻的激光数据与 t-1 时刻的地图匹配, scan to map),然后分别计算每个点云中hit点在栅格地图的CellIndex,并计算所有hit点的占据概率之和,概率最大值对应的位姿就是我们需要的。代表着在这个位姿下,激光点得映射到占据栅格中的可能性最大,当前观测与已知环境最为一致,前后帧匹配成功。 scan match非常重要,如果结果位姿很差,建图就会失败,后端怎么调整也没用

搜索的精度是栅格地图的分辨率

这个scan matcher可以修改做重定位,不过搜索窗口扩大很多
  1. 暴力搜索
    我们要计算 ,这就需要一个三层for循环 计算每一个位姿,还有第四个循环,也就是遍历点云中每个点。 计算量巨大,因为每一个位姿都要重新投影,需要计算sin和cos函数

  2. 计算2D切片
    把三层循环交换顺序,最外层遍历 ,这样(x, y)的循环只有加法。

  3. 多分辨率搜索
    首先选择一个相对低的分辨率对 map 和 scan 进行栅格化,每个栅格上保留高分辨率情况下的该区域的最高概率,这样在匹配时就不会错过正确的位姿估计
    低分辨率找到最佳匹配后再在该位置进行高分辨率的栅格化,并设定相对较小的 search window 进行计算。

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// 更新 pose_estimate, 返回最高的分数
double RealTimeCorrelativeScanMatcher2D::Match(
const transform::Rigid2d& initial_pose_estimate,
const sensor::PointCloud& point_cloud,
const Grid2D& grid,
transform::Rigid2d* pose_estimate ) const
{
CHECK(pose_estimate != nullptr);
// 预测位姿的旋转量
const Eigen::Rotation2Dd initial_rotation = initial_pose_estimate.rotation();
/* 左乘 initial_rotation, 将当前点云转换为
初始角度坐标系下点云,这里只有 旋转 */
const sensor::PointCloud rotated_point_cloud = sensor::TransformPointCloud(
point_cloud,
transform::Rigid3f::Rotation(Eigen::AngleAxisf(
initial_rotation.cast<float>().angle(),
Eigen::Vector3f::UnitZ() ) )
);

const SearchParameters search_parameters(
options_.linear_search_window(), options_.angular_search_window(),
rotated_point_cloud, grid.limits().resolution() );
// 获取搜索窗口下机器人朝向各个方向角时的点云数据

// 先通过角度切片,然后在xy方向上暴力搜索,为了减少计算量
// 切片数目 num_scans , 按照搜索角度/步长,以初始值为基础,生成一些列的旋转后的点云
const std::vector<sensor::PointCloud> rotated_scans =
GenerateRotatedScans(rotated_point_cloud, search_parameters);

// DiscreteScan2D 就是 std::vector<Eigen::Array2i>
/* 把切片后的旋转点云坐标,左乘initial_pose_estimate.translation
转化到地图坐标,这里只有 平移
最终通过GetCellIndex返回一系列的 激光点return所在的cell index*/
const std::vector<DiscreteScan2D> discrete_scans = DiscretizeScans(
grid.limits(), rotated_scans,
Eigen::Translation2f( initial_pose_estimate.translation().x(),
initial_pose_estimate.translation().y() )
);
/* 获得candidates, size是 search_parameters.num_scans *
(linear_bounds.max_x - inear_bounds.min_x) * (linear_bounds.max_y - inear_bounds.min_y)*/

/* 这里是num_scans循环,x和y循环,没有 theta循环了
成员是 scan_index, x_index_offset(逐步增加), y_index_offset(逐步增加),
search_parameters意思其实是 每个激光点附近的矩形区域*/
std::vector<Candidate2D> candidates =
GenerateExhaustiveSearchCandidates(search_parameters);

// 计算当前帧在theta、x、y三层窗口中,每个状态在grid地图中的匹配的评分
// search_parameters 实际没用
ScoreCandidates(grid, discrete_scans, search_parameters, &candidates);
// 取最好的候选
const Candidate2D& best_candidate =
*std::max_element(candidates.begin(), candidates.end());

// 最后的结果是预测值加候选位姿
*pose_estimate = transform::Rigid2d(
{initial_pose_estimate.translation().x() + best_candidate.x,
initial_pose_estimate.translation().y() + best_candidate.y },
// 前端观测,所以右乘
initial_rotation * Eigen::Rotation2Dd(best_candidate.orientation) );

return best_candidate.score;
}
  1. 获取位姿估计器传来的初值的旋转分量,将激光点云左乘旋转分量,即先做旋转变换
  2. 根据搜索窗口x、y、yaw的大小计算 num_scans和LinearBounds, x y搜索的num_linear_perturbations
  3. 切片旋转:GenerateRotatedScans函数,其实就是theta循环,将角度进行序列化(初始角度跟search_window的大小相关,按步长增加),点云依次左乘角度对应的旋转向量(旋转轴为z轴),即可生成num_scans段点云
  4. 把点云中所有点做变换:左乘初值的平移部分,根据变换后的坐标,找出它在栅格地图中的CellIndex并保存
  5. 根据搜索范围,生成候选者,数量是25*num_scans
  • 根据保存的索引队列,从栅格地图中获取最大置信度,这就用到了ProbabilityGrid类,根据每个点的CellIndex获取占据概率,最终对分数进行加权处理
  • 求得每个候选位置的score,score最高的为最佳位置。

候选者是针对scan_index (0~num_scans)所做的x y方向的offset,生成所有候选者的过程其实就是num_scans和x y三个循环

Match 函数的计算量很大 。 试验发现最终的分数,大部分在0.6~0.9

SearchParameters

根据lua参数,确定后面的切片步长angular_perturbation_step_size,切片数num_scans,搜索区域

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
SearchParameters::SearchParameters(
const double linear_search_window,
const double angular_search_window,
const sensor::PointCloud& point_cloud,
const double resolution)
: resolution(resolution)
{
// We set this value to something on the order of
// resolution to make sure that the std::acos() below is defined.
float max_scan_range = 3.f * resolution;
for (const sensor::RangefinderPoint& point : point_cloud) {
const float range = point.position.head<2>().norm();
max_scan_range = std::max(range, max_scan_range);
}
const double kSafetyMargin = 1. - 1e-3;
angular_perturbation_step_size =
kSafetyMargin * std::acos(1. - common::Pow2(resolution) /
(2. * common::Pow2(max_scan_range)));
// 这两者的计算不同
num_angular_perturbations =
std::ceil(angular_search_window / angular_perturbation_step_size);
const int num_linear_perturbations =
std::ceil(linear_search_window / resolution);
// 切片数目,由于resolution不修改,实际取决于 max_scan_range 和 angular_search_window
num_scans = 2 * num_angular_perturbations + 1;
/* Linear search window in pixel offsets; bounds are inclusive.
struct LinearBounds {
int min_x;
int max_x;
int min_y;
int max_y;
};
std::vector<LinearBounds> linear_bounds; Per rotated scans */
linear_bounds.reserve(num_scans);
// 对同一帧scan, linear_bounds的元素都相同,不同帧就不同了
for (int i = 0; i != num_scans; ++i) {
linear_bounds.push_back(
LinearBounds{-num_linear_perturbations, num_linear_perturbations,
-num_linear_perturbations, num_linear_perturbations} );
}
}

GenerateRotatedScans 切片旋转

生成序列化旋转的多段点云

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
std::vector<sensor::PointCloud> GenerateRotatedScans(
const sensor::PointCloud& point_cloud,
const SearchParameters& search_parameters)
{
std::vector<sensor::PointCloud> rotated_scans;
rotated_scans.reserve(search_parameters.num_scans);
// 旋转角度步长
double delta_theta = -search_parameters.num_angular_perturbations *
search_parameters.angular_perturbation_step_size;
// 个数 num_scans
for (int scan_index = 0; scan_index < search_parameters.num_scans;
++scan_index,
delta_theta += search_parameters.angular_perturbation_step_size)
{
// 左乘旋转向量,旋转轴为z轴
rotated_scans.push_back(sensor::TransformPointCloud(
point_cloud, transform::Rigid3f::Rotation(Eigen::AngleAxisf(
delta_theta, Eigen::Vector3f::UnitZ()) ) ) );
}
return rotated_scans;
}

ScoreCandidates

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
44
45
46
47
48
49
50
51
void ScoreCandidates(
const Grid2D& grid, const std::vector<DiscreteScan2D>& discrete_scans,
const SearchParameters& search_parameters,
std::vector<Candidate2D>* const candidates) const
{
for (Candidate2D& candidate : *candidates)
{
candidate.score = ComputeCandidateScore(
static_cast<const ProbabilityGrid&>(grid),
discrete_scans[candidate.scan_index],
candidate.x_index_offset,
candidate.y_index_offset);
break;
// 省略 GridType::TSDF
// 进一步做权重处理
candidate.score *=
std::exp(
-common::Pow2(
std::hypot(candidate.x, candidate.y) *
options_.translation_delta_cost_weight()
+
std::abs(candidate.orientation) *
options_.rotation_delta_cost_weight()
)
);
}
}


// 其中用到的函数, 计算每个候选的得分
float ComputeCandidateScore(const ProbabilityGrid& probability_grid,
const DiscreteScan2D& discrete_scan,
int x_index_offset, int y_index_offset)
{
float candidate_score = 0.f;
// 这里就用到前面获取的x y 索引队列
for (const Eigen::Array2i& xy_index : discrete_scan)
{
const Eigen::Array2i proposed_xy_index(xy_index.x() + x_index_offset,
xy_index.y() + y_index_offset );
// Returns probability of the cell with 'cell_index'
// 这个就是占据概率
const float probability =
probability_grid.GetProbability(proposed_xy_index);
candidate_score += probability;
}
// 返回的是一帧点云所有激光点得分的平均值
candidate_score /= static_cast<float>(discrete_scan.size());
CHECK_GT(candidate_score, 0.f);
return candidate_score;
}

参考: RealTimeCorrelativeScanMatcher2D