寫在前面
8月底的時候,@阿里巴巴 推出了一款名爲“拯救斯諾克”的闖關遊戲,作爲前端校園招聘的熱身,做的相當不錯,讓我非常喜歡。後來又傳出了一條消息,阿里推出了A-star(阿里星)計劃,入職阿里的技術培訓生,將接受CTO等技術大牛的封閉培訓,並被安排到最有挑戰的項目中,由技術帶頭人擔任主管。於是那幾天關注了一下阿里巴巴的消息,結果看到這麼一條微博(http://e.weibo.com/1897953162/A79Lpcvhi):
此刻,@阿里足球隊 可愛的隊員們已經出征北上。臨走前,後防線的隊員們留下一段親切的問候,送給對手,看@新浪足球隊 的前鋒們如何破解。@袁甲 @藍耀棟 #阿里新浪足球世紀大戰#
目測是一段Base64加密過的信息,但無奈的是這段信息是寫在圖片裏的,我想看到解密後的內容難道還一個字一個字地打出來?這麼懶這麼怕麻煩的我肯定不會這麼做啦→_→想到之前有看到過一篇關於HTML5實現驗證碼識別的文章,於是頓時覺得也應該動手嘗試一下,這纔是極客的風範嘛!
Demo與截圖
先來一個大家最喜歡的Demo地址(識別過程需要一定時間,請耐心等待,識別結果請按F12打開Console控制檯查看):
http://www.clanfei.com/demos/recognition/
再來張效果圖:
思路
實現一個算法,思路是最重要的,而實現不過是把思想轉化爲能夠運行的代碼。
簡單地說,要進行文本識別,自然是拿圖片的數據與文字的圖形數據進行對比,找到與圖片數據匹配程度最高的字符。
首先,先確定圖片中文本所用的字體、字號、行距等信息,打開PhotoShop,確定了字體爲微軟雅黑,16像素,行距爲24,Base64文字的開始座標爲(8, 161)。
然後,確定要進行匹配的字庫,Base64編碼中可能出現的字符爲26個字母大小寫、10個數字、加號、斜槓,但目測在圖片中沒有斜槓出現,因此字庫應該爲:
0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ+
接着,是確定如何判斷字符是否匹配,由於只需要對字型進行匹配,因此顏色值對算法並無用處,因此將其灰度化(詳見百度百科),並使用01數組表示,1代表該像素點落在此字符圖形上,0反之,而如何確定該某個灰度值在數組中應該表示爲0還是1,這個轉換公式更是算法中的關鍵。
最後,將字型的灰度化數據與圖片中文字部分的灰度化數據進行對比,將誤差最小的字型作爲匹配到的字符,然後進行下一個字符的匹配,直到圖片中所有字符匹配完畢爲止。
遞歸實現
詳細的思路於代碼註釋中,個人覺得這樣結合上下文更爲容易理解(注:代碼應運行於服務器環境,否則會出現跨域錯誤,代碼行數雖多,但註釋就佔了大半,有興趣可以耐心看完,圖片資源於上方“寫在前面”)。
- <!doctype html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8">
- <title>文字識別</title>
- </head>
- <body>
- <canvas id="canvas" width="880" height="1500"></canvas>
- <script type="text/javascript">
- var image = new Image();
- image.onload = recognition;
- image.src = 'image.jpg';
- function recognition(){
- // 開始時間,用於計算耗時
- var beginTime = new Date().getTime();
- // 獲取畫布
- var canvas = document.getElementById('canvas');
- // 字符庫
- var letters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ+';
- // 字型數據
- var letterData = {};
- // 獲取context
- var context = canvas.getContext('2d');
- // 設置字體、字號
- context.font = '16px 微軟雅黑';
- // 設置文字繪製基線爲文字頂端
- context.textBaseline = 'top';
- // 一個循環獲取字符庫對應的字型數據
- for(var i = 0; i < letters.length; ++i){
- var letter = letters[i];
- // 獲取字符繪製寬度
- var width = context.measureText(letter).width;
- // 繪製白色背景,與圖片背景對應
- context.fillStyle = '#fff';
- context.fillRect(0, 0, width, 22);
- // 繪製文字,以獲取字型數據
- context.fillStyle = '#000';
- context.fillText(letter, 0, 0);
- // 緩存字型灰度化0-1數據
- letterData[letter] = {
- width : width,
- data : getBinary(context.getImageData(0, 0, width, 22).data)
- }
- // 清空該區域以獲取下個字符字型數據
- context.clearRect(0, 0, width, 22);
- }
- // console.log(letterData);
- // 繪製圖片
- context.drawImage(this, 0, 0);
- // 要識別的文字開始座標
- var x = beginX = 8;
- var y = beginY = 161;
- // 行高
- var lineHeight = 24;
- // 遞歸次數
- var count = 0;
- // 結果文本
- var result = '';
- // 遞歸開始
- findLetter(beginX, beginY, '');
- // 遞歸函數
- function findLetter(x, y, str){
- // 找到結果文本,則遞歸結束
- if(result){
- return;
- }
- // 遞歸次數自增1
- ++ count;
- // console.log(str);
- // 隊列,用於儲存可能匹配的字符
- var queue = [];
- // 循環匹配字符庫字型數據
- for(var letter in letterData){
- // 獲取當前字符寬度
- var width = letterData[letter].width;
- // 獲取該矩形區域下的灰度化0-1數據
- var data = getBinary(context.getImageData(x, y, width, 22).data);
- // 當前字符灰度化數據與當前矩形區域下灰度化數據的偏差量
- var deviation = 0;
- // 一個臨時變量以確定是否到了行末
- var isEmpty = true;
- // 如果當前矩形區域已經超出圖片寬度,則進行下一個字符匹配
- if(x + width > 440){
- continue;
- }
- // 計算偏差
- for(var i = 0, l = data.length; i < l; ++i){
- // 如果發現存在的有效像素點,則確定未到行末
- if(isEmpty && data[i]){
- isEmpty = false;
- }
- // 不匹配的像素點,偏差量自增1
- if(data[i] != letterData[letter].data[i]){
- ++deviation;
- }
- }
- // 由於調試時是在獵豹瀏覽器下進行的,而不同瀏覽器下的繪圖API表現略有不同
- // 考慮到用Chrome的讀者應該也不少,故簡單地針對Chrome對偏差進行一點手動微調
- // (好吧,我承認我是懶得重新調整getBinary方法的灰度化、0-1化公式=_=||)
- // 下面這段if分支在獵豹瀏覽器下可以刪除
- if(letter == 'F' || letter == 'E'){
- deviation -= 6;
- }
- // 如果匹配完所有17行數據,則遞歸結束
- if(y > beginY + lineHeight * 17){
- result = str;
- break;
- }
- // 如果已經到了行末,重置匹配座標
- if(isEmpty){
- x = beginX;
- y += lineHeight;
- str += '\n';
- }
- // 如果偏差量與寬度的比值小於3,則納入匹配隊列中
- // 這裏也是算法中的關鍵點,怎樣的偏差量可以納入匹配隊列中
- // 剛開始是直接用絕對偏差量判斷,當偏差量小於某個值的時候則匹配成功,但調試過程中發現不妥之處
- // 字符字型較小的絕對偏差量自然也小,這樣l,i等較小的字型特別容易匹配成功
- // 因此使用偏差量與字型寬度的比值作爲判斷依據較爲合理
- // 而這個判斷值3的確定也是難點之一,大了遞歸的複雜度會大爲增長,小了很可能將正確的字符漏掉
- if(deviation / width < 3){
- queue.push({
- letter : letter,
- width : width,
- deviation : deviation
- });
- }
- }
- // 如果匹配隊列不爲空
- if(queue.length){
- // 對隊列進行排序,同樣是根據偏差量與字符寬度的比例
- queue.sort(compare);
- // console.log(queue);
- // 從隊頭開始進行下一個字符的匹配
- for(var i = 0; i < queue.length && ! result; ++i){
- var item = queue[i];
- // 下一步遞歸
- findLetter(x + item.width, y, str + item.letter);
- }
- }else{
- return false;
- }
- }
- // 遞歸結束
- // 兩個匹配到的字符的比較方法,用於排序
- function compare(letter1, letter2){
- return letter1.deviation / letter1.width - letter2.deviation / letter2.width;
- }
- // 圖像數據的灰度化及0-1化
- function getBinary(data){
- var binaryData = [];
- for(var i = 0, l = data.length; i < l; i += 4){
- // 嘗試過三種方式
- // 一種是正常的灰度化公式,無論係數如何調整都無法與繪製的文字字型數據很好地匹配
- // binaryData[i / 4] = (data[i] * 0.3 + data[i + 1] * 0.59 + data[i + 2] * 0.11) < 90;
- // 一種是自己是通過自己手動調整係數,結果雖然接近但總是不盡人意
- // binaryData[i / 4] = data[i] < 250 && data[i + 1] < 203 && data[i + 2] < 203;
- // 最後使用了平均值,結果比較理想
- binaryData[i / 4] = (data[i] + data[i + 1] + data[i + 2]) / 3 < 200;
- }
- return binaryData;
- }
- console.log(result);
- // 輸出耗時
- console.log(count, (new Date().getTime() - beginTime) / 1000 + ' s');
- // 將文字繪製到圖片對應位置上,以方便查看提取是否正確
- context.drawImage(this, this.width, 0);
- var textArray = result.split('\n');
- for(var i = 0; i < textArray.length; ++i){
- context.fillText(textArray[i], this.width + beginX, beginY + lineHeight * i);
- }
- }
- </script>
- </body>
- </html>
運行環境
Win7 64位,i3-3220 CPU 3.30 GHz,8G內存
運行結果
- yv66vgAAADIAHQoABgAPCQAQABEIABIKABMAFAcAF
- QcAFgEABjxpbml0PgEAAygpVgEABENvZGUB
- AA9MaW5lTnVtYmVyVGFibGUBAARtYWluAQAWKFtMa
- mF2YS9sYW5nL1N0cmluZzspVgEAClNvdXJj
- ZUZpbGUBAAlNYWluLmphdmEMAAcACAcAFwwAGAA
- ZAQBv5paw5rWq6Laz55CD6Zif5a6e5Yqb6LaF
- 576k77yM6Zi15a656LGq5Y2O44CC5LmF5Luw5aSn5ZC
- N77yM5ZGo5pel5LiA5oiY77yM6L+Y5pyb
- 5LiN6YGX5L2Z5Yqb77yM5LiN5ZCd6LWQ5pWZ44CCBw
- AaDAAbABwBAARNYWluAQAQamF2YS9sYW5n
- L09iamVjdAEAEGphdmEvbGFuZy9TeXN0ZW0BAANvdX
- QBABVMamF2YS9pby9QcmludFN0cmVhbTsB
- ABNqYXZhL2lvL1ByaW50U3RyZWFtAQAHcHJpbnRsbgE
- AFShMamF2YS9sYW5nL1N0cmluZzspVgAh
- AAUABgAAAAAAAgABAAcACAABAAkAAAAdAAEAAQA
- AAAUqtwABsQAAAAEACgAAAAYAAQAAAAEACQAL
- AAwAAQAJAAAAJQACAAEAAAAJsgACEgO2AASxAAAA
- AQAKAAAACgACAAAAAwAIAAQAAQANAAAAAgAO
- 715 1.984 s(獵豹)
- 772 15.52 s(Chrome)
(遞歸次數谷歌只比獵豹多幾十,耗時卻對了十幾秒,看來獵豹真的比Chrome快?)
非遞歸實現
其實非遞歸實現只是遞歸實現前做的一點小嚐試,只在獵豹下調試完成,因爲不捨得刪,所以順便貼出來了,使用Chrome的各位就不要跑了(我真的不是在給獵豹做廣告= =||)。
- <!doctype html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8">
- <title>文字識別</title>
- </head>
- <body>
- <canvas id="canvas" width="880" height="1500"></canvas>
- <script type="text/javascript">
- var image = new Image();
- image.onload = recognition;
- image.src = 'image.jpg';
- function recognition(){
- // 開始時間,用於計算耗時
- var beginTime = new Date().getTime();
- // 獲取畫布
- var canvas = document.getElementById('canvas');
- // 字符庫
- var letters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ+';
- // 字型數據
- var letterData = {};
- // 獲取context
- var context = canvas.getContext('2d');
- // 設置字體、字號
- context.font = '16px 微軟雅黑';
- // 設置文字繪製基線爲文字頂端
- context.textBaseline = 'top';
- // 一個循環獲取字符庫對應的字型數據
- for(var i = 0; i < letters.length; ++i){
- var letter = letters[i];
- // 獲取字符繪製寬度
- var width = context.measureText(letter).width;
- // 繪製白色背景,與圖片背景對應
- context.fillStyle = '#fff';
- context.fillRect(0, 0, width, 22);
- // 繪製文字,以獲取字型數據
- context.fillStyle = '#000';
- context.fillText(letter, 0, 0);
- // 緩存字型灰度化0-1數據
- letterData[letter] = {
- width : width,
- data : getBinary(context.getImageData(0, 0, width, 22).data)
- }
- // 清空該區域以獲取下個字符字型數據
- context.clearRect(0, 0, width, 22);
- }
- // console.log(letterData);
- // 繪製圖片
- context.drawImage(this, 0, 0);
- // 要識別的文字開始座標
- var x = beginX = 8;
- var y = beginY = 161;
- // 行高
- var lineHeight = 24;
- // 結果文本
- var result = '';
- // 非遞歸開始
- var count = 0;
- while(y <= 569 && ++count < 1000){
- // 當前最匹配的字符
- var trueLetter = {letter: null, width : null, deviation: 100};
- // 循環匹配字符
- for(var letter in letterData){
- // 獲取當前字符寬度
- var width = letterData[letter].width;
- // 獲取該矩形區域下的灰度化0-1數據
- var data = getBinary(context.getImageData(x, y, width, 22).data);
- // 當前字符灰度化數據與當前矩形區域下灰度化數據的偏差量
- var deviation = 0;
- // 一個臨時變量以確定是否到了行末
- var isEmpty = true;
- // 如果當前矩形區域已經超出圖片寬度,則進行下一個字符匹配
- if(x + width > this.width){
- continue;
- }
- // 計算偏差
- for(var i = 0, l = data.length; i < l; ++i){
- // 如果發現存在的有效像素點,則確定未到行末
- if(isEmpty && data[i]){
- isEmpty = false;
- }
- // 不匹配的像素點,偏差量自增1
- if(data[i] != letterData[letter].data[i]){
- ++deviation;
- }
- }
- // 非遞歸無法遍歷所有情況,因此針對某些字符進行一些微調(這裏只針對獵豹,Chrome的沒做)
- // 因爲其實非遞歸實現只是在遞歸實現前做的一點小嚐試,因爲不捨得刪,就順便貼出來了
- if(letter == 'M'){
- deviation -= 6;
- }
- // 如果偏差量與寬度的比值小於3,則視爲匹配成功
- if(deviation / width < 3){
- // 將偏差量與寬度比值最小的作爲當前最匹配的字符
- if(deviation / width < trueLetter.deviation / trueLetter.width){
- trueLetter.letter = letter;
- trueLetter.width = width;
- trueLetter.deviation = deviation;
- }
- }
- }
- // 如果已經到了行末,重置匹配座標,進行下一輪匹配
- if(isEmpty){
- x = beginX;
- y += lineHeight;
- result += '\n';
- continue;
- }
- // 如果匹配到的字符不爲空,則加入結果字符串,否則輸出匹配結果
- if(trueLetter.letter){
- result += trueLetter.letter;
- // console.log(x, y, trueLetter.letter);
- }else{
- console.log(x, y, result.length);
- break;
- }
- // 調整座標至下一個字符匹配位置
- x += trueLetter.width;
- }
- // 非遞歸結束
- // 圖像數據的灰度化及0-1化
- function getBinary(data){
- var binaryData = [];
- for(var i = 0, l = data.length; i < l; i += 4){
- // 嘗試過三種方式
- // 一種是正常的灰度化公式,無論係數如何調整都無法與繪製的文字字型數據很好地匹配
- // binaryData[i / 4] = (data[i] * 0.3 + data[i + 1] * 0.59 + data[i + 2] * 0.11) < 90;
- // 一種是自己是通過自己手動調整係數,結果雖然接近但總是不盡人意
- // binaryData[i / 4] = data[i] < 250 && data[i + 1] < 203 && data[i + 2] < 203;
- // 最後使用了平均值,結果比較理想
- binaryData[i / 4] = (data[i] + data[i + 1] + data[i + 2]) / 3 < 200;
- }
- return binaryData;
- }
- console.log(result);
- // 輸出耗時
- console.log(count, (new Date().getTime() - beginTime) / 1000 + ' s');
- // 將文字繪製到圖片對應位置上,以方便查看提取是否正確
- context.drawImage(this, this.width, 0);
- var textArray = result.split('\n');
- for(var i = 0; i < textArray.length; ++i){
- context.fillText(textArray[i], this.width + beginX, beginY + lineHeight * i);
- }
- }
- </script>
- </body>
- </html>
運行結果
- yv66vgAAADIAHQoABgAPCQAQABEIABIKABMAFAcAF
- QcAFgEABjxpbml0PgEAAygpVgEABENvZGUB
- AA9MaW5lTnVtYmVyVGFibGUBAARtYWluAQAWKFtMa
- mF2YS9sYW5nL1N0cmluZzspVgEAClNvdXJj
- ZUZpbGUBAAlNYWluLmphdmEMAAcACAcAFwwAGAA
- ZAQBv5paw5rWq6Laz55CD6Zif5a6e5Yqb6LaF
- 576k77yM6Zi15a656LGq5Y2O44CC5LmF5Luw5aSn5ZC
- N77yM5ZGo5pel5LiA5oiY77yM6L+Y5pyb
- 5LiN6YGX5L2Z5Yqb77yM5LiN5ZCd6LWQ5pWZ44CCBw
- AaDAAbABwBAARNYWluAQAQamF2YS9sYW5n
- L09iamVjdAEAEGphdmEvbGFuZy9TeXN0ZW0BAANvdX
- QBABVMamF2YS9pby9QcmludFN0cmVhbTsB
- ABNqYXZhL2lvL1ByaW50U3RyZWFtAQAHcHJpbnRsbgE
- AFShMamF2YS9sYW5nL1N0cmluZzspVgAh
- AAUABgAAAAAAAgABAAcACAABAAkAAAAdAAEAAQA
- AAAUqtwABsQAAAAEACgAAAAYAAQAAAAEACQAL
- AAwAAQAJAAAAJQACAAEAAAAJsgACEgO2AASxAAAA
- AQAKAAAACgACAAAAAwAIAAQAAQANAAAAAgAO
- 702 1.931 s(獵豹)
真正的結果
找了個在線的Base64解碼工具將上面的提取結果進行了一下解碼,發現是一個Java編譯後的.class文件,大概內容是:“新浪足球隊實力超羣,陣容豪華。久仰大名,週日一戰,還望不遺餘力,不吝賜教。”
轉自:http://www.clanfei.com/2013/09/1723.html