☰
基于YOLOv8与ByteTrack的多端车流检测与越线计数系统
2026/11/2 15:35:38 网站建设 项目流程

简介:面向毕业设计与目标检测学习者的YOLOv8多端车流检测系统源码包,围绕车辆实时检测、车流统计与多端部署展开,含训练配置、预训练权重、数据集组织、GUI交互与结果可视化模块,可用于交通监控和智能城市场景验证。压缩包共396个文件、约16.94MB,以Python源码与编译文件为主,辅以图像、YAML配置、说明文档、模型权重和演示视频,便于按训练、推理、界面及部署环节检索。已有1366人学习下载。读者可梳理从数据预处理、训练评估到实时检测的完整流程,参考GUI上传图片或视频并展示车辆框与统计信息的实现,也能借鉴多节点结果整合、低延迟部署与可视化思路,适合作为毕设方案或开源改进起点。

1. 需求:能框出车不算方案,能说清过线了几辆才算

答辩季最常见的场景,是同学拿着一段路口视频说“模型能检测到车了”,导师反问一句“那这一小时过了多少辆、哪条道堵、平均车速多少”,场面立刻安静。检测框只回答单帧里有没有目标,车流量回答的是时间段内的过线数、车型构成、流向和速度。这套基于 YOLOv8 的多端车流检测系统,把链路拆成视频接入、检测、跟踪、计数、聚合、多端展示六段,服务端负责推理与统计,Web 看板看实时与历史,边缘端本地叠加输出。它适合要交毕设或课设的人当完整骨架来改,也适合需要一套能演示、能出图表的车流检测 demo 的开发者。

2. 总体架构设计:三层结构、六个模块与多端接口契约

先把边界划清楚,后面写代码才不会互相打架。我一般把系统分成采集层、推理统计层、服务展示层三层,再往下拆六个模块:视频接入、检测引擎、跟踪计数、聚合统计、接口服务、前端看板。三层不是画着好看的,它决定了你换模型、加一路摄像头、接一个移动端时,需要动多少文件。

2.1 三层划分与六个模块的职责边界

采集层只干一件事:把 RTSP 流、本地视频文件、批量图片统一成“按帧率吐帧”的迭代器,屏蔽掉源之间的差异。推理统计层是核心,检测引擎输出(bbox, cls, conf),跟踪模块把框串成带track_id的轨迹,计数模块消费轨迹序列做越线判断。服务展示层拿计数结果做聚合、出接口、推流。

这样切的关键收益是解耦:检测引擎不认识“计数线”,计数模块也不认识 YOLO。想换模型、想从 PyTorch 切到 ONNX Runtime、想在边缘端换 NCNN,只改detector.py一个文件。

# 工程目录(节选),解压后按这个结构找文件 traffic-flow/ ├── configs/ │ ├── point.yaml # 点位配置:视频源、ROI 多边形、计数线端点与方向 │ └── model.yaml # 模型路径、conf/iou 阈值、跟踪器参数 ├── core/ │ ├── capture.py # 视频接入:RTSP / 本地文件 / 图片批量,统一吐帧 │ ├── detector.py # YOLOv8 推理封装,对外只暴露 detect(frame) │ ├── tracker.py # ByteTrack 封装,输出 track_id + bbox + cls │ └── counter.py # 计数状态机:越线、区域进出、方向判定 ├── service/ │ ├── api.py # FastAPI 路由:流量查询、点位管理 │ ├── aggregate.py # 分钟级聚合任务,可重跑补数 │ └── ws.py # WebSocket,实时推标注帧或纯指标 ├── web/ # 看板前端:折线图、热力时段、实时画面 └── edge/ # 边缘端启动脚本与导出模型

模块之间的契约只有两条:detector.detect(frame) -> list[Box],counter.update(track_id, center, frame_idx) -> int。只要这两条不变,前端、数据库、部署方式随便换。答辩被问“这套东西能不能换成别的检测模型”,答案就在这两行签名里。

