【原文: http://blog.csdn.net/raby_gyl/article/details/11617875】
书名:《Mastering OpenCV with Practical Computer Vision Projects》
由于添加了一个*号,显示乱码,不晓得怎么回事,为了不耽误大家看可以下载word版本的翻译:http://download.csdn.net/detail/xuluhui123/6806749
不免有错,不在本文章中修改了,以防再次出现乱码,这里整理好了,但是代码没有中文注释,word版本中有。
《书中部分其他章节的翻译》:http://blog.csdn.net/raby_gyl/article/details/17472271
电子书下载地址:http://download.csdn.net/detail/xuluhui123/6310819
随书源代码下载地址:http://download.csdn.net/detail/xuluhui123/6310851
程序流程图下载地址:http://download.csdn.net/detail/xuluhui123/6310811
流程图也可参考:http://blog.csdn.net/xuluhui123/article/details/11659915
推荐:采用SVM和神经网络车牌识别详细流程图,和代码中部分解释:http://blog.csdn.net/raby_gyl/article/details/11659915
本人英语四级水平,以下翻译只供自己存档和像我一样的初学者参阅。里面错误肯定很多,也有好多英语句子我不太明白的。敬请大家纠正。我最近还要好好的消化一下。还会进行修改的。最近刚看了opencv中文版的机器学习篇,opencv自带的特征数据都是已经做好了的,对于特征数据的生成,矩阵的操作不熟悉的我,还是在脑子里难以形成一个整体的思路。想在网上下载一些实例项目的代码,好像很难,即使找到了又没有详细的说明,对于一个初学者可能要花好久的时间才能完全整理清楚(还必须查阅相关资料)。最近有幸在opencv论坛,看到朋友们推荐的这本书,而且附有源代码,能够运行,看到了效果。这使我决定要看一看,对于一个不喜欢看英文的我,硬着头皮把第五章看完了。读英文最大的痛苦是句子长,来回的修饰,然而第二大痛苦就是,我们记忆里不怎么好,读完一段话,即使每句话都好像能理解,然而将的是什么还是云里雾里。其实我感觉最好的方法,就是自己大约理解了,翻译下来,整成中文的,然后推敲一下是不是语义上合理。
<内容>
本章向大家介绍创建一个自动车牌识别应用(ANPR)所需要的步骤。基于不同的情况,有不同的方法和技术。例如,IR camera(红外线摄像机),固定车位置,光亮情况,等等。我们开始构建一个ANPR应用,来检测离车2-3米拍的照片中的车牌。在模糊的光线下,并且不是平行与地面而是与车牌的有个小角度的倾斜。
本章的主要目的是向大家介绍图像的分割和特征提取,模式识别的基础,和两个重要的模式识别算法:支持向量机和人工神经网络。在这一章,我们将包含以下内容:
1、ANPR(自动车牌识别)
2、车牌检测
3、车牌识别
ANPR介绍:
ANRP也就是众所周知的ALPR,或者AVI,或者CPR,是一种用在光学字符识别的监视方法和其他方法,例如分割和检测来读取车牌号。
在ANPR系统中最好的结果是使用一个红外线摄像机,因为检测和OCR(光学字符识别)分割之前的分割步骤变的简单,干净和错误最小化。这是由于光线法则,最基本的是因为入射角度等于反射角度。当我们看一个光滑的表面例如一个平面镜,我们能看到这个基本的反射。粗糙表面的反射例如一张纸导致的反射称为漫射或者散射。车牌号的主要部分有一个特殊的特性叫做回复反射。车牌的表面
是用覆盖有成千上万个细小半球的材料做成的。这样会使光线回复反射到光线源,我们从下面的图可以看到:
角度反射 散射或者漫射 回复反射
如果我们使用一个带有红外线投影结构和滤波的摄像机,我们使用带有红外线的摄像机重新获取,将得到一个非常高质量的照片用来分割和随后的检测和识别车牌数字。即不依赖于任何光线环境,如下图所示:
在这一章,我们没有使用红外线摄像,我们使用常规的摄像。我们这样做,以至于我们没有得到最好的结果,得到的是一个更高水平的检测错误和高的错误识别率。这与我们使用红外摄像机所期待的结果截然相反。然而,两者的步骤是一样的。
每个国家车牌的大小和规格不同,为了获得更好的结果和减少错误,我们知道这些规格是佷有用的。本章使用的算法意图是阐述ANPR的基本原理和西班牙车牌,但是我们能把他们扩到任何国家或者规格车牌。
在本章,我将使用来之西班牙的车牌。在西班牙,有三种不同大小和形状的车牌。我们将使用最普通(使用最多)的车牌,其大小是520*110mm。两种字符(数字和字母)的间距是41mm。数字和数字之前(或者字母和字母之间)距离是14mm。第一组字符含有四个数字。另外一组含有三个字母,其中不包括元音字母:A,E,I,O,U。和N,Q。所有的字符大小为45*77。
这些数据对于字符分割很重要,因为我们能够检查两个字符和空格,来核实我们得到是一个字符而没有其它图片部分。如下是一个车牌图。
ANPR 算法
在解释ANPR代码之前,我们需要定义算法的主要步骤和任务。ANPR主要分为两步:车牌的检测和车牌的识别。车牌检测就是检测车牌在整个图像帧中的位置。当一个图像中的车牌检测到时,车牌的分割将交给接下来的一步——车牌识别。在车牌识别中,我们用OCR算法来决定车牌上的字母数字的字符。
在下图我们可以看到两个主要算法的步骤,车牌检测和车牌识别。车牌识别之后,程序将在图像帧中画出检测到的车牌。这个算法能返回坏的结果甚至没有结果(检测不到)。
每个步骤都展示在上边的图中,我们来定义另外三个步骤。他们通常用在模式识别算法中。
1、分割。该步检测和移动图像中每个感兴趣的区域。
2、特征提取。该步提取每个块的一系列的特征。
3、分类。该步从车牌识别步骤或者把图像部分分为有车牌和无车牌的车牌检测的步骤,(上述两个步骤中)提取每个字符。
下图向我们展示了在整个算法中模式识别的步骤。
抛开主要的应用,即该应用的目的是检测和识别一个车牌数字,我们来简单介绍两个不经常被介绍的任务:
1、怎么样训练一个模式识别系统?
2、怎么样来评估这样的一个系统?
然而,这些任务通常比主要应用本身更重要。因为,如果我们不能正确的训练模式识别系统,我们的系统就会失败并且不能正确的工作。不同的模式需要不同类型的训练和评估。我们需要在不同的环境,条件,带有不同特征,来评估我们的系统,进而得到最好的效果。这两个任务有时一起使用,因为不同的特征能产生不同的结果,这种情况我们会在评估部分看到。
车牌检测
在这一步中,我们需要检测在一个图像帧中所有的车牌。为了做这个任务。我们分为两个主要的步骤:分割和分割分类。特征步骤不在阐述,是因为我们用图像部分作为一个特征矢量。
第一步(分割),我们应用不同的滤波器,形态学操作,轮廓算法,和确认获取图像的这些部分可能有一个车牌。
第二步(分类),我们采用支持向量机(SVM)分类出每个图像部分——我们的特征。在创建主程序之前,我们训练两个不同的类别——有车牌和无车牌。我们采用前向_水平视觉的彩色图像,宽度为800像素,从离车的2到4米处获取的。这样要求对确保正确的分割很重要。如果我们创建了一个多尺度图像算法,我们能够展示检测。
在下面的图像中,我们展示了车牌检测所包含的所有处理:
1、Sobel滤波
2、阈值操作
3、闭操作
4、填充区域的掩膜
5、把可能检测到车牌标记为红色(特征图像)
6、SVM分类后,检测到的车牌
分割
分割是把一幅图像分割成许多部分的过程。这个过程简化图像分析,使特征提取更容易。
车牌分割的一个重要特征是在车牌中的高数量的垂直边缘(就是垂直边缘比较多)(假定照片是从前面拍的,车牌没有旋转,并且没有视觉上的扭曲。这个特征可以用来在分割的第一步(sobel滤波),来排除那些没有垂直边缘的区域。
在寻找垂直边缘之前,我们需要把彩色图像转换为灰度图像,因为彩色在我们的任务中没有帮助,并且移除来之相机或者外界的噪声。如果我们不应用去噪方法,我们将得到许多的垂直边缘,将会产生检测失败。
[cpp] view plain copy
- //convert image to gray
- Mat img_gray;
- cvtColor(input,img_gray,CV_BGR2GRAY);
- blur(img_gray,img_gray,Size(5,5));
为了寻找垂直边缘,我们采用sobel滤波并且找到一阶垂直方向导数。这个导数是个数学函数,允许我们找到图像上的垂直边缘。Opencv中Sobel函数的定义如下:
[cpp] view plain copy
- void Sobel(InputArray src,OutputArray dst,int ddepth,int xorder,int yoder,int ksize=3,double scale=1,double delta=0,int borderType=BORDER_DEFAULT)
这里,ddepth是目的图像的深度,xorder是x导数的次序(即x的order阶导数),yorder为y导数的次序。ksize核的大小要么是1,3,5要么是7。scale用在计算导数值,是个可选项。delta是一个加到结果的可选项。bordertype是像素的插值方法。
在本程序中,我们使用xorder=1,yorder=0,ksize=3;
[cpp] view plain copy
- //寻找垂直方向的线,车牌还很的垂直线密度
- Mat img_sobel;
- Sobel(img_gray,img_sobel,CV_8U,1,0,3,1,0);
Sobel滤波后,我们应用一个阈值滤波器来获得一个二值图像,阈值的通过otsu方法获得。Ostu算法需要一个8位图像作为输入,该方法自动的决定最佳的阈值。
[cpp] view plain copy
- Mat img_threshold;
- threshold(img_sobel,img_threhold,0,255,CV_THRESH_OTSU+CV_THRESH_BINARY);
为了在阈threshold函数中定义ostus方法,我们使用CV_THRESH_OTST值混合参数。则阈值参数被忽略。
(小心:当CV_THRSH_OTST被定义,threshold函数会通过ostus算法返回最优阈值)
通过应用一个闭操作,我们能够去掉每个垂直边缘线的空白部分。并且连接有含有边缘数量很多的所有区域。在这一步,我们得到可能的含有车牌的区域。
[cpp] view plain copy
- Mat element = getStructuringElement(MORPH_RECT, Size(17, 3));
在morphologEx函数中使用上述定义的结构元。
[cpp] view plain copy
- morphologyEx(img_threshold, img_threshold, CV_MOP_CLOSE, element);
应用完这些操作之后,我们的得到了可能含有车牌的区域,大部分的这些区域将没有包含车牌。这些区域用连通部分分析(opencv 中文版319页)或者使用findContours函数来分开。最后一个函数用不同的方法和结果来获得一个二值图像的轮廓。我们只需要用任何分层关系和任何多边形近似结果来获得外轮廓。
[cpp] view plain copy
- //Find contours of possibles plates
- vector< vector< Point> > contours;
- findContours(img_threshold,
- contours, // a vector of contours
- CV_RETR_EXTERNAL, // retrieve the external contours
- CV_CHAIN_APPROX_NONE); // all pixels of each contour
为了检测每个轮廓,提取轮廓的最小矩形边界框。OpenCV采用minAreaRect函数来完成这个任务。这个函数返回一个旋转矩形类对象:RotatedRect。我们使用vector容器迭代器访问每一个轮廓,我们可以得到旋转的矩行,在分类前做一些初步的确认。
[cpp] view plain copy
- //Start to iterate to each contour found
- vector<vector<Point> >::iterator itc= contours.begin();
- vector<RotatedRect> rects;
- //Remove patch that has no inside limits of aspect ratio and area.
- while (itc!=contours.end()) {
- //Create bounding rect of object
- RotatedRect mr= minAreaRect(Mat(*itc));
- if( !verifySizes(mr)){
- itc= contours.erase(itc);
- }else{
- ++itc;
- rects.push_back(mr);
- }
- }
我们基于面积和宽高比,对于检查到的区域做一下确认。如果宽高比大于为 520/110=4.727272 (车牌宽除以车牌高)(允许带有 40% 的误差)和边界在 15 像素和 125 像素高的区域,我们才认为是一个车牌区域。这些值根据图像的大小和相机的位置进行计算。
[cpp] view plain copy
- bool DetectRegions::verifySizes(RotatedRect candidate ){
- float error=0.4;
- //Spain car plate size: 52x11 aspect 4,7272
- const float aspect=4.7272;
- //Set a min and max area. All other patches are discarded
- int min= 15*aspect*15; // minimum area
- int max= 125*aspect*125; // maximum area
- //Get only patches that match to a respect ratio.
- float rmin= aspect-aspect*error;
- float rmax= aspect+aspect*error;
- int area= candidate.size.height * candidate.size.width;
- float r= (float)candidate.size.width / (float)candidate.size.height;
- if(r<1)
- r= 1/r;
- if(( area < min || area > max ) || ( r < rmin || r > rmax )){
- return false;
- }else{
- return true;
- }
- }
我们利用车牌的白色背景属性可以进一步改善。所有的车牌都有统一的背景颜色。我们可以使用漫水填充算法来获取旋转矩阵的精确修剪。
剪切车牌的第一步是在最后一个旋转矩阵中心的附近得到一些种子,在宽度和高度中得到最小的车牌,用它来产生离中心近的种子。
我们想要选择白色区域,我们需要一些种子,至少有一个种子接触到白色区域。接着对每一个种子,我们使用floodFill函数来得到一个掩码图像,用来存储新的最接近的修剪区域。
[cpp] view plain copy
- for(int i=0; i< rects.size(); i++){
- //For better rect cropping for each possible box
- //Make floodfill algorithm because the plate has white background
- //And then we can retrieve more clearly the contour box
- circle(result, rects[i].center, 3, Scalar(0,255,0), -1);
- //get the min size between width and height
- float minSize=(rects[i].size.width < rects[i].size.height)?rects[i].
- size.width:rects[i].size.height;
- minSize=minSize-minSize*0.5;
- //initialize rand and get 5 points around center for floodfill
- algorithm
- srand ( time(NULL) );
- //Initialize floodfill parameters and variables
- Mat mask;
- mask.create(input.rows + 2, input.cols + 2, CV_8UC1);
- mask= Scalar::all(0);
- int loDiff = 30;
- int upDiff = 30;
- int connectivity = 4;
- int newMaskVal = 255;
- int NumSeeds = 10;
- Rect ccomp;
- int flags = connectivity + (newMaskVal << 8 ) + CV_FLOODFILL_FIXED_
- RANGE + CV_FLOODFILL_MASK_ONLY;
- for(int j=0; j<NumSeeds; j++){
- Point seed;
- seed.x=rects[i].center.x+rand()%(int)minSize-(minSize/2);
- seed.y=rects[i].center.y+rand()%(int)minSize-(minSize/2);
- circle(result, seed, 1, Scalar(0,255,255), -1);
- int area = floodFill(input, mask, seed, Scalar(255,0,0), &ccomp,
- Scalar(loDiff, loDiff, loDiff), Scalar(upDiff, upDiff, upDiff),
- flags);
- }
漫水填充函数用颜色把连通区域填充到掩码图像,填充从种子开始。设置与相邻像素或者种子像素之间差异的最下界和最上界(如果设置了 CV_FLOODFILL_FIXED_RANGE ,则填充的像素点都是与种子点进行比较。就是如果像素值为 x,seed-low<=x<=seed+up ,则该位置将被填充)
[cpp] view plain copy
- int floodFill(InputOutputArray image, InputOutputArray mask, Point
- seed, Scalar newVal, Rect* rect=0, Scalar loDiff=Scalar(), Scalar
- upDiff=Scalar(), int flags=4 )
参数 newVal 是填充到图像的新值 . 参数 loDiff 和 upDiff 就是上边描述的。
参数 flag 由以下组成:
1、低位:包含连通的值,默认4连通,或者8连通。连通决定了像素的哪个邻居像素被考虑进来。
2、高位:可以为0,也可以是下边值的组合:CV_FLOODFILL_FIXED_RANGE 和CV_FLOODFILL_MASK_ONLY.
CV_FLOODFILL_FIXED_RANGE用来设置当前像素和种子像素之间的差异。
CV_FLOODFILL_MASK_ONLY,将填充掩码图像,而不是图像本身。
一旦我们得到了用来剪切的掩码图像,我们进而得到掩码图像点的最小外接矩形,再次检查矩形大小。对于每一个掩码,一个白色像素获得位置用minAreaRect函数重新得到最相近的修剪区域。
//检查新城的漫水填充掩码是不是一个正确的块。(因为使用车牌,车牌有边界,漫水填充不会超过车牌的边界,而对于其他区域(检查出来的矩形)漫水填充会占据很多区域,形成的矩形也很大,再进入verifySizes函数时,可能就会被丢弃,得到更可能是车牌的区域)
//得到所有的点为最小旋转矩形
[cpp] view plain copy
- //Check new floodfill mask match for a correct patch.
- //Get all points detected for minimal rotated Rect
- vector<Point> pointsInterest;
- Mat_<uchar>::iterator itMask= mask.begin<uchar>();
- Mat_<uchar>::iterator end= mask.end<uchar>();
- for( ; itMask!=end; ++itMask)
- if(*itMask==255)
- pointsInterest.push_back(itMask.pos());
- RotatedRect minRect = minAreaRect(pointsInterest);
- if(verifySizes(minRect)){
- …
既然分割过程已经完成并且我们得到了有效的区域。我们能够修剪每一个检测到的区域,去掉那些可能存在的旋转,修剪图像区域,重新设置图像的大小,并且均衡化修剪过的区域。
首先,我们需要通过函数 getRotationMatrix2D 来获得转换矩阵,用来去掉那些检测区域的旋转。我们需要注意高度,因为 RectatedRect 类能够被返回并且旋转了 90 度。因此我们必须检查矩形的宽高比,如果它小于 1 ,则进行 90 度的旋转。
[cpp] view plain copy
- //Get rotation matrix
- float r= (float)minRect.size.width / (float)minRect.size.height;
- float angle=minRect.angle;
- if(r<1)
- angle=90+angle;
- Mat rotmat= getRotationMatrix2D(minRect.center, angle,1);
用转换矩阵,我们现在能通过仿射变换旋转输入图像了(几何中的仿射变换是平行线到平行线(可以参考opencv中文版 186页,仿射变换和透视变换的区别)。在warpAffine函数中,我们设置输入输出图像,转换矩阵,输出图像的大小(在我们的程序中,我们使用和输入图像一样的大小),插值方法。如果我们需要的话,我们可以定义边界方法和边界值。
[cpp] view plain copy
- //Create and rotate image
- Mat img_rotated;
- warpAffine(input, img_rotated, rotmat, input.size(), CV_INTER_CUBIC);
我们旋转图像之后,我们用getRectSubPix函数来修剪图像,该函数修剪拷贝给定长度和宽度,以及中心点的图像部分。如果图像旋转了,我们需要使用C++swap函数来改变宽和高的大小。
[cpp] view plain copy
- //Crop image
- Size rect_size=minRect.size;
- if(r < 1)
- swap(rect_size.width, rect_size.height);
- Mat img_crop;
- getRectSubPix(img_rotated, rect_size, minRect.center, img_crop);
修剪的图像不能很好的在训练和分类中使用,因为他们没有相同的大小。并且,每个图像包含不同的光照条件,增加了他们之间的差别。为了解决这个问题。我们把所有的图像调整为统一大的大小,采用直方图均衡化。
[cpp] view plain copy
- Mat resultResized;
- resultResized.create(33,144, CV_8UC3);
- resize(img_crop, resultResized, resultResized.size(), 0, 0, INTER_
- CUBIC);
- //Equalize cropped image
- Mat grayResult;
- cvtColor(resultResized, grayResult, CV_BGR2GRAY);
- blur(grayResult, grayResult, Size(3,3));
- equalizeHist(grayResult, grayResult);
对于每一个检测到区域,我们存储修剪过的图像。把他们的位置存储在vector中.
[cpp] view plain copy
- output.push_back(Plate(grayResult,minRect.boundingRect()));
分类
我们预处理和分割图像的所有可能部分之后,我们现在需要判别每一个分割是不是一个车牌。为这样做,我们使用SVM算法。
支持向量机是一个模式识别算法,它是监督学习算法的一份子,最初是创建是为了二值分类的。有监督的学习是一种机器学习算法,它通过标签数据的使用进行学习。我们需要一些带有标签的数据来训练这个算法。每一个数据集需要有一个类别。
SVM创建一个或多个超平面,用来区分每类数据。
一个典型的例子是2维点集,它定义了两个类。SVM寻找最优线来区分每个类。
在任一分类之前的第一个任务是训练我们的分类器。这项工作的完成优先于开始主要的应用程序。它被称为离线训练。这不是一个简单的工作,因为它需要充足的数据来训练这个系统。但是大的数据集并不总是暗示最好的结果。在我们的例子中,我们没有充足的数据,是因为没有一个公共的车牌数据的事实。正因为如此,我们需要拍数百张车照,然后预处理和分割所有的照片。
我们用大小为144*33的75张车牌和35非车牌来训练我们的系统。我们在下面的图中能看到数据的一个样本。这并不是个大的数据集,对我们的需求来说,它已经可以充足的得到一个体面的结果。在实际的应用中,我们需要训练更多的数据。
很容易理解机器学习是怎样工作的,我们使用分类器算法的图像像素特征(想一下,有很多更好的方法和特征来训练一个SVM,比如主成分分析,傅里叶变换,纹理分析,等等)
我们需要通过DectectRegions类创建图像和训练我们的系统。把变量savingRegions设置为真用来保存图像。我们可以通过segementAllFiles.sh bash脚本文件把文件夹下的所有图像文件上重复这个过程。该文件可以从书的源代码中获得。
为了使这个更简单,我们存储了已经处理好和准备好是所有图像的数据,放在了xml文件里,之间使用SVM函数调用。trainSVM.cpp程序用一些文件夹下的数张图片文件创建的xml文件。
小心:为机器学习的Opencv算法训练的数据存储在一个N*M的矩阵中,N表示样本数,M表示特征数。每个数据集作为一行存储在训练矩阵中(就是N*M个像素点,展开成一行,作为训练矩阵的N*M个特征。详细可以参考trainSVM.cpp代码中)
类别存储在另外一个大小为N*1的矩阵中。每一个类通过一个浮点数来识别。(这里代码中是不是int?)
opencv有个简单的方式来管理xml或者Json个数的数据文件,即FileStorage类。这个类使我们存储和读取opencv变量和结构体或者我们传统的变量。使用这个函数,我们嫩而过读取训练的数据矩阵和训练的类别,并且把他们保存在SVM_TrainingData 和SVM_Classes中:
[cpp] view plain copy
- FileStorage fs;
- fs.open(”SVM.xml”, FileStorage::READ);
- Mat SVM_TrainingData;
- Mat SVM_Classes;
- fs[”TrainingData”] >> SVM_TrainingData;
- fs[”classes”] >> SVM_Classes;
现在我们需要设置SVM参数,定义最基本的参数来供SVM算法的使用。我们使用CvSVMParam结构来定义它。它是一个映射,用来把训练的数据提升到一个线性可分的数据集合。这种映射包括数据维数的增加,通过一个核函数可以有效的得到。我们在这里选用CvSVM::LINEAR类型,这就是意味着没有映射。
[cpp] view plain copy
- //Set SVM params
- CvSVMParams SVM_params;
- SVM_params.kernel_type = CvSVM::LINEAR;
这时我们创建和训练我们的分离器。Opencv为支持向量机算法定义了CvSVM类。我们用训练的数据来,类别和参数数据来初始化它。
[cpp] view plain copy
- CvSVM svmClassifier(SVM_TrainingData, SVM_Classes, Mat(), Mat(), SVM_
- params);
我们的分离器准备好了,我们可以使用SVM类的predict函数来预测一个可能的修剪图像。这个函数返回类别i。在我们的实例中,我们标记每一个车牌类别为1,非车牌类别标记为0。对于每个检测到的区域,我们使用SVM来分出它是车牌还是非车牌,并且只保存正确的响应。下面的代码是主程序的一部分,成为在线处理:
[cpp] view plain copy
- vector<Plate> plates;
- for(int i=0; i< possible_regions.size(); i++)
- {
- Mat img=possible_regions[i].plateImg;
- Mat p= img.reshape(1, 1);//convert img to 1 row m features
- p.convertTo(p, CV_32FC1);
- int response = (int)svmClassifier.predict( p );
- if(response==1)
- plates.push_back(possible_regions[i]);
- }
车牌识别
车牌识别目标的第二步就是用光符字符识别来获取车牌上的字符。对于每个检测到的车牌,我开始分割车牌得到每个字符,并且使用人工神经网络机器学习算法来识别字符。同时在这一部分我们也将学习怎么样评估一个分类算法。
OCR分割
首先,我们获得车牌图像的部分作为OCR分割函数是输入(已经均衡化直方图的图像)。我们应用一个阈值滤波器滤波,并把滤波后的阈值图像作为寻找轮廓算法的输入。我们可以通过下图看到过程:
分割处理的代码如下;
[cpp] view plain copy
- Mat img_threshold;
- threshold(input, img_threshold, 60, 255, CV_THRESH_BINARY_INV);
- if(DEBUG)
- imshow(”Threshold plate”, img_threshold);
- Mat img_contours;
- img_threshold.copyTo(img_contours);
- //Find contours of possibles characters
- vector< vector< Point> > contours;
- findContours(img_contours,
- contours, // a vector of contours
- CV_RETR_EXTERNAL, // retrieve the external contours
- CV_CHAIN_APPROX_NONE); // all pixels of each contour
我们使用CV_THRESH_BINARY参数通过把白色值变为黑色,黑色值变为白色来实现阈值输出的反转。因为我们需要获取字符的轮廓,而轮廓的算法寻找的是白色像素。
对于每一个检测到的轮廓,我们核实一下大小,去除那些规格太小的或者宽高比不正确的区域。字符是45/77的宽高比。我们允许用于选择或者扭曲带来的百分之35的误差。如果一个区域面积高于80%(就是像素大于0的超过80%),则我们认为这个区域是一个黑色块.(因为白变黑,黑变白处理了),不是字符。为了计算面积我们使用countNonZero函数来计算高于0值的像素值。
[cpp] view plain copy
- bool OCR::verifySizes(Mat r)
- {
- //Char sizes 45x77
- float aspect=45.0f/77.0f;
- float charAspect= (float)r.cols/(float)r.rows;
- float error=0.35;
- float minHeight=15;
- float maxHeight=28;
- //We have a different aspect ratio for number 1, and it can be
- //~0.2
- float minAspect=0.2;
- float maxAspect=aspect+aspect*error;
- //area of pixels
- float area=countNonZero(r);
- //bb area
- float bbArea=r.cols*r.rows;
- //% of pixel in area
- float percPixels=area/bbArea;
- if(percPixels < 0.8 && charAspect > minAspect && charAspect <
- maxAspect && r.rows >= minHeight && r.rows < maxHeight)
- return true;
- else
- return false;
- }
如果分割字符被证实了,我们必须对它进行预处理,设置同样的大小和所有字符的位置,把它存储在一个附加的charsegment类的对象中。这个类保存了字符的图像和位置,我们需要给字符排序,因为寻找轮廓的算法返回的轮廓并不是规定的顺序。
特征提取
下一步是对每个分割的字符进行特征提取用神经网络算法训练和分类。不像车牌检测特征提取的步骤那样使用的是SVM,我们不用图像的所有像素。我们使用一个更加通用的特征在光符字符识别中,其中包括水平和垂直累加直方图,一个低分辨率样本。我们在下图可以看到更加形象的特征,每个图像有一个5*5的低分辨率和直方图的累加。
对于每一个字符,我们用countNonZero计算每一行或者每一列的非零的个数,把他们存储在一个新的数据矩阵mhist中。我们使用minMaxLoc函数找到数据矩阵的最大值,并用这个值来归一化,即采用convertTo函数将mhist的所有元素都除以这个最大的值。我们创建一个ProjecteddHistotram函数来创建累积直方图,这个函数带有两个输入的参数,一个是一个二值图像,一个是我们需要的直方图的类型即水平或者垂直。
[cpp] view plain copy
- Mat OCR::ProjectedHistogram(Mat img, int t)
- {
- int sz=(t)?img.rows:img.cols;
- Mat mhist=Mat::zeros(1,sz,CV_32F);
- for(int j=0; j<sz; j++){
- Mat data=(t)?img.row(j):img.col(j);
- mhist.at<float>(j)=countNonZero(data);
- }
- //Normalize histogram
- double min, max;
- minMaxLoc(mhist, &min, &max);
- if(max>0)
- mhist.convertTo(mhist,-1 , 1.0f/max, 0);
- return mhist;
- }
其他特征使用一个低分辨率的样本图像。替代使用整个字符图像,我们创建一个低分辨率字符,例如5*5。我们用5*5,10*10,20*20大小的字符来训练我们的系统(本程序中Charsize=20)。然后评估哪一个返回的是最好的结果,然后我们在系统中使用它。一旦我们拥有了特征,我们创建一个M列的矩阵,矩阵的每一行的每一列都是特征值。
[cpp] view plain copy
- Mat OCR::features(Mat in, int sizeData)
- {
- //Histogram features
- Mat vhist=ProjectedHistogram(in,VERTICAL);
- Mat hhist=ProjectedHistogram(in,HORIZONTAL);
- //Low data feature
- Mat lowData;
- resize(in, lowData, Size(sizeData, sizeData) );
- int numCols=vhist.cols + hhist.cols + lowData.cols *
- lowData.cols;
- Mat out=Mat::zeros(1,numCols,CV_32F);
- //Assign values to feature
- int j=0;
- for(int i=0; i<vhist.cols; i++)
- {
- out.at<float>(j)=vhist.at<float>(i);
- j++;
- }
- for(int i=0; i<hhist.cols; i++)
- {
- out.at<float>(j)=hhist.at<float>(i);
- j++;
- }
- for(int x=0; x<lowData.cols; x++)
- {
- for(int y=0; y<lowData.rows; y++)
- {
- out.at<float>(j)=(float)lowData.at<unsigned char>(x,y);
- j++;
- }
- }
- return out;
- }
OCR分类
在分类这一部,我们使用人工神经网络机器学习算法。更具体一点,多层感知器(MLP),一个广泛使用的人工神经网络算法。
MLP神经网络有一个输入层,输出层和一个或多个隐层。每一层有一个活多个神经元连接着前向和后向层。
下面的例子表示一个3层感知器(它是一个二值分类器,它映射输入的一个实值向量,输出单一的二值),它带有三个输入,两个输出和一个含有5个神经元的隐层。
MLP中所有的神经元,都相似,每个神经元有几个输出(前向连接神经元)和几个输出连接着相同的值(后向连接神经元)。每个神经元用带有权重的输入的和加上一个偏移量再经过一个选择的激活函数转换后得到输出结果。
有三个广泛使用的激活函数:Identiy,S函数,高斯函数。最常用的默认的激活函数是S函数。其中alpha和beta设置为1:
一个训练人工神经网络的输入是一个特征矢量。它传输值到隐层。用权重和激活函数来计算结果。它进一步的把输出结果往下传输直到到达含有一定数量的神经元类别时。
每一层的权重,突触,和神经元通过训练神经网络算法来计算和学习。为了训练我们的分类器。在SVM训练时,我们创建两个数据矩阵,但是训练的标签有点不同。替代N*1的矩阵(这里N代表训练数据的行数,1是矩阵的列),那里我们使用了数字作为表示符。我们必须创建一个N*M大小的矩阵,这里的N是训练/样本的数据,M是类别(我们的示例中是10个数字和20个字母)。如果数据行i归宿与类别j,则我们将位置(i,j)的设置为1.
我们创建OCR::train函数来创建我们所需要的矩阵并且用训练数据矩阵,类别矩阵和隐层神经元的数目来训练我们的系统。训练数据从xml文件导入,就像我们为SVM训练做的那样。
我们必须定义每一层的神经元的数目来初始化ANN类。在我们的例子中,我们只只用了一个隐层,因此我们定义一个1行3列的矩阵。第一类位置是特征的数目,第二列位置是隐层所含隐藏的神经元的数目,第三列的位置是类的数目。
Opencv为ANN定义了一个CvANN_MLP类。通过定义的层的数目和原子数,激活函数和alpha和beta参数用创建函数来初始化类。
[cpp] view plain copy
- void OCR::train(Mat TrainData, Mat classes, int nlayers)
- {
- Mat layerSizes(1,3,CV_32SC1);
- layerSizes.at<int>(0)= TrainData.cols;
- layerSizes.at<int>(1)= nlayers;
- layerSizes.at<int>(2)= numCharacters;
- ann.create(layerSizes, CvANN_MLP::SIGMOID_SYM, 1, 1); //ann is
- global class variable
- //Prepare trainClasses
- //Create a mat with n trained data by m classes
- Mat trainClasses;
- trainClasses.create( TrainData.rows, numCharacters, CV_32FC1 );
- for( int i = 0; i < trainClasses.rows; i++ )
- {
- for( int k = 0; k < trainClasses.cols; k++ )
- {
- //If class of data i is same than a k class
- if( k == classes.at<int>(i) )
- trainClasses.at<float>(i,k) = 1;
- else
- trainClasses.at<float>(i,k) = 0;
- }
- }
- Mat weights( 1, TrainData.rows, CV_32FC1, Scalar::all(1) );
- //Learn classifier
- ann.train( TrainData, trainClasses, weights );
- trained=true;
- }
训练之后,我们能使用OCR::classify函数对任何分割的特征车牌进行分类。
[cpp] view plain copy
- int OCR::classify(Mat f)
- {
- int result=-1;
- Mat output(1, numCharacters, CV_32FC1);
- ann.predict(f, output);
- Point maxLoc;
- double maxVal;
- minMaxLoc(output, 0, &maxVal, 0, &maxLoc);
- //We need to know where in output is the max val, the x (cols) is
- //the class.
- return maxLoc.x;
- }
CvANN_MLP类使用predict函数来为一个特征矢量分类。不想SVM的分类函数,ANN的predict函数返回的是一个矢量,其大小和类的数目相同,带有属于每一个类的输入特征的几率。(计算矢量中的每一个值,相当于该矢量属于概率的概率,大题可以这样认为)
为了得到更好的效果,我们使用minMaxLoc函数来获得响应的最大数和最小数(这里只用了最大数),以及在矩阵中的位置。我们字符的类通过x位置的一个高值(值大的)来指定。
为了完成每一个车牌的检测,我们对字符排好序,用Plate类的str()函数来返回一个string。并且我们可以在原图上画出来。
[cpp] view plain copy
- string licensePlate=plate.str();
- rectangle(input_image, plate.position, Scalar(0,0,200));
- putText(input_image, licensePlate, Point(plate.position.x, plate.
- position.y), CV_FONT_HERSHEY_SIMPLEX, 1, Scalar(0,0,200),2);
评估
我们的工程完成了,但是当我们训练一个机器学习算法比如Ocr,例如,我们需要指定最好的特征和参数来使用,怎样在我们的系统中正确的分类,识别和检测错误。
我们需要用不同的情况和参数来评估我们的系统,评价错误的产生,和得到最好的参数来使这些错误最小化。
在这一章,我们评估OCR任务使用下面的变量:低分辨率图像的大小和隐层含有的神经元的数目。
我们已经创建了evalOCR.cpp程序,在那里我们将使用通过trainOCR.cpp产生的XML训练数据文件。OCR.xml文件包含5*5,10*10,15*15,20*20下采样图像特征构成的训练数据矩阵。
[cpp] view plain copy
- Mat classes;
- Mat trainingData;
- //Read file storage.
- FileStorage fs;
- fs.open(”OCR.xml”, FileStorage::READ);
- fs[data] >> trainingData;
- fs[”classes”] >> classes;
评估应用获取每一个下采样矩阵特征,获取100个随机行用来训练,和其他用来测试ANN算法的行一样并且检查错误。
在训练系统之前,我们测试每一个随机样本,检查一下响应是不是正确。如果响应不正确,我们增加错误计算变量,接着除以样本的数量用来评估。这表示用随机数据训练的错误(该值在0到1之间)
[cpp] view plain copy
- float test(Mat samples, Mat classes)
- {
- float errors=0;
- for(int i=0; i<samples.rows; i++)
- {
- int result= ocr.classify(samples.row(i));
- if(result!= classes.at<int>(i))
- errors++;
- }
- return errors/samples.rows;
- }
这个程序返回每一个样本集的错误率。对于一个好的评估,我们需要用不同的随机训练行数据来训练我们的程序。这会产生不同的测试错误值。我们可以把这样错误加起来,然后求平均值。为了完成这个任务。我们创建了下面的bash unix脚本来自动执行:
[html] view plain copy
- #!/bin/bash
- echo ”#ITS \t 5 \t 10 \t 15 \t 20” > data.txt
- folder=$(pwd)
- for numNeurons in 10 20 30 40 50 60 70 80 90 100 120 150 200 500
- do
- s5=0;
- s10=0;
- s15=0;
- s20=0;
- for j in {1..100}
- do
- echo numNeurons j
- a= ( folder/build/evalOCR $numNeurons TrainingDataF5)
- s5=(echo "</span><span class="attribute" style="margin:0px; padding:0px; border:currentColor; color:red; background-color:inherit">scale</span><span style="margin:0px; padding:0px; border:currentColor; background-color:inherit">=</span><span class="attribute-value" style="margin:0px; padding:0px; border:currentColor; color:blue; background-color:inherit">4</span><span style="margin:0px; padding:0px; border:currentColor; background-color:inherit">; scale=4; s5+$a” | bc -q 2>/dev/null)
- a= ( folder/build/evalOCR $numNeurons TrainingDataF10)
- s10=(echo "</span><span class="attribute" style="margin:0px; padding:0px; border:currentColor; color:red; background-color:inherit">scale</span><span style="margin:0px; padding:0px; border:currentColor; background-color:inherit">=</span><span class="attribute-value" style="margin:0px; padding:0px; border:currentColor; color:blue; background-color:inherit">4</span><span style="margin:0px; padding:0px; border:currentColor; background-color:inherit">; scale=4; s10+$a” | bc -q 2>/dev/null)
- a= ( folder/build/evalOCR $numNeurons TrainingDataF15)
- s15=(echo "</span><span class="attribute" style="margin:0px; padding:0px; border:currentColor; color:red; background-color:inherit">scale</span><span style="margin:0px; padding:0px; border:currentColor; background-color:inherit">=</span><span class="attribute-value" style="margin:0px; padding:0px; border:currentColor; color:blue; background-color:inherit">4</span><span style="margin:0px; padding:0px; border:currentColor; background-color:inherit">; scale=4; s15+$a” | bc -q 2>/dev/null)
- a= ( folder/build/evalOCR $numNeurons TrainingDataF20)
- s20=(echo "</span><span class="attribute" style="margin:0px; padding:0px; border:currentColor; color:red; background-color:inherit">scale</span><span style="margin:0px; padding:0px; border:currentColor; background-color:inherit">=</span><span class="attribute-value" style="margin:0px; padding:0px; border:currentColor; color:blue; background-color:inherit">4</span><span style="margin:0px; padding:0px; border:currentColor; background-color:inherit">; scale=4; s20+$a” | bc -q 2>/dev/null)
- done
- echo ”i \t s5 \t s10 \t s15 \t $s20”
- echo ”i \t s5 \t s10 \t s15 \t $s20” >> data.txt
- done
这个脚本保存了一个data.text文件,它包含了每个大学的所有结果和隐层神经元的数目。这个可以使用gnuplot画出来。我们可以在下面的图中看到结果:
我们可以看到最低的错误在8%以下,它是使用隐层包含20个神经元和从规格是10*10d的图像块中提取的特征。
总结
在这一章,我们学习了自动车牌识别项目是怎么工作的,并且它有两个重要的步骤:
车牌定位和车牌识别。
在第一步中,我们学习了怎么样分割图像来找到存在车牌的部分。和怎么样用一个简单的启发法和支持向量机来对有车牌和无车牌进行二值分类。
在第二步我们学习了怎么样通过轮廓算法来分割图像。从每个字符提取特征向量,并且使用人工神经网络把对特征进行分类,分到一个字符类中。
我们也学习了怎么样用训练随机样本来评估一个机器算法。并且使用不同的参数和特征对它进行评估。
[cpp] view plain copy
- <pre code_snippet_id=“138968” snippet_file_name=“blog_20140102_4_5303797”></pre>
- <pre></pre>
- <pre></pre>
- <pre></pre>
- <pre></pre>
- <pre></pre>
- <pre></pre>
- <pre></pre>
- <pre></pre>
- <pre></pre>
- <pre></pre>
- <pre></pre>
- <pre></pre>
- <pre></pre>
- <pre></pre>
- <pre></pre>
- <pre></pre>
- <pre></pre>