1. 为什么要把DETR模型转换成ONNX格式?

如果你用过PyTorch训练过DETR模型,肯定体验过它在GPU上那种“飞一般”的感觉。但现实是,很多实际部署场景,比如一些边缘计算盒子、工业质检的工控机,或者我们自己的开发笔记本,并没有强大的GPU。这时候,CPU就成了我们唯一的选择。直接把PyTorch的.pth模型扔到CPU上跑,速度慢得让人怀疑人生,内存占用也高得吓人。这时候,ONNX(Open Neural Network Exchange) 就该登场了。

你可以把ONNX想象成一个“中间翻译官”。PyTorch、TensorFlow这些框架各有各的“方言”,ONNX就是它们都能听懂的“普通话”。把DETR模型从PyTorch转换成ONNX格式,最大的好处就是标准化和跨平台。转换后的ONNX模型,可以被ONNX Runtime、OpenVINO、TensorRT等多种推理引擎加载,特别适合在资源受限的CPU环境里做优化。我实测过,一个优化得当的ONNX模型,在CPU上的推理速度比直接用PyTorch原版能快上好几倍,内存占用也能降下来不少。

所以,这篇指南就是来解决这个痛点的。我会手把手带你走通从PyTorch的DETR模型到ONNX的完整转换流程,然后重点分享在纯CPU环境下,怎么让这个模型跑得又快又稳。过程中你会遇到一些“坑”,比如转换时的警告、推理时的精度对齐问题,我都会结合自己的踩坑经验,告诉你具体的解决办法和优化技巧。目标很简单:让你在没GPU的机器上,也能高效地跑起DETR来做目标检测。

2. 转换前的准备工作:搭建你的实验环境

工欲善其事,必先利其器。在开始转换之前,我们需要一个干净、版本匹配的环境。这一步没做好,后面可能会遇到各种莫名其妙的错误。

2.1 创建项目结构与获取模型

我习惯为一个项目创建一个独立的目录,这样管理起来清晰。假设我们的项目叫detr_onnx_demo,结构可以这样安排:

detr_onnx_demo/
├── onnx/               # 存放模型文件
│   ├── weights/        # 存放下载的.pth权重文件
│   ├── exported/       # 存放导出的.onnx文件
│   └── images/         # 存放待测试的图片
├── scripts/            # 存放转换和推理脚本
└── README.md

首先,我们需要DETR的PyTorch模型。Facebook Research官方提供了预训练模型。最直接的方式是使用他们hubconf.py中定义的模型加载函数。为了确保复现,我们这里采用一个更可控的方式:先下载好权重文件。

你可以从DETR的官方GitHub仓库找到模型下载链接。这里我们以detr-r50-e632da11.pth这个ResNet-50 backbone的模型为例。下载后,把它放到onnx/weights/目录下。

2.2 安装关键的Python依赖

版本兼容性是深度学习项目的“头号杀手”。下面是我经过多次测试后,总结出的一个比较稳定的依赖组合,特别针对ONNX导出和CPU推理优化:

# 核心深度学习框架
torch==1.12.0
torchvision==0.13.0

# ONNX生态
onnx==1.13.0
onnxruntime==1.13.1  # 这是CPU版本,后续推理就靠它
onnx-simplifier==0.4.8  # 可选,用于简化模型结构

# 工具库
opencv-python==4.7.0.72
Pillow==9.4.0
numpy==1.23.5

为什么是这个版本组合? PyTorch 1.12的ONNX导出算子支持比较成熟稳定;ONNX Runtime 1.13.1在CPU算子优化上做了很多工作,性能表现很好。安装时,强烈建议使用虚拟环境(conda或venv),避免污染系统环境。你可以用下面的命令快速安装:

pip install torch==1.12.0 torchvision==0.13.0 --index-url https://download.pytorch.org/whl/cpu
pip install onnx onnxruntime onnx-simplifier opencv-python Pillow numpy

2.3 准备DETR模型源码

ONNX导出需要模型的类定义。最简单的方法是把DETR官方源码中模型定义的部分拿过来。我们不需要完整的训练代码,只需要模型构建的部分。通常,关注models/detr.py这个文件就够了。你可以直接从官方仓库复制,或者更简单一点,利用PyTorch Hub。在接下来的导出脚本里,我们会使用torch.hub.load的方式来加载模型,这样能省去手动管理源码的麻烦,但为了理解透彻,我建议你也简单浏览一下模型的结构定义。

3. 核心步骤:将DETR模型导出为ONNX格式

