简介:VisionAndMotionPro 是一套基于 Halcon 与 C# 联合开发的拖拉式视觉检测平台源码,面向机器视觉初学者、工控软件开发者及需要快速搭建检测流程的工程师。它解决的核心问题是:无需编写代码,通过图形化界面拖放视觉任务模块即可定制检测流程,适用于电子元器件、包装印刷、汽车零部件等生产线的质量控制场景。资源包共约 2000 个文件,以 866 个 cs 源码、270 个 dll 动态库、185 个 resx 资源文件、65 个 csproj 工程文件为主,另含 png 图标、config 配置、sln 解决方案等,压缩包整体约 90.29MB,工程结构完整。目前已有 420 人学习下载。源码涵盖用户界面交互、工作流程管理、Halcon 接口调用与结果展示等模块,读者可借此理解视觉平台架构、插件式相机接入与任务依赖处理方式,并在此基础上扩展定制,提升 Halcon 与 C# 结合开发的实战能力。
1. 从一堆.cache文件说起:VisionAndMotionPro 到底是个什么资源
如果你拿到过 VisionAndMotionPro 的源码包,第一反应大概率是懵的——解压出来满屏都是DesignTimeResolveAssemblyReferences.cache、Plugin.Cameras.Hikvision.csprojAssemblyReference.cache、Plugin.MeasureLine.csprojAssemblyReference.cache这类文件,看着像编译垃圾,实际上它们暴露了整个平台的骨架:这是一个按插件拆分的 Halcon + C# 视觉检测平台,相机接入、直线测量、椭圆拟合、文字创建、图像显示各自独立成工程。它解决的不是"从零写一个 Halcon 程序"的问题,而是"怎么把 Halcon 的算子能力包装成一套可拖拽、可配置、可扩展的检测流程"。适合两类人:一类是做 C# 上位机、想把手眼标定、找圆、找直线这些 Halcon 测量能力产品化的工程师;另一类是接过产线视觉项目、被"每换一个检测项就重写一遍代码"折磨过的开发者。拖拉式流程编排是它的核心卖点,也是这份源码最值得拆的地方。
2. 插件化架构拆解:从 csproj 命名反推模块边界
2.1 为什么用插件而不是一个大工程
工业视觉项目最怕的不是算法难,而是需求变。今天测直线,明天加个椭圆,后天客户要换海康相机。如果所有逻辑塞在一个 Form 里,改一处就得全量回归。VisionAndMotionPro 的做法是把每个检测能力做成独立插件工程,从缓存文件名能直接读出模块划分:Plugin.Cameras.Hikvision、Plugin.Cameras.Basler负责相机接入,Plugin.MeasureLine、Plugin.MeasureEllipse负责几何测量,Plugin.CreateText负责在图像上叠加文字,Plugin.ShowImage负责显示。这种拆法的好处是相机换品牌只动相机插件,测量加算法只加测量插件,主程序只认接口不认实现。
常见做法是定义一个插件契约接口,所有插件实现它,主程序通过反射或配置加载。下面是一个典型的插件接口定义,我一般会这样写:
// 插件契约:所有视觉任务插件必须实现此接口 public interface IVisionPlugin { // 插件显示名称,用于拖拽面板展示 string PluginName { get; } // 插件分类:相机/测量/显示/工具 string Category { get; } // 执行入口,输入图像,输出结果对象 PluginResult Execute(HImage input, Dictionary<string, object> parameters); // 参数定义,供属性面板动态生成 UI List<PluginParameter> GetParameters(); } // 参数描述:让主程序知道该给插件配什么控件 public class PluginParameter { public string Name { get; set; } // 参数名 public Type ValueType { get; set; } // 类型:double/int/string/bool public object DefaultValue { get; set; }// 默认值 public string Description { get; set; } // 提示文字 }这段代码的关键在于GetParameters()返回参数描述列表,主程序的属性面板据此动态生成输入框,而不是每个插件写死一个配置窗体。Execute的入参用HImage而不是Bitmap,是为了避免在插件内部反复做格式转换——Halcon 的HImage和 .NET 的Bitmap互转是有开销的,尤其在连续采图场景下。参数用Dictionary<string, object>传递,灵活但要注意类型转换时的装箱拆箱,高频调用时建议改成强类型参数对象。
2.2 插件加载与拖拽流程的落地步骤
拖拽式设计的本质是:左侧工具箱列出所有已加载插件,用户拖到画布上生成一个节点,节点连线定义执行顺序。落地分三步。
第一步,扫描插件目录并加载程序集。我一般会约定插件 DLL 放在主程序同级的Plugins文件夹:
// 扫描 Plugins 目录,加载所有实现 IVisionPlugin 的类型 public List<IVisionPlugin> LoadPlugins(string pluginDir) { var plugins = new List<IVisionPlugin>(); foreach (var dll in Directory.GetFiles(pluginDir, "*.dll")) { var asm = Assembly.LoadFrom(dll); foreach (var type in asm.GetTypes()) { // 只挑实现了接口且非抽象的类型 if (typeof(IVisionPlugin).IsAssignableFrom(type) && !type.IsAbstract) { plugins.Add((IVisionPlugin)Activator.CreateInstance(type)); } } } return plugins; }这里有个坑:Assembly.LoadFrom加载的 DLL 如果依赖 Halcon 的halcondotnet.dll,而主程序已经加载了另一个版本的 Halcon 运行时,会抛FileLoadException。解决办法是统一 Halcon 版本,或者把插件和主程序放在同一目录让 CLR 自动解析依赖。Activator.CreateInstance要求插件有无参构造函数,如果插件需要注入相机管理器之类的服务,得改成依赖注入容器或工厂模式。
第二步,拖拽生成节点。WinForms 里用DoDragDrop发起拖拽,画布控件处理DragDrop事件创建节点对象。WPF 则用DragDrop.DoDragDrop配合Adorner做拖拽预览。节点对象持有插件实例和参数值,连线用贝塞尔曲线绘制,数据结构上是一个有向图。
第三步,执行流程。按拓扑排序遍历节点,前一个节点的输出图像作为后一个节点的输入。这里要注意 Halcon 的HImage是引用类型,多个节点共享同一张图时,如果某个节点内部调用了会修改图像的操作(比如ReduceDomain),会影响后续节点。稳妥做法是每个节点执行前CopyImage一份,代价是内存翻倍,产线节拍紧的话要权衡。
3. Halcon 与 C# 的接口层:HImage 生命周期与算子调用
3.1 HImage 与 Bitmap 互转的正确姿势
Halcon 的 .NET 接口里,HImage是核心图像容器,但 WinForms 的PictureBox只认Bitmap,所以互转是绕不开的。常见做法是用HOperatorSet的GenImage1或GetImagePointer1做零拷贝转换,但新手容易写成逐像素循环,一张 500 万像素的图能卡几百毫秒。
// Bitmap 转 HImage:用指针避免逐像素拷贝 public HImage BitmapToHImage(Bitmap bmp) { // 锁定位图内存,得到首地址和步长 var data = bmp.LockBits(new Rectangle(0, 0, bmp.Width, bmp.Height), ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb); try { // GenImage1 直接引用内存,注意这里没有拷贝 HImage img = new HImage(); HOperatorSet.GenImage1(out img, "byte", bmp.Width, bmp.Height, data.Scan0); return img; } finally { bmp.UnlockBits(data); // 必须解锁,否则 Bitmap 后续无法访问 } }参数说明:GenImage1的第三个参数是像素类型,工业相机常见的是byte(8 位灰度)或uint2(16 位)。data.Scan0是内存首地址,GenImage1只是引用这块内存,不复制。这意味着UnlockBits之后如果 Bitmap 被 GC 回收,HImage 指向的就是野内存。血泪经验:转换后要么立刻CopyImage,要么保证 Bitmap 生命周期覆盖 HImage 的使用周期。反过来 HImage 转 Bitmap 用GetImagePointer1拿指针再Marshal.Copy,同样要注意步长对齐——Halcon 的图像行是连续存储的,而 Bitmap 可能有 padding。
3.2 测量类算子的参数怎么设
Plugin.MeasureLine和Plugin.MeasureEllipse这两个插件对应的是 Halcon 的卡尺测量。以找直线为例,核心算子链是GenMeasureRectangle2生成测量矩形,MeasurePos执行边缘提取,FitLineContourXld拟合直线。参数设不对,结果就是玄学——同一张图两次跑出不同角度。
// 卡尺找直线:先定义测量矩形,再提取边缘点拟合 public void MeasureLine(HImage img, out HTuple row1, out HTuple col1, out HTuple row2, out HTuple col2) { // 测量矩形:中心行、列、角度、半宽、半高 HOperatorSet.GenMeasureRectangle2(300, 400, 0, 100, 20, img.Width, img.Height, "nearest_neighbor", out HTuple measureHandle); // 边缘提取:Sigma 越大越抗噪但定位越粗,Threshold 是梯度阈值 HOperatorSet.MeasurePos(img, measureHandle, 1.0, 30, "all", "all", out HTuple edgeRow, out HTuple edgeCol, out HTuple amp, out HTuple dist); // 拟合直线:'tukey' 加权能抑制离群边缘点 HOperatorSet.FitLineContourXld(edgeRow, edgeCol, "tukey", -1, 0, 5, 2, out row1, out col1, out row2, out col2); }参数逐个说:Sigma是高斯平滑系数,设 1.0 适合边缘清晰的金属件,磨砂面或低对比度场景要加到 1.5 到 2.0,但超过 2.0 边缘位置会漂移。Threshold是梯度幅值阈值,30 是经验起点,太低会把噪声当边缘,太高会漏掉弱边缘。"all"表示正负边缘都提取,如果只想要亮到暗的过渡,改成"negative"。FitLineContourXld的"tukey"是鲁棒拟合,能自动降低离群点权重,比最小二乘稳。最后一个参数 2 是最大迭代次数,一般不用改。找圆的参数逻辑类似,FitCircleContourXld替换拟合算子即可,但圆的卡尺矩形要沿径向布置,角度参数得按圆周方向算。
4. 避坑与排查:源码跑不起来时先看这几条
4.1 现象:编译报错找不到 halcondotnet.dll
原因:Halcon 的 .NET 程序集不在系统 GAC 里,也不在 NuGet 上,必须手动引用。源码包里通常只带了 csproj,没带 Halcon 安装目录的 DLL。
解决:先确认本机装了 Halcon,找到安装目录下的bin\dotnet35或bin\dotnet文件夹,把halcondotnet.dll复制到主程序输出目录,或者在 csproj 里用 HintPath 指向绝对路径。注意 Halcon 版本要和源码匹配,用 Halcon 20.11 的 DLL 去跑基于 12.0 写的代码,算子签名可能对不上。
4.2 现象:插件加载后拖拽面板是空的
原因:Assembly.LoadFrom加载的插件 DLL 依赖的 Halcon 版本和主程序不一致,CLR 静默跳过了加载失败的类型,GetTypes()抛ReflectionTypeLoadException但被 catch 吞了。
解决:在加载循环里加日志,把ReflectionTypeLoadException.LoaderExceptions打出来。常见做法是统一所有工程的 Halcon 引用版本,或者用AppDomain.AssemblyResolve事件手动指定依赖路径。
4.3 现象:连续采图时内存持续上涨
原因:HImage是 Halcon 的非托管对象,C# 的 GC 管不到它。每次new HImage()不释放,Halcon 的句柄就泄漏。
解决:所有HImage、HRegion、HMeasure用完必须Dispose(),或者用using包裹。测量句柄measureHandle尤其容易漏,GenMeasureRectangle2每调用一次就占一个句柄,循环里不释放,跑几千次就崩。我一般会在插件基类里统一管理资源释放,Execute结束前把临时对象清干净。
4.4 现象:拖拽连线后执行顺序不对
原因:节点图没有做拓扑排序,直接按添加顺序执行,遇到分支或汇聚就乱套。
解决:执行前先做一次拓扑排序,检测有没有环。有环说明用户连线形成了循环依赖,要弹提示而不是死循环。常见做法是用 Kahn 算法,入度为 0 的节点先入队,逐个出队并减少后继节点入度。
4.5 现象:Halcon 算子报 "Wrong image width"
原因:GenImage1转换时 Bitmap 的PixelFormat不是 24bppRgb,比如用了 32bppArgb,导致步长和 Halcon 预期不符。
解决:转换前统一new Bitmap(src)强制转成 24bppRgb,或者根据实际格式调整GenImage1的像素类型参数。工业相机出来的图常见是 8 位灰度,对应PixelFormat.Format8bppIndexed,这种要单独处理调色板。
5. 进阶:把插件流程导出成可复用的检测方案
源码跑通之后,真正提升效率的是把调好的流程存下来。VisionAndMotionPro 的拖拉式设计如果只停留在界面上,每次开机重拖一遍就白瞎了。我一般会加一个方案序列化功能,把节点图存成 JSON,包含插件类型名、参数值、连线关系,下次直接反序列化重建。
// 方案序列化:节点存类型名和参数,连线存索引对 public class FlowScheme { public List<NodeData> Nodes { get; set; } public List<LinkData> Links { get; set; } } public class NodeData { public string PluginType { get; set; } // 程序集限定名,反序列化时反射创建 public Dictionary<string, object> Parameters { get; set; } public double X { get; set; } // 画布坐标,用于还原布局 public double Y { get; set; } } public class LinkData { public int FromIndex { get; set; } // 源节点在 Nodes 中的下标 public int ToIndex { get; set; } }存PluginType用程序集限定名而不是简单类名,是为了避免不同插件里有同名类时反射创建错对象。参数值用Dictionary<string, object>存,JSON 序列化时object会丢类型信息,反序列化回来全是JsonElement,所以更稳的做法是存字符串,加载时按PluginParameter.ValueType转回去。连线存下标而不是节点 ID,是因为节点顺序在序列化时已经固定,下标更省空间,但要注意加载后如果用户删了节点,下标会错位,所以重建时要先建节点再建连线。
验证方案是否可复用的方法很直接:存一份,重启程序,加载,跑同一张测试图,对比测量结果的小数点后三位。如果对不上,八成是某个参数没序列化全,或者浮点数精度在 JSON 往返中丢了。我习惯在方案里加一个版本号字段,插件参数结构变了就升版本,加载时做兼容转换,免得旧方案打不开。
从那以后我每次调完一个检测流程,第一件事就是导出方案 JSON 并跑一遍回归验证,确认参数落盘无误才敢关机。希望帮到你。
本文还有配套的精品资源,点击获取