CRNN+CTC 识别验证码

本文来自社区投稿与征集,作者:李传两,TensorFlow 爱好者,喜欢钻研图像分类、图像识别、目标检测等,乐于分享、乐于助人、乐于学习。

GitHub: GitHub - YouSmart2016/crnnvalidcode

上篇文章 发表之后,得到了不少读者的好评,在此感谢大家。上篇主要讲了我们把验证码当成了分类问题去识别,但实际上它是序列问题,识别序列用什么呢?当然是循环神经网络 (RNN) 了,由于 RNN 在序列变长时会出现梯度消散和梯度爆炸的问题,为了解决这个问题,出现了它的改进版长短时记忆网络 (LSTM),双向 LSTM(BiLSTM) 又是 LSTM 的改进版。有了 BiLSTM,我们还需要卷积神经网络 (CNN) 进行特征提取,两者合二为一,名叫端到端识别 (CRNN),可以处理任意长度的序列。连接时序分类器 (CTC) 是损失函数, 主要是解决标签与输出不对齐的问题,它会将连续相同的字符做去重,空白字符做去重。

他们真的可以识别吗?纸上得来终觉浅呀,自己不亲自实验一下,总觉得有点不信,那就来吧。本次实验的开发环境是 Python为3.7,TensorFlow 为 2.2.0,理论上该代码在 TF 2.0+ 版本都能跑起来。

在查阅资料,参考了不少案例,发现一个问题就是配置参数太多,我只想一键启动,其次数据集太大,训练时间长,普通电脑难以承受。MJSynth 数据集大约 10GB,我这 i7 的 CPU 吃不消呀!自己造数据吧。

第一步:生成数据集

图片高 32px,宽在 64~100px 之间,黑底白字,4 位 0~9 的数字,命名方式为 label_index.jpg,共计生成 16000 张,以 9:1 进行拆分为训练集和验证集代码如下:

def gen_captcha(img_dir, 
num_per_image, 
number,choices):
  if os.path.exists(img_dir):
      shutil.rmtree(img_dir,ignore_errors=True)
  if not os.path.exists(img_dir):
      os.makedirs(img_dir)
  count=0
  random_widths = list(range(64, 100, 1))
  for _ in range(number):
      for i in itertools.permutations(choices, num_per_image):
          if count>=number:
              break
          else:
              captcha = ''.join(i)
              fn = os.path.join(img_dir, '%s_%s.jpg' % (captcha,count))
              ima = ImageCaptcha(width=random_widths[int(random.random()*36)], height=32, font_sizes=(26, 28, 30))
              ima = ima.create_captcha_image(chars=captcha, color='white', background='black')
              ima.save(fn)
              count += 1

img_dir:保存图片的路径,num_per_image:每张图片生成几个字符,number:生成多少张,choices:表示字符集合

第二步:搭建网络模型

由于我们的训练样本的 shape 是 321001,样本尺寸较小,背景单一,没有干扰点,且是4位数字验证码,因而我们采用了简单的 VGG 网络,并未采用 InceptionNet,ResNet。我们使用 Keras 的 Function API 来构建模型,全部用 33 卷积核和 22 的池化核,VGG 过后,再经过双向的 BiLSTM 循环网络,再过全连接层,最后输出为 (None,24,11),其中第 1 维度指 batchsize,第 2 维度指 24 个时间步,每个时间步输出一个最大概率的字符,第三维度表示 11 个分类(10 个数字+1 个 Blank),高 32px 经多次卷积采样成 1,模型使用的 loss 是继承自 keras.losses.Loss 的自定义的 CTCLoss 类,metrics 指定的是继承 keras.metrics.Metric 的自定义的 WorAccuracy 类,回调配置的是以 checkpoints 形式进行保存,每训练完一个 Epoch 保存一次,使用 TensorFlow 自带的 Tensorboard 进行观察 loss 和 accuracy 的变化,具体网络概述如下:

