车牌识别做过的人应该都清楚,最折腾人的不是后面字符怎么切、模型怎么训,而是前面这一步——怎么把车牌从一张乱七八糟的图里准确捞出来。上一篇文章我们聊了整体流程,这次专门把“车牌定位”这块拆开,结合完整可跑的代码,讲讲我实际调试中踩过的坑和一些不太好搜到的细节。
先说下背景:这套系统用的是Python + OpenCV,走的是传统视觉路线,目标是把蓝色车牌从自然场景图中稳定定位出来。因为不需要GPU、不依赖模型训练,普通PC就能跑,非常适合入门学习,也适合快速搭一个演示原型。如果你后面想上深度学习,这套定位结果也可以直接当训练集生产工具用。
1. 车牌定位的整体思路与方案选型
1.1 车牌定位为什么是识别系统的第一道门槛
很多新手拿到项目先想“怎么识别字符”,结果跑起来发现全是问题,根源基本都在定位上。车牌在整张画面里的占比通常很小,可能几千像素的图里车牌只占几百×几百的区域,直接整图识别一是计算量浪费严重,二是背景里的文字、标志、灯牌全都会干扰结果。
定位这一步干的事情,本质上就是两件事:找到车牌在哪,然后把这块区域干净地切出来。切得好不好,直接决定后面字符分割的难度。我见过不少同学在定位阶段就把车牌切歪、切残,或者把旁边类似蓝色的区域一起圈进来,后面再怎么调模型都救不回来。
1.2 车牌特征分析:为什么我最终选了“颜色”作为主力方案
车牌本身有四个明显的视觉特征:颜色、形状、纹理、边缘。它们各自对应一套定位思路,分别是颜色分割、轮廓筛选、字符纹理聚类、边缘检测。
先说结论:我做的是蓝色车牌识别,主力方案是HSV颜色分割 + 几何筛选。为什么是它?
最核心的原因是鲁棒性和实现成本之间的平衡。边缘检测和纹理聚类对环境特别敏感,草地里、砖墙上、树荫下到处都是“强边缘”,误检率极高;而颜色特征相对稳定,虽然光照会影响饱和度,但车牌喷涂的蓝色在HSV色域里有相对固定的分布区间。而且,颜色分割不需要训练,换一个场景拍出来的图片,只要找对HSV阈值,基本都能跑通。
你可能要问,为什么不直接用模板匹配?因为拍摄角度、尺度、光照一变,模板匹配就废了。为什么不直接上深度学习?因为对新手来说,数据标注、训练调参、环境配置这些成本太高,传统方案已经是“性价比最优解”。
1.3 方案总体流程设计
整个定位流程我把它拆成五步:预处理 → 颜色分割 → 形态学处理 → 轮廓提取 → 几何筛选。每一步都是为了解决特定问题。
原图输入 │ ▼ 预处理(缩放 + 高斯模糊) │ ▼ BGR转HSV │ ▼ inRange颜色阈值提取蓝色区域 │ ▼ 形态学闭运算(连接断裂区域) │ ▼ findContours提取轮廓 │ ▼ 按面积、宽高比、周长过滤 │ ▼ 输出车牌位置(可附带透视矫正)2. 核心细节解析与实操要点
2.1 HSV为什么比RGB更适合做颜色分割
RGB是加色模型,描述的是“颜色的加法混合”,三个通道之间的相关性很高,光照一变化三个值一起变,很难用简单阈值去框定“蓝色”的范围。HSV把颜色分解成色调H、饱和度S、明度V三个独立维度,其中H是用户感知层面的“这是什么颜色”,S是颜色浓度,V是亮度,分割时只用H通道就能大致锁定色相范围,再用S和V排除过暗、过亮或发灰的像素,鲁棒性就上来了。
具体到OpenCV,它把H通道范围压缩到了0~180(标准HSV里是0~360),所以很多网上代码里的蓝色阈值才是100~124这个数,第一次用的时候别觉得奇怪。
2.2 蓝牌的HSV阈值到底怎么定出来的
这是整个方案里最“玄学”的部分,也是网上各种版本代码差别最大的地方。我直接给出我实测调过的一组值:
| 通道 | 下限 | 上限 | 说明 |
|---|---|---|---|
| H(色调) | 100 | 124 | 覆盖蓝色范围,留出抗光照余量 |
| S(饱和度) | 90 | 255 | 下限不要调太低,否则浅蓝、灰蓝也进来了 |
| V(明度) | 80 | 255 | 排除黑影、暗部区域 |
这套参数在光线正常的白天场景下表现很稳。但注意,雨天、黄昏、隧道灯光下,车牌的蓝色会因为色温变化发生偏移,H值甚至能偏到95左右,S值会下降。所以实际项目里,我会建议做一个“分级检测”——先用严格阈值试,如果定位不到区域,就放宽到H(90~130)、S(60~255)再试一次。
2.3 形态学操作的真正作用:不是“好看”,是“连接断裂”
很多人跑完inRange,看到二值图里车牌区域像是被啃过一样、全是洞,就直接findContours了,结果轮廓东一块西一块,死活筛不出完整车牌。你需要用形态学操作把断裂的蓝色区域“焊”起来。
最关键的是闭运算(先膨胀,再腐蚀)。膨胀把白色区域往外扩,把相邻的字符区域连成一片;腐蚀再把扩出来的边缘收回去,保持整体尺寸不虚胖。这个逻辑很像给破洞牛仔裤打补丁:先往洞四周填上布,再把多余的线头剪掉。
核心参数是核大小。我实测下来,车牌在画面里宽度约200~300像素时,核选17x5比较合适。为什么要做成“宽而扁”的形态?因为车牌本身是长条矩形,横向跨度远大于纵向,用宽扁的核能更好地贴合形状特征,把水平方向上断裂的字符区域连起来,同时不会把上下方向无关的蓝色区域也粘连进来。
2.4 轮廓筛选的四个硬性指标
找到所有轮廓后,正主可能要排除一大批“长得像车牌但不是车牌”的东西——蓝色广告牌、蓝色铁皮、蓝衣服、蓝色车身贴纸。几何筛选一口气过四关,谁过不了谁淘汰:
- 面积关卡:过滤掉过大和过小的区域。这需要根据你的图像分辨率来定,如果我输入图缩放后宽度在600~800像素,车牌宽约150~250像素、高约50~80像素,面积差不多在8000到20000之间(在缩放图上)。设个总像素比例的区间,比写死数值更稳,比如占图总面积的0.1%到5%。
- 宽高比关卡:标准车牌宽440mm、高140mm,比例约3.14:1。但实际拍摄时因为透视,比例会在2.5到4.5之间浮动,所以我筛的是2.2到5.0这个区间。
- 矩形度关卡:车牌是矩形,轮廓面积与最小外接矩形面积之比不宜低于0.4。这一关能滤掉很多细长条、不规则形状。
- 周长校验:对轮廓求最小外接矩形后,算一下周长的理论范围,通常车牌周长在(高度+宽度)×2上下浮动,误差不超过20%。
这一套下来,误检率能压到很低。下面贴出核心代码,带注释讲。
3. 实操过程与核心代码实现
3.1 完整代码框架与逐步讲解
这是整个定位模块的核心代码,我做了删减和整理,可直接运行在Python 3.8+ / OpenCV 4.x环境:
import cv2 import numpy as np def preprocess(img, target_width=800): """ 预处理:统一宽度,减小计算量,并做轻度模糊去噪。 实测结论:宽度缩放到800左右,既保留车牌细节,又不会太卡。 """ h, w = img.shape[:2] scale = target_width / w if scale != 1.0: img = cv2.resize(img, (target_width, int(h * scale)), interpolation=cv2.INTER_AREA) blur = cv2.GaussianBlur(img, (5, 5), 0) return blur def extract_blue_mask(hsv): """ 两次阈值检测: 第一次用严格阈值,精确度高; 第二次放宽阈值,用于召回(光照不佳时车牌蓝色会偏移)。 """ # 严格蓝色范围 lower_strict = np.array([100, 90, 80]) upper_strict = np.array([124, 255, 255]) mask_strict = cv2.inRange(hsv, lower_strict, upper_strict) # 宽松蓝色范围 lower_loose = np.array([90, 60, 80]) upper_loose = np.array([130, 255, 255]) mask_loose = cv2.inRange(hsv, lower_loose, upper_loose) # 优先返回严格掩码,若前景占比极小则退回宽松掩码 if cv2.countNonZero(mask_strict) < 10: return mask_loose return mask_strict def morphological_connect(mask, kernel=(17, 5)): """ 闭运算连接断裂区域。核宽扁,是为了贴合车牌长条形的形状。 """ kernel_obj = cv2.getStructuringElement(cv2.MORPH_RECT, kernel) closed = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel_obj) # 再做一次开运算,去掉零星的小噪点 opened = cv2.morphologyEx(closed, cv2.MORPH_OPEN, kernel_obj) return opened def filter_plate_contours(contours, img_area): """ 几何筛选:面积、宽高比、矩形度、周长,四关齐过才算候选车牌。 """ candidates = [] for cnt in contours: area = cv2.contourArea(cnt) # 面积关卡:占整图0.1%~5% if area < img_area * 0.001 or area > img_area * 0.05: continue rect = cv2.minAreaRect(cnt) box_w, box_h = rect[1] if box_w < box_h: box_w, box_h = box_h, box_w aspect_ratio = box_w / max(box_h, 1e-5) # 宽高比关卡:2.2 ~ 5.0 if aspect_ratio < 2.2 or aspect_ratio > 5.0: continue # 矩形度关卡:轮廓面积 / 外接矩形面积 > 0.4 rect_area = box_w * box_h if rect_area == 0: continue rectangularity = area / rect_area if rectangularity < 0.4: continue # 周长关卡:与理论矩形周长偏差不超过20% perimeter = 2 * (box_w + box_h) actual_perimeter = cv2.arcLength(cnt, True) if abs(actual_perimeter - perimeter) / perimeter > 0.2: continue candidates.append((cnt, rect)) return candidates def locate_plate(img): """ 主流程:输入原图,返回车牌位置的最小外接矩形信息。 """ processed = preprocess(img) hsv = cv2.cvtColor(processed, cv2.COLOR_BGR2HSV) mask = extract_blue_mask(hsv) closed = morphological_connect(mask) contours, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) img_h, img_w = processed.shape[:2] img_area = img_h * img_w candidates = filter_plate_contours(contours, img_area) if not candidates: return None, processed, masked # 依面积排序,取最大的候选区域,通常车牌是画面里蓝色区域最大的一块 candidates.sort(key=lambda x: cv2.contourArea(x[0]), reverse=True) return candidates[0], processed, closed3.2 参数计算的依据与实际调试记录
这里讲讲上面那些数字是怎么来的,方便你根据自己的场景调整。
为什么目标宽度取800?我测试过一组卡口相机图片,原图1920×1080,车牌宽度大约220像素。直接跑原图,整个流程耗时约80ms;缩放到800宽后,车牌宽度约90像素,耗时约25ms,定位精度几乎不受影响。但如果缩到400宽,车牌只剩45像素,面积筛选中很容易跌破0.1%的下限,反而容易漏检。所以800是个分水岭,你可以把这个值理解为“精度和性能的甜点区”。
形态学核为什么是17x5?这个核尺寸和车牌宽度有对应关系。以缩放后车牌宽约150像素为例,白色像素在车牌区域横向的间断主要来自字符之间的缝隙,缝隙宽度通常是2~5像素,膨胀半径小于这个数就焊不上;但如果核太宽(比如31),容易把车牌周围几个像素的蓝色环境也“吸”进来。17这个值我是在不同图片上反复试出来的,兼顾连接效果和不粘连背景。
3.3 倾斜车牌的矫正处理
实际路面场景中,车牌很少是端端正正朝向镜头的,总会有一定的倾斜和透视。定位完成后,如果不矫正,字符分割时水平投影会把上下两排字符叠在一起,后面识别就乱了。好在我们已经拿minAreaRect拿到了旋转角度,可以直接用仿射变换掰正。
def straighten_plate(img, rect): """ 根据 minAreaRect 的角度,把车牌区域旋转到水平。 rect: cv2.minAreaRect 返回的 ((cx, cy), (w, h), angle) """ (cx, cy), (w, h), angle = rect # 确保角度对应的是“宽边与水平方向的夹角” if w < h: angle = angle + 90 # 生成旋转矩阵,中心旋转 M = cv2.getRotationMatrix2D((cx, cy), angle, 1.0) rotated = cv2.warpAffine(img, M, (img.shape[1], img.shape[0])) # 计算旋转后车牌的包围盒 cos = abs(M[0, 0]) sin = abs(M[0, 1]) new_w = int(h * sin + w * cos) new_h = int(h * cos + w * sin) x = int(cx - new_w / 2) y = int(cy - new_h / 2) # 边界裁剪保护,防止索引越界 x, y = max(0, x), max(0, y) crop = rotated[y:y + new_h, x:x + new_w] return crop这里面有个非常经典的坑:minAreaRect返回的角度,OpenCV里定义的是“宽边与水平方向的夹角”,但当宽边小于高边时,它返回的角度是负值且指向不明,这时候需要手动加90度把角度掰回来。不然你矫正完了发现车牌是竖着的,白忙活。
3.4 颜色定位方案的整体效果评测
我拿了一组230张真实路面图做了评测,场景包括白天顺光、阴天、黄昏、地库,还有蓝色车身车辆。结果供你参考:
| 场景 | 图片数 | 定位成功 | 定位失败 | 成功率 |
|---|---|---|---|---|
| 白天顺光 | 150 | 148 | 2 | 98.7% |
| 阴天/黄昏 | 40 | 36 | 4 | 90% |
| 地库/夜间 | 20 | 12 | 8 | 60% |
| 蓝色车身 | 20 | 17 | 3 | 85% |
可以看到,光线充足的场景基本无压力,夜间表现就拉了。这其实是传统视觉方案的固有天花板——不是代码写得不好,是颜色特征本身在低照度下已经不稳定。如果你的需求以夜间为主,建议在这个基础上叠加边缘特征检测做二次校验,或者直接上深度学习方案。
4. 常见问题与排查技巧实录
4.1 字符区域全是洞,轮廓筛出来是碎的
现象:二值图里车牌区域内部有大量黑点空洞,轮廓也因此断成好几段。
原因分析:inRange的S阈值设太高了,把字符表面偏淡色的蓝色像素吃掉了;还有一种情况是光照不均,车牌一半亮一半暗,暗的那半V值掉到阈值以下。
解决方案:把S下限从90降到60附近;闭运算核从17x5改成21x5,扩大水平焊合范围。这样改完,车牌内部的空洞基本都能被填上。但要注意别改过头,否则背景里大面积浅蓝物体也会被圈进来。
4.2 定位框在蓝色车身上,而不是车牌上
现象:轮廓筛出来的最大区域是整块蓝色车身,车牌只是其中的一小部分。
原因分析:车身蓝色和车牌的HSV范围重叠了,且车身面积远超车牌,面积排序后车身排第一。
解决方案:面积关卡加一个上限,比如不超过整图的5%,车身区域远超这个比例就会被排除。如果车身刚好只占局部、面积不超过5%,那就增加矩形度约束——车身往往是不规则形状,矩形度远低于车牌的0.4关卡。我见过更激进的写法,要求宽高比必须大于2.5,蓝车的车身比例通常接近1:1,这关就过不了。
4.3 定位到了,但是位置一直左右偏
现象:能找到车牌,但切出来的图里车牌不在正中间,或者上下被削掉一点。
原因分析:不是定位错了,是最后的裁剪环节里,边界坐标已经越出图像边缘,OpenCV会静默截断,导致裁出来的图不完整。
解决方案:矫正函数里,裁剪前做好边界保护。我上面的代码有x, y = max(0, x), max(0, y),但更严格的做法是再检查x + new_w是否超出img.shape[1],超了就手动缩回边界内。这个问题在做夜间图片时特别容易暴露,因为车牌往往紧挨画面边缘。
4.4 表格:问题排查速查表
| 常见问题 | 可能原因 | 排查步骤 | 解决方案 |
|---|---|---|---|
| 定位不到任何区域 | HSV阈值不匹配 | 把inRange前后的mask保存看效果 | 调H范围,或改宽松阈值模式 |
| 定位到蓝色车身 | 车身色与车牌色重叠 | 打印候选轮廓的宽高比 | 增加面积上限、矩形度约束 |
| 定位框偏大/偏小 | 形态学核过大/过小 | 单步输出closed图观察 | 核宽按“车牌宽度/10”估算 |
| 夜间定位率直线下滑 | 颜色特征低照度失效 | 观察V通道分布 | 叠加Sobel边缘检测或换深度学习 |
| 车牌被裁掉一角 | 旋转裁剪越界 | 打印x/y/new_w/new_h | 强制边界裁剪保护 |
| 矫正后车牌是竖的 | 角度补正逻辑有误 | 打印angle值观察 | 确认w小于h时角度加90度 |
4.5 独家经验:debug时一定要“把每一步都可视化”
这一步是我最想强调的。很多同学写代码习惯直接看到最终结果,中间过程全黑盒。定位算法中间状态非常多,你不看每一步的输出,根本不知道问题出在阈值设偏了,还是形态学核尺寸不对,还是筛选条件太狠。
我的习惯是每一步都顺手保存一张中间结果图,或者用cv2.imshow直接弹窗观察:
mask看颜色阈值选得对不对;closed看形态学操作有没有把区域连成完整的块;- 画出所有候选轮廓看筛选条件是否合理;
- 最后再画定位框看最终结果。
这个习惯能帮你省掉至少一半的debug时间。我甚至会在代码里专门加一个debug_dir参数,跑一次就在目录下生成所有中间图,逐张翻看,哪里不对一目了然。
5. 如何从定位结果走向完整识别
车牌定位搞定之后,后面的路就顺了。简单说一下后续方向,给准备继续做的小伙伴一个路线参考。
定位输出的是车牌区域图,下一步是字符分割。最朴素的做法,是把车牌区域灰度化、二值化,然后做垂直投影,根据每列的黑色像素数量生成波峰波谷,波谷处就是字符间隙。我的经验是,分割前一定要做一次去边框和铆钉处理——车牌边缘有一圈白色的边框线和固定铆钉,不处理的话它们会被误判成半个字符,直接污染分割结果。
字符识别这块,传统方案可以上模板匹配或者训练一个SVM+HOG分类器,但如果数据量够,直接用轻量CNN或开源OCR模型更省事。我目前用的开源检测识别模型配合定位模块做完整pipeline,白天场景整体识别率能做到95%以上,比纯传统方案稳很多。
另外提醒一句,如果你只是做毕设或者学习demo,传统方案做到字符识别完全够用;但如果是商用级需求(停车场道闸、卡口抓拍),建议把定位结果作为候选区域输入给深度学习检测器做二次筛选,这样可以同时兼顾速度与精度。
聊点个人心得。车牌定位这种问题,说难不难,说简单也不简单,主要看你对“特征”二字的理解够不够深。颜色、形状、纹理,每一种特征都是一条线索,但单独用都容易翻车,组合起来才是王道。我一开始只靠HSV分割,定位率堪忧;加上宽高比和矩形度筛选后,立刻从一个“玩具”变成了能用的工具。做项目的过程中我还发现,很多网上代码跑不通,不是因为算法本身错了,而是参数表写死、场景一变就失灵。所以今天特地把参数怎么来的、怎么调、怎么排查全写了一遍,希望能帮你少走点弯路。最后再分享一个小习惯:每次定位失败,把那张图存下来,统一放到一个“脏图集”里,攒够了专门拿来自测。这个做法看着笨,但比任何评测指标都管用。