☰
基于OpenCV的双目视觉测量物体尺寸:从标定到三维测距
2026/10/12 20:27:42 网站建设 项目流程

简介:基于Python与OpenCV的双目视觉测量项目源码,面向计算机、通信、人工智能、自动化等相关专业的学生、教师及从业者,用于测量被摄物体的尺寸。项目为个人毕业设计,答辩评审分达98分,代码经调试测试可直接运行,适合课程设计、大作业或毕业设计参考,也便于初学者进阶学习。资源共23个文件,核心为1个Python源码文件与1份Markdown文档说明,辅以21张双目图像样本(左右视图及测量结果图),压缩包约2.26MB。图片素材涵盖左右相机视图与测量结果图,可配合源码对照理解双目匹配与尺寸测量流程;文档说明则有助于梳理算法思路与工程实现细节。资源包结构简洁清晰,按左右相机图像、处理结果和主程序分类组织,便于对照运行效果逐步调试。已有201人学习使用。基础较好者可在此基础上调整相机参数或扩展测量对象,实现更丰富的视觉测量应用。

1. 双目视觉测量物体尺寸:这个毕设题目到底在解决什么

先问一个最直接的问题:如果手里只有一个普通USB摄像头,怎么知道眼前那个零件最长边是多少毫米?常见做法是放一把尺子做参照,或者在已知距离下用像素当量换算,但这些方法换个距离、换个角度就得重来,精度也经不起推敲。这个题目给了一条更稳的路:用两个摄像头模仿人眼,通过左右画面的视差算出每个点的深度,再把像素坐标投影回真实的三维空间,最后直接量两点之间的物理距离。这就是基于Python和OpenCV的双目视觉尺寸测量,一个从标定到出数都能自己掌控的完整闭环。

这个方案的价值在于不依赖任何深度传感器,一台电脑加两个普通摄像头就能复现,适合做毕业设计,也适合做产线视觉检测的前期预研。标题里“源码+文档说明”表明项目不只是算法Demo,还有一套可交付的工程结构。接下来我从选型、标定、立体匹配、尺寸计算到踩坑,按一条完整可复现的路径讲清楚。

2. 先把硬件和标定做扎实:双目系统的地基不能省

2.1 硬件选型:为什么两个摄像头必须同型号同焦距

很多初学者第一反应是找两个旧手机摄像头或者手边任意两个USB摄像头拼在一起,结果画面颜色、亮度、畸变都不一样,后面校正和匹配全部跟着遭殃。双目视觉测量对硬件的基本要求是两个相机型号一致、分辨率一致、镜头焦距一致,最好连感光芯片型号都相同。差异越小,左右图像的色彩响应越接近,立体匹配的误匹配率越低。

我一般会建议用两个同型号的USB免驱摄像头,固定在一根铝型材或者一块3D打印的支架上,光轴尽量平行,基线距离(两个镜头光心之间的距离)在5到15厘米之间。基线越大,近距离深度分辨率越高,但公共视野会变窄;基线太小,深度精度又不够。做毕业设计放在桌面上测量小物体,10厘米左右的基线比较稳妥。

还有一个容易被忽略的点:两个摄像头必须同时触发采集。USB摄像头用OpenCV的VideoCapture分别打开两个设备时,帧同步是做不到严格对齐的。常见做法是让两个摄像头对着同一块棋盘格拍摄视频流,然后选时间戳最近的左右帧作为一对。如果被测物体是静止的,这个问题不大;如果物体在移动,就要考虑用硬件触发或者接受一定的帧不同步误差。

2.2 单目标定拿到内参和畸变系数

在跑双目标定之前,先把两个相机各自的单目标定做完。这一步的目的是拿到每个相机的内参矩阵、畸变系数和重投影误差。OpenCV里用cv2.calibrateCamera()来完成,输入是棋盘格角点的世界坐标和像素坐标。

单目标定的采集注意事项先讲清楚:用A4纸打印一张10×7的棋盘格(内部角点9×6),贴在硬纸板上,确保棋盘格绝对平整,不要贴在软纸上。采集时让棋盘格在画面中占据不同位置,每个位置旋转不同角度,至少采集15到20张有效图像。图像模糊、高光反光、角点残缺的直接删掉,留着只会拉高标定误差。

