双目相机标定全流程详解:从棋盘格到SGBM立体匹配与点云生成
2026/9/19 14:35:29 网站建设 项目流程

双目相机标定这事,听起来学术味很重,我第一次接触的时候也以为要啃一堆矩阵推导。实际上它就是给两只眼睛"配眼镜":装了两个镜头做深度感知,第一步必须先搞清楚,每个镜头自身的焦距、光心、畸变是多少;两个镜头之间的相对位置又是怎样的。这两个问题不解决,后面算视差、生成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], flags=flags, 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, flags=cv2.CALIB_ZERO_DISPARITY, alpha=0 ) 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时保留所有原始像素。如果只是做深度估计,alpha=0没问题;如果还需要和原始图像对齐做后续视觉处理,可以调大一点。实拍校正效果怎么看?在两张校正图上画一条水平线,观察同一个物理点在左右图中的位置是否落在同一行。也可以用OpenCV的cv2.drawMatches把校正后的左右图画出来连几条线,直观确认。

我测试时有个习惯:校正前先看一眼roi1roi2,如果有效区域特别小,说明双目系统安装角度太偏或标定质量不佳,这时候优先回去检查标定数据,而不是继续往下走。

4. 从视差图到3D点云:立体匹配与重投影

4.1 SGBM立体匹配的参数和经验值

极线校正完成后,左右图的行已经对齐了,这时做立体匹配就是逐像素找左右图上的对应点。对应点之间水平位置的差值就是视差(disparity)。离相机越近的物体,视差越大;越远,视差越小。

OpenCV里最常用的算法是SGBM(半全局块匹配)。说它"半全局"是因为它既考虑局部窗口的灰度匹配代价,又用动态规划的方式在多个方向上聚合代价,在精度和速度之间取得了不错的平衡。代码和参数如下:

block_size = 11 stereo = cv2.StereoSGBM_create( minDisparity=0, numDisparities=16 * 6, blockSize=block_size, P1=8 * 3 * block_size ** 2, P2=32 * 3 * block_size ** 2, disp12MaxDiff=1, uniquenessRatio=10, speckleWindowSize=150, speckleRange=32, mode=cv2.STEREO_SGBM_MODE_SGBM ) disparity = stereo.compute(rectL, rectR).astype(np.float32) / 16.0

这几个参数干的事,我给新手朋友翻译一下:

  • numDisparities:允许匹配的视差范围,必须是16的倍数。范围越大能感知的近距离物体越多,但计算量成倍增加,且容易误匹配。我一般从16x4开始调,看直方图和深度效果再加减。
  • blockSize:匹配窗口边长,奇数,常用3到21。窗口越大视差图越平滑,但细节丢失越严重,物体边缘容易出现"毛刺"。
  • P1P2:平滑惩罚系数。P1用于相邻像素视差变化1个像素时的惩罚,P2用于变化更多的惩罚。P2一般设为P1的4到5倍。一个经验公式是P1 = 8 * 通道数 * blockSize^2P2 = 32 * 通道数 * blockSize^2,灰度图通道数取3效果更好。
  • disp12MaxDiff:左右一致性检查的阈值。左右图各算一次视差,差异超过这个值的像素被认为不可靠,置为无效值。设1或2比较稳妥。
  • uniquenessRatio:唯一性比例。值越大,匹配结果越"挑剔",误匹配少但空洞多,通常在5到15之间。
  • speckleWindowSizespeckleRange:滤波去斑。视差图里那些小噪点区域会被当成"斑点"抹掉。

调参没有银弹,我的建议是先固定其他参数,只调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_right=disparity_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_z=100, max_z=3000): 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(f'element 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 numpy

cv2.ximgproc在opencv-contrib-python里,只装opencv-python会没有。版本上,我在不同机器上遇到过SGBM API写法的差异,比如新版本中cv2.StereoSGBM_createmode参数推荐用cv2.STEREO_SGBM_MODE_SGBM_3WAY,效果更好,但老版本不一定支持。如果你用的是OpenCV 4.x,直接用我上面代码里的参数即可。

还有一个小坑:图像读取路径别带中文名,OpenCV的imread对中文路径支持不友好,会静默返回None,然后整个程序报错在莫名其妙的地方。所有图像路径统一用英文/数字命名。

至于Python环境本身,现在Anaconda或者虚拟环境都很成熟,我的建议是给视觉项目单独建一个环境,避免和其他项目互相污染依赖。版本尽量用Python 3.8到3.11,太新的Python版本可能有些预编译OpenCV轮子还没跟上。


我个人在实际操作中最大的体会是:标定这步确实枯燥,但它决定了整个系统能走多远。你可以在标定上省时间,后面就得在调参和修数据上加倍还回来。那批我当年随手拍的"差不多"的棋盘格照片,后来让我整整排查了两天,最后发现是标定板没贴平。先把棋盘格贴平、把照片拍全,这套流程跑下来其实是很有成就感的——尤其是第一次在MeshLab里看到自己相机拍出来的物体变成3D点云的那一刻。最后再分享一个小技巧:标定结果和点云效果别只看数字,把校正后的左右图直接存成视频逐帧看,行对齐是否稳定、视差边缘是否锐利,一眼就能看出系统状态。这个习惯我一直用到现在。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询