文章目錄
前面一篇文章介紹了利用PyTorch實現的MTCNN/LPRNe車牌識別的理論框架,但是光有理論還不行,這篇文章主要是對裏面的一些具體細節進行闡述。
車牌識別整體流程:
- 獲取圖片
- PNet網絡處理
- ONet網絡處理
- STN網絡處理
- LPRNet網絡識別
- 解碼網絡輸出結果
來個流程圖就是:
接下來就詳細的把這幾個步驟的情況說明一下:
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
由分隔符-
分爲幾個部分:
-
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
模糊度
所以根據文件名即可獲得所有標註信息。
2.MTCNN數據處理
數據處理並不簡單,主要包含三個部分:
- 對數據處理輸入進PNet網絡
- 對數據處理輸入ONet網絡
2.1 PNet網絡數據預處理
返回最終結果:三種圖片路徑(positive,negative,part)+ 類別(positive=1,negative=0,part=-1)+ 迴歸框偏移值。
具體(以一張圖片爲例):
- 獲取一張圖片(不限大小)
- 獲取圖片已經標記好的boxes座標(左上,右下)
- 生成大量候選框
- 計算候選框與實際框的IOU值:
· 大於0.65,positive=1
· 大於0.3,negative=0
· 0.3~0.65,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<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個迴歸框信息)
具體步驟:
- 定義一個Dataset類,含圖片信息,標籤信息和迴歸框信息(人臉識別多一個landmark信息)
torch.utils.data.DataLoader
讀取數據- 定義優化器和損失函數(分類用
loss_cls = nn.CrossEntropyLoss()
迴歸用loss_offset = nn.MSELoss()
) - 將每張圖片的預測值與原始值計算損失和準確率就OK了。
- 保存訓練好的PNet網絡。
2.3 ONet網絡數據預處理
從上圖撇開RNet網絡,可以清晰的知道ONet是幹什麼的。他接收兩部分的輸入:
- 原始圖像信息
- 經過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的方法”
訓練具體思路:
- 定義一個Dataset,獲取圖片信息(根據座標只截取車牌部分)和標籤信息(就是車牌號對應的數字)
- 定義LPRNet網絡結構並初始化
- 定義STN網絡結構,至於STN網絡,沒什麼特殊的,直接用就是了。
- 定義優化器和損失函數
- 訓練,計算損失和參數
- 保存模型
4. 預測
前面的話算起來差不多有4個網絡(我的天,四個網絡……)
這四個網絡難點就在於PNet和ONet兩個過程,後面兩個很簡單。
梳理一下這四個網絡分別具體幹了些啥:
4.1 PNet過程
- 我們輸入一張圖,首先形成圖像金字塔(factor = 0.707或sqrt(0.5)),假設我們得到n張圖像,我們一次將每張圖像送入PNet(這裏就體現了全卷積網絡的優點了:對輸入圖像的尺寸沒有要求)。
- PNet網絡預測輸出是預測迴歸框的偏移值(pred_offsets),接下來,對上述產生的結果使用NMS算法,算法的本質就是挑選出置信度最大的候選框
- 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來做,什麼二值化啊,腐蝕膨脹啊,邊緣檢測啊之類的,這裏根據我個人的理解總結一下傳統方法和深度學習方法的優缺點。
- 深度學習相比於傳統方法準確率要高
- 深度學習方法對數據大小有要求,數據量小了根本訓練不出好的網絡,而傳統方法對每張圖處理方法都一樣,不怎麼受數據量大小的影響
- 傳統方法對數據的質量要求比較高,例如正矩形要比平行四邊形好識別
此外,針對於這種MTCNN來進行目標檢測的,時候可以換成Faster RCNN,Mask RCNN或者YOLO V3/V4呢?LPRnet車牌識別能不能換成其他字符識別呢?
其實有時間的話可以去試一試,反正這些網絡很多都有現成的框架,最終效果怎麼樣,還望在座的各位大佬帶帶我😄😄