
做三维重建的没跟光束平差法Bundle Adjustment后文统一叫BA打过交道的人几乎不存在。无论你跑的是openMVG、COLMAP、OpenSfM还是自己从零搭一套SfM管线跑完特征匹配和位姿初始化之后一定会撞上BA这一步。它在整个重建管道里扮演的角色简单说就是把前面所有环节攒下来的误差一次性“压实”让相机位姿和空间点坐标达到全局自洽。这篇文章我会从原理讲到实操再把我自己调试BA时踩过的坑、总结的排查套路一并写出来希望能帮你少走点弯路。为什么我强调“全局自洽”因为三维重建本质上是解一个巨大的几何反问题给你一堆照片上的同名点你要反推出每张照片拍照时相机在哪、朝哪看以及每个同名点对应的空间点在哪。特征匹配给的是“像素上的对应关系”初始化阶段给的是“一个大差不差的初值”但这两者都有误差。BA就是在这个基础上做联合优化——同时调相机参数和点坐标让所有重投影误差的平方和尽量小。你要是跳过BA哪怕前面匹配做得再漂亮重建出来的点云也会像喝多了酒一样东倒西歪。这篇文章适合三类人看刚入门SfM、对BA只有模糊概念的学生已经会调COLMAP但想知道里面到底发生了什么、遇到问题不知道怎么排查的工程师以及准备自己手写一个轻量BA求解器、想理清数学和工程细节的进阶选手。我会尽量把公式讲得人话一点重点放在“为什么这么做”和“实操时怎么调”而不是堆一堆别人看不懂的推导就完事。1. 光束平差法到底在干什么从“糊成一片”聊起1.1 一个生活化类比先看清楚BA的职责想象你站在一个空旷的广场上手里拿着一张地图想确定广场上几根柱子的准确位置。你拍了三张照片每张照片里柱子都出现在不同位置。你可以凭感觉说“柱子大概在那片区域”但每张照片的拍摄角度、你的站位都有误差导致你对柱子位置的猜测互相打架——照片A说柱子偏左照片B说柱子偏右照片C说柱子更远。你最后要做的事就是同时调整“你的站位猜测”和“柱子的位置猜测”让这三张照片里的柱子在所有照片里都对得上。BA干的就是这件事。这里的“你”就是相机位姿“柱子”就是三维空间点“照片里的柱子位置”就是观测到的像点坐标。BA会找到一个折中方案稍微挪一下相机再稍微动一下空间点让所有点的重投影误差整体最小。它不是魔法不能凭空造出信息但它能把已有信息用到了极致。这个类比的妙处在于它能解释BA最重要的特性——联合优化。不是先修相机再修点也不是先修点再修相机而是同时修。正因为联合优化BA才能把误差均匀地摊到所有变量上而不是像分步优化那样前面失误后面全错。1.2 BA在三维重建流程中的位置一套典型的增量式SfM流程大概是这样的特征提取与匹配、从匹配关系估计基础矩阵或本质矩阵、三角化初始空间点、PnP新增相机位姿、然后就是BA。你可以把BA理解成整个重建过程的“收口”环节每加入一批新相机和新点就要跑一次局部BA或全局BA。COLMAP实际执行的就是这个策略每次注册新相机后做局部BA把所有相机都注册完了再做一次全局BA收尾。为什么不能只在最后做一次全局BA因为增量式重建过程中如果中途不加控制误差会一步步累积后面新增的相机位姿可能从一开始就是歪的最后一次性优化很难拉回来。这就好比写代码每写一个函数就顺手做一次小测试等全部写完再统一Debug你很可能面对的是一个分不清是哪个模块搞坏了的巨型Bug。所以理解BA不能只看它本身还得理解它在整个管线里的位置。它的输入非常明确一组相机参数初值、一组三维点初值、一组带观测权重的对应关系。它的输出也非常明确优化后的相机参数和三维点。前后都不需要玄学但接口做得好不好、初值给得好不好直接决定BA能不能收敛到好结果。2. 核心数学框架重投影误差与LM迭代2.1 重投影误差这么定义BA的目标函数长这样要优化的变量是相机外参T_i旋转加平移、以及三维点坐标X_j。对每个观测第i个相机看到第j个点观测到的像素坐标是u_ij我们计算预测值用当前的相机参数把X_j投影到图像平面得到预测像素坐标然后和真实观测值做差。这个差就叫重投影误差。e_ij u_ij - proj(C_i, X_j)其中C_i是相机的内参和外参集合proj是投影函数。整个BA就是在最小化所有e_ij的加权平方和。如果考虑鲁棒性还会给误差加一个核函数比如Huber核把大误差的影响压下去——这个后面会细说。这里有一个容易被忽略但很重要的点误差的定义方式决定了对变量尺度变化的敏感度。如果你用像素坐标直接算误差图像分辨率越高误差数值就越大梯度也越猛。如果你用归一化坐标系算误差数值就温和得多。实践里通常会在进入求解器之前对观测数据做归一化或者给不同尺度的变量设置不同的参数化方式目的都是让优化问题在数值上更好解。2.2 目标函数与雅可比矩阵把所有误差项堆起来BA就变成一个非线性最小二乘问题min Σ || e_ij ||^2非线性最小二乘的标准解法是迭代法每一步把误差函数在当前变量值附近线性化也就是计算误差对每个优化变量的导数雅可比矩阵然后解一个线性方程组得到更新量更新变量后再重复。雅可比矩阵的稀疏结构是BA最著名的特征每个误差项只关联一个相机和一个三维点所以整个雅可比矩阵是高度稀疏的。这个稀疏性不是工程上的偷懒而是算法能做快的关键。Ceres Solver里面你不需要自己手写这个雅可比矩阵只需要定义每个误差项的CostFunctor实现operator()计算残差和雅可比。但理解雅可比的结构仍然很重要——它决定了你用哪个线性求解器、内存占用多少、跑得快不快。举个例子一个包含1000个相机、50000个点的BA问题如果老老实实用稠密矩阵存雅可比几个GB内存都不一定够。但用稀疏结构存储加上Schur补技巧普通工作站几十秒就能跑完。2.3 为什么是LM而不是高斯牛顿非线性最小二乘的迭代法有好几种最速下降、高斯牛顿、列文伯格-马夸尔特LM。BA领域基本被LM统治。原因很简单高斯牛顿在靠近最优解时收敛快但离最优解远时容易发飘最速下降稳定但慢得让人着急。LM算法在两者之间动态插值通过一个阻尼因子λ控制步伐λ大的时候接近最速下降稳但慢λ小的时候接近高斯牛顿快但野。LM的工程实现里有几个小细节直接影响收敛质量λ的初值怎么设、每次迭代后怎么调整λ、什么时候判定收敛。Ceres里的LM实现已经把这些都封装好了但如果你是手写求解器这些细节能让你调到头秃。我个人的经验是λ初值不要拍脑袋可以根据初始误差的梯度范数来定比如设成梯度的最大元素乘以一个系数。每轮迭代如果误差下降就减小λ比如除以3如果误差不降反升就增大λ比如乘以10并放弃这次更新。这两个比例常数看起来玄学但试过几组之后你会发现3和10在大多数问题上确实比2和5好用。3. 实操环节数据准备、参数化与稀疏求解3.1 喂给BA的数据到底长什么样先别急着写代码调参数BA能不能收敛60%以上取决于输入数据组织得对不对。一个典型的BA问题包含三部分相机参数块、三维点参数块、残差块。以Ceres的BALBundle Adjustment in the Large数据格式为例文件开头是三个数字相机个数、三维点个数、观测个数。接下来是每个相机的参数通常是9维或7维再是每个三维点的XYZ坐标最后是一大堆观测——每一行写明相机编号、点编号、像素观测坐标或者归一化坐标。我自己第一次手写BA数据加载的时候犯过一个特别低级的错误观测里的相机编号和点编号没有对齐到参数块的索引。结果就是优化全程不收敛cost函数一路狂飙后来一行一行查才发现数据对错了位。所以第一课就是先花20分钟把数据可视化一遍把相机画出来、把点画出来确认初始状态没有离谱到“相机在火星、点在金星”的程度再开始优化。数据格式确认无误后还有一个容易被忽略的点观测权重的设置。不是所有匹配都一样可信BAL格式虽然不带权重但Ceres里每个ResidualBlock可以通过SetInformation给一个协方差矩阵。如果你的匹配有置信度信息务必用上——给高置信度的观测更大的权重能显著提升重建的稳定性和精度。3.2 姿态参数化旋转矩阵、四元数还是欧拉角这是BA里最容易翻车、也最容易被新手忽略的细节。三维旋转有无数种表示方式但适合做优化的只有少数几种。欧拉角虽然直观但存在万向锁问题而且三个角的微分量之间不是线性独立的——优化过程中极容易卡在奇异点附近。旋转矩阵有9个分量但只有3个自由度直接作为优化变量会引入额外的约束Ceres里一般用Manifold来约束。四元数有4个分量、3个自由度单位范数约束也好处理是视觉SLAM里的常客。还有一种是旋转向量/轴角3个分量没有多余约束在COLMAP这类SfM系统里很常用。Ceres里最标准的做法是相机位姿用6维参数块旋转3维平移3维旋转部分用罗德里格斯公式映射到旋转矩阵然后用QuaternionManifold或EigenQuaternionManifold处理旋转向量到四元数的转换。我不建议你直接用普通的加法更新旋转向量——因为旋转向量的“加法”不满足欧几里得空间的性质你得告诉求解器“5°加5°是10°但旋转向量的加法应该在场流形上处理”。实操上你只需要记住在Ceres里把旋转参数设置成AutoDiff QuaternionManifold或者在自定义Manifold里做指数映射更新已经能覆盖95%的场景。别自己去实现一个“旋转矩阵9参数直接优化”的版本我见过不少新手栽在这上面——结果要么是矩阵不再正交要么是优化奇慢无比。3.3 稀疏矩阵与Schur补BA快起来的关键理解了参数化之后真正让BA能跑起来规模的就是稀疏求解。回到雅可比矩阵的稀疏结构每个残差只牵扯一个相机参数块和一个点参数块。于是整个信息矩阵/近似海森矩阵是一个块状稀疏矩阵——对角线上是相机-相机、点-点块非对角线上只有相机-点相关的块。Schur补的核心思路是先把所有三维点的变量消掉只留下相机的变量。因为点变量的个数通常远大于相机个数消元之后要求解的线性系统规模大大缩小。具体做法是把信息矩阵按相机块和点块分块然后用块消元得到关于相机增量的方程。解完相机增量之后再回代求解点增量。这个过程在Ceres里就是LinearSolverType::SPARSE_SCHUR或者DENSE_SCHUR干的事。选择哪个线性求解器规模是硬指标。我给的参考值相机数少于100、点数少于10000时用DENSE_SCHUR省事相机数几百、点数几万用SPARSE_SCHUR相机数上千、点数几十万就得考虑ITERATIVE_SCHUR配合预处理。你在Ceres里只需要设置options.linear_solver_type它就会自动选择对应的实现。3.4 收敛判据与初值策略求解器迭代到什么时候停Ceres里有好几个判据常用的有函数值下降量小于某个阈值、参数更新量足够小、梯度足够小、或达到最大迭代次数。默认值在很多场景都不错但你调BA时最好自己监控一下cost的下降曲线。我习惯在每轮迭代里打印cost看它是平滑下降还是锯齿状震荡——锯齿状说明λ调整策略有问题cost卡住不动说明可能到了局部极小值或数值出问题了。初值策略上我的经验是永远不要直接跑一个“从零开始”的BA。你要么跑增量式SfM逐步三角化和PnP要么先用DWO/Distributed初始化近似解。直接把所有相机初始化为单位姿态、所有点初始化为原点然后指望BA自己找到真相我试过结果稳定收敛到一团浆糊。BA是“微调器”不是“全局寻优器”它喜欢的初值是“大体正确、细节偏差”这样优化几步就能收敛。4. 常见问题与排查技巧实录4.1 不收敛先查初值再查λ最后查Bug如果你遇到cost不降反升或者震荡不要第一时间去调阻尼因子先检查初值有没有出大问题。最常见的坑有三个相机初值朝向反了比如转了180度、三维点初值尺度差了数量级、某个观测匹配向外点。前两个会导致优化从一开始就走在错误的道路上第三个会让误差大的离谱把整体cost带偏。实际排查步骤我建议按这个顺序走第一步用最小化可视化工具把初始状态画出来看看相机和点是不是“看起来已经大致对齐”。第二步随机抽几条观测手动计算重投影误差确认误差量级在像素级别而不是几万像素。第三步如果你用的是Ceres把loss_function先设成nullptr用平方损失跑几步如果cost还是飞涨多半是数据或代码bug再往雅可比计算方向查。还有一个平时不会注意但关键时刻很管用的技巧先只优化相机、固定点或者反过来只优化点固定相机跑几步看cost能不能降。这种交替优化虽然最终精度不如联合优化但能帮你迅速定位是“相机参数的方向错了”还是“点的坐标方向错了”。4.2 阻尼因子与代价函数的调参心得LM的阻尼因子λ对收敛速度影响非常大但Ceres里已经帮你自动处理了大部分情况下你不太需要动它。真要我给经验那就是如果你的初始cost特别大比如大于1e8考虑对观测做归一化或者给每个残差乘一个权重让cost落到1e3左右的量级——这个量级的数值对LM的默认策略最友好。鲁棒核函数的选择我是强烈建议加的。我试过同一个数据集不加Huber和加了Huber重建质量差很多。哪怕你的特征匹配已经做过ratio test、几何验证总会有那么一小撮外点漏网。Huber核能在梯度上限制外点的最大影响让优化不被几个离谱观测带偏。loss_function我默认用Huber参数设成1.0到3.0之间的像素值。如果你是手动从0写BA在目标函数里改核函数牵扯到梯度计算工作量比较大但Ceres里就是SetLossFunction(new ceres::HuberLoss(1.0))这么一行的事。4.3 漂移问题闭环检测之后的全局BA在序列图像重建里BA不收敛只是一个话题另一个更让人头疼的问题是累计漂移。增量式重建一步步加相机误差像灰尘一样越积越厚。你从第一张图开始重建前几十张没什么感觉到两百张之后相机的轨迹已经明显偏离真实路径了。这个时候局部BA根本救不回来因为漂移是全局性的局部优化总会把新误差摊给附近的观测和位姿。解决的常规套路就是闭环检测加全局BA。检测到闭环意味着你知道第50帧和第350帧其实看到的是同一片区域——这就在整个轨迹上添加了一根强约束相当于告诉优化器“这两个相机其实应该挨得很近”。然后跑一次全局BA把几百上千帧相机位姿和所有地图点一起优化才能把这些年积累的漂移“拉”回来。实操里全局BA的代价不可小视我之前处理过一个8000帧的数据集全局BA一次要跑四十分钟。所以更稳的策略是分级优化先做位姿图优化Pose Graph Optimization只优化相机位姿不优化点云等位姿收敛了再固定位姿重新三角化点最后再跑一次全局BA。这样既能利用闭环约束又不会在最开始就掉进海森矩阵的泥潭里。4.4 我手里的BA问题还是慢怎么办如果你确认数据没问题、初值也合理但BA跑起来慢得离谱可以从这几个方向去排查。第一是检查线性求解器设置。很多人默认用DENSE_SCHUR跑几千个相机的问题那必然慢。换成SPARSE_SCHUR往往能提速几倍到十几倍。第二是检查雅可比的计算方式——Ceres里数值微分、自动微分和解析微分三者速度能差一个量级。能用AutoDiff的地方优先AutoDiff能手动推导解析雅可比的一旦写对速度最猛但调试成本也最高。第三是减少优化变量的数量比如相机内参如果标定过了就不放进BA里优化或者只给一个很小的权值只优化外参和点问题规模小一大截。还有一个容易被忽视的点把优化分成多个子问题并行跑。Distributed BA分布式BA在工业级重建里已经是标配了核心思想就是把相机分成几组每组内部先做局部BA再把边界信息和相邻组的优化结果交换迭代收敛到全局一致。这个方法工程量大但对付大规模场景几乎是必经之路。我自己的经验是在单机多核环境下用Ceres自带的ParallelFor配合多线程改造成本远低于自己实现真正的分布式求解效果也不错。5. 写给自己的调试备忘录最后把我的调试清单放在这算是给未来的自己留个备忘也给看到这里的朋友一点参考遇到不收敛先画图可视化初值再看cost曲线不急着调参数旋转参数永远用合适的Manifold别用欧拉角裸奔鲁棒核函数默认加上Huber用1.0起步初值质量决定一切BA是精修不是从零寻找线性求解器选择按规模来小规模稠密大规模稀疏如果要用几千个相机的全局BA先跑位姿图优化再跑完整BA每次改完数据或代码先用一个最小规模子集验证正确性我再强调一遍BA这个工具本身并不神秘它就是一个非线性最小二乘求解器在三维重建问题上的具体应用。真正决定一个重建系统好不好用的是你对数据的理解深度、对异常情况的容忍策略以及你调试时能不能保持冷静、一步步排查问题的习惯。这些经验不是看论文能看出来的都是我换了不知道多少组数据、跑了多少个通宵的迭代一遍遍试出来的。希望这篇笔记能在你调试的时候给你一个相对清晰的路线图。