关于中文点选验证码的识别

关于中文点选验证码的识别

背景描述

之前对yolov3学习了挺多,包括KerasPytorch版本的框架代码都有使用过,而且还针对网易易盾的滑块验证码做过训练和识别,因为整体上所有滑块仅可看作一类目标,所以任务比较简单;接下来打算对网易易盾的文字点选验证码进行尝试,其中又分为有语序要求和无语序要求的两类。

无语序要求

无语序要求的就是一张验证码图片上给4~5个字,然后再给你三个字,让你按顺序点击。

有语序要求

有语序要求的就是一张验证码图片上给4~5个字,但是不再直接给出让你点击的文字,而是让你根据语义顺序点击,一般是一个成语(词语)或古诗(古文里的句子,比如这里”感时花溅泪”),成语的话比较简单,利用结巴分词一般就可以解决,古诗不太好弄。

解决方案

针对文字点选类型的验证码,首先我们应该可以想到关于有语序要求的这种,实际上就涉及NLP自然语言处理领域的技术了,所以理论上我们应该把问题的解决分为两个大的阶段

  • 第一阶段

    把图片中的每个文字的位置定位到,并且分别识别出来每个位置的字是什么,这个是计算机视觉方面的技术;

  • 第二阶段

    如果是有语序要求的验证码,要通过NLP方面的技术去解决,若无语序要求那就没有第二阶段。

我们可以清楚地意识到,第一阶段和第二阶段用到的技术栈相当于是完全不同的,这篇文章会介绍下关于第一阶段的一些解决思路(大部分是理论)。第二阶段会用另一篇文章分词相关的来单独介绍。

根据第一阶段工作目标的描述,我们很容易就想到两种方案。

方案一:

第一步:先定位文字,也就是仅做目标检测,所有文字认为是1类

这一步yolov3可以轻松将问题解决,我只使用100张数据集图片就得到了一个比较不错的检测模型;

第二步:对定位区域进行切割(把字抠出来),单个文字进行识别

注意点:有些文字的角度可能不正,后面识别的时候可能需要旋转;

关于单个文字的识别方式,我能想到这样几种方式:

  • 利用识别普通的字符验证码(只包括字母和数字)的方式我觉得应该也是可以,只不过类别更多(几千个常见文字),相应的需要的数据集更多!需要的神经网络深度更深!

  • 还有一种比较取巧的方式——模板匹配,这个方案有个前提是验证码图片里要给出想让你点的文字

    这也分两种情况:

    1、验证码图片上既给了可以点击的若干个汉字,又给了想让你点击的汉字(就像上面那张图一样),也就是说你实际上也是不知道让你点的文字是什么,这个的意思是你的程序不知道(可能比较绕,希望你能懂),除非你把两个区域的文字都进行识别;

    这个时候你就可以拿需要点的文字去图中匹配(opencv中有可用的模板匹配方法,我前面的文章也有介绍使用过),这种一般在两个区域文字字体比较相似的时候,效果好一些。

    2、你能通过网站直接获取到让你点击的文字,意思的是你的程序可以获取到具体的文字,那你就可以自己将文字生成到图片中,按照可以点击的汉字字体来生成,这个就需要你能找到究竟是什么字体。

    还有其他类似相似度匹配的方法也是这样一个原理。

  • OCR识别,按照经验普通的字符验证码识别网络肯定得到的准确率不是很高,所以才会有一系列OCR识别的特殊网络被研究出来,关于OCR在文章后面的地方会单独介绍。

    当然现在已经有一些通用的OCR识别接口(可能需要收费)或者模块(例如muggle),可以直接拿来用,针对那种字体比较“端正”,背景干扰比较少的情况,这个方法还是可行的。

第三步:返回相应文字的坐标(像素点)

方案二:

第一步:定位+分类同时实现,那就要做一个几千个类别的目标检测和分类模型

这时候第一想到的可能也是yolov3,因为yolov3本身就是支持目标检测+分类的算法,但是这样一个几千个类别应该不行,yolov3应该默认可以进行80个类别的分类,但是通过改写网络说不定可以。

