简介:面向需要将HALCON视觉算法引入C#项目的开发者,这份示例工程演示了在.NET环境中调用HALCON引擎的完整流程,从HALCON.NET接口的安装配置、命名空间引入,到HInstance对象创建、算子加载、模板匹配执行,再到图像与参数对象的资源释放,形成一条清晰的调用链路,同时特别提示了多线程场景下每个线程需独立HInstance的注意事项。压缩包共36个文件,以C#源码、工程与解决方案文件、运行配置文件及编译产物为主,另含HALCON脚本与调试缓存,整体体积仅418KB,结构精简,方便快速定位关键代码。目前已有1886人学习下载。示例工程内包含可运行的Debug与Release版本,读者可直接查看主要源码理解HALCON引擎在C#中的实际调用方式,还能基于现有匹配逻辑封装自定义类,进一步扩展形状匹配、条码识别、测量检测等功能,构建完整的自动化视觉项目骨架。对于刚接触HALCON联合开发或希望规范调用方式的C#程序员,这份资源提供了高效参考,也适合作为实际项目的起点。
1. 为什么要在 C# 里调用 HALCON 引擎:视觉算法库和业务系统不该是两座孤岛
做机器视觉上位机的朋友应该都有体会:HALCON 里调参、跑流程又快又直观,但一到交付就头疼——总不能给客户装个 HDevelop 让他自己点执行。C# 联合 HALCON 编程真正的痛点,不是“能不能调”,而是“怎么把 HALCON 引擎干净地嵌进 C# 程序里,让它跟相机采集、PLC 通信、数据库落库一起稳定跑起来”。这篇笔记要解决的,就是 HALCON 引擎在 C# 内的调用全流程:从选接口、写第一行调用代码,到封装成能上产线的视觉服务,再把那些容易让人翻车的坑一个一个踩平。适合已经会写 C#、但第一次做 HALCON 联合开发的上位机工程师,也适合从零搭视觉框架的人参照着改。
2. HALCON 引擎在 C# 内的两种调用方式:脚本引擎与算子级调用怎么选
2.1 HDevEngine 方式:保留 HDevelop 脚本的灵活性
HALCON 引擎在 C# 内最常见的调用方式是 HDevEngine(HDevelop 引擎)。它的核心价值在于:你在 HDevelop 里写好的 .hdev 脚本,C# 程序可以原样加载并执行,不需要把图像处理逻辑翻译成 C# 代码。这意味着算法工程师在 HDevelop 里改阈值、换形态学核、调匹配参数,上位机工程师不需要跟着改代码,只要脚本接口不变,C# 侧几乎不用动。
这种方式的适用场景非常明确:算法还处于持续调优阶段,或者产线上不同机种共用一套上位机框架、只是视觉脚本不同。我一般会建议先评估脚本的执行频率——如果一秒钟调用几十次,HDevEngine 每次执行都有脚本解析和上下文切换的开销,这时候就要考虑下面说的算子级调用。
HDevEngine 在 C# 里的编程模型是:HDevEngine对象负责环境,HDevProgram加载脚本文件,HDevProgramCall代表一次可执行的调用。每一次执行都是独立状态,脚本里的中间变量不会残留到下一次,这对产线重复调用非常友好。
// 创建引擎实例,设置脚本搜索路径 HDevEngine engine = new HDevEngine(); engine.SetProcedurePath(@"D:\Vision\Procedures"); // 加载HDevelop脚本,注意脚本内的外部过程也要能搜到 HDevProgram program = new HDevProgram(@"D:\Vision\Scripts\measure.hdev"); // 创建一次程序调用 HDevProgramCall call = program.Call();逻辑说明:第一行创建的是引擎级对象,整个程序生命周期里一般只创建一次。SetProcedurePath设置的是外部过程(.hdvp 文件)的搜索路径,如果你的脚本里调用了自定义过程,这一步必须有,否则执行时会报“找不到外部过程”。HDevProgram加载 .hdev 源文件,program.Call()实例化一次调用。这里的call对象是有状态的,如果一个脚本要并发执行,每个线程必须各持有一个HDevProgramCall,这一点后面避坑章节会重点讲。
参数说明:SetProcedurePath可以多次调用,路径之间用分号分隔;HDevProgram的构造函数里如果只写文件名,引擎会去上面设置的路径里找,所以也可以只写"measure.hdev"。
2.2 直接调用 HALCON .NET 算子:性能优先的裸调用
第二种方式是通过 HalconDotNet 命名空间下的HOperatorSet类,直接在 C# 里逐条调用 HALCON 算子。这种方式不经过脚本引擎,没有脚本解析开销,执行路径最短,适合算法已经固化、追求每帧毫秒级响应的场景。
直接调算子的代码长这样:
HObject ho_Image = null, ho_Region = null; HTuple hv_Width = null, hv_Height = null; try { // 读取图像 HOperatorSet.ReadImage(out ho_Image, @"D:\Vision\Images\sample.png"); // 获取图像尺寸 HOperatorSet.GetImageSize(ho_Image, out hv_Width, out hv_Height); // 阈值分割 HOperatorSet.Threshold(ho_Image, out ho_Region, 80, 255); } finally { // 显式释放中间对象 ho_Image?.Dispose(); ho_Region?.Dispose(); }逻辑说明:HOperatorSet是 HALCON .NET 接口对外暴露算子的入口,每个 HALCON 算子都有一个对应的静态方法。注意 HALCON 惯用的 C# 签名:输入对象顺着传,输出对象都带out关键字。ReadImage里第一个out ho_Image是输出的图像对象,文件路径在后。Threshold的out ho_Region是分割结果区域,后面两个参数是灰度阈值下界和上界。
参数说明:灰度阈值是整数类型,默认按 0-255 灰度范围理解,如果图像是 uint2 格式,这个阈值范围要相应放大。另外,HALCON 的HObject是 IDisposable 的,用完后必须 Dispose,否则长时间跑会看到内存只涨不降——这一点避坑章节还会再强调。
2.3 环境准备:DLL 分布与初始化顺序
无论选哪种方式,C# 项目里都要正确引用 HALCON 的 .NET 接口 DLL。常见做法是把halcondotnet.dll和hdevengine.dll(如果走 HDevEngine)复制到输出目录,并在项目里添加引用。需要注意几点:DLL 的位数必须和程序的编译目标一致,HALCON 是 x64 就用 x64 编译,用 AnyCPU 有时候会踩原生库加载的坑;.NET Framework 版本建议和你的 HALCON 版本官方支持范围对齐,不要用太新的框架去引用老版本接口。
初始化顺序上,我一般遵循“先引擎、后程序、再调用”的固定顺序:
# 部署目录常见结构(伪目录示意) bin/ ├── halcondotnet.dll ├── hdevengine.dll ├── halcon.dll ├── YourApp.exe └── Procedures/ └── 外部过程目录提示:HALCON 引擎在首次创建 HDevEngine 实例时会加载原生运行时“halcon.dll”,这一步会在几秒内完成。如果程序启动第一帧图像到来得特别早,需要预留这个初始化时间。
3. 核心调用链路:从加载 .hdev 到取出 HObject 的完整示例
3.1 最小可用代码:执行一段 HDevelop 脚本并取回结果
理解了调用方式,接下来看一段完整的 C# 代码,演示“加载脚本 → 设置输入 → 执行 → 读取输出”的完整链路。假设你已经有一个 HDevelop 脚本measure.hdev,里面有两个输入:一个图像(iconic 输入),一个最小灰度阈值(ctrl 输入),输出是一个区域的面积和圆心坐标。
using HalconDotNet; // 1. 初始化引擎 HDevEngine engine = new HDevEngine(); engine.SetProcedurePath(@"D:\Vision\Procedures"); // 2. 加载程序并创建调用 HDevProgram program = new HDevProgram(@"D:\Vision\Scripts\measure.hdev"); HDevProgramCall call = program.Call(); // 3. 准备输入:从文件读取图像 HObject image = null; HOperatorSet.ReadImage(out image, @"D:\Vision\Images\part.png"); // 4. 设置输入参数 call.SetInputIconicParamObject("InputImage", image); call.SetInputCtrlParamTuple("MinGray", new HTuple(90)); call.SetInputCtrlParamTuple("MaxGray", new HTuple(200)); // 5. 执行脚本 call.Execute(); // 6. 读取输出 HTuple area = call.GetOutputCtrlParamTuple("Area"); HTuple row = call.GetOutputCtrlParamTuple("Row"); HTuple col = call.GetOutputCtrlParamTuple("Column"); HObject region = call.GetOutputIconicParamObject("ResultRegion"); // 7. 打印结果 Console.WriteLine($"面积: {area.D}, 圆心: ({row.D}, {col.D})"); // 8. 清理 image.Dispose(); region.Dispose(); call.Dispose();逻辑说明:最关键的是第 4 步到第 6 步的参数名称映射。SetInputIconicParamObject的第一个字符串参数,必须与 HDevelop 脚本里输入图像参数的变量名完全一致;SetInputCtrlParamTuple对应脚本里的控制参数。执行时脚本内部写死read_image这类算子也可以跑,但一旦该文件路径不存在,脚本会直接报错,所以更规范的做法是把图像从 C# 侧传入。
参数说明:HTuple是 HALCON 的万能参数容器,new HTuple(90)创建单值元组。取值时.D属性取 double,.S取 string,.I取 int。如果你的脚本输出是一个区域数组,GetOutputIconicParamObject返回的是HObject,但要注意它返回的是一个“对象元组”的容器,可能需要用HOperatorSet.SelectObj逐个取出。
3.2 参数传递:什么时候该用 HObject,什么时候该用 HTuple
在 C# 与 HALCON 交互中,分清两类参数是避免低级报错的关键。HALCON 把所有数据分成两大家族:图形对象(iconic)和控制参数(ctrl)。
图形对象也就是HObject,包括图像、区域、XLD 轮廓等。它们的特点是数据量大、有内部缓存,在 C# 侧表现为一个引用句柄。控制参数是HTuple,包括阈值、角度、坐标、字符串、数组等基础类型。脚本里的每一个输入输出参数,在 C# 侧要么是 HObject,要么是 HTuple,类型错了直接执行失败。
判断方法也简单:翻开你的 HDevelop 脚本,看每个参数的图标——图像图标对应 HObject,表格图标对应 HTuple。批量传参时,HTuple支持直接把 C# 数组转进去,例如:
double[] thresholds = new double[] { 80, 120, 200 }; HTuple tuple = new HTuple(thresholds); call.SetInputCtrlParamTuple("ThresholdList", tuple);逻辑说明:HALCON 元组是可以嵌套多维的,但 C# 侧构造时一般用一维数组。脚本里如果用了数组参数,C# 侧也必须是元组形式;如果你把单个 int 传进去,HALCON 会报“元组长度不匹配”。
3.3 结果输出与句柄回收:从 HDevelop 到 C# 的数据搬运动线
脚本执行完,结果仍然装在 HALCON 引擎的内部存储里。C# 侧通过GetOutputCtrlParamTuple和GetOutputIconicParamObject把数据“搬运”出来。这里有个容易忽略的点:GetOutputIconicParamObject返回的HObject是从引擎内部拷贝出来的新句柄,使用完后必须由 C# 侧 Dispose;而HDevProgramCall本身也持有引擎侧的临时对象,每次执行完应调用Dispose或重新Call()。
如果脚本输出很多,我习惯一次拿完再统一释放:
// 统一获取和释放的写法 HObject outRegion = null; try { outRegion = call.GetOutputIconicParamObject("ResultRegion"); // 对 outRegion 做处理,例如转 Bitmap、保存文件 HOperatorSet.WriteRegion(outRegion, @"D:\Vision\Output\result.hobj"); } finally { outRegion?.Dispose(); }逻辑说明:WriteRegion是把区域对象持久化为 .hobj 二进制文件的算子,调试时很有用,能让你在 HDevelop 里离线复查结果区域是否合理。
提示:如果你发现程序跑一段时间后句柄数暴涨,八成是某个分支里拿到的 HObject 没有 Dispose。建议在开发阶段每输出一个对象就数一下句柄,用任务管理器或性能监视器盯着,确认稳定后再放行。
4. 业务化封装:把 HALCON 引擎封装成可配置、可断言的视觉服务
4.1 做一个线程安全的引擎封装类
脚本调用链路学完,直接写在窗体事件里肯定不行的。产线上相机采图回调、PLC 请求、UI 刷新可能在不同线程,视觉模块必须提供线程安全的入口。前面提过,HDevEngine 对象可以全局单例,但 HDevProgramCall 不能跨线程共享。一个务实的封装思路是“引擎单例 + 每线程独立调用句柄”。
public class VisionEngine { private readonly HDevEngine _engine; private readonly string _scriptPath; public VisionEngine(string scriptPath, string procedurePath) { _scriptPath = scriptPath; _engine = new HDevEngine(); if (!string.IsNullOrEmpty(procedurePath)) _engine.SetProcedurePath(procedurePath); } public VisionResult Execute(HObject inputImage, double minGray, double maxGray) { // 每个线程每次调用都新建 HDevProgramCall using (HDevProgram program = new HDevProgram(_scriptPath)) using (HDevProgramCall call = program.Call()) { call.SetInputIconicParamObject("InputImage", inputImage); call.SetInputCtrlParamTuple("MinGray", minGray); call.SetInputCtrlParamTuple("MaxGray", maxGray); call.Execute(); VisionResult result = new VisionResult { Area = call.GetOutputCtrlParamTuple("Area").D, Row = call.GetOutputCtrlParamTuple("Row").D, Column = call.GetOutputCtrlParamTuple("Column").D, Region = call.GetOutputIconicParamObject("ResultRegion") }; return result; } } } public class VisionResult { public double Area { get; set; } public double Row { get; set; } public double Column { get; set; } public HObject Region { get; set; } // 调用方负责 Dispose }逻辑说明:每次Execute都新建HDevProgram和HDevProgramCall,用using确保释放。这样代价是每次调用都重新解析脚本,但换来了确定性的线程安全——不同线程的call对象彼此隔离。实测下来,脚本解析开销通常在几毫秒到十几毫秒,对于大多数产线应用的节拍是可以接受的。
参数说明:VisionResult里的Region是 HObject,调用方拿到后自行决定何时 Dispose;如果调用方不管,可以约定视觉服务内部转成 Bitmap 再返回,这样 HObject 生命周期就锁在服务内部。
4.2 参数外部化:阈值、模型路径从配置读取
脚本里写死的阈值和路径是交付后改参数的大麻烦。与其教现场工程师改 HDevelop 再重编,不如把参数暴露到配置文件中。我通常把 HALCON 脚本需要的控制参数做成一个字典,从 JSON 或 ini 读出来再灌进调用。
// 配置文件示例: vision_config.json // { // "ScriptPath": "D:\\Vision\\Scripts\\measure.hdev", // "ProcedurePath": "D:\\Vision\\Procedures", // "Params": { "MinGray": 90, "MaxGray": 200 } // } public VisionResult RunWithConfig(HObject image, Dictionary<string, object> paramDict) { using (HDevProgram program = new HDevProgram(_scriptPath)) using (HDevProgramCall call = program.Call()) { call.SetInputIconicParamObject("InputImage", image); foreach (var kv in paramDict) { call.SetInputCtrlParamTuple(kv.Key, new HTuple(kv.Value)); } call.Execute(); // 取结果... } }逻辑说明:SetInputCtrlParamTuple的参数名一旦传错,HDevelop 脚本执行时会报“未知变量”,所以配置里的 key 必须和脚本变量名严格一致。这一步看起来简单,却是现场最常出问题的地方——现场改配置改出了拼写错误,报错信息还不直观。
参数说明:kv.Value可能是 double、int、string,new HTuple(kv.Value)会自动做类型适配。如果脚本期望的是 double 而配置里写的是字符串,HALCON 会尽量转换,但建议统一数值型参数都用数字格式写在配置里。
4.3 捕获异常与返回码:让调用方不感知 HALCON 细节
封装再干净,也挡不住脚本本身执行失败。HALCON 在执行算子和脚本时抛的异常是不带调用栈的,很多时候只有一行错误码。业务化封装必须把这一步兜住,把 HALCON 错误转成业务上能看懂的返回信息。
public (bool ok, string message) TryExecute(..., out VisionResult result) { result = null; try { result = Execute(...); return (true, "OK"); } catch (HalconException ex) { // HalconException 包含 ErrorCode 和原始错误消息 return (false, $"HALCON错误码: {ex.ErrorCode}, 消息: {ex.Message}"); } catch (Exception ex) { return (false, $"系统异常: {ex.Message}"); } }逻辑说明:HalconException是 HalconDotNet 里的标准异常类型,ErrorCode对应 HALCON 手册里的错误码表。业务代码收到(false, message)后,可以直接把 message 写到日志或显示在界面上,不必引用 HALCON 命名空间。
5. 避坑与常见问题:C# 调用 HALCON 引擎的五个高频坎
5.1 现象:程序一执行就崩溃,报“无法加载DLL”
这个问题几乎每个初次上手的人都会遇到。症状是程序启动时直接抛DllNotFoundException,或执行到new HDevEngine()时崩溃。
原因:要么是 halcon 相关 DLL 没有放在程序输出目录,要么是位数不匹配。64 位程序加载了 32 位的 HALCON 运行时,Windows 会给出一个很迷惑的“模块找不到”错误,实际上架构不匹配。
解决:把 HALCON 安装目录下的bin\x64-win64里的 DLL 全部或按需复制到程序输出目录;项目平台目标改为 x64,不要用 AnyCPU;如果还报错,用依赖查看工具打开 halcondotnet.dll 看它依赖的原生库在不在旁边。
5.2 现象:开发机上跑得好好的,换一台电脑就起不来
开发环境装了完整 HALCON,部署电脑只拷了程序,结果初始化引擎时静默失败或执行脚本时报许可证错误。
原因:HALCON 引擎运行需要运行时许可证。开发版许可证只认本机,部署机没有对应 license 文件,引擎会拒绝工作。
解决:部署时向授权方申请 runtime license,将 license 文件放到 HALCON 根目录的 license 文件夹下;或者把 license 文件内嵌到程序资源并在启动时写入临时目录。另外注意 runtime license 有功能范围和有效期,超出范围调用会报“算子不可用”。
5.3 现象:脚本里用了绝对路径,现场换目录就找不到图
HDevelop 里调试时写死了read_image ('D:/Images/part.png'),交付后现场路劲不一样,脚本执行到这一步直接报“文件不存在”。
原因:脚本里写死了文件系统路径,这不是 C# 侧的错,是脚本和业务耦合了路径。
解决:把read_image从脚本里删掉,图像统一由 C# 侧读好通过SetInputIconicParamObject传入;如果脚本里确实需要读固定文件,把路径也做外部参数,由 C# 侧配置传入。
5.4 现象:程序运行一晚上,内存占用不断上涨
第一晚值班,第二天看内存已经 1GB 以上,重启才好。定位发现每次视觉处理完,内存曲线都涨一小截。
原因:HObject对象未释放。具体到 HDevEngine 场景,call.GetOutputIconicParamObject拿到的区域、图像对象没有 Dispose;或者每次调用都 newHDevProgram却没用 using,脚本解析产生的临时对象堆积。
解决:给所有HObject和HDevProgramCall加上 using 或 try-finally 释放;开发阶段在每次视觉执行后打印GC.GetTotalMemory(false)做参考看曲线是否水平。
5.5 现象:多线程同时调用脚本时,结果数据串了
开了四个工作线程分别处理四条产线,跑着跑着发现这条产线的结果出现在另一条产线的记录里。
原因:多个线程共享了同一个HDevProgramCall实例,HALCON 引擎内部状态被并发访问污染。
解决:每个线程独立创建HDevProgramCall,最稳妥的写法是用[ThreadStatic]或线程本地存储缓存调用句柄,避免每次重新构建又想要性能。封装成上面第 4 章的Execute方法后,每次 new 一个 call,就不会串数据了。
6. 进阶技巧:把引擎调用包装成异步任务,并输出每步耗时
视觉算法跑在 UI 线程里会卡界面,产线上经常需要“触发一次拍照、后台处理、完成后回调”。把 HALCON 引擎调用塞进Task.Run里,是兼顾响应速度和代码简洁的常见做法。
public async Task<VisionResult> ExecuteAsync(HObject image, Dictionary<string, object> paramDict, CancellationToken token) { return await Task.Run(() => { token.ThrowIfCancellationRequested(); Stopwatch sw = Stopwatch.StartNew(); using (HDevProgram program = new HDevProgram(_scriptPath)) using (HDevProgramCall call = program.Call()) { call.SetInputIconicParamObject("InputImage", image); foreach (var kv in paramDict) call.SetInputCtrlParamTuple(kv.Key, new HTuple(kv.Value)); // 记录参数装载耗时 sw.Stop(); Log($"参数装载: {sw.ElapsedMilliseconds}ms"); sw.Restart(); call.Execute(); sw.Stop(); Log($"脚本执行: {sw.ElapsedMilliseconds}ms"); // 输出结果打包 token.ThrowIfCancellationRequested(); return new VisionResult { Area = call.GetOutputCtrlParamTuple("Area").D, Row = call.GetOutputCtrlParamTuple("Row").D, Column = call.GetOutputCtrlParamTuple("Column").D, Region = call.GetOutputIconicParamObject("ResultRegion") }; } }, token); }逻辑说明:Task.Run把引擎调用推到线程池,配合CancellationToken可以在用户取消拍照流程时提早退出,避免一个卡死的脚本拖住整个系统。Stopwatch分段计时能定位耗时瓶颈——是参数装载慢、脚本执行慢还是结果转换慢。
参数说明:Log方法建议写成一个独立的静态方法,内部把消息写到日志文件和调试窗口。分段计时的粒度可以更细,比如对脚本里每个关键算子,在 HDevelop 侧用count_seconds包起来,再通过控制参数输出耗时,这样能看到脚本内部每一步的性能。
如果要支持超时强制结束,Task.Run本身不能硬杀线程,更稳的办法是给 CancellationToken 加上超时时间,例如new CancellationTokenSource(TimeSpan.FromSeconds(5)).Token,超过 5 秒未完成就放弃本次结果。注意 HALCON 脚本一旦真正卡在某个算子上,ThrowIfCancellationRequested只在算子返回后才能生效,所以超时机制更多是保护业务流程,不能解决死循环脚本本身。
那以后我每次交付视觉项目,都会强制走一遍这套检查:DLL 是否齐备、license 是否部署、脚本路径是否外部化、每个 HObject 是否释放、每个线程是否独立 call。顺序一乱,现场就会用各种姿势翻车。HALCON 引擎本身很稳定,崩的基本都是调用方式的问题。希望这份拆解的踩坑记录能帮你少折腾几个通宵,直接落到能跑的代码上。
本文还有配套的精品资源,点击获取