☰
C#与HALCON视觉检测系统开发实战:从模板匹配到SQLite落库的完整工程
2026/10/16 10:37:40 网站建设 项目流程

简介:一份基于 C# 与 HALCON 开发的完整机器视觉检测软件系统源代码包,面向工业视觉应用开发者、机器视觉初学者及需要二次集成的工程技术人员。系统主打工件表面质量检测,覆盖图像采集、预处理、特征提取、缺陷判断与结果展示全链路,源码结构清晰,有助于理解 C# 与 HALCON 算法库如何协同完成实际视觉任务。压缩包共 245 个文件,约 45.48MB,内含 80 个 C# 源文件、35 幅 BMP 样本图、必要的 DLL 与 EXE 依赖库、项目工程配置及数据库脚本等,分类明确,便于按模块查阅。已有 7063 人下载学习,适合对照代码掌握机器视觉系统搭建流程,也可基于现有模块扩展检测功能或整合其他硬件设备。

1. 一套能上产线的 C# + HALCON 视觉检测系统:不是脚本 Demo,是能交付的完整工程

一条典型的产线视觉检测需求长这样:产品流过来,相机拍照,系统判断尺寸合不合格,结果显示在屏幕上,数据存进库里。HDevelop 里跑得好好的脚本,到了交付阶段往往缺一大块——没有操作界面、没有检测记录、没有参数回退机制。这套源码解决的就是这个断层:用 C# 写上位机,把 HALCON 图像处理能力嵌进去,把相机采集、模板定位、卡尺测量、结果判定、SQLite 存储、用户权限串成一套能直接上线的完整系统。

适合两类读者。一类是想用 C# 调 HALCON 做产线视觉项目,但不想从空白窗体搭框架的开发者;另一类是手里有 HALCON 脚本,想知道脚本怎么跟界面、数据库、日志这些工程化部分联动的开发者。这套系统的价值在“完整”——每个环节都是能落地的工程代码,不是单点 Demo。

2. 系统架构拆解:UI、业务、算法、数据四层怎么分工协作

2.1 拿到源码先看目录:工程分层一看就懂

我拆这类工程有个习惯,先不开 Visual Studio,而是先看目录结构。把整个系统的源码摆在面前时,第一件事是确认分层是否清晰。这套源码的目录设计非常规矩,大致是下面这个样子:

VisionSystem/ ├── VisionSystem.sln ├── src/ │ ├── Vision.UI/ # WinForms 界面工程 │ ├── Vision.Core/ # 业务逻辑与流程编排 │ ├── Vision.Algorithm/ # HALCON 算法封装 │ ├── Vision.Data/ # SQLite 数据访问 │ └── Vision.Communication/ # TCP / PLC 通信 ├── config/ │ ├── camera.config │ ├── recipe.config │ └── logging.config └── models/ └── shape_model.shm

四个 src 目录正好对应四个职责区域。Vision.UI 只做界面展示和用户交互,不写图像处理;Vision.Algorithm 是核心,负责 HALCON 的算子调用,不依赖任何窗体;Vision.Core 做流程调度,它知道“先拍照、再定位、再测量”的顺序;Vision.Data 只管数据库读写,把检测结果落库。

我拿到源码后最先打开的是 Vision.Algorithm,因为它是整个系统的技术核心,含金量最高。其次是 Vision.UI,主要看数据绑定逻辑,确认界面是直接操作控件还是走了 MVVM 之类的模式。这套源码用的是 WinForms,控件操作比较直接,读起来不费劲。

2.2 技术选型:为什么是 C# 加 HALCON 而不是纯脚本或 C++

选型这件事在产线项目里通常不是技术偏好问题,而是开发效率问题。HDevelop 做图像算法验证和调试确实快,拉个模板、调个阈值,几分钟就能看到效果,但它做不了友好的工业界面,也做不了数据库和通信联动。纯 HDevelop 脚本交付给产线,操作员面对的是一个调试窗口,这是没法用的。

C# 在上位机领域生态成熟,WinForms 和 WPF 都够稳定,SQLite、日志组件、串口和 Socket 通信库都是现成的。C++ 性能更强,但对团队要求高,开发周期长,视觉项目的瓶颈多数时候不在算子执行速度上,而在业务逻辑和交互流程上。

