Wie sich Kinect-1-Apps nach Kinect 2 portieren lassen

Know-how  –  0 Kommentare

Microsoft war in der Vergangenheit als Bastion der Abwärtskompatibilität bekannt: Im Rahmen der Auslieferung von Windows 95 arbeitete beispielsweise ein Team daran, störrische Win16-Apps zum Laufen zu bekommen. Kinect-Entwickler empfinden die Bilanz als weniger positiv: Sie stehen mittlerweile vor dem zweiten Bruch mit der Binärkompatibilität.

Zur Nutzung der Version 2 der Hardware zur Steuerung der Videospielkonsole Xbox 360 sind nicht unerhebliche Hardwareanforderungen zu erfüllen. Neben einem kompatiblen USB-3.0-Port zur Bewältigung der Datenmengen brauchen Anwender zwangsweise eine DirectX11-fähige Grafikkarte mit einer Rechenleistung von mehr als 150 GFLOPS/Sec. Dass auf Seiten der Workstation Windows 8 vorausgesetzt wird, folgt aus der (für Entwickler ärgerlichen) Marktlogik.

Die folgenden Schritte erfolgen auf einem AMD-FX-8320-Prozessor mit einer Radeon-R7-250-Grafikkarte. Als USB-Controller kommt ein auf einem Motherboard vom Typ M5A78L-M/USB3 fix verlöteter Chip aus dem Hause ASMedia zum Einsatz, als IDE dient Visual Studio 2013 Express for Desktop.

Sprechen Sie Histogramm?

Das vom Autor beim dpunkt.verlag erschienene Lehrbuch zum Thema Kinect enthielt eine witzig gemeinte Anwendung, die die von der Kinect angelieferten Tiefendaten in Form eines Histogramms präsentierte und ausgab. Es ist insofern für die folgenden Schritte geeignet, weil es über das reine Anzeigen von Tiefendaten hinausgehende Logik enthält. Dadurch besteht die Möglichkeit, die (vergleichsweise minimalen) Auswirkungen des neuen Sensors auf die Applikationslogik zu bewerten.

Das Projektbeispiel KinectWPFDHisto lässt sich auf einer Workstation ohne installiertem Visual Studio 2010 nicht per Doppelklick öffnen. Entwickler müssen stattdessen die Öffnen-Funktion der aktuellen Version der IDE zum Importieren nutzen, gefolgt vom Anpassen der Verweise: Die neue Version der Assembly Microsoft.Kinect findet sich unter Assemblys | Erweiterungen.

Im Store ist alles anders

Wer "Applikationen für neuartige Technologien" für den Windows Store entwickeln möchte, muss statt Microsoft.Kinect auf WindowsPreview.Kinect zurückgreifen. Leider sind diese Assemblies im Moment noch nicht für die öffentliche Verwendung freigegeben: Hochgeladene Apps werden vom QA-Team des Stores aussortiert.

Die Treiberarchitektur der Kinect 1 ermöglichte das Anschließen mehrerer Sensoren an einer Workstation. Jeder Sensor ließ sich zu jedem Zeitpunkt nur von einer Applikation nutzen: Sie konnte den Farb-, den Tiefen- und den Skelettaldatenstrom durch das Aufrufen der jeweiligen Enabled-Methode aktivieren. Ein als KinectSensorChooser bezeichnetes Steuerelement erlaubte dem Benutzer die Auswahl des zu verwendenden Sensors. Im SDK der Kinect 2 gibt es dieses nicht mehr – der erste Akt besteht nun darin, das Projekt Microsoft.Kinect.Toolkit aus der Solution zu entfernen. Bei MainWindow.xaml ist der Verweis auf den Toolkit-Namensraum und die Einbindung des KinectSensorChooserUI-Elements zu beseitigen. Die neue Version der Datei sieht so aus:

<Window x:Class="KinectWPFDHisto.MainWindow" 
xmlns:toolkit="clr-namespace:Microsoft.Kinect.Toolkit;
assembly=Microsoft.Kinect.Toolkit"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="MainWindow" Height="537" Width="843">
<Grid>
<Image Height="480" HorizontalAlignment="Left" Name="image1"
Stretch="Fill" VerticalAlignment="Top" Width="640" />
<toolkit:KinectSensorChooserUI x:Name="SensorChooserUI"
IsListening="True" HorizontalAlignment="Center"
VerticalAlignment="Top" />
<Label Content="Label" Height="28" HorizontalAlignment="Left"
Margin="667,9,0,0" Name="label1" VerticalAlignment="Top"
Width="121" />
</Grid>
</Window>

