1. 程式人生 > >Java實現超簡單驗證碼識別

Java實現超簡單驗證碼識別

閒來想實現程式模擬登陸一個系統,說白了,就是寫個簡單的爬蟲,但是無奈,遇到了數字圖片驗證碼,在查閱了一些方案以後,遂決定自己手寫程式碼實現驗證碼識別,分享一下整個過程。

圖片驗證碼是什麼

圖片驗證碼,這個大家應該都見過。最普遍的圖片驗證碼就是一張圖片上面有4-6個歪歪扭扭的數字字母,圖片還有點看不清楚,但是基本可以肉眼識別出上面的數字字母。那為什麼要有這個東東呢?

其實驗證碼的出現為了區分人與機器。對於歪歪妞妞還有點看不清的數字字母圖片,由於人腦的特殊構造,是可以完全無障礙識別的,但是想讓奇蹟識別出這些字母數字,就會出現識別錯誤。那為什麼要區別人與機器呢?假如一個一個系統沒有驗證碼,我知道了你的使用者名稱,並且知道你的登入密碼是8位的數字,那我完全可以寫個指令碼程式窮舉出所有的8位數組合,挨個去嘗試登入,這個過程對於人來說可能耗時耗力,但是對於程式來說,so easy。所以驗證碼的出現就會阻止程式進行這樣的窮舉登入。

隨著技術的發展,現在很多的驗證碼系統都可以通過影象處理、機器學習深度學習等方式進行攻破,圖片驗證碼已經不再安全,即使是非常有名的12306驗證碼,也已經被利用深度學習達到了很高的識別精度。所以也出現了手機驗證碼、拖動滑塊圖片到指定位置的驗證碼等各種驗證碼。下面展示的就是幾種常見的驗證碼。

這裡寫圖片描述 這裡寫圖片描述
這裡寫圖片描述 這裡寫圖片描述

超簡單驗證碼

為什麼說是超簡單呢?因為這次需要處理的驗證碼,就是簡單的數字圖片驗證碼,並且圖片很乾淨,沒有干擾元素,數字也很規整,沒有扭曲、變形和移位。如下圖所示。
這裡寫圖片描述
看到圖片可能很多人就說了,這不就是個簡單的影象處理問題嗎,太簡單了。

先說說我看到這個圖片驗證碼的第一想法,不是自己手動實現,我先想到的是OCR(光學字元識別)。因為圖片上的數字太規整了,OCR識別是最快、最省力的,只需要呼叫介面即可。但是查了一下目前的OCR介面,找到了騰訊的OCR介面,但是一個月只有1000次免費呼叫,感覺用在爬蟲上不太夠,而且我這個驗證碼是gif圖片,騰訊的介面不支援gif。所以就乾脆自己寫一個識別程式。

首先說一下,對於這個程式的要求,識別速度要快,識別準確度要高,程式要儘量簡單,儘量不涉及影象處理的內容。換句話說就是用最低的成本實現這個驗證碼的識別。

分析思路

實現的思路其實很簡單,由於數字圖片驗證碼只有0-9這10個數字,場景很少,加之數字很規整,所以可以先收集到包含有0-9這10個數字的圖片。然後用程式進行圖片裁剪,裁剪出0-9這10個單個數字的形態的圖片並存儲。然後對於一張新的驗證碼圖片,我們可以採用先裁剪為4張單個數字圖片,然後與我們事先準備好的10個數字圖片進行相似度對比,最相似的即為正確的數字。

具體實現

下面看看具體的程式碼實現。

1.圖片邊緣空白裁剪

這一步主要是把圖片邊緣的空白裁減掉,讓剩餘的圖片剛好包含四個數字即可。圖片的原始大小是60px*36px,將其匯入ps中檢視需要裁剪的部分,然後用程式進行裁剪。如圖,就是把紅色框之外的部分裁減掉。
這裡寫圖片描述


這裡為了程式儘可能簡單,所以不使用第三方的Java包,知識用Java本身內建的ImageIO工具類進行圖片的讀寫和簡單裁剪,封裝的函式如下圖所示:

