在前两篇文章《最小二乘问题详解10:PnP问题求解》和《最小二乘问题详解11:基于李代数的PnP优化》中,我们分别通过常规思想与李代数思想,深入探讨了计算机视觉中 SFM(Structure from Motion)系统的核心子问题之一——PnP 问题。该问题建模于针孔成像原理,本质上是利用单视图中的2D-3D对应关系求解相机位姿,常被归入单视图几何的范畴。
然而,仅靠单视图无法恢复场景的三维结构:深度信息在投影过程中永久丢失。要重建真实世界,必须引入多视图几何(Multi-view Geometry)。而在多视图框架下,最基础、最关键的优化问题之一,便是三角化(Triangulation)——即:在已知多个相机位姿的前提下,通过多视角下的同名点观测,反推空间中对应 3D 点的位置。
三角化看似简单,却是 SfM 和 SLAM 系统中“结构恢复”的基础,本文要讲解就是三角化问题的非线性优化求解方法。
在计算机视觉中,三角化(Triangulation)是指已知多个相机的位姿和同一空间点在各图像中的观测位置,求解该点在世界坐标系下的三维坐标。其名称源于几何直观:从两个(或多个)相机光心向对应的图像点作射线,这些射线理论上应交于空间中的同一点——形成一个“三角形”。在理想无噪声情况下,两射线精确相交;但在实际中,由于位姿误差、特征匹配噪声等,射线往往不交,此时需通过优化找到“最佳”交点。
需要注意的是,三角化假设相机位姿已知(通常由 PnP 或 SfM 前端提供),因此它是一个纯结构恢复问题,与 PnP(已知结构求位姿)互为对偶。
回顾一下《最小二乘问题详解10:PnP问题求解》和《最小二乘问题详解11:基于李代数的PnP优化》中提到的针孔相机成像模型。设某空间点在世界坐标系下的位置为:
对于第
个相机,其位姿由旋转矩阵
和平移向量
描述(即世界到相机的变换)。 该点在第
幅图像上的投影像素坐标
满足:
其中:
为第
个相机的内参矩阵(通常假设已标定且恒定,记为
);
为未知尺度因子(深度)。
去齐次化后,得到重投影函数:
其中
表示
的第
行。
设某空间点被
个视角观测到,对应图像坐标为
,相机位姿为
。我们的目标是找到一个
,使得其在所有视角下的重投影结果尽可能接近观测值。由此定义残差向量:
最终的非线性最小二乘问题为:
可以看到,与 PnP 问题相比,三角化问题的优化还相对简单一点:PnP 优化变量是
,需李代数处理;三角化优化变量是
,在普通欧氏空间即可求解,无需流形。当然,由于
中存在分母(
),整体为非线性、非凸函数,需借助迭代优化方法求解。
尽管三角化的本质是非线性的(见式 (4)),但在实际应用中,我们常先使用一种线性近似方法快速获得初值,这就是直接线性变换(Direct Linear Transform, DLT)。DLT 的核心思想是将透视投影方程转化为齐次线性方程组,并通过 SVD 求解。
回顾成像方程 (1):
其中
。令
为第
个相机的投影矩阵,并将世界点表示为齐次坐标
,则上式可简写为:
由于等式两边相差一个未知尺度
,我们可以利用叉积消去尺度:
展开叉积(设
为
的三行):
注意到第三行是前两行的线性组合(因叉积秩为2),因此只需取前两行作为独立约束:
对每个视角
,我们得到两个线性方程。若共有
个视角,则可堆叠成一个
的设计矩阵
:
方程
构成一个齐次线性系统。在《最小二乘问题详解2:线性最小二乘求解》中,我们已系统讨论了线性最小二乘问题的一般形式
及其求解方法。然而,DLT 所面对的是该框架下的一个特殊情形:
。由于齐次方程的解在尺度上不确定(若
是解,则任意缩放
也是解),直接最小化
会退化为无意义的平凡解
。因此,我们必须施加单位范数约束
,以在单位球面上寻找使
最小的非零向量——即具有单位长度的最优方向。
这一目标等价于:
对
进行奇异值分解:
由于
是正交矩阵,其列向量构成
的一组标准正交基。因此,任何满足
的候选解均可唯一表示为:
由于
是正交矩阵,满足
,因此范数计算中
可被消去。此时有:
为使该式最小,在
约束下,应将全部权重分配给最小奇异值对应的分量,即取
,其余
。因此,最优解为:
📌 为何必须用 SVD,而不能用 QR? QR 分解适用于非齐次最小二乘问题(
),其核心是通过正交变换将问题转化为上三角系统求解。但在齐次情形
下,QR 无法直接揭示矩阵的零空间结构。只有 SVD 能显式给出所有奇异值及其对应的奇异向量,从而可靠地提取出使
最小的单位向量——这正是 DLT 所需的解。
最后,需对解进行去齐次化(dehomogenization)。这是因为
是齐次坐标,仅定义射影空间中的方向,而实际 3D 点位于欧氏空间。根据齐次坐标的定义,其对应的欧氏坐标为:
若
,说明点在无穷远处,通常应舍弃;若
,则可能对应负深度(点位于相机后方),需结合相机位姿进行符号校正。
尽管 DLT 实现简单、计算高效,但它存在几个根本性缺陷,限制了其精度:
,即代数残差。该残差混合了像素坐标、深度和尺度,没有明确的几何或物理意义。相比之下,我们真正关心的是重投影误差(单位:像素),如式 (4) 所示。
,若
是解,则
也是解。这导致 DLT 可能输出负深度的点(位于相机后方),必须通过深度符号校正(检查
)才能得到合理结果。
因此,在实际应用中,DLT 通常仅作为非线性最小二乘优化的初值——它计算快速,但精度有限。要获得高精度的三维点,我们必须回到几何本质:最小化重投影误差。
如第2节所述,三角化的理想目标是最小化重投影误差(式 (4)):
该问题是典型的非线性最小二乘问题。根据《最小二乘问题详解4:非线性最小二乘》和《最小二乘问题详解8:Levenberg-Marquardt方法》中的框架,其求解依赖于对残差函数
的一阶泰勒展开,而展开的核心正是雅可比矩阵
。
尽管现代优化库(如 Ceres)支持自动微分,但手动推导雅可比不仅能加深对几何模型的理解,还能在自定义优化器或性能敏感场景中提供关键优势。下面,我们详细推导该雅可比矩阵。
为简化记号,令第
个相机的外参变换为:
则重投影函数(式 (2))可写为:
残差为:
我们需要计算:
利用链式法则:
首先计算中间偏导:
而:
其中
是
的第
行。
因此,最终雅可比矩阵为:
或等价地写成:
雅可比矩阵描述了3D 点微小扰动如何影响图像观测。在这里可以看到,分母
表明:深度越大(
越大),图像对 3D 扰动越不敏感——这正是透视投影的非均匀性体现,也是 DLT 忽略的关键信息。
有了残差
和雅可比
,即可构建整体残差向量
和雅可比矩阵
(将所有
垂直堆叠)。
随后,可采用 Gauss-Newton 或 Levenberg-Marquardt 方法迭代求解(详见《最小二乘问题详解4:非线性最小二乘》和《最小二乘问题详解8:Levenberg-Marquardt方法》):
或
根据前文的理论推导,我们完整实现了基于 DLT 初值估计与非线性优化的三角化流程。具体代码如下:
#include <ceres/ceres.h>
#include <Eigen/Core>
#include <Eigen/Geometry>
#include <iomanip>
#include <iostream>
#include <random>
#include <vector>
constexpr double PI = 3.14159265358979323846;
// 投影函数:将世界坐标 X 投影到图像平面(对应式 (1))
Eigen::Vector2d Project(const Eigen::Matrix3d& K, const Eigen::Matrix3d& R_i,
const Eigen::Vector3d& t_i,
const Eigen::Vector3d& X_world) {
// 相机坐标系下的点: X_c = R_i * X + t_i (式 (1) 中间步骤)
Eigen::Vector3d X_cam = R_i * X_world + t_i;
// 像素齐次坐标: s * [u, v, 1]^T = K * X_cam
Eigen::Vector3d px_hom = K * X_cam;
// 去齐次化(式 (2))
return Eigen::Vector2d(px_hom.x() / px_hom.z(), px_hom.y() / px_hom.z());
}
// DLT 三角化(基于式 (5)-(8))
Eigen::Vector3d TriangulateDLT(const std::vector<Eigen::Matrix3d>& Rs,
const std::vector<Eigen::Vector3d>& ts,
const std::vector<Eigen::Vector2d>& observations,
const Eigen::Matrix3d& K) {
size_t N = Rs.size();
Eigen::MatrixXd A(2 * N, 4); // 式 (8): A ∈ ℝ^{2N×4}
for (size_t i = 0; i < N; ++i) {
// 构造完整的投影矩阵 P_i = K [R_i | t_i] ∈ ℝ^{3×4} (式 (5))
Eigen::Matrix<double, 3, 4> P_i;
P_i.block<3, 3>(0, 0) = K * Rs[i]; // K * R_i
P_i.col(3) = K * ts[i]; // K * t_i
double u_i = observations[i].x(); // 观测像素 u_i
double v_i = observations[i].y(); // 观测像素 v_i
// 构造 DLT 约束(式 (7)):
// (u_i * p_{i3}^T - p_{i1}^T) * X_tilde = 0
// (v_i * p_{i3}^T - p_{i2}^T) * X_tilde = 0
A.row(2 * i) = u_i * P_i.row(2) - P_i.row(0);
A.row(2 * i + 1) = v_i * P_i.row(2) - P_i.row(1);
}
// SVD 求解 min ||A X_tilde|| s.t. ||X_tilde||=1 (式 (9) 前)
Eigen::JacobiSVD<Eigen::MatrixXd> svd(A, Eigen::ComputeFullV);
Eigen::Vector4d X_tilde = svd.matrixV().col(3); // 对应 v_4
// 齐次坐标 X_tilde = [X, Y, Z, W]^T → 欧氏坐标 = [X/W, Y/W, Z/W]^T
if (std::abs(X_tilde.w()) < 1e-8) {
// 点在无穷远处,无法有效三角化;返回原点或可抛异常
return Eigen::Vector3d::Zero();
}
Eigen::Vector3d X_euclid(X_tilde.x() / X_tilde.w(), X_tilde.y() / X_tilde.w(),
X_tilde.z() / X_tilde.w());
// 符号校正:由于 X_tilde 和 -X_tilde 都是齐次解,
// 去齐次化后对应 X_euclid 和 -X_euclid,需选择使更多相机看到正深度的解
Eigen::Vector3d X1 = X_euclid;
Eigen::Vector3d X2 = -X_euclid;
auto count_positive_depth = [&](const Eigen::Vector3d& X) -> int {
int cnt = 0;
for (size_t i = 0; i < N; ++i) {
double z_cam = (Rs[i] * X + ts[i]).z(); // (R_i X + t_i)_z
if (z_cam > 0) cnt++;
}
return cnt;
};
Eigen::Vector3d X_dlt =
(count_positive_depth(X1) >= count_positive_depth(X2)) ? X1 : X2;
// 最终保障:至少第一个相机深度为正
if ((Rs[0] * X_dlt + ts[0]).z() <= 0) {
X_dlt = -X_dlt;
}
return X_dlt;
}
// Ceres 残差块:重投影误差(对应式 (3))
struct ReprojectionError {
ReprojectionError(const Eigen::Vector2d& u_obs, const Eigen::Matrix3d& K,
const Eigen::Matrix3d& R_i, const Eigen::Vector3d& t_i)
: u_obs_(u_obs), K_(K), R_i_(R_i), t_i_(t_i) {}
template <typename T>
bool operator()(const T* const X_world, T* residuals) const {
// X_world: 优化变量,对应式 (4) 中的 X ∈ ℝ^3
Eigen::Map<const Eigen::Matrix<T, 3, 1>> X(X_world);
// 转换到相机坐标系: X_cam = R_i * X + t_i
Eigen::Matrix<T, 3, 3> R_i_T = R_i_.template cast<T>();
Eigen::Matrix<T, 3, 1> t_i_T = t_i_.template cast<T>();
Eigen::Matrix<T, 3, 1> X_cam = R_i_T * X + t_i_T;
// 投影到像素平面(式 (2))
Eigen::Matrix<T, 3, 3> K_T = K_.template cast<T>();
Eigen::Matrix<T, 3, 1> px_hom = K_T * X_cam;
T u_proj = px_hom[0] / px_hom[2]; // f_x * x_c / z_c + c_x
T v_proj = px_hom[1] / px_hom[2]; // f_y * y_c / z_c + c_y
// 残差 = 投影值 - 观测值(式 (3))
residuals[0] = u_proj - T(u_obs_.x());
residuals[1] = v_proj - T(u_obs_.y());
return true;
}
static ceres::CostFunction* Create(const Eigen::Vector2d& u_obs,
const Eigen::Matrix3d& K,
const Eigen::Matrix3d& R_i,
const Eigen::Vector3d& t_i) {
return new ceres::AutoDiffCostFunction<ReprojectionError, 2, 3>(
new ReprojectionError(u_obs, K, R_i, t_i));
}
private:
Eigen::Vector2d u_obs_; // 观测像素坐标 [u_i, v_i]^T
Eigen::Matrix3d K_, R_i_; // 内参、旋转
Eigen::Vector3d t_i_; // 平移
};
int main() {
// === 相机内参 K(式 (1))===
double f_x = 800.0, f_y = 800.0, c_x = 320.0, c_y = 240.0;
Eigen::Matrix3d K;
K << f_x, 0, c_x, 0, f_y, c_y, 0, 0, 1;
// === 真实3D点 X_gt(世界坐标)===
Eigen::Vector3d X_gt(1.2, -0.5, 3.0);
// === 相机位姿 {R_i, t_i} ===
std::vector<Eigen::Matrix3d> Rs;
std::vector<Eigen::Vector3d> ts;
// 相机1: 单位位姿
Rs.push_back(Eigen::Matrix3d::Identity());
ts.push_back(Eigen::Vector3d::Zero());
// 相机2: 绕Y轴旋转30度,平移(0.5, 0, 0)
double angle = PI / 6.0;
Eigen::AngleAxisd rot(angle, Eigen::Vector3d::UnitY());
Rs.push_back(rot.toRotationMatrix());
ts.push_back(Eigen::Vector3d(0.5, 0.0, 0.0));
// 相机3: 绕Y轴旋转 -20 度,平移 (-0.3, 0.1, 0.2)
Rs.push_back(Eigen::AngleAxisd(-PI / 9.0, Eigen::Vector3d::UnitY())
.toRotationMatrix());
ts.push_back(Eigen::Vector3d(-0.3, 0.1, 0.2));
// === 生成带噪声观测 {u_i} ===
std::vector<Eigen::Vector2d> observations_clean;
for (size_t i = 0; i < Rs.size(); ++i) {
Eigen::Vector2d proj = Project(K, Rs[i], ts[i], X_gt);
observations_clean.push_back(proj);
}
std::mt19937 gen(42);
std::normal_distribution<double> noise(0.0, 2); // 2像素高斯噪声
std::vector<Eigen::Vector2d> observations;
for (const auto& obs : observations_clean) {
double u_noisy = obs.x() + noise(gen);
double v_noisy = obs.y() + noise(gen);
observations.emplace_back(u_noisy, v_noisy);
}
std::cout << std::fixed << std::setprecision(6);
std::cout << "=== Ground Truth 3D Point X_gt ===" << std::endl;
std::cout << "[" << X_gt.x() << ", " << X_gt.y() << ", " << X_gt.z() << "]"
<< std::endl;
// === 1. DLT 初值 ===
Eigen::Vector3d X_dlt = TriangulateDLT(Rs, ts, observations, K);
std::cout << "\n=== DLT Estimate X_dlt ===" << std::endl;
std::cout << "[" << X_dlt.x() << ", " << X_dlt.y() << ", " << X_dlt.z() << "]"
<< std::endl;
// === 2. 非线性优化(最小化式 (4))===
double X_opt[3] = {X_dlt.x(), X_dlt.y(), X_dlt.z()}; // 初值
ceres::Problem problem;
for (size_t i = 0; i < Rs.size(); ++i) {
problem.AddResidualBlock(
ReprojectionError::Create(observations[i], K, Rs[i], ts[i]), nullptr,
X_opt);
}
ceres::Solver::Options options;
options.linear_solver_type = ceres::DENSE_QR;
options.minimizer_progress_to_stdout = true;
options.max_num_iterations = 20;
ceres::Solver::Summary summary;
ceres::Solve(options, &problem, &summary);
std::cout << "\n" << summary.BriefReport() << "\n";
Eigen::Vector3d X_est(X_opt[0], X_opt[1], X_opt[2]);
std::cout << "=== Nonlinear Optimization Estimate X_est ===" << std::endl;
std::cout << "[" << X_est.x() << ", " << X_est.y() << ", " << X_est.z() << "]"
<< std::endl;
// === 3. 评估 ===
double dlt_error = (X_gt - X_dlt).norm();
double opt_error = (X_gt - X_est).norm();
double total_reproj_err_sq = 0.0;
for (size_t i = 0; i < Rs.size(); ++i) {
Eigen::Vector2d proj = Project(K, Rs[i], ts[i], X_est);
double err = (proj - observations[i]).norm();
total_reproj_err_sq += err * err;
}
double reproj_rmse = std::sqrt(total_reproj_err_sq / Rs.size());
std::cout << "\n=== Evaluation ===" << std::endl;
std::cout << "DLT 3D error: " << dlt_error << " meters" << std::endl;
std::cout << "Optimized 3D error: " << opt_error << " meters" << std::endl;
std::cout << "Final reprojection RMSE: " << reproj_rmse << " pixels"
<< std::endl;
return 0;
}该实现遵循典型的视觉三维重建范式:先通过 DLT(Direct Linear Transform)快速获得一个闭式初值,再以该初值为起点,利用 Ceres Solver 对重投影误差进行非线性最小二乘优化,从而在几何意义上获得更精确的 3D 点估计。整个流程与《最小二乘问题详解9:使用Ceres求解非线性最小二乘》中介绍的通用优化框架完全一致。
值得注意的是,DLT 求解的是齐次坐标下的代数最小二乘问题,其解在符号上具有天然的二义性——即若
是解,则
同样满足方程。然而,只有其中一个符号对应物理上合理的 3D 点(即在所有或大多数相机前方)。因此,代码中专门加入了符号校正机制:通过统计各视角下点的深度(
分量)是否为正,选择使更多相机看到“正深度”的解,并进一步确保第一个相机的深度为正,以消除歧义。
本实验设置了三个视角:主相机位于原点,第二、第三相机分别绕 Y 轴旋转 ±30°/20° 并施加小幅平移,构成良好的三角化几何结构。同时,我们在理想投影上叠加了标准差为 2 像素的高斯噪声,以模拟实际特征匹配中的观测误差。
程序运行输出如下:
=== Ground Truth 3D Point X_gt ===
[1.200000, -0.500000, 3.000000]
=== DLT Estimate X_dlt ===
[1.195321, -0.500514, 2.982901]
iter cost cost_change |gradient| |step| tr_ratio tr_radius ls_iter iter_time total_time
0 3.862688e+00 0.00e+00 6.30e+02 0.00e+00 0.00e+00 1.00e+04 0 9.30e-05 2.91e-04
1 3.171789e+00 6.91e-01 4.62e-01 0.00e+00 1.00e+00 3.00e+04 1 1.12e-04 5.51e-04
Ceres Solver Report: Iterations: 2, Initial cost: 3.862688e+00, Final cost: 3.171789e+00, Termination: CONVERGENCE
=== Nonlinear Optimization Estimate X_est ===
[1.195772, -0.498440, 2.983621]
=== Evaluation ===
DLT 3D error: 0.017735 meters
Optimized 3D error: 0.016988 meters
Final reprojection RMSE: 1.454141 pixels可以看到,此处 DLT 的 3D 误差略大于优化结果(0.0177 > 0.0169),表明非线性优化确实带来了改进。不过,DLT 的初值已经非常接近真值,非线性优化带来的 3D 位置改进相对微小(约 4%)。这是因为当前三角化条件良好(视角夹角适中、基线合理、噪声水平较低)。另外,从输出可见,优化后的重投影 RMSE 已成功降低,说明优化器确实更好地拟合了带噪观测数据。
在真实应用中,三角化精度高度依赖于多视图几何构型。当面临小基线、大噪声、弱纹理或极端视角等挑战性场景时,DLT 的代数误差会显著放大,导致深度估计严重失真;而基于重投影误差的非线性优化则能通过合理的几何约束保持鲁棒性与精度,优势将更加明显。
尽管三角化(包括 DLT 和非线性优化)是多视图几何中的核心工具,但在实际应用中其可靠性受到多重因素的制约:
基于以上且不局限于以上的现实工程环境,单纯依赖两视图或多视图三角化可能失效。实践中常采用以下策略来提高三角化的鲁棒性和精度:
因此,虽然三角化是视觉 SLAM 与 SfM 中的基础模块,但其成功应用高度依赖于合理的场景条件与系统级设计。未来的研究方向可以进一步探索基于学习的深度初始化方法、概率三角化(考虑观测协方差)或多传感器融合策略,以突破传统几何方法的局限,提高系统的鲁棒性和精度。
我的博客即将同步至腾讯云开发者社区,邀请大家一同入驻:https://cloud.tencent.com/developer/support-plan?invite_code=d6dlr68ip754