这篇文章发表于 606 天前,可能其部分内容已经发生变化,如有疑问可询问作者。

在一些场景我们可能需要识别验证码,本文就简单介绍我遇到的某个场景该如何完成验证码的识别,其实就相当于一个简单例子。你甚至可以把本文当做这个项目的简单实践。

事情是这样的,某网站用到了验证码,然后它长这样:

example

另外,该验证码的运算只涉及加减乘除,数字都是0-9,问号永远在等于的右边,计算结果为正整数或者零。

因为之前完全不会深度学习(主要是没钱买显卡55555),在此特别感谢某两位友人的帮助,让我稍微体验了一下炼丹hhhh。

以下是我的操作和思考的流程。

Python-tesseract

该项目利用了Google’s Tesseract-OCR Engine。看起来使用这个方法成本比较低。我本打算用它识别出0123456789+-*/=?这些字符,但是遇到了让我放弃此方法的阻力。

对于windows而言,前期准备只需要pip install pytesseract;然后去该网站下载一个tesseract-ocr-w64-setup.exe即可,它需要你进一步配置相应的环境变量。你当然也可以不设置环境变量,像我下面这样写就好。

另外对于不同的语言,你需要从这里下载相应的traineddata然后置于Tesseract-OCR/tessdata文件夹中。

对于一般的文字而言,如下的代码就足以识别。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from PIL import Image
import os
import pytesseract

pytesseract.pytesseract.tesseract_cmd = 'C:\\Program Files\\Tesseract-OCR\\tesseract.exe'
os.environ['TESSDATA_PREFIX'] = 'C:\\Program Files\\Tesseract-OCR\\tessdata'

def viewImage(image):
return pytesseract.image_to_string(image, lang="chi_sim")

def getImage():
path = "captchaImage.jpg"
return Image.open(path)

image = getImage()
result = viewImage(image)
print(result)

我们需要根据验证码的情况进一步对相关的内容进行训练以简单生成自己需要的数据集,需要用到的便是jTessBoxEditor工具(有文章建议下载带有FX字样的,支持中文,然而我跑不起来)。接下来我从那个网站上搞了一堆验证码下来,先用两张验证码走一遍流程(您可以参考这篇文章),发现很不顺利。

不太顺利在哪呢?一是这个需要一点点很费劲地把文字部分框住,二是开始训练之后我遇到一个解决不了的报错(貌似是程序自己的BUG)。于是乎很快就放弃了。

很郁闷,当时并不想搞啥机器学习、深度学习这种(因为完全不会),但还是简单看了看一些项目,比如这个,还有这个。似乎实践起来并不难。

dddd_trainer

最后我将目光转到这个项目上。按照它的意思,应该是只要生成一堆带着label的图片然后喂给它即可,这些label就是形如1+1=?这样一串公式,而并不是最后的结果2(可能也行,没试过)。

看起来最后需要跑下GPU,然而因为没有显卡,又嫌Colab太慢,就去外面租了一个服务器,本人选择恒源云(不是打广告 ==)。

在跑这项目前,我们先需要生成一堆公式图片才行。

生成图片

可是我并不知道网站是如何生成验证码的,所以只能通过一些调试让自己生成的验证码尽可能像目标网站上的。

那个网站的后端是java,于是可以尝试从java常用的一些验证码库里面寻找,能找到一篇比较有趣的文章,里面有几个验证码,和我们在那个网站上遇到的非常相似。

可以自行编写一下,目标网页验证码的特点在前面也提到过,所以在生成表达式的时候(特别是减法和除法)还要注意下限制。

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
import com.google.code.kaptcha.Producer;
import com.google.code.kaptcha.impl.DefaultKaptcha;
import com.google.code.kaptcha.util.Config;

import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.util.Properties;
import java.util.Random;

