前言
验证码是目前互联网上非常常见也是非常重要的一个事物,充当着很多系统的 防火墙 功能,但是随时OCR技术的发展,验证码暴露出来的安全问题也越来越严峻。本文介绍了一套字符验证码识别的完整流程,对于验证码安全和OCR识别技术都有一定的借鉴意义。
GitHub上有大神总结的非常好的源码及博客,链接如下
GitHub:
博客:http://blog.topspeedsnail.com/archives/10858
本文需要的依赖:
- python3.5
- PIL
- libsvm
破解验证码的基本流程:
- 准备原始图片素材
- 图片预处理
- 图片字符切割
- 图片尺寸归一化
- 图片字符标记
- 字符图片特征提取
- 生成特征和标记对应的训练数据集
- 训练特征标记数据生成识别模型
- 使用识别模型预测新的未知图片集
- 达到根据“图片”就能返回识别正确的字符集的目标
正文
第一步:准备素材
验证码图片如下:
真正的破解程序需要准备大量的素材,然后在进行大量的训练后才可达到相对高的识别度
def downloads_pic(**kwargs):
pic_name = kwargs.get('pic_name', None) url = 'http://xxxx/rand_code_captcha/'
res = requests.get(url, stream=True)
with open(pic_path + pic_name+'.bmp', 'wb') as f:
for chunk in res.iter_content(chunk_size=1024):
if chunk: # filter out keep-alive new chunks
f.write(chunk)
f.flush()
f.close()
#循环执行N次,即可保存N张验证素材了。
抓取大量验证码素材的代码
第二步:图片预处理
1. 二值化图片
#将RGB彩图转为灰度图,再按照设定阈值转化为二值图
def get_bin_table(threshold=140):
"""
获取灰度转二值的映射table
"""
table = []
for i in range(256):
if i < threshold:
table.append(0)
else:
table.append(1) return table image = Image.open(img_path)
imgry = image.convert('L') # 转化为灰度图 table = get_bin_table()
out = imgry.point(table, '')
由PIL转化后变成二值图片:0表示黑色,1表示白色。二值化后带噪点的 6937 的像素点输出后如下图:
1111000111111000111111100001111100000011
1110111011110111011111011110111100110111
1001110011110111101011011010101101110111
1101111111110110101111110101111111101111
1101000111110111001111110011111111101111
1100111011111000001111111001011111011111
1101110001111111101011010110111111011111
1101111011111111101111011110111111011111
1101111011110111001111011110111111011100
1110000111111000011101100001110111011111
#如果你是近视眼,然后离屏幕远一点,可以隐约看到 6937 的骨架了。
2. 去除噪点
在转化为二值图片后,就需要清除噪点。本文选择的素材比较简单,大部分噪点也是最简单的那种 孤立点,所以可以通过检测这些孤立点就能移除大量的噪点。
关于如何去除更复杂的噪点甚至干扰线和色块,有比较成熟的算法: 洪水填充法 Flood Fill ,后面有兴趣的时间可以继续研究一下。
本文为了问题简单化,干脆就用一种简单的自己想的 简单办法 来解决掉这个问题:
- 对某个 黑点 周边的九宫格里面的黑色点计数
- 如果黑色点少于2个则证明此点为孤立点,然后得到所有的孤立点
- 对所有孤立点一次批量移除。
下面将详细介绍关于具体的算法原理。
将所有的像素点如下图分成三大类
- 顶点A
- 非顶点的边界点B
- 内部点C
种类点示意图如下:
- A类点计算周边相邻的3个点(如上图红框所示)
- B类点计算周边相邻的5个点(如上图红框所示)
- C类点计算周边相邻的8个点(如上图红框所示)
当然,由于基准点在计算区域的方向不同,A类点和B类点还会有细分:
- A类点继续细分为:左上,左下,右上,右下
- B类点继续细分为:上,下,左,右
- C类点不用细分
然后这些细分点将成为后续坐标获取的准则。
主要算法的python实现如下:
def sum_9_region(img, x, y):
"""
9邻域框,以当前点为中心的田字框,黑点个数
:param x:
:param y:
:return:
"""
# todo 判断图片的长宽度下限
cur_pixel = img.getpixel((x, y)) # 当前像素点的值
width = img.width
height = img.height if cur_pixel == 1: # 如果当前点为白色区域,则不统计邻域值
return 0 if y == 0: # 第一行
if x == 0: # 左上顶点,4邻域
# 中心点旁边3个点
sum = cur_pixel \
+ img.getpixel((x, y + 1)) \
+ img.getpixel((x + 1, y)) \
+ img.getpixel((x + 1, y + 1))
return 4 - sum
elif x == width - 1: # 右上顶点
sum = cur_pixel \
+ img.getpixel((x, y + 1)) \
+ img.getpixel((x - 1, y)) \
+ img.getpixel((x - 1, y + 1)) return 4 - sum
else: # 最上非顶点,6邻域
sum = img.getpixel((x - 1, y)) \
+ img.getpixel((x - 1, y + 1)) \
+ cur_pixel \
+ img.getpixel((x, y + 1)) \
+ img.getpixel((x + 1, y)) \
+ img.getpixel((x + 1, y + 1))
return 6 - sum
elif y == height - 1: # 最下面一行
if x == 0: # 左下顶点
# 中心点旁边3个点
sum = cur_pixel \
+ img.getpixel((x + 1, y)) \
+ img.getpixel((x + 1, y - 1)) \
+ img.getpixel((x, y - 1))
return 4 - sum
elif x == width - 1: # 右下顶点
sum = cur_pixel \
+ img.getpixel((x, y - 1)) \
+ img.getpixel((x - 1, y)) \
+ img.getpixel((x - 1, y - 1)) return 4 - sum
else: # 最下非顶点,6邻域
sum = cur_pixel \
+ img.getpixel((x - 1, y)) \
+ img.getpixel((x + 1, y)) \
+ img.getpixel((x, y - 1)) \
+ img.getpixel((x - 1, y - 1)) \
+ img.getpixel((x + 1, y - 1))
return 6 - sum
else: # y不在边界
if x == 0: # 左边非顶点
sum = img.getpixel((x, y - 1)) \
+ cur_pixel \
+ img.getpixel((x, y + 1)) \
+ img.getpixel((x + 1, y - 1)) \
+ img.getpixel((x + 1, y)) \
+ img.getpixel((x + 1, y + 1)) return 6 - sum
elif x == width - 1: # 右边非顶点
# print('%s,%s' % (x, y))
sum = img.getpixel((x, y - 1)) \
+ cur_pixel \
+ img.getpixel((x, y + 1)) \
+ img.getpixel((x - 1, y - 1)) \
+ img.getpixel((x - 1, y)) \
+ img.getpixel((x - 1, y + 1)) return 6 - sum
else: # 具备9领域条件的
sum = img.getpixel((x - 1, y - 1)) \
+ img.getpixel((x - 1, y)) \
+ img.getpixel((x - 1, y + 1)) \
+ img.getpixel((x, y - 1)) \
+ cur_pixel \
+ img.getpixel((x, y + 1)) \
+ img.getpixel((x + 1, y - 1)) \
+ img.getpixel((x + 1, y)) \
+ img.getpixel((x + 1, y + 1))
return 9 - sum
Tips:这个地方是相当考验人的细心和耐心程度了,这个地方的工作量还是蛮大的,花了半个晚上的时间才完成的。
计算好每个像素点的周边像素黑点(注意:PIL转化的图片黑点的值为0)个数后,只需要筛选出个数为 1或者2 的点的坐标即为 孤立点 。这个判断方法可能不太准确,但是基本上能够满足本文的需求了。
经过预处理后的图片如下所示:
对比文章开头的原始图片,那些 孤立点 都被移除掉,相对比较 干净 的验证码图片已经生成。
第三步:图片字符切割——分割算法
由于字符型 验证码图片 本质就可以看着是由一系列的 单个字符图片 拼接而成,为了简化研究对象,我们也可以将这些图片分解到 原子级 ,即: 只包含单个字符的图片。
于是,我们的研究对象由 “N种字串的组合对象” 变成 “10种阿拉伯数字” 的处理,极大的简化和减少了处理对象。
现实生活中的字符验证码的产生千奇百怪,有各种扭曲和变形。关于字符分割的算法,也没有很通用的方式。这个算法也是需要开发人员仔细研究所要识别的字符图片的特点来制定的。
当然,本文所选的研究对象尽量简化了这个步骤的难度,下文将慢慢进行介绍。
使用图像编辑软件(PhoneShop或者其它)打开验证码图片,放大到像素级别,观察其它一些参数特点:
可以得到如下参数:
- 整个图片尺寸是 40*10
- 单个字符尺寸是 6*10
- 左右字符和左右边缘相距2个像素
- 字符上下紧挨边缘(即相距0个像素)
这样就可以很容易就定位到每个字符在整个图片中占据的像素区域,然后就可以进行分割了,具体代码如下:
def get_crop_imgs(img):
"""
按照图片的特点,进行切割,这个要根据具体的验证码来进行工作. # 见原理图
:param img:
:return:
"""
child_img_list = []
for i in range(4):
x = 2 + i * (6 + 4) # 见原理图
y = 0
child_img = img.crop((x, y, x + 6, y + 10))
child_img_list.append(child_img) return child_img_list
然后就能得到被切割的 原子级 的图片元素了:
基于本部分的内容的讨论,相信大家已经了解到了,如果验证码的干扰(扭曲,噪点,干扰色块,干扰线……)做得不够强的话,可以得到如下两个结论:
4位字符和40000位字符的验证码区别不大
- 纯数字 和 数字及字母组合 的验证码区别不大
-
纯数字。分类数为10
- 纯字母
-
- 不区分大小写。分类数为26
- 区分大小写。分类数为52
数字和区分大小写的字母组合。分类数为62
在没有形成 指数级或者几何级 的难度增加,而只是 线性有限级 增加计算量时,意义不太大。
第四步:尺寸归一
本文所选择的研究对象本身尺寸就是统一状态:6*10的规格,所以此部分不需要额外处理。但是一些进行了扭曲和缩放的验证码,则此部分也会是一个图像处理的难点。
第五步:模型训练
在前面的环节,已经完成了对单个图片的处理和分割了。后面就开始进行 识别模型 的训练了。
整个训练过程如下:
- 大量完成预处理并切割到原子级的图片素材准备
- 对素材图片进行人为分类,即:打标签
- 定义单张图片的识别特征
- 使用SVM训练模型对打了标签的特征文件进行训练,得到模型文件
第六步:素材准备
本文在训练阶段重新下载了同一模式的4数字的验证图片总计:3000张。然后对这3000张图片进行处理和切割,得到12000张原子级图片。
在这12000张图片中删除一些会影响训练和识别的强干扰的干扰素材,切割后的效果图如下:
第七步:素材标记
由于本文使用的这种识别方法中,机器在最开始是不具备任何 数字的观念的。所以需要人为的对素材进行标识,告诉 机器什么样的图片的内容是 1……。
这个过程叫做 “标记”。
具体打标签的方法是:
为0~9每个数字建立一个目录,目录名称为相应数字(相当于标签)
人为判定 图片内容,并将图片拖到指定数字目录中
- 每个目录中存放100张左右的素材
- 一般情况下,标记的素材越多,那么训练出的模型的分辨能力和预测能力越强。例如本文中,标记素材为十多张的时候,对新的测试图片识别率基本为零,但是到达100张时,则可以达到近乎100%的识别率
第八步:特征选择
对于切割后的单个字符图片,像素级放大图如下:
从宏观上看,不同的数字图片的本质就是将黑色按照一定规则填充在相应的像素点上,所以这些特征都是最后围绕像素点进行。
字符图片 宽6个像素,高10个像素 ,理论上可以最简单粗暴地可以定义出60个特征:60个像素点上面的像素值。但是显然这样高维度必然会造成过大的计算量,可以适当的降维。
- 每行上黑色像素的个数,可以得到10个特征
- 每列上黑色像素的个数,可以得到6个特征
最后得到16维的一组特征,实现代码如下:
def get_feature(img):
"""
获取指定图片的特征值,
1. 按照每排的像素点,高度为10,则有10个维度,然后为6列,总共16个维度
:param img_path:
:return:一个维度为10(高度)的列表
""" width, height = img.size pixel_cnt_list = []
height = 10
for y in range(height):
pix_cnt_x = 0
for x in range(width):
if img.getpixel((x, y)) == 0: # 黑色点
pix_cnt_x += 1 pixel_cnt_list.append(pix_cnt_x) for x in range(width):
pix_cnt_y = 0
for y in range(height):
if img.getpixel((x, y)) == 0: # 黑色点
pix_cnt_y += 1 pixel_cnt_list.append(pix_cnt_y) return pixel_cnt_list
然后就将图片素材特征化,按照 libSVM 指定的格式生成一组带特征值和标记值的向量文件。内容示例如下:
说明如下:
- 第一列是标签列,即此图片人为标记值,后续还有其它数值1~9的标记
- 后面是16组特征值,冒号前面是索引号,后面是值
- 如果有1000张训练图片,那么会产生1000行的记录
对此文件格式有兴趣的同学,可以到 libSVM 官网搜索更多的资料。
第九步:模型训练
到这个阶段后,由于本文直接使用的是开源的 libSVM 方案,属于应用了,所以此处内容就比较简单的。只需要输入特征文件,然后输出模型文件即可。
可以搜索到很多相关中文资料 。
主要代码如下:
def train_svm_model():
"""
训练并生成model文件
:return:
"""
y, x = svm_read_problem(svm_root + '/train_pix_feature_xy.txt')
model = svm_train(y, x)
svm_save_model(model_path, model)
备注:生成的模型文件名称为 svm_model_file
第十步:模型测试
训练生成模型后,需要使用 训练集 之外的全新的标记后的图片作为 测试集 来对模型进行测试。
本文中的测试实验如下:
- 使用一组全部标记为8的21张图片来进行模型测试
- 测试图片生成带标记的特征文件名称为 last_test_pix_xy_new.txt
在早期训练集样本只有每字符十几张图的时候,虽然对训练集样本有很好的区分度,但是对于新样本测试集基本没区分能力,识别基本是错误的。逐渐增加标记为8的训练集的样本后情况有了比较好的改观:
- 到60张左右的时候,正确率大概80%
- 到185张的时候,正确率基本上达到100%
以数字8的这种模型强化方法,继续强化对数字0~9中的其它数字的模型训练,最后可以达到对所有的数字的图片的识别率达到近乎 100%。在本文示例中基本上每个数字的训练集在100张左右时,就可以达到100%的识别率了。
模型测试代码如下:
def svm_model_test():
"""
使用测试集测试模型
:return:
"""
yt, xt = svm_read_problem(svm_root + '/last_test_pix_xy_new.txt')
model = svm_load_model(model_path)
p_label, p_acc, p_val = svm_predict(yt, xt, model)#p_label即为识别的结果 cnt = 0
for item in p_label:
print('%d' % item, end=',')
cnt += 1
if cnt % 8 == 0:
print('')
至此,验证的识别工作算是完满结束。