上一篇我们用 13 张棋盘格图片,解出了单台相机的内参和畸变系数。但工业现场很少只装一台相机------左中右三台同时拍,才是常见的配置。问题来了:三台相机各自的"内心世界"知道了,可它们之间的相对位置关系怎么求?这一篇就解决这件事。顺手还挖出一个官方示例藏了很久的坑,看完你会发现------你以为的 0.03 像素精度,其实是 0.32 像素。
一、效果先行
先看结果。这是三台相机各自去畸变+极线校正之后,并排拼在一张画布上的样子:

画面被分成左中右三块,每块都是同一块棋盘格。注意那些绿色的水平横线 ------这是官方刻意画的判据线。校正如果做对了,同一个物点在左中右三块里,应该落在同一条绿线附近。仔细看棋盘格的角点,三块之间的高度对得很齐。
换一组场景再看一次:

这是校正之前的三张原图并排:

差别是肉眼可见的------原图里三张的棋盘格位置、倾斜度都不一致;校正之后才被拉到同一个水平框架里。这就是三目视觉能做出立体测量的前提。
二、三目标定到底在标什么
先把问题说清楚。三台相机水平排列,每一台都有自己的坐标系。我们要的是两样东西:
一是每台相机自己的内参------fx、fy、cx、cy 加上畸变系数。这部分上一篇已经解决,直接复用。
二是相机之间的相对位置关系 ,也就是相对外参。用旋转矩阵 R 和平移向量 T 描述。官方示例的做法是"星型拓扑":拿中间的相机 1 当基准,分别和相机 2、相机 3 配对,解出两组外参:
R12、T12:把相机 1 坐标系的点变换到相机 2 坐标系R13、T13:把相机 1 坐标系的点变换到相机 3 坐标系
变换公式就是一句话:
X_cam2 = R12 · X_cam1 + T12
外参解出来之后,第三步是把三张图的极线校正到水平对齐 。这一步用的是 rectify3Collinear,它假定三个光心共线------这也是为什么示例叫"3Calibration",三目必须共线才好算。
三、三步流程与关键 API
官方 3calibration.cpp 一共 360 行,结构非常清晰,就是三步:
step 1 三台相机各自 calibrateCamera → 各自内参 + 畸变系数
step 2 stereoCalibrate 配对 (1,2) 与 (1,3) → 相对外参 R12,T12 / R13,T13
step 3 rectify3Collinear(1,2,3) → 校正旋转 R1..R3、投影矩阵 P1..P3、Q
其中最核心、也是这一篇真正的新知识,是 stereoCalibrate。它的调用长这样:
cpp
double err = stereoCalibrate(objpt, imgpt, imgpt_right,
cameraMatrix1, distCoeffs1,
cameraMatrix, distCoeffs,
imageSize, R, T, E, F,
CALIB_FIX_INTRINSIC,
TermCriteria(TermCriteria::COUNT, 30, 0));
这里有个非常重要的参数:CALIB_FIX_INTRINSIC。
它的含义是锁定两边的内参,只解外参 。为什么要这样?因为内参在 step 1 里已经用单目标定单独解出来了,此时如果放开让 stereoCalibrate 重新求解,等于把"已经确定的量"又当成未知量去拟合------自由度白白增加,结果反而更不稳。这是多相机标定的标准做法,也是工程上必须知道的一条经验。
stereoCalibrate 除了输出 R、T,还会顺便输出 E(本质矩阵) 和 F(基础矩阵)。这两个是后续对极几何、立体匹配的基础,下一篇会用到。
第三步 rectify3Collinear 能一次求出三台相机的校正变换:
cpp
double ratio = rectify3Collinear(cameraMatrix[0], distCoeffs[0],
cameraMatrix[1], distCoeffs[1],
cameraMatrix[2], distCoeffs[2],
imgpt[0], imgpt[2],
imageSize, R12, T12, R13, T13,
R[0], R[1], R[2], P[0], P[1], P[2], Q, -1.,
imageSize, 0, 0, CALIB_ZERO_DISPARITY);
注意第 7、8 个参数只传了 imgpt[0] 和 imgpt[2]------首尾两台相机的角点 。中间那台不需要传,因为"三目共线"这个几何约束已经把它的位置确定下来了。参数名里的 Collinear(共线)就在说这件事。
它输出的 Q 矩阵是把视差图转成三维坐标的关键,下一篇做立体匹配时会变成主角。
一个容易踩的细节:rectify3Collinear 的返回值 ratio 是视差比例因子 ,会被乘到 Q 矩阵的对应元素上。本次实测干净数据是 -2.47628。负数是正常的------它只是表示基向量方向与内部假设相反,不是失败标志。判断标定质量要看重投影误差和极线对齐情况,别盯着这个符号。
四、独立验证:把中间量挖出来对账
官方这个示例有个问题:它除了输出一个 yml,什么图都不存 。所有中间结果都靠 imshow 弹窗让人肉眼看,跑完就没了。这让我们没法严格自证"到底跑对了没有"。
所以还是老办法------同一个 CMakeLists 里加一个验证 target,官方原版一个字节不改,另写一个程序独立复现关键中间量。
先做角点检测可视化:

