ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

YOLOv5目标检测与训练可视化:Flask+Vue前端集成实现

YOLOv5目标检测与训练可视化:Flask+Vue前端集成实现 简介这套基于YOLOv5的Vue前端目标检测与训练可视化系统面向有一定Python和前端基础的开发者解决目标检测模型训练与结果展示脱节的问题可应用于安全监控、工业质检、智能交通等场景提供从数据准备、模型训练到界面交互的完整闭环。压缩包共316个文件约29.02MB包含Python训练脚本、yaml模型配置、pt权重文件、TensorBoard事件记录以及Vue打包后的js/css/html静态资源同时配有Nginx与Dockerfile便于快速部署与二次开发。大量json标注文件和jpg/png图片样本可直接用于模型微调演示动图与说明文档则有助于理解目标检测中分类、定位、大小、形状等核心问题以及One-stage、Two-stage检测思路和YOLOv5的回归化检测原理。已有132人浏览学习既适合作为课程设计、毕业设计的工程模板也能在此基础上快速搭建可视化检测Demo或预研原型。1. 这套系统不是给算法工程师自己用的是给团队用的终端里跑着yolov5 train.pyloss 曲线用 TensorBoard 看检测结果用cv2.imshow弹窗验证一个人调参完全没问题。但模型迭代到第 40 个 epoch产品、测试、客户都开始问“现在效果怎么样”再挨个发截图就说不通了。标题里那个.zip暴露的是它的交付形态把 YOLOv5 的检测推理和训练监控一起搬进 Vue 前端算法进程在后台跑浏览器页面只负责呈现和操作。这套系统解决的是信息同步问题——非算法角色不需要读懂终端日志打开网页就能看检测效果、确认当前训练到第几轮、盯 loss 曲线是否还在下降。适合三类人做内部工具平台的 AI 工程师、做软硬一体交付的集成团队、拿 YOLO 做课程设计的学生。三者的共同诉求只有一句话让“训练”从命令行命令变成页面上的一个按钮。2. YOLOv5后端接口设计一份同时服务检测和训练的Python薄封装后端部分用 Flask 是常见选择。它不像 FastAPI 那样自带异步和 OpenAPI但在这类“检测 训练管理”的低并发场景里Flask 的线程模型足够用依赖也轻。如果后续要接 WebSocket 推视频帧可以换成 FastAPI 或者给 Flask 挂 flask-sock接口设计思路不变。要达成的目标是前端只通过 HTTP 接口拿到检测框和训练状态不关心模型权重路径、数据集 yaml 路径这些底层信息。2.1 检测接口的最小实现模型加载一次请求处理多次检测接口最常见的错误是每次请求都执行一次torch.hub.load。YOLOv5s 权重约 14MB加载加权重反序列化在 CPU 上要 2 到 4 秒在 GPU 上也要 1 秒左右前端请求必然超时。所以核心是把模型做成全局懒加载第一次请求时初始化之后复用同一份权重。# server/app.py import threading import torch from flask import Flask, request, jsonify app Flask(__name__) det_model None model_lock threading.Lock() def get_detector(weights: str yolov5s.pt): 懒加载检测模型双检锁避免并发时重复加载 global det_model if det_model is None: with model_lock: if det_model is None: det_model torch.hub.load( ultralytics/yolov5, custom, pathweights, devicecuda if torch.cuda.is_available() else cpu, ) return det_model双检锁是这里的关键。Flask 默认多线程处理请求第一次并发打进来时如果没有锁两个线程会同时执行torch.hub.load各自加载一份模型显存直接翻倍。外层if det_model is None做快速路径进入锁后再查一次保证只会加载一次。torch.hub.load会尝试从 GitHub 拉仓库离线环境要改成加载本地解压好的 YOLOv5 目录把第一个参数换成本地路径。推理接口直接收文件流app.route(/api/detect, methods[POST]) def detect(): file request.files.get(image) if file is None: return jsonify({error: 缺少 image 文件字段}), 400 results get_detector()(file.stream) dets results.pandas().xyxy[0] payload dets[ [xmin, ymin, xmax, ymax, name, confidence] ].to_dict(orientrecords) return jsonify({detections: payload, count: len(payload)})file.stream是 Werkzeug 封装的上传文件对象YOLOv5 的推理入口能直接读取这种流式对象省掉一次临时文件写入。results.pandas().xyxy[0]会返回一个 DataFrame列里有xmin、ymin、xmax、ymax、name、confidence六列to_dict(orientrecords)把它变成前端最好处理的数组。注意 YOLOv5 分叉版本多旧版本的置信度列名是conf而不是confidence对接时先打印一列确认别直接用。2.2 训练任务的启停与状态管理让前端看到“第几轮了”训练接口不能用同步方式实现——subprocess.wait()会阻塞 Flask worker前端等几小时拿不到响应。我的做法是启动子进程后立刻返回 job_id训练状态全部挂在内存字典里前端轮询。# server/train.py from flask import request, jsonify import subprocess import signal import uuid from pathlib import Path train_jobs {} app.route(/api/train/start, methods[POST]) def train_start(): body request.get_json(silentTrue) or {} args body.get(args, {}) job_id uuid.uuid4().hex[:8] cmd [ python, train.py, --epochs, str(args.get(epochs, 100)), --batch-size, str(args.get(batch_size, 16)), --img, str(args.get(img_size, 640)), --data, args.get(data, data/coco128.yaml), --project, runs/train, --name, job_id, --workers, 4, ] log_dir Path(runs/train) / job_id log_dir.mkdir(parentsTrue, exist_okTrue) logf open(log_dir / proc.log, w) proc subprocess.Popen( cmd, stdoutlogf, stderrsubprocess.STDOUT, cwd/path/to/yolov5, ) train_jobs[job_id] {proc: proc, logf: logf, status: RUNNING} return jsonify({job_id: job_id, status: RUNNING})--name传 job_id 是刻意的YOLOv5 默认把每次训练放到runs/train/exp、exp2这种递增目录里前端要判断哪次训练对应哪个目录很麻烦。直接指定目录名后面读 results.csv、proc.log 都是确定路径不依赖目录扫描顺序。--workers受机器 CPU 核数和内存限制训练机和开发机共用时拉低一点4 是稳妥值。app.route(/api/train/status/job_id) def train_status(job_id): job train_jobs.get(job_id) if job is None: return jsonify({error: 任务不存在}), 404 if job[proc].poll() is not None: job[status] FINISHED if job[proc].returncode 0 else FAILED return jsonify({job_id: job_id, status: job[status]})proc.poll()返回 None 表示进程还活着返回整数则是退出码。训练正常结束返回 0被 CtrlC 中断或被 OOM killer 杀掉返回非 0。这个状态机只有三个状态——RUNNING、FINISHED、FAILED——但对前端展示足够。复杂一点的状态机还可以加 PENDING排队和 STOPPING终止中单卡单任务场景暂时用不上。app.route(/api/train/stop/job_id, methods[POST]) def train_stop(job_id): job train_jobs.get(job_id) if job and job[proc].poll() is None: job[proc].send_signal(signal.SIGINT) job[status] STOPPING return jsonify({code: 0})用的是 SIGINT 而不是 SIGKILL。SIGKILL 直接杀进程PyTorch 的 DataLoader 子进程可能残留权重文件也可能停留在写了一半的状态。SIGINT 让 train.py 走 Python 的 KeyboardInterrupt 流程有机会 flush 日志和保存当前 ckpt。代价是如果训练卡在某个不可中断的 CUDA kernel 上SIGINT 要等一会儿才生效前端停止按钮要有 loading 态。2.3 接口参数对照表哪些超参数值得暴露给前端训练可视化系统里前端能改的参数不是越多越好。这张表本身就是 YOLOv5 超参数在前端页面上的落地方式哪些适合做成控件哪些该锁死在配置文件里。前端控件train.py 参数建议类型取值范围与说明训练轮数--epochsnumber input30~300超 300 建议配合 cosine lr批大小--batch-sizeselect8 / 16 / 32 / 64受显存限制输入分辨率--imgselect320 / 416 / 640小目标检测拉高到 640初始学习率--lr0slider0.001~0.01默认 0.01优化器--optimizerradioSGD / Adam / AdamW预训练权重--weightsfile input断点续训填last.pt路径数据配置--dataselect对应数据集的 yaml 文件路径--data参数背后是“YOLOv5 训练自己的数据集”的完整链路数据 yaml 里必须包含train、val路径nc类别数names类别名列表。前端在选择数据集时后端最好把 yaml 读出来把类别数、类别名一并返回页面上展示“该数据集共 80 类”比用户填错 nc 后在训练启动时报错要友好得多。3. Vue前端消费检测结果从axios到Canvas画框一条链路说清楚检测结果在前端看起来只是六个数字加一个名字但真正把方框画到图片上细节比想象的多。这一章从 Vue 工程初始化开始把请求层、渲染层和实时性选型逐个说明整套流程可以直接抄进项目里。3.1 Vue项目初始化与HTTP层封装用 Vite 创建 Vue 3 项目是最省事的路径“Vue 安装及环境配置”在 Vite 下就是两条命令npm create vitelatest detect-front -- --template vue cd detect-front npm install axiosHTTP 层我习惯单独放在src/api/index.js所有接口函数集中管理组件里不直接出现 axios 调用// src/api/index.js import axios from axios const http axios.create({ baseURL: import.meta.env.DEV ? http://localhost:8000/api : /api, timeout: 60000, }) export function detectImage(file) { const fd new FormData() fd.append(image, file) return http.post(/detect, fd) } export function getTrainStatus(jobId) { return http.get(/train/status/${jobId}) }baseURL用import.meta.env.DEV切换本地开发指向 Flask 的 8000 端口生产构建后走同域下的/api由 nginx 反代到后端。这里刻意不在 axios 里手动设置Content-Type——浏览器在提交 FormData 时会自动带上multipart/form-data; boundary...一旦手动指定了错误的 Content-Type后端解析上传文件就会失败这是 Vue 文件上传最常见的坑之一。timeout设置成 60 秒检测接口在 CPU 上跑大图时确实可能超过默认的 30 秒。3.2 Canvas画框坐标换算原图尺寸与显示尺寸的映射后端返回的坐标是原始图片的像素坐标图片在页面里被 CSS 缩放后直接拿来画框会整体偏移。换算公式很直接scaleX 显示宽度 / 原图宽度scaleY 显示高度 / 原图高度框的每一条边都乘对应比例。!-- src/components/DetectionCanvas.vue -- template canvas refcanvasRef stylemax-width: 100%/canvas /template script setup import { ref } from vue import { detectImage } from /api const canvasRef ref(null) async function runDetect(file) { const { data } await detectImage(file) const img new Image() img.src URL.createObjectURL(file) await new Promise((resolve) { img.onload resolve }) const canvas canvasRef.value const ctx canvas.getContext(2d) const W 800 const H Math.round((W * img.height) / img.width) canvas.width W canvas.height H ctx.drawImage(img, 0, 0, W, H) const sx W / img.width const sy H / img.height for (const d of data.detections) { const x d.xmin * sx const y d.ymin * sy const w (d.xmax - d.xmin) * sx const h (d.ymax - d.ymin) * sy ctx.strokeStyle #00e676 ctx.lineWidth 2 ctx.strokeRect(x, y, w, h) ctx.fillStyle rgba(0, 230, 118, 0.85) ctx.font 13px monospace ctx.fillText(${d.name} ${Number(d.confidence).toFixed(2)}, x, y - 8) } } /script这里把canvas.width直接设成 800而不是用 CSSwidth: 800px去缩放。canvas 的物理像素和显示尺寸不一致会导致绘制结果模糊直接设 width 属性最省事。img.onload用 Promise 包了一层确保drawImage执行时图片已经解码完成——否则拿到一张空白 canvas 是常事。y - 8给标签文字留出和框线的距离文字顶部覆盖到框线上不利于阅读。如果做的是摄像头实时检测流程改成requestAnimationFrame里drawImage(videoEl, ...)再把当前帧发送到后端检测返回后重绘叠加框。requestAnimationFrame会把绘制节奏和浏览器刷新率对齐比固定setInterval(16ms)更顺滑。3.3 实时性选型检测帧和训练状态不能用一个方案同一个系统里检测和训练对实时性的要求完全不同。检测视频流要求帧级延迟训练指标一个 epoch 才更新一次几分钟一次都行。所以选型不应该“统一用 WebSocket”而是按场景分开。场景数据量更新频率推荐方式理由检测框叠加视频帧每帧若干目标帧率级WebSocket轮询有往返延迟高帧率会排队训练 loss / mAP 曲线每 epoch 几行秒级HTTP 轮询epoch 粒度本身就慢轮询实现最可靠训练任务状态极小秒级轮询即可状态变化次数少没必要维持长连接训练日志输出中等秒级SSE单向文本推送SSE 自动重连比 WS 省心前端 WebSocket 检测帧的最小示例const ws new WebSocket(ws://localhost:8000/ws/video) ws.onmessage (evt) { const frame JSON.parse(evt.data) // { image_b64, detections } const img new Image() img.onload () drawBoxes(frame.detections) img.src data:image/jpeg;base64,${frame.image_b64} }注意这里 frame 里的 detections 是后端算好的坐标前端只做渲染不该在浏览器里再跑一次 YOLOv5 推理。浏览器端用 TensorFlow.js 跑目标检测是另一个方向但在“Vue 前端 YOLOv5 目标检测”这套架构里前端只画框、后端负责推理责任更清晰GPU 利用率也更高。如果只做训练可视化WebSocket 可以完全不碰——Flask 原生不支持 WebSocket后端要多装 flask-sock 处理升级握手复杂度会明显上升。4. 训练可视化不是“画曲线”日志解析、增量读取和ECharts联动训练可视化最容易做成“前端画个假曲线”。要让曲线和 YOLOv5 训练过程真实联动后端必须解析训练产物前端定时拉取从数据源头到图表渲染每一步都要对得上。4.1 从results.csv取指标训练日志的准确解析姿势YOLOv5 每次训练都会在训练目录下生成results.csv文件名固定列名格式随版本略有差异。因此在解析时先确认列名再取值不能写死# server/metrics.py import csv from pathlib import Path def read_metrics(exp_dir: Path) - list[dict]: csv_path exp_dir / results.csv if not csv_path.exists(): return [] rows [] with open(csv_path, newline) as f: reader csv.DictReader(f) for row in reader: rows.append({ epoch: int(row.get(epoch, 0)), box_loss: float(row.get(train/box_loss, 0.0)), val_box_loss: float(row.get(val/box_loss, 0.0)), map50: float(row.get(metrics/mAP.5, 0.0)), map50_95: float(row.get(metrics/mAP.5:.95, 0.0)), }) return rowstrain.py 的日志文件用--name指定目录名后路径就是runs/train/job_id/results.csv不依赖目录扫描。csv.DictReader会把第一行当作列名所以直接取字段名不同 YOLOv5 分支列名有差异老版本是train_box_loss下划线命名v6.0 之后变成train/box_loss斜杠命名所以接口里用get带默认值。解析前打印一行csv_path.read_text().splitlines()[0]确认格式能省掉大量排错时间。提示YOLOv5 分叉版本极多results.csv 的列名和 detect 接口的返回字段都会随版本变化。对接前先打印一行头部字段比对着旧文档猜要快得多。4.2 增量读取与断点重启轮询接口要注意的两个边界训练在跑的时候 results.csv 是被 train.py 持续写入的。直接每次全量读整个文件没问题几十 KB 的文件读一次开销很小。真正的坑有两个一是 train.py 写入时有缓冲文件尾部可能短暂出现不完整的半行二是训练中断后用--resume续训时同一个 results.csv 会被追加而不是重建前端的曲线会出现“断点前后混在一张图”的现象。半行问题用“忽略解析失败的行”处理最直接def read_metrics_safe(exp_dir: Path) - list[dict]: rows [] for raw in read_lines(exp_dir / results.csv): try: rows.append(parse_row(raw)) except (ValueError, KeyError): pass # 半行数据等下一次轮询再解析 return rows续训问题更好办训练接口里--name每次生成新的 job_id相当于每次训练都是全新目录。前端角度看一次训练对应一个目录、一条曲线、一组干净数据不跟历史续训混在一起。如果一定要在已有目录上续训就得在结果里加一个resumed_from字段前端把曲线按“续训标志”拆成多条 series 展示——这个复杂度大多数项目不值得。4.3 ECharts渲染训练曲线组件封装与轮询防堆积后端按接口返回递增的数据前端 ECharts 组件负责把 metrics 数组映射成图表。一个可复用的 Vue 3 图表组件长这样!-- src/components/TrainChart.vue -- template div refchartRef stylewidth: 100%; height: 360px/div /template script setup import { ref, onMounted, watch } from vue import * as echarts from echarts const props defineProps({ metrics: { type: Array, default: () [] } }) const chartRef ref(null) let chart onMounted(() { chart echarts.init(chartRef.value) render() }) watch(() props.metrics, render, { deep: false }) function render() { if (!chart) return const epochs props.metrics.map(m m.epoch) const boxLoss props.metrics.map(m m.box_loss) const valBoxLoss props.metrics.map(m m.val_box_loss) const map50 props.metrics.map(m m.map50) chart.setOption({ tooltip: { trigger: axis }, legend: { data: [box_loss, val_box_loss, mAP0.5] }, xAxis: { type: category, data: epochs, name: epoch }, yAxis: [ { type: value, name: loss }, { type: value, name: mAP, scale: true } ], series: [ { name: box_loss, type: line, smooth: true, data: boxLoss }, { name: val_box_loss, type: line, smooth: true, data: valBoxLoss }, { name: mAP0.5, type: line, smooth: true, yAxisIndex: 1, data: map50 } ] }) } /script双 y 轴是这里的细节loss 和 mAP 量纲不同loss 一般在 0~10 之间mAP 是 0~1放同一根轴会让 mAP 曲线被压成一条贴地平线。yAxis数组定义两根轴mAP 的 series 通过yAxisIndex: 1挂到第二根轴上。训练过程里 loss 的大幅波动是正常的设置smooth: true是为了展示趋势但真要看细节应该把 tooltip 打开鼠标悬停能看每个 epoch 的具体数值。轮询接口用 setTimeout 递归而不是 setIntervallet timer null async function pollTraining(jobId) { const { data } await http.get(/train/metrics/${jobId}) metrics.value data.metrics timer setTimeout(() pollTraining(jobId), 3000) } function stopPolling() { clearTimeout(timer) }setInterval 的一个隐蔽问题是如果一次请求耗时超过 3 秒训练机负载高时很容易定时器会在上一次请求还没返回时就再次触发造成请求堆积接口压力翻倍。setTimeout 递归是“下一次定时从上一次返回后开始计时”天然避免了这个问题。轮询停止时clearTimeout只清掉了当前调度中的那一次如果还有上一次请求在飞行中前端需要加AbortController才能彻底取消——大多数内部工具场景里页面销毁时不再触发下一次定时就足够。5. 联调和部署时真正卡住人的三个细节5.1 CORS 配置开发环境与生产环境要分开前端跑在 Vite 的 5173 端口后端跑在 Flask 的 8000 端口浏览器跨域拦截第一个来。开发环境用 Flask-CORS 放开本机来源就行from flask_cors import CORS CORS(app, resources{ /api/*: {origins: [ http://localhost:5173, http://127.0.0.1:5173 ]} })生产环境如果前后端走同一个 nginx 域名同源情况下根本不需要 CORS。origins用白名单而不是*的原因是训练启动和停止接口一旦暴露任何人都能往你的训练机上塞任务、杀进程GPU 资源等于裸奔。CORS 白名单是安全基线的一部分不是可选项。5.2 Vue 打包后刷新 404 与资源路径npm run build生成 dist部署到 nginx 子路径时vite 默认的base: /会把 JS、CSS 资源路径写成根路径浏览器请求/assets/xxx.js时 404。改成相对路径// vite.config.js export default defineConfig({ base: ./, })如果前端用了 vue-router 的 history 模式刷新子路由页面时 nginx 会返回 404因为服务器上不存在/training这样的物理文件。加一条 try_files 回退location / { try_files $uri $uri/ /index.html; }这条规则的意思是把所有匹配不到静态文件的请求都交给index.htmlvue-router 接管后再按路由渲染对应组件。Vue 打包后布局异常多数情况下不是代码问题而是这两处路径配置。5.3 模型加载时长与半精度推理检测接口第一次请求耗时远高于后续请求这是懒加载模型的必然结果。改进方式是服务启动时后台预热threading.Thread(targetget_detector, daemonTrue).start()启动时后台加载接口先返回“模型加载中”的提示等权重读完后续请求才进入正常推理路径。前端配合一个modelReady状态字段加载完成前按钮置灰避免用户在 4 秒空白期反复点击。YOLOv5 在 GPU 上可以把模型切成半精度推理det_model.half()半精度能把显存占用压到接近一半推理时间通常下降 20% 到 30%但对精度有轻微影响mAP 通常掉 0.1~0.3 个点。做目标检测演示系统可以接受做精度要求高的业务不建议。CPU 推理不要用 half很多算子没有 CPU 半精度实现会回退到 fp32速度反而更慢。接口联调验证用 curl 看 JSON 结构最直接curl -X POST http://localhost:8000/api/detect \ -F imagebird.jpg \ | python3 -m json.tool响应里是xmin、ymin、xmax、ymax、name、confidence六元素的检测数组前端拿到这个结果就能直接进 Canvas 画框。本文还有配套的精品资源点击获取
返回列表