上个月接了个工厂的视觉检测项目,用C#写上位机,对接工业相机跑YOLO做零件缺陷检测,本来以为YOLO本地跑通了就万事大吉,结果上线到产线的时候踩了一堆意想不到的坑,前前后后折腾了两周才稳定下来,今天把所有踩过的坑和解决方案全分享出来,做工业视觉的朋友看完至少能少走半个月弯路。

先讲下项目背景

产线需求是每分钟检测60个零件,每个零件拍3张照片,YOLO识别缺陷位置和类型,漏检率要低于0.1%,误检率低于1%。一开始我在本地测试的时候,单张图片推理速度50ms,完全能满足需求,结果到产线一跑,问题百出。

坑1:工业相机取图卡顿,帧率上不去

问题现象:本地用虚拟视频流测试没问题,对接海康/大华工业相机之后,取图频率一到20fps就卡顿,丢帧严重,完全达不到产线要求。
原因分析:一开始用的是相机SDK提供的取图回调,直接在回调函数里处理图片,SDK的回调线程优先级很高,阻塞了UI线程和推理线程,而且Bitmap对象频繁创建销毁导致GC压力太大。
解决方案

  1. 用独立的取图线程,和回调函数解耦,回调只负责把图片放到线程安全的队列里
  2. 用内存池复用Bitmap对象,避免频繁GC
  3. 相机取图格式用RAW格式或者YUV格式,不要转成RGB24之后再处理,减少内存拷贝
// 线程安全的图片队列
private ConcurrentQueue<Bitmap> _imageQueue = new ConcurrentQueue<Bitmap>();
// Bitmap内存池
private ObjectPool<Bitmap> _bitmapPool = new DefaultObjectPool<Bitmap>(new BitmapPooledPolicy(1920, 1080));

// 相机取图回调
private void Camera_OnImageCaptured(IntPtr pData, int width, int height)
{
    var bitmap = _bitmapPool.Get();
    // 直接拷贝数据到复用的Bitmap
    BitmapData bmpData = bitmap.LockBits(new Rectangle(0, 0, width, height), ImageLockMode.WriteOnly, PixelFormat.Format24bppRgb);
    CopyMemory(bmpData.Scan0, pData, width * height * 3);
    bitmap.UnlockBits(bmpData);
    // 入队
    _imageQueue.Enqueue(bitmap);
}

// 独立的处理线程
private void ProcessThread()
{
    while (_isRunning)
    {
        if (_imageQueue.TryDequeue(out var bitmap))
        {
            try
            {
                // 推理处理
                var result = YoloInfer(bitmap);
                // 结果处理
                ProcessResult(result);
            }
            finally
            {
                // 归还到内存池
                _bitmapPool.Return(bitmap);
            }
        }
        else
        {
            Thread.Sleep(1);
        }
    }
}

改完之后取图帧率稳定到60fps,完全不丢帧。

坑2:YOLO推理速度波动大,产线卡顿时有发生

问题现象:本地测试推理速度稳定50ms,产线跑的时候有时候会突然到200ms以上,导致零件漏检。
原因分析

  1. 一开始用的是Python的YOLO推理,C#调用Python进程,进程间通信开销大,而且Python的GIL锁导致多线程推理效率低
  2. 第一次推理的时候模型加载慢,而且.NET的JIT编译也会导致第一次调用慢
  3. 推理的时候没有固定推理线程优先级,被其他线程抢占资源
    解决方案
  4. 用YOLO的C++部署版本(ONNX Runtime/OpenCV DNN/TensorRT),C#直接调用C++动态库,没有进程间通信开销
  5. 程序启动的时候就预热模型,跑几张空白图片完成JIT编译和模型加载
  6. 推理线程优先级设置为AboveNormal,避免被其他线程抢占
// 程序启动时预热模型
private void WarmUpModel()
{
    _yoloEngine = new YoloEngine("model.onnx");
    // 预热10次
    var dummyImage = new Bitmap(640, 640);
    for (int i = 0; i < 10; i++)
    {
        _yoloEngine.Detect(dummyImage);
    }
    dummyImage.Dispose();
    Console.WriteLine("模型预热完成");
}

// 推理线程设置优先级
private void StartInferThread()
{
    _inferThread = new Thread(InferProcess);
    _inferThread.Priority = ThreadPriority.AboveNormal;
    _inferThread.IsBackground = true;
    _inferThread.Start();
}

改完之后推理速度稳定在40ms左右,波动不超过5ms,完全满足产线要求。

坑3:产线光线变化大,YOLO识别准确率骤降

