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

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

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

MediaPipe

https://google.github.io/mediapipe/

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

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

poruruba/Pictogram

https://github.com/poruruba/Pictogram

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

https://poruruba.github.io/Pictogram/

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/09ac89bb-03fe-ec3c-0bd6-cdcc1a268c5b.png)

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

![snapshot (1).jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/c76758f4-adba-3d03-d257-4e9592d364df.jpeg)

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

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

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

https://google.github.io/mediapipe/solutions/pose.html

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

https://google.github.io/mediapipe/images/mobile/pose_tracking_full_body_landmarks.png

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


#ピクトグラムの構成要素

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/73788caf-5875-6180-a8c5-0b3fcf757a86.png)

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/0c0b864d-cbfb-0dcb-2791-2c521ba7044f.png)

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/61660a48-3bfa-44e9-383e-48e5e42b5e2d.png)

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/305656d1-8bc8-abe8-3ccb-6dd764462cb9.png)

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

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/8dbf754a-bc19-afe4-f87c-1b8cc685750a.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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/a5be90f0-cd5d-f6c2-3d39-61f99c1c28e1.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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/0918fc2b-f9dd-5fab-79db-15d7cc3944f1.png)

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

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/2cc79ce8-eb6f-509e-16b3-22c11fc9c9a5.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が実際にキャプチャで検出された座標です。
それを実装に起こしたのが以下です。

```js
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への描画

あとは、モーションキャプチャ完了の都度、以下の関数が呼ばれます。

```js
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);
}
```

変な台形の描画は以下の部分です。

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

以上