ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

基于YOLOv8的多端车流检测系统:检测跟踪与越线计数落地

基于YOLOv8的多端车流检测系统:检测跟踪与越线计数落地

简介:基于YOLOv8的多端车流检测系统源码包,面向做毕业设计、课程项目或目标检测入门实践的高校学生与开发者,围绕车辆实时检测、车流统计与多端部署场景,提供从模型训练到界面展示的完整工程参考。压缩包共396个文件,约16.94MB,以150个Python脚本和132个pyc文件承载模型推理、训练配置与业务逻辑,另有40张PNG、23张JPG用于界面素材和检测样例,34个YAML保存训练与数据配置,并包含预训练权重、数据库脚本、UI文件及演示视频等,目录按功能模块组织,便于对照修改。已有1366人学习下载,说明其在同类毕设项目中具备一定参考基础。读者可据此梳理YOLOv8的数据预处理、模型训练与评估、实时视频流检测、GUI可视化及多端结果整合思路,也能借助现成脚本和配置文件快速复现车流检测流程,适合作为毕设方案骨架和二次开发起点。

1. 车流检测:一个把 YOLOv8 从演示草稿变成路口实际可运行系统的完整工程

路口架一台旧摄像头,画面里车来车往,你要是只想知道“过去一分钟过去了多少辆”,其实不需要上一整套大平台。基于 YOLOv8 的多端车流检测系统就是为这个需求做的:一份权重、一套计数逻辑,能在 Web 页面上看,也能在桌面端跑,还能塞进手机里做离线推理。

它最适合两类人:一类是手里有毕设要做,需要一套既能讲清架构、又能当场演示的完整工程;另一类是手上真有一个小区或厂区路口,想低成本看看车流分布。这个项目把检测、跟踪、越线计数、测速串成了一条完整链路,而且每一层都能单独拆出来换,不是那种所有逻辑都堆在main.py里的演示代码。

下面的拆解按“先看骨架、再看链路、最后动手改”的顺序走。

2. 架构与目录:推理内核、服务层与三端外壳怎么分层

拿到一个多端项目,我第一件事不是跑pip install,而是先看目录。目录结构会暴露作者到底有没有分层,还是三端各写一套、复制粘贴出来的。这套代码属于后者里的反例:它把真正的计算压在一个很薄的内核层里,三个端都只是“调用方”,这决定了你后面改一处逻辑,三端同时生效。

2.1 为什么锁定 YOLOv8,而不是继续用 v5

这类车流项目在选型上其实没太多花样,检测器无非是 YOLO 系列、RT-DETR 或者 SSD 那一挂。作者选 v8 的理由,我跑过一遍之后是认的:ultralytics这个包把训练、验证、推理、导出收进了同一套 API,model.predict()和model.track()的签名几乎一致,跟踪器直接内置 ByteTrack,不用再单独接一个deep_sort仓库;更关键的是导出链路短,一条命令就能出 ONNX、ncnn、TFLite,多端复用才有了落地可能。

从精度上看,v8 是无锚框加解耦头的结构,在 640 输入下对中近距离的车辆召回比 v5 稳一些,尤其是并排跟车、部分遮挡的场景。当然代价也有:n 级别模型在 CPU 上单帧大概 60~120ms,你要是拿工控机软解 1080p 视频,必然要抽帧。这一点在后面“跑起来”和“避坑”两章还会反复出现。

提示:模型级别的选择比想象中重要。车流统计看的是“过线事件”,不是逐帧精度,所以yolov8n往往够用;但如果你要区分卡车、客车、摩托车,n的小目标召回会掉,建议直接上s。

2.2 三层结构:一份权重,三种运行方式

把整个工程横着切一刀,大致是三层。

最底下是内核层,通常叫core/,里面放着detector.py、tracker.py、counter.py、speed.py这几个文件。这一层只认numpy.ndarray格式的帧,返回纯 Python 字典,不碰任何 Web 框架、不碰 GUI,也不碰 Android。这种“只处理数组”的约束是多端复用的前提。

