基于OpenCV的相机测距:双目视差与单目solvePnP实现指南

发布时间:2026/10/4 5:04:22
基于OpenCV的相机测距:双目视差与单目solvePnP实现指南
简介基于Python与OpenCV实现的普通相机图像测距系统项目面向计算机视觉初学者及立体成像开发者解决利用单目/双目相机标定校正、视差计算与深度测量完成实际距离估算的问题。压缩包共41个文件大小2.35MB其中包含26张左右相机标定样本JPG、6张PNG结果图、4个Python脚本标定、立体校正、主流程、绘图、2个文本说明、1份Word附赠资料及README文档代码与图片配套可直接运行学习。已有179人学习下载。通过该资源可掌握棋盘格标定板处理、相机内参与外参求解、双目极线校正、特征匹配及视差图生成、深度映射等核心流程项目目录清晰包含left/right原始图像、calibresult与disparity结果目录便于对照验证。适合正在开展三维重建、机器人导航或工业测距项目的开发者参考能快速搭建起从图像采集到深度可视化的完整实验链路。1. 普通相机图像测距不靠激光雷达只靠两幅图像怎么算出距离做机器视觉项目的人迟早会遇到同一个诉求客户不想买几万块的激光雷达或深度相机手里只有普通USB摄像头却要求测出目标物的距离误差还得控制在厘米级。这个标题给出的路径是——用Python和OpenCV做相机标定校正单目或双目二选一通过视差计算和深度测量把普通相机变成测距工具。这套方案的现实意义在于双目立体视觉是计算机视觉里最经典的被动测距手段只要两张图像存在视差就能用三角测量原理还原深度信息成本几乎为零适合机器人避障、倒车辅助、尺寸测量这类场景。单目方案则更轻用solvePnP和已知几何约束也能估算距离精度需求不高时完全够用。适合的人群是熟悉Python基础、想从图像处理跨到三维视觉的从业者或者需要在低成本硬件上实现测距功能的嵌入式工程师。这篇文章会把标定、校正、视差计算到深度映射的完整链路拆开每一步都给可复现的代码和参数。2. 相机标定校正为什么这一步不做后面全是白搭2.1 内参外参和畸变先把相机的“出厂缺陷”摸清楚相机标定是整个测距系统的地基。普通相机镜头是光学系统生产装配时不可能做到完美图像会产生两种畸变径向畸变让直线变成曲线桶形或枕形失真切向畸变则来自镜头与传感器平面的不平行导致图像拉伸。如果不校正这些畸变后续的视差计算和深度映射都会带上系统性误差测距结果根本没法看。标定的本质是求解相机的内参、外参和畸变系数。内参是焦距f_x、f_y、主点c_x、c_y描述光心到像素平面的映射关系外参是旋转矩阵R和平移向量T描述相机在世界坐标系中的位姿畸变系数包括k1、k2、k3径向和p1、p2切向。单目标定能得到内参和畸变系数双目标定额外能得到两个相机之间的相对位姿也就是R和T这是后续立体校正和视差计算的核心依据。OpenCV里完成这一步只需要一个棋盘格标定板和一个cv2.calibrateCamera或者cv2.stereoCalibrate调用但坑在于采集图像的质量。我一般用A4纸打印7x9的棋盘格格子边长20mm贴在平整的硬板上。采集时让标定板在画面里出现在不同位置和角度至少拍15到20张每张都要保证棋盘格完整可见角点能清晰检测到。2.2 单目标定用cv2.findChessboardCorners提取角点的完整流程单目标定代码是整套系统里最基础的模块。下面这段脚本会读取文件夹里所有标定板图像检测角点执行标定并保存参数。import cv2 import numpy as np import glob # 棋盘格内角点数量列数-1 × 行数-1 CHECKERBOARD (7, 9) # 每个格子的物理尺寸单位毫米 SQUARE_SIZE 20.0 # 准备对象点所有图像的棋盘格角点物理坐标都相同 objp np.zeros((CHECKERBOARD[0] * CHECKERBOARD[1], 3), np.float32) objp[:, :2] np.mgrid[0:CHECKERBOARD[0], 0:CHECKERBOARD[1]].T.reshape(-1, 2) objp * SQUARE_SIZE obj_points [] # 世界坐标系中的3D点 img_points [] # 图像中的2D角点位置 images 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, CHECKERBOARD, None) if ret: # 亚像素精细化提高角点定位精度 criteria (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) corners cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria) obj_points.append(objp) img_points.append(corners) # 绘制角点用于检查 cv2.drawChessboardCorners(img, CHECKERBOARD, corners, ret) cv2.imshow(Corners, img) cv2.waitKey(500) cv2.destroyAllWindows() # 执行摄像头标定 ret, mtx, dist, rvecs, tvecs cv2.calibrateCamera( obj_points, img_points, gray.shape[::-1], None, None ) # 保存内参和畸变系数 np.savez(single_calib.npz, mtxmtx, distdist) print(内参矩阵:\n, mtx) print(畸变系数:\n, dist)这里的关键参数是CHECKERBOARD元组它必须是棋盘格内角点的数量而不是格子数量7x9的棋盘格对应(6, 8)内角点写反了角点检测永远会失败。SQUARE_SIZE的单位要和后续测距单位统一我习惯用毫米这样深度计算出来的结果直接就是毫米。cornerSubPix亚像素精细化这一步能显著提升标定精度尤其在图像模糊或标定板倾斜时不加这一步重投影误差会明显偏大。标定完成后要检查重投影误差cv2.calibrateCamera返回的ret就是均方根误差通常应小于0.5像素。如果误差超过1像素优先怀疑标定板不平整或采集图像太少而不是急着调参数。2.3 双目标定与立体校正让两幅图像做到行对齐单目标定解决的是每台相机自身的畸变问题双目测距还需要知道两个相机之间的空间关系。双目标定用cv2.stereoCalibrate同时求解左右相机的内参、畸变系数以及相对位姿R和T。实操中我先分别对左右相机做单目标定再把两边的角点数据合并传入双目标定函数这样比直接从零进行双目标定更快收敛。# 假设已经从左右相机分别获得了 obj_points_L, img_points_L, obj_points_R, img_points_R # 以及左相机内参 mtx_L, dist_L 和右相机内参 mtx_R, dist_R # 双目标定求解相对旋转R和平移T ret, mtx_L, dist_L, mtx_R, dist_R, R, T, E, F cv2.stereoCalibrate( obj_points_L, img_points_L, img_points_R, mtx_L, dist_L, mtx_R, dist_R, gray_size_L, # 图像尺寸 (width, height) criteria(cv2.TERM_CRITERIA_MAX_ITER cv2.TERM_CRITERIA_EPS, 100, 1e-5), flagscv2.CALIB_USE_INTRINSIC_GUESS ) # 立体校正计算左右视图的重映射表 R1, R2, P1, P2, Q, roi1, roi2 cv2.stereoRectify( mtx_L, dist_L, mtx_R, dist_R, gray_size_L, R, T, alpha0 # alpha0 裁剪掉黑色边框视差范围更大 ) # 计算映射表后续用 remap 校正图像 map1_L, map2_L cv2.initUndistortRectifyMap( mtx_L, dist_L, R1, P1, gray_size_L, cv2.CV_16SC2 ) map1_R, map2_R cv2.initUndistortRectifyMap( mtx_R, dist_R, R2, P2, gray_size_L, cv2.CV_16SC2 ) np.savez(stereo_calib.npz, mtx_Lmtx_L, dist_Ldist_L, mtx_Rmtx_R, dist_Rdist_R, RR, TT, R1R1, R2R2, P1P1, P2P2, QQ, map1_Lmap1_L, map2_Lmap2_L, map1_Rmap1_R, map2_Rmap2_R)立体校正这一步是双目测距的关键分水岭。stereoRectify会把左右图像重新投影到同一个平面上让极线水平对齐这样左图上的任意像素在右图上只可能出现在同一行搜索匹配从二维降到一维视差计算的复杂度和错误率都大幅下降。alpha参数值得注意alpha0会裁剪黑色边框区域视差图的有效范围更大alpha1保留全部原始像素但会有明显黑边我一般用alpha0。校正结果的验证可以调用cv2.drawMatches把左右图拼在一起画几条水平线如果对应点不在同一水平线上说明立体校正失败或标定质量差需要回头看收集的图像数据。3. 双目视差计算与深度测量SGBM参数调优是核心战场3.1 从视差到深度Z f × B / d 这条公式的物理含义立体匹配的目标是为左图的每个像素在右图中找到对应点两个像素横坐标的差值就是视差d。视差越大说明物体离相机越近这符合人眼近大远小的直觉。深度的计算公式是Z f × B / d其中f是焦距像素单位B是双目相机的基线距离毫米d是视差像素。这个公式要求的输入是经过立体校正的像素坐标系f来自标定得到的P1矩阵的左上角元素B就是标定出的T向量的模长。Q矩阵是重投影矩阵它把视差图直接映射到三维坐标cv2.reprojectImageTo3D就是用Q矩阵做这个变换。很多初学者直接用未校正的原始图像计算视差结果深度图完全失真这就是没理解公式的前置条件。精度方面depth的误差和视差误差的关系是ΔZ Z² × Δd / (f × B)也就是说距离越远误差呈平方级放大基线越大精度越高。所以双目测距的可靠范围一般控制在基线长度的10到20倍以内比如基线60mm的相机测距上限大概在1到1.5米超过这个范围误差会快速恶化。3.2 SGBM视差计算numDisparities、blockSize、P1和P2的调参逻辑OpenCV的StereoSGBM是半全局匹配算法它在局部匹配的基础上加入了全局能量最小化的约束效果比BM好很多代价是计算量大。调参是这里最容易翻车的环节很多项目跑出来的视差图全是噪点就是因为参数没按图像分辨率来设置。import cv2 import numpy as np # 加载标定结果 calib np.load(stereo_calib.npz) map1_L calib[map1_L]; map2_L calib[map2_L] map1_R calib[map1_R]; map2_R calib[map2_R] # 读取左右原始图像并做立体校正 img_L cv2.imread(left.png) img_R cv2.imread(right.png) 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) # 转灰度图SGBM只处理单通道 gray_L cv2.cvtColor(rect_L, cv2.COLOR_BGR2GRAY) gray_R cv2.cvtColor(rect_R, cv2.COLOR_BGR2GRAY) # 创建SGBM匹配器 # 参数根据图像尺寸动态计算 img_height, img_width gray_L.shape num_disparities 16 * 5 # 必须是16的倍数视差搜索范围 block_size 11 # 匹配窗口大小奇数 sgbm cv2.StereoSGBM_create( minDisparity0, numDisparitiesnum_disparities, blockSizeblock_size, P18 * 3 * block_size ** 2, P232 * 3 * block_size ** 2, disp12MaxDiff1, preFilterCap63, uniquenessRatio10, speckleWindowSize100, speckleRange32, modecv2.STEREO_SGBM_MODE_SGBM ) # 计算视差图输出是int16格式 disparity sgbm.compute(gray_L, gray_R).astype(np.float32) / 16.0 # 用WLS滤波器平滑能显著减少边缘毛刺 wls cv2.ximgproc.createDisparityWLSFilter(sgbm) sgbm_right cv2.ximgproc.createRightMatcher(sgbm) disparity_right sgbm_right.compute(gray_R, gray_L).astype(np.float32) / 16.0 disparity_filtered wls.filter(disparity, gray_L, None, disparity_right) # 将视差图映射为深度图Q矩阵来自stereoRectify Q calib[Q] points_3d cv2.reprojectImageTo3D(disparity_filtered, Q) # 提取某个像素点的深度 x, y 320, 240 depth points_3d[y, x, 2] # 单位与标定板尺寸单位一致毫米 print(f像素({x}, {y})处的深度: {depth:.1f} mm)SGBM参数里最关键的是numDisparities和blockSize。numDisparities决定了能探测的最近距离它越大视差范围越宽但计算量和误匹配也越多通常根据场景中目标的最小距离来定16的倍数这个约束来自算法内部的代价聚合方式。blockSize是匹配窗口的边长窗口太小在纹理稀疏区域容易产生空洞太大则会在物体边缘产生视差平滑效应并引入计算开销。P1和P2是平滑惩罚项P2应该远大于P1通常按8×3×blockSize²和32×3×blockSize²的比例预设然后根据视差图的噪声水平微调。P1过大会导致视差图过度平滑丢失细节P2过小则会有明显的水平条纹噪声。uniquenessRatio控制的是匹配唯一性值越大越严格误匹配越少但有效视差也少。speckleWindowSize和speckleRange用于过滤孤立的小块噪声区域对消除视差图中的飞点很有帮助。display12MaxDiff是左右一致性检查的阈值用于剔除遮挡区域的错误匹配通常设1就够了太大就失去了检查意义。3.3 深度测量的精度边界分辨率、基线、光照如何影响最终误差深度误差的传播规律决定了双目测距系统的设计边界。假设基线B60mm焦距f600像素视差误差1像素那么距离Z500mm处的深度误差约为500²×1/(600×60)≈6.9mm看起来还行但Z1500mm时误差达到62.5mm比例一下子到了4%。这说明视差误差在远处被放大得非常明显所以提升精度的核心不是增加分辨率而是减小视差匹配误差。光照影响通常被低估。SGBM对左右图像的光照一致性有天然假设如果左右摄像头自动曝光参数不一致或场景中存在强烈的镜面反射匹配代价就会失真。我一般把摄像头调到手动曝光和手动白平衡保证左右图像亮度接近。分辨率的选择也讲究不是越高越好高分辨率提升视差精度但计算量快速增长实时性会崩。常见做法是先缩放到640×480做视差计算再用原始分辨率做目标检测测距只关心中心点附近区域。4. 单目测距替代方案solvePnP和几何约束的工程权衡4.1 为什么单目也能测距已知尺寸和平面约束的三角测量思维双目测距最怕的是硬件条件不满足比如设备只能装一个摄像头或者左右相机基线太短没有足够视差。这时单目测距就是唯一的低成本选项。单目测距的前提是必须知道目标物体的某个几何尺寸比如车牌宽度、行人高度、或者二维码的真实边长。有了这个先验信息就可以利用小孔成像模型建相似三角形像素尺寸和物理尺寸的比例关系直接推算出距离。更通用的做法是用solvePnP求解相机在世界坐标系中的位姿。把物体的三维模型点和对应的二维图像点都准备好solvePnP会返回旋转向量和平移向量平移向量的模长就是相机的距离。这个方法不限于平面物体任何有明确3D结构的物体都可以用只要能够建立2D到3D的点对应关系。但需要注意solvePnP对点对应关系的精度非常敏感2D点定位差几个像素距离解算结果会差很多。4.2 实战用A4纸和solvePnP做单目测距单目测距的落地方案我用过最简单的是基于平面标记物的solvePnP测距。找一张A4纸打印一个黑色矩形边框矩形四个角的世界坐标已知用OpenCV提取四个角点的像素坐标再调用solvePnP。import cv2 import numpy as np # 相机内参和畸变系数来自单目标定结果 calib np.load(single_calib.npz) mtx calib[mtx] dist calib[dist] # A4纸的物理尺寸单位毫米 width_mm 210.0 height_mm 297.0 # 四个角点的世界坐标假设纸张平面位于Z0 object_points np.array([ [0, 0, 0], [width_mm, 0, 0], [width_mm, height_mm, 0], [0, height_mm, 0] ], dtypenp.float32) # 读取图像并提取A4纸的四个角点 img cv2.imread(desk_with_paper.jpg) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 用边缘检测和轮廓查找找到纸张轮廓 edges cv2.Canny(gray, 50, 150) contours, _ cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) contours sorted(contours, keycv2.contourArea, reverseTrue) paper_contour contours[0] # 多边形逼近得到四个角点 peri cv2.arcLength(paper_contour, True) approx cv2.approxPolyDP(paper_contour, 0.02 * peri, True) if len(approx) ! 4: raise ValueError(未检测到A4纸的四个角点) # 按左上、右上、右下、左下的顺序排列角点 image_points approx.reshape(4, 2).astype(np.float32) # solvePnP求解位姿使用迭代法 retval, rvec, tvec cv2.solvePnP( object_points, image_points, mtx, dist, flagscv2.SOLVEPNP_ITERATIVE ) # 相机到纸张中心的距离即平移向量的模长 distance np.linalg.norm(tvec) print(f相机到纸张的距离: {distance:.1f} mm) # 绘制角点方便可视化 cv2.drawContours(img, [approx], -1, (0, 255, 0), 2) cv2.imshow(Detection, img) cv2.waitKey(0) cv2.destroyAllWindows()solvePnP的flags参数决定了求解方式。SOLVEPNP_ITERATIVE适合平面物体在点数量少时稳定SOLVEPNP_EPNP和SOLVEPNP_P3P适合点数量更多的场景但平面物体上效果不一定比ITERATIVE好。距离计算用的是tvec的模长也就是相机光心到物体坐标系原点的距离。如果物体坐标系原点设在纸张中心tvec直接就是光心到纸张中心的距离角点顺序不匹配会导致位姿解算错误。单目测距的精度在距离10米以内可以达到5%左右的误差水平前提是角点检测误差控制在1像素之内。如果检测到的轮廓受光照干扰不稳定建议用ArUco标记替代A4纸。OpenCV的aruco模块直接内置了检测和解算API角点定位精度远高于边缘检测是更可靠的方案。5. 常见踩坑与排查清单视差图噪声、标定误差和单位混淆5.1 现象视差图全是斑点或雪花最常见的问题是视差图看起来像随机噪声完全看不出物体轮廓。原因通常有三个第一是左右图像没有做立体校正或者校正表计算错误我用cv2.drawMatches画水平线检查后才发现校正映射表用混了第二是SGBM参数不合理尤其是blockSize太小和numDisparities不足第三是左右相机曝光差异太大。解决思路是从简到繁排查先确认remap前后左右图的行对齐情况再逐步调参数。blockSize从11起步图像纹理细腻可以降到7纹理稀疏要加到15甚至21。numDisparities要保证覆盖场景的最大视差可以用公式估算最大视差基线×焦距/最近距离。如果最近物体距离是500mm基线60mm焦距600像素最大视差就是72像素numDisparities要设成16×580才能覆盖。5.2 现象标定重投影误差大或角点检测失败角点检测失败的典型表现是findChessboardCorners返回False。原因往往是棋盘格图像反光、模糊或者只拍到了部分角点。注意findChessboardCorners要求棋盘格完整可见不会自动排除被遮挡的角点。解决办法是拍摄时保证光照均匀避免标定板表面有反光用支架固定相机避免运动模糊增加图像数量到20张以上。重投影误差大还有一个常见原因标定板不平整。打印好的棋盘格如果贴在不平的桌面或弯曲的纸板上角点的世界坐标假设就失效了。我踩过这个坑用普通打印纸贴在纸箱上误差始终在1像素以上换用铝塑板贴合后误差立刻降到0.2像素。标定板要有一定厚度和刚性这钱省不了。5.3 现象测距结果单位错乱数值大得离谱单位混淆是新手最容易犯的错误。标定时棋盘格尺寸用毫米solvePnP的tvec单位就同步为毫米但SGBM视差图和reprojectImageTo3D输出的深度单位是Q矩阵P(3,4)项对应的单位而P矩阵来自stereoRectify的T向量T向量的单位由双目标定时objp的尺寸决定。所以整个链路必须统一用毫米或统一用米任何一步混用了米和毫米结果都会差出三个数量级。另一个隐蔽问题是基线方向。双目标定出的T向量可能带有正负号取决于左右相机的物理排列。stereoRectify要求左相机是参考相机如果T向量方向反了校正后的图像会垂直错位视差图全是无效值。检查方法是看Q矩阵的T_x分量如果目标在水平方向上移动视差值应该随之单调变化否则需要交换左右相机的角色重新标定。5.4 现象实时性能达不到要求SGBM在高分辨率图像上非常耗时比如1920×1080的视差计算在普通CPU上可能要几秒钟一帧。解决办法有三个方向缩小输入图像尺寸这是最直接有效的用cv2.ximgproc的SGBM加速变体或者将算法切换到BM模式把耗时计算放到独立线程并跳过中间帧保证测距结果以低帧率更新。GPU加速是终极方案OpenCV的cuda模块有SGBM的GPU实现速度能提升一个数量级但需要编译带CUDA支持的OpenCV版本环境搭建成本较高。项目初期建议先用640×480验证算法正确性再考虑性能优化不要一开始就追求全分辨率实时。6. 深度精度验证的实用方法尺子、标定板和交叉验证6.1 用已知距离的目标做端到端精度测试一个常见的认知误区是把标定重投影误差当成系统精度。实际上标定误差只代表成像模型拟合得好不好距离测量的误差会被视差匹配误差、立体校正残差、硬件同步误差共同影响。我习惯用最朴素的方法做端到端验证把相机固定在三脚架上用卷尺量出1米、2米、3米的距离在每个位置放一个纹理丰富的标定板或人物分别计算测距值记录误差曲线。import numpy as np # 实际距离毫米与测量距离毫米的对照 ground_truth np.array([500, 1000, 1500, 2000]) measured np.array([508, 1023, 1550, 2120]) # 计算绝对误差和相对误差 abs_error np.abs(measured - ground_truth) rel_error abs_error / ground_truth * 100 for gt, me, ae, re in zip(ground_truth, measured, abs_error, rel_error): print(f真实距离: {gt:5d} mm | 测量距离: {me:5.0f} mm | f绝对误差: {ae:5.0f} mm | 相对误差: {re:.2f}%) # 拟合误差随距离的变化 coeffs np.polyfit(ground_truth, abs_error, 2) print(f误差拟合系数(二次多项式): {coeffs})从误差曲线可以看出距离越远误差增长越快这和Z²的关系吻合。如果误差曲线上有明显的非线性跳变说明视差匹配在某个距离上出现了系统性偏差比如纹理细节无法被SGBM识别。这时候优先调SGBM参数而不是归因于标定不精确。6.2 验证立体校正质量极线检查和视差连续性立体校正质量的快速验证方法是画极线图。取校正后的左右图在同一行上绘制几条水平彩色线用cv2.hconcat拼接显示。如果左右对应点不在同一水平线上或校正图边缘有明显弯曲说明stereoRectify环节有问题。更量化的指标是视差图的连续性在物体边缘视差值应该发生跳变但保持局部连通如果视差边缘出现大量空洞说明WLS滤波参数或speckle参数没调好。另一个检验深度图正确性的技巧是观察遮挡区域。左右相机由于视角不同遮挡区域的视差值是无效的Q矩阵映射时这些点的深度值会异常大或为负。可以用cv2.reprojectImageTo3D输出深度值检查无效点占比占比超过20%时考虑调整SGBM的左右一致性检查和speckle参数。6.3 一个提高重复精度的习惯标定后做一次盲测再固化参数这套系统里最容易骗人的就是标定结果看着很漂亮实际测量一测就翻车。我养成的习惯是标定完成后立刻做一轮盲测拿一个已知尺寸的物体在不同距离各测几次记录误差分布如果误差波动超过预期就回到采集环节检查数据质量。标定参数和SGBM参数一旦确定就保存成配置文件不要每次运行都重新标定输入图像的分辨率也固定下来。程序里加一个启动时自动检查分辨率是否匹配的断言很多人换摄像头测试时忘了重新标定结果莫名其妙地测不准。双目测距系统的精度天花板来自硬件好一点的工业相机配合大基线能到毫米级普通USB摄像头做到厘米级已经很不错。选型时把相机固定距离、同步触发、手动曝光都考虑到测距结果的稳定性会有本质提升。这些细节看着不起眼但都是长期项目里一点一点磨出来的。希望这篇梳理能帮你在自己的项目里少走一段弯路。本文还有配套的精品资源点击获取