1. 基于景深数据的用户交互

  到目前为止我们只用了骨骼数据中关节点的X,Y值。然而Kinect产生的关节点数据除了X,Y值外还有一个深度值。基于Kinect的应用程序应该利用好这个深度值。下面的部分将会介绍如何在Kinect应用程序中使用深度值。

    除了使用WPF的3D特性外,在布局系统中可以根据深度值来设定可视化元素的尺寸大小来达到某种程序的立体效果。下面的例子使用Canvas.ZIndex属性来设置元素的层次,手动设置控件的大小并使用ScaleTransform来根据深度值的改变来进行缩放。用户界面包括一些圆形,每一个圆代表一定的深度。应用程序跟踪用户的手部关节点,以手形图标显示,图标会根据用户手部关节点的深度值来进行缩放,用户离Kinect越近,手形图表越大,反之越小。

    创建一个新的WPF项目,主界面的XAML如下。主要的布局容器为Cnavas容器。它包含5个Ellipses及对应的TextBlock控件,TextBlock用来对圆形进行说明。这几个圆形随机分布在屏幕上,但是圆形的Canvas.ZIndex是确定的。Canvas容器也包含了两个图像控件,用来代表两只手。每一个手部图标都定义了一个ScaleTransform对象。手形图标是和右手方向一致的,将ScaleTransform的ScaleX设置为-1可以将其反转,看起来像左手。

<Window x:Class="KinectDepthBasedInteraction.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Depth UI Target" Height="1080" Width="1920" WindowState="Maximized" Background="White">
    <Window.Resources>
        <Style x:Key="TargetLabel" TargetType="TextBlock" >
            <Setter Property="FontSize" Value="40" />
            <Setter Property="Foreground" Value="White"/>
            <Setter Property="FontWeight" Value="Bold" />
            <Setter Property="IsHitTestVisible" Value="False" />
        </Style>
    </Window.Resources>
    <Viewbox>
        
        <Grid x:Name="LayoutRoot" Width="1920" Height="1280">
            <Image x:Name="DepthImage"/>
            <StackPanel HorizontalAlignment="Left" VerticalAlignment="Top">
                <TextBlock x:Name="DebugLeftHand" Style="{StaticResource TargetLabel}" Foreground="Black" />
                <TextBlock x:Name="DebugRightHand" Style="{StaticResource TargetLabel}" Foreground="Black" />
            </StackPanel>
            <Canvas>
                <Ellipse x:Name="Target3" Fill="Orange" Height="200" Width="200" Canvas.Left="776" Canvas.Top="162" Canvas.ZIndex="1040" />
                <TextBlock Text="3" Canvas.Left="860" Canvas.Top="206" Panel.ZIndex="1040" Style="{StaticResource TargetLabel}" />

                <Ellipse x:Name="Target4" Fill="Purple" Height="150" Width="150" Canvas.Left="732" Canvas.Top="320" Canvas.ZIndex="940" />
                <TextBlock Text="4" Canvas.Left="840" Canvas.Top="372" Panel.ZIndex="940" Style="{StaticResource TargetLabel}" />

                <Ellipse x:Name="Target5" Fill="Green" Height="120" Width="120" Canvas.Left="880" Canvas.Top="592" Canvas.ZIndex="840" />
                <TextBlock Text="5" Canvas.Left="908" Canvas.Top="590" Panel.ZIndex="840" Style="{StaticResource TargetLabel}" />

                <Ellipse x:Name="Target6" Fill="Blue" Height="100" Width="100" Canvas.Left="352" Canvas.Top="544" Canvas.ZIndex="740" />
                <TextBlock Text="6" Canvas.Left="368" Canvas.Top="582" Panel.ZIndex="740" Style="{StaticResource TargetLabel}" />

                <Ellipse x:Name="Target7" Fill="Red" Height="85" Width="85" Canvas.Left="378" Canvas.Top="192" Canvas.ZIndex="640" />
                <TextBlock Text="7" Canvas.Left="422" Canvas.Top="226" Panel.ZIndex="640" Style="{StaticResource TargetLabel}" />

                <Image x:Name="LeftHandElement" Source="Images/hand.png" Width="80" Height="80" RenderTransformOrigin="0.5,0.5">
                    <Image.RenderTransform>
                        <ScaleTransform x:Name="LeftHandScaleTransform" ScaleX="1" CenterY="-1" />
                    </Image.RenderTransform>
                </Image>
                
                <Image x:Name="RightHandElement" Source="Images/hand.png" Width="80" Height="80" RenderTransformOrigin="0.5,0.5">
                    <Image.RenderTransform>
                        <ScaleTransform x:Name="RightHandScaleTransform" CenterY="1" ScaleX="1" />
                    </Image.RenderTransform>
                </Image>
            </Canvas>
        </Grid>
    </Viewbox>