中间是服务层,server/,一般是 FastAPI 起一个 HTTP + WebSocket 服务,负责拉流、解码、调度内核、把画好框的 JPEG 推给前端,同时把每分钟的计数落到 SQLite。这一层是 Web 端和桌面端共用的,桌面端很多时候就是一个内嵌浏览器或者直接用requests拉流。

最上面是外壳层,clients/下分web、desktop、mobile三个目录。它们做的事高度一致:拿图、显示、在图上叠框、把统计数字贴到界面上。差别只在渲染技术——Web 用 canvas,桌面用 PyQt 的QLabel,移动端用ImageView加一层自定义 View。

三层之间靠一份 YAML 配置串起来:权重路径、置信度、IOU、输入尺寸、设备、虚拟线圈坐标、像素米比,全在这一个文件里。这样你在手机上改阈值,改的是同一份语义,不用去三个地方找魔法数字。

2.3 目录结构:从 weights 到各端入口

实际下载下来,目录大概长这样,名字可能略有出入,但分层是清楚的:

vehicle-flow/ ├── configs/ │ ├── default.yaml # 全局参数,三端共用 │ └── cam_gate_a.yaml # 单个点位的线圈、ROI、标定 ├── core/ │ ├── detector.py # YOLOv8 检测封装 │ ├── tracker.py # ByteTrack 封装,输出稳定 track_id │ ├── counter.py # 虚拟线圈越线计数 │ ├── speed.py # 像素位移换算车速 │ └── pipeline.py # 把上面四个串成一条流水线 ├── server/ │ ├── app.py # FastAPI 入口,含 WebSocket │ ├── db.py # SQLite 落库,按分钟聚合 │ └── static/ # 前端页面 ├── clients/ │ ├── desktop/main.py # PyQt5 桌面端 │ └── mobile/ # Android 工程 + 导出的 ncnn 模型 ├── weights/ │ └── yolov8n.pt ├── data/ │ └── samples/ # 几段测试视频 ├── requirements.txt └── README.md

这份结构里有两个细节值得注意。一是core/pipeline.py的存在,说明作者没有让每个端自己写“先检测再跟踪再计数”的编排,而是把编排也收进内核层,三端只负责喂帧和取结果。二是configs/被拆成全局和点位两份,默认参数和现场标定分开,这个习惯在真实部署里非常值钱——换一个路口,你只需要新增一个cam_xxx.yaml。

注意:weights/目录里只放了.pt。移动端要用的.onnx和 ncnn 模型通常是运行时导出或者单独打包,不要以为下载下来就能直接装到手机上。

3. 核心链路:一帧画面到一条车流量记录的四个步骤

这一章是全篇最值得逐行看的部分。检测本身没什么好讲的,调个predict而已;真正决定这套系统“能不能拿去演示”的,是检测之后的三步:跟踪拿到稳定 ID、越线判定方向、去重不漏计也不重计。任何一步写歪,界面上那个数字就是错的。

3.1 检测封装:把 ultralytics 的 predict 包成可替换接口

内核层的第一件事,是把ultralytics的输出翻译成项目自己的数据结构。别小看这一层翻译,它决定了你以后换模型(比如换成 RT-DETR 或者自训的 YOLOv11)时要改多少地方。

# core/detector.py from ultralytics import YOLO # COCO 里和车相关的类别,其余全部丢掉 VEHICLE_CLS = {2: "car", 3: "motorcycle", 5: "bus", 7: "truck"} class Detector: def __init__(self, weight="weights/yolov8n.pt", conf=0.35, iou=0.5, imgsz=640, device="cpu"): self.model = YOLO(weight) self.conf, self.iou = conf, iou self.imgsz, self.device = imgsz, device def detect(self, frame): r = self.model.predict( frame, conf=self.conf, iou=self.iou, imgsz=self.imgsz, device=self.device, classes=list(VEHICLE_CLS), verbose=False)[0] out = [] for b in r.boxes: x1, y1, x2, y2 = b.xyxy[0].tolist() out.append({ "cls": VEHICLE_CLS[int(b.cls.item())], "conf": float(b.conf.item()), "box": [x1, y1, x2, y2], "center": ((x1 + x2) / 2, (y1 + y2) / 2), }) return out

