ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

C#与Halcon车牌识别开源工具:从图像定位到OCR的完整实现

C#与Halcon车牌识别开源工具:从图像定位到OCR的完整实现

前阵子把几个项目里一直在用的车牌识别逻辑单独抽出来,做成了一个可独立运行的开源小工具。技术栈很直接:C# 负责界面、任务调度和结果输出,Halcon 负责图像预处理、车牌定位、字符切分和 OCR 识别。源码是完整的一套,拿到之后可以直接编译运行,不用自己再补一堆依赖。这个工具解决的问题很聚焦:停车场出入口、园区门禁、厂区内部车道这类固定点位抓拍场景,输入一张包含车头的图像,输出车牌号码、置信度、位置坐标,同时保留原始截图和识别记录。如果你正在做工业视觉、安防监控或者上位机开发,又不想从头去碰图像算法,这套代码值得花点时间看看。

1. 车牌识别工具的整体设计与思路拆解

1.1 车牌识别的核心链路

车牌识别本质是一条固定流水线:图像读取、预处理、车牌区域定位、几何校正、字符分割、OCR 识别、业务后处理。听起来简单,真正落地时每个环节都有坑。比如“定位”这一步,如果抓拍图像背景里有大量蓝色物体,分分钟会把车身贴膜、路边广告牌当车牌;“字符分割”遇到汉字笔画断裂、字符粘连,识别结果就会变成乱码;“OCR 识别”面对模糊字符、反光和倾斜角度,置信度会明显下降。

这个工具在设计时把整条链路拆成了四个独立模块:图像获取模块负责从文件目录或相机回调拿原始帧,预处理模块负责灰度化、滤波和 ROI 裁剪,车牌检测模块负责定位和校正,字符识别模块负责切分和 OCR。模块之间只传标准化结果对象,比如PlateImage和PlateResult,这样后续不管是替换算法实现,还是接入新的相机型号,都不会牵一发动全身。

这种拆法还有一个现实原因:实际项目里往往不是“识别不准”,而是“不知道哪里不准”。把链路拆开之后,每一层都能单独输出中间状态图。我在工具里加了一个 Debug 模式,开启后会依次画出预处理结果、候选区域、校正后的车牌图、字符切分边界。排错的时候不用猜,直接看图就知道问题出在哪一环。

1.2 为什么这次选了 C# 加 Halcon

开发这类工具,常见的方案还有“C++ + OpenCV”和“Python + OpenCV”。C++ 性能上限高,但开发周期长,尤其是界面、日志、数据库上报这些业务功能要写大量代码。Python 做算法验证很快,但交付给现场使用时,打包环境、版本兼容、性能抖动都是麻烦事。C# 恰好卡在一个很舒服的位置:Windows 生态成熟,WPF 做界面顺手,串口、数据库、网络通信都有现成库,和 Halcon 的 .NET 接口配合也特别自然。

Halcon 在这个项目里起到的作用不是简单地调用几个算子,而是把图像处理里最麻烦的部分做了工业级优化。它的形态学算子、连通域分析、OCR 分类器,在常见的工业场景中表现稳定,很多细节参数不需要自己从头调。比如字符切分之后要筛选“面积符合车牌字符大小”的区域,如果纯用 OpenCV 写,需要自己控制轮廓层级和几何规则;而在 Halcon 里,一个select_shape加几个参数就能把大多数干扰区域清掉。开发效率和鲁棒性都能兼顾。

当然,选型也要考虑成本。Halcon 是商业授权软件,不是所有场景都愿意花这部分预算。但如果你已经有 Halcon 许可证,或者正在评估工业视觉方案,那这套 C# + Halcon 的架构就可以直接作为参考;如果完全没有预算,后续可以把 Halcon 调用层替换成 OpenCV 实现,整体接口不需要动,这种替换成本在设计阶段就已经预留出来了。

2. 工具选型解析:车牌识别中的关键算法细节

2.1 图像获取与预处理阶段

实际项目里,图像来源通常有两种:网络相机抓拍帧和本地图片文件。开源工具里我两条路都保留了。测试阶段,最省事的方式是直接把一批图片放到固定目录下,程序启动后批量识别,输出 CSV 结果;现场部署时,再通过相机 SDK 的回调接口把帧数据转成HImage送入识别流程。