</Window

    不同颜色的圆形代表不同的深度,例如名为Target3的元素代表距离为3英尺。Target3的长宽比Target7要大,这简单的通过缩放可以实现。在我们的实例程序中,我们将其大小进行硬编码,实际的程序中,应该根据特定要求可以进行缩放。Canvas容器会根据子元素的Canvas.ZIndex的值对元素在垂直于计算机屏幕的方向上进行排列,例如最上面的元素,其Canvas.ZIndex最大。如果两个元素有相同的ZIndex值,那么会根据其在XAML中声明的顺序进行显示,在XAML中,后面声明的元素在之前声明的元素的前面。对于Canvas的所有子元素,ZIndex值越大,离屏幕越近,越小离屏幕越远。将深度值取反刚好能达到想要的效果。这意味这我们不能直接使用深度值来给ZIndex来赋值,而要对它进行一点转换。Kinect能够产生的最大深度值为13.4英尺,相应的,我们将Canvas.Zindex的取值范围设置为0-1340,将深度值乘以100能获得更好的精度。因此Target5的Canvas.ZIndex设置为840(13.5-5=8.4*100=840)。

     XAML文件中,包含两个名为DebugLeftHand和DebugRightHand的TextBlocks控件。这两个控件用来显示两只手的关节点数据的深度值。因为调试Kinect程序比较麻烦,这个值是用来调试程序的。

    下面的代码用来处理骨骼数据。SkeletonFrameReady事件处理代码除了TrackHand方法之外和之前的例子没有区别。TrackHand方法对手形图标使用深度值进行缩放。方法将手所在点的坐标转换到UI界面上后,使用Canvas.SetLeft和Canvas.SetTop方法进行赋值。Cnavas.ZIndex的使用前面讨论的计算方法。

    设置好Canvas.ZIndex对于可视化元素的布局已经足够,但是不能够根据深度视觉效果对物体进行缩放。对于Kinect应用程序,Z值其他输入设备不能提供的,如果没有根据节点深度数据进行的缩放,那么这以独特的Z值就浪费了。缩放比例可能需要测试以后才能确定下来。如果想要达到最好的用户体验效果。手形图标的大小应该和用户手的实际大小一致,目前从Kinect数据不能直接获取到用户手的大小信息。有一种方法时让用户戴上类似感应手套这一类产品以提供另外一些额外的信息,这样可以产生更加自然的交互体验。

private void KinectDevice_SkeletonFrameReady(object sender, SkeletonFrameReadyEventArgs e)
{
    using (SkeletonFrame frame = e.OpenSkeletonFrame())
    {
        if (frame != null)
        {
            frame.CopySkeletonDataTo(this.frameSkeletons);
            Skeleton skeleton = GetPrimarySkeleton(this.frameSkeletons);
            if (skeleton != null)
            {
                TrackHand(skeleton.Joints[JointType.HandLeft], LeftHandElement, LeftHandScaleTransform, LayoutRoot, true);
                TrackHand(skeleton.Joints[JointType.HandRight], RightHandElement, RightHandScaleTransform, LayoutRoot, false);

            }
        }
    }
}

private void TrackHand(Joint hand, FrameworkElement cursorElement, ScaleTransform cursorScale, FrameworkElement container, bool isLeft)
{
    if (hand.TrackingState != JointTrackingState.NotTracked)
    {
        double z = hand.Position.Z*FeetPerMeters;
        cursorElement.Visibility = System.Windows.Visibility.Visible;
        Point cursorCenter = new Point(cursorElement.ActualWidth / 2.0, cursorElement.ActualHeight / 2.0);

        Point jointPoint = GetJointPoint(this.KinectDevice, hand, container.RenderSize, cursorCenter);
        Canvas.SetLeft(cursorElement, jointPoint.X);
        Canvas.SetTop(cursorElement, jointPoint.Y);
        Canvas.SetZIndex(cursorElement, (int)(1200- (z * 100)));

        cursorScale.ScaleX = 12 / z * (isLeft ? -1 : 1);
        cursorScale.ScaleY = 12 / z;
        if (hand.JointType == JointType.HandLeft)
        {
            DebugLeftHand.Text = String.Format("Left Hand:{0:0.00} feet", z);
        }
        else
        {
            DebugRightHand.Text = String.Format("Right Hand:{0:0.00} feet", z);
        }
    }
    else
    {
        DebugLeftHand.Text = String.Empty;
        DebugRightHand.Text = String.Empty;
    }
}

    编译并运行程序,将手距Kinect不同距离,界面上的手形图标会随着距离的大小进行缩放;同时界面上用于调试的信息也在变化,还可以注意到,随着远近的不同,参考深度标注图案,手形图标在界面上的深度值也会相应的发生变化,有时候在图标在某些标签的前面,有时候在某些标签后面。

 