import cv2 import numpy as np import glob # 棋盘格内部角点数 CHECKERBOARD = (9, 6) criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) objp = np.zeros((CHECKERBOARD[0] * CHECKERBOARD[1], 3), np.float32) objp[:, :2] = np.mgrid[0:CHECKERBOARD[0], 0:CHECKERBOARD[1]].T.reshape(-1, 2) objpoints = [] # 世界坐标系下的角点坐标 imgpoints_left = [] # 左相机像素坐标 imgpoints_right = [] # 右相机像素坐标 images_left = sorted(glob.glob('calib_images/left/*.jpg')) images_right = sorted(glob.glob('calib_images/right/*.jpg')) assert len(images_left) == len(images_right), "左右图像数量不一致" for img_left_path, img_right_path in zip(images_left, images_right): img_left = cv2.imread(img_left_path) img_right = cv2.imread(img_right_path) gray_left = cv2.cvtColor(img_left, cv2.COLOR_BGR2GRAY) gray_right = cv2.cvtColor(img_right, cv2.COLOR_BGR2GRAY) ret_left, corners_left = cv2.findChessboardCorners(gray_left, CHECKERBOARD, None) ret_right, corners_right = cv2.findChessboardCorners(gray_right, CHECKERBOARD, None) if ret_left and ret_right: corners_left = cv2.cornerSubPix(gray_left, corners_left, (11, 11), (-1, -1), criteria) corners_right = cv2.cornerSubPix(gray_right, corners_right, (11, 11), (-1, -1), criteria) objpoints.append(objp) imgpoints_left.append(corners_left) imgpoints_right.append(corners_right) # 单目标定左相机 ret_l, mtx_l, dist_l, rvecs_l, tvecs_l = cv2.calibrateCamera( objpoints, imgpoints_left, gray_left.shape[::-1], None, None) # 单目标定右相机 ret_r, mtx_r, dist_r, rvecs_r, tvecs_r = cv2.calibrateCamera( objpoints, imgpoints_right, gray_right.shape[::-1], None, None) print("左相机重投影误差:", ret_l) print("右相机重投影误差:", ret_r) print("左相机内参矩阵:\n", mtx_l) print("右相机内参矩阵:\n", mtx_r)

这段代码的逻辑是:先用findChessboardCorners找棋盘格角点粗位置,再用cornerSubPix做亚像素细化,最后把左右相机的角点分别送入calibrateCamera。返回的ret_l和ret_r是重投影误差,单位是像素,数值越小越好,一般低于0.3像素就算合格。

这里有个参数值得注意:objp的尺度是任意的,因为标定只需要角点的相对位置关系,不需要真实的棋盘格尺寸。但如果后面要做双目标定的平移向量,最好把objp改成用真实格边长乘以行列索引,我一般会把棋盘格格边长设为30毫米,这样标定出的平移向量直接就是毫米单位。

2.3 双目标定:把两个相机的位置关系锁死

单目标定完成后,立刻做双目标定。双目标定的目标是拿到左右相机之间的旋转矩阵和平移向量,这决定了后续极线校正的质量。

# 双目标定:使用单目得到的初始值 flags = cv2.CALIB_FIX_INTRINSIC criteria_stereo = (cv2.TERM_CRITERIA_MAX_ITER + cv2.TERM_CRITERIA_EPS, 100, 1e-5) ret_stereo, mtx_l, dist_l, mtx_r, dist_r, R, T, E, F = cv2.stereoCalibrate( objpoints, imgpoints_left, imgpoints_right, mtx_l, dist_l, mtx_r, dist_r, gray_left.shape[::-1], criteria=criteria_stereo, flags=flags) print("立体标定重投影误差:", ret_stereo) print("旋转矩阵 R:\n", R) print("平移向量 T:\n", T) print("本征矩阵 E:\n", E) print("基础矩阵 F:\n", F)

