☰
双目相机标定从入门到实践:棋盘格到点云全流程指南
2026/10/7 6:52:12 网站建设 项目流程

棋盘格、角点检测、极线校正、视差图、点云,这几个词放在一起,很多人第一反应就是“又要面对一大堆数学公式”。但实际做过一次双目相机标定后你会发现,这个流程完全可以玩着学——只要搞懂每一步在解决什么问题,代码基本就是套OpenCV现成接口,真正消耗时间的反而是采集照片和调参数这些看似不起眼的环节。

这篇博文的目标很明确:用最短的路径,把从棋盘格准备到3D点云生成的完整流程走通,每一步都附带可以直接运行的Python代码。视频、3D测量、机器人抓取,只要你想使用双目相机获取三维数据,这套东西大概率都能帮到你。我会把自己踩过的坑、试出来的参数、以及那些文档里很少写清楚的经验都放进来,尽量让你读完就能照着复现。

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描述。标定单目相机,本质就是把这些参数估计出来,之后就能把畸变的图像恢复成“针孔模型应该输出的样子”。

1.2 双目标定的核心目标:左相机到右相机的变换

单目标定解决的是“每只眼睛的度数”问题,但双目系统还必须解决“两只眼睛的相对位置”问题。给定左相机坐标系下的一个点,它对应在右相机坐标系里是什么位置?这个关系由旋转矩阵R和平移向量T描述,也就是从左相机到右相机的刚体变换:P_right = R * P_left + T。

你可能听过本质矩阵E、基础矩阵F这些概念,stereoCalibrate也会把它们一并算出来。但对最终重建点云的任务来说,最关键的是R和T,因为后续的立体校正(极线校正)要靠它们把左右图像的极线拉成水平直线,让同名点只在水平方向偏移,这样才可以用一维搜索高效匹配像素。

评价一个双目标定结果好不好,除了看单目重投影误差,还要看标定出的R和T是否符合物理直觉:双目相机通常是水平放置的,所以T应该以x方向位移为主、y方向接近0,R应该非常接近单位矩阵。如果你标定出的T_y大得离谱,先别急着分析代码,检查一下是不是相机本身装配就歪了。

2. 棋盘格标定实操:从打印一张纸到采集合格的图像对

很多人把标定当成“在代码里随便调一下”,其实最影响标定质量的环节根本不在代码里,而在采集图片这一步。这一节讲透怎么准备标定板、怎么拍出足够“有信息量”的图像,以及怎么把不合格的数据剔除干净。

2.1 标定板的选择与打印:这些细节直接影响最终精度

棋盘格是最常见的标定板,OpenCV对它的支持最成熟。打印前先确认两件事:内角点数量和实际边长。比如“9x6”指的是9列6行的内角点,对应的棋盘格实际是10x7个格子。代码里findChessboardCorners传入的是内角点数。

边长必须用卡尺精确测量,单位毫米,然后准确填进代码。别随便估个大概值,因为边长直接进入尺度因子:假设你标定板的格子实际是30mm,你却填了29mm,重建出来的物体尺寸会整体偏小约3%,在1米距离上就是3厘米的误差,工业场景根本没法接受。

打印的注意事项:尽量用相纸、哑光纸,避免普通A4纸受潮变形;贴在完全平整的硬板上,比如亚克力板或者铝合金板;如果条件允许,覆一层膜防反光。如果是自己在实验室用,我建议标定板面积稍微大一点,让它在图像里能占据足够大的区域,这样角点亚像素定位的稳定性会好很多。

2.2 采集图像的“黄金法则”:每张照片都要有“信息量”

很多初学者拿着标定板对着相机随便拍二三十张,结果标定出来的内外参一塌糊涂。问题往往出在图像“太像了”——棋盘格永远在画面正中间,永远正对着相机,永远在同一距离。

