MTCNN/LPRNet车牌识别细节


前面一篇文章介绍了利用PyTorch实现的MTCNN/LPRNe车牌识别的理论框架,但是光有理论还不行,这篇文章主要是对里面的一些具体细节进行阐述。

车牌识别整体流程:

  1. 获取图片
  2. PNet网络处理
  3. ONet网络处理
  4. STN网络处理
  5. LPRNet网络识别
  6. 解码网络输出结果

来个流程图就是:

接下来就详细的把这几个步骤的情况说明一下:

1.获取车牌图片

目前已知的公开的数据集,最大的就是CCPD数据集了。

CCPD(中国城市停车数据集,ECCV)和PDRC(车牌检测与识别挑战)。这是一个用于车牌识别的大型国内的数据集,由中科大的科研人员构建出来的。发表在ECCV2018论文Towards End-to-End License Plate Detection and Recognition: A Large Dataset and Baseline

https://github.com/detectRecog/CCPD

该数据集在合肥市的停车场采集得来的,采集时间早上7:30到晚上10:00.涉及多种复杂环境。一共包含超多25万张图片,每种图片大小720x1160x3。一共包含9项。每项占比如下:

CCPD- 数量/k 描述
Base 200 正常车牌
FN 20 距离摄像头相当的远或者相当近
DB 20 光线暗或者比较亮
Rotate 10 水平倾斜20-25°,垂直倾斜-10-10°
Tilt 10 水平倾斜15-45°,垂直倾斜15-45°
Weather 10 在雨天,雪天,或者雾天
Blur(已删除) 5 由于相机抖动造成的模糊(这个后面被删了)
Challenge 10 其他的比较有挑战性的车牌
NP 5 没有车牌的新车

数据标注:

文件名就是数据标注。eg:

025-95_113-154&383_386&473-386&473_177&454_154&383_363&402-0_0_22_27_27_33_16-37-15.jpg

由分隔符-分为几个部分:

  1. 025为车牌占全图面积比,

  2. 95_113 对应两个角度, 水平倾斜度和垂直倾斜度,水平95°, 竖直113°

  3. 154&383_386&473对应边界框座标:左上(154, 383), 右下(386, 473)

  4. 386&473_177&454_154&383_363&402对应四个角点右下、左下、左上、右上座标

  5. 0_0_22_27_27_33_16为车牌号码 映射关系如下: 第一个为省份0 对应省份字典皖, 后面的为字母和文字, 查看ads字典.如0为A, 22为Y…

provinces = [“皖”, “沪”, “津”, “渝”, “冀”, “晋”, “蒙”, “辽”, “吉”, “黑”, “苏”, “浙”, “京”, “闽”, “赣”, “鲁”, “豫”, “鄂”, “湘”, “粤”, “桂”, “琼”, “川”, “贵”, “云”, “藏”, “陕”, “甘”, “青”, “宁”, “新”, “警”, “学”, “O”]


alphabets = [‘A’, ‘B’, ‘C’, ‘D’, ‘E’, ‘F’, ‘G’, ‘H’, ‘J’, ‘K’, ‘L’, ‘M’, ‘N’, ‘P’, ‘Q’, ‘R’, ‘S’, ‘T’, ‘U’, ‘V’, ‘W’, ‘X’, ‘Y’, ‘Z’, ‘O’]


ads = [‘A’, ‘B’, ‘C’, ‘D’, ‘E’, ‘F’, ‘G’, ‘H’, ‘J’, ‘K’, ‘L’, ‘M’, ‘N’, ‘P’, ‘Q’, ‘R’, ‘S’, ‘T’, ‘U’, ‘V’, ‘W’, ‘X’, ‘Y’, ‘Z’,0,1,2,3,4,5,6,7,8,9, ‘O’]
  1. 37亮度

  2. 15模糊度

所以根据文件名即可获得所有标注信息。

2.MTCNN数据处理

数据处理并不简单,主要包含三个部分:

  1. 对数据处理输入进PNet网络
  2. 对数据处理输入ONet网络

2.1 PNet网络数据预处理

返回最终结果:三种图片路径(positive,negative,part)+ 类别(positive=1,negative=0,part=-1)+ 回归框偏移值。

