用Tensorflow2實現卷積神經網路(CNN)識別圖片驗證碼

語言: CN / TW / HK

highlight: ocean

1. 前言

最近想要實現一個驗證碼識別的功能,在神經網路和計算機圖形學之間搖擺了一下,但是卷積神經網路能實現“端到端”,亦即輸入圖片,輸出驗證碼的驗證碼識別,就拋棄了CV選擇了CNN。

其實原本我比較熟悉pytorch的,甚至現成的pytorch + CUDA + cudnn環境都已經有了,但無奈TensorflowTensorflow.js可以與原本選擇的跨平臺解決方案Electron完美結合,就背棄了pytorch,轉投Tensorflow懷抱。

經過一段時間綜合研(mo)究(gai)了官方文件+github+stackoverflow+知乎+csdn的各方程式碼,終於可以用我的GTX1660煉丹了!

2. 實現

2.1 圖片預處理

首先第一步要做的就是圖片預處理,起碼一個灰度模式要有吧?

來看看要處理的驗證碼圖片:

233HU6.png

可以看見裡面有兩條線一條紅的,一條綠的,貫穿了圖片。理論上來說,可以通過(原圖-紅-綠)的影象處理手段去掉這兩條線。

然而既然都用上卷積神經網路了,就不搞這麼多花裡胡哨的了,直接莽上去。

src=http___p5.itc.cn_images01_20210108_715640535f1945feadeb1b7f5dd6afdd.png&refer=http___p5.itc.jpg

幾行程式碼實現把圖片灰度化然後去掉周圍的黑線: python img = cv2.imread(img_file, cv2.IMREAD_GRAYSCALE) np_img = np.array(img) flat_img = np_img.flatten() ind = np.argmax(np.bincount(flat_img)) if(ind < 255): np_img[0] = ind np_img[-1] = ind np_img[:, 0] = ind np_img[:, -1] = ind img = np_img / 255.0

輸出圖片:

233HU6.jpg

當然這裡的圖片處理只是因為我的搭的網路模型對輸入資料的shape沒有要求,事實上有不少神經網路對輸入資料的形狀有要求的,比如InceptionV4(299,299),ResNet_18(224,224)等,因此也可以基於opencv-python擴充套件圖片,之前做了一種實現是先用cv2.copyMakeBorder把短邊弄到和長邊一樣長,然後再cv2.resize

很好,接下來就是採集足夠多的圖片驗證碼以供訓練了。


後來原本還做了二值化處理,但後來發現只灰度表現也不錯,就去掉了二值化。

2.2 卷積神經網路搭建與訓練

2.2.1 訓練過程

經過幾天的採集,手工標註了幾百張圖(後來擴充套件到了18000+張)作為訓練集,可以開始嘗試煉丹了。

不得不說[email protected]時代太輝煌了,現在找到的大多數是1.x版本的原始碼。

於是看了看github上面一些倉庫後就參考官方文件的tutorial開始手擼(魔改)。

思路就是把訓練集中標註好的驗證碼圖片按照對應的字符集用One-Hot喂進模型。從前面的驗證碼圖可以看出,它一個圖有六個字元,每個字元共有10個數字+26個大寫字母=36種可能性。因此它是一個多分類(multi-class)模型,總的類別是6*36=216,損失函式categorical_crossentropy,啟用函式softmax

以下是搭建模型的程式碼部分(當然主體是在一個類裡面的,略去了)

python def generate_model(self): self.model = model = models.Sequential() input_shape=(self.image_height, self.image_width, 1) # conventional model.add(layers.Conv2D(96, (3, 3), activation='relu',padding="SAME")) model.add(layers.Conv2D(96, (3, 3), activation='relu',padding="SAME")) model.add(layers.MaxPooling2D((2, 2))) # conv2 model.add(layers.Conv2D(128, (3, 3), activation='relu',padding="SAME")) model.add(layers.Conv2D(128, (3, 3), activation='relu',padding="SAME")) model.add(layers.Conv2D(128, (3, 3), activation='relu',padding="SAME")) model.add(layers.MaxPooling2D((2, 2))) # conv3 model.add(layers.Conv2D(128, (3, 3), activation='relu')) model.add(layers.Conv2D(128, (3, 3), activation='relu')) model.add(layers.MaxPooling2D((2, 2))) # conv4 model.add(layers.Conv2D(128, (3, 3), activation='relu')) model.add(layers.Conv2D(128, (3, 3), activation='relu')) model.add(layers.MaxPooling2D((2, 2))) # flatten model.add(layers.Flatten()) # fc1 model.add(layers.Dense(384, activation='relu')) model.add(layers.AlphaDropout(rate=0.2)) # fc2 model.add(layers.Dense(512, activation='relu')) model.add(layers.AlphaDropout(rate=0.2)) # output model.add(layers.Dense(self.max_captcha*self.char_set_len, activation='softmax')) model.add(layers.Reshape((self.max_captcha,self.char_set_len))) model.summary() model.compile(optimizer=tf.keras.optimizers.Nadam(learning_rate=1e-5, clipnorm=1), loss='categorical_crossentropy', metrics=['accuracy']) return model

compile出來的網路模型長這樣:

```markdown Model: "sequential"


Layer (type) Output Shape Param #

conv2d (Conv2D) (None, 80, 300, 96) 960

conv2d_1 (Conv2D) (None, 80, 300, 96) 83040

conv2d_2 (Conv2D) (None, 80, 300, 96) 83040

max_pooling2d (MaxPooling2D (None, 40, 150, 96) 0 )

conv2d_3 (Conv2D) (None, 40, 150, 128) 110720

conv2d_4 (Conv2D) (None, 40, 150, 128) 147584

conv2d_5 (Conv2D) (None, 40, 150, 128) 147584

max_pooling2d_1 (MaxPooling (None, 20, 75, 128) 0 2D)

conv2d_6 (Conv2D) (None, 18, 73, 128) 147584

conv2d_7 (Conv2D) (None, 16, 71, 128) 147584

max_pooling2d_2 (MaxPooling (None, 8, 35, 128) 0 2D)

conv2d_8 (Conv2D) (None, 6, 33, 128) 147584

conv2d_9 (Conv2D) (None, 4, 31, 128) 147584

max_pooling2d_3 (MaxPooling (None, 2, 15, 128) 0 2D)

flatten (Flatten) (None, 3840) 0

dense (Dense) (None, 384) 1474944

alpha_dropout (AlphaDropout (None, 384) 0 )

dense_1 (Dense) (None, 512) 197120

alpha_dropout_1 (AlphaDropo (None, 512) 0 ut)

dense_2 (Dense) (None, 216) 110808

reshape (Reshape) (None, 6, 36) 0

================================================================= Total params: 2,946,136 Trainable params: 2,946,136 Non-trainable params: 0 ```

眾所周知,對網路的優化可以通過增加網路層數(depth,比如從 ResNet (He et al.)從resnet18到resnet200 ), 也可以通過增加寬度,比如WideResNet (Zagoruyko & Komodakis, 2016)和Mo-bileNets (Howard et al., 2017) 可以擴大網路的width (#channels), 還有就是更大的輸入影象尺寸(resolution)也可以幫助提高精度。——知乎老哥

原本魔改出來的程式碼應用的模型是複用了Alexnet的,但是計算複雜,層數多,引數太多,模型檔案也大,練了一會發現收斂也超級慢。後來結合應用場景進行了一定的trade-off得出了目前這個模型。

不得不說tf2比tf1好上手多了!應用model.fit方法可以開始煉丹了!

我在model.fit裡面加了兩個callback,一個是儲存斷點,一個是儲存歷史資料。

```python def train_cnn(self): x_train, y_train = self.get_train_data() cp_path = './cp.h5' print(np.any(np.isnan(y_train))) print(y_train[0]) save_chec_points = tf.keras.callbacks.ModelCheckpoint( filepath=cp_path, save_weights_only=False, save_best_only=True) try: model = tf.keras.models.load_model(self.model_save_dir) except Exception as e: model = self.generate_model() try: model.load_weights(cp_path) except Exception as e: pass filename = 'log.csv' history_logger = tf.keras.callbacks.CSVLogger( filename, separator=",", append=True)

    history = model.fit(x_train, y_train, batch_size=24,
                        epochs=200, validation_split=0.1, callbacks=[save_chec_points, history_logger],
                        shuffle=True)
    model.save(self.model_save_dir)
    plt.plot(history.history['accuracy'], label='accuracy')
    plt.plot(history.history['val_accuracy'], label='val_accuracy')
    plt.xlabel('Epoch')
    plt.ylabel('Accuracy')
    plt.ylim([0, 1])
    plt.legend(loc='lower right')
    plt.show()

```

在callback裡面加了兩個函式,主要是要儲存checkpoint和每個epoch的資料。

找了個一天長時間開著電腦煉丹,練了200個epoch,畫出來的影象如下(忘了畫loss, nevermind):

Figure_1.png

可以看到訓練完兩百個epoch,模型的accuracy已經有0.9左右。然後我又再來了50個epoch,穩定在0.94。

一百張圖片這個人工智障它能大概看懂94張,這個效能可以了!作為沒有調參的產物還要什麼腳踏車。

2.2.2 踩坑記錄

寫程式碼過程中,總是難免要踩坑。

首先是踩了個天坑,每次一訓練,在第一個epoch裡面loss就會極速增大直到變成NaN...這是啥子情況呢?明明損失函式和啟用函式好像都沒錯呀!

找了好久才發現!

原來是因為我的model程式碼是改官方文件tutorial的,裡面的最後一步到啟用函式softmax就結束了,而training的部分又是從別人的tf1程式碼魔改的...但是找出來categorical_crossentropy的文件,它是需要reshape的,亦即輸出的不應該是(1,216)而是形如(6,36)

complie前加一層model.add(layers.Reshape((self.max_captcha,self.char_set_len))),把它改成(6,36)形狀的輸出,問題可解。

然後,又發現總是煉到一半隨機在某一個epoch裡面loss會變成NaN,把歷史記錄拿出來看毫無頭緒;按照一些網站查了輸入和label是不是NaN,發現也不關事。

結果發現優化演算法是Adam,損失函式是categorical_crossentropy的時候,才會有這種無端變NaN的現象發生,把優化演算法改成sgd或者其他都不會復現...

後來又訓練了別的驗證碼識別模型,發現加上BatchNormalization層收斂更快,每個epoch的lost和accuracy變化更穩定。

如果要predict也很簡單,直接model.predit就可,輸入(N,80,300,1)的張量,輸出(N,6,36),再把輸出的tensor按照onehot的順序匹配即可。

3. 總結

雖然只是一個Toy Project,但我還是傾注了很多心血的,從配環境到寫程式碼到煉丹,第一次成功炮通Tensorflow2的專案。這一路走來,很感激我的GTX1660的默默付出!

這應該是我2021年最後一篇技術類文章惹,完結撒花!