Keras 識別驗證碼

使用深度學習來破解 captcha 驗證碼

本項目會通過 Keras 搭建一個深度卷積神經網絡來識別 captcha 驗證碼,建議使用顯卡來運行該項目。

下面的可視化代碼都是在 jupyter notebook 中完成的,如果你希望寫成 python 腳本,稍加修改即可正常運行,當然也可以去掉這些可視化代碼。Keras 版本:1.2.2。

captcha

captcha 是用 python 寫的生成驗證碼的庫,它支持圖片驗證碼和語音驗證碼,我們使用的是它生成圖片驗證碼的功能。

首先我們設置我們的驗證碼格式爲數字加大寫字母,生成一串驗證碼試試看:

from captcha.image import ImageCaptcha
import matplotlib.pyplot as plt
import numpy as np
import random

%matplotlib inline
%config InlineBackend.figure_format = 'retina'

import string
characters = string.digits + string.ascii_uppercase
print(characters)

width, height, n_len, n_class = 170, 80, 4, len(characters)

generator = ImageCaptcha(width=width, height=height)
random_str = ''.join([random.choice(characters) for j in range(4)])
img = generator.generate_image(random_str)

plt.imshow(img)
plt.title(random_str)

數據生成器

訓練模型的時候,我們可以選擇兩種方式來生成我們的訓練數據,一種是一次性生成幾萬張圖,然後開始訓練,一種是定義一個數據生成器,然後利用 fit_generator 函數來訓練。

第一種方式的好處是訓練的時候顯卡利用率高,如果你需要經常調參,可以一次生成,多次使用;第二種方式的好處是你不需要生成大量數據,訓練過程中可以利用 CPU 生成數據,而且還有一個好處是你可以無限生成數據。

我們的數據格式如下:

X

X 的形狀是 (batch_size, height, width, 3),比如一批生成32個樣本,圖片寬度爲170,高度爲80,那麼形狀就是 (32, 80, 170, 3),取第一張圖就是 X[0]

y

y 的形狀是四個 (batch_size, n_class),如果轉換成 numpy 的格式,則是 (n_len, batch_size, n_class),比如一批生成32個樣本,驗證碼的字符有36種,長度是4位,那麼它的形狀就是4個 (32, 36),也可以說是 (4, 32, 36),解碼函數在下個代碼塊。

def gen(batch_size=32):
    X = np.zeros((batch_size, height, width, 3), dtype=np.uint8)
    y = [np.zeros((batch_size, n_class), dtype=np.uint8) for i in range(n_len)]
    generator = ImageCaptcha(width=width, height=height)
    while True:
        for i in range(batch_size):
            random_str = ''.join([random.choice(characters) for j in range(4)])
            X[i] = generator.generate_image(random_str)
            for j, ch in enumerate(random_str):
                y[j][i, :] = 0
                y[j][i, characters.find(ch)] = 1
        yield X, y

上面就是一個可以無限生成數據的例子,我們將使用這個生成器來訓練我們的模型。

使用生成器

生成器的使用方法很簡單,只需要用 next 函數即可。下面是一個例子,生成32個數據,然後顯示第一個數據。當然,在這裏我們還對生成的 One-Hot 編碼後的數據進行了解碼,首先將它轉爲 numpy 數組,然後取36個字符中最大的數字的位置,因爲神經網絡會輸出36個字符的概率,然後將概率最大的四個字符的編號轉換爲字符串。

def decode(y):
    y = np.argmax(np.array(y), axis=2)[:,0]
    return ''.join([characters[x] for x in y])

X, y = next(gen(1))
plt.imshow(X[0])
plt.title(decode(y))

構建深度卷積神經網絡

from keras.models import *
from keras.layers import *

input_tensor = Input((height, width, 3))
x = input_tensor
for i in range(4):
    x = Convolution2D(32*2**i, 3, 3, activation='relu')(x)
    x = Convolution2D(32*2**i, 3, 3, activation='relu')(x)
    x = MaxPooling2D((2, 2))(x)

x = Flatten()(x)
x = Dropout(0.25)(x)
x = [Dense(n_class, activation='softmax', name='c%d'%(i+1))(x) for i in range(4)]
model = Model(input=input_tensor, output=x)

