利用 Canvas API 实现正方验证码的识别与自动填充

ZGQ's Blog

背景

新学期伊始,年度抢课大戏同步上映,正方教务系统也迎来了前所未有的流量冲击。自然这跑在 Windows 2003 的上古时期的 ASP.NET 程序的服务器也承受不住,在选课高峰期频频崩溃,从而也导致了登录的账户在选课期间频频掉线的问题。

不得不吐槽一下这上古时期的网页的交互逻辑:教务系统在每一次掉线以后都需要重新登录,这个过程最致命的是,当登录时输入错误的情况下,用户名、密码、验证码都需要重新填写,可以说是十分反人类了。

用户名和密码可以通过浏览器自动填充,解决了一个大问题,但是验证码还需要手动输入,重复去输入那辣眼睛的验证码还是让人有些心塞,且很容易出错,想想这几秒钟也十分关键,关系到那些喜欢且热门的课程能否到手,所以我产生了做一个验证码自动填充的程序的想法。

经过半个月的研究,这个想法也变成了现实。

你可以选择以下任意一种方式使用本脚本:

  1. 油猴脚本:SCNU JWC Captcha filler
  2. 书签栏链接JS注入脚本:一键登录教务系统脚本生成器 2.0

思路

受到 eleflea/neu_filler: 自动填充东北大学教务处验证码脚本 的启发,发现通过 HTML5 的 Canvas API 的 getImageData 方法可以实现对网页图片像素的读取,这里的思路大致也是一样的,唯独多了个旋转归一的过程,具体可以分为以下5个步骤:

  1. 获取图片信息
  2. 将图片二值化
  3. 把图片中不同的字符分割出来
  4. 将分割的字符通过旋转的方式归一化
  5. 验证码的训练与识别

图片信息的获取

这里通过 Canvas API 将验证码图片元素绘制到 canvas 中,再通过 context 对象的 getImageData 方法取出图片的像素数组备用。

获取图片信息的代码:

let img = document.getElementById("icode");
// 获取图片宽高
const { width, height } = img;
// 通过 canvas 存图片
const canvas = document.createElement("canvas");
// 设置 canvas 的宽高
canvas.width = width;
canvas.height = height;
// context
const ctx = canvas.getContext("2d");

// 把验证码绘制到canvas中
ctx.drawImage(img, 0, 0);

// 提取像素颜色数组
let imageData = ctx.getImageData(0, 0, width, height).data;

通过上述代码获取的 imageData 是一个 Uint8ClampedArray 数组对象,该数组长度是像素点个数的4倍,依次是像素点的 R, G, B, A 四个通道的大小,唯一要注意的是,context 对象的 getImageData 函数是受到浏览器的安全策略限制的,详情可以参考API文档关于跨域图片的描述

二值化

获取了一批样本后,发现验证码默认使用 rgb(0, 0, 153) 作为字符主体的颜色,所以这里可以通过一个简单的方式来把它区分开来。为此我新建了一个数组变量用来保存二值化图片各个像素的信息。

// 下一步,二值化
let grayImageData = [];
for (let i = 0; i < 4 * width * height; i += 4) {
  // 策略,蓝色优先
  let white = imageData[i] < 105 && imageData[i + 1] < 105 && imageData[i + 2] > 100;
  grayImageData.push(white ? 1 : 0);
}

同时我也写了一个将新产生的二值化数据转换为 Canvas 的函数(详见后文),效果如图:
81201-blgeuo43fb7.png

81201-blgeuo43fb7.png

在上图中我们发现二值化后的图片中有许多噪点也一并包括了,这是因为噪点的颜色也比较接近字符产生的问题。

噪点基本上都是孤立的像素,所以我们可以通过统计一个点周围的像素多少来判断它是不是噪点,实现的过程中,针对坐标分别设计 get, set 函数实现实际坐标与数组下标的映射。

// 获取坐标对应像素点的值
function get(x, y) {
  return imageData[y * width + x];
}

// 设置像素点的值
function set(x, y, value) {
  imageData[y * width + x] = value;
}

imageData.forEach((v, i) => {
  // 第几行 i / width
  // 第几列 i % width
  let x = i % width, y = parseInt(i / width);
  // console.log(x, ', ', y);
  let u = get(x, y - 1) !== 0;
  let d = get(x, y + 1) !== 0;
  let l = get(x - 1, y) !== 0;
  let r = get(x + 1, y) !== 0;
  // 判断像素四周是否为孤立的像素点,没有就设置为0
  // console.log(u + d + l + r);
  if (v == 1 && (u + d + l + r) < 2) {
    imageData[i] = 0;
  }
});

