简介:这是一套基于C#与ONNX Runtime的YOLOv8烟雾检测完整实现源码,面向需要在Windows桌面应用中集成实时目标检测的.NET开发者。项目以Visual Studio解决方案形式组织,包含C#核心逻辑(模型加载、图像预处理、推理与后处理)、可直接运行的ONNX模型以及配套依赖库与配置文件,适合用来学习C#调用深度学习模型和YOLOv8部署流程。压缩包共45个文件,以cs源码、dll依赖、cache缓存、config配置及resx/resources资源文件为主,还包括exe可执行程序与sln解决方案,整体大小约99.98MB。已有784人学习下载,可作为安全监控、工业巡检等场景中实现烟雾检测功能的基础框架,帮助开发者快速理解端到端推理链路,节省从零搭建环境与模型调试的时间。 做C#上位机的朋友,迟早会碰到一个需求:把深度学习模型集成到自己的工业软件里做实时检测。烟雾检测是里面比较典型的一类场景,工厂车间、仓库、机房、野外监控,都有早期火灾预警的需求。你不可能让现场还装一套Python环境,更不可能让操作人员去跑命令行。最务实的路线就是C#直接调用ONNX模型,而YOLOv8目前是目标检测里综合效果和部署便利性最平衡的选择。这篇文章把我实际跑通的这套C# + ONNX Runtime + YOLOv8烟雾检测方案完整拆开,从模型导出到C#推理,从后处理优化到现场踩坑,一次说清楚,给正在做类似需求的朋友一个可以照着抄的作业。
1. 方案选型解析:为什么是C# + ONNX Runtime + YOLOv8
先说结论:这套组合不是唯一选择,但在Windows桌面端、工业上位机场景里,它是最省心的。
C#这边的优势不用多说,WinForm、WPF做界面快,与PLC、扫码枪、数据库、串口网口通信的生态非常成熟。你做一个烟雾检测模块,最终一定是要和整个工控系统联动的,C#能让你把检测结果直接推到报警界面、写入日志、触发联动设备。换成Python来做,那头程通信和UI开发反而成了麻烦事。
ONNX Runtime是微软主推的推理引擎。它的核心价值在于:模型一旦导出为ONNX格式,就脱离了PyTorch、TensorFlow这些训练框架,可以在不安装任何深度学习框架的机器上运行。这正好契合工控机的部署环境——现场机器一般配置不高,系统环境五花八门,装了杀毒软件、工控驱动,你敢随便往上装Python和PyTorch吗?不敢。但ONNX Runtime只是一个运行库,几个DLL拷过去就能用。
YOLOv8选它是因为部署生态实在太完善了。Ultralytics官方直接支持一键导出ONNX,不需要多余的中间步骤。而且YOLOv8的检测精度和速度平衡得不错,nano版本在CPU上也能跑到几十毫秒一帧,对烟雾这种目标来说完全够用。
还有个实际原因:烟雾检测的客户现场,往往没有NVIDIA显卡。工控机上大概率是集成显卡甚至没有显卡,纯CPU推理是常态。ONNX Runtime在CPU上的优化做得很好,配合正确设置线程数,小模型推理帧率完全可以接受。
这套方案能解决的问题也很明确:在不依赖训练框架、不依赖GPU、不重写算法逻辑的前提下,把YOLOv8的检测能力嵌入到C#上位机里,实现本地化、离线化、实时化的烟雾识别。
2. YOLOv8模型导出:PyTorch到ONNX的关键细节
2.1 导出前的模型与数据准备
模型导出的前提是你手里已经有一个训练好的烟雾检测权重,比如best.pt(PyTorch格式),或者直接用官方预训练COCO权重里的person、fire等类别做二次开发。如果是自己训练的烟雾数据集,我建议训练时重点关注几个方向:烟雾在不同光照下的形态、透明烟与浓烟的区别、与蒸汽/雾气/灯光的混淆情况。数据集质量直接决定后续检测效果,这一点在模型导出前就要心里有数。
导出前还要确认模型输入尺寸。YOLOv8默认是640x640,如果你的场景中烟雾目标普遍很小,可以考虑把输入提升到960甚至1280,但代价是推理时间成倍增长。我实测下来,640x640在大多数烟雾场景里已经够用,尤其是配合后续的帧间判别逻辑,比盲目提高分辨率划算得多。
2.2 导出命令与输出结构解读
导出本身很简单,Ultralytics库一条命令搞定:
yolo export model=best.pt format=onnx opset=12 dynamic=False这里有几个参数值得单独说明。opset=12是兼容性最好的算子版本,ONNX Runtime全版本都支持;dynamic=False固定输入尺寸,导出后模型推理速度更快、显存和内存占用更稳定。如果你需要适配不同分辨率输入,可以开dynamic=True,但C#端处理起来会多一层麻烦,我建议没特殊需求不要开。
导出完成后,用Netron打开best.onnx,你会看到网络结构。对C#开发来说,最关键的只有两头:输入节点的名称(通常是images)和输出节点的维度。YOLOv8的输出是一个三维张量,形状类似[1, 84, 8400]。其中8400是模型在不同尺度特征图上生成的候选框总数,84 = 4 + 80,对应4个坐标值和80个COCO类别概率。如果是自训练的烟雾单类别模型,第二个维度就是5(4个坐标 + 1个类别概率)。
这个维度信息在C#端写代码时要用到,建议导出后先记下来,不同模型输出维度可能不一样,直接写死在代码里最省事。
2.3 C#端必不可少的输入预处理
模型导出后,C#端的输入预处理往往会劝退新手。YOLOv8要求输入是归一化后的RGB图像,顺序是[1, 3, 640, 640],而OpenCV读出来的图像是BGR、HWC格式,尺寸也不一定匹配。所以推理前必须做三件事:
- 将图像Resize到640x640(建议用LetterBox等比例缩放填充,而不是直接拉伸,否则目标形变会影响检测效果)
- 把BGR通道顺序换成RGB
- 像素值从0~255归一化到0.0~1.0
这三个步骤看着简单,但顺序和细节错了,检测结果就会稀烂。我见过不少朋友把RGB和BGR搞反,结果模型什么都检测不出来,还以为是模型没训练好。实际上烟雾检测在这种情况下尤其明显:烟雾是半透明的,颜色通道顺序一错,特征全乱了。
3. C#端推理代码实战:从加载模型到输出结果
3.1 项目配置与NuGet依赖
先在Visual Studio里创建一个.NET 6/8的类库或者WinForm项目,然后通过NuGet安装两个包:
Microsoft.ML.OnnxRuntime OpenCvSharp4Microsoft.ML.OnnxRuntime是推理引擎本体,OpenCvSharp4用来做图像读取和预处理。OpenCvSharp4在Windows下需要额外把OpenCvSharp4.runtime.win和OpenCvSharp4.Extensions(如果用到WinForm显示)也装上,否则运行时会报找不到OpenCvSharpNative.dll。这个坑很常见,装上就好了。
如果你要跑GPU版本,要装的是Microsoft.ML.OnnxRuntime.Gpu,不是同一个包。CPU版和GPU版的API基本一致,只是初始化时SessionOptions略有不同。
3.2 推理引擎的封装与初始化
我建议把整个推理逻辑封装成一个类,一方面方便后续复用,另一方面可以统一管理资源释放。类里核心的成员就两个:InferenceSession和SessionOptions。
using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; public class SmokeDetector : IDisposable { private InferenceSession _session; private string _inputName; public SmokeDetector(string modelPath) { var options = new SessionOptions(); options.LogSeverityLevel = OrtLoggingLevel.ORT_LOGGING_LEVEL_WARNING; options.IntraOpNumThreads = Environment.ProcessorCount; _session = new InferenceSession(modelPath, options); _inputName = _session.InputMetadata.Keys.First(); } public void Dispose() { _session?.Dispose(); } }注意IntraOpNumThreads这个参数。默认情况下ONNX Runtime会根据系统自动选线程数,但在工控机上它往往选得不够合理。我实测过,把这个值设为Environment.ProcessorCount,推理速度能提升20%到30%。AMD的CPU上尤其明显。
InferenceSession是线程安全的,可以多个线程同时调用Run方法,这在后面做多路视频流检测时特别有用。但是要注意,Run方法内部会做内存分配,频繁调用时会触发GC。这个问题后面单独讲。
3.3 完整推理流程与代码实现
推理流程拆成四步:读图预处理 -> 转Tensor -> Run推理 -> 解析输出。
预处理部分借助OpenCvSharp实现:
public float[] Preprocess(string imagePath, int inputSize = 640) { using var mat = Cv2.ImRead(imagePath, ImReadModes.Color); using var resized = new Mat(); // LetterBox等比例缩放 int newW, newH; float ratio = Math.Min((float)inputSize / mat.Width, (float)inputSize / mat.Height); newW = (int)(mat.Width * ratio); newH = (int)(mat.Height * ratio); Cv2.Resize(mat, resized, new OpenCvSharp.Size(newW, newH)); // 填充到640x640 using var canvas = new Mat(inputSize, inputSize, MatType.CV_8UC3, Scalar.All(114)); resized.CopyTo(canvas[new OpenCvSharp.Rect(0, 0, newW, newH)]); // BGR -> RGB + HWC -> CHW + 归一化 float[] inputData = new float[3 * inputSize * inputSize]; for (int y = 0; y < inputSize; y++) { for (int x = 0; x < inputSize; x++) { Vec3b pixel = canvas.At<Vec3b>(y, x); inputData[0 * inputSize * inputSize + y * inputSize + x] = pixel[2] / 255f; inputData[1 * inputSize * inputSize + y * inputSize + x] = pixel[1] / 255f; inputData[2 * inputSize * inputSize + y * inputSize + x] = pixel[0] / 255f; } } return inputData; }这段代码看起来长,但每一步都有明确目的。填充成640x640时,填充值用114是YOLO系列训练时的惯例,你换成其他值影响不大,但保持训练分布总归是最稳妥的。
推理部分更直接:
public List<Detection> Infer(float[] inputData) { var inputTensor = new DenseTensor<float>(inputData, new[] { 1, 3, 640, 640 }); var inputs = new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor(_inputName, inputTensor) }; using var results = _session.Run(inputs); var output = results.First().AsTensor<float>(); // 解析输出... }这里有个性能关键点:每次Run都会重新分配输出缓冲区。如果实时性要求高,可以考虑复用输入输出缓冲区,用Run的重载方法传入预分配的内存。不过这个优化带来的复杂度比较高,建议先把基础流程跑通,性能不够再优化。
3.4 输出解析:坐标还原与置信度筛选
YOLOv8的输出解析是这份代码里最容易出错的部分。输出张量的形状是[1, 84, 8400],你需要转成[8400, 84]再处理。每行84个元素里,前4个是目标框的中心点坐标和宽高(cx, cy, w, h),后面是各类别的置信度。
烟雾检测如果是单类别模型,那就简单了:只看最后一个置信度,大于阈值就认为检测到烟雾。如果是多类别模型,要遍历所有类别找到最大置信度,再看类别索引。我封装了一个Detection类来存最终结果:
public class Detection { public float X1, Y1, X2, Y2; // 原始图像坐标 public float Confidence; public int ClassId; }从模型输出还原到原始图像坐标,需要把LetterBox的缩放和偏移还原回去。这里千万别忘:模型输出的坐标是基于640x640输入图像的,不是原图。要乘回缩放比例,再减去填充区域的偏移量。
坐标解析完成后,还需要做NMS(非极大值抑制)。ONNX Runtime本身不内置NMS算子,要么自己写一个简化版,要么在导出模型时加nms=True参数让Ultralytics帮你集成。我建议自己写,原因后面讲。
4. 核心环节实现:NMS、绘制与触发式检测
4.1 手写NMS:算法原理与C#实现
NMS做的事情很直观:同一片区域可能有好几个重叠的候选框,都指向同一个烟雾目标,只保留置信度最高的那个,去掉重复的框。实现步骤就是按置信度降序排序,逐个拿当前最高置信度的框,把所有IoU超过阈值的框都删掉,重复直到遍历完。IoU就是两个框的交集面积除以并集面积。
private static List<Detection> Nms(List<Detection> detections, float iouThreshold) { var result = new List<Detection>(); var sorted = detections .OrderByDescending(d => d.Confidence) .ToList(); while (sorted.Count > 0) { var best = sorted[0]; result.Add(best); sorted.RemoveAt(0); sorted.RemoveAll(d => CalcIou(best, d) > iouThreshold); } return result; }NMS阈值一般取0.45到0.5。烟雾目标边界模糊,框和框之间重叠面积比较大,阈值太严会把相邻的重复框全删掉,容易漏检;太松又会保留一堆多余的框。我试下来0.45对于烟雾检测来说比较均衡。
4.2 把检测结果可视化并叠加到图像上
显示检测结果时,建议直接用OpenCvSharp在原图上画框和标签,而不是自己用GDI+画。OpenCvSharp画出来的框在存储和视频流处理时兼容性更好,而且代码简短很多:
Cv2.Rectangle(mat, new OpenCvSharp.Rect((int)x1, (int)y1, (int)(x2 - x1), (int)(y2 - y1)), Scalar.Red, 2); Cv2.PutText(mat, $"Smoke {conf:P0}", new OpenCvSharp.Point(x1, y1 - 5), HersheyFonts.HersheySimplex, 0.6, Scalar.Red, 1);这里注意坐标类型要转成int,OpenCvSharp的绘图API不直接接受float坐标。另外标签文字如果被截断,可以调整一下绘制起点,保证文字在图像范围内。
4.3 触发式检测:结合扫码枪与外部信号的架构思路
烟雾检测和常规的视频流检测不太一样,很多场景下不需要7x24小时连续跑推理。比如某个工位在特定工序才产生烟雾,或者检测窗口只在扫码枪触发后才打开。这种触发式检测的设计,可以让工控机CPU占用率低很多。
具体做法是把检测流程封装成异步方法,用扫码枪触发事件作为入口。扫码枪一般是串口或HID设备,数据到达时会触发事件。事件响应函数里做两件事:一是不做任何大计算量操作,立即把任务丢到后台线程池;二是置一个isBusy标志位,防止上一次推理没结束又来新任务导致排队堆积。
private async Task OnScanTriggered(object sender, string barcode) { if (_isBusy) return; _isBusy = true; try { await Task.Run(() => { var detections = DetectFromCamera(); if (detections.Count > 0) { RaiseAlarm(detections); } }); } finally { _isBusy = false; } }这种架构的另一个好处是,后期如果要集成PLC信号触发、定时轮询、告警联动,都只需要在事件入口处做扩展,不需要改动推理核心代码。
5. 工程化经验:性能优化、常见坑与排查思路
5.1 推理卡顿与UI假死的根治办法
这是上位机开发里最常见的问题:把推理放到UI线程执行,界面直接假死。尤其是OpenCvSharp的ImRead和Resize在图片较大时本来就耗时,再加上模型推理,UI线程根本扛不住。
规范做法是:所有耗时操作都放到后台线程,用async/await或System.Threading.Tasks.Task.Run包裹。UI线程只负责显示结果。如果你需要实时预览摄像头画面,可以用一个独立的摄像头采集线程专门抓帧,另一个推理线程从缓冲区取帧做检测,两个线程之间用队列或BlockingCollection解耦。
另外提醒一点:不要在UI线程里反复创建Mat对象,也别在对性能敏感的循环里用Bitmap.Save写文件。实测这会让内存碎片化,GC频繁触发,表现就是过一会儿卡一下。
5.2 模型加载失败与DLL缺失排查
ONNX Runtime部署最典型的坑就是DLL问题。程序在本机跑得好好的,拷到现场工控机上就报System.DllNotFoundException或者Failed to load onnxruntime.dll。原因通常是本机装了Visual C++ Redistributable,而现场机器没有。
解决办法有两个:一是打包时把Microsoft.ML.OnnxRuntime目录下的原生DLL都带上,专门整理成一个runtimes文件夹,随程序一起发布;二是在目标机器上安装对应版本的VC++运行库。另外,如果用了GPU版本,还需要对方机器装CUDA和cuDNN,版本必须和ONNX Runtime要求的严格对应,这点最容易踩坑,建议CPU版本打天下,除非现场确认有独立显卡。
还有一点:ONNX Runtime 1.16以上版本对CPU指令集有要求,老旧的工控机CPU可能不支持AVX指令集,会直接崩溃。如果你要部署到老旧设备,建议用旧版ONNX Runtime,或者用Dnnl(oneDNN)执行提供程序测试兼容性。
5.3 检测准确率不佳的常用排查方向
模型检测不到烟雾,或者误报率高,先别急着怀疑模型。按这个顺序排查:预处理是否正确(RGB/BGR是否转换、归一化是否正确)、输入尺寸是否和模型匹配(640还是其他尺寸)、置信度阈值是否合理(烟雾建议0.25~0.35,不要用默认的0.5)、NMS阈值是否合理(烟雾建议0.4~0.5)、检查测试图片本身是否清晰。
烟雾和其他检测目标有个明显区别:烟雾是半透明且无固定形态的,边界很模糊,标准IoU计算本身就容易出错。所以有时降低置信度阈值到0.2,反而能抓到很多肉眼可见的烟雾,虽然也会带来一些误报,但配合帧间连续判定(连续3帧都检测到才报警)会可靠很多。
5.4 我个人实测的性能数据参考
最后给一组我实际跑过的数据供参考。机器是i5-9500(6核6线程,无独立显卡),CPU推理YOLOv8n烟雾模型,输入640x640,单帧推理耗时大约35~45毫秒,折合帧率22~28 FPS。换成YOLOv8s大约70~90毫秒一帧。在烟雾检测这种对实时性要求不是极端的场景里,完全够用了。如果采用触发式检测,实际CPU占用率还可以压到10%以下。
如果要做到实时视频流25FPS以上,有两个方向:一是用YOLOv8n配合IntraOpNumThreads调优,二是封装GPU推理。但GPU方案要考虑现场硬件条件、CUDA版本、显存大小,维护复杂度高不少。我个人的建议是:烟雾检测对帧率需求没那么高,5~10 FPS足够用了,CPU方案简单可靠,部署成本低,客户现场不会因为你用CPU版而卡顿报警,反而会因为你用了GPU版导致驱动冲突而投诉你。
从部署到验收,这套方案我已经在三个项目里跑过了。从最早的Python原型,到后面纯C#上位机集成,最大的体会是:模型训练只是整个项目的一小部分,部署链路里的坑(预处理、后处理、线程模型、DLL依赖、现场环境差异)才是真正花时间的部分。如果你也在做类似的烟雾检测集成,建议先把本文的流程跑通,再根据自己的现场情况调整。最后提醒一句,现场部署前,务必在目标机器上做一次完整的冒烟测试,尤其是DLL依赖和权限问题,提前暴露,别等验收时出事。
本文还有配套的精品资源,点击获取