knn&parzen窗的验证码识别程序的实现

来源:互联网 发布:数码宝贝网络侦探练级 编辑:程序博客网 时间:2024/05/17 02:05

1.写在前面

作为一个新入门图像处理&计算机视觉&模式识别领域的学生,我非常希望可以得到这样的一份代码来帮助我快速入门:该代码完成了一项具体的工作,代码长度在1000行左右,结构比较清楚,注释相对较全,适合新手阅读;并且在每个关键的函数上有说明该操作的名称(原理),以及应用在此场景的原因,最好能引出一些相关的其他应用。可能是我搜索水平有限,却始终没有如愿以偿,至今仍在入门的边缘摸爬滚打。最近模式识别课要实现K-Nearest Neighbor和parzen window两种算法,刚好借此机会,查阅了一些相关资料,做了一个验证码识别的小程序,大概有不到一千行。因为是一个入门2周的新手写的程序,程序大概前后写了一周,所以没有用到什么高深的算法,应该算是简单易懂,适合入门者了解一些基本的图像处理操作和这两个算法。前人栽树,后人乘凉,我希望把自己的曲折道路以及路上的探索都记录下来,以便后入门者可以少走弯路,从应用出发快速地入门这个领域。

2.综述

本程序包含以下5个部分:
1. 验证码生成:生成验证码,用于后续的生成训练集合测试集
2. 验证码预处理:对生成的验证码做预处理

3. 字符分割:分割出验证码的字符,并提取分割后的区域

4. 特征提取:对分割出来的字符提取特征,用于识别字符
5. 字符识别:用knn和parzen窗计算概率密度,对字符分类
代码中包含以下5个文件:
1. main.cpp : 程序的主体部分,顺序地执行生成训练集->生成parzen窗算法的参数->测试并且显示->测试正确率
2. generateCode.cpp : 生成验证码,生成训练集
3. codeProcess.cpp : 所有与图像处理相关的函数都在这个文件下面,包括预处理,图像分割,特征提取,字符识别等函数。
4. ml.cpp : 所有与机器学习相关的代码都在这个文件下面,包含knn,parzen窗,以及简易的直方图等估计概率密度的方法。
5. constant.h : 一些预处理的宏定义,一些参数的宏定义,以及一个结构体和宏定义操作

3.分步详解

3.1图像处理相关

3.1.1图像显示

如下图所示,整个结果图像会显示在一个这一个窗口之中,此窗口是一个三通道图:

整个图片由下面的Mat产生,所有的小图片(验证码,预处理结果,分割结果,识别结果,真实值)都是在mat中提取兴趣域,然后将图片复制到兴趣域上面显示的。

Mat Background(window_height, window_width, CV_8UC3, Scalar(192, 192, 192));int n = 0;
其中n为循环的标志,从0开始,表示这是n+1个用于显示的测试样例。因为要提取较多的兴趣域,所以我将提取兴趣域用一个宏定义写出:
//创建一个兴趣区#define ADDRIO(_dst,_image,_width,_n,_start) do{\_start += gap;\_dst=_image(Range(gap * ((_n)+2) + ((_n)+1)*code_height, ((_n) + 2)*(code_height + gap)), Range((_start), (_start) + (_width))); \_start += (_width);\}while (0)
其中code_height,gap分别代表验证码的高度和显示列表的间隙,详见constant.h文件。
这段代码从Mat _image中提取一个宽度为_width,高度为code_height的兴趣域,其中初始的高度为第n+1层的高度,初始的左边缘为_start,并将感兴趣域赋给_dst;

