ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

车牌识别系统实战:从传统视觉到深度学习的完整搭建指南

车牌识别系统实战:从传统视觉到深度学习的完整搭建指南

打开搜索页输入“车牌识别”四个字,你大概会看到一堆论文摘要、演示视频和开源项目截图。但真正动手把一套系统从零搭出来,能跑、能答、能演示、能经受住答辩追问的同学,往往比想象中少得多。基于机器视觉的车牌识别系统作为毕业设计,卡住大多数人的地方其实不是“原理看不懂”,而是“这个模块到底怎么调参”“那条技术路线到底该怎么选”“实测的时候哪些问题躲都躲不开”。这篇文章就把整个项目从定位、分割、识别到最终串成系统的完整过程拆开讲,结合我近期重跑这一课题的经验,把每个环节的选型理由、参数依据和踩坑心得一并写出来,希望能给准备做这个方向的同学一份真正接地气的参考。

1. 先从路线选择说起:传统视觉流程和深度学习方案怎么权衡

做车牌识别之前,第一个要做的不是写代码,而是想清楚走哪条技术路径。这一步选错,后面可能要多花两倍以上的时间切换。我把常见的方案分成两条大路。

第一条是传统机器视觉流程,也是很多人印象中的“OpenCV车牌识别”。流程大致是:读图、灰度化、边缘检测、形态学处理、轮廓筛选、颜色确认,然后对车牌区域做字符分割,接着用模板匹配或者分类器做字符识别。这条路的优点非常明显:不依赖显卡、环境配置简单、每个环节都可以单独展示原理,特别适合需要讲清楚“机器视觉”概念的毕设。缺点是鲁棒性有限,光照一变、角度一偏、画面一模糊,定位或者分割就容易出问题。

第二条是深度学习方案。最常见的是两阶段结构:先用目标检测模型定位车牌位置,再用识别模型或者端到端序列识别模型直接输出字符。这条路在复杂场景下的效果远好于传统方法,也是工程落地的主流选择。但代价是要准备数据集、要训练模型、要处理标注问题,时间成本高出一截,而且答辩时如果老师追问模型原理,你需要把网络结构、损失函数、训练策略这些细节都说清楚,不能只会调包。

很多同学上来就纠结“哪个高级选哪个”,其实思路反了。我在重做这个项目时的体会是:先看你的题目要求和可用条件。如果老师明确要求“基于机器视觉”,走传统流程反而更好讲故事;如果题目没有额外限定,而你有一定深度学习的底子,那么混合方案是性价比最高的——用传统视觉方法做车牌定位展示图像处理功底,识别层用轻量卷积神经网络替代模板匹配,既保住了“机器视觉”的核心,又体现了对现代方法的掌握。

我最后采用并推荐的,正是这种混合结构。下面把每个模块的具体做法拆开聊。

2. 第一步落地:车牌定位不是找车牌,而是筛掉一堆“长得像”的东西

2.1 预处理与边缘提取:参数为什么这样设

车牌定位的思路,本质上是利用车牌的几何特征和颜色特征,从整张图里找出那个矩形区域。先把图像转换成灰度图,然后用高斯滤波做平滑,这一步非常重要。很多新手跳过滤波直接做边缘检测,结果边缘图里全是纹理噪声,车牌轮廓根本连不成一个完整区域。

高斯滤波的核大小我一般取(5, 5),sigma直接给0让OpenCV自己算。接着用Canny边缘检测。Canny有两个阈值,低阈值和高阈值,我常用的是(100, 200)。这个组合的意思是:梯度幅值超过200的像素一定被当边缘,低于100的直接丢弃,介于中间则看是否与强边缘相连。对于白天拍摄的车辆图片,这套参数基本能稳定工作;如果整体画面偏暗,可以把两组值同时下调到(80, 160)再试试。

为什么不推荐Sobel?Sobel计算量小,但它的边缘响应是各向异性的,而且会产生双边缘,对后续形态学连接并不友好。Canny做了非极大值抑制和滞后阈值连接,出来的边缘更“干净”,方便闭运算把它们拼成块。

