
多视图几何里三角测量Triangulation是绕不开的一环。很多资料里也把它写成三角形法说的其实是同一件事已知同一个三维点在两个或多个视图里的投影位置加上各视图的相机位姿把这个点的三维坐标反解回来。它是特征点法视觉建图流程里最靠后、也最容易被轻视的一步——前端匹配、位姿估计做得好不好最终都要落到这个点到底在空间里哪个位置上。我自己在做的几个重建相关的小项目里三角化模块前后重写过四五版从最初的中点法一路换到 DLT 加重投影误差精化中间踩的坑比位姿估计那边加起来还多。这篇文章就把这套东西完整拆一遍三角化在解什么、几种主流解法各自的适用边界、怎么从零写一个能直接进工程的模块、以及那些只有真正跑过数据才会遇到的问题。不管你是刚接触多视图几何、还在啃对极约束的新手还是已经能把整个流程跑通、但发现点云总是发飘的同行下面这些内容应该都能直接用上。1. 三角测量到底在解一个什么问题1.1 从一个最朴素的场景说起假设你手上只有两台相机左右各拍一张某个特征点在左图落在像素 (u1, v1)在右图落在 (u2, v2)。两台相机的内参和相对位姿都已经标定好或者估计出来了那么从每个相机光心出发穿过对应像素的那条射线在空间里是唯一确定的。理论上这两条射线会交于一点这个交点就是我们要的三维坐标。整个过程听起来像是初中几何题两条直线求交点。但真实数据里没有理论上。像素坐标是带噪声的可能差半个像素也可能差两三个像素相机位姿是估计出来的平移和旋转各自带着自己的不确定度标定参数本身也不是绝对准确。这些误差叠加在一起结果就是两条射线在空间里往往是异面直线——它们不相交只是靠得很近。于是问题从求交点变成了在两条射线之间找一个最优的点而这个最优怎么定义就成了不同三角化方法的分水岭。我一开始没意识到这一点直接用了解析几何里求两条空间直线最近点的公式也就是常说的中点法代码三行就写完了跑起来也出数。直到把重建出来的点云和真值放在一起看才发现整片点云沿着深度方向被系统性地拉长了越远的点偏得越离谱。那次之后我才认真去翻三角化的几种标准解法。1.2 为什么两条视线未必相交把这件事说得更数学一点。设世界点齐次坐标为 $X (X, Y, Z, 1)^T$第 $i$ 台相机的投影矩阵为 $P_i K_i [R_i \mid t_i]$那么投影关系是 $x_i \sim P_i X$这里的 $\sim$ 表示齐次坐标下差一个非零尺度因子。由于尺度可以任意等式两边叉乘 $x_i$ 就消掉了尺度$$x_i \times (P_i X) 0$$这个叉乘约束看上去是三个方程但因为 $x_i$ 和 $P_i X$ 平行三个方程里只有两个线性无关。所以每台相机贡献两个独立约束。两台相机四个约束、三个未知数超定一格这多出来的一格正好就是两条射线不相交在代数上的体现。约束超定意味着一般没有精确解只能求最小二乘意义下的解——不同的方法本质上就是对残差怎么量给出了不同答案。DLT 量的是代数误差中点量的是两条射线之间的空间距离重投影误差量的是像素平面上的偏差。这三种残差在噪声小的时候结果差不多噪声一大、视差一小差距就非常明显了。理解这一点比记住任何一个公式都重要。1.3 三角化的输出到底该长什么样一个能在工程里用的三角化接口输出不应该只是一个(x, y, z)。我后来把返回值改成了包含这些字段的结构三维点坐标世界系下两台或多台相机下的深度值最大重投影误差视线夹角视差角是否通过合法性校验的布尔标记理由很实际下游的建图模块需要知道这个点可不可信。一个重投影误差 0.3 像素、视差角 8 度的点和一个重投影误差 3 像素、视差角 0.4 度的点哪怕算出来的坐标看着都合理可靠性差着两个数量级。只返回坐标等于把判断责任推给了下游而下游往往没能力判断。这算是被坑出来的设计早期版本只返回坐标结果一批几乎贴着相机光轴、视差极小的点被当成正常点塞进了地图整个地图的尺度估计都被带偏了。2. 四条技术路线以及我为什么最后选了DLT 加重投影精化2.1 中点法直觉最舒服几何上最不靠谱中点法的思路很符合直觉两条射线既然不相交那就取它们公垂线段的中点。具体做法是把每条视线写成参数形式 $C_i \lambda_i d_i$然后最小化两点间距离的平方对 $\lambda_1, \lambda_2$ 求偏导解一个 2×2 线性方程组最后取两个最近点的平均。这个方法的好处是快、好懂、不需要 SVD而且它最小化的量有明确的几何意义——空间距离。坏处也恰恰在这里它最小化的是空间中的距离而我们的观测误差是在像素平面上产生的。这两者不是一回事。一个远处的点在像素上偏 1 个像素对应的空间偏移可能有几十厘米一个近处的点同样偏 1 个像素空间偏移可能只有几毫米。中点法对近距离点赋予了过大的权重对远距离点几乎不管误差分布完全失衡。更麻烦的是它不满足射影不变性。你把整个世界坐标系做一个射影变换中点法的结果会变而三角化这个问题本身在射影变换下应该是良定义的在已知标定的度量重建场景下更应如此。这意味着中点法的误差会随着场景结构、相机配置而变化很难用一个统一的阈值去卡。我实测下来在两台相机夹角较大、场景中近距离为主的情况下中点法和精化后的结果差得不多大概在 1% 到 3% 的深度相对误差一旦场景纵深拉大远端点的相对误差能到 10% 以上。注意中点法不是错的在某些只关心近处结构、对精度要求不高的快速验证场景里它完全够用。但如果你的下游要做尺度估计、要做稠密重建、要做点的可信度筛选那就别用它。2.2 线性三角化DLT一次 SVD 换来全局闭式解直接线性变换DLT的思路是把叉乘约束展开成线性方程组。对每台相机取叉乘结果里线性无关的两行得到$$u_i (P_{i3}^T X) - (P_{i1}^T X) 0$$ $$v_i (P_{i3}^T X) - (P_{i2}^T X) 0$$其中 $P_{ij}^T$ 表示 $P_i$ 的第 $j$ 行转置成的列向量。两台相机堆起来就是一个 4×4 的矩阵 $A$满足 $A X 0$。这是一个齐次线性方程组非零解存在当且仅当 $A$ 奇异有噪声时 $A$ 满秩我们就取使 $|AX|$ 在 $|X|1$ 约束下最小的解——这正是 $A$ 的最小奇异值对应的右奇异向量一次 SVD 直接拿到。这套流程的优点非常突出闭式解、无需迭代、不会陷入局部极小、代码十几行。缺点是它最小化的是代数误差而代数误差里混着齐次坐标的尺度因子各点的权重并不等价。反映到结果上就是 DLT 的解在噪声较小时和真正的最优解很接近噪声一大就会有可见偏差尤其在点接近相机光轴方向时更明显。即便如此DLT 依然是工程里最常用的初值提供者。它给出的解已经落在真值附近拿它做非线性优化的起点几乎不会跑到错误的局部极小去。这就是我最终选DLT 重投影精化这套组合的核心原因用闭式解解决从哪开始用迭代优化解决到哪结束。2.3 最小化重投影误差把测量噪声假设摆到台面上重投影误差最小化做的事情很直白找一个三维点 $X$使得把它重新投影到各视图后与观测像素的距离平方和最小$$\min_X \sum_i \left| x_i - \pi\left(K_i [R_i \mid t_i] X\right) \right|^2$$这里的 $\pi$ 是透视除法齐次坐标归一化。这个目标函数的物理意义非常清楚它假设像素观测的噪声是各向同性的高斯分布那么最小化重投影误差就等价于最大似然估计。这个假设在大多数特征点检测器上是基本成立的所以这套做法在理论上站得住。代价是它没有闭式解得迭代。好在目标函数的形式不算复杂雅可比可以解析写出来用 Gauss-Newton 或者带阻尼的 Levenberg-Marquardt 通常五六次迭代就收敛单点耗时在微秒量级几万个点也就几十毫秒完全能接受。雅可比怎么推设第 $i$ 台相机下点的坐标为 $X_{ci} M_i \tilde{X}$其中 $M_i [R_i \mid t_i]$ 是 3×4 矩阵$\tilde{X} (X, 1)$。投影后的像素为 $u X_{ci,x} / X_{ci,z}$$v X_{ci,y} / X_{ci,z}$此时 $M_i$ 已含内参或者把内参乘在外面。对 $X$ 求导用链式法则$$\frac{\partial (u,v)}{\partial X_{ci}} \begin{bmatrix} \frac{1}{Z} 0 -\frac{X}{Z^2} \ 0 \frac{1}{Z} -\frac{Y}{Z^2} \end{bmatrix}, \quad \frac{\partial X_{ci}}{\partial X} M_{i}[:,, :3]$$两块一乘就是 2×3 的雅可比块各视图堆起来得到 2N×3 的 $J$然后标准 Gauss-Newton 更新 $\delta -(J^T J)^{-1} J^T r$。加个阻尼项 $\lambda I$ 就是 LM。2.4 选型对照表把这四种常见做法放在一起对比结论会更清楚。下面的数据来自我用合成数据跑的一组对比两台相机基线 0.2 米、焦距 500 像素、图像分辨率 640×480在 0.5 米到 8 米深度范围内撒点像素噪声标准差 1 像素每组统计 2000 个点。方法深度相对误差近端 1m深度相对误差远端 8m单点耗时是否需要初值主要短板中点法约 1.2%约 12%最快否误差分配失衡不具射影不变性DLT未精化约 0.9%约 7%快一次 4×4 SVD否代数误差与像素误差不等价仅重投影优化随机初值不稳定不稳定慢是可能收敛到错误解DLT 重投影精化约 0.6%约 4.5%中等是用 DLT 提供实现复杂度稍高这组数字是我本地一次实验的量级换场景、换噪声水平会变但趋势是稳定的精化后的解在远端优势最明显。提示如果你的管线里已经有 BA光束法平差三角化其实只是给它提供初值那么单独做精化的收益没那么大——BA 会把这些点一起优化掉。但如果三角化结果要直接用于地图点筛选、深度滤波、遮挡判断那这一步精化的性价比就非常高。3. 手写一个能上生产的三角化模块3.1 输入统一与坐标归一化在动笔写公式之前先把输入格式统一这一步能省掉后面大量的调试时间。我的约定是投影矩阵统一用 3×4 的 $P K [R \mid t]$世界系到像素系一次矩阵乘直接给出齐次像素坐标。观测点统一用归一化前的像素坐标不是归一化平面坐标这样重投影误差的单位就是像素阈值好定。三维点统一用世界系欧氏坐标 末尾补 1 的齐次形式中间计算允许用齐次输出时强制归一化。关于坐标归一化有一个实用技巧值得说在使用 DLT 之前先把图像坐标平移到主点、除以平均焦距让像素坐标的量级落在 1 附近再构造 $A$ 矩阵。原因是 SVD 对矩阵元素的量级很敏感如果一行是 $u \cdot P_3$量级几百另一行是 $P_1$量级个位数数值条件会很差最小奇异向量的精度下降。这一步做完我实测在极端配置点接近光轴、视差很小下解的稳定性有明显改善。3.2 DLT 的核心几行到底在做什么DLT 的实现核心就是构造 $A$ 然后取 SVD。但有两个细节很容易写错我在这上面各浪费过半天。第一个是行索引。$P$ 的第 0、1、2 行分别对应 $x$、$y$、$z$ 分量约束方程是 $u \cdot P[2] - P[0]$ 和 $v \cdot P[2] - P[1]$很容易把 $u$ 和 $v$ 对应的行搞反搞反之后结果仍然看起来像个数但完全错。定位方法是拿一个已知真值的合成点去测如果重投影误差巨大就是这里错了。第二个是齐次结果的归一化。SVD 给出的右奇异向量是单位长度的其第 4 个分量对应齐次坐标的 $w$可能接近 0也可能是负的。直接除以 $w$ 会在 $w \approx 0$ 时爆炸。稳妥的做法是先按向量范数归一化再根据 $w$ 的符号决定整体是否取反——因为 $X$ 和 $-X$ 表示同一个点取反不改变几何意义但会影响后面深度符号的判断。3.3 深度符号与合法性校验这一步是纯工程经验公式推导里不会讲。DLT 有可能给出一个几何上成立但物理上不可能的解点落在某台相机的背后。判断方法是对每台相机算 $X_{ci} P_i \tilde{X}$然后检查 $X_{ci}$ 的第 3 个分量与第 4 个分量是否同号齐次坐标下 $z/w 0$ 才表示在相机前方。我用一个组合条件做校验所有相机下深度为正最大重投影误差小于阈值我一般用 4 像素噪声大的数据集放宽到 8视线夹角大于阈值见第 4 节。三个条件全过才认为这个点是可靠的。这个过滤器看起来粗糙但在我处理过的几批数据上它挡掉的绝大部分确实是坏点尤其是那些因为误匹配产生的、几何上完全说不通的点。3.4 用 Gauss-Newton 把解磨到最优精化阶段的初值来自 DLT所以不用担心收敛域问题。我的实现里加了两个小改进效果比较明显。一个是对每次迭代计算重投影误差的下降量如果连续两次下降小于 1e-8 就提前退出避免无意义的迭代。另一个是步长控制如果某次更新后误差反而变大了在非线性强的地方有可能就把步长减半重试最多重试三次。这两个小改动让整个模块在脏数据上的鲁棒性提升不少尤其是在视差很小、解本身很平的那些点上。还有一点迭代变量用欧氏三维坐标而不是齐次坐标。齐次坐标多一个自由度直接优化会引入额外的尺度自由度容易数值不稳。用欧氏坐标三个未知数干净。3.5 完整代码下面这份是我目前用得最顺手的一版Python NumPy去掉了所有业务耦合可以直接拷走当工具函数。import numpy as np def triangulate_dlt(Ps, xs): 线性三角化。Ps: 投影矩阵列表 [(3,4)]; xs: 像素坐标列表 [(2,)] A [] for P, x in zip(Ps, xs): u, v x[0], x[1] A.append(u * P[2] - P[0]) A.append(v * P[2] - P[1]) A np.asarray(A, dtypenp.float64) # 行归一化改善数值条件 norms np.linalg.norm(A, axis1, keepdimsTrue) norms[norms 1e-12] 1.0 A A / norms _, _, Vt np.linalg.svd(A) Xh Vt[-1] Xh Xh / np.linalg.norm(Xh) # 先按范数归一 if Xh[3] 0: # 统一符号让 w 为正 Xh -Xh return Xh[:3] / Xh[3], Xh def check_cheirality(Ps, Xh): 检查点是否在所有相机前方 for P in Ps: Xc P Xh if Xc[2] * Xc[3] 0: return False return True def reprojection_errors(Ps, xs, X): Xh np.append(X, 1.0) errs [] for P, x in zip(Ps, xs): xc P Xh proj xc[:2] / xc[2] errs.append(np.linalg.norm(proj - np.asarray(x))) return np.asarray(errs) def refine_gn(X0, Ps, xs, iters20, tol1e-8, damping1e-6): Gauss-Newton带轻阻尼精化最小化重投影误差 X np.asarray(X0, dtypenp.float64).copy() prev_cost None for _ in range(iters): H np.zeros((3, 3)) g np.zeros(3) cost 0.0 for P, x in zip(Ps, xs): Xh np.append(X, 1.0) xc P Xh Z xc[2] if abs(Z) 1e-12: return X, np.inf proj xc[:2] / Z r proj - np.asarray(x) # 2 维残差 cost r r # d(proj)/d(Xc) 为 2x3 dpdXc np.array([[1.0 / Z, 0.0, -xc[0] / (Z * Z)], [0.0, 1.0 / Z, -xc[1] / (Z * Z)]]) J dpdXc P[:, :3] # 2x3 H J.T J g J.T r # LM 阻尼 H_d H damping * np.eye(3) try: delta np.linalg.solve(H_d, -g) except np.linalg.LinAlgError: break # 带回溯的步长控制 step 1.0 for _ in range(3): X_new X step * delta cost_new np.sum(reprojection_errors(Ps, xs, X_new) ** 2) if cost_new cost: break step * 0.5 X X step * delta if prev_cost is not None and abs(prev_cost - cost) tol: break prev_cost cost return X, prev_cost def world_to_camera_centers(Ps): 由投影矩阵反解相机光心C -M^{-1} p4 centers [] for P in Ps: M P[:, :3] p4 P[:, 3] C -np.linalg.solve(M, p4) centers.append(C) return np.asarray(centers) def parallax_angle(Ps, X): 计算各视线两两之间的最大夹角度 Cs world_to_camera_centers(Ps) dirs [] for C in Cs: d X - C n np.linalg.norm(d) dirs.append(d / max(n, 1e-12)) dirs np.asarray(dirs) max_ang 0.0 for i in range(len(dirs)): for j in range(i 1, len(dirs)): c np.clip(dirs[i] dirs[j], -1.0, 1.0) max_ang max(max_ang, np.degrees(np.arccos(c))) return max_ang def triangulate(Ps, xs, max_reproj4.0, min_angle1.0): 对外主入口 X, Xh triangulate_dlt(Ps, xs) if not check_cheirality(Ps, Xh): return None X, _ refine_gn(X, Ps, xs) errs reprojection_errors(Ps, xs, X) ang parallax_angle(Ps, X) if errs.max() max_reproj or ang min_angle: return None return {X: X, reproj_max: errs.max(), angle_deg: ang}调用很简单P1 K1 np.hstack([R1, t1]) P2 K2 np.hstack([R2, t2]) out triangulate([P1, P2], [(u1, v1), (u2, v2)]) if out is None: pass # 这个点扔掉 else: X out[X]注意world_to_camera_centers里用了np.linalg.solve要求 $M K R$ 可逆。正常情况下内参和旋转都满秩没问题但如果你的代码里把投影矩阵做了奇怪的缩放比如整体乘了个负数会解出错误的光心角度判断就全废了。加个np.linalg.cond检查会更保险。4. 踩过的坑三角化的问题排查实录4.1 算出来的点跑到相机背后这是最常见的症状。表现是明明两个视图都匹配上了重投影误差也不大但算出来的点深度是负的或者点在两台相机中间而不是前方。原因通常有三类。第一类是误匹配两个像素点根本不对应同一个三维点那么最优解落在一个毫无物理意义的位置上很正常。第二类是投影矩阵的行列式符号搞反了比如在构造 $P$ 的时候把 $[R \mid t]$ 和 $[R \mid -R^T t]$ 弄混了或者矩阵乘的顺序错了$K R$ 写成 $R K$。这类错误的特点是所有点都跑到背后很好定位。第三类是齐次坐标的符号没处理好交比计算里 $w$ 为负时直接除把符号带进了三维坐标。我的定位流程是先拿一个真值已知的合成点跑一遍如果合成点也错是代码 bug合成点对但真实数据错就是匹配质量问题。这个二分的排查方式帮我省了很多时间。4.2 远点的深度炸了深度估计的相对误差和深度的平方成正比这是个硬约束不是实现问题。从误差传播的角度看视差角为 $\theta$、基线为 $b$ 时深度的不确定度大致是 $\sigma_Z \approx \frac{Z^2}{f \cdot b} \sigma_{px}$。也就是说深度翻倍误差涨四倍。这解释了一个我早期很困惑的现象远处的地面点、天空边缘的点深度值跳动得像随机数但它们的重投影误差看起来完全正常。原因是这些点本身就处在解很平的区域目标函数在深度方向上的曲率极小一点点噪声就能把解推出去很远。重投影误差小不代表解唯一。对策很简单对远点放宽容错或者干脆按深度分层对超过一定深度的点提高视差角阈值。我给远点用的角度阈值是近点的 2 到 3 倍效果立竿见影——点云不再有那些拖得老长的尾巴了。4.3 小视差场景的连锁反应当两台相机的基线相对于场景深度很小时比如车载场景里对着几百米外的建筑或者无人机高空俯拍视差角可能只有零点几度。这时候三角化的解对像素噪声极度敏感前面提到的 DLT 精化也救不回来——因为信息量本身就不够不是算法的问题。更麻烦的是这会引发连锁反应这些不可靠的点进入地图之后会参与后续的位姿优化把位姿也带偏位姿偏了之后三角化更不准一个正反馈就这么形成了。我在一个高楼场景的数据上吃过这个亏整条轨迹在纵深方向缓慢漂移查了好久才发现根子在一个没做视差过滤的三角化函数上。处理办法只有两条路要么卡阈值把这类点全部拒掉保守但安全要么给它们一个非常大的不确定度让后端优化时自动降权需要整个系统支持信息矩阵。我倾向后者但前提是后端确实用得上这个信息否则就是自欺欺人。4.4 常见问题速查表现象最可能的原因排查动作处理方式所有点深度为负投影矩阵构造错误用已知真值点验证 P检查 $K[R\mid t]$ 的乘序与符号部分点深度为负误匹配看该点的重投影误差与描述子距离直接剔除远端深度剧烈跳动视差过小解不唯一打印视差角提高角度阈值或降权点云整体沿深度拉长用了中点法/未精化对比 DLT 与精化结果加一步重投影精化重投影误差大但坐标合理像素坐标与投影矩阵不匹配畸变未校正检查去畸变流程统一在去畸变后的图上操作精化后误差反而变大初值太差或迭代发散打印每次迭代的 cost加步长回溯或改用 LM 阻尼多视图三角化结果与两视图差异大某视图位姿误差大逐对计算重投影误差剔除异常视图后重算5. 工程层面的几个取舍5.1 三角化角度阈值不是万能药很多人包括早期的我把视差角阈值当成银弹设个 1 度就以为万事大吉。实际上这个阈值和焦距、分辨率、噪声水平都相关不能跨数据集照搬。一个粗略的估法假设像素噪声是 $\sigma_{px}$、焦距 $f$那么视线方向的角度噪声约等于 $\sigma_{px} / f$弧度。要让相对深度误差控制在 $\epsilon$ 以内需要的视差角大致满足 $\theta \gtrsim \frac{\sigma_{px}}{f \cdot \epsilon}$。举个具体数$\sigma_{px} 1$ 像素、$f 500$ 像素、要求 $\epsilon 2%$算出 $\theta \gtrsim 0.1$ 弧度也就是大约 5.7 度——比我最初拍的 1 度严格得多。当然这是保守估计实际用 2 到 3 度也能接受但 1 度确实太松了。这个公式还解释了一件事广角镜头焦距短的相机同样的像素噪声下需要的视差角更大也就是更难三角化。这跟直觉有点反但数学上没毛病——焦距短意味着同样的角度变化对应更少的像素位移信息量本来就少。5.2 和 BA 的耦合谁先谁后在完整的建图流程里三角化和 BA 是交替进行的先三角化新点再跑局部 BABA 更新位姿后又会影响点的位置。这里有三个经验性的做法。一是三角化用精化但不必追求极致。因为 BA 会重新优化这些点单独把三角化做到极致是浪费。我的做法是 GN 迭代上限设 10 次误差下降小于 1e-6 就停够用了。二是新点的筛选要在 BA 之前做。把明显错误的点深度为负、重投影误差超阈值、视差过小尽早剔除能让 BA 的求解规模小一大截而且避免坏点把位姿带偏。这一点在点云规模上去之后体会特别深。三是BA 之后回头重新三角化一遍。位姿优化之后相机参数更准了用新位姿重新三角化同一批观测往往能救回一些之前因为位姿不准而被拒掉的点。我一般会在局部 BA 之后跑一轮补点能提升 5% 到 10% 的点云密度代价很小。5.3 退化与纯旋转的识别纯旋转是个经典退化场景相机只转不平移所有视线平行三角化完全失效。这时候 DLT 会给出一个数值上看起来正常但完全无意义的解——这是最危险的情况因为没有任何报错。识别方法有两个。一是检查本质矩阵/基础矩阵求解时的平移分量如果平移接近零就直接跳过三角化。二是检查三角化结果里的视差角纯旋转时算出来的视差角会异常小因为解会往无穷远跑角度阈值天然能挡住一部分。还有一个不那么明显的退化场景点全部共面且相机运动方向与平面平行。这种情况下三角化的条件数会变差但不会完全失效只是精度下降。我的处理是不要在这种场景下调低角度阈值去抢救点云宁可少点也不要错点。5.4 精度评估怎么知道自己算得对没有真值的时候怎么验证三角化写对了我常用三个自检手段。第一个是重投影闭合检查把算出来的点投影回去看是不是落在原像素上。这一步能抓到 90% 的代码 bug。第二个是多视图一致性用三台以上相机分别两两三角化比较结果的差异。如果两两之间差得很多说明某个视图的位姿有问题或者匹配有问题。这个方法在调试多相机系统时特别有用。第三个是合成数据回归测试构造一组已知真值的点和相机加固定随机种子的噪声跑整个流程比较误差是否在预期范围内。这个测试我现在还留着每次改三角化代码都跑一遍防止改一处坏一处。提示合成数据测试里噪声的施加位置很重要。要在像素平面上加噪声模拟检测误差而不是在三维点上加。在三维点上加噪声会低估问题的难度因为那样相当于所有视图共享同一个扰动反而变简单了。还有个小技巧验证时把重投影误差的分布打出来看而不是只看均值。正常情况下它应该接近 Rayleigh 分布二维高斯模长的分布如果出现明显的双峰或者长尾说明数据里混着两类不同性质的点需要分开处理。我在实际项目里最后形成的习惯是三角化模块永远返回坐标 质量指标绝不只是坐标质量指标一定参与下游的点筛选而不是打印出来看看就完事。这么做一开始觉得啰嗦等到点云规模上万、开始出现那种看着都对但整体就是不对的问题时就会发现能按质量分层排查是多么省命的一件事。另外如果你的管线里同时存在多个三角化入口比如初始化阶段一个、跟踪阶段一个尽早把它们合并成同一个函数我吃过两次因为两份实现阈值不一致导致行为诡异的亏。