资讯动态

Halcon与C#联合编程实战:硬币识别项目全流程解析

发布时间:2026/10/5 0:22:49 来源:尧图企业网站定制
做视觉项目最挡路的往往不是文档看不懂而是Halcon和C#联合编程时代码不知道往哪放。我刚开始练手时选的就是硬币识别目标明确、特征干净一套流程下来能把图像预处理、区域筛选、界面集成全走通比看一堆零散算子教程有用得多。这篇文章就是我当时练习过程的完整复盘包含Halcon侧算法思路、C#集成方案对比、调参经验以及几个真正会卡住新人的坑。如果你也想用Halcon和C#联合编程做一个能运行的图像识别小程序这篇应该能帮你少走不少弯路。1. 为什么选硬币识别以及联合编程环境的正确搭建1.1 Halcon负责图像处理C#负责什么很多新手容易误解联合编程这个词以为Halcon和C#都在做算法。实际上两者的分工非常清楚Halcon负责图像处理链也就是读图、滤波、分割、特征提取、拟合圆这些视觉算法C#负责业务逻辑比如窗体界面、按钮交互、显示结果、保存数据、将来连数据库或上位机。硬币识别这个项目恰好能把分工体现出来。Halcon处理的是图像里有哪些圆形物体、半径多少、中心在哪C#处理的是把这些结果展示给用户、让用户点按钮选择图片、最后输出检测报告。如果只用HDevelop写脚本算法再漂亮也无法做成一个别人能用的软件如果只用C#不接Halcon那图像算法部分就得用OpenCV从零写成本高得多。联合编程的收益在于算法迭代在HDevelop里实时调界面和流程则留在C#里。1.2 环境准备版本选择与halcondotnet引入做这个项目我用的是Halcon 23.05 Steady版本Visual Studio 2019WinForms项目目标框架.NET Framework 4.7.2。这个组合是当时最稳的社区里能找到的案例也基本是这套。安装Halcon之后真正关键的一步是找到两个东西halcondotnet.dll这是C#调用Halcon的核心程序集通常位于安装目录的bin\dotnet4或bin\dotnetstandard下。HSmartWindowControl控件Halcon提供的图像显示控件在VS工具箱里右键选择项浏览到halcondotnet.dll后勾选即可。这里有一个很容易踩的版本坑如果你的C#工程是.NET Framework 4.x引用bin\dotnet4下的dll如果是.NET Core或.NET 5以上必须引用bin\dotnetstandard下的版本。我最初图省事直接拖了dotnet4的dll到.NET 6工程里编译能过但一运行就报程序集加载异常。另外Halcon现在基本都是64位VS项目的平台目标设置为x64不然会遇到BadImageFormatException。这两点在环境搭建阶段就要确认好不然后面每跑一步都在怀疑人生。1.3 测试图怎么准备硬币识别的测试图不需要专门找数据集。我当时用了两种方式一是自己手工做几张和背景反差明显的硬币图比如把几枚硬币放在深色吸光布上用手机固定高度拍摄再转成灰度存到本地二是用HDevelop自带的示例图安装目录下有一些类似硬币、元件的图可以临时用来跑流程。入门练习我建议先不要急着接相机。相机采集涉及GigE Vision/USB3 Vision协议、触发模式、曝光时间这些和图像算法是两码事。先拿一张固定的图片跑通检测流程确认算法稳定后再去想实时采集。起步阶段每引入一个变量排查问题难度就会翻一倍。2. Halcon侧图像处理如何把硬币从照片里“抠”出来2.1 读图、灰度化与滤波硬币识别在Halcon侧的第一步是读图。如果你的测试图是彩色图需要先转成灰度图因为后面的阈值分割、边缘提取都建立在单通道灰度之上。HDevelop里最基础的流程是read_image (Image, D:/coins/coins_01.png) rgb1_to_gray (Image, GrayImage) median_image (GrayImage, ImageMedian, circle, 3, mirrored)rgb1_to_gray没太多可说的彩色转灰度是标准操作。重点说median_image中值滤波。硬币表面并不是干净的金属色上面有花纹、反光、磨损痕迹相机传感器也会引入噪点。直接用原图做阈值分割容易出现碎块和毛刺。中值滤波能在保留边缘的同时去掉孤立噪点是硬币这个场景性价比很高的预处理手段。半径参数3表示滤波窗口半径窗口越大图像越平滑但边缘也被抹得越厉害一般不要超过5。这里有个易忽略的细节滤mirrored边缘处理方式在图像边界补像素时比较好不会在边缘位置产生额外的灰度跳变。入门阶段先记住这个参数不用深究。2.2 阈值分割把硬币从背景中分离阈值分割是硬币识别最核心的一步目标是让硬币所在的像素区域和背景彻底分开。threshold (ImageMedian, HardRegion, 0, 128)这行代码的意思是把灰度值在0到128之间的像素划分成一个区域。但问题很快就会出现硬币表面的高光部分灰度可能超过128背景暗区的灰度可能低于128固定的上下限很难覆盖所有情况。我当时用一张背景深浅不均匀的图试阈值设低了硬币中间漏成筛子设高了背景纹理全进来了。比较实用的替代方案是dyn_threshold动态阈值它不直接对灰度绝对值设定界限而是让每个像素和它周围邻域的平均灰度做比较mean_image (ImageMedian, ImageMean, 21, 21) dyn_threshold (ImageMedian, ImageMean, RegionDyn, 12, dark)这样做的原理很简单背景光照变化通常是比较缓慢的渐晕硬币相对周围局部区域会有明显的灰度差异。mean_image算出一个局部背景估计dyn_threshold再去判断哪些像素比局部背景“暗得足够多”。光照不均时动态阈值比固定阈值稳得多。硬币识别这种场景默认先试dyn_threshold不行再退回手动调threshold。2.3 连通域筛选和圆拟合阈值分割后的区域往往是杂乱无章的包含硬币区域、背景噪点、甚至桌子纹理。下一步是把它拆成一个个独立对象再根据特征筛掉不想要的。connection (RegionDyn, ConnectedRegions) select_shape (ConnectedRegions, SelectedRegions, [area, circularity], and, [3000, 0.4], [99999, 1.0])connection把区域中互不连通的像素块拆分成独立区域。这一步很直观每个连通块对应图像里的一个物体候选。select_shape则是按特征过滤我选了两个特征面积area和圆形度circularity。面积过滤是去掉图中小噪点圆形度过滤是为了丢掉和硬币形状差异大的物体。圆形度越接近1形状越接近标准圆。但像素级区域的圆形度在边缘锯齿影响下不会特别理想而且硬币的精确直径、圆心坐标在像素区域上算不出高精度值。所以更严谨的做法是在选出的区域上提取亚像素轮廓再拟合圆reduce_domain (ImageMedian, SelectedRegions, ImageReduced) edges_sub_pix (ImageReduced, Edges, canny, 1.5, 20, 40) select_contours_xld (Edges, SelectedContours, contour_length, 200, 99999, -0.5, 0.5) fit_circle_contour_xld (SelectedContours, algebraic, -1, 0, 0, 3, 2, Row, Column, Radius, StartPhi, EndPhi, PointOrder)edges_sub_pix用Canny算子提取亚像素边缘。这里的20和40分别是滞后阈值的低值和高值含义是梯度大于40的像素确定是边缘低20但和高梯度边缘相连的像素也算边缘。select_contours_xld按轮廓长度筛掉过短的碎轮廓fit_circle_contour_xld用代数距离最小化拟合圆输出每枚硬币的圆心坐标和半径。到这一步Halcon侧已经能输出一组圆心和半径了。我在HDevelop里用disp_circle把拟合结果叠在原图上直观看到了圆圈完整包围住硬币边缘整个流程算是跑通了。3. C#集成Halcon的三种路子入门选哪一种3.1 HDevelop导出C#代码Halcon集成C#最直接的方式是HDevelop里文件-导出语言选C#。导出的.cs文件里包含一个类类里每个函数对应你在HDevelop里写的脚本流程。这个方式的优点非常明显脚本里已经调好的参数、算子顺序全都在C#侧只需要调用类方法就行。比如我在HDevelop里把流程封装成一个名为CoinRecognition的过程输入是图像对象输出是半径数组和圆心坐标数组。导出后在C#里这样用using HalconDotNet; HDevelopExport hdev new HDevelopExport(); HObject image new HObject(); HOperatorSet.ReadImage(out image, D:\coins\coins_01.png); HTuple radius, row, col; hdev.CoinRecognition(image, out row, out col, out radius); for (int i 0; i radius.Length; i) { Console.WriteLine($圆心:({row.DArr[i]:F1},{col.DArr[i]:F1}) 半径:{radius.DArr[i]:F2}); } image.Dispose();如果只是把Halcon当成一个算法库用导出方式是性价比最高的。但它的缺点也很明显每次调完参数都要重新导出覆盖代码稍微改一点逻辑就得来回切窗口。3.2 HDevEngine动态调用脚本HDevEngine是Halcon提供的脚本解释引擎它的思路不是生成C#代码而是让C#程序在运行时直接加载并执行HDevelop脚本文件。脚本改动后不需要重新导出改完保存C#程序重启或重新加载脚本就生效。使用HDevEngine的第一步是配置脚本路径然后通过过程调用对象传入图像、取出结果using HalconDotNet; using HalconDotNet.HDevEngine; HDevEngine engine new HDevEngine(); engine.SetProcedurePath(D:\halcon_procedures); HDevProcedure proc new HDevProcedure(coin_recognition); HDevProcedureCall call proc.CreateCall(); HObject image new HObject(); HOperatorSet.ReadImage(out image, D:\coins\coins_01.png); call.SetInputIconicParamObject(Image, image); call.Execute(); HTuple radius call.GetOutputCtrlParamTuple(Radius);使用HDevEngine时有一个前置操作很容易漏在HDevelop里写脚本时必须把Image定义成输入参数把Radius定义成输出参数不能直接用read_image写死在脚本里。也就是说脚本入口要这样定义* 在HDevelop的参数配置窗口中设置 * 输入参数: Image (图像对象) * 输出参数: Radius (控制参数)HDevEngine适合算法和业务逻辑分离的场景。我当时在验证阶段用了它因为可以在不重编译C#的情况下反复调阈值和筛选参数节约了大量时间。坏处是发布时需要把脚本一起打包而且脚本里的算子如果依赖某些特定版本Halcon换了环境容易出问题。3.3 直接使用halcondotnet算子封装第三种方式是在C#里直接用HOperatorSet类调用各个Halcon算子HObject image, grayImage, region; HOperatorSet.ReadImage(out image, D:\coins\coins_01.png); HOperatorSet.Rgb1ToGray(image, out grayImage); HOperatorSet.Threshold(grayImage, out region, 0, 128);这种写法本质上就是用C#复述HDevelop脚本。它的好处是逻辑完全透明断点调试方便每一步结果都能在VS里直接看到。坏处是代码量大、可读性差同样一段流程在HDevelop里只要6行在C#里写出来要20多行而且类型转换、HObject释放都要自己处理。3.4 我的选择导出手动封装三种方式我最后都用了一遍但正式作为练习项目保留下来的是第一种HDevelop导出然后在C#里把导出的类封装成一个服务类。选择理由是这是入门阶段能最快看到成果的方式。HDevelop里调好流程导出C#代码然后在WinForm按钮里调用20分钟内就能看到界面上的结果。HDevEngine虽然灵活但脚本参数定义、路径配置这些对新手来说又多了一层复杂度直接用halcondotnet写算子更是容易陷进“翻译代码”里反而忘了算法本身。封装思路很简单导出的HDevelopExport实例作为核心处理对象自定义类里再包一层接口public class CoinDetector { private HDevelopExport _hdev new HDevelopExport(); public ListCoinInfo Detect(string imagePath) { HObject image new HObject(); HOperatorSet.ReadImage(out image, imagePath); HTuple row, col, radius; _hdev.CoinRecognition(image, out row, out col, out radius); ListCoinInfo coins new ListCoinInfo(); for (int i 0; i radius.Length; i) { coins.Add(new CoinInfo(row.DArr[i], col.DArr[i], radius.DArr[i])); } image.Dispose(); return coins; } }这样界面层只关心“传入图片路径、拿到List ”不需要懂Halcon。将来想换HDevEngine方案只要改Detect内部实现界面代码完全不用动。4. 硬币识别真正的难点反光、粘连和参数漂移入门跑通流程不难难的是让它“稍微换个环境还能稳定工作”。这个项目里我最常遇到的三类问题反光导致边缘断裂、硬币相互粘连、光照改变后同一个阈值不再适用。4.1 反光和光照不均的根因与局部阈值硬币识别最大的敌人不是背景复杂而是硬币自身的曲面反光。硬币不是平面表面有弧度在光照下某些区域会特别亮这些高光区域的灰度值可能和背景接近阈值分割时硬币边缘在高光处会“断掉”。用固定阈值threshold时3000这个面积和圆形度约束根本救不了断边因为断裂后硬币区域直接分裂成了两半或多块。解决思路有两个维度第一个维度是在预处理上做文章。中值滤波窗口加大、或者先做一次illuminate_correction类型的光照校正让灰度分布更平缓。第二个维度是换成分割算法使用动态阈值dyn_threshold它的本质是用局部背景做参照而不是用全局低灰度值切分所以边缘高光处的局部差异仍然存在。mean_image (ImageMedian, ImageMean, 31, 31) dyn_threshold (ImageMedian, ImageMean, RegionDyn, 12, dark)窗口大小31需要按硬币在图像中占的尺寸来估。硬币直径大约是150像素左右窗口选取直径的一半左右效果比较稳。如果窗口太小硬币内部大面积同色区域会被误判成背景如果太大局部背景估计又变得和全局阈值一样失去意义。4.2 边缘断裂和内部空洞的补救动态阈值能解决大部分高光断边问题但硬币表面的图案、字纹也可能在阈值分割后留下内部空洞。比如菊花图案、国徽凹凸处反光不同这些位置在二值图里表现为一块块黑色孔洞。处理这类问题的方法是用形态学闭运算closing_circle先膨胀再腐蚀把内部的小孔洞填掉同时保持整体轮廓不变化太大。或者直接在筛选阶段对区域做fill_up把所有封闭孔洞填充为实心closing_circle (RegionDyn, RegionClosed, 5.5) fill_up (RegionClosed, RegionFilled)如果追求更精确的圆拟合还可以不从区域走改从边缘走先对硬币区域生成亚像素轮廓再用fit_circle_contour_xld拟合圆。拟合圆时只要边缘轮廓超过180度圆心和半径通常不会因为内部空洞受太大影响。这是比区域后处理更稳妥的思路。硬币表面反光还会导致一个问题边缘轮廓在局部地方会出现一条“假边”也就是侧光造成的阴影线。这种情况下收紧edges_sub_pix的滞后阈值或者调低边缘平滑系数1.5能过滤掉一部分杂边。4.3 硬币粘连怎么拆如果硬币之间贴得比较近连通域分析会把两枚挨在一起的硬币判成一个区域。入门练习时最理想的规避方案是摆拍时让硬币彼此分开但真实场景迟早会遇到粘连。处理粘连比较常用的组合是距离变换再分水岭distance_transform (RegionFilled, DistanceImage, octagonal, true, 128, 128) watersheds_threshold (DistanceImage, Basins, 8)距离变换计算每个前景像素到最近背景的距离值越大说明距离边缘越远累积下来会在每个硬币中心形成一个“山头”。分水岭分割再把多个山头之间的低凹区域切开。这里的水位阈值8决定分割的敏感度太高会把一枚硬币从中间切开太低又拦不住粘连。这个步骤不是每次都需要。我的建议是先做区域筛选如果发现select_shape之后某个区域的面积异常大比如是普通硬币面积的1.8倍以上再针对它走距离变换、分水岭分支。这样既保证效率又避免正常硬币被过度分割。4.4 像素半径和实际币值怎么对应图像里拟合出的半径单位是像素如果要判断硬币币值得把像素映射到物理尺寸。最简单实用的方法是利用已知硬币直径推算比例关系币种实际直径mm我测试图中的像素半径范围约1元硬币25.0135~1505角硬币20.5115~1281角硬币19.0105~114比例关系不是固定的取决于相机工作距离和分辨率。只要相机安装高度和镜头焦距不变同一场景下像素半径和实际直径的换算比例就是固定的。用一枚已知币值硬币的实际直径除以它的像素直径得到毫米/像素的比例再对其他半径做分类。更严谨的做法是做标定板标定消除镜头畸变。但入门阶段用已知硬币直径做线性换算已经足够。当时我按半径区间设计了一个简单的分类逻辑在一组固定的拍摄条件下准确率还不错。如果镜头带了比较明显的桶形畸变硬币在图像边缘会变形半径分类就会出问题这时再去考虑calibrate_cameras也不迟。5. 联合编程最容易踩的坑以及练习后的扩展方向5.1 按踩坑频率排序的四个问题练习过程中踩过的坑不少挑几个让最多人崩溃的列一下。第一个坑是dll位数不匹配。Halcon新版本基本都是64位C#工程默认的平台目标可能是AnyCPU或x86。运行时报BadImageFormatException很多人第一反应是代码问题其实只要把程序集目标平台改成x64就解决了。第二个坑是HDevelop导出代码后C#工程里找不到某些类型。这种情况通常是忘了添加Halcon runtime相关的引用。导出代码依赖两个程序集halcondotnet.dll和hdevengine.dll。如果只添加了halcondotnet使用HDevEngine接口时会报类型未加载。第三个坑是License报错。HDevelop里跑得好好的一到C#程序调用就报License错误常见原因是Halcon的运行时环境变量没有正确指向license文件。检查系统环境变量里是否设置了HALCONROOT以及license文件是否放在安装目录对应的license子目录下。第四个坑是HObject对象不释放导致内存持续上涨。尤其批量测试几十张图片时每张图生成的HObject如果不调用DisposeHalcon内部对象会一直堆积。C#的垃圾回收不会自动回收非托管图像对象必须在每次循环结束或每帧处理完成后手动释放image.Dispose(); region.Dispose();我开始没在意这个连续跑了20张图后内存涨了快1GB。后来把所有中间HObject集中在finally里释放问题才解决。这也是为什么封装一个CoinDetector类有好处释放逻辑集中在内部不会到处漏。5.2 从图片到实时相机采集练习项目跑稳后最自然的扩展方向是把“读图片文件”替换成“读取相机图像”。Halcon在这块也有比较成熟的接口比如使用open_framegrabber连接GigE Vision或U3V相机open_framegrabber (GigEVision2, 0, 0, 0, 0, 0, 0, default, -1, default, -1, false, Camera_1, default, -1, -1, AcqHandle) grab_image (Image, AcqHandle) close_framegrabber (AcqHandle)在C#里需要注意线程问题。采集相机的循环不能放在UI线程否则界面会卡死。要开一个后台线程持续grab_image通过事件或队列把图像传给UI线程显示。Halcon的HSmartWindowControl也不是完全线程安全的跨线程显示时要通过委托或者控件自带的BeginInvoke机制来调用。如果暂时没有相机也可以先用图片文件夹模拟“采集”——用定时器每200毫秒切换一张图片代码结构上和相机采集完全一样先把线程模型跑明白。5.3 后续扩展可以做哪些硬币识别只是入门载体它背后的技术点在工业视觉里都很常见把硬币换成药片、螺丝、电子元件算法框架不需要大改主要是特征参数变化。分类逻辑可以从半径判断升级成更好的方式比如用硬币区域的灰度均值、纹理特征甚至用Halcon的深度学习目标检测模型直接输出币种。把检测结果导出成报表或者通过串口、以太网通信发给其他设备这就是工业上位机的常见形态。加一个自动对焦或光源控制逻辑把Halcon当成整个视觉系统的中枢而不是只管识别。我在做完这个练手项目后接着做了一个小零件的缺陷检测练习过程里大量复用了硬币识别里学到的预处理、特征提取、轮廓拟合套路。这印证了一个感受练手的项目本身简单与否不重要重要的是把从图像到结果再到界面集成的完整链路跑通一次。后面再遇到任何视觉需求你心里都已经有一条清晰的主线可以走。最后再分享一个小技巧整个练习过程中我把每次调好的参数截图留档尤其是阈值上下限、筛选面积范围。当时觉得麻烦后面做其他项目时发现这些记录帮我很快找回了“这类材质/光照下该从哪个参数起步”的感觉比重新读文档高效得多。如果你也刚开始接触Halcon和C#联合编程建议从硬币识别这样的项目入手先跑通一次全流程再回头补算子细节效率会高很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