文章27
标签52
分类15

Python 手动实现数字验证码识别

(迁移文章,本文写于2019年8月3日)本人维护的一个项目 中北信息 小程序需要模拟登录来获取信息,这就需要在后台识别验证码。需要识别的验证码比较简单且为纯数字,有简单到可以忽略不计的变形,像下面这个样子。

验证码样例

可以看到稍微有一点噪点,应该是压缩解压过程造成的(JPG 是有损压缩)。

这是近乎标准印刷体的四位数字,只是有稍微倾斜,最开始想到的办法是 OCR 。

最初使用 Tesseract 进行本地 OCR 。效果不是很理想,重要问题是 Tesseract 是通用 OCR,他会识别所有的英文字符和标点,这就造成大量的识别不准确。而且 Tesseract 是一个神经网络,在本地运行相当耗费资源。在平时可以应付大部分情况,但在访问高峰时会消耗大量时间在多个识别线程中切换,甚至直接造成服务器宕机。 在没有新的解决方案之前使用了接近一年,只能说勉强能用。

前不久,发现百度云的 OCR 提供每天 5 万次的免费调用,测试发现可以使用且效果不错。使用一段时间后发现一些问题,异步的调用方式造成的延迟不可忽略,尤其是网络延迟。测试发现这个解决方案每个验证码识别有将近 1s 的网络延迟,这使得后台响应时间被延长,影响用户体验。虽然百度云不会将数字识别成其他字符,但经常出现只识别出两位数字的情况造成大量重试,更加影响性能。

两度更换解决方案后,还是没能找到令人满意的方案。决定自己实现验证码的识别,这里不需要训练神经网络,直接通过规则匹配能得到很好的性能与效果,最终单个验证码识别时间为 4-5ms,正确率 100%。

识别步骤如下:

  1. 灰度化
  2. 二值化
  3. 切割为单个数字
  4. 和字库对比

导入需要用到库

import math
import time
from io import BytesIO

import numpy as np
from PIL import Image

import data

最后一行导入的是字库,后面会写到。

灰度化

从之前的例子可以看到图像有一些噪点,为了不让其干扰识别需要进行降噪处理,第一步是将图片灰度化。所谓灰度化是将 RGB 色彩空间中三个量合并成一个量 L,这样原本的彩色图像变成灰度图了。每个像素的取值也由 2563256^{3}(16777216)个减少到 256 个,能够减少后继的计算量。将上面的图片灰度化之后变成下面的样子。

灰度化后

处理代码:

# 灰度处理并创建二维矩阵
img_matrix = np.array(Image.open(BytesIO(image_bytes)).convert("L"))

其中 Image.open(BytesIO(image_bytes)).convert("L") 是从 image_bytes 中创建图像并转为灰度图, image_bytes  可以是从网络加载或从本地文件读入的字节数据。然后根据灰度图创建一个 NumPy 矩阵方便后面的运算。

灰度化之后与原图好像没有区别是因为原图本身是黑白的,如果原图是彩色的话能够很明显地看出区别。

二值化

二值化是将图像的像素与某个阈值比较,若大于这个阈值则设置为灰度最大值(这里是 1,白色),小于某个值则设为灰度最小值(这里是 0,黑色)。这样上一步的灰度图就转化为二值图,便于接下来的图像分割。选择合适的阈值还可以去除图像噪点。

二值化后

二值化后噪点已经完全消失,数字的边界更加锐利。

处理代码:

# 获取矩阵(图像)的长宽
rows, cols = img_matrix.shape
for i in range(rows):
    for j in range(cols):
        # 与阈值比较
        if img_matrix[i, j] <= 128:
            # 设为灰度最小值
            img_matrix[i, j] = 0
        else:
            # 设为灰度最大值
            img_matrix[i, j] = 1

切割为单个数字

二值化后可以看到图像中有大量的空白,这对于识别会造成一些影响,并且对比多个验证码发现数字的垂直位置并非固定,也就是上下边距是浮动的。因此要将空白部分删掉只保留有图像的部分 。 先看实现:

# 每行最小值
row_min = np.min(img_matrix, axis=1)
# 找到第一个有图像的行
row_start = np.argmin(row_min)
# 找到最后一个有图像的行
row_end = np.argmin(np.flip(row_min))
# 只取有图像的行
img_matrix = img_matrix[row_start:-row_end, :]
去除上下空白

row_min 的值为

[1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1]

其中 1 表示该行最小值为 1 ,在本例中表示这一行都是 1 ,即没有黑色像素。0 则表示这一行有 0 的存在,即存在黑色像素,也就是这一行有图像,需要保留。np.argmin()row_min 中查找最小值第一次出现的位置,即为图像开始行。将 row_min 反转之后再次查找最小值下标就得到了结束下标,这个下标的是从右向左计数的。 得到 row_startrow_end 后对矩阵进行切片,row_end 要取负值表示表示从右开始计数。

