前言

本文采用 Opencv C++ 实现简单的手势识别;主要步骤为获取手部轮廓、提取特征、训练网络、预测结果。

肤色检测

这一步主要是便于后续的手部轮廓提取。这里用到的方法是 基于 YCrCb + OSTU 的方法的,其他方法可参考上一篇文章基于Opencv的人体肤色检测

相关代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Mat YCrCb_otsu_Detect(Mat& src)
{
//resize(src, src, Size(300,300));

Mat ycrcb_img;
cvtColor(src, ycrcb_img, COLOR_BGR2YCrCb);
// 分离通道 提取Cr分量
vector<Mat> channels;
split(ycrcb_img, channels);

Mat cr_img = channels[1];
threshold(cr_img, cr_img, 0, 255, THRESH_BINARY | THRESH_OTSU);

Mat ycrcb_otsu_detect;
src.copyTo(ycrcb_otsu_detect, cr_img);
//imshow("cr_img", cr_img);
//imshow("ycrcb_otsu_detect", ycrcb_otsu_detect);

return cr_img;
}

获取手部轮廓和特征

获取手部外轮廓直接采用 findContours()函数即可,特征这里本文采用的是傅里叶描述子。手势识别受平移、旋转、缩放这些因素的影响要很小,因为识别一个手势主要在于它整体的一个轮廓,所以这里采用了归一化的傅里叶描述子,可以使得描述子在旋转和尺度变化下具有不变性。

傅里叶描述子的基本原理

傅里叶描述子(Fourier Descriptors)是一种在计算机视觉和图像处理中用于形状分析的技术,也可以在OpenCV中使用。傅里叶描述子通过将形状的边界曲线表示为频域上的复数信号,然后应用傅里叶变换,将形状的信息转换到频域中。这种技术通常用于对象识别、模式匹配、形状比较等任务。

傅里叶描述子的基本思想是将形状的轮廓信息从空间域(即图像中的像素坐标)转换到频域,通过这种方式来对形状进行表示和分析。

总之,傅立叶描述子可以很好地描述轮廓特征,并且只需少量的描述子(即向量中的数不需要太多)即可大致代表整个轮廓。其次,对傅立叶描述字进行简单的归一化操作后,即可使描述子具有平移、旋转、尺度不变性,即不受轮廓在图像中的位置、角度及轮廓的缩放等影响,是一个鲁棒性较好的图像特征。

归一化的傅里叶描述子是一种在计算傅里叶描述子时常用的方法,它可以使得描述子在旋转和尺度变化下具有不变性。

在冈萨雷斯的《数字图象处理》一书有详细的推导过程如下:

image-20230817195818813

相关代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
void getGesture(Mat& binary, Mat& Fourier_Out)
{
//binary = YCrCb_otsu_Detect();

vector <vector<Point>> contours;
findContours(binary, contours, RETR_EXTERNAL, CHAIN_APPROX_NONE);

int max_size = 0; // 保存最大轮廓的size
int contours_num = 0; // 保存最大轮廓的索引
// 这里用的方式是: RETR_EXTERNAL,所以好像size一直为1,因为内部已经找出最外的轮廓了
for (int i = 0; i < contours.size(); i++)
{
if (contours[i].size() > max_size)
{
max_size = contours[i].size();
contours_num = i;
}
/*cout << "size:" << contours.size() << endl;
cout << "max_size:" << max_size << endl;*/
}
Mat contour_img = Mat::zeros(binary.size(), CV_8UC1);
drawContours(contour_img, contours, contours_num, Scalar::all(255), 2, 8);
//imshow("contour_img", contour_img);

vector<float> fourier;
vector<float> fourier_normal; // 存储归一化后的傅里叶描述子
// 计算傅里叶描述子
for (int u = 0; u < max_size; u++)
{
float x, y, sum_x = 0, sum_y = 0;
for (int j = 0; j < max_size; j++)
{
x = contours[contours_num][j].x;
y = contours[contours_num][j].y;
sum_x += (float)(x * cos(2 * CV_PI * u * j / max_size) + y * sin(2 * CV_PI * u * j / max_size));
sum_y += (float)(y * cos(2 * CV_PI * u * j / max_size) - x * sin(2 * CV_PI * u * j / max_size));
}
fourier.push_back(sqrt((sum_x * sum_x) + (sum_y * sum_y))); // 计算复数的模
}

fourier_normal.push_back(0); // 第 0 位无用处
// 归一化存储前14位即可,轮廓信息是低频
for (int k = 2; k < 16; k++)
{
fourier[k] = fourier[k] / fourier[1];
fourier_normal.push_back(fourier[k]);
//cout << "fourier_normal: " << fourier[k] << endl;
}

Fourier_Out = Mat::zeros(1, fourier_normal.size(), CV_32F); // 一维用来存储计算之后的手势特征
for (int i = 0; i < fourier_normal.size(); i++)
{
Fourier_Out.at<float>(i) = fourier_normal[i];
}
}

