简介:一份基于YOLOv9与Flask框架构建的目标检测Web应用完整项目源码,面向具备一定Python基础、希望将深度学习模型落地为Web服务的开发者。项目完整展示了从模型调用、后端接口设计到前端交互展示的链路,可直接运行体验,也可作为课程设计或生产项目二次开发的模板。压缩包内共1882个文件,包体约22.26MB,以js、css、svg、ts等前端资源为主,用于支撑管理后台与检测界面;py为Flask后端核心逻辑,html与json提供页面与配置,mp4可用于辅助了解实际运行效果。源码模块划分清晰,包含模型加载、请求处理、结果渲染等关键部分,并附有必要的配置与文档,便于理解YOLOv9如何与Web框架集成。目前已有116人学习下载,对于需要实践目标检测应用或掌握深度学习Web部署的开发者,是一份兼顾完整性与规范性的优质实战资源。
1. 用YOLOv9+Flask构建目标检测Web应用:先看懂这套东西在解决什么问题
把 YOLOv9 权重文件塞进 Flask 接口,看起来只是一行model(img)的事。但真要把一个"目标检测应用"跑成稳定可用的 Web 服务,要处理的不只是模型:加载时机、图片预处理、NMS 参数、坐标还原、并发下的线程安全,每一项都能让服务在安静运行一整天后突然挂掉。这个标题指向的正是一类可以拿过来直接改、直接部署的 YOLOv9 + Flask 项目源码包,它把模型推理、图片上传接口和前端展示串成了闭环。适合两类人:一是想给已有检测模型快速做 Web 演示界面的算法工程师,二是要接手检测类 Web 项目的后端开发。下文按我改造这类项目源码包时的真实顺序来写,从选型逻辑讲到接口代码,再到前后端联动、踩坑记录和性能验证。
2. 为什么项目源码里选YOLOv9而不是YOLOv8:模型差异与部署形态
2.1 从GELAN到PGI:YOLOv9比YOLOv8改了什么
YOLOv9 在 2024 年初发布时,最核心的两个结构变量是 GELAN 和 PGI。GELAN 是主干网络的特征聚合结构,它把不同层的特征以更高效的方式融合;PGI(可编程梯度信息)则是在训练阶段多构造了一条梯度提供路径,让浅层网络也能拿到足够强的监督信号。对做 Web 部署的人来说,论文细节可以后补,但有三点必须理解到位。
第一,PGI 带来的精度增益主要发生在训练阶段,推理时主体模型不变,没有额外分支,所以权重体积和推理耗时并没有被辅助结构放大。这是它能被选进服务端项目的前提。第二,YOLOv9 依然是 PyTorch 体系的模型,官方仓库提供.pt权重,Python 后端可以直接加载,不需要额外搭一套推理框架。第三,与同期的 YOLOv8 相比,YOLOv9 在相同参数量级下拿到了更高的 COCO mAP,简单说就是用同样的硬件跑推理,精度上限更高。
一个容易混淆的点:YOLOv9 不在 Ultralytics 的 YOLO 生态里,它的官方代码用独立的models、utils目录组织。这导致你拿到源码包后,看到的通常是相对导入形式的 Python 库,而不是一行from ultralytics import YOLO。这也解释了为什么很多项目源码包会自带一份完整的检测库代码,而不是用 pip 包替代。下面第三章的代码就按这种结构写,如果你手里的包是 Ultralytics 封装版,只需把入口替换成YOLO类的预测调用,处理逻辑完全一致。
2.2 YOLOv9-C还是YOLOv9-E:源码包预置权重怎么选
这类项目源码包通常会带两个预置权重:yolov9-c.pt和yolov9-e.pt。C 是 Compact 版本,E 是 Extended 版本,两者都是 COCO 80 类预训练。选哪个取决于你的运行环境和检测场景,我一般按下面这张表来定。
| 权重 | 参数量级 | 精度特点 | 推理代价 |
|---|---|---|---|
| yolov9-c.pt | 比 E 小一半以上 | mAP 中上,均衡型 | 有独显可以跑到准实时 |
| yolov9-e.pt | 明显更大 | mAP 上限更高 | 单帧推理耗时约为 C 的两倍 |
如果服务跑在纯 CPU 机器上,直接用 E 版本是常见的翻车操作。我在 CPU 上测过同一张 640 分辨率图片,C 版本耗时还能接受,E 版本直接翻倍,并发一上来请求就排队。反过来,如果服务器有一块像样的 NVIDIA 显卡,E 版本的精度收益是值得的。
还要注意权重与业务领域的匹配。源码包默认是 COCO 的 80 类权重,适合人、车、狗、杯子这类通用目标。如果你要检测的是遥感图像里的建筑物,或者红外小目标数据集里的亮点目标,COCO 权重完全不能用,必须换成自己训练的权重,并把model.names改成你的类别表。否则接口返回的 label 序号全部错位,前端画出来的名字和真实物体对不上,而且你很难排查出来。
2.3 从pt到onnx:确定你的推理运行时
weights目录下放的是.pt还是.onnx,直接决定了项目的部署方式,这是我拿到源码包第一个检查的地方。
如果只有.pt,推理路径依赖 PyTorch,项目里requirements.txt通常会有torch、numpy、opencv-python、pillow、flask这几项。这种方式的优点是调试容易,模型加载、预处理、后处理全程可以用 Python 单步排查,和训练环境完全一致;缺点是 PyTorch 库本身比较大,在纯 CPU 的服务器上内存占用偏高。
如果包里有.onnx,推理可以脱离 PyTorch,改用onnxruntime。CPU 场景下内存占用明显下降,启动也快很多。但 ONNX 版本要确认两个问题:它是否是固定输入尺寸,导出时是否带了 NMS 节点。固定尺寸意味着你每次只能传和导出时一样的分辨率,不能动态缩放;如果没带 NMS,后处理仍要自己写。我的建议是先把.pt路径跑通,再考虑 ONNX,两条路并存能让排查问题时的选择更灵活。
3. 把YOLOv9模型装进Flask:从上传图片到返回检测框的完整代码
3.1 先看清源码包目录:五个必须检查的东西
拿到这类源码包,我一般先按目录结构过一遍,确认五个关键位置。常见布局如下,不同包的具体目录名会有出入,但职责划分基本一致。
yolov9-flask-app/ ├── app.py # Flask 入口,路由与页面渲染 ├── detector.py # 推理封装:加载、预处理、NMS、画框 ├── weights/ │ └── yolov9-c.pt # 预训练权重,也可能是 .onnx ├── templates/ │ └── index.html # 前端上传与展示页面 ├── static/ │ ├── uploads/ # 用户上传的原图 │ └── results/ # 标注后的图片 ├── requirements.txt # Python 依赖清单 └── README.md # 项目说明先看requirements.txt,确认 PyTorch 版本是否和你的 CUDA 版本匹配。没有 GPU 的机器直接装 CPU 版torch就行,不用硬上 CUDA 版。再看weights目录下有没有权重文件,很多源码包为了控制体积不会附带权重,只写一句"下载后放入此目录",这一步缺失会导致应用一启动就报找不到模型。
最后确认app.py里模型是在模块顶层加载的,还是放在了请求函数内部。如果是后者,项目大概率是给教学演示用的,生产级改造要先从这一步动手。前端这块,templates和static决定了页面长什么样,实际业务里很多人会直接替换成自己的页面。
3.2 模型加载与预处理:放在全局区还是每次请求都加载
模型加载位置是这份代码的第一个关键决策。请把模型加载放在模块顶层,而不是放到 Flask 路由函数里。路由函数里的代码每次请求都会执行,如果里面带了attempt_load,等于每个用户访问时都重新读一遍几百 MB 的权重,第一个请求能把人等疯。
# detector.py import torch import numpy as np from pathlib import Path from PIL import Image from models.experimental import attempt_load from utils.augmentations import letterbox from utils.general import non_max_suppression, scale_boxes # 权重文件位置按你的目录结构调整 WEIGHTS = Path("weights/yolov9-c.pt") DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") # 模块加载时执行一次,进程生命周期内不再重复加载 model = attempt_load(WEIGHTS, device=DEVICE) model.eval()代码说明:attempt_load是 YOLO 系列代码库里的通用加载函数,它能读取权重并自动构建对应网络结构。放在模块顶层意味着 Flask 进程一启动,模型就被加载进内存,第一个请求进来时可以直接推理,省掉了冷启动等待。model.eval()必须调用,否则模型里的 Dropout 和 BatchNorm 行为会进入训练模式,推理结果不稳定。
预处理部分同样要单独抽成函数。YOLOv9 默认输入是 640×640,但用户上传的图片几乎不可能正好是这个尺寸。直接resize会拉伸图像,导致小目标框偏移,所以要用 letterbox 等比缩放,多余部分用灰边填充。
def preprocess(image: Image.Image, img_size=640): # 统一转 RGB,去掉 alpha 通道,避免 RGBA 图片在 tensor 转换时报维度错误 img = np.array(image.convert("RGB")) # 等比缩放 + 灰边填充,stride=32 对应模型下采样倍数 img, ratio, (dw, dh) = letterbox(img, new_shape=img_size, stride=32) # OpenCV 系模型用 BGR,PIL 读出来是 RGB,必须翻转通道 img = img[:, :, ::-1].transpose(2, 0, 1) # HWC -> CHW img = torch.from_numpy(np.ascontiguousarray(img)).float() / 255.0 return img.unsqueeze(0).to(DEVICE), ratio, (dw, dh)这里有两个细节值得展开。第一是通道顺序,YOLO 系列训练时用 OpenCV 读图,喂给模型的通道顺序是 BGR,而 Pillow 读出来是 RGB。不翻转通道,模型照样能跑,但检测类别会错乱,比如把狗识别成猫。第二是letterbox返回的ratio和(dw, dh),它们记录了缩放比例和灰边尺寸,推理出的坐标必须靠这两个值还原回原图尺寸,这个环节的坑在第四章展开。
3.3 推理接口与NMS参数:conf与iou阈值怎么设
Flask 接口是整个服务的核心入口。下面这段代码实现了"接收图片 → 预处理 → 推理 → NMS → 返回 JSON"的完整链路。
# app.py from flask import Flask, request, jsonify, render_template from PIL import Image from detector import model, preprocess from utils.general import non_max_suppression, scale_boxes app = Flask(__name__) @app.route("/") def index(): # 渲染前端页面,表单上传指向 /api/detect return render_template("index.html") @app.route("/api/detect", methods=["POST"]) def detect(): file = request.files.get("image") if not file: return jsonify({"code": 400, "msg": "no image"}), 400 img = Image.open(file.stream) tensor, ratio, pad = preprocess(img) with torch.no_grad(): pred = model(tensor)[0] # conf_thres 控制置信度,iou_thres 控制重复框抑制 det = non_max_suppression(pred, conf_thres=0.25, iou_thres=0.45) results = [] if det[0] is not None: for *xyxy, conf, cls in reversed(det[0]): # 把 640 特征图上的坐标映射回原图坐标 x1, y1, x2, y2 = scale_boxes( (tensor.shape[2], tensor.shape[3]), xyxy, (img.height, img.width), ratio, pad ) results.append({ "bbox": [round(float(v)) for v in (x1, y1, x2, y2)], "score": round(float(conf), 4), "label": model.names[int(cls)], }) return jsonify({"code": 0, "count": len(results), "results": results}) if __name__ == "__main__": app.run(host="0.0.0.0", port=5000)代码说明:request.files.get("image")取的是前端表单里name="image"的文件控件,名字写错会一直拿到None。Image.open(file.stream)直接读请求流,不用先存盘再读,少一次磁盘 IO。scale_boxes的参数顺序在不同源码版本里略有差异,以你包内utils/general.py的函数签名为准,核心用途是把 640 像素特征图上的框放大回原图坐标,并去掉 letterbox 灰边造成的偏移。
两个 NMS 参数直接影响页面上框的多少。conf_thres=0.25是通用值,置信度低于它的框直接丢弃,适合监控和通用检测场景;漏检代价高时调到0.1,代价是页面上会多出一堆置信度很低的干扰框;纯演示项目调到0.5,页面干净好看,但小目标容易漏。iou_thres=0.45是去重阈值,行人密集场景建议0.3,物体稀疏时0.5也不会误伤。如果 COCO 场景只想检测人,给non_max_suppression传classes=[0]就够了,既减少误检,也能省一点后处理时间。
3.4 前端页面联动:把JSON结果渲染成图片上的绿框
后端返回的是坐标和标签,不是一张画好框的图。把坐标变成用户能看到的绿框这一步,是前端要解决的问题。搜索引擎里经常有人问"flask 如何绑定到网页元素",本质就是这个环节:后端回传 JSON,前端 JS 根据坐标更新页面元素。
<form id="uploadForm" enctype="multipart/form-data"> <input type="file" name="image" accept="image/*"> <button type="submit">开始检测</button> </form> <img id="preview" src="" alt=""> <canvas id="overlay"></canvas>document.getElementById("uploadForm").onsubmit = async (e) => { e.preventDefault(); const form = new FormData(e.target); const resp = await fetch("/api/detect", { method: "POST", body: form }); const data = await resp.json(); drawBoxes(data.results); }; function drawBoxes(results) { const img = document.getElementById("preview"); const canvas = document.getElementById("overlay"); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; const ctx = canvas.getContext("2d"); ctx.strokeStyle = "lime"; ctx.lineWidth = 3; results.forEach(r => { const [x1, y1, x2, y2] = r.bbox; ctx.strokeRect(x1, y1, x2 - x1, y2 - y1); ctx.fillStyle = "lime"; ctx.font = "16px sans-serif"; ctx.fillText(`${r.label} ${r.score}`, x1, y1 - 6); }); }代码说明:canvas 的宽高直接用图片原始像素尺寸,避免 DPR 缩放导致框的位置偏移。fetch提交表单时不需要手动设置Content-Type,FormData会自动带上multipart/form-data边界。另一种方案是后端用 OpenCV 画完框,把图片转 base64 返回前端,实现更简单,但每次响应都要多传一整张图片。我的习惯是前端 canvas 渲染,响应体更小,批量检测场景性能更好。
4. 目标检测Web应用部署避坑:五个容易翻车的细节
4.1 现象:第一个请求等了几十秒才出结果
接手源码包后启动服务,第一次调用检测接口,等了半分钟才返回,后面再调就快了。原因:模型加载被放在了路由函数内部,或者 Flask 代码里用了@app.before_request之类的钩子做懒加载,每个进程的第一次请求都要现场读取权重。解决:把attempt_load移到模块顶层,进程启动时就完成加载。如果用了 Gunicorn 部署,还要确认 worker 是否配置了--preload,否则每个 worker 都会各自做一次懒加载,造成多倍等待。判断方法很简单,看日志里第一次请求前后有没有模型加载输出。
4.2 现象:上传大图直接报 413
用户传一张 10MB 的照片,接口立刻返回 413 Request Entity Too Large。原因有两层:Flask 层面没有设置MAX_CONTENT_LENGTH,理论上请求体大小没有上限,只取决于服务器;但很多项目跑在 Nginx 反代后面,Nginx 默认client_max_body_size是 1MB,超过就返回 413。解决:Flask 里在实例化后加一行app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 * 1024,Nginx 的server块里加client_max_body_size 16m;。注意 413 可能来自两层中的任意一层,看到报错先确认是 Flask 日志还是 Nginx 日志。
4.3 现象:高并发时检测结果张冠李戴
同时来几个请求,返回的框有时属于另一张图。这类问题最隐蔽,因为它不是必现。原因:代码里把中间变量放到了全局作用域,比如预处理时复用了同一个列表,或把模型输出写进了模块级变量。多线程下请求相互覆盖,A 请求写到全局的结果被 B 请求冲掉。解决:把推理过程全部封装成无状态函数,中间 tensor 一律局部变量。这就是第三章里preprocess函数内部所有变量都是局部的原因。如果排查完仍是这个问题,再看看是不是多个 worker 共用了同一块共享内存,这类情况建议直接改为进程隔离。
4.4 现象:GPU显存持续上涨,跑一天后OOM
推理用的 GPU 显存稳步上升,最后报CUDA out of memory。原因:推理时没有包torch.no_grad(),模型前向过程构建了计算图,显存被梯度信息占着不释放;另一种是每次请求都往 GPU 上拷贝模型,造成重复占用。解决:推理代码统一用with torch.no_grad(): pred = model(tensor),确保不求导、不建图。如果显存仍不下降,再考虑在请求结束后调用torch.cuda.empty_cache(),但这个函数会清理整个缓存池,频繁调用反而拖慢吞吐,只建议在显存压力大的场景里用。
4.5 现象:框画得偏右上,或者全部偏移一个固定距离
检测结果准,但画到原图上框的位置整体往右上角偏。原因:这是 letterbox 的锅。缩放原图时加了灰边,模型预测出的坐标是"加了灰边之后的图"上的位置,没有缩放回原图尺寸,也没减掉灰边偏移。解决:用scale_boxes统一还原,传对ratio和pad两个参数。很多人在这一步图省事,手动乘个缩放比就完事,遇到奇数尺寸的图片时灰边不相等,偏移就出现了。我的习惯是永远信任后处理工具函数,不要自己写坐标映射,除非你要处理的是旋转框,那得另外写一套。
5. 跑通之后怎么验证:从精度指标到接口响应时间的量化评估
5.1 用val.py在验证集上算mAP:确认权重和数据集是匹配的
项目能出框只是第一步,框得准不准是另一回事。如果你的源码包是从 YOLOv9 官方代码改造而来,一般会保留val.py验证脚本。在带标签的验证集上跑一轮 mAP,比肉眼看二十张图可靠得多。
python val.py --data coco.yaml --weights weights/yolov9-c.pt \ --img 640 --batch 8 --device 0参数说明:--data指向数据集配置文件,--img必须和接口里preprocess的img_size保持一致,否则算出来的 mAP 无法代表线上表现;--batch按显存调,8GB 显存跑 8 没问题,更大的 batch 对 mAP 没影响,只影响验证速度;--device 0是 GPU 编号,CPU 机器改成--device cpu。
读结果时重点看两个数:mAP50和mAP50-95。前者是 IoU 阈值 0.5 下的平均精度,后者是 0.5 到 0.95 的均值,更严格。COCO 预训练的 YOLOv9-C 权重,mAP50-95 在 0.5 以上是正常的,如果你手里的权重在这个值附近明显偏低,大概率是它只训了很少的 epoch,或者数据集配置没写对。如果你之前处理过 YOLOv8 的数据集,映射到本项目时要注意names列表顺序,YOLO 格式标签里的类别索引必须和权重训练时的顺序一致,这个错位不会报错,只会让 mAP 惨不忍睹。
5.2 用ab压测接口:耗时、吞吐与失败率
验证完精度,需要确认服务扛得住请求。常见做法是用 ApacheBench 做接口压力测试,它对 POST 文件上传的支持足够用。
ab -n 200 -c 10 -T 'image/jpeg' -p test_dog.jpg \ http://127.0.0.1:5000/api/detect参数说明:-n 200是总请求数,-c 10是并发数,-T指定上传文件的 Content-Type,-p指向测试图片。输出里看三个指标:Requests per second代表吞吐,Time per request (mean)代表平均单次延迟,Failed requests如果非零,说明并发下服务处理不过来,可能是超时,也可能是进程直接崩了。
几个基线参考:纯 CPU 机器上,640 输入、C 权重,并发 10 时 RPS 能到 2 以上就算正常,压测时 CPU 会跑满。如果 RPS 连 1 都不到,先尝试把preprocess的img_size从 640 降到 480,延迟能降一半左右。GPU 机器上 RPS 通常能到几十,瓶颈往往不在推理,而在 Flask 开发服务器的单进程能力,这时候直接看第六章的 Gunicorn 方案。
5.3 接口回归测试:用curl和脚本确认返回结构稳定
压测看的是性能,回归测试看的是正确性。每次改权重、改预处理逻辑之后,跑一组固定图片的断言脚本,能防止"模型换好了,接口悄悄崩了"的尴尬。
curl -X POST -F "image=@test_dog.jpg" \ http://127.0.0.1:5000/api/detect | python3 -m json.tool这条命令能快速检查返回 JSON 结构是否完整,code、count、results三个字段是否都在。更正式的回归用 Python 脚本写断言:
import requests resp = requests.post( "http://127.0.0.1:5000/api/detect", files={"image": open("test_dog.jpg", "rb")} ) data = resp.json() assert data["code"] == 0 assert data["count"] >= 1 for item in data["results"]: assert len(item["bbox"]) == 4 assert item["score"] > 0代码说明:断言count >= 1确保这张测试图至少检出一个目标,bbox长度校验防止坐标字段缺失,score范围校验防止 NMS 参数改坏后返回 0.0 的异常结果。换权重之前跑一遍,换完再跑一遍,两个版本的results做对比,如果类别分布差异很大,说明新旧权重的类别表不一致,接口的model.names要同步更新。这个脚本值得放进项目的 CI 里,哪怕只是手动执行,也能省掉很多半夜排查。
6. 从Demo走向生产:把Flask检测服务接到真实环境
6.1 用Gunicorn替换开发服务器
Flask 自带的开发服务器是 WERKZEUG 实现,单进程多线程,生产环境直接暴露它是个危险动作。常见做法是换 Gunicorn 跑多 worker。
gunicorn -w 2 -b 0.0.0.0:8000 --timeout 60 --preload app:app参数说明:-w 2表示 2 个 worker 进程;--preload让 app 在 fork worker 之前加载一次,模型只读进内存一次;--timeout 60防止模型首次推理超时被 Gunicorn 杀死。注意多 worker 意味着模型被复制到每个进程里,GPU 显存占用按 worker 数成倍上涨。我手滑开过 8 个 worker,静态加载阶段直接触发显存 OOM,后来把-w控制在 GPU 能容纳的范围内才算稳住。如果模型是全局单例,也不用刻意加锁,多进程模型下每个 worker 内部是串行推理,天然隔离。
6.2 从PyTorch推理换到ONNX Runtime
如果生产机器没有 GPU,或者 PyTorch 依赖太重,下一步通常是导出 ONNX 并用onnxruntime推理。源码包里一般带有导出脚本,通用命令如下。
python export.py --weights weights/yolov9-c.pt --include onnx导出后确认两件事:输入尺寸是否固定,NMS 是否已内建。固定尺寸的 ONNX 模型不能动态接收任意分辨率,接口里要强制缩放;不带 NMS 的模型,后处理仍需沿用non_max_suppression的前半段逻辑。CPU 场景下,onnxruntime 的intra_op_num_threads可以按物理核心数配置,默认值的表现经常不是最优。这个替换值得做,但不要放在项目跑通的第一周,先把 PyTorch 路径稳定住,再加第二条推理通道作为备选。
最后分享一个习惯:把置信度阈值、输入尺寸、类别表这三个变量写进配置文件,不要散落在路由代码和 JS 里。因为换业务领域,比如从通用检测换到遥感或红外小目标方向时,要改的就是这三个值,而不是重写推理链路。这是我做过多次模型替换后的血泪经验,希望帮到你。
本文还有配套的精品资源,点击获取