具体(以一张图片为例):

  1. 获取一张图片(不限大小)
  2. 获取图片已经标记好的boxes座标(左上,右下)
  3. 生成大量候选框
  4. 计算候选框与实际框的IOU值:
    · 大于0.65,positive=1
    · 大于0.3,negative=0
    · 0.3~0.65,part=-1
  5. 计算回归框偏移量
  6. 分别保存裁剪后的图片
  7. 统一整合成最终的输出结果
    在这里插入图片描述

以下是MTCNN当中PNet人脸数据生成参考代码,仅供阅读:

# coding:utf-8
import os
import cv2
import numpy as np
import numpy.random as npr
def IoU(box, boxes):
    """Compute IoU between detect box and gt boxes
    Parameters:
    ----------
    box: numpy array , shape (4, ): x1, y1, x2, y2
        predicted boxes
    boxes: numpy array, shape (n, 4): x1, x2, y1, y2
        input ground truth boxes

    Returns:
    -------
    ovr: numpy.array, shape (n, )
        IoU
    """
    # 函数的传入参数为box(随机裁剪后的框)和boxes(实际人脸框)
    box_area = (box[2] - box[0] + 1) * (box[3] - box[1] + 1)
    # 计算随机裁剪后的框的面积,因为传入的box是以x1, y1, x2, y2这样的数组形式,所以分别对应着左上角的顶点座标和右下角的顶点座标,根据这两个坐
    # 标点就可以确定出了一个裁剪框,然后横纵座标的差值的乘积就是随机裁剪框的面积,
    area = (boxes[:, 1] - boxes[:, 0] + 1) * (boxes[:, 3] - boxes[:, 2] + 1)
    # 同上,得出的是实际的人脸框的面积,但是这里要注意一点,因为一张图片的人脸是一个或者多个,所以说实际的boxes是个n行4列的数组,n>=1,n表示实
    # 际人脸的个数。故这里用到了boxes[:,2]-boxes[:,0]这样的写法,意思是取出所有维数的第3个元素减去对应的第1个元素,然后加上一,这样就把n个人
    # 脸对应的各自的面积存进了area这个数组里面
    xx1 = np.maximum(box[0], boxes[:, 0])  # 将随机裁剪框的x1和各个人脸的x1比较,得到较大的xx1
    yy1 = np.maximum(box[1], boxes[:, 2])  # 将随机裁剪框的y1和各个人脸的y1比较,得到较大的yy1
    xx2 = np.minimum(box[2], boxes[:, 1])  # 将随机裁剪框的x2和各个人脸的x2比较,得到较小的xx2
    yy2 = np.minimum(box[3], boxes[:, 3])  # 将随机裁剪框的y2和各个人脸的y2比较,得到较小的yy2
    # 这样做的目的是得出两个图片交叉重叠区域的矩形的左上角和右下角座标

    # compute the width and height of the bounding box
    h = np.maximum(0, xx2 - xx1 + 1)

    w = np.maximum(0, yy2 - yy1 + 1)


    inter = w * h  # 求得重叠区域的面积
    ovr = inter / (box_area + area - inter)  # 重叠区域的面积除以真实人脸框的面积与随机裁剪区域面积的和减去重叠区域的面积就是重合率
    return ovr  # 返回重合率



anno_file = "C:/Desktop/train/trainImageList.txt"  # 下载的wider face数据集对应的每张图片的人脸方框数据
im_dir = "C:\\Users\\Desktop\\train"  # 将图片解压到这个文件夹
pos_save_dir = "E:/MTCNN/12/positive"  # 生成的正样本存放路径
part_save_dir = "E:/MTCNN/12/part"  # 生成的无关样本存放路径
neg_save_dir = 'E:/MTCNN/12/negative'  # 生成的负样本存放路径
save_dir = "E:/MTCNN/12"
if not os.path.exists(save_dir):  # 路径的创建
    os.makedirs(save_dir)
if not os.path.exists(pos_save_dir):
    os.makedirs(pos_save_dir)
if not os.path.exists(part_save_dir):
    os.makedirs(part_save_dir)
if not os.path.exists(neg_save_dir):
    os.makedirs(neg_save_dir)

