使用卷积神经网络训练模型来自动识别验证码——在 YYeTs 的实践

BennyThink 大佬做了一个很炫的网站,叫做「人人影视分享站」,但是发现登录的时候要验证码,那我如果忘记密码了的话怎么一瞬间登录 100 次来尝试我的各种密码组合呢?

验证码保存下来是一个 160px * 60px 的图片,为了了解这个验证码是怎么生成的,我们可以直接参考这个网站的代码,在 https://github.com/tgbot-collection/YYeTsBot/blob/master/yyetsweb/database.py 下有如下代码:

from captcha.image import ImageCaptcha

captcha_ex = 60 * 10
predefined_str = re.sub(r"[1l0oOI]", "", string.ascii_letters + string.digits)

class CaptchaResource:
    redis = Redis()

    def get_captcha(self, captcha_id):
        chars = "".join([random.choice(predefined_str) for _ in range(4)])
        image = ImageCaptcha()
        data = image.generate(chars)
        self.redis.r.set(captcha_id, chars, ex=captcha_ex)
        return f"data:image/png;base64,{base64.b64encode(data.getvalue()).decode('ascii')}"

其中 predefined_str 应该是 BennyThink 大佬精选的字符集(去除了 0oO 这类容易被混淆的字符),字符集内容如下,一共 56 个字符:

abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789

