CRNN+CTC实现不定长验证码识别(keras模型-训练篇)

前言

  本文为CRNN+CTC实现不定长验证码识别(keras模型-示例篇)的续篇,示例篇中使用的字符仅为数字,本文将训练集拓展到包含数字字母在内的数据集,同时替换了模型中的部分网络层试图提高效果及效率(未验证),并在训练过程使用了一些小技巧(tricks),极大程度上避免因数据集字符数量的拓展而导致模型不收敛的问题。

运行环境

  • python环境(anaconda+python3.7.4)
第三方库版本
captcha0.3
matplotlib3.1.1
numpy1.17.2
opencv-python4.1.1
seaborn0.9.0
tensorflow1.14.0

p.s.: 由于以下部分代码使用了f-string格式字符串,因此要求python版本 ≥ \geq 3.6

  • 硬件环境
    cpu: Intel® Xeon® CPU E5-2630 v3 @ 2.40GHz
    硬盘:固态硬盘阵列

生成数据集

  与示例篇一样,这里我们同样利用captcha使用其默认图片大小随机生成长度从4到7的验证码各最多100000张(由于直接以验证码的标签作为文件名,可能出现文件重名而覆盖),并保存到当前工作目录的img_dir目录下,此处生成数据集代码与示例篇代码除生成数量外无异,代码如下

from captcha.image import ImageCaptcha
import os
import random
import string

chars = string.digits + string.ascii_letters # 验证码字符集

def generate_img(img_dir: '图片保存目录'='img_dir'):
    img_generator = ImageCaptcha()
    for length in range(4, 8): # 验证码长度
        if not os.path.exists(f'{img_dir}/{length}'):
            os.makedirs(f'{img_dir}/{length}')
        for _ in range(100000): 
            char = ''.join([random.choice(chars) for _ in range(length)])
            img_generator.write(chars=char, output=f'{img_dir}/{length}/{char}.jpg')
generate_img()

  生成图片后,我们使用opencv读取图片到内存中,这里为了保持验证码的特征,不再像示例篇中作缩小并灰度化的处理。读入图片后每张图片的shape为【图片高度,图片宽度,BGR通道数】,最后将图片真实标签编码为数字备用,代码如下

import cv2
import numpy as np

char_map = {chars[c]: c for c in range(len(chars))} # 验证码编码(0到len(chars) - 1)

def load_img(img_dir: '图片保存目录'='img_dir', min_length: '最小长度'=4, max_length: '最大长度'=7):
    labels = {length: [] for length in range(min_length, max_length + 1)} # 验证码真实标签{长度:标签列表}
    imgs = {length: [] for length in range(min_length, max_length + 1)} # 图片BGR数据字典{长度:BGR数据列表}
    ### 读取图片
    for length in range(min_length, max_length + 1):
        for file in os.listdir(f'{img_dir}/{length}'):
            img = cv2.imread(f'{img_dir}/{length}/{file}')
            labels[length].append(file[:-4])
            imgs[length].append(img)

    ### 编码真实标签
    labels_encode = {length: [] for length in range(min_length, max_length + 1)}
    for length in range(min_length, max_length + 1):
        for label in labels[length]:
            label = [char_map[i] for i in label]
            labels_encode[length].append(label)
    return imgs, labels, labels_encode
imgs, labels, labels_encode = load_img()

构建网络模型

  这里构建的网络模型总体结构与示例篇基本一致,区别只在于中间的卷积层和循环神经网络层,构建出的训练用网络模型如下在这里插入图片描述  这里我不再解释模型末端的设计,详情可参考示例篇。其中输入固定高度为60;循环层使用了双向(Bidirectional)GRU(据说GRU效果跟LSTM差不多,计算效率更高),由于使用的是cpu版的tensorflow,因此没有使用针对cudnn优化过的CuDNNGRU,如有条件可将里面的GRU替换为CuDNNGRU,模型的实现代码如下

