书写数字识别(C++ 、 KNN 、openCV)

本文介绍了使用opencv库和KNN(K-NearestNeighbor)算法进行手写数字识别的过程。首先解释了KNN算法的基本概念,然后详细讲述了opencv中mat、point和size类的使用。接着,提供了生成训练图片和XML文件的代码示例,以及测试识别功能的主程序。文章还分享了训练样本生成、模型训练和识别的实现细节,并建议根据实际需求调整代码。
摘要由CSDN通过智能技术生成

目录

前言

什么是KNN?

opencv中的类

mat类:

point类:

size类:

生成训练图片

生成XML文件

测试


前言

前段时间在工作中用到一些图像处理的内容,功能不算太难,但却是我第一次使用opencv库、第一次用c++写工程(老c了)、第一次接触KNN算法,还是很有纪念意义的,所以今天在这里记录一下,也算是重新巩固一遍做过的内容。

说到图像处理大家肯定都会想到opencv,没错!作为一个专门为图像处理而生的库,它的功能确实强大,里边集成了大量的图像处理相关的类、API以及算法,日常的图像处理操作几乎都可以在库里边找到相应的函数来实现(有兴趣的话完全可以自己写一个p图APP,哈哈)。

什么是KNN?

KNN(K-Nearest Neighbor,KNN)算法又叫做“最近相邻算法”,是著名的模式识别统计学方法,在机器学习分类算法中占有相当大的地位。它是一个理论上比较成熟的方法。既是最简单的机器学习算法之一,也是基于实例的学习方法中最基本的,又是最好的文本分类算法之一。这个解释是不是很官方?!莫慌!!!其实在我看来,其奥义就是“少数服从多数”。具体算法理论以及过程这里不再细说(主要是我也看不太懂,但不影响使用哈哈),网上官方的解释很多,想深入了解的话可以多去看看相关的资料。

下边引用了链接的解释(自己理解的哈),如下图所示,判断绿点处应该属于哪一类?是蓝色方块还是红色三角?这时就需要考虑到一个权值的问题。如果K=3(假设临近绿点的对象一共有三个),那么红色三角形有两个,蓝色的方块只有一个,此时红色占比为2/3,如果这时处理的是一个图像的话,绿点处就归到红色三角形那一类中了;如果K=5,这个时候蓝色的方块又比红色的三角形多一个,所以绿点处的会被归于蓝色方块那一类中。由此可以看出,K值的取值会对你的识别率有很大的影响。K值太大易引起欠拟合,太小容易过拟合,需交叉验证确定K值(别问,问就是好像懂了,也好想没懂)关于K值选取

个人理解是:K值越小就越逼真于原图,但是对于那种区域特征重合比较多的图像就不太容易分辨(这个是我在开发过程中发现的,比如在识别1和7的时候,虽然看起来两个数字差别是很大的,但是在手写的过程中,1都会稍微往右偏一点,就会和7的下半部分特征重合度增高,原本写的是1,但是识别出来就会是7),这个时候你就可以加大K值,但是对于那种特征比较明显的数字,那样又会降低它本来的准确率。个人建议多实验几次,选取识别率最高的那个作为K值,还有就是,K一定要取奇数!!!。(看了很多例程确实没有取偶数的,自己想一下当在一定的范围内,如果两类的数量相同怎么办?)这个时候估计有人会杠,那如果就算是取了奇数,但是是三个类怎么办?他们也有数量相等的可能。是的!所以在进行KNN运算之前,最重要的一步是一定要对样本特征进行归一化处理(可以理解为只有0和1),在项目中体现为把所有图像都转换为灰度图像来进行处理以上理解仅是个人观点哈,从开发过程中来看,好像确实是这样的,如果不对的话,欢迎大家提出来共同探讨。

opencv中的类

opencv库中的类有很多,在这里提一下手写数字识别时可能用到的类,具体内容还是自行百度比较好(你们懂得),在这里仅是提供一个方向,其实在开发的过程中根据各自的项目需求,往往需要了解大量的类以及API(下边借鉴了这位作者的整理opencv常用类)。