接下来将数字切片成单个:

codes = [0] * 4
for i in range(4):
    # 切片
    imag_matrix_spited = img_matrix[:, 19 * i:19 * (i + 1)]
    col_min = np.min(imag_matrix_spited, axis=0)
    col_start = np.argmin(col_min)
    col_end = np.argmin(np.flip(col_min))
    # 图像宽度
    width = col_min.shape[0] - (col_start + col_end)
    # 宽度扩宽到 9 像素
    width_rest = 9 - width
    # 左边界
    col_start -= int(math.ceil(width_rest / 2.0))
    # 右边界
    col_end -= int(math.floor(width_rest / 2.0))
    # 裁剪为 9 像素宽的图像
    imag_matrix_spited = imag_matrix_spited[:, col_start:-col_end]

先粗略裁剪为每个数字 20 像素,然后再判断左边界和右边界,同之前一样的算法。但是这里不能直接使用得到的边界,每个数字的宽度不一样,直接切片会导致矩阵大小不一样,会影响之后的计算。因此要将将宽度统一为 9 像素,即为最宽的数字宽度。

切片结果-8
切片结果-4
切片结果-0
切片结果-3

和字库对比

res = [0] * 10
# 展开成一维
x = imag_matrix_spited.flatten()
for j in range(10):
    # 一次取字库中标准数据
    y = data.array_map[j]
    # 通过异或计算不同元素的数量
    res[j] = np.sum(x ^ y)
    # 取差异最小的下标
codes[i] = str(np.argmin(res))

首先将原先的二维矩阵展开为一维,再与字库中的数据对比找到差异最小的那个既是最终结果,这样整个验证码就识别出来了。字库则需要将展开后的矩阵按照顺序整理一下得到。

下面是针对这种验证码的字库(0~9):

import numpy as np

array_map = [0] * 10
array_map[0] = np.array(
    [1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 0, 1, 1, 0, 1, 1, 1, 1, 1, 0, 1, 1,
     0, 1, 1, 1, 1, 1, 0, 1, 0, 0, 1, 1, 1, 1, 1, 0, 1, 0, 0, 1, 1, 1, 1, 0, 0, 1, 0, 0, 1, 1, 1, 1, 0, 0, 1, 0, 1, 1,
     1, 1, 1, 0, 0, 1, 0, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1])
array_map[1] = np.array(
    [1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1,
     1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1,
     0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1])
array_map[2] = np.array(
    [1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1,
     1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 0,
     1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1])
array_map[3] = np.array(
    [1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1,
     1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1,
     1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 1])
array_map[4] = np.array(
    [1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 1, 0, 1, 1, 1,
     1, 0, 0, 1, 0, 0, 1, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 0, 0, 0, 1, 0, 0, 0, 1, 1, 0, 0, 0,
     0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1])
array_map[5] = np.array(
    [1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1,
     0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1,
     1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 1])
array_map[6] = np.array(
    [1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1,
     0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 1, 0, 0, 1, 1, 1, 1, 0, 0, 1, 0, 1, 1,
     1, 1, 1, 0, 0, 1, 0, 0, 1, 1, 1, 1, 0, 1, 1, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1])
array_map[7] = np.array(
    [1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1,
     1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0,
     1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1])
array_map[8] = np.array(
    [1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 0, 1, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 1, 0, 1, 1,
     1, 0, 0, 1, 1, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 1, 0, 0, 0, 1, 1, 1, 0, 1, 1, 1, 0, 0, 0, 1, 0, 0, 1,
     1, 1, 1, 0, 0, 1, 0, 1, 1, 1, 1, 1, 0, 1, 1, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1])
array_map[9] = np.array(
    [1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 0, 1, 1, 0, 1, 1, 1, 1, 1, 0, 1, 1,
     0, 1, 1, 1, 1, 1, 0, 1, 1, 0, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 0, 1, 1, 1, 0, 0, 0, 1, 0, 0, 1, 1, 1, 1,
     1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 1])

至此验证码识别成功。

最后

完整的代码和一百个测试验证码可以在 Dreace/IVC 找到。

要求环境为 Python 3+,运行前请先安装 requirements.txt 中的依赖。

本文作者:Dreace
本文链接:https://blog.dreace.top/2020/Use-Python-to-Manually-Realize-Mumeral-Verification-Code-Recognition/
版权声明:本文采用 CC BY-NC-SA 3.0 CN 协议进行许可