这段代码里几个参数的取舍值得说清楚。conf=0.35比默认的 0.25 高,是因为车流场景里远端的车辆框往往又小又糊,低阈值会带来大量一闪而过的假框,跟踪器一抖就多计一次。iou=0.5是 NMS 的重叠阈值,并排跟车时调太高会把两辆车合成一辆,调到 0.6 以上在密集车流里会明显掉计数。imgsz=640是精度和速度的平衡点,实测降到 480 能快 30%,但远处的小车就基本检不出来了。

classes=list(VEHICLE_CLS)这个写法比在循环里if cls not in ...: continue更好,因为它把过滤放进了推理后端,NMS 之前就把行人和红绿灯干掉了,省一点算力。verbose=False是必须的,否则每帧都往 stdout 打一行速度统计,服务端日志会被冲爆。

返回的字典里带center,是因为后面计数和测速都只需要中心点,提前算好省得每处都重算一遍。

3.2 跟踪:用 ByteTrack 拿到稳定的 track_id

没有跟踪,就没有“过去一辆车”这个概念,只有一堆帧间不相干的框。所以第二步必须给每个目标分配一个跨帧稳定的 ID。

常见做法是直接用ultralytics内置的跟踪接口,它底层挂的就是 ByteTrack,配置文件是bytetrack.yaml:

# core/tracker.py class Tracker: def __init__(self, model, conf=0.35, iou=0.5, imgsz=640, device="cpu"): self.model = model self.kw = dict(conf=conf, iou=iou, imgsz=imgsz, device=device, persist=True, tracker="bytetrack.yaml", classes=list(VEHICLE_CLS), verbose=False) def track(self, frame): r = self.model.track(frame, **self.kw)[0] if r.boxes.id is None: # 首帧或空画面,没有 ID return [] tracks = [] for box, tid, cls in zip(r.boxes.xyxy.tolist(), r.boxes.id.tolist(), r.boxes.cls.tolist()): x1, y1, x2, y2 = box tracks.append({ "id": int(tid), "cls": VEHICLE_CLS[int(cls)], "box": box, "center": ((x1 + x2) / 2, (y1 + y2) / 2), }) return tracks

这里最容易出错的是persist=True。它让跟踪器在多次调用之间保留状态,视频流必须开;如果你每帧都新建一个YOLO实例或者忘了这个参数,ID 会从 1 重新开始,结果是每帧都“新来一辆车”,计数直接起飞。这个坑我在第五步第 2 条还会再提一次。

另一个隐性参数是帧序。ByteTrack 内部用卡尔曼滤波预测下一帧位置,它假设你的帧是连续且时间均匀的。如果你在服务端做了跳帧(比如每 3 帧取 1 帧),跟踪器本身不会报错,但 ID 断裂率会明显上升,尤其是快速通过的小车。稳妥做法是抽帧时把fps一并传给统计模块,让测速按真实时间而不是帧数算。

3.3 越线计数:虚拟线圈的方向判定与去重

计数逻辑是这套项目的灵魂,也是最容易被写错的地方。很多演示代码是这么写的:判断中心点是否落在某条线附近(比如距离小于 5 像素),是就count += 1。这在 25fps 下一辆车会被计五六次,因为它在线的邻域里待了好几帧。

正确做法是判“符号翻转”,也就是用叉积判断中心点在直线的哪一侧,只在侧别发生变化的瞬间计一次,并且用一个counted集合兜底去重。

# core/counter.py from collections import defaultdict def side_of(p, a, b): """叉积符号:>0 在直线一侧,<0 在另一侧""" return (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]) class LineCounter: def __init__(self, line, fps=25): self.a, self.b = line # 线圈两个端点,像素坐标 self.prev = {} # track_id -> 上一帧所在侧 self.counted = set() # 已计数的 track_id self.total = 0 self.up = self.down = 0 # 分方向统计 self.by_class = defaultdict(int) def update(self, tracks): for t in tracks: tid = t["id"] s = 1 if side_of(t["center"], self.a, self.b) > 0 else -1 old = self.prev.get(tid) if old is not None and old != s and tid not in self.counted: self.total += 1 self.by_class[t["cls"]] += 1 if s > 0: self.up += 1 # 一个方向 else: self.down += 1 # 反方向 self.counted.add(tid) # 关键:一个 ID 只计一次 self.prev[tid] = s