合格的标定图像集要覆盖足够的视角变化。我的做法是:把视野水平分成左、中、右三个区域,每个区域再覆盖近、中、远三个距离,总共9组组合;每组拍3-5张,共30对左右。每张照片里,棋盘格的俯仰角、偏航角都要有变化,最好让棋盘格相对于相机有明显倾斜(30度级别),这样能有效约束内外参数的解空间。

还有个关键原则:相机固定,移动标定板;不要拿着相机绕标定板转圈。双目相机两个镜头之间一旦因为晃动发生位移,标定外参就会失真。另外,确保左右相机标定板上没有反光点、边缘没有被裁切,棋盘格至少占图像面积的1/3以上。

2.3 角点检测与剔除不合格角点的实战技巧

角点检测用cv2.findChessboardCorners就能搞定,但实操中经常检测失败或检出错误顺序的角点。我常用的检测标志组合是:

cv2.CALIB_CB_ADAPTIVE_THRESH + cv2.CALIB_CB_NORMALIZE_IMAGE

这两个标志能显著提升光照不均、边缘阴影下的检测成功率。检测到整数像素角点后,还要用cornerSubPix做亚像素细化,搜索窗口一般设为(11,11),终止条件设为迭代30次、精度0.001。

剔除不合格角点是标定流程里容易被忽略但极其重要的一步。我一年前第一次做完整流程时,就因为没清理坏数据,重投影误差一直下不来,后来才发现是某几对图像的角点顺序不一致。推荐按下面的顺序过滤:

  • 数量过滤:左右图像任意一张没有检测到全部角点,直接丢弃该图对
  • 顺序一致性过滤:检测出的角点必须按从左到右、从上到下的行列顺序排列,否则后续标定会把对应关系搞错
  • 重投影误差过滤:用所有图像先跑一次单目标定,计算每张图的RMS误差,剔除超过1.5倍中位数的图像对,再重新标定
  • 几何合理性过滤:检查棋盘格四个角点围成的四边形面积,面积过小说明标定板太远或角度过偏,保留价值不大

这套过滤逻辑不仅能提升标定精度,还能让stereoCalibrate的迭代收敛更稳定。下面这段代码就是检测加过滤的骨架:

import cv2 import numpy as np import glob CHESS_COL = 9 CHESS_ROW = 6 SQUARE_SIZE = 23.0 objp = np.zeros((CHESS_COL * CHESS_ROW, 3), np.float32) objp[:, :2] = np.mgrid[0:CHESS_COL, 0:CHESS_ROW].T.reshape(-1, 2) * SQUARE_SIZE obj_points = [] img_points_left = [] img_points_right = [] images_left = sorted(glob.glob('images/left/*.jpg')) images_right = sorted(glob.glob('images/right/*.jpg')) assert len(images_left) == len(images_right) criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) for img_l, img_r in zip(images_left, images_right): gray_l = cv2.imread(img_l, 0) gray_r = cv2.imread(img_r, 0) flags = cv2.CALIB_CB_ADAPTIVE_THRESH + cv2.CALIB_CB_NORMALIZE_IMAGE ret_l, corners_l = cv2.findChessboardCorners(gray_l, (CHESS_COL, CHESS_ROW), None, flags) ret_r, corners_r = cv2.findChessboardCorners(gray_r, (CHESS_COL, CHESS_ROW), None, flags) if not (ret_l and ret_r): print(f"跳过 {img_l} 或 {img_r},角点检测失败") continue corners_l = cv2.cornerSubPix(gray_l, corners_l, (11, 11), (-1, -1), criteria) corners_r = cv2.cornerSubPix(gray_r, corners_r, (11, 11), (-1, -1), criteria) obj_points.append(objp) img_points_left.append(corners_l) img_points_right.append(corners_r)

3. 完整Python代码:从单目标定到立体校正

代码部分我会按实际项目习惯,从环境准备讲起,然后给出单目、双目标定和立体校正的完整实现。建议你先把环境配好,再跟着跑。

3.1 环境准备与依赖安装

基础依赖是OpenCV、NumPy、Matplotlib,点云可视化用Open3D。安装命令:

pip install opencv-contrib-python numpy open3d matplotlib

注意:opencv-python和opencv-contrib-python不能同时安装,否则会有符号冲突。推荐只装opencv-contrib-python,因为它包含了SGBM等扩展模块,功能更全。Open3D比较大,如果只想跑通核心流程可以先不装,等生成点云可视化时再补。

3.2 单目标定:先让每只眼睛“度数”准确

单目标定可以复用上一节的角点检测结果,核心就一行cv2.calibrateCamera。注意传给它的图像尺寸参数是(宽, 高),即gray.shape[::-1],很多人在这里传错导致报错或者结果异常。

ret_l, mtx_l, dist_l, rvecs_l, tvecs_l = cv2.calibrateCamera( obj_points, img_points_left, gray_l.shape[::-1], None, None) ret_r, mtx_r, dist_r, rvecs_r, tvecs_r = cv2.calibrateCamera( obj_points, img_points_right, gray_r.shape[::-1], None, None) print("左相机重投影误差:", ret_l) print("左相机内参:\n", mtx_l) print("左相机畸变系数:", dist_l.ravel()) print("右相机重投影误差:", ret_r) print("右相机内参:\n", mtx_r) print("右相机畸变系数:", dist_r.ravel())

calibrateCamera返回的第一个值ret是RMS重投影误差,单位是像素,我一般要求小于0.3。如果误差在0.5以上,基本可以断定数据里有不合格图像,回到第2.3节做剔除,而不要指望靠算法硬吃。

3.3 双目标定:计算左右相机之间的R和T

双目标定是在单目标定基础上,用同一组角点对估计左右相机的外参关系。代码:

flags = cv2.CALIB_FIX_INTRINSIC criteria_stereo = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 100, 1e-5) ret_stereo, mtx_l, dist_l, mtx_r, dist_r, R, T, E, F = cv2.stereoCalibrate( obj_points, img_points_left, img_points_right, mtx_l, dist_l, mtx_r, dist_r, gray_l.shape[::-1], criteria=criteria_stereo, flags=flags) print("双目标定误差:", ret_stereo) print("R:\n", R) print("T:\n", T)

CALIB_FIX_INTRINSIC的含义是固定单目标定得到的内参,只优化R和T。这个flag在单目结果比较可信时能减少优化变量、提高稳定性。如果采集图像覆盖不充分,放开内参让它们一起优化反而容易收敛到离谱的解。

R和T的物理意义:一个三维点,在左相机坐标系下坐标是X_left,映射到右相机坐标系为X_right = R * X_left + T。之后stereoRectify会根据R和T计算出左右相机各自新的旋转矩阵R1、R2,以及新的投影矩阵P1、P2,还有把视差转成三维坐标的Q矩阵:

R1, R2, P1, P2, Q, roi1, roi2 = cv2.stereoRectify( mtx_l, dist_l, mtx_r, dist_r, gray_l.shape[::-1], R, T, alpha=1) map1_l, map2_l = cv2.initUndistortRectifyMap( mtx_l, dist_l, R1, P1, gray_l.shape[::-1], cv2.CV_32FC1) map1_r, map2_r = cv2.initUndistortRectifyMap( mtx_r, dist_r, R2, P2, gray_r.shape[::-1], cv2.CV_32FC1)

alpha参数决定校正后图像的保留范围:alpha=0时图像会裁掉非公共区域,alpha=1时保留全部像素,代价是边缘会有明显畸变残留和拉伸感。如果只关心中央区域,alpha=1很直观;如果追求高质量视差图,alpha=0可以减少无用计算。

校正后的图像可以直接用remap生成:

left_rect = cv2.remap(gray_l, map1_l, map2_l, cv2.INTER_LINEAR) right_rect = cv2.remap(gray_r, map1_r, map2_r, cv2.INTER_LINEAR)

想验证极线是否对齐,最简单的方法是把左右校正图拼在一起,每隔几十像素画一条水平线,观察棋盘格和场景边缘是否在同一条水平线上。如果明显错位,说明标定或矫正参数出了问题。