2.2 形态学闭运算与候选框筛选:核心是“把断裂的边缘连起来”

车牌边缘经过Canny之后,通常不是一条完整闭合的矩形边框,而是一段一段的短线。这时候就用形态学闭运算,先膨胀再腐蚀,把相邻的边缘点连起来,最终让整个车牌区域变成一个相对完整的白色块。

闭运算的核尺寸是这套流程里最值得调的参数。车牌的形状是横向矩形,所以我用的核也是横向矩形,比如(17, 5)。核太大会把旁边的车灯、保险杠边缘也连进来,候选框多得离谱;核太小则车牌边缘连不成块,直接漏检。需要说明一点:这些像素级参数和输入图像尺寸强相关,如果测试图是1920×1080或更高分辨率,17像素宽的核就不太够用,要同比放大到(25, 7)甚至更大。最省事的做法是把输入图统一缩放到一个固定宽度,比如1280像素,再来调参,之后测试集都走同一套尺度。

得到闭运算图之后,查找轮廓,然后按几何条件过滤。我的过滤条件一般长这样:

for cnt in contours: box = cv2.minAreaRect(cnt) (x, y), (w, h), angle = box if w < h: w, h = h, w angle += 90 aspect = w / max(h, 1e-6) if 2.5 < aspect < 5.8 and w > 80 and h > 20: # 用面积填充率再做一次筛选 area = cv2.contourArea(cnt) if area / (w * h) > 0.4: candidates.append(box)

宽高比为什么取2.5到5.8?国内标准车牌的长宽比大约是2.7左右,但考虑到拍摄角度和透视变形,实际框出来会浮动,所以我留了一定余量。面积填充率大于0.4这条,是为了排除那些“虽然是矩形但内部极不规则”的轮廓,比如远处建筑的窗户、车身上的广告图案。

如果你只做边缘+几何筛选,会发现候选框仍然不少——车灯、中网、反光贴纸都可能被框进来。这时候必须上颜色验证。

2.3 颜色确认和透视矫正:定位环节的“兜底”

车牌最稳定的特征其实是颜色。蓝色车牌、绿色新能源车牌、黄色车牌,底色和字符颜色组合非常固定,用HSV空间的颜色比例能快速筛掉误检。

我的做法是把候选区域截下来,转成HSV,统计对应颜色的像素占比。注意,HSV的H分量是色相,S是饱和度,V是明度。蓝色车牌的大致范围我常定为:

mask = cv2.inRange(hsv, (90, 80, 90), (130, 255, 255))

这个范围的意思是:H在90到130之间偏蓝紫色,S够高表示不是灰白色,V够高表示不是暗黑环境。很多同学在这里喜欢把范围卡得很死,结果相机白平衡一变,蓝色偏了一点紫,就被系统漏掉了。正确思路是把H范围放宽到90到130,宁可多保留几个候选,后面再用蓝色像素比例二次过滤。

比例阈值我一般用0.3,也就是候选区域里蓝色像素超过30%才认为是车牌。如果多张图片颜色分布不稳定,可以把这个阈值降到0.25,代价是误检率上升,但对定位流程影响不大,因为后面还有字符分割和识别在把关。

候选区域确认之后,如果车牌有倾斜,要做透视矫正。最简单的做法是用cv2.minAreaRect返回的角度信息,对roi做仿射旋转;更稳的做法是提取最小外接矩形的四个顶点,用透视变换拉正。角度小于8度时,不做矫正也行,但一旦超过这个值,字符分割的垂直投影就基本废了。所以我的建议是:定位环节统一把车牌照正,宁可在这一步多花一点计算,也不要把倾斜问题留给后面。

3. 分割环节:字符切分是整条流程里最脆的一环

这个环节是整个传统路线里的重灾区。很多同学走到这一步才发现:就算车牌定位准了,字符也未必能干净切开。

3.1 二值化与去除干扰:分割前必须处理的事

先把车牌区域放大,比如缩放到高60像素左右,保持宽高比。然后转灰度,用大津法(Otsu)自动求阈值做二值化。蓝底白字的情况下,灰度化后字符比背景亮,二值化出来是白字黑底,可以直接用;如果是黄底黑字,则要反色后再处理。

