欢迎光临
我们一直在努力

工业现场不让装Python?威哥纯C# YOLO实时检测实战:零Python依赖,40fps跑满

大家好,我是威哥。上个月天津滨海的李老板把我堵在车间门口,脸色特别难看:“威哥你看,之前那套Python写的YOLO,昨天环境崩了,折腾了半天才修好,生产线停了3小时,损失好几万。客户说了,以后所有系统必须纯C#,连Python的边都不能沾,你能不能帮我们改改?”

我当时拍胸脯说没问题,但心里其实有点打鼓——之前一直用Python的Ultralytics库转模型、训模型,纯C#调用YOLO还真没试过。后来蹲在实验室熬了两个通宵,试了ONNX Runtime C#版,又自己写了预处理和后处理的代码,终于把系统搞定了:零Python依赖,GPU版40fps跑满,CPU版也有10fps,稳定运行30天没出问题。

今天就跟大家唠唠这中间的具体实现和踩的坑,从模型准备到纯C#推理,再到工业现场的优化,全是能直接落地的干货,没有半句虚的。


先理清楚为什么要“零Python依赖”:工业现场的硬规矩

在做方案之前,得先搞清楚工业现场为什么不让装Python——李老板给我列了三个原因,我觉得特别真实:

  • 环境配置太麻烦:Python的版本、库的版本,稍微错一点就报错,现场的运维人员根本不会弄;
  • 稳定性差:Python的GC、多线程,偶尔会出一些莫名其妙的问题,生产线停不起;
  • 客户要求:很多汽车、电子行业的客户,对生产系统的软件有严格的清单,Python不在清单里,连装都不让装。
  • 搞清楚这些,方案就有方向了:模型转换在开发机用Python做(现场不用),推理全用C#,选ONNX Runtime作为推理引擎——通用、稳定、纯C#。


    第一步:模型准备——开发机转好ONNX,现场只需要一个文件

    虽然现场不用Python,但模型转换还是得在开发机用Ultralytics做——毕竟纯C#转YOLO模型太麻烦,没必要 reinvent the wheel。

    1. 开发机转ONNX(就这一步用Python,现场不用)

    在开发机(可以是你自己的电脑)上装Ultralytics,然后用一行命令转ONNX:

    yolo export model=yolov8n.pt format=onnx opset=12

    • opset=12:ONNX Runtime 1.16.0对opset 12支持最好,别用太新的;
    • 转好后会得到一个yolov8n.onnx文件,把这个文件拷到现场的工控机上就行,现场不用装Python。

    2. 踩坑:ONNX的输入输出要搞清楚

    转好ONNX后,别着急写代码,先用Netron打开看看输入输出——我一开始没看,直接写代码,结果输入维度搞反了,检测框全错。

    用Netron打开yolov8n.onnx,你会看到:

    • 输入:images,维度是[1, 3, 640, 640],数据类型是float32,格式是NCHW(Batch, Channel, Height, Width);
    • 输出:output0,维度是[1, 5 + nc, 8400],其中nc是类别数,8400是锚框的数量。

    记住这个输入输出,后面写代码全靠它。


    第二步:C#环境搭建——选ONNX Runtime,纯C#,通用又稳定

    推理引擎我选了ONNX Runtime C#版——原因有三个:

  • 纯C#:NuGet包安装就行,不用装Python,不用装CUDA(如果用CPU版的话);
  • 通用:CPU、GPU(NVIDIA/AMD/Intel)都能跑,工业现场的工控机不管是啥配置都能用;
  • 稳定:微软维护的,更新及时,文档也全。
  • 1. NuGet包安装

    打开Visual Studio,创建一个C# WinForms/WPF/控制台项目,然后在NuGet包管理器里搜索安装:

    • CPU版:Microsoft.ML.OnnxRuntime(版本选1.16.0,稳定);
    • GPU版:Microsoft.ML.OnnxRuntime.Gpu(同样1.16.0,需要现场工控机装CUDA 11.8和cuDNN 8.9.7,但不用Python)。

    2. 踩坑:GPU版的CUDA版本要对应

    一开始我装了ONNX Runtime Gpu 1.16.0,结果报错说找不到CUDA——后来翻了文档才知道,1.16.0对应CUDA 11.8和cuDNN 8.9.7,版本错一点都不行。大家一定要注意,别装错版本。


    第三步:纯C#图像预处理——用Emgu.CV,别用System.Drawing(太慢)

    预处理是YOLO推理的关键,要做三件事:Resize到640×640、归一化、HWC转NCHW。我一开始用System.Drawing,结果Resize一帧要20ms,太慢了,后来换了Emgu.CV,Resize一帧只要2ms,快了10倍。

    1. Emgu.CV的NuGet包安装

    同样在NuGet包管理器里搜索安装:

    • Emgu.CV
    • Emgu.CV.runtime.windows(Windows平台用)

    2. 纯C#预处理代码(核心!)

    我写了个简化版的预处理类,大家可以直接用:

    using Emgu.CV;
    using Emgu.CV.CvEnum;
    using Emgu.CV.Structure;
    using Microsoft.ML.OnnxRuntime.Tensors;

    public class YoloPreprocessor
    {
    private readonly int _inputSize = 640;
    private readonly float[] _mean = { 0.0f, 0.0f, 0.0f };
    private readonly float[] _std = { 255.0f, 255.0f, 255.0f };

    public DenseTensor<float> Preprocess(Mat originalImg)
    {
    // 1. Resize到640×640,用双线性插值
    Mat resizedImg = new Mat();
    CvInvoke.Resize(originalImg, resizedImg, new Size(_inputSize, _inputSize), 0, 0, Inter.Linear);

    // 2. 转成RGB(Emgu.CV默认是BGR)
    Mat rgbImg = new Mat();
    CvInvoke.CvtColor(resizedImg, rgbImg, ColorConversion.Bgr2Rgb);

    // 3. 归一化和HWC转NCHW(纯C#,用Span优化,快!)
    var tensor = new DenseTensor<float>(new[] { 1, 3, _inputSize, _inputSize });
    var data = rgbImg.GetData();
    int height = rgbImg.Rows;
    int width = rgbImg.Cols;

    unsafe
    {
    fixed (byte* pData = data)
    {
    byte* ptr = pData;
    for (int y = 0; y < height; y++)
    {
    for (int x = 0; x < width; x++)
    {
    // HWC -> NCHW:把每个像素的R、G、B分别放到三个通道里
    tensor[0, 0, y, x] = (ptr[0] _mean[0]) / _std[0]; // R
    tensor[0, 1, y, x] = (ptr[1] _mean[1]) / _std[1]; // G
    tensor[0, 2, y, x] = (ptr[2] _mean[2]) / _std[2]; // B
    ptr += 3;
    }
    }
    }
    }

    return tensor;
    }
    }

    3. 踩坑:HWC转NCHW容易搞反

    这是最常见的坑——我一开始把R、G、B的顺序搞反了,或者把Height和Width搞反了,结果检测框全错。大家一定要注意:

    • ONNX的输入是NCHW:Batch(1)、Channel(3,R/G/B)、Height(640)、Width(640);
    • Emgu.CV的Mat是HWC:Height、Width、Channel(3,B/G/R);
    • 转换的时候要先把BGR转成RGB,再把HWC转成NCHW。

    第四步:纯C#推理和后处理——NMS自己写,用Span优化

    推理很简单,用ONNX Runtime的API就行,麻烦的是后处理——要解析输出、做NMS(非极大值抑制)。纯C#的NMS一开始我用List写,慢得要死,一帧要10ms,后来用Span和数组优化,一帧只要1ms,快了10倍。

    1. 纯C#推理代码

    using Microsoft.ML.OnnxRuntime;
    using Microsoft.ML.OnnxRuntime.Tensors;

    public class YoloInference
    {
    private readonly InferenceSession _session;
    private readonly string _inputName;
    private readonly string _outputName;
    private readonly YoloPreprocessor _preprocessor;
    private readonly YoloPostprocessor _postprocessor;

    public YoloInference(string modelPath, bool useGpu = false)
    {
    // 1. 配置Session选项
    var sessionOptions = new SessionOptions();
    if (useGpu)
    {
    // GPU版:用CUDA Execution Provider
    sessionOptions.AppendExecutionProvider_CUDA(0);
    }
    else
    {
    // CPU版:用多线程优化
    sessionOptions.GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL;
    sessionOptions.IntraOpNumThreads = 4;
    sessionOptions.InterOpNumThreads = 4;
    }

    // 2. 加载模型
    _session = new InferenceSession(modelPath, sessionOptions);
    _inputName = _session.InputMetadata.Keys.First();
    _outputName = _session.OutputMetadata.Keys.First();

    // 3. 初始化预处理和后处理
    _preprocessor = new YoloPreprocessor();
    _postprocessor = new YoloPostprocessor();
    }

    public List<YoloDetection> Detect(Mat originalImg)
    {
    // 1. 预处理
    var inputTensor = _preprocessor.Preprocess(originalImg);

    // 2. 构建输入
    var inputs = new List<NamedOnnxValue>
    {
    NamedOnnxValue.CreateFromTensor(_inputName, inputTensor)
    };

    // 3. 推理
    using var results = _session.Run(inputs);

    // 4. 获取输出
    var outputTensor = results.First().AsEnumerable<float>().ToArray();

    // 5. 后处理
    return _postprocessor.Postprocess(outputTensor, originalImg.Width, originalImg.Height);
    }
    }

    2. 纯C#后处理代码(NMS用Span优化,核心!)

    public class YoloDetection
    {
    public Rectangle BoundingBox { get; set; }
    public string ClassName { get; set; }
    public float Confidence { get; set; }
    }

    public class YoloPostprocessor
    {
    private readonly int _inputSize = 640;
    private readonly float _confidenceThreshold = 0.5f;
    private readonly float _iouThreshold = 0.45f;
    private readonly string[] _classNames = { "scratch", "defect" }; // 改成你自己的类别

    public List<YoloDetection> Postprocess(float[] output, int originalWidth, int originalHeight)
    {
    var detections = new List<YoloDetection>();
    int numClasses = _classNames.Length;
    int numAnchors = 8400;

    // 1. 解析输出:output的维度是[1, 5+nc, 8400],展平成了数组
    // 用Span优化,避免数组拷贝
    ReadOnlySpan<float> outputSpan = output.AsSpan();

    // 计算缩放比例:把640×640的检测框映射回原图尺寸
    float scaleX = (float)originalWidth / _inputSize;
    float scaleY = (float)originalHeight / _inputSize;

    // 2. 遍历所有锚框
    for (int i = 0; i < numAnchors; i++)
    {
    // 找到置信度最高的类别
    float maxConfidence = 0;
    int classId = 1;
    for (int j = 0; j < numClasses; j++)
    {
    float confidence = outputSpan[5 + j + i * (5 + numClasses)];
    if (confidence > maxConfidence)
    {
    maxConfidence = confidence;
    classId = j;
    }
    }

    // 过滤低置信度的检测
    if (maxConfidence < _confidenceThreshold)
    continue;

    // 解析检测框:cx, cy, w, h(都是相对于640×640的比例)
    float cx = outputSpan[0 + i * (5 + numClasses)] * _inputSize;
    float cy = outputSpan[1 + i * (5 + numClasses)] * _inputSize;
    float w = outputSpan[2 + i * (5 + numClasses)] * _inputSize;
    float h = outputSpan[3 + i * (5 + numClasses)] * _inputSize;

    // 转成x1, y1, x2, y2
    float x1 = (cx w / 2) * scaleX;
    float y1 = (cy h / 2) * scaleY;
    float x2 = (cx + w / 2) * scaleX;
    float y2 = (cy + h / 2) * scaleY;

    // 边界检查
    x1 = Math.Max(0, x1);
    y1 = Math.Max(0, y1);
    x2 = Math.Min(originalWidth, x2);
    y2 = Math.Min(originalHeight, y2);

    detections.Add(new YoloDetection
    {
    BoundingBox = new Rectangle((int)x1, (int)y1, (int)(x2 x1), (int)(y2 y1)),
    ClassName = _classNames[classId],
    Confidence = maxConfidence
    });
    }

    // 3. 做NMS(纯C#,用Span优化)
    return NonMaxSuppression(detections, _iouThreshold);
    }

    // 轻量级NMS,用Span和数组优化
    private List<YoloDetection> NonMaxSuppression(List<YoloDetection> detections, float iouThreshold)
    {
    if (detections.Count == 0)
    return detections;

    // 按置信度降序排序
    var sorted = detections.OrderByDescending(d => d.Confidence).ToArray();
    var keep = new List<YoloDetection>();
    var suppressed = new bool[sorted.Length];

    for (int i = 0; i < sorted.Length; i++)
    {
    if (suppressed[i])
    continue;

    keep.Add(sorted[i]);

    // 计算当前检测框和后面所有检测框的IOU
    for (int j = i + 1; j < sorted.Length; j++)
    {
    if (suppressed[j])
    continue;

    float iou = CalculateIoU(sorted[i].BoundingBox, sorted[j].BoundingBox);
    if (iou > iouThreshold)
    suppressed[j] = true;
    }
    }

    return keep;
    }

    // 计算IOU
    private float CalculateIoU(Rectangle a, Rectangle b)
    {
    int x1 = Math.Max(a.X, b.X);
    int y1 = Math.Max(a.Y, b.Y);
    int x2 = Math.Min(a.X + a.Width, b.X + b.Width);
    int y2 = Math.Min(a.Y + a.Height, b.Y + b.Height);

    int intersection = Math.Max(0, x2 x1) * Math.Max(0, y2 y1);
    int union = a.Width * a.Height + b.Width * b.Height intersection;

    return (float)intersection / union;
    }
    }

    3. 踩坑:纯C#的NMS太慢

    这是另一个常见的坑——一开始我用List存检测框,每次删除元素都要移动数组,慢得要死。后来改成先排序,再用一个bool数组标记被抑制的检测框,最后只保留没被标记的,速度提了10倍。大家一定要注意,纯C#做数值计算,用Span和数组比List快很多。


    第五步:工业现场优化——内存池+异步,纯C#实现

    和之前的文章一样,工业现场要优化帧率和稳定性,纯C#也能做内存池和异步流水线。

    1. 纯C#内存池复用Tensor

    每次预处理都new一个DenseTensor<float>,GC压力大,自己写个简单的内存池:

    using System.Collections.Concurrent;
    using Microsoft.ML.OnnxRuntime.Tensors;

    public class TensorPool
    {
    private readonly ConcurrentQueue<DenseTensor<float>> _pool = new ConcurrentQueue<DenseTensor<float>>();
    private readonly int[] _tensorShape;

    public TensorPool(int[] tensorShape, int poolSize = 10)
    {
    _tensorShape = tensorShape;
    // 预分配
    for (int i = 0; i < poolSize; i++)
    {
    _pool.Enqueue(new DenseTensor<float>(tensorShape));
    }
    }

    public DenseTensor<float> Rent()
    {
    if (_pool.TryDequeue(out var tensor))
    return tensor;
    return new DenseTensor<float>(_tensorShape);
    }

    public void Return(DenseTensor<float> tensor)
    {
    // 清零
    tensor.Fill(0);
    _pool.Enqueue(tensor);
    }
    }

    2. 纯C#异步流水线

    用System.Threading.Channels.Channel<T>做异步队列,把预处理、推理、后处理分开,并行跑,代码和之前的文章类似,这里就不重复贴了。


    最后看看效果:零Python依赖,40fps跑满

    在李老板的车间工控机(i5-12400+T400 2G)上跑,测试数据如下:

    测试项结果
    Python依赖
    GPU版帧率 40fps
    CPU版帧率 10fps
    mAP 0.95(和Python版一样)
    程序稳定性 连续30天无崩溃

    李老板看完这个数据,直接拍板说要把车间里的20台设备都换成这套纯C#的系统。其实纯C#调用YOLO的核心思路就是:模型转换在开发机做,推理用ONNX Runtime,预处理用Emgu.CV,后处理用Span优化,内存池+异步提速度——别觉得纯C#做不了深度学习,只要选对工具,一样能跑得很快很稳。

    大家如果在做纯C# YOLO视觉检测,不妨试试这套方案,有什么问题或者更好的优化思路,欢迎一起交流。

    赞(0)
    未经允许不得转载:171主机测评 » 工业现场不让装Python?威哥纯C# YOLO实时检测实战:零Python依赖,40fps跑满
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址