非常详细的讲解车牌识别easypr

更新:基于keras-tensorflow的车牌识别,HyperLPR是一个基于Python的使用深度学习针对对中文车牌识别的实现,与开源的EasyPR相比,它的检测速度和鲁棒性和多场景的适应性都要好于EasyPR。


来自:计算机的潜意识


非常详细的讲解车牌识别EasyPR



EasyPR--一个开源的中文车牌识别系统


我正在做一个开源的中文车牌识别系统,Git地址为:https://github.com/liuruoze/EasyPR。

  我给它取的名字为EasyPR,也就是Easy to do Plate Recognition的意思。我开发这套系统的主要原因是因为我希望能够锻炼我在这方面的能力,包括C++技术、计算机图形学、机器学习等。我把这个项目开源的主要目的是:1.它基于开源的代码诞生,理应回归开源;2.我希望有人能够一起协助强化这套系统,包括代码、训练数据等,能够让这套系统的准确性更高,鲁棒性更强等等。

  相比于其他的车牌识别系统,EasyPR有如下特点:

  1. 它基于openCV这个开源库,这意味着所有它的代码都可以轻易的获取。
  2. 它能够识别中文,例如车牌为苏EUK722的图片,它可以准确地输出std:string类型的"苏EUK722"的结果。
  3. 它的识别率较高。目前情况下,字符识别已经可以达到90%以上的精度。

  系统还提供全套的训练数据提供(包括车牌检测的近500个车牌和字符识别的4000多个字符)。所有全部都可以在Github的项目地址上直接下载到。

那么,EasyPR是如何产生的呢?我简单介绍一下它的诞生过程:

  首先,在5月份左右时我考虑要做一个车牌识别系统。这个车牌系统中所有的代码都应该是开源的,不能基于任何黑盒技术。这主要起源于我想锻炼自己的C++和计算机视觉的水平。

  我在网上开始搜索了资料。由于计算机视觉中很多的算法我都是使用openCV,而且openCV发展非常良好,因此我查找的项目必须得是基于OpenCV技术的。于是我在CSDN的博客上找了一篇文章

  文章的作者taotao1233在这两篇博客中以半学习笔记半开发讲解的方式说明了一个车牌识别系统的全部开发过程。非常感谢他的这些博客,借助于这些资料,我着手开始了开发。当时的想法非常朴素,就是想看看按照这些资料,能否真的实现一个车牌识别的系统。关于车牌照片数据的问题,幸运的很,我正在开发的一个项目中有大量的照片,因此数据不是问题。

  令人高兴的是,系统确实能够工作,但是让人沮丧的,似乎也就“仅仅”能够工作而已。在车牌检测这个环节中正确性已经惨不忍睹。

  这个事情给了我一拨不小的冷水,本来我以为很快的开发进度看来是乐观过头了。于是我决定沉下心来,仔细研究他的系统实现的每一个过程,结合OpenCV的官网教程与API资料,我发现他的实现系统中有很多并不适合我目前在做的场景。

  我手里的数据大部分是高速上的图像抓拍数据,其中每个车牌都偏小,而且模糊度较差。直接使用他们的方法,正确率低到了可怕的地步。于是我开始尝试利用openCv中的一些函数与功能,替代,增加,调优等等方法,不断的优化。这个过程很漫长,但是也有很多的积累。我逐渐发现,并且了解他系统中每一个步骤的目的,原理以及如果修改可以进行优化的方法。

  在最终实现的代码中,我的代码已经跟他的原始代码有很多的不一样了,但是成功率大幅度上升,而且车牌的正确检测率不断被优化。在系列文章的后面,我会逐一分享这些优化的过程与心得。

  最终我实现的系统与他的系统有以下几点不同:

  1. 他的系统代码基本上完全参照了《Mastering OpenCV with Practical Computer Vision Projects》这本书的代码,而这本书的代码是专门为西班牙车牌所开发的,因此不适合中文的环境。
  2. 他的系统的代码大部分是原始代码的搬迁,并没有做到优化与改进的地步。而我的系统中对原来的识别过程,做了很多优化步骤。
  3. 车牌识别中核心的机器学习算法的模型,他直接使用了原书提供的,而我这两个过程的模型是自己生成,而且模型也做了测试,作为开源系统的一部分也提供了出来。

  尽管我和他的系统有这么多的不同,但是我们在根本的系统结构上是一致的。应该说,我们都是参照了“Mastering OpenCV”这本数的处理结构。在这点上,我并没有所“创新”,事实上,结果也证明了“Mastering OpenCV”上的车牌识别的处理逻辑,是一个实际有效的最佳处理流程。

  “Mastering OpenCV”,包括我们的系统,都是把车牌识别划分为了两个过程:即车牌检测(Plate Detection)和字符识别(Chars Recognition)两个过程。可能有些书籍或论文上不是这样叫的,但是我觉得,这样的叫法更容易理解,也不容易搞混。

  • 车牌检测(Plate Detection):对一个包含车牌的图像进行分析,最终截取出只包含车牌的一个图块。这个步骤的主要目的是降低了在车牌识别过程中的计算量。如果直接对原始的图像进行车牌识别,会非常的慢,因此需要检测的过程。在本系统中,我们使用SVM(支持向量机)这个机器学习算法去判别截取的图块是否是真的“车牌”。
  • 字符识别(Chars Recognition):有的书上也叫Plate Recognition,我为了与整个系统的名称做区分,所以改为此名字。这个步骤的主要目的就是从上一个车牌检测步骤中获取到的车牌图像,进行光学字符识别(OCR)这个过程。其中用到的机器学习算法是著名的人工神经网络(ANN)中的多层感知机(MLP)模型。最近一段时间非常火的“深度学习”其实就是多隐层的人工神经网络,与其有非常紧密的联系。通过了解光学字符识别(OCR)这个过程,也可以知晓深度学习所基于的人工神经网路技术的一些内容。

  下图是一个完整的EasyPR的处理流程:

本开源项目的目标客户群有三类:
  1. 需要开发一个车牌识别系统的(开发者)。
  2. 需要车牌系统去识别车牌的(用户)。
  3. 急于做毕业设计的(学生)。

  第一类客户是本项目的主要使用者,因此项目特地被精心划分为了6个模块,以供开发者按需选择。
  第二类客户可能会有部分,EasyPR有一个同级项目EasyPR_Dll,可以DLL方式嵌入到其他的程序中,另外还有个一个同级项目EasyPR_Win,基于WTL开发的界面程序,可以简化与帮助车牌识别的结果比对过程。
  对于第三类客户,可以这么说,有完整的全套代码和详细的说明,我相信你们可以稍作修改就可以通过设计大考。

推荐你使用EasyPR有以下几点理由:

  • 这里面的代码都是作者亲自优化过的,你可以在上面做修改,做优化,甚至一起协作开发,一些处理车牌的细节方法你应该是感兴趣的。
  • 如果你对代码不感兴趣,那么经过作者精心训练的模型,包括SVM和ANN的模型,可以帮助你提升或验证你程序的正确率。
  • 如果你对模型也不感兴趣,那么成百上千经过作者亲自挑选的训练数据生成的文件,你应该感兴趣。作者花了大量的时间处理这些训练数据与调整,现在直接提供给你,可以大幅度减轻很多人缺少数据的难题。

  有兴趣的同志可以留言或发Email:liuruoze@163.com 或者直接在Git上发起pull requet,都可以,未来我会在cnblogs上发布更多的关于系统的介绍,包括编码过程,训练心得。







EasyPR--中文开源车牌识别系统 开发详解(1)


 

上篇文档中作者已经简单的介绍了EasyPR,现在在本文档中详细的介绍EasyPR的开发过程。

  正如淘宝诞生于一个购买来的LAMP系统,EasyPR也有它诞生的原型,起源于CSDN的taotao1233的一个博客,博主以读书笔记的形式记述了通过阅读“Mastering OpenCV”这本书完成的一个车牌系统的雏形。

  这个雏形有几个特点:1.将车牌系统划分为了两个过程,即车牌检测和字符识别。2.整个系统是针对西班牙的车牌开发的,与中文车牌不同。3.系统的训练模型来自于原书。作者基于这个系统,诞生了开发一个适用于中文的,且适合与协作开发的开源车牌系统的想法,也就是EasyPR。

  当然了,现在车牌系统满大街都是,随便上下百度首页都是大量的广告,一些甚至宣称自己实现了99%的识别率。那么,作者为什么还要开发这个系统呢?这主要是基于时势与机遇的原因。

众所皆知,现在是大数据的时代。那么,什么是大数据?可能有些人认为这个只是一个概念或着炒作。但是大数据确是实实在在有着基础理论与科学研究背景的一门技术,其中包含着分布式计算、内存计算、机器学习、计算机视觉、语音识别、自然语言处理等众多计算机界崭新的技术,而且是这些技术综合的产物。事实上,大数据的“大”包含着4个特征,即4V理念,包括Volume(体量)、Varity(多样性)、Velocity(速度)、Value(价值)。

  见下图的说明:

图1 大数据技术的4V特征

  综上,大数据技术不仅包含数据量的大,也包含处理数据的复杂,和处理数据的速度,以及数据中蕴含的价值。而车牌识别这个系统,虽然传统,古老,却是包含了所有这四个特侦的一个大数据技术的缩影。

  在车牌识别中,你需要处理的数据是图像中海量的像素单元;你处理的数据不再是传统的结构化数据,而是图像这种复杂的数据;如果不能在很短的时间内识别出车牌,那么系统就缺少意义;虽然一副图像中有很多的信息,但可能仅仅只有那一小块的信息(车牌)以及车身的颜色是你关心,而且这些信息都蕴含着巨大的价值。也就是说,车牌识别系统事实上就是现在火热的大数据技术在某个领域的一个聚焦,通过了解车牌识别系统,可以很好的帮助你理解大数据技术的内涵,也能清楚的认识到大数据的价值。

  很神奇吧,也许你觉得车牌识别系统很低端,这不是随便大街上都有的么,而你又认为大数据技术很高端,似乎高大上的感觉。其实两者本质上是一样的。另外对于觉得大数据技术是虚幻的炒作念头的同学,你们也可以了解一下车牌识别系统,就能知道大数据落在实地,事实上已经不知不觉进入我们的生活很长时间了,像一些其他的如抢票系统,语音助手等,都是大数据技术的真真切切的体现。所谓再虚幻的概念落到实处,就成了下里巴人,应该就是这个意思。所以对于炒概念要有所警觉,但是不能因此排除一切,要了解具体的技术内涵,才能更好的利用技术为我们服务。

  除了帮忙我们更好的理解大数据技术,使我们跟的上时代,开发一个车牌系统还有其他原因。

  那就是、现在的车牌系统,仍然还有许多待解决的挑战。这个可能很多同学有疑问,你别骗我,百度上我随便一搜都是99%,只要多少多少元,就可以99%。但是事实上,车牌识别系统业界一直都没有一个成熟的百分百适用的方案。一些90%以上的车牌识别系统都是跟高清摄像机做了集成,由摄像头传入的高分辨率图片进入识别系统,可以达到较高的识别率。但是如果图像分辨率一旦下来,或者图里的车牌脏了的话,那么很遗憾,识别率远远不如我们的肉眼。也就是说,距离真正的智能的车牌识别系统,目前已有的系统还有许多挑战。什么时候能够达到人眼的精度以及识别速率,估计那时候才算是完整成熟的。

  那么,有同学问,就没有办法进一步优化了么。答案是有的,这个就需要谈到目前火热的深度学习与计算机视觉技术,使用多隐层的深度神经网络也许能够解决这个问题。但是目前EasyPR并没有采用这种技术,或许以后会采用。但是这个方向是有的。也就是说,通过研究车牌识别系统,也许会让你一领略当今人工智能与计算机视觉技术最尖端的研究方向,即深度学习技术。怎么样,听了是不是很心动?最后扯一下,前端时间非常火热Google大脑技术和百度深度学习研究院,都是跟深度学习相关的。

  下图是一个深度学习(右)与传统技术(左)的对比,可以看出深度学习对于数据的分类能力的优势。

图2 深度学习(右)与PCA技术(左)的对比

  总结一下:开发一个车牌识别系统可以让你了解最新的时势---大数据的内涵,同时,也有机遇让你了解最新的人工智能技术---深度学习。因此,不要轻易的小看这门技术中蕴含的价值。

  好,谈价值就说这么多。现在,我简单的介绍一下EasyPR的具体过程。

  在上一篇文档中,我们了解到EasyPR包括两个部分,但实际上为了更好进行模块化开发,EasyPR被划分成了六个模块,其中每个模块的准确率与速度都影响着整个系统。

  具体说来,EasyPR中PlateDetect与CharsRecognize各包括三个模块。

  PlateDetect包括的是车牌定位,SVM训练,车牌判断三个过程,见下图。

图3 PlateDetect过程详解 

  通过PlateDetect过程我们获得了许多可能是车牌的图块,将这些图块进行手工分类,聚集一定数量后,放入SVM模型中训练,得到SVM的一个判断模型,在实际的车牌过程中,我们再把所有可能是车牌的图块输入SVM判断模型,通过SVM模型自动的选择出实际上真正是车牌的图块。

  PlateDetect过程结束后,我们获得一个图片中我们真正关心的部分--车牌。那么下一步该如何处理呢。下一步就是根据这个车牌图片,生成一个车牌号字符串的过程,也就是CharsRecognisze的过程。

  CharsRecognise包括的是字符分割,ANN训练,字符识别三个过程,具体见下图。

图4 CharsRecognise过程详解

  在CharsRecognise过程中,一副车牌图块首先会进行灰度化,二值化,然后使用一系列算法获取到车牌的每个字符的分割图块。获得海量的这些字符图块后,进行手工分类(这个步骤非常耗时间,后面会介绍如何加速这个处理的方法),然后喂入神经网络(ANN)的MLP模型中,进行训练。在实际的车牌识别过程中,将得到7个字符图块放入训练好的神经网络模型,通过模型来预测每个图块所表示的具体字符,例如图片中就输出了“苏EUK722”,(这个车牌只是示例,切勿以为这个车牌有什么特定选取目标。车主既不是作者,也不是什么深仇大恨,仅仅为学术说明选择而已)。

  至此一个完整的车牌识别过程就结束了,但是在每一步的处理过程中,有许多的优化方法和处理策略。尤其是车牌定位和字符分割这两块,非常重要,它们不仅生成实际数据,还生成训练数据,因此会直接影响到模型的准确性,以及模型判断的最终结果。这两部分会是作者重点介绍的模块,至于SVM模型与ANN模型,由于使用的是OpenCV提供的类,因此可以直接看openCV的源码或者机器学习介绍的书,来了解训练与判断过程。

  好了,本期就介绍这么多。下面的篇章中作者会重点介绍其中每个模块的开发过程与内容,但是时间不定,可能几个星期发一篇吧。

  最后,祝大家国庆快乐,阖家幸福!













EasyPR--开发详解(2)车牌定位



这篇文章是一个系列中的第三篇。前两篇的地址贴下:介绍详解1。我撰写这系列文章的目的是:1、普及车牌识别中相关的技术与知识点;2、帮助开发者了解EasyPR的实现细节;3、增进沟通。

  EasyPR的项目地址在这:GitHub。要想运行EasyPR的程序,首先必须配置好openCV,具体可以参照这篇文章

  在前两篇文章中,我们已经初步了解了EasyPR的大概内容,在本篇内容中我们开始深入EasyRP的程序细节。了解EasyPR是如何一步一步实现一个车牌的识别过程的。根据EasyPR的结构,我们把它分为六个部分,前三个部分统称为“Plate Detect”过程。主要目的是在一副图片中发现仅包含车牌的图块,以此提高整体识别的准确率与速度。这个过程非常重要,如果这步失败了,后面的字符识别过程就别想了。而“Plate Detect”过程中的三个部分又分别称之为“Plate Locate” ,“SVM train”,“Plate judge”,其中最重要的部分是第一步“Plate Locate”过程。本篇文章中就是主要介绍“Plate Locate”过程,并且回答以下三个问题:

  1.此过程的作用是什么,为什么重要?

  2.此过程是如何实现车牌定位这个功能的?

  3.此过程中的细节是什么,如何进行调优?

1.“Plate Locate”的作用与重要性

  在说明“Plate Locate”的作用与重要性之前,请看下面这两幅图片。

图1 两幅包含车牌的不同形式图片

  左边的图片是作者训练的图片(作者大部分的训练与测试都是基于此类交通抓拍图片),右边的图片则是在百度图片中“车牌”获得(这个图片也可以称之为生活照片)。右边图片的问题是一个网友评论时问的。他说EasyPR在处理百度图片时的识别率不高。确实如此,由于工业与生活应用目的不同,拍摄的车牌的大小,角度,色泽,清晰度不一样。而对图像处理技术而言,一些算法对于图像的形式以及结构都有一定的要求或者假设。因此在一个场景下适应的算法并不适用其他场景。目前EasyPR所有的功能都是基于交通抓拍场景的图片制作的,因此也就导致了其无法处理生活场景中这些车牌照片。

  那么是否可以用一致的“Plate Locate”过程中去处理它?答案是也许可以,但是很难,而且最后即便处理成功,效率也许也不尽如人意。我的推荐是:对于不同的场景要做不同的适配。尽管“Plate Locate”过程无法处理生活照片的定位,但是在后面的字符识别过程中两者是通用的。可以对EasyPR的“Plate Locate”做改造,同时仍然使用整体架构,这样或许可以处理。

  有一点事实值得了解到是,在生产环境中,你所面对的图片形式是固定的,例如左边的图片。你可以根据特定的图片形式来调优你的车牌程序,使你的程序对这类图片足够健壮,效率也够高。在上线以后,也有很好的效果。但当图片形式调整时,就必须要调整你的算法了。在“Plate Locate”过程中,有一些参数可以调整。如果通过调整这些参数就可以使程序良好工作,那最好不过。当这些参数也不能够满足需求时,就需要完全修改 EasyPR的实现代码,因此需要开发者了解EasyPR是如何实现plateLocate这一过程的。

  在EasyPR中,“Plate Locate”过程被封装成了一个“CPlateLocate”类,通过“plate_locate.h”声明,在“plate_locate.cpp”中实现。

  CPlateLocate包含三个方法以及数个变量。方法提供了车牌定位的主要功能,变量则提供了可定制的参数,有些参数对于车牌定位的效果有非常明显的影响,例如高斯模糊半径、Sobel算子的水平与垂直方向权值、闭操作的矩形宽度。CPlateLocate类的声明如下:

[cpp]  view plain  copy
  1. class CPlateLocate   
  2. {  
  3. public:  
  4.     CPlateLocate();  
  5.   
  6.     //! 车牌定位  
  7.     int plateLocate(Mat, vector<Mat>& );  
  8.   
  9.     //! 车牌的尺寸验证  
  10.     bool verifySizes(RotatedRect mr);  
  11.   
  12.     //! 结果车牌显示  
  13.     Mat showResultMat(Mat src, Size rect_size, Point2f center);  
  14.   
  15.     //! 设置与读取变量  
  16.     //...  
  17.   
  18. protected:  
  19.     //! 高斯模糊所用变量  
  20.     int m_GaussianBlurSize;  
  21.   
  22.     //! 连接操作所用变量  
  23.     int m_MorphSizeWidth;  
  24.     int m_MorphSizeHeight;  
  25.   
  26.     //! verifySize所用变量  
  27.     float m_error;  
  28.     float m_aspect;  
  29.     int m_verifyMin;  
  30.     int m_verifyMax;  
  31.   
  32.     //! 角度判断所用变量  
  33.     int m_angle;  
  34.   
  35.     //! 是否开启调试模式,0关闭,非0开启  
  36.     int m_debug;  
  37. };  


  注意,所有EasyPR中的类都声明在命名空间easypr内,这里没有列出。CPlateLocate中最核心的方法是plateLocate方法。它的声明如下:

[html]  view plain  copy
  1. //! 车牌定位  
  2.    int plateLocate(Mat, vector<Mat>& );  

 方法有两个参数,第一个参数代表输入的源图像,第二个参数是输出数组,代表所有检索到的车牌图块。返回值为int型,0代表成功,其他代表失败。plateLocate内部是如何实现的,让我们再深入下看看。

2.“Plate Locate”的实现过程

  plateLocate过程基本参考了taotao1233的博客的处理流程,但略有不同。

  plateLocate的总体识别思路是:如果我们的车牌没有大的旋转或变形,那么其中必然包括很多垂直边缘(这些垂直边缘往往缘由车牌中的字符),如果能够找到一个包含很多垂直边缘的矩形块,那么有很大的可能性它就是车牌。

  依照这个思路我们可以设计一个车牌定位的流程。设计好后,再根据实际效果进行调优。下面的流程是经过多次调整与尝试后得出的,包含了数月来作者针对测试图片集的一个最佳过程(这个流程并不一定适用所有情况)。plateLocate的实现代码在这里不贴了,Git上有所有源码。plateLocate主要处理流程图如下:

图2 plateLocate流程图

  下面会一步一步参照上面的流程图,给出每个步骤的中间临时图片。这些图片可以在1.01版的CPlateLocate中设置如下代码开启调试模式。

[cpp]  view plain  copy
  1. CPlateLocate plate;  
  2. plate.setDebug(1);  

  临时图片会生成在tmp文件夹下。对多个车牌图片处理的结果仅会保留最后一个车牌图片的临时图片。

  1、原始图片。

  2、经过高斯模糊后的图片。经过这步处理,可以看出图像变的模糊了。这步的作用是为接下来的Sobel算子去除干扰的噪声。

  3、将图像进行灰度化。这个步骤是一个分水岭,意味着后面的所有操作都不能基于色彩信息了。此步骤是利是弊,后面再做分析。

  4、对图像进行Sobel运算,得到的是图像的一阶水平方向导数。这步过后,车牌被明显的区分出来。

  5、对图像进行二值化。将灰度图像(每个像素点有256个取值可能)转化为二值图像(每个像素点仅有1和0两个取值可能)。

  6、使用闭操作。对图像进行闭操作以后,可以看到车牌区域被连接成一个矩形装的区域。

  7、求轮廓。求出图中所有的轮廓。这个算法会把全图的轮廓都计算出来,因此要进行筛选。

  8、筛选。对轮廓求最小外接矩形,然后验证,不满足条件的淘汰。经过这步,仅仅只有六个黄色边框的矩形通过了筛选。

  8、角度判断与旋转。把倾斜角度大于阈值(如正负30度)的矩形舍弃。左边第一、二、四个矩形被舍弃了。余下的矩形进行微小的旋转,使其水平。

  10、统一尺寸。上步得到的图块尺寸是不一样的。为了进入机器学习模型,需要统一尺寸。统一尺寸的标准宽度是136,长度是36。这个标准是对千个测试车牌平均后得出的通用值。下图为最终的三个候选”车牌“图块。

  这些“车牌”有两个作用:一、积累下来作为支持向量机(SVM)模型的训练集,以此训练出一个车牌判断模型;二、在实际的车牌检测过程中,将这些候选“车牌”交由训练好的车牌判断模型进行判断。如果车牌判断模型认为这是车牌的话就进入下一步即字符识别过程,如果不是,则舍弃。

3.“Plate Locate”的深入讨论与调优策略

  好了,说了这么多,读者想必对整个“Plate Locate”过程已经有了一个完整的认识。那么让我们一步步审核一下处理流程中的每一个步骤。回答下面三个问题:这个步骤的作用是什么?省略这步或者替换这步可不可以?这个步骤中是否有参数可以调优的?通过这几个问题可以帮助我们更好的理解车牌定位功能,并且便于自己做修改、定制。

  由于篇幅关系,下面的深入讨论放在下期











EasyPR--开发详解(3)高斯模糊、灰度化和Sobel算子


上篇文章中我们了解了PlateLocate的过程中的所有步骤。在本篇文章中我们对前3个步骤,分别是高斯模糊、灰度化和Sobel算子进行分析。

一、高斯模糊

1.目标

  对图像去噪,为边缘检测算法做准备。  

2.效果

  在我们的车牌定位中的第一步就是高斯模糊处理。

图1 高斯模糊效果

3.理论

  详细说明可以看这篇:阮一峰讲高斯模糊

  高斯模糊是非常有名的一种图像处理技术。顾名思义,其一般应用是将图像变得模糊,但同时高斯模糊也应用在图像的预处理阶段。理解高斯模糊前,先看一下平均模糊算法。平均模糊的算法非常简单。见下图,每一个像素的值都取周围所有像素(共8个)的平均值。


图2 平均模糊示意图

  在上图中,左边红色点的像素值本来是2,经过模糊后,就成了1(取周围所有像素的均值)。在平均模糊中,周围像素的权值都是一样的,都是1。如果周围像素的权值不一样,并且与二维的高斯分布的值一样,那么就叫做高斯模糊。

  在上面的模糊过程中,每个像素取的是周围一圈的平均值,也称为模糊半径为1。如果取周围三圈,则称之为半径为3。半径增大的话,会更加深模糊的效果。

4.实践

  在PlateLocate中是这样调用高斯模糊的。

    //高斯模糊。Size中的数字影响车牌定位的效果。
    GaussianBlur( src, src_blur, Size(m_GaussianBlurSize, m_GaussianBlurSize), 
        0, 0, BORDER_DEFAULT );

  其中Size字段的参数指定了高斯模糊的半径。值是CPlateLocate类的m_GaussianBlurSize变量。由于opencv的高斯模糊仅接收奇数的半径,因此变量为偶数值会抛出异常。
  这里给出了opencv的高斯模糊的API(英文,2.48以上版本)。
  高斯模糊这个过程一定是必要的么。笔者的回答是必要的,倘若我们将这句代码注释并稍作修改,重新运行一下。你会发现plateLocate过程在闭操作时就和原来发生了变化。最后结果如下。

图3 不采用高斯模糊后的结果  

  可以看出,车牌所在的矩形产生了偏斜。最后得到的候选“车牌”图块如下:

图4 不采用高斯模糊后的“车牌”图块

  如果不使用高斯模糊而直接用边缘检测算法,我们得到的候选“车牌”达到了8个!这样不仅会增加车牌判断的处理时间,还增加了判断出错的概率。由于得到的车牌图块中车牌是斜着的,如果我们的字符识别算法需要一个水平的车牌图块,那么几乎肯定我们会无法得到正确的字符识别效果。

  高斯模糊中的半径也会给结果带来明显的变化。有的图片,高斯模糊半径过高了,车牌就定位不出来。有的图片,高斯模糊半径偏低了,车牌也定位不出来。因此、高斯模糊的半径既不宜过高,也不能过低。CPlateLocate类中的值为5的静态常量DEFAULT_GAUSSIANBLUR_SIZE,标示着推荐的高斯模糊的半径。这个值是对于近千张图片经过测试后得出的综合定位率最高的一个值。在CPlateLocate类的构造函数中,m_GaussianBlurSize被赋予了DEFAULT_GAUSSIANBLUR_SIZE的值,因此,默认的高斯模糊的半径就是5。如果不是特殊情况,不需要修改它。

  在数次的实验以后,必须承认,保留高斯模糊过程与半径值为5是最佳的实践。为应对特殊需求,在CPlateLocate类中也应该提供了方法修改高斯半径的值,调用代码(假设需要一个为3的高斯模糊半径)如下:

    CPlateLocate plate;
    plate.setGaussianBlurSize(3);

  目前EasyPR的处理步骤是先进行高斯模糊,再进行灰度化。从目前的实验结果来看,基于色彩的高斯模糊过程比灰度后的高斯模糊过程更容易检测到边缘点。