参考 neu 的同学的代码,同时也想到之后的操作可能对图片数组需要大量的复用,所以这里开始抽象了一个命名为 GrayImage 的数据类型,并在其原型中添加一个把二值化图片打印到 Canvas 元素的可视化方法:

// 定义一个 GrayImage 数据类型
function GrayImage(width, height, dataArr) {
  this.width = width;
  this.height = height;

  // 预先设置图片数据
  if (dataArr) {
    this.imageData = dataArr;
  } else {
    this.imageData = [];
    // 初始化:所有像素点置0
    for (let i = 0; i < this.width * this.height; i++) {
      this.imageData.push(0);
    }
  }
}

// 通过 Canvas API 把二值化图形可视化
GrayImage.prototype.draw = function (ctx) {
  let imgArr = new Uint8ClampedArray(4 * this.width * this.height);
  for (let i = 0; i < this.width * this.height; i++) {
    for (let j = 0; j < 3; j++) {
      imgArr[4 * i + j] = this.imageData[i] * 255;
    }
    imgArr[4 * i + 3] = 255;
  }

  let newImageData = new ImageData(imgArr, this.width, this.height);

  ctx.putImageData(newImageData, 0, 0);
};

// 获取坐标对应像素点的值
GrayImage.prototype.get = function (x, y) {
  return this.imageData[y * this.width + x];
};

// 设置像素点的值
GrayImage.prototype.set = function (x, y, value) {
  this.imageData[y * this.width + x] = value;
};

// 去除孤立噪点
GrayImage.prototype.removeNoise = function () {
  this.imageData.forEach((v, i) => {
    // 第几行 i / width
    // 第几列 i % width
    let x = i % this.width, y = parseInt(i / this.width);
    let u = this.get(x, y - 1) !== 0;
    let d = this.get(x, y + 1) !== 0;
    let l = this.get(x - 1, y) !== 0;
    let r = this.get(x + 1, y) !== 0;
    // 判断像素四周是否为孤立的像素点,没有就设置为0
    if (v == 1 && (u + d + l + r) < 2) {
      this.imageData[i] = 0;
    }
  });
};

从此,当我们需要二值化图片的时候,直接 new 一个 GrayImage 就好了,可视化也成为了一件非常方便的事情,只需新建一个 canvas 元素,把它的context对象传入 draw 方法即可,效果还是挺完美的。

93562-thf9064iqw.png

93562-thf9064iqw.png

分割字符串

这一步是整个项目的开发过程的难点,验证码的字符经过了随机的旋转,有很大的可能会出现粘连的状况,如何分割粘连字符成为了一个很大的问题。注意到它每个字符出现的位置比较平均,经过几天的尝试,我最终选择的方案是,先循环纵向扫描,通过统计一条扫描线上出现字符的像素点个数来一个初步的切割,然后再根据实际的情况,通过宽度2等分、3等分、4等分的方式把不同的字符切割开来,代码较长,就不贴了,可以在Github查看。

切割以后需要将字符四周空白部分去掉,以免影响下一步的判断,这里将其作为 GrayImage 数据类型的成员方法:

// 去除四周空白部分
GrayImage.prototype.removeBlank = function () {
  let newImageData = [];
  // 上下左右边界
  let u = this.height * 2;
  let d = 0;
  let l = -1;
  let r = 0;
  // 按竖线来扫描
  for (let i = 0; i < this.width; i++) {
    // 统计数量,方便区分左右边界
    let count = 0;
    for (let j = 0; j < this.height; j++) {
      if (parseInt(this.get(i, j)) === 1) {
        count++;
        // 上边界
        if (j < u) {
          u = j;
        }
        // 下边界
        if (j > d) {
          d = j;
        }
      }
    }
    // 左边界,如果l没被赋值过就设置初值,以后都不变了
    if (count > 0 && l === -1) {
      l = i;
    }
    // 右边界
    if (count > 0 && r < i) {
      r = i;
    }
  }
  // 若u没有改变,说明没扫到像素点
  if (u === this.height * 2) {
    u = 0;
  }
  // 保存到数组里面
  for (let i = u; i < d + 1; i++) {
    for (let j = l; j < r + 1; j++) {
      // console.log(this.get(j, i));
      newImageData.push(this.get(j, i));
    }
  }
  this.imageData = newImageData;
  // 新的宽高
  this.width = r - l + 1;
  this.height = d - u + 1;
}

效果:
51050-encoxwpu8z.png

51050-encoxwpu8z.png

旋转字符的归一化