Model summary

Layer (type) Output Shape Param #
input_1 [(None, 32, 100, 1)] 0
conv2d (None, 32, 100, 64) 640
max_pooling2d (None, 16, 50, 64) 0
conv2d_1 (None, 16,50, 128) 73856
max_pooling2d_1 (None, 8, 25, 128) 0
conv2d_2 (None, 8, 25, 256) 294912
batch_normalization (None, 8, 25, 256) 1024
activation (None, 8, 25, 256) 0
conv2d_3 (None, 8, 25, 256) 590080
max_pooling2d_2 (None, 4, 25, 256) 0
conv2d_4 (None, 4, 25, 512) 1179648
batch_normalization_1 (None, 4, 25, 512) 2048
activation_1 (None, 4, 25, 512) 0
conv2d_5 (None, 4, 25, 512) 2359808
max_pooling2d_3 (None, 2, 25, 512) 0
conv2d_6 (None, 1, 24, 512) 1048576
batch_normalization_2 (None, 1, 24, 512) 2048
activation_2 (None, 1,24, 512) 0
reshape (None, 24, 512) 0
bidirectional (None,24, 512) 1574912
bidirectional_1 (None, 24, 512) 1574912
dense (None, 24, 11) 5643

Total params: 8,708,107

Trainable params: 8,705,547

Non-trainable params: 2,560

以下是2 BiLSTM的代码:

def vgg_style(input_tensor):

    x = layers.Conv2D(64, 3, padding='same', activation='relu')(input_tensor)
    x = layers.MaxPool2D(pool_size=2, padding='same')(x)

    x = layers.Conv2D(128, 3, padding='same', activation='relu')(x)
    x = layers.MaxPool2D(pool_size=2, padding='same')(x)

    x = layers.Conv2D(256, 3, padding='same', use_bias=False)(x)
    x = layers.BatchNormalization()(x)
    x = layers.Activation('relu')(x)
    x = layers.Conv2D(256, 3, padding='same', activation='relu')(x)
    x = layers.MaxPool2D(pool_size=2, strides=(2, 1), padding='same')(x)

    x = layers.Conv2D(512, 3, padding='same', use_bias=False)(x)
    x = layers.BatchNormalization()(x)
    x = layers.Activation('relu')(x)
    x = layers.Conv2D(512, 3, padding='same', activation='relu')(x)

    x = layers.MaxPool2D(pool_size=2, strides=(2, 1), padding='same')(x)

    x = layers.Conv2D(512, 2, use_bias=False)(x)
    x = layers.BatchNormalization()(x)
    x = layers.Activation('relu')(x)
    return x

def build_model(num_classes, image_width=None, channels=1):

    img_input = keras.Input(shape=(32, image_width, channels))
    x = vgg_style(img_input)
    x = layers.Reshape((-1, 512))(x)

    x = layers.Bidirectional(layers.LSTM(units=256, return_sequences=True))(x)
    x = layers.Bidirectional(layers.LSTM(units=256, return_sequences=True))(x)
    x = layers.Dense(units=num_classes)(x)
    return keras.Model(inputs=img_input, outputs=x, name='CRNN')

第三步:开始训练

终于等到你,还好没有放弃,在历经 12 个小时的苦苦等待,完美的曲线映射眼帘。

image

loss w/ 2 BiLSTM

image

accuracy w/ 2 BiLSTM

第四步:测试样本

综合上面两图我们可以发现在第 15 回合之后时 loss 和 accuracy 已经达到很好了,在第 60 回合时,val_loss 稍稍有上升,val_accuracy 稍稍有下降,epoch 的设置也不是越大越好,多训练,多观察观察,见好就收。

本次测试我取的是第 18 回合保存的模型进行测试,读者也可以选择其他回合的模型测试。首先我们先测试一下 100 张 4 位验证码

4 位验证码测试图

