20200406のAndroidに関する記事は6件です。

【Flutter】BLoCパターンでアプリを作成してみた。~カウンターアプリ、githubリポジトリ検索アプリ~

BLoCパターンとは

BLoC PatternはFlutterでのアプリケーション開発時に用いる、状態管理手法の1つです。
ビジネスロジックをコンポーネント単位で管理しやすくするためのパターンです。

こちらを参考にしてください。

サンプルアプリの紹介

Githubはこちら

カウンターアプリ

プラスボタン、マイナスボタンを押下することで画面中央の数字がインクリメント、デクリメントされます。

カウンターアプリカウンターアプリ

githubリポジトリ検索アプリ

TextFieldに検索キーワードを入力して、検索すると対象のGitHubリポジトリの一覧を表示して、要素をタップするとWebViewで表示します。

githubリポジトリ検索アプリgithubリポジトリ検索アプリ

ソースコード解説

main.dart
import 'Model/counter_bloc.dart';
import 'Model/search_bloc.dart';

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

import 'UI/counter_page.dart';
import 'UI/search_page.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
        title: 'Flutter Demo',
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
        home: MultiProvider(
          providers: [
            Provider<CounterBloc>(
              create: (context) => CounterBloc(),
              dispose: (context, bloc) => bloc.dispose(),
            ),
            Provider<SearchBloc>(
              create: (context) => SearchBloc(),
              dispose: (context, bloc) => bloc.dispose(),
            ),
          ],
          child: MyHomePage(title: 'Flutter BLoC Sample'),
        )
    );
  }
}

class MyHomePage extends StatelessWidget {
  MyHomePage({this.title});

  final String title;

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: 2,
      child: Scaffold(
        appBar: AppBar(
          title: Text(title),
          bottom: TabBar(
            tabs: <Widget>[
              new Tab(
                text: "Count",
              ),
              new Tab(
                text: "Search",
              ),
            ],
          ),
        ),
        body: TabBarView(
          children: <Widget> [
            CounterPage(),
            SearchPage(),
          ],
        ),
        // This trailing comma makes auto-formatting nicer for build methods.
      ),
    );
  }
}

Providerの利用

Providerを使うことで、childパラメータに指定したWidget以下全てのWidgetで、同じBLoCインスタンスにアクセスすることができます。

複数のProviderを設定する場合は、MultiProviderを設定します。

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
        title: 'Flutter Demo',
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
        home: MultiProvider(
          providers: [
            Provider<CounterBloc>(
              create: (context) => CounterBloc(),
              dispose: (context, bloc) => bloc.dispose(),
            ),
            Provider<SearchBloc>(
              create: (context) => SearchBloc(),
              dispose: (context, bloc) => bloc.dispose(),
            ),
          ],
          child: MyHomePage(title: 'Flutter BLoC Sample'),
        )
    );
  }
}

Counter UIの作成

counter_page.dart
import '../Model/counter_bloc.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

class CounterPage extends StatelessWidget {
  CounterPage();
  @override
  Widget build(BuildContext context) {
    final counterBloc = Provider.of<CounterBloc>(context);
    return new Scaffold(
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              Text(
                'You have pushed the button this many times:',
              ),
              StreamBuilder(
                initialData: 0,
                stream: counterBloc.count,
                builder: (context, snapshot) {
                  return Text(
                    '${snapshot.data}',
                    style: Theme.of(context).textTheme.display1,
                  );
                },
              )
            ],
          ),
        ),
        floatingActionButton: Column(
          verticalDirection: VerticalDirection.up, // childrenの先頭を下に配置
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            FloatingActionButton(
              onPressed: () {
                counterBloc.changeCountAction.add(false);
              },
              tooltip: 'Declement',
              child: Icon(Icons.remove),
            ),
            Container( // 余白のためContainerでラップ
              margin: EdgeInsets.only(bottom: 16.0),
              child: FloatingActionButton(
                onPressed: () {
                  counterBloc.changeCountAction.add(true);
                },
                tooltip: 'Increment',
                backgroundColor: Colors.redAccent,
                child: Icon(Icons.add),
              ),
            ),
          ],
        )
    );
  }
}

Counter BLoCの生成

counter_bloc.dart
import 'dart:async';
import 'package:rxdart/rxdart.dart';

class CounterBloc {
  // input
  final _actionController = BehaviorSubject<bool>();
  Sink<void> get changeCountAction => _actionController.sink;

  //output
  final _countController = BehaviorSubject<int>();
  Stream<int> get count => _countController.stream;

  int _count = 0;

  CounterBloc() {
    _actionController.stream.listen((isPlus) {
      if (isPlus) {
        _count++;
      } else {
        _count--;
      }
      _countController.sink.add(_count);
    });
  }

  void dispose() {
    _actionController.close();
    _countController.close();
  }
}

BLoCの呼び出し