到了这一步,必要的字符已经分割出来了,但是还存在新的问题:本身教务的验证码有30多个字符组成(0-9, a-z, 有的字母没有出现),而且每个字符可能会有不同的旋转角度,所以这时候的状态是非常多样的,基于这么多种情况的判断自然是一件十分消耗性能的事情,在浏览器端实现不太现实,有必要做一个操作使得相同字符旋转到同一个角度,方便采样与判断。

这里的 GrayImage 的旋转是通过线性代数的齐次坐标运算实现的,参考 这篇文章 ,结合旋转与平移的操作,最终我们得到了一个这样的线性变换矩阵:

27533-vpubjdbr7ms.png

27533-vpubjdbr7ms.png

把原本位图的像素点坐标写成 (x, y, 1) 的齐次坐标形式,通过变换矩阵的作用(矩阵乘法),得到旋转以后的点的坐标,这在JS的实现如下:

function rotate(x, y, tx, ty, angle) {
  let cos = Math.cos(angle);
  let sin = Math.sin(angle);
  let rx = Math.round(x * cos - y * sin + (1 - cos) * tx + sin * ty);
  let ry = Math.round(x * sin + y * cos + (1 - cos) * ty - sin * tx);
  return { rx, ry };
}

其中,x, y 为原始的横纵坐标,tx, ty 为旋转中心坐标,angle为旋转角的弧度形式,角度需要 * π / 180 转换为弧度,函数返回经过旋转变换以后像素点的坐标。

接下来遍历字符所有的像素点,得到所有通过旋转矩阵作用后新的像素点的坐标,继续封装成了 GrayImage 数据类型的成员方法,具体实现过程中,由于旋转以后可能会出现负数的坐标,不能落在可视范围内,所以这里创建了一个相对较大的“画布”(GrayImage),并将旋转后的点略微平移了一下,再调用本身的 removeBlank 方法去除掉了没用的空白部分:

// 绕中心点旋转一定角度,返回一个旋转后的GrayImage
GrayImage.prototype.rotate = function (deg) {
  // 创建一个 30 x 30 的临时画布
  let grayChar = new GrayImage(30, 30);
  // 开始旋转,像素矩阵需要经过旋转矩阵变换得到新的矩阵
  let tx = Math.round(this.width / 2);
  let ty = Math.round(this.height / 2);
  for (let i = 0; i < this.width; i++) {
    for (let j = 0; j < this.height; j++) {
      let axis = rotate(i, j, tx, ty, deg * Math.PI / 180); // 旋转角,需要转换为弧度制
      // console.log(i, j, axis);
      // +8是为了把旋转后的图形平移到可视范围,方便进一步切割
      grayChar.set(axis.rx + 8, axis.ry + 8, this.get(i, j));
    }
  }
  // 返回之前去除多余空白
  grayChar.removeBlank();
  return grayChar;

  //
  // 旋转角度函数,基于齐次坐标的线性变换
  // http://blog.csdn.net/csxiaoshui/article/details/65446125
  //
  function rotate(x, y, tx, ty, angle) {
    let cos = Math.cos(angle);
    let sin = Math.sin(angle);
    let rx = Math.round(x * cos - y * sin + (1 - cos) * tx + sin * ty);
    let ry = Math.round(x * sin + y * cos + (1 - cos) * ty - sin * tx);
    return { rx, ry };
  }
}

那么怎么通过旋转使得字符归一化呢?这里的思路是,通过从逆时针25度到顺时针25度依次的旋转,在每次旋转中判断旋转后图形的宽度,取宽度最小的字符作为最终结果返回,为了减小性能损失,这里的步进值是2度(在实际的测试中,对于 Chromium 的 V8 引擎来说,这种事情对于它的性能并没有多大的影响):

// 获得标准化字符(把歪了的字符转回来)
GrayImage.prototype.normalize = function () {
  let charList = [];
  let minIndex = -1;
  let minWidth = this.width;
  // 从-25度旋转到25度,比较宽度,找到宽度最小的一个
  for (let i = -25; i <= 25; i += 2) {
    let newChar = this.rotate(i);
    charList.push(newChar);
    if (newChar.width < minWidth) {
      minWidth = newChar.width;
      minIndex = charList.length - 1;
    }
  }
  if (minIndex === -1)
    return this;
  // console.log(minWidth, minIndex);
  return charList[minIndex];
};

打印出经过旋转标准化后的图形,它们大体上没有问题,但总给人一种“千疮百孔”的感觉。

04809-gbi7q32w1mk.png

04809-gbi7q32w1mk.png

起初我以为是JS引擎中三角函数的计算精度问题,但后来经过统计发现,像素点并没有缺少,经过搜索发现,这一步其实引出了位图变换的插值问题。(感觉像是打开了一个新的巨坑,以前从未注意过)

