真夏のピークも過ぎて、すっかり過ごしやすい季節になってきましたね!
というわけで、ここ最近jsで顔認識して遊んでみたのでその過程を記事にしてみたいと思います。(ここに至るまでに色々と詰まったところもあったので、もしかしたらもしかするとこの知見が誰かの役に立つことを願いつつ)
何を作ったか
shimabox/F3: Face(eyes)! Face(nose)! Face(mouth)! です。
これは
- jsを使って顔認識をします
- auduno/clmtrackr を使っています
- 目、鼻、口をそれぞれ描画します
- 顔だけを描画することもできます
- 停止中に顔部品をドラッグすることができます
- デバッグモードで座標の描画と顔部品を実際の座標に描画し直します
という、ぶっちゃけ誰得?なものになっていますが、jsだけでここまでできるぞっていうことが伝われば幸いです。
※ clmtrackrを使ったサンプルはけっこう出ていますけども、、
※ 自分は生粋のペチパーなので間違っているところがあれば突っ込みお願いします
Demo
https://shimabox.github.io/F3/
実装の流れ
作った過程を一つずつ書いていきます。
(コミットログは違いますが、こんなイメージだと捉えていただければと。。)
1. videoをcanvasに描画
まず、videoタグを使ってwebカメラから取得した画像をcanvasに描画します。
ここは稚拙ではありますが、shimabox/v2c: Video(webcam) to canvas. を利用しました。
2. canvasをclmtrackrに食わせる
canvasに描画された画像をclmtrackrに食わせて顔を認識させて顔座標情報を取得します。
3. レイヤーを被せて、その上に顔部品を描画する
レイヤー(空のdiv)を用意し、顔を描画しているcanvasに被せていったん隠します。
2.
で取得した顔座標を使って顔部品を作成しcanvasに描画します。
Reference にある顔座標から、左目(眉)、右目(眉)、鼻、口、顔のみを抽出してそれぞれ描画しています。
※ 後述しますが、フロントカメラで描画するときはtransform: scaleX(-1);
を意識する必要があります
4. カメラスイッチ、デバッグモード、顔部品をコントロールするUIなどを設置する
顔部品をコントロールするUIはdataarts/dat.gui を利用しました。
実装の流れはこんな感じです。
余談ですが、顔認識して座標をそれぞれ返すようなライブラリであればclmtrackr
でなくても大丈夫だと思います。
苦労したところ
実装の流れの中で特に苦労した点を書いていきます。
フロントカメラで描画するときはtransform: scaleX(-1);
を意識する
どういうことなんだぜ?
フロントカメラを利用する場合、videoを描画するcanvasにはtransform: scaleX(-1);
をつけるのがセオリーっぽいです。そうしないと普段フロントカメラで見ているような鏡のような描画で映らないです。で、transform: scaleX(-1);
をつけていると、そこから返却されるx座標
が反転された位置?で返ってくるので上手く計算して描画しないと意図しない描画となってしまいます。
今回で言うと以下の計算式で、顔部品のx座標を求めました。
videoを描画しているcanvasの横幅 - (clmtrackrから返却される顔部品のx座標 - 顔部品の横幅) + 顔部品の余白
これでフロントカメラ利用時に、ピッタリ描画されるようになりました。
これプラス、フロントカメラを利用している場合はclmtrackrから返却される座標を入れ替える処理も入れています。このあたりは、clmtrackr.jsで顔認識してへのへのもへじを描画する | Shimabox Blogでも記載しています。
なお、リア(背面)カメラ利用時は以下の計算式になります。
clmtrackrから返却される顔部品のx座標 - 顔部品の余白
スマホでドラッグするとエラー
停止中に顔部品をドラッグする機能を 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の記事を参考に解決させていただきました。
また、素直に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 を参考に
if (e.cancelable) {
e.preventDefault();
}
の記述をしています。
iOSのSafariでまったく動かなかった
class構文使えるやんけ!ほんなら使ってみよう!と思い当初意気揚々とこういった形で実装していたのですが、
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
- Classes#Field_declarations - JavaScript | MDN
なお、iOS14でサポートされるようなので今なら上手く動くかもしれません。
※ 実装中はFireFoxでも動かなかった気がしたのですが、今ならパブリッククラスフィールドを使っても動いているようです
ドラッグ後の座標反映に困った
顔部品にそれぞれ、_distance(距離)
と_degree(度)
を持たせてJavaScriptの三角関数とcanvasで円運動アニメーションを作るを参考にグリグリと移動するようにしていたのですが移動の停止中にドラッグ可能としたことでドラッグ後の距離と度を反映させる必要がありました。そうしないと再び移動を開始した際にドラッグ前の座標に戻ってしまいます。
そこで、
の記事を参考に、停止した時点の座標(x1:this._lastLeftPosition, y1:this._lastTopPosition
)からドラッグ後の座標(x2:dragEndX, y2:dragEndY
)を使って_distanceと_degreeを再度反映させるようにしドラッグ後の解決をさせていただきました。
// 停止中のパーツ座標(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を利用しているのですが、目線の種類を選ぶ部分でラジオボタンにする必要がありましたが、どうにもラジオボタン的なものはない様子でしたので以下を参考に実装しました。
javascript - Radio buttons with dat.gui - Stack Overflow
_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 に今まで作ったやつを書いていたりするので、こちらもお暇があれば覗いてくれると嬉しいです。