预处理阶段要控制“度”。第一步通常是转灰度,如果车牌底色是蓝色,也可以直接在 HSV 色彩空间里提取色相通道,这样对光线变化更稳健。中值滤波要去掉压缩噪声和传感器噪点,但滤波窗口不能太大,我用的是 3×3,否则字符边缘会被磨掉,后面做二值化时笔画容易断。对比度拉伸同样要留余地,尤其是在夜间抓拍图像中,直接做全局直方图均衡化会让反光区域过曝,车牌字符反而看不清。

另一个关键点是 ROI。固定点位的相机,车牌出现的位置往往高度集中在某个区域。不要一上来就对整张 1920×1080 图像跑算法,先按经验把识别区域缩到画面中央偏下的一个多边形里。这样既能减少误检,又能明显降低处理耗时。这个工具里 ROI 区域可以在界面里手动拖拽保存,识别时直接加载配置。

2.2 车牌定位的思路和参数选择

这个工具里使用的定位方式是“颜色特征 + 形态学闭运算 + 连通域筛选”,实际效果在大多数场景下都够用。国内常见的蓝牌、绿牌、黄牌、白牌,底色相对固定,而且车牌区域的字符和底色之间对比度很高,提取颜色区间之后能形成一块边界清晰的色块。

以蓝牌为例,我会把 RGB 图像转成 HSV,取色相和饱和度阈值范围,过滤出蓝色像素,再生成二值掩膜。掩膜上通常会包含车身、车灯、背景广告牌等蓝色区域,接着做一次闭运算,让内部的白色字符区域被周围的蓝色像素填充,整个车牌就会连成一个接近矩形的连通域。关键参数是闭运算的卷积核尺寸,我用的是宽 30、高 10 的矩形结构元素,专门针对车牌这种“扁长形状”设计。核太大容易把旁边物体也连进来,核太小字符区又补不上,这个尺寸需要在调试环境里多试几轮。

连通域筛选用select_shape,主要关注面积、宽度、高度和长宽比。不同分辨率的图像,面积阈值必须动态计算,不能写死。比如 1920×1080 图像里,车牌区域面积通常占整幅画面的 2% 到 5%,我在代码里按图像总像素数换算出一个合理区间,再把宽高比限制在 2.5 到 5.2 之间。经过这一步,误检区域基本能清掉,剩下少数候选区域再用字符纹理特征做二次确认。

倾斜校正是很多人容易忽略的步骤。车牌不可能是绝对水平的,常见倾斜角度在正负 15 度以内。我提取每个候选区域的最小外接矩形,拿到角度值之后做仿射变换,把车牌区域旋转到水平状态。这一步不能省,否则后面字符切分的垂直投影会偏差极大,识别率断崖式下降。

2.3 字符切分与 OCR 分类

字符切分我采用“连通域分析 + 垂直投影”组合方式。第一步是通过二值化把字符变成白色前景,连通域分析能找到每个独立字符块;第二步利用字符之间固定间距的特点,用垂直投影直方图切分连续字符列。如果字符发生粘连,连通域会把两个字符连成一个块,这时候再用固定宽度滑动切分来补救。相反,如果笔画断裂导致一个字符变成两个块,就根据字符宽度阈值做合并。

OCR 识别层是这套工具里最需要根据业务定制的地方。Halcon 自带常用的工业字符集分类器,对英文和数字识别效果不错,但中文车牌里的省份简称,比如“京、沪、粤、苏、浙”,这些汉字在标准英数分类器里是不存在的。所以在这个项目中,我额外准备了一个针对省份简称的训练数据,用一批真实车牌图片切出的汉字样本做了分类器训练。分类器输出每个字符候选类别和置信度,后面再接一层正则校验。

正则校验是车牌识别特有的是“业务常识约束”。普通字符识别可能出现的“O”和“0”、“I”和“1”混淆,放到车牌规则里就很好处理:第二位一定是字母,最后五位通常是字母和数字混合,但不能出现 I 和 O。代码里直接用正则表达式过滤,把明显不符合车牌规则的候选组合直接淘汰,置信度排在后面的合法组合补上来。这步改动看起来不大,但整体识别准确率能提升好几个点。

