1. 这个框架到底要解决什么问题
1.1 视觉项目为什么总是“一个项目一套代码”
做机器视觉这行时间长了,你会发现一个很尴尬的事实:大部分公司的视觉软件,都是从某个项目里“长”出来的。项目A需要检测螺丝有无,程序员写死了一个检测流程;项目B需要测量产品尺寸,复制了项目A的代码,把算法换成了卡尺,然后改一改界面。等到项目C、D、E接踵而来,代码里的if else越来越多,相机型号换了就要重写采集,PLC通讯协议换了又要到处找发振的位置,最终整个软件变成一团乱麻。
这种“一个项目一套代码”的模式,短期看开发快,长期看全是坑。主要体现在几个方面:第一,重复劳动严重,每个新项目都要重新做相机采集、图像显示、结果输出这些和算法无关的模块;第二,人员依赖严重,老员工一走,新员工接手要翻很久才能看懂项目特有的逻辑;第三,稳定性不可控,每个项目都是“薛定谔的稳定”,只有跑起来才知道有没有雷;第四,算法和业务逻辑深度耦合,想单独验证某个算法或者更换Halcon版本,牵一发动全身。
我见过不少团队,辛辛苦苦把项目交付了,客户验收也过了,但半年后产线工艺调整,需要增加一个新的检测项,结果软件改了一周才上线,产线停线一天损失就是几万块。所以,从这里出发,我们真正需要的不是“某一个项目的视觉软件”,而是一个视觉通用框架——把采集、算法、通信、UI、调度这些视觉项目里共性的东西沉淀下来,每个新项目只做配置和算法流程编排,而不是从零开始写代码。
1.2 通用框架的核心目标与适用范围
我当时给自己定的核心目标有四个:
- 复用:80%以上的代码可以跨项目复用,新项目只写算法流程和必要的定制逻辑。
- 解耦:相机、算法、通信、UI互相独立,替换任何一个模块不影响其他模块。
- 可配置:检测流程、参数、结果显示都能通过配置文件或界面配置,不写死。
- 可落地:框架不是玩具,要能在真实产线上7x24小时稳定跑,异常能恢复。
适用范围可以覆盖常见的视觉场景:定位引导、尺寸测量、缺陷检测、OCR识别、条码/二维码读取、颜色识别等。硬件上覆盖市面上主流的面阵相机、线阵相机,以及串口、TCP/IP、Modbus等常见PLC通讯方式。
这不是一个“大而全”的工业软件平台,而是一个务实、够用、可扩展的框架底座。你可以在上面继续做深度学习检测、3D视觉、多相机拼接等更复杂的事,但基础的地基要先打好。
2. 技术选型:C#与Halcon的配合方式
2.1 为什么选Halcon做算法层
视觉算法库市面上主流的有Halcon、OpenCV、VisionPro等,各有各的拥趸。我个人做工业项目时更倾向于Halcon,原因说白了就几点:
- 算法覆盖面广:从Blob分析、边缘提取、卡尺测量、模板匹配、标定,到OCR、条码识别、缺陷检测,Halcon基本都有成熟算子,而且很多算子经过了工业现场十几年的打磨,稳定性比自己去调OpenCV参数要好得多。
- 开发效率高:Halcon自带的HDevelop交互环境,可以一边看图一边调参数,算法验证快。你可以先用HDevelop把流程跑通,再导出为C#代码集成到框架里,这条路我走了很多遍,效率极高。
- 跨平台和硬件适配好:Halcon支持Windows/Linux,支持主流的GigE Vision、USB3 Vision、CameraLink接口相机,对硬件兼容性处理得很成熟。尤其GigE相机,Halcon自带的接口比很多相机厂商SDK封装得还好用,丢包率低,参数控制灵活。
- 性能不错:除非是做深度学习那种超大计算量,传统视觉算法Halcon的性能都是够用的,而且它对多核CPU的利用率也做得不错。
当然,OpenCV也有它的价值,比如开源、免费、算法种类更新快,但在工业现场的稳定性和易用性上,Halcon图腾一般的地位还是有道理的。商业授权费用不低,但相比项目烂尾、产线停线,这笔钱值得。
2.2 C#侧负责什么,Halcon侧负责什么
这是一个很多新手纠结的问题。我的理解很朴素:Halcon负责“看”和“算”,C#负责“管”和“动”。
Halcon侧要干的事情是:图像采集(通过Halcon的相机接口)、图像预处理、定位、测量、识别、缺陷检测。这些算法的核心都在HDevelop里写成脚本,然后可以在C#里通过两种方式调用:一是直接引用HalconDotNet,用C#调用HOperatorSet的一系列算子;二是用HDevelop导出C#代码,再在框架里集成。第二种是我推荐的,因为算法工程师可以在HDevelop里快速迭代,然后把写好的代码导出来,C#工程师负责集成到框架里。
C#侧要干的事情是:系统启动/停止、硬件资源管理、相机开关与参数管理、任务调度、UI界面、PLC/上位机通讯、数据存储、日志、异常恢复、生产统计等。C#作为.NET生态的主力语言,写GUI、写Socket、写数据库、写多线程都极其顺手,而且Visual Studio的开发调试体验无可挑剔。
两者的连接点就是HalconDotNet库。Halcon导出的C#代码会生成HObject、HTuple、HDevEngine等对象,C#框架负责管理这些对象的生命周期,并把算法封装成独立的类或服务。这里有个重要的设计原则:不要让Halcon的代码穿透到UI和通信层,所有Halcon相关类型都应该封装在算法层内部,这样未来即使要换算法引擎,也只是替换一个模块的事。
2.3 环境配置与版本兼容经验
环境配置看似简单,但坑不少。我平时用的组合是:Visual Studio 2019/2022 + Halcon 20.11或更新的版本(不同版本用到的命名空间和接口略有不同)。安装Halcon时,建议勾选“License”和“Interface”相关组件,否则连相机时会发现少了GigE Vision接口文件。
这里有几个经验,踩过坑的人会懂:
- 64位必须全家桶:整个项目的Platform Target必须统一为x64,且Halcon的安装路径也要是64位的。如果项目中混了AnyCPU或x86,运行时大概率会出现BadImageFormatException,这类问题查起来特别浪费时间。
- 引用DLL要完整:Halcon安装目录下的
bin\\dotnet35或bin\\dotnetcore里包含了HalconDotNet.dll。除了这个核心DLL,运行时还需要halcon.dll等原生DLL,它们通常在bin\\x64-win64下。部署到客户机器时,要么把整个运行时目录加上,要么用安装包带上运行库。我建议直接用安装包自带的runtime合并模式,省心。 - 版本一致的坑:HalconDotNet.dll的版本必须和Halcon运行时版本完全一致,混用版本会莫名其妙崩溃在HALCON引擎内部,有的是报
HALCON error #34000,有的是直接内存访问冲突。所以升级Halcon时,所有引用一起换,不要只换DLL不换运行库。 - 相机SDK冲突:如果你同时装了Halcon和相机厂商的SDK,并且都用GigE Vision,有可能会因为filter驱动冲突导致相机枚举不到。解决办法是,框架中只通过Halcon的接口连相机,厂商SDK只用于固件升级或特殊设置,平时不要启动其驱动服务。
3. 框架的模块划分与架构设计
3.1 模块总览:采集、算法、通信、UI、任务调度
我设计框架时,没有一开始就画特别复杂的UML图,而是先按职责把整个系统切成几个能够独立替换的模块。最终稳定的模块划分是这样的:
| 模块 | 职责 | 关键接口/类 |
|---|---|---|
| 相机采集模块 | 控制相机开关、参数设置、采集图像,回调原始图像 | ICamera、CameraManager、FrameGrabbed事件 |
| 算法模块 | 管理算法流程,接收图像,返回结果 | IVisionFlow、VisionResult、VisionProfile |
| 任务调度模块 | 定义检测流程的状态流转,协调采集、算法、通讯、UI | WorkflowRunner、WorkflowContext |
| 通讯模块 | 与PLC/上位机交互,收发指令和结果 | PlcClient、ModbusTcpClient、DataExchange |
| UI模块 | 显示图像、当前状态、检测结果、参数配置 | MainForm、ImageViewer、ResultPanel |
| 数据与日志模块 | 保存检测记录、图像、统计信息,输出日志 | DataLogger、StatisticsService |
这些模块之间的依赖关系是单向的:UI层依赖任务调度层,任务调度层依赖采集/算法/通讯层,而采集/算法/通讯层互不依赖。这样一来,如果现场换了一种PLC协议,只需要替换通讯模块实现;如果换了相机品牌,只需要换采集模块实现;如果算法逻辑要改,只需要在算法模块内调整流程,不会影响到UI和通讯。
模块之间通过接口沟通,而不是直接new一个具体类。举个例子,采集模块定义ICamera接口,不同相机有不同实现类,框架通过配置文件的相机类型来动态创建对应的实例。这样加新相机支持的时候,只要实现一个接口,不需要动任何已有代码。
3.2 相机采集层的抽象与实现
采集层是整个框架里最需要稳定的地方,因为产线上的相机一掉线,影响的是整条线的节拍。我在采集层做的核心设计是把“相机”抽象成黑盒:你给它一个触发信号,它给你一帧图像;你需要设置曝光,它就调整曝光;你需要查状态,它能告诉你当前硬件状态。
接口设计大致长这样:
public interface ICamera : IDisposable { bool Open(string configPath); bool Close(); bool IsOpened { get; } bool IsGrabbing { get; } void StartGrabbing(); void StopGrabbing(); bool SetExposure(double exposureMs); bool SetGain(double gain); bool TriggerOnce(out HObject image); event EventHandler<FrameGrabbedEventArgs> FrameGrabbed; }这里有几个实操细节:
- 触发模式。工业视觉通常不是软件随便拍,而是等PLC给一个硬触发信号或者软触发指令。采图线程要能够等待触发事件,然后采集一张图,否则一直拉流会浪费CPU和带宽。所以我一般会设计为两种模式:软触发模式(上位机发送触发命令)和硬触发模式(相机IO收到外部信号)。硬触发模式下,相机内部会把图像缓冲起来,软件只需要在收到图像事件时取走即可,这种方式延迟最低、最稳定。
- 图片格式。Halcon里统一用
HObject表示图像。从相机获取到原始数据后,最好统一转成HObject的interleaved或者gray格式,再交给算法层。不要到处保留Bitmap,因为Bitmap在GDI+下有很多坑,而且转换耗时长。 - 掉线重连。相机在工作一段时间后,特别是GigE相机,有可能因为网线松动、交换机和防火墙等原因断掉。采集层必须要有重连机制。我实现的方式是,在独立的监控线程里定时检测相机连接状态,如果发现掉线,先尝试
Close,再Open,如果连续重试N次失败,则通知任务调度层进入错误状态并报警。
采集层的另一个关键点是多相机支持。一条产线可能有四个相机拍不同位置,所以框架里要有CameraManager来管理多个ICamera实例,每个实例有自己的配置和图像回调,互不干扰。这样四相机同时采集、并行处理成为可能。
3.3 算法封装层的设计
算法层是视觉框架的灵魂,也是我最花心思的地方。根本原则是:算法逻辑与框架逻辑分离,算法流程可配置化。
我把一次视觉检测拆分成这样的“算法步骤链”:图像获取 -> 预处理 -> 定位/找ROI -> 测量/检测/识别 -> 结果后处理 -> 输出。
每个步骤是一个独立的算法节点,节点之间用管道方式连接。比如一个典型的端子缺陷检测流程,可以有这些步骤:
- 读取图像(或从缓存取图)
- 图像预处理:灰度化、中值滤波、对比度增强
- 使用模板匹配定位端子的基准位置
- 根据基准位置生成多个ROI区域
- 在每个ROI内执行Blob分析或边缘检测
- 对结果进行尺寸/数量判断
- 输出OK/NG信号和详细测量值
在框架里,我把每个“算法函数”包装成VisionStep,每个步骤包含输入参数、算法代码和输出对象。一个完整的检测流程VisionProfile就是一个List<VisionStep>,按照顺序执行。
为什么这样做?因为现场算法工程师经常会微调检测流程,比如在处理前加一步滤波,或者把某个阈值从20改成25。如果流程是写死的,每次都要改代码重编译;如果流程可配置,只需要在配置文件中调整步骤和参数,甚至可以在界面做简单的流程编辑器,极大提升调试效率。
当然,完全做可视化流程编辑器的工作量不小,我一般第一版采用XML或JSON配置,第二版再做简单的可视化编辑。配置文件里长这样:
{ "Steps": [ { "Name": "LoadImage", "Type": "HalconOp.LoadImage", "Params": { "Source": "Camera1" } }, { "Name": "MedianFilter", "Type": "HalconOp.MedianImage", "Params": { "MaskType": "circle", "Radius": 3.5 } }, { "Name": "ShapeMatch", "Type": "HalconOp.FindShapeModel", "Params": { "ModelFile": "models/connector.shm", "MinScore": 0.6, "NumLevels": 4 } } ] }实际上,算法层内部仍然使用HDevelop导出的C#代码。比如ShapeMatch步骤的Execute(HObject image)方法内部就是HDevelop导出的find_shape_model等调用。我需要做的只是把参数包成配置,把返回结果包成统一的VisionResult。
3.4 通信层与PLC协作
工业现场,视觉软件通常作为一个从站,接收主站(PLC)的指令,并回报检测结果。通讯方式取决于设备,最常见的有TCP/IP、Modbus TCP、串口(RS232/RS485)、Profinet等。框架里我抽象了一个IMasterLink接口:
public interface IMasterLink { void Connect(); void Disconnect(); bool IsConnected { get; } void SendResult(ProductResult result); void RequestTrigger(); event EventHandler<TriggerEventArgs> TriggerReceived; }在这个实现中,PLC的请求触发可以通过两种方式:一种是PLC发送一个字符串指令,例如“TRIGGER”,软件收到后触发一次采集;另一种是通过IO硬触发相机,软件只需要监听采集事件即可。对于TCP/Modbus通讯,我重点做两件事:协议帧的解析与组包、断线重连与心跳机制。
一个小经验:通讯模块的报文解析一定要做超时和校验。有的PLC工程师会发一些非标准的字符,或者帧头帧尾不完整,如果解析代码没有做容错,一个异常字节就可能导致整个通讯线程卡死。我习惯在报文解析层加一个状态机,逐字节处理,并且收到完整帧才交给上层,避免半包和粘包问题。
另外,通讯模块最好不要和业务逻辑直接耦合。比如收到“TRIGGER”后,不应该直接去调相机采集,而是抛出一个TriggerReceived事件,由任务调度层决定下一步动作。这样通讯模块可以单独测试,而且未来如果把软触发改为IO触发,通讯层不需要改动,只要触发源换成硬件事件就行。
3.5 任务调度与状态机
视觉系统运行起来其实是一个状态机:空闲(等待触发) -> 采集中 -> 图像处理中 -> 结果上报 -> 回到空闲;如果出现异常则进入错误处理,故障恢复后回到空闲。状态机不复杂,但要很清晰,否则现场会出现各种“卡死”现象。
我设计了一个简单的WorkflowRunner:
public enum WorkflowState { Idle, Grabbing, Processing, Reporting, Error, Stopped }任务调度的核心代码逻辑如下:
- 进入
Idle状态,等待触发事件。 - 收到触发,切换到
Grabbing,调用相机采图,并设置超时定时器。 - 采到图后,切换到
Processing,在独立线程里执行算法流程。 - 算法完成后,切换到
Reporting,把结果发给PLC/上位机,同时UI更新。 - 结果发送完毕(或超时),回到
Idle。 - 任一步骤发生异常,切换到
Error,调用错误处理策略(重试、跳过、停机报警)。
这个状态机在单相机、单工位时很简单,但多相机、多工位并行时,就要求每个工位有独立的状态机实例。可以用一个StationContext来管理工位号,每个工位有自己的相机、算法配置和状态。这样整条产线可以多个工位同时触发和检测,互不等待。
状态机中最容易忽略的是“超时”。比如相机采图超时、PLC应答超时、算法卡死等。没有超时机制,一旦某个环节掉链子,整个系统就会一直假死。所以我给每个状态转移都加了监控时间:采集超过3秒没回调,判定采图超时;算法执行超过10秒没有返回,判定算法超时,都会触发恢复逻辑。
4. 核心源码实现:从采集到结果显示的完整链路
4.1 相机采集代码如何封装
我们把之前接口落实到具体代码。以海康或Basler的GigE相机为例,Halcon里可以使用open_framegrabber来连接相机:
open_framegrabber ('GigEVision', 0, 0, 0, 0, 0, 0, 'default', -1, 'default', -1, 'false', 'camera_serial', '0', 1, -1, FGHandle) grab_image_start (FGHandle, -1) grab_image (Image, FGHandle) close_framegrabber (FGHandle)在C#里封装后,核心实现大概是:
public class HalconGigECamera : ICamera { private HTuple _fgHandle; private HObject _image; private bool _isGrabbing; private Thread _grabThread; public bool Open(string cameraSerial) { // 释放旧句柄 if (_fgHandle != null && _fgHandle.Length > 0) { HOperatorSet.CloseFramegrabber(_fgHandle); } HOperatorSet.OpenFramegrabber("GigEVision", 0, 0, 0, 0, 0, 0, "default", -1, "default", -1, "false", "camera_serial", cameraSerial, 1, -1, out _fgHandle); return true; } public void StartGrabbing() { _isGrabbing = true; _grabThread = new Thread(GrabLoop) { IsBackground = true, Priority = ThreadPriority.AboveNormal }; _grabThread.Start(); } private void GrabLoop() { while (_isGrabbing) { try { HObject image = null; HOperatorSet.GrabImage(out image, _fgHandle); FrameGrabbed?.Invoke(this, new FrameGrabbedEventArgs(image)); } catch (Exception ex) { HandleGrabException(ex); Thread.Sleep(100); } } } }这里我用了一个独立的采集线程循环抓图。对于软触发模式,GrabImage会阻塞等待图像到达;对于硬触发,它会在有图时立即返回。这个设计比用事件回调更简单稳定,线程优先级稍微调高一点,避免调度延迟导致图像抖动。
实际项目中,建议不要直接引用HOperatorSet.GrabImage后马上处理,而是将采集到的HObject交给WorkflowRunner,由它决定是立即处理还是放入队列。这样做的好处是采集循环不会被算法耗时影响,始终保持快速抓图能力。
4.2 图像处理流程如何配置化
框架里算法的执行由VisionProfile驱动。我定义了一个VisionStep基类:
public abstract class VisionStep { public string Name { get; set; } public abstract VisionResult Execute(VisionContext context); }每个具体步骤继承这个基类,例如MedianFilterStep、ShapeMatchStep、BlobAnalysisStep等。VisionContext里包含当前图像、ROI区域、中间结果数据、参数集合等。这样每个步骤读取需要的输入,产生输出,放入上下文供后续步骤使用。
执行时,VisionProfile.Run(HObject inputImage)会遍历步骤列表:
public VisionResult Run(HObject inputImage) { var context = new VisionContext { Image = inputImage }; foreach (var step in Steps) { var result = step.Execute(context); if (!result.Success) { result.ErrorStep = step.Name; return result; } context.LastResult = result; } return context.LastResult; }在实际项目中,算法步骤之间的依赖关系不一定完全是线性的,有时候需要分支。比如一个产品在定位后,可能根据位置决定是用左模板还是右模板进行匹配。这种情况我采用了“条件步骤”:
public class ConditionalStep : VisionStep { public string ConditionExpression { get; set; } public List<VisionStep> TrueSteps { get; set; } public List<VisionStep> FalseSteps { get; set; } }虽然这种设计会让流程配置复杂一些,但极大提高了框架的通用性。我在现场遇到很多需要在流程里做判断的场景,比如胶路检测中,不同的产品型号对应不同的检测区域,用条件步骤可以在配置层面就处理掉,避免把分支写死在代码里。
4.3 结果显示与数据存储
视觉系统的输出不只是OK/NG。客户往往要看具体的测量值、缺陷坐标、图像留存等。所以VisionResult里包含了:
public class VisionResult { public bool IsSuccess { get; set; } public bool IsAccept { get; set; } public double Score { get; set; } public List<MeasurementItem> Measurements { get; set; } public List<RegionResult> Regions { get; set; } public string Message { get; set; } public long TimestampMs { get; set; } }UI显示时,我会用Halcon的HWindowControl控件显示图像,并在上面叠加结果。比如定位成功时画一个十字线,NG时框出缺陷区域。这些叠加操作可以直接调用HOperatorSet.DispCross和DispRegion。注意,HWindowControl的绘图要在UI线程,否则容易闪退或无响应。
数据存储方面,我采用SQLite保存检测记录和统计数据。每一帧图像可以按日期存储在本地磁盘,文件名包含时间戳和条码信息,方便追溯。存图策略根据现场需求:全量存图、只存NG图、按频率抽帧存图。这个在框架里做成配置项,默认“只存NG图”,否则一天的图像就能占到几十GB。
4.4 一个完整的视觉检测流程示例
实践出真知,用一个连接器Pin针检测的例子串一遍。假设我们要检测连接器中6根Pin针是否位置正确、有无歪斜。Halcon算法思路:
- 读图后先做模板匹配,找到连接器的整体位置。
- 根据模板匹配得到的坐标,生成6个候选Pin针的ROI区域。
- 在每个ROI内,使用边缘检测找到Pin针的直线边缘,计算中心线。
- 将每条中心线的位置和角度与标准值对比,超出公差则判NG。
- 输出6个坐标值、角度值和最终OK/NG。
在框架里配置的VisionProfile的步骤列表如下:
| 序号 | 步骤类型 | 关键参数 |
|---|---|---|
| 1 | LoadImage | 取相机图像 |
| 2 | FindShapeModel | 模型文件connector_model.shm,minScore=0.5 |
| 3 | GenMeasureROIs | 根据定位结果生成6个测量区域 |
| 4 | MeasurePin | 在每个ROI内做边缘测量,输出位置角度 |
| 5 | ValidatePins | 判断位置偏差与角度偏差是否在公差内 |
| 6 | ExportResult | 构造VisionResult,触发通讯发送 |
这套流程在HDevelop里验证好之后,导出成C#,再填到对应的步骤类里。之后如果客户要求增加一个Pin针或者调整公差,只需要在配置文件中修改,不需要改C#代码。这就是框架的价值。
5. 多线程与性能优化实战
5.1 采集线程、处理线程、UI线程怎么协调
工业视觉对实时性要求很高,尤其是产线节拍小于1秒的场景。多线程设计没有银弹,但可以遵循一个原则:任何地方都不能阻塞UI线程,耗时操作全部异步化或线程化。
我的典型线程模型是:
- UI线程:只负责刷新界面、响应用户操作。
- 采集线程:每个相机一个,只做抓图并放入待处理队列。
- 处理线程:一个或一组,从队列取出图像,跑算法流程。多相机多工位时可以每个工位一个处理线程。
- 通讯线程:负责发送结果给PLC,接收PLC指令。
- 日志/IO线程:写数据、存图等耗时IO操作不占用处理流程。
采集和处理之间的队列是ConcurrentQueue<FrameItem>,并设置了最大长度上限,比如50帧。如果队列满了,说明处理速度跟不上采集速度,这时候要有策略:要么丢帧(后面还会来新图,适合检测间隔性的场景),要么阻塞采集线程(适用于必须处理每一帧的场景)。我会在配置里加一个QueueOverflowPolicy选项,缺省为“丢弃旧帧保留最新帧”,保证处理的是最新状态。
这里有一个很常见的BUG:UI上显示图像时,直接在主线程里调用HOperatorSet.DispObj,当采集频率高时,主线程会卡死。解决办法是把图像拷贝一份放入显示队列,由UI定时器每50ms取最新一帧刷新显示。注意不要直接把采集线程里的HObject绑定到UI控件上,因为HObject不是线程安全的,采集线程可能会在UI绘制过程中修改同一对象,导致异常。
5.2 HDevEngine与导出C#代码两种方式对比
在整合Halcon到C#时,有两种主流做法:
方式一:使用HDevelop脚本引擎(HDevEngine)
通过在C#中加载.hdev脚本,调用其中的过程。使用HDevEngine的好处是:算法调整时只需替换脚本文件,不用重新编译C#代码,非常适合算法快速迭代的现场。缺点是:执行效率略低于直接C#调用,而且脚本调试时的错误信息比较难定位。
方式二:HDevelop导出C#代码
在HDevelop中开发完成后,文件 -> 导出 -> C#,会生成一个包含HDevelopExport类的.cs文件,里面是静态方法,每步算子都变成了C#方法调用。这种方式执行效率高,代码逻辑清晰,和框架结合更紧密。缺点是:算法一变,需要重新导出、编译、部署。
我的选择是:如果框架已经趋于稳定,采用方式二;如果在项目初期或算法频繁调整阶段,可以先用方式一作为过渡。实际框架里,我封装了HalconScriptRunner作为可选模块,当启用HDevEngine模式时,算法流程的配置可以指向.hdev脚本,否则执行编译好的步骤类。这样兼顾了两者的优点。
5.3 内存与GC优化技巧
视觉图像动辄1200万像素,一张灰度图就是12MB左右,如果再加上RGB,一张图30MB很常见。C#的垃圾回收(GC)在大对象频繁分配时会产生明显的停顿,而视觉程序最怕延迟。所以内存优化非常重要。
我的经验主要有几条:
- 不要频繁new大矩阵。Halcon的
HObject内部是托管原生内存,虽然会被Dispose释放,但如果你在循环里不断创建HObject而不手动Dispose,程序跑几个小时就会出现内存暴涨。最佳实践是:using或try-finally中调用Dispose()。对于算法中间结果,比如滤波后的图像、中间Region,用完立刻Dispose。 - 使用图像队列复用。采集到的图像如果不需要保留原图,可以只保留处理后的结果信息,而不长期持有
HObject。比如定位结果只需要坐标值,测量结果只需要数值,原始的HObject处理完就释放。除非需要保存NG图像,才把NG的那帧留下来写盘。 - 算法处理线程用专用线程。不要让CLR的线程池跑算法,因为线程池会动态调整线程数量,不适合需要稳定吞吐的视觉处理。我用一个固定线程数的
WorkerThread池,每个工位一个处理线程,在线程中循环取队列任务,这样可以避免线程创建销毁的抖动。 - 限制Halcon内部缓存。Halcon的
set_system('tsp_cache', ...)等参数可以调整内部缓存策略,适当限制能减少内存占用。还有set_system('mmu_use_shared_memory', 'false')在部分版本中能避免共享内存占用过大。 - UI显示降采样。如果图像很大,UI显示不需要全分辨率,可以每次提取一个缩小版本用于显示,既减少UI负担,也避免HWindowControl在绘制大图时卡顿。
6. 常见问题与排查实录
6.1 相机掉线与重连
这是产线现场最频发的问题,没有之一。症状通常是:视觉软件运行几个小时或几天后,图像突然黑屏,相机状态异常。排查流程可以按这个思路来:
- 先看相机指示灯,确认硬件是否正常。
- 用Halcon自带的Image Acquisition查看是否可以重新连上相机,如果能连上,说明是软件层连接断开。
- 检查网络设置:GigE Vision相机要求网卡开启巨帧,并且与相机同网段。有些现场交换机会开启节能模式,导致长时间空闲后连接断开。建议在网卡高级设置里关闭“节能以太网”和“绿色以太网”。
- 固件和驱动版本问题:建议将相机固件升级到厂商最新稳定版,并把Halcon更新到兼容的版本。
框架层面,我在HalconGigECamera里实现了自动重连。核心逻辑很简单:在GrabLoop捕获异常后,先尝试关闭旧的framegrabber,等待1秒,再重新打开;如果连续失败,累计重试次数,每失败5次,上报给调度层。当重连成功,会重置状态,开始新的抓图。这个机制实测下来能把产线的平均无故障时间提高一个数量级。
6.2 Halcon异常与调试
Halcon抛出的异常有时很模糊,比如HALCON error #32006: Unable to acquire image。排查这类问题,不要只盯着异常信息,建议做以下几件事:
- 开启Halcon的系统调试日志。在HDevelop里运行
set_system('debug_log', 'true'),它会输出更细节的运行信息,包括哪个算子、哪个参数不对。 - 确认授权(License)是否完整。Halcon运行时会检查授权,如果某个算子需要特定授权模块,比如OCR、深度学习,授权不匹配会报错。
- 检查图像通道数与算子要求是否匹配。很多算子要求输入图像是byte类型,但你的图像可能是uint2,这时算子会报
Invalid image type。把图像转成byte再试试。 - 检查区域为空导致的错误。比如模板匹配没找到目标,
FindShapeModel返回空区域,后续基于这个空区域继续调用ReduceDomain就会崩溃。框架里必须做空结果判断,算法步骤一旦发现匹配结果为空,应该立即返回失败,并附上ErrorStep信息,而不是让异常继续蔓延。
算法调试最好的工具还是HDevelop。每次遇到算法问题,建议把现场保存的畸形图拿到HDevelop里一步步跑,找出是哪一步开始出错,再针对性调整。
6.3 标定与坐标转换的坑
视觉引导、测量类的项目,必然涉及到像素坐标和实际机械坐标的转换。我做这类项目时,标定都是重中之重,踩过的坑也非常多。
简单常用的标定流程:
- 使用Halcon标定板,拍摄多张不同位姿的图片,用
find_calib_object提取标定板角点。 - 通过
camera_calibration计算相机内参和畸变系数,输出CameraParam。 - 如果是单相机平面测量,标定完成后,用
image_to_world_plane得到像素到世界坐标的映射关系。 - 如果是手眼标定(机械臂引导),需要先做相机-机械臂标定,常用
hand_eye_calibration。
实操中的关键经验:
- 标定板不要斜得太厉害,倾斜角度越大,标定误差越大,建议控制在10度以内。
- 标定图片数量要够,至少10-15张。我的习惯是,标定板覆盖视场的9个区域,每个区域拍1-2张不同角度的图。
- 标定后一定要做验证,而不是只看重投影误差。在视场内放几个已知尺寸的标准量块,用标定结果去测量,看误差是否在允许范围内。实测通常会比理论值大几倍,所以留足余量。
- 坐标转换不要写死系数。两台同样的相机,因为安装位置不同,标定结果都必须独立计算。框架里把标定参数保存在配置文件中,设备更换或挪动相机后重新标定并更新配置文件,软件不需要改代码。
如果你做的是简单的像素比例标定(比如像素当量),也要注意光照变化会导致边缘提取偏差,间接导致测量误差。所以光照稳定很重要,必要时加遮光罩或恒定光源。
6.4 框架上线后的维护心得
框架不是因为写完了就能一直稳定,真正的考验是产线长期运行。我在现场维护时总结出几条心得:
- 日志系统必须完善。框架要记录每次检测的关键节点耗时、每次异常堆栈、每帧图像的参数和结果。没有日志,出问题只能靠猜。我把日志分成了调试、信息、警告、错误四个级别,并自动按日期滚动。
- 版本管理要严格。框架本体、算法配置、相机配置、界面配置都纳入版本控制。特别是产线上的配置,要定期备份,防止误改后无法恢复。
- 做灰度发布。如果有多条产线,先在一台设备上跑新版本,稳定后再逐步铺开,不要一口气全换。
- 训练现场维护人员。框架最好提供一个简单的运行状态界面,让现场人员能看到当前任务状态、相机状态、最近异常原因,这样很多问题他们自己就能判断,不用每次叫开发者到现场。我在UI里加了一个“自诊断面板”,列出各模块心跳时间、图像处理帧率、平均耗时,这对排查性能瓶颈很有帮助。
最后再说一个很多人容易忽略的细节:机器视觉框架的代码风格一定要保持简洁。算法工程师和软件工程师可能会在同一个项目里协作,代码里全是花哨的泛型和多级继承,只会增加所有人的理解成本。我在这个框架里刻意避免过度设计,能用配置解决的就用配置,能用简单接口解决的就不要引入依赖注入容器。毕竟,产线上的系统,稳定可维护比什么都重要。这是我做了几个视觉项目之后最深的体会。