Leap Motion Java - Hand Tracking (1)
Zoltan Ruzman 4684
Abstract
Im ersten Teil dieser Reihe wird eine abstraktes Hand-Tracking-Handling vorgestellt. D.h. unabhängig von der Visualisierung (AWT, Swing, LWJGL, etc.) soll die Leap Motion alle Hände registrieren und ihre Position auf den Bildschirm berechnen. Die in diesem Teil erarbeiteten Klassen stehen unter dem Link HandTracking zum Download zur Verfügung.
2D Hand Tracking - Umgebung
Zu Beginn wird die Hand nur in der vertikalen Ebene getrackt und erst in den nächsten Teilen die dritte Dimension hinzugefügt. Die obige Abbildung soll das Prinzip veranschaulichen. Damit der Anwender nicht versehentlich aus dem Fokus der Leap Motion verschwindet, wird die Trackingzone nicht den kompletten Handlungsspielraum einnehmen. Zur groben Orientierung enthält die obige Abbildung Maßeinheiten. Zudem führt eine kleine Trackingzone dazu, dass der Anwender mit seiner Hand keine zu großen Strecken zurücklegen muss.
Im ersten Schritt wird die "Umgebung" des Anwenders in der Klasse Environment festgehalten. Dazu wird die Bildschirmbreite und -höhe (in Pixel) ermittelt.
public final class Environment {
public static final int DISPLAY_WIDTH;
public static final int DISPLAY_HEIGHT;
static {
GraphicsDevice device = GraphicsEnvironment
.getLocalGraphicsEnvironment().getDefaultScreenDevice();
DisplayMode dispMode = device.getDisplayMode();
DISPLAY_WIDTH = dispMode.getWidth();
DISPLAY_HEIGHT = dispMode.getHeight();
}
}
Zusätzlich wird die Trackingzone der Leap Motion (in Millimeter) bestimmt. Da der Leap Motion Controller im Zentrum des Koordinatensystems steht, gibt es einen linken und rechten Bereich. Eine Interaktionshöhe ist notwendig, um im Fokus zu bleiben und weil es aus Usability-Sicht Sinn ergibt.
public static int minHeight;
public static int height;
public static int left;
public static int right;
Diese Variablen werden dann in der init()-Methode initialisiert. An dieser Stelle könnte die Frage aufkommen, warum public static genutzt wird? Das soll fürs erste die Performance mit einem geringen Programmieraufwand verbessern. Des Weiteren werden alle relevanten nativen Libraries mit der Klasse NativeLibrary eingebunden und die Controller-Instanz erzeugt. Als letzte Klassenvariable wird step deklariert. Diese enthält den Umrechnungsfaktor, um den zurückgelegten Weg (in mm) in Pixel umzurechnen.
2D Hand Tracking - Berechnung
Die Berechnung der Bildschirmkoordinaten wird in der Klasse ExtendedHand implementiert. Aus designtechnischen Gründen ist ExtendedHand keine Unterklasse von Hand, sondern von AbstractPoint. Zur Klasse AbstractPoint wird es in den nächsten Teilen weitere Informationen geben. An dieser Stelle ist es nur notwendig zu wissen, dass AbstractPoint die ID einer Hand, die Position (in Pixel) und den Durchmesser (in Pixel) speichert. Wobei der Durchmesser optional ist. Mit diesem wird verhindert, dass die Position außerhalb des Bildschirms liegt, sofern eine Hand die Trackingzone verlässt. Die Berechnung der Koordinaten findet in der Methode update() statt und wird im folgenden Quelltext erläutert:
protected void update(Frame frame) {
// Aktuelle HandPosition erfassen.
Vector palm = frame.hand(id()).palmPosition();
// (palm.getX()+left) == Verschiebe die HandPosition um die linke Teilhälfte nach rechts.
// Der Restwert von (palm.getX()+left) entspricht der Distanz zur linken Trackingzone-Wand in mm.
// Dieser Restwert wird mit dem mm/px-Umrechnungsfaktor (step) multipliziert.
// Dies Wert entspricht dann der X-Pixel-Koordinate.
float x = (palm.getX()+left)*step;
// (palm.getY()-minHeight) == Ziehe von der Y-Koordinate die Mindesthöhe ab.
// Der Restwert von (palm.getX()+left) entspricht der Distanz zum Boden.
// Dieser Restwert wird mit dem mm/px-Umrechnungsfaktor (step) multipliziert.
// Die Y-Pixel-Koordinate geht von oben nach unten und die Distanz zeigt von unten nach oben.
// Deshalb wird der ermittelte Wert gedreht.
float y = DISPLAY_HEIGHT - ((palm.getY()-minHeight)*step);
// Sofern die Hand einen "Durchmesser" hat, kann sie den Bildschirm
// nur verlassen, wenn der Fokus verloren geht.
if(hasDiameter()) {
if (x < 0) {
x = 0;
} else if(x > maxXPos) {
x = maxXPos;
}
if (y < 0) {
y = 0;
} else if(y > minYPos) {
y = minYPos;
}
}
// Koordinaten abspeichern.
position.setX(x);
position.setY(y);
}
2D Hand Tracking - Anlaufstelle
Obwohl die Implementierung der Klasse MotionRegistry noch sehr mager ist, wird es die zentrale und damit auch bedeutendste Anlaufstelle bei der Verwaltung der Hände. Später werden Pointables etc. folgen. Durch diese wird die Applikation mit der Leap Motion synchronisiert. Taucht eine Hand auf oder verschwindet, wird die MotionRegistry aktualisiert und dem Program steht die Information zur Verfügung.
2D Hand Tracking - Beispiel
Da das Ganze sehr abstrakt ist, folgt nun eine Beispiel-Applikation. Diese wird die Position der Hand mit einem roten Kreis auf dem Bildschirm anzeigen. Zum Rendern der Kreise wird Slick2D genutzt. Der grobe Aufbau ist dem UML-Diagramm zu entnehmen:
Zu beachten ist, dass die Applikation in zwei Threads läuft. Da die MotionRegistry eine Unterklasse von Listener ist, wird dort die onFrame()-Methode überschrieben. Immer wenn ein neuer Frame zur Verfügung steht, werden die Hände ermittelt, parallel dazu ein CirclePalm-Instanz erzeugt (sofern noch nicht vorhanden) und die Position neu berechnet. Weil es immer wieder vorkommt, dass eine Hand kurz verschwindet oder eine fälschlicherweise auftaucht, enthält die Klasse AbstractPoint die boolesche Variable isActive. Dadurch wird die Anzahl der zu erzeugenden Objekte erheblich reduziert. In Java sieht das folgendermaßen aus (Klasse Registry):
@Override
public void onFrame(Controller controller) {
Frame frame = controller.frame();
for(ExtendedHand extendedHand: hands.values()) {
extendedHand.setActive(false);
}
CirclePalm circlePalm;
for(Hand hand: Environment.getFrame().hands()) {
circlePalm = (CirclePalm) hands.get(hand.id());
if(circlePalm == null) {
hands.put(hand.id(), new CirclePalm(hand.id()));
}
circlePalm.update(frame);
}
}
Die Klasse Display hingegen muss nur noch die vorbereiteten CirclePalm-Instanzen aus der Registry abholen und die render()-Methode zum Zeichnen aufrufen. Slick2D sorgt dafür, dass die render()-Methode ununterbrochen aufgerufen wird.
@Override
public void init(GameContainer container) throws SlickException {
registry = new Registry();
}
@Override
public void render(GameContainer gc, Graphics g)
throws SlickException {
for(CirclePalm criclePalm: registry.getHands()) {
criclePalm.render(gc, g);
}
}
Auch die Klasse CirclePalm enthält kaum Logik. An dieser Stelle sollte man nicht vergessen die Position des Shapes mit der Position der Hand gleichzusetzen. Ansonsten würde sich nichts auf dem Bildschirm bewegen.
public class CirclePalm extends ExtendedHand {
Circle circle = new Circle(0, 0, 30);
public CirclePalm(int id) {
super(id);
setDiameter(new Vector(60, 60, 0));
}
@Override
public void update(Frame frame) {
super.update(frame);
circle.setX(position.getX());
circle.setY(position.getY());
}
public void render(GameContainer gc, Graphics g) {
g.setColor(Color.red);
g.fill(circle);
g.draw(circle);
}
}
Fazit
Obwohl noch einige Stellen sehr unsauber programmiert sind, halte ich diesen Ansatz für eine gute Lösung. Durch das Registry-Pattern wird die starke Kopplung an die Leap Motion API gelöst und die Hände können durch Vererbung leicht erweitert werden. Zudem sehe ich keine Schwierigkeiten weitere Elemente (Finger, Tools, etc.) aufzunehmen bzw. eine dritte Dimension hinzuzufügen.