import tensorflow as tf
import tensorflow.keras as keras
import tensorflow.keras.backend as K
from tensorflow.keras.layers import BatchNormalization
from tensorflow.keras.layers import Bidirectional
from tensorflow.keras.layers import Conv2D
from tensorflow.keras.layers import Dense
from tensorflow.keras.layers import Flatten
from tensorflow.keras.layers import GRU
from tensorflow.keras.layers import Input
from tensorflow.keras.layers import Lambda
from tensorflow.keras.layers import MaxPooling2D
from tensorflow.keras.layers import Permute
from tensorflow.keras.layers import ReLU
from tensorflow.keras.layers import Reshape
from tensorflow.keras.layers import TimeDistributed
from tensorflow.keras.models import Model, Sequential
from tensorflow.keras.optimizers import Adadelta

def ctc_loss(args):
    return K.ctc_batch_cost(*args)
def ctc_decode(softmax):
    return K.ctc_decode(softmax, K.tile([K.shape(softmax)[1]], [K.shape(softmax)[0]]))[0][0]
def char_decode(label_encode): 
    return [''.join([idx_map[column] for column in row]) for row in label_encode]

labels_input = Input([None], dtype='int32')
sequential = Sequential([
    Conv2D(filters=64, kernel_size=(3, 3), activation='relu', padding='same', input_shape=[60, None, 3]),
    Conv2D(filters=64, kernel_size=(3, 3), activation='relu', padding='same'),
    MaxPooling2D(pool_size=(2, 2)),
    Conv2D(filters=128, kernel_size=(3, 3), activation='relu', padding='same'),
    Conv2D(filters=128, kernel_size=(3, 3), activation='relu', padding='same'),
    MaxPooling2D(pool_size=(2, 2)),
    Conv2D(filters=256, kernel_size=(3, 3), activation='relu', padding='same'),
    Conv2D(filters=256, kernel_size=(3, 3), activation='relu', padding='same'),
    MaxPooling2D(pool_size=(2, 2)),
    Conv2D(filters=512, kernel_size=(3, 3), activation='relu', padding='same'),
    Conv2D(filters=512, kernel_size=(3, 3), activation='relu', padding='same'),
    MaxPooling2D(pool_size=(2, 1)),
    Permute((2, 1, 3)),
    TimeDistributed(Flatten()),
    Bidirectional(GRU(128,return_sequences=True)),
    Bidirectional(GRU(128,return_sequences=True)),
    TimeDistributed(Dense(len(chars) + 1, activation='softmax'))
])
input_length = Lambda(lambda x: K.tile([[K.shape(x)[1]]], [K.shape(x)[0], 1]))(sequential.output)
label_length = Lambda(lambda x: K.tile([[K.shape(x)[1]]], [K.shape(x)[0], 1]))(labels_input)
output = Lambda(ctc_loss)([labels_input, sequential.output, input_length, label_length])
fit_model = Model(inputs=[sequential.input, labels_input], outputs=output)
ctc_decode_output = Lambda(ctc_decode)(sequential.output)
model = Model(inputs=sequential.input, outputs=ctc_decode_output)
adadelta = Adadelta(lr=0.05)
fit_model.compile(
    loss=lambda y_true, y_pred: y_pred,
    optimizer=adadelta)
fit_model.summary()

初步训练模型

  同示例篇,这里我们利用keras.Model的训练接口fit_generator来随机生成数据训练模型,该函数需要传入一个数据生成器,生成器函数代码如下

def generate_data_fixed_length(imgs, labels_encode, batch_size):
    imgs = np.array(imgs) # 图片BGR数据字典{长度:BGR数据数组}
    labels_encode = np.array(labels_encode) # 验证码真实标签{长度:标签数组}
    while True:
        test_idx = np.random.choice(range(len(imgs)), batch_size)
        batch_imgs = imgs[test_idx]
        batch_labels = labels_encode[test_idx]
        yield ([batch_imgs, batch_labels], None) # 元组的第一个元素为输入,第二个元素为训练标签,即自定义loss函数时的y_true

  下面可以开始训练模型了,只是如果直接利用生成器使用所有数据进行训练,模型会一直不收敛,因此必须采取另外一种训练的策略,即先在一个数据量小的训练集中训练模型直到loss下降到一定程度,再扩大训练集的规模,并重复训练的步骤,一直在所有训练数据都有较低的loss为止。为了能够控制模型训练过程中当loss下降到某一阀值就停止,我们还需利用keras的训练接口中的参数callbacks,实现一个自定义停止器1并传参给它,以下是停止器的代码

