做视觉项目的朋友应该都遇到过这种尴尬:现场设备上跑的是LabVIEW写的上位机,但客户点名要加一个基于YOLOv5的检测功能。YOLOv5模型本身不难搞,Python环境里训一版、导出一版,都能跑得欢,可问题在于——现场那台工控机不可能为了一个识别功能专门装Python解释器和一堆依赖库。在这种约束下,要把YOLOv5模型落到LabVIEW里,基本只有一条自己动手的路:模型转成ONNX,交给ONNX Runtime推理,再把推理逻辑封装成DLL,最后在LabVIEW里用调用库函数节点(CLFN)去调用这个DLL。
我用这套方案完整做了一个项目,从YOLOv5导出ONNX开始,到C++封装DLL,再到LabVIEW侧调用并画出检测框,整个过程踩了不少坑,也沉淀了一些比较成熟的做法。这篇文章把全过程拆开来写,包括选型理由、模型导出细节、DLL内部代码骨架、LabVIEW调用配置,以及部署现场最容易翻车的地方。目标读者是那些 LabVIEW 用得熟、但对深度学习模型落地方案不太熟的工程师;如果你正好卡在这个接缝处,应该能省下不少试错时间。
1. 需求与选型:为什么是LabVIEW + ONNX Runtime + DLL这条路
1.1 LabVIEW里做深度学习推理的几种常见路线,差距比想象中大
先摆一下我当初对比过的几条路线,每条都实际验证过,不是纸上谈兵。
第一条是NI官方的深度学习推理工具。它确实能跟LabVIEW无缝集成,但有两个硬伤:一是支持的模型格式有限,主要围绕自己生态内的模型和部分常用框架,YOLOv5这种带自定义后处理的模型适配起来非常费劲;二是授权和部署成本不低,现场多台设备都要算License,指不定就要走商务流程。对于中小项目来说,性价比不高。
第二条是在LabVIEW里通过Python节点调用Python脚本。这条路胜在灵活,能直接复用Python生态里的一切,但部署时要把Python运行时、Anaconda环境、一堆pip包一股脑塞到工控机上。现场机器网络受限、系统环境不可控的情况一多,光维护环境就够头疼。而且LabVIEW和Python之间的数据传递效率不高,图像数据来回拷贝,实时性很难保证,更适合做实验验证,不适合做产品化交付。
第三条就是把推理能力封装成DLL,让LabVIEW通过CLFN调用。这条路的优势非常明显:DLL是LabVIEW最成熟、最稳定的外部语言接口;检测链路里最繁琐的预处理、NMS、后处理全部用C++写在DLL内部,LabVIEW侧只需要传图、拿结果;部署时一个DLL文件加一个ONNX模型文件就完事,现场没有任何Python痕迹。最终我选了这条路,也是因为它的边界最干净。
1.2 选型时真正要考虑的三件事
一是推理引擎的选型。ONNX Runtime在不同硬件上的适配性很好,CPU上能跑,有N卡也能切CUDA,而且它的C++接口稳定,封装DLL非常顺。对比过OpenCV DNN,它虽然也在C++里能用,但算子覆盖度、性能和更新频率都不如ONNX Runtime;TensorRT性能很强,可它对模型格式、显卡型号的约束多,容易把项目绑死在特定硬件上。综合下来,ONNX Runtime是最平衡的选择。
二是LabVIEW和C++的分工。目标检测模型的全链路包括图像缩放、归一化、张量变换、推理、NMS、坐标还原,如果在LabVIEW里用图形化节点把这一整套搭出来,工作量会非常惊人,而且后续换模型、调参数都要改G代码。我选择把从输入图像到检测结果的完整链路全部放进DLL,LabVIEW只负责取图和显示。这样模型升级只动DLL和ONNX文件,LabVIEW侧的程序结构完全不用变。
三是接口设计的可维护性。前期的接口约定决定了后续现场调试的顺畅程度。我希望LabVIEW工程师看接口时不需要理解深度学习,只需要知道"传进去一张图,拿回来一组框"就够。基于这个目标,DLL的导出函数设计得尽量简单,所有复杂逻辑都对调用方隐藏。
2. 动手前必须搞清楚的模型导出细节
2.1 导出ONNX时的参数选择,直接影响后续工作量
YOLOv5官方仓库里自带export.py脚本,导出命令本身不复杂,但有几个参数要提前想清楚。
我当时用的导出命令大致是这样:
python export.py --weights yolov5s.pt --include onnx --img-size 640 640 --batch 1这里面有三个关键点值得展开。
第一,--img-size 640 640决定了模型的固定输入尺寸。我选择了固定尺寸而不是动态尺寸,原因很简单:LabVIEW侧调用DLL时,如果输入尺寸是动态的,每次推理都要重新处理张量形状和内存分配,CLFN的参数配置也会更复杂;而固定640×640后,预处理只需要把任意输入图缩放到这个尺寸再做letterbox,逻辑非常固定,性能也更稳。
第二,不要在图里挂NMS。export.py里有个--nms参数,可以把NMS算子直接编译进ONNX模型,好处是推理后直接得到最终框,坏处是输出格式僵化,而且置信度阈值、IoU阈值在部署后不可调。我推荐的做法是导出不带NMS的原始模型,在DLL里用C++后处理做NMS。这样模型文件干净,后续想调阈值只需要改DLL的配置文件或接口参数,不需要重新导出模型。
第三,注意输出张量的形状。不带NMS的YOLOv5模型,输入节点名通常是images,形状是[1,3,640,640];输出节点名通常是output0,形状是[1,25200,85]。这个25200的来源是640×640分辨率下3个尺度特征图产生的预测框总数,85对应4个坐标值、1个目标置信度、80个类别得分(以COCO数据集为例)。这些数值在写C++后处理时都要用到,导出后最好确认一下。
2.2 导出后先验证,再往下走
很多项目翻车就翻在模型还没验证就急着写DLL,最后推理结果不对,很难判断是模型问题还是封装问题。我每次导出完都会先做一次简单的Python脚本验证。
验证的第一步是检查模型结构。用ONNX Runtime自带的Python接口,把模型加载起来,打印输入输出节点信息,确认节点名、数据类型、张量形状和预期一致。
import onnxruntime as ort sess = ort.InferenceSession("yolov5s.onnx", providers=["CPUExecutionProvider"]) for inp in sess.get_inputs(): print("input:", inp.name, inp.shape, inp.type) for out in sess.get_outputs(): print("output:", out.name, out.shape, out.type)第二步是跑一张真实图片,把原始输出保存下来,后续用C++封装DLL时做对照用。注意这里要保存的是ONNX模型的原始输出,不是画框以后的图,因为我们的目标是验证预处理和后处理逻辑与Python侧是否一致。
第三步,如果模型比较大,推荐用onnx-simplifier做一次简化。有些模型导出后会带一些冗余算子,简化以后DLL里的OrtSession加载会更快,推理也可能有微小提升。命令很简单:
python -m onnxsim yolov5s.onnx yolov5s_sim.onnx简化过程偶尔会把某些算子弄丢导致输出不一致,所以简化完之后一定再跑一次上面的Python验证,确保输出和简化前一致。
3. DLL封装:核心推理代码与内存管理设计
3.1 推理核心的代码骨架,核心模块逐个拆
DLL内部我用的开发环境是Visual Studio,依赖项包括ONNX Runtime库和OpenCV库。ONNX Runtime的C++接口在1.x版本里已经相当稳定,如果直接用NuGet包管理来引入,整个链路会轻松很多;OpenCV主要用来做图像缩放、颜色转换和letterbox。
推理类的大体结构是这样的:
#include <onnxruntime_cxx_api.h> #include <opencv2/opencv.hpp> #include <vector> #include <string> class YoloDetector { public: YoloDetector() : session(nullptr) {} bool initialize(const std::string& modelPath, int threads) { Ort::Env& env = getEnv(); Ort::SessionOptions options; options.SetIntraOpNumThreads(threads); options.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL); session = std::make_unique<Ort::Session>(env, modelPath.c_str(), options); return session != nullptr; } int detect(int width, int height, const unsigned char* bgrData, int maxResults, DetectResult* results, int* resultCount); private: std::unique_ptr<Ort::Session> session; static Ort::Env& getEnv() { static Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "labview-yolo"); return env; } };这个结构里有个小细节值得注意:全局环境对象Ort::Env用静态局部变量来管理,因为ONNX Runtime官方建议整个进程生命周期内保持单例环境,反复创建销毁环境对象会带来不必要的开销。Session则用std::unique_ptr管理,这样在DLL卸载时能够自动释放。
detect函数是核心流程,内部拆成四步。
第一步是图像预处理。收到LabVIEW传过来的原始图像数据后,先做一个和YOLOv5训练时完全一致的letterbox。所谓letterbox,就是把图像按比例缩放到640×640,剩余区域用固定灰度值(YOLOv5里默认是114)填充,这样图像内容不会因为强制拉伸而变形。缩放比例取min(640/width, 640/height),然后计算填充偏移量。这一步如果和训练时不一致,检测精度会明显下降。
第二步是张量变换。把BGR排列的图像数据转成RGB顺序,再按通道顺序排列成CHW格式,归一化到0到1之间,最后装入输入张量。
第三步是推理调用。通过session->Run执行一次前向传播,拿到原始的output0张量。
第四步是后处理。从输出张量里解析出坐标、置信度、类别信息,把letterbox坐标系还原回原图坐标系,再做NMS抑制重叠框,最终填充到DetectResult结构体数组。
3.2 图像输入的内存布局,最容易出问题的地方
LabVIEW和DLL之间的图像传递,用的是裸指针加宽高参数的方式。DLL对外导出的检测函数,输入参数就设计成这样:
struct DetectResult { float x1, y1, x2, y2; float score; int classId; }; extern "C" __declspec(dllexport) int YoloDetect(int width, int height, const unsigned char* imageData, int maxResults, DetectResult* results, int* resultCount);这里的imageData是一段连续的、按行排列的图像像素数据,顺序是BGR还是RGB必须在接口文档里约定清楚。我在实际项目里约定的是BGR顺序,因为OpenCV读图默认就是BGR,C++侧处理起来最方便。LabVIEW侧的图像数据通常来自NI的视觉采集模块,常见的是RGB32格式,所以LabVIEW那边在传给DLL之前要先做一次像素重排,否则通道顺序错了,模型识别效果会非常差。
另外一个隐藏坑是行对齐。IMAQ图像在内存里有时并不是每一行都紧密排列,行末可能存在对齐字节;如果直接把IMAQ的图像Buffer指针传给DLL,很容易出现图像错位,识别出来的框全都偏了。稳妥的做法是在LabVIEW里先把图像转成紧凑的二维数组,保证数据连续紧凑后再传入DLL。
3.3 检测结果返回,内存分配和结构体对齐都要设计
检测结果数组的设计,我强烈建议采用"调用方传入预分配缓冲、DLL只负责填充"的方案。
原因是跨DLL边界分配和释放内存容易埋雷:如果DLL内部用malloc或new分配内存给LabVIEW侧用,必须再导出配套的释放函数,而且一旦LabVIEW侧的数组处理方式与DLL的分配方式不一致,轻则内存泄漏,重则崩溃。采用调用方预留固定大小的结构体数组后,LabVIEW可以创建一个固定长度200的Cluster数组(200个检测框在常规缺陷检测场景下足够用),把数组的数据指针传给DLL,DLL在maxResults的范围内写入检测结果。这样做内存归属清晰,LabVIEW侧拿到的是一个合法数组,显示时直接按有效数量取用即可。
结构体对齐是个容易忽略的细节。C++编译器默认的结构体有padding,而LabVIEW的Cluster排列规则不一定跟C++的默认对齐一致。为了彻底避免这种错位,在定义DetectResult结构体时,我用了1字节对齐:
#pragma pack(push, 1) struct DetectResult { float x1, y1, x2, y2; float score; int classId; }; #pragma pack(pop)这样结构体的实际内存布局就是4个float加1个int,总共20字节,任意的编译器和LabVIEW端都能精确匹配。如果你用了默认对齐,可能结构体实际大小不是20字节,LabVIEW解析出来的字段就会全是乱值。
3.4 C接口的导出规范,少走弯路的几个硬性约定
DLL导出给LabVIEW的接口,必须用extern "C"修饰,避免C++的名字修饰机制把函数名改成一堆不可读的符号。加上__declspec(dllexport)后,LabVIEW的CLFN里才能直接按原始函数名找到入口。
调用约定也要提前定好。如果C++工程用的是__cdecl(这也是Visual Studio默认的C/C++运行时约定),那LabVIEW的CLFN配置里Calling Convention就要选"C";如果用了__stdcall,就要选"StdCall"。这个看似不起眼的选项一旦选错,轻则参数解析错误,重则栈不平衡直接崩溃,而且崩溃时机不定,排查起来非常痛苦。
除了最基本的初始化、推理、释放这三个接口,我还习惯加一个版本查询接口,返回DLL的版本号和模型要求的最低尺寸。现场软件升级时,LabVIEW侧可以先查版本,避免DLL和界面程序版本不匹配导致莫名其妙的错误。
4. LabVIEW侧调用:CLFN配置与显示实现
4.1 CLFN里每一步点哪里,照着配就行
LabVIEW调用DLL的入口是函数面板里的"调用库函数节点",就是这个节点把外部代码和图形化语言连接起来。
放置节点后,右键选择"配置",重点配置以下几项。
库路径选到编译好的DLL文件,注意当前开发机上编译的是Debug还是Release版本,Debug版依赖的C运行时DLL可能跟现场环境不一致,尽量用Release版。
函数名那一栏,下拉列表里能直接看到DLL导出的所有函数,选YoloDetect即可。如果下拉列表里看不到函数,多半是导出符号有问题,回VS工程里检查是否有extern "C"和__declspec(dllexport)。
调用约定需要和DLL编译时的约定一致。我用的VS工程默认是__cdecl,所以CLFN里选择"C"。
参数配置是整个环节最关键的部分,逐个对应DLL接口里的参数类型。width和height是两个有符号32位整数,作为输入参数;imageData是一个无符号8位整型数组,Data Format选择"Array Data Pointer",这样LabVIEW会把连续数组的首地址传进DLL;maxResults是输入整数,告诉DLL缓冲区最多能写多少条结果;results是预分配好的Cluster数组,需要同时配置为"Array Data Pointer"并让它作为传入和传出参数;resultCount是一个整数指针,用于DLL返回实际检测到的目标个数,配置为"Integer"类型并勾选函数参数方向为"Both"或"Pointer to Value"。
返回类型设为有符号32位整数,用返回值作为错误码,LabVIEW侧判断非零值即调用失败,通过错误码去查日志。
4.2 图像传进去之前,LabVIEW侧要做哪些处理
LabVIEW里读图的路径千差万别,有的是从相机采的,有的是从文件读的,但最终进DLL之前,都要统一成一组数据:宽、高、紧凑排列的BGR像素数组。
拿文件读图举例,先用IMAQ ReadFile读取图片,然后通过IMAQ GetImageData拿到原始像素数据。这里有个坑:IMAQ图像的数据排列可能不是紧凑的,行间可能有padding,直接给DLL会导致每行错位。我的处理方式是先把IMAQ图像转换成二维数组,再重排成连续的一维数组,确保数据紧凑。
通道顺序是第二个坑。IMAQ默认像素数据是RGB或RGBA排列,而DLL内部做推理时按OpenCV习惯用BGR。所以LabVIEW侧要把每个像素的R和B交换位置再传给DLL。这项工作可以在LabVIEW里用数组操作完成,也可以用一个小工具函数封装好;我在项目里是直接在LabVIEW里做了一个子VI,输入IMAQ图像,输出紧凑BGR数组和宽高,后面所有调用点复用这一个子VI,避免每处都改一遍。
4.3 拿到检测框之后,如何显示在界面上
检测结果从DLL返回后,是1个数组加1个有效计数的结构。LabVIEW侧根据resultCount截取数组的前N条记录,然后循环在图像上绘制矩形框。
显示方式有两种。如果图像是以IMAQ图像的形式显示的,可以用IMAQ Overlay Rectangle这个函数在图像上叠加矩形,自定义颜色和线宽,叠加层跟随图像缩放,实时性比较好。如果图像是用Picture控件显示的,可以先把图片转成Picture数据,再用绘制矩形函数往Picture上画,适合不用IMAQ生态的场景。
绘制时注意两点。第一,DLL返回的坐标是原图坐标系,不是letterbox后的坐标,因此不需要再转换;第二,classId对应类别名称,在LabVIEW里维护一个字符串数组,按index取名称显示在框的左上角,标签太长可以用字号和颜色区分。
调用前后还要注意释放。CLFN调用完DLL后,预分配的Cluster数组要复用,千万别在循环里反复创建新数组,否则内存波动会很剧烈。
5. 部署中反复踩过的坑与优化建议
5.1 预处理链路的每一个环节,都要和训练时严格一致
这个坑我踩过不止一次,而且每次的表现形式都不一样。第一次是图像缩放比例算错,letterbox的缩放因子和填充偏移没配对,导致所有检测框在原图上整体偏移;第二次是填充色不对,模型在填充区域上产生了奇怪的误检;第三次是通道顺序搞反,检测结果的置信度整体非常低。
这些都是预处理链路上非常小的问题,但表现成最终效果就是"识别不准"。排查这类问题时,我的方法是用Python侧保存一张已经完成预处理的图像,直接看letterbox后的样子,然后对照DLL里的实现,一步一步核对。所有预处理逻辑务必完全复刻训练时的流程,任何"简化"都可能让精度明显下降。
5.2 内存泄漏和生命周期管理
DLL被LabVIEW加载后,会一直驻留在进程里直到LabVIEW退出,所以内存问题会被长时间放大。
第一个容易出现的问题是反复创建Session。在初始化接口里创建一个Ort::Session是正常的,但如果有人在每次检测前都重新初始化一次,内存就直接失控了。我的做法是把Session放在单例类里,初始化接口只在程序启动时调用一次,检测接口只做推理不重新加载模型。
第二个问题是NMS后处理里的临时变量。每个检测框候选都要排序、计算IoU,如果频繁在循环里new数组,检测次数多了就会积累成明显的内存碎片。我全部改用预先分配好的std::vector,容量在初始化阶段就reserve足够大小。
第三个问题是DLL卸载时的资源释放。在DllMain的DLL_PROCESS_DETACH分支里释放全局资源,保证LabVIEW关闭时进程能干净退出。如果忽略这一步,LabVIEW退出时偶尔会出现崩溃或无响应,现场用户反馈的"软件关闭时卡死",往往就是这个原因。
5.3 性能调优的几个方向,按收益排序
先列一个我在项目中实际验证过的优化顺序,从性价比最高到最低:
第一,固定输入尺寸。固定640×640省去了动态shape的复杂性,ONNX Runtime能为固定输入做更多图优化,推理速度和稳定性都更好。
第二,线程数的配置。ONNX Runtime的SetIntraOpNumThreads会影响CPU推理的并行度,但线程数不是越多越好。我在4核工控机上测试,设置线程数为4比8更快,因为线程切换开销会抵消并行收益,而且LabVIEW主程序还要占用资源。具体值需要在目标机器上实测,不同CPU表现差异明显。
第三,如果现场有NVIDIA显卡且模型比较大,可以考虑启用CUDA EP,推理速度提升非常可观。但要注意CUDA环境的依赖,ONNX Runtime的CUDA版本需要和显卡驱动匹配,部署时多一个依赖就多一分现场风险。项目初期我建议先CPU版本跑通,再按需升级到GPU版本。
第四,DLL内部后处理代码的微优化。NMS部分的排序、IoU计算尽量用结构化代码写,少用动态类型和虚函数,配合编译器优化开关,几十微秒级别的提升虽然不起眼,但在高帧率场景下还是有点意义。
5.4 一套建议的工程文件组织结构
经过这个项目,我最终形成了一套比较顺手的工程组织方式,在这里分享出来。
VS工程里分两个项目:一个核心静态库放YoloDetector类和通用工具函数,负责所有与模型相关的逻辑,便于单元测试;一个DLL导出项目只写导出函数,把静态库的接口包装成C接口。这样做的好处是以后如果要做其他LabVIEW调用场景,只需要改导出层,核心推理代码完全复用。
文件分发时,我始终把DLL、ONNX模型、类名配置表放在固定相对路径下,约定LabVIEW程序通过当前VI所在路径去定位这些文件。现场升级时直接替换这三个文件,LabVIEW界面程序不用动。
日志模块一定要在早期就埋进去。DLL内部的关键步骤,包括模型加载成功、推理耗时、检测框数量、错误信息,都写进一个简单的文本日志。现场出了问题,第一件事就是看日志,这比远程连上去猜原因高效得多。日志要轻量,避免高频写入拖慢推理性能,一般只在错误和初始化阶段打点就够。
最后再说一个小的经验
这套"YOLOv5 + ONNX Runtime + DLL + LabVIEW"的组合,目前已经在好几个类似项目里稳定跑了一段时间。回想整个过程,最值得提醒的还是那句话:先把最小闭环跑通再扩展。所谓最小闭环,就是LabVIEW读一张图片,调用一次DLL,显示一个正确检测框。这个闭环一旦成立,整个技术路线基本就稳了,剩下的事情只是把图片换成视频流、把单张检测换成循环检测、把检测结果接到PLC逻辑上去而已。
如果我再做一个类似项目,会在前期就把接口边界文档写得再细一点,尤其是图像格式约定和结构体对齐方式这两条,看起来都是很小的细节,但实际排查起来的成本一点都不小。希望这篇记录能帮你跳过那些我已经踩过的坑。