在需要的时候,这个函数生成一个验证码图片(的 base64 版本)和 ID 传到网页上,同时把 captcha_idchars(实际的验证码字符)放在 Redis 中,登录的时候直接从 Redis 中取验证码,利用 Redis 来自动过期,非常精妙。(比我自己写 PHP 的时候去折腾什么 https://github.com/mewebstudio/captcha,然后用 composer 搞半天,还得 override 函数来改 API Endpoint 不知道高端到哪儿去了。)

Train Network

在已经知道了验证码的生成方式之后,为了实现一瞬间登录 100 次的梦想,我们就得开始考虑如何自动识别验证码了,一般来说,有如下思路:

  1. 让 BennyThink 大佬为我的 IP 关闭验证码,显然不行
  2. 找自动接码平台,要花钱,而且速度不够快,肯定不行
  3. 自己训练模型来识别验证码,看上去可行,但是我完全 0 基础

最终,我们选择了方案 3,利用工作之余,从基础,到完全放弃 AI/ML。

在搜寻了一些资料后发现,主流的方法是使用 CNN(卷积神经网络),或者 RNN(循环神经网络)。

由于上面两个神经网络我完全不熟,这里就不展开了

一般来说,要训练一个模型,有以下典型步骤:

  1. 收集样本
  2. 清洗样本并分开训练集和测试集
  3. 训练模型
  4. 测试是否真的可以用来识别

我们一步步来看

收集样本

由于我们已经知道了验证码是怎么生成的了,所以这里我们并不需要去爆破人人影视分享站的验证码接口来获得验证码(而且这种方式还没法知道真正的验证码是啥),所以摆在我们面前的有两条路,要么预先生成一堆样本用于训练,要么用生成器来实时生成。

第一种方式的好处是训练的时候显卡利用率高,如果你需要经常调参,可以一次生成,多次使用;第二种方式的好处是你不需要生成大量数据,训练过程中可以利用 CPU 生成数据,而且还有一个好处是你可以无限生成数据。

批量生成样本

比如我们的验证码是 yTse,那么我们就生成一个 yTse.png 放在一个目录下,PoC 代码如下:

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

predefined_str = re.sub(r"[1l0oOI]", "", string.ascii_letters + string.digits)

for i in range(10000):
    chars = "".join([random.choice(predefined_str) for _ in range(4)])
    image = ImageCaptcha()
    data = image.generate(chars)
    img_path = "./generated/" + chars + ".png"
    image.write(chars, img_path)

使用生成器

这里直接参考了 ypwhs/captcha_break 的代码,不过由于我们的验证码尺寸的问题,做了一些调整:

from tensorflow.keras.utils import Sequence

width, height, n_len, n_class = 160, 60, 4, len(characters)

class CaptchaSequence(Sequence):
    def __init__(self, characters, batch_size, steps, n_len=4, width=160, height=60):
        self.characters = characters
        self.batch_size = batch_size
        self.steps = steps
        self.n_len = n_len
        self.width = width
        self.height = height
        self.n_class = len(characters)
        self.generator = ImageCaptcha(width=width, height=height)
    
    def __len__(self):
        return self.steps

    def __getitem__(self, idx):
        X = np.zeros((self.batch_size, self.height, self.width, 3), dtype=np.float32)
        y = [np.zeros((self.batch_size, self.n_class), dtype=np.uint8) for i in range(self.n_len)]
        for i in range(self.batch_size):
            random_str = ''.join([random.choice(self.characters) for j in range(self.n_len)])
            X[i] = np.array(self.generator.generate_image(random_str)) / 255.0
            for j, ch in enumerate(random_str):
                y[j][i, :] = 0
                y[j][i, self.characters.find(ch)] = 1
        return X, y

来测试一下这个生成器是否好用:

好用的,由于可以直接使用生成器批量生成验证码,这里直接放弃第一种预先生成的方案。

清洗样本并分开训练集和测试集

由于有了生成器,所以训练集和测试集就很好区分了,并不需要传统的 train_test_split 方法,只要:

train_data = CaptchaSequence(characters, batch_size=160, steps=1000)
valid_data = CaptchaSequence(characters, batch_size=160, steps=100)

即可。

训练模型

由于我对神经网络完全不熟,这里继续参考 ypwhs/captcha_break 的代码和描述,不过由于我们的字符集是 56 位的,所以做了一些调整:

模型结构很简单,特征提取部分使用的是两个卷积,一个池化的结构,这个结构是学的 VGG16 的结构。我们重复五个 block,然后我们将它 Flatten,连接四个分类器,每个分类器是36个神经元,输出36个字符的概率。

from tensorflow.keras.models import *
from tensorflow.keras.layers import *
from tensorflow.keras.callbacks import EarlyStopping, CSVLogger, ModelCheckpoint
from tensorflow.keras.optimizers import *

input_tensor = Input((height, width, 3))
x = input_tensor
for i, n_cnn in enumerate([2, 2, 2, 2, 2]):
    for j in range(n_cnn):
        x = Conv2D(32*2**min(i, 3), kernel_size=3, padding='same', kernel_initializer='he_uniform')(x)
        x = BatchNormalization()(x)
        x = Activation('relu')(x)
    x = MaxPooling2D(2)(x)

x = Flatten()(x)
x = [Dense(n_class, activation='softmax', name='c%d'%(i+1))(x) for i in range(n_len)]
model = Model(inputs=input_tensor, outputs=x)

callbacks = [EarlyStopping(patience=3), CSVLogger('cnn.csv'), ModelCheckpoint('cnn_best.h5', save_best_only=True)]

model.compile(loss='categorical_crossentropy',
              optimizer=Adam(1e-3, amsgrad=True), 
              metrics=['accuracy'])
model.fit_generator(train_data, epochs=100, validation_data=valid_data, workers=4, use_multiprocessing=True,
                    callbacks=callbacks)

model.fit_generator 之后,机器就会开始自动调参了,由于设置了 EarlyStopping(patience=3),所以这里 epoch 并不会到达 100,而会在 loss 超过了 3 个 epoch 没有下降后自动停止,为了加快速度,可以使用 GPU,但是…

我没有钱,仅存的 GPU 是一个用来玩网页游戏的亮机卡

再次印证了 「ClickHouse 各版本在不同 CPU 架构上的性能差异对比」一文中的说法:「没有钱,就没法做科研」

思来想去,最终决定整个 Telsa:

当然,不是这个 Tesla,而是…

Sun May 21 03:32:58 2022       
+-----------------------------------------------------------------------------+
| NVIDIA-SMI 460.32.03    Driver Version: 460.32.03    CUDA Version: 11.2     |
|-------------------------------+----------------------+----------------------+
| GPU  Name        Persistence-M| Bus-Id        Disp.A | Volatile Uncorr. ECC |
| Fan  Temp  Perf  Pwr:Usage/Cap|         Memory-Usage | GPU-Util  Compute M. |
|                               |                      |               MIG M. |
|===============================+======================+======================|
|   0  Tesla T4            Off  | 00000000:00:04.0 Off |                    0 |
| N/A   55C    P0    28W /  70W |   6036MiB / 15109MiB |      0%      Default |
|                               |                      |                  N/A |
+-------------------------------+----------------------+----------------------+
                                                                               
+-----------------------------------------------------------------------------+
| Processes:                                                                  |
|  GPU   GI   CI        PID   Type   Process name                  GPU Memory |
|        ID   ID                                                   Usage      |
|=============================================================================|
+-----------------------------------------------------------------------------+

现在东西都准备齐了,是时候被机器学习了。

每个 Epoch 大约 10 分钟(对比 AMD Ryzen R5 3x00 每个 Epoch 大约需要 50 分钟)

这个时候你只有坐着等的份,和炼丹一样

很快,我们就有了一个比较不错的模型 cnn_best.h5

1000/1000 [==============================] - 536s 534ms/step - loss: 0.1164 - c1_loss: 0.0238 - c2_loss: 0.0320 - c3_loss: 0.0349 - c4_loss: 0.0256 - c1_accuracy: 0.9913 - c2_accuracy: 0.9887 - c3_accuracy: 0.9879 - c4_accuracy: 0.9907 - val_loss: 0.2460 - val_c1_loss: 0.0325 - val_c2_loss: 0.0650 - val_c3_loss: 0.0963 - val_c4_loss: 0.0521 - val_c1_accuracy: 0.9895 - val_c2_accuracy: 0.9793 - val_c3_accuracy: 0.9711 - val_c4_accuracy: 0.9843

验证模型

在我们有了模型之后,我们就需要下载回来进行验证了,这次我们直接使用真实验证码来测试,比如我们可以从BennyThink 大佬的「人人影视分享站」上下载一个,然后本地载入模型后进行验证:

from PIL import Image
from tensorflow.keras.models import *
from tensorflow.keras.layers import *

characters = re.sub(r"[1l0oOI]", "", string.ascii_letters + string.digits)
width, height, n_len, n_class = 160, 60, 4, len(characters)

input_tensor = Input((60, 160, 3))
x = input_tensor
for i, n_cnn in enumerate([2, 2, 2, 2, 2]):
    for j in range(n_cnn):
        x = Conv2D(32*2**min(i, 3), kernel_size=3, padding='same', kernel_initializer='he_uniform')(x)
        x = BatchNormalization()(x)
        x = Activation('relu')(x)
    x = MaxPooling2D(2)(x)

x = Flatten()(x)
x = [Dense(n_class, activation='softmax', name='c%d'%(i+1))(x) for i in range(n_len)]
model = Model(inputs=input_tensor, outputs=x)

model.load_weights('cnn_best.h5')
# Read index.png to local_data
local_data = np.array(Image.open('index.png')) / 255.0
plt.imshow(local_data)

def decode(y):
    y = np.argmax(np.array(y), axis=2)[:,0]
    return ''.join([characters[x] for x in y])

y_pred = model.predict(local_data.reshape(1, *local_data.shape))
print("Predicted: " + decode(y_pred))

我们用文章开头的例子看看效果?

现在脚本只要稍加改装,就可以实现文章开头提到的一瞬间登录 100 次的梦想了。

后记

由于我不怎么会写代码加上对神经网络部分一窍不通,这里面踩了好多坑。

比如一开始尝试使用类似 MNIST 的方式,魔改 Captcha 的代码,让他只生成单个(无干扰线的)字符的图片用于单独训练,后来发现这样的训练效果很好,但是实际用来识别的时候识别率非常非常低。

然后尝试使用 k 邻域降噪 + OpenCV 二值化的方式给完整验证码降噪,发现效果也很一般(可能我水平不行)。


通过偷代码和抄代码,我们实现了从 0 基础,到完全放弃 AI/ML,同时在运行的过程中对这个领域有了一些了解,现在可以带着问题去正统地学习一下相关知识了。

Happy Hacking!

References

  1. lepture/captcha
  2. ypwhs/captcha_break
  3. How to break a CAPTCHA system in 15 minutes with Machine Learning
  4. Image classification from scratch
  5. Image Thresholding

comments powered by Disqus