YOLO 训练好的模型怎么在 QT 下使用?从 ONNX 到 DirectML GPU 加速的完整落地
YOLO 训练这步现在很省心。Ultralytics 一条命令,best.pt转best.onnx,几分钟的事。
麻烦的是下一步。
模型交到桌面端,怎么用?我们的场景很典型:产线检测工具要发给产线工程师,对方机器上不能要求装 Python,更不能要求每台机器都有 N 卡。最后用 VS2019 + Qt 5.14.2 + ONNX Runtime(DirectML 版)做了个纯 C++ 的检测工具YoloDetQt,导入模型、批量检测、统计导出,一条链路走完,全程零 Python 依赖。
先看跑起来的效果。PCB 缺陷检测,六类缺陷,GPU 加速下单张 24ms:
模型导入之后,类别名、输入尺寸、执行设备全是自动解析的。换别的模型,一行代码不用动:
下面把整个过程摊开讲:工程怎么配,推理代码长什么样,CPU 和 GPU 的配置差在哪,以及当时为什么没走 CUDA 而是选了 DirectML。
整体路线:pt 是进不了 Qt 的,先转 onnx
一件事先说死:Qt/C++ 端加载不了.pt。别在这上面浪费时间,标准做法就两步。
第一步在训练环境里导出:
fromultralyticsimportYOLO model=YOLO("best.pt")model.export(format="onnx",imgsz=640)# 得到 best.onnx第二步是这篇文章的正题。
推理后端我们踩过一次坑。一开始试的 OpenCV DNN,想着能少带一个依赖库,结果 YOLO26 导出的计算图,OpenCV 4.14 的 legacy 导入器和 5.0 的新导入器都啃不动。换 ONNX Runtime,一次通过。而且后来发现它还有个更大的好处——DirectML 版本自带 GPU 加速,不需要 CUDA。这个后面单独说。
工程配置
整个工程就两样依赖:Qt5 管界面,onnxruntime 管推理。图像读写和画框全用 Qt 自带的 QImage 和 QPainter,连 OpenCV 都没带。
onnxruntime 的运行库直接随工程自带,目录长这样:
YoloDetQt/ ├── YoloDetQt.pro # qmake 工程入口 ├── CMakeLists.txt # CMake 入口(跟 .pro 二选一) ├── build_msvc.bat # 一键构建脚本 ├── deps/ │ └── onnxruntime/ # DirectML 版 ONNX Runtime │ ├── include/ # onnxruntime_cxx_api.h 等头文件 │ ├── lib/ # onnxruntime.lib │ └── bin/ # onnxruntime.dll + DirectML.dll ★ └── src/ ├── YoloDetector.* # 推理封装,本文核心 ├── DetectWorker.* # 批量推理后台线程 ├── MainWindow.* # 主界面 └── ...bin 里那个 DirectML.dll,是 GPU 加速的关键,必须和 exe 待在同一目录。记住这句话,后面有个坑就是它挖的。
qmake 配置没什么花头,头文件路径、链接库、构建完拷 DLL,三件事:
# ---- onnxruntime(随工程自带)---- ORT_ROOT = $$PWD/deps/onnxruntime INCLUDEPATH += $$ORT_ROOT/include LIBS += -L$$ORT_ROOT/lib -lonnxruntime win32 { # 源码是 UTF-8,不加这个 MSVC 会按本地代码页解析,中文字符串乱码 QMAKE_CXXFLAGS += /utf-8 # 构建完成后把三个 DLL 拷到 exe 目录 QMAKE_POST_LINK += $$QMAKE_COPY \"$$ORT_ROOT/bin/onnxruntime.dll\" \"$$OUT_PWD\" QMAKE_POST_LINK += $$QMAKE_COPY \"$$ORT_ROOT/bin/onnxruntime_providers_shared.dll\" \"$$OUT_PWD\" QMAKE_POST_LINK += $$QMAKE_COPY \"$$ORT_ROOT/bin/DirectML.dll\" \"$$OUT_PWD\" }CMake 那边同理,链接onnxruntime.lib,POST_BUILD 里拷同样三个 DLL,就不重复贴了。
顺带一句,/utf-8这个选项建议所有 Qt + MSVC 工程都无脑加上,不然界面中文乱码,这个坑不值得踩第二遍。
发布的时候,exe 旁边要带齐这几个文件:
| 文件 | 作用 | 缺了会怎样 |
|---|---|---|
onnxruntime.dll | 推理引擎本体 | 程序起不来 |
onnxruntime_providers_shared.dll | EP 共享库 | 部分提供者加载失败 |
DirectML.dll | DirectML 后端 | 静默回退 CPU,不报任何错 |
| Qt5*.dll / platforms/ 等 | Qt 运行时 | 界面起不来 |
表格里第三行要展开讲讲。DirectML.dll 缺失的时候程序不会崩、不会报错,只是悄悄用 CPU 跑,一切功能照常。我们就被这个静默回退坑过一次——工具发出去用了两天,同事说他那边怎么这么慢,一看执行设备,CPU。查下来是构建时 DLL 没拷过去。
从那以后我们在模型信息栏加了一行"执行设备"的显示。GPU 配置到底生没生效,界面上得能直接看出来,不能靠猜:
![]()
核心代码
推理封装成一个YoloDetector类,对上层就两个接口:load()和detect()。核心代码不到一百行。
加载模型,GPU 和 CPU 的分岔口就在这
DirectML 的 EP 入口函数,DML 版的 onnxruntime.dll 里是导出了的,但公共头文件没声明,得自己补一行 extern “C”。然后加载时先试着走 GPU,不行就落回 CPU:
extern"C"OrtStatus*ORT_API_CALLOrtSessionOptionsAppendExecutionProvider_DML(OrtSessionOptions*options,intdevice_id);boolYoloDetector::load(constQString&onnxPath,QString*error){m_env=std::make_unique<Ort::Env>(ORT_LOGGING_LEVEL_WARNING,"YoloDetQt");automakeOptions=[nThreads](){Ort::SessionOptions opts;opts.SetIntraOpNumThreads(nThreads);// CPU 线程数,默认 min(4, 核数)opts.SetGraphOptimizationLevel(ORT_ENABLE_ALL);returnopts;};booldmlOk=false;if(m_useGpu){// 先试 GPU (DirectML)try{Ort::SessionOptions opts=makeOptions();OrtStatus*st=OrtSessionOptionsAppendExecutionProvider_DML(opts,0);if(!st){m_session=std::make_unique<Ort::Session>(*m_env,onnxPath.toStdWString().c_str(),opts);dmlOk=true;}}catch(conststd::exception&){m_session.reset();// DML 不可用,落回 CPU}}if(!dmlOk){// CPU 路径Ort::SessionOptions opts=makeOptions();m_session=std::make_unique<Ort::Session>(*m_env,onnxPath.toStdWString().c_str(),opts);}m_info.provider=dmlOk?"GPU (DirectML)":"CPU";// ... 解析输入输出形状、类别名}这就是 CPU 和 GPU 的全部配置差异了。同一个 DLL,同一份代码,跑在哪个设备只取决于中间那一行AppendExecutionProvider_DML。CPU 路径什么都不用加,默认就是 CPU EP。
这个写法还有个白送的好处:DML 注册失败或者显卡不支持,会自动落到 CPU 上接着跑。工具发到任何一台 Windows 机器上都能用,区别只是快慢。
预处理:跟训练侧对不齐,全白搭
推理结果对不对,预处理说了算。这里必须和 Ultralytics 训练、验证时的处理严格一致:等比缩放、短边补 114 的灰边、RGB、除 255、NCHW。少对齐一项,精度就对不上。
// letterbox:等比缩放,短边补 114 灰边constfloatr=qMin(float(H)/ih,float(W)/iw);constintnh=qRound(ih*r),nw=qRound(iw*r);constinttop=(H-nh)/2,left=(W-nw)/2;QImage resized=image.convertToFormat(QImage::Format_RGB888).scaled(nw,nh,Qt::IgnoreAspectRatio,Qt::SmoothTransformation);// NCHW 浮点输入,padding 区保持 114 灰std::vector<float>input(size_t(W)*H*3,114.f/255.f);for(inty=0;y<nh;++y){constuchar*line=resized.constScanLine(y);float*rRow=input.data()+size_t(top+y)*W+left;for(intx=0;x<nw;++x){rRow[x]=line[x*3+0]/255.f;// R 平面rRow[x+plane]=line[x*3+1]/255.f;// G 平面rRow[x+2*plane]=line[x*3+2]/255.f;// B 平面}}前向
Ort::MemoryInfo mem=Ort::MemoryInfo::CreateCpu(OrtArenaAllocator,OrtMemTypeDefault);constint64_tshape[4]={1,3,H,W};Ort::Value inputTensor=Ort::Value::CreateTensor<float>(mem,input.data(),input.size(),shape,4);constchar*inName="images";// 从 session 读出的实际输入输出名constchar*outName="output0";autot0=std::chrono::steady_clock::now();std::vector<Ort::Value>outputs=m_session->Run(Ort::RunOptions{nullptr},&inName,&inputTensor,1,&outName,1);autot1=std::chrono::steady_clock::now();// t1 - t0 就是单张推理耗时写法跟 Python 端的session.run()一一对应,用过 Python 推理的人看一眼就懂。
解码和 NMS
YOLO26 的输出布局是[1, 4+nc, A],A 是锚点数,640 输入对应 8400。前 4 行是cx,cy,w,h,后面每行一个类别的分数,没有 objectness。
解码流程文字描述就够:遍历锚点取各类别最大分数,过 conf 阈值,按类别做贪心 NMS,最后把坐标经 letterbox 逆变换还原回原图,也就是(x - pad) / scale这么一下。
有两个细节容易翻车。
一是布局要自适应。YOLOv5 导出的图带 objectness(5+nc),YOLOv8/26 不带(4+nc),我们按rows == nc + 5自动区分,两类模型通吃。
二是类别名不用硬编码。Ultralytics 导出时把names写进了 ONNX 的metadata_props,手工解析 protobuf 把它取出来就行,多少个类别都能正确显示。
检测出来的效果,框按类别配色,置信度标在框上:
![]()
CPU 和 GPU 差多少
配置上的差异前面已经说了,就一行代码加一个 DLL。那性能呢?
我们在 RTX 3050 上跑了 100 张 640×640 的 PCB 图。CPU 四线程,单张 286.6ms,折合 3.5 FPS;换 DirectML 走 GPU,单张 24.1ms,41.4 FPS。十一倍多的加速。
比数字更重要的是另一件事:两边逐图比对过,检测出的目标数、类别分布完全一致。换后端没有引入任何数值偏差,这点验完我们才敢把 GPU 开关默认打开。
界面上的切换就是个勾选框,切完重载模型生效:
![]()
检测结果会缓存在内存里,改置信度阈值只重新解码、不重新推理,一百张图刷一遍不到一百毫秒。调阈值的时候这个体验差距非常大:
![]()
为什么不是 CUDA
这是选型时被问得最多的问题。我们的结论:分发给别人的 Windows 桌面工具,DirectML 就是最优解。CUDA 适合自家机房、硬件全可控的场景。
理由展开说。
最直接的一条,CUDA 只认 N 卡。DirectML 建在 DirectX 12 上,NVIDIA、AMD、Intel 全覆盖,办公本的核显也算数。我们发出去的工具,产线上一半机器是没有独显的办公机——走 CUDA 的话这些机器直接不可用,除非再维护一条 CPU 路径,等于两套逻辑两套测试。
第二条是部署。CUDA 方案的依赖清单:显卡驱动版本要匹配,CUDA Runtime 要装或者要带,cuDNN 要装或者要带,onnxruntime-gpu 的版本还得跟 CUDA、cuDNN 三者对齐。任何一台机器上组合不对,就是一串CUDA_ERROR_XXX,远程排查能把人折腾死。DirectML 呢?一个 DirectML.dll,18MB,放 exe 旁边,完事。驱动要求宽松到"支持 DX12 就行",近十年的卡全满足。
第三条,性能。说实话这是我最开始担心的,实测之后不担心了。24ms 一张、41 FPS 的吞吐,产线单张检测场景绰绰有余。DirectML 作为通用后端,极限性能确实比不过同卡深度调优的 CUDA + TensorRT,但那是服务端批处理该操心的事,一个桌面工具犯不着。
第四条算是保险:ONNX Runtime 的 DirectML EP 是微软官方维护的执行提供者,不是社区魔改,失败回退 CPU 的行为也是框架原生的。接入成本一行代码,鲁棒性白送。
一句话收尾:CUDA 快,但只快在 N 卡上;DirectML 稍慢一点,可它让工具在任何一台 Windows 机器上都跑得起来。桌面工具要的是发出去就能用,不是单机极限性能。
最后的建议
整个过程压缩一下就五步:导出 onnx,工程里带上 directml 版的 onnxruntime,写推理封装(加载、letterbox、Run、解码),跟 Python 端逐图比对验证,部署时 DLL 拷齐。
但真正想留给大家的是前面那个教训:**一定要在 UI 上把执行设备显示出来。**DirectML 缺 DLL 的时候是静默回退的,没有这个显示,你以为在用 GPU,其实一直在用 CPU,还纳闷为什么加速没效果。
工程环境:VS2019 + Qt 5.14.2(msvc2017_64)+ ONNX Runtime 1.24.4 DirectML 版;测试模型 Ultralytics YOLO26m(PCB 缺陷 6 类);测试硬件 RTX 3050。