DepthInteraction1w

DepthInteraction2w

 

    以上例子展示了骨骼数据信息中Z值的用处,一般在开发基于Kinect应用程序时,除了用到关节点的X,Y点数据来进行定位之外,还可以用上面讲的方法来使用Z值数据。有时候Z值数据可以增加应用程序的应用体验。

 

2. 姿势

 

    姿势(Pose)是人和其他物体的重要区别。在日常生活中,人们通过姿势来表达感情。姿势是一段个动作的停顿,他能传达一些信息息 。例如在体育运动中, 裁判员会使用各种各样的姿势来向运动员传递信息。姿势和手势通常会混淆,但是他们是两个不同的概念。当一个人摆一个姿势时,他会保持身体的位置和样子一段时间。但是手势包含有动作,例如用户通过手势在触摸屏上,放大图片等操作。

    在Kinect开发的早期,更多的经历会放在手势的识别上而不是姿势的识别上。 这有点不对,但是可以理解。Kinect的卖点在于运动识别。Kinect这个名字本身就来源于单词kinetic,他是“运动的”的意思。Kinect 做为Xbox的一个外设,使得可以使用游戏者的肢体动作,或者说是手势,来控制游戏。手势识别给开发者带来了很多机遇以全新的用户界面设计。在后面的文章中,我们将会看到,手势并非都很简单,有些手势很复杂,使得应用程序很难识别出来。相对而言,姿势是用户有意做的动作,它通常有一定的形式。

    虽然姿势识别没有手势识别那样受开发者关注,但即使在现在,很多游戏中都大量使用姿势识别。通常,游戏者很容易模仿指定姿势并且比较容易编写算法来识别指定的姿势。例如,如果开发一个用户在天上飞的游戏。 一种控制游戏的方式是,游戏者像鸟一样挥动手臂。挥动的频率越快游戏角色飞的越快,这是一个手势。还有一种方法是,展开双臂,双臂张得越快开,飞的越快。双臂离身体越近,飞的越慢。在Simon Says游戏中游戏者必须伸开双臂将双手放到指定的位置才能开始游戏,也可以将这个改为,当用户伸开双臂时即可开始游戏。问题是,如何识别这一姿势呢?

 

2.1 姿势识别

     身体以及各个关节点的位置定义了一个姿势。更具体的来说,是某些关节点相对于其他关节点的位置定义了一个姿势。姿势的类型和复杂度决定了识别算法的复杂度。通过关节点位置的交叉或者关节点之间的角度都可以进行姿势识别。

     通过关节点交叉进行姿势识别就是对关节点进行命中测试。在前一篇文章中,我们可以确定某一个关节点的位置是否在UI界面上某一个可视化元素的有效范围内。我们可以对关节点做同样的测试。但是需要的工作量要少的多,因为所有的关节点都是在同一个坐标空间中,这使得计算相对容易。例如叉腰动作(hand-on-hip),可以从骨骼追踪的数据获取左右髋关节和左右手的位置。然后计算左手和左髋关节的位置。如果这个距离小于某一个阈值,就认为这两个点相交。这个阈值可以很小,对一个确定的相交点进行命中测试,就像我们对界面上可视化元素进行命中测试那样,可能会有比较不好的用户界面。即使通过一些平滑参数设置,从Kinect中获取的关节点数据要完全匹配也不太现实。另外,不可能期望用户做出一些连贯一致的动作,或者保持一个姿势一段时间。简而言之,用户运动的精度以及数据的精度使得这种简单计算不适用。因此,计算两个点的长度,并测试长度是否在一个阈值内是唯一的选择。

    当两个关节点比较接近时,会导致关节点位置精度进一步下降,这使得使用骨骼追踪引擎判断一个关节点的开始是否是另一个关节点的结束点变得困难。例如,如果将手放在脸的位置上,那么头的位置大致就在鼻子那个地方,手的关节点位置和头的关节点位置就不能匹配起来。这使得难以区分某些相似的姿势,比如,很难将手放在脸的前面,手放在头上,和手捂住耳朵这几个姿势区分开来。这些还不是所有应用设计和开发者可能遇到的问题。有时候会,要摆出一个确切的姿势也很困难,用户是否会按照程序显示的姿势来做也是一个问题。

    节点交叉并不需要使用X,Y的所有信息。一些姿势只需要使用一个坐标轴信息。例如:立正姿势,在这个姿势中,手臂和肩膀近乎在一个垂直坐标轴内而不用考虑用户的身体的大小和形状。在这个姿势中,逻辑上只需要测试手和肩部节点的X坐标的差值,如果在一个阈值内就可以判断这些关节点在一个平面内。但是这并不能保证用户是立正姿势。应用程序还需要判断手在Y坐标轴上应该低于肩部。这能提高识别精度,但仍然不够完美。没有一个简单的方法能够判定用户所处的站立姿势。如果用户只是稍微将膝盖弯曲有点,那么这种识别方法就不是很科学。

    并不是所有的姿势识别都适合使用节点交叉法,一些姿势使用其他方法识别精度会更高。例如,用户伸开双臂和肩膀在一条线上这个姿势,称之为T姿势。可以使用节点相交技术,判断手、肘、以及肩膀是否在Y轴上处于近乎相同的位置。另一种方法是计算某些关节点连线之间的角度。骨骼追踪引擎能够识别多达20个关节点数据。任何三个关节点就可以组成一个三角形。使用三角几何就可以计算出他们之间的角度。

    实际上我们只需要根据两个关节点即可绘制一个三角形,第三个点有时候可以这两个关节点来决定的。知道每个节点的坐标就可以计算每个边长的值。然后使用余弦定理就可以计算出角度了。公式如下图:

 

