摘要: 本文介绍基于 OpenCV 的手势识别方法,核心是利用凸缺陷检测来识别手势所表示的数字。文章首先概述手势识别的应用场景,然后详细讲解凸缺陷检测原理,说明如何通过统计手指间凸缺陷的个数来区分数字 2~5,并给出完整的 C++ 代码示例。针对凸缺陷个数为 0 的情况,文章还介绍了结合面积差等方法区分手势 0 和 1 的思路,最终实现对手势 0 到 5 的识别。
长期以来,手势识别一直是计算机视觉社区中的一个非常有趣的项目。这主要是因为从杂乱的背景中分割前景对象是一个具有挑战性的实践问题。最明显的原因是,当人类和计算机看同一图像时,涉及语义差距。人类可以很容易地弄清楚图像中的内容,但对于计算机来说,图像只是三维矩阵。正因为如此,计算机视觉中的手势识别问题仍然是一个挑战。
1.手势识别概述
手势识别是一项应用非常广泛且有趣的技术,它可以在许多不同的场景中发挥巨大的作用。手势识别用于识别手势所表示的数值,这在一些需要快速输入数字的场景中特别有用,比如计算、游戏等。
在特定游戏中,手势识别可以让我们更自然地与游戏进行交互。比如,通过识别"石头、剪刀、布"这样的手势,我们可以进行有趣的猜拳游戏;或者通过识别前进、跳跃、后退等手势,我们可以更直观地控制游戏角色的行动。
当然,手势识别不局限于游戏中,在现实生活中,它也有着广泛的应用。例如,识别表示"OK"的手势,可以帮助我们确认一些操作或信息;识别表示胜利的手势,可以让我们在庆祝时更加激动。总之,手势识别技术正在不断地发展和完善,它将在未来为我们带来更多的便利和乐趣。
2.手势识别原理
在手势识别中,利用凸缺陷检测来识别手势是一种有效的方法,特别是当手势主要由手指组成时,我们只需要计算手指间的凸缺陷个数,就可以根据这个数值识别出手势所表示的数目,如图所示。

·当有4个手指间凸缺陷时,手势表示数值5。
·当有3个手指间凸缺陷时,手势表示数值4。
·当有2个手指间凸缺陷时,手势表示数值3。
·当有1个手指间凸缺陷时,手势表示数值2。
·当有0个手指间凸缺陷时,手势可能表示数值1,也可能表示数值0。
由此看出,在对手势进行数字识别时,手指间凸缺陷的个数确实是一个有用的特征,特别是当手指间凸缺陷个数不为0时,它可以帮助我们区分数字2~5。然而,当手指间凸缺陷个数为0时,这一特征变得不再有效,因为此时手势可能表示数字0或1,无法仅凭手指间凸缺陷的个数来确定具体是哪个数字。因此,在实际应用中,当我们遇到手指间凸缺陷个数为0的情况时,需要依赖其他特征或信息来进一步判断手势所表示的数字。这可能包括手势的整体形状、手指的相对位置、手势的动态变化等。通过综合考虑多个特征,我们可以更准确地识别出手势所表示的数字。
在这里要强调的是,上面所说的原理是基于手指间的凸缺陷,注意"手指间"这3个字必不可少!为何呢?因为除了手指间凸缺陷,图片中通常还存在其他凸缺陷,这些凸缺陷通常认为是噪声缺陷,应该想办法忽略掉。
【例18.4】查找所有凸缺陷
cpp
#include <opencv2/opencv.hpp>
#include <iostream>
using namespace std;
using namespace cv;
int main()
{
Mat srcGary = imread("f2.jpg",0);
imshow("srcGary", srcGary);
// 阈值化操作
Mat threMat;
int thresh = 128;
threshold(srcGary, threMat, thresh, 255, THRESH_BINARY);
// 轮廓检测
vector<vector<Point> > contours;
vector<Vec4i> hierarchy;
findContours(threMat, contours, hierarchy, RETR_TREE, CHAIN_APPROX_SIMPLE, Point(0, 0));
// 绘制轮廓
Mat contours_img(srcGary.size(), CV_8U, Scalar(0));
drawContours(contours_img, contours, -1, Scalar(255), 1);
imshow("contours_img", contours_img);
//凸包检测和凸包缺陷检测
vector<vector<Point> > pointHull(contours.size());
vector<vector<int> > intHull(contours.size());
vector<vector<Vec4i> > hullDefect(contours.size());
for (size_t i = 0; i < contours.size(); i++)
{
// 输出结果为Point类型的凸包检测
convexHull(Mat(contours[i]), pointHull[i], false);
// 输出结果为int类型的凸包检测
convexHull(Mat(contours[i]), intHull[i], false);
//凸包缺陷检测
convexityDefects(Mat(contours[i]), intHull[i], hullDefect[i]);
}
//绘制凸包及凸包缺陷
Mat convex_hull_img = contours_img.clone();
cvtColor(convex_hull_img, convex_hull_img, COLOR_GRAY2BGR);
cout<<contours.size()<<endl; //输出轮廓个数
for (size_t i = 0; i < contours.size(); i++)
{
Scalar color = Scalar(0, 255, 0);//BGR顺序,因此是绿色
drawContours(convex_hull_img, pointHull, i, color, 1, 8, vector<Vec4i>(), 0, Point());//绘制凸包
// 绘制缺陷
size_t count = contours[i].size();
cout<<hullDefect[i].size();//输出当前轮廓的凸缺陷个数
// 凸包缺陷迭代器设置
vector<Vec4i>::iterator iterDefects = hullDefect[i].begin();
// 遍历得到凸包缺陷的4个特征量并进行绘制
while (iterDefects != hullDefect[i].end()) //遍历第i个轮廓上的凸缺陷
{
Vec4i& v = (*iterDefects);
int faridx = v[2]; //最远点
Point ptFar(contours[i][faridx]);
circle(convex_hull_img, ptFar, 4, Scalar(128, 0, 255), 2);//最远点ptFar用粉红色
iterDefects++;
}//while
}
imshow("convex_hull_img", convex_hull_img);//显示最终结果图像
waitKey(); //等待用户按键
return 0;
}

