OpenCV双目立体标定与校正实战:从棋盘格到视差图避坑
简介:这份资源是一份基于OpenCV的双目视觉立体标定与校正代码文档,面向已具备一定C++与计算机视觉基础、正在做双目相机标定实验或三维重建项目的开发者和学生,解决从棋盘格图像采集到参数估计、图像校正的完整流程实现问题。压缩包共1个文件,为doc文档,约294KB,内容以源码分析为主线,包含StereoCalib函数的实现讲解与关键代码片段。文档围绕cv::stereoCalibrate完成内外参估计,借助cv::undistort去除镜头畸变,并用cv::stereoRectify做立体校正,同时说明了标定flag的取值含义、棋盘格角点数与方格尺寸等输入参数的设置方式,以及标定结果写入文件的处理思路。读者可据此理解双目标定的整体链路,将示例代码迁移到自己的相机型号与采集环境中,减少在参数配置和接口调用上的试错成本。目前已有2595人学习,适合作为双目视觉入门到实践的参考材料。
1. 一对摄像头摆上支架之后,先别急着开 SGBM
两个摄像头拧上铝型材,很多人第一反应是直接跑 SGBM 看视差图,结果参数调了两天还是满屏麻点——问题八成不在匹配算法,而在前面的双目视觉立体标定与校正没做干净。这份基于 OpenCV 的双目立体标定与校正代码,干的就是这件事:读一组棋盘格图,跑cvStereoCalibrate拿到双目内参和外参,再用cvStereoRectify或cvStereoRectifyUncalibrated算校正映射,cvRemap之后左右图的行就对齐了,顺手还能出一张 BM 视差图。它出自经典教材的配套示例,C 接口写成,代码不长,但两套校正路线、标定质量自检、StereoBM 参数一个不缺。刚搭好硬件想先跑通基线的人,和匹配调到怀疑标定的人,都值得先把它啃一遍。
2. 立体标定的数学模型:内参、外参、本征矩阵和基础矩阵怎么串起来
2.1 为什么左右各标一次再拼起来不行
单目标定做的事,是让一个相机知道自己镜头的焦距、主点和畸变;双目要做的事,是让两个相机知道彼此站着的位置差。看着只差一步,坐标系却完全不同。左右相机各自标定时,棋盘格在左视图坐标系里有一套 R1、t1,在右视图坐标系里有一套 R2、t2,它们描述的是「棋盘相对相机」,而两次拍摄时棋盘可以摆在任意位置——这两个 R 之间没有任何可比性,硬拼出来的基线是错的。
正解是让同一块棋盘同时出现在左右视野里,把「同一组三维物点、两套二维像点」一起丢进优化器,让左右相机的内外参在同一个重投影误差下联合求解。这样输出的R和T才有物理意义:T的模就是基线长度,T的 x 分量(水平双目时)近似就是两台相机的光心间距。这个数字直接决定深度精度,Z = f·B/d,基线 B 差 5%,十米外的深度就偏 5%。所以squareSize这种看着不起眼的量纲参数,后面会反复咬人。
源码里的cvStereoCalibrate调用就是把这件事一次做完:既输出 M1、D1、M2、D2,也输出 R、T、E、F。E 是本征矩阵,F 是基础矩阵,两者关系是E = T× · R、F = M2⁻ᵀ · E · M1⁻¹。E 在归一化坐标下描述对极约束,F 在像素坐标下描述,后者才是能直接拿像素点验证的那个。
2.2cvStereoCalibrate的十五个参数逐个拆
这个函数参数多到反人类,但只要按「输入点 → 输出内参 → 输出外参 → 优化控制」四段去读,就不乱了。
| 序号 | 参数 | 方向 | 说明与常见取值 |
|---|---|---|---|
| 1 | objectPoints | 入 | N×3 物点,N = 帧数 × 每帧角点数,单位由 squareSize 决定 |
| 2 | imagePoints1 | 入 | 左图角点,与物点一一对应 |
| 3 | imagePoints2 | 入 | 右图角点,顺序必须与左图完全一致 |
| 4 | npoints | 入 | 每帧点数,本代码全部填 nx*ny |
| 5、7 | M1、M2 | 入/出 | 3×3 内参,可以给初值也可以给单位阵让优化器自己找 |
| 6、8 | D1、D2 | 入/出 | 畸变系数,这里只用了 5 个(k1 k2 p1 p2 k3) |
| 9 | imageSize | 入 | 像素尺寸,只用于细化内参,不影响外参 |
| 10、11 | R、T | 出 | 右相机相对左相机的旋转与平移 |
| 12、13 | E、F | 出 | 本征矩阵、基础矩阵 |
| 14 | criteria | 入 | 通常cvTermCriteria(CV_TERMCRIT_ITER+CV_TERMCRIT_EPS, 100, 1e-5) |
| 15 | flags | 入 | 约束项,下一节细说 |
源码里的调用长这样,我补上了返回值判断:
// 立体标定:一次求解左右内参 + 相对位姿 CvMat _objectPoints = cvMat(1, N, CV_32FC3, &objectPoints[0]); CvMat _imagePoints1 = cvMat(1, N, CV_32FC2, &points[0][0]); CvMat _imagePoints2 = cvMat(1, N, CV_32FC2, &points[1][0]); CvMat _npoints = cvMat(1, npoints.size(), CV_32S, &npoints[0]); cvSetIdentity(&_M1); // 内参初值:单位阵 cvSetIdentity(&_M2); cvZero(&_D1); // 畸变初值:全零 cvZero(&_D2); int ok = cvStereoCalibrate( &_objectPoints, &_imagePoints1, &_imagePoints2, &_npoints, &_M1, &_D1, &_M2, &_D2, // 输出内参与畸变 imageSize, // 仅用于内参细化 &_R, &_T, &_E, &_F, // 输出相对位姿与 E、F cvTermCriteria(CV_TERMCRIT_ITER + CV_TERMCRIT_EPS, 100, 1e-5), CV_CALIB_FIX_ASPECT_RATIO + CV_CALIB_ZERO_TANGENT_DIST + CV_CALIB_SAME_FOCAL_LENGTH); if (ok == 0) { fprintf(stderr, "stereo calibrate failed: check correspondences\n"); }这里的逻辑顺序值得说清楚:先给初值,再求解,最后判返回值。内参初值给单位阵不是偷懒,而是让优化器从「一个焦距等于 1 的针孔模型」起步,靠 LM 迭代收敛到真实值;如果给了一组离谱的初值(比如手填了 5000),反而容易在错误的局部极小值停下。返回值 0 表示求解失败,最常见的原因是有效棋盘帧数太少、或者所有角点近似共线,此时不会抛异常,只会静默给你一堆垃圾数字,所以这个判断别省。
参数里最容易被忽略的是imageSize。源码取的是最后一帧图像的尺寸,如果这组图里混进了不同分辨率的照片,这个值就是错的,内参细化会偏。我一般会在读图的循环里加一句尺寸一致性检查,不一致直接报错退出,比后面查半天强。
2.3 三个标定 flag 的取舍:什么时候该放,什么时候该收
源码用的组合是CV_CALIB_FIX_ASPECT_RATIO + CV_CALIB_ZERO_TANGENT_DIST + CV_CALIB_SAME_FOCAL_LENGTH,这三个不是随便挑的。
CV_CALIB_FIX_ASPECT_RATIO把 fy/fx 锁成一个自由参数,等于承认「像素是方的」。绝大多数工业相机和 USB 相机确实如此,锁上能少一个自由度,在小样本(十几帧)时明显更稳。但如果相机用了非方形像素或者做过裁剪缩放,这条就得去掉,否则标出来的焦距看上去很漂亮,实际重投影误差会异常偏大。
CV_CALIB_ZERO_TANGENT_DIST强制切向畸变 p1、p2 为 0。镜片装配良好的相机切向畸变确实接近零,锁掉它能防止这两个参数在噪声里乱跑——这是最常见的翻车点之一:不锁的时候,某些数据集会解出 p1 = 0.03 这种量级,然后你拿它去畸变,边角直接被拽歪。但如果镜头明显歪(比如拆机重装的模组),锁死反而掩盖问题,这时应该放开,同时把帧数堆到 30 以上。
CV_CALIB_SAME_FOCAL_LENGTH假设左右相机焦距完全相同。同一批次同一型号的模组通常成立,但如果你手上是两只来源不同的镜头,这条必须去掉,否则优化器会强行让两边妥协,误差全压到主点上。
判断标准不玄学:跑一遍看avg err,把某个 flag 放开再跑一遍,如果误差没降反而参数跳变得离谱,说明这条约束对你的硬件是成立的,留着。
2.4 用极线误差自检:那个avg err到底在算什么
标定跑完不能直接信,得验。源码里的做法很地道:先把角点用cvUndistortPoints拉到去畸变空间,再用cvComputeCorrespondEpilines分别算出左右图每个点对应的极线,最后把点代进直线方程取绝对值累加。
// 去畸变后再算极线误差,避免畸变污染判断 cvUndistortPoints(&_imagePoints1, &_imagePoints1, &_M1, &_D1, 0, &_M1); cvUndistortPoints(&_imagePoints2, &_imagePoints2, &_M2, &_D2, 0, &_M2); cvComputeCorrespondEpilines(&_imagePoints1, 1, &_F, &_L1); cvComputeCorrespondEpilines(&_imagePoints2, 2, &_F, &_L2); double avgErr = 0; for (int i = 0; i < N; i++) { double err = fabs(points[0][i].x * lines[1][i].x + points[0][i].y * lines[1][i].y + lines[1][i].z) + fabs(points[1][i].x * lines[0][i].x + points[1][i].y * lines[0][i].y + lines[0][i].z); avgErr += err; } printf("avg err = %g\n", avgErr / (nframes * n));这段的关键在于「先去畸变」。畸变会让极线弯成曲线,而F描述的是针孔模型下的直线约束,不先去畸变,误差里混的是畸变量而不是标定质量,数字没有参考价值。第二个注意点是:这个avg err是代数误差,不是点到直线的像素距离——代码没有除以sqrt(a²+b²),所以严格来说它偏大一点,量纲也不完全是像素。作为相对指标够用:同一批数据同一套参数,误差从 0.6 降到 0.3 说明改进有效;但要跟别人比绝对值就没意义了,想比就自己补上归一化。
我的经验阈值:1280×720 的图,avg err在 0.2~0.5 属于正常,0.5~1.0 要看看是不是某几帧拖后腿,超过 1.5 基本可以判断角点提取有错配或者棋盘不平。想定位是哪一帧,把每帧的误差单独打印出来排序,最差的那几帧拉出来看图,十有八九能看见反光、运动模糊或者棋盘被遮挡。
3. 角点提取与列表组织:源码里那几个数组的来龙去脉
3.1ch12_list.txt的格式约定与左右交替读取
这份代码没有用目录遍历,而是要求你提供一个纯文本列表,一行一个图片路径,左右图交替排列——第一行左、第二行右、第三行左、第四行右。源码里靠lr = i % 2判断当前行属于哪一路,#开头的行跳过,行尾空白会被裁掉。
手工维护这个文件很容易错位,我一般用脚本生成,顺便做一次「左右图能否配对」的检查:
import os left_dir, right_dir = "stereo/left", "stereo/right" out = [] lefts = sorted(f for f in os.listdir(left_dir) if f.lower().endswith((".png", ".jpg", ".bmp"))) for name in lefts: lpath = os.path.abspath(os.path.join(left_dir, name)) rpath = os.path.abspath(os.path.join(right_dir, name)) if not os.path.exists(rpath): # 缺右图直接报出来,别静默跳过 print("missing pair:", name) continue out.append(lpath) out.append(rpath) # 严格左、右交替 with open("ch12_list.txt", "w", encoding="utf-8") as f: f.write("\n".join(out) + "\n") print("pairs:", len(out) // 2)逻辑说明:sorted保证左右两侧按同一顺序取,配对靠同名文件;先写左再写右这个顺序不能反,源码的i % 2是死约定,反了整个标定会算出一个横向基线的镜像值,误差看着还不大,装到设备上才发现左右颠倒。参数说明:如果采集时左右图用了不同前缀(比如L_001.png/R_001.png),把生成 rpath 的那行换成相应的重命名规则即可。
还有两个细节:路径里不要带空格,fgets加cvLoadImage这条链路处理空格路径虽说不至于崩,但 Windows 下的反斜杠容易在转义上出问题,统一用正斜杠最省事。
3.2 角点检测、放大重试与亚像素精化
cvFindChessboardCorners在低分辨率、大畸变或者边缘虚焦的图上经常返回 0 个角点。源码的处理是加了一个maxScale的重试机制,先原图检测,失败就放大再试。虽然这里maxScale被设成 1(等于没开),但机制是现成的,把它改成 2 或 3 能救回不少边缘帧。
// 角点检测 + 亚像素精化 int count = 0, found = 0; for (int s = 1; s <= maxScale; s++) { IplImage* timg = img; if (s > 1) { // 放大重试 timg = cvCreateImage(cvSize(img->width * s, img->height * s), img->depth, img->nChannels); cvResize(img, timg, CV_INTER_CUBIC); } found = cvFindChessboardCorners( timg, cvSize(nx, ny), &temp[0], &count, CV_CALIB_CB_ADAPTIVE_THRESH | CV_CALIB_CB_NORMALIZE_IMAGE); if (timg != img) cvReleaseImage(&timg); if (found || s == maxScale) for (int j = 0; j < count; j++) { // 坐标还原回原尺度 temp[j].x /= s; temp[j].y /= s; } if (found) break; } if (found) { // 亚像素精化,窗口 11x11,迭代 30 次或精度 0.01 cvFindCornerSubPix(img, &temp[0], count, cvSize(11, 11), cvSize(-1, -1), cvTermCriteria(CV_TERMCRIT_ITER + CV_TERMCRIT_EPS, 30, 0.01)); }关键点在于坐标还原:放大 s 倍检测出来的角点坐标是放大图上的,必须除以 s 才能在原图上用,源码在循环里做了这件事。第二个关键点是cvFindChessboardCorners只给整像素粗定位,cvFindCornerSubPix才把它精化到亚像素。源码注释里那句 "Calibration will suffer without subpixel interpolation" 不是客套话——不做亚像素,重投影误差基本卡在 0.5 像素往上,做了能压到 0.1 量级。
参数上,亚像素窗口cvSize(11, 11)对应 640×480 到 1280×720 这一档比较合适;如果图是 4K 的,棋盘格在画面里占比很小,11 像素窗口可能连一个格子的边都跨不过去,得放大到 15 或 21。终止条件30 次 / 0.01 像素是稳妥值,迭代次数给太小(比如 10)在低对比度图上会提前停。
3.3 三维物点怎么造:i和j的顺序别搞反
物点列表是「假装棋盘就摆在某个已知位置」造出来的理想坐标。源码这段只有五行,但错一个下标就全废:
objectPoints.resize(nframes * n); for (int i = 0; i < ny; i++) // 注意:外层是 ny for (int j = 0; j < nx; j++) // 内层是 nx objectPoints[i * nx + j] = cvPoint3D32f(i * squareSize, j * squareSize, 0); for (int i = 1; i < nframes; i++) // 其余帧复制第一帧 copy(objectPoints.begin(), objectPoints.begin() + n, objectPoints.begin() + i * n);索引是i * nx + j,坐标却写成(i * squareSize, j * squareSize)——也就是行方向给了 x、列方向给了 y,跟常见的「x 走列、y 走行」相反。这算 bug 吗?在这个场景里不算:棋盘是平面规则网格,x、y 轴互换等价于整块板子绕 z 轴转 90°,只要左右两路用的是同一套物点,联合优化出来的 R、T 仍然正确,只是坐标系的定义换了。真正致命的是另一件事:物点的排列顺序必须和cvFindChessboardCorners返回角点的顺序一一对应。那个函数是按行扫描从左到右返回的,所以物点也必须按「第 0 行所有列、第 1 行所有列」这样排。顺序一乱,优化器会试图把棋盘扭曲成一个不存在的形状,现象是avg err直接飙到几十,但程序一声不吭地跑完了。
squareSize在源码里是1.f,意思是「一格 = 1 个单位」。这个设定对标定内参没影响(内参是像素单位,与物点尺度解耦),但会让 T 变成「多少格」,比如 T = [-3.2, 0.05, 0.1] 表示基线约 3.2 格。后面要拿 T 算真实基线长度或者做尺度恢复,就必须把它换回毫米。我的习惯是直接把squareSize设成实测格长(比如 25.0 表示 25mm),一次到位,省得后面换算时把单位丢了。
3.4active[]和npoints:成功与失败帧的记录方式
源码里有个容易被忽略的设计:active[lr]记录每帧是否检测成功,npoints记录每帧的点数。这是为「部分帧检测失败」准备的——理论上你可以在后面只挑成功的帧参与标定。但源码实际用的nframes = active[0].size()只看了左路,然后npoints.resize(nframes, n)全部填满n,等于默认左右两路帧数和成功情况完全一致。这是个隐患,第 5 章会展开。
我一般会把这个逻辑改成显式配对:只有左右同一帧都检测成功,才把这一对推进物点列表,否则整对丢弃。宁可少几帧,也不要让左右点的对应关系错位。
4. 两条校正路线:Bouguet 和 Hartley 分别适合谁
4.1 Bouguet:有内参就走这条,映射表一次算好反复用
useUncalibrated == 0走的是标定校正,也就是 Bouguet 方法。核心是两个函数接力:cvStereoRectify算出左右各一个 3×3 旋转矩阵 R1、R2 和两个 3×4 投影矩阵 P1、P2,把两个成像平面「掰」到共面且行对齐;cvInitUndistortRectifyMap把内参、畸变、R、P 揉成两张映射表 mx/my,之后每帧只需cvRemap查表,速度很快。
CvMat _R1 = cvMat(3, 3, CV_64F, R1); CvMat _R2 = cvMat(3, 3, CV_64F, R2); CvMat _P1 = cvMat(3, 4, CV_64F, P1); CvMat _P2 = cvMat(3, 4, CV_64F, P2); // alpha=0:只保留有效像素;flags 给 0 表示不强制零视差 cvStereoRectify(&_M1, &_M2, &_D1, &_D2, imageSize, &_R, &_T, &_R1, &_R2, &_P1, &_P2, 0, 0); // 判断是不是上下堆叠的双目 isVerticalStereo = fabs(P2[1][3]) > fabs(P2[0][3]); // 预计算映射表,后续每帧复用 cvInitUndistortRectifyMap(&_M1, &_D1, &_R1, &_P1, mx1, my1); cvInitUndistortRectifyMap(&_M2, &_D2, &_R2, &_P2, mx2, my2);cvStereoRectify的第六个参数是alpha,源码传 0,含义是「裁剪掉所有黑边,只留两图共有的有效区域」。传 1 则保留全部原始像素但会引入大片黑边,做视差时黑边区域没有匹配值。做测量类应用我倾向 0,做可视化倾向 1。
flags传 0 而不是CV_CALIB_ZERO_DISPARITY,意味着不强制把主点对齐到同一行,好处是保留更多有效像素,代价是最小视差可能不为零,minDisparity得相应设成负值——源码里minDisparity = -64就是在兜这个底。如果你希望视差从 0 开始、方便直接映射成深度,就把 flag 加上,同时把minDisparity改回 0。
isVerticalStereo那句判断很巧:P2的第 4 列是右相机光心在左相机坐标系下的投影,若 y 分量比 x 分量大,说明两台相机是上下装的。这个标志后面控制拼接显示方向和是否允许做 BM 匹配,别删。
4.2 Hartley:没有内参也能救,但要接受精度损失
useUncalibrated == 1 或 2走的是非标定校正。顾名思义,不需要事先知道内参,只靠左右匹配点算出的基础矩阵 F,直接求两个单应 H1、H2 把极线掰平行。== 2的区别仅仅是在内部重新调一次cvFindFundamentalMat,== 1则用cvStereoCalibrate输出的 F。
double H1[3][3], H2[3][3], iM[3][3]; CvMat _H1 = cvMat(3, 3, CV_64F, H1); CvMat _H2 = cvMat(3, 3, CV_64F, H2); CvMat _iM = cvMat(3, 3, CV_64F, iM); if (useUncalibrated == 2) cvFindFundamentalMat(&_imagePoints1, &_imagePoints2, &_F); cvStereoRectifyUncalibrated(&_imagePoints1, &_imagePoints2, &_F, imageSize, &_H1, &_H2, 3); // H 是在像素坐标下的单应,转成旋转矩阵形式 R = M^-1 * H * M cvInvert(&_M1, &_iM); cvMatMul(&_H1, &_M1, &_R1); cvMatMul(&_iM, &_R1, &_R1); cvInvert(&_M2, &_iM); cvMatMul(&_H2, &_M2, &_R2); cvMatMul(&_iM, &_R2, &_R2);这段推导值得看懂:H作用在像素坐标上,而cvInitUndistortRectifyMap要的是作用在归一化坐标上的旋转矩阵,所以要做一次相似变换R = M⁻¹ · H · M。源码用两次cvMatMul实现,中间那次cvMatMul(&_H1, &_M1, &_R1)的结果直接覆盖进_R1,再左乘M⁻¹,顺序不能反,反了矩阵就不是旋转了,remap 出来的图会明显扭曲。
cvStereoRectifyUncalibrated的最后一个参数3是极线距离阈值,用于把匹配点分成内点和外点。点质量差的时候可以放宽到 5 甚至 8,但放太宽会让 F 被少量错误匹配带偏。
什么时候用 Hartley?我的判断是:手上只有一对相机、没有任何标定板、又必须先把视差跑起来做算法验证的时候。它能用,但输出的不是真正的行对准,而是「极线大致平行」,垂直方向仍残留视差,做 BM/SGBM 时匹配窗口跨行搜索会显著掉精度。真要测量,还是老老实实回到 Bouguet。
顺带说一句这份源码里的一个笔误:Hartley 分支最后两行是
cvInitUndistortRectifyMap(&_M2, &_D1, &_R2, &_M2, mx2, my2); // 注意这里传的是 _D1右相机用左相机的畸变系数去建映射表,畸变差异大时右图会被拉歪。正确写法应该是&_D2。这类错误在用同一型号相机的实验里几乎看不出来,换镜头就暴露了。
4.3 校正完还得看视差:StereoBM 参数怎么定
源码在 remap 之后直接接了一个 BM 匹配,参数是写死的:
| 参数 | 源码取值 | 作用与调整方向 |
|---|---|---|
| preFilterSize | 41 | 预滤波窗口,越大越平滑但吃细节,一般 5~21 |
| preFilterCap | 31 | 预滤波输出截断,控制在 31 附近即可 |
| SADWindowSize | 41 | 匹配代价窗口,越大越稳但边缘越糊,41 偏大 |
| minDisparity | -64 | 最小视差,允许负值以兼容未强制零视差 |
| numberOfDisparities | 128 | 视差搜索范围,必须是 16 的整数倍 |
| textureThreshold | 10 | 纹理阈值,低于此值的区域直接判无效 |
| uniquenessRatio | 15 | 唯一性比率,越大越严格,5~15 常见 |
41 的 SAD 窗口放到 1280×720 的图上偏大,边缘会被啃掉一大圈;我一般从 15 或 21 起步,看噪点情况再往上加。numberOfDisparities是最容易设错的一个:它决定了能测多近的物体,d_max < B·f / Z_min,基线 60mm、焦距 700 像素、想测最近 0.5m,就需要d_max > 84,取 96 或 128 都行,取 16 就完全测不到近处。
CvStereoBMState* BMState = cvCreateStereoBMState(); BMState->preFilterSize = 41; BMState->preFilterCap = 31; BMState->SADWindowSize = 41; BMState->minDisparity = -64; BMState->numberOfDisparities = 128; BMState->textureThreshold = 10; BMState->uniquenessRatio = 15; cvFindStereoCorrespondenceBM(img1r, img2r, disp, BMState); cvNormalize(disp, vdisp, 0, 256, CV_MINMAX); // 16S -> 8U 便于显示 cvShowImage("disparity", vdisp); cvReleaseStereoBMState(&BMState); // 别忘了释放cvFindStereoCorrespondenceBM的输入必须是已经 remap 过的灰度图,顺序是左、右、输出视差。输出disp是 16 位有符号,单位是像素的 1/16(OpenCV 的定点表示),直接当像素用会小 16 倍。源码用cvNormalize(..., 0, 256, CV_MINMAX)映射到 8 位只是为了显示,如果后面要算深度,得用原始disp除以 16 再乘1/16——这个换算关系是 BM 的一个经典坑,很多人第一次看到视差图全是黑的,就是这里没除。
4.4 C 接口到 C++ 接口的对照
这份代码是 2008 年前后的 C 接口写法,现在新项目基本都是cv::命名空间。对照关系整理如下,迁移时逐个替换即可。
| C 接口 | C++ 接口 |
|---|---|
cvFindChessboardCorners | cv::findChessboardCorners |
cvFindCornerSubPix | cv::cornerSubPix |
cvStereoCalibrate | cv::stereoCalibrate |
cvStereoRectify | cv::stereoRectify |
cvStereoRectifyUncalibrated | cv::stereoRectifyUncalibrated |
cvInitUndistortRectifyMap | cv::initUndistortRectifyMap |
cvRemap | cv::remap |
cvCreateStereoBMState+cvFindStereoCorrespondenceBM | cv::StereoBM::create+compute |
IplImage* | cv::Mat |
迁移时有一处语义差异要注意:C 接口的cvStereoCalibrate接收CvMat*,矩阵维度靠cvMat(1, N, ...)显式声明;C++ 接口接收std::vector<cv::Point3f>和std::vector<cv::Point2f>,点集组织方式变成「每帧一个 vector」,外层再套一个 vector。别直接把扁平数组塞进去,维度对不上会抛断言。
5. 避坑清单:那些让avg err从 0.3 飙到 5 的细节
5.1 现象:左右检测成功数不同,程序不报错但标定结果离谱
现象很隐蔽——程序正常跑完,avg err也能打出来,但数字大得离谱,或者T的模长跟实测基线差一大截。
原因是源码用nframes = active[0].size()只统计了左路的帧数,npoints全部填n,而points[1]的长度取决于右路实际检测成功多少帧。一旦右路少了几帧,两边点集长度就不一致,优化器会把错位的点当成对应点去拟合,解出来的东西自然没法看。
解决办法很直接:在读图循环结束后,对左右两路的active逐帧做与运算,只保留两边都成功的那几帧,重建objectPoints、points[0]、points[1]。宁可丢帧,不能错位。
5.2 现象:列表文件里有一行路径写错,整个标定直接中断
if (!img) break;这句话的意思是「读不到图就跳出整个循环」,而不是跳过这一帧。列表里只要有一张图被删了、路径有中文、或者大小写不对,程序会在那一行悄无声息地结束,后面所有帧都白采。
改法是把break换成continue并打印警告,同时在循环外用计数器记录实际读入的对数,低于 10 对就拒绝标定。另外,文件夹路径尽量用全英文,虽然 OpenCV 在多数平台能处理中文路径,但fgets读进来的字符串编码和cvLoadImage期望的编码未必一致,与其赌,不如避开。
5.3 现象:棋盘格检测总返回 0,换了角度也不行
先确认nx、ny填的是内角点数而不是方格数。一块 10×7 格的棋盘,内角点是 9×6——源码里StereoCalib("ch12_list.txt", 9, 6, 1)就是 9 列 6 行内角点。填成 10 和 7 的话,函数永远找不到。
再确认行列顺序:cvSize(nx, ny)里 nx 是列方向(水平)角点数。如果棋盘在画面里是竖着放的,nx 和 ny 要交换。判断方法是把displayCorners打开,加一个窗口把检测到的角点画出来,一眼就知道是数量不对还是顺序不对。此外,棋盘格必须留白边——四周没有白边的棋盘,外圈角点检测会不稳定,这是打印时最容易忽略的一条。
5.4 现象:标定能跑,但 remap 之后左右图行对不齐
先看squareSize。设成 1.f 的时候,T 的单位是「格」,如果你后面拿 T 去和实测基线比对,会发现数字对不上,从而误以为标定错了。真正的行对齐问题和这个无关,但要先把量纲排除掉。
真正的常见原因是畸变没去干净。Bouguet 路线里cvInitUndistortRectifyMap同时做了去畸变和旋转,理论上一步到位;但如果标定阶段用了CV_CALIB_ZERO_TANGENT_DIST而镜头实际有明显切向畸变,去畸变就是不完整的,残差会体现在行方向上。验证方法是拿几张标定集之外的图,remap 之后在左右图上画同一水平线,看同一物理点是否落在同一行——差超过 2 像素就得回头放开切向畸变重标。
还有一个只在上下堆叠双目上出现的问题:源码注释里写得很清楚,垂直双目且用标定校正时,图像不会被转置,极线是竖直的,而 BM 匹配只支持水平极线。这时要么把相机转 90 度装,要么在校正后手动转置图像再匹配。
5.5 现象:视差图整片发黑或者全是噪点
整片发黑,八成是numberOfDisparities太小或者minDisparity设错,真实视差落在搜索范围之外,全部被判无效。把disp归一化前后的最大值打印出来看看,如果最大值恰好等于numberOfDisparities,说明搜索范围被撑满了,得往上加。
全是噪点则通常是纹理问题:白墙、纯色桌面这种低纹理场景,textureThreshold = 10会把大片区域直接判无效,这其实是正确行为,不是 bug。真要在这类场景出图,就得换 SGBM 或者加结构光/投影纹理,BM 本身救不了。
6. 把标定结果落盘并做一次闭环验证
标定跑通只是第一步,真正上线要把 M1、D1、M2、D2、R、T、R1、R2、P1、P2 全部存下来,否则每次启动都重标一遍,既慢又不可复现。用cv::FileStorage存成 YAML,读写都方便:
// 保存:一次写全,避免上线时缺参数 cv::FileStorage fs("stereo_calib.yml", cv::FileStorage::WRITE); fs << "image_width" << imageSize.width; fs << "image_height" << imageSize.height; fs << "M1" << cv::Mat(3, 3, CV_64F, M1) << "D1" << cv::Mat(1, 5, CV_64F, D1); fs << "M2" << cv::Mat(3, 3, CV_64F, M2) << "D2" << cv::Mat(1, 5, CV_64F, D2); fs << "R" << cv::Mat(3, 3, CV_64F, R) << "T" << cv::Mat(3, 1, CV_64F, T); fs << "R1" << cv::Mat(3, 3, CV_64F, R1) << "R2" << cv::Mat(3, 3, CV_64F, R2); fs << "P1" << cv::Mat(3, 4, CV_64F, P1) << "P2" << cv::Mat(3, 4, CV_64F, P2); fs << "square_size_mm" << squareSize; // 量纲一起存,防止后人猜 fs.release(); // 加载:尺寸不匹配就直接拒绝,别硬算 cv::FileStorage in("stereo_calib.yml", cv::FileStorage::READ); int w, h; in["image_width"] >> w; in["image_height"] >> h; if (w != frame.cols || h != frame.rows) { throw std::runtime_error("calib resolution mismatch"); }存的时候把square_size_mm一起写进去,是我踩过坑之后加的习惯:半年前标的一套参数,回头要用时死活想不起T的单位是格还是毫米,只能重标。加载时校验分辨率同理,同一套参数拿去处理不同分辨率的图,initUndistortRectifyMap不会报错,只是默默给你一张歪的图。
闭环验证我一般做两件事。一是留出法:采集时预留 5 对标定集之外的图,用保存的参数跑一遍完整的 remap,然后在左右图上叠加等间距水平线,肉眼看同一特征点是否在同一行;差 1~2 像素可以接受,超过就得回头查。二是重投影:拿标定集里的角点,用 R、T、M、D 反投影回图像,算 RMS 误差,跟当初的avg err对得上,说明参数落盘没写错位。
还有个小技巧:把numberOfDisparities做成可配置项,按B·f/Z_min估出来再取 16 的整数倍,比拍脑袋填 128 靠谱。做完这套,同一套硬件换个场景重采一组图重标,十几分钟就能走完。
从那以后我每次标完双目,都会强制走一遍「存盘 → 重加载 → 预留图 remap 画线」这三步,哪怕当时急着出结果也不跳——跳过的那几次,无一例外都在一周后以「视差错得莫名其妙」的形式找回来。希望帮到你。
本文还有配套的精品资源,点击获取