THETA で OpenCV を使って色検知してみた

## Maker Faire Tokyo 2019 に THETA プラグインのネタで参加

こんにちは｡リコーの@ueue です｡

8 月 3(土)4(日)に東京ビッグサイトで行われた Maker Faire Tokyo(MFT)に THETA プラグインネタで参加しました｡
僕は､｢だるまさんがころんだ｣というネタを持ち込みました｡
この記事では｢だるまさんがころんだ｣の中身について説明します｡
メインは OpenCV による特定の色の動き検知になります｡

ちなみに他に MFT に持ち込んだネタは以下に詳細な記事があります｡

- [THETA プラグイン ×M5BALA Part2：ライントレースする](https://qiita.com/KA-2/items/f640e8bfed3c421f9663)
- [THETA でインスタントカメラ](https://qiita.com/shrhdk_/items/d041b3c1eadcb6f0e8fb)

THETA V､THETA Z1 は OS に Android を採用していて、本体内部で Android アプリを動かすことができます。
THETA 本体にインストールできる Android アプリのことを THETA プラグインと呼んでいて、開発したプラグインは公式プラグインストアにて配布することができます。

インスタントカメラもライントレーサーも､そしてこのだるまさんがころんだもすべて THETA プラグインでつくられています｡
これらのコードは[Github で公開](https://github.com/theta-skunkworks)されており､
今回の｢だるまさんがころんだ｣プラグインも[こちら](https://github.com/theta-skunkworks/theta-plugin-darumasan-ga-koronda-sample)で公開しています｡

## THETA で｢だるまさんがころんだ｣とは？

![gainenzu.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264025/3351d5cb-7c0e-2cce-685c-0ab985410942.png)

普通のだるまさんがころんだは鬼が｢だるまさんがころんだ｣といい終わったタイミングでふりかえり､
鬼が見ている間は参加者は動いてはいけません｡参加者は｢だるまさんがころんだ｣と言っている間に
ちょっとずつ鬼に近づいて鬼にタッチできたら勝ちというゲームです｡

ただ､人通りが多い MFT の会場内で特定の人の動きを検知するのは至難の業です｡
なので､今回は｢赤､青､緑の大きなかたまり｣が動いたことを検知することにしました｡
MFT 当日はブースの位置や大きさなどを加味し、青と緑 2 色の大きな風船（直径 90㎝）を参加者に持ってもらい、風船が動いたことを検知するデモンストレーションにしました。

また､｢近づいてタッチ｣を THETA で検知するのはむずかしいので
かわりに｢だるまさんがころんだ → 一定時間動き検知｣を 3 回繰り返して､どの色もうごかなかったら
鬼の負け(参加者の勝ち)というルールにしました｡

もう一つ普通のだるまさんがころんだと違う点は､鬼が 360 度カメラである点です｡
すなわち､THETA を中心にして 360 度のどこにいても検知することができます｡
僕はプラグインを利用することで､THETA を 360 度カメラから 360 度画像認識装置に進化させられると
思っていて､このだるまさんがころんだプラグインはその第一弾です｡

話を戻して､だるまさんがころんだプラグインの動作を整理すると以下のような流れとなります｡

1. だるまさんがころんだプラグインを起動
2. シャッターボタンを押下
3. ｢だるまさんがころんだ｣という音声ファイルがながれる(音声ファイルは 4 種類の中からランダムに起動)
4. 一定期間､赤､青､緑のかたまりの動きを検知
5. 動きを検知したら｢\*\*色が動いた｣という音声ファイルを流して終了
   動きがない場合は 3 に戻る｡
6. 3 から 5 を 3 回繰り返し､どの色も動かない場合は｢みんなのかち｣という音声ファイルを流して終了｡

上記に加えて､THETA が見ている特定色のフィルター結果画像を Vysor を通して見る仕組みも実装しています｡
Wi-Fi ボタンをおすことで､
順に緑フィルター結果､青フィルター結果､赤フィルター結果､フィルターなしの画像をみることができます｡
これは色抽出のしきい値をきめるところで利用します｡詳細は後述します｡

## 全体構成

処理は大きく 2 つの部分にわかれており､それぞれ別スレッドで動作します｡
一つは OpenCV を使った画像処理部分です｡
カメラが撮影したフレームごとに色抽出し､重心位置の計算を行います｡

もう一つは鬼の処理です｡
鬼は上記の重心位置を必要なタイミングで取り出して､動き(位置の変化量)を計算します｡

この 2 つのスレッドで各色の重心位置の情報を共有します｡

## 画像処理

まずは OpenCV による画像処理部分を説明します｡
ここは以下の記事をベースに作っています｡
[THETA の中で OpenCV を動かす【プレビューフレーム応用編】](https://qiita.com/roohii_3/items/75c3e73cee0ed6d53b4b)

コードは[Github](https://github.com/theta-skunkworks/theta-plugin-opencv-detection-sample)から
もってきました｡これをベースにすれば OpenCV がすぐ動きます｡

ここの処理は MainActivity の onCameraFrame 内で処理しています｡
コードは[こちら](https://github.com/theta-skunkworks/theta-plugin-darumasan-ga-koronda-sample/blob/d28619fa972c2b1962597b2fe26ceff1f918b65f/app/src/main/java/com/theta360/darumasangakoronda/MainActivity.java#L174)になります｡

大まかな流れは以下になります｡

- 画像処理 1. RGB 情報のフレームを取得
- 画像処理 2. フレームをリサイズ
- 画像処理 3. RGB から HSV に変換
- 画像処理 4. 各色を抽出
- 画像処理 5. 抽出した対象物の中から最大のものの重心のポジションを計算

画像処理のために OpenCV の[Imgproc クラス](https://docs.opencv.org/3.4/javadoc/org/opencv/imgproc/Imgproc.html)のメソッドを使います｡
Imagproc クラスのメソッドでフレームに相当する Mat オブジェクトを順に変換していく､という流れになります｡

### 画像処理 1. RGB 情報のフレームを取得

対応するのは以下の部分です｡
この Mat オブジェクト frameOrg が一つのフレームの情報に対応しています｡
この時点では色情報は RGB で持っています｡

```Java
// RGBのフレーム
Mat frameOrg = inputFrame.rgba();
```

以下で､この frameOrg に対して処理を施して
新しい Mat オブジェクトを取得する､という流れになっています｡

### 画像処理 2. フレームをリサイズ

それなりの大きさの色の塊を抽出するのに大きな画像である必要はないので
リサイズして小さくします｡
これで必要なメモリ量や CPU パワーを軽減させることが狙いです｡

対応するコードは以下の部分です｡

```Java
//リサイズ
Mat frameMini = frameOrg.clone();
Imgproc.resize(frameOrg, frameMini, new Size(160, 80), 0, 0, Imgproc.INTER_LINEAR);
```

[Imgproc.resize メソッド](https://docs.opencv.org/3.4/javadoc/org/opencv/imgproc/Imgproc.html#resize-org.opencv.core.Mat-org.opencv.core.Mat-org.opencv.core.Size-double-double-int-)を使います｡
リサイズされた結果として frameMini オブジェクトができます｡

### 画像処理 3. RGB から HSV に変換

色情報を RGB から HSV に変換します｡
HSV は色を色相(Hue)、彩度(Saturation)、明度(Value)の 3 値で表現するカラーモデルです｡
[Imgproc.cvtColor メソッド](https://docs.opencv.org/3.4/javadoc/org/opencv/imgproc/Imgproc.html#cvtColor-org.opencv.core.Mat-org.opencv.core.Mat-int-)を使います｡
第三引数の指定で変換方法をコントロールできます｡frameMini オブジェクトが HSV 情報を持ったオブジェクトとして上書きされます｡

```Java
 // HSVに変換
 Imgproc.cvtColor(frameMini.clone(), frameMini, Imgproc.COLOR_RGB2HSV);
```

### 画像処理 4. 色を抽出

OnCameraFrame メソッドでは以下に対応します｡

```Java
// 色を抽出
final Scalar GreenMin = new Scalar(50,50,10);
final Scalar GreenMax = new Scalar(90,255,255);
final Scalar BlueMin = new Scalar(100, 100, 10);
final Scalar BlueMax = new Scalar(130, 255, 255);
final Scalar RedMin = new  Scalar(0, 100, 30);
final Scalar RedMax = new Scalar(5, 255, 255);

final Mat frameGreen = getColorFrame(frameMini, GreenMin, GreenMax);
final Mat frameBlue = getColorFrame(frameMini, BlueMin, BlueMax);
final Mat frameRed = getColorFrame(frameMini, RedMin,RedMax);
```

Scalar オブジェクトが色の範囲を決めています｡
例えば緑の範囲を以下のように決めています｡

```Java
final Scalar GreenMin = new Scalar(50,50,10);
final Scalar GreenMax = new Scalar(90,255,255);
```

Scalar の引数が順に HSV の値です｡
すなわち色相､彩度､明度が 50､50､10 から 90､255､255 の間に含まれるものを緑と定義しています｡
この値が色抽出のインプットになります｡
この数値の決め方は後述します｡

getColorFrame メソッドで色の抽出とノイズ除去を行っています｡

```Java
  private Mat getColorFrame(Mat frame, Scalar min, Scalar max) {
        Mat frameColor = frame.clone();

        // color抽出
        Core.inRange(frameColor.clone(), min, max, frameColor);
        Imgproc.threshold(frameColor.clone(), frameColor, 100, 255, THRESH_BINARY);

        //noize除去
        Imgproc.morphologyEx(frameColor.clone(), frameColor, Imgproc.MORPH_OPEN, mStructuringElement);

        return frameColor;
    }
```

Core.inRange で色の抽出をおこなっています｡第 4 引数が変換後の画像です｡
このあとは[Imgproc.threshold](https://docs.opencv.org/3.4/javadoc/org/opencv/imgproc/Imgproc.html#threshold-org.opencv.core.Mat-org.opencv.core.Mat-double-double-int-)で 2 値化したのち､[Imgproc.morphology](https://docs.opencv.org/3.4/javadoc/org/opencv/imgproc/Imgproc.html#morphologyEx-org.opencv.core.Mat-org.opencv.core.Mat-int-org.opencv.core.Mat-)でモルフォロジー変換によりノイズを除去します｡
モルフォロジー変換については[こちらのサイト](http://labs.eecs.tottori-u.ac.jp/sd/Member/oyamada/OpenCV/html/py_tutorials/py_imgproc/py_morphological_ops/py_morphological_ops.html)を参照ください｡

### 画像処理 5. 抽出した対象物の中から最大のものの重心のポジションを計算

この処理は getMaxContourPosition メソッドで行っています｡

```Java
 // 最大の輪郭の中心位置を取得
        int[] positionGreen = getMaxContourPosition(frameGreen);
```

getMaxContourPosition は以下のとおりです｡
[Imgproc.findContours](https://docs.opencv.org/3.4/javadoc/org/opencv/imgproc/Imgproc.html#findContours-org.opencv.core.Mat-java.util.List-org.opencv.core.Mat-int-int-)メソッドで
画像の輪郭を抽出します｡

今回､一番困ったのはここです｡
さきほど､ノイズ除去をおこないましたが､例えば､赤い服を着た人が近くを通ったら､それは
物体として検出してしまいます｡

findContors の結果にはすべての物体の輪郭が含まれるため､
結果に複数の輪郭が含まれる場合､どうやって風船のものだけを抽出するのかというところが問題です｡

結論としては｢輪郭の面積が最大値のものを抽出する｣という方法を採用しました｡

- THETA は超広角レンズのため､距離が離れると急激に被写体の画像が小さくなる｡
- MFT の会場では風船を持った人が THETA から､それほど離れることはできない｡
- 今回の風船はかなり大きい(直径 90cm くらい)
- 風船より前方に､同じ色の物体が入る可能性は低い
- 風船より後方で､直径 90cm よりも更に大きい物体が入る可能性は低い

つまり､かなり状況依存な抽出法になっています｡

```Java
 private int[] getMaxContourPosition(Mat frame) {
        List<MatOfPoint> contours = new ArrayList<>();
        Imgproc.findContours(frame, contours, new Mat(), Imgproc.RETR_TREE, Imgproc.CHAIN_APPROX_SIMPLE);

        double maxArea = 0;
        MatOfPoint maxContour = new MatOfPoint();
        for (MatOfPoint contour : contours) {
            double area = Imgproc.contourArea(contour);
            if (maxArea < area) {
                maxArea = area;
                maxContour = contour;
            }
        }

        Moments p = Imgproc.moments(maxContour);
        int[] position = new int[2];

        position[0] = (int) (p.get_m10() / p.get_m00());
        position[1] = (int) (p.get_m01() / p.get_m00());

        return position;
    }
```

こうやって計算した値をメンバ変数である positionNowGreen､positionNowBlue､positionNowRed にいれます｡

```Java
synchronized (lockGreen) {
    positionNowGreen = positionGreen;
}
```

positonNow は別スレッドの Oni クラスで読み込むために syncronized を使っています｡

### 色範囲の決め方

色抽出処理のところで決めた色範囲の決め方について説明します｡

```Java
final Scalar GreenMin = new Scalar(50,50,10);
final Scalar GreenMax = new Scalar(90,255,255);
```

この値の決め方について｡今回は以下のようにしました｡
[こういうサイト](https://www.peko-step.com/tool/hsvrgb.html)などで
まずは RGB と HSV 変換サイトで､HSV の値の当たりをつけます｡
あとは､対象の風船を THETA の近くにおいて､
その領域をうまく区切れているか､他の物体ははいっていないかというのを
画像を見ながら､ひたすら試行錯誤します｡

画面を見るためには Vysor を使います｡
下の画面は緑を抽出した画面です｡THETA の前においている緑のボールを検知していることが
わかります｡あと､ディスプレイの下にちょこっと写っている緑の風船も検知しています｡

![nofilter.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264025/37bf2071-45c6-f7af-6c21-17f026e3d56d.png)    ![midori.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264025/c26e45ad-df7a-fba2-41be-099042ee97d9.jpeg)

この画面をみながら､Scalar の引数の HSV 値を最適化していきます｡
ちなみに､Wi-Fi ボタンを押すことで､緑､青､赤､フィルターなしの画像が表示されます｡
そのように切り替える仕組みは OnCameraFrame メソッドの最後にあります

```Java
// Vysorを使って､色抽出した画面をチェックする
// Wi-Fiボタンで表示切り替え
switch (showFrameNumber % 4) {
    case 0:
        Imgproc.resize(frameGreen.clone(), frameOrg, new Size(640, 320), 0, 0, Imgproc.INTER_LINEAR);
        break;
    case 1:
        Imgproc.resize(frameBlue.clone(), frameOrg, new Size(640, 320), 0, 0, Imgproc.INTER_LINEAR);
        break;
    case 2:
        Imgproc.resize(frameRed.clone(), frameOrg, new Size(640, 320), 0, 0, Imgproc.INTER_LINEAR);
        break;
    case 3:
        //do nothing. return original frame
}

return frameOrg;
```

Wi-Fi ボタンを押すと showFrameNumber の値がインクリメントされます｡
その数に対応した色の Frame サイズをもとに戻して return しています｡

なお､Wi-Fi ボタンの押下の処理は OnCreate の OnKeyDown の中で行っています｡

```Java
setKeyCallback(new KeyCallback() {
@Override
public void onKeyDown(int keyCode, KeyEvent keyEvent) {
    if (keyCode == KeyReceiver.KEYCODE_CAMERA) {
        if (!oniAlive) {
            Oni oni = new Oni();
            oni.start();
        }
    }
    if (keyCode == KeyReceiver.KEYCODE_WLAN_ON_OFF) {
        showFrameNumber += 1;
    }
}
```

ちなみに､同じ onKeyDown の中でシャッターボタンを押すと Oni クラスの処理が走るように設定されています｡

## 鬼クラス

Oni クラスがだるまさんがころんだの処理のメインです｡
ここは以下の処理をしているだけなのでソースを見てもらえればわかると思います｡

1. シャッターボタンが押されたら処理をスタート
2. 4 種類の｢だるまさんがころんだ｣音声ファイルからランダムに一つを選択して､流す｡
3. 音声ファイル終了直後の各色の位置を記録
4. 動きを検知するフレームの枚数を 6 から 16 の幅でランダムに決定
5. 500msec まって､そのタイミングの位置を検知
6. 4 と 5 の距離を算出して､しきい値と比較
   7-1. しきい値を超えていれば｢\*\*色がうごいた｣音声ファイルを流して終了
   7-2. しきい値を超えていなければ 2-6 を 3 回繰り返す
7. 3 回繰り返して､どの色の動きも検知しなかった場合､｢みんなの勝ち｣音声ファイルをながして終了

Oni クラスの全体は[こちら](https://github.com/theta-skunkworks/theta-plugin-darumasan-ga-koronda-sample/blob/d28619fa972c2b1962597b2fe26ceff1f918b65f/app/src/main/java/com/theta360/darumasangakoronda/MainActivity.java#L286)です

2 つだけポイントを説明します｡

まず､1 つ目のポイント｡
シャッターが連続で押された場合､2 回目以降を無視するための仕組みとして
run メソッドの最初に oniAlive というフラグを設定しています｡
これは run メソッドが起動中であることを別のスレッドに伝えるためです｡

```Java
oniAlive = true;
```

oniAlive をチェックしているのはシャッターボタンがおされたタイミングです｡
oniAlive が false のときのみ run メソッドが起動されます｡

```Java
public void onKeyDown(int keyCode, KeyEvent keyEvent) {
    if (keyCode == KeyReceiver.KEYCODE_CAMERA) {
        if (!oniAlive) {
            Oni oni = new Oni();
            oni.start();
        }
    }
```

もう一つのポイントは positionNowGreen､positionNowBlue､positionNowRed の読み込みです｡
OpenCV のスレッドで値が入力されているため､その値を読み込むときには syncronized を使っています｡

```Java
synchronized (lockGreen) {
    positionInitGreen = positionNowGreen;
}
```

## まとめ

OpenCV の色検知を利用した｢だるまさんがころんだ｣プラグインの中身を説明しました｡

## RICOH THETA プラグインパートナープログラムについて

THETA プラグインに興味を持たれた方がいれば、以下の記事もぜひご覧ください。

- [THETA プラグインパートナープログラムはじめました](https://qiita.com/mrtakada/items/8f3bbf8d283c26899473)
- [【THETA プラグイン開発】プラグインパートナープログラムに登録してみよう](https://qiita.com/mShiiina/items/55d98f366e650ca42251)

[RICOH THETA プラグイン開発者コミュニティ](https://qiita.com/organizations/theta-plugin)では、他にも記事を書いています。
RICOH THETA プラグインについては[こちら](https://api.ricoh/products/theta-plugin/)に情報がまとまっています。興味を持たれた方は[twitter](https://twitter.com/thetaplugin)のフォローと[THETA プラグイン開発コミュニティ(slack)](https://join.slack.com/t/thetadeveloper/shared_invite/enQtNzcxODI2MTk2ODg0LWY5YmU2ZTIxNGYwNzlmNWZjMWMyMWFlMzY3ZmQ3NGU3ZjZiZjI0Njk4MGIxMDU5NDJiMzQ4Y2FkNDEzYmFlNGU)への参加もぜひどうぞ｡