ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于YOLO26的单目测距与测速感知管线

基于YOLO26的单目测距与测速感知管线 在车辆视觉感知项目里我们经常会遇到一个尴尬情况只用目标检测模型能知道“画面里有什么”但不知道目标离我们多远、移动速度多快单纯做跟踪又缺少类别和置信度信息。想把检测、跟踪、测距、测速串成一条完整管线网上资料经常各讲各的很难一步跑通。这篇文章围绕一个基于 YOLO26 的单目测距与测速感知 Demo完整梳理一套“检测 → 多目标跟踪 → 单目深度测距 → 速度估算”的可复现实现方案。无论你是刚接触自动驾驶感知、想完成课程设计还是准备在车路协同、无人机巡检等场景里做原型验证都可以参考这套代码思路把流程跑起来。1. 这套 Demo 想解决什么问题1.1 单目感知中的“一条龙”需求很多视觉项目刚开始只做目标检测例如检测车辆、行人、骑行人员。但检测结果只是一组包围框它本身不回答下面几个问题视频里出现的这个目标到底是刚才那个目标还是新出现的目标目标距离相机多少米目标是否在移动移动速度大概是多少如果只用一个检测模型这三个问题都很难回答。检测模型通常是无状态的它对每一帧独立输出结果不会主动维护目标的时序关系。因此在实际项目中我们需要把多个模块组合起来目标检测模块定位目标输出类别和置信度。多目标跟踪模块给同一目标分配稳定 ID输出轨迹。距离估计模块把图像坐标换算成相机坐标系下的距离。速度估计模块利用目标在相邻帧之间的位置变化和时间差计算速度。这套 Demo 的核心价值就是把这些模块组装起来形成一个最小可用的开源感知原型。1.2 核心概念梳理先说目标检测。YOLO26 是目前 YOLO 系列中比较新的版本整体使用方式通常还是“输入图像 → 输出多个目标的类别 包围框 置信度”。不同渠道下载的权重文件、训练代码可能有一定差异但作为管线中的检测器它对外暴露的能力通常是统一的。再说多目标跟踪。多目标跟踪的输入是连续帧的检测结果输出是带有 ID 的轨迹。理想情况下同一辆车在第 1 帧是 ID 1在第 100 帧仍然是 ID 1即使它被短暂遮挡后重新出现也能尽量保持 ID 不跳变。常用的开源方案有 ByteTrack、DeepSORT、BoT-SORT 等。为了减少外部依赖本文 Demo 里先实现了一个基于 IoU 匹配的轻量跟踪器方便你理解跟踪原理也方便后续替换成更成熟的跟踪器。然后是单目测距。单目相机只有一个摄像头不能像双目相机那样通过视差直接恢复深度。单目测距通常依赖几何先验比如已知相机安装高度已知相机焦距假设目标底部与地面接触假设地面是平面。在这些假设下目标包围框的底边位置可以看作目标在地面上的投影点通过相似三角形换算就能估算出目标到相机的距离。最后是速度估算。速度的本质是位移除以时间。只要我们能稳定获得目标在两个时刻的距离变化量并且知道两帧之间的时间间隔就可以估算速度。这个 Demo 里使用视频帧率或者帧间时间戳来计算时间差。1.3 Pipeline 整体流程直观来看这条管线如下视频帧输入 ↓ YOLO26 目标检测 ↓ 多目标跟踪分配 ID ↓ 单目测距模块估算 x, z ↓ 速度估算模块结合帧间时间差 ↓ 可视化 结构化日志输出需要提前说明的是单目测距存在尺度不确定性问题。也就是说如果没有标定或者强先验单靠一个点的像素坐标无法恢复真实世界距离。本 Demo 使用的是“相机高度 焦距 地面假设”的估算方式优点是计算简单、实时性好缺点是对相机安装角度和地面平坦度比较敏感。理解这一点再看后面代码就会更清楚。2. 环境准备与工程结构2.1 运行环境说明本文的代码以 Python 为基础主要依赖 PyTorch 生态中的目标检测框架、OpenCV 和 NumPy。操作系统可以是 Windows 10/11、Ubuntu 20.04/22.04。如果你使用 GPU需要提前安装对应版本的 CUDA 和 cuDNN如果只是验证流程CPU 也可以运行但推理速度会慢很多。版本方面不建议完全照搬某一个固定版本因为 YOLO26 的出现时间较新不同预训练权重可能对应不同版本的推理代码。整体思路是Python 3.9PyTorch 2.0具体以目标检测框架要求为准OpenCV 4.5NumPy 1.23YOLO26 对应的推理库例如 Ultralytics 或官方源码仓库。如果你在环境配置时遇到版本冲突建议为项目单独创建一个虚拟环境不要直接污染系统 Python 环境。2.2 安装依赖假设你已经在虚拟环境中可以执行类似下面的命令pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install ultralytics opencv-python numpy如果 YOLO26 的权重并不是通过 Ultralytics 加载请根据你使用的推理仓库到对应目录执行pip install -r requirements.txt这里需要特别注意不要盲目安装最新版 PyTorch。部分 YOLO26 推理代码可能依赖特定版本的 PyTorch 算子建议先查看权重来源仓库的环境说明再安装依赖。2.3 项目目录设计为了让代码保持清晰建议用下面的结构组织项目yolo26-monocular-metric-demo/ ├── weights/ │ └── yolo26n.pt # YOLO26 权重文件按实际下载结果放置 ├── modules/ │ ├── __init__.py │ ├── detector.py # 目标检测封装 │ ├── mini_tracker.py # 轻量多目标跟踪 │ └── measure.py # 单目测距与速度估算 ├── pipeline.py # 主流程脚本 ├── demo.mp4 # 测试视频 └── requirements.txt这种结构的好处是每个模块单独一个文件后期替换跟踪器、更换测距模型时不需要改动主流程。2.4 模型权重说明在 Demo 中权重路径默认为weights/yolo26n.pt。如果你手里没有这个文件可以把权重替换成你实际下载的 YOLO26 权重路径。很多时候官方开源仓库会提供不同规格的预训练模型例如轻量版、标准版、大模型版本它们对推理速度和精度的影响差异很大。3. 关键原理怎么从画面走到“多少米”“多少速度”3.1 YOLO26 在管线中承担什么角色YOLO26 在整条管线里只负责“检测”这一个环节。它的输入是一帧图像输出是cls_id目标类别编号conf置信度bbox目标包围框。这里不对 YOLO26 的内部网络结构做过多展开因为本文重点是感知管线而不是某种具体的 Backbone 或 Neck 设计。你只需要知道检测器输出的包围框质量会直接影响下游跟踪和测距效果。如果目标经常漏检跟踪器就无法维持稳定 ID如果包围框抖动很大目标底边位置就不稳定测距结果也会忽远忽近。3.2 多目标跟踪为什么不能省如果没有跟踪模块每一帧的检测结果都是孤立的。假设画面中有三辆车第 1 帧检测到 3 辆车第 2 帧检测到 4 辆车第 3 帧检测到 3 辆车。如果不做跟踪你无法判断“第 2 帧多出来的那辆车是新进入画面的还是本来就在画面里只是第一帧漏检了”也无法统计每一辆车到底移动了多少距离。跟踪模块的核心任务就是把属于同一目标的多帧检测框关联起来。常见的关联思路有基于 IoU 关联相邻帧之间同一个目标的包围框重叠度通常较高基于外观特征关联使用 ReID 特征判断是否为同一目标基于运动模型关联用卡尔曼滤波预测目标下一帧位置再与检测结果匹配。本文 Demo 先实现一个轻量 IoU 跟踪器核心原则是简单、可解释。它的优点是代码量少、不依赖额外特征提取模型缺点是在目标密集遮挡场景里容易发生 ID Switch。3.3 单目测距的几何模型单目测距的原理要从针孔相机模型说起。假设相机固定安装光轴近似水平相机离地高度为h。图像中某个目标底边中心对应的像素纵坐标为v相机光心对应的像素纵坐标为cy相机焦距以像素为单位为f。那么目标与相机之间的水平距离近似为z h * f / (v - cy)其中h相机离地高度单位米f焦距单位像素(v - cy)目标底边中心在图像中相对光心或地平线的像素偏移。这个公式来自相似三角形。当目标越靠近相机时它在图像中的位置越靠下(v - cy)越大因此距离z越小当目标逐渐远离时目标底边接近地平线位置距离计算值变大。目标的横向位置也可以用类似方法换算x (u - cx) * z / f其中u是目标底边中心的像素横坐标cx是相机主点的像素横坐标。这里必须提醒一句上述公式成立的前提是相机安装时基本水平且目标底部确实落在路平面上。如果相机存在明显俯仰角或者车辆行驶在上坡、下坡路段误差会迅速增大。更严谨的做法是进行相机标定并利用相机外参建立地面平面方程。3.4 测速的计时与坐标换算有了相机坐标系下的位置(x, z)后速度计算就变得直接了。假设上一帧目标的位置为(x1, z1)时间为t1当前帧目标的位置为(x2, z2)时间为t2。那么目标在时间间隔内的位移为delta_s sqrt((x2 - x1)^2 (z2 - z1)^2)速度为v delta_s / (t2 - t1)如果希望把速度转换为日常更容易理解的单位可以乘以 3.6把m/s转换为km/h。在实际计算中建议不要直接使用帧号相减作为时间差更好的做法是用视频时间戳或系统时间。如果视频帧率存在轻微波动直接用frame_id / fps会造成额外误差。4. 代码实现检测 → 跟踪 → 测距 → 测速下面进入代码部分。为了让代码可以单独运行我会把每个功能模块拆开讲解。4.1 检测器封装新建文件modules/detector.py# 文件路径modules/detector.py from ultralytics import YOLO class Detector: def __init__(self, weights: str weights/yolo26n.pt, conf: float 0.35, device: str ): # 这里以 Ultralytics 接口为例。 # 如果 YOLO26 官方推理脚本不同请按实际加载方式替换。 self.model YOLO(weights) self.conf conf def detect(self, frame): results self.model(frame, confself.conf, verboseFalse)[0] boxes results.boxes if boxes is None or len(boxes) 0: return [] data boxes.data.cpu().numpy() dets [] for row in data: x1, y1, x2, y2, score, cls_id row[:6] dets.append({ bbox: [float(x1), float(y1), float(x2 - x1), float(y2 - y1)], score: float(score), cls_id: int(cls_id) }) return dets代码说明YOLO(weights)用于加载模型权重。results.boxes中保存了模型输出的检测框。我把x1y1x2y2格式转换成了x, y, w, h便于后续计算包围框中心点。cls_id可以用于筛类别比如只检测车辆时只保留cls_id属于车辆类别的目标。实际使用中YOLO26 权重文件也可能是.engine、.onnx等格式。如果使用 TensorRT 部署加载方式会有所不同但检测器对外暴露的detect(frame)接口可以保持一致这也是把检测模块独立封装的好处。4.2 轻量多目标跟踪器新建文件modules/mini_tracker.py# 文件路径modules/mini_tracker.py def iou_xywh(a, b): 计算两个 xywh 包围框的 IoU。 a, b: [x, y, w, h] ax1, ay1 a[0], a[1] ax2, ay2 a[0] a[2], a[1] a[3] bx1, by1 b[0], b[1] bx2, by2 b[0] b[2], b[1] b[3] xx1 max(ax1, bx1) yy1 max(ay1, by1) xx2 min(ax2, bx2) yy2 min(ay2, by2) inter_w max(0, xx2 - xx1) inter_h max(0, yy2 - yy1) inter_area inter_w * inter_h area_a a[2] * a[3] area_b b[2] * b[3] union_area area_a area_b - inter_area if union_area 0: return 0.0 return inter_area / union_area class MiniTracker: def __init__(self, max_age: int 15, min_iou: float 0.25): self.tracks {} self.next_id 0 self.max_age max_age self.min_iou min_iou def update(self, dets, frame_id: int): 根据当前帧检测结果更新跟踪状态返回活跃轨迹列表。 dets: [ {bbox: [x, y, w, h], score: 0.8, cls_id: 2}, ... ] active_tracks [] unmatched_track_ids set(self.tracks.keys()) for det in dets: best_tid None best_iou self.min_iou # 在当前剩余轨迹中寻找 IoU 最大的匹配 for tid in unmatched_track_ids: trk self.tracks[tid] # 不同类别尽量不关联 if trk[cls_id] ! det[cls_id]: continue iou_value iou_xywh(trk[bbox], det[bbox]) if iou_value best_iou: best_iou iou_value best_tid tid if best_tid is None: # 如果没有匹配到已有轨迹则创建新轨迹 tid self.next_id self.next_id 1 self.tracks[tid] { id: tid, bbox: det[bbox], score: det[score], cls_id: det[cls_id], last_seen: frame_id, hits: 1, } else: # 匹配成功更新轨迹状态 trk self.tracks[best_tid] trk[bbox] det[bbox] trk[score] det[score] trk[cls_id] det[cls_id] trk[last_seen] frame_id trk[hits] 1 unmatched_track_ids.discard(best_tid) active_tracks.append(self.tracks[tid]) # 清理超过一定帧数未更新的轨迹 expired_ids [] for tid, trk in self.tracks.items(): if frame_id - trk[last_seen] self.max_age: expired_ids.append(tid) for tid in expired_ids: del self.tracks[tid] return active_tracks这个轻量跟踪器的思路很直接把当前帧检测结果和上一帧轨迹计算 IoU。如果某个检测框和已有轨迹的 IoU 大于阈值就认为是同一目标。如果没有匹配到任何轨迹则分配一个新的 ID。如果某个轨迹连续多帧没有匹配到检测框就认为目标已经消失。它适合作为理解跟踪原理的教学代码也适合目标稀疏、遮挡不严重的场景。如果是要处理密集车流或严重遮挡建议替换为 ByteTrack 或 BoT-SORT。4.3 单目测距模块新建文件modules/measure.py# 文件路径modules/measure.py import math class SingleCameraMeasure: def __init__(self, focal_px: float 0.0, cx: float 0.0, cy: float 0.0, camera_height_m: float 1.2): self.focal_px focal_px self.cx cx self.cy cy self.cam_h camera_height_m def measure(self, bbox, frame_hNone): 将检测框转换为相机坐标系下的位置。 bbox: [x, y, w, h] 返回: (x_m, z_m, distance_m) 如果缺少焦距或目标底边不在图像下侧返回 None。 if self.focal_px 0: return None x, y, w, h bbox # 目标底边中心的像素坐标 u x w / 2.0 v y h # 在光轴水平假设下目标底边越靠近地平线距离越远 delta_v v - self.cy if delta_v 0: return None # 纵向距离 z_m self.cam_h * self.focal_px / delta_v # 横向位置 x_m (u - self.cx) * z_m / self.focal_px # 斜向距离 distance_m math.hypot(x_m, z_m) return x_m, z_m, distance_m代码说明focal_px是焦距的像素单位通常需要通过相机标定获得。如果知道相机水平视场角fov和图像宽度img_w也可以近似计算focal_px (img_w / 2) / tan(fov / 2)cy在无俯仰角时可以用图像中心行代替如果相机有固定俯仰角建议用标定得到的地平线像素行代替误差会小很多。cam_h是相机离地高度。这个值需要现场测量单位是米。这段代码没有引入任何需要训练的参数它的计算量几乎可以忽略所以很适合嵌入式平台或边缘设备。4.4 主流程 pipeline新建文件pipeline.py# 文件路径pipeline.py import argparse import json import cv2 from modules.detector import Detector from modules.mini_tracker import MiniTracker from modules.measure import SingleCameraMeasure def parse_args(): parser argparse.ArgumentParser(descriptionYOLO26 单目测距与测速 Demo) parser.add_argument(--source, typestr, defaultdemo.mp4, help视频文件路径或摄像头索引) parser.add_argument(--weights, typestr, defaultweights/yolo26n.pt, helpYOLO26 权重路径) parser.add_argument(--conf, typefloat, default0.35, help检测置信度阈值) parser.add_argument(--focal, typefloat, default0.0, help相机焦距像素通过标定获得) parser.add_argument(--cx, typefloat, default0.0, help相机主点 x默认取图像中心) parser.add_argument(--cy, typefloat, default0.0, help相机主点 y默认取图像中心) parser.add_argument(--cam-height, typefloat, default1.2, help相机离地高度米) parser.add_argument(--classes, typeint, nargs*, defaultNone, help只处理指定类别例如 --classes 2 7) parser.add_argument(--save, typestr, default, help输出视频保存路径) parser.add_argument(--debug, actionstore_true, help打印结构化日志) return parser.parse_args() def format_speed(speed_mps): if speed_mps is None: return ? return f{abs(speed_mps * 3.6):.1f} km/h def main(): args parse_args() # 打开视频或摄像头 if args.source.isdigit(): cap cv2.VideoCapture(int(args.source)) else: cap cv2.VideoCapture(args.source) if not cap.isOpened(): raise RuntimeError(f无法打开视频源: {args.source}) fps cap.get(cv2.CAP_PROP_FPS) if fps is None or fps 0: fps 30.0 frame_w int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) frame_h int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) print(f[信息] 视频尺寸: {frame_w}x{frame_h}, fps{fps}) # 没有焦距时距离和速度无法输出有效值 if args.focal 0: print([警告] 未提供相机焦距 focal距离/速度估计将不生效。) print([提示] 建议用棋盘格标定相机后再传入 --focal 参数。) detector Detector(weightsargs.weights, confargs.conf) tracker MiniTracker() cx args.cx if args.cx 0 else frame_w / 2.0 cy args.cy if args.cy 0 else frame_h / 2.0 # 单目测距模块 measure SingleCameraMeasure( focal_pxargs.focal, cxcx, cycy, camera_height_margs.cam_height, ) writer None if args.save: fourcc cv2.VideoWriter_fourcc(*mp4v) writer cv2.VideoWriter(args.save, fourcc, fps, (frame_w, frame_h)) prev_state {} frame_id 0 while True: ok, frame cap.read() if not ok: break time_s frame_id / fps frame_id 1 # 1. 检测 dets detector.detect(frame) # 类别过滤 if args.classes is not None: dets [d for d in dets if d[cls_id] in args.classes] # 2. 跟踪 active_tracks tracker.update(dets, frame_id) # 3. 测距 测速 for trk in active_tracks: x, y, w, h trk[bbox] tid trk[id] m measure.measure([x, y, w, h]) speed_mps None label fID:{tid} if m is not None: x_m, z_m, dist_m m label f dist:{dist_m:.1f}m # 用帧号折算时间戳 if tid in prev_state: prev prev_state[tid] dt time_s - prev[time_s] dz z_m - prev[z_m] if dt 0.05 and abs(dz) 100: speed_mps dz / dt label f v:{format_speed(speed_mps)} prev_state[tid] { x_m: x_m, z_m: z_m, time_s: time_s, } if args.debug: log_item { frame: frame_id, id: tid, cls: trk[cls_id], bbox: [round(_, 1) for _ in [x, y, w, h]], x_m: round(x_m, 2), z_m: round(z_m, 2), dist_m: round(dist_m, 2), speed_kmh: round(abs(speed_mps * 3.6), 2) if speed_mps is not None else None, } print(json.dumps(log_item, ensure_asciiFalse)) else: label dist:? # 绘制检测框 cv2.rectangle(frame, (int(x), int(y)), (int(x w), int(y h)), (0, 255, 0), 2) cv2.putText(frame, label, (int(x), max(20, int(y) - 6)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) # 展示画面 cv2.imshow(YOLO26 Monocular Demo, frame) if writer: writer.write(frame) key cv2.waitKey(1) 0xFF if key ord(q): break cap.release()
返回列表