3. 实操过程与核心环节实现

3.1 工程结构与环境准备

开源工程目录分成三块:PlateDetector负责定位和校正,OcrRecognizer负责字符切分和分类,MainApp负责界面与调度。这样分的好处是,算法模块可以被其他项目直接引用,不需要连带界面代码一起拷走。

环境准备方面,安装 Halcon 后,在安装目录的bin目录下能找到halcondotnet.dll和hdevengine.dll,把这两个程序集引用进 C# 工程。需要注意,主程序的项目平台目标要改成 x64,因为 Halcon 运行时是按 64 位发布的,如果不匹配,加载时会直接报BadImageFormatException。第一次运行前,还要把 Halcon 运行时目录加入 PATH,或者把需要的运行时文件拷贝到程序输出目录,否则调用任何算子都会报“找不到动态库”。

界面我用的是 WinForms,代码结构更直白,方便大家看原理。在主窗体上放一个PictureBox显示原始图和识别结果,旁边是识别日志列表,底部是统计信息。为了不阻塞界面,所有识别操作都放到后台线程池里执行,识别完成后再通过事件回传结果。

3.2 核心算子调用:定位、校正与识别

在 C# 中调用 Halcon 算子,最直接的方式是通过HOperatorSet静态方法。下面是一段典型的定位代码,演示从图像读取到候选区域筛选:

using HalconDotNet; HImage image = new HImage(imagePath); // 转 HSV,提取蓝色通道范围 HImage hsv = image.TransToRgb("hsv"); HChannel hChannel = hsv.Decompose3(out HChannel sChannel, out HChannel vChannel); // 蓝色车牌阈值 HRegion blueRegion = hChannel.Threshold(200, 255); HRegion greenRegion = hChannel.Threshold(90, 140); // 新能源绿牌 HRegion mask = blueRegion.Union2(greenRegion); // 闭运算,填充字符间隙 HRegion closed = mask.DilationRectangle1(30, 10); closed = closed.ErosionRectangle1(30, 10); // 连通域筛选 HRegion connectedRegions = closed.Connection(); HRegion selected = connectedRegions.SelectShape("area", "and", minArea, maxArea); selected = selected.SelectShape("width", "and", 100, 800); selected = selected.SelectShape("height", "and", 30, 200);

因为 Halcon 算子的输出参数使用out传递,变量必须先声明再传入,看清楚类型就能减少编译报错。上面代码里的Decompose3返回三个通道,我用out HChannel sChannel, out HChannel vChannel的方式直接接收,这是比较常见的 C# 写法。阈值范围只是示例值,真实现场需要根据相机型号和画面亮度调整。

拿到候选区域后,再做一次找最小外接矩形和仿射变换:

// 计算最小外接矩形 HRegion plateRegion = selected[0]; HTuple row, col, phi, len1, len2; plateRegion.SmallestRectangle2(out row, out col, out phi, out len1, out len2); // 生成旋转矩阵并校正 HHomMat2D homMat = new HHomMat2D(); homMat = homMat.HomMat2dRotate(-phi, row, col); HImage rotated = image.AffineTransImage(homMat, "constant", "false");

这里有个关键点:phi的单位是弧度,角度转正时需要取反,否则会把本来就水平的图像转歪。代码注释里我特意写了这个坑,之前版本漏掉取反导致现场调试时怎么都识别不对。

字符切分和 OCR 识别的核心代码如下:

// 把校正后的区域裁剪出来,转灰度并二值化 HImage plateGray = rotated.Rgb1ToGray(); HRegion plateRegion = plateGray.Threshold(0, 120); HRegion charRegions = plateRegion.Connection(); // 用连通域筛选字符块 HRegion charBlocks = charRegions.SelectShape("height", "and", 20, 120); // OCR识别 HTuple ocrHandle; HOperatorSet.ReadOcrClassMlp("Industrial_0-9A-Z_NoRej.omc", out ocrHandle); HTuple classes, confidences; HOperatorSet.DoOcrMultiClassMlp(charBlocks, plateGray, ocrHandle, out classes, out confidences);