再做去畸变前后的对比,把棋盘格的直线拉出来看弯曲:

然后是数字层面的对账,这是最有价值的部分:
| 验证项 | 结果 |
|---|---|
| 角点检测成功率 | 6/6 组全成功,每视角恒 54 点 |
| fx 相对偏差(三台) | 0.205% / 0.200% / 0.212% |
| fy 相对偏差(三台) | 0.307% / 0.302% / 0.317% |
| 跨相机投影 RMS | 0.430 像素 |
| 深度量级自检 | 相机 2 的 z = 15.707,相机 1 的 |t1| = 16.731 |
内参独立重跑与官方输出吻合到 0.2%~0.3%,这是双向印证过的。
其中最"硬"的一条证据是跨相机投影。做法是:把相机 1 看到的棋盘格角点,经过 R12、T12 变换到相机 2 的坐标系,再投影到相机 2 的像素平面,看它落在哪里:
cpp
Mat Xo = (Mat_<double>(3,1) << obj[q].x, obj[q].y, obj[q].z); // 棋盘格坐标
Mat Xc1 = R1 * Xo + t1; // ① 棋盘格坐标 → 相机1坐标
Mat Xc2 = R12 * Xc1 + T12; // ② 相机1坐标 → 相机2坐标
Mat zero = Mat::zeros(3,1,CV_64F);
projectPoints(ptsInCam2, zero, zero, cm2, dc2, prj); // 已在 cam2 系 → 零旋转零平移
这里我自己踩了一个坑 :一开始图省事,直接把棋盘格坐标乘 R12 加 T12,跳过了第 ① 步。结果输出是 137905369740476416.0------1.4 乘 10 的 17 次方,然后平方直接溢出成 inf。
原因很简单:Xo 是棋盘格坐标系 下的点(z 恒等于 0 的平面),不是相机 1 坐标系下的点。必须先用相机 1 的外参 R1、t1 把它转到相机 1 坐标系,才能再乘 R12 转到相机 2。
诊断诀窍 :打印中间量看量级 。相机 2 的 z 应该在 16 左右(和相机 1 的 |t1| 同一个量级),而不是 10 的 17 次方。数字离谱的时候,先怀疑自己的坐标系转换漏了一步,而不是怀疑 OpenCV 算错了。
修正之后,跨相机投影的 RMS 误差是 0.430 像素------外参是对的。
五、踩坑记录:0.03 像素的真相
现在说这一篇最重要的发现。
官方程序跑完,打印出来是这样的:
Camera 1 calibration reprojection error = 0.031362
Camera 2 calibration reprojection error = 0.0317473
Camera 3 calibration reprojection error = 0.0315657
Pair (1,2) calibration reprojection error = 0.02232
Pair (1,3) calibration reprojection error = 0.0222498
Disparity ratio = -2.47628
看到 0.031 像素,第一反应是"精度高得离谱"。业界公认的标准是 RMS 小于 0.5 像素可接受、小于 0.3 像素算优秀------这里直接干到 0.03,比优秀还低一个数量级。
但用同一批角点、同一套内参做独立复现,结果是:
| 量 | 数值 |
|---|---|
calibrateCamera 的返回值 |
0.321982 |
我的 solvePnP + projectPoints 独立复现 |
0.321982(逐位一致) |
官方打印的 sqrt(err/N) |
0.031524 |
这两个数差了 10.2 倍。
(写这篇文章的初版时,我在这一段下了个结论:官方示例把重投影误差二次归一化了,真实值是 0.322 而不是 0.031 。这个结论是错的。下一课的追查把它推翻了,过程的教训比结论本身更值钱,所以我把这一整段重写一遍放上来。)
先说结论:官方没有 bug。
我当时的推理链是这样:
- 读到源码
calibration.cpp:1912→return std::sqrt(reprojErr/total);✅ 这步是对的 - 由此认定
calibrateCamera返回值已经是像素级 RMS ✅ 这步也是对的 - 于是把官方示例里那个
err变量,当成了「第 1 步那个返回值」❌ 错在这
真相是,官方示例里的 err 是它自己另外累加的一个量,跟函数返回值没有关系:
cpp
// 3calibration.cpp 官方原版(未改动)
double err = 0;
for( int j = 0; j < N; j++ )
{
double e = norm(imagePoints[i][j] - _imagePoints[i][j]); // 单点重投影误差(px)
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 倍。是两个不同的量。
这里有个我当时就该做的动作:看到两个同名量的数值对不上,第一件事是确认它们是不是同一个量------把各自的类型、维度、来源行号打出来,再谈谁对谁错。我跳过了这一步,直接假设「官方错了」,然后拿着错误假设往回找证据,一路顺理成章地"证明"了自己的猜想。
顺带把这几条 API 语义钉死(stereoCalibrate 的括号里为什么有 *2):
1241: return std::sqrt(reprojErr/total); // cvStereoRectify
1912: return std::sqrt(reprojErr/total); // cvCalibrateCamera2Internal
2449: return std::sqrt(reprojErr/(pointsTotal*2)); // cvStereoCalibrate
stereoCalibrate 按 pointsTotal*2 归一,是因为一个三维点在一对相机里贡献左图 (x,y) 加右图 (x,y) 共 4 个残差分量。这三处的返回值都直接可用,是像素级 RMS:
cpp
double rms = calibrateCamera(objpt, imgpt, imageSize, cm, dc, rv, tv, flags);
std::cout << "RMS = " << rms << " px" << std::endl; // 直接用,别再套一层 sqrt(err/N)
最后立一条规矩,是这次踩坑换来的:报告任何误差数值,必须同时报告它的定义------计算式、坐标系、出处行号,三要素缺一不可。只有数值没有定义,等于没报,而且会误导后面所有基于它的判断。
六、第二个坑:检测失败是静默的
这个坑比上一个更隐蔽,也更危险。
3calibration.cpp 里检测棋盘格角点的代码是这样的:
cpp
bool found = findChessboardCorners( view, boardSize, ptvec, CALIB_CB_ADAPTIVE_THRESH );
drawChessboardCorners( view, boardSize, Mat(ptvec), found );
if( found )
{
imgpt[k1][i].resize(ptvec.size());
std::copy(ptvec.begin(), ptvec.end(), imgpt[k1][i].begin());
}
// 没有 else
没有 else 分支。检测失败就留空,不报错、不警告、不退出。后续 run3Calibration 里再用 if( !imgpt0[i].empty() ) 把这些空视角悄悄剔除掉,只要剩下至少 3 个视角,标定就继续跑,最后照样输出一份格式完全正确的 yml。
我是怎么发现这个问题的?因为我在写测试数据生成器时试着把三台虚拟相机的平移量调大了一点。官方 samples 目录里没有三目配套数据 (只有单目的 left01 到 left14),所以这些三元组图是我自己合成的:同一张源图,左移、不动、右移,模拟三台相机。
平移量设成 60 像素时,程序"跑通了",但 Disparity ratio 从干净的 -2.476 变成了 -0.288。而打印出来的重投影误差还是 0.0315 量级,完全看不出异常。
单独跑一遍检测统计才发现问题:
| 场景 | 失败相机 |
|---|---|
| scene02 | 相机 3,检测到 0 个点 |
| scene05 | 相机 3,检测到 0 个点 |
| 结果 | 6 组里只有 4 组可用 |
根因也能量化。我在原图上先检测角点,再按平移量算出它们在新图里的坐标,数一数有多少跑出了画面:
| 平移量 | 有角点出界的图 | 出界点数 |
|---|---|---|
| 0 / 30 | 0 / 6 | 0 |
| 60 | 2 / 6 | 各 3 / 54 |
| 90 | 3 / 6 | 1 到 11 |
| 120 | 5 / 6 | 2 到 17 |
失败的正好是 left03 和 left06 那两组------它们各有 3 个角点被推出了画面右边界。而 findChessboardCorners 要求棋盘格完整可见,缺一个角就整张失败。
两条经验:做多相机数据采集时,任何一台相机都不能让标定板角点出画 ;自己的标定工具必须显式统计检测成功率并对失败视角报警,绝不能像示例这样静默跳过。
七、其他值得记的细节
还有一些实测发现的小结:
第一,CommandLineParser::has() 对有非空默认值的 key 恒返回 true 。官方参数表里 {a|1|} 有默认值,所以 if (parser.has("a")) 永远成立,CALIB_FIX_ASPECT_RATIO 总是被加上。实证就是输出 yml 里三组内参的 fx 和 fy 完全相等(532.188/532.188、532.210/532.210、532.197/532.197)------这是该 flag 生效的痕迹。
第二,三元组文件的索引有个无注释的隐藏重排 。源码里 k1 = k==0 ? 2 : k==1 ? 0 : 1,意思是磁盘上第 0、1、2 张分别对应逻辑相机 3、1、2。如果按直觉顺序组织数据,会得到相机编号错位的 R、T,而且重投影误差不会报警。
第三,3calibration.cpp 没有做亚像素角点细化 。它在 findChessboardCorners 之后直接拷贝整像素角点,所以 calibrateCamera 返回的 0.322 像素 RMS 很大程度是受了像素量化限制。补上 cornerSubPix 能显著降低:
cpp
cv::cornerSubPix(gray, ptvec, Size(11,11), Size(-1,-1),
TermCriteria(TermCriteria::EPS + TermCriteria::MAX_ITER, 30, 0.001));
第四,程序末尾必定报一句 Can't destroy non-registered window 的警告。查源码发现 destroyWindow("view") 想销毁一个从没创建过的窗口------因为 imshow("view", ...) 那行被注释掉了。无害,忽略即可。
八、AI 与 LLM Wiki
这一课在知识库里的沉淀:

新增了一张概念页,681 行,包含 20 条 API 汇总表、7 小节逐块语法分析、9 条踩坑记录,另加一个专章讲两种 RMS 口径的区别(该章结论已在下一课更正,wiki 里保留了完整的更正痕迹)。还新增了一张问答沉淀页,10 个 Q&A,每条结论都带源码行号级的证据。
进度表从 53 项推进到 54 项,97 个实例的完成度到 56%。
这里真正值得说的是工作流本身怎么用 AI 提升 。这一课如果只跑一遍官方程序、只看它打印的 0.031,得到的就是"精度极高"这个错误结论。真正挖出问题的动作是:另写一个独立程序,把官方不打印的中间量自己算出来,然后逐位对账。
这个模式到目前为止已经连续复用了 5 次:
| 学习日 | 对账方式 |
|---|---|
| Day25 | 库图检索命中,用像素 MSE 判定 |
| Day26 | 光流 S 通道统计 + 伽马增强旁路诊断 |
| Day27 | 投影框几何判据 |
| Day28 | 角点坐标逐位对账 |
| Day29 | 独立复现 RMS + 统计官方不报的检测成功率 |
同一个动作重复到第五次,它就不再是"某天用了个技巧",而是一条可复用的方法论:官方示例的打印输出不能无条件相信,要自己复现一遍关键中间量。这条经验已经固化进技能文档,下次遇到"官方只出图不打日志"的示例,直接照方抓药。
顺带说,这也是这套 LLM Wiki 方法论的价值所在------知识不是记在脑子里等着忘,而是写进技能,下次自动生效。
写在最后
这一篇从三目标定出发,挖出了一个当时以为的坑、和一个真正的坑:一个是重投影误差口径误解 (当时判定官方二次归一化,下一课推翻------官方没错,是我把两个不同的量当成一个了),一个是角点检测失败被静默跳过 (这个是真坑,官方确无 else 分支)。
这两个坑有个共同点:都不会报错。程序跑得很顺,输出很漂亮,yml 格式完全正确。唯一的破绽是你仔细算一遍真实数字------或者看看那个突然从 -2.476 变成 -0.288 的视差比例因子。
这大概就是工业视觉最需要的那种警惕性:能跑通和跑对了,是两件事。
下一篇进入双目标定与立体匹配,用 stereo_calib.cpp 解出左右相机的相对位姿,再用 stereo_match.cpp 做 SGBM 立体匹配生成视差图------这一篇里那个 Q 矩阵,下一篇就派上大用场了。从两张图里算出每个像素的深度,想想还是挺有意思的。
本文示例代码均出自 OpenCV 官方 samples,遵循 Apache 2.0 协议。