上个月在天津滨海新区那个汽车零部件厂的项目,我差点当场辞职。
客户要求在产线上加个缺陷检测,用YOLOv8识别零件的划痕和毛刺。我当时图省事,直接用Python写了个检测程序,本地跑的好好的,一到客户现场直接傻眼。
客户的服务器是Windows Server 2012,连外网都断了,USB口还封了一半。我带着U盘拷了Python3.9的安装包,还有十几个whl文件,装到一半提示缺少VC++2015运行库。回去下完运行库,又说numpy版本不兼容,和opencv-python打起来了。
折腾了整整三天,最后没办法,把我自己电脑上的整个Anaconda环境用PyInstaller打包成了exe。生成的文件1.2G,拷到客户服务器上,刚双击运行,360直接弹出17个病毒警告,连文件带文件夹全给我删了。
我当时在客户机房冻得要死,穿了两件外套还是流鼻涕,看着屏幕上的病毒提示,差点把键盘砸了。
也就是那天,我下定决心,以后所有工业项目的YOLO部署,全部用C#写。
工业界90%的上位机都是C#写的,你跟PLC通信要C#,接海康工业相机要C#,写界面要C#,连数据库要C#。非要插个Python进去,纯属给自己找罪受。
很多人以为C#部署YOLO很复杂,其实真的简单到离谱。你不需要装OpenCV,不需要装CUDA,不需要任何乱七八糟的环境,就三个NuGet包,一个exe扔过去就能跑。
Microsoft.ML.OnnxRuntime 1.16.3 SixLabors.ImageSharp 3.1.2 SixLabors.ImageSharp.Drawing 1.0.0别用最新版的ONNX Runtime,1.17预览版我试过,推理结果随机出错,内存泄漏漏的跟筛子一样。1.16.3是目前最稳定的版本,没有之一。
还有,绝对不要用微软自带的System.Drawing.Common。这个坑我踩了整整三天,后面细说。
先导出模型。在你训练好YOLOv8的Python环境里,运行这段代码,参数一个都不能改,改一个就炸。
fromultralyticsimportYOLO model=YOLO("yolov8n.pt")model.export(format="onnx",opset=17,dynamic=True,simplify=True)别问我为什么opset是17,问就是18和19全是坑。我之前用opset=19导出的模型,在ONNX Runtime里跑,同一个图片每次推理结果都不一样,一会检测出三个车,一会检测出五个,跟闹鬼一样。
dynamic=True这个参数一定要加!一定要加!一定要加!
我之前就是没加这个,导出的模型只能接受640x640的输入。客户产线上的相机拍出来是1280x720的图片,我缩放到640x640传进去,推理不报错,但是结果全是错的,置信度全是0.01左右。我排查了整整两天,从预处理查到后处理,把每一步的数值都和Python对比,最后才发现是模型导出的时候没开动态轴。
当时我真想抽自己两个大嘴巴子。
导出完成之后,你会得到一个6MB左右的yolov8n.onnx文件,扔到C#项目的根目录,属性里设置"如果较新则复制"。
然后定义个检测结果的类,没什么好说的。
publicclassDetectionResult{publicintClassId{get;set;}publicstringClassName{get;set;}publicfloatConfidence{get;set;}publicRectangleFBoundingBox{get;set;}}接下来是检测器的主类。我把所有逻辑都封装在这一个类里,用的时候直接new就行。
publicclassYoloV8Detector:IDisposable{privatereadonlyInferenceSession_session;privatereadonlystring[]_classNames;privatereadonlyfloat_confThreshold;privatereadonlyfloat_iouThreshold;publicYoloV8Detector(stringmodelPath,floatconfThreshold=0.5f,floatiouThreshold=0.4f){_confThreshold=confThreshold;_iouThreshold=iouThreshold;varsessionOptions=newSessionOptions();sessionOptions.AppendExecutionProvider_CPU();sessionOptions.GraphOptimizationLevel=GraphOptimizationLevel.ORT_ENABLE_ALL;// 别问我为什么是核心数的一半,我测了无数次,这个值速度最快sessionOptions.IntraOpNumThreads=Environment.ProcessorCount/2;sessionOptions.InterOpNumThreads=1;_session=newInferenceSession(modelPath,sessionOptions);// 从模型元数据里读类别名,不用自己硬写if(_session.ModelMetadata.CustomMetadataMap.TryGetValue("names",outvarnamesJson)){_classNames=JsonSerializer.Deserialize<string[]>(namesJson);}else{// 万一模型里没存,就用COCO默认的,剩下的自己补_classNames=new[]{"person","bicycle","car","motorcycle","airplane","bus","train","truck","boat","traffic light"};}}模型加载就这么几行代码。很多人喜欢把线程数设成CPU核心数,觉得越多越快。我在i7-12700H上测过,设成8线程的时候,单张图片推理要50ms,设成4线程的时候,只要32ms。线程多了反而会因为上下文切换变慢,别瞎设。
然后是预处理。90%的人部署YOLO出问题,都是栽在预处理上。
YOLOv8的预处理步骤说起来简单:缩放保持宽高比,黑边填充,像素归一化到0-1,转成NCHW格式。但是只要有一个步骤和Python不一样,最终的检测结果就会差十万八千里。
我之前用System.Drawing.Common写预处理,结果检测出来的框总是偏个几像素。我把每一步的像素值都打印出来对比,发现System.Drawing的缩放算法和OpenCV的不一样,差了一点点,就是这一点点,导致最终结果全错。
后来换成SixLabors.ImageSharp,问题直接解决了。而且速度比System.Drawing快了至少三倍。
private(DenseTensor<float>,intpadX,intpadY,floatscale)Preprocess(Imageimage){constinttargetSize=640;// 计算缩放比例varscale=Math.Min((float)targetSize/image.Width,(float)targetSize/image.Height);varscaledWidth=(int)(image.Width*scale);varscaledHeight=(int)(image.Height*scale);// 计算上下左右的填充量varpadX=(targetSize-scaledWidth)/2;varpadY=(targetSize-scaledHeight)/2;// 创建黑色背景的新图usingvarresized=newImage<Rgb24>(targetSize,targetSize);resized.Mutate(ctx=>{ctx.DrawImage(image,newRectangle(padX,padY,scaledWidth,scaledHeight),1f);});// 直接操作像素,比GetPixel快10倍都不止vartensor=newDenseTensor<float>(new[]{1,3,targetSize,targetSize});resized.ProcessPixelRows(accessor=>{for(inty=0;y<targetSize;y++){varrow=accessor.GetRowSpan(y);for(intx=0;x<targetSize;x++){varpixel=row[x];tensor[0,0,y,x]=pixel.R/255f;tensor[0,1,y,x]=pixel.G/255f;tensor[0,2,y,x]=pixel.B/255f;}}});return(tensor,padX,padY,1/scale);}这段预处理我改了八遍,终于和Python的ultralytics库输出的张量完全一致了。你直接抄就行,不用改。
然后是推理和后处理。YOLOv8的输出和之前的v5、v6都不一样,它的输出是[1, 84, 8400]的张量,不是[1, 25200, 85]。很多老教程还在按v5的格式解析,结果肯定全错。
8400是检测框的数量,前4个是x_center, y_center, width, height,后面80个是COCO类别的置信度。
publicList<DetectionResult>Detect(Imageimage){var(tensor,padX,padY,scale)=Preprocess(image);varresults=newList<DetectionResult>();// 运行推理usingvaroutputs=_session.Run(newList<NamedOnnxValue>{NamedOnnxValue.CreateFromTensor("images",tensor)});varoutput=outputs.First().AsTensor<float>();// 遍历所有检测框for(inti=0;i<8400;i++){// 找置信度最高的类别floatmaxConf=0;intclassId=0;for(intj=4;j<84;j++){varconf=output[0,j,i];if(conf>maxConf){maxConf=conf;classId=j-4;}}if(maxConf<_confThreshold)continue;// 解析坐标,注意这里是相对于640x640的坐标varxc=output[0,0,i];varyc=output[0,1,i];varw=output[0,2,i];varh=output[0,3,i];// 转换回原图坐标,减去填充量,再乘以缩放比例varx1=(xc-w/2-padX)*scale;vary1=(yc-h/2-padY)*scale;varx2=(xc+w/2-padX)*scale;vary2=(yc+h/2-padY)*scale;// 防止坐标越界x1=Math.Max(0,x1);y1=Math.Max(0,y1);x2=Math.Min(image.Width-1,x2);y2=Math.Min(image.Height-1,y2);results.Add(newDetectionResult{ClassId=classId,ClassName=_classNames[classId],Confidence=maxConf,BoundingBox=newRectangleF(x1,y1,x2-x1,y2-y1)});}// 非极大值抑制,去掉重复的框returnNms(results);}NMS我就自己写了个简单版,够用就行。不用去引什么第三方库,搞那么复杂。
privateList<DetectionResult>Nms(List<DetectionResult>detections){varresult=newList<DetectionResult>();// 按置信度从高到低排序varsorted=detections.OrderByDescending(d=>d.Confidence).ToList();while(sorted.Count>0){varcurrent=sorted[0];result.Add(current);sorted.RemoveAt(0);// 删掉和当前框重叠度高的for(inti=sorted.Count-1;i>=0;i--){variou=CalculateIoU(current.BoundingBox,sorted[i].BoundingBox);if(iou>_iouThreshold){sorted.RemoveAt(i);}}}returnresult;}privatefloatCalculateIoU(RectangleFa,RectangleFb){varintersection=RectangleF.Intersect(a,b);if(intersection.IsEmpty)return0;varinterArea=intersection.Width*intersection.Height;varunionArea=a.Width*a.Height+b.Width*b.Height-interArea;returninterArea/unionArea;}publicvoidDispose(){_session?.Dispose();}}好了,整个检测器就写完了。用的时候就这么几行代码:
usingvardetector=newYoloV8Detector("yolov8n.onnx");usingvarimage=Image.Load("test.jpg");varresults=detector.Detect(image);foreach(varresinresults){Console.WriteLine($"{res.ClassName}{res.Confidence:F2}{res.BoundingBox}");}我在i7-12700H上测过,单张640x640的图片,平均推理时间32ms。同样的模型,同样的CPU,用Python的ultralytics库跑,平均78ms。
C#快了一倍还多。
说到坑,我这一周踩的坑能写一本书。
第一个就是System.Drawing.Common的坑。我之前用它写预处理,结果程序跑一晚上,内存就涨到12G,然后直接崩溃。客户半夜两点给我打电话,说产线停了,我爬起来远程看,内存泄漏漏的一塌糊涂。
后来查了才知道,微软在.NET 6之后就把System.Drawing.Common标记为Windows专用了,而且它底层依赖GDI+,在服务器环境下就是会内存泄漏,无解。
换成SixLabors.ImageSharp之后,程序连续跑了七天七夜,内存稳定在200M左右,一点问题都没有。
第二个坑是多线程并发推理。很多人以为InferenceSession是线程安全的,可以在多个线程同时调用Run方法。
根本不是。
我之前做了个多线程的版本,四个线程同时跑推理,结果程序运行个十几分钟就会随机崩溃,没有任何错误信息,直接退出。我抓了三天的dump,最后才发现是ONNX Runtime的InferenceSession不是线程安全的,多线程同时调用会导致内部状态错乱。
解决方案就是用对象池,创建多个InferenceSession实例,每个线程用一个。
publicclassYoloDetectorPool{privatereadonlyConcurrentQueue<YoloV8Detector>_pool;privatereadonlystring_modelPath;privatereadonlyint_maxSize;publicYoloDetectorPool(stringmodelPath,intmaxSize){_modelPath=modelPath;_maxSize=maxSize;_pool=newConcurrentQueue<YoloV8Detector>();// 预创建几个实例for(inti=0;i<maxSize;i++){_pool.Enqueue(newYoloV8Detector(modelPath));}}publicYoloV8DetectorGet(){if(_pool.TryDequeue(outvardetector)){returndetector;}returnnewYoloV8Detector(_modelPath);}publicvoidReturn(YoloV8Detectordetector){if(_pool.Count<_maxSize){_pool.Enqueue(detector);}else{detector.Dispose();}}}用的时候记得用完要还回去:
vardetector=pool.Get();try{varresults=detector.Detect(image);}finally{pool.Return(detector);}第三个坑是INT8量化。很多人说量化之后精度会掉很多,其实根本不会。我用自己的数据集测过,INT8量化之后的模型,精度和FP32几乎没有区别,但是速度快了2.5倍,体积还小了一半。
导出的时候加个int8=True就行:
model.export(format="onnx",opset=17,dynamic=True,simplify=True,int8=True)量化之后的yolov8n.onnx只有3MB,推理一张图片只要14ms,也就是每秒70多帧,完全满足实时检测的需求。
如果你有NVIDIA显卡,那就更爽了。把NuGet包换成Microsoft.ML.OnnxRuntime.Gpu,然后在SessionOptions里加一行:
sessionOptions.AppendExecutionProvider_CUDA(0);YOLOv8n在RTX3060上可以跑到120帧以上,比Python的TensorRT部署慢不了多少。
我现在所有的工业项目,只要涉及到目标检测,全部都是用这个方案。
再也不用跟客户解释为什么要装Python,再也不用跟杀毒软件斗智斗勇,再也不用处理各种版本冲突。
一个exe,一个onnx模型,两个文件,拷到任何一台Windows电脑上,双击就能跑。
上次那个天津的项目,我用C#重写了之后,整个程序打包完才20MB,拷到客户服务器上,一秒钟启动,运行了一个月没出过任何问题。
客户的项目经理都惊呆了,说之前别的公司做的类似项目,光部署就花了一周,还天天出问题。