从传统图像处理到深度学习:车牌识别系统演进实践
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)是个三段式结构:
-
CNN 卷积层:提取图像特征;
-
RNN 结构(我们用的是双向 LSTM):处理特征序列,理解前后语境;
-
全连接层 + 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 做跨平台部署;
• 引入日志追踪和自动告警系统,提升系统可维护性;
这些就是后话了,有机会再分享。
脱敏说明:本文所有出现的表名、字段名、接口地址、变量名、IP地址及示例数据等均非真实,仅用于阐述技术思路与实现步骤,示例代码亦非公司真实代码。示例方案亦非公司真实完整方案,仅为本人记忆总结,用于技术学习探讨。
• 文中所示任何标识符并不对应实际生产环境中的名称或编号。
• 示例 SQL、脚本、代码及数据等均为演示用途,不含真实业务数据,也不具备直接运行或复现的完整上下文。
• 读者若需在实际项目中参考本文方案,请结合自身业务场景及数据安全规范,使用符合内部命名和权限控制的配置。Data Desensitization Notice: All table names, field names, API endpoints, variable names, IP addresses, and sample data appearing in this article are fictitious and intended solely to illustrate technical concepts and implementation steps. The sample code is not actual company code. The proposed solutions are not complete or actual company solutions but are summarized from the author's memory for technical learning and discussion.
• Any identifiers shown in the text do not correspond to names or numbers in any actual production environment.
• Sample SQL, scripts, code, and data are for demonstration purposes only, do not contain real business data, and lack the full context required for direct execution or reproduction.
• Readers who wish to reference the solutions in this article for actual projects should adapt them to their own business scenarios and data security standards, using configurations that comply with internal naming and access control policies.版权声明:本文版权归原作者所有,未经作者事先书面许可,任何单位或个人不得以任何方式复制、转载、摘编或用于商业用途。
• 若需非商业性引用或转载本文内容,请务必注明出处并保持内容完整。
• 对因商业使用、篡改或不当引用本文内容所产生的法律纠纷,作者保留追究法律责任的权利。Copyright Notice: The copyright of this article belongs to the original author. Without prior written permission from the author, no entity or individual may copy, reproduce, excerpt, or use it for commercial purposes in any way.
• For non-commercial citation or reproduction of this content, attribution must be given, and the integrity of the content must be maintained.
• The author reserves the right to pursue legal action against any legal disputes arising from the commercial use, alteration, or improper citation of this article's content.Copyright © 1989–Present Ge Yuxu. All Rights Reserved.