逐段看。side_of用的是二维叉积(b-a) × (p-a),它的符号只跟点在直线的哪一侧有关,跟距离无关,所以目标离线很远也不会误判,只要它没跨过去。这比“距离小于 N 像素”稳得多。

self.prev记录的是“上一帧这个 ID 在哪一侧”,第一次见到某个 ID 时old是None,不计数,这是对的——你不可能知道它从哪来。

self.counted是去重的关键。理论上一个 ID 只会翻一次侧,但现实中因为跟踪 ID 抖动、目标在线上来回蹭(比如摩托车在路口犹豫),同一个 ID 可能翻好几次。加了这个集合之后,一个 ID 终生只贡献一次计数。代价是如果跟踪器真的把 ID 复用给了另一辆车(ByteTrack 在长时间丢失后会复用),就会漏计,所以工程上通常还会给counted配一个定期清理策略,比如超过 300 秒的 ID 从集合里移除。

up和down分开统计,是因为车流有方向性。一条双向车道,你来一个总数意义不大,出城和进城得分开看。

3.4 测速与区域过滤:像素到米的标定

车速不是必须功能,但毕设答辩时它是最能撑场面的一项。原理很简单:记录同一个 ID 最近 N 帧的中心点,算位移,乘上像素米比,除以时间。

# core/speed.py from collections import defaultdict, deque class SpeedEstimator: def __init__(self, meters_per_pixel, fps=25, window=1.0): self.mpp = meters_per_pixel self.fps = fps self.n = max(2, int(fps * window)) # 取最近 1 秒的点 self.hist = defaultdict(deque) def update(self, tid, center): dq = self.hist[tid] dq.append(center) while len(dq) > self.n: dq.popleft() if len(dq) < self.n: return 0.0 (x0, y0), (x1, y1) = dq[0], dq[-1] dist_px = ((x1 - x0) ** 2 + (y1 - y0) ** 2) ** 0.5 dt = (len(dq) - 1) / self.fps return dist_px * self.mpp / dt * 3.6 # m/s -> km/h

meters_per_pixel是标定出来的:在画面里量一段已知长度的路面(比如车道虚线实线一段 6 米),数它占多少像素,除一下。这个值直接决定测速准不准,而且它随距离变化——靠近摄像头的 1 米可能占 40 像素,远处只占 8 像素。所以严格来说应该做透视变换,把画面先 warp 成鸟瞰图再测速。

工程上的折中做法是:只在画面下半部分(近处)测速,远处直接返回 0。或者用两条平行线做标定,车辆依次穿过两条线时记录时间差,用两线间的真实距离除以时间,这个比单帧位移靠谱得多,也不受透视影响。作者没写死用哪种,但从counter.py里line参数是数组来看,多线配置是留了口子的。

区域过滤则是另一个必要功能。摄像头对着路口,画面里必然有不该计数的区域——人行道上的电动车、对面车道的车、停在路边的车。常见做法是配一个多边形 ROI,中心点落在 ROI 内才送进计数器:

import cv2 def in_roi(center, polygon): # polygon 是 [[x,y], ...],返回 1/0/-1 return cv2.pointPolygonTest( polygon.astype("int32"), center, False) >= 0

cv2.pointPolygonTest的第三个参数measureDist传False只判内外,速度快;传True会返回带符号的距离,能拿来做“离边界还有多远”的软判断,但每帧对每个目标都算一次,成本不低,车流场景没必要。

4. 多端落地与首次运行:从 FastAPI 服务到桌面与移动端

内核层写完之后,剩下的工作本质上是“怎么把数组和数字送到三个不同的屏幕上”。这一章按启动顺序走:先把服务端拉起来,再让 Web 或桌面端连上,最后处理移动端那个最麻烦的导出。

4.1 服务端:FastAPI + WebSocket 的推流接口

