【内容安全审查】通过
YOLO26 模型导出:从训练到部署的最后一公里,我踩过的坑和最终方案
做计算机视觉的同学应该都有这种经历:训练时各种指标刷得飞起,模型在 Colab 上跑得好好的,loss 降得赏心悦目,mAP 也说得过去。结果到了要交付的时候,导师或者甲方一句“把模型给我,我这边要接进系统”,你才发现手里只有一个.pt文件,拿去给对方的 C++ 工程师,人家眉头一皱:这玩意儿我们这边跑不了。
这就是模型导出的意义。YOLO26 作为目前 YOLO 系列里热度一直居高不下的版本,网上关于它训练、改进、结构图解析的内容已经很多了,但“训练完之后怎么把模型导出成不同平台能吃的格式”这块,系统性讲清楚的内容反而不多。不少做计算机视觉大作业的同学、做 YOLO26 人员入侵检测项目的人,甚至很多已经在跑 YOLO26 训练自己数据集的工程师,都卡在了导出这一步。
这篇东西我想把它彻底讲透。你不需要是部署专家,只要手里有一个训练好的 YOLO26 模型,能看懂 Python,按着下面的思路走一遍,基本就能把 ONNX、OpenVINO、TensorRT 这些格式理清楚,也知道每一步为什么要那么做。
1. 导出不只是“换个后缀”,模型的运行世界变了
很多人第一次接触导出,觉得就是把.pt转成.onnx,本质上是文件格式转换。这个理解不算错,但太浅了,会导致后面遇到问题完全找不到方向。
1.1 训练框架和推理引擎对“模型”的理解完全不同
PyTorch 里的模型,本质是一堆层结构加上一堆权重,然后被 Python 的 runtime 动态解释执行。它灵活、方便调试,但每跑一次前向,都要经过 Python 层的调度,图的优化也几乎为零。这在训练阶段完全没问题,因为训练本身就要频繁改权重、算梯度,本来就需要这种灵活性。
但推理不一样。推理的时候,模型结构是固定的,权重也是固定的,不存在反向传播,也不存在动态建图的需求。推理引擎要的是“把计算图固化下来,针对固定 shape 做极致优化”。这就是为什么业界要把训练好的模型从 PyTorch 里“搬”出来,搬到一个中立的、描述计算图的格式里。
ONNX(Open Neural Network Exchange)就是这个“中立格式”。PyTorch 训练完的模型导出成 ONNX 后,相当于把整张计算图固化成了标准化的描述文件。逻辑上,它就是一个“计算图快照”,任何支持 ONNX 的推理引擎(ONNX Runtime、TensorRT、OpenVINO、Core ML 等)都能读取并执行它。
用个生活化的比喻:PyTorch 模型像一位会临场发挥的厨师,你告诉他“做一道鱼”,他会根据今天有什么食材、灶台什么火力、自己当时的心情决定具体怎么做。而 ONNX 就像一份精确到克数的菜谱,食材、步骤、火候全部写死。你把这菜谱给任何一个能看懂菜谱的厨房(各种推理引擎),都能做出一模一样的菜。
1.2 ONNX、TensorRT、OpenVINO 各管哪一段路
这是新手最容易懵的地方,格式太多了分不清。我把它们按“离硬件有多近”排个序就清楚了:
- ONNX:最中立的计算图描述格式,本身不做太多优化,像一个通用的中间表示(IR),不太依赖具体硬件。它能跑的地方最广,但优化的空间也最大。
- ONNX Runtime(ORT):微软出品的推理引擎,直接吃 ONNX 文件。它在 CPU 和 GPU 上都能跑,做了不少图优化和算子融合,适合做“通用部署”。
- OpenVINO:Intel 出品的推理工具套件,针对 Intel CPU、集成显卡、Movidius 神经计算棒做了深度优化。如果你部署的目标机器是 Intel CPU,OpenVINO 往往是性能最好的选择之一。它可以把 ONNX 进一步转成它自己的 IR 格式(
.xml+.bin),也可以直接读 ONNX。 - TensorRT:NVIDIA 出品的推理优化器,只在 NVIDIA GPU 上工作。它会针对你的目标 GPU 做层融合、精度校准、kernel 自动调优,是“NVIDIA GPU 上推理性能天花板”级别的存在。它吃 ONNX,转化后再序列化成 TensorRT engine(
.engine文件)。 - Core ML:Apple 生态的模型格式,给 iPhone、Mac 上用的。
所以整个导出的链路一般是:
PyTorch (.pt) → ONNX → OpenVINO / TensorRT / Core ML / ONNX Runtime理解了这条链路,你就明白为什么大家总说“先导出 ONNX”:它是一个必经的中间站,是 PyTorch 和各个硬件推理引擎之间的“通用语言”。不只是 YOLO26,YOLOv5、v8、v11 这些全是这个套路。
1.3 NMS 在导出流程里的真正地位
提到 YOLO 导出,有个绕不开的东西叫NMS(Non-Maximum Suppression,非极大值抑制)。
YOLO 模型输出的原始裸结果是一大堆候选框,每张图可能有上万甚至几万个框,其中绝大多数是重叠的、低置信度的“废框”。NMS 的作用就是把这些框过滤、合并,最后留下每个目标位置上最合适的那个框。
训练和验证的时候,NMS 是在 PyTorch 代码里用纯 Python 或 C++ 扩展实现的,这部分逻辑根本没进到模型的计算图里。而导出成 ONNX 后,你拿到的是“模型输出原始框”这一步,NMS 要不要一起导出,就成了 YOLO 系列导出时最容易出现分歧的大问题。
实际工程里,主流做法是模型只导出到“原始输出”为止,NMS 后处理放在推理代码里用目标语言(C++/C#/Java/Go)自己写。原因后面会详述,但你现在先记住:导出 YOLO 系列模型,基本默认不导出 NMS,除非你的部署场景非常特殊(比如目标检测的嵌入式端,后处理写起来极其困难,才有动力把 NMS 一起塞进模型里)。
2. 你的 YOLO26 是“哪一款”?不同任务导出的策略不一样
在具体动手前,先别急着敲命令。你要回头看一下自己训练的 YOLO26 到底是“哪一款”模型。YOLO 系列从 v8 开始就不仅仅是目标检测器了,它同时支持分类、分割、姿态估计这些任务。YOLO26 在这个基础上还衍生出了 depth(深度估计)等变体,网上搜“yolo26 depth”“yolo26 姿态模型版本区别”就能看到很多人都在这块有疑问。
不同任务类型,模型输出的头(head)完全不同,导出的注意事项也各不相同。
2.1 常规目标检测模型的导出重点
这是最简单、最常见的情况。你训练了一个 YOLO26 目标检测模型(比如 YOLO26n、yolo26s、yolo26m 这些),用来做人员入侵检测、安全帽检测、车辆识别等任务。
这种情况导出时核心关注点有三个:
- 输入尺寸:训练时用的 imgsz 是多少,导出时就尽量保持一致。虽然 ONNX 支持动态输入,但对推理性能不友好,后面细说。
- 输出格式:YOLO 检测模型的原始输出一般是
[1, 4 + num_classes, 8400]这种形状(具体数字取决于模型输入尺寸和 stride),其中 4 是框坐标(cx, cy, w, h 或 xyxy),num_classes 是类别数,8400 是不同尺度特征图上的候选框总数(输入 640 时)。导出时你会在输出节点上看到类似名字。 - 要不要 NMS:默认不要。让下游代码自己处理。
如果你是用 Ultralytics 框架训练的 YOLO26,框架本身已经把导出逻辑封装好了,正常情况一句model.export()就能搞定。但如果你跑的是别人魔改的 YOLO26(网上“yolo26 改进专栏”里这种特别多,比如加了注意力机制、换了检测头),就不能完全依赖框架自动导出,要手动检查改动过的层能不能被 ONNX 算子集支持。这是最让人头疼的场景,我后面会用一节专门讲怎么排查这类问题。
2.2 depth 版本导出时处理额外输出头
如果你用的是 YOLO26 depth 版本(用于单目深度估计),它的网络结构在检测头之外还挂了一个深度头,输出的是一个和输入图像尺寸成一定比例的深度图。导出这种模型,你要额外确认:
深度头的输出名称和 shape:导出的 ONNX 文件中,深度输出是一个独立的输出节点(通常像
[1, H, W]或[1, H/scale, W/scale])。深度值范围:你训练时的深度标签是相对深度(0~1)还是绝对深度(米)?模型输出的原始值范围是什么?这些信息虽然不影响导出,但会影响你部署后怎么处理深度图,最好在导出的同时写进模型说明文档。
后处理差异:检测分支可能需要 NMS,深度分支只需要插值缩放回原图尺寸并做可视化或后续计算。这决定了你在推理代码里要同时处理“框后处理”和“深度图后处理”两套逻辑,导出本身没区别,但部署代码要注意。
姿态模型导出与模型版本差异
YOLO26 的姿态模型(pose)输出就更特殊了。Ultralytics 框架里的 pose 模型,训练好的.pt导出 ONNX 后,输出通常是两个头的拼接或分开:一个是检测框分支,一个是关键点分支。
以 COCO 姿态任务为例,关键点通常是 17 个,每个点有(x, y, confidence)三个值。所以关键点分支的输出维度一般是 batch 相关、通道数 = 17 * 3。检测框分支仍然输出框和类别置信度。
这里必须注意“版本差异”。网上搜“yolo26 姿态模型版本区别”能看到很多人在问,同一个 YOLO26 名字下有不同权重版本,关键点位顺序、坐标约定是不是和 COCO 一致,是否带了可见性 flag(visible flag),这些细节不同训练脚本导出的结果形状就不一样。
我的建议是:导出姿态模型之前,先拿一张测试图,用 PyTorch 原始模型推理一遍,打印出每个输出的 shape,并人工检查关键点的坐标值是不是符合预期。只有确认了“PyTorch 阶段输出是正确的”,你导出 ONNX 后去对比才有参照物。如果你连 PyTorch 阶段的输出都没确认过,导出后出了 bug 你根本分不清是导出环节的问题还是模型本身的问题。
2.4 基于 YOLO26 做人员入侵检测等项目时的导出选型结合
很多做“YOLO26 人员入侵检测”这类计算机视觉项目的同学,其实是为了交大作业或者做毕设。这种项目通常需要部署在一台普通的电脑上,甚至可能是 CPU only 的机器。这种场景下,我最推荐导出OpenVINO格式,原因有两点:
- Intel CPU 是个巨大的存量市场,OpenVINO 对此的优化是出了名的猛,经常能在不损失精度的情况下比纯 PyTorch CPU 推理快 2 到 4 倍。
- Ultralytics 框架原生支持导出 OpenVINO,一条命令就能完成,还会帮你生成配套的部署文件。
如果项目方指定要用 NVIDIA GPU 跑推理,那就直接上 TensorRT。虽然推理速度最快,但因为它和具体显卡绑定(注意是“绑定”不是“兼容”,换了一张显卡型号甚至驱动版本都可能要重新导出),在交付时要多留个心眼:千万别只给客户一个 .engine 文件,一定要把 ONNX 文件一起交付,不然客户换台机器就跑不了。
3. 直接抄作业:我验证过的导出流程和参数配置
理论铺垫完了,现在进入实操。下面是我在自己的环境里跑通的完整导出流程,包含了环境准备和导出命令,以及每一步背后的选择逻辑。
3.1 环境准备与版本对齐
先说一个重要教训:导出 YOLO26 前,先确认你的 ultralytics 包版本和你下载/训练的模型来源一致。
YOLO26 相关的代码和权重在社区里流传速度很快,往往你今天下载的yolo26n.pt需要用某个特定版本的ultralytics包才能正常加载。如果你是在跑“yolo26 环境配置”时按网上的博客装了一个版本的包,然后又从另一个来源下载了权重,很可能加载时就报错或者行为异常。
我本地的建议环境组合:
- Python 3.9~3.11
- PyTorch 2.1 或以上
- ultralytics 包版本:以你模型来源说明为准(一般 8.2.x 之后的版本对 YOLO26 支持比较完整)
- onnxruntime 1.17 以上(用于导出后验证)
- onnx 1.15 以上
- openvino 2024.x(如果用 CPU 部署,建议导出 OpenVINO 格式)
- tensorrt 8.6 或以上(如果用 NVIDIA GPU,建议导出 TensorRT)
提示:Torch、CUDA、TensorRT 三个版本要互相兼容,这是老生常谈但永远有人在这上面翻车。TensorRT 对 CUDA 版本很敏感,建议统一用 NVIDIA 官方文档里列的兼容矩阵来配。
你可以在终端里检查版本:
python -c "import torch, ultralytics, onnx, onnxruntime; print(torch.__version__, ultralytics.__version__, onnx.__version__, onnxruntime.__version__)"如果输出里没有报错,说明基础环境没问题。
3.2 标准导出命令与超参选择
如果是用 Ultralytics 框架训练的 YOLO26,最笨但也最靠谱的导出代码长这样:
from ultralytics import YOLO # 加载你自己训练的权重 model = YOLO("runs/detect/train/weights/best.pt") # 导出 ONNX model.export( format="onnx", # 导出格式 imgsz=640, # 输入尺寸,和训练时保持一致 opset=12, # ONNX 算子集版本 dynamic=False, # 是否允许动态输入尺寸 simplify=True, # 是否用 onnxsim 简化计算图 nms=False, # 是否导出 NMS(强烈建议 False) )这行代码执行完后,会在权重同目录下生成一个best.onnx。
你可能会问:参数为什么这么设?我逐个解释:
imgsz=640:你训练时如果是 640,这里就填 640。如果训练时是 1280,这里就得填 1280。模型的 backbone 和 head 对输入特征图的尺寸有依赖,stride 通常是 8、16、32 的组合。输入尺寸必须是 32 的倍数(或者至少是 stride 最小公倍数的倍数),否则特征图会出问题。你导出时如果填了一个训练时完全没有用过的尺寸,精度可能会莫名掉点,因为模型从未在这个分辨率上充分适配过。
opset=12:ONNX 的算子集版本。太低的版本不支持一些较新的算子,太高的版本对老版本推理引擎不友好。YOLO 系列在这种 CNN 结构里,opset 11~13 之间都够用。我默认选 12,兼容性和功能都比较平衡。如果你后续要转 TensorRT,TensorRT 8.6 以上的版本对 ONNX opset 的适配已经很好,不需要额外操心。
dynamic=False:这个我强烈建议。设成 True 虽然能让模型接受任意尺寸输入,但代价是推理引擎没法做输入 shape 相关的固定优化(比如 TensorRT 的某些层会根据固定尺寸做内存布局调优),性能会有损。部署阶段,如果你能保证输入尺寸固定,就固定死,不要开动态。但有个例外:如果你的业务里输入图像尺寸不能预知且变化幅度极大,又不想做 letterbox 填充(把图像缩放并填充成固定尺寸),那只能开 dynamic。这种情况你要意识到:每换一个尺寸推理,引擎可能会重新做 shape 推导,延迟会高不少。
simplify=True:导出的 ONNX 会经过 onnxsim 工具做一遍常量折叠、冗余节点删除之类的优化。实测下来,YOLO26 的模型开 simplify 后文件体积和推理时间都有改善,而且不影响精度。但注意:如果你的模型里有自定义算子,simplify 可能会报错,那就不用 onnxsim,后面手动排查。
nms=False:前面讲过原因。官方导出选项里有 nms 这个参数,默认是 False。我建议保持默认,除非你非常明确自己的部署链路为什么需要模型内置 NMS。
3.3 导出 ONNX 后,我用这三种方式验证它没问题
导出成功不等于导出正确。我见过太多人 export 时没有报错,以为万事大吉,结果接入部署代码后检测结果全乱套。以下三步,每步都值得做:
第一步:用 onnx.checker 检查模型结构合法性
import onnx onnx_model = onnx.load("best.onnx") onnx.checker.check_model(onnx_model) print("ONNX model check passed.")如果你看懂了 checker 的报错内容,大多数结构性问题(比如节点输入输出不匹配、维度信息缺失)都能在这一步暴露出来。
第二步:用 onnxruntime 推理一张测试图,和 PyTorch 输出对比
这是最关键的一步。拿同一张测试图,分别用原始.pt模型和导出的.onnx推理,打印输出矩阵的 shape 和数值,看看是否一致。注意,不同框架的预处理方式可能有细微差别,比如像素归一化方式是 0~1 还是 -1~1,通道顺序是 RGB 还是 BGR。为了验证 ONNX 本身没导错,你应该把 ONNX 的输入喂成 PyTorch 模型输入张量的完全相同的值。这在部署阶段做“预处理对齐”前的一个独立验证。
import numpy as np import torch import onnxruntime as ort from ultralytics import YOLO # 用 PyTorch 模型推理得到输入张量 x 和输出 model = YOLO("best.pt") x = torch.randn(1, 3, 640, 640) # 这里可以放一张真实图片预处理后的张量 with torch.no_grad(): pt_outputs = model.model(x) # 这是 PyTorch 原始输出,不是后处理后的结果 # 用 ONNX Runtime 跑同一个输入 session = ort.InferenceSession("best.onnx", providers=["CPUExecutionProvider"]) ort_inputs = {session.get_inputs()[0].name: x.numpy()} ort_outputs = session.run(None, ort_inputs) # 对比每个输出的 shape 和数值 for i, pt_out in enumerate(pt_outputs): ort_out = ort_outputs[i] print(f"PyTorch output {i}: shape={pt_out.shape}") print(f"ONNX output {i}: shape={ort_out.shape}") print(f"Max absolute diff: {np.abs(pt_out.numpy() - ort_out).max():.6f}")如果 max diff 在 1e-4 这个量级甚至更小,说明导出基本无损。如果 diff 很大(比如超过 1e-2),那说明计算图里有算子被错误替换了或权重对齐出了问题,要回头查简化过程或者逐层对比。
第三步:可视化模型结构
把导出的 ONNX 文件拖进 Netron 网页端(https://netron.app),认一遍从输入到到各分支输出的形状变化。这一步特别适合检查你用的是不是接了正确 head,也适合给做 YOLO26 结构图展示的人用。
这个习惯很值得养成:导出不是验证的终点,“导出完更要对齐再收工”,省下的全是后面排查自己的时间。我在自己带团队做项目时甚至立过规矩:没做输出对齐验证的模型,不允许发给下游。
3.4 继续导出 OpenVINO 和 TensorRT
YOLO26 用 Ultralytics 导出 OpenVINO 很简单:
model.export(format="openvino", imgsz=640, half=False) # half=False 保持 FP32如果推理机器支持,且对精度损失做了评估,你可以设置half=True,转成 FP16。很多 Intel CPU 的核显对 FP16 也算友好,但如果你在普通 CPU 上做纯 CPU 推理,FP32 其实更稳妥。
导出 TensorRT 则这样:
model.export(format="engine", imgsz=640, half=True, device=0) # device 指定 GPUTensorRT 导出时务必指定在 NVIDIA GPU 上。half=True是 FP16 精度,在主流 Ampere 及之后架构的卡上,YOLO26 用 FP16 推理精度损失通常很小,速度提升明显。如果你的模型是用 FP32 训练的,在 NVIDIA 显卡上直接用 FP16 推理,绝大多数情况下没有问题,但你仍然要在导出完成后对同一批验证集图跑一遍 mAP,确认精度下降不超过 1 个百分点才比较稳。
| 目标平台 | 推荐格式 | 关键特征 |
|---|---|---|
| 通用 CPU | ONNX + ONNX Runtime | 跨平台,安装简单 |
| Intel CPU | OpenVINO | CPU 推理优化充分,易部署 |
| NVIDIA GPU | TensorRT | 推理性能天花板,绑定显卡 |
| Apple 设备 | Core ML | 生态私有格式 |
| 跨平台原型验证 | ONNX | 先落地,再决定正式优化 |
4. 导出时最容易翻车的隐蔽环节与完整排查链路
这一节我想专门讲“导出过程中报错”的处理思路。因为 YOLO26 的源码和公开资源太多太杂,你下载的“YOLO26 源码下载”很可能是某个博主二次开发的版本,结构图和官方不完全一样,甚至已经加了各种改进模块。这种模型导出,最容易在“自定义算子转 ONNX”这一步挂掉。
4.1 先复现再排查:处理导出报错的通用链路
假设你在跑 export 时遇到了一个问题,比如报错说某个算子不支持。不要急着搜“YOLO26 导出报错”这种宽泛的关键词,按照下面的链路一步步缩小范围:
第一步:精确定位到具体的层名和算子
Ultralytics 的 export 日志通常会打印到哪个模块、哪个节点失败。如果日志里只给了统一错误,用 traceback 定位到模型结构定义文件里的具体调用栈,找到出错的模块名。
第二步:检查这个模块是不是新加的“改进模块”
如果它来自你下载的“YOLO26 改进专栏”代码,那么它大概率是社区里有人写的自定义模块,内部可能用了不常规的 PyTorch 操作,比如torch.cumsum+scatter的组合(有些注意力机制会这么写)、用了可变循环长度等。这些操作要么 ONNX 算子集里没有直接对应,要么对动态 shape 支持不好。
第三步:把该模块单独抠出来,构造假数据导出单测
这是非常高效的排查手段。你在一个新文件里单独定义一个只包含那个模块的小模型,随机生成相同 shape 的输入,对它单独做 torch.onnx.export,看能不能成功。如果单独导出成功,说明该模块本身没有算子问题,问题出在和模型中其他模块的配合上;如果单独导出失败,那问题就锁定在这个模块内部。然后尝试把模块内疑似有问题的操作逐个替换成更基础的算子(比如把某些变形操作用 reshape + transpose 拆开),再重新导出验证。
第四步:考虑将“改进模块”留在 PyTorch 中,用前后处理承接
如果自定义模块实在无法转 ONNX(某些复杂的注意力机制和检测头结构确实会有问题),可以考虑导出时屏蔽掉该模块,把它放到部署代码前后处理阶段用别的方式补偿。比如注意力模块输出可以在 PyTorch 阶段预先计算好,部署时把它的结果作为预处理的一部分。这听起来绕,但在工程上是常见的妥协方案。
第五步:计算图分段导出再拼接
如果模型主体是标准结构、只有个别模块导不出,可以把这个模块从全图中拆出来单独导出成一个小 ONNX,然后用 ONNX GraphSurgeon、onnx_graphsurgeon 或者 ONNX 官方工具库进行图拼接。这样做非常接近“模型主体 + 一系列子图”的部署方式,比较考验对计算图结构的理解。一般不建议新手轻易尝试,但它确实是高阶方案里最有效的。
4.2 图像缩放方式造成的“精度翻车”,是部署阶段最常见的坑
导出时模型内部计算没问题,但推理结果不对,最常见的原因之一是图像预处理没有对齐训练时的逻辑。
YOLO 系列训练和验证时有一套默认的预处理流程:等比缩放 + letterbox(向边缘填充灰边)+ 归一化 + BGR/RGB 通道顺序约定。这套逻辑在 PyTorch 阶段是框架内部自动帮你做掉的,但导出到 ONNX 后,模型本身只负责“吃一个张量,吐一个张量”,预处理你必须自己在推理代码里实现。
我见过无数人栽在这件事上:直接用了 OpenCV 的cv2.resize(image, (640, 640))把原图拉变形了喂进模型,导致小目标检测效果极差;或者图像尺寸没做 letterbox 填充,直接把非正方形图塞进去,模型输出的候选框坐标在映射回原图时就全位移了。
正确的做法是部署代码里复刻 ultralytics 的预处理函数。核心逻辑如下:计算缩放比例、完成等比缩放、再用灰边 (114, 114, 114) 填充到目标尺寸、最后做归一化和维度调整。下采样映射回原图时再把 letterbox 填充的偏移量减去。
这段代码不复杂,但如果抄网上零散版本,很容易漏掉 BGR/RGB 转换。建议统一从你训练的框架源码里把预处理函数抠出来,翻译成目标语言的版本,而不是自己重新“发明”一遍预处理。
4.3 导出 ONNX 后输出和 PyTorch 输出差一点,但不算离谱
这类问题很隐蔽。报错倒是没有,数值对比 diff 也在 1e-3 到 1e-2 量级。这种情况通常来源于两种因素:
一是 batch norm 层转 fold 时的数值误差。PyTorch 推理时 batch norm 是按(x - running_mean)/ sqrt(running_var + eps) * weight + bias 逐层计算的,而导出到 ONNX 时推理引擎会尝试把 BN 融合到卷积层里,变成 y = x * scale + offset。这个融合理论上精确,但浮点运算顺序变化会引入微小误差。这种误差在 1e-4 量级,通常不影响后处理结果。
二是在 FP16 推理下引入的量化误差。如果你导出时开了 half=True,FP32 权重转成 FP16 会损失一点精度,输出 diff 到 1e-2 量级也是正常的。
如果 diff 在这个量级,而你的后处理结果(比如检测框)没有明显变化,基本可以继续走;但如果 diff 到了 1e-1 以上,检测框都飘了,就别以为是“正常误差”,大概率是你某个参数导出时变了,比如权重文件没正确加载。
4.4 dynamic=True 导出后,动态 shape 导致的隐性 bug
有的 YOLO26 模型以动态 shape 导出后,某些自研模块在推导输入尺寸时依赖于“shape 的数值”做条件分支(比如判断特征图宽度是否大于某阈值来决定是否执行某个操作)。PyTorch 是实时执行,没问题;但 ONNX 导出时这种条件分支会被固化成静态的算子路径,换了输入尺寸可能执行了错误的计算路径,导致输出错乱。
规避方案很直接:不要开 dynamic。如果你的业务场景确实需要输入尺寸可变,优先考虑在你的部署代码里做“多档位”策略,比如固定支持三种输入尺寸,分别导出三个 engine 或 ONNX 模型,推理时按需调用。这在工程上比一个动态模型靠谱得多。
4.5 CPU 和 GPU 推理的输出差异注意点
同一个 ONNX 文件,在 CPU 上用 ONNX Runtime 跑和在 GPU 上用 TensorRT 跑,输出结果会有很小的差异,原因在于浮点运算顺序不同、某些算子在不同后端有不同的实现策略。只要差异在正常范围内(通常 mAP 差异不超过 1 个百分点),都是合理的。如果差异明显,优先检查是不是某个算子(如Resize、GridSample)在两种后端上的插值方式实现不同。YOLO 系列里Resize最常出问题,因为框架默认的Resize模式不同版本有细微差别。
5. 导出完成后,模型要怎么接进真实系统
导出只是第一步,模型接进推理引擎跑起来,才是完整闭环。这里讲一讲我在实际项目中用的最小可用推理链路,以 ONNX Runtime 为例,方便各位理解部署侧在做什么。
5.1 一套简洁的 ONNX Runtime CPU 推理流程
import numpy as np import cv2 import onnxruntime as ort class YOLO26ONNX: def __init__(self, onnx_path, conf_thres=0.25, iou_thres=0.45): self.session = ort.InferenceSession(onnx_path, providers=["CPUExecutionProvider"]) self.input_name = self.session.get_inputs()[0].name self.input_shape = self.session.get_inputs()[0].shape # [1, 3, H, W] self.conf_thres = conf_thres self.iou_thres = iou_thres def preprocess(self, img_bgr): # 这是从 ultralytics 源码里抠出来的 letterbox 逻辑 h, w = img_bgr.shape[:2] target_h, target_w = self.input_shape[2], self.input_shape[3] ratio = min(target_w / w, target_h / h) new_w, new_h = int(round(w * ratio)), int(round(h * ratio)) resized = cv2.resize(img_bgr, (new_w, new_h), interpolation=cv2.INTER_LINEAR) canvas = np.full((target_h, target_w, 3), 114, dtype=np.uint8) dw, dh = (target_w - new_w) // 2, (target_h - new_h) // 2 canvas[dh:dh + new_h, dw:dw + new_w] = resized # BGR -> RGB, HWC -> CHW, 归一化 blob = canvas[:, :, ::-1].transpose(2, 0, 1).astype(np.float32) / 255.0 blob = np.expand_dims(blob, axis=0) return blob, ratio, dw, dh def postprocess(self, outputs, ratio, dw, dh): # 先做置信度过滤,再做 NMS,把框坐标减掉 letterbox 偏移并除以缩放比 # outputs shape: [1, 4 + num_classes, num_anchors] preds = outputs[0][0] # preds shape: [4 + num_classes, num_anchors] boxes = preds[:4].T # [num_anchors, 4] class_scores = preds[4:].T # [num_anchors, num_classes] class_ids = class_scores.argmax(axis=1) confs = class_scores.max(axis=1) mask = confs > self.conf_thres boxes, confs, class_ids = boxes[mask], confs[mask], class_ids[mask] # 转换为 xyxy x_center, y_center, bw, bh = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3] x1, y1 = x_center - bw / 2 + dw, y_center - bh / 2 + dh x2, y2 = x_center + bw / 2 + dw, y_center + bh / 2 + dh candidates = np.stack([x1, y1, x2, y2, confs, class_ids], axis=1) # 这里可以接 nms 函数,按 class 分别做 NMS 或者统一做 NMS return candidates上面代码里 NMS 部分我没有展开,推荐大家直接复用成熟实现,比如 torchvision.ops.nms 的等价逻辑,或者自己翻译成纯 NumPy 的矩阵版本。最需要注意的是后处理时要把 letterbox 加的偏移量(dw, dh)减掉、把坐标除以ratio才能映射回原始图像尺寸。这一步是绝大多数“模型导出后检测框不准”的根源。
5.2 TensorRT 部署时的 batch 与显存管理
如果你导出了 TensorRT engine,推理时用的是 TensorRT Python API 或者通过 ONNX Runtime 的 TensorRT provider。这里提醒三个点:
- batch size:导出 engine 时如果固定了 batch=1,那推理时只能一次一张地跑;如果要支持多 batch,导出时需要设置 batch 维度(Ultralytics export 里 batchsize 参数可配)。
- 显存占用:TensorRT engine 在推理时会额外申请 workspace,如果你的显存比较紧张,在导出时可以通过参数限制 workspace 大小,避免推理时显存溢出。
- 第一次推理慢:TensorRT 第一次加载 engine 和选 kernel 需要预热时间,生产环境建议在服务启动时就提前加载并 warmup 一次,而不是等第一个请求进来才做。
5.3 一个被低估的能力:判断导出模型的正确“工作温度”
推理引擎在不同精度模式下的运行速度差距非常大。FP32、FP16、INT8 三种模式下,延迟可以差 3 到 10 倍。实际部署中优先级顺序是:
- 先用 FP32 把全链路跑通、产出结果正确,再做 FP16 优化
- 精度评估通过后再考虑 INT8 量化
- 不要一上来直接上 INT8,除非你非常了解量化校准集的概念
我的建议是:导出不是一次性的,而是按性能需求分阶段迭代的。第一次先导入 ONNX 跑通正确性,第二次导出 OpenVINO/Engine 追求速度,第三次尝试 INT8 压缩体积。
写在最后
YOLO26 的模型导出,本质上一句话:把 PyTorch 的动态图世界搬到静态图推理引擎的确定世界里去,中间所有的不确定性都要靠流程和验证来消灭。
我个人在大量导出实践中有一个心得:永远不要让“格式转换成功”成为你做导出任务的终点,而是要拿“对下游完全可解释、可复现”作为交付标准。你可以顺手把验证脚本、输出 shape、精度误差记录、导出命令都一起放进一个说明文件里,和模型一起交付。这样无论是给自己半年后回头看,还是交给下一位接手的工程师,都会省掉大把重复排雷的时间。
如果你现在正好卡在某个导出报错里,不妨回头按我排查链路的顺序一步步走,大概率会在第五步之前找到问题症结。YOLO26 的代码和社区迭代速度都很快,工具链本身也在不断变化,但“导出前先确认结构、导出后立刻对齐验证、部署时复刻预处理”这三条原则是长期有效的。祝各位都能顺利把模型送到生产环境里好好地跑起来。