f1 = open(os.path.join(save_dir, 'pos_12.txt'), 'w')  # 对应的样本的文档建立
f2 = open(os.path.join(save_dir, 'neg_12.txt'), 'w')
f3 = open(os.path.join(save_dir, 'part_12.txt'), 'w')
with open(anno_file, 'r') as f:
    annotations = f.readlines()  # 按行读取存放进列表annotations里面
num = len(annotations)  # 里面的每一个元素对应着一张照片的人脸数据,所以这个列表的大小就是数据集的照片数量。
print("%d pics in total" % num)  # 打印出照片的数量
p_idx = 0  # positive
n_idx = 0  # negative
d_idx = 0  # don't care
idx = 0
box_idx = 0
for annotation in annotations:  # for循环读取数据
    print(annotation)
    annotation = annotation.strip().split(' ')  # 去掉每一行数据的首尾空格换行字符,同时以空格为界限,分成一个个的字符
    # image path
    im_path = annotation[0]  # 第0号元素代表的是一个路径
    # print(im_path)
    # boxed change to float type
    bbox = list(map(float, annotation[1:5]))  # 第1号元素开始到第4个元素,每四个元素代表着一个人脸框
    # gt
    print(bbox)
    boxes = np.array(bbox, dtype=np.float32).reshape(-1, 4)  # 将人脸框的座标进行reshape操作,变成n行4列的array
    # load image

    path = os.path.join(im_dir, im_path )
    path = path.replace('\\', '/')
    print(path)
    img = cv2.imread(os.path.join(im_dir, im_path ))  # 将路径拼接后读取图片
    idx += 1
    # if idx % 100 == 0:
    # print(idx, "images done")

    height, width, channel = img.shape  # 读取图片的宽、高、通道数并记录下来

    neg_num = 0  # 负样本数初始化为0
    # 1---->50
    # keep crop random parts, until have 50 negative examples
    # get 50 negative sample from every image
    while neg_num < 5:  # 负样本数小于50的时候
        # neg_num's size [40,min(width, height) / 2],min_size:40
        # size is a random number between 12 and min(width,height)
        size = npr.randint(12, min(width, height) / 2)  # size是一个随机数
        # top_left coordinate
        nx = npr.randint(0, width - size)  # 左上方的x座标是一个随机数
        ny = npr.randint(0, height - size)  # 左上方的y座标是一个随机数
        # random crop
        crop_box = np.array([nx, ny, nx + size, ny + size])  # 随机裁剪的样本
        print(crop_box)
        # calculate iou
        Iou = IoU(crop_box, boxes)  # 引入Iou()函数,含有两个参数,随机裁剪的样本crop_box和实际的人脸框boxes,计
        # 算出Iou()值

        # crop a part from inital image
        cropped_im = img[ny: ny + size, nx: nx + size, :]  # 将这个部分样本裁剪下来
        # resize the cropped image to size 12*12
        resized_im = cv2.resize(cropped_im, (12, 12),  # resize这个样本成12*12
                                interpolation=cv2.INTER_LINEAR)

        if np.max(Iou) < 0.3:  # 当Iou的值小于0.3的时候为负样本
            # Iou with all gts must below 0.3
            save_file = os.path.join(neg_save_dir, "%s.jpg" % n_idx)
            f2.write("E:/MTCNN/12/negative/%s.jpg" % n_idx + ' 0\n')  # 样本的路径保存下来
            cv2.imwrite(save_file, resized_im)  # 图片保存下来
            n_idx += 1
            neg_num += 1

    # for every bounding boxes
    for box in boxes:
        # box (x_left, x_right,y_top , y_bottom)
        x1, x2, y1, y2 = box
        # gt's width
        w = x2 - x1 + 1
        # gt's height
        h = y2 - y1 + 1
        # 获取每一个样本的宽和高

        # in case the ground truth boxes of small faces are not accurate
        # 忽略一些小的人脸和那些左顶点超出了图片的人脸框
        # 防止那些小人脸的座标不准确
        if max(w, h) < 20 or x1 < 0 or y1 < 0:
            continue

        # 下面仍然是返回5个负样本,但是返回的样本一定是和真实的人脸框有一定的交集,即(0<IoU<0.3),上面返回的50负样本是不一定和真实人脸框有交集
        for i in range(2):
            # size of the image to be cropped
            size = npr.randint(12, min(width, height) / 2)
            # parameter high of randint make sure there will be intersection between bbox and cropped_box
            delta_x = npr.randint(max(-size, -x1), w)  # 求取(-size和-x1的最大值可以保证x1+delta_x一定大于等于0,
            delta_y = npr.randint(max(-size, -y1), h)  # 同上
            # max here not really necessary
            nx1 = int(max(0, x1 + delta_x))  # 得到x1的偏移座标nx1
            ny1 = int(max(0, y1 + delta_y))  # 得到y1的偏移座标ny1

            # 如果裁剪框的右下座标超出了图片的范围就跳过此次循环,进行下一次裁剪,注意这里的width是原始图片的宽度,不是真实人脸框的宽w
            if nx1 + size > width or ny1 + size > height:
                continue
            crop_box = np.array([nx1, ny1, nx1 + size, ny1 + size])  # 获取裁剪后的矩形框
            Iou = IoU(crop_box, boxes)  # 计算IoU值

            cropped_im = img[ny1: ny1 + size, nx1: nx1 + size, :]
            # 图片resize到12*12
            resized_im = cv2.resize(cropped_im, (12, 12), interpolation=cv2.INTER_LINEAR)
            # 将符合条件的样本框保存,完成这部操作之后每张图片都生成了55个负样本
            if np.max(Iou) < 0.3:
                # Iou with all gts must below 0.3
                save_file = os.path.join(neg_save_dir, "%s.jpg" % n_idx)
                f2.write("E:/MTCNN/12/negative/%s.jpg" % n_idx + ' 0\n')
                cv2.imwrite(save_file, resized_im)
                n_idx += 1

        # 生成正样本和无关样本
        for i in range(3):
            # pos and part face size [minsize*0.8,maxsize*1.25]
            # 设置正样本和部分样本的size
            size = npr.randint(int(min(w, h) * 0.8), np.ceil(1.25 * max(w, h)))

            # delta here is the offset of box center
            if w < 5:
                print(w)
                continue

            # x1和y1的偏移量
            delta_x = npr.randint(-w *0.2, w * 0.2)
            delta_y = npr.randint(-h *0.2, h * 0.2)

            # deduct size/2 to make sure that the right bottom corner will be out of
            # nx1是人脸框的中点的x座标加减0.2倍宽度再减去一半的size和0之间的最大值
            # ny1是人脸框的中点的y座标加减0.2倍高度再减去一半的size和0之间的最大值
            nx1 = int(max(x1 + w / 2 + delta_x - size / 2, 0))
            ny1 = int(max(y1 + h / 2 + delta_y - size / 2, 0))
            nx2 = nx1 + size  # 获得右下角的nx2座标
            ny2 = ny1 + size  # 获得右下角的ny2座标

            # 去掉超出图片的的座标点
            if nx2 > width or ny2 > height:
                continue
            crop_box = np.array([nx1, ny1, nx2, ny2])
            # yu gt de offset
            # 这是一个bounding box regression操作
            offset_x1 = (x1 - nx1) / float(size)
            offset_y1 = (y1 - ny1) / float(size)
            offset_x2 = (x2 - nx2) / float(size)
            offset_y2 = (y2 - ny2) / float(size)
            # 裁剪图片
            cropped_im = img[ny1: ny2, nx1: nx2, :]
            # resize操作
            resized_im = cv2.resize(cropped_im, (12, 12), interpolation=cv2.INTER_LINEAR)

            box_ = box.reshape(1, -1)  # reshape成行数等于一列数未知的数组
            iou = IoU(crop_box, box_)  # 计算IoU值
            if iou >= 0.65:  # 保存为正样本
                save_file = os.path.join(pos_save_dir, "%s.jpg" % p_idx)
                f1.write("E:/MTCNN/12/positive/%s.jpg" % p_idx + ' 1 %.2f %.2f %.2f %.2f\n' % (
                offset_x1, offset_y1, offset_x2, offset_y2))
                cv2.imwrite(save_file, resized_im)
                p_idx += 1
            elif iou >= 0.4:  # 保存为部分样本
                save_file = os.path.join(part_save_dir, "%s.jpg" % d_idx)
                f3.write("E:/MTCNN/12/part/%s.jpg" % d_idx + ' -1 %.2f %.2f %.2f %.2f\n' % (
                offset_x1, offset_y1, offset_x2, offset_y2))
                cv2.imwrite(save_file, resized_im)
                d_idx += 1
        box_idx += 1
        if idx % 100 == 0:
            print("%s images done, pos: %s part: %s neg: %s" % (idx, p_idx, d_idx, n_idx))
