【项目009】基于卷积神经网络的车牌识别(学生版) 显示答案 | 返回首页

作者:欧新宇(Xinyu OU)
当前版本:Release v2.0
开发平台:Paddle 2.5.2
运行环境:Intel Core i7-7700K CPU 4.2GHz, nVidia GeForce GTX 1080 Ti
本教案所涉及的数据集仅用于教学和交流使用,请勿用作商用。

最后更新:2024年5月17日


车牌识别(License Plate Recognition, LPR) 是计算机视觉领域的一个重要应用,广泛应用于智能交通系统、停车场管理、车辆追踪等领域。本案例教学将通过构建一个简单的车牌识别系统,来介绍使用卷积神经网络(Convolutional Neural Network, CNN)进行车牌识别的基本原理和实现步骤。本教学案例可以直接作为常规教学的实训课进行开设,建议4-8学时。

本案例主要包括下图所示五个部分,分别是数据集准备、全局参数定义及数据准备、训练与评估、推理预测及结果分析。

VehicleLicenseExamples

【实验目的】

  1. 学会对下载的数据集进行初步整理,包括处理非法文件名、创建数据列表
  2. 学会将数据集划分为训练集、验证集和测试集
  3. 学会按照神经网络的设计要求创建神经网络类
  4. 学会使用mini-batch方法实现卷积神经网络的训练并进行预测
  5. 学会保存模型,并使用保存的模型进行预测(即应用模型到生产环境)
  6. 学会使用函数化编程方法完成卷积神经网络的训练和测试
  7. 学会在已有代码的基础上完成新任务的迭代

