前言:
本篇博客主要介绍基于OPENCV的手势识别程序,代码为C++,OPENCV版本为OPENCV4会有较为详细的实现流程和源码,并且做到源码尽量简单,注释也自认为较为清晰,希望能帮助到大家。(源码将放在文章末尾的链接中,代码较为粗糙,有错误欢迎大家指出。)
一、手势识别流程图
首先是对于流程图的简单说明:两条线是分开进行的,两者在比对分类之前是不会互相影响的(当然有部分函数,例如提取特征的函数,是在两条线中都是会使用到的),因此可以分别完成两条线的工作。如果作为需要分工的项目,可以按照这两部分进行分工,最后进行整合。
而关于两条线的顺序,个人认为是优先进行神经网络的训练部分(也就是下方线路),原因是对于要识别的手势,首先要有对应的样本,优先去寻找样本,以此确定能够识别的手势。(当然,要是已经找到了样本,先做上方线路也没啥太大问题)
本篇文章的顺序:
先讲对于图片的处理:
因为先明白图片如何处理,并知道提取特征的方法,才能理解要把什么东西放到神经网络里,得到的数据又是什么。
再讲神经网路的搭建:
神经网路的搭建其实就是一个模板性的东西,计算机并不知道你要识别的东西到底是什么,是数字还是手势,对于计算机来说它只是一堆数据,它只负责给你找到——你输入的数据在网络里跟哪个数据最为匹配,然后就给你输出。
二、读取图片、获取皮肤部分及二值化
1)读取图片部分:
并没有过多好说的,直接使用imread函数对图片进行读入。(注意,读入的图片应该是彩色的而不是灰度图,否则无法进行后面的皮肤区域获取)
2)获取皮肤部分及二值化:
关于皮肤部分的获取,这里列出几种算法。由于图片的光照等的不同,不同算法的优劣也很难对比,各位自行选择算法。
①基于RGB颜色空间的简单阈值肤色识别:
根据他人的研究,我们可以知道有这样的一条判别式来用于肤色检测
R>95 && G>40 && B>20 && R>G && R>B && Max(R,G,B)-Min(R,G,B)>15 && Abs(R-G)>15
有了条判别式,我们就能够很容易地写出代码来实现肤色检测。但该算法对于光线的抗干扰能力较弱,光线稍微不好就识别不出皮肤点。
Mat getSkin(Mat& ImageIn)//获取皮肤的区域,返回二值化图像
{
vector<Mat> r_g_b;//用于存放RGB分量
split(ImageIn,r_g_b);//分离RGB分量,顺序为B,G,R
Mat Binary = Mat::zeros(ImageIn.size(),CV_8UC1);
Mat R = r_g_b[2];
Mat G = r_g_b[1];
Mat B = r_g_b[0];
for (int i = 0; i < ImageIn.rows; i++)
{
for (int j = 0; j < ImageIn.cols; j++)
{
if (R.at<uchar>(i, j) > 95 && G.at<uchar>(i, j) > 40 && B.at<uchar>(i, j) > 20 &&
R.at<uchar>(i, j) > G.at<uchar>(i, j) && R.at<uchar>(i, j) > B.at<uchar>(i, j) &&
MyMax(R.at<uchar>(i, j), G.at<uchar>(i, j), B.at<uchar>(i, j)) - MyMin(R.at<uchar>(i, j), G.at<uchar>(i, j), B.at<uchar>(i, j)) > 15
&& abs(R.at<uchar>(i, j) - G.at<uchar>(i, j)) > 15)
{
Binary.at<uchar>(i, j) = 255;
}
}
}
return Binary;
}
代码说明:首先对彩色图片分离开R、G、B分量,然后根据公式,将每一个点的R、G、B分量代入公式中进行判断,符合条件的点我们可以认为它是皮肤中的一个点。(其中的MyMax和MyMin是自写的判断大小函数)将认为是皮肤的点的值置为255,就可以达到二值化的效果。
可以看到除了部分因为光线问题导致的阴影没有被识别出来以外,几乎所有皮肤点都被识别出来了,效果还过得去。
②基于椭圆皮肤模型的皮肤检测
研究发现,将皮肤映射到YCrCb空间,则在YCrCb空间中皮肤的像素点近似成一个椭圆的分布。因此如果我们得到了一个CrCb的椭圆,对于一个点的坐标(Cr, Cb),我们只需判断它是否在椭圆内(包括边界)就可以得知它是不是肤色点。
该算法对于光线的敏感性没有这么高,基本上该检测到的皮肤都能够检测到,抗干扰能力相对较强。(原因大概是YCrCb中Y分量表示明亮度,而而“Cr”和“Cb” 表示的则是色度,作用是描述影像色彩及饱和度)
Mat getSkin2(Mat& ImageIn)
{
Mat Image = ImageIn.clone();//复制输入的图片
//利用OPENCV自带的ellipse函数生成一个椭圆的模型
Mat skinCrCbHist = Mat::zeros(Size(256, 256), CV_8UC1);
ellipse(skinCrCbHist, Point(113, 155.6), Size(23.4, 15.2), 43.0, 0.0, 360.0, Scalar(255, 255, 255), -1);
Mat ycrcb_Image;
cvtColor(Image, ycrcb_Image, COLOR_BGR2YCrCb);//用cvtColor函数将图片转换为YCrCb色彩空间的图片
Mat Binary = Mat::zeros(Image.size(), CV_8UC1);//输出的二值化图片
vector<Mat>y_cr_cb;//用于存放分离开的YCrCb分量
split(ycrcb_Image, y_cr_cb);//分离YCrCb分量,顺序是Y、Cr、Cb
Mat CR = y_cr_cb[1];
Mat CB = y_cr_cb[2];
for (int i = 0; i < Image.rows; i++)
{
for (int j = 0; j < Image.cols; j++)
{
if (skinCrCbHist.at<uchar>(CR.at<uchar>(i,j), CB.at<uchar>(i,j)) > 0)//在椭圆内的点置为255
{
Binary.at<uchar>(i, j) = 255;
}
}
}
return Binary;
}
代码说明:首先用OPENCV自带的函数生成一个椭圆的模型,然后将RGB图片转换为YCrCb图片,分离开Y,Cr,Cb,对于原图片里的每一个点,判断其是否在椭圆内,在,则认为该点是一个皮肤点。
可以看到几乎所有的皮肤部分都被识别出来了,效果比上一个算法看上去要好不少。
③基于YCrCb颜色空间Cr,Cb范围筛选法
这种方法与第一种方法在原理上是一样的,只不过这次将颜色空间变为了YCrCb空间。同样的,我们有公式
Cr>133 && Cr<173 && Cb>77 && Cb<127
将CrCb分量代入这条公式进行判断,就能得到皮肤点,此处不进行过多的讲述。
Mat getSkin3(Mat& ImageIn)
{
Mat Image = ImageIn.clone();
Mat ycrcb_Image;
cvtColor(Image, ycrcb_Image,COLOR_BGR2YCrCb);
vector<Mat>y_cr_cb;
split(ycrcb_Image, y_cr_cb);
Mat CR = y_cr_cb[1];
Mat CB = y_cr_cb[2];
Mat ImageOut = Mat::zeros(Image.size(), CV_8UC1);
for (int i = 0; i < Image.rows; i++)
{
for (int j = 0; j < Image.cols; j++)
{
if (CR.at<uchar>(i, j) > 133 && CR.at<uchar>(i, j) < 173 && CB.at<uchar>(i, j) > 77 && CB.at<uchar>(i, j) < 127)
{
ImageOut.at<uchar>(i, j) = 255;
}
}
}
return ImageOut;
}
我们可以看到效果也是挺不错的(相比第一种方法得到的),与第二种方法不分伯仲。
④YCrCb颜色空间Cr分量+Otsu法阈值分割
首先我们要知道YCrCb色彩空间是什么:YCrCb即YUV,其中“Y”表示明亮度(Luminance或Luma),也就是灰阶值;而“U”和“V” 表示的则是色度(Chrominance或Chroma),作用是描述影像色彩及饱和度,用于指定像素的颜色。其中,Cr反映了RGB输入信号红色部分与RGB信号亮度值之间的差异。(来自百度百科)
所以,该方法的原理也十分简单:
1、将RGB图像转换到YCrCb颜色空间,提取Cr分量图像
2、对Cr做自适应二值化阈值分割处理(Otsu法)
Mat getSkin4(Mat& ImageIn)
{
Mat Image = ImageIn.clone();
Mat ycrcb_Image;
cvtColor(Image, ycrcb_Image, COLOR_BGR2YCrCb);//转换色彩空间
vector<Mat>y_cr_cb;
split(ycrcb_Image, y_cr_cb);//分离YCrCb
Mat CR = y_cr_cb[1];//图片的CR分量
Mat CR1;
Mat Binary = Mat::zeros(Image.size(), CV_8UC1);
GaussianBlur(CR, CR1, Size(3, 3), 0, 0);//对CR分量进行高斯滤波,得到CR1(注意这里一定要新建一张图片存放结果)
threshold(CR1, Binary, 0, 255, THRESH_OTSU);//用系统自带的threshold函数,对CR分量进行二值化,算法为自适应阈值的OTSU算法
return Binary;
}
代码说明:前面的转换色彩空间不再赘述,关键点在于系统的二值化函数threshold,使用了OTSU的算法对图像前景和背景进行区分。(请不清楚threshold函数使用和OTSU算法的读者自行查找资料,这里由于篇幅问题不展开解释)
可以看到效果也不错,基本也能识别出来。
⑤OPENCV自带的肤色检测类AdaptiveSkinDetector
(但是我没用过,仅是放上来让大家知道…)
总结:
对于以上几种方法,很难说到底哪一种会更加准确,根据环境不同,图片不同,算法之间的优劣也有差别。因此有条件的可以每一种都尝试,看看在自己的环境下哪种算法的效果最好。我在代码中使用的是方法②,也仅供参考。
三、获取轮廓及特征(特征为傅里叶描绘子)
原理:(请务必看看)
1)什么是图像的特征?
或许换个问题你就理解了,什么是人的特征?我们或许可以认为,会直立行走,能制造和使用工具,这些就是人的特征。然后,进一步,什么是手势0的特征?是一根手指都没有伸出来,这就是手势0的特征。手势1呢?只伸出了一根手指对吧?这就是特征。
2)为什么要获取图像特征?
我们举个比较简单(但是并不真实)的例子:你输入了一张二值化后的图片(假设是手势2,即我们前面的图片里的手势),我们通过一个获取特征的函数,获得了这个手势的特征值,假设这一连串的值是[1,2,3,4,5,6]。
而在神经网络里,手势0的特征值被认为是[4,4,3,3,2,2],手势1被认为是[9,8,7,6,5,4,],手势2被认为是[1,2,3,4,5,5]。那么你输入了[1,2,3,4,5,6],你认为最匹配的是哪个手势呢?显然就是手势2。
而这就是神经网络的工作原理。你输入一串代表特征的数值,预测函数会在神经网络里去找跟这串数值最相似的那个结果,把它告诉你:这就是我判断的手势结果。
而图像的特征,就是上面我们所说的[1,2,3,4,5,6]这串数字。我们要想办法获取这个特征向量,扔到神经网络里去让它识别。至于神经网络怎么知道[1,2,3,4,5,5]代表手势2,[4,4,3,3,2,2]代表手势0,我们在后面的神经网络部分会说到。
3)要获取什么特征,以及怎么获取?
首先,对于手势来说,我们获取的特征应该是对旋转和缩放都不敏感的。为什么呢?你总不希望你的手旋转了45°系统就识别不出来了吧?你总不希望你离得远一点系统就识别不出来了吧?所以,我们要找到一个方法来描述你的手势,并且这个方法对于旋转和缩放都不那么敏感。
而在本项目中,我们使用的是傅里叶描绘子,这是一种边界描绘子(就是专门用来描绘边界的),它对于旋转以及缩放并不十分敏感。这里简单说一下傅里叶描绘子的原理。对于坐标系内的一个点,通常我们用(X,Y)来表示。但这就是两个数值了,有没有什么办法用一个数值来表示?幸好我们有复数这个东西!对于点(X,Y)我们可以写成X+iY,这不就变成一个数了吗?这能将一个二维的问题转变为一维的问题。
而我们应该知道,任何一个周期函数都可以展开为傅里叶级数,所以我们可以通过傅里叶级数来描绘一个周期变换的函数。那么你可能会问,这个二值化后的手势图,跟周期变换函数有什么关系啊?还真有!沿边界曲线上一个动点s[k] = [x(k),y(k)]就是一个以形状边界周长为周期的函数。
为什么外轮廓是周期函数?假设有一个点在外轮廓上向着一个方向移动,转了一圈,两圈…,那这是不是周期函数?所以问题就解决了,我们可以用傅里叶描绘子来描绘手势的外轮廓,从而描绘手势。
关于傅里叶描绘子更详细的介绍以及推导建议各位去查阅相关资料,这里不打算过多说(毕竟估计也没几个人感兴趣)
傅里叶描绘子原理
我这里直接给出计算公式:
其中a(u)就是傅里叶级数的第n项,k就是图像中第k个点。
当然,这个公式可以使用欧拉公式进行展开,对于编程实现来说会更加简单。但是仅仅是这个公式是不够的,因为这样计算出来的傅里叶描绘子与形状尺度,方向和曲线起始点S0都有关系,显然不是我们想要的。所以,我们还要以a(1)为基准,进行归一化,最终得到:
由于形状的能量大多集中在低频部分,高频部分一般很小且容易受到干扰,所以我们只取前12位就足够了。
具体实现:
首先,要描绘手势的外轮廓,我们就需要先得到手势的外轮廓。幸好,OPENCV有对应的函数让我们使用,就是findContours函数。
Mat ImageBinary = ImageIn;//二值化的图片(传入时应该就已经是二值化的图片了)
vector<vector<Point>> contours;//定义轮廓向量
findContours(ImageBinary, contours, RETR_EXTERNAL, CHAIN_APPROX_NONE);//寻找轮廓
int max_size = 0;//最大的轮廓的size(通常来说就是我们的手势轮廓)
int contour_num = 0;//最大的轮廓是图片中的第contour_num个轮廓
for (int i = 0; i < contours.size(); i++)//找到最大的轮廓,记录在contour_num中
{
if (contours[i].size() > max_size)
{
max_size = contours[i].size();
contour_num = i;
}
}
这样,我们就找到了图片的全部轮廓,并且存在了二维向量contours中。然后我们就可以计算傅里叶描绘子了。
/***计算图像的傅里叶描绘子***/
/***傅里叶变换后的系数储存在f[d]中***/
vector<float>f;
vector<float>fd;//最终傅里叶描绘子前14位
Point p;
for (int i = 0; i < max_size; i++)//主要的计算部分
{
float x, y, sumx = 0, sumy = 0;
for (int j = 0; j < max_size; j++)
{
p = contours[contour_num].at(j);
x = p.x;
y = p.y;
sumx += (float)(x * cos(2 * CV_PI * i * j / max_size) + y * sin(2 * CV_PI * i * j / max_size));
sumy += (float)(y * cos(2 * CV_PI * i * j / max_size) - x * sin(2 * CV_PI * i * j / max_size));
}
f.push_back(sqrt((sumx * sumx) + (sumy * sumy)));
}
fd.push_back(0);//放入了标志位‘0’,并不影响最终结果
for (int k = 2; k < 16; k++)//进行归一化,然后放入最终结果中
{
f[k] = f[k] / f[1];
fd.push_back(f[k]);
}
out = Mat::zeros(1, fd.size(), CV_32F);//out是用于输出的手势特征
for (int i = 0; i < fd.size(); i++)
{
out.at<float>(i) = fd[i];
}
计算完了傅里叶描绘子,我们就算是拿到了手势的特征了。
四:找到训练样本
首先,我们要明确找到训练样本是很重要的,因为训练样本决定了你能识别到的是什么。要是你找到了身份证数字的训练样本,那么你就能识别身份证的数字;要是你找到了车牌号码的数字样本,那么你就能识别车牌;而我们在这个项目要找到手势的样本。
不过在这里我可以将我找到的样本分享给大家,我将其一并打包在了项目文件中,大家有需要的可以下载我的工程文件。其中有10个手势,从0到9,希望能帮助到大家。
五:样本的特征提取
对于样本的特征提取,其实跟前面我们说到的特征提取没有什么不同。只不过我们这里需要的是对大量的样本进行特征提取,并且将其存放在一个文件中,方便我们后续进行的神经网络训练。
void getAnnXML()
{
FileStorage fs("ann_xml.xml", FileStorage::WRITE);
if (!fs.isOpened())
{
cout << "failed to open " << "/n";
}
Mat trainData;//用于存放样本的特征数据
Mat classes = Mat::zeros(200, 1, CV_8UC1);//用于标记是第几类手势
char path[60];//样本路径
Mat Image_read;//读入的样本
for (int i = 0; i < 4; i++)//第i类手势 比如手势1、手势2
{
for (int j = 1; j < 51; j++)//每个手势设置50个样本
{
sprintf_s(path, "D:\\数字图像处理\\Gesture_Picture\\%d_ (%d).png", i, j);
Image_read = imread(path, 1);
Mat Binary = OTSU_Binary(Image_read, 1);//对输入的图片进行二值化
Mat dst_feature;//该样本对应的特征值
getFeatures(Binary,dst_feature);
trainData.push_back(dst_feature);
classes.at<uchar>(i * 50 + j - 1) = i;
}
}
fs << "TrainingData" << trainData;
fs << "classes" << classes;
fs.release();
cout << "训练矩阵和标签矩阵搞定了!" << endl;
在该段代码运行完之后,在文件夹下会生成一个ann_xml.xml文件,里面存放着所有样本的特征值和一个标签矩阵(标签矩阵如下图)
其中的getFeatures函数与前面用到的特征提取函数是完全一致的。但是在这部分我们是可以省去找到皮肤部分的函数,因为样本给到我们的就已经是只有皮肤部分的图片了。所以我们只需要进行二值化并找到最大的轮廓,提取特征。
六:训练神经网络
一些想法:神经网络的训练看起来像是比较难的一个部分,但其实并不是,看起来难的原因也许是之前并没有接触过神经网络,觉得无从下手。(所以在写这部分代码之前应该先去了解一下什么是神经网络,不需要过于深入,知道基本的工作原理即可)但其实只要写过一次,就会发现这是一个模板性的东西,写过了就是从0到1的变化。当然在写该部分的代码时会遇到很多的小问题,比如OPENCV版本的不同,有部分网上的代码使用的是OPENCV2,对于OPENCV3就不适用了。
废话不多说了直接开始吧,首先是训练部分的函数代码:
void ann_train(Ptr<ANN_MLP>& ann, int numCharacters, int nlayers)//神经网络训练函数,numCharacters设置为4,nlayers设置为24
{
Mat trainData, classes;
FileStorage fs;
fs.open("ann_xml.xml", FileStorage::READ);
fs["TrainingData"] >> trainData;
fs["classes"] >> classes;
Mat layerSizes(1, 3, CV_32SC1); //3层神经网络
layerSizes.at<int>(0) = trainData.cols; //输入层的神经元结点数,设置为15
layerSizes.at<int>(1) = nlayers; //1个隐藏层的神经元结点数,设置为24
layerSizes.at<int>(2) = numCharacters; //输出层的神经元结点数为:4
ann->setLayerSizes(layerSizes);
ann->setTrainMethod(ANN_MLP::BACKPROP, 0.1, 0.1);//后两个参数: 权梯度项的强度(一般设置为0.1) 动量项的强度(一般设置为0.1)
ann->setActivationFunction(ANN_MLP::SIGMOID_SYM);
ann->setTermCriteria(TermCriteria(TermCriteria::MAX_ITER, 5000, 0.01));//后两项参数为迭代次数和误差最小值
Mat trainClasses;//用于告诉神经网络该特征对应的是什么手势
trainClasses.create(trainData.rows, numCharacters, CV_32FC1);
for (int i = 0; i < trainData.rows; i++)
{
for (int k = 0; k < trainClasses.cols; k++)
{
if (k == (int)classes.at<uchar>(i))
{
trainClasses.at<float>(i, k) = 1;
}
else
trainClasses.at<float>(i, k) = 0;
}
}
//Mat weights(1 , trainData.rows , CV_32FC1 ,Scalar::all(1) );
ann->train(trainData, ml::ROW_SAMPLE, trainClasses);
cout << " 训练完了! " << endl;
}
代码说明:
1、首先我们要将ann_xml.xml文件中的数据读入到程序中,包括样本的特征还有标签矩阵。
2、然后设置神经网络的基本参数。首先,设置3层的神经网络,即1层输入层,1层隐藏层,1层输出层。
输入层的结点数 这却决于我们一张图片获得的特征值的数量。(根据第三大点中说到的,我们取傅里叶描绘子的前12位)
隐藏层的结点数 我们在该项目中将隐藏层设置为24。
输出层的结点数 我们最后想让神经网络识别几个手势,就设置为多少。比如该项目最后要识别四个手势,就设置为4。
setTrainMethod(训练方法) 使用ANN_MLP::BACKPROP,即反向传播神经网络,这个较为常用。
setActivationFunction(激活函数) 一般常用的就是Sigmoid 函数,即下图的函数
setTermCriteria为迭代终止准则的设置
trainClasses变量 这个变量建议大家画个图理解一下。到底和class的区别是什么?虽然都是作为一个标签矩阵,但还是有一些不同。
train函数 这个就是神经网络的训练函数,把相应的参数代入进去,就能够训练出一个神经网络。
需要注意的是,这个函数不需要每一次都识别都运行,(而且运行该函数需要花费较多的时间)只要我们运行一次,把神经神经网络保存下来,下次识别的时候直接读取神经网络,就可以进行识别了。具体如下:
Ptr<ANN_MLP> ann = ANN_MLP::create();
ann_train(ann, 4, 24);
ann->save("ann_param");
这样就能保存一个ann_param的文件了,下次使用只需要
Ptr<ANN_MLP> ann = ANN_MLP::load("ann_param");//读取神经网络
七:比对分类,预测结果
预测函数的作用就是帮助我们对比图片的特征与神经网络里哪个最像,然后把这个作为结果输出。所以在这里我们要获取要预测的图片的特征,然后使用预测函数predict进行预测。最后找到逻辑最大值,作为结果。
int classify(Ptr<ANN_MLP>& ann, Mat& Gesture)//预测函数,找到最符合的一个手势(输入的图片是二值化的图片)
{
int result = -1;
Mat output(1, 4, CV_32FC1); //1*4矩阵
Mat Gesture_feature;
getFeatures(Gesture, Gesture_feature);
ann->predict(Gesture_feature, output);
Point maxLoc;
double maxVal;
minMaxLoc(output, 0, &maxVal, 0, &maxLoc);//对比值,看哪个是最符合的。
result = maxLoc.x;
return result;
}
output变量存储的是“该手势对应每个手势的可能性”,找到最大的那个可能,作为结果输出。至此,我们获得了我们想要的那个结果。
结尾:
我们最后把main函数放上来,这样就能更清楚地看清流程:
int main(void)
{
Mat ImageIn = imread("D://VSprogram/DistinguishGesture/MyTestPicture/test.jpg", 1);//输入彩色图片
Mat Binary = skinMask(ImageIn);//获取二值化后的手势图
Mat Binary2 = OPENorCLOSE_Operation(Binary, 0, 3, 3);//对手势图进行闭运算,使一些小的点连起来
//只需要执行一次,获取了标签矩阵和神经网络后就不再需要执行
//getAnnXML();
//Ptr ann = ANN_MLP::create();
//ann_train(ann, 4, 24);
//ann->save("ann_param");
//cout << "搞定" << endl;
//Mat contour = showContour(Binary2);//获得手势的轮廓图
//imshow("Contour", contour);//画出手势的轮廓图
Ptr<ANN_MLP> ann = ANN_MLP::load("ann_param");//读取神经网络
int result = classify(ann, Binary2);//预测最终结果
cout << "手势是:"<<result << endl;//输出
waitKey(0);
return 0;
}
然后我再把源代码链接放在这里,需要的可以下载来看看(我尽量把积分调低,让大家即使觉得没啥用也不会亏多少积分)
https://download.csdn.net/download/qq_42884797/13658137
(如果你说你真的没有积分你就评论或是私信我发给你吧,或者是比较急需代码的,可以发邮件到[email protected],否则可能没法及时回复)
参考文献及链接:
赵三琴,丁为民,刘徳营.基于傅里叶描述子的稻飞虱形状识别[J].农业机械学报,2009,40(8):181~184
https://blog.csdn.net/qq_41562704/article/details/88975569
https://blog.csdn.net/javastart/article/details/97615918