前言
驗證碼是目前互聯網上非常常見也是非常重要的一個事物,充當着很多系統的 防火牆 功能,但是隨時OCR技術的發展,驗證碼暴露出來的安全問題也越來越嚴峻。本文介紹了一套字符驗證碼識別的完整流程,對於驗證碼安全和OCR識別技術都有一定的借鑒意義。
GitHub上有大神總結的非常好的源碼及博客,鏈接如下
GitHub:
博客:http://blog.topspeedsnail.com/archives/10858
本文需要的依賴:
- python3.5
- PIL
- libsvm
破解驗證碼的基本流程:
- 准備原始圖片素材
- 圖片預處理
- 圖片字符切割
- 圖片尺寸歸一化
- 圖片字符標記
- 字符圖片特征提取
- 生成特征和標記對應的訓練數據集
- 訓練特征標記數據生成識別模型
- 使用識別模型預測新的未知圖片集
- 達到根據“圖片”就能返回識別正確的字符集的目標
正文
第一步:准備素材
驗證碼圖片如下:

真正的破解程序需要准備大量的素材,然后在進行大量的訓練后才可達到相對高的識別度
def downloads_pic(**kwargs): pic_name = kwargs.get('pic_name', None) url = 'http://xxxx/rand_code_captcha/' res = requests.get(url, stream=True) with open(pic_path + pic_name+'.bmp', 'wb') as f: for chunk in res.iter_content(chunk_size=1024): if chunk: # filter out keep-alive new chunks f.write(chunk) f.flush() f.close() #循環執行N次,即可保存N張驗證素材了。
第二步:圖片預處理
1. 二值化圖片
#將RGB彩圖轉為灰度圖,再按照設定閾值轉化為二值圖 def get_bin_table(threshold=140): """ 獲取灰度轉二值的映射table """ table = [] for i in range(256): if i < threshold: table.append(0) else: table.append(1) return table image = Image.open(img_path) imgry = image.convert('L') # 轉化為灰度圖 table = get_bin_table() out = imgry.point(table, '1')
由PIL轉化后變成二值圖片:0表示黑色,1表示白色。二值化后帶噪點的 6937 的像素點輸出后如下圖:
1111000111111000111111100001111100000011 1110111011110111011111011110111100110111 1001110011110111101011011010101101110111 1101111111110110101111110101111111101111 1101000111110111001111110011111111101111 1100111011111000001111111001011111011111 1101110001111111101011010110111111011111 1101111011111111101111011110111111011111 1101111011110111001111011110111111011100 1110000111111000011101100001110111011111 #如果你是近視眼,然后離屏幕遠一點,可以隱約看到 6937 的骨架了。
2. 去除噪點
在轉化為二值圖片后,就需要清除噪點。本文選擇的素材比較簡單,大部分噪點也是最簡單的那種 孤立點,所以可以通過檢測這些孤立點就能移除大量的噪點。
關於如何去除更復雜的噪點甚至干擾線和色塊,有比較成熟的算法: 洪水填充法 Flood Fill ,后面有興趣的時間可以繼續研究一下。
本文為了問題簡單化,干脆就用一種簡單的自己想的 簡單辦法 來解決掉這個問題:
-
- 對某個 黑點 周邊的九宮格里面的黑色點計數
- 如果黑色點少於2個則證明此點為孤立點,然后得到所有的孤立點
- 對所有孤立點一次批量移除。
下面將詳細介紹關於具體的算法原理。
將所有的像素點如下圖分成三大類
-
- 頂點A
- 非頂點的邊界點B
- 內部點C
種類點示意圖如下:
-
- A類點計算周邊相鄰的3個點(如上圖紅框所示)
- B類點計算周邊相鄰的5個點(如上圖紅框所示)
- C類點計算周邊相鄰的8個點(如上圖紅框所示)
當然,由於基准點在計算區域的方向不同,A類點和B類點還會有細分:
-
- A類點繼續細分為:左上,左下,右上,右下
- B類點繼續細分為:上,下,左,右
- C類點不用細分
然后這些細分點將成為后續坐標獲取的准則。
主要算法的python實現如下:
def sum_9_region(img, x, y): """ 9鄰域框,以當前點為中心的田字框,黑點個數 :param x: :param y: :return: """ # todo 判斷圖片的長寬度下限 cur_pixel = img.getpixel((x, y)) # 當前像素點的值 width = img.width height = img.height if cur_pixel == 1: # 如果當前點為白色區域,則不統計鄰域值 return 0 if y == 0: # 第一行 if x == 0: # 左上頂點,4鄰域 # 中心點旁邊3個點 sum = cur_pixel \ + img.getpixel((x, y + 1)) \ + img.getpixel((x + 1, y)) \ + img.getpixel((x + 1, y + 1)) return 4 - sum elif x == width - 1: # 右上頂點 sum = cur_pixel \ + img.getpixel((x, y + 1)) \ + img.getpixel((x - 1, y)) \ + img.getpixel((x - 1, y + 1)) return 4 - sum else: # 最上非頂點,6鄰域 sum = img.getpixel((x - 1, y)) \ + img.getpixel((x - 1, y + 1)) \ + cur_pixel \ + img.getpixel((x, y + 1)) \ + img.getpixel((x + 1, y)) \ + img.getpixel((x + 1, y + 1)) return 6 - sum elif y == height - 1: # 最下面一行 if x == 0: # 左下頂點 # 中心點旁邊3個點 sum = cur_pixel \ + img.getpixel((x + 1, y)) \ + img.getpixel((x + 1, y - 1)) \ + img.getpixel((x, y - 1)) return 4 - sum elif x == width - 1: # 右下頂點 sum = cur_pixel \ + img.getpixel((x, y - 1)) \ + img.getpixel((x - 1, y)) \ + img.getpixel((x - 1, y - 1)) return 4 - sum else: # 最下非頂點,6鄰域 sum = cur_pixel \ + img.getpixel((x - 1, y)) \ + img.getpixel((x + 1, y)) \ + img.getpixel((x, y - 1)) \ + img.getpixel((x - 1, y - 1)) \ + img.getpixel((x + 1, y - 1)) return 6 - sum else: # y不在邊界 if x == 0: # 左邊非頂點 sum = img.getpixel((x, y - 1)) \ + cur_pixel \ + img.getpixel((x, y + 1)) \ + img.getpixel((x + 1, y - 1)) \ + img.getpixel((x + 1, y)) \ + img.getpixel((x + 1, y + 1)) return 6 - sum elif x == width - 1: # 右邊非頂點 # print('%s,%s' % (x, y)) sum = img.getpixel((x, y - 1)) \ + cur_pixel \ + img.getpixel((x, y + 1)) \ + img.getpixel((x - 1, y - 1)) \ + img.getpixel((x - 1, y)) \ + img.getpixel((x - 1, y + 1)) return 6 - sum else: # 具備9領域條件的 sum = img.getpixel((x - 1, y - 1)) \ + img.getpixel((x - 1, y)) \ + img.getpixel((x - 1, y + 1)) \ + img.getpixel((x, y - 1)) \ + cur_pixel \ + img.getpixel((x, y + 1)) \ + img.getpixel((x + 1, y - 1)) \ + img.getpixel((x + 1, y)) \ + img.getpixel((x + 1, y + 1)) return 9 - sum
Tips:這個地方是相當考驗人的細心和耐心程度了,這個地方的工作量還是蠻大的,花了半個晚上的時間才完成的。
計算好每個像素點的周邊像素黑點(注意:PIL轉化的圖片黑點的值為0)個數后,只需要篩選出個數為 1或者2 的點的坐標即為 孤立點 。這個判斷方法可能不太准確,但是基本上能夠滿足本文的需求了。
經過預處理后的圖片如下所示:

