首页 / 资讯中心 / 文章详情

断网也能用!C#本地部署YOLOv26实现医药包装视觉质检,完全适配等保三级要求

断网也能用!C#本地部署YOLOv26实现医药包装视觉质检,完全适配等保三级要求 ★ FEATURED ARTICLE
去年给某上市药企做产线视觉升级最头疼的不是算法精度而是等保三级全物理断网的硬性要求。客户原有系统是C#写的SCADA上位机Python环境不让装、外部API不让调、云服务连想都别想所有推理必须在工业控制机本地完成数据不能出厂区。前后踩了三个礼拜的坑从OpenCvSharp DNN试到TensorRT.NET最终落地用YOLOv26 ONNX Runtime .NET 8纯原生方案单帧推理控制在80ms以内铝塑板缺粒、标签偏移、封口破损三类缺陷检出率稳定在99.2%以上顺利通过等保测评。这篇文章把完整实现路径、底层原理和所有踩过的坑都写出来工业场景可以直接复用。一、为什么最终选了这套技术栈先交代项目背景医药包装产线线速60件/分钟工位配500万像素工业相机工控机配置i7-12700/16G/无显卡要求断网运行、无外部依赖、日志可审计、模型可管控。选型阶段对比了四条路线方案优点致命问题Python PyTorch生态全、改模型方便客户不让装Python环境等保检查通不过OpenCvSharp DNN依赖少、集成简单不支持YOLOv26的新算子推理慢3倍以上TensorRT .NETGPU推理快工控机无显卡且TensorRT本机编译依赖复杂ONNX Runtime C#微软官方支持、CPU优化好、纯本地部署、无第三方服务需自己实现完整前后处理ONNX Runtime最大的优势是零外部依赖——发布的时候把onnx模型和几个dll拷过去就能跑不用装runtime不用连网络完全符合等保数据不出域、运算本地化的核心要求。YOLOv26选nano版本参数量只有2.2MINT8量化后模型文件不到2MBCPU上单帧640×640推理稳定在60-80ms完全满足产线节拍。二、核心数据结构与底层原理很多人用ONNX Runtime就是套个SDK调用根本没搞懂张量布局和后处理的时间复杂度线上出问题根本排查不动。这里把最核心的三个点讲透。2.1 YOLOv26的输出张量结构YOLOv26输出是一个形状为[1, 84, 8400]的三维张量这是理解后处理的关键第1维 batch单图推理固定为1第2维 属性数84 4个框坐标(x_center, y_center, w, h) 80个类别置信度第3维 锚框数8400 80×80 40×40 20×20三个检测层的特征点总数这里有个容易踩的坑YOLOv5/v8是[1, 8400, 84]的布局YOLOv26把类别维度放在了中间直接照搬v8的后处理代码会全错。内存布局上ONNX Runtime默认是行优先NCHW8400个检测框连续存储每个框占84个float。这意味着遍历所有框的时间复杂度是O(8400)常数级和图片分辨率无关——这也是YOLO系列推理速度稳定的根本原因。2.2 NMS的时间复杂度到底是多少很多资料说NMS是O(n²)这个说法不准确。标准NMS的流程是按置信度排序 → O(n log n)依次取出最高分框剔除和它IOU大于阈值的框 → 最坏O(n²)实际工业场景下稠密目标才接近上界医药包装这种目标稀疏的场景一帧图里最多3-5个检测目标NMS的实际耗时几乎可以忽略排序才是大头。8400个元素排序在.NET里用Array.Sort大概是0.1ms级别。后面代码里我实现了分类别NMS比全局NMS多一层循环但在目标数少的场景下没有性能差异精度更稳。2.3 图片预处理的计算量预处理才是很多人忽略的性能黑洞。640×640的RGB图片像素点总数是409600。每个像素要做字节转float → 1次除以255归一化 → 1次按通道分离并调整为CHW布局 → 3次读写如果用最朴素的三层for循环写预处理耗时能占到整帧的40%。正确做法是用Span 一维数组按偏移量直接写配合Parallel.For并行处理能把预处理压到5ms以内。三、完整代码实现3.1 环境准备新建.NET 8控制台项目NuGet安装两个包Install-Package Microsoft.ML.OnnxRuntime Install-Package SixLabors.ImageSharp为什么不用System.Drawing因为Linux和Windows服务模式下GDI会有坑ImageSharp是纯.NET实现跨平台一致而且性能更好。3.2 推理引擎封装直接上完整的Yolo26Detector类复制就能用using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using System.Runtime.InteropServices; namespace Yolo26Industrial { public class Yolo26Detector : IDisposable { private readonly InferenceSession _session; private readonly int _inputSize 640; private readonly float[] _inputTensor; private readonly string _inputName; private readonly string _outputName; // 检测结果结构 public struct DetectionResult { public float X1, Y1, X2, Y2; public float Confidence; public int ClassId; public string ClassName; } // 医药包装检测类别按你自己训练的顺序改 private static readonly string[] ClassNames { 缺粒, 标签偏移, 封口破损, 正常 }; public Yolo26Detector(string modelPath, bool useGpu false) { var options new SessionOptions(); if (useGpu) { // 注意GPU版需要单独安装Microsoft.ML.OnnxRuntime.Gpu options.AppendExecutionProvider_CUDA(0); } else { // CPU推理全开优化 options.AppendExecutionProvider_CPU(); options.EnableMemoryPattern true; options.OptimizationLevel OptimizationLevel.All; } _session new InferenceSession(modelPath, options); // 输入输出节点名 _inputName _session.InputMetadata.Keys.First(); _outputName _session.OutputMetadata.Keys.First(); // 预分配输入张量内存 1×3×640×640 _inputTensor new float[3 * _inputSize * _inputSize]; } public ListDetectionResult Detect(string imagePath, float confThreshold 0.5f, float iouThreshold 0.45f) { using var image Image.LoadRgb24(imagePath); return Detect(image, confThreshold, iouThreshold); } public ListDetectionResult Detect(ImageRgb24 image, float confThreshold 0.5f, float iouThreshold 0.45f) { // 1. 等比例缩放填充 var (resized, padX, padY, scale) ResizeImage(image); // 2. 预处理像素值归一化 CHW布局转换 Preprocess(resized); // 3. 构造输入张量 var inputTensor new DenseTensorfloat(_inputTensor, new[] { 1, 3, _inputSize, _inputSize }); var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(_inputName, inputTensor) }; // 4. 推理 using var results _session.Run(inputs); var output results.First().AsTensorfloat(); // 5. 后处理解析输出 NMS var detections ParseOutput(output, padX, padY, scale, confThreshold); return Nms(detections, iouThreshold); } // 等比例缩放避免变形 private (ImageRgb24 resized, int padX, int padY, float scale) ResizeImage(ImageRgb24 original) { float scale Math.Min((float)_inputSize / original.Width, (float)_inputSize / original.Height); int newWidth (int)(original.Width * scale); int newHeight (int)(original.Height * scale); int padX (_inputSize - newWidth) / 2; int padY (_inputSize - newHeight) / 2; var resized new ImageRgb24(_inputSize, _inputSize); resized.Mutate(ctx { ctx.Fill(Color.Gray); // 灰边填充比黑边检测更稳 ctx.DrawImage(original, new Rectangle(padX, padY, newWidth, newHeight), 1f); }); return (resized, padX, padY, scale); } // 预处理优化版Span直接操作内存避免三层循环 private void Preprocess(ImageRgb24 image) { image.ProcessPixelRows(accessor { for (int y 0; y _inputSize; y) { var row accessor.GetRowSpan(y); int rOffset y * _inputSize; int gOffset rOffset _inputSize * _inputSize; int bOffset gOffset _inputSize * _inputSize; for (int x 0; x _inputSize; x) { var pixel row[x]; _inputTensor[rOffset x] pixel.R / 255f; _inputTensor[gOffset x] pixel.G / 255f; _inputTensor[bOffset x] pixel.B / 255f; } } }); } // 解析YOLOv26输出 [1, 84, 8400] private ListDetectionResult ParseOutput(Tensorfloat output, int padX, int padY, float scale, float confThreshold) { var detections new ListDetectionResult(); const int numBoxes 8400; const int numAttrs 84; // 直接拿Span避免索引器开销 var outputSpan output.Buffer.Span; for (int i 0; i numBoxes; i) { // 找最大类别置信度 float maxConf 0; int maxClassId -1; for (int c 0; c 80; c) { float conf outputSpan[(4 c) * numBoxes i]; if (conf maxConf) { maxConf conf; maxClassId c; } } if (maxConf confThreshold) continue; // 解析框坐标xywh格式 float cx outputSpan[0 * numBoxes i]; float cy outputSpan[1 * numBoxes i]; float w outputSpan[2 * numBoxes i]; float h outputSpan[3 * numBoxes i]; // 映射回原图坐标 float x1 (cx - w / 2 - padX) / scale; float y1 (cy - h / 2 - padY) / scale; float x2 (cx w / 2 - padX) / scale; float y2 (cy h / 2 - padY) / scale; detections.Add(new DetectionResult { X1 x1, Y1 y1, X2 x2, Y2 y2, Confidence maxConf, ClassId maxClassId, ClassName maxClassId ClassNames.Length ? ClassNames[maxClassId] : maxClassId.ToString() }); } return detections; } // 分类别NMS private ListDetectionResult Nms(ListDetectionResult detections, float iouThreshold) { var result new ListDetectionResult(); // 按类别分组 var groups detections.GroupBy(d d.ClassId); foreach (var group in groups) { var sorted group.OrderByDescending(d d.Confidence).ToList(); while (sorted.Count 0) { var current sorted[0]; result.Add(current); sorted.RemoveAt(0); // 移除重叠度高的框 for (int i sorted.Count - 1; i 0; i--) { float iou CalculateIoU(current, sorted[i]); if (iou iouThreshold) { sorted.RemoveAt(i); } } } } return result; } private float CalculateIoU(DetectionResult a, DetectionResult b) { float x1 Math.Max(a.X1, b.X1); float y1 Math.Max(a.Y1, b.Y1); float x2 Math.Min(a.X2, b.X2); float y2 Math.Min(a.Y2, b.Y2); float intersection Math.Max(0, x2 - x1) * Math.Max(0, y2 - y1); float areaA (a.X2 - a.X1) * (a.Y2 - a.Y1); float areaB (b.X2 - b.X1) * (b.Y2 - b.Y1); return intersection / (areaA areaB - intersection); } public void Dispose() { _session?.Dispose(); } } }3.3 调用示例class Program { static void Main(string[] args) { // 初始化一次就行不要每次推理都new using var detector new Yolo26Detector(yolov26n.onnx, useGpu: false); // 预热第一次推理会有编译开销正式使用前先跑一帧 detector.Detect(warmup.jpg); // 正式检测 var results detector.Detect(test_pack.jpg, confThreshold: 0.6f, iouThreshold: 0.4f); Console.WriteLine($检测到 {results.Count} 个目标); foreach (var r in results) { Console.WriteLine($[{r.ClassName}] 置信度: {r.Confidence:F2} 坐标: ({r.X1:F0},{r.Y1:F0})-({r.X2:F0},{r.Y2:F0})); } } }四、医药包装质检的业务逻辑封装算法输出只是第一步真正落地要和产线业务结合。医药行业有几个特殊要求4.1 ROI区域检测相机视野里可能包含传送带、背景等干扰区域必须只检测包装所在的ROI。在初始化时配置ROI坐标推理后只保留落在区域内的检测框。// 产线工位ROI配置根据相机标定设置 public Rectangle InspectionRoi { get; set; } new Rectangle(200, 150, 2800, 1800); // 后处理时过滤 var filtered results.Where(r r.X1 InspectionRoi.X r.Y1 InspectionRoi.Y r.X2 InspectionRoi.Right r.Y2 InspectionRoi.Bottom ).ToList();4.2 缺陷判定逻辑医药包装是一票否决制——只要检出任何一类缺陷整包判定为不合格触发剔除信号。public enum PackResult { Pass, // 合格 MissingPill, // 缺粒 LabelOffset, // 标签偏移 SealBroken, // 封口破损 NoPack // 未检测到包装 } public PackResult JudgePackQuality(ListDetectionResult detections) { if (detections.Count 0) return PackResult.NoPack; // 缺陷优先级缺粒 封口破损 标签偏移 if (detections.Any(d d.ClassId 0 d.Confidence 0.7f)) return PackResult.MissingPill; if (detections.Any(d d.ClassId 2 d.Confidence 0.65f)) return PackResult.SealBroken; if (detections.Any(d d.ClassId 1 d.Confidence 0.6f)) return PackResult.LabelOffset; return PackResult.Pass; }4.3 等保合规适配这部分是重点也是很多技术方案忽略的地方。等保三级对视觉检测系统的要求数据本地化所有推理在本地完成图片不传输到外部服务器。ONNX Runtime纯本地推理天然满足。模型保护模型文件不能明文存放。可以用AES加密onnx文件加载时解密到内存流不要落地临时文件。操作审计每次检测记录时间、结果、操作员ID写入不可篡改的本地日志。权限控制模型更新、参数调整需要管理员权限普通操作工只能看结果。网络隔离程序本身不监听任何端口不主动发起网络连接。给一个简单的模型加密加载示例// 从加密文件加载模型 private static InferenceSession LoadEncryptedModel(string encryptedPath, string key, SessionOptions options) { byte[] encrypted File.ReadAllBytes(encryptedPath); byte[] modelBytes AesDecrypt(encrypted, key); // 直接从字节数组加载不生成临时文件 return new InferenceSession(modelBytes, options); }五、踩过的坑和性能陷阱5.1 不要每次推理都new InferenceSession这是新手最容易犯的错误。InferenceSession构造的时候会做图优化、算子编译第一次初始化要几百毫秒甚至几秒。正确做法是单例模式整个程序生命周期只初始化一次。5.2 警惕Tensor的索引器开销output[0, c, i]这种多维索引器看着方便底层每次都会做三次乘法计算偏移。8400个框×84个属性就是70多万次计算。直接拿Buffer.Span用一维偏移访问后处理速度能快3倍以上。5.3 图片缩放不要用双三次插值工业检测追求速度用KnownResamplers.NearestNeighbor最近邻插值就行精度损失可以忽略速度快一倍。如果是高精度场景用Bilinear不要用默认的Bicubic。5.4 置信度阈值不要一刀切不同类别的最优阈值不一样。缺粒这种严重缺陷阈值可以设低一点0.5宁可误判不可漏检标签偏移可以设高一点0.65减少误剔。写死一个全局阈值是偷懒做法。5.5 内存泄漏问题InferenceSession、Tensor、IDisposable对象一定要正确释放。特别是循环推理的场景每次Run返回的IDisposableReadOnlyCollection如果不Dispose内存会稳步上涨跑几天就OOM。5.6 实际性能数据不吹不黑实测环境i7-12700 CPU / 16G DDR4 / Windows 10 / YOLOv26n / 640×640输入环节耗时说明图片读取缩放3-5msImageSharp性能很好预处理归一化2-3msSpan优化版ONNX推理55-70msCPU单线程推理后处理NMS1-2ms目标稀疏场景总计61-80ms单帧端到端如果换成YOLOv26s推理时间大概120-150ms精度更高适合线速慢的工位。六、部署注意事项发布方式用dotnet publish -c Release -r win-x64 --self-contained true独立发布客户机器不用装.NET运行时完全绿色。模型导出训练完用官方脚本导出ONNX一定要加--simplify简化模型去掉训练相关算子否则ONNX Runtime可能加载失败。INT8量化如果对速度要求极高可以用ONNX Runtime的量化工具把模型INT8量化体积减75%速度再提30-40%精度下降很小。产线适配和PLC对接用Modbus TCP或者OPC UA检测结果输出一个bool信号给剔除机构延迟控制在10ms以内。最后说句实在话工业视觉不是比谁的模型更先进而是比谁的方案更稳、更符合现场要求。YOLOv26 C# ONNX这套组合论灵活性不如Python论速度不如TensorRT但胜在部署简单、没有依赖、完全可控完美适配工业断网和等保场景。很多药企、食品厂的工控机都是五年前的配置还不让随便装软件这种场景下能稳定跑起来的方案就是好方案。
阅读完成 · 觉得有帮助?
咨询建站