image

 

    为了演示使用关节点三角形方法来识别姿势,我们考虑在健美中常看到了展示肱二头肌姿势。用户肩部和肘在一条线上并且和地面平行,手腕与肘部与胳膊垂直。在这个姿势中,可以很容易看到有一个直角或者锐角三角形。我们可以使用上面所说的方法来计算三角形的每一个角度,如下图所示:

 

image

 

    上图中,组成三角形的三个关节点为。肩膀,轴和手腕。根据这三个关节点的坐标可以计算三个角度。

    有两种使用节点三角形的方法。最明显的如上面的例子那样,使用三个节点来构造一个三角形。另一个方法就是使用两个节点,第三个节点手动指定一个点。这种方法取决于姿势的限制和复杂度。在上面的例子中,我们使用三个及节点的方法,因为需要的角度可以由手腕-肘-肩部构成。不论其他部位如何变化,这三者所构成的三角形相形状相对不变。

    使用两个节点来识别这一动作只需要肘部和手腕关节点信息。将肘部作为整个坐标系统的中心或者零点。以肘部为基准点,随便找一个水平的X轴上的点。然后就可以由这三点组成一个三角形。在两点方法中,用户在直立和有点倾斜姿势下所计算得到的结果是不一样的。

 

2.2 响应识别到的姿势

    了解了姿势识别后,使得我们可以在Kinect开发中使用的姿势信息。应用程序如何处理这些信息以及如何和用户交互对于功能完整的应用程序来说同样重要。识别姿势的目的是触发一些操作。最简单的方法是当探测到某一姿势后立即响应一些类似鼠标点击之类的事件。

    Kinect应用程序比较酷的一点是可以使用人体作为输入设备,但这也带来了一新的问题。对于应用程序设计和开发者来说,用户通常不会如我们设想的那样按照设定好的,或者指定的姿势来进行运动。近十年来,应用设计者和开发者一直在关注如如何改进键盘及鼠标驱动的应用程序,使得这些应用程序能够正确,健壮的处理任何用户的键盘或者鼠标操作。可惜的是这些方面的经验并不适用于Kinect。当使用鼠标时,用户需要有意的去按下鼠标左键或者右键去进行操作。大多数鼠标点击事件时有意的,如果是无意中按下的,应用程序也不能判断用户是否无意按下。但是因为需要按下按钮,通常无意按中的情况极少。但在识别姿势时,这种情况就不同了,因为用户一直在摆pose。

    应用程序要使用姿势识别必须知道什么时候该忽略什么时候该响应特定的姿势。如前所述,最简单的方法是当识别到某一姿势时立即响应。如果这是应用程序的功能,需要选择一个用户不可能会在休息或者放松时会产生的姿势。选择一个姿势很容易,但是这个姿势不能是户自然而然或者大多数情况下都会产生的姿势。这意味着姿势必须是有意识的,就像是鼠标点击那样,用户需要进行某项操作才会去做某种特定的姿势。除了马上响应识别到的某个姿势外,另一种方法是触发一个计时器。只有用户保持这一姿势一段时间,应用程序才会触发相应的操作。这个和手势有点类似。在以后的文章中我会详细讨论。

    另一种方法是当用户摆出某一系列的姿势时才触发某一动作。这需要用户按照特定的序列摆出一些列的姿势,才会执行某一操作。使用系列姿势和一些不常用的姿势可以使得应用程序知道用户有意想进行某一项操作,而不是误操作。换句话说,这能够帮助用户减少误操作。

 

