车牌识别(LPR)这个方向,我觉得是所有想认真走一遍计算机视觉流程的人都不该绕开的项目。它不像人脸识别那样高度依赖大规模数据,也不像图像分割那样需要精细到像素级标注,但它把“目标检测”“图像预处理”“字符分类”这几个最核心的环节全串起来了。这篇学习记录就是用 PyTorch 从零走了一遍“车牌定位 → 字符分割 → 字符识别”的完整链路,适合正在学 CV 的同学,也适合做课程设计或者毕设开题前想摸底的人。我不会写一堆花哨理论,就按我当时实际跑通流程的顺序,把方案选型、数据怎么造、每个模块怎么落地、中间踩过的坑都摊开讲。
1. 整体设计:先拆开,再逐个干掉
1.1 为什么不做端到端方案
很多人拿到车牌识别任务的第一反应是“搞一个端到端模型,输入图像直接输出字符串”。这种做法的确很酷,一个网络从原始像素到最终文本,理论上限很高,但放到个人学习项目里,会面临三个很现实的问题:真实数据量不够、单模型调试困难、问题不好定位。端到端模型一旦输出错误,你很难判断是检测模块漏了车牌,还是识别模块把字符认错了,只能从头到尾漫无目的地调参。
所以我把任务拆成了三个独立模块。这样每个模块都有明确的输入输出,任何一个环节出问题都能立刻单独验证。车牌定位负责“车牌在哪”,字符分割负责“车牌上有哪些字”,字符识别负责“每个字是什么”。模块之间用标准图像接口连接,后续替换掉任何一个模块都不影响其余部分,这种解耦对学习阶段特别友好。
1.2 模块接口与数据流
我先把三个模块的数据接口定下来,这步看似简单,实际上帮了大忙。
定位模块输入一张普通场景图,输出的是一个车牌区域的四角坐标。我不直接输出矩形框,因为照片里车牌经常是倾斜的,矩形框会带进大量背景。输出四角坐标后,可以做透视变换得到正视角的车牌图,这会大大降低后面分割和识别的难度。
分割模块输入是校正后的车牌图,输出是 7 个字符小图。这里我按国内蓝牌固定 7 位的格式处理,后面会细说。识别模块输入单字符小图,输出一个字符类别。每个模块都可以单独测试,比如我后来发现整牌识别准确率不高,就先把分割中间结果全部导出来看,果然有将近三成的错误来自字符被切歪,而不是模型认错字。这就是拆模块最大的价值。
2. 数据准备:没有数据,就先造一批数据
2.1 合成数据为主,真实数据为辅
跑起来之后我才发现,这个项目最耗时间的不是写模型,而是搞数据。真实车牌涉及个人信息,个人很难合法大规模采集,开源数据集的量级和场景覆盖又不够,很多样本还是旧格式,标注对不齐。到最后我选择了一条更可控的路:合成数据。
合成思路很简单,先用字体把字符串绘制成一张“车牌底图”,再做透视变换、贴到随机背景上,最后叠加光照、模糊、噪声等干扰。背景我从开源图库里随便抽,可以是停车场、路边、墙面、草地,反正只要场景多样化就行。这一步的关键是每张合成图都能同时保留定位框、字符位置、字符文本三份“标注”,训练三个模块的数据全都有了,完全不用手工标。
2.2 标注格式与训练集划分
我实际维护的标注结构是这样的:每张合成图对应一个文本文件,记录车牌四角坐标、7 个字符各自的边界框、完整字符串。三个模块各取所需,互不干扰。
数据量上,我最初合成了约 8 万张整图,其中大概 10% 留着当测试集,剩下的再按 9:1 拆成训练集和验证集。单字符识别模型就从这些图的字符框里切出几十万个单字符样本,类别近百类的规模对合成数据来说完全够用。这个量级在普通入门级显卡上跑起来也很舒服,批量大小不用开很大。
2.3 数据增强:让合成数据别那么“假”
合成数据最怕的就是“假”,生成时干干净净,一到真实照片就全线崩溃。我的增强策略核心是模拟拍摄过程中的各种退化,而不是做花哨的对抗扰动。
- 亮度扰动 0.6~1.4,覆盖白天到黄昏、强曝光到欠曝
- 对比度扰动 0.7~1.3,模拟顺光逆光差异
- 高斯噪声加 sigma 0~5 的随机值,还原传感器噪点
- 高斯模糊偶尔用一下,模拟轻微失焦
- HSV 色相翻转,蓝色车牌在不同光照下色相会漂移
- 透视变换角度在正负 15 度之间随机,模拟不同机位
- 随机缩放和裁剪,模拟距离远近
我后来复盘发现,透视变换和亮度扰动收益最高。真实照片里车牌几乎没有完全正对着镜头的,如果合成数据里全是正视角,模型一到真实场景就会明显掉点。
3. 车牌定位:OpenCV 也能干得漂亮
3.1 为什么用 HSV 而不是直接用 BGR
定位模块我采用的是传统视觉方案,核心手段是颜色筛选。最开始我直接在 BGR 空间里抠蓝色,结果发现蓝色像素在阴影下三通道一起变化,固定阈值完全没法用。换到 HSV 空间后,色调 H 通道和亮度 V 通道分离,蓝色的识别就稳定多了。
实际阈值我用的是 H 在 100 到 124、S 大于 80、V 大于 80。这个范围是我拿一批样本反复试出来的,不同品牌蓝色车漆的色相略有差别,把范围稍微放宽一点能减少漏检。核心代码很短:
import cv2 import numpy as np hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) mask = cv2.inRange(hsv, (100, 80, 80), (124, 255, 255))3.2 形态学处理与候选框筛选
颜色掩码出来后通常是碎的、带洞的。车牌字符区域在掩码中呈现出大量孔洞,直接找轮廓会得到一堆碎片。我先用闭运算把孔洞补上,核大小选 5x5,做一次就够,核太大容易把周围蓝色物体也连进来,核太小补不干净。
然后跑 findContours,按轮廓面积和宽高比筛。真实蓝牌的宽高比大约是 3.14,考虑到拍摄角度和边框残留,我把筛选条件放宽到 2.5~4.5,面积下限根据图像分辨率再定。多个候选框按面积排序,优先取大的:
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) candidates = [] for c in contours: area = cv2.contourArea(c) x, y, w, h = cv2.boundingRect(c) ratio = w / h if area > 1500 and 2.5 < ratio < 4.5: candidates.append(c) candidates.sort(key=cv2.contourArea, reverse=True)3.3 倾斜校正:把歪车牌摆正
颜色筛选只能拿到外接矩形,倾斜车牌会带进大块背景,而且字符是歪的,直接分割会失败。我用 minAreaRect 拿到最小外接矩形,根据角度对原图做旋转。这里有个坑:minAreaRect 返回的角度定义在不同 OpenCV 版本里表现不完全一样,不要直接信 angle 字段,要看长边方向。稳妥的做法是取矩形四个顶点,判断哪条边是长边,再计算长边的倾斜角:
rect = cv2.minAreaRect(c) box = cv2.boxPoints(rect) box = np.int0(box) angle = ... # 根据长边两端点计算 M = cv2.getRotationMatrix2D(center, angle, 1.0) rotated = cv2.warpAffine(img, M, (w, h))对于更明显的俯仰视角,旋转不够,还要用四个点的透视变换。我一般把 minAreaRect 的四点作为透视源点,目标矩形的宽高按车牌比例设定,然后 warpPerspective。校正这一步做好,后面分割的难度直接下降一个量级。
3.4 什么时候应该换成深度学习定位
这段定位流程在“蓝底白字、光照正常、车距适中”的场景里非常稳,但夜间的蓝牌、黄色工程车车牌、新能源绿牌,光靠颜色阈值就会抓瞎,更别提远处小目标。工业应用想覆盖这些场景,直接用轻量检测模型替换掉颜色筛选模块就行,分割和识别模块都可以保留。我的建议是,传统视觉方案用来理解管线、跑通 demo 很有价值,但要追求鲁棒性就要果断换检测模型,没必要在传统方案上硬磕。
4. 字符分割:把车牌切成单个字符
4.1 二值化:先让字符从复杂背景里凸出来
切割之前先把校正好的车牌图转灰度,再二值化。这里我推荐用 OTSU 大津法做自适应阈值,而不是手写固定阈值,因为不同车牌的底色深浅、曝光条件差异很大,固定阈值几乎没有一次是对的。
二值化方向要注意:蓝底白字车牌,直接用普通阈值会得到白底黑字,投影统计时还要反过来数非零像素,逻辑容易绕晕。我统一用 THRESH_BINARY_INV,把字符变成白色,背景变成黑色,这样后面的投影分割语义就非常清晰了:
gray = cv2.cvtColor(plate, cv2.COLOR_BGR2GRAY) _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)4.2 垂直投影:找字符之间的“空隙”
分割思路是逐列统计白色像素数量,画出投影直方图。字符笔画多的列白点数多,字符之间的空隙白点数接近 0,波谷位置就是分割点。代码写起来不复杂:
proj = (binary == 255).sum(axis=0) in_char = False start = 0 segments = [] for x, count in enumerate(proj): if count > 0 and not in_char: in_char = True start = x elif count == 0 and in_char: in_char = False segments.append((start, x - 1))这段逻辑找出来的是一组连续非零区间,这些区间不一定正好就是单个字符,还需要按规则合并和过滤。
4.3 边框、铆钉和汉字粘连问题
这是我踩坑最多的地方。真实车牌放大后边缘还有白色边框和铆钉,阴影和反光也可能产生噪声,导致投影图在最左和最右出现两根很窄的“假字符”。处理办法是在投影前先把图像左右边缘各裁掉几个像素,再用小核开运算把零散噪点去掉。
汉字是分割里最麻烦的部分。左右结构的汉字笔画中间会有空隙,投影图上字内部出现一个明显的波谷,所以一个汉字经常被切成左右两截。我的合并思路很简单:先拿到所有候选区间的宽度,算中位数,然后把宽度明显小于中位数 60% 且和相邻区间间隙特别小的片段合并。这个规则是启发式的,不是绝对严谨,但实测中能修掉大部分过切问题。
还有一种更暴力的兜底:如果按投影法切完后的数量不是 7,我就直接按整张图宽度等分成 7 份。这个方案在倾斜校正做得好的情况下准确率相当高,相当于给分割环节加了一层保底逻辑。
4.4 分割图的归一化处理
每个字符小图输出之前都要做归一化。直接 resize 到 48x48 会破坏笔画比例,所以我先按字符框稍微加一圈 padding,再 resize。针对像素值,只做简单线性归一化到 0~1,均值方差归一化反而在字符图像上收益不大。最终每个字符图是单通道灰度图,进入识别网络之前要保持通道数一致,我用 1 通道训练,推理时也用 1 通道,不要训练时单通道、推理时 BGR 转灰度搞乱维度。
5. 识别模型:一个轻量 CNN 就够用
5.1 设计字符集:先把不可能出现的字符干掉
车牌字符识别的类别空间比想象中小很多。常规蓝牌中,第一位是省级简称汉字,第二位是字母,后面是字母数字混合。为了降低混淆,车牌里不会出现英文字母 I 和 O,因为会和数字 1、0 打架。所以我的字符集构成为:省级简称汉字约 31 类,大写字母 24 类,数字 10 类,总共 65 类。
我最初想做一个位置感知的多分支模型,第一位只输出汉字,第二位只输出字母。后来发现没必要,单字符分类模型根本不关心字符出现在第几位,模型只看字符形态就能学会。汉字和字母、数字的形态差异极其明显,模型不会因为“第一位”才认识汉字。推理时如果发现结果第一位是数字,那基本是分割阶段出了问题,而不是分类模型的问题。
5.2 网络结构:三层卷积加一个分类头
网络结构比我预期的还要简单。输入是 48x48 单通道字符图,经过三层卷积加池化,最后用全局平均池化接全连接层。我没有照搬 VGG 那种全连接大尾巴,因为字符图太小、类别适中,全连接层太大会迅速过拟合。
| 层 | 输出尺寸 | 说明 |
|---|---|---|
| Input | 1 x 48 x 48 | 单通道字符图 |
| Conv3x3 -> BN -> ReLU | 32 x 48 x 48 | 步长 1,padding 1 |
| MaxPool 2x2 | 32 x 24 x 24 | 降采样 |
| Conv3x3 -> BN -> ReLU | 64 x 24 x 24 | 步长 1,padding 1 |
| MaxPool 2x2 | 64 x 12 x 12 | 降采样 |
| Conv3x3 -> BN -> ReLU | 128 x 12 x 12 | 步长 1,padding 1 |
| MaxPool 2x2 | 128 x 6 x 6 | 降采样 |
| GlobalAvgPool | 128 | 全局平均池化 |
| FC -> ReLU -> Dropout(0.3) | 256 | 分类头 |
| FC | 65 | 类别输出 |
整个模型的参数量大约 15 万,比动辄上千万参数的分类网络小了快两个数量级,在 CPU 上推一张字符图也就是毫秒级别。BN 层很关键,合成数据的像素分布和真实图差异大,BN 能缓解训练和推理分布不一致的问题。
5.3 损失函数与评估指标
分类损失直接用 CrossEntropyLoss,没有做类别加权,因为合成数据的类别分布相对均匀,出现个别汉字类别样本少时,我的处理是重新生成一批数据补进去,而不是靠加权硬拉。
评估指标我同时看两个:单字符准确率和整牌准确率。整牌准确率要求一张车牌上 7 个字符全部对才算对。训练时单字符准确率很容易到 99%,但整牌准确率会因为误差累积掉到 95% 左右,这才是最终要优化的指标。如果训练日志只盯单字符率,很容易产生“模型已经无敌”的错觉。
5.4 进阶方向:不定长字符序列
固定 7 位方案有一个隐含前提:车牌的字符数固定。国内蓝牌没问题,但新能源绿牌是 8 位,黄牌啊、其它特种牌又有不同位数。如果想做成通用方案,可以走 LPRNet 或者 CRNN 的思路,用一个网络直接输入整张车牌图,用 CTC Loss 输出不定长字符串。这个方案工程上更通用,但调试复杂度高了一个档次,我是在跑通固定 7 位方案之后才打算碰它的。对学习项目来说,先把固定长度版本做对,后面再扩展,我认为是最稳的路线。
6. 训练与推理:从模型到完整链路
6.1 训练配置:这个参数组合可以直接抄
训练配置我试过好几轮,最后稳定在一组很常规的参数上。
| 配置项 | 参数 |
|---|---|
| 优化器 | AdamW |
| 初始学习率 | 1e-3 |
| 学习率调度 | CosineAnnealingLR |
| weight_decay | 5e-4 |
| batch_size | 128 |
| epoch | 50 |
| 输入尺寸 | 48 x 48 |
初始学习率 1e-3 是 Adam 系优化器比较舒服的位置,SGD 想用这个学习率大概率会震荡。weight_decay 我调过几次,5e-4 在字符分类这种小模型上是比较稳的,太大了欠拟合,太小了后期会有轻微过拟合。如果显存不足,优先调小 batch_size,学习率可以暂时不动,因为还有 cosine 调度在兜底。
6.2 训练循环里的几个关键点
训练循环本身是标准套路,多轮训练、验证、保存最优模型。我额外加了两个习惯:验证集上计算整牌准确率是单独写函数的,不会在训练循环里顺便算;早停逻辑参考验证集整牌准确率而不是单字符率,因为整牌率更能反映最终效果。
模型保存也讲究。我每个 epoch 都对比验证集整牌准确率,只要比上一轮好就覆盖保存最优权重,而不是等到训练完全结束再保存最后一轮。最后一轮往往已经过拟合了,直接用会吃亏:
if val_whole_acc > best_acc: best_acc = val_whole_acc torch.save(model.state_dict(), "best_char.pth")6.3 推理流程:三个模块串成一条流水线
训练完不是终点,把三个模块串起来才是完整Demo。我的推理函数大致长这样:
def recognize(img_path): img = cv2.imread(img_path) plate = locate_plate(img) # 模块1:定位 if plate is None: return None warped = align_plate(plate) # 模块2:透视校正 chars = split_chars(warped) # 模块3:字符分割 result = "".join( model.predict(normalize(c)) for c in chars ) return result中间每一步我都在磁盘上留下可视化结果:定位结果画框、校正后的车牌图、分割出的字符小图按顺序拼接成一张横条图。这些中间结果平时看觉得多余,一旦效果出问题,它们就是最好的排查依据。
6.4 效果评估与预期
在合成测试集上,我最终的字符准确率在 99% 以上,整牌准确率在 95% 左右。换到真实随手拍的照片后,整牌准确率直接掉到 80% 上下,最典型的问题是倾斜角度超出增强范围,以及低分辨率和运动模糊。所以评判这个项目时不能只看合成指标,真实场景的准确率才是真实水平。哪怕只是加一个“把定位模块换成 YOLO”的改动,真实场景整牌率就能再往上走不少。
7. 踩坑记录:这些问题我挨个遇到过
7.1 光照变化一多,蓝牌就“泛白”
白天顺光拍到的蓝牌颜色浓郁,傍晚或者阴天拍到的蓝牌饱和度低,HSV 范围的 S 下限拉高后容易漏检,调低后又会把蓝色车身、蓝色招牌误检出来。我的处理思路是先把 S 和 V 的下限放宽一点,用面积和宽高比过滤掉明显的误检;晚上和极端光照场景下干脆承认传统方案不够用,直接走深度学习定位路线,不要在颜色阈值上费太多时间。
7.2 汉字被切两半,问题出在分割而不是识别
有段时间整牌准确率卡在 90% 上不去,我怀疑识别模型不够好,换了更大的网络也没用。后来把分割结果可视化一看,很多汉字被竖着切成了两半,右半边被单独当成一个字符去识别,模型当然全是错的。合并规则加进去之后,准确率一下就上来了。这个案例给我的经验是:很多“识别不准”其实是前面的预处理或者分割不准,先看图再调模型,永远比盲目调参快。
7.3 字母数字相似对:B 和 8、D 和 0
车牌字符集里去掉了 I 和 O,但 B 和 8、D 和 0、Z 和 2、Q 和 0 依然长得像。在低分辨率或者模糊场景中,模型经常在这些相似对上翻车。我的处理是合成数据时对这几组相似对有意识提高概率,让模型多看难例;推理阶段再叠一个位置合理性规则:第一位必须是汉字,第二位必须是字母,其余位置如果是字母和数字混合就不再做硬约束。这层规则并不复杂,但能救回不少边缘情况。
7.4 真实场景掉点,核心是合成数据“太干净”
合成数据在纯色背景、标准字体、均匀光照下生成,模型学到的其实是“一张干净图片里的字符”,而不是“真实世界里的字符”。我花了很大力气把背景换成真实街景图,加了随机运动模糊和 JPEG 压缩模拟,让合成分布的边界往外扩。domain randomization 的收益在这个项目里非常明显,模型泛化能力提升比换网络结构来得直接。
7.5 常见问题排查速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 车牌框定位偏移 | HSV 阈值不合适 | 输出 mask 可视化,看目标区域是否完整 |
| 检测到多个蓝色区域 | 形态学核过大 | 调小闭运算核,增加宽高比过滤 |
| 分割结果多出碎块 | 边框、铆钉被当作字符 | 投影前裁掉左右边缘,做开运算 |
| 汉字被切成两半 | 投影法受汉字内部空隙干扰 | 用宽度中位数合并过窄片段 |
| 识别结果第一位是数字 | 分割把汉字切坏了 | 检查分割中间图,位置规则兜底 |
| 真实图片掉点严重 | 合成分布太窄 | 增强背景、光照、模糊的多样性 |
8. 项目最后的一点心得
这个车牌识别项目做完,我最大的收获不是模型准确率,而是养成了一套“先定位问题环节”的排查习惯。每次结果不对,我不会再一头扎进模型结构里改来改去,而是先把每个模块的中间输出打印出来,看看到底是定位框歪了、字符切碎了,还是模型认错了,问题发生在哪个环节就改哪里。
最后分享一个小技巧:训练车牌识别这类有明确“任务链”的项目,一定要把中间结果可视化落实到代码里,哪怕多写几行自动保存的逻辑,调试效率也会翻倍。因为我踩过的坑里,至少有一半问题都是通过看中间图才定位到的,而不是靠看准确率曲线看出来的。这个项目后面我打算把定位模块替换成深度学习方案,再把字符识别换成 CTC 不定长输出,方向已经很清楚,但骨架仍然还是这几个模块。