stereoCalibrate返回的R是右相机相对左相机的旋转矩阵,T是平移向量。只要标定板被两边的相机同时完整看到,这个函数就能把两相机的外参锁死。如果重投影误差超过1像素,说明标定板在采集过程中弯了、图像模糊、或者某张图的角点检测结果错了,需要把异常样本挑出来删除后重新标定。

这里我没有用CALIB_FIX_INTRINSIC以外的flag,是因为对毕设场景来说,固定内参只优化外参已经足够稳定。如果你的镜头畸变特别大,可以去掉这个flag让内参也参与优化,但容易出现内参漂移导致结果不收敛的情况。先固定内参跑一遍,如果误差太大,再做一次全参数优化。

3. 极线校正与SGBM立体匹配:从双图到视差图

3.1 极线校正:让匹配变成一行的事

双目匹配最核心的前提是:左图中一个像素点在右图中的对应位置,必须出现在同一行。这就是极线约束。现实中的两个相机很难做到光轴完全平行,所以要用stereoRectify和initUndistortRectifyMap把左右图像变换到这个理想状态。

# 图像尺寸 h, w = gray_left.shape[:2] # 计算校正映射 R1, R2, P1, P2, Q, roi1, roi2 = cv2.stereoRectify( mtx_l, dist_l, mtx_r, dist_r, (w, h), R, T, alpha=0.0) # 初始化映射表 map1_l, map2_l = cv2.initUndistortRectifyMap( mtx_l, dist_l, R1, P1, (w, h), cv2.CV_32FC1) map1_r, map2_r = cv2.initUndistortRectifyMap( mtx_r, dist_r, R2, P2, (w, h), cv2.CV_32FC1) # 校正后的图像 rectified_left = cv2.remap(img_left, map1_l, map2_l, cv2.INTER_LINEAR) rectified_right = cv2.remap(img_right, map1_r, map2_r, cv2.INTER_LINEAR)

stereoRectify返回的P1和P2是投影矩阵,Q是视差到三维坐标的变换矩阵,第四章会用到。alpha=0.0表示对图像做最大程度的裁剪,去掉校正后出现的黑边区域。如果不想丢失边缘像素,可以把alpha设为0.5或1.0,但这样有效像素区域变小,匹配范围也受影响。

代码写完后要做的验证是:把校正后的左右图像并排显示,在图像上用鼠标取几个特征点,画水平线检查对应点是否在同一行。另一个更快的方法是直接看画面边缘的物体是否有明显的高度差。如果两侧画面里的同一物体不在同一水平位置,说明标定有问题,返回上一章检查。

3.2 SGBM参数:视差图质量的真正决定因素

立体匹配的算法选择上,OpenCV提供了StereoBM和StereoSGBM两种。BM速度快但精度低,适合实时性要求高的场景;SGBM是半全局块匹配,精度高不少,也是这个项目里最常用的选择。SGBM的核心思想是对每个像素计算多方向的匹配代价并做能量最小化,效果明显优于局部块匹配。

# 创建SGBM匹配器 stereo = cv2.StereoSGBM_create( minDisparity=0, numDisparities=128, # 必须能被16整除,数值越大可匹配的深度范围越大 blockSize=11, # 匹配块大小,奇数,一般5~21之间 P1=8 * 3 * blockSize ** 2, P2=32 * 3 * blockSize ** 2, disp12MaxDiff=1, # 左右一致性检查的最大容差 uniquenessRatio=10, # 唯一性比例,抑制低置信度匹配 speckleWindowSize=100, # 去除斑点区域窗口大小 speckleRange=32, # 斑点区域内视差最大变化 mode=cv2.STEREO_SGBM_MODE_SGBM ) disparity = stereo.compute(rectified_left, rectified_right).astype(np.float32) / 16.0

SGBM的几个关键参数需要展开细说。numDisparities决定视差搜索范围,范围越大,能测的深度范围越小(近处物体),但计算量也越大。blockSize是匹配窗口大小,窗口越小细节保留越好,但弱纹理区域误匹配越多;窗口越大结果越平滑,但边缘会被钝化。P1和P2是平滑惩罚项,P2控制视差突变(物体边缘)的惩罚强度,一般让P2是P1的4到10倍。