这段代码里使用的训练分类器是 Halcon 自带的标准英数模型,适合识别英文和数字。省份汉字的识别需要切换到我训练好的中文分类器,工程里已经提供了对应的.omc文件,直接替换ReadOcrClassMlp的参数即可。

3.3 识别结果的工程化封装

识别结果不能只返回一个字符串,实际业务里至少需要四样东西:车牌号、整体置信度、车牌位置坐标、识别时间。我在工程里定义了一个PlateResult类,字段包括PlateText、Confidence、BoundingBox、SnapPath、IsValid。这样上层 UI 拿到的就是一个完整对象,不需要再反查原始数据。

置信度的计算不能简单对每个字符取平均,因为汉字识别错误往往发生在低置信度字符上,但英数部分可能很准。我用了加权平均:汉字段权重 0.2,字母段权重 0.5,数字段权重 0.3。具体权重可配置,在实测中比简单平均更能反映真实可靠程度。

对外接口也做了异步封装。调用方传入图像路径或HImage,立即返回Task<PlateResult>。内部通过线程池把识别任务丢到后台,识别完再回到上下文回调。这样的好处是,即使一台机器接了几路相机的画面轮流识别,业务层也不会被卡住。

3.4 多线程调度与 UI 刷新

写上位机不要在主线程里做图像算法,这个原则在这个工具里贯彻得很彻底。相机抓帧线程只负责往内存队列里塞FrameItem,识别线程从队列取图,跑完算法后把结果投递到 UI 线程。队列使用ConcurrentQueue,并且限制最大长度,超过阈值直接丢弃最旧的帧。这样做可以避免现场相机帧率不稳时内存无限增长。

UI 刷新使用事件方式,识别完成触发OnPlateRecognized事件,界面订阅后更新识别列表和图片框。注意界面控件跨线程更新要使用Invoke或BeginInvoke,否则会出现线程安全问题。调试时我习惯在事件回调里加一个Stopwatch统计耗时,这样能直观看到每帧的耗时瓶颈在定位还是识别。

4. 常见问题与排查技巧实录

4.1 Halcon 环境与集成问题

最容易翻车的是 DLL 相关报错。HalconDotNet.dll引用没问题,但运行时找不到hdevengine.dll,原因是项目输出目录里没有拷贝运行时。解决方案很简单,把安装目录里的hdevengine.dll等运行时文件设为“复制到输出目录”,或者统一放到系统 PATH 中。每次升级 Halcon 版本后,记得把引用路径重新指一下,否则会出现版本不匹配的异常。

另一个常见问题是 32 位和 64 位不匹配。C# 工程默认是AnyCPU,在 64 位系统上会以 64 位运行,但 Halcon 的某些配套组件还是 32 位时就会崩溃。我直接在工程属性里把平台目标改为x64,彻底避开这种问题。如果现场机器是 32 位系统,那就需要整套换成 32 位运行时,不能混用。

许可证问题也不少见。Halcon 使用授权许可证,第一次运行会在控制台或日志里输出授权错误。遇到这类提示先检查两件事:环境变量HALCON_LICENSE_PATH是否指向许可证目录,当前系统时间是否和授权期限一致。不要想着绕过授权,正确做法是联系供应商购买合适的授权类型。

4.2 车牌定位不准或误检

定位问题在四个环节都可能出现,我整理了一个排查表,方便直接对应处理:

现象可能原因处理方式
蓝色底色区域完全没找出来色相通道阈值过窄放宽 HSV 阈值范围,增加饱和度约束
车身贴膜、蓝色车灯被当车牌颜色区间太宽 + 形态学核过大缩小结构元素尺寸,增加宽高比约束
定位结果倾斜严重最小外接矩形角度跳变检查phi取反,增加角度平滑处理
夜间反光导致区域断裂二值化阈值过低改用自适应阈值,或做局部对比度增强

我的习惯是优先用 Debug 模式看中间图。如果掩膜图上目标区域颜色不对,说明色彩空间通道选错了;如果区域没有连成完整矩形,说明闭运算核太小;如果筛选出的区域形状不对劲,那问题在select_shape参数而不是后续算法。按这个逻辑逐个排除,通常几分钟就能定位到问题源头。

