ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

RT-DETR 部署实战:ONNXRuntime C++ 与 Python 推理全流程

RT-DETR 部署实战:ONNXRuntime C++ 与 Python 推理全流程

简介:本资源面向算法部署工程师与目标检测方向学习者,聚焦如何用C++和Python结合ONNXRuntime部署RT-DETR实时目标检测模型,解决从PyTorch模型到跨平台高效推理的落地问题。压缩包共12个文件,约150.47MB,包含onnx模型权重、cpp与py推理源码、CMakeLists构建脚本、标签文件、示例图片及README说明,覆盖模型转换、加载编译、预处理、推理与NMS后处理等关键环节。已有1414人学习下载,适合具备一定深度学习与C++基础、希望掌握端到端部署流程的读者。通过源码实践,可理解RT-DETR相较原始DETR在推理速度上的优化思路,掌握ONNXRuntime跨平台推理引擎的使用技巧,并对照C++与Python两套实现完成性能调优与结果验证,切实提升算法工程化部署能力。

1. 从 PyTorch 到 C++ 推理:RT-DETR 部署这件事到底卡在哪

很多做目标检测的团队都有过类似经历:Python 里model(x)跑得挺欢,一到产线要上 C++、要控延迟、要脱离 PyTorch 环境,整个链路就卡住了。RT-DETR 这类基于 Transformer 的检测器更是如此——它没有锚框、没有 NMS 依赖(原始 DETR 系列),后处理逻辑和 YOLO 完全不是一套写法,直接照搬 YOLO 的部署经验大概率翻车。这个项目给的就是一份能落地的答案:把 RT-DETR 导出成 ONNX,用 ONNXRuntime 在 C++ 和 Python 两端各跑通一遍,源码里main.cpp、main.py、CMakeLists.txt、labels.txt、rtdetr_r50vd_6x_coco.onnx一应俱全,还配了bus.jpg和两张示例图的推理结果对照。它解决的不是"模型怎么训",而是"训完的模型怎么在真实工程里跑起来",适合已经会用 PyTorch 训模型、但被 C++ 部署卡住的算法工程师和边缘端开发者。

2. ONNXRuntime 推理链路拆解:从模型加载到张量绑定的完整流程

ONNXRuntime 的核心价值在于把模型和执行环境解耦:ONNX 文件描述计算图,Runtime 负责在不同硬件上生成执行计划。理解这条链路,后面 C++ 和 Python 的代码才不是照抄。

2.1 为什么选 ONNXRuntime 而不是 LibTorch

LibTorch 是 PyTorch 的 C++ 前端,优点是算子覆盖全、和训练代码一致;缺点是包体积大(动辄几百 MB)、依赖多、跨平台编译麻烦。ONNXRuntime 走的是另一条路:模型先固化成一个.onnx文件,Runtime 只负责执行,C++ 端依赖可以压到几十 MB,Windows/Linux/ARM 都有预编译库。

对 RT-DETR 这种结构固定的检测模型,导出后计算图不再变,ONNXRuntime 的图优化(算子融合、常量折叠、内存复用)能直接吃到收益。常见做法是:训练和实验阶段用 PyTorch,交付阶段统一转 ONNX,C++ 端只依赖 ONNXRuntime,Python 端也能用同一份模型做对齐验证,避免"两端结果对不上"的玄学问题。

选型上还有几个现实考量:ONNXRuntime 支持 CPU、CUDA、TensorRT、OpenVINO 等多种 Execution Provider,换硬件时改的是 provider 配置而不是推理代码;模型文件是自包含的,不依赖 Python 环境,部署到没有 Python 的机器上也不慌。这些正是工业部署最看重的点。

2.2 模型导出与输入输出约定

RT-DETR 导出 ONNX 的关键是固定输入形状和确认输出结构。RT-DETR 的输入通常是[1, 3, H, W],官方 R50 版本常用 640×640。导出时模型必须处于eval()模式,否则 BatchNorm 和 Dropout 会带来随机性。