BLoCは、子Widgetのbuild()メソッドで呼ぶのが定番です。

  @override
  Widget build(BuildContext context) {
    final counterBloc = Provider.of<CounterBloc>(context);
    return new Scaffold(

Sink<T>.add() でBLoCに値を送る

例では、counterBloc.changeCountActionに対して、プラスならTrue,マイナスならFalseのBooleanを渡しています。

              child: FloatingActionButton(
                onPressed: () {
                  counterBloc.changeCountAction.add(true);
                },

Stream.listenでinputの値に対して処理を実行

Stream.listenで流れてきたBooleanを受け取る。

ちなみにStreamはこんなイメージを持ってもらえば良いと思う。
用意された川に対して、今回だとBooleanの要素をプラスボタンやマイナスボタンが押される度に流されるイメージ。
image.png
引用: https://medium.com/@teivah/reactivewm-a-reactive-framework-for-webmethods-2c91c7de82b3

  CounterBloc() {
    _actionController.stream.listen((isPlus) {
      if (isPlus) {
        _count++;
      } else {
        _count--;
      }
      _countController.sink.add(_count);
    });
  }

以下のコードで再度、別のStreamに要素(この例ではint)を流している。
Dart
_countController.sink.add(_count);

StreamBuilderで値の受け取り

StreamBuilderを使って、Streamの値を反映します。StreamBuilderを使うことで、build()メソッドを呼ぶことなくStreamの値に応じてこの箇所だけUIを更新することができます。

              StreamBuilder(
                initialData: 0,
                stream: counterBloc.count,
                builder: (context, snapshot) {
                  return Text(
                    '${snapshot.data}',
                    style: Theme.of(context).textTheme.display1,
                  );
                },
              )

これで、カウンターアプリは完成です。

続いて、、、githubのリポジトリ検索アプリを紹介

検索画面UIの生成

search_page.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../Model/search_bloc.dart';
import 'package:flutter_inappbrowser/flutter_inappbrowser.dart';

class SearchPage extends StatelessWidget {
  final _formKey = GlobalKey<FormState>();

  TextEditingController queryInputController = TextEditingController(text: '');

  @override
  Widget build(BuildContext context) {
    final searchBloc = Provider.of<SearchBloc>(context);
    return Scaffold(
        body: Center(
            child: Column(children: <Widget>[
              Form(
                  key: _formKey,
                  child: Column(children: <Widget>[
                    Padding(
                        padding: const EdgeInsets.all(16.0),
                        child: Center(
                            child: TextFormField(
                              decoration: InputDecoration(
                                labelText: '検索キーワード',
                              ),
                              controller: queryInputController,
                            ))),
                    RaisedButton(
                        child: const Text('検索'),
                        onPressed: () =>
                            searchBloc.changeQuery.add(queryInputController.text)),
                  ])),
              StreamBuilder(
                  stream: searchBloc.result,
                  builder: (context, snapshot) {
                    if (snapshot.hasError) {
                      print(snapshot.error);
                      // snapshot.error を使ったWidgetを返す
                      // snapshot は AsyncSnapshot<T> で
                    }
                    if (snapshot.data != null) {
                      return Expanded(
                        child: ListView.builder(
                            scrollDirection: Axis.vertical,
                            shrinkWrap: true,
                            itemCount: snapshot.data.length,
                            itemBuilder: (context, int index) {
                              var item = snapshot.data[index];
                              return Container(
                                  decoration: BoxDecoration(
                                    border: Border(
                                      bottom: BorderSide(color: Colors.black38),
                                    ),
                                  ),
                                  child: ListTile(
                                    title: Text(item['full_name']),
                                    subtitle: Text('Star: ' + item['stargazers_count'].toString()),
                                    onTap: () {
                                      this.openBrowser(url: item['html_url']);
                                    },
                                  ));
                            }),
                      );
                    }
                    return Container();
                  })
            ])));
  }

  openBrowser({String url}) {
    ChromeSafariBrowser browser = ChromeSafariBrowser();
    browser.open(url: url, options: ChromeSafariBrowserClassOptions(
      androidChromeCustomTabsOptions: AndroidChromeCustomTabsOptions(
        addShareButton: true,
        toolbarBackgroundColor: "#ff0000",
        enableUrlBarHiding: true,
      ),
      iosSafariOptions: IosSafariOptions(
        barCollapsingEnabled: true,
      )
    ));
  }
}

検索BLoCの生成

search_bloc.dart
import 'dart:async';
import 'dart:convert';
import 'package:flutterbloc/Model/API/api_service.dart';
import 'package:rxdart/rxdart.dart';
import 'API/chopper_client_creater.dart';

import 'package:chopper/chopper.dart';

class SearchBloc {
  final searchApi = SearchApi();

  final searchQueryController = BehaviorSubject<String>();
  Stream<String> get query => searchQueryController.stream;
  StreamSink<String> get changeQuery => searchQueryController.sink;

  // APIの返り値となるSearchResult型を自作したと仮定
  final searchResultController = BehaviorSubject<List<dynamic>>();
  Stream<List<dynamic>> get result => searchResultController.stream;
  StreamSink<List<dynamic>> get changeResult => searchResultController.sink;

  SearchBloc() {
    query.listen((v) async {
      // APIの返り値となるSearchResult型を自作したと仮定
      final List<dynamic> searchResults = await searchApi.fetchApi(query: v);
      print("------");
      print(searchResults);
      print("------");
      if (searchResults.isEmpty) {
        changeResult.addError(searchResults);
      } else {
        changeResult.add(searchResults);
      }
    });
  }

  void dispose() {
    searchResultController.close();
    searchQueryController.close();
  }
}

class SearchApi {
  final ApiService service =
  ApiService.create(ChopperClientCreator.create());

  Future<List<dynamic>> fetchApi({String query}) async {
    final Response response = await service.fetchApi(query: query);
    if (response.isSuccessful) {
      return response.body['items'];
    } else {
      print(response.error);
    }
  }
}

APIモデルの生成

今回はChopperを利用しました。

こちら参考記事になります。

api_service.dart
import 'package:chopper/chopper.dart';

part 'api_service.chopper.dart';

@ChopperApi(baseUrl: '')
abstract class ApiService extends ChopperService {
  static ApiService create([ChopperClient client]) =>
      _$ApiService(client);

  @Get(path: "/repositories")
  Future<Response> fetchApi({
    @Query('q') String query,
    @Query('sort') String sort = 'stars'
  });
}
chopper_client_creater.dart
import 'package:chopper/chopper.dart';

class ChopperClientCreator {
  static final String baseUrl = "https://api.github.com/search";

  static ChopperClient create() {
    return ChopperClient(
      baseUrl: ChopperClientCreator.baseUrl,
      converter: JsonConverter(),
    );
  }
}
api_service.chopper.dart
// GENERATED CODE - DO NOT MODIFY BY HAND

part of 'api_service.dart';

// **************************************************************************
// ChopperGenerator
// **************************************************************************

// ignore_for_file: always_put_control_body_on_new_line, always_specify_types, prefer_const_declarations
class _$ApiService extends ApiService {
  _$ApiService([ChopperClient client]) {
    if (client == null) return;
    this.client = client;
  }

  @override
  final definitionType = ApiService;

  @override
  Future<Response<dynamic>> fetchApi({String query, String sort = 'stars'}) {
    final $url = '/repositories';
    final $params = <String, dynamic>{'q': query, 'sort': sort};
    final $request = Request('GET', $url, client.baseUrl, parameters: $params);
    return client.send<dynamic, dynamic>($request);
  }
}

参考記事

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Androidアプリ開発中にバーションアップで調べようと思うと(個人的に)ちょっと面倒なものまとめ

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Kotlin 基礎文法

目次

  • 制御構文
  • 繰り返し
  • 関数
  • ラムダ式

本記事は、自己学習のアウトプットのために作成した記事となります。
修正点等いただければ助かります。

制御構文

・if文

val Point = 100
if(score >= 55){
 println("Good")
}else{
 println("Bad")
}
//Good
//短縮
val Point = 100
val decision = if(Point >= 55) "Good" else "Bad"
println(decision)

複数の条件式

val Point = 100
val decision = if(Point >= 90){
 "Very Good"
}else if(Point >= 50){
 "Good"
}else{
 "Bad"
}
println(decision)
when式の複数式

Rangeオブジェクトを使用して、記述いたします。

※Rangeオブジェクトとは..範囲を表すオブジェクトです。

val Point = 120
val decision = when(Point){
 in 100..200 -> "Good"
 in 1..99    -> "OK"
 else        -> "Bad"
}
println(decision)
//実行結果:Good

繰り返し

for文とwhile文で記述していきます。

for(i in arrayOf(0.1.10.11.100){
 println(i)
}

List要素から文字列を順に取り出して出力します。

val Sweet = listOf("Apple","Banana","Orange","grape")
 for(Sweets in Sweet){
  println(Sweets)
}

whileループ

var i = 1
while(i < 10){
 println(i)
 i++
}
var j = 5
do{
 println(j)
 j--
}while(j>1)

関数

関数は、funキーワードを使って宣言します。
以下は、構文となります。

fun 関数名(引数1 :型、引数2 :型、・・・):戻り値{
 処理
}

上記の構文で例を作成いたします。

fun main(args: Array<String>){
 println(square(10))
}

fun square(x: Int):Int{
 return x+x
}
//実行結果20

関数が1つの式だけの時は、次のように{}を省略して、以下のように記述できます。
`fun square(x: Int):Int = x+x

//短縮
fun main(args: Array<String>){
 hello("Tanaka")
}
fun hello(name: String?):Unit{
 if(name != null)
    println("Hello ${name}")
  else
    println("Hi there")
}
//結果:Hello Tanaka

ラムダ式

構文は、{引数のリスト -> 処理}となります。

var plus = { x: Int, y: Int -> x*y}
println(plus(10,10))

今回はここまで!!

次回はクラスについて〜未定。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

THETAプラグインで連続フレームにTensorFlow Liteの物体認識をかける

はじめに

リコーの @KA-2 です。

弊社ではRICOH THETAという全周囲360度撮れるカメラを出しています。
RICOH THETA VRICOH THETA Z1は、OSにAndroidを採用しています。Androidアプリを作る感覚でTHETAをカスタマイズすることもでき、そのカスタマイズ機能を「プラグイン」と呼んでいます(詳細は本記事の末尾を参照)。

今回は、THETAプラグインでライブプリビューを扱いやすくするの続編です。
上記記事の末尾で予告したとおり、THETAのライブプリビューにTensorFlow LiteのObject Detectionをかけ、その結果を利用します。
利用といっても今回はひとまず、「THETAの姿勢基準(local座標系)で、特定物を横方向にトラッキングする」というところまでを説明します。

「特定物を全方位トラッキングする」こともできているのですが、こちらは私が執筆する次回記事としますね。というのも「equirectangularの回転処理」「THETAの姿勢情報利用」という要素が含まれており、他のTHETAプラグイン作成にも役立つ事項なので詳しく解説したいためです。
次回記事も含めて参照くださるとありがたいです。振る舞いだけチラ見せすると以下な感じです。

全方位トラッキング調整中2_400.gif

それではまず、「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種類の例(学習済みモデル込み)が公開されています。

TFLのサンプル2.png

モデルの設計を自身で行えば応用範囲はもっと広がりますし、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をかけた例が以下となります。

1024x512で物体認識した例.jpg

さらっと言うと「"単一の画像内"の認識結果を"複数個"得られる」という処理です。
もう少し具体的に条件なども含めて羅列すると以下になります。

  • 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の他学習モデル利用する際には、各自で手順をカスタマイズしてください。

作業のベースとなるプロジェクトファイル一式は以下記事で解説したものです。

今回は、ライブプリビューの連続フレーム(JPEG)をBitmapにしたデータに対して物体認識を行います。
CameraAPIで取得した連続フレームにも応用できますが、その場合には、必要な事項を各自で適用してください。(文章にすると長くみえますが、わりと簡単ですのでご安心ください)

build.gradle(Module:app)の設定

以下2つの定義を追加しています。

  • 「aaptOptions」で、モデルファイルを圧縮しないようにしています
  • 「implementation」の定義を2行書き加えています
build.gradle
android {

    ~省略~

    aaptOptions {
        noCompress "tflite"
    }
}

dependencies {

    ~省略~

    implementation 'org.tensorflow:tensorflow-lite:0.0.0-nightly'
    implementation 'org.tensorflow:tensorflow-lite-gpu:0.0.0-nightly'
}

学習済みモデルとラベルの配置

TensorFlow Lite:Object Detectionのページの「Download starter model and labels」のボタンをクリックしてzipファイルをダウンロードしてください。
記事執筆時点のファイル名は「coco_ssd_mobilenet_v1_1.0_quant_2018_06_29.zip」です。

モデルファイルダウンロード.png

ダウンロードしたzipファイルを解凍すると以下2ファイルが得られます。

ファイル名 説明
detect.tflite 物体認識の学習済みモデルです
labelmap.txt 学習済みモデルのラベル(識別可能な80品目の名称)定義ファイルです

その2つのファイルをどちらもassetsフォルダーに配置してください。

モデルファイルの配置.png

物体検出クラスの配置

Android用のサンプルコードから、以下の3ファイルを取得します。

ファイル名 説明
Classifier.java 物体認識をするクラスのインターフェースが定義されています
TensorFlowObjectDetectionAPIModel.java 物体認識をするクラスの実処理が記述されています
Logger.java 物体認識をする処理から呼び出されているログ出力クラスです

取得したファイルは、配置の仕方にあわせpackageの定義を書き換えてください。

以下は、ベースとしたプロジェクトの「~\app\src\main\java\com\theta360\extendedpreview」に3つのファイルを配置した場合の例です。この場合、packageの定義は3ファイル共に以下となります。

package com.theta360.extendedpreview;

今回のファイル配置の場合、TFLiteObjectDetectionAPIModel.javaの以下importは削除、または、コメントアウトしてください。ファイル配置が異なる場合は記述が変わります。

TFLiteObjectDetectionAPIModel.java
//import org.tensorflow.lite.examples.detection.env.Logger;

ここまで準備すると、MainActivity.javaから物体認識の処理を呼び出すことができます。

補足:コア数の指定やGPUデリゲート

前述のとおり、今回利用している学習済みモデルは量子化されているので、現在はGPUが利用できずCPUのみを利用することになります。その際、演算時に利用する最大コア数を「TFLiteObjectDetectionAPIModel.java」に定義されている以下の数値で指定できます。

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,正方形です)、モデルの量子化有無を定義しています。

MainActivity.java
    private static final String TF_OD_API_MODEL_FILE = "detect.tflite";
    private static final String TF_OD_API_LABELS_FILE = "file:///android_asset/labelmap.txt";
    private static final int TF_OD_API_INPUT_SIZE = 300;
    private static final boolean TF_OD_API_IS_QUANTIZED = true;

初期化

Classifierクラスのオブジェクトをdetectorという名称で生成しています。

MainActivity.java
                ///////////////////////////////////////////////////////////////////////
                // TFLite Initial detector
                ///////////////////////////////////////////////////////////////////////
                Classifier detector=null;
                try {
                    Log.d(TAG, "### TFLite Initial detector ###");
                    detector = TFLiteObjectDetectionAPIModel.create(
                            getAssets(),
                            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;
                }

認識処理

今回は、事前に300×300 pixelに切り出したBitmapを与え物体認識処理を行っています。
目的によっては「黒帯つきで300×300 pixelにリサイズして画像全体に認識処理をかける」とか「縦に引き伸ばした300×300 pixelの画像全体に認識処理をかける」ということも考えられますが、、、画素数が減るほどに小さなもの(遠くのもの)は認識されにくくなりますし、縦横比率を変えると元々歪んでいる映像がさらに変形します。今回利用しているモデルはそのようなデータで学習されていませんので、あまりお勧めできません。
(とはいえ、ある程度動作してしまうのが機械学習の不思議さではあります)

MainActivity.java
                        ///////////////////////////////////////////////////////////////////////
                        // TFLite Object detection
                        ///////////////////////////////////////////////////////////////////////
                        final List<Classifier.Recognition> results = detector.recognizeImage(cropBitmap);
                        Log.d(TAG, "### TFLite Object detection [result] ###");
                        for (final Classifier.Recognition result : results) {
                            drawDetectResult(result, resultCanvas, mPaint, offsetX, offsetY);
                        }

結果はClassifier.RecognitionクラスのList形式で、recognizeImageメソッドの戻り値として取得できます。Listの数だけループさせながら結果を描画するdrawDetectResultメソッドを呼び出しています。

検出結果の利用方法

drawDetectResultメソッドを参照してください。
TensorFlow LiteのObject Detectionのページで説明されている通り、confidence値でカットオフしたほうがよいです。今回は 0.54以上の結果を描画するようにしてみました。お好みに応じて値を変えてください。

MainActivity.java
        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の横方向回転をする

折角の全天球(全方位)画像が得られているのに、物体認識できるエリアを固定してしまうのは勿体ないので、ひとまず、バナナを横方向にトラッキングする処理をしています。
横方向の回転であれば、特殊なライブラリなど使わずに行えるのを知って頂きたいためです。

図示すると以下のような処理をしています。

横方向回転.PNG

コードを記述する際のポイントは以下2点です。

  • BitmapをinMutable=trueで生成し編集可能にしておく
  • CanvasクラスとPaintクラスを使って切り貼りを行う

詳しくは、rotationYawメソッドを参照してください。
本格的なEquirectangularの回転処理については、私が執筆する次回記事で解説する予定です。

OLEDの表示

以下のような表示を行っています。

OLED表示.jpg

詳しくは、displayResultメソッドを参照してください。
THETA Vで今回のサンプルを動かす場合には無駄な処理になります。コメントアウトすると僅かながら処理負荷の低減になります。

高負荷処理のタスク優先度

「THREAD_PRIORITY_わかりやすい名称」の形式で定義されているタスク優先度についてはこちらを参照してください。一般の方のまとめですが、こちらのページの説明もわかりやすいです。
数値が小さいほど優先度が高く、数値が大きいほど優先度が低くなります。

imageProcessingThreadの先頭では、以下のようにタスク優先度を指定しています。
その他は、指定をせず初期状態のままとしています。

MainActivity.java
    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)全文

ソースコード全文を折りたたんで掲載しておきます。

こちらをクリックして開いてください
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";
    private static final String TF_OD_API_LABELS_FILE = "file:///android_asset/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
                ///////////////////////////////////////////////////////////////////////
                Classifier detector=null;
                try {
                    Log.d(TAG, "### TFLite Initial detector ###");
                    detector = TFLiteObjectDetectionAPIModel.create(
                            getAssets(),
                            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<Classifier.Recognition> results = detector.recognizeImage(cropBitmap);
                        Log.d(TAG, "### TFLite Object detection [result] ###");
                        for (final Classifier.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(Classifier.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(Classifier.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周期の更新で十分です。

preview.js
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定義を以下としてください。

preview.js
var PREVIEW_FORMAT = 3;

WebUIの表示

ここは見た目の話なのでお好みで修正してください。
index.htmlの「titleタグの内容」「h1タグの内容」「imgタグの widthとheight」を以下としています。

index.html
<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の姿勢情報利用」について詳しく説明します。

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

THETAプラグインをご存じない方はこちらをご覧ください。
パートナープログラムへの登録方法はこちらにもまとめてあります。
QiitaのRICOH THETAプラグイン開発者コミュニティ TOPページ「About」に便利な記事リンク集もあります。
興味を持たれた方はTwitterのフォローとTHETAプラグイン開発者コミュニティ(Slack)への参加もよろしくおねがいします。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

AndroidのPreference系を簡単に扱いたい

Preference系

SQLiteを使うまでもないデータを保存しておくにはPreferenceを使うのが一番です。

しかしながら、一つのデータにアクセスするコードが微妙に長い。超せっかっち人間の僕にとっては悩みの種でした。

簡単に扱えるようにした?

簡単?に扱えるようにクラスにしてみました。インスタンスはアプリで一つ。トップレベルで保持することとします。

コード

lass SettingsClass(_context: Context) {
    private val context = _context

    fun setBoolean(name: String, key: String, value: Boolean) {
        val preference = context.getSharedPreferences(name, Context.MODE_PRIVATE)
        preference.edit().putBoolean(key, value).apply()
    }

    fun getBoolean(name: String, key: String, default: Boolean): Boolean {
        val preference = context.getSharedPreferences(name, Context.MODE_PRIVATE)
        return preference.getBoolean(key, default)
    }

    fun setInt(name: String, key: String, value: Int) {
        val preference = context.getSharedPreferences(name, Context.MODE_PRIVATE)
        preference.edit().putInt(key, value).apply()
    }

    fun getInt(name: String, key: String, default: Int): Int {
        val preference = context.getSharedPreferences(name, Context.MODE_PRIVATE)
        return preference.getInt(key, default)
    }

    fun setLong(name: String, key: String, value: Long) {
        val preference = context.getSharedPreferences(name, Context.MODE_PRIVATE)
        preference.edit().putLong(key, value).apply()
    }

    fun getLong(name: String, key: String, default: Long): Long {
        val preference = context.getSharedPreferences(name, Context.MODE_PRIVATE)
        return preference.getLong(key, default)
    }

    fun setString(name: String, key: String, value: String?) {
        val preference = context.getSharedPreferences(name, Context.MODE_PRIVATE)
        preference.edit().putString(key, value).apply()
    }

    fun getString(name: String, key: String, default: String?): String? {
        val preference = context.getSharedPreferences(name, Context.MODE_PRIVATE)
        return preference.getString(key, default)
    }

    fun pSetBoolean(key: String, value: Boolean) {
        val preference = PreferenceManager.getDefaultSharedPreferences(context)
        preference.edit().putBoolean(key, value).apply()
    }

    fun pGetBoolean(key: String, default: Boolean): Boolean {
        return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(key, default)
    }

    fun pSetInt(key: String, value: Int) {
        val preference = PreferenceManager.getDefaultSharedPreferences(context)
        preference.edit().putInt(key, value).apply()
    }

    fun pGetInt(key: String, default: Int): Int {
        return PreferenceManager.getDefaultSharedPreferences(context).getInt(key, default)
    }

    fun pSetString(key: String, value: String) {
        val preference = PreferenceManager.getDefaultSharedPreferences(context)
        preference.edit().putString(key, value).apply()
    }

    fun pGetString(key: String, default: String): String? {
        return PreferenceManager.getDefaultSharedPreferences(context).getString(key, default)
    }

    companion object {
        const val SETTING_MUSIC_MODE = "CAIOS_SETTING_MUSIC_MODE"
        const val SETTING_MUSIC_VALUE = "CAIOS_SETTING_MUSIC_VALUE"
    }

最後のcompanion objectの値は自由です。アプリに沿って決めてください。

使用例

設定値の保存

settings.setBoolean("設定-bool", true)

設定値の読み出し

settings.getInt("設定-int", -1)
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Androidをペンタブレットにする(2/3)

前回、Androidをペンタブレットにする(1/3) を投稿しましたが、タッチパネルだけに飽き足らず、Androidスマホが持っているいろんな機能を取り込んでみました。

そのうち、以下の機能の実装を備忘録としておきます。スニペット的に残しておきましたので、コピペで使ってください。

• Androidの地磁気センサー、ジャイロスコープ、加速度センサーを検出して、Windowsに通知する。
• AndroidのGPS情報を取得し、Windowsに通知する。
• AndroidでQRコードをスキャンし、Windowsに通知する。
• Androidで音声認識して、Windowsに通知する。
• AndroidのクリップボードにWindowsからコピーやペーストする。

地磁気センサー、ジャイロスコープ、加速度センサー

〇利用するクラス宣言

import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;

〇変数定義

  SensorManager sensorManager;
  boolean isGyroscope = false;
  boolean isAccelerometer = false;
  boolean isMagnetic = false;

〇onCreateにて

    sensorManager = (SensorManager)getSystemService(SENSOR_SERVICE);

センサー機能を有効、無効にします。
isMagnetic等は、アクティビティがアクティブでない場合に一時停止し、アクティブになったら再度開始にするためのものです。

    private void setSensorEnable(int type, boolean enable){
        if( type == Sensor.TYPE_MAGNETIC_FIELD ) {
            isMagnetic = enable;
        }else if( type == Sensor.TYPE_GYROSCOPE){
            isGyroscope = enable;
        }else if( type == Sensor.TYPE_ACCELEROMETER ){
            isAccelerometer = enable;
        }
        if (enable)
            sensorStart(type);
        else
            sensorStop(type);
    }

アクティビティがアクティブでないときは、センサー検知を一時停止にします。

    @Override
    protected void onResume(){
        super.onResume();

        if(isGyroscope)
            sensorStart(Sensor.TYPE_GYROSCOPE);
        if( isAccelerometer )
            sensorStart(Sensor.TYPE_ACCELEROMETER);
        if( isMagnetic )
            sensorStart(Sensor.TYPE_MAGNETIC_FIELD);
    }

    @Override
    protected void onPause(){
        super.onPause();

        sensorStop(Sensor.TYPE_GYROSCOPE);
        sensorStop(Sensor.TYPE_ACCELEROMETER);
        sensorStop(Sensor.TYPE_MAGNETIC_FIELD);
    }

以下が、センサー検知イベントの開始です。
registerListenerで登録したクラスにイベントが通知されます。SensorEventListenerクラスを実装する必要があります。イベントの頻度は、SENSOR_DELAY_NORMAL以外にもいくつか種類があります。

    private void sensorStart(int type){
        if( sensorManager == null )
            return;

        if( type == Sensor.TYPE_MAGNETIC_FIELD ) {
            Sensor sensor = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD);
            if (sensor != null) {
                sensorManager.registerListener(sensorListnerMagnetic, sensor, SensorManager.SENSOR_DELAY_NORMAL);
            }
        }else if( type == Sensor.TYPE_GYROSCOPE ){
            Sensor sensor = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE);
            if (sensor != null) {
                sensorManager.registerListener( sensorListnerGyro,  sensor, SensorManager.SENSOR_DELAY_NORMAL);
            }
        }else if( type == Sensor.TYPE_ACCELEROMETER){
            Sensor sensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
            if (sensor != null) {
                sensorManager.registerListener( sensorListnerAccel,  sensor, SensorManager.SENSOR_DELAY_NORMAL);
            }
        }
    }

以下が、センサー検知イベントの停止です。

    private void sensorStop(int type){
        if( sensorManager == null )
            return;

        if( type == Sensor.TYPE_MAGNETIC_FIELD ) {
            sensorManager.unregisterListener(sensorListnerMagnetic);
        }else if( type == Sensor.TYPE_GYROSCOPE ){
            sensorManager.unregisterListener(sensorListnerGyro);
        }else if( type == Sensor.TYPE_ACCELEROMETER){
            sensorManager.unregisterListener(sensorListnerAccel);
        }
    }

以降、センサー検知イベントの受信処理のためのクラス実装です。地磁気センサー、ジャイロスコープ、加速度センサーとありますが、同じフォーマットです。

    private SensorEventListener sensorListnerMagnetic = new SensorEventListener(){
        @Override
        public void onSensorChanged(SensorEvent sensorEvent) {
            switch( sensorEvent.sensor.getType() ){
                case Sensor.TYPE_MAGNETIC_FIELD:{
// BLEセントラルにイベントを転送
                    int unit = Float.SIZE / Byte.SIZE;
                    byte[] send_buffer = new byte[1 + 3 * unit];
                    send_buffer[0] = Const.RSP_MAGNETIC;
                    setFloatBytes(send_buffer, 1, sensorEvent.values[0]);
                    setFloatBytes(send_buffer, 1 + unit, sensorEvent.values[1]);
                    setFloatBytes(send_buffer, 1 + 2 * unit, sensorEvent.values[2]);
                    sendBuffer(send_buffer, send_buffer.length);                    break;
                }
            }
        }

        @Override
        public void onAccuracyChanged(Sensor sensor, int i) {
            // do nothieng
        }
    };

    private SensorEventListener sensorListnerGyro = new SensorEventListener(){
        @Override
        public void onSensorChanged(SensorEvent sensorEvent) {
            switch( sensorEvent.sensor.getType() ){
                case Sensor.TYPE_GYROSCOPE:{
// BLEセントラルにイベントを転送
                    break;
                }
            }
        }

        @Override
        public void onAccuracyChanged(Sensor sensor, int i) {
            // do nothieng
        }
    };

    private SensorEventListener sensorListnerAccel = new SensorEventListener(){
        @Override
        public void onSensorChanged(SensorEvent sensorEvent) {
            switch( sensorEvent.sensor.getType() ){
                case Sensor.TYPE_ACCELEROMETER:{
// BLEセントラルにイベントを転送
                    break;
                }
            }
        }

        @Override
        public void onAccuracyChanged(Sensor sensor, int i) {
            // do nothieng
        }
    };

GPS情報

appのbuild.gradleのdependenciesに以下を追加します。

build.gradle
    implementation 'com.google.android.gms:play-services-location:17.0.0'

AndroidManifest.xmlに以下を追加します。

AndroidManifest.xml
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />

〇利用するクラス宣言

import android.location.Location;
import com.google.android.gms.location.FusedLocationProviderClient;
import com.google.android.gms.location.LocationRequest;
import com.google.android.gms.location.LocationServices;
import com.google.android.gms.tasks.OnCompleteListener;
import com.google.android.gms.tasks.Task;

〇変数宣言

    FusedLocationProviderClient fusedLocationClient;

〇onCreateにて

        fusedLocationClient = LocationServices.getFusedLocationProviderClient(this);
        LocationRequest locationRequest = new LocationRequest();
        locationRequest.setPriority(LocationRequest.PRIORITY_HIGH_ACCURACY);

GPS情報取得には、ユーザに対して許可を得る必要があります。許可を得ていないのであれば、許可を要求し、すでに得ているのであれば、GPS情報を取得します。

    private void getLocation() {
        if (fusedLocationClient == null)
            return;

        if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED){
            ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.ACCESS_FINE_LOCATION}, REQUEST_PERMISSION_LOCATION);
        }else {
            if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
                fusedLocationClient.getLastLocation().addOnCompleteListener(this, new OnCompleteListener<Location>() {
                    @Override
                    public void onComplete(@NonNull Task<Location> task) {
                        Location location = task.getResult();
                        sendLocation(location.getLatitude(), location.getLongitude());
                    }
                });
            }
        }
    }

GPS情報取得の許可をユーザから得ると、以下のActivityの関数が呼ばれます。許可を得たのち、GPSを取得します。

    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults){
        if( requestCode == REQUEST_PERMISSION_LOCATION )
            if (grantResults[0] == PackageManager.PERMISSION_GRANTED)
                getLocation();
    }

QRコードスキャン

こちらを使わせていただきました。

journeyapps/zxing-android-embedded
 https://github.com/journeyapps/zxing-android-embedded

appのbuild.gradleのdependenciesに以下を追加します。

build.gradle
    implementation 'com.journeyapps:zxing-android-embedded:3.2.0@aar'
    implementation 'com.google.zxing:core:3.2.1'

〇利用するクラス宣言

import com.google.zxing.integration.android.IntentIntegrator;
import com.google.zxing.integration.android.IntentResult;

あとは、以下を呼ぶだけです。カメラが起動し、QRコードをスキャンします。

    private void startQrScan(){
        new IntentIntegrator(this).initiateScan();
    }

スキャンが完了すると、以下のActivityの関数が呼ばれます。result.getContents()にスキャンした文字列があります。

    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        IntentResult result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data);
        if(result != null) {
            if(result.getContents() != null)
                sendQrcode(result.getContents());
            return;
        }

        super.onActivityResult(requestCode, resultCode, data);

// ・・・

音声認識

AndroidManifest.xmlに以下を追加します。

AndroidManifest.xml
    <uses-permission android:name="android.permission.INTERNET" />

〇利用するクラス宣言

import android.speech.RecognizerIntent;

あとは、以下を呼ぶだけです。

    private void doRecognize(){
        Intent intent = new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH);
        intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.JAPAN.toString() );
        intent.putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 100);
        intent.putExtra(RecognizerIntent.EXTRA_PROMPT, "音声を入力");
        startActivityForResult(intent, REQUEST_RECOGNITION);
    }

音声認識が完了すると、Activityの以下の関数が呼ばれます。candidates.get(0)に一番可能性の高い文章が入っています。

    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {

//・・・

        super.onActivityResult(requestCode, resultCode, data);
        if(requestCode == REQUEST_RECOGNITION && resultCode == RESULT_OK) {
            ArrayList<String> candidates = data.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS);
            if( candidates.size() > 0 )
                sendText(Const.TYPE_TEXT_RECOGNITION, candidates.get(0) );
        }

クリップボード

〇利用するクラス宣言

import android.content.ClipData;
import android.content.ClipboardManager;

〇変数宣言

    ClipboardManager clipboardManager;

〇onCreateにて

        clipboardManager = (ClipboardManager)getSystemService(Context.CLIPBOARD_SERVICE);

クリップボードから文字列を取得し、BLEセントラルに転送します。クリップボード操作には直接は関係しませんが、クリップボードから取得したことをToastでもわかるようにしました。

    private void sendCopy(){
        if( clipboardManager == null )
            return;

        ClipData cd = clipboardManager.getPrimaryClip();
        if(cd != null){
            ClipData.Item item = cd.getItemAt(0);
            Toast.makeText(this, item.getText().toString(), Toast.LENGTH_LONG).show();
            sendText(Const.TYPE_TEXT_COPY, item.getText().toString());
        }
    }

BLEセントラルから受け取った文字列をクリップボードにコピーします。
クリップボード操作には直接は関係しませんが、クリップボードに受け取ったことをToast で表示したいのですが、UIスレッドでない場合に備えて、UIスレッドにメッセージ送信しています。

    private void setPaste(String text){
        if( clipboardManager == null )
            return;

        clipboardManager.setPrimaryClip(ClipData.newPlainText("", text));
        handler.sendUIMessage(UIHandler.MSG_ID_TEXT, UIMSG_TOAST, "Receive Clipboard");
    }

以上

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む