二、灰度化处理

1.目标

  为边缘检测算法准备灰度化环境。

2.效果

灰度化的效果如下。

图5 灰度化效果

 3.理论

  在灰度化处理步骤中,争议最大的就是信息的损失。无疑的,原先plateLocate过程面对的图片是彩色图片,而从这一步以后,就会面对的是灰度图片。在前面,已经说过这步骤是利是弊是需要讨论的。

   无疑,对于计算机而言,色彩图像相对于灰度图像难处理多了,很多图像处理算法仅仅只适用于灰度图像,例如后面提到的Sobel算子。在这种情况下,你除 了把图片转成灰度图像再进行处理别无它法,除非重新设计算法。但另一方面,转化成灰度图像后恰恰失去了最丰富的细节。要知道,真实世界是彩色的,人类对于 事物的辨别是基于彩色的框架。甚至可以这样说,因为我们的肉眼能够区别彩色,所以我们对于事物的区分,辨别,记忆的能力就非常的强。
  车牌定位环节中去掉彩色的利弊也是同理。转换成灰度图像虽然利于使用各种专用的算法,但失去了真实世界中辨别的最重要工具---色彩的区分。举个简单的例子,人怎么在一张图片中找到车牌?非常简单,一眼望去,一个合适大小的矩形,蓝色的、或者黄色的、或者其他颜色的在另一个黑色,或者白色的大的跟车形类似的矩形中。这个过程非常直观,明显,而且可以排除模糊,色泽,不清楚等很多影响。如果使用灰度图像,就必须借助水平,垂直求导等方法。
  未来如果PlateLocate过程可以使用颜色来判断,可能会比现在的定位更清楚、准确。但这需要研究与实验过程,在EasyPR的未来版本中可能会实现。但无疑,使用色彩判断是一种趋势,因为它不仅符合人眼识别的规律,更趋近于人工智能的本质,而且它更准确,速度更快。

4.实践

  在PlateLocate过程中是这样调用灰度化的。

cvtColor( src_blur, src_gray, CV_RGB2GRAY );

  这里给出了opencv的灰度化的API(英文,2.48以上版本)。

三.Sobel算子

1.目标

  检测图像中的垂直边缘,便于区分车牌。

 2.效果

下图是Sobel算子的效果。


图6 Sobel效果

3.理论

  如果要说哪个步骤是plateLocate中的核心与灵魂,毫无疑问是Sobel算子。没有Sobel算子,也就没有垂直边缘的检测,也就无法得到车牌的可能位置,也就没有后面的一系列的车牌判断、字符识别过程。通过Sobel算子,可以很方便的得到车牌的一个相对准确的位置,为我们的后续处理打好坚实的基础。在上面的plateLocate的执行过程中可以看到,正是通过Sobel算子,将车牌中的字符与车的背景明显区分开来,为后面的二值化与闭操作打下了基础。那么Sobel算子是如何运作的呢?

  Soble算子原理是对图像求一阶的水平与垂直方向导数,根据导数值的大小来判断是否是边缘。请详见CSDN小魏的博客(小心她博客里把Gx和Gy弄反了)。

  为了计算方便,Soble算子并没有真正去求导,而是使用了周边值的加权和的方法,学术上称作“卷积”。权值称为“卷积模板”。例如下图左边就是Sobel的Gx卷积模板(计算垂直边缘),中间是原图像,右边是经过卷积模板后的新图像。

图7 Sobel算子Gx示意图

  在这里演示了通过卷积模板,原始图像红色的像素点原本是5的值,经过卷积计算(- 1 * 3 - 2 * 3 - 1 * 4 + 1 * 5 + 2 * 7 + 1 * 6 = 12)后红色像素的值变成了12。

 4.实践

  在代码中调用Soble算子需要较多的步骤。

[cpp]  view plain  copy
  1. /// Generate grad_x and grad_y  
  2. Mat grad_x, grad_y;  
  3. Mat abs_grad_x, abs_grad_y;  
  4.   
  5. /// Gradient X  
  6. //Scharr( src_gray, grad_x, ddepth, 1, 0, scale, delta, BORDER_DEFAULT );  
  7. Sobel( src_gray, grad_x, ddepth, 1, 0, 3, scale, delta, BORDER_DEFAULT );  
  8. convertScaleAbs( grad_x, abs_grad_x );  
  9.   
  10. /// Gradient Y  
  11. //Scharr( src_gray, grad_y, ddepth, 0, 1, scale, delta, BORDER_DEFAULT );  
  12. Sobel( src_gray, grad_y, ddepth, 0, 1, 3, scale, delta, BORDER_DEFAULT );  
  13. convertScaleAbs( grad_y, abs_grad_y );  
  14.   
  15. /// Total Gradient (approximate)  
  16. addWeighted( abs_grad_x, SOBEL_X_WEIGHT, abs_grad_y, SOBEL_Y_WEIGHT, 0, grad );  

  这里给出了opencv的Sobel的API(英文,2.48以上版本)

  在调用参数中有两个常量SOBEL_X_WEIGHT与SOBEL_Y_WEIGHT代表水平方向和垂直方向的权值,默认前者是1,后者是0,代表仅仅做水平方向求导,而不做垂直方向求导。这样做的意义是,如果我们做了垂直方向求导,会检测出很多水平边缘。水平边缘多也许有利于生成更精确的轮廓,但是由于有些车子前端太多的水平边缘了,例如车头排气孔,标志等等,很多的水平边缘会误导我们的连接结果,导致我们得不到一个恰好的车牌位置。例如,我们对于测试的图做如下实验,将SOBEL_X_WEIGHT与SOBEL_Y_WEIGHT都设置为0.5(代表两者的权值相等),那么最后得到的闭操作后的结果图为

  由于Sobel算子如此重要,可以将车牌与其他区域明显区分出来,那么问题就来了,有没有与Sobel功能类似的算子可以达到一致的效果,或者有没有比Sobel效果更好的算子?

  Sobel算子求图像的一阶导数,Laplace算子则是求图像的二阶导数,在通常情况下,也能检测出边缘,不过Laplace算子的检测不分水平和垂直。下图是Laplace算子与Sobel算子的一个对比。

图8 Sobel与Laplace示意图

  可以看出,通过Laplace算子的图像包含了水平边缘和垂直边缘,根据我们刚才的描述。水平边缘对于车牌的检测一般无利反而有害。经过对近百幅图像的测试,Sobel算子的效果优于Laplace算子,因此不适宜采用Laplace算子替代Sobel算子。

  除了Sobel算子,还有一个算子,Shcarr算子。但这个算子其实只是Sobel算子的一个变种,由于Sobel算子在3*3的卷积模板上计算往往不太精确,因此有一个特殊的Sobel算子,其权值按照下图来表达,称之为Scharr算子。下图是Sobel算子与Scharr算子的一个对比。

图9 Sobel与Scharr示意图

  一般来说,Scharr算子能够比Sobel算子检测边缘的效果更好,从上图也可以看出。但是,这个“更好”是一把双刃剑。我们的目的并不是画出图像的边缘,而是确定车牌的一个区域,越精细的边缘越会干扰后面的闭运算。因此,针对大量的图片的测试,Sobel算子一般都优于Scharr 算子。
  关于Sobel算子更详细的解释和Scharr算子与Sobel算子的同异,可以参看官网的介绍:Sobel与Scharr
  综上所述,在求图像边缘的过程中,Sobel算子是一个最佳的契合车牌定位需求的算子,Laplace算子与Scharr算子的效果都不如它。
  有一点要说明的:Sobel算子仅能对灰度图像有效果,不能将色彩图像作为输入。因此在进行Soble算子前必须进行前面的灰度化工作。





EasyPR--开发详解(4)形态学操作、尺寸验证、旋转等操作


根据前文的内容,车牌定位的功能还剩下如下的步骤,见下图中未涂灰的部分。

图1 车牌定位步骤

  我们首先从Soble算子分析出来的边缘来看。通过下图可见,Sobel算子有很强的区分性,车牌中的字符被清晰的描绘出来,那么如何根据这些信息定位出车牌的位置呢?

图2 Sobel后效果

  我们的车牌定位功能做了个假设,即车牌是包含字符图块的一个最小的外接矩形。在大部分车牌处理中,这个假设都能工作的很好。我们来看下这个假设是如何工作的。

  车牌定位过程的全部代码如下:

 

[cpp]  view plain  copy
  1. //! 定位车牌图像  
  2. //! src 原始图像  
  3. //! resultVec 一个Mat的向量,存储所有抓取到的图像  
  4. //! 成功返回0,否则返回-1  
  5. int CPlateLocate::plateLocate(Mat src, vector<Mat>& resultVec)  
  6. {  
  7.     Mat src_blur, src_gray;  
  8.     Mat grad;  
  9.   
  10.     int scale = SOBEL_SCALE;  
  11.     int delta = SOBEL_DELTA;  
  12.     int ddepth = SOBEL_DDEPTH;  
  13.   
  14.     if( !src.data )  
  15.     { return -1; }  
  16.   
  17.     //高斯模糊。Size中的数字影响车牌定位的效果。  
  18.     GaussianBlur( src, src_blur, Size(m_GaussianBlurSize, m_GaussianBlurSize),   
  19.         0, 0, BORDER_DEFAULT );  
  20.   
  21.     if(m_debug)  
  22.     {   
  23.         stringstream ss(stringstream::in | stringstream::out);  
  24.         ss << "tmp/debug_GaussianBlur" << ".jpg";  
  25.         imwrite(ss.str(), src_blur);  
  26.     }  
  27.   
  28.     /// Convert it to gray  
  29.     cvtColor( src_blur, src_gray, CV_RGB2GRAY );  
  30.   
  31.     if(m_debug)  
  32.     {   
  33.         stringstream ss(stringstream::in | stringstream::out);  
  34.         ss << "tmp/debug_gray" << ".jpg";  
  35.         imwrite(ss.str(), src_gray);  
  36.     }  
  37.   
  38.     /// Generate grad_x and grad_y  
  39.     Mat grad_x, grad_y;  
  40.     Mat abs_grad_x, abs_grad_y;  
  41.   
  42.     /// Gradient X  
  43.     //Scharr( src_gray, grad_x, ddepth, 1, 0, scale, delta, BORDER_DEFAULT );  
  44.     Sobel( src_gray, grad_x, ddepth, 1, 0, 3, scale, delta, BORDER_DEFAULT );  
  45.     convertScaleAbs( grad_x, abs_grad_x );  
  46.   
  47.     /// Gradient Y  
  48.     //Scharr( src_gray, grad_y, ddepth, 0, 1, scale, delta, BORDER_DEFAULT );  
  49.     Sobel( src_gray, grad_y, ddepth, 0, 1, 3, scale, delta, BORDER_DEFAULT );  
  50.     convertScaleAbs( grad_y, abs_grad_y );  
  51.   
  52.     /// Total Gradient (approximate)  
  53.     addWeighted( abs_grad_x, SOBEL_X_WEIGHT, abs_grad_y, SOBEL_Y_WEIGHT, 0, grad );  
  54.   
  55.     //Laplacian( src_gray, grad_x, ddepth, 3, scale, delta, BORDER_DEFAULT );    
  56.     //convertScaleAbs( grad_x, grad );    
  57.   
  58.   
  59.     if(m_debug)  
  60.     {   
  61.         stringstream ss(stringstream::in | stringstream::out);  
  62.         ss << "tmp/debug_Sobel" << ".jpg";  
  63.         imwrite(ss.str(), grad);  
  64.     }  
  65.   
  66.     Mat img_threshold;  
  67.     threshold(grad, img_threshold, 0, 255, CV_THRESH_OTSU+CV_THRESH_BINARY);  
  68.     //threshold(grad, img_threshold, 75, 255, CV_THRESH_BINARY);  
  69.   
  70.     if(m_debug)  
  71.     {   
  72.         stringstream ss(stringstream::in | stringstream::out);  
  73.         ss << "tmp/debug_threshold" << ".jpg";  
  74.         imwrite(ss.str(), img_threshold);  
  75.     }  
  76.   
  77.     Mat element = getStructuringElement(MORPH_RECT, Size(m_MorphSizeWidth, m_MorphSizeHeight) );  
  78.     morphologyEx(img_threshold, img_threshold, MORPH_CLOSE, element);  
  79.       
  80.     if(m_debug)  
  81.     {   
  82.         stringstream ss(stringstream::in | stringstream::out);  
  83.         ss << "tmp/debug_morphology" << ".jpg";  
  84.         imwrite(ss.str(), img_threshold);  
  85.     }  
  86.   
  87.     //Find 轮廓 of possibles plates  
  88.     vector< vector< Point> > contours;  
  89.     findContours(img_threshold,  
  90.         contours, // a vector of contours  
  91.         CV_RETR_EXTERNAL, // 提取外部轮廓  
  92.         CV_CHAIN_APPROX_NONE); // all pixels of each contours  
  93.   
  94.     Mat result;  
  95.     if(m_debug)  
  96.     {   
  97.          Draw blue contours on a white image  
  98.         src.copyTo(result);  
  99.         drawContours(result, contours,  
  100.             -1, // draw all contours  
  101.             Scalar(0,0,255), // in blue  
  102.             1); // with a thickness of 1  
  103.         stringstream ss(stringstream::in | stringstream::out);  
  104.         ss << "tmp/debug_Contours" << ".jpg";  
  105.         imwrite(ss.str(), result);  
  106.     }  
  107.   
  108.   
  109.     //Start to iterate to each contour founded  
  110.     vector<vector<Point> >::iterator itc = contours.begin();  
  111.       
  112.     vector<RotatedRect> rects;  
  113.     //Remove patch that are no inside limits of aspect ratio and area.  
  114.     int t = 0;  
  115.     while (itc != contours.end())  
  116.     {  
  117.         //Create bounding rect of object  
  118.         RotatedRect mr = minAreaRect(Mat(*itc));  
  119.   
  120.         //large the rect for more  
  121.         if( !verifySizes(mr))  
  122.         {  
  123.             itc = contours.erase(itc);  
  124.         }  
  125.         else  
  126.         {  
  127.             ++itc;  
  128.             rects.push_back(mr);  
  129.         }  
  130.     }  
  131.   
  132.     int k = 1;  
  133.     for(int i=0; i< rects.size(); i++)  
  134.     {  
  135.         RotatedRect minRect = rects[i];  
  136.         if(verifySizes(minRect))  
  137.         {      
  138.             // rotated rectangle drawing   
  139.             // Get rotation matrix  
  140.             // 旋转这部分代码确实可以将某些倾斜的车牌调整正,  
  141.             // 但是它也会误将更多正的车牌搞成倾斜!所以综合考虑,还是不使用这段代码。  
  142.             // 2014-08-14,由于新到的一批图片中发现有很多车牌是倾斜的,因此决定再次尝试  
  143.             // 这段代码。  
  144.             if(m_debug)  
  145.             {   
  146.                 Point2f rect_points[4];   
  147.                 minRect.points( rect_points );  
  148.                 forint j = 0; j < 4; j++ )  
  149.                     line( result, rect_points[j], rect_points[(j+1)%4], Scalar(0,255,255), 1, 8 );  
  150.             }  
  151.   
  152.             float r = (float)minRect.size.width / (float)minRect.size.height;  
  153.             float angle = minRect.angle;  
  154.             Size rect_size = minRect.size;  
  155.             if (r < 1)  
  156.             {  
  157.                 angle = 90 + angle;  
  158.                 swap(rect_size.width, rect_size.height);  
  159.             }  
  160.             //如果抓取的方块旋转超过m_angle角度,则不是车牌,放弃处理  
  161.             if (angle - m_angle < 0 && angle + m_angle > 0)  
  162.             {  
  163.                 //Create and rotate image  
  164.                 Mat rotmat = getRotationMatrix2D(minRect.center, angle, 1);  
  165.                 Mat img_rotated;  
  166.                 warpAffine(src, img_rotated, rotmat, src.size(), CV_INTER_CUBIC);  
  167.   
  168.                 Mat resultMat;  
  169.                 resultMat = showResultMat(img_rotated, rect_size, minRect.center, k++);  
  170.   
  171.                 resultVec.push_back(resultMat);  
  172.             }  
  173.         }  
  174.     }  
  175.   
  176.     if(m_debug)  
  177.     {   
  178.         stringstream ss(stringstream::in | stringstream::out);  
  179.         ss << "tmp/debug_result" << ".jpg";  
  180.         imwrite(ss.str(), result);  
  181.     }  
  182.   
  183.     return 0;  
  184. }  

  首先,我们通过二值化处理将Sobel生成的灰度图像转变为二值图像。
四.二值化

  二值化算法非常简单,就是对图像的每个像素做一个阈值处理。

1.目标

  为后续的形态学算子Morph等准备二值化的图像。 

2.效果

  经过二值化处理后的图像效果为下图,与灰度图像仔细区分下,二值化图像中的白色是没有颜色强与暗的区别的。

图3 二值化后效果

  3.理论

  在灰度图像中,每个像素的值是0-255之间的数字,代表灰暗的程度。如果设定一个阈值T,规定像素的值x满足如下条件时则:

 if x < t then x = 0; if x >= t then x = 1。

  如此一来,每个像素的值仅有{0,1}两种取值,0代表黑、1代表白,图像就被转换成了二值化的图像。在上面的公式中,阈值T应该取多少?由于不同图像的光造程度不同,导致作为二值化区分的阈值T也不一样。因此一个简单的做法是直接使用opencv的二值化函数时加上自适应阈值参数。如下:

threshold(src, dest, 0, 255, CV_THRESH_OTSU+CV_THRESH_BINARY); 

  通过这种方法,我们不需要计算阈值的取值,直接使用即可。
  threshold函数是二值化函数,参数src代表源图像,dest代表目标图像,两者的类型都是cv::Mat型,最后的参数代表二值化时的选项,
CV_THRESH_OTSU代表自适应阈值,CV_THRESH_BINARY代表正二值化。正二值化意味着像素的值越接近0,越可能被赋值为0,反之则为1。而另外一种二值化方法表示反二值化,其含义是像素的值越接近0,越可能被赋值1,,计算公式如下: 

 if x < t then x = 1; if x >= t then x = 0,

  如果想使用反二值化,可以使用参数CV_THRESH_BINARY_INV代替CV_THRESH_BINARY即可。在后面的字符识别中我们会同时使用到正二值化与反二值化两种例子。因为中国的车牌有很多类型,最常见的是蓝牌和黄牌。其中蓝牌字符浅,背景深,黄牌则是字符深,背景浅,因此需要正二值化方法与反二值化两种方法来处理,其中正二值化处理蓝牌,反二值化处理黄牌。

五.闭操作

闭操作是个非常重要的操作,我会花很多的字数与图片介绍它。

1.目标

  将车牌字母连接成为一个连通域,便于取轮廓。 

2.效果

  我们这里看下经过闭操作后图像连接的效果。

图4 闭操作后效果

3.理论

  在做闭操作的说明前,必须简单介绍一下腐蚀和膨胀两个操作。
  在图像处理技术中,有一些的操作会对图像的形态发生改变,这些操作一般称之为形态学操作。形态学操作的对象是二值化图像。
有名的形态学操作中包括腐蚀,膨胀,开操作,闭操作等。其中腐蚀,膨胀是许多形态学操作的基础。
  腐蚀操作:
  顾名思义,是将物体的边缘加以腐蚀。具体的操作方法是拿一个宽m,高n的矩形作为模板,对图像中的每一个像素x做如下处理:像素x至于模板的中心,根据模版的大小,遍历所有被模板覆盖的其他像素,修改像素x的值为所有像素中最小的值。这样操作的结果是会将图像外围的突出点加以腐蚀。如下图的操作过程:

图5 腐蚀操作原理

  上图演示的过程是背景为黑色,物体为白色的情况。腐蚀将白色物体的表面加以“腐蚀”。在opencv的官方教程中,是以如下的图示说明腐蚀过程的,与我上面图的区别在于:背景是白色,而物体为黑色(这个不太符合一般的情况,所以我没有拿这张图作为通用的例子)。读者只需要了解背景为不同颜色时腐蚀也是不同的效果就可以了。

图6 腐蚀操作原理2

  膨胀操作:
  膨胀操作与腐蚀操作相反,是将图像的轮廓加以膨胀。操作方法与腐蚀操作类似,也是拿一个矩形模板,对图像的每个像素做遍历处理。不同之处在于修改像素的值不是所有像素中最小的值,而是最大的值。这样操作的结果会将图像外围的突出点连接并向外延伸。如下图的操作过程:


图7 膨胀操作原理

  下面是在opencv的官方教程中,膨胀过程的图示:

图8 膨胀操作原理2

  开操作:
  开操作就是对图像先腐蚀,再膨胀。其中腐蚀与膨胀使用的模板是一样大小的。为了说明开操作的效果,请看下图的操作过程:

图9 开操作原理

  由于开操作是先腐蚀,再膨胀。因此可以结合图5和图7得出图9,其中图5的输出是图7的输入,所以开操作的结果也就是图7的结果。

  闭操作:
  闭操作就是对图像先膨胀,再腐蚀。闭操作的结果一般是可以将许多靠近的图块相连称为一个无突起的连通域。在我们的图像定位中,使用了闭操作去连接所有的字符小图块,然后形成一个车牌的大致轮廓。闭操作的过程我会讲的细致一点。为了说明字符图块连接的过程。在这里选取的原图跟上面三个操作的原图不大一样,是一个由两个分开的图块组成的图。原图首先经过膨胀操作,将两个分开的图块结合起来(注意我用偏白的灰色图块表示由于膨胀操作而产生的新的白色)。接着通过腐蚀操作,将连通域的边缘和突起进行削平(注意我用偏黑的灰色图块表示由于腐蚀被侵蚀成黑色图块)。最后得到的是一个无突起的连通域(纯白的部分)。

图10 闭操作原理

4.代码

  在opencv中,调用闭操作的方法是首先建立矩形模板,矩形的大小是可以设置的,由于矩形是用来覆盖以中心像素的所有其他像素,因此矩形的宽和高最好是奇数。
  通过以下代码设置矩形的宽和高。

    Mat element = getStructuringElement(MORPH_RECT, Size(m_MorphSizeWidth, m_MorphSizeHeight) );

  在这里,我们使用了类成员变量,这两个类成员变量在构造函数中被赋予了初始值。宽是17,高是3.
  设置完矩形的宽和高以后,就可以调用形态学操作了。opencv中所有形态学操作有一个统一的函数,通过参数来区分不同的具体操作。例如MOP_CLOSE代表闭操作,MOP_OPEN代表开操作。

morphologyEx(img_threshold, img_threshold, MORPH_CLOSE, element);

  如果我对二值化的图像进行开操作,结果会是什么样的?下图是图像使用闭操作与开操作处理后的一个区别:

  图11 开与闭的对比

  晕,怎么开操作后图像没了?原因是:开操作第一步腐蚀的效果太强,直接导致接下来的膨胀操作几乎没有效果,所以图像就变几乎没了。
  可以看出,使用闭操作以后,车牌字符的图块被连接成了一个较为规则的矩形,通过闭操作,将车牌中的字符连成了一个图块,同时将突出的部分进行裁剪,图块成为了一个类似于矩形的不规则图块。我们知道,车牌应该是一个规则的矩形,因此获取规则矩形的办法就是先取轮廓,再接着求最小外接矩形。
  这里需要注意的是,矩形模板的宽度,17是个推荐值,低于17都不推荐。
  为什么这么说,因为有一个”断节“的问题。中国车牌有一个特点,就是表示城市的字母与右边相邻的字符距离远大于其他相邻字符之间的距离。如果你设置的不够大,结果导致左边的字符与右边的字符中间断开了,如下图:

 图12 “断节”效果

  这种情况我称之为“断节”如果你不想字符从中间被分成"苏A"和"7EUK22"的话,那么就必须把它设置大点。
  另外还有一种讨厌的情况,就是右边的字符第一个为1的情况,例如苏B13GH7。在这种情况下,由于1的字符的形态原因,导致跟左边的B的字符的距离更远,在这种情况下,低于17都有很大的可能性会断节。下图说明了矩形模板宽度过小时(例如设置为7)面对不同车牌情况下的效果。其中第二个例子选取了苏E开头的车牌,由于E在Sobel算子运算过后仅存有左边的竖杠,因此也会导致跟右边的字符相距过远的情况!

图13 “断节”发生示意

  宽度过大也是不好的,因为它会导致闭操作连接不该连接的部分,例如下图的情况。

 图14 矩形模板宽度过大

  这种情况下,你取轮廓获得矩形肯定会大于你设置的校验规则,即便通过校验了,由于图块中有不少不是车牌的部分,会给字符识别带来麻烦。
  因此,矩形的宽度是一个需要非常细心权衡的值,过大过小都不好,取决于你的环境。至于矩形的高度,3是一个较好的值,一般来说都能工作的很好,不需要改变。

  记得我在前一篇文章中提到,工业用图片与生活场景下图片的区别么。笔者做了一个实验,下载了30多张左右的百度车牌图片。用plateLocate过程去识别他们。如果按照下面的方式设置参数,可以保证90%以上的定位成功率。

[cpp]  view plain  copy
  1. CPlateLocate plate;  
  2. plate.setDebug(1);  
  3. plate.setGaussianBlurSize(5);  
  4. plate.setMorphSizeWidth(7);  
  5. plate.setMorphSizeHeight(3);  
  6. plate.setVerifyError(0.9);  
  7. plate.setVerifyAspect(4);  
  8. plate.setVerifyMin(1);  
  9. plate.setVerifyMax(30);  

  在EasyPR的下一个版本中,会增加对于生活场景下图片的一个模式。只要选择这个模式,就适用于百度图片这种日常生活抓拍图片的效果。但是,仍然有一些图片是EasyPR不好处理的。或者可以说,按照目前的边缘检测算法,难以处理的。

  请看下面一张图片:


图15 难以权衡的一张图片

  这张图片最麻烦的地方在于车牌左右两侧凹下去的边侧,这个边缘在Sobel算子中非常明显,如果矩形模板过长,很容易跟它们连接起来。更麻烦的是这个车牌属于上面说的“断节”很容易发生的类型,因为车牌右侧字符的第一个字母是“1”,这个导致如果矩形模板过短,则很容易车牌断成两截。结果最后导致了如下的情况。

  如果我设置矩形模板宽度为12,则会发生下面的情况:

图16 车牌被一分为二

  如果我增加矩形模板宽度到13,则又会发生下面的情况。

图17 车牌区域被不不正确的放大

  因此矩形模板的宽度是个整数值,在12和13中间没有中间值。这个导致几乎没有办法处理这幅车牌图像。

  上面的情况属于车尾车牌的一种没办法解决的情况。下面所说的情况属于车头的情况,相比前者,错误检测的几率高的多!为什么,因为是一类型车牌无法处理。要问我这家车是哪家,我只能说:碰到开奥迪Q5及其系列的,早点嫁了吧。伤不起。

图18 奥迪Q5前部垂直边缘太多

  这么多的垂直边缘,极为容易检错。已经试过了,几乎没有办法处理这种车牌。只能替换边缘检测这种思路,采用颜色区分等方法。奥体Q系列前脸太多垂直边缘了,给跪。

六.取轮廓

取轮廓操作是个相对简单的操作,因此只做简短的介绍。

1.目标

  将连通域的外围勾画出来,便于形成外接矩形。 

2.效果

  我们这里看下经过取轮廓操作的效果。

图19 取轮廓操作

  在图中,红色的线条就是轮廓,可以看到,有非常多的轮廓。取轮廓操作就是将图像中的所有独立的不与外界有交接的图块取出来。然后根据这些轮廓,求这些轮廓的最小外接矩形。这里面需要注意的是这里用的矩形是RotatedRect,意思是可旋转的。因此我们得到的矩形不是水平的,这样就为处理倾斜的车牌打下了基础。

  取轮廓操作的代码如下:

[cpp]  view plain  copy
  1. vector< vector< Point> > contours;  
  2. findContours(img_threshold,  
  3.    contours, // a vector of contours  
  4.     CV_RETR_EXTERNAL, // 提取外部轮廓  
  5.     CV_CHAIN_APPROX_NONE); // all pixels of each contours  

 

七.尺寸判断

尺寸判断操作是对外接矩形进行判断,以判断它们是否是可能的候选车牌的操作。

1.目标

  排除不可能是车牌的矩形。 

2.效果

  经过尺寸判断,会排除大量由轮廓生成的不合适尺寸的最小外接矩形。效果如下图:

图20 尺寸判断操作

  通过对图像中所有的轮廓的外接矩形进行遍历,我们调用CplateLocate的另一个成员方法verifySizes,代码如下:

[cpp]  view plain  copy
  1. 显示最终生成的车牌图像,便于判断是否成功进行了旋转。  
  2.  Mat CPlateLocate::showResultMat(Mat src, Size rect_size, Point2f center, int index)  
  3.  {  
  4.      Mat img_crop;  
  5.      getRectSubPix(src, rect_size, center, img_crop);  
  6.    
  7.      if(m_debug)  
  8.      {   
  9.          stringstream ss(stringstream::in | stringstream::out);  
  10.          ss << "tmp/debug_crop_" << index << ".jpg";  
  11.          imwrite(ss.str(), img_crop);  
  12.      }  
  13.    
  14.      Mat resultResized;  
  15.      resultResized.create(HEIGHT, WIDTH, TYPE);  
  16.    
  17.      resize(img_crop, resultResized, resultResized.size(), 0, 0, INTER_CUBIC);  
  18.    
  19.      if(m_debug)  
  20.      {   
  21.          stringstream ss(stringstream::in | stringstream::out);  
  22.          ss << "tmp/debug_resize_" << index << ".jpg";  
  23.          imwrite(ss.str(), resultResized);  
  24.      }  
  25.    
  26.      return resultResized;  
  27.  }  

  在原先的verifySizes方法中,使用的是针对西班牙车牌的检测。而我们的系统需要检测的是中国的车牌。因此需要对中国的车牌大小有一个认识。

  中国车牌的一般大小是440mm*140mm,面积为440*140,宽高比为3.14。verifySizes使用如下方法判断矩形是否是车牌:  1.设立一个偏差率error,根据这个偏差率计算最大和最小的宽高比rmax、rmin。判断矩形的r是否满足在rmax、rmin之间。  2.设定一个面积最大值max与面积最小值min。判断矩形的面积area是否满足在max与min之间。  以上两个条件必须同时满足,任何一个不满足都代表这不是车牌。  偏差率和面积最大值、最小值都可以通过参数设置进行修改,且他们都有一个默认值。如果发现verifySizes方法无法发现你图中的车牌,试着修改这些参数。  另外,verifySizes方法是可选的。你也可以不进行verifySizes直接处理,但是这会大大加重后面的车牌判断的压力。一般来说,合理的verifySizes能够去除90%不合适的矩形。

八.角度判断

角度判断操作通过角度进一步排除一部分车牌。

1.目标

  排除不可能是车牌的矩形。 

  通过verifySizes的矩形,还必须进行一个筛选,即角度判断。一般来说,在一副图片中,车牌不太会有非常大的倾斜,我们做如下规定:如果一个矩形的偏斜角度大于某个角度(例如30度),则认为不是车牌并舍弃。

  对上面的尺寸判断结果的六个黄色矩形应用角度判断后结果如下图:

图21 角度判断后的候选车牌

  可以看出,原先的6个候选矩形只剩3个。车牌两侧的车灯的矩形被成功筛选出来。角度判断会去除verifySizes筛选余下的7%矩形,使得最终进入车牌判断环节的矩形只有原先的全部矩形的3%。

  角度判断以及接下来的旋转操作的代码如下:

 View Code

九.旋转

旋转操作是为后面的车牌判断与字符识别提高成功率的关键环节。

1.目标

  旋转操作将偏斜的车牌调整为水平。 

2.效果

  假设待处理的图片如下图:

图22 倾斜的车牌

  使用旋转与不适用旋转的效果区别如下图:

图23 旋转的效果

  可以看出,没有旋转操作的车牌是倾斜,加大了后续车牌判断与字符识别的难度。因此最好需要对车牌进行旋转。

  在角度判定阈值内的车牌矩形,我们会根据它偏转的角度进行一个旋转,保证最后得到的矩形是水平的。调用的opencv函数如下:

[cpp]  view plain  copy
  1. Mat rotmat = getRotationMatrix2D(minRect.center, angle, 1);  
  2. Mat img_rotated;  
  3. warpAffine(src, img_rotated, rotmat, src.size(), CV_INTER_CUBIC);  

  这个调用使用了一个旋转矩阵,属于几何代数内容,在这里不做详细解释。

十.大小调整

  结束了么?不,还没有,至少在我们把这些候选车牌导入机器学习模型之前,需要确保他们的尺寸一致。  机器学习模型在预测的时候,是通过模型输入的特征来判断的。我们的车牌判断模型的特征是所有的像素的值组成的矩阵。因此,如果候选车牌的尺寸不一致,就无法被机器学习模型处理。因此需要用resize方法进行调整。  我们将车牌resize为宽度136,高度36的矩形。为什么用这个值?这个值一开始也不是确定的,我试过许多值。最后我将近千张候选车牌做了一个统计,取它们的平均宽度与高度,因此就有了136和36这个值。所以,这个是一个统计值,平均来说,这个值的效果最好。

  大小调整调用了CplateLocate的最后一个成员方法showResultMat,代码很简单,贴下,不做细讲了。

 View Code

十一.总结

  通过接近10多个步骤的处理,我们才有了最终的候选车牌。这些过程是一环套一环的,前步骤的输出是后步骤的输入,而且顺序也是有规则的。目前针对我的测试图片来说,它们工作的很好,但不一定适用于你的情况。车牌定位以及图像处理算法的一个大的问题就是他的弱鲁棒性,换一个场景可能就得换一套工作方式。因此结合你的使用场景来做调整吧,这是我为什么要在这里费这么多字数详细说明的原因。如果你不了解细节,你就不可能进行修改,也就无法使它适合你的工作需求。  讨论:  车牌定位全部步骤了解后,我们来讨论下。这个过程是否是一个最优的解?  毫无疑问,一个算法的好坏除了取决于它的设计思路,还取决于它是否充分利用了已知的信息。如果一个算法没有充分利用提供的信息,那么它就有进一步优化的空间。EasyPR的 plateLocate过程就是如此,在实施过程中它相继抛弃掉了色彩信息,没有利用纹理信息,因此车牌定位的过程应该还有优化的空间。如果 plateLocate过程无法良好的解决你的定位问题,那么尝试下能够利用其他信息的方法,也许你会大幅度提高你的定位成功率。  车牌定位讲完后,下面就是机器学习的过程。不同于前者,我不会重点说明其中的细节,而是会概括性的说明每个步骤的用途以及训练的最佳实践。在下一个章节中,我会首先介绍下什么是机器学习,为什么它如今这么火热,机器学习和大数据的关系,欢迎继续阅读。  本项目的Git地址:这里。如果有问题欢迎提issue。本文是一个系列中的第5篇,前几篇文章见前面的博客

 

从机器学习谈起

  在本篇文章中,我将对机器学习做个概要的介绍。本文的目的是能让即便完全不了解机器学习的人也能了解机器学习,并且上手相关的实践。这篇文档也算是EasyPR开发的番外篇,从这里开始,必须对机器学习了解才能进一步介绍EasyPR的内核。当然,本文也面对一般读者,不会对阅读有相关的前提要求。

  在进入正题前,我想读者心中可能会有一个疑惑:机器学习有什么重要性,以至于要阅读完这篇非常长的文章呢?

  我并不直接回答这个问题前。相反,我想请大家看两张图,下图是图一:


 图1 机器学习界的执牛耳者与互联网界的大鳄的联姻  

  这幅图上上的三人是当今机器学习界的执牛耳者。中间的是Geoffrey Hinton, 加拿大多伦多大学的教授,如今被聘为“Google大脑”的负责人。右边的是Yann LeCun, 纽约大学教授,如今是Facebook人工智能实验室的主任。而左边的大家都很熟悉,Andrew Ng,中文名吴恩达,斯坦福大学副教授,如今也是“百度大脑”的负责人与百度首席科学家。这三位都是目前业界炙手可热的大牛,被互联网界大鳄求贤若渴的聘请,足见他们的重要性。而他们的研究方向,则全部都是机器学习的子类--深度学习。

  下图是图二:

图2 语音助手产品

  这幅图上描述的是什么?Windows Phone上的语音助手Cortana,名字来源于《光环》中士官长的助手。相比其他竞争对手,微软很迟才推出这个服务。Cortana背后的核心技术是什么,为什么它能够听懂人的语音?事实上,这个技术正是机器学习。机器学习是所有语音助手产品(包括Apple的siri与Google的Now)能够跟人交互的关键技术。

  通过上面两图,我相信大家可以看出机器学习似乎是一个很重要的,有很多未知特性的技术。学习它似乎是一件有趣的任务。实际上,学习机器学习不仅可以帮助我们了解互联网界最新的趋势,同时也可以知道伴随我们的便利服务的实现技术。

  机器学习是什么,为什么它能有这么大的魔力,这些问题正是本文要回答的。同时,本文叫做“从机器学习谈起”,因此会以漫谈的形式介绍跟机器学习相关的所有内容,包括学科(如数据挖掘、计算机视觉等),算法(神经网络,svm)等等。本文的主要目录如下:

  1.一个故事说明什么是机器学习

  2.机器学习的定义

  3.机器学习的范围

  4.机器学习的方法

  5.机器学习的应用--大数据

  6.机器学习的子类--深度学习

  7.机器学习的父类--人工智能

  8.机器学习的思考--计算机的潜意识

  9.总结

  10.后记

1.一个故事说明什么是机器学习

  机器学习这个词是让人疑惑的,首先它是英文名称Machine Learning(简称ML)的直译,在计算界Machine一般指计算机。这个名字使用了拟人的手法,说明了这门技术是让机器“学习”的技术。但是计算机是死的,怎么可能像人类一样“学习”呢?

  传统上如果我们想让计算机工作,我们给它一串指令,然后它遵照这个指令一步步执行下去。有因有果,非常明确。但这样的方式在机器学习中行不通。机器学习根本不接受你输入的指令,相反,它接受你输入的数据! 也就是说,机器学习是一种让计算机利用数据而不是指令来进行各种工作的方法。这听起来非常不可思议,但结果上却是非常可行的。“统计”思想将在你学习“机器学习”相关理念时无时无刻不伴随,相关而不是因果的概念将是支撑机器学习能够工作的核心概念。你会颠覆对你以前所有程序中建立的因果无处不在的根本理念。

  下面我通过一个故事来简单地阐明什么是机器学习。这个故事比较适合用在知乎上作为一个概念的阐明。在这里,这个故事没有展开,但相关内容与核心是存在的。如果你想简单的了解一下什么是机器学习,那么看完这个故事就足够了。如果你想了解机器学习的更多知识以及与它关联紧密的当代技术,那么请你继续往下看,后面有更多的丰富的内容。

  这个例子来源于我真实的生活经验,我在思考这个问题的时候突然发现它的过程可以被扩充化为一个完整的机器学习的过程,因此我决定使用这个例子作为所有介绍的开始。这个故事称为“等人问题”。

  我相信大家都有跟别人相约,然后等人的经历。现实中不是每个人都那么守时的,于是当你碰到一些爱迟到的人,你的时间不可避免的要浪费。我就碰到过这样的一个例子。

  对我的一个朋友小Y而言,他就不是那么守时,最常见的表现是他经常迟到。当有一次我跟他约好3点钟在某个麦当劳见面时,在我出门的那一刻我突然想到一个问题:我现在出发合适么?我会不会又到了地点后,花上30分钟去等他?我决定采取一个策略解决这个问题。

  要想解决这个问题,有好几种方法。第一种方法是采用知识:我搜寻能够解决这个问题的知识。但很遗憾,没有人会把如何等人这个问题作为知识传授,因此我不可能找到已有的知识能够解决这个问题。第二种方法是问他人:我去询问他人获得解决这个问题的能力。但是同样的,这个问题没有人能够解答,因为可能没人碰上跟我一样的情况。第三种方法是准则法:我问自己的内心,我有否设立过什么准则去面对这个问题?例如,无论别人如何,我都会守时到达。但我不是个死板的人,我没有设立过这样的规则。

  事实上,我相信有种方法比以上三种都合适。我把过往跟小Y相约的经历在脑海中重现一下,看看跟他相约的次数中,迟到占了多大的比例。而我利用这来预测他这次迟到的可能性。如果这个值超出了我心里的某个界限,那我选择等一会再出发。假设我跟小Y约过5次,他迟到的次数是1次,那么他按时到的比例为80%,我心中的阈值为70%,我认为这次小Y应该不会迟到,因此我按时出门。如果小Y在5次迟到的次数中占了4次,也就是他按时到达的比例为20%,由于这个值低于我的阈值,因此我选择推迟出门的时间。这个方法从它的利用层面来看,又称为经验法。在经验法的思考过程中,我事实上利用了以往所有相约的数据。因此也可以称之为依据数据做的判断。

  依据数据所做的判断跟机器学习的思想根本上是一致的。

  刚才的思考过程我只考虑“频次”这种属性。在真实的机器学习中,这可能都不算是一个应用。一般的机器学习模型至少考虑两个量:一个是因变量,也就是我们希望预测的结果,在这个例子里就是小Y迟到与否的判断。另一个是自变量,也就是用来预测小Y是否迟到的量。假设我把时间作为自变量,譬如我发现小Y所有迟到的日子基本都是星期五,而在非星期五情况下他基本不迟到。于是我可以建立一个模型,来模拟小Y迟到与否跟日子是否是星期五的概率。见下图:

 

图3 决策树模型

  这样的图就是一个最简单的机器学习模型,称之为决策树。

  当我们考虑的自变量只有一个时,情况较为简单。如果把我们的自变量再增加一个。例如小Y迟到的部分情况时是在他开车过来的时候(你可以理解为他开车水平较臭,或者路较堵)。于是我可以关联考虑这些信息。建立一个更复杂的模型,这个模型包含两个自变量与一个因变量。

  再更复杂一点,小Y的迟到跟天气也有一定的原因,例如下雨的时候,这时候我需要考虑三个自变量。

  如果我希望能够预测小Y迟到的具体时间,我可以把他每次迟到的时间跟雨量的大小以及前面考虑的自变量统一建立一个模型。于是我的模型可以预测值,例如他大概会迟到几分钟。这样可以帮助我更好的规划我出门的时间。在这样的情况下,决策树就无法很好地支撑了,因为决策树只能预测离散值。我们可以用节2所介绍的线型回归方法建立这个模型。

  如果我把这些建立模型的过程交给电脑。比如把所有的自变量和因变量输入,然后让计算机帮我生成一个模型,同时让计算机根据我当前的情况,给出我是否需要迟出门,需要迟几分钟的建议。那么计算机执行这些辅助决策的过程就是机器学习的过程。

  机器学习方法是计算机利用已有的数据(经验),得出了某种模型(迟到的规律),并利用此模型预测未来(是否迟到)的一种方法。

  通过上面的分析,可以看出机器学习与人类思考的经验过程是类似的,不过它能考虑更多的情况,执行更加复杂的计算。事实上,机器学习的一个主要目的就是把人类思考归纳经验的过程转化为计算机通过对数据的处理计算得出模型的过程。经过计算机得出的模型能够以近似于人的方式解决很多灵活复杂的问题。

  下面,我会开始对机器学习的正式介绍,包括定义、范围,方法、应用等等,都有所包含。

 

2.机器学习的定义

  从广义上来说,机器学习是一种能够赋予机器学习的能力以此让它完成直接编程无法完成的功能的方法。但从实践的意义上来说,机器学习是一种通过利用数据,训练出模型,然后使用模型预测的一种方法。

  让我们具体看一个例子。

图4 房价的例子

  拿国民话题的房子来说。现在我手里有一栋房子需要售卖,我应该给它标上多大的价格?房子的面积是100平方米,价格是100万,120万,还是140万?

  很显然,我希望获得房价与面积的某种规律。那么我该如何获得这个规律?用报纸上的房价平均数据么?还是参考别人面积相似的?无论哪种,似乎都并不是太靠谱。

  我现在希望获得一个合理的,并且能够最大程度的反映面积与房价关系的规律。于是我调查了周边与我房型类似的一些房子,获得一组数据。这组数据中包含了大大小小房子的面积与价格,如果我能从这组数据中找出面积与价格的规律,那么我就可以得出房子的价格。

  对规律的寻找很简单,拟合出一条直线,让它“穿过”所有的点,并且与各个点的距离尽可能的小。

  通过这条直线,我获得了一个能够最佳反映房价与面积规律的规律。这条直线同时也是一个下式所表明的函数:

  房价 = 面积 * a + b

  上述中的a、b都是直线的参数。获得这些参数以后,我就可以计算出房子的价格。

  假设a = 0.75,b = 50,则房价 = 100 * 0.75 + 50 = 125万。这个结果与我前面所列的100万,120万,140万都不一样。由于这条直线综合考虑了大部分的情况,因此从“统计”意义上来说,这是一个最合理的预测。

  在求解过程中透露出了两个信息:
  1.房价模型是根据拟合的函数类型决定的。如果是直线,那么拟合出的就是直线方程。如果是其他类型的线,例如抛物线,那么拟合出的就是抛物线方程。机器学习有众多算法,一些强力算法可以拟合出复杂的非线性模型,用来反映一些不是直线所能表达的情况。
  2.如果我的数据越多,我的模型就越能够考虑到越多的情况,由此对于新情况的预测效果可能就越好。这是机器学习界“数据为王”思想的一个体现。一般来说(不是绝对),数据越多,最后机器学习生成的模型预测的效果越好。

  通过我拟合直线的过程,我们可以对机器学习过程做一个完整的回顾。首先,我们需要在计算机中存储历史的数据。接着,我们将这些 数据通过机器学习算法进行处理,这个过程在机器学习中叫做“训练”,处理的结果可以被我们用来对新的数据进行预测,这个结果一般称之为“模型”。对新数据 的预测过程在机器学习中叫做“预测”。“训练”与“预测”是机器学习的两个过程,“模型”则是过程的中间输出结果,“训练”产生“模型”,“模型”指导 “预测”。

  让我们把机器学习的过程与人类对历史经验归纳的过程做个比对。

图5 机器学习与人类思考的类比


  人类在成长、生活过程中积累了很多的历史与经验。人类定期地对这些经验进行“归纳”,获得了生活的“规律”。当人类遇到未知的问题或者需要对未来进行“推测”的时候,人类使用这些“规律”,对未知问题与未来进行“推测”,从而指导自己的生活和工作。

  机器学习中的“训练”与“预测”过程可以对应到人类的“归纳”和“推测”过程。通过这样的对应,我们可以发现,机器学习的思想并不复杂,仅仅是对人类在生活中学习成长的一个模拟。由于机器学习不是基于编程形成的结果,因此它的处理过程不是因果的逻辑,而是通过归纳思想得出的相关性结论。

   这也可以联想到人类为什么要学习历史,历史实际上是人类过往经验的总结。有句话说得很好,“历史往往不一样,但历史总是惊人的相似”。通过学习历史,我们从历史中归纳出人生与国家的规律,从而指导我们的下一步工作,这是具有莫大价值的。当代一些人忽视了历史的本来价值,而是把其作为一种宣扬功绩的手段,这其实是对历史真实价值的一种误用。

  

3.机器学习的范围

  上文虽然说明了机器学习是什么,但是并没有给出机器学习的范围。

  其实,机器学习跟模式识别,统计学习,数据挖掘,计算机视觉,语音识别,自然语言处理等领域有着很深的联系。

  从范围上来说,机器学习跟模式识别,统计学习,数据挖掘是类似的,同时,机器学习与其他领域的处理技术的结合,形成了计算机视觉、语音识别、自然语言处理等交叉学科。因此,一般说数据挖掘时,可以等同于说机器学习。同时,我们平常所说的机器学习应用,应该是通用的,不仅仅局限在结构化数据,还有图像,音频等应用。

  在这节对机器学习这些相关领域的介绍有助于我们理清机器学习的应用场景与研究范围,更好的理解后面的算法与应用层次。

  下图是机器学习所牵扯的一些相关范围的学科与研究领域。


图6 机器学习与相关学科

  模式识别
  模式识别=机器学习。两者的主要区别在于前者是从工业界发展起来的概念,后者则主要源自计算机学科。在著名的《Pattern Recognition And Machine Learning》这本书中,Christopher M. Bishop在开头是这样说的“模式识别源自工业界,而机器学习来自于计算机学科。不过,它们中的活动可以被视为同一个领域的两个方面,同时在过去的10年间,它们都有了长足的发展”。
  
  数据挖掘
  数据挖掘=机器学习+数据库。这几年数据挖掘的概念实在是太耳熟能详。几乎等同于炒作。但凡说数据挖掘都会吹嘘数据挖掘如何如何,例如从数据中挖出金子,以及将废弃的数据转化为价值等等。但是,我尽管可能会挖出金子,但我也可能挖的是“石头”啊。这个说法的意思是,数据挖掘仅仅是一种思考方式,告诉我们应该尝试从数据中挖掘出知识,但不是每个数据都能挖掘出金子的,所以不要神话它。一个系统绝对不会因为上了一个数据挖掘模块就变得无所不能(这是IBM最喜欢吹嘘的),恰恰相反,一个拥有数据挖掘思维的人员才是关键,而且他还必须对数据有深刻的认识,这样才可能从数据中导出模式指引业务的改善。大部分数据挖掘中的算法是机器学习的算法在数据库中的优化。

  统计学习
  统计学习近似等于机器学习。统计学习是个与机器学习高度重叠的学科。因为机器学习中的大多数方法来自统计学,甚至可以认为,统计学的发展促进机器学习的繁荣昌盛。例如著名的支持向量机算法,就是源自统计学科。但是在某种程度上两者是有分别的,这个分别在于:统计学习者重点关注的是统计模型的发展与优化,偏数学,而机器学习者更关注的是能够解决问题,偏实践,因此机器学习研究者会重点研究学习算法在计算机上执行的效率与准确性的提升。
    
  计算机视觉
  计算机视觉=图像处理+机器学习。图像处理技术用于将图像处理为适合进入机器学习模型中的输入,机器学习则负责从图像中识别出相关的模式。计算机视觉相关的应用非常的多,例如百度识图、手写字符识别、车牌识别等等应用。这个领域是应用前景非常火热的,同时也是研究的热门方向。随着机器学习的新领域深度学习的发展,大大促进了计算机图像识别的效果,因此未来计算机视觉界的发展前景不可估量。
  
  语音识别
  语音识别=语音处理+机器学习。语音识别就是音频处理技术与机器学习的结合。语音识别技术一般不会单独使用,一般会结合自然语言处理的相关技术。目前的相关应用有苹果的语音助手siri等。

  自然语言处理
  自然语言处理=文本处理+机器学习。自然语言处理技术主要是让机器理解人类的语言的一门领域。在自然语言处理技术中,大量使用了编译原理相关的技术,例如词法分析,语法分析等等,除此之外,在理解这个层面,则使用了语义理解,机器学习等技术。作为唯一由人类自身创造的符号,自然语言处理一直是机器学习界不断研究的方向。按照百度机器学习专家余凯的说法“听与看,说白了就是阿猫和阿狗都会的,而只有语言才是人类独有的”。如何利用机器学习技术进行自然语言的的深度理解,一直是工业和学术界关注的焦点。

  可以看出机器学习在众多领域的外延和应用。机器学习技术的发展促使了很多智能领域的进步,改善着我们的生活。

 