2.2 数据模型:点位表、过车明细与分钟聚合三张表

统计类系统最容易在设计阶段偷懒的地方,是把“过车记录”和“展示数据”混成一张表。看板每次刷新都去扫几十万行明细,跑两天就卡。常规做法是明细归明细、聚合归聚合。

-- 点位表:一个摄像头或一段固定视频就是一个点位 CREATE TABLE point ( id SERIAL PRIMARY KEY, name VARCHAR(64) NOT NULL, source_url VARCHAR(255) NOT NULL, -- rtsp 地址或本地文件路径 roi_polygon JSONB, -- 检测区域,[[x,y],...] 原图像素坐标 count_lines JSONB, -- 计数线:端点、方向、所属车道 fps SMALLINT DEFAULT 25, status SMALLINT DEFAULT 1 -- 1 启用 0 停用 ); -- 过车明细:一条轨迹过一次线写一行,是回溯与复核的黑匣子 CREATE TABLE pass_record ( id BIGSERIAL PRIMARY KEY, point_id INT NOT NULL REFERENCES point(id), track_id INT NOT NULL, lane_no SMALLINT, -- 车道号,-1 表示未分车道 vtype SMALLINT, -- 1 car 2 bus 3 truck 4 motorcycle direction SMALLINT, -- 1 正向 2 反向 speed_kmh NUMERIC(5,1), -- 无标定时为 NULL pass_at TIMESTAMPTZ NOT NULL ); CREATE INDEX idx_pass_point_time ON pass_record (point_id, pass_at); -- 分钟聚合:看板只查这张表;bucket_at 存 UTC,渲染时按客户端时区转换 CREATE TABLE flow_minute ( point_id INT NOT NULL, bucket_at TIMESTAMPTZ NOT NULL, lane_no SMALLINT NOT NULL DEFAULT -1, -- -1 = 全车道汇总行 vtype SMALLINT NOT NULL DEFAULT -1, cnt INT NOT NULL DEFAULT 0, avg_speed NUMERIC(5,1), PRIMARY KEY (point_id, bucket_at, lane_no, vtype) );

三张表各管一段:point是配置,pass_record是事实,flow_minute是视图的物化。聚合表的主键设计成四元组,写入时用ON CONFLICT DO UPDATE做幂等,聚合任务重跑不会把数字翻倍——这是补数时最省心的一个设计。bucket_at统一存 UTC,前端按浏览器时区渲染。别小看这一条,时区混用会让折线图在某一天平白多出一小时的数据,排查起来能磨掉一整天。

2.3 多端接口契约:一次流量查询怎么走完

多端不是把同一个页面塞进手机浏览器。Web 端要标注帧和图表,移动端只要指标,边缘端连网络都可能没有。所以接口按“数据密度”分三档:查询接口给 JSON,实时接口给 WebSocket,边缘端本地叠加不开服务。

# service/api.py(节选) from fastapi import APIRouter, Query from datetime import datetime router = APIRouter(prefix="/api/v1") @router.get("/flow") def get_flow( point_id: int = Query(..., ge=1), start: datetime = Query(...), # 必须带时区的 ISO8601,否则按 UTC 解释 end: datetime = Query(...), granularity: str = Query("1m", pattern="^(1m|5m|1h|1d)$"), lane_no: int = Query(-1), # -1 表示全部车道,直接取汇总行 ): # 粒度在 SQL 层用 date_trunc 完成,不把明细捞回内存再算 rows = aggregate.query(point_id, start, end, granularity, lane_no) return {"code": 0, "msg": "ok", "data": rows}

granularity直接映射到date_trunc的单位,1m/5m给实时看板,1h/1d给日报导出;lane_no=-1走汇总行,避免前端拿五条车道再自己求和,前端一旦参与聚合,口径就开始各说各话。start/end强制带时区,把“时间基准”这件事在接口层钉死,比在每个客户端各补一次转换靠谱。

