上周刚把丰田座椅滑轨产线的视觉检测系统升级到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=17dynamic=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的预处理步骤是:

  1. 将图片缩放到640x640,保持宽高比,用灰色填充空白部分
  2. 将像素值从0-255转换为0-1之间的浮点数
  3. 转换为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; }
}

这里有几个关键点:

  1. InferenceSession只创建一次,在构造函数里初始化
  2. 所有实现了IDisposable接口的对象都用using语句包裹
  3. 正确实现了IDisposable接口,确保资源被释放
  4. 预处理时保持了宽高比,和训练时一致

产线运行的最后优化

代码写好后,我在产线实际跑了一下,发现还有几个可以优化的地方。

第一个是多线程推理。产线的相机是30帧每秒的,我们的推理速度是32ms每帧,也就是31帧每秒,理论上刚好能跟上。但实际运行时,偶尔会出现卡顿,导致丢帧。

后来我加了一个简单的生产者消费者模式,用一个线程负责从相机取图,放到队列里,另一个线程负责从队列里取图进行推理。这样即使偶尔推理慢了一点,也不会影响相机取图。

第二个是结果缓存。对于连续的几帧图片,如果检测结果没有变化,就不用重复处理,直接返回上一次的结果。这个优化在产线这种变化比较慢的场景下非常有效,能进一步降低CPU占用。

第三个是关闭不必要的日志。OnnxRuntime默认会输出很多日志,这些日志会影响性能。在创建SessionOptions的时候,可以加上options.LogSeverityLevel=OrtLoggingLevel.ORT_LOGGING_LEVEL_ERROR,只输出错误日志。

现在这个系统已经在产线稳定运行了七天,每天24小时不停机,CPU占用稳定在28%左右,没有出现过一次内存泄漏或者崩溃,漏检率为0,完全满足产线的要求。

给大家看一下最终的部署架构:

其实做工业部署和做实验室里的demo完全是两回事。实验室里只要能跑通就行,而工业部署要求的是稳定、稳定、再稳定。一个小小的内存泄漏,在实验室里跑几个小时可能看不出来,但在产线跑几天就会出大问题。

这次YOLOv12的部署,我前前后后踩了十几个坑,花了两周时间才搞定。但当看到产线顺利运行,检测速度和精度都满足要求的时候,还是很有成就感的。

后面我打算再试试TensorRT加速,如果能用上工控机里的集成显卡,应该还能再快一些。

更多推荐