什么是机器学习?
听到机器学习的名头时,人们往往会一脸错愕的联想到类似《终结者》、《我,机器人》里的场景,但实际上的机器学习却与之相差甚远。
机器学习实际上更像是统计模型训练或者算法模型训练,通过训练模型和拟合数据,让模型去猜出结果。也就是说,机器学习的目标便是让算法模拟智能。
案例概述
验证码识别实际上难点并不在算法模型上,毕竟验证码千奇百怪,极难找到分布规律。这些分布规律也就是算法模型的维度,当一个模型的维度出现各式各样的偏差时,模型本身一定是会受到影响的。所以说本文的难点问题便是验证码的字符的清洗与分割。
依赖库
import os
import time
import random
import threading
import joblib
import numpy as np
from PIL import Image, ImageFilter
from concurrent.futures import ThreadPoolExecutor
from sklearn.neighbors import KNeighborsClassifier
from sklearn.model_selection import train_test_split
from captcha.image import ImageCaptcha
-
os
模块主要用于文件夹操作 -
time
模块用于计算时间戳等操作 -
random
随机数模块 -
threading
线程模块 -
joblib
模块用于打包模型 -
numpy
主要用于数值化图片 -
PTL
图像处理模块 -
ThreadPoolExecutor
线程池 -
KNeighborsClassifier
即knn算法模型 -
train_test_split
用于分割数据集 -
ImageCaptcha
用于生成验证码图片
清洗图片
关于验证码的生成部分,由于并非本文核心,在此处不做多余赘述,详情请看下方的代码链接:
https://github.com/macxin123/verification_code_identification/blob/master/make_code.py
关于图片的请洗,一般都要经历这么几个过程:灰度图处理、黑白图像处理、中值滤波处理。
-
灰度图处理
由于验证码大部分都是颜色不统一的,所有说第1步便是将其颜色归一。
from PIL import Image # 读取图片 img = Image.open('./img.jpg') # 将图片转换为灰度图 img_l = img.convert('L')
convert
方法即可将图像调整为灰度图。可调用
img_l.show()
查看。
-
黑白图像处理
当图片被转换成数组时,数组中的每个数值都是一个像素,当这个数值越大时,代表这个像素颜色越深。
# 将灰度图转换为数组形式 img_arr = np.array(img_l) # 求出该数组的平均值 means = img_arr.mean() # 调整平均值,作为清洗参数 if means > 227: means = means + 30 # 清洗灰度图中的“雀斑” img_clean = img_l.point(lambda i: i>means-40, mode='1')
将图像数组化后,取数组的平均值作为黑白化的阈值,通过point方法进行调整,这个阈值最好是根据实际情况进行调整,不必死磕。
-
中值滤波处理
MedianFilter
为PIL库的中值滤波器,该方法会以size为像素点中心,选择中值像素作为新值。# 使用中值滤波再次清洗 img_end = img_clean.filter(ImageFilter.MedianFilter(size=3))
因为要照顾到所有样本图片,导致中值滤波对部分图片只能起到“雀斑”优化的作用,而不能完全剔除。
也可选用最大值滤波器
MaxFilter
,效果如下:关于使用何种滤波器,需要根据实际情况权衡使用。
分割图片
完成了图像请洗的阶段后,我们需要将图片中的字符分割出来,即后续的识别是进行单个字符的识别。如果使用整张验证码进行训练,那么可能需要十分庞大的数据集,这其中的算法复杂度也会大幅度提升。
本案例的数据集最麻烦的一点在于,验证码直接的间隔基本上是随机的,甚至还有撞在一起的,这就令人十分头痛,甚至劝退,毕竟,就是用来恶心爬虫工程师的。不过办法总比困难多,毕竟Google、stackoverflow上的大神们还是蛮多的。
# 是否为开始位置
inletter = False
# 是否为结束位置
foundletter = False
# 开始位置的x坐标
start = 0
# 结束位置的x坐标
end = 0
# 存储分割位置的列表
letters = list()
# 遍历图片中的像素点,进行切割位置识别
for x in range(img.size[0]):
for y in range(img.size[1]):
pix = img.getpixel((x, y))
if pix != 1:
inletter = True
if foundletter == False and inletter == True:
foundletter = True
start = x
if foundletter == True and inletter == False:
foundletter = False
end = x
letters.append((start, end))
inletter = False
上述代码中,内层的for
循环相当于是遍历图片中的列像素,如果此列存在不为1的像素,也就是非白色,则判定为起点切割位置,直到遇见整列为白色像素的列,判定为终点位置。
# 存储筛选后分割位置的列表
real_letters = list()
# 切割后的图片宽度小于15,大概率为没有清洗好的像素点,所以直接舍弃
for n in letters:
if abs(n[0] - n[1]) > 15:
real_letters.append(n)
由于图片中只有4个字符,但因为“雀斑”的存在,可能会多划分出好几块,所有需要上述代码进行判定舍弃。
不过心细的小伙伴会发现,此处还存在一个bug:如果两个字符连在一起,那是不是就区分不出来了?
确实,上述算法无法判定出已连接的两个字符,所有还需要下列代码去进行判定。
def max_pro(x):
"""
返回两点间的绝对值距离
"""
return abs(x[0] - x[1])
# 如果real_letters为3,代码有2个字符被分割到了一起
if len(real_letters) == 3:
res = max(real_letters, key=max_pro)
res_index = real_letters.index(res)
le = list()
for i in range(4):
if len(le) == 4:
break
if i == res_index:
# 再次分割图片
ca = round(abs(real_letters[i][1] - real_letters[i][0]) / 2)
le.append((real_letters[i][0], real_letters[i][0] + ca))
le.append((real_letters[i][0] + ca +1, real_letters[i][1]))
else:
le.append(real_letters[i])
real_letters = le
# 如果real_letters为2,代码有3个字符被分割到了一起
elif len(real_letters) == 2:
res = max(real_letters, key=max_pro)
res_index = real_letters.index(res)
le = list()
for i in range(4):
if len(le) == 4:
break
if i == res_index:
# 再次分割图片
ca = round(abs(real_letters[i][1] - real_letters[i][0]) / 3)
le.append((real_letters[i][0], real_letters[i][0] + ca))
le.append((real_letters[i][0] + ca + 1, real_letters[i][0] + ca + ca))
le.append((real_letters[i][0] + ca + ca +1, real_letters[i][1]))
else:
le.append(real_letters[i])
real_letters = le
# 4为正确分割,此时什么都不用做
elif len(real_letters) == 4:
pass
# 出现其他情况,则放弃该验证码
else:
return None
上述代码的核心即,根据列表中的数量的不同,选择最宽的那段进行平均切割。(缺几块,分几份)
有了分割位置的列表,就可以开始切图了。
# 文件名(验证码内容)
filename = img.filename.rsplit('.')[1].rsplit('/')[-1]
for i, v in enumerate(real_letters):
# 切割的起始横坐标,起始纵坐标,切割的宽度,切割的高度
img_split = img.crop((v[0], 0, v[1], img.size[1]))
# 将图片size进行统一
i_m_g = img_split.resize((40, 60), Image.ANTIALIAS)
# windows文件夹名不区分大小写,所以文件夹名需要更改
if filename[i].isupper():
dir_name = filename[i] + '_Upper'
i_m_g.save(f'./chars/{dir_name}/{filename[i] + str(random.randint(1, 999)) + str(time.time())[:8]}.jpg')
else:
i_m_g.save(f'./chars/{filename[i]}/{filename[i] + str(random.randint(1, 999)) + str(time.time())[-6:]}.jpg')
保存图片的时候有一个坑需要注意一下,那就是关于文件夹的命名问题,a文件夹和A文件夹是一个文件夹
,即windows文件夹名称是不区分大小写的,这点需要特别注意一下。
还有就是图片大小的问题,需要通过resize
方法进行size的统一,否则会导致数据集的偏差过大。
KNN分类算法
KNN最近邻算法是一种非常简单易懂的算法,使用“距离”进行度量,通过“多数表决”进行分类。可以解决有监督学习的分类问题、回归问题、以及无监督学习等多个领域的问题,适用范围较广。本文的算法模型将使用KNN进行验证码识别模型训练。
训练模型
训练模型前,我们需要先将图片数值化,以便进行运算,之后再划分训练集和测试集。
# 数据
data = list()
# 标签
label = list()
# 图片路径
path = './chars/'
dir_list = os.listdir(path)
for p in dir_list:
dir_path = path + p + '/'
lst = os.listdir(dir_path)
for jpg in lst:
img = Image.open(dir_path + jpg)
filename = img.filename.rsplit('/')[-1].split('.')[0][0]
label.append(list(filename))
img_arr = np.array(img).tolist()
xx = list()
for arr in img_arr:
for a in arr:
xx.append(a)
data.append(xx)
使用sklearn自带的train_test_split
方法自动划分训练集和测试集。
# 测试集占比30%
x_train, x_test, y_train, y_test = train_test_split(x_list, y_list, test_size=0.3, random_state=12)
接下来,便是直接开始训练模型了。
# 实例化knn算法模型
knn = KNeighborsClassifier()
# 训练数据
knn.fit(x_train, y_train)
# 测试准确率
score = knn.score(x_test, y_test)
此时,我们最好通过更换K值测试最优的算法模型,当然,此处使用KNN也并不是最优解,作者测试准确率时,仅有70%左右的准确率。
最后一步,便是保存我们的算法模型了。
joblib.dump(knn, './knn_model.pkl')
结语
写到最后的一刻我是有些崩溃的,因为处理了很多意料之外的麻烦,再加上最后的准确率只有7成,实在让人劝退。
一直到最后我都在问自己,难道是CNN不香了嘛???即便是CNN不香,那打码平台也不香嘛???
没办法,自己挖的坑还是得自己填。不过呢,在实际工作中,尤其是爬虫程序中遇见图片验证码时,还是建议使用打码平台或者使用深度学习算法去建模。
关于这个验证码识别脚本的全部代码,请参考下面的GitHub链接:
https://github.com/macxin123/verification_code_identification