实验图

img

mini_img

flip_img

可见在经过缩小旋转下,归一化后的傅里叶描述子基本一样。

建立特征库

这一步主要是对一些样本进行特征提取,样本的来源我比较懒所以就直接用手机拍 5 个手势,每个手势 10 个样本,大小为 512 * 512 ,保存格式为 i_j.jpg,表示手势 i 第 j 个图像。当然你也可以写一段代码通过数据增强的方法采集不同的手势,这样可以使得后续训练的网络具有泛化性。

如果你采用别人的数据集的话,那么这个数据集需要很大并且多样化,不然等到后面你用自己的手势去进行识别的话效果一般会很差。

建立特征库只需要执行一次就行,如果你要重新建立特征库则再重新执行。

FileStorage 类的使用可参考:

OpenCV —数据持久化: FileStorage类的数据存取操作与示例_getfirsttoplevelnode_iracer的博客-CSDN博客

【OpenCV 4开发详解】保存和读取XML和YMAL文件 - 知乎 (zhihu.com)

OpenCV: cv::FileStorage Class Reference

相关代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
void getAnnXml()
{
FileStorage fs("My_ann_xml.xml", FileStorage::WRITE); // 存储位置为当前目录下
if (!fs.isOpened())
{
cout << "Could not open xml file" << endl;
}

Mat Classes = Mat::zeros(sample_classes * sample_nums, 1, CV_8UC1); // 标记样本所属类别

Mat TrainData; // 存放样本的特征数据,后面用来训练网络
Mat sample_read; // 样本矩阵
char Samplepath[100]; // 读取图片的路径,这里要注意路径的大小,根据情况设置
// 遍历训练样本
for (int i = 1; i <= sample_classes; i++) // 训练 sample_classes 类手势
{
for (int j = 1; j <= sample_nums; j++) // 每个手势 sample_nums 个样本
{
sprintf_s(Samplepath, "G:\\opencv-python-image\\MyHandImg\\%d_%d.jpg", i, j);
sample_read = imread(Samplepath);
cout << "Samplepath:" << Samplepath << endl;

Mat binary = YCrCb_otsu_Detect(sample_read); // 肤色检测
Mat feature_dst;
getGesture(binary, feature_dst); // 提取手势特征

TrainData.push_back(feature_dst);
Classes.at<uchar>((i - 1) * sample_nums + j - 1) = i;
}
}

fs << "TrainData" << TrainData; // 训练数据写入文件
fs << "Classes" << Classes; // 训练类别写入文件
fs.release(); // 释放资源

cout << "数据和类别写入xml文件完成!" << endl;

}

保存完的两个文件内容大致如下:

ann_xml

ann_param

训练网络

这里采用 Opencv 的 ML 模块里的 ANN 网络对样本特征进行训练。步骤跟正常的神经网络训练差不多。

OpenCV 中的 ML 模块(Machine Learning)提供了各种机器学习算法的实现,包括神经网络。其中,cv2.ANN_MLP(Artificial Neural Network - Multi-Layer Perceptron)是OpenCV中用于实现多层感知器(MLP)神经网络的类。