但实际车牌图像里还有两样东西会毁掉分割:边框和铆钉。车牌上沿下沿的白色边框在被截出来之后,会宽出字符很多,必须去掉。常见做法是做一次水平投影——统计每一行的白色像素数量,字符所在行的像素数呈现多个连续高峰,而边框区域全是白色,行像素数异常高,可以按这个规律切除上下多余行。铆钉是字符两侧的小圆点,虽然单个体积不大,但会干扰投影切割,我的做法是先做一次小核的开运算,比如(3, 3)的矩形核,把细小的点状干扰去掉。

还有车牌第二和第三个字符之间的间隔圆点。这个圆点在分割时经常被当成一个单独的字符块,正确的处理方式是:在垂直投影切出的候选块里,把宽度明显小于正常字符的块直接丢弃。下面这段伪代码是垂直投影分割的核心逻辑:

def vertical_projection(binary): h, w = binary.shape col_sum = binary.sum(axis=0) // 255 blocks = [] in_block = False start = 0 for i in range(w): if col_sum[i] > 0 and not in_block: in_block = True start = i elif col_sum[i] == 0 and in_block: in_block = False end = i if end - start >= 10: blocks.append((start, end)) return blocks

这段代码的思路很直白:一列有白色像素就认为字符开始,连续出现空白列就认为字符结束。10像素这个最小宽度是经验值,如果车牌已经被缩放到高60像素,一个正常字符的宽度大约在20~35像素之间,10像素以下的块基本都是圆点、噪点或者残边。

3.2 字符粘连和断裂:分割里最头痛的两个问题

垂直投影能处理大多数“干净”车牌,但一到字符粘连就开始失灵。粘连出现在两种情况:一是二值化之后字符笔画之间的空隙被噪声填上,导致两个字符连成一片;二是笔画本身很密,比如某些汉字宽度又大,和相邻字母挨得很近。

处理粘连,我常用的办法是先看这个块的宽度是不是超过正常字符宽度的1.4倍左右。如果是,就在投影曲线的“谷底”位置切开,也就是找列像素数最小值的地方。这个方案不完美,但对大部分轻微粘连有效。

字符断裂则是另一回事,比如数字“0”中间如果因为反光断了一截,可能被切成上下两段。这类问题靠分割本身很难完全解决,我的做法是加一个特征校验:切出来的字符块,宽高比应该在0.3到1.2之间(汉字接近方、字母和数字略窄),如果某个块宽高比异常大,就尝试使用膨胀操作把断裂处补上再重新投影。说到底,分割要做到“能用规则兜住大部分情况”就够了,剩下的极端个案,要么用识别模型纠错,要么干脆在设计系统边界时说明为已知局限。

4. 识别层:模板、分类器、端到端序列模型,三条路各有什么代价

字符识别是真正拉开效果差距的地方。我按由简到难,把三种常见做法都试过,说说实际表现。

4.1 模板匹配:最简单也最脆

模板匹配的思路极其朴素:把切好的字符图片和准备好的模板库逐一对比,找相似度最高的模板作为结果。好处是代码量小、不需要训练,坏处是它对字体的泛化能力几乎为零。车牌虽然使用标准字体,但不同批次的工艺、不同角度的轻微形变、二值化后的粗细变化,都会让待识别字符和模板的差异变大。实测下来,模板匹配的准确率在90%左右,听上去还行,但这是建立在“模板完全来自同一拍摄环境”的前提上的,换个停车场、换台相机,准确率立刻掉到80%以下。

4.2 分类器路线:HOG特征配合SVM

在深度学习还没这么普及的时候,HOG特征加SVM是车牌识别里的主流做法。HOG提取的是图像的梯度方向分布,对局部形变有一定容忍度;SVM训练快、样本需求相对小。这条路的门槛主要在于:特征维数怎么选、SVM的核函数怎么配、数据类别的数量怎么平衡。字符类别本身很多——31个省级行政区汉字简称、24个英文字母(车牌不使用I和O)、10个数字,加起来超过60类,每个类别都需要足够的正样本,才能让SVM学出可用的边界。

