はじめに
リコーの @KA-2 です。
弊社ではRICOH THETAという全周囲360度撮れるカメラを出しています。
RICOH THETA VやRICOH THETA Z1は、OSにAndroidを採用しています。Androidアプリを作る感覚でTHETAをカスタマイズすることもでき、そのカスタマイズ機能を「プラグイン」と呼んでいます(詳細は本記事の末尾を参照)。
今回は、THETAプラグインでライブプリビューを扱いやすくするの続編です。
上記記事の末尾で予告したとおり、THETAのライブプリビューにTensorFlow LiteのObject Detectionをかけ、その結果を利用します。
利用といっても今回はひとまず、「THETAの姿勢基準(local座標系)で、特定物を横方向にトラッキングする」というところまでを説明します。
◤Qiita記事公開◢
— THETAプラグイン開発者コミュニティ (@thetaplugin) April 6, 2020
THETAプラグインで 連続フレームにTensorFlowLiteのObjectDetectionをかけ、横360°バナナを追跡するプラグインの解説記事をリリース!
動画後半は予告映像、テスト中の全方位トラッキングも。#thetaplugin #TensorFlowLite #ObjectDetectionhttps://t.co/EumcDEkA49 pic.twitter.com/XWQ0UMk1Bv
「特定物を全方位トラッキングする」こともできているのですが、こちらは私が執筆する次回記事としますね。というのも「equirectangularの回転処理」「THETAの姿勢情報利用」という要素が含まれており、他のTHETAプラグイン作成にも役立つ事項なので詳しく解説したいためです。
次回記事も含めて参照くださるとありがたいです。振る舞いだけチラ見せすると以下な感じです。
【2020/4/24 追記】
NDKでEquirectangularの回転処理をして物体を全方位トラッキングする
に続編をリリースしました!
それではまず、「THETAの映像にTensorFlow LiteのObject Detectionをかける」について解説します。
TensorFlow Lite のObject Detectionについて
私、遠い昔にNeural Network(「機械学習」でくくられるカテゴリの一角)の経験はありますがTensorFlowに触れるのは初めてです。もしかしたら、幾らか表現や用語がおかしいことがあるかもしれませんがご容赦を。
ざっくりと今回利用するモノゴトの概要を説明しておきます。
TensorFlow について
Google社が提供している機械学習を簡単に利用するためのライブラリーやツールなどの総合名称だと理解しています。「学習」と「推論」のどちらも行え、扱える「モデル」の形式も多め、複数のOSやコンピュータ言語に対応しています。
特に学習にについては多量のデータを扱いますので、マシンパワーがあるほど有利です。
TensorFlow Liteについて
TensorFlowをモバイル機器(iOSやAndroidなど)向けに最適化した機械学習のライブラリ&ツール群です。TensorFlowと比べると、機能が制限されている点が多くあります。
モバイル機器向けTensorFlowとしては、TensorFlow Liteより前にTensorFlow Mobileというものが存在していました(今も利用できますが・・・これから存在が薄れていきます)。
TensorFlow Mobileが、「学習」と「推論」のどちらも行えるのに対し、TensorFlow Liteは「推論」だけが行えます。「モデル」のデータ形式も変わりました(いくらか制限はあるようですが、データ形式の変換ツールもあります)。学習はフルスペックなTensorFlowを利用することになります。
学習はマシンパワーがあっても時間のかかる作業で、推論は小型かつ廉価な機器を使い、ネットワーク接続の有無によらず行いたくなります。ツールがこのように進化するのは自然な流れだと思います。
現在 Mobile から Lite への移行期間ということもあり、Liteは完全ではありません(徐々に充実しつつあるようです)。
2020年4月初旬時点、TensorFlow Liteのページには、8種類の例(学習済みモデル込み)が公開されています。
モデルの設計を自身で行えば応用範囲はもっと広がりますし、TensorFlow Liteの学習済みモデルが公開されている他の事例もあります。
(例えば、2019年夏頃に話題となったGoogleのHand Tracking(ハンドサイン認識)もTensorFlow Liteのモデルを利用しています。荒い認識 → 詳細認識と2段階行います。Media Pipeという仕組みが使えないとフレームレートは遅くなるものの、おそらくTHETAでも動くはずです。あとでトライしたいです。。。)
モデルには「浮動小数点モデル」だけでなく「量子化モデル(整数と表現されますが、固定小数点と思ってもよさそう)」が扱えるようになりました。量子化により、モデルのデータサイズを小さくでき、かつ、演算速度の向上も望めますが、計算精度は劣ることになります。
実施する内容次第ではありますが、スマートフォンと比べると遥かに演算能力が低いマイクロコンピューターの類でも、TensorFlow Liteが利用できるケースがでてきています。
TensorFlow LiteのObject Detection
詳しくはTensorFlow Lite Object Detectionのページを参照してください。
Android用のサンプルコードはこちらです。
1024×512 pixelのEquirectangular形式画像の中央部300×300pixelの範囲(緑枠)にTensorFlow LiteのObject Detectionをかけた例が以下となります。
さらっと言うと「"単一の画像内"の認識結果を"複数個"得られる」という処理です。
もう少し具体的に条件なども含めて羅列すると以下になります。
- 80品目(80クラス)の識別が行えます。学習済みモデルと共に配布されているラベルファイル(テキストファイル)を参照すると具体的な品目名称が分かります
- 入力画像は 300×300 pixelに制限されています(イチからモデルを設計すれば制限は変えられます)
- 1回の処理で最大10個までの認識結果が得られます
- 1品目の認識結果は、Title(認識した品目の名称)、confidence(信頼度:最大値が1.0、最小値が0.0)、Location(認識した品目の範囲=top,left,bottom,right座標)です
今のところ量子化済みモデルのみが公開されているようです。
量子化済みモデルは、(現在のTensorFlow Liteでは)GPUが使えません。CPUで動作させるときには、マルチコアを利用することが可能です。
転移学習により認識できるものを増やしたり認識率を上げたりもできますが、今回は公開されている学習済みモデルから推論を行うところのみを実施します。
(このモデルは転移学習の初期値として公開されているフシがあるので、上の例ではGoogleが説明しているとおり、洋梨が「Apple」と認識されたり、りんごが「Orange」と認識されたりしています。うん、ちゃんとTHETAでも動いている。。。)
THETA Plug-in SDKにTensorFlow Liteの動作環境を整える
今回はTensorFlow LiteのObject Detectionに限定します。
TensorFlow Liteの他学習モデル利用する際には、各自で手順をカスタマイズしてください。
作業のベースとなるプロジェクトファイル一式は以下記事で解説したものです。
【2020/04/24追記】
本記事の続編【NDKでEquirectangularの回転処理をして物体を全方位トラッキングする】のリポジトリは今回の要素も含んでいます。ちょっと作業が楽になるのでご参考まで。
今回は、ライブプリビューの連続フレーム(JPEG)をBitmapにしたデータに対して物体認識を行います。
CameraAPIで取得した連続フレームにも応用できますが、その場合には、必要な事項を各自で適用してください。(文章にすると長くみえますが、わりと簡単ですのでご安心ください)
build.gradle(Module:app)の設定
以下2つの定義を追加しています。
- 「aaptOptions」で、モデルファイルを圧縮しないようにしています
- 「implementation」の定義を
2行3行書き加えています
★2020/10/23:新しいGoogleのコードに対応するために追加
android {
~省略~
aaptOptions {
noCompress "tflite"
}
}
dependencies {
~省略~
implementation 'org.tensorflow:tensorflow-lite:0.0.0-nightly'
implementation 'org.tensorflow:tensorflow-lite-gpu:0.0.0-nightly'
implementation 'org.tensorflow:tensorflow-lite-metadata:0.0.0-nightly' //★2020/10/23:新しいGoogleのコードに対応するために追加
}
学習済みモデルとラベルの配置
(参考)古い形式のファイル取得手順
TensorFlow Lite:Object Detectionのページの「Download starter model and labels」のボタンをクリックしてzipファイルをダウンロードしてください。
記事執筆時点のファイル名は「coco_ssd_mobilenet_v1_1.0_quant_2018_06_29.zip」です。
ダウンロードしたzipファイルを解凍すると以下2ファイルが得られます。
ファイル名 | 説明 |
---|---|
detect.tflite | 物体認識の学習済みモデルです |
labelmap.txt | 学習済みモデルのラベル(識別可能な80品目の名称)定義ファイルです |
その2つのファイルをどちらもassetsフォルダーに配置してください。
以下二つのファイルをダウンロードします
ファイル名 | 説明 |
---|---|
ssd_mobilenet_v1_1_metadata_1.tflite (執筆時点のファイル名です) |
新形式の物体認識の学習済みモデルです メタデータを含める形式ですがラベルは含まれていないようです |
labelmap.txt | 学習済みモデルのラベル(識別可能な80品目の名称)定義ファイルです |
「ssd_mobilenet_v1_1_metadata_1.tflite」は、TensorFlow Lite:Object Detectionのページの「メタデータを含むスターターモデルをダウンロードする」のボタンをクリックしてダウンロードしてください。(※メタデータを含められる形式ですが、ラベルは含まれていないようです。公式サンプルコードがlabelmap.txtを使用していますので、同じ手順としてあります)
labelmap.txtは表中のリンク(GitHubに公開されている公式サンプルコード)から取得してください。「(参考)古い形式のファイル取得手順」のzipファイルに含まれているものと同じファイルですので、そちらから取得してもOKです。
ファイルを取得したら、どちらもassetsフォルダに配置してください。
物体検出クラスの配置
Android用のサンプルコードから、以下の32ファイルを取得します。
ファイル名 | 説明 |
---|---|
Detector.java ★2020/10/23ファイルの場所と名称が変わりましたが内容は同じです |
物体認識をするクラスのインターフェースが定義されています |
TensorFlowObjectDetectionAPIModel.java ★2020/10/23ファイルの場所と内容が変わりました。ラベルを内包する学習データ用のコードです。 |
物体認識をするクラスの実処理が記述されています |
★2020/10/23 不要になりました |
取得したファイルは、配置の仕方にあわせpackageの定義を書き換えてください。
以下は、ベースとしたプロジェクトの「~\app\src\main\java\com\theta360\extendedpreview」に32つのファイルを配置した場合の例です。この場合、packageの定義は32ファイル共に以下となります。
package com.theta360.extendedpreview;
今回のファイル配置の場合、TFLiteObjectDetectionAPIModel.javaの以下importは削除、または、コメントアウトしてください。ファイル配置が異なる場合は記述が変わります。
~~```TFLiteObjectDetectionAPIModel.java
//import org.tensorflow.lite.examples.detection.env.Logger;
★2020/10/23 追記
2020年10月中旬、Googleは、ラベルファイルをメタデータとして内包する形式の学習データに対応するためのサンプルコードに変更しましたが、ObjectDetectionの学習データは変わっていません。以下のコードを書き換えて、どちらの学習データにも対応できるようにします。
※変更前※
```TFLiteObjectDetectionAPIModel.java
MetadataExtractor metadata = new MetadataExtractor(modelFile);
try (BufferedReader br =
new BufferedReader(
new InputStreamReader(
metadata.getAssociatedFile(labelFilename), Charset.defaultCharset()))) {
String line;
while ((line = br.readLine()) != null) {
Log.w(TAG, line);
d.labels.add(line);
}
}
古い形式のモデルファイルを与えると「metadata.getAssociatedFile(labelFilename)」のところでエラーが発生します。
GitHubにissueとして登録されていましたが、2020/10/23現在未解決です。
しかし、TensorFlow Liteのドキュメントに以下の記述がありますので
それに従った変更を加え解決します。
※変更後※
MetadataExtractor metadata = new MetadataExtractor(modelFile);
// "2. Describe the issue" の対策
//https://github.com/tensorflow/models/issues/9341
BufferedReader br = null;
if( metadata.hasMetadata() ) {
Log.w(TAG, "Has Metadata");
br =new BufferedReader(
new InputStreamReader(
metadata.getAssociatedFile(labelFilename), Charset.defaultCharset()));
} else {
Log.w(TAG, "No Metadata");
InputStream labelsInput = context.getAssets().open(labelFilename);
br = new BufferedReader(new InputStreamReader(labelsInput));
}
String line;
boolean firstLine = true;
while ((line = br.readLine()) != null) {
if ( firstLine ) {
firstLine=false;
} else {
Log.w(TAG, line);
d.labels.add(line);
}
}
br.close();
d.labels.add(line)でラベルファイルを登録する際、1行目を読み捨てにしています。
Interpreterクラスの振る舞いも新しくなっているようで、これをしないと認識結果のラベルが1つズレたので現物あわせしました。対症療法です。
(Googleによる問題解決がなされた場合には、また記事を修正しますが、当面このままかな。モデルファイルにラベルが含まれないうちはそのままと思われます。)
ここまで準備すると、MainActivity.javaから物体認識の処理を呼び出すことができます。
補足:コア数の指定やGPUデリゲート
前述のとおり、今回利用している学習済みモデルは量子化されているので、現在はGPUが利用できずCPUのみを利用することになります。その際、演算時に利用する最大コア数を「TFLiteObjectDetectionAPIModel.java」に定義されている以下の数値で指定できます。
// Number of threads in the java app
private static final int NUM_THREADS = 4;
こちらの記事でも紹介しているとおり、THETA V および THETA Z1のCPUはoctacoreです。
1から8まで順に数値を振って処理速度を確認してみてみましたが、4で処理速度が頭打ちしました。5以上の数値を指定しても意味がないようです。OSのサービスや撮影アプリに加え、この記事で説明しているアプリ内でも「MainActivity」「MOTION JPEGの常時読み取り」「WebUI用のWebサーバー」「物体認識を行うスレッド」と複数のプロセスが同時に動作していますので無理もありません。
処理速度が遅くなってもよいからCPUへの負荷や発熱を減らしたい場合には1~3の数値を試してみてください。
今回はサンプルファイルのまま動作させます。
少々余談になりますが、もしも物体認識の浮動小数点モデルが入手でき(たとえば他の物体認識モデルのデータ形式を変換する等)、それをGPUで動作させたい(GPUデリゲートを利用したい)場合には TensorFlowObjectDetectionAPIModel.java にちょっと手を加えなければいけないと思いますのでご注意ください。今回は説明を割愛します。
MainActivity.java の修正
本章末尾に折りたたんで全文を掲載しておきます。
大切そうなポイントについては以下で説明しますので必要に応じて参照してください。
事前に「THETAプラグインでライブプリビューを扱いやすくする」の内容も理解しておいてください。
物体検出クラスの呼び出し
今回のメインディッシュです。わりと簡単です。
固定値の定義
モデルファイル名、ラベルファイル名、画像1辺のサイズ(単位はpixel,正方形です)、モデルの量子化有無を定義しています。
//private static final String TF_OD_API_MODEL_FILE = "detect.tflite";//OLD
private static final String TF_OD_API_MODEL_FILE = "ssd_mobilenet_v1_1_metadata_1.tflite";
//private static final String TF_OD_API_LABELS_FILE = "file:///android_asset/labelmap.txt"; //OLD
private static final String TF_OD_API_LABELS_FILE = "labelmap.txt";
private static final int TF_OD_API_INPUT_SIZE = 300;
private static final boolean TF_OD_API_IS_QUANTIZED = true;
★2020/10/23 TF_OD_API_LABELS_FILE の定義にパスを含めなくなりました。
★2020/10/23 TF_OD_API_MODEL_FILE は公開プロジェクトに新旧両方を置いてあります。
初期化
ClassifierDetectorクラスのオブジェクトをdetectorという名称で生成しています。
///////////////////////////////////////////////////////////////////////
// TFLite Initial detector
///////////////////////////////////////////////////////////////////////
Detector detector=null;
try {
Log.d(TAG, "### TFLite Initial detector ###");
detector = TFLiteObjectDetectionAPIModel.create(
//getAssets(), //OLD
getApplicationContext(),
TF_OD_API_MODEL_FILE,
TF_OD_API_LABELS_FILE,
TF_OD_API_INPUT_SIZE,
TF_OD_API_IS_QUANTIZED);
} catch (final IOException e) {
e.printStackTrace();
Log.d(TAG, "IOException:" + e);
mFinished = true;
}
★2020/10/23 TFLiteObjectDetectionAPIModel.createの第一引数が変わりました。
★2020/10/23 ClassifierクラスがDetectorクラスに名称変更されました。
認識処理
今回は、事前に300×300 pixelに切り出したBitmapを与え物体認識処理を行っています。
目的によっては「黒帯つきで300×300 pixelにリサイズして画像全体に認識処理をかける」とか「縦に引き伸ばした300×300 pixelの画像全体に認識処理をかける」ということも考えられますが、、、画素数が減るほどに小さなもの(遠くのもの)は認識されにくくなりますし、縦横比率を変えると元々歪んでいる映像がさらに変形します。今回利用しているモデルはそのようなデータで学習されていませんので、あまりお勧めできません。
(とはいえ、ある程度動作してしまうのが機械学習の不思議さではあります)
///////////////////////////////////////////////////////////////////////
// TFLite Object detection
///////////////////////////////////////////////////////////////////////
final List<Detector.Recognition> results = detector.recognizeImage(cropBitmap);
Log.d(TAG, "### TFLite Object detection [result] ###");
for (final Detector.Recognition result : results) {
drawDetectResult(result, resultCanvas, mPaint, offsetX, offsetY);
}
★2020/10/23 ClassifierクラスがDetectorクラスに名称変更されました。
結果はClassifierDetector.RecognitionクラスのList形式で、recognizeImageメソッドの戻り値として取得できます。Listの数だけループさせながら結果を描画するdrawDetectResultメソッドを呼び出しています。
検出結果の利用方法
drawDetectResultメソッドを参照してください。
TensorFlow LiteのObject Detectionのページで説明されている通り、confidence値でカットオフしたほうがよいです。今回は 0.54以上の結果を描画するようにしてみました。お好みに応じて値を変えてください。
double confidence = Double.valueOf(inResult.getConfidence());
if ( confidence >= 0.54 ) {
Log.d(TAG, "[result] Title:" + inResult.getTitle());
Log.d(TAG, "[result] Confidence:" + inResult.getConfidence());
Log.d(TAG, "[result] Location:" + inResult.getLocation());
~省略~
}
Bitmapクラスで Equirectangularの横方向回転をする
折角の全天球(全方位)画像が得られているのに、物体認識できるエリアを固定してしまうのは勿体ないので、ひとまず、バナナを横方向にトラッキングする処理をしています。
横方向の回転であれば、特殊なライブラリなど使わずに行えるのを知って頂きたいためです。
図示すると以下のような処理をしています。
コードを記述する際のポイントは以下2点です。
- BitmapをinMutable=trueで生成し編集可能にしておく
- CanvasクラスとPaintクラスを使って切り貼りを行う
詳しくは、rotationYawメソッドを参照してください。
本格的なEquirectangularの回転処理については、私が執筆する次回記事で解説する予定です。
OLEDの表示
以下のような表示を行っています。
詳しくは、displayResultメソッドを参照してください。
THETA Vで今回のサンプルを動かす場合には無駄な処理になります。コメントアウトすると僅かながら処理負荷の低減になります。
高負荷処理のタスク優先度
「THREAD_PRIORITY_わかりやすい名称」の形式で定義されているタスク優先度についてはこちらを参照してください。一般の方のまとめですが、こちらのページの説明もわかりやすいです。
数値が小さいほど優先度が高く、数値が大きいほど優先度が低くなります。
imageProcessingThreadの先頭では、以下のようにタスク優先度を指定しています。
その他は、指定をせず初期状態のままとしています。
public void imageProcessingThread() {
new Thread(new Runnable() {
@Override
public void run() {
~省略~
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_BACKGROUND);
~省略~
}
}).start();
}
これは、アプリケーション全体の振る舞いを考慮して設定しています。
定常状態(WebUIは立ち上げている)で動作し続けている処理は以下の3種類です。
処理名称 | 処理概要 | 指定なしの優先度 | 変更後の優先度 |
---|---|---|---|
GetLiveViewTask | MOTION JPEG読み取り | THREAD_PRIORITY_BACKGROUND(10) | 変更なし |
imageProcessingThread | 連続フレームの物体認識 | THREAD_PRIORITY_DEFAULT(0) | THREAD_PRIORITY_BACKGROUND(10) |
WebServer | WebUIからのコマンド実行 (主に表示画像を返す) |
THREAD_PRIORITY_DEFAULT(0) | 変更なし |
WebServerの処理は、最新の認識結果(画像)を送るだけの短い処理ですので、他の2つのタスクより優先度が高いままで問題ありません。
GetLiveViewTaskは、ひたすらMOTION JPEGを受信しながらフレーム分割をしており、滞ると困る処理になります。doInBackgroundメソッドで動作しており、優先度はTHREAD_PRIORITY_BACKGROUNDです。
このときに、imageProcessingThreadの優先度がTHREAD_PRIORITY_DEFAULTのままではMOTION JPEGの受信を邪魔していました。このためTHREAD_PRIORITY_BACKGROUNDを設定したという経緯です。
「処理時間を要する重い処理は優先度を下げる」というセオリーとおりの対応で収まりました。
ソースコード(MainActivity.java)全文
ソースコード全文を折りたたんで掲載しておきます。
こちらをクリックして開いてください
/**
* Copyright 2018 Ricoh Company, Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.theta360.extendedpreview;
import android.content.Context;
import android.content.pm.ActivityInfo;
import android.os.AsyncTask;
import android.util.Log;
import android.os.Bundle;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.List;
import android.view.KeyEvent;
import com.theta360.pluginlibrary.activity.PluginActivity;
import com.theta360.pluginlibrary.callback.KeyCallback;
import com.theta360.pluginlibrary.receiver.KeyReceiver;
import com.theta360.pluginapplication.task.TakePictureTask;
import com.theta360.pluginapplication.task.TakePictureTask.Callback;
import com.theta360.pluginapplication.task.GetLiveViewTask;
import com.theta360.pluginapplication.task.MjisTimeOutTask;
import com.theta360.pluginapplication.view.MJpegInputStream;
import com.theta360.pluginapplication.oled.Oled;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Color;
import android.graphics.RectF;
public class MainActivity extends PluginActivity {
private static final String TAG = "ExtendedPreview";
//Button Resorce
private boolean onKeyDownModeButton = false;
private boolean onKeyLongPressWlan = false;
private boolean onKeyLongPressFn = false;
//Preview Resorce
private int previewFormatNo;
GetLiveViewTask mGetLiveViewTask;
private byte[] latestLvFrame;
private byte[] latestFrame_Result;
//Preview Timeout Resorce
private static final long FRAME_READ_TIMEOUT_MSEC = 1000;
MjisTimeOutTask mTimeOutTask;
MJpegInputStream mjis;
//WebServer Resorce
private Context context;
private WebServer webServer;
//OLED Dislay Resorce
Oled oledDisplay = null;
private boolean mFinished;
private TakePictureTask.Callback mTakePictureTaskCallback = new Callback() {
@Override
public void onTakePicture(String fileUrl) {
startPreview(mGetLiveViewTaskCallback, previewFormatNo);
}
};
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
// Set enable to close by pluginlibrary, If you set false, please call close() after finishing your end processing.
setAutoClose(true);
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
//init OLED
oledDisplay = new Oled(getApplicationContext());
oledDisplay.brightness(100);
oledDisplay.clear(oledDisplay.black);
oledDisplay.draw();
// Set a callback when a button operation event is acquired.
setKeyCallback(new KeyCallback() {
@Override
public void onKeyDown(int keyCode, KeyEvent event) {
switch (keyCode) {
case KeyReceiver.KEYCODE_CAMERA :
stopPreview();
new TakePictureTask(mTakePictureTaskCallback).execute();
break;
case KeyReceiver.KEYCODE_MEDIA_RECORD :
// Disable onKeyUp of startup operation.
onKeyDownModeButton = true;
break;
default:
break;
}
}
@Override
public void onKeyUp(int keyCode, KeyEvent event) {
switch (keyCode) {
case KeyReceiver.KEYCODE_WLAN_ON_OFF :
if (onKeyLongPressWlan) {
onKeyLongPressWlan=false;
} else {
//reset Object detection dir
lastDetectYaw = equiW/2; // Front
lastDetectPitch = equiH/2;
}
break;
case KeyReceiver.KEYCODE_MEDIA_RECORD :
if (onKeyDownModeButton) {
if (mGetLiveViewTask!=null) {
stopPreview();
} else {
startPreview(mGetLiveViewTaskCallback, previewFormatNo);
}
onKeyDownModeButton = false;
}
break;
case KeyEvent.KEYCODE_FUNCTION :
if (onKeyLongPressFn) {
onKeyLongPressFn=false;
} else {
//reset Object detection dir
lastDetectYaw = 0/*(equiW/2)*/; //Back
lastDetectPitch = equiH/2;
}
break;
default:
break;
}
}
@Override
public void onKeyLongPress(int keyCode, KeyEvent event) {
switch (keyCode) {
case KeyReceiver.KEYCODE_WLAN_ON_OFF:
onKeyLongPressWlan=true;
//NOP : KEYCODE_WLAN_ON_OFF
break;
case KeyEvent.KEYCODE_FUNCTION :
onKeyLongPressFn=true;
//NOP : KEYCODE_FUNCTION
break;
default:
break;
}
}
});
this.context = getApplicationContext();
this.webServer = new WebServer(this.context, mWebServerCallback);
try {
this.webServer.start();
} catch (IOException e) {
e.printStackTrace();
}
}
@Override
protected void onResume() {
super.onResume();
if (isApConnected()) {
}
//Start LivePreview
previewFormatNo = GetLiveViewTask.FORMAT_NO_1024_8FPS;
startPreview(mGetLiveViewTaskCallback, previewFormatNo);
//Start OLED thread
mFinished = false;
imageProcessingThread();
}
@Override
protected void onPause() {
// Do end processing
//close();
//Stop Web server
this.webServer.stop();
//Stop LivePreview
stopPreview();
//Stop OLED thread
mFinished = true;
super.onPause();
}
@Override
protected void onDestroy() {
super.onDestroy();
if (this.webServer != null) {
this.webServer.stop();
}
}
private void startPreview(GetLiveViewTask.Callback callback, int formatNo){
if (mGetLiveViewTask!=null) {
stopPreview();
try {
Thread.sleep(400);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
mGetLiveViewTask = new GetLiveViewTask(callback, formatNo);
mGetLiveViewTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
private void stopPreview(){
//At the intended stop, timeout monitoring also stops.
if (mTimeOutTask!=null) {
mTimeOutTask.cancel(false);
mTimeOutTask=null;
}
if (mGetLiveViewTask!=null) {
mGetLiveViewTask.cancel(false);
mGetLiveViewTask = null;
}
}
/**
* GetLiveViewTask Callback.
*/
private GetLiveViewTask.Callback mGetLiveViewTaskCallback = new GetLiveViewTask.Callback() {
@Override
public void onGetResorce(MJpegInputStream inMjis) {
mjis = inMjis;
}
@Override
public void onLivePreviewFrame(byte[] previewByteArray) {
latestLvFrame = previewByteArray;
//Update timeout monitor
if (mTimeOutTask!=null) {
mTimeOutTask.cancel(false);
mTimeOutTask=null;
}
mTimeOutTask = new MjisTimeOutTask(mMjisTimeOutTaskCallback, FRAME_READ_TIMEOUT_MSEC);
mTimeOutTask.execute();
}
@Override
public void onCancelled(Boolean inTimeoutOccurred) {
mGetLiveViewTask = null;
latestLvFrame = null;
if (inTimeoutOccurred) {
startPreview(mGetLiveViewTaskCallback, previewFormatNo);
}
}
};
/**
* MjisTimeOutTask Callback.
*/
private MjisTimeOutTask.Callback mMjisTimeOutTaskCallback = new MjisTimeOutTask.Callback() {
@Override
public void onTimeoutExec(){
if (mjis!=null) {
try {
// Force an IOException to `mjis.readMJpegFrame()' in GetLiveViewTask()
mjis.close();
} catch (IOException e) {
Log.d(TAG, "[timeout] mjis.close() IOException");
e.printStackTrace();
}
mjis=null;
}
}
};
/**
* WebServer Callback.
*/
private WebServer.Callback mWebServerCallback = new WebServer.Callback() {
@Override
public void execStartPreview(int format) {
previewFormatNo = format;
startPreview(mGetLiveViewTaskCallback, format);
}
@Override
public void execStopPreview() {
stopPreview();
}
@Override
public boolean execGetPreviewStat() {
if (mGetLiveViewTask==null) {
return false;
} else {
return true;
}
}
@Override
public byte[] getLatestFrame() {
//return latestLvFrame;
return latestFrame_Result;
}
};
//==============================================================
// Image processing Thread
//==============================================================
//private static final String TF_OD_API_MODEL_FILE = "detect.tflite";//OLD
private static final String TF_OD_API_MODEL_FILE = "ssd_mobilenet_v1_1_metadata_1.tflite";
//private static final String TF_OD_API_LABELS_FILE = "file:///android_asset/labelmap.txt"; //OLD
private static final String TF_OD_API_LABELS_FILE = "labelmap.txt";
private static final int TF_OD_API_INPUT_SIZE = 300;
private static final boolean TF_OD_API_IS_QUANTIZED = true;
//Object Detection dir
private int equiW = 0;
private int equiH = 0;
boolean detectFlag = false;
private int lastDetectYaw=512;
private int lastDetectPitch=256;
public void imageProcessingThread() {
new Thread(new Runnable() {
@Override
public void run() {
int outFps=0;
long startTime = System.currentTimeMillis();
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_BACKGROUND);
///////////////////////////////////////////////////////////////////////
// TFLite Initial detector
///////////////////////////////////////////////////////////////////////
Detector detector=null;
try {
Log.d(TAG, "### TFLite Initial detector ###");
detector = TFLiteObjectDetectionAPIModel.create(
//getAssets(), //OLD
getApplicationContext(),
TF_OD_API_MODEL_FILE,
TF_OD_API_LABELS_FILE,
TF_OD_API_INPUT_SIZE,
TF_OD_API_IS_QUANTIZED);
} catch (final IOException e) {
e.printStackTrace();
Log.d(TAG, "IOException:" + e);
mFinished = true;
}
//set detection area offset
int offsetX=0;
int offsetY=0;
while (mFinished == false) {
detectFlag = false;
//set detection area offset
if ( (previewFormatNo==GetLiveViewTask.FORMAT_NO_640_8FPS) ||
(previewFormatNo==GetLiveViewTask.FORMAT_NO_640_30FPS) ) {
offsetX = 170;
offsetY = 10;
equiW = 640;
} else if ( (previewFormatNo==GetLiveViewTask.FORMAT_NO_1024_8FPS) ||
(previewFormatNo==GetLiveViewTask.FORMAT_NO_1024_30FPS) ) {
offsetX = 362;
offsetY = 106;
equiW = 1024;
} else if ( (previewFormatNo==GetLiveViewTask.FORMAT_NO_1920_8FPS) ) {
offsetX = 810;
offsetY = 330;
equiW = 1920;
} else {
offsetX = 170;
offsetY = 10;
equiW = 640;
}
equiH = equiW/2;
byte[] jpegFrame = latestLvFrame;
if ( jpegFrame != null ) {
//JPEG -> Bitmap
BitmapFactory.Options options = new BitmapFactory.Options();
options.inMutable = true;
Bitmap bitmap = BitmapFactory.decodeByteArray(jpegFrame, 0, jpegFrame.length, options);
//rotation yaw
bitmap = rotationYaw(lastDetectYaw, equiW, bitmap);
//crop detect area
Bitmap cropBitmap = Bitmap.createBitmap(bitmap, offsetX, offsetY, TF_OD_API_INPUT_SIZE, TF_OD_API_INPUT_SIZE, null, true);
//make result canvas
Canvas resultCanvas = new Canvas(bitmap);
Paint mPaint = new Paint();
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setColor( Color.GREEN );
resultCanvas.drawRect(offsetX, offsetY, offsetX+TF_OD_API_INPUT_SIZE, offsetY+TF_OD_API_INPUT_SIZE, mPaint);
///////////////////////////////////////////////////////////////////////
// TFLite Object detection
///////////////////////////////////////////////////////////////////////
final List<Detector.Recognition> results = detector.recognizeImage(cropBitmap);
Log.d(TAG, "### TFLite Object detection [result] ###");
for (final Detector.Recognition result : results) {
drawDetectResult(result, resultCanvas, mPaint, offsetX, offsetY);
}
//set result image
ByteArrayOutputStream baos = new ByteArrayOutputStream();
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, baos);
latestFrame_Result = baos.toByteArray();
outFps++;
} else {
try {
Thread.sleep(33);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//Dislpay Detect Dir to OLED
double lastDetectYawDig = (lastDetectYaw-equiW/2)*360.0/equiW;
double lastDetectPitchDig = (equiH/2-lastDetectPitch)*180/equiH;
displayResult(lastDetectYawDig, lastDetectPitchDig, detectFlag);
long curTime = System.currentTimeMillis();
long diffTime = curTime - startTime;
if (diffTime >= 1000 ) {
Log.d(TAG, "[OLED]" + String.valueOf(outFps) + "[fps]" );
startTime = curTime;
outFps =0;
}
}
}
}).start();
}
private Bitmap rotationYaw(int inLastDetectYaw, int equiW, Bitmap inBitmap) {
//Yaw axis rotation [Moving the detection frame]
Log.d(TAG, "### Yaw axis rotation START [result] ###");
//Yaw axis rotation [Image rotation]
Bitmap rotationBmp = Bitmap.createBitmap(equiW, (equiW/2), Bitmap.Config.ARGB_8888);
Canvas rotationCanvas = new Canvas(rotationBmp);
if ( (equiW/2) < inLastDetectYaw ) {
Log.d(TAG, "Case 1 [result]");
int leftWidth = (equiW/2) + ( equiW - inLastDetectYaw ) ;
Bitmap leftBmp = Bitmap.createBitmap(inBitmap, (inLastDetectYaw-(equiW/2)), 0, leftWidth, (equiW/2), null, true);
Bitmap rightBmp = Bitmap.createBitmap(inBitmap, 0, 0, (inLastDetectYaw-(equiW/2)), (equiW/2), null, true);
Paint mPaint = new Paint();
rotationCanvas.drawBitmap(leftBmp, 0, 0, mPaint);
rotationCanvas.drawBitmap(rightBmp, leftWidth, 0, mPaint);
} else if ( inLastDetectYaw<(equiW/2) ) {
Log.d(TAG, "Case 2 [result]");
Bitmap leftBmp = Bitmap.createBitmap(inBitmap, (inLastDetectYaw+(equiW/2)), 0, ((equiW/2)-inLastDetectYaw), (equiW/2), null, true);
Bitmap rightBmp = Bitmap.createBitmap(inBitmap, 0, 0, (inLastDetectYaw+(equiW/2)), (equiW/2), null, true);
Paint mPaint = new Paint();
rotationCanvas.drawBitmap(leftBmp, 0, 0, mPaint);
rotationCanvas.drawBitmap(rightBmp, ((equiW/2)-inLastDetectYaw), 0, mPaint);
} else {
Log.d(TAG, "Case 3 [result]");
Paint mPaint = new Paint();
rotationCanvas.drawBitmap(inBitmap, 0, 0, mPaint);
}
Log.d(TAG, "### Yaw axis rotation END [result] ###");
return rotationBmp;
}
private void drawDetectResult(Detector.Recognition inResult, Canvas inResultCanvas, Paint inPaint, int inOffsetX, int inOffsetY){
double confidence = Double.valueOf(inResult.getConfidence());
if ( confidence >= 0.54 ) {
Log.d(TAG, "[result] Title:" + inResult.getTitle());
Log.d(TAG, "[result] Confidence:" + inResult.getConfidence());
Log.d(TAG, "[result] Location:" + inResult.getLocation());
// draw result
if (confidence >= 0.56) {
String title = inResult.getTitle();
if ( title.equals("apple")) {
inPaint.setColor( Color.RED );
} else if ( title.equals("banana") ) {
inPaint.setColor( Color.YELLOW );
detectFlag = true;
updateDetectInfo(inResult, inOffsetX, inOffsetY);
} else if ( title.equals("orange") ) {
inPaint.setColor(Color.CYAN );
} else {
inPaint.setColor( Color.BLUE );
}
} else {
inPaint.setColor( Color.DKGRAY );
}
RectF offsetRectF = new RectF(inResult.getLocation().left, inResult.getLocation().top, inResult.getLocation().right, inResult.getLocation().bottom);
offsetRectF.offset( (float) inOffsetX, (float) inOffsetY );
inResultCanvas.drawRect( offsetRectF, inPaint );
inResultCanvas.drawText(inResult.getTitle() + " : " + inResult.getConfidence(), offsetRectF.left, offsetRectF.top, inPaint);
}
}
private void updateDetectInfo(Detector.Recognition inResult, int inOffsetX, int inOffsetY){
int tmp = lastDetectYaw;
int curDetectYaw = (int)( inOffsetX + inResult.getLocation().left + ((inResult.getLocation().right-inResult.getLocation().left)/2) );
if ( curDetectYaw <= (equiW/2) ) {
lastDetectYaw -= ((equiW/2)-curDetectYaw);
} else {
lastDetectYaw += (curDetectYaw-(equiW/2));
}
if ( equiW < lastDetectYaw ) {
lastDetectYaw -= equiW ;
} else if (lastDetectYaw<0) {
lastDetectYaw = equiW + lastDetectYaw;
}
Log.d(TAG, "[result] lastDetectYaw=" + String.valueOf(lastDetectYaw) + ", befor=" +String.valueOf(tmp) );
int curDetectPitch = (int)( inOffsetY + inResult.getLocation().top + ((inResult.getLocation().bottom-inResult.getLocation().top)/2) );
lastDetectPitch = curDetectPitch;
}
private void displayResult(double detectYawDig, double detectPitchDig, boolean inDetectFlag) {
double lineLength = 10.0;
double lineEndDig = detectYawDig-90.0;
double lineEndX = lineLength * Math.cos( Math.toRadians( lineEndDig ) );
double lineEndY = lineLength * Math.sin( Math.toRadians( lineEndDig ) );
double arrowLength = 6.0;
double arrowEndX1 = arrowLength * Math.cos( Math.toRadians( lineEndDig+210.0 ) );
double arrowEndY1 = arrowLength * Math.sin( Math.toRadians( lineEndDig+210.0 ) );
double arrowEndX2 = arrowLength * Math.cos( Math.toRadians( lineEndDig-210.0 ) );
double arrowEndY2 = arrowLength * Math.sin( Math.toRadians( lineEndDig-210.0 ) );
int centerX = 15;
int centerY = 12;
oledDisplay.clear();
oledDisplay.circle(centerX, centerY, 11);
oledDisplay.line(centerX, centerY, (int)(centerX+lineEndX+0.5), (int)(centerY+lineEndY+0.5));
oledDisplay.line((int)(centerX+lineEndX+0.5), (int)(centerY+lineEndY+0.5), (int)(centerX+lineEndX+arrowEndX1+0.5), (int)(centerY+lineEndY+arrowEndY1+0.5) );
oledDisplay.line((int)(centerX+lineEndX+0.5), (int)(centerY+lineEndY+0.5), (int)(centerX+lineEndX+arrowEndX2+0.5), (int)(centerY+lineEndY+arrowEndY2+0.5) );
String line1Str = "";
if (mGetLiveViewTask!=null) {
if (inDetectFlag) {
line1Str = "** Lock-On! **";
} else {
line1Str = "- can't find -";
}
} else {
line1Str = "STOP Detection";
}
String line2Str = "Yaw : " + String.valueOf( (int)detectYawDig );
String line3Str = "Pitch : " + String.valueOf( (int)detectPitchDig );
int textLine1 = 0;
int textLine2 = 8;
int textLine3 = 16;
oledDisplay.setString(35, textLine1,line1Str);
oledDisplay.setString(35, textLine2,line2Str);
oledDisplay.setString(35, textLine3,line3Str);
oledDisplay.draw();
}
}
WebUIの修正
事前に「THETAプラグインでライブプリビューを扱いやすくする」の内容も理解しておいてください。
元ファイルからの修正ポイントが少ないので全文は掲載しません。
基本的には数値の書き換えだけで大丈夫です。
とはいえ、変更を忘れると意図どおりに動作しないのでご注意を。
ライブプリビュー表示の更新頻度を下げる
解説はこちらを参照してください。
元のままですとWebサーバーの処理頻度が高いため、優先度が低い物体認識処理やMOTION JPEGの読み取り処理を邪魔しすぎてしまいます。入力を8fpsとしていますので125ms周期の更新で十分です。
function repeat() {
const d1 = new Date();
while (true) {
const d2 = new Date();
if (d2 - d1 > 125) {
break;
}
}
updatePreviwFrame();
updatePreviewStat();
updateEv();
}
画像サイズの指定
解説はこちらを参照してください。
1024x512 8fpsの画像に対して物体認識をかける場合、preview.jsのPREVIEW_FORMAT定義を以下としてください。
var PREVIEW_FORMAT = 3;
WebUIの表示
ここは見た目の話なのでお好みで修正してください。
index.htmlの「titleタグの内容」「h1タグの内容」「imgタグの widthとheight」を以下としています。
<html>
<head>
<title>TF-Lite : Object detection</title>
<script src="js/preview.js"></script>
</head>
<body onLoad="startLivePreview();updatePreviwFrame();">
<h1>
TF-Lite : Object detection
</h1>
<img id="lvimg" src="" width="1024" height="512">
~省略~
</body>
</html>
物体認識に便利な小道具について
物体認識の動作確認は、一般的な画角の映像を利用するのであれば、PCのディスプレイに表示した写真で行われることが多いようです。
この手法をTHETAで行った場合、「画角が広すぎる」のと「ディスプレイと外界の輝度差」から、ディスプレイしか認識できないことが多いです。
そこで、お供え物用の食品サンプル的なフルーツを利用しました。
1個買いをするよりも、複数種類の食品サンプルが1セットになったものを選ぶとお得なようです。
物体認識全般で役立ちそうな情報なので、参考として紹介しておきます。
まとめ
WebAPIのライブプリビュー利用でも「1024x512のequirectangular形式連続フレームにTensorFlow LiteのObject Detectionをかけながら、バナナを横方向にトラッキングする」という重くてややこしい処理を、おおむね 6fps(たまに7fps)で動作させることができました。
THETAプラグイン、けっこうやるでしょ?
画像サイズを 640x320にすると、8fpsくらいまで処理速度が向上しますが、バナナのような手に持てる程度の小さな物は、レンズと物体の距離を 10cm程度まで近づけないと認識できません。
何を認識させるかによって画像サイズをいろいろと試してみてください。
ちなみに、人間の認識率は結構高かったです。全身、身体の一部どちらもよく認識します。THETAにはこれくらいの大きさのものが相性がよさそうなので、姿勢推定(人間の両腕両足なども含めたポーズがわかります)を試してみると面白いかもしれません。バンザイシャッターとか作れそうです!
ウィークポイントは発熱です。20~22℃の環境、金属ボディで放熱しやすいZ1で20~30分くらい、Z1より放熱しにくいVで10~15分くらいの連続動作が限界でした。
認識結果をトリガーに記念撮影をする程度のアプリケーションはなんとかなると思われますが、もっと長時間動作するアプリケーションをつくるには、認識処理を行う頻度を落としたり、WebUIへの表示はデバッグ時のみとしたり、認識処理のOn/Offを手軽にできるようにするなどの工夫が必要そうです。
次回は、記事先頭で説明したとおり
「ライブビューの天頂補正を行いながら、全方位のバナナトラッキングする」という事例を使って「equirectangularの回転処理」「THETAの姿勢情報利用」について詳しく説明します。
【追記】
2020/4/24に続編を投稿しました。
NDKでEquirectangularの回転処理をして物体を全方位トラッキングする
こちらもよろしくお願いします。
RICOH THETAプラグインパートナープログラムについて
THETAプラグインをご存じない方はこちらをご覧ください。
パートナープログラムへの登録方法はこちらにもまとめてあります。
QiitaのRICOH THETAプラグイン開発者コミュニティ TOPページ「About」に便利な記事リンク集もあります。
興味を持たれた方はTwitterのフォローとTHETAプラグイン開発者コミュニティ(Slack)への参加もよろしくおねがいします。