讲解关于slam一系列文章汇总链接:史上最全slam从零开始,针对于本栏目讲解(02)Cartographer源码无死角解析-链接如下:
(02)Cartographer源码无死角解析- (00)目录_最新无死角讲解:https://blog.csdn.net/weixin_43013761/article/details/127350885
文末正下方中心提供了本人
联系方式,
点击本人照片即可显示
W
X
→
官方认证
{\color{blue}{文末正下方中心}提供了本人 \color{red} 联系方式,\color{blue}点击本人照片即可显示WX→官方认证}
文末正下方中心提供了本人联系方式,点击本人照片即可显示WX→官方认证
一、前言
在上一篇博客中,对ceres扫描匹配的平移与旋转优化部分进行了详细的讲解,知道关于ceres的使用核心在于禅茶的构建。不过,留下了两个疑问暂时比较迷糊:
疑问 1 \color{red}疑问1 疑问1 与 疑问 2 \color{red}疑问2 疑问2 整合:CeresScanMatcher2D::Match()函数中,为什么平移残差优化的目标是 target_translation(由推断器或者暴力匹配获得),为什么旋转的残差目标值设置为 ceres_pose_estimate[2]。暂且记住这两个疑问,看下接下来的分析中是否能够找到答案。
下面要分析的是 src/cartographer/cartographer/mapping/internal/2d/scan_matching/ceres_scan_matcher_2d.cc 文件中的 CeresScanMatcher2D::Match() 函数的如下部分:
// 地图部分的残差
CHECK_GT(options_.occupied_space_weight(), 0.);
switch (grid.GetGridType()) {
case GridType::PROBABILITY_GRID:
problem.AddResidualBlock(
CreateOccupiedSpaceCostFunction2D(
options_.occupied_space_weight() /
std::sqrt(static_cast<double>(point_cloud.size())),
point_cloud, grid),
nullptr /* loss function */, ceres_pose_estimate);
break;
case GridType::TSDF:
problem.AddResidualBlock(
CreateTSDFMatchCostFunction2D(
options_.occupied_space_weight() /
std::sqrt(static_cast<double>(point_cloud.size())),
point_cloud, static_cast<const TSDF2D&>(grid)),
nullptr /* loss function */, ceres_pose_estimate);
break;
}
由于目前使用的是概率栅格地图,所以只对上述的 case GridType::PROBABILITY_GRID 情况进行讲解,即其核心函数为 CreateOccupiedSpaceCostFunction2D(),该函数主要作用是构建一个 ceres::CostFunction 对象指针返回作为 problem.AddResidualBlock() 函数的第一个实参。这里与平移旋转的优化一样 loss function 设置为 nullptr 表示不使用损失函数,CreateOccupiedSpaceCostFunction2D() 函数最后接收的参数 ceres_pose_estimate 表示待优化位姿的初始值。
那么下面就来详细分析一下吧。
二、CreateOccupiedSpaceCostFunction2D()
首先这里粘贴一下该函数的代码:
// 工厂函数, 返回地图的CostFunction
ceres::CostFunction* CreateOccupiedSpaceCostFunction2D(
const double scaling_factor, const sensor::PointCloud& point_cloud,
const Grid2D& grid) {
return new ceres::AutoDiffCostFunction<OccupiedSpaceCostFunction2D,
ceres::DYNAMIC /* residuals */,
3 /* pose variables */>(
new OccupiedSpaceCostFunction2D(scaling_factor, point_cloud, grid),
point_cloud.size()); // 比固定残差维度的 多了一个参数
}
首先其是一个工厂函数,第一个形参表示 scaling_factor 表示残差结果的所缩放因子;第二个形参 point_cloud 表示需要进行扫描匹配的点云数据,也是待优化的点云数据;第三个形参 grid 存储了栅格地图的相关数据与信息,后续会使用到。
ceres::AutoDiffCostFunction 类模板根据根据上述的三个形参构建一个:
ceres::AutoDiffCostFunction<OccupiedSpaceCostFunction2D,
ceres::DYNAMIC /* residuals */,
3 /* pose variables */>
类对象,第一个模板参数OccupiedSpaceCostFunction2D是一个可调用类型(可以是仿函数,或者可调用对象);第二个非类型模板参数 ceres::DYNAMIC=-1 与上一篇博客讲解的有些不一样,这里是一个动态值,也就是说残差结果项是动态的,需要在程序执行过程中才能确定(主要与点云数量相关);第三个非类型模板参数3表示位姿,也就是前面参数 ceres_pose_estimate(位姿初始值) 的维度,简单的说,就是待优化变量的维度。
创建上述类模板的实例,需要传递两个参数,第一个参数为可调用类型 OccupiedSpaceCostFunction2D 的实例对象,第二个参数为 point_cloud.size(),只有在 ceres::DYNAMIC=-1,即残差项为动态的情况下,才需要传入该参数,其目的就是指定残差项的维度。
三、GridArrayAdapter
从上面的分析中可以明显知道,class OccupiedSpaceCostFunction2D 这个类是栅格地图优化的核心,不过在对齐进行讲解之前,先来看 class GridArrayAdapter 这个类,该类同样实现于 occupied_space_cost_function_2d.cc 文件中,且被包含在类 OccupiedSpaceCostFunction2D 之中。
GridArrayAdapter 类型实例主要用于创建类模板 ceres::BiCubicInterpolator 的实例对象,ceres::BiCubicInterpolator 的作用是进行双线性插值。其对需要进行插值的实例对象有一定要求,在本人人在 /usr/include/ceres/cubic_interpolation.h 中关于 ceres::BiCubicInterpolator 的介绍,可以找到如下内容:
// Given as input an infinite two dimensional grid like object, which
// provides the following interface:
//
// struct Grid {
// enum { DATA_DIMENSION = 1 };
// void GetValue(int row, int col, double* f) const;
// };
//
// Where, GetValue gives us the value of a function f (possibly vector
// valued) for any pairs of integers (row, col), and the enum
// DATA_DIMENSION indicates the dimensionality of the function being
// interpolated. For example if you are interpolating a color image
// with three channels (Red, Green & Blue), then DATA_DIMENSION = 3.
大概的意思:如果使用 ceres::BiCubicInterpolator 进行双线性插值,需要实现一个结构体(或者类) Grid,用该类创建的实例对象作为 ceres::BiCubicInterpolator 构造函数的实参。关于 Grid 这个结构体需要实现两个接口:①enum { DATA_DIMENSION = 1 }用于指定数据的维度;②GetValue函数,该函数返回指定行列(int row, int col)位置的value值。
源码中的 Grid 结构体,就是 class GridArrayAdapte,是严格按照上述要求进行实现的。并且还额外实现了 NumRows() 与 NumCols() 函数,在GetValue() 函数重被调用,需要注意的是,其对 map上下左右各增加 kPadding,也就是对 map 进行了填充。相关代码注释如下:
private:
static constexpr int kPadding = INT_MAX / 4;
// 自定义网格
class GridArrayAdapter {
public:
// 枚举 DATA_DIMENSION 表示被插值的向量或者函数的维度
enum { DATA_DIMENSION = 1 };
explicit GridArrayAdapter(const Grid2D& grid) : grid_(grid) {}
// 获取栅格free值
void GetValue(const int row, const int column, double* const value) const {
// 处于地图外部时, 赋予最大free值
if (row < kPadding || column < kPadding || row >= NumRows() - kPadding ||
column >= NumCols() - kPadding) {
*value = kMaxCorrespondenceCost;
}
// 根据索引获取free值
else {
*value = static_cast<double>(grid_.GetCorrespondenceCost(
Eigen::Array2i(column - kPadding, row - kPadding)));
}
}
// map上下左右各增加 kPadding
int NumRows() const {
return grid_.limits().cell_limits().num_y_cells + 2 * kPadding;
}
int NumCols() const {
return grid_.limits().cell_limits().num_x_cells + 2 * kPadding;
}
private:
const Grid2D& grid_;
};
实现了 GridArrayAdapter 之后,后续就能够通过如下代码进行双线性插值了:
const GridArrayAdapter adapter(grid_);
ceres::BiCubicInterpolator<GridArrayAdapter> interpolator(adapter);
关于双线性插值的知识,这里就不进行讲解了,不是很明白的朋友可以查阅一些其他的资料。总的来说,没有插值之前,我们只能通过整数索引 (int row, const int column) 获得栅格值,但是通过插值之后,就可以通过小数索引获得栅格值了。
四、OccupiedSpaceCostFunction2D
首先这里讲解一下优化的原理:①待优化的参数为机器人位姿,即 CeresScanMatcher2D::Match() 函数中ceres_pose_estimate变量,其使用二维的方式表示机器人位姿,即位置(x,y),角度(angle)。②优化之后的最终结果是希望所有点云数据都能够打在障碍物上,即点云数据对应的栅格值(没有被占用概率)越小越好,也就说,点云对应的栅格值越小,则其被占用的机率越大。
1.源码注释
先简单过一下源码的注释,然后再对其重难点进行分袖
class OccupiedSpaceCostFunction2D {
public:
OccupiedSpaceCostFunction2D(const double scaling_factor,
const sensor::PointCloud& point_cloud,
const Grid2D& grid)
: scaling_factor_(scaling_factor),//残差项的缩放因子
point_cloud_(point_cloud),//当前扫描匹配的点云数据
grid_(grid) {}//存储有地图栅格信息
template <typename T>
bool operator()(const T* const pose, T* residual) const {
Eigen::Matrix<T, 2, 1> translation(pose[0], pose[1]);//二维位置
Eigen::Rotation2D<T> rotation(pose[2]);//二维角度(姿态)
//把角度使用2x2矩阵形式表示
Eigen::Matrix<T, 2, 2> rotation_matrix = rotation.toRotationMatrix();
/*姿态与位姿都添加到3x3的矩阵中,构成类似: r1 r2 tx 的矩阵形式
r3 r4 ty
0. 0. 1. */
Eigen::Matrix<T, 3, 3> transform;
transform << rotation_matrix, translation, T(0.), T(0.), T(1.);
//对地图进行双线性插值,ceres的基本使用形式
const GridArrayAdapter adapter(grid_);
ceres::BiCubicInterpolator<GridArrayAdapter> interpolator(adapter);
const MapLimits& limits = grid_.limits(); //获取地图边界信息
//循环遍历进行扫描匹配的点云数据
for (size_t i = 0; i < point_cloud_.size(); ++i) {
// Note that this is a 2D point. The third component is a scaling factor.
// 由于点云数据经过重力校正,所以只取x,y坐标
const Eigen::Matrix<T, 3, 1> point((T(point_cloud_[i].position.x())),
(T(point_cloud_[i].position.y())),
T(1.));
// 根据预测位姿对单个点进行坐标变换
// 这里把point看成一个向量, 该向量的原点未local系, 向量的长度
// 该向量的长度点云数据的模,所以还需要对齐进行变换,等价于把
// 该向量的原点移动至Robot系原点,但是点云数据的数字是相对于loacl系的
const Eigen::Matrix<T, 3, 1> world = transform * point;
// 获取三次插值之后的栅格free值, Evaluate函数内部调用了GetValue函数
interpolator.Evaluate(
(limits.max().x() - world[0]) / limits.resolution() - 0.5 +
static_cast<double>(kPadding),
(limits.max().y() - world[1]) / limits.resolution() - 0.5 +
static_cast<double>(kPadding),
&residual[i]);
// free值越小, 表示占用的概率越大
// 希望residual[i]为0,即希望每个点云都打在障碍物上,也就是等价
// 点云对应栅格值越小越好
residual[i] = scaling_factor_ * residual[i];
}
return true;
}
private:
static constexpr int kPadding = INT_MAX / 4;
OccupiedSpaceCostFunction2D(const OccupiedSpaceCostFunction2D&) = delete;
OccupiedSpaceCostFunction2D& operator=(const OccupiedSpaceCostFunction2D&) =
delete;
const double scaling_factor_;
const sensor::PointCloud& point_cloud_;
const Grid2D& grid_;
};
2.重难点分析
其上的大部分代码都是比较容易理解的,但是对于如下部分代码或许存在一定疑问:
const Eigen::Matrix<T, 3, 1> world = transform * point;
下面是本人绘画的图示,①黄色圆点表示点云数据;②紫色五边形表示机器人所在位置及朝向;③黑色矩形表示障碍物;
从上图可以看到,点云数据虽然位于local坐标系下,但是很明显是不能计算残差的。需要把点云数据变换到如下位置才能进行残差计算:
上述源码就是为了实现上述功能,需要注意的是,源码中的 point 与 world 都是基于local 系的。从上图很容易看出,point 基于local系进行平移与旋转即可变换成 world,其平移与旋转的量就是机器人在local系下的位姿。使用数学公式表示则如下所示:
p
o
i
n
t
t
r
a
c
k
i
n
g
l
o
c
a
l
=
R
o
b
o
t
t
r
a
c
k
i
n
g
l
o
c
a
l
∗
p
o
i
n
t
l
o
c
a
l
(01)
\color{Green} \tag{01} point^{local}_{tracking}=\mathbf {Robot}^{local}_{tracking}*point^{local}
pointtrackinglocal=Robottrackinglocal∗pointlocal(01)
p
o
i
n
t
t
r
a
c
k
i
n
g
l
o
c
a
l
point^{local}_{tracking}
pointtrackinglocal 表是的含义就是对 local 系下的向量,起点为 tracking(机器人),终点为点云数值。
五、结语
通过上一篇博客与该篇博客的讲解,可以知道扫描匹配核心函数 CeresScanMatcher2D::Match() 主要是对初始位姿 ceres_pose_estimate 进行优化,优化主要包含如下三个步骤:
①首先根据点云数据,结合栅格地图进行位姿优化,尽量让所有的点云数据都打在障碍物上。
②以推断器推断出来位置target_translation作为优化目标,计算x,y的残差。
③以ceres_pose_estimate[2]作为角度优化目标,该残差也只计算了角度这一项。
这里再重述一下上一篇博客遗留的疑问→ 疑问 1 \color{red}疑问1 疑问1 与 疑问 2 \color{red}疑问2 疑问2 整合:CeresScanMatcher2D::Match()函数中,为什么平移残差优化的目标是 target_translation(由推断器或者暴力匹配获得),为什么旋转的残差目标值设置为 ceres_pose_estimate[2]。
CeresScanMatcher2D::Match() 的三个残差块都可以通过配置文件中的 occupied_space_weight、translation_weight、rotation_weight 对残差结果进行缩放。
总的来说,Cartographer源码是比较信任推断器的的,所以②使用target_translation作为优化目标,起到一个约束作用,避免优化过程中跑偏了。①的优化可能会导致角度发生得到优化,即ceres_pose_estimate[2]发生变化,但是源码中似乎认为推断器推断出来的姿态是十分准确的,并不想对齐进行优化,所以用初值作为优化目标,尽量保证其值不变。