class StopTraining(keras.callbacks.Callback):
    def __init__(self, thres):
        super(StopTraining, self).__init__()
        self.thres = thres
    def on_epoch_end(self, batch, logs={}):
        if logs.get('loss') < self.thres:
            self.model.stop_training = True

  该停止器初始化时需要一个参数thres,其代表训练过程中当一轮的平均loss小于该值就停止训练。之后我们便可以利用这个停止器来控制训练过程是否提前停止,训练模型的代码如下

import math
import time

for length in imgs.keys():
    for size in range(2, int(math.ceil(math.log10(len(imgs[length])))) + 1):
        sample_size = min(len(imgs[length]), 10 ** size)
        fit_model.fit_generator(
            generate_data_fixed_length(imgs[length][: sample_size], labels_encode[length][: sample_size], 32), 
            epochs=100, 
            steps_per_epoch=100, 
            verbose=1,
            callbacks=[StopTraining(1)])
    time_str = time.strftime("%Y_%m_%d_%H_%M_%S")
    fit_model.save_weights(f'model/fit_model_{time_str}.ckpt')
    model.save_weights(f'model/model_{time_str}.ckpt')

  这里我分别对每一种长度的训练数据集作训练,从长度为4的训练数据集开始,初始训练集定为当前长度训练集的前100个样本,每次训练到平均loss小于1后就停止并将训练集的数量级增加1,直到训练完当前长度的所有训练数据,再使用其他长度的训练数据集进行训练,最后每训练完一种长度的数据集就保存当前模型的参数。这里的训练过程花费了我近13个小时,该数据仅供参考。

测试模型

  训练结束后,该是测试模型的时候了,测试之前,我们同样定义一个测试数据生成器生成测试数据,代码如下

width = 160
height = 60  

def generate_captcha():
    img_generator = ImageCaptcha(width=width, height=height)   
    def generator(char): 
        return np.asarray(img_generator.generate_image(char))
    return generator

def generate_test_data(generate_img, batch_size):
    while True:
        test_labels_batch = []
        test_imgs_batch = []
        length = random.randint(4, 7)
        for _ in range(batch_size):
            char = ''.join([random.choice(chars) for _ in range(length)])
            img = generate_img(char)
            test_labels_batch.append(char)
            test_imgs_batch.append(img)
        yield([np.array(test_imgs_batch), np.array(test_labels_batch)])

  其中generate_captcha函数将返回一个用于生成指定字符验证码样本的函数。而测试数据生成器generate_test_data需要两个参数,其中的generate_img代表生成指定字符验证码的函数,可传入generate_captcha();batch_size代表每批生成多少样本。
  接下来可以开始测试训练好的模型了,训练代码如下

import matplotlib.pyplot as plt
import seaborn as sns

plt.rcParams['font.sans-serif'] = ['SimHei'] 
plt.rcParams['axes.unicode_minus'] = False

char_map = {chars[c]: c for c in range(len(chars))} # 验证码编码(0到len(chars) - 1)
idx_map = {value: key for key, value in char_map.items()} # 编码映射到字符
idx_map[-1] = '' # -1映射到空

def test(generator, test_iter_num=10):
    error_cnt = 0
    iterator = generator
    sample_num = 0
    loss_pred_all = None
    for _ in range(test_iter_num): 
        ### 生成测试数据
        test_imgs_batch, test_labels_batch = next(iterator)
        test_labels_encode_batch = []
        for label in test_labels_batch:
            label = [char_map[i] for i in label]
            test_labels_encode_batch.append(label) 
        
        ### 统计错误样本数
        labels_pred = model.predict_on_batch(np.array(test_imgs_batch))
        labels_pred = char_decode(labels_pred)   
        for label, label_pred in zip(test_labels_batch, labels_pred): 
            if label != label_pred:
                error_cnt += 1
