简介这是一份基于Python和OpenCV实现的车牌识别毕业设计项目面向计算机视觉与人工智能方向的学生适用于毕业设计、课程设计或期末大作业也可作为入门级实战演练。压缩包共包含81个文件其中51张车牌图片样本用于测试与展示7个Python脚本覆盖图像预处理、车牌定位、字符分割与识别等核心环节另有XML配置、SVM训练数据模型以及可直接运行的exe程序整体大小约67.93MB目录结构一目了然。项目完整展示从输入图像到输出车牌号码的流程代码中融入HSV颜色空间转换、边缘特征提取等关键算法并附有注释和示例图片方便理解每个步骤的工程实现即使尚未熟悉Python环境也可通过exe演示程序直观查看识别效果。目前已有590人学习下载适合需要完整参考、二次开发或快速复现车牌识别系统的学生与开发者。1. 从毕设答辩到真实停车场OpenCV 车牌识别到底该怎么做做车牌识别系统的毕设最尴尬的事不是算法跑不通而是答辩时老师问一句“你这套东西换个角度、换个光照还能识别吗”你心里没底。基于 Python 和 OpenCV 的车牌识别核心链路其实很固定车牌定位、字符分割、字符识别。但这条链路上每一步都有至少三个“玄学参数”调不好就直接翻车。很多 A 同学拿着开源代码跑通 demo 就算完事结果换一批图片整个流程就崩了。这篇笔记不给你贴那种只能跑通“标准黄色车牌”的玩具代码而是把每一步的选型理由、参数边界和踩坑点都拆开讲。适合两类人一是拿这个题目做毕业设计、想让系统在答辩时经得起追问的二是真要在小区出入口、园区道闸场景里做原型验证想少走弯路的从业者。读完你应该能独立实现一版从“图片输入”到“车牌字符串输出”的完整流程并且知道失败时该看哪里。2. 车牌定位的两种主流路线颜色特征和形状特征怎么取舍车牌定位是整个系统的地基。定位错了后面所有工作都是白做。常见的方案有两条路基于颜色特征做区域提取或者基于边缘和形态学做候选区筛选。我的建议是毕业设计阶段两条路都实现一遍然后对比效果这本身就可以写进论文里的“方案对比”章节。2.1 HSV 颜色空间为什么比 RGB 更适合筛车牌RGB 颜色空间对光照变化极度敏感。同样一块蓝色车牌在树荫下和正午阳光下像素值的分布范围可能完全对不上。HSV 把色相、饱和度、亮度拆开筛颜色时主要看 H 通道S 和 V 只做辅助过滤这就天然抗光照干扰。import cv2 import numpy as np def extract_plate_by_color(img): # 转为 HSV 色彩空间 hsv cv2.cvtColor(img, cv2.COLOR_BGR2HSV) # 蓝色车牌范围H 在 100~124S 大于 90V 大于 80 lower_blue np.array([100, 90, 80]) upper_blue np.array([124, 255, 255]) mask cv2.inRange(hsv, lower_blue, upper_blue) # 形态学闭运算把断裂的色块连起来 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (15, 15)) closed cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) return closed这段代码是典型的颜色筛选手法。inRange输出一张二值图白色的就是“可能是车牌”的区域。闭运算的核大小是重点我一般先用 (15, 15) 起步如果车牌的边框和字符被拆成很多碎块就把核加大到 (25, 25)。但核太大也会有个副作用车身贴了深色车膜、车灯是蓝色调的时候会把整片区域连进来。所以闭运算之后一定要接“轮廓筛选”用面积和宽高比把候选框收回来。2.2 轮廓筛选的宽高比和面积阈值该怎么定国内标准车牌蓝牌的尺寸比例大约是 440mm×140mm宽高比接近 3.14。但实际图像里因为拍摄角度和透视变形这个比例会在 2.2 到 4.5 之间波动。你如果死守“宽高比必须大于 3”那侧面拍到的车牌大概率被丢掉。def find_plate_contours(binary_img, original_img): contours, _ cv2.findContours(binary_img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) candidates [] for cnt in contours: x, y, w, h cv2.boundingRect(cnt) area w * h # 面积太小或太大都跳过按图片尺寸动态算 if area original_img.shape[0] * original_img.shape[1] * 0.001: continue if area original_img.shape[0] * original_img.shape[1] * 0.3: continue aspect_ratio w / max(h, 1) if 2.0 aspect_ratio 5.0: candidates.append((x, y, w, h)) return candidates这里有两个关键点。一是面积阈值的写法直接写死像素数等于废物因为不同摄像头分辨率差别很大。我习惯按“占整张图面积的比例”来算从 0.001 到 0.3相当于允许车牌占画面的 0.1% 到 30%。二是宽高比范围2.0 到 5.0 是一个比较宽容的区间。如果你只做道闸正前方的识别视角很正可以收紧到 2.5 到 4.0如果要做侧向抓拍就保持宽区间把后面的字符识别做成强把关。2.3 蓝色车牌之外新能源、黄色、白色车牌的定位差异这是最容易在答辩时被追问的点。现在路面上新能源绿牌渐变绿、教练车黄牌、警车白牌都在增加。如果你的毕设标题写的是“车牌识别系统”而不是“蓝色车牌识别系统”那至少要处理绿牌。绿牌的 HSV 范围和蓝色车牌差异很大因为绿牌的渐变特性导致它从黄绿色到深绿色都有。我处理时会放宽 H 通道到 35~100然后不再依赖单一颜色而是加一个“边缘密度”二次确认。def is_green_plate(hsv_img, x, y, w, h): region hsv_img[y:yh, x:xw] # 统计绿色像素占比 mask_green cv2.inRange(region, np.array([35, 80, 60]), np.array([100, 255, 255])) ratio np.sum(mask_green 0) / (w * h) # 绿色像素占比超过 0.3 就认为是绿牌候选 return ratio 0.3二次确认的意思就是“颜色说了不算还得看占比”。绿色像素占比超过 30% 才认可以过滤掉大面积的绿色植物、车身拉花。这里注意绿牌的字符是黑色和渐变底色对比度较高所以定位成功后后续的字符分割流程不用改只需要在最终输出时加一个“车牌颜色”字段即可。3. 字符分割的三板斧垂直投影、轮廓查找、倾斜校正定位出车牌矩形区域后下一步就是把“京A12345”这串字符切成单个字符图片。这一步的难点不在于“怎么切”而在于“切坏了怎么补救”。我见过太多代码投影分割出来 6 个或 8 个框和车牌实际字符数对不上就直接报错。3.1 预处理顺序不能乱灰度、二值化、去边框拿到车牌区域的彩色图之后千万别直接转灰度做二值化。车牌边框和铆钉会严重影响分割结果。我的习惯是先做一次“去边框”操作然后再二值化。def preprocess_plate(plate_img): # 先转灰度 gray cv2.cvtColor(plate_img, cv2.COLOR_BGR2GRAY) # 高斯模糊去掉噪点但别选太大核 blurred cv2.GaussianBlur(gray, (3, 3), 0) # 自适应阈值blockSize 必须是奇数 binary cv2.adaptiveThreshold(blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 21, 10) # 去掉上下左右边缘 2~4 像素通常能去掉边框线 if binary.shape[0] 10 and binary.shape[1] 10: binary binary[2:-2, 2:-2] return binary自适应阈值比全局 OTSU 好在哪答案是局部光照不均时不会一块全黑一块全白。但blockSize参数很敏感太大的话小字符会被抹掉太小的阈值会跟着噪声走。21 是一个相对保守的起步值字符笔画细的车牌可以降到 15。THRESH_BINARY_INV的意思是字符变白、背景变黑因为后面找轮廓时默认是“白色物体”。3.2 垂直投影切字符原理和手写实现垂直投影的原理极其简单把二值图按列求和每一列的白色像素个数就是投影值。字符和字符之间有间隙投影值为 0 或接近 0 的列就是切分点。def segment_by_vertical_projection(binary_img): # 垂直投影统计每列白色像素数量 projection np.sum(binary_img, axis0) / 255 # 找投影值大于阈值的列区间 threshold 1 in_char False char_regions [] start 0 for i, val in enumerate(projection): if val threshold and not in_char: in_char True start i elif val threshold and in_char: in_char False char_regions.append((start, i)) # 清洗过窄或过宽的区间 cleaned [] for (s, e) in char_regions: width e - s if 5 width 200: cleaned.append((s, e)) return cleaned这段代码看起来简单但有一个大坑如果车牌图片里有铆钉、生锈污渍、或者字符粘连投影值不会干净地归零。我加了threshold 1的容错意思是“这一列只要还有 1 个白点就算字符的一部分”。如果你发现字符被切开就把 threshold 调大如果字符粘在一起分不开就把 threshold 调小。这个参数在答辩时拿来演示调试过程非常加分。3.3 倾斜校正用最小外接矩形算旋转角车牌在图片里不可能是绝对水平的总会有几度倾斜。不做校正的话字符投影会叠在一起。常见的做法是用找出来的最小外接矩形算旋转角度然后做仿射变换。def rotate_plate(plate_img, angle): h, w plate_img.shape[:2] center (w // 2, h // 2) matrix cv2.getRotationMatrix2D(center, angle, 1.0) rotated cv2.warpAffine(plate_img, matrix, (w, h), flagscv2.INTER_CUBIC, borderModecv2.BORDER_REPLICATE) return rotated旋转角度怎么算我一般先对二值化的车牌图找轮廓然后对最大轮廓做cv2.minAreaRect返回的rect[2]就是角度。注意角度范围是 -90 到 0 度你需要判断一下如果角度小于 -45 度就把它加 90 度转换成正值旋转。这个细节能解决大约 30% 的“投影分不开”问题属于血泪经验。如果旋转之后边框被切掉一块可以用BORDER_REPLICATE模式复制边缘像素补上比补黑边效果好。3.4 分割个数不对时的兜底策略字符分割经常遇到的结果是切出来 6 个框少一个或者 8 个框多两个。原因多半是第一个汉字的左右偏旁分开了或者中间有两个字符因为污渍粘联。我的兜底策略是不强行依赖投影结果而是先按“宽度中位数”过滤异常框。def filter_char_regions(regions, binary_w): # 去除明显过宽和过窄的框 widths [e - s for s, e in regions] if not widths: return [] median_w np.median(widths) filtered [] for (s, e) in regions: w e - s # 宽度在 0.3~2.5 倍中位数之间的保留 if 0.3 * median_w w 2.5 * median_w: filtered.append((s, e)) return filtered如果过滤之后框的数量还是和预期不一致蓝牌 7 个字符新能源 8 个字符我还有一个最后手段丢弃最左边和最右边的框因为定位阶段留下的边框残影大多分布在两边。这个手段不优雅但有效你可以在论文里写“基于字符宽度统计的异常框剔除策略”。4. 字符识别的选型模板匹配还是 CNN毕业设计都需要字符识别是整套系统的最终输出环节。它决定了你打印在终端上的那一串字符串对不对。我自己的习惯是两套方案都写上模板匹配作为基线版本CNN 作为对标版本。这样论文里既有基线数据又有优化后的数据。同时从工程角度模板匹配对字符形变的容忍度低而 CNN 对训练数据要求高两者在毕设时间预算下的取舍很现实。4.1 模板匹配实现 OCR 识别特征就是“像素差异”模板匹配的朴素想法是把待识别字符缩放到和模板一样大小然后逐像素算差异差异最小的模板就是识别结果。def match_char(char_img, templates, label_map): # 统一缩放到 32x64 resized cv2.resize(char_img, (32, 64), interpolationcv2.INTER_AREA) best_score float(inf) best_label for template, label in zip(templates, label_map): # 计算绝对差值和 score np.sum(np.abs(resized.astype(np.float32) - template.astype(np.float32))) if score best_score: best_score score best_label label return best_label模板匹配有个隐性问题字符粗细、字体、噪声都会直接影响分数。同一个“苏”字楷体和小黑体差异巨大。所以模板匹配的精度上限并不高我的建议是把这个方法用作“快速验证”比如在答辩 ppt 上放一张流程图输入字符 → 缩放 → 遍历模板 → 输出结果。老师能看懂项目自己也不依赖它。4.2 轻量 CNN 识别车牌字符数据从哪里来真正的识别主力我建议用一个轻量 CNN因为车牌字符只有几十类不需要 ResNet 这种大网络。一个两层卷积加全连接的小模型在 CPU 上识别单个字符也只需要几十毫秒。import tensorflow as tf model tf.keras.Sequential([ tf.keras.layers.Conv2D(32, (3, 3), activationrelu, input_shape(32, 64, 1)), tf.keras.layers.MaxPooling2D((2, 2)), tf.keras.layers.Conv2D(64, (3, 3), activationrelu), tf.keras.layers.MaxPooling2D((2, 2)), tf.keras.layers.Flatten(), tf.keras.layers.Dense(128, activationrelu), tf.keras.layers.Dense(num_classes, activationsoftmax) ]) model.compile(optimizeradam, losscategorical_crossentropy, metrics[accuracy])数据来源是个难题。你不可能自己去车管所拿几万张标注车牌图。最现实的做法是先用合成数据预训练再用真实图片微调。合成数据的生成方式是用真实车牌字体渲染到随机背景上叠加随机光照和噪声。常见的做法是不需要很复杂用 PIL 就能做。实际使用中我见过很多同学从开源数据集上拿字符图片来训练效果也还行但必须做一步“风格对齐”——把训练图片都转成和你的分割模块输出一致的灰度图和尺寸。4.3 字符集设计省份简称和特殊字符的坑车牌字符集包括汉字省份简称、大写字母不包含 I 和 O避免和数字 1、0 混淆、数字。所以分类总数在 30 到 40 左右具体取决于你要支持多少省份。# 蓝牌字符集去除 I 和 O province_chars 京津冀晋蒙辽吉黑沪苏浙皖闽赣鲁豫鄂湘粤桂琼渝川贵云藏陕甘青宁新 letters_and_digits ABCDEFGHJKLMNPQRSTUVWXYZ0123456789 char_set province_chars letters_and_digits num_classes len(char_set)有一个细节必须注意省份简称里有几个字形极其接近比如“渝”和“豫”“赣”和“黔”。如果你的训练数据里这些类别样本数量太少识别时很容易混。我的做法是每个字符类别至少凑 200 张图不够就做数据增强平移几个像素、加一点高斯噪声都可以。另外新能源车牌最后一位可能是字母如 D 代表纯电动字符集里一定要包含不然永远识别不出来。4.4 整牌识别把分割和识别串联起来的完整流程整牌识别就是把前面几个模块串起来跑一遍。我建议把整个流程封装成一个类不要散落一堆函数。这既是工程习惯也是答辩时演示完整流程的必要组织。class PlateRecognizer: def __init__(self, model, char_set): self.model model self.char_set char_set self.char_to_idx {c: i for i, c in enumerate(char_set)} def recognize(self, img_path): img cv2.imread(img_path) # 1. 定位 mask extract_plate_by_color(img) candidates find_plate_contours(mask, img) if not candidates: return None # 取最大的候选区域 x, y, w, h max(candidates, keylambda r: r[2] * r[3]) plate img[y:yh, x:xw] # 2. 预处理和分割 binary preprocess_plate(plate) rotated_binary rotate_plate(binary, get_rotation_angle(binary)) regions segment_by_vertical_projection(rotated_binary) regions filter_char_regions(regions, rotated_binary.shape[1]) # 3. 识别 result for (s, e) in regions: char_img rotated_binary[:, s:e] resized cv2.resize(char_img, (32, 64)) resized resized.reshape(1, 32, 64, 1).astype(float32) / 255.0 preds self.model.predict(resized, verbose0) idx np.argmax(preds[0]) result self.char_set[idx] return result这里的get_rotation_angle函数需要自己实现逻辑是基于minAreaRect的角度换算前面已经提过。整体流程跑通之后你会遇到一个“概率性失败”的问题大部分图片识别正确个别图片丢字符或串字符。这种问题没法通过调一个参数解决得回到具体环节去查中间产物。这也是下一章要展开的。5. 车牌识别系统的避坑指南5 条实战踩坑记录这章的内容来自我帮某开发者和 A 同学调通同类模拟项目时的真实经验。每条都按“现象 → 原因 → 解决”的顺序写你可以直接对照排查。5.1 定位框把整辆车框进去了现象是车牌区域没找对find_plate_contours返回的第一个候选框占据了图片一半面积。原因多半是闭运算的核太大把车身和车牌连成一片了。另一个可能是蓝色车身的车整个车身都被 HSV 筛出来了。解决方法是把闭运算核从 (15, 15) 改小到 (9, 9) 或 (7, 7)同时在轮廓筛选中增加“宽高比上限”比如超过 6 的框直接放弃。如果你发现大面积同样的颜色比如蓝色货车可以在 HSV 筛完后的掩膜上先做一次开运算去噪再做闭运算连接。5.2 夜间和逆光场景下的 HSV 阈值失效现象是白天好好的蓝色车牌到了晚上或者强逆光下定位环节直接找不到候选框。原因是 BGR 转 HSV 之后暗光环境下的蓝色像素饱和度大幅下降S 通道低于你设的 90导致被inRange过滤掉。解决方法是把 S 通道的下限从 90 降到 40V 通道的下限从 80 降到 30。代价是误检会增加所以后续的轮廓筛选要更严格。如果你要处理夜间场景更好的做法是先用直方图均衡化提升整体对比度再进 HSV 流程。def preprocess_for_dark(img): # 转换到 YUV 空间只对亮度通道做直方图均衡化 yuv cv2.cvtColor(img, cv2.COLOR_BGR2YUV) yuv[:, :, 0] cv2.equalizeHist(yuv[:, :, 0]) return cv2.cvtColor(yuv, cv2.COLOR_YUV2BGR)这个预处理可以保留颜色信息同时让暗部细节更清晰。要注意的是直方图均衡化对本来就是白天正常曝光的图片反而会破坏色彩平衡所以最好在调用前先判断一下整图的平均亮度低于某个阈值才启用。5.3 字符分割把“京”字一分为二现象是车牌上的第一个汉字被切成左右两半导致识别结果多出一个字符。原因是汉字的字形结构和字母数字差别大“京”字左侧的点和横折钩与右边部分在投影时出现了明显的低谷。解决方法是不要只盯投影值是否为 0而是设置一个相对比例阈值。比如当某列的投影值小于字符区域最大投影值的 20% 时才视为分割点。另一个有效办法是对分割结果做后合并如果两个相邻区域的宽度之和明显小于中位数宽度且中间缝隙小于 3 像素就把它们合并。5.4 模板匹配时字符背景没统一现象是识别率忽高忽低同一个字符在不同图片上时而对时而错。原因很隐蔽二值化后有些字符是白色、背景黑色但残留在字符内部的孔洞被填充成了黑色导致字符形状变化。解决方法是模板和待识别图片必须走完全相同的预处理流程。很多同学从网上下载模板图片就忘了对模板也做同样的二值化和缩放。统一流程后识别率的波动会明显变小。5.5 数据集类别不平衡导致“贵”字永远识别成“桂”现象是模型训练完了其他字符精度都在 95% 以上唯独某些省份简称识别精度极低。原因是训练集里“贵”字的样本可能只有几十张而“桂”字有几百张模型直接学成了偏向高频类别的局部最优。解决方法是做类别加权或者过采样。最简单的手段是把少的那个类别每个训练样本复制 5 到 10 份加一点随机噪声再喂给网络。在代码层面可以在model.fit()里传class_weight参数按类别样本量的反比设置权重。# 计算类别权重缓解样本不均衡 from sklearn.utils.class_weight import compute_class_weight weights compute_class_weight(balanced, classesnp.unique(train_labels), ytrain_labels) class_weight_dict {i: weights[i] for i in range(len(weights))} model.fit(train_x, train_y, class_weightclass_weight_dict, epochs15)这一步操作在答辩时极其加分因为老师大概率会问“你的数据是否均衡”。你直接抛出来权重计算逻辑比一句“我收集了很多数据”有说服力得多。6. 进阶验证技巧批量生成测试用例让系统在答辩时无懈可击整套系统跑通之后你面临的最大问题不是功能而是“怎么证明它稳定”。单张图片演示很容易翻车因为现场环境不可控。我建议你做一个批量测试脚本自动扫描某个文件夹里的所有测试图片输出每张图的识别结果和置信度并统计准确率。这一章的技巧能让你的毕设报告和答辩演示都再上一个台阶。测试集不要只用自己拍的几张照片。你可以用代码把已知车牌字符串渲染到不同颜色的背景上再叠加入为噪声、旋转、缩放、光线变化生成几百张合成测试图。这些图和真实图片混合在一起做验证能系统性地测出你的定位、分割、识别模块各自对干扰的容忍能力。# 生成一张合成测试图用于批量验证 import cv2 import numpy as np def render_synthetic_plate(text, bg_color(255, 0, 0)): # 创建纯色底图模拟蓝底白字车牌 plate np.full((140, 440, 3), bg_color, dtypenp.uint8) # 白色字体写在图上 cv2.putText(plate, text, (25, 100), cv2.FONT_HERSHEY_SIMPLEX, 1.8, (255, 255, 255), 4) # 轻微旋转 3 度 center (220, 70) matrix cv2.getRotationMatrix2D(center, 3, 1.0) plate cv2.warpAffine(plate, matrix, (440, 140)) # 添加高斯噪声 noise np.random.normal(0, 5, plate.shape).astype(np.int16) plate np.clip(plate.astype(np.int16) noise, 0, 255).astype(np.uint8) return plate这套方法的妙处在于你能精确控制一张测试图里的“变量是什么”。比如你固定噪声强度、只改变旋转角度就能得到“系统在 0 到 8 度旋转范围内的准确率曲线”。这比“我的系统准确率 95%”这种模糊表述硬核得多。将这些曲线整理成图表放进论文就是完整的“系统鲁棒性分析”章节素材。我的个人习惯是在最终验收前跑一遍批量脚本把所有失败的图片单独保存到一个文件夹。然后逐个查看失败图片到底是在哪个环节出错的定位框偏了、分割多了、还是分类器看走眼了。这个过程帮我避免了不止一次答辩翻车。你拿到这套系统之后强烈建议也养成盯着失败样本看的习惯——成功的案例千篇一律失败的案例各有各的坑把坑一次填平系统就真的可用了。希望这些实战经验能帮你少走弯路祝答辩顺利。本文还有配套的精品资源点击获取