OpenCV for Unityで炎を操る

#はじめに
たまたまセールで半額になっていたので，思い切ってOpenCV for Unityを購入しました．
せっかくなのでマーカレスARをやろうと意気込んだものの，OpenCV for UnityでマーカレスARをやっている記事があまりなかったので躓いたところを書き残します．

##OpenCV for Unityとは
[OpenCV for Unity](https://assetstore.unity.com/packages/tools/integration/opencv-for-unity-21088)は，UnityでOpenCVを使用するための有料アセットです．
UnityでOpenCVを使用する方法はいくつかあり，以下の記事にわかりやすい比較があります．

- [マルチプラットフォーム対応のOpenCV画像処理アプリをUnityで効率的に作る方法](https://qiita.com/utibenkei/items/5069694dae16204273e2)

以前WindowsでOpenCVSharpを使ったことがあったのですが，Macに乗り換えたのと，半額の衝撃でOpenCV for Unityを購入しました．

##作ったもの
https://github.com/takaya901/DetectHand
(OpenCV for Unityのフォルダはignoreしています)
<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">OpenCV for Unity使ってみた <a href="https://t.co/GXBBpyiJYf">pic.twitter.com/GXBBpyiJYf</a></p>&mdash; 敬也 (@takaya901) <a href="https://twitter.com/takaya901/status/1072800609001062407?ref_src=twsrc%5Etfw">2018年12月12日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

今回は練習ということで，手を認識して炎を操ってる感をだしてみました．

##Unity+OpenCVでマーカレスAR
OpenCVで画像認識を行い，その情報をもとにUnityで3Dモデルやエフェクトを重畳することで，VuforiaやOpenCV単体ではできなかったマーカレスかつ3次元のARを実現することができます．Unityなのでクロスプラットフォームというのも強みですね．

ただし，ここで問題となるのが，カメラ映像はUnityのシーンと違って2次元であるということです．
マーカを使わない場合，我々はカメラ映像の奥行き情報を得ることができません．したがって，今回は2次元平面上の位置合わせのみを行いました．

仕組みは以下のとおりです．QuadにWebカメラからの映像を投影します．そして，映像から手の2次元位置を取得して，その位置にオブジェクトを生成します（Z座標はQuadの少し手前で固定）．これらをMainCameraで撮影して画面に表示することで，無理やりですがマーカレスARを実現できます．

#手順
シーン作成とスクリプトは"OpenCVForUnity/Examples/Advanced/ComicFilterExample"を参考にしました．

環境：
・MacOS Mojave
・Unity 2018.2.15f1
・OpenCV for Unity 2.3.2

##OpenCV for Unityのセットアップ
以下の記事を参考にさせていただきました．
[UnityでOpenCVを利用した顔検出・画像処理アプリ事始め](https://qiita.com/utibenkei/items/fb93623a7ccaabb342f2)

##シーン作成
スクリプトでCamera.mainを参照するため，Main CameraのTagをMainCameraに設定します．また，ProjectionをOrthographicにします．Lightは不要です．

次に，Webカメラの映像を写すQuadを用意します．このQuadにスクリプトをアタッチしていきます．
Webカメラから取得したWebCamTexture型のデータをOpenCVで扱うMat型に変換するための機能が，WebCamTextureToMatHelperというクラスとして用意されています．解像度やFPS，モバイルのリアカメラ/フロントカメラの切り替えも管理してくれます．これをQuadにアタッチします．FPSを画面に表示してくれるFpsMonitorも必要に応じてアタッチします．
最後に，自分で作成する画像処理スクリプトをアタッチします（今回はDetectHnad.csとします）．

<img width="250" alt="camera.png" src="https://qiita-image-store.s3.amazonaws.com/0/239014/d299f172-9c17-528d-ed14-062b0102cd18.png"> 　　<img width="300" alt="quad.png" vspace="30" src="https://qiita-image-store.s3.amazonaws.com/0/239014/4ffcd925-822f-3d45-725f-51f9eff5e5b6.png">

ちなみに，Quadのマテリアルがデフォルトのままだと下の画像のように暗くなってしまいます．"Assets/OpenCVForUnity/Examples/"にあるExampleMaterialに変えたらちゃんと表示されました．

<img width="300" alt="Screen Shot.png" src="https://qiita-image-store.s3.amazonaws.com/0/239014/bb4de761-8b2f-b1ce-165f-1796e6486b6f.png">

##スクリプト作成

###WebCamTextureToMatHelperに関するメソッド
まず，ComicFilterExample.csから以下のメソッドをそのままコピペします．

- OnWebCamTextureToMatHelperInitialized()
- OnWebCamTextureToMatHelperDisposed()
- OnWebCamTextureToMatHelperErrorOccurred()

OnWebCamTextureToMatHelperInitialized()内で，QuadのテクスチャにTexture2D型のフィールドを設定します．これにより，Update()内でそのフィールドを更新するとQuadに表示されるようになります．また，カメラの画角いっぱいにQuadが表示されるよう設定しています．

Update()は基本的に以下の流れになると思います．

1．WebCamTextureToMatHelperのGetMat()でカメラ映像をMat（RGBA）として取得
2．1で取得した画像に対して処理を行う
3．Utils.fastMatToTexture2D()で処理後のMatをQuadのテクスチャに変換する

###手の検出
今回は簡単に，以下のような手順で検出しました．

1．画像から肌色の領域を抽出する
2．1で得た2値画像（肌色領域が255，それ以外は0）に対してラベリングを行う
3．ラベリングした中で面積が最大の領域を手とする

とても単純なやり方なので顔にも反応してしまいます．手のカスケード分類器を使えば顔の誤検出は防げますが，手は形状的にカスケード分類器による検出に向いていないらしく，諦めました．

メソッドは以下のとおりです．ただし，クラス名省略のために

```cs
using static OpenCVForUnity.Imgproc;
using static OpenCVForUnity.Core;
```
を指定しています．

```cs
void SetFire(Mat webcamMat)
{
    using(var hsvMat = new Mat()) 
    using(var handMask = new Mat())
    using(var centroids = new Mat())
    using(var stats = new Mat())
    {
        //RGBAをHSVに変換
        cvtColor(webcamMat, hsvMat, COLOR_RGBA2RGB);
        cvtColor(hsvMat, hsvMat, COLOR_RGB2HSV);
    
        //肌色領域を抽出
        inRange(hsvMat, SKIN_LOWER, SKIN_UPPER, handMask);

        //ラベリング
        var nLabels = connectedComponentsWithStats(handMask, new Mat(), stats, centroids);

        //最大の領域の重心を取得
        var maxAreaLabel = 0;
        var maxArea = 0.0;
        for (int i = 1; i < nLabels; i++) {           //0番目のラベルは背景のため飛ばす
            var area = stats.get(i, CC_STAT_AREA)[0];
            if (area > maxArea) {
                maxArea = area;
                maxAreaLabel = i;
            }
        }
    
        //画像上の重心位置をワールド座標に変換
        var ctrdOnImg = new Point(centroids.get(maxAreaLabel, 0)[0], centroids.get(maxAreaLabel, 1)[0]);
        var ctrdOnWorld = new Point((float)ctrdOnImg.x - webcamMat.width() / 2f, webcamMat.height() / 2f - (float)ctrdOnImg.y);
    
        //炎を手の位置に移動
        _fire.transform.position = new Vector3((float)ctrdOnWorld.x, (float)ctrdOnWorld.y, FIRE_Z_POS);
    }
}
```

引数のwebcamMatは，WebCamTextureToMatHelperのGetMat()メソッドで取得したRGBA画像です．

[こちらの記事](https://qiita.com/mogamin/items/f41cbc3c812506962e8e)にあるように，Mat型のローカル変数はusing等を使ってリリースしたほうがいいです．Androidで実行すると結構落ちました．
ラベリング後の面積や重心の取得については[こちらの記事](https://qiita.com/takaya901/items/35d257a27f1928cc21cc)に書きました．

#おわりに
[公式リファレンス](https://enoxsoftware.github.io/OpenCVForUnity/3.0.0/doc/html/index.html)があんまり親切じゃないので，Java用OpenCVのリファレンスやコードを参考にするといいと思います．

手の検出に関しては"Assets/OpenCVForUnity/Examples/Advanced/HandPoseEstimationExample/"にサンプルがあります（[参考動画](https://www.youtube.com/watch?v=KuGpOxOcpds)）．検出方法自体は肌色検出＋ラベリングのようですが，立てている指の本数などの情報も取れるようなので，時間があるときにコードを見てみようと思います．