アンキロカップ

顔テトリス

https://github.com/ulxsth/square-face-tetris

Go

顔で操作するテトリス

yotu

fujikawa

エイ

p3qlasflro

推しアイデア

テトリスの「四角」と、カメラの「視覚」がかかっている←すごい!

作った背景

顔でテトリスがしたかったから

推し技術

技術スタックをコンパクトにまとめ、WebAssembly で Go をビルドすることで静的なページとして公開できるところ

プロジェクト詳細

あそびかた

  1. 顔を左右に振ることでテトリミノが左右に動きます
  2. 顔を上に動かすことでテトリミノが回転します
  3. 顔を下に動かすことでテトリミノの落下が加速します
  4. テトリミノが落下したとき、2つ先のテトリミノの抽選に入ります。このときプレイヤーの表情を読み取り、表情に応じてテトリミノが選ばれます

技術

言語:Go

  • Ebitengine:ゲームエンジンに採用
  • pigo:顔認識ライブラリ
    • pigo-face-tracking:顔の動きを追跡するライブラリ

工夫

  • Web で動くコンパクトなゲームを目指した

Python で API サーバを立ててそちらに表情分析を一任するなどの手段がありましたが、よりコンパクトな構造にしたかったので pigo を採用しました。これによって余計なサーバを用意することなく、静的サイト上で動作するアプリケーションになっています ただし pigo はそこまでネット上に情報がない(公式ドキュメントもほとんどない)ので、ここはトレードオフです

  • パフォーマンスの向上

WebAssembly を介して js の関数から映像を取得する都合上、一度 canvas に映像を映し、そこから base64 形式にデコード、これを Ebitengine 上で描画できるように image.Image に変換して NewImage で表示、という3段階を踏む必要がありました。 この各処理を愚直に実装したところ FPS が 10 を切り紙芝居と化したので、以下の工夫を取り入れています。

  1. プレビューの FPS を固定
// constants.go const ( // カメラプレビューのFPS CAMERA_PREVIEW_FPS = 5 )
// camera.go var lastUpdateTime time.Time var updateInterval = time.Second / constants.CAMERA_PREVIEW_FPS // ... func UpdateCamera() { if time.Since(lastUpdateTime) < updateInterval { return } lastUpdateTime = time.Now()

毎 tick プレビューの更新処理をしていることがそもそもの問題だったので、秒間の更新回数を固定しました

  1. canvas.getImageDatawillReadFrequently を指定する

これはメモリの節約のほうですが、画像データの取得時にオプションを指定してあげることでより適した方法を取ってくれるようです(参考) 画像データを顔認識用に加工するために使用しました

rgba := ctx.Call("getImageData", 0, 0, cameraWidth, cameraHeight, map[string]interface{}{ "willReadFrequently": true, }).Get("data")
  • 表情認識

本来ならささっと Python なりでサーバーを起動して表情認識処理を API に丸投げすればいいのですが、面白くない(のとリアルタイム性を重視した)ので、すべてを Go で完結するようにしました ところで、pigo には表情認識機能はありません。代わりに、顔のランドマーク(眉、鼻、口などの頂点)を取得することができます。 image

これを利用して表情を分析していきます。ここからは例として「怒り😠」の表情を定義していきます。怒りを特徴から分析すると、

  • 眉間の長さが通常より短くなっている
  • 口を上に寄せるため、鼻から口の下までの距離が短くなる

と定義できます。つまり、通常時の顔と今の顔を見比べ、以上の特徴が見られれば「怒っている」と判別できそうです。 しかし、プレイヤーが常にカメラとの距離を一定に保ってくれるとは限りません。カメラからの距離が遠くなれば当然各ランドマーク間の距離も短くなるわけで、誤検知を誘発してしまいます。そのため、絶対距離ではなく相対距離を比較に用いました。 具体的には、ゲーム開始時に各パーツの距離を計測し、比率を分析します。水平方向は眉の外側を結んだ線分、垂直方向は眉間と口の中心をつないだ線分を基準としました。

// face.go type Face struct { Snapshot struct { Landmarks [][]int Horizonal struct { LEyebrowOuter2REyebrowOuter float64 // 左眉外側から右眉外側までの距離 // ... } Vertical struct { Glabella2MouthCenter float64 // 眉間から口中心までの距離 // ... } } HorizonalRatio struct { LEyebrowOuter2REyebrowOuterRatio float64 // 左眉外側から右眉外側までの距離比率(基準値) // ... } VerticalRatio struct { Glabella2MouthCenterRatio float64 // 眉間から口中心までの距離比率(基準値) // ... } }

これをもとに、例として眉間の距離を算出してみます。比の等式では外側の積と内側の積が等しくなる(a:b = a':b' において ab' = a'b )ので、現在の眉の外側の距離に、最初に測った眉間の比率をかけ合わせれば、基準となる眉間の長さが算出できそうです。

// face.go // スナップショットの比率をもとに、現在の眉間の距離を算出する // 怒っていると眉間が狭まるため、基準よりも小さい値になる currentEyebrowOuterDist := calcDistance(landmarks[constants.L_EYEBROW_OUTER], landmarks[constants.R_EYEBROW_OUTER]) basisEyebrowInnerDist := currentEyebrowOuterDist * f.HorizonalRatio.LEyebrowInner2REyebrowInnerRatio

上で求めた眉間の長さと現在の眉間の長さを比較し、現在の方が短くなっていれば怒っている...といった具合に算出しています。 実際は1mmでも長いと怒っている判定になってしまう超シビア判定にならないよう、しきい値を使ってある程度の猶予を設けています。

currentEyebrowInnerDist := calcDistance(landmarks[constants.L_EYEBROW_INNER], landmarks[constants.R_EYEBROW_INNER]) // 基準との差がボーダー以上なら怒っている😠 isAngryEyebrow := (currentEyebrowInnerDist - basisEyebrowInnerDist) > eyebrowBorder

デモ動画

キーのみ表情検知デモ
https://www.youtube.com/watch?v=DwXBTDMjhaA

鼻操作デモ https://www.youtube.com/watch?v=p0c90rwFB8U

つらかったこと

顔認識の都合上、デバッグ中は常に前髪を持ち上げていなければいけませんでした。 image

ついでに、表情分析のテストのために眉が筋肉痛になるまで動かしました。

image

かわいそう。

yotu

@yotu