WebSocket 走/ws/live/{point_id},只推两种消息:meta(点位信息、分辨率、计数线端点,连接时推一次)和frame(标注后的 JPEG 或tracks数组)。Web 端订阅frame拿画面叠加,移动端只订阅stats每两秒一条 JSON,省流量也省电。边缘端用--no-server参数启动,把计数结果写本地 SQLite,等有网了再同步。

提示:接口返回里code字段保留,别用 HTTP 状态码强行承载业务错误。车流查询里“点位不存在”和“查询区间无数据”是两回事,前者 404,后者要返回空数组加code: 0,否则前端图表会误报成故障。

3. 检测模型落地:数据集口径、YOLOv8 训练参数与 ONNX 导出

模型这一章最容易被跳过,很多人直接下载 COCO 预训练权重跑起来,发现“truck 和 bus 分不清”“摩托车算成自行车”,然后归因于模型不行。真实原因多半在数据集口径和导出后预处理这两处。

3.1 数据集怎么组织与标注口径

类别先定死,四个就够:car / bus / truck / motorcycle。有面包车、三轮车的点位,要么单开一类,要么明确并进car,绝不能让标注的人自由发挥——同一个车型在不同人手里标成两类,模型的混淆矩阵会很难看,而且是那种查不出原因的难看。

标注口径写成一页纸,贴在标注工具旁边。遮挡超过七成不标;夜间只要轮廓可辨就标;被画面边缘截断但主体可见的要标;跨线车辆按可见部分画框。这套规则不是形式主义,它直接决定了 Recall 上限。

规模上,单点位 3000 到 8000 帧足够起步。抽帧间隔按 5 到 10 帧取,25fps 下大约 0.2 到 0.4 秒一帧,相邻帧几乎一样,密集标注纯属浪费预算。

划分方式是最容易翻车的一环:按视频片段或时段划分训练/验证/测试,比例 8:1:1 或 7:2:1,绝不随机按帧打乱。同一段视频的相邻帧同时进训练集和验证集,等于把答案漏给了验证集,mAP 能虚高十几个点,上线立刻现原形。

# configs/data.yaml path: /data/traffic train: images/train val: images/val test: images/test names: 0: car 1: bus 2: truck 3: motorcycle

3.2 训练参数与指标判读顺序

from ultralytics import YOLO model = YOLO("yolov8s.pt") # 单卡 8G 显存够用;n 更轻但小目标和远处车辆召回偏差 model.train( data="configs/data.yaml", epochs=100, imgsz=640, # 与部署端严格一致,训练/推理尺寸不同必掉点 batch=16, optimizer="SGD", # 中小数据集上泛化通常比 AdamW 稳 lr0=0.01, lrf=0.01, cos_lr=True, warmup_epochs=3, mosaic=1.0, close_mosaic=10, # 最后 10 轮关掉 mosaic,收敛更干净 hsv_v=0.5, # 夜间、逆光样本少,用亮度扰动补一点泛化 degrees=0.0, # 固定枪机,不做大角度旋转 fliplr=0.5, project="runs/traffic", name="v8s_640", )

参数逐个说清楚:imgsz=640不是随便挑的,是部署端要用的尺寸,训练和推理尺寸不一致时,小目标召回会掉得很明显;degrees=0因为路口相机是固定的,加旋转只会让模型学到不存在的视角;close_mosaic=10让最后十轮在真实分布上收尾,框的定位精度会好看一些;optimizer="SGD"在几千张量级的数据集上比 AdamW 更不容易过拟合。

指标判读顺序别搞反:先看 Recall,再看 mAP50,最后翻混淆矩阵。车流统计里漏检一辆车,等于这个时段少数一辆,误差会直接进报表;多检一辆,还能靠跟踪模块的连续帧确认机制压掉一部分。所以 Recall 权重高于 Precision。混淆矩阵重点看car↔truck和bus↔truck这两组,串得厉害说明标注口径里有边界车型没定清楚,回头改标注规则比调参有效。

