
双目相机标定这事听起来学术味很重我第一次接触的时候也以为要啃一堆矩阵推导。实际上它就是给两只眼睛配眼镜装了两个镜头做深度感知第一步必须先搞清楚每个镜头自身的焦距、光心、畸变是多少两个镜头之间的相对位置又是怎样的。这两个问题不解决后面算视差、生成3D点云都是在沙地上盖楼。标题里说5分钟搞懂我的理解是花5分钟把整个流程和原理框架过一遍心里有张地图实操时每一步都知道自己在干什么、为什么这么干。真正跑通需要多久看你对OpenCV熟不熟快的话一个下午能出点云慢的话卡在环境问题上也是常有的事。这篇文章我会把从棋盘格打印、拍照采集、角点检测、双目标定、极线校正到立体匹配和点云生成的完整流程走一遍附上我调试通过可复现的Python代码适合想入门双目视觉、做机器人抓取或三维重建的新手朋友参考。1. 先把原理拆开双目标定到底在算什么1.1 相机模型的三个关键问题要理解标定先得知道相机成像这件事被我们简化成了什么模型。最常用的是针孔模型三维空间里一个点通过镜头光心投到图像平面上形成一个像素。这个过程可以分解成两个步骤对应标定要解决的三组未知数。第一步是相机内部的映射。三维点从相机坐标系投影到像素坐标系由内参矩阵决定K [fx, 0, cx, 0, fy, cy, 0, 0, 1]这里的fx、fy表示焦距在像素单位下的值cx、cy是光心在图像上的位置。可以通俗理解为镜头有多放大fx、fy图像中心点在哪cx、cy。这套参数只跟相机自身有关换一个镜头就得重新标。第二步是镜头畸变。真实镜头不是理想针孔光线穿过镜片会弯折导致直线成像后变弯尤其是画面边缘。畸变模型通常用五个系数描述径向畸变k1、k2、k3切向畸变p1、p2。径向畸变是鱼眼那种桶形或枕形变形切向畸变是镜头装配不平行导致的倾斜畸变。第三步是两个相机之间的关系。双目标定要额外求解两个镜头坐标系之间的旋转矩阵R和平移向量T。R描述了两个相机谁相对谁偏了多少角度T描述了两个相机之间的基线距离这个基线长度直接决定了深度感知的范围和精度。1.2 为什么标定是3D重建的前提省不掉在我见过的一些新手项目里有人跳过标定直接做SGBM立体匹配结果发现视差图全是噪声、点云完全没法看。原因很简单立体匹配算法默认左右图像已经对齐到只有水平方向的视差差异同时每个像素的深度是借助相机参数反算出来的。如果你不给算法准确的参数它拿什么算我把标定比作体检。你得先知道自己的视力是多少度才能配出合适的眼镜你得知道两眼之间的距离瞳距眼镜才能对准。双目系统也一样内参相当于度数基线T相当于瞳距。从工程上看标定的质量决定了深度精度的上限。标定误差大哪怕立体匹配做得再好三角测量算出来的3D坐标也会有系统性的偏差。这也是为什么很多视觉团队愿意花大量时间在标定环节而不是急着去调匹配算法参数。2. 棋盘格准备与角点检测前戏做足后面才爽2.1 打印棋盘格的讲究与照片采集姿势棋盘格标定板是行业内最常用的标定工具主要原因是它的角点特征稳定、检测算法成熟。但我在实操中见过很多同学在这步翻车所以多说几句。第一选择内角点数。OpenCV的findChessboardCorners需要你传入棋盘格的内角点数不是格子数。比如规格9x6指的是横向有9个内角点、纵向有6个内角点对应棋盘格是10x7个格子。我强烈建议用不对称的尺寸比如9x6或者11x8不要用正方形棋盘格比如9x9。为什么因为正方形棋盘格在旋转90度后特征完全相同标定时方向约束会出问题极线校正后两个相机之间可能对不上。第二打印的方格边长要精确测量。常见做法是A4纸打印方格边长比如30mm但打印出来可能有误差用游标卡尺量一下实际边长以毫米为单位写进代码。这个数值会作为世界坐标系的尺度直接影响点云的尺寸是否真实。如果你只是看形状不看真实尺寸可以放松些但如果要测物体尺寸这一步必须较真。第三采集姿势比数量更重要。我一般采集20到30对图像关键不是拍得多而是覆盖足够多的姿态标定板在画面中心、四个角落、四条边中间都要有倾斜角度要丰富前后俯仰、左右旋转都要拍距离要有近有远比如30cm到1m之间变化左右相机必须同时看到完整棋盘格关于同时最稳妥的做法是把两个相机固定好用视频同步录制然后抽取包含棋盘格的帧。如果两个相机没有硬件同步那就在拍摄时保证标定板静止不动先左拍一张立刻右拍一张。棋盘格动了左右帧不对应角点检测出来也是错位的标定结果自然不对。另外标定板最好贴在硬纸板或泡沫板上保证平整。我用过直接手拎着A4纸的操作纸面一弯曲角点检测其实还能检测到但世界坐标里那些角点已经不在同一个平面上了标出来的结果会有偏差很难看出来。2.2 角点检测代码与不合格角点的剔除策略先上一段我在项目里常用的检测代码。它做的事情是遍历左右相机所有图片用findChessboardCorners找角点再用cornerSubPix把角点精度提到亚像素级别。import cv2 import numpy as np import glob chessboard_size (9, 6) # 内角点数 square_size 30.0 # 方格边长单位mm criteria (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) # 每张图对应的世界坐标点z始终为0 objp np.zeros((chessboard_size[0] * chessboard_size[1], 3), np.float32) objp[:, :2] np.mgrid[0:chessboard_size[0], 0:chessboard_size[1]].T.reshape(-1, 2) objp * square_size def detect_corners(image_path): img cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) ret, corners cv2.findChessboardCorners(img, chessboard_size, None) if ret: corners cv2.cornerSubPix(img, corners, (11, 11), (-1, -1), criteria) return img, ret, corners left_images sorted(glob.glob(left/*.jpg)) right_images sorted(glob.glob(right/*.jpg)) obj_points, img_points_left, img_points_right [], [], [] valid_pairs [] for lpath, rpath in zip(left_images, right_images): imgL, retL, cornersL detect_corners(lpath) imgR, retR, cornersR detect_corners(rpath) if retL and retR: obj_points.append(objp) img_points_left.append(cornersL) img_points_right.append(cornersR) valid_pairs.append((lpath, rpath))这里有个容易被忽略的细节findChessboardCorners返回的角点顺序是固定的从左到右、从上到下这保证了左右图中同一角点对应同一个世界坐标点。如果你的图像旋转了或者棋盘格方向翻转了这个顺序就可能对不上这是后边标定结果差的一个重要隐患。代码里我直接检测左右图都成功才保留这对图像。这是最基础的剔除策略。但实际还有一类不合格角点findChessboardCorners检测成功了但某些角点的定位偏差很大肉眼还看不出来。我的经验是先跑一轮单目标定计算每组图的重投影误差把误差大的图像对剔除。重投影误差的意思是一组棋盘格角点用当前标定出的参数投影回图像后和实际检测到的角点像素坐标之间的距离。通常要求平均重投影误差小于0.5个像素如果某张图的误差超过1个像素基本可以断定这张图采集姿势有问题或者存在运动模糊建议直接删掉。3. 双目标定核心代码从内参到极线校正3.1 单目标定先把两只眼睛度数配好有了角点对应的数据先分别对左右相机做单目标定。这一步求解每个相机自己的内参矩阵、畸变系数以及每个视图的外参旋转向量和平移向量。retL, mtxL, distL, rvecsL, tvecsL cv2.calibrateCamera( obj_points, img_points_left, img_shape[::-1], None, None ) retR, mtxR, distR, rvecsR, tvecsR cv2.calibrateCamera( obj_points, img_points_right, img_shape[::-1], None, None ) print(左目重投影误差: , retL) print(右目重投影误差: , retR) print(左目内参: \n, mtxL) print(右目内参: \n, mtxR)calibrateCamera返回的第一个值是整体重投影误差单位是像素。一般来说小于0.3就算很好0.5以内可以接受超过1就得回去检查数据了。误差大优先检查有没有角点检测失败但没被剔除的图。内参矩阵里最需要注意的是fx和fy的差异。正常情况下两者接近如果相差很大可能意味着图像被不均匀拉伸或者镜头本身有问题。cx、cy理想情况应该接近图像分辨率的一半如果偏离太多可能是图像被裁剪过也可能是标定板的图像没有覆盖好整个画面。我在实际项目中习惯把单目标定得到的mtxL、distL、mtxR、distR保存下来方便后期调试立体匹配参数时不用重新标定。3.2 双目标定求两相机相对位姿R和T双目标定输入左右相机的内参和畸变系数输出旋转矩阵R和平移向量T。这里有个常用flags组合CALIB_FIX_INTRINSIC表示固定单目标定的内参不变只优化R、T。如果你觉得单目结果已经很好了用这个标志就够了如果你希望双目标定过程连内参一起再优化一遍可以去掉这个flag用CALIB_USE_INTRINSIC_GUESS把单目结果作为初值。我个人的习惯是先在固定内参模式下跑一轮看双目标定的重投影误差能不能接受如果误差明显偏大再放开内参优化。flags cv2.CALIB_FIX_INTRINSIC retS, mtxL, distL, mtxR, distR, R, T, E, F cv2.stereoCalibrate( obj_points, img_points_left, img_points_right, mtxL, distL, mtxR, distR, img_shape[::-1], flagsflags, criteria(cv2.TERM_CRITERIA_MAX_ITER cv2.TERM_CRITERIA_EPS, 100, 1e-5) ) print(双目标定重投影误差: , retS) print(旋转矩阵 R: \n, R) print(平移向量 T: \n, T)这里每次我都会认真看T向量的x分量它代表基线长度。比如两个相机横向放置T的x分量大小应该和实际用尺子量的镜头光心距离大致匹配。如果差异巨大比如实际距离50mm标定出来T的x只有2mm多半是标定板尺寸单位或者图像输入有问题。有一点容易踩坑OpenCV版本不同stereoCalibrate的返回值数量可能有差异。网上老代码经常解包成ret, K1, D1, K2, D2, R, T, E, F新版本在某些配置下可能返回更多值。稳妥做法是先用Python打印一下len(cv2.stereoCalibrate(...))看看到底返回几个值再解包。3.3 极线校正让左右图行对齐双目标定完成之后不是直接拿原始图去做立体匹配而是先做极线校正。这一步的物理意义是让左右两个图像平面经过重投影变成完全平行且行对准的理想状态。理想状态下左图中的一个点在右图中只出现在同一行上这样立体匹配算法只需要在水平方向搜索对应点从二维搜索问题降成一维搜索既提高速度又减少误匹配。R1, R2, P1, P2, Q, roi1, roi2 cv2.stereoRectify( mtxL, distL, mtxR, distR, img_shape[::-1], R, T, flagscv2.CALIB_ZERO_DISPARITY, alpha0 ) mapLx, mapLy cv2.initUndistortRectifyMap( mtxL, distL, R1, P1, img_shape[::-1], cv2.CV_32FC1 ) mapRx, mapRy cv2.initUndistortRectifyMap( mtxR, distR, R2, P2, img_shape[::-1], cv2.CV_32FC1 ) rectL cv2.remap(imgL_color, mapLx, mapLy, cv2.INTER_LINEAR) rectR cv2.remap(imgR_color, mapRx, mapRy, cv2.INTER_LINEAR)stereoRectify输出的Q矩阵很关键它是一个4x4的重投影矩阵后面从视差图到3D点云全靠它。CALIB_ZERO_DISPARITY标志让主点在校正后保持一致这样Q矩阵会简化一些我一般都会加上。alpha参数控制校正后图像的裁剪程度。设为0时OpenCV会尽量去掉黑色空白边缘图像会被裁剪掉一部分设为1时保留所有原始像素。如果只是做深度估计alpha0没问题如果还需要和原始图像对齐做后续视觉处理可以调大一点。实拍校正效果怎么看在两张校正图上画一条水平线观察同一个物理点在左右图中的位置是否落在同一行。也可以用OpenCV的cv2.drawMatches把校正后的左右图画出来连几条线直观确认。我测试时有个习惯校正前先看一眼roi1和roi2如果有效区域特别小说明双目系统安装角度太偏或标定质量不佳这时候优先回去检查标定数据而不是继续往下走。4. 从视差图到3D点云立体匹配与重投影4.1 SGBM立体匹配的参数和经验值极线校正完成后左右图的行已经对齐了这时做立体匹配就是逐像素找左右图上的对应点。对应点之间水平位置的差值就是视差disparity。离相机越近的物体视差越大越远视差越小。OpenCV里最常用的算法是SGBM半全局块匹配。说它半全局是因为它既考虑局部窗口的灰度匹配代价又用动态规划的方式在多个方向上聚合代价在精度和速度之间取得了不错的平衡。代码和参数如下block_size 11 stereo cv2.StereoSGBM_create( minDisparity0, numDisparities16 * 6, blockSizeblock_size, P18 * 3 * block_size ** 2, P232 * 3 * block_size ** 2, disp12MaxDiff1, uniquenessRatio10, speckleWindowSize150, speckleRange32, modecv2.STEREO_SGBM_MODE_SGBM ) disparity stereo.compute(rectL, rectR).astype(np.float32) / 16.0这几个参数干的事我给新手朋友翻译一下numDisparities允许匹配的视差范围必须是16的倍数。范围越大能感知的近距离物体越多但计算量成倍增加且容易误匹配。我一般从16x4开始调看直方图和深度效果再加减。blockSize匹配窗口边长奇数常用3到21。窗口越大视差图越平滑但细节丢失越严重物体边缘容易出现毛刺。P1和P2平滑惩罚系数。P1用于相邻像素视差变化1个像素时的惩罚P2用于变化更多的惩罚。P2一般设为P1的4到5倍。一个经验公式是P1 8 * 通道数 * blockSize^2P2 32 * 通道数 * blockSize^2灰度图通道数取3效果更好。disp12MaxDiff左右一致性检查的阈值。左右图各算一次视差差异超过这个值的像素被认为不可靠置为无效值。设1或2比较稳妥。uniquenessRatio唯一性比例。值越大匹配结果越挑剔误匹配少但空洞多通常在5到15之间。speckleWindowSize和speckleRange滤波去斑。视差图里那些小噪点区域会被当成斑点抹掉。调参没有银弹我的建议是先固定其他参数只调numDisparities和blockSize把整体深度层次调出来再调P2让边缘更干净最后用唯一性约束和斑点滤波清理噪点。更进一步如果安装了opencv-contrib-python推荐用WLS滤波对原始视差做后处理。它基于左侧图和右侧一致性信息平滑视差的同时尽量保留边缘效果立竿见影import cv2.ximgproc as ximgproc right_matcher cv2.ximgproc.createRightMatcher(stereo) wls_filter cv2.ximgproc.createDisparityWLSFilter(stereo) disparity_left stereo.compute(rectL, rectR).astype(np.float32) / 16.0 disparity_right right_matcher.compute(rectR, rectL).astype(np.float32) / 16.0 disparity_filtered wls_filter.filter( disparity_left, rectL, disparity_map_rightdisparity_right )WLS滤波后的视差图会平滑很多但要注意它也会抹掉一些极细小的结构。做高精度测量时宁可保留噪声也不要过度平滑。4.2 重投影生成3D点云并导出PLY有了视差图配合标定阶段得到的Q矩阵就可以用一行代码把像素坐标加视差值转换成相机坐标系下的三维坐标points_3d cv2.reprojectImageTo3D(disparity_filtered, Q)Q矩阵的含义可以理解为完成从像素视差到三维坐标的映射。它的具体形式来自stereoRectify的输出其中包含了基线长度T、焦距f、光心坐标等经过校正后的参数。重投影得到的三维坐标系以左相机光心为原点x轴向右y轴向下z轴指向相机前方。拿到points_3d之后还需要做两步清洗。第一步是去除无效点视差值无效的像素通常是黑色空洞区域重投影出来的z坐标可能是极大值或无穷大。第二步是限制有效深度范围把太远或太近的噪点去掉。我这里写了一个简单的导出PLY文件的函数可以直接用MeshLab或CloudCompare打开查看点云def filter_points(points_3d, disparity, img_color, min_z100, max_z3000): mask (disparity disparity[disparity 0].min()) | (disparity 0) z points_3d[:, :, 2] mask (z min_z) (z max_z) np.isfinite(z) pts points_3d[mask] colors img_color[mask] return pts, colors def write_ply(filename, points, colors): with open(filename, w) as f: f.write(ply\n) f.write(format ascii 1.0\n) f.write(felement vertex {len(points)}\n) f.write(property float x\n) f.write(property float y\n) f.write(property float z\n) f.write(property uchar red\n) f.write(property uchar green\n) f.write(property uchar blue\n) f.write(end_header\n) for p, c in zip(points, colors): f.write(f{p[0]:.3f} {p[1]:.3f} {p[2]:.3f} {int(c[0])} {int(c[1])} {int(c[2])}\n) pts, colors filter_points(points_3d, disparity_filtered, rectL) write_ply(output.ply, pts, colors)这里有一个坐标系方向要提醒OpenCV的相机坐标系z轴朝前y轴向下。如果直接把点云丢到三维软件里会看到模型是倒的绕x轴旋转180度就能纠正。很多新手第一次看到点云会怀疑自己标定错了其实就是这个坐标系方向的问题。另外如果你不需要可视化只想拿深度图可以直接用points_3d[:, :, 2]取z值。z值就是每个像素到相机光心平面的距离对于做避障、测距的工程完全够用。5. 常见问题与排查技巧实录5.1 标定阶段角点检测失败和RMS过大先说角点检测失败。findChessboardCorners返回False首先检查chessboard_size是不是写错了。我在项目里因为行列写反浪费过一下午9x6和6x9如果写反检测大概率失败。其次检查图像是不是太模糊手持拍摄容易运动模糊角点提取会失败。还有一种是标定板太远角点像素间距太小算法也容易失手。我的经验是画面里棋盘格边长至少要有15个像素以上。RMS过大比如超过1像素优先怀疑有没有脏数据混进来。一个实用的办法是像我前面说的那样把每张图的重投影误差打印出来单独看哪几张误差大然后删掉重跑。注意这里不是让你反复删到所有误差都变小而是删除那些明显异常的数据。删太多会导致姿态覆盖不够一般保留15对以上即可。还有一种情况双目标定的RMS比单目标定明显大很多。这通常说明左右图的角点并没有对应到同一个物理点上常见原因是我上面提到的左右图拍摄时标定板动了。解决办法是回到采集环节确保拍摄时标定板静止或者用同步视频抽帧。5.2 匹配和点云阶段视差图噪声多点云翘曲视差图大量黑色空洞首选检查numDisparities是否太小物体近处视差超过这个范围就会变成空洞。再调disp12MaxDiff设成0会导致很多像素过不了左右一致性检查设成2或4会好一些。点云出现明显的斜面或翘曲比如一面墙点云是凹的或凸的这几乎可以断定是在极线校正环节出了问题或者标定板的平整度太差。回头检查stereoRectify参数里的alpha和CALIB_ZERO_DISPARITY标志是否一致另外看一看校正后的图像行对齐效果。如果行对齐明显歪斜就是R、T标定不准。这个时候重跑一遍双目标定或者检查是不是内参固定错了。如果点云上有一层雾状的稀疏噪点多半是视差图的原生噪声导致的。加大uniquenessRatio或者把min_z和max_z范围收紧一点能有效减少这种飞点。5.3 环境与版本最常见也最容易被卡的几个坑运行环境问题很多读者会卡在import cv2报错或者没有cv2.ximgproc。这里统一说一下pip安装建议一次装全pip install opencv-python opencv-contrib-python numpycv2.ximgproc在opencv-contrib-python里只装opencv-python会没有。版本上我在不同机器上遇到过SGBM API写法的差异比如新版本中cv2.StereoSGBM_create的mode参数推荐用cv2.STEREO_SGBM_MODE_SGBM_3WAY效果更好但老版本不一定支持。如果你用的是OpenCV 4.x直接用我上面代码里的参数即可。还有一个小坑图像读取路径别带中文名OpenCV的imread对中文路径支持不友好会静默返回None然后整个程序报错在莫名其妙的地方。所有图像路径统一用英文/数字命名。至于Python环境本身现在Anaconda或者虚拟环境都很成熟我的建议是给视觉项目单独建一个环境避免和其他项目互相污染依赖。版本尽量用Python 3.8到3.11太新的Python版本可能有些预编译OpenCV轮子还没跟上。我个人在实际操作中最大的体会是标定这步确实枯燥但它决定了整个系统能走多远。你可以在标定上省时间后面就得在调参和修数据上加倍还回来。那批我当年随手拍的差不多的棋盘格照片后来让我整整排查了两天最后发现是标定板没贴平。先把棋盘格贴平、把照片拍全这套流程跑下来其实是很有成就感的——尤其是第一次在MeshLab里看到自己相机拍出来的物体变成3D点云的那一刻。最后再分享一个小技巧标定结果和点云效果别只看数字把校正后的左右图直接存成视频逐帧看行对齐是否稳定、视差边缘是否锐利一眼就能看出系统状态。这个习惯我一直用到现在。