3. Simon Says 游戏中使用姿势识别

 

    使用上一篇文章介绍的游戏项目,我们重新实现一些功能,比如我们使用姿势而不是可视化元素的命中测试来进行指令执行判断。在这个版本中,Simon指令时让用户按照顺序做一系列的姿势,而不是触摸那四个矩形。使用关节点角度进行姿势识别可以给予应用程序更多的姿势选择。更多的和更疯狂的姿势可以使得游戏更加有趣和好玩。

    使用姿势替代可视化元素需要对代码做出较大改动,但幸好的是识别姿势的代码比命中测试和判断手是否在指定可视化元素有效范围内的代码要少。姿势识别主要是使用三角几何。改动代码的同时也改变了用提体验和游戏的玩法。所有界面上的矩形块都会移除,只保留TextBlocks和手形图标。我们还需要用一定的方式提示用户摆出某种姿势。最好的方式是显示要摆出姿势的图片。为了简便,我们这里使用一个TextBlock,显示姿势的名称,让用户来做指定的姿势。

    游戏的玩法也变了,去掉了所有用来进行命中测试的可视化元素后,将使用摆出某种姿势来开始游戏。Simon Says游戏的开始姿势和之前的一样。前面是游戏者伸开胳膊,将双手放到指定的区域内就开始游戏。现在是用户摆出一个T型的姿势。

    在之前的游戏中,Simon Says指令序列指针对用户触摸到正确的可视化元素时移到下一个地方。在现在的版本中,游戏只需要在指定的时间内摆出某种要求的姿势,如果在规定的时间不能摆出姿势的话,游戏就结束了。如果识别了指定的姿势,游戏继续下一个姿势,计时器归零。

    在写代码之前,必须把架子搭起来。为了让游戏好玩,需要尽可能多的选择可识别的姿势。另外,还要能比较容易的将新的姿势添加进来。为了创建一个姿势库,需要创建一个新的PoseAngle类和名为Pose的结构。如下面的代码所示。Pose存储了一个姿势的名称和一个PoseAngle数组。PoseAngle的有两个JointType类型的成员变量用来计算角度,Angle为期望角度,Threshold 阈值。 我们并不期望用户关节点之间的夹角和期望的角度完全吻合,这也是不可能的。就像命中测试那样,只要关节点夹角在一定的阈值范围内即可。

public class PoseAngle
{
    public PoseAngle(JointType centerJoint, JointType angleJoint, double angle, double threshold)
    {
        CenterJoint = centerJoint;
        AngleJoint  = angleJoint;
        Angle       = angle;
        Threshold   = threshold;
    }

    public JointType CenterJoint { get; private set;}
    public JointType AngleJoint { get; private set;}
    public double Angle { get; private set;}
    public double Threshold { get; private set;}
}

public struct Pose
{
    public string Title;
    public PoseAngle[] Angles;
}

     在MainWindows.xaml.cs中添加poseLibrary和startPose变量,以及一个PopulatePoseLibrary方法。代码如下。PopulatePoseLibrary方法定义了开始姿势(T姿势),以及游戏中需要的四个姿势。第一个姿势称之为“举起手来”姿势,就是双手举起来,第二个姿势和第一个姿势相反,将双手放下来,第三个和第四个分别为只举起左手或者右手姿势。

 

private Pose[] poseLibrary;
private Pose startPose; 

 

