用Tensorflow2实现卷积神经网络(CNN)识别图片验证码

2,479 阅读7分钟

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

几行代码实现把图片灰度化然后去掉周围的黑线:

    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+张)作为训练集,可以开始尝试炼丹了。

不得不说Tensorflow@1.x时代太辉煌了,现在找到的大多数是1.x版本的源码。

于是看了看github上面一些仓库后就参考官方文档的tutorial开始手撸(魔改)。

思路就是把训练集中标注好的验证码图片按照对应的字符集用One-Hot喂进模型。从前面的验证码图可以看出,它一个图有六个字符,每个字符共有10个数字+26个大写字母=36种可能性。因此它是一个多分类(multi-class)模型,总的类别是6*36=216,损失函数categorical_crossentropy,激活函数softmax

以下是搭建模型的代码部分(当然主体是在一个类里面的,略去了)

    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出来的网络模型长这样:

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,一个是保存断点,一个是保存历史数据。

    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年最后一篇技术类文章惹,完结撒花!