OpenCV-Python实战(25)------基于深度传感器与凸性分析打造实时手势识别系统
-
- [0. 前言](#0. 前言)
- [1. 本节目标](#1. 本节目标)
- [2. 规划应用](#2. 规划应用)
- [2. 搭建应用程序](#2. 搭建应用程序)
-
- [2.1 访问 Kinect 3D 传感器](#2.1 访问 Kinect 3D 传感器)
- [2.2 使用兼容 OpenNI 的传感器](#2.2 使用兼容 OpenNI 的传感器)
- [2.3 运行应用程序和主函数例程](#2.3 运行应用程序和主函数例程)
- [3. 实时跟踪手势](#3. 实时跟踪手势)
- [4. 手部区域分割](#4. 手部区域分割)
-
- [4.1 寻找图像中心区域的最显著深度值](#4.1 寻找图像中心区域的最显著深度值)
- [4.2 应用形态学闭运算进行平滑处理](#4.2 应用形态学闭运算进行平滑处理)
- [4.3 在分割掩码中查找连通分量](#4.3 在分割掩码中查找连通分量)
- [5. 手部形状分析](#5. 手部形状分析)
-
- [5.1 确定分割后手部区域的轮廓](#5.1 确定分割后手部区域的轮廓)
- [5.2 查找轮廓区域的凸包](#5.2 查找轮廓区域的凸包)
- [5.3 找到凸包的凸性缺陷](#5.3 找到凸包的凸性缺陷)
- [6. 执行手势识别](#6. 执行手势识别)
-
- [6.1 区分凸性缺陷的不同成因](#6.1 区分凸性缺陷的不同成因)
- [6.2 根据伸展手指数对手势进行分类](#6.2 根据伸展手指数对手势进行分类)
- 小结
- 系列链接
0. 前言
本节的目的是开发一个应用程序,利用深度传感器(例如 Microsoft Kinect 3D 传感器或 ASUS Xtion 传感器)的输出,实时检测并跟踪简单的手势。该应用程序将分析每一帧捕获的图像,以执行以下任务:
- 手部区域分割:通过分析
Kinect传感器输出的深度图,在每一帧中提取用户的手部区域。这通过阈值分割、应用形态学操作以及查找连通分量来实现 - 手部形状分析:通过确定轮廓、凸包和凸性缺陷来分析分割得到的手部区域的形状
- 手势识别:根据手部轮廓的凸性缺陷来确定伸展手指的数量,并据此对手势进行分类(没有伸展手指对应拳头,五根手指伸展对应张开的手)
手势识别在计算机科学中一直是一个非常热门的话题。这不仅因为它使人类能够与机器进行交流(即人机交互 (Human-Machine Interaction, HMI)),而且也是机器开始理解人类肢体语言的第一步。借助 Microsoft Kinect 或 Asus Xtion 等传感器,以及 OpenKinect 和 OpenNI 等开源软件,我们可以很轻松的入门这一领域。
我们在本节中实现的算法的优点在于,它能够很好地处理多种手势,同时又足够简单,可以在普通笔记本电脑上实时运行。此外,如果我们需要,还可以轻松地扩展它,以纳入更复杂的手部姿态估计。
1. 本节目标
完成本应用程序后,将了解如何在自已的应用中使用深度传感器。将学习如何利用 OpenCV 从深度信息中提取感兴趣的形状,以及如何使用 OpenCV 通过几何属性分析形状。
本节需要我们安装 Microsoft Kinect 3D 传感器,或者,也可以安装 Asus Xtion 传感器或其他 OpenCV 内置支持的深度传感器。首先,安装 OpenKinect 和 libfreenect。
2. 规划应用
最终的应用程序将包含以下模块和脚本:
gestures:这是一个包含手势识别算法的模块gestures.process:该函数实现手势识别的完整处理流程。它接受单通道深度图像(从Kinect深度传感器获取),并返回带注释的蓝绿红 (BGR) 彩色图像,同时给出估计的伸展手指数量
kinect_hand:本节的主脚本kinect_hand.main:主函数例程,它遍历从深度传感器获取的帧,使用gestures.process处理每一帧,然后展示结果
最终效果如下图所示:

可以看到,无论手伸展出多少根手指,该算法都能正确分割手部区域(白色部分),绘制相应的凸包(环绕手部的绿色轮廓),找出属于指缝间的所有凸性缺陷(大的绿色点),同时忽略其他缺陷(小的红色点),并推断出正确的伸展手指数量(右下角的数字),即使是拳头也能正确识别。
2. 搭建应用程序
在我们深入研究手势识别算法的细节之前,需要确保能够访问深度传感器并显示深度帧流。在本节中,我们将介绍以下有助于搭建应用程序的内容:
- 访问
Kinect 3D传感器 - 使用与
OpenNI兼容的传感器 - 运行应用程序和主函数例程
2.1 访问 Kinect 3D 传感器
访问 Kinect 传感器的最简单方法是使用名为 freenect 的 OpenKinect 模块。
freenect 模块包含 sync_get_depth() 和 sync_get_video() 等函数,分别用于从深度传感器和摄像头传感器同步获取图像。本节中,我们只需要 Kinect 深度图,这是一个单通道(灰度)图像,其中每个像素值表示从摄像头到视觉场景中特定表面的估计距离。
在这里,我们将设计一个函数,用于从传感器读取一帧并将其转换为所需格式,然后返回该帧以及一个成功状态,如下所示:
python
def read_frame(): -> Tuple[bool,np.ndarray]:
该函数包括以下步骤:
(1) 获取一帧;如果未能获取到帧,则终止函数:
python
frame, timestamp = freenect.sync_get_depth()
if frame is None:
return False, None
sync_get_depth 方法返回深度图和一个时间戳。默认情况下,深度图是 11 位格式。传感器的最后 10 位描述深度值,而第一位如果等于 1,则表示距离估算失败。
(2) 最好将数据标准化为 8 位精度的格式,因为 11 位格式不适合立即用 cv2.imshow 可视化,也不利于后续处理。我们可能要使用一些以不同格式返回的不同传感器:
python
frame = np.clip(frame, 0, 2**10 - 1)
frame >>= 2
在上述代码中,我们首先将数值裁剪到 1023 (即 2 10 − 1 2^{10} - 1 210−1),以适配 10 位范围。这样的裁剪会将未检测到的距离值赋值为最远的可能点。接着,我们向右移位 2 位,将距离适配到 8 位范围内。
(3) 最后,我们将图像转换为 8 位无符号整数数组,并返回结果:
python
return True, frame.astype(np.uint8)
可视化深度图像:
python
cv2.imshow("depth", read_frame()[1])
下一节中,我们将学习如何使用兼容 OpenNI 的传感器。
2.2 使用兼容 OpenNI 的传感器
要使用兼容 OpenNI 的传感器,首先必须确保已安装 OpenNI2,并且使用的 OpenCV 是在支持 OpenNI 的情况下编译的。编译信息可以通过如下方式打印:
python
import cv2
print(cv2.getBuildInformation())
如果 OpenCV 是在支持 OpenNI 的情况下编译的,会在 Video I/O 部分找到相关信息。否则,需要重新编译 OpenCV 并启用 OpenNI 支持,这可以通过向 cmake 传递 -D WITH_OPENNI2=ON 标志来完成。
安装过程完成后,可以像使用其他视频输入设备一样,通过 cv2.VideoCapture 访问传感器。在本应用中,如果想要使用兼容 OpenNI 的传感器而不是 Kinect 3D 传感器,需要执行以下步骤:
(1) 创建一个连接到兼容 OpenNI 传感器的视频捕获对象:
python
device = cv2.cv.CV_CAP_OPENNI
capture = cv2.VideoCapture(device)
如果要连接到 Asus Xtion,应将 device 变量赋值为 cv2.CV_CAP_OPENNI_ASUS。
(2) 将输入帧大小更改为标准视频图形矩阵 (Video Graphics Array, VGA) 分辨率:
python
capture.set(cv2.cv.CV_CAP_PROP_FRAME_WIDTH, 640)
capture.set(cv2.cv.CV_CAP_PROP_FRAME_HEIGHT, 480)
(3) 在上一小节中,我们已经定义了 read_frame 函数,该函数使用 freenect 访问 Kinect 传感器。为了从视频捕获对象中读取深度图像,需要将该函数修改为以下形式:
python
def read_frame():
if not capture.grab():
return False,None
return capture.retrieve(cv2.CAP_OPENNI_DEPTH_MAP)
可以看到,我们使用了 grab 和 retrieve 方法而不是 read 方法。原因在于,当需要同步一组摄像头或像 Kinect 这样的多核摄像头时,cv2.VideoCapture 的 read 方法并不适用。
在这种情况下,可以使用 grab 方法在某个时刻从多个传感器抓取帧,然后使用 retrieve 方法检索所需传感器的数据。例如,在应用中,我们可能还需要检索 BGR 帧(标准摄像头帧),这可以通过向 retrieve 方法传递 cv2.CAP_OPENNI_BGR_IMAGE 来实现。
现在,我们已经能够从传感器读取数据,接下来我们将学习如何运行应用程序。
2.3 运行应用程序和主函数例程
kinect_hand.py 脚本负责运行应用程序,它首先导入以下模块:
python
import cv2
import numpy as np
from typing import Tuple
from gestures import recognize
from frame_reader import read_frame
recognize 函数负责识别手势,我们将在本续部分中编写它。为了方便起见,我们还将上一小节中编写的 read_frame 方法放在了一个单独的脚本中。
为了简化分割任务,我们会提示用户将手放在屏幕中央。为了提供视觉辅助,我们创建以下函数:
python
def draw_helpers(img_draw: np.ndarray) -> None:
height, width = img_draw.shape[:2]
color = (0,102,255)
cv2.circle(img_draw, (width // 2, height // 2), 3, color, 2)
cv2.rectangle(img_draw, (width // 3, height // 3),
(width * 2 // 3, height * 2 // 3), color, 2)
该函数会在图像中心周围绘制一个矩形,并用橙色高亮标记图像的中心像素。
所有核心工作都由 main 函数完成:
python
def main():
for _, frame in iter(read_frame, (False, None)):
该函数遍历来自 Kinect 的灰度帧,在每次迭代中执行以下步骤:
(1) 使用 recognize 函数识别手势,该函数返回估计的伸展手指数量 (num_fingers)以及一张带注释的 BGR` 彩色图像:
python
num_fingers, img_draw = recognize(frame)
(2) 在带注释的 BGR 图像上调用 draw_helpers 函数,以提供手部放置的视觉辅助:
python
draw_helpers(img_draw)
(3) 最后,main 函数在带注释的帧上绘制手指数量,使用 cv2.imshow 显示结果,并设置终止条件:
python
cv2.putText(img_draw, str(num_fingers), (30, 30),
cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255))
cv2.imshow("frame", img_draw)
# Exit on escape
if cv2.waitKey(10) == 27:
break
现在,我们已经有了主脚本,唯一缺失的函数就是 recognize 函数。为了实时跟踪手势,我们将在下一小节中编写这个函数。
3. 实时跟踪手势
手势由 recognize 函数进行分析,该函数处理从原始灰度图像到识别出手势的整个流程,它返回手指数量以及用于展示的帧。该函数实现以下步骤:
(1) 通过分析深度图 (img_gray) 提取用户的手部区域,并返回手部区域掩码 (segment):
python
def recognize(img_gray):
segment = segment_arm(img_gray)
(2) 对手部区域掩码 (segment) 执行轮廓分析。然后,返回图像中找到的最大轮廓 (contour) 以及所有凸性缺陷 (defects):
python
(contour, defects) = find_hull_defects(segment)
(3) 根据找到的轮廓和凸性缺陷,检测图像中伸展手指的数量 (num_fingers)。然后,使用 segment 图像作为模板创建用于展示的图像 (img_draw),并用轮廓和缺陷点进行注释:
python
img_draw = cv2.cvtColor(segment, cv2.COLOR_GRAY2RGB)
(num_fingers, img_draw) = detect_num_fingers(contour,
defects, img_draw)
(4) 最后,返回估计的伸展手指数量 (num_fingers) 以及带注释的输出图像 (img_draw):
python
return (num_fingers, img_draw)
在下一小节中,我们学习如何实现手部区域分割。
4. 手部区域分割
手臂------以及后续手部区域的自动检测,可以通过结合手臂或手部的形状和颜色信息来设计,其复杂程度因环境而异。但是,在光照条件不佳或用户佩戴手套的情况下,使用肤色作为寻找手部的决定性特征可能会彻底失效。因此,我们选择通过深度图中的形状来识别用户的手部。
允许各种手部出现在图像的任意区域会不必要地增加本节任务的复杂度,因此我们做了两个简化的假设:
- 我们会指导应用程序的用户将手放在屏幕中央前方,手掌方向大致与
Kinect传感器平行,这样更容易识别手部对应的深度层 - 我们还会指导用户坐在距离
Kinect约1到2米的位置,并将手臂稍微向身体前方伸展,这样手部最终会出现在与手臂略有不同的深度层上。不过,即使整个手臂都可见,该算法仍然有效
这样一来,仅基于深度层来分割图像就相对简单了。否则,我们首先需要设计一个手部检测算法,这将会不必要地增加任务的复杂度。
接下来,我们来学习如何找到图像中心区域的最显著深度值。
4.1 寻找图像中心区域的最显著深度值
一旦手部大致放置在屏幕中央,我们就可以开始寻找所有与手部处于同一深度平面的图像像素。通过执行以下步骤来完成此操作:
(1) 首先,我们只需确定图像中心区域的最显著深度值。最简单的方法是仅查看中心像素的深度值:
python
width, height = depth.shape
center_pixel_depth = depth[width/2, height/2]
(2) 然后,创建一个掩码,其中所有深度为 center_pixel_depth 的像素为白色,其他所有像素为黑色:
python
import numpy as np
depth_mask = np.where(depth == center_pixel_depth, 255, 0).astype(np.uint8)
然而,这种方法不够鲁棒,因为它可能会受到以下因素的影响:
- 手不会完全平行于
Kinect传感器放置 - 手不会是完全平坦的
Kinect传感器的数值包含噪声
因此,手部的不同区域会有略微不同的深度值。
segment_arm 方法进行了小的改进,查看图像中心的一个小邻域,并确定其中位深度值。通过执行以下步骤来完成此操作:
(1) 首先,找到图像帧的中心区域(例如 21×21 像素):
python
def segment_arm(frame: np.ndarray, abs_depth_dev: int = 14) -> np.ndarray:
height, width = frame.shape
center_half = 10 # half-width of 21 is 21/2-1
center = frame[height // 2 - center_half:height // 2 + center_half,
width // 2 - center_half:width // 2 + center_half]
(2) 然后,确定中位深度值 med_val:
python
med_val = np.median(center)
现在,我们可以将 med_val 与图像中所有像素的深度值进行比较,并创建一个掩码,其中深度值在特定范围 [med_val - abs_depth_dev, med_val + abs_depth_dev] 内的所有像素为白色,其他所有像素为黑色。
但是,我们暂时将像素绘制为灰色而不是白色(后续部分将说明这样做的原因):
python
frame = np.where(abs(frame - med_val) <= abs_depth_dev,
128, 0).astype(np.uint8)
结果如下所示:

可以看到,分割掩码并不平滑。具体来说,在深度传感器无法进行预测的位置,掩码中存在孔洞。下一小节中,我们将学习如何应用形态学闭运算来平滑分割掩码。
4.2 应用形态学闭运算进行平滑处理
分割的一个常见问题是,硬阈值通常会导致分割区域中出现微小的瑕疵(例如上图中的孔洞)。这些孔洞可以通过使用形态学开运算和闭运算来减轻。开运算可以移除前景中的小物体(假设物体在暗背景上为亮色),而闭运算则可以移除小孔洞(暗区域)。
这意味着我们可以通过使用一个 3×3 像素的小尺寸核进行形态学闭运算(先膨胀后腐蚀)来去除掩码中的小黑色区域:
python
kernel = np.ones((3, 3), np.uint8)
frame = cv2.morphologyEx(frame, cv2.MORPH_CLOSE, kernel)
结果看起来平滑了许多,如下图所示:

但需要注意的是,掩码中仍然包含不属于手或手臂的区域,例如左侧似乎是一个膝盖,右侧则是一些家具。这些物体恰好与手臂和手处于同一深度层。如果可能,我们现在可以将深度信息与其他描述符(例如基于纹理或骨骼的手部分类器)结合起来,以剔除所有非肤色区域。
大多数情况下,手不会与膝盖或家具相连,因此,一个更简单的方法是在分割掩码中查找连通分量。
4.3 在分割掩码中查找连通分量
我们已经知道中心区域属于手部。对于这种情况,我们可以直接应用 cv2.floodFill 来查找所有连通的图像区域。
在此之前,我们需要绝对确保泛洪填充 (flood filling) 的种子点属于正确的掩码区域。这可以通过将种子点的灰度值设为 128 来实现。但我们还需要确保中心像素不会恰好位于形态学操作未能闭合的空腔中。
因此,我们设置一个小的 7 x 7 像素区域,其灰度值为 128:
python
small_kernel = 3
frame[height // 2 - small_kernel:height // 2 + small_kernel,
width // 2 - small_kernel:width // 2 + small_kernel] = 128
由于泛洪填充(以及形态学操作)存在潜在风险,OpenCV 要求指定一个掩码,以避免填充整个图像。该掩码必须比原始图像宽 2 像素、高 2 像素,并且必须与 cv2.FLOODFILL_MASK_ONLY 标志结合使用。
将泛洪填充限制在图像的小区域或特定轮廓内会非常有帮助,这样我们就不必连接两个本来就不应该连通的相邻区域。
但是,在这里我们将掩码完全设为黑色:
python
mask = np.zeros((height + 2, width + 2), np.uint8)
然后,我们可以对中心像素(种子点)应用泛洪填充,并将所有连通区域绘制为白色:
python
flood = frame.copy()
cv2.floodFill(flood, mask, (width // 2, height // 2), 255,
flags=4 | (255 << 8))
此时,我们应该能明白为什么之前决定从灰色掩码开始。现在我们拥有一个包含白色区域(手臂和手)、灰色区域(既不是手臂也不是手,但处于同一深度平面上的其他物体)和黑色区域(所有其他物体)的掩码。通过这样的设置,我们可以轻松应用一个简单的二值阈值,仅突出显示预分割深度平面中的相关区域:
python
ret, flooded = cv2.threshold(flood, 129, 255, cv2.THRESH_BINARY)
生成的掩码如下所示:

现在,得到的分割掩码可以返回到 recognize 函数中,在那里它将作为输入传递给 find_hull_defects 函数,同时也作为绘制最终输出图像 (img_draw) 的画布。该函数通过分析手的形状来检测与手相对应的凸包的缺陷。下一小节中,我们将学习如何执行手部形状分析。
5. 手部形状分析
现在我们(大致)知道了手部的位置,接下来目标是了解手部的形状。在本应用中,我们将基于手部对应轮廓的凸性缺陷来判断具体展示了哪种手势。
下面我们继续学习如何确定分割后手部区域的轮廓,这是手部形状分析的第一步。
5.1 确定分割后手部区域的轮廓
第一步涉及确定分割后手部区域的轮廓。OpenCV 自带了一个现成的算法------cv2.findContours,该函数作用于二值图像,并返回一组被认为是轮廓一部分的点集。由于图像中可能存在多个轮廓,我们可以获取整个轮廓的层次结构:
python
def find_hull_defects(segment: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
contours, hierarchy = cv2.findContours(segment, cv2.RETR_TREE,
cv2.CHAIN_APPROX_SIMPLE)
此外,因为我们不确定要找的是哪个轮廓,所以必须做一个假设来清理轮廓结果,因为即使在形态学闭运算之后,仍可能残留一些小空腔。不过,我们相当确信我们的掩码只包含感兴趣的分割区域,我们假设找到的最大轮廓就是我们要找的那个。
因此,我们只需遍历轮廓列表,计算轮廓面积 (cv2.contourArea),并只存储最大的那个 (max_contour):
python
max_contour = max(contours, key=cv2.contourArea)
我们找到的轮廓可能仍然有太多角点。我们用一个相似的轮廓来近似它,该轮廓的边长不小于原轮廓周长的 1%:
pyhton
epsilon = 0.01 * cv2.arcLength(max_contour, True)
max_contour = cv2.approxPolyDP(max_contour, epsilon, True)
在下一节中,我们将学习如何找到轮廓区域的凸包。
5.2 查找轮廓区域的凸包
一旦我们在掩码中识别出最大的轮廓,就可以直接计算该轮廓区域的凸包。凸包基本上就是轮廓区域的包络线。如果将属于轮廓区域的所有像素想象成钉在板子上的一排钉子,那么一个紧绷的橡皮筋会将所有钉子环绕起来,形成凸包的形状。我们可以直接从最大轮廓 (max_contour) 获取凸包:
python
hull = cv2.convexHull(max_contour, returnPoints=False)
由于我们现在想要查看这个凸包中的凸性缺陷,因此我们将 returnPoints 可选标志设置为 False。
在分割出的手部区域周围用黄色绘制的凸包如下所示:

如前所述,我们将基于凸性缺陷来判断手势。接下来,我们继续学习如何找到凸包的凸性缺陷,这将使我们向手势识别更近一步。
5.3 找到凸包的凸性缺陷
从上图可以明显看出,并非凸包上的所有点都属于分割出的手部区域。事实上,所有手指和手腕都会导致严重的凸性缺陷------即轮廓上那些远离凸包的点。
我们可以通过同时使用最大轮廓 (max_contour) 和对应的凸包 (hull) 来找到这些缺陷:
python
defects = cv2.convexityDefects(max_contour, hull)
函数 defects 的输出是一个包含所有缺陷的 NumPy 数组。每个缺陷是一个包含四个整数的数组,分别为:start_index (缺陷在轮廓上起始点的索引)、end_index (缺陷在轮廓上结束点的索引)、farthest_pt_index (缺陷内离凸包最远点的索引)以及 fixpt_depth (最远点与凸包之间的距离)。稍后当我们尝试估计伸展手指的数量时,将会用到这些信息。
不过就目前而言,我们的工作已经完成。提取到的轮廓 (max_contour) 和凸性缺陷 (defects) 可以返回给 recognize 函数,在那里它们将作为输入传递给 detect_num_fingers:
python
return max_contour, defects
现在我们已经找到了凸性缺陷,接下来继续学习如何使用凸性缺陷进行手势识别,这将使我们完成整个应用程序。
6. 执行手势识别
接下来,依据伸展手指的数量对手势进行分类。例如,如果检测到五根伸展的手指,我们假设手是张开的;而没有伸展的手指则表示拳头。我们要做的就是计数从零到五,并让应用程序识别对应的手指数量。
这实际上比初看起来要棘手。例如,在欧洲,人们可能通过伸出拇指、食指和中指来表示数字三,而在中国,我们通常使用中指、无名指和小指表示数字三。因此,我们需要找到一种方法来概括这两种情况,在本节中,我们采取计数伸展手指的数量,这种方法与凸性缺陷有关。如前所述,伸展的手指会导致凸包产生缺陷。然而,反过来并不成立;也就是说,并非所有的凸性缺陷都是由手指造成的!手腕以及手或手臂的整体朝向也可能产生额外的缺陷。我们该如何区分这些不同原因造成的缺陷呢?
下一小节中,我们来区分凸性缺陷的不同成因。
6.1 区分凸性缺陷的不同成因
区分凸性缺陷的方法在于观察缺陷内离凸包最远的点 (farthest_pt_index) 与缺陷的起点 (start_index) 和终点 (end_index) 之间的夹角,如下图所示:

在上图中,橙色标记用于视觉辅助,帮助将手置于屏幕中央,凸包用绿色勾勒。每个红点对应检测到的每个凸性缺陷中离凸包最远的点 (farthest_pt_index)。如果将两个伸展手指之间的典型夹角(例如 θ j θ_j θj)与由手部一般几何形状引起的夹角(例如 θ i θ_i θi)进行比较,我们会注意到前者远小于后者。
这显然是因为人类只能将手指张开一定角度,从而在最远缺陷点与相邻指尖之间形成一个狭窄的夹角。因此,我们可以遍历所有凸性缺陷,并计算上述点之间的夹角。为此,我们需要一个工具函数,用于计算两个任意向量(如 v 1 v_1 v1 和 v 2 v_2 v2 )之间的夹角(以弧度为单位):
python
def angle_rad(v1, v2):
return np.arctan2(np.linalg.norm(np.cross(v1, v2)), np.dot(v1, v2))
该方法使用叉积来计算角度,而不是采用标准方式。计算两个向量 v 1 v_1 v1 和 v 2 v_2 v2 之间夹角的标准方法是计算它们的点积,然后除以 v 1 v_1 v1 的模和 v 2 v_2 v2 的模。然而,这种方法存在两个缺陷:
- 如果 v 1 v_1 v1 的模或 v 2 v_2 v2 的模为零,必须手动避免除零错误
- 对于小角度,该方法返回的结果相对不准确
同样,我们提供了一个简单的函数,用于将角度从度数转换为弧度,如下所示:
python
def deg2rad(angle_deg):
return angle_deg/180.0*np.pi
下一节中,我们将了解如何根据伸展手指的数量对手势进行分类。
6.2 根据伸展手指数对手势进行分类
剩下要做的实际上是基于伸展手指的实例数量来对手势进行分类。分类通过以下函数完成:
python
def detect_num_fingers(contour: np.ndarray, defects: np.ndarray,
img_draw: np.ndarray, thresh_deg: float = 80.0) -> Tuple[int, np.ndarray]:
该函数接受检测到的轮廓 (contour)、凸性缺陷 (defects)、用于绘制的画布 (img_draw) 以及一个截止角度 (thresh_deg),该角度用作判断凸性缺陷是否由伸展手指引起的阈值。
除了拇指和食指之间的夹角外,很难得到接近 90 度的角度,因此任何接近该数值的角度应该都可以。我们不希望截止角度设置得太高,因为这可能导致分类错误。完整的函数将返回手指数量以及用于展示的帧,并包含以下步骤:
(1) 首先,我们关注特殊情况。如果没有找到任何凸性缺陷,则意味着可能在凸包计算中出错了,或者画面中根本没有伸展的手指,因此我们返回 0 作为检测到的手指数量:
python
if defects is None:
return [0, img_draw]
(2) 但是,我们还可以进一步思考。由于手臂通常比手或拳头更细,我们可以假设手部几何形状至少会产生两个凸性缺陷(通常属于手腕部位)。因此,如果没有额外的缺陷,就意味着没有伸展的手指:
python
if len(defects) <= 2:
return [0, img_draw]
(3) 现在我们已经考虑了所有特殊情况,现在我们可以开始统计真正的手指数量了。如果有足够多的缺陷,每两根手指之间都会存在一个缺陷。因此,为了得到正确的数量 (num_fingers),我们应该从 1 开始计数:
python
num_fingers = 1
(4) 然后,我们开始遍历所有凸性缺陷。对于每个缺陷,我们提取三个点,并绘制其凸包以便可视化:
python
for defect in defects[:, 0, :]:
start, end, far = [contour[i][0] for i in defect[:3]]
# draw the hull
cv2.line(img_draw, tuple(start), tuple(end), (0, 255, 0), 2)
(5) 接着,我们计算从 far 点到 start 点以及从 far 点到 end 点这两条边之间的夹角。如果该夹角小于 thresh_deg 度,意味着我们处理的这个缺陷很可能是由两根伸展的手指造成的。在这种情况下,我们增加检测到的手指数量 (num_fingers),并用绿色绘制该点。否则,我们用红色绘制该点:
python
if angle_rad(start - far, end - far) < deg2rad(thresh_deg):
num_fingers += 1
cv2.circle(img_draw, tuple(far), 5, (0, 255, 0), -1)
else:
cv2.circle(img_draw, tuple(far), 5, (0, 0, 255), -1)
(6) 遍历完所有凸性缺陷后,返回检测到的手指数量和组合好的输出图像:
python
return min(5, num_fingers), img_draw
取最小值可以确保我们不会超过每只手的常见手指数量。

我们的应用能够在多种手部姿态下正确检测出伸展手指的数量。伸展手指之间的缺陷点很容易被算法分类,而其他缺陷则被成功忽略。
小结
本节介绍了一种通过计数伸展的手指数来识别各种手势的相对简单但鲁棒的方法。该算法首先展示了如何利用从 Microsoft Kinect 3D 传感器获取的深度信息来分割图像中与任务相关的区域,以及如何使用形态学操作来清理分割结果。通过分析分割后手部区域的形状,该算法提出了一种基于图像中发现的凸性缺陷类型来对手势进行分类的方法。手势识别是计算机科学中一个流行但具有挑战性的领域,在众多领域都有应用,例如人机交互、视频监控,甚至电子游戏行业。现在,我们可以利用对分割和结构分析的高级理解,构建自己最先进的手势识别系统。另一种可用于手势识别的方法是训练一个基于手势的深度图像分类网络。我们将在后续学习中讨论用于图像分类的深度神经网络。
系列链接
OpenCV-Python实战(1)------OpenCV简介与图像处理基础
OpenCV-Python实战(2)------图像与视频文件的处理
OpenCV-Python实战(3)------OpenCV中绘制图形与文本
OpenCV-Python实战(4)------OpenCV常见图像处理技术
OpenCV-Python实战(5)------OpenCV图像运算
OpenCV-Python实战(6)------OpenCV中的色彩空间和色彩映射
OpenCV-Python实战(8)------直方图均衡化
OpenCV-Python实战(9)------OpenCV用于图像分割的阈值技术
OpenCV-Python实战(10)------OpenCV轮廓检测
OpenCV-Python实战(11)------OpenCV轮廓检测相关应用
OpenCV-Python实战(12)------一文详解AR增强现实
OpenCV-Python实战(13)------OpenCV与机器学习的碰撞
OpenCV-Python实战(14)------人脸检测详解
OpenCV-Python实战(15)------面部特征点检测详解
OpenCV-Python实战(16)------人脸追踪详解
OpenCV-Python实战(17)------人脸识别详解
OpenCV-Python实战(18)------深度学习简介与入门示例
OpenCV-Python实战(19)------OpenCV与深度学习的碰撞
OpenCV-Python实战(20)------OpenCV计算机视觉项目在Web端的部署
OpenCV-Python实战(21)------OpenCV人脸检测项目在Web端的部署
OpenCV-Python实战(22)------使用Keras和Flask在Web端部署图像识别应用