资讯动态

Yolov5工业视觉落地:TensorRT加速与C++封装DLL全流程

发布时间:2026/10/18 11:07:50 来源:尧图企业网站定制
做工业视觉项目这些年我越来越觉得深度学习模型训练只是整个系统里最“干净”的一环。真正让人头疼的是把模型塞进产线工控机、接上工业相机、再封装成能让上位机调用的动态链接库这一路。前阵子做了一个Yolov5目标检测的工业视觉方案从相机取流、TensorRT推理到C封装DLL整条链路都跑通了中间踩了不少坑。这篇就把这套方案从选型到落地的完整过程拆开讲重点讲清楚图片目标检测在工业场景下怎么落地以及C封装DLL时那些文档里不会写的细节。这套方案能干什么说人话就是用工业相机拍产品Yolov5模型实时检测目标并输出坐标检测能力封装成一个standard的DLL给MFC、C#或者LabVIEW写的上位机直接调用。适合正在做视觉检测项目、准备把Python训练的模型搬进C工程又没走通过全流程的人参考。我会尽量把每一步“为什么这么做”也讲明白而不是只甩给你一堆代码。1. 整体设计与选型思路1.1 为什么选Yolov5而不是其他检测模型很多人上来就问现在都V8、V9了为什么还在用Yolov5这个问题我在实际项目里被问过不下十次。其实答案很朴素工业项目要的不是“最新”而是“最稳”。Yolov5在目标检测这个领域生态成熟度是其他版本没法比的。意思就是你遇到的每一个坑大概率都有人踩过并留下了解决方案。从Pytorch导出ONNX、ONNX转TensorRT引擎到量化、裁剪、迁移学习每个环节都有大量现成资料可以查。V8在这些方面逐渐赶上来但如果你去搜一句“yolov5 export onnx dynamic batch报错”和“yolov8 export onnx报错”得到的有效信息量差一个量级。另外一个更现实的考量是部署后的排查。产线上的工控机显卡往往很老驱动锁得死死的不能乱升级。Yolov5的C部署范例多TensorRT版本兼容性也相对宽松出问题好定位。在实际测试里同样的硬件环境下Yolov5s的FPS表现和V8s差距不大而Yolov5的代码量更精简二次改造成本低——这就够了。1.2 工业相机选型的核心考量做工业视觉相机选型是第一步也是决定成败的一步。我这次用的是某国产主流工业相机品牌的千兆网GigE接口面阵相机为什么这么选有几个原因接口稳定性GigE相机用网线通信线材便宜、抗干扰能力尚可适合产线上常见的3-5米传输距离。USB3.0相机虽然理论带宽高但线材质量参差不齐距离远了容易掉线在工业场景不如GigE省心。SDK成熟度工业相机SDK一般提供相机枚举、参数配置、软硬触发、图像回调等接口这套东西在工业现场很成熟。相比直接用USB摄像头或RTSP网络摄像头SDK能帮你绕开很多底层协议问题。像素格式多样性产线上常见的是黑白相机Mono8、拜耳彩色相机BayerRG8以及带像素合并Binning模式。这些都需要SDK层面的支持普通摄像头往往做不到。这里还想多说一句不要一上来就买几千上万的高分辨率相机。工业检测一般先看被检物体最小缺陷尺寸再看视野大小最后反推分辨率要求。比如视野100mmx100mm需要检测0.1mm的缺陷分辨率至少1000x1000选130万或200万像素的相机就够用。盲目堆像素只会让传输带宽吃紧反而拖累帧率。1.3 为什么非要封装成DLL这个问题很多从算法转过来的同学不理解觉得“我明明写好了Python推理脚本直接跑不就行了”行是行但产线不是实验室。产线上位机程序绝大多数是C#、C或者LabVIEW写的。这些程序要调用你的模型总不能装个Python环境、再装一堆依赖、最后还要求操作员能看懂报错日志吧把Yolov5推理能力封装成一个DLL本质上是做了一个算法能力与上位机解耦的中间层上位机只需要InitDetector()、DetectImage()、ReleaseDetector()几个函数不需要关心内部是TensorRT还是ONNX Runtime。模型升级只替换DLL和模型文件上位机代码一行不用改。相机取流、图像处理、推理这些耗时操作全部放在DLL内部线程里上位机UI不会卡住。这是工业软件里很经典的“稳定接口 易变实现”模式和平时写代码追求的“开闭原则”是一个道理。2. 环境与工具链准备2.1 推理引擎选型为什么上TensorRT模型训练出来只是开始真正决定能不能产线落地的是推理速度。刚开始我用Pytorch直接推理640x640的输入在RTX 3060上大概跑20ms一帧算下来50FPS看起来还行。但注意这只是纯模型推理时间还没算相机采集、图像转换、预处理、后处理、显示这些环节。一整套下来一帧要60-70ms产线要是节拍200ms一个工件勉强够一旦要求双相机同时检测就捉襟见肘了。所以后来我把推理引擎换成了TensorRT做了FP16精度优化模型的推理时间降到了11ms左右。TensorRT的原理说白了就是把训练好的网络结构提前“编译”成针对特定GPU的最优执行计划包括算子融合、精度校准、显存复用等等。相当于你训练时写的是“通用脚本”TensorRT给你编译成“针对这台机器的机器码”。还有条件的话可以做INT8量化速度还能再提一截但需要准备一批校准图片来避免精度掉太多。我的经验是FP16精度损失很小收益明显适合大多数工业场景INT8需要仔细评估检出率变化特别是有小目标的时候要慎重。2.2 开发环境与依赖版本这一节我把环境列清楚新手照着装基本不会踩坑。组件推荐版本备注操作系统Windows 10/11 专业版工控机用Win10居多别用家庭版CUDA11.4 或 11.8要和显卡驱动匹配cuDNN8.x和CUDA版本对应TensorRT8.4 或 8.5下载对应CUDA版本OpenCV4.5.5建议用官方预编译包Visual Studio2019 / 2022要装C桌面开发组件CMake3.20构建DLL工程用Pytorch1.10训练/导出模型用这里有个特别容易出问题的点TensorRT和OpenCV的版本搭配。OpenCV从4.x开始某些版本会引入和CUDA/TensorRT冲突的依赖库比如opencv_world里自带的第三方库导致DLL运行时出现莫名其妙的崩溃。我的习惯是尽量用官方预编译的OpenCV并且把OpenCV、CUDA、TensorRT的bin目录按顺序配置到系统PATH里避免覆盖冲突。2.3 模型导出从pt到ONNX再到engineYolov5模型训练好之后是.pt格式不能直接给TensorRT用。标准流程是.pt-.onnx-.engine。第一步把Pytorch模型导出成ONNX。Yolov5官方仓库的export.py已经封装好了命令大概是这样python export.py --weights yolov5s.pt --img 640 --batch 1 --include onnx --opset 12 --simplify注意几个参数--img 640要和后续推理输入尺寸一致--opset 12以上是TensorRT兼容性比较好的--simplify会调用onnx-simplifier优化计算图建议加上。如果模型里有自己改的检测头或者自定义层导出后最好先用onnxruntime跑一下看看输出是不是正常别等部署了才发现网络结构导错了。第二步把ONNX转成TensorRT引擎。可以用trtexec命令行工具也可以写Python脚本。我直接用的命令行trtexec --onnxyolov5s.onnx --saveEngineyolov5s_fp16.engine --fp16加上--fp16就是FP16精度。转出来的.engine文件是和具体GPU绑定的换一张显卡就得重新转这个要提前和产线确认好工控机配置别拿自己电脑的engine丢产线去跑。3. 相机采集与图像解码实操3.1 初始化相机与关键参数配置相机SDK提供的API大同小异流程基本是枚举设备 - 打开设备 - 设置采集模式 - 开始采集 - 获取图像。用伪代码写一下大概是这样// 枚举相机 std::vectorCameraInfo cameras CameraSDK::EnumCameras(); if (cameras.empty()) { /* 处理无相机 */ } // 打开设备 CameraHandle handle CameraSDK::Open(cameras[0].ip); // 设置参数 CameraSDK::SetExposureTime(handle, 3000.0); // 曝光时间 3ms CameraSDK::SetGain(handle, 10.0); // 增益 10dB CameraSDK::SetTriggerMode(handle, TRIGGER_MODE_SOFTWARE); // 软触发 // 注册图像回调 CameraSDK::RegisterImageCallback(handle, onImageCallback, userData); // 开始采集 CameraSDK::StartGrabbing(handle);这里最有讲究的是触发模式。工业场景一般有两种选择硬触发外部传感器光电开关、接近开关给一个电平信号相机才拍一张。适合运动速度固定、有明确触发位置的产线。软触发上位机软件调用一次触发指令相机拍一张。适合节拍由程序控制、需要随时抓拍的场景。我这次用的软触发因为产线节拍是上位机控制的逻辑简单不容易出错。如果你的工件在流水线上高速运动还要考虑运动模糊的问题——曝光时间要足够短或者配频闪光源。3.2 图像格式转换别让Bayer坑了你工业相机输出的原始图像格式和普通网页摄像头的JPEG完全不是一回事。常见的有像素格式说明Mono8单通道灰度图每个像素8bit直接转为单通道MatBayerRG8拜耳彩色图每个像素只有R/G/G/B中的一个分量需要去马赛克RGB24 / YUV422彩色格式需要转换成OpenCV的BGR这个转换步骤看着简单但实际项目里最容易在这里翻车。我遇到过一个情况相机输出BayerRG8我直接用OpenCV的cv::cvtColor转成BGR结果图像看起来整体泛绿带条纹。原因就是Bayer的排列顺序不匹配——BayerRG和BayerGB、BayerGR之间都有区别相机SDK文档里标注的RGBA顺序要和OpenCV的转换代码一一对应才行。更稳妥的做法是用相机SDK自带的转换接口。工业相机SDK一般提供ConvertToBGR或者PixelConvert这类函数它们会考虑相机内部的实际排列比OpenCV的通用转换可靠得多。但SDK转换函数有的不能在线程里并发调用这个要看具体文档必要时要加锁。另外工业相机还有一个坑图像步长stride可能不等于宽度。有些相机的每一行数据会按4字节或8字节对齐导致stride width。如果你直接用cv::Mat(height, width, CV_8UC3, buffer)来构造Mat行尾有多余的填充字节图像就会歪斜或出现竖条纹。正确做法是int width cameraInfo.width; int height cameraInfo.height; int stride cameraInfo.stride; // 相机给的行字节数 cv::Mat raw(height, height * stride, CV_8UC1, buffer); // 先按完整stride构造 cv::Mat img raw(cv::Rect(0, 0, width, height)).clone(); // 裁掉填充部分3.3 采集线程与缓冲队列设计图像采集不能和推理放同一个线程里做否则相机SDK回调一阻塞后面的图像就会排队积压延迟越来越大。我常用的结构是生产者-消费者相机SDK的回调线程是生产者拿到一帧图像后做格式转换然后压入一个无锁队列或用互斥锁保护的队列。推理线程是消费者从队列里取最新一帧做预处理和模型推理。这里有个细节很多人容易忽略队列长度和丢帧策略。工业视觉很多时候不需要每一帧都处理比如相机采集30FPS推理只能跑15FPS那中间帧注定要被丢掉。如果队列积压了很多旧帧推理线程取出来处理的是“几秒前的画面”那检测结果毫无实时性可言。所以我的队列实现里故意做成了只保留最新一帧的机制新帧来了直接覆盖旧帧。这样虽然会丢帧但保证了永远处理的是最新画面延迟可控。对于产线节拍检测来说“看到当前时刻”远比“一帧不漏”重要。4. Yolov5模型的C推理集成4.1 预处理LetterBox的每一项细节把相机采集到的图像喂给模型之前要做Yolov5标准的预处理。这一步看着简单但里面的坑我整整排查了两天。标准流程是将原始图像按比例缩放长边缩放到640短边按同比例缩放不足的部分用灰色114,114,114填充。将BGR图像转换为RGB。将图像数据从HWC排列转为CHW排列。归一化到0-1之间除以255。转换为FP32送入TensorRT。C里实现LetterBox时要特别注意填充和缩放的计算必须和训练时保持一致。Yolov5训练时用的letterbox函数当原图宽高比和目标宽高比不完全相等时它选择的缩放比例是min(ratio_w, ratio_h)然后按这个比例缩放宽高最后计算需要填充的像素数。cv::Mat letterbox(const cv::Mat src, int targetSize 640) { int w src.cols, h src.rows; float ratio std::min(float(targetSize) / w, float(targetSize) / h); int newW std::round(w * ratio); int newH std::round(h * ratio); cv::Mat resized; cv::resize(src, resized, cv::Size(newW, newH), 0, 0, cv::INTER_LINEAR); int padW targetSize - newW; int padH targetSize - newH; int top padH / 2; int left padW / 2; cv::Mat out; cv::copyMakeBorder(resized, out, top, padH - top, left, padW - left, cv::BORDER_CONSTANT, cv::Scalar(114, 114, 114)); return out; }这里top和left的数值在后续坐标映射时要用到所以必须记录下来。我一般用结构体保存struct LetterBoxInfo { float ratio; int top; int left; int padW; int padH; };4.2 推理与后处理NMS在C里怎么实现TensorRT推理有标准流程创建ExecutionContext - 绑定输入输出buffer - 拷贝输入 - enqueue推理 - 拷贝输出。这部分代码模板网上很多我就不贴完整代码了重点说几个容易出问题的地方。输入buffer是float*类型size是1x3x640x640。要把预处理好的图像数据memcpy进去注意TensorRT要求的输入顺序是NCHW和OpenCV的Mat默认HWC不同别搞混了。输出Tensor的解析才是重头戏。Yolov5的输出形状是[1, num_anchors * num_classes 5, output_count]在C里拿到的直接就是这个三维Tensor展开后的数据。以Yolov5s为例输入640x640时三个检测头输出的anchor总数是(80*80 40*40 20*20) * 3 25200每个anchor有5 num_classes个值COCO数据集就是5 80 85。所以输出buffer的长度就是25200 * 85。解析阶段要做的遍历所有anchor取前5个值。前4个是[cx, cy, w, h]注意这是模型输出空间的坐标不是原图坐标第5个是目标置信度obj_conf。计算每个类别的置信度class_conf obj_conf * cls_prob。如果class_conf大于阈值工业场景我常用0.25-0.4保留这个候选框。对保留的候选框做NMS非极大值抑制去掉重叠过高的框。NMS的实现有几种选择手写循环、用OpenCV的cv::dnn::NMSBoxes、用TensorRT自带的后处理插件。我个人建议先把cv::dnn::NMSBoxes用起来它简单可靠不需要你写排序和IoU计算。但注意NMSBoxes处理的是像素坐标整数或float它需要的是box的坐标格式是(x, y, w, h)和Yolov5输出的中心点坐标要先转换一下才能传进去。如果你要处理的目标特别多比如一张图几百个目标OpenCV的NMS可能成为瓶颈那时候再手写一个高效的NMS实现或者上TensorRT的NMS插件收益会比较大。4.3 检测框坐标映射回原图模型输出的是在640x640输入空间里的坐标要映射回原始图像需要做LetterBox的逆变换float x1 (box.x1 - left) / ratio; float y1 (box.y1 - top) / ratio; float x2 (box.x2 - left) / ratio; float y2 (box.y2 - top) / ratio;注意如果LetterBox的宽或高padding是奇数left和top和right、bottom不相等必须严格按照之前记录的left/top来减。这个细节不处理好检测框会出现整体偏移的情况特别是在图像边缘的目标。另外映射完记得做一次clip把坐标限制在原始图像的宽高范围内防止坐标越界导致后续绘制或ROI提取崩溃。x1 std::clamp(x1, 0.0f, (float)src.cols - 1); y1 std::clamp(y1, 0.0f, (float)src.rows - 1); x2 std::clamp(x2, 0.0f, (float)src.cols - 1); y2 std::clamp(y2, 0.0f, (float)src.rows - 1);检测框坐标一旦映射回原图后面就可以直接画框、裁ROI、做OCR或者其他进一步处理和PyTorch里的结果是完全对应的。5. C封装DLL的完整流程5.1 接口设计稳定ABI是底线DLL封装这块我认为是最考验工程经验的环节因为算法写得再好接口设计烂了照样崩。第一个原则DLL导出的接口尽量用C语言风格接口不要直接导出C的class。原因有两层一是C接口没有C的名称修饰name mangling问题可以被C、C、C#、Python等任何语言调用二是C接口能避免STL、异常等跨模块传递带来的ABI兼容问题。我设计的接口大概长这样// 初始化检测器 // 返回句柄后续所有接口都基于这个句柄操作 HANDLE Detector_Init(const char* modelPath, int gpuId); // 传入一张BGR图像执行检测结果通过回调函数返回 int Detector_DetectImage(HANDLE handle, const unsigned char* bgrData, int width, int height, DetectCallback callback, void* userContext); // 释放检测器 void Detector_Release(HANDLE handle);检测结果用回调函数返回的好处是不需要在DLL内部维护一个结果缓存区也不存在“调完函数再翻数据”的同步问题。回调函数的定义typedef void (*DetectCallback)(int objectCount, const DetectResult* results, void* userContext);其中DetectResult是一个纯C结构体typedef struct { float x1, y1, x2, y2; float confidence; int classId; } DetectResult;C#那边可以直接用Marshal.StructToStructure解析这个结构体。如果你把std::vectorDetectResult传到DLL边界C#调用方就傻眼了。5.2 内部封装句柄模式管理类实例C语言接口不能直接传递C对象指针所以我们用句柄模式HANDLE其实是一个不透明的指针指向内部C类的实例。extern C HANDLE Detector_Init(const char* modelPath, int gpuId) { auto* detector new Detector(modelPath, gpuId); return (HANDLE)detector; }然后DetectImage里再把句柄转回来extern C int Detector_DetectImage(HANDLE handle, ...) { auto* detector (Detector*)handle; detector-DetectImage(...); }这个模式的好处是你可以在DLL内部任意修改类的成员和依赖只要对外接口签名不变调用方完全无感。模型初始化、TensorRT引擎加载、相机实例管理这些全部藏在类内部。Detector类内部我大致分了几块Engine封装TensorRT引擎加载、推理。Preprocessor图像预处理。PostProcessor输出解析和NMS。ResultQueue异步结果队列如果要做异步检测。CameraManager相机采集管理。5.3 回调机制与状态上报回调函数是DLL和上位机之间最直接的沟通方式但也是最容易出问题的地方。有几个经验值得分享回调线程必须明确。假设你在DLL的推理线程里调用回调函数而上位机在UI线程里更新界面那就涉及跨线程UI更新的问题。C#的UI控件不能在非UI线程直接更新否则会抛异常。所以上位机那边拿到回调后要自己通过Invoke或者BeginInvoke切回UI线程。这个不是你DLL能控制的但接口文档里必须写清楚回调在哪个线程执行避免两边因为线程问题互相甩锅。不要在回调里做耗时操作。如果上位机的回调里做了保存图片、数据库写入这类操作会阻塞DLL的推理线程导致帧率下降。规范做法是上位机回调里只把结果塞进自己的队列耗时操作放到自己的后台线程。另外除了检测结果DLL还可以定期上报状态信息比如FPS、丢帧率、GPU温度、最近一次错误码。这些用另一个回调函数或者统一的回调对象传过来对产线运维特别有用。我这次是定义了一个StatusCallback每1秒推送一次状态。5.4 内存管理与线程安全DLL最容易崩溃的第二个点是内存管理。C接口的DLL有一个铁律谁分配谁释放。在DLL里分配的buffer不能在调用方那边直接free或delete反之亦然。我设计的接口里输入图像的内存是调用方分配的DLL只读不写这个简单。检测结果数组呢如果每次回调传一个临时数组的指针调用方直接用——这个没问题因为回调是同步的调用方在回调函数里读取这些数据时DLL侧还没释放。但如果要做异步检测检测结果需要从推理线程传给回调那就要考虑生命周期。我的做法是在推理线程内先new一个DetectResult[]数组填充完把指针传给回调回调结束后由DLL统一释放。这样设计调用方不需要关心释放DLL也不会出现“调用方还在用DLL提前释放”的悬垂指针问题。线程安全方面实际场景里要处理几个并发多个上位机线程同时调用DetectImage但TensorRT推理本身是串行的所以需要一把互斥锁保证同一时刻只有一个推理。相机采集线程和推理线程共享的图像队列需要加锁或者用无锁队列。如果在同一个DLL里管理多个相机和多个模型实例注意它们之间是共享资源还是独占资源别搞混。6. 常见问题与排查技巧实录这一节把我实际踩过的坑和排查过程写出来希望能帮你省几天时间。6.1 相机SDK和GPU抢显存导致崩溃这个坑非常隐蔽。某次联调时DLL加载模型后一切正常但相机一开程序直接崩溃或者报CUDA error: out of memory。排查了很久才发现原因相机SDK的某个功能模块默认会自己分配一大块GPU显存用于图像处理加速而TensorRT引擎加载时已经把显存用得差不多了两边撞上了。解决办法有两个方向一是调整相机SDK的GPU显存使用设置在初始化时通过配置参数禁用或减少显存占用二是把TensorRT的推理显存池设置小一点给相机SDK留出余量。工业相机的SDK大多数都有类似参数关键是你要知道有这回事不然你怎么查都查不到。6.2 检测框整体偏移或错位一次现场调试模型推理结果完全正确画出来的框却整体向右下角偏了几十个像素。第一反应是坐标映射公式有问题但检查了好几遍代码逻辑都对。最后发现是输入图像的步长问题——生产环境的工控机分辨率设置和开发机不同相机SDK返回的图像行字节数多了一段paddingcv::Mat直接构造时没有裁掉图像整体被拉伸了检测框自然对不上。解决方案就是我前面提到的构造Mat时先带stride构造再裁掉多余部分。这个坑在分辨率越大时越明显值得重点排查。6.3 C#调用DLL总是崩溃很多搞算法的人不熟悉C#调用C DLL的坑。最典型的问题是C#的string传给C接口时默认用UTF-16编码而C里的const char*是UTF-8/ANSI编码要不你DLL里显式转编码要不C#那边用Marshal.PtrToStringAnsi处理。两者不匹配轻则乱码重则栈溢出崩溃。另外C#侧的委托Delegate传给C函数指针时要确保委托对象在C调用期间不被GC回收。最简单的方法是保持一个全局静态变量引用委托或者用GCHandle.Alloc(handle, GCHandleType.Pinned)固定住。否则你会发现程序跑着跑着突然崩溃毫无规律。6.4 帧率上不去瓶颈到底在哪帧率不达标是工业视觉项目最常被diss的点。排查的思路不要靠猜要逐段量化。我给一个标准流程只测相机采集到内存的时间一般GigE相机控制在几毫秒到十几毫秒如果超过50ms先查网络和驱动。只测预处理时间LetterBox加格式转换OpenCV下5ms以内算正常。只测推理时间TensorRT FP16下Yolov5s约10-15ms这步超时先怀疑显卡算力和引擎版本。只测后处理时间置信度过滤加NMS一般1-3ms。定位到具体环节后再针对性优化。如果每一段都快但总帧率还是上不去那就是线程调度和缓冲的问题了优先检查队列是否积压、是否频繁加锁、是否存在不必要的cv::Mat深拷贝。一些想法收尾整个方案做下来我最深的感受是深度学习部署这件事真正难的从来不是模型而是把模型塞进一个工业系统里让它和其他模块协同工作。相机采集、图像处理、推理引擎、接口设计、线程调度任何一环掉链子最终效果都归零。Yolov5本身已经足够成熟TensorRT的性能也足够好多数项目失败不是因为模型选得不好而是因为在环境配置、数据格式转换、DLL接口设计这些琐碎又关键的环节上踩了坑没人带。如果你们也在做类似的视觉检测项目我建议把每一步的验证做扎实。导出的模型先用onnxruntime验证一遍TensorRT的engine先跑测试图片验证输出DLL先写一个简单的C控制台程序调用最后再接C#上位机。每增加一层调用关系就多一层出错的可能逐层验证能帮你把问题迅速定位到具体模块。最后再分享一个小技巧DLL工程在Visual Studio里编译时把调试信息和符号文件.pdb保留好同时开启/Zo选项增强优化调试。当上位机那边出了诡异崩溃你把这套PDB文件给到对方用VS附加到进程调试十次里有八次能直接定位到问题出在DLL哪一行。这一手在工业现场排障时比看半天日志高效太多了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