简介:面向双目视觉、三维建模与相机标定方向的开发者,这是一套可直接运行参考的双目相机数据采集与标定工程包。资源基于Qt与C++实现,内含程序源码、界面配置、左右目图片列表,并配有40张标定板实拍图,覆盖从图像采集、数据组织到相机内外参标定的完整代码路径,便于读者对照工程理解双目视觉标定的实现思路。压缩包共49个文件,以jpg图像为主,辅以cpp/h源码、ui/pro工程配置和txt标定列表,整体仅3.82MB,轻量易用;该包目前已有164人学习下载。借助这套工程,可掌握双目标定项目的基本结构,包括左右视图文件管理、标定程序入口与界面交互,并附带了“解决中文乱码问题”的说明图,对处理Qt开发中的编码异常也很有参考价值,适合刚接触双目视觉或需要快速搭建标定实验的读者。
1. 拿到“双目相机数据采集、标定.zip”后,先想清楚它要解决什么问题
一个以双目相机数据采集、标定.zip命名的压缩包,在工程项目里通常不是单纯的数据文件,而是一整套方案的封装:里面可能是左右目图像序列、标定板照片、相机参数文件,也可能是采集脚本和标定工程。文件名把“采集”和“标定”并列,恰恰说明这两件事在双目视觉里是强耦合的——采集的数据质量直接决定标定结果,而标定参数又反过来约束采集时的镜头和结构设计。对于刚接触双目立体视觉的工程师,这个压缩包是一个完整的入门闭环;对于有经验的开发者,里面真正值钱的是采集规范、标定参数表和重投影误差统计,而不只是几张图片。这篇文章要做的事,就是顺着“数据采集→标定→参数验证→常见坑”这条主线,把双目相机从拿到手到产出可靠深度图所需的全部关键步骤、代码和参数讲清楚,让新手能照做、老手能看到边界条件。
2. 双目相机采集与标定的原理和核心概念
2.1 为什么双目相机能测深度:视差与三角测量
双目相机测距的本质是三角测量:两个固定间距(基线)的相机同时观察同一空间点,由于视角不同,该点在左右图像上的投影位置存在水平差异,这个差异叫视差(disparity)。空间点越近,视差越大;越远,视差越小。已知基线长度 B 和相机焦距 f,深度 Z 与视差 d 的关系可以表示为:
Z = (B * f) / d这个式子看起来简单,但工程实现时有一个关键点常常被忽视:上式成立的前提是左右相机光轴严格平行、且两个成像平面共面。现实中几乎不可能做到,所以必须通过标定获得左右相机的内参(焦距、主点、畸变系数)和相对外参(旋转矩阵 R 和平移向量 T),再做立体校正(stereo rectification),把左右图像变换成理想的外极线平行状态。这样视差搜索就从二维降到一维,既降低计算量,也提高匹配精度。这也是为什么“数据采集”和“标定”必须放在同一个压缩包或同一个工作流里——没有标定参数的采集数据无法直接用于深度计算。
提示:工程中常用
BM(块匹配)或SGBM(半全局块匹配)算法计算视差图,两者都对输入图像的极线校正质量敏感。标定误差超过 0.5 像素时,远距离物体(5 米以外)的深度误差会明显放大,表现为物体边缘出现“雾化”或错误视差。
2.2 标定的目标:内参、畸变和外参
标定要解的未知量分三组。第一组是内参矩阵 K,包含 fx、fy(以像素为单位的焦距)和 cx、cy(主点坐标)。第二组是畸变系数,通常用 OpenCV 的 5 参数模型表示(k1, k2, p1, p2, k3),其中 k1、k2、k3 是径向畸变系数,p1、p2 是切向畸变系数。第三组是外参,包括单目标定时的棋盘格位姿(每张图一个 R、T),以及双目标定时的相对位姿。
张正友标定法是当前最主流的方案,它用平面棋盘格替代高成本的三维标定块,通过拍摄 10 到 30 个不同位姿的棋盘格图像,利用单应性矩阵约束,线性求解初值后再用最大似然估计做非线性优化。这个算法的优点在于操作门槛低:只要有一台相机、一张棋盘格、足够的自然光,就能完成标定。OpenCV 的calibrateCamera和stereoCalibrate函数就是该算法的工程实现。
2.3 硬件选型对采集和标定的影响:从 D435 到模块化双目相机
热词里出现“d435双目相机指南”,这说明很多从业者起步时用的是 Intel RealSense 系列。D435 是一种主动立体视觉方案,带有红外投影仪来增强纹理不足区域的匹配能力,它内部已经完成了出厂标定,用户通常不需要再做散焦标定。但如果你的任务基于工业相机模组或普通 USB 双目摄像头,就必须自己走完整的标定流程。
选型时要关注三个参数:
| 参数 | 典型值/范围 | 对标定的影响 |
|---|---|---|
| 基线长度 | 60mm ~ 120mm | 基线越大,近距测距精度越高,但视野重叠区域缩小 |
| 镜头视场角 | 60° ~ 100° | 视场角越大,畸变越明显,需要更高的标定精度 |
| 同步方式 | 硬件触发 / 软件同步 | 运动场景必须硬件同步,否则左右目各拍各的 |
采集时如果左右目曝光时间不一致或触发不同步,拍摄棋盘格时棋盘格发生了微小位移,会直接污染角点坐标。这会让单目内参标定还算正常,但双目标定得到的 R、T 出现明显偏移,最终投影误差居高不下。
3. 数据采集的完整流程与可靠代码实现
3.1 采集前准备工作:棋盘格与相机固定
标定板的选择是第一道分水岭。打印在普通 A4 纸上的棋盘格会因纸张弯曲产生非线性误差,我一般会建议至少用 3mm 厚的铝板或玻璃基板贴高精度打印纸,条件允许时直接买陶瓷或玻璃棋盘格。棋盘格的规格用“内角点数 × 内角点数”表示,例如9 × 6表示每行 9 个内角点、每列 6 个内角点。角点数量越多,标定约束越强,但太密会导致角点检测在远距离时不稳定,常用9 × 6或11 × 8,方格边长 20mm 到 30mm 适合大多数室内场景。
相机和标定板的相对运动要模拟实际使用场景中的距离范围。如果双目相机的工作距离是 0.5 米到 3 米,标定图像应该覆盖这个范围,且棋盘格在画面中占比从 15% 到 60% 都应采样。固定相机时使用三脚架或光学平台确保采集过程没有振动,这一点常被忽略——用手机支架夹住 USB 摄像头,每按一次快门手部都会施加微小的力矩,导致相机晃动。
3.2 用 Python 和 OpenCV 编写双路采集脚本
以下代码实现左右目同步采集、实时预览、自动保存为按序号命名的 PNG 文件。这是从零开始采集标定数据的最小可用版本。
import cv2 import os import time import threading # 打开左右相机,0 和 1 是设备索引,实际值用 lsusb 或设备管理器确认 cap_left = cv2.VideoCapture(0) cap_right = cv2.VideoCapture(1) # 统一分辨率:两个相机必须一致,否则极线校正无从谈起 WIDTH, HEIGHT = 1280, 720 for cap in (cap_left, cap_right): cap.set(cv2.CAP_PROP_FRAME_WIDTH, WIDTH) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, HEIGHT) # 手动设置曝光值,防止自动曝光导致左右亮度差异大 for cap in (cap_left, cap_right): cap.set(cv2.CAP_PROP_AUTO_EXPOSURE, 1) # 1 表示关闭自动曝光(具体含义取决于驱动) cap.set(cv2.CAP_PROP_EXPOSURE, 40) # 曝光值需按实际环境调整 save_dir = "stereo_calib_images" os.makedirs(save_dir, exist_ok=True) counter = 0 def grab_frame(cap, result, idx): ok, frame = cap.read() if ok: result[idx] = frame while True: # 用两个线程同时 read,降低左右帧的时间差 result = [None, None] t1 = threading.Thread(target=grab_frame, args=(cap_left, result, 0)) t2 = threading.Thread(target=grab_frame, args=(cap_right, result, 1)) t1.start(); t2.start() t1.join(); t2.join() if result[0] is None or result[1] is None: continue left_img, right_img = result[0], result[1] combined = cv2.hconcat([left_img, right_img]) cv2.imshow("Stereo Capture - press SPACE to save, ESC to exit", combined) key = cv2.waitKey(1) & 0xFF if key == 27: # ESC break elif key == 32: # SPACE left_name = os.path.join(save_dir, f"left_{counter:04d}.png") right_name = os.path.join(save_dir, f"right_{counter:04d}.png") cv2.imwrite(left_name, left_img) cv2.imwrite(right_name, right_img) counter += 1 print(f"Saved pair {counter}") cap_left.release() cap_right.release() cv2.destroyAllWindows()代码的逻辑要点如下:第一,用两个线程并行调用cap.read(),而不是串行读取左右相机,后者会让两帧之间的时间差达到几十毫秒,相当于强制引入了运动伪影;第二,手动关闭自动曝光并固定曝光值,是因为自动曝光会让两张图像的亮度产生明显阶梯变化,影响角点亚像素定位;第三,统一分辨率是硬性要求,两个相机的传感器即使型号相同,默认分辨率也可能不同。
采集数量和质量标准:每对图像中棋盘格必须完整可见,不能有遮挡;左右图像中棋盘格都要被检测到。整体数量在 20 到 30 对之间即可,额外增加位姿多样性比增加数量更有效。具体分布建议为:正对相机占 30%,水平旋转 ±30° 占 40%,俯仰变化 ±20° 占 30%。
3.3 角点检测与不合格图像剔除
采集完图像后,不能直接送入标定函数,要先跑一遍角点检测,把不合格的图片对删掉。OpenCV 的cv2.findChessboardCorners在光照不均匀、反光、模糊三种情况下经常失败,这里用以下代码批量检视数据质量。
import cv2 import glob pattern_size = (9, 6) # 内角点数,不是棋盘格总数 left_images = sorted(glob.glob("stereo_calib_images/left_*.png")) right_images = sorted(glob.glob("stereo_calib_images/right_*.png")) valid_pairs = [] for lpath, rpath in zip(left_images, right_images): l = cv2.imread(lpath, cv2.IMREAD_GRAYSCALE) r = cv2.imread(rpath, cv2.IMREAD_GRAYSCALE) ok_l, corners_l = cv2.findChessboardCorners(l, pattern_size) ok_r, corners_r = cv2.findChessboardCorners(r, pattern_size) if ok_l and ok_r: # 亚像素细化,提高角点坐标精度 criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) corners_l = cv2.cornerSubPix(l, corners_l, (5, 5), (-1, -1), criteria) corners_r = cv2.cornerSubPix(r, corners_r, (5, 5), (-1, -1), criteria) valid_pairs.append((lpath, rpath, corners_l, corners_r)) print(f"Valid: {lpath}") else: print(f"Invalid: {lpath} / {rpath}") print(f"Valid pairs: {len(valid_pairs)} / {len(left_images)}")这段代码里有两个容易出错的地方。第一,pattern_size要用“内角点数”,比如棋盘格打印出来是 10×7 的方格阵列,内角点数就是 9×6,填反会导致检测始终失败。第二,亚像素细化的窗口半径(5,5)在图像分辨率低于 640×480 时建议改为(3,3),防止窗口超出角点邻域范围。对粘了灰尘或反光的标定板,可以先cv2.medianBlur(l, 3)做中值滤波再检测,但注意滤波后的角点坐标要用原图做亚像素细化,以免坐标被滤波偏移。
3.4 采集时的常见错误与曝光参数经验值
把采集阶段的风险列成一个快速排查表,每个问题对应一个最常见的根因——这些是我在实际项目中反复遇到的:
| 现象 | 直接原因 | 解决办法 |
|---|---|---|
| 左右图亮度差异大 | 自动曝光未关闭或左右增益不一致 | 固定曝光和增益;如果相机驱动没有直接曝光接口,用光源补光 |
| 角点检测成功但重投影误差 > 0.3 像素 | 图像模糊或标定板弯曲 | 提高快门速度,缩短曝光时间;换刚性标定板 |
| 标定时误差正常但视差图很烂 | 采集时左右帧不同步 | 硬件触发同步,或至少用线程并行采集后再做时间戳校验 |
| 近距离标定效果好,远距离误差大 | 缺少远距离采样图像 | 补拍棋盘格占画面 15%~20% 的远距离照片 |
对于曝光参数,一个可复用的经验值是:室内荧光灯环境下,曝光时间 15ms~30ms,增益 0dB;如果在阳光下使用,曝光时间压到 1ms 以下,并加偏振片减少标定板表面的镜面反射。
4. 从单目标定到立体校正的完整实现
4.1 单目标定:获取内参与畸变系数
单目标定是双目标定的前置步骤,因为双目标定需要输入左右目的内参初值。OpenCV 提供calibrateCamera函数,输入是全部有效的角点坐标和对应的世界坐标。棋盘格的世界坐标通常设 z=0,x、y 按方格尺寸递增。以下代码会输出每张图的平均重投影误差,这是判断单目标定质量的第一道关卡。
import cv2 import numpy as np import glob pattern_size = (9, 6) square_size = 0.025 # 方格边长,单位:米 # 构造棋盘格的世界坐标 objp = np.zeros((pattern_size[0] * pattern_size[1], 3), np.float32) objp[:, :2] = np.mgrid[0:pattern_size[0], 0:pattern_size[1]].T.reshape(-1, 2) objp *= square_size obj_points = [] # 世界坐标 img_points_l = [] # 左目像素坐标 left_images = sorted(glob.glob("stereo_calib_images/left_*.png")) for fname in left_images: img = cv2.imread(fname) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) ok, corners = cv2.findChessboardCorners(gray, pattern_size) if ok: criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) corners = cv2.cornerSubPix(gray, corners, (5, 5), (-1, -1), criteria) obj_points.append(objp) img_points_l.append(corners) ret, mtx_l, dist_l, rvecs_l, tvecs_l = cv2.calibrateCamera( obj_points, img_points_l, gray.shape[::-1], None, None ) print("Left camera RMS error:", ret) print("Intrinsic matrix:\n", mtx_l) print("Distortion coefficients:", dist_l)ret就是重投影误差的 RMS 值,单位是像素。经验判断标准是:低于 0.1 像素说明标定质量优秀;0.1 到 0.3 像素是正常范围;超过 0.5 像素则需要检查图像模糊或角点检测是否稳定。RMS 只反映整体水平,真正暴露问题的是逐张图的误差——可以用cv2.projectPoints对每一张图单独计算误差,然后把误差最大的 3 张图找出来,检查是否是棋盘格部分出界或反光导致角点偏移。对右目重复同样的流程,得到mtx_r和dist_r。
4.2 双目标定:求解相对外参 R、T
左右目各自的内参确定后,双目标定就是利用同一时刻拍摄的棋盘格对,计算右相机坐标系相对左相机坐标系的旋转矩阵和平移向量。这一步依赖的stereoCalibrate函数会同时对全部参数做全局优化,所以单目标定的误差会在这一步被部分修正,但修正幅度有限——如果单目 RMS 已经超过 0.5 像素,双目标定很难救回来。
# 使用前面单目标定的结果作为输入初值 flags = cv2.CALIB_FIX_INTRINSIC # 固定内参,只优化外参,稳定性更高 ret_stereo, mtx_l, dist_l, mtx_r, dist_r, R, T, E, F = cv2.stereoCalibrate( obj_points_list, # 所有图像对共享的世界坐标 img_points_l, # 左目角点 img_points_r, # 右目角点 mtx_l, dist_l, mtx_r, dist_r, image_size, # (宽, 高) 元组 criteria=(cv2.TERM_CRITERIA_MAX_ITER + cv2.TERM_CRITERIA_EPS, 100, 1e-5), flags=flags ) print("Stereo RMS:", ret_stereo) print("Rotation matrix R:\n", R) print("Translation vector T:", T.ravel())CALIB_FIX_INTRINSIC是我想强调的一个参数。对双目模组,左右相机内参在出厂时已经比较稳定,固定内参只优化 R、T 能有效避免整体的参数漂移。但是如果左右相机是临时搭建的组合,内参可信度不高,就应该去掉这个 flag,让全局优化同时调整内参和外参。对于无人机、机器狗这类运动平台,T 的估计尤其要关注基线长度的量级。例如一个真实基线为 60mm 的双目系统,标定出来的T的模长应该在 0.06 米附近。如果偏差超过 10%,说明标定图像中棋盘格的尺度约束不足,需要补充不同距离的采样。
4.3 立体校正:让左右图像极线对齐
标定参数的意义最终要体现在校正效果上。stereoRectify计算左右相机的校正映射,initUndistortRectifyMap生成重映射查表,最后用remap把原图变换为校正后的图像。校正后左右图像的同名点只存在水平方向的视差,这是后续 SGBM 能高效匹配的前提。
R1, R2, P1, P2, Q, valid_roi1, valid_roi2 = cv2.stereoRectify( mtx_l, dist_l, mtx_r, dist_r, image_size, R, T, flags=cv2.CALIB_ZERO_DISPARITY, alpha=0.0 ) map1_l, map2_l = cv2.initUndistortRectifyMap( mtx_l, dist_l, R1, P1, image_size, cv2.CV_32FC1 ) map1_r, map2_r = cv2.initUndistortRectifyMap( mtx_r, dist_r, R2, P2, image_size, cv2.CV_32FC1 ) left_rect = cv2.remap(left_img, map1_l, map2_l, cv2.INTER_LINEAR) right_rect = cv2.remap(right_img, map1_r, map2_r, cv2.INTER_LINEAR)这里alpha参数决定校正后图像的裁剪方式,alpha=0.0表示裁剪掉所有无效区域,输出图像没有黑边但视野会变小;alpha=1.0保留全部有效像素但黑边明显。我通常先用alpha=0.0验证极线对齐情况,正式部署时再根据视野需求调整。P1和P2是校正后的投影矩阵,Q矩阵用于把视差图转换为三维点云。
4.4 张正友标定流程中的参数含义速查表
为了让上面的流程能直接用,我按下表整理了所有关键参数的取值建议:
| 参数 | 推荐值/范围 | 说明 |
|---|---|---|
| 棋盘格内角点数 | 9×6 或 11×8 | 角点越多约束越强,但远距离检测更困难 |
| 方格边长 | 20mm ~ 30mm | 需要精确测量,卡尺误差直接影响尺度 |
| 图像数量 | 20~30 对 | 覆盖不同角度、距离、姿态 |
| 亚像素迭代阈值 | eps=0.001,迭代 30 次 | 过小导致收敛慢,过大导致坐标跳动 |
| 单目 RMS | < 0.15 像素(优秀) | 大于 0.5 需要处理数据源 |
| 双目 RMS | < 0.3 像素 | 双目标定因为多一组外参,误差会稍大 |
| 校正后 ROI | 尽可能大于原图 90% | 小于 80% 说明畸变太大或外参不准确 |
5. 标定结果验证、相机同步采集与常见坑
5.1 用 OpenCV 验证极线校正效果:画水平线检查
完成remap后,最直观的验证方式是在校正后的左右图上画同一组水平线,观察棋盘格角点是否落在同一条直线上。如果角点垂直方向偏差超过 1 个像素,SGBM 在低纹理区域会产生大量错误视差。
check_img = np.hstack([left_rect, right_rect]) h, w = check_img.shape[:2] for y in range(0, h, 40): cv2.line(check_img, (0, y), (2 * w, y), (0, 255, 0), 1) cv2.imshow("Rectified Check", check_img)垂直方向的偏差大小可以直接用角点坐标差分来量化:左右目第 i 个角点的 y 坐标之差平均应在 0.3 像素以内。如果偏差过大,优先检查是否存在左右目图像时间不同步导致的棋盘格位置变化,再检查是不是标定板表面不平整。
5.2 双目自动标定和手眼标定的关系辨析
热词里同时出现了“双目自动标定”和“手眼标定”,这两个概念容易混淆。双目自动标定指的是通过运动恢复结构或在线优化方法,在机器人运动过程中不断更新相机内参和双目光束夹角,适合长时间使用后参数漂移的场景;而手眼标定解决的是相机坐标系和机械臂末端坐标系之间的变换关系。如果你在机器人抓取场景中使用双目相机,那么流程应该是先用本文第 4 章完成双目标定,再做手眼标定——两者不是二选一的关系。双目自动标定通常需要较高的初始精度作为前提,如果当前项目处于起步阶段,老老实实做一次离线标定比引入自动标定更稳妥。
5.3 输出最终的标定文件
标定完成后,我习惯把参数保存为一个 YAML 文件,方便 C++ 或 Python 端统一下发。保存内容除了内参、畸变、R、T 之外,还要保存校正映射的生成版本和时间戳,便于回溯。一个最小化的存储格式如下:
%YAML:1.0 left_camera_matrix: !!opencv-matrix rows: 3 cols: 3 data: [ 740.5, 0.0, 642.1, 0.0, 741.2, 368.4, 0.0, 0.0, 1.0 ] left_distortion: [ -0.12, 0.03, 0.001, -0.002, 0.0 ] right_camera_matrix: !!opencv-matrix rows: 3 cols: 3 data: [ 742.0, 0.0, 640.8, 0.0, 741.6, 366.9, 0.0, 0.0, 1.0 ] right_distortion: [ -0.11, 0.02, 0.0008, -0.001, 0.0 ] rotation_matrix: [ 0.9999, -0.004, 0.001, 0.004, 0.9999, -0.002, -0.001, 0.002, 1.0 ] translation_vector: [ -60.2, 0.32, 1.05 ] rectification_timestamp: 2025-06-10-14-30-00保存为 YAML 后,用 OpenCV 的cv2.FileStorage读入。C++ 工程中同样可以用cv::FileStorage读取,格式保持一致,避免跨语言时反复手撕文本。文件里额外记录“标定时间”是一个值得养成的习惯——双目相机的结构在温度变化、震动或镜头松动时会漂移,有了时间戳才能判断参数是否过期。
5.4 参数的时间漂移:什么时候必须重新标定
双目相机不是标定一次就一劳永逸。以下三类情况触发重新标定:第一,相机受到剧烈冲击或磕碰,特别是镜头安装结构松动;第二,温度变化范围超过 20℃ 且基线较长,金属结构的热胀冷缩会明显改变外参;第三,有人手动旋转了镜头环或改变了镜头位置。判断是否需要重新标定有一个快速方法:用当前参数做立体校正,观察远处竖杆或建筑物边缘的重影宽度。重影超过 2 个像素就说明外参已经漂移,这时再跑一次完整流程比在算法层强行滤波更有效率。神经网络只是在匹配阶段提升视差质量,无法补救极线没有对齐的问题。
若要对标定结果做最终验证,可以计算视差图的深度精度:在已知距离处垂直放置棋盘格,从视差图取中心区域的深度值,与真实距离比较,误差不超过 1% 即认为系统合格。比如 1 米距离允许 1cm 误差,3 米距离允许 3cm 误差。这个验证手段比单纯看标定 RMS 更贴近实际应用。
本文还有配套的精品资源,点击获取