多层感知器是一种前馈神经网络结构,由输入层、多个隐藏层和输出层组成。cv2.ANN_MLP可以用于分类和回归任务,如图像分类、预测等。以下是使用cv2.ANN_MLP的基本步骤:

  1. 创建网络: 使用cv2.ANN_MLP.create()方法创建一个cv2.ANN_MLP对象,并设置网络的参数,如层的数量、神经元的数量、激活函数等。
  2. 准备训练数据: 将训练数据准备为适当的格式。通常,你需要将特征作为输入,将标签作为输出。
  3. 训练网络: 使用创建的cv2.ANN_MLP对象的train()方法来对网络进行训练。你需要提供训练数据和相应的标签,以及设置训练的一些参数,如迭代次数、学习率等。
  4. 预测: 使用训练好的网络进行预测,可以使用predict()方法。将特征作为输入传递给网络,得到预测结果。
  5. 评估: 对于分类任务,你可以使用一些指标(如准确率、混淆矩阵)来评估模型的性能。

训练网络只需要执行一次就行,如果你要重新训练网络则再重新执行。

相关代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
void AnnTrain(int hidden_layer, int out_layer)
{
// 读取xml文件
Mat TrainData, Classes;
FileStorage fs("My_ann_xml.xml", FileStorage::READ);
fs["TrainData"] >> TrainData;
fs["Classes"] >> Classes;

Mat layerSizes(1, 3, CV_32SC1);
layerSizes.at<int>(0) = TrainData.cols; // 输入层神经元结点数,依据xml文件数据
layerSizes.at<int>(1) = hidden_layer; // 隐藏层神经元结点数,传参设置
layerSizes.at<int>(2) = out_layer; // 输出层神经元结点数,传参设置

Ptr<ANN_MLP> ann = ANN_MLP::create();
ann->setLayerSizes(layerSizes); // 神经网络的层数信息
ann->setActivationFunction(ANN_MLP::SIGMOID_SYM, 1.0, 1.0); // 激活函数
ann->setTrainMethod(ANN_MLP::BACKPROP, 0.1, 0.1); // 训练方法:反向传播算法,后两个参数: 权梯度项的强度(一般设置为0.1) 动量项的强度(一般设置为0.1)
ann->setTermCriteria(TermCriteria(TermCriteria::MAX_ITER, 5000, 0.01)); // 迭代终止条件,后两项参数为迭代次数和误差最小值

Mat TrainClasses = Mat::zeros(TrainData.rows, out_layer, CV_32FC1); // 有监督学习,标记每个样本所属类别
// 行表示第几个样本,列表示第几个(需要加1)类别
for (int i = 0; i < TrainClasses.rows; i++)
{
for (int j = 0; j < TrainClasses.cols; j++)
{
if ((j + 1) == (int)Classes.at<uchar>(i))
TrainClasses.at<float>(i, j) = 1;
}
}
cout << "Start Train!" << endl;
cout << "Training..." << endl;

ann->train(TrainData, ml::ROW_SAMPLE, TrainClasses); // ml::ROW_SAMPLE: 每一行表示样本

cout << "Train Succeed!" << endl;
cout << "Save Train Param!" << endl;

ann->save("My_ann_param");

cout << "Param Save Succeed!" << endl;

}

预测结果

通过ann->predict(gesture_feature, output);得到该手势对应每个手势的可能性,再通过 minMaxLoc()找出占比最大的值,则为对应的手势。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int GestureClassify(Ptr<ANN_MLP> ann, Mat& original_img)
{
int result = -1;
Mat binary = YCrCb_otsu_Detect(original_img);
Mat gesture_feature;
getGesture(binary, gesture_feature);

Mat output;
ann->predict(gesture_feature, output);

Point maxLoc;
double maxVal = 0;
minMaxLoc(output, 0, &maxVal, 0, &maxLoc); // 找出占比最大的值,看哪个是最符合的。

result = maxLoc.x;
return (result + 1);

}

计算训练集准确率

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
void train_test()
{
Mat original_img;
char Samplepath[100]; // 读取图片的路径,这里要注意路径的大小,根据情况设置
Ptr<ANN_MLP> ann = ANN_MLP::load("My_ann_param"); // 加载训练完的神经网络的参数

int accurate_num = 0, error_num = 0;
// 遍历训练样本
for (int i = 1; i <= sample_classes; i++) // 先训练 sample_classes 类手势
{
for (int j = 1; j <= sample_nums; j++) // 每个手势 sample_nums 个样本
{
sprintf_s(Samplepath, "G:\\opencv-python-image\\MyHandImg\\%d_%d.jpg", i, j);
original_img = imread(Samplepath);

int result = GestureClassify(ann, original_img); // 预测结果
printf("%d_%d.jpg的", i, j);
cout << "预测手势是: " << result << endl;
imshow("src", original_img);

char str[20];
sprintf_s(str, "Gesture: %d", result);
string text = str;
putText(original_img, text, Point(20, 30), FONT_HERSHEY_SIMPLEX, 1, Scalar(255, 255, 0), 2, 8);
imshow("result", original_img);

if (i == result){
accurate_num++;
}
else {
error_num++;
}
waitKey(200);
}
}
cout << "准确率: " << double(accurate_num) / double(accurate_num + error_num) << endl;
}

