THETAプラグインで手持ち/固定を判定する

## はじめに
リコーの @KA-2 です。

弊社では[RICOH THETA](https://theta360.com/ja/)という全周囲360度撮れるカメラを出しています。
<B>RICOH THETA V</B>や<b>RICOH THETA Z1</b>は、OSに<B>Android</B>を採用しています。Androidアプリを作る感覚でTHETAをカスタマイズすることもでき、そのカスタマイズ機能を「プラグイン」と呼んでいます（詳細は本記事の末尾を参照）。

今回は、THETAが手持ちされているか固定されているか、簡単に判断する方法を紹介します。
Android標準のライブラリをうまく使うことでも、かなり高精度な判定ができるのです。
（ある意味、厳密に判定しすぎかも？）

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">◤Qiita記事公開◢<br>THETAプラグインで、カメラが手持ちされているか固定されているか簡単に判定する方法を紹介します！<br>先日公開Instant Night Snapで効果をすぐ試せます。銅像パントマイムの人でも誤判定させられないかも！？<a href="https://twitter.com/hashtag/THETA?src=hash&amp;ref_src=twsrc%5Etfw">#THETA</a> <a href="https://twitter.com/hashtag/thetaplugin?src=hash&amp;ref_src=twsrc%5Etfw">#thetaplugin</a> <a href="https://twitter.com/hashtag/android?src=hash&amp;ref_src=twsrc%5Etfw">#android</a> <a href="https://twitter.com/hashtag/360camera?src=hash&amp;ref_src=twsrc%5Etfw">#360camera</a><a href="https://t.co/Zaov2JXiPG">https://t.co/Zaov2JXiPG</a> <a href="https://t.co/hQZq5kIUu1">pic.twitter.com/hQZq5kIUu1</a></p>&mdash; THETAプラグイン開発者コミュニティ (@thetaplugin) <a href="https://twitter.com/thetaplugin/status/1453904460409831426?ref_src=twsrc%5Etfw">October 29, 2021</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

動画よりも頑張って静かに手持ちしても「固定」と判定されません。
机におくと「固定」と判定されます。シンプル判定なのになかなか凄い。

この判定方法が盛り込まれたプラグインは、[「Instant Night Snap」という名称で THETA Plug-in STORE に公開](https://pluginstore.theta360.com/plugins/com.theta360.instantnightsnap/)されています。
本当に、手持ちのときに固定と判定されないのか、是非ともおためしあれ！
（もしも手持ちで固定と判定された場合、「アナタの手持ち凄い！ロボット並み！」という事で、まず人間業では無理かと思います。私は無理でした！）


## 何の役にたつの？

この判定が、何の役に立つのかしら？と思う方もいらっしゃるかもしれません。
先に紹介した[「Instant Night Snap」](https://pluginstore.theta360.com/plugins/com.theta360.instantnightsnap/)のように、手持ち向き撮影設定、固定向き撮影設定を自動で切り替えることに使えます。
それ以外にも、「手持ちしているときだけ〇〇認識をする」や「THETAを持ち逃げしようとした人を撮影して威嚇音も出す」なんていう使い方もよさそうです。THETAを使ったゲームも作れそうです。色々と応用が考えられます。


## 手持ち/固定の判定をどう実現したのか？

### 一般的な手持ち/固定判定と本プラグインの違い

各カメラメーカーさんは「センサーの生データからどうノイズを除去するか」「センサードリフト（温度変化に応じたセンサー出力変化など）をどうするか」というレベルから、「三脚に機材を固定したときの微振動をどう扱うか（FFTで特定周波数成分をみて、あーたらこーたら）」などの高度なレベルまで、とても凝った判定をしています。
本当は、まじめに考えると結構大変なことなのですが、このプラグインでは判りやすく簡易的な手法を選択しました。

本プラグインでは、面倒ごとを [「THETAの姿勢を利用し、スマホなし設定を可能にする」の記事で紹介した<b>Androidの姿勢情報</b>](https://qiita.com/KA-2/items/d8d0549d07bd06b0b9c7)にまるごとお任せします。

### 着目した点

スマートフォンの世界では、姿勢情報を「画面の傾きに応じてボールを転がし迷路を抜けるようなゲーム」や「VRビュー(360度映像から画面の姿勢に応じた範囲を切り出して表示する)」に利用しています。
これらのアプリでは、動かす対象物が小刻みに揺れたり、表示位置が突然飛ぶなどの挙動をすることがないと思います。とても微細な傾け方にも反応しますし、動きも滑らかです。

姿勢情報、目的とする判定にも使えそうなイメージ持てたでしょ？

上記の感触を頼りにTHETAプラグインで実験してみたところ、ノイズ除去（もしくは平滑化）やセンサードリフトを考慮しなくても、目的の判定ができる事がわかりました。
Androidの姿勢情報は、3軸の加速度と角速度をライブラリの中でセンサーフュージョンする際、これらの面倒ごとも引き受けてくれていると想像できます。（オープンソースですので、ソースコードを探して読めば正確なことまで判るとおもいますが、時間が限られていたのでそのような調査はしていません。）

「Androidのライブラリは実は中身がとてもいい感じになっている！」のです。目的とする処理を記述することだけに専念できます。
そして、その恩恵をTHETAプラグインでも受けられるというわけです。


### 判定方法詳細

結果をまず書いてしまいます。

(1) 姿勢情報(3軸)を約30回/秒の頻度で蓄積する。
(2) 判定トリガーの0.7 秒前～ 2 秒前の期間の姿勢変化（姿勢の最大最小差）が3軸共に 0.1° 以内だと「固定」 それ以外は「手持ち」と判断する 。

![説明図.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/265455/d64302a1-91fd-2a0b-8aed-9f756daa70a5.png)

ね、シンプルでしょ？拍子抜けするほど簡単でしょ？


面倒だったのは、蓄積したデータからどの範囲をみるか、判定閾値をどうするか、具体的な数値の決定です。
この数値が、今回の判定方法における「味付け」になります。簡単に個性が出せるポイントです。

ボタン押下～0.7秒前を無効期間としたのは、「ボタン押下より前にどうしてもTHETA本体を触る期間を無視する」ためです。
このとき「静かに操作しなくては」とボタン押下前に本体をそっと触っている期間が長いと、固定と判定されません。「そっと」「素早く」ボタンを押すことが操作のコツとなります。


現時点で判ってる弱点としては、「自立できる一脚」で固定と判定されることが難しいということです。
地面に近い支点からTHETA本体までの距離が長いと、目で見ても気づきにくいけれど、今回の判定閾値には引っかかる程度揺れているのです。揺れが収まる場合でも時間がかかります。
かといって判定閾値を緩くすると「手持ちなのに固定と誤判定される」ということが生じます。
「良い塩梅」をひたすら実験して追い込んだ結果となります。
自立一脚のケースは、揺れてることを揺れてると正しく認識できているのです！
（周波数解析して、緩い揺れを無視するような策も考えられるんですけどね）

お気に召さない場合、お好みの具合となるようご自身で調整にチャレンジしてみてください。


## ソースコード

[こちらのプロジェクト一式](https://github.com/theta-skunkworks/theta-plugin-instant-night-snap-sample)を参照してください。
手持ち/固定判定にポイントを絞った説明を以下に記載します。

### ファイル構成

本プラグインは、姿勢情報を定期的に取得しつつ静止画撮影も行うことから、[「THETAの姿勢を利用し、スマホなし設定を可能にする」記事のソースコード](https://qiita.com/KA-2/items/d8d0549d07bd06b0b9c7)をベースに作成しました。
新規作成、または、変更を加えたファイルは以下のとおりです。

```
theta-instant-night-snap\app
└src\main
　├assets　　　　　　　 // ベースとしたプロジェクトのままです。
　└java\com\theta360
　　　├pluginapplication
　　　│　├model　　 　　　// ベースとしたプロジェクトのままです。
　　　│　├network　 　　　// ベースとしたプロジェクトのままです。
　　　│　├oled　　　　　　// ベースとしたプロジェクトのままです。
　　　│　├task　　　　　　//　InstantNightSnapTask.javaはShutterButtonTask.javaの代わりです。
　　　│　│　　　　　　　　//　以下は未使用のため削除しました。
　　　│　│　　　　　　　　//　- ChangeApertureTask.java
　　　│　│　　　　　　　　//　- ChangeIsoTask.java
　　　│　│　　　　　　　　//　- ChangeShutterSpeedTask.java
　　　│　└view　　　　　　// ベースとしたプロジェクトのままです。
　　　└instantnightsnap　// - Attitude.java, DisplayInfo.java はベースとしたプロジェクトのままです。
　　　　　　　　　　　　　//　- judgeCameraMoving.java は手持ち/三脚判定のクラスです。
　　　　　　　　　　　　　//　- MainActivity.java　はプラグインの全体構造が記述されています。
```

judgeCameraMoving.javaとMainActivity.javaが手持ち/固定判定に関わるところです。
これらを中心に説明します。

### judgeCameraMoving.java

手持ち/固定判定を行っている核となるクラスです。

- データを蓄積するためのデータ構造
- データ蓄積を行うメソッド
- 蓄積済データから手持ち/固定判定を行うメソッド

で構成されています。

##### データ構造

「ミリセカンドオーダーの時間（Long型）」の配列と「Yaw、Pitch、Roll 3軸の姿勢（Double型）」の配列を用意しました。

```judgeCameraMoving.java
    ArrayList<Long> historyMilli;
    ArrayList<ArrayList<Double>> history;
```

２つの配列は同数まで蓄積できるサイクリックバッファとして利用します。
蓄積数やサイクリックにつかう仕組みはデータ蓄積メソッドのコードで決まります。


##### データ蓄積メソッド

前述のデータ構造に、最新データ100セットまでを蓄積するメソッドとなっています。
30回/秒で2秒間まで蓄積できればよいのですが、試行錯誤の実験をしていたため、3秒ちょい程度まで蓄積できるようになってます。
後述する周期動作するスレッドの中で定期的に呼び出しています。

```judgeCameraMoving.java
    public void setSensorHistory(double inYaw, double inPitch, double inRoll) {
        /*
        Log.d(TAG, "inYaw="+ String.valueOf(inYaw)
                + "inPitch="+ String.valueOf(inPitch)
                +"inRoll="+ String.valueOf(inRoll) );
         */

        historyMilli.add(System.currentTimeMillis());

        ArrayList<Double> member = new ArrayList<Double>();
        member.add(inYaw);
        member.add(inPitch);
        member.add(inRoll);
        history.add(member);

        if (history.size() > 100) {
            //古いものを消す
            history.remove(0);
            historyMilli.remove(0);
        }

        Log.d(TAG, "history.size()="+ String.valueOf(history.size()) );
    }
```


##### 手持ち/固定判定メソッド

「判定方法詳細」に記載してあることをソースコードに書き換えただけです。
このメソッドは、手持ちと判定するとtrue、固定と判定するとfalseを返します。

3軸分あるのでちょっと長く感じるかもしれませんが、

- メソッド前半のループで、判定対象期間のデータの中の最大値と最小値を選出する
- メソッド後半で具体的な判定している

という、シンプルな振る舞いです。

履歴を使い終わった後、引数の値によって履歴をクリアできるようにしてありますが、念のために入れた仕掛けで今回は利用していません。

```judgeCameraMoving.java
    public boolean judge( boolean eraseHistory ){
        long judgeMilli = System.currentTimeMillis();
        Log.d(TAG, "Judge Time = " + String.valueOf(judgeMilli) );

        boolean judgeResult = false;

        double maxYaw = 0.0;
        double minYaw = 0.0;
        double maxPitch = 0.0;
        double minPitch = 0.0;
        double maxRoll = 0.0;
        double minRoll = 0.0;
        boolean maxMinSet=false;

        Log.d(TAG, "No, time, Yaw, Pitch, Roll");
        for(int i=0; i<history.size(); i++) {
            long diffMilli = judgeMilli - historyMilli.get(i);

            Log.d(TAG, String.valueOf(i)
                    + ", " + String.valueOf( diffMilli )
                    + ", " + String.valueOf( history.get(i).get(0))
                    + ", " + String.valueOf( history.get(i).get(1))
                    + ", " + String.valueOf( history.get(i).get(2))
            );

            // 判定期間内のデータだけを取り扱う。
            if ( (BUTTON_OPERAT_THRESHOLD < diffMilli ) && (diffMilli <= JUDGEMENT_PREIOD)) {
                if (maxMinSet==false) {
                    maxYaw = history.get(i).get(0);
                    minYaw = history.get(i).get(0);
                    maxPitch = history.get(i).get(1);
                    minPitch = history.get(i).get(1);
                    maxRoll = history.get(i).get(2);
                    minRoll = history.get(i).get(2);
                    maxMinSet = true;
                } else {
                    if ( maxYaw < history.get(i).get(0) ) {
                        maxYaw = history.get(i).get(0);
                    }
                    if ( minYaw > history.get(i).get(0) ) {
                        minYaw = history.get(i).get(0);
                    }

                    if ( maxPitch < history.get(i).get(1) ) {
                        maxPitch = history.get(i).get(1);
                    }
                    if ( minPitch > history.get(i).get(1) ) {
                        minPitch = history.get(i).get(1);
                    }

                    if ( maxRoll < history.get(i).get(2) ) {
                        maxRoll = history.get(i).get(2);
                    }
                    if ( minRoll > history.get(i).get(2) ) {
                        minRoll = history.get(i).get(2);
                    }
                }
            }
        }

        if (eraseHistory) {
            history.clear();
            historyMilli.clear();
        }

        double judgeValueYaw = maxYaw-minYaw;
        double judgeValuePitch = maxPitch-minPitch;
        double judgeValueRoll = maxRoll-minRoll;
        Log.d(TAG, "Yaw   : max-min=" + String.valueOf(judgeValueYaw) + "max=" + String.valueOf(maxYaw) + ", min=" + String.valueOf(minYaw) );
        Log.d(TAG, "Pitch : max-min=" + String.valueOf(judgeValuePitch) + "max=" + String.valueOf(maxPitch) + ", min=" + String.valueOf(minPitch) );
        Log.d(TAG, "Roll  : max-min=" + String.valueOf(judgeValueRoll) + "max=" + String.valueOf(maxRoll) + ", min=" + String.valueOf(minRoll) );

        // Moving = true, Fix = false
        if ( judgeValueYaw > MOVE_THRESHOLD_YAW ) {
            judgeResult = true;
        }
        if ( judgeValuePitch > MOVE_THRESHOLD_PITCH ) {
            judgeResult = true;
        }
        if ( judgeValueRoll > MOVE_THRESHOLD_ROLL ) {
            judgeResult = true;
        }

        return judgeResult;
    }
```


### MainActivity.java

プログラム全体の構造を記述してあります。
元としたプロジェクトの不要部分を削って、今回のプラグインに必要なことを書き足した程度の差異です。
手持ち/固定判定に関わるところは以下だけです。

- 周期動作のスレッドの先頭で、データ蓄積のメソッドを呼んでいます。

```MainActivity.java
    cameraMove.setSensorHistory(attitude.getDegAzimath(), attitude.getDegPitch(), attitude.getDegRoll());
```


- 手持ち/固定判定は、判定したいタイミングで judgeメソッドを呼び出すだけです。今回は、周期動作と撮影ボタンが押されたときに呼び出しています。

```MainActivity.java
    if ( cameraMove.judge(false) ) {
        //手持ち(動いている)
    } else {
        //固定
    }
```


### 手持ち/固定判定以外のポイント

Instant Night Snapプラグインは、手持ち/固定判定以外にも、簡単操作を実現するためにいくらかの仕掛けが施してあります。代表的なところは以下のとおりです。細かな説明は割愛します。

- セルフタイマーの取り扱い<br>本体ボタン操作で撮影指示がされ、判定結果が固定であったとき、セルフタイマー5秒が発動するよう作りこんであります。撮影シーケンスの組みやすさ（変更のしやすさ）と、通信をせず動作できると反応がよいという観点から、WebAPIのセルフタイマーを利用せず、MainActivity.javaのselfTimerメソッドを作って利用しています。
- 機種別に夜景向けの撮影設定を変えている点の取り扱い<br>InstantNightSnapTask.javaに集約してあります。機種判定は、カメラ内部で動作するプログラムである利点を生かし、WebUIを利用していません。「Build.MODEL」というところに機種名が固定値として入っているのです。THETAプラグインの小技としてお見知りおきを。

```MainActivity.java
　new InstantNightSnapTask(true, Build.MODEL).execute();
```


## まとめ

念押しのためにもう一度。本記事の手持ち/固定判定方法は簡易的なものです。
簡易的でも、かなり使える判定結果になるよということを紹介させて頂きました。

より良い判定アルゴをプラグインに入れ込むことも可能です。
センサー生データをとることもできますので、「平滑化（ノイズ除去）に工夫をいれたい」などの事情がある場合には、ご自身で色々とトライしてみてください！


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

THETAプラグインをご存じない方は[こちら](https://github.com/ricohapi/theta-api-specs/blob/main/core/products/theta-plugin.md)をご覧ください。
パートナープログラムへの登録方法は[こちら](https://qiita.com/mShiiina/items/55d98f366e650ca42251)にもまとめてあります。
Qiitaの[RICOH THETAプラグイン開発者コミュニティ TOPページ「About」](https://qiita.com/organizations/theta-plugin)に便利な記事リンク集もあります。
興味を持たれた方は[Twitter](https://twitter.com/thetaplugin)のフォローと[THETAプラグイン開発者コミュニティ(Slack)](https://join.slack.com/t/thetadeveloper/shared_invite/enQtNzcxODI2MTk2ODg0LWY5YmU2ZTIxNGYwNzlmNWZjMWMyMWFlMzY3ZmQ3NGU3ZjZiZjI0Njk4MGIxMDU5NDJiMzQ4Y2FkNDEzYmFlNGU)への参加もよろしくおねがいします｡