3.3 多端导出与预处理对齐

from ultralytics import YOLO m = YOLO("runs/traffic/v8s_640/weights/best.pt") m.export(format="onnx", imgsz=640, opset=12, simplify=True, dynamic=False) # 服务端 ONNXRuntime m.export(format="engine", imgsz=640, half=True, device=0) # 带 N 卡的边缘盒子 m.export(format="ncnn", imgsz=640) # ARM 边缘端

dynamic=False固定 batch=1,边缘端 shape 固定能省掉一次内存重排;half=True只在确定支持 FP16 的设备上开,否则会出现 NaN 框这种玄学现象;opset=12是兼容性最稳的一档,别追新。

导出之后必须做一件很多人跳过的事:写一个对比脚本,拿 100 张图分别过 PyTorch 和导出模型,逐框比对 IoU,低于 0.95 就要查预处理。预处理不一致是最隐蔽的精度杀手。

import cv2, numpy as np def letterbox(img, new=640, color=114): """与训练时一致的等比缩放 + 灰边填充,返回缩放比和左上偏移""" h, w = img.shape[:2] r = min(new / h, new / w) nh, nw = int(round(h * r)), int(round(w * r)) img = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_LINEAR) top, left = (new - nh) // 2, (new - nw) // 2 out = np.full((new, new, 3), color, dtype=np.uint8) out[top:top + nh, left:left + nw] = img return out, r, (left, top) # r 和偏移用于把检测框映射回原图坐标

三个细节必须对齐:填充值 114、通道顺序 BGR、不做手动/255(YOLOv8 在模型内部完成归一化)。返回的r和(left, top)用来把框还原到原图坐标系,如果这一步漏了,计数线的像素坐标和框的坐标就活在两个世界里,画面上看着框在线两侧来回跳,计数结果却始终为零。这类问题查起来没有报错、没有日志,只能靠肉眼盯。

4. 车流统计链路:ByteTrack 跟踪、越线计数与指标口径

检测给的是“点”,统计要的是“线”。中间必须有一层跟踪,把同一辆车在连续帧里的框串成一条轨迹并给一个稳定 ID。没有 ID,“过线”这个动作就无从定义。

4.1 用 ByteTrack 把检测框串成轨迹

