97 个 OpenCV 实例(三十):双目立体,从标定到点云

上一篇在三目标定里踩了个大坑------我当时判定「官方示例把重投影误差二次归一化了」。这篇文章开头第一件事,就是把这个判定撤回来。然后我们进入双目:两台相机怎么标定、怎么校正、怎么从视差算出深度。

**一、没有错误

上一篇我写了一段 官方 3calibration.cpp 打印的重投影误差被额外除了一次根号 N,真实值应该是 0.322 像素而不是 0.031 像素,差了 10.2 倍。我当时觉得这是整篇文章最有价值的东西。

先把当时的推理链摆出来,因为它错得很典型:

第一,我读到 OpenCV 源码 calibration.cpp 第 1912 行是 return std::sqrt(reprojErr/total);,这一步没读错。

第二,我由此认定 calibrateCamera返回值已经是像素级 RMS,这一步也没错。

第三,我把官方示例里那个 err 变量,当成了第一步看到的那个返回值。

真相是,官方示例里的 err 是它自己另外累加出来的一个量,跟函数返回值没有任何关系:

cpp 复制代码
// 3calibration.cpp 官方原版
double err = 0;
for( int j = 0; j < N; j++ )
{
    double e = norm(imagePoints[i][j] - _imagePoints[i][j]);
    err += e * e;
}
printf("Camera %d calibration reprojection error = %g\n", c, sqrt(err/N));

这是标准的 sqrt(Σe²/N),就是像素级 RMS,写法完全正确。我却以为 err 已经是被归一化过的返回值,于是幻想出「官方又除了一次根号 N」。那个根号 N 从来不存在,只在我的想象里。

那 10.2 倍的差到底从哪来?是分母不一样。 函数返回值按 pointsTotal(全部视角的全部点)归一化,而官方示例的 N视角数 ,这里等于 6,不是点数。根号下 324 除以 6 约等于 7.35,再叠加两者点集口径的细微差异,就凑出了这 10 倍。是两个不同的量,不是官方算错了。

这里有个我当时就该做的动作:看到两个同名量的数值对不上,第一件事是确认它们是不是同一个量------把各自的类型、维度、来源行号打出来,再谈谁对谁错。我跳过了这一步,直接假设官方错了,然后拿着错误假设往回找证据,一路顺理成章地"证明"了自己的猜想。

这件事的最后一条教训,我把它立成了规矩:报告任何误差数值,必须同时报告它的定义。计算式、坐标系、出处行号,三要素缺一不可。只有数值没有定义,等于没报,而且会误导后面所有基于它的判断。

上一篇的 wiki、README、文章都已经改过了。做技术内容,错了就要改,而且要把改的过程写清楚------这比一开始就对更有价值,因为它展示了怎么从错误里走出来。

二、双目:从测距需求倒推的整条链

单目相机只能测角度,测不了距离。双目能测距离,原理是三角测量:两个相机分别看同一个点,这个点在两张图上的横向位置差(视差)反比于它的距离。

Z = f · B / d

Z 是深度,f 是焦距,B 是两个相机的基线距离,d 是视差。视差越大,物体越近。

但要这个公式成立,得先满足一个前提:两张图必须已经"校正"过了 ------同一个物点在左右图里要落在同一水平线上。否则你在左图某个像素去找右图的对应点,得在二维平面里搜,代价高得离谱;校正之后只需要在同一行里水平找,复杂度从平方降到线性。

所以整条链是这样:

  • stereo_calib 标定:解出两个相机的内参、畸变,以及它们的相对位姿
  • stereoRectify 校正:算出该做怎样的旋转和投影,把两图拉到同一水平线
  • stereo_match 匹配:在水平线上找视差,得到视差图
  • reprojectImageTo3D 反投影:视差转成三维点云

这一篇走完前两步加第三步的多种实现,最后落到点云。

三、效果先行

先看校正到底做了什么事。下面这张四联图,上排是原始的左右图,下排是校正后的,绿线是每隔 16 像素画的水平参考线:

看下排:同一个棋盘格角点,在左右两张图里的高度完全一致,都压在同一条绿线上。上排就不一样了,同一个点在左右图里高度是错开的。

这不是看着像,是量出来的。我统计了棋盘格上全部 54 个角点在左右图的 y 坐标差:

状态 y 坐标差(均值) 最大
原始像素(未校正) 12.3014 px 16.3937 px
校正后 0.2141 px 0.9445 px
改善 98.3% 94.2%