主函数代码

这里采用摄像头截取手部识别手势,如果对视频每一帧就不断识别(处理结果时间较长 200ms左右)会导致视频帧率很低,估计就 5 帧左右;因此我这里改为当按下空格键再截取视频帧图像并进行识别显示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#include<opencv2/opencv.hpp>
#include<iostream>
#include <string>
using namespace std;
using namespace cv;
using namespace ml;

int sample_classes = 5; // 训练几个类别
int sample_nums = 10; // 每个类别有几个样本

Mat YCrCb_otsu_Detect(Mat& src); // 肤色检测
void getGesture(Mat& binary, Mat& Fourier_Out); // 提取一个图像的傅里叶描述子特征
void getAnnXml(); // 对大量样本进行特征提取,并存储为xml文件,若没修改训练样本,提取一次即可。
void AnnTrain(int hidden_layer, int out_layer); // 对样本进行神经网络训练,若没修改训练样本,训练一次即可。
int GestureClassify(Ptr<ANN_MLP> ann, Mat& original_img); // 对测试样本进行预测
void train_test(); // 计算训练集准确率

int main()
{
//getAnnXml(); // 将特征写入xml文件
//AnnTrain(24, sample_classes); // 训练网络 24隐藏层结点,5个输出结点

//train_test(); // 测试训练集的训练结果

char Samplepath[100]; // 读取图片的路径,这里要注意路径的大小,根据情况设置
Ptr<ANN_MLP> ann = ANN_MLP::load("My_ann_param"); // 加载训练完的神经网络的参数

VideoCapture capture(0);
if (!capture.isOpened())
{
cout << "Video load error" << endl;
exit(-1);
return -1;
}

namedWindow("frame", WINDOW_NORMAL);
resizeWindow("frame", 512, 512);

Mat frame;
while (capture.read(frame))
{
char key = waitKey(100);
if (key == 27) // Esc 退出
break;

if (key == ' ') // 空格截取图像并识别
{
clock_t StartTime = clock();
Mat processed_frame;
flip(frame, processed_frame, 0);

int result = GestureClassify(ann, processed_frame); // 预测结果
cout << "预测手势是: " << result << endl;

char str[20];
sprintf_s(str, "Gesture: %d", result);
string text = str;
putText(processed_frame, text, Point(20, 30), FONT_HERSHEY_SIMPLEX, 1, Scalar(255, 255, 0), 2, 8);
imshow("processed_frame", processed_frame);
cout << "Each Frame Wastes Time: " << clock() - StartTime << endl;
}
imshow("frame", frame);
}
capture.release();
waitKey(0);
destroyAllWindows();
return 0;
}

结果图

result.png

每个手势样本只有 10 个,训练集准确率还有 0.96,还是不错的,不过这样的话重新拍照手势进行识别时,对测试图像的拍摄要求就相对高一些,要想使得识别更具有泛化性,还需要更多的训练样本进行训练。

结语

本文使用 Opencv 实现一个简单的手势识别,该项目值得优化的地方还有很多,比如增加训练样本、修改网络参数、对代码进行优化减少识别时间等等。

主要步骤有:获取手部轮廓、计算傅里叶描述子、训练 ANN_MLP 网络、预测结果及识别效果显示。

参考资料

基于OPENCV的手势识别技术_opencv手势识别_Lingyoha的博客-CSDN博客

OpenCV之傅里叶描述子特征构建(Fourier Descriptor)_opencv 傅里叶描述子_wendy_ya的博客-CSDN博客

【OpenCV 4开发详解】保存和读取XML和YMAL文件 - 知乎 (zhihu.com)