简介:这份资源是面向计算机、人工智能、自动化、电子信息等专业学生与科研人员的双目摄像头立体视觉系统完整项目包,围绕相机标定、立体匹配与深度图生成三大核心环节展开,可作为毕业设计、课程设计或项目立项演示的参考方案。压缩包共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_ok=True) 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(f"saved 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], flags=cv2.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最小视差,通常 0;numDisparities视差搜索范围,必须是 16 的整数倍,比如 64、128、256;blockSize匹配块大小,奇数,3 到 11 之间,越大越平滑但细节丢失越多;P1、P2是平滑惩罚系数,P1 控制视差小变化的惩罚,P2 控制大变化的惩罚,一般 P1 = 8 * channels * blockSize^2,P2 = 32 * channels * blockSize^2;disp12MaxDiff左右一致性检查阈值,设 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( minDisparity=min_disp, numDisparities=num_disp, blockSize=block_size, P1=8 * 1 * block_size * block_size, P2=32 * 1 * block_size * block_size, disp12MaxDiff=1, uniquenessRatio=10, speckleWindowSize=100, speckleRange=32, mode=cv2.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为 128,blockSize为 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, dtype=bool) 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)。也就是说,距离越远,同样的视差误差导致的深度误差越大,呈平方增长。比如 f=600 像素,B=0.06 米,在 1 米处,视差误差 0.5 像素对应深度误差约 14mm;在 3 米处,同样视差误差对应深度误差约 125mm。所以双目视觉适合近距离(0.5 到 3 米),远了精度急剧下降。
注意:视差图里视差为 0 或负值的像素是无效的,计算深度前必须掩掉,否则会出现无穷大或负深度。
4. 深度图生成与可视化:从数据到能看的图
4.1 深度图转点云和法线估计
拿到深度图后,最常见的后续操作是转点云。用相机内参把每个像素的 (u, v, z) 反投影到相机坐标系下的 (X, Y, Z):X = (u - cx) * z / f,Y = (v - cy) * z / f,Z = 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_normal=True)可以看方向。
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 循环做后处理。解决:降分辨率到 640x480;numDisparities从 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( minDisparity=0, numDisparities=128, blockSize=5, P1=8*1*25, P2=32*1*25, disp12MaxDiff=1, uniquenessRatio=10, speckleWindowSize=100, speckleRange=32 ) 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_right=disp_right)lambda控制平滑强度,8000 是常用起点,越大越平滑但可能过模糊。sigmaColor控制颜色相似度对滤波的影响,1.5 左右适合大多数场景。WLS 滤波后的视差图在边缘和弱纹理区域明显更干净,但计算量增加约 30%。
参数扫描则是写一个循环,遍历blockSize从 3 到 11,uniquenessRatio从 5 到 20,speckleWindowSize从 50 到 300,每组合算一次视差图,用有效像素占比和已知距离误差打分,选最优组合。这个过程在毕设里可以做成一张热力图,写进论文的“参数分析”章节,比只贴一张调好的图有说服力得多。
我自己的习惯是:先固定numDisparities和blockSize,扫uniquenessRatio和speckleWindowSize,因为这两个对噪点影响最大;然后再微调blockSize和 P1、P2。每次只动一个参数,记录有效像素占比和目视效果。别一次改五个参数,否则你根本不知道是哪个起了作用。
最后说一个容易被忽略的点:深度图的坐标系。reprojectImageTo3D输出的点云是在校正后的左相机坐标系下,X 向右,Y 向下,Z 向前。如果你要跟机器人手眼标定对接,记得做坐标变换。这个坑我在第一次做抓取实验时踩过,点云方向全反了,调了一下午才发现是 Y 轴朝下。
希望帮到你。
本文还有配套的精品资源,点击获取