均值从 12.3 像素降到 0.21 像素,改善 98.3%。 这就是校正的全部意义。

放大看校正后的左右两图,绿线是水平参考线,同一个角点在两图里压在同一条线上:

然后看视差图。用 SGBM 的 three-way 模式跑出来:

暖色(红橙)近、冷色(蓝绿)远,画面里的结构层次一目了然。这个视差图可以直接反投影成三维点云,本次跑出了 4.2 MB 的点云文本文件。

官方自带的灰度视差图长这样,同样的数据、同样的算法:

四、官方示例的坑:13 对图,只报成功的

跑官方 stereo_calib.cpp 的输出是这样:

复制代码
..........................13 pairs have been successfully detected.
Running stereo calibration ...
done with RMS error=1.75069
average epipolar err = 0.559362

注意第一行:13 pairs have been successfully detected.只报成功了多少对,不报有没有失败

翻源码,检测循环里是这样的:

cpp 复制代码
if( !found ) break;
// ...
if( !found ) continue;

breakcontinue 后面没有任何日志 。也就是说,如果有三对图检测失败,程序会静默跳过它们,用剩下的十对图继续标定,最后照样打印一个漂亮的 RMS。你不会知道少了数据。

这跟上一篇三目标定里发现的问题一模一样------官方示例在检测失败时不报警。生产代码里这是不能接受的,必须统计成功率并报警。

所以我另写了一个验证程序,第一件事就是把成功率统计出来:

复制代码
成功 = 13 / 13 对,失败 = 0
pair 0: cam1=54 点  cam2=54 点
...

13 对全部检出,每对恒 54 个点(9×6 内角点)。这个统计官方不给你,得自己算。

五、那个 1.75069 到底是什么

这是我在这一课花时间最多的问题。

官方打印的 RMS error=1.75069,我要搞清楚它是怎么算出来的、可不可信。前前后后走了五次弯路,每一次都是我的问题,没有一次是官方的

第一次弯路:我用 Python 复现,得到 RMS 是 0.634,跟官方对不上。正准备写「官方参数有问题」,忽然想到应该先看看版本:

复制代码
python3 -c "import cv2; print(cv2.__version__)"
4.6.0

本机 apt 装的 cv2 是 4.6.0,而我编译的 C++ 版是 4.14.0。 跨版本对比得出的差异就是伪发现------两个版本的 LM 优化实现本来就不同。跨版本对照得出的「差异」,不能当结论。

第二次弯路 :改用 C++ 复现,我把相机1的位姿钉死在单位位姿去投影,得到一个夸张的数字:1889 像素。原因是我以为 stereoCalibrate 只用第一个视角的位姿,实际上它给每一个视角都单独解了一个相机1位姿

第三次弯路 :我在代码里写了 Rodrigues(R, Rv),然后整个程序崩溃,报错是:

复制代码
matmul.dispatch.cpp:363 (-215) a_size.width == len in function 'gemm'

这个报错完全指不到 Rodrigues,我一开始以为是矩阵乘法写错了。用 gdb 定位到行号才发现问题:Rodrigues 是双向函数 ------3×1 的旋转向量输入,得到 3×3 矩阵;而 3×3 矩阵输入,得到的是 3×1 向量

我从文件里读出来的 R 本来就是 3×3 旋转矩阵,再过一次 Rodrigues 就变成了 3×1 向量,后面所有矩阵乘法全乱。口诀:向量和矩阵互转,方向由输入维度定;已经是矩阵了,就别再过 Rodrigues。

第四次弯路 :我用 rvecs[2*i] 去索引逐视角位姿,得到 131.6 像素的垃圾值。查出来 rvecs 的长度是 13 (图像对数),不是 26。而且它只存相机1的位姿 ------相机2的位姿得自己用 composeRT 合成。

第五次,用正确的语义重算:

stereoCalibrate 返回值 1.75069
我独立复现 sqrt(Σe²/点数) 1.74136
相对差 0.53%

关键点:官方打印的这个数字,就是 stereoCalibrate 的返回值本身,而返回值是像素级的 RMS。 我用官方自己返回的位姿做链式投影复现,得到 1.74136,与官方的 1.75069 只差 0.53%(这 0.53% 来自内部畸变求导的细节差异)。

结论:官方没有 bug,1.75069 是真实可信的像素级重投影误差。 我前面所有的怀疑,全都是我自己的理解问题和写法问题。

