
简介这份 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 米处就能差出半米。方案依赖输入适用目标主要误差源相似三角形 Df·H/h目标真实高度 H人、车、货箱等尺寸稳定的目标目标姿态变化、框高抖动地面成像 Df·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, flagscv2.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, flagscv2.CALIB_FIX_K3) # 普通工业镜头不必估 K3降自由度更稳 fx, fy K[0, 0], K[1, 1] cx, cy K[0, 2], K[1, 2] print(fRMS{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 # sourcelocal 表示从本地仓库读模型定义不走网络 model torch.hub.load(./yolov5, custom, path./yolov5/weights/yolov5s.pt, sourcelocal, devicecpu) model.conf 0.35 # 置信度阈值测距场景宁可漏检也别误检 model.iou 0.45 # NMS 阈值 model.classes [0] # 只保留 person 类按自己权重的类别索引改 model.eval() frame ... # BGR ndarray results model(frame, size640) 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_px12): 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_dy2.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_px12是个经验阈值框高低于 12 像素时 1 像素的抖动会带来 8% 以上的误差与其给出一个看着精确的假数字不如直接返回 None 让上层逻辑忽略。real_h_m必须跟类别绑定人取 1.70轿车取 1.50车顶到地面SUV 取 1.70货车按实际车型另设。用统一的 1.5 去套所有车误差能到 30%。dist_by_ground里的max_dy2.0是防止除零和数值爆炸实践中地平线附近的检测框本身就不该参与测距。注意cy用的是标定得到的真实主点不是简单的height/2工业相机的主点经常偏离中心十几个像素用height/2会引入一个恒定的系统偏差在近距离上不明显远了就体现出来了。多目标场景下要给每个框维护独立的状态用跟踪 ID 关联不能让同一辆车在第 N 帧用中位数平滑、第 N1 帧又从零开始。简单做法是用检测框与上一帧框的 IoU 做匹配IoU 大于 0.3 视为同一目标把距离历史带过去。3.3 地面成像模型加上俯仰角补偿相机装在车上、机器人上几乎不可能保证光轴严格水平向下俯 3 到 5 度是常态。俯仰角不补偿地面模型在 5 米处就能差出 20% 以上。带俯仰角的完整反解是这样推的设相机高度 h俯仰角 θ向下为正接地点在图像上的行坐标为 vdy 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_dy2.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, n9, max_jump6.0): self.buf deque(maxlenn) 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.lastn9对应 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 度范围内变。从那以后我每装一台相机都强制先把支架拧死、跑一段烂路复测俯仰角再谈模型精度。硬件稳不住算法再精细都是白搭。希望帮到你。本文还有配套的精品资源点击获取