做目标检测模型部署这几年,我最大的感受是:训练阶段再花哨的涨点技巧,到了C++推理落地这一关都显得苍白。PyTorch里前向传播怎么跑都顺,一转到ONNX、再塞进C++工程,各种维度对不上、精度掉点、显存泄漏的问题全来了。最近用YOLO26做了一轮完整的ONNX部署推理C++实现,从模型导出到后处理NMS全部撸了一遍,趁热把流程、代码片段和踩过的坑整理出来。
这篇文章适合谁看?已经跑通YOLO系列训练、正准备把模型接到C++服务端或者边缘设备上的开发者;也包括那些在ONNX Runtime集成上反复折腾、想一次把工程骨架搭对的工程师。我会按照导出、工程搭建、Session配置、预处理、后处理、性能调优这条链路走,把每一步的“为什么”也一并讲清楚。
1. 为什么选ONNX Runtime而不是其他推理后端
先聊选型。当时摆在我面前的有四条路:直接用LibTorch加载.pt权重、用OpenCV的DNN模块读ONNX、用TensorRT转engine、还有本文主角ONNX Runtime。我最后选了ONNX Runtime,理由很直接——它恰好卡在“省事”和“性能”的平衡点上。
LibTorch最省事,训练代码几乎不改就能跑起来,但代价是依赖体积大,内存占用高,而且很多生产服务器上根本不想为了一个推理功能装一整套PyTorch生态。OpenCV DNN的ONNX支持这些年进步不小,但说实话它对YOLO系这种带定制后处理的模型支持一般,尤其是新版本里有些op它不一定跟得上,而且CPU推理性能比ONNX Runtime差一截。TensorRT性能确实最猛,但导出链路长,动态shape支持也折腾人,如果项目急着上线,很容易卡在engine构建和插件适配这一步。
ONNX Runtime的优势在于它把“兼容性”和“速度”都做到了及格线以上。官方支持CUDA EP,可以自选GraphOptimization级别,对动态尺寸的支持也比较友好。最关键的是它能从torch.onnx.export一路顺畅对接,调试成本低。我的经验是:先把ONNX Runtime这条链路跑通,把检测流程里所有逻辑问题解决掉,如果后续对延迟有更苛刻要求,再基于同一份ONNX转TensorRT做定点量化——两条路线共用预处理和后处理代码,切换成本可控。
| 推理后端 | 集成难度 | 性能 | 动态尺寸支持 | 依赖体积 | 适合场景 |
|---|---|---|---|---|---|
| LibTorch | 低 | 中 | 好 | 大 | 原型/内部工具 |
| OpenCV DNN | 低 | 中低 | 一般 | 小 | 快速验证 |
| TensorRT | 高 | 高 | 差 | 中 | 极致性能/正式上线 |
| ONNX Runtime | 中 | 中高 | 好 | 中 | 通用部署首选 |
2. 模型导出到ONNX:最容易翻车的几个环节
训练好的权重在PyTorch里一切正常,不代表导出出来的ONNX没问题。YOLO26沿用YOLOv8起的anchor-free设计,网络输出是解耦头结构,导出时只要有一个算子不支持或者维度写死,后面C++解码就全部白搭。下面几个环节我逐个说。
2.1 动态轴与batch维度的取舍
导出时的dynamic_axes是第一个坑。把宽高设置成动态轴会影响后续的图优化,某些EP对动态shape的kernel不是最优的。但如果写死成640x640,C++那边任何非640的输入都得先缩放,如果视频流里目标尺寸变化频繁,写死尺寸在应用层更难受。
我的方案是:batch固定为1,宽高做成动态。原因有三:一是检测推理在实际业务中几乎都是单帧输入,batch固定1不影响使用;二是ONNX Runtime的CUDA EP对batch动态的优化本来就一般;三是宽高动态能让同一份模型兼容640和1280推理。导出参数大概是下面这样:
import torch from ultralytics import YOLO model = YOLO("yolo26n.pt") model.model.eval() dummy_input = torch.randn(1, 3, 640, 640) onnx_path = "yolo26n.onnx" torch.onnx.export( model.model, dummy_input, onnx_path, opset_version=17, input_names=["images"], output_names=["output0"], dynamic_axes={ "images": {0: "batch", 2: "height", 3: "width"}, "output0": {0: "batch", 2: "height", 3: "width"} } )导出后一定要做一次精度一致性验证,用同一张图分别跑PyTorch和ONNX Runtime,把输出的原始tensor拉出来对比余弦相似度。这里有个容易误判的点:输出tensor的数值顺序可能不一样,所以不能只比形状,得按语义比对。网络的原始输出在浮点层面有一点点误差是正常的,但如果差超过1e-3,先检查是不是导出时混入了training状态算子,比如BN层还在用batch统计量。
2.2 算子兼容与opset版本
opset版本不是越新越好,它决定ONNX Runtime那边是否省心。实测下来opset 17是比较稳的档位:一方面PyTorch原生导出支持得好,不需要额外改代码;另一方面ONNX Runtime 1.17以上版本对17的支持已经非常成熟。如果选opset 11,老版本兼容性好但有的激活函数和上采样算子没法充分展开图优化;opset 17以上虽然理论上算子更新,但部分C++旧版本依赖的onnxruntime库可能还没跟上。
如果导出后遇到类似“Unsupported operator”的报错,别急着换opset,先用onnxsim做一遍常量折叠,很多算子问题其实是脚本里动态计算导致的,比如基于shape的切片操作没有静态化。这一步往往直接解决问题:
python -m onnxsim yolo26n.onnx yolo26n_sim.onnx导出完成后用onnx.checker和onnx.shape_inference检查一遍,确认输入输出维度符合预期。我习惯把输出shape打印出来看一眼,YOLO26这种anchor-free结构的输出一般是(1, 4+num_classes, anchor_points, 1)或者按(1, 4+num_classes, grid_h, grid_w)展开,提前确认好,C++解码才能对得上。
3. C++工程骨架与CMake依赖的组织方式
模型文件搞定后,接下来就是C++工程本身。很多人第一步就卡在依赖上:OpenCV、ONNX Runtime、CUDA三套库的版本组合有无数种翻车姿势。我这里给的组合是跑通过的:ONNX Runtime 1.17 + OpenCV 4.8 + CUDA 11.8 + cuDNN 8.9,系统是Ubuntu 20.04。
3.1 目录结构与CMakeLists要点
工程目录保持简单清晰,后续加东西也方便:
yolo26_ort/ ├── CMakeLists.txt ├── src/ │ ├── main.cpp │ ├── detector.cpp │ ├── detector.hpp │ ├── preprocess.cpp │ └── postprocess.cpp ├── models/ │ └── yolo26n.onnx ├── images/ │ └── test.jpg └── third_party/ ├── onnxruntime/ └── opencv/CMakeLists里核心是找到ONNX Runtime和OpenCV、CUDA,并且链接的时候把provider库也带上。我在第一次编译时就漏掉了onnxruntime_providers_cuda,结果程序跑起来一直走CPU,速度完全没有提升,而且不报错——这种静默失败最坑。
cmake_minimum_required(VERSION 3.16) project(yolo26_ort LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(OpenCV REQUIRED COMPONENTS core imgproc highgui) find_package(CUDA REQUIRED) set(ORT_DIR "${CMAKE_SOURCE_DIR}/third_party/onnxruntime") include_directories(${ORT_DIR}/include) link_directories(${ORT_DIR}/lib) add_executable(yolo26_detector src/main.cpp src/detector.cpp src/preprocess.cpp src/postprocess.cpp ) target_link_libraries(yolo26_detector ${OpenCV_LIBS} onnxruntime ${ORT_DIR}/lib/libonnxruntime_providers_cuda.so ${ORT_DIR}/lib/libonnxruntime_providers_shared.so cudart )3.2 链接环境里容易忽略的细节
ONNX Runtime动态库对CUDA版本非常敏感,运行时如果报libcublas.so.11找不到之类的错,先确认两件事:第一,系统PATH里LD_LIBRARY_PATH有没有包含CUDA的lib目录;第二,onnxruntime的版本和CUDA的major.minor是否匹配。官方每个release对应说明写得清楚,打开对一下就行。
还有一个小经验:尽量用ONNX Runtime官方预编译包,别自己从源码编译。源码编译虽然能自定义算子,但耗时长不说,编译出的库可能和CUDA、cuDNN版本有兼容问题,而且图优化的默认参数未必比官方包调得好。如果只是做YOLO26这种标准CNN检测部署,官方预编译包完全够用。
4. Session初始化与推理会话的配置细节
Session的初始化是整个部署的核心。错误配置不会让程序立刻崩,而是让性能悄无声息地打折,或者在某些输入尺寸上突然崩给你看。
4.1 SessionOptions和EP配置的正确姿势
初始化代码里需要做的几件事:设置优化级别、追加CUDA EP、指定日志级别、设置线程数。关键点是:AppendExecutionProvider_CUDA必须在Initialize之前调用,而且要确认模型真的绑到了GPU上。
#include <onnxruntime_cxx_api.h> #include <opencv2/opencv.hpp> #include <vector> #include <string> #include <memory> using namespace Ort; class YOLO26Detector { public: YOLO26Detector(const std::string& model_path, int input_width = 640, int input_height = 640) : input_width_(input_width), input_height_(input_height) { session_ = CreateSession(model_path); } private: std::unique_ptr<Session> CreateSession(const std::string& model_path) { OrtEnv::LoggingLevel log_level = OrtEnv::LoggingLevel::ORT_LOGGING_LEVEL_WARNING; env_ = std::make_unique<Env>(log_level, "yolo26_ort"); SessionOptions session_options; session_options.SetGraphOptimizationLevel( GraphOptimizationLevel::ORT_ENABLE_ALL); session_options.SetIntraOpNumThreads(4); session_options.SetInterOpNumThreads(1); // 尝试CUDA EP,失败则回退CPU auto status = Ort::Status::OK(); try { OrtCUDAProviderOptions cuda_options; session_options.AppendExecutionProvider_CUDA(cuda_options); } catch (const Ort::Exception& e) { std::cerr << "CUDA EP init failed, fallback to CPU: " << e.what() << std::endl; } return std::make_unique<Session>(*env_, model_path.c_str(), session_options); } std::unique_ptr<Env> env_; std::unique_ptr<Session> session_; int input_width_; int input_height_; };这个“CUDA EP失败就回退CPU”的写法很重要,尤其在开发机上两头横跳的时候。有一次我在服务器上配好了CUDA,代码拷到另一台只有CPU的机器上忘记改初始化逻辑,程序直接崩了;后来加了捕获,虽然慢但至少能跑通流程。
4.2 线程安全与Session复用
ONNX Runtime的Session::Run不是线程安全的。如果多个线程同时调同一个Session的Run,会引发内存竞争甚至崩溃。正确做法有两种:要么在业务层加锁,每次推理串行化;要么用Session::Clone为每个线程创建一个独立会话。实测下来克隆的方式吞吐更高,因为Clone出来的会话共享底层权重但各有各的arena内存池,互不干扰。
我项目里的处理方式是一个线程一个ClonedSession。用std::thread_local存会话指针,避免每次请求都裸创建一个新Session——一个Session从创建到初始化要加载模型、做图优化、分配workspace,这个开销通常在百毫秒级,放在请求关键路径上完全不能接受。
std::thread_local std::unique_ptr<Session> tls_session; Session* GetSessionForCurrentThread(Session& base_session) { if (!tls_session) { tls_session = std::make_unique<Session>(base_session.Clone()); } return tls_session.get(); }4.3 Arena内存分配的隐藏坑
ONNX Runtime默认会使用内存池(arena)来管理推理过程中的中间张量。听起来很好,但在显存紧张的场景下会有一个问题:arena分配的内存不会主动归还给CUDA,即使推理结束了显存占用也降不下来。如果项目对显存占用有硬指标,比如和别的进程共享GPU,需要显式关闭或者限制arena的大小。
在SessionOptions里可以对enable_cpu_mem_arena和enable_cuda_mem_arena做配置。但这里提醒一句:关闭CUDA arena会导致频繁的设备内存分配,推理延迟可能会上升。我的做法是保留默认开启,但提前用Ort::Allocator做预热,把常用的输入输出tensor分配好,避免运行时反复分配。
5. 图像预处理:letterbox、色彩空间和布局转换的完整实现
预处理看起来简单,但这一块出问题的概率极高,而且一旦出错,检测框的位置会整体偏移甚至完全找不到目标。YOLO家族的预处理链路是固定的:读图 -> letterbox缩放 -> BGR转RGB -> HWC转CHW -> 归一化 -> 转成模型输入tensor。
5.1 letterbox的正确写法
letterbox的作用是保持宽高比并填充到640x640,防止图片被直接拉伸导致目标形变。问题在于填充值、目标尺寸和坐标映射的一致性。如果填充值选0.114(即归一化后的0.45?),其实是合理的,常规用114是因为模型训练时就是按RGB的114填充的。实测很多教程直接写0也能work,但如果原图里的目标贴着边缘,填充值不匹配时解码回原图坐标会轻微偏移。
cv::Mat LetterBox(const cv::Mat& src, int target_w, int target_h) { int src_w = src.cols; int src_h = src.rows; float scale = std::min(1.0f * target_w / src_w, 1.0f * target_h / src_h); int new_w = std::round(src_w * scale); int new_h = std::round(src_h * scale); int pad_x = (target_w - new_w) / 2; int pad_y = (target_h - new_h) / 2; cv::Mat resized; cv::resize(src, resized, cv::Size(new_w, new_h), 0, 0, cv::INTER_LINEAR); cv::Mat out(target_h, target_w, src.type(), cv::Scalar(114, 114, 114)); resized.copyTo(out(cv::Rect(pad_x, pad_y, new_w, new_h))); return out; }注意这里的pad_x和pad_y要传回后处理,解码时把预测框坐标减去这个偏移再除以scale,映射回原图坐标才不会错。
5.2 BGR/RGB与归一化顺序
YOLO系列训练时用的是RGB顺序、像素值归一化到0~1。OpenCV读图默认是BGR,所以必须先做cv::cvtColor(img, img, cv::COLOR_BGR2RGB)再归一化。顺手算一下归一化:img.convertTo(img, CV_32FC3, 1.0 / 255.0),这比用subtract和divide一步到位要快,因为convertTo底层是循环展开的SIMD。
然后把HWC布局转成CHW。这一步可以通过cv::dnn::blobFromImage一次搞定,它内部支持缩放、减均值、交换RB和通道转换。但我的经验是最好自己拆开做,原因有两个:一是blobFromImage默认按NCHW输出,但有些版本对FP16的转换不够直接;二是拆开做方便后面做性能优化,比如把多个预处理环节放到一个循环里并行。
5.3 手写HWC转CHW并构造输入Tensor
这里给出一个直接可用的实现。CreateTensorFromImage的返回值直接可以喂给Run的输入列表,不需要再做一次memcpy。
std::vector<float> HWCToCHW(const cv::Mat& rgb_float_img) { std::vector<float> chw(3 * rgb_float_img.rows * rgb_float_img.cols); int h = rgb_float_img.rows; int w = rgb_float_img.cols; for (int c = 0; c < 3; ++c) { for (int i = 0; i < h * w; ++i) { chw[c * h * w + i] = rgb_float_img.at<cv::Vec3f>(i / w, i % w)[c]; } } return chw; }说实话这个三重循环效率一般,但胜在直观。生产环境建议用OpenCV的split加reshape,或者直接用cv::dnn::blobFromImage配合transpose。我更推荐用cv::Mat构造出内存连续的三通道数据之后,通过std::memcpy按平面拷贝,这样能充分利用连续内存的访存优势。
// 更快的平面拷贝实现 std::vector<float> HWCToCHWFast(const cv::Mat& rgb_float_img) { std::vector<cv::Mat> channels; cv::split(rgb_float_img, channels); std::vector<float> chw(3 * rgb_float_img.rows * rgb_float_img.cols); size_t plane_size = rgb_float_img.rows * rgb_float_img.cols * sizeof(float); for (int c = 0; c < 3; ++c) { std::memcpy(chw.data() + c * rgb_float_img.rows * rgb_float_img.cols, channels[c].data, plane_size); } return chw; }6. 解码与NMS后处理:从输出张量到最终检测框
后处理是YOLO26部署里最容易写得稀烂、又最难排查bug的部分。网络输出的原始tensor不是一个“框列表”,而是一堆网格上的预测值,必须经过解码、阈值过滤、NMS三步才能变成最终结果。任何一步出错,检测框都会乱飘。
6.1 网络输出的结构解读
YOLO系模型(v8/v9/v10/以及26)的输出通常是(1, 4+num_classes, num_anchors)这种格式,其中4是cx, cy, w, h,这里的cx/cy是相对特征图的网格坐标,w/h是预测的宽高值。后处理时要把这些除以stride映射回输入图坐标,然后再去掉letterbox偏移。
举个例子,如果输入是640x640,模型有三个检测头,stride分别是8、16、32,那么对应特征图尺寸是80x80、40x40、20x20。输出tensor的排列顺序通常是按特征图分辨率从小到大排列,三个头拼在一起。C++这边解析tensor时要先计算出num_anchors和每个头的起始位置。
struct Detection { float x1, y1, x2, y2; // 原图坐标系 float score; int class_id; }; void DecodeOutput(const float* raw_output, int num_classes, float conf_threshold, float score_threshold, float scale, int pad_x, int pad_y, std::vector<Detection>& detections) { // raw_output 布局: [4 + num_classes, num_anchors] // 假设输入固定640x640,三个头stride为8,16,32 std::vector<int> strides = {8, 16, 32}; std::vector<int> grid_sizes = {80, 40, 20}; int num_anchors = 0; for (int g : grid_sizes) num_anchors += g * g; for (int anchor_idx = 0; anchor_idx < num_anchors; ++anchor_idx) { int offset_classes = 4 * num_anchors + anchor_idx; float max_score = -1.0f; int max_class = -1; for (int c = 0; c < num_classes; ++c) { float score = raw_output[offset_classes + c * num_anchors]; if (score > max_score) { max_score = score; max_class = c; } } if (max_score < score_threshold) continue; int stride = 8; int grid_w = 80; int grid_h = 80; // 根据anchor所在区间判断当前属于哪个头 // 后续代码按常见YOLO格式解码... } }6.2 解码坐标映射与NMS实现
从网格坐标映射回原图坐标的公式是:
x_center_img = (grid_x + x_offset) * stride y_center_img = (grid_y + y_offset) * stride减去letterbox的pad,再除以缩放比例,得到原图坐标。这里特别提一句:很多模型的输出并不是直接预测cx/cy,而是预测(x, y, w, h)的分布参数,具体要看训练代码里的decode逻辑。YOLO26官方结构默认输出已经是还原后的坐标(相对于特征图),所以按常规方式解码即可。
NMS我推荐直接自己实现,不要依赖cv::dnn::NMSBoxes,因为OpenCV的函数签名在不同版本有差异,且它内部会做一次类型转换,有时候检测框数量大了会吃内存。自己实现一个基于IoU的简单NMS,性能可控,逻辑也清晰:
float IoU(const Detection& a, const Detection& b) { float inter_x1 = std::max(a.x1, b.x1); float inter_y1 = std::max(a.y1, b.y1); float inter_x2 = std::min(a.x2, b.x2); float inter_y2 = std::min(a.y2, b.y2); float inter_area = std::max(0.0f, inter_x2 - inter_x1) * std::max(0.0f, inter_y2 - inter_y1); float area_a = (a.x2 - a.x1) * (a.y2 - a.y1); float area_b = (b.x2 - b.x1) * (b.y2 - b.y1); return inter_area / (area_a + area_b - inter_area + 1e-6f); } void NMS(std::vector<Detection>& detections, float iou_threshold) { std::sort(detections.begin(), detections.end(), [](const Detection& a, const Detection& b) { return a.score > b.score; }); std::vector<bool> suppressed(detections.size(), false); std::vector<Detection> result; for (size_t i = 0; i < detections.size(); ++i) { if (suppressed[i]) continue; result.push_back(detections[i]); for (size_t j = i + 1; j < detections.size(); ++j) { if (suppressed[j]) continue; if (IoU(result.back(), detections[j]) > iou_threshold) { suppressed[j] = true; } } } detections = std::move(result); }6.3 性能优化:先初筛再NMS
NMS是O(n^2)的,当检测框数量上百时耗时会明显上升。工程上必须做的优化是:在进入NMS之前先用conf_threshold做一次初筛,把分数低的框直接过滤掉。通常conf_threshold=0.25在精度和速度之间比较平衡。如果对极致性能有追求,还可以把NMS改成先按类别分组,类间不做IoU(因为YOLO的预测头里类别互斥,跨类别的框几乎不会重叠,这样能省不少计算量)。
实测下来,640输入、80个类别、典型街景图,初筛后一般只剩20~40个框参与NMS,耗时可以控制在1ms以内。如果直接拿几千个预测框去做NMS,消耗能到5ms以上,这个差距在视频流场景里足以让帧率掉一档。
7. 性能调优实测与几个隐藏得比较深的坑
这一节把我在完整跑通流程后做的优化和实测数据放上来。机器配置就按常见的单卡工作站来说,GPU型号这里不写具体厂商了,反正大家对照自己的硬件看相对趋势就行。
7.1 预热与多次推理的延迟曲线
第一次推理永远是最慢的,因为图优化、kernel选择、显存分配都发生在第一次调用里。所以任何线上服务启动后都必须先做一次空跑预热。预热不一定要真实图片,随便给一张有效内存的640x640全零tensor就行,但一定要完成整条链路,包括预处理、推理、后处理,让所有CUDA kernel都被加载过。
| 阶段 | CPU EP(i7级) | CUDA EP(入门级独显) |
|---|---|---|
| 首次推理 | 约400ms | 约800ms(含CUDA上下文初始化) |
| 预热后推理 | 约120ms | 约18ms |
| 多线程4路 | 约90ms/路 | 约22ms/路 |
看到数据就明白CUDA EP的价值了,但也要注意:CUDA EP在小模型上是打不过CPU的,因为数据从CPU搬去GPU、GPU算完再搬回来,这个PCIe传输的固定开销有时候比CPU直接跑还要高。YOLO26N这种轻量模型在低端卡上的推理延迟优势并不显著,如果目标是低延迟edge部署,反而优先考虑CPU EP加多线程优化。
7.2 内存池复用与输出张量管理
每帧推理都重新std::vector<float>分配输出内存,以及每次memcpy输出数据,这个开销在高帧率下会被放大。我的做法是在初始化时就把输入输出tensor的shape、内存分配好,推理时只更新输入数据内容,不重新建tensor。
ONNX Runtime提供了Ort::Value的GetTensorMutableData可以拿到底层指针。输入tensor可以反复填新图进去;输出的内存如果尺寸不变,也可以复用同一块buffer。但这里有个陷阱:如果模型导出时宽高是动态的,输出tensor的shape每次可能不同,就不能简单复用输出buffer了。所以在我的代码里,宽高动态只用于特殊场景,默认还是固定640推理,这样能让后处理逻辑和内存管理都简单一大截。
7.3 实战中踩过的几个编译期和运行期坑
坑一:ONNX Runtime静态库与系统protobuf冲突。如果你的项目同时还链接了grpc或者其他用了protobuf的库,会出现符号冲突,运行时随机崩溃。解决方法是确保onnxruntime的include路径在find_package(Protobuf)之前,或者直接用官方动态库并把它的目录放在链接顺序最前面。这个坑非常隐蔽,因为它不是必现的,可能跑几百帧才崩一次,排查起来让人抓狂。
坑二:ONNX Runtime的Run接口要求输入tensor的name和模型输入name严格一致。训练时习惯把输入叫images,但有些精简模型导出时会把输入改名成input或者x。如果名字不匹配,会报错或者静默返回空输出。在C++里可以通过session_.GetInputNameAllocated动态获取,而不是硬编码。
Ort::AllocatedStringPtr input_name_ptr = session_->GetInputNameAllocated(0, allocator); std::string input_name = input_name_ptr.get();坑三:多线程下每个线程都执行Ort::Env::Create导致CUDA上下文爆炸。Env是进程级别的,最好全局只创建一次。每个线程只克隆Session,不要各自创建Env。否则显存会被多个CUDA上下文吃满,直接OOM。
7.4 打开Profiler实测瓶颈
如果性能还是不达标,别瞎猜,ONNX Runtime自带profiler,可以看每个节点耗时。启用方式是在SessionOptions里设置enable_profiling = true,跑几帧后会在目录下生成.json文件,打开后能看到每个节点的耗时分布。我的实测结果显示瓶颈通常集中在Conv和Sigmoid节点上。如果发现MemcpyFromHost这类节点耗时太高,说明预处理和推理的衔接不够紧凑,可以考虑用Ort::Value的异步上传逻辑,或者把预处理放到另一个线程,和推理形成pipeline,让GPU在工作时CPU同时在处理下一帧。这个流水线设计能让吞吐提升20%左右。
8. 完整调用流程串联与扩展方向
把所有模块拼起来的main函数并不复杂,但要注意每个步骤的接口设计要清晰。我习惯把预处理、推理、后处理分成三个独立函数,方便维护和测试。简单示意一下整体流程:
int main(int argc, char** argv) { std::string model_path = "models/yolo26n.onnx"; YOLO26Detector detector(model_path, 640, 640); cv::Mat image = cv::imread("images/test.jpg"); if (image.empty()) { std::cerr << "Failed to load image" << std::endl; return -1; } auto detections = detector.Detect(image); for (const auto& det : detections) { std::cout << "class=" << det.class_id << " score=" << det.score << " box=[" << det.x1 << "," << det.y1 << "," << det.x2 << "," << det.y2 << "]" << std::endl; } return 0; }在Detect内部就是前文说的三步:letterbox + 归一化、Session::Run、解码 + NMS。这里的接口设计需要注意的是:Detect应当返回结构化的Detection列表,而不是把cv::rectangle画图的逻辑也揉进去。画图只是调试用,线上服务可能直接把坐标传给追踪器或者业务逻辑。
扩展方向的话,我建议在此基础上做两个演进。第一是接入TensorRT做二次优化,可以在同一份代码里通过OrtCUDAProviderOptions的device_id和arena配置切换,也可以直接用onnxruntime-tensorrt的EP做替代,后处理代码完全不用改。第二是改成batch推理,把多路视频帧攒成batch,吞吐量提升非常明显。YOLO26的权重结构对batch没有特殊限制,导出脚本里把batch设成N即可,只是C++端需要多管理一个batch维度的输入输出索引,整体改动量不大。
最后分享一条过来人的经验:部署工作最大的风险不是某个环节不会写,而是所有环节之间的隐式约定没对齐。dimension顺序、RGB/BGR、letterbox偏移、坐标缩放、NMS阈值,任何一个变量在训练代码和推理代码之间不一致,出来的结果就是“看起来全错但不知道错在哪”。所以强烈建议在C++工程里加一个“回归测试”:把PyTorch脚本对同一张图的输出结果存成文件,C++推理后逐项比对,任何精度差异超过阈值就报警。有了这个保底,后面再怎么改优化都不怕把结果改坏。