做机器视觉项目几年以后,我越来越觉得,视觉项目中真正困难的部分往往并不是某一个算法。
用 HALCON 找一条边、用 VisionPro 做一个 PatMax、用 OpenCV 做一次二值化,这些问题通常都有比较明确的解决方法。
真正让项目变得复杂的是:
相机怎么管理?
PLC触发的时候程序正在处理上一片产品怎么办?
两台甚至十台相机同时回来图像怎么办?
算法异常会不会把整个程序卡死?
图片保存会不会影响CT?
软件运行几天以后内存为什么越来越大?
程序崩溃以后没有保存的数据怎么办?
换产品型号以后参数怎么管理?
这些问题决定的,才是一套机器视觉软件到底只是一个Demo,还是一套能够真正运行在产线上的工业软件。
本文结合我这些年使用 C#、VisionPro、HALCON、OpenCV 等视觉平台开发非标视觉软件的一些经验,整理一下我目前比较推荐的工业机器视觉软件架构。
1. 最常见的问题:所有代码都写在Form里面
很多视觉项目最开始都会写成这样:
MainForm
├─ 相机连接
├─ 相机采图
├─ PLC通讯
├─ VisionPro运行
├─ HALCON运行
├─ 保存图片
├─ 保存CSV
├─ 更新界面
└─ 报警处理
项目小时没有任何问题。
但随着功能增加,MainForm很快就会变成几千甚至上万行。
最后会出现一种非常典型的情况:
修改PLC通讯逻辑,影响相机采集;
修改界面显示,影响算法线程;
增加一个相机,需要改很多地方;
程序出现偶发异常,很难判断到底是通讯、算法还是UI造成的。
所以我现在做视觉软件时,一个非常重要的原则就是:
Form只负责显示,不负责业务。
2. 我比较推荐的视觉软件分层方式
对于一个中等规模的工业视觉软件,可以按下面的方式拆分:
VisionApplication
│
├─ UI
│ ├─ MainForm
│ ├─ CameraForm
│ ├─ VisionForm
│ ├─ LogForm
│ └─ SettingForm
│
├─ Application
│ ├─ InspectionService
│ ├─ ProductionService
│ └─ RecipeService
│
├─ Vision
│ ├─ IVisionTool
│ ├─ VisionProEngine
│ ├─ HalconEngine
│ └─ OpenCvEngine
│
├─ Camera
│ ├─ ICamera
│ ├─ HikCamera
│ ├─ BaslerCamera
│ └─ CognexCamera
│
├─ Communication
│ ├─ PlcService
│ ├─ TcpService
│ └─ MesService
│
├─ Data
│ ├─ Repository
│ ├─ SQLiteRepository
│ └─ CsvRepository
│
└─ Infrastructure
├─ Logger
├─ Config
├─ ImageSaveQueue
└─ PerformanceMonitor
这种结构最大的意义并不是"看起来专业",而是让每个模块只负责自己的事情。
例如PLC出现问题时,我只需要检查 Communication。
图片保存慢,只检查 ImageSaveQueue。
算法运行异常,只检查 Vision。
这样维护成本会低很多。
3. 相机一定要抽象接口
假设项目一开始使用海康相机:
cs
public interface ICamera
{
bool IsConnected { get; }
bool Connect();
void Disconnect();
void StartGrab();
void StopGrab();
Bitmap GrabImage();
}
然后实现:
cs
public class HikCamera : ICamera
{
public bool IsConnected { get; private set; }
public bool Connect()
{
// 海康SDK连接
return true;
}
public void Disconnect()
{
}
public void StartGrab()
{
}
public void StopGrab()
{
}
public Bitmap GrabImage()
{
return null;
}
}
以后项目改成 Basler,相机上层逻辑基本不需要变化。
cs
InspectionService
↓
ICamera
↓ ↓
HikCamera BaslerCamera
这对于非标项目非常重要。
因为视觉软件经常会因为客户指定品牌、成本或者交期更换硬件。
4. 不要让相机回调直接执行算法
这是多相机项目里一个非常容易出现的问题。
很多程序会这样写:
cs
private void Camera_ImageReceived(Bitmap image)
{
RunVision(image);
SaveImage(image);
UpdateUI(image);
}
问题在于,相机SDK回调线程被算法占用了。
如果算法耗时增加,后续图像继续进入,就很容易造成阻塞、掉帧甚至SDK内部缓存堆积。
更合理的方式是:
cs
Camera
↓
ImageReceived
↓
ImageQueue
↓
WorkerThread
↓
Vision
↓
Result
例如:
cs
private readonly BlockingCollection<Bitmap> _queue =
new BlockingCollection<Bitmap>(20);
回调只负责入队:
cs
private void Camera_ImageReceived(Bitmap image)
{
if (!_queue.TryAdd(image))
{
image.Dispose();
Logger.Error("图像队列已满");
}
}
检测线程单独执行:
cs
private void ProcessLoop()
{
foreach (Bitmap image in _queue.GetConsumingEnumerable())
{
try
{
RunVision(image);
}
catch (Exception ex)
{
Logger.Error(ex.ToString());
}
finally
{
image.Dispose();
}
}
}
这样采集线程和算法线程就解耦了。
5. 为什么一定要限制队列长度
很多人第一次写异步队列时会使用:
cs
ConcurrentQueue<Bitmap>
然后不断往里面放。
这在工业视觉里面其实存在风险。
假设:
相机每100ms产生一张图片;
算法因为某种原因变成200ms;
那么生产速度永远高于消费速度。
结果就是:
cs
10张
20张
100张
1000张
...
内存最终一定会出问题。
所以视觉系统中的队列通常应该是:
cs
| 有界队列,而不是无限队列。
队列满以后应该明确决定:
丢弃?
报警?
停止设备?
这属于生产策略,而不应该让程序无限堆积。
6. 图片保存必须异步
工业视觉软件另外一个常见性能问题就是存图。
尤其是高分辨率相机。
一次 JPEG/PNG/BMP 编码加磁盘写入可能耗费几十毫秒甚至更多。
如果代码是:
cs
RunVision();
SaveImage();
SendResult();
那么保存图片直接进入产品CT。
更合理的是:
cs
检测线程
│
├── 输出PLC结果
│
└── ImageSaveQueue
↓
SaveWorker
↓
SSD
检测完成以后,只需要将保存任务放入队列。
真正的编码和磁盘IO交给后台线程。
7. UI显示也不应该每一帧都刷新
WinForms更新图像其实也有成本。
特别是:
cs
9台相机
+
高分辨率图像
+
检测Region
+
文字
+
大量Graphic
如果每一帧都刷新UI,很容易造成主线程压力。
工业视觉软件中的UI本质上只是:
cs
| 给人看的监控界面。
它不是生产逻辑。
例如相机100FPS运行,并不意味着UI也必须显示100FPS。
完全可以算法处理100FPS,而UI只显示10FPS。
8. 每一个产品都应该有唯一的生命周期
一个比较成熟的检测流程不应该只是:
cs
PLC触发
↓
拍照
↓
检测
↓
写结果
而应该生成一个 InspectionContext:
cs
public class InspectionContext
{
public long Id { get; set; }
public DateTime TriggerTime { get; set; }
public DateTime GrabTime { get; set; }
public DateTime FinishTime { get; set; }
public string ProductCode { get; set; }
public bool Result { get; set; }
public double VisionTime { get; set; }
}
以后所有日志都带这个ID:
cs
[120382] PLC Trigger
[120382] Camera Grab 23ms
[120382] Vision Start
[120382] Vision Finished 81ms
[120382] Result NG
[120382] Send PLC OK
当产线出现偶发异常时,这种日志价值非常高。
9. 日志不要只记录"程序运行了"
我现在认为视觉软件最重要的功能之一其实是日志。
至少应该能够看到:
cs
GrabTime
VisionTime
ModelTime
DisplayTime
SaveTime
CommunicationTime
TotalCT
否则客户告诉你:
cs
| 昨天晚上3点设备偶尔慢了一次。
如果没有性能日志,基本只能猜。
而有日志以后,很容易判断:
到底是相机慢了;
模型慢了;
磁盘IO慢了;
PLC响应慢了;
还是UI卡住了。
10. 算法和业务逻辑一定要分离
例如检测螺丝。
算法层只应该返回:
cs
public class VisionResult
{
public bool IsOK { get; set; }
public double Score { get; set; }
public double X { get; set; }
public double Y { get; set; }
public double Angle { get; set; }
public string ErrorMessage { get; set; }
}
至于:
cs
OK写PLC 1
NG写PLC 2
保存NG图片
MES上传
统计产量
这些都不应该写进算法里面。
因为这些属于业务逻辑。
11. 配方Recipe也是视觉软件非常重要的一部分
工业项目几乎不可能永远只有一个产品。
因此项目一开始就应该考虑:
cs
Recipe
├─ CameraParameters
├─ VisionParameters
├─ PLCParameters
├─ SaveParameters
└─ ProductParameters
切换型号,本质上应该是:
cs
LoadRecipe
↓
ApplyCameraParameters
↓
LoadVisionProgram
↓
UpdateCommunicationParameters
↓
Ready
而不是在代码里面写:
cs
if (Product == "A")
{
}
if (Product == "B")
{
}
后者型号一多以后会非常难维护。
12. 一个真正稳定的视觉软件,算法可能只占30%
项目早期,我们经常会把注意力集中在:
模板匹配准不准;
Blob能不能找到;
边缘稳不稳定。
但软件真正上线以后,经常遇到的问题反而来自:
线程;
内存;
磁盘;
网络;
PLC;
相机SDK;
异常恢复;
状态同步。
所以我现在理解的机器视觉软件开发已经不只是:
cs
| 图像处理。
而是:
cs
| 图像处理 + 工业自动化 + 软件工程。
算法决定系统"能不能检测"。
软件工程决定系统"能不能稳定运行"。
这两件事情缺一不可。
结语
如果只是做一个Demo,把所有代码写到MainForm里并没有什么问题。
但如果目标是一套真正需要运行几年、面对各种异常、支持多型号、多相机、PLC和MES的工业视觉软件,那么架构设计越早做,后面的维护成本就越低。
后续我准备继续整理一些自己在实际机器视觉项目中遇到的问题,包括:
C#多相机并发;
VisionPro二次开发;
HALCON工程化;
PLC视觉握手;
异步图像保存;
视觉软件性能监控;
3D视觉数据处理;
通用视觉软件插件化架构。
希望能把以前单纯记录代码的博客,逐渐整理成一套完整的工业机器视觉软件开发笔记。