仿VisionPro的Halcon视觉流程框架:WinForms插件化实现

仿VisionPro的Halcon视觉流程框架:WinForms插件化实现 简介这是一款面向机器视觉工程师与C#图像处理初学者的WinForm通用图像处理工具基于Halcon算法库构建解决传统图像处理软件学习门槛高、流程配置僵化、功能扩展困难等问题特别适用于视觉检测、尺寸测量、缺陷识别等工业场景快速原型开发。资源包共715个文件含398个C#核心逻辑文件.cs、114个本地化资源文件.resx、70个UI图标与界面素材.png、34个Halcon及第三方依赖DLL以及23个工程配置文件.csproj和3个解决方案文件.sln整体压缩后21.94MB结构清晰支持插件热加载与JOB流程可视化编排。已有229人学习下载。用户可直接运行主程序含10个.exe通过拖拽式VisionPro风格界面连接处理模块实现跨插件的参数与图像数据自动传递项目内置DockPanel布局、log4net日志、证书签名及多套预置BIN模型sgim_*.bin并提供完整构建脚本buildnuget.bat与配置模板App.config、DockPanel.config便于二次开发与功能拓展。 很多做视觉上位机的人应该都有这种感觉一旦用惯了 VisionPro 那种把工具块拖到界面上、用鼠标拉一条线就把数据从上一个工具传到下一个工具的操作方式再回到 Halcon 的 HDevelop 里纯写脚本总觉得少了点什么。Halcon 的算法能力确实强但流程编排这块完全靠代码现场调试的时候操作工看不懂换个需求就得改代码重新编译效率很低。所以我花了大概一个多月用 WinForms 基于 Halcon 做了一款通用的图像处理工具框架形态上就是仿照 VisionPro 的拖拉式工具流左侧是工具列表中间是流程设计面板工具之间通过连线传递值连成一条完整的 JOB 流程点击运行后工具按顺序执行数据在流程里流动。工具本身做成独立插件各自封装成 dll主程序启动时动态扫描加载以后新增算法工具不用动主程序直接放一个 dll 进去就能用。这篇文章我就把这个框架的设计思路、核心代码和踩坑记录完整写出来给同样想做 Halcon 二次开发、或者想把 VisionPro 那种交互体验搬到自家软件里的朋友一个参考。1. 为什么自己造这个轮子不是闲是真的有需要先聊动机。我是在一个自动化设备项目里被逼到这一步的当时产线上的视觉需求很杂今天测个位置明天换个产品要加测量后天又要加 OCR。每次需求一变就得动上位机源码重新编译再让电气工程师陪着调试光沟通成本就受不了。1.1 VisionPro 的拖线式开发香在哪VisionPro 最让我佩服的不是它的算法多牛而是它把“流程”这种抽象的东西变得特别直观。一个 Job 就是一条完整的视觉处理链路你把图像采集工具、模板匹配工具、卡尺测量工具一个个拖进去然后用连线把上一个工具的输出引脚接到下一个工具的输入引脚整个处理顺序和数据流向一目了然。Debug 的时候选一个工具单步执行中间结果图像直接显示在右边窗口哪个环节有问题一眼就能定位。这种设计带来的直接好处有两层。第一层开发者的调试效率高不用靠日志去猜某个中间变量是什么状态直接看图看数据。第二层它在开发者和现场人员之间建立了一种共同语言电气工程师和操作工也能看懂你这套流程里每一步在干什么。这种“流程可视化”的价值恰恰是 Halcon 以脚本为中心的 HDevelop 给不了的。1.2 自研方案的现实考量与收益当时摆在我面前的选择有几个直接买 VisionPro 授权成本不低而且我们整套设备的主控程序是 C# 写的集成没问题但后续在视觉工具上的二次开发空间会被套在康耐视的框架里。另一个选择就是完全用 Halcon 写代码硬扛算法没问题但前面说的流程编排痛点绕不过去。我自己评估下来最有性价比的做法是做一个“形似 VisionPro”的自家框架底层算法引擎用 Halcon界面和交互用自己的 WinForms 实现。Halcon 的优势就在算法库模板匹配、测量、Blob 分析、OCR 这些成熟得不能再成熟而且它提供 HalconDotNetC# 可以直接调用完全不用考虑跨语言的通信开销。WinForms 作为上位机界面的主力框架工业现场到处是它的身影成熟稳定资料多招人也好招。这套框架落地之后收益是实打实的。新来一个产品需求我在界面上拖几个工具连上线配好参数就能跑通流程不用改一行代码。现场要调整流程顺序把工具的连接关系重新拖一拖就行。开发新算法的时候我只需要写好一个新的工具插件丢进插件目录主程序一刷新就能看到新工具完全不影响已经写好的流程。2. 整体架构与核心模块设计这套框架我最看重的就是架构不能一上来就写死。工具之间的值传递、JOB 流程的运行必须抽象成一套稳定的机制否则插件化就是一句空话。2.1 三层架构界面层、流程引擎层、工具插件层整个框架分成三个层次各干各的活互相之间通过接口沟通不直接依赖。界面层就是主窗体负责三块区域的展示左侧是工具列表中间是流程设计画布右侧是图像显示窗口底部还有输出日志。这一层最核心的职责是“把用户的操作翻译成引擎能理解的数据”比如用户拖了一个工具到画布上界面层就调引擎创建一个工具实例用户画了一条连线界面层就告诉引擎这两个端口之间建立了数据绑定关系。流程引擎层是整套东西的中枢负责维护当前 JOB 里有哪些工具实例、工具之间的连接关系、数据容器、以及运行状态。它还负责把工具插件的执行逻辑串联起来按顺序跑完整个流程。引擎层不关心具体某个工具内部是怎么算的它只面向统一的工具接口编程。工具插件层是底层能力的集合每个工具就是一个独立的类库项目引用统一的工具接口定义实现自己的 Execute 逻辑。主程序运行时扫描插件目录把 dll 里所有实现工具接口的类都拉出来注册到工具列表里这就是插件化的落地。2.2 工具接口设计一个接口管住所有工具工具接口是整个插件化设计的地基我把接口定义得尽量稳定避免工具多了以后来回改。每个工具必须暴露以下信息工具名称、所属分类、描述信息、输入引脚列表、输出引脚列表以及最重要的执行方法。输入和输出引脚我设计成一组属性每个引脚有名字、数据类型和值。引脚之间通过引擎维护的连接关系来传递数据。这样工具开发者写插件的时候根本不需要关心数据是从哪来的只需要在 Execute 方法里从自己的输入引脚取值处理完把结果写到输出引脚就行。public interface IVisionTool { string ToolName { get; set; } string Category { get; } string Description { get; } ListToolPin InputPins { get; } ListToolPin OutputPins { get; } ToolResult Execute(IToolContext context); }ToolPin 是引脚的数据结构包含 PinName、PinType 和 PinValue 三个核心字段。PinType 用来声明这个引脚是什么类型的数据比如图像、坐标、测量值引擎在运行时可以根据类型做校验避免把毫不相干的数据接在一起。public class ToolPin { public string PinName { get; set; } public Type PinType { get; set; } public object PinValue { get; set; } }2.3 JOB 流程引擎的运行机制JOB 这个概念借鉴的就是 VisionPro一个 JOB 包含一组工具实例和它们之间的连线关系。我把 JOB 数据模型定义成工具列表加连接表一个 JOB 运行时就是按连接关系把工具串起来执行。引擎执行流程的时候核心循环很简单按顺序遍历工具列表对于每个工具先把它的输入引脚从数据容器里取出来然后调用工具的 Execute执行完再把输出引脚写回数据容器。这样工具之间没有直接的引用关系互相完全解耦。数据容器底层就是一个线程安全的字典键是引脚名称对应的全局路径。引擎还支持单步执行和断点暂停。单步调试的时候每一步执行完就把当前工具的输入输出快照显示在界面上这样现场排查问题的时候能很清楚地看到数据是在哪个环节断了、哪个环节算错了。引擎执行顺序 1. 遍历工具列表 2. 对每个工具根据连接表从数据容器读取输入引脚值 3. 调用 Execute 4. 把输出引脚值写回数据容器 5. 如果工具返回失败停止流程并标记失败工具我后来还加了一个“运行路径可视化”的功能在画布上当前正在执行的工具节点会高亮连线也会变色这样现场的人看着界面就知道流程跑到哪一步了很有 VisionPro 那个味儿。3. 工具间值传递与插件动态加载的实现细节这一部分是最能体现“仿 VisionPro”灵魂的地方也是我调试中踩坑最多的区域。值传递的机制直接决定了框架好不好用插件加载的机制则决定了扩展性上限。3.1 数据容器的设计共享变量区是灵魂我看过很多自己搭流程框架的人工具之间的数据传递直接用方法参数一层层传工具多了以后参数列表改一次整个依赖链全要动非常痛苦。我更推荐的做法是搞一个共享变量区就像 VisionPro 里的工具输入输出终端一样每个工具只和这个容器打交道。数据容器的核心是一个 Dictionary但直接用裸字典不安全我加了一层封装支持按路径读写、类型转换和存在性校验。比如图像采集工具输出一个路径是/Job/Image/CurrentImage的图像模板匹配工具就可以在连接表里声明自己的输入图像引脚绑定到这个路径上运行的时候引擎自动把值填进去。public class DataContainer { private readonly Dictionarystring, object _values new Dictionarystring, object(); public void SetValue(string path, object value) { _values[path] value; } public T GetValueT(string path) { if (_values.TryGetValue(path, out object val)) { return (T)Convert.ChangeType(val, typeof(T)); } return default; } public bool ContainsKey(string path) _values.ContainsKey(path); }用这种方式传递数据工具之间的耦合度降到最低。我后面再加新工具的时候只需要在新工具上声明输入输出引脚再在界面上把连线画好剩下的不用管。而且这个容器天然支持多线程不同 JOB 之间用不同的容器实例就行互不干扰。3.2 工具输入输出如何绑定有了数据容器接下来就是怎么把引脚和容器路径绑定起来。连接表里我记录的是一个三元组源工具 ID、源输出引脚名、目标工具 ID、目标输入引脚名。引擎运行到某个工具之前先扫描这个工具的所有输入连接把源输出引脚对应的容器路径读出来然后赋给目标输入引脚。这里有个关键细节图像对象这类大对象如果在容器里反复拷贝内存开销会很吓人。Halcon 的 HObject 底层是原生内存C# 托管对象只是句柄直接引用传递就行不需要深拷贝。我在容器的 SetValue 和 GetValue 里对 HObject 类型做了特别处理每个工具用完图像后不立刻释放而是等到整个流程结束统一释放防止前一个工具释放了图像句柄后一个工具还在用直接抛异常。为了直观表达连接关系我在界面层用一张表来维护用户拖线的时候就是在改这张表。源工具输出引脚目标工具输入引脚图像采集图像模板匹配输入图像模板匹配匹配行坐标卡尺测量搜索起始行模板匹配匹配列坐标卡尺测量搜索起始列3.3 插件动态加载用反射把算法封装成积木插件加载这块我用的是反射加特性标记的方式。主程序启动时扫描指定目录下的所有 dll查找里面标记了 ToolPluginAttribute 且实现了 IVisionTool 接口的类然后实例化并注册到工具列表面板。这样每次新增一个工具就是把编译好的 dll 丢进插件目录主程序甚至可以在运行过程中扫描到新插件并加载刷新。加载代码大概长这样public ListIVisionTool LoadToolsFromDirectory(string pluginPath) { var tools new ListIVisionTool(); foreach (var dllPath in Directory.GetFiles(pluginPath, *.dll)) { var assembly Assembly.LoadFrom(dllPath); foreach (var type in assembly.GetTypes()) { var attr type.GetCustomAttributeToolPluginAttribute(); if (attr ! null typeof(IVisionTool).IsAssignableFrom(type)) { var instance Activator.CreateInstance(type) as IVisionTool; tools.Add(instance); } } } return tools; }当然这里有个比较隐晦的问题插件 dll 和主程序都引用 HalconDotNet如果主程序编译用的 Halcon 版本和插件里的不一致运行时会报强命名签名验证失败之类的错误。这个坑我后面专门讲解决方法就是统一版本并且尽量在插件项目里引用和主程序同一份 HalconDotNet.dll。4. 实操从零搭一套最小可运行框架前面架构讲了一堆这一节我直接上代码从工具接口、模板匹配工具、流程引擎三段代码入手展示一个最小可运行框架是怎么拼起来的。别的先不说先把链路跑通再谈优化。4.1 工具接口与基类代码实现在实际项目中我不会让每个工具直接裸实现 IVisionTool那样代码重复度太高。我写了一个抽象基类 VisionToolBase把常用的输入输出引脚管理、参数配置界面入口都做了默认实现子类只需要重写 Execute 方法就行。public abstract class VisionToolBase : IVisionTool { public string ToolName { get; set; } 未命名工具; public virtual string Category 通用; public virtual string Description string.Empty; public ListToolPin InputPins { get; } new ListToolPin(); public ListToolPin OutputPins { get; } new ListToolPin(); protected T GetInputValueT(string pinName) { var pin InputPins.FirstOrDefault(p p.PinName pinName); if (pin null) throw new KeyNotFoundException($未找到输入引脚: {pinName}); return (T)pin.PinValue; } protected void SetOutputValue(string pinName, object value) { var pin OutputPins.FirstOrDefault(p p.PinName pinName); if (pin null) throw new KeyNotFoundException($未找到输出引脚: {pinName}); pin.PinValue value; } public abstract ToolResult Execute(IToolContext context); }基类里 GetInputValue 和 SetOutputValue 两个辅助方法特别重要工具开发者写自己的算法逻辑时整个注意力都在“取图像、做处理、输出结果”这条线上不用理引擎的调度细节。4.2 做一个模板匹配插件并接入模板匹配是视觉项目里最常用的工具我拿它当例子写一个插件类。这个工具做的事情是从输入引脚拿一张图像和模板文件名调用 Halcon 的 create_shape_model 和 find_shape_model 完成匹配然后把匹配到的位置坐标和分数写到输出引脚。[ToolPlugin(模板匹配工具, 定位)] public class ShapeModelTool : VisionToolBase { public override string Description 基于Halcon形状模板的定位工具; public ShapeModelTool() { InputPins.Add(new ToolPin { PinName 输入图像, PinType typeof(HObject) }); InputPins.Add(new ToolPin { PinName 模板文件, PinType typeof(string) }); OutputPins.Add(new ToolPin { PinName 匹配行坐标, PinType typeof(double) }); OutputPins.Add(new ToolPin { PinName 匹配列坐标, PinType typeof(double) }); OutputPins.Add(new ToolPin { PinName 匹配分数, PinType typeof(double) }); OutputPins.Add(new ToolPin { PinName 结果图像, PinType typeof(HObject) }); } public override ToolResult Execute(IToolContext context) { HObject inputImage GetInputValueHObject(输入图像); string modelFile GetInputValuestring(模板文件); HTuple modelId null, row, col, angle, score; HOperatorSet.ReadShapeModel(modelFile, out modelId); HOperatorSet.FindShapeModel(inputImage, modelId, -0.39, 0.79, 0.5, 1, 0.5, least_squares, 4, 0.9, out row, out col, out angle, out score); if (row.Length 0) { return ToolResult.Fail(未找到目标模板); } SetOutputValue(匹配行坐标, row[0].D); SetOutputValue(匹配列坐标, col[0].D); SetOutputValue(匹配分数, score[0].D); HObject resultImage; HOperatorSet.HomMat2dIdentity(out HTuple homMat2d); HOperatorSet.HomMat2dTranslate(homMat2d, row[0].D, col[0].D, out homMat2d); HOperatorSet.AffineTransContourXld(inputImage, out resultImage, homMat2d); SetOutputValue(结果图像, resultImage); return ToolResult.Ok(); } }这个插件编译成 dll 放进插件目录后主程序扫描到它就自动注册了。模板匹配工具运行需要的输入参数比如模板文件路径可以在它自己的配置界面上设置也可以由前面的工具输出绑定过来灵活性很高。4.3 流程引擎执行与画布交互引擎的 ExecuteJob 方法我非常精简遍历工具列表依次处理连接、执行、写回哪个工具失败就停止并返回那次失败的工具名。单个工具内部可能挺复杂但引擎本身越简单越不容易出错。public bool ExecuteJob(ListIVisionTool tools, ListToolConnection connections, DataContainer data) { foreach (var tool in tools) { // 1. 读取输入绑定 foreach (var conn in connections.Where(c c.TargetToolId tool.ToolName)) { string sourcePath $/{conn.SourceToolId}/{conn.SourcePinName}; object sourceValue data.ContainsKey(sourcePath) ? data.GetValueobject(sourcePath) : null; var targetPin tool.InputPins.First(p p.PinName conn.TargetPinName); targetPin.PinValue sourceValue; } // 2. 执行 var result tool.Execute(context); // 3. 写回输出 foreach (var outputPin in tool.OutputPins) { data.SetValue($/{tool.ToolName}/{outputPin.PinName}, outputPin.PinValue); } if (!result.IsSuccess) { return false; } } return true; }画布交互这块我用的是自绘控件每个工具画成一个圆角节点左边是输入端子右边是输出端子。鼠标按下端子开始拖线松开到目标端子上就建立连接。为了视觉上更直观我还在节点下面显示当前工具的运行状态正在执行的时候边框变绿出错变红。5. 常见问题与调试实录框架写完之后用了两个月遇到的坑比写代码的时间还多。下面这些是我认为最有价值的排错经验直接列成表格方便大家查阅。5.1 Halcon 与 WinForms 集成时的内存与显示坑Halcon 的 HObject 是托管外壳包原生内存最典型的坑是忘掉 Dispose跑一个班次下来内存蹭蹭往上涨。我一开始没当回事结果现场 8G 内存的工控机跑了两小时就卡死了。后来统一处理图像工具产生的 HObject 在流程结束后统一释放中间结果如果需要保留就复制一份。还有一个显示相关的问题HWindowControl 是 HalconDotNet 提供的控件它内部封装了窗口句柄如果在非 UI 线程操作它的显示方法很容易闪退或者花屏。我的做法是在引擎执行完一个工具后通过事件通知 UI 线程刷新显示不在后台线程里直接碰控件。现象原因解决办法内存持续增长HObject 未释放用 using 或 finally 统一释放流程结束清空容器界面闪退跨线程访问 HWindowControl用 Invoke 切回 UI 线程刷新显示插件加载报强名称错误插件和主程序 Halcon 版本不一致统一使用同一版本插件引用主程序输出目录的 HalconDotNet5.2 插件加载失败排查思路插件加载失败是动态加载机制里最常遇到的问题。反射加载 dll 的时候如果插件依赖的某个程序集在主程序目录里找不到通常不会在 LoadFrom 的时候立刻报错而是等 Activator.CreateInstance 的时候抛 FileNotFoundException这个错误信息很容易误导人一眼看过去像是代码写错了。我的排查步骤是先用 Assembly.LoadFrom 把 dll 加载进来再用 Assembly.GetReferencedAssemblies 输出它的所有依赖项逐个检查主程序目录里有没有。后来为了省事我干脆把所有插件项目都输出到同一个目录并且强制复制依赖项到输出目录彻底规避了这个问题。另外如果插件项目用了 .NET Framework 4.8 而主程序是 4.7.2也会因为目标框架版本不一致导致加载被拒绝这种错误一般都发生在应用层报错信息是 MissingMethodException 或者 BadImageFormatException看起来和实际原因八竿子打不着。5.3 值传递错误中的典型场景值传递这个环节最容易出问题的不是类型不匹配而是图像对象的生命周期。我做第一步调试的时候把模板匹配的结果图像写进容器紧接着一个测量工具读取这张结果图像做进一步测量中间我的容器存储的是 HObject 引用结果测量工具一读发现图像句柄已经失效直接报 Invalid handle。原因就是容器里存储 HObject 引用时如果前一个工具在输出后把局部变量 Dispose 了引用就悬空了。解决办法有两个一个是约定输出图像不能随意释放由流程结束统一释放另一个是在写入容器时用 HObject.Clone 复制一份代价是多占内存。我更倾向第一种约定清晰性能也好。还有一类问题是线程安全。如果把两个 JOB 放在两个线程里同时跑DataContainer 不加锁的话字典并发写会直接抛异常。我后来把容器的读写方法内部都加了锁虽然有一点性能损耗但工业现场这种规模的数据量完全无所谓稳定压倒一切。5.4 现场部署时必须注意的几个环境细节部署到工控机的时候有几个点很容易翻车。Halcon 的 runtime 必须跟着程序一起装很多人开发机上跑得好好的一到现场就报找不到 HALCON 动态库就是因为忘了装 runtime。另外 Halcon 的 license 文件要放到固定位置环境变量 HALCONROOT、HALCONLICENSE 这些最好由安装向导统一配好别让现场工程师手动配。WinForms 在高 DPI 显示器上字体模糊也算一个老生常谈的问题。现场工控机经常接 1080p 或者更高分辨率的屏幕如果不对 DPI 做适配工具节点上的文字会发虚连线也对不准。解决办法是在主窗体的 app.manifest 里声明 PerMonitorV2 V2 支持然后所有坐标绘制都基于缩放因子换算。还有就是 Halcon 操作符在被频繁调用的时候比如 find_shape_model 每一帧都要跑建议在工具内部缓存 modelId而不是每次 Execute 都 ReadShapeModel。我记得有一次优化前一次匹配要 50 毫秒把模型加载移到初始化阶段后直接降到 15 毫秒这个优化幅度在实时视觉项目里是非常可观的。6. 实践中的优化与扩展方向框架跑顺以后我开始琢磨怎么让它更好用陆续加了几个很有价值的功能。单步执行、断点、数据断线颜色提示这些交互层面上的优化实际效果比我预想的好得多。6.1 流程调试体验优化断点与中间结果快照我加了一个断点功能用户可以在任意工具节点上右键设置断点运行到断点时流程暂停右侧图像窗口显示这个工具当前的输入图像下方列表显示它所有输入输出的实值。这个过程对现场调试特别有用操作工拍了一张怀疑有问题的图像我可以直接断在定位工具上看匹配结果马上知道是抓拍时机不对还是产品本身异常。实现断点也不复杂在 ExecuteJob 循环里加一个判断当前工具设置了断点就触发 Pause 事件等 UI 线程确认继续后再接着跑。这个机制用了很久稳定性很好。6.2 工具执行耗时统计与性能瓶颈定位对视觉系统来说节拍就是生命。我给引擎加了一组性能埋点每个工具 Execute 开始前记录时间戳结束后计算耗时写到日志和界面上。这样就能非常直观地看到整条流程的瓶颈在哪个工具上是采集慢、定位慢还是测量慢。有一次我优化一条 JOB从界面上看到图像采集工具占掉 40 毫秒模板匹配占 25 毫秒测量反而只要 5 毫秒于是我回头去优化采集逻辑改成异步采集加软触发整条流程节拍从 90 毫秒压到了 55 毫秒效率提升了一倍。没有性能统计的话这 35 毫秒的优化空间很难定位得这么准。这个框架目前的形态我已经很满意工具插件化、JOB 可视化、数据流自动绑定这几个核心目标都实现了。模块化之后新来的同事只需要照着模板写一个工具类编译完放到插件目录里画布上就能用上手成本比原来低很多。如果后续再扩展我会考虑加一个脚本工具允许用户在工具内部写一小段 C# 表达式来做自定义数据转换把场景适配能力再往上提一档。本文还有配套的精品资源点击获取