方案优点缺点适合场景
HDevelop 脚本调试快、算子验证直观无界面、无数据库、难交付算法验证
C# + HALCON工程化完善、开发效率高需要维护 .NET 与 HALCON 版本匹配产线交付
C++ + HALCON性能上限高开发成本高、联调周期长高速高频检测

这套系统的稳定姿态就是技术栈选型里“性价比最高”的那个:C# 做壳,HALCON 做芯。

2.3 一次完整检测的业务流:从传感器触发到数据落库

我看代码时习惯先梳理业务流,再回头细看每个代码块的实现。这套系统的检测流程是典型的上位机视觉检测循环,完整链路可以拆成九步:

  1. 外部传感器或 PLC 发出触发信号,Vision.Communication 层收到信号;
  2. UI 把当前状态切换为“运行中”,按钮置灰防重复触发;
  3. CameraService 调用 GrabImage 采集一张 HImage;
  4. 图像预处理,包括转灰度、降噪、ROI 裁剪;
  5. FindShapeModel 做模板匹配,找到产品在图像里的位置和角度;
  6. Metrology 卡尺工具测量关键尺寸;
  7. 和配方里的上下限比较,生成 DetectResult 对象;
  8. 结果写入 SQLite,同时把 OK/NG 信号通过通信层回报给 PLC;
  9. UI 用 BeginInvoke 刷新显示。

这个流程每一步都不复杂,但串起来容易出问题的地方在于两步之间数据怎么传递。这套系统的做法是设计一个统一的结果对象,每个模块只管自己那一份数据,最后在 Vision.Core 里把所有数据汇总成 DetectResult。这样的好处是逻辑清晰,改算法不会牵连界面,改界面也不会影响检测逻辑。

2.4 线程模型:采集和 UI 为什么不许挤在一个线程

我看到很多从零搭的视觉项目第一次翻车都翻在线程上。相机的 GrabImage 在 GigE 相机上是阻塞调用,如果直接放在按钮点击事件里执行,界面会卡死,拖动窗口都会一顿一顿。这套系统用的是典型的后台线程加 BeginInvoke 模式:

private async void BtnStart_Click(object sender, EventArgs e) { _running = true; await Task.Run(() => DetectionLoop()); } private void DetectionLoop() { while (_running) { _camera.Grab(); var result = _detector.Run(_camera.CurrentImage); // 跨线程改控件会抛异常,必须通过 BeginInvoke 回 UI 线程 this.BeginInvoke(new Action(() => { lblStatus.Text = result.IsOk ? "OK" : "NG"; lblValue.Text = result.MeasuredValue.ToString("F3"); })); Thread.Sleep(20); // 给 UI 消息循环喘息时间 } }

DetectionLoop 跑在后台线程里,采集和检测都在这里执行,不阻塞界面。每次循环结束后 Sleep 20 毫秒,是为了避免线程空转把 CPU 吃满。BeginInvoke 的语义是“把这段代码丢回 UI 线程执行”,这是 WinForms 跨线程更新界的标准解法。这套系统的线程模型没有标新立异,但足够可靠,产线环境下稳定优先。

3. 核心模块实战:从相机采集到结果落库的代码全流程

3.1 相机采集:OpenFramegrabber 参数逐个说清楚

相机这块是新手最容易懵的地方,因为 OpenFramegrabber 的参数量很大,看一眼就劝退。但拆开看其实就三组:接口类型、采集配置、连接字符串。这套源码里封装了一个 CameraService,核心代码长这样:

using HalconDotNet; public class CameraService : IDisposable { private HTuple _acqHandle; public bool Open(string cameraName) { try { HOperatorSet.OpenFramegrabber( "GigEVision", // 接口类型:GigE 相机用 GigEVision 0, 0, 0, 0, 0, 0, // 图像尺寸参数,0 表示用相机默认值 "default", // Field 场模式 -1, // 位深,-1 表示默认 "default", // 颜色格式 -1, // 相机类型 "false", // 是否使用外部触发 "default", // 端口 cameraName, // 相机连接字符串,通常是相机 IP 0, -1, out _acqHandle); return true; } catch (HalconException ex) { Log.Error("打开相机失败:" + ex.Message); return false; } } public bool Grab(out HImage image) { image = null; try { HOperatorSet.GrabImage(out image, _acqHandle); return true; } catch (HalconException ex) { Log.Error("采集失败:" + ex.Message); return false; } } public void Dispose() { if (_acqHandle != null && _acqHandle.Length > 0) HOperatorSet.CloseFramegrabber(_acqHandle); } }

这里有个关键细节:GrabImage 拿到的 HImage 是后续所有处理的数据源,它属于托管对象,但也包装了非托管内存。Dispose 里关掉采集句柄是必须的,否则程序退出时相机会一直处于被占用状态,这是产线项目里很典型的问题。

我一般不在手写参数上纠结。HALCON 的采集助手可以直接生成开相机的代码,把助手生成的参数粘过来,替换掉相机 IP 就行。源码里这套参数是能直接用的,但如果你换了一台相机,建议重新用助手生成,而不是手动去猜参数含义,省时间也省心。

3.2 模板匹配定位:FindShapeModel 是整套系统的定海神针

视觉检测系统的第一步永远是“先找到产品在哪里”。产品在图像里的位置会偏移、旋转,所以需要用模板匹配做定位。这套源码的定位模块封装了 ShapeModel 的读取和匹配:

public class ProductLocator { private HTuple _modelID; public void LoadModel(string path) { HOperatorSet.ReadShapeModel(path, out HTuple modelID); _modelID = modelID; } public Pose Locate(HImage image) { HOperatorSet.FindShapeModel( image, _modelID, -0.39, // 角度搜索下限,约 -22 度 0.79, // 角度搜索范围,约 +45 度 0.7, // 最小匹配分数,低于 0.7 视为找不到 1, // 返回的最大匹配个数 0.5, // 最大重叠比例 "least_squares", // 亚像素精度,least_squares 最准 0, // 金字塔层数,0 表示自动 0.7, // 贪婪度,越小越慢越准 out HTuple row, out HTuple col, out HTuple angle, out HTuple score); return new Pose { Row = row.D, Column = col.D, Angle = angle.D, Score = score.D }; } }

定位的输出是一个 Pose,包含行、列和角度三个值。这三个值决定了后续测量的参考坐标系:不管产品怎么偏移和旋转,测量工具都能跟着产品走。这里的角度范围 -0.39 到 0.79 是弧度制,换算成角度大约是 -22 度到 +45 度,覆盖了产线上常见的摆放偏差。

MinScore 设为 0.7 是一个比较稳妥的平衡点。太高了光线波动时容易找不到目标,太低了会把错误位置当成目标。Greediness 的 0.7 也是类似逻辑,追求速度时往 0.9 调,追求稳定性时往 0.5 调。这套源码的默认值是产线调试过的结果,我建议不轻易动。

3.3 卡尺测量:Metrology 模型让测量距离精确到亚像素

定位之后是测量。这套系统用的是 HALCON 的 Metrology 模型,它比手动找边缘再算距离的方式更工程化。测量对象加到模型里之后,ApplyMetrologyModel 一次性执行所有测量:

HOperatorSet.CreateMetrologyModel(out HTuple metrologyHandle); HOperatorSet.SetMetrologyModelImageSize(metrologyHandle, width, height); // 添加一条线性测量对象 // 参数含义依次是:模型句柄、类型、位置、测量长度、测量距离、阈值、参数 HOperatorSet.AddMetrologyObjectGeneric( metrologyHandle, "line", new HTuple(new double[] { row1, col1, row2, col2 }), new HTuple(25), // 测量长度,单位像素 5, // 测量距离,卡尺宽度 20, // 边缘阈值 new HTuple(), new HTuple(), out HTuple measureObjectID); HOperatorSet.ApplyMetrologyModel(image, metrologyHandle); HOperatorSet.GetMetrologyObjectResult( metrologyHandle, measureObjectID, "all", "result_type", out HTuple result);

Metrology 的优势在于测量线是依附于定位结果动态生成的。产品偏移了,测量线的位置也随之偏移,不需要每次重新标定。这套系统把这部分封装在 Vision.Algorithm 里,外部调用者只关心最终结果数值。

测量长度 25 表示卡尺在边缘两侧各延伸 25 像素,测量距离 5 表示每条卡尺线之间的间距。这两个参数直接影响测量稳定性:太小容易受到噪声干扰,太大又可能跨到别的边缘上。实际调试时可以在 HDevelop 里打开测量助手,拖动卡尺看实时测量效果,参数确定后再固化到代码里。

3.4 检测结果对象:数据是怎么在模块之间流转的

从定位到测量到判定,中间会产生很多中间数据。这套系统没有用全局变量到处传,而是定义了一个统一的 DetectResult 对象,一次检测的所有输出都汇总到这里:

public class DetectResult { public string ProductId { get; set; } public bool IsOk { get; set; } public string DefectType { get; set; } public double Value { get; set; } public double UpperLimit { get; set; } public double LowerLimit { get; set; } public string ImagePath { get; set; } public long ElapsedMs { get; set; } public override string ToString() { return $"{ProductId} | {(IsOk ? "OK" : "NG")} | 实测 {Value:F3}"; } }

Value 是测量出来的实际尺寸,UpperLimit 和 LowerLimit 是配方里的公差上下限,IsOk 就是 Value 和上下限比较的结果。ProductId 是当前产品标识,ImagePath 保存本次检测图像的落盘路径,方便后续追溯问题批次。

ImagePath 这个字段很多新手会忽略,但在产线项目里非常重要。产品被判 NG 之后,过一会儿产线工人会来问“刚才那个不良品长什么样”,如果系统没有保存图像,就只能凭口述,完全没有说服力。这套源码把每张图像都按时间戳命名存档,这是个很成熟的工程习惯。

3.5 结果落库:SQLite 存储与查询

检测结果最终要落到数据库里,这套系统用的是 SQLite,方便部署,不需要单独装数据库服务。写入逻辑被封装在 Data 层:

public void SaveRecord(DetectResult result) { using (var conn = new SQLiteConnection(_connectionString)) { conn.Open(); using (var cmd = conn.CreateCommand()) { cmd.CommandText = @" INSERT INTO detect_record (product_id, is_ok, value, upper_limit, lower_limit, image_path, create_time) VALUES (@pid, @ok, @value, @upper, @lower, @img, @time)"; cmd.Parameters.AddWithValue("@pid", result.ProductId); cmd.Parameters.AddWithValue("@ok", result.IsOk ? 1 : 0); cmd.Parameters.AddWithValue("@value", result.Value); cmd.Parameters.AddWithValue("@upper", result.UpperLimit); cmd.Parameters.AddWithValue("@lower", result.LowerLimit); cmd.Parameters.AddWithValue("@img", result.ImagePath); cmd.Parameters.AddWithValue("@time", DateTime.Now); cmd.ExecuteNonQuery(); } } }

参数化 SQL 是必须的,检测结果里如果拼字符串,万一产品编码里带了特殊字符,SQL 就崩了。SQLite 的连接用完即释放,避免长时间占用文件句柄,这在产线连续运行几万次检测后差别很明显。

4. 从源码到跑通:环境配置、编译联调与配置项解读

4.1 环境依赖清单

源码本身不会凭空运行,要把它跑起来需要准备这几样东西。第一个是 HALCON 运行时,本机必须安装 HALCON,安装时记得勾选 .NET 接口组件。第二个是 Visual Studio,版本 2019 或 2022 都行,工程目标框架一般是 .NET Framework 4.7.2。第三个是相机驱动,但如果暂时没有相机,完全可以先用 Demo 模式把整条软件流程跑通,源码里保留了无相机模式。

没有相机也能跑这件事很重要。我见过太多人在环境搭建阶段就被相机驱动卡住,其实视觉系统的软件框架验证根本不需要相机。用 HALCON 安装目录自带的示例图像,照样能验证模板匹配、测量、数据库落库这一整条链路。

4.2 编译五步走

拿到源码后按下面五个步骤操作,每一步我都会说明为什么这么做,避免重复踩坑。

第一步,用 Visual Studio 打开解决方案,先看工程的 Target Framework 是否和本机安装的 .NET 版本匹配,不一致会导致编译错误。第二步,检查 HalconDotNet 引用。源码里的引用路径可能指向作者本机的 HALCON 安装目录,换机器后这个引用会变成黄色感叹号,需要在引用管理里删掉旧的,重新从HALCON\bin\dotnet\<版本>目录添加 halcondotnet.dll。

第三步,把解决方案平台从 AnyCPU 改成 x64。HALCON 的 DLL 按 64 位和 32 位分开打包,如果工程是 AnyCPU,运行时在 64 位系统上默认以 64 位进程跑,但引用的 DLL 可能是 32 位,会出现运行时加载失败。第四步,确认 HALCON 的运行时 DLL 能被程序找到,最省事的方式是把 HALCON 的 bin 目录加到系统 PATH,或者把需要的 DLL 复制到输出目录。第五步,F5 启动,如果一切正常,会先弹出登录窗口,登录后进入主界面。

编译过程最常见的报错有三个。第一个是找不到 HALCON 的命名空间,这是因为引用没加成功。第二个是 BadImageFormatException,这是位数不匹配,按上面的第三步解决。第三个是缺少 halcon.dll,这是运行时 DLL 路径问题,按第四步解决。

4.3 无相机模式下怎么验证全流程

源码里带了一个配置开关,可以把系统切换到无相机模式,用本地图像模拟采集。这个设计我特别喜欢,因为它让开发和调试不依赖硬件:

public HImage LoadTestImage() { // 从 HALCON 安装目录的示例图像中读取 HOperatorSet.ReadImage(out HImage img, "printer_chip/printer_chip_01"); return img; }

把这条读取逻辑接在 CameraService 的 Grab 方法内部,当配置项的 DemoMode 为 true 时,不调用相机而是读取本地图片。这样整个流程就可以脱离相机反复跑,调试测量参数时特别方便。

配上这个模式,UI 的“开始检测”按钮仍然有效,数据库仍然正常写入记录,和真实采集的唯一区别只是图像来源。我在调试视觉项目时,都是用这种方式先跑通全流程,再去接真实相机。

4.4 配置文件逐项解读

这套系统的参数没有散落在代码里,而是集中在 config 目录下。相机参数和检测配方是分开的,这个设计很合理,因为两者修改的频率完全不同:

<VisionConfig> <Camera Interface="GigEVision" Name="cam0" ExposureTime="5000" /> <Recipe Current="default"> <ModelPath>models/shape_model.shm</ModelPath> <Measure UpperLimit="25.05" LowerLimit="24.95" /> </Recipe> </VisionConfig>
配置项作用修改后需要做什么
Camera.Interface相机接口类型,GigEVision 还是 USB3Vision重新打开程序才生效
Camera.ExposureTime曝光时间,单位微秒,影响图像亮度保存后重新触发采集
Recipe.ModelPath模板文件路径,换产品就换模板重新加载模板
Recipe.Measure公差上下限,判定 OK/NG 的依据保存后立即生效

这里特别注意,测量公差上限下限做成配置之后,产线换产品型号时不用重新编译程序,只要改配置即可。这套系统把“换型”这个操作简化成了“改配置文件 + 换模板文件”,这是它能直接上产线的重要原因之一。

5. HALCON 与 C# 集成避坑指南:5 条真实踩坑记录

5.1 编译通过,运行时“无法加载 halcon.dll”

现象:工程能编译,但一运行就弹窗,提示无法加载 halcon.dll 或相关依赖 DLL,或者直接抛 BadImageFormatException。

原因:这个问题九成是位数不匹配。HALCON 安装时可以选择装 32 位还是 64 位,工程编译成 x86 却引用了 64 位的 halcondotnet.dll,运行时自然找不到对应 DLL。

解决:把工程平台统一改成 x64,并在工程属性里确认 PlatformTarget 为 x64,然后删除引用重新添加对应版本的 halcondotnet.dll。从那以后我新建视觉工程第一步就强制设 x64,明白了这是视觉项目的硬性门槛。

5.2 HDevelop 里能匹配,C# 里 FindShapeModel 分数一直为零

现象:同一个模板,同一张图,在 HDevelop 里跑 FindShapeModel 输出很漂亮,移植到 C# 里跑,输出分数永远小于阈值,定位失败。

原因:图像格式不一致。HDevelop 调试时读图默认是灰度,而相机采集的彩色图可能直接传给了模板匹配接口,模板匹配在内部对图像格式有要求,遇到不支持的格式不会报错,只会匹配失败。

解决:采集到图像后先做一次 Rgb1ToGray 转灰度,再进入定位模块。模板匹配前统一走一遍预处理,不做这个转换,后续匹配结果就会写满玄学。

5.3 相机采集卡住,程序界面毫无响应

现象:点击开始检测后,程序界面卡死,窗口无法拖动,点关闭也没反应,过一会甚至提示“程序未响应”。

原因:GrabImage 是阻塞调用,直接放在了 UI 线程执行。GigE 相机的网络传输存在波动,一旦网络拥堵或相机异常,阻塞时间会很长,UI 线程被卡死,整个程序看起来就死了。

解决:采集逻辑必须放到后台线程,界面通过 BeginInvoke 更新。同时给采集过程做超时保护,超过设定时间就强制放弃本次采集并重试,这个机制在产线网络不稳定的场景里极其重要。

5.4 长时间批量检测,内存占用一直涨,最后卡顿

现象:系统连续跑几千次检测后,内存占用持续攀升,从几百兆涨到几个 G,界面操作开始明显变慢。

原因:HImage 包装了非托管内存,如果没有正确释放,垃圾回收器不会及时回收这部分内存。特别是 HObject 在循环里反复赋值时,旧的图像对象如果没有 Dispose,内存就会一直累积。

解决:每次循环里用到的 HImage 和中间 HObject,在使用完毕后手动调用 Dispose,或者利用 using 语句块包裹。我一般会做一个统一的图像处理上下文类,在 finally 块里集中释放,避免遗漏。

5.5 加载模板文件路径带中文,脚本执行失败

现象:把模型文件放在带中文的路径下,程序里加载模板时,HDevelop 脚本引擎偶尔执行报错,但在英文路径下完全正常。

原因:HALCON 脚本引擎和底层文件接口对非 ASCII 字符路径的支持存在兼容问题,中文路径会传参错误。

解决:整个工程根目录、模型文件路径、配置路径全部使用英文,一条路径都不许出现中文。这个习惯虽然在项目协作时需要多沟通,但能省掉大量莫名其妙的文件加载问题。视觉项目里“全英文路径”应该作为一条铁律写进团队规范。

6. 二次开发思路:把检测参数从“写死”改成可切换配方

6.1 扩展点在哪里

这套系统最容易改造的地方是测量参数和定位逻辑。Vision.Algorithm 层暴露了 LoadModel 和 Run 两个核心方法,只要你新增的算法类实现了同样的接口,替换定位器不会动到界面和数据库。这是分层带来的直接好处。如果你想给系统增加“多配方切换”功能,重点在 Vision.Core 层加一个 RecipeManager,负责按产品型号加载不同的配置和模板。

6.2 一个具体改造:把公差上下限改成随配方加载

原始代码里上下限可能是常量。改造的第一步,定义一个 Recipe 类,从配置文件读取参数:

public class Recipe { public string ProductId { get; set; } public string ModelPath { get; set; } public double UpperLimit { get; set; } public double LowerLimit { get; set; } public static Recipe Load(string productId) { // 从 recipe.config 按产品编码读取对应节点 // 找不到时抛出异常,防止无配方状态下误判 return new Recipe { ProductId = productId, ModelPath = "default.shm", UpperLimit = 25.05, LowerLimit = 24.95 }; } }

第二步,检测流程里不再直接使用硬编码,而是注入配方对象。第三步,在 UI 上加一个产品型号下拉框,切换型号时重新加载 Recipe 和 ShapeModel。验证方法很简单:跑之前先用同一张测试图跑三遍,把结果写死作为基准;改造后再用同一张图跑,对比测量值完全一致,就说明改造没有引入回归。

从那以后我每次接手视觉项目,都愿意先跑通无相机模式,用同一张图把全流程扛一遍,再接真实相机。一套能顺利更换配方的视觉系统,才真正具备上线后被长期维护的资格。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询