オリンピックでのピクトグラムをきっかけに、たくさんの方がピクトグラムのモーションキャプチャを作られました。
私も、モチベーションを上げるには今しかないと思い、Googleの機械学習であるMediaPipeを使ってピクトグラムを作ってみました。多くの方が、ピクトグラムのモーションキャプチャにMediaPipeを使っていらっしゃいますし、私も過去に使ったことがあったので、さくっと作ってみようと思いました。
MediaPipe
が、意外にベクトル計算にてまどったので、まとめておきます。
久しぶりに、高校生になった気分でした。
ソースコードもろもろは以下に置いてあります。
poruruba/Pictogram
使ってみたい方は、以下をブラウザから開いてみてください。PCにWebCamがつながっている必要があります。スマホだと縦横比を調整してないです。
すでにたくさんの方がピクトグラムに挑戦されているため、あまり真新しさはありませんが、ベクトル計算の部分がためになったので、投稿しておきました。
こちらがピクトグラムしているときの画面。(ちょっと近すぎ)
こちらがスナップショット撮影したときの画像
(2021/8/1 修正)
何を勘違いしたのか、投稿内容の計算式が間違っていたので修正しました。
モーションキャプチャのしくみ
モーションキャプチャには、MediaPipeのPoseソリューションを使います。
MediaPipeを使うことで、ブラウザに接続されたWebCamを使って撮影された映像から、以下にある体の一部を検出してくれます。
一番難しい作業のはずが、今回の中では一番簡単な作業でした。。。
ピクトグラムの構成要素
ピクトグラムは、HTMLのCanvasに描画します。
ピクトグラムは、ヒト型にすると以下のようになります。
これを、部品に分けると以下の2つになります。
頭の部分は単に円なので、簡単です。後者は、腕や足の関節も表現できるようになっていて、少し複雑です。
今度は、これを少し分解します。
丸と台形を2つ合わせたようなものに分かれました。これまた後者が少々複雑です。
後者は、2つの端点の円の半径が異なるため、台形のような形になっています。
各頂点をP1からP6とし、P1とP2をMediaPipeで検出した点(Landmark)に割り当てればよいです。なので、P1とP2からそれ以外の場所(X座標、Y座標)がわかれば、塗り潰しできるのですが。。。。
図形を見てみると、P1とP2を境にした2つの台形をつなげたもののようです。なぜ、P3、P5、P4、P6による台形にならないのかは、以下のように表示するとわかります。
わかりやすいように、P1を原点にし、P2がX座標と交わるように場所と角度を変えます。
それでは、この時の、P3の座標を求めてみましょう。
以降は、角度を求める必要がでてきますが、角度を求める代わりに、cosθ、sinθを求めることで、直接角度(ラジアン)を導出する必要がないようにしています。
以降では、座標を以下のように表現します。
P3 = (P3.x, P3.y) または、P3.x、P3.y
P3の座標は、以下のように表現されます。
P3.x = r1 * cosα
P3.y = r1 * sinα
r1とr2は、各端点の円の半径です。r1>r2の前提です。
r1が既知なので、αの角度がわかればよいですね。
先にいっておくと、P1とP2の間の距離が非常に大事です。Dとします
D = |P2-P1| = sqrt((P2.x-P1.x)2乗 + (P2.y-P1.y)2乗)
実は、P2を頂点とした三角形も同じ形なので、こっちを使った方がよさそうです。
そうすると、
cosα = (r1 – r2) / D
1 = sinα 2乗 + cosα 2乗
sinα = sqrt(1 – cosα 2乗)
となります。三角関数の公式を使っています(懐かしい。。。)。よって、以下のようになります。
P3.x = r1 * cosα = r1 * (r1 – r2) / D
P3.y = r1 * sinα = r1 * sqrt(1 – cosα 2乗) = r1 * sqrt(1 – ((r1 – r2) / D)2乗)
次に、P3がわかったので、P4、P5、P6を求めます。ついでに、自明ですが、P1、P2も書いておきます。
P4.x = D + r2/r1 * P3.x
P4.y = r2/r1 * P3.y
P5.x = P3.x
P5.y = -P3.y
P6.x = P4.x
P6.y = -P4.y
P1.x = 0
P1.y = 0
P2.x = D
P2.y = 0
できました!
見てわかります通り、P1とP2の間の距離Dさえわかれば、それ以外の座標が決まることがわかります。
それでは、実際にモーションキャプチャでキャプチャされたp1とp2から求めたいのですが、P1が原点に、P2がX軸上に来るように座標をずらしていました。
ですので、先ほど求めたPnの座標を、実際にキャプチャされたp1とp2に合わせて回転と移動する必要があります。
P1からみたP2の角度に合わせて、さきほどのPnの図形を回転させたのち、原点としていたP1を実際のキャプチャされたp1に移動します。
高校時代に、こんな感じの回転行列を使っていましたね。。。。
R(θ).x = x * cosθ – y * sinθ
R(θ).y = x * sinθ + y * cosθ
回転の計算に必要なcosθ、sinθは以下で表されます。
cosθ = (p2.x - p1.x) / D
sinθ = (p2.y - p1.y) / D
一方の移動量Eは以下で表されます。
E.x = p1.x
E.y = p1.y
あとはこれを、回転→移動の順に適用すればよいです。
pn = R(θ)Pn + E
結局のところ展開すると以下になります。
pn.x = Pn.x * cosθ- Pn.y * sinθ + E.x
pn.y = Pn.x * sinθ+ Pn.y * cosθ + E.y
PnというのがさきほどP1を原点としたときの座標で、pnが実際にキャプチャで検出された座標です。
それを実装に起こしたのが以下です。
function calcRange( x1, y1, x2, y2, r1, r2 ){
var D = Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
var cosF = (x2 - x1) / D;
var sinF = (y2 - y1) / D;
var p3x = r1 * (r1 - r2) / D;
var p3y = r1 * Math.sqrt(1 - Math.pow((r1 - r2) / D, 2));
var q3 = rotation(p3x, p3y, cosF, sinF);
var q3x = q3.x + x1;
var q3y = q3.y + y1;
var p2x = D;
var p2y = 0;
var q2 = rotation( p2x, p2y, cosF, sinF );
var q2x = q2.x + x1;
var q2y = q2.y + y1;
var p4x = D + r2 / r1 * p3x;
var p4y = r2 / r1 * p3y;
var q4 = rotation(p4x, p4y, cosF, sinF );
var q4x = q4.x + x1;
var q4y = q4.y + y1;
var p5x = p3x;
var p5y = -p3y;
var q5 = rotation(p5x, p5y, cosF, sinF);
var q5x = q5.x + x1;
var q5y = q5.y + y1;
var p6x = p4x;
var p6y = -p4y;
var q6 = rotation(p6x, p6y, cosF, sinF);
var q6x = q6.x + x1;
var q6y = q6.y + y1;
var q1x = x1;
var q1y = y1;
return { q1x, q1y, q2x, q2y, q3x, q3y, q4x, q4y, q5x, q5y, q6x, q6y };
}
function rotation(x, y, cosF, sinF) {
return { x: x * cosF - y * sinF, y: x * sinF + y * cosF };
}
Canvasへの描画
あとは、モーションキャプチャ完了の都度、以下の関数が呼ばれます。
function onResults(results)
{
// videoCtx.drawImage(results.image, 0, 0, canvasElement.width, canvasElement.height);
if (!results.poseLandmarks || results.poseLandmarks.length < 31 )
return;
// 全体を背景色で塗りつぶし
canvasCtx.fillStyle = PICTO_BACKGROUND;
canvasCtx.fillRect(0, 0, canvasElement.width, canvasElement.height);
canvasCtx.fillStyle = PICTO_FOREGROUND;
// 頭部分の円を描画
// var head = dimension([results.poseLandmarks[0], results.poseLandmarks[4], results.poseLandmarks[1]]) * 100;
var head = dimension([results.poseLandmarks[11], results.poseLandmarks[12], results.poseLandmarks[23], results.poseLandmarks[24]]) * 10;
drawCircle(median([results.poseLandmarks[0], results.poseLandmarks[4], results.poseLandmarks[1]]), head * 35);
// 腕の部分を描画
drawParts(results.poseLandmarks[11], results.poseLandmarks[13], head * 23, head * 20);
drawParts(results.poseLandmarks[13], results.poseLandmarks[15], head * 20, head * 15);
drawParts(results.poseLandmarks[12], results.poseLandmarks[14], head * 23, head * 20);
drawParts(results.poseLandmarks[14], results.poseLandmarks[16], head * 20, head * 15);
// 足の部分を描画
var waist = median([results.poseLandmarks[23], results.poseLandmarks[24]]);
drawParts(waist, results.poseLandmarks[26], head * 23, head * 20);
drawParts(results.poseLandmarks[26], median([results.poseLandmarks[28], results.poseLandmarks[30], results.poseLandmarks[32]]), head * 20, head * 15);
drawParts(waist, results.poseLandmarks[25], head * 23, head * 20);
drawParts(results.poseLandmarks[25], median([results.poseLandmarks[27], results.poseLandmarks[29], results.poseLandmarks[31]]), head * 20, head * 15);
}
変な台形の描画は以下の部分です。
function drawParts(pos1, pos2, r1, r2){
var x1 = pos1.x * canvasElement.width;
var y1 = pos1.y * canvasElement.height;
var x2 = pos2.x * canvasElement.width;
var y2 = pos2.y * canvasElement.height;
var p = calcRange(x1, y1, x2, y2, r1, r2);
canvasCtx.beginPath();
canvasCtx.moveTo(p.q1x, p.q1y);
canvasCtx.lineTo(p.q3x, p.q3y);
canvasCtx.lineTo(p.q4x, p.q4y);
canvasCtx.lineTo(p.q2x, p.q2y);
canvasCtx.lineTo(p.q6x, p.q6y);
canvasCtx.lineTo(p.q5x, p.q5y);
canvasCtx.fill();
canvasCtx.beginPath();
canvasCtx.arc(p.q1x, p.q1y, r1, 0, 2 * Math.PI);
canvasCtx.fill();
canvasCtx.beginPath();
canvasCtx.arc(p.q2x, p.q2y, r2, 0, 2 * Math.PI);
canvasCtx.fill();
}
PoseのLandmarkの単点のうち、0, 1, 4, 11, 12, 13, 14, 15, 16, 23, 24, 25, 26, 27, 28 を使っています。
全体的な縮尺は、顔のパーツの間隔からだったり、胴体の大きさからだったり、なんでもよいです。
終わりに
専門家でもないので、もっとよい計算の仕方があるとは思いますが、まあ、頑張って自分なりのやり方で求めたので、記念に残しておきます。
あと、おまけで、Canvas部分をマウスクリックすれば、スナップショット撮影できるようにしたり、モーション状態を動画で録画できるようにしておきました。
以上