首先來看一下Kinect設備:php
黑色的Kinect設備以下圖:基座和感應器之間有一個電動的馬達,經過程序可以調整俯仰角度,在上面的感應器中有一個紅外投影儀,兩個攝像頭,四個麥克風和一個風扇。打開外面的蓋子能夠看到裏面的構造:這些感應器用來捕捉RGB和深度數據,面對Kinect,從左往右看。最左邊是紅外光源,其次是LED指示燈,再次是彩色攝像頭,用來收集RGB數據,最右邊是紅外攝像頭用才採集景深數據。彩色攝像頭最大支持1280*960分辨率成像,紅外攝像頭最大支持640*480成像。html
在感應器的下方是麥克風陣列,他包括四個不一樣的麥克風,一個在左邊的紅外發射器下面,另外3個在右邊景深攝像頭下面。前端
初步瞭解了Kinect構造後,接下來看看開發環境的搭建:ios
Kinect for Windows SDK是一些列的類庫,他可以使得開發者可以將Kinect做爲輸入設備開發各類應用程序。就像名字所顯示的那樣,Kinect for Windows SDK只能運行在32位或者64位的windows7及以上版本的操做系統上。c++
使用Visual Studio 2010 Express版本或者專業版進行開發時,須要安裝最新版本的Kinect for Windows SDK,SDK中包含有對Kinect的硬件驅動。程序員
1. Visual Studio 2010 Express或者Visual Studio 2010專業版或其餘版本web
2. .NET Framework 4.0算法
3. Kinect for Windows SDK,最新版本爲1.0版本,下載地址:http://www.microsoft.com/en-us/kinectforwindows/develop/overview.aspxexpress
安裝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上是空的。
<pre cl 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)
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<int,int,int,int>,每個都是Ax+By+Cz+D=0地面平面(floor plane)表達式裏面的係數項。元組中第一個元素表示A,即x前面的係數,一次類推,最後一個表示常數項,一般爲負數,是Kinect距離地面高度。在可能的狀況下SDK會利用圖像處理技術來肯定這些係數。可是有時候這些係數不願能可以肯定下來,可能須要預估。當地面不能肯定時FloorClipPlane中的全部元素均爲0.
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類的基本結構,其中包含了一些重載的構造器。
<pre cl 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<T>(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第一次成功的使用了垂直滾動界面做爲手勢交互界面。在下面的手勢交互圖中,當用戶擡起或者放下手臂時會使得屏幕的內容垂直滾動。胳膊遠離身體,擡起手臂會使得屏幕或者菜單從下往上移動,放下手臂會使得從上往下移動。
水平的划動在Kinect應用中彷佛很常見(尤爲是在Metro風格的Xbox遊戲交互界面中,水平划動是佔主導的手勢),可是垂直滾動用戶體驗更加友好,也是用戶交互界面更好的選擇。水平或者垂直划動手勢有一些小的用戶體驗問題。另外,划動手勢在識別上也較困難,由於揮動的形式和動做因人而異,且差異很大。就算同一我的,划動手勢也不是一直不變的。划動手勢在觸摸屏設備上可以較好的工做是由於除非不觸摸到屏幕,那麼動做就不會發生。可是在手勢識別界面上,用戶的手是和視覺元素進行交互的,這時手就是在某一特定的座標空間中的視覺元素。
當用戶作划動手勢時,在整個手的划動過程當中會手的位置在水平方向會保持相對一致。這就使得若是想進行屢次連續的划動手勢時會產生一些問題。有時候會產生一些比較尷尬的場景,那就是會無心中撤銷前一次的划動手勢。例如,用戶使用右手從右向左進行划動手勢,使得頁面會跳轉到下一頁,如今用戶的右手在身體的左邊位置了,而後用戶想將手移動回原始的開始位置以準備下一次的從右向左的揮動手勢。可是,若是用於依然保持手在水平位置大體一致的話,應用程序會探測到一次從左向右的划動操做而後又將界面切換到了以前的那一頁。這就使得用戶必須建立一個循環的運動來避免沒必要要的誤讀。更進一步,頻繁的划動手勢也容易使得用戶疲勞,而垂直方向的划動也只會加重這一問題。
可是垂直滾動條則不會有上述的這些用戶體驗上的缺點。他比較容易使用,對用戶來講也更加友好,另外,用戶也不須要爲了保持手在水平或者垂直方向一致而致使的疲勞。從技術方面來說,垂直滾動操做識別較划動識別簡單。垂直滾動在技術上是一個姿式而不是手勢。滾動操做的探測是基於當前手臂的位置而不是手臂的運動。滾動的方向和大小由手臂和水平方向的夾角來肯定。下圖演示了垂直滾動。
使用以前的姿式識別那篇文章中的內容,咱們可以計算從用戶的身體到肩部和手腕的夾角,定義一個角度區間做爲中間姿式,當用戶手臂在這一區間內時,不會產生任何動做,如上圖中的,當手臂天然處於-5度或者355度時,做爲偏移的零點。建議在實際開發中,將零點的偏移上下加上20度左右。當用戶的手臂離開這一區域時,離開的夾角及變化的幅度根據需求而定。可是建議至少在0度區間上下有兩個區間來表示小幅和大幅的增長。這使得可以更好的實現傳統的人機交互界面中的垂直滾動條的邏輯。
暫停按鈕,一般做爲引導手勢或者退出手勢,是微軟建議在給用戶提供引導時不多的幾個手勢之一。這個手勢是經過將左臂保持和身體45度角來完成的。在不少Kinect的遊戲中都使用到了這一手勢,用來暫停動做或者喚出Xbox菜單。和本文以前介紹的手勢不同,這個手勢並無什麼符號學上的含義,是一個認爲設計的動做。通用暫停手勢很容易實現,也不必定要限制手臂,而且不容易和其餘手勢混淆。
通用暫停手勢的識別和垂直滾動手勢的識別有點相似,就是計算左臂和身體的夾角,而後加上一個閾值便可,相信很簡單,在這裏就再也不贅述了。
結合前篇文章中的揮動(wave)手勢識別,以及上文將的幾種按鈕,作了一個小的例子,使用以前開發的手勢識別庫,以及手勢識別按鈕。這部分代碼很簡單,直接引用以前在類庫中定義好的控件便可。你們能夠下載本文後面的代碼本身回去實驗一下。 截圖以下:
咱們進入商場可能對各類商品不會有很深的印象,一樣,隨着時間的流逝,Kinect也會變得不那麼使人印象深入,甚至被大多數人忘記。可是,隨着軟硬件技術的發展,Kinect或者類似的技術會融入到生活的各個方面,讓你看不到Kinect的存在。
當咱們進入一個購物商場時,當你靠近入口時,門會自動打開。這在當時很讓人印象深入,但沒有人會注意,觀察甚至意識到這一特性。在將來的某一天,Kinect也會像自動門同樣融入生活的方方面面。使得咱們感覺不到他的存在。
Kinect以及NUI的世界纔剛剛開始,隨着時間的推移,這種交互體驗會發生巨大變化。在電影《少數派報告》(Minority Report)中,湯姆克魯斯使用手勢瀏覽和打開大屏幕上的各類文件和影像資料,這一場景如今已是基於Kinect的應用程序的要實現目標之一。 有時候,科幻片中比現實有更好的想像力,也能提供比現實生活中更好的科技。在星際迷航(Star Trek),星球大戰(Star Wars)或者 2001:太空漫遊(2001: A Space Odyssey)這些科幻電影中,電腦可以看到和感應人的存在。在這些電影中,用戶使用語音和手勢無縫的和電腦進行交互。固然這種交互也有負面因素,應該設置一些限制。
雖然科幻電影變成現實的這一前景會引起一些理性的擔心,可是這種變化正在到來。意識到這種變化帶來的好處很重要。Kinect及其相似的技術使得咱們的生活環境更加智能化。它使得應用程序可以識別用戶的手勢,進而可以分析出用戶的意圖,而不須要用戶明確的給出這些信息或者指令。如今的Kinect遊戲是基於應用程序查找特定的用戶的手勢而進行操做的,用戶必須主動的和應用程序進行交流或者發送指令。可是,仍是有不少用戶的信息沒有被捕捉和處理。若是應用程序可以探測到其餘信息,確切的說,如用戶的情緒,那麼就能夠提供更加人性化的定製服務了。如今咱們所識別的姿式都很簡單,咱們只是在學習如何創建交互界面,能夠預見在將來,隨着基於手勢交互的應用程序愈來愈多,這種用戶界面就會消失,就像移動設備中觸摸界面逐漸取代光標那樣。
想象一下,下班後回到家,走到臥室,說一句「電腦,放點music」。因而電腦就會識別語音指令開始播放音樂。可是,電腦也可以識別到你工做了一天,須要一些音樂來放鬆心情,因而電腦自動的根據你的心情選擇一些歌曲。語音成了首要的發送指令的形式,手勢來對一些指令進行加強。在上面的例子中,電腦可以根據你的身體語言,識別你的情緒,這樣,手勢是一種主動的,有上下文情景的和電腦進行交互的方法。這並不意味這手勢變得不那麼重要,相反重要性增長了,只不過是以一種間接的方式。
現在有一些聲控傳感器,例如可以根據聲音探測到人進入到房間,而後開燈或者關燈的聲控開關。這是一種比較笨拙的系統,他沒有上下文提供。若是使用Kinect技術,它可以識別用戶的運動,而後根據情形調整燈光的亮度。例如,若是在凌晨2點,你想起牀喝點水,電腦可能會將燈光調整的比較暗,以致於不會太刺眼。可是若是某一天你凌晨2點鐘從外面回來,Kinect識別到你是清醒的,就會把燈所有打開。
目前,Kinect仍然是一種比較新的技術,咱們仍然試圖理解而後可以更好的發揮它的潛能。在最開始Kinect出來的時候只是觀看或者體驗。隨着他做爲Xbox外設的發佈,遊戲的主題也有了一些限制。大多數遊戲都是運動型的,這些遊戲都只能識別用戶的一些基本手勢,如跑,跳,踢,划動,扔等這些手勢或動做。早期的Kinect體感遊戲也只有一些簡單的菜單系統,使用手來進行操做。
雖然用戶體驗設計發生了巨大變化,可是目前基於手勢的遊戲和應用比較簡單。咱們仍然處在學習如何定義和識別手勢的階段。這使得咱們的手勢有些粗糙。咱們仍須要擴大手勢的幅度才能達到好的識別效果。當咱們可以識別到手勢的細微方面時,應用程序所帶來的沉浸感將會大大提高。
如今的足球遊戲只能識別到基本的踢球動做,遊戲不可以判斷用戶是使用腳趾,腳背,腳踝仍是腳跟跟球進行交互的。這些不一樣的姿式可能對球產生徹底不一樣的影響,從而使得遊戲產生不一樣的結果。更進一步,遊戲應該可以根據用戶踢球的動做,腳的位置,應用一些物理特性,給予球一些真實的加速度,旋轉,弧度等特性,這樣會使得遊戲更加真實,玩家也更加有沉浸感。
目前的這些限制主要是由Kinect攝像頭的分辨率決定的。下一代的Kinect硬件設備可能會使用更高分辨率的攝像頭來提供更好的深度影像數據。微軟已經放出了關於第二代Kinect硬件方面的相關信息。這使得更精確的手勢識別變爲可能,給基於Kinect的應用程序開發帶來了兩個方面的改進。首先是骨骼關節精度的提高,這不但可以提高手勢識別的精度,也可以擴大手勢識別的種類。另外一個改進是使得可以產生一些額外的關節點,如手指的信息,以及一些非關節點如嘴脣,鼻子,耳朵,眼睛等位置信息。現在這些信息都可以識別的出來,只是須要使用一些第三方的類庫,官方的SDK中沒有原生的對這些特徵進行支持。
對手指進行追蹤和識別可以大大的提升符號語言的做用。若是應用程序可以識別手指的運動,用戶就可以使用手指進行更高精度和更天然的操做。手指手勢交互信息很複雜,在用戶進行交流的過程當中可以提供豐富的上下文信息。即便可以識別到手指手勢,今天的基於Kinect的間交互體驗也沒有發生革命性的變化,這是由於用戶依然必須意識到Kinect的存在,還要知道如何和這些硬件交互,須要作什麼樣的手勢才能達到想要的結果。當你看別人玩Kinect遊戲時,你會注意到他是如何在Kinect前面作動做的。用戶的姿式有時候比較僵硬和不天然。不少姿式並不能識別,一些須要重複屢次,更壞的狀況下姿式會被錯誤識別,這樣就會致使一些意想不到的操做。更進一步,用戶的手勢一般須要過度的誇大才能被kinect識別的到。這些問題可能都是暫時的。在將來,隨着軟硬件的不斷升級和改進,在使用基於手勢的人機交互界面時,用戶會愈來愈感到舒服和天然。從這方面講,Kinect是一個神奇的設備,以致於會像自動門那樣被廣大的用戶所知道和接受。
在上一篇文章介紹手勢識別概念和識別方法的基礎上,本文進一步解釋瞭如何進行手勢識別。首先,構建了一個基本的手勢識別的框架,而後在此基礎上對經常使用的8中手勢中的剩餘7中手勢進行逐一講解與實現。最後展望了Kienct將來在手勢識別方面的前景和應用。
Kinect的麥克風陣列在Kinect設備的下方。這一陣列由4個獨立的水平分佈在Kinect下方的麥克風組成。雖然每個麥克風都捕獲相同的音頻信號,可是組成陣列能夠探測到聲音的來源方向。使得可以用來識別從某一個特定的方向傳來的聲音。麥克風陣列捕獲的音頻數據流通過複雜的音頻加強效果算法處理來移除不相關的背景噪音。全部這些複雜操做在Kinect硬件和Kinect SDK之間進行處理,這使得可以在一個大的空間範圍內,即便人離麥克風必定的距離也可以進行語音命令的識別。
在Kinect第一次做爲Xbox360的外設發佈時,骨骼追蹤和語音識別是Kinect SDK最受開發者歡迎的特性,可是相比骨骼追蹤,語音識別中麥克風陣列的強大功能有一點被忽視了。一部分緣由歸於Kinect中的使人興奮的骨骼追蹤系統,另外一部分緣由在於Xbox遊戲操控面板以及Kinect體感遊戲沒有充分發揮Kinect音頻處理的優勢。
做爲一個開始使用Kinect進行應用開發的開發者,Kinect上的麥克風陣列的出現使得基於Kinect應用程序的功能更增強大。雖然Kinect的視覺分析使人印象深入,可是仍然不能很好的對馬達進行控制。當咱們從一種人機交互界面切換到另外一種人機交互界面:如從命令行交互應用程序到標籤頁交互界面,再到鼠標圖形用戶界面或者觸摸交互界面時,每一種交互界面都提供了各類最基本的更加容易實現的操做,這個操做就是選擇。進一步,能夠說,每一種交互界面都改進了咱們對對象進行選擇的能力。奇怪的是,Kinect破壞了這一趨勢。
在Kinect應用程序中,選擇操做是最複雜和難以掌握的行爲之一。Xbox360中最初的選擇操做是經過將手放到特定的位置,而後保持一段時間。在《舞林大會》遊戲中,經過一個短暫的停頓加上滑動操做來對選擇操做進行了一點改進。這一改進也被應用在了Xbox的操做面板中。另外的對選擇進行改進的操做包括某種特定的手勢,如將胳膊舉起來。
這些問題,能夠經過將語音識別指令和骨骼追蹤系統結合起來產生一個複合的姿式來相對簡單的解決:保持某一動做,而後經過語音執行。菜單的設計也能夠經過首先展現菜單項,而後讓用戶說出菜單項的名稱來進行選擇-不少Xbox中的遊戲已經使用了這種方式。能夠預見,不管是程序開發者仍是遊戲公司,這種複合的解決方案在將來會愈來愈多的應用到新的交互方式中,而不用再像之前那樣使用指而後點(point and click)這種方式來選擇。
安裝完Microsoft Kinect SDK以後,語音識別的組件會自動安裝。Kinect的麥克風陣列工做在一些語音識別的類庫之上,這些類庫是從Vista系統之時就有的。他們包括語音捕獲DirectX多媒體對象(DirectX Media Object,DMO)以及語音識別API(Speech Recognition API,SAPI)。
在C#中,Kinect SDK提供了對語音捕獲DMO的封裝。語音捕獲DMO最初是被設計用來給麥克風陣列提供API來支持一些功能如回聲消除(acoustic echo cancellation,AEC),自動增益控制(automatic gain control,AGC)和噪聲抑制(noise suppression)。這些功能在SDK的音頻控制類中能夠找到。 Kinect SDK中音頻處理對語音捕獲DMO進行了簡單封裝,並專門針對Kinect傳感器進行了性能優化。爲了可以使用Kinect SDK進行語音識別,自動安裝的類庫包括:Speech Platform API, Speech Platform SDK和Kinect for Windows Runtime Language Pack。
語音識別API可以簡化操做系統自帶的語音識別所需的類庫。例如,若是你想經過普通的麥克風而不是Kinect麥克風陣列添加一些語音指令到桌面應用程序中去,能夠使用也能夠不使用Kinect SDK。
Kinect for windows 運行語言包是一系列的語言模型,用來在Kinect SDK和語音識別API組件之間進行互操做。就像Kinect骨骼識別須要大量的計算模型來提供決策樹信息來分析節點位置那樣,語音識別API也須要複雜的模型來輔助解釋從Kinect麥克風陣列接收到的語言模型。Kinect語言包提供了這些模型來優化語音指令的識別。
Kinect中處理音頻主要是經過KinectAudioSource這個對象來完成的。KinectAudioSource類的主要做用是從麥克風陣列中提取原始的或者通過處理的音頻流。音頻流可能會通過一系列的算法來處理以提升音頻質量,這些處理包括:降噪、自動增益控制和回聲消除。KinectAudioSource可以進行一些配置使得Kinect麥克風陣列能夠以不一樣的模式進行工做。也可以用來探測從那個方向來的哪一種音頻信息最早達到麥克風以及用來強制麥克風陣列接受指定方向的音頻信息。
本節儘可能不會去介紹一些音頻處理技術方面的較低層次的技術。可是爲了使用KinectAudioSource,瞭解語音捕獲以及語音傳輸中的一些術語可能會對熟悉KinectAudioSource中的一些屬性和方法有所幫助。
KinectAudioSource類提供一些對音頻捕獲多方面的較高層次的控制,雖然它並無提供DMO中的全部功能。KinectAudioSource中用來調整音頻處理的各類屬性被稱之爲功能(features)屬性。下表中列出了能夠調整的功能屬性。Kinect SDK早期的Beta版本視圖提供了DMO中的全部功能以使得可以有更增強大的控制能力,可是這也極大的增長了複雜度。SDK的正式版本提取了DMO中的全部可能的配置而後將其封裝爲特徵屬性使得咱們不用關心底層的配置細節。對於沒有接觸過這些底層配置屬性的開發者來講,這是一種巨大的解脫。
EchoCancellationMode是一個隱藏在不起眼的名稱後面神奇的技術之一。他可能的設置以下表。爲了適應AEC,須要給EchoCancellationSpeakerIndex屬性賦一個int值來指定那一個用戶的噪音須要控制。SDK會自動執行活動麥克風的發現和初始化。
BeamAngleMode對底層的DMO系統模式和麥克風陣列屬性進行了抽象封裝。在DMO級別上,他決定了是由DMO仍是應用程序進行波束成形。在這一基礎上Kinect for Windows SDK提供了額外的一系列算法來進行波束成行。一般,能夠將該屬性設置爲Adaptive,將這些複雜的操做交給SDK進行處理。下表展現了每個可設置值的屬性。
自適應波速成形(Adaptive beamforming)可以發揮Kinect傳感器的特性優點,根據骨骼追蹤所找到的遊戲者,從而找出正確的聲音源。和骨骼追蹤同樣,Kinect的波束成形特性也可以使用手動模式,容許應用程序來設定要探測聲音的方向。要使用Kinect傳感器做爲定向的麥克風,須要將波束角度模式設定爲Manual而後設置KinectAudioSource的ManualBeamAngle屬性。
語音識別能夠分爲兩類:對特定命令的識別(recognition of command)和對自由形式的語音的識別(recognition of free-form dictation)。自由形式的語音識別須要訓練軟件來識別特定的聲音以提升識別精度。通常作法是讓講話人大聲的朗讀一系列的語料來使得軟件可以識別講話人聲音的特徵模式,而後根據這一特徵模式來進行識別。
而命令識別(Command recognition)則應用了不一樣的策略來提升識別精度。和必須識別說話人聲音不一樣,命令識別限制了說話人所講的詞彙的範圍。基於這一有限的範圍,命令識別能夠不須要熟悉講話人的語音模式就能夠推斷出說話人想要說的內容。
考慮到Kinect的特性,使用Kinect進行自由形式的語音識別沒有多大意義。Kinect SDK的設計初衷是讓你們可以簡單容易的使用,所以,SDK提供了Microsoft.Speech類庫來原生支持語音命令的識別。Microsoft.Speech類庫是Microsoft語音識別技術的服務器版本。若是你想使用System.Speech類庫中的語音識別能力,能夠使用Windows操做系統內建的桌面版語音識別來經過Kinect的麥克風來創建一個自由語音識別系統。可是經過將Kinect的麥克風和System.Speech類庫組合開發的自由語音識別系統的識別效果可能不會太好。這是由於Kinect for windows運行時語言包,可以適應從開放空間中的聲音,而不是從麥克風發出的聲音,這些語言模型在 System.Speech 中不可以使用。
Microsoft.Speech類庫的語音識別功能是經過SpeechRecognitionEngine對象提供的。SpeechRecognitionEngine類是語音識別的核心,它負責從Kinect傳感器獲取處理後的音頻數據流,而後分析和解譯這些數據流,而後匹配出最合適的語音命令。引擎給基本發聲單元必定的權重,若是判斷出發聲包含特定待識別的命令,就經過事件進行進一步處理,若是不包含,直接丟掉這部分音頻數據流。
咱們須要告訴SpeechRecognitionEngine從一個特定的稱之爲語法(grammars)的對象中進行查找。Grammar對象由一系列的單個單詞或者詞語組成。若是咱們不關心短語的部份內容,能夠使用語法對象中的通配符。例如,咱們可能不會在乎命令包含短語"an" apple或者"the" apple,語法中的通配符告訴識別引擎這二者都是能夠接受的。此外,咱們還能夠添加一個稱之爲Choices的對象到語法中來。選擇類(Choices)是通配符類(Wildcard)的一種,它能夠包含多個值。但與通配符不一樣的是,咱們能夠指定可接受的值的順序。例如若是咱們想要識別「Give me some fruit」咱們不關心fruit單詞以前的內容,可是咱們想將fruit替換爲其它的值,如apple,orange或者banana等值。這個語法能夠經過下面的代碼來實現。Microsoft.Speech類庫中提供了一個GrammarBuilder類來創建語法(grammars)。
var choices = new Choices(); choices.Add("fruit"); choices.Add("apple"); choices.Add("orange"); choices.Add("banana"); var grammarBuilder = new GrammarBuilder(); grammarBuilder.Append("give"); grammarBuilder.Append("me"); grammarBuilder.AppendWildcard(); grammarBuilder.Append(choices); var grammar = new Grammar(grammarBuilder);
語法中的單詞不區分大小寫,可是出於一致性考慮,要麼都用大寫,要麼都用小寫。
語音識別引擎使用LoadGrammar方法將Grammars對象加載進來。語音識別引擎可以加載並且一般是加載多個語法對象。識別引擎有3個事件:SpeechHypothesized,SpeechRecognized和SpeechRecognitionRejected。 SpeechHypothesized事件是識別引擎在決定接受或者拒絕用戶命令以前解釋用戶說話的內容。SpeechRecognitionRejected用來處理識別命令失敗時須要執行的操做。SpeechRecognized是最重要的事件,他在引擎決定接受用戶的語音命令時觸發。該事件觸發時,經過SpeechRecognizedEventArgs對象參數傳遞一些數據。SpeechRecognizedEventArgs類有一個Result屬性,該屬性描述以下:
實例化SpeechRecognitionEngine對象須要執行一系列特定的步驟。首先,須要設置識別引擎的ID編號。當安裝了服務器版本的Microsoft語音庫時,名爲Microsoft Lightweight Speech Recognizier的識別引擎有一個爲SR_MS_ZXX_Lightweight_v10.0的ID值(這個值根據你所安裝的語音庫的不一樣而不一樣)。當安裝了Kinect for Windows運行時語音庫時,第二個ID爲Server Speech Recognition Language-Kinect(en-US)的語音庫能夠使用。這是Kinect中咱們能夠使用的第二個識別語音庫。下一步SpeechRecognitionEngine須要指定正確的識別語音庫。因爲第二個語音識別庫的ID可能會在之後有所改變,咱們須要使用模式匹配來找到這一ID。最後,語音識別引擎須要進行配置,以接收來自KinectAudioSource對象的音頻數據流。下面是執行以上過程的樣板代碼片斷。
var source = new KinectAudioSource(); Func<RecognizerInfo, bool> matchingFunc = r => { String value; r.AdditionalInfo.TryGetValue("Kinect", out value); return "True".Equals(value, StringComparison.InvariantCultureIgnoreCase) && "en-US".Equals(r.Culture.Name, StringComparison.InvariantCultureIgnoreCase); }; RecognizerInfo ri = SpeechRecognitionEngine.InstalledRecognizers().Where(matchingFunc).FirstOrDefault(); var sre = new SpeechRecognitionEngine(ri.Id); KinectSensor.KinectSensors[0].Start(); Stream s = source.Start(); sre.SetInputToAudioStream(s,new SpeechAudioFormatInfo(EncodingFormat.Pcm, 16000, 16, 1, 32000, 2, null)); sre.Recognize();
SetInputToAudioStream方法的第二個參數用來設置從Kinect獲取的音頻數據流的格式。在上面的代碼中,咱們設置音頻編碼格式爲Pulse Code Modulation(PCM),每秒接收16000個採樣,每一個樣本佔16位,只有1個通道,每秒中產生32000字節數據,塊對齊值設置爲2。
Grammars加載到語音識別引擎後,引擎必須啓動後才能進行識別,啓動引擎有幾種模式,能夠使用同步或者異步模式啓動。另外也能夠識別一次或者繼續識別從KinectAudioSource傳來的多條語音命令。下表列出了開始語音識別的可選方法。
在文將使用一些簡單的例子來展現如何使用KinectAudioSource和SpeechRecognitionEngine類。
雖然 KinectAudioSource 類的最主要做用是爲語音識別引擎提供音頻數據流,可是它也能夠用於其餘目的。他還可以用來錄製 wav 文件。下面的示例將使用KinectAudioSource來開發一個音頻錄音機。使用這個項目做爲錄音機,讀者能夠修改Kinect sdk中KinectAudioSource的各個參數的默認值的來了解這些參數是如何控制音頻數據流的產生。
雖然使用的是Kinect的音頻相關類,而不是視覺元素類,可是創建一個Kinect音頻項目的過程大體是相似的。
1. 建立一個名爲KinectAudioRecorder的WPF應用項目。
2. 添加對Microsoft.Kinect.dll和Microsoft.Speech.dll的引用。
3. 在MainWindows中添加名爲Play,Record和Stop三個按鈕。
4. 將主窗體的名稱改成「Audio Recorder」
在VS的設計視圖中,界面看起來應該以下:
使人遺憾的是,C#沒有一個方法可以直接寫入wav文件。爲了可以幫助咱們生成wav文件,咱們使用下面自定義的RecorderHelper類,該類中有一個稱之爲WAVFORMATEX的結構,他是C++中對象轉換過來的,用來方便咱們對音頻數據進行處理。該類中也有一個稱之爲IsRecording的屬性來使得咱們能夠中止錄製。類的基本結構,以及WAVFORMATEX的結構和屬性以下。咱們也須要初始化一個私有名爲buffer字節數組用來緩存咱們從Kinect接收到的音頻數據流。
class RecorderHelper { static byte[] buffer = new byte[4096]; static bool _isRecording; public static bool IsRecording { get { return _isRecording; } set { _isRecording = value; } } struct WAVEFORMATEX { public ushort wFormatTag; public ushort nChannels; public uint nSamplesPerSec; public uint nAvgBytesPerSec; public ushort nBlockAlign; public ushort wBitsPerSample; public ushort cbSize; } }
爲了完成這個幫助類,咱們還須要添加三個方法:WriteString,WriteWavHeader和WriteWavFile方法。WriteWavFile方法以下,方法接受KinectAudioSource和FileStream對象,從KinectAudioSource對象中咱們能夠獲取音頻數據,咱們使用FileStream來寫入數據。方法開始寫入一個假的頭文件,而後讀取Kinect中的音頻數據流,而後填充FileStream對象,直到_isRecoding屬性被設置爲false。而後檢查已經寫入到文件中的數據流大小,用這個值來改寫以前寫入的文件頭。
public static void WriteWavFile(KinectAudioSource source, FileStream fileStream) { var size = 0; //write wav header placeholder WriteWavHeader(fileStream, size); using (var audioStream = source.Start()) { //chunk audio stream to file while (audioStream.Read(buffer, 0, buffer.Length) > 0 && _isRecording) { fileStream.Write(buffer, 0, buffer.Length); size += buffer.Length; } } //write real wav header long prePosition = fileStream.Position; fileStream.Seek(0, SeekOrigin.Begin); WriteWavHeader(fileStream, size); fileStream.Seek(prePosition, SeekOrigin.Begin); fileStream.Flush(); } public static void WriteWavHeader(Stream stream, int dataLength) { using (MemoryStream memStream = new MemoryStream(64)) { int cbFormat = 18; WAVEFORMATEX format = new WAVEFORMATEX() { wFormatTag = 1, nChannels = 1, nSamplesPerSec = 16000, nAvgBytesPerSec = 32000, nBlockAlign = 2, wBitsPerSample = 16, cbSize = 0 }; using (var bw = new BinaryWriter(memStream)) { WriteString(memStream, "RIFF"); bw.Write(dataLength + cbFormat + 4); WriteString(memStream, "WAVE"); WriteString(memStream, "fmt "); bw.Write(cbFormat); bw.Write(format.wFormatTag); bw.Write(format.nChannels); bw.Write(format.nSamplesPerSec); bw.Write(format.nAvgBytesPerSec); bw.Write(format.nBlockAlign); bw.Write(format.wBitsPerSample); bw.Write(format.cbSize); WriteString(memStream, "data"); bw.Write(dataLength); memStream.WriteTo(stream); } } } static void WriteString(Stream stream, string s) { byte[] bytes = Encoding.ASCII.GetBytes(s); stream.Write(bytes, 0, bytes.Length); }
使用該幫助方法,咱們能夠開始創建和配置KinectAudioSource對象。首先添加一個私有的_isPlaying 布爾值來保存是否咱們想要播放錄製的wav文件。這可以幫助咱們避免錄音和播放功能同事發生。除此以外,還添加了一個MediaPlayer對象用來播放錄製好的wav文件。_recodingFileName用來保存最近錄製好的音頻文件的名稱。代碼以下所示,咱們添加了幾個屬性來關閉和開啓這三個按鈕,他們是:IsPlaying,IsRecording,IsPlayingEnabled,IsRecordingEnabled和IsStopEnabled。爲了使得這些對象能夠被綁定,咱們使MainWindows對象實現INotifyPropertyChanged接口,而後添加一個NotifyPropertyChanged事件以及一個OnNotifyPropertyChanged幫助方法。
在設置各類屬性的邏輯中,先判斷IsRecording屬性,若是爲false,再設置IsPlayingEnabled屬性。一樣的先判斷IsPlaying屬性爲是否false,而後在設置IsRecordingEnabled屬性。前端的XAML代碼以下:
<Window x:Class="KinectRecordAudio.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="Audio Recorder" Height="226" Width="405"> <Grid Width="369" Height="170"> <Button Content="Play" Height="44" HorizontalAlignment="Left" Margin="12,13,0,0" Name="button1" VerticalAlignment="Top" Width="114" Click="button1_Click" IsEnabled="{Binding IsPlayingEnabled}" FontSize="18"></Button> <Button Content="Record" Height="44" HorizontalAlignment="Left" Margin="132,13,0,0" Name="button2" VerticalAlignment="Top" Width="110" Click="button2_Click" IsEnabled="{Binding IsRecordingEnabled}" FontSize="18"/> <Button Content="Stop" Height="44" HorizontalAlignment="Left" Margin="248,13,0,0" Name="button3" VerticalAlignment="Top" Width="107" Click="button3_Click" IsEnabled="{Binding IsStopEnabled}" FontSize="18"/> </Grid> </Window>
MainWindow構造函數中實例化一個MediaPlayer對象,將其存儲到_mediaPlayer變量中。由於Media Player對象在其本身的線程中,咱們須要捕獲播放完成的時間,來重置全部按鈕的狀態。另外,咱們使用WPF中的技巧來使得咱們的MainWindow綁定IsPlayingEnabled以及屬性。咱們將MainPage的DataContext屬性設置給本身。這是提升代碼可讀性的一個捷徑,雖然典型的作法是將這些綁定屬性放置到各自單獨的類中。
public MainWindow() { InitializeComponent(); this.Loaded += delegate { KinectSensor.KinectSensors[0].Start(); }; _mplayer = new MediaPlayer(); _mplayer.MediaEnded += delegate { _mplayer.Close(); IsPlaying = false; }; this.DataContext = this; }
如今,咱們準備好了實例化KinectAudioSource類,並將其傳遞給以前建立的RecordHelper類。爲了安全性,咱們給RecordKinectAudio方法添加了鎖。在加鎖以前咱們將IsRunning屬性設置爲true,方法結束後,將該屬性設置回false。
private object lockObj = new object(); private void RecordKinectAudio() { lock (lockObj) { IsRecording = true; var source = CreateAudioSource(); var time = DateTime.Now.ToString("hhmmss"); _recordingFileName = time + ".wav"; using (var fileStream = new FileStream(_recordingFileName, FileMode.Create)) { RecorderHelper.WriteWavFile(source, fileStream); } IsRecording = false; } } private KinectAudioSource CreateAudioSource() { var source = KinectSensor.KinectSensors[0].AudioSource; source.BeamAngleMode = BeamAngleMode.Adaptive; source.NoiseSuppression = _isNoiseSuppressionOn; source.AutomaticGainControlEnabled = _isAutomaticGainOn; if (IsAECOn) { source.EchoCancellationMode = EchoCancellationMode.CancellationOnly; source.AutomaticGainControlEnabled = false; IsAutomaticGainOn = false; source.EchoCancellationSpeakerIndex = 0; } return source; }
爲了保證不對以前錄製好的音頻文件進行再次寫入,咱們在每一個音頻文件錄製完了以後,使用當前的時間位文件名建立一個新的音頻文件。最後一步就是錄製和播放按鈕調用的方法。UI界面上的按鈕調用Play_Click和Record_Click方法。這些方法只是調用實際對象的Play和Record方法。須要注意的是下面的Record方法,從新開啓了一個新的線程來執行RecordKinectAudio方法。
private void Play() { IsPlaying = true; _mplayer.Open(new Uri(_recordingFileName, UriKind.Relative)); _mplayer.Play(); } private void Record() { Thread thread = new Thread(new ThreadStart(RecordKinectAudio)); thread.Priority = ThreadPriority.Highest; thread.Start(); } private void Stop() { KinectSensor.KinectSensors[0].AudioSource.Stop(); IsRecording = false; }
如今就能夠使用Kinect來錄製音頻文件了。運行程序,點擊錄製按鈕,而後在房間裏走動,以測試Kinect從不一樣距離錄製的聲音效果。在CreateAudioSource方法中,麥克風陣列配置爲使用自適應波束成形,所以當你說話時,會跟隨錄製。點擊中止錄製按鈕結束錄製。而後點擊播放,就能夠播放剛纔錄製的音頻文件了。
咱們能夠將以前的例子進行擴展,添加一些屬性配置。在本節中,咱們添加對噪音抑制和自動增益屬性開關的控制。下圖顯示了修改後的程序界面。
bool _isNoiseSuppressionOn; bool _isAutomaticGainOn; bool _isAECOn;
使用以前OnPropertyChanged幫助方法,咱們可以建立一個綁定屬性。代碼以下:
public bool IsNoiseSuppressionOn { get { return _isNoiseSuppressionOn; } set { if (_isNoiseSuppressionOn != value) { _isNoiseSuppressionOn = value; OnPropertyChanged("IsNoiseSuppressionOn"); } } } public bool IsAutomaticGainOn { get { return _isAutomaticGainOn; } set { if (_isAutomaticGainOn != value) { _isAutomaticGainOn = value; OnPropertyChanged("IsAutomaticGainOn"); } } }
邏輯部分代碼寫好後,在界面上添加幾個Checkbox來控制.
<CheckBox Content="Noise Suppression" Height="16" HorizontalAlignment="Left" Margin="16,77,0,0" VerticalAlignment="Top" Width="142" IsChecked="{Binding IsNoiseSuppressionOn}" /> <CheckBox Content="Automatic Gain Control" Height="16" HorizontalAlignment="Left" Margin="16,104,0,0" VerticalAlignment="Top" IsChecked="{Binding IsAutomaticGainOn}"/> <CheckBox Content="AEC" Height="44" HorizontalAlignment="Left" IsChecked="{Binding IsAECOn}" Margin="16,129,0,0" VerticalAlignment="Top" />
最後在CreateAudioSource方法中綁定這些值.
private KinectAudioSource CreateAudioSource() { var source = KinectSensor.KinectSensors[0].AudioSource; source.BeamAngleMod