服务端要解决两件事:拉流解码(OpenCV 的VideoCapture足够),以及把处理后的帧推给浏览器。HTTP 轮询太慢,multipart/x-mixed-replace的 MJPEG 流简单但没法同时传统计数字,所以常见做法是 WebSocket 推 JPEG 的 base64。

# server/app.py import asyncio, base64 from fastapi import FastAPI, WebSocket, WebSocketDisconnect from core.pipeline import Pipeline app = FastAPI() pipes = {} @app.websocket("/ws/stream/{cam_id}") async def stream(ws: WebSocket, cam_id: str): await ws.accept() pipe = pipes.setdefault(cam_id, Pipeline(cam_id)) try: async for jpg, stat in pipe.frames(): await ws.send_json({ "img": base64.b64encode(jpg).decode(), "stat": stat, # {"total": 128, "up": 70, "down": 58} }) await asyncio.sleep(0) # 让出事件循环,别把连接卡死 except WebSocketDisconnect: pipe.release()

send_json里塞 base64 是有代价的:体积会涨 33%,而且编解码要占 CPU。之所以还这么写,是因为前端拿到一个 JSON 就能同时更新画面和数字,不用开两条连接。如果你只做单向视频,改成send_bytes直接推 JPEG 二进制,带宽能省三成,前端用URL.createObjectURL(new Blob([...]))也能显示。

await asyncio.sleep(0)这行看起来没意义,其实是保命的。pipe.frames()如果是同步生成器,事件循环会被它一直占着,其他连接全部排队。加一个sleep(0)主动让出控制权,多个摄像头并发时才不会互相饿死。更彻底的做法是把推理放进run_in_executor或者干脆扔到独立进程,用队列通信。

pipes这个字典是进程内缓存,意味着一个摄像头只会有一路解码,多个前端连同一个cam_id时共享同一份计算结果。这个设计很实用——值班室三台电脑看同一个路口,不会把 CPU 跑满。

4.2 前端与桌面端:把框画在 canvas 上

前端接上 WebSocket 之后,onmessage里拿到的是一张已经画好框的图,直接塞给<img>就行。但更灵活的做法是只传原始帧和检测结果,前端用 canvas 自己画,这样可以在不重启服务的情况下换配色、开关标签、做悬停高亮。

// server/static/app.js const ws = new WebSocket(`ws://${location.host}/ws/stream/gate_a`); const img = document.getElementById('frame'); const ctx = document.getElementById('overlay').getContext('2d'); ws.onmessage = (ev) => { const { img: b64, stat, boxes } = JSON.parse(ev.data); img.src = 'data:image/jpeg;base64,' + b64; ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); ctx.strokeStyle = '#00e676'; ctx.lineWidth = 2; for (const b of boxes || []) { ctx.strokeRect(b[0], b[1], b[2] - b[0], b[3] - b[1]); } document.getElementById('total').textContent = stat.total; };

ctx.clearRect每帧必须先调,否则框会一层层叠上去,几秒后画面就是一团绿。桌面端用 PyQt 时逻辑一样,只是把img.src换成QLabel.setPixmap,把 canvas 换成另一个透明QLabel叠在上面。真正的差异在性能:桌面端 30fps 完全没问题,浏览器在 1080p 下画几十个框也扛得住,但如果同时开四个窗口,CPU 就开始叫了。

4.3 移动端:导出 ONNX / ncnn 与降采样策略

移动端是整个多端里最麻烦的一环。手机上没有ultralytics,也没有 PyTorch runtime,只能走导出。

# 导出 ONNX,opset 12 兼容性最好 yolo export model=weights/yolov8n.pt format=onnx opset=12 imgsz=640 simplify=True # 导出 ncnn,Android 上首推这个,不需要额外依赖 yolo export model=weights/yolov8n.pt format=ncnn imgsz=320 half=True

opset=12是个经验值,再往上某些推理引擎的支持会跟不上,simplify=True会跑一遍图优化,能省 10% 左右的推理时间。imgsz=320是移动端的现实妥协——640 在骁龙 7 系上单帧要 200ms 以上,320 大概能压到 60ms,代价是远处车辆的召回明显下降。half=True用 fp16,ncnn 对它的支持很成熟,速度能再提一点。

