HTML5 JavaScript实现图片文字识别与提取

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文件,大概内容是:“新浪足球队实力超群,阵容豪华。久仰大名,周日一战,还望不遗余力,不吝赐教。”


写在最后


这个只是一个最浅层次的文字识别提取算法,不够通用,性能也一般,权当兴趣研究之用,不过我想,勇于实践、敢于尝试的精神才是最重要的。。

因为最近实习工作略忙,再加上学校开学事情也多,拖了两个星期才把这边文章写出来,除此之外还有不少计划都落下了,还得继续努力啊>_<

还有最近的一些思考的结果和感触也要找个时间写下来。

PS:写这篇博客的时候精神略差,之后有想到什么再作补充吧,如果写的不好还请多多指教!




=======================签 名 档=======================

原文地址(我的博客):http://www.clanfei.com/2013/09/1723.html
欢迎访问交流,至于我为什么要多弄一个博客,因为我热爱前端,热爱网页,我更希望有一个更加自由、真正属于我自己的小站,或许并不是那么有名气,但至少能够让我为了它而加倍努力。。
=======================签 名 档=======================

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值