上一篇文章用在UI界面上繪制骨骼數據的例子展示了骨骼追蹤系統涉及的主要對象,然後詳細討論了骨骼追蹤中所涉及的對象模型。但是了解了基本概念是一回事,能夠建立一個完整的可用的應用程序又是另外一回事,本文通過介紹一個簡單的Kinect游戲來詳細討論如何應用這些對象來建立一個完整的Kinect應用,以加深對Kinect骨骼追蹤所涉及的各個對象的了解。
1. Kinect連線游戲
相信大家在小時候都做過一個數學題目,就是在紙上將一些列數字(用一個圓點表示)從小到大用線連起來。游戲邏輯很簡單,只不過我們在這裡要實現的是動動手將這些點連起來,而不是用筆或者鼠標。
這個小游戲顯然沒有第一人稱射擊游戲那樣復雜,但如果能夠做成那樣更好。我們要使用骨骼追蹤引擎來收集游戲者的關節數據,執行操作並渲染UI界面。這個小游戲展示了自然用戶界面(Natural User Interface,NUI)的理念,這正是基於Kinect開發的常見交互界面,就是手部跟蹤。這個連線小游戲沒有僅僅用到了WPF的繪圖功能,沒有好看的圖片和動畫效果,這些以後可以逐步添加。
在開始寫代碼之前,需要明確定義我們的游戲目標。連線游戲是一個智力游戲,游戲者需要將數字從小到大連起來。程序可以自定義游戲上面的數字和位置(合稱一個關卡)。每一個關卡包括一些列的數字(以點表示)及其位置。我們要創建一個DotPuzzle類來管理這些點對象的集合。可能一開始不需要這個類,僅僅需要一個集合就可以,但是為了以後方便添加其他功能,使用類更好一點。這些點在程序中有兩個地方需要用到,一個是最開始的時候在界面上繪制關卡,其次是判斷用戶是否碰到了這些點。
當用戶碰到點時,程序開始繪制,直線以碰到的點為起始點,直線的終點位用戶碰到的下一個點。然後下一個點又作為另一條直線的起點,依次類推。直到最後一個點和第一個點連起來,這樣關卡算是通過了,游戲結束。
游戲規則定義好了之後,我們就可以開始編碼了,隨著這個小游戲的開發進度,可能會添加一些其他的新功能。一開始,建一個WPF工程,然後引用Microsoft.Kinect.dll,和之前的項目一樣,添加發現和初始化Kinect傳感器的代碼。然後注冊KinectSensor對象的SkeletonFrameReady事件。
1.1 游戲的用戶界面
游戲界面代碼如下,有幾個地方需要說明一下。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變換,使得變得看起來像右手。
1.2 手部追蹤
游戲者使用手進行交互,因此准確判斷是那只手以及手的位置對於基於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; } } }
編譯運行程序,當移動手時,手勢圖標會跟著移動。
1.3 繪制游戲界面邏輯
為了顯示繪制游戲的邏輯,我們創建一個新的類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); } } }
1.4 游戲邏輯實現
到目前為止,我們的游戲已經有了用戶界面和基本的數據。移動手,能夠看到手勢圖標會跟著移動。我們要將線畫出來。當游戲者的手移動到點上時,開始繪制直線的起點,然後知道手朋到下一個點時,將這點作為直線的終點,並開始另一條直線,並以該點作為起點。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中就可以開始玩這個小游戲了。運行游戲,結果如下:
1.5 進一步可改進地方
在功能上,游戲已經完成了。游戲者可以開始游戲,移動手掌就可以玩游戲了。但是離完美的程序還很遠。還需要進一步改進和完善。最主要的是要增加移動的平滑性。游戲過程中可以注意到手勢有時候會跳躍。第二個主要問題是需要重新恢復游戲初始化狀態。現在的程序,當游戲者完成游戲後只有結束應用程序才能開始新的游戲。
一個解決方式是,在左上角放一個重置按鈕,當用戶手進入到這個按鈕上時,應用程序重置游戲,將puzzleDotIndex設置為0,清除CrayonElement對象中的所有子對象。最好的方式是,創建一個名為ResetPuzzle的新方法。
為了能夠使得這個游戲有更好的體驗,下面是可以進行改進的地方:
創建更多的游戲場景。當游戲加載是,可以從XML文件中讀取一系列的數據,然後讓隨機的產生場景。
列出一系列游戲場景,可以讓用戶選擇想玩那一個,可以創建一個菜單,讓用戶選擇。
一旦用戶完成了當前游戲,自動出現下一個游戲場景。
添加一些額外的數據,比如游戲名稱,背景圖片,提示信息等。例如,如果游戲結束,換一個背景圖片,來個動畫,或者音樂。
添加一些提示,比如可以提示用戶下一個點時那一個。可以設置一個計時器,當用戶找下一個點超過了某一個時間後,認為用戶遇到了困難,可以進行有好的提示,例如可以用文字或者箭頭表示下一個點的位置。如果用戶找到了,則重置計時器。
如果用戶離開游戲,應該重置游戲。比如用戶可能需要接電話,喝茶或者其他的,可以設置一個定時器,當Kinect探測不到游戲者時可以開始計時,如果用戶離開的時間超過了某一個限度,那麼就重置游戲。
可以試著當用戶找到一個下一個點時給一點有效的激勵,比如說放一段小的音樂,或者給一個提示音等等。
當用戶完成游戲後,可以出現一個繪圖板,可供用戶選擇顏色,然後可以在屏幕上繪圖。
2. 各種坐標空間及變換
在之前的各種例子中,我們處理和操作了關節點數據的位置。在大多數情況下,原始的坐標數據是不能直接使用的。骨骼點數據和深度數據或者彩色影像數據的測量方法不同。每一種類的數據(深度數據,影像數據,骨骼數據)都是在特定的集合坐標或空間內定義的。深度數據或者影像數據用像素來表示,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數據流的空間坐標系。
2.1 空間變換
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空間。
2.2 骨骼數據鏡面對稱
細心地你可能會發現,骨骼數據是鏡面對稱的。在大多數情況下,應用是可行的,因為人對應於顯示屏就應該是鏡面對稱。在上面的連線小游戲中,人對於與屏幕也應該是鏡面對稱,這樣恰好模擬人的手勢。但是在一些游戲中,角色代表實際的游戲者,可能角色是背對著游戲者的,這就是所謂的第三人稱視角。在有些游戲中,這種鏡像了的骨骼數據可能不好在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界面上的人物將會抬起右腳。
3. SkeletonViewer自定義控件
開發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; } } } } }
添加後,運行之前的程序,就可以看到如下界面:
4. 結語
本文通過介紹一個簡單的Kinect連線游戲的開發來詳細討論如何骨骼追蹤引擎來建立一個完整的Kinect應用,然後簡要介紹了各個坐標控件以及轉換,最後建立了一個顯示骨骼信息的自定義控件,並演示了如何將自定義控件引入到應用程序中。下一篇文章將會結合另外一個小游戲來介紹WPF的相關知識以及骨骼追蹤方面進一步值得注意和改進的地方。
作者: yangecnu(yangecnu's Blog on 博客園)
出處:http://www.cnblogs.com/yangecnu/