
简介目标检测Web部署Flask框架项目面向希望将YOLOv5模型用于实际Web服务的开发者解决从模型训练到在线推理的工程化衔接问题。项目完整演示了加载预训练权重、接收用户上传图片、执行目标检测并返回JSON结果的完整流程配套简洁的HTML上传页面特别适合课程设计、毕业设计或初学者入门模型部署。资源共15个文件压缩包仅187KB主要包含4个Python脚本Flask主服务、RESTful API、请求测试与推理测试、前端模板与样式、Dockerfile容器化配置、依赖清单和详细说明文档。已有712人学习下载适合作为快速搭建检测服务的微型参考项目。阅读源码可掌握PIL图像处理、PyTorch Hub加载模型、Flask路由设计等技巧Dockerfile与测试脚本则提供了从本地调试到容器部署的完整思路便于后续扩展GPU加速或云服务上线。1. YOLOv5 本地推理能跑不算完Web 部署才是那道分水岭目标检测的文档这两年很多YOLOv5 的训练、数据标注、超参数调优攻略一搜一大把但“把 YOLOv5 接入 Web 服务并用 Flask 暴露成接口”能直接落地的完整资源却不多。这份资源恰好补上了这一段既有 app.py 这种页面上传版也有 restapi.py 这种纯 JSON 接口版还带 index.html 前端、test_inference.py 本地推理脚本、tests_scripts/test_request.py 接口自测脚本外加一个可以直接 build 的 Dockerfile。我复现下来最大的感受是模型加载方式、置信度阈值、文件上传的 key 名任何一个细节没对齐第一次请求就会直接 500。这套资源适合两类人——刚训完自定义数据集、想快速把模型包装成内部工具的算法工程师以及接到“把模型挂到网页上”需求、不想从零搭框架的 Python 后端。下面按我实测过的顺序展开踩过的坑都写在避坑那一章里照着做基本能一次跑通。2. 先让 zidane.jpg 出框单图推理与模型加载的两条路径2.1 环境准备依赖版本锁定的第一道坎拿到资源先把环境搭出来。这份资源自带 requirements.txt但里面的依赖版本在不同年份差异很大我一般不会直接裸装而是先建虚拟环境、再把关键版本锁死。原因很简单目标检测依赖里最容易出问题的就是 torch 和 torchvision 的版本配对两者不匹配时通常是在 import 阶段直接报错和业务代码一点关系都没有排错特别憋屈。我习惯用虚拟环境隔离避免把系统 Python 环境搞乱。python -m venv .venv source .venv/bin/activate # Windows 则执行 .venv\Scripts\activate pip install --upgrade pip pip install -r requirements.txtrequirements.txt 我常用的锁定版本资源自带的文件可以在此基础上放宽flask3.0.3 torch2.2.2 torchvision0.17.2 pillow10.0.0 numpy1.24.0 requests2.31.0 opencv-python4.8.0这段命令做两件事创建隔离环境然后按锁定版本安装依赖。把 torch 锁成 2.2.2、torchvision 锁成 0.17.2是因为这对组合在 Python 3.8 到 3.11 上都很稳定CPU 版和 CUDA 版的轮子都好找。如果你机器上有 GPU 且要上 CUDA把 torch 那行换成torch2.2.2cu118这类带后缀的版本即可业务代码一行不用动。torchtorchvision推荐 Python1.13.10.14.13.7 - 3.102.0.10.15.23.8 - 3.112.2.20.17.23.8 - 3.11另外别忽略 opencv-python。虽然推理主流程用的是 PIL但 results.render() 画框时会用到它缺了会在保存标注图那一步才报错反而更难定位。2.2 test_inference.py 逐行拆解阈值参数直接决定接口效果环境就绪后先进单图推理验证模型本身能不能用。资源里的 test_inference.py 就是干这个的测试图 zidane.jpg 是一张多人合影有遮挡、有近景人脸用来验证目标检测是足够的。重点不是跑通脚本而是看懂脚本里哪些参数会影响后续 Web 服务的行为。import torch from PIL import Image # 首次执行会自动拉取 yolov5 源码和 yolov5s.pt 权重 model torch.hub.load(ultralytics/yolov5, yolov5s, pretrainedTrue) model.conf 0.25 # 置信度阈值低于 0.25 的检测框直接丢弃 model.iou 0.45 # NMS 的 IoU 阈值控制重叠框保留程度 img Image.open(zidane.jpg).convert(RGB) results model(img) # 支持 PIL、numpy 数组、本地路径、URL df results.pandas().xyxy[0] print(df.to_string()) results.render() # 在原图上画框 Image.fromarray(results.ims[0]).save(zidane_out.jpg)这里最关键的是 model.conf 和 model.iou 两个阈值。conf 控制“多确定才算数”调太低会冒出一堆置信度 0.2 的误检框调太高又容易漏掉远处的小目标iou 控制 NMS 去重强度行人密集场景下如果框叠得起飞可以试试把 iou 降到 0.35。这两个参数在模块级设置一次后面所有 Web 路由都会继承不需要每个请求重设。参数典型值作用conf0.25置信度阈值低于该值的目标不显示iou0.45NMS 去重强度控制重叠框保留数量max_det300单张图片最多返回的检测框数量classes[0, 2] 或 None只返回指定的 COCO 类别索引再补充一个常见坑model(img) 传 PIL 对象没问题但别直接传 f.stream 这种文件流torch.hub 版本的模型不会自动帮你解析流对象接口层必须先转成 PIL 或数组。另外 df 的坐标列名是 xmin、ymin、xmax、ymax单位是像素对应原图尺寸而不是缩放到 640 推理尺寸之后的坐标前端画框可以直接用不需要做反变换。2.3 从在线权重切到本地权重换自己数据集模型的关键torch.hub.load 在线方式方便但有两个现实问题一是首次访问要连 GitHub网络波动时直接卡住服务根本起不来二是生产环境希望权重文件跟着项目走而不是散落在用户目录的 torch hub 缓存里。我一般在一开始就把模型切成 custom 本地加载代码改动也不大import torch model torch.hub.load( ultralytics/yolov5, custom, pathweights/yolov5s.pt, # 指向项目内权重文件 force_reloadTrue )custom 模式读的是你指定的 pt 文件不再依赖网络可达性。把自己训练的结果接进来也走这条路径把 path 换成 runs/train/exp*/weights/best.pt 就行app.py 里其余代码完全不用动。这也回应了一个高频问题yolov5 训练自己的数据集之后怎么部署——答案就是换 best.pt其他都是细节。换成 yolov8s.pt、yolov11s.pt 这类后续版本的权重custom 加载的逻辑同理区别只在 pt 内部结构的字段名不影响 Flask 这一层。3. Flask 把推理包成接口从上传图片到 JSON 返回3.1 app.py 核心路由模型全局加载一次请求只走推理单图推理验证通过下一步就是把 model 装进 Flask。这套资源设计了两个入口app.py 面向浏览器用户restapi.py 面向程序调用。两个文件共享同一套推理逻辑差别只在请求解析和响应格式。先说 app.py。选择 Flask 的原因很朴素这个场景不需要 Django 的 admin 后台、ORM、中间件那一整套只需要“接收图片、调模型、返回结果”三件事Flask 的微内核能省掉大量无关概念部署文件也少。项目里带 templates/index.html 和 static/style.css说明它走的是 Flask 默认的模板渲染方式后端渲染页面、前端发请求职责清楚。from flask import Flask, request, jsonify, render_template import torch from PIL import Image app Flask(__name__) # 模型放模块级变量进程启动时加载一次避免每个请求重复初始化 model torch.hub.load(ultralytics/yolov5, custom, pathweights/yolov5s.pt) model.conf 0.25 model.classes None # None 表示检测全部 COCO 80 类 app.route(/, methods[GET]) def index(): return render_template(index.html) app.route(/detect, methods[POST]) def detect(): f request.files.get(image) if f is None or f.filename : return jsonify({success: False, error: missing image}), 400 img Image.open(f.stream).convert(RGB) results model(img) records results.pandas().xyxy[0].to_dict(orientrecords) return jsonify({success: True, detections: records}) if __name__ __main__: app.run(host0.0.0.0, port8000, debugFalse)这段代码值得反复读的部分有三个。第一model 定义在模块级Flask 进程启动时只执行一次这决定了服务的首请求延迟和后续请求延迟差好几倍如果把它挪进 detect() 函数里每次请求都重新加载模型慢不说热加载还容易把显存搞爆。第二request.files.get(image) 用 get 而不是下标取值配合判空逻辑文件缺失时返回 400 而不是崩成 500。第三Image.open(f.stream).convert(RGB) 强制转 RGB这是为了把灰度图、带透明通道的 PNG 统一成模型能接受的三通道格式。响应里的 detections 是一个列表每个元素是包含 xmin、ymin、xmax、ymax、confidence、class、name 的字典本身就是 JSON 可序列化结构jsonify 直接就能处理。前端拿到这份数据后可以在 canvas 上自己画框也可以调 /annotated 拿后端渲染好的图。3.2 前端表单Flask 是怎么绑定到网页元素的index.html 看起来简单但最容易翻车的地方全在表单里。很多人会把接口写成 /detect前端 form 的 action 却写成 /或者 file input 的 name 跟后端 request.files.get() 的参数对不上结果前端看着发出去了后端却拿不到文件。Flask 表单绑定逻辑其实只有三条规则action 对应后端路由、method 必须是 post、input 的 name 必须等于后端取的 key。!DOCTYPE html html head meta charsetutf-8 link relstylesheet href{{ url_for(static, filenamestyle.css) }} /head body h1YOLOv5 目标检测/h1 form action/detect methodpost enctypemultipart/form-data input typefile nameimage acceptimage/* required button typesubmit开始检测/button /form /body /htmlenctypemultipart/form-data 是关键中的关键。缺了这一行浏览器会把图片内容以普通文本字段提交Flask 端的 request.files 就是空的接口直接 500。接受图片的 input 加 acceptimage/* 可以过滤掉大部分非图片文件但后端判空逻辑依然要有因为手工构造的 POST 请求不会理会前端限制。如果想在页面里直接显示画框图我通常会多加一个 /annotated 路由后端渲染完存成 JPEG 字节返回前端用 fetch 拿 blob 塞进 img src比前端手动在 canvas 上画矩形省事得多import io from flask import Response app.route(/annotated, methods[POST]) def annotated(): f request.files.get(image) img Image.open(f.stream).convert(RGB) results model(img) results.render() buf io.BytesIO() Image.fromarray(results.ims[0]).save(buf, formatJPEG) return Response(buf.getvalue(), mimetypeimage/jpeg)这段代码展示了 Flask 传文件的另一种形态不进 JSON直接回图片字节。mimetype 指定成 image/jpeg 后浏览器能直接把响应解析成图片前端 img 标签的 src 指向这个接口返回的 blob 或接口地址都可以。3.3 restapi.py纯 JSON 接口适合机器调用如果调用方不是浏览器而是另一个服务用表单上传就不太合适了更常见的做法是 base64 编码的图片字符串放进 JSON body 一起传。restapi.py 走的就是这条路。它和 app.py 的差别只在请求解析一个读 request.files一个读 request.get_json()。import base64 import io from flask import Flask, request, jsonify from PIL import Image app Flask(__name__) model load_model() # 复用上一节的模型加载逻辑 app.route(/detect, methods[POST]) def detect_json(): data request.get_json(forceTrue) img_b64 data.get(image) if not img_b64: return jsonify({success: False, error: missing image}), 400 raw base64.b64decode(img_b64.split(,)[0]) img Image.open(io.BytesIO(raw)).convert(RGB) results model(img) return jsonify({ success: True, detections: results.pandas().xyxy[0].to_dict(orientrecords) })split(,) 是为了兼容 data URI 格式的 base64 字符串前端 canvas.toDataURL() 生成的就是带 data:image/jpeg;base64, 前缀的去掉前缀再解码能少踩一个格式坑。get_json(forceTrue) 会把请求体强制按 JSON 解析即使没带 Content-Type 头也能工作联调时很方便但生产环境还是建议调用方规范地带上 Content-Type。接口返回结构和 app.py 保持一致这样同一套前端代码既能接文件版也能接 JSON 版迁移成本为零。4. 部署避坑清单五个高频问题的现象、原因与处理把整个流程串起来跑一遍并不难真正花时间的是运行时各种奇怪报错。下面五条是我拆这套资源时实打实踩过的坑每条按“现象、原因、解决”记录基本覆盖第一次部署会遇到的问题。4.1 模型加载卡下载、显存爆问题都出在加载方式上现象一服务启动后没有任何报错但第一次 POST 请求迟迟不返回过几分钟后超时或者日志里出现 Downloading ultralytics/yolov5 后卡住不动。原因torch.hub.load 首次执行会检查本地有没有缓存没有就去 GitHub 拉源码和权重网络不稳定时整个请求线程挂在下载上。这不是接口代码的问题而是模型加载路径选错了。解决把权重下载到项目目录用 2.3 节的 custom 方式加载把拉取环节挪到 Docker 构建阶段或启动前的初始化脚本里。更稳的做法是启动时打印一条 Model loaded 日志看到这行字再对外提供服务。现象二GPU 机器上单张推理没问题并发一上来就报 RuntimeError: CUDA out of memory服务直接断掉。原因模型在模块级只加载一次但每个请求都会在前向传播时分配临时显存更隐蔽的是 debugTrue 会启动 reloader 双进程模型实际被加载了两遍显存直接翻倍。解决生产环境把 debugFalse 作为底线worker 数量按显存估算2GB 显存跑 yolov5s 时 gunicorn 最多开两个 worker。临时显存会在请求结束后释放但别指望它立刻回到初始值PyTorch 的缓存分配器会留着复用监控显存时要把这一点算进去。4.2 接口 500数据格式问题占了大头现象三前端上传图片接口返回 500Flask 日志显示 request.files 为空或 None。原因十次里有九次是 form 表单漏了 enctypemultipart/form-data还有一种情况是 input 的 name 写成了 file而接口读的是 imagekey 对不上。解决先查表单三项——action 指向 /detect、method 是 post、enctype 是 multipart/form-data。再用 curl 绕过前端验证后端本身curl -X POST -F imagezidane.jpg http://localhost:8000/detect。如果 curl 通而页面不通问题在前端如果 curl 也 500问题在后端解析。现象四图片上传成功但后端报 ValueError: image has no data 或者 OSError: image file is truncated。原因model() 只认 PIL、numpy 数组和本地路径直接传文件流会解析失败另外带透明通道的 PNG 用 PIL 打开是 RGBA 四通道YOLOv5 的预处理按 RGB 三通道处理通道数不一致直接报错。解决统一在入口做Image.open(f.stream).convert(RGB)一行解决灰度图、RGBA、损坏图三类问题。如果图片本身损坏在 Image.open 外面包 try/except捕获异常后返回 400而不是让异常冒泡成 500。4.3 网络与性能监听地址、防火墙与并发容量现象五服务在服务器上跑起来了日志显示 Running on http://0.0.0.0:8000但其他机器就是访问不了本机 curl 却很正常。原因两种情况最常见——一是 app.run 里 host 写的 127.0.0.1只监听回环地址二是服务器防火墙没放行 8000 端口。前者是程序问题后者是环境问题单看日志里那行 Running on 分辨不出来。解决先在本机curl http://127.0.0.1:8000/确认服务活着再从另一台机器 curl 服务器内网 IP 或公网 IP。监听地址固定写 0.0.0.0端口用环境变量接管port int(os.environ.get(PORT, 8000))部署时随时能改。防火墙规则在云厂商控制台的安全组里配置每家的入口不一样这个没法用一套命令解决。5. Docker 化与接口自测上线前把最后一公里走完5.1 Dockerfile 怎么读镜像分层与权重挂载本地能跑通就该考虑把它变成可交付的东西。Docker 在这里的价值不是炫技而是把 Python 版本、pip 依赖、模型权重固化成一份可复现的镜像免得半年后换台机器重新踩一遍环境坑。资源里自带的 Dockerfile 结构非常标准我拆给你看FROM python:3.9-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 8000 CMD [python, app.py]这个文件一共七行每个指令都值得细究。python:3.9-slim 比全量版小一大截yolov5s 推理用不到编译工具链slim 足够COPY requirements.txt 单独放在一层是为了利用 Docker 分层缓存依赖文件没变时重建镜像不会重新执行 pip install最后 CMD 直接起 Flask适合先验证镜像通不通真要上线建议换成 gunicorn。构建和启动命令如下docker build -t yolov5-flask . docker run -d --name yolo8000 -p 8000:8000 yolov5-flask docker logs -f yolo8000关于权重文件的处理我倾向于不把 weights/ 打进镜像而是在 run 时挂载进去docker run -d --name yolo8000 -p 8000:8000 \ -v /opt/models/weights:/app/weights \ yolov5-flask这样换模型权重只需要替换宿主机目录里的 pt 文件再重启容器不用重新 build。模型权重与镜像本体解耦后一套镜像可以在不同任务间复用多迭代几次省下来的带宽和时间相当可观。如果需要 GPU宿主机要装 nvidia-container-toolkit然后启动命令加 --gpus alldocker run --gpus all -p 8000:8000 \ -v /opt/models/weights:/app/weights \ yolov5-flask这条命令在有 GPU 的机器上会把 CUDA 设备映射进容器镜像内部不需要再装 CUDA只要 torch 是带 CUDA 编译的版本就行。没有 GPU 的机器老老实实跑 CPU 版yolov5s 单张 640 大概两三百毫秒到一秒不等看具体机器。树莓派这类 ARM 设备上也能跑但要先确认 torch 有没有对应架构的轮子省事一点的做法是直接在 pytorch 官方支持列表里挑型号。5.2 test_request.py 冒烟与并发压测先确认能用再谈性能Docker 起来之后第一时间不是点开浏览器而是跑一遍资源里自带的自测脚本。tests_scripts/test_request.py 模拟最真实的请求链路构造 multipart 文件上传、发 POST 到 /detect、解析 JSON 响应。我把它浓缩成最小可用版本import requests import time url http://localhost:8000/detect with open(zidane.jpg, rb) as fp: files {image: fp} t0 time.time() resp requests.post(url, filesfiles, timeout30) cost round(time.time() - t0, 3) print(耗时(秒):, cost) print(状态码:, resp.status_code) data resp.json() print(检测目标数:, len(data.get(detections, [])))这个脚本的价值有三点验证路由和文件 key 名、验证返回结构、记录基线耗时。我一般把所有接口改动做完后都会跑一遍它然后再进 UI 验证。注意 timeout 至少要给 30 秒因为首请求如果没做 warm-up模型初始化和权重加载都算在这段时间里timeout 给太短会误报超时。接下来可以顺手做一个并发冒烟测试不严谨但能快速暴露明显的容量问题from concurrent.futures import ThreadPoolExecutor import requests url http://localhost:8000/detect def send(_): with open(zidane.jpg, rb) as fp: r requests.post(url, files{image: fp}, timeout30) return r.status_code with ThreadPoolExecutor(max_workers4) as pool: codes list(pool.map(send, range(8))) print(状态码分布:, {c: codes.count(c) for c in set(codes)})这段代码起了 4 个线程连续打 8 次请求最终打印状态码分布比如 {200: 8}。它的意义不在测 QPS而在验证服务在并发下还能不能保持 200——如果出现 500 或 503通常说明模型推理线程不安全或者 worker 数不够。真要压测换 locust 或 hey 这类专门工具测出 p95 和吞吐量再做容量规划。开发服务器的并发能力有限我上线前会把启动命令换成 gunicorngunicorn -w 2 -b 0.0.0.0:8000 app:app-w 是 worker 数-b 是监听地址app:app 是“模块名:Flask 实例名”。worker 数别贪多CPU 机器上 2 到 4 个够用GPU 机器上按显存定跑几轮压测看显存占用再调。worker 太多反而会因为 GIL 和显存争抢拉低整体吞吐。如果前端还有 nginx 要用同一个端口反代多个 Web 系统把每个服务拆成独立容器、按路径转发比硬塞进一个进程干净得多。6. 接口升级的最后一招批量推理与 Warm-up6.1 批量推理一次请求处理多张图单图接口在监控、审核这类场景下够用但上游如果是一次传一个图片包逐张请求的 HTTP 开销就会成为瓶颈。YOLOv5 模型本身支持 batch 推理把图片列表直接传给 model 就行imgs [Image.open(p).convert(RGB) for p in [a.jpg, b.jpg, c.jpg]] results model(imgs) for i, det in enumerate(results.xyxy): print(f第{i 1}张图检测到 {len(det)} 个目标)batch 推理的主要收益是并行计算复用尤其是 GPU 上3 张图一起算通常比 3 次单张快一倍以上。batch size 不是越大越好显存占用随 batch 线性增长CPU 上内存同理。我一般从 2 开始翻倍试找到耗时开始陡增的那一档再退回来。这个值跟机器配置强相关属于典型的“自己跑一轮才知道”的区间看任何理论分析都不如实测准。6.2 Warm-up把冷启动的首请求尖峰抹平还有一个容易被忽略的点是冷启动。服务刚启动时第一次请求要承担模型权重加载、CUDA 上下文初始化等开销监控图上那个刺眼的尖峰通常就是它。规避方法是在服务启动前喂一张纯色图走一遍前向# 服务启动时执行一次把权重加载和 CUDA kernel 初始化提前完成 model(Image.new(RGB, (640, 640), (128, 128, 128)))这行代码放在 app Flask(name) 之后即可不引入任何额外依赖。效果是首请求延迟从数秒级降到百毫秒级监控曲线平滑很多用户第一次点上传也不会以为服务挂了。我第一次给团队做目标检测服务时忘了做 warm-up结果上线当天第一个真实用户等了三秒多才看到结果监控面板上一片飘红。从那以后每次改完模型或依赖我都强制走一遍固定流程先跑 test_inference.py 看单图效果再起服务用 test_request.py 打验证请求最后用批量图和 warm-up 过一遍稳定性。这套流程救了我很多次也希望能帮到你。本文还有配套的精品资源点击获取