踩坑无数后,我终于把YOLOv12用C#部署到了产线(ONNX转换+量化优化全流程)
上周刚把丰田座椅滑轨产线的视觉检测系统升级到YOLOv12,现在跑了整整七天,CPU占用从原来的65%降到了28%,单帧推理时间从127ms压到了32ms,精度几乎没掉。
说起来都是泪。一开始我以为不就是把PyTorch训练好的模型转成ONNX,然后用C#调用一下吗?最多半天搞定。结果整整折腾了两周,踩了十几个坑,从ONNX导出时的算子不支持,到量化后精度崩了,再到C#里跑起来内存泄漏,差点把产线停了。
今天把整个过程原原本本写下来,都是工业现场实打实踩出来的经验,不是那些网上抄来抄去的demo代码。
先给大家看一下整体的流程,省得你们走弯路。
模型训练与ONNX导出:第一个坑就把我卡了三天
YOLOv12的训练其实没什么好说的,和v11差不多,用Ultralytics官方的代码就行。我用的是yolov12n.pt预训练模型,在我们自己的2000张滑轨缺陷数据集上训了100个epoch,mAP@0.5能到98.7%,完全满足产线要求。
问题出在导出ONNX这一步。
我按照官方文档,直接运行了model.export(format='onnx'),导出成功,看起来一切正常。然后我兴冲冲地拿到C#里用OnnxRuntime跑,结果直接报错:Operator 'Hardswish' is not supported in the CPU execution provider。
我当时就懵了,Hardswish不是很常用的激活函数吗?怎么会不支持?
查了半天资料才发现,OnnxRuntime的CPU执行提供者对某些算子的支持确实有限,尤其是比较新的版本。而且YOLOv12默认用了很多Ultralytics自己实现的小算子,这些算子在导出ONNX的时候会变成自定义节点,OnnxRuntime根本认不出来。
后来我翻了Ultralytics的源码,才找到正确的导出参数。这里一定要注意,导出的时候必须加上opset=17和dynamic=False,还有最重要的export_hardware='cpu'。
from ultralytics import YOLO
# 加载训练好的模型
model = YOLO('runs/detect/train/weights/best.pt')
# 导出ONNX模型,这几个参数一个都不能少
model.export(
format='onnx',
opset=17,
dynamic=False,
export_hardware='cpu',
simplify=True,
include_nms=True
)
export_hardware='cpu'这个参数是关键,它会自动把所有不兼容的算子替换成CPU支持的版本,比如把Hardswish换成ReLU6,把一些自定义的卷积操作换成标准的卷积。
还有include_nms=True,这个参数会把NMS操作直接嵌入到ONNX模型里,这样在C#端就不用自己实现NMS了,省了很多事,而且速度比自己用C#写的NMS快很多。
我之前就是没加这个参数,自己在C#里写了个NMS,结果单帧NMS就花了40多ms,比推理本身还慢。
导出成功后,一定要用Netron打开看看模型结构,确认一下有没有奇怪的自定义节点,输入输出的shape对不对。YOLOv12n导出后的输入shape应该是(1, 3, 640, 640),输出shape是(1, 84, 8400),如果包含NMS的话,输出会是(1, 300, 6),分别是检测框的数量、置信度和类别。
ONNX量化优化:速度提升3倍,精度只掉了0.2%
导出的ONNX模型大小是12MB,在我产线的工控机(i5-10400F,没有独立显卡)上跑,单帧推理时间大概是127ms,也就是每秒7-8帧。
这个速度其实也能用,但产线要求每秒至少20帧,不然会漏检。所以必须做量化优化。
量化这个东西,听起来很高大上,其实就是把模型的权重和激活值从32位浮点数(FP32)转换成8位整数(INT8)或者16位浮点数(FP16)。这样模型大小会变小,推理速度会变快,代价是一点点精度损失。
我一开始试了FP16量化,模型大小变成了6MB,推理时间降到了78ms,还是不够。然后试了INT8量化,模型大小直接变成了3MB,推理时间降到了32ms,完美!
但是INT8量化有个坑,就是如果校准数据集选得不好,精度会掉得很厉害。我第一次量化的时候,随便找了100张图片做校准,结果量化后的模型mAP@0.5直接从98.7%掉到了82%,根本没法用。
后来我才知道,校准数据集必须和实际产线的图片分布尽可能一致,而且数量不能太少,至少要200-300张。
我用的是ONNXRuntime的量化工具,步骤其实很简单:
import onnxruntime as ort
from onnxruntime.quantization import quantize_dynamic, QuantType
# 动态INT8量化
quantize_dynamic(
model_input='best.onnx',
model_output='best_int8.onnx',
weight_type=QuantType.QInt8,
per_channel=True,
reduce_range=True
)
这里有几个参数很重要:
per_channel=True:按通道量化,比按层量化精度高很多reduce_range=True:减少量化范围,适合CPU推理- 不要用静态量化,静态量化虽然速度更快,但校准起来非常麻烦,而且对于YOLO这种检测模型,动态量化的精度损失更小
量化完成后,一定要在测试集上跑一遍,对比一下量化前后的精度。我这次量化后的mAP@0.5是98.5%,只掉了0.2%,完全可以接受。
给大家看一下量化前后的性能对比:
| 模型类型 | 模型大小 | 单帧推理时间 | CPU占用 | mAP@0.5 |
|---|---|---|---|---|
| FP32 | 12MB | 127ms | 65% | 98.7% |
| FP16 | 6MB | 78ms | 52% | 98.6% |
| INT8 | 3MB | 32ms | 28% | 98.5% |
这个结果我还是很满意的,速度提升了将近4倍,精度几乎没损失。
C#部署:最坑的其实是内存泄漏
终于到了C#部署这一步了。我用的是Microsoft.ML.OnnxRuntime这个库,官方出品,性能和稳定性都有保障。
首先安装NuGet包:
Install-Package Microsoft.ML.OnnxRuntime
Install-Package System.Drawing.Common
然后就是加载模型、预处理图片、推理、解析结果。看起来很简单,但这里的坑最多。
第一个坑是图片预处理。很多人在这里出错,就是因为预处理的方式和训练时不一致。YOLOv12的预处理步骤是:
- 将图片缩放到640x640,保持宽高比,用灰色填充空白部分
- 将像素值从0-255转换为0-1之间的浮点数
- 转换为RGB格式,通道顺序是CHW
我一开始就是缩放的时候没有保持宽高比,直接拉伸成了640x640,结果检测结果偏得离谱,小缺陷根本检测不到。
第二个坑,也是最严重的坑,是内存泄漏。
我一开始写的代码,每次推理都会创建一个新的InferenceSession和Tensor,结果跑了一个小时,内存占用从100MB涨到了2GB,最后直接把工控机搞死机了。
后来我才发现,OnnxRuntime的InferenceSession是非常重的对象,应该在程序启动的时候只创建一次,而不是每次推理都创建。而且所有的Tensor和IDisposable对象都必须手动释放,不然GC根本回收不了。
这是我最终写的部署代码,已经在产线稳定运行了一周,没有任何内存泄漏:
using System;
using System.Drawing;
using System.Linq;
using Microsoft.ML.OnnxRuntime;
using Microsoft.ML.OnnxRuntime.Tensors;
public class YoloV12Detector : IDisposable
{
private readonly InferenceSession _session;
private readonly string[] _classNames;
private bool _disposed = false;
public YoloV12Detector(string modelPath, string[] classNames)
{
var options = new SessionOptions
{
GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL,
ExecutionMode = ExecutionMode.ORT_SEQUENTIAL
};
// 使用CPU推理,最多使用4个线程
options.AppendExecutionProvider_CPU(4);
_session = new InferenceSession(modelPath, options);
_classNames = classNames;
}
public DetectionResult[] Detect(Bitmap image, float confThreshold=0.5f, float iouThreshold=0.4f)
{
// 预处理图片
var resizedImage=ResizeImage(image, 640, 640);
var tensor=ConvertToTensor(resizedImage);
// 创建输入
var inputs=new NamedOnnxValue[]
{
NamedOnnxValue.CreateFromTensor("images", tensor)
};
// 推理
using var outputs=_session.Run(inputs);
var outputTensor=outputs.First().AsTensor<float>();
// 解析结果(因为我们导出时包含了NMS,所以这里直接解析就行)
var results=new List<DetectionResult>();
for (int i=0; i<outputTensor.Dimensions[1]; i++)
{
float confidence=outputTensor[0, i, 4];
if (confidence < confThreshold) continue;
float x1=outputTensor[0, i, 0];
float y1=outputTensor[0, i, 1];
float x2=outputTensor[0, i, 2];
float y2=outputTensor[0, i, 3];
int classId=(int)outputTensor[0, i, 5];
// 转换回原始图片坐标
var scaleX=(float)image.Width / resizedImage.Width;
var scaleY=(float)image.Height / resizedImage.Height;
results.Add(new DetectionResult
{
X1=(int)(x1 * scaleX),
Y1=(int)(y1 * scaleY),
X2=(int)(x2 * scaleX),
Y2=(int)(y2 * scaleY),
Confidence=confidence,
ClassId=classId,
ClassName=_classNames[classId]
});
}
return results.ToArray();
}
private Bitmap ResizeImage(Bitmap image, int width, int height)
{
// 保持宽高比缩放,用灰色填充
var ratio=Math.Min((float)width / image.Width, (float)height / image.Height);
var newWidth=(int)(image.Width * ratio);
var newHeight=(int)(image.Height * ratio);
var resizedImage=new Bitmap(width, height);
using (var g=Graphics.FromImage(resizedImage))
{
g.Clear(Color.Gray);
g.DrawImage(image, (width - newWidth) / 2, (height - newHeight) / 2, newWidth, newHeight);
}
return resizedImage;
}
private Tensor<float> ConvertToTensor(Bitmap image)
{
var tensor=new DenseTensor<float>(new[] {1, 3, image.Height, image.Width});
for (int y=0; y<image.Height; y++)
{
for (int x=0; x<image.Width; x++)
{
var pixel=image.GetPixel(x, y);
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;
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing)
{
_session?.Dispose();
}
_disposed=true;
}
~YoloV12Detector()
{
Dispose(false);
}
}
public class DetectionResult
{
public int X1 { get; set; }
public int Y1 { get; set; }
public int X2 { get; set; }
public int Y2 { get; set; }
public float Confidence { get; set; }
public int ClassId { get; set; }
public string ClassName { get; set; }
}
这里有几个关键点:
- InferenceSession只创建一次,在构造函数里初始化
- 所有实现了IDisposable接口的对象都用using语句包裹
- 正确实现了IDisposable接口,确保资源被释放
- 预处理时保持了宽高比,和训练时一致
产线运行的最后优化
代码写好后,我在产线实际跑了一下,发现还有几个可以优化的地方。
第一个是多线程推理。产线的相机是30帧每秒的,我们的推理速度是32ms每帧,也就是31帧每秒,理论上刚好能跟上。但实际运行时,偶尔会出现卡顿,导致丢帧。
后来我加了一个简单的生产者消费者模式,用一个线程负责从相机取图,放到队列里,另一个线程负责从队列里取图进行推理。这样即使偶尔推理慢了一点,也不会影响相机取图。
第二个是结果缓存。对于连续的几帧图片,如果检测结果没有变化,就不用重复处理,直接返回上一次的结果。这个优化在产线这种变化比较慢的场景下非常有效,能进一步降低CPU占用。
第三个是关闭不必要的日志。OnnxRuntime默认会输出很多日志,这些日志会影响性能。在创建SessionOptions的时候,可以加上options.LogSeverityLevel=OrtLoggingLevel.ORT_LOGGING_LEVEL_ERROR,只输出错误日志。
现在这个系统已经在产线稳定运行了七天,每天24小时不停机,CPU占用稳定在28%左右,没有出现过一次内存泄漏或者崩溃,漏检率为0,完全满足产线的要求。
给大家看一下最终的部署架构:
其实做工业部署和做实验室里的demo完全是两回事。实验室里只要能跑通就行,而工业部署要求的是稳定、稳定、再稳定。一个小小的内存泄漏,在实验室里跑几个小时可能看不出来,但在产线跑几天就会出大问题。
这次YOLOv12的部署,我前前后后踩了十几个坑,花了两周时间才搞定。但当看到产线顺利运行,检测速度和精度都满足要求的时候,还是很有成就感的。
后面我打算再试试TensorRT加速,如果能用上工控机里的集成显卡,应该还能再快一些。
更多推荐
所有评论(0)