看到知乎上关于这个话题的一句话:

自然科学中只要涉及到 —连续的物理信号采样和表示为离散信号,再重建为连续信号— 这一过程的,插值都是重建过程中的主要手段之一。

但是我想做的只是浏览器端的验证码识别哇,形状没差就行,所以图形的处理就到此为止吧。

样本训练与识别的实现

由于博主目前掌握的知识还不足以驾驭「机器学习算法」,加之浏览器的性能也不允许整的太复杂,所以最终我采用的是较为简单方便的比较 Levenshtein distance (莱文斯坦距离,也叫编辑距离) 的方法来实现,通过处理后的验证码字符的 ImageData 数组与收集的样本一一计算莱文斯坦距离,找到最接近的一个字符,将这个字符的值作为返回结果。

这时候这个算法的性能就取决于算法本身的开销以及样本的数量了,好在正方的验证码本身并没有太多像素点,还是很好判断的,样本的数量的话,先看着办吧,回头再考虑优化的问题。

Levenshtein distance 算法本身在网上有许多实现,这里我选择了一个目前性能最好的库 gustf/js-levenshtein, 用 Node 的 Express 框架实现了一个简单的人工打码样本收集器,将样本的宽高,对应字符,二值化数组数据保存在一个 SQLite 数据库中。

17735-u4x87v7r24.png

17735-u4x87v7r24.png

大概收集了 1500 多条的数据,识别的精度已经很高了,时间消耗大约在 200ms 左右,具体多少我没有测试,但实际使用中,绝大部分图片都可以一次识别通过,偶尔需要两次。

后来我按照字符的宽度将样本做了一个简单的分类,减小比较的次数,效果还是很显著的,识别的时间消耗降低到了 50-100ms 左右,基本可以投入使用了。

这里同样也抽象了一个样本集合的对象出来,主要代码如下:

// charList 样本库数据对象
let charList = {
  // 按照宽度分类的字符列表
  charQueueList: null,
  // 从服务器下载样本集
  download: async function () {
    try {
      let { success, data, version } = await fetchURL("/chars/get");
      if (!success) throw "error";
      // 保存样本集的数组
      let charList = data;
      // 按照宽度来分类样本集
      this.charQueueList = [];
      charList.forEach((v) => {
        // 创建二维数组
        if (!this.charQueueList[v.width]) {
          this.charQueueList[v.width] = [];
        }
        this.charQueueList[v.width].push(v);
      });
      // 存入缓存
      storage.set("charListVersion", version);
      storage.set("charList", this.charQueueList);
      // 开始处理
      return { version };
    } catch (e) {
      return Promise.reject(e);
    }
  },
  // 找到与传入的 GrayImage 最接近的字符
  recognize: function (char) {
    console.log(char);
    let distance;
    let c;
    // 取出宽度对应的执行队列
    let queue = this.charQueueList[char.width];
    // console.log(queue);
    for (let i = 0; i < queue.length; i++) {
      let d = levenshtein(char.imageData.join(""), queue[i].data);
      if (distance === undefined || d < distance) {
        distance = d;
        c = queue[i];
      }
      if (distance < 4) break;
    }

    return { distance, char: c };
  },
}

至此,关键的核心功能已经实现,剩下要做的就是将其嵌入真正的教务系统中了,为此我打包了一个油猴脚本,点击直达:SCNU JWC Captcha filler

为了方便不方便整油猴脚本的小伙伴,我还另外做了一个通过浏览器书签栏加载的版本,并且写了个简单的界面:一键登录教务系统脚本生成器 2.0 (这种方式涉及到了一些较为危险的操作,例如从我的服务器下载脚本到浏览器端执行,若不信任请勿使用)

后记

我在做这个脚本的过程收获颇多,了解到了许多浏览器API、DOM对象的细节,同时也感受到了自己基础的薄弱之处,日后得多花一些时间精力提升编程的内功。

本文涉及的所有代码均上传到了Github,需要的伙伴可以参考参考。

Github主页:https://github.com/zgq354/zf_captcha_filler

参考资料:

  1. 转:常见验证码的弱点与验证码识别 - kira2will - 博客园
  2. 正方教务管理系统验证码识别|猎豹园地
  3. Kuri-su/CAPTCHA_Reader: PHP 验证码识别与训练框架

本文由 黑白世界4648 第一时间收藏到GET,原文来自 → blog.izgq.net

「GetParty」

关注微信号,推送好文章

微信中长按图片即可关注

更多精选文章

评论
微博一键登入