从传统图像处理到深度学习:车牌识别系统演进实践


1. 项目背景简介

这几年智能停车场越来越多,很多商场和写字楼都在把原来那套人工登记、人工收费的系统换成自动识别的。我们当时也正好接到一个需求,要做一个支持车牌自动识别 + 自动计费 + 自动放行的系统,用来提升停车场的出入口通行效率。

其实一开始要求还挺高的:识别准确率要能稳定在 95% 以上,识别+计费流程整体耗时不能超过 1.5 秒,而且最好支持多种车牌样式——蓝的黄的新能源的全都得识别,还得能部署在本地边缘设备上,不能太吃资源。

当时想着,先用一些传统图像处理手段快速搭一套试试看,成本低、见效快。于是我们就先搞了一版基于 OpenCV 的实现。


2. 初版方案:传统图像处理路线

我们最早的一版系统,其实没有用任何深度学习,就是靠图像处理+逻辑判断“硬撸”出来的。虽然精度不高,但原型很快就能跑起来。

2.1 滑窗 + OpenCV 做车牌区域检测

我们用 OpenCV 把图像先做一波预处理,主要流程是灰度化 → 高斯滤波 → Canny 边缘检测,然后就开始滑窗遍历整张图,去找那些长宽比像车牌的矩形区域。

为了过滤掉噪声,我们还加了一些颜色和形状上的判断,比如常见的蓝底白字车牌,在 HSV 色彩空间里很容易就能分出来。靠这些规则,基本能把车牌位置框出来,虽然偶尔也会误框一些广告牌或者后车灯。

2.2 字符分割 + 模板匹配做识别

框出来之后,我们用图像切割把车牌字符一个个分出来,然后做模板匹配。简单说就是和一堆已有的字母、数字图片做对比,看哪个最像,就认成哪个。

说实话,这一步就是纯靠像素比对,样式稍微变一点它就认不出来了。比如字体粗细变了、字歪了、背景有点反光,全都容易识错。

2.3 实际遇到的问题

我们内部测试的时候觉得还凑合,但真上线了就开始掉链子——

• 下雨天车牌有水渍,识别率暴跌;

• 晚上光线暗,边缘提不出来;

• 有些车歪着进场,角度一偏字符就分不准了;

• 模板匹配对字形太敏感,新版新能源车牌基本识别失败。

还有个致命问题是速度太慢了。滑窗处理一整张图耗时很久,尤其分辨率一高,CPU 根本跑不过来。那时候我们就知道,再优化也有限,得换思路了——考虑上深度学习。

3. 升级阶段一:引入 CTPN 做字符区域检测

传统方法在定位车牌区域这一步已经吃力了,但更麻烦的其实是字符切割。一旦车牌有点歪,或者字符之间间距不一致,传统的按列投影法基本就挂了。我们团队那时候几乎一半时间都花在调字符分割上,怎么调都不稳定。

所以后来我们决定直接上深度学习,第一步就是换掉字符定位的部分,用一个更鲁棒的检测模型:CTPN(Connectionist Text Proposal Network)

3.1 为什么选 CTPN?

那时候还没有像现在这么多轻量级的 OCR 模型。CTPN 虽然不是最新的,但它是专门做自然场景中的文本检测的,特别适合处理那些角度稍歪、背景有干扰的文字区域。而且它的原理比较有意思:不是把整行字符当成一个框,而是把字符行“切”成一段段的小 proposal,然后再把它们串起来,最终框出整段文本。

这种“横向连通”的思想,恰好适合车牌这种一行排开的字符。

3.2 模型使用流程

我们当时复用了别人开源的 CTPN TensorFlow 实现,拿过来以后先在公开数据集上验证能跑起来,再用我们自己采集的车牌图像进行 finetune(微调)。

训练过程倒没太多坑,就是数据标注有点费劲——每张图都要标出字符区域的小框,后来我们写了个半自动工具,先用传统边缘检测预选,再人工微调,大大提高了效率。

训练好之后模型效果立竿见影:

• 哪怕车牌轻微倾斜,也能正确框出整行字符;

• 遇到遮挡或者局部模糊的车牌,也能保证字符区域尽量完整;

• 最关键的是,字符行出来了之后就不用再手动分割字符了!

这对我们后面的字符识别环节简直是质的飞跃。

3.3 性能表现

CTPN 本身不算轻量,尤其是在当年的设备环境下(主要是边缘侧部署),我们做了以下几步来优化:

• 裁剪输入图像,只对车牌区域附近做检测(前面用传统方式做一次粗定位);

• 减小模型输入尺寸,换成更小的 anchor 尺寸;

• 模型量化 + TensorRT 优化一波(这个后来才做,效果不错);

优化之后,CTPN 模块的平均耗时控制在 300~500ms 左右,精度也远比传统方式高不少。