从我的体验来说,HOG+SVM当成一个完整的“传统方案”来讲解是合格的,但它的准确率上限受特征表达的制约,对模糊字符、低对比度字符的表现都不太理想。如果你已经决定做传统流程,与其在SVM里调参,不如直接上小型卷积神经网络。

4.3 轻量CNN代替传统分类器

我最后采用的是这个方案。切好的字符统一缩放到(32, 32),转成单通道灰度图,送进一个三层卷积加两层全连接的轻量网络。网络结构大概是:

class PlateCharCNN(nn.Module): def __init__(self, num_classes): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(128 * 4 * 4, 256), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(256, num_classes), )

这样一个网络的参数量很小,CPU上跑单字符识别不到几毫秒,但单字符识别准确率能到97%左右,明显优于模板匹配和SVM。训练时用了划分好的字符数据集,优化器用Adam,初始学习率设0.001,交叉熵损失,跑了大概二十轮就收敛了。

4.4 端到端序列识别:另一条完全不同的路

如果不想做分割,可以直接用端到端的序列识别模型。这类模型把车牌图像作为输入,输出直接是字符序列,中间不需要字符级标注,训练时用CTC损失对齐输入和输出序列。好处是摆脱了分割这个脆弱环节,坏处是模型更复杂、需要的数据更多、训练时间更长,而且对输入图像的尺度有一定要求。

对你自己的定位来说,我的建议是:如果时间只剩两三周,就用“传统定位+分割+轻量CNN”;如果时间充裕,想冲刺高识别率,可以用“检测模型定位+序列识别”,并且在论文里把两套方案的准确率和耗时都列成对比表,这本身就是很好的正文素材。

5. 训练数据从哪里来:自采、合成与增强的实操比例

5.1 为什么不要只指望现成数据集

很多同学把“数据集”这一环想得太简单,觉得去下载一个开源车牌数据集,直接开始训练就行了。真实的麻烦在于:网络上的车牌数据集要么数量有限,要么拍摄场景单一,要么标注格式不统一,下载下来之后你还要花大量时间清洗和整理。更关键的是,数据集里的车牌字符分布往往不均衡,有些省份汉字出现次数很少,分类器对这类字符的识别率就会明显偏低。

我的经验是,不要指望着靠一个数据集打天下。把数据来源拆成三个部分,组合起来才够用。

5.2 自采流程与合规脱敏

自采数据是最符合毕设场景的。你可以用手机在停车场、小区出入口、路边停车位拍一些清晰的正视角和斜视角车牌,覆盖白天、傍晚、阴天等不同光线。

采集真实车辆图像时,有一点必须提醒:车辆牌照属于个人信息范畴,拍到的行人面部、周边建筑环境也都属于无关信息。用于个人学习研究时,建议对图像做脱敏处理,比如把人脸区域和背景中不必要的车牌模糊掉,数据仅用于课程设计或学术研究,不要公开传播原始数据。这是给自己减少麻烦,也是对他人隐私的基本尊重。

自采的数据量不必追求很大,我在项目里大概拍了三四百张,主要用于测试系统的真实场景表现,而不是作为分类器的主要训练来源。

5.3 程序化合成数据的具体做法

真正解决训练样本量问题的,是程序化合成。做法是:用标准车牌字体渲染出字符组合,做成“无背景”的车牌图,再贴到自然图片的随机位置,同时做透视变换、亮度调整、高斯模糊、噪声叠加。这样一天就能生成几千张带精确标注的车牌图,而且字符类别分布可以由你控制,想多生成哪个省份的汉字都能调。

合成的数据分布和真实场景有差距,直接训练的模型在真实图上可能会掉点。但把它和真实数据混合使用,效果会好很多。我当时用的比例大约是70%合成数据加30%真实数据,分类器的泛化能力比只用真实数据更稳。

5.4 数据增强的顺序和力度