mat类:

1.使用Mat()构造函数

Mat是Opencv中的通用矩阵类型,我们通常将它作为图片的容器,它包含了矩阵头(包含矩阵尺寸,储存方法,储存地址等信息)和指向储存所有点值的指针。其创建方法如下:

对于二维多通道的Mat类型,我们通常可以用如下形式来构建:

Mat test(2,3,CV_8UC3,Scalar(0,22,23));

前两个参数给出了矩阵的行列信息,第三个参数给出了矩阵的通道数目以及位深,最后一个参数则是给元素赋初值(可以暂时忽略,后续赋值)
2.使用create()成员函数
利用Mat类的成员函数来进行Mat类的初始化,使用方法如下:

Mat test;
test.create(4,4,CV_BUC2);

具体参数的含义我就不说了吧...

3.使用函数初始化特定矩阵
这种方法和Matlab中创建数组的方法一致,可以用于建立全零、全一、对角阵,使用方法如下:

Mat E=Mat::eye(4,4,CV_64F);
Mat O=Mat::ones(2,2,CV_32F);
Mat Z=Mat::zeros(3,3,CV_8UC1);

4.小矩阵直接赋值
当所需矩阵较小时,可以通过”<<"运算符对其直接初始化赋值,使用方法如下:

Mat C=(Mat_<double>(3,3)<<0,1,2,3,4,5,6,7,8);

5.通过已有矩阵赋值
通过clone()函数对已有的矩阵或者数组进行深复制(不是新建信息头,相当于克隆,这个用的很多),将其赋值给待初始化的数组,类似于c++中的深拷贝。

//通过已有数组赋值
float b[4]={5,6,7,8};
Mat c = Mat(2,2,CV_32F,b).clone();
//抽取矩阵某一行赋值
Mat C = (Mat_<double>(3, 3) << 0, 1, 2, 3, 4, 5, 6, 7, 8);
Mat RowClone = C.row(1).clone();

矩阵创建完后需要对矩阵进行访问,以利用矩阵的值进行相应的操作。元素的访问方式有很多,用的最多的就是类名直接访问吧(.at()),另外就是通过指针进行访问(.ptr()),具体用法不在赘述。mat类是图像处理过程中必用的一大类,细节性的知识很多,这里就等着大家去深挖了。

point类:

Point类常常是用与描述二维坐标系下点的位置,其使用方式如下:

Point point1= Point(1,2);
Pointe point2;
point2.x=2;
point2.y=1;

point类用的也是比较多的,用法有很多,经常与vector类联系,后期手写轨迹的处理都是需要通过point类来进行操作的。

size类:

Size类是一个常用于描述图片尺寸的二元素数组,实质上也就是一个模板列,其成员变量包括width和height,都需要为int。常用初始化如下:

Size size(600,800)   //Size(width,height)

size类也是在开发过程中经常用到的,视窗和图像的大小参数都是可以通过size类来进行调节的。
除了这些常用类外还有一些常用的API也是需要掌握的,比如文件操作、c++的常用类、回调函数、windowsAPI等,其实这方面我也是小白,就不献丑了,各位还是多看看大佬的文章比较好。

生成训练图片

下边的代码是github上一位大佬写的,我只不过是根据自己的工作需要把工程改了一下而已,忘记链接是什么了,小编在这里先拿来跟大家分享一下,如有侵权,请私信我删除,谢谢!

#include <iostream>
#include <opencv2\opencv.hpp>

using namespace std;
using namespace cv;

Point clickPoint;
Mat matHandWriting;
bool cc = true;
void on_Mouse(int event, int x, int y, int flags, void*);

