手势识别






4.95/5 (107投票s)
使用 AForge.NET 框架(C#)对静态图像和视频流中的手势识别提出的一些想法。
引言
自我写第一篇关于运动检测的文章以来,我收到了来自世界各地许多人的电子邮件,他们发现这篇文章很有用,并在许多不同的领域找到了该代码的许多应用。这些领域包括简单的视频监控主题到令人印象深刻的应用,如激光手势识别、用望远镜检测彗星、检测蜂鸟并拍摄照片、控制水炮等等。
在本文中,我想讨论另一个应用,该应用以运动检测作为第一步,然后对检测到的对象进行一些有趣的例程——手势识别。假设我们有一台摄像机监视一个区域。当有人进入该区域并在摄像机前做出某些手势时,应用程序应检测手势的类型,并引发一个事件,例如。当检测到手势时,应用程序可以根据手势的类型执行不同的操作。例如,手势识别应用程序可以控制某种设备,或者另一个应用程序根据识别的手势向其发送不同的命令。我们谈论的是哪种手势?本文讨论的特定应用程序可以识别多达 15 种手势,这些手势是两只手的四种不同位置的组合——手未抬起、对角线下垂、对角线上举或直举。
本文中描述的所有算法都基于 AForge.NET 框架,该框架提供了应用程序使用的各种图像处理例程。该应用程序还使用了一些受该框架和另一篇关于 运动检测 的文章启发的运动检测例程。
在我们深入讨论应用程序的功能及其实现方式之前,让我们先快速看一下演示...
注意:文章中提供了一些代码片段,请勿尝试编译它们——它们仅用于阐明想法。但是,代码片段代表了真实可用的代码,它们是从文章源代码中包含的文件中复制粘贴的。因此,如果您想查看完整的代码并进行编译,请查看文章附件。
运动检测和对象提取
在开始手势识别之前,首先,我们需要识别演示手势的人体,并找到进行实际手势识别的合适时机。对于这两项任务,我们将重用运动检测文章中描述的一些运动检测思想。
对于对象提取任务,我们将使用一种基于背景建模的方法。假设视频流的第一帧不包含任何移动对象,只包含背景场景。
当然,这种假设在所有情况下都可能不成立。但是,首先,让我们假设它在大多数情况下都有效,因此它是相当适用的,其次,我们的算法将是自适应的,因此它可以处理第一帧不只是背景的情况。但是,让我们按顺序来……所以,我们的第一帧可以被当作背景帧的近似。
// check background frame
if ( backgroundFrame == null )
{
// save image dimension
width = image.Width;
height = image.Height;
frameSize = width * height;
// create initial backgroung image
backgroundFrame = grayscaleFilter.Apply( image );
return;
}
现在,假设过了一会儿,我们收到一帧,其中包含某个对象,而我们的任务是提取它。
当我们有了两张图像,即背景图像和带有对象的图像时,我们可以使用 Difference
过滤器来获取差分图像。
// apply the grayscale filter
Bitmap currentFrame = grayscaleFilter.Apply( image );
// set backgroud frame as an overlay for difference filter
differenceFilter.OverlayImage = backgroundFrame;
// apply difference filter
Bitmap motionObjectsImage = differenceFilter.Apply( currentFrame );
在差分图像上,可以看到两张图像之间的绝对差值——白色区域表示差异较大的区域,黑色区域表示无差异的区域。接下来的两个步骤是
- 使用
Threshold
过滤器对差分图像进行阈值处理,以便每个像素都可以被分类为显著变化(最有可能由移动对象引起)或非显著变化。 - 使用
Opening
过滤器从阈值差分图像中去除噪声。在此步骤之后,独立的像素(可能由相机噪声和其他情况引起)将被移除,因此我们得到一张图像,该图像仅描绘了或多或少显著的变化区域(运动区域)。
看起来,我们得到了一张相当不错的手势图像,我们已准备好进入下一步——识别……还没。我们获得的示例对象的图像代表了相当可识别的人体,它演示了手势。但是,在我们视频流中获得这样的图像之前,我们会收到许多其他帧,其中可能包含许多其他与人体相去甚远的对象。这些对象可能是场景中移动的其他任何东西,甚至可能是我们之前过滤掉的噪声。为了摆脱虚假对象,让我们检查图像中的所有对象并检查它们的大小。为此,我们将使用 BlobCounter
类。
// process blobs
blobCounter.ProcessImage( motionObjectsData );
Blob[] blobs = blobCounter.GetObjectInformation( );
int maxSize = 0;
Blob maxObject = new Blob( 0, new Rectangle( 0, 0, 0, 0 ) );
// find the biggest blob
if ( blobs != null )
{
foreach ( Blob blob in blobs )
{
int blobSize = blob.Rectangle.Width * blob.Rectangle.Height;
if ( blobSize > maxSize )
{
maxSize = blobSize;
maxObject = blob;
}
}
}
我们将如何利用最大对象大小的信息?首先,我们将实现之前提到的自适应背景。假设,不时地,我们可能会在场景中出现一些小的变化,比如光照条件的微小变化、小物体的移动,甚至是一个已经出现并停留在场景中的小物体。为了考虑这些变化,我们将有一个自适应背景——我们将朝着我们变化的方向改变我们的背景帧(它从第一视频帧初始化),使用 MoveTowards
过滤器。MoveTowards
过滤器会稍微改变图像,使其朝着与提供的第二张图像更小的差异方向移动。例如,如果我们有一个只包含场景的背景图像,以及一个包含相同场景加上一个对象的图像,那么连续将 MoveTowards
过滤器应用于背景图像一段时间后,它将变得与对象图像相同——我们越多地将 MoveTowards
过滤器应用于背景图像,该对象在其上的存在就越明显(背景图像变得“更接近”对象图像——差异变得更小)。
因此,我们检查当前帧中最大对象的尺寸,如果它不是那么大,我们就认为该对象不显著,我们只需更新我们的背景帧以适应变化。
// if we have only small objects then let's adopt to changes in the scene
if ( ( maxObject.Rectangle.Width < 20 ) || ( maxObject.Rectangle.Height < 20 ) )
{
// move background towards current frame
moveTowardsFilter.OverlayImage = currentFrame;
moveTowardsFilter.ApplyInPlace( backgroundFrame );
}
最大对象尺寸的第二个用途是找到一个相当显著的、可能是一个人体对象。为了节省 CPU 时间,我们的手势识别算法不会分析当前帧中最大的任何对象,而只分析满足某些要求的对象。
if ( ( maxObject.Rectangle.Width >= minBodyWidth ) &&
( maxObject.Rectangle.Height >= minBodyHeight ) &&
( previousFrame != null ) )
{
// do further processing of the frame
}
好的,现在我们有一个包含移动对象的图像,并且该对象的大小相当合理,所以它可能是一个人体。我们是否可以准备好将图像传递给手势识别模块进行进一步处理?同样,还没有……
是的,我们已经检测到一个相当大的对象,它可能是一个演示某些手势的人体。但是,如果对象仍在移动怎么办?如果对象还没有停止,还没有准备好向我们展示它想做的真实手势怎么办?我们真的想在对象仍在移动时,将所有这些帧都传递给手势识别模块,从而增加我们的 CPU 计算量吗?更重要的是,由于对象仍在移动,我们甚至可能检测到一个不是对象想要演示的手势。所以,我们现在不要急于进行手势识别。
在我们检测到一个对象可以进行进一步处理后,我们想给它一个机会停下来,向我们展示一些东西——一个手势。如果对象一直移动,它不想向我们展示任何东西,所以我们可以跳过它的处理。为了捕捉到对象停止的时刻,我们将使用另一个基于帧之间差异的运动检测器。运动检测器会检查两个连续视频帧(当前帧和前一帧)之间的变化量,并根据此决定是否检测到运动。但是在这种特定情况下,我们不关心运动检测,而是关心无运动检测。
// check motion level between frames
differenceFilter.OverlayImage = previousFrame;
// apply difference filter
Bitmap betweenFramesMotion = differenceFilter.Apply( currentFrame );
// lock image with between frames motion for faster further processing
BitmapData betweenFramesMotionData = betweenFramesMotion.LockBits(
new Rectangle( 0, 0, width, height ),
ImageLockMode.ReadWrite, PixelFormat.Format8bppIndexed );
// apply threshold filter
thresholdFilter.ApplyInPlace( betweenFramesMotionData );
// apply opening filter to remove noise
openingFilter.ApplyInPlace( betweenFramesMotionData );
// calculate amount of changed pixels
VerticalIntensityStatistics vis =
new VerticalIntensityStatistics( betweenFramesMotionData );
int[] histogram = vis.Gray.Values;
int changedPixels = 0;
for ( int i = 0, n = histogram.Length; i < n; i++ )
{
changedPixels += histogram[i] / 255;
}
// free temporary image
betweenFramesMotion.UnlockBits( betweenFramesMotionData );
betweenFramesMotion.Dispose( );
// check motion level
if ( (double) changedPixels / frameSize <= motionLimit )
{
framesWithoutMotion++;
}
else
{
// reset counters
framesWithoutMotion = 0;
framesWithoutGestureChange = 0;
notDetected = true;
}
如上面的代码所示,通过分析 changedPixel
变量来检查帧之间的差异,该变量用于计算变化量(以百分比表示),然后将该值与配置的运动限制进行比较,以检查是否有运动。但是,如上代码所示,我们不会在检测到无运动后立即调用手势识别例程。相反,我们维护一个计数器,该计数器计算连续无运动帧的数量。只有当连续无运动帧的数量达到某个特定值时,我们才最终将对象传递给手势识别模块。
// check if we don't have motion for a while
if ( framesWithoutMotion >= minFramesWithoutMotion )
{
if ( notDetected )
{
// extract the biggest blob
blobCounter.ExtractBlobsImage( motionObjectsData, maxObject );
// recognize gesture from the image
Gesture gesture = gestureRecognizer.Recognize( maxObject.Image, true );
maxObject.Image.Dispose( );
...
}
}
在我们移至手势识别讨论之前,还有一句话。为了确保我们不会出现错误的手势识别,我们会进行额外的检查——我们检查相同的姿势可以在连续几帧中识别出来。此额外检查可确保我们检测到的对象在一段时间内确实向我们展示了一个姿势,并且手势识别模块提供了准确的结果。
// check if gestures has changed since the previous frame
if (
( gesture.LeftHand == previousGesture.LeftHand ) &&
( gesture.RightHand == previousGesture.RightHand )
)
{
framesWithoutGestureChange++;
}
else
{
framesWithoutGestureChange = 0;
}
// check if gesture was not changing for a while
if ( framesWithoutGestureChange >= minFramesWithoutGestureChange )
{
if ( GestureDetected != null )
{
GestureDetected( this, gesture );
}
notDetected = false;
}
previousGesture = gesture;
手势识别
现在,我们已经检测到一个要处理的对象,我们可以对其进行分析,尝试识别手势。下面描述的手势识别算法假定目标对象占据整个图像,而不是其中的一部分。
我们手势识别算法的核心思想非常简单,100% 基于直方图和统计数据,而不是基于模式识别、神经网络等。这使得该算法在实现和理解方面相当容易。
该算法的核心思想基于分析两种对象直方图——水平直方图和垂直直方图,可以使用 HorizontalIntensityStatistics
和 VerticalIntensityStatistics
类来计算。
我们将从利用水平直方图开始手势识别,因为作为第一步,它看起来更有用。我们要做的第一件事是找到图像中被手占据的区域,以及被躯干占据的区域。
让我们仔细看看水平直方图。从直方图可以看出,手部区域在直方图上的值相对较小,而躯干区域则由高值峰表示。考虑到人体的某些简单相对比例,我们可以说人手的厚度永远不会超过人体身高的 30%(30% 是一个相当大的值,但让我们为了安全起见,将其作为示例)。因此,通过对水平直方图应用简单的阈值处理,我们可以轻松地对区域和躯干区域进行分类。
// get statistics about horizontal pixels distribution
HorizontalIntensityStatistics his =
new HorizontalIntensityStatistics( bodyImageData );
int[] hisValues = (int[]) his.Gray.Values.Clone( );
// build map of hands (0) and torso (1)
double torsoLimit = torsoCoefficient * bodyHeight;
// torsoCoefficient = 0.3
for ( int i = 0; i < bodyWidth; i++ )
{
hisValues[i] = ( (double) hisValues[i] / 255 > torsoLimit ) ? 1 : 0;
}
从阈值处理后的水平直方图,我们可以轻松计算手的长度和身体躯干的宽度——右手长度是直方图右侧的空白区域宽度,左手长度是直方图左侧的空白区域宽度,而躯干宽度是空白区域之间的区域宽度。
// get hands' length
int leftHand = 0;
while ( ( hisValues[leftHand] == 0 ) && ( leftHand < bodyWidth ) )
leftHand++;
int rightHand = bodyWidth - 1;
while ( ( hisValues[rightHand] == 0 ) && ( rightHand > 0 ) )
rightHand--;
rightHand = bodyWidth - ( rightHand + 1 );
// get torso's width
int torsoWidth = bodyWidth - leftHand - rightHand;
现在,当我们有了手的长度和躯干的宽度后,我们就可以确定手是否被抬起。对于每只手,算法会尝试检测手是否未抬起、抬起、对角线下垂、直举或对角线上举。下面图片中按列出顺序演示了所有四种可能的位置。
为了检查手是否被抬起,我们将再次使用一些关于身体比例的统计假设。例如,如果手未抬起,其在水平直方图上的宽度不会超过躯干宽度的 30%。否则,它以某种方式被抬起。
// process left hand
if ( ( (double) leftHand / torsoWidth ) >= handsMinProportion )
{
// hand is raised
}
else
{
// hand is not raised
}
到目前为止,我们已经能够识别出手的位置——当手未抬起时。现在,我们需要完成算法,识别出手被抬起时的确切位置。为了做到这一点,我们将使用之前提到的 VerticalIntensityStatistics
类。但现在,该类将应用于整个对象的图像,而仅应用于手部的图像。
// extract left hand's image
Crop cropFilter = new Crop( new Rectangle( 0, 0, leftHand, bodyHeight ) );
Bitmap handImage = cropFilter.Apply( bodyImageData );
// get vertical intensity statistics of the hand
VerticalIntensityStatistics stat = new VerticalIntensityStatistics( handImage );
上面的图像包含好的样本,使用上述直方图,很容易识别手势。但是,在某些情况下,我们可能没有像上面那样清晰的直方图,只有嘈杂的直方图,这可能是由光照条件和阴影引起的。因此,在就抬起的手做出任何最终决定之前,让我们执行两个小的预处理步骤。这两个附加步骤非常简单,因此此处未提供其代码,但可以从文章源代码中包含的文件中检索。
- 首先,我们需要从直方图中移除低值,例如低于直方图最大值 10% 的值。下图展示了一个包含一些阴影引起的人手图像。此类伪影可以通过过滤直方图上的低值轻松去除,如下图所示(直方图已过滤)。
- 我们需要处理的另一种问题是“双重”手,实际上是阴影。这可以通过遍历直方图并移除所有不是最高峰的峰值来轻松解决。
此时,我们应该有一个清晰的垂直直方图,就像我们之前看到的那样,所以现在我们距离识别手势只有几步之遥了。
让我们先从识别直举的手开始。如果我们看一下直举的手的图像,那么我们可以对身体比例做出另一个假设——手的长度远大于其宽度。在直举的情况下,其直方图应该有一个很高但很窄的峰。所以,让我们使用这些属性来检查手是否直举。
if ( ( (double) handImage.Width / ( histogram.Max -
histogram.Min + 1 ) ) > minStraightHandProportion )
{
handPosition = HandPosition.RaisedStraigh;
}
else
{
// processing of diagonaly raised hand
}
(注意:Histogram
类的 Min
和 Max
属性返回具有非零概率的最小值和最大值。在上面的示例代码中,这些值用于计算直方图区域被手占据的宽度。请参阅 AForge.Math
命名空间的文档。)
现在,我们需要进行最后的检查,以确定手是斜向上还是斜向下举。正如我们可以从斜向上/斜向下手的直方图中看到的,斜向上手的峰值已移动到直方图的开头(在垂直直方图的情况下是顶部),而斜向下手的峰值则更移向中心。再次,我们可以使用此属性来检查抬起手的确切类型。
if ( ( (double) histogram.Min / ( histogram.Max - histogram.Min + 1 ) ) <
maxRaisedUpHandProportion )
{
handPosition = HandPosition.RaisedDiagonallyUp;
}
else
{
handPosition = HandPosition.RaisedDiagonallyDown;
}
我们完成了!现在,我们的算法能够识别每只手的四种位置。将相同的方法应用于第二只手,我们的算法将为这四种手势提供以下结果。
- 左手未举;右手未举;
- 左手斜向下;右手未举;
- 左手直举;右手未举;
- 左手斜向上;右手未举。
如果两个未举的手不被视为一个手势,那么该算法可以识别 15 种手势,这些手势是不同手部位置的组合。
结论
正如我们在上述文章中看到的,我们已经获得了算法,这些算法首先允许我们从视频流中提取移动对象,其次,成功识别对象演示的手势。识别算法非常简单,易于实现和理解。此外,由于它仅基于直方图信息,因此性能相当高效,并且不需要大量的计算资源,这在我们需要处理大量帧/秒的情况下非常重要。
为了使算法易于理解,我使用了 AForge.Imaging 库中的通用图像处理例程,该库是 AForge.NET framework 的一部分。这意味着,从通用例程到专用例程(可以组合几个步骤的例程),可以轻松地进一步提高这些算法的性能。
这些算法的改进方向
- 在手部阴影投射到墙壁的情况下,实现更鲁棒的识别;
- 处理动态场景,其中主对象后面可能会发生各种运动。