public class Kaptcha {

// 验证码格式调整
public Producer kaptchaProducer() {
Properties properties = new Properties();
properties.setProperty("kaptcha.image.width", "100");
properties.setProperty("kaptcha.image.height", "40");
properties.setProperty("kaptcha.textproducer.font.size", "32");
properties.setProperty("kaptcha.textproducer.font.color", "black");
properties.setProperty("kaptcha.textproducer.noise.impl", "com.google.code.kaptcha.impl.NoNoise");

DefaultKaptcha kaptcha = new DefaultKaptcha();
Config config = new Config(properties);
kaptcha.setConfig(config);
return kaptcha;
}

// 生成验证码图片
public void kaptchaGen(String text, String fileName) throws IOException {
System.out.println("验证码:" + text);
BufferedImage image = this.kaptchaProducer().createImage(text); // 生成图片
File outfile = new File(fileName);
ImageIO.write(image, "png", outfile);
}

// 构建表达式,有一些限制
public String expressionGen() {
Random r = new Random();
float result = (float) 0.1;
String expression = "";
while (result % 1 != 0 || result < 0) {
expression = "";
int i1 = r.nextInt(10);
int i2 = r.nextInt(10);
int opIndex = r.nextInt(4);
switch (opIndex) {
case 0:
expression = i1 + "+" + i2;
result = i1 + i2;
break;
case 1:
expression = i1 + "-" + i2;
result = i1 - i2;
break;
case 2:
expression = i1 + "*" + i2;
result = i1 * i2;
break;
case 3:
if (i2 != 0){
expression = i1 + "/" + i2;
result = (float) i1 / (float) i2;
}
break;
}
}
expression += "=?@" + result;
return expression;
}

// 入口
public static void main(String[] args) throws IOException {
Kaptcha k = new Kaptcha();
String text = k.expressionGen();
String fileName = "save1.png";
k.kaptchaGen(text.split("@")[0], fileName);
}

}

从save下来的图片我们可以看到确实是构建了和那个图片一样的表达式,但在样式上有较大的区别。

save1

稍微调一调生成用的参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 验证码格式调整
public Producer kaptchaProducer() {
Properties properties = new Properties();
properties.setProperty("kaptcha.image.width", "160");
properties.setProperty("kaptcha.image.height", "60");
properties.setProperty("kaptcha.textproducer.font.size", "34");
properties.setProperty("kaptcha.textproducer.font.color", "10,5,222");
//properties.setProperty("kaptcha.background.clear.to", "white");
properties.setProperty("kaptcha.noise.impl","com.google.code.kaptcha.impl.NoNoise");
properties.setProperty("kaptcha.obscurificator.impl", "com.google.code.kaptcha.impl.ShadowGimpy");
properties.setProperty("kaptcha.border.color", "green");
properties.setProperty("kaptcha.textproducer.font.names", "Times,Arial,Courier,Serif");

DefaultKaptcha kaptcha = new DefaultKaptcha();
Config config = new Config(properties);
kaptcha.setConfig(config);
return kaptcha;
}

save2

好了不少,对比最开始那张3-3=?的图片,当时我觉得差不多了。但其实这里是存在一些问题的,您可以先停下来对比看看图并想一想。您可能觉得这个font.color有点离谱,确实,开发不太可能闲得搞个这样的颜色,还是改成blue吧。

剩下的就是按照ddddocr_train里面的步骤生成数据,可以看到它需要提供images和labels.txt文件。考虑到以上程序的加减乘除四种运算的概率有点不同,我顺便改进了一下程序。

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
import com.google.code.kaptcha.Producer;
import com.google.code.kaptcha.impl.DefaultKaptcha;
import com.google.code.kaptcha.util.Config;
import net.lingala.zip4j.ZipFile;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.io.FileUtils;

import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.*;
import java.util.Properties;
import java.util.Random;

public class Kaptcha {

// 验证码格式调整
public Producer kaptchaProducer() {
Properties properties = new Properties();
properties.setProperty("kaptcha.image.width", "160");
properties.setProperty("kaptcha.image.height", "60");
properties.setProperty("kaptcha.textproducer.font.size", "34");
properties.setProperty("kaptcha.textproducer.font.color", "blue");
//properties.setProperty("kaptcha.background.clear.to", "white");
properties.setProperty("kaptcha.noise.impl","com.google.code.kaptcha.impl.NoNoise");
properties.setProperty("kaptcha.obscurificator.impl", "com.google.code.kaptcha.impl.ShadowGimpy");
properties.setProperty("kaptcha.border.color", "green");
properties.setProperty("kaptcha.textproducer.font.names", "Times,Arial,Courier,Serif");

DefaultKaptcha kaptcha = new DefaultKaptcha();
Config config = new Config(properties);
kaptcha.setConfig(config);
return kaptcha;
}

// 生成验证码图片
public void kaptchaGen(String text, String fileName) throws IOException {
//System.out.println("验证码:" + text);
BufferedImage image = this.kaptchaProducer().createImage(text); // 生成图片
File outfile = new File(fileName);
ImageIO.write(image, "JPEG", outfile);
}

// 构建表达式,有一些限制
public String expressionGen(int opIndex) {
Random r = new Random();
float result = (float) 0.1;
String expression = "";
while (result % 1 != 0 || result < 0) {
expression = "";
int i1 = r.nextInt(10);
int i2 = r.nextInt(10);
switch (opIndex) {
case 0:
expression = i1 + "+" + i2;
result = i1 + i2;
break;
case 1:
expression = i1 + "-" + i2;
result = i1 - i2;
break;
case 2:
expression = i1 + "*" + i2;
result = i1 * i2;
break;
case 3:
if (i2 != 0){
expression = i1 + "/" + i2;
result = (float) i1 / (float) i2;
}
break;
}
}
expression += "=?@" + result;
return expression;
}

// 入口
public static void main(String[] args) throws IOException {

String basePath = "./data/";
String imgPath = basePath + "images_set/images/";

FileUtils.deleteDirectory(new File(basePath));
FileUtils.createParentDirectories(new File(imgPath + "x"));

Kaptcha k = new Kaptcha();
System.out.println("[*] GENERATING Pics...");

// 循环控制加减乘除数量每种各1000个
for (int i = 0; i < 1000; i++) {
for (int j = 0; j < 4; j++) {
String text = k.expressionGen(j);
String fileName = DigestUtils.md5Hex(text + i) + ".jpg";
k.kaptchaGen(text.split("@")[0], imgPath + fileName);
//System.out.println("FILE" + i + ": " + fileName);
FileUtils.writeStringToFile(new File("./data/images_set/labels.txt"), fileName + "\t" + text.split("@")[0] + "\n", true);
}
}
System.out.println("[*] GENERATING PROCESS IS DONE!");

ZipFile zipfile = new ZipFile(new File("./data/data.zip"));
zipfile.addFolder(new File(basePath));

System.out.println("[+] ZIP is OK.");
}
}