import torch from models.rtdetr import RTDETR # 以实际工程路径为准 model = RTDETR(num_classes=80) model.load_state_dict(torch.load("rtdetr_r50vd_6x_coco.pth", map_location="cpu")) model.eval() dummy = torch.randn(1, 3, 640, 640) torch.onnx.export( model, dummy, "rtdetr_r50vd_6x_coco.onnx", input_names=["images"], output_names=["logits", "boxes"], # 具体名字以模型 forward 返回为准 opset_version=12, # 12 及以上对 Transformer 算子支持更稳 dynamic_axes=None # 固定形状,部署端更省心 )

这里几个参数值得说清楚:opset_version建议 12 或更高,低版本对LayerNormalization、MultiHeadAttention相关算子支持不全,导出会报 unsupported operator;dynamic_axes=None表示固定 batch 和分辨率,部署时张量形状确定,Runtime 能提前做内存规划,比动态形状快;output_names要和后面 C++/Python 里按名字取输出的逻辑对上,名字写错就是空指针或找不到输出。

导出后建议用onnx.checker.check_model和onnxruntime各跑一遍,确认没有算子落到 CPU fallback 或者直接报错。这一步偷懒,后面 C++ 端排查会痛苦十倍。

2.3 C++ 端加载与推理骨架

C++ 端的核心是Ort::Session,围绕它做环境初始化、会话创建、输入输出张量绑定。下面这段是骨架,实际项目里main.cpp会在此基础上加图像预处理和后处理。

#include <onnxruntime_cxx_api.h> #include <opencv2/opencv.hpp> int main() { Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "rtdetr"); Ort::SessionOptions opts; opts.SetIntraOpNumThreads(4); // 单算子内部并行线程 opts.SetGraphOptimizationLevel( GraphOptimizationLevel::ORT_ENABLE_ALL); // 开启全部图优化 Ort::Session session(env, "rtdetr_r50vd_6x_coco.onnx", opts); // 查询输入输出信息,避免硬编码维度 Ort::AllocatorWithDefaultOptions allocator; auto input_name = session.GetInputNameAllocated(0, allocator); auto output_name = session.GetOutputNameAllocated(0, allocator); // 预处理:letterbox 到 640x640,归一化,HWC->CHW cv::Mat img = cv::imread("bus.jpg"); cv::Mat blob; cv::dnn::blobFromImage(img, blob, 1.0 / 255.0, cv::Size(640, 640), cv::Scalar(), true, false); std::vector<int64_t> input_shape = {1, 3, 640, 640}; auto memory_info = Ort::MemoryInfo::CreateCpu( OrtArenaAllocator, OrtMemTypeDefault); Ort::Value input_tensor = Ort::Value::CreateTensor<float>( memory_info, (float*)blob.data, blob.total(), input_shape.data(), input_shape.size()); const char* input_names[] = {input_name.get()}; const char* output_names[] = {output_name.get()}; auto outputs = session.Run(Ort::RunOptions{nullptr}, input_names, &input_tensor, 1, output_names, 1); // outputs[0] 即检测结果,进入后处理 return 0; }

逻辑说明:SetIntraOpNumThreads控制算子内部并行度,CPU 上一般设成物理核数;ORT_ENABLE_ALL打开图优化,能融合的算子会融合。GetInputNameAllocated动态取名字,比硬编码字符串稳,模型换了名字也不用改代码。blobFromImage的swapRB=true是因为 OpenCV 读进来是 BGR,模型要 RGB;1.0/255.0是归一化系数,必须和训练时一致,否则精度直接崩。

参数上最容易错的是归一化均值和方差。RT-DETR 训练时如果用了 ImageNet 的 mean/std,这里就不能只除 255,得按(x/255 - mean) / std逐通道处理。项目里main.cpp的预处理部分要重点核对这一块。

2.4 Python 端对齐验证

Python 端存在的意义不只是"也能跑",更重要的是做结果对齐。C++ 出问题时,先用 Python 跑同一张图,对比输出张量,能快速定位是预处理错了还是后处理错了。

import onnxruntime as ort import numpy as np import cv2 sess = ort.InferenceSession("rtdetr_r50vd_6x_coco.onnx", providers=["CPUExecutionProvider"]) img = cv2.imread("bus.jpg") img = cv2.resize(img, (640, 640)) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0 img = np.transpose(img, (2, 0, 1))[None, ...] # HWC -> NCHW outputs = sess.run(None, {"images": img}) print([o.shape for o in outputs])

