车牌识别系统实战:从传统视觉到深度学习的完整搭建指南
打开搜索页输入“车牌识别”四个字你大概会看到一堆论文摘要、演示视频和开源项目截图。但真正动手把一套系统从零搭出来能跑、能答、能演示、能经受住答辩追问的同学往往比想象中少得多。基于机器视觉的车牌识别系统作为毕业设计卡住大多数人的地方其实不是“原理看不懂”而是“这个模块到底怎么调参”“那条技术路线到底该怎么选”“实测的时候哪些问题躲都躲不开”。这篇文章就把整个项目从定位、分割、识别到最终串成系统的完整过程拆开讲结合我近期重跑这一课题的经验把每个环节的选型理由、参数依据和踩坑心得一并写出来希望能给准备做这个方向的同学一份真正接地气的参考。1. 先从路线选择说起传统视觉流程和深度学习方案怎么权衡做车牌识别之前第一个要做的不是写代码而是想清楚走哪条技术路径。这一步选错后面可能要多花两倍以上的时间切换。我把常见的方案分成两条大路。第一条是传统机器视觉流程也是很多人印象中的“OpenCV车牌识别”。流程大致是读图、灰度化、边缘检测、形态学处理、轮廓筛选、颜色确认然后对车牌区域做字符分割接着用模板匹配或者分类器做字符识别。这条路的优点非常明显不依赖显卡、环境配置简单、每个环节都可以单独展示原理特别适合需要讲清楚“机器视觉”概念的毕设。缺点是鲁棒性有限光照一变、角度一偏、画面一模糊定位或者分割就容易出问题。第二条是深度学习方案。最常见的是两阶段结构先用目标检测模型定位车牌位置再用识别模型或者端到端序列识别模型直接输出字符。这条路在复杂场景下的效果远好于传统方法也是工程落地的主流选择。但代价是要准备数据集、要训练模型、要处理标注问题时间成本高出一截而且答辩时如果老师追问模型原理你需要把网络结构、损失函数、训练策略这些细节都说清楚不能只会调包。很多同学上来就纠结“哪个高级选哪个”其实思路反了。我在重做这个项目时的体会是先看你的题目要求和可用条件。如果老师明确要求“基于机器视觉”走传统流程反而更好讲故事如果题目没有额外限定而你有一定深度学习的底子那么混合方案是性价比最高的——用传统视觉方法做车牌定位展示图像处理功底识别层用轻量卷积神经网络替代模板匹配既保住了“机器视觉”的核心又体现了对现代方法的掌握。我最后采用并推荐的正是这种混合结构。下面把每个模块的具体做法拆开聊。2. 第一步落地车牌定位不是找车牌而是筛掉一堆“长得像”的东西2.1 预处理与边缘提取参数为什么这样设车牌定位的思路本质上是利用车牌的几何特征和颜色特征从整张图里找出那个矩形区域。先把图像转换成灰度图然后用高斯滤波做平滑这一步非常重要。很多新手跳过滤波直接做边缘检测结果边缘图里全是纹理噪声车牌轮廓根本连不成一个完整区域。高斯滤波的核大小我一般取(5, 5)sigma直接给0让OpenCV自己算。接着用Canny边缘检测。Canny有两个阈值低阈值和高阈值我常用的是(100, 200)。这个组合的意思是梯度幅值超过200的像素一定被当边缘低于100的直接丢弃介于中间则看是否与强边缘相连。对于白天拍摄的车辆图片这套参数基本能稳定工作如果整体画面偏暗可以把两组值同时下调到(80, 160)再试试。为什么不推荐SobelSobel计算量小但它的边缘响应是各向异性的而且会产生双边缘对后续形态学连接并不友好。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(axis0) // 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学出可用的边界。从我的体验来说HOGSVM当成一个完整的“传统方案”来讲解是合格的但它的准确率上限受特征表达的制约对模糊字符、低对比度字符的表现都不太理想。如果你已经决定做传统流程与其在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, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(128 * 4 * 4, 256), nn.ReLU(inplaceTrue), 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 还有一句实在话如果你做这个项目的时间有限请一定先固定一个评测集每改一个模块就重跑一次同一组图片记录准确率和耗时的变化。这个习惯能让你避免“调了一周参最后说不清是变好了还是变差了”的窘境。我自己重跑这套车牌识别流程时最大的体会就是单模块的感知质量和全流程的客观指标完全是两回事只有评测数据能告诉你真正的答案。车牌识别这个课题听起来老套但做下来涉及的环节其实不少——图像预处理、形态学分析、特征筛选、机器学习或深度学习分类、系统集成每一块都足够展开写深。选一条适合自己的路线先把主流程跑通再把细节逐一打磨最终成果一定对得起这几个月的投入。