對比文章開頭的原始圖片,那些 孤立點 都被移除掉,相對比較 干凈 的驗證碼圖片已經生成。
第三步:圖片字符切割——分割算法
由於字符型 驗證碼圖片 本質就可以看着是由一系列的 單個字符圖片 拼接而成,為了簡化研究對象,我們也可以將這些圖片分解到 原子級 ,即: 只包含單個字符的圖片。
於是,我們的研究對象由 “N種字串的組合對象” 變成 “10種阿拉伯數字” 的處理,極大的簡化和減少了處理對象。
現實生活中的字符驗證碼的產生千奇百怪,有各種扭曲和變形。關於字符分割的算法,也沒有很通用的方式。這個算法也是需要開發人員仔細研究所要識別的字符圖片的特點來制定的。
當然,本文所選的研究對象盡量簡化了這個步驟的難度,下文將慢慢進行介紹。
使用圖像編輯軟件(PhoneShop或者其它)打開驗證碼圖片,放大到像素級別,觀察其它一些參數特點:

可以得到如下參數:
- 整個圖片尺寸是 40*10
- 單個字符尺寸是 6*10
- 左右字符和左右邊緣相距2個像素
- 字符上下緊挨邊緣(即相距0個像素)
這樣就可以很容易就定位到每個字符在整個圖片中占據的像素區域,然后就可以進行分割了,具體代碼如下:
def get_crop_imgs(img): """ 按照圖片的特點,進行切割,這個要根據具體的驗證碼來進行工作. # 見原理圖 :param img: :return: """ child_img_list = [] for i in range(4): x = 2 + i * (6 + 4) # 見原理圖 y = 0 child_img = img.crop((x, y, x + 6, y + 10)) child_img_list.append(child_img) return child_img_list
然后就能得到被切割的 原子級 的圖片元素了:

基於本部分的內容的討論,相信大家已經了解到了,如果驗證碼的干擾(扭曲,噪點,干擾色塊,干擾線……)做得不夠強的話,可以得到如下兩個結論:
-
4位字符和40000位字符的驗證碼區別不大
-
- 純數字 和 數字及字母組合 的驗證碼區別不大
-
-
純數字。分類數為10
-
- 純字母
-
- 不區分大小寫。分類數為26
- 區分大小寫。分類數為52
-
數字和區分大小寫的字母組合。分類數為62
-
在沒有形成 指數級或者幾何級 的難度增加,而只是 線性有限級 增加計算量時,意義不太大。
第四步:尺寸歸一
本文所選擇的研究對象本身尺寸就是統一狀態:6*10的規格,所以此部分不需要額外處理。但是一些進行了扭曲和縮放的驗證碼,則此部分也會是一個圖像處理的難點。
第五步:模型訓練
在前面的環節,已經完成了對單個圖片的處理和分割了。后面就開始進行 識別模型 的訓練了。
整個訓練過程如下:
- 大量完成預處理並切割到原子級的圖片素材准備
- 對素材圖片進行人為分類,即:打標簽
- 定義單張圖片的識別特征
- 使用SVM訓練模型對打了標簽的特征文件進行訓練,得到模型文件
第六步:素材准備
本文在訓練階段重新下載了同一模式的4數字的驗證圖片總計:3000張。然后對這3000張圖片進行處理和切割,得到12000張原子級圖片。
在這12000張圖片中刪除一些會影響訓練和識別的強干擾的干擾素材,切割后的效果圖如下:

第七步:素材標記
由於本文使用的這種識別方法中,機器在最開始是不具備任何 數字的觀念的。所以需要人為的對素材進行標識,告訴 機器什么樣的圖片的內容是 1……。
這個過程叫做 “標記”。
具體打標簽的方法是:
-
為0~9每個數字建立一個目錄,目錄名稱為相應數字(相當於標簽)
-
人為判定 圖片內容,並將圖片拖到指定數字目錄中
-
- 每個目錄中存放100張左右的素材
- 一般情況下,標記的素材越多,那么訓練出的模型的分辨能力和預測能力越強。例如本文中,標記素材為十多張的時候,對新的測試圖片識別率基本為零,但是到達100張時,則可以達到近乎100%的識別率
第八步:特征選擇
對於切割后的單個字符圖片,像素級放大圖如下:

從宏觀上看,不同的數字圖片的本質就是將黑色按照一定規則填充在相應的像素點上,所以這些特征都是最后圍繞像素點進行。
字符圖片 寬6個像素,高10個像素 ,理論上可以最簡單粗暴地可以定義出60個特征:60個像素點上面的像素值。但是顯然這樣高維度必然會造成過大的計算量,可以適當的降維。
- 每行上黑色像素的個數,可以得到10個特征
- 每列上黑色像素的個數,可以得到6個特征
最后得到16維的一組特征,實現代碼如下:
def get_feature(img): """ 獲取指定圖片的特征值, 1. 按照每排的像素點,高度為10,則有10個維度,然后為6列,總共16個維度 :param img_path: :return:一個維度為10(高度)的列表 """ width, height = img.size pixel_cnt_list = [] height = 10 for y in range(height): pix_cnt_x = 0 for x in range(width): if img.getpixel((x, y)) == 0: # 黑色點 pix_cnt_x += 1 pixel_cnt_list.append(pix_cnt_x) for x in range(width): pix_cnt_y = 0 for y in range(height): if img.getpixel((x, y)) == 0: # 黑色點 pix_cnt_y += 1 pixel_cnt_list.append(pix_cnt_y) return pixel_cnt_list
然后就將圖片素材特征化,按照 libSVM 指定的格式生成一組帶特征值和標記值的向量文件。內容示例如下:
說明如下:
- 第一列是標簽列,即此圖片人為標記值,后續還有其它數值1~9的標記
- 后面是16組特征值,冒號前面是索引號,后面是值
- 如果有1000張訓練圖片,那么會產生1000行的記錄
對此文件格式有興趣的同學,可以到 libSVM 官網搜索更多的資料。
第九步:模型訓練
到這個階段后,由於本文直接使用的是開源的 libSVM 方案,屬於應用了,所以此處內容就比較簡單的。只需要輸入特征文件,然后輸出模型文件即可。
可以搜索到很多相關中文資料 。
主要代碼如下:
def train_svm_model(): """ 訓練並生成model文件 :return: """ y, x = svm_read_problem(svm_root + '/train_pix_feature_xy.txt') model = svm_train(y, x) svm_save_model(model_path, model)
備注:生成的模型文件名稱為 svm_model_file
第十步:模型測試
訓練生成模型后,需要使用 訓練集 之外的全新的標記后的圖片作為 測試集 來對模型進行測試。
本文中的測試實驗如下:
- 使用一組全部標記為8的21張圖片來進行模型測試
- 測試圖片生成帶標記的特征文件名稱為 last_test_pix_xy_new.txt
在早期訓練集樣本只有每字符十幾張圖的時候,雖然對訓練集樣本有很好的區分度,但是對於新樣本測試集基本沒區分能力,識別基本是錯誤的。逐漸增加標記為8的訓練集的樣本后情況有了比較好的改觀:
- 到60張左右的時候,正確率大概80%
- 到185張的時候,正確率基本上達到100%
以數字8的這種模型強化方法,繼續強化對數字0~9中的其它數字的模型訓練,最后可以達到對所有的數字的圖片的識別率達到近乎 100%。在本文示例中基本上每個數字的訓練集在100張左右時,就可以達到100%的識別率了。
模型測試代碼如下:
def svm_model_test(): """ 使用測試集測試模型 :return: """ yt, xt = svm_read_problem(svm_root + '/last_test_pix_xy_new.txt') model = svm_load_model(model_path) p_label, p_acc, p_val = svm_predict(yt, xt, model)#p_label即為識別的結果 cnt = 0 for item in p_label: print('%d' % item, end=',') cnt += 1 if cnt % 8 == 0: print('')
至此,驗證的識別工作算是完滿結束。

