用深度学习来解决验证码识别问题

验证码(Captchas),里面包含了波浪形的字母和数字,扭曲并加上混淆,以便没有人能够认出它应该是什么。CNN的计算机视觉每天都取得了令人印象深刻的成果,我决定尝试一下。

数据集

幸运的是,我们在kaggle上下载了提供1070次标记的验证码的数据集。它们看起来像这样:

浏览数据集,我发现它具有以下属性:

  • 每个验证码都包含5个字符
  • 字符是字母(az)或数字(1-9)
  • 一个字符可能会在一个验证码中多次出现

数据以包含所有验证码图像的文件夹的形式给出。每个图像的标签由其文件名给出。'bny23.png'代表上面的例子。这样可以在准备数据集时轻松提取标签:

def label_from_filename(path):
label = [char for char in path.name[:-4]]
return label

多标签分类

在多标签分类中,我们有一个标签列表。当给定一个输入图像时,我们试图预测输入图像具有哪些标签。一个图像可以有多个标签。

在验证验证码的情况下,标签列表如下所示:

  • 里面有'a'
  • 里面有'b'
  • 里面有'1'
  • ….

多标签分类允许我们知道在验证码中存在哪些字符。这种方法的明显缺点是:

  • 我们不知道每个字符的具体位置
  • 我们不知道每个字符有多少次重复。

如果CNN能够完全处理任务,这仍然是一个有用的第一步。

多标签分类的想法如何转化为CNN的架构呢?

像往常一样,我们从一些卷积层开始。从原始图像开始,应用了许多卷积滤波器。输出是一组特征映射,用于检测简单的特征,例如直线。在以下每个层中,另一组卷积滤波器应用于前一层生成的特征映射。随着越来越多的层彼此堆叠,检测到的特征的复杂性增加。

最后,我们最终得到了标量特征。最后一层的元素不再是映射(2维),而是标量(“正常数字”,1维)。这些数字可以被认为是描述输入图像的某些属性的自动提取的特征。

然后这些提取的特征用作完全连接的神经网络的输入。FCNN作为一个分类器,作用于卷积层提取的特征。

FCNN对每个标签都有一个输出。每个输出都可以被认为是输入图像具有该标签的概率。在上面的示例中,输出被解释为“验证码包含B2 ”。

fast.ai库使得创建这样的结构变得非常容易。首先,我们通过datablock api加载数据:

data = (ImageList.from_folder(path)
.split_by_rand_pct(0.2)
.label_from_func(label_from_filename)
.transform(get_transforms(do_flip=False))
.databunch()
.normalize()
)

这会产生如下数据集:

请注意,左上角的验证码中有两个“d”,但标签只有一个。这与“验证码中至少有一个d ”的想法一致。

对于实际的训练,我们将使用迁移学习。主要思想是使用在大型数据集上训练的特征提取器。最重要的是,添加了自定义FCNN以进行分类。

在第一步中,我们只在最后训练自定义分类层。由于它们的权重在开始时是完全随机的,因此将它们与预先训练的特征提取层一起训练只会把它们弄乱。

learn = cnn_learner(data, models.resnet18, model_dir='/tmp')
lr = 5e-2
learn.fit_one_cycle(5, lr)

一旦自定义分类层提供了很好的结果,我们就“unfreeze”网络。这意味着,从现在开始,每一层都经过训练。这使得调整检测到的特征成为可能。

learn.unfreeze()
learn.fit_one_cycle(15, slice(1e-3, lr/5))

验证集的最终精度高于98%。

在迭代过程中绘制模型的损失可以清楚地显示unfreeze。这是损失突然下降:

在第60次迭代前后的可见跳跃

对验证集中的每个输入图像进行预测。将预测结果与实际标签进行比较可以让我们计算损失。损失越高,预测错误就越高。有了这个,我们可以查看损失最大的输入,即“错误最多”的验证码:

错误最多的验证码

正确最多的验证码:

正确最多的验证码

这就是解决验证码的第一步。我们发现CNN可以识别验证码中的字符。下一步是添加关于字符位置的信息。

多标签方法的明显缺点:哪个字符在验证码中的哪个位置的信息。

解决这种验证码的问题在于我们想要在字符级别上解决它们。理想的解决方案是学习不同的字符(a-z,0-9),然后识别它们在图像上的存在和位置。作为实现这一目标的第一步,我会考虑一个稍微简单的问题。

解决验证码上的第一个字符

这是标准的分类任务。输入是验证码的图像,输出是与第一个字符对应的单个类。

简单分类:第一个字符

加载数据集与使用多标签分类相同,我只需要更改标签函数来返回文件名的第一个字符。

def char_from_path(path):
return path.name[0]
data = (ImageList.from_folder(path)
.split_by_rand_pct(0.2)
.label_from_func(partial(char_from_path, position=0))
.transform(get_transforms(do_flip=False))
.databunch())

完成标记后,我可以为这个任务构建CNN。该体系结构与多标签分类几乎相同。首先,卷积层用于特征的自动提取。

使用卷积层自动提取特征

其次,使用提取的特征作为实际分类输入的完全连接网络(FCNN)看起来几乎相同。实际上唯一的区别是,输出层中的激活是标准化的(它们都是正的并且总和为1.0)。选择与激活值最大的输出节点对应的标签作为分类结果。

