借@阿里巴巴 耍了個帥——HTML5 JavaScript實現圖片文字識別與提取(轉載原因:欣賞Geek精神)

寫在前面

8月底的時候,@阿里巴巴 推出了一款名爲“拯救斯諾克”的闖關遊戲,作爲前端校園招聘的熱身,做的相當不錯,讓我非常喜歡。後來又傳出了一條消息,阿里推出了A-star(阿里星)計劃,入職阿里的技術培訓生,將接受CTO等技術大牛的封閉培訓,並被安排到最有挑戰的項目中,由技術帶頭人擔任主管。於是那幾天關注了一下阿里巴巴的消息,結果看到這麼一條微博(http://e.weibo.com/1897953162/A79Lpcvhi):

此刻,@阿里足球隊 可愛的隊員們已經出征北上。臨走前,後防線的隊員們留下一段親切的問候,送給對手,看@新浪足球隊 的前鋒們如何破解。@袁甲 @藍耀棟 #阿里新浪足球世紀大戰#

阿里足球隊

目測是一段Base64加密過的信息,但無奈的是這段信息是寫在圖片裏的,我想看到解密後的內容難道還一個字一個字地打出來?這麼懶這麼怕麻煩的我肯定不會這麼做啦→_→想到之前有看到過一篇關於HTML5實現驗證碼識別的文章,於是頓時覺得也應該動手嘗試一下,這纔是極客的風範嘛!

Demo與截圖

先來一個大家最喜歡的Demo地址(識別過程需要一定時間,請耐心等待,識別結果請按F12打開Console控制檯查看):

http://www.clanfei.com/demos/recognition/

再來張效果圖:
HTML5 JavaScript實現圖片文字提取

思路

實現一個算法,思路是最重要的,而實現不過是把思想轉化爲能夠運行的代碼。

簡單地說,要進行文本識別,自然是拿圖片的數據與文字的圖形數據進行對比,找到與圖片數據匹配程度最高的字符。

首先,先確定圖片中文本所用的字體、字號、行距等信息,打開PhotoShop,確定了字體爲微軟雅黑,16像素,行距爲24,Base64文字的開始座標爲(8, 161)。

然後,確定要進行匹配的字庫,Base64編碼中可能出現的字符爲26個字母大小寫、10個數字、加號、斜槓,但目測在圖片中沒有斜槓出現,因此字庫應該爲:

0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ+

接着,是確定如何判斷字符是否匹配,由於只需要對字型進行匹配,因此顏色值對算法並無用處,因此將其灰度化(詳見百度百科),並使用01數組表示,1代表該像素點落在此字符圖形上,0反之,而如何確定該某個灰度值在數組中應該表示爲0還是1,這個轉換公式更是算法中的關鍵。

最後,將字型的灰度化數據與圖片中文字部分的灰度化數據進行對比,將誤差最小的字型作爲匹配到的字符,然後進行下一個字符的匹配,直到圖片中所有字符匹配完畢爲止。

遞歸實現

詳細的思路於代碼註釋中,個人覺得這樣結合上下文更爲容易理解(注:代碼應運行於服務器環境,否則會出現跨域錯誤,代碼行數雖多,但註釋就佔了大半,有興趣可以耐心看完,圖片資源於上方“寫在前面”)。

  1. <!doctype html>
  2. <html lang="zh-CN">
  3. <head>
  4.         <meta charset="UTF-8">
  5.         <title>文字識別</title>
  6. </head>
  7. <body>
  8.         <canvas id="canvas" width="880" height="1500"></canvas>
  9.         <script type="text/javascript">
  10.         var image = new Image();
  11.         image.onload = recognition;
  12.         image.src = 'image.jpg';
  13.         function recognition(){
  14.                 // 開始時間,用於計算耗時
  15.                 var beginTime = new Date().getTime();
  16.                 // 獲取畫布
  17.                 var canvas = document.getElementById('canvas');
  18.                 // 字符庫
  19.                 var letters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ+';
  20.                 // 字型數據
  21.                 var letterData = {};
  22.                 // 獲取context
  23.                 var context = canvas.getContext('2d');
  24.                 // 設置字體、字號
  25.                 context.font = '16px 微軟雅黑';
  26.                 // 設置文字繪製基線爲文字頂端
  27.                 context.textBaseline = 'top';
  28.                 // 一個循環獲取字符庫對應的字型數據
  29.                 for(var i = 0; i < letters.length; ++i){
  30.                         var letter = letters[i];
  31.                         // 獲取字符繪製寬度
  32.                         var width = context.measureText(letter).width;
  33.                         // 繪製白色背景,與圖片背景對應
  34.                         context.fillStyle = '#fff';
  35.                         context.fillRect(0, 0, width, 22);
  36.                         // 繪製文字,以獲取字型數據
  37.                         context.fillStyle = '#000';
  38.                         context.fillText(letter, 0, 0);
  39.                         // 緩存字型灰度化0-1數據
  40.                         letterData[letter] = {
  41.                                 width : width,
  42.                                 data : getBinary(context.getImageData(0, 0, width, 22).data)
  43.                         }
  44.                         // 清空該區域以獲取下個字符字型數據
  45.                         context.clearRect(0, 0, width, 22);
  46.                 }
  47.                 // console.log(letterData);
  48.                 
  49.                 // 繪製圖片
  50.                 context.drawImage(this, 0, 0);
  51.                 // 要識別的文字開始座標
  52.                 var x = beginX = 8;
  53.                 var y = beginY = 161;
  54.                 // 行高
  55.                 var lineHeight = 24;
  56.                 // 遞歸次數
  57.                 var count = 0;
  58.                 // 結果文本
  59.                 var result = '';
  60.                 // 遞歸開始
  61.                 findLetter(beginX, beginY, '');
  62.                 // 遞歸函數
  63.                 function findLetter(x, y, str){
  64.                         // 找到結果文本,則遞歸結束
  65.                         if(result){
  66.                                 return;
  67.                         }
  68.                         // 遞歸次數自增1
  69.                         ++ count;
  70.                         // console.log(str);
  71.                         // 隊列,用於儲存可能匹配的字符
  72.                         var queue = [];
  73.                         // 循環匹配字符庫字型數據
  74.                         for(var letter in letterData){
  75.                                 // 獲取當前字符寬度
  76.                                 var width = letterData[letter].width;
  77.                                 // 獲取該矩形區域下的灰度化0-1數據
  78.                                 var data = getBinary(context.getImageData(x, y, width, 22).data);
  79.                                 // 當前字符灰度化數據與當前矩形區域下灰度化數據的偏差量
  80.                                 var deviation = 0;
  81.                                 // 一個臨時變量以確定是否到了行末
  82.                                 var isEmpty = true;
  83.                                 // 如果當前矩形區域已經超出圖片寬度,則進行下一個字符匹配
  84.                                 if(+ width > 440){
  85.                                         continue;
  86.                                 }
  87.                                 // 計算偏差
  88.                                 for(var i = 0, l = data.length; i < l; ++i){
  89.                                         // 如果發現存在的有效像素點,則確定未到行末
  90.                                         if(isEmpty && data[i]){
  91.                                                 isEmpty = false;
  92.                                         }
  93.                                         // 不匹配的像素點,偏差量自增1
  94.                                         if(data[i] != letterData[letter].data[i]){
  95.                                                 ++deviation;
  96.                                         }
  97.                                 }
  98.                                 // 由於調試時是在獵豹瀏覽器下進行的,而不同瀏覽器下的繪圖API表現略有不同
  99.                                 // 考慮到用Chrome的讀者應該也不少,故簡單地針對Chrome對偏差進行一點手動微調
  100.                                 // (好吧,我承認我是懶得重新調整getBinary方法的灰度化、0-1化公式=_=||)
  101.                                 // 下面這段if分支在獵豹瀏覽器下可以刪除
  102.                                 if(letter == 'F' || letter == 'E'){
  103.                                         deviation -= 6;
  104.                                 }
  105.                                 // 如果匹配完所有17行數據,則遞歸結束
  106.                                 if(> beginY + lineHeight * 17){
  107.                                         result = str;
  108.                                         break;
  109.                                 }
  110.                                 // 如果已經到了行末,重置匹配座標
  111.                                 if(isEmpty){
  112.                                         x = beginX;
  113.                                         y += lineHeight;
  114.                                         str += '\n';
  115.                                 }
  116.                                 // 如果偏差量與寬度的比值小於3,則納入匹配隊列中
  117.                                 // 這裏也是算法中的關鍵點,怎樣的偏差量可以納入匹配隊列中
  118.                                 // 剛開始是直接用絕對偏差量判斷,當偏差量小於某個值的時候則匹配成功,但調試過程中發現不妥之處
  119.                                 // 字符字型較小的絕對偏差量自然也小,這樣l,i等較小的字型特別容易匹配成功
  120.                                 // 因此使用偏差量與字型寬度的比值作爲判斷依據較爲合理
  121.                                 // 而這個判斷值3的確定也是難點之一,大了遞歸的複雜度會大爲增長,小了很可能將正確的字符漏掉
  122.                                 if(deviation / width < 3){
  123.                                         queue.push({
  124.                                                 letter : letter,
  125.                                                 width : width,
  126.                                                 deviation : deviation
  127.                                         });
  128.                                 }
  129.                         }
  130.                         // 如果匹配隊列不爲空
  131.                         if(queue.length){
  132.                                 // 對隊列進行排序,同樣是根據偏差量與字符寬度的比例
  133.                                 queue.sort(compare);
  134.                                 // console.log(queue);
  135.                                 // 從隊頭開始進行下一個字符的匹配
  136.                                 for(var i = 0; i < queue.length && ! result; ++i){
  137.                                         var item = queue[i];
  138.                                         // 下一步遞歸
  139.                                         findLetter(+ item.width, y, str + item.letter);
  140.                                 }
  141.                         }else{
  142.                                 return false;
  143.                         }
  144.                 }
  145.                 // 遞歸結束
  146.                 // 兩個匹配到的字符的比較方法,用於排序
  147.                 function compare(letter1, letter2){
  148.                         return letter1.deviation / letter1.width - letter2.deviation / letter2.width;
  149.                 }
  150.                 // 圖像數據的灰度化及0-1化
  151.                 function getBinary(data){
  152.                         var binaryData = [];
  153.                         for(var i = 0, l = data.length; i < l; i += 4){
  154.                                 // 嘗試過三種方式
  155.                                 // 一種是正常的灰度化公式,無論係數如何調整都無法與繪製的文字字型數據很好地匹配
  156.                                 // binaryData[i / 4] = (data[i] * 0.3 + data[i + 1] * 0.59 + data[i + 2] * 0.11) < 90;
  157.                                 // 一種是自己是通過自己手動調整係數,結果雖然接近但總是不盡人意
  158.                                 // binaryData[i / 4] = data[i] < 250 && data[i + 1] < 203 && data[i + 2] < 203;
  159.                                 // 最後使用了平均值,結果比較理想
  160.                                 binaryData[/ 4] = (data[i] + data[+ 1] + data[+ 2]) / 3 < 200;
  161.                         }
  162.                         return binaryData;
  163.                 }
  164.                 console.log(result);
  165.                 // 輸出耗時
  166.                 console.log(count, (new Date().getTime() - beginTime) / 1000 + ' s');
  167.                 // 將文字繪製到圖片對應位置上,以方便查看提取是否正確
  168.                 context.drawImage(this, this.width, 0);
  169.                 var textArray = result.split('\n');
  170.                 for(var i = 0; i < textArray.length; ++i){
  171.                         context.fillText(textArray[i], this.width + beginX, beginY + lineHeight * i);
  172.                 }
  173.         }
  174.         </script>
  175. </body>
  176. </html>

運行環境

Win7 64位,i3-3220 CPU 3.30 GHz,8G內存

運行結果

  1. yv66vgAAADIAHQoABgAPCQAQABEIABIKABMAFAcAF
  2. QcAFgEABjxpbml0PgEAAygpVgEABENvZGUB
  3. AA9MaW5lTnVtYmVyVGFibGUBAARtYWluAQAWKFtMa
  4. mF2YS9sYW5nL1N0cmluZzspVgEAClNvdXJj
  5. ZUZpbGUBAAlNYWluLmphdmEMAAcACAcAFwwAGAA
  6. ZAQBv5paw5rWq6Laz55CD6Zif5a6e5Yqb6LaF
  7. 576k77yM6Zi15a656LGq5Y2O44CC5LmF5Luw5aSn5ZC
  8. N77yM5ZGo5pel5LiA5oiY77yM6L+Y5pyb
  9. 5LiN6YGX5L2Z5Yqb77yM5LiN5ZCd6LWQ5pWZ44CCBw
  10. AaDAAbABwBAARNYWluAQAQamF2YS9sYW5n
  11. L09iamVjdAEAEGphdmEvbGFuZy9TeXN0ZW0BAANvdX
  12. QBABVMamF2YS9pby9QcmludFN0cmVhbTsB
  13. ABNqYXZhL2lvL1ByaW50U3RyZWFtAQAHcHJpbnRsbgE
  14. AFShMamF2YS9sYW5nL1N0cmluZzspVgAh
  15. AAUABgAAAAAAAgABAAcACAABAAkAAAAdAAEAAQA
  16. AAAUqtwABsQAAAAEACgAAAAYAAQAAAAEACQAL
  17. AAwAAQAJAAAAJQACAAEAAAAJsgACEgO2AASxAAAA
  18. AQAKAAAACgACAAAAAwAIAAQAAQANAAAAAgAO
  19. 715 1.984 s(獵豹)
  20. 772 15.52 sChrome

(遞歸次數谷歌只比獵豹多幾十,耗時卻對了十幾秒,看來獵豹真的比Chrome快?)

非遞歸實現

其實非遞歸實現只是遞歸實現前做的一點小嚐試,只在獵豹下調試完成,因爲不捨得刪,所以順便貼出來了,使用Chrome的各位就不要跑了(我真的不是在給獵豹做廣告= =||)。

  1. <!doctype html>
  2. <html lang="zh-CN">
  3. <head>
  4.         <meta charset="UTF-8">
  5.         <title>文字識別</title>
  6. </head>
  7. <body>
  8.         <canvas id="canvas" width="880" height="1500"></canvas>
  9.         <script type="text/javascript">
  10.         var image = new Image();
  11.         image.onload = recognition;
  12.         image.src = 'image.jpg';
  13.         function recognition(){
  14.                 // 開始時間,用於計算耗時
  15.                 var beginTime = new Date().getTime();
  16.                 // 獲取畫布
  17.                 var canvas = document.getElementById('canvas');
  18.                 // 字符庫
  19.                 var letters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ+';
  20.                 // 字型數據
  21.                 var letterData = {};
  22.                 // 獲取context
  23.                 var context = canvas.getContext('2d');
  24.                 // 設置字體、字號
  25.                 context.font = '16px 微軟雅黑';
  26.                 // 設置文字繪製基線爲文字頂端
  27.                 context.textBaseline = 'top';
  28.                 // 一個循環獲取字符庫對應的字型數據
  29.                 for(var i = 0; i < letters.length; ++i){
  30.                         var letter = letters[i];
  31.                         // 獲取字符繪製寬度
  32.                         var width = context.measureText(letter).width;
  33.                         // 繪製白色背景,與圖片背景對應
  34.                         context.fillStyle = '#fff';
  35.                         context.fillRect(0, 0, width, 22);
  36.                         // 繪製文字,以獲取字型數據
  37.                         context.fillStyle = '#000';
  38.                         context.fillText(letter, 0, 0);
  39.                         // 緩存字型灰度化0-1數據
  40.                         letterData[letter] = {
  41.                                 width : width,
  42.                                 data : getBinary(context.getImageData(0, 0, width, 22).data)
  43.                         }
  44.                         // 清空該區域以獲取下個字符字型數據
  45.                         context.clearRect(0, 0, width, 22);
  46.                 }
  47.                 // console.log(letterData);
  48.                 
  49.                 // 繪製圖片
  50.                 context.drawImage(this, 0, 0);
  51.                 // 要識別的文字開始座標
  52.                 var x = beginX = 8;
  53.                 var y = beginY = 161;
  54.                 // 行高
  55.                 var lineHeight = 24;
  56.                 // 結果文本
  57.                 var result = '';
  58.                 // 非遞歸開始 
  59.                 var count = 0;
  60.                 while(<= 569 && ++count < 1000){
  61.                         // 當前最匹配的字符
  62.                         var trueLetter = {letter: null, width : null, deviation: 100};
  63.                         // 循環匹配字符
  64.                         for(var letter in letterData){
  65.                                 // 獲取當前字符寬度
  66.                                 var width = letterData[letter].width;
  67.                                 // 獲取該矩形區域下的灰度化0-1數據
  68.                                 var data = getBinary(context.getImageData(x, y, width, 22).data);
  69.                                 // 當前字符灰度化數據與當前矩形區域下灰度化數據的偏差量
  70.                                 var deviation = 0;
  71.                                 // 一個臨時變量以確定是否到了行末
  72.                                 var isEmpty = true;
  73.                                 // 如果當前矩形區域已經超出圖片寬度,則進行下一個字符匹配
  74.                                 if(+ width > this.width){
  75.                                         continue;
  76.                                 }
  77.                                 // 計算偏差
  78.                                 for(var i = 0, l = data.length; i < l; ++i){
  79.                                         // 如果發現存在的有效像素點,則確定未到行末
  80.                                         if(isEmpty && data[i]){
  81.                                                 isEmpty = false;
  82.                                         }
  83.                                         // 不匹配的像素點,偏差量自增1
  84.                                         if(data[i] != letterData[letter].data[i]){
  85.                                                 ++deviation;
  86.                                         }
  87.                                 }
  88.                                 // 非遞歸無法遍歷所有情況,因此針對某些字符進行一些微調(這裏只針對獵豹,Chrome的沒做)
  89.                                 // 因爲其實非遞歸實現只是在遞歸實現前做的一點小嚐試,因爲不捨得刪,就順便貼出來了
  90.                                 if(letter == 'M'){
  91.                                         deviation -= 6;
  92.                                 }
  93.                                 // 如果偏差量與寬度的比值小於3,則視爲匹配成功
  94.                                 if(deviation / width < 3){
  95.                                         // 將偏差量與寬度比值最小的作爲當前最匹配的字符
  96.                                         if(deviation / width < trueLetter.deviation / trueLetter.width){
  97.                                                 trueLetter.letter = letter;
  98.                                                 trueLetter.width = width;
  99.                                                 trueLetter.deviation = deviation;
  100.                                         }
  101.                                 }
  102.                         }
  103.                         // 如果已經到了行末,重置匹配座標,進行下一輪匹配
  104.                         if(isEmpty){
  105.                                 x = beginX;
  106.                                 y += lineHeight;
  107.                                 result += '\n';
  108.                                 continue;
  109.                         }
  110.                         // 如果匹配到的字符不爲空,則加入結果字符串,否則輸出匹配結果
  111.                         if(trueLetter.letter){
  112.                                 result += trueLetter.letter;
  113.                                 // console.log(x, y, trueLetter.letter);
  114.                         }else{
  115.                                 console.log(x, y, result.length);
  116.                                 break;
  117.                         }
  118.                         // 調整座標至下一個字符匹配位置
  119.                         x += trueLetter.width;
  120.                 }
  121.                 // 非遞歸結束
  122.                 // 圖像數據的灰度化及0-1化
  123.                 function getBinary(data){
  124.                         var binaryData = [];
  125.                         for(var i = 0, l = data.length; i < l; i += 4){
  126.                                 // 嘗試過三種方式
  127.                                 // 一種是正常的灰度化公式,無論係數如何調整都無法與繪製的文字字型數據很好地匹配
  128.                                 // binaryData[i / 4] = (data[i] * 0.3 + data[i + 1] * 0.59 + data[i + 2] * 0.11) < 90;
  129.                                 // 一種是自己是通過自己手動調整係數,結果雖然接近但總是不盡人意
  130.                                 // binaryData[i / 4] = data[i] < 250 && data[i + 1] < 203 && data[i + 2] < 203;
  131.                                 // 最後使用了平均值,結果比較理想
  132.                                 binaryData[/ 4] = (data[i] + data[+ 1] + data[+ 2]) / 3 < 200;
  133.                         }
  134.                         return binaryData;
  135.                 }
  136.                 console.log(result);
  137.                 // 輸出耗時
  138.                 console.log(count, (new Date().getTime() - beginTime) / 1000 + ' s');
  139.                 // 將文字繪製到圖片對應位置上,以方便查看提取是否正確
  140.                 context.drawImage(this, this.width, 0);
  141.                 var textArray = result.split('\n');
  142.                 for(var i = 0; i < textArray.length; ++i){
  143.                         context.fillText(textArray[i], this.width + beginX, beginY + lineHeight * i);
  144.                 }
  145.         }
  146.         </script>
  147. </body>
  148. </html>

運行結果

  1. yv66vgAAADIAHQoABgAPCQAQABEIABIKABMAFAcAF
  2. QcAFgEABjxpbml0PgEAAygpVgEABENvZGUB
  3. AA9MaW5lTnVtYmVyVGFibGUBAARtYWluAQAWKFtMa
  4. mF2YS9sYW5nL1N0cmluZzspVgEAClNvdXJj
  5. ZUZpbGUBAAlNYWluLmphdmEMAAcACAcAFwwAGAA
  6. ZAQBv5paw5rWq6Laz55CD6Zif5a6e5Yqb6LaF
  7. 576k77yM6Zi15a656LGq5Y2O44CC5LmF5Luw5aSn5ZC
  8. N77yM5ZGo5pel5LiA5oiY77yM6L+Y5pyb
  9. 5LiN6YGX5L2Z5Yqb77yM5LiN5ZCd6LWQ5pWZ44CCBw
  10. AaDAAbABwBAARNYWluAQAQamF2YS9sYW5n
  11. L09iamVjdAEAEGphdmEvbGFuZy9TeXN0ZW0BAANvdX
  12. QBABVMamF2YS9pby9QcmludFN0cmVhbTsB
  13. ABNqYXZhL2lvL1ByaW50U3RyZWFtAQAHcHJpbnRsbgE
  14. AFShMamF2YS9sYW5nL1N0cmluZzspVgAh
  15. AAUABgAAAAAAAgABAAcACAABAAkAAAAdAAEAAQA
  16. AAAUqtwABsQAAAAEACgAAAAYAAQAAAAEACQAL
  17. AAwAAQAJAAAAJQACAAEAAAAJsgACEgO2AASxAAAA
  18. AQAKAAAACgACAAAAAwAIAAQAAQANAAAAAgAO
  19. 702 1.931 s(獵豹)

真正的結果

找了個在線的Base64解碼工具將上面的提取結果進行了一下解碼,發現是一個Java編譯後的.class文件,大概內容是:“新浪足球隊實力超羣,陣容豪華。久仰大名,週日一戰,還望不遺餘力,不吝賜教。”

轉自:http://www.clanfei.com/2013/09/1723.html

發佈了35 篇原創文章 · 獲贊 3 · 訪問量 47萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章