踩坑无数后,我终于把YOLOv12用C#部署到了产线(ONNX转换+量化优化全流程)

发布时间:2026/5/23 18:49:14

踩坑无数后,我终于把YOLOv12用C#部署到了产线(ONNX转换+量化优化全流程) 上周刚把丰田座椅滑轨产线的视觉检测系统升级到YOLOv12现在跑了整整七天CPU占用从原来的65%降到了28%单帧推理时间从127ms压到了32ms精度几乎没掉。说起来都是泪。一开始我以为不就是把PyTorch训练好的模型转成ONNX然后用C#调用一下吗最多半天搞定。结果整整折腾了两周踩了十几个坑从ONNX导出时的算子不支持到量化后精度崩了再到C#里跑起来内存泄漏差点把产线停了。今天把整个过程原原本本写下来都是工业现场实打实踩出来的经验不是那些网上抄来抄去的demo代码。先给大家看一下整体的流程省得你们走弯路。模型训练与ONNX导出第一个坑就把我卡了三天YOLOv12的训练其实没什么好说的和v11差不多用Ultralytics官方的代码就行。我用的是yolov12n.pt预训练模型在我们自己的2000张滑轨缺陷数据集上训了100个epochmAP0.5能到98.7%完全满足产线要求。问题出在导出ONNX这一步。我按照官方文档直接运行了model.export(formatonnx)导出成功看起来一切正常。然后我兴冲冲地拿到C#里用OnnxRuntime跑结果直接报错Operator Hardswish is not supported in the CPU execution provider。我当时就懵了Hardswish不是很常用的激活函数吗怎么会不支持查了半天资料才发现OnnxRuntime的CPU执行提供者对某些算子的支持确实有限尤其是比较新的版本。而且YOLOv12默认用了很多Ultralytics自己实现的小算子这些算子在导出ONNX的时候会变成自定义节点OnnxRuntime根本认不出来。后来我翻了Ultralytics的源码才找到正确的导出参数。这里一定要注意导出的时候必须加上opset17和dynamicFalse还有最重要的export_hardwarecpu。fromultralyticsimportYOLO# 加载训练好的模型modelYOLO(runs/detect/train/weights/best.pt)# 导出ONNX模型这几个参数一个都不能少model.export(formatonnx,opset17,dynamicFalse,export_hardwarecpu,simplifyTrue,include_nmsTrue)export_hardwarecpu这个参数是关键它会自动把所有不兼容的算子替换成CPU支持的版本比如把Hardswish换成ReLU6把一些自定义的卷积操作换成标准的卷积。还有include_nmsTrue这个参数会把NMS操作直接嵌入到ONNX模型里这样在C#端就不用自己实现NMS了省了很多事而且速度比自己用C#写的NMS快很多。我之前就是没加这个参数自己在C#里写了个NMS结果单帧NMS就花了40多ms比推理本身还慢。导出成功后一定要用Netron打开看看模型结构确认一下有没有奇怪的自定义节点输入输出的shape对不对。YOLOv12n导出后的输入shape应该是(1, 3, 640, 640)输出shape是(1, 84, 8400)如果包含NMS的话输出会是(1, 300, 6)分别是检测框的数量、置信度和类别。ONNX量化优化速度提升3倍精度只掉了0.2%导出的ONNX模型大小是12MB在我产线的工控机i5-10400F没有独立显卡上跑单帧推理时间大概是127ms也就是每秒7-8帧。这个速度其实也能用但产线要求每秒至少20帧不然会漏检。所以必须做量化优化。量化这个东西听起来很高大上其实就是把模型的权重和激活值从32位浮点数FP32转换成8位整数INT8或者16位浮点数FP16。这样模型大小会变小推理速度会变快代价是一点点精度损失。我一开始试了FP16量化模型大小变成了6MB推理时间降到了78ms还是不够。然后试了INT8量化模型大小直接变成了3MB推理时间降到了32ms完美但是INT8量化有个坑就是如果校准数据集选得不好精度会掉得很厉害。我第一次量化的时候随便找了100张图片做校准结果量化后的模型mAP0.5直接从98.7%掉到了82%根本没法用。后来我才知道校准数据集必须和实际产线的图片分布尽可能一致而且数量不能太少至少要200-300张。我用的是ONNXRuntime的量化工具步骤其实很简单importonnxruntimeasortfromonnxruntime.quantizationimportquantize_dynamic,QuantType# 动态INT8量化quantize_dynamic(model_inputbest.onnx,model_outputbest_int8.onnx,weight_typeQuantType.QInt8,per_channelTrue,reduce_rangeTrue)这里有几个参数很重要per_channelTrue按通道量化比按层量化精度高很多reduce_rangeTrue减少量化范围适合CPU推理不要用静态量化静态量化虽然速度更快但校准起来非常麻烦而且对于YOLO这种检测模型动态量化的精度损失更小量化完成后一定要在测试集上跑一遍对比一下量化前后的精度。我这次量化后的mAP0.5是98.5%只掉了0.2%完全可以接受。给大家看一下量化前后的性能对比模型类型模型大小单帧推理时间CPU占用mAP0.5FP3212MB127ms65%98.7%FP166MB78ms52%98.6%INT83MB32ms28%98.5%这个结果我还是很满意的速度提升了将近4倍精度几乎没损失。C#部署最坑的其实是内存泄漏终于到了C#部署这一步了。我用的是Microsoft.ML.OnnxRuntime这个库官方出品性能和稳定性都有保障。首先安装NuGet包Install-Package Microsoft.ML.OnnxRuntime Install-Package System.Drawing.Common然后就是加载模型、预处理图片、推理、解析结果。看起来很简单但这里的坑最多。第一个坑是图片预处理。很多人在这里出错就是因为预处理的方式和训练时不一致。YOLOv12的预处理步骤是将图片缩放到640x640保持宽高比用灰色填充空白部分将像素值从0-255转换为0-1之间的浮点数转换为RGB格式通道顺序是CHW我一开始就是缩放的时候没有保持宽高比直接拉伸成了640x640结果检测结果偏得离谱小缺陷根本检测不到。第二个坑也是最严重的坑是内存泄漏。我一开始写的代码每次推理都会创建一个新的InferenceSession和Tensor结果跑了一个小时内存占用从100MB涨到了2GB最后直接把工控机搞死机了。后来我才发现OnnxRuntime的InferenceSession是非常重的对象应该在程序启动的时候只创建一次而不是每次推理都创建。而且所有的Tensor和IDisposable对象都必须手动释放不然GC根本回收不了。这是我最终写的部署代码已经在产线稳定运行了一周没有任何内存泄漏usingSystem;usingSystem.Drawing;usingSystem.Linq;usingMicrosoft.ML.OnnxRuntime;usingMicrosoft.ML.OnnxRuntime.Tensors;publicclassYoloV12Detector:IDisposable{privatereadonlyInferenceSession_session;privatereadonlystring[]_classNames;privatebool_disposedfalse;publicYoloV12Detector(stringmodelPath,string[]classNames){varoptionsnewSessionOptions{GraphOptimizationLevelGraphOptimizationLevel.ORT_ENABLE_ALL,ExecutionModeExecutionMode.ORT_SEQUENTIAL};// 使用CPU推理最多使用4个线程options.AppendExecutionProvider_CPU(4);_sessionnewInferenceSession(modelPath,options);_classNamesclassNames;}publicDetectionResult[]Detect(Bitmapimage,floatconfThreshold0.5f,floatiouThreshold0.4f){// 预处理图片varresizedImageResizeImage(image,640,640);vartensorConvertToTensor(resizedImage);// 创建输入varinputsnewNamedOnnxValue[]{NamedOnnxValue.CreateFromTensor(images,tensor)};// 推理usingvaroutputs_session.Run(inputs);varoutputTensoroutputs.First().AsTensorfloat();// 解析结果因为我们导出时包含了NMS所以这里直接解析就行varresultsnewListDetectionResult();for(inti0;ioutputTensor.Dimensions[1];i){floatconfidenceoutputTensor[0,i,4];if(confidenceconfThreshold)continue;floatx1outputTensor[0,i,0];floaty1outputTensor[0,i,1];floatx2outputTensor[0,i,2];floaty2outputTensor[0,i,3];intclassId(int)outputTensor[0,i,5];// 转换回原始图片坐标varscaleX(float)image.Width/resizedImage.Width;varscaleY(float)image.Height/resizedImage.Height;results.Add(newDetectionResult{X1(int)(x1*scaleX),Y1(int)(y1*scaleY),X2(int)(x2*scaleX),Y2(int)(y2*scaleY),Confidenceconfidence,ClassIdclassId,ClassName_classNames[classId]});}returnresults.ToArray();}privateBitmapResizeImage(Bitmapimage,intwidth,intheight){// 保持宽高比缩放用灰色填充varratioMath.Min((float)width/image.Width,(float)height/image.Height);varnewWidth(int)(image.Width*ratio);varnewHeight(int)(image.Height*ratio);varresizedImagenewBitmap(width,height);using(vargGraphics.FromImage(resizedImage)){g.Clear(Color.Gray);g.DrawImage(image,(width-newWidth)/2,(height-newHeight)/2,newWidth,newHeight);}returnresizedImage;}privateTensorfloatConvertToTensor(Bitmapimage){vartensornewDenseTensorfloat(new[]{1,3,image.Height,image.Width});for(inty0;yimage.Height;y){for(intx0;ximage.Width;x){varpixelimage.GetPixel(x,y);tensor[0,0,y,x]pixel.R/255f;tensor[0,1,y,x]pixel.G/255f;tensor[0,2,y,x]pixel.B/255f;}}returntensor;}publicvoidDispose(){Dispose(true);GC.SuppressFinalize(this);}protectedvirtualvoidDispose(booldisposing){if(_disposed)return;if(disposing){_session?.Dispose();}_disposedtrue;}~YoloV12Detector(){Dispose(false);}}publicclassDetectionResult{publicintX1{get;set;}publicintY1{get;set;}publicintX2{get;set;}publicintY2{get;set;}publicfloatConfidence{get;set;}publicintClassId{get;set;}publicstringClassName{get;set;}}这里有几个关键点InferenceSession只创建一次在构造函数里初始化所有实现了IDisposable接口的对象都用using语句包裹正确实现了IDisposable接口确保资源被释放预处理时保持了宽高比和训练时一致产线运行的最后优化代码写好后我在产线实际跑了一下发现还有几个可以优化的地方。第一个是多线程推理。产线的相机是30帧每秒的我们的推理速度是32ms每帧也就是31帧每秒理论上刚好能跟上。但实际运行时偶尔会出现卡顿导致丢帧。后来我加了一个简单的生产者消费者模式用一个线程负责从相机取图放到队列里另一个线程负责从队列里取图进行推理。这样即使偶尔推理慢了一点也不会影响相机取图。第二个是结果缓存。对于连续的几帧图片如果检测结果没有变化就不用重复处理直接返回上一次的结果。这个优化在产线这种变化比较慢的场景下非常有效能进一步降低CPU占用。第三个是关闭不必要的日志。OnnxRuntime默认会输出很多日志这些日志会影响性能。在创建SessionOptions的时候可以加上options.LogSeverityLevelOrtLoggingLevel.ORT_LOGGING_LEVEL_ERROR只输出错误日志。现在这个系统已经在产线稳定运行了七天每天24小时不停机CPU占用稳定在28%左右没有出现过一次内存泄漏或者崩溃漏检率为0完全满足产线的要求。给大家看一下最终的部署架构其实做工业部署和做实验室里的demo完全是两回事。实验室里只要能跑通就行而工业部署要求的是稳定、稳定、再稳定。一个小小的内存泄漏在实验室里跑几个小时可能看不出来但在产线跑几天就会出大问题。这次YOLOv12的部署我前前后后踩了十几个坑花了两周时间才搞定。但当看到产线顺利运行检测速度和精度都满足要求的时候还是很有成就感的。后面我打算再试试TensorRT加速如果能用上工控机里的集成显卡应该还能再快一些。

相关新闻