4.3 字符分割和 OCR 的坑

字符切分最常见的现象是“两个字粘在一起识别成一个字”或者“一个字被拆成两个”。前者多数是因为二值化之后字符之间仍有连接,可以用腐蚀操作断开,腐蚀核不能太大,否则笔画细的字符会消失。后者通常是字符内部笔画断裂,比如反光让字符中段变成背景色,这时需要按宽度合并相邻连通域。

OCR 层面,英文和数字在标准分类器下表现很好,容易出问题的是汉字。车牌汉字字形相对固定,但不同省份字体和笔画密度差异大,我建议自己采集真实场景字符样本训练分类器,至少覆盖业务范围内可能出现的那几个省份简称,不要贪多。训练样本每类至少几百张,保证光照、角度和清晰度的多样性。

后处理能救回不少误识别。我写了一个字符级黑白名单:第二位只能是字母,字符长度固定为 7,不能出现字母 I 和 O。最后再通过多帧校验:同一辆车抓三帧,三帧结果一致才输出,任何一帧不同就重置计数。虽然牺牲了一点实时性,但在入口道闸这种场景里,稳定压倒一切。

4.4 性能瓶颈与优化方向

性能优化不一定非得 GPU。我先用Stopwatch测出定位、切分、识别三段的耗时,发现定位阶段如果全图处理会吃掉接近一半时间。于是把 ROI 裁剪提前,识别区域只保留画面中的车道范围,处理后耗时降了 40%。如果 ROI 还不够,再开 OpenCL 加速通道。Halcon 支持很多 GPU 算子,但启用后需要监测不同硬件下的显存占用,部分老显卡反而比 CPU 慢。

多路相机并发识别时,要避免无限制创建线程。我用的线程池固定为 4 个 worker,任务队列排队处理。实际测试中,4 路 1080p 画面保持 15 fps 没有问题。如果任务持续积压,说明单机算力不够,这时不要优化程序了,直接在架构上扩展一台识别节点。

5. 开源结构、后续扩展与个人经验

5.1 源码工程里包含什么

工程里包含的源码覆盖了完整闭环:界面程序、算法封装、分类器文件、示例图片、说明文档。示例图片我放了几十张不同场景的抓拍,包括白天、夜间、顺光和逆光,方便第一次跑起来就能立刻看到效果。说明文档里写了环境配置步骤和每个参数的调整建议。分类器文件单独放在Resources目录,如果觉得准确率不够,可以直接替换成自己训练好的模型。

5.2 如何接入新场景

换现场时,最该调整的不是算法,而是 ROI 和相机参数。每个点位的相机高度和角度都不同,保存一套独立的CameraProfile配置比较合适。工具里已经做了配置文件热加载,切换现场时只需要换一个 JSON 配置,不需要重新编译程序。如果要换相机品牌,只需在图像获取模块里实现对应的相机 SDK 回调,后面所有逻辑都能复用。

5.3 把车牌识别封装成服务的建议

实际项目中我发现,车牌识别这层最容易被多个业务系统调用,比如门禁系统要识别结果,收费系统也要识别结果,有时候监控大屏还要展示。如果在每个系统里各放一份算法代码,维护成本会剧增。后来我把识别模块单独抽成一个本地 HTTP 服务,对外暴露一个接口,输入图片路径返回车牌结果。各业务系统只管调用接口,算法升级时只需要替换服务端。

再往后走,如果现场设备算力足够,可以把识别服务部署在边缘设备上,只把识别结果回传到中心。这样中心平台负载会瞬间降下来,网络带宽消耗也小很多。这套工程里的PlateDetector和OcrRecognizer本来就是无界面依赖的类库,移植成独立服务只需要加一层监听封装。

最后说一点我实际操作中的真实感受:车牌识别这类工具,算法选型只是第一步,后续的取景、标定、调参和现场适配才真正决定口碑。开源版本解决了“从零到一”,但“从一到一百”还需要不断积累样本和场景数据。这个工具我会持续迭代,后面大概率会加入车型分类、车身颜色识别、车标识别这些方向。你现在拿到源码,可以先把基础链路跑通,再按自己业务需要慢慢加功能。

RELATED READING

延伸阅读

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