这是整个流程中最关键的一步。导出的ONNX模型质量,直接决定了后续推理的效率和稳定性。

3.1 编写模型导出脚本

我们不能直接用torch.onnx.export简单了事,因为需要验证导出的模型是否正确。我写了一个ONNXExporter工具类,它做了三件事:导出模型、用ONNX Runtime验证输出精度、检查模型格式。下面我把代码拆开,详细解释每个部分的作用。

首先,创建scripts/export_onnx.py文件。我们导入必要的库:

import io
import argparse
import onnx
import onnxruntime
import torch
import torchvision.transforms as T
from PIL import Image

# 注意:这里我们使用torch.hub来加载DETR模型,确保获取的是标准结构
# 你也可以从本地源码导入,但hub方式更省心

接下来是ONNXExporter类的核心方法run_model。它的参数设计很有讲究:

  • model: 要导出的PyTorch模型,必须处于eval()模式。
  • onnx_path: 导出的ONNX文件路径。
  • inputs_list: 一个包含示例输入张量的列表。为什么是列表?因为我们可以用多组输入来验证模型在不同情况下的行为,确保导出稳健。
  • tolerate_small_mismatch: 是否容忍微小的数值差异。由于PyTorch和ONNX Runtime底层计算库可能略有不同,绝对精度完全一致有时很难,只要差异在可接受范围(如1e-3)内即可。
  • do_constant_folding: 强烈建议开启。这个选项会让ONNX导出器在导出前进行常量折叠优化。比如,模型中的固定计算(如某些归一化参数)会被预先计算好,从而简化计算图,对CPU推理提速非常有帮助。
  • input_namesoutput_names: 给模型的输入输出起个名字,后续推理时要用到。

导出部分代码如下:

def run_model(self, model, onnx_path, inputs_list, tolerate_small_mismatch=False,
              do_constant_folding=True, output_names=None, input_names=None):
    model.eval()
    # 使用BytesIO在内存中先保存一份,用于快速验证
    onnx_io = io.BytesIO()

    # 执行导出!opset_version设置为12,这是一个广泛支持且稳定的版本
    torch.onnx.export(model,
                      inputs_list[0],  # 以第一组输入为示例
                      onnx_io,
                      input_names=input_names,
                      output_names=output_names,
                      export_params=True,  # 将模型参数也嵌入到ONNX文件中
                      training=False,      # 导出推理模式下的图
                      opset_version=12,
                      do_constant_folding=do_constant_folding,
                      # 动态轴设置:如果你需要支持动态batch或尺寸,需要额外设置dynamic_axes参数
                      # dynamic_axes={'inputs': {0: 'batch_size'}, 'pred_logits': {0: 'batch_size'}, 'pred_boxes': {0: 'batch_size'}}
                      )

    # 将内存中的模型也保存到文件
    with open(onnx_path, 'wb') as f:
        f.write(onnx_io.getvalue())
    print(f"[SUCCESS] ONNX model saved to: {onnx_path}")

3.2 处理导出过程中的关键警告

运行导出脚本时,你很可能会看到类似这样的警告:

TracerWarning: Converting a tensor to a Python boolean might cause the trace to be incorrect...

或者关于torch.jit.trace的一些提示。别慌,这几乎是DETR模型导出时的“标配”。这些警告通常源于模型代码中存在一些动态控制流(如if-else判断),而ONNX导出依赖于追踪(tracing)机制,对于过于动态的逻辑处理起来比较保守。

怎么办? 对于DETR,这些警告大多是可以忽略的,只要最终的验证通过。我们的ort_validate方法会紧接着用ONNX Runtime跑一遍推理,对比和PyTorch原始输出的差异。如果差异在容忍范围内(我们设置了rtol=1e-03, atol=1e-05),就说明模型转换在功能上是正确的。如果验证失败,错误信息会明确告诉你哪一层输出对不上,这时候就需要去检查对应的模型代码,看是否有不支持的算子或逻辑。

3.3 执行导出并验证

最后,编写主函数来串联整个流程:

if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument('--model_path', type=str, default='onnx/weights/detr-r50-e632da11.pth')
    parser.add_argument('--onnx_path', type=str, default='onnx/exported/detr.onnx')
    parser.add_argument('--batch_size', type=int, default=1)
    args = parser.parse_args()

    # 1. 加载PyTorch模型
    # 使用torch.hub加载模型结构,然后加载我们下载的权重
    model = torch.hub.load('facebookresearch/detr', 'detr_resnet50', pretrained=False, num_classes=91)
    checkpoint = torch.load(args.model_path, map_location='cpu')
    model.load_state_dict(checkpoint['model'])
    model.eval()

    # 2. 准备示例输入(Dummy Input)
    # DETR的输入是图像批次,这里模拟一个800x800的输入,这也是训练时的常用尺寸
    dummy_input = torch.randn(args.batch_size, 3, 800, 800)

    # 3. 导出和验证
    exporter = ONNXExporter()
    exporter.run_model(model,
                       args.onnx_path,
                       [dummy_input],
                       input_names=['inputs'],
                       output_names=['pred_logits', 'pred_boxes'],
                       tolerate_small_mismatch=True)

    # 4. 使用onnx.checker进行格式检查
    exporter.check_onnx(args.onnx_path)

运行这个脚本:python scripts/export_onnx.py。如果一切顺利,你会先看到一些警告(无视之),一两分钟后,看到[SUCCESS][INFO]的提示,onnx/exported/目录下就会生成你的detr.onnx文件。这个文件就是后续所有操作的基石。

4. 在CPU上部署与推理:让模型飞起来

拿到了ONNX模型,我们终于可以在CPU上运行它了。但直接跑可能还是慢,我们需要一些部署上的技巧。

4.1 编写ONNX Runtime推理脚本

创建scripts/inference_onnx.py。推理流程大致是:读图 -> 预处理 -> ONNX Runtime推理 -> 后处理 -> 画框。

预处理必须严格对齐:这是最容易出错的地方。DETR训练时用了特定的预处理流程,我们必须完全复现:

# 这组变换必须和训练时保持一致!
transform = T.Compose([
    T.Resize(800),                     # 将短边缩放到800,长边按比例缩放
    T.ToTensor(),                      # 转为Tensor,并归一化到[0,1]
    T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) # ImageNet均值和标准差
])

注意,T.Resize(800)并不是固定输出800x800,它会保持宽高比。而我们的模型输入是固定的800x800,这中间会有个填充(padding)步骤。原始的DETR代码里有一个NestedTensor的结构来处理变长序列,但在我们导出为固定尺寸ONNX模型后,推理时就需要自己处理填充。为了简化,我们在导出时使用了固定尺寸的dummy input,所以推理时也必须将图像resize到800x800(可能会变形),或者采用先缩放再居中填充的方式。这里为了代码清晰,我们采用直接缩放到800x800。

创建ONNX Runtime会话:这是核心对象。

onnx_path = 'onnx/exported/detr.onnx'
# 这里有几个重要的SessionOptions可以配置
so = onnxruntime.SessionOptions()
# 设置线程数,根据你的CPU核心数调整,通常设置为物理核心数
so.intra_op_num_threads = 4
so.inter_op_num_threads = 4
# 可以启用一些优化,对于CPU,'ORT_ENABLE_ALL'是个不错的选择
so.graph_optimization_level = onnxruntime.GraphOptimizationLevel.ORT_ENABLE_ALL

# 创建会话。注意providers参数,在CPU机器上就是['CPUExecutionProvider']
ort_session = onnxruntime.InferenceSession(onnx_path, sess_options=so, providers=['CPUExecutionProvider'])

4.2 理解输出并进行后处理

DETR的输出是两个东西:pred_logitspred_boxes

  • pred_logits: 形状为 [batch, num_queries, num_classes+1],是每个查询(query)对应每个类别的未归一化分数。最后一个类别是“无对象”。
  • pred_boxes: 形状为 [batch, num_queries, 4],是每个查询预测的边界框,格式是中心点坐标和宽高(cx, cy, w, h),且坐标是归一化到[0,1]的。

我们的后处理任务就是:

  1. pred_logits做softmax得到概率。
  2. 过滤掉“无对象”类别(最后一类)。
  3. 设置一个置信度阈值(比如0.7),过滤掉低置信度的预测。
  4. 将归一化的边界框坐标(cx, cy, w, h)转换回原图尺寸的(xmin, ymin, xmax, ymax)

代码实现如下:

def detect_onnx(ort_session, pil_image, prob_threshold=0.7):
    # 预处理
    img_tensor = transform(pil_image).unsqueeze(0)  # 增加batch维度
    img_np = img_tensor.cpu().numpy()

    # ONNX Runtime推理
    ort_inputs = {ort_session.get_inputs()[0].name: img_np}
    ort_outputs = ort_session.run(None, ort_inputs)
    scores, boxes = ort_outputs  # scores即logits, boxes即预测框

    # 后处理
    scores = torch.from_numpy(scores)
    boxes = torch.from_numpy(boxes)

    # Softmax并过滤“无对象”类
    probas = scores.softmax(-1)[0, :, :-1]  # [num_queries, num_classes]
    # 取每个查询的最大类别概率
    max_probs, _ = probas.max(dim=1)
    # 根据阈值过滤
    keep = max_probs > prob_threshold

    # 转换框坐标
    boxes = boxes[0, keep]  # 过滤后的框
    probas = probas[keep]   # 过滤后的概率

    # 将归一化坐标转换到原图尺寸
    # boxes是[cx, cy, w, h],且值在0~1之间
    img_width, img_height = pil_image.size
    boxes = boxes * torch.tensor([img_width, img_height, img_width, img_height])
    # 转换为xyxy格式
    cx, cy, w, h = boxes.unbind(1)
    boxes_xyxy = torch.stack([cx - 0.5*w, cy - 0.5*h, cx + 0.5*w, cy + 0.5*h], dim=1)

    return probas.numpy(), boxes_xyxy.numpy()

4.3 可视化与批量测试

最后,我们可以用OpenCV把框画出来。这部分代码比较常规,就是遍历每个检测结果,画出矩形和类别标签。你可以将结果保存下来查看。我建议用一个包含多张不同场景图片的onnx/images/文件夹进行批量测试,确保模型在各种情况下都能正常工作。

运行推理脚本:python scripts/inference_onnx.py。如果一切正常,你会在onnx/result/目录下看到带检测框的图片。

5. 性能优化实战:从“能用”到“好用”

在CPU上跑深度学习模型,不优化一下简直是对时间的浪费。下面是我总结的几个行之有效的优化手段,能让你的DETR ONNX模型推理速度提升一个档次。

5.1 ONNX模型图优化与简化

ONNX Runtime在加载模型时,可以自动进行一系列图优化,比如算子融合、常量传播、冗余节点消除等。我们在创建会话时通过so.graph_optimization_level = onnxruntime.GraphOptimizationLevel.ORT_ENABLE_ALL已经开启了。但有时,我们还可以在导出后,使用专门的工具进行离线优化。

使用onnx-simplifier:这个工具能神奇地简化模型结构。DETR模型里可能有一些复杂的GatherShape节点,onnx-simplifier会尝试用更简单、更高效的算子组合来替换它们。

pip install onnx-simplifier
python -m onnxsim onnx/exported/detr.onnx onnx/exported/detr_simplified.onnx

简化后的模型,通常推理速度会有一点提升,而且模型文件也可能变小。记得用简化后的模型重新进行精度验证,确保功能没有变化。

5.2 调整ONNX Runtime配置参数

SessionOptions里藏着很多宝贝,针对CPU调优主要关注这几个:

  • 线程控制intra_op_num_threads控制单个算子内部的并行度(如矩阵运算),inter_op_num_threads控制多个算子间的并行度。对于计算密集型的模型,将intra_op_num_threads设置为你的CPU物理核心数通常效果最好。inter_op_num_threads可以设置小一点,比如2。需要你根据实际CPU型号做一些测试。
  • 执行模式execution_mode可以设置为ORT_SEQUENTIALORT_PARALLEL。对于DETR这种结构,默认的ORT_SEQUENTIAL一般就够了。
  • 内存策略enable_cpu_mem_arena建议开启,它允许运行时复用内存,减少频繁分配释放的开销。

一个更激进的配置示例如下:

so = onnxruntime.SessionOptions()
so.enable_cpu_mem_arena = True
so.intra_op_num_threads = 8
so.inter_op_num_threads = 2
so.execution_mode = onnxruntime.ExecutionMode.ORT_SEQUENTIAL
so.graph_optimization_level = onnxruntime.GraphOptimizationLevel.ORT_ENABLE_ALL
# 还可以尝试启用更具体的优化
# so.add_session_config_entry('session.set_denormal_as_zero', '1') # 在某些CPU上处理非规格化数更快

5.3 输入尺寸优化与动态形状

我们之前导出的是固定800x800的输入。但实际应用中,图片尺寸千变万化。总是resize到800x800,如果原图很大,会浪费计算量;如果原图很小,上采样又会引入不必要的计算和模糊。

解决方案是导出支持动态尺寸的ONNX模型。在torch.onnx.export时,通过dynamic_axes参数指定哪些维度是动态的。

torch.onnx.export(...,
                  dynamic_axes={
                      'inputs': {0: 'batch_size', 2: 'height', 3: 'width'},  # 支持动态batch和H,W
                      'pred_logits': {0: 'batch_size'},
                      'pred_boxes': {0: 'batch_size'}
                  })