数据增强策略上,我最常用的是随机亮度变化、对比度拉伸、轻微仿射变换、椒盐噪声和高斯模糊。顺序是有讲究的:先做几何变换,再做色彩变换,最后加噪声。因为如果先加噪声再做仿射,插值过程会无意中移除部分噪声,增强效果就打了折扣。

增强力度也要控制。过度的随机遮挡会让模型学到“字符缺一半也能猜”的错误先验,反而降低在纯净车牌上的识别率。我的经验是:单张训练图最多做3到5次随机变换,不要叠加得太狠。

6. 把零散模块拼成系统:主流程、评测指标和演示界面

6.1 主流程代码的组织方式

模块写完之后,不要全部塞在一个脚本里,否则后期想单独调哪一块都很痛苦。我把项目拆成几个文件:预处理模块、定位模块、分割模块、识别模块、主流程。

主流程的逻辑非常简洁:

def recognize_plate(img_bgr): candidates = locate_plate(img_bgr) if not candidates: return None, 0.0, None roi = warp_plate(img_bgr, candidates[0]) chars = segment_chars(roi) if len(chars) < 7: return None, 0.0, roi text = recognize_sequence(chars) return text, confidence, roi

接口设计成输入一张BGR图像,输出识别字符串、置信度和用于调试的车牌区域图。这样的好处是,无论后续是接入摄像头实时视频还是批量处理图片,入口都只有这一个函数。

6.2 评测不只盯着准确率

很多同学汇报成果时只会说“准确率98%”,但这个数字怎么算的、在什么数据上算的,根本没写清楚。评测指标一定要拆细。我建议至少统计三个数:单张图像的运行耗时、字符级准确率、车牌级准确率。字符级准确率是所有识别正确的字符数除以总字符数;车牌级准确率是整块车牌完全识别正确的比例。这两个指标缺一不可,因为有可能字符级准确率很高,但每一块车牌都错了一个字,导致车牌级准确率很低。

评测的时候还要准备一个固定的测试集,每一次改完模块都跑同一批图,记录指标变化。这个习惯极其重要,它能告诉你哪些改动真的有效,哪些只是自我感觉良好。

6.3 演示界面要做但别做重

毕设答辩需要看到东西,只放几个准确率数字是没有感染力的。用一个简单的桌面界面,左边放上传或摄像头画面,右边显示识别结果和置信度,再带一个日志区域记录历史识别记录,就够了。我见过有人把界面做得花团锦簇,各种按钮动画,结果核心流程跑不出结果,老师一问就露馅。界面是锦上添花,不要抢走系统本身的核心地位。

7. 实测翻车记录:颜色、光照和相似字符,这三个坑最值得提前防

7.1 蓝牌、绿牌、黄牌的HSV范围陷阱

蓝牌是最常见的,H在90到130之间基本能覆盖。但绿牌容易出问题:新能源车牌的绿色是渐变色,从左上到右下由深绿过渡到浅绿,H范围跨度接近40度。如果沿用蓝牌那种窄区间思路,绿牌的比例统计会经常失败。我的处理是给绿色牌单独开一条判断路径:H在40到90、S大于60、V大于50的像素都算“绿”,比例阈值同样放在0.3左右。

黄牌这块容易被忽略,教练车、大型车的黄底黑字在灰度化之后,字符和底色的对比方向跟蓝牌相反,分割前必须反色。如果你不想处理颜色分支带来的复杂度,可以在定位环节只保留蓝色和绿色两种车牌,把黄牌当作系统边界明确写进局限说明。

7.2 倾斜、边框、铆钉对分割的连锁破坏

这三个问题单独出现还好说,就怕叠加。一张略微倾斜的绿牌,拍到的边框在灰度化后可能和字符区域连成一片,水平投影去边框时把第一行字符也切掉一截;铆钉又紧贴字母,开运算去噪的同时也磨损了字符笔画。全套流程跑下来,分割出来的字符图像质量很差。

我的对策是把“定位矫正去边框”这三个动作按顺序固化为一个工具函数,任何一张车牌进来都先过这个函数,再进分割。定位阶段角度超过5度就强制做透视矫正;矫正后立刻用高度方向上的字符纹理分布去切除上下边框;铆钉的去除放在二值化之前,对彩色图先做一次形态学滤波。把这些问题在分割之前全部处理掉,后面识别会省心非常多。