from ultralytics import YOLO model = YOLO("weights/v8s_640.onnx", task="detect") def track_stream(frames, conf=0.25): for frame in frames: res = model.track( frame, persist=True, # 关键:跨帧保持 track_id,不加等于每帧重置 tracker="bytetrack.yaml",# 用配置文件而不是硬编码,方便调参 conf=conf, iou=0.5, classes=[0, 1, 2, 3], # 只保留四类交通目标,滤掉行人等干扰 verbose=False, )[0] yield frame, res.boxes

persist=True是这套链路成立的前提。tracker指定配置文件,是为了能调两个参数:track_buffer控制目标丢失后保留轨迹的帧数,遮挡严重的路口从默认 30 提到 50,轨迹不容易断;match_thresh控制匹配阈值,调高更容易接上新框,但 ID 复用风险同步上升。这两个参数是跷跷板,往哪边调都要配合计数模块的冷却窗口。

classes过滤看着不起眼,实际影响不小。不过滤的话,行人和非机动车也会拿到track_id,虽然不计数,但会占用匹配资源,交叉口人车混行时还会干扰车辆轨迹的关联。

4.2 越线计数的状态机与滞回

import numpy as np class LineCounter: def __init__(self, p1, p2, cooldown=8, ttl=300): self.p1 = np.array(p1, dtype=float) self.p2 = np.array(p2, dtype=float) # 线段法向量:点积符号即"在线的哪一侧",不用求交点 self.n = np.array([-(self.p2 - self.p1)[1], (self.p2 - self.p1)[0]]) self.cooldown = cooldown # 冷却帧数,防抖动重复计数 self.ttl = ttl # 轨迹消失多久后清理状态 self.state = {} # track_id -> (last_side, last_frame) def _side(self, pt): return float(np.dot(pt - self.p1, self.n)) def update(self, track_id, center, frame_idx): s = self._side(np.array(center, dtype=float)) side = 1 if s > 0 else -1 last = self.state.get(track_id) self.state[track_id] = (side, frame_idx) if last is None: return 0 last_side, last_frame = last # 方向翻转 + 距上次翻转超过冷却帧数,两个条件都满足才记一次 if side != last_side and frame_idx - last_frame >= self.cooldown: return side # 1 正向过线,-1 反向过线 return 0 def gc(self, frame_idx): # 定期清理长时间未更新的轨迹,避免长跑内存膨胀与 ID 复用污染 dead = [k for k, (_, f) in self.state.items() if frame_idx - f > self.ttl] for k in dead: self.state.pop(k, None)

几个设计取舍值得说清楚。用法向量点积判断方位,比求交点和参数化线段简单得多,而且框中心在线上轻微抖动时不会因为浮点误差反复触发;cooldown用帧数而不是毫秒,因为跳帧推理时时间戳不均匀,用帧号更可控。cooldown给小了,一辆车过线能被记两三次;给大了,紧跟着的摩托车会被吃掉。25fps 下我一般给 8 到 12,具体看车速分布。

gc()别省。长跑场景下state会一直增长,更麻烦的是 track_id 会被回收复用,带着上一条轨迹的last_side进来,导致新目标一出现就被判成“过线”。这种 bug 上线第一天看不出来,跑一晚上数据就歪了。

4.3 车速、车型分布与指标口径

车速有两条路。一是双线法:在路面上画两条平行虚拟线,间距 L 米(用车道虚线长度或实地量),记录同一track_id过两条线的时间差,v = L / Δt * 3.6。二是透视变换:把路面四点映射到俯视图,按标定出的“像素-米”比例算位移。双线法简单、误差来源清楚,毕设场景优先用它。

指标定义落地注意
车流量单位时间过线车辆数,换算为辆/小时分钟桶不满时用 5 分钟滑窗折算,别拿 1 分钟直接乘 60,误差放大 60 倍
平均车速有速度记录的轨迹算术平均先剔除大于 150 km/h 的异常值,多为 ID 跳变导致
车型分布各 vtype 计数占比分母统一用总过车数,别混用“有效轨迹数”
车道占有率ROI 内检测框面积占比对时间的积分是粗略近似,写进结论时要注明假设
排队长度连续 N 帧位移低于阈值的轨迹数位移阈值和 fps 挂钩,换帧率要重算

口径比精度更重要。答辩时被问“你这车流量准不准”,先讲口径再讲误差:时间窗多长、空窗怎么折算、跨线抖动怎么处理。顺序反了,就会被追着问“为什么和人工数的不一样”,而你手上没有口径,只能干着急。

5. 避坑与排查:车流项目最容易翻车的五个点

5.1 训练指标漂亮,上线统计偏差三成

现象是验证集 mAP50 有 0.9 以上,实际跑视频却频繁漏检远处小车,计数比人工少三成。原因八成是预处理没对齐:训练时imgsz=640加 letterbox,部署时直接把 1080p 原图喂进去让框架自己 resize,宽高比被拉变形,小目标特征全乱。解决办法是统一走同一个letterbox函数,并在导出后跑一遍 PyTorch 与 ONNX 的逐框对比,IoU 低于 0.95 就停下来查,不要带着偏差往下做。

5.2 同一辆车被计了两三次

现象是明细表里相邻几十毫秒出现同车型、同车道的多条记录,track_id却不同。原因有两个叠加:框中心在计数线上来回抖动触发多次翻转,以及遮挡导致轨迹断裂后 ByteTrack 分配了新 ID。解决靠两头:计数侧加冷却帧数和方位状态机,跟踪侧把track_buffer调大,并在计数模块里用“同车道 + 短时间内 + 外观类别相同”做一次轻量去重。

5.3 边缘端帧率掉到个位数

现象是开发机上跑 30fps,挪到边缘盒子只有 5fps,画面卡成 PPT。原因通常是三条同时中招:1080p 原图不缩放直接推理、用 PyTorch 权重跑 CPU、每帧都做画框和 JPEG 编码。解决办法是按收益排序:先换 ONNX 或 TensorRT 权重,再把imgsz固定 640,然后改跳帧推理——每两帧推理一次,中间帧用跟踪的预测位置补,最后把编码改成只对有变化的区域刷新。

5.4 聚合表和明细表对不上

现象是看板显示某小时 320 辆,导出明细只有 298 辆。原因一般是聚合任务重跑没有幂等,INSERT把同一分钟写了两遍,或者时间戳一边用本地时间一边用 UTC,跨零点那一段直接错位。解决办法是把聚合写入改成带唯一键的 upsert,bucket_at全链路统一 UTC,再写一个对账脚本定时比对count(pass_record)和sum(flow_minute.cnt),差值为零才认为这一分钟是完整的。

5.5 夜间和逆光场景几乎不可用

现象是白天计数正常,晚上七点之后数字断崖式下跌。原因很直接:数据集几乎全是白天样本,模型没见过低照度分布,同时conf阈值照搬了白天的 0.25。解决办法分两步,先补夜间与逆光样本,用hsv_v做亮度扰动扩样本;再把阈值降下来提高召回,靠跟踪侧的“连续 N 帧确认后才允许计数”把误检压回去。只降阈值不做确认,误检会立刻爆表。

注意:这五条里,第一条和第五条会同时出现。补样本之前先做预处理对齐,否则你会以为是数据不够,其实是坐标和色彩空间错了,白标一批数据。

6. 验收与答辩:用一段回放视频把计数误差压到 5% 以内

验收不要靠“看着还行”。固定一段 10 到 15 分钟的回放视频,人工数一遍总过车数作为基准,跑系统再数一遍,误差控制在 5% 以内才算过。逐分钟对比比只看总数有用,误差集中在哪个时段、哪条车道,一眼就能看出来。

验收项方法通过标准
检测质量独立测试集跑valmAP50 ≥ 0.80,Recall ≥ 0.75
计数准确率回放视频人工对数总误差 ≤ 5%,单分钟误差 ≤ 10%
端到端延迟从取帧到出统计的时间戳差≤ 500 ms
推理吞吐连续跑 10 分钟取均值服务端 ≥ 20 fps,边缘端 ≥ 10 fps
接口性能查询 24 小时粒度数据P95 ≤ 300 ms
长稳连续运行 24 小时无内存增长,聚合与明细对账为零

答辩环节我一般建议按这个顺序讲:先讲口径(什么叫一次过线、时间窗多长),再讲架构(为什么跟踪和检测要拆开),然后放对比数据(人工 vs 系统),最后主动交代边界——不做车牌识别、不做违章判定、车速在无标定时只给估算值。主动划边界比被问出来强得多。

演示一定要有兜底。现场网络抖一下 RTSP 断流,整个答辩就废了。我习惯准备一段本地视频文件和一份已经跑好的结果数据,断网也能把图表和明细翻出来。

从那以后我每次交这类方案,都强制走一遍“固定回放 + 人工对数 + 逐分钟对账”这条流程,哪怕时间再紧也不跳。误差数字是自己先算出来的,比被追问时现场解释强一百倍。这套工程的代码、点位配置模板和验收用的对账脚本都在压缩包里,按第 2 章的目录结构解压就能跑起来,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询