调参有一个通用顺序:先固定blockSize=11,numDisparities=128,把其他参数设成默认,跑出一张视差图看整体效果,再根据问题逐个调。如果视差图噪声很大,加大speckleWindowSize和P2;如果物体边缘发糊,减小blockSize;如果黑色区域大片空洞,检查光照是否均匀,而不是急着调参。

注意:compute返回的原始视差图是固定小数点格式,必须除以16.0转换成浮点像素视差,否则后面计算三维坐标全部不对。

3.3 后处理滤波

SGBM输出的视差图常常带着空洞和噪点,尤其是弱纹理区域和物体边界。常见做法是用cv2.medianBlur做中值滤波,或者用cv2.bilateralFilter做双边滤波。中值滤波速度快,能有效去除椒盐噪点;双边滤波保边效果好,但参数要调好,否则把边缘糊掉。下图效果不好时先检查后处理参数,不要盲目加大滤波窗口。

# 中值滤波去噪 disparity_filtered = cv2.medianBlur(disparity, 5) # 可视化:归一化到0-255并映射为彩色 disparity_visual = cv2.normalize( disparity_filtered, None, alpha=0, beta=255, norm_type=cv2.NORM_MINMAX, dtype=cv2.CV_8U) disparity_color = cv2.applyColorMap(disparity_visual, cv2.COLORMAP_JET)

中值滤波的窗口大小不要超过7,否则会严重破坏物体边缘的视差精度。如果你对测量精度要求比较高,可以尝试用WLS滤波(cv2.ximgproc.createRightMatcher+cv2.ximgproc.createDisparityWLSFilter),它利用左右两张视差图做加权最小二乘优化,边缘保持效果好很多,但速度慢,帧实时性就别想了。

4. 从视差到三维坐标:把像素距离换算成毫米尺寸

4.1 深度图和点云:Q矩阵的用法

有了视差图,下一步就是把每个像素的视差值转换成三维坐标。OpenCV已经在stereoRectify阶段返回了Q矩阵,这个矩阵是4×4的投影矩阵,能把像素坐标和视差直接映射到相机坐标系下的三维坐标。用cv2.reprojectImageTo3D一次性完成批量转换。

# 将视差图转为三维点云 points_3d = cv2.reprojectImageTo3D(disparity_filtered, Q) # points_3d的形状是(H, W, 3),每个像素对应(x, y, z)坐标 # 过滤无效深度(视差为0或无穷大的点) valid_mask = disparity_filtered > 0 valid_points = points_3d[valid_mask] # 点云中每个点的单位取决于标定时objp的单位 # 如果标定时用的是30mm的棋盘格,这里直接就是毫米

reprojectImageTo3D输出的三维坐标里,x是水平方向,y是垂直方向,z是深度(物体到相机平面的距离)。这三个值都是在左相机坐标系下的度量结果。单位完全由标定时棋盘格的真实尺寸决定,所以劝大家标定前先量好格子边长,别随便填一个数。

这个函数有个容易踩的坑:输入视差图的类型必须是float32,且数值是真实视差值不是归一化后的灰度值。很多人直接把disparity_visual丢进去,出来的点云完全变形。正确做法是先把SGBM的输出除以16拿到浮点视差,滤波后再送入reprojectImageTo3D。

4.2 尺寸测量的实现思路

拿到三维点云后,尺寸测量就变成了一个点云几何问题。测量一个物体某个方向上的长度,思路是先用鼠标框选或者用轮廓检测锁定感兴趣区域,然后在该区域内找到三维坐标的最小值和最大值,差值就是对应尺寸。

def measure_distance(points_3d, point_a, point_b): """ 计算三维空间中两点之间的欧氏距离 point_a, point_b: (x, y) 像素坐标 """ x1, y1, z1 = points_3d[point_a[1], point_a[0]] x2, y2, z2 = points_3d[point_b[1], point_b[0]] distance = np.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2 + (z1 - z2) ** 2) return distance def measure_bounding_box(points_3d, roi_mask): """ 测量ROI区域的包围盒尺寸 roi_mask: 二值掩码,物体区域为255 """ object_points = points_3d[roi_mask > 0] if len(object_points) == 0: return None min_coords = object_points.min(axis=0) max_coords = object_points.max(axis=0) length = max_coords[0] - min_coords[0] width = max_coords[1] - min_coords[1] height = max_coords[2] - min_coords[2] return length, width, height

