ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C#+HALCON视觉检测源码:WinForm上位机与采图定位测量判定通讯

C#+HALCON视觉检测源码:WinForm上位机与采图定位测量判定通讯

简介:面向机器视觉开发者与工业检测方向学习者的C#结合HALCON视觉检测软件系统完整源代码,聚焦工件表面质量检测场景,可作为缺陷检测、图像采集处理与决策反馈全流程的工程参考,也适合有C#基础、希望深入HALCON算法调用的中高级读者进行二次开发与定制。压缩包共245个文件,约45.48MB,以80个cs源码文件、35张bmp测试图像、29个dll与23个pdb程序集及调试文件为主,另有10个resx和resources界面资源、6个csproj工程文件及配置、数据库脚本等,完整保留项目工程结构与运行依赖。已有7066人学习下载,说明该案例在机器视觉入门与工程实践社群中具有一定参考价值。读者可从中了解图像采集、图像预处理、特征提取、决策反馈和图形化界面等模块的组织方式,参考C#调用HALCON完成形状匹配、模板匹配、测量与缺陷判定的实现思路,并借助现有工程快速验证算法、查漏补缺,提升视觉系统搭建与二次开发能力。

1. 从一条换型就要重编译的检测线说起:这套 C# + HALCON 系统能顶什么活

某公司的视觉组接手过一条小件外观检测线。最初的做法是在 HDevelop 里把流程调通,导出成 C# 代码,整段塞进一个按钮的点击事件里。第一版跑得挺好,第二个月客户加了三个新型号,每换一次型号就得改算子参数、重新编译、重新部署,现场调试的人连日志都看不到。后来他们把这份 C# + HALCON 的视觉检测系统源代码重新梳理了一遍,才把“流程”和“算子”真正拆开。

这份资源适合两类人:手里有工业相机、要用 C# 做上位机界面,但不知道 HALCON 算子和 WinForm 怎么对接的开发者;以及已经有能跑的检测程序,想看看别人怎么组织采图、定位、测量、判定、通讯这五块的熟手。它更像一套把机器视觉检测从取像到结果输出串成完整链路的工程骨架,并非只会“读图—阈值—显示”的 Demo。

2. 技术栈与工程结构:解决方案里到底分了哪几层

2.1 选型清单:WinForm + HalconDotNet + .NET Framework 4.x 的现实理由

现场工控机大多还是五六年前的配置,集显、4G 内存、Win7 或 Win10 LTSC,这种环境下 WPF 的渲染开销和部署复杂度都不划算,所以整套系统用 WinForm 做界面是合理选择。算法侧用 HALCON 的托管封装 HalconDotNet,通过引用 HALCON 安装目录下 dotnet 目录里的 halcondotnet.dll 调用算子,本质是把 HDevelop 导出的过程调用包成 C# 方法。

版本对齐是这套组合里最关键的一件事。halcondotnet.dll、halcon.dll、运行时授权文件三者版本必须一致,工程平台目标必须与引用的 HALCON 库位数一致(现在基本都是 x64)。很多新手第一次跑就报“无法加载 DLL halcon”,八成是 AnyCPU 加了个 32 位库,或者 NuGet 上随手装了个来路不明的包。

组成常见取向说明
UI 框架WinForm(.NET Framework 4.6.2 / 4.8)现场部署简单,控件够用,拍照显示用 PictureBox 或自绘 Panel
视觉库HALCON + HalconDotNet算子用 HDevelop 调通后导出 C# 代码,再封装进工具类
图像采集相机厂商 SDK 回调 + HObject 转换比 HALCON 自带采集接口更贴近现场,曝光增益控制更细
通讯Modbus TCP / 厂商私有协议与 PLC 做触发、结果、心跳三类信号握手
配置JSON 或 XML把型号、参数、ROI 从代码里挪出去
日志文本滚动日志 + 图片留档NG 图必须存,这是后面追问题的唯一证据

值得注意的是,这套源码的价值不在于用了哪个版本的 HALCON,而在于它把“型号参数”外置了。同一个检测工位,换型号只改配置不改代码,这才是产线愿意接受的东西。

2.2 目录结构:UI、流程、算子、设备四层切开

一个能长期维护的视觉项目,目录结构基本能看出作者的思路。这套代码的组织方式大致如下:

