ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

YOLOv11训练到ONNX部署:跨平台推理与INT8量化实战

YOLOv11训练到ONNX部署:跨平台推理与INT8量化实战 简介这份 PDF 教程面向零基础想踏入目标检测领域的开发者与在校学生围绕 YOLOv11 从 PyTorch 训练到 ONNX 跨平台部署给出完整路径帮助解决算法入门门槛高、训练与部署环节割裂的问题。教程共 45 页正文含引言、YOLOv11 核心架构骨干网络、颈部网络与检测头、环境搭建、数据收集与标注、PyTorch 训练流程、模型评估与优化、ONNX 格式转换、跨平台部署及常见问题排查等模块目录支持跳转阅读器左侧可显示大纲并快速定位章节。压缩包内仅 1 个 PDF 文件体积约 2.29MB轻量易存目前已有 77 人学习。读者可据此理解单阶段检测算法的训练链路掌握从权重导出、ONNX 校验到 ONNX Runtime 推理与硬件平台部署的关键方法并借鉴量化、并行计算、缓存等性能优化思路适合作为动手实践的案头参考也可为安防、工业检测等场景的迁移应用提供入门指引。1. 训练能跑通和模型能上线中间隔着一条什么沟在实验室机器上用yolo11n.pt跑完 100 个 epoch、验证集 mAP50 到 0.85很多人以为任务到此结束。真到交付才发现业务侧是 Java 写的服务服务器上不允许再装一套 conda推理要跨平台单张图延时还得压到 30 毫秒以内。训练只是这条链路的前三分之一后面还有导出、对齐、量化和服务端集成。YOLOv11 给出来的是权重和一套 Python 训练接口ONNX 给出来的是一张与框架解耦的计算图。把前者导出成后者再用 ONNX Runtime 在 Python、Java 甚至边缘设备上加载是从「能训练」走到「能部署」最短的一条路。接下来的内容按顺序走一遍conda 环境怎么建、LabelImg 打标产出的 YOLO 格式标签怎么写进data.yaml、训练参数怎么调、小目标怎么优化、导出 ONNX 时哪些参数会直接吃掉精度、INT8 量化在什么条件下才值得开。刚上手的人可以照着做已经在做模型交付的人可以拿它对照排查。2. 用 PyTorch 环境和 YOLO 格式数据集把训练前置条件搭好零基础最容易卡住的不是模型是环境。显卡驱动、CUDA、PyTorch 三者版本对不上torch.cuda.is_available()返回 False后面所有训练都变成 CPU 龟速跑。这一章先把环境一次配干净再把数据集按 YOLO 格式组织好最后写一个校验脚本避免训练跑了两小时才发现标签越界。2.1 用 conda 建一个不污染系统环境的 PyTorch 训练环境不建议把 PyTorch 装在 base 环境。项目之间 CUDA 版本要求不同混装一次就得重装系统级 Python。常见做法是给每个项目单独建一个 conda 环境# 创建独立环境Python 版本选 3.10兼容性最好 conda create -n yolo11 python3.10 -y conda activate yolo11 # 安装带 CUDA 的 PyTorchURL 里的 cu121 要和本机驱动匹配 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121 # 安装 ultralyticsYOLOv11 的官方实现库 pip install ultralytics onnx onnxruntime # 验证 GPU 是否真的可用 python -c import torch; print(torch.__version__, torch.cuda.is_available())这段命令的逻辑是先用 conda 隔离解释器和依赖目录再用 PyTorch 官方 wheel 源装带 CUDA 支持的包最后装 ultralytics 和 ONNX 工具链。参数上要注意三点cu121这类后缀必须与本机nvidia-smi右上角显示的 CUDA Version 兼容驱动版本旧就换成cu118onnxruntime默认装的是 CPU 版如果要在 Python 侧用 GPU 推理需要换成onnxruntime-gpuultralytics会自动拉取 numpy、opencv 等依赖不要手动先装一个低版本 numpy 再去装它容易触发版本回退。组件作用版本选择原则CUDA 驱动提供 GPU 运行底座由显卡驱动决定不可高于驱动支持上限PyTorch训练框架产出.pt权重与本机 CUDA 匹配的 wheelultralyticsYOLOv11 训练与导出入口用较新稳定版导出 ONNX 的接口变动较频繁onnx模型中间格式与图校验与 onnxruntime 保持兼容onnxruntime跨平台推理引擎CPU 部署用默认包GPU 部署换 gpu 包提示装完先跑一条yolo detect train的极小样本测试比事后排查环境问题划算得多。2.2 LabelImg 打标产出的 YOLO 格式标签与 data.yaml 的对应关系LabelImg 切到 YOLO 模式后每张图会产出一个同名.txt每行是类别id 中心x 中心y 宽 高五个值都是相对图像尺寸归一化到 0~1 的浮点数。类别 id 从 0 开始顺序必须和data.yaml里的names列表严格一致错一位就是整类识别错乱。推荐的目录结构是训练集和验证集分开图片和标签放在对应子目录下dataset/ images/ train/ a.jpg b.jpg ... val/ c.jpg ... labels/ train/ a.txt b.txt ... val/ c.txt ... data.yamldata.yaml只描述路径和类别不描述超参# 建议写绝对路径避免训练时工作目录变化导致找不到数据 path: /data/dataset train: images/train val: images/val # 类别数必须等于 names 的长度 nc: 3 names: 0: person 1: helmet 2: vest这里path是数据集根目录train和val是相对path的子路径ultralytics 会把它们拼起来再扫描。nc写错是最隐蔽的坑写大了不会报错只是某一类永远学不出来写小了会直接崩在损失计算处。如果标签里出现nc范围外的 id训练会以索引越界的形式报错但报错信息往往指向 dataloader不指向标签文件所以下一步的校验脚本很必要。2.3 训练前跑一次数据集校验把越界标签提前挡掉几千张图靠肉眼检查不现实写个脚本扫一遍成本极低from pathlib import Path root Path(/data/dataset) nc 3 bad [] for split in (train, val): for txt in (root / labels / split).glob(*.txt): lines txt.read_text().strip().splitlines() if not lines: bad.append((txt, 空标签文件)) continue for i, line in enumerate(lines): parts line.split() # YOLO 格式必须是 5 列cls x y w h if len(parts) ! 5: bad.append((txt, f第{i}行列数{len(parts)})) continue cls int(float(parts[0])) x, y, w, h map(float, parts[1:]) if cls 0 or cls nc: bad.append((txt, f第{i}行类别越界 cls{cls})) # 归一化坐标超出 [0,1] 说明标注框画到了图外 if not (0 x 1 and 0 y 1 and 0 w 1 and 0 h 1): bad.append((txt, f第{i}行坐标异常 {x},{y},{w},{h})) for path, reason in bad[:50]: print(path, reason) print(问题文件总数:, len(bad))脚本做四件事过滤空标签文件这类文件常被当成负样本但 ultralytics 对空标签的处理依赖配置、校验列数、校验类别 id 范围、校验归一化坐标是否在合理区间。输出只打印前 50 条避免刷屏。把nc改成与data.yaml一致的值再跑。如果问题文件总数不为零先修数据再训练不要指望模型自己扛过去。另外还有两个容易被忽略的点图片和标签必须同名同数量images/train/a.jpg要对应labels/train/a.txt缺标签的图默认会被当成背景样本图片格式尽量统一成 jpg 或 png 之一混用会增加 dataloader 的读取开销。3. YOLOv11 训练从最小命令到小目标场景的参数调整环境和数据准备好之后训练本身反而是命令行能解决的事。真正拉开效果差距的是参数imgsz决定输入分辨率batch决定梯度稳定性学习率决定收敛速度小目标还得从网络结构和数据增强两头动手。这一章从一条最小可跑命令开始逐个拆开这些参数最后讲怎么用推理结果和训练日志确认模型是不是真的训练好了。3.1 从预训练权重启动一次最小训练不要从零初始化权重开始训。COCO 预训练的yolo11n.pt已经学到了大量通用边缘和纹理特征微调能省下数倍 epoch# 最小可跑命令指定数据配置、预训练权重、轮数和输入尺寸 yolo detect train \ data/data/dataset/data.yaml \ modelyolo11n.pt \ epochs100 \ imgsz640 \ batch16 \ device0 \ project/runs/helmet \ nameexp01data指向上一章写好的 yamlmodel写权重文件名时ultralytics 会自动下载官方预训练权重device0指定第一块显卡多卡写0,1project和name决定日志与权重的落盘位置最终会生成/runs/helmet/exp01/weights/best.pt和last.pt。训练过程中每个 epoch 结束会刷新results.csv里面记录了 box_loss、cls_loss、mAP50、mAP50-95 等列这个文件是后面判断收敛的主要依据。模型规模上yolo11n/s/m/l/x依次变大参数量和精度同步上升。边缘设备或 Java 服务端部署优先从 n 或 s 起步因为 ONNX 文件体积和推理耗时直接跟参数量成正比。如果一开始就用 l 训练导出后单张图几百毫秒部署侧再怎么优化也追不回来。3.2 batch、imgsz、学习率这三个参数怎么定参数作用调整方向典型取值imgsz训练与推理输入分辨率小目标调大显存不足调小640 / 960 / 1280batch单次梯度更新的样本数显存允许下取大配 auto 自动探测16 / 32 / -1lr0初始学习率小数据集调小大数据集保持默认0.01 / 0.005epochs训练轮数数据量小则多配合 patience 早停100~300patience早停耐心值防止过拟合空跑50batch-1是 ultralytics 的自动批大小模式会根据显存占用反推一个能塞下的值适合第一次跑不确定显存边界时使用。学习率方面微调场景比从头训练敏感得多数据量在几千张以内时把lr0降到默认的一半往往更稳lrf控制的是末端学习率比例不要单独改它。imgsz是最贵的一个参数它同时影响显存和推理耗时调大一档显存翻倍是常事。判断参数是否合适看results.csv的前 10 个 epoch如果 box_loss 几乎不下降通常是学习率太低或数据有问题如果 loss 快速下降后剧烈震荡多半是 batch 太小或学习率偏高。3.3 小目标场景从 P2 检测层和数据增强两头改小目标小于 32×32 像素是检测里最难的一类典型场景是航拍、远距离监控、工业缺陷。YOLOv11 默认在最深的三层特征图上做检测下采样倍数大小目标在特征图上只剩一两个像素信息基本丢光。可行的手段有两个方向。第一个方向是加高分辨率检测层。ultralytics 提供了带 P2 层的模型配置把更浅、分辨率更高的特征图也接入检测头# 用带 P2 检测层的配置配合更大的输入尺寸 yolo detect train \ data/data/dataset/data.yaml \ modelyolo11n-p2.yaml \ imgsz1280 \ batch8 \ epochs150 \ cos_lrTrueP2 层带来的是检测精度提升和计算量上升的双重结果所以 batch 要相应调小cos_lrTrue让学习率按余弦曲线衰减长训练下末端更稳定。代价是导出 ONNX 后模型图更大推理耗时增加边缘设备上要权衡。第二个方向是调数据增强。默认的 mosaic 会把四张图拼成一张对小目标有利但如果目标本身已经很密集拼接会让小目标更拥挤反而掉点。可以关掉 mosaic 的最后若干轮让模型在训练后期见到真实分布yolo detect train data/data/dataset/data.yaml modelyolo11n-p2.yaml \ imgsz1280 batch8 epochs150 \ mosaic0.8 close_mosaic30 \ scale0.3 copy_paste0.1mosaic0.8表示 80% 的概率启用拼接close_mosaic30表示最后 30 个 epoch 关闭拼接scale0.3控制随机缩放幅度copy_paste0.1是实例复制粘贴增强对样本少的小目标类别提升明显。这几个值没有普适最优解建议固定其他参数只动一个做对照实验。3.4 把推理结果存下来用眼睛和指标双向确认训练日志里的 mAP 是聚合指标看不出模型到底错在哪。用训练好的权重跑一轮推理并把结果落盘是最直接的验证方式# 在验证集上推理保存可视化图片和 txt 结果 yolo detect predict \ model/runs/helmet/exp01/weights/best.pt \ source/data/dataset/images/val \ imgsz1280 \ conf0.25 \ iou0.6 \ saveTrue \ save_txtTrue \ save_confTrue \ project/runs/helmet \ namepred01saveTrue把画框后的图片存到runs/helmet/pred01/save_txtTrue同时输出与 YOLO 标注格式一致的预测 txtsave_confTrue会在每行末尾追加置信度方便后续做阈值筛选和误检分析。conf0.25是置信度阈值iou0.6是 NMS 的 IoU 阈值这两个值在部署阶段还会再调一次训练验证阶段先用默认值即可。把预测 txt 和真实标签放一起看能快速区分三类问题漏检集中在小目标还是遮挡目标误检集中在背景纹理还是相似类别定位偏差是系统性的还是随机的。这三类问题对应的解法完全不同先分类再调参比盲目加 epoch 有效得多。4. 导出 ONNX 与 ONNX Runtime 跨平台推理落地训练完的.pt权重绑定了 PyTorch 运行时业务侧是 Java 服务时没法直接用。ONNX 的作用是把计算图连同权重序列化成一份语言无关的文件再由各平台的 ONNX Runtime 加载执行。这一章讲三件事导出时哪些参数会直接影响精度和灵活性Python 侧怎么验证导出结果Java 服务端怎么接进来最后讨论 INT8 量化该不该开。4.1 导出 ONNX 的关键参数opset、dynamic、simplify导出有两种写法命令行和 Python API。命令行适合快速验证Python API 适合写进流水线from ultralytics import YOLO model YOLO(/runs/helmet/exp01/weights/best.pt) model.export( formatonnx, # 目标格式 opset12, # 算子集版本动态量化对 12 支持最稳 imgsz1280, # 必须与训练/推理一致否则精度对不上 dynamicTrue, # batch 维度动态服务端可一次推多张 simplifyTrue, # 用 onnxsim 折叠冗余算子 halfFalse, # 是否导出 FP16服务端 CPU 推理要 False devicecpu, # 导出在图优化阶段用 CPU 更稳 )opset决定可用的算子集合选得太新老版本 onnxruntime 加载会报找不到算子的错误选得太旧某些结构无法表达。12 是一个兼容性较好的平衡点做 INT8 量化时也常用这个值。dynamicTrue只把 batch 维设为动态服务端就能一次传 1 张或 8 张如果同时把宽高也设为动态图优化会变弱边缘设备上反而更慢一般不建议。simplifyTrue依赖 onnxsim它会做常量折叠和冗余节点消除模型文件能小一截、推理略快但极少数情况下会改掉数值行为所以开启后必须做精度对齐。halfTrue导出的是 FP16只有 GPU 推理且硬件支持时才用CPU 上 ONNX Runtime 会做隐式转换既慢又可能掉精度。导出完成后模型文件旁边会出现best.onnx可以用下面的命令确认输入输出签名python -c import onnxruntime as ort s ort.InferenceSession(best.onnx, providers[CPUExecutionProvider]) for i in s.get_inputs(): print(in , i.name, i.shape, i.type) for o in s.get_outputs(): print(out, o.name, o.shape, o.type) 正常情况输入是[batch, 3, 1280, 1280]的 float32输出是[batch, 4nc, 8400]这种形状通道在前、候选框在后。记住这个布局写前后处理时全靠它。如果输出的通道数和nc对不上说明导出用的权重和data.yaml不匹配。4.2 Python 侧用 onnxruntime 跑通推理与前后处理导出后第一步不是接 Java而是在 Python 里用 onnxruntime 复现一遍结果这样出问题时能确定是导出环节还是服务端代码的问题import cv2, numpy as np, onnxruntime as ort IMG 1280 sess ort.InferenceSession(best.onnx, providers[CPUExecutionProvider]) in_name sess.get_inputs()[0].name img cv2.imread(test.jpg) h0, w0 img.shape[:2] # letterbox等比缩放 灰边填充避免直接 resize 造成形变 scale min(IMG / w0, IMG / h0) nw, nh int(round(w0 * scale)), int(round(h0 * scale)) canvas np.full((IMG, IMG, 3), 114, dtypenp.uint8) resized cv2.resize(img, (nw, nh)) top, left (IMG - nh) // 2, (IMG - nw) // 2 canvas[top:top nh, left:left nw] resized # BGR-RGB、HWC-CHW、归一化、加 batch 维 blob canvas[:, :, ::-1].transpose(2, 0, 1).astype(np.float32) / 255.0 blob np.ascontiguousarray(blob[None]) out sess.run(None, {in_name: blob})[0] # [1, 4nc, 8400] pred out[0].T # 转成 [8400, 4nc] 便于处理 boxes, scores pred[:, :4], pred[:, 4:] cls_ids scores.argmax(1) confs scores[np.arange(len(scores)), cls_ids] keep confs 0.25 print(候选框数量:, keep.sum(), 最高置信度:, confs.max())这段代码做了两件容易出错的事。letterbox 的填充值是 114必须和训练时 ultralytics 使用的值一致用 0 填充会让边缘区域分布偏移靠近图片边缘的目标容易漏检。输出转置之后前 4 列是cx, cy, w, h格式的中心点坐标和宽高都在 1280 的输入尺度上要映射回原图需要先减去 padding 偏移再除以scale。剩下的是置信度筛选和 NMSNMS 建议用cv2.dnn.NMSBoxes或自己实现注意在映射回原图坐标之后再算 IoU避免坐标尺度混淆。4.3 Java 服务端用 ONNX Runtime 加载模型Java 侧接 ONNX 主要靠 Maven 依赖核心类只有OrtEnvironment、OrtSession和OnnxTensor几个// pom.xml 依赖com.microsoft.onnxruntime:onnxruntime版本用属性统一管理 OrtEnvironment env OrtEnvironment.getEnvironment(); OrtSession.SessionOptions opts new OrtSession.SessionOptions(); opts.setIntraOpNumThreads(4); // 控制线程数避免和 Web 容器线程池抢 CPU opts.setOptimizationLevel(OrtSession.SessionOptions.OptLevel.ALL_OPT); OrtSession session env.createSession(best.onnx, opts); long[] shape {1, 3, 1280, 1280}; FloatBuffer buf FloatBuffer.wrap(blob); // blob 由 Java 侧 letterbox 得到 OnnxTensor input OnnxTensor.createTensor(env, buf, shape); OrtSession.Result result session.run(Collections.singletonMap(images, input)); float[][][] out (float[][][]) result.get(0).getValue();三个要点。第一setIntraOpNumThreads必须显式设置默认值会按物理核数起线程在容器里超过 CPU limit 会引发频繁上下文切换4 线程是个常见起点。第二输入张量的名字images要和导出时的输入名一致用上一节的get_inputs()打印出来核对。第三OnnxTensor是本地资源必须放在 try-with-resources 或手动 close否则高频推理下会持续泄漏堆外内存表现为跑几小时后进程被系统杀掉。前后处理部分与 Python 完全一致Java 里可以用 OpenCV 的 Java 绑定做 letterbox 和 resize也可以用BufferedImage手写双线性插值。前者性能好后者零依赖按部署环境选。NMS 建议自己用纯 Java 实现逻辑简单且没有额外依赖。4.4 INT8 量化什么条件下值得开INT8 量化把权重从 32 位浮点压到 8 位整数模型体积通常降到四分之一CPU 推理速度有明显提升。ONNX Runtime 提供的是训练后动态量化不需要重训from onnxruntime.quantization import quantize_dynamic, QuantType # 动态量化权重转 INT8激活值在推理时动态量化 quantize_dynamic( model_inputbest.onnx, model_outputbest_int8.onnx, weight_typeQuantType.QInt8, per_channelTrue, # 逐通道量化精度损失更小 reduce_rangeFalse, # 新硬件上保持 False避免不必要的截断 )动态量化的原理是权重预先转成 INT8激活值在每次推理时按当前张量的范围动态定标所以不需要校准数据集。代价是精度损失不可控检测任务上 mAP 掉 1~3 个点很常见小目标和低置信度目标掉得更明显。判断要不要开看三个条件是否同时满足部署侧是 CPU 且算力紧张模型体积或内存占用是硬约束业务能接受 1 个点左右的 mAP 下降。如果任一条不成立就保持 FP32把优化精力放到输入尺寸和后处理上。开了量化之后一定要在完整验证集上重跑一遍 mAP不能只看几张图的框画得对不对。升级方案是用静态量化配合校准集精度损失更小但需要准备几百张代表性图片并调整校准流程工程量明显更高适合量化收益足够大的场景。5. 上线前的精度对齐与排错清单导出和量化都会引入数值偏差上线前必须用同一批数据把 PyTorch、ONNX FP32、ONNX INT8 三条路径的输出放在一起比才能确定偏差来源。5.1 三组数值比对定位偏差出在哪一环最直接的做法是固定一张测试图把三条路径的原始输出张量算出来做逐元素比对import numpy as np, torch from ultralytics import YOLO img test.jpg # 1) PyTorch 原始输出 pt_out YOLO(best.pt).predict(img, imgsz1280, verboseFalse)[0].boxes.data.cpu().numpy() # 2) ONNX FP32 输出用上一节的 sess/blob fp32_out sess_fp32.run(None, {in_name: blob})[0][0].T # 3) ONNX INT8 输出 int8_out sess_int8.run(None, {in_name: blob})[0][0].T # 比对最高置信度目标的位置差异 def top1(arr): i arr[:, 4:].max(1).argmax() return arr[i, :4], arr[i, 4:].max() print(FP32 与 PyTorch 框偏差:, np.abs(top1(fp32_out)[0] - top1(pt_out)[0]).max()) print(INT8 与 FP32 框偏差:, np.abs(top1(int8_out)[0] - top1(fp32_out)[0]).max())FP32 与 PyTorch 的框偏差在 1 像素以内属于正常超过 5 像素就要回头查imgsz是否一致、letterbox 填充值是否一致、颜色通道顺序是否被换过。INT8 与 FP32 的偏差通常是前者的数倍只要在可接受范围内且验证集 mAP 下降不超过阈值就可以放行。5.2 导出与部署阶段的坑位对照现象常见原因排查动作ONNX 加载报找不到算子opset 过高运行时版本偏旧降 opset 到 12 重新导出推理框位置整体偏移letterbox 填充值或缩放比不一致核对填充值 114 与 scale 计算类别全部错乱输出通道顺序理解错或 names 顺序不一致打印get_outputs()形状核对框数量异常多置信度阈值过低或漏做 NMS检查 conf 阈值与 NMS 的 IoU 参数Java 侧内存持续上涨OnnxTensor未 close改 try-with-resources服务端延时抖动大线程数设置过高限制intraOpNumThreads并压测量化后小目标全丢INT8 激活范围截断关量化或改静态量化加校准集压测阶段的重点是看 P99 而不是平均值。检测模型的延时分布通常有明显长尾平均值 20 毫秒、P99 到 200 毫秒的服务在生产上会被用户直接感知。压测时固定输入尺寸、固定线程数、固定并发数逐项扫描找到延时开始非线性上升的那个并发点把它作为服务的容量上限比事后扩容更可控。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进