4.机器学习的方法

  通过上节的介绍我们知晓了机器学习的大致范围,那么机器学习里面究竟有多少经典的算法呢?在这个部分我会简要介绍一下机器学习中的经典代表方法。这部分介绍的重点是这些方法内涵的思想,数学与实践细节不会在这讨论。

  1、回归算法

  在大部分机器学习课程中,回归算法都是介绍的第一个算法。原因有两个:一.回归算法比较简单,介绍它可以让人平滑地从统计学迁移到机器学习中。二.回归算法是后面若干强大算法的基石,如果不理解回归算法,无法学习那些强大的算法。回归算法有两个重要的子类:即线性回归和逻辑回归。

  线性回归就是我们前面说过的房价求解问题。如何拟合出一条直线最佳匹配我所有的数据?一般使用“最小二乘法”来求解。“最小二乘法”的思想是这样的,假设我们拟合出的直线代表数据的真实值,而观测到的数据代表拥有误差的值。为了尽可能减小误差的影响,需要求解一条直线使所有误差的平方和最小。最小二乘法将最优问题转化为求函数极值问题。函数极值在数学上我们一般会采用求导数为0的方法。但这种做法并不适合计算机,可能求解不出来,也可能计算量太大。

  计算机科学界专门有一个学科叫“数值计算”,专门用来提升计算机进行各类计算时的准确性和效率问题。例如,著名的“梯度下降”以及“牛顿法”就是数值计算中的经典算法,也非常适合来处理求解函数极值的问题。梯度下降法是解决回归模型中最简单且有效的方法之一。从严格意义上来说,由于后文中的神经网络和推荐算法中都有线性回归的因子,因此梯度下降法在后面的算法实现中也有应用。

  逻辑回归是一种与线性回归非常类似的算法,但是,从本质上讲,线型回归处理的问题类型与逻辑回归不一致。线性回归处理的是数值问题,也就是最后预测出的结果是数字,例如房价。而逻辑回归属于分类算法,也就是说,逻辑回归预测结果是离散的分类,例如判断这封邮件是否是垃圾邮件,以及用户是否会点击此广告等等。

  实现方面的话,逻辑回归只是对对线性回归的计算结果加上了一个Sigmoid函数,将数值结果转化为了0到1之间的概率(Sigmoid函数的图像一般来说并不直观,你只需要理解对数值越大,函数越逼近1,数值越小,函数越逼近0),接着我们根据这个概率可以做预测,例如概率大于0.5,则这封邮件就是垃圾邮件,或者肿瘤是否是恶性的等等。从直观上来说,逻辑回归是画出了一条分类线,见下图。


  图7 逻辑回归的直观解释

  假设我们有一组肿瘤患者的数据,这些患者的肿瘤中有些是良性的(图中的蓝色点),有些是恶性的(图中的红色点)。这里肿瘤的红蓝色可以被称作数据的“标签”。同时每个数据包括两个“特征”:患者的年龄与肿瘤的大小。我们将这两个特征与标签映射到这个二维空间上,形成了我上图的数据。

  当我有一个绿色的点时,我该判断这个肿瘤是恶性的还是良性的呢?根据红蓝点我们训练出了一个逻辑回归模型,也就是图中的分类线。这时,根据绿点出现在分类线的左侧,因此我们判断它的标签应该是红色,也就是说属于恶性肿瘤。

  逻辑回归算法划出的分类线基本都是线性的(也有划出非线性分类线的逻辑回归,不过那样的模型在处理数据量较大的时候效率会很低),这意味着当两类之间的界线不是线性时,逻辑回归的表达能力就不足。下面的两个算法是机器学习界最强大且重要的算法,都可以拟合出非线性的分类线。

  2、神经网络

  神经网络(也称之为人工神经网络,ANN)算法是80年代机器学习界非常流行的算法,不过在90年代中途衰落。现在,携着“深度学习”之势,神经网络重装归来,重新成为最强大的机器学习算法之一。

  神经网络的诞生起源于对大脑工作机理的研究。早期生物界学者们使用神经网络来模拟大脑。机器学习的学者们使用神经网络进行机器学习的实验,发现在视觉与语音的识别上效果都相当好。在BP算法(加速神经网络训练过程的数值算法)诞生以后,神经网络的发展进入了一个热潮。BP算法的发明人之一是前面介绍的机器学习大牛Geoffrey Hinton(图1中的中间者)。

  具体说来,神经网络的学习机理是什么?简单来说,就是分解与整合。在著名的Hubel-Wiesel试验中,学者们研究猫的视觉分析机理是这样的。



   图8 Hubel-Wiesel试验与大脑视觉机理

  比方说,一个正方形,分解为四个折线进入视觉处理的下一层中。四个神经元分别处理一个折线。每个折线再继续被分解为两条直线,每条直线再被分解为黑白两个面。于是,一个复杂的图像变成了大量的细节进入神经元,神经元处理以后再进行整合,最后得出了看到的是正方形的结论。这就是大脑视觉识别的机理,也是神经网络工作的机理。

  让我们看一个简单的神经网络的逻辑架构。在这个网络中,分成输入层,隐藏层,和输出层。输入层负责接收信号,隐藏层负责对数据的分解与处理,最后的结果被整合到输出层。每层中的一个圆代表一个处理单元,可以认为是模拟了一个神经元,若干个处理单元组成了一个层,若干个层再组成了一个网络,也就是"神经网络"。


图9 神经网络的逻辑架构


  在神经网络中,每个处理单元事实上就是一个逻辑回归模型,逻辑回归模型接收上层的输入,把模型的预测结果作为输出传输到下一个层次。通过这样的过程,神经网络可以完成非常复杂的非线性分类。

  下图会演示神经网络在图像识别领域的一个著名应用,这个程序叫做LeNet,是一个基于多个隐层构建的神经网络。通过LeNet可以识别多种手写数字,并且达到很高的识别精度与拥有较好的鲁棒性。

图10 LeNet的效果展示

  右下方的方形中显示的是输入计算机的图像,方形上方的红色字样“answer”后面显示的是计算机的输出。左边的三条竖直的图像列显示的是神经网络中三个隐藏层的输出,可以看出,随着层次的不断深入,越深的层次处理的细节越低,例如层3基本处理的都已经是线的细节了。LeNet的发明人就是前文介绍过的机器学习的大牛Yann LeCun(图1右者)。

  进入90年代,神经网络的发展进入了一个瓶颈期。其主要原因是尽管有BP算法的加速,神经网络的训练过程仍然很困难。因此90年代后期支持向量机(SVM)算法取代了神经网络的地位。

  3、SVM(支持向量机)

  支持向量机算法是诞生于统计学习界,同时在机器学习界大放光彩的经典算法。

  支持向量机算法从某种意义上来说是逻辑回归算法的强化:通过给予逻辑回归算法更严格的优化条件,支持向量机算法可以获得比逻辑回归更好的分类界线。但是如果没有某类函数技术,则支持向量机算法最多算是一种更好的线性分类技术。

  但是,通过跟高斯“核”的结合,支持向量机可以表达出非常复杂的分类界线,从而达成很好的的分类效果。“核”事实上就是一种特殊的函数,最典型的特征就是可以将低维的空间映射到高维的空间。

  例如下图所示:

          

 图11 支持向量机图例


  我们如何在二维平面划分出一个圆形的分类界线?在二维平面可能会很困难,但是通过“核”可以将二维空间映射到三维空间,然后使用一个线性平面就可以达成类似效果。也就是说,二维平面划分出的非线性分类界线可以等价于三维平面的线性分类界线。于是,我们可以通过在三维空间中进行简单的线性划分就可以达到在二维平面中的非线性划分效果。

 图12 三维空间的切割


  支持向量机是一种数学成分很浓的机器学习算法(相对的,神经网络则有生物科学成分)。在算法的核心步骤中,有一步证明,即将数据从低维映射到高维不会带来最后计算复杂性的提升。于是,通过支持向量机算法,既可以保持计算效率,又可以获得非常好的分类效果。因此支持向量机在90年代后期一直占据着机器学习中最核心的地位,基本取代了神经网络算法。直到现在神经网络借着深度学习重新兴起,两者之间才又发生了微妙的平衡转变。

  4、聚类算法

  前面的算法中的一个显著特征就是我的训练数据中包含了标签,训练出的模型可以对其他未知数据预测标签。在下面的算法中,训练数据都是不含标签的,而算法的目的则是通过训练,推测出这些数据的标签。这类算法有一个统称,即无监督算法(前面有标签的数据的算法则是有监督算法)。无监督算法中最典型的代表就是聚类算法。

  让我们还是拿一个二维的数据来说,某一个数据包含两个特征。我希望通过聚类算法,给他们中不同的种类打上标签,我该怎么做呢?简单来说,聚类算法就是计算种群中的距离,根据距离的远近将数据划分为多个族群。

  聚类算法中最典型的代表就是K-Means算法。

  5、降维算法

  降维算法也是一种无监督学习算法,其主要特征是将数据从高维降低到低维层次。在这里,维度其实表示的是数据的特征量的大小,例如,房价包含房子的长、宽、面积与房间数量四个特征,也就是维度为4维的数据。可以看出来,长与宽事实上与面积表示的信息重叠了,例如面积=长 × 宽。通过降维算法我们就可以去除冗余信息,将特征减少为面积与房间数量两个特征,即从4维的数据压缩到2维。于是我们将数据从高维降低到低维,不仅利于表示,同时在计算上也能带来加速。

  刚才说的降维过程中减少的维度属于肉眼可视的层次,同时压缩也不会带来信息的损失(因为信息冗余了)。如果肉眼不可视,或者没有冗余的特征,降维算法也能工作,不过这样会带来一些信息的损失。但是,降维算法可以从数学上证明,从高维压缩到的低维中最大程度地保留了数据的信息。因此,使用降维算法仍然有很多的好处。

  降维算法的主要作用是压缩数据与提升机器学习其他算法的效率。通过降维算法,可以将具有几千个特征的数据压缩至若干个特征。另外,降维算法的另一个好处是数据的可视化,例如将5维的数据压缩至2维,然后可以用二维平面来可视。降维算法的主要代表是PCA算法(即主成分分析算法)。

  6、推荐算法

  推荐算法是目前业界非常火的一种算法,在电商界,如亚马逊,天猫,京东等得到了广泛的运用。推荐算法的主要特征就是可以自动向用户推荐他们最感兴趣的东西,从而增加购买率,提升效益。推荐算法有两个主要的类别:

  一类是基于物品内容的推荐,是将与用户购买的内容近似的物品推荐给用户,这样的前提是每个物品都得有若干个标签,因此才可以找出与用户购买物品类似的物品,这样推荐的好处是关联程度较大,但是由于每个物品都需要贴标签,因此工作量较大。

  另一类是基于用户相似度的推荐,则是将与目标用户兴趣相同的其他用户购买的东西推荐给目标用户,例如小A历史上买了物品B和C,经过算法分析,发现另一个与小A近似的用户小D购买了物品E,于是将物品E推荐给小A。

  两类推荐都有各自的优缺点,在一般的电商应用中,一般是两类混合使用。推荐算法中最有名的算法就是协同过滤算法。

  7、其他

  除了以上算法之外,机器学习界还有其他的如高斯判别,朴素贝叶斯,决策树等等算法。但是上面列的六个算法是使用最多,影响最广,种类最全的典型。机器学习界的一个特色就是算法众多,发展百花齐放。

  下面做一个总结,按照训练的数据有无标签,可以将上面算法分为监督学习算法和无监督学习算法,但推荐算法较为特殊,既不属于监督学习,也不属于非监督学习,是单独的一类。

  监督学习算法:
  线性回归,逻辑回归,神经网络,SVM

  无监督学习算法:
  聚类算法,降维算法

  特殊算法:
  推荐算法

  除了这些算法以外,有一些算法的名字在机器学习领域中也经常出现。但他们本身并不算是一个机器学习算法,而是为了解决某个子问题而诞生的。你可以理解他们为以上算法的子算法,用于大幅度提高训练过程。其中的代表有:梯度下降法,主要运用在线型回归,逻辑回归,神经网络,推荐算法中;牛顿法,主要运用在线型回归中;BP算法,主要运用在神经网络中;SMO算法,主要运用在SVM中。


5.机器学习的应用--大数据

  说完机器学习的方法,下面要谈一谈机器学习的应用了。无疑,在2010年以前,机器学习的应用在某些特定领域发挥了巨大的作用,如车牌识别,网络攻击防范,手写字符识别等等。但是,从2010年以后,随着大数据概念的兴起,机器学习大量的应用都与大数据高度耦合,几乎可以认为大数据是机器学习应用的最佳场景。

  譬如,但凡你能找到的介绍大数据魔力的文章,都会说大数据如何准确准确预测到了某些事。例如经典的Google利用大数据预测了H1N1在美国某小镇的爆发。

 

图13 Google成功预测H1N1


  百度预测2014年世界杯,从淘汰赛到决赛全部预测正确。

图14 百度世界杯成功预测了所有比赛结果

  这些实在太神奇了,那么究竟是什么原因导致大数据具有这些魔力的呢?简单来说,就是机器学习技术。正是基于机器学习技术的应用,数据才能发挥其魔力。

  大数据的核心是利用数据的价值,机器学习是利用数据价值的关键技术,对于大数据而言,机器学习是不可或缺的。相反,对于机器学习而言,越多的数据会越 可能提升模型的精确性,同时,复杂的机器学习算法的计算时间也迫切需要分布式计算与内存计算这样的关键技术。因此,机器学习的兴盛也离不开大数据的帮助。 大数据与机器学习两者是互相促进,相依相存的关系。

  机器学习与大数据紧密联系。但是,必须清醒的认识到,大数据并不等同于机器学习,同理,机器学习也不等同于大数据。大数据中包含有分布式计算,内存数据库,多维分析等等多种技术。单从分析方法来看,大数据也包含以下四种分析方法:

  1.大数据,小分析:即数据仓库领域的OLAP分析思路,也就是多维分析思想。
  2.大数据,大分析:这个代表的就是数据挖掘与机器学习分析法。
  3.流式分析:这个主要指的是事件驱动架构。
  4.查询分析:经典代表是NoSQL数据库。

  也就是说,机器学习仅仅是大数据分析中的一种而已。尽管机器学习的一些结果具有很大的魔力,在某种场合下是大数据价值最好的说明。但这并不代表机器学习是大数据下的唯一的分析方法。

  机器学习与大数据的结合产生了巨大的价值。基于机器学习技术的发展,数据能够“预测”。对人类而言,积累的经验越丰富,阅历也广泛,对未来的判断越准确。例如常说的“经验丰富”的人比“初出茅庐”的小伙子更有工作上的优势,就在于经验丰富的人获得的规律比他人更准确。而在机器学习领域,根据著名的一个实验,有效的证实了机器学习界一个理论:即机器学习模型的数据越多,机器学习的预测的效率就越好。见下图:

图15 机器学习准确率与数据的关系

  通过这张图可以看出,各种不同算法在输入的数据量达到一定级数后,都有相近的高准确度。于是诞生了机器学习界的名言:成功的机器学习应用不是拥有最好的算法,而是拥有最多的数据!

  在大数据的时代,有好多优势促使机器学习能够应用更广泛。例如随着物联网和移动设备的发展,我们拥有的数据越来越多,种类也包括图片、文本、视频等非结构化数据,这使得机器学习模型可以获得越来越多的数据。同时大数据技术中的分布式计算Map-Reduce使得机器学习的速度越来越快,可以更方便的使用。种种优势使得在大数据时代,机器学习的优势可以得到最佳的发挥。

6.机器学习的子类--深度学习

  近来,机器学习的发展产生了一个新的方向,即“深度学习”。

  虽然深度学习这四字听起来颇为高大上,但其理念却非常简单,就是传统的神经网络发展到了多隐藏层的情况。

  在上文介绍过,自从90年代以后,神经网络已经消寂了一段时间。但是BP算法的发明人Geoffrey Hinton一直没有放弃对神经网络的研究。由于神经网络在隐藏层扩大到两个以上,其训练速度就会非常慢,因此实用性一直低于支持向量机。2006年,Geoffrey Hinton在科学杂志《Science》上发表了一篇文章,论证了两个观点:

  1.多隐层的神经网络具有优异的特征学习能力,学习得到的特征对数据有更本质的刻画,从而有利于可视化或分类;

  2.深度神经网络在训练上的难度,可以通过“逐层初始化” 来有效克服。



图16 Geoffrey Hinton与他的学生在Science上发表文章

  通过这样的发现,不仅解决了神经网络在计算上的难度,同时也说明了深层神经网络在学习上的优异性。从此,神经网络重新成为了机器学习界中的主流强大学习技术。同时,具有多个隐藏层的神经网络被称为深度神经网络,基于深度神经网络的学习研究称之为深度学习。

  由于深度学习的重要性质,在各方面都取得极大的关注,按照时间轴排序,有以下四个标志性事件值得一说:

  2012年6月,《纽约时报》披露了Google Brain项目,这个项目是由Andrew Ng和Map-Reduce发明人Jeff Dean共同主导,用16000个CPU Core的并行计算平台训练一种称为“深层神经网络”的机器学习模型,在语音识别和图像识别等领域获得了巨大的成功。Andrew Ng就是文章开始所介绍的机器学习的大牛(图1中左者)。

  2012年11月,微软在中国天津的一次活动上公开演示了一个全自动的同声传译系统,讲演者用英文演讲,后台的计算机一气呵成自动完成语音识别、英中机器翻译,以及中文语音合成,效果非常流畅,其中支撑的关键技术是深度学习;

  2013年1月,在百度的年会上,创始人兼CEO李彦宏高调宣布要成立百度研究院,其中第一个重点方向就是深度学习,并为此而成立深度学习研究院(IDL)。

  2013年4月,《麻省理工学院技术评论》杂志将深度学习列为2013年十大突破性技术(Breakthrough Technology)之首。


图17 深度学习的发展热潮

  文章开头所列的三位机器学习的大牛,不仅都是机器学习界的专家,更是深度学习研究领域的先驱。因此,使他们担任各个大型互联网公司技术掌舵者的原因不仅在于他们的技术实力,更在于他们研究的领域是前景无限的深度学习技术。

  目前业界许多的图像识别技术与语音识别技术的进步都源于深度学习的发展,除了本文开头所提的Cortana等语音助手,还包括一些图像识别应用,其中典型的代表就是下图的百度识图功能。

图18 百度识图

  深度学习属于机器学习的子类。基于深度学习的发展极大的促进了机器学习的地位提高,更进一步地,推动了业界对机器学习父类人工智能梦想的再次重视。

 

7.机器学习的父类--人工智能

  人工智能是机器学习的父类。深度学习则是机器学习的子类。如果把三者的关系用图来表明的话,则是下图:


图19 深度学习、机器学习、人工智能三者关系

  毫无疑问,人工智能(AI)是人类所能想象的科技界最突破性的发明了,某种意义上来说,人工智能就像游戏最终幻想的名字一样,是人类对于科技界的最终梦想。从50年代提出人工智能的理念以后,科技界,产业界不断在探索,研究。这段时间各种小说、电影都在以各种方式展现对于人工智能的想象。人类可以发明类似于人类的机器,这是多么伟大的一种理念!但事实上,自从50年代以后,人工智能的发展就磕磕碰碰,未有见到足够震撼的科学技术的进步。

  总结起来,人工智能的发展经历了如下若干阶段,从早期的逻辑推理,到中期的专家系统,这些科研进步确实使我们离机器的智能有点接近了,但还有一大段距离。直到机器学习诞生以后,人工智能界感觉终于找对了方向。基于机器学习的图像识别和语音识别在某些垂直领域达到了跟人相媲美的程度。机器学习使人类第一次如此接近人工智能的梦想。

  事实上,如果我们把人工智能相关的技术以及其他业界的技术做一个类比,就可以发现机器学习在人工智能中的重要地位不是没有理由的。

  人类区别于其他物体,植物,动物的最主要区别,作者认为是“智慧”。而智慧的最佳体现是什么?

  是计算能力么,应该不是,心算速度快的人我们一般称之为天才。
  是反应能力么,也不是,反应快的人我们称之为灵敏。
  是记忆能力么,也不是,记忆好的人我们一般称之为过目不忘。
  是推理能力么,这样的人我也许会称他智力很高,类似“福尔摩斯”,但不会称他拥有智慧。
  是知识能力么,这样的人我们称之为博闻广,也不会称他拥有智慧。

  想想看我们一般形容谁有大智慧?圣人,诸如庄子,老子等。智慧是对生活的感悟,是对人生的积淀与思考,这与我们机器学习的思想何其相似?通过经验获取规律,指导人生与未来。没有经验就没有智慧。

 

图20 机器学习与智慧

  

  那么,从计算机来看,以上的种种能力都有种种技术去应对。

  例如计算能力我们有分布式计算,反应能力我们有事件驱动架构,检索能力我们有搜索引擎,知识存储能力我们有数据仓库,逻辑推理能力我们有专家系统,但是,唯有对应智慧中最显著特征的归纳与感悟能力,只有机器学习与之对应。这也是机器学习能力最能表征智慧的根本原因。

  让我们再看一下机器人的制造,在我们具有了强大的计算,海量的存储,快速的检索,迅速的反应,优秀的逻辑推理后我们如果再配合上一个强大的智慧大脑,一个真正意义上的人工智能也许就会诞生,这也是为什么说在机器学习快速发展的现在,人工智能可能不再是梦想的原因。

  人工智能的发展可能不仅取决于机器学习,更取决于前面所介绍的深度学习,深度学习技术由于深度模拟了人类大脑的构成,在视觉识别与语音识别上显著性的突破了原有机器学习技术的界限,因此极有可能是真正实现人工智能梦想的关键技术。无论是谷歌大脑还是百度大脑,都是通过海量层次的深度学习网络所构成的。也许借助于深度学习技术,在不远的将来,一个具有人类智能的计算机真的有可能实现。

  最后再说一下题外话,由于人工智能借助于深度学习技术的快速发展,已经在某些地方引起了传统技术界达人的担忧。真实世界的“钢铁侠”,特斯拉CEO马斯克就是其中之一。最近马斯克在参加MIT讨论会时,就表达了对于人工智能的担忧。“人工智能的研究就类似于召唤恶魔,我们必须在某些地方加强注意。”

 

图21 马斯克与人工智能

  尽管马斯克的担心有些危言耸听,但是马斯克的推理不无道理。“如果人工智能想要消除垃圾邮件的话,可能它最后的决定就是消灭人类。”马斯克认为预防此类现象的方法是引入政府的监管。在这里作者的观点与马斯克类似,在人工智能诞生之初就给其加上若干规则限制可能有效,也就是不应该使用单纯的机器学习,而应该是机器学习与规则引擎等系统的综合能够较好的解决这类问题。因为如果学习没有限制,极有可能进入某个误区,必须要加上某些引导。正如人类社会中,法律就是一个最好的规则,杀人者死就是对于人类在探索提高生产力时不可逾越的界限。

  在这里,必须提一下这里的规则与机器学习引出的规律的不同,规律不是一个严格意义的准则,其代表的更多是概率上的指导,而规则则是神圣不可侵犯,不可修改的。规律可以调整,但规则是不能改变的。有效的结合规律与规则的特点,可以引导出一个合理的,可控的学习型人工智能。

 

8.机器学习的思考--计算机的潜意识

  最后,作者想谈一谈关于机器学习的一些思考。主要是作者在日常生活总结出来的一些感悟。

  回想一下我在节1里所说的故事,我把小Y过往跟我相约的经历做了一个罗列。但是这种罗列以往所有经历的方法只有少数人会这么做,大部分的人采用的是更直接的方法,即利用直觉。那么,直觉是什么?其实直觉也是你在潜意识状态下思考经验后得出的规律。就像你通过机器学习算法,得到了一个模型,那么你下次只要直接使用就行了。那么这个规律你是什么时候思考的?可能是在你无意识的情况下,例如睡觉,走路等情况。这种时候,大脑其实也在默默地做一些你察觉不到的工作。

  这种直觉与潜意识,我把它与另一种人类思考经验的方式做了区分。如果一个人勤于思考,例如他会每天做一个小结,譬如“吾日三省吾身”,或者他经常与同伴讨论最近工作的得失,那么他这种训练模型的方式是直接的,明意识的思考与归纳。这样的效果很好,记忆性强,并且更能得出有效反应现实的规律。但是大部分的人可能很少做这样的总结,那么他们得出生活中规律的方法使用的就是潜意识法。

  举一个作者本人关于潜意识的例子。作者本人以前没开过车,最近一段时间买了车后,天天开车上班。我每天都走固定的路线。有趣的是,在一开始的几天,我非常紧张的注意着前方的路况,而现在我已经在无意识中就把车开到了目标。这个过程中我的眼睛是注视着前方的,我的大脑是没有思考,但是我手握着的方向盘会自动的调整方向。也就是说。随着我开车次数的增多,我已经把我开车的动作交给了潜意识。这是非常有趣的一件事。在这段过程中,我的大脑将前方路况的图像记录了下来,同时大脑也记忆了我转动方向盘的动作。经过大脑自己的潜意识思考,最后生成的潜意识可以直接根据前方的图像调整我手的动作。假设我们将前方的录像交给计算机,然后让计算机记录与图像对应的驾驶员的动作。经过一段时间的学习,计算机生成的机器学习模型就可以进行自动驾驶了。这很神奇,不是么。其实包括Google、特斯拉在内的自动驾驶汽车技术的原理就是这样。

  除了自动驾驶汽车以外,潜意识的思想还可以扩展到人的交际。譬如说服别人,一个最佳的方法就是给他展示一些信息,然后让他自己去归纳得出我们想要的结论。就好比在阐述一个观点时,用一个事实,或者一个故事,比大段的道理要好很多。古往今来,但凡优秀的说客,无不采用的是这种方法。春秋战国时期,各国合纵连横,经常有各种说客去跟一国之君交流,直接告诉君主该做什么,无异于自寻死路,但是跟君主讲故事,通过这些故事让君主恍然大悟,就是一种正确的过程。这里面有许多杰出的代表,如墨子,苏秦等等。

  基本上所有的交流过程,使用故事说明的效果都要远胜于阐述道义之类的效果好很多。为什么用故事的方法比道理或者其他的方法好很多,这是因为在人成长的过程,经过自己的思考,已经形成了很多规律与潜意识。如果你告诉的规律与对方的不相符,很有可能出于保护,他们会本能的拒绝你的新规律,但是如果你跟他讲一个故事,传递一些信息,输送一些数据给他,他会思考并自我改变。他的思考过程实际上就是机器学习的过程,他把新的数据纳入到他的旧有的记忆与数据中,经过重新训练。如果你给出的数据的信息量非常大,大到调整了他的模型,那么他就会按照你希望的规律去做事。有的时候,他会本能的拒绝执行这个思考过程,但是数据一旦输入,无论他希望与否,他的大脑都会在潜意识状态下思考,并且可能改变他的看法。

  如果计算机也拥有潜意识(正如本博客的名称一样),那么会怎么样?譬如让计算机在工作的过程中,逐渐产生了自身的潜意识,于是甚至可以在你不需要告诉它做什么时它就会完成那件事。这是个非常有意思的设想,这里留给各位读者去发散思考吧。


