YOLOv5单目测距全链路:标定、接入、平滑与误差控制
简介:这份 Python 项目围绕 YOLOv5 目标检测与单目视觉测距展开,适合具备一定深度学习基础、希望把检测框与距离估计结合到自动驾驶、机器人导航或智能监控场景中的开发者学习与二次开发。压缩包共 132 个文件,约 13.87MB,以 py 训练与推理脚本、yaml/yml 模型与数据配置、md 说明文档为主,辅以 sh 启动脚本、pt 预训练权重及 Dockerfile 等环境文件,目录层次较完整,便于快速复现与迁移。已有 3868 人学习下载。读者可从中获得 YOLOv5 6.1 的模型代码与预训练权重、单目测距网络实现、训练与测试流程脚本,以及 Docker 环境和示例图像,用于理解检测结果如何与深度估计结合输出物体距离;也可借鉴配置组织方式,完成从数据准备、模型训练到可视化验证的完整链路,并为无额外深度硬件的低成本测距方案提供实践参考。
1. 摄像头只有一个,距离照样能算准
摄像头只有一个,距离照样能算出来,但得先承认精度天花板在哪。YOLOv5 单目测距这套组合,模型负责把目标框稳定在像素级,真正决定米数准不准的是相机内参、目标尺寸假设和安装姿态这三件事。它适合做车载前向的中远距离预警、仓储小车的障碍物粗定位、人流间距统计,这类场景 5%~10% 的相对误差够用;不适合塞进毫米级绝对坐标的机械臂抓取。后面按标定、接入、平滑、排错的顺序把链路拆开,每个环节都给能直接抄的参数和代码。
2. 针孔模型与焦距标定:三个参数决定米数准不准
2.1 相似三角形测距,公式里每个符号对应什么
单目测距最常用的两条路,本质都是针孔模型。第一条是相似三角形,靠目标真实高度反推距离:
D = f_px * H_real / h_pxD 是相机光心到目标的直线距离(米),H_real 是目标真实高度(米),h_px 是目标在图像里占的高度像素数,f_px 是焦距换算成像素后的值。这个式子看着简单,但 f_px 一旦取错单位,结果能差几倍。OpenCV 标定返回的 fx、fy 本身就是像素单位,可以直接用;镜头标签上印的 4mm、6mm 是物理焦距,跟 fx 不是一回事,拿它代入必然翻车。
对 D 求偏导能看出误差传播关系:h_px 测错 1 个像素,D 的相对误差就是 1/h_px。一个 1.7 米高的人在 640 宽度图像里框高 51 像素,那 1 像素抖动带来约 2% 的距离误差;到远处框高只剩 12 像素,同样 1 像素抖动就是 8% 的误差。这解释了为什么远处目标必须做平滑,而不是靠调模型精度硬扛。
第二条路是地面成像模型,不依赖目标本身尺寸:
D = f_py * H_cam / (v_ground - cy)H_cam 是相机光心离地高度,v_ground 是目标接地点(也就是检测框底边中点)的像素行坐标,cy 是主点行坐标,f_py 是 y 方向的像素焦距。这条路的物理含义是:地面在图像里是一条随距离抬升的线,越远的目标接地点越靠近地平线,也就是越接近 cy。它适合车辆、行人这类贴地目标的纵向距离估计,但对相机俯仰角极其敏感,装歪一两度,5 米处就能差出半米。
| 方案 | 依赖输入 | 适用目标 | 主要误差源 |
|---|---|---|---|
| 相似三角形 D=f·H/h | 目标真实高度 H | 人、车、货箱等尺寸稳定的目标 | 目标姿态变化、框高抖动 |
| 地面成像 D=f·Hc/(v-cy) | 相机离地高度、主点 | 贴地运动目标 | 俯仰角、相机颠簸 |
| 两者融合 | 上面全部 | 中远距离场景 | 权重分配与标定质量 |
我的习惯是两条路都算,在 3 米到 15 米区间以地面模型为主(这个区间它对俯仰角不敏感),15 米以外切到相似三角形加平滑,两边结果差超过 20% 就标记为不可信。判断依据不是拍脑袋,而是两个模型在标定阶段各自算一遍已知距离的靶子,看谁的系统偏差小。
2.2 棋盘格标定拿内参:一次采集、一次求解
内参是整个链路的地基,标定做歪了,后面所有调参都是在错误的数值上做微调。常见做法是打印一张棋盘格,用同一台相机、同一个分辨率、同一个对焦状态拍 15 到 25 张不同角度和距离的照片,覆盖画面四角和中心。注意必须是最终部署时用的那个分辨率和对焦距离,中途改动过变焦或换了分辨率,这套内参就作废了。
import cv2 import numpy as np import glob PATTERN = (9, 6) # 棋盘格内角点数(列, 行),不是方格数 SQUARE_MM = 25.0 # 单格边长,单位毫米,务必用尺子量 IMG_DIR = './calib_imgs/*.jpg' # 生成世界坐标系下的角点,(0,0,0) 是第一个内角点 objp = np.zeros((PATTERN[0] * PATTERN[1], 3), np.float32) objp[:, :2] = np.mgrid[0:PATTERN[0], 0:PATTERN[1]].T.reshape(-1, 2) * SQUARE_MM obj_points, img_points = [], [] h, w = None, None for path in sorted(glob.glob(IMG_DIR)): gray = cv2.cvtColor(cv2.imread(path), cv2.COLOR_BGR2GRAY) h, w = gray.shape[:2] # SB 版本对噪声和模糊更鲁棒,旧版 OpenCV 换 findChessboardCorners ok, corners = cv2.findChessboardCornersSB(gray, PATTERN, flags=cv2.CALIB_CB_EXHAUSTIVE) if not ok: print('skip:', path) # 检测失败的图直接丢掉,别硬塞 continue obj_points.append(objp) img_points.append(corners.reshape(-1, 1, 2)) rms, K, dist, rvecs, tvecs = cv2.calibrateCamera( obj_points, img_points, (w, h), None, None, flags=cv2.CALIB_FIX_K3) # 普通工业镜头不必估 K3,降自由度更稳 fx, fy = K[0, 0], K[1, 1] cx, cy = K[0, 2], K[1, 2] print(f'RMS={rms:.3f}px fx={fx:.2f} fy={fy:.2f} cx={cx:.2f} cy={cy:.2f}')代码逻辑分三段:构造世界坐标角点、逐图检测角点、整体求解并输出内参。PATTERN传的是内角点数,一个 10×7 的方格棋盘内角点是 9×6,这一点错了会一直检测失败。SQUARE_MM只影响外参平移量的量纲,对 fx、fy 本身没有影响,但为了后续可能用到的世界坐标(比如地面标定),单位统一成毫米最省心。CALIB_FIX_K3是保守做法,样本不足时放开高阶畸变系数容易过拟合。
判断标定质量只看一个数:RMS 重投影误差。低于 0.5 像素说明这套内参可信,0.5 到 1.0 之间凑合用,超过 1.0 一定是有图检测错了或者拍得太糊,回去重拍。注意 RMS 低不代表畸变模型选对了,还要看 undistort 之后的画面直线是否还是直线,尤其是广角镜头拍出来的桶形边缘。
拿到了 K 和 dist,推理时如果直接喂原始帧,YOLOv5 内部会做 letterbox 缩放,坐标会被还原回原图尺度,fx、fy、cx、cy 可以直接对应原始分辨率使用。但只要你手动把帧 resize 成 640×640 再喂进去,内参就必须同比缩放,否则距离会整体偏掉一个倍数。这个坑在 5 章会展开。
2.3 没有标定板时的反推焦距法与适用边界
手上没有棋盘格,或者相机已经死死装在设备里拆不下来,退而求其次的做法是用已知距离反推焦距。找一块平地,把相机架好,在 5 米、10 米、15 米各放一个已知高度(比如 1.7 米高的标尺或人)的物体,量出它在图像里的框高像素数,倒推:
import numpy as np def estimate_focal(known_dist_m, box_h_px, real_h_m): # 由 D = f * H / h 反解 f return known_dist_m * box_h_px / real_h_m samples = [ estimate_focal(5.0, 306, 1.70), estimate_focal(10.0, 154, 1.70), estimate_focal(15.0, 103, 1.70), ] f_px = float(np.median(samples)) # 取中位数,抗单次量测误差 print(samples, f_px)逻辑很简单,就是同一公式的反解,多用几个距离点取中位数是为了压掉单次量测误差。这里的box_h_px必须是精确到像素的人工标注结果,不能直接用模型输出的框,否则模型本身的框高偏差会被固化进焦距里。反推法的适用边界很明确:它把主点 cy 默认成了图像中心,把畸变当成了零,所以只能在中距离、画面中心区域用,广角镜头边缘的区域误差会明显放大。它适合快速验证和 Demo,正经部署还是老老实实标定。
还有一点容易忽略:反推出来的 f 是那个特定分辨率下的像素焦距,跟标定得到的 fx 一样,换分辨率就要按f_new = f_old * (w_new / w_old)重新算。我一般会把标定结果连同分辨率、采集日期一起写进一个 json 配置文件,代码启动时读取,避免几套参数在代码里混着用。
3. YOLOv5 推理管线接入测距:从 xyxy 到米数
3.1 本地加载模型与输出结构
测距模块不需要改 YOLOv5 的模型结构,它只是消费检测结果的一个后处理。我一般把 YOLOv5 仓库整个放在工程目录下,用本地路径加载,避免运行时依赖网络。加载方式如下:
import torch import numpy as np # source='local' 表示从本地仓库读模型定义,不走网络 model = torch.hub.load('./yolov5', 'custom', path='./yolov5/weights/yolov5s.pt', source='local', device='cpu') model.conf = 0.35 # 置信度阈值,测距场景宁可漏检也别误检 model.iou = 0.45 # NMS 阈值 model.classes = [0] # 只保留 person 类,按自己权重的类别索引改 model.eval() frame = ... # BGR ndarray results = model(frame, size=640) det = results.xyxy[0].cpu().numpy() # 每行 [x1, y1, x2, y2, conf, cls]det的坐标已经是还原到原始帧尺寸的绝对值,因为 torch.hub 接口内部做了 letterbox 的逆变换。这一点很关键:如果你绕过接口自己写预处理,就得自己调用scale_coords,否则框会整体偏移和缩放,测出来的距离完全没有参考价值。
model.conf我一般从默认的 0.25 提到 0.35,测距场景跟纯检测场景的取舍不一样。误检会凭空冒出一个距离告警,操作员对虚假报警的容忍度远低于漏掉一个远处目标;而漏检的目标在下一帧大概率还能被检出,代价小得多。model.classes按需保留,做车辆纵向距离就只留 car、truck、bus 这几类,把人去掉能显著减少画面里的框数量,也减少测距模块的计算量。
3.2 从检测框到距离:底边中点与框高的用法区别
相似三角形用的是框高,地面模型用的是框底边中点的纵坐标,这两个量都从同一个 xyxy 里来,但含义完全不同,混用是新手最常见的错误。
def dist_by_height(box, f_px, real_h_m, min_h_px=12): x1, y1, x2, y2 = box[:4] h_px = y2 - y1 if h_px < min_h_px: return None # 太小的框不参与测距,误差不可控 return f_px * real_h_m / h_px def dist_by_ground(box, f_py, cy, cam_h_m, max_dy=2.0): v_ground = (box[1] + box[3]) / 2.0 # 框底边中点 dy = v_ground - cy if dy <= max_dy: return None # 接地点太接近地平线,距离趋于无穷 return f_py * cam_h_m / dydist_by_height里的min_h_px=12是个经验阈值,框高低于 12 像素时 1 像素的抖动会带来 8% 以上的误差,与其给出一个看着精确的假数字,不如直接返回 None 让上层逻辑忽略。real_h_m必须跟类别绑定,人取 1.70,轿车取 1.50(车顶到地面),SUV 取 1.70,货车按实际车型另设。用统一的 1.5 去套所有车,误差能到 30%。
dist_by_ground里的max_dy=2.0是防止除零和数值爆炸,实践中地平线附近的检测框本身就不该参与测距。注意cy用的是标定得到的真实主点,不是简单的height/2,工业相机的主点经常偏离中心十几个像素,用height/2会引入一个恒定的系统偏差,在近距离上不明显,远了就体现出来了。
多目标场景下要给每个框维护独立的状态,用跟踪 ID 关联,不能让同一辆车在第 N 帧用中位数平滑、第 N+1 帧又从零开始。简单做法是用检测框与上一帧框的 IoU 做匹配,IoU 大于 0.3 视为同一目标,把距离历史带过去。
3.3 地面成像模型加上俯仰角补偿
相机装在车上、机器人上,几乎不可能保证光轴严格水平,向下俯 3 到 5 度是常态。俯仰角不补偿,地面模型在 5 米处就能差出 20% 以上。带俯仰角的完整反解是这样推的:设相机高度 h,俯仰角 θ(向下为正),接地点在图像上的行坐标为 v,dy = v - cy,则
D = h * (f_py * cosθ - dy * sinθ) / (dy * cosθ + f_py * sinθ)θ 等于 0 时退化回D = f_py * h / dy,可以用这个特例验算代码是否正确。
def dist_ground_pitch(v_px, f_py, cy, cam_h_m, pitch_deg, min_dy=2.0): dy = v_px - cy if dy <= min_dy: return None th = np.deg2rad(pitch_deg) num = cam_h_m * (f_py * np.cos(th) - dy * np.sin(th)) den = dy * np.cos(th) + f_py * np.sin(th) return num / denpitch_deg怎么来?两个办法。一是用水平仪或手机倾角 app 装好之后直接读,精度到 0.5 度就够用;二是标定法,在已知距离的地面上摆两个点(比如 5 米和 10 米),用两个点的接地点像素坐标代入公式反解 θ,解出来的值比手工量更贴合实际安装姿态。第二种做法我一般会做一遍,因为支架的形变、轮胎的压缩这些因素,手工量根本量不准。
值得提醒的是,cam_h_m也要实测。相机光心离地高度不是车顶到地面的高度,也不是支架安装高度,得从镜头中心点垂直量到地面,车辆满载和空载能差 3 到 5 厘米,这个差异在近距离测距里是能看出来的。我习惯在配置文件里同时存空载和满载两个高度,按工况切换。
4. 平滑、可视化与告警分级:让数字别乱跳
4.1 中位数滑窗和一维卡尔曼怎么选
原始测距结果一定是抖的,因为框的底边在相邻帧之间抖一两个像素是常态,而Z ∝ 1/dy这个反比关系会把小抖动放大。不加平滑直接用,画面上就会出现距离从 8.2 米跳到 11.7 米再跳回来的情况,看的人第一反应是这套东西不可信。
from collections import deque import numpy as np class MedianSmoother: def __init__(self, n=9, max_jump=6.0): self.buf = deque(maxlen=n) self.max_jump = max_jump self.last = None def update(self, d): if d is None or not np.isfinite(d): return self.last # 本帧无效,沿用上次结果 if self.last is not None: # 单帧跳变超过 max_jump 米时做限幅,防误检导致的假急刹 if abs(d - self.last) > self.max_jump: d = self.last + np.sign(d - self.last) * self.max_jump self.buf.append(d) self.last = float(np.median(self.buf)) return self.lastn=9对应 30fps 下约 0.3 秒的响应延迟,这个延迟对预警类应用可以接受,对避障类应用要降到 5。用中位数而不是均值,是因为中位数对偶发的离群值免疫,一帧误检不会污染整个窗口。max_jump限幅是血泪经验:模型偶尔会把两个挨着的目标框合并成一个大框,框底边突然下移,算出来的距离会瞬间缩小好几米,中位数窗口要好几帧才能拉回来,加个限幅能立刻压住。
一维卡尔曼比中位数平滑在匀速运动下更贴合,但需要调过程噪声 Q 和观测噪声 R,调不好反而引入滞后。我的取舍是:只需要稳定读数、不关心速度的,用中位数;要做 TTC(碰撞时间)估算、需要速度量的,用卡尔曼,因为它能同时给出速度状态。两种方案的核心差异在下面这张表里。
| 方案 | 响应延迟 | 抗离群值 | 额外输出 | 调参成本 |
|---|---|---|---|---|
| 中位数滑窗 | 约 n/2 帧 | 强 | 无 | 极低 |
| 一维卡尔曼 | 约 2~3 帧 | 中 | 速度 | 中 |
| 指数滑动平均 | 约 1 帧 | 弱 | 无 | 低 |
4.2 绘制标注与距离分级的排版细节
可视化不只是画个框写个数字,告警分级的颜色阈值和文字位置会直接影响使用者的判断速度。分级我一般按场景定,车载前向用 6 米红、15 米黄、其余绿;仓储小车速度慢,阈值整体压缩到 2 米和 5 米。
import cv2 def draw_detection(frame, box, label, dist): x1, y1, x2, y2 = map(int, box[:4]) if dist is None: color, text = (160, 160, 160), f'{label} --' elif dist < 6.0: color, text = (0, 0, 255), f'{label} {dist:.1f}m' elif dist < 15.0: color, text = (0, 215, 255), f'{label} {dist:.1f}m' else: color, text = (0, 200, 0), f'{label} {dist:.1f}m' cv2.rectangle(frame, (x1, y1), (x2, y2), color, 2) # 文字底色块,避免浅色背景上看不清 (tw, th), _ = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, 0.6, 2) cv2.rectangle(frame, (x1, y1 - th - 8), (x1 + tw + 6, y1), color, -1) cv2.putText(frame, text, (x1 + 3, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 2) # 接地点打一个小圆,方便核对地面模型用的坐标 cv2.circle(frame, ((x1 + x2) // 2, y2), 3, (255, 0, 255), -1) return frame代码里几个细节值得说。文字先用getTextSize量出尺寸再画底色块,否则背景是浅色天空时白字完全看不见。接地点画一个洋红小圆点是排查神器,调试阶段一眼就能看出地面模型取的是不是框底边中点,框画歪了立刻能发现。cv2.putText不支持中文,类别名要么用英文,要么整帧转 PIL 绘制再转回来,后者在 30fps 下会明显拖慢速度,我一般只在离线分析视频时用。
分级阈值不要写死在绘制函数里,抽成配置项。同一个代码换个场景就要重新调阈值,写死在函数里改一次就要重新过一遍代码评审,得不偿失。
5. 单目测距五条避坑记录:现象、原因与解决
5.1 距离整体偏大或偏小一个固定倍数
现象:所有目标的距离都比实际大 2 倍左右,而且倍数很稳定。原因基本只有一个,标定用的分辨率跟推理用的分辨率不一致,或者手动 resize 之后没有同比缩放 fx、fy、cx、cy。内参是跟像素栅格绑定的,图像缩一半,像素焦距也必须缩一半。解决办法是把分辨率和内参绑成一个配置项,代码启动时断言abs(cfg_w - frame.shape[1]) < 1,不匹配直接报错退出,别让它静默跑下去。
5.2 远处目标距离疯狂跳动
现象:15 米以外的目标距离在 15 到 40 米之间乱跳,近处很稳。原因是D ∝ 1/h_px和D ∝ 1/dy都是反比关系,分母小的时候,1 像素的量化误差会被放大十几倍。解决办法是设置有效测距上限,框高低于 12 像素、dy小于 2 像素的目标直接返回 None,界面上显示--而不是一个假数字;同时在 15 米外切到相似三角形加长窗口中位数平滑。
5.3 相机稍微低头,近处距离就系统性偏大
现象:5 米处的目标测出来是 6.2 米,10 米处测出来 11.5 米,误差随距离减小而增大。原因是地面模型默认光轴水平,相机实际向下俯了几度,等效于地平线在图像里上移了。解决办法是用两个已知距离的地面点反解俯仰角,把 θ 代入完整公式,别用简化式硬扛。反解出来的 θ 如果超过 10 度,说明相机装得太斜,边缘畸变会明显变大,建议先调整支架角度。
5.4 目标框合并导致距离骤降
现象:画面上两个人挨着走,两个框突然合并成一个大框,距离数字瞬间从 12 米掉到 4 米。原因是 NMS 之后剩余的框在空间上重叠,或者跟踪匹配把两个目标串成了一个。解决办法有三层:NMS 的 IoU 阈值从 0.45 降到 0.4 减少合并;平滑模块加单帧限幅;类别过滤只保留真正需要的类,减少画面里的目标密度。
5.5 强光、逆光下整段失效
现象:下午逆光时测距整体偏小 20% 以上,换到阴影里又正常。原因是过曝导致目标轮廓被吃掉,框的下边界向上收缩,h_px和dy同时变小。这类问题靠算法补不回来,只能从硬件侧解决:给相机加遮光罩,开启自动曝光的上限限制,或者在标定阶段就采集一组强光样本单独算一套参数按光照切换。我在户外场景一律把曝光上限压到 1/500 秒,宁可画面暗一点也不让高光溢出。
6. 把误差压到 5% 以内:分段拟合与现场校验
标定和内参都对的前提下,剩下的系统性误差主要来自两处:镜头畸变在画面边缘的非线性残留,以及目标尺寸假设跟实际目标的偏差。前者用分段校正,后者用拟合校正,两个都用实测数据说话,别靠公式推。
分段校正的核心思路是在有效测距范围内布若干已知距离的靶点,记录模型输出值,然后做一次最小二乘拟合,把系统性偏差吃掉:
import numpy as np # 实测样本:第一列是算法原始输出,第二列是激光测距仪真值 samples = np.array([ [3.12, 3.00], [5.28, 5.00], [7.45, 7.00], [10.10, 9.60], [14.80, 14.00], [19.60, 18.50], ]) d_meas, d_true = samples[:, 0], samples[:, 1] # 二次多项式拟合,系数太少压不住远端误差,太多会过拟合 coef = np.polyfit(d_meas, d_true, 2) def correct(d_raw): if d_raw is None: return None return float(np.polyval(coef, d_raw)) print('coef =', coef) for raw, true in samples: print(f'{raw:5.2f} -> {correct(raw):5.2f} (真值 {true:5.2f})')np.polyfit的次数选 2 是个折中:一次项只能校正整体缩放,二次项能吃掉远端因为畸变和角度引起的非线性偏差,三次以上在样本量少于 10 时基本就是过拟合。拟合完一定要把残差打出来看,最大残差超过 0.5 米说明样本里有异常点,回去检查那个距离上的靶点是不是没摆正。系数要跟相机、分辨率、俯仰角一起存进配置文件,换任何一个都得重标。
现场校验这一步不能省。我会在部署后拿激光测距仪在 5 米、10 米、20 米各测 10 次取中位数,跟系统输出对比,偏差超过 5% 就说明哪一环出了偏差。这套流程走完之后,20 米内的人形目标实测误差基本能落在 3% 到 5% 区间,超过 25 米就别指望了,单目的物理极限在那里。
有一件事我踩过最狠的坑:整套参数看着都对,装车之后误差忽大忽小,查了两天才发现是相机支架用扎带固定的,跑起来会轻微晃动,俯仰角一直在 2 度范围内变。从那以后我每装一台相机,都强制先把支架拧死、跑一段烂路复测俯仰角,再谈模型精度。硬件稳不住,算法再精细都是白搭。希望帮到你。
本文还有配套的精品资源,点击获取