
简介这套基于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 要大于 80V 不用卡太死。下面这组参数是我在卡口视频上反复调出来的起点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~124S 大于 80V 大于 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, iterations1) mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations2) 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(keylambda b: b[2] * b[3], reverseTrue) 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_thresh0.3): if len(boxes) 0: return [] boxes sorted(boxes, keylambda b: b[2] * b[3], reverseTrue) 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, axis1) # 找出行投影不为 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(keylambda 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(fchar_{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 4x4block 2x2 cell9 个梯度方向特征维度约 3780 features hog(img, orientations9, pixels_per_cell(4, 4), cells_per_block(2, 2), block_normL2-Hys) return features # 训练阶段X 是特征矩阵y 是字符标签 # X np.array([extract_hog_features(cv2.imread(f, 0)) for f in train_files]) # clf svm.SVC(kernelrbf, C10, gammascale) # 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字符识别对模糊和形变的鲁棒性会明显提升。如果只是个人项目或 demoSVM 加阈值校正已经够用如果是道闸、卡口的正式业务YOLO 定位加 OCR 识别是当前更稳妥的技术选型。希望这些踩过坑的经验能帮到你也值得你花一个下午把参数在你自己的测试集上重新跑一遍——毕竟每台相机的色彩特性都不一样拿到现场数据才是最终标准。本文还有配套的精品资源点击获取