9.总结

  本文首先介绍了互联网界与机器学习大牛结合的趋势,以及使用机器学习的相关应用,接着以一个“等人故事”展开对机器学习的介绍。介绍中首先是机器学习的概念与定义,然后是机器学习的相关学科,机器学习中包含的各类学习算法,接着介绍机器学习与大数据的关系,机器学习的新子类深度学习,最后探讨了一下机器学习与人工智能发展的联系以及机器学习与潜意识的关联。经过本文的介绍,相信大家对机器学习技术有一定的了解,例如机器学习是什么,它的内核思想是什么(即统计和归纳),通过了解机器学习与人类思考的近似联系可以知晓机器学习为什么具有智慧能力的原因等等。其次,本文漫谈了机器学习与外延学科的关系,机器学习与大数据相互促进相得益彰的联系,机器学习界最新的深度学习的迅猛发展,以及对于人类基于机器学习开发智能机器人的一种展望与思考,最后作者简单谈了一点关于让计算机拥有潜意识的设想。

  机器学习是目前业界最为Amazing与火热的一项技术,从网上的每一次淘宝的购买东西,到自动驾驶汽车技术,以及网络攻击抵御系统等等,都有机器学习的因子在内,同时机器学习也是最有可能使人类完成AI dream的一项技术,各种人工智能目前的应用,如微软小冰聊天机器人,到计算机视觉技术的进步,都有机器学习努力的成分。作为一名当代的计算机领域的开发或管理人员,以及身处这个世界,使用者IT技术带来便利的人们,最好都应该了解一些机器学习的相关知识与概念,因为这可以帮你更好的理解为你带来莫大便利技术的背后原理,以及让你更好的理解当代科技的进程。

10.后记

  这篇文档花了作者两个月的时间,终于在2014年的最后一天的前一天基本完成。通过这篇文章,作者希望对机器学习在国内的普及做一点贡献,同时也是作者本人自己对于所学机器学习知识的一个融汇贯通,整体归纳的提高过程。作者把这么多的知识经过自己的大脑思考,训练出了一个模型,形成了这篇文档,可以说这也是一种机器学习的过程吧(笑)。

  作者所在的行业会接触到大量的数据,因此对于数据的处理和分析是平常非常重要的工作,机器学习课程的思想和理念对于作者日常的工作指引作用极大,几乎导致了作者对于数据价值的重新认识。想想半年前,作者还对机器学习似懂非懂,如今也可以算是一个机器学习的Expert了(笑)。但作者始终认为,机器学习的真正应用不是通过概念或者思想的方式,而是通过实践。只有当把机器学习技术真正应用时,才可算是对机器学习的理解进入了一个层次。正所谓再“阳春白雪”的技术,也必须落到“下里巴人”的场景下运用。目前有一种风气,国内外研究机器学习的某些学者,有一种高贵的逼格,认为自己的研究是普通人无法理解的,但是这样的理念是根本错误的,没有在真正实际的地方发挥作用,凭什么证明你的研究有所价值呢?作者认为必须将高大上的技术用在改变普通人的生活上,才能发挥其根本的价值。一些简单的场景,恰恰是实践机器学习技术的最好地方。














EasyPR--开发详解(5)颜色定位与偏斜扭转

本篇文章介绍EasyPR里新的定位功能:颜色定位与偏斜扭正。希望这篇文档可以帮助开发者与使用者更好的理解EasyPR的设计思想。

  让我们先看一下示例图片,这幅图片中的车牌通过颜色的定位法进行定位并从偏斜的视角中扭正为正视角(请看右图的左上角)。

图1 新版本的定位效果

下面内容会对这两个特性的实现过程展开具体的介绍。首先介绍颜色定位的原理,然后是偏斜扭正的实现细节。

  由于本文较长,为方便读者,以下是本文的目录:

  一.颜色定位

  1.1起源

  1.2方法

  1.3不足与改善

  二.偏斜扭正

  2.1分析

  2.2ROI截取

  2.3扩大化旋转

  2.4偏斜判断

  2.5仿射变换

  2.6总结

  三.总结

一. 颜色定位

1.起源

  在前面的介绍里,我们使用了Sobel查找垂直边缘的方法,成功定位了许多车牌。但是,Sobel法最大的问题就在于面对垂直边缘交错的情况下,无法准确地定位车牌。例如下图。为了解决这个问题,可以考虑使用颜色信息进行定位。

图2 颜色定位与Sobel定位的比较

  如果将颜色定位与Sobel定位加以结合的话,可以使车牌的定位准确率从75%上升到94%。

2.方法

  关于颜色定位首先我们想到的解决方案就是:利用RGB值来判断。

  这个想法听起来很自然:如果我们想找出一幅图像中的蓝色部分,那么我们只需要检查RGB分量(RGB分量由Red分量--红色,Green分量 --绿色,Blue分量--蓝色共同组成)中的Blue分量就可以了。一般来说,Blue分量是个0到255的值。如果我们设定一个阈值,并且检查每个像素的Blue分量是否大于它,那我们不就可以得知这些像素是不是蓝色的了么?这个想法虽然很好,不过存在一个问题,我们该怎么来选择这个阈值?这是第一个问题。

  即便我们用一些方法决定了阈值以后,那么下面的一个问题就会让人抓狂,颜色是组合的,即便蓝色属性在255(这样已经很‘蓝’了吧),只要另外两个分量配合(例如都为255),你最后得到的不是蓝色,而是黑色。

  这还只是区分蓝色的问题,黄色更麻烦,它是由红色和绿色组合而成的,这意味着你需要考虑两个变量的配比问题。这些问题让选择RGB颜色作为判断的难度大到难以接受的地步。因此必须另想办法。

  为了解决各种颜色相关的问题,人们发明了各种颜色模型。其中有一个模型,非常适合解决颜色判断的问题。这个模型就是HSV模型。

图3 HSV颜色模型

  HSV模型是根据颜色的直观特性创建的一种圆锥模型。与RGB颜色模型中的每个分量都代表一种颜色不同的是,HSV模型中每个分量并不代表一种颜色,而分别是:色调(H),饱和度(S),亮度(V)。

  H分量是代表颜色特性的分量,用角度度量,取值范围为0~360,从红色开始按逆时针方向计算,红色为0,绿色为120,蓝色为240。S分量代表颜色的饱和信息,取值范围为0.0~1.0,值越大,颜色越饱和。V分量代表明暗信息,取值范围为0.0~1.0,值越大,色彩越明亮。

  H分量是HSV模型中唯一跟颜色本质相关的分量。只要固定了H的值,并且保持S和V分量不太小,那么表现的颜色就会基本固定。为了判断蓝色车牌颜色的范围,可以固定了S和V两个值为1以后,调整H的值,然后看颜色的变化范围。通过一段摸索,可以发现当H的取值范围在200到280时,这些颜色都可以被认为是蓝色车牌的颜色范畴。于是我们可以用H分量是否在200与280之间来决定某个像素是否属于蓝色车牌。黄色车牌也是一样的道理,通过观察,可以发现当H值在30到80时,颜色的值可以作为黄色车牌的颜色。

  这里的颜色表来自于这个网站

  下图显示了蓝色的H分量变化范围。

图4 蓝色的H分量区间 

  下图显示了黄色的H分量变化范围。 

图5 黄色的H分量区间

  光判断H分量的值是否就足够了?

  事实上是不足的。固定了H的值以后,如果移动V和S会带来颜色的饱和度和亮度的变化。当V和S都达到最高值,也就是1时,颜色是最纯正的。降低S,颜色越发趋向于变白。降低V,颜色趋向于变黑,当V为0时,颜色变为黑色。因此,S和V的值也会影响最终颜色的效果。

  我们可以设置一个阈值,假设S和V都大于阈值时,颜色才属于H所表达的颜色。

  在EasyPR里,这个值是0.35,也就是V属于0.35到1且S属于0.35到1的一个范围,类似于一个矩形。对V和S的阈值判断是有必要的,因为很多车牌周身的车身,都是H分量属于200-280,而V分量或者S分量小于0.35的。通过S和V的判断可以排除车牌周围车身的干扰。

图6 V和S的区间

  明确了使用HSV模型以及用阈值进行判断以后,下面就是一个颜色定位的完整过程。

  第一步,将图像的颜色空间从RGB转为HSV,在这里由于光照的影响,对于图像使用直方图均衡进行预处理;

  第二步,依次遍历图像的所有像素,当H值落在200-280之间并且S值与V值也落在0.35-1.0之间,标记为白色像素,否则为黑色像素;

  第三步,对仅有白黑两个颜色的二值图参照原先车牌定位中的方法,使用闭操作,取轮廓等方法将车牌的外接矩形截取出来做进一步的处理。

图7 蓝色定位效果

  以上就完成了一个蓝色车牌的定位过程。我们把对图像中蓝色车牌的寻找过程称为一次与蓝色模板的匹配过程。代码中的函数称之为colorMatch。一般说来,一幅图像需要进行一次蓝色模板的匹配,还要进行一次黄色模板的匹配,以此确保蓝色和黄色的车牌都被定位出来。

  黄色车牌的定位方法与其类似,仅仅只是H阈值范围的不同。事实上,黄色定位的效果一般好的出奇,可以在非常复杂的环境下将车牌极为准确的定位出来,这可能源于现实世界中黄色非常醒目的原因。

图8 黄色定位效果

  从实际效果来看,颜色定位的效果是很好的。在通用数据测试集里,大约70%的车牌都可以被定位出来(一些颜色定位不了的,我们可以用Sobel定位处理)。

  在代码中有些细节需要注意:

  一. opencv为了保证HSV三个分量都落在0-255之间(确保一个char能装的下),对H分量除以了2,也就是0-180的范围,S和V分量乘以了 255,将0-1的范围扩展到0-255。我们在设置阈值的时候需要参照opencv的标准,因此对参数要进行一个转换。

  二. 是v和s取值的问题。对于暗的图来说,取值过大容易漏,而对于亮的图,取值过小则容易跟车身混淆。因此可以考虑最适应的改变阈值。

  三. 是模板问题。目前的做法是针对蓝色和黄色的匹配使用了两个模板,而不是统一的模板。统一模板的问题在于担心蓝色和黄色的干扰问题,例如黄色的车与蓝色的牌的干扰,或者蓝色的车和黄色牌的干扰,这里面最典型的例子就是一个带有蓝色车牌的黄色出租车,在很多城市里这已经是“标准配置”。因此需要将蓝色和黄色的匹配分别用不同的模板处理。

  了解完这三个细节以后,下面就是代码部分。

[cpp]  view plain  copy
  1. //! 根据一幅图像与颜色模板获取对应的二值图  
  2.  //! 输入RGB图像, 颜色模板(蓝色、黄色)  
  3.  //! 输出灰度图(只有0和255两个值,255代表匹配,0代表不匹配)  
  4.  Mat colorMatch(const Mat& src, Mat& match, const Color r, const bool adaptive_minsv)  
  5.  {  
  6.      // S和V的最小值由adaptive_minsv这个bool值判断  
  7.      // 如果为true,则最小值取决于H值,按比例衰减  
  8.      // 如果为false,则不再自适应,使用固定的最小值minabs_sv  
  9.      // 默认为false  
  10.      const float max_sv = 255;  
  11.      const float minref_sv = 64;  
  12.   
  13.      const float minabs_sv = 95;  
  14.   
  15.      //blue的H范围  
  16.      const int min_blue = 100;  //100  
  17.      const int max_blue = 140;  //140  
  18.   
  19.      //yellow的H范围  
  20.      const int min_yellow = 15; //15  
  21.      const int max_yellow = 40; //40  
  22.   
  23.      Mat src_hsv;  
  24.      // 转到HSV空间进行处理,颜色搜索主要使用的是H分量进行蓝色与黄色的匹配工作  
  25.      cvtColor(src, src_hsv, CV_BGR2HSV);  
  26.   
  27.      vector<Mat> hsvSplit;  
  28.      split(src_hsv, hsvSplit);  
  29.      equalizeHist(hsvSplit[2], hsvSplit[2]);  
  30.      merge(hsvSplit, src_hsv);  
  31.   
  32.      //匹配模板基色,切换以查找想要的基色  
  33.      int min_h = 0;  
  34.      int max_h = 0;  
  35.      switch (r) {  
  36.      case BLUE:  
  37.          min_h = min_blue;  
  38.          max_h = max_blue;  
  39.          break;  
  40.      case YELLOW:  
  41.          min_h = min_yellow;  
  42.          max_h = max_yellow;  
  43.          break;  
  44.      }  
  45.   
  46.      float diff_h = float((max_h - min_h) / 2);  
  47.      int avg_h = min_h + diff_h;  
  48.   
  49.      int channels = src_hsv.channels();  
  50.      int nRows = src_hsv.rows;  
  51.      //图像数据列需要考虑通道数的影响;  
  52.      int nCols = src_hsv.cols * channels;  
  53.   
  54.      if (src_hsv.isContinuous())//连续存储的数据,按一行处理  
  55.      {  
  56.          nCols *= nRows;  
  57.          nRows = 1;  
  58.      }  
  59.   
  60.      int i, j;  
  61.      uchar* p;  
  62.      float s_all = 0;  
  63.      float v_all = 0;  
  64.      float count = 0;  
  65.      for (i = 0; i < nRows; ++i)  
  66.      {  
  67.          p = src_hsv.ptr<uchar>(i);  
  68.          for (j = 0; j < nCols; j += 3)  
  69.          {  
  70.              int H = int(p[j]); //0-180  
  71.              int S = int(p[j + 1]);  //0-255  
  72.              int V = int(p[j + 2]);  //0-255  
  73.   
  74.              s_all += S;  
  75.              v_all += V;  
  76.              count++;  
  77.   
  78.              bool colorMatched = false;  
  79.   
  80.              if (H > min_h && H < max_h)  
  81.              {  
  82.                  int Hdiff = 0;  
  83.                  if (H > avg_h)  
  84.                      Hdiff = H - avg_h;  
  85.                  else  
  86.                      Hdiff = avg_h - H;  
  87.   
  88.                  float Hdiff_p = float(Hdiff) / diff_h;  
  89.   
  90.                  // S和V的最小值由adaptive_minsv这个bool值判断  
  91.                  // 如果为true,则最小值取决于H值,按比例衰减  
  92.                  // 如果为false,则不再自适应,使用固定的最小值minabs_sv  
  93.                  float min_sv = 0;  
  94.                  if (true == adaptive_minsv)  
  95.                      min_sv = minref_sv - minref_sv / 2 * (1 - Hdiff_p); // inref_sv - minref_sv / 2 * (1 - Hdiff_p)  
  96.                  else  
  97.                      min_sv = minabs_sv; // add  
  98.   
  99.                  if ((S > min_sv && S < max_sv) && (V > min_sv && V < max_sv))  
  100.                      colorMatched = true;  
  101.              }  
  102.   
  103.              if (colorMatched == true) {  
  104.                  p[j] = 0; p[j + 1] = 0; p[j + 2] = 255;  
  105.              }  
  106.              else {  
  107.                  p[j] = 0; p[j + 1] = 0; p[j + 2] = 0;  
  108.              }  
  109.          }  
  110.      }  
  111.   
  112.      //cout << "avg_s:" << s_all / count << endl;  
  113.      //cout << "avg_v:" << v_all / count << endl;  
  114.   
  115.      // 获取颜色匹配后的二值灰度图  
  116.      Mat src_grey;  
  117.      vector<Mat> hsvSplit_done;  
  118.      split(src_hsv, hsvSplit_done);  
  119.      src_grey = hsvSplit_done[2];  
  120.   
  121.      match = src_grey;  
  122.   
  123.      return src_grey;  
  124.  }  

3.不足

  以上说明了颜色定位的设计思想与细节。那么颜色定位是不是就是万能的?答案是否定的。在色彩充足,光照足够的情况下,颜色定位的效果很好,但是在面对光线不足的情况,或者蓝色车身的情况时,颜色定位的效果很糟糕。下图是一辆蓝色车辆,可以看出,车牌与车身内容完全重叠,无法分割。

图9 失效的颜色定位

  碰到失效的颜色定位情况时需要使用原先的Sobel定位法。

  目前的新版本使用了颜色定位与Sobel定位结合的方式。首先进行颜色定位,然后根据条件使用Sobel进行再次定位,增加整个系统的适应能力。

  为了加强鲁棒性,Sobel定位法可以用两阶段的查找。也就是在已经被Sobel定位的图块中,再进行一次Sobel定位。这样可以增加准确率,但会降低了速度。一个折衷的方案是让用户决定一个参数m_maxPlates的值,这个值决定了你在一幅图里最多定位多少车牌。系统首先用颜色定位出候选车牌,然后通过SVM模型来判断是否是车牌,最后统计数量。如果这个数量大于你设定的参数,则认为车牌已经定位足够了,不需要后一步处理,也就不会进行两阶段的Sobel查找。相反,如果这个数量不足,则继续进行Sobel定位。

  综合定位的代码位于CPlateDectec中的的成员函数plateDetectDeep中,以下是plateDetectDeep的整体流程。

图10 综合定位全部流程

  有没有颜色定位与Sobel定位都失效的情况?有的。这种情况下可能需要使用第三类定位技术--字符定位技术。这是EasyPR发展的一个方向,这里不展开讨论。

二. 偏斜扭转

  解决了颜色的定位问题以后,下面的问题是:在定位以后,我们如何把偏斜过来的车牌扭正呢?

图11 偏斜扭转效果

  这个过程叫做偏斜扭转过程。其中一个关键函数就是opencv的仿射变换函数。但在具体实施时,有很多需要解决的问题。

1.分析

  在任何新的功能开发之前,技术预研都是第一步。

  在这篇文档介绍了opencv的仿射变换功能。效果见下图。

图12 仿射变换效果 

  仔细看下,貌似这个功能跟我们的需求很相似。我们的偏斜扭转功能,说白了,就是把对图像的观察视角进行了一个转换。

  不过这篇文章里的代码基本来自于另一篇官方文档。官方文档里还有一个例子,可以矩形扭转成平行四边形。而我们的需求正是将平行四边形的车牌扭正成矩形。这么说来,只要使用例子中对应的反函数,应该就可以实现我们的需求。从这个角度来看,偏斜扭转功可以实现。确定了可行性以后,下一步就是思考如何实现。

  在原先的版本中,我们对定位出来的区域会进行一次角度判断,当角度小于某个阈值(默认30度)时就会进行全图旋转。

  这种方式有两个问题:

  一是我们的策略是对整幅图像旋转。对于opencv来说,每次旋转操作都是一个矩形的乘法过程,对于非常大的图像,这个过程是非常消耗计算资源的;

  二是30度的阈值无法处理示例图片。事实上,示例图片的定位区域的角度是-50度左右,已经大于我们的阈值了。为了处理这样的图片,我们需要把我们的阈值增大,例如增加到60度,那么这样的结果是带来候选区域的增多。

  两个因素结合,会大幅度增加处理时间。为了不让处理速度下降,必须想办法规避这些影响。

  一个方法是不再使用全图旋转,而是区域旋转。其实我们在获取定位区域后,我们并不需要定位区域以外的图像。

  倘若我们能划出一块小的区域包围定位区域,然后我们仅对定位区域进行旋转,那么计算量就会大幅度降低。而这点,在opencv里是可以实现的,我们对定位区域RotatedRect用boundingRect()方法获取外接矩形,再使用Mat(Rect ...)方法截取这个区域图块,从而生成一个小的区域图像。于是下面的所有旋转等操作都可以基于这个区域图像进行。

  在这些设计决定以后,下面就来思考整个功能的架构。

  我们要解决的问题包括三类,第一类是正的车牌,第二类是倾斜的车牌,第三类是偏斜的车牌。前两类是前面说过的,第三类是本次新增的功能需求。第二类倾斜车牌与第三类车牌的区别见下图。

图13 两类不同的旋转

  通过上图可以看出,正视角的旋转图片的观察角度仍然是正方向的,只是由于路的不平或者摄像机的倾斜等原因,导致矩形有一定倾斜。这类图块的特点就是在RotataedRect内部,车牌部分仍然是个矩形。偏斜视角的图片的观察角度是非正方向的,是从侧面去看车牌。这类图块的特点是在 RotataedRect内部,车牌部分不再是个矩形,而是一个平行四边形。这个特性决定了我们需要区别的对待这两类图片。

  一个初步的处理思路就是下图。

图14 分析实现流程

  简单来说,整个处理流程包括下面四步:

  1.感兴趣区域的截取  2.角度判断  3.偏斜判断  4.仿射变换 

  接下来按照这四个步骤依次介绍。

2.ROI截取

  如果要使用区域旋转,首先我们必须从原图中截取出一个包含定位区域的图块。

  opencv提供了一个从图像中截取感兴趣区域ROI的方法,也就是Mat(Rect ...)。这个方法会在Rect所在的位置,截取原图中一个图块,然后将其赋值到一个新的Mat图像里。遗憾的是这个方法不支持 RotataedRect,同时Rect与RotataedRect也没有继承关系。因此布不能直接调用这个方法。

  我们可以使用RotataedRect的boudingRect()方法。这个方法会返回一个RotataedRect的最小外接矩形,而且这个矩形是一个Rect。因此将这个Rect传递给Mat(Rect...)方法就可以截取出原图的ROI图块,并获得对应的ROI图像。

  需要注意的是,ROI图块和ROI图像的区别,当我们给定原图以及一个Rect时,原图中被Rect包围的区域称为ROI图块,此时图块里的坐标仍然是原图的坐标。当这个图块里的内容被拷贝到一个新的Mat里时,我们称这个新Mat为ROI图像。ROI图像里仅仅只包含原来图块里的内容,跟原图没有任何关系。所以图块和图像虽然显示的内容一样,但坐标系已经发生了改变。在从ROI图块到ROI图像以后,点的坐标要计算一个偏移量。

  下一步的工作中可以仅对这个ROI图像进行处理,包括对其旋转或者变换等操作。

  示例图片中的截取出来的ROI图像如下图:

图15 截取后的ROI图像

  在截取中可能会发生一个问题。如果直接使用boundingRect()函数的话,在运行过程中会经常发生这样的异常。OpenCV Error: Assertion failed (0 <= roi.x && 0 <= roi.width && roi.x + roi.width <= m.cols && 0 <= roi.y && 0 <= roi.height && roi.y + roi.height <= m.rows) incv::Mat::Mat,如下图。

图16 不安全的外接矩形函数会抛出异常

  这个异常产生的原因在于,在opencv2.4.8中(不清楚opencv其他版本是否没有这个问题),boundingRect()函数计算出的Rect的四个点的坐标没有做验证。这意味着你计算一个RotataedRect的最小外接矩形Rect时,它可能会给你一个负坐标,或者是一个超过原图片外界的坐标。于是当你把Rect作为参数传递给Mat(Rect ...)的话,它会提示你所要截取的Rect中的坐标越界了!

  解决方案是实现一个安全的计算最小外接矩形Rect的函数,在boundingRect()结果之上,对角点坐标进行一次判断,如果值为负数,就置为0,如果值超过了原始Mat的rows或cols,就置为原始Mat的这些rows或cols。

  这个安全函数名为calcSafeRect(...),下面是这个函数的代码。

 View Code

3.扩大化旋转

  好,当我通过calcSafeRect(...)获取了一个安全的Rect,然后通过Mat(Rect ...)函数截取了这个感兴趣图像ROI以后。下面的工作就是对这个新的ROI图像进行操作。

  首先是判断这个ROI图像是否要旋转。为了降低工作量,我们不对角度在-5度到5度区间的ROI进行旋转(注意这里讲的角度针对的生成ROI的RotataedRect,ROI本身是水平的)。因为这么小的角度对于SVM判断以及字符识别来说,都是没有影响的。

  对其他的角度我们需要对ROI进行旋转。当我们对ROI进行旋转以后,接着把转正后的RotataedRect部分从ROI中截取出来。

  但很快我们就会碰到一个新问题。让我们看一下下图,为什么我们截取出来的车牌区域最左边的“川”字和右边的“2”字发生了形变?为了搞清这个原因,作者仔细地研究了旋转与截取函数,但很快发现了形变的根源在于旋转后的ROI图像。

  仔细看一下旋转后的ROI图像,是否左右两侧不再完整,像是被截去了一部分?

图17 旋转后图像被截断

  要想理解这个问题,需要理解opencv的旋转变换函数的特性。作为旋转变换的核心函数,affinTransform会要求你输出一个旋转矩阵给它。这很简单,因为我们只需要给它一个旋转中心点以及角度,它就能计算出我们想要的旋转矩阵。旋转矩阵的获得是通过如下的函数得到的:

  Mat rot_mat = getRotationMatrix2D(new_center, angle, 1);

  在获取了旋转矩阵rot_mat,那么接下来就需要调用函数warpAffine来开始旋转操作。这个函数的参数包括一个目标图像、以及目标图像的Size。目标图像容易理解,大部分opencv的函数都会需要这个参数。我们只要新建一个Mat即可。那么目标图像的Size是什么?在一般的观点中,假设我们需要旋转一个图像,我们给opencv一个原始图像,以及我需要在某个旋转点对它旋转一个角度的需求,那么opencv返回一个图像给我即可,这个图像的Size或者说大小应该是opencv返回给我的,为什么要我来告诉它呢?

  你可以试着对一个正方形进行旋转,仔细看看,这个正方形的外接矩形的大小会如何变化?当旋转角度还小时,一切都还好,当角度变大时,明显我们看到的外接矩形的大小也在扩增。在这里,外接矩形被称为视框,也就是我需要旋转的正方形所需要的最小区域。随着旋转角度的变大,视框明显增大。

图18 矩形旋转后所需视框增大 

  在图像旋转完以后,有三类点会获得不同的处理,一种是有原图像对应点且在视框内的,这些点被正常显示;一类是在视框内但找不到原图像与之对应的点,这些点被置0值(显示为黑色);最后一类是有原图像与之对应的点,但不在视框内的,这些点被悲惨的抛弃。

图19 旋转后三类不同点的命运

  这就是旋转后不同三类点的命运,也就是新生成的图像中一些点呈现黑色(被置0),一些点被截断(被抛弃)的原因。如果把视框调整大点的话,就可以大幅度减少被截断点的数量。所以,为了保证旋转后的图像不被截断,因此我们需要计算一个合理的目标图像的Size,让我们的感兴趣区域得到完整的显示。

  下面的代码使用了一个极为简单的策略,它将原始图像与目标图像都进行了扩大化。首先新建一个尺寸为原始图像1.5倍的新图像,接着把原始图像映射到新图像上,于是我们得到了一个显示区域(视框)扩大化后的原始图像。显示区域扩大以后,那些在原图像中没有值的像素被置了一个初值。

  接着调用warpAffine函数,使用新图像的大小作为目标图像的大小。warpAffine函数会将新图像旋转,并用目标图像尺寸的视框去显示它。于是我们得到了一个所有感兴趣区域都被完整显示的旋转后图像。

  这样,我们再使用getRectSubPix()函数就可以获得想要的车牌区域了。

图20 扩大化旋转后图像不再被截断

  以下就是旋转函数rotation的代码。