f1.close()
f2.close()
f3.close()

2.2 PNet网络训练

修改后的MTCNN结构如下:

输入是一个12 * 47大小的图片,所以训练前需要把生成的训练数据(通过生成bounding box,然后把该bounding box 剪切成12 * 47大小的图片),转换成12 * 47 * 3的结构。

PNet是一个全卷积网络,所以Input可以是任意大小的图片,用来传入我们要Inference的图片,但是这个时候Pnet的输出的就不是11大小的特征图了,而是一个WH的特征图,每个特征图上的网格对应于我们上面所说的(2个分类信息,4个回归框信息)

具体步骤:

  1. 定义一个Dataset类,含图片信息,标签信息和回归框信息(人脸识别多一个landmark信息)
  2. torch.utils.data.DataLoader读取数据
  3. 定义优化器和损失函数(分类用loss_cls = nn.CrossEntropyLoss()回归用loss_offset = nn.MSELoss()
  4. 将每张图片的预测值与原始值计算损失和准确率就OK了。
  5. 保存训练好的PNet网络。

2.3 ONet网络数据预处理

从上图撇开RNet网络,可以清晰的知道ONet是干什么的。他接收两部分的输入:

  1. 原始图像信息
  2. 经过PNet网络计算的输出bounding boxes信息

原始图像信息我就不说了,和PNet处理差不多(去GitHub查看),只是说明一下如何对PNet网络计算的输出bounding boxes信息进行利用。

回归框的非极大值抑制
由PNet网络计算的输出步骤,可以看到一个原始图片会产生大量的回归框,那么到底要把那个回归框让RNet继续训练呢?这里采用非极大值抑制方法(NMS),该算法的主要思想是:将所有框的得分排序,选中最高分及其对应的框;遍历其余的框,如果和当前最高分框的重叠面积(IOU)大于一定阈值,我们就将框删除;从未处理的框中继续选一个得分最高的,重复上述过程。

2.4 ONet网络训练

ONet网络训练方式类似于PNet,也是分类用loss_cls = nn.CrossEntropyLoss()回归用loss_offset = nn.MSELoss(),训练好模型之后保存ONet网络。

3. LPRNet数据处理和训练

LPRNet网络就很好理解了,说白了就是个分类网络,损失函数就是nn.CTCLoss(blank=len(CHARS)-1, reduction='mean'),至于为什么使用CTCLoss,可以看这篇文章“如何优雅的使用pytorch内置torch.nn.CTCLoss的方法

训练具体思路:

  1. 定义一个Dataset,获取图片信息(根据座标只截取车牌部分)和标签信息(就是车牌号对应的数字)
  2. 定义LPRNet网络结构并初始化
  3. 定义STN网络结构,至于STN网络,没什么特殊的,直接用就是了。
  4. 定义优化器和损失函数
  5. 训练,计算损失和参数
  6. 保存模型

4. 预测

前面的话算起来差不多有4个网络(我的天,四个网络……)
这四个网络难点就在于PNet和ONet两个过程,后面两个很简单。
梳理一下这四个网络分别具体干了些啥:

4.1 PNet过程

  1. 我们输入一张图,首先形成图像金字塔(factor = 0.707或sqrt(0.5)),假设我们得到n张图像,我们一次将每张图像送入PNet(这里就体现了全卷积网络的优点了:对输入图像的尺寸没有要求)。
  2. PNet网络预测输出是预测回归框的偏移值(pred_offsets),接下来,对上述产生的结果使用NMS算法,算法的本质就是挑选出置信度最大的候选框
  3. NMS算法计算完毕后,返回从输入的bbox中挑选出的目标索引,因此首先根据索引挑选出目标bbox,然后根据目标bbox中指定的像素座标和座标位置差,确定车牌的真实座标。根据上面所说,bbox的前面4项是bbox在原图像中的像素座标,而最后面四项是候选框区域相对于像素座标的偏差。因此,将原像素座标加上偏差值,即可得到候选框的座标。

4.2 ONet过程

将原始图片的信息和PNet网络预测的框框,输入给ONet网络,进行进一步修正,流程上除了图像金字塔这一部分其他和PNet差不多。

总结:经过PNet和ONet之后就可以得到精确地车牌位置信息

4.3 STN过程

这一步就很简单了,加载STN网络和预训练好的权重就可以直接用了。目的就是,调整图片(图片增强)。

STN = STNet()
STN.to(device)
STN.load_state_dict(torch.load('LPRNet/weights/Final_STN_model.pth', map_location=lambda storage, loc: storage))
STN.eval()

4.4 LPRNet过程

LPRNet过程就是一个分类过程,输入裁剪后的车牌图片就得出车牌信息对应的数值。

模型主干的基本构建块是受SqueezeNet、Fire Blocks和Inception Blocks的启发。输入图像大小设置为94x24像素RGB图像。图像由空间变换层(STN)进行预处理,以获得更好的特性。转换后的RGB图像通过特征提取骨干网络来捕获重要的特征,而不是使用LSTM。中间特征映射通过全局上下文嵌入和连接在一起进行增强。为了将特征映射的深度调整为字符类数,增加了1x1卷积。模型输出和目标字符序列长度不同。这里我们使用18的长度作为输出,对于每个输出字符,有68个不同的类是由字符产生的。

4.5 解码过程

简单点就是上一步LPRNet输出的并不是标准的车牌信息,而是需要我们将LPRNet输出转变为标准的车牌照字母数值。

def decode(preds, CHARS):
    # greedy decode
    pred_labels = list()
    labels = list()
    for i in range(preds.shape[0]):
        pred = preds[i, :, :]
        pred_label = list()
        for j in range(pred.shape[1]):
            pred_label.append(np.argmax(pred[:, j], axis=0))
        no_repeat_blank_label = list()
        pre_c = pred_label[0]
        for c in pred_label: # dropout repeate label and blank label
            if (pre_c == c) or (c == len(CHARS) - 1):
                if c == len(CHARS) - 1:
                    pre_c = c
                continue
            no_repeat_blank_label.append(c)
            pre_c = c
        pred_labels.append(no_repeat_blank_label)
        
    for i, label in enumerate(pred_labels):
        lb = ""
        for i in label:
            lb += CHARS[i]
        labels.append(lb)
    
    return labels, np.array(pred_labels) 

preds = preds.cpu().detach().numpy()  # (1, 68, 18)
labels, pred_labels = decode(preds, CHARS)
print("label is", labels)
print("pred_labels is", pred_labels)

5. 总结

说白了车牌检测也算是一个比较老的项目了,但是呢,从这个MTCNN+STN+LPRNet的项目来说,准确率是挺高的,但是相应的时间成本,运行成本代价也就稍微高了那么一点点(总之不怎么推荐)。以前做车牌检测都是一直用的OpenCV来做,什么二值化啊,腐蚀膨胀啊,边缘检测啊之类的,这里根据我个人的理解总结一下传统方法和深度学习方法的优缺点。

  1. 深度学习相比于传统方法准确率要高
  2. 深度学习方法对数据大小有要求,数据量小了根本训练不出好的网络,而传统方法对每张图处理方法都一样,不怎么受数据量大小的影响
  3. 传统方法对数据的质量要求比较高,例如正矩形要比平行四边形好识别

此外,针对于这种MTCNN来进行目标检测的,时候可以换成Faster RCNN,Mask RCNN或者YOLO V3/V4呢?LPRnet车牌识别能不能换成其他字符识别呢?

其实有时间的话可以去试一试,反正这些网络很多都有现成的框架,最终效果怎么样,还望在座的各位大佬带带我😄😄

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章