简介一份面向C#桌面开发与机器视觉初学者的完整示例工程聚焦在WinForm界面中集成Halcon图像处理库并通过DirectShow调用笔记本内置摄像头进行实时视频流捕获与二维码识别。项目为开源代码内置测试二维码图片便于验证识别效果。压缩包共36个文件核心包括9个C#源文件、编译生成的DLL与EXE、工程配置文件、资源文件以及示例图片和说明文本整体大小约11.05MBsln与csproj齐全可直接在Visual Studio中打开调试。已有460人学习使用。读者可以从中掌握DirectShow过滤器图的搭建与摄像头视频流获取方式学习如何在WinForm中嵌入视频显示控件并通过Halcon API对实时帧执行条码定位、解码与结果展示。该示例融合了C#程序设计、WinForm界面开发、DirectShow多媒体处理以及Halcon机器视觉四方面知识适合希望快速上手工业视觉应用的开发者和爱好者参考。1. 一个摄像头读二维码的C#工程frmWindowTest 里到底装了什么做机器视觉的人十有八九都遇到过这种情况算法在 Halcon 里调通了一到 WinForm 界面里就各种掉链子——摄像头打不开、图像格式对不上、读码偶发失败。frmWindowTest 这个压缩包正好是一个把「C# WinForm Halcon DirectShow 摄像头采集 二维码读取」串在一起的完整工程。它解决的就是从「Halcon 里跑通算子」到「界面上实时读码出结果」这中间那段没人替你踩的路。包里有现成的 Form1.cs、csproj、以及一张 22.jpg 测试二维码图适合正在做视觉项目、又不太想从零搭架构的 C# 开发者和机器视觉工程师。它不复杂但你顺着源码走一遍能省下好几个晚上翻文档的时间。2. 从 csproj 到 Form1.cs把 Halcon 接进 WinForm 的代码骨架2.1 先读懂压缩包里的文件结构拿到 frmWindowTest.rar解压之后你会看到一套标准的 C# WinForm 项目结构。frmWindowTest.sln 是解决方案入口frmWindowTest.csproj 是工程文件Form1.cs 是主窗口逻辑Form1.Designer.cs 是界面控件的声明和布局代码Program.cs 是程序启动入口。bin 和 obj 目录是编译产物Properties 里存着程序集信息。22.jpg 是 Halcon 从相机里截取并保存的测试图Form1.resx 是窗体资源文件。这份代码骨架的关键不在这些文件本身而在 csproj 里的引用关系。Halcon 在 C# 里要跑起来核心依赖是halcondotnet.dll你在工程里必须正确引用它否则后面所有 Halcon 命名空间都打不开。用文本编辑器打开 frmWindowTest.csproj你会看到类似这样的引用配置Reference Includehalcondotnet HintPath..\..\Program Files\MVTec\Halcon\bin\dotnet35\halcondotnet.dll/HintPath /Reference这个 HintPath 是相对路径和你本机 Halcon 的安装位置直接相关。我一般会把它改成绝对路径或者用环境变量拼出来因为换一台电脑编译时最常见的报错就是找不到 halcondotnet。需要注意Halcon 的 dotnet 目录下还有 dotnet20、dotnet35、dotnet40 等子目录对应不同的 .NET Framework 版本。WinForm 项目绝大多数用的都是 .NET Framework 4.x所以引用 dotnet40 下的 halcondotnet.dll 是最稳的。如果你打开工程发现引用已经丢失Visual Studio 里会显示黄色感叹号。此时右键引用节点选择「添加引用」→「浏览」手动定位到C:\Program Files\MVTec\Halcon\bin\dotnet40\halcondotnet.dll确认目标框架一致后重新编译即可。2.2 Halcon 的两种调用姿势HDevEngine 还是 HOperatorSetHalcon 在 C# 里有两种常用的集成方式一种是直接用 HOperatorSet 静态类逐算子调用另一种是启动 HDevEngine 来执行 .hdev 脚本。frmWindowTest 这种场景下我的习惯是直接用 HOperatorSet因为它的控制粒度最细算子执行完能立刻拿到 HObject 和 HTuple 结果没有脚本解释器的额外开销。using HalconDotNet; public partial class Form1 : Form { private HObject ho_Image; private void GrabAndDecode() { HOperatorSet.GrabImage(out ho_Image, hv_AcqHandle); HOperatorSet.FindDataCode2d(ho_Image, out HObject ho_SymbolXLDs, new HTuple(), new HTuple(qr_tolerance, minimum_contrast), new HTuple(0.5, 15), out HTuple hv_DataCodeHandle, out HTuple hv_ResultStrings); if (hv_ResultStrings.Length 0) { labelResult.Text 识别结果: hv_ResultStrings.S; } } }这段代码的核心逻辑是先 GrabImage 抓一帧图像紧接着用 FindDataCode2d 在图像里找二维码最后把识别的字符串显示到窗体 Label 上。我们需要留意hv_AcqHandle是从哪里来的——它是 OpenFramegrabber 算子返回的采集句柄代表一个已打开的摄像头设备。摄像头如果没打开GrabImage 这一行就会直接抛异常。每次调用 FindDataCode2d 都重新创建 DataCodeHandle 其实不够高效。工业现场的推荐做法是初始化时创建一次模型之后每帧复用这样省去了算子内部的模板加载时间。至于这个模型怎么创建和复用下一章讲摄像头采集时会一并说清楚。3. DirectShow 摄像头采集为什么不用 AForge以及 Filter Graph 的搭建细节3.1 选型考量Halcon 自带采集、AForge.NET 与 DirectShow 的取舍Halcon 本身是有采集接口的。OpenFramegrabber指定DirectShow作为采集设备类型就能直接拉起摄像头。但实际项目中我发现一个问题Halcon 的 DirectShow 采集对笔记本内置摄像头的兼容性时好时坏有些型号的摄像头在 Halcon 里能打开却拿不到正确的分辨率另一些则根本枚举不到。这背后通常是 Camera 驱动对 MediaType 的协商逻辑不同Halcon 强制用某种格式去请求驱动不认就直接失败。AForge.NET 的 VideoCaptureDevice 用起来简单但它的底层封装在 USB 摄像头上默认用 MJPG 压缩流拿到帧以后要手动解码成 Bitmap再转成 Halcon 的 HObject中间多了一层格式转换而且 AForge 项目很多年没更新在新系统上容易踩坑。frmWindowTest 直接走 DirectShow 原生接口好处是链路最短摄像头 → Filter Graph → SampleGrabber → 内存中的图像数据没有多余的编解码环节拿到的是原始画面给 Halcon 处理时最省事。3.2 枚举摄像头设备与构建 Filter GraphDirectShow 编程的第一步是枚举系统里的视频输入设备。下面这段代码使用 DirectShowLib 库枚举所有摄像头填充到 ComboBox 里。using DirectShowLib; private void EnumVideoDevices(ComboBox cb) { DsDevice[] devices DsDevice.GetDevicesOfCat(FilterCategory.VideoInputDevice); foreach (DsDevice device in devices) { cb.Items.Add(device.Name); } if (cb.Items.Count 0) { cb.SelectedIndex 0; } }DsDevice 是 DirectShowLib 提供的设备封装类型GetDevicesOfCat 传入 FilterCategory.VideoInputDevice 即可拿到所有视频输入设备。这个方法在 Windows 7 到 Windows 11 上都稳定可用原因是 DirectShow 的枚举机制沿用自系统底层不依赖具体的摄像头品牌。拿到设备名之后就要用 FilterGraph 拉起采集链路。Form1 里把 GraphBuilder、CaptureGraphBuilder、SampleGrabber 都声明为窗体级私有字段因为整个采集生命周期内都要保持这些 COM 对象存活。GraphBuilder 是 Filter Graph 的容器CaptureGraphBuilder 负责把摄像头 Filter、SampleGrabber、渲染器连接起来SampleGrabber 则是图像数据流经我们代码的「偷看窗口」。private ICaptureGraphBuilder2 captureGraph; private IGraphBuilder graphBuilder; private ISampleGrabber sampleGrabber; private IMediaControl mediaControl; private bool StartCamera(int deviceIndex) { graphBuilder (IGraphBuilder)new FilterGraph(); captureGraph (ICaptureGraphBuilder2)new CaptureGraphBuilder2(); sampleGrabber (ISampleGrabber)new SampleGrabber(); captureGraph.SetFiltergraph(graphBuilder); DsDevice[] devices DsDevice.GetDevicesOfCat(FilterCategory.VideoInputDevice); IBaseFilter sourceFilter null; devices[deviceIndex].Mon.BindToObject(null, null, typeof(IBaseFilter).GUID, out sourceFilter); graphBuilder.AddFilter(sourceFilter, VideoSource); AMMediaType mediaType new AMMediaType(); mediaType.majorType MediaType.Video; mediaType.subType MediaSubType.RGB24; sampleGrabber.SetMediaType(mediaType); captureGraph.RenderStream(PinCategory.Capture, MediaType.Video, sourceFilter, sampleGrabber, null); mediaControl (IMediaControl)graphBuilder; mediaControl.Run(); return true; }这段代码的要点是先用 BindToObject 把设备标识变成真实的源 FilterAddFilter 加入 Graph然后设置 SampleGrabber 的媒体类型为 RGB24最后用 RenderStream 把采集管线的链路搭通。RenderStream 的第三到第五个参数分别是源、中间环节、目标这里把源 Filter 作为源头SampleGrabber 作为中间处理null 表示让 GraphBuilder 自动选择默认的渲染器。这一步对于没有显示窗口的控制台或后台处理模式特有用因为我们不需要 VideoWindow只要数据流经过 SampleGrabber 就够。3.3 帧回调与图像传递到 HalconSampleGrabber 拿帧有两种方式轮询 BufferCB 接口或者设置回调模式。回调模式更高效因为每来一帧系统会主动通知你。实现 ISampleGrabberCB 接口并注册帧数据会以 IntPtr 形式传给你这时候把它转成 Halcon 能吃的 HObjectprivate class SampleGrabberCallback : ISampleGrabberCB { public int BufferCB(double SampleTime, IntPtr pBuffer, int BufferLen) { // 相机分辨率例如 640x480 int width 640; int height 480; HOperatorSet.GenImage1(out HObject ho_Image, byte, width, height, pBuffer); // 把图像交给主线程处理避免跨线程访问控件 return 0; } }GenImage1 是 Halcon 里把裸内存数据包装成 HObject 的关键算子第四个参数接收的就是 DirectShow 回调里的帧指针。我们需要注意的是像素格式必须匹配——SampleGrabber 设置成 RGB24那这里图像类型就是byte宽度高度对齐后Halcon 就能直接在这张图上跑 FindDataCode2d 了。为什么要用回调而不是主线程轮询因为视频帧率通常是 30fps每帧间隔约 33 毫秒轮询容易丢帧或者延迟累积。而回调是独立线程触发的主线程只负责刷新界面读码操作如果耗时较长可以放到队列里异步处理。frmWindowTest 里直接在 BufferCB 里做了读码实际项目中如果读码较慢我更建议把图像放入 ConcurrentQueue由后台线程消费避免阻塞采集线程导致画面卡顿。4. Halcon 二维码解码从 GrabImage 到 find_data_code_2d 的参数调优4.1 读码算子的完整调用链Halcon 读二维的算子主入口是find_data_code_2d它同时支持 QR Code、Data Matrix 等常见码制。frmWindowTest 里的调用方式非常典型值得拆开讲一下。完整的读码流程是创建模型 → 设置参数 → 执行查找 → 提取字符串 → 释放模型。// 创建数据码模型 HOperatorSet.CreateDataCode2dModel(QR Code, new HTuple(), new HTuple(), out HTuple hv_DataCodeHandle); // 设置容错与对比度参数 HOperatorSet.SetDataCode2dParam(hv_DataCodeHandle, qr_tolerance, 0.5); HOperatorSet.SetDataCode2dParam(hv_DataCodeHandle, minimum_contrast, 15); // 执行查找 HOperatorSet.FindDataCode2d(ho_Image, out HObject ho_SymbolXLDs, hv_DataCodeHandle, new HTuple(), new HTuple(), out HTuple hv_ResultStrings); if (hv_ResultStrings.Length 0) { // hv_ResultStrings.S 拿到的就是二维码内容 textBoxResult.Text hv_ResultStrings.S; } else { textBoxResult.Text 未识别到二维码; }第一步 CreateDataCode2dModel 只执行一次放在摄像头打开之后初始化。这里有个性能细节find_data_code_2d 每次调用都带上模型句柄模型会缓存码制信息、容错参数等所以执行查找时不需要重新解析模型配置速度能快不少。工业视觉里常见的误区是每帧新建模型、用完再释放频繁的模型创建销毁会严重影响帧率。qr_tolerance参数控制的是图像畸变和打印误差的容错范围值越大对变形、模糊的容忍度越高但误识别率也会上升。minimum_contrast决定码图案与背景的最小对比度暗光环境下要调低强反光场景要调高。这两个参数是读码稳定性最大头的两个变量后面避坑章节我会展开细讲。4.2 参数表读码稳定性的关键旋钮参数名作用常见取值调试建议qr_tolerance容错率对污损/畸变的容忍度0.3 ~ 0.8二维码有遮挡或打印变形时调大minimum_contrast最小对比度过滤低对比度干扰5 ~ 40暗光调低反光调高module_size_min模块最小尺寸过滤小噪点2 ~ 5码距相机远时调小persistence模型持久化级别0 ~ 30 不持久化3 开启持久化提升连续帧速度time_out单次查找超时时间(ms)100 ~ 5000根据帧率和码数量设置一张 QR Code 由多个模块组成背景越乱、码越小越需要精细调整上面这些参数。我一般调试顺序是先固定对比度 15把 qr_tolerance 从 0.3 逐步往上加每档都拿 20 张现场图片试跑一编看识别率的变化趋势。识别率上不去再动 minimum_contrast。这样能快速定位是哪个因素在拖后腿。4.3 在线读码与离线读图并行验证frmWindowTest 里放了一张 22.jpg这是从相机里保存下来的真实帧。它的价值非常大——你可以把 GrabImage 临时替换成 ReadImage用这张静态图做读码 pipeline 的回归验证不去碰摄像头排除硬件因素。// 离线调试读静态图验证算法链路 HOperatorSet.ReadImage(out HObject ho_TestImage, 22.jpg); HOperatorSet.FindDataCode2d(ho_TestImage, out HObject ho_Symbols, hv_DataCodeHandle, new HTuple(), new HTuple(), out HTuple hv_Codes);这样做的意义在于如果你的代码用 22.jpg 能读出二维码但接上摄像头就时好时坏问题几乎可以断定在图像采集质量而不是 Halcon 识别逻辑。反过来说如果 22.jpg 都读不出来那就要回头检查模型参数和安装的 Halcon Runtime 是否正常。这个技巧临床检验非常有效能帮你在两分钟内把问题定位到「采集」还是「识别」环节。5. 高频避坑摄像头、Halcon 与 WinForm 组合的五个典型翻车现场5.1 x86/x64 位平台不匹配导致 Halcon 加载失败现象编译能通过但程序一运行就报BadImageFormatException或者 Halcon 算子调用时提示找不到 halcondotnet.dll。在 Visual Studio 里点运行偶尔不报错打包到别的机器就崩。原因Halcon 的 dotnet 程序集是按位数区分的x64 版本在bin\x64-win64\dotnet40下x86 版本在bin\win32\dotnet40下。如果工程编译成 AnyCPU在 64 位系统上默认以 64 位运行但你引用的却是 32 位的 halcondotnet.dll加载时就会炸。DirectShow 的 COM 组件也分位数摄像头驱动经常只提供 64 位或 32 位版本两边的位数必须对死。解决显式把工程平台目标设为 x64绝大多数现代电脑的选择。路径是项目属性 → 生成 → 平台目标 → 选 x64然后把 halcondotnet.dll 引用换成C:\Program Files\MVTec\Halcon\bin\x64-win64\dotnet40\halcondotnet.dll。同时确保 DirectShowLib 也是 x64 编译。从那以后我每次新建工程第一件事就是把平台目标定死绝不放 AnyCPU。5.2 摄像头被其他程序占用GrabImage 超时挂死现象程序启动时摄像头预览正常但运行几分钟后界面假死或者调试时走到 GrabImage 就卡住不动。把程序关掉再开提示相机被占用。原因很多笔记本自带的摄像头驱动不支持多客户端并发。如果你的程序启动后没有正确调用CloseFramegrabber释放设备又一次重复打开或者系统里有其他软件视频会议、系统相机应用在使用摄像头DirectShow 的 RenderStream 可能会一直等待设备就绪导致线程阻塞。解决在 OpenFramegrabber 之后设置超时参数并给采集线程加保护机制。Halcon 的 OpenFramegrabber 有一个timeout参数可以设置设备打开等待时间HOperatorSet.OpenFramegrabber(DirectShow, 1, 1, 0, 0, 0, 0, default, 8, rgb, -1, false, default, [0] Integrated Camera, 0, -1, out hv_AcqHandle);最后一个参数-1是默认不超时等待我一般改成 3000 毫秒。同时用 try-catch 把 GrabImage 包起来异常时主动释放句柄并重试这样即使摄像头被临时占用程序也不会整窗假死。5.3 二维码反光或过暗导致读取失败现象Halcon 没报错但 FindDataCode2d 返回的 ResultStrings 为空。把 22.jpg 拿来做离线测试却一切正常。检查摄像头画面发现屏幕上二维码区域反光发白或者整体偏暗。原因摄像头自动曝光和自动白平衡把画面亮度拉到了一个不适合读码的状态。反光时 minimum_contrast 只有 15但实际局部对比度可能只有 58过暗时二维码模块之间的边界在灰度图上几乎消失Halcon 根本找不到定位图案。解决把 minimum_contrast 降到 5 再试同时把 qr_tolerance 调到 0.6 增加容错。如果反光严重我的做法是在摄像头正上方加一块偏振片或者调整光源角度到 45 度入射把镜面反射移出相机视野。代码层面可以在 GrabImage 之后先跑一次scale_image或equ_histo_image做灰度拉伸把对比度拉开再送进读码算子。5.4 窗体最小化或锁屏后视频流中断现象程序挂着跑人离开工位电脑锁屏回来发现界面黑屏重新打开窗体也无法恢复图像。只有重启程序才能恢复。原因DirectShow 的 Filter Graph 在渲染窗口不可见时Video Renderer 会暂停接收样本此时 SampleGrabber 的回调也随之停止。锁屏后桌面会话切换到安全桌面视频渲染器的输出目标失效整条采集链路被系统挂起。解决监控窗体的 Resize 和系统电源事件记录锁屏状态恢复后重建 Graph。最稳妥的方案是检测到画面中断超过 3 秒时主动停止 mediaControl释放 sourceFilter然后重新执行第 3 章的 StartCamera 流程。这套重启逻辑可以封装成一个RestartCamera()方法在SystemEvents.SessionSwitch事件或窗体的VisibleChanged事件里调用。5.5 Halcon Runtime 版本不一致导致算子异常现象代码在本机能跑打包到别的电脑直接报异常异常信息里提到halcon.dll或者HALCON/路径找不到甚至 FindDataCode2d 执行到一半退出。原因Halcon 是商业库运行目标机器必须安装对应版本的 Halcon Runtime 或者把依赖的原生 DLL 一并拷贝过去。只拷 halcondotnet.dll 不够因为它是 C 原生库halcon.dll的 .NET 包装层原生库版本不匹配时托管层加载会失败。解决用 Halcon 自带的 Runtime 安装包在目标机器上装一遍对应版本。如果不想装包就把bin\x64-win64下的 halcon.dll、hdevengine.dll 等原生 DLL 拷贝到程序根目录和 halcondotnet.dll 放在一起。我在部署文档里会特别标注Halcon 的版本号必须和开发机完全一致20.11 的工程不要用 17.12 的 Runtime 去跑算子行为会有差异。6. 进阶验证技巧把采集链路隔离掉用 22.jpg 快速回归 Halcon 读码逻辑做视觉工程的人都明白一个道理硬件环节和算法环节搓在一起调试出了问题两头都怀疑。frmWindowTest 给了一张 22.jpg这就是一个很好的回归测试工具。我在自己项目里会把这套逻辑做成一个「无相机模式」方便任何时候快速验证读码部分。6.1 无相机模式的实现思路在 Form1 里加一个布尔开关useCameratrue 时走 DirectShow 采集false 时定时从磁盘读图。读码逻辑完全共用同一个方法这样你既能验证算法参数也能验证界面显示逻辑。private bool useCamera false; // 调试时设为 false部署时改为 true private Timer timerDecode; private void timerDecode_Tick(object sender, EventArgs e) { try { HObject ho_Image; if (useCamera) { HOperatorSet.GrabImage(out ho_Image, hv_AcqHandle); } else { HOperatorSet.ReadImage(out ho_Image, 22.jpg); } HOperatorSet.FindDataCode2d(ho_Image, out HObject ho_Symbols, hv_DataCodeHandle, new HTuple(qr_tolerance, minimum_contrast), new HTuple(0.5, 15), out HTuple hv_Codes); if (hv_Codes.Length 0) { labelStatus.Text OK: hv_Codes.S; } else { labelStatus.Text NG: 未识别; } ho_Image.Dispose(); } catch (HalconException ex) { labelStatus.Text 异常: ex.GetErrorMessage(); } }这段代码的巧妙之处在于除了图像来源不同读码、显示、异常处理全部走同一条路径。你调 qr_tolerance 或者 minimum_contrast 的时候只需要在定时器里改一行参数运行一次就能知道改动是变好还是变坏。ho_Image.Dispose()这行不能省Halcon 的 HObject 包装的是原生内存不及时释放长时间跑会内存暴涨。6.2 用连续帧测试参数稳定性单张图片识别成功还不算完工业场景里二维码不会永远端正地出现在画面中央。我一般会在无相机模式下准备 5 到 10 张不同角度、不同距离、不同光照下的测试图循环播放模拟连续的识别场景。frmWindowTest 里只有一张 22.jpg你自己可以扩充这个测试集。string[] testImages { 22.jpg, test2.jpg, test3.jpg }; int idx 0; private void timerDecode_Tick(object sender, EventArgs e) { HOperatorSet.ReadImage(out HObject ho_Image, testImages[idx]); idx (idx 1) % testImages.Length; // 后面读码逻辑和前面一致 }这样跑上几百帧如果识别率稳定在 100%再把 useCamera 切回 true接上摄像头做实拍验证。如果实拍出现偶发失败回到第 4 章的参数表逐项对照排查。这个「先离线后在线」的习惯我坚持了很多年几乎每次都能在五分钟内定位出问题的根源不是在算法上瞎调也不是把板子打到摄像头上。希望这个思路对你有用。本文还有配套的精品资源点击获取