4. 升级阶段二:CRNN + CTC 字符识别方案

字符区域搞定之后,接下来就是让系统“读”出字符了。我们最开始也试过一些轻量级的字符分类网络,比如把每个字符框提出来后单独做分类(Softmax + CNN),但发现效果也不稳定:字符框切得不准就识别错误,而且这种方式对字符的前后顺序毫无理解能力。

所以最终我们上了比较经典的一套组合:CRNN + CTC

4.1 CRNN 是啥?

简单说,CRNN(Convolutional Recurrent Neural Network)是个三段式结构:

  1. CNN 卷积层:提取图像特征;

  2. RNN 结构(我们用的是双向 LSTM):处理特征序列,理解前后语境;

  3. 全连接层 + CTC 输出:生成字符预测结果。

这种结构特别适合处理「一整行字符」的图像,也就是那种你不需要逐个分割字符,而是直接把一张字符区域图喂进去,它就能一口气输出整个字符串的方案。非常适合车牌识别场景。

4.2 为什么要用 CTC?

CTC(Connectionist Temporal Classification)最牛的地方就是不要求字符和位置一一对齐。也就是说,我们训练的时候只需要提供整张图片对应的文字标签,比如:

标签:鲁N Y97L0

CTC 会自己在训练中学会怎么对齐字符和图像上的位置,不需要你提前标出每个字符在哪儿。对我们这种实际数据标注难度高的项目太友好了。

4.3 模型训练的一些事

我们训练用的数据主要来自我们自己采集的监控视频截图,用脚本定期抓取画面,然后人工标注车牌文字。清洗过程还是挺繁琐的:

• 把模糊图、反光图去掉;

• 对图片做标准化处理,比如统一宽高比;

• 加了一些图像增强操作,比如旋转、模糊、噪声、色偏,提升模型鲁棒性。

训练过程中也踩过一些坑,比如:

• 序列长度不够长,CTC 输出容易塌缩(输出成重复字符);

• 字符集设计不完善,漏掉了新能源车的“D”“F”前缀;

• 数据分布不均,造成某些字符识别率偏低;

不过整体来说,模型在验证集上的准确率很快就拉到了 95% 左右,最关键的是——在一些传统方法完全识别不出来的场景,它居然能读出来!像那种模糊不清但人眼还能猜的图,它也能成功识别,算是给我们打了一针强心剂。

5. 数据准备与增强实践

说句实话,模型训练靠的不是花里胡哨的网络结构,真的靠的是数据质量。我们这个车牌识别模型能跑得还不错,80%功劳都得归到数据处理这一步上。

5.1 数据从哪来?

我们最初的数据来源是监控视频截图。停车场每辆车进出时摄像头都会抓拍画面,我们写了个脚本,定期从存储系统里抽取图片,带时间戳的那种。

不过这些原始图都不能直接拿来训练,需要经过一轮又一轮清洗。我们干了这些事:

图像裁剪:只保留有车牌的部分,背景太大的图直接舍弃;

质量筛选:模糊、曝光过度、反光太强的图直接扔掉;

人工标注:用一个简单的图像标注工具,人肉输入每张图的真实车牌号;

字符集整理:收集所有可能的字符,建立一套车牌字符字典(包括新能源前缀、军牌、省份简称等等);

标注过程真的挺磨人的,不过好在只要首批数据标得够准,后面可以用模型做预标注,再人工纠正,就轻松多了。

5.2 图像增强操作(真的有用)

为了让模型能扛住各种“车牌奇葩状态”,我们给训练图做了一大堆增强:

旋转:±15° 以内轻微旋转,模拟歪着进来的车;

亮度变化:随机调亮/调暗,模拟白天和夜晚;

模糊处理:加一点点高斯模糊,模拟镜头虚焦;

添加噪声:模拟雨天、脏污;

色调变化:HSV 色彩空间做扰动,模拟白平衡偏差;

这些增强操作真的不是为了凑热闹——我们一开始模型在夜间图表现特别差,后来特地加了一批“夜间风格”的增强图,再训练一次之后明显效果提升了。

而且有些问题增强也解决不了,比如遮挡、反光那些,我们后来干脆拉了一批「问题图」单独训练,变成模型的专项强化练习(笑)。

5.3 数据分布也很重要

一开始我们犯了个低级错误:数据集中上海车牌(沪A、沪B)占了大头,导致模型总是喜欢猜“沪”。后来我们调整了数据分布比例,让不同省份、不同类型的车牌在训练集里更均匀,这才缓过来。

6. 模型部署与系统集成

模型训好了,只能算半成事。真正要上线用,还得把模型变成一个随时能被系统“叫醒”、快速响应的服务,这部分其实我们也花了不少时间。

6.1 用 Flask 封一层接口