【实验要求】

  1. 所有作业均在AIStudio上进行提交,提交时包含源代码和运行结果
  2. 补全数据列表生成代码(Q1:10分
  3. 补全数据集类的定义代码(Q2:10分
  4. 按照给定的网络体系结构图设计卷积神经网络并补全网络参数配置表和神经网络类的定义(Q3:10分, Q4:10分
  5. 使用训练集训练模型,并在训练过程中输出验证集精度Q5:20分, Q6:10分
  6. 使用训练好的模型在测试集上输出测试精度Q7:10分
  7. 使用训练好的模型对给定的车牌进行预测,尽力而为地获得最优的预测结果(Q8:20分

【实验一】 数据集准备

实验摘要: 对于模型训练的任务,需要数据预处理,将数据整理成为适合给模型训练使用的格式。车牌识别的项目是一个多分类的任务,数据集中有65个文件夹,总共1602张图片,包含从0-9,A-Z,以及各省简称的图片。每个图片都是1×20×20的灰度图像,我们需要将图片读入,并按照1:9划分测试集和训练集。

实验目的:

  1. 学会观察数据集的文件,并实现基本的数据清理方法,包括删除无法读取的样本、处理冗长不合规范的文件命名等
  2. 能够按照训练集、验证集、训练验证集、测试集四种子集对数据集进行划分,并生成数据列表
  3. 能简单展示和预览数据的基本信息,包括数据量,规模,数据类型和位深度等

1.1 数据集介绍

VehicleLicense 车牌识别数据集包含16151张单字符数据,所有的单字符均为严格切割且都转换为黑白二值图像(如下第一行:训练数据所示)。真实检测的数据如下图(第二行:原始车牌)所示。第三行处理后的车牌是根据真实检测的车牌进行精致编辑,总共包含8幅720×170的测试样本(test01-08)。

注意:由于本例中的测试代码并没有包含严格图像分割及预处理代码,因此无法很好识别原始车牌及非标准车牌(标准车牌为蓝底白字,光线充足),此例仅供简单验证。

数据集中包含三个文件夹:Data, Infer, Infer0。其中dataset为训练验证测试数据,Infer为处理后的车牌,Infer0为原始车牌。

VehicleLicenseExamples

1.2 数据集预处理

在很多时候,下载好的数据集无法直接使用,需要对其进行一定的预处理,并且这种预处理并没有通用的代码。但大体上可以分为以下几点:

值得注意的是,与数据预处理需要在每次训练和测试前执行不同。由于上面所描述的关于数据集的预处理并不需要多次执行,这主要是因为:

  1. 只需要执行一次即可生成相应的内容;
  2. 这些处理可能会比较耗时。
    因此该类代码通常是独立于模型训练,只需要完成一次即可。

1.2.1 数据清洗(处理数据集中样本命名的非法字符)

原始的数据集的名字有可能会存在特殊的命名符号,从而导致在某些情况下无法正确识别。因此,可以通过批量改名的重命名方式来解决该问题。

##################################################################################
# 数据清理
# 作者: Xinyu Ou (http://ouxinyu.cn)
# 数据集名称:车牌识别数据集
# 数据集简介: VehicleLicense车牌识别数据集包含16151张单字符数据,所有
# 的单字符均为严格切割且都转换为黑白二值图像。
# 本程序功能:
# 1. 对样本文件进行改名,屏蔽特殊命名符号对训练的影响
###################################################################################

import os
dataset_root_path = 'D:\\WorkSpace\\ExpDatasets\\VehicleLicense'

data_path = os.path.join(dataset_root_path, 'Data')
character_folders = os.listdir(data_path)

num_image = 0 
for character_folder in character_folders:
    character_imgs = os.listdir(os.path.join(data_path, character_folder))
    
    id = 0
    for character_img in character_imgs:
        newname = character_folder + '_' + str(id).rjust(4,'0') + os.path.splitext(character_img)[1]
        os.rename(os.path.join(data_path, character_folder, character_img), 
                  os.path.join(data_path, character_folder, newname))
        id += 1
        num_image += 1

    print('\r 已完成{}副图片的改名'.format(num_image), end='')

已完成16151副图片的改名, 已完成。

1.2.2 生成数据列表

若官方数据集没有数据集的划分列表,或者数据集为自建数据集,则需要手动生成数据集的划分,一般包括训练集、验证集和测试集。

值得注意的是,在进行数据划分的时候要注意类别的均衡性,处理的方法主要有两种。一是,对所有样本进行打乱,再进行划分;二是,顺序遍历不同类别的文件夹,然后均匀划分。下面的代码属于第二种。有兴趣的同学可以尝试第一种方法。例如在1.2.1节改名的时候,就收集文件名,并进行打乱。

Q1: 补全下列代码,实现将数据集按照7:1:2的比例分为训练集train, 验证集val 和测试集test(10分) ([Your codes 1~3])

##################################################################################
# 数据集预处理
# 作者: Xinyu Ou (http://ouxinyu.cn)
# 数据集名称:车牌识别数据集
# 数据集简介: VehicleLicense车牌识别数据集包含16151张单字符数据,所有的单字符均为严格切割且都转换为黑白二值图像。
# 本程序功能:
# 1. 将数据集按照7:1:2的比例划分为训练验证集、训练集、验证集、测试集
# 2. 代码将生成4个文件:训练验证集trainval.txt, 训练集列表train.txt, 验证集列表val.txt, 测试集列表test.txt, 数据集信息dataset_info.json
# 3. 代码输出信息:图像列表已生成, 其中训练验证集样本12877,训练集样本11232个, 验证集样本1645个, 测试集样本3274个, 共计16151个。
# 4. 生成数据集标签词典时,需要根据标签-文件夹列表匹配标签列表
###################################################################################

import os
import json
import codecs

num_trainval = 0
num_train = 0
num_val = 0
num_test = 0
class_dim = 0
dataset_info = {
    'dataset_name': '',
    'num_trainval': -1,
    'num_train': -1,
    'num_val': -1,
    'num_test': -1,
    'class_dim': -1,
    'label_dict': {}
}
# 本地运行时,需要修改数据集的名称和绝对路径,注意和文件夹名称一致
dataset_name = 'VehicleLicense'
dataset_path = 'D:\\Workspace\\ExpDatasets\\'
dataset_root_path = os.path.join(dataset_path, dataset_name)
excluded_folder = ['.DS_Store', '.ipynb_checkpoints']      # 被排除的文件夹

# 获取标签和文件夹的对应关系,即省市拼音和中文对照关系
json_label_match = os.path.join(dataset_root_path, 'label_match.json')
label_match = json.loads(open(json_label_match, 'r', encoding='utf-8').read()) 
    
# Q1-1: 补全下列代码实现数据集列表路径的路径定义和数据集信息文件的路径定义
# [Your codes 1]
data_path = os.path.join(dataset_root_path, 'Data')
trainval_list = 
train_list = 
val_list = 
test_list = 
dataset_info_list = 

# 检测数据集列表是否存在,如果存在则先删除。其中测试集列表是一次写入,因此可以通过'w'参数进行覆盖写入,而不用进行手动删除。
if os.path.exists(trainval_list):
    os.remove(trainval_list)
if os.path.exists(train_list):
    os.remove(train_list)
if os.path.exists(val_list):
    os.remove(val_list)
if os.path.exists(test_list):
    os.remove(test_list)

# Q1-2:补全下列代码,按照7:2:1的比例将数据集划分为训练集train、验证集val、测试集test和训练验证集trainval
# [Your codes 2]
class_name_list = 
with codecs.open(trainval_list, 'a', 'utf-8') as f_trainval:
    with codecs.open(train_list, 'a', 'utf-8') as f_train:
        with codecs.open(val_list, 'a', 'utf-8') as f_val:
            with codecs.open(test_list, 'a', 'utf-8') as f_test:





        

# Q1-3: 将程序运行的相关结果保存到数据集信息json文件中
# [Your codes 3]
dataset_info['dataset_name'] = 
dataset_info['num_trainval'] = 
dataset_info['num_train'] = 
dataset_info['num_val'] = 
dataset_info['num_test'] = 
dataset_info['class_dim'] = 

with codecs.open(dataset_info_list, 'w', encoding='utf-8') as f_dataset_info:
    json.dump(dataset_info, f_dataset_info, ensure_ascii=False, indent=4, separators=(',', ':')) # 格式化字典格式的参数列表

print("图像列表已生成, 其中训练验证集样本{},训练集样本{}个, 验证集样本{}个, 测试集样本{}个, 共计{}个。".format(num_trainval, num_train, num_val, num_test, num_train+num_val+num_test))

图像列表已生成, 其中训练验证集样本12877,训练集样本11232个, 验证集样本1645个, 测试集样本3274个, 共计16151个。

【实验二】 全局参数设置及数据准备

实验摘要: 车牌识别是一个多分类问题,我们通过卷积神经网络来完成。这部分通过PaddlePaddle构造一个LeNet卷积神经的网络,最后一层采用Softmax激活函数完成分类任务。

实验目的:

  1. 学会使用配置文件定义全局参数
  2. 学会设置和载入数据集
  3. 学会对输入样本进行基本的预处理
  4. 学会定义可视化函数,可视化训练过程

2.1 导入依赖及全局参数设置

# 1. 导入依赖库
import os
import cv2
import numpy as np
import codecs
import json
import time                                     # 载入time时间库,用于计算训练时间
import paddle as paddle                         # 载入PaddlePaddle基本库
import matplotlib.pyplot as plt                 # 载入matplotlib绘图库
import warnings  
warnings.filterwarnings("ignore", category=UserWarning)
plt.rcParams['font.family'] = 'sans-serif'  
plt.rcParams['font.sans-serif'] = 'SimHei,Times New Roman'# 中文设置成宋体,除此之外的字体设置成New Roman 
np.set_printoptions(precision=5, suppress=True) # 设置numpy的精度,用于打印输出

# 2. 全局参数配置
project_name      = 'Project009CNNVehicleLicense'                                               # 定义项目名称(用于存储时的标识)
dataset_name      = 'VehicleLicense'                                                         # 定义数据集名称

# 2.1 定义数据集列表文件及模型路径
dataset_path      = 'D:\\WorkSpace\\ExpDatasets\\'                                           # 定义数据集根路径
dataset_root_path = os.path.join(dataset_path, dataset_name)                                 # 定义本项目数据集路径

trainval_list = os.path.join(dataset_root_path, 'trainval.txt')                              # 定义训练验证集列表
train_list = os.path.join(dataset_root_path, 'train.txt')                                    # 定义训练集列表
val_list = os.path.join(dataset_root_path, 'val.txt')                                        # 定义验证集列表
test_list = os.path.join(dataset_root_path, 'test.txt')                                      # 定义测试集列表

result_root_path  = 'D:\\WorkSpace\\ExpResults\\'
result_root_path  = os.path.join(result_root_path, project_name)                             # 定义结果保存路径
final_models_path = os.path.join(result_root_path, 'final_models', 'best_model')    # 定义模型的保存的路径
final_figures_path = os.path.join(result_root_path, 'final_figures')                         # 定义可视化图的输出路径

# 2.2 图像基本信息
img_size = 20
img_channel = 1

# 2.3 训练参数定义
total_epoch        = 20       # 总迭代次数, 代码调试好后考虑Epochs_num = 50
log_interval       = 100      # 训练时显示训练日志的间隔
eval_interval      = 1        # 设置在训练过程中,每隔一定的周期进行一次测试
learning_rate      = 0.001    # 学习率
momentum           = 0.9      # 动量(Momentum方法时使用)
BATCH_SIZE         = 64       # 设置每个批次的数据大小,同时对训练提供器和测试提供器有效

2.2 定义数据集类

通过集成飞桨内置的 paddle.io.Dataset 实现数据集类的定义,包括数据列表读取和数据预处理。

Q2:完成数据集类定义的部分代码(10分) ([Your codes 4~7])

import paddle.vision.transforms as T
from paddle.io import DataLoader

# 1. 数据集的定义
class Dataset(paddle.io.Dataset):
    def __init__(self, dataset_root_path, mode='test'):
        assert mode in ['train', 'val', 'test', 'trainval']
        self.data = []                              # 创建空列表文件,用于保存数据的路径和标签
        
        # Q2-1 读取数据集列表文件,并将路径路径和标签进行拆分,其中测试集若不存在标签则复制为"-1"
        # [Your codes 4]




        
        # Q2-2 使用transform接口定义数据预处理,本例需要规范图像尺度为[20,20], 并将图像转换为Paddle要求的Tensor
        # [Your codes 5]



    
    # 根据索引获取单个样本
    def __getitem__(self, index):
        # Q2-3: 对self.data变量进行拆分,划分为图像路径及标签,并按照路径进行图像载入
        # [Your codes 6]





        return img, label
                
    # Q2-4 获取样本总数
    # [Your codes 7]    


2.3 设置训练和测试数据提供器

对于要使用的所有数据均需要设置数据提供器,本例我们给出基于训练集、验证集和测试集和训练验证集划分的设置。

# 1. 实例化数据类
dataset_train = VehicleLicenseDataset(dataset_root_path, mode='train')
dataset_val = VehicleLicenseDataset(dataset_root_path, mode='val')
dataset_trainval = VehicleLicenseDataset(dataset_root_path, mode='trainval')
dataset_test = VehicleLicenseDataset(dataset_root_path, mode='test')

# 2. 创建迭代读取器
# 使用paddle.io.DataLoader 定义DataLoader对象用于加载Python生成器产生的数据,
# DataLoader 返回的是一个批次数据迭代器,并且是异步的。
train_reader = DataLoader(dataset_train, batch_size=64, shuffle=True, drop_last=True)
val_reader = DataLoader(dataset_val, batch_size=64, shuffle=False, drop_last=False)
trainval_reader = DataLoader(dataset_trainval, batch_size=64, shuffle=True, drop_last=True)
test_reader = DataLoader(dataset_test, batch_size=64, shuffle=False, drop_last=False)

#####################################################################################################
# 数据迭代器测试
# 1. 输出数据集的基本情况
print('数据集包含训练数据{}个,验证数据{}个,训练验证集{}个,测试数据{}个。'.format(len(dataset_train),len(dataset_val),len(dataset_trainval),len(dataset_test)))
print('数据的形态为:{}'.format(dataset_val[0][0].shape)) 

# 2. 迭代的读取数据并打印数据的形状
for i, (img, label) in enumerate(val_reader()):
    if i > 2:
        break
    print('验证集batch_{}的图像形态:{}, 标签形态:{}'.format(i, img.shape, label.shape))
数据集包含训练数据11232个,验证数据1645个,训练验证集12877个,测试数据3274个。
数据的形态为:[1, 20, 20]
验证集batch_0的图像形态:[64, 1, 20, 20], 标签形态:[64]
验证集batch_1的图像形态:[64, 1, 20, 20], 标签形态:[64]
验证集batch_2的图像形态:[64, 1, 20, 20], 标签形态:[64]

2.4 定义过程可视化函数

定义训练过程中用到的可视化方法, 包括训练损失, 训练集批准确率, 测试集准确率. 根据具体的需求,可以在训练后展示这些数据和迭代次数的关系. 值得注意的是, 训练过程中可以每个epoch绘制一个数据点,也可以每个batch绘制一个数据点,也可以每个n个batch或n个epoch绘制一个数据点.

def draw_process_ch6(visualization_log, show_top5=False, figure_path=final_figures_path, figurename='visualization_log', isShow=True):
    """绘制训练过程中的训练误差、训练精度、验证误差和验证精度四个重要输出"""
    train_losses = visualization_log['train_losses']        # 训练集的损失值
    train_accs_top1 = visualization_log['train_accs_top1']  # 训练集的top1精确度
    train_accs_top5 = visualization_log['train_accs_top5']  # 训练集的top5精确度
    val_losses = visualization_log['val_losses']            # 验证集的损失值
    val_accs_top1 = visualization_log['val_accs_top1']      # 验证集的精确度
    val_accs_top5 = visualization_log['val_accs_top5']      # 验证集的精确度
    epoch_iters = visualization_log['epoch_iters']          # 周期epoch迭代次数
    batch_iters = visualization_log['batch_iters']          # 批次batch迭代次数

    # 第一组坐标轴 Loss
    _, ax1 = plt.subplots()
    ax1.plot(batch_iters, train_losses, color='orange', linestyle='--', label='train_loss')
    ax1.plot(epoch_iters, val_losses, color='cyan', linestyle='--', label='val_loss')
    ax1.set_xlabel('Iters', fontsize=16)
    ax1.set_ylabel('Loss', fontsize=16)
    max_loss = max(max(train_losses), max(val_losses))
    ax1.set_ylim(0, max_loss*1.2)

    # 第二组坐标轴 accuracy
    ax2 = ax1.twinx()
    ax2.plot(epoch_iters, train_accs_top1, 'o-', color='red', markersize=3, label='train_accuracy(top1)')
    ax2.plot(epoch_iters, val_accs_top1, 'o-', color='blue', markersize=3, label='val_accuracy(top1)')
    if show_top5==True:
        ax2.plot(epoch_iters, train_accs_top5, 'o-', color='magenta', markersize=3, label='train_accuracy(top5)')
        ax2.plot(epoch_iters, val_accs_top5, 'o-', color='pink', markersize=3, label='val_accuracy(top5)')
    ax2.set_ylabel('Accuracy', fontsize=16)
    max_accs = max(max(train_accs_top1), max(train_accs_top5), max(val_accs_top1), max(val_accs_top5))
    ax2.set_ylim(0, max_accs*1.2)

    # 3.配置图例
    plt.title('Training and Validation Results', fontsize=18)  
    handles1, labels1 = ax1.get_legend_handles_labels()    # 图例1
    handles2, labels2 = ax2.get_legend_handles_labels()    # 图例2
    plt.legend(handles1+handles2, labels1+labels2, loc='best')
    plt.grid()
    
    # 4.将绘图结果保存到 final_figures 目录
    plt.savefig(os.path.join(figure_path, figurename + '.png'))
    
    # 5.显示绘图结果
    if isShow is True:
        plt.show()

### 测试可视化函数 ###################################################
if __name__ == '__main__':
    try:
        log_file = json.loads(open(os.path.join(final_figures_path, 'visualization_log.json'), 'r', encoding='utf-8').read())
        draw_process_ch6(log_file, show_top5=True)
    except:
        print('数据不存在,无法进行绘制')    

png

【实验三】 模型训练与评估

实验摘要: 车牌识别是一个多分类问题,我们通过卷积神经网络来完成。这部分通过PaddlePaddle构造一个LeNet卷积神经的网络,最后一层采用Softmax激活函数完成分类任务。

实验目的:

  1. 掌握卷积神经网络的构建和基本原理
  2. 深刻理解训练集、验证集、训练验证集及测试集在模型训练中的作用
  3. 学会在线测试和离线测试两种测试方法

3.1 配置网络

3.1.1 网络拓扑结构图

Project00901NetworkArchitecture

3.1.2 网络参数配置表

Q3: 根据拓扑结构图补全网络参数配置表(10分)

Layer Input Kernels_num Kernels_size Stride Padding PoolingType Output Parameters
Input 1×20×20 - - - - - -
Conv1 - - - 1 0 - - -
Pool1 - - - 1 0 max - 0
Conv2 - - - 1 0 - - -
Pool2 - - - 1 0 max - 0
Conv3 - - - 1 0 - - -
FC1 - - - - - - - -
Output - - - - - - 65×1 -
- - - - - - - - Total = 226137

3.1.3 定义神经网络类

Q4:根据网络拓扑结构图和网络参数配置表完成神经网络类的定义(10分)([Your codes 8~10])

from paddle.nn import Sequential, Conv2D, MaxPool2D, Linear, ReLU

# 定义多层感知机(CNN)
class myCNN(paddle.nn.Layer):
    name_scope = 'myCNN'
    def __init__(self, num_classes=65): # 初始化CNN类,并为CNN增加对象self.x
        super(myCNN, self).__init__()
        # Conv2D(in_channels, out_channels, kernel_size, stride: int = 1, padding: int = 0)

        # Q3-1:定义模型的卷积、激活、池化层及全连接层
        # [Your codes 8]
        self.features = Sequential(



        )

        self.fc = Sequential(



        )

    # Q3-2: 完成前向传输方法的定义
    # [Your codes 9]
    def forward(self, input):              # 为CNN类增加forward方法



        return y
    
############################################################################
# 模型测试
if __name__ == '__main__':
    # Q3-3:完成模型测试代码的定义
    # [Your codes 10]



    ---------------------------------------------------------------------------
     Layer (type)       Input Shape          Output Shape         Param #    
    ===========================================================================
       Conv2D-1      [[10, 1, 20, 20]]     [10, 28, 16, 16]         728      
        ReLU-1       [[10, 28, 16, 16]]    [10, 28, 16, 16]          0       
      MaxPool2D-1    [[10, 28, 16, 16]]    [10, 28, 15, 15]          0       
       Conv2D-2      [[10, 28, 15, 15]]    [10, 32, 13, 13]        8,096     
        ReLU-2       [[10, 32, 13, 13]]    [10, 32, 13, 13]          0       
      MaxPool2D-2    [[10, 32, 13, 13]]    [10, 32, 12, 12]          0       
       Conv2D-3      [[10, 32, 12, 12]]    [10, 32, 10, 10]        9,248     
        ReLU-3       [[10, 32, 10, 10]]    [10, 32, 10, 10]          0       
       Linear-1         [[10, 3200]]           [10, 65]           208,065    
    ===========================================================================
    Total params: 226,137
    Trainable params: 226,137
    Non-trainable params: 0
    ---------------------------------------------------------------------------
    Input size (MB): 0.02
    Forward/backward pass size (MB): 3.24
    Params size (MB): 0.86
    Estimated Total Size (MB): 4.12
    ---------------------------------------------------------------------------

3.2 模型训练及评估

3.2.1 定义测试函数

在飞桨2.0+的环境中,模型验证/测试使用 model.eval_batch() 方法实现,一般包含如下几个步骤:

  1. 定义输入数据的维度。输入数据的来源包含两种,一种是通过数据集类获取,此时不需要再次显示定义维度;一种是从外部获取数据,此时需要再次显示定义数据维度,Paddle中可以使用InputSpec来实现。图像输入维度 [None, channel, Width, Height],标签输入维度 [batch, 1],其中None用于匹配batch信息。
  2. 实例化模型,并载入参数。若需要输出损失值或评价指标,则还需要为模型配置损失函数和Metric函数。并且,模型需要以调优模式进行载入,而不是推理模型。
  3. 调用 model.eval_batch() 方法,并载入 [image][label] 作为输入执行前向出传输。模型的输出为 model.prepare() 方法所指定的评价指标,一般包括损失值、top1和top5精度。
  4. 累加批次输出的结果,计算平均精度和平均损失。

此外,在定义eval()函数的时候,我们需要为其指定两个参数:model是测试的模型,data_reader是迭代的数据读取器,取值为val_reader(), test_reader(),分别对验证集和测试集。此处验证集和测试集数据的测试过程是相同的,只是所使用的数据不同。

from paddle.static import InputSpec

def eval(model, data_reader, verbose=0):
    acc_top1 = []
    acc_top5 = []
    losses = []
    n_total = 0

    for batch_id, (image, label) in enumerate(data_reader):#测试集
        n_batch  = len(label)
        n_total = n_total + n_batch
        label = paddle.unsqueeze(label, axis=1)      # 将图像转换为4D张量

        loss, acc = model.eval_batch([image], [label])
        losses.append(loss[0]*n_batch)
        acc_top1.append(acc[0][0]*n_batch)
        acc_top5.append(acc[0][1]*n_batch)

        avg_loss = np.sum(losses)/n_total                 # loss 记录的是当前batch的累积值
        avg_acc_top1 = np.sum(acc_top1)/n_total    # metric 是当前batch的平均值
        avg_acc_top5 = np.sum(acc_top5)/n_total
        
    return avg_loss, avg_acc_top1, avg_acc_top5

##############################################################     
if __name__ == '__main__':
    try:
        # 设置输入样本的维度    
        input_spec = InputSpec(shape=[None, img_channel, img_size, img_size], dtype='float32', name='image')
        label_spec = InputSpec(shape=[None, 1], dtype='int64', name='label')

        # 载入模型

        network = myCNN() 
        model = paddle.Model(network, input_spec, label_spec)                   # 模型实例化
        model.load(final_models_path)                                # 载入调优模型的参数
        model.prepare(loss=paddle.nn.CrossEntropyLoss(),                        # 设置loss
                      metrics=paddle.metric.Accuracy(topk=(1,5)))               # 设置评价指标
    
        # 执行评估函数,并输出验证集样本的损失和精度
        print('开始评估...')
        avg_loss, avg_acc_top1, avg_acc_top5 = eval(model, val_reader(), verbose=1)
        print('\r [验证集] 损失: {:.5f}, top1精度:{:.5f}, top5精度为:{:.5f} \n'.format(avg_loss, avg_acc_top1, avg_acc_top5), end='')    
        avg_loss, avg_acc_top1, avg_acc_top5 = eval(model, test_reader(), verbose=1)
        print('\r [测试集] 损失: {:.5f}, top1精度:{:.5f}, top5精度为:{:.5f}'.format(avg_loss, avg_acc_top1, avg_acc_top5), end='')
    except:
        print('数据不存在跳过测试')
开始评估...
 [验证集] 损失: 1.18715, top1精度:0.92766, top5精度为:0.97325 
 [测试集] 损失: 1.03287, top1精度:0.93128, top5精度为:0.97404

3.2.2 定义训练函数

在飞桨2.0+中,动态图模式是默认模式,所有的训练测试代码都需要基于动态图进行创建。由于是默认模式,因此不需要再像1.8版本中一样使用守护进程进行启用。

训练部分的具体流程,与验证部分大体相同,主要包括如下几个部分:

  1. 定义输入数据的维度。输入数据的来源包含两种,一种是通过数据集类获取,此时不需要再次显示定义维度;一种是从外部获取数据,此时需要再次显示定义数据维度,Paddle中可以使用InputSpec来实现。图像输入维度 [None, channel, Width, Height],标签输入维度 [batch, 1],其中None用于匹配batch信息。
  2. 实例化模型,并载入参数。在训练中除了损失函数和Metric函数是必须定义的,还需要定义优化器算法。此时,我们可以通过 model.prepare() 实现定义。
  3. 调用 model.train_batch() 方法,并载入 [image][label] 作为输入执行前向出传输。在飞桨2.0+中,反向求导部分不需要进行显示定义,train_batch()会自动执行。
  4. 累加批次输出的结果,计算平均精度和平均损失。

此外,在训练过程中,我们可以每个一定的周期调用一次验证函数 eval(),来对验证集进行测试。一般来说,每个epoch都可以进行一次验证。另外,可视化也训练和验证的loss和accuracy也是训练中常用的模型选择方法。在训练过程中,可以将周期,批次,损失及精度等信息打印到屏幕。

在本项目中,我们在训练中,每100个batch之后会输出一次平均训练误差和准确率;每一轮训练之后,使用测试集进行一次测试,在每轮测试中,均打输出一次平均测试误差和准确率。

【注意】注意在下列的代码中,我们每个epoch都会判断当前的模型是否是最优模型,如果是最优模型,我们会进行一次模型保存,并将其名命名为 best_model。对于复杂的模型和大型数据集上,我们通常还会在每个周期训练结束后都保存一个 checkpoint_model。这种经常性的模型保存,有利于我们执行EarlyStopping策略,并回退到任意一个时间节点。也便于当我们发现运行曲线不再继续收敛时,就可以结束训练。

Q5. 完成下列模型训练函数的主体部分(20分) ([Your codes 11~13])

from paddle.static import InputSpec
import paddle.optimizer as optimizer

visualization_log = {            # 初始化状态字典
    'train_losses': [],          # 训练损失值
    'train_accs_top1': [],       # 训练top1精度
    'train_accs_top5': [],       # 训练top5精度
    'val_losses': [],            # 验证损失值
    'val_accs_top1': [],         # 验证top1精度
    'val_accs_top5': [],         # 验证top5精度
    'batch_iters': [],           # 批次batch迭代次数
    'epoch_iters': [],           # 周期epoch迭代次数
}

def train(model):
    print('启动训练...')
    start = time.perf_counter()
    num_batch = 0
    best_result = 0
    best_result_id = 0
    elapsed =0

    for epoch in range(1, total_epoch+1):
        for batch_id, (image, label) in enumerate(train_reader()):
            num_batch += 1
            
            # Q5-1. 调用model.train_batch方法进行训练,注意需要对label的尺度进行规范化
            # [Your codes 11]
            label =                        
            loss, acc = 
            
            if num_batch % log_interval == 0: # 每10个batch显示一次日志,适合大数据集  
                # Q5-2. 从训练的输出中获取损失值,top1精度和top5精度
                # [Your codes 12] 
                avg_loss = 
                acc_top1 = 
                acc_top5 = 
                
                elapsed_step = time.perf_counter() - elapsed - start
                elapsed = time.perf_counter() - start
                print('Epoch:{}/{}, batch:{}, train_loss:[{:.5f}], acc_top1:[{:.5f}], acc_top5:[{:.5f}]({:.2f}s)'
                            .format(epoch, total_epoch, num_batch, loss[0][0], acc[0][0], acc[0][1], elapsed_step))


                # 记录训练过程,用于可视化训练过程中的loss和accuracy
                visualization_log['train_losses'].append(float(avg_loss))
                visualization_log['batch_iters'].append(num_batch)                          

        # 每隔一定周期进行一次测试
        if epoch % eval_interval == 0 or epoch == total_epoch:
            # 模型校验
            val_loss, val_acc_top1, val_acc_top5 = eval(model, val_reader())            
            print('[validation] Epoch:{}/{}, val_loss:[{:.5f}], val_top1:[{:.5f}], val_top5:[{:.5f}]'.format(epoch, total_epoch, val_loss, val_acc_top1, val_acc_top5))
            
            # 记录测试过程,用于可视化训练过程中的loss和accuracy
            visualization_log['epoch_iters'].append(num_batch)
            visualization_log['train_accs_top1'].append(float(acc_top1))
            visualization_log['train_accs_top5'].append(float(acc_top5))
            visualization_log['val_losses'].append(float(val_loss))
            visualization_log['val_accs_top1'].append(float(val_acc_top1))
            visualization_log['val_accs_top5'].append(float(val_acc_top5))

            # Q5-3. 将性能最好的模型保存为final模型,注意同时保存调优模型和推理模型
            # model.save(<path>, training=False|True),True:调优模型 | False:推理模型
            # [Your codes 13] 




 
    # 输出训练过程数据,将日志字典保存为json格式,绘图数据可以在训练结束后自动显示,也可以在训练中手动执行以显示结果
    if not os.path.exists(final_figures_path):
        os.makedirs(final_figures_path)

    with codecs.open(os.path.join(final_figures_path, 'visualization_log.json'), 'w', encoding='utf-8') as f_log:
        json.dump(visualization_log, f_log, ensure_ascii=False, indent=4, separators=(',', ':'))

    print('训练完成,最终性能accuracy={:.5f}(epoch={}), 总耗时{:.2f}s, 已将其保存为:best_model'.format(best_result, best_result_id, time.perf_counter() - start))

3.2.3 训练主函数

Q6. 完成主函数的定义(10分) ([Your codes 14])

#### 训练主函数 ########################################################3
if __name__ == '__main__':
    # [Your codes 14]

    # 1. 设置输入样本的维度
    input_spec = 
    label_spec = 

    # 2. 载入设计好的网络,并实例化model变量
    network =  
    model =  

    # 3. 设置学习率、优化器、损失函数和评价指标
    optimizer = 
    model.prepare()

    # 4. 启动训练过程
    train(model)
    print('训练完毕,结果路径{}.'.format(result_root_path))

    # 5. 输出训练过程图
    draw_process_ch6(visualization_log) 
启动训练...
Epoch:1/20, batch:100, train_loss:[1.26940], acc_top1:[0.76562], acc_top5:[0.85938](4.61s)
[validation] Epoch:1/20, val_loss:[1.26940], val_top1:[0.93128], val_top5:[0.97404]
Epoch:2/20, batch:200, train_loss:[0.89697], acc_top1:[0.78125], acc_top5:[0.89062](25.26s)
Epoch:2/20, batch:300, train_loss:[0.58039], acc_top1:[0.82812], acc_top5:[0.90625](2.58s)
[validation] Epoch:2/20, val_loss:[0.58039], val_top1:[0.93128], val_top5:[0.97404]
Epoch:3/20, batch:400, train_loss:[0.48632], acc_top1:[0.87500], acc_top5:[0.95312](1.22s)
Epoch:3/20, batch:500, train_loss:[0.39505], acc_top1:[0.90625], acc_top5:[0.96875](0.96s)
[validation] Epoch:3/20, val_loss:[0.39505], val_top1:[0.93128], val_top5:[0.97404]
Epoch:4/20, batch:600, train_loss:[0.14182], acc_top1:[0.95312], acc_top5:[1.00000](1.24s)
Epoch:4/20, batch:700, train_loss:[0.31880], acc_top1:[0.93750], acc_top5:[0.98438](0.97s)
[validation] Epoch:4/20, val_loss:[0.31880], val_top1:[0.93128], val_top5:[0.97404]
Epoch:5/20, batch:800, train_loss:[0.11153], acc_top1:[0.95312], acc_top5:[1.00000](1.19s)
[validation] Epoch:5/20, val_loss:[0.11153], val_top1:[0.93128], val_top5:[0.97404]
Epoch:6/20, batch:900, train_loss:[0.15027], acc_top1:[0.95312], acc_top5:[1.00000](1.20s)
Epoch:6/20, batch:1000, train_loss:[0.25309], acc_top1:[0.93750], acc_top5:[1.00000](0.93s)
[validation] Epoch:6/20, val_loss:[0.25309], val_top1:[0.93128], val_top5:[0.97404]
Epoch:7/20, batch:1100, train_loss:[0.05757], acc_top1:[0.98438], acc_top5:[1.00000](1.18s)
Epoch:7/20, batch:1200, train_loss:[0.24150], acc_top1:[0.93750], acc_top5:[0.98438](0.98s)
[validation] Epoch:7/20, val_loss:[0.24150], val_top1:[0.93128], val_top5:[0.97404]
Epoch:8/20, batch:1300, train_loss:[0.08737], acc_top1:[0.98438], acc_top5:[1.00000](1.35s)
Epoch:8/20, batch:1400, train_loss:[0.06368], acc_top1:[0.95312], acc_top5:[1.00000](0.97s)
[validation] Epoch:8/20, val_loss:[0.06368], val_top1:[0.93128], val_top5:[0.97404]
Epoch:9/20, batch:1500, train_loss:[0.01878], acc_top1:[1.00000], acc_top5:[1.00000](1.16s)
[validation] Epoch:9/20, val_loss:[0.01878], val_top1:[0.93128], val_top5:[0.97404]
Epoch:10/20, batch:1600, train_loss:[0.14690], acc_top1:[0.96875], acc_top5:[0.98438](1.18s)
Epoch:10/20, batch:1700, train_loss:[0.08274], acc_top1:[0.98438], acc_top5:[1.00000](0.95s)
[validation] Epoch:10/20, val_loss:[0.08274], val_top1:[0.93128], val_top5:[0.97404]
Epoch:11/20, batch:1800, train_loss:[0.08237], acc_top1:[0.98438], acc_top5:[1.00000](1.16s)
Epoch:11/20, batch:1900, train_loss:[0.13697], acc_top1:[0.93750], acc_top5:[1.00000](0.92s)
[validation] Epoch:11/20, val_loss:[0.13697], val_top1:[0.93128], val_top5:[0.97404]
Epoch:12/20, batch:2000, train_loss:[0.00066], acc_top1:[1.00000], acc_top5:[1.00000](1.14s)
Epoch:12/20, batch:2100, train_loss:[0.04592], acc_top1:[0.98438], acc_top5:[1.00000](0.95s)
[validation] Epoch:12/20, val_loss:[0.04592], val_top1:[0.93128], val_top5:[0.97404]
Epoch:13/20, batch:2200, train_loss:[0.07770], acc_top1:[0.96875], acc_top5:[1.00000](1.27s)
[validation] Epoch:13/20, val_loss:[0.07770], val_top1:[0.93128], val_top5:[0.97404]
Epoch:14/20, batch:2300, train_loss:[0.04654], acc_top1:[0.96875], acc_top5:[1.00000](1.30s)
Epoch:14/20, batch:2400, train_loss:[0.05807], acc_top1:[0.96875], acc_top5:[1.00000](0.95s)
[validation] Epoch:14/20, val_loss:[0.05807], val_top1:[0.93128], val_top5:[0.97404]
Epoch:15/20, batch:2500, train_loss:[0.09849], acc_top1:[0.96875], acc_top5:[1.00000](1.21s)
Epoch:15/20, batch:2600, train_loss:[0.04895], acc_top1:[0.98438], acc_top5:[1.00000](0.93s)
[validation] Epoch:15/20, val_loss:[0.04895], val_top1:[0.93128], val_top5:[0.97404]
Epoch:16/20, batch:2700, train_loss:[0.22798], acc_top1:[0.93750], acc_top5:[1.00000](1.20s)
Epoch:16/20, batch:2800, train_loss:[0.08528], acc_top1:[0.96875], acc_top5:[0.98438](0.94s)
[validation] Epoch:16/20, val_loss:[0.08528], val_top1:[0.93128], val_top5:[0.97404]
Epoch:17/20, batch:2900, train_loss:[0.05738], acc_top1:[0.98438], acc_top5:[1.00000](1.14s)
[validation] Epoch:17/20, val_loss:[0.05738], val_top1:[0.93128], val_top5:[0.97404]
Epoch:18/20, batch:3000, train_loss:[0.10596], acc_top1:[0.96875], acc_top5:[1.00000](1.23s)
Epoch:18/20, batch:3100, train_loss:[0.38073], acc_top1:[0.93750], acc_top5:[1.00000](0.92s)
[validation] Epoch:18/20, val_loss:[0.38073], val_top1:[0.93128], val_top5:[0.97404]
Epoch:19/20, batch:3200, train_loss:[0.00568], acc_top1:[1.00000], acc_top5:[1.00000](1.15s)
Epoch:19/20, batch:3300, train_loss:[0.00253], acc_top1:[1.00000], acc_top5:[1.00000](0.95s)
[validation] Epoch:19/20, val_loss:[0.00253], val_top1:[0.93128], val_top5:[0.97404]
Epoch:20/20, batch:3400, train_loss:[0.30926], acc_top1:[0.96875], acc_top5:[1.00000](1.20s)
Epoch:20/20, batch:3500, train_loss:[0.27312], acc_top1:[0.98438], acc_top5:[1.00000](0.90s)
[validation] Epoch:20/20, val_loss:[0.27312], val_top1:[0.93128], val_top5:[0.97404]
训练完成,最终性能accuracy=0.94468(epoch=20), 总耗时67.65s, 已将其保存为:best_model
训练完毕,结果路径D:\WorkSpace\ExpResults\Project009CNNVehicleLicense.
Project00904TrainingResults

将训练过程中的损失函数和模型在训练集上的准确率可视化,有助于发现模型在训练中遇到的问题。损失函数小幅震荡属于正常现象,总体向下即可。可以查看到损失值趋势下降,准确度在上升的趋势,趋近90~100%。

3.2.4 离线测试

离线测试与验证几乎相同,直接调用 eval() 方法即可,唯一的区别是离线测试通常是先读取保存的模型,再进行测试。

Q7. 完成离线测试的模型载入代码(10分) ([Your codes 15])

# [Your codes 15]
# 1. 设置输入样本的维度    
input_spec = InputSpec(shape=[None, img_channel, img_size, img_size], dtype='float32', name='image')
label_spec = InputSpec(shape=[None, 1], dtype='int64', name='label')

# 2. 载入模型




# 3. 执行评估函数,并输出验证集样本的损失和精度
print('开始评估...')

print('\r [验证集] 损失: {:.5f}, top1精度:{:.5f}, top5精度为:{:.5f} \n'.format(avg_loss, avg_acc_top1, avg_acc_top5), end='')    

print('\r [测试集] 损失: {:.5f}, top1精度:{:.5f}, top5精度为:{:.5f}'.format(avg_loss, avg_acc_top1, avg_acc_top5), end='')
开始评估...
 [验证集] 损失: 1.47688, top1精度:0.94468, top5精度为:0.97872 
 [测试集] 损失: 1.35058, top1精度:0.94227, top5精度为:0.98167

【结果分析】
需要注意的是此处的精度与训练过程中输出的测试精度是不相同的,因为训练过程中使用的是验证集VehicleLicense_val, 而这里的离线测试使用的是测试集VehicleLicense_test.

【实验四】 模型预测(应用)

实验摘要: 对训练过的模型,我们通过测试集进行模型效果评估,并可以在实际场景中进行预测,查看模型的效果。

实验目的:

  1. 掌握模型推理和应用
  2. 能够根据任务设计代码逻辑

Q8:使用训练好的模型对给定的车牌进行预测,尽力而为地获得最优的预测结果(20分)([Your codes 16~18])

4.1 导入依赖库及全局参数配置

# 0.导入依赖库
import os
import cv2
import json
import numpy as np
import paddle                      # 载入PaddlePaddle基本库
import matplotlib.pyplot as plt    # 载入python的第三方图像处理库

# Q8-1:全局参数定义
# [Your codes 16]
# 1. 定义全局路径,包括项目名称、结果路径、模型路径等
project_name      = 
dataset_name      = 
root_path         = 'D:\\Workspace\\' 
final_model_path  = os.path.join(root_path, 'ExpResults', project_name, 'final_models', 'best_model')
dataset_root_path = os.path.join(root_path, 'ExpDatasets', dataset_name)


# 2. 图像基本信息
img_size = 20
img_channel = 1

4.2 获取待预测数据及数据预处理

在预测之前,通常需要对图像进行预处理。此处的预处理包含两部分,一部分是常规的预处理,一部分是针对数据集的特殊预处理。

  1. 常规预处理包括将每个字符进行二次预处理,包括缩放至训练样本的尺度(20×20),数据格式规范为paddle要求的浮点型数据,且形态为[C,H,W],数值归一化到[0~1] 等操作,这部分操作,要求和训练时的预处理方案,完全一致。我们使用函数load_image()进行定义。
  2. 针对数据集的特殊预处理。此处也包括两个步骤:
    1). 将所有样本转换为灰度图,并通过阈值变换将其转换为二值图像。其中黑色部分为背景,白色部分为字符,函数 color2bin()对该功能进行定义。
    2). 将二值图像按照字符进行分割,每个字符构成一个子文件。本例中使用比较粗糙的方法(等间隔)进行拆分,函数 Segmentation() 对该功能进行定义。有兴趣的同学可以扩展该函数,以实现更好的字符分割。

值得注意的是,二值化灰度图有利于提高系统的识别性能,是灰度图像预处理的一个重要步骤,在允许的情况下,尽量执行该操作。但选择二值分割阈值是一件经验性的数据驱动型工作,需要慎重选择。

import paddle.vision.transforms as T

# Q8-2:定义数据读取函数,包括读入数据,将数据转换为float32,尺度规范为[20,20],格式转换为Tesnsor并转置为4D形态
# [Your codes 17]
def load_image(img_path):
    img =                                 # cv2.imread(path, 0|1),其中0表示灰度模式,1表示彩色模式
    img =                                 # 将图像数据类型转化为float32

    transforms = T.Compose([              # 传入定义好的数据处理方法,作为自定义数据集类的一个属性
        

    ]) 
    img =                                 # 调用transforms方法对数据进行预处理
    img =                                 # 调整数据形状paddle默认格式

    return img
# 将图像转换为二值模式
def color2bin(img_path):
    img_gray = cv2.imread(img_path, 0)                                                   # cv2.imread(path, 0|1),其中0表示灰度模式,1表示彩色模式
    ret, img_bin = cv2.threshold(img_gray, 120, 255, cv2.THRESH_BINARY)                  # 将图像转换为二值模式,分割阈值为120
    return img_bin

# 定义车牌字符分割函数,实现将车牌分割成单字符
def Segmentation(img_path, img_name):
    
    img_bin = color2bin(os.path.join(img_path, img_name))

    # 对车牌图片进行处理,分割出车牌中的每一个字符并保存
    result = []
    for col in range(img_bin.shape[1]):
        result.append(0)
        for row in range(img_bin.shape[0]):
            result[col] = result[col] + img_bin[row][col]/255
    character_dict = {}
    num = 0
    i = 0
    while i < len(result):
        if result[i] == 0:
            i += 1        
        else:
            index = i + 1
            while result[index] != 0:
                index += 1
            character_dict[num] = [i, index-1]
            num += 1
            i = index
#     print(character_dict)

    for i in range(8):
        if i==2:
            continue
        padding = (170 - (character_dict[i][1] - character_dict[i][0])) / 2
        ndarray = np.pad(img_bin[:,character_dict[i][0]:character_dict[i][1]], ((0,0), (int(padding), int(padding))), 'constant', constant_values=(0,0))
        ndarray = cv2.resize(ndarray, (20,20))
        
        tmp_path = os.path.join(img_path, 'tmp')        
        if not os.path.exists(tmp_path):
            os.makedirs(tmp_path)        
        cv2.imwrite(os.path.join(tmp_path, str(i) + '.png'), ndarray)
        
######################################################################        
# 输出二值化后的图像示例
if __name__ == "__main__":
    img_name = 'test02.png'                            
    img_path = os.path.join(dataset_root_path, 'Infer')

    img_bin = color2bin(os.path.join(img_path, img_name))
    plt.imshow(img_bin, cmap='gray')

png

4.3 载入模型并开始进行推理

车牌识别需要有两个过程,1. 对原始车牌进行分割,分割成单字符;2. 对单字符进行预测,并输出预测结果

### Q8-3: 载入模型并实现车牌预测
# [Your codes 18]

# 0. 设置待预测样本 (示例样本test02.png)
img_name = 'test02.png'                            
img_path = 

# 1. 载入模型并进行实例化
model = 

# 2. 获取标签名称和标签ID的对应关系
json_label_match = os.path.join(dataset_root_path, 'dataset_info.json')
label_match = json.loads(open(json_label_match, 'r', encoding='utf-8').read()) 

# 3. 将原始车牌图片切割成单字符,原始车牌图片切割成单字符,并进行二值化处理


# 4. 对拆分好的车牌,进行依次预测








# 5. 输出预测结果,要求:1.输出文字预测:形式为“车牌识别结果为:云A·XXXXX”;2.输出待预测车牌



车牌识别结果为:京N·8P8F8

png

【结果分析】
本例代码并没有做严格的检测和分割,也没有做严格的预处理(例如光照和色彩),因此识别系统限制较多。例如:

  1. 原始车牌无法识别,包括尺度不规范的图片
  2. 色彩不符合规则的无法识别,例如 test04, test07
  3. 光照不足的无法识别,例如 test05
  4. 车牌不规范的无法很好识别,例如 test08

有兴趣的同学建议进行一定的改进,处理以上问题。改进版的“车牌识别系统”,可以作为毕业设计(论文)进行提交,或用于参加各种计算机的竞赛。

【项目009】基于卷积神经网络的车牌识别(学生版) 显示答案 | 返回首页

【实验一】 数据集准备

【实验二】 全局参数设置及数据准备

【实验三】 模型训练与评估

【实验四】 模型预测(应用)