以makeTitle为例讲一下这个图片的具体构造,下面的显示验证码部分的原理相同。
void makeTitle(Mat& Background){int start = 0;Mat rio1, rio2, rio3, rio4,rio5;//创建五个兴趣域ADDRIO(rio1, Background, code_width, -1, start);putText(rio1, "CodeImage", Point2d(0, 4 * code_height / 5), 4,0.7, Scalar(0, 0, 0), 1, 8);ADDRIO(rio2, Background, code_width, -1, start);putText(rio2, "Extraction", Point2d(0, 4 * code_height / 5), 4,0.7, Scalar(0, 0, 0), 1, 8);ADDRIO(rio3, Background, letter_width*4+gap*3, -1, start);putText(rio3, "Split", Point2d(0, 4 * code_height / 5), 4,1, Scalar(0, 0, 0), 1, 8);ADDRIO(rio4, Background, code_width, -1, start);putText(rio4, "Result", Point2d(0, 4 * code_height / 5), 4,1, Scalar(0, 0, 0), 1, 8);ADDRIO(rio5, Background, code_width, -1, start);putText(rio5, "Code", Point2d(0, 4 * code_height / 5), 4,1, Scalar(0, 0, 0), 1, 8);}

首先创建五个兴趣域,分别记为rio1,...,rio5,n=-1代表所有的兴趣域都提取在第0行上面,定义一个变量start代表该行的左边缘位置。
分别使用ADDRIO创建兴趣域,第一,二,五个兴趣域的宽度均为code_width,因为这三个列都显示一个验证码,第三个兴趣域的宽度是letter_width*4+gap*3,因为该列显示分割之后的字符。第四列显示识别结果,这里为了方便,依然让第四列的宽度为code_width。每次生成兴趣域之后,start会自动加上间隙和宽度,因此不需要手动地去更改start的值。
下面显示验证码,识别结果,分割结果的图原理相同。需要注意的是,这个用于显示的图是三通道图,而验证码在预处理之后均为单通道图,因此需要将单通道图转换为三通道图用于显示,这就是generateCode(),preProcess(),splitCode()三个函数重载的原因,在生成要显示的若干个测试时,需要转换为三通道图显示,而生成训练集,生成测试集则不需要进行这些额外的操作。

3.1.2验证码生成

验证码的生成大概分以下几步:
1. 噪声。遍历图片,生成随机噪音点;此处采用遍历地址来遍历图片,对每个点,生成一个0-9之间的随机数,若随机数大于6,则赋予随机颜色的噪音。这样噪声的密度会小于30%(有字符的地方会覆盖噪声);
2. 加入干扰线。干扰线是5-8个大小,圆心位置,以及旋转角度都不确定的椭圆;
3. 生成字符。字符初始位置都不确定,但是保证了一定是按照四个字符生成的顺序从左到右排列的,尽量避免字符重叠;偶尔有重叠的现象,会在3.1.4验证码分割一节讲处理办法。如果验证码是用于生成训练集,则会顺次填入label中(label是训练集的标签);
4. 复制并显示。如果是为了显示的测试,则codeShow传入非空的参数,会将code输出到codeShow上面去显示,以与识别的结果做对比。如果是生成训练集或者测试集,则不需要显示正确的验证码,因此传入Mat()这个对象,此对象的data为NULL,故判断的是if(codeShow.data)...
此函数有三个重载函数,分别对应以下应用场景:

void generateCode(char *code, Mat& codeShow, Mat& identifyingCode, int* labels){...}
//用于显示训练集,传入的特殊参数是标签集lablesvoid generateCode(char *code, Mat& identifyingCode,int* labels){    generateCode(code, Mat(), identifyingCode,labels);}//用于生成显示的测试,传入的特殊参数是codeShow用于显示void generateCode(char *code, Mat& codeShow, Mat& identifyingCode){    generateCode(code, codeShow, identifyingCode, NULL);}//用于生成测试集,批量测试来测试准确率,无特殊参数void generateCode(char *code, Mat& identifyingCode){    generateCode(code, Mat(), identifyingCode, NULL);}

3.1.3验证码预处理

首先,将三通道图转换为灰度图。
cvtColor(image, gray, CV_BGR2GRAY);

然后,计算灰度图的颜色直方图。预先统计每种颜色大概的频度,发现最小的单词大概是90,因此把阈值设置为75(略小于最小频度)。遍历灰度直方图,创建一个查找表LUT,颜色频率在75以上的位置置为255,其余的置为0。此处需要考虑一种特殊情况,即为背景色的频度非常高,因此将频度在1000以上的同样置为0。调用LUT函数,实现元素批量查找个更改操作,此操作比遍历图像效率更高。此处需要注意的是,直方图是col=1,row=256的,因此需要用hist.at<float>(i),而不能从头往后读内存来获取。
int histSize = 256;float range[] = { 0, 256 };const float* histRange = { range };calcHist(&gray, 1, 0, Mat(), hist, 1, &histSize, &histRange, true, false);int i;uchar *q;q = lut.ptr<uchar>(0);for (i = 0; i < 256; i++){int fre = cvRound(hist.at<float>(i));q[i] = fre < 75 ? 0 : fre>1000 ? 0 : 255;}LUT(gray, lut, gray);

此举可以剔除出现频率很低的点和线,删除掉大量的噪声点。
最后进行开运算。开运算的模板是2*2的方块,用来剔除孤立的点和宽度为1的线。开运算就是先腐蚀再膨胀,需要注意的点是,腐蚀和膨胀都是相对于二值图中的白色部分,所以需要是黑色背景,白色文字。
Mat element1 = getStructuringElement(MORPH_RECT, Size(2, 2),Point(0, 0));Mat element2 = getStructuringElement(MORPH_RECT, Size(2, 2), Point(1, 1));erode(gray, gray, element1);dilate(gray, gray, element2);
还有一个需要注意的地方,开运算如果模板不是中心对称的,则膨胀操作用的模板需要是腐蚀模板关于中心点对称的模板,否则在该复原的地方无法复原,反而产生一种“平移”的效果,影响处理结果。此处getStructuringElement()的最后一个参数指明模板的中心点,一个是(0,0),一个是(1,1)保证了两个模板是对称的。
之后如果需要显示图像的话,复制到三通道图用于显示即可。
此函数只有一个重载函数。
//用于生成训练集合测试集的预处理void preProcess(Mat& image, Mat& gray){preProcess(image, gray, Mat());}

3.1.4验证码分割

首先,图像分割的第一步通常是轮廓查找,轮廓查找会直接在原图像上进行一些奇怪的操作导致原图像很模糊,因此需要将图像复制一份,用复制的图像去查找轮廓。
vector<vector<Point>>contours;Mat tempGrayCode(code_height, code_width, CV_8UC1);threshold(grayCode, tempGrayCode, 48, 255, CV_THRESH_BINARY);

查找轮廓只需要调用findContours即可。

findContours(tempGrayCode, contours, RETR_EXTERNAL, CHAIN_APPROX_SIMPLE);
由于查找出来的轮廓顺序不是很清楚,所以需要按x轴的位置进行排序,保证左边的轮廓在前,右边的轮廓在后,以便字符分割之后左边的字符在前,右边的字符在后
sort(contours.begin(), contours.end(), SortByX);
其中sortByX是定义的排序函数。保证contours按照轮廓的第一个点的x轴位置从小到大排序。
由于前面预处理之后还会有一些细小的点和线,因此需要进一步处理。找到每个轮廓的最小包围矩形,计算该矩形的面积,如果面积过小(<100),我们认为它是噪声点或者噪声线,予以剔除。方法是将该轮廓从contours矢量中移出,然后放到fill的队列中,遍历所有的轮廓之后,在原图上填充这些噪声轮廓。填充时调用drawContours函数,thickness为-1表示内部填充。
//各个轮廓的最小包围矩形vector<vector<Point>> fill;for (vector<vector<Point>>::iterator i = contours.begin(); i != contours.end();){Rect r = boundingRect(Mat(*i));Mat temp = grayCode(r);//去除小的椒盐噪声和i,j上面的点的干扰 (*i).size是边框中角点的个数,所以要求面积if (temp.cols*temp.rows < 100) {fill.push_back(*i);i=contours.erase(i);continue;}//防止i==contours.begin()时越界出错else{i++;}if (identifyingCode.data){rectangle(identifyingCode, r, Scalar(0, 255, 0), 1);}}//用背景色填充灰度图的小区域drawContours(grayCode, fill, -1, 0, -1);

之后就可以提取字符区域了。此时我们需要判断恰好剩余有四个轮廓,如果不是,则直接返回0,表示分割字符失败,交由3.3节的训练测试模块处理。否则直接生成最小包围矩形,并且提取这个最小包围矩形。将提取之后的区域交由3.2.1节的特征提取函数提取字符特征。如果需要显示的话,将提取之后的字符区域调整大小并复制到显示的兴趣域中。代码如下:
Mat temp[4];if (contours.size()!= 4) return 0;for (vector<vector<Point>>::iterator i = contours.begin(); i != contours.end();i++){Rect r = boundingRect(Mat(*i));temp[i - contours.begin()] = grayCode(r);calcPattern(temp[i - contours.begin()], *i, pt[i - contours.begin()]);if (letter1.data){resize(temp[i - contours.begin()], temp[i - contours.begin()], Size(letter_width, code_height), 0, 0, INTER_CUBIC);}}

3.2模式识别相关

3.2.1特征提取

由于到目前为止,我还没有学过任何关于特征提取,特征生成,以及检验特征优劣的方法,只能凭感觉提取一些比较简单的特征。此处我一个提取了8个特征,分别是:
1.轮廓的面积和长度
2.轮廓最小包围矩形的长和宽
3.轮廓的hu矩(第一个和第二个)
Moments mu = moments(contours, false);pt[n++] = mu.m00;//特征1:轮廓面积pt[n++] = arcLength(contours, true); //特征2:轮廓长度pt[n++] = grayCode.cols;  //特征3:字符宽度pt[n++] = grayCode.rows;  //特征4:字符图高度double hu[8] = { 0 };HuMoments(mu, hu);pt[n++] = hu[0];  //特征5:hu矩1pt[n++] = hu[1];  //特征6:hu矩2
4.水平分布直方图的方差和垂直分布直方图的方差,水平方向计算的代码如下,同理计算垂直方向的方差
int colHist[letter_width]; //水平分布直方图//水平分布直方图方差int sum=0;double ave = 0;double accum = 0;for (int i = 0; i < row; i++){sum += rowHist[i];}ave = sum / (double)row;for (int i = 0; i < row; i++){accum += (rowHist[i] - ave)*(rowHist[i] - ave);}accum /= row;pt[n++] = sqrt(accum);  //特征7:水平分布直方图标准差

特征提取之后要归一化,在3.3 训练测试相关一节会详细说明。

3.2.2knn

knn原理略。
实现步骤:
1.计算测试点到每一个样本点特征向量的第二范数(欧氏距离);
2. 对所有训练样本按照求得的第二范数从小到大排序。
3.统计前K个样本的标签出现的频度,除以样本容量得到频率作为该测试点的类条件概率密度。
4.由于样本的先验概率几乎相等,证据因子也相等,所以后验概率与类条件概率密度成正比。于是可以将出现最高频率的标签当做该测试点的类别。

3.2.3parzen窗

parzen窗原理略
由于受各种限制,parzen窗的似然函数很难计算,因此用两种方法代替。
一、统计以测试点为中心,边长为windowSize的超正N维体里面的所有点,以这些点出现的频率作为该测试点的类条件概率密度。同样可以将最高频率的标签当做该测试点的此类别。此法与标准parzen窗采用均匀核的准确率是完全一样的,只是在效率方面,没有预计算似然函数高,但是从实现角度来说,更加容易实现。在实现方面,与knn合并共同扫描一遍样本点。
二、直接将整个超体划为(1/windowSize)^dim个小的超体,其中dim是特征的个数,即样本的维度。统计每个格子内各类别的频率作为此格内的类条件概率密度,所有格子组成一个离散的似然函数存储。此法与parzen窗的结果略有差异,类似于直方图统计法,当样本足够大的时候,差异并不明显,但是可以提前预估似然函数,使得训练一次之后保存参数即可,效率较高。
直方图统计法会有(1/windowSize)^dim个小的超体,取dim=8,windowSize=10时,窗体的数目已经达到了10^9个,即1G个窗体。若每个窗体有10个分类,每个类都要统计类条件概率密度(double型,8字节),则需要80GB的存储空间,然而opencv一般用的是32位的,无法处理4GB以上的变量。研究发现,大量的窗格里面其实并没有变量,因此这里用了map的结构来存储。只建立有样本的网格节点,这样大大缩减了存储空间。减缩之后,30万个样本数据只需要250多个节点即可存储完。

这块代码比较简单,了解knn和parzen窗原理之后,参考注释即可读懂代码

3.2.4返回值说明

每次预测的结果是char类型的,占8个比特,返回值为int,占32个比特,因此可以用一个int型的返回多个模型训练的结果。knn的结果为knn_result,parzen的结果为parzen_result,则返回的结果就为(knn_result<<8)+parzen_result.最终在getLetter()里,返回值int从高位到低位依次是knn的结果,parzen窗的结果,直方图统计法的结果。
假设四个字符的识别结果顺次存储在result[0:3]中:
char resultLetter[15];for (int i = 0; i < 4; i++){resultLetter[i] = (result[i] >> 16);resultLetter[i + 5] = (result[i]>>8) % 256;resultLetter[i + 10] = result[i] % 256;}resultLetter[4] = '|';resultLetter[9] = '|';resultLetter[14] = '\0';
通过这种方法可以提取每个字符。也可以通过如下方法:
char resultLetter[15];for (int i = 0; i < 4; i++){resultLetter[i] = (result[i] >> 16);resultLetter[i + 5] = (result[i]&0x0FF00)>>8;resultLetter[i + 10] = result[i]&0x0FF;}resultLetter[4] = '|';resultLetter[9] = '|';resultLetter[14] = '\0';

3.3训练测试相关

3.3.1训练集的产生

训练集由generateTrainSet()函数产生,传入的参数分别为训练集,标签集,以及训练集特征的一些参数的地址,函数执行之后会生成这些值。训练集产生之后会写入文件中,NOFRESH宏表示不更新训练集,即从文件中读取训练集。生成训练集的过程是:生成验证码,预处理,分割和特征提取,之后要对每个特征的所有样本进行归一化,此时记录TrainSet每一列的均值,标准差,最大值,最小值以及量级,可以采用Max-Min和Z-Score两种法则对列向量进行归一化。之后将产生的训练集输出到文件中。
如果某个训练样本在字符分割阶段出现错误,即最后生成的不是四个轮廓,则本次生成的训练数据作废,重新生成一次该序号的训练样本。下面生成测试样本也相同。

3.3.2测试集的产生  

生成测试集的步骤与生成训练集相似,都是先生成验证码,预处理,字符分割,不同的是在提取特征之后,根据之前训练集归一化的参数对该特征进行归一化,采用的法则也要同训练集一样。对获取的特征进行字符识别,然后根据3.4.2 返回值说明 一节的方法分离不同的学习器产生的学习结果。如果需要显示则输出到BackGround中,如果是用来测试准确率则统计每种方法的正确次数。只有每个验证码四个字符全部正确我们才认为这个测试通过。

4.结果及代码


可以看出,在这个简单的情境下,knn的识别率达到了可观的99.7%,parzen窗则稍逊一筹,只有87.7%,而由于直方图统计法可以一次训练,之后用似然函数估计,大大增加了效率,因此训练集扩充到了30万,所以效率自然地提升到了95.6%。
相关的代码的github地址是https://github.com/wzh1994/CodeRecognition.git,这是我第一次写的这方面的相关代码,欢迎大家下载阅读,批评指正。也欢迎大家关注我的git,在这个领域的曲折路上,我会一直将自己的探索发布在这个git上面。
0 0