
第一次接触单目相机测距是在一个巡检机器人项目里。当时手头只有一个普通USB摄像头老板问能不能用摄像头测出前方障碍物的距离。说实话我第一反应是摇头单目相机只有一张二维图像哪里来的深度后来把几何模型捋顺发现这条路完全走得通。这篇就把我第一次做单目相机测距的完整过程拆开讲包括为什么选单目、标定怎么做、代码怎么落地以及我踩过的那些坑给同样想用单目相机测距的朋友做一个真实参考。单目相机测距听起来反直觉它的核心不是从图像里变出深度而是借助已知的物理条件去反推距离。这里面的关键是物理世界的先验信息比如目标实际尺寸、相机安装高度、镜头焦距和姿态。只要这些条件给得够稳一个几十块钱的摄像头也能给出可用的距离数据。下面我从方案选型开始说起。1. 先从需求说起为什么单目相机也能测距1.1 我为什么没有直接上激光雷达和立体相机正经做机器人项目第一选择通常不是单目相机。激光雷达、带深度输出的立体相机、超声波模块看着都比单目靠谱。但落到我的实际场景这三个方案各有各的麻烦。激光雷达价格摆在那里哪怕是单线雷达也要多花一笔预算更别说多线型号。超声波模块便宜而且原理简单发射一个超声脉冲测回波回来的时间距离等于声速乘时间再除以二。这就是大家常说的超声波测距原理。但我实测下来它对软质吸声材料、倾斜表面、细杆目标都很头疼波束角也比较大经常量到的是最近的一颗障碍物而不是正前方的东西。有效距离也就几米放到开阔场景里不太够用。ZED这类立体相机我后来也试过。原生深度输出确实香但立体视觉的精度依赖基线标定、纹理特征和算力设备的功耗和体积也不是随便一个嵌入式平台都能接受。如果项目只是为了从普通单目视频流里估个距离为了一个测距功能就把立体相机搬上去多少有点小题大做。ZED单目模式下跑本文这套流程也不是不行但等于把它当普通摄像头用深度通道就浪费了。蓝牙测距我也调查过BLE的RSSI信号强度会随距离衰减理论上能估算距离但多径效应和人体遮挡对信号影响太大。实测下来只能分个近中远三档做精细距离判断完全不够。所以最后我把目光放回单目相机它便宜、小巧、通用只要把几何关系想清楚一样能干活。1.2 单目测距的核心逻辑一张图里的几何关系想理解单目相机测距先要接受一个事实三维场景被投影成二维图像时深度信息丢了。物体距离相机越远在图像上占的像素越少距离越近占的像素越多。这个近大远小的关系就是单目测距的第一把钥匙。最常见的方法是相似三角形。假设相机镜头的等效焦距为f像素目标实际高度为H米目标在图像上量出的像素高度是h像素那么距离D满足D f * H / h举个例子相机等效焦距是1200像素一个高0.3米的箱子在图像上高100像素距离就是1200乘以0.3再除以100等于3.6米。这个方法很好理解也很适合验证但条件非常明确你必须知道目标的实际尺寸而且目标最好正对相机不要歪着站。另一类更实用的是地平面模型。如果相机安装在车上或者机器人上高度固定光轴方向固定那么图像上每一个像素都能对应一条从光心射出的射线。目标是站在地面上的找到目标底边所在的像素行这条射线与地面平面的交点离相机的水平距离就是我们要的距离。这个方法不需要知道目标具体尺寸只用相机高度、内参和俯仰角所以对行人、车辆、护栏这类地面目标特别友好。后面会详细讲怎么做。还有一个很容易忽略的点单目测距的误差不是线性的。距离越远同样的像素误差对应的实际距离误差越大。比如5米外差1个像素可能是几十厘米到了30米外差1个像素可能就是好几米。所以第一版别贪远把可靠测距范围定在近中距才是务实的做法。2. 准备工作相机标定和参数求解才是第一步2.1 相机标定到底在解决什么问题很多人拿到相机第一件事就写测距代码结果发现距离值忽大忽小怎么调都不对。原因往往不是算法而是相机内参根本没搞明白。那什么是内参简单说就是镜头焦距、主点位置和镜头畸变这些参数。厂家标的3.6mm焦距是镜头的物理焦距但真正参与像素计算的是等效像素焦距它等于物理焦距除以像素尺寸。不同分辨率和不同传感器等效像素焦距完全不同。镜头本身还会引入畸变最常见的是桶形畸变和枕形畸变。图像边缘的物体形状都会被扭曲直接量像素尺寸肯定不准。标定就是拿到一组内参矩阵和畸变系数后续每一帧先做去畸变再量尺寸数据才能站得住。我用的标定工具是OpenCV的棋盘格标定。原理是打印一张黑白棋盘用相机从不同角度拍每张照片里棋盘的角点是明确的OpenCV会建立棋盘在3D空间和2D图像之间的对应关系然后求解相机参数。2.2 我用的标定方法和实测参数我的标定板是A4纸打印的10乘7棋盘格实际方格边长2.5厘米贴在一块硬纸板上。硬纸板比软纸强得多软纸一旦有弧度角点定位就会歪。总共拍了28张照片角度从正面、侧面、俯仰到旋转都有覆盖画面中心和四角区域。注意别把棋盘切出画面也别拍糊这两点直接影响重投影误差。标定代码逻辑很简单import cv2 import numpy as np # 角点尺寸棋盘内角点数量是(9, 6) pattern_size (9, 6) square_size 0.025 # 每格边长单位米 corners_list [] object_points [] # 构造棋盘格在世界坐标系中的坐标 objp np.zeros((pattern_size[0] * pattern_size[1], 3), np.float32) objp[:, :2] np.mgrid[0:pattern_size[0], 0:pattern_size[1]].T.reshape(-1, 2) objp * square_size images [...] # 自己准备图片路径列表 for path in images: img cv2.imread(path) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) ret, corners cv2.findChessboardCorners(gray, pattern_size, 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) corners_list.append(corners) object_points.append(objp) # 标定 ret, mtx, dist, rvecs, tvecs cv2.calibrateCamera( object_points, corners_list, gray.shape[::-1], None, None ) # 保存结果 np.savez(calib.npz, mtxmtx, distdist) print(内参矩阵, mtx) print(畸变系数, dist)我那次得到的结果大概是fx和fy在1360上下主点cx、cy接近960、540畸变系数k1约-0.14。重投影误差在0.15像素左右。这个误差水平已经能满足后续测距使用了。参数会随相机不同变化所以换相机或换分辨率后一定要重新标定不能一套参数到处套。2.3 焦距换算和常见误区如果暂时不想走完整标定流程也有一个快速方法拿到等效焦距。把相机固定好把一个实际宽度已知的纸箱放在距离相机正好1米的位置量它在画面里的像素宽度然后用公式算f 像素宽度 * 距离 / 实际宽度比如纸箱宽0.3米在1米处图像里宽400像素那等效焦距就是400乘以1再除以0.3约1333像素。这个值可以作为临时fx使用。不过它没有处理畸变所以只能用于粗测后面还是要做完整标定。这里有个新手很容易踩的误区把镜头外包装上的f3.6mm直接塞进公式。这个3.6毫米必须除以单个像素的物理尺寸才能换算成像素单位不同相机传感器不一样直接套用结果完全错误。另外分辨率变了焦距像素值也要跟着变。同一台相机从1280乘720切成640乘480fx和fy都得按比例缩放。还有一个容易忽略的细节测宽度用fx测高度用fy。如果你的目标歪着放像素宽度对应的物理尺寸就不是目标正面宽度了。所以第一版测试时我尽量让目标正对相机避免把尺寸投影搞错。3. 核心实现从像素坐标到距离值的完整流程3.1 软件环境和架构设计我当时的开发环境是Python 3.8加OpenCV 4.5.4目标检测部分第一版没有直接用YOLO而是选了ArUco码。原因是ArUco码的角点稳定四个角点都能精确到亚像素实际边长已知测距流程最干净。YOLO这类模型确实能检测人和车但边界框高度受姿态、截断、遮挡影响很大。比如人弯腰时高度直接少一截车头正对和侧对时像素宽度也完全不同。第一次做测距我不建议上来就玩神经网络先用ArUco码把整个链路跑通建立对误差的直觉再慢慢迁移到真实目标。整体架构是这样的视频帧进入程序后先做去畸变再检测ArUco码拿到角点和边长然后套用相似三角形公式算距离最后把结果在画面里画出来。如果需要转发给下位机再加一个串口发送步骤。每一帧都独立计算不依赖历史数据简单直接。3.2 最小可运行的单目测距代码我第一版的可运行代码其实很短。在已经完成标定并保存calib.npz的前提下核心逻辑如下import cv2 import numpy as np # 加载标定结果 data np.load(calib.npz) mtx data[mtx] dist data[dist] cap cv2.VideoCapture(0) # 使用6X6_250字典里的ArUco码 dictionary cv2.aruco.Dictionary_get(cv2.aruco.DICT_6X6_250) params cv2.aruco.DetectorParameters_create() # ArUco码实际边长单位米 marker_size 0.05 while True: ret, frame cap.read() if not ret: break # 先做去畸变 frame cv2.undistort(frame, mtx, dist) corners, ids, _ cv2.aruco.detectMarkers(frame, dictionary, parametersparams) if ids is not None: for i in range(len(corners)): corner corners[i] # 取第一条边的像素长度 side np.linalg.norm(corner[0][0] - corner[0][1]) if side 0: # 水平测距用fx垂直测距用fy这里统一用fx distance mtx[0, 0] * marker_size / side text f{distance:.2f}m cv2.putText(frame, text, tuple(corner[0][0].astype(int)), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2) cv2.imshow(Monocular Distance, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()这段代码跑起来后ArUco码离相机越远测出的距离越大数值趋势是对的。我用卷尺实测0.5米到2米范围内误差大概正负2厘米到了3米以后误差明显变大到5米正负0.3到0.5米都很正常。如果检测的是人或者车辆把ArUco码替换成目标检测模型然后取目标底部物理高度和对应的像素高度公式结构是一样的距离等于等效焦距乘实际高度再除以像素高度。区别只在于实际高度是一个统计先验算出来的是估计值不是精确值。3.3 测量结果发给STM32和超声波测距、蓝牙测距怎么做配合视觉测距算出来的距离只是一个软件数值实际项目里往往要发给单片机执行动作。我当时的做法是通过USB转TTL串口把距离值发给STM32格式非常简单import serial ser serial.Serial(/dev/ttyUSB0, 115200, timeout0.1) distance 1.23 ser.write(fD{distance:.2f}\n.encode())STM32那边只要在串口中断里按换行符解析一帧数据就行。注意串口波特率两边要一致数据长度不要写成长度不可控的字符串否则解析很容易出问题。超声波测距模块我也接上了用的是HC-SR04。它的原理前面提过给Trig引脚一个10微秒高电平Echo引脚会输出一个宽度正比于距离的高电平。把高电平时间换算成距离的公式是distance_cm time_us * 0.034 / 2为什么除以2因为超声波走了个来回。比如Echo高电平持续2000微秒距离就是2000乘0.034除以2等于34厘米。这在实际测试中精度还可以但超声波容易受温度和安装角度影响低温或大风环境下声速会变化所以只能当辅助传感器用。融合逻辑我是这样写的视觉测距作为主传感器输出可信距离超声波负责近处盲区因为相机在很近距离时目标会跑出画面或严重变形超声波正好补这个空档。当视觉检测置信度低或者目标丢失时切换到超声波数据。不用做太复杂的卡尔曼融合第一版先保证有数据、不冲突就是胜利。至于蓝牙测距我更愿意把它当数据回传通道而不是测距传感器。BLE的RSSI测距在室内受环境干扰太严重金属货架和人一走动数值就乱跳。如果只是判断人靠近了没蓝牙够用要精确到厘米级还是靠视觉加超声波靠谱。4. 实操中的坑与排查技巧4.1 误差源头排查清单我把第一次调试时遇到的典型问题整理成了一张表按现象、可能原因、处理思路来排查效率会高很多。现象可能原因处理思路距离值上下跳动目标角点定位抖动、未去畸变、光照变化先加去畸变再做滤波第一版用ArUco码排查近处准远处不准像素量化误差在远距离被放大缩小可靠测距范围提高分辨率用亚像素角点距离整体偏大或偏小目标实际尺寸给错用卷尺量别靠估计确认单位是米目标左右不对称相机与目标平面不平行固定安装支架校正镜头姿态检测框频繁丢目标目标太暗、反光或部分移出画面增加置信度阈值丢弃不完整检测4.2 相机倾斜和高度误差怎么校正相似三角形法有个前提相机光轴和目标平面大致垂直。如果相机是朝下俯拍的比如装在机器人顶部斜向下看地面再用这条公式算距离就会整体偏差。更好的做法是利用相机高度和像素位置重建地面距离。当光轴水平时目标底边像素行与距离的关系很简单distance camera_height * fy / (v - cy)这里camera_height是相机离地高度fy是垂直方向等效焦距v是目标底边的像素纵坐标cy是图像主点纵坐标。目标底边在图像上的位置越靠近图像下边缘v减cy越大距离越近越靠近主点距离越远。如果相机有一定俯仰角alpha就不能忽略角度的影响。可以把每个像素点换算成相对光轴的夹角theta再用正切关系求地面距离。总的下倾角等于alpha加上theta距离就等于相机高度除以总下倾角的正切值。现场没有IMU时我是在已知距离处放一个纸箱从实测图像反推出alpha再固化到程序里。这个方法虽然粗糙但比完全不考虑俯仰角强很多。这里要特别提醒相机安装高度必须量准而且要以镜头光心位置为准不是以相机外壳底边为准。差几厘米在近距离看不太出来距离一远误差会被放大。4.3 我调试时反复踩过的几个细节第一个细节是分辨率。我一开始用1280乘720采集跑YOLO时嫌慢直接把画面缩到640乘480结果内参还是用旧参数距离全线偏移。后来才意识到等效焦距和分辨率是绑定的改分辨率必须重新标定或者按比例缩放内参。第二个细节是滤波。原始测距值即使标定没问题也会有小幅抖动因为角点检测存在亚像素级的随机偏差。我加了个简单的滑动平均窗口取最近五帧距离的中位数。中位数比平均数更能抵抗突然跳到远处的异常值这一条对我帮助很大。第三个细节是光心位置。很多人把镜头前的镜片边缘当光心但实际光心位置不同测距时需要把卷尺零点对准光心平面。如果这个基准错了所有标称距离都会差一个常数。我第一次测量时就是从相机前壳开始量的结果误差比预期大不少。5. 从能测到测准的一些经验扩展5.1 先验信息比算法模型更重要单目相机测距单独看是算法问题实际做下来更像一个信息补齐问题。模型再高级如果目标的实际尺寸假设是错的或者相机高度没有量准结果一样不靠谱。我做完第一版之后的体会是先验信息大于算法。目标实际尺寸、相机内参、安装高度、俯仰角任何一个环节出问题后面的数值都没有意义。反过来把这些物理量控制住一个简单的Similiar Triangle公式就能满足大部分项目需求。所以在进入正式项目之前我建议花时间做一次完整的现场校准而不是依赖网上找的通用参数。每台相机、每次安装位置变化都重新过一遍标定流程。这个步骤偷懒后面所有数据都会还回来。5.2 后面能怎么扩展如果已经能稳定输出距离下一步可以做三件事一是加目标跟踪和滤波让输出距离更平滑二是把单目测距结果与超声波、IMU做简单融合提升遮挡场景的稳定性三是在可靠距离范围内的数据统一走协议上报供上位机或远端展示。ZED这类立体相机如果有条件也可以拿来对比它的原生深度图精度确实更高。但单目方案的优势从来不是精度而是成本低、部署简单、通用性强。在资源受限或者只需要一个辅助距离信号的场景里这套方法完全够用。最后再分享一个小习惯换相机、换安装位置、换分辨率之后别急着跑测距代码先把内参和相机高度重新测一遍所有标定数据保存好并备注拍摄时间。我踩过一次没备注参数的坑后来回看数据完全分不清是哪台相机拍的只能全部重来。这个习惯帮我少加了好几次班也希望你能用上。