VisionSystem/ ├─ VisionSystem.sln ├─ 01.UI/ 主界面、参数配置页、结果与统计页、图像回放 ├─ 02.Core/ 流程引擎、任务调度、结果判定、状态机 ├─ 03.HalconTools/ 算子封装:定位、测量、Blob、模板、标定 ├─ 04.Devices/ 相机、光源控制器、PLC、IO 卡驱动封装 ├─ 05.Infrastructure/ 日志、配置读写、异常处理、授权检查 └─ 06.Tests/ 离线图片回放与算法回归

分层的意义在于:03 层只认 HObject,不认任何界面控件;01 层只认结果对象,不认算子句柄。中间靠 02 层的流程引擎粘合。这样改算法的时候不用动界面,改界面的时候不会误伤算法。

血泪经验是,绝大多数“视觉软件越改越乱”的项目,问题都出在把 HOperatorSet 调用直接写在 Form 的按钮事件里。算子句柄没有释放、图像对象被 UI 线程持有、异常吞掉不记录,最后全变成玄学问题。

2.3 一份 JSON 描述一个检测方案:配置和代码分离

把每个检测项抽象成统一的接口,是这套代码里最值得抄的部分。它定义了一个很薄的工具契约:

// 所有视觉工具的统一入口,新增算子只要实现这个接口即可 public interface IVisionTool { string Name { get; } // 工具名,如 "LocateChip"、"MeasurePin" void Load(JObject cfg); // 从方案 JSON 中读取本工具的参数 ToolResult Run(HObject image, HObject roi); // image 为原图,roi 为上一级传下来的搜索区域 } public class ToolResult { public bool Ok { get; set; } // 单项判定结果 public double[] Values { get; set; } // 测量值,如宽度、间距、角度 public HTuple Row { get; set; } // 定位输出,供下一级工具使用 public HTuple Col { get; set; } public HTuple Angle { get; set; } public HObject DebugRegion { get; set; } // 调试用叠加区域,正式跑图时置空省内存 }

配合的方案配置文件大致长这样,一个型号一个文件,换型就是换文件:

{ "model": "TYPE-A", "tools": [ { "name": "LocateChip", "type": "ShapeMatch", "angleStart": -30, "angleExtent": 60, "minScore": 0.7 }, { "name": "MeasurePin", "type": "Caliper", "edgeThreshold": 25, "tolerance": 0.05 }, { "name": "BlobDefect", "type": "Blob", "minArea": 80, "maxArea": 5000, "grayMin": 0, "grayMax": 60 } ] }

Load负责反序列化参数,Run负责执行并回填结果,DebugRegion是调试期叠加显示用的,正式跑的时候一定要置空,否则每小时几百张图的句柄堆积起来会把内存吃掉。这套结构的直接好处是:加一个新检测项,只需要写一个实现类并在配置里加一行,不用碰流程引擎,也不用碰界面。

3. 采图链路:从相机回调到第一帧能参与运算的 HObject

3.1 相机接入:厂商 SDK 回调还是 HALCON 的图像采集接口

两条路各有取舍。用 HALCON 的open_framegrabber/grab_image_async,接口统一、换相机改一个字符串就行,但曝光、增益、触发延时这些细节控制不如厂商 SDK 细;用厂商 SDK 注册回调拿原始数据,控制力强,代价是要自己写格式转换。

产线项目里我一般选后者:相机厂商的 SDK 拿到的是 byte 数组或已封装的位图,转成 HObject 只是一次内存拷贝的事,而曝光时间、白平衡、IO 触发沿这些参数在调试时天天要动。转换的关键是别用HImage的构造函数去接Bitmap,那条路会多一次托管到非托管的复制,2000 万像素下能明显感到卡。

// 把相机 SDK 回调里的原始 buffer 直接包成 HObject,避免 Bitmap 中转 public HObject BufferToHObject(byte[] buffer, int width, int height) { // 单通道灰度图:byte 数组 -> HObject,指针只在调用期间有效 HOperatorSet.GenImage1(out HObject image, "byte", width, height, buffer); return image; } // 彩色相机用三通道,注意 BGR 顺序要和 HALCON 的 'bgr' 对齐 public HObject BufferToHObjectRgb(byte[] b, byte[] g, byte[] r, int w, int h) { HOperatorSet.GenImage3(out HObject image, "byte", w, h, b, g, r); return image; }