这个方案的原理很直接:ROI内所有三维点的坐标分布范围等同于物体的外接长方体尺寸。要注意的是,包围盒方案测量的物体尺寸与物体姿态强相关。如果物体斜着放,测量结果会偏大。所以要么想办法把物体摆正,要么用主成分分析先算出物体的主轴方向再做旋转对齐。毕设阶段先把物体摆正测量,把误差来源说清楚,就已经能拿一个不错的分数了。

还有个更实用的技巧:如果只想测一条边,在图像上手动点两个端点,然后用measure_distance函数计算三维欧氏距离,这样不用做物体分割,适用面更广,展示效果也好。

4.3 像素当量法和三维点云法:哪个更靠谱

很多毕设里出现的单目方案其实是“像素当量法”,就是先放一个已知尺寸的参照物,算出每像素对应多少毫米,然后乘上目标物体的像素尺寸。这个方法的优点是只需要一个相机,但致命缺点是一旦被测物体不在参照物所在的深度平面上,像素当量就变了,误差随深度差线性放大。双目方案本质上绕开了这个问题,每个像素的物理尺寸都通过深度计算出来,不需要参照物,深度变化也不怕。

当然双目也不是万能。物体表面很平整、纹理很少时,匹配算法找不到可靠的对应点,深度图就会出现黑洞。一个常见补救是给物体表面打上结构光斑点,或者在物体背后放一张纹理丰富的背景板,对比之下能明显改善匹配质量。

5. 避坑手册:双目视觉测量最容易翻车的6个环节

5.1 标定板不够平整,重投影误差死活降不下去

现象:标定误差一直在0.5像素以上,怎么增加图像数量都不改善。

原因:棋盘格打印在软纸上,贴在硬纸板上时没有完全展平,导致世界坐标建模错误。标定算法假设棋盘格是平面刚体,一旦弯曲,角点坐标与实际位置不符。

解决:把棋盘格用双面胶完全贴平在铝板或玻璃板上,确认没有气泡和翘角。采集时不要把标定板拿在手里抖,固定在一个支架上拍。重新采集后,重投影误差一般能降到0.15到0.3像素。

5.2 SGBM参数没调,视差图出现大量横纹和空洞

现象:视差图上有明显的横条纹,或者物体边缘附近出现大块黑色空洞。

原因:P1和P2设得太小,平滑约束不够;numDisparities小于场景实际的视差范围,导致近处物体匹配不到;blockSize太大把边缘糊掉。

解决:先按公式P1=8*3*blockSize**2和P2=32*3*blockSize**2设置基准值,再根据画面效果微调。空洞多就检查光照,避免物体表面大面积反光。横条纹明显时把uniquenessRatio调大到15左右,强制匹配点唯一性。

5.3 remap后图像出现黑边和拉伸变形

现象:校正后的图像边缘出现不规则黑边,画面内容有明显变形。

原因:stereoRectify的alpha=0.0会导致图像强烈裁剪,alpha=1.0则保留全部原始像素但引入严重的畸变拉伸。初学者经常一脸懵。

解决:对毕设项目,设置alpha=0.5到alpha=1.0之间较好,既保留有效区域又不过度变形。黑边区域用roi1和roi2裁剪掉,不影响后续匹配。

5.4 左右视图无法同时看到完整标定板

现象:双目标定时大量图像只有左相机看到棋盘格,右相机只看到一半甚至看不到。

原因:两个摄像头的公共视野范围比想象中窄,尤其是基线较大时,近距离的标定板容易超出右侧画面。

解决:把基线缩短到8厘米以内,标定板放远一点,保证左右画面中棋盘格完整且占画面面积的四分之一到三分之一。同时让标定板在画面不同位置倾斜、旋转,多角度采图。