在手势识别中,处理除指缝外的其他凸缺陷是一个重要的步骤,特别是当这些凸缺陷可能由图像噪声、手部细节(如皱纹、毛孔)或轮廓检测算法不精确引起时。一个常见的方法是基于凸缺陷的某些特征(如大小、深度或形状)来判断它们是否应被视为噪声。常见的方式有如下几种:
(1)根据凸缺陷的面积来判断,面积相对较小的凸缺陷很可能是噪声。
(2)根据角度。通过分析凸缺陷最远点与起点、终点之间构成的角度来进一步过滤噪声。如果这个角度大于90°,那么它可能不是由指缝形成的,因为通常指缝间的角度会小于或等于90°(尽管这也会受到手指弯曲程度和手势复杂度的影响)。
(3)通过计算凸缺陷上的最远点到凸包的近似距离来进行判断。如果这个距离很小,我们可以认为这个凸缺陷是由噪声产生的,并将其处理为噪声。
这些方法可以单独使用,也可以联合起来使用,当然联合起来使用效果更好。
3.区分手势
我们把手势0和手势1区分开来后,接下来就只需区分2到5这几个数字。原理是根据手指间角度,即通过分析凸缺陷最远点与起点、终点之间构成的角度进行判断,如果这个角度大于90°,那么它可能不是由指缝形成的,因为通常指缝间的角度会小于或等于90°;否则就认为这个凸缺陷是手指间凸缺陷,最后统计出手指间的凸缺陷的个数后,再加上1就是手指的个数。
另外,要讲一下数学原理:我们把一个手的全部凸缺陷找出来后,针对每一个凸缺陷,可以知道起点、终点和最远点这3个点,这3个点可以围成一个三角形,然后根据两点间距离公式,可以得到三角形的每条边的长度,接着通过余弦定理和反余弦函数得到起点和最远点之间的边与终点和最远点之间的边的角度,最后把这个角度和90°进行比较。这过程涉及两个数学知识。
(1)平面坐标两点距离公式,即已知两个点A、B及其坐标分别为(x,y)、(x,y),则两点间的距离为:

在C++中,可以用函数norm求两点间距离(两点间的向量模),代码如下:
c
#include
#include
using namespace std;
#include
int main()
{
Vec3f A = Vec3f(10, 10, 1);
Vec3f B = Vec3f(3, 3, 1);
qDebug()<< "线段AB的长度为 = " << norm(A - B) << endl;
return 0;
}
结果为9.89949,即sqrt(98)。
(2)已知三角形的三边长度分别为a、b、c,可以通过余弦定理来求解角度。具体公
式如下:

