简介:这份资源面向计算机视觉与智能交通方向的个人学习者,提供一套基于YOLOv8与ByteTrack融合的车辆实时检测、追踪与流量统计完整实现。系统通过YOLOv8完成视频帧中车辆定位与车型分类,再借助ByteTrack进行跨帧身份关联,最终基于轨迹分析实现多车道车辆计数、速度记录与轨迹绘制,并具备违章变道、违规停车等异常行为识别能力,适合作为课程设计、毕业设计或算法练手项目。资源包共12个文件,约74MB,以Python源码与依赖配置为主,另含演示视频、效果截图、说明文档及若干备份文件,便于直接运行与二次修改。目前已有88人学习下载。读者可从中获取从检测、追踪到统计的完整工程代码与运行素材,理解多目标追踪中的数据关联与状态估计思路,并参考可视化结果快速验证算法效果。
1. 从一段路口视频说起:YOLOv8+ByteTrack 到底在解决什么
手里有一段路口监控视频,想统计一小时内经过了多少辆车、每辆车往哪个方向走、哪个车道压力最大。人工数不现实,纯检测模型逐帧框车也不行——同一辆车在连续 300 帧里会被框 300 次,计数直接爆炸。这就是多目标车辆实时检测与流量统计要解决的核心问题:检测负责"看见",跟踪负责"认人",统计负责"记账"。
YOLOv8 负责每帧把车、卡车、公交车、摩托车框出来,ByteTrack 负责把这些框在时间轴上串成一条条轨迹,每条轨迹给一个稳定 ID,最后在虚拟线圈或越线判定上做加减计数。整套方案适合做交通流量统计、停车场进出管理、卡口过车记录这类场景,硬件从 GTX1660Ti 到 RK3588、Orin 都能跑,关键看你把检测和跟踪的算力预算怎么分配。下面按"先跑通、再调参、最后避坑"的顺序讲清楚。
2. 检测与跟踪的分工:为什么是 YOLOv8 配 ByteTrack
2.1 YOLOv8 在流量统计里的角色边界
很多人一上来就想让 YOLOv8 直接输出"第几辆车",这是方向性错误。YOLOv8 是单帧检测器,它的输出是当前帧的边界框、类别和置信度,帧与帧之间没有任何记忆。你给它两张相邻帧,它不知道框里是不是同一辆车。所以检测模型在系统里的职责被严格限定为:在每一帧里尽可能准、尽可能快地给出车辆的位置和类别。
选 YOLOv8 而不是更早的版本,实际工程里主要看三点。第一是 anchor-free 的检测头,省掉了聚簇调 anchor 的环节,换数据集时不用重新跑 k-means,对交通场景这种目标尺度跨度大的任务更省心。第二是模型尺寸梯度完整,n/s/m/l/x 五档,GTX1660Ti 这种 6G 显存卡跑 s 档能到实时,RK3588 板端跑 n 档量化后也能撑住 15fps 以上。第三是训练和导出链路成熟,yolov8n.pt这类预训练权重直接能拿来微调,model.export(format='onnx')一行出 ONNX,部署侧不用自己写转换脚本。
需要提醒的是,YOLOv8 官方预训练权重是在 COCO 上训的,车辆类别只有 car、bus、truck、motorcycle 这几个粗类。如果你要区分轿车/SUV/面包车,或者要识别车牌,必须自己标数据微调,别指望预训练权重直接给你细分类。
2.2 ByteTrack 的匹配逻辑:低分框为什么不能直接扔
ByteTrack 的核心贡献就一句话:把检测框按置信度分成高低两档,高分框先匹配,低分框再拿去补匹配那些没跟上的轨迹。传统 SORT 类方法会把置信度低于阈值的框直接丢掉,但车辆在遮挡、远距离、运动模糊时,检测分数经常掉到 0.3 以下,这些框虽然分数低,位置却是对的,扔掉就等于让轨迹断掉,ID 一断计数就错。
ByteTrack 的匹配分两步走。第一步用高分框(比如 conf>0.5)和现有轨迹做 IoU 匹配,用的是匈牙利算法,代价矩阵是 IoU 距离。匹配上的轨迹更新状态,没匹配上的轨迹进入第二步。第二步把低分框(0.1<conf<0.5)拿出来,和第一步剩下的未匹配轨迹再匹配一次,这次只算 IoU,不看分数。这样遮挡期间轨迹能靠低分框续上,ID 不会跳。
# ByteTrack 匹配逻辑的简化示意,帮助理解高低分两阶段 def bytetrack_associate(detections, tracks, high_th=0.5, low_th=0.1): # 按置信度切分检测框 high_dets = [d for d in detections if d.score >= high_th] low_dets = [d for d in detections if low_th <= d.score < high_th] # 第一阶段:高分框与轨迹做 IoU 匹配 matched, unmatched_tracks, unmatched_high = iou_match(high_dets, tracks) for t, d in matched: t.update(d) # 匹配上的轨迹用检测框更新位置 # 第二阶段:低分框与剩余轨迹再匹配,只算 IoU 不看分数 matched2, unmatched_tracks2, _ = iou_match(low_dets, unmatched_tracks) for t, d in matched2: t.update(d) # 遮挡恢复时靠这一步续上 ID # 仍未匹配的轨迹进入丢失缓存,超过 max_age 帧才删除 for t in unmatched_tracks2: t.mark_lost() return tracks这段逻辑里三个参数最关键。high_th决定哪些框算"可信",交通场景一般设 0.5,夜间或雨雾可以降到 0.4。low_th是低分框下限,设 0.1 是经验值,再低会把背景噪声引进来。max_age是轨迹丢失后保留多少帧,路口场景车被大车挡住通常 2~3 秒,按 25fps 算就是 50~75 帧,设太小 ID 会断,设太大容易把已经离开的车和新车搞混。
2.3 检测+跟踪串起来的最小可跑代码
把 YOLOv8 和 ByteTrack 接起来,最省事的方式是用 Ultralytics 自带的model.track(),它内部已经集成了 ByteTrack 和 BoT-SORT。下面这段是本地跑通的最小骨架。
from ultralytics import YOLO import cv2 # 加载 YOLOv8 检测权重,n 档适合先验证流程 model = YOLO("yolov8n.pt") # 打开视频源,0 是摄像头,也可以传视频文件路径 cap = cv2.VideoCapture("road.mp4") # 只保留车辆相关类别,COCO 里 2=car 3=motorcycle 5=bus 7=truck VEHICLE_CLASSES = [2, 3, 5, 7] while cap.isOpened(): ret, frame = cap.read() if not ret: break # persist=True 是关键,告诉跟踪器这是连续帧,不要每帧重置 results = model.track( frame, persist=True, tracker="bytetrack.yaml", # 指定 ByteTrack 配置 classes=VEHICLE_CLASSES, # 只检测车辆,减少误检 conf=0.3, # 检测置信度阈值 iou=0.5, # NMS 的 IoU 阈值 verbose=False ) # 取出带 ID 的跟踪结果 if results[0].boxes.id is not None: boxes = results[0].boxes.xyxy.cpu().numpy() ids = results[0].boxes.id.cpu().numpy().astype(int) for box, tid in zip(boxes, ids): x1, y1, x2, y2 = map(int, box) cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(frame, f"ID {tid}", (x1, y1 - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) cv2.imshow("track", frame) if cv2.waitKey(1) & 0xFF == ord("q"): break cap.release() cv2.destroyAllWindows()persist=True是最容易漏的参数,不加的话每帧都会当成新序列,ID 每帧都在变,跟踪等于没做。classes过滤掉行人、红绿灯等无关类别,既提速又减少误检。conf=0.3比默认 0.25 略高,是因为跟踪阶段低分框由 ByteTrack 自己处理,检测阶段不用放太松。tracker="bytetrack.yaml"指向 Ultralytics 内置配置,里面就是track_high_thresh、track_low_thresh、track_buffer这几个参数,需要调的时候直接改这个 yaml 或复制一份改。
3. 流量统计怎么落地:虚拟线圈、越线判定与计数去重
3.1 虚拟线圈和越线判定的实现
检测和跟踪跑通后,统计就是在这条轨迹上做几何判断。最常见两种方案:虚拟线圈和越线判定。虚拟线圈是在画面上画一个矩形区域,轨迹中心点从区域外进入区域内算一次进入,从内到外算一次离开,进出配对得到车流量。越线判定是画一条线,轨迹中心点从线的一侧穿到另一侧算一次通过,配合方向向量还能区分上下行。
# 越线计数:判断轨迹中心点是否穿过一条水平线 class LineCounter: def __init__(self, line_y, direction="down"): self.line_y = line_y # 线的 y 坐标 self.direction = direction # 允许通过的方向 self.prev_side = {} # 记录每个 ID 上一帧在线的哪一侧 self.counted = set() # 已计数的 ID,防止来回抖动重复计 def update(self, track_id, cy): side = "below" if cy > self.line_y else "above" prev = self.prev_side.get(track_id) # 首次出现只记录位置,不计数 if prev is None: self.prev_side[track_id] = side return False # 发生跨线且方向符合,且该 ID 没计过 crossed = (prev != side) if crossed and track_id not in self.counted: if self.direction == "down" and prev == "above" and side == "below": self.counted.add(track_id) self.prev_side[track_id] = side return True if self.direction == "up" and prev == "below" and side == "above": self.counted.add(track_id) self.prev_side[track_id] = side return True self.prev_side[track_id] = side return Falsecounted这个集合是防重复计数的后悔药。车辆在线上来回抖动、或者跟踪 ID 短暂跳变又跳回来,都会导致同一辆车被计两次。用 ID 做去重键,配合方向判断,能挡掉大部分误计。prev_side字典要定期清理,轨迹消失后对应条目留着会占内存,一般配合跟踪器的max_age一起清。
3.2 计数去重与 ID 跳变的处理
ID 跳变是流量统计里最头疼的问题。车被遮挡几帧,ByteTrack 续不上,重新出现时给了新 ID,同一辆车就被计了两次。工程上有几种缓解手段。一是调大track_buffer,让轨迹丢失后保留更久,给低分框更多机会续上。二是用外观特征做二次匹配,ByteTrack 本身不带 ReID,可以外挂一个轻量 ReID 模型,在 ID 跳变时用特征相似度把新旧轨迹关联起来。三是统计层面做去重,同一位置短时间内出现的新 ID,如果运动方向一致、时间间隔小于阈值,判定为同一辆车。
# 基于时空约束的 ID 去重:短时间内同方向的新 ID 视为同一辆车 class IDDeduplicator: def __init__(self, time_window=1.5, dist_thresh=80): self.time_window = time_window # 秒 self.dist_thresh = dist_thresh # 像素 self.recent = [] # (timestamp, cx, cy, direction) def is_duplicate(self, ts, cx, cy, direction): self.recent = [r for r in self.recent if ts - r[0] < self.time_window] for r_ts, r_cx, r_cy, r_dir in self.recent: dist = ((cx - r_cx) ** 2 + (cy - r_cy) ** 2) ** 0.5 if dist < self.dist_thresh and r_dir == direction: return True self.recent.append((ts, cx, cy, direction)) return Falsetime_window设 1.5 秒是经验值,路口车速下 1.5 秒车移动距离有限,超过这个窗口的新 ID 更可能是真的新车。dist_thresh按画面分辨率调,1080p 下 80 像素大概对应半个车身宽度。这套去重是统计层的补丁,不能替代跟踪层的稳定,根子上还是要把 ByteTrack 参数调好。
3.3 分车道、分方向的流量统计表
单一线圈只能给总数,实际项目往往要分车道、分方向。做法是给每个车道画独立的线圈或线,每条轨迹根据中心点落在哪个车道区域归属到对应车道,再叠加方向判断,最后按分钟或小时聚合。
| 统计维度 | 实现方式 | 关键参数 | 输出示例 |
|---|---|---|---|
| 总流量 | 单线圈进出配对 | 线圈坐标 | 1200 辆/小时 |
| 分车道 | 每车道独立线圈 | 车道多边形 | 左道 400,中道 500,右道 300 |
| 分方向 | 越线方向向量 | 方向阈值 | 上行 600,下行 600 |
| 分车型 | 检测类别映射 | 类别 ID | 轿车 900,卡车 200,公交 100 |
| 分时段 | 时间窗口聚合 | 窗口长度 | 每分钟 20 辆 |
聚合时注意时间窗口的边界,跨窗口的轨迹要归到越线那一刻所在的窗口,不能按轨迹开始时间算,否则高峰期数据会错位。
4. 避坑与排查:那些让计数对不上的细节
4.1 现象:同一辆车被计了多次
原因通常是 ID 跳变或轨迹抖动。车被遮挡后 ByteTrack 给了新 ID,或者车在线上来回移动导致多次跨线判定。解决分两层:跟踪层调大track_buffer到 50~75,降低track_high_thresh到 0.4 让更多框参与匹配;统计层用 ID 去重集合加时空去重,双保险。如果还不行,检查视频帧率是否稳定,掉帧会让跟踪器的时间假设失效。
4.2 现象:夜间或逆光下漏检严重
YOLOv8 在低照度下召回率下降是常态,检测漏了跟踪自然断。解决不是硬调检测阈值,而是先做图像预处理:CLAHE 增强对比度,或者用 Gamma 校正提亮暗部。如果场景固定,最彻底的办法是采集夜间数据微调模型,加几百张夜间标注图,mAP 能明显回升。临时方案是把conf降到 0.2,靠 ByteTrack 的低分框机制兜底,但误检会增多,要配合类别过滤。
4.3 现象:计数比实际少,轨迹频繁断
除了遮挡,常见原因是检测框抖动导致 IoU 匹配失败。车静止或慢速时,相邻帧检测框位置几乎不变,IoU 很高没问题;但车快速运动时,相邻帧位移大,IoU 可能低于匹配阈值。解决是调低 ByteTrack 的 IoU 匹配阈值,或者改用中心点距离做代价矩阵。另外检查max_age是不是设太小,车被挡 2 秒就删轨迹,出来就是新 ID。
4.4 现象:GPU 利用率低但帧率上不去
瓶颈往往不在 GPU 而在前后处理。视频解码用 CPU 软解会拖慢整体,换成cv2.CAP_FFMPEG或硬件解码能提速。另外model.track()每帧都做 NMS 和跟踪匹配,如果画面里车不多,可以把检测间隔拉大,比如每 2 帧检测一次,中间帧靠跟踪器预测位置,帧率能翻倍,代价是快速运动时框会滞后。
4.5 现象:RK3588 板端跑不动
板端部署和 PC 是两套逻辑。YOLOv8 要导出 ONNX 再转 RKNN,量化到 int8,n 档模型在 RK3588 上能到 15~20fps。ByteTrack 是纯 CPU 逻辑,反而成了瓶颈,要控制轨迹数量,及时清理丢失轨迹。常见翻车点是量化后精度掉太多,解决是用混合量化,检测头部分保留 fp16。板端内存有限,视频缓冲别开太大,否则容易 OOM。
5. 把统计做准的进阶技巧:从能跑到可信
系统能跑起来只是第一步,统计结果能不能信,取决于你怎么处理边界情况。我一般会加一个轨迹质量分,综合轨迹长度、平均检测置信度、ID 切换次数,给每条轨迹打分,低于阈值的轨迹不参与计数。这样能挡掉那些一闪而过的误检和碎片轨迹。
# 轨迹质量分:长度、置信度、ID 稳定性加权 def track_quality(track): length_score = min(track.hits / 30.0, 1.0) # 至少 30 帧才算完整 conf_score = track.avg_score # 平均检测置信度 id_score = 1.0 / (1 + track.id_switches) # ID 切换越多分越低 return 0.4 * length_score + 0.4 * conf_score + 0.2 * id_scorehits是轨迹被成功匹配的帧数,30 帧在 25fps 下约 1.2 秒,低于这个的轨迹大概率是噪声。avg_score是轨迹生命周期内检测框的平均置信度,低于 0.4 的轨迹要警惕。id_switches记录这条轨迹关联过几个不同 ID,切换多说明跟踪不稳。三个权重按场景调,路口场景长度和置信度更重要,权重可以给到 0.4/0.4。
验证统计准不准,最土也最有效的办法是人工抽帧核对。抽 10 段各 1 分钟的视频,人工数一遍,和系统输出对比,误差超过 5% 就回去查参数。别只看总数,要分车道、分方向对,总数对得上但分车道错位的情况很常见,那是线圈画歪了。
还有一个容易被忽略的点:时间同步。如果视频有丢帧,或者处理速度跟不上导致跳帧,基于帧序号的逻辑都会错。我习惯在处理循环里记录真实时间戳,统计窗口按真实时间切,不按帧号切。这样即使帧率波动,每分钟的流量统计依然准。
最后说个习惯:每次调完参数,别只看一段视频就下结论。交通流量有早晚高峰、有随机波动,至少跑满一个完整周期(比如早 7 点到晚 7 点)再看统计曲线是否合理。我踩过最深的坑就是拿一段平峰视频调好参数,上线遇到高峰直接崩,ID 跳变率翻了三倍。参数要按最坏场景调,不是按最好场景调。希望帮到你。
本文还有配套的精品资源,点击获取