年初完成了深蓝学院多传感器融合的课程,接触到因子图,作业中完成了基于ceres库的因子图构建,但存在cost不下降的问题。计划利用gtsam进行重写,开始学习记录。本文主要内容为gtsam官方教程的翻译,结合泡泡机器人公开课56,会在中间穿插自己的理解和应用。但为了记录自己的学习过程,有些理论性已经掌握的内容会稍微省略。
官方git:官方git(https://github.com/borglab/gtsam)
官方教程:官方教程(https://gtsam.org/tutorials/intro.html)
因子图作为一种概率图模型,是将一个具有多变量的全局函数因子分解,得到几个局部函数的乘积,以此为基础得到的一个双向图叫做因子图。
在面试的过程中,有被问到因子图和位姿图的区别,当时有些懵,这里一起记录。
从BA开始说起,BA是SLAM和SFM定义出来的一个优化问题,以重投影误差为误差函数,以每个时刻位姿和所有路标点为优化变量,即将位姿和特征点一起优化。
位姿图优化在优化几次以后把特征点固定住不再优化,只当做位姿估计的约束,之后主要优化位姿。其节点是位姿,边为两个节点之间的相对运动。
将含有三个时间戳的隐马尔科夫模型的贝叶斯网络表示为例。
在该贝叶斯网络中,每个节点都和一个条件密度相关联。
这里,观测量 Z t Z_t Zt是已知的,因此后验概率可以拆分为六个因子的乘积(和一个系数),即三个先验与三个似然因子1。这里定义 L ( X t ; z ) ∝ P ( Z t = z ∣ X t ) L(X_t ; z) \propto P(Z_t = z | X_t) L(Xt;z)∝P(Zt=z∣Xt)。
P ( X 1 , X 2 , X 3 ∣ Z 1 , Z 2 , Z 3 ) ∝ P ( X 1 ) P ( X 2 ∣ X 1 ) P ( X 3 ∣ X 2 ) L ( X 1 ; z 1 ) L ( X 2 ; z 2 ) L ( X 3 ; z 3 ) P\left(X_{1}, X_{2}, X_{3} \mid Z_{1}, Z_{2}, Z_{3}\right) \propto P\left(X_{1}\right) P\left(X_{2} \mid X_{1}\right) P\left(X_{3} \mid X_{2}\right) L\left(X_{1} ; z_{1}\right) L\left(X_{2} ; z_{2}\right) L\left(X 3 ; z_{3}\right) P(X1,X2,X3∣Z1,Z2,Z3)∝P(X1)P(X2∣X1)P(X3∣X2)L(X1;z1)L(X2;z2)L(X3;z3)
将上面的隐马尔科夫模型以因子图的形式表示出来,可以得到下图,只需要表示未知的状态变量 X 1 , X 2 , X 3 X_1, X_2, X_3 X1,X2,X3以及与因子相连的概率信息。
为了最大化后验,需要使各因子乘积最大化,即
f ( X 1 , X 2 , X 3 ) = ∏ f i ( X i ) f\left(X_{1}, X_{2}, X_{3}\right)=\prod f_{i}\left(\mathscr{X}_{i}\right) f(X1,X2,X3)=∏fi(Xi)
从图中可以看出,因子图的连通性为每个因子编码了它所依赖的变量子集。 在下面的示例中,我们使用因子图来模拟机器人中更复杂的 MAP 推理问题。
简单的运动模型的例子。
三个变量表示了机器人的三个位姿。在这个例子中,有一个一元的因子 f 0 ( x 1 ) f_0(x_1) f0(x1),代表了先验信息( x 1 x_1 x1)。两个表示连续姿势相关的二元因子 f 1 ( x 1 , x 2 ; o 1 ) f_1(x_1,x_2;o_1) f1(x1,x2;o1)、 f 2 ( x 2 , x 3 ; o 2 ) f_2(x_2,x3;o_2) f2(x2,x3;o2)。其中, o 1 , o 2 o_1, o_2 o1,o2表示里程计观测。
// 新建一个非线性因子图
NonlinearFactorGraph graph;
// 假设x1上添加服从高斯分布的先验
Pose2 priorMean(0.0, 0.0, 0.0); //先验均值
noiseModel::Diagonal::shared_ptr priorNoise = //先验标准差, 30cm, 30cm, 0.1弧度
noiseModel::Diagonal::Sigmas(Vector3(0.3, 0.3, 0.1));
graph.add(PriorFactor<Pose2>(1, priorMean, priorNoise));
// 添加里程计因子
Pose2 odometry(2.0, 0.0, 0.0);
noiseModel::Diagonal::shared_ptr odometryNoise =
noiseModel::Diagonal::Sigmas(Vector3(0.2, 0.2, 0.1));
graph.add(BetweenFactor<Pose2>(1, 2, odometry, odometryNoise));
graph.add(BetweenFactor<Pose2>(2, 3, odometry, odometryNoise));
输出结果
Factor Graph:
size: 3
Factor 0: PriorFactor on 1
prior mean: (0, 0, 0)
noise model: diagonal sigmas [0.3; 0.3; 0.1];
Factor 1: BetweenFactor(1,2)
measured: (2, 0, 0)
noise model: diagonal sigmas [0.2; 0.2; 0.1];
Factor 2: BetweenFactor(2,3)
measured: (2, 0, 0)
noise model: diagonal sigmas [0.2; 0.2; 0.1];
GTSAM中,有两个很重要的点
第二点常常是一个易混淆的点。在设计GTSAM时,采用了与不可变的数学对象所对应的函数类方法。我们在使用时应当将因子图视为一个应用于数值的函数而不是一个对象的模型化,即 f ( X ) ∝ P ( X ∣ Z ) f(X) \propto P(X \mid Z) f(X)∝P(X∣Z)。
这里创建了一个变量示例,将其作为初值以找到整个轨迹的最大后验(MAP)。
// 建立初始估计(不准确的)
Values initial;
initial.insert(1, Pose2(0.5, 0.0, 0.2));
initial.insert(2, Pose2(2.3, 0.1, -0.2));
initial.insert(3, Pose2(4.1, 0.1, 0.1));
// 使用LM优化
Values result = LevenbergMarquardtOptimizer(graph, initial).optimize();
结果如下,可以看出最终结果非常接近真值(0,0,0) (2,0,0) (4,0,0)
Initial Estimate:
Values with 3 values:
Value 1: (0.5, 0, 0.2)
Value 2: (2.3, 0.1, -0.2)
Value 3: (4.1, 0.1, 0.1)
Final Result:
Values with 3 values:
Value 1: (-1.8e-16, 8.7e-18, -9.1e-19)
Value 2: (2, 7.4e-18, -2.5e-18)
Value 3: (4, -1.8e-18, -3.1e-18)
GTSAM可用于在合并所有观测信息 Z Z Z后计算每个位姿的协方差矩阵。
因子图编码了后验概率 P ( X ∣ Z ) P(X|Z) P(X∣Z)。每个位姿的 x x x的均值 μ \mu μ以及协方差 Σ \Sigma Σ近似了边缘后验密度 P ( x ∣ Z ) P(x|Z) P(x∣Z)。这只是一个近似值,因为即使在这种简单的情况下,里程计因子的参数实际上也是非线性的,并且 GTSAM 仅计算真实基础后验的高斯近似值。
下面的代码将复原后验边缘概率。
// 查询边缘
cout.precision(2);
Marginals marginals(graph, result);
cout << "x1 covariance:\n" << marginals.marginalCovariance(1) << endl;
cout << "x2 covariance:\n" << marginals.marginalCovariance(2) << endl;
cout << "x3 covariance:\n" << marginals.marginalCovariance(3) << endl;
相应输出
x1 covariance:
0.09 1.1e-47 5.7e-33
1.1e-47 0.09 1.9e-17
5.7e-33 1.9e-17 0.01
x2 covariance:
0.13 4.7e-18 2.4e-18
4.7e-18 0.17 0.02
2.4e-18 0.02 0.02
x3 covariance:
0.17 2.7e-17 8.4e-18
2.7e-17 0.37 0.06
8.4e-18 0.06 0.03
我们看到的是 x 1 x_1 x1 上的边缘协方差 P ( x 1 ∣ Z ) P(x_1|Z) P(x1∣Z) 只是 x 1 x_1 x1 上的先验知识,但是随着机器人移动,所有维度的不确定性都无限制地增长,并且 y y y 和 θ θ θ 分量变为(正 ) 相关。
解释这些数字时要注意的一个重要事实是协方差矩阵以相对坐标而不是绝对坐标给出。 这是因为 GTSAM 在内部针对线性化点的变化进行了优化,所有非线性优化库也是如此。
在本节中,我们将测量添加到因子图中,这将帮助我们随着时间的推移实际定位机器人。 该示例还可用作创建新因子类型的教程。
使用一元测量因子来处理外部观测。2.1中的因子图在实际中很少使用,因为它只含有里程计因子。这种外部会导致机器人位姿不确定度的快速增加。而上图添加的一元约束只与当前时刻的位姿有关系,可以由GPS、地图匹配位姿等方式得到。
我们可以创建自定义的一元约束。这可以通过继承类NoiseModelFactor1
实现,其噪声模型为高斯模型。
L ( q ; m ) = exp { − 1 2 ∥ h ( q ) − m ∥ Σ 2 } = f ( q ) L(q ; m)=\exp \left\{-\frac{1}{2}\|h(q)-m\|_{\Sigma}^{2}\right\}=f(q) L(q;m)=exp{−21∥h(q)−m∥Σ2}=f(q)
其中, m m m为观测值,认为是已知量; q q q为未知变量; h ( q ) h(q) h(q)是观测方程(可能为非线性); Σ \Sigma Σ是噪声方差。需要注意的是, 似 然 L ( q ; m ) 似然L(q ; m) 似然L(q;m)是与条件概率 P ( m ∣ q ) P(m|q) P(m∣q)成正比的任意函数,仅作为q的一个函数来计算,即可以表示为 f ( q ) f(q) f(q)。
一元约束的定义,可以用来实现GNSS、map-matching等位姿的约束。
每个因子最重要的就是实现函数evaluateError()
。因为我们需要将其用于非线性优化,所以应当显式的提供观测方程 h ( q ) h(q) h(q)的雅克比矩阵H。
class UnaryFactor: public NoiseModelFactor1<Pose2> { //Pose2作为默认类型
double mx_, my_; /// 位置的观测量
public:
UnaryFactor(Key j, double x, double y, const SharedNoiseModel& model):
NoiseModelFactor1<Pose2>(model, j), mx_(x), my_(y) {} //传入变量索引j,噪声模型,观测位置
//最重要的实现
Vector evaluateError(const Pose2& q,
boost::optional<Matrix&> H = boost::none) const
{
if (H) (*H) = (Matrix(2,3)<< 1.0,0.0,0.0, 0.0,1.0,0.0).finished();
return (Vector(2) << q.x() - mx_, q.y() - my_).finished();
}
};
在本例中,观测方程 h ( q ) = [ q x q y ] h\left(\begin{array}{l}q\end{array}\right)=\left[\begin{array}{l}q_{x} \\ q_{y}\end{array}\right] h(q)=[qxqy],但最终位姿为 q = ( q x , q y , q θ ) q=\left(q_{x}, q_{y}, q_{\theta}\right) q=(qx,qy,qθ),因此矩阵H为
H = [ 1 0 0 0 1 0 ] H=\left[\begin{array}{lll} 1 & 0 & 0 \\ 0 & 1 & 0 \end{array}\right] H=[100100]
在3.2中完成了因子的定义,接下来进行使用。
// 为所有位姿添加一元约束
//定义一元约束噪声
noiseModel::Diagonal::shared_ptr unaryNoise =
noiseModel::Diagonal::Sigmas(Vector2(0.1, 0.1)); // 10cm std on x,y
//添加约束
graph.add(boost::make_shared<UnaryFactor>(1, 0.0, 0.0, unaryNoise));
graph.add(boost::make_shared<UnaryFactor>(2, 2.0, 0.0, unaryNoise));
graph.add(boost::make_shared<UnaryFactor>(3, 4.0, 0.0, unaryNoise));
这三个GPS因素足以完全约束所有未知姿势,并将它们绑定到全局坐标系。否则,GTSAM将以奇异矩阵异常退出。可完全按照第2.5节的要求恢复边缘值,现在通过以下公式给出解和边缘协方差。误差最大值与2.5中相近,但不再逐步增大。这说明全局约束限制了姿态。
Final Result:
Values with 3 values:
Value 1: (-1.5e-14, 1.3e-15, -1.4e-16)
Value 2: (2, 3.1e-16, -8.5e-17)
Value 3: (4, -6e-16, -8.2e-17)
x1 covariance:
0.0083 4.3e-19 -1.1e-18
4.3e-19 0.0094 -0.0031
-1.1e-18 -0.0031 0.0082
x2 covariance:
0.0071 2.5e-19 -3.4e-19
2.5e-19 0.0078 -0.0011
-3.4e-19 -0.0011 0.0082
x3 covariance:
0.0083 4.4e-19 1.2e-18
4.4e-19 0.0094 0.0031
1.2e-18 0.0031 0.018
SLAM问题最简单的实例是PoseSLAM,避免了显式构建环境。SLAM的目标是在给定传感器测量值的情况下,同时定位机器人并绘制环境地图。除了轮式里程计,最常用的传感器之一是2D激光雷达,它提供连续位姿之间的里程计约束,以及机器人的回环约束。
新的因子图如下
代码实现如下
NonlinearFactorGraph graph;
noiseModel::Diagonal::shared_ptr priorNoise =
noiseModel::Diagonal::Sigmas(Vector3(0.3, 0.3, 0.1));
// 添加先验
graph.add(PriorFactor<Pose2>(1, Pose2(0, 0, 0), priorNoise));
// 添加里程计因子
noiseModel::Diagonal::shared_ptr model = //里程计噪声
noiseModel::Diagonal::Sigmas(Vector3(0.2, 0.2, 0.1));
graph.add(BetweenFactor<Pose2>(1, 2, Pose2(2, 0, 0 ), model));
graph.add(BetweenFactor<Pose2>(2, 3, Pose2(2, 0, M_PI_2), model));
graph.add(BetweenFactor<Pose2>(3, 4, Pose2(2, 0, M_PI_2), model));
graph.add(BetweenFactor<Pose2>(4, 5, Pose2(2, 0, M_PI_2), model));
// 添加回环约束
graph.add(BetweenFactor<Pose2>(5, 2, Pose2(2, 0, M_PI_2), model));
这边的话之后不太用matlab,所以跳过。
Matlab代码,生成100个位姿,运行结果如像下图所示。绿色位姿为初始位姿,蓝色为优化后位姿。
%% Initialize graph, initial estimate, and odometry noise
datafile = findExampleDataFile('w100.graph');
model = noiseModel.Diagonal.Sigmas([0.05; 0.05; 5*pi/180]);
[graph,initial] = load2D(datafile, model);
%% Add a Gaussian prior on pose x_0
priorMean = Pose2(0, 0, 0);
priorNoise = noiseModel.Diagonal.Sigmas([0.01; 0.01; 0.01]);
graph.add(PriorFactorPose2(0, priorMean, priorNoise));
%% Optimize using Levenberg-Marquardt optimization and get marginals
optimizer = LevenbergMarquardtOptimizer(graph, initial);
result = optimizer.optimizeSafely;
marginals = Marginals(graph, result);
PoseSlam可以很容易的扩展为三维位姿,但是需要注意更新旋转表示(四元数或旋转矩阵)。这可以使用GTSAM_USE_QUATERNIONS
选择。
通过以下MATLAB代码可以生成下图,绿色为优化前轨迹,红色为优化后轨迹。
%% Initialize graph, initial estimate, and odometry noise
datafile = findExampleDataFile('sphere2500.txt');
model = noiseModel.Diagonal.Sigmas([5*pi/180; 5*pi/180; 5*pi/180; 0.05; 0.05; 0.05]);
[graph,initial] = load3D(datafile, model, true, 2500);
plot3DTrajectory(initial, 'g-', false); % Plot Initial Estimate
%% Read again, now with all constraints, and optimize
graph = load3D(datafile, model, false, 2500);
graph.add(NonlinearEqualityPose3(0, initial.atPose3(0)));
optimizer = LevenbergMarquardtOptimizer(graph, initial);
result = optimizer.optimizeSafely();
plot3DTrajectory(result, 'r-', false); axis equal;
在基于地标的SLAM中,通过机器人位姿和地标的位置来显示构建地图。一个典型的链接关系如下图:位姿以里程计马尔科夫链链接,地标可以从不同的位姿被观测到,包括一元约束和二元约束;同时,x1存在先验因子。
该因子图的优化结果如下:绿线和蓝线分别表示了位姿和地标的协方差椭圆表示,轨迹和地标使用红色和青色表示。
其MATLAB代码如下:
% 创建图
graph = NonlinearFactorGraph;
% 创建变量索引(key)
i1 = symbol('x',1); i2 = symbol('x',2); i3 = symbol('x',3);
j1 = symbol('l',1); j2 = symbol('l',2);
% 添加先验
priorMean = Pose2(0.0, 0.0, 0.0); % prior at origin
priorNoise = noiseModel.Diagonal.Sigmas([0.3; 0.3; 0.1]);
% add directly to graph
graph.add(PriorFactorPose2(i1, priorMean, priorNoise));
% 添加里程计因子
odometry = Pose2(2.0, 0.0, 0.0);
odometryNoise = noiseModel.Diagonal.Sigmas([0.2; 0.2; 0.1]);
graph.add(BetweenFactorPose2(i1, i2, odometry, odometryNoise));
graph.add(BetweenFactorPose2(i2, i3, odometry, odometryNoise));
% 添加距离观测因子,以极坐标形式。
degrees = pi/180;
brNoise = noiseModel.Diagonal.Sigmas([0.1; 0.2]);
graph.add(BearingRangeFactor2D(i1, j1, Rot2(45*degrees), sqrt(8), brNoise));
graph.add(BearingRangeFactor2D(i2, j1, Rot2(90*degrees), 2, brNoise));
graph.add(BearingRangeFactor2D(i3, j2, Rot2(90*degrees), 2, brNoise));
在上面代码中,4~6行创建变量索引(key)在之前并没有强调。这里是使用符号函数为位姿和地标创建整数索引(key)。在GTSAM中,使用Key类型来处理所有变量,其类型为 s i z e _ t size\_t size_t(32或64位整形)。Key并不需要连续,但是必须唯一。对于不同类型的变量,MATLAB中提供了符号函数,c++中提供了符号类型,用于帮助创建可能距离较远的Key。因此可以开始对任意点开始编号而不用考虑偏移量。
创建Key只需要提供一个字符和一个整数索引。实际上,由符号生成Key是由Values类中的print函数自动生成的。
本例大约含有100个位姿与30个左右的地标,包含119个变量和517个因子,优化时间在10ms之内。通过文件PlanarSLAMExample_graph.m
运行而来。
下面是悉尼维多利亚公园的数据。蓝色的小椭圆是通过我们的快速算法获得的精确协方差与基于反演的精确协方差(橙色,被蓝色小椭圆所遮挡)一致。绿色椭圆则是基于我们早期工作的协方差。
SFM问题,本例在20X20X20的立方体周围摆放10个相机,用于捕获立方体顶点。
该问题可用GTSAM中完全相同的因子图框架,只是使用的是SFM观测因子。另外,增加了投影因子来计算给定相机位姿和顶点的重投影误差。因子由2D观测和已知的标定参数K来实例化。代码如下
%% Add factors for all measurements
noise = noiseModel.Isotropic.Sigma(2, measurementNoiseSigma);
for i = 1:length(Z),
for k = 1:length(Z{i})
j = J{i}{k};
G.add(GenericProjectionFactorCal3_S2(
Z{i}{k}, noise, symbol('x', i), symbol('p', j), K));
end
end
注:SFM的一个重要问题是数据关联和初始化,这是GTSAM没有完成的。GTSAM只是提供了BA优化。
GTSAM提供了一种基于贝叶斯树的增量推理算法,即iSAM。对于移动机器人,在有新的传感器数据时能够获取最新的地图是十分重要的。iSAM的目的就是这个。
下面的代码展示了如何在简单的视觉SLAM中使用iSAM。该示例涉及从八个连续相机位姿中看到的八个3D点。因此,在这里省略的第一步中,所有八个地标和第一个位姿都需要被恰当地初始化。在代码中,这是通过在真值附近增加扰动来完成的,但在实际应用中,需要非常小心地进行初始化位姿和地标,尤其是在单目相机中。
//创建非线性iSAM对象。每隔3步进行重新线性化和排序。
//该值取决于问题的非线性程度以及每一步期望的准确程度。
//ISAM2中不需要这个参数,进行了自动化判断。
int relinearizeInterval = 3;
NonlinearISAM isam(relinearizeInterval);
// ... 省略了第一帧初始化 ...
// 在不同的姿势上循环,将观察结果添加到iSAM
for (size_t i = 1; i < poses.size(); ++i) {
// 为每个地标的观测添加因子
NonlinearFactorGraph graph;
for (size_t j = 0; j < points.size(); ++j) {
graph.add(
GenericProjectionFactor<Pose3, Point3, Cal3_S2>
(z[i][j], noise,Symbol('x', i), Symbol('l', j), K)
);
}
// 添加对当前位姿增加初始估计
Values initialEstimate;
initialEstimate.insert(Symbol('x', i), initial_x[i]);
// 使用新的因子更新isam
isam.update(graph, initialEstimate);
}
代码的下半部分是典型的isam循环。
GTSAM还包括用于解决大规模SLAM问题的有效预处理共轭梯度(preconditioned conjugate gradients,PCG)方法。虽然直接法具有二次收敛性,并且对于稀疏问题非常有效,但它们通常需要大量的存储和有效的消除顺序。迭代优化方法只需要访问梯度,内存占用小,但收敛性差。GTSAM的子图预处理法结合了直接法和迭代法的优点,通过确定可以使用直接法轻松解决的子问题,并使用PCG解决剩余部分。
生成树显示为黑色,其余闭环约束显示为红色。GTSAM可以使用生成树作为预条件。
视觉里程计。通过跟踪或关联由刚性安装在机器人上的摄像机拍摄的连续图像中的视觉特征,提供连续机器人位姿之间的约束。GTSAM包括C++和MATLAB两个示例代码,以及VO特定的因子。
视觉SLAM是一种SLAM变体,当摄像机在空间中移动时,摄像机可以观察3D点,可以安装在机器人上,也可以用手移动。GTSAM,尤其是iSAM(见下文),可以很容易地调整为在这种情况下的后端优化器。
GTSAM可以轻松地执行递归估计,其中因子图中只保留位姿的子集,而其余位姿则被边缘化。在上面的所有示例中,使用所有可用的观测量显式优化所有变量,这称为平滑,因为轨迹是“平滑”的,这就是GTSAM的名称。相反,当图形中只保留最后n个姿势时,可以称为固定滞后平滑。最后,当只保留单个最新姿势时,可以说是滤波,事实上,SLAM的原始公式是基于滤波的。
最后,因子图不仅限于连续变量:GTSAM还可用于建模和解决离散优化问题。例如,隐马尔可夫模型(HMM)与第2节中的机器人定位问题具有相同的图形模型结构,只是HMM中的变量是离散的。GTSAM可以对离散模型进行优化和推理。
本文主要是对官方教程的翻译,代码较为简单,后续会进行代码方面的进一步学习。
贝叶斯公式,最大后验概率与似然乘先验成正比。即
P ( X ∣ Z ) = P ( Z ∣ X ) ( P X ) P ( Z ) ∝ P ( Z ∣ X ) P ( X ) P(X \mid Z)=\frac{P(Z \mid X)(P X)}{P(Z)} \propto P(Z \mid X) P(X) P(X∣Z)=P(Z)P(Z∣X)(PX)∝P(Z∣X)P(X) ↩︎