双目立体视觉毕设指南:标定、匹配与深度图生成
简介这份资源是面向计算机、人工智能、自动化、电子信息等专业学生与科研人员的双目摄像头立体视觉系统完整项目包围绕相机标定、立体匹配与深度图生成三大核心环节展开可作为毕业设计、课程设计或项目立项演示的参考方案。压缩包共190个文件约2.58MB以34个Python脚本、11个C与11个H头文件、22个JavaScript文件为主辅以38张jpg与12张png图像素材、11个json配置及若干工程文件覆盖算法实现、界面交互与硬件驱动等多个层面。项目代码经过测试功能完善且运行稳定配套设计文档与项目报告齐全便于复现与二次开发。目前已有39人学习关注。读者可从中获取标定流程、匹配算法与深度图生成的完整实现思路并借鉴其目录组织与模块划分方式适合在现有代码基础上修改扩展实现更多立体视觉相关功能。1. 双目立体视觉这套东西到底能拿来做什么实验室里摆着两个 USB 摄像头间距大概六厘米对着桌面上一个纸盒子。你想知道这个盒子离镜头多远于是打开 Python调cv2.VideoCapture抓了两帧图用cv2.StereoBM一算屏幕上跳出一张花花绿绿的深度图——近处红、远处蓝盒子轮廓清清楚楚。这就是双目摄像头立体视觉系统最朴素的样子两个相机看同一个场景靠视差反推距离不需要激光雷达不需要结构光成本压到几百块。毕业设计选这个题核心要交付三样东西相机标定参数、立体匹配结果、深度图。标定解决“两个相机各自的内参和它们之间的相对位置关系”匹配解决“左图里的某个像素在右图里对应哪个像素”深度图则是把视差按几何公式换算成实际距离。这三步串起来才构成一个能跑通、能出数、能写进论文的完整链路。适合谁看如果你正在做毕设、课程设计或者想给机器人、AGV、智能小车加一双“眼睛”又不想一上来就碰激光雷达的标定和点云配准双目视觉是性价比最高的切入点。但别指望它像宣传视频里那么稳——光照一变、纹理一弱、标定一歪深度图立刻翻车。下面按实际动手顺序把标定、匹配、深度图生成和避坑一条条拆开。2. 标定从张正友标定法到双目联合标定2.1 为什么单目标定完还要做双目标定很多人第一次做双目以为把左右相机分别用cv2.calibrateCamera标一遍拿到两个内参矩阵就完事了。结果做立体校正时发现极线对不齐匹配出来的视差图全是噪点。原因很简单单目标定只告诉你每个相机自己的内参焦距、主点、畸变系数但双目深度计算依赖的是两个相机之间的旋转矩阵 R 和平移向量 T。没有这两个外参你就不知道右相机相对于左相机偏了多少、转了多少视差到深度的换算公式 z f * B / d 里的基线 B 就无从谈起。常见做法是先分别单目标定拿到左右内参和畸变系数再用cv2.stereoCalibrate把左右内参作为初始值联合优化出 R、T、E、F。这一步会同时最小化左右重投影误差比单独标完再拼要准得多。张正友标定法的核心就是用平面棋盘格在不同姿态下拍十几张图利用棋盘格角点的已知世界坐标和图像坐标的对应关系解出单应矩阵再分解出内外参。双目联合标定则是在此基础上增加左右相机之间的约束。提示标定板建议用 9x6 或 11x8 的棋盘格方格实际尺寸用尺子量准比如 25mm。打印后贴在一块平整的亚克力板或铝板上别用软纸一弯曲角点检测就飘。2.2 采集标定图像的具体操作与参数先写一个采集脚本左右相机同时抓图按空格保存一对按 q 退出。关键点是棋盘格要覆盖画面不同区域——左上、右上、左下、右下、中间、远近、倾斜。一般 15 到 20 对就够太少外参不稳太多也没必要。import cv2 import os # 左右相机索引根据实际设备改 cap_left cv2.VideoCapture(0) cap_right cv2.VideoCapture(1) # 设置分辨率建议 640x480 或 1280x720太高标定慢 cap_left.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap_left.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) cap_right.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap_right.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) save_dir calib_images os.makedirs(save_dir, exist_okTrue) idx 0 while True: ret_l, frame_l cap_left.read() ret_r, frame_r cap_right.read() if not ret_l or not ret_r: break # 左右并排显示方便观察棋盘格是否同时完整 combined cv2.hconcat([frame_l, frame_r]) cv2.imshow(left | right, combined) key cv2.waitKey(1) 0xFF if key ord( ): cv2.imwrite(f{save_dir}/left_{idx:02d}.png, frame_l) cv2.imwrite(f{save_dir}/right_{idx:02d}.png, frame_r) print(fsaved pair {idx}) idx 1 elif key ord(q): break cap_left.release() cap_right.release() cv2.destroyAllWindows()逻辑说明左右相机必须同时读帧否则运动物体或手抖会导致同一时刻的左右图不对应。保存时用同一序号命名后续标定脚本按序号配对读取。参数方面分辨率别设太高640x480 足够标定用1280x720 也可以但角点检测会慢一些。曝光用自动即可但避免强逆光。采集完后用 OpenCV 的findChessboardCorners检测角点再用cornerSubPix亚像素优化。标定板规格要提前确定比如pattern_size (9, 6)表示内角点每行 9 个、每列 6 个。方格物理尺寸square_size 0.025米。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 objpoints [] # 3D 点 imgpoints_l [] # 左图 2D 点 imgpoints_r [] left_imgs sorted(glob.glob(calib_images/left_*.png)) right_imgs sorted(glob.glob(calib_images/right_*.png)) for l_path, r_path in zip(left_imgs, right_imgs): img_l cv2.imread(l_path) img_r cv2.imread(r_path) gray_l cv2.cvtColor(img_l, cv2.COLOR_BGR2GRAY) gray_r cv2.cvtColor(img_r, cv2.COLOR_BGR2GRAY) ret_l, corners_l cv2.findChessboardCorners(gray_l, pattern_size, None) ret_r, corners_r cv2.findChessboardCorners(gray_r, pattern_size, None) if ret_l and ret_r: criteria (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) 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) objpoints.append(objp) imgpoints_l.append(corners_l) imgpoints_r.append(corners_r) # 单目标定 ret_l, mtx_l, dist_l, _, _ cv2.calibrateCamera(objpoints, imgpoints_l, gray_l.shape[::-1], None, None) ret_r, mtx_r, dist_r, _, _ cv2.calibrateCamera(objpoints, imgpoints_r, gray_r.shape[::-1], None, None) # 双目标定 ret, mtx_l, dist_l, mtx_r, dist_r, R, T, E, F cv2.stereoCalibrate( objpoints, imgpoints_l, imgpoints_r, mtx_l, dist_l, mtx_r, dist_r, gray_l.shape[::-1], flagscv2.CALIB_FIX_INTRINSIC # 如果单标结果可信固定内参 ) print(基线 B , np.linalg.norm(T), 米) print(旋转矩阵 R \n, R) print(平移向量 T \n, T)参数说明CALIB_FIX_INTRINSIC表示固定单目标定得到的内参只优化外参。如果单标重投影误差较大可以去掉这个 flag 让联合标定同时优化内参。stereoCalibrate返回的 T 是右相机相对于左相机的平移向量其模长就是基线 B。基线越大深度测量范围越远但视差搜索范围也越大匹配越容易出错。常见 USB 双目基线在 60mm 到 120mm 之间。标定完成后用cv2.stereoRectify计算校正映射再用cv2.initUndistortRectifyMap生成映射表。校正后左右图的极线水平对齐同一物点在左右图的 y 坐标基本一致匹配时只需在同一行搜索效率大幅提升。2.3 标定结果怎么验证才靠谱标定完别急着做匹配先验证。最直接的方法是看重投影误差cv2.calibrateCamera返回的 ret 是平均重投影误差单位像素一般小于 0.5 像素算不错大于 1 像素说明图像质量或角点检测有问题。双目标定后可以用cv2.stereoRectify校正一对图像在左右图上画水平线看同一角点是否落在同一条线上。如果偏差超过两三个像素外参可能不准。另一个验证手段是测已知距离。把棋盘格放在离相机比如 500mm 处用校正后的图像做一次匹配算深度图看棋盘格区域的深度值是否接近 500mm。误差在 5% 以内可以接受。如果差得离谱先检查基线单位——T 的单位跟 square_size 一致square_size 用米T 就是米。注意标定时的光照和后续使用时的光照尽量一致。标定用暖光测试用冷光角点检测和匹配都会受影响。这不是玄学是血泪经验。3. 立体匹配BM、SGBM 和那些调参的坑3.1 块匹配 BM 和半全局匹配 SGBM 怎么选OpenCV 提供两种经典立体匹配算法StereoBM和StereoSGBM。BM 是块匹配速度快适合实时性要求高的场景但视差图噪点多、边缘模糊。SGBM 是半全局匹配用动态规划思想在多个方向聚合代价视差图平滑很多边缘保持也更好但计算量大约是 BM 的 5 到 10 倍。毕设里如果只是出图写论文SGBM 是首选如果要跑在嵌入式板子上做实时BM 更现实。SGBM 的核心参数有这几个minDisparity最小视差通常 0numDisparities视差搜索范围必须是 16 的整数倍比如 64、128、256blockSize匹配块大小奇数3 到 11 之间越大越平滑但细节丢失越多P1、P2是平滑惩罚系数P1 控制视差小变化的惩罚P2 控制大变化的惩罚一般 P1 8 * channels * blockSize^2P2 32 * channels * blockSize^2disp12MaxDiff左右一致性检查阈值设 1 或 2 可以去掉不少错误视差uniquenessRatio唯一性比率5 到 15 之间越大越严格但可能丢弱纹理区域。import cv2 import numpy as np # 读取校正后的左右图 img_l cv2.imread(rectified_left.png, cv2.IMREAD_GRAYSCALE) img_r cv2.imread(rectified_right.png, cv2.IMREAD_GRAYSCALE) # SGBM 参数 min_disp 0 num_disp 128 # 必须是 16 的倍数 block_size 5 stereo cv2.StereoSGBM_create( minDisparitymin_disp, numDisparitiesnum_disp, blockSizeblock_size, P18 * 1 * block_size * block_size, P232 * 1 * block_size * block_size, disp12MaxDiff1, uniquenessRatio10, speckleWindowSize100, speckleRange32, modecv2.STEREO_SGBM_MODE_SGBM_3WAY ) disparity stereo.compute(img_l, img_r).astype(np.float32) / 16.0 # 除以 16 得到真实视差 # 归一化显示 disp_vis cv2.normalize(disparity, None, 0, 255, cv2.NORM_MINMAX, cv2.CV_8U) cv2.imshow(disparity, disp_vis) cv2.waitKey(0)逻辑说明compute返回的是 16 位定点数实际视差是返回值除以 16。speckleWindowSize和speckleRange用于过滤小连通区域的噪点窗口大小 100 表示小于 100 像素的斑点被剔除。mode选SGBM_3WAY比默认的SGBM快一些效果差不多。参数怎么调先固定numDisparities为 128blockSize为 5看视差图。如果近处物体视差超过 128说明搜索范围不够加大到 256。如果噪点多先加大uniquenessRatio到 15再加大speckleWindowSize到 200。如果边缘太糊减小blockSize到 3但噪点会增多。P1、P2 一般不用大改除非视差图出现明显条纹。3.2 视差图后处理左右一致性检查与空洞填充原始 SGBM 视差图在遮挡区域和弱纹理区域会有大量空洞和错误。左右一致性检查是标配把左右图互换再算一次视差对每个像素如果左视差和右视差之差大于disp12MaxDiff就标记为无效。OpenCV 的 SGBM 已经内置了这个检查但你可以手动再做一遍更严格。空洞填充常用两种一是用cv2.filterSpeckles去掉小斑点二是用邻域中值或均值填充无效区域。更高级的用cv2.ximgproc.createDisparityWLSFilter做加权最小二乘滤波能显著提升边缘质量但需要 opencv-contrib 包。# 左右一致性检查手动版 disparity_right stereo.compute(img_r, img_l).astype(np.float32) / 16.0 h, w disparity.shape invalid np.zeros_like(disparity, dtypebool) for y in range(h): for x in range(w): d disparity[y, x] if d 0: xr int(x - d) if 0 xr w: if abs(d - disparity_right[y, xr]) 1.0: invalid[y, x] True else: invalid[y, x] True else: invalid[y, x] True disparity[invalid] 0这段双重循环在 Python 里很慢实际项目建议用 numpy 向量化或 C 实现。逻辑就是左图 x 处视差为 d那么右图对应点应该在 x-d 处如果右图该点的视差与 d 差太多说明这个匹配不可信。提示弱纹理区域是双目匹配的天然死穴。白墙、纯色桌面、光滑金属表面SGBM 基本给不出有效视差。解决办法是给场景加纹理——贴几张报纸、放几个带图案的盒子或者用投影仪打散斑。这不是作弊是工程常规操作。3.3 从视差到深度公式、单位和精度视差转深度的公式很简单z f * B / d。其中 f 是校正后的焦距像素单位B 是基线米d 是视差像素z 是深度米。f 可以从校正后的内参矩阵P_left里取P_left[0, 0]B 从stereoCalibrate的 T 向量模长取。# 假设校正后左相机投影矩阵 P_left f P_left[0, 0] # 焦距像素 B np.linalg.norm(T) # 基线米 # 视差图 disparity 单位像素 depth np.zeros_like(disparity) mask disparity 0 depth[mask] f * B / disparity[mask]精度方面深度误差与视差误差的关系是 Δz z^2 * Δd / (f * B)。也就是说距离越远同样的视差误差导致的深度误差越大呈平方增长。比如 f600 像素B0.06 米在 1 米处视差误差 0.5 像素对应深度误差约 14mm在 3 米处同样视差误差对应深度误差约 125mm。所以双目视觉适合近距离0.5 到 3 米远了精度急剧下降。注意视差图里视差为 0 或负值的像素是无效的计算深度前必须掩掉否则会出现无穷大或负深度。4. 深度图生成与可视化从数据到能看的图4.1 深度图转点云和法线估计拿到深度图后最常见的后续操作是转点云。用相机内参把每个像素的 (u, v, z) 反投影到相机坐标系下的 (X, Y, Z)X (u - cx) * z / fY (v - cy) * z / fZ z。然后可以用 Open3D 或 PCL 做点云可视化、滤波、法线估计。法线估计对后续的平面检测、抓取规划很有用。import numpy as np import open3d as o3d # 假设 depth 是米为单位的深度图fx, fy, cx, cy 是校正后内参 h, w depth.shape fx, fy P_left[0, 0], P_left[1, 1] cx, cy P_left[0, 2], P_left[1, 2] points [] colors [] img_color cv2.imread(rectified_left.png) for v in range(h): for u in range(w): z depth[v, u] if z 0 or z 5.0: # 过滤无效和过远点 continue x (u - cx) * z / fx y (v - cy) * z / fy points.append([x, y, z]) b, g, r img_color[v, u] colors.append([r / 255.0, g / 255.0, b / 255.0]) pcd o3d.geometry.PointCloud() pcd.points o3d.utility.Vector3dVector(np.array(points)) pcd.colors o3d.utility.Vector3dVector(np.array(colors)) o3d.visualization.draw_geometries([pcd])这段代码把深度图变成了带颜色的点云可以直接在 Open3D 窗口里旋转查看。参数方面深度截断设 5 米超过的当无效避免远处噪点干扰。法线估计用pcd.estimate_normals()然后o3d.visualization.draw_geometries([pcd], point_show_normalTrue)可以看方向。4.2 深度图可视化的三种实用方式第一种是伪彩色图用cv2.applyColorMap把深度归一化后映射成 JET 或 TURBO 色表近红远蓝直观。第二种是网格图用matplotlib的plot_surface把深度当高度画三维曲面适合论文插图。第三种是点云截图用 Open3D 调好视角后截图比伪彩色更有空间感。伪彩色图的关键是归一化范围。如果场景深度在 0.5 到 2 米就把cv2.normalize的 min 和 max 设成 0.5 和 2.0别用全局最小最大否则近处细节全挤在一起。# 伪彩色深度图 depth_clipped np.clip(depth, 0.5, 2.0) depth_norm ((depth_clipped - 0.5) / (2.0 - 0.5) * 255).astype(np.uint8) depth_color cv2.applyColorMap(depth_norm, cv2.COLORMAP_JET) cv2.imshow(depth color, depth_color) cv2.waitKey(0)提示论文里放深度图最好同时放原图、视差图和深度伪彩色图三张并排审稿人一眼就能看出你的流程完整。4.3 深度图质量评估的简单指标没有真值深度的情况下可以用几个间接指标有效像素占比视差大于 0 的像素比例、视差图的平滑度相邻像素视差差的均值、以及已知距离物体的深度误差。如果有激光测距仪测几个点的实际距离跟深度图对应位置比算相对误差。一般双目系统在 1 米处相对误差 2% 到 5% 算正常。5. 避坑与排查双目系统翻车的五个典型场景5.1 标定重投影误差大角点检测总失败现象findChessboardCorners返回 False或者标定完 ret 大于 2 像素。原因通常是棋盘格打印不平、光照不均、或者棋盘格尺寸填错。解决把棋盘格贴到硬板上用均匀白光从侧面照避免反光确认pattern_size是内角点数不是方格数square_size用尺子量实际方格边长别用打印设置的标称值。5.2 校正后极线不水平匹配全是斜纹现象stereoRectify后左右图同一物体不在同一行视差图出现斜向条纹。原因一般是双目标定时左右图像没有严格同步或者标定图像中棋盘格姿态太单一。解决采集时用硬件同步或软件同时读帧标定图像要覆盖多种倾斜角度别只拍正面检查stereoCalibrate的 flags如果单标内参不准别用CALIB_FIX_INTRINSIC。5.3 视差图大面积空洞弱纹理区域全黑现象白墙、桌面等区域视差为 0深度图出现大洞。原因是 SGBM 在弱纹理区域找不到可靠匹配。解决给场景加纹理贴报纸、放图案板或者用cv2.ximgproc.createDisparityWLSFilter做后处理填充如果允许换用主动双目带红外散斑投射但成本上升。5.4 深度值整体偏大或偏小比例不对现象已知 1 米的物体深度图算出 1.5 米或 0.7 米。原因通常是基线 B 的单位错了或者焦距 f 取错了。解决确认square_size单位是米T 的模长就是米f 从校正后的P_left[0, 0]取不是从原始内参矩阵取检查stereoRectify输出的Q矩阵用cv2.reprojectImageTo3D可以直接得到三维坐标跟手动公式对比验证。5.5 运行速度太慢达不到实时现象SGBM 在 1280x720 上跑一帧要几百毫秒。原因numDisparities太大、blockSize太大、或者用了 Python 循环做后处理。解决降分辨率到 640x480numDisparities从 256 降到 128 或 64用SGBM_3WAY模式后处理用 numpy 向量化或 C 重写如果还不行换 BM 算法牺牲质量换速度。6. 进阶技巧用 WLS 滤波和参数扫描把深度图质量拉满如果你已经跑通了基础流程想让深度图在论文里更拿得出手有两个方向值得投入一是 WLS 滤波二是参数扫描。WLS 滤波需要opencv-contrib-python安装后这样用import cv2 # 左视差和右视差 disp_left stereo.compute(img_l, img_r).astype(np.float32) / 16.0 disp_right stereo.compute(img_r, img_l).astype(np.float32) / 16.0 # 创建匹配器和滤波器 matcher cv2.StereoSGBM_create( minDisparity0, numDisparities128, blockSize5, P18*1*25, P232*1*25, disp12MaxDiff1, uniquenessRatio10, speckleWindowSize100, speckleRange32 ) right_matcher cv2.ximgproc.createRightMatcher(matcher) wls_filter cv2.ximgproc.createDisparityWLSFilter(matcher) wls_filter.setLambda(8000) wls_filter.setSigmaColor(1.5) filtered_disp wls_filter.filter(disp_left, img_l, disparity_map_rightdisp_right)lambda控制平滑强度8000 是常用起点越大越平滑但可能过模糊。sigmaColor控制颜色相似度对滤波的影响1.5 左右适合大多数场景。WLS 滤波后的视差图在边缘和弱纹理区域明显更干净但计算量增加约 30%。参数扫描则是写一个循环遍历blockSize从 3 到 11uniquenessRatio从 5 到 20speckleWindowSize从 50 到 300每组合算一次视差图用有效像素占比和已知距离误差打分选最优组合。这个过程在毕设里可以做成一张热力图写进论文的“参数分析”章节比只贴一张调好的图有说服力得多。我自己的习惯是先固定numDisparities和blockSize扫uniquenessRatio和speckleWindowSize因为这两个对噪点影响最大然后再微调blockSize和 P1、P2。每次只动一个参数记录有效像素占比和目视效果。别一次改五个参数否则你根本不知道是哪个起了作用。最后说一个容易被忽略的点深度图的坐标系。reprojectImageTo3D输出的点云是在校正后的左相机坐标系下X 向右Y 向下Z 向前。如果你要跟机器人手眼标定对接记得做坐标变换。这个坑我在第一次做抓取实验时踩过点云方向全反了调了一下午才发现是 Y 轴朝下。希望帮到你。本文还有配套的精品资源点击获取