使用神经网络识别同济选课网的验证码

第一次体验回调地狱

Posted by tsengkasing on 2017-04-24

最近在上数据挖掘的课程,学习到了很多分类方法、回归方法、频繁集查找等等。

同时@Novemser在大二的时候上机器学习公选课的时候使用BP神经网络识别了同济选课网的验证码。

在这些事情的驱动下,想自己也用JavaScript实现一遍

(已然成为js系的人

当然啦本文说的js是nodejs而不是跑在浏览器的js

抓取验证码图片

这一步就是简单的发网络请求,将response写入文件,存成图片格式。

目标是xuanke.tongji.edu.cn/CheckImage

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
const http = require('http');
const fs = require("fs");
const sleep = require('thread-sleep');
//爬101张
const counts = 100;
function run(i) {
const opts = {
hostname: "xuanke.tongji.edu.cn",
path: "/CheckImage",
method: 'GET',
port: 80,
headers: {
'Accept':'image/webp,image/*,*/*;q=0.8',
'Accept-Encoding':'gzip, deflate, sdch',
'Accept-Language':'en-US,en;q=0.8,zh-CN;q=0.6,zh;q=0.4',
'Cache-Control':'no-cache',
'Connection':'keep-alive',
'Cookie':'yunsuo_session_verify=5405136ec66b2fdd9866f9fb72b8804f; JSESSIONID=00003VcU3lwTPmM1OkYmTVQOHn8:-1',
'DNT':1,
'Host':'xuanke.tongji.edu.cn',
'Pragma':'no-cache',
'Referer':'http://xuanke.tongji.edu.cn/',
'User-Agent':'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/57.0.2987.133 Safari/537.36'
}
};
var req = http.request(opts, function(_res) {
let info = [];
_res.on('data', (chunk) => {
info.push(chunk);
});
_res.on('end', () => {
if(!info) return;
info = Buffer.concat(info);
fs.writeFile(`./xuanke_code/code_${i}.jpg`, info, {flag: 'w'}, function (err) {
if(err) {
console.error(err);
} else {
console.log(`[INFO] Save File code_${i}.jpg`);
if(i < counts) {
sleep(3000);
run(i + 1);
}
}
});
});
});
req.on('error', (e) => {
console.log(`请求遇到问题: ${e.message}`);
});
req.end();
}
run(0);

抓取的结果

读取图片并解码成扁平数组

由于这个验证码的图片是每一张含有4个数字,我们识别的时候要裁剪成单独的数字去训练和预测。

这里使用了依赖包Jimpjpeg-js.

  1. 首先使用Jimp读取图片
  2. 进行使用Jimp的crop接口裁剪成4张图片
  3. 通过Jimp的getBuffer接口获取裁剪之后的Buffer
  4. 用jpeg-js的decode接口解码之前裁剪之后的Buffer
  5. 将Buffer解析成像素矩阵,每一个像素是rgba的一个四维数组
  6. 将图像二值化,白色的地方成为0,黑色的地方成为1。由于验证码图像非常典型,直接识别r是否大于127来判断即可。

Nodejs是异步的,以上每一步操作都是一个异步操作,要批量读取图片的话就是回调地狱~~

于是使用了ES6的Promise
同时也感谢Jimp提供的接口也是Promise接口,非常方便。

加载图片

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
function loadImages(length_labels) {//这里的length_labels是指图片的个数
return new Promise(function (resolve) {
let files_typed_array = {};
let promises = [];
let promises_image = [];
for(let i = 0; i < length_labels; i++) {
//设置图片路径
const img_path = `${image_folder_path}code_${i}.jpg`;
//使用Jimp提供的promise读取图片接口
promises_image.push(Jimp.read(img_path));
}
//使用Promise的all,等待所有promise结束。
Promise.all(promises_image).then(function (images) {
for (let index = 0;index < images.length; index++) {
const img = images[index];
//裁剪成4张
let _imgs = splitImageTo4Parts(img);
promises.push(...decodeImageToTypedArray(_imgs, index, files_typed_array));
}
Promise.all(promises).then(function () {
console.log('Images Loaded.');
resolve(files_typed_array);
});
}).catch(function (err) {
console.error(err);
});
});
}

分割图片成4份

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//分割图片成4个数字
function splitImageTo4Parts(img) {
const single_digit_width = img.bitmap.width / 4.2;
const single_digit_height = img.bitmap.height * 0.8;
const single_digit_y = 2;
let i0 = img.clone();
i0.crop(1, single_digit_y, single_digit_width, single_digit_height);
let i1 = img.clone();
i1.crop(single_digit_width, single_digit_y, single_digit_width, single_digit_height);
let i2 = img.clone();
i2.crop(single_digit_width * 2, single_digit_y, single_digit_width, single_digit_height);
let i3 = img.clone();
i3.crop(single_digit_width * 3, single_digit_y, single_digit_width, single_digit_height);
return [i0, i1, i2, i3];
}

为了查看是否裁剪正确,调整裁剪的参数,使用Jimp的write接口写入文件查看裁剪结果

提取像素到矩阵

1
2
3
4
5
6
7
8
9
10
11
//提取单个图片的像素到矩阵
function extractPixel(typed_array) {//jpeg-js返回的rgba扁平数组
if(typed_array.length % 4 !== 0) throw 'Pixel Not In RGBA Format';
let pixels = [];
//这里步长为4,是因为每个像素为rgba模式
for(let i = 0; i < typed_array.length; i = i + 4) {
let pixel = [typed_array[i], typed_array[i + 1], typed_array[i + 2], typed_array[i + 3]];
pixels.push(pixel);
}
return pixels;
}

图像二值化

1
2
3
4
5
6
7
8
9
10
11
//图像二值化
function binarizeImage(pixels) {//pixels为像素矩阵
for(let i = 0; i < pixels.length; i++) {
//简单判断是否大于127(255的一半)
if(pixels[i][1] > 127)
pixels[i] = 1;
else
pixels[i] = 0;
}
return pixels;
}

将单个数字图片转换成扁平的二值化数组

将以上三步按顺序结合在一起

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//将单个数字图片转换成扁平的二值化数组
//images是图片的Jimp实例对象数组,分别是4个数字,index是我读写文件用的索引,files_typed_array是转换结果写入的地方
function decodeImageToTypedArray(images, index, files_typed_array) {
let promises = [];
for (let _img = 0; _img < images.length; _img++) {
let _promise_get_array =
new Promise(function (__resolve) {
images[_img].getBuffer(Jimp.MIME_JPEG, function (err, buffer) {
__resolve(buffer);
});
}).then(function (buffer) {
return new Promise(function (_resolve) {
files_typed_array[`code_${index}_${_img}`] =
binarizeImage(extractPixel(jpeg.decode(buffer, true).data));
_resolve();
});
});
promises.push(_promise_get_array);
}
return promises;
}

贴标签

要准确地训练神经网络,当然要告诉它哪个是正确的结果应该是怎样的,于是要手动给我们的验证码图片贴标签。

手动贴标签,写成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
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
//一部分的主函数
function main() {
let labels = fs.readFileSync(`${image_folder_path}labels.txt`).toString();
try {
labels = JSON.parse(labels);
}catch (err){
console.error('labels file error');
return null;
}
}
//解析训练集和标签
function parseDataSet(typed_arrays, labels) {
return new Promise(function (resolve) {
if(Object.keys(typed_arrays).length !== labels.length * 4) {
console.error(`${Object.keys(typed_arrays).length} & ${labels.length * 4}`);
throw new Error('Length Not Equal');
}
//训练的输入
let train_feature = [];
//训练的标签
let train_label = [];
for(let i = 0; i < labels.length; i++) {
for(let j = 0; j < labels[i].length; j++) {
let digits_array = typed_arrays[`code_${i}_${j}`];
train_feature.push(digits_array);
//标签以一个10维的01数组表示
let label_bool = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
label_bool[labels[i][j]] = 1;
train_label.push(label_bool);
}
}
console.log(`训练数据总共${labels.length * 4}个数字`);
resolve({train_feature, train_label});
});
}

训练神经网络

一开始想打算找教程原理手撕一下BP神经网络,后来还是因为数学太差而放弃。
于是去npmjs找别人实现的神经网络的包,包括以下

  • ml-fnn
  • neural-node
  • neural_network
  • neuralnet

发现准确率好低好低啊,有的还特别慢(非常冷漠

最后终于找到了一个brain.js

虽然已经不再维护了,但是准确率100%呀,还能导出JSON格式的Model,或者导出静态的独立的function。(喜极而泣

虽然之前爬取了101张图片,也就是404个数字,但还是懒得把全部打上标签,只打了256个数字的标签,就用200个训练,56个测试

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
//训练神经网络
function trainNeuralNetwork(dataSet) {
return new Promise(function (resolve) {
let {train_feature, train_label} = dataSet;
let brain = require('brain');
let net = new brain.NeuralNetwork();
//训练前200个数字
let inputSet = [];
for(let i = 0; i < 200; i++) {
inputSet.push({
input: train_feature[i],
output: train_label[i]
});
}
net.train(inputSet);
//测试后56个数字
let digits_correct = 0, digits_all = 0;
for(let i = 200; i < train_label.length; i++) {
let output = net.run(train_feature[i]);
let predict_index = output.indexOf(Math.max(...output));
let label_index = train_label[i].indexOf(1);
digits_all++;
if(predict_index === label_index){
digits_correct++;
}
}
console.log(`准确率:${digits_correct / digits_all * 100}%`);
resolve(net);
});
}

将训练好的模型导出

brain.js可以导出JSON格式的model,但是读取还原的时候还需要引入brain依赖包,而导出function的话可以独立使用,不依赖包。

1
2
3
4
5
6
//将预测函数导出到文件,之后可以单独使用,不需要引用依赖包。
function storeNeuralNetworkModel(net) {
let forecast = net.toFunction();
fs.writeFileSync(`./parseCAPTCHA.js`, forecast.toString());
}

使用

我将整个识别预测写成了一个module parseCAPTCHA
使用方法如下

1
2
3
4
5
6
7
const forecast = require('./parseCAPTCHA');
//图片路径
let image_path = './xuanke_code/code_92.jpg';
//在回调函数中返回结果r
forecast(image_path, function (r) {
console.log(r);
});

依赖

本小项目依赖的包主要有4个

  • brain
  • fs
  • jimp
  • jpeg-js

集成训练结果的parseCAPTCHA.js文件不再依赖brain

代码已经放在Github上 CAPTCHA_Recognition_xuanke.tongji

神经网络真的好可怕啊