Sich einen Zeiger auf den zu verwendenden Sensor einzurichten, kann ab sofort im Konstruktor erfolgen. Dazu genügt es, folgendes Snippet anstelle der Umsetzung der SensorChooser-UI zu implementieren:

public MainWindow() 
{
InitializeComponent();

mySensor = KinectSensor.GetDefault();
mySensor.Open();
setupSources();
}

Nutzer können ihren Sensor – zumindest in der Theorie – im laufenden Betrieb abstecken. Das lässt sich durch das Prüfen von IsAvailable und/oder das Anmelden eines Eventlisteners überprüfen – zwei Maßnahmen, die hier nicht gezeigt werden.

Frames

Eine Abstraktionsschicht tiefer

Wer die Datenbasis der Kinect der ersten Generation anzapfen wollte, registrierte sein Interesse an einem oder mehreren FrameReady-Events. Bei der Kinect 2 ist die Lage aufgrund der neuen Treiberarchitektur etwas anders: Frames werden über als Demultiplexer agierende FrameReader-Instanzen an ihre Konsumenten weitergeleitet. Die für die Initialisierung notwendige Funktion setupSources sieht nach der Anpassung so aus:

void setupSources() 
{
myDReader = mySensor.DepthFrameSource.OpenReader();
myDReader.FrameArrived += myDReader_FrameArrived;
FrameDescription myFrameSpecs = myDReader.DepthFrameSource.
FrameDescription;
myDArray1 = new ushort[myFrameSpecs.LengthInPixels];
myDArray2 = new ushort[myFrameSpecs.LengthInPixels];
myDArray3 = new ushort[myFrameSpecs.LengthInPixels];
myHistoArray = new int[50];
myFinalArray = new ushort[myFrameSpecs.LengthInPixels];

}

Bei der Kinect 2 steht AllFramesReady nicht mehr zur Verfügung. Entwickler müssen daher die von den einzelnen Quellen abgefeuerten Frames sammeln und können ihre Bearbeitung bei Bedarf in einer gemeinsamen Methode bündeln. Als Alternative bietet sich die Nutzung des MultiFrameSourceReader an. Für das Beispiel hier genügt es, wenn man FrameArrived nach folgendem Schema implementiert:

void myDReader_FrameArrived(object sender, DepthFrameArrivedEventArgs e) 
{
DepthFrame d = e.FrameReference.AcquireFrame();

if (d == null) return;
myHistoArray = new int[50];

myDArray3 = (ushort[])myDArray2.Clone();
myDArray2 = (ushort[])myDArray1.Clone();
d.CopyFrameDataToArray(myDArray1);
d.Dispose();

Die erste und wichtigste Änderung betrifft die Art des Frame-Handlings. Jede FrameSource stellt ihrem Klienten immer nur einen Frame zur Verfügung: Solange dieser nicht durch Aufruf von Dispose "über den Jordan geschickt" wird, kommen bei neuen Aufrufen von AcquireFrame keine weiteren Informationen nach.

Bei der Kinect 1 wurden die Tiefeninformationen mit dem Spielerindex verarbeitet und gemeinsam an den Entwickler ausgeliefert. Microsoft spendiert der Kinect 2 mit der BodyIndexFrameSource eine eigene Datenquelle für Spielerindizes, weshalb das bisher notwendige "Bit Twiddling" ersatzlos entfällt:

for (int x = 0; x < 512; x++) 
{
for (int y = 0; y < 424; y++)
{
//Get Depth
int innerCoord = y * 512 + x;
ushort depthVal = myDArray1[innerCoord];
//Nicht mehr notwendig!
//depthVal = (short)(depthVal >>
DepthImageFrame.PlayerIndexBitmaskWidth);
myDArray1[innerCoord] = depthVal;
}
}

