先扔个结论:车牌识别项目里,字符分割的准确率几乎决定了整牌识别的上限。你可以花大力气在识别环节换更好的模型、调更复杂的网络,但分割阶段一旦把两个字符粘连到一起,或者把一个字符切成两截,后面再怎么优化都补不回来。这句话不是我拍脑袋说说的,是我在做某个车牌识别Demo时反复踩坑踩出来的。
这个Demo用工业相机抓拍出入口车辆,完成从采集到字符输出的全流程。整套系统跑下来,我最大的感受是:很多人盯着“识别”这个动作使劲,却忽略了分割环节才是准确率的主要瓶颈。所以我这篇不是泛泛讲所谓完整方案,而是按实际项目里踩过的坑,把7种优化方法逐个拆开,OpenCV和Halcon两条路线都给出能直接跑的代码思路,适合正在做车牌识别项目、或者准备用工业相机做类似视觉课题的朋友参考。
1. 项目整体思路:车牌识别为什么先要解决“分割”
1.1 识别瓶颈不在模型,在字符区域切分
车牌识别在工业现场和普通照片识别有个很大的差异:现场环境不可控。晴天逆光、夜间补光灯过曝、车体震动、车牌倾斜、铆钉反光,任何一个因素都会直接影响二值化效果和字符区域的完整性。模型再强,它是拿“切好的字符”去分类;切得不对,后面全白搭。
我当时用固定阈值做二值化,分割出来的字符区域经常多出边框残段、铆钉块,也会出现漢字断笔、窄字母丢一半的情况。做过一个统计:整牌识别失败的结果里,有七成以上能追溯到分割错误,而不是识别错误。所以我把优化的重心放在分割之前的图像处理和分割策略上,识别环节反而没有换特别重的方案。
这篇里梳理的7种优化方法,归纳起来分三层:
| 优化层面 | 对应方法 | 解决的直接问题 |
|---|---|---|
| 图像质量 | 方法一:自适应阈值取代固定阈值 | 光照不均、二值化过曝或欠曝 |
| 分割策略 | 方法二:形态学去粘连与断笔修复 | 字符断裂、噪声块、边缘干扰 |
| 分割策略 | 方法三:垂直投影自适应切分 | 粘连字符、分割个数不稳定 |
| 分割策略 | 方法四:连通域二次校验 | 边框、铆钉、杂点混入候选字符 |
| 分割策略 | 方法五:Halcon字符分割组合拳 | 工程化分割与快速可视化调试 |
| 识别决策 | 方法六:多尺度模板匹配 | 字符归一化后的稳定分类 |
| 识别决策 | 方法七:OCR分类器+多帧投票 | 单帧误识别、相似字符混淆 |
1.2 工业相机怎么选:品牌不是重点,快门和触发才是
很多朋友看到标题里提到某类工业相机品牌,问我是不是非这个品牌不可。我的实际看法是:项目里选型时,品牌说服力不如下面三个硬指标重要:
第一是全局快门。车牌抓拍时车辆在动,卷帘快门容易产生果冻效应,字符边缘会斜向扭曲,分割直接被带偏。全局快门能把整幅画面的曝光控制在同一个瞬间,是拍动态车牌的底线。
第二是硬触发。停车场场景里车是不断移动的,靠软件轮询抓拍很容易漏帧或者拍糊。我用的是外部触发信号触发相机曝光,这样每一帧都是“车辆到达指定位置”的有效帧,避免大量无用车牌图进入算法。
第三是接口稳定。GigE Vision 或 USB3 Vision 都行,我这次走的是 GigE Vision,因为现场线缆可以拉得比较长,采集稳定性也符合预期。
至于具体品牌,我这次用的是一台常见的全局快门CMOS工业相机,和市面上主流品牌同级。其实算法侧对相机品牌并不敏感,SDK取流之后转成OpenCV的Mat或者Halcon的HObject,后面完全通用。所以下文的方案不绑死某个具体品牌,换相机也能照搬。
1.3 OpenCV和Halcon怎么分工
这个项目里我把两套工具都用了,原因不是“谁更厉害”,而是各有顺手的地方。
OpenCV开源、灵活,适合做预处理、形态学、投影分析和模板匹配,调试起来每一步都能直接打印中间图像,方便定位问题。
Halcon的OCR算子成熟,自带的分类器训练流程比我自己用OpenCV从零训练轻量网络省事得多。更重要的是它有图形化调试界面,区域分割结果能用变量窗口直接叠在原图上检查,在现场排查字符区域很直观。
所以我的分工策略是:OpenCV负责图像增强、车牌定位、字符区域粗分割,Halcon负责字符细分割与OCR识别。后面代码部分会分别给出两套流程,你也可以理解为“纯OpenCV路线”和“OpenCV+Halcon混合路线”两种选择。
2. 图像预处理与车牌定位:先拍好一张能用的图
2.1 采集端参数调整:曝光时间和增益别乱拉
分割和识别都是建立在图像上的,第一张图如果没拍好,后面所有算法都在硬抗。我在项目初期吃过这个亏:为了提亮夜间图像直接拉高增益,结果噪点被放大,二值化之后出现一堆细小碎块,字符区域被干扰得乱七八糟。后来把增益压回去,靠补光灯和合理曝光来提亮,分割干净了不止一个档次。
相机参数上我建议这样调:白天曝光1/2000秒上下,增益保持低位;夜间启用补光,曝光适当拉长,但不要让高光区域溢出太多。车牌底色的反光比较强,尤其是蓝色车牌的底色区域,过曝后字符和底色会融为一体,分割切不开。
还有一点容易被忽略:触发时刻要避开车辆急刹或转向瞬间。车身姿态变化会导致车牌倾斜角度偏大,后续透视校正压力就大。我当时的解决方法是把触发位置尽量放在车辆直行、速度较平稳的区域,采集到的车牌形态稳定很多。
2.2 灰度化、去噪与光照均衡的实操细节
采集到原始彩色图后,我直接转灰度,没有在彩色空间里做太多文章。原因是后续二值化基于灰度对比度,彩色信息在字符分割阶段帮助不大,反而增加计算量。灰度化用标准加权公式就行,OpenCV的cvtColor一步到位。
去噪方面,我的首选是高斯模糊做平滑,核大小用3x3或5x5。注意核不能太大,否则字符边缘会被磨掉,分割时笔画变细甚至断裂。如果发现图像里有明显椒盐噪声,我会补一步中值滤波,比如3x3中值滤波,实测对夜间噪点有效,对字符边缘影响也小。
光照不均是车牌现场的常态。车灯直射、树影遮挡、补光灯照射不均匀,都会让车牌的亮度和对比度不一致。一个简单有效的处理是直方图均衡化,拉伸灰度分布,让暗部细节出来。但要注意全局均衡化在部分逆光场景下会过度增强背景,因此我会优先考虑对比度受限的自适应直方图均衡化,也就是CLAHE,对局部光照更友好。
import cv2 gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 轻量平滑,核过大容易磨掉字符细节 blur = cv2.GaussianBlur(gray, (3, 3), 0) # 光照不均时做局部对比度增强 clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) enhanced = clahe.apply(blur)这段代码里clipLimit=2.0是我调过之后比较稳定的值,太小增强不明显,太大容易出现色块噪声。tileGridSize按图像分辨率调整,车牌区域占画面比例不大时,8x8的网格足够。
2.3 车牌区域提取:先用颜色粗筛,再用形态学收拢
车牌区域定位本次做的相对“传统”:因为场景中车牌底色固定,先用颜色信息把候选区域筛出来。比如蓝色车牌,在HSV空间里筛选蓝色范围,然后转灰度做形态学操作,把碎片连接成完整的候选块,再按矩形长宽比过滤。这个思路在固定场景里非常稳定,也比深度学习目标检测轻量得多。
具体流程:先根据项目里实际车牌的HSV范围做阈值分割,得到一块块底色区域。然后做一次闭运算,把字符笔画和底色区域的缝隙填上;再做一次开运算,去掉细小噪点。之后用findContours找外部轮廓,过滤形状:车牌区域的长宽比通常接近3:1到4:1,宽高比在2.5到5之间,面积太小或太大的直接排除。
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) mask = cv2.inRange(hsv, (100, 80, 80), (130, 255, 255)) kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 5)) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) for cnt in contours: x, y, w, h = cv2.boundingRect(cnt) ratio = w / h if 2.5 < ratio < 5.0 and w > 80: plate_region = enhanced[y:y + h, x:x + w]等你拿到车牌区域的图像后,后面字符分割才正式开场。
3. 字符分割:7种优化方法的核心战场
3.1 方法一:自适应阈值取代固定阈值二值化
固定阈值在实验室里看着挺好,一上现场就露馅。阳光直射时车牌反光强烈,字符和底色对比度下降;夜间补光不均匀时,车牌上半部分亮、下半部分暗。固定阈值无法兼顾整块区域的明暗变化,结果就是分割出来的黑白图大量粘连或断笔。
改用自适应阈值后,二值化效果明显稳了。它的原理是对每个像素取邻域灰度均值或高斯加权均值,再用这个动态基准判断当前像素属于前景还是背景。局部亮的地方阈值自动抬高,局部暗的地方阈值自动降低,对光照不均的适应能力强很多。
# blockSize必须是奇数,C是偏移常量 binary = cv2.adaptiveThreshold(enhanced, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 21, 10)我调参时发现blockSize=21、C=10在多数车牌场景下稳定。C值如果太小,噪声会被当成前景;C值如果太大,字符细笔画会被滤掉。现场可以根据图像分辨率微调,但这两个参数是很好的起点。二值化结果用THRESH_BINARY_INV,让字符变白色、背景变黑色,方便后续形态学和投影分析。
Halcon里对应的做法是均值图像加动态阈值,本质也是局部对比:
mean_image(Enhanced, ImageMean, 21, 21) dyn_threshold(Enhanced, ImageMean, Binary, 10, 'dark')3.2 方法二:形态学去粘连与断笔修复
二值化之后的问题是杂点、字符断笔、字符间粘连并存。这块我用OpenCV形态学分开处理。
开运算先腐蚀后膨胀,主要用来去掉字符间的小噪点和粘连毛刺。车牌表面有灰尘、反光斑点时,二值化会出现一堆毫米级小块,直接用投影法切分时这些小块会被误判成字符。开运算能把它们清理掉。
闭运算先膨胀后腐蚀,主要用来连接断裂笔画。汉字结构复杂,比如“省份简称汉字”里横折笔画多,二值化之后经常出现某个笔画中间断掉的情况。闭运算可以把相邻的笔画片段接上。
open_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (2, 2)) closed_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) cleaned = cv2.morphologyEx(binary, cv2.MORPH_OPEN, open_kernel) cleaned = cv2.morphologyEx(cleaned, cv2.MORPH_CLOSE, closed_kernel)结构元素尺寸很讲究。开运算核我用2x2,只去掉孤立噪点,不伤害字符笔画。闭运算核用3x3,短距离断笔可以连上,但不会把两个相邻字符糊成一团。核再大一点,相邻字符之间就容易粘连,分割反而更难。
3.3 方法三:垂直投影自适应切分
字符分割最核心的一步,就是对二值化后的车牌图做垂直投影。所谓垂直投影,就是把每一列像素里白色前景的数量统计出来,有字符的列投影值高,字符间隙的列投影值接近零。通过波峰和波谷就能把字符一个个切出来。
def vertical_projection(binary): h, w = binary.shape proj = [0] * w for x in range(w): proj[x] = cv2.countNonZero(binary[:, x]) return proj def find_char_ranges(proj, min_width=10, gap_thresh=2): ranges = [] start = None for x, val in enumerate(proj): if val > gap_thresh and start is None: start = x elif val <= gap_thresh and start is not None: if x - start >= min_width: ranges.append((start, x)) start = None if start is not None: ranges.append((start, len(proj) - 1)) return ranges这段逻辑不复杂,但有几个细节要考虑。第一,gap_thresh不能直接取0,因为车牌边框或轻微噪声会让字符间隙的投影值不完全为零,取2作为阈值比较稳。第二,切出来的连续段数量可能多于实际字符数。常见标准燃油车牌是省份简称汉字+字母+数字共7位,但新能源车牌可以到8位。直接按固定个数截取不现实,我会用先验做修正:如果切出的段数多于预期,把宽度较小的段合并到相邻段;如果段数少于预期,说明有粘连,需要回到前一步调整形态学参数。
投影法对规范安装、光照正常的车牌效果最好。车牌一旦倾斜,投影波峰会变得模糊,所以倾斜校正在分割之前必须处理到位,这一点后面单独说。
3.4 方法四:连通域分析做二次校验
投影法切出来的是整列区间,但区间里可能混入边框、铆钉或者其他干扰物。这时候要做二次校验:对每个候选字符区间再做连通域分析,保留真正符合字符特征的组件。
车牌字符有相对稳定的几何规律:汉字接近正方形,宽高比接近1;字母和数字多数偏窄,宽高比大约在0.3到0.8之间。利用这个约束,把明显不合规的连通域剔除。
num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats( cleaned, connectivity=8 ) valid_chars = [] for i in range(1, num_labels): x, y, w, h, area = stats[i] if h <= 0: continue ratio = w / h if 0.2 < ratio < 1.2 and 50 < area < 3000: valid_chars.append((x, y, w, h))这里还要注意位置约束。字符在车牌内部的位置相对固定,比如上方不能贴边,下方不能悬空。如果一个候选区域的上边缘离车牌顶部太近,大概率是车牌边框而不是字符。我会再做一个筛选:字符区域上边界至少离车牌顶部5到10像素。
3.5 方法五:Halcon字符分割组合拳
如果你在Halcon里做分割,流程会更紧凑。Halcon的区域操作算子非常强大,threshold之后直接connection得到所有连通域,再通过select_shape按几何特征过滤,随后用sort_region按字符阅读顺序排序。这段代码调试起来非常舒服,因为每一步的区域都能可视化叠在原图上检查。
* 动态阈值二值化,效果类似OpenCV自适应阈值 mean_image(Enhanced, ImageMean, 21, 21) dyn_threshold(Enhanced, ImageMean, Binary, 10, 'dark') * 连通域分割与筛选 connection(Binary, ConnectedRegions) select_shape(ConnectedRegions, SelectedRegions, ['area', 'width', 'height'], 'and', [100, 10, 30], [5000, 60, 100]) * 按水平位置从左到右排序 sort_region(SelectedRegions, SortedRegions, 'character', 'true', 'column')这里sort_region的排序方式选择'character'很重要。它不只是按X坐标排序,还会考虑字符的阅读顺序,比简单按列排序更合理。如果排序方式不对,识别结果顺序会乱,整牌拼接自然出错。
Halcon还有一个优势是select_shape可以叠加多个特征条件,比如同时限制面积、宽度、高度,省去写循环的麻烦。我自己在实际调试时,会把SelectedRegions通过dev_display叠在图像上看,哪个区域多切了、哪个少切了,一眼就能发现。
4. 字符识别:从模板匹配到分类器,再到多帧决策
4.1 方法六:多尺度模板匹配策略
字符分割完成之后,进入识别环节。这个项目里字符集比较固定:省份简称汉字、字母、数字,总共几十个类别。模板匹配在字符集受限的场景下是个很实用的轻量方案,不需要训练过程,准备一套标准模板就能跑。
关键是模板准备和归一化。模板我从采集到的样张里裁剪得到,截取标准字符的外接矩形,统一缩放到固定尺寸,比如宽度20、高度40。识别时把待识别字符也做同样归一化,再用归一化相关系数匹配。
def match_char(char_img, templates): char_resized = cv2.resize(char_img, (20, 40), interpolation=cv2.INTER_AREA) best_score = -1 best_label = None for label, tmpl in templates.items(): resized_tmpl = cv2.resize(tmpl, (20, 40), interpolation=cv2.INTER_AREA) result = cv2.matchTemplate(char_resized, resized_tmpl, cv2.TM_CCOEFF_NORMED) _, max_val, _, _ = cv2.minMaxLoc(result) if max_val > best_score: best_score = max_val best_label = label if best_score > 0.55: return best_label return None匹配方式我建议用TM_CCOEFF_NORMED,它对光照和对比度变化更稳健。直接算像素差的TM_SQDIFF对灰度波动太敏感,现场效果不稳。
匹配分数阈值要看实际情况。字符印刷清晰时,分数常在0.8以上;图像模糊或噪声大时,分数会掉到0.6左右。我一般把阈值设在0.55到0.65之间,并配合置信度过滤:低于阈值的字符不直接输出,而是标记为待人工复核。
4.2 方法七:Halcon OCR分类器与多帧投票
模板匹配有两个短板:一是对字体变化敏感,二是对形近字符区分能力有限。为了弥补这个短板,我在Halcon里训练了OCR分类器。Halcon的OCR接口很成熟,先准备字符样本图,然后调用create_ocr_class_mlp训练一个多层感知机分类器,把训练好的模型保存成.omc文件,识别时加载即可。
* 加载训练好的OCR分类器 read_ocr_class_mlp('plate_ocr.omc', OCRHandle) * 对排序后的字符区域批量识别 do_ocr_multi(SortedRegions, Enhanced, OCRHandle, RecognizedClass, Confidence)do_ocr_multi返回的RecognizedClass就是字符结果,Confidence是置信度。我设置了置信度门槛,低于门槛的结果不采信。实际项目中,Confidence较高时可以直接输出,较低时结合下文的多帧投票修正。
单帧识别难免有抖动,尤其在夜间、运动模糊、遮挡等情况下。我的做法是把连续多帧的识别结果做投票融合。比如连续5帧都识别同一块车牌,每个字符位独立统计出现次数,取出现次数最多的字符作为最终输出。
from collections import Counter def vote_plate(frames_recognized): # frames_recognized: list,每个元素是某一帧识别出的字符列表 num_chars = min(len(f) for f in frames_recognized) final = [] for i in range(num_chars): counter = Counter(f[i] for f in frames_recognized) char, count = counter.most_common(1)[0] final.append(char if count >= 3 else None) return ''.join(final if all(c is not None for c in final) else '')注意这里用了硬投票。更精细的做法是按置信度加权投票,比如某个字符置信度0.9的票权远高于置信度0.6的。但我实测下来,在车辆多帧连续通过的情况下,硬投票已经能把单帧偶然误识别的“0变O”“1变I”这类问题压下去。
4.3 识别结果后处理:格式校验和相似字符纠错
分割和识别跑完,还要做一道后处理。车牌有固定格式,标准燃油车通常是“1位省份简称汉字+1位字母+5位字母数字”,新能源车会有8位。我用规则校验结果,不符合格式的直接判为识别失败,不输出。
字母数字里有一些形近字符经常混淆:数字0和字母O、数字1和字母I、数字8和字母B。这类错误只靠识别器很难完全避免,我会根据车牌规则做自动纠错。
import re def post_process(raw_result): raw_result = raw_result.replace('O', '0').replace('I', '1') # 标准燃油车格式,实际场景按需求调整 pattern = r'^[\u4e00-\u9fa5][A-Z][A-Z0-9]{5}$' if re.match(pattern, raw_result): return raw_result return None纠错逻辑要小心。车牌上确实可能出现字母O吗?大多数车牌不允许出现O和I,为了避免混淆,车管部门在车牌字符集里通常排除这两个字母。因此把识别结果里的O替换成0、I替换成1,是符合规则的做法。但不同地区和不同车牌类型可能有差异,上线前一定要确认规则。
格式校验是最后一道防线。有一次我调试时发现,识别结果第一位变成了英文字母而不是汉字,就是规则校验帮我拦下来,顺藤摸瓜发现是分割时把车牌边框当成了字符。
5. 常见问题与排查技巧实录
5.1 车牌倾斜导致分割全乱
倾斜是车牌识别里最影响分割的问题之一。车辆行驶中车身姿态变化、相机安装角度不正,都会让车牌在图像里是斜的。斜着做垂直投影会导致字符波峰无法区分,分割结果严重混乱。
我的处理流程分两步。第一步用边缘检测和霍夫直线检测找出车牌上下边框,计算边框平均倾角。第二步做仿射校正,把车牌拉正。
edges = cv2.Canny(enhanced, 80, 200) lines = cv2.HoughLinesP(edges, 1, np.pi / 180, threshold=80, minLineLength=60, maxLineGap=10) angles = [] for line in lines: x1, y1, x2, y2 = line[0] angle = np.arctan2(y2 - y1, x2 - x1) * 180 / np.pi angles.append(angle) median_angle = np.median(angles) M = cv2.getRotationMatrix2D((w // 2, h // 2), median_angle, 1.0) rotated = cv2.warpAffine(enhanced, M, (w, h))如果车辆是被侧前方相机拍到,车牌本身是透视变形,不是简单旋转,这时候旋转还不够,得做透视校正。用牌照的四个角点求透视变换矩阵,映射到标准矩形。定位角点的方式可以基于车牌底色区域的轮廓最小外接矩形来提取。
5.2 夜间和逆光场景下二值化失效
夜间车牌识别,最典型的问题是车灯直射导致车牌过曝,或者补光灯只照亮车牌一侧。自适应阈值能解决一部分问题,但过曝区域如果连字符和底色的灰度都拉不开差距,阈值再自适应也切不出字符。
我的经验是先把过曝区域单独处理。一个实用技巧是顶帽变换:原图减去开运算结果,可以把暗部细节从背景中分离出来,对反光区域有抑制作用。再配合局部自适应阈值,夜间场景的字符轮廓会比直接二值化清晰很多。
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (11, 11)) tophat = cv2.morphologyEx(enhanced, cv2.MORPH_TOPHAT, kernel) binary = cv2.adaptiveThreshold(tophat, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 21, 10)如果场景里车牌区域很小,顶帽核的尺寸要大于字符宽度但小于车牌宽度,一般取15到25个像素宽,效果比较好。这块需要现场微调,不同相机的分辨率差别不小。
5.3 分割个数不对:粘连与断裂的诊断思路
分割后最常遇到的情况是:候选区域数量不是7也不是8,而是9或者6。我一般先看投影曲线图,找出是哪个环节出了问题。
区域偏多,通常有两类原因:一是车牌边框或铆钉被当成字符,检查投影波峰里是否有宽度很窄又紧挨着真实字符的小峰;二是字符内部有纵向的大块噪声,把单个字符切成多段。针对前者,用连通域几何特征过滤;针对后者,检查开运算结构元素是否太小、闭运算是否没起作用。
区域偏少,通常是字符之间发生粘连。二值化阈值太低或者闭运算结构元素过大,都会把相邻字符连成一片。此时先调低闭运算核,再考虑调高自适应阈值的C值,让字符间隙更明显。
我建立一个习惯:把每一次分割失败的中间图像保存下来,跟原始图并排对比。不需要多少高级工具,一个简单的截图脚本就够用,记录投影曲线和切分结果,问题出在哪里一目了然。
5.4 常见问题速查表
| 现象 | 主要原因 | 排查与对策 |
|---|---|---|
| 分割出多个小块 | 铆钉、边框残段、反光噪点 | 用连通域筛面积和宽高比,或用开运算去噪 |
| 单个字符断裂成两截 | 光照不均、笔画过细、阈值偏大 | 缩小阈值偏移量C,增加闭运算连接断笔 |
| 字符之间粘连 | 闭运算核过大、阈值偏低 | 缩小结构元素、调大C值,检查投影波峰间隔 |
| 第一个字符被识别成字母 | 边框混入候选区域 | 增加位置约束,过滤车牌顶边附近的区域 |
| 0和O、1和I混淆 | 字符形态相似 | 规则校验,按车牌字符集强制替换 |
| 车辆快速通过时识别不稳 | 单帧图像模糊 | 启用相机硬触发,缩短曝光时间,加多帧投票 |
6. 实战代码整合与实测复盘
6.1 OpenCV完整流程代码整理
下面把OpenCV路线的流程串起来。为了可读性,我把流程拆成函数;实际项目里你可以在主循环里直接调用。
import cv2 import numpy as np def preprocess(img): gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) blur = cv2.GaussianBlur(gray, (3, 3), 0) clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) enhanced = clahe.apply(blur) binary = cv2.adaptiveThreshold(enhanced, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 21, 10) kernel_open = cv2.getStructuringElement(cv2.MORPH_RECT, (2, 2)) kernel_close = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) cleaned = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel_open) cleaned = cv2.morphologyEx(cleaned, cv2.MORPH_CLOSE, kernel_close) return enhanced, cleaned def segment_chars(cleaned): proj = vertical_projection(cleaned) ranges = find_char_ranges(proj, min_width=10, gap_thresh=2) return ranges def recognize_chars(enhanced, ranges, templates): recognized = [] for x1, x2 in ranges: char_img = enhanced[5:enhanced.shape[0] - 5, x1:x2] label = match_char(char_img, templates) if label is not None: recognized.append(label) return post_process(''.join(recognized)) plate_text = recognize_chars(enhanced, seg_ranges, templates)整个流程没有用到特别重的依赖,一个opencv-python加上numpy就能跑,很适合先验证算法可行性。如果现场识别的字体变化较大,我再把模板匹配替换成Halcon OCR,或者接一个轻量分类模型。
6.2 Halcon完整流程代码整理
Halcon路线的重点放在分割和OCR识别上。前端的图像增强可以用OpenCV做完,转成Halcon图像后直接走区域操作。
* 读取图像,OpenCV预处理后的图可保存为中间文件再读取 read_image(Image, 'plate_after_preprocess.png') * 动态阈值得到二值化区域 mean_image(Image, ImageMean, 21, 21) dyn_threshold(Image, ImageMean, Binary, 10, 'dark') * 连通域分割、筛选、排序 connection(Binary, ConnectedRegions) select_shape(ConnectedRegions, SelectedRegions, ['area', 'width', 'height'], 'and', [100, 10, 30], [5000, 60, 100]) sort_region(SelectedRegions, SortedRegions, 'character', 'true', 'column') * 识别 read_ocr_class_mlp('plate_ocr.omc', OCRHandle) do_ocr_multi(SortedRegions, Image, OCRHandle, RecognizedClass, Confidence)这里SortedRegions的优点是选区和原图可以叠加显示,如果字符区域筛选有问题,直接在调试窗口里框出来看,比纯代码排查快很多。我强烈建议你在Halcon里把每一步区域都dev_display出来,能省下大量猜测时间。
6.3 一组实测数据复盘
我在一套自建的测试集上记录了优化过程中的准确率变化。测试集来自某停车场出入口的真实场景,包含白天、夜晚、逆光、模糊等常见工况,共约2000张有效车牌样本。这里的准确率是按“整牌完全识别正确”统计的。
| 方案版本 | 字符分割准确率 | 整牌识别准确率 |
|---|---|---|
| 固定阈值 + 简单投影 | 76% | 58% |
| 自适应阈值 | 88% | 75% |
| 自适应阈值 + 形态学处理 | 91% | 82% |
| 加入连通域二次校验 | 94% | 87% |
| 加入倾斜校正 | 96% | 91% |
| 加入多帧投票 | 97% | 94% |
从数据能清楚看到两个结论:第一,分割优化的贡献远大于识别优化。在识别方案不变的情况下,仅靠改善分割就把整牌准确率从58%拉到87%左右。第二,越靠后的优化项,提升幅度越小,说明系统逐渐逼近场景的上限,此时瓶颈开始转向极端模糊、强遮挡这类硬样本。
这套数据也提醒我:不要把大量精力花在追求最后一个百分点的提升上。当指标到90%以上,优先考虑提升系统稳定性,比如增加重试机制、报警机制,而不是继续死磕算法。
这个项目做下来,我个人体会最深的是:车牌识别这类工业视觉任务,真正的难点从来不是某个单点技术不够高级,而是每个环节的误差都会叠加放大。我在实际项目中反复调整过很多次,发现最有用的调试习惯是把中间结果可视化出来,每一步都确认“这一段到底切对没有”。分割切好了,识别器自然轻松;分割切不好,换再强的模型也难救。如果你也在做类似的车牌识别或者字符分割项目,建议先从投影曲线和连通域截图开始,把这些基本功吃透,再考虑上更复杂的识别网络。这个方法套路不仅适用于车牌,像是集装箱号、零件二维码、包装喷码字符这类固定位置字符识别,同样可以直接复用同一套优化思路。