providers指定执行后端,CPU 上就是CPUExecutionProvider,有 GPU 时换成CUDAExecutionProvider。sess.run(None, ...)的第一个参数为 None 表示返回所有输出。打印 shape 是为了确认输出维度和预期一致——RT-DETR 一般输出类别 logits 和归一化框坐标,shape 对不上说明导出或输入有问题。

3. 后处理与坐标还原:RT-DETR 和 YOLO 到底差在哪

后处理是 RT-DETR 部署里最容易翻车的一环,因为它和 YOLO 的思路完全不同。

3.1 RT-DETR 的输出结构与解码逻辑

YOLO 系列输出的是相对网格的偏移量,需要 anchor 解码加 NMS;RT-DETR 是端到端检测器,输出的是归一化的cxcywh框和每个 query 的类别分数,理论上不需要 NMS。但实际部署时,很多实现仍会加一个轻量筛选:按类别分数阈值过滤,再对框做 top-k 保留。

解码逻辑大致是:模型输出boxes是[cx, cy, w, h]归一化到[0,1],乘以原图宽高就得到像素坐标;logits经过 sigmoid(多分类)或 softmax 得到每类概率,取最大值为类别,超过阈值就保留。这里要注意:RT-DETR 的框是归一化到输入尺寸(640×640)的,如果预处理用了 letterbox,还原时要先减去 padding 再除以缩放比例,否则框会整体偏移。

def postprocess(outputs, orig_w, orig_h, ratio, pad, score_thr=0.4): logits, boxes = outputs[0], outputs[1] scores = 1 / (1 + np.exp(-logits)) # sigmoid cls_ids = scores.argmax(-1) cls_scores = scores.max(-1) keep = cls_scores > score_thr boxes, cls_ids, cls_scores = boxes[keep], cls_ids[keep], cls_scores[keep] # cxcywh -> xyxy,再还原 letterbox cx, cy, w, h = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3] x1 = (cx - w / 2 - pad[0]) / ratio y1 = (cy - h / 2 - pad[1]) / ratio x2 = (cx + w / 2 - pad[0]) / ratio y2 = (cy + h / 2 - pad[1]) / ratio return np.stack([x1, y1, x2, y2], -1), cls_ids, cls_scores

score_thr是置信度阈值,0.4 是常见起点,实际按业务调;ratio和pad来自 letterbox 预处理,必须和预处理时算的一致。这段逻辑在 C++ 里要一一对应实现,任何一步顺序错了,框就会飘。

3.2 letterbox 预处理与坐标还原的对应关系

letterbox 的目的是保持长宽比,把原图缩放到 640×640 并补边。计算方式是:ratio = min(640/w, 640/h),缩放后算 padding。预处理和后处理必须用同一组ratio和pad,否则框位置全错。

步骤操作关键参数
读图cv::imread默认 BGR
缩放按 ratio 等比缩放ratio = min(640/w, 640/h)
补边填充到 640×640pad 值常用 114
归一化除 255 或减均值除方差与训练一致
通道HWC 转 CHW模型要求 NCHW

这张表建议直接对着main.cpp和main.py核对,两端必须完全一致。我见过太多案例是 Python 端用了 letterbox、C++ 端直接 resize,结果 Python 框准、C++ 框偏,查半天才发现预处理不一致。

3.3 编译与运行:CMakeLists 里的依赖怎么配

项目里的CMakeLists.txt负责把 ONNXRuntime 和 OpenCV 链进来。典型写法是find_package(OpenCV REQUIRED)加手动指定 ONNXRuntime 的头文件和库路径。

cmake_minimum_required(VERSION 3.15) project(rtdetr_deploy) set(CMAKE_CXX_STANDARD 17) find_package(OpenCV REQUIRED) set(ONNXRUNTIME_DIR "/path/to/onnxruntime") include_directories(${ONNXRUNTIME_DIR}/include) link_directories(${ONNXRUNTIME_DIR}/lib) add_executable(rtdetr_demo main.cpp) target_link_libraries(rtdetr_demo ${OpenCV_LIBS} onnxruntime)

