简介基于OpenCV的车牌号码识别Python工程压缩包面向具备一定Python基础、希望入门计算机视觉与人工智能的开发者。压缩包内提供可直接运行的车牌识别主线代码完整覆盖图像灰度化、二值化、高斯滤波、Canny边缘检测、车牌定位、字符分割与SVM识别等关键环节同时配套训练图像、字符样本、训练脚本、配置参数与已训练模型可帮助读者边运行边理解车牌识别系统的实现原理。包内共47个文件以jpg/png图像样本21张jpg、15张png、py源码、7z压缩数据集、dat模型文件及gitignore配置为主整体大小28.8MB目录结构清晰便于按功能模块查阅。训练与识别脚本分离替换成自己的数据集即可重新训练适合二次开发。已有263人浏览学习对于想动手实践OpenCV图像处理、模板匹配和机器学习识别流程的开发者是一份具备较强参考价值的可直接运行案例。1. 一个能在你机器上跑起来的 OpenCV 车牌识别项目你大概也经历过这种场景毕设题目挂着“车牌识别”四个字打开搜索引擎看了几十篇论文代码还停在第一行pip install。我手里这份以 OpenCV 为基础的车牌号码识别 Python 代码包就是用来打破这种僵局的——解压、装依赖、python main.py它能把一张带车牌的图片变成终端里的一行车牌号。这个流程不依赖 GPU、不需要标注数据集、也没有多余的交互依赖从图片预处理一路走到车牌定位、字符分割、模板匹配识别每个环节用的都是裸露的 OpenCV 函数你可以直接看到灰度图、边缘图、轮廓框长什么样。适合毕业论文起步、计算机视觉入门以及想快速验证车牌识别想法的开发者。2. 环境准备OpenCV 版本、Python 解释器与依赖排查2.1 为什么这个项目选传统视觉而不是深度学习先回答一个很多人拿到代码就会问的问题现在 YOLO CRNN 做车牌识别早就遍地都是了为什么还要用 OpenCV 传统视觉原因很简单——这份代码的目标是“可直接运行”而传统方案的样本成本和算力门槛都低得多。深度学习方案你需要先收集几千张车牌图、标出车牌框和字符框再训一个检测模型加一个识别模型没有 GPU 的话训练一轮就要在 CPU 上烤好几个小时。而 OpenCV 这条链路一张图从输入到输出车牌号在普通笔记本上也就是几十毫秒的事而且每一步中间结果都是可视化的一幅图对理解整个识别流程特别友好。选型边界也要说清楚传统方案在光照均匀、车牌角度不太歪、图像分辨率达标的场景下表现稳定比如停车场出入口、小区道闸这类固定机位但如果是夜间强反光、车牌严重倾斜、或者新能源绿牌这种渐变底色就需要在预处理和筛选参数上做调整这些我在后面避坑章节里都会展开。这份代码解决的是“从 0 到 1 跑通”的问题不是“在极端场景下做到 99.9%”的问题。2.2 依赖安装与版本配套解压压缩包之后第一步是把环境捋干净。我一般会在一个干净的虚拟环境里装依赖避免和系统全局 Python 包冲突。核心依赖只有两个opencv-python和numpy如果你还要跑批量测试脚本顺手加一个argparse这个 Python 标准库自带不用额外装。python -m venv .venv source .venv/bin/activate # Windows 下用 .venv\Scripts\activate pip install opencv-python numpy python -c import cv2; print(cv2.__version__)第一行创建虚拟环境第二行激活第三行安装两个核心包。最后一行是验证能打印出版本号说明 OpenCV 装成功了。这里有个细节opencv-python和opencv-contrib-python二选一就行不要两个都装后装的包会覆盖前装的导致某些 API 行为不一致。如果你只需要这套代码里的主模块装opencv-python就够contrib版本多出来的是 SIFT、SURF 这类扩展模块这个项目用不上。Python 版本建议用 3.7 到 3.10 之间太老的解释器对 OpenCV 4.x 的支持不友好太新的版本有时候会遇到预编译 wheel 还没跟上发布节奏的问题。OpenCV 版本选 4.x 的任何一个小版本都行代码里用到的函数从 4.1 到 4.10 接口都没变过。2.3 项目文件结构与主流程入口压缩包里常见的结构一般是这样拿到手先对照着看一眼别急着跑路径作用main.py主入口串联整个识别流程utils.py或preprocess.py预处理、定位、分割等函数封装templates/模板匹配用的字符图片库test_imgs/测试图片每张图对应一个真实车牌号requirements.txt依赖清单pip install -r requirements.txt一键装齐主流程的调用关系也很直白读图 → 定位车牌 → 分割字符 → 模板匹配 → 拼接字符串输出。代码包里一般会有一个detect_plate(img)函数把后面四步串起来你在命令行里调它就是整个识别接口。我习惯把这种函数写成输入一张 BGR 图像的 ndarray、输出一个字符串车牌号这样不管是单张测试、批量跑图还是接摄像头都只需要调同一个函数。2.4 首次运行与验证环境装好之后先跑一张测试图确认流程通不通。代码包一般会带一个--img参数来指定输入路径python main.py --img test_imgs/blue_car.jpg正常情况终端会打印一行结果类似检测到车牌: 粤B12345。第一跑如果报ModuleNotFoundError: No module named cv2说明 OpenCV 没装进当前环境回到 2.2 重新激活虚拟环境再装如果报文件找不到检查路径是不是相对于当前工作目录写的。跑通之后建议把代码里的中间结果输出打开看一眼。多数代码包会在定位、分割环节预留cv2.imshow或者cv2.imwrite的调试入口你看到边缘图、闭运算图、二值化图长什么样后面调参数才有依据而不是瞎猜。3. 车牌定位把目标区域从复杂背景里抠出来3.1 图像预处理链路灰度、模糊、纵向边缘车牌定位是整个识别流程里最重要的一步车牌找歪了后面分割识别全是白搭。常见做法是先用边缘检测把图像里可能属于车牌的区域挑出来。汽车牌照在图像里最显著的特征就是字符笔画密集、竖向边缘丰富所以这里用一个纵向 Sobel 算子来响应这个特征。import cv2 import numpy as np def preprocess(img): # 灰度化丢掉颜色信息只留亮度减少计算量 gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯模糊核大小 5x5sigma 取 0 由 OpenCV 自动算抑制传感器噪声 blur cv2.GaussianBlur(gray, (5, 5), 0) # 纵向 Sobeldx1, dy0只检测竖向边缘 sobel cv2.Sobel(blur, cv2.CV_16S, 1, 0, ksize3) # 结果是 16 位有符号数需要转回 8 位才能可视化 sobel_abs cv2.convertScaleAbs(sobel) return sobel_abs逻辑说明灰度化之后高斯模糊核取(5, 5)这是纯经验值太小压不住噪声太大会把车牌字符的细小边缘也抹掉。ksize3是 Sobel 的最小核对细边缘最敏感字符笔画在图像里通常也就 2~4 像素宽用大核反而检测不到。cv2.CV_16S这一步必须写Sobel 计算过程中会产生负值用 8 位无符号数会截断成 0你最后看到的边缘图会少了整整一半的信息。3.2 形态学闭运算把字符缝隙连起来边缘图拿到手之后车牌区域会呈现为一大片密集的竖条纹但字符和字符之间是有间隙的这些间隙会把一个完整的车牌轮廓切碎成好几块导致后面 findContours 找出来的轮廓要么缺一半要么干脆不是一个整体。解决办法是形态学闭运算——先膨胀再腐蚀把邻近的白色边缘区域粘连成一个完整的块。def close_edges(edge_img): # 闭运算内核宽度 17、高度 3 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (17, 3)) closed cv2.morphologyEx(edge_img, cv2.MORPH_CLOSE, kernel) return closed内核为什么是(17, 3)而不是正方形观察车牌布局——字符水平排列字符间距通常只有 3~5 个像素而字符本身高度远大于间距。宽度取 17 是保证一次膨胀能把两个字符间的空隙填上高度取 3 是为了避免把车牌上下的车身横线、保险杠边缘也粘连进来。如果闭运算之后车牌区域还是碎的你可以把宽度从 17 调大到 21如果发现旁边车身的边缘也被连进来了就把宽度往回缩。这一步做完你会看到边缘图里原本零散的竖条纹变成了一块突出的白色矩形多数情况下就是车牌的区域。3.3 轮廓筛选面积、长宽比与矩形度闭运算结果是一张二值图现在用findContours找所有连通域再用车牌本身的几何特征把候选区域筛出来。def locate_plate(img): edge preprocess(img) closed close_edges(edge) # OpenCV 4.x 返回两个值3.x 返回三个值注意解包差异 contours, _ cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) candidates [] for cnt in contours: rect cv2.minAreaRect(cnt) # 返回 (中心点, (宽, 高), 角度) w, h rect[1] area cv2.contourArea(cnt) # 长宽比车牌标准尺寸 440x140长边比短边约 3.1 long_side, short_side max(w, h), min(w, h) aspect_ratio long_side / short_side # 面积范围在 1920x1080 图像里车牌约占 8000~30000 像素 if 2.5 aspect_ratio 6.0 and 5000 area 40000: candidates.append(rect) return candidates逻辑说明RETR_EXTERNAL只取最外层轮廓避免拿到字符内部的小坑CHAIN_APPROX_SIMPLE压缩轮廓存储点减少内存。minAreaRect返回的是旋转矩形比boundingRect更贴合倾斜的车牌。长宽比阈值2.5~6.0是根据标准车牌比例放宽出来的正常是 3.1允许车辆行驶中产生一定透视变形面积阈值 5000~40000 是针对 1080p 图像的如果你的测试图是 4K 或者只有 640x480这组数字要成比例缩放否则候选框会全空。3.4 颜色特征做二次确认边缘 几何形状这套组合在蓝色车牌上效果最好但遇到车身本身有大量竖线条纹的车比如白色面包车的车门缝隙容易误检。我一般会加一道颜色校验车牌底色的 HSV 范围是稳定的蓝色车牌 H 通道集中在 100~130绿色新能源牌在 35~85黄色教练车在 20~35。def color_mask(img): hsv cv2.cvtColor(img, cv2.COLOR_BGR2HSV) # 蓝色车牌掩码H 范围放宽到 90~130S 下限 80 过滤灰色干扰 blue_mask cv2.inRange(hsv, (90, 80, 80), (130, 255, 255)) return blue_mask这个掩码和边缘轮廓取交集交集面积占轮廓面积比例超过 0.5 就保留否则丢弃。颜色范围不要写死不同相机的白平衡差异很大同一块蓝色车牌在白天和黄昏的 H 值能差出 20 个色阶。这个辅助校验对蓝色牌提升效果明显对渐变底色的新能源绿牌提升有限绿牌那个渐变背景在 HSV 空间里经常横跨两个色区单靠一个inRange罩不住更稳妥的做法是后面避坑章节里讲的把预处理换成直方图均衡化。4. 字符分割与识别从二值化到模板匹配4.1 仿射校正与大津二值化定位到车牌区域之后得到的是一块带角度的旋转矩形。直接在这块图里做字符分割会受倾斜影响字符投影会歪。这里用minAreaRect返回的角度做一次仿射变换把车牌摆正。def rectify(plate_rect, img): (cx, cy), (w, h), angle plate_rect # 统一把长边当作宽度避免角度方向混乱 if h w: angle angle - 90 w, h h, w # 旋转矩阵以车牌中心为原点反向旋转摆正 M cv2.getRotationMatrix2D((cx, cy), -angle, 1.0) rotated cv2.warpAffine(img, M, (img.shape[1], img.shape[0])) # 裁剪出车牌区域四周留 10 像素余量 x1, y1 int(cx - w / 2) - 10, int(cy - h / 2) - 10 x2, y2 int(cx w / 2) 10, int(cy h / 2) 10 return rotated[y1:y2, x1:x2]逻辑说明minAreaRect的宽高和角度是互相关联的OpenCV 约定角度范围在 -90 到 0 度之间但车牌摆向不同会让人判断不清哪条边算宽。这里判断如果h w说明宽高被反着放了把角度减 90 再交换宽高后面裁剪时长短边就不会错。裁剪四周留 10 像素余量目的是保证最左边和最右边的字符完整因为透视校正后边缘处像素可能被裁掉一点点。摆正后做二值化。这里用大津法OTSU自动计算阈值而不是手动指定一个固定值。车牌区域内的灰度分布通常呈现字符亮、背景暗的双峰结构OTSU 就是在这个双峰中间切一刀让类间方差最大。def binarize(plate_img): gray cv2.cvtColor(plate_img, cv2.COLOR_BGR2GRAY) # 大津法自动阈值THRESH_BINARY 是前景白、背景黑 _, binary cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) return binary参数说明thresh参数传 0 在 OTSU 模式下会被忽略实际阈值由算法自己算出来。如果你的测试图是深色车贴的深色牌照二值化结果可能会出现前景背景颠倒一眼就能看出来——字符变成黑色、底板变白色这时候把THRESH_BINARY换成THRESH_BINARY_INV即可。4.2 字符切割投影法的取舍摆正且二值化之后字符是白色、底板是黑色现在要把每个字符单独切出来。最省事的方案是列投影法统计每一列白色像素的个数字符区域会形成波峰字符间的缝隙会是波谷顺着波谷的位置切一刀。def split_chars(binary): # 统计每列白色像素数量 col_sum np.sum(binary 255, axis0) chars [] in_char False start 0 for i, val in enumerate(col_sum): if val 0 and not in_char: in_char True start i # 进入一个字符区域 elif val 0 and in_char: in_char False # 过滤掉宽度小于 15 像素的噪声列 if i - start 15: chars.append((start, i)) return chars逻辑说明这个循环本质上是扫描白色像素列的连通区间。start记录一个字符的左边界i是当前列索引当某列没有白色像素且之前处于字符区内说明字符结束按(start, i)记录这一段的左右坐标。宽度过滤阈值 15 是经验值能滤掉两类噪声一是车牌中间的圆点分隔符它通常只有 3~6 像素宽二是二值化残留的孤立噪点。投影法的局限在汉字上体现得比较明显。省份简称里的汉字笔画左右分离比如“苏”字左右结构中间有间隙投影后中间可能出现白色像素极少的列被误切成两半另一类情况是“京”这种框式结构内芯笔画细投影波峰和边缘不连贯。处理方法是对二值图先做一次小核膨胀把内部笔画连起来再做投影。这个操作不会改变字符整体边界只用来消除内部缝隙我在项目里会单独写一个dilate_for_projection函数处理。4.3 模板匹配识别分数阈值与归一化字符图片切出来之后就是识别环节了。传统方案里最省事的就是模板匹配准备一批已经标注好类别的字符模板图把待识别字符缩放到和模板同尺寸然后计算匹配分数分数最高的模板类别就是识别结果。def match_char(char_img, templates): best_score -1.0 best_label ? # templates 是 dict: {0: ndarray, A: ndarray, 粤: ndarray, ...} for label, tmpl in templates.items(): th, tw tmpl.shape[:2] # 统一缩放到模板尺寸(tw, th) 是 (宽, 高) resized cv2.resize(char_img, (tw, th)) # 归一化相关系数结果越接近 1 越相似 result cv2.matchTemplate(resized, tmpl, cv2.TM_CCOEFF_NORMED) score result[0][0] if score best_score: best_score score best_label label return best_label, best_score模板匹配的分数阈值建议设 0.6~0.7。低于 0.5 的匹配结果大概率是误检比如切割时切入了半个字符或者字符被二值化搞得缺笔画。模板库的构建方式有两种一种是从真实车牌图片里手工裁剪字符存成 PNG另一种是用预设字体渲染字符。前者贴合实际图像风格但样本量有限后者覆盖字符全但和真实拍摄图像风格差异大。实践下来混着用效果最好——主体用真实裁剪图缺的类别再用渲染图补。字符识别还有两个常见坑。第一个是省份汉字必须单独建模板集汉字类别只有几十个省份简称但和数字字母形状差异大全部放在一起匹配时模板数量失衡反而干扰排序第二个是7和1、B和8这类相似字符容易混如果识别结果整体准确率卡住了优先看这两个类别绑定的模板图片是否清晰换一张分辨率更高的往往就能解决。5. 避坑手册运行报错、识别率低与定位翻车的排查记录5.1 ModuleNotFoundError: No module named cv2现象运行主程序第一行就崩终端提示找不到 cv2 模块。原因绝大多数情况是没有在当前 Python 环境里安装 OpenCV。如果你用了虚拟环境却给全局 Python 装了包或者反过来都会出现这个报错。另一个隐蔽原因是用pip install opencv而不是pip install opencv-python前者安装的是一个完全无关的包。解决先确认当前解释器是哪个。在项目目录下执行which python看指向的是不是激活的虚拟环境路径然后安装opencv-python。如果网络慢导致安装超时可以用清华镜像源一条命令搞定pip install opencv-python -i https://pypi.tuna.tsinghua.edu.cn/simple装完再做一次 2.2 里的验证导入确保安装生效。5.2 findContours 解包报错 ValueError: not enough values to unpack现象contours, _ cv2.findContours(...)抛ValueError说解包的值的数量不够。原因OpenCV 版本差异。OpenCV 3.x 的findContours返回三个值(image, contours, hierarchy)OpenCV 4.x 返回两个值(contours, hierarchy)。如果你之前在 3.x 上跑的代码是三个变量解包换到 4.x 就会报错反过来也一样。解决统一改成两个变量的写法contours, _ cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)如果必须在兼容 3.x 和 4.x 的代码里通用可以用一个小函数封装def get_contours(binary): out cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) # 兼容两种返回值结构 return out[0] if isinstance(out, tuple) and len(out) 2 else out[1]5.3 蓝牌识别正常绿牌黄牌几乎全挂现象同一套代码蓝色车牌定位基本稳换成新能源绿牌或教练车黄牌定位框不是飘到车身就是干脆返回空。原因传统 Sobel 边缘 形态学闭运算的组合高度依赖字符和底板的灰度差异。蓝牌的白字和蓝底对比度大边缘响应强绿牌是渐变底色黄牌和白字的灰度差小边缘响应弱产生不了足够大的连通域长宽比和面积筛选自然全都落空。解决绿牌和黄牌走颜色优先的定位路径。先在 HSV 空间做颜色掩码提取底色区域再对掩码做轮廓筛选而不是依赖边缘图。绿牌掩码范围我一般用H: 35~85, S: 80~255, V: 60~255黄牌用H: 20~35。注意绿牌范围不能和蓝色范围重叠否则两者会被混在一起。有了掩码之后轮廓筛选的长宽比阈值对绿牌要放宽到1.5~4.0因为新式绿牌比蓝牌更宽更扁。最稳的做法是蓝牌走边缘定位、绿牌和黄牌走颜色定位两路结果合并取交集宁可多检一个候选框也别漏检。5.4 汉字识别出来是问号现象识别结果长这样?B12345数字和字母全对第一个汉字老失败。原因模板匹配库里汉字模板缺失或者汉字模板图片质量太差、分辨率过低。由于汉字类别少只有几十个省份简称很多教程代码偷懒不建汉字模板库只拿数字和字母跑结果自然拼不出车牌号。解决补齐省份汉字模板库并且单独做一轮汉字匹配。第一个字符和后面的字符分开处理——第一个字符只和汉字模板集合匹配后面五个或六个字符只和字母数字模板集合匹配。这样两个集合内部竞争不容易出现“字母把汉字顶下去”的情况。另外汉字笔画紧凑二值化时内部容易粘连模板匹配前对汉字字符先做一次 3x3 腐蚀把笔画边缘的毛刺去掉再匹配。def match_province_char(char_img, province_templates): # 汉字单独走腐蚀预处理核 3x3迭代一次 kernel np.ones((3, 3), np.uint8) eroded cv2.erode(char_img, kernel, iterations1) return match_char(eroded, province_templates)5.5 字符分割把字切成两半或多出一个点现象二值化和投影切割之后某个汉字被拦腰切断或者结果里混进一个多余的点号比如粤.B12345。原因投影法对字符内部的笔画间隙太敏感。汉字左右结构之间的缝隙在列投影上呈现为零值被误判为字符边界而车牌中间的圆点分隔符宽度不足却没被宽度过滤条件拦下来。解决对二值化图像先做形态学膨胀让左右结构的笔画连成一体再做投影这一步我在 4.2 已经写过。对于圆点分隔符把宽度过滤阈值从 15 上调到 20同时加一个位置过滤——车牌字符除了第一个汉字和第二个字母之间有间隔其他字符间距基本均匀圆点出现在中间位置且宽度明显小于平均字符宽度直接丢弃即可。切割前打印一次投影曲线也是个好习惯肉眼扫一眼就能判断是噪声还是字符比盲调参数快得多。提示以上五个坑是按照“环境 → 版本 → 算法 → 模板 → 分割”的顺序写的排查问题时也建议按这个顺序走。先确认环境没毛病再看数据本身。6. 进阶玩法从单张图片到实时摄像头识别6.1 用 VideoCapture 接摄像头做实时检测代码包里跑通了单张图片之后下一步自然是接摄像头。OpenCV 的VideoCapture用法很直接但直接把单张图的检测流程套上去会有两个问题一是逐帧检测 CPU 占用高二是定位偶尔会抖动。我一般会把输入分辨率限制在 640x480 到 1024x768 之间并且跳帧检测。import cv2 from main import detect_plate cap cv2.VideoCapture(0) # 0 表示默认摄像头 frame_idx 0 while True: ret, frame cap.read() if not ret: break # 每 2 帧检测一次省一半算力 if frame_idx % 2 0: plate detect_plate(frame) if plate: print(f检测到车牌: {plate}) frame_idx 1 if cv2.waitKey(1) 0xFF ord(q): break cap.release()VideoCapture(0)的 0 对应系统默认摄像头插了外置摄像头不想用默认的改成 1 或者 2 再试。跳帧能有效降低 CPU 占用代价是输出帧率减半对道闸这种低速场景完全够用。如果你要处理的是视频文件而不是摄像头把0换成视频文件路径即可其他逻辑不用动。6.2 批量测试脚本把识别的成功率亮出来单张图跑通了不算数你得拿一批图验证。我会把测试图片命名成“真实车牌号_序号.jpg”的格式然后用一个脚本遍历目录跑完自动统计准确率。import os import argparse import cv2 from main import detect_plate parser argparse.ArgumentParser() parser.add_argument(--dir, defaulttest_imgs, help测试图片目录) args parser.parse_args() total, correct, fail_list 0, 0, [] for fname in sorted(os.listdir(args.dir)): if not fname.lower().endswith((.jpg, .png, .jpeg)): continue # 文件名前缀就是真实车牌号分割出下划线前面的部分 ground_truth fname.split(_)[0] img cv2.imread(os.path.join(args.dir, fname)) result detect_plate(img) ok result ground_truth total 1 correct 1 if ok else 0 if not ok: fail_list.append((fname, result, ground_truth)) print(f{fname}: 识别{result} 真实{ground_truth} {OK if ok else FAIL}) print(f\n准确率: {correct}/{total} {correct / total:.2%}) if fail_list: print(失败样本:) for f, r, g in fail_list: print(f {f}: 识别为 {r}, 实际为 {g})这个脚本的价值在于暴露“幸存者偏差”——只拿一张精心挑选的图测试你看到的是处处完美批量跑过之后哪些场景容易翻车就一目了然了。失败样本的输出尤其重要我调参时不会只看准确率数字还会逐个看失败文件确认是定位问题、分割问题还是识别问题。从那以后我每次拿到一套新的识别代码都强制自己先做三件事重装干净环境、跑一遍批量测试、再看一遍失败样本的中间结果图。车牌识别这类传统 OpenCV 项目代码量不大但每一步中间结果都是可以打印出来检查的图——边缘图、闭运算图、二值化图、切割框这些图叠加在一起问题出在哪一环就看得清清楚楚。希望这一篇拆解帮到你少走几个我当年绕过的弯路。本文还有配套的精品资源点击获取