int main(int argc, char** argv)
{
	matHandWriting.create(80, 80, CV_8UC1);
	matHandWriting = Scalar::all(0);
    //共输入100个样本
	int intName[100];
	for (int i = 0; i < 100; i++)
	{
		intName[i] = i;
	}

	namedWindow("win", WINDOW_NORMAL);
	resizeWindow("win", 80, 80);
	setMouseCallback("win", on_Mouse, 0);
    //把样本命名为count.png保存
	int count = 0;
	while (count < 100)
	{
		stringstream ss;
		ss << intName[count];
		string stringName;
		ss >> stringName;
		imshow("win", matHandWriting);
		int key = waitKey(0);
		if (key == 32 && cc) //第一次空格键按下保存
		{
			cc = false;
			imwrite(stringName+".png", matHandWriting);
			cout << count << "  ";
			count++;
		}
		else if (key == 32 && !cc) //第二次空格键按下清除,开始进行下次录入
		{
			cc = true;
			matHandWriting = Scalar::all(0);
			imshow("win", matHandWriting);
		}
		else if (key == 27) //esc退出
		{
			break;
		}
	}

	return 0;
}
//读取鼠标状态
void on_Mouse(int event, int x, int y, int flags, void*)
{
	// 如果鼠标不在窗口中则返回
	if (x < 0 || x >= matHandWriting.cols || y < 0 || y >= matHandWriting.rows)
		return;

	// 如果鼠标左键被按下,获取鼠标当前位置;当鼠标左键按下并且移动时,绘制白线;
	if (event == EVENT_LBUTTONDOWN)
	{
		clickPoint = Point(x, y);
	}
	else if (event == EVENT_MOUSEMOVE && (flags & EVENT_FLAG_LBUTTON))
	{
		Point point(x, y);
		line(matHandWriting, clickPoint, point, Scalar::all(255), 6, 8, 0);
		clickPoint = point;
		imshow("win", matHandWriting);
	}
}

该部分实现的是录入样本并保存为图像文件,为后续训练使用。当然这个过程也可以根据需要录入更为完善的mnist数据集(主要是电脑吃不消。。)那样训练得到的特征量会更加具有普遍性。这里是我整理的mnist数据集、训练数据集的例程、手写识别例程。至于训练过程我就不细说了,工程里边注释的都很清楚,有兴趣的可以试一下(应该是可以用的,github找的,网上资源很多)。奥,对了,opencv用的是3.4.14版本的,这里有下载链接及库的添加教程(是不是很懂你们。。)

生成XML文件

话不多说,直接上代码

#include <iostream>
#include <opencv2\opencv.hpp>

using namespace cv;
using namespace std;

int main()
{
	Mat matClassificationInts;				// 保存我们感兴趣的字符,0~9
    Mat matTrainingImagesAsFlattenedFloats;	// 保存训练图片中所有单个字符ROI

	for (int i = 0; i < 10; i++) // 0-9十个数字
	{
		for (int j = 0; j < 10; j++) //每个数字训练十次(这里要和你录入的样本相对应)
		{
			matClassificationInts.push_back(i);
		}
	}

	int intName[100]; 
	for (int i = 0; i < 100; i++)
	{
		intName[i] = i;
	}
    //读取0-99.png,攻读取100次(如:0-9.png对应的是数字0,10-19.png对应数字1,依次...)
	for (int i = 0; i < 100; i++)
	{
		stringstream ss;
		ss << intName[i];
		string stringName;
		ss >> stringName;
		Mat matROI = imread("numRecognize\\"+stringName+".png", 0);
		Mat matROICopy = matROI.clone();
		vector<vector<Point>> contours;
		vector<Vec4i> hierarchy;
		findContours(matROI, contours, hierarchy, RETR_EXTERNAL, CHAIN_APPROX_SIMPLE);
		Rect boundRect = boundingRect(contours[0]);
		Mat numImage = matROICopy(boundRect).clone();
		resize(numImage, numImage, Size(40, 40));

		/* 这里计算目标图像的HOG特征(方向梯度直方图特征)代替之前用的灰度特征 */
		HOGDescriptor *hog = new HOGDescriptor(Size(40, 40), Size(16, 16), Size(8, 8), Size(8, 8), 9);
		vector<float> descriptors;
		hog->compute(numImage, descriptors);
		Mat dst(1, (int)(descriptors.size()), CV_32FC1, descriptors.data());

		matTrainingImagesAsFlattenedFloats.push_back(dst);
	}

	// 保存分类文件为 classifications.xml
    FileStorage fsClassifications("classifications.xml", FileStorage::WRITE);

    if (fsClassifications.isOpened() == false) {
		cout << "ERROR: 无法打开训练分类文件classifications.xml\n\n";
		system("pause");
		return 0;
    }

    fsClassifications << "classifications" << matClassificationInts;        // write classifications into classifications section of classifications file
    fsClassifications.release();                                            // close the classifications file

	// 保存训练图片文件为 images.xml
    FileStorage fsTrainingImages("images.xml", FileStorage::WRITE);

    if (fsTrainingImages.isOpened() == false) {
        cout << "ERROR: 无法打开训练图片文件images.xml\n\n";
		system("pause");
		return 0;
    }

    fsTrainingImages << "images" << matTrainingImagesAsFlattenedFloats;      // write training images into images section of images file
    fsTrainingImages.release(); 

	cout << "成功生成xml文件!\n" << endl;
	//system("pause");
	return 0;
}

