
简介围绕GPU环境下以Flask部署YOLOv5的ONNX模型这一实战主题面向具备Python和深度学习基础、需要将目标检测能力封装为Web服务的开发者。压缩包内置3个Python代码文件整体仅6KB分别承担模型加载与推理、图像预处理与后处理、HTTP接口构建等环节精简而不失完整性。通过这份资源可以快速理清从PyTorch导出ONNX到Flask推理回传结果的完整链路掌握数据归一化、非极大值抑制及路由响应的关键写法便于直接迁移到自己的检测服务中。目前已有1167人浏览学习是一个轻量、易上手的目标检测API部署样板。1. flask部署yolov5的onnx(GPU版本)先把推理链路拆清楚把 YOLOv5 的 ONNX 模型用 Flask 包成 HTTP 接口再让推理跑到 GPU 上这个标题拆开是三个独立问题模型怎么从 PyTorch 权重变成不依赖训练框架的计算图onnxruntime-gpu 的 CUDA 环境怎么和系统驱动对齐以及并发请求进来时显存和线程怎么排布。很多项目挂在第二步get_available_providers()明明返回了 CUDAExecutionProvider一压测却回落 CPU延迟拉开好几倍。适合读这篇文章的人是已经训好自有数据集、想把检测能力对外提供成稳定接口的 Flask 开发者。正文按导出、环境、接口、并发、验证的链路展开每个环节都给能直接粘的命令和参数。2. pt转onnx导出配置、opset 与动态尺寸参数2.1 为什么要转 ONNX计算图固化带来的收益在给导出命令之前先说明为什么 YOLOv5 部署通常走 ONNX而不是直接加载.pt权重。直接加载 PyTorch 权重推理时每次调用都经过 Python 层前向传播nn.Module里每个 Conv、BN、SiLU 都由 Python 调度器逐个派发到 CUDA 算子这部分调度开销在单张图片上不明显但并发一上来解释器开销会被明显放大。ONNX 把前向计算固化成静态图算子融合和常量折叠在加载阶段完成Python 层只需要调用一次session.run剩下的算子流水线在 onnxruntime 内部执行。对 GPU 部署来说静态图还带来一个额外好处显存分配由 CUDAExecutionProvider 统一管理不会跟随 Python 对象的生命周期反复申请释放长稳运行下的显存水位更平。这里需要顺带解释 Opset。ONNX 算子集版本决定导出时可用算子的规格不同 opset 生成的图结构不一样。Ultralytics 官方导出默认使用 opset 12这个版本对 YOLOv5 检测头支持最稳opset 过低会把 SiLU 拆成一堆基础算子过高则可能在某些旧 CUDA 驱动下触发 not implemented 错误。YOLOv5 的网络结构里Detect 头在 stride 8/16/32 三组特征图上输出预测框导出 ONNX 时这三个输出默认保留且trainingFalse时输出已经过解码坐标相对输入图尺寸。2.2 最小导出命令与参数说明以 yolov5s.pt 为例最常用的一条导出命令是pip install ultralytics onnx onnxsim onnxruntime-gpu yolo export modelyolov5s.pt formatonnx opset12 dynamicTrue imgsz640 simplifyTrue执行后当前目录会生成yolov5s.onnx。dynamicTrue把 batch、高、宽三个维度都标记为动态轴图中对应显示为 -1imgsz640是训练时的输入尺寸它决定后面预处理 letterbox 的目标尺寸不要随手改大simplifyTrue会调用 onnxsim 对图做常量折叠和冗余节点删除去掉 PyTorch 导出常见的 Identity、Reshape 链减少每次推理时 CUDA kernel 的启动次数。导出完成后建议先跑一次 onnxruntime CPU 推理验证图结构不要直接上 GPU。CPU 能跑通说明模型文件本身没问题后面遇到环境类报错可以快速隔离原因。在线接口大多是单张图片请求推荐导出 batch1 的静态版本图优化器可以做更多形状相关的编译优化如果有离线批处理任务比如视频抽帧后按 batch 喂给模型就再加一份 batch16 的静态版本。动态 batch 虽然灵活但显存会按最大形状做预算碎片率更高。我一般同时导出两个文件yolov5s_bs1.onnx给在线服务yolov5s_bs16.onnx留给批处理代码里用环境变量切换路径。2.3 导出参数对照与自检命令整理一份导出时最常调的参数导出参数推荐值影响与建议opset12 或 1312 对 YOLOv5 算子兼容最稳13 适合需要新算子时imgsz640 或训练尺寸必须与预处理 letterbox 目标一致否则坐标映射全错batch在线接口用 1动态 batch 会让显存预分配增加图优化变弱dynamicFalse 优先固定形状利于 CUDA kernel 选择动态尺寸只留给特殊需求simplifyTrue减少无用节点明显降低 session 加载耗时half显存小于 8GB 建议 True模型转 FP16速度和显存都有改观见第 6 章导出完不要只看文件大小用 onnx 自带工具做一次结构校验import onnx model onnx.load(yolov5s.onnx) onnx.checker.check_model(model) print(model.graph.input[0])check_model会校验图的节点和 shape 是否合法存在未初始化的权重或环形依赖会直接抛异常。打印输入节点能看到名称、数据类型和形状信息dim_param为字符串的位置就是动态维度。这一步花 30 秒能避免把坏模型带进 GPU 环境排查半天。3. onnxruntime GPU 环境CUDA 匹配、Provider 验证与常见报错3.1 onnxruntime-gpu 与 CUDA/cuDNN 的版本对应onnxruntime 的 GPU 支持长期通过独立包onnxruntime-gpu提供pip 安装时版本必须与本机 CUDA 对齐。如果安装的包要求 CUDA 12而系统里只有 CUDA 11.8 的运行时程序通常不会直接崩溃而是悄悄把 CUDAExecutionProvider 从可用列表里去掉所有推理回落 CPU最终表现为延迟高数倍且 GPU 利用率接近 0。常见对应关系onnxruntime-gpu 版本要求 CUDA要求 cuDNN备注1.15.x11.88.7老项目迁移常用1.16.x11.8 / 12.x8.7两个 build 需按环境选择1.17.x12.x8.9Flask 部署里个人最常用1.18.x11.8 / 12.x8.9依然是独立 GPU 包1.19.x 起见 PyPI 描述8.9官方包合并按文档选装检查环境分两步nvidia-smi看的是驱动支持的 CUDA 版本nvcc -V看的是运行时版本。许多机器两个版本不一致onnxruntime 依赖的是运行时版本所以优先以nvcc输出为准。安装命令pip uninstall onnxruntime -y pip install onnxruntime-gpu1.17.1 python -c import onnxruntime as ort; print(ort.__version__)提示如果之前装过 CPU 版 onnxruntime务必先卸载。两个包的文件会互相覆盖import 拿到哪个版本取决于安装顺序非常容易踩。3.2 Provider 验证确认推理真正发生在 GPU 上安装完成后第一件事是验证 CUDAExecutionProvider 可用。写一个最小脚本import numpy as np import onnxruntime as ort print(available providers:, ort.get_available_providers()) sess ort.InferenceSession( yolov5s.onnx, providers[CUDAExecutionProvider, CPUExecutionProvider], ) print(active providers:, sess.get_providers()) input_name sess.get_inputs()[0].name dummy np.zeros((1, 3, 640, 640), dtypenp.float32) sess.run(None, {input_name: dummy})这个脚本的输出有两种情况需要警惕。第一种是get_available_providers()里没有 CUDAExecutionProvider说明 onnxruntime-gpu 没装对或者 CUDA 动态库没加载回到 3.1 查版本。第二种是 providers 列表里有 CUDAExecutionProvider但部分算子不支持 CUDA 导致计算图拆分、某些层回落到 CPU此时 providers 列表仍然显示两个名字都在性能已经打了折扣。要确认算子级执行设备可以打开 onnxruntime profiling跑一次推理后查看生成的 json 文件里面记录了每个算子的执行耗时和设备归属。这个最小脚本同时承担预热作用。第一次sess.run会初始化 CUDA context耗时可能到秒级后续调用才会进入正常延迟区间所以服务启动后的第一个推理请求必须排除在统计之外。3.3 显存观测与常见错误对照GPU 服务上线后要日常看显存最简单的方式是交互式刷新watch -n 2 nvidia-smi监控脚本里更推荐用查询模式输出干净且可直接落日志nvidia-smi --query-gpuutilization.gpu,memory.used,memory.total --formatcsv -l 10显存曲线的价值在于判断 session 生命周期压测时显存持续上涨大概率是代码里每次请求都在创建 InferenceSession显存稳定但延迟上涨则要查线程和进程编排。下面按常见现象给一份排查对照报错 / 现象常见原因处理CUDAExecutionProvider is not enabledonnxruntime-gpu 与 CUDA 版本不匹配按 3.1 表格重装加载模型提示 cuDNN 相关错误cuDNN 动态库缺失或版本不符安装对应版本 cuDNN或换 onnxruntime-gpu 版本Not implemented某算子opset 或图结构问题导出时固定 opset12再用 onnxruntime 的优化工具过一遍cuda out of memory并发下 session 被反复创建确认全局 session参考第 5 章第一次推理很慢之后变快CUDA context 初始化部署后先预热再对外提供你可能想问 GPU 实例化到底减少的是什么创建 session 时 CUDAExecutionProvider 要建立 CUDA context、cuDNN 算法选择器和显存图分配器这些初始化成本是固定的。复用同一个 session 正是省掉了每个请求重复初始化 context 的开销所以全局 session 是 Flask 部署的默认前提不接受把 InferenceSession 写进视图函数。4. Flask 封装 YOLOv5 ONNX 推理请求处理、后处理与 Session 管理4.1 图片进模型前的 letterbox 预处理YOLOv5 训练时会把图片等比缩放并填充到 640×640推理必须走完全相同的流程否则目标比例被拉伸坐标全盘失真。这里给一个可直接复制的预处理函数import cv2 import numpy as np def letterbox(img: np.ndarray, new_shape: int 640) - tuple[np.ndarray, float, int, int]: h, w img.shape[:2] r min(new_shape / h, new_shape / w) new_w, new_h int(round(w * r)), int(round(h * r)) resized cv2.resize(img, (new_w, new_h), interpolationcv2.INTER_LINEAR) dw, dh new_shape - new_w, new_shape - new_h left, top dw // 2, dh // 2 right, bottom dw - left, dh - top canvas np.full((new_shape, new_shape, 3), 114, dtypenp.uint8) canvas[top:top new_h, left:left new_w] resized return canvas, r, left, topr是等比缩放比例后面解码坐标时要用它把模型输出映射回原图(left, top)是填充偏移量原图是正方形时这两个值都为 0。填充色用 114 而不是 0这是 YOLOv5 训练时数据增强固定的灰度值换颜色会影响后续结果的一致性。送入 onnxruntime 之前还要做通道和维度变换def preprocess(img: np.ndarray) - tuple[np.ndarray, float, int, int]: padded, r, left, top letterbox(img, 640) x cv2.cvtColor(padded, cv2.COLOR_BGR2RGB) x x.astype(np.float32) / 255.0 x x.transpose(2, 0, 1)[None] return np.ascontiguousarray(x), r, left, top/255.0对应训练时的归一化规则transpose把 HWC 转成 CHW[None]扩出 batch 维ascontiguousarray保证内存连续避免 onnxruntime 复制输入张量。4.2 输出张量解码与 NMS 实现导出的 ONNX 有三个输出头对应 8 倍、16 倍、32 倍下采样特征图每个输出张量形状是(1, 3, grid_h, grid_w, 85)。这里的 85 是 4 个坐标、1 个 obj 置信度、80 个类别概率自定义数据集要把 80 换成自己的类别数。关键点在于导出时 Detect 层已把坐标按 stride 解码xywh 是相对 640×640 输入图的像素坐标后处理不需要再除以网格尺寸。完整后处理def postprocess(outputs: list[np.ndarray], r: float, left: int, top: int, conf_thres: float 0.25, iou_thres: float 0.45, num_classes: int 80) - list[dict]: boxes, scores, labels [], [], [] for layer_out in outputs: pred layer_out[0] # (3, grid_h, grid_w, 85) grid_h, grid_w pred.shape[1], pred.shape[2] pred pred.reshape(3, grid_h * grid_w, 85) obj_conf pred[..., 4] class_conf pred[..., 5:] class_score class_conf * obj_conf[..., None] mask class_score.max(axis2) conf_thres for anchor_idx in range(3): valid np.where(mask[anchor_idx])[0] if len(valid) 0: continue cx pred[anchor_idx, valid, 0] cy pred[anchor_idx, valid, 1] w pred[anchor_idx, valid, 2] h pred[anchor_idx, valid, 3] # 去掉 letterbox padding按缩放比例回原图 x1 (cx - w / 2 - left) / r y1 (cy - h / 2 - top) / r x2 (cx w / 2 - left) / r y2 (cy h / 2 - top) / r cls class_score[anchor_idx, valid].argmax(axis1) conf class_score[anchor_idx, valid].max(axis1) for i in range(len(valid)): boxes.append([x1[i], y1[i], x2[i], y2[i]]) scores.append(float(conf[i])) labels.append(int(cls[i])) if not boxes: return [] keep nms(np.array(boxes, dtypenp.float32), np.array(scores, dtypenp.float32), iou_thres) results [] for idx in keep: results.append({ bbox: [round(float(v), 1) for v in boxes[idx]], confidence: round(float(scores[idx]), 4), class_id: labels[idx], }) return results这段代码最容易写错的是坐标回映射模型输出的 xywh 是相对 640 输入图的坐标要先减掉(left, top)填充偏移再除以缩放比例r映射回原图。很多部署脚本漏掉 padding 偏移导致小目标框整体偏移十几二十个像素检测框贴不紧物体。class_score class_conf * obj_conf是把类别概率和物体置信度相乘和训练验证时计算 mAP 的规则保持一致。NMS 的 numpy 实现同样可以复制def nms(boxes: np.ndarray, scores: np.ndarray, iou_thres: float) - list[int]: x1, y1, x2, y2 boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3] areas (x2 - x1) * (y2 - y1) order scores.argsort()[::-1] keep [] while order.size 0: i order[0] keep.append(i) xx1 np.maximum(x1[i], x1[order[1:]]) yy1 np.maximum(y1[i], y1[order[1:]]) xx2 np.minimum(x2[i], x2[order[1:]]) yy2 np.minimum(y2[i], y2[order[1:]]) inter np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1) iou inter / (areas[i] areas[order[1:]] - inter) order order[1:][iou iou_thres] return keep这是经典贪心 NMS按置信度降序取框与剩余框计算 IOU大于阈值的全部丢弃。iou_thres0.45偏严格密集小目标场景可以放到 0.5稀疏场景保持 0.45 更稳。类别多且互相遮挡时建议改成按 class_id 分组后组内做 NMS避免两个类别目标重叠导致误删。4.3 Flask 路由与全局 Session 设计接口层我一般只保留两个路由POST /detect做检测GET /healthz做存活探针。最小实现import cv2 import numpy as np from flask import Flask, request, jsonify import onnxruntime as ort app Flask(__name__) session ort.InferenceSession( yolov5s.onnx, providers[CUDAExecutionProvider, CPUExecutionProvider], ) app.post(/detect) def detect(): f request.files.get(image) if f is None: return jsonify({error: 缺少 image 字段}), 400 data np.frombuffer(f.read(), np.uint8) img cv2.imdecode(data, cv2.IMREAD_COLOR) if img is None: return jsonify({error: 图片解码失败}), 400 tensor, r, left, top preprocess(img) outputs session.run(None, {session.get_inputs()[0].name: tensor}) detections postprocess(outputs, r, left, top) return jsonify({detections: detections}) app.get(/healthz) def healthz(): return oksession.run(None, inputs)的第二个参数是输入字典key 必须从session.get_inputs()[0].name动态获取不要手写 images因为不同方式导出的模型输入名不一样。session在模块加载时创建一次全局复用这是整条部署链路里最重要的一条规则任何把InferenceSession(...)写进视图函数的做法都会让每个请求重新初始化 CUDA context 并加载模型显存和延迟立刻失控。图片解码用cv2.imdecode而不是 PIL 的Image.open底层走 libjpeg速度更快而且直接得到 BGR 排列的 ndarray省一次颜色转换。到这里可以发现onnx 模型部署流程里七成工作量在数据进出而不是推理本身。输入字节流如何高效变成 tensor、输出 tensor 如何准确映射回原图坐标这两步做扎实GPU 推理才能真正跑起来。5. 并发承载与显存策略多进程模型下的 YOLOv5 ONNX 服务5.1 多线程还是多进程GIL 与 onnxruntime 内部线程池Flask 开发服务器默认是单进程多线程能接收并发请求但并发只体现在 I/O 等待上。onnxruntime 的session.run调用时会释放 GIL算子执行阶段真正并行的是其内部线程池和 CUDA kernelPython 层并不参与但图片解码、NMS 后处理全是 Python 和 numpy 代码受 GIL 约束同一时刻只有一个线程能执行。高并发场景下后处理留在请求线程里就是最先暴露的瓶颈。我常用的部署形态是用 gunicorn 起多个 worker每个 worker 是独立进程并持有自己的 session。进程数不是越多越好每个进程都会加载一次模型并占用一份显存。一个 640×640 输入、FP32 精度的 YOLOv5s 模型模型权重加上 CUDA context 约占 1.5 到 2GB 显存4 个 worker 就需要 6 到 8GB。显存容量决定了 worker 数上界而不是 CPU 核数。5.2 gunicorn 启动参数与独立推理进程最小生产启动命令pip install gunicorn gevent gunicorn -w 2 -k gevent --timeout 120 --bind 0.0.0.0:8080 app:app-w 2指定 worker 进程数-k gevent让 worker 内部用协程处理高并发 HTTP 连接--timeout 120是单次请求的超时保护。GPU 推理本身很快但遇到大图或动态形状时单次可能超过默认的 30 秒超时后 worker 会被 gunicorn 杀掉所以调大超时是必要的。启动前需要确认模型文件和 app.py 在同一目录并且已经做过一次预热。如果请求量再往上走多 worker 各持一份 session 的显存浪费就会很明显。另一种方案是把推理独立成进程池Flask 进程只收请求把图片字节放进 multiprocessing 队列推理进程从队列取任务、跑 session、回传结果。这样模型只加载两份显存总量可控import onnxruntime as ort from multiprocessing import Process, Queue def inference_worker(task_q: Queue, result_q: Queue): sess ort.InferenceSession( yolov5s.onnx, providers[CUDAExecutionProvider, CPUExecutionProvider], ) while True: img_bytes task_q.get() data np.frombuffer(img_bytes, np.uint8) img cv2.imdecode(data, cv2.IMREAD_COLOR) tensor, r, left, top preprocess(img) out sess.run(None, {sess.get_inputs()[0].name: tensor}) result_q.put(postprocess(out, r, left, top))任务队列里放的是原始 JPEG 字节跨进程传输时 pickle 序列化开销最小推理进程自己负责解码把解码这一步的 CPU 开销从 Flask worker 里卸掉。这个方案的典型场景是 8GB 显存卡跑两个推理 worker 加两个 Flask worker延迟和吞吐都能保持平稳。5.3 并发压测如何找边界部署后先做一轮并发上探不要直接开几十个线程打。用 locust 起一个 60 秒的压测pip install locust locust -f load_test.py --headless -u 20 -r 5 -t 60s压测时要同时看三个指标GPU 利用率、显存水位、worker 的 CPU 占用。GPU 利用率到 90% 以上说明算力是瓶颈加 worker 没有帮助GPU 利用率不到 50% 而延迟上涨说明后处理或排队出了问题显存持续上涨则几乎一定是 session 被反复创建回到 4.3 检查全局复用。把 nvidia-smi 查询命令落进监控脚本每 10 秒记录一次跟 Flask access log 对齐时间轴nvidia-smi --query-gpuutilization.gpu,memory.used --formatcsv -l 10如果发现某个时间点显存突然增加 2GB 又慢慢回落那就是有请求触发了 session 重建。这是代码问题不是模型问题回请求路径里找谁调用了 InferenceSession。还有个容易被忽略的点CUDA 的显存分配器不会把释放的显存立刻还回系统nvidia-smi看到的显存占用会保持高位这不代表泄漏只要曲线是一条水平线就是健康状态。6. FP16 精度与端到端延迟验证的落点前面部署的模型默认是 FP32参数量和显存占用都偏大。在精度验证通过之后可以把 ONNX 转成 FP16 再部署显存占用直接减半部分 GPU 上算子吞吐还有提升。首选方式是重新导出需要机器上有一块支持 FP16 的 N 卡yolo export modelyolov5s.pt formatonnx halfTrue dynamicFalse imgsz640如果没有 GPU 环境也可以用 onnxmltools 对已有 ONNX 做离线转换import onnx import onnxmltools from onnxmltools.utils.float16_converter import convert_float_to_float16 model onnx.load(yolov5s.onnx) model_fp16 convert_float_to_float16(model, keep_io_typesFalse) onnx.save(model_fp16, yolov5s_fp16.onnx)FP16 模型上线前要验证两个点一是低对比度场景下 bbox 精度是否可接受拿一小批验证集对比 FP32 和 FP16 的检测框 diff二是确认 CUDAExecutionProvider 真的执行了 FP16 kernel常见问题是某些节点自动插入了 Cast 算子实际执行还是 FP32显存没省下来。部署完成后做预热和延迟统计时要同时看 p50 和 p95import time import numpy as np warmup np.zeros((1, 3, 640, 640), dtypenp.float32) for _ in range(5): session.run(None, {input_name: warmup}) latencies [] for _ in range(100): start time.perf_counter() session.run(None, {input_name: warmup}) latencies.append(time.perf_counter() - start) latencies.sort() print(p50:, latencies[50], p95:, latencies[95], p99:, latencies[99])第一次run包含 CUDA context 初始化和 cuDNN 算法选择必须先跑 5 次预热再统计。如果 p95 比 p50 高一倍以上优先查显存抖动再查 gunicorn worker 线程调度。把/healthz做厚一点返回当前 provider 列表、显存占用量和模型路径每次上线排障都能省一轮人工核对。实践时先从单张 640 图片的 p95 摸清下限再加并发往上探延迟分布比吞吐指标更早暴露显存和线程瓶颈。本文还有配套的精品资源点击获取