六、Q 矩阵到底等不等于手算

标定完会得到一个 Q 矩阵,它是视差转深度的核心。常见的疑问是:用 Q 反投影算出来的深度,跟直接用 Z = f·B/d 手算,一样吗?

我把两个都算了,对比:

视差 d (px) Q 矩阵算深度 手算 f·B/d 相对差
1.0 1938.2869 1938.2869 0.0000%
4.0 484.5717 484.5717 0.0000%
16.0 121.1429 121.1429 0.0000%
32.0 60.5715 60.5715 0.0000%
64.0 30.2857 30.2857 0.0000%

五档视差,相对差全是 0.0000%。 两者完全等价,这不是近似,是同一个公式的两种写法。

其中 Q(2,3) 就是焦距 565.461,1/Q(3,2) 就是基线 4.0194------和标定解出的 |T| 完全一致。这条对账做完,"到底该用 Q 还是手算"这个疑问就有确定答案了:随便,它们一模一样。

七、九种匹配算法的横评

官方 stereo_match.cpp 支持 BM 和四种 SGBM 变体,但每次只跑一种,也不打印任何质量指标。我写了个横评程序,用同一对校正图把九种配置全跑一遍:

算法 blockSize 耗时 (ms) 有效占比 (%) 中位视差 深度@中位
bm 9 24.5 9.20 4.23 537.3
sgbm 3 103.5 43.79 70.38 32.30
sgbm 9 131.8 47.97 73.19 31.06
hh 3 253.1 40.74 71.94 31.59
hh 9 228.3 43.75 74.69 30.43
hh4 3 96.4 42.19 67.88 33.49
hh4 9 91.7 46.94 73.12 31.08
sgbm3way 3 25.2 45.76 67.12 33.86
sgbm3way 9 23.5 49.08 72.62 31.30

「有效占比」= 视差落在 (0, numDisparities) 范围内的像素比例,反映匹配成功的比例。

三条结论:

第一,BM 的表现差得惊人。 有效视差只有 9.2%,而其他算法都在 40% 到 49% 之间。它的中位视差是 4.23,跟别家的 70 多完全不是一个量级------说明它在这个场景里大面积匹配失败。BM 是纯局部块匹配,只在纹理丰富、视差范围小的场景才好用。

下面这张是 BM 的输出,跟上面 sgbm3way 那张对比着看,差距一眼就出来:

第二,sgbm3way 是性价比之王。 23.5 毫秒,拿下最快,同时有效占比 49.08% 也是最高。它比 hh 模式快了将近 10 倍,质量还略优。要用就直接用它。

第三,blockSize 不是越大越好。 SGBM 从 3 加到 9,有效占比升了 4 个点,但耗时涨了 27%。小的 blockSize 保留更多边缘细节,大的在弱纹理区更稳------按场景取舍,别盲目调大。

八、踩坑记录

坑一:undistortPoints 传不传 R/P,输出坐标系完全不同。

cpp 复制代码
undistortPoints(src, dst, K, D);        // → 归一化像平面 (x,y ~ 0.几)
undistortPoints(src, dst, K, D, R, P);  // → P 坐标系下的像素 (x,y ~ 几百)

我在算校正效果的时候,用四参版得到归一化坐标、用六参版得到像素坐标,然后直接把两个 y 差相减------得出结论「校正让行对齐误差变大了 877%」。

荒谬。 一个量在归一化域(数值 0.0 几),一个在像素域(数值几百),根本不是同一个尺度,怎么可以相减。改成同尺度比较后,正确结论是「改善 98.3%」。

这个坑和 RMS 那次的病根是同一个:比较两个量之前,先确认它们是不是同一个量、是不是同一个尺度。

坑二:stereoCalibratervecs/tvecs 长度是图像对数,而且只存相机1。

13 对图,rvecs 长度是 13,不是 26。误用 rvecs[2*i] 索引直接得到 131.6 像素的垃圾值。

cpp 复制代码
// 相机1 第 i 视角: (rvecs[i], tvecs[i])
// 相机2 第 i 视角: composeRT(rvecs[i], tvecs[i], R, T)
//                   即 R2 = R * R1,  t2 = R * t1 + T

坑三:官方 xml 列表第 0 条不是文件名。

stereo_calib.xml 读出来有 27 个条目,第 0 条是标量 1.0。官方程序能跑通是因为它内部跳过了。我自己生成列表时没过它,导致条目数是奇数、触发「列表长度必须是偶数」的报错退出。自己生成列表必须过滤非图片条目。