private void PopulatePoseLibrary()
{
    this.poseLibrary = new Pose[4];

    //游戏开始 Pose - 伸开双臂 Arms Extended
    this.startPose             = new Pose();
    this.startPose.Title       = "Start Pose";
    this.startPose.Angles      = new PoseAngle[4];
    this.startPose.Angles[0]   = new PoseAngle(JointType.ShoulderLeft, JointType.ElbowLeft, 180, 20);
    this.startPose.Angles[1]   = new PoseAngle(JointType.ElbowLeft, JointType.WristLeft, 180, 20);
    this.startPose.Angles[2]   = new PoseAngle(JointType.ShoulderRight, JointType.ElbowRight, 0, 20);
    this.startPose.Angles[3]   = new PoseAngle(JointType.ElbowRight, JointType.WristRight, 0, 20);             


    //Pose 1 -举起手来 Both Hands Up
    this.poseLibrary[0]            = new Pose();
    this.poseLibrary[0].Title      = "举起手来(Arms Up)";
    this.poseLibrary[0].Angles     = new PoseAngle[4];
    this.poseLibrary[0].Angles[0]  = new PoseAngle(JointType.ShoulderLeft, JointType.ElbowLeft, 180, 20);
    this.poseLibrary[0].Angles[1]  = new PoseAngle(JointType.ElbowLeft, JointType.WristLeft, 90, 20);
    this.poseLibrary[0].Angles[2]  = new PoseAngle(JointType.ShoulderRight, JointType.ElbowRight, 0, 20);
    this.poseLibrary[0].Angles[3]  = new PoseAngle(JointType.ElbowRight, JointType.WristRight, 90, 20);


    //Pose 2 - 把手放下来 Both Hands Down
    this.poseLibrary[1]            = new Pose();
    this.poseLibrary[1].Title = "把手放下来(Arms Down)";
    this.poseLibrary[1].Angles     = new PoseAngle[4];            
    this.poseLibrary[1].Angles[0]  = new PoseAngle(JointType.ShoulderLeft, JointType.ElbowLeft, 180, 20);
    this.poseLibrary[1].Angles[1]  = new PoseAngle(JointType.ElbowLeft, JointType.WristLeft, 270, 20);            
    this.poseLibrary[1].Angles[2]  = new PoseAngle(JointType.ShoulderRight, JointType.ElbowRight, 0, 20);
    this.poseLibrary[1].Angles[3]  = new PoseAngle(JointType.ElbowRight, JointType.WristRight, 270, 20);


    //Pose 3 - 举起左手 Left Up and Right Down
    this.poseLibrary[2]            = new Pose();
    this.poseLibrary[2].Title = "(举起左手)Left Up and Right Down";
    this.poseLibrary[2].Angles     = new PoseAngle[4];
    this.poseLibrary[2].Angles[0]  = new PoseAngle(JointType.ShoulderLeft, JointType.ElbowLeft, 180, 20);
    this.poseLibrary[2].Angles[1]  = new PoseAngle(JointType.ElbowLeft, JointType.WristLeft, 90, 20);
    this.poseLibrary[2].Angles[2]  = new PoseAngle(JointType.ShoulderRight, JointType.ElbowRight, 0, 20);
    this.poseLibrary[2].Angles[3]  = new PoseAngle(JointType.ElbowRight, JointType.WristRight, 270, 20);


    //Pose 4 - 举起右手 Right Up and Left Down
    this.poseLibrary[3]            = new Pose();
    this.poseLibrary[3].Title = "(举起右手)Right Up and Left Down";
    this.poseLibrary[3].Angles     = new PoseAngle[4];
    this.poseLibrary[3].Angles[0]  = new PoseAngle(JointType.ShoulderLeft, JointType.ElbowLeft, 180, 20);
    this.poseLibrary[3].Angles[1]  = new PoseAngle(JointType.ElbowLeft, JointType.WristLeft, 270, 20);
    this.poseLibrary[3].Angles[2]  = new PoseAngle(JointType.ShoulderRight, JointType.ElbowRight, 0, 20);
    this.poseLibrary[3].Angles[3]  = new PoseAngle(JointType.ElbowRight, JointType.WristRight, 90, 20);
}

 

    开始姿势和姿势库定义好了之后,下面来开始改写游戏的逻辑代码。当游戏GameOver时,会调用ProcessGameOver方法。在前篇文章中,这个方法用来判断用户的双手是否在指定的对象上,现在替换为识别用户的姿势是否是指定的姿势。如下代码展示了如何处理游戏开始和姿势识别,IsPose方法判断是否和指定的姿势匹配,这个方法在多个地方都可能会用到。IsPost方法遍历一个姿势中的所有PoseAngle,如果任何一个关节点角度和定义的不一致,方法就返回false,表示不是指定的姿势。方法中的if语句用来判断角度是否在360度范围内,如果不在,则转换到该范围内。

private void ProcessGameOver(Skeleton skeleton)
{
    if(IsPose(skeleton, this.startPose))
    {
        ChangePhase(GamePhase.SimonInstructing);
    }         
}