GenImage1的第三个第四个参数是宽高,第五个是数据指针。这里传 byte[] 时 HALCON 会自动做一次封送,图像对象持有的是自己的一份副本,所以回调里的 buffer 之后被相机 SDK 复用不影响结果。如果是用HTuple接指针的写法(GenImage1Extern),就得保证指针在图像生命周期内有效,一旦相机 buffer 被回收就会出现读脏数据的花屏,这种问题排查起来极其费劲。

采图这一层还要处理一件事:触发模式。产线一般是 PLC 给硬触发,相机收到信号后曝光出图,回调里把图丢进队列。软件触发只适合调试。

3.2 HObject 与 Bitmap 互转:显示、存图、回放三处都要用

界面上要显示,就得转成 Bitmap;NG 图要存档,可以直接用 HALCON 的write_image;离线回放要读图,用read_image拿到 HObject。三条路径都要经过转换,所以这段工具代码必须写得干净。

8 位灰度图转 Bitmap 有个经典坑:直接new Bitmap(w, h, PixelFormat.Format8bppIndexed)出来的图,如果不写调色板,显示出来是全黑的,很多人第一次遇到会以为是图像没取到。

public static Bitmap ToBitmap(HObject image) { HOperatorSet.GetImageSize(image, out HTuple width, out HTuple height); HOperatorSet.GetImageType(image, out HTuple type); int w = width.I, h = height.I; if (type.S == "byte") { HOperatorSet.GetImagePointer1(image, out IntPtr ptr, out _, out _, out _); var bmp = new Bitmap(w, h, PixelFormat.Format8bppIndexed); // 8 位索引图必须显式写灰度调色板,否则整幅图显示为全黑 ColorPalette palette = bmp.Palette; for (int i = 0; i < 256; i++) palette.Entries[i] = Color.FromArgb(i, i, i); bmp.Palette = palette; BitmapData data = bmp.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.WriteOnly, PixelFormat.Format8bppIndexed); // 逐行拷贝,因为 Bitmap 的 stride 可能大于 width for (int y = 0; y < h; y++) CopyMemory(data.Scan0 + y * data.Stride, ptr + y * w, (IntPtr)w); bmp.UnlockBits(data); return bmp; } // 三通道图走 Format24bppRgb,注意 HALCON 的通道顺序是 RGB throw new NotSupportedException($"unsupported image type: {type.S}"); }

LockBits加Scan0 + y * Stride的写法比逐像素SetPixel快两个数量级,这是处理大图时的基本操作。这里没有直接用Marshal.Copy一次性拷贝整块,是因为 Bitmap 每行的 stride 会被对齐到 4 字节边界,宽度不是 4 的倍数时直接整块拷会错行。这个细节不注意,1920×1200 这种宽度能对,1280×1024 也能对,但换个 500 万像素的相机就花屏了。

存 NG 图时我一般用 HALCON 自己写:HOperatorSet.WriteImage(image, "bmp", 0, path),好处是不用管调色板,读回来还是 HObject,离线复现问题的路径最短。

3.3 采集线程与队列:别让 UI 线程碰算子

一套检测系统正常的线程模型是三段:相机回调线程只负责把图塞进BlockingCollection<HObject>;处理线程从队列取图,跑流程引擎,得到结果;UI 线程只负责把结果和叠加图刷到界面上。三段之间用有界队列隔开,队列满了就丢旧图并计数报警,宁可丢帧也不能让队列无限涨。

private readonly BlockingCollection<HObject> _queue = new BlockingCollection<HObject>(8); // 处理线程主循环,单线程跑算子,避免多线程争抢 HALCON 内部资源 private void WorkerLoop(CancellationToken token) { foreach (HObject image in _queue.GetConsumingEnumerable(token)) { try { ToolResult result = _engine.Run(image); // 流程引擎内部串行执行各工具 BeginInvoke(new Action(() => ShowResult(result))); // 结果回 UI,跨线程必须 Invoke } catch (Exception ex) { _logger.Error("process failed", ex); // 异常一定要落日志,别吞 } finally { image.Dispose(); // HObject 是句柄资源,必须显式释放 } } }

队列容量设成 8 是根据节拍来的:单张处理耗时 60ms、来图间隔 80ms 的产线,队列基本是空的;如果处理耗时超过来图间隔,队列会很快涨满,这时候要做的是优化算法或者降分辨率,而不是把队列调大。把队列从 8 调到 800 只是把问题推迟半小时,然后一次性爆内存。

image.Dispose()这一行不能省。HObject 背后是非托管内存,靠 GC 回收的话,跑一晚上能把 8G 内存吃干净,这是新手最容易踩的坑之一。

4. 检测算子与结果判定:定位、测量、缺陷三条腿怎么串成一条流

4.1 定位先行:CreateShapeModel 到 FindShapeModel 的参数怎么定

多相机多工位的系统里,几乎所有的测量和缺陷分析都依赖一个前提:工件位置是已知的。所以流程的第一步永远是模板匹配定位,后续所有 ROI 都跟着定位结果做仿射偏移,而不是写死坐标。

模板的创建一般放在“示教”模式里做一次,把 ModelID 序列化保存,正式跑的时候直接读,不要每张图都重新建模板。

// 示教:从一张合格品图像里抠出定位模板,参数存盘 public void Teach(HObject teachImage, HObject roiRegion) { HOperatorSet.ReduceDomain(teachImage, roiRegion, out HObject modelImage); // 金字塔层数给 auto,角度 ±30°,对比度阈值 auto 让 HALCON 自己挑边缘 HOperatorSet.CreateShapeModel(modelImage, "auto", -0.52, 0.52, "auto", "auto", "use_polarity", "auto", "auto", out HTuple modelId); HOperatorSet.WriteShapeModel(modelId, "locate_chip.shm"); HOperatorSet.ClearShapeModel(modelId); } // 运行:在整幅图里找模板,取分数最高的一处作为定位基准 public ToolResult Locate(HObject image, HTuple modelId) { HOperatorSet.FindShapeModel(image, modelId, -0.52, 0.52, 0.7, 1, 0.5, "least_squares", 0, 0.8, out HTuple row, out HTuple col, out HTuple angle, out HTuple score); if (score.Length == 0 || score.D < 0.7) return new ToolResult { Ok = false }; // 没找到就整张图判 NG,不要带着错位置往下跑 return new ToolResult { Ok = true, Row = row, Col = col, Angle = angle, Values = new[] { score.D } }; }

几个参数的经验值:AngleStart和AngleExtent用弧度,±30° 是-0.52和1.05;MinScore现场一般给 0.6~0.75,给到 0.9 会因为光照波动频繁误判漏检;NumMatches设 1 还是多值看工件,一个视野多个件的场合要用多值再按行列排序;Greediness给 0.8~0.9,给 1.0 最快但可能漏掉,给 0.5 慢一倍。

SubPixel用"least_squares"比"interpolation"精度高,代价是耗时增加 20% 左右,精密测量场合值得。定位分数低于阈值时,正确的处理是整张判 NG 并报警,绝不能带着一个错误的位置继续做测量,否则下游全是垃圾数据。

定位之后,用AffineTransContourXld或直接对 ROI 做AffineTransRegion,把每个工具的名义坐标变换到实际位置。这一步做扎实了,后面的测量就简单了。

4.2 缺陷与尺寸:Blob 分析和卡尺测量的组合

缺陷检测用 Blob 分析的组合拳:阈值分割、连通、按面积和形状筛选、统计。核心参数是灰度区间和面积上下限,这两个值必须拿真实样片反复试,不能拍脑袋。

public ToolResult BlobDefect(HObject image, HObject roi, double grayMin, double grayMax, int minArea, int maxArea) { HOperatorSet.ReduceDomain(image, roi, out HObject reduced); HOperatorSet.Threshold(reduced, out HObject region, grayMin, grayMax); // 先做一次形态学开运算,去掉噪点和毛刺,否则面积全是碎片 HOperatorSet.OpeningCircle(region, out HObject cleaned, 1.5); HOperatorSet.Connection(cleaned, out HObject connected); HOperatorSet.SelectShape(connected, out HObject defects, "area", "and", minArea, maxArea); HOperatorSet.CountObj(defects, out HTuple count); return new ToolResult { Ok = count.I == 0, // 检出即 NG Values = new[] { (double)count.I }, DebugRegion = defects }; }

OpeningCircle的半径 1.5 像素是个万能起点,能干掉单像素噪声又不会吃掉真实的小缺陷;SelectShape的"area"配合"and"是闭区间,参数字符串顺序不能反。面积上限别忘了设,否则一大片反光会被当成一个巨型缺陷,产线上一天能报几百次假 NG。

尺寸测量用卡尺(Caliper),比 Blob 的边界定位精度高一个量级,因为它沿垂直于边缘的方向做一维灰度投影,能找到亚像素边缘。

// 在定位结果给出的位置生成测量矩形,沿 Phi 方向找一对边缘并算间距 HOperatorSet.GenMeasureRectangle2(row, col, phi, len1, len2, imgW, imgH, "nearest_neighbor", out HTuple handle); // Sigma 平滑尺度,Threshold 边缘幅值阈值,Transition 选 all/positive/negative HOperatorSet.MeasurePairs(image, handle, 1.0, 30, "all", out HTuple r1, out HTuple c1, out HTuple amp1, out HTuple r2, out HTuple c2, out HTuple amp2, out HTuple rc, out HTuple cc, out HTuple intra, out HTuple inter); HOperatorSet.CloseMeasure(handle); double width = inter.D; // 一对边缘的距离,单位像素,乘标定系数才是毫米

GenMeasureRectangle2的len1是沿边缘方向的半长,决定了平均多少个像素,给大一点抗噪但会掩盖局部变形;len2是垂直边缘方向的半长,要略大于预期的边缘搜索范围。Sigma给 1.0 是默认值,噪声大调到 1.5~2.0,但调太大会把真实边缘也抹平。Threshold给 20~40,反光强的工件要往上加。

inter.D拿到的是像素距离,乘上标定系数mmPerPixel才是物理尺寸。标定系数用标准块规或者圆点标定板算,别拿图纸尺寸反推,那样量出来永远是准的,因为你自己校准过。

4.3 判定与输出:公差带、NG 分类、PLC 握手

所有工具跑完,结果汇总到一个判定器里。判定逻辑本身很简单,麻烦的是 NG 分类和与 PLC 的时序。

public JudgeResult Judge(IList<ToolResult> results, Spec spec) { var judge = new JudgeResult { Ok = true, Code = 0 }; // 定位失败优先级最高,直接返回,后面的数据都不可信 if (!results[0].Ok) { judge.Ok = false; judge.Code = 100; return judge; } foreach (var r in results.Skip(1)) { if (!r.Ok) { judge.Ok = false; judge.Code = 200; continue; } // 测量值逐个比对公差带,超上限和超下限给不同错误码,方便现场分因 for (int i = 0; i < r.Values.Length; i++) { double v = r.Values[i]; if (v > spec.Upper[i]) { judge.Ok = false; judge.Code = 301; } else if (v < spec.Lower[i]) { judge.Ok = false; judge.Code = 302; } } } return judge; }

错误码分段是有讲究的:100 段是定位类、200 段是缺陷类、300 段是尺寸类。现场操作员看到屏幕上跳 301 还是 302,能立刻判断是工件偏大还是偏小,不用翻日志。这套编码规则比任何文档都管用。

PLC 握手一般用三个信号:InPosition(工件到位)、ResultReady(结果就绪)、ResultOk(结果值)。时序上必须做到:置位ResultReady之后保持到收到 PLC 的Ack信号才复位,否则 PLC 扫描周期和软件线程不同步时会丢结果。这个握手逻辑写成状态机,比散落在各处的if可靠得多。

5. 部署与运行避坑:五条现场翻车记录和它们的解法

5.1 启动阶段:环境与授权

现象:程序在开发机上跑得好好的,拷到现场工控机上双击就报“无法加载 DLL 'halcon'”或“找不到指定的模块”。

原因:一是平台目标设成了 AnyCPU,在 64 位系统上跑起来找 32 位库;二是 HALCON 的运行时 DLL 没跟着发布,只拷了 exe;三是运行环境变量里没有 HALCON 的 bin 目录。

解决:平台目标固定 x64;把 halcon.dll、halconcpp.dll、halcondotnet.dll 等运行时文件随程序一起发布到输出目录;安装 HALCON 运行环境(Runtime)而不是完整的开发环境。这三件事做完,99% 的启动失败就没了。

现象:程序能起来,一调算法就抛异常,提示授权无效。

原因:开发授权和运行授权是两套东西。开发机上有加密狗或者开发授权文件,现场机器上没有;或者授权文件对应的 HALCON 版本和引用库版本差了半代。

解决:现场机器需要部署对应的运行授权,并且版本严格对齐。发布前在干净的机器上做一次冒烟测试,别等到产线停机才发现。

5.2 运行阶段:算法打滑与结果跳动

现象:模板匹配偶尔完全找不到,分数掉到 0.3 以下,下一张图又正常。

原因:八成是光照漂移或工件表面状态变化(油污、划痕、反光)。也有可能是模板本身选得不好,抠的 ROI 里包含了背景边缘,背景一动模板就跟着动。

解决:模板 ROI 只包含工件上形状最独特、最不容易变化的那块结构,别贪多。角度范围收到实际可能的范围,范围越大匹配越慢也越容易误匹配。光照问题要从光源上解决,算法上可以加一层Emphasize增强边缘,或者改用CreateScaledShapeModel应对尺寸批次差异。

现象:尺寸测量值整体偏了 0.02mm,但重复性很好。

原因:标定系数过期了。镜头换过、工作距离调过、相机重新装过,标定系数没更新。

解决:把标定系数写进配置文件的方案里,和型号绑定,每次换镜头或调整工作距离必须重标。我一般还会在系统里放一个“标定检查”按钮,用标准件跑一遍看偏差,超过阈值就报警不让开工。

5.3 长跑阶段:内存与线程

现象:程序跑三四个小时以后越来越慢,最后卡死或崩溃。

原因:HObject 和 HTuple 里的句柄没有主动释放,全压在 GC 的终结器队列里;或者 DebugRegion 在正式跑的时候还留着,每张图都存一份调试区域。

解决:所有 HObject 用完立刻Dispose(),HWindow、HMeasure、HShapeModel这类句柄资源用using或者显式Clear;正式模式把调试区域和叠加显示关掉,只在 NG 时生成一张存档图。上线前用 2000 张历史图做一次连跑压测,看内存曲线是不是平的,这个习惯能省掉很多半夜的电话。

6. 二次开发改造:把固定流程改成可编排的检测方案

拿到这套代码之后,我做的第一件事通常是把流程引擎从“写死的顺序调用”改成“按配置串工具”。改造点很小:IVisionTool加一个DependsOn属性,引擎按依赖关系拓扑排序执行,每个工具的Run拿到的是上游工具的ToolResult。这样“定位 → 测量 → 缺陷”和“定位 → 缺陷 → 测量”只是配置顺序不同,代码一行不用改。

第二步是给工具加一个Clone(),支持在方案编辑器里复制粘贴检测项。视觉项目的参数调试极其耗时,操作员经常要“把这个测量项复制一份,位置偏移 5mm 测下一个引脚”,没有克隆功能的话,每个引脚都要手工填一遍参数,十个引脚就是十倍的调试时间。

第三步是加离线回放。工程里保留一个06.Tests目录,把生产的 NG 图批量导进去,改完参数跑一遍看命中率变化。这一步比任何单元测试都值钱,因为视觉算法的“正确”是统计意义上的,单张图对不代表批量对。

改造点改动量收益
流程依赖编排引擎层加 50 行换检测项不用改代码
工具克隆每个工具加 5 行多工位调试时间减半
离线批量回放独立控制台工程参数调整有数据支撑
NG 图自动归档结果层加 20 行客诉时能立刻复现

改造完必须验证。我做验证的方式很土但有效:拿 30 片标准件,连续跑三遍,每次记录同一测量项的极差,极差不超过公差带的 1/10 才算过关;然后拿 30 片已知 NG 的件跑,看漏检和误判。这套流程走下来,参数才敢往产线上放。

从那以后我每次接新的视觉项目,都先把采图、定位、测量、判定这四段拆成独立接口再动手写算子,哪怕项目再小也这么干。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进