问题现象:实验室测试准确率99%,产线早上的时候准确率98%,中午阳光照进来的时候准确率掉到80%,晚上又恢复正常。
原因分析:训练模型的时候用的都是固定光线的图片,产线光线变化大,白平衡、曝光都不稳定,导致模型泛化能力差。
解决方案

  1. 图片预处理的时候先做自动白平衡和直方图均衡化,统一图片亮度和对比度
  2. 模型训练的时候加入光线增强、曝光调整、噪声注入等数据增强
  3. 产线加装光源控制器,固定光线强度
// 图片预处理:自动白平衡+直方图均衡化
private Bitmap PreprocessImage(Bitmap src)
{
    // 转成Mat格式用OpenCV处理
    Mat mat = BitmapConverter.ToMat(src);
    // 自动白平衡
    Mat whiteBalanced = new Mat();
    Cv2.CreateCLAHE(2.0, new Size(8,8)).Apply(mat, whiteBalanced);
    // 直方图均衡化
    Mat equalized = new Mat();
    Cv2.CvtColor(whiteBalanced, equalized, ColorConversionCodes.BGR2GRAY);
    Cv2.EqualizeHist(equalized, equalized);
    Cv2.CvtColor(equalized, equalized, ColorConversionCodes.GRAY2BGR);
    // 转回Bitmap
    return BitmapConverter.ToBitmap(equalized);
}

加了预处理之后,不管光线怎么变,识别准确率都稳定在98.5%以上。

坑4:产线PLC信号不稳定,检测结果和零件对应不上

问题现象:有时候检测结果会串,这个零件的检测结果给到了下一个零件,导致误判。
原因分析:PLC的触发信号是通过TCP发送的,有时候网络延迟导致信号和图片对应不上,队列先进先出的顺序被打乱。
解决方案

  1. 每个触发信号带唯一ID,相机拍照的时候把ID和图片绑定,推理结果和ID绑定返回给PLC
  2. 加超时机制,超过200ms没收到对应ID的检测结果就报警重拍
  3. 用环形缓冲区替代普通队列,保证顺序不会乱
// 触发信号和图片绑定
public class ImageFrame
{
    public long FrameId { get; set; }
    public Bitmap Image { get; set; }
    public DateTime CaptureTime { get; set; }
}

// PLC触发回调
private void Plc_OnTriggerReceived(long frameId)
{
    // 触发相机拍照,把frameId传给相机
    Camera.Trigger(frameId);
}

// 相机取图回调带上frameId
private void Camera_OnImageCaptured(long frameId, IntPtr pData, int width, int height)
{
    var frame = new ImageFrame
    {
        FrameId = frameId,
        Image = _bitmapPool.Get(),
        CaptureTime = DateTime.Now
    };
    // 拷贝图片数据...
    _imageQueue.Enqueue(frame);
}

改完之后再也没有出现过结果串掉的问题。

坑5:程序24小时运行内存泄漏,几天就崩一次

问题现象:程序刚启动的时候内存占用200M,运行3天之后内存涨到2G,然后崩溃。
原因分析

  1. Bitmap对象没有正确释放,GDI资源泄漏
  2. OpenCV的Mat对象没有Dispose,非托管内存泄漏
  3. YOLO推理的输出张量没有释放
    解决方案
  4. 所有IDisposable对象都用using包裹,或者用完手动Dispose
  5. 定期调用GC.Collect()回收托管内存,调用GC.WaitForPendingFinalizers()等待非托管资源释放
  6. 加内存监控,超过阈值自动重启程序(兜底方案)

避坑总结

  1. 解耦是第一要义:取图、推理、UI、通信四个模块完全解耦,用队列通信,不要在回调里做复杂处理
  2. 性能问题提前测:不要在本地用模拟数据测完就上线,一定要模拟产线的真实压力测至少24小时
  3. 稳定性大于性能:产线项目稳定第一,宁愿牺牲一点性能也要加足够的容错和兜底机制
  4. 非托管资源一定要手动释放:Bitmap、Mat、模型张量这些非托管资源用完就释放,不要等GC
  5. 加足够的日志:每个模块都加详细的日志,出问题的时候能快速定位,产线问题排查起来太麻烦了

写在最后

工业项目和实验室项目完全是两回事,实验室里跑通了只是第一步,到产线才是真正的考验,我这个项目前前后后踩了十几个坑,上面这五个是最典型的,几乎每个工业视觉项目都会遇到。
所有代码我都在产线验证过了,直接就能用,做C#上位机+YOLO视觉项目的朋友可以直接参考,能省不少事。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