【JavaScript】clmtrackrで顔認識して遊ぶ

真夏のピークも過ぎて、すっかり過ごしやすい季節になってきましたね！
というわけで、ここ最近jsで顔認識して遊んでみたのでその過程を記事にしてみたいと思います。(ここに至るまでに色々と詰まったところもあったので、もしかしたらもしかするとこの知見が誰かの役に立つことを願いつつ)

## 何を作ったか

[shimabox/F3: Face(eyes)! Face(nose)! Face(mouth)!](https://github.com/shimabox/F3 "shimabox/F3: Face(eyes)! Face(nose)! Face(mouth)!") です。

これは

- jsを使って顔認識をします
  - [auduno/clmtrackr](https://github.com/auduno/clmtrackr "auduno/clmtrackr: Javascript library for precise tracking of facial features via Constrained Local Models") を使っています
- 目、鼻、口をそれぞれ描画します
  - 顔だけを描画することもできます
- 停止中に顔部品をドラッグすることができます
- デバッグモードで座標の描画と顔部品を実際の座標に描画し直します

という、ぶっちゃけ誰得？なものになっていますが、jsだけでここまでできるぞっていうことが伝われば幸いです。
※ clmtrackrを使ったサンプルはけっこう出ていますけども、、
※ 自分は生粋のペチパーなので間違っているところがあれば突っ込みお願いします

## Demo

![demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9615/052f1822-db54-8cb2-6cc3-b8b715eacbba.gif)
https://shimabox.github.io/F3/
![qr.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9615/d6e3a88e-0b69-1a3d-d6b9-7de3126bec46.png)


## 実装の流れ

作った過程を一つずつ書いていきます。
(コミットログは違いますが、こんなイメージだと捉えていただければと。。)

### 1. videoをcanvasに描画

まず、videoタグを使ってwebカメラから取得した画像をcanvasに描画します。
ここは稚拙ではありますが、[shimabox/v2c: Video(webcam) to canvas.](https://github.com/shimabox/v2c "shimabox/v2c: Video(webcam) to canvas.") を利用しました。

**こんなイメージ**
![img1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9615/8c96425c-ca08-8c6f-0970-abe8540f6c85.png)

### 2. canvasをclmtrackrに食わせる

canvasに描画された画像をclmtrackrに食わせて顔を認識させて顔座標情報を取得します。

**こんなイメージ**
![img2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9615/cd8e7700-cdb4-c53e-5f6c-70fea8b5d503.png)

### 3. レイヤーを被せて、その上に顔部品を描画する

レイヤー(空のdiv)を用意し、顔を描画しているcanvasに被せていったん隠します。
`2.`で取得した顔座標を使って顔部品を作成しcanvasに描画します。

**こんなイメージ**
![img3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9615/56214baf-a01c-0548-1473-6b8698e1b96d.png)

[Reference](https://www.auduno.com/clmtrackr/docs/reference.html "Reference") にある顔座標から、左目(眉)、右目(眉)、鼻、口、顔のみを抽出してそれぞれ描画しています。
※ 後述しますが、フロントカメラで描画するときは`transform: scaleX(-1);`を意識する必要があります

### 4. カメラスイッチ、デバッグモード、顔部品をコントロールするUIなどを設置する

顔部品をコントロールするUIは[dataarts/dat.gui](https://github.com/dataarts/dat.gui "dataarts/dat.gui: dat.gui is a lightweight controller library for JavaScript.") を利用しました。

**最終的にこうなります**
![img4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9615/3d9ba586-225d-f730-9aa1-8d5479fbaa94.png)

実装の流れはこんな感じです。
余談ですが、顔認識して座標をそれぞれ返すようなライブラリであれば`clmtrackr`でなくても大丈夫だと思います。

## 苦労したところ

実装の流れの中で特に苦労した点を書いていきます。

### フロントカメラで描画するときは`transform: scaleX(-1);`を意識する

#### どういうことなんだぜ？

フロントカメラを利用する場合、videoを描画するcanvasには`transform: scaleX(-1);`をつけるのがセオリーっぽいです。そうしないと普段フロントカメラで見ているような鏡のような描画で映らないです。で、`transform: scaleX(-1);`をつけていると、そこから返却される`x座標`が反転された位置？で返ってくるので上手く計算して描画しないと意図しない描画となってしまいます。

今回で言うと以下の計算式で、顔部品のx座標を求めました。
`videoを描画しているcanvasの横幅 - (clmtrackrから返却される顔部品のx座標 - 顔部品の横幅) + 顔部品の余白`
これでフロントカメラ利用時に、ピッタリ描画されるようになりました。

これプラス、フロントカメラを利用している場合はclmtrackrから返却される座標を入れ替える処理も入れています。このあたりは、[clmtrackr.jsで顔認識してへのへのもへじを描画する | Shimabox Blog](https://blog.shimabox.net/2019/03/18/face_recognition_with_clmtrakr-js_and_draw_henohenomoheji/#toc_id_2 "clmtrackr.jsで顔認識してへのへのもへじを描画する | Shimabox Blog")でも記載しています。

なお、リア(背面)カメラ利用時は以下の計算式になります。
`clmtrackrから返却される顔部品のx座標 - 顔部品の余白`

### スマホでドラッグするとエラー

停止中に顔部品をドラッグする機能を [JavaScriptを使って要素をドラッグ＆ドロップで移動 | q-Az](https://q-az.net/elements-drag-and-drop/ "JavaScriptを使って要素をドラッグ＆ドロップで移動 | q-Az") を参考にして~~謎に~~つけたのですが、コンソールでエラーを吐いてしまいました。

#### こんなエラー

```
[Intervention] Unable to preventDefault inside passive event listener due to target being treated as passive.
```

これはググってみるとどうやら、`スクロールジャンク`というやつみたいです。
`touchmove`内で`preventDefault`を呼び出す(デフォルトのイベントを止めたい)場合は、AddEventListenerOptions(addEventListener:第3引数)の`passiveの値をfalse`にしないといけないみたい。いやぁ、全然知らんかったわぁ。

というわけで、[2019年、JavaScriptでのスクロール一時禁止はこれだ！（スマートフォン） - Qiita](https://qiita.com/yukiTTT/items/773356c2483b96c9d4e0 "2019年、JavaScriptでのスクロール一時禁止はこれだ！（スマートフォン） - Qiita")の記事を参考に解決させていただきました。

また、素直に`touchmove`のハンドラー内で`e.preventDefault();`だけを記述していたところ

```
Ignored attempt to cancel a touchmove event with cancelable=false, for example because scrolling is in progress and cannot be interrupted.
```
が発生していたので、[javascript - Touch move getting stuck Ignored attempt to cancel a touchmove - Stack Overflow](https://stackoverflow.com/questions/26478267/touch-move-getting-stuck-ignored-attempt-to-cancel-a-touchmove "javascript - Touch move getting stuck Ignored attempt to cancel a touchmove - Stack Overflow") を参考に

```js
if (e.cancelable) {
    e.preventDefault();
}
```
の記述をしています。

### iOSのSafariでまったく動かなかった

class構文使えるやんけ！ほんなら使ってみよう！と思い当初意気揚々とこういった形で実装していたのですが、

```js
class FaceTracker {
    _v2c;
    _ctracker;
    _stage;

    constructor(v2c, ctracker, stage) {
         this._v2c = v2c;
         this._ctracker = ctracker;
         this._stage = stage;
    }

    // 〜 略 〜

}
```

こんなエラーが出てiOSのSafariでまったく動きませんでした。

```
Unexpected token '='. Expected an opening '(' before a method's parameter list
```

これはパブリッククラスフィールドをサポートしていないのが原因でしたので、コンストラクタ内ですべて初期化する形にしています。トランスパイラを使えばいい感じにしてくれたかもしれませんが、~~そんな知識が自分にはない~~サクッと作りたかったので生のjsで書きました。

- [javascript - SAFARI : Unexpected token '='. Expected an opening '(' before a method's parameter list - Stack Overflow](https://stackoverflow.com/questions/60026651/safari-unexpected-token-expected-an-opening-before-a-methods-paramet "javascript - SAFARI : Unexpected token '='. Expected an opening '(' before a method's parameter list - Stack Overflow")
- [Classes#Field_declarations - JavaScript | MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes#Field_declarations "Classes - JavaScript | MDN")

なお、[iOS14でサポートされる](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes#Browser_compatibility "Classes - JavaScript | MDN")ようなので今なら上手く動くかもしれません。
※ 実装中はFireFoxでも動かなかった気がしたのですが、今ならパブリッククラスフィールドを使っても動いているようです

### ドラッグ後の座標反映に困った

顔部品にそれぞれ、`_distance(距離)`と`_degree(度)`を持たせて[JavaScriptの三角関数とcanvasで円運動アニメーションを作る](https://sbfl.net/blog/2017/02/17/javascript-animation-with-trig-functions/ "JavaScriptの三角関数とcanvasで円運動アニメーションを作る")を参考にグリグリと移動するようにしていたのですが移動の停止中にドラッグ可能としたことでドラッグ後の距離と度を反映させる必要がありました。そうしないと再び移動を開始した際にドラッグ前の座標に戻ってしまいます。

そこで、

- [JavaScriptで2点の座標の距離を求める方法](https://lab.syncer.jp/Web/JavaScript/Snippet/34/ "JavaScriptで2点の座標の距離を求める方法")
- [JavaScriptで2点の座標の角度を求める方法](https://lab.syncer.jp/Web/JavaScript/Snippet/37/ "JavaScriptで2点の座標の角度を求める方法")

の記事を参考に、停止した時点の座標(`x1:this._lastLeftPosition, y1:this._lastTopPosition`)からドラッグ後の座標(`x2:dragEndX, y2:dragEndY`)を使って_distanceと_degreeを再度反映させるようにしドラッグ後の解決をさせていただきました。

```js
// 停止中のパーツ座標(this._lastLeftPosition, this._lastTopPosition)と
// ドラッグ後の座標(dragEndX, dragEndY) から距離と角度を計算して反映
this._distance = Math.sqrt(Math.pow(dragEndX - this._lastLeftPosition, 2) + Math.pow(dragEndY - this._lastTopPosition, 2));
this._degree = Math.atan2(dragEndY - this._lastTopPosition, dragEndX - this._lastLeftPosition);
```
※ ソースから抜粋

### dat.guiでラジオボタンを表現する

顔部品のコントロールに[dataarts/dat.gui](https://github.com/dataarts/dat.gui "dataarts/dat.gui: dat.gui is a lightweight controller library for JavaScript.")を利用しているのですが、目線の種類を選ぶ部分でラジオボタンにする必要がありましたが、どうにもラジオボタン的なものはない様子でしたので以下を参考に実装しました。

[javascript - Radio buttons with dat.gui - Stack Overflow](https://stackoverflow.com/questions/44630700/radio-buttons-with-dat-gui "javascript - Radio buttons with dat.gui - Stack Overflow")

```js
_setUpEyeLineController() {
    const eyeLine = this._gui.addFolder('Eye Line');
    eyeLine.open();

    const none = eyeLine.add(this._guiParameter, 'none')
        .listen()
        .onChange(() => setEyeLine('none'));
    const mosaic = eyeLine.add(this._guiParameter, 'mosaic')
        .listen()
        .onChange(() => setEyeLine('mosaic'));
    const line = eyeLine.add(this._guiParameter, 'line')
        .listen()
        .onChange(() => setEyeLine('line'));

    const setEyeLine = (prop) => {
        const eyeLineParameter = [
            'none',
            'mosaic',
            'line'
        ]
        for (const param of eyeLineParameter){
            this._guiParameter[param] = false;
        }
        this._guiParameter[prop] = true;

        if (prop !== 'none') {
            this._faceTracker.addEyeLine(prop);
            return;
        }
        this._faceTracker.addEyeLine('');
    }
}
```
※ ソースから抜粋

## おわりに

ふと気づいたら無駄に長くなってしまいました。
冒頭で述べたとおり、誰得な内容なのですが誰かのお役に立てれば幸いです。

ちなみになのですが、自分はこういった顔認識して遊ぶ系がなぜか好きで [顔認識 | Shimabox Blog](https://blog.shimabox.net/category/face-recognition/ "顔認識 | Shimabox Blog") に今まで作ったやつを書いていたりするので、こちらもお暇があれば覗いてくれると嬉しいです。