车牌识别系统做到第二篇,上一篇我们把整体框架搭起来了,图像采集、预处理、边缘强化这些基础环节都讲透了。这一篇直接进入整个系统里最有技术含量、也最考验工程经验的部分——车牌定位。
我先把话说在前头:车牌识别这条流水线里,“定位”这一步决定了后面字符分割和识别的天花板。你定位出来的区域偏了、歪了、或者把车灯当成车牌框进去了,后面就算识别模型再好也白搭。所以这一篇我尽量把定位环节的原理、代码、参数调优、坑位全部摊开讲,依然沿用OpenCV为主的传统方案,同时补充一些基于深度学习检测模型的进阶思路,方便你按自己的需求对号入座。
1. 车牌定位的核心思路与方案选型
1.1 先搞清楚定位到底要解决什么问题
车牌定位的任务听起来很简单:在一张图片里,把车牌所在的矩形区域找出来。但仔细琢磨一下就知道难点在哪:一张真实场景的照片里可能有车灯、保险杠、车标、广告牌、路牌、树叶阴影,这些元素在颜色、形状、纹理上都有可能和车牌沾边。定位算法要做的就是在这些干扰项里,把真正的车牌区域筛选出来。
从技术路线上看,主流的定位方案大概有四类:
- 基于颜色的方法:利用车牌底色(蓝底白字、黄底黑字、绿底白字等)在HSV颜色空间中的聚类特性,通过阈值分割提取候选区域。
- 基于边缘与形态学的方法:利用车牌区域字符密集、边缘纹理丰富的特点,通过边缘检测、形态学闭运算、轮廓筛选锁定位置。
- 基于机器学习的方法:用Haar、LBP等特征训练级联分类器,本质上是把定位当成一个二分类问题来扫窗口。
- 基于深度学习的方法:用目标检测模型(YOLO、SSD、Faster R-CNN)直接回归出车牌位置,这是目前工程落地的主流方案。
我这次的项目选的是“颜色+边缘+形态学+轮廓筛选”这条传统路线。原因很实在:第一,这个系列的目的是把识别链路讲透,传统方法每一步都能可视化、能调参,适合理解原理;第二,传统方案在光线稳定、场景相对固定的出入口场景下,速度和资源占用有优势,跑在树莓派、Jetson Nano这类边缘设备上毫无压力;第三,不依赖标注数据和GPU训练,部署简单。
1.2 为什么把“定位”单独拎出来讲
很多初学者上手车牌识别,喜欢直接调一个OCR模型把图片丢进去,但实际效果往往惨不忍睹。原因就在于OCR模型接收的输入应该是“已经裁剪好的车牌区域”,而不是整张场景图。你让识别模型在一张1080p的大图里自己去“找”车牌,它的注意力可能全被车灯、车标、车身贴纸带跑了,更不用说整图推理对算力的浪费有多严重。
所以整个系统的正确打开方式,一定是分阶段处理:先定位,把车牌区域从场景中切出来;再分割,把车牌中的字符一个个拆开;最后识别,对单字符或者整行序列做内容识别。定位是承上启下的关键节点,定位不准,后面全是无用功。
我自己在项目里对定位模块有三个硬性要求:定位框要紧凑,宁可稍微紧一点也别把边上的干扰塞进框里;定位速度要快,单帧处理时间不能拖后腿;定位结果要给后续模块提供清晰的二值图,方便字符分割。这三个要求直接决定了我在传统方法里的参数选型和处理顺序。
1.3 传统方法与深度学习怎么选
如果你是做实际项目,我的建议是分场景看。停车场道闸、小区出入口、高速收费站这类“相机位置固定、拍摄角度固定、光线可控或可补光”的场景,传统图像处理方法完全够用,而且稳定、可解释、好维护。你甚至可以用一台几十块钱的USB摄像头加一个树莓派,就能实现全天候的车牌抓拍。
但如果你要处理的是复杂场景,比如道路监控里的多车道车辆、夜间强光逆光、雨天泥点遮挡、多车辆同时入镜,传统方法就会频繁翻车。这时候必须上深度学习的检测模型,用数据让模型自己学习“车牌长什么样”,鲁棒性会高很多。
我这篇文章会把传统方法的代码完整拉通,同时在第4章讲清楚如何用YOLO这类检测模型来做定位,以及两种方案的取舍和融合策略。
2. 定位算法的原理拆解与关键预处理
2.1 预处理阶段到底做了什么
车牌定位不是上来就找轮廓的,前面还有一大堆预处理工作要做。我用的处理链路是这样的:图像缩放→灰度化→高斯滤波去噪→Sobel边缘检测→二值化→形态学闭运算→轮廓查找与筛选。每一步看着简单,但每一步的细节都可能决定成败。
图像缩放是第一件重要的事。原始图片如果是1920×1080,直接送进算法,不仅处理慢,而且很多小噪点会在后续步骤中被放大干扰。我通常把图片最长边缩放到800到1000像素左右,既能保留车牌的纹理细节,又能显著提升处理速度。注意别缩太小,不然车牌边缘纹理会被抹掉。
灰度化和高斯滤波属于常规操作。灰度化降低计算量,高斯滤波主要用来压制图像噪声,尤其是光照不均产生的颗粒噪点。这里有一个关键参数:高斯核的大小。核太大会把车牌的边缘也模糊掉,核太小等于没滤波。我的经验是先用5×5的核,如果发现边缘检测结果太多杂讯,再考虑加大到7×7。
2.2 边缘检测的选型与参数
边缘检测是车牌定位的基石。车牌区域里有多个字符,字符和底色之间存在高对比度的边缘,这些边缘密集且方向相对统一,是车牌区域最稳定的特征之一。常用的边缘检测算子有Sobel、Laplacian和Canny,我在这个项目里选的是Sobel算子。
为什么不直接用Canny?Canny虽然能给出细化的边缘图,但它的双阈值需要根据图像内容动态调整,真实场景光照变化大,固定阈值很难一招吃遍天。Sobel算子计算简单、对噪声不敏感,而且可以指定只检测水平方向或垂直方向的边缘,这对车牌场景非常友好。车牌字符的边缘在水平方向上分布密集,我通常只做水平方向的Sobel检测,这样能天然过滤掉一部分垂直方向的干扰边缘。
关键参数是Sobel核大小(ksize)和输出图像的数据类型。ksize一般取3x3或5x5,我习惯取5x5,边缘更连续。输出结果需要取绝对值再转成8位图,这一步经常被漏掉,会导致后面二值化全黑全白,如果你发现自己的结果图是花的,先检查这里。
2.3 二值化与形态学操作的深意
边缘检测得到的是灰度边缘图,接下来要做二值化,把边缘信息压缩成黑白两色。我用的方法是固定阈值二值化,阈值取100到150之间,具体看边缘图的对比度。如果光照剧烈变化,推荐用OTSU自适应阈值,它自动计算最优分割阈值,省心很多。
二值化之后,车牌区域的字符边缘会呈现出一条条细碎的白色纹理。这时候要给这些细碎纹理“连线搭桥”,把字符之间、字符与车牌边框之间的缝隙连成一个整体,这就是形态学闭运算的核心作用。
闭运算包括先膨胀后腐蚀两个操作,对目标区域内的细小空洞有填充效果。这里最有技术含量的是结构元素(核)的大小怎么定。核的宽度至少要大于单个字符间的空隙宽度,否则无法把字符连起来;核的高度又不能太大,否则会把车牌的上下边框和邻近的干扰区域也连进来。以440mm×140mm的常见车牌比例来说,图像中字符间隙约占车牌宽度的10%到15%左右,我试验下来,在图像最长边缩放到800像素的前提下,取一个宽度为17、高度为5的矩形核效果比较稳定。这个参数值得你反复调试,因为不同的图片尺寸、不同拍摄距离,最优核大小都不一样。
2.4 轮廓查找与候选框筛选逻辑
形态学处理完,图像中会出现若干连通区域,其中有一个或几个就是车辆车牌的候选区域。OpenCV的findContours函数可以把这些连通区域的外轮廓找出来,接下来就是筛选。
筛选的核心思路是从“形状像不像”和“面积够不够”两个维度把关。常规车牌的宽高比大约在3:1左右(具体根据不同国家和车型会略有浮动),我设置的筛选范围通常是宽高比在2.0到4.5之间。同时面积也有要求:候选框面积占整张图面积的比例,下限设0.5%,上限设10%左右,避免过小的噪点区域和过大的车身区域混进来。
除此之外,我还会加一个“矩形度”的验证,也就是轮廓面积和其最小外接矩形面积的比值,车牌是标准矩形,这个比值通常很高,如果某个候选区域的矩形度低于0.5,那大概率是形状不规则的干扰物。
3. 基于OpenCV的车牌定位完整代码实现
3.1 环境准备与依赖安装
先说环境。我的开发环境是Python 3.8+OpenCV 4.5,操作系统随便,Windows、Linux、macOS都行。主要依赖就两个,opencv-python和numpy,安装命令很简单:
pip install opencv-python numpy如果你需要可视化调试,可以再装一个matplotlib,用来显示中间过程的图像,方便调参。我自己调试时习惯把边缘图、二值图、形态学结果图全部画出来对比,哪个环节出问题一目了然。
3.2 核心定位函数的完整实现
下面这段代码是我在项目里实际使用的定位函数,注释写得很详细,可以直接拿去跑。
import cv2 import numpy as np def locate_license_plate(img): """ 输入: 单帧图像 (BGR) 输出: 车牌区域裁剪图、车牌外接矩形坐标,未找到返回 None """ # 保持宽高比,将最长边缩放到 800,降低计算量同时保留细节 h, w = img.shape[:2] scale = 800.0 / max(h, w) if scale < 1.0: img = cv2.resize(img, (int(w * scale), int(h * scale))) # 1. 转灰度 + 高斯滤波去噪 gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) gray = cv2.GaussianBlur(gray, (5, 5), 0) # 2. 水平方向 Sobel 边缘检测,因为车牌字符边缘在水平方向密集 sobel_x = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=5) sobel_x = cv2.convertScaleAbs(sobel_x) # 3. 二值化,固定阈值 + 手动调优;光照变化大时推荐 cv2.THRESH_OTSU _, binary = cv2.threshold(sobel_x, 120, 255, cv2.THRESH_BINARY) # 4. 形态学闭运算:先膨胀再腐蚀,把字符缝隙连接起来 kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (17, 5)) closed = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) # 5. 查找轮廓 contours, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) h_img, w_img = closed.shape[:2] candidates = [] for cnt in contours: x, y, w_cnt, h_cnt = cv2.boundingRect(cnt) area_ratio = (w_cnt * h_cnt) / (w_img * h_img) aspect_ratio = w_cnt / max(h_cnt, 1) # 核心筛选条件:宽高比 + 面积占比 + 矩形度 if 2.0 < aspect_ratio < 4.5 and 0.005 < area_ratio < 0.1: rect_area = w_cnt * h_cnt cnt_area = cv2.contourArea(cnt) solidity = cnt_area / rect_area if rect_area > 0 else 0 if solidity > 0.5: candidates.append((w_cnt * h_cnt, (x, y, w_cnt, h_cnt))) if not candidates: return None, None # 6. 按面积从大到小排序,返回面积最大的候选框 candidates.sort(key=lambda s: s[0], reverse=True) _, best_rect = candidates[0] x, y, w_rect, h_rect = best_rect # 边缘收紧,减少冗余背景干扰 margin_x = int(w_rect * 0.05) margin_y = int(h_rect * 0.1) x = max(0, x - margin_x) y = max(0, y - margin_y) x2 = min(w_img, x + w_rect + margin_x * 2) y2 = min(h_img, y + h_rect + margin_y * 2) plate_img = img[y:y2, x:x2] return plate_img, best_rect3.3 主流程与可视化调试代码
有了核心定位函数,主流程就简单了。我通常会把中间结果保留下来,写一个小工具函数把各个阶段的图拼在一起展示。这里有一个完整的调用示例:
if __name__ == '__main__': img = cv2.imread('test_car.jpg') plate_img, rect = locate_license_plate(img) if plate_img is not None: x, y, w, h = rect result = img.copy() cv2.rectangle(result, (x, y), (x + w, y + h), (0, 255, 0), 2) cv2.imwrite('plate_detect_result.jpg', result) cv2.imwrite('plate_crop.jpg', plate_img) print('定位成功,矩形坐标:', rect) else: print('未定位到车牌')我强烈建议在调试阶段把gray、sobel_x、binary、closed这几张中间图都保存下来,放在一起对比看。很多时候定位失败,一眼看到某一步的图像烂了,直接就知道该调哪个参数。我自己的调试脚本里还会加上滑动条,实时调阈值观察效果,这一步能帮你节省大量调参时间。
3.4 传统定位方案的局限性
上面这套代码在光线稳定、角度正常的场景下,定位成功率还是相当可观的,我实测在停车场出入口那种环境能达到95%以上。但这套方案有它的硬伤。
一是对黄色车牌的适应性差。黄色车牌和蓝色车牌的灰度特征差异很大,蓝色车牌的灰度值和车身接近,在灰度图上区分度不高,所以靠边缘和形态学能处理蓝牌,但对黄色车牌的召回率会有波动。解决方案是增加颜色通道的识别,比如在HSV空间里先粗定位蓝色或黄色区域,再结合边缘结果融合判断。
二是对倾斜和畸变敏感。如果拍摄角度过大,车牌在图像里是梯形甚至平行四边形,边缘检测后形成的连通区域形状会不太规整,单纯用矩形宽高比筛选很容易漏检。这时候需要加一步透视矫正,或者用旋转外接矩形去拟合。
三是受复杂背景干扰严重。车身贴纸、保险杠镀铬条、路牌、灯箱广告,这些都可能形成与车牌相似的边缘密度和形状,有时候一次会筛出三四个候选框。这时候就要靠后续的字符分割和识别结果来反馈验证,把识别置信度最高的框作为真车牌。
4. 进阶方案:把深度学习模型引入定位环节
4.1 为什么商业项目里更常用YOLO
前面说过,传统方法在要求不高的场景下够用,但我必须提醒一句:如果你把目标定位在完整的商业项目上,深度学习检测方案才是真正的主流选择。原因有三个:复杂场景鲁棒性、算法维护成本、对新场景的适配能力。
传统方法的阈值参数高度依赖场景,换一个摄像头位置可能就要重新调一遍。深度学习模型本质上是用数据驱动的方式自动学习车牌特征,换场景后只需要补充一些新场景的标注数据做微调,不需要从零开始设计特征规则。而且在多目标场景下,一个检测模型可以一次把所有车牌都框出来,传统方法要处理得加很多逻辑。
目前工程上用得最多的检测模型集中在YOLO系列,从YOLOv5到YOLOv8都有成熟的开源实现,部署工具链也很完善。车牌检测这种目标特征非常明确、类别单一的任务,效果可以做得非常好。
4.2 基于YOLO的车牌检测代码骨架
这里我给一个训练和推理的最小闭环示例,模型结构直接用目标检测框架自带的。以YOLOv8为例:
# 安装依赖 # pip install ultralytics from ultralytics import YOLO # 加载预训练模型(也可以用 yolo11n.pt 等新版本权重) model = YOLO('yolov8n.pt') # 训练:数据集格式为 YOLO 格式的 label 文件 # data.yaml 里配置 train/val 路径、类别数量和名称 model.train(data='plate_data.yaml', epochs=100, imgsz=640, batch=16) # 推理:直接检测图片中的车牌 results = model.predict('test_car.jpg', conf=0.5, save=True) for r in results: boxes = r.boxes.xyxy.cpu().numpy() scores = r.boxes.conf.cpu().numpy() for box, score in zip(boxes, scores): x1, y1, x2, y2 = map(int, box) print(f'车牌框: ({x1}, {y1}, {x2}, {y2}), 置信度: {score:.2f}')代码看着很短,但实际工程里难点在数据侧。车牌标注的规范很重要,比如是否包含车牌边框、是否要包含螺丝孔区域,各家项目约定不同。我自己的习惯是标注框紧贴字符区域,不包含外边框和螺丝孔,这样有利于后续做字符分割时减少干扰。当然如果你只做检测不管识别,标注规范可以更随意一些。
4.3 数据准备、训练调优与部署要点
数据集这块,开源的车牌检测数据集不少,但真实场景千差万别,我建议至少要自己采集500到1000张现场图像做补充标注。采集时尽量覆盖不同时间段(白天强光、黄昏、夜间)、不同天气、不同角度。数据增强也很关键,YOLO训练时自带的mosaic、翻转、色彩空间变换已经很好用,我还会额外加入轻微的模糊和噪声模拟,提升模型的抗干扰能力。
训练参数上,imgsz一般取640。如果车牌目标在整张图中偏小,可以适当提高到960或1280,但推理速度会下降。epochs看数据量,数据量小(两三千张)100轮足够,数据量大就提前用早停机制,避免过拟合。batch_size根据显存来调整,我的经验是训练集几千张图的规模下,16到32都不错。
部署上,如果你跑在Jetson Nano这类设备上,推荐用TensorRT做加速,可以把YOLOv8n的推理时间压到20毫秒以下。如果跑在普通PC上,直接用PyTorch或者ONNX Runtime也能满足实时性要求。边缘设备上做模型量化(FP16或INT8)时要注意精度回退,尤其是夜间场景,量化后漏检率可能会上升。
4.4 传统方法和深度学习方案的融合经验
对实际项目来说,没必要把两种方案对立起来。我见过不少落地方案是把两者串成一个pipeline:先用YOLO检测出候选车牌区域,这块速度快、召回率高;然后用传统图像处理做精调和验证,裁出来的区域再做一次倾斜矫正和清晰度判断,质量不合格的触发重拍或补光。这样既能保证召回率,又能控制误检率。
另外有一个非常实用的融合技巧:用深度模型检测结果做置信度评分,当模型置信度低于某个阈值时,再调用传统颜色+形态学方案做第二路确认,两路结果一致就放行,不一致就判定为不确定,交给后续字符识别模块二次裁决。这个策略在极暗光线下效果尤其明显,能把整体识别成功率拉高好几个百分点。
5. 定位环节的常见问题与排查技巧实录
5.1 高频问题速查表
我把这段时间被问得最多的几个问题整理成一个表格,都是我实际踩过的坑,你遇到类似情况可以直接对照排查。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 定位框完全找不到车牌 | Sobel之后图像太黑,边缘没出来 | 检查sobel_x是否做convertScaleAbs,确认二值化阈值是否过高 |
| 定位框老是框到车灯或车身贴纸 | 形态学核太大,把相邻区域连成一片 | 缩小核的宽度和高度,或者增加矩形度筛选条件 |
| 远景图片定位不到车牌 | 缩放后车牌区域边长小于阈值,轮廓过小被过滤 | 缩放到800像素的基础上,减小面积下限,或对ROI做局部放大检测 |
| 夜间图片定位率大跌 | 光线太暗,边缘纹理不清晰 | 预处理加自适应直方图均衡化(CLAHE),或降低二值化阈值 |
| 运行速度太慢 | 原图太大,形态学核太大 | 先缩放再处理,缩放到640即可,核大小同步缩小 |
| 蓝牌和绿牌一个能定位一个不行 | 灰度空间里两种车牌对比度差异大 | 增加HSV颜色空间的并行检测,融合两次结果 |
5.2 最值得讲的一个隐藏坑:核大小与图像尺寸的联动关系
这个坑我一开始没意识到,后来在换了一组新摄像头数据时定位率突然暴跌,排查了很久才发现是形态学核大小没有跟着图像缩放比例调整。
很多人写代码时图省事,把核大小写死成(17, 5)。但如果你的图像预处理从1280缩放到了800,而另一组测试数据从1920缩放到了1000,同一个核在两种尺度下的实际效果是完全不同的。合理做法是让核的尺寸和图像尺寸保持一个近似的比例关系,比如在最长边800像素时用17的宽度,在600像素时就应该缩到13左右,在1000像素时就放大到21。
我做了一个简单的计算公式来辅助调参:核宽度约等于图像最小边长的1/50到1/30,核高度取宽度的1/3到1/4。这个范围不一定最优,但比写死一个值要稳健得多。类似的动态参数思路也适用于二值化阈值和面积筛选区间,本质上是让算法在不同分辨率下保持一致性。
5.3 调试工具与肉眼验证心得
最后一个建议:一定要把定位结果可视化出来,而且要看中间过程,不要只看最终框。我调试时一定会保存四张图:原始图、Sobel边缘图、二值图、闭运算结果图,再和最终定位框放在一起对比。哪个环节出了问题,一眼就能找到。
另外,批量跑测试的时候,我习惯把所有定位失败和定位偏差大的图片单独归到一个文件夹里,定期翻出来看。很多问题在单张图上不明显,但几十张放一起就能看出规律,比如“下午三点的逆光图全部失败”“所有白色车辆的图都会多框一个行李架区域”,这些规律就是后续优化的切入点。
车牌定位做完,下一步就是字符分割和识别了。字符分割的思路和定位环节有不少相通的地方,还是先从边缘、投影、连通域这几个角度去做拆解,下一篇我会把字符分割的坑和识别模型的接入一起写。定位这块如果你正卡在参数调不出来,或者出现什么离谱的误检,建议把我上面的调试流程完整跑一遍,很多问题其实都出在预处理链条中不起眼的细节上。