简介:面向计算机视觉初学者与双目相机开发者的 OpenCV 立体标定与校正学习文档,聚焦双目摄像头内参、外参与畸变系数的估计,以及图像去畸变和极线校正的实现思路,适合正在做三维重建、测距或立体匹配项目、需要补齐标定流程的读者。压缩包内共 1 个 doc 文件,约 294KB,以文字讲解结合源码片段的方式梳理 StereoCalib 函数、棋盘格角点读取、stereoCalibrate 标定、undistort 去畸变、stereoRectify 校正与视差显示等关键环节,便于对照理解参数含义和调用顺序。目前已有 2595 人学习,可作为双目视觉入门与代码复现的参考材料,帮助读者快速建立从采集棋盘格图像到输出校正结果的整体认识,并在此基础上排查标定误差、理解内外参矩阵与校正映射的使用方式。
1. 两个相机夹在铝型材上,USB 线一插,程序跑出来一张糊成噪点的视差图
十有八九不是匹配算法的问题,是标定和校正没做对。这份双目视觉立体标定与校正代码干的就是这一步:喂一组棋盘格图进去,它把左右相机的内参、畸变系数、相机间的旋转平移、本征矩阵、基础矩阵全算出来,再把两幅图拉到同一组极线上,最后输出一张视差图给你验收。整份代码基于 OpenCV 的老式 C 接口写成,一个StereoCalib函数从读列表一路走到窗口显示,逻辑平铺、没有分层封装,正适合刚上手双目、想看清每个参数到底落在哪一行的人。它不是库,是要你拆开改的示例——看懂一条链路,比背十页对极几何公式管用。
2. 工程结构与数据流:一个函数吃下整条链路,先看清它手里的五份数据
整个工程只有一个.cpp,入口信息全压在StereoCalib这一个静态函数的签名里:imageList是图像清单文本的路径,nx、ny是棋盘格内角点在行、列两个方向的数量,useUncalibrated是路线开关(0 走标定结果做 Bouguet 校正,1 和 2 走 Hartley 的非标定校正)。没有类、没有配置解析、没有异常处理,所有中间结果要么是栈上的定长数组,要么是vector,CvMat只是给这些裸内存套一层头,好让 C 接口函数认得出形状和类型。这种写法在今天的 C++ 工程里显得土,但它有个好处:任何一个数据从哪来、到哪去,顺着函数往下读三屏就能看全,不用跳头文件。
落地第一件事不是读算法,是把这份代码放回一个能编过的环境里。它的函数调用全是cvFindChessboardCorners、cvStereoCalibrate、cvInitUndistortRectifyMap这类 C 风格名字,数据结构是IplImage*和CvMat,跟现在随手#include <opencv2/opencv.hpp>之后写cv::Mat的日常完全是两套东西。先把环境捋顺,后面每一步的参数才有意义。
2.1 头文件与编译环境:为什么这份代码在 OpenCV 4 上直接编不过
源码顶部的包含列表,信息量比看起来大:
#pragma warning( disable: 4996 ) // MSVC:放过 fopen/fgets 的"不安全"警告 #include "opencv2/opencv.hpp" // C++ 总头,提供 Mat、VideoCapture 等 #include <iostream> #include <stdio.h> #include <stdarg.h> #include <opencv/cv.h> // 1.x 时代的 C 接口图像处理 #include <opencv/cvaux.h> // 1.x 的辅助/实验模块 #include <opencv/cxcore.h> // 1.x 的核心数据结构 #include <opencv/highgui.h> // 1.x 的窗口与视频 IO这四个opencv/xxx.h是老分模块头,对应 OpenCV 1.x 的 C 接口。2.x 时期还留着兼容层,cvaux.h里装着立体匹配之类没完全定型的算法;到 3.x 这些内容被收进独立的 legacy 模块,4.x 默认根本不参与编译。所以把这份代码直接扔进新装的 OpenCV 4 环境,第一屏报错一定是cannot open source file "opencv/cvaux.h",而不是业务代码有问题。
两条路,各有代价。省事的一条是装一个 2.4 系列的老库,include 和 lib 配好就能编,缺点是IplImage这套接口在后续版本里没有未来,等于一开始就把自己锁死。我一般选另一条:把它改写成 C++ API。改写量没有想象中大,因为核心函数有一一对应关系——cvFindChessboardCorners对cv::findChessboardCorners,cvFindCornerSubPix对cv::cornerSubPix,cvStereoCalibrate对cv::stereoCalibrate,cvStereoRectify对cv::stereoRectify,cvInitUndistortRectifyMap对cv::initUndistortRectifyMap,CvStereoBMState那一套对cv::StereoBM::create。真正需要动脑的是CvMat包装vector内存这一段,换成cv::Mat之后行数、列数的组织方式要重排,属于重构而不是翻译。
配套的构建脚本我习惯写成这样,find_package一挂就能跟着机器上的 OpenCV 走:
cmake_minimum_required(VERSION 3.10) project(stereo_calib CXX) set(CMAKE_CXX_STANDARD 11) find_package(OpenCV REQUIRED) # 老代码请指向 2.4 系列的安装路径 include_directories(${OpenCV_INCLUDE_DIRS}) add_executable(stereo_calib stereo_calib.cpp) target_link_libraries(stereo_calib ${OpenCV_LIBS})在 Windows 上还想继续用 2.4 老库的话,配置完直接cmake -G "Visual Studio 15 2017" ..就能生成工程;关键是OpenCV_DIR要指到build目录下,指向源码根目录会找不到配置文件。编译通过只是万里长征第一步,先别急着跑,下一节的文件顺序问题会让你的标定结果从源头就废掉。
2.2 图像列表文件与左右交替约定:lr = i % 2决定了文件顺序
函数一进来就fopen(imageList, "rt"),逐行fgets,用行号的奇偶判断这一行是左图还是右图:
FILE* f = fopen(imageList, "rt"); if( !f ) { fprintf(stderr, "can not open file %s\n", imageList ); return; } for(i=0;;i++) { char buf[1024]; lr = i % 2; // 偶数行 = 左图,奇数行 = 右图 vector<CvPoint2D32f>& pts = points[lr]; if( !fgets( buf, sizeof(buf)-3, f ) ) break; size_t len = strlen(buf); while( len > 0 && isspace(buf[len-1]) ) buf[--len] = '\0'; // 去掉行尾 CR/LF if( buf[0] == '#' ) continue; // 注释行跳过 IplImage* img = cvLoadImage( buf, 0 ); // 0 = 强制灰度 if( !img ) break; // 任意一张读不到,整个流程直接结束 imageSize = cvGetSize(img); imageNames[lr].push_back(buf); ... }配套的列表文件长这样,一行一个路径,左右严格交替:
left01.jpg right01.jpg left02.jpg right02.jpg left03.jpg right03.jpgcvLoadImage的第二个参数传 0,意味着直接以灰度读入,省掉后面cvCvtColor的一次拷贝,也避免彩色图上做角点检测时三个通道各找一遍的浪费。行尾的空白字符必须手动清掉,从 Windows 文本文件里读出来的每一行都带\r,不清掉的话cvLoadImage找不到文件,img为 NULL,程序在break处静默退出,窗口都不弹一个,新手很容易以为是标定算法崩了。
这里有个特别隐蔽的地方:continue跳过注释行,但i++在for的第三段,跳之前已经执行过了。也就是说一条注释行同样消耗了一个行号,把后面所有图的左右配对整体翻转。左图被当成右图、右图被当成左图,cvStereoCalibrate照样能算出一组结果,只是那组结果毫无意义。我在项目里一般干脆禁掉列表文件的注释,或者把读取逻辑改成用独立的计数器idx递增,只对真正的图像行加一,注释行完全不碰idx。
2.3 五组容器与数据流:角点、物点、有效标记怎么对上号
读完图之后,函数手里攥着几组并行数组,理解它们的关系等于理解了整条数据流:
| 变量 | 类型 | 存什么 | 维度 |
|---|---|---|---|
imageNames[2] | vector<string> | 左/右图的文件路径 | 两个 vector,各 nframes 项 |
points[2] | vector<CvPoint2D32f> | 左/右图检出的角点像素坐标 | 各 nframes × nx × ny |
active[2] | vector<uchar> | 每张图是否检角成功(1/0) | 各 nframes 项 |
objectPoints | vector<CvPoint3D32f> | 棋盘格物理坐标 | nframes × nx × ny |
npoints | vector<int> | 每张图参与标定的点数 | nframes 项,初值全为 n |
points[lr]一开始是空的,检出角点后先resize(N + n)腾位置,再copy到尾部;如果某张图没检出,active里记 0,但那n个位置照样占着,填的是cvPoint2D32f(0,0)。最后传给cvStereoCalibrate的_npoints是每张图的有效点数,配合_objectPoints、_imagePoints1/2一起告诉函数"这一坨点按图怎么切"。所以哪怕中间有几张图废了,只要npoints和物点、像点的排布保持一致,标定依然能跑——前提是这三个数组的扩容节奏完全同步,任何一处漏掉都会让点和图错位,算出来的内参看上去正常、实则全歪。
CvMat的包装是这段里最容易被抄错的部分:
N = nframes * n; // 总点数 CvMat _objectPoints = cvMat(1, N, CV_32FC3, &objectPoints[0]); // 1×N 三通道 CvMat _imagePoints1 = cvMat(1, N, CV_32FC2, &points[0][0]); // 1×N 双通道 CvMat _imagePoints2 = cvMat(1, N, CV_32FC2, &points[1][0]); CvMat _npoints = cvMat(1, npoints.size(), CV_32S, &npoints[0]);cvMat(rows, cols, type, data)只做头,不拷贝数据,所以&objectPoints[0]必须连续有效。这几个矩阵的行数写成 1、列数写成总点数 N,是 C 接口"把多个图像的点串成一条长序列"的老约定,配合_npoints就能切回去。换成 C++ API 之后就不能这么写了,stereoCalibrate要的是vector<vector<Point3f>>,每个内层 vector 对应一张图,这是改写时第一个要动的地方。
顺带一提,函数里points[0].resize(N)和points[1].resize(N)在标定之后才出现,那是为了后面重算极线时让两个数组对齐到N。如果中间有废图,points的尾部会有未定义内容,那段avg err的统计就只能算个大概——这也是为什么很多人在有废图的批次上看到的误差数字忽大忽小。
3. 角点提取与立体标定:cvFindChessboardCorners到cvStereoCalibrate之间发生了什么
这一段是整份代码的心脏。从一张灰度图上找出一组棋盘格内角点的像素坐标,到把这些坐标连同物理坐标一起喂进cvStereoCalibrate解出内外参,中间有三处参数必须对得上:角点顺序、物点行列、以及标定 flag。顺序错一个,后面所有结果都是漂亮的错数。
3.1 粗定位:cvFindChessboardCorners的两个 flag 和maxScale放大策略
for( int s = 1; s <= maxScale; s++ ) { IplImage* timg = img; if( s > 1 ) // 只有 maxScale > 1 才会真的放大 { timg = cvCreateImage( cvSize(img->width*s, img->height*s), img->depth, img->nChannels ); cvResize( img, timg, CV_INTER_CUBIC ); } result = cvFindChessboardCorners( timg, cvSize(nx, ny), &temp[0], &count, CV_CALIB_CB_ADAPTIVE_THRESH | CV_CALIB_CB_NORMALIZE_IMAGE ); if( timg != img ) cvReleaseImage( &timg ); if( result || s == maxScale ) // 成功,或已经到最后一档 for( j = 0; j < count; j++ ) { temp[j].x /= s; // 放大过就要把坐标除回去 temp[j].y /= s; } if( result ) break; }cvSize(nx, ny)里的nx、ny是内角点数,不是格子数。一块 10×7 的黑白格棋盘,内角点是 9×6;写反了或者多写一个,函数返回 0,count保持原值,整张图被判为废图。这个错误极其常见,因为"棋盘格大小"这句话在中文语境里有歧义,很多资料说"10×7 的棋盘"指的是格子数。
两个 flag 的作用:CV_CALIB_CB_ADAPTIVE_THRESH让函数在二值化时用自适应阈值,光照不均、有暗角时比固定阈值稳;CV_CALIB_CB_NORMALIZE_IMAGE在检测前做一次直方图归一化,手机或工业相机自动曝光不一致时能救回来。两个都开,代价是慢一点。
maxScale在这份代码里是 1,也就是完全不做放大。这个策略适用于分辨率已经够高的图——棋盘格在图上占的像素够多,角点定位本身就准。真遇到低分辨率或者棋盘在画面里占得很小的场景,把它调到 2 甚至 3 有意义:放大后角点更容易被检出,代价是耗时翻几倍,而且temp[j] /= s这一步如果忘掉,所有坐标会被放大 s 倍,标定出来的焦距也跟着放大,肉眼看不出问题,一上真实场景就崩。
3.2 亚像素精化:11×11 窗口、30 次迭代、0.01 像素
粗定位给出的是整数像素级别的角点,做立体标定远远不够——两张图各差半个像素,重投影误差就够呛。所以检出成功的图还要再过一遍亚像素:
if( result ) { cvFindCornerSubPix( img, &temp[0], count, cvSize(11, 11), // 搜索半窗口 cvSize(-1,-1), // 死区:-1 表示不设 cvTermCriteria( CV_TERMCRIT_ITER + CV_TERMCRIT_EPS, 30, 0.01 ) ); // 最多 30 次,或精度到 0.01 像素 copy( temp.begin(), temp.end(), pts.begin() + N ); }第一个参数传的是原始img而不是放大后的timg,因为此时temp里的坐标已经被除回原图尺度了,用原图正好对上。窗口cvSize(11,11)指的是以角点为中心的半宽半高,实际搜索区域是 23×23,这是个经验值:窗口太小,噪声容易把角点拉偏;窗口太大,邻近角点会互相污染,尤其是棋盘格在图上比较密的时候。我一般在图上目视估一下角点间距占多少像素,窗口给到间距的三分之一左右比较稳。
cvSize(-1,-1)是零区(zeroZone),表示窗口中心那一块不参与梯度计算。设成负数就是禁用,实际用起来禁用居多,因为棋盘格角点本身就足够尖锐,不需要靠抹掉中心来避开平坦区域。
终止条件两个都用:CV_TERMCRIT_ITER + CV_TERMCRIT_EPS表示"迭代次数到 30 或者两次迭代位移小于 0.01 像素,哪个先到算哪个"。0.01 已经远小于一个像素,再往下抠基本是数值噪声。这里有个小细节:亚像素精化只对检角成功的图做,失败的图在pts里留的是全零占位,靠active和npoints标记出来。
3.3 物点构造:squareSize与nx、ny的行列顺序
objectPoints.resize(nframes * n); for( i = 0; i < ny; i++ ) // 外层是行 for( j = 0; j < nx; j++ ) // 内层是列 objectPoints[i*nx + j] = cvPoint3D32f( i*squareSize, j*squareSize, 0 ); for( i = 1; i < nframes; i++ ) // 第一幅的模板复制给其余所有图 copy( objectPoints.begin(), objectPoints.begin() + n, objectPoints.begin() + i*n );物点是一个平面的 Z=0 网格,间距squareSize。这个循环的两层顺序必须与cvFindChessboardCorners返回角点的排列顺序一致——它按行扫描,先走完一行再换行,也是行优先。两份顺序一旦相反,标定出来的内参矩阵不是对称阵,误差项会大得离谱,而且函数不会报错。
squareSize在这份代码里是1.f,也就是把棋盘格边长当成单位长度。这不影响内参的比值和归一化结果,只影响平移向量T的绝对尺度——T的单位会跟着变成"格"。要拿真实毫米数,就把它改成实测的格子边长,比如 25.0。我通常建议改,因为后面算基线长度、做三维重建都要真实单位,一开始就统一了省事。
复制模板给其余图这一步容易漏。nframes是active[0].size(),即左图张数,前提是左右图的张数一致。如果列表里左图比右图多一行,nframes还是按左图算,points[1]在越界访问时不会报错,只会读到垃圾值,最终表现为标定能跑完但结果荒谬。
3.4cvStereoCalibrate参数逐个对号,顺手聊聊三个 flag
cvSetIdentity(&_M1); cvSetIdentity(&_M2); // 内参初值给单位阵 cvZero(&_D1); cvZero(&_D2); // 畸变初值给零 cvStereoCalibrate( &_objectPoints, &_imagePoints1, &_imagePoints2, &_npoints, &_M1, &_D1, &_M2, &_D2, imageSize, // 只用于细化内参 &_R, &_T, &_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 );按顺序对号:_objectPoints是 N×3 的物点(这里写成 1×N 三通道),_imagePoints1、_imagePoints2是左右像点,_npoints是每幅图的点数。_M1、_D1、_M2、_D2是输入也是输出——初始给单位阵和零,函数在这基础上优化出左右内参和畸变系数。imageSize只在需要细化内参时用得上。_R、_T是左相机到右相机的旋转和平移,_E是本征矩阵,_F是基础矩阵,都是输出。
三个 flag 的取舍值得说清楚:
CV_CALIB_FIX_ASPECT_RATIO:锁定fx/fy的比例。绝大多数工业相机和手机模组的感光元是方形像素,这个假设成立,锁住能少一个自由度,样本少的时候更稳。但如果用的是模拟相机或者做过非等比缩放的图,锁了反而引入系统偏差。CV_CALIB_ZERO_TANGENT_DIST:切向畸变p1、p2直接置零。现代镜头装配精度足够,切向畸变通常很小,锁零能显著减少所需样本数。老镜头、有明显装配倾斜的模组就别锁。CV_CALIB_SAME_FOCAL_LENGTH:假设左右相机焦距完全相同。同一批次、同一型号、同一焦距设置下成立;两个不同镜头拼起来的双目系统,这个 flag 必须去掉,否则解出来的焦距会强行拉平,重投影误差被摊到别处。
终止条件 100 次迭代、1e-5 的精度,比角点那步严得多。标定是全局优化,收敛慢,给宽一点合理。
3.5 极线自检:avg err这个数字怎么读
标定跑完,函数自己会做一次体检,思路是把所有点变换到无畸变空间,用基础矩阵算出对极线,再看每个点到对应对极线的距离:
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( 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) );ax + by + c是极线的直线方程,把点代进去取绝对值再归一化,就是点到直线的距离。左右各算一次再相加,除以总点数。理想情况下这个值应该接近 0。
经验阈值:小于 1 像素算很好,说明内参、外参、畸变系数互相自洽;1 到 3 之间可用,多半是角点定位精度或者样本覆盖不够;超过 5 就别往下做了,先回头查数据。最常见的三个原因——棋盘格没有覆盖到画面四角(畸变系数靠边缘的样本才定得准)、拍摄角度太单一(全是正对镜头的图,外参退化)、左右图配对错位(就是 2.2 节那个注释行的问题)。这个数字是整份代码里最有价值的反馈,别跳过。
4. 校正与视差:cvStereoRectify之后的两张映射表和一块 BM 状态机
标定给出的是数学关系,校正要把它变成两张查找表。这一步的目标很直观:把左右两幅图重映射到同一个虚拟相机平面上,让同名点落在同一行像素上。做完之后,找匹配点只需要在水平方向扫描,搜索空间从二维降到一维。这份代码给了两条路线,一条依赖前面算出来的内外参,一条绕开它直接吃基础矩阵。
4.1 Bouguet 路线:R1、R2、P1、P2 与两张映射表
double R1[3][3], R2[3][3], P1[3][4], P2[3][4]; CvMat _R1 = cvMat(3, 3, CV_64F, R1); CvMat _R2 = cvMat(3, 3, CV_64F, R2); if( useUncalibrated == 0 ) { CvMat _P1 = cvMat(3, 4, CV_64F, P1); CvMat _P2 = cvMat(3, 4, CV_64F, P2); cvStereoRectify( &_M1, &_M2, &_D1, &_D2, imageSize, &_R, &_T, &_R1, &_R2, &_P1, &_P2, 0, 0 /* 不启用 CV_CALIB_ZERO_DISPARITY */ ); isVerticalStereo = fabs(P2[1][3]) > fabs(P2[0][3]); // 判断是不是上下堆叠 cvInitUndistortRectifyMap( &_M1, &_D1, &_R1, &_P1, mx1, my1 ); cvInitUndistortRectifyMap( &_M2, &_D2, &_R2, &_P2, mx2, my2 ); }cvStereoRectify的输出有四个:R1、R2是把左右相机各转到一个共同平面的旋转矩阵,P1、P2是新的投影矩阵。共同平面怎么选、图像怎么裁剪,全靠最后一个 flags 参数。这里传 0,意味着不做零视差对齐,主点不强制挪到同一水平位置,结果就是视差可能为负——所以后面 BM 的minDisparity设成 -64,这个负值是配套的,不是拍脑袋。
isVerticalStereo那行用P2的平移分量判断相机的物理排布是左右还是上下。左右排布时基线主要在 x 方向,P2[0][3]明显;上下排布时 y 方向分量大。这个判断影响后面拼接显示和视差计算的分支,摆反了会出现"校正后极线是竖的"这种诡异画面。
cvInitUndistortRectifyMap生成的是两张浮点映射表,尺寸等于图像尺寸。之后每来一帧,cvRemap照着表逐像素采样就行,不用重算畸变模型和旋转。这是性能上最关键的优化:一个 1280×720 的图,重算一遍畸变要几十毫秒,查表只要一两毫秒。
4.2 Hartley 路线:没有可靠标定结果时的兜底
useUncalibrated等于 1 或 2 时走另一条路。它的前提不一样:不再信任R、T,直接从像点里估一个基础矩阵,用纯几何的方式算校正变换。
else if( useUncalibrated == 1 || useUncalibrated == 2 ) { 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 ) // 模式 2:自己重算 F cvFindFundamentalMat( &_imagePoints1, &_imagePoints2, &_F ); cvStereoRectifyUncalibrated( &_imagePoints1, &_imagePoints2, &_F, imageSize, &_H1, &_H2, 3 ); cvInvert( &_M1, &_iM ); cvMatMul( &_H1, &_M1, &_R1 ); cvMatMul( &_iM, &_R1, &_R1 ); // R1 = M1^-1 * H1 * M1 cvInvert( &_M2, &_iM ); cvMatMul( &_H2, &_M2, &_R2 ); cvMatMul( &_iM, &_R2, &_R2 ); // R2 = M2^-1 * H2 * M2 cvInitUndistortRectifyMap( &_M1, &_D1, &_R1, &_M1, mx1, my1 ); cvInitUndistortRectifyMap( &_M2, &_D1, &_R2, &_M2, mx2, my2 ); // 注意这里 }cvStereoRectifyUncalibrated的最后一个参数 3 是外点阈值,单位像素,超过这个距离的匹配点不参与估计。H1、H2是直接作用在齐次像素坐标上的单应矩阵,要转成旋转矩阵才能喂给cvInitUndistortRectifyMap,所以有了那两步H * M再左乘M^-1。
Hartley 路线的适用场景很明确:相机内参不可信、或者根本没有标定过、手上只有一对对极几何还算干净的图。它的缺点也明显——只保证极线水平,不保证图像形状规整,容易出现明显的桶形拉伸或梯形变形,视差图的边缘会更糟。
注意:Hartley 分支第二行
cvInitUndistortRectifyMap传的畸变是&_D1,不是&_D2。照抄进项目里,右图会套着左图的畸变模型去校正,画面看着"差不多对"但误差一直在。这行改成&_D2就行。
这两个分支还有一个共同点:cvInitUndistortRectifyMap的最后一个参数(新相机矩阵)在 Bouguet 路线里是P1、P2,在 Hartley 路线里退化成M1、M2。区别在于前者会做裁剪和缩放,后者保持原内参、只旋转,所以 Hartley 结果的视野更完整,但图像尺寸里的有效区域形状更怪。
4.3 StereoBM 的六个参数与实际手感
CvStereoBMState *BMState = cvCreateStereoBMState(); assert( BMState != 0 ); BMState->preFilterSize = 41; // 预处理窗口,必须奇数 BMState->preFilterCap = 31; // 预处理结果截断阈值 BMState->SADWindowSize = 41; // 匹配代价窗口,必须奇数 BMState->minDisparity = -64; // 最小视差,呼应前面没做零视差对齐 BMState->numberOfDisparities = 128; // 视差搜索范围,必须是 16 的倍数 BMState->textureThreshold = 10; // 低于此纹理强度的区域不算视差 BMState->uniquenessRatio = 15; // 最优与次优的差值比例,抑制误匹配逐个说手感:
SADWindowSize是影响最大的一项。41 属于偏大的窗口,好处是噪声图、弱纹理区域更稳,坏处是物体边缘会"糊"出去,细小的前景会被背景吞掉。常规建议 5 到 21,快接近 41 的时候说明图本身质量或者校正质量可能有问题。numberOfDisparities决定最大可测视差,等于 16 的倍数。它和基线、焦距、最近测距之间是硬关系:搜得不够,近处物体算出无效值;搜得太宽,计算量翻倍,还可能引入周期性误匹配。先用理论公式估一个下限,再加一档冗余。minDisparity负值是为了接住前面cvStereoRectify没做零视差对齐带来的负视差。如果把cvStereoRectify的 flags 改成CV_CALIB_ZERO_DISPARITY,这里就该调成 0,两处必须一起改,单独改一处会出现整张视差图缺失一半。preFilterSize、preFilterCap是一组,前者做窗口归一化,后者限制归一化后的动态范围。图像对比度低就调大 cap,噪声大就调大 size。41 和 31 这一对在 640×480 上有不错的效果,换到 1280 以上分辨率要相应放大。textureThreshold是"没纹理就别算"的开关。墙面、天空这类区域梯度小,算出来的视差纯靠猜,设个阈值让它们直接归零,比事后滤波更省事。uniquenessRatio是防误匹配的最后一道闸。最优匹配的代价要明显优于次优,否则认为不可靠。15 算中等偏严,对重复纹理(比如瓷砖、栅栏)要调大,对弱纹理场景要调小才不至于大片空洞。
4.4 拼接、画横线、归一化
if( !isVerticalStereo ) pair = cvCreateMat( imageSize.height, imageSize.width*2, CV_8UC3 ); else pair = cvCreateMat( imageSize.height*2, imageSize.width, CV_8UC3 ); for( i = 0; i < nframes; i++ ) { IplImage* img1 = cvLoadImage( imageNames[0][i].c_str(), 0 ); IplImage* img2 = cvLoadImage( imageNames[1][i].c_str(), 0 ); if( !img1 || !img2 ) continue; cvRemap( img1, img1r, mx1, my1 ); // 左图按表重映射 cvRemap( img2, img2r, mx2, my2 ); // 右图按表重映射 cvFindStereoCorrespondenceBM( img1r, img2r, disp, BMState ); cvNormalize( disp, vdisp, 0, 256, CV_MINMAX ); cvShowImage( "disparity", vdisp ); cvGetCols( pair, &part, 0, imageSize.width ); cvCvtColor( img1r, &part, CV_GRAY2BGR ); cvGetCols( pair, &part, imageSize.width, imageSize.width*2 ); cvCvtColor( img2r, &part, CV_GRAY2BGR ); for( j = 0; j < imageSize.height; j += 16 ) // 每隔 16 行画一条参考线 cvLine( pair, cvPoint(0,j), cvPoint(pair->width,j), cvScalar(0,255,0) ); }这段代码最实用的地方是那个每 16 行一条的水平参考线。校正做对了,绿线会精确穿过左右两图中的同名点——比如棋盘格的同一个角,在左半图和右半图里都落在同一条绿线上。校正没做对,绿线会穿过左图的角点、却从右图角点上方飘过去,偏多少像素一目了然。这比看avg err数字直观得多,我到现在还习惯保留这个窗口当验收手段。
cvNormalize(disp, vdisp, 0, 256, CV_MINMAX)把 16 位有符号的视差值拉伸到 0 到 255,纯粹为了可视化,真实视差要用disp原值。很多人把它当数据处理,后面算深度时拿vdisp去乘系数,结果深度全错,这是新手期最容易犯的一个错。
5. 避坑与排查:五个让标定结果直接作废的坑
这份代码跑起来不难,跑出可信结果不容易。下面五条都是我或身边人在真实项目里踩过的,按"现象—原因—解决"排,遇到问题时可以当检查表顺着过。
5.1 角点阶段的坑
一、列表文件里写了注释行,左右图整体错位
现象:程序跑完了,avg err大得离谱(几十甚至上百),视差图基本全黑或者一片噪声,但没有任何报错。
原因:for(i=0;;i++)里lr = i % 2在continue之前执行,注释行同样消耗了一个行号,后面的左右配对全部翻转。而cvStereoCalibrate面对一组"左图对左图"的数据不会报错,它会把两个镜头的差异当成外参解出来,得到一个看起来收敛的荒谬结果。
解决:列表文件里不放注释行;或者改读取逻辑,单独维护一个int idx = 0,只在真正读到图像路径时idx++,用idx % 2判定左右。改完之后加一句打印,把每个文件归到哪一侧输出出来,肉眼过一遍最省心。
二、nx、ny写成格子数而不是内角点数
现象:cvFindChessboardCorners对所有图都返回 0,active全是 0,最后avg err是 `
本文还有配套的精品资源,点击获取