[cpp]  view plain  copy
  1. //! 旋转操作  
  2. bool CPlateLocate::rotation(Mat& in, Mat& out, const Size rect_size, const Point2f center, const double angle)  
  3. {  
  4.     Mat in_large;  
  5.     in_large.create(in.rows*1.5, in.cols*1.5, in.type());  
  6.   
  7.     int x = in_large.cols / 2 - center.x > 0 ? in_large.cols / 2 - center.x : 0;  
  8.     int y = in_large.rows / 2 - center.y > 0 ? in_large.rows / 2 - center.y : 0;  
  9.   
  10.     int width = x + in.cols < in_large.cols ? in.cols : in_large.cols - x;  
  11.     int height = y + in.rows < in_large.rows ? in.rows : in_large.rows - y;  
  12.   
  13.     /*assert(width == in.cols); 
  14.     assert(height == in.rows);*/  
  15.   
  16.     if (width != in.cols || height != in.rows)  
  17.         return false;  
  18.   
  19.     Mat imageRoi = in_large(Rect(x, y, width, height));  
  20.     addWeighted(imageRoi, 0, in, 1, 0, imageRoi);  
  21.   
  22.     Point2f center_diff(in.cols/2, in.rows/2);  
  23.     Point2f new_center(in_large.cols / 2, in_large.rows / 2);  
  24.   
  25.     Mat rot_mat = getRotationMatrix2D(new_center, angle, 1);  
  26.   
  27.     /*imshow("in_copy", in_large); 
  28.     waitKey(0);*/  
  29.   
  30.     Mat mat_rotated;  
  31.     warpAffine(in_large, mat_rotated, rot_mat, Size(in_large.cols, in_large.rows), CV_INTER_CUBIC);  
  32.   
  33.     /*imshow("mat_rotated", mat_rotated); 
  34.     waitKey(0);*/  
  35.   
  36.     Mat img_crop;  
  37.     getRectSubPix(mat_rotated, Size(rect_size.width, rect_size.height), new_center, img_crop);  
  38.   
  39.     out = img_crop;  
  40.   
  41.     /*imshow("img_crop", img_crop); 
  42.     waitKey(0);*/  
  43.   
  44.     return true;  
  45.   
  46.       
  47. }  

4.偏斜判断

  当我们对ROI进行旋转以后,下面一步工作就是把RotataedRect部分从ROI中截取出来,这里可以使用getRectSubPix方法,这个函数可以在被旋转后的图像中截取一个正的矩形图块出来,并赋值到一个新的Mat中,称为车牌区域。

  下步工作就是分析截取后的车牌区域。车牌区域里的车牌分为正角度和偏斜角度两种。对于正的角度而言,可以看出车牌区域就是车牌,因此直接输出即可。而对于偏斜角度而言,车牌是平行四边形,与矩形的车牌区域不重合。

  如何判断一个图像中的图形是否是平行四边形?

  一种简单的思路就是对图像二值化,然后根据二值化图像进行判断。图像二值化的方法有很多种,假设我们这里使用一开始在车牌定位功能中使用的大津阈值二值化法的话,效果不会太好。因为大津阈值是自适应阈值,在完整的图像中二值出来的平行四边形可能在小的局部图像中就不再是。最好的办法是使用在前面定位模块生成后的原图的二值图像,我们通过同样的操作就可以在原图中截取一个跟车牌区域对应的二值化图像。

  下图就是一个二值化车牌区域获得的过程。

图21 二值化的车牌区域

  接下来就是对二值化车牌区域进行处理。为了判断二值化图像中白色的部分是平行四边形。一种简单的做法就是从图像中选择一些特定的行。计算在这个行中,第一个全为0的串的长度。从几何意义上来看,这就是平行四边形斜边上某个点距离外接矩形的长度。

  假设我们选择的这些行位于二值化图像高度的1/4,2/4,3/4处的话,如果是白色图形是矩形的话,这些串的大小应该是相等或者相差很小的,相反如果是平行四边形的话,那么这些串的大小应该不等,并且呈现一个递增或递减的关系。通过这种不同,我们就可以判断车牌区域里的图形,究竟是矩形还是平行四边形。

  偏斜判断的另一个重要作用就是,计算平行四边形倾斜的斜率,这个斜率值用来在下面的仿射变换中发挥作用。我们使用一个简单的公式去计算这个斜率,那就是利用上面判断过程中使用的串大小,假设二值化图像高度的1/4,2/4,3/4处对应的串的大小分别为 len1,len2,len3,车牌区域的高度为Height。一个计算斜率slope的计算公式就是:(len3-len1)/Height*2。

  Slope的直观含义见下图。

图22 slope的几何含义

  需要说明的,这个计算结果在平行四边形是右斜时是负值,而在左斜时则是正值。于是可以根据slope的正负判断平行四边形是右斜或者左斜。在实践中,会发生一些公式不能应对的情况,例如像下图这种情况,斜边的部分区域发生了内凹或者外凸现象。这种现象会导致len1,len2或者len3的计算有误,因此slope也会不准。

图23 内凹现象

  为了实现一个鲁棒性更好的计算方法,可以用(len2-len1)/Height*4与(len3-len1)/Height*2两者之间更靠近tan(angle)的值作为solpe的值(在这里,angle代表的是原来RotataedRect的角度)。

  多采取了一个slope备选的好处是可以避免单点的内凹或者外凸,但这仍然不是最好的解决方案。在最后的讨论中会介绍一个其他的实现思路。

  完成偏斜判断与斜率计算的函数是isdeflection,下面是它的代码。

[cpp]  view plain  copy
  1. //! 是否偏斜  
  2. //! 输入二值化图像,输出判断结果  
  3. bool CPlateLocate::isdeflection(const Mat& in, const double angle, double& slope)  
  4. {  
  5.     int nRows = in.rows;  
  6.     int nCols = in.cols;  
  7.   
  8.     assert(in.channels() == 1);  
  9.   
  10.     int comp_index[3];  
  11.     int len[3];  
  12.   
  13.     comp_index[0] = nRows / 4;  
  14.     comp_index[1] = nRows / 4 * 2;  
  15.     comp_index[2] = nRows / 4 * 3;  
  16.   
  17.     const uchar* p;  
  18.       
  19.     for (int i = 0; i < 3; i++)  
  20.     {  
  21.         int index = comp_index[i];  
  22.         p = in.ptr<uchar>(index);  
  23.   
  24.         int j = 0;  
  25.         int value = 0;  
  26.         while (0 == value && j < nCols)  
  27.             value = int(p[j++]);  
  28.   
  29.         len[i] = j;  
  30.     }  
  31.   
  32.     //cout << "len[0]:" << len[0] << endl;  
  33.     //cout << "len[1]:" << len[1] << endl;  
  34.     //cout << "len[2]:" << len[2] << endl;  
  35.       
  36.     double maxlen = max(len[2], len[0]);  
  37.     double minlen = min(len[2], len[0]);  
  38.     double difflen = abs(len[2] - len[0]);  
  39.     //cout << "nCols:" << nCols << endl;  
  40.   
  41.     double PI = 3.14159265;  
  42.     double g = tan(angle * PI / 180.0);  
  43.   
  44.     if (maxlen - len[1] > nCols/32 || len[1] - minlen > nCols/32 ) {  
  45.         // 如果斜率为正,则底部在下,反之在上  
  46.         double slope_can_1 = double(len[2] - len[0]) / double(comp_index[1]);  
  47.         double slope_can_2 = double(len[1] - len[0]) / double(comp_index[0]);  
  48.         double slope_can_3 = double(len[2] - len[1]) / double(comp_index[0]);  
  49.   
  50.         /*cout << "slope_can_1:" << slope_can_1 << endl; 
  51.         cout << "slope_can_2:" << slope_can_2 << endl; 
  52.         cout << "slope_can_3:" << slope_can_3 << endl;*/  
  53.    
  54.         slope = abs(slope_can_1 - g) <= abs(slope_can_2 - g) ? slope_can_1 : slope_can_2;  
  55.   
  56.         /*slope = max(  double(len[2] - len[0]) / double(comp_index[1]), 
  57.             double(len[1] - len[0]) / double(comp_index[0]));*/  
  58.           
  59.         //cout << "slope:" << slope << endl;  
  60.         return true;  
  61.     }  
  62.     else {  
  63.         slope = 0;  
  64.     }  
  65.   
  66.     return false;  
  67. }  

5.仿射变换

  俗话说:行百里者半九十。前面已经做了如此多的工作,应该可以实现偏斜扭转功能了吧?但在最后的道路中,仍然有问题等着我们。

  我们已经实现了旋转功能,并且在旋转后的区域中截取了车牌区域,然后判断车牌区域中的图形是一个平行四边形。下面要做的工作就是把平行四边形扭正成一个矩形。

图24 从平行四边形车牌到矩形车牌

  首先第一个问题就是解决如何从平行四边形变换成一个矩形的问题。opencv提供了一个函数warpAffine,就是仿射变换函数。注意,warpAffine不仅可以让图像旋转(前面介绍过),也可以进行仿射变换,真是一个多才多艺的函数。o

  通过仿射变换函数可以把任意的矩形拉伸成其他的平行四边形。opencv的官方文档里给了一个示例,值得注意的是,这个示例演示的是把矩形变换为平行四边形,跟我们想要的恰恰相反。但没关系,我们先看一下它的使用方法。

图25 opencv官网上对warpAffine使用的示例

  warpAffine方法要求输入的参数是原始图像的左上点,右上点,左下点,以及输出图像的左上点,右上点,左下点。注意,必须保证这些点的对应顺序,否则仿射的效果跟你预想的不一样。通过这个方法介绍,我们可以大概看出,opencv需要的是三个点对(共六个点)的坐标,然后建立一个映射关系,通过这个映射关系将原始图像的所有点映射到目标图像上。 

图26 warpAffine需要的三个对应坐标点

  再回来看一下我们的需求,我们的目标是把车牌区域中的平行四边形映射为一个矩形。让我们做个假设,如果我们选取了车牌区域中的平行四边形车牌的三个关键点,然后再确定了我们希望将车牌扭正成的矩形的三个关键点的话,我们是否就可以实现从平行四边形车牌到矩形车牌的扭正?

  让我们画一幅图像来看看这个变换的作用。有趣的是,把一个平行四边形变换为矩形会对包围平行四边形车牌的区域带来影响。

  例如下图中,蓝色的实线代表扭转前的平行四边形车牌,虚线代表扭转后的。黑色的实线代表矩形的车牌区域,虚线代表扭转后的效果。可以看到,当蓝色车牌被扭转为矩形的同时,黑色车牌区域则被扭转为平行四边形。

  注意,当车牌区域扭变为平行四边形以后,需要显示它的视框增大了。跟我们在旋转图像时碰到的情形一样。

图27 平行四边形的扭转带来的变化

  让我们先实际尝试一下仿射变换吧。
  根据仿射函数的需要,我们计算平行四边形车牌的三个关键点坐标。其中左上点的值(xdiff,0)中的xdiff就是根据车牌区域的高度height与平行四边形的斜率slope计算得到的:

xidff = Height * abs(slope)

  为了计算目标矩形的三个关键点坐标,我们首先需要把扭转后的原点坐标调整到平行四边形车牌区域左上角位置。见下图。

图28 原图像的坐标计算

  依次推算关键点的三个坐标。它们应该是

[cpp]  view plain  copy
  1. plTri[0] = Point2f(0 + xiff, 0);  
  2. plTri[1] = Point2f(width - 1, 0);  
  3. plTri[2] = Point2f(0, height - 1);  
  4.   
  5. dstTri[0] = Point2f(xiff, 0);  
  6. dstTri[1] = Point2f(width - 1, 0);  
  7. dstTri[2] = Point2f(xiff, height - 1);  

  根据上图的坐标,我们开始进行一次仿射变换的尝试。

  opencv的warpAffine函数不会改变变换后图像的大小。而我们给它传递的目标图像的大小仅会决定视框的大小。不过这次我们不用担心视框的大小,因为根据图27看来,哪怕视框跟原始图像一样大,我们也足够显示扭正后的车牌。

  看看仿射的效果。晕,好像效果不对,视框的大小是足够了,但是图像往右偏了一些,导致最右边的字母没有显示全。

图29 被偏移的车牌区域

  这次的问题不再是目标图像的大小问题了,而是视框的偏移问题。仔细观察一下我们的视框,倘若我们想把车牌全部显示的话,视框往右偏移一段距离,是不是就可以解决这个问题呢?为保证新的视框中心能够正好与车牌的中心重合,我们可以选择偏移xidff/2长度。正如下图所显示的一样。

图30 考虑偏移的坐标计算

  视框往右偏移的含义就是目标图像Mat的原点往右偏移。如果原点偏移的话,那么仿射后图像的三个关键点的坐标要重新计算,都需要减去xidff/2大小。

  重新计算的映射点坐标为下:

[cpp]  view plain  copy
  1. plTri[0] = Point2f(0 + xiff, 0);  
  2. plTri[1] = Point2f(width - 1, 0);  
  3. plTri[2] = Point2f(0, height - 1);  
  4.   
  5. dstTri[0] = Point2f(xiff/2, 0);  
  6. dstTri[1] = Point2f(width - 1 - xiff + xiff/2, 0);  
  7. dstTri[2] = Point2f(xiff/2, height - 1);  

  再试一次。果然,视框被调整到我们希望的地方了,我们可以看到所有的车牌区域了。这次解决的是warpAffine函数带来的视框偏移问题。

图31 完整的车牌区域

  关于坐标调整的另一个理解就是当中心点保持不变时,平行四边形扭正为矩形时恰好是左上的点往左偏移了xdiff/2的距离,左下的点往右偏移了xdiff/2的距离,形成一种对称的平移。可以使用ps或者inkspace类似的矢量制图软件看看“斜切”的效果, 

  如此一来,就完成了偏斜扭正的过程。需要注意的是,向左倾斜的车牌的视框偏移方向与向右倾斜的车牌是相反的。我们可以用slope的正负来判断车牌是左斜还是右斜。

6.总结

  通过以上过程,我们成功的将一个偏斜的车牌经过旋转变换等方法扭正过来。

  让我们回顾一下偏斜扭正过程。我们需要将一个偏斜的车牌扭正,为了达成这个目的我们首先需要对图像进行旋转。因为旋转是个计算量很大的函数,所以我们需要考虑不再用全图旋转,而是区域旋转。在旋转过程中,会发生图像截断问题,所以需要使用扩大化旋转方法。旋转以后,只有偏斜视角的车牌才需要扭正,正视角的车牌不需要,因此还需要一个偏斜判断过程。如此一来,偏斜扭正的过程需要旋转,区域截取,扩大化,偏斜判断等等过程的协助,这就是整个流程中有这么多步需要处理的原因。

  下图从另一个视角回顾了偏斜扭正的过程,主要说明了偏斜扭转中的两次“截取”过程。

图32 偏斜扭正全过程

  1. 首先我们获取RotatedRect,然后对每个RotatedRect获取外界矩形,也就是ROI区域。外接矩形的计算有可能获得不安全的坐标,因此需要使用安全的获取外界矩形的函数。
  2. 获取安全外接矩形以后,在原图中截取这部分区域,并放置到一个新的Mat里,称之为ROI图像。这是本过程中第一次截取,使用Mat(Rect ...)函数。
  3. 接下来对ROI图像根据RotatedRect的角度展开旋转,旋转的过程中使用了放大化旋转法,以此防止车牌区域被截断。
  4. 旋转完以后,我们把已经转正的RotatedRect部分截取出来,称之为车牌区域。这是本过程中第二次截取,与第一次不同,这次截取使用getRectSubPix()方法。
  5. 接下里使用偏斜判断函数来判断车牌区域里的车牌是否是倾斜的。
  6. 如果是,则继续使用仿射变换函数wrapAffine来进行扭正处理,处理过程中要注意三个关键点的坐标。
  7. 最后使用resize函数将车牌区域统一化为EasyPR的车牌大小。

  整个过程有一个统一的函数--deskew。下面是deskew的代码。

 View Code

  最后是改善建议:

  角度偏斜判断时可以用白色区域的轮廓来确定平行四边形的四个点,然后用这四个点来计算斜率。这样算出来的斜率的可能鲁棒性更好。

三. 总结

  本篇文档介绍了颜色定位与偏斜扭转等功能。其中颜色定位属于作者一直想做的定位方法,而偏斜扭转则是作者以前认为不可能解决的问题。这些问题现在都基本被攻克了,并在这篇文档中阐述,希望这篇文档可以帮助到读者。

  作者希望能在这片文档中不仅传递知识,也传授我在摸索过程中积累的经验。因为光知道怎么做并不能加深对车牌识别的认识,只有经历过失败,了解哪些思想尝试过,碰到了哪些问题,是如何解决的,才能帮助读者更好地认识这个系统的内涵。

  最后,作者很感谢能够阅读到这里的读者。如果看完觉得好的话,还请轻轻点一下赞,你们的鼓励就是作者继续行文的动力。

对EasyPR做下说明:EasyPR,一个开源的中文车牌识别系统,代码托管在github。其次,在前面的博客文章中,包含EasyPR至今的开发文档与介绍。在后续的文章中,作者会介绍EasyPR中字符分割与识别等相关内容,欢迎继续阅读






EasyPR--开发详解(6)SVM开发详解



在前面的几篇文章中,我们介绍了EasyPR中车牌定位模块的相关内容。本文开始分析车牌定位模块后续步骤的车牌判断模块。车牌判断模块是EasyPR中的基于机器学习模型的一个模块,这个模型就是作者前文中从机器学习谈起中提到的SVM(支持向量机)。
  我们已经知道,车牌定位模块的输出是一些候选车牌的图片。但如何从这些候选车牌图片中甄选出真正的车牌,就是通过SVM模型判断/预测得到的。

图1 从候选车牌中选出真正的车牌

  简单来说,EasyPR的车牌判断模块就是将候选车牌的图片一张张地输入到SVM模型中,然后问它,这是车牌么?如果SVM模型回答不是,那么就继续下一张,如果是,则把图片放到一个输出列表里。最后把列表输入到下一步处理。由于EasyPR使用的是列表作为输出,因此它可以输出一副图片中所有的车牌,不像一些车牌识别程序,只能输出一个车牌结果。

图2 EasyPR输出多个车牌

  现在,让我们一步步地,进入这个SVM模型的核心看看,它是如何做到判断一副图片是车牌还是不是车牌的?本文主要分为三个大的部分:

  1. SVM应用:描述如何利用SVM模型进行车牌图片的判断。
  2. SVM训练:说明如何通过一系列步骤得到SVM模型。
  3. SVM调优:讨论如何对SVM模型进行优化,使其效果更加好。

一.SVM应用

  人类是如何判断一个张图片所表达的信息呢?简单来说,人类在成长过程中,大脑记忆了无数的图像,并且依次给这些图像打上了标签,例如太阳,天空,房子,车子等等。你们还记得当年上幼儿园时的那些教科书么,上面一个太阳,下面是文字。图像的组成事实上就是许多个像素,由像素组成的这些信息被输入大脑中,然后得出这个是什么东西的回答。我们在SVM模型中一开始输入的原始信息也是图像的所有像素,然后SVM模型通过对这些像素进行分析,输出这个图片是否是车牌的结论。

图3 通过图像来学习

  SVM模型处理的是最简单的情况,它只要回答是或者不是这个“二值”问题,比从许多类中检索要简单很多。

  我们可以看一下SVM进行判断的代码:

 View Code

  首先我们读取这幅图片,然后把这幅图片转为OPENCV需要的格式;

    Mat p = histeq(inMat).reshape(1, 1);
    p.convertTo(p, CV_32FC1);

  接着调用svm的方法predict;

    int response = (int)svm.predict(p);

  perdict方法返回的值是1的话,就代表是车牌,否则就不是;

    if (response == 1)
    {
        resultVec.push_back(inMat);
    }

  svm是类CvSVM的一个对象。这个类是opencv里内置的一个机器学习类。

    CvSVM svm;

  opencv的CvSVM的实现基于libsvm(具体信息可以看opencv的官方文档的介绍 )。

  libsvm是台湾大学林智仁(Lin Chih-Jen)教授写的一个世界知名的svm库(可能算是目前业界使用率最高的一个库)。官方主页地址是这里

  libsvm的实现基于SVM这个算法,90年代初由Vapnik等人提出。国内几篇较好的解释svm原理的博文:cnblog的LeftNotEasy(解释的易懂),pluskid的博文(专业有配图)。

  作为支持向量机的发明者,Vapnik是一位机器学习界极为重要的大牛。最近这位大牛也加入了Facebook

图4 SVM之父Vapnik

  svm的perdict方法的输入是待预测数据的特征,也称之为features。在这里,我们输入的特征是图像全部的像素。由于svm要求输入的特征应该是一个向量,而Mat是与图像宽高对应的矩阵,因此在输入前我们需要使用reshape(1,1)方法把矩阵拉伸成向量。除了全部像素以外,也可以有其他的特征,具体看第三部分“SVM调优”。
  predict方法的输出是float型的值,我们需要把它转变为int型后再进行判断。如果是1代表就是车牌,否则不是。这个"1"的取值是由你在训练时输入的标签决定的。标签,又称之为label,代表某个数据的分类。如果你给 SVM模型输入一个车牌,并告诉它,这个图片的标签是5。那么你这边判断时所用的值就应该是5。
  以上就是svm模型判断的全过程。事实上,在你使用EasyPR的过程中,这些全部都是透明的。你不需要转变图片格式,也不需要调用svm模型preditct方法,这些全部由EasyPR在内部调用。
  那么,我们应该做什么?这里的关键在于CvSVM这个类。我在前面的机器学习论文中介绍过,机器学习过程的步骤就是首先你搜集大量的数据,然后把这些数据输入模型中训练,最后再把生成的模型拿出来使用。
  训练和预测两个过程是分开的。也就是说你们在使用EasyPR时用到的CvSVM类是我在先前就训练好的。我是如何把我训练好的模型交给各位使用的呢?CvSVM类有个方法,把训练好的结果以xml文件的形式存储,我就是把这个xml文件随EasyPR发布,并让程序在执行前先加载好这个xml。这个xml的位置就是在文件夹Model下面--svm.xml文件。

图5 model文件夹下的svm.xml

  如果看CPlateJudge的代码,在构造函数中调用了LoadModel()这个方法。

CPlateJudge::CPlateJudge()
{
    //cout << "CPlateJudge" << endl;
    m_path = "model/svm.xml";
    LoadModel();
}

  LoadModel()方法的主要任务就是装载model文件夹下svm.xml这个模型。

void CPlateJudge::LoadModel()
{
    svm.clear();
    svm.load(m_path.c_str(), "svm");
}

  如果你把这个xml文件换成其他的,那么你就可以改变EasyPR车牌判断的内核,从而实现你自己的车牌判断模块。
  后面的部分全部是告诉你如何有效地实现一个自己的模型(也就是svm.xml文件)。如果你对EasyPR的需求仅仅在应用层面,那么到目前的了解就足够了。如果你希望能够改善EasyPR的效果,定制一个自己的车牌判断模块,那么请继续往下看。

二.SVM训练
  恭喜你!从现在开始起,你将真正踏入机器学习这个神秘并且充满未知的领域。至今为止,机器学习很多方法的背后原理都非常复杂,但众多的实践都证明了其有效性。与许多其他学科不同,机器学习界更为关注的是最终方法的效果,也就是偏重以实践效果作为评判标准。因此非常适合从工程的角度入手,通过自己动手实践一个项目里来学习,然后再转入理论。这个过程已经被证明是有效的,本文的作者在开发EasyPR的时候,还没有任何机器学习的理论基础。后来的知识是将通过学习相关课程后获取的。
  简而言之,SVM训练部分的目标就是通过一批数据,然后生成一个代表我们模型的xml文件。

  EasyPR中所有关于训练的方法都可以在svm_train.cpp中找到(1.0版位于train/code文件夹下,1.1版位于src/train文件夹下)。

  一个训练过程包含5个步骤,见下图:

图6 一个完整的SVM训练流程

  下面具体讲解一下这5个步骤,步骤后面的括号里代表的是这个步骤主要的输入与输出。
1. preprocss(原始数据->学习数据(未标签))

  预处理步骤主要处理的是原始数据到学习数据的转换过程。原始数据(raw data),表示你一开始拿到的数据。这些数据的情况是取决你具体的环境的,可能有各种问题。学习数据(learn data),是可以被输入到模型的数据。

  为了能够进入模型训练,必须将原始数据处理为学习数据,同时也可能进行了数据的筛选。比方说你有10000张原始图片,出于性能考虑,你只想用 1000张图片训练,那么你的预处理过程就是将这10000张处理为符合训练要求的1000张。你生成的1000张图片中应该包含两类数据:真正的车牌图片和不是车牌的图片。如果你想让你的模型能够区分这两种类型。你就必须给它输入这两类的数据。

  通过EasyPR的车牌定位模块PlateLocate可以生成大量的候选车牌图片,里面包括模型需要的车牌和非车牌图片。但这些候选车牌是没有经过分类的,也就是说没有标签。下步工作就是给这些数据贴上标签。
2. label (学习数据(未标签)->学习数据)

  训练过程的第二步就是将未贴标签的数据转化为贴过标签的学习数据。我们所要做的工作只是将车牌图片放到一个文件夹里,非车牌图片放到另一个文件夹里。在EasyPR里,这两个文件夹分别叫做HasPlate和NoPlate。如果你打开train/data/plate_detect_svm 后,你就会看到这两个压缩包,解压后就是打好标签的数据(1.1版本在同层learn data文件夹下面)。
  如果有人问我开发一个机器学习系统最耗时的步骤是哪个,我会毫不犹豫的回答:“贴标签”。诚然,各位看到的压缩包里已经有打好标签的数据了。但各位可能不知道作者花在贴这些标签上的时间。粗略估计,整个EasyPR开发过程中有70%的时间都在贴标签。SVM模型还好,只有两个类,训练数据仅有1000张。到了ANN模型那里,字符的类数有40多个,而且训练数据有4000张左右。那时候的贴标签过程,真是不堪回首的回忆,来回移动文件导致作者手经常性的非常酸。后来我一度想找个实习生帮我做这些工作。但转念一想,这些苦我都不愿承担,何苦还要那些小伙子承担呢。“己所不欲,勿施于人”。算了,既然这是机器学习者的命,那就欣然接受吧。幸好在这段磨砺的时光,我逐渐掌握了一个方法,大幅度减少了我贴标签的时间与精力。不然,我可能还未开始写这个系列的教程,就已经累吐血了。开发EasyPR1.1版本时,新增了一大批数据,因此又有了贴标签的过程。幸好使用这个方法,使得相关时间大幅度减少。这个方法叫做逐次迭代自动标签法。在后面会介绍这个方法。

  贴标签后的车牌数据如下图:

图7 在HasPlate文件夹下的图片

  贴标签后的非车牌数据下图:

图8 在NoPlate文件夹下的图片

  拥有了贴好标签的数据以后,下面的步骤是分组,也称之为divide过程。
