大家好,我是威哥。上个月天津滨海的李老板把我堵在车间门口,脸色特别难看:“威哥你看,之前那套Python写的YOLO,昨天环境崩了,折腾了半天才修好,生产线停了3小时,损失好几万。客户说了,以后所有系统必须纯C#,连Python的边都不能沾,你能不能帮我们改改?”
我当时拍胸脯说没问题,但心里其实有点打鼓——之前一直用Python的Ultralytics库转模型、训模型,纯C#调用YOLO还真没试过。后来蹲在实验室熬了两个通宵,试了ONNX Runtime C#版,又自己写了预处理和后处理的代码,终于把系统搞定了:零Python依赖,GPU版40fps跑满,CPU版也有10fps,稳定运行30天没出问题。
今天就跟大家唠唠这中间的具体实现和踩的坑,从模型准备到纯C#推理,再到工业现场的优化,全是能直接落地的干货,没有半句虚的。
先理清楚为什么要“零Python依赖”:工业现场的硬规矩
在做方案之前,得先搞清楚工业现场为什么不让装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#版——原因有三个:
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视觉检测,不妨试试这套方案,有什么问题或者更好的优化思路,欢迎一起交流。