网络输出每个类的概率

训练以通常的方式进行:训练FCNN几个周期,解冻,然后训练整个模型。

训练整个模型后的结果

我提前终止了训练,准确率达到了95%。

可视化实际标签与预测标签

混淆矩阵显示了实际类与预测类。如果预测与实际类匹配,则将条目添加到主对角线。如果预测和实际不匹配,则将条目添加到相应的单元格。具有完美精度的模型只在主对角线上具有条目。直觉上我会认为“m”和“n”很难区分。事实上,混淆矩阵证实了这一假设。

在我使用这个单字符模型为整个验证码构建分类器之前,让我们确认分类器按照预期的方式工作:

CAM—类激活映射

所谓的CAM可以用于可视化输入图像的哪个部分在分类过程中是重要的。这基本上是让模型解释其决策的一种基本方法。

对于上面的例子,我希望围绕第一个角色进行高度激活。由于生成CAM的方法,热点图的分辨率不是很高。尽管如此,如果模型正在寻找合适的位置,它应该给出一个粗略的意义。

类激活图(所有都以2开头的巧合)

正如您所看到的,模型似乎正在做它应该做的事情。

5个字符—5个分类器

上面的分类器在识别验证码中的第一个字符时产生了不错的结果。将此扩展到所有字符的自然方式是什么?只需构建其中的5个,每个位置一个。同样,fast.ai库使得运行这样的实验变得非常容易。代码如下:

learners = []
for i in range(5):
data = data_from_position(i)
learn = cnn_learner(data, models.resnet34,
metrics=accuracy,
model_dir='/tmp')

lr = 5e-2
learn.fit_one_cycle(5, lr)

learn.unfreeze()
learn.fit_one_cycle(15, slice(5e-4, lr/5))

learners.append(learn)

以下是每个位置的最终准确度:

  • 第一名:98.59%
  • 第二名:97.19%
  • 第三名:96.72%
  • 第四名:96.72%
  • 第五名:100%

正如所料,中间的字符似乎是最难识别的。可能是因为网络实际上需要做一些“计数”以确定位置。

可视化每个模型的激活热点图显示了每个模型正在寻找其特征的确切位置。将这5个模型包装在一起,为我们提供了5位数验证码的工作分类器:

def predict_captcha(img, learners):
return ''.join([str(learner.predict(img)[0])
for learner in learners])

运行整个验证集,预测整个标签的准确度为95%!

整个文本作为标签

最简单的方法是使用整个验证码文本作为图像的标签。这会将任务转变为简单的分类。

使用完整的验证码文本作为标签

然而,这将使所有验证码文本彼此独立。AUT3NAUT4N的区别就像它与74OMU的区别一样大。这种方法只有在每个可能的解决方案文本都有多个验证码时才有效。它没有使用验证码是由5个部分组成的事实。

字符的输出位置

在数据集中,只有19个不同的字符。我们可以将验证码建模为长度为19的向量。向量的每个元素对应于一个可能的字符。向量中每个条目的数字表示相应字符在验证码中的位置。因此,1表示位置1,2表示位置2,依此类推。0表示根本不存在。

左:编码向量,中:编码标签,右:实际标签

我认为这会很有效。唯一的问题是:它不能覆盖整个可能的验证码配置范围。任何字符都可能出现多次。这种方法无法表现出来。

完整的独热编码

在单字符分类方法中,位置i处的字符被表示为长度为19的向量。对整个验证码进行编码将得到一个19×5的矩阵。矩阵的列对应于给定位置的独热编码字符:

左:分辨编码每个位置, 中:将编码矩阵展平为单个向量, 右:实际标签

展平该编码矩阵得到长度为19 * 5 = 95的一维向量。这种方法能够编码所有可能的验证码,并且可以直接用于训练CNN。

编码非常简单。首先,我们将列举19个不同的字符。

encoding_dict = {l:e for e,l in enumerate(labels)}
decoding_dict = {e:l for l,e in encoding_dict.items()}

接下来,我们将一个给定标签转换为19×5矩阵:

def to_onehot(label):
onehot = np.zeros((19, 5))
for column, letter in enumerate(label):
onehot[encoding_dict[letter], column] = 1
return onehot.reshape(-1)

然后将其展平成长度为95的向量。

由于我认为验证码解决了分类任务,我现在尝试使用交叉熵作为损失函数。因此,将网络输出重构为19 * 5矩阵,计算每列的交叉熵,然后取平均值。

对每个位置都使用交叉熵损失,而不是将这些损失加起来。

这在训练开始时非常有效,只训练了线性层。然而,当解冻模型时,收敛速度非常缓慢。所以我把它作为一个回归任务,将均方根误差(MSE)作为损失函数。

经过一些调整(最明显的是dropout正则化的转变),这个模型训练得非常好!经过20次迭代后,我在验证集上达到了99%的准确率。

我们所要解决的问题已经完成,这是关于用深度编码解决验证码的全部内容。以不同的方式对问题进行建模,这是一个很好的练习。

发表评论
留言与评论(共有 0 条评论)
   
验证码:

相关文章

推荐文章

'); })();