3. divide (学习数据->分组数据)

  分组这个过程是EasyPR1.1版新引入的方法。

  在贴完标签以后,我拥有了车牌图片和非车牌图片共几千张。在我直接训练前,不急。先拿出30%的数据,只用剩下的70%数据进行SVM模型的训练,训练好的模型再用这30%数据进行一个效果测试。这30%数据充当的作用就是一个评判数据测试集,称之为test data,另70%数据称之为train data。于是一个完整的learn data被分为了train data和test data。

图9 数据分组过程

  在EasyPR1.0版是没有test data概念的,所有数据都输入训练,然后直接在原始的数据上进行测试。直接在原始的数据集上测试与单独划分出30%的数据测试效果究竟有多少不同?

事实上,我们训练出模型的根本目的是为了对未知的,新的数据进行预测与判断。

  当使用训练的数据进行测试时,由于模型已经考虑到了训练数据的特征,因此很难将这个测试效果推广到其他未知数据上。如果使用单独的测试集进行验证,由于测试数据集跟模型的生成没有关联,因此可以很好的反映出模型推广到其他场景下的效果。这个过程就可以简单描述为你不可以拿你给学生的复习提纲卷去考学生,而是应该出一份考察知识点一样,但题目不一样的卷子。前者的方式无法区分出真正学会的人和死记硬背的人,而后者就能有效地反映出哪些人才是真正“学会”的。
  在divide的过程中,注意无论在train data和test data中都要保持数据的标签,也就是说车牌数据仍然归到HasPlate文件夹,非车牌数据归到NoPlate文件夹。于是,车牌图片30%归到 test data下面的hasplate文件夹,70%归到train data下面的hasplate文件夹,非车牌图片30%归到test data下面的noplate文件夹,70%归到train data下面的noplate文件夹。于是在文件夹train 和 test下面又有两个子文件夹,他们的结构树就是下图:

图10 分组后的文件树

  divide数据结束以后,我们就可以进入真正的机器学习过程。也就是对数据的训练过程。
4. train (训练数据->模型)

  模型在代码里的代表就是CvSVM类。在这一步中所要做的就是加载train data,然后用CvSVM类的train方法进行训练。这个步骤只针对的是上步中生成的总数据70%的训练数据。

  具体来说,分为以下几个子步骤:
  1) 加载待训练的车牌数据。见下面这段代码。


[cpp]  view plain  copy
  1. void getPlate(Mat& trainingImages, vector<int>& trainingLabels)  
  2. {  
  3.   
  4.     char * filePath = "train/data/plate_detect_svm/HasPlate/HasPlate";  
  5.     vector<string> files;  
  6.   
  7.     getFiles(filePath, files );  
  8.   
  9.     int size = files.size();  
  10.     if (0 == size)  
  11.         cout << "No File Found in train HasPlate!" << endl;  
  12.   
  13.     for (int i = 0;i < size;i++)  
  14.     {  
  15.         cout << files[i].c_str() << endl;  
  16.         Mat img = imread(files[i].c_str());  
  17.   
  18.         img= img.reshape(1, 1);  
  19.                 trainingImages.push_back(img);  
  20.                 trainingLabels.push_back(1);  
  21.     }  
  22. }   


  注意看,车牌图像我存储在的是一个vector<Mat>中,而标签数据我存储在的是一个vector<int>中。我将train/HasPlate中的图像依次取出来,存入vector<Mat>。每存入一个图像,同时也往 vector<int>中存入一个int值1,也就是说图像和标签分别存在不同的vector对象里,但是保持一一对应的关系。
  2) 加载待训练的非车牌数据,见下面这段代码中的函数。基本内容与加载车牌数据类似,不同之处在于文件夹是train/NoPlate,并且我往vector<int>中存入的是int值0,代表无车牌。



void getNoPlate(Mat& trainingImages, vector<int>& trainingLabels)
{

    char * filePath = "train/data/plate_detect_svm/NoPlate/NoPlate";
    vector<string> files;

    getFiles(filePath, files );
    int size = files.size();
    if (0 == size)
        cout << "No File Found in train NoPlate!" << endl;

    for (int i = 0;i < size;i++)
    {
        cout << files[i].c_str() << endl;
        Mat img = imread(files[i].c_str());
        
        img= img.reshape(1, 1);
                trainingImages.push_back(img);
                trainingLabels.push_back(0);
    }
}


  3) 将两者合并。目前拥有了两个vector<Mat>和两个vector<int>。将代表车牌图片和非车牌图片数据的两个 vector<Mat>组成一个新的Mat--trainingData,而代表车牌图片与非车牌图片标签的两个 vector<int>组成另一个Mat--classes。接着做一些数据类型的调整,以让其符合svm训练函数train的要求。这些做完后,数据的准备工作基本结束,下面就是参数配置的工作。


    Mat classes;//(numPlates+numNoPlates, 1, CV_32FC1);
    Mat trainingData;//(numPlates+numNoPlates, imageWidth*imageHeight, CV_32FC1 );

    Mat trainingImages;
    vector<int> trainingLabels;

    getPlate(trainingImages, trainingLabels);
    getNoPlate(trainingImages, trainingLabels);

    Mat(trainingImages).copyTo(trainingData);
    trainingData.convertTo(trainingData, CV_32FC1);
    Mat(trainingLabels).copyTo(classes);


  4) 配置SVM模型的训练参数。SVM模型的训练需要一个CvSVMParams的对象,这个类是SVM模型中训练对象的参数的组合,如何给这里的参数赋值,是很有讲究的一个工作。注意,这里是SVM训练的核心内容,也是最能体现一个机器学习专家和新手区别的地方。机器学习最后模型的效果差异有很大因素取决与模型训练时的参数,尤其是SVM,有非常多的参数供你配置(见下面的代码)。参数众多是一个问题,更为显著的是,机器学习模型中参数的一点微调都可能带来最终结果的巨大差异。


    CvSVMParams SVM_params;
    SVM_params.svm_type = CvSVM::C_SVC;
    SVM_params.kernel_type = CvSVM::LINEAR; //CvSVM::LINEAR;
    SVM_params.degree = 0;
    SVM_params.gamma = 1;
    SVM_params.coef0 = 0;
    SVM_params.C = 1;
    SVM_params.nu = 0;
    SVM_params.p = 0;
    SVM_params.term_crit = cvTermCriteria(CV_TERMCRIT_ITER, 1000, 0.01);


  opencv官网文档对CvSVMParams类的各个参数有一个详细的解释。如果你上过SVM课程的理论部分,你可能对这些参数的意思能搞的明白。但在这里,我们可以不去管参数的含义,因为我们有更好的方法去解决这个问题。

图11 SVM各参数的作用

  这个原因在于:EasyPR1.0使用的是liner核,也称之为线型核,因此degree和gamma还有coef0三个参数没有作用。同时,在这里SVM模型用作的问题是分类问题,那么nu和p两个参数也没有影响。最后唯一能影响的参数只有Cvalue。到了EasyPR1.1版本以后,默认使用的是RBF核,因此需要调整的参数多了一个gamma。

  以上参数的选择都可以用自动训练(train_auto)的方法去解决,在下面的SVM调优部分会具体介绍train_auto。

  5) 开始训练。OK!数据载入完毕,参数配置结束,一切准备就绪,下面就是交给opencv的时间。我们只要将前面的 trainingData,classes,以及CvSVMParams的对象SVM_params交给CvSVM类的train函数就可以。另外,直接使用CvSVM的构造函数,也可以完成训练过程。例如下面这行代码:

    CvSVM svm(trainingData, classes, Mat(), Mat(), SVM_params);

  训练开始后,慢慢等一会。机器学习中数据训练的计算量往往是非常大的,即便现代计算机也要运行很长时间。具体的时间取决于你训练的数据量的大小以及模型的复杂度。在我的2.0GHz的机器上,训练1000条数据的SVM模型的时间大约在1分钟左右。
  训练完成以后,我们就可以用CvSVM类的对象svm去进行预测了。如果我们仅仅需要这个模型,现在可以把它存到xml文件里,留待下次使用:

    FileStorage fsTo("train/svm.xml", cv::FileStorage::WRITE);
    svm.write(*fsTo, "svm");

  5. test (测试数据->评判指标)

  记得我们还有30%的测试数据了么?现在是使用它们的时候了。将这些数据以及它们的标签加载如内存,这个过程与加载训练数据的过程是一样的。接着使用我们训练好的SVM模型去判断这些图片。
  下面的步骤是对我们的模型做指标评判的过程。首先,测试数据是有标签的数据,这意味着我们知道每张图片是车牌还是不是车牌。另外,用新生成的svm模型对数据进行判断,也会生成一个标签,叫做“预测标签”。“预测标签”与“标签”一般是存在误差的,这也就是模型的误差。这种误差有两种情况:1.这副图片是真的车牌,但是svm模型判断它是“非车牌”;2.这幅图片不是车牌,但svm模型判断它是“车牌”。无疑,这两种情况都属于svm模型判断失误的情况。我们需要设计出来两个指标,来分别评测这两种失误情况发生的概率。这两个指标就是下面要说的“准确率”(precision)和“查全率” (recall)。

  准确率是统计在我已经预测为车牌的图片中,真正车牌数据所占的比例。假设我们用ptrue_rtrue表示预测(p)为车牌并且实际(r)为车牌的数量,而用ptrue_rfalse表示实际不为车牌的数量。

  准确率的计算公式是:

图12 precise 准确率

  查全率是统计真正的车牌图片中,我预测为车牌的图片所占的比例。同上,我们用ptrue_rtrue表示预测与实际都为车牌的数量。用pfalse_rtrue表示实际为车牌,但我预测为非车牌的数量。

  查全率的计算公式是:

图13 recall 查全率

  recall的公式与precision公式唯一的区别在于右下角。precision是ptrue_rfalse,代表预测为车牌但实际不是的数量;而recall是pfalse_rtrue,代表预测是非车牌但其实是车牌的数量。

  简单来说,precision指标的期望含义就是要“查的准”,recall的期望含义就是“不要漏”。查全率还有一个翻译叫做“召回率”。但很明显,召回这个词没有反映出查全率所体现出的不要漏的含义。

  值得说明的是,precise和recall这两个值自然是越高越好。但是如果一个高,一个低的话效果会如何,如何跟两个都中等的情况进行比较?为了能够数字化这种比较。机器学习界又引入了FScore这个数值。当precise和recall两者中任一者较高,而另一者较低是,FScore 都会较低。两者中等的情况下Fscore表现比一高一低要好。当两者都很高时,FScore会很高。

  FScore的计算公式如下图:

图14 Fscore计算公式

  模型测试以及评价指标是EasyPR1.1中新增的功能。在svm_train.cpp的最下面可以看到这三个指标的计算过程。

  训练心得

  通过以上5个步骤,我们就完成了模型的准备,训练,测试的全部过程。下面,说一说过程中的几点心得。
  1. 完善EasyPR的plateLocate功能

  在1.1版本中的EasyPR的车牌定位模块仍然不够完善。如果你的所有的图片符合某种通用的模式,参照前面的车牌定位的几篇教程,以及使用EasyPR新增的Debug模式,你可以将EasyPR的plateLocate模块改造为适合你的情况。于是,你就可以利用EasyPR为你制造大量的学习数据。通过原始数据的输入,然后通过plateLocate进行定位,再使用EasyPR已有的车牌判断模块进行图片的分类,于是你就可以得到一个基本分好类的学习数据。下面所需要做的就是人工核对,确认一下,保证每张图片的标签是正确的,然后再输入模型进行训练。

  2. 使用“逐次迭代自动标签法”。

  上面讨论的贴标签方法是在EasyPR已经提供了一个训练好的模型的情况下。如果一开始手上任何模型都没有,该怎么办?假设目前手里有成千上万个通过定位出来的各种候选车牌,手工一个个贴标签的话,岂不会让人累吐血?在前文中说过,我在一开始贴标签过程中碰到了这个问题,在不断被折磨与痛苦中,我发现了一个好方法,大幅度减轻了这整个工作的痛苦性。

  当然,这个方法很简单。我如果说出来你一定也不觉得有什么奇妙的。但是如果在你准备对1000张图片进行手工贴标签时,相信我,使用这个方法会让你最后的时间节省一半。如果你需要雇10个人来贴标签的话,那么用了这个方法,可能你最后一个人都不用雇。

  这个方法被我称为“逐次迭代自动标签法”。

  方法核心很简单。就是假设你有3000张未分类的图片。你从中选出1%,也就是30张出来,手工给它们每个图片进行分类工作。好的,如今你有了 30张贴好标签的数据了,下步你把它直接输入到SVM模型中训练,获得了一个简单粗旷的模型。之后,你从图片集中再取出3%的图片,也就是90张,然后用刚训练好的模型对这些图片进行预测,根据预测结果将它们自动分到hasplate和noplate文件夹下面。分完以后,你到这两个文件夹下面,看看哪些是预测错的,把hasplate里预测错的移动到noplate里,反之,把noplate里预测错的移动到hasplate里。

  接着,你把一开始手工分类好的那30张图片,结合调整分类的90张图片,总共120张图片再输入svm模型中进行训练。于是你获得一个比最开始粗旷模型更精准点的模型。然后,你从3000张图片中再取出6%的图片来,用这个模型再对它们进行预测,分类....

  以上反复。你每训练出一个新模型,用它来预测后面更多的数据,然后自动分类。这样做最大的好处就是你只需要移动那些被分类错误的图片。其他的图片已经被正 确的归类了。注意,在整个过程中,你每次只需要对新拿出的数据进行人工确认,因为前面的数据已经分好类了。因此,你最好使用两个文件夹,一个是已经分好类 的数据,另一个是自动分类数据,需要手工确认的。这样两者不容易乱。

  每次从未标签的原始数据库中取出的数据不要多,最好不要超过上次数据的两倍。这样可以保证你的模型的准确率稳步上升。如果想一口吃个大胖子,例如用30张图片训练出的模型,去预测1000张数据,那最后结果跟你手工分类没有任何区别了。

  整个方法的原理很简单,就是不断迭代循环细化的思想。跟软件工程中迭代开发过程有异曲同工之妙。你只要理解了其原理,很容易就可以复用在任何其他机器学习模型的训练中,从而大幅度(或者部分)减轻机器学习过程中贴标签的巨大负担。

  回到一个核心问题,对于开发者而言,什么样的方法才是自己实现一个svm.xml的最好方法。有以下几种选择。

  1.你使用EasyPR提供的svm.xml,这个方式等同于你没有训练,那么EasyPR识别的效率取决于你的环境与EasyPR的匹配度。运气好的话,这个效果也会不错。但如果你的环境下车牌跟EasyPR默认的不一样。那么可能就会有点问题。

  2.使用EasyPR提供的训练数据,例如train/data文件下的数据,这样生成的效果等同于第一步的,不过你可以调整参数,试试看模型的表现会不会更好一点。

  3.使用自己的数据进行训练。这个方法的适应性最好。首先你得准备你原始的数据,并且写一个处理方法,能够将原始数据转化为学习数据。下面你调用EasyPR的PlateLocate方法进行处理,将候选车牌图片从原图片截取出来。你可以使用逐次迭代自动标签思想,使用EasyPR已有的svm 模型对这些候选图片进行预标签。然后再进行肉眼确认和手工调整,以生成标准的贴好标签的数据。后面的步骤就可以按照分组,训练,测试等过程顺次走下去。如果你使用了EasyPR1.1版本,后面的这几个过程已经帮你实现好代码了,你甚至可以直接在命令行选择操作。
  以上就是SVM模型训练的部分,通过这个步骤的学习,你知道如何通过已有的数据去训练出一个自己的模型。下面的部分,是对这个训练过程的一个思考,讨论通过何种方法可以改善我最后模型的效果。

三.SVM调优
  SVM调优部分,是通过对SVM的原理进行了解,并运用机器学习的一些调优策略进行优化的步骤。

  在这个部分里,最好要懂一点机器学习的知识。同时,本部分也会讲的尽量通俗易懂,让人不会有理解上的负担。在EasyPR1.0版本中,SVM 模型的代码完全参考了mastering opencv书里的实现思路。从1.1版本开始,EasyPR对车牌判断模块进行了优化,使得模型最后的效果有了较大的改善。
  具体说来,本部分主要包括如下几个子部分:1.RBF核;2.参数调优;3.特征提取;4.接口函数;5.自动化。

  下面分别对这几个子部分展开介绍。
1.RBF核
  SVM中最关键的技巧是核技巧。“核”其实是一个函数,通过一些转换规则把低维的数据映射为高维的数据。在机器学习里,数据跟向量是等同的意思。例如,一个 [174, 72]表示人的身高与体重的数据就是一个两维的向量。在这里,维度代表的是向量的长度。(务必要区分“维度”这个词在不同语境下的含义,有的时候我们会说向量是一维的,矩阵是二维的,这种说法针对的是数据展开的层次。机器学习里讲的维度代表的是向量的长度,与前者不同)

  简单来说,低维空间到高维空间映射带来的好处就是可以利用高维空间的线型切割模拟低维空间的非线性分类效果。也就是说,SVM模型其实只能做线型分类,但是在线型分类前,它可以通过核技巧把数据映射到高维,然后在高维空间进行线型切割。高维空间的线型切割完后在低维空间中最后看到的效果就是划出了一条复杂的分线型分类界限。从这点来看,SVM并没有完成真正的非线性分类,而是通过其它方式达到了类似目的,可谓“曲径通幽”。

  SVM模型总共可以支持多少种核呢。根据官方文档,支持的核类型有以下几种:

  1. liner核,也就是无核。
  2. rbf核,使用的是高斯函数作为核函数。
  3. poly核,使用多项式函数作为核函数。
  4. sigmoid核,使用sigmoid函数作为核函数。

  liner核和rbf核是所有核中应用最广泛的。

  liner核,虽然名称带核,但它其实是无核模型,也就是没有使用核函数对数据进行转换。因此,它的分类效果仅仅比逻辑回归好一点。在EasyPR1.0版中,我们的SVM模型应用的是liner核。我们用的是图像的全部像素作为特征。

  rbf核,会将输入数据的特征维数进行一个维度转换,具体会转换为多少维?这个等于你输入的训练量。假设你有500张图片,rbf核会把每张图片的数据转 换为500维的。如果你有1000张图片,rbf核会把每幅图片的特征转到1000维。这么说来,随着你输入训练数据量的增长,数据的维数越多。更方便在高维空间下的分类效果,因此最后模型效果表现较好。

  既然选择SVM作为模型,而且SVM中核心的关键技巧是核函数,那么理应使用带核的函数模型,充分利用数据高维化的好处,利用高维的线型分类带来低维空间下的非线性分类效果。但是,rbf核的使用是需要条件的。

当你的数据量很大,但是每个数据量的维度一般时,才适合用rbf核。相反,当你的数据量不多,但是每个数据量的维数都很大时,适合用线型核。

在EasyPR1.0版中,我们用的是图像的全部像素作为特征,那么根据车牌图像的136×36的大小来看的话,就是4896维的数据,再加上我们输入的 是彩色图像,也就是说有R,G,B三个通道,那么数量还要乘以3,也就是14688个维度。这是一个非常庞大的数据量,你可以把每幅图片的数据理解为长度 为14688的向量。这个时候,每个数据的维度很大,而数据的总数很少,如果用rbf核的话,相反效果反而不如无核。

  在EasyPR1.1版本时,输入训练的数据有3000张图片,每个数据的特征改用直方统计,共有172个维度。这个场景下,如果用rbf核的话,就会将每个数据的维度转化为与数据总数一样的数量,也就是3000的维度,可以充分利用数据高维化后的好处。

  因此可以看出,为了让EasyPR新版使用rbf核技巧,我们给训练数据做了增加,扩充了两倍的数据,同时,减小了每个数据的维度。以此满足了rbf核的使用条件。通过使用rbf核来训练,充分发挥了非线性模型分类的优势,因此带来了较好的分类效果。  

  但是,使用rbf核也有一个问题,那就是参数设置的问题。在rbf训练的过程中,参数的选择会显著的影响最后rbf核训练出模型的效果。因此必须对参数进行最优选择。

2.参数调优

  传统的参数调优方法是人手完成的。机器学习工程师观察训练出的模型与参数的对应关系,不断调整,寻找最优的参数。由于机器学习工程师大部分时间在调整模型的参数,也有了“机器学习就是调参”这个说法。

  幸好,opencv的svm方法中提供了一个自动训练的方法。也就是由opencv帮你,不断改变参数,训练模型,测试模型,最后选择模型效果最好的那些参数。整个过程是全自动的,完全不需要你参与,你只需要输入你需要调整参数的参数类型,以及每次参数调整的步长即可。

  现在有个问题,如何验证svm参数的效果?你可能会说,使用训练集以外的那30%测试集啊。但事实上,机器学习模型中专门有一个数据集,是用来验证参数效果的。也就是交叉验证集(cross validation set,简称validate data) 这个概念。

validate data就是专门从train data中取出一部分数据,用这部分数据来验证参数调整的效果。比方说现在有70%的训练数据,从中取出20%的数据,剩下50%数据用来训练,再用训练出来的模型在20%数据上进行测试。这20%的数据就叫做validate data。真正拿来训练的数据仅仅只是50%的数据。
  正如上面把数据划分为test data和train data的理由一样。为了验证参数在新数据上的推广性,我们不能用一个训练数据集,所以我们要把训练数据集再细分为train data和validate data。在train data上训练,然后在validate data上测试参数的效果。所以说,在一个更一般的机器学习场景中,机器学习工程师会把数据分为train data,validate data,以及test data。在train data上训练模型,用validate data测试参数,最后用test data测试模型和参数的整体表现。
  说了这么多,那么,大家可能要问,是不是还缺少一个数据集,需要再划分出来一个validate data吧。但是答案是No。opencv的train_auto函数帮你完成了所有工作,你只需要告诉它,你需要划分多少个子分组,以及validate data所占的比例。然后train_auto函数会自动帮你从你输入的train data中划分出一部分的validate data,然后自动测试,选择表现效果最好的参数。

  感谢train_auto函数!既帮我们划分了参数验证的数据集,还帮我们一步步调整参数,最后选择效果最好的那个参数,可谓是节省了调优过程中80%的工作。

  train_auto函数的调用代码如下:


    svm.train_auto(trainingData, classes, Mat(), Mat(), SVM_params, 10, 
                CvSVM::get_default_grid(CvSVM::C),
                CvSVM::get_default_grid(CvSVM::GAMMA), 
                CvSVM::get_default_grid(CvSVM::P), 
                CvSVM::get_default_grid(CvSVM::NU), 
                CvSVM::get_default_grid(CvSVM::COEF),
                CvSVM::get_default_grid(CvSVM::DEGREE),
                true);


  你唯一需要做的就是泡杯茶,翻翻书,然后慢慢等待这计算机帮你处理好所有事情(时间较长,因为每次调整参数又得重新训练一次)。作者最近的一次训练的耗时为1个半小时)。

  训练完毕后,看看模型和参数在test data上的表现把。99%的precise和98%的recall。非常棒,比任何一次手工配的效果都好。
3.特征提取
  在rbf核介绍时提到过,输入数据的特征的维度现在是172,那么这个数字是如何计算出来的?现在的特征用的是直方统计函数,也就是先把图像二值化,然后统计图像中一行元素中1的数目,由于输入图像有36行,因此有36个值,再统计图像中每一列中1的数目,图像有136列,因此有136个值,两者相加正好等于172。新的输入数据的特征提取函数就是下面的代码:


// ! EasyPR的getFeatures回调函数
// !本函数是获取垂直和水平的直方图图值
void getHistogramFeatures(const Mat& image, Mat& features)
{
    Mat grayImage;
    cvtColor(image, grayImage, CV_RGB2GRAY);
    Mat img_threshold;
    threshold(grayImage, img_threshold, 0, 255, CV_THRESH_OTSU+CV_THRESH_BINARY);
    features = getTheFeatures(img_threshold);
}


  我们输入数据的特征不再是全部的三原色的像素值了,而是抽取过的一些特征。从原始的图像到抽取后的特征的过程就被称为特征提取的过程。在1.0版中没有特征提取的概念,是直接把图像中全部像素作为特征的。这要感谢群里的“如果有一天”同学,他坚持认为全部像素的输入是最低级的做法,认为用特征提取后的效果会好多。我问大概能到多少准确率,当时的准确率有92%,我以为已经很高了,结果他说能到99%。在半信半疑中我尝试了,果真如他所说,结合了rbf核与新特征训练的模型达到的precise在99%左右,而且recall也有98%,这真是个令人咋舌并且非常惊喜的成绩。

  “如果有一天”建议用的是SFIT特征提取或者HOG特征提取,由于时间原因,这两者我没有实现,但是把函数留在了那里。留待以后有时间完成。在这个过程中,我充分体会到了开源的力量,如果不是把软件开源,如果不是有这么多优秀的大家一起讨论,这样的思路与改善是不可能出现的。

4.接口函数

  由于有SIFT以及HOG等特征没有实现,而且未来有可能会有更多有效的特征函数出现。因此我把特征函数抽象为借口。使用回调函数的思路实现。所有回调函数的代码都在feature.cpp中,开发者可以实现自己的回调函数,并把它赋值给EasyPR中的某个函数指针,从而实现自定义的特征提取。也许你们会有更多更好的特征的想法与创意。

  关于特征其实有更多的思考,原始的SVM模型的输入是图像的全部像素,正如人类在小时候通过图像识别各种事物的过程。后来SVM模型的输入是经过抽取的特 征。正如随着人类接触的事物越来越多,会发现单凭图像越来越难区分一些非常相似的东西,于是学会了总结特征。例如太阳就是圆的,黄色,在天空等,可以凭借 这些特征就进行区分和判断。

从本质上说,特征是区分事物的关键特性。这些特性,一定是从某些维度去看待的。例如,苹果和梨子,一个是绿色,一个是黄色,这就是颜色的维度;鱼和鸟,一个在水里,一个在空中,这是位置的区分,也就是空间的维度。特征,是许多维度中最有区分意义的维度。传统数据仓库中的OLAP,也称为多维分析,提供了人类从多个维度观察,比较的能力。通过人类的观察比较,从多个维度中挑选出来的维度,就是要分析目标的特征。从这点来看,机器学习与多维分析有了关联。多维分析提供了选择特征的能力。而机器学习可以根据这些特征进行建模。

  机器学习界也有很多算法,专门是用来从数据中抽取特征信息的。例如传统的PCA(主成分分析)算法以及最近流行的深度学习中的 AutoEncoder(自动编码机)技术。这些算法的主要功能就是在数据中学习出最能够明显区分数据的特征,从而提升后续的机器学习分类算法的效果。

  说一个特征学习的案例。作者买车时,经常会把大众的两款车--迈腾与帕萨特给弄混,因为两者实在太像了。大家可以到网上去搜一下这两车的图片。如果不依赖后排的文字,光靠外形实在难以将两车区分开来(虽然从生产商来说,前者是一汽大众生产的,产地在长春,后者是上海大众生产的,产地在上海。两个不同的公司,南北两个地方,相差了十万八千里)。后来我通过仔细观察,终于发现了一个明显区分两辆车的特征,后来我再也没有认错过。这个特征就是:迈腾的前脸有四条银杠,而帕萨特只有三条,迈腾比帕萨特多一条银杠。可以这么说,就是这么一条银杠,分割了北和南两个地方生产的汽车。


