C++学习笔记—g2o库—BA
1. 问题描述
我们有一个相机,它在某个未知的位置和姿态(合称位姿)下拍摄了一张照片。我们知道:
- 一些三维空间点的坐标 (
points_3d
)。 - 这些三维点在相机拍摄的照片上对应的二维像素坐标 (
points_2d
)。 - 相机的内部参数(焦距、主点),通常表示为一个内参矩阵 K。
- 相机位姿的一个初始估计值(旋转矩阵
R
和平移向量t
)。
由于三维点坐标的测量、二维点观测以及初始位姿估计都可能存在误差,直接将三维点通过初始位姿和内参投影到图像平面,得到的二维坐标通常不会精确地等于观测到的二维坐标。
目标: 同时优化调整相机的位姿 (R, t) 和 三维点的空间坐标 (points_3d
),使得所有三维点在优化后的位姿下,根据相机内参投影到图像平面上的重投影坐标与观测到的二维坐标 (points_2d
) 之间的误差(重投影误差) 的总和最小。
这本质上是一个大规模的非线性最小二乘优化问题。
2. 思考过程:如何用图优化解决?
我们将 BA 问题转化为 g2o 的图模型:
顶点 (Vertex) 代表什么?
- 顶点代表需要优化的未知变量。
- 在这个问题中,未知变量有两类:
- 相机的位姿 (Pose): 它有 6 个自由度(3 个旋转,3 个平移)。我们将用一个顶点来表示它。
- 每个三维空间点的坐标 (3D Point): 每个点有 3 个自由度(x, y, z)。我们将为每一个三维点创建一个对应的顶点。
- 因此,图中将包含 1 个位姿顶点和 N 个三维点顶点 (N 是点的数量)。
边 (Edge) 代表什么?
- 边代表变量之间的约束或测量误差。
- 我们的约束来自于观测:每个三维点 $P_i$ 在图像上被观测为一个二维点 $p_i$。这个观测关系同时关联了三维点 $P_i$ 的坐标和相机的位姿。
- 因此,每一个观测 $(P_i, p_i)$ 都对应图中的一条边。 这条边表达的是:根据当前估计的相机位姿和当前估计的三维点 $P_i$ 坐标,计算出的 $P_i$ 的重投影坐标 $p’_{i}$,与实际观测到的 $p_i$ 之间的误差 $e_i = p_i - p’_{i}$。
顶点和边如何连接?
- 计算第 $i$ 个观测的重投影误差 $e_i$ 时,我们需要用到:
- 相机的位姿(位姿顶点)。
- 第 $i$ 个三维点的坐标(第 $i$ 个点顶点)。
- 相机的内参 $K$(这个不是变量,而是已知参数)。
- 这意味着每条边都需要连接一个位姿顶点和一个对应的三维点顶点。
- 因此,这些边都是二元边 (Binary Edge)。
- 计算第 $i$ 个观测的重投影误差 $e_i$ 时,我们需要用到:
相机内参 K 如何处理?
- 在这个例子中,相机内参 $K$ 是已知且固定的,我们不优化它。
- 但是,在计算重投影误差(边的工作)时又必须用到它。
- g2o 提供了一种参数 (Parameter) 机制来处理这种情况。我们可以将相机内参封装成一个
g2o::CameraParameters
对象,并将其添加到优化器中。边在计算误差时可以访问这些参数。
总结思路: 我们将创建一个图,包含:
- 1 个
VertexSE3Expmap
类型的顶点(表示相机位姿)。 - N 个
VertexPointXYZ
类型的顶点(表示 N 个三维点)。 - 1 个
CameraParameters
类型的参数(存储相机内参 K)。 - N 条
EdgeProjectXYZ2UV
类型的二元边(每条边连接位姿顶点和一个点顶点,代表一个重投影误差约束,并关联相机参数)。
然后,让 g2o 优化这个图,调整所有顶点的值(位姿和点坐标),使得所有边的重投影误差平方和(考虑信息矩阵)最小。
3. g2o 实现步骤
根据 BA 的图优化思路,代码中执行了以下操作:
3.1 定义/选择顶点类型
- 相机位姿顶点:
- g2o 提供了预定义的
g2o::VertexSE3Expmap
。 - 它内部使用
SE3Quat
(包含 Eigen 的四元数和平移向量) 存储位姿估计值。 - 关键在于它的
oplusImpl
使用 李代数 se(3) 进行增量更新(通过指数映射),这对于表示旋转非常自然且避免了万向锁等问题。维度是 6。
- g2o 提供了预定义的
- 三维点顶点:
- g2o 提供了预定义的
g2o::VertexPointXYZ
。 - 它内部使用
Eigen::Vector3d
存储点的 (x, y, z) 坐标。维度是 3。 - 它的
oplusImpl
是简单的向量加法。
- g2o 提供了预定义的
3.2 定义/选择边类型
- 重投影误差边:
- g2o 提供了预定义的
g2o::EdgeProjectXYZ2UV
。 - 它是一个二元边,模板参数为
<2, Eigen::Vector2d, g2o::VertexPointXYZ, g2o::VertexSE3Expmap>
,表示:- 误差维度
D=2
(误差是二维像素向量 $(u_{err}, v_{err})$)。 - 测量值类型
E=Eigen::Vector2d
(存储观测到的像素坐标 $(u, v)$)。 - 连接的第一个顶点类型是
VertexPointXYZ
(索引 0)。 - 连接的第二个顶点类型是
VertexSE3Expmap
(索引 1)。
- 误差维度
- 这个类内部已经实现了
computeError()
和linearizeOplus()
:computeError()
: 获取位姿和点的当前估计值,获取相机参数,执行”世界坐标 -> 相机坐标 -> 像素坐标”的投影变换,计算预测像素坐标,然后用_measurement
(观测像素坐标) 减去预测坐标,得到_error
。linearizeOplus()
: 计算_error
(2维) 相对于VertexPointXYZ
(3维) 的雅可比矩阵 (2x3) 和相对于VertexSE3Expmap
(6维李代数) 的雅可比矩阵 (2x6)。这是 BA 的核心数学推导,g2o 已经帮你做好了。
- g2o 提供了预定义的
3.3 定义参数
- 相机内参:
- 使用
g2o::CameraParameters
类。 - 需要用相机的焦距 $f_x$ (假设 $f_y=f_x$) 和主点 $(c_x, c_y)$ 来构造它。
- 它需要设置一个 ID,并使用
optimizer.addParameter()
添加到优化器中。
- 使用
3.4 配置优化器
- 目的: 设置求解策略。
- 实现:
- 块求解器:
BlockSolver< BlockSolverTraits<6, 3> >
。这里的<6, 3>
通常与 BA 中涉及的主要变量维度(位姿 6D,点 3D)相关,尤其是在使用 Schur 消元时。 - 线性求解器:
LinearSolverCSparse
。BA 问题通常规模较大但具有稀疏性(一个点只会被少数几个位姿看到),稀疏求解器是必要的。 - 优化算法:
OptimizationAlgorithmLevenberg
(LM 算法) 是 BA 的常用选择,因为它结合了高斯牛顿法和梯度下降法的优点,比较鲁棒。 - 创建
SparseOptimizer
并设置算法和verbose
模式。
- 块求解器:
3.5 构建图
- 目的: 将具体的顶点、边和参数加入优化器。
- 实现:
- 添加位姿顶点:
- 创建
VertexSE3Expmap
对象。 - 从输入的初始
R
和t
创建g2o::SE3Quat
对象,并用setEstimate()
设置初始位姿。 - 设置 ID (例如 0)。
- 添加到优化器
optimizer.addVertex()
。
- 创建
- 添加三维点顶点:
- 遍历输入的
points_3d
。 - 为每个点创建一个
VertexPointXYZ
对象。 - 用
setEstimate()
设置其初始坐标。 - 设置唯一的 ID (例如从 1 开始递增)。
point->setMarginalized(true);
: 非常重要! 这告诉 g2o 在求解线性方程 $Hx = -b$ 时,可以将这些点顶点对应的变量边缘化 (Marginalize Out)。这利用了 BA 问题的特殊结构(点只与少数位姿相关),通过 Schur 消元 (Schur Complement) 技巧,可以大大减小求解的线性系统的规模(只需求解与位姿相关的部分),从而显著提高效率。- 添加到优化器
optimizer.addVertex()
。
- 遍历输入的
- 添加相机参数:
- 从内参矩阵
K
提取 $f_x, c_x, c_y$。 - 创建
CameraParameters
对象。 - 设置 ID (例如 0)。
- 添加到优化器
optimizer.addParameter()
。
- 从内参矩阵
- 添加边:
- 遍历输入的
points_2d
(需要与points_3d
一一对应)。 - 为每个观测创建一个
EdgeProjectXYZ2UV
对象。 - 设置唯一的 ID (例如从 1 开始递增,与点 ID 对应)。
- 连接顶点:
edge->setVertex(0, ...)
连接到对应的点顶点。注意代码中使用了optimizer.vertex(index)
通过 ID 获取顶点指针,并用dynamic_cast
转换类型。edge->setVertex(1, pose)
连接到位姿顶点。
- 设置测量值:
edge->setMeasurement(Eigen::Vector2d(p.x, p.y))
,传入观测到的像素坐标。 - 关联参数:
edge->setParameterId(0, 0)
。第一个 0 表示这是第 0 个参数集(CameraParameters),第二个 0 是该参数在优化器中的 ID。 - 设置信息矩阵:
edge->setInformation(Eigen::Matrix2d::Identity())
。这里简单地使用了单位矩阵,表示假设所有像素观测的噪声是各向同性的,且方差为 1。在实际应用中,可以根据像素噪声的估计来设置更精确的信息矩阵(例如,$\frac{1}{\sigma^2} \mathbf{I}$,其中 $\sigma$ 是像素噪声标准差)。 - 添加到优化器
optimizer.addEdge()
。
- 遍历输入的
- 添加位姿顶点:
3.6 执行优化
- 调用
optimizer.initializeOptimization()
。 - 调用
optimizer.optimize(num_iterations)
。
3.7 获取结果
- 优化完成后,位姿顶点
pose
内部的_estimate
已经被更新。 - 通过
pose->estimate()
获取优化后的g2o::SE3Quat
,可以将其转换回旋转矩阵R
和平移向量t
(代码中直接输出了Isometry3d
形式的变换矩阵)。 - 如果需要,也可以遍历所有点顶点,获取优化后的三维点坐标。
4. 准备工作
- 包含头文件: g2o 核心库、顶点类型 (
VertexSE3Expmap
,VertexPointXYZ
)、边类型 (EdgeProjectXYZ2UV
)、参数类型 (CameraParameters
)、求解器、算法、Eigen 等。 - 准备输入数据:
std::vector<cv::Point3f> points_3d
: 三维点坐标。std::vector<cv::Point2f> points_2d
: 对应的二维观测点坐标。cv::Mat K
: 相机内参矩阵。cv::Mat R
,cv::Mat t
: 相机位姿的初始估计。
- 数据类型转换: 需要将 OpenCV 的
cv::Mat
(R, t) 和cv::Point
转换为 g2o/Eigen 使用的Eigen::Matrix3d
,Eigen::Vector3d
,Eigen::Vector2d
等。
5. 完整示例代码 (带注释)
1 |
|