简介:面向毕业设计、课程设计与目标检测入门实践的YOLOv8多端车流检测系统源码包,围绕车辆实时检测、GUI可视化与多端部署展开,适合需要完整项目参考的高校学生及开源学习者。压缩包共396个文件,约16.94MB,以150个Python源码、132个pyc编译文件、34个yaml配置和40张png、23张jpg图像为主,另含pt权重、ui界面、sql脚本、env配置及演示mp4,覆盖训练推理、界面交互与结果展示等环节。已有1363人学习下载。读者可据此梳理YOLOv8车流检测的工程目录、模型配置与权重调用方式,参考GUI上传图片/视频后的检测结果展示、告警图片留存及多端系统组织思路,用于毕设复现、功能扩展和排错对照。
1. 一个路口八小时的视频,人工数车能数到怀疑人生
某开发者接了个横向课题,手上只有一段路口监控,八小时、四条车道。他原本打算抽样人工数车,数到第二小时就撑不住了——车灯反光、树影晃动、排队时车贴车,眼睛根本跟不上。换成基于 YOLOv8 的多端车流检测系统之后,同一段视频跑一遍,分流向、分车型的计数表直接落盘。这类多端车流检测项目在毕设里出现频率很高,但真正能跑通、还能在网页端和桌面端同时看结果的并不多。这份压缩包给的是一整套:检测模型、跟踪计数逻辑、后端接口和几个端的界面都在里面,适合想找个能改能答辩的毕设底座,或者手上正好有路口视频、想快速出统计结果的人。
2. 技术选型与目录结构:一份压缩包里到底塞了哪几层
2.1 动手之前先想清楚的三件事
拿到这类项目,我一般不会先开代码,而是先把三个问题问明白,否则后面改起来全是返工。
第一个是计数口径。到底是“越线计数”还是“区域内计数”,这两套逻辑实现完全不同。越线计数画的是一条虚拟线,车头中心点穿过线段就 +1,适合统计断面流量;区域计数是画一个多边形,统计此刻区域里停着多少车,适合算排队长度和拥堵度。这份项目里两套都有,但默认走的是越线,因为断面流量是交通调查里最常用的指标,答辩时也好解释。
第二个是“多端”到底多在哪。不少同类项目的做法是每个端各自加载一次模型,网页端用 ONNX Runtime 跑,桌面端用 PyTorch 跑,结果是同一段视频在两个端上数出来的数不一样,这就很尴尬。合理的架构是推理只在一个地方做——后端进程里跑 YOLOv8,算完把结构化的结果通过 HTTP 和 WebSocket 分发给各个端,端上只负责画框和渲染图表。这样多端只是展示层的复制,不存在口径不一致的玄学问题。
第三个是实时还是离线。离线处理视频文件可以把整个视频解码完再逐帧推理,帧率不敏感,能开大 imgsz 换精度;实时接摄像头或 RTSP 流就必须控帧率,宁可降分辨率也要保证不积压。项目里这两种模式是配置项切换的,source.type写video还是camera,后面的缓冲区策略跟着变。
2.2 技术栈拆解:每一层为什么用这个
| 层次 | 选型 | 选它的理由 |
|---|---|---|
| 目标检测 | YOLOv8(ultralytics 库) | 一行predict就能出框,权重生态全,n/s/m 三档随时换 |
| 多目标跟踪 | ByteTrack(ultralytics 内置bytetrack.yaml) | 不需要额外依赖,遮挡后重识别比纯 IoU 匹配稳 |
| 计数判定 | 自研虚拟线 + 轨迹符号判定 | 逻辑简单可解释,答辩被问细节不会卡壳 |
| 视频解码 | OpenCVVideoCapture | 通吃 mp4 / avi / RTSP / 本地摄像头 |
| 后端服务 | FastAPI + Uvicorn | 原生支持 WebSocket,推流比分发图片省带宽 |
| Web 前端 | 原生 JS + Canvas 叠框 | 不引入前端框架,压缩包体积小,改起来门槛低 |
| 桌面端 | PyQt5 封装 | 复用同一套 Python 逻辑,打包成 exe 就能交付 |
| 移动端 | 轻量 H5 页面(WebView 套壳) | 不重写模型,直接连后端 WebSocket 看结果 |
这张表里最值得说的是跟踪这一环。很多人第一次做车流计数,直接用检测框的中心点算位移,结果一遇到遮挡,ID 就断了,同一辆车过线两次被算成两辆。ByteTrack 的价值在于它把低置信度的框也拿来参与匹配,遮挡恢复后能接上原来的 ID。代价是要开persist=True让跟踪器在帧之间保留状态,忘了这个参数,每帧都会重新分配 ID,计数直接翻倍——这个坑后面还会细说。
后端选 FastAPI 而不是 Flask,主要是 WebSocket 这块省事。车流检测的输出频率是每秒十几到二十几次,用轮询拉 JSON 会有明显延迟,用 WebSocket 推就顺很多。另外 FastAPI 自带StreamingResponse,真要退化成 MJPEG 推流也是一行的事。
2.3 目录结构:解压之后先看哪几个文件夹
vehicle-flow/ ├── configs/ │ └── app.yaml # 全局配置:模型、视频源、虚拟线、服务端口 ├── weights/ │ └── yolov8n.pt # 检测权重,换成自己训的模型就改这里 ├── core/ │ ├── detector.py # YOLOv8 推理封装,负责出框 │ ├── tracker.py # ByteTrack 调用 + 轨迹缓存 │ ├── counter.py # 虚拟线越线判定与计数 │ └── metrics.py # 速度估算、流量、拥堵度 ├── server/ │ ├── main.py # FastAPI 入口,HTTP + WebSocket │ └── schemas.py # 输出结构定义,前后端对齐用 ├── web/ │ ├── index.html # 网页端主界面 │ └── static/js/player.js # Canvas 叠框与图表渲染 ├── desktop/ │ └── app.py # PyQt5 桌面端入口 ├── data/ │ └── demo.mp4 # 示例视频 └── requirements.txtcore/是整个项目的重心,四个文件对应四步:出框、跟住、判断过线、算指标。server/和web/、desktop/都只是把core/的结果往外送,改展示层不会碰到算法层。configs/app.yaml是唯一需要动手改的地方,虚拟线的两个端点坐标、每米像素数、视频源路径全在这里,把配置和代码分开是这份包做得比较规矩的一点。
有一点要注意:weights/里放的通常是 COCO 预训练的yolov8n.pt,它认的是 car、motorcycle、bus、truck 这几个通用类。如果你的场景里要分轿车、SUV、面包车,就得自己标数据重训,这一步在后面二次开发那章展开。
3. 核心链路拆解:一辆车从进画面到被计数要过四道关
3.1 第一关:预处理与推理封装
检测这一层没什么花活,就是把 OpenCV 读出来的 BGR 帧丢给 YOLOv8,拿回 N×6 的数组。真正需要拿捏的是几个阈值和类别过滤。
import cv2 import numpy as np from ultralytics import YOLO class Detector: def __init__(self, weights="weights/yolov8n.pt", conf=0.35, iou=0.5, imgsz=640, device="cpu"): self.model = YOLO(weights) self.conf = conf # 路侧场景别低于 0.3,再低误检会爆炸 self.iou = iou # NMS 阈值,车贴车排队时适当调高到 0.6 self.imgsz = imgsz # 1280 对小目标友好,但显存占用翻倍 self.device = device def __call__(self, frame): results = self.model.predict( frame, conf=self.conf, iou=self.iou, imgsz=self.imgsz, device=self.device, verbose=False, classes=[2, 3, 5, 7], # COCO: car / motorcycle / bus / truck )[0] boxes = results.boxes if boxes is None or len(boxes) == 0: return np.empty((0, 6), dtype=np.float32) xyxy = boxes.xyxy.cpu().numpy() # 左上右下坐标 cls = boxes.cls.cpu().numpy().reshape(-1, 1) # 类别索引 conf = boxes.conf.cpu().numpy().reshape(-1, 1) # 置信度 return np.hstack([xyxy, cls, conf]) # N x 6代码里最关键的是classes=[2, 3, 5, 7]。COCO 一共 80 类,不筛的话人会占掉大量算力,而且行人被误判成车辆的概率不低。加上这个过滤,后处理的数据量能降一半以上。conf默认给 0.35 是个折中值:调到 0.5 会漏掉远处的车,调到 0.2 又会把护栏、广告牌上的车图认成真车,具体数值建议拿自己那段视频跑一遍,看误检和漏检哪个更不可接受。imgsz从 640 提到 1280,对高机位俯拍的小目标提升很明显,但 CPU 上会慢到没法看,所以配置里默认是 640,有显卡再往上调。
verbose=False这个小参数容易被忽略,不加的话每帧都往终端打一行推理耗时,实时跑起来日志刷屏,真正的报错反而被冲掉了。
3.2 第二关:跟踪与越线判定
跟踪直接用 ultralytics 内置的 ByteTrack,一行参数的事;越线判定得自己写,核心就是判断连续两帧的中心点是不是落在这条线的两侧。
from collections import defaultdict, deque class LineCounter: def __init__(self, p1, p2, memory=30): self.p1, self.p2 = p1, p2 # 虚拟线两端点,建议画在画面中段 self.memory = memory # 每条轨迹保留最近 N 个中心点 self.tracks = defaultdict(lambda: deque(maxlen=memory)) self.counted = set() # 已计数的轨迹 ID,防止重复累加 def _side(self, pt): # 二维叉积,返回值符号代表点在线段的哪一侧 (x1, y1), (x2, y2) = self.p1, self.p2 return (x2 - x1) * (pt[1] - y1) - (y2 - y1) * (pt[0] - x1) def update(self, track_id, center, cls_name): self.tracks[track_id].append(center) if track_id in self.counted or len(self.tracks[track_id]) < 2: return None prev, now = self.tracks[track_id][-2], self.tracks[track_id][-1] s_prev, s_now = self._side(prev), self._side(now) if s_prev * s_now < 0: # 符号翻转 = 跨过了这条线 self.counted.add(track_id) return cls_name return None_side用的是叉积符号判定,比算直线方程再比 y 值稳,因为车道线很少是水平的,斜线情况下前者不会出错。s_prev * s_now < 0表示前后两帧在一侧一负,中间必然穿过了线。self.counted这个集合是防重复计数的后悔药——一辆车压线行驶时中心点可能在线上来回抖,不加这个集合会被算成三四辆。memory=30的意思是每条轨迹只留最近 30 帧的位置,超出的自动丢弃,避免长时间跟踪的轨迹把内存吃掉。
调用侧长这样,注意persist=True:
results = model.track(frame, persist=True, tracker="bytetrack.yaml", classes=[2, 3, 5, 7], conf=0.35, verbose=False)[0] if results.boxes.id is not None: ids = results.boxes.id.int().cpu().tolist() xyxy = results.boxes.xyxy.cpu().numpy() clss = results.boxes.cls.int().cpu().tolist() for tid, box, c in zip(ids, xyxy, clss): cx, cy = (box[0] + box[2]) / 2, (box[1] + box[3]) / 2 hit = counter.update(tid, (cx, cy), CLASS_NAMES[c]) if hit: stats[hit] += 1不写persist=True,跟踪器每帧重建,boxes.id永远是 1、2、3 重新开始,counted集合形同虚设,计数会翻好几倍。这是这套代码里最容易踩的一个点。
3.3 第三关:速度估算与拥堵度
速度靠像素位移换算。前提是标定出“每米多少像素”,方法很简单:在画面里找一段已知长度的车道虚线,量出它在图上的像素长度,除一下就有ppm。
def speed_kmh(prev, now, fps, ppm): # ppm: pixels per meter,用车道虚线标定,一次标定长期有效 dist_px = ((now[0] - prev[0]) ** 2 + (now[1] - prev[1]) ** 2) ** 0.5 dist_m = dist_px / ppm return dist_m * fps * 3.6 # 米/秒 转 公里/小时 def congestion_level(veh_count, area_m2, avg_speed): density = veh_count / max(area_m2, 1e-6) # 辆/平方米 if density > 0.06 or avg_speed < 10: return "拥堵" if density > 0.03 or avg_speed < 25: return "缓行" return "畅通"速度这块要提醒一句:透视变换没做的话,画面远端和近端的ppm是不一样的,同一辆车在远处测得慢、近处测得快。项目里给的是单ppm简化版,够毕设用;要严谨一点就上透视变换,把像素坐标先映射到俯视平面再算距离,cv2.getPerspectiveTransform四行代码的事。拥堵度那三个阈值不是标准值,是经验值,不同城市、不同路段差别很大,交付前拿实测数据调一遍比较靠谱。
3.4 第四关:结构化输出与分发
算完的结果不能只画在画面上,得变成各端都能消费的结构。这一步做得好不好,直接决定了多端接入是改十行还是改一百行。
def build_payload(fid, fps, stats, tracks, avg_speed): return { "frame_id": fid, "ts": round(fid / fps, 2), # 视频内时间戳,秒 "counts": stats, # {"car": 12, "bus": 2, ...} "flow_per_min": round(stats["total"] / max(fid / fps, 1) * 60, 1), "avg_speed": round(avg_speed, 1), "tracks": [ # 只带必要字段,别把整帧塞进去 {"id": t["id"], "bbox": t["bbox"], "cls": t["cls"], "speed": round(t.get("speed", 0), 1)} for t in tracks ], }tracks里只保留 id、bbox、类别和速度,不要把原图 base64 塞进去。一张 1080p 的 JPEG base64 之后有几百 KB,按 20fps 推,带宽直接爆掉;而几十个框的坐标加起来不到 2KB,前端拿 Canvas 自己画,流畅度和带宽都合适。这是多端项目里一个很实际的取舍点。
4. 跑通全流程:从虚拟环境到网页端看到实时框的三个命令
4.1 环境准备与依赖安装
python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple python -c "import torch; print(torch.__version__, torch.cuda.is_available())"最后那行验证是必须跑的。如果输出False,说明装的是 CPU 版 torch,检测能跑但很慢,1080p 视频大概 3~5 fps。想用显卡得按本机 CUDA 版本重装对应的 torch,这一步版本对不上就会报一堆CUDA error,属于典型的环境问题。依赖里比较大的是ultralytics、opencv-python、fastapi、uvicorn和pyqt5,PyQt5 只在跑桌面端时才需要,纯服务端部署可以删掉省几十兆。
安装完成后建议先跑一次内置的示例视频,确认模型能加载、能出框,再去动配置。顺序反了的话,出了问题时你分不清是环境没装好还是配置改错了。
4.2 权重与配置文件
权重文件yolov8n.pt大约 6MB,首次运行时如果本地没有,ultralytics 会自动去下载。内网环境下载不了的话,提前把文件放到weights/目录下,路径对上就行。
# configs/app.yaml model: weights: weights/yolov8n.pt imgsz: 640 conf: 0.35 device: cpu # 有显卡改成 cuda:0 source: type: video # video | camera | rtsp path: data/demo.mp4 # camera 模式填 0,rtsp 模式填完整地址 counter: line: [[420, 680], [1180, 680]] # 虚拟线两端点,务必按实际分辨率改 ppm: 18.5 # 每米像素数,拿车道虚线标一次 server: host: 0.0.0.0 port: 8000 ws_path: /ws/streamline这两个坐标是整套配置里最容易出错的地方。示例视频是 1280×720,线画在 y=680 的位置,接近画面底部;你换成 1920×1080 的素材,同样的坐标就跑到画面中间偏上了,车还没进画面就被计数。改之前先用播放器看一眼分辨率,再拿截图量一下想画线的位置,别凭感觉填。
ppm的标定方法前面说过,找一段车道虚线,量像素长度除以实际米数。国内高速车道虚线一般是 6 米线 9 米空,城市道路差别较大,实在不确定就按 15~20 之间的经验值先跑,速度数值不准但不影响计数。
4.3 启动后端与网页端
# 终端一:起后端,--reload 只在开发时加 uvicorn server.main:app --host 0.0.0.0 --port 8000 --reload # 终端二:起一个静态服务器托管网页 python -m http.server 5173 --directory web浏览器打开http://127.0.0.1:5173,正常的话能看到视频画面和叠加的检测框,右侧是实时计数和流量曲线。如果画面出来了但框不动,先看后端终端有没有WebSocket connected的日志,没有就是前端连错了地址——player.js里那个ws://地址默认写的是127.0.0.1:8000,部署到别的机器上得改成对应 IP。
服务端还留了几个 HTTP 接口方便对接:/api/stats拿当前累计统计,/api/export导出 CSV,/api/config读当前配置。做二次开发时,别的系统要接你的数据,直接调前两个就行,不用碰 WebSocket。
4.4 桌面端打包与移动端接入
桌面端就是desktop/app.py那个 PyQt5 壳子,内部还是调core/里的同一套逻辑,好处是断网也能跑,适合现场演示。打包用 PyInstaller:
pyinstaller -F -w desktop/app.py \ --add-data "weights/yolov8n.pt:weights" \ --add-data "configs/app.yaml:configs"--add-data这两行不能省,PyInstaller 只打 Python 代码,模型和配置文件得显式带上,路径分隔符在 Windows 上是分号、Linux/macOS 是冒号,跨平台打包时容易翻车。
移动端这块项目走的是最省事的路线:做一个 H5 页面,用 WebView 套壳,连后端 WebSocket 拿数据渲染。优点是不用重训模型、不用折腾移动端推理框架;缺点是需要和后端在同一网络里。真要做端上推理,就得把模型导成 ONNX 或 TFLite,那是另一个工作量级的事,毕设阶段不划算。
5. 避坑与排查:多端车流项目最容易翻车的五个地方
5.1 视频能播,但一个框都不出
现象:网页上视频正常播放,Canvas 层干干净净,控制台没有报错,后端日志显示推理在跑。
原因:八成是类别过滤和模型对不上。如果换成自己训的模型,类别索引就不是 COCO 的 2/3/5/7 了,classes一过滤,所有框都没了。另一种情况是conf设得太高,比如 0.8,普通路侧场景根本出不了这么高的置信度。
解决:先把classes参数整个删掉跑一次,看有没有框。有框说明是类别索引问题,打印model.names对一下自己模型的类别表,把索引改对。没框就把conf降到 0.1 再试,同时确认weights路径加载的是预期那个文件——加载失败时 ultralytics 有时会自动回退去下载官方权重,日志里一闪而过,很容易看漏。
5.2 计数翻倍,一辆车被算成两三辆
现象:视频里明明过了 20 辆车,统计出来 50 多。
原因:跟踪器状态没保持住,每帧重新分配 ID,counted集合拦不住。也可能是虚拟线画在了车辆容易停留的位置,比如停止线或者收费口,一辆车在线上反复抖动,来回穿线。
解决:先检查model.track()有没有persist=True,这是最高频的原因。确认参数没问题还翻倍,就把虚拟线往画面中段挪,避开车辆排队区域。还有一个隐蔽情况:视频帧率不稳定时,同一辆车可能连续两帧位置跳变很大,叉积符号来回翻,这时可以把判定条件收紧成“前后两帧距离超过 N 像素且符号翻转”才算数。
5.3 换了摄像头画面,计数全乱
现象:示例视频跑得好好的,换成自己的素材,数字明显不对,有时一辆车算五六次。
原因:分辨率变了,line坐标还是按原来的填的,虚拟线落到了画面外或者不合理的位置;同时ppm也没重标,虽然不影响计数但速度全错。
解决:每次换视频源,第一件事是确认分辨率,重新在画面上标两个端点。我一般会在配置里加一行注释写清楚这组坐标对应的分辨率,换素材时对照着改。标完先别急着看统计,把带虚拟线的画面截一张图,肉眼确认线横跨了整条车道、没有压在停止线上,这一步花两分钟能省半小时排查。
5.4 网页端画面卡成 PPT
现象:后端 CPU 占用不高,但网页上框的移动一顿一顿,拖动进度条要等好几秒。
原因:多半是把整帧图片 base64 塞进 WebSocket 推送了。1080p 的 JPEG 编码后几百 KB,20fps 就是每秒十几兆,浏览器解码都来不及。另一种可能是前端每收到一条消息就重建一次 Canvas 上下文,频繁 GC。
解决:确认推的是坐标不是图片。前端拿 bbox 在 Canvas 上画矩形,一帧几十个框的开销可以忽略。视频画面本身走<video>标签播放原始文件,或者后端用StreamingResponse推 MJPEG,两条通道分开。Canvas 上下文在初始化时取一次存起来,别在onmessage里反复getContext。
5.5 打包成 exe 之后模型加载失败
现象:源码跑得好好的,PyInstaller 打完包双击闪退,命令行里能看到FileNotFoundError: weights/yolov8n.pt。
原因:PyInstaller 打包后运行时的工作目录和源码目录不一样,相对路径失效。而且模型文件不在 Python 代码里,不会被自动收集。
解决:用--add-data显式带上模型和配置,代码里读路径时用sys._MEIPASS判断是否处于打包环境:
import sys, os def res_path(rel): base = getattr(sys, "_MEIPASS", os.path.abspath(".")) return os.path.join(base, rel) weights = res_path("weights/yolov8n.pt")这个函数放在工具模块里,所有读资源的地方统一走它,能省掉后面一个个改路径的麻烦。
6. 二次开发:把通用车流检测改成你课题里的那一版
改造这件事,我的建议是从“统计口径”入手,而不是从模型入手。换模型成本最高、见效最慢;改口径和加指标,往往两三天就能做出明显区别于原版的成果。
第一类改造是换检测粒度。原版分 car/bus/truck/motorcycle 四类,如果你的课题是“城市路口车型构成分析”,就得把 car 拆成轿车、SUV、面包车。做法是拿自己场景的数据标个几百张,用yolo train data=my.yaml model=yolov8n.pt epochs=100 imgsz=640微调,n 版模型在单卡上几个小时就能出结果。标数据时注意把远端的、被遮挡的车也标上,不然微调完模型只认近处的大目标,远处全漏。训完把configs/app.yaml里的weights指向新文件,classes改成新模型的类别索引,其他代码一行不用动。
第二类改造是加指标。原版有流量、平均速度、拥堵度三个,想加“车头时距”或者“排队长度”都可以在core/metrics.py里补函数。车头时距就是同一车道连续两辆车过线的帧差除以 fps,排队长度是区域计数乘平均车长,实现都不复杂。加完记得在build_payload里把字段带上,前端图表加一条曲线就完事。加指标的时候有个细节:新字段名一旦定下来就别改,前端、导出的 CSV、答辩 PPT 里的截图全引用它,中途改名会牵出一堆地方。
第三类改造是加端。想加个小程序端,直接把 H5 页面搬过去改样式,接口地址换成后端 IP 就行;想加个大屏可视化,用 ECharts 接/api/stats轮询,比 WebSocket 好写。多端架构的好处在这里体现得很明显——算法层不动,端上想加几个加几个。
验证方法我一般分三步走。先拿示例视频跑,把统计结果和肉眼数出来的对比,误差在 5% 以内算过关;再拿自己场景的视频跑,重点看夜间、雨天、逆光这几种情况,这些场景掉点最厉害;最后跑长视频,半小时以上,看内存有没有持续上涨——tracks那个字典如果不做清理,跑久了会越来越大,加个定期清理超过 N 帧未更新的轨迹就行。
说个我自己踩过的坑。有次改完计数逻辑,示例视频跑出来完全正确,直接拿去答辩演示,现场换了一段新素材,数字差了快一倍。回来查了半天才发现是新视频的帧率是 25fps,而代码里写死按 30fps 算流量,速度指标全偏了。从那以后我每次换视频源,都强制走一遍“读分辨率、读帧率、标虚拟线、跑一分钟看数字”这套流程,一个都不跳。希望帮到你。
本文还有配套的精品资源,点击获取