ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

YOLOv5到v11统一推理实战:C++与Python双语言部署指南

YOLOv5到v11统一推理实战:C++与Python双语言部署指南 简介面向目标检测与深度学习部署开发者这份代码包覆盖YOLOv5到YOLOv11全系列模型的推理实现提供C与Python两种语言版本。支持Libtorch/PyTorch、ONNXRuntime、OpenCV、OpenVINO、TensorRT等主流推理后端同时适配分类、检测、分割三类任务并兼容FP32、FP16、INT8精度模型方便在不同硬件环境下灵活选用。压缩包共42个文件大小约60KB包含14个Python脚本、10个C头文件、8个C源文件以及CUDA代码、编译运行脚本和说明文档工程按Python与C分成两个主目录并将Libtorch、ONNXRuntime、TensorRT、OpenVINO等后端独立编排结构清晰便于对照不同后端的调用逻辑。目前已有2351人学习下载。资源提供了从模型加载、预处理到推理输出解析的完整示例读者可直接参考工程配置与核心代码快速集成多个YOLO版本减少重复搭建环境的时间也可作为多后端部署方案的对比参考适用于需要统一维护多个模型版本的工程团队。 从接触到YOLOv5开始到后来的YOLOv6、v7、v8、v9、v10再到刚出不久的YOLOv11这些模型在目标检测领域的迭代速度确实快。我最近刚好把这一整个系列的推理代码在C和Python两套环境里完整跑通踩了不少坑也梳理出了一套比较通用的流程。这篇博文就基于这次项目实践聊聊从v5到v11的推理实现思路、关键代码、以及在C和Python两种语言下落地时要注意的事情。这个项目解决的问题很直接不管模型是v5还是v11都能用同一套推理框架跑起来重点是让后处理和输出格式兼容避免每次换模型就重写一遍算法。适合的对象包括刚接触YOLO推理的初学者、准备把模型部署到生产环境的工程师以及想在C和Python之间做选型对比的开发者。1. 项目概述为什么我要把七代YOLO放在一起做推理1.1 这个项目解决的实际问题在实际项目中经常遇到两种情况一种是在服务器端做离线批量检测Python脚本最快另一种是在摄像头采集、边缘设备或者嵌入式环境里做实时推理这时候C更稳。更麻烦的是模型版本一直在升级前两个月才用上YOLOv8转眼就到了YOLOv10、YOLOv11。每换一个版本如果推理代码就要推倒重来那开发周期会被严重拖垮。我这次的思路很简单——把所有版本的YOLO模型先统一导出成ONNX格式ONNX是跨框架、跨语言通用的模型中间表示然后用统一的预处理、前向计算、后处理逻辑去适配它们。这样C端和Python端各自只需要维护一套核心代码切换模型时只改输入尺寸、类别数量、输出解析方式这几个参数即可。1.2 核心难点和整体技术栈整个项目中真正花时间的不是调用模型推理而是三点不同版本输出格式差异、C环境下的张量处理和内存管理、后处理中坐标映射与NMS逻辑的统一。模型结构本身由各版本官方仓库负责我们要做的是把“输入图片到输出检测框”这条链路彻底打通。我使用的技术栈如下Python端PyTorch导出ONNX、ONNX Runtime推理、NumPy做后处理、OpenCV做图像读取和绘制。C端ONNX Runtime C API、OpenCV 4.x、CMake构建工程。硬件环境NVIDIA GPU支持CUDA加速同时兼容CPU模式。提示C和Python两端共用同一份ONNX模型文件是保证两边推理结果一致的最简单办法。两边预处理、后处理的参数必须保持完全一致否则同一张图在不同语言里会得到不同的框。2. YOLO家族演进从v5到v11推理代码改动点在哪里2.1 六代模型的核心结构变化很多同学以为自己用的是YOLOv11其实打开ONNX一看发现输出张量还是老一套。理解每个版本的结构差异是写出通用推理代码的前提。YOLOv5是典型的anchor-based检测头输出三个尺度的特征图每个位置基于预设anchor预测边界框需要通过解码加上anchor偏移得到最终坐标。YOLOv6引入了解耦头和anchor-free机制将分类和回归分支分开后面几个版本基本延续了这个思路。YOLOv7走的是高效聚合网络E-ELAN路线其输出格式在v5基础上做了优化但对外暴露的检测头仍带anchor信息。YOLOv8开始官方代码里已经彻底转为anchor-free同时引入DFLDistribution Focal Loss简单理解就是每个边界框边不再直接回归一个数值而是预测一组离散分布再从分布求解最终坐标。YOLOv9的GELAN结构属于主干和颈部网络的重设计推理输出格式和v8保持一致。YOLOv10做了端到端目标检测的尝试去掉了传统NMS增加了一对一匹配头。最新的YOLOv11在主干上调整了C3k2、C2PSA等模块整体还是anchor-free加DFL那套输出逻辑。所以最终ONNX导出后v8、v9、v11的输出格式是基本一致的v5和v7是一类v10是另一类。2.2 输出格式差异对比为了让后续代码能够统一处理我把各版本输出整理成了一张表这也是写推理程序前必看的内容。模型版本输出Shape以640输入、COCO 80类为例解码方式是否需NMSYOLOv5(1, 25200, 85) 三尺度合并后基于anchor解码x,y,w,h需要YOLOv6(1, 8400, 84) anchor-free直接输出xywh需要YOLOv7(1, 35400或按版本不同, 85)含anchor基于anchor解码需要YOLOv8(1, 84, 8400)DFL解码直接输出xywh需要YOLOv9(1, 84, 8400)DFL解码直接输出xywh需要YOLOv10(1, 80, 8400) 或 (1, 84, 8400)端到端输出one-to-one结果不需要传统NMS按阈值过滤即可YOLOv11(1, 84, 8400)DFL解码直接输出xywh需要注意一个细节v5和v7导出的ONNX输出是(1, 25200, 85)这种三维形态而v8和v11则是(1, 84, 8400)这种通道在前的形态。v10如果启用端到端模式输出往往不需要再做NMS直接用score阈值筛选并用TopK选出最终框。3. Python端推理一套代码通吃全部版本的实现方案3.1 环境准备与模型导出Python端我建议虚拟环境独立安装依赖。PyTorch按官方推荐的CUDA版本安装ONNX Runtime通过pip安装即可。conda create -n yolo_infer python3.10 conda activate yolo_infer pip install torch torchvision onnx onnxruntime opencv-python numpy模型导出我统一用各版本官方仓库的脚本。以ultralytics系列的v8和v11为例命令非常简洁yolo export modelyolov8n.pt formatonnx imgsz640 opset12 yolo export modelyolov11n.pt formatonnx imgsz640 opset12对于v5仓库python export.py --weights yolov5s.pt --include onnx --img 640 --batch 1导出的时候主要关注opset版本ONNX Runtime新版对opset的支持已经很好opset 12到17都可以。如果导出后提示某些算子不兼容优先降低opset到11或12再试。注意imgsz尽量固定为实际推理的尺寸。导出的ONNX在动态尺寸和固定尺寸之间我一般选择固定尺寸这样在C端解析输出shape时不需要额外处理动态维度代码更省事。3.2 预处理与通用推理流程预处理做的事情按顺序是读取图片、等比缩放填充到640×640、归一化到0~1、将HWC转成CHW并增加batch维度。这一步在C端要一模一样地写否则结果对不上。我在Python端封装了一个preprocess函数import cv2 import numpy as np def letterbox(img, new_shape(640, 640), color(114, 114, 114)): shape img.shape[:2] r min(new_shape[0] / shape[0], new_shape[1] / shape[1]) new_unpad (int(round(shape[1] * r)), int(round(shape[0] * r))) dw, dh new_shape[1] - new_unpad[0], new_shape[0] - new_unpad[1] dw, dh dw / 2, dh / 2 if shape[::-1] ! new_unpad: img cv2.resize(img, new_unpad, interpolationcv2.INTER_LINEAR) top, bottom int(round(dh - 0.1)), int(round(dh 0.1)) left, right int(round(dw - 0.1)), int(round(dw 0.1)) img cv2.copyMakeBorder(img, top, bottom, left, right, cv2.BORDER_CONSTANT, valuecolor) return img, r, (dw, dh)拿到letterbox结果后再转成输入张量def to_tensor(img): img img.astype(np.float32) / 255.0 img np.transpose(img, (2, 0, 1)) img np.expand_dims(img, axis0).astype(np.float32) return img推理本身很简单。ONNX Runtime加载模型后指定输入输出名称调用run接口即可。import onnxruntime as ort session ort.InferenceSession(yolov8n.onnx, providers[CUDAExecutionProvider, CPUExecutionProvider]) input_name session.get_inputs()[0].name output_name session.get_outputs()[0].name def infer(img_tensor): result session.run([output_name], {input_name: img_tensor}) return result[0]输出是一个numpy数组。如果你用的是v8或者v11shape是(1, 84, 8400)需要先转置成(8400, 84)再做后处理。如果你用的是v5输出shape是(1, 25200, 85)转置成(25200, 85)即可。3.3 后处理统一封装与坐标还原后处理是所有版本差异最大的地方我用一个策略把输出统一成“候选框列表”然后走同一个NMS流程。对于v5和v7输出是xywh加objectness加class scores需要乘上anchor解码。为简化处理我建议在导出ONNX时用官方自带的decode逻辑或者直接选不带decode的版本然后在后处理里根据模型源码补上anchor转换。比较费时间但好处是不用为每个模型单独维护C代码。实际项目中我倾向于把解码逻辑放到Python端完成然后将中间结果保存成通用数组方便检查和调试。对于v8和v11输出已经是xywh形式用sigmoid处理置信度即可def postprocess(pred, conf_thres0.25, iou_thres0.45, num_classes80): pred pred[0].transpose(-1, -2) # (8400, 84) boxes pred[..., :4] scores pred[..., 4:] cls_conf scores.max(axis-1) cls_id scores.argmax(axis-1) mask cls_conf conf_thres boxes boxes[mask] cls_conf cls_conf[mask] cls_id cls_id[mask] # xywh to xyxy boxes_xyxy np.concatenate([ boxes[:, :2] - boxes[:, 2:] / 2, boxes[:, :2] boxes[:, 2:] / 2 ], axis-1) # NMS 或用 cv2.dnn.NMSBoxes keep cv2.dnn.NMSBoxes(boxes_xyxy.tolist(), cls_conf.tolist(), conf_thres, iou_thres) if len(keep) 0: return [] keep keep.flatten() return boxes_xyxy[keep], cls_conf[keep], cls_id[keep]坐标映射时因为输入图是经过letterbox处理的所以输出坐标要先减掉padding偏移再除缩放比例才能映射回原图坐标。def scale_boxes(boxes_xyxy, org_shape, input_shape(640, 640)): r min(input_shape[0] / org_shape[0], input_shape[1] / org_shape[1]) dw (input_shape[1] - org_shape[1] * r) / 2 dh (input_shape[0] - org_shape[0] * r) / 2 boxes_xyxy[:, [0, 2]] (boxes_xyxy[:, [0, 2]] - dw) / r boxes_xyxy[:, [1, 3]] (boxes_xyxy[:, [1, 3]] - dh) / r return boxes_xyxy.clip(min0)对于YOLOv10后处理更简单因为官方已经消除了NMS依赖。直接用阈值过滤输出再TopK取最高分的k个框即可。3.4 保存推理结果到本地我习惯把每个检测结果保存为图片加标签文本。图片上用OpenCV画矩形框和类别文本文件用统一的“class_id x_center y_center width height”格式方便后续训练集制作和数据统计。关键代码如下with open(result.txt, w) as f: for box, conf, cls in zip(boxes, confs, ids): x1, y1, x2, y2 box f.write(f{cls} {(x1x2)/2/img_w} {(y1y2)/2/img_h} {(x2-x1)/img_w} {(y2-y1)/img_h}\n)4. C端推理基于ONNX Runtime的生产级部署4.1 为什么选择ONNX Runtime而不是OpenCV DNN很多朋友一上来就问我C里能不能直接加载darknet权重有没有用OpenCV的DNN模块我说可以但对于v8到v11的新模型OpenCV DNN经常不支持某些新算子比如DFL中的卷积操作有时会被旧版OpenCV误判。如果只是为了跑个demoOpenCV DNN确实方便但在生产环境中我更推荐ONNX Runtime理由是算子支持更全、GPU加速稳定、能直接吃通过ultralytics导出的ONNX文件。如果你环境里已经装了CUDA和cuDNNONNX Runtime同时能启用CUDAExecutionProvider推理速度比纯CPU快一个量级。具体安装可以在ONNX Runtime官网选对应版本配合CUDA版本匹配。Windows下还需要安装Microsoft Visual C Redistributable否则运行时会提示缺少dll文件这个坑很多人踩过。4.2 工程结构和CMake配置C工程我用的是标准三层目录结构yolo_infer_cpp/ ├── include/ │ ├── yolo_detector.hpp │ └── common.hpp ├── src/ │ ├── main.cpp │ └── yolo_detector.cpp ├── models/ │ └── yolov8n.onnx └── CMakeLists.txtCMakeLists.txt的编写要点是找到OpenCV和ONNX Runtime的头文件与库目录。我尽量用绝对路径避免自动查找失败cmake_minimum_required(VERSION 3.20) project(YoloInfer) 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(yolo_infer main.cpp src/yolo_detector.cpp) target_link_libraries(yolo_infer ${OpenCV_LIBS} onnxruntime)4.3 关键代码实现先看模型加载和session创建。ONNX Runtime在C中通过Ort::Session对象管理模型。#include onnxruntime_cxx_api.h #include opencv2/opencv.hpp #include vector #include string class YoloDetector { public: YoloDetector(const std::string model_path, int input_size 640) : input_size_(input_size) { Ort::Env env(ORT_LOGGING_LEVEL_WARNING, yolo_infer); Ort::SessionOptions session_options; session_options.SetIntraOpNumThreads(4); session_options.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL); session_ std::make_uniqueOrt::Session(env, model_path.c_str(), session_options); // 获取输入输出名称存入成员变量 Ort::AllocatorWithDefaultOptions allocator; input_name_ session_-GetInputNameAllocated(0, allocator).get(); output_name_ session_-GetOutputNameAllocated(0, allocator).get(); } std::vectorDetection detect(const cv::Mat image); private: int input_size_; std::unique_ptrOrt::Session session_; std::string input_name_; std::string output_name_; };推理前需要把Mat转成float数组。代码中最容易出错的是内存布局必须确保数据是一段连续内存且通道顺序是CHW。做预处理时我直接遍历像素填充vectorstd::vectorfloat input_tensor_values(input_size_ * input_size_ * 3); int channel_length input_size_ * input_size_; cv::Mat resized; resize(image, resized, cv::Size(input_size_, input_size_)); for (int c 0; c 3; c) { for (int i 0; i input_size_; i) { for (int j 0; j input_size_; j) { cv::Vec3b pixel resized.atcv::Vec3b(i, j); float val pixel[c] / 255.0f; input_tensor_values[c * channel_length i * input_size_ j] val; } } }接着构建Ort::Value输入运行sessionstd::vectorint64_t shape {1, 3, input_size_, input_size_}; auto memory_info Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault); Ort::Value input_tensor Ort::Value::CreateTensorfloat( memory_info, input_tensor_values.data(), input_tensor_values.size(), shape.data(), shape.size()); auto output_tensors session_-Run( Ort::RunOptions{nullptr}, input_name_, input_tensor, 1, output_name_, 1);拿到输出后按照前一节Python的规则做转置、过滤、NMS。C中NMS我直接用线性复杂度实现避免额外依赖第三方库std::vectorint nms(const std::vectorcv::Rect boxes, const std::vectorfloat scores, float iou_threshold) { std::vectorint indices; std::vectorint order(scores.size()); std::iota(order.begin(), order.end(), 0); std::sort(order.begin(), order.end(), [](int i, int j) { return scores[i] scores[j]; }); // 按置信度从高到低遍历计算IoU高于阈值则抑制 while (!order.empty()) { int idx order.front(); order.erase(order.begin()); indices.push_back(idx); cv::Rect box boxes[idx]; for (auto it order.begin(); it ! order.end();) { float iou calc_iou(box, boxes[*it]); if (iou iou_threshold) { it order.erase(it); } else { it; } } } return indices; }这个版本实现简单性能也足够。如果目标数量特别多比如上千个框再用预筛选和最大堆优化但在实际生产里一般检测框就几十个这段代码完全够用。4.4 性能与内存优化C端推理的瓶颈往往不在模型本身而在图像预处理和结果绘制的效率上。我在项目里做过几个优化效果比较明显输入图像如果尺寸很大先等比缩放到640附近不要直接用原图尺寸跑模型。用cv::dnn::blobFromImage替代手动三重for循环它内部会做归一化和HWC转CHW耗时下降不少。推理结果直接使用float数组运算避免频繁构造std::vector cv::Rect 导致内存反复分配。多线程场景下每个线程创建各自的Ort::Session实例不要在多个线程之间共享同一个session做并发推理否则会因为内部状态竞争导致性能下降甚至崩溃。5. 常见问题与排查技巧实录5.1 CUDA环境与GPU相关的问题“AMD 580显卡能跑YOLO吗需要装CUDA吗”这类问题经常出现在新手的提问里。AMD显卡的ROCm生态在Linux下有所支持但生产环境我用得最多的还是NVIDIA显卡配CUDA。如果你的显卡不是NVIDIA最稳妥的方案是CPU推理模型为nano版本时640输入下CPU也能跑到几十毫秒一帧对很多场景已经够用。ONNX Runtime启用CUDA时要严格匹配CUDA版本和cuDNN版本版本不匹配最常见的报错是“DLL load failed”或者“Provider not found”。我的建议是直接用ONNX Runtime预编译包里自带的CUDA版本对应表不要自己随意混搭。5.2 输出Shape和类别数量对不上v8和v11默认按COCO 80类导出输出维度是84480。如果你用官方仓库在自定义数据集上训练导出后输出维度就变成了4N。在Python端可以通过inspect session output shape来确认print(session.get_outputs()[0].shape)在C端最好用session的输出信息动态读取shape而不是写死auto output_info session_-GetOutputTypeInfo(0); auto shape output_info.GetTensorTypeAndShapeInfo().GetShape();这样就算模型更新也能自动适配输出。5.3 推理结果与原图坐标偏了这个问题大多数是因为letterbox的padding和缩放比例计算不一致。尤其是当原图宽高比和输入尺寸不一致时如果忘记把坐标减去padding框就会整体偏移。我建议把letterbox函数里的r和(dw, dh)作为结构体返回在坐标映射时使用同一组值两边统一后测试通过。5.4 常见问题速查表现象可能原因排查方向运行报错缺少onnxruntime.dll未安装Visual C Redistributable安装对应VC运行库推理结果全为空置信度阈值过高 / 预处理归一化错误调低conf_thres检查除以255是否生效输出框严重偏移letterbox填充参数不一致检查top/left偏移量和缩放比例v8导出ONNX在OpenCV DNN中报错算子不受支持改用ONNX RuntimeGPU推理比CPU还慢数据未拷贝到GPU / session未启用CUDA检查ExecutionProvider列表v10结果有大量重复框未正确理解端到端输出确认是否走one-to-one分支不需要NMS6. 实操中打磨的一些体会我个人实际项目里最常用的组合是Python端做模型验证和结果可视化C端做最终的线上服务或边缘设备部署。两边共用ONNX模型这事省掉了非常多麻烦。踩过不少坑之后我现在的代码已经沉淀成一套通用工具不管是换版本还是换语言只改配置参数和极个别后处理分支。如果你刚开始做YOLO推理我建议先不要追求把C写得多炫先用Python把整个链路跑通把预处理、推理、后处理每一步的结果打印出来和官方预测对比确认一致后再迁移到C。迁移时也尽量保留相同的数据结构和变量名Debug时能省很多时间。最后分享一个小技巧给C工程加一个“保存中间结果”的开关比如把letterbox后的图像和NMS前的候选框输出到本地目录。这样无论模型怎么换只要对比这些中间文件就能快速定位是预处理错了、模型加载错了还是后处理解析错了。这套方法我第一次用的时候直接在半小时内排查出了一个困扰两天的坐标偏移问题强烈建议照做。本文还有配套的精品资源点击获取
返回列表