本文来自社区投稿与征集,作者:李传两,TensorFlow 爱好者,喜欢钻研图像分类、图像识别、目标检测等,乐于分享、乐于助人、乐于学习。
上篇文章 发表之后,得到了不少读者的好评,在此感谢大家。上篇主要讲了我们把验证码当成了分类问题去识别,但实际上它是序列问题,识别序列用什么呢?当然是循环神经网络 (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 个小时的苦苦等待,完美的曲线映射眼帘。
loss w/ 2 BiLSTM
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')
loss w/ 1 BiLSTM
accuracy w/ 1 BiLSTM
如此,参数量减少到了 6.2M,发现训练效果依旧一样,同时加快了训练速度,减少了训练时间。由此可见,对于这个问题,模型还有进一步优化的空间。此外,我们或许也可以采用预训练好的图像分类模型,采用模型微调 (Fine tune) 的方式,进一步减少模型训练参数,加速模型训练速度。有兴趣的读者可以进一步探索和研究,相信会有意想不到的收获。
综上,如果需要识别更长的序列,可以在训练集加入不同位数的验证码,也可加入大写和小写字母进行训练。在实际工作和生活中,验证码都会有一些噪点,如果过多不如先进行高斯模糊灰度二值化等处理再进行训练,如果不多直接就加入训练即可。
在此非常感谢知乎上的大佬 FLming,他的文章让我收益良多,同时也要感谢梅超老师的指导,真的是收获满满。希望大家都能动手试一把,真的很爽的。全部代码见 GitHub,如需查看可视化的训练结果,见 Tensorboard。