导出动态模型后,在推理时,你就可以输入任意尺寸的图片(但长宽最好是32的倍数,因为Backbone有下采样)。ONNX Runtime会根据实际输入尺寸动态调整计算图。这能避免不必要的计算,是CPU端推理一个很重要的优化点。不过,动态模型可能会牺牲一点点极致的优化效果,因为一些基于固定尺寸的优化无法进行了。你需要根据实际场景(输入尺寸是否多变)来权衡。

5.4 量化:终极速度提升手段(进阶)

如果经过上述优化速度仍不满足要求,可以考虑量化(Quantization)。量化将模型权重和激活从32位浮点数(FP32)转换为8位整数(INT8),计算速度可以提升2-4倍,模型体积减少75%,内存占用也大幅下降。

ONNX Runtime提供了方便的量化工具。量化分为动态量化和静态量化。对于DETR这种包含较多复杂算子的模型,动态量化是一个不错的起点,它只量化权重,对激活在推理时动态量化,实现起来比较简单。

from onnxruntime.quantization import quantize_dynamic, QuantType
model_fp32 = 'onnx/exported/detr_simplified.onnx'
model_quant = 'onnx/exported/detr_quantized.onnx'
quantize_dynamic(model_fp32, model_quant, weight_type=QuantType.QUInt8)

量化后的模型,推理代码完全不用变,直接加载detr_quantized.onnx即可。但要注意,量化会带来一定的精度损失。对于检测任务,mAP可能会下降零点几个到几个点。你需要用测试集评估量化后的精度是否在可接受范围内。在我的一个项目中,对DETR进行动态量化后,CPU推理速度提升了近3倍,而mAP只下降了约1.5%,在业务上是完全可以接受的。

6. 避坑指南与常见问题排查

走完整个流程,你可能会遇到一些我这里没提到的问题。下面是我在多个项目中总结出来的常见“坑”和解决方法。

问题一:导出失败,提示“Unsupported operator XXX” 这通常是PyTorch版本或opset版本问题。首先,确保你的PyTorch版本不是太老或太新。其次,尝试调整opset_version,比如从12降到11,或升到13。ONNX对算子的支持是随着opset版本更新的。可以在ONNX算子文档中查询某个算子从哪个版本开始支持。

问题二:推理结果全是乱框,或者置信度极低 99%的原因是预处理没对齐。请仔细检查:

  1. 图像resize的方式和训练时是否一致(是拉伸还是保持比例填充)?
  2. 归一化用的均值和标准差对不对?DETR用的是ImageNet的统计值。
  3. 图像在送入模型前,数据类型和数值范围对吗?应该是float32,且经过归一化。

一个调试技巧:用同一张图片,分别用PyTorch原模型和ONNX模型推理,对比中间某一层(比如backbone输出)的特征图,看看从哪里开始出现差异。

问题三:CPU推理时内存占用过高,甚至崩溃 DETR的Transformer部分注意力计算比较耗内存,尤其是序列长度大时。可以尝试:

  1. 降低输入图像分辨率。如果不是必须800x800,试试600x600。
  2. 确保使用了enable_cpu_mem_arena,让内存可复用。
  3. 检查是否有内存泄漏。在长时间运行的推理服务中,确保ort_session.run的输入输出变量被及时释放。

问题四:速度比预期慢很多

  1. 首先用工具(如htop或任务管理器)看看CPU占用率是否真的跑满了所有核心。如果没有,检查线程设置。
  2. 使用性能分析工具。ONNX Runtime有内置的Profiling功能,可以生成一个时间线文件,用chrome的chrome://tracing打开,能看到每个算子的耗时,找到瓶颈层。
    so.enable_profiling = True
    so.profile_file_prefix = "detr_profile"
    
  3. 考虑更底层的优化。如果你的CPU支持AVX-512等高级指令集,可以编译或寻找针对这些指令集优化的ONNX Runtime版本,性能会有显著提升。

最后,模型转换和优化是一个迭代和权衡的过程。没有一劳永逸的“最佳配置”,你需要根据你的具体硬件、图片尺寸、精度要求和延迟预算,反复测试调整这些旋钮。我自己的经验是,先保证功能正确,然后开启基础优化(图优化、线程调整),如果还不够,再考虑动态输入和量化这些更高级的手段。每次改动后,都要用一组代表性的图片测试精度和速度,做好记录。这样一步步下来,你就能在CPU上“调教”出一个既快又准的DETR检测服务了。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