
先说明一下背景我这套项目是在工控上位机里做实时缺陷检测相机采集图像软件端识别目标并输出坐标。原来一直用LabVIEW加NI Vision那套传统算法换了YOLOv5之后发现纯Python推理在产线上根本顶不住CPU晚一拍就是停机GPU推理如果不用TensorRT帧率也上不去。磨了两个星期最后定了这条路shouxieai那版YOLOv5-TensorRT做C推理引擎封装成DLL供LabVIEW通过调用库函数节点加载。整套流程跑通之后单张图像推理耗时稳定在5到8毫秒看batch和分辨率配合LabVIEW的生产者消费者架构完全跟得上相机曝光节拍。这条路能不能走通核心不在于YOLOv5训练得怎么样而在于推理架构的拆解和跨语言封装。LabVIEW不是C没法直接new一个TensorRT引擎对象更没法直接调类成员函数。所以要解决的事情其实就三块一是把YOLOv5模型转成TensorRT可加载的engine二是写一个纯C接口的DLL把C对象藏到指针里三是在LabVIEW端把图像数据正确喂给DLL并把结果拿回来。每一步都有坑我下面按实际操作顺序把关键细节全部拆开讲。1. 方案选型与整体设计思路1.1 为什么是TensorRT而不是OpenVINO、ONNXRuntime做工业检测项目的人应该都有同感模型训练出来是一回事部署到产线是另一回事。YOLOv5官方库自带export.py能导出ONNX和TorchScript但ONNX Runtime在NVIDIA显卡上跑说实话只是“能跑”离“吃满显卡”还差很远。TensorRT是NVIDIA自家的深度学习推理优化器它会把网络做层融合、精度校准、kernel自动调优同一张3090上YOLOv5s的TensorRT FP16推理速度可以做到ONNX Runtime FP16的两倍以上尤其在小目标检测这种场景下差距更明显。OpenVINO我也考虑过它对Intel集显和CPU优化很好但我们是NVIDIA的GPU部署OpenVINO没法直接调用CUDA硬要用只能走ONNX Runtime的ExecutionProvider性能打折。ONNXRuntime胜在通用、部署简单但TensorRT的engine是序列化好的跳过了解释器开销工程上更符合“稳定、低延迟”的工控要求。1.2 为什么选shouxieai这版TensorRT推理代码网上YOLOv5-TensorRT的仓库一大堆很多人自己写的代码只支持单个模型、只跑一次就退出拿来嵌入DLL根本不具备工程性。shouxieai那版yolov5_tensorrtGitHub上叫shouxieai/yolov5_tensorrt是我比较下来最合适做二次开发的理由有几点它对YOLOv5的Focus、SPP、SiLU激活层这些结构做了针对性优化ONNX导出时不需要过多修改网络定义。C代码结构清晰构建engine和推理主逻辑分离方便改成DLL导出。支持预处理、后处理NMS和可视化分离对嵌入上位机友好我只用前两部分。它的engine构建支持FP16和INT8产线精度要求高我用FP16要求不那么严格可以上INT8再省一档延迟。不是给它打广告单纯从“能不能快速改成DLL”这个角度看这版工程结构是最省事的。后处理部分是用C手写NMS意味着不依赖Python环境DLL完全自洽这对LabVIEW调用至关重要——我不想在LabVIEW机器上还装个Python环境那维护成本太高了。1.3 整体架构怎么排整套系统分三层底层Ubuntu或Windows下的TensorRT推理DLL负责加载engine、图像预处理resize、letterbox、归一化、推理、后处理解码、NMS对外只暴露几个C接口函数。中间层LabVIEW调用库函数节点加载DLL完成图像内存传递和结果回调/轮询。顶层LabVIEW主VI做相机采集、显示、坐标计算、PLC通信热搜词里能看到周立功CAN通信-LabVIEW(二)说明很多人是这套组合。这里有个关键决策DLL内部不做图像显示、不做UI就是一个纯计算单元。图像格式统一为RGB三通道byte数组和OpenCV的Mat布局一致这样我们在C里可以直接把LabVIEW传来的数组包成cv::Mat省去一次拷贝。结果输出用一个自定义结构体数组每个检测框包含x1、y1、x2、y2、score、class_idLabVIEW里用一个簇数组接收。2. 环境搭建与YOLOv5模型转换2.1 TensorRT安装的版本匹配问题TensorRT安装绝对不要只见版本号不看CUDA版本我在这上面栽过跟头。TensorRT 8.5对应CUDA 11.8TensorRT 8.6对应CUDA 12.0装错的话编译期报了一堆链接错误折腾了一整天才发现是CUDA和TensorRT版本不匹配。我们生产环境用的Ubuntu 20.04CUDA 11.8cuDNN 8.9.1TensorRT 8.5.3。命令行安装方式# 下载对应run文件或deb包我习惯用deb sudo dpkg -i nv-tensorrt-repo-ubuntu2004-cuda11.8-trt8.5.3.0-ga-20230314_1-1_amd64.deb sudo apt-key add /var/nv-tensorrt-repo-ubuntu2004-cuda11.8-trt8.5.3.0-ga-20230314/7fa2af80.pub sudo apt-get update sudo apt-get install tensorrt装完验证一下dpkg -l | grep TensorRT python3 -c import tensorrt; print(tensorrt.__version__)如果是Windows部署LZ的启动器则直接下对应CUDA的TensorRT zip包解压后把lib、include路径加进VS工程就行。注意Windows下TensorRT对显卡驱动版本也有要求跑之前用nvidia-smi检查驱动版本够不够新。2.2 YOLOv5模型导出ONNXshouxieai那版代码支持直接加载TensorRT官方转换好的engine但前提是你先把YOLOv5的pytorch模型转成ONNX。YOLOv5官方仓库的export.py基本能直接用关键参数是python export.py --weights yolov5s.pt --include onnx --opset 11 --simplify--opset 11是TensorRT兼容性最好的opset新版本opset比如17会导致某些算子在转engine的时候不支持我之前用opset 17导出同样的模型TensorRT直接报Unsupported Operator。导出成功之后拿Netron看一眼ONNX输出节点YOLOv5默认的输出是一个1x25200x85的矩阵以640输入为例说明网络结构完好后处理留着让shouxieai版的C代码处理。如果只想让engine输出单层也可以改输出层但DLL里后处理逻辑就要跟着改没必要保持默认就好。2.3 构建engine文件用shouxieai的代码构建engine通常是在工程里写一个转换工具读ONNX用TensorRT的ONNXParser解析然后设置builder配置// 关键配置片段 IBuilder* builder createInferBuilder(gLogger); const auto explicitBatch 1U static_castuint32_t(NetworkDefinitionCreationFlag::kEXPLICIT_BATCH); INetworkDefinition* network builder-createNetworkV2(explicitBatch); // 加载onnx auto parser nvonnxparser::createParser(*network, gLogger); parser-parseFromFile(onnxPath.c_str(), 0); // 配置 builder-setMaxBatchSize(1); // 或者动态batch视生产需求 builder-setMaxWorkspaceSize(1 30); builder-setFp16Mode(true); // 然后构建engine并序列化写盘这里要注意maxBatchSize设多少直接决定显存占用和推理吞吐。我们产线单次只处理一张图就设1。如果后续要批量推理batch设16、32都要在构建时预留否则推理时engine会拒绝执行。构建完的engine文件后缀.engine或.trt是序列化好的后续加载不需要ONNXParser直接用runtime反序列化启动速度会快很多。这一步务必在开发机上完成产线机器只需要拷贝engine和DLL不要装TensorRT全家桶虽然DLL运行时依赖TensorRT库但不需要CUDA toolkit。3. 封装DLL的关键设计与实现3.1 动态库接口设计思路DLL是给LabVIEW用的LabVIEW的调用库函数节点不认C类方法只认“C风格接口函数与基本数据类型”。所以DLL的导出函数要非常有分寸不要一次性铺太多功能我最终只暴露了三个核心接口void* YOLO_CreateEngine(const char* enginePath)加载engine返回一个不透明指针一切后续调用都要带这个指针。int YOLO_Infer(void* engine, uint8_t* imageData, int width, int height, int imageChannels, YOLO_DetectResult* outResults, int maxResults, int* outResultCount)传入图像数据与尺寸返回结果数组。void YOLO_DestroyEngine(void* engine)释放engine资源。为什么不暴露“初始化”和“推理”合并的函数因为LabVIEW主循环可能多次调用推理而初始化只需一次拆开方便LabVIEW在程序启动时初始化循环里只调推理退出时销毁。3.2 导出函数写法与跨语言数据类型规范化头文件关键定义#ifdef _WIN32 #define YOLO_API __declspec(dllexport) #else #define YOLO_API __attribute__((visibility(default))) #endif #pragma pack(push, 1) typedef struct { float x1, y1, x2, y2; // 框坐标相对输入图像宽高单位像素 float score; // 置信度 int class_id; // 类别ID } YOLO_DetectResult; #pragma pack(pop) extern C { YOLO_API void* YOLO_CreateEngine(const char* enginePath); YOLO_API int YOLO_Infer(void* engine, uint8_t* imageData, int width, int height, int channels, YOLO_DetectResult* outResults, int maxResults, int* outResultCount); YOLO_API void YOLO_DestroyEngine(void* engine); }几个工程细节值得单独说整个头文件必须用extern C包裹而且如果是给LabVIEW在Windows上用默认调用约定是stdcall还是cdecl要跟LabVIEW里配置一致我统一用__stdcall并加__declspec(dllexport)这样可以省去工程里改汇编名字的麻烦。#pragma pack(push, 1)是防止结构体对齐导致LabVIEW端数据错位C默认按4字节对齐LabVIEW接收的是一个扁平数据块如果不强制1字节对齐字段之间会多出填充字节结果解析全部错乱。这一点是必须强调的。3.3 推理函数实现的关键逻辑YOLO_Infer内部大致是这个逻辑int YOLO_Infer(void* engine, uint8_t* imageData, int width, int height, int channels, YOLO_DetectResult* outResults, int maxResults, int* outResultCount) { try { YOLOEngine* yolo static_castYOLOEngine*(engine); cv::Mat img(height, width, CV_8UC3, imageData); std::vectorDetectBox boxes yolo-Infer(img); int count 0; for (auto box : boxes) { if (count maxResults) break; outResults[count].x1 box.x1; ... } *outResultCount count; return 0; } catch (const std::exception e) { return -1; } }把cv::Mat包在传入数据上零拷贝。但要注意LabVIEW传过来的图像数据内存由LabVIEW管理C只能在这个函数执行期间使用不能写进一个成员变量长期保存。我们在推理函数内部先做letterbox变换到640x640再做归一化然后拷贝到GPU输入buffer这一套都在函数调用过程中完成没有异步。推理输出的框坐标是640尺度下的返回给LabVIEW的时候要先做反letterbox换算转回原始图的坐标。这个换算放到C端一次性完成LabVIEW端就不用再算一次了否则还要把letterbox参数偏移、缩放比传给LabVIEW增加了出错概率。3.4 GPU资源管理与线程安全工控上位机一旦跑起来相机采集线程、界面线程、PLC通信线程是并行的。LabVIEW自身有调度机制但调用库函数节点是阻塞式调用如果同时多个线程都在调YOLO_InferDLL内部的执行上下文必须加锁。我用一个std::mutex包住整段推理逻辑问题就简单了std::mutex infer_mutex; int YOLO_Infer(...) { std::lock_guardstd::mutex lock(infer_mutex); ... }为什么必须加锁TensorRT的CUDA context不是线程安全的同一个engine在多线程并发执行会crash或产生不可预期的结果。如果你接受“LabVIEW并行调用但排队执行”这种模式mutex就够了。如果追求真正的多路并发就需要每个线程独享一个CUDA context工程复杂度瞬间翻倍产线场景真没必要。GPU内存和engine文件的创建/销毁要单独控制。我在YOLO_CreateEngine里加载engine文件并分配好输入输出bufferYOLO_Infer只做数据拷贝和推理尽量不在推理热路径里做显存分配。每次推理都cudaMalloc是非常错误的做法实测推理延迟会从5ms飙到15ms以上。4. LabVIEW端调用DLL的配置与图像数据流4.1 调用库函数节点的正确配置LabVIEW里调用DLL用的是函数面板互连接口库与可执行程序调用库函数节点Call Library Function Node。右键节点选“配置”这里有几个项目极其容易配错。以YOLO_Infer为例库名或路径填DLL完整路径建议填成与VI同目录下相对路径方便部署。函数名选择YOLO_Infer如果DLL函数名被编译器修饰过LabVIEW会显示找不到函数这时要在VS工程里用.def文件或/EXPORT指定导出名。调用约定选stdcall (WINAPI)这要和DLL编译时一致。参数列表按顺序配置engine是void*类型在LabVIEW里只能选“匹配至类型”也就是“数值指针”通常用U64或Adapt to type我用“无符号32位指针”会丢地址64位系统下指针是64位坑过一次正确的是64位整型。imageData是U8数组在LabVIEW里配置为“数组数据指针”而且要勾选“最小尺寸”为1否则空数组传入时DLL拿到的可能是NULL。width、height、channels是数值直接选有符号32位整型。outResults是YOLO_DetectResult数组LabVIEW没有结构体数组指针概念我们约定传一个扁平U8数组作为缓冲区或者传一个U8数组首地址然后在LabVIEW端拆字节重组成簇数组。outResultCount是有符号32位整型指针。maxResults直接传一个常量比如100让DLL最多填100个检测框。好多人在这个配置界面卡住尤其是返回值的处理方式。我强烈建议把返回类型设为Void一切结果都通过指针参数带回来。为什么LabVIEW调用DLL如果返回int它默认当成布尔或32位整数处理我们内部返回0代表成功、-1代表失败但人眼看去只是个数字还得在VI里判断。更干脆的做法是让DLL内部把错误信息写入一个全局日志或输出一个错误码参数返回类型直接Void。4.2 图像数据如何从相机传到DLLLabVIEW里图像一般以IMAQ图像句柄存在不能直接送进DLL。我用的方案是先把IMAQ图像转成二维U8数组再用数组至指针或传给调用库函数节点的方式传入DLL。具体分两步IMAQ图像用IMAQ ExtractColorPlanes拆成红绿蓝三个plane各是一个二维数组。注意YOLOv5训练的时候图像格式是BGR还是RGBOpenCV默认读入是BGRshouxieai的代码里预处理用的是BGR还是RGB需要核对。我在这上面踩过坑如果LabVIEW传入的是RGB顺序但C按BGR处理推理结果会显著变差因为颜色通道顺序搞反了。解决办法很简单在LabVIEW里换一下两个plane的顺序或者在C端用cv::cvtColor转换一次。把三个plane合到一个三维U8数组或者直接在内存里交错成一个连续RGB数组LabVIEW里可以用IMAQ ImageToArray配合Build Array完成。图像传输性能上LabVIEW的内存拷贝是有开销的高分辨率图像例如2448x2048一次拷贝约10MB大概2~3毫秒相比推理的5ms还算可接受。如果对性能极限有要求可以改成用IMAQ图像的buffer指针直接传给DLL但这种方式要求C端知道IMAQ图像的内存布局裸指针操作风险高我建议前期先用数组方式跑通后期再优化。4.3 结果数据解析从字节数组到簇数组outResults在LabVIEW端接收的是一块连续内存本质是一个字节数组。我的做法是在LabVIEW前面板放一个U8数组控件长度设为maxResults * sizeof(YOLO_DetectResult)也就是100*24字节24字节是因为4个float加一个float加一个int正好24字节pack(1)之后也是24。调用DLL后把这个U8数组按24字节一组切分每组再用Unflatten From String或手动拆字节成5个浮点加一个整数。最终组合成一个簇数组簇内元素是{x1,y1,x2,y2,score,class_id}。手动拆24字节有点烦但一旦封装成子VI就不用再动了。我做好了一个“YOLO解析.vi”输入是字节数组输出是检测簇数组后续再做坐标转换、IO绘制、质量判定都以这个簇数组为数据源。如果检测结果要叠加显示在LabVIEW的图像上我推荐直接在LabVIEW端绘制矩形框和标签不要指望DLL输出图像。因为一旦DLL输出JPG或Mat格式转换又要拷贝一遍效率低。LabVIEW的IMAQ OverlayRectangle和IMAQ OverlayText足够好用显示延迟可以接受。4.4 生产者消费者架构的配合LabVIEW里强烈建议用生产者-消费者模式跑这个推理调用。生产者循环只负责相机采图、压入队列消费者循环负责调用DLL推理、解析结果、更新界面。推理阻塞在消费者循环里不会把采集循环卡死。队列深度要限制不然相机速度比推理速度快时队列会无限增长延时越堆越大。我的做法是固定队列长度为10满了之后生产者丢弃最旧的一帧Flush Queue只保留最新图像这样牺牲一部分中间帧但保证实时性产线上这个策略很实用。5. 实操中遇到的高频问题与排查方法5.1 DLL加载失败找不到依赖库LabVIEW调用DLL时最常见的问题是程序运行后直接报错说无法加载DLL。排查绝对不要一来就去想LabVIEW配置先看DLL在系统里能不能独立加载。用Dependency Walker或VS自带的dumpbin /dependents看看DLL依赖哪些库。我们这套DLL依赖TensorRT、CUDA、cudnn这些库的路径必须在系统PATH里或者把对应dll直接拷到LabVIEW工程目录下。实测最稳妥的做法是把所有跟TensorRT/CUDA相关的DLL全部复制到LabVIEW工程根目录的libs文件夹里不依赖PATH。原因很简单工控机上安装的软件污染少你不会希望维修工程师去配环境变量。Linux端如果LabVIEW没有原生的有的人会走LabVIEW的Crosser或者Linux版LabVIEW我这次是在Windows下部署Linux只是做引擎转换的构建机。如果你确实需要在Linux上调用那要把.so文件路径加到LD_LIBRARY_PATH而且在LabVIEW调用库函数节点的路径配置里写绝对路径。5.2 engine构建后无法加载或推理报错这类报错通常不是DLL的问题而是engine本身构建时选的平台和部署平台不一致。TensorRT的engine和CUDA版本、显卡架构强绑定在A卡其实是N卡的特定sm版本上构建的engine不能拿到另一个型号的显卡上跑。注意23550是sm_86Ampere架构如果是更老的Pascal/Volta显卡需要重新构建。另一个高频问题是YOLOv5模型用opset 17导出的ONNX解析不了。我建议导出时固定--opset 11。注意ONNX里可能带了ConstantOfShape、NonMaxSuppression这些算子TensorRT有些版本不支持shouxieai这版一般会让你在ONNX导出时把NMS算子在导出前就裁掉只保留原始输出后处理放C做。5.3 结果坐标不正确或图像全黑颜色通道顺序错乱是最常见的结果异常。排查方法是先拿一张纯红图片测试如果返回的检测结果完全反常大概率是BGR/RGB顺序不对。LabVIEW的IMAQ ExtractColorPlanes默认返回R、G、B三个平面如果C端按OpenCV的BGR处理就一定要交换R和B平面顺序。还有一种情况是letterbox的填充值不对。YOLOv5训练时letterbox的填充色是(114, 114, 114)shouxieai的预处理里也是用这个灰度值。但如果C端设置成了0黑色填充那些被填充的区域可能会被误检成目标尤其是深色背景的工业场景。5.4 推理线程阻塞导致界面卡死很多人在LabVIEW里直接把YOLO_Infer放在事件结构里调用结果一推理界面全部卡死。调用库函数节点是同步阻塞的推理期间VI的主线程被占用所有UI事件都处理不了画面自然是冻结的。如果想保持界面流畅唯一正解是在消费者循环中调用推理而不是在主事件循环里调用。还有一个小技巧在LabVIEW里把调用库函数节点的“参数”页面勾选“运行在UI线程之外”或者用调度VI把调用DLL的子VI设置为异步调用这样至少UI不会完全卡死但要注意结果返回的同步问题还是推荐写队列。5.5 内存泄漏与长时间运行崩溃工控系统要求7x24小时运行DLL内部内存泄漏是定时炸弹。我的排查经验是确保CUDA上下文只在YOLO_CreateEngine里创建一次不要在YOLO_Infer里反复创建/销毁。GPU显存buffer在初始化时分配推理循环中重复使用不要每次推理都cudaMalloc。检测结果用std::vector返回时要注意右值拷贝的开销直接在函数内复用本地vector。LabVIEW侧要注意的是每次调用DLL后LabVIEW会自动管理数组内存但如果你传给DLL的是一个“由用户管理”的指针LabVIEW不会负责释放这个必须由C端保证不越界写。超出maxResults的检测框在DLL端直接丢弃不要硬写否则会直接踩内存访问异常把LabVIEW进程干崩。5.6 打包部署注意事项最后打包交付的时候我给出了一个清单照着做基本不会出问题将engine文件和DLL放在同一个相对路径下YOLO_CreateEngine的参数用相对路径这样工程整体移动不会失效。把TensorRT、CUDA、cudnn的DLL放在LabVIEW的同一目录或系统PATH下但与项目目录隔离避免以后卸载软件时被误删。如果客户机器显卡驱动版本低于开发机即使engine文件拷贝过去也可能初始化失败务必确认驱动版本满足TensorRT最低要求。6. 性能实测与调优记录6.1 推理耗时分布我拿YOLOv5s模型640x640输入FP16精度TensorRT 8.5在一张RTX 3060上做了详细统计环节耗时LabVIEW图像转数组约1.5ms1920x1080数组传入DLL letterbox 归一化约2.0msGPU推理TensorRT约4.5msNMS后处理约0.8ms结果返回并解析约1.0ms总周期约10ms约100FPS如果换成RTX 3090总周期能压到6ms。实际产线我跑的是YOLOv5m640输入FP16RTX 3060上总周期16ms左右完全满足产线节拍。6.2 调优空间--workspace设大一点1GBTensorRT的auto-tune能找到更好的kernel。如果显存够把batch设为4同样执行时间可以处理4张图但LabVIEW端要改写数组缓冲逻辑。预处理改到GPU上做TensorRT的CUDA预处理KernelCPU耗时能再降2ms但工程复杂度提升不少我只在另外几个项目里用过。如果CPU很弱建议把NMS后处理改成GPU版shouxieai的代码里可以搭配TensorRT的plugin实现NMS的GPU加速但NMS处理耗时很小优先优化画像传输更有价值。7. 总结里最想说的几句话做LabVIEWYOLOv5TensorRT这套东西最让我有体会的一点是算法层面的优化再天花乱坠跨界面对接时一个字节对齐、一个调用约定就能让你卡一个星期。整个方案里最费时间的不是训练模型而是把C推理引擎稳定地藏进DLL后面并且让LabVIEW无感调用。数据结构的定义、显存生命周期管理、线程安全的设计这些才是工程落地的主战场。如果你也在跑这条路我建议先用最简单的YOLOv5s在开发机上把整个链路跑通再逐步换大模型、加精度。别一上来就搞INT8量化先把FP16稳定运行一个月再谈压缩。还有一个小技巧engine文件生成后最好写一个构建脚本保存到工程目录方便换显卡之后一键重建不要依赖原始构建日志。产线机器尽量不要装CUDA toolkit包里带着运行时依赖就够了减少变量稳定压倒一切。