3.4 把中间结果保存好:别让标定成为一次性工程

标定一次要采集几十张图,还可能要反复调整,非常费时间。把关键参数保存到npz文件里,之后无论做实验还是换机器,直接加载即可:

np.savez('stereo_params.npz', mtx_l=mtx_l, dist_l=dist_l, mtx_r=mtx_r, dist_r=dist_r, R=R, T=T, R1=R1, R2=R2, P1=P1, P2=P2, Q=Q)

下次使用时:

with np.load('stereo_params.npz') as data: mtx_l = data['mtx_l'] dist_l = data['dist_l'] # 按需取出其他参数

这里有个重要提醒:标定结果只对“标定时的那台相机、那个焦距、那个分辨率”有效。如果你调整了镜头焦距、更换了相机、或者改变了采集分辨率,必须重新标定,旧的npz文件直接作废。

4. 从视差图到3D点云:深度信息如何真正“算”出来

极线校正完成之后,左右图像已经严格水平对齐。此时求深度就变成了一维问题:左图上某个像素,在右图上只可能出现在同一行里,找到它的水平位移(视差),就能利用三角测量原理算出距离。

4.1 视差计算原理:一个公式看懂深度

双目测距的核心公式是:

Z = f * B / d

Z是深度,f是焦距(像素单位),B是基线(毫米),d是视差(像素)。可以看出,深度和视差成反比:离相机越近,视差越大;越远,视差越小且趋近于0。这也解释了为什么双目相机对远处目标的深度精度会越来越差。

OpenCV里最常用的立体匹配算法是SGBM(半全局块匹配)。它本质是在能量最小化框架下做像素匹配,比简单的块匹配更平滑,对弱纹理区域也有一定鲁棒性。基本用法:

sgbm = cv2.StereoSGBM_create( minDisparity=0, numDisparities=128, blockSize=11, P1=8 * 3 * 11 ** 2, P2=32 * 3 * 11 ** 2, disp12MaxDiff=1, uniquenessRatio=10, speckleWindowSize=100, speckleRange=32, ) disparity = sgbm.compute(left_rect, right_rect).astype(np.float32) / 16.0

注意:compute返回的是CV_16S类型,也就是short,单位是1/16像素。必须先转成float32再除以16,才是真实视差值。忘记除以16是新手最容易犯的错误。

关于参数,我的经验值是:numDisparities取64或128,如果目标物体距离很近可以增大;blockSize取7到11之间,太小噪声大,太大容易丢失物体边缘;uniquenessRatio取10左右,它控制匹配唯一性的置信度;P1和P2是平滑惩罚项,通常按8 * 3 * blockSize^2和32 * 3 * blockSize^2来设。

4.2 用Q矩阵重投影到三维:点云的诞生

立体校正阶段的Q矩阵就是干这个的。reprojectImageTo3D函数把视差图直接转换成三维坐标数组:

points_3d = cv2.reprojectImageTo3D(disparity, Q, handleMissingValues=True)

points_3d的shape是(H, W, 3),最后一维分别是X、Y、Z坐标。Z的单位和标定时棋盘格边长的单位一致:如果你SQUARE_SIZE填的是毫米,Z单位就是毫米;填米就是米。记得统一好单位,否则后面做测量或者机械臂抓取时会出现数量级错误。

视差图里会有无效像素(匹配失败、被遮挡的区域),一般表现为视差非正或极大,需要过滤:

mask = (disparity > 0) & (disparity < 128) valid_xyz = points_3d[mask] valid_color = right_rect[mask] # 或者用左图,取决于你想把哪个作为纹理来源

再按距离过滤一些太远或太近的噪点:

dist = np.linalg.norm(valid_xyz, axis=1) valid_xyz = valid_xyz[(dist > 200) & (dist < 5000)]

具体距离范围根据你的相机和场景设置。

4.3 点云可视化:Open3D快速展示