不过我还是试了一下,使用4000+张图片,2200+个文字分类进行训练,训练到18轮mAP还是0,所以自动就终止训练了,我推测可能是数据集太少,因为类别较多,相应的每个类别对应的图片数量就比较少了,所以不足以神经网络来提取特征。但是目前没有办法获取更多的数据集,所以没有进行下一步的工作。

按照之前字符验证码的训练经验,针对每个字符至少要有100个样本,这里4000x5/2200,平均每个字只有不到10个样本,并且文字又比字母和数字复杂的多!

第二步:返回相应文字的坐标(像素点)

下面是在实践上述部分操作时,针对pytorch-yolov3框架的使用记录和一些其他的技术。

pytorch-yolov3修改anchor box尺寸和数量

针对上面方案二的第一步,因为验证码图片大小固定,且文字大小差距也不大,所以anchor box的尺寸只需要3种大小就够了,所以这里尝试了下要做哪些调整

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[convolutional]
size=1
stride=1
pad=1
filters=18 --> 6 共三处
activation=linear


[yolo]
mask = 6,7,8 --> 2
anchors = 31,29, 36,35, 39,38 共三处
classes=1
num=9 -- > 3 共三处
jitter=.3
ignore_thresh = .7
truth_thresh = 1
random=1


......
mask = 3,4,5 --> 1
......
mask = 0,1,2 --> 0

filters = x*(classes+5),x表示每个尺寸的特征图对应的anchor box大小。

yolov3 原本是输出三个尺寸的特征图,且共有9种尺寸的anchor box,相当于每个尺寸的特征图平均分得3种尺寸的anchor box,当把anchor box尺寸数量改为3,那么x就等于1了。

多分类配置cfg文件

共2293个文字,也就是2293个分类

需要修改这两个值,共三处;x=1,fliters=1*(2293+5)

1
2
filters=2298 
classes = 2293

Resize图片

1
2
3
4
5
6
7
8
9
10
11
12
13
import cv2


img = cv2.imread("VOC2007/JPEGImages/1-1.jpg")
height, width = img.shape[:2]

# 定义缩放信息 以等比例将高缩放到416为例,宽不固定
scale = 416/height
height = 416
width = int(width*scale)

img = cv2.resize(img, (width, height))
cv2.imwrite("1-1-new.jpg", img)

切割图片

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from PIL import Image
from io import BytesIO


img_old = Image.open("VOC2007/JPEGImages/1-1.jpg")
# 剪裁出一块
img_new = img_old.crop((0, 0, 320, 160)) # 左上角和右下角坐标

# 将Image对象转换为二进制流
img_byte = BytesIO()
img_new.save(img_byte, format='PNG')
captcha_content = img_byte.getvalue()
with open(rf'1-1-new.jpg', 'wb') as fw:
fw.write(captcha_content)

读写 xml

  • 读取标注文件,并修改坐标信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import xml.dom.minidom


dom = xml.dom.minidom.parse("VOC2007/Annotations/1-1.xml")
root = dom.documentElement

# 读取标注目标框
objects = root.getElementsByTagName("bndbox")

for object in objects:
xmin = object.getElementsByTagName("xmin")
xmin_data = int(float(xmin[0].firstChild.data))
# xmin[0].firstChild.data =str(int(xmin1 * x))
ymin = object.getElementsByTagName("ymin")
ymin_data = int(float(ymin[0].firstChild.data))
xmax = object.getElementsByTagName("xmax")
xmax_data = int(float(xmax[0].firstChild.data))
ymax = object.getElementsByTagName("ymax")
ymax_data = int(float(ymax[0].firstChild.data))

# 更新xml
width_xml = root.getElementsByTagName("width")
width_xml[0].firstChild.data = width
height_xml = root.getElementsByTagName("height")
height_xml[0].firstChild.data = height

xmin[0].firstChild.data = int(xmin_data*scale)
ymin[0].firstChild.data = int(ymin_data*scale)
xmax[0].firstChild.data = int(xmax_data*scale)
ymax[0].firstChild.data = int(ymax_data*scale)

# 另存更新后的文件
with open('1-1-new.xml', 'w') as f:
dom.writexml(f, addindent=' ', encoding='utf-8')
  • 自己编写标注文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
from xml.dom import minidom


