机器学习无非分为两大步骤:
- 训练:收集数据提供给计算机进行学习,学习如何对验证码进行识别,即如何进行分类,最终训练出一个模型,以文件形式存储。
- 识别(分类):利用训练产生的模型,对新验证码进行识别
这里要弄清一点:训练的过程(程序)是不属于识别程序的,而训练的结果(数据)是识别程序的输入之一。
- 所有要导入的包
import urllib
import os
import PIL.Image as image
import numpy as np
import sklearn.externals.joblib as job
import sklearn.svm as svm
import io
import requests
训练
1. 准备原始图片素材
首先研究目标网站的登录页面,找到验证码图片的src。
-
如我学校的网站:
可以发现该验证码图片的src是由一个js函数动态获得的
-
又如这个网站:
它的验证码是通过后端脚本获得的
- 只有以上两种方式是本文要讨论的,是需要图像识别出马的。对于给出图片地址的验证码,我们完全不需要利用图像识别技术,因为那种验证码是服务器的静态文件,不是动态生成的,可以通过反复爬取,save all distinctly to get 100% accuracy!
- 另外,仔细观察以上两种验证码,可以发现:
- 主要以英文和数字组成(字符种类不是很多)
- 相同字符的大小基本相同(这一点很重要,之后SVM操作中可以了解到)
- 字符之间不存在重叠(可以偶尔有几个重叠)
# -*- coding: UTF-8 -*-
import urllib
def scratch_image(url, save_dir):
"""
url直接传入【域名+src】
从url抓取图片,保存到本地,并自动为每张图片命名
:param url: 图片地址,请求到的必须是图片格式
:param save_dir: 保存到本地的目录,不包括文件名
:return: 无
"""
bin = urllib.urlopen(url).read()
no = len(os.listdir(save_dir))
name = "no" + str(no) + ".jpeg"
path = os.path.join(save_dir, name)
f = open(path, "wb")
f.write(bin)
print "Picture saved: " + path
f.close()
可以通过循环此函数实现批量抓取,批量抓取时请务必加上
f.close()
2. 图片预处理
经过第一步,我们将训练图片集存储在了硬盘上。为了让图片更容易被机器看懂,我们读取的同时需要做一点初步的加工。
- 读入一张图片,并将其转换为二值图片数组
def img_to_bin(img_path, threshold):
"""
将图片转化为灰度图,二值化
0为黑,1为白
:param img_path: 图片的本地路径,也可以直接传入文件
:param threshold: 二值化的阈值,一般取255/2左右的值,如130
:return: img_bin
"""
img_bin = np.array(image.open(img_path).convert("L"))
for i in range(len(img_bin)):
for j in range(len(img_bin[i])):
if img_bin[i][j] > threshold:
img_bin[i][j] = 1
else:
img_bin[i][j] = 0
return img_bin
- 去除噪声
基本原理:如果一个黑点所在的3*3的格子内只有1个或2个黑点(包括自己),则其为噪点。注意处理边界情况。
def remove_noise(img_bin):
"""
0为黑,1为白
初步去除图片的噪声,即去除较为孤立的点
:param img_bin: 二值图像numpy数组
:return: 修改过的二值图像
"""
for i in range(len(img_bin)):
for j in range(len(img_bin[i])):
# 如果是0(黑)则进行判断
if img_bin[i][j] == 0:
count = 1
# 如果是上边界(包括左右两端)
if i == 0:
# 左上角的点
if j == 0:
img_bin[i][j] = 1
# 右上角的点
elif j == len(img_bin[0]) - 1:
img_bin[i][j] = 1
# 上边的点
else:
count += (5 - img_bin[0][j - 1]
- img_bin[0][j + 1]
- img_bin[1][j - 1]
- img_bin[1][j]
- img_bin[1][j + 1])
# 如果是下边界(包括左右两端)
elif i == len(img_bin) - 1:
# 左下角的点
if j == 0:
img_bin[i][j] = 1
# 右下角的点
elif j == len(img_bin[0]) - 1:
img_bin[i][j] = 1
# 下边的点
else:
count += (5 - img_bin[i][j - 1]
- img_bin[i][j + 1]
- img_bin[i - 1][j - 1]
- img_bin[i - 1][j]
- img_bin[i - 1][j + 1])
# 如果是左边界(不包括上下两端)
elif j == 0:
# 左边
if 0 < i < len(img_bin) - 1:
count += (5 - img_bin[i - 1][0]
- img_bin[i + 1][0]
- img_bin[i - 1][1]
- img_bin[i][1]
- img_bin[i + 1][1])
# 如果是右边界(不包括上下两端)
elif j == len(img_bin[0]) - 1:
# 右边
if 0 < i < len(img_bin) - 1:
count += (5 - img_bin[i - 1][j]
- img_bin[i + 1][j]
- img_bin[i - 1][j - 1]
- img_bin[i][j - 1]
- img_bin[i][j - 1])
# 非边界情况
else:
count += (8 - img_bin[i - 1][j - 1]
- img_bin[i - 1][j]
- img_bin[i - 1][j + 1]
- img_bin[i][j - 1]
- img_bin[i][j + 1]
- img_bin[i + 1][j - 1]
- img_bin[i + 1][j]
- img_bin[i + 1][j + 1])
if count <= 2:
img_bin[i][j] = 1
return img_bin
3. 图片字符切割
- 由于学校网站的验证码还算比较规整的,不经常出现字符之间相互重叠的情况,因此可以先切割,再一个一个单独识别。
- 基本原理:考虑到对单个英文字符和数字横向扫描,不会出现一整列都是白点的情况;纵向扫描时,只有i和j两个字符是两段的情况。因此,将若干行(列)空白作为切割线。
def cut_image(img_bin, sum=4):
"""
按照特定的规则切割图像,若切割后的块的数量不等于sum,则提示出错,但不抛出异常,也不进行异常处理
:param img_bin: 二值图像数组
:param sum: 期望切割得到的image块数
:return: img_bin数组
"""
result = []
# img二值图像矩阵转置
t_img_bin = img_bin.T
start_i = 0 # 记录字符的开始列
end_i = 0 # 记录字符的结束列
count0_in_row_last = 0
for i in range(len(t_img_bin)):
# 计算转置前的第i列的点的个数
count0_in_row = 0
for j in range(len(t_img_bin[0])):
if t_img_bin[i][j] == 0:
count0_in_row += 1
# 点的个数从0或1个变到多个,说明是一个字符的开始
if count0_in_row_last <= 0 and count0_in_row > 0:
start_i = i
# 点的个数从多个变到0或1个,说明是一个字符的结束,字符结束之前一定只经历过且仅经历过一个开始
if count0_in_row_last > 0 and count0_in_row <= 0:
end_i = i
# 过滤掉end_i-start_i过小的
if end_i - start_i > 2:
# 根据start_i和end_i切割出一个image
temp = t_img_bin[start_i:end_i].T
# 对temp进行cut处理
im = cut_image_vertically(temp)
result.append(im)
count0_in_row_last = count0_in_row
# 返回结果
if len(result) != sum:
print "切割后图像块数为" + str(len(result))
return result
def cut_image_vertically(img_bin):
"""
函数cut_image用到的辅助函数
:param img_bin:
:return:
"""
start_i = 0 # 记录字符的开始行
end_i = 0 # 记录字符的结束行
count0_in_row_last = 0
for i in range(len(img_bin)):
# 计算转置前的第i行的点的个数
count0_in_row = 0
for j in range(len(img_bin[0])):
if img_bin[i][j] == 0:
count0_in_row += 1
# 点的个数从0或1个变到多个,说明是一个字符的开始
if count0_in_row_last <= 0 and count0_in_row > 0:
start_i = i
# 点的个数从多个变到0或1个,说明是一个字符的结束,字符结束之前一定只经历过且仅经历过一个开始
if count0_in_row_last > 0 and count0_in_row <= 0:
end_i = i
# 过滤掉end_i-start_i过小的
if end_i - start_i > 2:
return img_bin[start_i:end_i]
count0_in_row_last = count0_in_row
return None
4. 图片尺寸归一化
- 切割出来的字符图片大小不尽相同。而要使用SVM的话,所有图片的尺寸相同,才可以进行训练。
- 我研究过一系列的图像缩放算法,但是对于验证码这种小型图片都不适用,要么会造成数据丢失,要么会造成数据冗余。
- 基本原理:其实很简单。不是对图像进行拉伸,而仅仅在字符周围增加空白,扩张图片,以达到尺寸归一化的效果。
- 注意:必须通过统计所有字符图片的大小,来确定width和height参数,使它们均大于原本的宽高。
def resize_image(img_bin, width, height):
"""
改变图片的尺寸(不拉伸字符,仅仅在字符周围加上空白)
:param img_bin: 二值图像数组
:param width: 目标宽度
:param height: 目标高度
:return: 修改过的img_bin
"""
w_img = len(img_bin[0])
h_img = len(img_bin)
if w_img < width:
# 计算出在左侧加多少列,在右侧加多少列
left = (width - w_img) / 2
right = (left + 1) if (width - w_img) % 2 == 1 else left
l_ex = np.array([[1] * h_img] * left)
r_ex = np.array([[1] * h_img] * right)
img_bin = np.concatenate((l_ex, img_bin.T, r_ex)).T
else:
return np.array([])
w_img = len(img_bin[0])
h_img = len(img_bin)
if h_img < height:
# 计算出在上部加多少行,在下部加多少行
top = (height - h_img) / 2
bottom = (top + 1) if (height - h_img) % 2 == 1 else top
t_ex = np.array([[1] * w_img] * top)
b_ex = np.array([[1] * w_img] * bottom)
img_bin = np.concatenate((t_ex, img_bin, b_ex))
else:
return np.array([])
return img_bin
5. 图片字符标记
- 机器是死的,不能凭空学出东西来,你得先教它。
- 你要告诉它,这些字符图片分别代表哪个字符。
- 经过前几步,我们将切割后的图片保存下来。可以直接保存np.array。调用方法:
np.save(path, img_bin)
- 基本原理:在文本文件中,手动输入{array文件名: 所代表字符}键值对,再在程序中读取出来以训练模型。
- 设读取出来的字典为result,数据结构为{"文件名": "所代表字符"}
6. 字符图片特征提取
- 首先我们来理解一下什么叫特征。
从下表可以看出:
- 每一行表示一张字符图片
- 除了最后一列,每一列表示字符图片的一个特征
- 最后一列表示该图片所代表的字符
- 每张图片的特征维度(数量)是相同的,如下表,为3
feature1 | feature2 | feature3 | character |
---|---|---|---|
10 | 12 | 0 | A |
11 | 13 | 0 | A |
9 | 12 | 1 | A |
5 | 18 | 19 | B |
6 | 1 | 13 | 3 |
4 | 18 | 19 | B |
所以如何提取图片的特征至关重要。应该尽可能做到:每行的特征唯一确定一张图片。
feature1 | feature2 | feature3 | character |
---|---|---|---|
10 | 12 | 0 | A |
11 | 13 | 0 | A |
9 | 12 | 1 | A |
5 | 18 | 19 | B |
6 | 1 | 13 | 3 |
4 | 18 | 19 | B |
- 以怎样的标准提取图片的特征?
- 为了做到每张图片的特征尽可能地唯一代表它自己,我们可以将图片的每个像素点作为特征,这样对于一张18*18的图片,就有18*18个特征,特征值为{0, 1},但这样会出现过拟合现象,因此不予以采用。
- 我通过以下方法提取特征:将每一行及每一列的黑点个数作为一个特征。则维度为18+18,特征值为[0, 18]
- python实现(这个函数仅仅是给出一张图片的特征向量)
def create_feature_array(img_bin):
"""
对单个字符的二值图像数组进行特征描述
:param img_bin: 二值图像数组
:return: 特征向量
"""
# 对于每个img_bin,计算其特征矩阵
# 每一行的0的个数都是一个特征
feature_array = []
for i in range(len(img_bin)):
count = 0
for j in range(len(img_bin[0])):
if img_bin[i][j] == 0:
count += 1
feature_array.append(count)
# 每一列的0的个数都是一个特征
for i in range(len(img_bin.T)):
count = 0
for j in range(len(img_bin.T[0])):
if img_bin.T[i][j] == 0:
count += 1
feature_array.append(count)
return feature_array
7. 根据特征和标记对应的训练数据集
SVM的输入数据主要是两个:特征矩阵X和结果向量y。
还是那张表格。前三列就是X,最后一列就是y。
feature1 | feature2 | feature3 | character |
---|---|---|---|
10 | 12 | 0 | A |
11 | 13 | 0 | A |
9 | 12 | 1 | A |
5 | 18 | 19 | B |
6 | 1 | 13 | 3 |
4 | 18 | 19 | B |
- 生成X和y
根据前面第5点生成的result。
def create_feature_matrix(result):
"""
result的数据结构举例:
{"1.npy": "A", "2.npy": "B", "3.npy": "A", "4.npy": "3"}
"""
X = []
y = []
for f, ch in result.items():
arr = create_feature_array(np.load(f))
X.append(arr)
y.append(ch)
return X, y
8. 训练特征标记数据生成识别模型
def create_classifier(result, save_path):
"""
创建并保存svm分类器
:param result: 手动输入的每张图片所代表的字符
:param save_path: 保存模型路径
:return:
"""
X, y = create_feature_matrix(result)
clf = svm.SVC(decision_function_shape="ovo")
clf.fit(X, y)
job.dump(clf, save_path)
识别
通过【训练】我们得到了训练模型文件,我取名为"train_model.m"。
之后给出一张验证码图片进行识别其实很简单,网上提到的有很多。
但是!!我发现困扰初学者更多的是如何利用Python库正确地发送HTTP请求。
1. 让我们先复习一下必要的HTTP请求的知识
- POST和GET
它们是HTTP的请求方法。HTTP有五种请求方法,本项目只用到最重要的两种:POST和GET。两种方法的主要区别在于传递参数(WEB浏览器向WEB服务器传递参数)。
- POST:参数放在报文中。安全的传参方法,适用于传递用户民改密码。
- GET:参数放在url中。默认请求方法,不适用于传递用户名密码等参数。
- 本项目中,【请求验证码图片】和【提交表单】属于两次HTTP请求。前者不涉及密码,用的是GET方法;后者用的是POST方法。
- 想系统学习相关知识的同学,请看 http://www.w3school.com.cn/tags/html_ref_httpmethods.asp
- Session和Cookie
用于记录历史信息的HTTP的两种机制。
- 为什么要记录历史信息?
HTTP是无状态的协议,因此一次Request and Response结束后,其中的信息就会丢失。Session和Cookie就是用来记录这些信息的存在。
- Session和Cookie有什么区别?
Session是保存在服务器端的,存储的信息相对于Cookie来说较安全,但服务器容量有限,因此需要Cookie的存在。
Cookie是保存在客户端的,存储信息不安全,因此主要存储一些非敏感性信息。
2. 本项目工作原理
- 利用Python的Request库模拟浏览器的请求行为。
- 向服务器发送【请求验证码图片】的HTTP请求,由于是第一次(所谓第一次,是指客户端没有存储带有SessionId的Cookie),Cookies中是不存在SessionId的。服务器收到Request,发现其中不带有SessionId,于是服务器就会为该客户端新建一个Session,之后返回Response,其SetCookie项就会包含该SessionId,其Image项即验证码图片数据。
- 执行识别验证码的程序。
- 向服务器发送【提交表单】的请求。请务必将该Cookie封装到HTTP报文中,否则验证码图片就会发生变化。此时该SessionId所对应的服务器Session就已经记住了你的登录状态。
- 使用这个Cookie就可以访问该网站下任意的(带有你的登录状态的)网页了。
3. 模拟登陆,过程概述
st=>start: 开始
ed=>end: 结束
r1=>operation: Request 验证码
r2=>operation: Request 提交表单
login=>condition: 登陆是否成功
r3=>operation: 访问任意网页
st->r1->r2->login(yes)->r3->r3
login(no)->r1
4. 代码实现
解释一下下面代码
image_file = io.BytesIO(image_bytes)
io.BytesIO
函数将二进制封装成文件。该函数返回类型是文件类型,不过跟python内置文件类型不同,前者是内存文件,后者是外存文件。为什么不能用file.write(image_bytes)
呢?因为写外存文件是异步操作,write
后立马read
是来不及读到数据的。这两种文件类型的接口大多相同,所以可以通用。
if len(r2.history) == 1:
这句话代表“判断登录是否成功”。实际上判断登录是否成功有很多方法,比如:对Response的Html进行正则检查。这种方法是最简便的,不过有时可能行不通。而且,可能是Request库做的有问题。response.is_redirect, response.status_code等,返回的结果均不正确。因此究竟用哪种方法进行判断,需要读者根据不同网站自行摸索。
- 参数
headers
和data
需要利用抓包软件。先用浏览器正常登录,查看抓到的HTTP Headers中的User-Agent和Referer并记录下来;查看抓到的HTTP Content,用户名密码和验证码的字段名称叫什么,以及有没有其他字段。
def get_cookies(img_url, headers, data, index_url):
"""
获取登录成功之后的Cookies,以便之后免登录
:param img_url: 验证码图片的url
:param headers: {dict} 提交表单用到的headers
:param data: {dict} 提交表单用到的data
:param index_url: 输入用户名密码的页面的url
"""
# 获取response
r1 = requests.get(
img_url
)
# 读取r1的内容,封装成图片并进行识别
image_bytes = r1.content
image_file = io.BytesIO(image_bytes)
code = recognize_code(img_file)
if code == "Error":
return None
# 登录
r2 = requests.post(
index_url,
data=data,
headers=headers,
cookies=r1.cookies.get_dict()
)
# 如果发生跳转
if len(r2.history) == 1:
return r1.cookies.get_dict()
return None
def recognize_code(img_file):
"""
识别验证码
:param img_file: 文件类型的字符图片
:return: 若切割成非四块,则返回false
"""
clf = job.load("train_model.m")
img_bin = img_to_bin(img_file, 130)
img_bin = remove_noise(img_bin)
img_bins = cut_image(img_bin)
code = ""
if len(img_bins) == 4:
for img in img_bins:
img_ = resize_image(img, 18, 18)
ch = clf.predict([create_feature_array(img_)])[0]
code = code + ch[0]
else:
return "Error"
return code
5. 大功告成
访问该网站下的任意网页
def visit(url, cookies):
r = requests.get(
url,
cookies=cookies
)
return r.read()
不过由于我们的训练与识别算法是比较初级的,无法达到100%的正确率,因此需要进行反复登录。
def main(max, img_url, headers, data, index_url, url):
for i in range(max):
cookies = get_cookies(img_url, headers, data, index_url)
if cookies:
print "第", i, "次尝试成功"
print visit(url)
break
if i == max:
print "尝试了", i, "次未成功"