导出之后还有一步最容易忘:前后处理必须和训练时对齐。YOLOv8 的推理要求输入先做 letterbox(等比缩放后补边到正方形),输出再反算回原图坐标。PC 端ultralytics帮你做完了,移动端要自己写:

# 移动端前处理的核心逻辑 def letterbox(img, new=320, color=(114, 114, 114)): h, w = img.shape[:2] r = min(new / h, new / w) nh, nw = int(round(h * r)), int(round(w * r)) resized = cv2.resize(img, (nw, nh)) top = (new - nh) // 2 left = (new - nw) // 2 out = cv2.copyMakeBorder(resized, top, new - nh - top, left, new - nw - left, cv2.BORDER_CONSTANT, value=color) return out, r, left, top

r、left、top这三个值必须一路带到后处理里,反算时用(x - left) / r。任何一步漏了,你在手机上看到的框就会整体偏移或者缩放不对,而且因为画面里确实有车,框也框在车上,只是位置差一点,肉眼很难第一时间发现。

4.4 启动顺序:环境、权重、配置、验证

把上面几块拼起来,本地跑通的顺序大概是这样:

# 1. 建环境,Python 3.9~3.11 都行,3.12 有时 ultralytics 的轮子不全 conda create -n flow python=3.10 -y && conda activate flow # 2. 装依赖,torch 建议按 CUDA 版本单独装 pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu pip install ultralytics fastapi uvicorn opencv-python pyyaml # 3. 放权重,没有自己的模型就用官方 n 级别顶一下 # weights/yolov8n.pt # 4. 改配置:先只改视频源和线圈坐标,其余保持默认 # configs/cam_gate_a.yaml # 5. 起服务,--reload 只在调试时开 uvicorn server.app:app --host 0.0.0.0 --port 8000 # 6. 验证:浏览器开 http://localhost:8000/static/index.html

第 5 步的--host 0.0.0.0是为了让手机在同一局域网里也能连上,只写127.0.0.1的话,手机怎么连都是超时。第 6 步如果画面出不来但服务端没报错,八成是前端请求的cam_id和pipes里注册的对不上,看一下浏览器控制台的 WebSocket 地址。

配置文件的部分,线圈坐标一定是从0到1的归一化值还是像素值,得跟counter.py里的用法对死。这是新手第一个大坑,下一章第一条就是它。

5. 避坑排查:五条真实跑通过程里最容易出问题的记录

这套代码逻辑不复杂,但落地时出问题的往往不是算法,是坐标、时序和资源管理。下面五条是我在类似项目里踩过、也见过别人踩的,按“现象 → 原因 → 解决”写,能省你几个晚上。

5.1 计数永远是 0,画面里框也在动

现象:检测框正常,跟踪 ID 也在变,但界面上的total从头到尾是 0,日志里也没报错。

原因:线圈坐标的单位搞混了。configs/cam_gate_a.yaml里如果写的是[[0.2, 0.75], [0.8, 0.75]](归一化),而LineCounter拿去和像素坐标的中心点做叉积,那条线实际上在画面左上角 1 像素范围内,所有车都在同一侧,符号永远不翻转。

解决:在Pipeline初始化时统一转换,读到配置就判断值是否都小于等于 1,是就乘上frame_width/frame_height再传给LineCounter。更稳的做法是配置里显式写coord: normalized或coord: pixel,让代码分支处理,别靠猜。

5.2 一辆车过了,数字涨了三次

现象:单辆车通过,计数涨 2~3;车流密的时候涨幅明显偏大。

原因:三个可能。一是persist=True没开,跟踪器每帧重置,同一辆车每帧都是新 ID。二是counted集合被写成了按帧清空,或者压根没实现。三是视频抽帧之后帧率参数没同步,跟踪器预测位置和实际位置偏差太大,ID 断成两段。

解决:先打印每帧的track_id列表,如果同一辆车周围 ID 一直在变,就是persist的问题;如果 ID 稳定但计数还是多,去查counted;如果 ID 中途断了一次又接上新的,把抽帧关掉验证,确认后再调track_buffer(ByteTrack 里控制 ID 保留帧数的参数),把它从默认 30 提到 60 左右。

