车牌识别整体流程:
来个流程图就是:
接下来就详细的把这几个步骤的情况说明一下:
目前已知的公开的数据集,最大的就是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
由分隔符-
分为几个部分:
025
为车牌占全图面积比,
95_113
对应两个角度, 水平倾斜度和垂直倾斜度,水平95°, 竖直113°
154&383_386&473
对应边界框坐标:左上(154, 383), 右下(386, 473)
386&473_177&454_154&383_363&402
对应四个角点右下、左下、左上、右上坐标
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’]
37
亮度
15
模糊度
所以根据文件名即可获得所有标注信息。
数据处理并不简单,主要包含三个部分:
返回最终结果:三种图片路径(positive,negative,part)+ 类别(positive=1,negative=0,part=-1)+ 回归框偏移值。
具体(以一张图片为例):
以下是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
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()
修改后的MTCNN结构如下:
输入是一个12 * 47大小的图片,所以训练前需要把生成的训练数据(通过生成bounding box,然后把该bounding box 剪切成12 * 47大小的图片),转换成12 * 47 * 3的结构。
PNet是一个全卷积网络,所以Input可以是任意大小的图片,用来传入我们要Inference的图片,但是这个时候Pnet的输出的就不是11大小的特征图了,而是一个WH的特征图,每个特征图上的网格对应于我们上面所说的(2个分类信息,4个回归框信息)
具体步骤:
torch.utils.data.DataLoader
读取数据loss_cls = nn.CrossEntropyLoss()
回归用loss_offset = nn.MSELoss()
)从上图撇开RNet网络,可以清晰的知道ONet是干什么的。他接收两部分的输入:
原始图像信息我就不说了,和PNet处理差不多(去GitHub查看),只是说明一下如何对PNet网络计算的输出bounding boxes信息进行利用。
回归框的非极大值抑制
由PNet网络计算的输出步骤,可以看到一个原始图片会产生大量的回归框,那么到底要把那个回归框让RNet继续训练呢?这里采用非极大值抑制方法(NMS),该算法的主要思想是:将所有框的得分排序,选中最高分及其对应的框;遍历其余的框,如果和当前最高分框的重叠面积(IOU)大于一定阈值,我们就将框删除;从未处理的框中继续选一个得分最高的,重复上述过程。
ONet网络训练方式类似于PNet,也是分类用loss_cls = nn.CrossEntropyLoss()
回归用loss_offset = nn.MSELoss()
,训练好模型之后保存ONet网络。
LPRNet网络就很好理解了,说白了就是个分类网络,损失函数就是nn.CTCLoss(blank=len(CHARS)-1, reduction='mean')
,至于为什么使用CTCLoss,可以看这篇文章“如何优雅的使用pytorch内置torch.nn.CTCLoss的方法”
训练具体思路:
前面的话算起来差不多有4个网络(我的天,四个网络……)
这四个网络难点就在于PNet和ONet两个过程,后面两个很简单。
梳理一下这四个网络分别具体干了些啥:
将原始图片的信息和PNet网络预测的框框,输入给ONet网络,进行进一步修正,流程上除了图像金字塔这一部分其他和PNet差不多。
总结:经过PNet和ONet之后就可以得到精确地车牌位置信息
这一步就很简单了,加载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()
LPRNet过程就是一个分类过程,输入裁剪后的车牌图片就得出车牌信息对应的数值。
模型主干的基本构建块是受SqueezeNet、Fire Blocks和Inception Blocks的启发。输入图像大小设置为94x24像素RGB图像。图像由空间变换层(STN)进行预处理,以获得更好的特性。转换后的RGB图像通过特征提取骨干网络来捕获重要的特征,而不是使用LSTM。中间特征映射通过全局上下文嵌入和连接在一起进行增强。为了将特征映射的深度调整为字符类数,增加了1x1卷积。模型输出和目标字符序列长度不同。这里我们使用18的长度作为输出,对于每个输出字符,有68个不同的类是由字符产生的。
简单点就是上一步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)
说白了车牌检测也算是一个比较老的项目了,但是呢,从这个MTCNN+STN+LPRNet的项目来说,准确率是挺高的,但是相应的时间成本,运行成本代价也就稍微高了那么一点点(总之不怎么推荐)。以前做车牌检测都是一直用的OpenCV来做,什么二值化啊,腐蚀膨胀啊,边缘检测啊之类的,这里根据我个人的理解总结一下传统方法和深度学习方法的优缺点。
此外,针对于这种MTCNN来进行目标检测的,时候可以换成Faster RCNN,Mask RCNN或者YOLO V3/V4呢?LPRnet车牌识别能不能换成其他字符识别呢?
其实有时间的话可以去试一试,反正这些网络很多都有现成的框架,最终效果怎么样,还望在座的各位大佬带带我