图15 一条银杠,分割了“北”和“南”

  在这里区分的过程,我是通过不断学习与研究才发现了这些区分的特征,这充分说明了事物的特征也是可以被学习的。如果让机器学习中的特征选择方法 PCA和AutoEncoder来分析的话,按理来说它们也应该找出这条银杠,否则它们就无法做到对这两类最有效的分类与判断。如果没有找到的话,证明我们目前的特征选择算法还有很多的改进空间(与这个案例类似的还有大众的另两款车,高尔夫和Polo。它们两的区分也是用同样的道理。相比迈腾和帕萨特,高尔夫和Polo价格差别的更大,所以区分的特征也更有价值)。

  5.自动化
  最后我想简单谈一下EasyPR1.1新增的自动化训练功能与命令行。大家可能看到第二部分介绍SVM训练时我将过程分成了5个步骤。事实上,这些步骤中的很多过程是可以模块化的。一开始的时候我写一些不相关的代码函数,帮我处理各种需要解决的问题,例如数据的分组,打标签等等。但后来,我把思路理清后,我觉得这几个步骤中很多的代码都可以通用。于是我把一些步骤模块化出来,形成通用的函数,并写了一个命令行界面去调用它们。在你运行EasyPR1.1版后,在你看到的第一个命令行界面选择“3.SVM训练过程”,你就可以看到这些全部的命令。

图16 svm训练命令行

  这里的命令主要有6个部分。第一个部分是最可能需要修改代码的地方,因为每个人的原始数据(raw data)都是不一样的,因此你需要在data_prepare.cpp中找到这个函数,改写成适应你格式的代码。接下来的第二个部分以后的功能基本都可以复用。例如自动贴标签(注意贴完以后要人工核对一下)。
  第三个到第六部分功能类似。如果你的数据还没分组,那么你执行3以后,系统自动帮你分组,然后训练,再测试验证。第四个命令行省略了分组过程。第五个命令行部分省略训练过程。第六个命令行省略了前面所有过程,只做最后模型的测试部分。

  让我们回顾一下SVM调优的五个思路。第一部分是rbf核,也就是模型选择层次,根据你的实际环境选择最合适的模型。第二部分是参数调优,也就是参数优化层次,这部分的参数最好通过一个验证集来确认,也可以使用opencv自带的train_auto函数。第三部分是特征抽取部分,也就是特征甄选们,要能选择出最能反映数据本质区别的特征来。在这方面,pca以及深度学习技术中的autoencoder可能都会有所帮助。第四部分是通用接口部分,为了给优化留下空间,需要抽象出接口,方便后续的改进与对比。第五部分是自动化部分,为了节省时间,将大量可以自动化处理的功能模块化出来,然后提供一些方便的操作界面。前三部分是从机器学习的效果来提高,后两部分是从软件工程的层面去优化。

  总结起来,就是模型,参数,特征,接口,模块五个层面。通过这五个层面,可以有效的提高机器学习模型训练的效果与速度,从而降低机器学习工程实施的难度与提升相关的效率。当需要对机器学习模型进行调优的时候,我们可以从这五个层面去考虑。

  后记

  讲到这里,本次的SVM开发详解也算是结束了。相信通过这篇文档,以及作者的一些心得,会对你在SVM模型的开发上面带来一些帮助。下面的工作可以考虑把这些相关的方法与思路运用到其他领域,或着改善EasyPR目前已有的模型与算法。如果你找出了比目前更好实现的思路,并且你愿意跟我们分享,那我们是非常欢迎的。
  EasyPR1.1的版本发生了较大的变化。我为了让它拥有多人协作,众包开发的能力,想过很多办法。最后决定引入了GDTS(General Data Test Set,通用测试数据集,也就是新的image/general_test下的众多车牌图片)以及GDSL(General Data Share License,通用数据分享协议,image/GDSL.txt)。这些概念与协议的引入非常重要,可能会改变目前车牌识别与机器学习在国内学习研究的格局。在下期的EasyPR开发详解中我会重点介绍1.1版的新加入功能以及这两个概念和背后的思想,欢迎继续阅读。

  上一篇还是第四篇,为什么本期SVM开发详解属于EasyPR开发的第六篇?事实上,对于目前的车牌定位模块我们团队觉得还有改进空间,所以第五篇的详解内容是留给改进后的车牌定位模块的。如果有车牌定位模块方面好的建议或者希望加入开源团队,欢迎跟我们团队联系(easypr_dev@163.com )。您也可以为中国的开源事业做出一份贡献






EasyPR--开发详解(7)字符分割


大家好,好久不见了。

  一转眼距离上一篇博客已经是4个月前的事了。要问博主这段时间去干了什么,我只能说:我去“外面看了看”。

 

图1 我想去看看 

  

  在外面跟几家创业公司谈了谈,交流了一些大数据与机器视觉相关的心得与经验。不过由于各种原因,博主又回来了。

  目前,博主的工作是在本地的一个高校做科研。而研究的方向主要是计算机视觉。

 

图2 科研就是不断的探索过程

 

  由于我所做的是计算机视觉方向,跟EasyPR本身非常契合。未来这个这个系列的博客会继续下去,并且以后会有更加专业的内容。

  目前我研究的方向是文字定位,这个技术跟车牌定位很像,都是在图中去定位一些语言相关的位置。不同之处在于,车牌定位只需要处理的是在车牌中出现的文字,字体,颜色都比较固定,背景也比相对单一(蓝色和黄色等)。

  文字定位则复杂很多,研究界目前要处理的是是各种类型,不同字体,且拥有复杂背景的文字。下图是一张样例:

 

 

图3 文字定位图片样例

 

  可以看出,文字定位要处理的问题是类似车牌定位的,不过难度要更大。一些文字定位的技术也应该可以应用于车牌的定位和识别。

  未来EasyPR会借鉴文字定位的一些思想和技术,来强化其定位的效果。

 

一.前言

  今天继续我们EasyPR的开发详解。

  这几个月我收到了不少的邮件问:为什么EasyPR开发详解教程中只有车牌定位的部分,而没有字符识别的部分?

  这个原因一是由于整个开发详解是按照车牌识别的流程顺序来的,因此先讲定位,后面再讲字符识别。所以字符识别的部分出来的比较晚。

  二是由于字符识别相对于前面的车牌定位而言,显得较为简单。不像在一个复杂和低分辨场景下进行车牌定位,在字符分割和识别的部分时,所需要处理的场景已经较为固定了,因此其处理技术也较为单一。

  这两个原因是字符分割和识别部分出来较晚的原因。不过在本篇博客中我们会将字符分割部分讲完。

 

二.整体流程

  我们首先看一下,字符分割所需要处理的输入: 即是前面车牌定位中的结果,一个完整的车牌。 

 图4 字符分割模块的输入 

 

  由于在车牌定位中,我们使用了归一化过程。因此所需要处理的车牌的大小是统一的,在目前的版本中(v1.3),这个值是136*36

  那么字符分割的结果就是将车牌中的所有文字一一分割开来,形成单一的字符块。生成的字符块就可以输入下一步的字符识别部分进行识别。在EasyPR里,字符识别所使用的技术是人工神经网络,也就是ANN

  具体而言,字符分割过程是如何做的呢?简单说,就是:灰度化->颜色判断->二值化->取轮廓->找外接矩形->截取图块。

图5 字符分割处理流程 

 

  下面,我们使用下图的车牌完整的跑一遍字符分割的流程,以此对其有一个全局的认识。 

 

图6 原始图片

 

  1.灰度化

  首先,我们把彩色的图片转化为灰度化图片。注意:为了以后可以利用彩色信息,在前面的车牌检测过程中,我们的输出结果不是灰度化图片,而是彩色图片。这样以后当我们改正算法,想利用彩色信息时就可以使用了。

  但是在这里,我们的算法还是针对的是灰度化图片,因此首先进行灰度化处理。

  灰度化后的图片见下图:

 

图7 灰度化后结果 

 

  2.颜色判断

  灰度化之后,为了分割字符。我们需要获取字符的轮廓。注意:分割字符有很多种方法。例如投影法,滑动窗口判断法,在这里,EasyPR使用的是取字符轮廓法。

  因为需要取轮廓,就需要把图片转化成一个二值化图片。不过,由于蓝色和黄色车牌图片的区别,两者需要用的二值化参数不一样,因此这里需要对车牌图片的颜色进行一个判断。车牌颜色对二值化的影响的分析见后面其他细节章节。

  这里颜色判断的使用的是前面颜色定位详解里的模板匹配法。

 

图8 颜色判断

 

  3.二值化

  获取颜色后,就可以选择不同的参数进行大津阈值法来进行二值化。对于本示例图片中的蓝色车牌而言,使用的参数为CV_THRESH_BINARY。

  二值化后的效果见下图:

 

图9 二值化后结果

 

   4.取轮廓

  接下来,使用被多次用到的取轮廓方法findContours。关于这个方法的具体内容,在前面的开发详解中已做过介绍,这里不再赘述。

  取轮廓后的结果如下图:

 

图10 取轮廓操作

 

   注意:直接使用findContours方法取轮廓时,在处理中文字符,也就是“苏”时,会发生断裂现象。因此为了处理中文字符,EasyPR换了一种思路,使用了额外的步骤来解决这个问题。具体可以见后面的“中文字符处理”章节。

 

  5.找外接矩形

  使用了中文字符处理方法以后,成功获取了所有的字符的外接矩形。

  具体见下图:

 

图11 所有字符的外接矩形

 

   6.截取图块

  最后,把图中的外接矩形一一截取出来,归一化到统一格式。留待输入下个步骤--字符识别模块处理。

  归一化后字符图块见下图:

            

图12 截取并归一化的图块

 

三.中文字符处理

  上面的流程在处理英文车牌时,效果是很好的。但是在处理中文车牌时,存在一个很大的问题。

  在取轮廓时,中文由于自身的特性,例如有笔画区间,取轮廓会造成断裂现象。例如下图中的。英文字符通过取轮廓都被完整的包括了,而字则分成了两个连通区域。

图13 取轮廓操作示例

 

  虽然并不是所有的中文都会存在这个问题(例如下图的字),但直接用取轮廓操作已经不合适了。

  EasyPR是如何解决这个问题的呢?其实想法很简单。那就是既然有些中文字符没办法用取轮廓处理,那么就干脆先不处理中文字符,而是用取轮廓操作处理中文字符后面的字符。例如“苏A88M88,其中“A88M88这六个字符我都能用取轮廓操作获得。我先获取这六个字符,再想办法获取中文字符。

图14 “津”字

 

  获取这六个字符后,接下来该如何获取“苏”这个中文字符的轮廓呢?

  这里的关键就是“苏”字符后面的A字符,这个字符在中文车牌里代表城市的代码,我们在这里简称它为“城市字符”或者“特殊字符”。

  这个字符有一个特征,就是与后面的字符存在一定的间隔。但是与前面的中文字符靠的较紧。倘若我获取了这个特殊字符的外接矩形,只要把这个外接矩形向左做一些的偏移(偏移的大小可以通过经验指定,例如设置为字符宽度的1.15倍),这样这个外接矩形就成了包含中文字符的一个矩形了。下面就可以截取中文字符的图块。

  下图就是“特殊字符”与被反推得到的“中文字符”的矩形,在图中用红色矩形表示。

图15 反推得到的中文字符位置

 

  下面的问题就是如何获取特殊字符”的位置?

  一种方法是把所有取轮廓操作获取到的矩形进行排序,最左边的就是特殊字符的图块。但是有些中文字符会被取轮廓操作截取为一个连通区域。在这种情况下,最左边的图块矩形是中文字符的矩形,而不是特殊字符的矩形了。所以这个方法不能用。

  另一种方法就是依次判断所有取轮廓操作得到的矩形的位置,设矩形的中点恰好在整个车牌的1/7到2/7之间时的矩形为特殊矩形。这样操作的前提是我们的车牌定位的非常准确,恰到把整个车牌截取的正正好。在这种情况下,只要外接矩形满足这些条件,就可以判断为特殊字符的矩形。

  这个方法思路很简单,实际中应用效果也不错,因此也是EasyPR目前采用的方法。

图16 获取特殊字符的位置

 

  以下是特殊字符判断的代码:

 View Code

 

  以上就是EasyPR能处理中文车牌的主要原因。原先的taotao1233的代码中无法处理中文的原因就是没有这样一步预处理。其实这是一个很简单的思想,但在之前并没有被实现。EasyPR里实现了这个思路,同时发现,这个方法效果出奇的好。基本可以应对所有的情况。所以说,这个方法可以说是一个简单,有效的处理中文车牌的方法。

 

四.其他一些细节

  1.颜色判断

  在进行二值化前,需要进行一次颜色判断,这是因为对于蓝色和黄色车牌而言,使用的二值化策略必须不同。

   

图17 蓝色与黄色车牌的不同

 

  对于蓝色车牌而言,使用的参数为CV_THRESH_BINARY。

  而对于黄色车牌而言,使用的参数为CV_THRESH_BINARY_INV。

  假设黄色车牌使用了CV_THRESH_BINARY作为参数,则会发生如下图一样的二值化结果,其中字符部分变成了黑色,而背景则是白色(同理,蓝色车牌使用CV_THRESH_BINARY_INV也是一样的效果)。

  在这种不正确的参数带来的二值化情况下,取轮廓操作将无法按照预期的行为进行处理。因此,必须使用正确的二值化参数。

     

图18 不正确参数的二值化效果

 

  在颜色判断时,有一个小技巧,就是先把四周的”截取后再进行颜色的判断,这样可以消除车牌定位时一些多余的四周的干扰。

  代码如下:

1   Mat tmpMat = input(Rect_<double>(w * 0.1, h * 0.1, w * 0.8, h * 0.8));
2 
3   // 判断车牌颜色以此确认threshold方法
4   Color plateType = getPlateType(tmpMat, true);

  

  颜色判断方法的代码如下:

 View Code

 

  2.排除缝隙

  在获得中文字符图块以后,下面一步就是把剩下的图块获取了。不过由于中文车牌一般只有7个字符,所以可以把后面的图块从左到右排序,依次选择6个即可。一些会被误判为“I”的缝隙可以通过这种方法排除出去。

  例如下图中,最右边的一个缝隙会被误识别为"1"。但是倘若从左到右依次选择的话,这个缝隙并不会被选入候选集合中,因为它已经是“第八个”字符了。

图19 最右边会被误判为"1"的缝隙

 

  排序与依次选择的代码如下:

 View Code

 

  3.去除柳钉

  有些中国的车牌中有一个非常妨碍识别的东西,那就是柳钉。倘若对一副含有柳钉的图进行二值化,极有可能会出现下图的结果。一些字符图块(下图的"9"和"1")通过柳钉的原因联系到了一体,那样的话就无法通过取轮廓操作来分割了。

图20 柳钉的影响

 

  因此在二值化之后,还需要一个去除柳钉的操作。

  去除柳钉的思想也并不复杂,就是依次扫描每行,判断跳变次数。车牌字符所在的行的跳变次数是很多的,而柳钉所在的行就会偏少。因此当发现某行跳变次数较少,则可以把该行的所有像素值赋值为0,这样就会大幅度消除柳钉的影响了。

  下图就是去除柳钉后的效果。

图21 去除柳钉后的效果

 

  去除柳钉函数的代码如下:

 View Code

 

五.总结 

  最后回顾一下整体的处理流程,首先是对车牌图像进行灰度化,然后根据车牌的不同颜色来进行不同的二值化处理。二值化完后首先去除柳钉,然后进行取轮廓操作。

  取轮廓操作以后,在所有的轮廓中根据先验知识,找到代表城市的字符,也就是A”中“A”的位置,根据“A的位置来反推“苏”的位置。

  最后将找到的这些轮廓依次排序,从左到右依次选择6个,和第一个的中文字符组成7个字符的图块数组,输入到下一步字符识别模块中进行处理。

  整个字符分割流程就到此结束了,还是比较简单的。其中的中文字符位置的确定使用了先验知识这种方法。这种方法在面对固定已知场景中是较好的方法,但是面对特殊情况时就可能会有不太好的效果,因此要根据具体情况来权衡。

 

六.未来展望

  本篇字符分割流程就到此结束。当下,EasyPR1.3 版也发布了,对整体架构以及处理效率都有所提升,可以下载试用。

  未来的博客会按照每2个月一篇的速度诞生,下篇博客的内容是”字符识别与人工神经网络”。

 


EasyPR--开发详解(8)文字定位


今天我们来介绍车牌定位中的一种新方法--文字定位方法(MSER),包括其主要设计思想与实现。接着我们会介绍一下EasyPR v1.5-beta版本中带来的几项改动。

 

一. 文字定位法

  在EasyPR前面几个版本中,最为人所诟病的就是定位效果不佳,尤其是在面对生活场景(例如手机拍摄)时。由于EasyPR最早的数据来源于卡口,因此对卡口数据进行了优化,而并没有对生活场景中图片有较好处理的策略。后来一个版本(v1.3)增加了颜色定位方法,改善了这种现象,但是对分辨率较大的图片处理仍然不好。再加上颜色定位在面对低光照,低对比度的图像时处理效果大幅度下降,颜色本身也是一个不稳定的特征。因此EasyPR的车牌定位的整体鲁棒性仍然不足。

  针对这种现象,EasyPR v1.5增加了一种新的定位方法,文字定位方法,大幅度改善了这些问题。下面几幅图可以说明文字定位法的效果。

   

 图1 夜间的车牌图像(左) , 图2 对比度非常低的图像(右)

 

  

 图3 近距离的图像(左) , 图4 高分辨率的图像(右)


  图1是夜间的车牌图像,图2是对比度非常低的图像,图3是非常近距离拍摄的图像,图4则是高分辨率(3200宽)的图像。

  文字定位方法是采用了低级过滤器提取文字,然后再将其组合的一种定位方法。原先是利用在场景中定位文字,在这里利用其定位车牌。与在扫描文档中的文字不同,自然场景中的文字具有低对比度,背景各异,光亮干扰较多等情况,因此需要一个极为鲁棒的方法去提取出来。目前业界用的较多的是MSER(最大稳定极值区域)方法。EasyPR使用的是MSER的一个改良方法,专门针对文字进行了优化。在文字定位出来以后,一般需要用一个分类器将其中大部分的定位错误的文字去掉,例如ANN模型。为了获得最终的车牌,这些文字需要组合起来。由于实际情况的复杂,简单的使用普通的聚类效果往往不好,因此EasyPR使用了一种鲁棒性较强的种子生长方法(seed growing)去组合。

  我在这里简单介绍一下具体的实现。关于方法的细节可以看代码,有很多的注释(代码可能较长)。关于方法的思想可以看附录的两篇论文。

 View Code

  

  首先通过MSER提取区域,提取出的区域进行一个尺寸判断,滤除明显不符合车牌文字尺寸的。接下来使用一个文字分类器,将分类结果概率大于0.9的设为强种子(下图的绿色方框)。靠近的强种子进行聚合,划出一条线穿过它们的中心(图中白色的线)。一般来说,这条线就是车牌的中间轴线,斜率什么都相同。之后,就在这条线的附近寻找那些概率低于0.9的弱种子(蓝色方框)。由于车牌的特征,这些蓝色方框应该跟绿色方框距离不太远,同时尺寸也不会相差太大。蓝色方框实在绿色方框的左右查找的,有时候,几个绿色方框中间可能存在着一个方库,这可以通过每个方框之间的距离差推出来,这就是橙色的方框。全部找完以后。绿色方框加上蓝色与橙色方框的总数代表着目前在车牌区域中发现的文字数。有时这个数会低于7(中文车牌的文字数),这是因为有些区域即便通过MSER也提取不到(例如非常不稳定或光照变化大的),另外很多中文也无法通过MSER提取到(中文大多是不连通的,MSER提取的区域基本都是连通的)。所以下面需要再增加一个滑动窗口(红色方框)来寻找这些缺失的文字或者中文,如果分类器概率大于某个阈值,就可以将其加入到最终的结果中。最后,把所有文字的位置用一个方框框起来,就是车牌的区域。

  想要通过中间图片进行调试程序的话,首先依次根据函数调用关系plateMserLocate->mserSearch->mserCharMatch在core_func.cpp找到位置。在函数的最后,把图片输出的判断符改为1。然后在resources/image下面依次新建tmp与plateDetect目录(跟代码中的一致),接下来再运行时在新目录里就可以看到这些调试图片。(EasyPR里还有很多其他类似的输出代码,只要按照代码的写法创建文件夹就可以看到输出结果了)。

  

 图5 文字定位的中间结果(调试图像) 

 

二. 更加合理准确的评价指标

  原先的EasyPR的评价标准中有很多不合理的地方。例如一张图片中找到了一个疑似的区域,就认为是定位成功了。或者如果一张图片中定位到了几个车牌,就用差距率最小的那个作为定位结果。这些地方不合理的地方在于,有可能找到的疑似区域根本不是车牌区域。另外一个包含几个车牌的图片仅仅用最大的一个作为结果,明显不合理。

  因此新评价指标需要考虑定位区域和车牌区域的位置差异,只有当两者接近时才能认为是定位成功。另外,一张图片如果有几个车牌,对应的就有几个定位区域,每个区域与车牌做比对,综合起来才能作为定位效果。因此需要加入一个GroundTruth,标记各个车牌的位置信息。新版本中,我们标记了251张图片,其中共250个车牌的位置信息。为了衡量定位区域与车牌区域的位置差的比例,又引入了ICDAR2003的评价协议,来最终计算出定位的recall,precise与fscore值。

  车牌定位评价中做了大改动。字符识别模块则做了小改动。首先是去除了“平均字符差距”这个意义较小的指标。转而用零字符差距,一字符差距,中文字符正确替代,这三者都是比率。零字符差距(0-error)指的是识别结果与车牌没有任何差异,跟原先的评价协议中的“完全正确率”指代一样。一字符差距(1-error)指的是错别仅仅只有1个字符或以下的,包括零字符差距。注意,中文一般是两个字符。中文字符正确(Chinese-precise)指代中文字符识别正确的比率。这三个指标,都是越大越好,100%最高。

  为了实际看出这些指标的效果,拿通用测试集里增加的50张复杂图片做对此测试,文字定位方法在这些数据上的表现的差异与原先的SOBEL,COLOR定位方法的区别可以看下面的结果。

  SOBEL+COLOR:
  总图片数:50, Plates count:52, 定位率:51.9231%
  Recall:46.1696%, Precise:26.3273%, Fscore:33.533%.
  0-error:12.5%, 1-error:12.5%, Chinese-precise:37.5%

  CMSER:
  总图片数:50, Plates count:52, 定位率:78.8462%
  Recall:70.6192%, Precise:70.1825%, Fscore:70.4002%.
  0-error:59.4595%, 1-error:70.2703%, Chinese-precise:70.2703%

  可以看出定位率提升了接近27个百分点,定位Fscore与中文识别正确率则提升了接近1倍。

 

三. 非极大值抑制

  新版本中另一个较大的改动就是大量的使用了非极大值抑制(Non-maximum suppression)。使用非极大值抑制有几个好处:1.当有几个定位区域重叠时,可以根据它们的置信度(也是SVM车牌判断模型得出的值)来取出其中最大概率准确的一个,移除其他几个。这样,不同定位方法,例如Sobel与Color定位的同一个区域,只有一个可以保留。因此,EasyPR新版本中,最终定位出的一个车牌区域,不再会有几个框了。2.结合滑动窗口,可以用其来准确定位文字的位置,例如在车牌定位模块中找到概率最大的文字位置,或者在文字识别模块中,更准确的找到中文文字的位置。

  非极大值抑制的使用使得EasyPR的定位方法与后面的识别模块解耦了。以前,每增加定位方法,可能会对最终输出产生影响。现在,无论多少定位方法定位出的车牌都会通过非极大值抑制取出最大概率的一个,对后面的方法没有一点影响。

  另外,如今setMaxPlates()这个函数可以确实的作用了。以前可以设置,但没效果。现在,设置这个值为n以后,当在一副图像中检测到大于n个车牌区域(注意,这个是经过非极大值抑制后的)时,EasyPR只会输出n个可能性最高的车牌区域。


四. 字符分割与识别部分的强化

  新版本中字符分割与识别部分都添加了新算法。例如使用了spatial-ostu替代普通的ostu算法,增加了图像分割在面对光照不均匀的图像上的二值化效果。

      

 图6 车牌图像(左),普通大津阈值结果(中),空间大津阈值结果(右)

 

  同时,识别部分针对中文增加了一种adaptive threshold方法。这种方法在二值化“川”字时有比ostu更好的效果。通过将两者一并使用,并选择其中字符识别概率最大的一个,显著提升了中文字符的识别准确率。在识别中文时,增加了一个小型的滑动窗口,以此来弥补通过省份字符直接查找中文字符时的定位不精等现象。



五. 新的特征与SVM模型,新的中文识别ANN模型

  为了强化车牌判断的鲁棒性,新版本中更改了SVM模型的特征,使用LBP特征的模型在面对低对比度与光照的车牌图像中也有很好的判断效果。为了强化中文识别的准确率,现在单独为31类中文文字训练了一个ANN模型ann_chinese,使用这个模型在分类中文是的效果,相对原先的通用模型可以提升近10个百分点。


六. 其他

  几天前EasyPR发布了1.5-alpha版本。今天发布的beta版本相对于alpha版本,增加了Grid Search功能, 对文字定位方法的参数又进行了部分调优,同时去除了一些中文注释以提高window下的兼容性,除此之外,在速度方面,此版本首次使用了多线程编程技术(OpenMP)来提高算法整体的效率等,使得最终的速度有了2倍左右的提升。

  下面说一点新版本的不足:目前来看,文字定位方法的鲁棒性确实很高,不过遗憾的速度跟颜色定位方法相比,还是慢了接近一倍(与Sobel定位效率相当)。后面的改善中,考虑对其进行优化。另外,字符分割的效果实际上还是可以有更多的优化算法选择的,未来的版本可以考虑对其做一个较大的尝试与改进。

 

  对EasyPR做下说明:EasyPR,一个开源的中文车牌识别系统,代码托管在github和gitosc。其次,在前面的博客文章中,包含EasyPR至今的开发文档与介绍














评论 30 您还未登录,请先 登录 后发表或查看评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
©️2022 CSDN 皮肤主题:大白 设计师:CSDN官方博客 返回首页
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值