文章出處

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

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



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

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

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

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

HTML5 JavaScript實現圖片文字提取

思路

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

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

首先,先確定圖片中文本所用的字體、字號、行距等信息,打開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內存

運行結果

01.yv66vgAAADIAHQoABgAPCQAQABEIABIKABMAFAcAF
02.QcAFgEABjxpbml0PgEAAygpVgEABENvZGUB
03.AA9MaW5lTnVtYmVyVGFibGUBAARtYWluAQAWKFtMa
04.mF2YS9sYW5nL1N0cmluZzspVgEAClNvdXJj
05.ZUZpbGUBAAlNYWluLmphdmEMAAcACAcAFwwAGAA
06.ZAQBv5paw5rWq6Laz55CD6Zif5a6e5Yqb6LaF
07.576k77yM6Zi15a656LGq5Y2O44CC5LmF5Luw5aSn5ZC
08.N77yM5ZGo5pel5LiA5oiY77yM6L+Y5pyb
09.5LiN6YGX5L2Z5Yqb77yM5LiN5ZCd6LWQ5pWZ44CCBw
10.AaDAAbABwBAARNYWluAQAQamF2YS9sYW5n
11.L09iamVjdAEAEGphdmEvbGFuZy9TeXN0ZW0BAANvdX
12.QBABVMamF2YS9pby9QcmludFN0cmVhbTsB
13.ABNqYXZhL2lvL1ByaW50U3RyZWFtAQAHcHJpbnRsbgE
14.AFShMamF2YS9sYW5nL1N0cmluZzspVgAh
15.AAUABgAAAAAAAgABAAcACAABAAkAAAAdAAEAAQA
16.AAAUqtwABsQAAAAEACgAAAAYAAQAAAAEACQAL
17.AAwAAQAJAAAAJQACAAEAAAAJsgACEgO2AASxAAAA
18.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>

運行結果

01.yv66vgAAADIAHQoABgAPCQAQABEIABIKABMAFAcAF
02.QcAFgEABjxpbml0PgEAAygpVgEABENvZGUB
03.AA9MaW5lTnVtYmVyVGFibGUBAARtYWluAQAWKFtMa
04.mF2YS9sYW5nL1N0cmluZzspVgEAClNvdXJj
05.ZUZpbGUBAAlNYWluLmphdmEMAAcACAcAFwwAGAA
06.ZAQBv5paw5rWq6Laz55CD6Zif5a6e5Yqb6LaF
07.576k77yM6Zi15a656LGq5Y2O44CC5LmF5Luw5aSn5ZC
08.N77yM5ZGo5pel5LiA5oiY77yM6L+Y5pyb
09.5LiN6YGX5L2Z5Yqb77yM5LiN5ZCd6LWQ5pWZ44CCBw
10.AaDAAbABwBAARNYWluAQAQamF2YS9sYW5n
11.L09iamVjdAEAEGphdmEvbGFuZy9TeXN0ZW0BAANvdX
12.QBABVMamF2YS9pby9QcmludFN0cmVhbTsB
13.ABNqYXZhL2lvL1ByaW50U3RyZWFtAQAHcHJpbnRsbgE
14.AFShMamF2YS9sYW5nL1N0cmluZzspVgAh
15.AAUABgAAAAAAAgABAAcACAABAAkAAAAdAAEAAQA
16.AAAUqtwABsQAAAAEACgAAAAYAAQAAAAEACQAL
17.AAwAAQAJAAAAJQACAAEAAAAJsgACEgO2AASxAAAA
18.AQAKAAAACgACAAAAAwAIAAQAAQANAAAAAgAO



702 1.931 s(獵豹)
真正的結果

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

寫在最后

這個只是一個最淺層次的文字識別提取算法,不夠通用,性能也一般,權當興趣研究之用。不過我想,勇于實踐、敢于嘗試的精神才是最重要的。

因為最近實習工作略忙,再加上學校開學事情也多,拖了兩個星期才把這邊文章寫出來,除此之外還有不少計劃都落下了,還得繼續努力啊>_<

還有最近的一些思考的結果和感觸也要找個時間寫下來。

PS:寫這篇博客的時候精神略差,之后有想到什么再作補充吧,如果寫的不好還請多多指教! 


文章列表


不含病毒。www.avast.com
arrow
arrow
    全站熱搜
    創作者介紹
    創作者 大師兄 的頭像
    大師兄

    IT工程師數位筆記本

    大師兄 發表在 痞客邦 留言(0) 人氣()