1. 项目概述:当PnP遇上李代数
在计算机视觉和机器人领域,估计相机相对于三维世界的位置和姿态(即相机位姿)是一个基础且核心的问题。Perspective-n-Point(PnP)问题就是其中的典型代表:给定一组三维空间点及其在二维图像上的投影点,求解相机的旋转和平移。这听起来像是解一个几何方程,但实际操作中,由于图像噪声、特征点匹配误差的存在,我们得到的往往是一个“病态”的方程组,直接求解要么无解,要么结果极不稳定。这时,“优化”的思想就登场了——我们不求一个完美的解析解,而是去寻找一个最优解,使得所有投影误差的平方和最小。这正是“最小二乘”的精髓。
然而,传统的基于欧拉角或旋转矩阵的优化会遇到一个大麻烦:旋转矩阵本身是带有约束的(正交且行列式为1),直接在它上面做加减法(即求导、迭代)会破坏这些约束,导致优化过程“跑偏”。这就好比在一个球面上行走,你每走一步都必须保证自己还在球面上,用普通的直角坐标加减法来规划路径会非常别扭且容易出错。
李代数的出现,完美地解决了这个“约束优化”的难题。对于旋转矩阵R所在的特殊正交群SO(3),其对应的李代数so(3)是一个向量空间。简单来说,李代数就是旋转矩阵在单位元(即单位矩阵,代表无旋转)处的“切空间”。在这个切空间里,我们可以自由地进行向量加减、求导,而每一次更新后,再通过一个指数映射,就能将切空间中的向量“拉回”到SO(3)流形上,得到一个合法的旋转矩阵。对于包含旋转和平移的刚体变换SE(3),其李代数se(3)同样提供了在六维空间(3维旋转+3维平移)中进行无约束优化的能力。
因此,“基于李代数的PnP优化”这个标题,指向的是一套非常经典且强大的视觉SLAM、三维重建位姿优化框架。它的核心流程是:将相机位姿(一个SE(3)矩阵)用其李代数se(3)的一个六维向量ξ(读作“克西”)参数化,然后构建关于ξ的重投影误差最小二乘问题,最后在se(3)这个向量空间中进行迭代优化(如高斯-牛顿法、列文伯格-马夸尔特法),从而得到最优的相机位姿。这套方法不仅是学术研究的热点,更是诸如ORB-SLAM、VINS-Mono等知名开源系统的基石。无论你是刚入门视觉几何的学子,还是希望深入理解SLAM优化原理的工程师,掌握这套“李代数上的PnP优化”都是打通任督二脉的关键一步。
2. 核心思路与数学基础拆解
要理解基于李代数的优化,我们不能绕过其背后的数学。但别担心,我们避开最晦涩的群论,聚焦于工程师最需要掌握的“是什么”和“怎么用”。
2.1 从SO(3)和SE(3)到李代数
旋转矩阵R属于特殊正交群SO(3)。它虽然只有9个元素,但受到6个约束(3个列向量单位正交),其自由度为3。这意味着,描述一个旋转最少只需要3个参数,比如旋转向量、欧拉角。李代数so(3)就是由三维向量φ(或记作ω)组成的空间,它与旋转向量有直接关系。两者之间的转换通过指数映射和对数映射完成:
- 指数映射 Exp(φ): 将李代数so(3)中的一个向量φ,映射为SO(3)中的一个旋转矩阵R。物理意义是:绕φ向量方向旋转|φ|角度。公式为罗德里格斯公式。
- 对数映射 Log(R): 将旋转矩阵R映射回其对应的李代数向量φ。这是指数映射的逆过程。
类似地,刚体变换矩阵T属于特殊欧氏群SE(3)。它包含旋转和平移,自由度为6。其对应的李代数se(3)是一个六维向量空间,记作 ξ = [ρ, φ]^T,其中φ对应旋转,ρ与平移和旋转都有关。se(3)与SE(3)的指数/对数映射形式更复杂,但思想一致:指数映射将se(3)中的“运动向量”ξ转换为一个刚体变换T;对数映射则反之。
注意:在优化中,我们几乎只使用指数映射。因为优化变量是李代数ξ,每次迭代得到增量Δξ后,我们需要用指数映射将更新后的ξ作用于上一轮的位姿估计上。对数映射多用于初始化或分析。
2.2 李代数的核心优势:加法与扰动模型
为什么李代数适合优化?关键在于李代数构成向量空间,在其上加法封闭。对于旋转矩阵,R1 + R2 通常不再是旋转矩阵。但对于李代数,φ1 + φ2 仍然是一个李代数向量。
在优化迭代中(如高斯-牛顿法),我们求解的是目标函数关于优化变量的导数,从而计算一个增量,然后用“原变量 + 增量”来更新变量。对于位姿T,如果我们将其表示为李代数ξ,那么更新步骤可以自然地写为:ξ_{k+1} = ξ_k + Δξ这里的“+”就是普通的向量加法。然后,我们通过T_{k+1} = Exp(Δξ) · T_k或等价地T_{k+1} = T_k · Exp(Δξ)来更新位姿矩阵。这两种形式分别对应左乘扰动模型和右乘扰动模型。
- 左乘扰动模型:假设扰动施加在相机坐标系(即当前位姿)本身。
Exp(Δξ) · T意味着先进行当前变换T,再施加一个微小的扰动变换。在求导时,我们是对扰动Δξ求导。这种模型在视觉SLAM中更为常用。 - 右乘扰动模型:假设扰动施加在世界坐标系到相机坐标系的变换过程中。
T · Exp(Δξ)意味着先施加扰动,再进行当前变换。两种模型求导公式不同,但本质等价,只需在推导时保持一致。
选择左乘或右乘,相当于选择了“扰动加在哪儿”,它影响了雅可比矩阵的形式。对于PnP问题,我们通常采用左乘扰动模型,因为它更直观:相机的位姿有了一个微小变化。
2.3 PnP最小二乘问题的构建
假设我们有n个三维空间点P_i^w = [X_i, Y_i, Z_i, 1]^T(齐次坐标,位于世界坐标系),以及它们在归一化相机平面上的投影点p_i = [u_i, v_i, 1]^T(去除了内参的像素坐标)。相机位姿T是一个4x4的SE(3)矩阵。
根据相机投影模型,有:s * p_i = K * T * P_i^w其中K是相机内参矩阵,s是深度尺度因子。为了简化,我们通常使用归一化坐标,即假设内参已知并已去除,令x_i = [x_i, y_i, 1]^T = K^{-1} * p_i。那么投影方程简化为:s * x_i = T * P_i^w
取前两维(第三维为尺度s),并引入李代数ξ参数化T,即T = Exp(ξ),我们可以定义第i个点的重投影误差e_i(ξ):e_i(ξ) = [x_i, y_i]^T - (Proj( Exp(ξ) * P_i^w ))其中Proj()操作表示将齐次坐标的前两维除以第三维,得到归一化平面坐标。
我们的目标是最小化所有点重投影误差的平方和,即构建最小二乘问题:ξ* = argmin_ξ 0.5 * Σ_i || e_i(ξ) ||^2
这就是我们需要优化的目标函数。它是一个关于李代数ξ的非线性函数,因为指数映射和投影操作都是非线性的。
3. 优化求解:高斯-牛顿法实战推导
对于非线性最小二乘问题,高斯-牛顿法是最常用且有效的工具之一。其核心思想是:在每次迭代的当前估计值ξ附近,将误差函数e_i(ξ)进行一阶泰勒展开,从而将非线性问题转化为关于增量Δξ的线性最小二乘问题。
3.1 误差函数的线性近似与雅可比矩阵
设当前迭代的位姿李代数为ξ,我们寻找一个增量Δξ,使得e_i(ξ + Δξ)最小。对其进行一阶泰勒展开:e_i(ξ + Δξ) ≈ e_i(ξ) + J_i Δξ其中J_i是误差e_i关于李代数增量Δξ的导数,是一个2x6的矩阵,称为雅可比矩阵。这是整个推导中最关键的一步。
根据左乘扰动模型,我们有T' = Exp(Δξ) · Exp(ξ)。将e_i在Δξ=0处求导:J_i = ∂e_i / ∂Δξ |_{Δξ=0} = - [ ∂Proj(P')/∂P' · ∂(T·P_i^w)/∂Δξ ] |_{Δξ=0}其中P' = T · P_i^w = [X', Y', Z']^T是点P_i^w在当前位姿估计下的相机坐标系坐标。
这个求导可以拆解为两部分:
- 投影导数:
∂Proj(P')/∂P'是一个2x3的矩阵。对于归一化平面投影[x, y]^T = [X'/Z', Y'/Z']^T,其导数为:∂Proj/∂P' = [ [1/Z', 0, -X'/Z'^2], [0, 1/Z', -Y'/Z'^2] ] - 变换导数:
∂(T·P_i^w)/∂Δξ是一个3x6的矩阵。根据SE(3)的指数映射及扰动模型,其导数形式固定,通常记为一个“变换的导数矩阵”。最终,雅可比矩阵J_i的形式为:
其中J_i = - [ [1/Z', 0, -X'/Z'^2], [0, 1/Z', -Y'/Z'^2] ] * [ [I_{3x3}, -[P']^∧] ][P']^∧是点P'的反对称矩阵。展开后,J_i是一个2x6的矩阵,其每一列对应李代数增量Δξ的每一个分量的影响。
实操心得:雅可比矩阵的推导是手写优化器时必须跨越的坎。初次实现时,极易在正负号、矩阵维度、扰动模型前后顺序上出错。一个有效的调试方法是使用数值微分来验证解析导数的正确性。即,给Δξ的某个分量一个微小扰动δ,计算
(e_i(δ) - e_i(0)) / δ,将其与解析求得的雅可比矩阵对应列进行比较。如果两者在数量级上一致(例如相差1e-4以内),则说明推导基本正确。
3.2 构建与求解增量方程
将每个点的误差线性近似代入总目标函数,我们得到:0.5 * Σ_i || e_i(ξ) + J_i Δξ ||^2这是一个关于Δξ的二次函数。为了最小化它,我们对其求导并令导数为零,得到著名的增量方程(或称正规方程):( Σ_i J_i^T J_i ) Δξ = - Σ_i J_i^T e_i(ξ)记H = Σ_i J_i^T J_i为海塞矩阵(在G-N法中近似),b = - Σ_i J_i^T e_i(ξ)。则方程简化为:H Δξ = b
这是一个线性方程组,Δξ是一个6维向量。我们求解这个方程组,得到本次迭代的最优增量Δξ。
3.3 迭代更新与算法流程
得到Δξ后,我们用它来更新当前的李代数估计:ξ = ξ + Δξ。注意,这里的“+”是向量加法。然后,我们需要将更新后的ξ通过指数映射转换为位姿矩阵T,用于下一轮迭代中计算新的投影点P'和误差e_i。
完整的基于高斯-牛顿法的李代数PnP优化算法流程如下:
- 初始化:给定初始位姿估计
T_0(可通过EPnP、DLT等线性方法求得),取其对应的李代数ξ_0 = Log(T_0)。设定最大迭代次数N和误差阈值epsilon。 - 迭代循环(for k = 0 to N-1): a.计算误差与雅可比:对于每一个匹配点对
(P_i^w, p_i),利用当前位姿T_k(由ξ_k通过指数映射得到)计算其在相机坐标系下的坐标P'_i,进而计算重投影误差e_i和雅可比矩阵J_i。 b.构建增量方程:计算海塞矩阵近似H = Σ J_i^T J_i和向量b = - Σ J_i^T e_i。 c.求解增量:求解线性方程组H Δξ = b,得到增量Δξ。这里需要注意矩阵H的可逆性,有时需要加入阻尼因子(即转向LM方法)或使用QR分解、SVD分解来稳定求解。 d.更新状态:ξ_{k+1} = ξ_k + Δξ。 e.判断收敛:如果增量Δξ的范数小于阈值epsilon,或者误差下降量很小,则提前终止迭代,认为收敛。 - 输出:将最终的李代数
ξ_*通过指数映射转换为最优位姿矩阵T_*。
4. 关键实现细节与工程化考量
理论推导清晰后,将其转化为稳定、高效的代码是另一项挑战。以下是一些关键的工程实现细节。
4.1 雅可比矩阵的计算优化
雅可比矩阵J_i的计算是迭代中最频繁的操作,需要高度优化。观察其形式:
J_i = - [ [1/Z', 0, -X'/Z'^2], [0, 1/Z', -Y'/Z'^2] ] * [ [I, -[P']^∧] ]计算过程可以手动展开,避免使用通用的矩阵乘法,以节省计算量。J_i的6个列向量分别对应[ρ_x, ρ_y, ρ_z, φ_x, φ_y, φ_z]的导数。展开后,我们可以直接写出每个元素的表达式:
J(0,0) = -1/Z‘; J(0,1) = 0; J(0,2) = X'/Z'^2; J(0,3) = X'*Y'/Z'^2; J(0,4) = -(1 + X'^2/Z'^2); J(0,5) = Y'/Z‘ J(1,0) = 0; J(1,1) = -1/Z'; J(1,2) = Y'/Z'^2; J(1,3) = 1 + Y'^2/Z'^2; J(1,4) = -X'*Y'/Z'^2; J(1,5) = -X'/Z'这样,在代码中只需计算一次1/Z',X'/Z',Y'/Z'以及它们的平方,就可以快速组装出整个雅可比矩阵。
4.2 李代数更新与指数映射的数值稳定性
更新步骤ξ = ξ + Δξ看似简单,但需要注意李代数的表示。在so(3)中,旋转向量φ的模长代表旋转角度。当旋转角度接近π或2π时,对数映射(从R求φ)存在奇异性。但在优化过程中,我们通常处理的是小增量Δξ,因此直接相加在大多数情况下是安全的。
指数映射Exp(ξ)的计算需要特别注意。对于so(3)部分(即ξ的后三维φ),通常使用罗德里格斯公式。当旋转角度θ = |φ|很小时,为了避免除以零,需要对sinθ/θ和(1-cosθ)/θ^2进行泰勒展开。成熟的数学库(如Sophus, Eigen的Geometry模块)都提供了数值稳定的实现。
注意事项:切勿自己从头实现指数/对数映射,除非是为了学习。强烈建议使用像Sophus(一个优秀的C++李代数库)这样的成熟库。它提供了SO(3)、SE(3)、Sim(3)等各种李群李代数的类,以及运算符重载、指数/对数映射、扰动模型求导等,能极大减少错误,提升开发效率。
4.3 从高斯-牛顿到列文伯格-马夸尔特
高斯-牛顿法在接近最优解时收敛速度快,但它的缺点是海塞矩阵近似H = J^T J可能病态(即奇异或条件数大),导致增量方程求解不稳定,迭代可能发散。
列文伯格-马夸尔特(L-M)方法是对高斯-牛顿法的改进。它在增量方程中引入一个阻尼因子λ:(H + λ * diag(H)) Δξ = b或者更常见的(H + λ I) Δξ = b。
- 当λ很大时,算法接近最速下降法,步长小,适合初始阶段或远离最优解时,保证稳定性。
- 当λ很小时,算法接近高斯-牛顿法,步长大,收敛快,适合接近最优解时。
L-M算法会根据本次迭代的效果(误差是否下降)动态调整λ:
- 如果误差下降,接受本次更新,并减小λ(例如除以10),以更接近高斯-牛顿法,加速收敛。
- 如果误差上升,拒绝本次更新,并增大λ(例如乘以10),以更接近最速下降法,寻找更稳妥的下降方向。
在PnP优化中,特别是当初始值较差或外点较多时,使用L-M法比纯高斯-牛顿法鲁棒得多。g2o、Ceres Solver等优化库在求解此类问题时,默认或推荐使用L-M或其变种。
4.4 鲁棒核函数的引入
最小二乘假设误差服从高斯分布,对 outliers(外点,即错误的匹配点)非常敏感。一个错误的匹配点会产生巨大的误差,由于其平方项,会严重扭曲优化结果,将解拉向错误的方向。
为了解决这个问题,需要引入鲁棒核函数(Robust Kernel)。核函数的作用是对误差的平方项进行重新加权,降低大误差项对总目标函数的贡献。常用的核函数有:
- Huber核:在误差较小时为二次函数,误差较大时转为一次函数。这是最常用的核函数,能平滑地抑制外点。
- Cauchy核:对重尾分布更鲁棒。
- Tukey核:当误差超过某个阈值后,权重直接降为0,完全丢弃该点。
在优化问题中,引入核函数后,目标函数变为:ξ* = argmin_ξ Σ_i ρ( || e_i(ξ) ||^2 )其中ρ(·)是核函数。这相当于对每个误差项e_i的平方e_i^T e_i施加了一个权重w_i。在迭代求解时,这个权重可以合并到信息矩阵中,即求解的方程变为:( Σ_i w_i * J_i^T J_i ) Δξ = - Σ_i w_i * J_i^T e_i(ξ)其中权重w_i = ρ'(s^2) / (2s),s = ||e_i||。
在工程实现中,可以在计算每个点的J_i^T J_i和J_i^T e_i之前,先计算其误差范数s,然后根据选定的核函数计算权重w_i,最后将w_i乘到J_i上(等价于sqrt(w_i) * J_i)再累加。Ceres Solver和g2o都内置了多种核函数,只需简单配置即可使用。
5. 代码实现与案例分析
让我们抛开庞大的库,用一个极度简化的C++示例,勾勒出基于高斯-牛顿法的PnP优化核心骨架。这里我们假设使用左乘扰动模型,并且忽略鲁棒核函数。
#include <iostream> #include <vector> #include <Eigen/Dense> #include <Eigen/Geometry> // 为了使用AngleAxisd,实际中应使用Sophus // 假设我们有一个简单的SE3指数映射函数(实际应用请用Sophus库) Eigen::Matrix4d Exp_se3(const Eigen::Vector6d& xi) { // 简化版:这里仅示意。实际应拆分旋转和平移部分,使用罗德里格斯公式等。 // 此处仅为保持代码段完整,假设有一个exp_se3实现。 Eigen::Matrix4d T = Eigen::Matrix4d::Identity(); // ... 实现指数映射 ... return T; } // 计算单个点的重投影误差和雅可比矩阵 (左乘扰动模型) void computeErrorAndJacobian(const Eigen::Vector3d& P_w, // 世界坐标系3D点 const Eigen::Vector2d& p_observed, // 观测到的归一化平面坐标 const Eigen::Matrix4d& T_cur, // 当前位姿估计 Eigen::Vector2d& error, // 输出:误差 Eigen::Matrix<double, 2, 6>& J) { // 输出:雅可比矩阵 // 将世界点变换到相机坐标系 Eigen::Vector4d P_w_homo(P_w.x(), P_w.y(), P_w.z(), 1.0); Eigen::Vector4d P_c_homo = T_cur * P_w_homo; Eigen::Vector3d P_c(P_c_homo.x(), P_c_homo.y(), P_c_homo.z()); double Z = P_c.z(); if (Z <= 0.0) { // 深度无效,可能是错误的位姿估计 error.setZero(); J.setZero(); return; } double inv_Z = 1.0 / Z; double inv_Z2 = inv_Z * inv_Z; // 计算理论投影点 (归一化平面) Eigen::Vector2d p_proj(P_c.x() * inv_Z, P_c.y() * inv_Z); // 计算误差 error = p_proj - p_observed; // 计算雅可比矩阵 J = - [∂proj/∂P_c] * [∂(T*P_w)/∂ξ] // 第一部分:投影导数 Eigen::Matrix<double, 2, 3> J_proj; J_proj << inv_Z, 0, -P_c.x() * inv_Z2, 0, inv_Z, -P_c.y() * inv_Z2; // 第二部分:变换导数 (左乘扰动模型) Eigen::Matrix<double, 3, 6> J_trans; J_trans.block<3, 3>(0, 0) = Eigen::Matrix3d::Identity(); // 平移部分 J_trans.block<3, 3>(0, 3) = -skewSymmetric(P_c); // 旋转部分,取负号 // 合并 J = -J_proj * J_trans; // 注意负号 } // 反对称矩阵函数 Eigen::Matrix3d skewSymmetric(const Eigen::Vector3d& v) { Eigen::Matrix3d S; S << 0, -v.z(), v.y(), v.z(), 0, -v.x(), -v.y(), v.x(), 0; return S; } // 高斯-牛顿法优化PnP Eigen::Matrix4d optimizePoseGN(const std::vector<Eigen::Vector3d>& points_3d, const std::vector<Eigen::Vector2d>& points_2d, const Eigen::Matrix4d& T_init, int max_iteration = 10) { Eigen::Matrix4d T_est = T_init; double current_error = 1e9; for (int iter = 0; iter < max_iteration; ++iter) { Eigen::Matrix<double, 6, 6> H = Eigen::Matrix<double, 6, 6>::Zero(); Eigen::Vector6d b = Eigen::Vector6d::Zero(); double total_error = 0.0; // 遍历所有点,计算雅可比和误差,构建H和b for (size_t i = 0; i < points_3d.size(); ++i) { Eigen::Vector2d error_i; Eigen::Matrix<double, 2, 6> J_i; computeErrorAndJacobian(points_3d[i], points_2d[i], T_est, error_i, J_i); H += J_i.transpose() * J_i; b += -J_i.transpose() * error_i; total_error += error_i.squaredNorm(); } // 求解增量方程 H * delta_xi = b Eigen::Vector6d delta_xi = H.ldlt().solve(b); // 使用LDLT分解求解 // 更新李代数 (这里简化处理,实际应用Sophus进行更新) // 假设有一个函数能将T转换为李代数向量xi,这里用伪代码表示 // Eigen::Vector6d xi_cur = log_se3(T_est); // Eigen::Vector6d xi_new = xi_cur + delta_xi; // T_est = exp_se3(xi_new); // 简化版:直接用一个近似的更新(仅用于示意,不正确!) // 正确做法应使用Sophus::SE3d::exp(delta_xi) * Sophus::SE3d(T_est) // 此处仅为展示流程 std::cout << "Iteration " << iter << ", error: " << total_error << ", delta_xi norm: " << delta_xi.norm() << std::endl; if (delta_xi.norm() < 1e-6) { std::cout << "Converged!" << std::endl; break; } // 使用Sophus的正确更新方式 (伪代码): // Sophus::SE3d T_se3(T_est); // T_se3 = Sophus::SE3d::exp(delta_xi) * T_se3; // 左乘扰动更新 // T_est = T_se3.matrix(); } return T_est; }这个示例省略了李代数与SE(3)相互转换的正确实现(应使用Sophus库),也省略了阻尼因子、鲁棒核等。但它清晰地展示了优化循环的骨架:计算误差和雅可比 -> 构建H和b -> 求解Δξ -> 更新位姿。
在实际项目中,我们绝不会从头写这个。而是使用g2o或Ceres Solver。
使用Ceres Solver示例:Ceres提供了自动求导功能,你只需要定义误差计算函数,它甚至能自动处理李代数的局部参数化。
struct PnPReprojectionError { PnPReprojectionError(const Eigen::Vector3d& P_w, const Eigen::Vector2d& p_obs) : P_w_(P_w), p_obs_(p_obs) {} template <typename T> bool operator()(const T* const pose_ptr, T* residuals_ptr) const { // pose_ptr 是长度为7的数组 [qx, qy, qz, qw, tx, ty, tz] (四元数+平移) // 或者用李代数参数化 // 计算重投影误差... // residuals_ptr[0] = T(px_proj) - T(p_obs_.x()); // residuals_ptr[1] = T(py_proj) - T(p_obs_.y()); return true; } }; // 然后添加到Problem,设置LocalParameterization(对于四元数或SE3)即可。使用g2o示例:g2o需要你定义顶点(Vertex,即待优化的位姿,使用李代数参数化)和边(Edge,即误差项,包含计算误差和雅可比的函数)。
// 定义顶点 class VertexPose : public g2o::BaseVertex<6, Sophus::SE3d> { // 实现read, write, oplusImpl (李代数更新) 等虚函数 }; // 定义边 class EdgeProjection : public g2o::BaseUnaryEdge<2, Eigen::Vector2d, VertexPose> { // 实现computeError和linearizeOplus (计算雅可比) 虚函数 };
6. 常见问题、调试技巧与性能优化
即使理解了原理,实现了代码,在实际应用中还是会遇到各种问题。下面是一些常见的坑和解决思路。
6.1 优化不收敛或结果发散
这是最常见的问题。可能的原因和排查步骤:
- 初始值太差:PnP非线性优化严重依赖初始值。如果初始位姿离真实值太远,优化可能陷入局部极小值或直接发散。务必先用一个线性方法(如EPnP、UPnP、DLT)或RANSAC+线性方法提供一个可靠的初始估计。
- 外点(Outliers)干扰:错误的特征匹配会产生巨大的误差,破坏优化。必须使用RANSAC在优化前剔除外点。即使在优化中,也应结合鲁棒核函数(如Huber核)。
- 数值问题:
- 海塞矩阵H奇异:当特征点共面或近似共面,或者点数量太少时,H矩阵可能不可逆。使用L-M法(添加阻尼因子λI)可以缓解。在代码中,使用稳定的线性代数求解器,如LDLT、QR或SVD分解(
H.jacobiSvd().solve(b)),它们能处理奇异或病态矩阵。 - 李代数更新溢出:当Δξ过大时,指数映射可能数值不稳定。确保使用稳定的指数映射实现(如Sophus),并检查迭代过程中Δξ的范数。如果单次更新过大,应减小学习率或增加L-M的阻尼因子。
- 海塞矩阵H奇异:当特征点共面或近似共面,或者点数量太少时,H矩阵可能不可逆。使用L-M法(添加阻尼因子λI)可以缓解。在代码中,使用稳定的线性代数求解器,如LDLT、QR或SVD分解(
- 雅可比计算错误:这是手推导数时最容易出错的地方。务必使用数值微分进行梯度检查。在迭代开始前,随机生成一个小扰动δ,分别用解析雅可比和数值微分计算
(e(ξ+δ) - e(ξ)) / δ,对比两者差异。如果差异显著,则解析导数公式有误。
6.2 尺度模糊性问题
在单目相机PnP中,如果所有3D点来自一个未知尺度的重建(如从运动中恢复结构SfM),那么求解出的平移向量t的尺度是不确定的。优化过程只能恢复出t_true = s * t,其中s是未知尺度因子。
解决方法:
- 引入尺度信息:至少有一个已知绝对尺度的3D点(例如,已知地图中某两点的真实距离)。
- 使用带尺度的SE(3)或Sim(3):如果你在优化一个单目SLAM的完整Bundle Adjustment,可能需要将位姿和点云一起在Sim(3)(相似变换群,包含一个尺度因子)下优化。
- 固定尺度:在优化中,固定平移向量的某一个分量(例如
t.z = 1)或者固定某些3D点的深度,从而确定尺度。但这需要谨慎选择固定的值。
6.3 性能优化技巧
当点数成千上万时,优化可能成为瓶颈。
- 稀疏性利用:在Bundle Adjustment中,海塞矩阵H具有特殊的稀疏结构(因为每个误差项只关联一个位姿和少数地图点)。使用g2o、Ceres或GTSAM等库,它们会自动利用稀疏性进行高效的舒尔消元(Schur Elimination),将大矩阵求逆分解为对更小矩阵的求逆,速度能提升几个数量级。
- 雅可比矩阵的稀疏性:对于PnP问题,每个误差项
e_i的雅可比J_i只与当前位姿ξ有关,是一个2x6的稠密小矩阵。因此,总雅可比矩阵是一个(2n) x 6的矩阵,H是一个6x6的小矩阵。求H = J^T J时,可以逐个点累加J_i^T J_i,避免存储巨大的J矩阵。 - 提前终止:设置合理的收敛条件(如
Δξ.norm() < 1e-6或误差下降率< 1e-6)和最大迭代次数(如10-20次),避免无意义的迭代。 - 使用更快的线性求解器:对于6x6的H矩阵,直接求逆(
H.inverse() * b)或使用LDLT分解就足够快。但在大型BA中,需要使用专门针对稀疏矩阵的求解器(如SuiteSparse、CHOLMOD)。
6.4 与EPnP、UPnP等线性方法的对比与选择
- 线性方法(EPnP, UPnP, DLT):速度快,无需初始值,能直接求解。适合作为非线性优化的初始值提供者。在RANSAC框架内,也常用线性方法快速生成假设模型。缺点是通常对噪声更敏感,且最小化的是代数误差或几何误差的近似,而非真正的重投影误差。
- 非线性优化(本文方法):精度高,能最小化真正的重投影误差,对噪声有更好的统计处理(结合鲁棒核)。缺点是速度慢,需要迭代,且严重依赖好的初始值。
标准流程:在实际的SLAM或SfM系统中,通常采用RANSAC + EPnP来在模型假设阶段快速、鲁棒地估计位姿并剔除外点,然后将内点集合和EPnP估计的位姿作为初始值,送入基于李代数的非线性优化进行精化,得到最终高精度位姿。
我个人在实现和调试这类优化问题时,最深的一点体会是:信任但不迷信数学推导,用数据来验证每一步。从数值微分验证雅可比开始,到用仿真数据(在已知真值的情况下加噪声)测试整个优化回路,确保它能收敛到真值附近。然后逐步引入真实数据的复杂性,如外点、初始值偏差等。同时,善用成熟的优化库,它们经过无数项目的锤炼,在数值稳定性和效率上远胜于个人实现的初版代码。理解其原理是为了在出问题时能有的放矢地排查,而不是为了重复造轮子。