OpenCV进阶之路:神经网络识别车牌字符
原文出处: Ronny 的博客(@RonnyYoung)
1. 关于OpenCV进阶之路
前段时间写过一些关于OpenCV基础知识方面的系列文章,主要内容是面向OpenCV初学者,介绍OpenCV中一些常用的函数的接口和调用方法,相关的内容在OpenCV的手册里都有更详细的解释,当时自己也是边学边写,权当为一种笔记的形式,所以难免有浅尝辄止的感觉,现在回头看来,很多地方描述上都存在不足,以后有时间,我会重新考虑每一篇文章,让成长系列对基础操作的介绍更加详细一些。
OpenCV进阶之路相比于成长系列,不会有太多的基础函数的介绍,相对来说会更偏向于工程实践,通过解决实际问题来说明某些较高级函数的用法和注意事项,主要内容会集中在特征提取、机器学习和目标跟踪几个方向。所以这个系列文章知识点没有先后顺序之分,根据个人平时工作学习中遇到的问题而定。
这篇文章主要介绍OpenCV中神经网络的用法,并通过车牌字符的识别来说明一些参数设置,函数调用顺序等,而关于神经网络的原理在博客机器学习分类里已经详细的讲解与实现了,所以本文中就不多加说明。
2. 车牌字符识别
车牌识别是计算机视觉在实际工程中一个非常成功的应用,虽然现在技术相对来说已经成熟,但是围绕着车牌定位、车牌二值化、车牌字符识别等方向,还是不时的有新的算法出现。通过学习车牌识别来提升自己在图像识别方面的工程经验是非常好的,因为它非常好的说明了计算机视觉的一般过程:
图像→预处理→图像分析→目标提取→目标识别
而整个车牌识别过程实际上相当于包含了两个上述过程:1,是车牌的识别;2,车牌字符的识别。
这篇文章其实主要是想介绍OpenCV中神经网络的用法,而不是介绍车牌识别技术。所以我们主要讨论的内容集中在车牌字符的识别上,关于定位、分割等不多加叙述叙述。
3. 字符特征提取
在深度学习(将特征提取作为训练的一部分)这个概念引入之前,一般在准备分类器进行识别之前都需要进行特征提取。因为一幅图像包含的内容太多,有些信息能区分差异性,而有些信息却代表了共性。所以我们要进行适当的特征提取把它们之间的差异性特征提取出来。
这里面我们计算二种简单的字符特征:梯度分布特征、灰度统计特征。这两个特征只是配合本篇文章来说明神经网络的普遍用法,实际中进行字符识别需要考虑的字符特征远远要比这复杂,还包括相似字特征的选取等,也由于工作上的原因,这一部分并不深入的介绍。
1,首先是梯度分布特征,该特征计算图像水平方向和竖直方向的梯度图像,然后通过给梯度图像分划不同的区域,进行梯度图像每个区域亮度值的统计,以下是算法步骤:
<1>将字符由RGB转化为灰度,然后将图像归一化到16*8。 <2>定义soble水平检测算子:x_mask=[−1,0,1;−2,0,2;–1,0,1]和竖直方向梯度检测算子y_mask=x_maskT。 <3>对图像分别用mask_x和mask_y进行图像滤波得到SobelX和SobelY,下图分别代表原图像、SobelX和SobelY。 <4>对滤波后的图像,计算图像总的像素和,然后划分4*2的网络,计算每个网格内的像素值的总和。 <5>将每个网络内总灰度值占整个图像的百分比统计在一起写入一个向量,将两个方向各自得到的向量并在一起,组成特征向量。
void calcGradientFeat(const Mat& imgSrc, vector<float>& feat) { float sumMatValue(const Mat& image); // 计算图像中像素灰度值总和 Mat image; cvtColor(imgSrc,image,CV_BGR2GRAY); resize(image,image,Size(8,16)); // 计算x方向和y方向上的滤波 float mask[3][3] = { { 1, 2, 1 }, { 0, 0, 0 }, { -1, -2, -1 } }; Mat y_mask = Mat(3, 3, CV_32F, mask) / 8; Mat x_mask = y_mask.t(); // 转置 Mat sobelX, sobelY; filter2D(image, sobelX, CV_32F, x_mask); filter2D(image, sobelY, CV_32F, y_mask); sobelX = abs(sobelX); sobelY = abs(sobelY); float totleValueX = sumMatValue(sobelX); float totleValueY = sumMatValue(sobelY); // 将图像划分为4*2共8个格子,计算每个格子里灰度值总和的百分比 for (int i = 0; i < image.rows; i = i + 4) { for (int j = 0; j < image.cols; j = j + 4) { Mat subImageX = sobelX(Rect(j, i, 4, 4)); feat.push_back(sumMatValue(subImageX) / totleValueX); Mat subImageY= sobelY(Rect(j, i, 4, 4)); feat.push_back(sumMatValue(subImageY) / totleValueY); } } } float sumMatValue(const Mat& image) { float sumValue = 0; int r = image.rows; int c = image.cols; if (image.isContinuous()) { c = r*c; r = 1; } for (int i = 0; i < r; i++) { const uchar* linePtr = image.ptr<uchar>(i); for (int j = 0; j < c; j++) { sumValue += linePtr[j]; } } return sumValue; }
2,第二个特征非常简单,只需要将图像归一化到特定的大小,然后将图像每个点的灰度值作为特征即可。
<1>将图像由RGB图像转换为灰度图像;
<2>将图像归一化大小为8×4,并将图像展开为一行,组成特征向量。
4. OpenCV中的神经网络
关于神经网络的原理我的博客里已经写了两篇文章,并且给出了C++的实现,所以这里我就不提了,下面主要说明在OpenCV中怎么使用它提供的库函数。
CvANN_MLP是OpenCV中提供的一个神经网络的类,正如它的名字一样(multi-layer perceptrons),它是一个多层感知网络,它有一个输入层,一个输出层以及1或多个隐藏层。
4.1. 首先我们来创建一个网络,我们可以利用CvANN_MLP的构造函数或者create函数。
CvANN_MLP::CvANN_MLP(const Mat& layerSizes, int activateFunc=CvANN_MLP::SIGMOID_SYM, double fparam1=0, double fparam2=0 ); void CvANN_MLP::create(const Mat& layerSizes, int activateFunc=CvANN_MLP::SIGMOID_SYM, double fparam1=0, double fparam2=0 );
上面是分别是构造函数和cteate成员函数的接口,我们来分析各个形参的意思。
layerSizes:一个整型的数组,这里面用Mat存储。它是一个1*N的Mat,N代表神经网络的层数,第i列的值表示第i层的结点数。这里需要注意的是,在创建这个Mat时,一定要是整型的,uchar和float型都会报错。
比如我们要创建一个3层的神经网络,其中第一层结点数为x1,第二层结点数为x2,第三层结点数为x3,则layerSizes可以采用如下定义:
Mat layerSizes=(Mat_<int>(1,3)<<x1,x2,x3);
或者用一个数组来初始化:
int ar[]={x1,x2,x3}; Mat layerSizes(1,3,CV_32S,ar);
activateFunc:这个参数用于指定激活函数,不熟悉的可以去看我博客里的这篇文章《神经网络:感知器与梯度下降》,一般情况下我们用SIGMOID函数就可以了,当然你也可以选择正切函数或高斯函数作为激活函数。OpenCV里提供了三种激活函数,线性函数(CvANN_MLP::IDENTITY)、sigmoid函数(CvANN_MLP::SIGMOID_SYM)和高斯激活函数(CvANN_MLP::GAUSSIAN)。
后面两个参数则是SIGMOID激活函数中的两个参数α和β,默认情况下会都被设置为1。
4.2. 设置神经网络训练参数
神经网络训练参数的类型存放在CvANN_MLP_TrainParams这个类里,它提供了一个默认的构造函数,我们可以直接调用,也可以一项一项去设。
CvANN_MLP_TrainParams::CvANN_MLP_TrainParams() { term_crit = cvTermCriteria( CV_TERMCRIT_ITER + CV_TERMCRIT_EPS, 1000, 0.01 ); train_method = RPROP; bp_dw_scale = bp_moment_scale = 0.1; rp_dw0 = 0.1; rp_dw_plus = 1.2; rp_dw_minus = 0.5; rp_dw_min = FLT_EPSILON; rp_dw_max = 50.; }
它的参数大概包括以下几项。
term_crit:终止条件,它包括了两项,迭代次数(CV_TERMCRIT_ITER)和误差最小值(CV_TERMCRIT_EPS),一旦有一个达到条件就终止训练。
train_method:训练方法,OpenCV里提供了两个方法一个是很经典的反向传播算法BACKPROP,另一个是弹性反馈算法RPROP,对第二种训练方法,没有仔细去研究过,这里我们运用第一种方法。
剩下就是关于每种训练方法的相关参数,针对于反向传播法,主要是两个参数,一个是权值更新率bp_dw_scale和权值更新冲量bp_moment_scale。这两个量一般情况设置为0.1就行了;太小了网络收敛速度会很慢,太大了可能会让网络越过最小值点。
我们一般先运用它的默认构造函数,然后根据需要再修改相应的参数就可以了。如下面代码所示,我们将迭代次数改为了5000次。
CvANN_MLP_TRainParams param; param.term_crit=cvTermCriteria(CV_TerMCrIT_ITER+CV_TERMCRIT_EPS,5000,0.01);
4.3. 神经网络的训练
我们先看训练函数的接口,然后按接口去准备数据。
int CvANN_MLP::train(const Mat& inputs, const Mat& outputs, const Mat& sampleWeights, const Mat& sampleIdx=Mat(), CvANN_MLP_TrainParams params=CvANN_MLP_TrainParams(), int flags=0 );
inputs:输入矩阵。它存储了所有训练样本的特征。假设所有样本总数为nSamples,而我们提取的特征维数为ndims,则inputs是一个nSamples∗ndims的矩阵,我们可以这样创建它。
Mat inputs(nSamples,ndims,CV_32FC1); //CV_32FC1说明它储存的数据是float型的。
我们需要将我们的训练集,经过特征提取把得到的特征向量存储在inputs中,每个样本的特征占一行。
outputs:输出矩阵。我们实际在训练中,我们知道每个样本所属的种类,假设一共有nClass类。那么我们将outputs设置为一个nSample行nClass列的矩阵,每一行表示一个样本的预期输出结果,该样本所属的那类对应的列设置为1,其他都为0。比如我们需要识别0-9这10个数字,则总的类数为10类,那么样本数字“3”的预期输出为[0,0,1,0,0,0,0,0,0,0];
sampleWeights:一个在使用RPROP方法训练时才需要的数据,所以这里我们不设置,直接设置为Mat()即可。
sampleIdx:相当于一个遮罩,它指定哪些行的数据参与训练。如果设置为Mat(),则所有行都参与。
params:这个在刚才已经说过了,是训练相关的参数。
flag:它提供了3个可选项参数,用来指定数据处理的方式,我们可以用逻辑符号去组合它们。UPDATE_WEIGHTS指定用一定的算法去初始化权值矩阵而不是用随机的方法。NO_INPUT_SCALE和NO_OUTPUT_SCALE分别用于禁止输入与输出矩阵的归一化。
一切都准备好后,直接开始训练吧!
4.4. 识别
识别是通过Cv_ANN_MLP类提供的predict来实现的,知道原理的会明白,它实际上就是做了一次向前传播。
float CvANN_MLP::predict(const Mat& inputs, Mat& outputs) const
在进行识别的时候,我们对图像进行特征提取,把它保存在inputs里,通过调用predict函数,我们得到一个输出向量,它是一个1*nClass的行向量,其中每一列说明它与该类的相似程度(0-1之间),也可以说是置信度。我们只用对output求一个最大值,就可得到结果。这个函数的返回值是一个无用的float值,可以忽略。
5. 车牌字符识别测试
1,我们需要读取所有的训练样本,将它们的路径在保存在vector<string>中。
这里面我的车牌字符,因为1和I、0和O是一样的,所以数字加字母一共34类,其中每类有200个样本图像,共34*200个训练样本。
2,计算特征。我们按顺序读入图像,调用特征计算函数,把得到的结合保存在input对应的行中,同时把图像对应的预期输出保存在output中。
3,创建神经网络,这里我们计算得到的特征维数为48维,所以我们简单的设计一个3层的神经网络,输入层有48个结点,隐藏层也为48个结点,输出层为34个结点。然后神经网络的训练方法选用BACKPROP,迭代次数设置为5000次。
4,调用训练函数进行训练,并保存训练得到的权值矩阵,直接调用save成员函数即可。
nnetwork.save(“mlp.xml”);
5,识别测试,我们可以用单张图像进行测试,也可以选定一个测试集去进行测试,比如可以用一半的图像作为训练集,一半的图像作为测试集。这里我们可以加载已经训练好的权值矩阵,而不用重新训练,只要开始有保存了xml文件。但是记得你还是要创建一个网络后,才能加载进来。
int NNClassifier::classifier(const Mat& image) { Mat nearest(1, nclass, CV_32FC1, Scalar(0)); Mat charFeature; calcFeature(image, charFeature); neuralNetwork.predict(charFeature, nearest); Point maxLoc; minMaxLoc(nearest, NULL, NULL, NULL, &maxLoc); int result = maxLoc.x; return result; }
这里我简单的做了一下测试,在这两个特征下,网络设置为3层[48,48,34],一半图像为测试集,得到的识别率为98%,我相信通过尝试调整网络的层数以及选用更好的特征,一定会得到更满意的识别率。PS(工作中用的是SVM识别器,正常采集到的车牌,字符识别率在99.8%以上)。但是神经网络识别器有个很大的优点就是,一旦网络训练好,识别需要的数据文件非常小,而且速度很快。
6. 字符样本的下载
看到文章下的评论多是需求字符样本的,希望拿到字符样本的同学不要将其用于商业用途或者创建分享下载的链接。博文里用的样本是每类200张图像的测试样本,下面给出一份每类50个图像的样本子集,我以为用来做学术测试已经够了,出于公司利益考虑,请勿再向我索要完整样本。
百度网盘:http://pan.baidu.com/s/1mgLUWBm