5.5 测量结果与真实尺寸偏差超过10%

现象:量一个50毫米的方块,程序输出55毫米,甚至偏差更大。

原因:第一,标定棋盘格的实际边长和代码里设置不一致,比如实际30毫米却写成26毫米;第二,视差图在边缘区域的误差大,用包围盒测量时边缘的毛刺点把尺寸撑大了。

解决:先用游标卡尺精确测量棋盘格边长,更新objp;测量时对ROI内的三维点做百分位截断,比如取2%到98%的分位区间,剔除离群点后再计算尺寸。

5.6 换了摄像头或者动过支架,所有标定全部失效

现象:上次运行好好的,过了一天重新插拔USB摄像头后,视差图完全乱了。

原因:USB摄像头每次上电后的图像采集状态不一定完全一致,而且手工固定支架一旦松动,双目的外参就变了。标定参数和当前物理装配是对应的,动一处就全废。

解决:把左右摄像头和支架做成一个整体,用螺丝锁死,不要用胶带粘。每次开机重新采集几帧确认画面稳定后,再做一次双目标定。如果只是微调支架,可以用cv2.solvePnP配合标定板算新的外参,不必全部重跑。

6. 精度验证方法:用标准量块量化系统误差,并做亚像素优化

毕业设计能不能拿高分,很大程度上取决于你有没有一套明确的精度验证流程。我自己的习惯是准备三块不同尺寸的标准量块,比如20毫米、40毫米、60毫米的金属块,分别放在距离相机30厘米、40厘米、50厘米的位置测量,每个位置测10次,记录平均值和标准差。

测量完成后整理一张误差表,列出每个量块在每个距离下的绝对误差和相对误差。这个表格直接写进论文结论里,比任何文字描述都有说服力。正常情况下,这套系统在40厘米距离内,经过合理调参后能做到2%到3%的相对误差,如果用了亚像素细化可以更好。

亚像素细化的方向有两个。第一个方向是视差层面的亚像素插值,用抛物线拟合寻找最小代价位置的亚像素偏移;第二个方向是再对三维点云做平面拟合或边缘拟合,替换掉单个像素的跳动。下面给出视差亚像素拟合的简单实现:

def subpixel_interp(disparity_map): """ 对视差图做亚像素精度的抛物线插值 """ rows, cols = disparity_map.shape disparity_subpixel = disparity_map.copy() for i in range(1, rows - 1): for j in range(1, cols - 1): d_minus = disparity_map[i, j - 1] d_current = disparity_map[i, j] d_plus = disparity_map[i, j + 1] denom = (d_minus - 2 * d_current + d_plus) if abs(denom) > 1e-6: delta = 0.5 * (d_minus - d_plus) / denom if abs(delta) < 1: disparity_subpixel[i, j] = d_current + delta return disparity_subpixel

这个实现的逻辑是:对每个像素的视差值,用左右相邻点的视差拟合一条抛物线,抛物线的极小值点就是亚像素视差位置。实际使用时建议先对整张视差图做一次平滑,避免噪声被插值放大。注意这个函数是纯Python循环实现的,速度较慢,对毕设场景够用;如果要实时,需要改成NumPy向量化或者用OpenCV的filter2D实现。

做完亚像素细化后,再跑一遍标准量块的测量流程,对比细化前后的误差变化。如果误差没有明显下降,问题很可能不在视差精度,而是物体边缘的三维点本身就不稳定,这时要回头检查光照和匹配参数。

最后说一个我从这个项目里得到的血泪经验:双目视觉测量最忌讳“一步到位”的心态。标定、校正、匹配、测量这几个环节是串联关系,前一环的问题一定会被后一环放大。把每一步的中间结果可视化出来,确认无误再往前走,比一口气跑完全流程再回来找问题要节省大量时间。每次修改标定参数或者匹配参数后,先用同一组标准量块记录数据,参数好坏立刻能看到。希望这套方案和这些踩坑记录能帮你少走一段弯路,把精度和数据都做得经得起推敲,顺利拿下这个毕设。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询