P5 und ML5: Motion-Tracking in Videos

Über ML5 kann P5 auf Machine-Learning-Modelle zugeifen, die im Bereich der Mustererkennung Dinge ermöglichen, die vor einigen Jahren für einfache Scriptsprachen wie Javascript oder P5 noch undenkbar waren. Einer dieser Hauptbereiche ist die Erkennung von menschlichen Posen und Bewegungen, so dass nun via Javascript/P5 Bewegungen und Posen erfasst und in Zahlen umgesetzt werden können, die dann mit anderen Zahlenwerten (z.B. aus physiologischen Messungen, Timbre Feature Analysen, EEG, Eye-Tracking etc.) in Verbindung gebracht werden können.

Um auf diese Möglichkeiten zugreifen zu können, muss im Header der HTML-Seite neben der P5-Library auch die ML5-Library eingebunden werden, so dass es im Header der Seite folgendermaßen heisst:

<script src="header/ml5.min_0.12.2.js"></script>

Im Script wird nach der Deklarierung der für die Verbindung mit dem Posen-/Bewegungsmodell von poseNet und die Videodarstellung notwendigen Variablen ...

var poseNet; // Array für das von poseNet zurückgelieferte Ergebnis
var poses = []; // Array für die erfassten Posen = Personen
var video; // Variable für das zu untersuchende Video
var video_weite =1232; // Weite des zu untersuchenden Videos
var video_hoehe =720; // Höhe des zu untersuchenden Videos
var videoIsPlaying; // Zustand des Videos

... die setup-Funktion gestartet, in der sowohl der Canvas zur Darstellung des Videos und der Bewegungserkennung erstellt wird als auch die Verbindung mit dem Modell für die Posen/Bewegungserkennung hergestellt wird:

function setup() {
videoIsPlaying = false; // das Video stoppen
var container = createCanvas(1024, 768); // Canvas erstellen
container.parent('p5container'); // an DIV-Container anhängen
container.mouseClicked(togglePlay); // auf Klick auf Canvas reagieren
video = createVideo("bilder/Aufnahme4_ausschnitt_3D.mp4"); // Video einladen und darstellen.
video.size(video_weite, video_hoehe); // Höhe und Weite des Videos einstellen
poseNet = ml5.poseNet(video, {scoreThreshold: 0.2, multiplier:0.75}, modelReady); // Verbindung zu poseNet starten, um Pose/Bewegung zu erkennen und das Ergebnis
// in der Variablen poseNet zu speichern, mit einer Entdeckungsschwelle von 0,2 und einer Genauigkeit von 0.75 (s.u.).

poseNet.on('pose', function(results) { //sobald eine Pose entdeckt wird ...
poses = results; // fülle ein Array mit einer zahlenmäßigen Beschreibung dieser Pose
});
video.hide(); // verstecke das Video (es würde sonst neben dem Canvas zusätzlich zum
// verarbeiteten Video erscheinen)

}