有了三维坐标和对应的RGB颜色,点云其实已经“存在”了。用Open3D展示非常直接:

import open3d as o3d pcd = o3d.geometry.PointCloud() pcd.points = o3d.utility.Vector3dVector(valid_xyz.reshape(-1, 3)) pcd.colors = o3d.utility.Vector3dVector(valid_color.reshape(-1, 3) / 255.0) # 可选:体素降采样,减少点数,提高渲染流畅度 pcd_down = pcd.voxel_down_sample(voxel_size=3.0) o3d.visualization.draw_geometries([pcd_down])

voxel_down_sample里的voxel_size单位和你点云坐标单位一致,3.0毫米的体素对中距离场景来说既能降噪又能保持轮廓。

如果点云出现了“纸张褶皱”一样的效果,即同一个平面上有大量周期性起伏,优先检查两个地方:SGBM的blockSize是不是太小,以及标定板的边长是否填错。褶皱往往是视差噪声和标定误差叠加的体现,不是点云本身的bug。

5. 常见问题与排查记录:标定踩坑实录

这块内容是我觉得整篇博客最值钱的部分。以下问题基本是我在不同项目里真实踩过的坑,按出现频率从高到低排列。

5.1 畸变校正后图像边缘被拉得很难看

现象:remap之后边缘区域有强烈拉伸、模糊,甚至出现“旋转”感。原因基本是畸变系数估计不准,或者标定板图像没有覆盖到画面边缘。解法是回到采集环节,专门补拍几组标定板靠近画面四角、且明显倾斜的图像,重新标定。另外,alpha参数从1调到0能显著减少边缘裁剪和拉伸,但会损失边缘像素,适合对边缘区域要求不高的场景。

5.2 重投影误差很小,但视差图质量差

这个现象最容易让人困惑。重投影误差小说明棋盘格标定自洽度好,但视差图差可能另有原因。常见因素:

  • 左右图像亮度不一致,SGBM对光照差异敏感。解决:先做直方图匹配,或者用同一曝光参数采集
  • 场景纹理太少。SGBM依赖纹理信息,白墙、纯色地板都会导致大片空洞或错误匹配
  • 基线太短,深度分辨率不足。基线决定了深度误差随距离放大倍数,物理限制无法靠算法救回来
  • SGBM参数不合理,比如blockSize太大导致边缘模糊

排查顺序建议:先看校正图像是否对齐,再看视差图上是否有大面积黑色空洞,最后调参。

5.3 角点检测失败怎么办

角点检测失败的典型原因有三个:棋盘格太小、光照太强导致反光、标定板太斜。可以尝试:

ret, corners = cv2.findChessboardCorners( gray, (CHESS_COL, CHESS_ROW), None, cv2.CALIB_CB_ADAPTIVE_THRESH + cv2.CALIB_CB_NORMALIZE_IMAGE)

如果还不行,换cv2.findChessboardCornersSB,这个接口基于Blob检测,对模糊、低对比度、暗光的鲁棒性更好。真不行就重新采集图像,别在代码上死磕。

5.4 双目相机的硬件注意事项

最后提一个很多人忽略的点:双目相机在标定过程中千万不要碰镜头、调整焦距、或者改变两个镜头之间的距离。如果是带自动对焦的相机,一定要先锁焦再标定;如果是变焦镜头,锁定在固定焦距上。任何结构变化都会让标定结果失效,这也是为什么工业场景普遍使用固定基线、固定焦距的专用双目模组。

我个人在实际操作中的体会是,标定不是一次性的“打怪通关”,而是一个反复迭代的过程。第一次标定结果可能不尽如人意,别急着怀疑代码,回到图像采集环节,补拍、剔除、重新跑,往往比调参数更能解决问题。养成把每轮标定的图像集、参数、误差都记录下来的习惯,会让你后续排查问题省掉大量时间。这套流程跑顺之后,再去看双目视觉的其他环节,比如立体匹配优化、深度滤波、SLAM建图,都会轻松很多。

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

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

立即咨询