OpenCV车牌识别实战:从定位到字符分割的Python实现

发布时间:2026/10/26 7:59:51
OpenCV车牌识别实战:从定位到字符分割的Python实现 简介这是一套面向计算机视觉初学者与开发者的车牌号识别与提取完整源码基于OpenCV与Python实现可应用于智能交通、停车场自动收费、车辆管控等场景。项目围绕车牌定位、字符分割与字符识别等核心环节展开通过图像预处理与模式识别完成号码提取适合作为课程设计、毕业设计或算法练手案例。资源包共43个文件以37张JPG车牌样本图片为主用于算法训练与测试另含2个Python源文件承载核心识别逻辑、2个Markdown文档说明项目结构与用法以及1个txt说明和1个pyc编译文件整体约2.47MB目录结构清晰。目前已有434人学习下载。读者可借此快速搭建车牌识别流程理解从样本到字符输出的完整链路并在此基础上针对光照、脏污、破损等复杂环境做优化与二次开发。1. 从一张歪斜的车牌说起OpenCV 车牌识别到底难在哪地下车库出口车头偏了十五度逆光车牌上还有泥点。这种图丢给一个刚跑通的 OpenCV 车牌号识别脚本十有八九返回空字符串或者一串乱码。很多人第一次做车牌识别以为难点在识别其实真正的坑全在定位和预处理上——字符分割错了后面的模板匹配再准也没用。这篇讲的是用 Python 加 OpenCV 从零搭一套车牌号识别与提取的完整链路读图、灰度、边缘、形态学、找轮廓、透视矫正、字符切分、字符识别。它适合两类人一类是刚学完 OpenCV 基础、想找个能跑通的项目练手的另一类是做嵌入式或后台系统需要一套不依赖深度学习框架、能在普通 CPU 上跑起来的轻量方案。整套东西的核心价值是可控——每一步你都能看到中间图出了问题知道该调哪个参数而不是面对一个黑匣子干瞪眼。2. 车牌定位颜色分割和边缘检测该怎么选车牌定位是整个流程里最容易翻车的一环。定位错了后面全是白干。常见做法有两条路一条走颜色一条走边缘加形态学。选哪条取决于你的实际场景。2.1 颜色分割法的适用边界与 HSV 阈值国内蓝底白字车牌在 HSV 空间里特征很明显。蓝牌的 H 通道大致落在 100 到 124 之间S 和 V 也有相对集中的区间。用cv2.inRange卡出蓝色区域再做闭运算把字符和底色连成一块最后找外接矩形这是最直观的思路。import cv2 import numpy as np def locate_by_color(img): # 转 HSV颜色分割必须在 HSV 空间做BGR 空间直接卡阈值极不稳定 hsv cv2.cvtColor(img, cv2.COLOR_BGR2HSV) # 蓝牌 H 约 100-124S 和 V 下限不能太低否则暗处噪点全进来 lower np.array([100, 80, 80]) upper np.array([124, 255, 255]) mask cv2.inRange(hsv, lower, upper) # 闭运算先膨胀再腐蚀把车牌内部字符的孔洞填上连成整块 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (17, 5)) mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) contours, _ cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) candidates [] for c in contours: x, y, w, h cv2.boundingRect(c) # 车牌宽高比大致 2.5 到 5.5面积太小的直接丢 ratio w / float(h) if 2.5 ratio 5.5 and w 80: candidates.append((x, y, w, h)) return candidates这段代码里三个参数最要命。HSV 下限的 S 和 V 设成 80是为了滤掉灰蒙蒙的阴影区域形态学核用 (17, 5) 这种扁长形状是因为车牌本身是横向的用正方形核会把相邻的车灯也粘进来宽高比卡 2.5 到 5.5是因为新能源绿牌比例略有不同如果你要兼容绿牌这个区间得放宽到 2.2 到 6.0。颜色法的死穴是光照。傍晚或者隧道口蓝色会偏紫或偏灰固定阈值直接失效。我一般会加一步自适应先统计全图 HSV 的 H 通道直方图找到峰值附近再动态生成阈值而不是写死。2.2 边缘检测加形态学的通用路线颜色靠不住的时候边缘法是更稳的兜底。思路是 Sobel 横向求导突出竖直边缘车牌字符是竖直排列的然后二值化、闭运算、找轮廓。def locate_by_edge(img): gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯模糊压噪点核大小 5 是经验值太大车牌边缘也糊了 blur cv2.GaussianBlur(gray, (5, 5), 0) # Sobel 只求 x 方向车牌字符的竖直边缘响应最强 sobel cv2.Sobel(blur, cv2.CV_8U, 1, 0, ksize3) # 二值化阈值用 OTSU 自动找比写死 127 稳 _, binary cv2.threshold(sobel, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) # 闭运算把断开的字符边缘连成矩形块 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (17, 5)) closed cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) contours, _ cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) result [] for c in contours: x, y, w, h cv2.boundingRect(c) ratio w / float(h) if 2.5 ratio 5.5 and w 80: result.append((x, y, w, h)) return resultSobel 的 ksize 用 3 就够用 5 或 7 会让边缘变粗闭运算后容易和车身线条粘连。OTSU 自动阈值在车牌对比度正常时很好用但如果整张图偏暗OTSU 会把大量背景也算成前景这时候得先做一次 CLAHE 直方图均衡。两条路线怎么选我的习惯是白天、光照均匀、蓝牌为主走颜色法快且准夜间、逆光、或者要兼容多种车牌颜色走边缘法。实际项目里我会两个都跑把候选框合并去重再按面积和位置打分选最优。2.3 候选框筛选宽高比、面积和位置打分不管用哪种方法最后都会得到一堆候选框。直接取最大的那个经常错因为车身反光区域可能比车牌还大。我一般用加权打分评分项权重说明宽高比接近 3.140.4蓝牌标准比例越接近分越高面积占比0.3车牌面积通常占全图 2% 到 15%垂直位置0.2车牌一般在下半部分y 坐标偏下加分矩形度0.1轮廓面积比外接矩形面积越接近 1 越好打分函数写起来不复杂核心是别让某一个维度一票否决。比如宽高比权重给太高遇到倾斜拍摄的车牌投影后比例失真就会误杀。3. 透视矫正与字符分割把歪车牌摆正再切开定位拿到的是个矩形框但实际车牌可能是斜的。不矫正直接切字符分割线会切到字符身上。这一步做不好后面识别率断崖式下跌。3.1 用 minAreaRect 做旋转矫正cv2.minAreaRect返回的是最小外接旋转矩形能拿到角度。角度在 -45 到 45 度之间时直接旋转超出范围要特殊处理因为 minAreaRect 的角度定义有歧义。def deskew(img, rect): # rect 是 minAreaRect 返回的 ((cx, cy), (w, h), angle) (cx, cy), (w, h), angle rect # OpenCV 4.x 里 angle 在 [0, 90)需要归一化到 [-45, 45) if angle 45: angle angle - 90 # 获取旋转矩阵中心点、角度、缩放 1.0 M cv2.getRotationMatrix2D((cx, cy), angle, 1.0) # 旋转后画布要放大否则边角会被裁掉 cos abs(M[0, 0]) sin abs(M[0, 1]) nw int(img.shape[0] * sin img.shape[1] * cos) nh int(img.shape[0] * cos img.shape[1] * sin) M[0, 2] nw / 2 - cx M[1, 2] nh / 2 - cy rotated cv2.warpAffine(img, M, (nw, nh)) return rotated角度归一化这步是血泪经验。OpenCV 不同版本 minAreaRect 的角度范围不一样3.x 是 [-90, 0)4.x 变成了 (0, 90]不处理的话旋转方向会反。画布放大那几行也不能省否则旋转后车牌四个角被裁掉字符分割直接废。3.2 垂直投影法切分字符矫正后的车牌是水平矩形字符竖直排列。把二值图沿垂直方向做投影统计每一列的白色像素数字符之间的间隙会形成波谷波谷就是切分点。def split_chars(binary_plate): # 二值化车牌字符是白字黑底或反过来看前面处理 _, bw cv2.threshold(binary_plate, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) # 垂直投影对每一列求和 projection np.sum(bw, axis0) / 255 # 找波谷投影值低于阈值的列认为是间隙 threshold max(projection) * 0.15 gaps [] in_gap False start 0 for i, val in enumerate(projection): if val threshold and not in_gap: in_gap True start i elif val threshold and in_gap: in_gap False gaps.append((start, i)) # 按间隙切出字符区域 chars [] prev 0 for g_start, g_end in gaps: if g_start - prev 5: # 太窄的段丢掉 chars.append(bw[:, prev:g_start]) prev g_end if bw.shape[1] - prev 5: chars.append(bw[:, prev:]) return chars投影阈值取最大值的 15% 是个经验数。阈值太高字符内部的笔画间隙也会被当成切分点一个字符被切成两半阈值太低字符之间的间隙识别不出来两个字符粘在一起。实际调的时候我会把投影曲线画出来看一眼比盲调快得多。3.3 字符归一化到统一尺寸切出来的字符大小不一识别前要统一到固定尺寸比如 20x40。用cv2.resize时插值方式选INTER_AREA缩小图像用它最不容易产生锯齿。def normalize_char(char_img, size(20, 40)): # 先做一次高斯模糊去掉二值化残留的毛刺 blurred cv2.GaussianBlur(char_img, (3, 3), 0) # INTER_AREA 适合缩小比默认的线性插值干净 resized cv2.resize(blurred, size, interpolationcv2.INTER_AREA) # 再二值化一次保证输入识别器的图是干净的黑白 _, final cv2.threshold(resized, 127, 255, cv2.THRESH_BINARY) return final尺寸选 20x40 是因为国内车牌字符本身高宽比大约 2:1这个比例下字符特征保留得比较完整。如果你要识别新能源车牌多一位尺寸不用改只是字符数量从 7 个变成 8 个。4. 字符识别模板匹配和特征分类的取舍字符识别这块很多人一上来就想上 CNN。但如果你只是做个课程设计或者轻量级工具模板匹配加一点特征判断完全够用而且不需要 GPU代码量也小。4.1 模板匹配的工程化写法模板匹配的核心是准备一套标准字符模板把每个切出来的字符和所有模板做匹配取相似度最高的那个。def match_char(char_img, templates): best_score -1 best_char ? for char, tmpl in templates.items(): # 模板和输入图尺寸必须一致 if tmpl.shape ! char_img.shape: tmpl cv2.resize(tmpl, (char_img.shape[1], char_img.shape[0])) # TM_CCOEFF_NORMED 返回 -1 到 1越接近 1 越相似 result cv2.matchTemplate(char_img, tmpl, cv2.TM_CCOEFF_NORMED) score result[0][0] if score best_score: best_score score best_char char return best_char, best_score模板匹配最大的问题是字体敏感。你的模板如果是黑体实际车牌是宋体匹配分数会掉一大截。解决办法是每个字符准备多套字体模板匹配时取最高分。另外TM_CCOEFF_NORMED对亮度变化不敏感比TM_SQDIFF更适合这种场景。4.2 用投影特征做二次校验模板匹配容易把0和O、1和I搞混。加一层投影特征校验能明显降低错误率。思路是统计字符水平和垂直方向的像素分布不同字符的分布曲线形状不一样。def projection_feature(char_img): # 水平投影每一行的白像素数 h_proj np.sum(char_img, axis1) / 255 # 垂直投影每一列的白像素数 v_proj np.sum(char_img, axis0) / 255 # 归一化消除尺寸影响 h_proj h_proj / (np.max(h_proj) 1e-6) v_proj v_proj / (np.max(v_proj) 1e-6) return h_proj, v_proj def verify_char(char_img, char, ref_features): h, v projection_feature(char_img) ref_h, ref_v ref_features[char] # 计算欧氏距离距离越小越像 dist np.linalg.norm(h - ref_h) np.linalg.norm(v - ref_v) return dist参考特征怎么来拿你的模板字符算一遍存起来就行。校验时如果模板匹配的最高分字符和投影特征距离最小的字符不一致就取两者加权或者直接标记为低置信度交给人工复核。4.3 置信度阈值与人工复核接口不是所有字符都能自动识别对。给每个字符输出一个置信度低于阈值的打上标记这是工程上必须留的后路。置信度区间处理策略 0.85直接采纳0.6 - 0.85采纳但标记记录日志 0.6标记为待复核输出原图切片这套机制在批量处理时特别有用。你跑一万张图可能只有几百张需要人工看比全自动出错后回头排查省事得多。5. 避坑与排查那些让识别率归零的细节这一章全是踩过的坑每条都按现象、原因、解决来写。新手照着排查能省下大量瞎调参数的时间。5.1 现象白天能识别晚上全返回空原因颜色分割的 HSV 阈值是白天调的夜间图像整体 V 通道偏低蓝色区域落到了阈值下限之外mask 全黑自然找不到轮廓。解决不要写死阈值。加一步亮度判断如果全图平均 V 低于 60先把图像做一次 Gamma 校正或者 CLAHE再走颜色分割。或者干脆夜间切到边缘法Sobel 对亮度不敏感。5.2 现象车牌定位框比实际车牌大一圈字符分割多出边框原因形态学闭运算的核太大把车牌周围的保险杠、车灯也连进来了。或者轮廓找的是外接矩形但车牌本身有倾斜外接矩形包含了大量背景。解决闭运算核从 (17, 5) 往下调试 (13, 5) 甚至 (11, 3)。另外定位后不要直接用 boundingRect改用 minAreaRect 拿旋转矩形再做透视变换能把背景裁掉。5.3 现象字符分割把川字切成三份原因垂直投影阈值设太高字符内部笔画之间的间隙被误判为字符间隙。像川这种三竖的字符笔画间隙和字符间隙在投影上差别不大。解决投影阈值降到最大值的 8% 到 10%。另外加一个最小字符宽度限制切出来的段宽度小于车牌高度的 1/3 就合并到相邻段。还可以先做一次水平方向的闭运算把字符内部笔画连起来再投影。5.4 现象模板匹配把8认成B0认成D原因模板匹配只看整体相似度不区分字符的拓扑结构。8和B在低分辨率下轮廓确实接近。解决加投影特征二次校验或者引入简单的结构特征比如字符内部的孔洞数量。8有两个孔B也有两个但B的孔是上下排列且右侧有竖直笔画8的孔是上下对称的。用cv2.connectedComponents统计孔洞位置能区分开。5.5 现象换一批图片识别率从 90% 掉到 40%原因模板和参数都是在特定数据集上调的换了拍摄设备、分辨率、光照条件分布变了。这是最典型的过拟合。解决模板库要覆盖多种字体和分辨率至少准备两套模板高清和低清。参数不要写死在代码里抽成配置文件换场景时先跑一批样本统计定位成功率再决定调哪个参数。另外如果条件允许收集一批新场景的图重新生成投影特征参考值。6. 从能跑到好用批量处理和效果验证的几个技巧单张图跑通只是起点。真正要用起来得考虑批量处理、结果验证和性能。这一章讲几个我实际项目里常用的技巧。6.1 批量处理的目录结构和日志批量跑的时候目录结构要清晰不然出了问题找不到是哪张图。我一般这么组织plate_batch/ ├── input/ # 原始图片 ├── output/ # 识别结果图画了定位框和识别文字 ├── failed/ # 定位失败的图单独存 ├── review/ # 低置信度字符的切片待人工复核 └── run.log # 每张图的处理日志日志里记录文件名、定位耗时、识别结果、每个字符的置信度。跑完一批后先看 failed 目录有多少张如果超过 10%说明定位参数需要调别急着看识别结果。6.2 用定位成功率而不是识别率来调参很多人调参盯着最终识别率但识别率受字符分割和模板匹配影响反馈链条太长。我习惯先单独统计定位成功率人工标注一批图的车牌位置跑定位代码算 IoU 大于 0.5 的比例。定位成功率上不去调后面的参数都是白费。def iou(box_a, box_b): # box 格式 (x, y, w, h) ax1, ay1, aw, ah box_a bx1, by1, bw, bh box_b ax2, ay2 ax1 aw, ay1 ah bx2, by2 bx1 bw, by1 bh # 交集 ix1 max(ax1, bx1) iy1 max(ay1, by1) ix2 min(ax2, bx2) iy2 min(ay2, by2) iw max(0, ix2 - ix1) ih max(0, iy2 - iy1) inter iw * ih union aw * ah bw * bh - inter return inter / union if union 0 else 0IoU 阈值 0.5 是个宽松标准实际项目里我要求 0.7 以上才算定位成功。达不到就回去看定位那一步的中间图是颜色分割漏了还是形态学核不对。6.3 性能优化把耗时花在刀刃上纯 Python 加 OpenCV 的流程单张图处理时间大概在 50 到 200 毫秒取决于图像分辨率。如果要做实时视频流这个速度不够。几个优化方向第一先把图缩小。定位阶段不需要原图分辨率缩到宽 640 再处理定位完把坐标映射回原图能省一半以上时间。第二颜色分割和边缘检测不要都跑根据场景选一个。第三模板匹配是最耗时的字符数量固定是 7 个模板数量控制在 30 个以内数字加字母加省份简称别把几千个汉字都塞进去。def resize_for_locate(img, target_width640): h, w img.shape[:2] if w target_width: return img, 1.0 scale target_width / float(w) resized cv2.resize(img, (target_width, int(h * scale)), interpolationcv2.INTER_AREA) return resized, scale # 定位后坐标还原 # x_orig int(x_small / scale)缩放系数要保存好定位框坐标除以 scale 才是原图坐标。这个细节不注意画出来的框会偏。6.4 一个容易被忽略的验证习惯每次改完参数不要只看一两张图就下结论。我一般会固定一个 50 张的验证集覆盖白天、夜间、倾斜、模糊四种情况每次改参数跑一遍记录定位成功率和识别准确率。参数调整是个多目标优化盯着单一指标容易顾此失彼。说个我自己的教训有次为了提升夜间识别率把 HSV 的 V 下限从 80 降到 40夜间确实好了但白天把大量灰色车身也框进来了定位成功率从 92% 掉到 78%。后来改成按亮度动态切换阈值才解决。调参这件事没有后悔药只能靠验证集说话。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询