简介YOLOv5单目测距Python实现方案面向计算机视觉入门到中阶开发者也适合在自动驾驶、机器人导航、智能监控等方向做场景验证的工程人员核心解决单摄像头条件下如何由二维图像推断目标距离的问题先用 YOLOv5 完成实时目标检测再结合深度估计网络把检测框映射为三维距离省去激光雷达等额外硬件成本。压缩包共132个文件、约13.87MB以34个 py 脚本训练、推理与测距流程、40个 yaml 配置数据集与模型参数、11个 yml 依赖环境、16个 pyc 编译文件为主另含 pt 权重、Dockerfile 与 sh 启动脚本、ipynb 教程示例目录层次分明便于按模块定位查阅。已有3869人学习。读者可直接拿到可运行的检测与测距联合代码及预训练权重对照数据增强、Mish 激活、自适应锚框等实现细节复现单目测距流程并在此基础上替换数据集、调整阈值做二次开发。1. 一个 USB 摄像头就能报出三维坐标这套 YOLOv5 单目测距能处理什么仓库货架横梁上装一颗几十块钱的普通 USB 摄像头要回答的问题其实很朴素画面里这个人、这台叉车离镜头大概几米偏左还是偏右。不加深度相机、不挂激光雷达只用 YOLOv5 做检测、再用单目几何把检测框换算成三维测距结果这就是这套 Python 工程的全部野心。它适合两类人一类是手上已经有监控或行车记录视频、想加一层“距离告警”的开发者另一类是刚学完目标检测、卡在“像素怎么变成米”这一步的工程师。反直觉的地方在于单目测距的精度上限几乎不由检测网络决定而由那几个相机内参和“目标站在地面上”这条假设决定——检测框画得再准fy填错一位距离就能差出两倍。2. 工程骨架YOLOv5 推理链路和测距模块是怎么拼起来的拿到一个叫“YOLOv5单目测距”的 Python 包最容易犯的错是直接python xxx.py然后被一堆路径和参数报错劝退。先把骨架看清楚检测部分是一套标准的 YOLOv5 前向推理测距部分是独立于网络之外的一段几何计算两者之间只靠一个东西耦合——检测框的像素坐标。理解这条边界后面改代码会轻松很多。2.1 目录结构与每个文件的职责这类工程常见的组织方式是把“模型推理”和“几何计算”拆成两个文件配置文件单独抽出来避免把内参写死在代码里。我拆过的几个版本大同小异大致是这个形态文件 / 目录作用跑之前要确认什么detect_distance.py主入口负责读图/读视频、推理、调用测距、绘制结果默认参数值尤其--source和--weightsdistance.py测距核心一个类装下所有几何算法里面的类别高度表是不是你要的类别calibrate.py棋盘格标定产出内参矩阵棋盘格内角点数、格子实际边长camera_config.yaml内参、相机安装高度、俯仰角、类别真实尺寸分辨率要和推理时一致models/YOLOv5 的网络定义与通用工具有无common.py、experimental.py等依赖weights/.pt权重文件权重的类别名和你代码里的类别名对不对得上utils/letterbox、NMS、绘图等辅助函数scale_boxes的坐标映射是否包含在内data/、output/输入素材与结果输出输出目录不存在时程序会不会自己建真正需要你动的通常只有三个camera_config.yaml填你自己相机和场景的参数、weights/换你自己的权重、distance.py里的类别高度字典。其余文件在第一次跑通之前别碰改了大概率是给自己挖坑。2.2 一帧图像从进入到输出的完整链路把数据流在纸上画一遍出问题时才知道该在哪一环打日志。整条链路是这样的cv2.VideoCapture读一帧得到(h0, w0)的 BGR 原图letterbox 等比缩放并补边到 640×640记录缩放比gain和填充量pad_x / pad_y转 RGB、转 CHW、归一化送进模型拿到(1, 25200, 85)这样的原始输出NMS 过滤掉重叠框和低置信度框留下N×6的[x1, y1, x2, y2, conf, cls]关键一步把框的坐标从 640×640 的“网络坐标系”还原回原图坐标系用还原后的框底边、框高度配合内参算出Z纵深和X / Y左右、上下把类别、置信度、距离画回原图并写出视频。第 5 步是这类工程里最容易出错的地方。很多“距离忽大忽小”的玄学问题根子不在测距公式而在于框的坐标还停留在补边后的图上y2比真实值大了一截除下来的距离自然对不上。血泪经验只要坐标系没对齐后面所有公式都是白算。2.3 环境搭建与第一次跑通的命令Python 版本建议 3.83.10太新的版本偶尔会和某些 torch 轮子打架。我一般这么起# 建环境装依赖 conda create -n mono3d python3.9 -y conda activate mono3d pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install opencv-python numpy pyyaml tqdm # 先跑一张图确认检测链路本身是通的 python detect_distance.py \ --source data/images/test.jpg \ --weights weights/best.pt \ --conf-thres 0.4 \ --view-img--conf-thres建议第一次就给到 0.4 以上低了会冒出一堆半截框测出来的距离全是噪声。如果只想先看检测效果、暂时不关心距离可以把camera_config.yaml里的enable_distance关掉先验证模型本身没问题。换成视频源只需把--source改成data/videos/test.mp4或者直接填0用本机摄像头。用本机摄像头时注意一点cv2.VideoCapture(0)取到的分辨率往往不是你以为的那个可能默认 640×480而你的内参是按 1920×1080 标定的这一错就是成倍的偏差。2.4 配置文件里的五个参数决定精度测距模块的参数我习惯全放 yaml改起来不用重读代码# camera_config.yaml camera_matrix: # 标定产出的 3x3 内参注意分辨率要匹配 fx: 1068.4 fy: 1069.1 cx: 959.2 cy: 541.7 calib_resolution: [1920, 1080] # 标定时用的分辨率 mount_height: 1.85 # 相机光心离地高度米 pitch_deg: 3.0 # 镜头下倾角度正数表示朝下 real_heights: # 每个类别的真实物理高度米 person: 1.70 forklift: 2.10 car: 1.55 default: 1.60 # 兜底避免字典里没这个类就崩fx / fy是像素单位的焦距不是毫米别把标定报告里的毫米数直接抄进来。calib_resolution存在的意义是当实际输入分辨率和标定分辨率不同时程序能按比例把fx、cx一起缩放这一步不做换分辨率就全乱。mount_height和pitch_deg只在用地平线法时才用到高度法可以不填但填了能让日志更可读。real_heights里那个default是个后悔药——新加一个类别忘了登记至少不会直接KeyError崩在半路。3. 测距数学从像素高度到米三条路线的推导与选型单目测距没有唯一解只有“在你的场景里够用”的解。工程里常见的就三条路相似三角形像素高度法、小孔成像 内参、地面平面假设。三套公式都很短难的是知道什么时候该用哪套以及误差会在哪里爆炸。3.1 相似三角形法最省事也最容易失效的一条路核心假设只有一句同一类目标的物理高度是固定的。物高H在距离Z处经焦距f像素单位成像像素高度h满足h f × H / Z反解就是Z fy × H / h_px其中h_px y2 - y1也就是检测框的高度。这条公式的好处是不依赖相机的安装姿态相机歪着装、装在多高都无所谓只要fy和H是准的。所以手持设备、临时架设的摄像头我都优先用它。它的软肋也很明确。第一它要求目标“直立且完整可见”人蹲下了、被货架挡掉半截腿框高度变了距离立刻跟着错。第二h_px出现在分母上误差是非线性的h_px 400时抖 2 像素距离误差 0.5%h_px 40约 20 米外时抖 2 像素误差直接 5%再远就完全没法看。第三不同类别的H必须实测靠“大概 1.7 米”估出来的数在 5 米内也许够用10 米外就明显偏。3.2 小孔成像 内参fx 才是真正的尺度来源把所有假设摊开三维点到像素的投影关系是这样的u fx × X / Z cxv fy × Y / Z cy。把它反解就得到从像素加深度回推三维坐标的完整式子Z 由测距方法给出X (u - cx) × Z / fxY (v - cy) × Z / fy这里u一般取框的水平中心(x1x2)/2v取框底边y2代表目标与地面的接触点。X是横向偏移单位米正数在右Y是相对光轴的高度偏移配合mount_height就能还原成离地高度。这套反投影是所有单目三维测距的公共出口——不管你前面用哪种方法拿到Z最后都要过一遍这两个式子才能得到(X, Y, Z)。要强调的是fx和fy的地位。它们不是“校准用的参数”而是尺度的定义本身。内参错 10%所有距离整体错 10%而且是系统性的、不会被滤波抹掉的那种错。3.3 地面平面假设车载与固定机位的主力方案如果目标是站在地面上的人、车、叉车还有一个更强的约束可以用它们与地面的接触点在一条平面上。相机光心离地Hc光轴水平时地面上距离Z处的点在图像中的行号是v cy fy × Hc / Z反解Z fy × Hc / (y2 - cy)这条式子的性质很特别y2越靠近cy地平线分母越小Z越趋向无穷大。也就是说远处目标的距离精度极差近处反而很准这跟高度法正好相反。实际工程里我会给分母设个下限比如y2 - cy 3像素时直接把结果标成inf或“超量程”而不是输出一个 200 米的荒谬数字。镜头有俯仰角θ时需要修正分母变成(y2 - cy) - fy × tan(θ)。这个θ千万别目测填我一般会让相机对着已知距离的标定点反算摆一个 5 米外的物体看它的y2落在哪倒推θ比拿量角器量准得多。3.4 三套方法的误差来源对比与选型方法必需输入适用场景主要误差源近距 / 远距表现像素高度法fy、类别真实高度H手持、临时架设、姿态不定框高度抖动、H不准、目标被遮挡近距好远距快速恶化地平线法fy、cy、安装高度、俯仰角车载、固定机位、地面目标俯仰角误差、接触点定位、非地面目标近距极准地平线附近失效逆透视 IPM四点对应 单应矩阵纯平面区域、固定相机平面不平、单应标定误差全距段均匀但受平面假设约束选型上我的习惯是能保证目标踩在地面上、相机固定不动就走地平线法精度最好相机可能被挪动、或者目标不一定在地面货架上的箱子走高度法如果两种都要就两个都算在合理区间内取加权平均差距过大时降级到可信度更高的那一路。逆透视适合做可视化俯视图直接拿来报距离反而麻烦因为单应矩阵对四点标定的精度要求高。4. 核心代码拆解标定、检测、测距、绘制四段实现公式看懂了不等于能跑。下面四段代码是这套工程里真正干活的部分我把每段的参数含义和容易改错的地方都标出来。4.1 相机标定把 fx、fy、cx、cy 拿到手标定是一次性的活但值得认真做因为它是所有距离的基准。用棋盘格拍 20 张以上不同角度的图代码很短import cv2 import numpy as np import glob pattern (9, 6) # 棋盘格内角点数横, 纵不是格子数 square_size 0.025 # 每格实际边长单位米务必拿尺子量 # 构造棋盘格在自身坐标系下的三维点 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_size obj_points, img_points [], [] for path in sorted(glob.glob(calib_images/*.jpg)): gray cv2.cvtColor(cv2.imread(path), cv2.COLOR_BGR2GRAY) ok, corners cv2.findChessboardCorners(gray, pattern, None) if not ok: print(角点没找到跳过:, path) continue # 亚像素精化能把重投影误差压下去一截 corners cv2.cornerSubPix( gray, corners, (11, 11), (-1, -1), (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)) obj_points.append(objp) img_points.append(corners) h, w gray.shape[:2] # 用的是最后一张图的尺寸务必全部同分辨率 ret, K, dist, _, _ cv2.calibrateCamera(obj_points, img_points, (w, h), None, None) print(f重投影误差: {ret:.3f} px) print(内参矩阵:\n, K)pattern填的是内角点数9×6 的格子实际是 10×7 个方格填错了角点一个都找不到。square_size用米输出的K里fx / fy就是像素单位跟公式对得上如果填厘米所有距离就整体差 100 倍。ret是平均重投影误差0.5 像素以内算好超过 1.5 像素建议重拍——多角度、多远近、铺满画面边缘别全在一个距离上拍。K是个 3×3 矩阵K[0][0]fx、K[1][1]fy、K[0][2]cx、K[1][2]cy。填进 yaml 的时候别填反了cx在K[0][2]、cy在K[1][2]行和列看着很像。4.2 检测结果还原letterbox 之后坐标必须映射回原图YOLOv5 为了保持长宽比会做 letterbox补边之后的 640×640 和原图之间差着一个缩放比和两个偏移量。这段函数就是把这笔账还回去def scale_boxes(img1_shape, boxes, img0_shape): img1_shape: 网络输入 (h, w)boxes: Nx4 的 xyxyimg0_shape: 原图 (h, w) gain min(img1_shape[0] / img0_shape[0], img1_shape[1] / img0_shape[1]) pad_x (img1_shape[1] - img0_shape[1] * gain) / 2 pad_y (img1_shape[0] - img0_shape[0] * gain) / 2 boxes[:, [0, 2]] - pad_x # 先减填充 boxes[:, [1, 3]] - pad_y boxes[:, :4] / gain # 再除缩放比 # 裁到图像范围内防止负数坐标把后面的绘制搞崩 boxes[:, [0, 2]] boxes[:, [0, 2]].clip(0, img0_shape[1]) boxes[:, [1, 3]] boxes[:, [1, 3]].clip(0, img0_shape[0]) return boxesgain取长宽两个方向缩放比的最小值因为 letterbox 是等比缩放后居中补边。pad_x / pad_y是补在两侧的边宽减掉才是原图坐标。顺序不能反必须先减 padding 再除 gain反过来算出来的框会整体偏移。这一步的验证方法很简单把还原后的框画在原图上肉眼看框是不是严丝合缝地套住目标。套不准说明img1_shape传错了传成原图尺寸或者传成(w, h)顺序颠倒。4.3 测距类用单个类封装三种算法把几何算法收进一个类调用方只管传框和类别名这是我觉得最省心的写法import math class MonoDistance: def __init__(self, cfg): K cfg[camera_matrix] self.fx, self.fy K[fx], K[fy] self.cx, self.cy K[cx], K[cy] self.real_heights cfg[real_heights] self.mount_height cfg[mount_height] self.pitch math.radians(cfg[pitch_deg]) def by_height(self, cls_name, y1, y2): 像素高度法不依赖相机姿态手持/临时架设首选 h_px max(y2 - y1, 1e-6) # 防除零 H self.real_heights.get(cls_name, self.real_heights[default]) return self.fy * H / h_px def by_ground(self, y2): 地平线法目标必须踩在地面上远距会趋向无穷 dy (y2 - self.cy) - self.fy * math.tan(self.pitch) if dy 3: # 地平线附近直接判超量程 return float(inf) return self.mount_height * self.fy / dy def to_xyz(self, box, z): 像素 深度 - 三维坐标单位米 x1, y1, x2, y2 box u, v (x1 x2) / 2.0, y2 # 底边中点代表接地点 X (u - self.cx) * z / self.fx Y (v - self.cy) * z / self.fy return X, Y, zby_height里的max(y2 - y1, 1e-6)是防除零的保险检测框退化成一个点时不会炸。real_heights用.get()加兜底新类别没登记也不会抛异常只是精度差一点比程序中断好。by_ground里的dy 3是我踩过坑之后加的不设这个阈值视频里远处会出现几十上百米的读数乱跳看着像算法坏了其实只是数学上进了一个奇点。pitch用弧度yaml 里填的是角度构造时统一转换别在每处调用里各转一次转漏一次就全错。to_xyz的v取y2而不是框中心因为要算“接地点”的地面位置。如果你的场景里目标是悬空的比如挂在墙上的箱子这里得改成框中心同时Y的含义从“离地高度”变成“相对光轴高度”两种语义别混着用。4.4 融合推理与画面叠加主循环长什么样把上面几块串起来主循环其实很短import cv2 import numpy as np import torch import yaml from distance import MonoDistance cfg yaml.safe_load(open(camera_config.yaml, encodingutf-8)) dist MonoDistance(cfg) model torch.load(weights/best.pt, map_locationcpu)[model].float().eval() names model.names cap cv2.VideoCapture(data/videos/test.mp4) while True: ok, frame cap.read() if not ok: break h0, w0 frame.shape[:2] # 1. letterbox 到 640 img letterbox(frame, 640)[0] tensor torch.from_numpy( np.ascontiguousarray(img[:, :, ::-1].transpose(2, 0, 1)) ).float().div(255).unsqueeze(0) # 2. 推理 NMS with torch.no_grad(): pred non_max_suppression(model(tensor)[0], 0.4, 0.45)[0] if pred is None: cv2.imshow(mono, frame) continue # 3. 坐标还原 - 测距 - 绘制 boxes scale_boxes((640, 640), pred[:, :4].clone(), (h0, w0)) for box, conf, cls in zip(boxes.tolist(), pred[:, 4], pred[:, 5]): label names[int(cls)] z dist.by_height(label, box[1], box[3]) if not np.isfinite(z) or z 0: continue X, Y, Z dist.to_xyz(box, z) cv2.rectangle(frame, (int(box[0]), int(box[1])), (int(box[2]), int(box[3])), (0, 255, 0), 2) cv2.putText(frame, f{label} Z{Z:.2f}m X{X:.2f}m, (int(box[0]), int(box[1]) - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 255, 0), 2) cv2.imshow(mono, frame) if cv2.waitKey(1) 0xFF 27: # ESC 退出 break几个参数值得盯住non_max_suppression的第二个 0.45 是 NMS 的 IoU 阈值目标密集比如货架上一排箱子时调高到 0.50.6否则会误删相邻目标0.4是置信度阈值跟命令行参数保持一致改一处忘了另一处是常见的错。pred[:, :4].clone()里的clone()不能省scale_boxes是原地修改直接传切片会把原始预测也改掉如果你同一帧要算两种测距方法第二次算的时候框已经被改过了。5. 避坑与排查单目测距上线前必查的 5 个细节测距代码跑起来容易跑准很难。下面五条都是我在实际项目里遇到过的按“症状—原因—解决”记下来遇到问题挨个对一遍能省不少时间。5.1 五个真实翻车现场现象一距离在 0.3 米和 30 米之间来回跳。原因通常是框高度抖动加上远处误差放大。h_px在分母上检测框每帧上下浮动 35 像素在近处几乎没影响到 15 米开外就变成几米的跳变。解决办法分两层先卡置信度阈值到 0.5 以上把半截框挤掉再对同一个 track 的Z做滑动平均或一维卡尔曼我一般用 α-β 滤波三行代码就够比引一个完整的跟踪框架轻量得多。另外给Z加个物理上下限比如 0.550 米超出的直接丢弃别让它污染滤波器的状态。现象二换了个输入分辨率所有距离整体差一倍。这是内参没有跟着分辨率走。标定在 1920×1080 做的实际推理用了 640×640 的输入图fx还是 1068那算出来的距离自然全错。解决方式是在配置文件里记下calib_resolution程序启动时按resize_x 实际宽 / 标定宽把fx和cx同步缩放fx fx × resize_xcx cx × resize_xfy和cy按纵轴比例缩。注意是分别按宽、高缩放输入长宽比和标定不一致时两个比例是不等的。现象三只有某一类目标距离离谱其他都对。十有八九是real_heights里那一项填错了。我见过把车的“宽度”当高度填进去的结果所有车都算成 1.5 倍距离。排查方法很土但有效拿卷尺量把目标摆在 3 米、5 米、8 米处打印h_px和Z反推H Z × h_px / fy看反推出来的数和你填的差多少。差得多的那一项就是要改的。现象四人站在台阶上、货箱放在桌上距离明显偏大。这是地面假设失效。地平线法假定所有目标接地点在同一平面目标抬高 1 米算出来的距离就会虚高抬高越多错得越离谱。处理办法是分场景地面目标走地平线法非地面目标走高度法判断依据可以用类别也可以用框底边y2和当前场景的地平线位置的相对关系。别指望一个公式吃下所有情况。现象五视频里的距离总比画面慢半拍人已经走过去了才报 3 米。这是结果和帧对不上常见于加了异步推理或者多线程读帧的改法。检测在一个线程、绘制在主线程中间没有帧号绑定结果就贴到了错误的帧上。解决很简单给每帧带一个自增的frame_id或者时间戳结果队列里回传frame_id绘制时只接受匹配当前帧的结果对不上就跳过不画。5.2 一套能复用的自检打印上面这些问题凭肉眼看看画面是判断不出来的需要在关键位置打日志。我习惯在测距调用处插一段诊断输出出问题时打开、平时关掉DEBUG True def debug_log(frame_id, label, box, dist, z): if not DEBUG: return h_px box[3] - box[1] horizon dist.cy dist.fy * math.tan(dist.pitch) print(f[{frame_id}] cls{label:10} h_px{h_px:6.1f} fy2{box[3]:7.1f} 地平线{horizon:7.1f} fdy{box[3] - horizon:7.1f} Z{z:6.2f}m)重点关注三个数h_px是不是稳定抖动大说明检测不稳、y2和地平线的距离dy小于 5 像素的目标本来就不该信、Z的变化曲线突变说明换类别或者框跳了。日志里带上frame_id出问题时能直接跳到那一帧复现比从头看视频快得多。6. 二次开发从“能显示距离”改到“能报警、能平滑、能对外发数据”跑通之后想做产品化改动集中在三处。第一处是把类别高度表外置成 csv 或者从配置中心拉同时加上距离分级Z 3标红报警、38标黄预警、 8保持绿色逻辑写在绘制之前别塞进测距类里。第二处是平滑前面提的 α-β 滤波足够用α取 0.6、β取 0.05 是我试出来的经验值α大了跟随快但抖小了平滑但滞后具体要按你的帧率调。第三处是出口把(frame_id, cls, X, Y, Z, conf)打包成 JSON 往 MQTT 或者本地 socket 推主循环里只做put_nowait别在绘制线程里等网络。def alpha_beta(prev, z, alpha0.6, beta0.05, dt1/30): 一维平滑prev(位置, 速度)track 断开时要重置 p, v prev pred p v * dt residual z - pred return (pred alpha * residual, v beta * residual / dt)验证方式我建议老老实实做一张实测表把目标摆在 1、2、3、5、8、12 米每档记 10 帧取平均算出绝对误差和相对误差。我的经验是 5 米内高度法能做到 ±0.2 米超过 10 米就基本只能定性判断“远还是近”这时候别再纠结小数点后两位改成区间告警反而实用。如果是车载场景改用地平线法近距离精度会好很多但要把地面平整度和俯仰角标定做扎实。踩过最贵的一次坑是在一个现场把标定图的尺寸和实际推流尺寸搞混了整套系统报了三天“差不多但不对”的距离我一度怀疑是模型权重的问题重训了两轮才发现配置文件里那一个数字。从那以后我每次上新现场都会先拿卷尺摆一个 5 米的参照物跑一帧把h_px、y2、Z三个数打出来核对一遍对得上再往下做。希望帮到你。本文还有配套的精品资源点击获取