Zu guter Letzt ist die für die Histogrammerstellung zuständige Auswertungsmethode an die neuen Gegebenheiten anzupassen. Die aus der Fotografie entlehnten Histogramme stellen die Werte aller in einem Feld befindlichen Tupel übersichtlich dar: Als klassischer Anwendungsfall hat sich das Anzeigen der Helligkeitsverteilung eines Fotos etabliert. Das im Beispiel errechnete Tiefenhistogramm zeigt, wie viel Prozent der vom Sensor gesehenen Objekte wie weit von seinem Mittelpunkt entfernt sind. Bei der Kinect der zweiten Generation liegen Tiefendaten immer in einer Auflösung von 512 x 424 Pixel vor. Das Datenformat selbst ist unverändert, der Erfassungsbereich reicht nun von 0,5 bis 4,5 Metern:

    for (int x = 0; x < 512; x++) 
{
for (int y = 0; y < 424; y++)
{
int innerCoord = y * 512 + x;
ushort depth1Val = myDArray1[innerCoord];
ushort depth2Val = myDArray2[innerCoord];
ushort depth3Val = myDArray3[innerCoord];
myFinalArray[innerCoord] = (ushort)(depth1Val /
3 + depth2Val / 3 + depth3Val / 3);

//Perform binning
int histoCoord=myFinalArray[innerCoord] /100;
if(histoCoord>49)histoCoord=49;
myHistoArray[histoCoord]++;
}
}

int maxVal = 1; //Prevent divide by zero
for (int i = 49; i > 0; i--)
{
if (myHistoArray[i] > maxVal) maxVal = myHistoArray[i];
}

DrawingVisual drawingVisual = new DrawingVisual();
DrawingContext drawingContext = drawingVisual.RenderOpen();
SolidColorBrush brickBrush =
new SolidColorBrush(Color.FromArgb(255, 0, 0, 0));
Pen brickPen = new Pen(brickBrush, 10);
for (int i = 49; i > 0; i--)
{
drawingContext.DrawLine(brickPen, new Point(i * 10, 480),
new Point(i * 10, 480 - myHistoArray[i] * 480 / maxVal));

}

SolidColorBrush gridBrush =
new SolidColorBrush(Color.FromArgb(255, 128, 128, 128));
Pen gridPen = new Pen(gridBrush, 1);
for (int i = 0; i <= 49; i = i + 10)
{
drawingContext.DrawLine(gridPen, new Point(i * 10, 480),
new Point(i * 10, 0));

}

//Calculate maximum height

int percentage = maxVal / ((640 * 480) / 100);
label1.Content = percentage.ToString() + "%";

drawingContext.Close();
RenderTargetBitmap myTarget =
new RenderTargetBitmap(640, 480, 96, 96, PixelFormats.Pbgra32);
myTarget.Render(drawingVisual);
image1.Source = myTarget;

d.Dispose();
}

Da die neuen Treiber-Assemblies auf der Version 4.5 des .NET Framework basieren, ist die Manifestdatei ein wenig anzupassen. Dafür muss man das Projekt im Solution Explorer rechts anklicken und im daraufhin erscheinenden Menü die Option "Eigenschaften" auswählen. Dann wird das Ziel-Framework auf 4.5 gesetzt, und nach dem Neustarten des Frameworks ist das Programm einsatzbereit – fertig ist das in Abbildung 1 gezeigte Histogramm.

Auch mit Kinect 2 lassen sich Umgebungsdaten abtasten (Abb. 1)

Facial Tracking

Schau dem Volk aufs Maul

Als zweites Beispiel befasst sich der Autor mit dem Facial Tracking. Wer mit einer Kinect 1 for Xbox arbeitete, litt aufgrund des fehlenden Near Modes unter eher bescheidener Genauigkeit: Dass der Sensor trotzdem bis zu 100 Punkte pro Gesicht erkennen konnte, ist eine algorithmische Meisterleistung von Microsoft.

Dank der wesentlich gesteigerten Auflösung des Sensors ist die Hardware der zweiten Generation deutlich besser für Facial Tracking geeignet. Um die Möglichkeiten der neuen API zu verdeutlichen, soll ein weiteres für den Kinect 1 vorgesehenes Programm adaptiert werden.

Die neue Lösung analysiert den Mundöffnungswinkel des vor dem Sensor stehenden Users. Nach dem Öffnen der Projektmappe müssen Entwickler die Verweise anpassen und das Kinect-Toolkit entfernen. Der Namespace Microsoft.Kinect.Toolkit.FaceTracking ist samt dem Wrapper-Projekt nicht mehr notwendig und wird durch einen Verweis auf Microsoft.Kinect.Face ersetzt.

Das SDK der Kinect 2 wird mit zwei verschiedenen Face-Trackern ausgeliefert. Der HighDefinition-Face-Tracker liefert Animation Units (AU) und genaue Gesichtskoordinaten, während der hier nicht weiter besprochene normale Face Tracker "zusammengefasste" Informationen liefert.

Aufgrund der weiter unten im Detail besprochenen Datenbank schließt die Nutzung des einen Trackers den anderen aus. Die Initialisierung erfolgt in beiden Fällen zweistufig: Das Source-Objekt generiert im nächsten Schritt einen Reader:

void setupStreams() 
{
myBodyReader = mySensor.BodyFrameSource.OpenReader();
myBodyReader.FrameArrived += myBodyReader_FrameArrived;

myColReader=mySensor.ColorFrameSource.OpenReader();
myColorArray = new byte[1920 * 1080 * 4];
myColReader.FrameArrived += myColReader_FrameArrived;

FrameDescription colorFrameDescription =
mySensor.ColorFrameSource.CreateFrameDescription
(ColorImageFormat.Bgra);
myBitmap= new WriteableBitmap(colorFrameDescription.Width,
colorFrameDescription.Height, 96.0, 96.0,
PixelFormats.Pbgra32, null);

myFFSource = new HighDefinitionFaceFrameSource(mySensor);
myFFReader = myFFSource.OpenReader();
myFace = new FaceAlignment();
myHasFaceFlag = false;
myFFReader.FrameArrived += myFFReader_FrameArrived;
}

Im SDK von Kinect 1 implementierte Face Tracker mussten Entwickler mit Tiefen-, Farb- und Skelettal-Frames versorgen. Das entfällt nun mehr oder weniger komplett – die Anforderung des Skelettal-Streams dient nur zum Ermitteln der Body-ID, die den Sensor über das zu bearbeitende Gesicht informiert:

void myBodyReader_FrameArrived(object sender, BodyFrameArrivedEventArgs e) 
{
BodyFrame myBodyFrame = e.FrameReference.AcquireFrame();
if (myBodyFrame == null) return;

Body selectedBody = FindClosestBody(myBodyFrame);

if (selectedBody != null)
{
myFFSource.TrackingId = selectedBody.TrackingId;
}
myBodyFrame.Dispose();
}

Eingehende Gesichts-Frames werden durch das Auslösen des FrameArrived-Events
angezeigt. Die Methode GetAndRefreshFaceAlignmentResult sorgt dafür, dass die Matrix mit den diversen AUs und Gesichtskoordinaten in ein Objekt herausgeschrieben wird.

Da die im MSDN-Portal beschriebene Eigenschaft für die Bounding-Box noch nicht implementiert ist, wird der Mittelpunkt des Gesichts ermittelt und als Basis für das zu zeichnende Rechteck genutzt. Die Umwandlung von Kamera- in Farbkoordinaten erfolgt über den CoordinateMapper, der ein Member des Sensorobjekts ist:

void myFFReader_FrameArrived(object sender, 
HighDefinitionFaceFrameArrivedEventArgs e)
{
using (var frame = e.FrameReference.AcquireFrame())
{
if (frame == null || !frame.IsFaceTracked)
{
return;
}

frame.GetAndRefreshFaceAlignmentResult(myFace);

ColorSpacePoint aPoint =
mySensor.CoordinateMapper.MapCameraPointToColorSpace
(myFace.HeadPivotPoint);


myRect=new Rect(aPoint.X-40,aPoint.Y-40,80,80);

myHasFaceFlag = true;
}
}

Die Zerlegen der Gesichtsdaten setzt Möglichkeiten voraus, die im Moment nicht als Teil der DLLs angeboten wird. Sie ist vor der Ausführung von Hand in das Zielverzeichnis zu kopieren. Dazu genügt ein Rechtsklick auf das Projekt im Solution Explorer. Im Eigenschaftendialog lassen sich unter Build-Ereignisse die Befehlszeile für Post-Build-Ereignisse parametrisieren. Dort muss folgendes Kommando eingegeben werden:

xcopy "$(KINECTSDK20_DIR)Redist\Face\$(Platform)\NuiDatabase" 
"$(TargetDir)\NuiDatabase" /S /R /Y /I

Optimiere mich

Aufgrund der um den Faktor vier höheren Datenmenge ist die bei Kinect-1-Applikationen angemessene Vorgehensweise mit der BitmapSource eher ungeeignet: Auf der vergleichsweise schnellen Workstation des Autors kam es mit dem ursprünglichen Algorithmus immer wieder zu massiven Rucklern. Zudem versagte der Face Tracker den Dienst, da ihm die verbleibende Rechenleistung nicht ausreichte.

Zur Lösung dieses Problems empfiehlt es sich, die Render-Pipeline auf ein WriteableBitmap umzustellen und dieses über die Erweiterungsbibliothek WriteableBitmapEx ansprechbar zu machen. Die neue Rendering-Routine sieht dann wie im folgenden Code aus – die einzige für mit WriteableBitmapEx erfahrene Entwickler interessante Passage ist die nach der Deklaration von mundOffen erfolgende Auswertung der AU:

void myColReader_FrameArrived(object sender, ColorFrameArrivedEventArgs e) 
{

ColorFrame myFrame = e.FrameReference.AcquireFrame();

if (myFrame == null) return;
myBitmap.Lock();
KinectBuffer b = myFrame.LockRawImageBuffer();
myFrame.CopyConvertedFrameDataToIntPtr(myBitmap.BackBuffer,
(uint)(1920 * 1080 * 4),
ColorImageFormat.Bgra);
myBitmap.AddDirtyRect(new Int32Rect(0, 0, myBitmap.PixelWidth,
myBitmap.PixelHeight));
b.Dispose();
myFrame.Dispose();

if (myHasFaceFlag == true)
{//Gesicht detektiert
//Rendern

Pen boxPen = new System.Windows.Media.Pen(new
SolidColorBrush(System.Windows.Media.Color.FromRgb
(255, 0, 0)), 2);
myBitmap.DrawRectangle((int)myRect.Left, (int)myRect.Top,
(int)myRect.Right, (int)myRect.Bottom, Color.FromRgb
(255, 0, 0));

float mundOffen = myFace.AnimationUnits[FaceShapeAnimations.JawOpen];
if (mundOffen < 0) mundOffen = 0;
if (myCalibratedFlag == true)
{
mundOffen = mundOffen * (1 / myCalibratedMaxval) * 180;
}
else
{
mundOffen = mundOffen * 180;
}
System.Windows.Rect boundaryRect =
new System.Windows.Rect(myRect.Right, myRect.Bottom, 180, 20);
System.Windows.Rect fillRect;

myBitmap.DrawRectangle((int)myRect.Right, (int)myRect.Bottom,
(int)myRect.Right + (int)180, (int)myRect.Bottom+20,
Color.FromRgb(255,0,0));


myBitmap.FillRectangle((int)myRect.Right, (int)myRect.Bottom,
(int)myRect.Right + (int)mundOffen, (int)myRect.Bottom + 20,
Color.FromRgb(255, 0, 0));

}
else
{

}

image1.Source = myBitmap;
myBitmap.Unlock();
}

An dieser Stelle gibt es eine kleine Falle. Die diversen in WriteableBitmapEx implementierten Zeichenfunktionen setzen ein Bitmap vom Typ Pbrga voraus, während die Kinect-Beispiele mit Rgba32 arbeiten. Da die Speicherformate weitgehend kompatibel zueinander sind, genügt es, die Deklaration des WriteableBitmap nach folgendem Schema anzupassen:

myBitmap= new WriteableBitmap(colorFrameDescription.Width, 
colorFrameDescription.Height, 96.0, 96.0, PixelFormats.Pbgra32, null);

Damit ist auch das zweite Beispiel – bis auf die nicht angepasste Kalibrationsroutine – einsatzbereit. Nach der Ausführung kann man sich an der in Abbildung 2 gezeigten Szene erfreuen.

Der Mundöffnungswinkel wird präzise erkannt (Abb. 2)

Fazit

Wer die Konzepte und die Rolle der Kinect-Technik in Grundzügen verstanden hat, sollte beim Umstieg auf die neue API keine allzu großen Probleme bekommen. Die Anpassung der nicht sonderlich stark modularisierten Beispiele aus dem in der Einleitung erwähnten Lehrbuch ist normalerweise in 30 Minuten erledigt.

Die zweite Generation des Sensorsystems ist keine revolutionäre Weiterentwicklung. Die Steigerung
der Auflösung und die eingebaute Infrarotlichtquelle beheben von Entwicklern häufig geäußerte Ärgernisse. In der Praxis ist es oftmals fraglich, ob sich der nicht unerhebliche Aufpreis lohnt.

Der preiswertere Sensor der ersten Generation kommt aufgrund des "primitiveren" Aufbaus mit wesentlich weniger Rechenleistung und mit einem USB-2.0-Port aus: Im Embedded-Bereich (Stichwort Bankfiliale) ist das oft wichtiger als eine Anlieferung von Daten in Full HD. (ane)

Tam Hanna
befasst sich seit der Zeit des Palm IIIc mit Programmierung und Anwendung von Handheldcomputern. Er entwickelt Programme für diverse Plattformen, betreibt Onlinenews-Dienste zum Thema und steht für Fragen, Trainings und Vorträge gern zur Verfügung.