我们一开始选的是最直接的方式:Flask + TensorFlow(1.x)原生 Session 来做部署。没用什么 fancy 的部署框架,主要是为了轻量和方便调试,接口结构大概就这样:

• /predict:接收图片(base64 或 multipart),调用模型推理,返回识别结果;

• /ping:健康检查;

• /reload(内部用):重新加载模型,方便后面热更新。

每次请求进来就走一套流程:图像预处理 → CTPN → CRNN+CTC → 后处理输出字符串。

虽然有点原始,但胜在简单可靠,后来部署到线下场景也挺稳定的。

6.2 模型加载和推理效率优化

说实话,TensorFlow 1.x 真不太友好。最开始我们每个请求都建 Session,推理时间能飙到 2~3 秒,完全不能用。后来换成全局加载模型 + 线程锁管理 Session,推理时间立马压到了 500ms 以内。

还有几点优化小心得:

模型冻结(freeze_graph):把训练好的 ckpt 转成 pb 文件,推理加载更快;

Batch 尺寸优化:我们评估时 batch_size 固定为 1,避免不必要的内存开销;

图像 resize 尺寸对齐:输入图统一为固定高宽,避免每次动态 reshape;

预处理和后处理放主线程处理,只把纯模型推理部分用锁保护,提升并发能力;

这些小改动,虽然不难,但每一条都能救你一口性能。

6.3 系统联动:识别 + 计费 + 放行

模型不是孤岛,它得和停车场管理系统打配合。我们这边做了几个联动:

入场识别:摄像头拍照,调用接口识别车牌,记录入场时间;

出场识别 + 计费:再次识别后查数据库计算停留时长、费用;

放行控制:识别+扣费成功后,下发开闸信号;

失败兜底:识别失败或多次识别结果不一致,会转人工审核;

为了防止“卡顿堵车”,我们整个流程控制在 1.5 秒以内,其中模型推理时间不能超过 700ms,剩下的时间给数据查库、业务逻辑和网络请求。

6.4 部署环境

最早几家门店我们用的是一台小型边缘服务器(i5 + 8GB RAM 没有 GPU),压测下来勉强够用。后面上线门店多了,我们换成了轻量级 Docker 服务部署在局域网网关设备上,响应时间更稳,维护也方便。

7. 实战成效与经验总结

系统上线那天其实我们还是挺紧张的,尤其是第一家门店,设备一接好,路口刚好就排着三四辆车等着进出,全靠我们模型不给我们“添堵”。

7.1 系统上线表现

我们系统最终在实际场景中跑得比预期还要稳一些,具体几个关键指标:

识别准确率:白天场景稳定在 96%+,夜间略低,但也保持在 93%左右

平均识别时延:单张图片识别时间在 600~800ms 之间(包含全部流程);

日均识别量:单店约 500~1000 次识别,高峰能到 2000+

上线门店数:最终部署到 30+ 个停车场,基本没出过重大事故。

更关键的是,原本高峰时段门口排队的情况缓解了不少,整体通行效率提升差不多在 40% 左右,人工干预大幅减少。我们现场运维的同事说,之前门卫经常抓狂写错车牌、算错钱,现在每天轻松很多(笑)。

7.2 遇到的坑(踩过才知道)

虽然结果不错,但中间也踩了不少坑,这里随便列几个,大家可以避避:

模型没做字符合法性校验,一开始竟然识别出“1A2B3C4”这种根本不存在的车牌号,后来加了正则规则和置信度筛选才缓解;

图片预处理太“干净”,前期训练图像质量偏高,结果一上线就被现实毒打,后来特地加了一批“脏数据”做鲁棒性提升;

多线程并发 Session 崩溃,用 Flask 时没加锁导致多线程 Session 冲突,线上一度频繁报错,后来老老实实加了锁管理;

字符集不统一,有的标注是“沪A12345”,有的是“沪 A12345”,训练时没注意,结果输出格式不一致,系统识别失败;

这些问题不是模型的问题,是我们“业务落地”经验不够造成的——你得时刻记得这个系统不是跑 demo 的,是要真干活的。

7.3 总结一下:

整个项目做完下来,我自己最大的体会是:

OCR 项目从来不是纯模型问题,而是

数据、业务、部署、系统联动

从传统方法到深度学习,看上去像是“技术升级”,但其实更像是一次「认知升级」:你开始关注更多系统层面的事情,比如服务性能、接口设计、用户体验、甚至运维和日志。

当然,我们这套也不完美,还有很多可以优化的地方,比如后面版本我们也在尝试:

• 用更轻量的模型(比如 MobileNet+CRNN)做前端部署;

• 用 ONNX 或 TensorRT 做跨平台部署;

• 引入日志追踪和自动告警系统,提升系统可维护性;

这些就是后话了,有机会再分享。