尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C++ 调用 OnnxRuntime 部署 YOLOv8:从 ONNX 导出到 NMS 后处理全流程

C++ 调用 OnnxRuntime 部署 YOLOv8:从 ONNX 导出到 NMS 后处理全流程 简介面向需要在C工程中集成YOLOv8模型进行实时目标检测的开发者该部署示例包提供了开箱即用的OnnxRuntime调用方案。压缩包共含3个文件包括两个YOLOv8的ONNX权重文件分别适用于常规检测与分割任务以及一份C推理源码整体大小约21.87MB。示例代码演示了从加载模型、创建会话到图像预处理、运行推理再到输出张量后处理的完整流程并涉及内存分配、CUDA/cuDNN加速等关键配置可以帮助读者快速理清OnnxRuntime C API的调用逻辑。针对模型加载失败、硬件加速不生效、内存管理不当等常见部署问题该工程也提供了相应的错误排查思路与处理参考。资源目前已有858人学习下载适合具备一定C基础、希望快速掌握模型部署思路并迁移到自身项目的算法工程与嵌入式开发人员查阅。1. 为什么用 C 接 OnnxRuntime 跑 YOLOv8YOLOv8 在 Python 里训练和验证都很顺手可一旦进入生产环境问题就来了PyTorch 的启动时间动辄几秒内存占用随 batch 和输入尺寸非线性上涨多线程推理时 GIL 又卡住并发。把模型导成 ONNX 再用 C 调用 OnnxRuntime 推理是把训练成果变成低延迟、可嵌入服务的常见路径也是 C 面试里被反复问到的工程细节之一。这篇文章按一条能落地的路线走从 YOLOv8 导出 ONNX 开始搭好 C 工程分别处理预处理、推理调用、NMS 后处理这几个环节最后把 Session 配置和内存复用这些影响性能的细节讲透。适合两类人一是用 C 做桌面或嵌入式视觉应用的开发二是在 vscode 里配 C/C 环境想跑通模型部署的新手。2. 从 YOLOv8 到 ONNX再准备 C 工程依赖2.1 用官方导出脚本生成推理用 ONNX 模型YOLOv8 的推理模型和训练模型不是一个东西。训练完成后需用 ultralytics 包里的 export 功能把模型转成 ONNX 格式。常见做法是直接在训练环境里执行yolo export modelyolov8n.pt formatonnx opset12 simplifyTrue参数说明model支持 .pt 权重路径或已训练好的模型文件yolov8n.pt 是官方轻量模型导出后的 ONNX 约 12MB适合先跑通流程。opsetONNX 算子集版本OnnxRuntime 1.15 以上对 opset 12 兼容性最稳opset 太高在旧版推理引擎上可能报未知算子。simplify用 onnxsim 做常量折叠和图优化能去掉一部分冗余 reshape 和 transpose 节点。导出完成后用 Python 快速验证输出形状import onnxruntime as ort import numpy as np session ort.InferenceSession(yolov8n.onnx, providers[CPUExecutionProvider]) for info in session.get_inputs(): print(info.name, info.shape, info.type) for info in session.get_outputs(): print(info.name, info.shape, info.type)这段代码会打印输入输出张量的名称和维度和 C 端要拿到的信息一一对应后面读输入输出名时会用到。YOLOv8 的 OnnxRuntime 导出版本输出通常是 1x84x8400 或者 1x84x84001x4x8400 的组合取决于模型是否带 NMS 模块——建议导出不带 NMS 的版本把后处理放回 C 自己控制否则布局和类名映射受导出工具约束。2.2 下载 OnnxRuntime 库并配置 CMake 工程C 侧依赖只有 OnnxRuntime 一个 SDK。官方为 Windows 和 Linux 发布预编译动态库解压后目录里包含 include/ 和 lib/既有 .lib 导入库也有 .dll/.so 运行时。Windows 上常见的坑是运行时不带 Visual C Redistributable 导致启动报错直接把 vcruntime140.dll 对应版本装好即可。CMakeLists.txt 的最小写法cmake_minimum_required(VERSION 3.16) project(yolov8_onnx) set(CMAKE_CXX_STANDARD 17) find_library(ONNXRUNTIME_LIB onnxruntime PATHS ${ONNXRUNTIME_ROOT}/lib) add_executable(yolov8_onnx main.cpp) target_include_directories(yolov8_onnx PRIVATE ${ONNXRUNTIME_ROOT}/include) target_link_libraries(yolov8_onnx PRIVATE ${ONNXRUNTIME_LIB})核心是find_library把 OnnxRuntime 的库路径指给链接器ONNXRUNTIME_ROOT作为外部变量传进来。还需要把头文件目录暴露给编译器。注意在 Windows 上OnnxRuntime 的动态库和导入库同名链接 .lib 后运行时仍需 onnxruntime.dll 在可执行文件目录或系统 PATH 中Linux 上需要用ldd确认 .so 被找到。3. C 端 OnnxRuntime 推理管线搭建3.1 初始化 Session 并绑定输入输出C 推理入口先创建 Ort::Env 和 Session。Env 负责线程池和日志等级Session 负责加载模型、分配内存#include onnxruntime_cxx_api.h #include opencv2/opencv.hpp Ort::Env env(ORT_LOGGING_LEVEL_WARNING, yolov8_engine); Ort::SessionOptions session_options; session_options.SetIntraOpNumThreads(4); session_options.SetGraphOptimizationLevel( GraphOptimizationLevel::ORT_ENABLE_ALL); Ort::Session session(env, Lyolov8n.onnx, session_options);逻辑说明SetIntraOpNumThreads(4)限制单次推理内算子并行线程数ORT_ENABLE_ALL打开图优化把相邻算子融合掉这对 CNN 类模型提升明显。注意 Windows 下 Session 构造函数第二个参数是宽字符串L...Linux 用普通字符串或std::string均可。拿到输入输出信息的代码auto input_name session.GetInputNameAllocated(0, Ort::Allocator::Default()); auto output_name session.GetOutputNameAllocated(0, Ort::Allocator::Default()); Ort::TypeInfo input_info session.GetInputTypeInfo(0); auto input_shape input_info.GetTensorTypeAndShapeInfo().GetShape(); std::cout input: input_name.get() shape: input_shape[0] x input_shape[1] x input_shape[2] x input_shape[3] std::endl;GetInputNameAllocated的返回值是分配器管理的字符串指针用get()访问。拿到 shape 是为了在预处理时显式确认输入布局为 NCHW常见的 YOLOv8 输入是 1x3x640x640。3.2 预处理letterbox 等比缩放与归一化YOLOv8 训练时用 640x640 正方形输入但真实图片宽高比不固定。直接把图片拉伸到 640x640 会改变目标形状影响检测精度。正确做法是 letterbox——等比缩放并补灰边cv::Mat letterbox(const cv::Mat src, cv::Mat pad, int target_size 640) { float scale std::min(target_size * 1.0f / src.cols, target_size * 1.0f / src.rows); int new_w static_castint(src.cols * scale); int new_h static_castint(src.rows * scale); cv::Mat resized; cv::resize(src, resized, cv::Size(new_w, new_h)); pad cv::Mat::zeros(target_size, target_size, CV_8UC3); pad.setTo(cv::Scalar(114, 114, 114)); int dx (target_size - new_w) / 2; int dy (target_size - new_h) / 2; resized.copyTo(pad(cv::Rect(dx, dy, new_w, new_h))); return pad; }参数说明:pad矩阵使用 114 作为填充色和 YOLOv8 训练时的默认一致; 如果模型是 .pt 训练时输入 1280则把 target_size 改成 1280同时后处理中的坐标缩放比例也要相应调整。这个函数返回的是 BGR 图像ONNX 模型的输入约定也是 BGR不需要额外通道转换。下一步把 cv::Mat 转成连续内存的 float 数组并执行 HWC→CHW 和归一化std::vectorfloat input_tensor_data(1 * 3 * 640 * 640); float* data_ptr input_tensor_data.data(); for (int c 0; c 3; c) { for (int h 0; h 640; h) { for (int w 0; w 640; w) { cv::Vec3b pixel padded.atcv::Vec3b(h, w); data_ptr[c * 640 * 640 h * 640 w] pixel[c] / 255.0f; } } }三段循环把像素从 BGR 交错内存排布读出来按通道连续排布写入输入缓冲区同时除以 255 归一化到 [0,1]。3.3 构造 Ort 输入张量并执行推理把预处理后的数据包装成 OnnxRuntime 能认的张量std::vectorint64_t input_shape {1, 3, 640, 640}; Ort::MemoryInfo memory_info Ort::MemoryInfo::CreateCpu( OrtArenaAllocator, OrtMemTypeDefault); Ort::Value input_tensor Ort::Value::CreateTensorfloat( memory_info, input_tensor_data.data(), input_tensor_data.size(), input_shape.data(), input_shape.size()); std::vectorconst char* input_names {input_name.get()}; std::vectorconst char* output_names {output_name.get()}; auto output_tensors session.Run(Ort::RunOptions{nullptr}, input_names.data(), input_tensor, 1, output_names.data(), output_names.size());逻辑说明CreateTensorfloat的第一个参数指定 CPU 内存分配器如果后续要跑 CUDA EP这一步得改用 GPU 分配的缓冲区。session.Run的第三个参数是输入 Value 数组指针input_tensor取出第一个张量的地址传入。输出结果是std::vectorOrt::Value每条 Value 对应一个输出张量。拿到输出后把它转成二维数组方便解析float* output_data output_tensors[0].GetTensorMutableDatafloat(); auto output_shape output_tensors[0].GetTensorTypeAndShapeInfo().GetShape(); int num_boxes static_castint(output_shape[2]);这里以输出维度 1x84x8400 为例84 是 4 个坐标 80 个类别8400 是三个特征图堆叠出的检测框数量。注意GetTensorMutableDatafloat直接拿到内部内存指针读取前确认模型输出类型是 float否则需要转换为 float。4. 输出解析与 NMS 后处理4.1 从 84x8400 输出中解码检测框YOLOv8 的输出格式和 YOLOv5 不同。YOLOv5 输出的是 cx, cy, w, h 加上类别概率而 YOLOv8 输出的是每个检测框的 4 个坐标中心点 x、中心点 y、宽、高和 80 个类别得分。解码核心是先找最高类别分再过滤低置信度框struct Detection { float x1, y1, x2, y2; int class_id; float confidence; }; std::vectorDetection decode_output(float* data, float conf_threshold 0.25) { std::vectorDetection detections; int num_channels 84; for (int i 0; i 8400; i) { float* row data i * num_channels; int class_id 0; float max_score 0.0f; for (int j 4; j 84; j) { if (row[j] max_score) { max_score row[j]; class_id j - 4; } } if (max_score conf_threshold) continue; float cx row[0], cy row[1]; float w row[2], h row[3]; Detection det; det.x1 cx - w / 2; det.y1 cy - h / 2; det.x2 cx w / 2; det.y2 cy h / 2; det.class_id class_id; det.confidence max_score; detections.push_back(det); } return detections; }逻辑说明row按行取第 i 个检测框的 84 个值前 4 位是坐标后 80 位是类别概率找到最大值对应的索引即为 class_id。坐标是模型输入图坐标系下的数值需按 letterbox 的缩放和 padding 换算回原图。4.2 手写 NMS 过滤重叠框同一目标会被多个检测框覆盖NMS 是必做步骤。用普通数组实现即可不需要额外依赖std::vectorDetection nms(std::vectorDetection detections, float iou_threshold 0.5) { std::vectorDetection result; std::sort(detections.begin(), detections.end(), [](const Detection a, const Detection b) { return a.confidence b.confidence; }); std::vectorbool suppressed(detections.size(), false); 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) { float inter_x1 std::max(detections[i].x1, detections[j].x1); float inter_y1 std::max(detections[i].y1, detections[j].y1); float inter_x2 std::min(detections[i].x2, detections[j].x2); float inter_y2 std::min(detections[i].y2, detections[j].y2); float inter_area std::max(0.0f, inter_x2 - inter_x1) * std::max(0.0f, inter_y2 - inter_y1); float union_area (detections[i].x2 - detections[i].x1) * (detections[i].y2 - detections[i].y1) (detections[j].x2 - detections[j].x1) * (detections[j].y2 - detections[j].y1) - inter_area; if (inter_area / union_area iou_threshold) { suppressed[j] true; } } } return result; }参数说明iou_threshold0.5是 COCO 评测的默认值追求高召回时可调到 0.7。suppressed数组用布尔标记是否保留避免反复删除 vector 元素带来的拷贝开销。排序按置信度降序保证高置信度框先参与抑制。4.3 把检测框映射回原图坐标letterbox 后坐标在原图上需要还原。假设原图为src_w x src_h缩放 scale 和 padding 偏移 dx、dy 来自之前预处理函数void remap_detections(std::vectorDetection dets, float scale, int dx, int dy) { for (auto d : dets) { d.x1 (d.x1 - dx) / scale; d.y1 (d.y1 - dy) / scale; d.x2 (d.x2 - dx) / scale; d.y2 (d.y2 - dy) / scale; } }这一步漏掉的话画框位置会整体偏移尤其非正方形图片上会出现检测框贴合目标但位置偏下的现象。4.4 类别映射与目标数量变化如果用的是官方 COCO 版本80 个类别按 YOLOv8 默认顺序排列——第 0 类是 person第 39 类是 bottle。用自己的数据集训练的模型导出 ONNX 后类别顺序和训练时的 data.yaml 中 names 字段一致。常见错误是拿 COCO 的顺序套自定义模型导致框画对但标签全错。可以用一个 vector 做映射std::vectorstd::string class_names { person, bicycle, car, /* ... 按模型实际类别填 ... */ }; std::cout class: class_names[d.class_id] conf: d.confidence bbox: d.x1 , d.y1 , d.x2 , d.y2 std::endl;输出的框坐标单位是原图像素如果想做成没有外部依赖的控制台检测程序到这里已经可以把坐标写到文件供后端消费。画框到图片上仍需 OpenCV 的cv::rectangle和cv::putText。5. OnnxRuntime 性能调参与部署边界5.1 Session 线程数与执行模式选择OnnxRuntime 默认会占满所有 CPU 核心但实际推理模型的延迟并不随线程数线性下降。CNN 的前几层是空间卷积并行度高后几层是 1x1 卷积和全局池化线程多了反而在同步上浪费时间。常见做法是把SetIntraOpNumThreads设为物理核心数的一半并配合运行环境测试session_options.SetIntraOpNumThreads(4); session_options.SetExecutionMode(ExecutionMode::ORT_SEQUENTIAL);ORT_SEQUENTIAL表示算子按图顺序逐个执行适合单模型严格按顺序推理的场景; 如果同一进程里多个模型实例并行考虑ORT_PARALLEL但这时需要SetInterOpNumThreads控制模型间并行度轻易别开大线程调度开销会吞掉收益。5.2 输入输出内存复用避免反复分配推理一次就创建一次Ort::Value是个隐蔽的性能陷阱。把输入缓冲区复用起来std::vectorfloat input_data(1 * 3 * 640 * 640); std::vectorint64_t input_shape {1, 3, 640, 640}; Ort::Value input_tensor Ort::Value::CreateTensorfloat( memory_info, input_data.data(), input_data.size(), input_shape.data(), input_shape.size()); while (true) { // 读帧, letterbox, 填入 input_data auto output_tensors session.Run(...); // 解析结果 }input_data是外部 vectorCreateTensor 只是包了一层接口真正干活时把数据写进这个 vector 即可。注意每次session.Run后输出 Value 持有的内存不能跨迭代保存指针因为下一次 Run 可能复用底层内存区取出的GetTensorMutableData指针只对当次输出有效。5.3 减少预处理拷贝的若干技巧cv::resize默认线性插值对缩小图片没必要改成cv::INTER_AREA在降采样时更平滑对检测精度无负面影响。letterbox 中setTo(114)的填充和前三次归一化循环可以合并先申请 CV_32FC3 的 padded 矩阵把归一化和填充一次性做完内存写入少一半。多路视频流场景下预处理可以用独立线程池并行做但注意 OnnxRuntime 推理线程和内存在同一进程内共享预处理线程数加推理线程数不要超过物理核心数不然切换开销反而增大。5.4 部署前的检查清单与典型错误做一个快速验证# Linux 下查动态库依赖 ldd yolov8_onnx | grep onnx # Windows 下可在 PowerShell 执行 dumpbin /dependents yolov8_onnx.exe确认 onnxruntime.dll/.so 被正确加载。还有一类问题与模型文件有关用 Python 导出模型时没有装 onnxsim输出的 ONNX 里混着不少 Identity 和 Cast 节点在 C 里ORT_ENABLE_ALL能吃掉一部分但最好在导出时就把 simplify 打开。下面用表格汇总几个高频报错和应对方向现象可能原因处理方式启动即崩报找不到 onnxruntime动态库不在 PATH把 dll/so 复制到可执行文件目录或设置 LD_LIBRARY_PATH推理输出全是 0输入归一化范围错误或通道顺序不对确认像素除以 255BGR 排布符合模型要求检测框整体向右下偏移letterbox 的 dx/dy 未传给坐标还原把预处理阶段的 pad 量存起来在后处理中减掉输出维度是 1x84x8400 但解析越界模型导出了带 NMS 的版本重新导出不带 NMS 的 ONNX或用session.GetOutputCount()检查输出个数GPU 版本无法加载CUDA/cuDNN 与 OnnxRuntime 版本不匹配查看官方版本兼容表; 先用 CPU EP 跑通逻辑再切 GPU如果追求更低延迟可以用 onnxruntime 提供的内存池接口在创建 Session 时传入Ort::MemoryInfo指定为 CUDA Pinned MemoryCPU 端预处理后把数据拷贝到 pinned 内存再送 GPU省去一次 PCIe 传输。这个优化在 1080Ti/1660Ti 这类老显卡上收益明显但推理帧率本身已接近实时时瓶颈往往在cv::resize而不是推理先把预处理放到另一个线程再做这个优化。YOLOv8 的 C 部署做到这里已经能跑通完整链路导出模型、初始化 Session、预处理、推理、NMS、坐标还原。剩下的工作按业务场景接输出即可。全文完本文还有配套的精品资源点击获取
返回列表