Unbelievable,我没看错吧,居然全对,1 和 7 我都分不清,神经网络居然都能识别出来,太神奇了,简直了,再来 100 张测试一下。

4 位验证码测试图

我滴神呀,真绝了,我简直不敢相信,既然它可以识别任意长度的序列,那 1 位,2 位,3 位,5 位,6 位的验证码都能识别吗?为此,我都测试了一番。

1 位验证码测试图

2 位验证码测试图

3 位验证码测试图

5 位验证码测试图

6 位验证码测试图

4 位及 4 位以下验证码基本上 100% 识别,5 位验证码成功识别 94 张,错误 6 张,6 位验证码成功识别 71 张,错误 29 张,这样就结束了吗?远不止,请读者继续往下看。

第五步:模型优化

回顾我们的建模,16K 的样本量相对于模型的可训练参数 (~9M) 小很多,感觉模型过大容易导致过拟合问题。因此,我试着去掉了一个 BiLSTM,并将 LSTM 的 hidden units 改成了 128,以下是我们的代码改动及模型。

1 个 BiLSTM 代码

def vgg_style(input_tensor):
    x = layers.Conv2D(64, 3, padding='same', activation='relu')(input_tensor)
    x = layers.MaxPool2D(pool_size=2, padding='same')(x)

    x = layers.Conv2D(128, 3, padding='same', activation='relu')(x)
    x = layers.MaxPool2D(pool_size=2, padding='same')(x)

    x = layers.Conv2D(256, 3, padding='same', use_bias=False)(x)
    x = layers.BatchNormalization()(x)
    x = layers.Activation('relu')(x)
    x = layers.Conv2D(256, 3, padding='same', activation='relu')(x)
    x = layers.MaxPool2D(pool_size=2, strides=(2, 1), padding='same')(x)

    x = layers.Conv2D(512, 3, padding='same', use_bias=False)(x)
    x = layers.BatchNormalization()(x)
    x = layers.Activation('relu')(x)
    x = layers.Conv2D(512, 3, padding='same', activation='relu')(x)
   
    x = layers.MaxPool2D(pool_size=2, strides=(2, 1), padding='same')(x)

    x = layers.Conv2D(512, 2, use_bias=False)(x)
    x = layers.BatchNormalization()(x)
    x = layers.Activation('relu')(x)
    return x

def build_model(num_classes, image_width=None, channels=1):
    img_input = keras.Input(shape=(32, image_width, channels))
    x = vgg_style(img_input)
    x = layers.Reshape((-1, 512))(x)
    x = layers.Bidirectional(layers.LSTM(units=128, return_sequences=True))(x)
    x = layers.Dense(units=num_classes)(x)
    return keras.Model(inputs=img_input, outputs=x, name='CRNN')

image

loss w/ 1 BiLSTM

image

accuracy w/ 1 BiLSTM

如此,参数量减少到了 6.2M,发现训练效果依旧一样,同时加快了训练速度,减少了训练时间。由此可见,对于这个问题,模型还有进一步优化的空间。此外,我们或许也可以采用预训练好的图像分类模型,采用模型微调 (Fine tune) 的方式,进一步减少模型训练参数,加速模型训练速度。有兴趣的读者可以进一步探索和研究,相信会有意想不到的收获。

综上,如果需要识别更长的序列,可以在训练集加入不同位数的验证码,也可加入大写和小写字母进行训练。在实际工作和生活中,验证码都会有一些噪点,如果过多不如先进行高斯模糊灰度二值化等处理再进行训练,如果不多直接就加入训练即可。

在此非常感谢知乎上的大佬 FLming,他的文章让我收益良多,同时也要感谢梅超老师的指导,真的是收获满满。希望大家都能动手试一把,真的很爽的。全部代码见 GitHub,如需查看可视化的训练结果,见 Tensorboard

参考资料:https://zhuanlan.zhihu.com/p/122512498

中文:TensorFlow 公众号