C++学习笔记—g2o库—曲线拟合
1. 问题描述
我们有一系列二维数据点 $(x_i, y_i)$,我们相信这些数据点是由一个指数模型生成的,具体形式为:
$$y = \exp(ax^2 + bx + c)$$
然而,我们的观测值 $y_i$ 包含噪声。我们的目标是:根据这些带有噪声的观测数据 $(x_i, y_i)$,估计出最可能生成这些数据的模型参数 $(a, b, c)$ 的值。
这本质上是一个优化问题:寻找一组参数 $(a, b, c)$,使得模型预测值 $\exp(ax_i^2 + bx_i + c)$ 与实际观测值 $y_i$ 之间的总误差最小(通常是最小化误差的平方和)。
2. 思考:如何用图优化解决?
我们要将这个问题”翻译”成 g2o 的语言——图。一个图由 顶点 (Vertices) 和 边 (Edges) 组成。
顶点 (Vertex) 代表什么?
- 顶点代表了我们要优化的 未知变量。
- 在这个问题中,未知变量就是曲线的参数 $(a, b, c)$。这三个参数需要一起被估计,它们共同构成了一个状态。
- 因此,我们将 $(a, b, c)$ 定义为一个 g2o 顶点。
边 (Edge) 代表什么?
- 边代表了变量之间的 约束 或者 测量带来的误差项。
- 我们拥有的信息是 $N$ 个数据点 $(x_i, y_i)$。每一个数据点都提供了一个约束:对于给定的 $x_i$,由当前估计的 $(a, b, c)$ 计算出的预测值 $\exp(ax_i^2 + bx_i + c)$ 应该接近观测值 $y_i$。
- 因此,每一个数据点 $(x_i, y_i)$ 都对应图中的一条边。 这条边衡量了模型预测值与实际测量值之间的误差(残差)。
顶点和边如何连接?
- 计算第 $i$ 个数据点的误差 $e_i = y_i - \exp(ax_i^2 + bx_i + c)$ 时,我们只需要用到参数 $(a, b, c)$ (也就是那个唯一的顶点)和对应的 $x_i$ 值。
- 这意味着每条边 只连接到我们定义的那个参数顶点上。
- 因此,这些边都是一元边 (Unary Edge)。
总结思路: 我们将创建一个包含 一个顶点 (代表参数 a, b, c) 和 N 条一元边 (每条边代表一个数据点 (x, y) 带来的约束/误差) 的图。然后,让 g2o 优化这个图,调整顶点的值 (a, b, c),使得所有边的误差平方和(考虑信息矩阵加权)最小。
3. g2o 实现步骤
根据上面的思考,我们需要在 g2o 中完成以下步骤:
3.1 定义顶点 (CurveFittingVertex
)
- 目的: 表示待优化的参数 $(a, b, c)$。
- 实现:
- 继承
g2o::BaseVertex<D, T>
。 D
(维度): 参数有 a, b, c 三个,所以维度是 3。T
(类型): 用 Eigen 的三维向量Eigen::Vector3d
来存储 $(a, b, c)$。- 所以继承
g2o::BaseVertex<3, Eigen::Vector3d>
。 - 重写
setToOriginImpl()
: 设置顶点的初始估计值。通常设为 (0, 0, 0)。 - 重写
oplusImpl()
: 定义如何将优化算法计算出的增量update
(一个包含 $\Delta a, \Delta b, \Delta c$ 的 3 维向量) 应用到当前估计值_estimate
上。对于 $(a, b, c)$ 这种简单的向量空间变量,直接做向量加法_estimate += update
即可。 read
/write
函数在此例中可以留空。
- 继承
3.2 定义边 (CurveFittingEdge
)
- 目的: 表示每个数据点 $(x_i, y_i)$ 带来的约束,并计算其误差。
- 实现:
- 继承
g2o::BaseUnaryEdge<D, E, VertexType>
(因为只连接一个顶点)。 D
(误差维度): 误差 $e_i = y_i - \exp(\dots)$ 是一个标量,所以维度是 1。E
(测量值类型): 测量值 $y_i$ 是一个double
类型。VertexType
: 这条边连接的顶点类型是CurveFittingVertex
。- 所以继承
g2o::BaseUnaryEdge<1, double, CurveFittingVertex>
。 - 构造函数: 计算误差时需要用到 $x_i$,但 $x_i$ 不是待优化变量,也不是全局参数。它只与这条特定的边(这个特定的数据点)相关。因此,在创建边对象时,需要将对应的 $x_i$ 传入并存储在边对象的成员变量 (如
_x
) 中。 - 重写
computeError()
: 这是 边的核心。在此函数中:- 获取所连接的顶点 (
_vertices[0]
)。 - 获取顶点当前的估计值 $(a, b, c) = \text{vertex}->\text{estimate}()$。
- 使用存储的
_x
和当前的 $(a, b, c)$ 计算预测值 $y_{pred} = \exp(a\textit{x}^2 + b\textit{x} + c)$。 - 计算误差 $e = \text{_measurement} - y_{pred}$,并将结果赋给
_error(0,0)
。(_measurement
存储的是观测值 $y_i$,通过setMeasurement()
设置)。
- 获取所连接的顶点 (
linearizeOplus()
(雅可比计算): 这个例子中 没有重写 这个函数。这意味着 g2o 会默认使用 自动求导 来计算误差 $e$ 对参数 $a, b, c$ 的偏导数 $(\frac{\partial e}{\partial a}, \frac{\partial e}{\partial b}, \frac{\partial e}{\partial c})$。对于简单问题,自动求导很方便;对于性能要求高的复杂问题,通常需要手动计算并重写此函数。read
/write
函数在此例中可以留空。
- 继承
3.3 配置优化器
- 目的: 告诉 g2o 如何求解这个优化问题。
- 实现:
- 选择求解器类型:
BlockSolver
: 定义了优化变量(顶点)和误差(边)的维度。这里是BlockSolverTraits<3, 1>
(顶点维度 3, 边维度 1)。LinearSolver
: 定义了如何求解线性方程组 $Hx = -b$。对于规模不大的问题,可以用LinearSolverDense
;对于规模较大、稀疏性较好的问题(如 SLAM),常用LinearSolverCSparse
或LinearSolverCholmod
。本例用了LinearSolverCSparse
。
- 选择优化算法:
- 常用的有
OptimizationAlgorithmLevenberg
(LM 算法,鲁棒性好)、OptimizationAlgorithmGaussNewton
(高斯牛顿法)、OptimizationAlgorithmDogleg
。本例用了 LM 算法。
- 常用的有
- 创建
SparseOptimizer
对象: 这是整个图优化的管理器。 - 设置: 将选择的优化算法设置给
SparseOptimizer
,可以设置setVerbose(true)
来打印优化过程信息。
- 选择求解器类型:
3.4 构建图
- 目的: 将具体的顶点和边添加到优化器中。
- 实现:
- 添加顶点:
- 创建
CurveFittingVertex
对象。 - 设置其初始估计值 (
setEstimate
),例如(0, 0, 0)
。 - 设置其唯一 ID (
setId
)。 - 调用
optimizer.addVertex()
将其添加到图中。
- 创建
- 添加边:
- 遍历所有数据点 $(x_i, y_i)$。
- 为每个数据点创建一个
CurveFittingEdge
对象,传入 $x_i$。 - 设置其唯一 ID (
setId
)。 - 连接到顶点: 调用
edge->setVertex(0, vertex_pointer)
,将边的第 0 个接口连接到之前创建的顶点。 - 设置测量值: 调用
edge->setMeasurement(y_i)
。 - 设置信息矩阵: 调用
edge->setInformation(...)
。信息矩阵是测量噪声协方差矩阵的逆,代表了这条边的权重。如果假设噪声是高斯分布,标准差为 $\sigma$,那么对于一维误差,信息矩阵就是 $1/\sigma^2$。这告诉优化器:噪声越小($\sigma$ 越小,信息矩阵越大),这个测量值就越可信,优化时应该更努力地满足这个约束。 - 调用
optimizer.addEdge()
将边添加到图中。
- 添加顶点:
3.5 执行优化
- 目的: 启动 g2o 的优化过程。
- 实现:
- 调用
optimizer.initializeOptimization()
进行初始化。 - 调用
optimizer.optimize(max_iterations)
开始迭代优化,指定最大迭代次数。
- 调用
3.6 获取结果
- 目的: 从优化后的图中提取最终的参数估计值。
- 实现:
- 优化完成后,顶点对象内部的
_estimate
成员变量已经被更新为最优值。 - 直接调用
vertex->estimate()
即可获取优化后的 $(a, b, c)$。
- 优化完成后,顶点对象内部的
4. 准备工作
在编写 g2o 代码之前,需要:
- 包含头文件: 包含 g2o 核心库、求解器、算法以及 Eigen 库等必要的头文件。
- 准备数据: 生成或加载用于拟合的数据点
x_data
和y_data
。例子中是程序内部生成的带高斯噪声的数据。 - 定义参数: 确定噪声标准差
w_sigma
(用于生成数据和设置信息矩阵),数据点数量N
等。
5. 完整示例代码
1 |
|