model.compile(loss='categorical_crossentropy',
              optimizer='adadelta',
              metrics=['accuracy'])

模型結構很簡單,特徵提取部分使用的是兩個卷積,一個池化的結構,這個結構是學的 VGG16 的結構。之後我們將它 Flatten,然後添加 Dropout ,儘量避免過擬合問題,最後連接四個分類器,每個分類器是36個神經元,輸出36個字符的概率。

模型可視化

得益於 Keras 自帶的可視化,我們可以使用幾句代碼來可視化模型的結構:

from keras.utils.visualize_util import plot
from IPython.display import Image

plot(model, to_file="model.png", show_shapes=True)
Image('model.png')

這裏需要使用 pydot 這個庫,以及 graphviz 這個庫,在 macOS 系統上安裝方法如下:

brew install graphviz
pip install pydot-ng

我們可以看到最後一層卷積層輸出的形狀是 (1, 6, 256),已經不能再加捲積層了。

訓練模型

訓練模型反而是所有步驟裏面最簡單的一個,直接使用 model.fit_generator 即可,這裏的驗證集使用了同樣的生成器,由於數據是通過生成器隨機生成的,所以我們不用考慮數據是否會重複。注意,這段代碼在筆記本上可能要耗費一下午時間。如果你想讓模型預測得更準確,可以將 nb_epoch 改爲 10 或者 20,但它也將耗費成倍的時間。注意我們這裏使用了一個小技巧,添加 nb_worker=2 參數讓 Keras 自動實現多進程生成數據,擺脫 python 單線程效率低的缺點。

model.fit_generator(gen(), samples_per_epoch=51200, nb_epoch=5, 
                    nb_worker=2, pickle_safe=True, 
                    validation_data=gen(), nb_val_samples=1280)

測試模型

當我們訓練完成以後,可以識別一個驗證碼試試看:

X, y = next(gen(1))
y_pred = model.predict(X)
plt.title('real: %s\npred:%s'%(decode(y), decode(y_pred)))
plt.imshow(X[0], cmap='gray')

計算模型總體準確率

模型在訓練的時候只會顯示每一個字符的準確率,爲了統計模型的總體準確率,我們可以寫下面的函數:

from tqdm import tqdm
def evaluate(model, batch_num=20):
    batch_acc = 0
    generator = gen()
    for i in tqdm(range(batch_num)):
        X, y = next(generator)
        y_pred = model.predict(X)
        y_pred = np.argmax(y_pred, axis=2).T
        y_true = np.argmax(y, axis=2).T
        batch_acc += np.mean(map(np.array_equal, y_true, y_pred))
    return batch_acc / batch_num

evaluate(model)

這裏用到了一個庫叫做 tqdm,它是一個進度條的庫,爲的是能夠實時反饋進度。然後我們通過一些 numpy 計算去統計我們的準確率,這裏計算規則是隻要有一個錯,那麼就不算它對。經過計算,我們的模型的總體準確率在經過五代訓練就可以達到 90%,繼續訓練還可以達到更高的準確率。

模型總結

模型的大小是16MB,在我的筆記本上跑1000張驗證碼需要用20秒,當然,顯卡會更快。對於驗證碼識別的問題來說,哪怕是10%的準確率也已經稱得上破解,畢竟假設100%識別率破解要一個小時,那麼10%的識別率也只用十個小時,還算等得起,而我們的識別率有90%,已經可以稱得上完全破解了這類驗證碼。

改進

對於這種按順序書寫的文字,我們還有一種方法可以使用,那就是循環神經網絡來識別序列。下面我們來了解一下如何使用循環神經網絡來識別這類驗證碼。

CTC Loss