其中b和c是角α的相邻边的长度,a是角α对边的长度。使用反余弦函数arccos可以得到角α的具体的角度(以弧度为单位)。注意:反余弦值的单位是弧度,值域为0,π,其中π表示180°,π/2表示90°。
当然,弧度和角度之间是可以互换的。弧度制与角度制的换算公式:
1°=π/180≈0.01745弧度,1弧度=180°/π≈57.3°。
下面程序中把弧度换算为角度时就乘以了57。
【例18.6】区分手势0到5
cpp
#include <opencv2/opencv.hpp>
#include <iostream>
#include <cmath>
#include <vector>
using namespace std;
using namespace cv;
std::string dis01(Mat imgSrc)
{
Mat srcGary;
cvtColor(imgSrc, srcGary, COLOR_BGR2GRAY);//灰度化图片,准备找轮廓
vector<vector<Point>> contours;
vector<Vec4i> hierarchy;
findContours(srcGary, contours, hierarchy, RETR_TREE, CHAIN_APPROX_SIMPLE);
//从所有轮廓中找到最大的,作为手势的轮廓
double max_area = 0;
vector<Point> max_contour;
for (int i = 0; i < contours.size(); ++i) {
double area = contourArea(contours[i]);
if (area > max_area) {
max_area = area;
max_contour = contours[i];
}
}
vector< Point > hull;
double areacnt = contourArea(max_contour); //获取轮廓面积
cout << areacnt;//可以输出下面积
convexHull(max_contour, hull, true);//获取轮廓的凸包
double areahull = contourArea(hull);//获取凸包的面积
cout << areahull;//可以输出下面积
double dif = areahull - areacnt;//计算凸包所围和轮廓所围区域的面积差
cout << dif;//可以输出下面积
if (dif < 150)
return "0"; //面积差是否小于150,若小于150,则认为是手势0
else
return "1";//若面积差大于等于150,则认为是手势1
}
int main()
{
cv::utils::logging::setLogLevel(cv::utils::logging::LOG_LEVEL_ERROR);
char imgPath[256] = "f5.jpg";
//cout << "enter image imgPath(f0.jpg---f5.jpg):";
//cin >> imgPath;
Mat img = imread(imgPath); //加载图片
if (!img.data)
{
//判断图像是否为空
cout << "could not load image...\n" << endl;
return -1;
}
imshow("src", img);//显示原图
Mat img_gray, thresh;
vector<vector<Point>> contours;
vector<Vec4i> hierarchy;
cvtColor(img, img_gray, COLOR_BGR2GRAY);//转换为灰度图
threshold(img_gray, thresh, 127, 255, 0);//对灰度图像进行阈值操作得到二值图像
findContours(thresh, contours, hierarchy, 2, 1);//查找轮廓
//从所有轮廓中找到最大的,作为手势的轮廓
double max_area = 0;
vector<Point> max_contour;
for (int i = 0; i < contours.size(); ++i) {
double area = contourArea(contours[i]);
if (area > max_area) {
max_area = area;
max_contour = contours[i];
}
}
// 获取凸缺陷
vector<int> hull_indices;
convexHull(max_contour, hull_indices, false);
vector<Vec4i> defects;
convexityDefects(max_contour, hull_indices, defects);
int cn = 0; //cn用于统计竖直手指的个数
// 遍历凸缺陷,判断是否为指间凸缺陷
for (size_t i = 0; i < defects.size(); i++) {
int s = defects[i][0]; // 起点的索引
int e = defects[i][1]; // 终点的索引
int f = defects[i][2]; // 远点的索引
Point start = (max_contour)[s];//起点的坐标
Point end = (max_contour)[e];//终点的坐标
Point far = (max_contour)[f];//远点的坐标
double a = norm(end - start); //得到两点间长度
double b = norm(far - start);
double c = norm(end - far);
// 计算手指之间的角度,乘以57是弧度换算为角度
double angle = acos((b * b + c * c - a * a) / (2 * b * c)) * 57;
if (angle <= 90 && defects[i][3] > 20) {
cn++;
circle(img, far, 3, Scalar(255, 0, 0), -1); // 用蓝色绘制最远点
}
// 绘制手势的凸包
line(img, start, end, Scalar(0, 255, 0), 2);
}
string str;
if (cn > 0)
{
cn += 1;//手指间凸缺陷数+1后才是手指数
str = to_string(cn);
putText(img, str, Size(50, 150), FONT_HERSHEY_SIMPLEX, 1, Scalar(0, 0, 255), 2, LINE_AA);
imshow("final_img", img);// 显示最终图像
}
else //如果cn=0,则说明现在的手势可能是0或1,要调用01区分函数进行判断
{
Mat a = imread(imgPath);
std::string stra = dis01(a);
str = to_string(cn);
// 把识别结果输出到原图
putText(a, stra, Size(50, 150), FONT_HERSHEY_SIMPLEX, 1, Scalar(0, 0, 255), 2, LINE_AA);
// 显示最终结果的图片
imshow("final_img", a);
}
waitKey();//等待用户按键
destroyAllWindows();//销毁所有窗口
return 0;
}

在得到凸缺陷后,我们首先获得起点、终点、最远点在轮廓上的索引,然后转换为坐标。再通过norm函数得到两点间的长度。norm函数是C ++ STL中的内置函数,在头文件中定义,用于获取复数的范数。复数的范数是数字的平方大小。数学中,范数(Norm)是一种函数,用来度量向量的大小。通俗地讲,范数就是一个向量的长度或大小。
为了便于理解,我们把手指间凸缺陷起点、终点和最远点和凸包线都标记出来了,这样对于计算两点间距离和反余弦函数要计算的那个角就很清楚了,也就是要计算的角是凸包线对面的角,即最远点所在位置的角。
利用余弦定理和反余弦函数计算角度,所得的角的单位是弧度,若要换算为角度,则需要乘以57。如果不想换算为角度,也可以和π/2进行比较。函数dis01和上例一样,这里不再列出。