private bool IsPose(Skeleton skeleton, Pose pose)
{
    bool isPose = true;
    double angle;
    double poseAngle;
    double poseThreshold;
    double loAngle;
    double hiAngle;

    for(int i = 0; i < pose.Angles.Length && isPose; i++)
    {
        poseAngle       = pose.Angles[i].Angle;
        poseThreshold   = pose.Angles[i].Threshold;
        angle           = GetJointAngle(skeleton.Joints[pose.Angles[i].CenterJoint], skeleton.Joints[pose.Angles[i].AngleJoint]);

        hiAngle = poseAngle + poseThreshold;
        loAngle = poseAngle - poseThreshold;

        if(hiAngle >= 360 || loAngle < 0)
        {
            loAngle = (loAngle < 0) ? 360 + loAngle : loAngle;
            hiAngle = hiAngle % 360;

            isPose = !(loAngle > angle && angle > hiAngle);
        }
        else
        {
            isPose = (loAngle <= angle && hiAngle >= angle);
        }
    }
    return isPose;
}

    IsPost方法调用GetJointAngle方法来计算两个关节点之间的角度。GetJointAngle调用GetJointPoint方法来获取每一个节点在主UI布局空间中的坐标。这一步其实没有太大必要,原始的位置信息也可以用来计算角度。但是,将关节点的坐标转换到主UI界面上来能够帮助我们进行调试。获得了节点的位置后,使用余弦定理计算节点间的角度。Math.Acos返回的值是度,将其转换到角度值。If语句处理角度值在180-360的情况。余弦定理返回的角度在0-180度内,if语句将在第三和第四象限的值调整到第一第二象限中来。

private double GetJointAngle(Joint centerJoint, Joint angleJoint)
{
    Point primaryPoint  = GetJointPoint(this.KinectDevice, centerJoint, this.LayoutRoot.RenderSize, new Point());
    Point anglePoint    = GetJointPoint(this.KinectDevice, angleJoint, this.LayoutRoot.RenderSize, new Point());
    Point x             = new Point(primaryPoint.X + anglePoint.X, primaryPoint.Y);

    double a;
    double b;
    double c;

    a = Math.Sqrt(Math.Pow(primaryPoint.X - anglePoint.X, 2) + Math.Pow(primaryPoint.Y - anglePoint.Y, 2));
    b = anglePoint.X;
    c = Math.Sqrt(Math.Pow(anglePoint.X - x.X, 2) + Math.Pow(anglePoint.Y - x.Y, 2));

    double angleRad = Math.Acos((a * a + b * b - c * c) / (2 * a * b));
    double angleDeg = angleRad * 180 / Math.PI;

    if(primaryPoint.Y < anglePoint.Y)
    {
        angleDeg = 360 - angleDeg;                            
    }

    return angleDeg;
}

 

     程序还必须识别姿势并启动程序。当程序识别到启动的姿势是,将游戏的状态切换到SimonInstructing。这部分代码和GenerateInstructions及DisplayInstructions是分开的。将GenerateInstructions产生的指令改为随机的从姿势库中选取某一个姿势。然后使用选择的姿势填充指令集合。DisplayInstructions方法可以使用自己的方法比如图片来给用户以提示。一旦游戏显示完指令,游戏转入PlayerPerforming阶段。这个阶段给了游戏者一定的时间来摆出特定的姿势,当程序识别到需要的姿势时,转到下一个姿势,并重启计时器。如果超过给定时间仍然没有给出指定的姿势,游戏结束。WPF中System.Windows.Threading命名空间下的DispatcherTimer类可以简单的完成计时器的功能。下面的代码显示了如何使用DispatcherTimer,代码首先实例化了一个类,然后设定间隔时间。添加PoseTimer局部变量,然后将下面的代码添加到主窗体的构造函数中。

private DispatcherTimer poseTimer;  

 

public MainWindow()
{
    ……………………
    this.poseTimer     = new DispatcherTimer();
    this.poseTimer.Interval    = TimeSpan.FromSeconds(10);
    this.poseTimer.Tick += (s, e) => { ChangePhase(GamePhase.GameOver); };
    this.poseTimer.Stop();
    ……………………
}

    最后一部分更新是ProcessPlayerPerforming方法,代码如下。每一次方法调用,都会判断当前的姿势是否在姿势库中匹配,如果匹配正确,那么停止计时器,进入到下一个姿势指令。当用户到了姿势序列中的末尾时,游戏更改姿势指令。否则,刷新到下一个姿势。

private void ProcessPlayerPerforming(Skeleton skeleton)
{           
    int instructionSeq = this.instructionSequence[this.instructionPosition];
    if(IsPose(skeleton, this.poseLibrary[instructionSeq]))
    {     
        this.poseTimer.Stop();           
        this.instructionPosition++;

        if(this.instructionPosition >= this.instructionSequence.Length)
        {
            ChangePhase(GamePhase.SimonInstructing);
        }
        else
        {
            //TODO: Notify the user of correct pose
            this.poseTimer.Start();
        }
    }
}

    将以上的这些代码添加到项目中区之后,Simon Says现在就是用姿势识别取代可视化元素的命中测试来进行判别用户是否完成了指定的指令了。运行游戏,为了截图,下面都是我端着键盘的姿势,大家可以下载本文的代码回去自己玩哈。

 

