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