简介:这份资源面向计算机视觉初学者与进阶开发者,提供一套基于双目视觉的深度图像生成与三维空间重建完整实现方案。内容围绕双目相机采集、OpenCV双目标定、畸变校正、极线对齐、视差计算、深度图空洞填充及三维点云重建等核心环节展开,可帮助读者理解从二维图像到三维空间信息的完整技术链路,适用于机器人导航、自动驾驶、AR/VR等场景的学习与实验参考。压缩包共107个文件,约47.54MB,以76张jpg与19张png图像数据为主,辅以yml/yaml标定参数、txt说明、cpp源码及docx文档,并附三维重建客厅gif与m4v演示,便于直观对照算法效果。目前已有130人学习下载。资源包含可运行的DepthMap.cpp核心代码与配套说明文档,读者可据此复现标定、校正、深度估计与点云生成流程,并参考空洞填充思路优化重建质量,适合作为课程设计或项目实践的参考素材。
1. 双目视觉做深度与三维重建:从两张图到一套点云,链路到底长什么样
很多人第一次接触双目视觉,是被“拍两张照片就能测距”这件事吸引的。但真正动手才会发现,从左右两张图到一套能用的三维点云,中间隔着一整条工程链路:相机标定、畸变校正、极线对齐、立体匹配、深度图生成、空洞填充、点云重投影。任何一环参数没调好,最后出来的点云就是一团糊。这套方案要解决的问题很具体——用普通双目相机,在 OpenCV 里跑通一条可复现的深度图与三维重建流水线,让新手能照着搭,熟手能看清每一步的边界在哪。
它适合三类人:做机器人避障、AGV 导航、体积测量、工业检测的工程师;想从单目视觉往三维方向走的开发者;以及手上有双目相机但标定完就卡住、不知道下一步怎么接的人。核心词双目视觉、深度图像、三维重建、OpenCV、双目标定会贯穿全文,但我不打算把它们当名词解释讲,而是按“先标定、再校正、再匹配、再填洞、最后出点云”的顺序,把每一步的参数、代码和翻车点摊开说。
2. 双目标定与极线校正:参数不准,后面全白干
2.1 为什么标定是整条链路的“地基”
双目视觉测距的本质是三角测量:同一个物点在左右相机成像平面上的横坐标差,也就是视差,和深度成反比。公式很简洁,Z = f * B / d,其中f是焦距,B是基线,d是视差。问题在于,这个公式成立的前提是左右图像已经“极线对齐”——同一个物点在左右图里处于同一行,视差只体现在水平方向。而真实相机有镜头畸变、两个相机光轴不平行、装配有误差,所以必须先通过双目标定把这些参数解出来,再用它们做校正。
常见做法是用棋盘格标定板,采集 15 到 25 组左右同步图像,覆盖画面不同位置和角度。标定质量直接决定后面深度图的质量,这一步偷懒,后面用再好的匹配算法也救不回来。
2.2 用 OpenCV 跑通双目标定的最小代码
下面这段代码是标定的核心流程,我一般会把它单独放一个脚本里跑,标定结果存成 npz,后面所有步骤都复用。
import cv2 import numpy as np import glob # 棋盘格内角点数量,注意是内角点,不是格子数 CHESSBOARD = (9, 6) # 棋盘格实际方格边长,单位毫米,按你打印的尺寸改 SQUARE_SIZE = 25.0 # 生成棋盘格三维坐标(Z=0平面) objp = np.zeros((CHESSBOARD[0] * CHESSBOARD[1], 3), np.float32) objp[:, :2] = np.mgrid[0:CHESSBOARD[0], 0:CHESSBOARD[1]].T.reshape(-1, 2) objp *= SQUARE_SIZE objpoints = [] # 三维点 imgpoints_l = [] # 左图角点 imgpoints_r = [] # 右图角点 left_images = sorted(glob.glob('calib/left/*.png')) right_images = sorted(glob.glob('calib/right/*.png')) criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) for lpath, rpath in zip(left_images, right_images): img_l = cv2.imread(lpath) img_r = cv2.imread(rpath) 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, CHESSBOARD, None) ret_r, corners_r = cv2.findChessboardCorners(gray_r, CHESSBOARD, None) if ret_l and ret_r: # 亚像素精化,这一步对精度影响很大 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], criteria=criteria, flags=cv2.CALIB_FIX_INTRINSIC ) print('基线 B =', np.linalg.norm(T), 'mm') 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)逻辑说明:findChessboardCorners负责找角点,cornerSubPix把角点精度从像素级提到亚像素级,这一步不做,重投影误差会明显偏大。stereoCalibrate里的CALIB_FIX_INTRINSIC表示沿用单目标定得到的内参,只优化两个相机之间的相对位姿。如果你发现基线B和实际卷尺量出来的差很多,基本可以判定标定板图像质量或角点检测出了问题。
参数说明:CHESSBOARD必须和实际棋盘格内角点一致,写错一位结果全错;SQUARE_SIZE只影响平移向量的物理单位,不影响像素级精度,但做真实测距时必须填对;criteria的迭代次数和精度阈值一般用 30 和 0.001 就够。
2.3 极线校正:把左右图拉到同一行
标定完只是拿到参数,真正让匹配变简单的是stereoRectify加initUndistortRectifyMap。校正后,左右图的极线变成水平,同一个物点在同一行,匹配只需要在水平方向找对应点,搜索空间从二维降到一维。
import cv2 import numpy as np data = np.load('stereo_params.npz') mtx_l, dist_l = data['mtx_l'], data['dist_l'] mtx_r, dist_r = data['mtx_r'], data['dist_r'] R, T = data['R'], data['T'] img_l = cv2.imread('scene/left.png') img_r = cv2.imread('scene/right.png') h, w = img_l.shape[:2] # 计算校正变换 R1, R2, P1, P2, Q, roi1, roi2 = cv2.stereoRectify( mtx_l, dist_l, mtx_r, dist_r, (w, h), R, T, flags=cv2.CALIB_ZERO_DISPARITY, alpha=0 ) # 生成映射表 map1_l, map2_l = cv2.initUndistortRectifyMap(mtx_l, dist_l, R1, P1, (w, h), cv2.CV_16SC2) map1_r, map2_r = cv2.initUndistortRectifyMap(mtx_r, dist_r, R2, P2, (w, h), cv2.CV_16SC2) # 重映射,得到校正后的左右图 rect_l = cv2.remap(img_l, map1_l, map2_l, cv2.INTER_LINEAR) rect_r = cv2.remap(img_r, map1_r, map2_r, cv2.INTER_LINEAR) cv2.imwrite('rect_left.png', rect_l) cv2.imwrite('rect_right.png', rect_r) np.save('Q.npy', Q) # Q 矩阵后面重投影成三维点要用逻辑说明:stereoRectify输出Q矩阵,这是后面reprojectImageTo3D把视差图变成三维点云的关键。alpha=0表示校正后只保留有效像素,边缘会被裁掉;如果想让画面完整一点,可以设alpha=1,但会引入黑色无效区域。initUndistortRectifyMap把畸变校正和极线校正合并成一张映射表,remap一次完成,比先校正畸变再旋转快很多。
参数说明:CALIB_ZERO_DISPARITY让左右相机主点在校正后对齐,是双目标准做法;cv2.CV_16SC2是映射表格式,配合remap使用,别改成CV_32FC1除非你有特殊需求。
验证校正是否成功,最简单的办法是在校正后的左右图上画水平线,看同一个物点是不是落在同一行。如果偏差超过一两个像素,说明标定或校正有问题,别急着往下走。
3. 立体匹配与深度图生成:SGBM 参数怎么调才不糊
3.1 匹配算法选型:为什么我默认用 SGBM
OpenCV 里能直接用的立体匹配算法有StereoBM和StereoSGBM。BM 快但噪点多,对纹理弱和光照变化很敏感;SGBM 基于半全局匹配,代价聚合考虑了多个方向,深度图连续性和边缘保持都明显更好。做三维重建,我一般默认 SGBM,只有在嵌入式设备算力实在不够时才退回 BM。
SGBM 的核心参数就那么几个:minDisparity、numDisparities、blockSize、P1、P2、uniquenessRatio、speckleWindowSize。调参的本质是在“匹配范围、平滑程度、噪声抑制”之间找平衡。
3.2 SGBM 生成视差图的完整代码与参数含义
import cv2 import numpy as np rect_l = cv2.imread('rect_left.png', cv2.IMREAD_GRAYSCALE) rect_r = cv2.imread('rect_right.png', cv2.IMREAD_GRAYSCALE) # numDisparities 必须是 16 的整数倍 num_disp = 16 * 6 block_size = 5 stereo = cv2.StereoSGBM_create( minDisparity=0, 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=2, preFilterCap=63, mode=cv2.STEREO_SGBM_MODE_SGBM_3WAY ) disparity = stereo.compute(rect_l, rect_r).astype(np.float32) / 16.0 # 归一化便于显示 disp_vis = cv2.normalize(disparity, None, 0, 255, cv2.NORM_MINMAX) disp_vis = np.uint8(disp_vis) cv2.imwrite('disparity.png', disp_vis) np.save('disparity.npy', disparity)逻辑说明:compute返回的是定点数,除以 16 才是真实视差。P1和P2是平滑惩罚项,控制视差在相邻像素间的变化代价,P2一般是P1的四倍左右。uniquenessRatio用来剔除匹配歧义,值越大越严格,但太大会让弱纹理区域大片无效。speckleWindowSize和speckleRange做连通域滤波,去掉小面积噪点。
参数说明:numDisparities决定能测的最近距离,值越大能覆盖的视差范围越广,但计算量也越大。如果你发现近处物体视差被截断,就加大这个值。blockSize一般 3 到 11,太小噪声多,太大边缘糊。disp12MaxDiff是左右一致性检查阈值,设 1 到 2 能有效去掉遮挡区错误匹配。
3.3 从视差图到深度图:Q 矩阵怎么用
有了视差,深度图就是一行代码的事,但前提是Q矩阵来自同一套校正参数。
import cv2 import numpy as np disparity = np.load('disparity.npy') Q = np.load('Q.npy') # 视差为 0 或负数的位置是无效点,重投影前先处理 disparity[disparity <= 0] = -1 points_3d = cv2.reprojectImageTo3D(disparity, Q) # 深度就是 Z 通道 depth = points_3d[:, :, 2] depth_valid = depth.copy() depth_valid[depth_valid <= 0] = 0 depth_vis = cv2.normalize(depth_valid, None, 0, 255, cv2.NORM_MINMAX) cv2.imwrite('depth.png', np.uint8(depth_vis))逻辑说明:reprojectImageTo3D把每个像素的视差通过Q矩阵映射成三维坐标,输出是H×W×3的数组。深度值单位和你标定时SQUARE_SIZE的单位一致,填毫米就是毫米。无效视差要提前置成 -1,否则会算出离谱的深度值。
参数说明:Q矩阵不能自己拼,必须用stereoRectify输出的那个。如果你换了图像分辨率,Q也要重新算,否则深度全错。
4. 空洞填充与点云优化:让深度图从“能用”到“好用”
4.1 深度图为什么会有空洞
SGBM 出来的深度图,空洞主要来自三个地方:弱纹理区域匹配失败、左右遮挡区、视差超出搜索范围。这些空洞如果不处理,重投影出来的点云就是一片片缺失,做测量或避障都不靠谱。常见填充思路有三种:邻域插值、形态学闭运算、引导滤波。我一般先用左右一致性检查标出无效区,再用邻域中值填充,最后用引导滤波平滑边缘。
4.2 空洞填充的实操代码
import cv2 import numpy as np depth = np.load('depth_raw.npy').astype(np.float32) valid_mask = (depth > 0).astype(np.uint8) # 方法一:用有效邻域的中值填充小空洞 kernel = np.ones((5, 5), np.uint8) depth_dilated = cv2.dilate(depth, kernel) mask_dilated = cv2.dilate(valid_mask, kernel) depth_filled = np.where(valid_mask == 0, depth_dilated, depth) depth_filled[mask_dilated == 0] = 0 # 方法二:引导滤波,用灰度图做引导,平滑深度同时保边 guide = cv2.imread('rect_left.png', cv2.IMREAD_GRAYSCALE).astype(np.float32) / 255.0 depth_norm = depth_filled / (depth_filled.max() + 1e-6) depth_smooth = cv2.ximgproc.guidedFilter(guide, depth_norm, radius=8, eps=1e-3) depth_smooth = depth_smooth * (depth_filled.max() + 1e-6) cv2.imwrite('depth_filled.png', depth_smooth)逻辑说明:先膨胀再取原值,相当于用周围有效像素的中值填洞,适合小面积空洞。引导滤波用左图灰度做引导,能在平滑深度的同时保住物体边缘,避免填充后边缘发虚。radius控制滤波窗口,eps控制保边强度,eps越小越保边。
参数说明:kernel大小根据空洞尺寸调,5×5 适合小洞,大洞要配合多尺度填充。guidedFilter需要opencv-contrib-python,普通opencv-python里没有ximgproc,这是新手最容易踩的坑之一。
4.3 点云生成与保存
填充完深度,就可以把三维点导出成点云文件,用 CloudCompare 或 MeshLab 查看。
import numpy as np import open3d as o3d points_3d = np.load('points_3d.npy') depth_filled = np.load('depth_filled.npy') color = cv2.imread('rect_left.png')[:, :, ::-1] # BGR 转 RGB h, w = depth_filled.shape points = points_3d.reshape(-1, 3) colors = color.reshape(-1, 3) / 255.0 # 只保留有效深度点 valid = depth_filled.reshape(-1) > 0 points = points[valid] colors = colors[valid] pcd = o3d.geometry.PointCloud() pcd.points = o3d.utility.Vector3dVector(points) pcd.colors = o3d.utility.Vector3dVector(colors) o3d.io.write_point_cloud('cloud.ply', pcd)逻辑说明:reprojectImageTo3D输出的点云是稠密的,每个像素一个点,直接保存文件会很大。实际用的时候一般会按深度范围或置信度做筛选。open3d保存的 PLY 带颜色,方便直接看效果。
参数说明:如果点云方向不对,检查Q矩阵和图像分辨率是否匹配。点云单位取决于标定单位,做真实测量前一定确认。
5. 避坑与排查:双目链路里最容易翻车的 5 个地方
5.1 标定重投影误差大,深度整体偏移
现象:stereoCalibrate返回的误差超过 1 像素,深度图和真实距离差很多。 原因:标定板图像太少、角度太单一,或者角点检测不准。 解决:采集 20 组以上,覆盖画面四角和不同倾斜角度;打开cornerSubPix;检查棋盘格打印是否平整,反光严重的板子换哑光材质。
5.2 校正后左右图不在同一行
现象:画水平线看同一个物点,左右图错开好几像素。 原因:stereoRectify用的内参和畸变系数不是同一套,或者图像分辨率变了但Q没重算。 解决:确保标定、校正、重投影三步用的是同一组参数和同一分辨率;换分辨率后重新跑stereoRectify。
5.3 SGBM 深度图大片黑色
现象:视差图大部分是无效值,只有近处一小块有颜色。 原因:numDisparities太小,远处视差超出搜索范围;或者uniquenessRatio太大,弱纹理区全被剔除。 解决:先加大numDisparities到 16 的整数倍,再适当降低uniquenessRatio到 5 到 10 之间,观察有效区域变化。
5.4 空洞填充后边缘发虚、物体变胖
现象:填充后深度图看起来平滑了,但物体轮廓糊了,测距偏大。 原因:形态学核太大,或者引导滤波eps设得太大,把边缘也平滑掉了。 解决:减小填充核,改用引导滤波并调小eps;对边缘区域单独处理,不要全局一刀切。
5.5 点云颜色和位置对不上
现象:PLY 文件打开后颜色错位,或者点云整体旋转了。 原因:颜色图用了校正前的原图,而点云来自校正后的坐标系;或者保存时通道顺序搞反。 解决:颜色一定用rect_left.png,和深度图同源;Open3D 要 RGB,OpenCV 读进来是 BGR,记得翻转通道。
6. 进阶技巧:用视差一致性做深度图质量自检
链路跑通之后,真正决定这套系统能不能上项目的,是你能不能快速判断一张深度图靠不靠谱。我自己的习惯是做一个视差一致性自检:把左视差图通过视差偏移投影到右图坐标系,和右视差图比对,差异超过阈值的像素标为不可信。这个检查不需要额外硬件,纯软件就能跑,能提前发现标定漂移、匹配参数退化这些问题。
import cv2 import numpy as np disp_l = np.load('disparity_l.npy') disp_r = np.load('disparity_r.npy') h, w = disp_l.shape xx = np.tile(np.arange(w), (h, 1)).astype(np.float32) # 左视差投影到右图坐标 xr = xx - disp_l xr_int = np.round(xr).astype(np.int32) valid = (xr_int >= 0) & (xr_int < w) & (disp_l > 0) disp_r_warp = np.zeros_like(disp_l) disp_r_warp[valid] = disp_r[np.where(valid)[0], xr_int[valid]] # 一致性阈值,一般 1 到 2 像素 diff = np.abs(disp_l - disp_r_warp) consistent = (diff < 1.5) & valid print('可信像素占比:', consistent.sum() / (disp_l > 0).sum())逻辑说明:这个自检本质是左右一致性检查的显式实现。可信占比低于 70% 时,我一般不会直接拿这张深度图做测量,而是回头查标定和匹配参数。diff阈值设 1.5 像素是经验值,场景纹理特别弱时可以放宽到 2。
参数说明:disp_r需要单独用右图作参考跑一次 SGBM 得到,左右视差图符号相反,投影时注意方向。这个检查会增加一倍计算量,但换来的是对深度图质量的量化判断,做产品时很值。
我自己的习惯是,每次换相机、换镜头、甚至相机被撞过一下,都先跑一遍标定和这个一致性自检,再谈后面的重建。双目视觉这套东西,玄学的地方不多,大部分翻车都能追溯到标定和参数。把这条链路固化成脚本,比每次手动调参靠谱得多。希望帮到你。
本文还有配套的精品资源,点击获取