5.3 手机上和电脑上框的位置对不上

现象:同一段视频,PC 端框得准,Android 端框整体偏移或者大小不对,偏得不多,但明显不是同一个框。

原因:letterbox 的前后处理没对齐。常见是缩放比例算错(用了new/w而不是min)、补边量在反算时符号写反、或者忘了把输出从 320 的坐标系还原回原图。

解决:拿一张固定测试图,画一个已知坐标的框,在两端分别跑一次,把反算后的坐标打印出来比对。差值是常数,说明补边量没减;差值是比例关系,说明r用错了。这个方法比盯着代码看快得多。

5.4 跑十几分钟内存涨到几个 G,最后进程被杀

现象:刚启动内存 800M,跑一小时后变成 4G,然后 OOM。

原因:几个典型的泄漏点。cv2.VideoCapture在切换视频源时没release();每帧都cv2.imwrite存图做调试但没清理;WebSocket 客户端断线后服务端的发送协程没退出,消息在队列里堆积;self.prev和self.counted这两个字典只增不减,ID 累积到几十万。

解决:给prev和counted加过期清理,用一个deque记录 ID 的最近活跃时间,超过 5 分钟的清掉;VideoCapture用try/finally包住确保释放;WebSocket 的写操作加超时,连续写失败就主动close。调试用的存图开关做成配置项,默认关。

5.5 CPU 上只有 2 FPS,画面卡成幻灯片

现象:GPU 机器上 30fps 很流畅,换到没独显的工控机上直接掉到 2fps,视频延迟越来越大。

原因:imgsz=640加 fp32 加每帧完整推理,在纯 CPU 上就是这个量级。另一个常被忽略的点是解码:1080p 的 H.264 用 OpenCV 软解本身就吃 30% 以上的 CPU,和推理抢核。

解决:按收益排序——先抽帧,每 3 帧处理 1 帧,车流统计完全够用;再降imgsz到 480 或 416;然后导 ONNX 用onnxruntime跑,比 PyTorch 的 CPU 推理快 1.5~2 倍;最后才考虑换模型级别。别一上来就把yolov8n换成yolov8m,那是反方向优化。

6. 二次开发:换类别、加统计维度与把最后 10% 的精度找回来

这套代码最值钱的地方不是它能数车,而是它的分层让改动成本很低。你想换成统计人流,改的是VEHICLE_CLS这张表和权重;你想加分车道统计,改的是LineCounter的输入;你想让数字进数据库做趋势图,改的是server/db.py。

换检测目标是最简单的:classes参数改成[0](行人)或者[0, 2, 3, 5, 7],VEHICLE_CLS那张映射表同步改,Detector和Tracker一行不用动。如果要识别的类别 COCO 里没有(比如工程车、三轮车),就只能自己标数据重训,这时候core/的分层就体现出价值了——换权重文件路径即可,上层完全感知不到。

加分车道统计,我一般会把单条line改成lines列表,每条线带一个lane_id,LineCounter内部维护by_lane字典。落库时按分钟聚合,一张flow_stat表存(camera_id, lane_id, minute, up, down, avg_speed),前端画折线图直接查。这个改动量大概半天,但演示效果提升很明显。

精度调优这块,有个顺序别搞反。先标定,再调阈值,最后才动模型。标定不准的话,后面所有参数都是在错误的坐标系上找平衡。阈值上conf和iou要联合调:conf从 0.35 往上扫,看漏检和误检的拐点;iou在密集车流里往 0.45 调能减少并排车被合并。至于模型,n换成s通常能换回 3~5 个点的召回,代价是 CPU 上慢一倍,值不值取决于你的硬件。

有个我自己的教训值得说一下:早几年做类似的东西,导出 ONNX 之后发现 PC 和手机的结果对不上,查了两天才发现是simplify=True之后某个算子被融合,反算坐标时的偏移没跟着改。从那以后我每次导模型,都强制走一遍“固定测试图 + 已知坐标框 + 两端比对”的验证流程,哪怕只改了一个imgsz参数。这套项目里那个data/samples/目录,就是干这个用的,别删。希望帮到你。

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

返回列表