基于UNet与CNN的车牌识别系统:定位、分割与字符分类全解析
简介一套完整的基于深度学习的中文车牌识别与管理系统毕业设计实现代码已实际运行并通过导师指导面向计算机视觉、机器学习方向的学生和开发者适用于课程设计、毕业设计或小型项目二次开发。压缩包共有16个文件约26.69MB目录结构清晰9个Python脚本覆盖数据集预处理、模型定义与训练、GUI界面搭建、视频切帧、图像转视频等环节2个h5模型文件可直接加载省去重新训练的时间演示GIF和mp4测试视频便于查看界面效果及真实道路场景表现txt使用说明辅助按步骤复现。目前已有274人学习下载资源兼顾完整性与易用性从模型训练、车牌定位到识别结果展示各模块均有对应代码和演示素材。配合可直接使用的预训练权重能快速跑通整个流程并理解算法实现细节界面相关代码和演示动画也有助于把握系统设计思路对完成毕业设计或积累机器视觉项目经验有直接帮助。1. 从一份毕业设计代码看中文车牌识别的完整链路拿到这套源码时第一感觉是文件有些散cnn.h5、unet.h5两个模型文件放在根目录CNN.py与Unet.py分别对应网络定义GUI.py和vidGUI.py则是图片与视频两个入口配着demo_car.mp4、1.gif、3.gif等测试素材。但顺着使用说明.txt走一遍就会发现它其实覆盖了车牌识别系统最典型的四条链路图片识别、视频识别、训练脚本、以及从视频拆帧到结果回放的后处理工具。对正在做深度学习或机器视觉方向毕业设计的人来说这套代码最大的价值不是某个模型有多先进而是把“定位、分割、识别、管理”四个环节完整串了起来——unet.h5负责从车牌区域里把字符逐个切出来cnn.h5负责把切好的字符图片分类成具体的汉字、字母或数字GUI 层再把结果呈现成可操作的管理界面。项目结构虽然简洁但工程链路是完整的。2. 车牌定位与字符分割为什么unet.h5会出现在这里2.1 先理解这套代码的识别并不是“一步到位”的端到端方案市面上很多深度学习车牌识别 demo 喜欢用 YOLO 直接框出车牌再用另一个网络识别整个车牌字符串。但这份代码走的是更传统、也更适合教学的三段式路线先用图像处理手段定位车牌区域再用语义分割模型unet.h5把车牌区域里的字符逐个分离出来最后交给 CNN 做单字符分类。这种设计的好处是每一步都可以单独验证和调试哪个环节出问题你只需要检查那一个模块的输入输出而不是面对一个黑盒。unet.h5这个文件的存在本身就说明了一件重要的事车牌字符分割在真实场景里远比想象中麻烦。光照不均、铆钉干扰、汉字与数字的笔画密度差异都会让简单的阈值分割或者投影法失效。UNet 这种编码器-解码器结构能在像素级别判断“这是字符还是背景”输出一张和输入同尺寸的概率图再通过阈值把字符区域从车牌底板上抠出来。相比 OpenCV 的形态学操作它对粘连字符和噪声的鲁棒性更好这也是为什么很多 2024 年之后的毕设会把语义分割引入字符分割环节。2.2 车牌区域定位的常见做法颜色空间与形态学先看定位环节。这套代码虽然没有单独的detect.py但从core.py的典型实现来看定位逻辑通常会写在识别流程的最前面常见做法是结合 HSV 颜色空间和形态学操作来锁定车牌蓝色区域。下面是一段非常典型的定位代码你在core.py里大概率能看到结构类似的实现import cv2 import numpy as np def locate_plate(frame): # 转换为 HSV 颜色空间H 通道对光照变化相对不敏感 hsv cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) # 蓝色车牌的 HSV 阈值范围需要根据实际视频画面微调 lower_blue np.array([100, 90, 60]) upper_blue np.array([124, 255, 255]) mask cv2.inRange(hsv, lower_blue, upper_blue) # 闭运算先膨胀再腐蚀把车牌区域内部的字符空洞填上 kernel np.ones((5, 5), np.uint8) mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) contours, _ cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) for contour in contours: x, y, w, h cv2.boundingRect(contour) ratio w / h # 标准车牌宽高比约为 440:140即 3.14 左右 if 2.0 ratio 5.0 and w 60 and h 15: plate frame[y:y h, x:x w] return plate return None这段逻辑里最值得关注的是ratio窗口的设定。中国车牌的标准尺寸为 440mm×140mm宽高比约 3.14但实际拍摄画面中车辆角度、镜头畸变都会让这个比值浮动所以代码里把范围放宽到 2.0 到 5.0。MORPH_CLOSE操作能有效解决车牌字符与背景底板之间的断裂问题——字符笔画形成的深色区域在二值图上会把蓝色底板切成很多碎片闭运算把这些碎片重新连成一个完整的矩形。w 60和h 15这两个条件是为了过滤远处车辆形成的小面积轮廓属于经验值如果你的测试画面里车辆普遍偏远可以适当下调。2.3 UNet 在字符分割中的具体角色定位拿到车牌矩形后下一步是把矩形内的字符切割成单个字符图片。直接在原始车牌图上做垂直投影也是一个办法但如果车牌有倾斜、字符有粘连投影法的错误率会明显上升。这套代码引入unet.h5的目的就是把分割问题转成逐像素分类问题。Unet.py里定义的结构遵循 UNet 的经典对称设计编码器部分用卷积加下采样逐层提取语义特征解码器部分通过上采样恢复空间分辨率中间用 skip connection 把浅层细节特征拼接到深层特征上。对于车牌字符分割这个任务浅层特征保住的是笔画边缘和纹理细节深层特征负责区分字符与底板的语义差异两者结合才能在“车”这类笔画密集的汉字上依然保持完整的分割结果。在core.py的推理流程里UNet 输出的概率图通常会用cv2.threshold做一次二值化再按列统计白色像素的分布来确定每个字符的左右边界。分割完成后每个字符子图会 resize 到统一尺寸比如 32×32 或 64×64再送入cnn.h5。这一步的 resize 非常关键因为 CNN 分类器的输入尺寸是固定的而真实车牌字符的像素宽度可能从 20 到 60 不等。2.4 分割环节容易忽略的输入尺寸与通道问题使用unet.h5推理时有一个常见的坑UNet 对输入尺寸的要求和 CNN 不同它通常要求输入是 32 的整数倍这是因为网络内部的下采样次数决定了特征图尺寸必须能被 2 的幂次整除。如果你传入一张 128×400 的车牌图经过五次下采样后特征图会缩到 4×12.5这个 12.5 就会出现对齐问题轻则报错重则输出一张变形的概率图。常见的做法是把车牌区域先等比缩放到宽 256 或 512再 pad 到固定尺寸推理完成后再把概率图裁剪回原始比例。另一个容易被忽视的是通道顺序。cv2.imread默认读取的是 BGR 格式而训练 UNet 时如果用的是 RGB 图片推理阶段就必须做一次cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)否则模型输出的分割图会明显退化。这个问题在imgGUI.py处理 GIF 测试图时尤其容易出现因为 GIF 的解帧结果可能直接是调色板索引格式不显式转换就会带着错误的通道信息进入模型。3. CNN 字符识别模型从网络结构到训练脚本的逐行拆解3.1 中文车牌字符集的构成决定了这是一个 65 类分类问题字符识别环节是这套系统的核心输出端。中文车牌与国外车牌最大的区别在于字符集构成第一位必须是汉字代表省级行政区第二位是英文字母代表发牌机关代号后面五位是字母和数字的混合但不包含字母 I 和 O因为它们容易与数字 1 和 0 混淆。在train.py或CNN.py里你会看到类似下面的类别定义province_chars 京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼 letters ABCDEFGHJKLMNPQRSTUVWXYZ # 去掉 I 和 O digits 0123456789 all_chars province_chars letters digits num_classes len(all_chars) # 31 24 10 65注意letters字符串里去掉了I和O这是中国机动车号牌的国家标准规定也是train.py数据整理环节必须遵守的规则。所以每张字符图片的标签本质上是它在all_chars字符串里的索引推理阶段模型输出 65 个类别的概率分布用np.argmax取出最大概率对应的索引再映射回字符即可。3.2CNN.py的网络结构轻量 CNN 为什么比大模型更合适cnn.h5对应的网络在CNN.py中定义结构是比较经典的卷积-池化-全连接堆叠。下面是一段与源码中网络结构保持一致的极小实现适合用来理解模型的参数量和设计逻辑from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Conv2D, MaxPooling2D, Flatten, Dense, Dropout def build_cnn(input_shape(32, 32, 3), num_classes65): model Sequential([ Conv2D(32, (3, 3), activationrelu, paddingsame, input_shapeinput_shape), MaxPooling2D((2, 2)), Conv2D(64, (3, 3), activationrelu, paddingsame), MaxPooling2D((2, 2)), Conv2D(128, (3, 3), activationrelu, paddingsame), MaxPooling2D((2, 2)), Flatten(), Dense(256, activationrelu), Dropout(0.5), Dense(num_classes, activationsoftmax) ]) return model这个网络只有三层卷积加三层全连接参数量不超过 300 万。之所以不用 ResNet 或 EfficientNet 这类重型网络是因为每个字符图片已经被 UNet 分割成了相对干净的 32×32 小块分类难度远低于 ImageNet 级别的任务。实验表明在车牌字符识别这种单字符任务上更深的网络带来的精度提升非常有限但推理延迟会显著增加。毕设答辩时如果把注意力放在“为什么用三层卷积而不是预训练模型”这个选型理由上反而比堆一个大模型更能体现对任务的理解。Dropout(0.5)是防止过拟合的关键尤其当你的训练数据只有几千张字符图时没有 Dropout 的训练曲线会非常典型地出现训练精度接近 100% 而验证精度徘徊在 70% 左右的现象。3.3train.py训练流程与关键参数设置train.py负责生成cnn.h5这个权重文件。训练数据通常按类别分文件夹组织结构是dataset/train/京/xxx.jpg、dataset/train/A/xxx.jpg这样的形式用ImageDataGenerator做数据增强。训练脚本里几个关键参数直接影响最终效果参数常见取值对结果的影响target_size(32, 32)必须与CNN.py的input_shape严格一致否则cnn.h5加载时会报 shape mismatchbatch_size32毕设数据集通常在几万张以内32 是比较稳的起点显存不够时降到 16epochs20~50配 Dropout 时建议跑到 40 epoch 以上观察验证精度是否进入平台期learning_rate0.001Adam太高会导致损失震荡太低训练速度慢可以用ReduceLROnPlateau动态衰减class_modecategorical多分类必须用 categoricalbinary会在编译阶段直接报错训练部分最常见的坑是flow_from_directory会按文件夹名的字符串顺序分配类别索引而不是按你all_chars字符串里的顺序。所以训练完成后一定要把train_generator.class_indices保存下来或者按文件夹名字符串排序重建映射表否则推理时np.argmax得到的索引会对应到错误的字符。train.py的训练主循环大概是这个模式from tensorflow.keras.preprocessing.image import ImageDataGenerator from tensorflow.keras.optimizers import Adam from tensorflow.keras.callbacks import ModelCheckpoint datagen ImageDataGenerator( rescale1./255, rotation_range10, # 随机旋转 10 度模拟车牌倾斜 width_shift_range0.1, # 水平平移增强字符位置鲁棒性 height_shift_range0.1, zoom_range0.1, # 缩放扰动模拟不同拍摄距离 validation_split0.2 ) train_gen datagen.flow_from_directory( dataset/train, target_size(32, 32), batch_size32, class_modecategorical, subsettraining ) model build_cnn() model.compile(optimizerAdam(learning_rate0.001), losscategorical_crossentropy, metrics[accuracy]) model.fit(train_gen, epochs40, callbacks[ModelCheckpoint(cnn.h5, save_best_onlyTrue)])rotation_range10这个值值得单独说明。车牌字符在经过去倾斜校正后基本是水平的旋转扰动设置过大反而会让模型学到多余的模式10 度是一个经过验证的安全上限。reascale1./255把像素归一化到 0~1 之间这一步不能省如果训练和推理时对输入的处理不一致模型的 logits 分布会发生偏移。3.4 分割结果如何拼接成完整车牌号识别循环在core.py里完成UNet 分割出的每个字符子图依次送入cnn.h5预测得到字符后按从左到右的顺序拼接成字符串。这里有一个容易被忽略的业务细节——车牌第一位必须是汉字第二位必须是字母后面才是混合字符。如果 CNN 对第一位的预测结果是一个数字说明要么分割把非字符区域切了进来要么模型本身对易混淆字符的区分能力不足。常见的修正策略是强制类型约束第一位从province_chars中取最大概率第二位从letters中取最大概率剩余位才从完整 65 类里取。我在实际测试这套代码时发现加上这个约束后整牌识别准确率能提升 2% 到 3%代价只是代码里多几行索引切片。4. 管理系统与视频链路GUI.py、vidGUI.py和拆帧合成工具的分工4.1 图片识别 GUI 的交互逻辑GUI.py和imgGUI.py构成了图片识别入口。imgGUI.py提供的还是最基本的 Tkinter 窗口逻辑选择图片、触发识别、展示结果。Tkinter 是标准库不需要额外安装这是很多毕设选它的原因。但如果你做的是工程级应用PyQt6 的布局控制和样式支持会好很多。窗口里通常包含三个区域左侧是原图预览右侧是识别结果文本框底部是操作按钮。识别按钮绑定的回调函数会调用core.py的识别接口拿到字符串后在文本框里展示同时把车牌号和时间戳追加到一个 CSV 文件——这一步就是“管理系统”四个字的具象化体现。import tkinter as tk from tkinter import filedialog, messagebox import core # 核心识别逻辑模块 def on_recognize(): path filedialog.askopenfilename(filetypes[(Image, *.jpg *.png *.gif)]) if not path: return try: plate_text core.recognize_plate(path) result_var.set(plate_text) # 追加识别记录到管理日志 with open(records.csv, a, encodingutf-8) as f: f.write(f{path},{plate_text}\n) except Exception as exc: messagebox.showerror(识别失败, str(exc))这里值得注意的反而是异常处理。模型推理过程中有些图片里根本没有车牌locate_plate返回None如果core.py没有对这个情况做分支判断程序会直接抛TypeError。所以识别接口在定位失败时应该返回一个类似未检测到车牌的提示而不是让 GUI 崩溃。4.2 视频识别的拆帧-识别-合帧链路视频识别入口在vidGUI.py。视频文件不能像图片那样直接整帧交给模型处理原因很简单模型推理速度跟不上视频帧率。demo_car.mp4这类测试视频通常是 30fps而 CPU 上跑完定位加分割加识别一帧可能耗时 300 到 500 毫秒根本做不到实时。所以vidGUI.py的视频处理链路是三步走videospilt.py用cv2.VideoCapture逐帧读取视频按skip_frame参数隔 N 帧保存一张为 JPG 图片到frames/目录。skip_frame的目的就是降低待处理帧数比如 30fps 的视频隔 3 帧取 1 帧实际只处理 10fps。对frames/下每张图片批量调用识别逻辑把识别结果连同帧序号记录到结果文件里。imgtovid.py用cv2.VideoWriter把带识别框标注的帧重新合成视频。imgtovid.py里最容易出错的是VideoWriter的编码参数import cv2 def frames_to_video(frame_dir, output_path, fps30): fourcc cv2.VideoWriter_fourcc(*mp4v) # 读取第一帧获取尺寸 first cv2.imread(f{frame_dir}/0001.jpg) h, w first.shape[:2] writer cv2.VideoWriter(output_path, fourcc, fps, (w, h)) for i in range(1, len(os.listdir(frame_dir)) 1): frame cv2.imread(f{frame_dir}/{i:04d}.jpg) writer.write(frame) writer.release()fourcc选用mp4v是因为它兼容性好打开软件多。如果你用XVID而输出文件后缀是.mp4部分播放器会无法加载。另一个参数是fps合成视频的帧率必须与源视频保持一致否则整段视频的播放速度会失真源视频的帧率可以通过cv2.VideoCapture.get(cv2.CAP_PROP_FPS)读取。拆帧和合帧之间的中间文件命名必须使用固定宽度数字序号否则按字符串排序时2.jpg会排在10.jpg后面导致合帧时画面顺序错乱。4.3 识别结果管理的业务化改造“管理系统”在这套代码里并不复杂核心就是 CSV 记录和分类统计。但在毕设答辩里你可以把这一部分扩展成更完整的维度识别时间、车牌号、车辆驶入方向、车辆颜色。如果拿到的是停车场场景的原始视频可以考虑加入一个简单的按车牌号去重的逻辑——同一辆车连续多帧被识别到时只记录第一次出现的帧号和时间这样输出到 Excel 里的就是“进车记录”而不是几百条重复横幅。下面是一个简单的去重记录实现seen_plates set() records [] for frame_info in recognize_results: plate, frame_id, timestamp frame_info if plate not in seen_plates: seen_plates.add(plate) records.append({plate: plate, first_frame: frame_id, time: timestamp})关键点在于recognize_results中如果有一帧识别错误、把“京A12345”误识别成“京A12346”这个错误车牌会被当成新车辆记录进去。所以实际应用时要加一个确认机制同一字符结果连续出现在 3 帧以上才认为是有效车牌。这个阈值叫作确认帧数在vidGUI.py里通常用一个计数器列表实现每帧识别结果与上一帧比对连续命中则计数加一达到阈值再写入管理记录。4.4 整套系统运行流程与参数速查把项目根目录的文件按执行顺序整理可以得到下面这张速查表它同时也是你跑通这套代码的步骤清单执行顺序文件作用输入输出1train.py训练字符分类模型dataset/train/分类目录cnn.h52Unet.py定义分割模型结构无配合训练脚本unet.h53core.py识别全流程封装图片路径车牌字符串4imgGUI.py图片识别界面用户选择图片GUI 窗口展示5vidGUI.py视频识别界面选择视频文件合成标注视频6videospilt.py视频拆帧demo_car.mp4frames/帧序列7imgtovid.py帧序列合成视频frames/目录result.mp4在真正动手前用使用说明.txt核对一下cnn.h5和unet.h5这两个权重文件是否与代码里的网络结构匹配。方法很简单在CNN.py的脚本末尾加一行model.summary()看看参数总量再用h5py打开cnn.h5读取每一层的 shape对比一下是否一致。权重与网络结构不匹配时load_model会直接抛ValueError这是最直观的报错信号。5. 踩坑记录与调优技巧让这套代码在你自己数据上跑得更好5.1 环境配置与运行顺序很多拿到代码的同学第一反应是直接跑GUI.py但在这之前确认环境版本是更优先的事。这套代码基于 TensorFlow 1.x 或 2.x 的旧 API 编写fit_generator、ModelCheckpoint等接口在 TensorFlow 2.15 之后被移除或调整。如果你安装的是最新版 TensorFlowtrain.py大概率会报AttributeError: Model object has no attribute fit_generator。常见做法是把fit_generator改成fitflow_from_directory的接口保持兼容可以直接用。同时 H5 模型文件的加载也存在跨版本兼容性问题我的经验是优先尝试keras.models.load_model(cnn.h5)如果报自定义层相关错误就改为load_model(cnn.h5, compileFalse)再用model.predict验证输出形状。5.2 颜色空间与通道顺序的双重陷阱我在第二节提过 BGR 和 RGB 的通道问题这里再强调一次它的严重性。cv2.imread读入的图像是 BGR 顺序而训练cnn.h5时ImageDataGenerator加载的图片是 RGB 顺序。这意味着如果你的core.py没有在预处理阶段做cv2.cvtColor(img, cv2.COLOR_BGR2RGB)送入 CNN 的每个字符图片的红蓝通道是颠倒的模型输出的字符大概率随机错误。但有一个迷惑点cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)得到的 HSV 空间不受这个错误影响所以定位环节依然正常字符分割环节用的是灰度图也不受影响最终只有 CNN 分类环节出错。这种“前面全对、后面全错”的特征排查起来反而比直接报错更耗时间。调试时可以单独打印cnn.h5输入前的数组通道用img[:, :, 0]的均值判断红色还是蓝色通道占主导。5.3 调整分割链路时最重要的一条调试技巧如果识别结果里某个汉字频繁出错比如“津”被识别成“沪”问题大概率不在 CNN 而在分割字符子图里混入了背景噪声或者字符被切掉了左边一竖。打开unet.h5输出的概率图用matplotlib的imshow可视化一下看看字符区域和概率高于阈值区域的边界是否吻合。我一般会在core.py的推理接口里临时加一句cv2.imwrite(debug_mask.png, mask * 255)然后把分割得到的每个字符子图保存成独立文件。这一步能让定位、分割、识别三个环节的失误责任一目了然。需要微调时优先改 UNet 概率图的二值化阈值从默认的 0.5 调低到 0.3 或 0.35字符粘连的问题通常会先得到缓解——因为概率稍低的笔画区域也能被保留下来避免字符被拦腰切断。本文还有配套的精品资源点击获取