运行后,该程序会自动生成这一系列图片和labels.txt文件然后还给你贴心地打个包。

运行trainer

这个其实ddddocr_train里面已经写清楚了,我不赘述了。

1
2
3
4
5
rm -rf projects/kaptcha1000/
python app.py create kaptcha1000
cp config.yaml projects/kaptcha1000/config.yaml # config.yaml方便本人进一步调试
python app.py cache kaptcha1000 /hy-tmp/data/images_set/ file
python app.py train kaptcha1000

running

训练结束后,将projects/kaptcha1000/models/*里的内容拷贝出来,和以下检验程序同级目录运行:

1
2
3
4
5
6
7
8
9
import ddddocr

ocr = ddddocr.DdddOcr(det=False, ocr=False, import_onnx_path="kaptcha400_1.0_11_1350_2022-08-28-22-24-11.onnx", charsets_path="charsets.json")

with open('1.jpg', 'rb') as f:
image_bytes = f.read()

res = ocr.classification(image_bytes)
print(res)

根据其最后输出的表达式和图片中的结果对比就知道训练效果了。

这里可能遇到几个问题:

  • 程序输出很快就出现ACC:1.0,而且到结束出现了非常多的ACC:1.0——这基本上是过拟合了,效果不会好。需要减小轮数,可以修改那个项目的配置文件中的TARGET-Accuracy, Cost, Epoch这些,最好也将步长改短一点
  • 程序输出一直在ACC:0.0附近徘徊或者一直在另外一个数上徘徊——这基本上是触及程序极限收敛了,让它重学吧
  • 程序输出看起来很正常但对从那个网站下载的验证码进行检测的时候效果很差——原因可能是多方面的

前两点很容易就能解决,对于最后一点,我的想法是尝试让读入的数据更好,也就是在生成图片这过程上下点工夫。如何才能更好呢?这个疑问让我找到了不少存在的问题。

图片生成中的问题

折腾很久参数后发现效果不明显,我渐渐将目光转移到数据生成这块内容上,进而认识到之前并没有关注的一些问题。

  • 大量图片可能比较相似,存在一些label相同而且样子也长得差不多的图片,对于这样的内容,其训练效果可能并不好

  • 图片中验证码的颜色和大小未必同网页上的验证码,尤其是字体大小

  • 图片上的验证码位置有一点点区别!这一点点区别其实是非常致命的,因为它会导致模型关注位置不一样

  • 边框也有一点点区别(但这个好像问题不大)

    compare

这两点就要求我们构造的图片不能这样草率。和朋友探讨之后觉得应该通过如下办法依次解决:

  • 遍历第一个数字、四个符号和第二个数字,生成10*4*10个表达式(这里不需要考虑表达式的意义,比如结果一定要正整数或零,就之前的限制其实可以无视),这样的400个表达式共生成2~3组(太多很可能导致重复,太少可能有些数字识别效果不好)
  • 字体大小搞一组区间,比如32-36。其实这一方法已经在之前的字体样式中已经用过,目的就是增强其鲁棒性
  • 原本以为字体位置是可以调整的,但找了很久手册都没发现相关的配置参数——最后发现这里需要使用的是com.github.penggle.kaptcha 2.3.2而不是com.google.code.kaptcha.kaptcha 2.3包,很坑

考虑到这些问题后的程序代码如下:

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
import com.google.code.kaptcha.Producer;
import com.google.code.kaptcha.impl.DefaultKaptcha;
import com.google.code.kaptcha.util.Config;
import net.lingala.zip4j.ZipFile;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.io.FileUtils;

import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.*;
import java.util.Properties;

public class Kaptcha {

// 验证码格式调整
public Producer kaptchaProducer(int fontSize) {
Properties properties = new Properties();
properties.setProperty("kaptcha.image.width", "160");
properties.setProperty("kaptcha.image.height", "60");
properties.setProperty("kaptcha.border.color", "green");
properties.setProperty("kaptcha.noise.color", "gray");
properties.setProperty("kaptcha.noise.impl","com.google.code.kaptcha.impl.NoNoise");
properties.setProperty("kaptcha.obscurificator.impl", "com.google.code.kaptcha.impl.ShadowGimpy");
properties.setProperty("kaptcha.textproducer.font.names", "Times,Arial,Courier,Serif");
properties.setProperty("kaptcha.textproducer.font.size", String.valueOf(fontSize));
properties.setProperty("kaptcha.textproducer.font.color", "blue");
properties.setProperty("kaptcha.textproducer.char.length", "10");
//properties.setProperty("kaptcha.background.clear.to", "white");

DefaultKaptcha kaptcha = new DefaultKaptcha();
Config config = new Config(properties);
kaptcha.setConfig(config);
return kaptcha;
}

// 生成验证码图片
public void kaptchaGen(String text, String fileName, int fontSize) throws IOException {
//System.out.println("验证码:" + text);
BufferedImage image = this.kaptchaProducer(fontSize).createImage(text); // 生成图片
File outfile = new File(fileName);
ImageIO.write(image, "JPEG", outfile);
}

// 构建表达式,有一些限制
public String expressionGen(int opIndex, int i1, int i2) {
String expression = "";
switch (opIndex) {
case 0:
expression = i1 + "+" + i2;
break;
case 1:
expression = i1 + "-" + i2;
break;
case 2:
expression = i1 + "*" + i2;
break;
case 3:
expression = i1 + "/" + i2;
break;
}
expression += "=?";
return expression;
}

// 入口
public static void main(String[] args) throws IOException {

String basePath = "./data/";
String imgPath = basePath + "images_set/images/";

FileUtils.deleteDirectory(new File(basePath));
FileUtils.createParentDirectories(new File(imgPath + "x"));

Kaptcha k = new Kaptcha();
System.out.println("[*] GENERATING Pics...");

for (int z = 0; z < 2; z++) {
for (int p = 0; p < 5; p++) {
for (int i1 = 0; i1 < 10; i1++) {
for (int j = 0; j < 4; j++) {
for (int i2 = 0; i2 < 10; i2++) {
String text = k.expressionGen(j, i1, i2);
String fileName = DigestUtils.md5Hex(text) + ".jpg";
k.kaptchaGen(text.split("@")[0], imgPath + fileName, 32 + p);
//System.out.println("FILE" + i + ": " + fileName);
FileUtils.writeStringToFile(new File("./data/images_set/labels.txt"), fileName + "\t" + text.split("@")[0] + "\n", true);
}
}
}
}
}
System.out.println("[*] GENERATING PROCESS IS DONE!");

ZipFile zipfile = new ZipFile(new File("./data/data.zip"));
zipfile.addFolder(new File(basePath));

System.out.println("[+] ZIP is OK.");
}
}

这样我们就生成了两次字体号为32~36、遍历数字和符号的表达式组,共计2*5*400=4000个表达式。

这生成验证码的程序打包可以在这里下载

接着重新开始炼丹,调整config.json文件中的参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Model:
CharSet: [' ', '-', '2', '1', '*', '8', '7', '9', '6', '3', '4', '0', /, '5',
+, '=', '?']
ImageChannel: 1
ImageHeight: 64
ImageWidth: -1
Word: false
System:
Allow_Ext: [jpg, jpeg, png, bmp]
GPU: true
GPU_ID: 0
Path: /hy-tmp/data/images_set/images
Project: kaptcha4000
Val: 0.03
Train:
BATCH_SIZE: 32
CNN: {NAME: ddddocr}
DROPOUT: 0.3
LR: 0.01
OPTIMIZER: SGD
SAVE_CHECKPOINTS_STEP: 100
TARGET: {Accuracy: 0.97, Cost: 0.05, Epoch: 10}
TEST_BATCH_SIZE: 32
TEST_STEP: 50

然后经过一段时间训练后,得到很好的效果(相关结果和部分网页测试数据可在这里下载)。