/**
 * 裁剪圖片
 * @param srcPath 原始圖片路徑
 * @param readImageFormat 讀取圖片的格式
 * @param x 裁剪的x座標
 * @param y 裁剪的y座標
 * @param width 裁剪後圖片寬度
 * @param height 裁剪後圖片高度
 * @param writeImageFormat 儲存裁剪後圖片的格式
 * @param isSave 是否儲存裁剪後的圖片到本地[不儲存會返回裁剪後圖片的位元組陣列]
 * @param toPath 裁剪後的圖片儲存路徑
 *
 * @return byte[] 如果圖片不儲存在本地,則返回裁剪後圖片的位元組陣列
 */
public static byte[] cropImg(String srcPath, String readImageFormat, int x, int y,
                           int width, int height, String writeImageFormat, boolean isSave, String toPath) {
    FileInputStream fis = null;
    ImageInputStream iis = null;
    try {
        //讀取圖片檔案
        fis = new FileInputStream(srcPath);
        Iterator it = ImageIO.getImageReadersByFormatName(readImageFormat);
        ImageReader reader = (ImageReader) it.next();
        //獲取圖片流
        iis = ImageIO.createImageInputStream(fis);
        reader.setInput(iis, true);
        ImageReadParam param = reader.getDefaultReadParam();
        //定義一個矩形
        Rectangle rect = new Rectangle(x, y, width, height);
        //提供一個 BufferedImage,將其用作解碼畫素資料的目標。
        param.setSourceRegion(rect);
        BufferedImage bi = reader.read(0, param);

        if (isSave){
            //儲存新圖片
            ImageIO.write(bi, writeImageFormat, new File(toPath));
            return null;
        }else {
            //返回位元組陣列
            ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(100);
            ImageIO.write(bi, writeImageFormat, byteArrayOutputStream);
            return byteArrayOutputStream.toByteArray();
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
    return null;
}

為了提升效能,我們在部分情況下無需將裁剪後的圖片圖片儲存到本地,而是直接轉化為位元組陣列然後進行處理即可。
使用下面的程式碼呼叫上面的函式即可完成對圖片邊緣空白的裁剪。

File sourceImgDir = new File("./sourceimg/");
    File[] sourceImgList = sourceImgDir.listFiles(new FileFilter() {
        @Override
        public boolean accept(File pathname) {
            if (pathname.getName().endsWith("gif")){
                return true;
            }
            return false;
        }
    });
    if (sourceImgList == null) {
        return;
    }

    for (File file : sourceImgList) {
        try {
            BufferedImage bufferedImage = ImageIO.read(file);
            System.out.println("width = " + bufferedImage.getWidth() + "\theight = " + bufferedImage.getHeight());
            //進行圖片邊緣空白的裁剪
            ImgUtil.cropImg(file.getPath(), "gif", 6, 16, 44, 10, "png", true, file.getPath() + ".png");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

裁剪完成後的對比如下:
這裡寫圖片描述 這裡寫圖片描述

2.圖片分割為單個數字

由於圖片非常規整,每個數字的寬度也是一致的,所以我們可以繼續使用上面的裁剪函式進行圖片裁剪,即可將包含四個數字的圖片裁剪為單個的數字的圖片,程式碼如下:

int oneW = (bufferedImage.getWidth() - 15) / 4;
for (int i = 0; i < 4; i++) {
    cropImg(file.getPath(), "png", i * (oneW + 5), 0, oneW, 10, "png", file.getPath() + i + ".png");
}

經過上面的步驟,我們就獲得0-9這10個數字的單個圖片,如下圖:
這裡寫圖片描述

3.驗證碼對比識別

經過檢視,由於圖片非常規整,我們每次裁剪出來的數字圖片都是一樣的,也就是同一個數字的兩張圖片的每一個位元組都是相同的,並且經過裁剪後的圖片其實非常小,所以我們的識別其實就是對比,只需要將待識別的圖片裁剪為4張小圖,然後與我們提前準備好的單張數字圖片對比即可。程式碼如下:

/**
 * 比較兩個圖片位元組陣列是否相同
 * @param img1Byte
 * @param img2Byte
 * @return
 */
public static boolean compareImg(byte[] img1Byte, byte[] img2Byte) {
    if (img1Byte == null || img2Byte == null){
        return false;
    }

    if (img1Byte.length == 0 || img2Byte.length == 0){
        return false;
    }

    if (img1Byte.length != img2Byte.length){
        return false;
    }

    for (int i = 0; i < img1Byte.length; i++) {
        if (img1Byte[i] != img2Byte[i]){
            return false;
        }
    }
    return true;
}

/**
 * 通過比較位元組來獲得是該圖片是數字幾
 * @param imgData 原始的0-9這10張圖片的位元組資訊
 * @param srcBytes 待識別的圖片位元組
 * @return
 */
public static int chooseImg(List<byte[]> imgData, byte[] srcBytes){
    if (imgData == null || imgData.size() == 0
            || srcBytes == null || srcBytes.length == 0){
        return -1;
    }
    for (int i = 0; i < imgData.size(); i++) {
        if (compareImg(imgData.get(i), srcBytes)){
            return i;
        }
    }
    return -1;
}

上面的函式就是對比位元組的函式。當然在對比之前,我們還需要將我們的10張數字圖片載入到記憶體中,便於後續對比,程式碼如下:

/**
 * 圖片檔案轉位元組陣列
 * @param imgFile
 * @return
 */
public static byte[] imgToBytes(File imgFile){
    if (imgFile == null){
        return null;
    }
    try {
        FileInputStream inputStream = new FileInputStream(imgFile);
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream(200);
        byte[] bytes = new byte[200];
        int n;
        while ((n = inputStream.read(bytes)) != -1){
            outputStream.write(bytes, 0, n);
        }
        inputStream.close();
        outputStream.close();
        return outputStream.toByteArray();
    } catch (IOException e) {
        e.printStackTrace();
    }
    return null;
}

/**
 * 載入圖片資料[裝載需要進行比較的圖片資料]
 * @param imgPath
 * @return
 */
public static List<byte[]> loadImgData(String imgPath){
    if (imgPath == null || "".equals(imgPath)){
        return null;
    }
    File imgDir = new File(imgPath);
    List<byte[]> imgData = new ArrayList<>(10);
    //獲得0-9的圖片資料
    File[] imgs = imgDir.listFiles(new FileFilter() {
        @Override
        public boolean accept(File pathname) {
            if (pathname.getName().endsWith(".png")){
                return true;
            }
            return false;
        }
    });
    if (imgs == null){
        return null;
    }

    for (File file : imgs){
        imgData.add(imgToBytes(file));
    }
    return imgData;
}

這裡的載入我們是按順序載入的,也就是下標為0的位置存放的就是數字0這個圖片的位元組陣列,以此來推。

下面就是我們載入圖片,並進行對比識別的程式碼:

public static void main(String[] args) {
    long start = System.currentTimeMillis();
    //載入0-9這10個數字的單張圖片
    List<byte[]> imgData = ImgUtil.loadImgData("./one/");

    File[] imgsPath = new File("./sourceimg/").listFiles(new FileFilter() {
        @Override
        public boolean accept(File pathname) {
            if (pathname.getName().endsWith(".gif")){
                return true;
            }
            return false;
        }
    });
    String distImgPath = "./distimg/dist.png";
    String srcImgPath = "";
    if (imgsPath == null){
        return;
    }
    for (File f : imgsPath) {
        srcImgPath = f.getPath();
        try {
            //裁剪圖片並存儲在本地
            //先做圖片一次裁剪,裁剪掉邊緣空白
            ImgUtil.cropImg(srcImgPath, "gif", 6, 16, 44, 10, "png", true, distImgPath);
            BufferedImage bufferedImage = ImageIO.read(new File(distImgPath));

            int oneW = (bufferedImage.getWidth() - 15) / 4;
            StringBuilder stringBuilder = new StringBuilder();
            //迴圈裁剪4個數字
            for (int i = 0; i < 4; i++) {
                //裁剪出每個數字
                byte[] bytes = ImgUtil.cropImg("./distimg/dist.png", "png", i * (oneW + 5), 0, oneW, 10, "png", false, null);
                //對比裁剪出的數字
                stringBuilder.append(ImgUtil.chooseImg(imgData, bytes));
            }
            //打印出識別結結果
            System.out.println(stringBuilder.toString());
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    System.out.println("用時" + System.currentTimeMillis() - start + "毫秒");
}

經過測試,7張圖片的識別時間為2200毫秒左右,識別準確率為100%。

寫在最後

上面介紹的這種方法只能用於特定的場合,由於不需要做影象處理,所以處理效率肯定是較高的,並且沒有使用第三方庫,所以專案依賴少。後續會陸續介紹稍微複雜驗證碼的識別處理方式。