In der ml5.poseNet()-Funktion können in den geschweiften Klammern {...} folgende Optionen für die Genauigkeit des Pose/Motion-Trackings eingestellt werden:

  • imageScaleFactor: = um welchen Faktor das Bild vergrößert oder verkleinert werden soll.
  • outputStride: = wie kleinschrittig die Ausgabe des Ergebnisses sein soll. Je kleiner der Wert, desto genauer die Abbildung, aber desto langsamer die Ausgabe (kann 8, 16 oder 32 sein).
  • flipHorizontal: = horizontale Spiegelung der Ausgabe (kann true oder false sein)
  • maxPoseDetections: = maximale Anzahl der entdeckten Personen/Posen (default bei 5)
  • scoreThreshold: = Schwelle, ab welcher Entdeckungswahrscheinlichkeit zwischen 0 und 1 ein Körperteil berücksichtigt werden soll (default bei 0,5).
  • nmsRadius: = bei mehreren Posen, wie groß der Radius sein soll, ab dem sich zwei Posen gegenseitig verdecken (default ist bei 20).
  • detectionType: = ob einzelne ('single') oder mehrere Personen erkannt werden sollen ('multiple')(für die Entdeckung von Einzelposen empfiehlt es sich das 'single' außerhalb der Optionen in die PoseNet-Funktion zu schreiben, also z.B.:
    poseNet = ml5.poseNet(video, {scoreThreshold: 0.2, multiplier:0.75}, 'single', modelReady);)
  • inputResolution: = Eingabe-Auflösung auf die das Bild reduziert wird, bevor es ins poseNet-Modell gespeist wird. Je größer die Auflösung, desto genauer die Erkennung, desto langsamer die Ausgabe (kann 161, 193, 257, 289, 321, 353, 385, 417, 449, 481, 513 und 801 sein, default ist bei 257).
  • multiplier: = Tiefe der Entdeckung, je höher die Zahl, desto genauer die Abbildung, desto langsamer die Ausgabegeschwindigkeit (kann 1.01, 1.0, 0.75 oder 0.5 sein).
  • quantBytes: = Quantisierung des Bilds, je höher die Auflösung desto genauer die Abbildung, desto langsamer die Ausgabegeschwindigkeit (kann 1 oder 4 sein).

In der draw-Funktion von P5 werden dann neben der Darstellung des Videos nur noch zwei weitere Funktionen aufgerufen: drawKeypoints(), um markante Punkte = Körperteile der erfassten Personen wiederzugeben, und drawSkeleton(), um diese Punkte über Linien zu einem Skelett bzw. einer Strichfigur zu verbinden:

function draw() {
background(255,255,255,125); // Hintergrund halb durchsichtig gestalten, damit mit der
// Maus zwischen Drahtgitterfigur und Video hin- und hergeblendet werden kann

cursor(HAND); // Mauszeiger in eine Hand umwandeln
image(video, 0, 0, video_weite, video_hoehe); // zeige das Video in seiner Höhe
// und Weite nach den oben definierten Variablen an

tint(255, 255, 255, map(mouseY, 0, video_hoehe, 0, 255), ); // Transparenz
// des Videos mit der Y-Position der Maus verknüpfen

drawKeypoints(); // rufe eine Funktion auf, in der das von PoseNet zurückgelieferte Array
// als Punktfigur ausgegeben wird
drawSkeleton(); // rufe eine Funktion auf, in der die markanten Punkte der Punktfigur zu
// einem Skelett/einer Strichfigur verbunden werden

}

In beiden Funktionen wird zuerst geschaut wie viele Posen d.h. Personen pro Frame erkannt wurden und pro Person wird dann geschaut, welche keypoints d.h. Körperteile dazu entdeckt wurden und wie sie sich zu einer Strichfigur verbinden lassen:

function drawKeypoints() { // Zeichne die markanten Punkte einer/mehrerer Personen/Posen
for (let i = 0; i < poses.length; i++) { //zähle das zurückgegebene Array durch,
// wie viele Posen = Personen pro Frame gefunden wurden

var pose = poses[i].pose; // übergebe der Reihe nach jede gefundene Pose = Person an die
// Variable pose (= wiederum ein Array mit einzelnen Keypoints = Körperteilen)

for (let j = 0; j < pose.keypoints.length; j++) { // zähle die zurückgegebenen
// Keypoints = Körperteile pro Person durch ...

let keypoint = pose.keypoints[j]; //... und weise sie der Variablen keypoint zu.
if (keypoint.score > 0.2) { //... wenn die Wahrscheinlichkeit für ein gefundenes
// Körperteil größer als 0,2 ist ...

noStroke(); fill(184, 0, 0); //... setze die Füllfarbe auf dunkelrot und ...
ellipse(keypoint.position.x, keypoint.position.y, 10, 10); // ... gebe den
// Punkt als kleinen Kreis aus ...

text(round(keypoint.position.x, 2), keypoint.position.x, keypoint.position.y);
//.. und mit ihm seine X- ...

text(round(keypoint.position.y, 2), keypoint.position.x, keypoint.position.y+10);
// ... und Y-Koordinaten.

}
}
}

function drawSkeleton() { // Zeichne Strichfiguren auf Grundlage der markanten Punkte
// einer/mehrerer Personen/Posen

for (var i = 0; i < poses.length; i++) { //zähle das zurückgegebene Array durch,
// wie viele Posen = Personen pro Frame gefunden wurden

var skeleton = poses[i].skeleton; // übergebe der Reihe nach jede gefundene Pose =
// Person an die Variable skeleton (= wiederum ein Array mit einzelnen Linien = Bestandteilen des
// Skeletts)

for (var j = 0; j < skeleton.length; j++) { // zähle die zurückgegebenen Linien =
// Skelettbestandteile pro Person durch ...

var partA = skeleton[j][0]; // weise den Anfangspunkt einer Skelettlinie mit zwei
// Koordinaten der Variablen partA zu ...

var partB = skeleton[j][1]; // und den Endpunkt der jeweiligen Skelettlinie mit zwei
// Koordinaten der Variablen partB.

stroke(184, 0, 0); // setze die Strichfarbe auf dunkelrot
line(partA.position.x, partA.position.y, partB.position.x, partB.position.y);
// zeichne die Linie aus den Koordinaten von partA und partB

}
}
}

Die Art der Körperteile wird in der Reihenfolge der keypoints codiert. So kann man über folgende Variablen auf die jeweiligen Körperteile zugreifen:

  • pose.keypoints[0] = Nase
  • pose.keypoints[1] = linkes Auge
  • pose.keypoints[2] = rechtes Auge
  • pose.keypoints[3] = linkes Ohr
  • pose.keypoints[4] = rechtes Ohr
  • pose.keypoints[5] = linke Schulter
  • pose.keypoints[6] = rechte Schulter
  • pose.keypoints[7] = linker Ellenbogen
  • pose.keypoints[8] = rechter Ellenbogen
  • pose.keypoints[9] = linkes Handgelenk
  • pose.keypoints[10] = rechtes Handgelenk
  • pose.keypoints[11] = linke Hüfte
  • pose.keypoints[12] = rechte Hüfte
  • pose.keypoints[13] = linkes Knie
  • pose.keypoints[14] = rechtes Knie
  • pose.keypoints[15] = linkes Fußgelenk
  • pose.keypoints[16] = rechtes Fußgelenk

Zu jedem Punkt wird neben seiner X- und Y-Koordinate auch ein score mitgegegeben, bei dem sich zwischen 0 und 1 erkennen lässt, wie hoch die Wahrscheinlichkeit ist, dass es sich tatsächlich um das entsprechende Körperteil handelt.

Insgesamt sieht das Script dann folgendermaßen aus:

 

<script src="header/ml5.min_0.12.2.js"></script>
<script src="header/p5.js"></script>

<script>

var poseNet;
var poses = [];
var video;
var video_weite =1232;
var video_hoehe =720;
var videoIsPlaying;

function setup() {
videoIsPlaying = false;
var container = createCanvas(1024, 768);
container.parent('p5container')
container.mouseClicked(togglePlay);
video = createVideo("bilder/Aufnahme4_ausschnitt_3D.mp4");
video.size(video_weite, video_hoehe);
poseNet = ml5.poseNet(video, {scoreThreshold: 0.2, multiplier:0.75}, modelReady);
poseNet.on('pose', function(results) {
poses = results;
});
video.hide();
}

function modelReady() {
console.log("Model ready!");
}

function draw() {
background(255,255,255,125);
cursor(HAND);
image(video, 0, 0, video_weite, video_hoehe);
tint(255, 255, 255, map(mouseY, 0, video_hoehe, 0, 255), );
drawKeypoints();
drawSkeleton();
}

function drawKeypoints() {
for (let i = 0; i < poses.length; i++) {
var pose = poses[i].pose;
for (let j = 0; j < pose.keypoints.length; j++) {
let keypoint = pose.keypoints[j];
if (keypoint.score > 0.2) {
noStroke(); fill(184, 0, 0);
ellipse(keypoint.position.x, keypoint.position.y, 10, 10);
text(round(keypoint.position.x, 2), keypoint.position.x, keypoint.position.y);
text(round(keypoint.position.y, 2), keypoint.position.x, keypoint.position.y+10);
}
}
//Bedeutung der Keypoints:
if (pose.keypoints[0].score > 0.2){fill(184, 0, 0);} else {fill(184, 184, 184);}
text('Nase: ', 10, 20);
text('X: '+ round(pose.keypoints[0].position.x), 120, 20);
text('Y: '+ round(pose.keypoints[0].position.y), 170, 20);
text('Score: '+ round(pose.keypoints[0].score, 2), 220, 20);
if (pose.keypoints[1].score > 0.2){fill(184, 0, 0);} else {fill(184, 184, 184);}
text('Auge links: ', 10, 30);
text('X: '+ round(pose.keypoints[1].position.x), 120, 30);
text('Y: '+ round(pose.keypoints[1].position.y), 170, 30);
text('Score: '+ round(pose.keypoints[1].score, 2), 220, 30);
if (pose.keypoints[2].score > 0.2){fill(184, 0, 0);} else {fill(184, 184, 184);}
text('Auge rechts: ', 10, 40);
text('X: '+ round(pose.keypoints[2].position.x), 120, 40);
text('Y: '+ round(pose.keypoints[2].position.y), 170, 40);
text('Score: '+ round(pose.keypoints[2].score, 2), 220, 40);
if (pose.keypoints[3].score > 0.2){fill(184, 0, 0);} else {fill(184, 184, 184);}
text('Ohr links: ', 10, 50);
text('X: '+ round(pose.keypoints[3].position.x), 120, 50);
text('Y: '+ round(pose.keypoints[3].position.y), 170, 50);
text('Score: '+ round(pose.keypoints[3].score, 2), 220, 50);
if (pose.keypoints[4].score > 0.2){fill(184, 0, 0);} else {fill(184, 184, 184);}
text('Ohr rechts: ', 10, 60);
text('X: '+ round(pose.keypoints[4].position.x), 120, 60);
text('Y: '+ round(pose.keypoints[4].position.y), 170, 60);
text('Score: '+ round(pose.keypoints[4].score, 2), 220, 60);
if (pose.keypoints[5].score > 0.2){fill(184, 0, 0);} else {fill(184, 184, 184);}
text('Schulter links: ', 10, 70);
text('X: '+ round(pose.keypoints[5].position.x), 120, 70);
text('Y: '+ round(pose.keypoints[5].position.y), 170, 70);
text('Score: '+ round(pose.keypoints[5].score, 2), 220, 70);
if (pose.keypoints[6].score > 0.2){fill(184, 0, 0);} else {fill(184, 184, 184);}
text('Schulter rechts: ', 10, 80);
text('X: '+ round(pose.keypoints[6].position.x), 120, 80);
text('Y: '+ round(pose.keypoints[6].position.y), 170, 80);
text('Score: '+ round(pose.keypoints[6].score, 2), 220, 80);
if (pose.keypoints[7].score > 0.2){fill(184, 0, 0);} else {fill(184, 184, 184);}
text('Ellenbogen links: ', 10, 90);
text('X: '+ round(pose.keypoints[7].position.x), 120, 90);
text('Y: '+ round(pose.keypoints[7].position.y), 170, 90);
text('Score: '+ round(pose.keypoints[7].score, 2), 220, 90);
if (pose.keypoints[8].score > 0.2){fill(184, 0, 0);} else {fill(184, 184, 184);}
text('Ellenbogen rechts: ', 10, 100);
text('X: '+ round(pose.keypoints[8].position.x), 120, 100);
text('Y: '+ round(pose.keypoints[8].position.y), 170, 100);
text('Score: '+ round(pose.keypoints[8].score, 2), 220, 100);
if (pose.keypoints[9].score > 0.2){fill(184, 0, 0);} else {fill(184, 184, 184);}
text('Handgelenk links: ', 10, 110);
text('X: '+ round(pose.keypoints[9].position.x), 120, 110);
text('Y: '+ round(pose.keypoints[9].position.y), 170, 110);
text('Score: '+ round(pose.keypoints[9].score, 2), 220, 110);
if (pose.keypoints[10].score > 0.2){fill(184, 0, 0);} else {fill(184, 184, 184);}
text('Handgelenk rechts: ', 10, 120);
text('X: '+ round(pose.keypoints[10].position.x), 120, 120);
text('Y: '+ round(pose.keypoints[10].position.y), 170, 120);
text('Score: '+ round(pose.keypoints[10].score, 2), 220, 120);
if (pose.keypoints[11].score > 0.2){fill(184, 0, 0);} else {fill(184, 184, 184);}
text('Huefte links: ', 10, 130);
text('X: '+ round(pose.keypoints[11].position.x), 120, 130);
text('Y: '+ round(pose.keypoints[11].position.y), 170, 130);
text('Score: '+ round(pose.keypoints[11].score, 2), 220, 130);
if (pose.keypoints[12].score > 0.2){fill(184, 0, 0);} else {fill(184, 184, 184);}
text('Huefte rechts: ', 10, 140);
text('X: '+ round(pose.keypoints[12].position.x), 120, 140);
text('Y: '+ round(pose.keypoints[12].position.y), 170, 140);
text('Score: '+ round(pose.keypoints[12].score, 2), 220, 140);
if (pose.keypoints[13].score > 0.2){fill(184, 0, 0);} else {fill(184, 184, 184);}
text('Knie links: ', 10, 150);
text('X: '+ round(pose.keypoints[13].position.x), 120, 150);
text('Y: '+ round(pose.keypoints[13].position.y), 170, 150);
text('Score: '+ round(pose.keypoints[13].score, 2), 220, 150);
if (pose.keypoints[14].score > 0.2){fill(184, 0, 0);} else {fill(184, 184, 184);}
text('Knie rechts: ', 10, 160);
text('X: '+ round(pose.keypoints[14].position.x), 120, 160);
text('Y: '+ round(pose.keypoints[14].position.y), 170, 160);
text('Score: '+ round(pose.keypoints[14].score, 2), 220, 160);
if (pose.keypoints[15].score > 0.2){fill(184, 0, 0);} else {fill(184, 184, 184);}
text('Fussgelenk links: ', 10, 170);
text('X: '+ round(pose.keypoints[15].position.x), 120, 170);
text('Y: '+ round(pose.keypoints[15].position.y), 170, 170);
text('Score: '+ round(pose.keypoints[15].score, 2), 220, 170);
if (pose.keypoints[16].score > 0.2){fill(184, 0, 0);} else {fill(184, 184, 184);}
text('Fussgelenk rechts: ', 10, 180);
text('X: '+ round(pose.keypoints[16].position.x), 120, 180);
text('Y: '+ round(pose.keypoints[16].position.y), 170, 180);
text('Score: '+ round(pose.keypoints[16].score, 2), 220, 180);
}
text('Anzahl der gefundenen Personen: '+ poses.length, 10, 200);
}

function drawSkeleton() {
for (var i = 0; i < poses.length; i++) {
var skeleton = poses[i].skeleton;
for (var j = 0; j < skeleton.length; j++) {
var partA = skeleton[j][0];
var partB = skeleton[j][1];
stroke(184, 0, 0);
line(partA.position.x, partA.position.y, partB.position.x, partB.position.y);
}
}
}

function togglePlay(){
if (videoIsPlaying==true) {
video.pause(); videoIsPlaying = false;
} else {
video.loop(); videoIsPlaying = true;
}
}

</script>

<DIV id="p5container" style="width:1024;border: 1px solid #333;box-shadow: 8px 8px 5px #444;padding: 8px 12px;background-color:ffffff"></DIV>