7.3 相似字符与字符集约束

字符识别的错误往往集中在几组相似字符上:数字0和字母O、数字1和字母I、数字8和字母B、汉字“渝”和“豫”等。解决思路有两层。第一层,在字符集定义时直接去掉不合法字符——车牌字符集里本就不含I和O,所以分类器的输出类别可以直接剪掉,避免模型在这两个类别之间摇摆。第二层,利用车牌的结构先验做后处理:第一位必须是汉字,第二位必须是字母,第三到七位是字母和数字的混合。这个校验规则写成一个后处理函数,一旦识别结果不符合结构约束,就对可疑位置的字符做二次识别,取logits里第二高分作为替补。

7.4 弱光与运动模糊如何低代价补救

弱光场景下,整个传统视觉流程的准确率会明显下滑,因为边缘检测和颜色判断同时失灵。一个低成本的提分手段是在预处理前端加CLAHE自适应直方图均衡化,它对局部亮度的提升比普通直方图均衡更自然,车牌区域往往从“几乎看不清”变成“勉强能识别”。但也要说实话,夜间强光照射、车灯炫光、大角度运动模糊这几种场景,传统方法就是扛不住,我最终把它们写进了论文的局限与展望,而不是强行修到完美。

如果是视频流识别,还有一招成本更低:连续帧投票。取同一个车牌在多帧里的识别结果,以出现次数最多的字符串作为最终结果。单帧准确率不足的时候,这种方法往往能把整段视频的判定结果拉回正确。

8. 如果你想把课题往上推:可扩展的方向和边界

8.1 端到端重新组合:检测加识别的轻量化

完成了传统流程之后,很多人会问:还能不能做得更好?我的建议是换个思路重新组合。用轻量级目标检测模型负责车牌定位,替代传统边缘加颜色的手工筛选;再把分割+单字符识别替换成序列识别模型。整套流程的定位鲁棒性和识别鲁棒性都会上一个台阶,而且两套方案可以形成对比实验,论文篇幅一下就充实了。

代价是环境依赖更重,通常需要GPU训练,而且模型的部署体积会变大。如果答辩现场的机器没有GPU,纯CPU推理速度可能不理想,这时就需要用一些CPU侧的推理加速方案,或者干脆只跑离线图片,不追求实时。

8.2 视频流多帧投票

刚才提到的多帧投票,如果想做完整,可以加入一个简单的车辆跟踪逻辑:在视频帧之间用IOU匹配同一辆车,维护一个“车牌投票池”,车辆离开画面时输出最终结果。这个小功能虽然代码量不大,但能显著改善实际场景下“单帧识别失败但全程识别成功”的表现,是加分项。

8.3 写清楚“系统边界”,比追求满分更重要

很多毕设论文最容易犯的毛病,是吹得天花乱坠,最后实测暴露一堆问题。我自己的经验是,反而把系统的边界老老实实写清楚,会让人觉得你对自己做的东西有清醒的认识。哪类场景识别率高、哪类场景会失败、失败的根本原因是什么、如果要改进需要做什么——这几句话写明白了,比在结论里喊“效果良好”有说服力得多。

8.4 还有一句实在话

如果你做这个项目的时间有限,请一定先固定一个评测集,每改一个模块就重跑一次同一组图片,记录准确率和耗时的变化。这个习惯能让你避免“调了一周参,最后说不清是变好了还是变差了”的窘境。我自己重跑这套车牌识别流程时,最大的体会就是:单模块的感知质量和全流程的客观指标完全是两回事,只有评测数据能告诉你真正的答案。

车牌识别这个课题,听起来老套,但做下来涉及的环节其实不少——图像预处理、形态学分析、特征筛选、机器学习或深度学习分类、系统集成,每一块都足够展开写深。选一条适合自己的路线,先把主流程跑通,再把细节逐一打磨,最终成果一定对得起这几个月的投入。

RELATED READING

延伸阅读

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