当然,这个过程也不一定非要用自己录入样本,上边上传的资料里有比较全面的手写数据集,也可以直接用它来生成训练模型(好几万个样本,估计需要一点时间),好处是样本量足够大,准确率也会提高,资料里也有附带的把训练集生成xml文件的例程(我没有这样做,应该是差不多的)。

测试

生成训练模型之后,之前的那些函数都可以屏蔽掉了,测试函数所需要的就是生成的xml文件,下边是主函数的代码

#include <iostream>
#include <opencv.hpp>
#include <opencv2\ml\ml.hpp>

using namespace cv;
using namespace std;

Mat matHandWriting;
Point clickPoint;
bool boolRecognize = true;

void on_Mouse(int event, int x, int y, int flags, void*);

void helpText()
{
	cout << "操作提示:" << endl;
	cout << "\t在窗口写数字,写完后按【空格键】识别,控制台输出识别结果" << endl;
	cout << "\t再次按【空格键】清除所写内容,然后进行下一次书写\n" << endl;
	cout << "识别结果:";
}

int main()
{
   FileStorage fsClassifications("classifications.xml", FileStorage::READ);  // 读取 classifications.xml 分类文件

   if (fsClassifications.isOpened() == false) {
       cout << "ERROR, 无法打开classifications.xml\n\n";
		system("pause");
       return 0;
   }

	Mat matClassificationInts;
   fsClassifications["classifications"] >> matClassificationInts;  // 把 classifications.xml 中的 classifications 读取进Mat变量
   fsClassifications.release();  // 关闭文件

   FileStorage fsTrainingImages("images.xml", FileStorage::READ);  // 打开训练图片文件

   if (fsTrainingImages.isOpened() == false) {
       cout << "ERROR, 无法打开images.xml\n\n";
		system("pause");
       return 0;
   }

	// 读取训练图片数据(从images.xml中)
   Mat matTrainingImagesAsFlattenedFloats;  // we will read multiple images into this single image variable as though it is a vector
   fsTrainingImages["images"] >> matTrainingImagesAsFlattenedFloats;  // 把 images.xml 中的 images 读取进Mat变量
   fsTrainingImages.release();

	// 训练
   Ptr<ml::KNearest> kNearest(ml::KNearest::create());  // 实例化 KNN 对象,邻近处理

	// 最终调用train函数,注意到两个参数都是Mat类型(单个Mat),尽管实际上他们都是多张图片或多个数
   kNearest->train(matTrainingImagesAsFlattenedFloats, ml::ROW_SAMPLE, matClassificationInts);

   // 测试
	matHandWriting.create(Size(160, 160), CV_8UC1);//创建图像
	matHandWriting = Scalar::all(0); //初始化窗口
	imshow("手写数字", matHandWriting);
	setMouseCallback("手写数字", on_Mouse, 0);//鼠标回调函数

	helpText();
	///
	while (true)
	{
		int key = waitKey(0);
		if (key == 32 && boolRecognize)//空格键按下
		{
			boolRecognize = false;

			Mat matROICopy = matHandWriting.clone();//将所有matHandWriting拷贝到matROICopy
			vector<vector<Point>> contours;
			//是一个向量,并且是一个双重向量,
			//向量内每个元素保存了一组由连续的Point点构成的点的集合的向量,
			//每一组Point点集就是一个轮廓。 有多少轮廓,向量contours就有多少元素
			vector<Vec4i> hierarchy;
			/*
			这是openCV里面找边界的程序里面的语句,
			contours被定义成二维浮点型向量,
			这里面将来会存储找到的边界的(x,y)坐标。
			vector<Vec4i>hierarchy是定义的层级。这个在找边界findcontours的时候会自动生成,
			这里只是给它开辟一个空间。
			*/
			findContours(matHandWriting, contours, hierarchy, CV_RETR_TREE, CHAIN_APPROX_SIMPLE);
			// 多边形逼近轮廓 + 获取矩形和圆形边界框
			Rect boundRect = boundingRect(contours[0]);
			Mat numImage = matROICopy(boundRect).clone();
			resize(numImage, numImage, Size(40, 40));

			/* 这里计算目标图像的HOG特征(方向梯度直方图特征)代替之前用的灰度特征 */
			HOGDescriptor *hog = new HOGDescriptor(Size(40, 40), Size(16, 16), Size(8, 8), Size(8, 8), 9);
			vector<float> descriptors;
			hog->compute(numImage, descriptors);
			Mat matROIFlattenedFloat(1, (int)(descriptors.size()), CV_32FC1, descriptors.data());

			Mat matCurrentChar(0, 0, CV_32F);  // findNearest的结果保存在这里
			
			// 最终调用 findNearest 函数
			kNearest->findNearest(matROIFlattenedFloat, 1, matCurrentChar);

			int intCurrentChar = (int)matCurrentChar.at<float>(0, 0);
			cout << intCurrentChar << "  "; //显示识别结果
		}
		else if (key == 32 && !boolRecognize)//空格键再次按下
		{
			boolRecognize = true;
			matHandWriting = Scalar::all(0);
			imshow("手写数字", matHandWriting);
		}
		else if (key == 27) //esc退出
			break;
	} 

	return 0;
}

