简介:基于Python与OpenCV构建的普通相机图像测距系统,面向计算机视觉初学者及立体成像项目开发者,解决单目与双目相机标定校正、视差计算和深度测量等核心问题。压缩包共41个文件,约2.35MB,包含26张左右相机标定与视差测试用JPG图像、6张PNG标定结果示意图、4个可直接运行的Python脚本(主程序、双目标定、单目校准、绘图辅助)、2个说明文档和1份DOCX配套资料,目录划分清晰,便于按模块对照学习。
系统覆盖相机内参求取、双目标定外参确定、立体特征匹配、视差图生成与深度映射的完整技术流程。通过棋盘格标定板计算焦距和畸变系数,采用SIFT、SURF或ORB等算法完成立体匹配,并将视差转换为实际物理距离。资源配套真实拍摄的左右图像与标定结果,适合需要快速上手图像测距实现细节的开发者。已有179人学习使用。
1. 普通相机测距的第一步:先分清单目和双目谁在干体力活
拿着一个普通USB摄像头去做图像测距,很多人的第一反应是“OpenCV里是不是有现成的测距函数”。答案是:有一条路,但没有任何一个函数能让你不标定就直接读出真实距离。所谓普通相机图像测距系统,本质上是把相机的成像几何关系还原出来——相机标定负责告诉你感光芯片和镜头之间的真实空间关系,视差计算或单目几何假设负责把这个关系投影到真实世界。单目测距的工程路径依赖“目标在已知平面上的几何约束”,比如车辆底盘贴地、行人身高近似;双目测距则靠左右相机的视差做三角化,理论上对任意可见点都能给出深度。选哪条路,取决于你的使用场景里有多少“已知条件”可用。
适合做这套方案的人,是手里只有普通相机、不想上激光雷达和深度相机,却需要在2到5米范围内拿到厘米级结果的开发者。无论是毕设、视觉大作业还是小型巡检项目,这条路都能走通,但前提是你愿意在标定棋盘格和参数调优上花足时间。这往往不是算法玄学,而是流程工程。
2. 相机标定与校正:从棋盘格照片到可用的内参矩阵
2.1 为什么标定不能跳过:内参、畸变直接写进测距公式
普通相机的镜头不是理想小孔。OpenCV里能算的测距公式全部依赖三个量:焦距fx/fy、光心位置cx/cy、畸变系数k1/k2/p1/p2/k3。如果不做标定直接使用标称参数或瞎填一组数值,误差通常在10%以上,极端情况下光心偏了30像素,远处目标距离直接偏出几十厘米。我见过太多人写双目代码时把焦距填成传感器尺寸和镜头焦距的换算值,结果视差图算出来深度整体平移,这就是典型的黑匣子式翻车。
先说明标定要处理的两类问题。第一类是内参矩阵mtx:
fx/sx 0 cx
0 fy/sy cy
0 0 1
fx和fy分别是x、y方向的等效焦距,单位是像素;cx、cy是主点,理论上在图像中心,但装配误差会让它偏移几十个像素。第二类是畸变系数dist,径向畸变k1/k2/k3和切向畸变p1/p2。对普通USB摄像头,k3一般可以忽略,但对广角镜头必须保留。OpenCV提供了cv2.calibrateCamera来同时求解这两个量,前提是你提供足够的棋盘格角点图像对。
如果用的是鱼眼镜头,普通标定流程会失效,你得改用cv2.fisheye.calibrate,数学模型不同。拿普通针孔模型的标定结果去校正鱼眼图像,边缘畸变永远消不干净。常见做法是先确认镜头视场角,超过120度就按鱼眼流程单独处理。
2.2 单目标定的最小可运行流程:calibrateCamera
标定的输入是至少10到15张左右不同角度、不同距离拍摄的棋盘格照片。我这里说的棋盘格是指内角点为9x6的打印图案,每格边长需要提前量准,单位用米。这是整个流程里最容易被忽略的坑——边长量错一毫米,焦距就错一批像素,后续测距全盘偏。
下面是单目标定代码,直接保存内参和畸变系数到npz文件:
import cv2 import numpy as np import glob chessboard_size = (9, 6) # 内角点数,不是格子数 square_size = 0.025 # 每个黑白格的物理边长,单位米 # 准备世界坐标系中的棋盘格角点坐标,z恒为0 objp = np.zeros((chessboard_size[0] * chessboard_size[1], 3), np.float32) objp[:, :2] = np.mgrid[0:chessboard_size[0], 0:chessboard_size[1]].T.reshape(-1, 2) objp *= square_size obj_points = [] # 世界坐标 img_points = [] # 图像坐标 images = sorted(glob.glob('calib_images/*.jpg')) for fname in images: img = cv2.imread(fname) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) ret, corners = cv2.findChessboardCorners(gray, chessboard_size, None) if ret: # 亚像素精确化 criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 50, 1e-6) corners2 = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria) obj_points.append(objp) img_points.append(corners2) ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera( obj_points, img_points, gray.shape[::-1], None, None ) np.savez('camera_params.npz', mtx=mtx, dist=dist) print("重投影误差:", ret) print("内参矩阵:\n", mtx) print("畸变系数:", dist.ravel())cv2.findChessboardCorners的输入是灰度图,直接传彩色图会报错;返回的corners是形状为(N,1,2)的数组,cornerSubPix要求这个维度,别自行reshape。calibrateCamera输出五个量,其中ret是重投影均方根误差,单位像素,小于0.5说明标定质量不错;mtx是3x3内参矩阵;dist是畸变系数,默认是5个元素即[k1,k2,p1,p2,k3]。
有一个经验值得记住:标定照片的角点必须在画面里分布得足够散。全部把棋盘格放在正中央拍,畸变参数会非常不稳定,因为边缘信息缺失。我一般会保证每张图里棋盘格占比在四分之一到一半之间,同时覆盖四个角落和中心区域。
关于棋盘格的尺寸,我在上面代码里用了0.025米即25毫米。打印后必须用尺子量一下实际边长,打印比例伸缩很常见。有条件的把棋盘格贴在硬质平板上,贴纸褶皱会在亚像素细化时引入系统性偏差,重投影误差再小都救不回来。
2.3 双目相机标定与立体校正:让左右图“平视”同一个世界
单目标定只解决单个相机看到的世界,双目测距还需要知道两个相机之间的相对位姿关系,即旋转矩阵R和平移向量T。这就是stereoCalibrate要做的事。它输入左右相机各自的内参和畸变系数,以及同一个棋盘格在左右相机画面中的角点坐标,输出R和T以及本质矩阵E、基础矩阵F。
双目标定的本质是优化一个联合代价:右相机看到的点,经过R和T变换后,应该在左相机坐标系里出现在正确的极线上。如果不做立体校正,左右图中的对应点不在同一水平线上,视差搜索就成了二维搜索,计算量和误匹配率都会大幅上升。
立体校正有两个阶段。第一个阶段是stereoRectify,根据R和T把左右相机的光轴旋转到平行,同时让左右图像平面共面且行对准;第二个阶段是remap,用生成的映射表把原图像重采样成校正图像。下面是完整的校正流程,包括保存映射表供后续直接加载:
import cv2 import numpy as np # 从单目标定结果加载左右相机参数 dataL = np.load('left_camera.npz') dataR = np.load('right_camera.npz') mtxL, distL = dataL['mtx'], dataL['dist'] mtxR, distR = dataR['mtx'], dataR['dist'] # 双目标定,使用同一棋盘格的左右角点 ret, mtxL, distL, mtxR, distR, R, T, E, F = cv2.stereoCalibrate( obj_points, img_points_l, img_points_r, mtxL, distL, mtxR, distR, gray_size[::-1], # 图像宽高 criteria=(cv2.TERM_CRITERIA_MAX_ITER + cv2.TERM_CRITERIA_EPS, 100, 1e-5), flags=cv2.CALIB_USE_INTRINSIC_GUESS ) # 立体校正,alpha=0表示只保留公共区域,丢弃遮幅 R1, R2, P1, P2, Q, roi1, roi2 = cv2.stereoRectify( mtxL, distL, mtxR, distR, gray_size[::-1], R, T, alpha=0 ) # 生成重映射表 map1L, map2L = cv2.initUndistortRectifyMap( mtxL, distL, R1, P1, gray_size[::-1], cv2.CV_32FC1 ) map1R, map2R = cv2.initUndistortRectifyMap( mtxR, distR, R2, P2, gray_size[::-1], cv2.CV_32FC1 ) np.savez('rectify_maps.npz', map1L=map1L, map2L=map2L, map1R=map1R, map2R=map2R, Q=Q, P1=P1) # 对左右图做校正 imgL_rect = cv2.remap(imgL, map1L, map2L, cv2.INTER_LINEAR) imgR_rect = cv2.remap(imgR, map1R, map2R, cv2.INTER_LINEAR)这里的flags使用了cv2.CALIB_USE_INTRINSIC_GUESS,意思是把单目标定得到的内参作为初始值,再在双目标定中做微调。实际上双目标定并不会重算焦距,它是在优化R和T的同时微调内参。如果左右相机本身不同型号或者分辨率不一致,需要各自先做单目标定再联合优化,不能只标其中一边。
保存下来的Q矩阵是一个4x4的投影矩阵,在后面的reprojectImageTo3D中会用到,它把视差图直接映射成三维坐标。P1矩阵的[0][0]即左相机的焦距fx,在手动计算深度时也要用到。这里有个常见错误:很多人直接把P1当成mtx去算深度,但立体校正之后光轴已经重新定义,P1才是校正后左相机的投影矩阵。用原内参去算深度,会出现系统性偏移。
2.4 标定质量的快速验证:画线时间到了
做完校正后,总得先确认极线是否对齐再进入视差计算。最直观的验证方法是把左右校正图画在同一个窗口里,每隔一定行数画一条水平直线。如果角点或物体轮廓上的特征点在水平线附近来回跳动超过1到2个像素,说明校正不够好。
原因通常是双目标定用的照片太少或者棋盘格在视野中占比太小。还有一个隐蔽原因:标定照片里有一个位置的棋盘格被手指按住了边缘,cornerSubPix把指尖当成了角点。检查方法很暴力——把每一张用于标定的棋盘格检测结果可视化,确认角点顺序没有错位。角点顺序是最容易翻车的地方,一旦标定照片左上角被裁掉了棋子,整张图的内角点检测顺序全部平移一位,世界坐标和图像坐标对不上,重投影误差直接飙到几个像素。
一个更省事的做法是把标定工具换成OpenCV的cv2.CharucoBoard。ChArUco板由ArUco标定板与棋盘混合构成,它允许标定照片中出现部分遮挡,只要有足够多的角点就能参与优化,对新手友好得多。如果你打算长期做相机标定,我建议直接上ChArUco板,棋盘格板可以作为备用方案。
3. 双目视差计算与深度测量:SGBM参数和三角化落地
3.1 视差是什么:从极线约束到深度公式
双目测距的核心物理公式是深度Z = f * B / d。其中f是焦距(像素单位),B是左右相机光心之间的水平距离(基线),d是同一个三维点在左右图像中的水平像素差,叫视差。这个公式成立的先决条件就是上一节的立体校正——校正后左右图像行对齐,同一空间点只看水平方向的像素偏移就行。这也就是为什么前面花那么多篇幅做标定和校正:一分钱一分货,标定质量直接决定深度公式的精度。
在OpenCV里,d的单位不是整数像素,SGBM输出的视差图以1/16像素为最小单位。也就是说计算真实视差时,必须把输出值除以16.0,否则深度会整体偏小16倍。这个坑我印象很深:第一次跑通SGBM时,看到视差图上物体边缘亮度正常,但reprojectImageTo3D后的深度值小得离谱,排查半天才发现是忘了除以16。
视差计算有两种典型算法:BM(Block Matching)更快但精度差,SGBM(Semi-Global Block Matching)在普通CPU上也能跑到实时。这个标题下我们一般选SGBM。SGBM的核心思路是在每行上做块匹配,然后用动态规划对整行进行能量平滑,既保留边缘又抑制噪声。它适用于纹理适中的场景,白墙、天空这种平坦区域是它的天敌。
3.2 SGBM的Python调用与全参数调优
先给出一段可运行的SGBM计算视差图的代码,包含必要的预处理和后处理:
import cv2 import numpy as np # 加载校正映射表 maps = np.load('rectify_maps.npz') imgL_rect = cv2.remap(imgL, maps['map1L'], maps['map2L'], cv2.INTER_LINEAR) imgR_rect = cv2.remap(imgR, maps['map1R'], maps['map2R'], cv2.INTER_LINEAR) # 转灰度并做直方图均衡化,提升弱纹理区域匹配率 grayL = cv2.cvtColor(imgL_rect, cv2.COLOR_BGR2GRAY) grayR = cv2.cvtColor(imgR_rect, cv2.COLOR_BGR2GRAY) clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) grayL = clahe.apply(grayL) grayR = clahe.apply(grayR) # 创建SGBM实例 sgbm = cv2.StereoSGBM_create( minDisparity=0, numDisparities=64, blockSize=11, P1=8 * 3 * 11 ** 2, P2=32 * 3 * 11 ** 2, disp12MaxDiff=1, uniquenessRatio=10, speckleWindowSize=100, speckleRange=32, mode=cv2.STEREO_SGBM_MODE_SGBM ) # 计算视差图并转成浮点型,注意除以16 disparity = sgbm.compute(grayL, grayR).astype(np.float32) / 16.0 # 去掉无效像素 disparity[disparity <= 0] = np.nan随后用reprojectImageTo3D把视差图转成三维点云,其中Q矩阵来自stereoRectify的输出:
points_3d = cv2.reprojectImageTo3D(disparity, Q) # points_3d的第三个通道就是该像素对应的深度值Z depth_map = points_3d[:, :, 2]参数里最关键的是numDisparities和P1/P2。numDisparities必须是16的倍数,它决定了能检测的最大视差范围。基线越短、目标越近,需要的视差范围越大。比如基线12厘米、最近目标0.5米,在640分辨率下视差可能达到80像素,numDisparities要给到96或128。但给太大也不是好事,因为大视差范围会让搜索时间变长,同时远处的匹配噪声会被放大。
blockSize必须是奇数,典型值在5到15之间。值太小,弱纹理区域匹配噪声大;值太大,物体边缘的深度会变得圆滑,边缘处出现“背景污染”。P1和P2是平滑惩罚项,控制视差图的平滑度。P1惩罚小梯度变化,P2惩罚大梯度变化,P2通常取P1的4到6倍。代码里用的83blockSize^2是OpenCV文档给出的经验参考,实际项目中如果深度图边缘断裂严重,可以适当增大P2;如果整幅图过于平滑丢失细节,则减小P2。
uniquenessRatio表示在搜索范围内,最优匹配值要超过次优匹配值多少百分比才认为是有效匹配。设小了会有大量误匹配,设大了有效像素覆盖率会降低,通常取5到15。speckleWindowSize和speckleRange用于剔除“斑点”状的小块孤立噪声,值越大滤除能力越强,但也会误杀细长物体的真实视差。
3.3 从视差图到深度值:选点与滤波
拿到depth_map之后怎么取距离?很多人直接取目标区域的均值或最值,这不太靠谱。因为SGBM在物体边缘处必然存在几个像素的“绒毛”,这些点的视差可能跳向背景,直接把深度均值拉偏。常见做法是先在视差图上做中值滤波或双边滤波,再取中心区域的中位数:
# 中值滤波消除零星噪声 depth_filtered = cv2.medianBlur(depth_map, 5) # 手动给一个目标ROI,取中位数作为最终距离 roi = depth_filtered[120:180, 200:260] valid = roi[np.isfinite(roi)] # 去掉无效点 final_depth = np.median(valid) print(f"目标距离: {final_depth:.3f} 米")为什么用中位数而不是均值?因为深度图的噪声往往不是高斯噪声,而是呈长尾分布的离群点。一个像素如果被错误地匹配到背景,深度可能差出几十米,均值会被这类点瞬间拉飞,中位数则稳如泰山。如果取中位数后标准差依然很大,说明这个区域不适合用SGBM测距,可以考虑更高分辨率的图像或者引入时序平均。
视差与深度的换算关系(双目)
| 参数 | 含义 | 对精度的影响 |
|---|---|---|
| 基线B | 左右相机光心水平距离 | B越大,近处精度越高,但远处匹配难度也越大 |
| 焦距f | 校正后P1[0][0] | f越大,同一距离下视差越大,深度分辨率越高 |
| 视差误差Δd | 一般在0.5~1像素 | 深度误差与Z²成正比,距离越远误差增长越快 |
| 图像分辨率 | 像素总数 | 分辨率减半,视差量化误差翻倍 |
从这张表能推导出一个工程结论:双目测距的精度是按距离平方衰减的。在3米以内做到厘米级不难,到10米时同样配置下误差可能就超过半米了。这也是为什么很多落地系统只把双目当作近距离避障传感器,远距离还是靠激光雷达或单目几何。
4. 单目测距的工程化方案:已知高度与几何关系的简化实现
4.1 小孔成像与相似三角形:从一个实物的像素高度反推距离
单目测距在所有讲述里都绕不开一个事实:单个相机从图像上无法恢复绝对尺度。但如果有先验信息——比如已知物体的实际高度、宽度,或者已知相机安装高度和俯仰角——尺度就被约束住了,此时单目测距的精度在某些情况下甚至优于双目。
最常见的公式来自相似三角形:设物体实际高度为H(米),在图像中占据h像素,相机焦距fy(像素),则距离d = fy * H / h。这个公式对正对镜头的竖直目标(如行人、标牌、杆子)尤其好用。前提是物体底部没有被遮挡,且物体姿态与地面垂直。如果你要测一辆汽车的车距,对着车尾牌照区域用这个公式比整体车身更稳,因为牌照尺寸是标准化的,而且不随车型高度波动。
4.2 地面投影测距:相机固定在车顶或云台时的几何计算
巡检机器人和车载系统的情况更典型:相机高度h固定,光轴相对水平面有一个俯仰角theta。目标在地面上的点对应图像中的某一行y。此时距离公式变成:
import numpy as np # 从标定得到内参 mtx = np.load('camera_params.npz')['mtx'] fy = mtx[1, 1] cy = mtx[1, 2] # 相机安装高度和俯仰角(弧度) cam_height = 0.35 # 相机离地高度,单位米 pitch_angle = 0.12 # 光轴俯仰角,单位弧度,约7度 def ground_distance(y_pixel): # y_pixel是目标接触地面那一行的像素纵坐标 delta = (y_pixel - cy) / fy angle = pitch_angle + np.arctan(delta) return cam_height / np.tan(angle) # 示例:目标底部在图像第580行 target_y = 580 dist = ground_distance(target_y) print(f"目标距相机地面投影点: {dist:.2f} 米")这个公式的本质是,把每一个像素行映射到地面上的一个距离值。也就是说你可以提前算好一张“距离查找表”:行从0到height-1,每个y对应一个距离。运行时直接用目标底部像素的y值查表即可,不用每帧做三角函数运算,速度更快也更适合嵌入式设备。
地面投影测距最怕两种场景。第一种是地面不平,有坡度。公式假设地面是绝对平面,遇到上坡或下坡,目标底部的像素位置不变但真实距离变了。解决办法是引入惯性测量单位(IMU)数据实时修正俯仰角theta,或者在多个距离上预先标定校准。第二种是俯仰角测不准。pitch_angle哪怕只有1度的误差,在5米外就会累积出几十厘米的偏差。一个实用技巧是用一条5米长的卷尺在地面做参考,反推theta的精确值,而不是依赖角度传感器读数。
4.3 单目平面单应性:标定一个地面矩形换更高精度
如果目标在同一个地面上移动,可以用更稳的单应性方法,即cv2.findHomography。它需要至少4组“图像坐标-地面坐标”对应点。你可以在地面上铺一张已知尺寸的标定布,把四个角点的像素坐标和以相机为原点的世界坐标配对,得到单应矩阵H。之后任意地面点的像素坐标乘H,就能得到它相对相机的平面距离。
import cv2 import numpy as np # 地面坐标系:以相机光轴在地面的投影点为原点 # 图像中四个角点对应的像素坐标 src_pts = np.array([[210, 180], [430, 182], [580, 420], [130, 410]], dtype=np.float32) # 对应的真实地面坐标,单位米 dst_pts = np.array([[0.0, 0.0], [2.0, 0.0], [2.0, 1.5], [0.0, 1.5]], dtype=np.float32) H, _ = cv2.findHomography(src_pts, dst_pts) def pixel_to_ground(px, py): pt = np.array([[[px, py]]], dtype=np.float32) ground = cv2.perspectiveTransform(pt, H) gx, gy = ground[0][0] return np.sqrt(gx ** 2 + gy ** 2) # 到原点的平面距离单应性矩阵的好处是同时矫正了俯仰角和镜头畸变的影响,而且不需要单独测量相机高度。它的误差来源主要是四个标定点的手动选取精度。选点时不要用肉眼在图像上点一下就完事,最好放一个ArUco板到地面指定的位置,用cv2.estimatePoseSingleMarkers获取精确角点,这样标定误差可以从十几个像素压到1个像素以内。另外一种更彻底的做法是:直接用地面标定布提取角点,自动生成上百个对应点对,把这些点对丢给findHomography做最小二乘,鲁棒性远超四点法。
5. 测距系统的五个典型坑:从标定到视差图的排查手册
5.1 标定重投影误差很低,但测距结果还是系统性偏移
现象:calibrateCamera返回的ret小于0.3像素,看起来一切正常。换到实际场景测距时,发现在2米处偏差足有15厘米,且误差随距离增大而增大。
原因:重投影误差低只代表标定板角点的检测点和重投影点重合度好,并不直接保证内参正确。最常见的情况是棋盘格边长的物理值量错了。另一个高发原因是打印的棋盘格没有保持完全平整,手持时轻微弯曲,导致每张图的角点世界坐标与真实位置有偏差。还有一种情况是标定板只出现在画面中央,没有得到足够的畸变信息,边缘区域的dist系数外推失真。
解决:用精度高一点的钢尺或游标卡尺多次测量棋盘格边长,每个方向的格子量三个位置取平均;把棋盘格贴在亚克力板或玻璃板上,避免柔性弯曲;重新补拍一批棋盘格靠近图像边缘的照片。我这里有一条经验:重投影误差低于0.5像素不代表可以收工,还必须在实景中对一个已知距离的目标做一次“端到端校验”。标定和数学没有问题,但物理世界的量测有误差,只有端到端校验能发现。
5.2 findChessboardCorners找不到角点或检测结果错位
现象:标定脚本运行后有一半的照片ret为False,或者角点检测成功但可视化时发现角点顺序错乱,棋盘边缘的角点被连到了其他位置。
原因:OpenCV的棋盘格检测要求棋盘格完整出现在画面中,且内角点不能被遮挡。打印机如果用了省墨模式,黑白格对比度不足也会导致检测失败。另一个常见问题是你生成棋盘格时用的是“10x7格子”而不是“9x6内角点”,两者相差一个单位,如果用错尺寸配合CALIB_CB_ASYMMETRIC_GRID标志,就会导致顺序错位。
解决:调用findChessboardCorners前先做一次直方图均衡化或CLAHE,提升低对比度环境下的检测率。打印棋盘时留足白边,确保最外圈黑白格完整。在拍摄时手持棋盘稍微倾斜30度到45度,避免正对镜头,这样角点的亚像素提纯更容易收敛。出现错位时,把img_points打印出来查看前三个点的坐标变化趋势,顺序错乱的标定结果宁可弃用也不要混入优化。
5.3 SGBM深度图大片空洞,目标区域全是NaN
现象:视差图整体稀疏,目标表面散落着黑色无效像素。CLAHE和滤波都做过了,覆盖面积还是不到六成。
原因:弱纹理区域是SGBM的天然盲区。纯色纸箱、墙面、天空这类区域局部窗口内的灰度方差太小,匹配代价在搜索范围内拉不开差距,容易误匹配。另一个原因可能是左右图亮度不一致,比如两个摄像头自动曝光参数不同,同一物体在左右图上灰度差异过大,匹配代价整体偏高。
解决:优先把左右相机切换到手动曝光模式,锁定同一个曝光时间和增益值。然后是参数层面的调整。一是把numDisparities加大,给搜索空间更宽裕的候选范围;二是降低uniquenessRatio,让次优匹配更容易被接受;三是用cv2.ximgproc.createRightMatcher配合WLS滤波器做后处理,它能利用左右一致性检验丢掉错误匹配,然后用相邻点的深度插值填补空洞。
另外一个工程技巧是改分辨率。SGBM在640x480上算不动的地方,提高到1280x720往往能匹配上,因为局部纹理在更高分辨率下包含更多鉴别信息。但分辨率提高会带来计算时间的明显上涨,做实时系统时需要权衡。
5.4 双目基线选短了还是选长了,深度误差范围完全失控
现象:基线12厘米时,近距离0.5米到2米效果不错,但3米外误差开始迅猛增大;换成40厘米基线,近处反而不准了。
原因:深度误差δZ = Z² / (f × B) × δd。这个公式说明:近处目标视差大,基线短也能给足够的分辨率;远处目标视差小,基线越短越容易被像素量化误差淹没。换成大基线的同时,左右相机的视角差变大,近距离物体在左右图上的外观差异剧烈变化,SGBM的匹配窗口无法适应这种透视变形,误匹配率上升。
解决:按你实际关心的距离范围来选基线,而不是越长越好或越短越好。经验公式是:基线B = Z_far × 视差精度(像素) / f。假设焦距800像素、视差精度0.8像素、期望10米处误差不超过0.5米,那B大约需要0.8×10/800 = 0.01米,实际上这个精度要求很保守;要精确到0.2米,B就需要约50毫米以上。反过来,基线定了之后能可靠测距的距离上限大约是基线乘以焦距再除以可接受的视差误差。没必要为了远处准就去加长基线,因为镜头视角会缩小,近处盲区也会变大。
另一个容易被忽略的约束是结构刚性。买来的双目摄像头模块如果外壳是塑料且没有固定螺丝,在运输或温度变化中左右相机的相对位姿都会漂移,刚标定好的R和T就不准了。工业方案通常用铝合金基座锁死两个摄像头,前面板还要加防震缓冲。你的这个项目如果用的是普通USB摄像头自制双目支架,务必保证两个相机之间没有自由度,不然每次开机都要重新双目标定。
5.5 换分辨率或裁剪画面后,内参矩阵没有同步缩放
现象:标定时用1280x720,之后为了性能把图像resize成640x360,其他代码不变,测距结果整体偏大或偏小。
原因:内参矩阵里的fx、fy和cx、cy都是像素单位,它们是针对原始分辨率计算的。图像缩放后,单一像素对应的物理尺度变了,焦距的像素值也必须按比例缩放。如果不缩放,相当于用“每毫米10像素”的焦距去解释“每毫米5像素”的图像,深度自然偏。
解决:如果图像是按等比缩放的,直接对内参矩阵做缩放处理即可。假设从宽W_old变成W_new,缩放比例s = W_new / W_old,新的fx' = fx * s,fy' = fy * s,cx' = cx * s,cy' = cy * s。注意dist畸变系数不需要缩放,它是归一化的。如果你在代码里去掉了某些ROI区域(比如切掉顶部),那就不只是缩放的问题了,主点cx/cy的实际位置已经偏移,需要重新标定或者对ROI裁剪做明确的主点平移补偿。这个场景下我没有更好的捷径,重新标定一份内参是更稳妥的方案。
6. 用离线回归验证测距精度:一个可复现的实测流程
当标定和视差计算都跑通之后,最容易被漏掉的一环是“精度验证”。很多人在屏幕上看到深度图上叠加了彩色伪影,就觉得项目完成了,其实心里完全没有底。我自己的习惯是,任何测距系统上线之前必须做一次离线回归:把相机架在固定位置,用卷尺量出一系列真实距离,在每个距离上采集若干张图像,用保存好的标定映射和参数批处理计算深度,最后画误差曲线。
具体流程分三步。第一步,固定相机后用卷尺在光轴方向标出1米、1.5米、2米、2.5米、3米、4米、5米七个位置,每个位置放一个竖直条纹纸箱,固定好后各拍10帧。第二步,写一个Python脚本遍历所有图像,对每帧提取目标区域深度中位数,记录预测值。第三步,计算每个真实距离下的均值、标准差和绝对误差百分比。如果标准差超过均值的10%,说明深度不稳定,优先排查是SGBM参数问题还是标定板物理尺寸问题。如果标准差稳定但均值偏大或偏小,则是标定内参或基线的系统性误差。
接下来重点观察误差随距离的变化趋势。单目测距误差随距离线性放大,双目测距误差随距离平方放大。如果你发现5米处误差突然从厘米级跳到米级,这不是算法出了问题,而是超出了该基线和分辨率的可靠测距范围。这时候要么缩短使用距离,要么加长基线、换更大焦距镜头,而不是盲目调SGBM参数。
我自己的经验是:在项目交付时,把最终参数固化成一个YAML配置文件,包含内参、畸变、校正映射路径、SGBM参数和距离查找表,主程序每次都从配置加载而不是重复标定。这样任何一次参数调整都能追溯,也方便同事接手时快速复现。跑回归脚本之前,关掉自动白平衡和自动曝光,否则左右图亮度不一致会让视差质量时好时坏。十分建议把校准用的棋盘格和卷尺放进项目附件里长期保存,每次相机被拆装过都要重新跑一遍回归流程。
最后提醒一句:不管是单目还是双目方案,相机只要被磕碰过、镜头焦距被旋转过、双目支架被拧过螺丝,就必须重新走标定流程。没有哪种标定是可以一次做完用一辈子的。把这个习惯固化下来,你的测距系统才算真正像个工程产品,而不是只在演示时能看的原型。做这一行久了,你会发现最值钱的不是那个标定算法,而是你肯不肯在每次动过相机之后老实重拍一组棋盘格。希望帮到你。
本文还有配套的精品资源,点击获取