OpenCV图像处理+百度OCR实现简单的验证码识别 - ZhangTory's NoteBlog - 张耀誉的笔记博客

OpenCV图像处理+百度OCR实现简单的验证码识别

起因是有朋友想挂XX医院的号,但是专家号一票难求,所以趁着过年期间有空,打算写个挂号脚本,当然仅仅是自己用。
根据我目前掌握的技能来说,模拟请求已经很手熟了,唯一的难点在于验证码识别。
于是我查了下目前较好的验证码识别方案,发现都是基于卷积神经网络的机器学习,需要一定数量的验证码去喂,才能得到较高的识别率。然而对于我来说,肯定难以得到那么多处理好的验证码。
最终我决定用老办法,OPENCV处理验证码图像,OCR识别处理好的图像。

首先来看看处理的效果吧。

原图:
2653.jpg
二值化后的图:
bin.jpg
勾勒好的图:
draw.jpg
最终用于OCR识别的图:
out.jpg

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

1.jpg
将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%,够用了。

添加新评论

电子邮件地址不会被公开,评论内容可能需要管理员审核后显示。