资讯详情 基于YOLOv8的多端车流检测系统:检测跟踪与越线计数落地
📅 2026/11/2 6:46:32
简介基于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这个包把训练、验证、推理、导出收进了同一套 APImodel.predict()和model.track()的签名几乎一致跟踪器直接内置 ByteTrack不用再单独接一个deep_sort仓库更关键的是导出链路短一条命令就能出 ONNX、ncnn、TFLite多端复用才有了落地可能。从精度上看v8 是无锚框加解耦头的结构在 640 输入下对中近距离的车辆召回比 v5 稳一些尤其是并排跟车、部分遮挡的场景。当然代价也有n 级别模型在 CPU 上单帧大概 60120ms你要是拿工控机软解 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, weightweights/yolov8n.pt, conf0.35, iou0.5, imgsz640, devicecpu): 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, confself.conf, iouself.iou, imgszself.imgsz, deviceself.device, classeslist(VEHICLE_CLS), verboseFalse)[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这段代码里几个参数的取舍值得说清楚。conf0.35比默认的 0.25 高是因为车流场景里远端的车辆框往往又小又糊低阈值会带来大量一闪而过的假框跟踪器一抖就多计一次。iou0.5是 NMS 的重叠阈值并排跟车时调太高会把两辆车合成一辆调到 0.6 以上在密集车流里会明显掉计数。imgsz640是精度和速度的平衡点实测降到 480 能快 30%但远处的小车就基本检不出来了。classeslist(VEHICLE_CLS)这个写法比在循环里if cls not in ...: continue更好因为它把过滤放进了推理后端NMS 之前就把行人和红绿灯干掉了省一点算力。verboseFalse是必须的否则每帧都往 stdout 打一行速度统计服务端日志会被冲爆。返回的字典里带center是因为后面计数和测速都只需要中心点提前算好省得每处都重算一遍。3.2 跟踪用 ByteTrack 拿到稳定的 track_id没有跟踪就没有“过去一辆车”这个概念只有一堆帧间不相干的框。所以第二步必须给每个目标分配一个跨帧稳定的 ID。常见做法是直接用ultralytics内置的跟踪接口它底层挂的就是 ByteTrack配置文件是bytetrack.yaml# core/tracker.py class Tracker: def __init__(self, model, conf0.35, iou0.5, imgsz640, devicecpu): self.model model self.kw dict(confconf, iouiou, imgszimgsz, devicedevice, persistTrue, trackerbytetrack.yaml, classeslist(VEHICLE_CLS), verboseFalse) 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这里最容易出错的是persistTrue。它让跟踪器在多次调用之间保留状态视频流必须开如果你每帧都新建一个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, fps25): 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, fps25, window1.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/hmeters_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) 0cv2.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只能走导出。# 导出 ONNXopset 12 兼容性最好 yolo export modelweights/yolov8n.pt formatonnx opset12 imgsz640 simplifyTrue # 导出 ncnnAndroid 上首推这个不需要额外依赖 yolo export modelweights/yolov8n.pt formatncnn imgsz320 halfTrueopset12是个经验值再往上某些推理引擎的支持会跟不上simplifyTrue会跑一遍图优化能省 10% 左右的推理时间。imgsz320是移动端的现实妥协——640 在骁龙 7 系上单帧要 200ms 以上320 大概能压到 60ms代价是远处车辆的召回明显下降。halfTrue用 fp16ncnn 对它的支持很成熟速度能再提一点。导出之后还有一步最容易忘前后处理必须和训练时对齐。YOLOv8 的推理要求输入先做 letterbox等比缩放后补边到正方形输出再反算回原图坐标。PC 端ultralytics帮你做完了移动端要自己写# 移动端前处理的核心逻辑 def letterbox(img, new320, 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, valuecolor) return out, r, left, topr、left、top这三个值必须一路带到后处理里反算时用(x - left) / r。任何一步漏了你在手机上看到的框就会整体偏移或者缩放不对而且因为画面里确实有车框也框在车上只是位置差一点肉眼很难第一时间发现。4.4 启动顺序环境、权重、配置、验证把上面几块拼起来本地跑通的顺序大概是这样# 1. 建环境Python 3.9~3.11 都行3.12 有时 ultralytics 的轮子不全 conda create -n flow python3.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 一辆车过了数字涨了三次现象单辆车通过计数涨 23车流密的时候涨幅明显偏大。原因三个可能。一是persistTrue没开跟踪器每帧重置同一辆车每帧都是新 ID。二是counted集合被写成了按帧清空或者压根没实现。三是视频抽帧之后帧率参数没同步跟踪器预测位置和实际位置偏差太大ID 断成两段。解决先打印每帧的track_id列表如果同一辆车周围 ID 一直在变就是persist的问题如果 ID 稳定但计数还是多去查counted如果 ID 中途断了一次又接上新的把抽帧关掉验证确认后再调track_bufferByteTrack 里控制 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视频延迟越来越大。原因imgsz640加 fp32 加每帧完整推理在纯 CPU 上就是这个量级。另一个常被忽略的点是解码1080p 的 H.264 用 OpenCV 软解本身就吃 30% 以上的 CPU和推理抢核。解决按收益排序——先抽帧每 3 帧处理 1 帧车流统计完全够用再降imgsz到 480 或 416然后导 ONNX 用onnxruntime跑比 PyTorch 的 CPU 推理快 1.52 倍最后才考虑换模型级别。别一上来就把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_idLineCounter内部维护by_lane字典。落库时按分钟聚合一张flow_stat表存(camera_id, lane_id, minute, up, down, avg_speed)前端画折线图直接查。这个改动量大概半天但演示效果提升很明显。精度调优这块有个顺序别搞反。先标定再调阈值最后才动模型。标定不准的话后面所有参数都是在错误的坐标系上找平衡。阈值上conf和iou要联合调conf从 0.35 往上扫看漏检和误检的拐点iou在密集车流里往 0.45 调能减少并排车被合并。至于模型n换成s通常能换回 35 个点的召回代价是 CPU 上慢一倍值不值取决于你的硬件。有个我自己的教训值得说一下早几年做类似的东西导出 ONNX 之后发现 PC 和手机的结果对不上查了两天才发现是simplifyTrue之后某个算子被融合反算坐标时的偏移没跟着改。从那以后我每次导模型都强制走一遍“固定测试图 已知坐标框 两端比对”的验证流程哪怕只改了一个imgsz参数。这套项目里那个data/samples/目录就是干这个用的别删。希望帮到你。本文还有配套的精品资源点击获取