LoginSignup
2
0

More than 1 year has passed since last update.

高校生になった気分でピクトグラム作ってみた

Last updated at Posted at 2021-08-01

オリンピックでのピクトグラムをきっかけに、たくさんの方がピクトグラムのモーションキャプチャを作られました。

私も、モチベーションを上げるには今しかないと思い、Googleの機械学習であるMediaPipeを使ってピクトグラムを作ってみました。多くの方が、ピクトグラムのモーションキャプチャにMediaPipeを使っていらっしゃいますし、私も過去に使ったことがあったので、さくっと作ってみようと思いました。

MediaPipe

が、意外にベクトル計算にてまどったので、まとめておきます。
久しぶりに、高校生になった気分でした。

ソースコードもろもろは以下に置いてあります。

poruruba/Pictogram

使ってみたい方は、以下をブラウザから開いてみてください。PCにWebCamがつながっている必要があります。スマホだと縦横比を調整してないです。

すでにたくさんの方がピクトグラムに挑戦されているため、あまり真新しさはありませんが、ベクトル計算の部分がためになったので、投稿しておきました。
こちらがピクトグラムしているときの画面。(ちょっと近すぎ)

image.png

こちらがスナップショット撮影したときの画像

snapshot (1).jpg

(2021/8/1 修正)
何を勘違いしたのか、投稿内容の計算式が間違っていたので修正しました。

モーションキャプチャのしくみ

モーションキャプチャには、MediaPipeのPoseソリューションを使います。

MediaPipeを使うことで、ブラウザに接続されたWebCamを使って撮影された映像から、以下にある体の一部を検出してくれます。

一番難しい作業のはずが、今回の中では一番簡単な作業でした。。。

ピクトグラムの構成要素

ピクトグラムは、HTMLのCanvasに描画します。
ピクトグラムは、ヒト型にすると以下のようになります。

image.png

これを、部品に分けると以下の2つになります。

image.png

頭の部分は単に円なので、簡単です。後者は、腕や足の関節も表現できるようになっていて、少し複雑です。
今度は、これを少し分解します。

image.png

丸と台形を2つ合わせたようなものに分かれました。これまた後者が少々複雑です。
後者は、2つの端点の円の半径が異なるため、台形のような形になっています。

image.png

各頂点をP1からP6とし、P1とP2をMediaPipeで検出した点(Landmark)に割り当てればよいです。なので、P1とP2からそれ以外の場所(X座標、Y座標)がわかれば、塗り潰しできるのですが。。。。

図形を見てみると、P1とP2を境にした2つの台形をつなげたもののようです。なぜ、P3、P5、P4、P6による台形にならないのかは、以下のように表示するとわかります。
わかりやすいように、P1を原点にし、P2がX座標と交わるように場所と角度を変えます。

image.png

それでは、この時の、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も書いておきます。

image.png

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に合わせて回転と移動する必要があります。

image.png

P1からみたP2の角度に合わせて、さきほどのPnの図形を回転させたのち、原点としていたP1を実際のキャプチャされたp1に移動します。

高校時代に、こんな感じの回転行列を使っていましたね。。。。

image.png

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部分をマウスクリックすれば、スナップショット撮影できるようにしたり、モーション状態を動画で録画できるようにしておきました。

以上

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0