# write_info为一个列表,五个元素分别为物体name和xmin、ymin、xmax、ymax
def create_xml(count, write_info):
# 新建xml文档对象
xml = minidom.Document()

# 创建第一个节点,第一个节点就是根节点了
annotation = xml.createElement('annotation')

# 创建节点后,还需要添加到文档中才有效
xml.appendChild(annotation)

# 一般根节点是很少写文本内容,那么给根节点再创建一个子节点
folder = xml.createElement('folder')
annotation.appendChild(folder)

# 给这个节点加入文本,文本也是一种节点
text = xml.createTextNode('images')
folder.appendChild(text)

filename = xml.createElement('filename')
annotation.appendChild(filename)

# filename写入的值待定
text = xml.createTextNode(f"test{count}.jpg")
filename.appendChild(text)

source = xml.createElement('source')
annotation.appendChild(source)
database = xml.createElement('database')
source.appendChild(database)

text = xml.createElement('Unknown')
database.appendChild(text)

size = xml.createElement('size')
annotation.appendChild(size)
width = xml.createElement('width')
size.appendChild(width)
text = xml.createTextNode('320')
width.appendChild(text)

height = xml.createElement('height')
size.appendChild(height)
text = xml.createTextNode('160')
height.appendChild(text)

depth = xml.createElement('depth')
size.appendChild(depth)
text = xml.createTextNode('3')
depth.appendChild(text)

segmented = xml.createElement('segmented')
annotation.appendChild(segmented)
text = xml.createTextNode('0')
segmented.appendChild(text)

for info in write_info:
xml_object = xml.createElement('object')
annotation.appendChild(xml_object)

name = xml.createElement('name')
xml_object.appendChild(name)
text = xml.createTextNode(str(info[0]))
name.appendChild(text)

pose = xml.createElement('pose')
xml_object.appendChild(pose)
text = xml.createTextNode('Unspecified')
pose.appendChild(text)

truncated = xml.createElement('truncated')
xml_object.appendChild(truncated)
text = xml.createTextNode('0')
truncated.appendChild(text)

difficult = xml.createElement('difficult')
xml_object.appendChild(difficult)
text = xml.createTextNode('0')
difficult.appendChild(text)

bndbox = xml.createElement('bndbox')
xml_object.appendChild(bndbox)

xmin = xml.createElement('xmin')
bndbox.appendChild(xmin)
text = xml.createTextNode(str(info[1]))
xmin.appendChild(text)

ymin = xml.createElement('ymin')
bndbox.appendChild(ymin)
text = xml.createTextNode(str(info[2]))
ymin.appendChild(text)

xmax = xml.createElement('xmax')
bndbox.appendChild(xmax)
text = xml.createTextNode(str(info[3]))
xmax.appendChild(text)

ymax = xml.createElement('ymax')
bndbox.appendChild(ymax)
text = xml.createTextNode(str(info[4]))
ymax.appendChild(text)

# 保存文档
f = open(f'xml/test{count}.xml', 'w', encoding='utf-8')
xml.writexml(f, addindent=' \n')
f.close()

OCR

光学字符识别(Optical Character Recognition,OCR)。

传统OCR方法的一般流程

图像输入

图像预处理:主要包括二值化、去噪声、倾斜校正等;

版面分析

字符切割

字符识别

版面恢复:按照原文档图片的顺序排列;

后处理:根据语言模型,对识别的结果进行语义校正。

缺点:整个处理流程工序太多,而且是串行的,导致错误不断被传递放大。例如,每一步都是90%的正确率,正确率看似很高,但是经过五步的错误叠加之后为0.59049,结果就已经不合格。另外,整个处理过程涉及太多人工设计,人工设计难以抓住问题的本质,而且面对稍复杂的问题,难以应对,泛化能力弱。

基于深度学习OCR方法的一般流程

主要分为两个步骤

文本检测(定位文本的位置)

文本识别(识别文本的具体内容)

文本检测的主流方法主要包含:基于候选框(Anchor)的文本检测、基于语义分割(Segmentation)的文本检测,以及基于两种方法的混合方法(Hybrid)。

文本识别的常用框架主要有两个:即CNN+RNN+CTCCNN+Seq2Seq+Attention