首先来看一下Kinect设备:
黑色的Kinect设备如下图:基座和感应器之间有一个电动的马达,通过程序能够调整俯仰角度,在上面的感应器中有一个红外投影仪,两个摄像头,四个麦克风和一个风扇。打开外面的盖子可以看到里面的构造:这些感应器用来捕捉RGB和深度数据,面对Kinect,从左往右看。最左边是红外光源,其次是LED指示灯,再次是彩色摄像头,用来收集RGB数据,最右边是红外摄像头用才采集景深数据。彩色摄像头最大支持1280*960分辨率成像,红外摄像头最大支持640*480成像。
在感应器的下方是麦克风阵列,他包括四个不同的麦克风,一个在左边的红外发射器下面,另外3个在右边景深摄像头下面。
初步了解了Kinect构造后,接下来看看开发环境的搭建:
Kinect for Windows SDK是一些列的类库,他能够使得开发者能够将Kinect作为输入设备开发各种应用程序。就像名字所显示的那样,Kinect for Windows SDK只能运行在32位或者64位的windows7及以上版本的操作系统上。
使用Visual Studio 2010 Express版本或者专业版进行开发时,需要安装最新版本的Kinect for Windows SDK,SDK中包含有对Kinect的硬件驱动。
1. Visual Studio 2010 Express或者Visual Studio 2010专业版或其他版本
2. .NET Framework 4.0
3. Kinect for Windows SDK,最新版本为1.0版本,下载地址:http://www.microsoft.com/en-us/kinectforwindows/develop/overview.aspx
安装SDK之前,需要断开Kinect与电脑的连接,并关闭Visual Studio。安装过程很简单,等SDK安装完成之后,将Kinect电源线插上并连接到电脑上,Win7会自动寻找和安装驱动,安装完成后就可以识别Kinect,这是Kinect上面LED指示灯会变成绿色。
看驱动是否安装成功,可以到电脑的设备管理器中查看,如下图:在Microsoft Kinect节点下应该有3个项,分别是Microsoft Kinect Audio Array Control,Microsoft Kinect Camera, 和 Microsoft Kinect Security Control.
查看Kinect麦克风是否安装成功可以在设备管理器的声音视频游戏控制器节点下查看,如下图,Kinect USB Audio 应该在这个节点下面:
创建一个Kincet项目通常需要:
1. 创建一个VS项目,一般为了展示通常创建一个wpf项目。
2. 添加Microsoft.Kinect.dll引用,如果是早期版本的SDK,这个名称可能不同。
3. 引入Kinect命名空间。
Kinect支持3中类型的托管应用程序,分别是:控制台应用程序,WPF以及Windows Form应用程序。首先来创建一个Windows 控制台应用程序,然后在Main函数所在的代码中引入Kinect命名控件,代码如下:
using Microsoft.Kinect;
static void Main(string[] args) { //初始化sensor实例 KinectSensor sensor = KinectSensor.KinectSensors[0]; //初始化照相机 sensor.DepthStream.Enable(); sensor.DepthFrameReady += new EventHandler<DepthImageFrameReadyEventArgs>(sensor_DepthFrameReady); Console.ForegroundColor=ConsoleColor.Green; //打开数据流 sensor.Start(); while (Console.ReadKey().Key != ConsoleKey.Spacebar) { } } static void sensor_DepthFrameReady(object sender, DepthImageFrameReadyEventArgs e) { using (var depthFrame=e.OpenDepthImageFrame()) { if (depthFrame == null) return; short[] bits = new short[depthFrame.PixelDataLength]; depthFrame.CopyPixelDataTo(bits); foreach (var bit in bits) Console.Write(bit); } }
在上面的代码中,为了从摄像头中获取数据流,需要初始化KinectSensor对象,然后启动他。为了获取景深数据,我们给sensor的DepthFrameReady注册了时事件。上面的代码中数据通过DepthFrameReady事件触发。在Kinect应用中,这些获取数据的事件有DepthFrameReady,ColorFrameReady以及SkeletonFrameReady。可以通过这些事件获取丰富的数据来完成各种有趣的应用。在上面的代码中,我们简单的将景深摄像头获取的数据输出打印到控制台上。运行程序,然后站在Kinect前面一段距离,你就会看到控制台上输出的信息,酷吧。
安装完Kinect for Windows SDK后,快捷菜单列表中有一个Kinect SDK Sample Browser,自带有一些示例应用程序,打开后如下图:
如果安装好了Kinect,就可以直接点击运行了:我试了一下,挺有趣的:
Kinect Explorer:这是一WPF程序,界面分为左右两个图像区域,左边通过彩色摄像头获取图像信息,并通过骨骼追踪将骨骼叠加在图像上,右边图像是通过景深摄像头获取的景深信息,也在图上叠加了骨骼信息,在这两幅图像下面是一些列对成像参数的设置。这个例子主要是用来展示这几个传感器的应用以及如何获取传感器的信息,还包括获取骨骼数据。
Shape Game:这个一个简单的游戏,界面上的人是通过骨骼追踪绘制出来的,在Kinect前面晃动时,界面上的人也会跟着动,当碰到图形时能够得分。
Kinect Audio Demo:这个是语音识别的例子上面展示的是声音的方向,下面是语音识别的结果,有Red,Green,Blue三个单词,当站在Kinect前面说某个单词时,如果识别正确,横线会显示相应的颜色。试了一下,还是挺灵敏的。后的的例子是针对Kinect for Windows sensor设备的例子,我的Kinect for xbox Sensor不能用。
本文简要介绍了Kinect传感器的结构,开发所需要的软硬件环境,并通过一个小例子展示了如何从Kinect获取数据,最后简单介绍了Kinect SDK所带的例子,希望本文能帮助你熟悉Kinect for windows SDK。
基于Kinect开发的应用程序最开始需要用到的对象就是KinectSensor对象,该对象直接表示Kinect硬件设备。KinectSensor对象是我们想要获取数据,包括彩色影像数据,景深数据和骨骼追踪数据的源头。本文将详细介绍ColorImageStream,后面的文章将详细讨论DepthImageStream和SkeletonStream。
从KinectSensor获取数据最常用的方式是通过监听该对象的一系列事件。每一种数据流都有对应的事件,当改类型数据流可用时,就会触发改时间。每一个数据流以帧(frame)为单位。例如:ColorImageStream当获取到了新的数据时就会触发ColorFrameReady事件。当在讨论各个具体的传感器数据流是我们将会详细讨论这些事件。
每一种数据流(Color,Depth,Skeleton)都是以数据点的方式在不同的坐标系中显示的,在后面的讨论中我们能够清楚的看到这一点。将一个数据流中的点数据转换到另一个数据流中是一个很常见的操作,在本文的后面将会讨论如何转换以及为什么这种转换很有必要。KinectSensor对象有一些列的方法能够进行数据流到数据点阵的转换,他们是MapDepthToColorImagePoint,MapDepthToSkeletonPoint以及MapSkeletonPointToDepth。在获取Kinect数据前,我们必须先发现连接的Kinect设备。发现Kinect设备很简单,但是也有需要主注意的地方。
KinectObject对象没有公共的构造器,应用程序不能直接创建它。相反,该对象是SDK在探测到有连接的Kinect设备时创建的。当有Kinect设备连接到计算机上时,应用程序应该得到通知或者提醒。KinectSeneor对象有一个静态的属性KinectSensors,该属性是一个KinectSensorCollection集合,该集合继承自ReadOnlyCollection,ReadOnlyCollection集合很简单,他只有一个索引器和一个称之为StatusChanged的事件。
使用集合中的索引器来获取KinectSensor对象。集合中元素的个数就是Kinect设备的个数。也就是说,一台电脑上可以连接多个Kinect设备来从不同的方向获取数据。应用程序可以使用多个Kinect设备来获取多方面的数据,Kinect个数的限制 只有电脑配置的限制。由于每个Kinect是通过USB来进行数据传输的,所以每一个Kinect设备需要一条USB线与电脑相连。此外,更多的Kinect设备需要更多的CPU和内存消耗。
查找Kinect设备可以通过简单的遍历集合找到;但是KinectSensor集合中的设备不是都能直接使用,所以KinectSensor对象有一个Status属性,他是一个枚举类型,标识了当前Kinect设备的状态。下表中列出了传感器的状态及其含义:
只有设备在Connected状态下时,KinectSensor对象才能初始化。在应用的整个生命周期中,传感器的状态可能会发生变化,这意味着我们开发的应用程序必须监控设备的连接状态,并且在设备连接状态发生变化时能够采取相应的措施来提高用户体验。例如,如果连接Kinect的USB线从电脑拔出,那么传感器的连接状态就会变为Disconnected,通常,应用程序在这种情况下应该暂停,并提示用户将Kinect设备插入到电脑上。应用程序不应该假定在一开始时Kinect设备就处于可用状态,也不应该假定在整个程序运行的过程中,Kinect设备会一直与电脑连接。
下面,首先创建一个WPF应用程序来展示如何发现,获取Kinect传感器的状态。先建按一个WPF项目,并添加Microsoft.Kinect.dll。在MainWindows.xaml.cs中写下如下代码:
public partial class MainWindow : Window { //私有Kinectsensor对象 private KinectSensor kinect; public KinectSensor Kinect { get { return this.kinect;} set { //如果带赋值的传感器和目前的不一样 if (this.kinect!=value) { //如果当前的传感对象不为null if (this.kinect!=null) { //uninitailize当前对象 this.kinect=null; } //如果传入的对象不为空,且状态为连接状态 if (value!=null&&value.Status==KinectStatus.Connected) { this.kinect=value; } } } } public MainWindow() { InitializeComponent(); this.Loaded += (s, e) => DiscoverKinectSensor(); this.Unloaded += (s, e) => this.kinect = null; } private void DiscoverKinectSensor() { KinectSensor.KinectSensors.StatusChanged += KinectSensors_StatusChanged; this.Kinect = KinectSensor.KinectSensors.FirstOrDefault(x => x.Status == KinectStatus.Connected); } private void KinectSensors_StatusChanged(object sender, StatusChangedEventArgs e) { switch (e.Status) { case KinectStatus.Connected: if (this.kinect == null) this.kinect = e.Sensor; break; case KinectStatus.Disconnected: if (this.kinect == e.Sensor) { this.kinect = null; this.kinect = KinectSensor.KinectSensors.FirstOrDefault(x => x.Status == KinectStatus.Connected); if (this.kinect == null) { //TODO:通知用于Kinect已拔出 } } break; //TODO:处理其他情况下的状态 } } }
上面的代码注释很详细,首先定义了一个私有变量kinect,应用程序应该定义一个私有的变量来存储对获取到的KincectSensor对象的引用,当应用程序不在需要KinectSensor产生数据时,可以使用这个局部变量来释放对KinectSensor对象的引用从而释放资源。我们还定义了一个Kinect属性来对这个私有变量进行包装,使用属性的目的是保证能够以正确的方式初始化和反初始化KinectSensor对象。在Set方法中我们可以看到,自由待赋值的对象的组航太是Connected的时候我们才进行赋值操作,任何将没有处在Connected状态的传感器对象复制给KinectSensor对象时都会抛出InvalidOperationException异常。
在构造函数中有两个匿名方法,一个用来监听Loaded事件,一个用来监听Unloaded事件。当卸载时应该将Kinect属性置为空。在窗口的Loaded事件中程序通过DiscoverKinectSensor方法试图调用一个连接了的传感器。在窗体的Loaded和Unloaded事件中注册这两个事件用来初始化和释放Kinect对象,如果应用程序没有找到Kinect对象,将会通知用户。
DiscoverKinectSensor方法只有两行代码,第一行代码注册StatusChanged事件,第二行代码通过lambda表达式查询集合中第一个处在Connected状态的传感器对象,并将该对象复制给Kinect属性。Kinect属性的set方法确保能都赋值一个合法的Kinect对象。
StatusChanged事件中值得注意的是,当状态为KinectSensor.Connected的时候,if语句限制了应用程序只能有一个kinect传感器,他忽略了电脑中可能连接的其他Kinect传感器。
以上代码展示了用于发现和引用Kinect设备的最精简的代码,随着应用的复杂,可能需要更多的代码来保证线程安全以及能让垃圾回收器及时释放资源以防止内存泄露。
一旦发现了传感器,在应用程序能够使用传感器之前必须对其进行初始化。传感器的初始化包括三个步骤。首先,应用程序必须设置需要使用的数据流,并将其状态设为可用。每一中类型的数据流都有一个Enable方法,该方法可以初始化数据流。每一种数据流都完全不同,在使用之前需要进行一些列的设置。在一些情况下这些设置都在Enable方法中处理了。在下面,我们将会讨论如何初始化ColorImageStream数据流,在以后的文章中还会讨论如何初始化DepthImageStream数据流和SkeletonStream数据流。
初始化之后,接下来就是要确定应用程序如何使用产生的数据流。最常用的方式是使用Kinect对象的一些列事件,每一种数据流都有对应的事件,他们是:ColorImageStream对应ColorFrameReady事件、DepthImageStream对应DepthFrameReady事件、SkeletonStream对象对应SkeletonFrameReady事件。以及AllFramesReady事件。各自对应的事件只有在对应的数据流enabled后才能使用,AllFramesReady事件在任何一个数据流状态enabled时就能使用。
最后,应用程序调用KinectSensor对象的Start方法后,frame-ready事件就会触发从而产生数据。
一旦传感器打开后,可以使用KinectSensor对象的Stop方法停止。这样所有的数据产生都会停止,因此在监听frameready事件时要先检查传感器是否不为null。
KinectSensor对象以及数据流都会使用系统资源,应用程序在不需要使用KinectSensor对象时必须能够合理的释放这些资源。在这种情况下,程序不仅要停止传单器,还用注销frameready事件。注意,不要去调用KinectSensor对象的Dispose方法。这将会阻止应用程序再次获取传感器。应用程序必须从启或者将Kinect从新拔出然后插入才能再次获得并使用对象。
Kinect有两类摄像头,近红外摄像头和普通的视频摄像头。视频摄像头提供了一般摄像头类似的彩色影像。这种数据流是三中数据流中使用和设置最简单的。因此我将他作为Kinect数据流介绍的例子。
使用Kinect数据流也有三部。首先是数据流必须可用。一旦数据流可用,应用程序就可以从数据量中读取数据并对数据进行处理和展现。一旦有新的数据帧可用,这两个步骤就会一直进行,下面的代码展现了如何初始化ColorImage对象。
public KinectSensor Kinect { get { return this.kinect;} set { //如果带赋值的传感器和目前的不一样 if (this.kinect!=value) { //如果当前的传感对象不为null if (this.kinect!=null) { UninitializeKinectSensor(this.kinect); //uninitailize当前对象 this.kinect=null; } //如果传入的对象不为空,且状态为连接状态 if (value!=null&&value.Status==KinectStatus.Connected) { this.kinect=value; InitializeKinectSensor(this.kinect); } } } } private void InitializeKinectSensor(KinectSensor kinectSensor) { if (kinectSensor != null) { kinectSensor.ColorStream.Enable(); kinectSensor.ColorFrameReady += new EventHandler<ColorImageFrameReadyEventArgs>(kinectSensor_ColorFrameReady); kinectSensor.Start(); } } private void UninitializeKinectSensor(KinectSensor kinectSensor) { if (kinectSensor != null) { kinectSensor.Stop(); kinectSensor.ColorFrameReady -= new EventHandler<ColorImageFrameReadyEventArgs>(kinectSensor_ColorFrameReady); } }
上面的代码对之前Kinect属性进行了修改,加粗为修改部分。新添加的两行调用了两个方法,分别初始化和释放KinectSensor和ColorImageStream对象。InitializeKinectSensor对象调用ColorImageStream的Enable方法,注册ColorFrameReady事件并调用start方法。一旦打开了传感器,当新数据帧大道是就会触发frameready事件,该事件触发频率是每秒30次。
在实现Kinect_ColorFrameReady方法前,我们先在XAML窗体中添加一些空间来展现获取到的数据,代码如下:
<Window x:Class="KinectApplicationFoundation.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="ColorImageStreamFromKinect" Height="350" Width="525"> <Grid> <Image x:Name="ColorImageElement">Image> Grid> Window>
然后,在Kinect_ColorFrameReady方法中,我们首先通过打开或者获取一个frame来提取获Frame数据。ColorImageFrameReadyEventArgs对象的OpenColorImageFrame属性返回一个当前的ColorImageFrame对象。这个对象实现了IDisposable接口。所以可以将这个对象抱在using语句中的原因,在提取像素数据之前需要使用一个Byte数组保存获取到的数据。FrameObject对象的PixelDataLength对象返回数据和序列的具体大小。调用CopyPixelDataTo方法可以填充像素数据,然后将数据展示到image控件上,具体代码如下:
void kinectSensor_ColorFrameReady(object sender, ColorImageFrameReadyEventArgs e) { using (ColorImageFrame frame = e.OpenColorImageFrame()) { if (frame != null) { byte[] pixelData = new byte[frame.PixelDataLength]; frame.CopyPixelDataTo(pixelData); ColorImageElement.Source = BitmapImage.Create(frame.Width, frame.Height, 96, 96, PixelFormats.Bgr32, null, pixelData, frame.Width * frame.BytesPerPixel); } } }
运行程序,就能得到从Kinect获取的视频信息,如下图所示这是从Kinect彩色摄像头获取的我房间的照片。和一般的视频没什麽两样,只不过这个是从Kinect的视频摄像头产生的。
本文简要介绍了Kinect开发会遇到的基本对象,Kinect物理设备的发现,KinectSensor对象的初始化,打开KinectSensor对象以及如何获取数据流,最后以ColorImageStream对象为例展示了如何从Kinect获取数据并展现出来。
由于Kinect的彩色摄像头默认每秒产生30副ColorImageFrame,所以上面的应用程序会产生30个Bitmap对象,而且这些对象初始化后很快将变成垃圾等待垃圾回收器进行收集,当采集的数据量很大时,将会对性能产生影响。限于篇幅原因,下篇文章将会介绍如何对这一点进行改进,并将讨论获取Kinect传感器产生数据的两种编程模式:基于事件的模式和轮询的模式。
上文的代码中,对于每一个彩色图像帧,都会创建一个新的Bitmap对象。由于Kinect视频摄像头默认采集频率为每秒30幅,所以应用程序每秒会创建30个bitmap对象,产生30次的Bitmap内存创建,对象初始化,填充像素数据等操作。这些对象很快就会变成垃圾等待垃圾回收器进行回收。对数据量小的程序来说可能影响不是很明显,但当数据量很大时,其缺点就会显现出来。
改进方法是使用WriteableBitmap对象。它位于System.Windows.Media.Imaging命名空间下面,该对象被用来处理需要频繁更新的像素数据。当创建WriteableBitmap时,应用程序需要指定它的高度,宽度以及格式,以使得能够一次性为WriteableBitmap创建好内存,以后只需根据需要更新像素即可。
使用WriteableBitmap代码改动地方很小。下面的代码中,首先定义三个新的成员变量,一个是实际的WriteableBitmap对象,另外两个用来更新像素数据。每一幅图像的大小都是不变的,因此在创建WriteableBitmap时只需计算一次即可。
InitializeKinect方法中加粗的部分是更改的代码。创建WriteableBitmap对象,准备接收像素数据,图像的范围同时也计算了。在初始化WriteableBitmap的时候,同时也绑定了UI元素(名为ColorImageElement的Image对象)。此时WriteableBitmap中没有像素数据,所以UI上是空的。
ass=code> private WriteableBitmap _ColorImageBitmap; private Int32Rect _ColorImageBitmapRect; private int _ColorImageStride; private byte[] _ColorImagePixelData;if (kinectSensor != null) { ColorImageStream colorStream=kinectSensor.ColorStream; colorStream.Enable(); this.colorImageBitMap = new WriteableBitmap(colorStream.FrameWidth, colorStream.FrameHeight, 96, 96, PixelFormats.Bgr32, null); this.colorImageBitmapRect = new Int32Rect(0, 0, colorStream.FrameWidth, colorStream.FrameHeight); this.colorImageStride = colorStream.FrameWidth * colorStream.FrameBytesPerPixel; ColorImageElement.Source = this.colorImageBitMap; kinectSensor.ColorFrameReady += kinectSensor_ColorFrameReady; kinectSensor.Start(); }
还需要进行的一处改动是,对ColorFrameReady事件响应的代码。如下图。首先删除之前创建Bitmap那部分的代码。调用WriteableBitmap对象的WritePixels方法来更新图像。方法使用图像的矩形范围,代码像素数据的数组,图像的Stride,以及偏移(offset).偏移量通常设置为0。
private void Kinect_ColorFrameReady(object sender, ColorImageFrameReadyEventArgs e) {using (ColorImageFrame frame = e.OpenColorImageFrame()) { if (frame != null) { byte[] pixelData = new byte[frame.PixelDataLength]; frame.CopyPixelDataTo(pixelData); this._ColorImageBitmap.WritePixels(this._ColorImageBitmapRect, pixelData, this._ColorImageStride, 0); } }}
基于Kinect的应用程序在无论是在显示ColorImageStream数据还是显示DepthImageStream数据的时候,都应该使用WriteableBitmap对象来显示帧影像。在最好的情况下,彩色数据流会每秒产生30帧彩色影像,这意味着对内存资源的消耗比较大。WriteableBitmap能够减少这种内存消耗,减少需要更新影响带来的内存开辟和回收操作。毕竟在应用中显示帧数据不是应用程序的最主要功能,所以在这方面减少内像存消耗显得很有必要。
每一帧ColorImageFrame都是以字节序列的方式返回原始的像素数据。应用程序必须以这些数据创建图像。这意味这我们可以对这些原始数据进行一定的处理,然后再展示出来。下面来看看如何对获取的原始数据进行一些简单的处理。
void kinectSensor_ColorFrameReady(object sender, ColorImageFrameReadyEventArgs e) { using (ColorImageFrame frame = e.OpenColorImageFrame()) { if (frame != null) { byte[] pixelData = new byte[frame.PixelDataLength]; frame.CopyPixelDataTo(pixelData); for (int i = 0; i < pixelData.Length; i += frame.BytesPerPixel) { pixelData[i] = 0x00;//蓝色 pixelData[i + 1] = 0x00;//绿色 } this.colorImageBitMap.WritePixels(this.colorImageBitmapRect, pixelData,this.colorImageStride,0); } } }
以上的实验关闭了每个像素点的蓝色和绿色通道。for循环遍历每个像素,使得i的起始位置重视该像素的第一个字节。由于数据的格式是Bgr32,即RGB32位(一个像素共占4个字节,每个字节8位),所以第一个字节是蓝色通道,第二个是绿色,第三个是红色。循环体类,将第一个和第二个通道设置为0.所以输出的代码中只用红色通道的信息。这是最基本的图像处理。
代码中对像素的操作和像素着色函数相识,可以通过很复杂的算法来进行。大家可以试试对这些像素赋予一些其它的值然后再查看图像的显示结果。这类操作通常很消耗计算资源。像素着色通常是GPU上的一些很基础的操作。下面有一些简单的算法用来对像素进行处理。
pixelData[i]=(byte)~pixelData[i];
pixelData[i+1]=(byte)~pixelData[i+1];
pixelData[i+2]=(byte)~pixelData[i+2];
pixelData[i]= pixelData[i+1];
pixelData[i+1]= pixelData[i];
pixelData[i+2]=(byte)~pixelData[i+2];
byte gray=Math.Max(pixelData[i],pixelData[i+1])
gray=Math.Max(gray,pixelData[i+2]);
pixelData[i]=gray;
pixelData[i+1]=gray;
pixelData[i+2]=gray;
byte gray=Math.Min(pixelData[i],pixelData[i+1]);
gray=Math.Min(gray,pixelData[i+2]);
pixelData[i]=gray;
pixelData[i+1]=gray;
pixelData[i+2] =gray;
double gray=(pixelData[i]*0.11)+(pixelData[i+1]*0.59)+(pixelData[i+2]*0.3);
double desaturation=0.75;
pixelData[i]=(byte)(pixelData[i]+desaturation*(gray-pixelData[i]));
pixelData[i+1]=(byte)(pixelData[i+1]+desaturation*(gray-pixelData[i+1]));
pixelData[i+2]=(byte)(pixelData[i+2]+desatuation*(gray-pixelData[i+2]));
If (pixelData[i]<0x33||pixelData[i]>0xE5)
{
pixelData[i]=0x00;
} else
{
pixelData[i]=0Xff;
}
If (pixelData[i+1]<0x33||pixelData[i+1]>0xE5)
{
pixelData[i+1]=0x00;
} else
{
pixelData[i+1]=0Xff;
}
If (pixelData[i+2]<0x33||pixelData[i+2]>0xE5)
{
pixelData[i+2]=0x00;
} else
{
pixelData[i+1]=0Xff;
}
一下是上面操作后的图像:
有时候,可能需要从彩色摄像头中截取一幅图像,例如可能要从摄像头中获取图像来设置人物头像。为了实现这一功能,首先需要在界面上设置一个按钮,代码如下:
<Window x:Class="KinectApplicationFoundation.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="ColorImageStreamFromKinect" Height="350" Width="525"> <Grid> <Image x:Name="ColorImageElement">Image> <StackPanel HorizontalAlignment="Left" VerticalAlignment="Top"> <Button Content="Take Picture" Click="TakePictureButton_Click" /> StackPanel> Grid> Window>
private void TakePictureButton_Click(object sender, RoutedEventArgs e) { String fileName = "snapshot.jpg"; if (File.Exists(fileName)) { File.Delete(fileName); } using (FileStream savedSnapshot=new FileStream(fileName,FileMode.CreateNew)) { BitmapSource image =(BitmapSource) ColorImageElement.Source; JpegBitmapEncoder jpgEncoder = new JpegBitmapEncoder(); jpgEncoder.QualityLevel = 70; jpgEncoder.Frames.Add(BitmapFrame.Create(image)); jpgEncoder.Save(savedSnapshot); savedSnapshot.Flush(); savedSnapshot.Close(); savedSnapshot.Dispose(); } }
为了演示,上面的代码中在当前目录创建了一个文件名。这是一种简单保存文件的方法。我们使用FileStream打开一个文件。JpegBitmapEncoder对象将UI上的图像转换为一个标准的JPEG文件,保存完后,需要调用对象的flush方法,然后关闭,最后释放对象。虽然这三部不需要,因为我们使用了using语句,这里是为了演示,所以把这三步加上了。
到此为止,我们讨论了如何发现以及初始化Kinect传感器,从Kinect的影像摄像头获取图片。现在让我们来看看一些关键的类,以及他们之间的关系。下图展现了ColorImageStream的对象模型图。
ColorImageStream是KinectSensor对象的一个属性,如同KinectSensorde其它流一样,色彩数据流在使用之前需要调用Enable方法。ColorImageStream有一个重载的Enabled方法,默认的Eanbled方法没有参数,重载的方法有一个ColorImageFormat参数,他是一个枚举类型,可以使用这个参数指定图像格式。下表列出了枚举成员。默认的Enabled将ColorImageStream设置为每秒30帧的640*480的RGB影像数据。一旦调用Enabled方法后,就可以通过对象的Foramt属性获取到图像的格式了。
ColorImageStream 有5个属性可以设置摄像头的视场。这些属性都以Nominal开头,当Stream被设置好后,这些值对应的分辨率就设置好了。一些应用程序可能需要基于摄像头的光学属性比如视场角和焦距的长度来进行计算。ColorImageStream建议程序员使用这些属性,以使得程序能够面对将来分辨率的变化。
ImageStream是ColorImageStream的基类。因此ColorImageStream集成了4个描述每一帧每一个像素数据的属性。在之前的代码中,我们使用这些属性创建了一个WriteableBitmap对象。这些属性与ColorImageFormat的设置有关。ImageStream中除了这些属性外还有一个IsEnabled属性和Disable方法。IsEnabled属性是一个只读的。当Stream打开时返回true,当调用了Disabled方法后就返回false了。Disable方法关闭Stream流,之后数据帧的产生就会停止,ColorFrameReady事件的触发也会停止。当ColorImageStream设置为可用状态后,就能产生ColorImageFrame对象。ColorImageFrame对象很简单。他有一个Format方法,他是父类的ColorImageFormat值。他只有一个CopyPixelDataTo方法,能够将图像的像素数据拷贝到指定的byte数组中,只读的PixelDataLength属性定义了数组的大小PixelDataLength属性通过对象的宽度,高度以及每像素多少位属性来获得的。这些属性都继承自ImageFrame抽象类。
数据流的格式决定了像素的格式,如果数据流是以ColorImageFormat.RgbResolution640*480Fps30格式初始化的,那么像素的格式就是Bgr32,它表示每一个像素占32位(4个字节),第一个字节表示蓝色通道值,第二个表示绿色,第三个表示红色。第四个待用。当像素的格式是Bgra32时,第四个字节表示像素的alpha或者透明度值。如果一个图像的大小是640*480,那么对于的字节数组有122880个字节(width*height*BytesPerPixel=640*480*4).在处理影像时有时候也会用到Stride这一术语,他表示影像中一行的像素所占的字节数,可以通过图像的宽度乘以每一个像素所占字节数得到。
除了描述像素数据的属性外,ColorImageFrame对象还有一些列描述本身的属性。Stream会为每一帧编一个号,这个号会随着时间顺序增长。应用程序不要假的每一帧的编号都比前一帧恰好大1,因为可能出现跳帧现象。另外一个描述帧的属性是Timestamp。他存储自KinectSensor开机(调用Start方法)以来经过的毫秒数。当每一次KinectSensor开始时都会复位为0。
目前为止我们都是使用KinectSensor对象的事件来获取数据的。事件在WPF中应用很广泛,在数据或者状态发生变化时,事件机制能够通知应用程序。对于大多数基于Kinect开发的应用程序来说基于事件的数据获取方式已经足够;但它不是唯一的能从数据流中获取数据的模式。应用程序能够手动的从Kinect数据流中获取到新的帧数据。
“拉”数据的方式就是应用程序会在某一时间询问数据源是否有新数据,如果有,就加载。每一个Kinect数据流都有一个称之为OpenNextFrame的方法。当调用OpenNextFrame的方式时,应用程序可以给定一个超时的值,这个值就是应用程序愿意等待新数据返回的最长时间,以毫秒记。方法试图在超时之前获取到新的数据帧。如果超时,方法将会返回一个null值。
当使用事件模型时,应用程序注册数据流的frame-ready事件,为其指定方法。每当事件触发时,注册方法将会调用事件的属性来获取数据帧。例如,在使用彩色数据流时,方法调用ColorImageFrameReadyEventArgs对象的OpenColorImageFrame方法来获取ColorImageFrame对象。程序应该测试获取的ColorImageFrame对象是否为空,因为有可能在某些情况下,虽然事件触发了,但是没有产生数据帧。除此之外,事件模型不需要其他的检查和异常处理。相比而言,OpenNextFrame方法在KinectSensor没有运行、Stream没有初始化或者在使用事件获取帧数据的时候都有可能会产生InvalidOperationException异常。应用程序可以自由选择何种数据获取模式,比如使用事件方式获取ColorImageStream产生的数据,同时采用“拉”的方式从SkeletonStream流获取数据。但是不能对同一数据流使用这两种模式。AllFrameReady事件包括了所有的数据流—意味着如果应用程序注册了AllFrameReady事件。任何试图以拉的方式获取流中的数据都会产生InvalidOperationException异常。
在展示如何以拉的模式从数据流中获取数据之前,理解使用模式获取数据的场景很有必要。使用“拉”数据的方式获取数据的最主要原因是性能,只在需要的时候采取获取数据。他的缺点是,实现起来比事件模式复杂。除了性能,应用程序的类型有时候也必须选择“拉”数据的这种模式。SDK也能用于XNA,他不同与WPF,它不是事件驱动的。当需要使用XNA开发游戏时,必须使用拉模式来获取数据。使用SDK也能创建没有用户界面的控制台应用程序。设想开发一个使用Kinect作为眼睛的机器人应用程序,他通过源源不断的主动从数据流中读取数据然后输入到机器人中进行处理,在这个时候,拉模型是比较好的获取数据的方式。下面的代码展示了如何使用拉模式获取数据:
private KinectSensor _Kinect; private WriteableBitmap _ColorImageBitmap; private Int32Rect _ColorImageBitmapRect; private int _ColorImageStride; private byte[] _ColorImagePixelData; public MainWindow() { InitializeComponent(); CompositionTarget.Rendering += CompositionTarget_Rendering; } private void CompositionTarget_Rendering(object sender, EventArgs e) { DiscoverKinectSensor(); PollColorImageStream(); }
代码声明部分和之前的一样。基于“拉”方式获取数据也需要发现和初始化KinectSensor对象。方法使用WriteBitmap来创建帧影像。最大的不同是,在构造函数中我们将Rendering事件绑定到CompositionTarget对象上。ComposationTarget对象表示应用程序中可绘制的界面。Rendering事件会在每一个渲染周期上触发。我们需要使用循环来取新的数据帧。有两种方式来创建循环。一种是使用线程,将在下一节中介绍。另一种方式是使用普通的循环语句。使用CompositionTarget对象有一个缺点,就是Rendering事件中如果处理时间过长会导致UI线程问题。因为时间处理在主UI线程中。所以不应在事件中做一些比较耗时的操作。Redering 事件中的代码需要做四件事情。必须发现一个连接的KinectSnesor,初始化传感器。响应传感器状态的变化,以及拉取新的数据并对数据进行处理。我们将这四个任务分为两个方法。下面的代码列出了方法的实现。和之前的代码差别不大:
private void DiscoverKinectSensor() { if(this._Kinect != null && this._Kinect.Status != KinectStatus.Connected) { this._Kinect = null; } if(this._Kinect == null) { this._Kinect = KinectSensor.KinectSensors.FirstOrDefault(x => x.Status == KinectStatus.Connected); if(this._Kinect != null) { this._Kinect.ColorStream.Enable(); this._Kinect.Start(); ColorImageStream colorStream = this._Kinect.ColorStream; this._ColorImageBitmap = new WriteableBitmap(colorStream.FrameWidth, colorStream.FrameHeight, 96, 96, PixelFormats.Bgr32, null); this._ColorImageBitmapRect = new Int32Rect(0, 0, colorStream.FrameWidth, colorStream.FrameHeight); this._ColorImageStride = colorStream.FrameWidth * colorStream.FrameBytesPerPixel; this.ColorImageElement.Source = this._ColorImageBitmap; this._ColorImagePixelData = new byte[colorStream.FramePixelDataLength]; } } }
下面的代码列出了PollColorImageStream方法的实现。代码首先判断是否有KinectSensor可用.然后调用OpneNextFrame方法获取新的彩色影像数据帧。代码获取新的数据后,然后更新WriteBitmap对象。这些操作包在using语句中,因为调用OpenNextFrame对象可能会抛出异常。在调用OpenNextFrame方法时,将超时时间设置为了100毫秒。合适的超时时间设置能够使得程序在即使有一两帧数据跳过时仍能够保持流畅。我们要尽可能的让程序每秒产生30帧左右的数据。
private void PollColorImageStream() { if(this._Kinect == null) { //TODO: Display a message to plug-in a Kinect. } else { try { using(ColorImageFrame frame = this._Kinect.ColorStream.OpenNextFrame(100)) { if(frame != null) { frame.CopyPixelDataTo(this._ColorImagePixelData); this._ColorImageBitmap.WritePixels(this._ColorImageBitmapRect, this._ColorImagePixelData, this._ColorImageStride, 0); } } } catch(Exception ex) { //TODO: Report an error message } } }
总体而言,采用拉模式获取数据的性能应该好于事件模式。上面的例子展示了使用拉方式获取数据,但是它有另一个问题。使用CompositionTarget对象,应用程序运行在WPF的UI线程中。任何长时间的数据处理或者在获取数据时超时 时间的设置不当都会使得程序变慢甚至无法响应用户的行为,因为这些操作都执行在UI线程上。解决方法是创建一个新的线程,然后在这个线程上执行数据获取和处理操作。 在.net中使用BackgroundWorker类能够简单的解决这个问题。代码如下:
private void Worker_DoWork(object sender, DoWorkEventArgs e) { BackgroundWorker worker = sender as BackgroundWorker; if(worker != null) { while(!worker.CancellationPending) { DiscoverKinectSensor(); PollColorImageStream(); } } }
首先,在变量声明中加入了一个BackgroundWorker变量 _Worker。在构造函数中,实例化了一个BackgroundWorker类,并注册了DoWork事件,启动了新的线程。当线程开始时就会触发DoWork事件。事件不断循环知道被取消。在循环体中,会调用DiscoverKinectSensor和PollColorImageStream方法。如果直接使用之前例子中的这两个方法,你会发现会出现InvalidOperationException异常,错误提示为“The calling thread cannot access this object because a different thread owns it”。这是由于,拉数据在background线程中,但是更新UI元素却在另外一个线程中。在background线程中更新UI界面,需要使用Dispatch对象。WPF中每一个UI元素都有一个Dispathch对象。下面是两个方法的更新版本:
private void DiscoverKinectSensor() { if(this._Kinect != null && this._Kinect.Status != KinectStatus.Connected) { this._Kinect = null; } if(this._Kinect == null) { this._Kinect = KinectSensor.KinectSensors .FirstOrDefault(x => x.Status == KinectStatus.Connected); if(this._Kinect != null) { this._Kinect.ColorStream.Enable(); this._Kinect.Start(); ColorImageStream colorStream = this._Kinect.ColorStream; this.ColorImageElement.Dispatcher.BeginInvoke(new Action(() => { this._ColorImageBitmap = new WriteableBitmap(colorStream.FrameWidth, colorStream.FrameHeight, 96, 96, PixelFormats.Bgr32, null); this._ColorImageBitmapRect = new Int32Rect(0, 0, colorStream.FrameWidth, colorStream.FrameHeight); this._ColorImageStride = colorStream.FrameWidth * colorStream.FrameBytesPerPixel; this._ColorImagePixelData = new byte[colorStream.FramePixelDataLength]; this.ColorImageElement.Source = this._ColorImageBitmap; })); } } }
private void PollColorImageStream() { if(this._Kinect == null) { //TODO: Notify that there are no available sensors. } else { try { using(ColorImageFrame frame = this._Kinect.ColorStream.OpenNextFrame(100)) { if(frame != null) { frame.CopyPixelDataTo(this._ColorImagePixelData); this.ColorImageElement.Dispatcher.BeginInvoke(new Action(() => { this._ColorImageBitmap.WritePixels(this._ColorImageBitmapRect, this._ColorImagePixelData, this._ColorImageStride, 0); })); } } } catch(Exception ex) { //TODO: Report an error message } } }
到此为止,我们展示了两种采用“拉”方式获取数据的例子,这两个例子都不够健壮。比如说还需要对资源进行清理,比如他们都没有释放KinectSensor对象,在构建基于Kinect的实际项目中这些都是需要处理的问题。
“拉”模式获取数据跟事件模式相比有很多独特的好处,但它增加了代码量和程序的复杂度。在大多数情况下,事件模式获取数据的方法已经足够,我们应该使用该模式而不是“拉”模式。唯一不能使用事件模型获取数据的情况是在编写非WPF平台的应用程序的时候。比如,当编写XNA或者其他的采用拉模式架构的应用程序。建议在编写基于WPF平台的Kinect应用程序时采用事件模式来获取数据。只有在极端注重性能的情况下才考虑使用“拉”的方式。
本节介绍了采用WriteableBitmap改进程序的性能,并讨论了ColorImageStream中几个重要对象的对象模型图并讨论了个对象之间的相关关系。最后讨论了在开发基于Kinect应用程序时,获取KinectSensor数据的两种模式,并讨论了各自的优缺点和应用场合,这些对于之后的DepthImageSteam和SkeletonStream也是适用的。
Kinect传感器的最主要功能之一就是能够产生三维数据,通过这些数据我们能够创建一些很酷的应用。开发Kinect应用程序之前,最好能够了解Kinect的硬件结构。Kinect红外传感器能够探测人体以及非人体对象例如椅子或者咖啡杯。有很多商业组织和实验室正在研究使用景深数据来探测物体。
本文详细介绍了Kinect红外传感器,景深数据格式,景深图像的获取与展示,景深图像的增强处理。
和许多输入设备不一样,Kinect能够产生三维数据,它有红外发射器和摄像头。和其他Kinect SDK如OpenNI或者libfreenect等SDK不同,微软的Kinect SDK没有提供获取原始红外数据流的方法,相反,Kinect SDK从红外摄像头获取的红外数据后,对其进行计算处理,然后产生景深影像数据。景深影像数据从DepthImageFrame产生,它由DepthImageStream对象提供。
DepthImageStream的使用和ColorImageStream的使用类似。DepthImageStream和ColorImageStream都继承自ImageStream。可以像从ColorImageStream获取数据生成图像那样生成景深图像。先看看将景深数据展现出来需要的步骤。下面的步骤和前面显示彩色影像数据相似:
1. 创建一个新的WPF对象。
2. 添加Microsoft.Kinect.dll对象引用。
3. 添加一个Image元素到UI上,将名称改为DepthImage。
4. 添加必要的发现和释放KinectSensor对象的代码。可以参照前面的文章。
5. 修改初始化KinectSensor对象的代码如下:
private void InitializeKinectSensor(KinectSensor kinectSensor) { if (kinectSensor != null) { DepthImageStream depthStream = kinectSensor.DepthStream; depthStream.Enable(); depthImageBitMap = new WriteableBitmap(depthStream.FrameWidth, depthStream.FrameHeight, 96,96,PixelFormats.Gray16, null); depthImageBitmapRect = new Int32Rect(0, 0, depthStream.FrameWidth, depthStream.FrameHeight); depthImageStride = depthStream.FrameWidth * depthStream.FrameBytesPerPixel; DepthImage.Source = depthImageBitMap; kinectSensor.DepthFrameReady += kinectSensor_DepthFrameReady; kinectSensor.Start(); } }
6. 修改DepthFrameReady事件,代码如下:
void kinectSensor_DepthFrameReady(object sender, DepthImageFrameReadyEventArgs e) { using (DepthImageFrame depthFrame = e.OpenDepthImageFrame()) { if (depthFrame != null) { short[] depthPixelDate = new short[depthFrame.PixelDataLength]; depthFrame.CopyPixelDataTo(depthPixelDate); depthImageBitMap.WritePixels(depthImageBitmapRect, depthPixelDate, depthImageStride, 0); } } }
运行程序,将会看到如下结果,由于一手需要截图,一手需要站在Kinect前面所以姿势不是很对,有点挫,不过人物的轮廓还是显示出来了,在景深数据中,离Kinect越近,颜色越深,越远,颜色越淡。
和其他摄像机一样,近红外摄像机也有视场。Kinect摄像机的视野是有限的,如下图所示:
如图,红外摄像机的视场是金字塔形状的。离摄像机远的物体比近的物体拥有更大的视场横截面积。这意味着影像的高度和宽度,比如640X480和摄像机视场的物理位置并不一一对应。但是每个像素的深度值是和视场中物体离摄像机的距离是对应的。深度帧数据中,每个像素占16位,这样BytesPerPixel属性,即每一个像素占2个字节。每一个像素的深度值只占用了16个位中的13个位。如下图:
获取每一个像素的距离很容易,但是要直接使用还需要做一些位操作。可能大家在实际编程中很少情况会用到位运算。如上图所示,深度值存储在第3至15位中,要获取能够直接使用的深度数据需要向右移位,将游戏者索引(Player Index)位移除。后面将会介绍游戏者索引位的重要性。下面的代码简要描述了如何获取像素的深度值。代码中pixelData变量就是从深度帧数据中获取的short数组。PixelIndex基于待计算像素的位置就算出来的。SDK在DepthImageFrame类中定义了一个常量PlayerIndexBitmaskWidth,它定义了要获取深度数据值需要向右移动的位数。在编写代码时应该使用这一常量而不是硬编码,因为未来随着软硬件水平的提高,Kinect可能会增加能够同时识别人数的个数,从而改变PlayerIndexBitmaskWidth常量的值。
Int32 pixelIndex = (Int32)(p.X + ((Int32)p.Y * frame.Width)); Int32 depth = this.depthPixelDate[pixelIndex] >> DepthImageFrame.PlayerIndexBitmaskWidth;
显示深度数据最简单的方式是将其打印出来。我们要将像素的深度值显示到界面上,当鼠标点击时,显示鼠标点击的位置的像素的深度值。第一步是在主UI界面上添加一个TextBlock:
<Window x:Class="KinectDepthImageDemo.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="KinectDepthImage" Height="600" Width="1280" WindowStartupLocation="CenterScreen"> <Grid> <StackPanel Orientation="Horizontal"> <TextBlock x:Name="PixelDepth" FontSize="48" HorizontalAlignment="Left" /> <Image x:Name="DepthImage" Width="640" Height="480" >Image> StackPanel> Grid> Window>
接着我们要处理鼠标点击事件。在添加该事件前,需要首先添加一个私有变量lastDepthFrame来保存每一次DepthFrameReady事件触发时获取到的DepthFrame值。因为我们保存了对最后一个DepthFrame对象的引用,所以事件处理代码不会马上释放该对象。然后,注册DepthFrame 图像控件的MouseLeftButtonUp事件。当用户点击深度图像时,DepthImage_MouseLeftButtonUp事件就会触发,根据鼠标位置获取正确的像素。最后一步将获取到的像素值的深度值显示到界面上,代码如下:
void kinectSensor_DepthFrameReady(object sender, DepthImageFrameReadyEventArgs e) { if (lastDepthFrame!=null) { lastDepthFrame.Dispose(); lastDepthFrame = null; } lastDepthFrame = e.OpenDepthImageFrame(); if (lastDepthFrame != null) { depthPixelDate = new short[lastDepthFrame.PixelDataLength]; lastDepthFrame.CopyPixelDataTo(depthPixelDate); depthImageBitMap.WritePixels(depthImageBitmapRect, depthPixelDate, depthImageStride, 0); } }private void DepthImage_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) { Point p = e.GetPosition(DepthImage); if (depthPixelDate != null && depthPixelDate.Length > 0) { Int32 pixelIndex = (Int32)(p.X + ((Int32)p.Y * this.lastDepthFrame.Width)); Int32 depth = this.depthPixelDate[pixelIndex] >> DepthImageFrame.PlayerIndexBitmaskWidth; Int32 depthInches = (Int32)(depth * 0.0393700787); Int32 depthFt = depthInches / 12; depthInches = depthInches % 12; PixelDepth.Text = String.Format("{0}mm~{1}'{2}", depth, depthFt, depthInches); } }
有一点值得注意的是,在UI界面中Image空间的属性中,宽度和高度是硬编码的。如果不设置值,那么空间会随着父容器(From窗体)的大小进行缩放,如果空间的长宽尺寸和深度数据帧的尺寸不一致,当鼠标点击图片时,代码就会返回错误的数据,在某些情况下甚至会抛出异常。像素数组中的数据是固定大小的,它是根据DepthImageStream的Enable方法中的DepthImageFormat参数值来确定的。如果不设置图像控件的大小,那么他就会根据Form窗体的大小进行缩放,这样就需要进行额外的计算,将鼠标的在Form中的位置换算到深度数据帧的维度上。这种缩放和空间转换操作很常见,在后面的文章中我们将会进行讨论,现在为了简单,对图像控件的尺寸进行硬编码。
结果如下图,由于截屏时截不到鼠标符号,所以用红色点代表鼠标位置,下面最左边图片中的红色点位于墙上,该点距离Kinect 2.905米,中间图的点在我的手上,可以看出手离Kinect距离为1.221米,实际距离和这个很相近,可见Kinect的景深数据还是很准确的。
上面最右边图中白色点的深度数据为-1mm。这表示Kinect不能够确定该像素的深度。在处理上数据时,这个值通常是一个特殊值,可以忽略。-1深度值可能是物体离Kinect传感器太近了的缘故。
在进一步讨论之前,需要会深度值图像进行一些处理。在下面的最左边的图中,灰度级都落在了黑色区域,为了使图像具有更好的灰度级我们需要像之前对彩色数据流图像进行处理那样,对深度值图像进行一些处理。
增强深度值图像的最简单方法是按位翻转像素值。图像的颜色是基于深度值的,他们从0开始。在数字光谱中0表示黑色,65536(16位灰阶)表示白色。这意味着下面最左边那幅图中,大部分的值都落在了黑色部分。还有就是所有的不能确定深度值的数据都设置为了0。对位取反操作就会将这些值转换到白色的部分。 作为对比,现在在UI上再添加一个Image控件用来显示处理后的值。
<Window x:Class="KinectDepthImageDemo.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="KinectDepthImage" Height="600" Width="1280" WindowStartupLocation="CenterScreen"> <Grid> <StackPanel Orientation="Horizontal"> <Image x:Name="DepthImage" Width="640" Height="480" >Image> <Image x:Name="EnhancedDepthImage" Width="640" Height="480" /> StackPanel> Grid> Window>
下面的代码展示了如何将之前的深度位数据取反获取更好的深度影像数据。该方法在kinectSensor_DepthFrameReady事件中被调用。代码首先创建了一个新的byte数组,然后对这个位数组进行取反操作。注意代码中过滤掉了一些距离太近的点。因为过近的点和过远的点都不准确。所以过滤掉了大于3.5米小于0米的数据,将这些数据设置为白色。
private void CreateLighterShadesOfGray(DepthImageFrame depthFrame, short[] pixelData) { Int32 depth; Int32 loThreashold = 0; Int32 hiThreshold = 3500; short[] enhPixelData = new short[depthFrame.Width * depthFrame.Height]; for (int i = 0; i < pixelData.Length; i++) { depth = pixelData[i] >> DepthImageFrame.PlayerIndexBitmaskWidth; if (depth < loThreashold || depth > hiThreshold) { enhPixelData[i] = 0xFF; } else { enhPixelData[i] = (short)~pixelData[i]; } } EnhancedDepthImage.Source= BitmapSource.Create(depthFrame.Width, depthFrame.Height, 96, 96, PixelFormats.Gray16, null, enhPixelData, depthFrame.Width * depthFrame.BytesPerPixel); }
经过处理,图像(上面中间那幅图)的表现力提高了一些,但是如果能够将16位的灰度级用32位彩色表示效果会更好。当 RGB值一样时,就会呈现出灰色。灰度值的范围是0~255,0为黑色,255为白色,之间的颜色为灰色。现在将灰色值以RGB模式展现出来。代码如下:
private void CreateBetterShadesOfGray(DepthImageFrame depthFrame, short[] pixelData) { Int32 depth; Int32 gray; Int32 loThreashold = 0; Int32 bytePerPixel = 4; Int32 hiThreshold = 3500; byte[] enhPixelData = new byte[depthFrame.Width * depthFrame.Height*bytePerPixel]; for (int i = 0,j=0; i < pixelData.Length; i++,j+=bytePerPixel) { depth = pixelData[i] >> DepthImageFrame.PlayerIndexBitmaskWidth; if (depth < loThreashold || depth > hiThreshold) { gray = 0xFF; } else { gray = (255*depth/0xFFF); } enhPixelData[j] = (byte)gray; enhPixelData[j + 1] = (byte)gray; enhPixelData[j + 2] = (byte)gray; } EnhancedDepthImage.Source = BitmapSource.Create(depthFrame.Width, depthFrame.Height, 96, 96, PixelFormats.Bgr32, null, enhPixelData, depthFrame.Width * bytePerPixel); }
上面的代码中,将彩色影像的格式改为了Bgr32位,这意味每一个像素占用32位(4个字节)。每一个R,G,B分别占8位,剩余8位留用。这种模式限制了RGB的取值为0-255,所以需要将深度值转换到这一个范围内。除此之外,我们还设置了最小最大的探测范围,这个和之前的一样,任何不在范围内的都设置为白色。将深度值除以4095(0XFFF,深度探测的最大值),然后乘以255,这样就可以将深度数据转换到0至255之间了。运行后效果如上右图所示,可以看出,采用颜色模式显示灰度较之前采用灰度模式显示能够显示更多的细节信息。
将深度数据值转化到0-255并用RGB模式进行显示可以起到增强图像的效果,能够从图像上直观的看出更多的深度细节信息。还有另外一种简单,效果也不错的方法,那就是将深度数据值转换为色调和饱和度并用图像予以显示。下面的代码展示了这一实现:
private void CreateColorDepthImage(DepthImageFrame depthFrame, short[] pixelData) { Int32 depth; Double hue; Int32 loThreshold = 1200; Int32 hiThreshold = 3500; Int32 bytesPerPixel = 4; byte[] rgb = new byte[3]; byte[] enhPixelData = new byte[depthFrame.Width * depthFrame.Height * bytesPerPixel]; for (int i = 0, j = 0; i < pixelData.Length; i++, j += bytesPerPixel) { depth = pixelData[i] >> DepthImageFrame.PlayerIndexBitmaskWidth; if (depth < loThreshold || depth > hiThreshold) { enhPixelData[j] = 0x00; enhPixelData[j + 1] = 0x00; enhPixelData[j + 2] = 0x00; } else { hue = ((360 * depth / 0xFFF) + loThreshold); ConvertHslToRgb(hue, 100, 100, rgb); enhPixelData[j] = rgb[2]; //Blue enhPixelData[j + 1] = rgb[1]; //Green enhPixelData[j + 2] = rgb[0]; //Red } } EnhancedDepthImage.Source = BitmapSource.Create(depthFrame.Width, depthFrame.Height, 96, 96, PixelFormats.Bgr32, null, enhPixelData, depthFrame.Width * bytesPerPixel); }
以上代码中使用了ConvertHslToRgb这一函数,该函数的作用是进行两个颜色空间的转换,就是将H(Hue色调)S(Saturation饱和度)L(Light亮度)颜色空间转换到RGB颜色空间的函数。之前学过遥感图像处理,所以对这两个颜色空间比较熟悉。转化的代码如下:
public void ConvertHslToRgb(Double hue, Double saturation, Double lightness, byte[] rgb) { Double red = 0.0; Double green = 0.0; Double blue = 0.0; hue = hue % 360.0; saturation = saturation / 100.0; lightness = lightness / 100.0; if (saturation == 0.0) { red = lightness; green = lightness; blue = lightness; } else { Double huePrime = hue / 60.0; Int32 x = (Int32)huePrime; Double xPrime = huePrime - (Double)x; Double L0 = lightness * (1.0 - saturation); Double L1 = lightness * (1.0 - (saturation * xPrime)); Double L2 = lightness * (1.0 - (saturation * (1.0 - xPrime))); switch (x) { case 0: red = lightness; green = L2; blue = L0; break; case 1: red = L1; green = lightness; blue = L0; break; case 2: red = L0; green = lightness; blue = L2; break; case 3: red = L0; green = L1; blue = lightness; break; case 4: red = L2; green = L0; blue = lightness; break; case 5: red = lightness; green = L0; blue = L1; break; } } rgb[0] = (byte)(255.0 * red); rgb[1] = (byte)(255.0 * green); rgb[2] = (byte)(255.0 * blue); }
运行程序,会得到如下右图结果(为了对比,下面左边第一幅图是原始数据,第二幅图是使用RGB模式显示深度数据)。最右边图中,离摄像头近的呈蓝色,然后由近至远颜色从蓝色变为紫色,最远的呈红色。图中,我手上托着截图用的键盘,所以可以看到,床离摄像头最近,呈蓝色,键盘比人体里摄像头更近,呈谈蓝色,人体各部分里摄像头的距离也不一样,胸、腹、头部离摄像头更近。后面的墙离摄像头最远,呈橙色至红色。
运行上面的程序会发现很卡,我好不容易才截到这张图,这是因为在将HUL空间向颜色空间转换需要对640*480=307200个像素逐个进行运算,并且运算中有小数,除法等操作。该计算操作和UI线程位于同一线程内,会阻塞UI线程更新界面。更好的做法是将这一运算操作放在background线程中。每一次当KinectSensor触发frame-ready事件时,代码顺序存储彩色影像。转换完成后,backgroud线程使用WPF中的Dispatcher来更新UI线程中Image对象的数据源。上一篇文章中以及讲过这一问题,这种异步的操作在基于Kinect开发的应用中很常见,因为获取深度数据是一个很频繁的操作。如果将获取数据以及对数据进行处理放在主UI线程中就会使得程序变得很慢,甚至不能响应用户的操作,这降低了用户体验。
本文介绍了Kinect红外摄像头产生的深度影像数据流,KinectSensor探测深度的原理,如何获取像素点的深度值,深度数据的可视化以及一些简单的增强处理。
限于篇幅原因,下一篇文章将会介绍Kinect景深数据影像处理,以及在本文第2节中所景深数据格式中没有讲到的游戏者索引位(Player Index),最后将会介绍KinectSensor红外传感器如何结合游戏者索引位获取人物的空间范围,包括人物的宽度,高度等信息,敬请期待。
在上篇文章中,我们讨论了如何获取像素点的深度值以及如何根据深度值产生影像。在之前的例子中,我们过滤掉了阈值之外的点。这就是一种简单的图像处理,叫阈值处理。使用的阈值方法虽然有点粗糙,但是有用。更好的方法是利用机器学习来从每一帧影像数据中计算出阈值。Kinect深度值最大为4096mm,0值通常表示深度值不能确定,一般应该将0值过滤掉。微软建议在开发中使用1220mm(4’)~3810mm(12.5’)范围内的值。在进行其他深度图像处理之前,应该使用阈值方法过滤深度数据至1220mm-3810mm这一范围内。
使用统计方法来处理深度影像数据是一个很常用的方法。阈值可以基于深度数据的平均值或者中值来确定。统计方法可以帮助确定某一点是否是噪声、阴影或者是其他比较有意义的物体,比如说用户的手的一部分。有时候如果不考虑像素的视觉意义,可以对原始深度进行数据挖掘。对景深数据处理的目的是进行形状或者物体的识别。通过这些信息,程序可以确定人体相对于Kinect的位置及动作。
直方图是统计数据分布的一个很有效的工具。在这里我们关心的是一个景深影像图中深度值的分布。直方图能够直观地反映给定数据集中数据的分布状况。从直方图中,我们能够看出深度值出现的频率以及聚集分组。通过这些信息,我们能够确定阈值以及其他能够用来对图像进行过滤的指标,使得能够最大化的揭示深度影像图中的深度信息。为了展示这一点,接下来我们将会展示一副景深影像数据的直方图,并通过直方图,使用一些简单的技术来过滤掉我们不想要的像素点。
首先创建一个新的项目。然后根据之前文章中讲的步骤发现和初始化KinectSensor对象来进行深度影像数据处理,包括注册DepthFrameReady事件。在添加实现深度直方图之前,将UI界面更改为如下:
<Window x:Class="KinectDepthHistogram.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="MainWindow" Height="800" Width="1200" WindowStartupLocation="CenterScreen"> <Grid> <StackPanel> <StackPanel Orientation="Horizontal"> <Image x:Name="DepthImage" Width="640" Height="480" /> <Image x:Name="FilteredDepthImage" Width="640" Height="480" /> StackPanel> <ScrollViewer Margin="0,15" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"> <StackPanel x:Name="DepthHistogram" Orientation="Horizontal" Height="300" /> ScrollViewer> StackPanel> Grid> Window>
创建直方图的方法很简单,就是创建一系列的矩形元素,然后将它添加到名为DepthHistogram的StackPanel元素中,由于DepthHistogram对象的Orientation属性设置为Horizontal,所以这些矩形会水平排列。大多数应用程序计算直方图只是用来进行中间过程处理用,如果想要将直方图展现出来,则需要在绘图上面做些工作。下面的代码展现了如何绘制直方图:
private void KinectDevice_DepthFrameReady(object sender, DepthImageFrameReadyEventArgs e) { using (DepthImageFrame frame = e.OpenDepthImageFrame()) { if (frame != null) { frame.CopyPixelDataTo(this._DepthPixelData); CreateBetterShadesOfGray(frame, this._DepthPixelData); CreateDepthHistogram(frame, this._DepthPixelData); } } }private void CreateDepthHistogram(DepthImageFrame depthFrame, short[] pixelData) { int depth; int[] depths = new int[4096]; double chartBarWidth = Math.Max(3, DepthHistogram.ActualWidth / depths.Length); int maxValue = 0; DepthHistogram.Children.Clear(); //计算并获取深度值.并统计每一个深度值出现的次数 for (int i = 0; i < pixelData.Length; i++) { depth = pixelData[i] >> DepthImageFrame.PlayerIndexBitmaskWidth; if (depth >= LoDepthThreshold && depth <= HiDepthThreshold) { depths[depth]++; } } //查找最大的深度值 for (int i = 0; i < depths.Length; i++) { maxValue = Math.Max(maxValue, depths[i]); } //绘制直方图 for (int i = 0; i < depths.Length; i++) { if (depths[i] > 0) { Rectangle r = new Rectangle(); r.Fill = Brushes.Black; r.Width = chartBarWidth; r.Height = DepthHistogram.ActualHeight * (depths[i] / (double)maxValue); r.Margin = new Thickness(1, 0, 1, 0); r.VerticalAlignment = System.Windows.VerticalAlignment.Bottom; DepthHistogram.Children.Add(r); } } }
绘制直方图时,创建一个数组来存储所有可能的深度值数据,因此数组的大小为4096。第一步遍历深度图像,获取深度值,然后统计深度值出现的次数。因为设置了最高最低的距离阈值,忽略了0值。下图显示了深度值影像的直方图,X轴表示深度值,Y轴表示深度值在图像中出现的次数。
当站在Kinect前后晃动时,下面的直方图会不停的变化。图中后面最长的几个线条表示墙壁,大约离摄像头3米左右,前面的几个小的线条是人体,大概离摄像头2米左右,下面那副图中,我手上拿了一个靠垫,可以发现直方图与之前的直方图相比发生了一些变化。
这两幅图中,可以看到直方图都集中在两个地方,前面的一小撮和后面的那一大坨。所以根据直方图可以看出,前面那个表示人体,后面那个代表房间的墙壁,在结合一些图像处理技术,就大致可以把人体和背景区分开来了。
本文不打算详细讲解图像处理的相关知识。只是讨论如何获取原始的深度数据,以及理解数据的用途。很多情况下,基于Kinect的应用程序不会对深度数据进行很多处理。如果要处理数据,也应该使用一些类库诸如OpenCV库来处理这些数据。深度影像处理经常要耗费大量计算资源,不应该使用诸如C#这类的高级语言来进行影像处理。
Note: OpenCV(Open Source Computer Vision)库是是一个经常用来处理和计算影像数据的算法类库。这个类库也包含点云库(Point Cloud Library, PCL) 和机器人操作系统(Robot Operating System, ROS),这些都涉及到了大量的深度数据处理。有兴趣的可以研究一下OpenCV库。
应用程序处理深度数据目的是用来确定人体在Kinect视场中的位置。虽然Kinect SDK中的骨骼追踪在这方面功能更强大,但是在某些情况下还是需要从深度数据中分析出人物所处的位置。在下节中,我们将会分析人体在深度影像中的范围。在开始之前,有必要了解和研究一下图像处理中常用的一些算法,有时候这些对特征提取非常有帮助。
阈值处理(Thresholding)
图像分割 (Segmentation)
高斯滤波(Gaussian filter)
Sobel、Prewitt、Kirsh算子
Canny算子
罗伯特 算子
Kinect SDK具有分析景深数据和探测人体或者游戏者轮廓的功能,它一次能够识别多达6个游戏者。SDK为每一个追踪到的游戏者编号作为索引。游戏者索引存储在深度数据的前3个位中。如前一篇文章讨论的,景深数据每一个像素占16位,0-2位存储游戏者索引值,3-15为存储深度值。7 (0000 0111)这个位掩码能够帮助我们从深度数据中获取到游戏者索引值。幸运的是,SDK为游戏者索引位定义了一些列常量。他们是DepthImageFrame.PlayerIndexBitmaskWidth和DepthImageFrame.PlayerIndexBitmask。前一个值是3,后一个是7。开发者应该使用SDK定义的常量而不应该硬编码3或者7。
游戏者索引位取值范围为0~6,值为0表示该像素不是游戏者。但是初始化了景深数据流并没有开启游戏者追踪。游戏者追踪需要依赖骨骼追踪技术。初始化KinectSensor对象和DepthImageStream对象时,需要同时初始化SkeletonStream对象。只有当SkeletonStream对象初始化了后,景深数据中才会有游戏者索引信息。获取游戏者索引信息并不需要注册SkeletonFrameReady事件。
再创建一个工程来展示如何获取游戏者索引位信息。首先,创建一个新的项目,初始化KinectSensor对象,初始化DepthImageStream和SkeletonStream对象,并注册KinectSensor的DepthFrameReady事件。在UI界面MainWindows.xaml中添加两个Image控件分别取名为RamDepthImage和EnhDepthImage。添加WirteableBitmap对象,代码如下:
<Window x:Class="KinectDepthImagePlayerIndex.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="Kinect Player Index" Height="600" Width="1200"> <Grid> <StackPanel Orientation="Horizontal"> <Image x:Name="RawDepthImage" Margin="0,0,10,0" Width="640" Height="480"/> <Image x:Name="EnhDepthImage" Width="640" Height="480"/> StackPanel> Grid> Window>
下面的代码将有游戏者索引位的数据显示为黑色,其他像元显示为白色。
private void KinectDevice_DepthFrameReady(object sender, DepthImageFrameReadyEventArgs e) { using (DepthImageFrame frame = e.OpenDepthImageFrame()) { if (frame != null) { frame.CopyPixelDataTo(this._RawDepthPixelData); this._RawDepthImage.WritePixels(this._RawDepthImageRect, this._RawDepthPixelData, this._RawDepthImageStride, 0); CreatePlayerDepthImage(frame, this._RawDepthPixelData); } } }private void CreatePlayerDepthImage(DepthImageFrame depthFrame, short[] pixelData) { int playerIndex; int depthBytePerPixel = 4; byte[] enhPixelData = new byte[depthFrame.Width * depthFrame.Height * depthBytePerPixel]; for (int i = 0, j = 0; i < pixelData.Length; i++, j += depthBytePerPixel) { playerIndex = pixelData[i] & DepthImageFrame.PlayerIndexBitmask; if (playerIndex == 0) { enhPixelData[j] = 0xFF; enhPixelData[j + 1] = 0xFF; enhPixelData[j + 2] = 0xFF; } else { enhPixelData[j] = 0x00; enhPixelData[j + 1] = 0x00; enhPixelData[j + 2] = 0x00; } } this._EnhDepthImage.WritePixels(this._EnhDepthImageRect, enhPixelData, this._EnhDepthImageStride, 0); }
运行后,效果如下图,还可以对上面的代码进行一些改进。例如,可以对游戏者所在的像素进行灰度值拉伸,能够绘制出游戏者深度值的直方图,根据直方图可以看出每一灰度级对应的频率。另一个改进是,可以对不同的游戏者给予不同的颜色显示,比如游戏者1用红色表示,游戏者2用蓝色表示等等。
要注意的是,不要对特定的游戏者索引位进行编码,因为他们是会变化的。实际的游戏者索引位并不总是和Kinect前面的游戏者编号一致。例如, Kinect视野中只有一个游戏者,但是返回的游戏者索引位值可能是3或者4。有时候第一个游戏者的游戏者索引位可能不是1,比如走进Kinect视野,返回的索引位是1,走出去后再次走进,可能索引位变为其他值了。所以开发Kinect应用程序的时候应该注意到这一点。
像上篇文章中对深度值测量原理进行讨论的那样,像素点的X,Y位置和实际的宽度和高度并不一致。但是运用几何知识,通过他们对物体进行测量是可能的。每一个摄像机都有视场,焦距的长度和相机传感器的大小决定了视场角。Kinect中相机的水平和垂直视场角分别为57°和43°。既然我们知道了深度值,利用三角几何知识,就可以计算出物体的实际宽度。示意图如下:
图中的公式在某些情况下可能不准确,Kinect返回的数据也有这个问题。这个简化的公式并没有考虑到游戏者的其他部分。尽管如此,公式依然能满足大部分的应用。这里只是简单地介绍了如何将Kinect数据映射到真实环境中。如果想得到更好的精度,则需要研究Kinect摄像头的焦距和摄像头的尺寸。
在开始写代码前,先看看上图中的公式。摄像头的视场角是一个以人体深度位置为底的一个等腰三角形。人体的实际深度值是这个等腰三角形的高。可以将这个等腰三角形以人所在的位置分为两个直角三角形,这样就可以计算出底边的长度。一旦知道了底边的长度,我们就可以将像素的宽度转换为现实中的宽度。例如:如果我们计算出等腰三角形底边的宽度为1500mm,游戏者所占有的总象元的宽度为100,深度影像数据的总象元宽度为320。那么游戏者实际的宽度为468.75mm((1500/320)*100)。公式中,我们需要知道游戏者的深度值和游戏者占用的总的象元宽度。我们可以将游戏者所在的象元的深度值取平均值作为游戏者的深度值。之所以求平均值是因为人体不是平的,这能够简化计算。计算人物高度也是类似的原理,只不过使用的垂直视场角和深度影像的高度。
知道了原理之后,就可以开始动手写代码实现了。先创建一个新的项目然后编写发现和初始化KinectSensor的代码,将DepthStream和SkeletonStream均初始化,然后注册KinectSnsor的DepthFrameReady事件。主UI界面中的代码如下:
<Window x:Class="KinectTakingMeasure.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="MainWindow" Height="800" Width="1200" WindowStartupLocation="CenterScreen"> <Grid> <StackPanel Orientation="Horizontal"> <Image x:Name="DepthImage" /> <ItemsControl x:Name="PlayerDepthData" Width="300" TextElement.FontSize="20"> <ItemsControl.ItemTemplate> <DataTemplate> <StackPanel Margin="0,15"> <StackPanel Orientation="Horizontal"> <TextBlock Text="PlayerId:" /> <TextBlock Text="{Binding Path=PlayerId}" /> StackPanel> <StackPanel Orientation="Horizontal"> <TextBlock Text="Width:" /> <TextBlock Text="{Binding Path=RealWidth}" /> StackPanel> <StackPanel Orientation="Horizontal"> <TextBlock Text="Height:" /> <TextBlock Text="{Binding Path=RealHeight}" /> StackPanel> StackPanel> DataTemplate> ItemsControl.ItemTemplate> ItemsControl> StackPanel> Grid> Window>
使用ItemControl的目的是用来显示结果。方法创建了一个对象来存放用户的深度数据以及计算得到的实际宽度和高度值。程序创建了一个这样的对象数组。他是ItemControl的ItemsSource值。UI定义了一个模板用来展示和游戏者深度值相关的数据,这个模板使用的对象取名为PlayerDepthData。下面的名为ClaculatePlayerSize的方法将作为DepthFrameReady事件发生时执行的操作。
private void KinectDevice_DepthFrameReady(object sender, DepthImageFrameReadyEventArgs e) { using (DepthImageFrame frame = e.OpenDepthImageFrame()) { if (frame != null) { frame.CopyPixelDataTo(this._DepthPixelData); CreateBetterShadesOfGray(frame, this._DepthPixelData); CalculatePlayerSize(frame, this._DepthPixelData); } } }private void CalculatePlayerSize(DepthImageFrame depthFrame, short[] pixelData) { int depth; int playerIndex; int pixelIndex; int bytesPerPixel = depthFrame.BytesPerPixel; PlayerDepthData[] players = new PlayerDepthData[6]; for (int row = 0; row < depthFrame.Height; row++) { for (int col = 0; col < depthFrame.Width; col++) { pixelIndex = col + (row * depthFrame.Width); depth = pixelData[pixelIndex] >> DepthImageFrame.PlayerIndexBitmaskWidth; if (depth != 0) { playerIndex = (pixelData[pixelIndex] & DepthImageFrame.PlayerIndexBitmask) - 1; if (playerIndex > -1) { if (players[playerIndex] == null) { players[playerIndex] = new PlayerDepthData(playerIndex + 1, depthFrame.Width, depthFrame.Height); } players[playerIndex].UpdateData(col, row, depth); } } } } PlayerDepthData.ItemsSource = players; }
粗体部分代码中使用了PlayerDepthData对象。CalculatePlayerSize方法遍历深度图像中的象元,然后提取游戏者索引位及其对应的深度值。算法忽略了所有深度值为0的象元以及游戏者之外的象元。对于游戏者的每一个象元,方法调用PlayerDepthData对象的UpdateData方法。处理完所有象元之后,将游戏者数组复制给名为PlayerDepthData的ItemControl对象的数据源。对游戏者宽度高度的计算封装在PlayerDepthData这一对象中。
PlayerDepthData对象的代码如下:
class PlayerDepthData { #region Member Variables private const double MillimetersPerInch = 0.0393700787; private static readonly double HorizontalTanA = Math.Tan(57.0 / 2.0 * Math.PI / 180); private static readonly double VerticalTanA = Math.Abs(Math.Tan(43.0 / 2.0 * Math.PI / 180)); private int _DepthSum; private int _DepthCount; private int _LoWidth; private int _HiWidth; private int _LoHeight; private int _HiHeight; #endregion Member Variables #region Constructor public PlayerDepthData(int playerId, double frameWidth, double frameHeight) { this.PlayerId = playerId; this.FrameWidth = frameWidth; this.FrameHeight = frameHeight; this._LoWidth = int.MaxValue; this._HiWidth = int.MinValue; this._LoHeight = int.MaxValue; this._HiHeight = int.MinValue; } #endregion Constructor #region Methods public void UpdateData(int x, int y, int depth) { this._DepthCount++; this._DepthSum += depth; this._LoWidth = Math.Min(this._LoWidth, x); this._HiWidth = Math.Max(this._HiWidth, x); this._LoHeight = Math.Min(this._LoHeight, y); this._HiHeight = Math.Max(this._HiHeight, y); } #endregion Methods #region Properties public int PlayerId { get; private set; } public double FrameWidth { get; private set; } public double FrameHeight { get; private set; } public double Depth { get { return this._DepthSum / (double)this._DepthCount; } } public int PixelWidth { get { return this._HiWidth - this._LoWidth; } } public int PixelHeight { get { return this._HiHeight - this._LoHeight; } } public string RealWidth { get { double inches = this.RealWidthInches; return string.Format("{0:0.0}mm", inches * 25.4); } } public string RealHeight { get { double inches = this.RealHeightInches; return string.Format("{0:0.0}mm", inches * 25.4); } } public double RealWidthInches { get { double opposite = this.Depth * HorizontalTanA; return this.PixelWidth * 2 * opposite / this.FrameWidth * MillimetersPerInch; } } public double RealHeightInches { get { double opposite = this.Depth * VerticalTanA; return this.PixelHeight * 2 * opposite / this.FrameHeight * MillimetersPerInch; } } #endregion Properties }
单独编写PlayerDepthData这个类的原因是封装计算逻辑。这个类有两个输入点和两个输出点。构造函数以及UpdateData方法是两个输入点。ReadlWith和RealHeight两个属性是两个输出点。这两个属性是基于上图中的公式计算得出的。公式使用平均深度值,深度数据帧的宽度和高度,和游戏者总共所占有的象元。平均深度值和所有的象元是通过参数传入到UpdateData方法中然后计算的出来的。真实的宽度和高度值是基于UpdateData方法提供的数据计算出来的。下面是我做的6个动作的不同截图,右边可以看到测量值,手上拿了键盘用来截图。
以上测量结果只是以KinectSensor能看到的部分来进行计算的。拿上图1来说。显示的高度是1563mm,宽度为622mm。这里高度存在偏差,实际高度应该是1665左右,可能是脚部和头部测量有误差。以上代码可以同时测量6个游戏者,但是由于只有我一个人,所以做了6个不同的动作,截了6次图。还可以看到一点的是,如上面所讨论的,当只有一个游戏者时,游戏者索引值不一定是从1开始,从上面6幅图可以看出,进出视野会导致游戏者索引值发生变化,值是不确定的。
在之前的例子中,我们将游戏者所属的象元用黑色显示出来,而其他的用白色显示,这样就达到了提取人物的目的。我们也可以将人物所属的象元用彩色表示,而将其他部分用白色表示。但是,有时候我们想用深度数据中游戏者所属的象元获取对应的彩色影像数据并叠加到视频图像中。这在电视制作和电影制作中很常见,这种技术叫做绿屏抠像,就是演员或者播音员站在绿色底板前,然后录完节目后,绿色背景抠出,换成其他场景,在一些科幻电影中演员不可能在实景中表演时常采用的造景手法。我们平常照证件照时,背景通常是蓝色或者红色,这样也是便于选取背景颜色方便抠图的缘故。在Kinect中我们也可以达到类似的效果。Kinect SDK使得这个很容易实现。
Note:这是现实增强的一个基本例子,现实增应用非常有趣而且能够获得非常好的用于体验。许多艺术家使用Kinect来进行现实增强交互时展览。另外,这种技术也通常作为广告和营销的工具。
前面的例子中,我们能够判断哪个像素是否有游戏者。但是这个只能对于景深数据使用。不幸的是,景深数据影像的象元不能转换到彩色影像中去,即使两者使用相同的分辨率。因为这两个摄像机位于Kinect上的不同位置,所以产生的影像不能够叠加到一起。就像人的两只眼睛一样,当你只睁开左眼看到的景象和只睁开右眼看到的景象是不一样的,人脑将这两只眼睛看到的景物融合成一幅合成的景象。
幸运的是,Kinect SDK提供了一些方法来方便我们进行这些转换,这些方法位于KinectSensor对象中,他们是MapDepthToColorImagePoint,MapDepthToSkeletonPoint,MapSkeletonPointToColor和MapSkeletonPointToDepth。在DepthImageFrame对象中这些方法的名字有点不同(MapFromSkeletonPoint,MapToColorImagePoint及MapToSkeletonPoint),但功能是相似的。在下面的例子中,我们使用MapDepthToColorImagePoint方法来将景深影像中游戏者所属的象元转换到对应的彩色影像中去。细心的读者可能会发现,没有一个方法能够将彩色影像中的象元转换到对应的景深影像中去。
创建一个新的工程,添加两个Image对象。第一个Image是背景图片。第二个Image是前景图像。在这个例子中,为了使景深影像和彩色影像尽可能的接近,我们采用轮询的方式。每一个影像都有一个Timestamp对象,我们通过比较数据帧的这个值来确定他们是否足够近。注册KinectSensor对象的AllFrameReady事件,并不能保证不同数据流产生的数据帧时同步的。这些帧不可能同时产生,但是轮询模式能够使得不同数据源产生的帧能够尽可能的够近。下面的代码展现了实现方式:
private KinectSensor _KinectDevice; private WriteableBitmap _GreenScreenImage; private Int32Rect _GreenScreenImageRect; private int _GreenScreenImageStride; private short[] _DepthPixelData; private byte[] _ColorPixelData; private bool _DoUsePolling;
private void CompositionTarget_Rendering(object sender, EventArgs e) { DiscoverKinect(); if (this.KinectDevice != null) { try { using (ColorImageFrame colorFrame = this.KinectDevice.ColorStream.OpenNextFrame(100)) { using (DepthImageFrame depthFrame = this.KinectDevice.DepthStream.OpenNextFrame(100)) { RenderGreenScreen(this.KinectDevice, colorFrame, depthFrame); } } } catch (Exception) { //Do nothing, because the likely result is that the Kinect has been unplugged. } } }private void DiscoverKinect() { if (this._KinectDevice != null && this._KinectDevice.Status != KinectStatus.Connected) { UninitializeKinectSensor(this._KinectDevice); this._KinectDevice = null; } if (this._KinectDevice == null) { this._KinectDevice = KinectSensor.KinectSensors.FirstOrDefault(x => x.Status == KinectStatus.Connected); if (this._KinectDevice != null) { InitializeKinectSensor(this._KinectDevice); } } } private void InitializeKinectSensor(KinectSensor sensor) { if (sensor != null) { sensor.DepthStream.Range = DepthRange.Default; sensor.SkeletonStream.Enable(); sensor.DepthStream.Enable(DepthImageFormat.Resolution640x480Fps30); sensor.ColorStream.Enable(ColorImageFormat.RgbResolution1280x960Fps12); DepthImageStream depthStream = sensor.DepthStream; this._GreenScreenImage = new WriteableBitmap(depthStream.FrameWidth, depthStream.FrameHeight, 96, 96, PixelFormats.Bgra32, null); this._GreenScreenImageRect = new Int32Rect(0, 0, (int)Math.Ceiling(this._GreenScreenImage.Width), (int)Math.Ceiling(this._GreenScreenImage.Height)); this._GreenScreenImageStride = depthStream.FrameWidth * 4; this.GreenScreenImage.Source = this._GreenScreenImage; this._DepthPixelData = new short[this._KinectDevice.DepthStream.FramePixelDataLength]; this._ColorPixelData = new byte[this._KinectDevice.ColorStream.FramePixelDataLength]; if (!this._DoUsePolling) { sensor.AllFramesReady += KinectDevice_AllFramesReady; } sensor.Start(); } } private void UninitializeKinectSensor(KinectSensor sensor) { if (sensor != null) { sensor.Stop(); sensor.ColorStream.Disable(); sensor.DepthStream.Disable(); sensor.SkeletonStream.Disable(); sensor.AllFramesReady -= KinectDevice_AllFramesReady; } }
以上代码有三个地方加粗。第一地方引用了RenderGreenScreen方法。第二个和第三个地方我们初始化了彩色和景深数据流。当在两个图像之间转换时,将彩色图形的分辨率设成景深数据的两倍能够得到最好的转换效果。
RenderGreenScreen方法中执行实际的转换操作。首先通过移除没有游戏者的象元创建一个新的彩色影像。算法遍历景深数据的每一个象元,然后判断游戏者索引是否有有效值。然后获取景深数据中游戏者所属象元对应的彩色图像上的象元,将获取到的象元存放在象元数组中。代码如下:
private void RenderGreenScreen(KinectSensor kinectDevice, ColorImageFrame colorFrame, DepthImageFrame depthFrame) { if (kinectDevice != null && depthFrame != null && colorFrame != null) { int depthPixelIndex; int playerIndex; int colorPixelIndex; ColorImagePoint colorPoint; int colorStride = colorFrame.BytesPerPixel * colorFrame.Width; int bytesPerPixel = 4; byte[] playerImage = new byte[depthFrame.Height * this._GreenScreenImageStride]; int playerImageIndex = 0; depthFrame.CopyPixelDataTo(this._DepthPixelData); colorFrame.CopyPixelDataTo(this._ColorPixelData); for (int depthY = 0; depthY < depthFrame.Height; depthY++) { for (int depthX = 0; depthX < depthFrame.Width; depthX++, playerImageIndex += bytesPerPixel) { depthPixelIndex = depthX + (depthY * depthFrame.Width); playerIndex = this._DepthPixelData[depthPixelIndex] & DepthImageFrame.PlayerIndexBitmask; if (playerIndex != 0) { colorPoint = kinectDevice.MapDepthToColorImagePoint(depthFrame.Format, depthX, depthY, this._DepthPixelData[depthPixelIndex], colorFrame.Format); colorPixelIndex = (colorPoint.X * colorFrame.BytesPerPixel) + (colorPoint.Y * colorStride); playerImage[playerImageIndex] = this._ColorPixelData[colorPixelIndex]; //Blue playerImage[playerImageIndex + 1] = this._ColorPixelData[colorPixelIndex + 1]; //Green playerImage[playerImageIndex + 2] = this._ColorPixelData[colorPixelIndex + 2]; //Red playerImage[playerImageIndex + 3] = 0xFF; //Alpha } } } this._GreenScreenImage.WritePixels(this._GreenScreenImageRect, playerImage, this._GreenScreenImageStride, 0); } }
PlayerImage位数组存储了所有属于游戏者的彩色影像象元。从景深数据对应位置获取到的彩色影像象元的大小和景深数据象元大小一致。与景深数据每一个象元占两个字节不同。彩色影像数据每个象元占4个字节,蓝绿红以及Alpha值各占一个字节,在本例中Alpha值很重要,它用来确定每个象元的透明度,游戏者所拥有的象元透明度设置为255(0xFF)不透明而其他物体则设置为0,表示透明。
MapDepthToColorImagePoint方法接受景深象元位置以及深度值,返回对应的对应彩色影像中象元的位置。剩下的代码获取游戏者在彩色影像中的象元并将其存储到PlayerImage数组中。当处理完所有的景深数据象元后,代码更新Image的数据源。运行程序后,需要站立一段时间后人物才能够显示出来,如果移动太快,可能出来不了,因为景深数据和彩色数据不能够对齐,可以看到任务轮廓有一些锯齿和噪声,但要处理这些问题还是有点麻烦的,它需要对象元进行平滑。要想获得最好的效果,可以将多帧彩色影像合称为一帧。运行程序后结果如下图,端了个键盘,人有点挫:
本文首先介绍了关于景深数据的简单图像数据,包括景深数据的直方图显示以及一些图像处理相关的算法,然后介绍了景深数据中的游戏者索引位,借助索引位,我们实现了人物宽度和高度的计算,最后借助景深数据结合彩色影像数据,将景深影像和视频图像进行了叠加。
至此,景深数据处理介绍完了,后面将会开始介绍Kinect的骨骼追踪技术,敬请期待。
Kinect产生的景深数据作用有限,要利用Kinect创建真正意义上交互,有趣和难忘的应用,还需要除了深度数据之外的其他数据。这就是骨骼追踪技术的初衷,骨骼追踪技术通过处理景深数据来建立人体各个关节的坐标,骨骼追踪能够确定人体的各个部分,如那部分是手,头部,以及身体。骨骼追踪产生X,Y,Z数据来确定这些骨骼点。在上文中,我们讨论了景深图像处理的一些技术。骨骼追踪系统采用的景深图像处理技术使用更复杂的算法如矩阵变换,机器学习及其他方式来确定骨骼点的坐标。
本文首先用一个例子展示骨骼追踪系统涉及的主要对象,然后在此基础上详细讨论骨骼追踪中所涉及的对象模型。
本节将会创建一个应用来将获取到的骨骼数据绘制到UI界面上来。在开始编码前,首先来看看一些基本的对象以及如何从这些对象中如何获取骨骼数据。在进行数据处理之前了解数据的格式也很有必要。这个例子很简单明了,只需要骨骼数据对象然后将获取到的数据绘制出来。
彩色影像数据,景深数据分别来自ColorImageSteam和DepthImageStream,同样地,骨骼数据来自SkeletonStream。访问骨骼数据和访问彩色影像数据、景深数据一样,也有事件模式和 “拉”模式两种方式。在本例中我们采用基于事件的方式,因为这种方式简单,代码量少,并且是一种很普通基本的方法。KinectSensor对象有一个名为SkeletonFrameReady事件。当SkeletonStream中有新的骨骼数据产生时就会触发该事件。通过AllFramesReady事件也可以获取骨骼数据。在下一节中,我们将会详细讨论骨骼追踪对象模型,现在我们只展示如何从SkeletonStream流中获取骨骼数据。SkeletonStream产生的每一帧数据都是一个骨骼对象集合。每一个骨骼对象包含有描述骨骼位置以及骨骼关节的数据。每一个关节有一个唯一标示符如头(head)、肩(shoulder)、肘(dlbow)等信息和3D向量数据。
现在来写代码。首先创建一个新的wpf工程文件,添加Microsoft.Kinect.dll。添加基本查找和初始化传感器的代码,这些代码参考之前的文章。在开始启动传感器之前,初始化SkeletonStream数据流,并注册KinectSensor对象的SkeletonFrameReady事件,这个例子没有使用彩色摄像机和红外摄像机产生的数据,所以不需要初始化这些数据流。UI界面采用默认的,将Grid的名称改为LayoutRoot,之后就再Grid里面绘制。代码如下:
<Window x:Class="KinectSkeletonTracking.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="MainWindow" Height="350" Width="525"> <Grid x:Name="LayoutRoot" Background="White"> Grid> Window>
后台逻辑代码如下:
private KinectSensor kinectDevice; private readonly Brush[] skeletonBrushes;//绘图笔刷 private Skeleton[] frameSkeletons; public MainWindow() { InitializeComponent(); skeletonBrushes = new Brush[] { Brushes.Black, Brushes.Crimson, Brushes.Indigo, Brushes.DodgerBlue, Brushes.Purple, Brushes.Pink }; KinectSensor.KinectSensors.StatusChanged += KinectSensors_StatusChanged; this.KinectDevice = KinectSensor.KinectSensors.FirstOrDefault(x => x.Status == KinectStatus.Connected); } public KinectSensor KinectDevice { get { return this.kinectDevice; } set { if (this.kinectDevice != value) { //Uninitialize if (this.kinectDevice != null) { this.kinectDevice.Stop(); this.kinectDevice.SkeletonFrameReady -= KinectDevice_SkeletonFrameReady; this.kinectDevice.SkeletonStream.Disable(); this.frameSkeletons = null; } this.kinectDevice = value; //Initialize if (this.kinectDevice != null) { if (this.kinectDevice.Status == KinectStatus.Connected) { this.kinectDevice.SkeletonStream.Enable(); this.frameSkeletons = new Skeleton[this.kinectDevice.SkeletonStream.FrameSkeletonArrayLength]; this.kinectDevice.SkeletonFrameReady += KinectDevice_SkeletonFrameReady; this.kinectDevice.Start(); } } } } } private void KinectSensors_StatusChanged(object sender, StatusChangedEventArgs e) { switch (e.Status) { case KinectStatus.Initializing: case KinectStatus.Connected: case KinectStatus.NotPowered: case KinectStatus.NotReady: case KinectStatus.DeviceNotGenuine: this.KinectDevice = e.Sensor; break; case KinectStatus.Disconnected: //TODO: Give the user feedback to plug-in a Kinect device. this.KinectDevice = null; break; default: //TODO: Show an error state break; } }
以上代码中,值得注意的是frameSkeletons数组以及该数组如何在流初始化时进行内存分配的。Kinect能够追踪到的骨骼数量是一个常量。这使得我们在整个应用程序中能够一次性的为数组分配内存。为了方便,Kinect SDK在SkeletonStream对象中定义了一个能够追踪到的骨骼个数常量FrameSkeletonArrayLength,使用这个常量可以方便的对数组进行初始化。代码中也定义了一个笔刷数组,这些笔刷在绘制骨骼时对多个游戏者可以使用不同的颜色进行绘制。也可以将笔刷数组中的颜色设置为自己喜欢的颜色。
下面的代码展示了SkeletonFrameReady事件的响应方法,每一次事件被激发时,通过调用事件参数的OpenSkeletonFrame方法就能够获取当前的骨骼数据帧。剩余的代码遍历骨骼数据帧的Skeleton数组frameSkeletons,在UI界面通过关节点将骨骼连接起来,用一条直线代表一根骨骼。UI界面简单,将Grid元素作为根结点,并将其背景设置为白色。
private void KinectDevice_SkeletonFrameReady(object sender, SkeletonFrameReadyEventArgs e) { using (SkeletonFrame frame = e.OpenSkeletonFrame()) { if (frame != null) { Polyline figure; Brush userBrush; Skeleton skeleton; LayoutRoot.Children.Clear(); frame.CopySkeletonDataTo(this.frameSkeletons); for (int i = 0; i < this.frameSkeletons.Length; i++) { skeleton = this.frameSkeletons[i]; if (skeleton.TrackingState == SkeletonTrackingState.Tracked) { userBrush = this.skeletonBrushes[i % this.skeletonBrushes.Length]; //绘制头和躯干 figure = CreateFigure(skeleton, userBrush, new[] { JointType.Head, JointType.ShoulderCenter, JointType.ShoulderLeft, JointType.Spine, JointType.ShoulderRight, JointType.ShoulderCenter, JointType.HipCenter }); LayoutRoot.Children.Add(figure); figure = CreateFigure(skeleton, userBrush, new[] { JointType.HipLeft, JointType.HipRight }); LayoutRoot.Children.Add(figure); //绘制作腿 figure = CreateFigure(skeleton, userBrush, new[] { JointType.HipCenter, JointType.HipLeft, JointType.KneeLeft, JointType.AnkleLeft, JointType.FootLeft }); LayoutRoot.Children.Add(figure); //绘制右腿 figure = CreateFigure(skeleton, userBrush, new[] { JointType.HipCenter, JointType.HipRight, JointType.KneeRight, JointType.AnkleRight, JointType.FootRight }); LayoutRoot.Children.Add(figure); //绘制左臂 figure = CreateFigure(skeleton, userBrush, new[] { JointType.ShoulderLeft, JointType.ElbowLeft, JointType.WristLeft, JointType.HandLeft }); LayoutRoot.Children.Add(figure); //绘制右臂 figure = CreateFigure(skeleton, userBrush, new[] { JointType.ShoulderRight, JointType.ElbowRight, JointType.WristRight, JointType.HandRight }); LayoutRoot.Children.Add(figure); } } } } }
循环遍历frameSkeletons对象,每一次处理一个骨骼,在处理之前需要判断是否是一个追踪好的骨骼,可以使用Skeleton对象的TrackingState属性来判断,只有骨骼追踪引擎追踪到的骨骼我们才进行绘制,忽略哪些不是游戏者的骨骼信息即过滤掉那些TrackingState不等于SkeletonTrackingState.Tracked的骨骼数据。Kinect能够探测到6个游戏者,但是同时只能够追踪到2个游戏者的骨骼关节位置信息。在后面我们将会详细讨论TrackingState这一属性。
处理骨骼数据相对简单,首先,我们根Kinect追踪到的游戏者的编号,选择一种颜色笔刷。然后利用这只笔刷绘制曲线。CreateFigure方法为每一根骨骼绘制一条直线。GetJointPoint方法在绘制骨骼曲线中很关键。该方法以关节点的三维坐标作为参数,然后调用KinectSensor对象的MapSkeletonPointToDepth方法将骨骼坐标转换到深度影像坐标上去。后面我们将会讨论为什么需要这样转换以及如何定义坐标系统。现在我们只需要知道的是,骨骼坐标系和深度坐标及彩色影像坐标系不一样,甚至和UI界面上的坐标系不一样。在开发Kinect应用程序中,从一个坐标系转换到另外一个坐标系这样的操作非常常见,GetJointPoint方法的目的就是将骨骼关节点的三维坐标转换到UI绘图坐标系统,返回该骨骼关节点在UI上的位置。下面的代码展示了CreateFigure和GetJointPoint这两个方法。
private Polyline CreateFigure(Skeleton skeleton, Brush brush, JointType[] joints) { Polyline figure = new Polyline(); figure.StrokeThickness = 8; figure.Stroke = brush; for (int i = 0; i < joints.Length; i++) { figure.Points.Add(GetJointPoint(skeleton.Joints[joints[i]])); } return figure; } private Point GetJointPoint(Joint joint) { DepthImagePoint point = this.KinectDevice.MapSkeletonPointToDepth(joint.Position, this.KinectDevice.DepthStream.Format); point.X *= (int)this.LayoutRoot.ActualWidth / KinectDevice.DepthStream.FrameWidth; point.Y *= (int)this.LayoutRoot.ActualHeight / KinectDevice.DepthStream.FrameHeight; return new Point(point.X, point.Y); }
值得注意的是,骨骼关节点的三维坐标中我们舍弃了Z值,只用了X,Y值。Kinect好不容易为我们提供了每一个节点的深度数据(Z值)而我们却没有使用,这看起来显得很浪费。其实不是这样的,我们使用了节点的Z值,只是没有直接使用,没有在UI界面上展现出来而已。在坐标空间转换中是需要深度数据的。可以试试在GetJointPoint方法中,将joint的Position中的Z值改为0,然后再调用MapSkeletonPointToDepth方法,你会发现返回的对象中x和y值均为0,可以试试,将图像以Z值进行等比缩放,可以发现图像的大小是和Z值(深度)成反的。也就是说,深度值越小,图像越大,即人物离Kinect越近,骨骼数据越大。
运行程序,会得到如下骨骼图像,这个是手握键盘准备截图的姿势。一开始可能需要调整一些Form窗体的大小。程序会为每一个游戏者以一种颜色绘制骨骼图像,可以试着在Kinect前面移动,可以看到骨骼图像的变化,也可以走进然后走出图像以观察颜色的变化。仔细观察有时候可以看到绘图出现了一些奇怪的图案,在讨论完骨骼追踪相关的API之后,就会明白这些现象出现的原因了。
Kinect SDK中骨骼追踪有一些和其他对象不一样的对象结构和枚举。在SDK中骨骼追踪相关的内容几乎占据了三分之一的内容,可见Kinect中骨骼追踪技术的重要性。下图展示了骨骼追踪系统中涉及到的一些主要的对象模型。有四个最主要的对象,他们是SkeletonStream,SkeletonFrame,Skeleton和Joint。下面将详细介绍这四个对象。
SkeletonStream对象产生SkeletonFrame。从SkeletonStream获取骨骼帧数据和从ColorStream及DepthStream中获取数据类似。可以注册SkeletonFrameReady事件或者AllFramesReady事件通过事件模型来获取数据,或者是使用OpenNextFrame方法通过“拉”模型来获取数据。不能对同一个SkeletonStream同时使用这两种模式。如果注册了SkeletonFrameReady事件然后又调用OpenNextFrame方法将会返回一个InvalidOperationException异常。
SkeletonStream的启动和关闭
除非启动了SkeletonStream对象,否则,不会产生任何数据,默认情况下,SkeletonStream对象是关闭的。要使SkeletonStream产生数据,必须调用对象的Enabled方法。相反,调用Disable方法能够使SkeletonStream对象暂停产生数据。SkeletonStream有一个IsEnabled方法来描述当前SkeletonStream对象的状态。只有SkeletonStream对象启动了,KinectSensor对象的SkeletonFrameReady事件才能被激活。如果要使用“拉”模式来获取数据SkeletonStream也必须启动后才能调用OpenNextFrame方法。否则也会抛出InvalidOperationException异常。
一般地在应用程序的声明周期中,一旦启动了SkeletonStream对象,一般会保持启动状态。但是在有些情况下,我们希望关闭SkeletonStream对象。比如在应用程序中使用多个Kinect传感器时。只有一个Kinect传感器能够产生骨骼数据,这也意味着,即使使用多个Kinect传感器,同时也只能追踪到两个游戏者的骨骼数据信息。在应用程序执行的过程中,有可能会关闭某一个Kinect传感器的SkeletonStream对象而开启另一个Kinect传感器的SkeletonStream对象。
另一个有可能关闭骨骼数据产生的原因是出于性能方面的考虑,骨骼数据处理是很耗费计算性能的操作。打开骨骼追踪是可以观察的到CPU的占用率明显增加。当不需要骨骼数据时,关闭骨骼追踪很有必要。例如,在有些游戏场景中可能在展现一些动画效果或者播放视频,在这个动画效果或者视频播放时,停止骨骼追踪可能可以使得游戏更加流畅。
当然关闭SkeletonStream也有一些副作用。当SkeletonStream的状态发生改变时,所有的数据产生都会停止和从新开始。SkeletonStream的状态改变会使传感器重新初始化,将TimeStamp和FrameNumber重置为0。在传感器重新初始化时也有几毫秒的延迟。
平滑化
在前面的例子中,会注意到,骨骼运动会呈现出跳跃式的变化。有几个原因会导致出现这一问题,可能是应用程序的性能,游戏者的动作不够连贯,也有可能是Kinect硬件的性能问题。骨骼关节点的相对位置可能在帧与帧之间变动很大,这回对应用程序产生一些负面的影像。除了会影像用户体验和不愉快意外,也可能会导致用户的形象或者手的颤动抽搐而使用户感到迷惑。
SkeletonStream对象有一种方法能够解决这个问题。他通过将骨骼关节点的坐标标准化来减少帧与帧之间的关节点位置差异。当初始化SkeletonStream对象调用重载的Enable方法时可以传入一个TransformSmoothParameters参数。SkeletonStream对象有两个与平滑有关只读属性:IsSmoothingEnabled和SmoothParameters。当调用Enable方法传入了TransformSmoothParameters是IsSmoothingEnabled返回true而当使用默认的不带参数的Enable方法初始化时,IsSmoothingEnabled对象返回false。SmoothParameters属性用来存储定义平滑参数。TransformSmoothParameters这个结构定义了一些属性:
对骨骼关节点进行平滑处理会产生性能开销。平滑处理的越多,性能消耗越大。设置平滑参数没有经验可以遵循。需要不断的测试和调试已达到最好的性能和效果。在程序运行的不同阶段,可能需要设置不同的平滑参数。
Note:SDK使用霍尔特指数平滑(Holt Double Exponential Smoothing)来对减少关节点的抖动。指数平滑数据处理与时间有关。骨骼数据是时间序列数据,因为骨骼引擎会以某一时间间隔不断产生一帧一帧的骨骼数据。平滑处理使用统计方法进行滑动平均,这样能够减少时间序列数据中的噪声和极值。类似的处理方法最开始被用于金融市场和经济数据的预测。
骨骼追踪对象选择
默认情况下,骨骼追踪引擎会对视野内的所有活动的游戏者进行追踪。但只会选择两个可能的游戏者产生骨骼数据,大多数情况下,这个选择过程不确定。如果要自己选择追踪对象,需要使用AppChoosesSkeletons属性和ChooseSkeletons方法。 默认情况下AppChoosesSkeleton属性为false,骨骼追踪引擎追踪所有可能的最多两个游戏者。要手动选择追踪者,需要将AppChoosesSkeleton设置为true,并调用ChooseSkeletons方法,传入TrackingIDs已表明需要追踪那个对象。ChooseSkeletons方法接受一个,两个或者0个TrackingIDs。当ChooseSkeletons方法传入0个参数时,引擎停止追踪骨骼信息。有一些需要注意的地方:
SkeletonStream产生SkeletonFrame对象。可以使用事件模型从事件参数中调用OpenSkeletonFrame方法来获取SkeletonFrame对象,或者采用”拉”模型调用SkeletonStream的OpenNextFrame来获取SkeletonFrame对象。SkeletonFrame对象会存储骨骼数据一段时间。同以通过调用SkeletonFrame对象的CopySkeletonDataTo方法将其保存的数据拷贝到骨骼对象数组中。SkeletonFrame对象有一个SkeletonArrayLength的属性,这个属性表示追踪到的骨骼信息的个数。
时间标记字段
SkeletonFrame的FrameNumber和Timestamp字段表示当前记录中的帧序列信息。FrameNumber是景深数据帧中的用来产生骨骼数据帧的帧编号。帧编号通常是不连续的,但是之后的帧编号一定比之前的要大。骨骼追踪引擎在追踪过程中可能会忽略某一帧深度数据,这跟应用程序的性能和每秒产生的帧数有关。例如,在基于事件获取骨骼帧信息中,如果事件中处理帧数据的时间过长就会导致这一帧数据还没有处理完就产生了新的数据,那么这些新的数据就有可能被忽略了。如果采用“拉”模型获取帧数据,那么取决于应用程序设置的骨骼引擎产生数据的频率,即取决于深度影像数据产生骨骼数据的频率。
Timestap字段记录字Kinect传感器初始化以来经过的累计毫秒时间。不用担心FrameNumber或者Timestamp字段会超出上限。FrameNumber是一个32位的整型,Timestamp是64位整型。如果应用程序以每秒30帧的速度产生数据,应用程序需要运行2.25年才会达到FrameNumber的限,此时Timestamp离上限还很远。另外在Kinect传感器每一次初始化时,这两个字段都会初始化为0。可以认为FrameNumber和Timestamp这两个值是唯一的。
这两个字段在分析处理帧序列数据时很重要,比如进行关节点值的平滑,手势识别操作等。在多数情况下,我们通常会处理帧时间序列数据,这两个字段就显得很有用。目前SDK中并没有包含手势识别引擎。在未来SDK中加入手势引擎之前,我们需要自己编写算法来对帧时间序列进行处理来识别手势,这样就会大量依赖这两个字段。
帧描述信息
FloorClipPlane字段是一个有四个元素的元组Tuple
Skeleton类定义了一系列字段来描述骨骼信息,包括描述骨骼的位置以及骨骼中关节可能的位置信息。骨骼数据可以通过调用SkeletonFrame对象的CopySkeletonDataTo方法获得Skeleton数组。CopySkeletonDataTo方法有一些不可预料的行为,可能会影响内存使用和其引用的骨骼数组对象。产生的每一个骨骼数组对象数组都是唯一的。以下面代码为例:
Skeleton[] skeletonA = new Skeleton[frame.SkeletonArrayLength]; Skeleton[] skeletonB = new Skeleton[frame.SkeletonArrayLength]; frame.CopySkeletonDataTo(skeletonA); frame.CopySkeletonDataTo(skeletonB); Boolean resultA = skeletonA[0] == skeletonB[0];//false Boolean resultB = skeletonA[0].TrackingId == skeletonB[0].TrackingId;//true
上面的代码可以看出,使用CopySkeletonDataTo是深拷贝对象,会产生两个不同的Skeleton数组对象。
TrackingID
骨骼追踪引擎对于每一个追踪到的游戏者的骨骼信息都有一个唯一编号。这个值是整型,他会随着新的追踪到的游戏者的产生添加增长。和之前帧序号一样,这个值并不是连续增长的,但是能保证的是后面追踪到的对象的编号要比之前的编号大。另外,这个编号的产生是不确定的。如果骨骼追踪引擎失去了对游戏者的追踪,比如说游戏者离开了Kinect的视野,那么这个对应的唯一编号就会过期。当Kinect追踪到了一个新的游戏者,他会为其分配一个新的唯一编号,编号值为0表示这个骨骼信息不是游戏者的,他在集合中仅仅是一个占位符。应用程序使用TrackingID来指定需要骨骼追踪引擎追踪那个游戏者。调用SkeletonStream对象的ChooseSkeleton能以初始化对指定游戏这的追踪。
TrackingState
该字段表示当前的骨骼数据的状态。下表展示了SkeletonTrackingState枚举的可能值机器含义:
Position
Position一个SkeletonPoint类型的字段,代表所有骨骼的中间点。身体的中间点和脊柱关节的位置相当。改字段提供了一个最快且最简单的所有视野范围内的游戏者位置的信息,而不管其是否在追踪状态中。在一些应用中,如果不用关心骨骼中具体的关节点的位置信息,那么该字段对于确定游戏者的位置状态已经足够。该字段对于手动选择要追踪的游戏者(SkeletonStream.ChooseSkeleton)也是一个参考。例如,应用程序可能需要追踪距离Kinect最近的且处于追踪状态的游戏者,那么该字段就可以用来过滤掉其他的游戏者。
ClippedEdges
ClippedEdges字段用来描述追踪者的身体哪部分位于Kinect的视野范围外。他大体上提供了一个追踪这的位置信息。使用这一属性可以通过程序调整Kinect摄像头的俯仰角或者提示游戏者让其返回到视野中来。该字段类型为FrameEdges,他是一个枚举并且有一个FlagsAtrribute自定义属性修饰。这意味着ClippedEdges字段可以一个或者多个FrameEdges值。下面列出了FrameEdges的所有可能的值。
当游戏者身体的某一部分超出Kinect视场范围时,就需要对骨骼追踪产生的数据进行某些改进,因为某些部位的数据可能追踪不到或者不准确。最简单的解决办法就是提示游戏者身体超出了Kinect的某一边界范围让游戏者回到视场中来。例如,有时候应用程序可能不关心游戏者超出Kinect视场下边界的情况,但是如果超出了左边界或者右边界时就会对应用产生影响,这是可以针对性的给游戏者一些提示。另一个解决办法是调整Kinect设备的物理位置。Kinect底座上面有一个小的马达能够调整Kinect的俯仰角度。俯仰角度可以通过更改KinectSensor对象的ElevationAnagle属性来进行调整。如果应用程序对于游戏者脚部动作比较关注,那么通过程序调整Kinect的俯仰角能够决绝脚部超出视场下界的情况。
ElevationAnagle以度为单位。KinectSensor的MaxElevationAngle和MinElevationAngle确定了可以调整角度的上下界。任何将ElevationAngle设置超出上下界的操作将会掏出ArgumentOutOfRangeExcepthion异常。微软建议不要过于频繁重复的调整俯仰角以免损坏马达。为了使得开发这少犯错误和保护马达,SDK限制了每秒能调整的俯仰角的值。SDK限制了在连续15次调整之后要暂停20秒。
Joints
每一个骨骼对象都有一个Joints字段。该字段是一个JointsCollection类型,它存储了一些列的Joint结构来描述骨骼中可追踪的关节点(如head,hands,elbow等等)。应用程序使用JointsCollection索引获取特定的关节点,并通过节点的JointType枚举来过滤指定的关节点。即使Kinect视场中没有游戏者Joints对象也被填充。
骨骼追踪引擎能够跟踪和获取每个用户的近20个点或者关节点信息。追踪的数据以关节点数据展现,它有三个属性。JointType属性是一个枚举类型。下图描述了可追踪的所有关节点。
每一个关节点都有类型为SkeletonPoint的Position属性,他通过X,Y,Z三个值来描述关节点的控件位置。X,Y值是相对于骨骼平面空间的位置,他和深度影像,彩色影像的空间坐标系不一样。KinectSnesor对象有一些列的坐标转换方法,可以将骨骼坐标点转换到对应的深度数据影像中去。最后每一个Skeleton对象还有一个JointTrackingState属性,他描述了该关节点的跟踪状态及方式,下面列出了所有的可能值。
本文首先通过一个例子展示骨骼追踪系统所涉及的主要对象,并将骨骼数据在UI界面上进行了绘制,在此基础上详细介绍了骨骼追踪对象模型中涉及到的主要对象,方法和属性。SDK中骨骼追踪占了大概三分之一的内容,所以熟悉这些对象对于开发基于Kinect应用程序至关重要。
相信大家在小时候都做过一个数学题目,就是在纸上将一些列数字(用一个圆点表示)从小到大用线连起来。游戏逻辑很简单,只不过我们在这里要实现的是动动手将这些点连起来,而不是用笔或者鼠标。
这个小游戏显然没有第一人称射击游戏那样复杂,但如果能够做成那样更好。我们要使用骨骼追踪引擎来收集游戏者的关节数据,执行操作并渲染UI界面。这个小游戏展示了自然用户界面(Natural User Interface,NUI)的理念,这正是基于Kinect开发的常见交互界面,就是手部跟踪。这个连线小游戏没有仅仅用到了WPF的绘图功能,没有好看的图片和动画效果,这些以后可以逐步添加。
在开始写代码之前,需要明确定义我们的游戏目标。连线游戏是一个智力游戏,游戏者需要将数字从小到大连起来。程序可以自定义游戏上面的数字和位置(合称一个关卡)。每一个关卡包括一些列的数字(以点表示)及其位置。我们要创建一个DotPuzzle类来管理这些点对象的集合。可能一开始不需要这个类,仅仅需要一个集合就可以,但是为了以后方便添加其他功能,使用类更好一点。这些点在程序中有两个地方需要用到,一个是最开始的时候在界面上绘制关卡,其次是判断用户是否碰到了这些点。
当用户碰到点时,程序开始绘制,直线以碰到的点为起始点,直线的终点位用户碰到的下一个点。然后下一个点又作为另一条直线的起点,依次类推。直到最后一个点和第一个点连起来,这样关卡算是通过了,游戏结束。
游戏规则定义好了之后,我们就可以开始编码了,随着这个小游戏的开发进度,可能会添加一些其他的新功能。一开始,建一个WPF工程,然后引用Microsoft.Kinect.dll,和之前的项目一样,添加发现和初始化Kinect传感器的代码。然后注册KinectSensor对象的SkeletonFrameReady事件。
游戏界面代码如下,有几个地方需要说明一下。Polyline对象用来表示点与点之间的连线。当用户在点和点之间移动手时,程序将点添加到Polyline对象中。PuzzleBoardElement Canvas对象用来作为UI界面上所有点的容器。Grid对象下面的Canvas的顺序是有意这样排列的,我们使用另外一个GameBoardElement Canvas对象来存储手势,以Image来表示,并且能够保证这一层总是在点图层之上。 将每一类对象放在各自层中的另外一个好处是重新开始一个新的游戏变得很容易,只需要将PuzzleBoardElement节点下的所有子节点清除,CrayonElement元素和其他的UI对象不会受到影响。
Viewbox和Grid对象对于UI界面很重要。如上一篇文章中讨论的,骨骼节点数据是基于骨骼空间的。这意味着我们要将骨骼向量转化到UI坐标系中来才能进行绘制。我们将UI控件硬编码,不允许它随着UI窗体的变化而浮动。Grid节点将UI空间大小定义为1920*1200。通常这个是显示器的全屏尺寸,而且他和深度影像数据的长宽比是一致的。这能够使得坐标转换更加清楚而且能够有更加流畅的手势移动体验。
<Window x:Class="KinectDrawDotsGame.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="MainWindow" Height="600" Width="800" Background="White"> <Viewbox> <Grid x:Name="LayoutRoot" Width="1920" Height="1200"> <Polyline x:Name="CrayonElement" Stroke="Black" StrokeThickness="3" /> <Canvas x:Name="PuzzleBoardElement" /> <Canvas x:Name="GameBoardElement"> <Image x:Name="HandCursorElement" Source="Images/hand.png" Width="75" Height="75" RenderTransformOrigin="0.5,0.5"> <Image.RenderTransform> <TransformGroup> <ScaleTransform x:Name="HandCursorScale" ScaleX="1" /> TransformGroup> Image.RenderTransform> Image> Canvas> Grid> Viewbox> Window>
硬编码UI界面也能够简化开发过程,能够使得从骨骼坐标向UI坐标的转化更加简单和快速,只需要几行代码就能完成操作。况且,如果不应编码,相应主UI窗体大小的改变将会增加额外的工作量。通过将Grid嵌入Viewbox节点来让WPF来帮我们做缩放操作。最后一个UI元素是Image对象,他表示手的位置。在这个小游戏中,我们使用这么一个简单的图标代表手。你可以选择其他的图片或者直接用一个Ellipse对象来代替。本游戏中图片使用的是右手。在游戏中,用户可以选择使用左手或者右手,如果用户使用左手,我们将该图片使用ScaleTransform变换,使得变得看起来像右手。
游戏者使用手进行交互,因此准确判断是那只手以及手的位置对于基于Kinect开发的应用程序显得至关重要。手的位置及动作是手势识别的基础。追踪手的运动是从Kinect获取数据的最重要用途。在这个应用中,我们将忽视其他关节点信息。
小时候,我们做这中连线时一般会用铅笔或者颜料笔,然后用手控制铅笔或则颜料笔进行连线。我们的这个小游戏颠覆了这种方式,我们的交互非常自然,就是手。这样有比较好的沉浸感,使得游戏更加有趣。当然,开发基于Kinect的应用程序这种交互显得自然显得至关重要。幸运的是,我们只需要一点代码就能实现这一点。
在应用程序中可能有多个游戏者,我们设定,不论那只手离Kinect最近,我们使用距离Kinect最近的那个游戏者的那只手作为控制程序绘图的手。当然,在游戏中,任何时候用户可以选择使用左手还是右手,这会使得用户操作起来比较舒服,SkeletonFrameReady代码如下:
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); Skeleton[] dataSet2 = new Skeleton[this.frameSkeletons.Length]; frame.CopySkeletonDataTo(dataSet2); if (skeleton == null) { HandCursorElement.Visibility = Visibility.Collapsed; } else { Joint primaryHand = GetPrimaryHand(skeleton); TrackHand(primaryHand); TrackPuzzle(primaryHand.Position); } } } }private static Skeleton GetPrimarySkeleton(Skeleton[] skeletons) { Skeleton skeleton = null; if (skeletons != null) { //查找最近的游戏者 for (int i = 0; i < skeletons.Length; i++) { if (skeletons[i].TrackingState == SkeletonTrackingState.Tracked) { if (skeleton == null) { skeleton = skeletons[i]; } else { if (skeleton.Position.Z > skeletons[i].Position.Z) { skeleton = skeletons[i]; } } } } } return skeleton; }
每一次事件执行时,我们查找第一个合适的游戏者。程序不会锁定某一个游戏者。如果有两个游戏者,那么靠Kinect最近的那个会是活动的游戏者。这就是GetPrimarySkeleton的功能。如果没有活动的游戏者,手势图标就隐藏。否则,我们使用活动游戏者离Kinect最近的那只手作为控制。查找控制游戏手的代码如下:
private static Joint GetPrimaryHand(Skeleton skeleton) { Joint primaryHand = new Joint(); if (skeleton != null) { primaryHand = skeleton.Joints[JointType.HandLeft]; Joint righHand = skeleton.Joints[JointType.HandRight]; if (righHand.TrackingState != JointTrackingState.NotTracked) { if (primaryHand.TrackingState == JointTrackingState.NotTracked) { primaryHand = righHand; } else { if (primaryHand.Position.Z > righHand.Position.Z) { primaryHand = righHand; } } } } return primaryHand; }
优先选择的是距离Kinect最近的那只手。但是,代码不单单是比较左右手的Z值来判断选择Z值小的那只手,如前篇文章讨论的,Z值为0表示该点的深度信息不能确定。所以,我们在进行比较之前需要进行验证,检查每一个节点的TrackingState状态。左手是默认的活动手,除非游戏者是左撇子。右手必须显示的追踪,或者被计算认为离Kinect更近。在操作关节点数据时,一定要检查TrackingState的状态,否则会得到一些异常的位置信息,这样会导致UI绘制错误或者是程序异常。
知道了哪只手是活动手后,下一步就是在界面上更新手势图标的位置了。如果手没有被追踪,隐藏图标。在一些比较专业的应用中,隐藏手势图标可以做成一个动画效果,比如淡入或者放大然后消失。在这个小游戏中只是简单的将其状态设置为不可见。在追踪手部操作时,确保手势图标可见,并且设定在UI上的X,Y位置,然后根据是左手还是右手确定UI界面上要显示的手势图标,然后更新。计算并确定手在UI界面上的位置可能需要进一步检验,这部分代码和上一篇文章中绘制骨骼信息类似。后面将会介绍空间坐标转换,现在只需要了解的是,获取的手势值是在骨骼控件坐标系中,我们需要将手在骨骼控件坐标系统中的位置转换到对于的UI坐标系统中去。
private void TrackHand(Joint hand) { if (hand.TrackingState == JointTrackingState.NotTracked) { HandCursorElement.Visibility = Visibility.Collapsed; } else { HandCursorElement.Visibility = Visibility.Visible; DepthImagePoint point = this.kinectDevice.MapSkeletonPointToDepth(hand.Position, this.kinectDevice.DepthStream.Format); point.X = (int)((point.X * LayoutRoot.ActualWidth / kinectDevice.DepthStream.FrameWidth) - (HandCursorElement.ActualWidth / 2.0)); point.Y = (int)((point.Y * LayoutRoot.ActualHeight / kinectDevice.DepthStream.FrameHeight) - (HandCursorElement.ActualHeight / 2.0)); Canvas.SetLeft(HandCursorElement, point.X); Canvas.SetTop(HandCursorElement, point.Y); if (hand.JointType == JointType.HandRight) { HandCursorScale.ScaleX = 1; } else { HandCursorScale.ScaleX = -1; } } }
编译运行程序,当移动手时,手势图标会跟着移动。
为了显示绘制游戏的逻辑,我们创建一个新的类DotPuzzle。这个类的最主要功能是保存一些数字,数字在集合中的位置决定了在数据系列中的前后位置。这个类允许序列化,我们能够从xml文件中读取关卡信息来建立新的关卡。
public class DotPuzzle { public List<Point> Dots { get; set; } public DotPuzzle() { this.Dots = new List<Point>(); } }
定义好结构之后,就可以开始将这些点绘制在UI上了。首先创建一个DotPuzzle类的实例,然后定义一些点,puzzleDotIndex用来追踪用户解题的进度,我们将puzzleDotIndex设置为-1表示用户还没有开始整个游戏,代码如下:
public MainWindow() { InitializeComponent(); puzzle = new DotPuzzle(); this.puzzle.Dots.Add(new Point(200, 300)); this.puzzle.Dots.Add(new Point(1600, 300)); this.puzzle.Dots.Add(new Point(1650, 400)); this.puzzle.Dots.Add(new Point(1600, 500)); this.puzzle.Dots.Add(new Point(1000, 500)); this.puzzle.Dots.Add(new Point(1000, 600)); this.puzzle.Dots.Add(new Point(1200, 700)); this.puzzle.Dots.Add(new Point(1150, 800)); this.puzzle.Dots.Add(new Point(750, 800)); this.puzzle.Dots.Add(new Point(700, 700)); this.puzzle.Dots.Add(new Point(900, 600)); this.puzzle.Dots.Add(new Point(900, 500)); this.puzzle.Dots.Add(new Point(200, 500)); this.puzzle.Dots.Add(new Point(150, 400)); this.puzzleDotIndex = -1; this.Loaded += (s, e) => { KinectSensor.KinectSensors.StatusChanged += KinectSensors_StatusChanged; this.KinectDevice = KinectSensor.KinectSensors.FirstOrDefault(x => x.Status == KinectStatus.Connected); DrawPuzzle(this.puzzle); }; }
最后一步是在UI界面上绘制点信息。我们创建了一个名为DrawPuzzle的方法,在主窗体加载完成的时候触发改事件。DrawPuzzle遍历集合中的每一个点,然后创建UI元素表示这个点,然后将这个点添加到PuzzleBoardElement节点下面。另一种方法是使用XAML 创建UI界面,将DotPuzzle对象作为ItemControl的ItemSource属性,ItemsControl对象的ItemTemplate对象能够定义每一个点的外观和位置。这种方式更加优雅,他允许定义界面的风格及主体。在这个例子中,我们把精力集中在Kinect代码方面而不是WPF方面,尽量减少代码量来实现功能。如果有兴趣的话,可以尝试改为ItemControl这种形式。DrawPuzzle代码如下:
private void DrawPuzzle(DotPuzzle puzzle) { PuzzleBoardElement.Children.Clear(); if (puzzle != null) { for (int i = 0; i < puzzle.Dots.Count; i++) { Grid dotContainer = new Grid(); dotContainer.Width = 50; dotContainer.Height = 50; dotContainer.Children.Add(new Ellipse { Fill = Brushes.Gray }); TextBlock dotLabel = new TextBlock(); dotLabel.Text = (i + 1).ToString(); dotLabel.Foreground = Brushes.White; dotLabel.FontSize = 24; dotLabel.HorizontalAlignment = HorizontalAlignment.Center; dotLabel.VerticalAlignment = VerticalAlignment.Center; dotContainer.Children.Add(dotLabel); //在UI界面上绘制点 Canvas.SetTop(dotContainer, puzzle.Dots[i].Y - (dotContainer.Height / 2)); Canvas.SetLeft(dotContainer, puzzle.Dots[i].X - (dotContainer.Width / 2)); PuzzleBoardElement.Children.Add(dotContainer); } } }
到目前为止,我们的游戏已经有了用户界面和基本的数据。移动手,能够看到手势图标会跟着移动。我们要将线画出来。当游戏者的手移动到点上时,开始绘制直线的起点,然后知道手朋到下一个点时,将这点作为直线的终点,并开始另一条直线,并以该点作为起点。TrackPuzzle代码如下:
private void TrackPuzzle(SkeletonPoint position) { if (this.puzzleDotIndex == this.puzzle.Dots.Count) { //游戏结束 } else { Point dot; if (this.puzzleDotIndex + 1 < this.puzzle.Dots.Count) { dot = this.puzzle.Dots[this.puzzleDotIndex + 1]; } else { dot = this.puzzle.Dots[0]; } DepthImagePoint point = this.kinectDevice.MapSkeletonPointToDepth(position, kinectDevice.DepthStream.Format); point.X = (int)(point.X * LayoutRoot.ActualWidth / kinectDevice.DepthStream.FrameWidth); point.Y = (int)(point.Y * LayoutRoot.ActualHeight / kinectDevice.DepthStream.FrameHeight); Point handPoint = new Point(point.X, point.Y); Point dotDiff = new Point(dot.X - handPoint.X, dot.Y - handPoint.Y); double length = Math.Sqrt(dotDiff.X * dotDiff.X + dotDiff.Y * dotDiff.Y); int lastPoint = this.CrayonElement.Points.Count - 1; //手势离点足够近 if (length < 25) { if (lastPoint > 0) { //移去最后一个点 this.CrayonElement.Points.RemoveAt(lastPoint); } //设置直线的终点 this.CrayonElement.Points.Add(new Point(dot.X, dot.Y)); //设置新的直线的起点 this.CrayonElement.Points.Add(new Point(dot.X, dot.Y)); //转到下一个点 this.puzzleDotIndex++; if (this.puzzleDotIndex == this.puzzle.Dots.Count) { //通知游戏者游戏结束 } } else { if (lastPoint > 0) { //移除最后一个点,更新界面 Point lineEndpoint = this.CrayonElement.Points[lastPoint]; this.CrayonElement.Points.RemoveAt(lastPoint); //将手势所在的点作为线的临时终点 lineEndpoint.X = handPoint.X; lineEndpoint.Y = handPoint.Y; this.CrayonElement.Points.Add(lineEndpoint); } } } }
代码的大部分逻辑是如何将直线绘制到UI上面,另一部分逻辑是实现游戏的规则逻辑,比如点要按照从小到大的顺序连起来。程序计算当前鼠标手势点和下一个点之间的直线距离,如果距离小于25个像素宽度,那么认为手势移动到了这个点上。当然25可能有点绝对,但是对于这个小游戏,这应该是一个合适的值。因为Kinect返回的关节点信息可能有点误差而且用户的手可能会抖动,所以有效点击范围应该要比实际的UI元素大。这一点在Kinect或者其他触控设备上都是应该遵循的设计原则。如果用户移动到了这个点击区域,就可以认为用户点击到了这个目标点。
最后将TrackPuzzle方法添加到SkeletonFrameReady中就可以开始玩这个小游戏了。运行游戏,结果如下:
在功能上,游戏已经完成了。游戏者可以开始游戏,移动手掌就可以玩游戏了。但是离完美的程序还很远。还需要进一步改进和完善。最主要的是要增加移动的平滑性。游戏过程中可以注意到手势有时候会跳跃。第二个主要问题是需要重新恢复游戏初始化状态。现在的程序,当游戏者完成游戏后只有结束应用程序才能开始新的游戏。
一个解决方式是,在左上角放一个重置按钮,当用户手进入到这个按钮上时,应用程序重置游戏,将puzzleDotIndex设置为0,清除CrayonElement对象中的所有子对象。最好的方式是,创建一个名为ResetPuzzle的新方法。
为了能够使得这个游戏有更好的体验,下面是可以进行改进的地方:
在之前的各种例子中,我们处理和操作了关节点数据的位置。在大多数情况下,原始的坐标数据是不能直接使用的。骨骼点数据和深度数据或者彩色影像数据的测量方法不同。每一种类的数据(深度数据,影像数据,骨骼数据)都是在特定的集合坐标或空间内定义的。深度数据或者影像数据用像素来表示,X,Y位置从左上角以0开始。深度数据的Z方位数据以毫米为单位。与这些不同的是,骨骼空间是以米为单位来描述的,以深度传感器为中心,其X,Y值为0。骨骼坐空间坐标系是右手坐标系,X正方向朝右,Y周正方向朝上X轴数据范围为-2.2~2.2,总共范围为4.2米,Y周范围为-1.6~1.6米,Z轴范围为0~4米。下图描述了Skeleton数据流的空间坐标系。
Kinect的应用程序就是用户和虚拟的空间进行交互。应用程序的交互越频繁。就越能增加应用的参与度和娱乐性。在上面的例子中,用户移动手来进行连线。我们知道用户需要将两个点连接起来,我们也需要知道用户的手是否在某一个点上。这种判断只有通过将骨骼数据变换到UI空间上去才能确定。由于SDK中骨骼数据并没有以一种可以直接在UI上绘图的方式提供,所以我们需要做一些变换。
将数据从骨骼数据空间转换到深度数据空间很容易。SDK提供了一系列方法来帮助我们进行这两个空间坐标系的转换。KinectSensor对象有一个称之为MapSkeletonPointToDepth的方法能够将骨骼点数据转换到UI空间中去。SDK中也提供了一个相反的MapDepthToSkeletonPoint方法。MapSkeletonPointToDepth方法接受一个SkeletonPoint点和一个DepthImageFormat作为参数。骨骼点数据来自Skeleton对象或者Joint对象的Position属性。方法的名字中有Depth,并不只是字面上的意思。目标空间并不需要Kinect深度影像。事实上,DepthStream不必初始化,方法通过DepthImageFormat来确定如何变化。一旦骨骼点数据被映射到深度空间中去了之后,他能够进行缩放到任意的纬度。
在之前绘制骨骼数据的例子中,GetJointPoint方法把每一个关节点数据转换LayoutRoot元素所在的到UI空间中,因为只有在UI空间中我们才能进行绘图。在上面的连线小游戏中,我们进行了两次这种转换。一个是在TrackHand方法中,在这个例子中,我们计算并将其转换到UI空间中,调整其位置,时期能够保证在点的中间。另一个地方是在TrackPuzzle方法中,使用用户的手势来绘制直线。这里只是简单的将数据从骨骼数据空间转换到UI空间。
细心地你可能会发现,骨骼数据是镜面对称的。在大多数情况下,应用是可行的,因为人对应于显示屏就应该是镜面对称。在上面的连线小游戏中,人对于与屏幕也应该是镜面对称,这样恰好模拟人的手势。但是在一些游戏中,角色代表实际的游戏者,可能角色是背对着游戏者的,这就是所谓的第三人称视角。在有些游戏中,这种镜像了的骨骼数据可能不好在UI上进行表现。一些应用或者游戏希望能够直面角色,不希望有这种镜像的效果。当游戏者挥动左手时也希望角色能够挥动左手。如果不修改代码直接绘制的话,在镜面效果下,角色会挥动右手,这显然不符合要求。
不幸的是SDK中并没有一个选项或者属性能够进行设置来使得骨骼追踪引擎能够直接产生非镜像数据。所以需要我们自己去编码进行这种转换,幸运的是,在了解了骨骼数据结构后进行转换比较简单。通过反转骨骼节点数据的X值就可以实现这个效果。要实现X值的反转,只需要将X的值乘以-1即可。我们可以对之前的那个绘制骨骼数据的例子中GetJointPoint方法进行一些调整,代码如下:
private Point GetJointPoint(Joint joint) { DepthImagePoint point = this.KinectDevice.MapSkeletonPointToDepth(joint.Position, this.KinectDevice.DepthStream.Format); point.X *= -1*(int) this.LayoutRoot.ActualWidth / KinectDevice.DepthStream.FrameWidth; point.Y *= (int) this.LayoutRoot.ActualHeight / KinectDevice.DepthStream.FrameHeight; return new Point(point.X, point.Y); }
修改之后运行程序就会看到,当游戏者抬起左臂时,UI界面上的人物将会抬起右脚。
开发Kinect应用程序进行交互时,在开发阶段,将骨骼关节点数据绘制到UI界面上是非常有帮助的。在调试程序时骨骼数据影像能够帮助我们看到和理解原始输入数据,但是在发布程序时,我们不需要这些信息。一种办法是每一处都复制一份将骨骼数据绘制到UI界面上的代码,这显然不符合DIY原则,所以我们应当把这部分代码独立出来,做成一个自定义控件。
本节我们的目标是,将骨骼数据查看代码封装起来,并使其在调试时为我们提供更多的实时信息。我们使用自定义控件来实现这一功能点。首先,创建一个名为SkeletonViewer的自定义控件。这个控件可以是任何一个panel对象的一个子节点。创建一个自定义控件,并将其XAML替换成如下代码:
<UserControl x:Class="KinectDrawDotsGame.SkeletonViewer" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" mc:Ignorable="d" d:DesignHeight="300" d:DesignWidth="300"> <Grid x:Name="LayoutRoot"> <Grid x:Name="SkeletonsPanel"/> <Canvas x:Name="JointInfoPanel"/> Grid> UserControl>
SkeletonsPanel就是绘制骨骼节点的panel。JointInfoPanel 是在调试时用来显示额外信息的图层。下一步是需要将一个KinectSnesor对象传递到这个自定义控件中来。为此,我们创建了一个DependencyProperty,使得我们可以使用数据绑定。下面的代码展示了这一属性。KinectDeviceChange静态方法对于任何使用该用户控件的方法和功能非常重要。该方法首先取消之前注册到KinectSensor的SkeletonFrameReady事件上的方法。如果不注销这些事件会导致内存泄漏。一个比较好的方法是采用弱事件处理模式(weak event handler pattern),这里不详细讨论。方法另一部分就是当KinectDevice属性部位空值时,注册SkeletonFrameReady事件。
protected const string KinectDevicePropertyName = "KinectDevice"; public static readonly DependencyProperty KinectDeviceProperty = DependencyProperty.Register(KinectDevicePropertyName, typeof(KinectSensor), typeof(SkeletonViewer), new PropertyMetadata(null, KinectDeviceChanged));private static void KinectDeviceChanged(DependencyObject owner, DependencyPropertyChangedEventArgs e) { SkeletonViewer viewer = (SkeletonViewer)owner; if (e.OldValue != null) { ((KinectSensor)e.OldValue).SkeletonFrameReady -= viewer.KinectDevice_SkeletonFrameReady; viewer._FrameSkeletons = null; } if (e.NewValue != null) { viewer.KinectDevice = (KinectSensor)e.NewValue; viewer.KinectDevice.SkeletonFrameReady += viewer.KinectDevice_SkeletonFrameReady; viewer._FrameSkeletons = new Skeleton[viewer.KinectDevice.SkeletonStream.FrameSkeletonArrayLength]; } }public KinectSensor KinectDevice { get { return (KinectSensor)GetValue(KinectDeviceProperty); } set { SetValue(KinectDeviceProperty, value); } }
现在用户控件能够接受来世KinectSensor对象的新的骨骼数据了。我们可以开始绘制这些骨骼数据。下面的代码展示了SkeletonFrameReady事件。大部分的代码和之前例子中的代码是一样的。一开始,判断用户控件的IsEnable控件是否被设置为true。这个属性可以使得应用程序可以方便的控制是否绘制骨骼数据。对于每一个骨骼数据,会调用两个方法,一个是DrawSkeleton方法,DrawSkeleton方法中有两个其他方法(CreateFigure和GetJointPoint)方法。另外一个方法是TrackJoint方法,这个方法显示节点的额外信息。TrackJoint方法在关节点所在的位置绘制圆圈,然后在圆圈上显示X,Y,X坐标信息。X,Y值是想对于用户控件的高度和宽度,以像素为单位。Z值是深度值。
private void KinectDevice_SkeletonFrameReady(object sender, SkeletonFrameReadyEventArgs e) { SkeletonsPanel.Children.Clear(); JointInfoPanel.Children.Clear(); using (SkeletonFrame frame = e.OpenSkeletonFrame()) { if (frame != null) { if (this.IsEnabled) { frame.CopySkeletonDataTo(this._FrameSkeletons); for (int i = 0; i < this._FrameSkeletons.Length; i++) { DrawSkeleton(this._FrameSkeletons[i], this._SkeletonBrushes[i]); TrackJoint(this._FrameSkeletons[i].Joints[JointType.HandLeft], this._SkeletonBrushes[i]); TrackJoint(this._FrameSkeletons[i].Joints[JointType.HandRight], this._SkeletonBrushes[i]); } } } } }private void TrackJoint(Joint joint, Brush brush) { if (joint.TrackingState != JointTrackingState.NotTracked) { Canvas container = new Canvas(); Point jointPoint = GetJointPoint(joint); double z = joint.Position.Z ; Ellipse element = new Ellipse(); element.Height = 15; element.Width = 15; element.Fill = brush; Canvas.SetLeft(element, 0 - (element.Width / 2)); Canvas.SetTop(element, 0 - (element.Height / 2)); container.Children.Add(element); TextBlock positionText = new TextBlock(); positionText.Text = string.Format("<{0:0.00}, {1:0.00}, {2:0.00}>", jointPoint.X, jointPoint.Y, z); positionText.Foreground = brush; positionText.FontSize = 24; positionText.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); Canvas.SetLeft(positionText, 35); Canvas.SetTop(positionText, 15); container.Children.Add(positionText); Canvas.SetLeft(container, jointPoint.X); Canvas.SetTop(container, jointPoint.Y); JointInfoPanel.Children.Add(container); } }
将这个自定义控件加到应用中很简单。由于是自定义控件,自需要在应用程序的XAML文件中声明自定义控件,然后在程序中给SkeletonViewer的KinectDevice赋值,主界面和后台逻辑代码更改部分如下加粗所示:
<Window x:Class="KinectDrawDotsGame.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:c="clr-namespace:KinectDrawDotsGame" Title="MainWindow" Height="600" Width="800" Background="White"> <Viewbox> <Grid x:Name="LayoutRoot" Width="1920" Height="1200"> <c:SkeletonViewer x:Name="SkeletonViewerElement"/> <Polyline x:Name="CrayonElement" Stroke="Black" StrokeThickness="3" /> <Canvas x:Name="PuzzleBoardElement" /> <Canvas x:Name="GameBoardElement"> <Image x:Name="HandCursorElement" Source="Images/hand.png" Width="75" Height="75" RenderTransformOrigin="0.5,0.5"> <Image.RenderTransform> <TransformGroup> <ScaleTransform x:Name="HandCursorScale" ScaleX="1" /> TransformGroup> Image.RenderTransform> Image> Canvas> Grid> Viewbox> Window>public KinectSensor KinectDevice { get { return this.kinectDevice; } set { if (this.kinectDevice != value) { //Uninitialize if (this.kinectDevice != null) { this.kinectDevice.Stop(); this.kinectDevice.SkeletonFrameReady -= KinectDevice_SkeletonFrameReady; this.kinectDevice.SkeletonStream.Disable(); SkeletonViewerElement.KinectDevice = null; this.frameSkeletons = null; } this.kinectDevice = value; //Initialize if (this.kinectDevice != null) { if (this.kinectDevice.Status == KinectStatus.Connected) { this.kinectDevice.SkeletonStream.Enable(); this.frameSkeletons = new Skeleton[this.kinectDevice.SkeletonStream.FrameSkeletonArrayLength]; SkeletonViewerElement.KinectDevice = this.KinectDevice; this.kinectDevice.Start(); this.KinectDevice.SkeletonFrameReady += KinectDevice_SkeletonFrameReady; } } } } }
添加后,运行之前的程序,就可以看到如下界面:
本文通过介绍一个简单的Kinect连线游戏的开发来详细讨论如何骨骼追踪引擎来建立一个完整的Kinect应用,然后简要介绍了各个坐标控件以及转换,最后建立了一个显示骨骼信息的自定义控件,并演示了如何将自定义控件引入到应用程序中。下一篇文章将会结合另外一个小游戏来介绍WPF的相关知识以及骨骼追踪方面进一步值得注意和改进的地方。
Kinect传感器核心只是发射红外线,并探测红外光反射,从而可以计算出视场范围内每一个像素的深度值。从深度数据中最先提取出来的是物体主体和形状,以及每一个像素点的游戏者索引信息。然后用这些形状信息来匹配人体的各个部分,最后计算匹配出来的各个关节在人体中的位置。这就是我们之前介绍过的骨骼追踪。
红外影像和深度数据对于Kinect系统来说很重要,它是Kinect的核心,在Kinect系统中其重要性仅次于骨骼追踪。事实上,这些数据相当于一个输入终端。随着Kinect或者其他深度摄像机的流行和普及。开发者可以不用关注原始的深度影像数据,他们变得不重要或者只是作为获取其他数据的一个基础数据而已。我们现在就处在这个阶段,Kinect SDK并没有提供给开发者访问原始红外影像数据流的接口,但是其它第三方的SDK可以这么做。可能大多数开发者不会使用原始的深度数据,用到的只是Kinect处理好了的骨骼数据。但是,一旦姿势和手势识别整合到Kinect SDK并成为其一部分时,可能开发者甚至不用接触到骨骼数据了。
希望能够早日实现这种集成,因为它代表这Kinect作为一种技术的走向成熟。本篇文章和下篇文章仍将讨论骨骼追踪,但是采用不同的方法来处理骨骼数据。我们将Kinect作为一个如同鼠标,键盘或者触摸屏那样的一个最基本的输入设备。微软当初推出Kinect for Xbox的口号是“你就是控制器”,从技术方面讲,就是“你就是输入设备”。通过骨骼数据,应用程序可以做鼠标或者触摸屏可以做的事情,所不同的是深度影像数据使得用户和应用程序可以实现以前从没有过的交互方法。下面来看看Kinect控制并与用户界面进行交互的机制吧。
运行在电脑上的应用程序需要输入信息。传统的信息来自于鼠标或者键盘等这些输入设备。用户直接与这些硬件设备进行交互,然后硬件设备响应用户的操作,将这些操作转换成数据传输到计算机中。计算机接收这些输入设备的信息然后将结果以可视化的形式展现出来。大多数计算机的图像用户界面上会有一个光标(Cursor),他通常代表鼠标所在的位置,因为鼠标是最开始有个滚轮设备。但是现在,如果将这个光标指代鼠标光标的话,可能不太准确,因为现在一些触摸板或手写设备也能像鼠标那样控制光标。当用户移动鼠标或者在触摸板上移动手指时,光标也能响应这种变化。当用户将光标移动到一个按钮上时,通常按钮的外观会发生变化,提示用户光标正位于按钮上。当用户点击按钮时,按钮则为显示另一种外观。当用户松开鼠标上的按键,按钮就会出现另外一种外观。显然,简单的点击事件会涉及到按钮的不同状态。
开发者可能对这些交互界面和操作习以为常,因为诸如WPF之类的用户交互平台使得程序与用户进行交互变得非常简单。当开发网页程序时,浏览器响应用户的交互,开发者只需要根据用户鼠标的悬停状态来设置样式即可进行交互。但是Kinect不同,他作为一个输入设备,并没有整合到WPF中去,因此,作为一个开发者。对操作系统和WPF所不能直接响应的那部分工作需要我们来完成。
在底层,鼠标,触摸板或者手写设备都是提供一些X,Y坐标,操作系统将这些X,Y坐标从其在的空间坐标系统转换到计算机屏幕上,这一点和上篇文章讨论的空间变换类似。操作系统的职责是响应这些标准输入设备输入的数据,然后将其转换到图形用户界面或者应用程序中去。操作系统的图形用户界面显示光标位置,并响应用户的输入。在有些时候,这个过程没有那么简单,需要我们了解GUI平台。以WPF应用程序为例,它并没有对Kinect提供像鼠标和键盘那样的原生的支持。这个工作就落到开发者身上了,我们需要从Kinect中获取数据,然后利用这些数据与按钮,下拉框或者其他控件进行交互。根据应用程序或者用户界面的复杂度的不同,这种工作可能需要我们了解很多有关WPF的知识。
当开发一个WPF应用程序时,开发者并不需要特别关注用户输入机制。WPF会为我们处理这些机制使得我们可以关注于如何响应用户的输入。毕竟作为一个开发者,我们更应该关心如何对用户输入的信息进行分析处理,而不是重新造轮子来考虑如何去收集用户的输入。如果应用程序需要一个按钮,只需要从工具箱中拖一个按钮出来放在界面上,然后在按钮的点击事件中编写处理逻辑即可。在大多数情况下,开发者可能需要对按钮设置不同的外观以响应用户鼠标的不同状态。WPF会在底层上为我们实现这些事件,诸如鼠标何时悬停在按钮上,或者被点击。
WPF有一个健全的输入系统来从输入设备中获取用户的输入信息,并响应这些输入信息所带来的控件变化。这些API位于System.Windows.Input命名空间中(Presentation.Core.dll),这些API直接从操作系统获取输入设备输入的数据,例如,名为Keyboard,Mouse,Stylus,Touch和Cursor的这些类。InputManager这个类负责管理所有输入设备获取的信息,并将这些信息传递到表现框架中。
WPF的另一类组件是位于System.Windows命名空间(PresentationCore.dll)下面的四个类,他们是UIElement,ContentElement,FrameworkElement以及FrameworkContentElement 。FrameworkElement继承自UIElement,FrameworkContentElement继承自ContentElement。这几个类是WPF中所有可视化元素的基类,如Button,TextBlock及ListBox。更多WPF输入系统相关信息可以参考MSDN文档。
InputManager监听所有的输入设备,并通过一系列方法和事件来通知UIElement和ContentElement对象,告知这些对象输入设备进行了一些有关可视化元素相关的操作。例如,在WPF中,当鼠标光标进入到可视化控件的有效区域时就会触发MouseEnterEvent事件。UIElement和ContentElement对象也有OnMouseEnter事件。这使得任何继承自UIElement或者ContentElement类的对象也能够接受来自输入设备的所触发的事件。WPF会在触发任何其它输入事件之前调用这些方法。在UIElement和ContentElement类中也有一些类似的事件包括MouseEnter,MouseLeave,MouseLeftButtonDown,MouseLeftButtonUp,TouchEnter,TouchLeave,TouchUp和TouchDown。
有时候开发者需要直接访问鼠标或者其他输出设备,InputManager对象有一个称之为PrimaryMouseDevice的属性。他返回一个MouseDevice对象。使用MouseDevice对象,能够在任何时候通过调用GetScreenPositon来获取鼠标的位置。另外,MouseDevice有一个名为GetPositon的方法,可以传入一个UI界面元素,将会返回在该UI元素所在的坐标空间中的鼠标位置。当需要判断鼠标悬停等操作时,这些信息尤其重要。当Kinect SDK每一次产生一幅新的SkeletonFrame帧数据时,我们需要进行坐标空间转换,将关节点位置信息转换到UI空间中去,使得可视化元素能够直接使用这些数据。当开发者需要将鼠标作为输入设备时, MouseDevice对象中的GetScreenPositon和GetPosition方法能提供当前鼠标所在点的位置信息。
在有些情况下,Kinect虽然和鼠标相似,但是某些方面差别很大。骨骼节点进入或者离开UI上的可视化元素这一点和鼠标移入移出行为类似。换句话说,关节点的悬停行为和鼠标光标一样。但是,类似鼠标点击和鼠标按钮的按下和弹起这些交互,关节点与UI的交互是没有。在后面的文章中,可以看到使用手可以模拟点击操作。在Kinect中相对于实现鼠标移入和移出操作来说,对鼠标点击这种支持相对来说较弱。
Kinect和触摸板也没有太多相同的地方。触摸输入可以通过名为Touch或者TouchDevice的类来访问。单点的触摸输入和鼠标输入类似,然而,多点触控是和Kinect类似的。鼠标和UI之间只有一个交互点(光标)但是触摸设备可以有多个触控点。就像Kinect可以有多个游戏者一样。从每一个游戏者身上可以捕捉到20个关节点输入信息。Kinect能够提供的信息更多,因为我们知道每一个输入点是属于游戏者身体的那个部位。而触控输入设备,应用程序不知道有多少个用户正在触摸屏幕。如果一个程序接收到了10个输入点,无法判断这10个点是一个人的10个手指还是10个人的一个手指触发的。 虽然触控设备支持多点触控,但这仍然是一种类似鼠标或者手写板的二维的输入。然而,触控输入设备除了有X,Y点坐标外,还有触控接触面积这个字段。毕竟,用户用手指按在屏幕上没有鼠标光标那样精确,触控接触面积通常大于1个像素。
当然,他们之间也有相似点。Kinect输入显然严格地符合WPF 所支持的任何输入设备的要求。除了有其它输入设备类似的输入方式外,他有独特的和用户进行交互的方式和图形用户界面。核心上,鼠标,触控板和手写板只传递一个像素点位置嘻嘻你。输入系统确定该点在可见元素上下文中的像素点位置,然后这些相关元素响应这个位置信息,然后进行响应操作。
期望是在未来Kinect能够完整的整合进WPF。在WPF4.0中,触控设备作为一个单独的模块。最开始触控设备被作为微软的Surface引入。Surface SDK包括一系列的WPF控件,诸如SurfaceButton,SurfaceCheckBox,和SurfaceListBox。如果你想按钮能够响应触摸事件,最好使用SurfaceButton控件。
能够想象到,如果Kinect被完整的整合进WPF,可能会有一个称之为SkeletonDevice的类。他和Kinect SDK中的SkeletonFrame对象类似。每一个Skeleton对象会有一个称之为GetJointPoint的方法,他和MouseDevice的GetPositon和TouchDevice的GetTouchPoint类似。另外,核心的可视化元素(UElement, ContentElement, FrameworkElement, FrameworkContentElement) 有能够相应的事件或者方法能够通知并处理骨骼关节点交互。例如,可能有一个JointEnter,JointLeave,和JointHover事件。更进一步,就像触控类有一个ManipulationStarted和ManipulationEnded事件一样,在Kinect输入的时候可能伴随GetstureStarted和GestureEnded事件。
目前,Kinect SDK和WPF是完全分开的,因此他和输入系统没有在底层进行整合。所以作为开发者的我们需要追踪骨骼关节点位置,并判断节点位置是否和UI界面上的元素有交互。当关节点在对应的UI坐标系可视化界面的有效范围内时,我们必须手动的改变这些可视化元素的外观以响应这种交互。
在确定用户是否和屏幕上的某一可视化元素进行交互之前,我们必须定义什么叫用户和可视化元素的交互。在以鼠标或者光标驱动的应用程序中有两种用户交互方式。鼠标悬停和点击交互。这些将事件划分为更精细的交互。就拿光标悬停来说,它必须进行可视化组件的坐标空间区域,当光标离开这一区域,悬停交互也就结束了。在WPF中,当用户进行这些操作时,会触发MouseEnter和MouseLeave操作。
除了点击和悬停外,鼠标还有另外一种常用的交互,那就是拖放。当光标移动到可视化组件上方,按下鼠标左键,然后在屏幕上拖动,我们称之为拖动(drag),当用户松开鼠标左键时,我们之位释放操作(drop)。鼠标拖动和释放是一个比较复杂的交互,这点和Kinect中的手势类似。
本节我们来看一下一些简单的诸如光标悬停,进入,离开可视化控件的交互。在前篇文章中的Kinect连线小游戏中,我们在绘制直线时需要判断手是否在点的合适范围内。在那个小游戏中,应用程序并没有像用户界面和人那样直接响应用户界的操作。这种差别很重要。应用程序在屏幕坐标空间中产生一些点的位置(数字),但是这些点并没有直接从屏幕空间派生。这些点只是存储在变量中的数据而已。我们改变屏幕大小使得很容易展现出来。在接收到新的骨骼数据帧之前。骨骼数据中手的位置被转换到屏幕中点所在的空间坐标系,然后我们判断手所在的位置的点是否在点序列中。技术上来讲,这个应用程序即使没有用户界面也能够正常运行。用户界面是动态的由这些数据产生的。用户直接和这些数据而不是和界面进行交互。
判断用户的手是否在点的附近远没有判断手是否在点的位置上那么简单。每一个点只是一个象元。为了使得应用程序能够工作。我们并不要求手的位置敲好在这个点所在的象元上,而是要求在以这个点为中心的某一个区域范围内。我们在点的周围创建了一个圆圈代表点的区域范围,用户的手的中心必须进入到这个点的区域范围才被认为是悬停在该点上。如图所示在圆形中的白色的点是实际的点,虚线绘制的圆形是该点的最大可触及范围。手形图标的中心用白色的点表示。所以,有可能手的图标和点的最大范围接触了,但是手的中心却不在该点的最大范围内。判断手的中心是否在点的最大范围之内称之为命中测试。
在Kinect连线游戏中,用户界面响应数据,依据产生的坐标将点绘制在图像界面上,系统使用点而不是用可视化控件的有效区间来进行命中测试。大多数的应用程序和游戏都不是这样做的。用户界面通常很复杂,而且是动态的。例如在Kinect for Windows SDK中自带的ShapeGame应用就是这样一个例子,它动态的从上至下产生一些形状。当用户触碰这些形状时形状会消失或者弹开。
ShapeGame这个应用比之前的Kinect连线游戏需要更为复杂的命中测试算法。WPF提供了一些工具来帮助我们实现命中测试。在System.Windows.Media命名空间下的VisualTreeHelper帮助类中有一个HitTest方法。这个方法有很多个重载,但是最基本的方法接受两个参数,一个是可视化控件对象,另一个是待测试的点。他返回可视化对象树中该点所命中的最顶层的那个可视化对象。听起来可能有点复杂,一个最简单的解释是,在WPF中有一个分层的可视化输出,有多个对象可能占据同一个相对空间,但是在不同的层。如果该点所在位置有多个对象,那么HitTest返回处在可视化树中处在最顶层的可视化对象。由于WPF的样式和模板系统使得一个控件能够由一个或者多个元素或者其它控件组成,所在通常在一个点可能有多个可视化元素。
上图可能帮助我们理解可视元素的分层。图中有三个元素:圆形,矩形和按钮。所有三个元素都在Canvas容器中。圆形和按钮在矩形之上,左边第一幅图中,鼠标位于圆形之上,在这点上的命中测试结果将返回这个圆形。第二幅图,即使矩形最底层,由于鼠标位于矩形上,所以命中测试会返回矩形。这是因为矩形在最底层,他是唯一个占据了鼠标光标象元所在位置的可视化元素。在第三幅图中,光标在按钮的文字上,命中测试将返回TextBlock对象,如果鼠标没有位于按钮的文字上,命中测试将会返回ButtonChrome元素。按钮的可视化表现通常由一个或者多个可视化控件组成,并能够定制。实际上,按钮没有继承可视化样式,它是一个没有可视化表现的控件。上图中的按钮使用的是默认样式,它由TextBlock和ButtonChrome这两个控件构成的。在这个例子中,我们通常会获得到有按钮样式组成的元素,但是永远获取不到实际的按钮控件。
为了使得命中测试更为方便,WPF提供了其他的方法来协助进行命中测试。UIElement类定义了一个InputHitTest方法,它接受一个Point对象,并返回该Point对象指定的一个IIputElement元素。UIElement和ContentElement两个类都实现了IInputElement接口。这意味着所有的WPF用户界面元素都实现了这个接口。VisualTreeHelper类中的HitTest方法可以用在一般的场合。
Note: MSDN中关于UIElement.InputHitTest方法的建议“应用程序一般不需要调用该方法,只有应用程序需要自己重新实现一系列已经实现了的底层输入特征,例如要重新实现鼠标设备的输入逻辑时才会去调用该方法。”由于Kinect并没有原生的集成到WPF中,所以必须重新实现类似鼠标设备的输入逻辑。
WPF中,命中测试依赖于两个变量,一个是可视化元素,另一个是点。测试首先该点转换到可视化元素所在坐标空间,然后确定是否处于该可视化元素的有效范围内。下图可以更好的理解可视化元素的坐标空间。WPF中的每一个可视化元素,不论其形状和大小,都有一个外轮廓:这个轮廓是一个矩形,它包含可视化元素并定义了可视化元素的宽度和高度。布局系统使用这个外轮廓来确定可视化元素的整体尺寸以及如何将其排列在屏幕上。当开发者使用Canvas,Grid,StackPanel等容器来布局其子元素时,元素的外轮廓是这些容器控件如进行布局计算的基础。用户看不到元素的外轮廓,下图中,可视化元素周围的虚线矩形显示了这些元素的外轮廓。此外,每一个元素有一个X,Y坐标用来指定该元素在其父容器中的位置。可以通过System.Windows.Controls.Primitives命名空间中的LayoutInformation静态类中的GetLayoutSlot方法来获取元素的外轮廓和其位置。举例来说,图中三角形的外轮廓的左上角坐标点为(0,0),三角形的宽和高都是200像素。所以在三角形外轮廓中,三角形的三个点的坐标分别为(100,0),(200,200),(0,200)。并不是在三角形外轮廓中的所有点在命中测试中都会成功,只有在三角形内部的点才会成功。点(0,0)不会命中,而三角形的中心(100,100)则能命中。
命中测试的结果依赖于可视化元素的布局。在目前所有的项目中,我们使用Canvas容器来包含所有可视化元素。Canvas是一个可视化的容器,能够使得开发者对可视化元素的位置进行完全控制,这一点在使用Kinect的时候尤其明显。像手部跟踪这类基本的方法也可以使用WPF中的其他容器,但是需要做很多其他工作,并且性能没有使用Canvas好。使用Cnavas容器,用户可以通过CanvasLeft和CanvasTop显式设定其所有子元素的起始X,Y的位置。前面讨论的坐标空间转换使用Cnavas作为容器,因为不需要太多的处理操作,转换也非常明了,只需要少量的代码就可以实现较好的性能。
使用Canvas作为容器的缺点也是其的优点。由于开发者可以完全控制在Canvas中子元素的位置,所以当窗体大小发生改变或者有比较复杂的布局时,也需要开发者去更新这些可视化元素的位置。而另外一些容器控件,如Grid,StackPanel则会帮助我们实现这些更新操作。但是,这些容器控件增加了可视化树的结构和坐标空间,从而增加了命中测试的复杂度。坐标空间越多,需要的点的转换就越多。这些容器还有alignment属性(水平和垂直)和相对于FrameworkElement的margin属性,进一步增加了命中测试的计算复杂度。如果可是化元素有RenderTransforms方法的话,我们可以直接使用这些方法而不用去自己写命中测试的算法了。
一个折中的方法是,将那些基于骨骼节点位置的需要频繁变化的可视化元素,如手形图标放在Canvas容器内,而将其他UI元素放在其他容器控件内。这种布局模式需要多个坐标空间转换,会影响程序性能,并且在进行坐标空间转换计算时可能会引入一些bug。这种混合的布局方案在大多数情况下是最好的选择,它充分利用了WPF布局系统的优点。要详细了解各种容器及其命中测试的相关概念,可以参阅MSDN中WPF的布局系统。
命中测试只能告诉当前用户输入点是否在可视化元素的有效区间内。用户界面最重要的一个功能是要给予用户一些对输入操作的反馈。当鼠标移到一个按钮上时,我们期望按钮能够改变其外观,告诉我们这个按钮是可以点击的。如果没有这种反馈,用户不仅用户体验不好,而且还会使用户感到迷惑。有时候即使功能完备,用户体验失败意味着应用的失败。
WPF有一套功能强大的系统来通知和响应用户的输入。只要用户的输入设备是鼠标,手写笔,触摸板这些标准设备,WPF的样式和模版系统使得开发出能够响应用户输入的高度的定制化的用户界面非常容易。而Kinect的开发者有两种选择:不使用WPF系统提供的功能,手动实现所有功能,或者创建一个通用的控件来响应Kinect输入。第二种方法虽然不是特别难,但是初学者也不太容易能够实现。
了解了这一点,在下面的章节中,我们将会开发一个游戏来使用命中测试并手动响应用户的输入。在开始之前,思考一个问题,到目前位置,还有那些问题没有很好解决?使用Kinect骨骼数据和用户界面进行交互是什么意思?核心的鼠标交互有:进入,离开和点击。触摸输入交互有进入,离开,按下,弹起。鼠标只有一个触控点,触摸版可以有多个触控点,但是只有一个是主触控点。Kinect骨骼节点数据有20个可能的数据点,哪一个点是主触控点?应该有一个主控点吗?一个可视化元素,比如说按钮,会在任何一个关节点数据到达按钮的有效范围内触发,还是只是特定的关节点数,比如手,进入范围后才能触发?
没有一个回答能够完全回答好上面的问题。这取决于应用程序界面的设计及要实现的功能。这些问题其实是自然交互界面设计中的一部分典型问题。在后面我们会介绍。对于大多数Kinect应用程序,包括本文中的例子,只允许手部关节点数据才能和用户界面进行交互。最开始的交互是进入和离开。除此之外的交互可能会很复杂。在后面我们将介绍这些复杂的交互,现在让我们来看看最基本的交互。
为了演示如何将Kinect作为一个输入设备,我们开始开发我们的项目:该项目使用手部关节点数据模仿鼠标或者触控板和用户界面进行交互。这个项目的目标是展示如何进行命中测试和使用WPF可视化元素来创建用户界面。项目是一个称之为“我说你做”(Simon Say)的小游戏。
“我说你做”(Simon says)是一个英国传统的儿童游戏。一般由3个或更多的人参加。其中一个人充当"Simon"。其他人必须根据情况对充当"Simon"的人宣布的命令做出不同反应。如果充当"Simon"的人以"Simon says"开头来宣布命令,则其他人必须按照命令做出相应动作。如:充当"Simon"的人说:"Simon says jump(跳)"。其他人就必须马上跳起;而如果充当"Simon"的人没有说"Simon says"而直接宣布命令,如:充当"Simon"的人说"jump"。则其他人不准有动作,如果有动作则做动作的人被淘汰出游戏。
在70年代末80年代初有一个叫Milton Bradley的游戏公司开发了一个电子版的Simon say游戏。该游戏界面由4个不同颜色 (红色,蓝色,绿色,黄色) 的按钮组成,这个游戏在电脑上运行,让游戏者按演示的顺序按下这些按钮。当开始游戏时,程序首先按照一定的顺序亮起每一个按钮,游戏者必须按照这个亮灯的顺序依次按下这些按钮。如果游戏者操作正确,那么下一个亮灯序列又开始,到后面变化会越来越快,直到游戏者不能够按照给定的顺序按下这些按钮位置。
我们要做的是,使用Kinect设备来实现这么一个Simon Say游戏。这是个很好的使用Kinect展示如何和用户界面进行交互的例子。这个游戏也有一些规则。下图展示了我们将要做的用户界面,他包含四个矩形,他用来模拟游戏中的按钮。界面上方是游戏标题,中间是游戏的操作指南。
这个Kinect版的Simon says游戏追踪游戏者的手部关节。当用户的手碰到了这四个填充了颜色的方框中的任何一个时,程序认为游戏者按下了一个按钮。在Kinect应用程序中,使用悬停或者点击来和按钮进行交互很常见。现在,我们的游戏操作指南还很简单。游戏一开始,我们提示用户将手放在界面上红色矩形中手势图标所在的位置。在用户将双手放到指定位置后,界面开始发出指令。如果游戏者不能够重复这个过程,游戏将会结束,并返回到这个状态。现在,我们对这个游戏的概念,规则和样子有了一些了解,现在开始编码。
首先来设计一个用户界面,下面的代码展示的主界面中的XAML和之前的连线游戏一样,我们将所有的主界面的UI元素包含在Viewbox容器中,让他来帮助我们进行不同显示器分辨率下面的缩放操作。主UI界面分辨率设置为1920*1080。UI界面共分为4个部分:标题及游戏指导,游戏界面,游戏开始界面以及用来追踪手部的手形图标。第一个TextBlock用来显示标题,游戏引导放在接下来的StackPanel元素中。这些元素是用来给游戏者提供当前游戏状态。他们没有功能性的作用,和Kinect或者骨骼追踪没有关系。
GameCanvas,ControlCanvas和HandCanvas包含了所有的和Kienct相关的UI元素,这些元素是基于当前用户手的位置和用户界面进行交互的。手的位置来自骨骼追踪。HandCanvas应该比较熟悉,程序中有两个手形图标,用来追踪游戏者两只手的运动。ControlCanvas存储的UI元素用来触发开始游戏。GameCanvas用来存储这4个矩形,在游戏中,用户需要点击这些矩形。不同的交互元素存储在不同的容器中,使得用户界面能够比较容易使用代码进行控制。比如,当用户开始游戏后,我们需要隐藏所有的ControlCanvas容器内的子元素,显然隐藏这个容器比隐藏其每个子控件容易的多。整个UI代码如下:
<Window x:Class="KinectSimonSay.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:c="clr-namespace:KinectSimonSay" Title="MainWindow" WindowState="Maximized"> <Viewbox> <Grid x:Name="LayoutRoot" Height="1080" Width="1920" Background="White" TextElement.Foreground="Black"> <c:SkeletonViewer x:Name="SkeletonViewerElement"/> <TextBlock Text="Simon Say" FontSize="72" Margin="0,25,0,0" HorizontalAlignment="Center" VerticalAlignment="Top">TextBlock> <StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Width="600"> <TextBlock x:Name="GameStateElement" FontSize="55" Text=" GAME OVER!" HorizontalAlignment="Center" /> <TextBlock x:Name="GameInstructionsElement" Text="将手放在对象上开始游戏。" FontSize="45" HorizontalAlignment="Center" TextAlignment="Center" TextWrapping="Wrap" Margin="0,20,0,0" /> StackPanel> <Canvas x:Name="GameCanvas"> <Rectangle x:Name="RedBlock" Height="400" Width="400" Fill="Red" Canvas.Left="170" Canvas.Top="90" Opacity="0.2" /> <Rectangle x:Name="BlueBlock" Height="400" Width="400" Fill="Blue" Canvas.Left="170" Canvas.Top="550" Opacity="0.2" /> <Rectangle x:Name="GreenBlock" Height="400" Width="400" Fill="Green" Canvas.Left="1350" Canvas.Top="550" Opacity="0.2" /> <Rectangle x:Name="YellowBlock" Height="400" Width="400" Fill="Yellow" Canvas.Left="1350" Canvas.Top="90" Opacity="0.2" /> Canvas> <Canvas x:Name="ControlCanvas"> <Border x:Name="RightHandStartElement" Background="Red" Height="200" Padding="20" Canvas.Left="1420" Canvas.Top="440" > <Image Source="Images/hand.png" /> Border> <Border x:Name="LeftHandStartElement" Background="Red" Height="200" Padding="20" Canvas.Left="300" Canvas.Top="440" > <Image Source="Images/hand.png" > <Image.RenderTransform> <TransformGroup> <TranslateTransform X="-130" /> <ScaleTransform ScaleX="-1" /> TransformGroup> Image.RenderTransform> Image> Border> Canvas> <Canvas x:Name="HandCanvas"> <Image x:Name="RightHandElement" Source="Images/hand.png" Visibility="Collapsed" Height="100" Width="100" /> <Image x:Name="LeftHandElement" Source="Images/hand.png" Visibility="Collapsed" Height="100" Width="100" > <Image.RenderTransform> <TransformGroup> <ScaleTransform ScaleX="-1" /> <TranslateTransform X="90" /> TransformGroup> Image.RenderTransform> Image> Canvas> Grid> Viewbox> Window>
UI界面设计好了之后,我们现在来看游戏的基础结构。需要在代码中添加响应SkeletonFrameReady事件的逻辑。在SkeletonFrameReady事件中,添加代码来跟踪游戏者手部关节的运动。基本代码如下:
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) { ChangePhase(GamePhase.GameOver); } else { LeftHandElement.Visibility = Visibility.Collapsed; RightHandElement.Visibility = Visibility.Collapsed; } }} }private static Skeleton GetPrimarySkeleton(Skeleton[] skeletons) { Skeleton skeleton = null; if (skeletons != null) { //Find the closest skeleton for (int i = 0; i < skeletons.Length; i++) { if (skeletons[i].TrackingState == SkeletonTrackingState.Tracked) { if (skeleton == null) { skeleton = skeletons[i]; } else { if (skeleton.Position.Z > skeletons[i].Position.Z) { skeleton = skeletons[i]; } } } } } return skeleton; }
上面代码中TrackHand和GetJointPoint代码和Kinect连线游戏中相同。对于大多数游戏来说,使用“拉模型”来获取数据比使用事件模型获取数据性能要好。游戏通常是一个循环,可以手动的从骨骼数据流中获取下一帧骨骼数据。但是在我们的例子中,仍然使用的是事件模型,为的是能够减少代码量和复杂度。
Simon say游戏分成三步。起始步骤,我们之为GameOver,意味着当前没有可以玩的游戏。这是游戏的默认状态。这也是当Kinect探测不到游戏者时所切换到的状态。然后游戏开始循环,Simon给出一些指令,然后游戏者重复执行这些指令,重复这一过程,直到用户没能够正确的执行Simon给出的指令为止。应用程序定义了一个枚举变量来描述游戏所有可能的状态,以及定义了一个变量来跟踪游戏这当前所执行了的指令位置。另外我们需要一个变量来描述游戏者成功的次数或者游戏等级。当游戏者成功的执行了Simon给出的指令后,这个变量加1。下面的代码展示了这个枚举以及变量,变量的初始化在类的够着函数中执行。
public enum GamePhase { GameOver = 0, SimonInstructing = 1, PlayerPerforming = 2 }public MainWindow() { InitializeComponent(); KinectSensor.KinectSensors.StatusChanged += KinectSensors_StatusChanged; this.KinectDevice = KinectSensor.KinectSensors.FirstOrDefault(x => x.Status == KinectStatus.Connected); ChangePhase(GamePhase.GameOver); this.currentLevel = 0; }
SkeletonFrameReady事件需要根据当前游戏所处的状态来执行不同的操作。下面的代码中根据当前游戏的状态执行ChangePhase,ProcessGameOver和ProcessPlayerPerforming子方法。这些方法的详细执行过程将在后面介绍。ChangePhase方法接受一个GamePhase枚举值,后两个方法接受一个Skeleton类型的参数。
当应用程序探测不到骨骼数据时,游戏会终止,并切换到Game Over阶段。当游戏者离开Kinect视野时会发生这种情况。当游戏处在Simon给出操作步骤阶段时,隐藏界面上的手势图标。否则,更新这两个图标的位置。当游戏处在其它状态时,程序基于当前特定的游戏阶段调用特定的处理方法。
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) { ChangePhase(GamePhase.GameOver); } else { if (this.currentPhase == GamePhase.SimonInstructing) { LeftHandElement.Visibility = Visibility.Collapsed; RightHandElement.Visibility = Visibility.Collapsed; } else { TrackHand(skeleton.Joints[JointType.HandLeft], LeftHandElement, LayoutRoot); TrackHand(skeleton.Joints[JointType.HandRight], RightHandElement, LayoutRoot); switch (this.currentPhase) { case GamePhase.GameOver: ProcessGameOver(skeleton); break; case GamePhase.PlayerPerforming: ProcessPlayerPerforming(skeleton); break; } } } } } }
当游戏处在GameOver阶段时,应用程序只调用了一个方法:该方法判断用户是否想玩游戏。当用户将相应的手放在UI界面上手势所处的位置时,游戏开始。左右手需要分别放在LeftHandStartElement和RightHandStartElement所处的位置内。在这个例子中,我们使用WPF自带的命中测试功能。我们的UI界面很小也很简单。InputHitTest操作所需要处理的UI元素很少,因此性能上没有太大问题。下面的代码展示了ProcessGameOver方法和GetHitTarget方法。
private void ProcessGameOver(Skeleton skeleton) { //判断用户是否想开始新的游戏 if (HitTest(skeleton.Joints[JointType.HandLeft], LeftHandStartElement) && HitTest(skeleton.Joints[JointType.HandRight], RightHandStartElement)) { ChangePhase(GamePhase.SimonInstructing); } } private bool HitTest(Joint joint, UIElement target) { return (GetHitTarget(joint, target) != null); } private IInputElement GetHitTarget(Joint joint, UIElement target) { Point targetPoint = LayoutRoot.TranslatePoint(GetJointPoint(this.KinectDevice, joint, LayoutRoot.RenderSize, new Point()), target); return target.InputHitTest(targetPoint); }
ProcessGameOver方法的逻辑简单明了:如果游戏者的任何一只手在UI界面上的对应位置,就切换当前游戏所处的状态。GetHitTarget方法用来测试给定的关节点是否在可视化控件有效范围内。他接受关节点数据和可视化控件,返回该点所在的特定的IInputElement对象。虽然代码只有两行,但是了解背后的逻辑很重要。
命中测试算法包含三个步骤,首先需要将关节点所在的骨骼空间坐标系中坐标转换到对应的LayoutRoot元素所在的空间坐标中来。GetJointPoint实现了这个功能。其次,使用UIElement类中的TranslatePoint方法将关节点从LayoutRoot元素所在的空间坐标转换到目标元素所在的空间坐标中。最后,点和目标元素在一个坐标空间之后,调用目标元素的InputHitTest方法,方法返回目标对象树中,点所在的确切的UI元素,任何非空值都表示命中测试成功。
注意到逻辑之所以这么简单是因为我们采用的UI布局方式,应用程序假定全屏运行并且不能调整大小。将UI界面设置为静态的,确定大小能够极大的简化计算量。另外,将所有的可交互的UI元素放在Canvas容器内使得我们只有一个坐标空间。使用其他容器空间来包含元素或者使用诸如HorizonAlignment,VerticalAlignment或者Margin这些自动布局属性会增加命中测试的复杂性。简言之,越是复杂的UI布局,命中测试的逻辑越复杂,也越会影响程序的性能。
编译并运行程序,如果没问题的话,结果应该如下图。应用程序能够追踪手部的运动,并且当用户将手放到对应的位置后,应用程序的状态会从GameOver转到SimonInstructing状态。下一步是要实现ChangePhase方法,代码如下:
private void ChangePhase(GamePhase newPhase) { if (newPhase != this.currentPhase) { this.currentPhase = newPhase; switch (this.currentPhase) { case GamePhase.GameOver: this.currentLevel = 0; RedBlock.Opacity = 0.2; BlueBlock.Opacity = 0.2; GreenBlock.Opacity = 0.2; YellowBlock.Opacity = 0.2; GameStateElement.Text = "GAME OVER!"; ControlCanvas.Visibility = Visibility.Visible; GameInstructionsElement.Text = "将手放在对象上开始新的游戏。"; break; case GamePhase.SimonInstructing: this.currentLevel++; GameStateElement.Text = string.Format("Level {0}", this.currentLevel); ControlCanvas.Visibility = Visibility.Collapsed; GameInstructionsElement.Text = "注意观察Simon的指示。"; GenerateInstructions(); DisplayInstructions(); break; case GamePhase.PlayerPerforming: this.instructionPosition = 0; GameInstructionsElement.Text = "请重复 Simon的指示"; break; } } }
上面的代码和Kinect无关,事实上可以使用鼠标或者触控板来实现这一步,但是这段代码是必须的。ChangePhase方法用来控制UI界面来显示当前游戏状态的变化,维护一些游戏进行所需要的数据。在GameOver状态时,矩形框会渐变消失,然后改变操作指示,显示按钮来开始一个新的游戏。SimonInStructing状态不在更新UI界面讨论范围内,他调用了两个方法,用来产生指令集合 (GenerateInstructions),并将这些指令显示到UI界面上(DisplayInstructions),代码中也定义了instructionPosition变量,来维护当前所完成的指令步骤。
下面的代码显示了一些局部变量和GenerateInstructions方法。instructionSequence变量用来存储一系列的UIElements对象,这些对象组成了Simon的指令集合。游戏者必须用手依次移动到这些指令上。这些指令的顺序是随机设定的。每一关指令的个数和当前等级是一样的。比如,到了第五关,就有5个指令。代码也显示了DisplayInstruction方法,他创建并触发了一个故事板动画效果来根据指令的顺序来改变每一个矩形的透明度。
private UIElement[] instructionSequence; private int instructionPosition; private int currentLevel; private Random rnd = new Random();
private void GenerateInstructions() { this.instructionSequence = new UIElement[this.currentLevel]; for (int i = 0; i < this.currentLevel; i++) { switch (rnd.Next(1, 4)) { case 1: this.instructionSequence[i] = RedBlock; break; case 2: this.instructionSequence[i] = BlueBlock; break; case 3: this.instructionSequence[i] = GreenBlock; break; case 4: this.instructionSequence[i] = YellowBlock; break; } } } private void DisplayInstructions() { Storyboard instructionsSequence = new Storyboard(); DoubleAnimationUsingKeyFrames animation; for (int i = 0; i < this.instructionSequence.Length; i++) { this.instructionSequence[i].ApplyAnimationClock(FrameworkElement.OpacityProperty, null); animation = new DoubleAnimationUsingKeyFrames(); animation.FillBehavior = FillBehavior.Stop; animation.BeginTime = TimeSpan.FromMilliseconds(i * 1500); Storyboard.SetTarget(animation, this.instructionSequence[i]); Storyboard.SetTargetProperty(animation, new PropertyPath("Opacity")); instructionsSequence.Children.Add(animation); animation.KeyFrames.Add(new EasingDoubleKeyFrame(0.3, KeyTime.FromTimeSpan(TimeSpan.Zero))); animation.KeyFrames.Add(new EasingDoubleKeyFrame(1, KeyTime.FromTimeSpan(TimeSpan.FromMilliseconds(500)))); animation.KeyFrames.Add(new EasingDoubleKeyFrame(1, KeyTime.FromTimeSpan(TimeSpan.FromMilliseconds(1000)))); animation.KeyFrames.Add(new EasingDoubleKeyFrame(0.3, KeyTime.FromTimeSpan(TimeSpan.FromMilliseconds(1300)))); } instructionsSequence.Completed += (s, e) => { ChangePhase(GamePhase.PlayerPerforming); }; instructionsSequence.Begin(LayoutRoot); }
运行程序,当双手放到指定位置是,Simon游戏开始。
游戏的最后一步就是根据指令来捕捉用户的动作。注意到当故事版动画完成了显示Simon的指令后,程序调用ChangePhase方法使游戏进入PlayerPerforming阶段。当在PlayerPerforming阶段时,应用程序执行ProcessPlayerPerforming方法。表面上,实现该方法很简单。逻辑是游戏者重复Simon给出的操作步骤,将手放在对应矩形上方。这和之前做的命中测试逻辑是一样的。但是,和测试两个静态的UI对象不同,我们测试指令集合中的下一个指令对应的元素。下面的代码展示的ProcessPlayerPerforming方法,编译并运行就可以看到效果了,虽然能够运行,但是它对用户非常不友好。实际上,这个游戏不能玩。我们的用户界面不完整。
private void ProcessPlayerPerforming(Skeleton skeleton) { //Determine if user is hitting a target and if that target is in the correct sequence. UIElement correctTarget = this._InstructionSequence[this._InstructionPosition]; IInputElement leftTarget = GetHitTarget(skeleton.Joints[JointType.HandLeft], GameCanvas); IInputElement rightTarget = GetHitTarget(skeleton.Joints[JointType.HandRight], GameCanvas); if(leftTarget != null && rightTarget != null) { ChangePhase(GamePhase.GameOver); } else if(leftTarget == null && rightTarget == null) { //Do nothing - target found } else if((leftTarget == correctTarget && rightTarget == null) || (rightTarget == correctTarget && leftTarget == null)) { this._InstructionPosition++; if(this._InstructionPosition >= this._InstructionSequence.Length) { ChangePhase(GamePhase.SimonInstructing); } } else { ChangePhase(GamePhase.GameOver); } }
上面的代码中,第一行获取目标对象元素,即指令序列中的当前指令。然后执行命中测试,获取左右手对应的命中元素。下面的代码对这三个变量进行操作。如果两只手都在UI元素上,游戏结束。我们的游戏很简单,只能允许一次点击一个矩形。当两只手都不在UI元素上时,什么都不做。如果一只手命中了期望的对象,我们就把当前指令步骤加1。当指令集合中还有其他指令时游戏继续运行,直到完成了指令集合中的最后一个指令。当完成了最后一个指令后,游戏状态又变为了SimonInstruction状态,然后将游戏者带入下一轮游戏。直到游戏者不能重复Simon指令而进入GameOver状态。
如果游戏者动作够快,那么上面代码工作正常,因为只要用户手进入到了可视化元素有效区域,那么指令位置就会自增,游戏者在进入到下一个指令之前,没有时间来从UI元素所在的空间上移除手。这么快的速度不可能使得游戏者能够闯过第二关。当游戏者成功的闯过第二关的指令后,游戏就会突然停止。
解决这个问题的办法是在进入到下一个指令前等待,直到游戏者的手势从UI界面上清除。这使得游戏者有机会能够调整手势的位置开始进入下一条指令,我们需要记录用户的手什么时候进入和离开UI对象。
在WPF中,每一个UIElement对象都会在鼠标进入和离开其有效范围内时触发MouseEnter和MouseLeave事件。不幸的是,如前面所讨论的,WPF本身并不支持Kinect产生的关节点数据和UI的直接交互,如果当关节点进入或者离开可视化元素时能够触发诸如JointEnter和JointLeave事件,那么就简单多了。既然不支持,那么我们只有自己手动实现这个逻辑了。要实现一个可重用,优雅,并能像鼠标那样能够在底层追踪关节点运动这样的控件不太容易并且不容易做成通用的。我们只针对我们当前遇到的问题来实现这个功能。
要修正游戏中的这个问题比较容易。我们添加一系列成员变量来保存UI元素上的哪一个鼠标手势最后悬停在上面。当用户的手经过UI元素的上方时,更新这个变量。对于每一个新的骨骼数据帧。我们检查游戏者手的位置,如果它离开了UI元素空间,那么我们处理这个UI元素。下面的代码展示了对上面ProcessPlayerPerforming方法的改进。改进的部分用粗体表示。
private IInputElement leftHandTarget; private IInputElement rightHandTarget;
private void ProcessPlayerPerforming(Skeleton skeleton) { //判断用户是否手势是否在目标对象上面,且在指定中的正确顺序 UIElement correctTarget = this.instructionSequence[this.instructionPosition]; IInputElement leftTarget = GetHitTarget(skeleton.Joints[JointType.HandLeft], GameCanvas); IInputElement rightTarget = GetHitTarget(skeleton.Joints[JointType.HandRight], GameCanvas); bool hasTargetChange = (leftTarget != this.leftHandTarget) || (rightTarget != this.rightHandTarget); if (hasTargetChange) { if (leftTarget != null && rightTarget != null) { ChangePhase(GamePhase.GameOver); } else if ((leftHandTarget == correctTarget && rightHandTarget == null) || (rightHandTarget == correctTarget && leftHandTarget == null)) { this.instructionPosition++; if (this.instructionPosition >= this.instructionSequence.Length) { ChangePhase(GamePhase.SimonInstructing); } } else if (leftTarget != null || rightTarget != null) { //Do nothing - target found } else { ChangePhase(GamePhase.GameOver); } if (leftTarget != this.leftHandTarget) { if (this.leftHandTarget != null) { ((FrameworkElement)this.leftHandTarget).Opacity = 0.2; } if (leftTarget != null) { ((FrameworkElement)leftTarget).Opacity = 1; } this.leftHandTarget = leftTarget; } if (rightTarget != this.rightHandTarget) { if (this.rightHandTarget != null) { ((FrameworkElement)this.rightHandTarget).Opacity = 0.2; } if (rightTarget != null) { ((FrameworkElement)rightTarget).Opacity = 1; } this.rightHandTarget = rightTarget; } } }
现在运行代码,由于游戏需要两只手进行操作,所以没法截图,读者可以自己下载代码运行。
这个游戏演示了如何建立一个基于Kinect进行交互的程序,虽然程序可以运行,但是仍然有一些有待改进的地方,有以下三个方面可以进行改进:用户体验,游戏内容和表现形式。
基于Kinect的应用程序和游戏比较新颖,在这种应用达到成熟前,要想获得良好的用户体验需要进行很多实验和测试。我们的Simon Say游戏的用户界面就有很多值得改进的地方。Simon Say的游戏者可能会意外的触摸到游戏的区间。游戏时在游戏开始的时候,有可能会碰到开始按钮。一旦两只手都在指定的区间,游戏就开始产生指令,如果用户没有及时的放开手,他可能会无意识的碰到一个游戏对象。一个有效的解决方法是在产生指令之前,给予用户一定的时间让其重新设置手的位置。因为人们会自然而然的将手垂在身体两边。一个比较好的变通方法是简单的给一个倒计时。在不同的关卡间,也可以给这样一个时间间隔。在开始新的一关时,用户应该有时间来从可视化元素中移开手。
产生游戏指令序列的逻辑比较简单。指令序列中指令的数目和当前的关卡是一致的。每一条指令所选择的可视化元素是随机选择的。在原始的Simon Say游戏中,新一轮的游戏通常会添加一些新的指令。例如,第一关中有红的,第二关中有红的和蓝的,第三关增加了绿的。因此在第三关指令可以是,红绿蓝。另一种改进可以不在每一关增加一个指令。而是将指令的个数设置为当前关卡数的2倍。软件开发一个有趣的地方就是应用程序可以有多种产生指令序列的算法。例如,应用程序可以分为容易,中等,难三种产生指令序列的方法供用户选择。最基本的产生指令序列的逻辑就是每一关要尽可能的比前一关要长,并且指令显示速度要以一个常量的速度显示。要增加游戏的难度,在显示指令序列时可以减少指令展示给用户的时间。
创建一个赋予表现力的程序远不止我们这里所介绍的这些内容。可能做一点改动就可以将我们的UI做的更加好看,比如,可以在显示指令提示,以及用户移入和离开指定区域时可以采用一些比较好看的动画。当用户执行的指令正确时,可以展现一个动画效果给予奖励。同样的,在游戏结束时也可以展现出一个动画。
本文围绕Kinect介绍了WPF输入系统的相关知识,并讨论了如何将Kinect作为WPF程序的输入设备与应用程序进行交互,最后展示了一个Simon say的小游戏来讲述如何进行这些实际操作。
到目前为止我们只用了骨骼数据中关节点的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不同距离,界面上的手形图标会随着距离的大小进行缩放;同时界面上用于调试的信息也在变化,还可以注意到,随着远近的不同,参考深度标注图案,手形图标在界面上的深度值也会相应的发生变化,有时候在图标在某些标签的前面,有时候在某些标签后面。
以上例子展示了骨骼数据信息中Z值的用处,一般在开发基于Kinect应用程序时,除了用到关节点的X,Y点数据来进行定位之外,还可以用上面讲的方法来使用Z值数据。有时候Z值数据可以增加应用程序的应用体验。
姿势(Pose)是人和其他物体的重要区别。在日常生活中,人们通过姿势来表达感情。姿势是一段个动作的停顿,他能传达一些信息息 。例如在体育运动中, 裁判员会使用各种各样的姿势来向运动员传递信息。姿势和手势通常会混淆,但是他们是两个不同的概念。当一个人摆一个姿势时,他会保持身体的位置和样子一段时间。但是手势包含有动作,例如用户通过手势在触摸屏上,放大图片等操作。
在Kinect开发的早期,更多的经历会放在手势的识别上而不是姿势的识别上。 这有点不对,但是可以理解。Kinect的卖点在于运动识别。Kinect这个名字本身就来源于单词kinetic,他是“运动的”的意思。Kinect 做为Xbox的一个外设,使得可以使用游戏者的肢体动作,或者说是手势,来控制游戏。手势识别给开发者带来了很多机遇以全新的用户界面设计。在后面的文章中,我们将会看到,手势并非都很简单,有些手势很复杂,使得应用程序很难识别出来。相对而言,姿势是用户有意做的动作,它通常有一定的形式。
虽然姿势识别没有手势识别那样受开发者关注,但即使在现在,很多游戏中都大量使用姿势识别。通常,游戏者很容易模仿指定姿势并且比较容易编写算法来识别指定的姿势。例如,如果开发一个用户在天上飞的游戏。 一种控制游戏的方式是,游戏者像鸟一样挥动手臂。挥动的频率越快游戏角色飞的越快,这是一个手势。还有一种方法是,展开双臂,双臂张得越快开,飞的越快。双臂离身体越近,飞的越慢。在Simon Says游戏中游戏者必须伸开双臂将双手放到指定的位置才能开始游戏,也可以将这个改为,当用户伸开双臂时即可开始游戏。问题是,如何识别这一姿势呢?
身体以及各个关节点的位置定义了一个姿势。更具体的来说,是某些关节点相对于其他关节点的位置定义了一个姿势。姿势的类型和复杂度决定了识别算法的复杂度。通过关节点位置的交叉或者关节点之间的角度都可以进行姿势识别。
通过关节点交叉进行姿势识别就是对关节点进行命中测试。在前一篇文章中,我们可以确定某一个关节点的位置是否在UI界面上某一个可视化元素的有效范围内。我们可以对关节点做同样的测试。但是需要的工作量要少的多,因为所有的关节点都是在同一个坐标空间中,这使得计算相对容易。例如叉腰动作(hand-on-hip),可以从骨骼追踪的数据获取左右髋关节和左右手的位置。然后计算左手和左髋关节的位置。如果这个距离小于某一个阈值,就认为这两个点相交。这个阈值可以很小,对一个确定的相交点进行命中测试,就像我们对界面上可视化元素进行命中测试那样,可能会有比较不好的用户界面。即使通过一些平滑参数设置,从Kinect中获取的关节点数据要完全匹配也不太现实。另外,不可能期望用户做出一些连贯一致的动作,或者保持一个姿势一段时间。简而言之,用户运动的精度以及数据的精度使得这种简单计算不适用。因此,计算两个点的长度,并测试长度是否在一个阈值内是唯一的选择。
当两个关节点比较接近时,会导致关节点位置精度进一步下降,这使得使用骨骼追踪引擎判断一个关节点的开始是否是另一个关节点的结束点变得困难。例如,如果将手放在脸的位置上,那么头的位置大致就在鼻子那个地方,手的关节点位置和头的关节点位置就不能匹配起来。这使得难以区分某些相似的姿势,比如,很难将手放在脸的前面,手放在头上,和手捂住耳朵这几个姿势区分开来。这些还不是所有应用设计和开发者可能遇到的问题。有时候会,要摆出一个确切的姿势也很困难,用户是否会按照程序显示的姿势来做也是一个问题。
节点交叉并不需要使用X,Y的所有信息。一些姿势只需要使用一个坐标轴信息。例如:立正姿势,在这个姿势中,手臂和肩膀近乎在一个垂直坐标轴内而不用考虑用户的身体的大小和形状。在这个姿势中,逻辑上只需要测试手和肩部节点的X坐标的差值,如果在一个阈值内就可以判断这些关节点在一个平面内。但是这并不能保证用户是立正姿势。应用程序还需要判断手在Y坐标轴上应该低于肩部。这能提高识别精度,但仍然不够完美。没有一个简单的方法能够判定用户所处的站立姿势。如果用户只是稍微将膝盖弯曲有点,那么这种识别方法就不是很科学。
并不是所有的姿势识别都适合使用节点交叉法,一些姿势使用其他方法识别精度会更高。例如,用户伸开双臂和肩膀在一条线上这个姿势,称之为T姿势。可以使用节点相交技术,判断手、肘、以及肩膀是否在Y轴上处于近乎相同的位置。另一种方法是计算某些关节点连线之间的角度。骨骼追踪引擎能够识别多达20个关节点数据。任何三个关节点就可以组成一个三角形。使用三角几何就可以计算出他们之间的角度。
实际上我们只需要根据两个关节点即可绘制一个三角形,第三个点有时候可以这两个关节点来决定的。知道每个节点的坐标就可以计算每个边长的值。然后使用余弦定理就可以计算出角度了。公式如下图:
为了演示使用关节点三角形方法来识别姿势,我们考虑在健美中常看到了展示肱二头肌姿势。用户肩部和肘在一条线上并且和地面平行,手腕与肘部与胳膊垂直。在这个姿势中,可以很容易看到有一个直角或者锐角三角形。我们可以使用上面所说的方法来计算三角形的每一个角度,如下图所示:
上图中,组成三角形的三个关节点为。肩膀,轴和手腕。根据这三个关节点的坐标可以计算三个角度。
有两种使用节点三角形的方法。最明显的如上面的例子那样,使用三个节点来构造一个三角形。另一个方法就是使用两个节点,第三个节点手动指定一个点。这种方法取决于姿势的限制和复杂度。在上面的例子中,我们使用三个及节点的方法,因为需要的角度可以由手腕-肘-肩部构成。不论其他部位如何变化,这三者所构成的三角形相形状相对不变。
使用两个节点来识别这一动作只需要肘部和手腕关节点信息。将肘部作为整个坐标系统的中心或者零点。以肘部为基准点,随便找一个水平的X轴上的点。然后就可以由这三点组成一个三角形。在两点方法中,用户在直立和有点倾斜姿势下所计算得到的结果是不一样的。
了解了姿势识别后,使得我们可以在Kinect开发中使用的姿势信息。应用程序如何处理这些信息以及如何和用户交互对于功能完整的应用程序来说同样重要。识别姿势的目的是触发一些操作。最简单的方法是当探测到某一姿势后立即响应一些类似鼠标点击之类的事件。
Kinect应用程序比较酷的一点是可以使用人体作为输入设备,但这也带来了一新的问题。对于应用程序设计和开发者来说,用户通常不会如我们设想的那样按照设定好的,或者指定的姿势来进行运动。近十年来,应用设计者和开发者一直在关注如如何改进键盘及鼠标驱动的应用程序,使得这些应用程序能够正确,健壮的处理任何用户的键盘或者鼠标操作。可惜的是这些方面的经验并不适用于Kinect。当使用鼠标时,用户需要有意的去按下鼠标左键或者右键去进行操作。大多数鼠标点击事件时有意的,如果是无意中按下的,应用程序也不能判断用户是否无意按下。但是因为需要按下按钮,通常无意按中的情况极少。但在识别姿势时,这种情况就不同了,因为用户一直在摆pose。
应用程序要使用姿势识别必须知道什么时候该忽略什么时候该响应特定的姿势。如前所述,最简单的方法是当识别到某一姿势时立即响应。如果这是应用程序的功能,需要选择一个用户不可能会在休息或者放松时会产生的姿势。选择一个姿势很容易,但是这个姿势不能是户自然而然或者大多数情况下都会产生的姿势。这意味着姿势必须是有意识的,就像是鼠标点击那样,用户需要进行某项操作才会去做某种特定的姿势。除了马上响应识别到的某个姿势外,另一种方法是触发一个计时器。只有用户保持这一姿势一段时间,应用程序才会触发相应的操作。这个和手势有点类似。在以后的文章中我会详细讨论。
另一种方法是当用户摆出某一系列的姿势时才触发某一动作。这需要用户按照特定的序列摆出一些列的姿势,才会执行某一操作。使用系列姿势和一些不常用的姿势可以使得应用程序知道用户有意想进行某一项操作,而不是误操作。换句话说,这能够帮助用户减少误操作。
使用上一篇文章介绍的游戏项目,我们重新实现一些功能,比如我们使用姿势而不是可视化元素的命中测试来进行指令执行判断。在这个版本中,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现在就是用姿势识别取代可视化元素的命中测试来进行判别用户是否完成了指定的指令了。运行游戏,为了截图,下面都是我端着键盘的姿势,大家可以下载本文的代码回去自己玩哈。
这个例子展示了在实际应用中,如何使用姿势识别。可以试着在姿势库中添加其他姿势,然后测试。你会发现并不是所有的姿势都是那么容易就能够识别的。
对于任何程序,尤其是Kinect应用程序,用户体验对于应用的成功与否至关重要。运行Simon Says游戏,你会感觉游戏缺少了很多东西。游戏界面缺少一些使得游戏交互更加有趣的元素。这个游戏缺少对用户动作的反馈,这对用户体验很重要。要使得Simon Say变成一个真正意义上的Kienct驱动的游戏,在游戏开始或者结束时必须给予一些提示信息。当游戏者正确的做了一个指定的姿势时应该给予一定的鼓励。有以下几个方面可以增加游戏的趣味性和可玩性:
本例中,使用的最多的地方是姿势识别部分代码。在Simon Says游戏中,我们写了很多代码来启动开始姿势识别引擎。在未来Kinect SDK中可能会增加姿势识别引擎,但是目前的SDK版本中没有这一功能。考虑到Kinect SDK是否会在未来增加这一功能,所以很有必要创建这么一个工具。Kinect开发社区有一些类似的工具,但都部是标准的。
可以考虑创建一个PoseEngine类,他有一个PoseDetected事件。当引擎识别到骨骼数据摆出了一个姿势时,触发该事件。默认地,PoseEngine类监听SkeletonFrameReady事件,他能够一帧一帧的使用某种方法测试骨骼数据帧,这使得能够支持“拉”数据模型。PosEngine类有一个Pose集合,他定义了一些能够识别的姿势合集。可以就像.Net中的List那样使用Add和Remove方法进行添加或者删除,开发者可以为应用程序定义一个姿势库。
为了能够动态的添加和删除姿势,姿势定义那部分代码不能像我们之前的Simon Says游戏中的那样硬编码。最简单的方法是使用序列化。序列化姿势数据有两个好处,一是姿势很容易从应用程序中添加和移除。应用程序可以在运行时动态对添加到配置文件中的姿势进行读取。更进一步的,我们可以将这些姿势配置持久化,使得我们可以创建一个专门的工具来捕捉或者定义姿势。
开发一个能够捕捉用户姿势,并将数据序列化成应用程序直接使用的数据源不是太难。这个程序可以使用前面我们所讲到的知识开发出来。可以在SkeletonView自定义控件的基础上,添加关节点之间角度计算逻辑。然后显示在SkeletonVeiw的输出信息中,将角度信息显示在关节点位置。姿势捕捉工具使用函数来对这用户的姿势进行截图,这截图实际上是一系列关节点之间的角度信息,截图可以序列化,使得能够很容易的添加到其他应用程序中去。
将SkeletonView根据上面的想法进行改进后,可以显示关节点夹角信息。下图展示了可能的输出。使得能够很容易的看出各个关节点之间的夹角。可以根据这个夹角来手动的定义一些姿势。甚至可以开发出一些工具根据这些夹角来生成姿势配置文件。将夹角显示在UI上也能提供很多有用的调试信息。
本文首先介绍了如何使用骨骼节点数据中的Z值来创建更好的体验,然后讨论了姿势识别的常用方法,并结合上文中Simon Says的游戏,把它改造为了使用姿势识别来判断指令执行是否正确,最后讨论了该游戏可以改进的一些地方和创建姿势识别引擎的一些设想。
像点击(clicks)是GUI平台的核心,轻点(taps)是触摸平台的核心那样,手势(gestures)是Kinect应用程序的核心。和图形用户界面中的数字交互不同,手势是现实生活中存在的动作。如果没有电脑我们就不需要鼠标,但是没了Kinect,手势依然存在。从另一方面讲,手势是日常生活中人与人之间相互交流的一部分。手势能够增强演讲的说服力,能够用来强调和传递情感。像挥手(waving)或者指点(pointing)这些手势都是某种无声的演讲。
Kinect应用程序的设计和开发者的任务就是将这些现实生活中存在的手势映射到计算机交互中去以传达人的想法。尝试从鼠标或触摸式的GUI设计移植基于手势的自然交互界面要做很多工作。借鉴过去30多年来对于这一概念的研究,以及从一些Kinect for Xbox的体感游戏中获取一些设计理念,计算机工程师和交互设计师一起为Kinect创建了一系列新的手势库。
本文将会介绍用户体验的一些知识,并讨论如何将手势应用到Kinect应用程序中。我们将展示Kinect如何作为自然交互界面(Natural User Interface)的人机交互模型的一部分。我们将讨论一些具体的使用Kinect来进行手势识别及交互的例子。更重要的是,将会展示一些已经作为Kinect手势识别库中的手势。
在许多不同的学科中,手势(gesture)有着其独特的含义,可能这些含义之间有某些异同。在艺术领域,手势被用来传达舞蹈中最富表现力的部分,特别是在亚洲舞蹈艺术中,手势被作为某些宗教符号或者象征。在交互设计领域,在基于触摸的自然交互界面中手势和操控有很大区别。
以上这些说明手势在不同的学科领域都有自己独特的含义。在学术领域都试图对手势定义一个抽象的概念。在用户体验设计领域使用最广泛的关于手势的定义实在Eric Hulteen 和Gord Kurtenbach 1990年发表的一篇名为人机交互中的手势(Gestures in Human-Computer Communication),定义如下:”手势是身体的运动,他包含一些信息。挥手道别是一种手势。敲击键盘不是手势,因为用手指的运动去敲击按键没有被观察,也不重要,他只表达的键盘被按下这一动作。(A gesture is a motion of the body that contains information. Waving goodbye is a gesture. Pressing a key on a keyboard is not a gesture because the motion of a finger on its way to hitting a key is neither observed nor significant. All that matters is which key was pressed)”
这个定义既解释了什么是手势也解释了什么不是手势。像这样的下一个正式的定义通常有两个方面的困难,既要避免太具体也要避免太抽象。如果一个定义太具体-如,定义某项技术-可能会随着UI技术的变化会变得模糊不清。作为一种学术定义而不是以常见的用法为基础的定义,它也必须足够一般,并且符合或者说广大的研究机构先前已发表在HCI的研究成果及艺术中符号学。另一方面,定义过于宽泛,也会有有无关紧要的风险:如果一切都是一种姿态,那么就什么都不是了。
Eric Hulteen 和Gord Kurtenbach关于手势的定义的中心在于手势能够用来交流,手势的意义在于讲述而不是执行。
有趣的是将语言和行为引入到人机交互接口中来,这是一种彻底的变革。我们与计算机交互语音变为无声的语言(mute):我们通过指点和手势而不是语言与计算设备进行沟通。当和计算机进行交互时,我们点击键盘按键或触摸屏幕。我们似乎更喜欢这种形式的静音通信即使当前的技术能够支持更简单的语音指令。我们没有操作(manipulation)的力量,和虚拟的对象而不是真实的物体进行交互,因而没有持久性。运动成为纯粹的手势。
基于Eric Hulteen 和Gord Kurtenbach的定义,我们都明白什么是 UI 操作 ——暂时不是一种手势 ——理解什么是手势以及手势表示"重大"行为或者符号仍然有很大的困难。移动交互的含义是什么?手势进行沟通和语言进行沟通的最明显不同是什么?我们做手势的象征意义往往很抽象简单。
在人机交互领域,手势通常被作为传达一些简单的指令而不是交流某些事实、描述问题或者陈述想法。使用手势操作电脑通常是命令式的,这通常不是人们使用手势的目的。例如,挥手(wave)这一动作,在现实世界中通常是打招呼的一种方式,但是这种打招呼的方式在人机交互中却不太常用。通常第一次写程序通常会显示“hello”,但我们对和电脑打招呼并不感兴趣。
但是,在一个繁忙的餐馆,挥手这一手势可能就有不同的含义了。当向服务员招收时,可能是要引起服务员注意,需要他们提供服务。在计算机中,要引起计算机注意有时候也有其特殊意义,比如,计算机休眠时,一般都会敲击键盘或者移动鼠标来唤醒,以提醒计算机“注意”。当使用Kinect时,可以使用更加直观的方式,就行少数派报告阿汤哥那样,抬起双手,或者简单的朝计算机挥挥手,计算机就会从休眠状态唤醒。
在人机交互领域,手势通常有一些含义,表示有意让某些事情发生。手势是一种指令。当通过鼠标或者触控板去点击UI界面上的按钮时,我们希望按钮会触发其背后的事件。通常,按钮上会有一个标签来指示按钮的功能如:开始、取消、打开、关闭。我们的手势操作就是想要实现这些事件。
上面的定义中的第一点可以得出,手势的另一个特点是比较随意(arbitrary)。手势有限定的领域,那么在该领域之外没有任何意义。令人惊讶的是除了指向(pointing)和耸肩(shurg),人类学家没有发现任何东西我们可以称之为一种通用的手势。然而,在计算机的UI中,指向(pointing)通常被认为是直接操作因为它牵涉到跟踪,同时耸肩的含义太微妙而不好辨识。因此,我们想要使用的任何Kinect手势必须基于应用程序的用户 和应用程序的设计和开发者之间就某种手势代表的含义达成一致。
因为手势是任意的(arbitrary)所以他们也是基于约定的(conventional)。应用程序的设计者必须告诉用户正在使用的手势的意义,或者是这些手势是约定俗称大家都知道的。此外,这些约定不是基于语言文化,而是对已确定的技术规则。我们知道如何使用鼠标 (行为学习) 并不是因为这是我们已经从我们的文化导入的东西,而是因为这是基于特定的图形用户界面的跨文化约定。同样地,我们知道如何点击或滑动智能手机,不是因为这些都是文化的约定,而是因为这些都是跨文化自然用户界面项约定。有趣的是,我们在一定程度上知道如何点击平板电脑,因为我们以前学习了如何使用鼠标单击。技术约定之间可以相互转化,这是因为语言和手势可以通过不同的语言和文化之间来转换。
然而,手势的这种任意性和基于约定的特性也带来了误解性(misunderstanding),这是在设计任何用户界面,尤其是像Kinect这样的没有任何预先设定好的操作约定的用户界面时需要关注的风险。就像在有些国家,点头表示否定摇头表示可能。手势,或者任何身体的运动,都有可能产生误解。
总之,在人机交互领域,手势是:
注意:实际的直接操作(manipulation)不是手势。
讨论手势而不讨论自然用户界面显然不完整。自然用户界面是一系列技术的合计,他包括:语音识别,多点触控以及类似Kinect的动感交互界面,他和Windows和Macs操作系统中鼠标和键盘交互这种很常见图形交互界面不同。就像图像交互界面和之前的命名行交互界面不同那样。
自然交互界面自然在哪儿呢?早期自然交互界面的发起者认为交互界面的设计应该对用户非常直观,使用用户先天就会的行为来进行交互操作。他的目标是不需要操作由图标和菜单构成的基于GUI 的应用程序界面,因为这种界面通常具有陡峭的学习曲线。相反,理想化的状态是,用户应该能够走到应用程序前面,就能够开始使用它。在过去的几年里随着触摸功能的智能手机和平板电脑的流行,逐渐取代了键盘鼠标,当我们看到孩子们开始走到任何触摸屏设备面前,用手去触摸它,期待它的响应,在这一点上看这一理念已经实现。
虽然自然用户界面的自然性似乎是直接操作的最佳写照,当使用手指来进行触摸交互时,先天自然和后天学习行为之间的对立被打破。一些手势,如轻触屏幕,在某种意义上就是先天就会的动作。其他的动作比如说双击,获得点击然后拖拉等,没有先天就会。而且随着不同的设备制造商开始支持不同触摸手势,为了使得相同的手势在不同的触摸平台上有相同的意义和行为,为某些手势定义一些约定显得更加重要。
自然用户界面(NUI)的自然性更多的是一种相对自然的概念。对于NUI的更现代的理解受Bill Buxton所影响。他认为NUI界面的设计充分利用了用户预先就会的技能,用户和UI进行交互感到很自然,使得他们甚至忘了是从哪里学到这些和UI进行交互所需的技能的。换句话说,第一次操作时,我们不记得我们曾经学过这些知识。例如,轻点(tap)这个手势早平板电脑和手机中使用的很频繁,这个技能是从我们之前在传统的人机交互界面上使用鼠标来指向并点击某一个界面上的元素学来的。点击(click)和轻点(tap)的最主要区别在于,点击需要鼠标,对于触摸屏,不需要额外的设备,只需要用手指轻轻触摸一下屏幕就可以了。
这引出了自然用户界面的另一个特点。用户和计算机之间的交互看起来不需要任何媒介,这种相互作用的媒介是不可见的。例如,在语音识别界面中,人机交互是通过具有复杂电子过滤去噪的麦克风实现的,其内部有解析发音语义单元的各种算法,将这些语义传递给其它软件来进行将特定的短语解释为命令,并将该命令映射到某种软件功能操作。但是,内部的这一切,对用户是不可见的。当用户对计算机发出这样的命令,"嘿,注意我",她会认为计算机会像类似大多数人的本能那样的响应这个命令。
自然用户界面的 依赖于先验知识和不需要媒介的交互这两个特征是每一种NUI界面的共同特征,其他方面如触摸,语音和动态交互界面则因设备的不同而各异。目前,大多数关于NUI的设计都是基于多点触控体验的。这就是为什么前面对于手势的标准定义是那样定义的。它是将多点触摸的场景进行修改并将手势和操作区分开来。
关于手势(gesture)和操作(manipulation)的争论也存在于语音交互界面中,命令等同于手势,语音等同于直接操作,在动态交互界面中,将手或者身体追踪展示在可视化界面上手和身体的运动等同于直接操作。自由形式的运动像挥手这一动作就属于手势。
但是Kinect还有第三种交互界面,他和触摸和语音交互不同。那就上一篇文章中所讲的姿势(pose),姿势是身体的某一部分和其他部分之间的一种静态关系,他不是运动的。Kinect中的姿势和日常生活中的姿势是一样的,例如,左臂伸出45度表示将当前的窗口变为活动的交互窗体,右臂伸出45度或者135度表示垂直滚动工具栏。
另外,交互方式可以从一种类型的交互界面转换到另外一种交互界面。以按钮为例,按钮其实就是一个符号,这是一个先验的图形用户界面。从最基本的功能来讲,按钮就是一个通过鼠标点击在一个可视化元素的文字或者图像上触发一些命令的工具。在过去15年,按钮被作为人机交互界面的一个集成部分,被转换到多点触摸界面,以及Kinect用户界面中来。
自然用户界面设计师所追求的是的是自然,按钮恰好提供了这一点。但是按钮在每一种用户界面中的转换都面临着一些挑战。
图形用户界面中按钮的一个通常的特征是他提供了一个悬浮状态来指示用户光标已经悬停在的按钮上方的正确位置。这种悬浮状态将点(click)这个动作离散开来。悬浮状态可以为按钮提供一些额外的信息。当将按钮移植到触摸屏交互界面时,按钮不能提供悬浮状态。触摸屏界面只能响应触摸。因此,和电脑上的图像用户界面相比,按钮只能提供“击”(click)操作,而没有“点”(point)的能力。
当将按钮移植到基于Kinect的用户界面上时,按钮的行为就变得更加特殊了。基于Kinect的图形界面中,按钮的行为和触摸界面中的刚好相反,他只提供了悬浮(hover)的“点”(point)的能力,没有“击”(click)的能力。按钮这种更令用户体验设计者感到沮丧的弱点,在过去的几年里,迫使设计者不断的对Kinect界面上的按钮进行改进,以提供更多巧妙的方式来点击视觉元素。这些改进包括:悬停在按钮上一段时间、将手掌向外推(笨拙地模仿点击一个按钮的行为)等。
虽然触摸界面也有手势,但Kinect 界面有些互动不是手势,不过软件的开发和设计者倾向于以 Kinect 手势操作作为交互界面。这似乎是因为使用手势作为物理操作是 Kinect 应用程序的最大的特点。与此相反的是,触摸界面的突出特点是直接操作。虽然可能不准确,人们通常将自然交互界面划分为三类:语音交互界面,触摸交互界面和手势交互界面。
然而,在关于Kinect的相关介绍文档中,你会发现有时候姿势(pose)和操作(manipulation)都被描述为手势。这些都没有错。要记住的是,当我们讨论Kinect中的一些术语,如挥手(wave),滑动(swipe),我们会作为纯粹的手势,而姿势和操控只有在隐喻意义上才称之为手势。
以上的讨论都很重要,因为我们会进一步设计Kinect互动的语意,我们将最终移除从其他图形界面上借鉴过来的关于按钮的语意,然后尝试建立基于Kinect的先验的语意。挥手(wave)这是Kinect中纯粹的手势,是最早的这种尝试。乔治亚技术研究所的研究人员正在利用 Kinect 来解释美国手语。相反,其他研究人员,正在利用 Kinect 解释身体语言——另一种预先形成的手势和姿势的沟通。诸如此类的研究可以视为对于NUI的第二层研究。这些逐渐接近了最初NUI人机交互的原始的梦想,不只是看不见,而且NUI能够自适应以理解我们的行为,而不是迫使我们了解我们和电脑的人机交互。
在手势交互界面中,纯粹的手势,姿势和追踪以及他们之间的组合构成了交互的基本术语。对于Kinect来说,目前可以使用的有8个通用的手势:挥手(wave),悬浮按钮(hover button),磁吸按钮(magnet button),推按钮(push button),磁吸幻灯片(magnetic slide),通用暂停(universal pause),垂直滚动条(vertical scrolling)和滑动(swipping)。其中的一些术语是微软自己引入的,有一些是游戏代理商设计的,还有一些是Kinect for PC开发人员为了开发应用而引入的。
很少情况下会为人际交互界面术语进行定制。通常要将这8种手势区分开来,并在一些应用中通用也不常见。相似的情况在web术语和手机手势中设计新的界面时也会遇到,其中只有部分的设计能够变成标准。在网页设计领域,走马灯和光标动画流行一时,并在一片鄙夷声中迅速消失。在手机设计领域由于苹果公司在触摸屏领域的早期地位这种术语得到了很好的规范。苹果引入了一些触摸手势术语,如轻点(tap),点住不放(tap and hold),滑动swipe及pinch。
交互术语形成规范有几个障碍。第一个就是为了获得利益而避免标准化。在90年代后期的浏览器大战中,尽管各大厂商在口头上说标准化协议很重要,但是在浏览器开发上依旧不停的开发自己的HTML版本,以吸引开发者使用他们的技术。设备制造商可以利用市场占有率的优势来锁定消费者,通过在他们的手机上实现自己定义语意的触屏,来推行自己的手势操作。这些都是不自然的行为,因为不同厂商对于同一手势的语意都不同,并且他们看起来不自然,使用不同厂商的产品需要再学习。
另一种形成规范化的障碍是上下文手势的专利。例如,苹果公司不能对“滑动”(swipe)操作申请专利,但是它可以对“滑动解锁手机”这个手势申请专利,这使得其他公司需要使用这一技术或者设计理念时要么给苹果公司支付专利费,要么将苹果告上法庭以避免专利费,或则干脆不使用这一上下文手势。如果不使用这一上下文手势,那么产品就破坏了之前我们学习到使用很自然的方式滑动解锁手机,音乐播放器,平板电脑等这一约定了。
最后一个障碍是,设计一个手势很困难。手势术语会面对一些App Store中手机应用程序和YouTube中视频应用所遇到的一些问题:人们要么会要么不会。手势需要思考如何定义的简单使得人们能够去用,这就是长尾理论留下来的问题。
那么什么样的手势术语才是好的呢。如果一个手势易于使用,那么他就被认为是设计良好的。在交互设计中,易用性有两个方面:可用(affordance)和反馈(feedback)。反馈就是说用户知道当前正在进行的操作。在网页中,点击按钮会看到按钮有一点偏移,这就表示交互成功。鼠标按键按下时的声音在某种意义上也是一种反馈,他表示鼠标在工作。对于Winodw Phone Metro风格的界面上的磁贴,开发这认为这些按钮应该足够大,以容下大面积的触摸区域,但是他们也认为过大的触摸区域会使得用户触摸到区域外面也会触发注册的事件。另外,状态信息或者确认对话框会在应用程序中弹出以提示用户发生了一些事情。在 Xbox 的仪表板中,使用Kinect传感器产生的光标悬停在的热点上开始动画播放。
如果说反馈发生在操作进行中或者之后,那么可用性(affordance)就发生在操作之前了。可用性就是一种提示或者引导,告诉用户某一个可视化元素是可以交互的,指示用户该元素的用处。在GUI交互界面中,按钮是能够最好的完成这些理念的元素。按钮通过文字或者图标提示来执行一些函数操作。GUI界面上的按钮通过悬浮状态可以提示用户其用途。最好的可用性-可能有点绕圈-就是约定俗成。用户知道某一个可视化元素的用途,因为之前在其他应用中使用过类似的可视化控件,或者是在其他设备中执行过类似的操作。但是,这一点对于基于Kinect的手势交互界面来说有点困难,因为一切都是新的。
通常的做法就是使用借用其他类型交互界面中的约定。在触摸交互界面中,一个轻点(tap)手势和通常的鼠标点击是等同的。响应轻点事件的两个可视化元素,图标和按钮,也被设计的和传统的GUI界面上的图标和按钮一样,来达到提示用户该元素的作用这一目的。Kinect也使用按钮和图标来使得用户能够更加容易使用。因为Kinect基本上是基于”点”(pointing)而原生不支持“击”(clicking)。在此之前,软件界面设计者和开发者的花费了很多精力来对手势交互界面进行定制以实现“击”这一动作。
和触摸交互界面不一样,手势交互界面可以从社会中人的一般手势中借用一些手势操作。这就使得挥手(wave)成为Kinect应用程序的经典手势。因为这一姿势和现实生活中的姿势有象征性联系使得非常容易理解和使用。轨迹追踪,虽然在技术上不是手势,但是他是另一个在现实生活中和指点有联系的术语。当在电视机或者显示器前挥动手时,好的Kinect应用程序应该能够追踪到手的运动,并显示一个光标随着手一起运动。当我们在现实生活中指点物体时,Kinect中的手部追踪显示的手形图标的反馈使得程序更加易用。
目前,现实生活中的易用性手势在Kinect交互界面中用的比较少,大部分的易用性都是从传统的GUI界面上的可用性移植过来的。随着时间的改变,这一点会得到改善。在触摸屏设备上新的手势通过在传统的已经建立的约定中添加手指来形成。两指轻点和一指轻点有些不同,使用两个手指或者多个手指进行滑动有其独特的含义。最终,触摸手势全部由手指完成。另一方面,真正的手势用户界面,有一个近乎无限的语意库,使得我们可以基于现实生活中相关联的手势进行改进。
本文接下来从理论到实现,讨论如何实现手势识别,并展示了Kinect中八中基本手势中的挥手(wave)手势的识别。
Microsoft Kinect SDK并没有包含手势识别引擎。因此需要开发者来定义和手势识别。从SDK的Beta版放出以来,一些第三方开发者创建的手势引擎已初见端倪。但是,微软没有将他们作为标准的引擎。看来这可能还要等微软将手势识别引擎添加到SDK中来,或者指明可替代的解决方案。本节对手势识别技术进行了简单介绍,希望能够帮助开发者在标准的手势识别引擎出来之前,可以自己动手开发手势识别引擎。
手势识别相对来说可以简单也可以很复杂,这取决与要识别的手势。有三种基本的方法可以用来识别手势:基于算法,基于神经网络和基于手势样本库。每一种方法都有其优缺点。开发者具体采用那种方法取决与待识别的手势、项目需求,开发时间以及开发水平。基于算法的手势识别相对简单容易实现,基于神经网络和手势样本库则有些复杂。
算法是解决软件开发中几乎所有问题的最基本方法。使用算法的基本流程就是定义处理规则和条件,这些处理规则和条件必须符合处理结果的要求。在手势识别中,这种算法的结果要求是一个二值型对象,某一手势要么符合预定的手势要么不符合。使用算法来识别手势是最基本的方法,因为对于有一点编程能力的开发这来说,手势识别的代码易于理解,编写,维护和调试。
但是,最简单直接的方法也有其缺点。算法的简单性限制了其能识别到的手势的类别。对于挥手(wave)识别较好的算法不能够识别扔(throw)和摆(swing)动作。前者动作相对简单和规整,后者则更加细微且多变。可能能够写一个识别摆动(swing)的算法,但是代码可能比较费解和脆弱。
算法还有一个内在的扩展性问题。虽然一些代码可以重用,但是每一种手势必须使用定制的算法来进行识别。随着新的手势识别算法加入类库,类库的大小会迅速增加。这就对程序的性能产生影响,因为需要使用很多算法来对某一个手势进行识别以判断该手势的类型。
最后,每一个手势识别算法需要不同的参数,例如时间间隔和阈值。尤其是在依据流程识别特定的手势的时候这一点显得尤其明显。开发者需要不断测试和实验以为每一种算法确定合适的参数值。这本身是一个有挑战也很乏味的工作。然而每一种手势的识别有着自己特殊的问题。
当用户在做手势时,手势的形式并不总是足够清晰到能够判断用户的意图。例如跳跃手势,跳跃手势就是用户短暂的跳起来,脚离开地面。这个定义不能够提供足够的信息来识别这一动作。
咋一看,这个动作似乎足够简单,使得可以使用算法来进行识别。首先,考虑到有很多种不同形式的跳跃:基本跳跃(basic jumping)、 跨栏(hurdling)、 跳远(long jumping)、 跳跃(hopping),等等。但是这里有一个大的问题就是,由于受到Kinect视场区域的限制,不可能总是能够探测到地板的位置,这使得脚部何时离开地板很难确定。想象一下,用户在膝盖到下蹲点处弯下,然后跳起来。手势识别引擎应该认为这是一个手势还是多个手势:下蹲或 下蹲跳起或者是跳起?如果用户在蹲下的时间和跳跃的时间相比过长,那么这一手势可能应被识别为下蹲而不是跳跃。
看到这些,最开始对跳跃的定义就会变得模糊。这一姿势很难定义清楚,使得不能够通过定义一些算法来进行识别,同时这些算法由于需要定义过多的规则和条件而变得难以管理和不稳定。使用对或错的二值策略来识别用户手势的算法太简单和不够健壮,不能够很好的识别出类似跳跃,下蹲等动作。
神经网络的组织和判断是基于统计和概率的,因此使得像识别手势这些过程变得容易控制。基于什么网络的手势识别引擎对于下蹲然后跳跃动作,80%的概率判断为跳跃,10%会判定为下蹲。
除了能够识别复杂和精细的手势,神经网络方法还能解决基于算法手势识别存在的扩展性问题。神经网络包含很多神经元,每一个神经元是一个好的算法,能够用来判断手势的细微部分的运动。在神经网络中,许多手势可以共享神经元。但是每一中手势识别有着独特的神经元的组合。而且,神经元具有高效的数据结构来处理信息。这使得在识别手势时具有很高的效率。
使用基于神经网络进行手势识别的缺点是方法本身复杂。虽然神经网络以及在计算机科学中对其的应用已经有了好几十年,建立一个好的神经网络对于大多数程序员来说还是有一些困难的。大多数开发者可能对数据结构中的图和树比较熟悉,而对神经网络中尺度和模糊逻辑的实现可能一点都不了解。这种缺乏建立神经网络的经验是一个巨大的困难,即使能够成功的构建一个神经网络,程序的调试相当困难。
和基于算法的方法相比,神经网络依赖大量的参数来能得到精确的结果。参数的个数随着神经元的个数增长。每一个神经元可以用来识别多个手势,每一个神经远的参数的变化都会影响其他节点的识别结果。配置和调整这些参数是一项艺术,需要经验,并没有特定的规则可循。然而,当神经网络配对机器学习过程中手动调整参数,随着时间的推移,系统的识别精度会随之提高。
基于样本或者基于模版的手势识别系统能够将人的手势和已知的手势相匹配。用户的手势在模板库中已经规范化了,使得能够用来计算手势的匹配精度。有两种样本识别方法,一种是存储一系列的点,另一种方法是使用类似的Kinect SDK中的骨骼追踪系统。在后面的那个方法中,系统中包含一系列骨骼数据和景深帧数据,能够使用统计方法对产生的影像帧数据进行匹配以识别出已知的帧数据来。
这种手势识别方法高度依赖于机器学习。识别引擎会记录,处理,和重用当前帧数据,所以随着时间的推移,手势识别精度会逐步提高。系统能够更好的识别出你想要表达的具体手势。这种方法能够比较容易的识别出新的手势,而且较其他两种方法能够更好的处理比较复杂的手势。但是建立这样一个系统也不容易。首先,系统依赖于大量的样本数据。数据越多,识别精度越高。所以系统需要大量的存储资源和CPU时间的来进行查找和匹配。其次系统需要不同高度,不同胖瘦,不同穿着(穿着会影响景深数据提取身体轮廓)的样本来进行某一个手势。
选择手势识别的方法通常是依赖于项目的需要。如果项目只需要识别几个简单的手势,那么使用基于算法或者基于神经网络的手势识别就足够了。对于其他类型的项目,如果有兴趣的话可以投入时间来建立可复用的手势识别引擎,或者使用一些人家已经写好的识别算法,接下来本文介绍几种常用的手势,并演示如何使用算法的方法来识别他们,手势识别的另外两种方法由于其复杂性本文不做介绍。
不论选择哪种手势识别的方法,都必须考虑手势的变化范围。系统必须具有灵活性,并允许某一个手势有某个范围内的变动。很少有人能够每次都做一模一样的手势。例如,考虑周伯通当前左右手画圆圈这个手势,重复这一手势10次,圆形的中心每次都在一个点吗,圆形的起点和重点每次都在相同的地方吗?每次画圆的时长都一样吗?然后使用右手做这个动作,最后比较结果。或者拉几个朋友或者家人来做,然后观察。也可以站在镜子前面看自己做,或者使用录像设备录下来再看。技巧就是对于某一手势,让尽可能多的人来做,然后试图标准化这一手势。手势识别一个比较好的方式就是关注手势最核心的部分而不是哪些外在的细枝末节。
只要玩过Xbox上的体感游戏,可能都使用过挥手这个手势。挥手这一手势不论年龄大小都能够做的一个简单动作。这是一个友好的,快乐的手势,人们通常挥手或者招手来打招呼或者道别。在应用开发的上下文中,挥手手势通常告诉应用程序已经准备好了,可以开始体验应用了。
挥手是最简单最基本的手势。使用算法方法能够很容易识别这一手势,但是之前讲到的任何方法也能够使用。虽然挥手是一个很简单的手势,但是如何使用代码来识别这一手势呢?读者可以在镜子前做向自己挥手,然后仔细观察手的运动,尤其注意观察手和胳膊之间的关系。继续观察手和胳膊之间的关系,然后观察在做这个手势事身体的整个姿势。有些人保持身体和胳膊的不动,使用手腕左右移动来挥手。有些人保持身体和胳膊不动使用手腕前后移动来挥手。可以通过观察这些姿势来了解其他各种不同挥手的方式。
XBOX中的挥手动作定义为:从胳膊开始到肘部弯曲。用户以胳膊肘为焦点来回移动前臂,移动平面和肩部在一个平面上,并且胳膊和地面保持平行,在手势的中部(下图1),前臂垂直于后臂和地面。下图展示了这一姿势。
从图中可以观察得出一些规律,第一个规律就是,手和手腕都是在肘部和肩部之上的,这也是大多是挥手动作的特征。这也是我们识别挥手这一手势的第一个标准。
第一幅图展示了挥手这一姿势的中间位置,前臂和后臂垂直。如果用户手臂改变了这种关系,前臂在垂直线左边或者右边,我们则认为这是该手势的一个片段。对于挥手这一姿势,每一个姿势片段必须来回重复多次,否则就不是一个完整的手势。这一运动规律就是我们的第二个准则:当某一手势是挥手时,手或者手腕,必须在中间姿势的左右来回重复特定的次数。使用这两点通过观察得到的规律,我们可以通过算法建立算法准则,来识别挥动手势了。
算法通过计算手离开中间姿势区域的次数。中间区域是一个以胳膊肘为原点并给予一定阈值的区域。算法也需要用户在一定的时间段内完成这个手势,否则识别就会失败。这里定义的挥动手势识别算法只是一个单独的算法,不包含在一个多层的手势识别系统内。算法维护自身的状态,并在识别完成时以事件形式告知用户识别结果。挥动识别监视多个用户以及两双手的挥动手势。识别算法计算新产生的每一帧骨骼数据,因此必须记录这些识别的状态。
下面的代码展示了记录手势识别状态的两个枚举和一个结构。第一个名为WavePosition的枚举用来定义手在挥手这一动作中的不同位置。手势识别类使用WaveGestureState枚举来追踪每一个用户的手的状态。WaveGestureTracker结构用来保存手势识别中所需要的数据。他有一个Reset方法,当用户的手达不到挥手这一手势的基本动作条件时,比如当手在胳膊肘以下时,可调用Reset方法来重置手势识别中所用到的数据。
private enum WavePosition { None = 0, Left = 1, Right = 2, Neutral = 3 } private enum WaveGestureState { None = 0, Success = 1, Failure = 2, InProgress = 3 } private struct WaveGestureTracker { public int IterationCount; public WaveGestureState State; public long Timestamp; public WavePosition StartPosition; public WavePosition CurrentPosition; public void Reset() { IterationCount = 0; State = WaveGestureState.None; Timestamp = 0; StartPosition = WavePosition.None; CurrentPosition = WavePosition.None; } }
下面代码显示了手势识别类的最基本结构:它定义了五个常量:中间区域阈值,手势动作持续时间,手势离开中间区域左右移动次数,以及左手和右手标识常量。这些常量应该作为配置文件的配置项存储,在这里为了简便,所以以常量声明。WaveGestureTracker数组保存每一个可能的游戏者的双手的手势的识别结果。当挥手这一手势探测到了之后,触发GestureDetected事件。
当主程序接收到一个新的数据帧时,就调用WaveGesture的Update方法。该方法循环遍历每一个用户的骨骼数据帧,然后调用TrackWave方法对左右手进行挥手姿势识别。当骨骼数据不在追踪状态时,重置手势识别状态。
public class WaveGesture { private const float WAVE_THRESHOLD = 0.1f; private const int WAVE_MOVEMENT_TIMEOUT = 5000; private const int LEFT_HAND = 0; private const int RIGHT_HAND = 1; private const int REQUIRED_ITERATIONS = 4; private WaveGestureTracker[,] _PlayerWaveTracker = new WaveGestureTracker[6, 2]; public event EventHandler GestureDetected; public void Update(Skeleton[] skeletons, long frameTimestamp) { if (skeletons != null) { Skeleton skeleton; for (int i = 0; i < skeletons.Length; i++) { skeleton = skeletons[i]; if (skeleton.TrackingState != SkeletonTrackingState.NotTracked) { TrackWave(skeleton, true, ref this._PlayerWaveTracker[i, LEFT_HAND], frameTimestamp); TrackWave(skeleton, false, ref this._PlayerWaveTracker[i, RIGHT_HAND], frameTimestamp); } else { this._PlayerWaveTracker[i, LEFT_HAND].Reset(); this._PlayerWaveTracker[i, RIGHT_HAND].Reset(); } } } } }
下面的代码是挥手姿势识别的主要逻辑方法TrackWave的主体部分。它验证我们先前定义的构成挥手姿势的条件,并更新手势识别的状态。方法识别左手或者右手的手势,第一个条件是验证,手和肘关节点是否处于追踪状态。如果这两个关节点信息不可用,则重置追踪状态,否则进行下一步的验证。
如果姿势持续时间超过阈值且还没有进入到下一步骤,在姿势追踪超时,重置追踪数据。下一个验证手部关节点是否在肘关节点之上。如果不是,则根据当前的追踪状态,挥手姿势识别失败或者重置识别条件。如果手部关节点在Y轴上且高于肘部关节点,方法继续判断手在Y轴上相对于肘关节的位置。调用UpdatePosition方法并传入合适的手关节点所处的位置。更新手关节点位置之后,最后判断定义的重复次数是否满足,如果满足这些条件,挥手这一手势识别成功,触发GetstureDetected事件。
private void TrackWave(Skeleton skeleton, bool isLeft, ref WaveGestureTracker tracker, long timestamp) { JointType handJointId = (isLeft) ? JointType.HandLeft : JointType.HandRight; JointType elbowJointId = (isLeft) ? JointType.ElbowLeft : JointType.ElbowRight; Joint hand = skeleton.Joints[handJointId]; Joint elbow = skeleton.Joints[elbowJointId]; if (hand.TrackingState != JointTrackingState.NotTracked && elbow.TrackingState != JointTrackingState.NotTracked) { if (tracker.State == WaveGestureState.InProgress && tracker.Timestamp + WAVE_MOVEMENT_TIMEOUT < timestamp) { tracker.UpdateState(WaveGestureState.Failure, timestamp); System.Diagnostics.Debug.WriteLine("Fail!"); } else if (hand.Position.Y > elbow.Position.Y) { //使用 (0, 0) 作为屏幕的中心. 从用户的视角看, X轴左负右正. if (hand.Position.X <= elbow.Position.X - WAVE_THRESHOLD) { tracker.UpdatePosition(WavePosition.Left, timestamp); } else if (hand.Position.X >= elbow.Position.X + WAVE_THRESHOLD) { tracker.UpdatePosition(WavePosition.Right, timestamp); } else { tracker.UpdatePosition(WavePosition.Neutral, timestamp); } if (tracker.State != WaveGestureState.Success && tracker.IterationCount == REQUIRED_ITERATIONS) { tracker.UpdateState(WaveGestureState.Success, timestamp); System.Diagnostics.Debug.WriteLine("Success!"); if (GestureDetected != null) { GestureDetected(this, new EventArgs()); } } } else { if (tracker.State == WaveGestureState.InProgress) { tracker.UpdateState(WaveGestureState.Failure, timestamp); System.Diagnostics.Debug.WriteLine("Fail!"); } else { tracker.Reset(); } } } else { tracker.Reset(); } }
下面的代码添加到WaveGestureTracker结构中:这些帮助方法维护结构中的字段,使得TrackWave方法易读。唯一需要注意的是UpdatePosition方法。TrackWave调用该方法判断手的位置已经移动。他的最主要目的是更新CurrentPosition和Timestamp属性,该方法也负责更新InterationCount字段合InPorgress状态。
public void UpdateState(WaveGestureState state, long timestamp) { State = state; Timestamp = timestamp; } public void Reset() { IterationCount = 0; State = WaveGestureState.None; Timestamp = 0; StartPosition = WavePosition.None; CurrentPosition = WavePosition.None; } public void UpdatePosition(WavePosition position, long timestamp) { if (CurrentPosition != position) { if (position == WavePosition.Left || position == WavePosition.Right) { if (State != WaveGestureState.InProgress) { State = WaveGestureState.InProgress; IterationCount = 0; StartPosition = position; } IterationCount++; } CurrentPosition = position; Timestamp = timestamp; } }
上述代码片段就可以实现挥动(wave)手势识别的逻辑了。
本文主要介绍了手势识别中设计的基本概念以及手势识别的发展过程,在此基础上介绍了手势识别的三种基本方法:基于算法的手势识别、基于神经网络的手势识别和基于样本库的手势识别。
手部追踪在技术上和手势识别不同,但是它和手势识别中用到的一些基本方法是一样的。在开发一个具体的手势控件之前,我们先建立一个可重用的追踪手部运动的类库以方便我们后续开发。这个手部追踪类库包含一个以动态光标显示的可视化反馈机制。手部追踪和手势控件之间的交互高度松耦合。
首先在Visual Studio中创建一个WPF控件类库项目。然后添加四个类: KinectCursorEventArgs.cs,KinectInput.cs,CusrorAdorner.cs和KinectCursorManager.cs这四个类之间通过相互调用来基于用户手所在的位置来完成光标位置的管理。KinectInput类包含了一些事件,这些事件可以在KinectCursorManager和一些控件之间共享。KinectCursorEventArgs提供了一个属性集合,能够用来在事件触发者和监听者之间传递数据。KinectCursorManager用来管理从Kinect传感器中获取的骨骼数据流,然后将其转换到WPF坐标系统,提供关于转换到屏幕位置的可视化反馈,并寻找屏幕上的控件,将事件传递到这些控件上。最后CursorAdorner.cs类包含了代表手的图标的可视化元素。
KinectCursorEventArgs继承自RoutedEventArgs类,它包含四个属性:X、Y、Z和Cursor。X、Y、Z是一个小数,代表待转换的用户手所在位置的宽度,高度和深度值。Cursor用来存储CursorAdorner类的实例,后面将会讨论,下面的代码展示了KinectCursorEventArgs类的基本结构,其中包含了一些重载的构造器。
ass=code> public class KinectCursorEventArgs: RoutedEventArgs{ public double X { get; set; } public double Y { get; set; } public double Z { get; set; } public CursorAdorner Cursor { get; set; } public KinectCursorEventArgs( double x, double y) { X = x; Y = y; } public KinectCursorEventArgs( Point point) { X = point.X; Y = point.Y; }}
RoutedEventArgs基类有一个构造函数能够接收RoutedEvent作为参数。这是一个有点特别的签名,WPF中的UIElement使用这种特殊的语法触发事件。下面的代码是KinectCursorEventArgs类对这一签名的实现,以及其他一些重载方法。
public KinectCursorEventArgs(RoutedEventroutedEvent) : base(routedEvent) { }
publicKinectCursorEventArgs(RoutedEventroutedEvent, doublex, doubley, doublez)
: base(routedEvent) { X = x; Y = y; Z = z; }
publicKinectCursorEventArgs(RoutedEventroutedEvent, Pointpoint)
: base(routedEvent) { X = point.X; Y = point.Y; }
publicKinectCursorEventArgs(RoutedEventroutedEvent, Pointpoint,doublez)
: base(routedEvent) { X = point.X; Y = point.Y; Z = z; }
publicKinectCursorEventArgs(RoutedEventroutedEvent, objectsource)
: base(routedEvent, source) {}
publicKinectCursorEventArgs(RoutedEventroutedEvent,objectsource,doublex,doubley,doublez)
: base(routedEvent, source) { X = x; Y = y; Z = z; }
publicKinectCursorEventArgs(RoutedEventroutedEvent, objectsource, Pointpoint)
: base(routedEvent, source) { X = point.X; Y = point.Y; } publicKinectCursorEventArgs(RoutedEventroutedEvent, objectsource, Pointpoint,doublez)
: base(routedEvent, source) { X = point.X; Y = point.Y; Z = z; }
接下来,要在KinectInput类中创建事件来将消息从KinectCursorManager中传递到可视化控件中去。这些事件传递的数据类型为KinectCursorEventArgs类型。
在KinectInput类中添加一个KinectCursorEventHandler的代理类型:(1) 添加一个静态的routed event声明。(2) 添加KinectCursorEnter,KinectCursorLeave,KinectCursorMove,KinectCursorActive和KinectCursorDeactivated事件的add和remove方法。下面的代码展示了三个和cursor相关的事件,其他的如KinectCursorActivated和KinectCursorDeactivated事件和这个结构相同:
public delegate void KinectCursorEventHandler(object sender,KinectCursorEventArgs e); public static class KinectInput { public static readonly RoutedEvent KinectCursorEnterEvent=EventManager.RegisterRoutedEvent("KinectCursorEnter",RoutingStrategy.Bubble, typeof(KinectCursorEventHandler),typeof(KinectInput)); public static void AddKinectCursorEnterHandler(DependencyObject o, KinectCursorEventHandler handler) { ((UIElement)o).AddHandler(KinectCursorEnterEvent, handler); } public static void RemoveKinectCursorEnterHandler(DependencyObject o, KinectCursorEventHandler handler) { ((UIElement)o).RemoveHandler(KinectCursorEnterEvent, handler); } public static readonly RoutedEvent KinectCursorLeaveEvent=EventManager.RegisterRoutedEvent("KinectCursorLeave",RoutingStrategy.Bubble, typeof(KinectCursorEventHandler),typeof(KinectInput)); public static void AddKinectCursorLeaveHandler(DependencyObject o, KinectCursorEventHandler handler) { ((UIElement)o).AddHandler(KinectCursorEnterEvent,handler); } public static void RemoveKinectCursorLeaveHandler(DependencyObject o, KinectCursorEventHandler handler) { ((UIElement)o).RemoveHandler(KinectCursorEnterEvent, handler); } }
注意到以上代码中没有声明任何GUI编程中的Click事件。这是因为在设计控件类库时,Kinect中并没有点击事件,相反Kinect中两个重要的行为是enter和leave。手势图标可能会移入和移出某一个可视化控件的有效区域。如果要实现普通GUI控件的点击效果的话,必须在Kinect中对这一事件进行模拟,因为Kinect原生并不支持点击这一行为。
CursorAdorner类用来保存用户手势图标可视化元素,它继承自WPF的Adorner类型。之所以使用这个类型是因为它有一个特点就是总是在其他元素之上绘制,这在我们的项目中非常有用,因为我们不希望我们的光标会被其他元素遮挡住。代码如下所示,我们默认的adorner对象将绘制一个默认的可视化元素来代表光标,当然也可以传递一个自定义的可视化元素。
public class CursorAdorner:Adorner { private readonly UIElement _adorningElement; private VisualCollection _visualChildren; private Canvas _cursorCanvas; protected FrameworkElement _cursor; StroyBoard _gradientStopAnimationStoryboard; readonly static Color _backColor = Colors.White; readonly static Color _foreColor = Colors.Gray; public CursorAdorner(FrameworkElement adorningElement) : base(adorningElement) { this._adorningElement = adorningElement; CreateCursorAdorner(); this.IsHitTestVisible = false; } public CursorAdorner(FrameworkElement adorningElement, FrameworkElement innerCursor) : base(adorningElement) { this._adorningElement = adorningElement; CreateCursorAdorner(innerCursor); this.IsHitTestVisible = false; } public FrameworkElement CursorVisual { get { return _cursor; } } public void CreateCursorAdorner() { var innerCursor = CreateCursor(); CreateCursorAdorner(innerCursor); } protected FrameworkElement CreateCursor() { var brush = new LinearGradientBrush(); brush.EndPoint = new Point(0, 1); brush.StartPoint = new Point(0, 0); brush.GradientStops.Add(new GradientStop(_backColor, 1)); brush.GradientStops.Add(new GradientStop(_foreColor, 1)); var cursor = new Ellipse() { Width=50, Height=50, Fill=brush }; return cursor; } public void CreateCursorAdorner(FrameworkElement innerCursor) { _visualChildren = new VisualCollection(this); _cursorCanvas = new Canvas(); _cursor = innerCursor; _cursorCanvas.Children.Add(this._cursorCanvas); _visualChildren.Add(this._cursorCanvas); AdornerLayer layer = AdornerLayer.GetAdornerLayer(_adorningElement); layer.Add(this); } }
因为继承自Adorner基类,我们需要重写某些基类的方法,下面的代码展示了基类中的方法如何和CreateCursorAdorner方法中实例化的_visualChildren和_cursorCanvas字段进行绑定。
protected override int VisualChildrenCount { get { return _visualChildren.Count; } } protected override Visual GetVisualChild(int index) { return _visualChildren[index]; } protected override Size MeasureOverride(Size constraint) { this._cursorCanvas.Measure(constraint); return this._cursorCanvas.DesiredSize; } protected override Size ArrangeOverride(Size finalSize) { this._cursorCanvas.Arrange(new Rect(finalSize)); return finalSize; }
CursorAdorner对象也负责找到手所在的正确的位置,该对象的UpdateCursor方法如下,方法接受X,Y坐标位置作为参数。然后方法在X,Y上加一个偏移量以使得图像的中心在X,Y之上,而不是在图像的边上。另外,我们提供了该方法的一个重载,该重载告诉光标对象一个特殊的坐标会传进去,所有的普通方法调用UpdateCursor将会被忽略。当我们在磁性按钮中想忽略基本的手部追踪给用户更好的手势体验时很有用。
public void UpdateCursor(Pointposition, boolisOverride)
{
_isOverriden = isOverride;
_cursor.SetValue(Canvas.LeftProperty,position.X-(_cursor.ActualWidth/2));
_cursor.SetValue(Canvas.LeftProperty, position.Y - (_cursor.ActualHeight / 2));
}
public void UpdateCursor(Pointposition)
{
if(_isOverriden) return;
_cursor.SetValue(Canvas.LeftProperty, position.X - (_cursor.ActualWidth / 2));
_cursor.SetValue(Canvas.LeftProperty, position.Y - (_cursor.ActualHeight / 2));
}
最后,添加光标对象动画效果。当Kinect控件需要悬浮于一个元素之上,在用户等待的时候,给用户反馈一些信息告知正在发生的事情,这一点很有好处。下面了的代码展示了如何使用代码实现动画效果:
public virtual void AnimateCursor(doublemilliSeconds) {
CreateGradientStopAnimation(milliSeconds);
if(_gradientStopAnimationStoryboard != null)
_gradientStopAnimationStoryboard.Begin(this, true);
}
public virtual void StopCursorAnimation(doublemilliSeconds)
{
if(_gradientStopAnimationStoryboard != null)
_gradientStopAnimationStoryboard.Stop(this);
}
public virtual void CreateGradientStopAnimation(doublemilliSeconds) {
NameScope.SetNameScope(this, newNameScope());
varcursor = _cursor asShape;
if(cursor == null)
return;
varbrush = cursor.Fill asLinearGradientBrush;
varstop1 = brush.GradientStops[0];
varstop2 = brush.GradientStops[1];
this.RegisterName("GradientStop1", stop1);
this.RegisterName("GradientStop2", stop2);
DoubleAnimationoffsetAnimation = newDoubleAnimation();
offsetAnimation.From = 1.0;
offsetAnimation.To = 0.0;
offsetAnimation.Duration = TimeSpan.FromMilliseconds(milliSeconds);
Storyboard.SetTargetName(offsetAnimation, "GradientStop1");
Storyboard.SetTargetProperty(offsetAnimation,
newPropertyPath(GradientStop.OffsetProperty));
DoubleAnimationoffsetAnimation2 = newDoubleAnimation();
offsetAnimation2.From = 1.0;
offsetAnimation2.To = 0.0;
offsetAnimation2.Duration = TimeSpan.FromMilliseconds(milliSeconds);
Storyboard.SetTargetName(offsetAnimation2, "GradientStop2");
Storyboard.SetTargetProperty(offsetAnimation2,
newPropertyPath(GradientStop.OffsetProperty));
_gradientStopAnimationStoryboard = newStoryboard();
_gradientStopAnimationStoryboard.Children.Add(offsetAnimation);
_gradientStopAnimationStoryboard.Children.Add(offsetAnimation2);
_gradientStopAnimationStoryboard.Completed += delegate{ _gradientStopAnimationStoryboard.Stop(this); };
}
为了实现KinectCursorManager类,我们需要几个帮助方法,代码如下,GetElementAtScreenPoint方法告诉我们哪个WPF对象位于X,Y坐标下面,在这个高度松散的结构中,GetElementAtScreenPoint方法是主要的引擎,用来从KinectCurosrManager传递消息到自定义控件,并接受这些事件。另外,我们使用两个方法来确定我们想要追踪的骨骼数据以及我们想要追踪的手。
private static UIElement GetElementAtScreenPoint(Point point, Window window) { if (!window.IsVisible) return null; Point windowPoint = window.PointFromScreen(point); IInputElement element = window.InputHitTest(windowPoint); if (element is UIElement) return (UIElement)element; else return null; } private static Skeleton GetPrimarySkeleton(IEnumerable<Skeleton> skeletons) { Skeleton primarySkeleton = null; foreach (Skeleton skeleton in skeletons) { if (skeleton.TrackingState != SkeletonTrackingState.Tracked) { continue; } if (primarySkeleton == null) primarySkeleton = skeleton; else if (primarySkeleton.Position.Z > skeleton.Position.Z) primarySkeleton = skeleton; } return primarySkeleton; } private static Joint? GetPrimaryHand(Skeleton skeleton) { Joint leftHand=skeleton.Joints[JointType.HandLeft]; Joint rightHand=skeleton.Joints[JointType.HandRight]; if (rightHand.TrackingState == JointTrackingState.Tracked) { if (leftHand.TrackingState != JointTrackingState.Tracked) return rightHand; else if (leftHand.Position.Z > rightHand.Position.Z) return rightHand; else return leftHand; } if (leftHand.TrackingState == JointTrackingState.Tracked) { return leftHand; } else return null; }
KinectCursorManager应该是一个单例类。这样设计是能够使得代码实例化起来简单。任何和KinectCursorManager工作的控件在KinectCursorManager没有实例化的情况下可以独立的进行KinectCursorManager的实例化。这意味着任何开发者使用这些控件不需要了解KinectCursorManager对象本身。相反,开发者能够简单的将控件拖动到应用程序中,控件负责实例化KinectCursorManager对象。为了使得这种自服务功能能和KinectCursorMange类一起使用,我们需要创建一个重载的Create方法来将应用程序的主窗体类传进来。下面的代码展示了重载的构造函数以及特殊的单例模式的实现方法。
public class KinectCursorManager { private KinectSensor kinectSensor; private CursorAdorner cursorAdorner; private readonly Window window; private UIElement lastElementOver; private bool isSkeletonTrackingActivated; private static bool isInitialized; private static KinectCursorManager instance; public static void Create(Window window) { if (!isInitialized) { instance = new KinectCursorManager(window); isInitialized = true; } } public static void Create(Window window,FrameworkElement cursor) { if (!isInitialized) { instance = new KinectCursorManager(window,cursor); isInitialized = true; } } public static void Create(Window window, KinectSensor sensor) { if (!isInitialized) { instance = new KinectCursorManager(window, sensor); isInitialized = true; } } public static void Create(Window window, KinectSensor sensor, FrameworkElement cursor) { if (!isInitialized) { instance = new KinectCursorManager(window, sensor, cursor); isInitialized = true; } } public static KinectCursorManager Instance { get { return instance; } } private KinectCursorManager(Window window) : this(window, KinectSensor.KinectSensors[0]) { } private KinectCursorManager(Window window, FrameworkElement cursor) : this(window, KinectSensor.KinectSensors[0], cursor) { } private KinectCursorManager(Window window, KinectSensor sensor) : this(window, sensor, null) { } private KinectCursorManager(Window window, KinectSensor sensor, FrameworkElement cursor) { this.window = window; if (KinectSensor.KinectSensors.Count > 0) { window.Unloaded += delegate { if (this.kinectSensor.SkeletonStream.IsEnabled) this.kinectSensor.SkeletonStream.Disable(); }; window.Loaded += delegate { if (cursor == null) cursorAdorner = new CursorAdorner((FrameworkElement)window.Content); else cursorAdorner = new CursorAdorner((FrameworkElement)window.Content, cursor); this.kinectSensor = sensor; this.kinectSensor.SkeletonFrameReady += SkeletonFrameReady; this.kinectSensor.SkeletonStream.Enable(new TransformSmoothParameters()); this.kinectSensor.Start(); }; } } ……
下面的代码展示了KinectCursorManager如何和窗体上的可视化元素进行交互。当用户的手位于应用程序可视化元素之上时,KinectCursorManager对象始终保持对当前手所在的可视化元素以及之前手所在的可视化元素的追踪。当这一点发生改变时,KinectCursorManager会触发之前控件的leave事件和当前控件的enter事件。我们也保持对KinectSensor对象的追踪,并触发activated和deactivated事件。
private void SetSkeletonTrackingActivated() { if (lastElementOver != null && isSkeletonTrackingActivated == false) { lastElementOver.RaiseEvent(new RoutedEventArgs(KinectInput.KinectCursorActivatedEvent)); } isSkeletonTrackingActivated = true; } private void SetSkeletonTrackingDeactivated() { if (lastElementOver != null && isSkeletonTrackingActivated == false) { lastElementOver.RaiseEvent(new RoutedEventArgs(KinectInput.KinectCursorDeactivatedEvent)); } isSkeletonTrackingActivated = false ; } private void HandleCursorEvents(Point point, double z) { UIElement element = GetElementAtScreenPoint(point, window); if (element != null) { element.RaiseEvent(new KinectCursorEventArgs(KinectInput.KinectCursorMoveEvent, point, z) {Cursor=cursorAdorner }); if (element != lastElementOver) { if (lastElementOver != null) { lastElementOver.RaiseEvent(new KinectCursorEventArgs(KinectInput.KinectCursorLeaveEvent, point, z) { Cursor = cursorAdorner }); } element.RaiseEvent(new KinectCursorEventArgs(KinectInput.KinectCursorEnterEvent, point, z) { Cursor = cursorAdorner }); } } lastElementOver = element; }
最后需要两个核心的方法来管理KinectCursorManger类。SkeletonFrameReady方法与之前一样,用来从Kinect获取骨骼数据帧时触发的事件。在这个项目中,SkeletonFrameReady方法负责获取合适的骨骼数据,然后获取合适的手部关节点数据。然后将手部关节点数据传到UpdateCusror方法中,UpdateCursor方法执行一系列方法将Kinect骨骼空间坐标系转化到WPF的坐标系统中,Kinect SDK中MapSkeletonPointToDepth方法提供了这一功能。SkeletonToDepthImage方法返回的X,Y值,然后转换到应用程序中实际的宽和高。和X,Y不一样,Z值进行了不同的缩放操作。简单的从Kinect深度摄像机中获取的毫米数据。代码如下,一旦这些坐标系定义好了之后,将他们传递到HandleCursorEvents方法然后CursorAdorner对象将会给用户以反馈。相关代码如下:
private void SkeletonFrameReady(objectsender, SkeletonFrameReadyEventArgse)
{
using(SkeletonFrameframe = e.OpenSkeletonFrame())
{
if(frame == null|| frame.SkeletonArrayLength == 0) return;
Skeleton[] skeletons = newSkeleton[frame.SkeletonArrayLength];
frame.CopySkeletonDataTo(skeletons);
Skeletonskeleton = GetPrimarySkeleton(skeletons);
if(skeleton == null)
{
SetHandTrackingDeactivated();
}
else
{
Joint? primaryHand = GetPrimaryHand(skeleton);
if(primaryHand.HasValue)
{
UpdateCursor(primaryHand.Value);
}
else
{
SetHandTrackingDeactivated();
}
}
}
}
private voidSetHandTrackingDeactivated()
{
cursorAdorner.SetVisibility(false);
if(lastElementOver != null&& isHandTrackingActivated == true)
{lastElementOver.RaiseEvent(newRoutedEventArgs(KinectInput.KinectCursorDeactivatedEvent)); };
isHandTrackingActivated = false;
}
private voidUpdateCursor(Jointhand)
{
varpoint = kinectSensor.MapSkeletonPointToDepth(hand.Position, kinectSensor.DepthStream.Format);
floatx = point.X;
floaty = point.Y;
floatz = point.Depth;
x = (float)(x * window.ActualWidth / kinectSensor.DepthStream.FrameWidth);
y = (float)(y * window.ActualHeight / kinectSensor.DepthStream.FrameHeight);
PointcursorPoint = newPoint(x, y);
HandleCursorEvents(cursorPoint, z);
cursorAdorner.UpdateCursor(cursorPoint);
}
至此,我们已经简单实现了一些基础结构,这些仅仅是实现了将用户手部的运动显示在屏幕上。现在我们要创建一个基类来监听光标对象的事件,首先创建一个KinectButton对象,该对象继承自WPF Button类型。定义三个之前在KinectInput中定义好的事件,同时创建这些事件的添加删除方法,代码如下:
public class KinectButton:Button
{
public static readonlyRoutedEventKinectCursorEnterEvent = KinectInput.KinectCursorEnterEvent.AddOwner(typeof(KinectButton));
public static readonlyRoutedEventKinectCursorLeaveEvent = KinectInput.KinectCursorLeaveEvent.AddOwner(typeof(KinectButton));
public static readonlyRoutedEventKinectCursorMoveEvent = KinectInput.KinectCursorMoveEvent.AddOwner(typeof(KinectButton));
public static readonlyRoutedEventKinectCursorActivatedEvent = KinectInput.KinectCursorActivatedEvent.AddOwner(typeof(KinectButton));
public static readonlyRoutedEventKinectCursorDeactivatedEvent = KinectInput.KinectCursorDeactivatedEvent.AddOwner(typeof(KinectButton));
public eventKinectCursorEventHandlerKinectCursorEnter
{
add{ base.AddHandler(KinectCursorEnterEvent, value); }
remove{ base.RemoveHandler(KinectCursorEnterEvent, value); }
}
public eventKinectCursorEventHandlerKinectCursorLeave
{
add{ base.AddHandler(KinectCursorLeaveEvent, value); }
remove{ base.RemoveHandler(KinectCursorLeaveEvent, value); }
}
public eventKinectCursorEventHandlerKinectCursorMove
{
add{ base.AddHandler(KinectCursorMoveEvent, value); }
remove{ base.RemoveHandler(KinectCursorMoveEvent, value); }
}
public eventRoutedEventHandlerKinectCursorActivated
{
add{ base.AddHandler(KinectCursorActivatedEvent, value); }
remove{ base.RemoveHandler(KinectCursorActivatedEvent, value); }
}
public eventRoutedEventHandlerKinectCursorDeactivated
{
add{ base.AddHandler(KinectCursorDeactivatedEvent, value); }
remove{ base.RemoveHandler(KinectCursorDeactivatedEvent, value); }
}
}
在KinectButton的构造函数中,首先检查当前控件是否运行在IDE或者一个实际的应用程序中。如果没有在设计器中,如果KinectCursorManager对象不存在,我们实例化KinectCursorManager对象。通过这种方式,我们可以在同一个窗体上添加多个Kinect 按钮。这些按钮自动创建KinectCursorManager的实例而不用开发者去创建。下面的代码展示了如何实现这一功能。KinectCursorManager类中的HandleCursorEvents方法负责处理这些事件。
public KinectButton()
{
if(!System.ComponentModel.DesignerProperties.GetIsInDesignMode(this))
KinectCursorManager.Create(Application.Current.MainWindow);
this.KinectCursorEnter+=newKinectCursorEventHandler(OnKinectCursorEnter); this.KinectCursorLeave+=newKinectCursorEventHandler(OnKinectCursorLeave);
this.KinectCursorMove+=newKinectCursorEventHandler(OnKinectCursorMove);
}
protected virtual voidOnKinectCursorLeave(Objectsender, KinectCursorEventArgse)
{ }
protected virtual voidOnKinectCursorMove(Objectsender, KinectCursorEventArgse)
{ }
下面的代码中,KinectCursorEnter事件中触发ClickEvent,将其改造成了一个标准的点击事件。使得KinectButton能够在鼠标移入时触发Click事件。Kinect中应用程序的交互术语还是使用之前GUI交互界面中的术语,这使得读者能够更容易理解。更重要的是,也能够使得开发者更容易理解,因为我们之前有很多使用按钮来构造用户界面的经验。当然终极的目标是舍弃这些各种各样的控件,改而使用纯粹的手势交互界面,但是按钮在现阶段的交互界面中还是很重要的。另外,这样也能够使用按钮来布局图形用户界面,只需要将普通的按钮换成Kinect按钮就可以了。
protected virtual void OnKinectCursorEnter(object sender, KinectCursorEventArgs e) { RaiseEvent(new RoutedEventArgs(ClickEvent)); }
这种控件有一个最大的问题,在大多数基于Kinect的应用程序中你看不到这个问题,那就是,你不能区分开是有意的还是无意的点击。在传统的基于鼠标的GUI应用中也有类似的倾向,每一次将鼠标移动到按钮上不用点击就会激活按钮。这种用户界面很容易不能使用,这也提醒了一个潜在的值得注意的问题,那就是将按钮从图形用户界面中移植到其他界面中可能存在的问题。悬浮按钮是微软试图解决这一特殊问题的一个尝试。
前面的文章中已经讲述了挥手手势的识别,本文接下来讲解余下7中常见手势的识别。
悬浮按钮是微软在2010年为Kinect对Xbox的操纵盘进行改进而引入的。 悬浮按钮通过将鼠标点击换成悬浮然后等待(hover-and-wait)动作,解决了不小心点击的问题。当光标位于按钮之上时,意味着用户通过将光标悬浮在按钮上一段时间来表示想选中按钮。另一个重要特点是悬浮按钮在用户悬浮并等待时,多少提供了视觉反馈。
在Kinect中实现悬浮按钮和在Windows Phone开发中实现轻点然后维持(tap-and-hold)这一手势在技术上比较类似。必须使用一个计时器来记录当前用户光标停留在按钮上的时间。一旦用户的手的光标和按钮的边界交叉就开始计时。如果某一个时间阈值内用户光标还没有移除,那么就触发点击事件。
创建一个名为HoverButton的类,他继承自之前创建的KinectButton类,在类中添加一个名为hoverTimer的DispatcherTime实例,代码如下。另外创建一个布尔型的timerEnable字段,将其设置为true。虽然目前不会用到这个字段,但是在后面部分将会用到,当我们想使用HoverButton的某些功能,但是不需要DispatcherTimer时就会非常有用。最后创建一个HoverInterval的依赖属性,使得运行我们将悬浮时间用代码或者xaml进行定义。默认设置为2秒,这是在大多是Xbox游戏中的时间。
public class HoverButton:KinectButton
{
readonlyDispatcherTimerhoverTimer = newDispatcherTimer();
protected booltimerEnabled = true;
public doubleHoverInterval
{
get{ return(double)GetValue(HoverIntervalProperty); }
set
{
SetValue(HoverIntervalProperty, value);
}
}
public static readonlyDependencyPropertyHoverIntervalProperty =
DependencyProperty.Register("HoverInterval", typeof(double), typeof(HoverButton), newUIPropertyMetadata(2000d));
……}
要实现悬浮按钮的核心功能,我们必须覆写基类中的OnKinectCursorLeave和OnKinectCursorEnter方法,所有和KinectCursorManger进行交互的部分在KinectButton中已经实现了,因此我们在这里不用操心。在类的构造方法中,只需要实例化DispathcerTimer对象,HoverInterval依赖属性和注册hoverTimer_Tick方法到计时器的Tick事件上即可。计时器在一定的间隔时间会触发Tick事件,该事件简单的处理一个Click事件,在OnKinectCursorEnter方法中启动计数器,在OnKinectCursorLeave事件中停止计数器。另外,重要的是,在enter和leave方法中启动和停止鼠标光标动画效果。
public HoverButton()
{
hoverTimer.Interval = TimeSpan.FromMilliseconds(HoverInterval);
hoverTimer.Tick += newEventHandler(hoverTimer_Tick);
hoverTimer.Stop();
}
voidhoverTimer_Tick(objectsender, EventArgse)
{
hoverTimer.Stop();
RaiseEvent(newRoutedEventArgs(ClickEvent));
}
protected override voidOnKinectCursorLeave(objectsender, KinectCursorEventArgse)
{
if(timerEnabled)
{
e.Cursor.StopCursorAnimation();
hoverTimer.Stop();
}
}
protected override voidOnKinectCursorEnter(objectsender, KinectCursorEventArgse)
{
if(timerEnabled)
{
hoverTimer.Interval = TimeSpan.FromMilliseconds(HoverInterval);
e.Cursor.AnimateCursor(HoverInterval);
hoverTimer.Start();
}
}
悬浮按钮在基于Kinect的Xbox游戏中几乎无处不在。悬浮按钮唯一存在的问题是,光标手势悬停在按钮上时会抖动,这可能是Kinect中骨骼识别本身的问题。当在运动状态时,Kinect能够很好的对这些抖动进行平滑,因为即使在快速移动状态下,Kinect中的软件使用了一系列预测和平滑技术来对抖动进行处理。姿势,和上面的悬停一样,因为是静止的,所以可能存在抖动的问题。另外,用户一般不会保持手势静止,即使他们想哪样做。Kinect将这些小的运动返回给用户。当用户什么都没做时,抖动的手可能会破坏手势的动画效果。对悬浮按钮的一个改进就是磁性按钮(Magnet Button),随着体感游戏的升级,这种按钮逐渐取代了之前的悬浮按钮,后面我们将看到如何实现磁性按钮。
就像悬浮按钮在Xbox中那样普遍一样,一些Kinect开发者也想创建一些类似PC上的那种交互方式的按钮,这种按钮称之为下压按钮(push button)。下压按钮试图将传统的GUI界面上的按钮移植到Kinect上去。为了代替鼠标点击,下压按钮使用一种将手向前推的手势来表示按下这一动作。
这种手势,手掌张开向前,在形式上有点像动态鼠标。下压按钮的核心算法就是探测手势在Z轴上有一个向负方向的运动。另外,相符方向必须有一个距离阈值,使得超过这一阈值就认为用户想要执行下压指令。代码如下所示:下压按钮有一个称之为Threshold的依赖属性,单位为毫米,这个值可以由开发者来根据动作的灵敏度来进行设置。当用户的手移动到下压按钮的上方时,我们记录一下当前位置手的Z值,以此为基准,然后比较手的深度值和阈值,如果超过阈值,就触发点击事件。
public class PushButton:KinectButton { protected double handDepth; public double PushThreshold { get { return (double)GetValue(PushThresholdProperty); } set { SetValue(PushThresholdProperty, value); } } public static readonly DependencyProperty PushThresholdProperty = DependencyProperty.Register("PushThreshold", typeof(double), typeof(PushButton), new UIPropertyMetadata(100d)); protected override void OnKinectCursorMove(object sender, KinectCursorEventArgs e) { if (e.Z < handDepth - PushThreshold) { RaiseEvent(new RoutedEventArgs(ClickEvent)); } } protected override void OnKinectCursorEnter(object sender, KinectCursorEventArgs e) { handDepth = e.Z; } }
如前面所讨论的,磁性按钮是对悬浮按钮的一种改进。他对用户悬浮在按钮上的这一体验进行了一些改进。他试图追踪用户手的位置,然后自动将光标对齐到磁性按钮的中间。当用户的手离开磁性按钮的区域是,手势追踪又恢复正常。在其他方面磁性按钮和悬浮按钮的行为一样。考虑到磁性按钮和悬浮按钮在功能方面差异很小,而我们将他单独作为一个完全不同的控件来对待可能有点奇怪。但是,在用户体验设计领域(UX),这一点差异就是一个完全不同的概念。从编码角度看,这一点功能性的差异也使得代码更加复杂。
首先,创建一个继承自HoverButton的名为MagnetButton的类。磁性按钮需要一些额外的事件和属性来管理手进入到磁性按钮区域和手自动对齐到磁性按钮中间区域的时间。我们需要在KinectInput类中添加新的lock和unlock事件,代码如下:
public static readonly RoutedEvent KinectCursorLockEvent = EventManager.RegisterRoutedEvent("KinectCursorLock", RoutingStrategy.Bubble, typeof(KinectCursorEventHandler), typeof(KinectInput)); public static void AddKinectCursorLockHandler(DependencyObject o, KinectCursorEventHandler handler) { ((UIElement)o).AddHandler(KinectCursorLockEvent, handler); } public static readonly RoutedEvent KinectCursorUnlockEvent = EventManager.RegisterRoutedEvent("KinectCursorUnlock", RoutingStrategy.Bubble, typeof(KinectCursorEventHandler), typeof(KinectInput)); public static void RemoveKinectCursorUnlockHandler(DependencyObject o, KinectCursorEventHandler handler) { ((UIElement)o).RemoveHandler(KinectCursorUnlockEvent, handler); }public class MagnetButton : HoverButton { protected bool isLockOn = true; public static readonly RoutedEvent KinectCursorLockEvent = KinectInput.KinectCursorUnlockEvent.AddOwner(typeof(MagnetButton)); public static readonly RoutedEvent KinectCursorUnlockEvent = KinectInput.KinectCursorLockEvent.AddOwner(typeof(MagnetButton)); private Storyboard move; public event KinectCursorEventHandler KinectCursorLock { add { base.AddHandler(KinectCursorLockEvent, value); } remove { base.RemoveHandler(KinectCursorLockEvent, value); } } public event KinectCursorEventHandler KinectCursorUnLock { add { base.AddHandler(KinectCursorUnlockEvent, value); } remove { base.RemoveHandler(KinectCursorUnlockEvent, value); } } public double LockInterval { get { return (double)GetValue(LockIntervalProperty); } set { SetValue(LockIntervalProperty, value); } } public static readonly DependencyProperty LockIntervalProperty = DependencyProperty.Register("LockInterval", typeof(double), typeof(MagnetButton), new UIPropertyMetadata(200d)); public double UnlockInterval { get { return (double)GetValue(UnlockIntervalProperty); } set { SetValue(UnlockIntervalProperty, value); } } public static readonly DependencyProperty UnlockIntervalProperty = DependencyProperty.Register("UnlockInterval", typeof(double), typeof(MagnetButton), new UIPropertyMetadata(80d)); ……}
磁性按钮的代码中,核心地方在于光标从当前位置移动到磁性按钮的中心位置。看起来很简单,实际上实现起来有点麻烦。需要重写基类中的OnKinectCursorEnter和OnKinectCursorLeave方法。确定磁性按钮的锁定位置第一步需要找到磁性按钮本身所处的位置。代码如下,我们使用WPF中最常见名为FindAncestor帮助方法来遍历可视化对象树来进行查找,需要找到承载该磁性按钮的Windows对象,匹配磁性按钮的当前实例到Windows上,然后将其赋给名为Point的变量。但是point对象只保存了当前磁性按钮的左上角的位置。所以,我们需要给在这个点上加一个磁性按钮一半长宽的偏移值,才能获取到磁性按钮的中心位置x,y。
private T FindAncestor(DependencyObjectdependencyObject) whereT:class
{
DependencyObjecttarget=dependencyObject;
do
{
target=VisualTreeHelper.GetParent(target);
}
while(target!=null&&!(target isT));
returntarget asT;
}
protected override void OnKinectCursorEnter(objectsender, KinectCursorEventArgse)
{
//获取按钮位置
varrootVisual=FindAncestor<Window>(this);
varpoint=this.TransformToAncestor(rootVisual).Transform(newPoint(0,0));
varx=point.X+this.ActualWidth/2;
vary=point.Y+this.ActualHeight/2;
varcursor=e.Cursor;
cursor.UpdateCursor(newPoint(e.X,e.Y),true);
//找到目的位置
PointlockPoint=newPoint(x-cursor.CursorVisual.ActualWidth/2,y-cursor.CursorVisual.ActualHeight/2);
//当前位置
PointcursorPoint=newPoint(e.X-cursor.CursorVisual.ActualWidth/2,e.Y-cursor.CursorVisual.ActualHeight/2);
//将光标从当前位置传送到目的位置
AnimateCursorToLockPosition(e,x,y,cursor,reflockPoint,refcursorPoint);
base.OnKinectCursorEnter(sender,e);
}
protected override void OnKinectCursorLeave(objectsender, KinectCursorEventArgse)
{
base.OnKinectCursorLeave(sender, e);
e.Cursor.UpdateCursor(newPoint(e.X,e.Y),false);
varrootVisual=FindAncestor<Window>(this);
varpoint=this.TransformToAncestor(rootVisual).Transform(newPoint(0,0));
varx=point.X+this.ActualWidth/2;
vary=point.Y+this.ActualHeight/2;
varcursor=e.Cursor;
//找到目的位置
PointlockPoint=newPoint(x-cursor.CursorVisual.ActualWidth/2,y-cursor.CursorVisual.ActualHeight/2);
//当前位置
PointcursorPoint=newPoint(e.X-cursor.CursorVisual.ActualWidth/2,e.Y-cursor.CursorVisual.ActualHeight/2);
AnimateCursorAwayFromLockPosition(e,cursor,reflockPoint,refcursorPoint);
}
接下来,我们用手所在的X,Y位置替换手势图标的位置。然而,我们也传入了第二个参数,告诉手势图标自动停止追踪手的位置一段时间。当用户看到光标不听手的使唤自动对齐到磁性按钮的中心,这可能有点不太友好。
虽然我们现在有了磁性按钮的中心位置,但是我们仍不能很好的将手势光标定位到中心。我们必须额外的给手势光标本身给一个一半长宽的偏移值,以使得手在光标的中心位置而不是在左上角。在完成这些操作之后,我们将最终的值赋给lockPoint变量。我们也执行了同样的操作来查找光标目前的左上角位置以及偏移量,并将其赋值给cursorPoint变量。有了这两个值,我们就可以从当前的位置使用动画移动到目标位置了。动画方法代码如下:
private void AnimateCursorAwayFromLockPosition(KinectCursorEventArgse,CursorAdornercursor,refPointlockPoint,refPointcursorPoint)
{
DoubleAnimationmoveLeft = newDoubleAnimation(lockPoint.X, cursorPoint.X, newDuration(TimeSpan.FromMilliseconds(UnlockInterval)));
Storyboard.SetTarget(moveLeft, cursor.CursorVisual);
Storyboard.SetTargetProperty(moveLeft, newPropertyPath(Canvas.LeftProperty));
DoubleAnimationmoveTop = newDoubleAnimation(lockPoint.Y, cursorPoint.Y, newDuration(TimeSpan.FromMilliseconds(UnlockInterval)));
Storyboard.SetTarget(moveTop, cursor.CursorVisual);
Storyboard.SetTargetProperty(moveTop, newPropertyPath(Canvas.TopProperty));
move = newStoryboard();
move.Children.Add(moveTop);
move.Children.Add(moveLeft);
move.Completed += delegate{
move.Stop(cursor);
cursor.UpdateCursor(newPoint(e.X, e.Y), false);
this.RaiseEvent(newKinectCursorEventArgs(KinectCursorUnlockEvent, newPoint(e.X, e.Y), e.Z) { Cursor = e.Cursor });
};
move.Begin(cursor, true);
}
private voidAnimateCursorToLockPosition(KinectCursorEventArgse,doublex,doubley,CursorAdornercursor,refPointlockPoint,refPointcursorPoint)
{
DoubleAnimationmoveLeft=newDoubleAnimation(cursorPoint.X,lockPoint.X,newDuration(TimeSpan.FromMilliseconds(LockInterval)));
Storyboard.SetTarget(moveLeft,cursor.CursorVisual);
Storyboard.SetTargetProperty(moveLeft,newPropertyPath(Canvas.LeftProperty));
DoubleAnimationmoveTop=newDoubleAnimation(cursorPoint.Y,lockPoint.Y,newDuration(TimeSpan.FromMilliseconds(LockInterval)));
Storyboard.SetTarget(moveTop,cursor.CursorVisual);
Storyboard.SetTargetProperty(moveTop,newPropertyPath(Canvas.TopProperty));
move=newStoryboard();
move.Children.Add(moveTop);
move.Children.Add(moveLeft);
move.Completed+=delegate
{
this.RaiseEvent(newKinectCursorEventArgs(KinectCursorLockEvent,newPoint(x,y),e.Z){Cursor=e.Cursor});
};
if(move!=null)
move.Stop(e.Cursor);
move.Begin(cursor,false);
}
在上面的lock和unlock动画中,我们等到动画结束时触发KinectCursorLock和KinectCursorUnlock事件。对于磁性按钮本身,这些事件用处不大。但是在后面可以给磁性幻灯片按钮提供一些帮助。
划动手势和挥手(wave)手势类似。识别划动手势需要不断的跟踪用户手部运动,并保持当前手的位置之前的手的位置。因为手势有一个速度阈值,我们需要追踪手运动的时间以及在三维空间中的坐标。下面的代码展示了存储手势位置点的X,Y,Z坐标以及时间值。如果熟悉图形学中的矢量计算,可以将这个认为是一个四维向量。将下面的结构添加到类库中。
public struct GesturePoint
{
public double X { get; set; }
public double Y { get; set; }
public double Z { get; set; }
public DateTime T { get; set; }
public override bool Equals(object obj)
{
var o = (GesturePoint)obj;
return (X == o.X) && (Y == o.Y) && (Z == o.Z)&&(T==o.T);
}
public override int GetHashCode()
{
return base.GetHashCode();
}
}
我们将在KinectCursorManager对象中实现划动手势识别的逻辑,这样在后面的磁吸幻灯片按钮中就可以复用这部分逻辑。实现代码如下,代码中为了支持划动识别,需要向KinectCurosrManager对象中添加几个字段。GesturePoints集合存储路径上的所有点,虽然我们会一边移除一些点然后添加新的点,但是该集合不可能太大。SwipeTime和swipeDeviation分别提供了划动手势经历的时间和划动手势在y轴上的偏移阈值。划动手势经历时间过长和划动手势路径偏移y值过大都会使得划动手势识别失败。我们会移除之前的路径上的点,然后添加新的划动手势上的点。SwipeLength提供了连续划动手势的阈值。我们提供了两个事件来处理划动手势识别成功和手势不合法两种情况。考虑到这是一个纯粹的手势,与GUI界面无关,所以在实现过程中不会使用click事件。
private List<GesturePoint> gesturePoints;
private bool gesturePointTrackingEnabled;
private double swipeLength, swipeDeviation;
private int swipeTime;
public event KinectCursorEventHandler swipeDetected;
public event KinectCursorEventHandler swipeOutofBoundDetected;
private double xOutOfBoundsLength;
private static double initialSwipeX;
xOutOfBoundsLength和initialSwipeX用来设置划动手势的开始位置。通常,我们并不关心挥划动手势的开始位置,只用在gesturePoints中寻找一定数量连续的点,然后进行模式匹配就可以了。但是有时候,我们只从某一个划动开始点来进行划动识别也很有用。例如如果在屏幕的边缘,我们实现水平滚动,在这种情况下,我们需要一个偏移阈值使得我们可以忽略在屏幕外的点,因为这些点不能产生手势。
下面的代码展示了一些帮助方法以及公共属性来管理手势追踪。GesturePointTrackingInitialize方法用来初始化各种手势追踪的参数。初始化好了划动手势之后,需要调用GesturePointTrackingStart方法。自然需要一个相应的GesturePointTrackingStop方法来结束挥动手势识别。最后我们需要提供两个重载的帮助方法ResetGesturePoint来管理一系列的我们不需要的手势点。
public void GesturePointTrackingInitialize(double swipeLength, double swipeDeviation, int swipeTime, double xOutOfBounds)
{
this.swipeLength = swipeLength; this.swipeDeviation = swipeDeviation;
this.swipeTime = swipeTime;
this.xOutOfBoundsLength = xOutOfBounds;
}
public void GesturePointTrackingStart()
{
if (swipeLength + swipeDeviation + swipeTime == 0)
throw new InvalidOperationException("挥动手势识别参数没有初始化!");
gesturePointTrackingEnabled = true;
}
public void GesturePointTrackingStop()
{
xOutOfBoundsLength = 0;
gesturePointTrackingEnabled = false;
gesturePoints.Clear();
}
public bool GesturePointTrackingEnabled
{
get { return gesturePointTrackingEnabled ; }
}
private void ResetGesturePoint(GesturePoint point)
{
bool startRemoving = false;
for (int i= gesturePoints.Count; i >=0; i--)
{
if (startRemoving)
gesturePoints.RemoveAt(i);
else
if (gesturePoints[i].Equals(point))
startRemoving = true;
}
}
private void ResetGesturePoint(int point)
{
if (point < 1)
return;
for (int i = point-1; i >=0; i--)
{
gesturePoints.RemoveAt(i);
}
}
划动(swipe)手势识别的核心算法在HandleGestureTracking方法中,代码如下。将KinectCursorManager中的UpdateCursor方法和Kinect中的骨骼追踪事件绑定。每一次当获取到新的坐标点时,HandGestureTracking方法将最新的GesturePoint数据添加到gesturePoints集合中去。然后执行一些列条件检查,首先判断新加入的点是否以手势开始位置为起点参考,偏离Y轴过远。如果是,抛出一个超出范围的事件,然后将所有之前累积的点清空,然后开始下一次的划动识别。其次,检查手势开始的时间和当前的时间,如果时间差大于阈值,那么移除开始处手势点,然后将紧接着的点作为手势识别的起始点。如果新的手的位置在这个集合中,就很好。紧接着,判断划动起始点的位置和当前位置的X轴上的距离是否超过了连续划动距离的阈值,如果超过了,则触发SwipeDetected事件,如果没有,我们可以有选择性的判断,当前位置的X点是否超过了划动识别的最大区间返回,然后触发对于的事件。然后我们等待新的手部点传到HandleGestureTracking方法中去。
private void HandleGestureTracking(float x, float y, float z) { if (!gesturePointTrackingEnabled) return; // check to see if xOutOfBounds is being used if (xOutOfBoundsLength != 0 && initialSwipeX == 0) { initialSwipeX = x; } GesturePoint newPoint = new GesturePoint() { X = x, Y = y, Z = z, T = DateTime.Now }; gesturePoints.Add(newPoint); GesturePoint startPoint = gesturePoints[0]; var point = new Point(x, y); //check for deviation if (Math.Abs(newPoint.Y - startPoint.Y) > swipeDeviation) { //Debug.WriteLine("Y out of bounds"); if (swipeOutofBoundDetected != null) swipeOutofBoundDetected(this, new KinectCursorEventArgs(point) { Z = z, Cursor = cursorAdorner }); ResetGesturePoint(gesturePoints.Count); return; } if ((newPoint.T - startPoint.T).Milliseconds > swipeTime) //check time { gesturePoints.RemoveAt(0); startPoint = gesturePoints[0]; } if ((swipeLength < 0 && newPoint.X - startPoint.X < swipeLength) // check to see if distance has been achieved swipe left || (swipeLength > 0 && newPoint.X - startPoint.X > swipeLength)) // check to see if distance has been achieved swipe right { gesturePoints.Clear(); //throw local event if (swipeDetected != null) swipeDetected(this, new KinectCursorEventArgs(point) { Z = z, Cursor = cursorAdorner }); return; } if (xOutOfBoundsLength != 0 && ((xOutOfBoundsLength < 0 && newPoint.X - initialSwipeX < xOutOfBoundsLength) // check to see if distance has been achieved swipe left || (xOutOfBoundsLength > 0 && newPoint.X - initialSwipeX > xOutOfBoundsLength)) ) { if (swipeOutofBoundDetected != null) swipeOutofBoundDetected(this, new KinectCursorEventArgs(point) { Z = z, Cursor = cursorAdorner }); } }
磁性幻灯片是Kinect手势中的精华(holy grail)。他由Harmonix公司的交互设计师们在开发《舞林大会》(Dance Central)这一款游戏时创造的。最初被用在菜单系统中,现在作为一种按钮在很多地方有应用,包括Xbox自身的操作面板。他比磁性按钮好的地方就是,不需要用户等待一段时间。在Xbox游戏中,没有人愿意去等待。而下压按钮又有自身的缺点,最主要的是用户体验不是很好。磁性幻灯片和磁性按钮一样,一旦用户进入到按钮的有效区域,光标就会自定锁定到某一点上。但是在这一点上,可以有不同的表现。除了悬停在按钮上方一段时间触发事件外,用户可以划动收来激活按钮。
从编程角度看,磁性幻灯片基本上是磁性按钮和划动手势(swipe)的组合。要开发一个磁性幻灯片按钮,我们可以简单的在可视化树中的悬浮按钮上声明一个计时器,然后再注册滑动手势识别事件。下面的代码展示了磁性幻灯片按钮的基本结构。其构造函数已经在基类中为我们声明好了计时器。InitializeSwipe和DeinitializeSwipe方法负责注册KinectCursorManager类中的滑动手势识别功能。
public class MagneticSlide:MagnetButton { private bool isLookingForSwipes; public MagneticSlide() { base.isLockOn = false; } private void InitializeSwipe() { if (isLookingForSwipes) return; var kinectMgr = KinectCursorManager.Instance; kinectMgr.GesturePointTrackingInitialize(SwipeLength, MaxDeviation, MaxSwipeTime, xOutOfBoundsLength); kinectMgr.swipeDetected += new KinectCursorEventHandler(kinectMgr_swipeDetected); kinectMgr.swipeOutofBoundDetected += new KinectCursorEventHandler(kinectMgr_swipeOutofBoundDetected); kinectMgr.GesturePointTrackingStart(); } private void DeInitializeSwipe() { var KinectMgr = KinectCursorManager.Instance; KinectMgr.swipeDetected -= new KinectCursorEventHandler(kinectMgr_swipeDetected); KinectMgr.swipeOutofBoundDetected -= new KinectCursorEventHandler(kinectMgr_swipeOutofBoundDetected); KinectMgr.GesturePointTrackingStop(); isLookingForSwipes = false; }
另外,我们也需要将控件的滑动手势的初始化参数暴露出来,这样就可以根据特定的需要进行设置了。下面的代码展示了SwipeLength和XOutOfBoundsLength属性,这两个都是默认值的相反数。这是因为磁性幻灯片按钮一般在屏幕的右侧,需要用户向左边划动,因此,相对于按钮位置的识别偏移以及边界偏移是其X坐标轴的相反数。
public static readonly DependencyProperty SwipeLengthProperty =
DependencyProperty.Register("SwipeLength", typeof(double), typeof(MagneticSlide), new UIPropertyMetadata(-500d));
public double SwipeLength
{
get { return (double)GetValue(SwipeLengthProperty); }
set { SetValue(SwipeLengthProperty, value); }
}
public static readonly DependencyProperty MaxDeviationProperty =
DependencyProperty.Register("MaxDeviation", typeof(double), typeof(MagneticSlide), new UIPropertyMetadata(100d));
public double MaxDeviation
{
get { return (double)GetValue(MaxDeviationProperty); } set { SetValue(MaxDeviationProperty, value); }
}
public static readonly DependencyProperty XOutOfBoundsLengthProperty =
DependencyProperty.Register("XOutOfBoundsLength", typeof(double), typeof(MagneticSlide), new UIPropertyMetadata(-700d));
public double XOutOfBoundsLength
{
get { return (double)GetValue(XOutOfBoundsLengthProperty); }
set { SetValue(XOutOfBoundsLengthProperty, value); }
}
public static readonly DependencyProperty MaxSwipeTimeProperty =
DependencyProperty.Register("MaxSwipeTime", typeof(int), typeof(MagneticSlide), new UIPropertyMetadata(300));
public int MaxSwipeTime
{
get { return (int)GetValue(MaxSwipeTimeProperty); }
set { SetValue(MaxSwipeTimeProperty, value); }
}
要实现磁性幻灯片按钮的逻辑,我们只需要处理基类中的enter事件,以及划动手势识别事件即可。我们不会处理基类中的leave事件,因为当用户做划动手势时,极有可能会不小心触发leave事件。我们不想破坏之前初始化好了的deactivate算法逻辑,所以取而代之的是,我们等待要么下一个划动识别成功,要么在关闭划动识别前划动手势超出识别范围。当探测到划动时,触发一个标准的click事件。
public static readonly RoutedEvent SwipeOutOfBoundsEvent = EventManager.RegisterRoutedEvent("SwipeOutOfBounds", RoutingStrategy.Bubble,
typeof(KinectCursorEventHandler), typeof(KinectInput));
public event RoutedEventHandler SwipeOutOfBounds
{
add { AddHandler(SwipeOutOfBoundsEvent, value); }
remove { RemoveHandler(SwipeOutOfBoundsEvent, value); }
}
void KinectMgr_swipeOutofBoundDetected(object sender, KinectCursorEventArgs e)
{
DeInitializeSwipe();
RaiseEvent(new KinectCursorEventArgs(SwipeOutOfBoundsEvent));
}
void KinectMgr_swipeDetected(object sender, KinectCursorEventArgs e)
{
DeInitializeSwipe();
RaiseEvent(new RoutedEventArgs(ClickEvent));
}
protected override void OnKinectCursorEnter(object sender, KinectCursorEventArgs e)
{
InitializeSwipe();
base.OnKinectCursorEnter(sender, e);
}
并不是所有的内容都能够在一屏之内显示完。有时候可能有一些内容会大于屏幕的实际尺寸,这就需要用户来滚动屏幕或者列表控件来显示在屏幕之外的内容。传统上,垂直滚动条一直是交互界面设计的一个禁忌。但是垂直滚动条在划动触摸界面中得到了很好的应用。所以Xbox和Sony PlayStation系统中都使用了垂直滚动条来构建菜单。Harmonix’s的《舞林大会》(Dance Central)这一系列游戏使用了垂直滚动条式的菜单系统。Dance Central第一次成功的使用了垂直滚动界面作为手势交互界面。在下面的手势交互图中,当用户抬起或者放下手臂时会使得屏幕的内容垂直滚动。胳膊远离身体,抬起手臂会使得屏幕或者菜单从下往上移动,放下手臂会使得从上往下移动。