简介:这套基于OpenCV实现车牌识别全流程的源代码包,内容涵盖车牌定位、倾斜校正、字符分割与识别等核心环节,适合计算机视觉初学者、相关课程设计以及希望快速搭建车牌识别原型的开发者。它直接解决“如何用OpenCV完成从图像输入到车牌字符串输出”的典型问题。包内共30个文件,以7个C++源文件、3个头文件、2个OpenCV模型文件为主,另有12张真实场景测试图片和3个Shell辅助脚本,RAR压缩包大小5.54MB,文件组织按模块划分,便于定位查阅。已有566人学习该资源,具备一定参考热度。源码实现了边缘检测、颜色空间分析、仿射与透视变换等关键操作,并带有SVM和OCR训练脚本,测试样本中包含不同拍摄角度和光照条件下的车牌图像。读者可以借此走通图像预处理、候选区域筛选、几何校正、字符特征提取与模型预测的完整链路,理解各步骤的工程实现细节,也为在交通监控、停车场管理等场景中进一步部署识别系统打下基础。
1. 别再被“车牌识别代码”唬住:OpenCV 车牌定位这件事,水有多深一眼看清
拿到一个lpr.rar压缩包,里面十有八九是这么一套东西:OpenCV 读图、HSV 颜色筛选把蓝色车牌区域抠出来、形态学闭运算补洞、轮廓找外接矩形、然后按宽高比过滤出“像车牌”的框。这套流程本质上不是“识别车牌号码”,而是“把车牌从画面里定位出来”——定位准了,后面的分割和字符识别才有意义。对刚入门的从业者来说,最容易踩的坑就是把“定位”和“识别”混为一谈,看到一张测试图跑通了就以为项目完成。实际上固定卡口、停车场入口这种场景下,这套传统视觉方案完全够用;但到了夜间、逆光、车牌倾斜超过 15 度的时候,HSV 阈值就开始飘,这也是大多数人第一次翻车的地方。这篇笔记就把定位、分割、识别三条链路拆开讲,配上能直接跑的代码和参数,帮你把疗坑提前踩完。
2. 车牌定位:HSV 颜色筛选 + 形态学闭运算,把蓝色车牌从背景里剥出来
车牌定位是整个 lpr 项目的基石。定位框偏了,后面分割和识别全白搭。OpenCV 做定位最常见的手法不是深度学习,而是“颜色 + 几何”两步走:先用 HSV 颜色空间把蓝色像素筛出来,再用形态学操作把分散的蓝色点焊成一个连通区域,最后按面积、宽高比过滤出候选车牌框。这套方法对蓝底白字的国内车牌特别有效,因为车牌蓝在自然界里相对稀缺——天空、蓝色车身、蓝色招牌是主要干扰源。
2.1 用 HSV 把蓝色车牌从背景里筛出来:参数这样设才不飘
HSV 相比 BGR 的优点是色相、饱和度、明度分离,颜色筛选不会受亮度变化影响那么剧烈。OpenCV 里cvtColor转到 HSV 后,蓝色车牌的 H 值大致落在 100 到 124 之间,S 要大于 80,V 不用卡太死。下面这组参数是我在卡口视频上反复调出来的起点:
import cv2 import numpy as np # 读取图像并缩放到统一宽度,减少后续计算量 img = cv2.imread("car.jpg") img = cv2.resize(img, (600, int(img.shape[0] * 600 / img.shape[1]))) # 中值滤波降噪:去掉路面颗粒和细小纹理,减小 HSV 抖动 blur = cv2.medianBlur(img, 5) hsv = cv2.cvtColor(blur, cv2.COLOR_BGR2HSV) # 蓝色车牌阈值:H 在 100~124,S 大于 80,V 大于 50 lower_blue = np.array([100, 80, 50]) upper_blue = np.array([124, 255, 255]) mask = cv2.inRange(hsv, lower_blue, upper_blue) # 开运算去掉零星噪点,闭运算把车牌内部的白色字符空洞补上 kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations=1) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=2) cv2.imwrite("mask.jpg", mask)这组参数里最敏感的是lower_blue的 H 下限和 S 下限。H 下限设到 100 会把浅蓝色车身也纳入进来,设到 105 又可能漏掉暗光下的车牌;S 下限的调整逻辑相反——阴天或傍晚时车牌饱和度降低,S 从 80 调到 60 能找回不少召回率,但误检也会成倍上涨。我的习惯是先拿 10 张不同时段的图跑一遍inRange,把mask可视化出来再微调,而不是盯着数字凭空猜。
2.2 轮廓过滤的 3 个阈值:面积、宽高比、矩形度一次说清
mask里剩下的白色块不全是车牌,蓝色车身、路牌、广告牌都会残留。这一步用findContours找出所有轮廓,然后按三个几何指标过滤。国内的标准车牌宽高比大约是 440:140,也就是 3.14 左右,但实际拍摄存在透视变形,范围放宽到 2.0 到 4.5 比较稳妥。
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) candidates = [] for cnt in contours: x, y, w, h = cv2.boundingRect(cnt) area = w * h aspect_ratio = w / h # 面积过滤:去除极小块噪点,也排除占满全图的大色块 if area < 1000 or area > img.shape[0] * img.shape[1] * 0.3: continue # 宽高比过滤:车牌是横向矩形,排除竖条和方形干扰 if aspect_ratio < 2.0 or aspect_ratio > 4.5: continue # 矩形度过滤:轮廓面积占外接矩形面积的比例,车牌是实心矩形,比例要高 rect_area = w * h contour_area = cv2.contourArea(cnt) rectangularity = contour_area / rect_area if rectangularity < 0.6: continue candidates.append((x, y, w, h)) # 多个候选框重叠时,保留面积最大的一个;实际项目中可按置信度排序取 Top N candidates.sort(key=lambda b: b[2] * b[3], reverse=True) best_box = candidates[0] x, y, w, h = best_box plate = img[y:y + h, x:x + w] cv2.imwrite("plate.jpg", plate)面积下限定在 1000 像素是相对保守的做法,适用于 600 像素宽度的输入图。如果你把图缩放到 1200 像素宽,这个下限要跟着翻倍,否则车牌区域会被误杀。矩形度阈值 0.6 的含义是:轮廓面积至少占外接矩形的六成。车牌边缘即使有缺口,矩形度一般也在 0.75 以上;而蓝色车身的轮廓往往是不规则形状,矩形度很难超过 0.5,这个特征比宽高比更能抗干扰。
2.3 候选框不止一个怎么办:多候选与 NMS 的取舍
真实场景里,一辆车可能同时被车身贴纸、前挡风玻璃反光干扰,一次筛出两三个候选框很常见。简单的做法是排序后取最大面积那个,但逆光时车牌本身的蓝色被压暗,面积可能比车身反光块还小,这时候就需要非极大值抑制(NMS),把重叠的候选框合并成一个。
def nms_boxes(boxes, overlap_thresh=0.3): if len(boxes) == 0: return [] boxes = sorted(boxes, key=lambda b: b[2] * b[3], reverse=True) kept = [] while boxes: current = boxes.pop(0) kept.append(current) boxes = [b for b in boxes if not _is_overlap(current, b, overlap_thresh)] return kept def _is_overlap(a, b, thresh): x1 = max(a[0], b[0]) y1 = max(a[1], b[1]) x2 = min(a[0] + a[2], b[0] + b[2]) y2 = min(a[1] + a[3], b[1] + b[3]) inter = max(0, x2 - x1) * max(0, y2 - y1) area_a = a[2] * a[3] area_b = b[2] * b[3] union = area_a + area_b - inter return inter / union > thresh注意 NMS 的overlap_thresh别设太高,0.3 意味着两个框只要有 30% 重叠就合并。卡口场景下车牌和车身贴纸的框往往大面积重叠,这个值很合适;但如果画面里有两辆车前后紧挨着,它们的车牌框不应该合并,这时候靠 NMS 反而出问题,得配上车辆检测的外层约束。这也是传统视觉方案的边界:定位能力上限就在这儿,想突破就得上 YOLO,后面第 6 章细说。
3. 字符分割:把车牌上的 7 个字符逐个切干净,别让汉字和字母黏在一起
定位到车牌区域只是第一步,接下来要把“京A12345”这样的字符串拆成单个字符。分割质量直接决定识别能不能做对。国内车牌通常是:1 个汉字 + 1 个大写字母 + 5 个字母/数字,共 7 个字符。字符之间有一定间隙,但汉字内部笔画复杂,“京”字的左右结构容易出现断裂,而字母和数字字形简单,反而好切。
3.1 灰度化 + 二值化:Otsu 还是固定阈值,取决于光照是否均匀
拿到定位出的车牌图后,先转灰度,再做二值化把字符变成白底黑字或黑底白字。常见做法是固定阈值 100 到 120,但对逆光或阴影覆盖的车牌,固定阈值会把字符细节吃掉。Otsu 自适应阈值按像素分布自动算阈值,均匀光照下效果稳定,但车牌边缘被 HSV 裁剪不干净时,Otsu 会把背景噪声也二值化成字符。
import cv2 import numpy as np plate_gray = cv2.cvtColor(plate, cv2.COLOR_BGR2GRAY) # 先做一次高斯模糊,抑制车牌表面污渍和反光纹理 plate_blur = cv2.GaussianBlur(plate_gray, (3, 3), 0) # 灰度直方图均衡:拉宽像素分布,让 Otsu 阈值更稳定 plate_eq = cv2.equalizeHist(plate_blur) # 二值化:THRESH_BINARY_INV 让字符变白、背景变黑,适合后续找轮廓 _, binary = cv2.threshold(plate_eq, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) cv2.imwrite("plate_binary.jpg", binary)注意THRESH_BINARY_INV的INV很关键。车牌底色是蓝色,字符是白色,用THRESH_BINARY会让字符变成黑色、背景变白色,对后续findContours来说,黑色字符的外部轮廓会被背景包住,提取出来的是整块白底的外框而不是字符。反转之后字符是白色目标,每个字符独立成连通域,这才是分割要的状态。如果车牌是黄底黑字(新能源车型或部分单层车牌),反转逻辑要反过来,用普通的THRESH_BINARY。
3.2 水平投影分割:先按行把上下边框切掉,字符高度才能统一
牌照上下边缘常有蓝色边框残留,直接做垂直投影会被边框干扰。先用水平投影找字符区域的上下边界,把边框切掉再去做垂直分割,这是标准做法。
# 统计每行白色像素数,投影数组长度为图片高度 h, w = binary.shape row_projection = np.sum(binary, axis=1) # 找出行投影不为 0 的区间,合并相邻行得到字符带 rows_with_text = np.where(row_projection > 0)[0] top = rows_with_text[0] bottom = rows_with_text[-1] # 上下各收缩 2 像素,避免把边框边缘的毛刺包进来 top += 2 bottom -= 2 binary = binary[top:bottom, :] cv2.imwrite("plate_cropped.jpg", binary)这里的row_projection > 0用了一个隐含假设:字符区域内至少有一列是白色像素。如果二值化后字符内部出现大量空洞(字符断裂成几段),行投影可能会有中间空洞,导致top取到第一段、bottom取到最后一段,中间空隙不影响——因为我们只取整体上下界。真正要小心的是上下边框没有被 HSV 滤镜洗干净时,边框像素也会参与投影,把top抬高了几像素。所以 HSV 定位阶段的形态学处理做得越干净,这里越省心。
3.3 垂直投影分割与轮廓排序:两种切法,一种防粘连、一种防断裂
上下边界切好之后,把字符从左到右逐个分开。两种常见路线:
路线一是垂直投影找 valley,按列统计白色像素,像素为 0 的列就是字符间隙,把连续为 0 的列当作分界点。适合字符间距均匀、没有粘连的情况,但汉字内部笔画断开时,会在中间产生伪间隙,把一个“京”字切成两部分。
路线二是直接对二值图找轮廓,按轮廓外接矩形的 x 坐标排序,再按宽度过滤掉噪点。对汉字断裂场景更友好,因为断开的部分会各自成轮廓,合并相邻轮廓就能拼回完整汉字。我一般用轮廓路线,合并时有个关键参数:两个轮廓的 x 间距小于前一个轮廓宽度的 0.3 倍时,视为同一字符。
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) boxes = [] for cnt in contours: x, y, cw, ch = cv2.boundingRect(cnt) if ch < binary.shape[0] * 0.4: continue # 过滤掉小噪点:高度不足字符带 40% 的不要 boxes.append((x, y, cw, ch)) boxes.sort(key=lambda b: b[0]) merged = [] for box in boxes: if not merged: merged.append(list(box)) continue last = merged[-1] gap = box[0] - (last[0] + last[2]) # 间隙小于前一个字符宽度的 0.3 倍,说明是同一个字断裂,合并 if gap < last[2] * 0.3: last[0] = min(last[0], box[0]) last[2] = max(last[2], box[0] + box[2] - last[0]) last[3] = max(last[3], box[3]) else: merged.append(list(box)) for i, box in enumerate(merged): char_img = binary[box[1]:box[1] + box[3], box[0]:box[0] + box[2]] cv2.imwrite(f"char_{i}.jpg", char_img)难点在ch < binary.shape[0] * 0.4这个阈值。车牌字符高度通常占字符带高度的 70% 以上,0.4 的过滤线是为了去掉铆钉、边框残边这类低矮噪点。但有的车牌字符下缘和边框粘连,轮廓高度会被拉高,这时候过滤条件会失去作用——边界情况要靠第 5 章的避坑经验来兜底。
4. 字符识别:模板匹配能跑通 demo,要稳定识别率得上 SVM 或 OCR 引擎
字符分割完成之后,真正的“识别”才刚刚开始。lpr 项目里最常见的误区是拿模板匹配当最终方案。模板匹配思路直观:把每个字符和一张标准字符模板比较相似度,谁最像就认成谁。OpenCV 的matchTemplate就是干这个的。但它有硬伤:对汉字笔画变形、字体差异和模糊图像几乎无能为力。如果你只需要在停车场道闸的固定角度下识别蓝底白字的燃油车牌,模板匹配勉强能跑;想上路的项目,建议直接切到 SVM 或轻量 OCR。
4.1 模板匹配:最小可运行版本,以及它会在哪一步翻车
模板匹配的落地步骤是:准备 0-9、A-Z、省份简称汉字的模板图集,每张模板归一化到 20x40,再把待识别字符归一化到同样尺寸,逐模板计算相似度取最大。下面是最小可运行版本:
import cv2 import numpy as np import os # 模板目录结构:template/省份/京.jpg、template/字母/A.jpg、template/数字/0.jpg TEMPLATE_DIR = "templates" templates = [] for root, _, files in os.walk(TEMPLATE_DIR): for name in files: if not name.endswith(".jpg"): continue label = os.path.splitext(name)[0] # 模板文件名就是字符标签 tpl = cv2.imread(os.path.join(root, name), cv2.IMREAD_GRAYSCALE) tpl = cv2.resize(tpl, (20, 40)) tpl = tpl.astype(np.float32) / 255.0 templates.append((label, tpl)) def recognize_char(char_img): char_img = cv2.resize(char_img, (20, 40)) char_img = char_img.astype(np.float32) / 255.0 best_score = -1 best_label = "?" for label, tpl in templates: # TM_CCOEFF_NORMED 对光照和对比度不敏感,适合字符匹配 result = cv2.matchTemplate(char_img, tpl, cv2.TM_CCOEFF_NORMED) score = result[0][0] if score > best_score: best_score = score best_label = label return best_label, best_scoreTM_CCOEFF_NORMED是归一化相关系数,取值在 -1 到 1 之间,对整体亮度的偏移不敏感,是字符匹配里最常用的 mode。模板尺寸统一到 20x40 不是随便定的——汉字笔画密集,尺寸太小会把结构性细节磨掉,太大又会引入模板字体与实拍字体的像素级差异。20x40 基本能保住骨架。真正让模板匹配翻车的是字体:标准车牌字体是专用的“公安数字”字体,和普通 Windows 字体的笔画粗细、拐角弧度差异很大,如果模板是用普通字体生成的,匹配分数会出现“谁都像又谁都不像”的窘境。这个坑几乎无法靠调参绕过,只能换识别算法。
4.2 用 SVM + HOG 把识别率拉高:从 70% 到 95% 的必经之路
HOG(方向梯度直方图)提取字符的局部梯度信息,SVM 做分类,是传统视觉方案里识别率最稳的一条路。HOG 的 cell size、block size 直接影响特征维度,对 20x40 的小图,cell 取 4x4、block 取 8x8、9 个方向的组合是经过大量项目验证的参数。
from skimage.feature import hog from sklearn import svm import numpy as np import cv2 def extract_hog_features(img): img = cv2.resize(img, (40, 20)) # 每个 cell 4x4,block 2x2 cell,9 个梯度方向,特征维度约 3780 features = hog(img, orientations=9, pixels_per_cell=(4, 4), cells_per_block=(2, 2), block_norm="L2-Hys") return features # 训练阶段:X 是特征矩阵,y 是字符标签 # X = np.array([extract_hog_features(cv2.imread(f, 0)) for f in train_files]) # clf = svm.SVC(kernel="rbf", C=10, gamma="scale") # clf.fit(X, y) # 预测阶段 def recognize_char_svm(char_img, clf): feat = extract_hog_features(char_img).reshape(1, -1) return clf.predict(feat)[0]SVM 的C值和gamma值是两个需要认真调的超参。C控制误分类惩罚,C 大容易过拟合训练集上的字体,C 小则欠拟合;gamma控制 RBF 核的影响半径,gamma 太大会把每个样本都当成孤立点,太小又分不开相似字符。我在实际项目里用网格搜索从C=[1, 10, 100]、gamma=[0.001, 0.01, 0.1]里选最优组合,训练集至少准备每个字符 50 张以上、来自不同卡口角度的样本。这一步没有捷径,数据量直接决定识别率天花板。
5. 避坑与排查:HSV 阈值飘移、汉字轮廓断裂、字符排序错乱,常见翻车点逐个拆
这套 OpenCV lpr 方案每一个环节都有暗坑。下面 5 条是我在项目上真实遇到、并且花过时间排查的问题,按“现象 → 原因 → 解决”写清楚,你照着比对能省下大量排查时间。
5.1 白天能用晚上废:HSV 阈值在暗光下集体失灵
现象:白天测试识别率 95%,到了傍晚和夜间,定位框要么找不到、要么漂到车身上,识别率掉到 40% 以下。原因:HSV 的 V(明度)通道在暗光下整体压缩,蓝色车牌的 S(饱和度)也同步下降,白天调好的lower_blue在夜间把车牌像素排除在阈值之外。解决:不要只调一组阈值,按亮度区间准备两组参数。代码里先用 V 通道的均值判断当前是白天还是夜间,再切换阈值:
v_mean = hsv[:, :, 2].mean() if v_mean > 90: lower_blue = np.array([100, 80, 50]) upper_blue = np.array([124, 255, 255]) else: # 夜间降低饱和度和明度下限,H 范围不变 lower_blue = np.array([100, 40, 30]) upper_blue = np.array([124, 255, 255])更稳的替代方案是不用固定阈值,改用灰度直方图统计蓝色像素分布,取直方图峰值附近作为自适应阈值范围。我在实际项目里先用直方图方式跑了一段时间,收集到足够多的夜间样本后,再回归到固定阈值加场景切换的方式,逻辑更可解释、调试更方便。
5.2 汉字断裂严重,一个“京”字被切成三块
现象:分割结果里“京”字被拆成左右两部分,中间的点又单独成块。原因:车牌在 HSV 定位阶段边缘有残留阴影,二值化后汉字笔画断裂,垂直投影在断裂处生成伪 valley。解决:轮廓合并法的 gap 阈值不能是固定像素值,要按前一个字符宽度的比例来;同时二值化前先做一次morphologyEx闭运算,用 3x3 内核把断裂处焊起来。如果闭运算把字符间的空隙也填上了,就说明内核大了,降回 2x2 重试。
5.3 边框和铆钉被当成字符,分割出 8 到 9 个“字符”
现象:字符分割输出比预期多,多出来的块集中在车牌左右两端。原因:HSV 定位框贴得太紧,车牌左右边框或固定铆钉混入字符带。解决:分割前先做一次宽度方向的腐蚀,内核取(1, 3),只削横向不削纵向,把细长边框擦掉;再按 5.2 的轮廓过滤逻辑,把宽度超过字符带宽度 0.2 倍的轮廓直接丢弃。
5.4 字母 O 和数字 0、字母 I 和数字 1 混淆
现象:识别结果里 O/0、I/1 频繁互换,尤其在低分辨率下。原因:HOG 特征对这类形状相似的字符区分度天然不足,加上训练样本里两类字符比例不均衡。解决:训练阶段有意平衡样本数量,并在预测后加入逻辑校验——车牌第二位必须是字母,如果 SVM 在这里输出了数字,强制映射回对应字母类别。注意超过 90% 的识别错误发生在相似字符组内部,这类后处理修正能提升整体准确率 2 到 3 个百分点。
5.5 opencv-python 版本差异:contourArea 未定义标识符
现象:换了一台机器,代码报错contourArea() 未定义标识符。原因:老代码里写的是cv2.contourArea,但安装的是较新版本 OpenCV 4.x 以上的某些编译版,接口有调整;或者findContours返回三个值、代码还在用旧版两个值的解包方式。解决:定位到findContours调用处,统一改为contours, _ = cv2.findContours(...);contourArea改成cv2.contourArea(cnt)的完整调用形式。实际项目中版本差异导致的报错,比算法问题更费时间,建议把运行环境版本号写进项目 README。
6. 从 80% 到 95%:自适应 HSV 参数校正和后续升级路线
传统 OpenCV 方案的识别率天花板大概在 90% 到 95% 之间,再往上提,瓶颈不在识别算法,而在定位环节的稳定性。我自己最后的优化手段不是堆样本,而是给 HSV 阈值加了一个“自适应漂移校正”:每隔 5 分钟从当前画面截一帧,做一次直方图统计,单独看蓝色通道的峰值位置。如果峰值在 H 值 110 附近,说明当前光照下蓝色车牌的色相没怎么漂移,沿用现有阈值;如果峰值偏到 120 以上,把upper_blue的 H 值跟着往上调 5 到 10 个点。这个校正逻辑每帧实时计算会有性能压力,所以只对固定相机低频更新。经过一周的数据验证,夜间和傍晚的定位漏检率从 15% 降到了 4% 左右。
另一个性价比很高的改动是在定位阶段加入一种“疑似车牌”的二次确认机制:HSV 定位出的候选框不直接进入分割,而是先切出小图,检查内部白色像素占比是否在 15% 到 45% 之间——车牌字符占车牌面积大致在这个范围,车身蓝色贴纸的白色占比通常要么接近 0、要么超过 60%。这个简单的统计过滤能挡住一大半误检框,实现起来只要几行代码:
def check_plate_confident(plate_img): gray = cv2.cvtColor(plate_img, cv2.COLOR_BGR2GRAY) white_ratio = (gray > 180).mean() return 0.15 < white_ratio < 0.45后续要更进一步,方向是两选一:一是把定位阶段替换成 YOLO 车牌检测,用几百张标注好的卡口图训练一个小模型,定位召回率能稳定到 99% 以上,HSV 方法彻底退役;二是保留 HSV 定位,但分割后接入 CRNN 或 PaddleOCR 这类轻量 OCR 网络替代 SVM,字符识别对模糊和形变的鲁棒性会明显提升。如果只是个人项目或 demo,SVM 加阈值校正已经够用;如果是道闸、卡口的正式业务,YOLO 定位加 OCR 识别是当前更稳妥的技术选型。希望这些踩过坑的经验能帮到你,也值得你花一个下午把参数在你自己的测试集上重新跑一遍——毕竟每台相机的色彩特性都不一样,拿到现场数据才是最终标准。
本文还有配套的精品资源,点击获取