pose1wpose2w

pose3wpose4w

 

    这个例子展示了在实际应用中,如何使用姿势识别。可以试着在姿势库中添加其他姿势,然后测试。你会发现并不是所有的姿势都是那么容易就能够识别的。

    对于任何程序,尤其是Kinect应用程序,用户体验对于应用的成功与否至关重要。运行Simon Says游戏,你会感觉游戏缺少了很多东西。游戏界面缺少一些使得游戏交互更加有趣的元素。这个游戏缺少对用户动作的反馈,这对用户体验很重要。要使得Simon Say变成一个真正意义上的Kienct驱动的游戏,在游戏开始或者结束时必须给予一些提示信息。当游戏者正确的做了一个指定的姿势时应该给予一定的鼓励。有以下几个方面可以增加游戏的趣味性和可玩性:

  • 添加更多的姿势。使用Pose类可以很容易的添加新的姿势。需要做的只是定义姿势里一些关节点之间的夹角。
  • 可以调整计时器,通关后,下一个姿势的计时时间可以加短一些,这样可以使得游戏更加紧张刺激。
  • 将计时器的时间显示在UI界面上可以增加游戏的紧张感。将计时器时间显示在UI界面上,并使用一些动画效果,这是游戏中常用的做法。
  • 在游戏过程中,对游戏者的姿势进行截屏,在游戏结束是可以幻灯片播放用户的姿势,这样可以使得游戏更加有趣。

 

4. 扩展与代码重构

 

    本例中,使用的最多的地方是姿势识别部分代码。在Simon Says游戏中,我们写了很多代码来启动开始姿势识别引擎。在未来Kinect SDK中可能会增加姿势识别引擎,但是目前的SDK版本中没有这一功能。考虑到Kinect SDK是否会在未来增加这一功能,所以很有必要创建这么一个工具。Kinect开发社区有一些类似的工具,但都部是标准的。

    可以考虑创建一个PoseEngine类,他有一个PoseDetected事件。当引擎识别到骨骼数据摆出了一个姿势时,触发该事件。默认地,PoseEngine类监听SkeletonFrameReady事件,他能够一帧一帧的使用某种方法测试骨骼数据帧,这使得能够支持“拉”数据模型。PosEngine类有一个Pose集合,他定义了一些能够识别的姿势合集。可以就像.Net中的List那样使用Add和Remove方法进行添加或者删除,开发者可以为应用程序定义一个姿势库。

    为了能够动态的添加和删除姿势,姿势定义那部分代码不能像我们之前的Simon Says游戏中的那样硬编码。最简单的方法是使用序列化。序列化姿势数据有两个好处,一是姿势很容易从应用程序中添加和移除。应用程序可以在运行时动态对添加到配置文件中的姿势进行读取。更进一步的,我们可以将这些姿势配置持久化,使得我们可以创建一个专门的工具来捕捉或者定义姿势。

    开发一个能够捕捉用户姿势,并将数据序列化成应用程序直接使用的数据源不是太难。这个程序可以使用前面我们所讲到的知识开发出来。可以在SkeletonView自定义控件的基础上,添加关节点之间角度计算逻辑。然后显示在SkeletonVeiw的输出信息中,将角度信息显示在关节点位置。姿势捕捉工具使用函数来对这用户的姿势进行截图,这截图实际上是一系列关节点之间的角度信息,截图可以序列化,使得能够很容易的添加到其他应用程序中去。

    将SkeletonView根据上面的想法进行改进后,可以显示关节点夹角信息。下图展示了可能的输出。使得能够很容易的看出各个关节点之间的夹角。可以根据这个夹角来手动的定义一些姿势。甚至可以开发出一些工具根据这些夹角来生成姿势配置文件。将夹角显示在UI上也能提供很多有用的调试信息。

 

Beginning.Kinect.Programming.with.the.Microsoft.Kinect

 

 

 

5. 结语

     本文首先介绍了如何使用骨骼节点数据中的Z值来创建更好的体验,然后讨论了姿势识别的常用方法,并结合上文中Simon Says的游戏,把它改造为了使用姿势识别来判断指令执行是否正确,最后讨论了该游戏可以改进的一些地方和创建姿势识别引擎的一些设想。本文是骨骼追踪的最后一节内容,从下篇文章开始将会介绍手势识别,敬请关注。