Cosa c’entra Leonardo e il suo Uomo Vitruviano, gli scheletri ed il Kinect?
Apparentemente nulla, ma leggendo questo post scoprirete che, invece, un legame c’è.
Abbiamo visto in precedenti post che l’infernale aggeggio (il Kinect) ci permette di ottenere, in maniera molto facile, le immagini video e le immagini di profondità. Già con questi due insiemi di informazioni potremmo realizzare una sorta di interazione tra i player ed il nostro applicativo.
L’SDK del Kinect, però, fa molto di più perché è in grado di fornirci gli “scheletri” dei player disposti davanti ad esso.
Vediamo come procedere per ricavare ciò.
La classe Runtime (di cui abbiamo parlato in questo post) prevede un evento chiamato SkeletonFrameReady che viene sollevato quando SDK ha a disposizione un oggetto di tipo SkeletonFrame pronto per essere elaborato.
L’istanza di questo oggetto è passata come proprietà dell’argomento dell’evento SkeletonFrameReady:
La classe SkeletonFrame, come possiamo vedere, espone una serie di proprietà decisamente interessanti:
- FloorClipPlane : l’oggetto Vector contenuto in questa proprietà ci fornisce i coefficienti dell’equazione del piano di terra (pavimento) stimata dall’SDK. L’equazione ha la forma:
Ax+By+Cz+D=0
dove:
A = FloorClipPlane.X
B = FloorClipPlane.Y
C = FloorClipPlane.Z
D = FloorClipPlane.W
L’equazione del piano è normalizzata, quindi D rappresenta, di fatto, l’altezza della camera rispetto al pavimento. Nella versione Beta dell’SDK il vettore è sempre nullo;
- NormalToGravity : consente di avere informazioni sul vettore di gravità. Nella versione Beta dell’SDK il vettore è sempre nullo.;
- FrameNumber : è il numero del frame in esame;
- TimeStamp : timestamp del frame (in maniera analoga a quanto visto per il video e la profondità);
- Quality : indica la qualità del frame;
- Skeletons : contiene un array di “scheletri” dei giocatori tracciati.
Il Kinect è in grado di tracciare fino a 6 giocatori contemporaneamente anche se:
- solo 2 giocatori possono essere tracciati “attivamente”. Di questi si conoscono tantissime info all’interno della struttura SkeletonData;
- fino a 4 giocatori possono essere tracciati “passivamente”. L’SDK mantiene solo alcune informazioni di questi giocatori.
La classe SkeletonData contiene, per ogni giocatore, i dati relativi alla posizione dello stesso nello spazio:
Lo scheletro è individuato da una collezione di punti del corpo umano, denominati Joint, e memorizzati nell’array Joints. Ogni Joint è individuato da un Id (di tipo JointID) e, allo stato attuale, possiamo contare su ben 20 punti mostrati nella seguente figura (ecco l’uomo vitruviano del titolo):
Ogni Joint fornisce la propria posizione nello spazio (proprietà Position) e lo stato di tracciamento (proprietà TrackingState). La posizione è identificata dalle tre coordinate spaziali secondo il seguente schema:
X e Y variano in un intervallo [-1,1] mentre Z esprime la distanza (in metri) dal sensore. Il valore di W, secondo quanto riportato dalla documentazione ufficiale dovrebbe variare tra 0 e 1 e identificare la precisione della posizione (1 posizione affidabile, 0 posizione totalmente inaffidabile) ma, allo stato attuale, il valore di W è sempre pari a 1.
La proprietà TrackingState della classe SkeletonData ci fornisce informazioni relative al tipo di tracciamento a cui è sottoposto lo scheletro e cioè se si tratta di un giocatore attivo (Tracked), passivo (PositionOnly) oppure se non è tracciato (NotTracked).
La proprietà Quality ci dice se lo scheletro è stato “tagliato”. Se, ad esempio, il Kinect riesce ad individuare lo scheletro dalle ginocchia in su (ad esempio perché caviglie e piedi sono nascosti), allora nella proprietà Quality avremo il valore ClippedBottom ad indicare che lo scheletro è stato “tagliato” in basso.
Interessante la proprietà Position di SkeletonData che contiene la posizione (approssimata) del centro di massa dello scheletro.
L’applicazione pratica che vi vorrei proporre a corollario di questo post è la realizzazione di una semplice applicazione che mostra, in tempo reale, la posizione dei venti punti dell’uomo vitruviano di cui sopra.
Cominciamo con il mostrare cosa vogliamo ottenere:
In pratica abbiamo, istantaneamente, la posizione di tutti i Joints disponibili del primo SkeletonData tracciato e, per ogni Joint, abbiamo un diverso colore di sfondo in base al loro stato di tracciamento.
In più visualizziamo il centro di massa (la TextBox in posizione “equivoca”),la qualità dello SkeletonFrame, il vettore NormalToGravity, il vettore FloorClipPlane (questi ultimi due sempre nulli).
L’applicazione è un’applicazione WPF in cui ho deciso di adottare il pattern MVVM, quindi, per spiegare il funzionamento, ci occuperemo, innanzitutto del ViewModel per poi usare il motore di binding di WPF per agganciare la UI.
Nel costruttore del View Model (MainWindowViewModel) avviamo l’istanza della classe Runtime dell’SDK del Kinect:
- Public Sub New()
- If Not DesignerProperties.GetIsInDesignMode(New DependencyObject()) Then
- Try
- Nui = New Runtime
- AddHandler Nui.DepthFrameReady, AddressOf DepthFrameReadyHandler
- AddHandler Nui.SkeletonFrameReady, AddressOf SkeletonFrameHandler
- Nui.Initialize(RuntimeOptions.UseDepthAndPlayerIndex Or RuntimeOptions.UseSkeletalTracking)
- Nui.DepthStream.Open(ImageStreamType.Depth, 2, ImageResolution.Resolution320x240, ImageType.DepthAndPlayerIndex)
- Catch ex As Exception
- End Try
- End If
- End Sub
Se non ci troviamo in design mode (primo if) eseguiamo i seguenti passi:
- creiamo l’istanza di Runtime;
- agganciamo il gestore dell’evento DepthFrameReady che ci servirà esclusivamente per mostrare l’immagine di profondità a video;
- agganciamo il gestore dell’evento SkeletonFrameReady che ci servirà per elaborare gli scheletri che il Kinect ci fornirà nel tempo;
- inizializziamo la Runtime selezionando lo stream di profondità e di Skeletal tracking;
- apriamo lo stream di profondità decidendo una risoluzione di 320x240 (per la profondità abbiamo solamente 320x240 e 80x60).
Tralasciamo il gestore di evento per le immagini di profondità (che abbiamo già visto nel precedente post) e ci dedichiamo a quello dello Skeletal Tracking:
- Private Sub SkeletonFrameHandler(sender As Object, e As SkeletonFrameReadyEventArgs)
- Me.NormalToGravity = e.SkeletonFrame.NormalToGravity
- Me.FloorClipPlane = e.SkeletonFrame.FloorClipPlane
- Me.Quality = e.SkeletonFrame.Quality.ToString()
- Dim skeleton = (From s In e.SkeletonFrame.Skeletons
- Where s.TrackingState = SkeletonTrackingState.Tracked
- Select s).FirstOrDefault()
- If skeleton IsNot Nothing Then
- Me.CenterPosition = skeleton.Position
- Me.Joints = skeleton.Joints.OfType(Of Joint)()
- Else
- Me.Joints = Nothing
- Me.CenterPosition = New Vector()
- End If
- End Sub
Le prime tre istruzioni non fanno altro che recuperare, rispettivamente, NormalToGravity, FloorClipPlane e Quality e valorizzare le relative proprietà del View Model (che andranno in binding con opportuni controlli di visualizzazione).
Tramite la query LINQ seguente andiamo a selezionare, tra tutti gli scheletri forniti nell’argomento dell’evento, il primo che abbia stato di tracciamento pari a Tracked.
Se ne esiste almeno uno (abbiamo, quindi, una istanza valida di SkeletonData), andiamo a valorizzare le proprietà del View Model che ci consentono di visualizzare il valore del centro di massa (proprietà CenterPosition) e il valore di ogni singolo Joint dello scheletro (proprietà Joints).
A questo punto abbiamo due tipologie di proprietà nel nostro View Model: quelle di tipo Vector (NormalToGravity, FloorClipPlane e CenterPosition) e la collezione di Joint esposta dalla Joints.
Tutte queste proprietà verranno visualizzate tramite TextBox e utilizzeremo degli appositi Converter per formattare opportunamente le stesse.
Per le proprietà di tipo Vector creiamo il converter:
- Public Class VectorToStringConverter
- Implements IValueConverter
- Public Function Convert(value As Object,
- targetType As System.Type,
- parameter As Object,
- culture As System.Globalization.CultureInfo) As Object Implements System.Windows.Data.IValueConverter.Convert
- If TypeOf value Is Vector Then
- Dim vector = CType(value, Vector)
- Dim strFormat = "{0:0.000} {1:0.000} {2:0.000} [{3:0.000}]"
- If parameter IsNot Nothing Then
- strFormat = parameter.ToString()
- End If
- Dim str = String.Format(strFormat, vector.X, vector.Y, vector.Z, vector.W)
- Return str
- Else
- Return Nothing
- End If
- End Function
- Public Function ConvertBack(value As Object,
- targetType As System.Type,
- parameter As Object,
- culture As System.Globalization.CultureInfo) As Object Implements System.Windows.Data.IValueConverter.ConvertBack
- Throw New NotImplementedException
- End Function
- End Class
In sostanza, prendiamo il valore da convertire e se è di tipo Vector e eseguiamo il metodo String.Format utilizzando la stringa di formato passata come parametro (o quella di default se il parametro non è impostato) e i valori di X, Y, Z e W del vettore.
Grazie all’utilizzo di questo converter possiamo visualizzare il centro di massa con il seguente XAML:
- <TextBox Text="{Binding Path=CenterPosition, Converter={StaticResource VectorToStringConverter}, ConverterParameter=\\cf1 {0:0.000\\cf1 } \\cf1 {1:0.000\\cf1 } \\cf1 {2:0.000\\cf1 } }"
- Canvas.Left="448" Canvas.Top="460" Style="{StaticResource PosizionTextBox}"
- Background="LightSteelBlue" Foreground="White"/>
Più interessante è sicuramente il converter che ci permetterà di visualizzare i dati di posizione del singolo Joint all’interno dei 20 TextBox che circondano l’uomo vitruviano.
In questo caso il converter accetta come parametri la stringa identificante l’id del Joint (as esempio “HandRight” per la mano destra) e la stringa di formattazione (questa è opzionale) separati dal carattere “|”.
Il codice del converter è il seguente:
- Public Class SkeletonJointPositionToStringConverter
- Implements IValueConverter
- Public Function Convert(value As Object,
- targetType As System.Type,
- parameter As Object,
- culture As System.Globalization.CultureInfo) As Object Implements System.Windows.Data.IValueConverter.Convert
- If TypeOf value Is IEnumerable(Of Joint) Then
- Dim joints = CType(value, IEnumerable(Of Joint))
- Dim parameters = parameter.ToString.Split("|"c)
- If parameters.Count > 0 Then
- Dim jointId = parameters(0)
- Dim jointQuery = From j In joints
- Where j.ID = CType([Enum].Parse(GetType(JointID), jointId), JointID)
- Select j
- If jointQuery.Any() Then
- Dim joint = jointQuery.First()
- Dim strFormat = "{0:0.000} {1:0.000} {2:0.000}"
- If parameters.Count > 1 Then
- strFormat = parameters(1)
- End If
- Dim str = String.Format(strFormat, joint.Position.X, joint.Position.Y, joint.Position.Z)
- Return str
- Else
- Return Nothing
- End If
- Else
- Return Nothing
- End If
- Else
- Return Nothing
- End If
- End Function
- Public Function ConvertBack(value As Object,
- targetType As System.Type,
- parameter As Object,
- culture As System.Globalization.CultureInfo) As Object Implements System.Windows.Data.IValueConverter.ConvertBack
- Throw New NotImplementedException
- End Function
- End Class
In questo caso, se il valore che stiamo convertendo è di tipo IEnumerable(Of Joint) (come la proprietà Joints del ViewModel), andiamo a ricercare, tramite una query LINQ, il Joint che ha id passato come parametro (notare la chiamata al metodo Split di String per ricavare i due argomenti separati da “|”). Se la query LINQ restituisce un joint valido (se la stringa di ID passata per argomento è uno dei valori ammessi), allora creiamo la stringa da visualizzare utilizzando la posizione del joint stesso e la restituiamo al chiamante.
Grazie a questo converter possiamo utilizzare il binding per visualizzare tutti e 20 i punti dell’uomo vitruviano senza dover creare 20 differenti proprietà nel view model. Ad esempio la visualizzazione della posizione della testa (Head) è la seguente:
- <TextBox Canvas.Left="470" Canvas.Top="70" Style="{StaticResource PosizionTextBox}"
- Text="{Binding Path=Joints, Converter={StaticResource SkeletonJointPositionToStringConverter1}, ConverterParameter=Head|\\cf1 {0:0.000\\cf1 } \\cf1 {1:0.000\\cf1 } \\cf1 {2:0.000\\cf1 }}"
- Background="{Binding Path=Joints, Converter={StaticResource SkeletonJointTrackingStateToSolidColorBrushConverter}, ConverterParameter=Head|LightGreen|LightYellow|LightCoral}" />
Osservando lo XAML, possiamo notare l’utilizzo di un binding anche sul background del TextBox e, questo, per fare in modo che in base allo stato di tracciamento del joint, si abbia un differente colore di sfondo. Anche in questo caso ricorriamo ad un converter:
- Public Class SkeletonJointTrackingStateToSolidColorBrushConverter
- Implements IValueConverter
- Public Function Convert(value As Object,
- targetType As System.Type,
- parameter As Object,
- culture As System.Globalization.CultureInfo) As Object Implements System.Windows.Data.IValueConverter.Convert
- If TypeOf value Is IEnumerable(Of Joint) Then
- Dim joints = CType(value, IEnumerable(Of Joint))
- Dim parameters = parameter.ToString.Split("|"c)
- If parameters.Count > 0 Then
- Dim jointId = parameters(0)
- Dim jointQuery = From j In joints
- Where j.ID = CType([Enum].Parse(GetType(JointID), jointId), JointID)
- Select j
- If jointQuery.Any() Then
- Dim joint = jointQuery.First()
- Dim color As Color?
- Select Case joint.TrackingState
- Case JointTrackingState.Tracked
- color = If(parameters.Count() < 1, Colors.LightGreen, ColorUtility.GetColorFromName(parameters(1)))
- Case JointTrackingState.Inferred
- color = If(parameters.Count() < 2, Colors.LightYellow, ColorUtility.GetColorFromName(parameters(2)))
- Case Else
- color = If(parameters.Count() < 3, Colors.LightCoral, ColorUtility.GetColorFromName(parameters(3)))
- End Select
- If color.HasValue Then
- Dim brush = New SolidColorBrush(color.Value)
- Return brush
- Else
- Return Nothing
- End If
- Else
- Return Nothing
- End If
- Else
- Return Nothing
- End If
- Else
- Return Nothing
- End If
- End Function
- Public Function ConvertBack(value As Object,
- targetType As System.Type,
- parameter As Object,
- culture As System.Globalization.CultureInfo) As Object Implements System.Windows.Data.IValueConverter.ConvertBack
- Throw New NotImplementedException
- End Function
- End Class
Il converter è del tutto analogo al precedente con la differenza che utilizza i parametri per ricevere la stringa identificante l’id del joint da analizzare e la terna di colori da utilizzare per gli stati Tracked, Inferred e NotTracked.
Ultima cosa prima di chiudere il post riguarda la possibilità di affinare la precisione dello skeletal tracking.
Se provate ad utilizzare lo skeletal tracking, infatti, vi renderete conto che anche rimanendo immobili, i valori di posizione oscillano.
E’ possibile intervenire su questo fenomeno di jittering impostando alcune proprietà del motore di skeletal tracking. La classe SkeletonEngine esposta dalla classe Runtime tramite la proprietà omonima, consente di impostare i parametri di Smooth Transform.
Questi parametri permettono il controllo della precisione del rilevamento dello scheletro da parte del KInect e la loro disamina accurata sarà oggetto di un altro post.
Nel nostro caso potremmo modificare il costruttore nel seguente modo:
- Public Sub New()
- If Not DesignerProperties.GetIsInDesignMode(New DependencyObject()) Then
- Try
- Nui = New Runtime
- AddHandler Nui.DepthFrameReady, AddressOf DepthFrameReadyHandler
- AddHandler Nui.SkeletonFrameReady, AddressOf SkeletonFrameHandler
- Nui.Initialize(RuntimeOptions.UseDepthAndPlayerIndex Or RuntimeOptions.UseSkeletalTracking)
- Nui.SkeletonEngine.TransformSmooth = True
- Dim parameters = New TransformSmoothParameters With {.Smoothing = 0.75F,
- .Correction = 0.0F,
- .Prediction = 0.0F,
- .JitterRadius = 0.05F,
- .MaxDeviationRadius = 0.04F}
- Nui.SkeletonEngine.SmoothParameters = parameters
- Nui.DepthStream.Open(ImageStreamType.Depth, 2, ImageResolution.Resolution320x240, ImageType.DepthAndPlayerIndex)
- Catch ex As Exception
- End Try
- End If
- End Sub
A seguito dell’initialize attiviamo la gestione dello smoothing (TransformSmooth=true), istanziamo un oggetto di classe TransformSmoothParameters e impostiamo la proprietà SmoothParameters dello SkeletonEngine.
La classe TransformSmoothParameters è poco documentata e per questo motivo cercheremo di approfondire il suo utilizzo in un ulteriore post.
Commenti