坑四:官方产出的 yml 落在当前目录,不是 build 目录。

FileStorage 用的是相对路径,而官方要求从 samples/data 目录运行,于是两个 yml 也落在那里。之后在 build 目录跑对账程序就报找不到文件。先复制过去再跑。

坑五:stereo_match 的两个参数没有默认值。

--max-disparity--blocksize 默认都是 0,而校验要求前者能被 16 整除、后者是正奇数,不传就直接报参数错误退出。必须显式传。

九、AI 与 LLM Wiki

这一课的知识库更新:

新增概念页「双目立体:双目标定与立体匹配」,674 行。里面有两张硬性表格------API 汇总表 25 条 (覆盖标定和匹配两个阶段,每条写明功能、关键参数、用法要点),以及demo 语法分析 (两个示例逐块拆解,包括匿名命名空间的作用、maxScale=2 上采样重试机制、& -16 位运算实现向上取整、内参随缩放同步变换等)。实测数据单独占五节,踩坑九条。

新增问答页「双目立体标定旁路验证记录」,15 个 Q&A,每条尽量带源码行号级或实测数值级的证据。

整个工作流是这样的:

从源码到可复用知识,六步闭环。

其中第三步「独立验证」是这套方法里最关键的一环,也是我踩坑最多的一环。官方示例普遍只开窗口显示、不落盘、不打印中间量,导致「跑通了」这件事无法自证。我的做法是在同一条 CMakeLists 里加第二个 target,官方原版一个字节不改(sha256 可自证),验证程序负责独立复现关键中间量:

  • 逐对统计角点检测成功率(官方不报这个)
  • solvePnP + projectPoints 独立复现重投影误差
  • 用 F 矩阵独立算对极误差
  • 对比 Q 矩阵深度和手算结果
  • 量化校正前后的行对齐误差

这一步让「我觉得跑通了」变成「我证明了这个数字是对的」。 而这次追查的结论正好反过来印证了它的价值:真正错的不是官方,是我自己------如果没有这套独立验证,我上一次的错误结论就会一直留在文章里。

第六步「固化进技能」这次还包括一件事:把我自己之前写错的踩坑条目(关于 RMS 二次归一化那条)在技能文档里标注更正。技能文档里的错误比文章里的更危险,它会在下次被自动加载并误导判断。

写在最后

这一篇的主线其实是一个认错的过程。上一次我把官方的正确写法判成了 bug,这一次花了大半天把它翻了过来,顺带把双目立体这条链走完:标定、校正、匹配、点云。

排查过程中我给自己定了一条顺序:同版本 → 同参数 → 同语义 。一遇到数值不一致,先确认自己用的库版本和官方一致,再对齐参数,最后读源码确认坐标约定。跳过这三步直接怀疑官方,就是这一次五次弯路的共同病根。

下一篇进入对极几何:基础矩阵和对极线,以及怎么从本质矩阵分解出旋转和平移、重建三维结构。

本文示例代码均出自 OpenCV 官方 samples,遵循 Apache 2.0 协议。

相关推荐
AlbertZein2 小时前
Step-5-Preview 上手实测:3D 游戏、金融分析、网页设计一次跑完
人工智能·aigc
LaughingZhu2 小时前
Product Hunt 每日热榜 | 2026-09-19
人工智能·深度学习·神经网络·搜索引擎·百度
美狐美颜SDK开放平台2 小时前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
wukangjupingbb2 小时前
智能网联汽车安全能力框架
人工智能
龙亘川3 小时前
明月照湾区,智启新赛道:从顶流文旅IP盛会看智慧文旅升级路径
人工智能·智慧城市·开源软件·数据可视化
飞猫的边缘AI3 小时前
边缘AI应用:家用AI摄像头怎么做数据训练?
人工智能·边缘计算·ai算法·边缘ai
猎头南楼3 小时前
知识社区推荐系统实践:新用户冷启动与长短期兴趣建模的挑战 资深推荐算法工程师
人工智能·深度学习·算法·机器学习
自由能燃气设备3 小时前
商用全预混低氮冷凝锅炉免费方案vs付费方案对比+选型避坑指南
大数据·数据库·人工智能
Seoyoneh3 小时前
呼叫中心系统上云还是本地部署?架构、成本与运维全维度技术对比
人工智能·信息与通信·通信