CMAKE_CXX_STANDARD 17是因为 ONNXRuntime 的 C++ API 用了不少现代特性;ONNXRUNTIME_DIR指向解压后的库目录,Windows 下库名可能是onnxruntime.lib,Linux 下是libonnxruntime.so,链接名要对应。编译命令就是标准的mkdir build && cd build && cmake .. && make。跑之前确认动态库在LD_LIBRARY_PATH(Linux)或 PATH(Windows)里,否则会报找不到库。

4. 部署避坑实录:五个让结果对不上的常见问题

4.1 现象:C++ 和 Python 框位置差一截

原因:两端预处理不一致,最常见的是 Python 用了 letterbox、C++ 用了直接 resize,或者归一化系数不同。解决:把预处理逻辑抽成一份伪代码,两端逐行对照,重点核对 ratio、pad、mean、std、通道顺序五项。

4.2 现象:模型加载报 unsupported operator

原因:导出时 opset 版本太低,Transformer 相关算子没被支持。解决:把opset_version提到 12 以上重新导出,导出后用onnxruntime的 Python 端先跑一遍确认无 fallback 警告。

4.3 现象:输出全是背景类,没有检测框

原因:输入张量布局错了,比如把 NCHW 写成了 NHWC,或者归一化没做。解决:打印输入张量的 shape 和前几个值,和 Python 端同一张图的输入逐元素对比,差异大就说明预处理错了。

4.4 现象:C++ 编译过但运行时报找不到 onnxruntime 库

原因:动态库路径没配。解决:Linux 下export LD_LIBRARY_PATH=$ONNXRUNTIME_DIR/lib:$LD_LIBRARY_PATH,Windows 下把 dll 拷到 exe 同目录或加进 PATH。这是血泪经验,别问为什么。

4.5 现象:CPU 推理慢得离谱

原因:线程数没设或设成了 1,图优化没开。解决:SetIntraOpNumThreads设成物理核数,SetGraphOptimizationLevel用ORT_ENABLE_ALL,再确认没有算子落到 CPU fallback(用sess.get_providers()和 profiling 看)。

5. 进阶:用 Execution Provider 切换硬件后端与性能验证

跑通只是第一步,真正交付要看性能。ONNXRuntime 的 Execution Provider 机制让你不改推理代码就能换后端:CPU 用CPUExecutionProvider,NVIDIA GPU 用CUDAExecutionProvider,Intel 平台可以试OpenVINOExecutionProvider。切换方式在 Python 端是改providers列表,C++ 端是SessionOptions里AppendExecutionProvider_CUDA。

验证性能别只看一次耗时,要跑预热加多次取平均。下面这段是常用的 benchmark 骨架:

import time import numpy as np # 预热,让 Runtime 完成内存分配和图优化 for _ in range(10): sess.run(None, {"images": dummy_input}) times = [] for _ in range(100): t0 = time.perf_counter() sess.run(None, {"images": dummy_input}) times.append((time.perf_counter() - t0) * 1000) print(f"avg {np.mean(times):.2f} ms, p99 {np.percentile(times, 99):.2f} ms")

perf_counter比time.time精度高;预热十次是必须的,第一次推理包含图优化和内存分配,算进去会严重偏高;看 p99 而不是只看平均,是因为产线更关心最坏情况。C++ 端同理,用std::chrono::high_resolution_clock做同样的预热加统计。

还有一个容易被忽略的点:ONNXRuntime 的 profiling。SessionOptions里开EnableProfiling,跑完会生成一个 json,能看到每个算子的耗时,定位瓶颈比盲猜快得多。如果发现某个算子特别慢,可能是它落到了 CPU fallback,检查 provider 是否真的生效。

我自己的习惯是:任何模型上线前,先用 Python 端跑通并记录基准延迟,再用 C++ 端对齐输出和延迟,两端差异超过 5% 就先查预处理而不是查模型。从那以后我每次部署新模型都强制走一遍"Python 对齐 → C++ 复现 → 性能 benchmark"三步,省下的排查时间远比多写的几行脚本值。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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