void on_Mouse(int event, int x, int y, int flags, void*)
{
	// 如果鼠标不在窗口中则返回
	if (x < 0 || x >= matHandWriting.cols || y < 0 || y >= matHandWriting.rows)
		return;

	// 如果鼠标左键被按下,获取鼠标当前位置;当鼠标左键按下并且移动时,绘制白线;
	if (event == EVENT_LBUTTONDOWN)
	{
		clickPoint = Point(x, y);
	}
	else if (event == EVENT_MOUSEMOVE && (flags & EVENT_FLAG_LBUTTON))
	{
		Point point(x, y);
		line(matHandWriting, clickPoint, point, Scalar::all(255), 12, 8, 0);
		clickPoint = point;
		imshow("手写数字", matHandWriting);
	}
}

个人建议把生成图片、训练样本、测试函数放到一个源文件中去,需要做哪个步骤的时候就把另外的两个函数屏蔽掉,只执行要用的函数,这样会方便一点。这里提供的只是一个大致的思路,后期可以根据自己的需要来进行相应的改写,比如连接触摸屏或者别的一些能够获取坐标点的设备,再把坐标点连线,最后生成图片训练,效果和用鼠标画出来的线是一样的,之只不过这个过程中可能会用到一些其他的类。总之思路提供给大家了,后期的完善就看各位了。上传的资料里边的工程亲测可用(前提是环境一定要搭建好,我用的vs2019社区版)。

最后还是那句话,个人也是第一次接触图像处理相关的知识(不过是用到了一些皮毛而已),如果有不对的地方欢迎大家提出,不胜感激!

  • 8
    点赞
  • 40
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 13
    评论
评论 13
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

陈大本事er

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值