ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

基于OpenCV的单目相机测距:相机标定、误差分析与实战

基于OpenCV的单目相机测距:相机标定、误差分析与实战 简介一个基于OpenCV和Python的相机测距项目面向计算机视觉初学者以及需要在工业测量、智能监控等场景下进行单目距离估算的开发者主要解决如何利用已知物体尺寸实时计算物体到相机距离的问题。项目采用三角形相似原理预先通过已知宽度和标定距离求出焦距之后在任意图像中检测目标轮廓并读取像素宽度即可完成距离计算整个过程清晰完整。压缩包共2个文件由Python主脚本和README说明文档组成整体仅2KB轻量紧凑适合快速阅读理解。已有757人学习下载可为希望短时间掌握视觉测距核心思路的读者提供直接参考。通过阅读该案例可掌握焦距标定、目标检测、轮廓提取、相似三角形换算等关键环节并可将代码灵活扩展至无人机避障、目标定位、辅助驾驶等实际项目当中。 写这篇东西的起因是我接手了一个小项目客户要求用普通USB摄像头测量传送带上工件到相机的距离误差控制在±5%以内。一开始我也以为要上双目视觉或者深度相机后来仔细一算单目加OpenCV完全够用。这篇文章把整个方案的推导、标定、编码、踩坑过程完整记录下来希望对做类似需求的朋友有帮助。1. 单目测距的本质不是测出来是算出来的先说一个很多人容易误解的点普通的单目摄像头拍出来的图像本质上是一张二维平面图每个像素只记录了光的强度和颜色压根没有深度信息。你不可能直接从一张照片里读出这个物体离我3.2米这样的数据。那为什么我们还能用单目做测距核心思路是几何换算不是测量。说白了就是如果我们提前知道物体的真实尺寸同时知道它在图像里占了多少像素再结合相机的焦距就可以用相似三角形把距离反推出来。打个比方你站在路边看远处的人之所以能大致判断距离是因为你默认人的身高在1.6到1.8米之间。如果对方是个小孩你会下意识调整预期。计算机也是同样的逻辑它需要一个已知尺寸的参照物这个参照物越标准距离计算就越可靠。所以这个方案的适用边界很清晰被测物体尺寸已知或者能通过检测算法稳定识别出来。相机固定不动视角变化不大。对绝对精度要求不算苛刻±5%以内通常没问题。物体表面与相机光轴接近垂直至少不能歪得太离谱。如果你的场景满足这些条件单目方案就是性价比最高的选择。不满足的话比如要测量任意未知物体的绝对深度那确实得上双目或者结构光这个后面再聊。2. 第一步不能省相机内参与畸变标定很多人做OpenCV测距上来就写检测代码结果发现同一个物体放在画面边缘测出来距离偏差特别大。这不是算法的问题是镜头畸变在捣鬼。2.1 为什么要先标定内参摄像头镜头不是完美的凸透镜光线经过镜头后会发生桶形畸变或枕形畸变。画面中心区域变形小边缘区域变形明显。如果不做畸变校正物体在边缘时像素尺寸被拉长或压缩测距结果自然不准。相机标定要拿到两样东西内参矩阵包含焦距fx, fy和光心坐标cx, cy这是距离计算的核心参数。畸变系数包含径向畸变k1, k2, k3和切向畸变p1, p2用于校正图像。内参矩阵描述的是三维世界点投影到二维图像平面的数学关系。焦距的单位是像素不是毫米这个细节特别关键。光心坐标通常接近图像中心但实际生产中装配误差会导致它偏移几十个像素所以必须标定出来。2.2 用棋盘格标定的完整流程标定棋盘格是最通用的做法一张棋盘格图片角点检测算法会自动找到内角点然后通过多角度拍摄建立方程。我用的是OpenCV的cv2.findChessboardCorners和cv2.calibrateCamera。核心步骤import cv2 import numpy as np import glob # 棋盘格规格这里是9x6指内角点数量 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 [] 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: objpoints.append(objp) corners2 cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria) imgpoints.append(corners2) cv2.drawChessboardCorners(img, CHECKERBOARD, corners2, ret) cv2.imshow(corners, img) cv2.waitKey(100) cv2.destroyAllWindows() ret, mtx, dist, rvecs, tvecs cv2.calibrateCamera( objpoints, imgpoints, gray.shape[::-1], None, None ) print(内参矩阵:\n, mtx) print(畸变系数:\n, dist) # 保存标定结果后续直接加载使用 np.savez(camera_calib.npz, mtxmtx, distdist)2.3 标定实操中容易踩的坑第一次标定我拍的照片太少只有5张算出来的焦距偏差很大。后来加到15到20张覆盖了画面的九个区域中心、四角、四边中点结果才稳定下来。拍摄时要注意棋盘格要平整。打印纸贴在硬纸板上时如果有气泡或褶皱角点坐标本身就错了。拍摄角度要有变化俯仰、旋转、左右移动都来一些单拍正面是解不出靠谱结果的。光照要均匀。反光会导致角点检测失败chessboard角点提取对这种噪声很敏感。把相机固定好再拍手持抖动虽然影响不大但尽量保持图像清晰。标定完成后每次换相机或换镜头分辨率都必须重新标定。同一个相机如果改了视频流分辨率内参也要重新算千万别偷懒。3. 核心原理相似三角形与焦距的换算逻辑拿到内参之后距离计算的数学基础就简单了。初中几何就能解释清楚。3.1 相似三角形公式推导假设相机光心为O物体真实宽度为W单位米在图像传感器上成像宽度为w单位像素相机焦距为f单位像素物体到相机光心的距离为D单位米。根据小孔成像模型这三个量满足D f × W / w这个公式的物理含义很直观物体越远成像宽度w越小。焦距f是相机的固有属性物体真实宽度W是已知量检测出w后一除就得出距离。关键是理解这里的f为什么是像素单位。相机的内参矩阵里fx F / sxF是物理焦距毫米sx是传感器每个像素的物理宽度毫米/像素两者一除单位就抵消了得到的就是以像素为单位的焦距。直接用这个值和图像里的像素宽度做计算单位才统一。3.2 焦距标定用已知距离反推问题来了虽然标定能得到fx和fy但这个值是从棋盘格的物理尺寸和像素坐标联合解算出来的对测距来说已经够用。不过更稳妥的做法是做一个焦距验证环节。在实际场景里放一个已知宽度的物体测量一个已知距离然后反推焦距f w × D / W比如我的测试目标物宽度W 0.20米放在D 1.0米的位置在图像里检测出宽度w 120像素那么f 120 × 1.0 / 0.20 600像素。然后把这个验证出来的f值和标定得到的fx做对比如果差距在5%以内说明标定是可信的。如果偏差过大优先怀疑目标物在图像中的分割精度而不是标定问题。3.3 为什么标定焦距和验证焦距会有偏差这其实反映了整个测量链路里的系统性误差。标定用的是棋盘格角点亚像素坐标精度很高。而测距时用的是物体轮廓的像素宽度这受光照、运动模糊、遮挡等因素影响严重。我实测下来完美光照条件下轮廓宽度能稳定在±1像素光线一乱±3像素都是常事。所以在实际项目中建议以验证焦距为准因为它是端到端的标定跳过了中间环节的误差累积。4. 完整代码实现检测物体并计算距离理论讲完了直接放代码。这个示例检测画面中轮廓面积最大的物体计算它的外接矩形宽度然后套公式算距离。4.1 环境准备与图像预处理我用的Python 3.9 OpenCV 4.8.0安装命令pip install opencv-python numpy输入图像的处理流程很关键先去畸变再转灰度然后高斯模糊降噪最后用Canny边缘检测加轮廓查找。import cv2 import numpy as np # 加载标定结果 calib_data np.load(camera_calib.npz) mtx calib_data[mtx] dist calib_data[dist] # 参数配置 KNOWN_WIDTH 0.20 # 目标物体真实宽度单位米 FOCAL_LENGTH 600.0 # 验证焦距单位像素 MIN_AREA 5000 # 最小轮廓面积过滤噪点 def detect_object_distance(frame): # 去畸变 h, w frame.shape[:2] newcameramtx, roi cv2.getOptimalNewCameraMatrix( mtx, dist, (w, h), 1, (w, h) ) undistorted cv2.undistort(frame, mtx, dist, None, newcameramtx) # 预处理 gray cv2.cvtColor(undistorted, cv2.COLOR_BGR2GRAY) blurred cv2.GaussianBlur(gray, (5, 5), 0) edges cv2.Canny(blurred, 50, 150) # 轮廓检测 contours, _ cv2.findContours( edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE ) if not contours: return frame, None # 找到面积最大的轮廓 max_contour max(contours, keycv2.contourArea) area cv2.contourArea(max_contour) if area MIN_AREA: return frame, None # 外接矩形宽度 _, _, rect_w, _, _ cv2.boundingRect(max_contour) if rect_w 0: return frame, None # 计算距离 distance (KNOWN_WIDTH * FOCAL_LENGTH) / rect_w # 可视化 cv2.drawContours(undistorted, [max_contour], -1, (0, 255, 0), 2) cv2.putText( undistorted, fDistance: {distance:.2f} m, (20, 40), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 0, 255), 2 ) return undistorted, distance4.2 视频实时测距实时视频流处理的难点在于每帧都在变化轮廓宽度会有抖动必须做平滑处理。我用了一个简单的滑动窗口平均。cap cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) history [] HISTORY_SIZE 5 while True: ret, frame cap.read() if not ret: break result, distance detect_object_distance(frame) if distance is not None: history.append(distance) if len(history) HISTORY_SIZE: history.pop(0) smoothed_distance np.mean(history) cv2.putText( result, fSmoothed: {smoothed_distance:.2f} m, (20, 80), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (255, 0, 0), 2 ) cv2.imshow(Distance Measurement, result) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()这个滑动平均看起来简单实际效果非常明显。把HISTORY_SIZE从1调到5标准差能从±8厘米降到±3厘米左右。4.3 用ArUco标记物替代轮廓检测轮廓检测有个致命弱点它只能找到和背景有清晰边缘的物体一旦物体颜色和背景接近Canny边缘检测就抓瞎了。实际项目里我强烈建议用ArUco标记物替代鲁棒性完全不是一个级别。ArUco是OpenCV自带的一种二维码标记四个角点可以被亚像素级精确检测而且自带ID信息多目标场景下天然防混淆。# ArUco检测代码 aruco_dict cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_6X6_250) params cv2.aruco.DetectorParameters() detector cv2.aruco.ArucoDetector(aruco_dict, params) corners, ids, _ detector.detectMarkers(gray) if ids is not None: # 取第一个标记的角点 corner corners[0][0] pixel_width np.linalg.norm(corner[0] - corner[1]) distance (MARKER_SIZE * FOCAL_LENGTH) / pixel_widthArUco标记的检测精度比轮廓高得多因为角点检测是在二值化后的标记边界上做亚像素精化受光照影响小。缺点是需要在被测物体上贴标记适用场景有限。5. 实测误差分析哪些场景会翻车写代码只是第一步真正决定项目能不能交付的是误差控制。我把自己实测的一组数据贴出来供参考。5.1 固定焦距下的实测数据用的是600像素验证焦距目标物宽度0.20米从0.5米到3.0米每隔0.25米测一次每个距离采集30帧取平均真实距离米测量距离米绝对误差米相对误差0.500.5170.0173.4%0.750.736-0.0141.9%1.000.984-0.0161.6%1.251.2780.0282.2%1.501.5520.0523.5%2.002.1030.1035.2%2.502.6820.1827.3%3.003.4890.48916.3%可以看到两个规律近距离0.5~1.5米精度很好误差通常能压在3%以内。远距离误差爆炸式增长3米处已经偏了16%。原因不复杂距离越远物体在图像中占的像素越少像素宽度的量化误差占比急剧上升。比如物体在1米处占120像素±1像素误差对应约0.8%但在3米处只占40像素同样的±1像素误差就变成了2.5%再加上轮廓检测的不稳定性累积到16%不奇怪。5.2 倾斜角对测距结果的影响这是最容易忽略的一个误差源。物体表面与相机光轴不垂直时图像里的宽度其实是透视压缩后的宽度比实际宽度小导致测得的距离偏大。实测数据物体放在1.0米处绕垂直轴旋转倾斜角度度测量距离米偏差00.984-1.6%101.0121.2%201.0878.7%301.21421.4%20度倾斜就有近9%的误差30度直接飙到21%。如果项目里物体姿态不可控单靠轮廓宽度测距根本不可靠。一种补救办法是用PnP求解姿态把三个维度都解出来再做补偿但那样复杂度就上去了。5.3 光照变化带来的轮廓波动光照对Canny边缘检测的影响非常大。低对比度环境下物体边缘可能被检测成断裂的弧线导致boundingRect宽度变小或变大。同一位置在不同光照强度下测量值波动可以差出5%以上。建议的缓解措施在工业场景加恒定光源这是最省事的办法。用ArUco标记代替轮廓检测角点对光照的敏感度远低于边缘响应。适当调低Canny的高阈值增大轮廓闭合的概率。如果物体本身是纯色且背景稳定可以改用颜色阈值分割比边缘检测稳定得多。6. 进阶优化把精度往上拉一截如果基础版精度满足不了需求下面这几招是按性价比排序的优化手段。6.1 多帧时域滤波前面代码里的滑动窗口平均就是一种简单滤波。更高级一点可以用卡尔曼滤波对动态目标的测距更友好。卡尔曼能同时估计距离和速度当目标快速移动时即使某几帧检测失败也能预测出下一帧的位置。实际效果对比同一组视频滑动平均把标准差降到±3厘米卡尔曼进一步压到±2厘米以内。代价是代码复杂度明显上升需要调Q和R矩阵参数。6.2 亚像素角点提取轮廓检测找出的boundingRect宽度是整数像素精度天然受限。如果想在轮廓方案上继续压榨精度可以这样做检测轮廓后对轮廓点集做直线拟合取拟合直线的长度为宽度。或者提取物体边缘的梯度响应做高斯拟合找亚像素峰值。这个方法能让宽度检测精度从±1像素提升到±0.3像素但前提是物体边缘必须足够清晰它对噪声很敏感。6.3 用PnP解算完整位姿如果被测物体的四个特征点坐标已知比如A4纸的四个角、ArUco的四个角点就可以用cv2.solvePnP解出物体相对于相机的完整位姿包含旋转向量和平移向量。平移向量的Z分量就是光心到物体中心的距离。PnP的好处是不再依赖宽度这个单一维度四个点提供了冗余信息即使物体倾斜也能算出准确距离。实际效果在30度倾斜下依然能保持5%以内的误差完爆轮廓方案。# PnP测距简化代码 # 已知ArUco标记的3D坐标以标记中心为原点单位米 marker_3d np.array([ [-0.05, -0.05, 0], [0.05, -0.05, 0], [0.05, 0.05, 0], [-0.05, 0.05, 0] ], dtypenp.float32) # 检测到的角点像素坐标 marker_2d corners[0].astype(np.float32) # 解算位姿 retval, rvec, tvec cv2.solvePnP( marker_3d, marker_2d, mtx, dist ) # tvec的Z分量就是距离米 distance tvec[2][0]这段代码跑通之后距离输出的稳定性比轮廓方案高了不止一个档次强烈推荐有倾斜场景需求的朋友直接上PnP。6.4 后端加个低通滤波最后分享一个非常土但非常有效的小技巧把连续N帧的距离值做加权平均越新的帧权重越大。这个在摄像头轻微晃动时效果特别好不至于让读数跳来跳去。alpha 0.4 # 新帧权重 smoothed alpha * current_distance (1 - alpha) * previous_smoothedalpha取0.3到0.5之间比较合适。太小响应慢太大滤波效果差。这个参数的调优没有理论最优值得根据现场情况试。7. 写在最后的经验总结整套方案从标定到实测前前后后我调了两周。最大的感触是OpenCV的代码写起来不难难的是理解每一步在干什么以及知道误差从哪来。标定环节别图快15张起步多角度覆盖全画面。验证焦距这一步不要跳过它能把标定的误差暴露在测距之前。轮廓检测方案适合demo和低精度场景真要交付级的东西ArUco或PnP是更可靠的选择。最后别忘了测距结果的精度上限最终取决于图像里物体所占的像素数——分辨率不够什么算法都白搭。如果后续有条件可以试试用深度学习检测替代轮廓分割比如训练一个YOLOv8模型输出物体的bounding box再用box宽度做测距。这样即使在复杂背景下也能稳定锁定目标但实时性和算力要求又是另一笔账了。本文还有配套的精品资源点击获取
返回列表