第一次尝试识别验证码,先从简单的开始。

上图为某处验证码。收集数张图片后可发现,图中字符只涉及移位这一种变换。但是,字符被允许粘连,或超出边界。

一般对于验证码的识别,基本的思路应该是:

去干扰 -> 切割字符 -> 识别

此处验证码并没有人为加的干扰线等障碍。所以只需将图像二值化处理,便可进行下一步。


利用PIL(Pillow)进行二值化处理

PIL支持一些图像模式。此处需要了解的有:

1 (1-bit pixels, black and white, stored with one pixel per byte)

L(指Luminance) (8-bit pixels, black and white)

RGB (3x8-bit pixels, true colour)

用PIL打开验证码图像的时候一般是RGB模式。先使用

img.convert('L')

将图像转换为灰度模式。此时每个像素上储存的信息为该点的亮度,0为最暗(黑色),255为最亮(白色)。但是这时仍然可以看到,原图上一些因为压缩而产生的色块仍然存在。接下来用

img = img.point(lambda x: 1 if x >= 160 else 0, mode='1')

来二值化图像。point可以按照指定的lookup table或函数映射图像。mode参数可以直接转换图像模式(仅支持部分模式)。此处lambda表达式中设定的阈值为160,该值需要根据不同图像的实际情况来设定。(亮度)高于该值的点会被map成1(白色),其余0(黑色)。

此时若要输出图像,可选择bmp格式,jpg格式的压缩可能给二值化后的图像带来干扰。


切割字符

虽然这个验证码比较低级,但是要切割出里面的字符,也不是最简单的方法(平均分)就可以搞定的。

def append_to_char_boxes_LR(lst, left, right):
    if right - left > 8:  # maximum char width == 8(此处的8是完成切割部分代码并建立了字符库后重新填上的)
        mid = (left + right) // 2
        lst.append((left, mid))
        lst.append((mid, right))
    else:
        lst.append((left, right))

for x in range(img_width):  # 找出每个字符的左、右边界
    empty_column = True
    for y in range(img_height):
        if px[x, y] == 0:  # black
            empty_column = False
            if stage == STAGE_SEARCHING_FOR_CHAR:
                stage = STAGE_PROCESSING_CHAR
                left = x
            break

    if stage == STAGE_PROCESSING_CHAR:
        if empty_column:
            stage = STAGE_SEARCHING_FOR_CHAR
            right = x
            append_to_char_boxes_LR(char_boxes_LR, left, right)
        else:
            if x == img_width - 1:  # last column(字符可以超出边界,所以要注意这种情况)
                stage = STAGE_SEARCHING_FOR_CHAR
                right = img_width
                append_to_char_boxes_LR(char_boxes_LR, left, right)

if len(char_boxes_LR) != 4:  # 验证码是4个字符。如果没有4组边界,一定是错的。
    return None

上面的代码将图片中的像素一列一列地遍历,试图找出由白色(无字符)到黑色(有字符)的分界。同时,它处理两种特殊情况:

  1. 若已经进入STAGE_PROCESSING_CHAR状态,但搜索到最后一列仍未能退出该状态,则判定为字符已经超出图片边界,直接将起点到图片右边缘的范围作为一组边界。
  2. 若某次搜索过程中,已经处于STAGE_PROCESSING_CHAR状态超过8列,则判定为字符粘连。此时代码会继续搜索,直到找出右边界,并从中间平均切开,生成两组边界。

# TODO: 寻找比“从中间平均切开”更合适的处理方法

另,此处须注意PIL库的crop函数的使用方法:

The region is defined by a 4-tuple, where coordinates are
(left, upper, right, lower). The Python Imaging Library
uses a coordinate system with (0, 0) in the upper left corner.

四个参数分别是裁切得到的区域的左、上、下、右四条边界线的“位置”。而且,此函数裁切时“含上不含下,含左不含右“。以上述代码为例,则可发现左边界的“位置”是一个字符包含的首个黑色像素的x值,而右边界的“位置”是一个字符右边首个空列(完全不包含黑色字符的一列)的x值。只有这样,才能不多不少地裁切出目标区域。PIL对超出图片范围的裁切参数也会接受,所以此处若不小心则可能无法发现bug。此规则亦适用于上、下边界。

依法炮制上、下边界,再调用crop函数,即可获得四个字母的图像。


建立字符库

完成裁切部分代码后,只要收集足够的验证码图像,裁切,并手工从中找出一组包含验证码使用的所有字符的完整的(无粘连、无缺损)的字符图片,为每张图片标识好对应的字符,就可以建立用于识别这个验证码图像的样本库。

# TODO: 扩大样本库适用范围


识别验证码

需要识别的验证码图片也应该按照上面的处理方法处理,并得到四个字符的图像。下面需要对四个字符分别进行识别。

首先,通过观察可以发现,对于那些可以完整地切出来(无粘连、无缺损)的字符,甚至根本不需要使用到KNN算法,因为这个例子中的验证码生成器不会对字符进行变形,即只要是两个完整的、相同的字符,它们必定拥有相同的图像数据(其实即使有粘连/缺损,只要粘连/缺损的方式、位置一致,也是可以的,但这样要求有更多的样本)。在这个例子中,我储存了样本库中字符的hash值;识别时,会先比较待识别字符是否与库中字符拥有相同hash值。然后只对未能通过hash匹配识别的字符使用KNN算法识别。

因为字符可以缺损、粘连,所以在使用KNN时,需要计算待识别字符与样本拥有不同相对位置时的相似度,并取最高得分作为该样本与待识别字符的相似度。但是,此处有一点可以简化,即,因为该验证码中字符不会被缩放、旋转等,故若待识别字符的宽度(或高度)大于某个样本的宽度(或高度),则可以直接排除该样本。

def calculate_similarity(cdata, csize, sdata, ssize):
    cwidth, cheight = csize  # size: (width, height)
    swidth, sheight = ssize
    if csize == ssize:
        score = 0
        for x in range(cwidth):
            for y in range(cheight):
                cdxy = cdata[x, y]
                sdxy = sdata[x, y]
                if cdxy == sdxy:
                    if cdxy == 0:  # We do not increase score if the pixels are white.
                        score += 1
                else:
                    score -= 1
        return score
    else:  # return the highest score obtained among all positions
        if cwidth > swidth or cheight > sheight: return -100  # This captcha generator does not zoom characters. Hence csize must not exceed ssize.
        score_list = []
        for xoffset in range(swidth - cwidth + 1):  # all possible positions on x axis
            for yoffset in range(sheight - cheight + 1):
                score = 0
                for x in range(cwidth):
                    for y in range(cheight):
                        cdxy = cdata[x, y]
                        sdxy = sdata[x, y]
                        if cdxy == sdxy:
                            if cdxy == 0:   # We do not do score++ if the pixels are white.
                                score += 1
                        else:
                            score -= 1
                score_list.append(score)
        return max(score_list)


def visual_recognise_char_img(cimg):
    cdata = cimg.load()
    csize = cimg.size
    score_list = []  # Stores score for each possible char. item: (score, char_name)
    for char_name, sdata, ssize in samples:
        score_list.append((calculate_similarity(cdata, csize, sdata, ssize), char_name))
    return max(score_list, key=lambda x: x[0])[1]

上面的代码中,对待识别字符和样本字符的对应点的分数计算方法为:

  • 若两者为一黑一白,则扣1分
  • 若两者均为白色,则不增不减
  • 若两者均为黑色,则加1分


References