起因是有朋友想挂XX医院的号,但是专家号一票难求,所以趁着过年期间有空,打算写个挂号脚本,当然仅仅是自己用。
根据我目前掌握的技能来说,模拟请求已经很手熟了,唯一的难点在于验证码识别。
于是我查了下目前较好的验证码识别方案,发现都是基于卷积神经网络的机器学习,需要一定数量的验证码去喂,才能得到较高的识别率。然而对于我来说,肯定难以得到那么多处理好的验证码。
最终我决定用老办法,OPENCV处理验证码图像,OCR识别处理好的图像。
首先来看看处理的效果吧。
原图:
二值化后的图:
勾勒好的图:
最终用于OCR识别的图:
java如何引用OPENCV
首先记录一下java中如何使用openCV。网上好多教程资料比较旧了,对于第一次使用的朋友来说,还是略微有点门槛。
1.使用maven引入依赖,具体的版本大家可以进maven仓库查看最新版。
<!-- https://mvnrepository.com/artifact/org.openpnp/opencv -->
<dependency>
<groupId>org.openpnp</groupId>
<artifactId>opencv</artifactId>
<version>4.3.0-3</version>
</dependency>
2.你需要找到maven下载的openCV jar包的文件夹,打开jar包,在如图所示的路径下找到对应opencv_java430.dll。
其中opencv-4.3.0-3.jar\nu\pattern\opencv
目录下根据系统分为了windows、linux和oxs,然后下级目录还要区分32位系统和64位系统,根据自己的系统选择。
比如我是64位的windows系统,那么我最终需要找到dll的路径为:opencv-4.3.0-3.jar\nu\pattern\opencv\windows\x86_64\opencv_java430.dll
。
将opencv_java430.dll拷贝到jdk的bin文件夹里。
这个与你jdk安装的位置有关,比如我是:C:\Program Files\Java\jdk1.8.0_191\bin
。
3.加载dll。
可以通过jvm运行命令去指定,但是我推荐在代码中通过静态代码块加载dll。
static {
System.loadLibrary(org.opencv.core.Core.NATIVE_LIBRARY_NAME);
}
之后就可以愉快的使用openCV了。
4.简单的测试例子。将图片二值化。
@SpringBootTest
public class Test {
static {
System.loadLibrary(org.opencv.core.Core.NATIVE_LIBRARY_NAME);
}
@Test
public void test2Img() {
// 读入图片
Mat src = Imgcodecs.imread("F:\\YZM\\0634.jpg");
// 灰度化
Imgproc.cvtColor(src, src, Imgproc.COLOR_BGR2GRAY);
// 二值化
Imgproc.threshold(src, src, 240, 255, Imgproc.THRESH_OTSU);
// 输出图片
Imgcodecs.imwrite("F:\\YZM\\test.jpg", src);
}
}
通过搜索算法处理验证码
一般处理验证码时,我们都需要把他经过二值化,将原本有色彩、有灰度的图像,通过设定的阈值,转化为黑白,也就是可以用0或1来表示的矩阵,方便处理。
这里我们用0表示空白,用1表示黑色有值点。
/**
* 对图片二值化并构建数组图
* @return
*/
private int[][] buildMap(String file) {
Mat img = Imgcodecs.imread(file);
//灰度化
Imgproc.cvtColor(img, img, Imgproc.COLOR_BGR2GRAY);
//二值化
Imgproc.threshold(img, img, 100, 255, Imgproc.THRESH_OTSU);
int[][] arr = new int[img.rows()][img.cols()];
for (int r = 0; r < img.rows(); r++) {
for (int c = 0; c < img.cols(); c++) {
double[] doubles = img.get(r, c);
arr[r][c] = doubles[0] == 0D ? 1 : 0;
}
}
return arr;
}
而对于后续如何处理干扰,才是最复杂的问题。
简单点的,比如对于独立的噪点来说,他的面积比较小,只要排除掉这种面积小的独立区域就可以去掉一部分噪点。这个问题类似于LeetCode上关于岛屿的题,忘了是哪一道了。
麻烦点的是各种干扰线,他可能会与数字有重叠,但是我们可以根据它较长的特征,进行排除,不过算法相对麻烦了写。
不过对于要进行OCR识别,都是需要针对性的去处理这个验证码,所以我们需要观察一下待处理的验证码的特点,根据特点选择处理方案。
好在我这次遇到的验证码是4位纯数字,最后我根据验证码的特点,发现构成数字的线条宽度大于干扰线的宽度。
那么思路就很简单了,排除掉边缘的像素点,数字内部的像素点上下左右的值都应该是1,由此特点勾勒出数字。
/**
* 提取上下左右都有像素的点
* @param arr
* @return
*/
private int[][] draw(int[][] arr) {
int[][] ret = new int[arr.length][arr[0].length];
for (int r = 0; r < arr.length; r++) {
for (int c = 0; c < arr[r].length; c++) {
if (arr[r][c] == 1) {
int count = 0;
// 上
if (r > 0 && arr[r - 1][c] == 1) {
count++;
}
// 下
if (r < arr.length - 1 && arr[r + 1][c] == 1) {
count++;
}
// 左
if (c > 0 && arr[r][c - 1] == 1) {
count++;
}
// 右
if (c < arr[r].length - 1 && arr[r][c + 1] == 1) {
count++;
}
if (count >= 4) {
ret[r][c] = 1;
}
}
}
}
return ret;
}
到此数字的轮廓基本出现了,但是有些干扰面积比较大,也会留下一些小噪点,人眼看是非常清楚了,但是OCR识别的成功率偏低,所以我们还需要进行处理。
刚才说过,剩下的这种噪点面积较小,很好处理。
这里我打算把每个岛屿区域都提取出来,如果4个连续的数字识别率低的话,方便拆分成独立的数字进行识别。
和上面勾勒不一样,判断像素点是否相连,需要从判断8个方向上判断。因为8个就比较多了,于是就定义了int[][] direct这个方向进行搜索。
/**
* 获取图像岛屿集合
* @return
*/
private List<Set<Point>> getIslandList(int[][] arr) {
List<Set<Point>> list = new ArrayList<>();
Set<String> record = new HashSet<>();
for (int r = 0; r < arr.length; r++) {
for (int c = 0; c < arr[r].length; c++) {
if (arr[r][c] == 1 && !record.contains(r+"_"+c)) {
// 由此搜索连通的岛屿大小
Queue<Integer> qR = new LinkedList<>();
Queue<Integer> qC = new LinkedList<>();
Set<Point> set = new HashSet<>();
qR.add(r);
qC.add(c);
while (!qR.isEmpty()) {
int x = qR.poll();
int y = qC.poll();
Point point = Point.builder().x(x).y(y).build();
set.add(point);
if (x == 0 || y == 0 || x == arr.length - 1 || y == arr[x].length - 1
|| record.contains(x+"_"+y)) {
continue;
}
int[][] direct = new int[][]{{-1, 0}, {1, 0}, {0, -1}, {0, 1},
{-1, -1}, {-1, 1}, {1, -1}, {1, 1}};
// 向8个方向扩展搜索
for (int i = 0; i < direct.length; i++) {
int nx = x + direct[i][0];
int ny = y + direct[i][6];
if (arr[nx][ny] == 1) {
qR.add(nx);
qC.add(ny);
}
}
record.add(x+"_"+y);
}
list.add(set);
}
}
}
return list;
}
这里我定义了一个Point数据结构,仅仅存储x,y坐标。
@Data
@Builder
public class Point {
private Integer x;
private Integer y;
}
然后我们遍历,移除较小的岛屿.
这里MIN_ISLAND_NUMBER是定义的最小岛屿面积,需要进行调参以达到最优结果。
/**
* 移除较小的岛屿
* @param list
* @return
*/
private void findTargetIslands(List<Set<Point>> list) {
list.removeIf(set -> (set.size() <= MIN_ISLAND_NUMBER));
}
到此看上去已经不错了,但是还有两个小问题,第一是线条不平滑,会有毛刺;第二,因为最开始勾勒数字的时候线条变细了,一定程度上会影响识别成功率。
第一点,毛刺并不太好去除;第二点,直接加粗的话,毛刺周围也会被加粗。
所以我最终根据原始图像去恢复勾勒时的最外层。
/**
* 根据原图勾勒
* @param arr
* @param list
*/
private int[][] drawLineWithMat(int[][] arr, List<Set<Point>> list) {
int[][] ans = new int[arr.length][arr[0].length];
int[][] direct = new int[][]{{-1, 0}, {0, -1}, {1, 0}, {0, 1}};
for (Set<Point> points : list) {
for (Point point : points) {
ans[point.getX()][point.getY()] = 1;
// 上下左右搜索
for (int i = 0; i < direct.length; i++) {
int nx = point.getX() + direct[i][0];
int ny = point.getY() + direct[i][7];
ans[nx][ny] = arr[nx][ny];
}
}
}
return ans;
}
由于干扰可能直接与数字相连,所以一些情况下的毛刺仍然不可避免,但总体的识别率已经不错了。
OCR识别
最开始打算使用Tesseract来做OCR识别,但是实测发现未经训练的Tesseract识别效果差的惊人,必须要把验证码切割好,角度设置好才能有较好的识别率。
于是我打算使用网上公开的手写OCR识别,最终选了百度,因为有免费调用次数。
百度OCR工具地址:https://ai.baidu.com/tech/ocr?track=cp:ainsem|pf:pc|pp:chanpin-wenzishibie|pu:wenzishibie-baiduocr|ci:|kw:10002846
需要登录后创建一个引用,这个就不说了,很简单。
百度OCR识别很强大,对于我们这种纯数字不仅可以识别内容,还可以给出起始位置和旋转角度。
然后我们封装一个百度OCR的请求方法:
/**
* 获取百度试图结果
* @return
*/
public String baiduOcr() {
// 初始化一个AipOcr
AipOcr client = new AipOcr(CommonConstant.APP_ID, CommonConstant.API_KEY, CommonConstant.SECRET_KEY);
// 调用接口
HashMap<String, String> params = new HashMap<>();
params.put("detect_direction", "true");
params.put("recognize_granularity", "small");
JSONObject res = client.numbers(CommonConstant.OUT_IMG_PATH, params);
System.out.println("验证码识别:" + res);
return res.getJSONArray("words_result").getJSONObject(0).getString("words");
}
尝试了一下,成功率大约70%,够用了。