這個 loss 是一個特別神奇的 loss,它可以在只知道序列的順序,不知道具體位置的情況下,讓模型收斂。在這方面百度似乎做得很不錯,利用它來識別音頻信號。(warp-ctc

那麼在 Keras 裏面,CTC Loss 已經內置了,我們直接定義這樣一個函數,即可實現 CTC Loss,由於我們使用的是循環神經網絡,所以默認丟掉前面兩個輸出,因爲它們通常無意義,且會影響模型的輸出。

  • y_pred 是模型的輸出,是按順序輸出的37個字符的概率,因爲我們這裏用到了循環神經網絡,所以需要一個空白字符的概念;
  • labels 是驗證碼,是四個數字;
  • input_length 表示 y_pred 的長度,我們這裏是15;
  • label_length 表示 labels 的長度,我們這裏是4。
from keras import backend as K

def ctc_lambda_func(args):
    y_pred, labels, input_length, label_length = args
    y_pred = y_pred[:, 2:, :]
    return K.ctc_batch_cost(labels, y_pred, input_length, label_length)

模型結構

我們的模型結構是這樣設計的,首先通過卷積神經網絡去識別特徵,然後經過一個全連接降維,再按水平順序輸入到一種特殊的循環神經網絡,叫 GRU,它具有一些特殊的性質,爲什麼用 GRU 而不用 LSTM 呢?總的來說就是它的效果比 LSTM 好,所以我們用它。

from keras.models import *
from keras.layers import *
rnn_size = 128

input_tensor = Input((width, height, 3))
x = input_tensor
for i in range(3):
    x = Convolution2D(32, 3, 3, activation='relu')(x)
    x = Convolution2D(32, 3, 3, activation='relu')(x)
    x = MaxPooling2D(pool_size=(2, 2))(x)

conv_shape = x.get_shape()
x = Reshape(target_shape=(int(conv_shape[1]), int(conv_shape[2]*conv_shape[3])))(x)

x = Dense(32, activation='relu')(x)

gru_1 = GRU(rnn_size, return_sequences=True, init='he_normal', name='gru1')(x)
gru_1b = GRU(rnn_size, return_sequences=True, go_backwards=True, 
             init='he_normal', name='gru1_b')(x)
gru1_merged = merge([gru_1, gru_1b], mode='sum')

gru_2 = GRU(rnn_size, return_sequences=True, init='he_normal', name='gru2')(gru1_merged)
gru_2b = GRU(rnn_size, return_sequences=True, go_backwards=True, 
             init='he_normal', name='gru2_b')(gru1_merged)
x = merge([gru_2, gru_2b], mode='concat')
x = Dropout(0.25)(x)
x = Dense(n_class, init='he_normal', activation='softmax')(x)
base_model = Model(input=input_tensor, output=x)

labels = Input(name='the_labels', shape=[n_len], dtype='float32')
input_length = Input(name='input_length', shape=[1], dtype='int64')
label_length = Input(name='label_length', shape=[1], dtype='int64')
loss_out = Lambda(ctc_lambda_func, output_shape=(1,), 
                  name='ctc')([x, labels, input_length, label_length])

model = Model(input=[input_tensor, labels, input_length, label_length], output=[loss_out])
model.compile(loss={'ctc': lambda y_true, y_pred: y_pred}, optimizer='adadelta')

模型可視化

可視化的代碼同上,這裏只貼圖。

可以看到模型比上一個模型複雜了許多,但實際上只是因爲輸入比較多,所以它顯得很大。還有一個值得注意的地方,我們的圖片在輸入的時候是經過了旋轉的,這是因爲我們希望以水平方向輸入,而圖片在 numpy 裏默認是這樣的形狀:(height, width, 3),因此我們使用了 transpose 函數將圖片轉爲了(width, height, 3)的格式,然後經過各種卷積和降維,變成了 (17, 32),這裏的每個長度爲32的向量都代表一個豎條的圖片的特徵,從左到右,一共有17條。然後我們兵分兩路,一路從左到右輸入到 GRU,一路從右到左輸入到 GRU,然後將他們輸出的結果加起來。再兵分兩路,還是一路正方向,一路反方向,只不過第二次我們直接將它們的輸出連起來,然後經過一個全連接,輸出每個字符的概率。

數據生成器

def gen(batch_size=128):
    X = np.zeros((batch_size, width, height, 3), dtype=np.uint8)
    y = np.zeros((batch_size, n_len), dtype=np.uint8)
    while True:
        generator = ImageCaptcha(width=width, height=height)
        for i in range(batch_size):
            random_str = ''.join([random.choice(characters) for j in range(4)])
            X[i] = np.array(generator.generate_image(random_str)).transpose(1, 0, 2)
            y[i] = [characters.find(x) for x in random_str]
        yield [X, y, np.ones(batch_size)*int(conv_shape[1]-2), 
               np.ones(batch_size)*n_len], np.ones(batch_size)

評估模型

def evaluate(model, batch_num=10):
    batch_acc = 0
    generator = gen()
    for i in range(batch_num):
        [X_test, y_test, _, _], _  = next(generator)
        y_pred = base_model.predict(X_test)
        shape = y_pred[:,2:,:].shape
        ctc_decode = K.ctc_decode(y_pred[:,2:,:], 
                                  input_length=np.ones(shape[0])*shape[1])[0][0]
        out = K.get_value(ctc_decode)[:, :4]
        if out.shape[1] == 4:
            batch_acc += ((y_test == out).sum(axis=1) == 4).mean()
    return batch_acc / batch_num

我們會通過這個函數來評估我們的模型,和上面的評估標準一樣,只有全部正確,我們纔算預測正確,中間有個坑,就是模型最開始訓練的時候,並不一定會輸出四個字符,所以我們如果遇到所有的字符都不到四個的時候,就不計算了,相當於加0,遇到多於4個字符的時候,只取前四個。

評估回調

因爲 Keras 沒有針對這種輸出計算準確率的選項,因此我們需要自定義一個回調函數,它會在每一代訓練完成的時候計算模型的準確率。

from keras.callbacks import *

class Evaluate(Callback):
    def __init__(self):
        self.accs = []
    
    def on_epoch_end(self, epoch, logs=None):
        acc = evaluate(base_model)*100
        self.accs.append(acc)
        print
        print 'acc: %f%%'%acc

evaluator = Evaluate()

訓練模型

由於 CTC Loss 收斂很慢,所以我們需要設置比較大的代數,這裏我們設置了100代,然後添加了一個早期停止的回調和我們上面定義的回調,但是第一次訓練只訓練37代就停了,測試準確率才95%,我又在這個基礎上繼續訓練了一次,停在了25代,得到了98%的準確率,所以一共訓練了62代。

model.fit_generator(gen(128), samples_per_epoch=51200, nb_epoch=200,
                    callbacks=[EarlyStopping(patience=10), evaluator],
                    validation_data=gen(), nb_val_samples=1280)

測試模型

characters2 = characters + ' '
[X_test, y_test, _, _], _  = next(gen(1))
y_pred = base_model.predict(X_test)
y_pred = y_pred[:,2:,:]
out = K.get_value(K.ctc_decode(y_pred, input_length=np.ones(y_pred.shape[0])*y_pred.shape[1], )[0][0])[:, :4]
out = ''.join([characters[x] for x in out[0]])
y_true = ''.join([characters[x] for x in y_test[0]])

plt.imshow(X_test[0].transpose(1, 0, 2))
plt.title('pred:' + str(out) + '\ntrue: ' + str(y_true))

argmax = np.argmax(y_pred, axis=2)[0]
list(zip(argmax, ''.join([characters2[x] for x in argmax])))

這裏隨機出來的驗證碼很厲害,是O0OP,不過更厲害的是模型認出來了。

有趣的問題

我又用之前的模型做了個測試,對於 O0O0 這樣喪心病狂的驗證碼,模型偶爾也能正確識別,這讓我非常驚訝,它是真的能識別 O 與 0 的差別呢,還是猜出來的呢?這很難說。

generator = ImageCaptcha(width=width, height=height)
random_str = 'O0O0'
X = generator.generate_image(random_str)
X = np.expand_dims(X, 0)

y_pred = model.predict(X)
plt.title('real: %s\npred:%s'%(random_str, decode(y_pred)))
plt.imshow(X[0], cmap='gray')

總結

模型的大小是4.7MB,在我的筆記本上跑1000張驗證碼需要用14秒,平均一秒識別71張,估計可以拼過網速。

最後附上一張本模型識別 HACK 。

參考鏈接

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