#                 print(f'{label} -> {label_pred}')  

        ### 保存测试数据以便绘制loss的概率密度函数
        loss_pred = fit_model.predict([test_imgs_batch, np.array(test_labels_encode_batch)])
        if loss_pred_all is None:
            loss_pred_all = loss_pred
        else:
            loss_pred_all = np.vstack([loss_pred_all, loss_pred])
        sample_num += len(loss_pred)
          
    ### 绘制loss的概率密度函数
    sns.distplot(loss_pred_all)
    plt.title(f'mean: {loss_pred_all.mean():.3f} | '
              f'max: {loss_pred_all.max():.3f} | '
              f'median: {np.median(loss_pred_all):.3f}\n'
              f'总样本数:{sample_num} | '
              f'错误数:{error_cnt} | '
              f'准确率:{1 - error_cnt / sample_num:.3f}', fontsize = 20)
    plt.xlabel('loss', fontsize = 20)
    plt.ylabel('PDF', fontsize = 20)
    plt.xticks(fontsize=15)
    plt.yticks(fontsize=15)
    plt.show()
test(generate_test_data(generate_captcha(), 32), test_iter_num=100)

  test函数中有两个参数,generator为测试数据生成器,test_iter_num表示生成多少批次测试数据。该函数最后绘制出训练集样本loss的概率分布图(PDF),其能够体现模型的测试效果,如下
初次训练后的模型效果
  通过这张图,我们能够得知测试样本的loss主要分布在0到10之间,小部分样本的loss超过了10,模型的准确率只有0.4。该模型虽然在训练集中能够达到平均loss小于1,但在这里的平均loss却达到了2.765,可见模型在训练集上发生了过拟合现象。

进一步训练模型

  完成了模型的初步训练之后,我们还能更进一步针对某种风格的验证码进行训练,此时我们已不必再生成训练样本保存到本地之后重复使用同一样本集进行训练,而可直接随机生成各种长度的验证码进行训练,最后都能使平均loss下降到1以下。在python上的验证码库并不算多,我了解到的还有wheezy.captcha和claptcha,而java下的有不少验证码生成库,关于验证码库可参考https://github.com/nickliqian/cnn_captcha,有能力的可使用java自行搭建一个tomcat+servlet的简易后台生成验证码,而在python这边利用requests请求获取验证码数据进行训练。
  这里我仍旧使用captcha随机生成训练集并进行训练,训练之前,先定义一个训练集的生成器,代码如下

def generate_data_random(generator, batch_size): 
    while True:
        labels_batch = []
        imgs_batch = []
        length = random.randint(4, 7)
        for _ in range(batch_size):
            char = ''.join([random.choice(chars) for _ in range(length)])
            img = generator(char)
            labels_batch.append(char)
            imgs_batch.append(img)
        labels_encode_batch = []
        for label in labels_batch:
            label = [char_map[i] for i in label]
            labels_encode_batch.append(label)
        yield([np.array(imgs_batch), np.array(labels_encode_batch)], None)

  generate_data_random函数中有两个参数,generator为训练数据生成器,可传入generate_captcha();batch_size代表每批生成多少样本。我们使用该生成器进行训练,代码如下

fit_model.fit_generator(
    generate_data_random(generate_captcha(), 32), 
    epochs=100, 
    steps_per_epoch=100, 
    verbose=1
)
time_str = time.strftime("%Y_%m_%d_%H_%M_%S")
fit_model.save_weights(f'testmodel/fit_model_{time_str}.ckpt')
model.save_weights(f'testmodel/model_{time_str}.ckpt')

  这里模型会使用随机样本训练100轮,但我训练的过程中发现训练到50轮之后模型的平均loss基本在0.55上下波动,没有再下降的迹象,这100轮的训练总共花了我近6小时。再次利用test测试模型,最终训练效果如下图
再次训练后的模型效果
  通过随机样本的训练,大部分样本的loss已经下降到2以下,只有一小部分样本的loss超过了2,且模型的准确率达到了0.846。

结语

  以上模型可通过训练其他风格验证码数据得到一个能适应多种风格验证码的较为通用的模型,只是要想使模型具有高可用性或许还得在训练过程或在网络结构上再下功夫。


2020.01.13更新: 新增了部分内容,修改StopTraining使得能够自定义停止阀值。


  1. 参考自https://codeday.me/bug/20180605/176510.html ↩︎

  • 4
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 31
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 31
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值