Masaponto's Blog

お勉強メモ

TensorflowLiteでAndroidでCNN

この記事はAizu Adc 2018 20日目にかかれた4日目の記事です。
前の人は@xatu0202氏, 次の人@ywkw氏です。

こんにちは。@masapontoです。最近は東京でデータ分析太郎として暮らしております。

気がづくと本ブログはアドベントカレンダーでしか書かなくなってしまいました。 今回はTensorflow Lite を使って Android上で画像分類(CNN)をする話をしようと思います。

TonsorFlow Liteを紹介する多くの日本語記事は、サンプルとして用意されている学習済みモデルを動かしてみたっていう話が多い気がします(主観)。 それに対し本記事では、自身で定義して学習を行ったCNNを動かしてみようと思います。 データはCIFAR10っていう画像データセットを使います。
CIFAR10はairplane, automobile, bird, cat, deer, dog, frog, horse, ship, truckの10クラスの画像データセットです。 https://www.cs.toronto.edu/~kriz/cifar.html

※本記事の内容は、単に動かしてみたよって感じの記事なので、詳細な説明を求める方にはおすすめできません。 まぁ何いっても公式ドキュメントを読めばいい話です(完)。 https://www.tensorflow.org/lite/devguide

Tensorflow Liteとは

モバイル端末や組み込み端末で機械学習の推論をするぞいっていうライブラリです。
https://www.tensorflow.org/lite/

基本的な使い方としては、以下の3ステップです。

  1. PC上のTensorFlowでNNを学習させてモデルMを作る。 (on Python)
  2. MをAndroid向けに変換し、 M'を作る。 (on Python)
  3. M'をAndroidアプリのassets/フォルダとかにいれて、呼び出す。 (on Android)

手順 on Python

私の開発環境は、ArchLinux (x64) / Python 3.6.1/ tensorflow 0.12.0 です。

で、下記のようにCNNを学習させるコードを書きます。 いつのまにかTensorFlowにKerasインターフェースが入っていたので、それを使って書いてみました。

# !/usr/bin/env python

import tensorflow as tf


def convolutional():
    model = tf.keras.models.Sequential([
        tf.keras.layers.Conv2D(filters=32,
                               kernel_size=[5, 5],
                               padding='same',
                               activation='relu',
                               input_shape=(32, 32, 3),
                               name='input'),
        tf.keras.layers.MaxPooling2D(pool_size=[2, 2], strides=2),
        tf.keras.layers.Conv2D(filters=32,
                               kernel_size=[5, 5],
                               padding='same',
                               activation='relu'),
        tf.keras.layers.MaxPooling2D(pool_size=[2, 2], strides=2),
        tf.keras.layers.Flatten(),
        tf.keras.layers.Dense(units=1024, activation='relu'),
        tf.keras.layers.Dropout(rate=0.4, trainable=True),
        tf.keras.layers.Dense(units=10, activation='softmax', name='output')
    ])

    return model


def main():

    (X_train, Y_train), (X_test, Y_test) = tf.keras.datasets.cifar10.load_data()

    X_train = X_train/255
    X_test = X_test/255


    print(X_train.shape)

    Y_train = tf.keras.utils.to_categorical(Y_train, 10)
    Y_test = tf.keras.utils.to_categorical(Y_test, 10)

    model = convolutional()
    model.compile(optimizer=tf.keras.optimizers.Adam(0.0001),
                  loss='categorical_crossentropy',
                  metrics=['accuracy'])

    model.fit(X_train,
              Y_train,
              epochs=100,
              batch_size=50,
              verbose=1)


    # モデルの図示 (任意)
    tf.keras.utils.plot_model(model, to_file='model.png', show_shapes=True)

    # モデルをh5で保存
    keras_file = "model_keras/cnn_model.h5"
    model.save(keras_file)

    # TFLiteでコンバータを用意
    converter = tf.contrib.lite.TFLiteConverter.from_keras_model_file(keras_file,
                                                                      input_arrays=['input_input'],
                                                                      output_arrays=['output/Softmax'],
                                                                      input_shapes={'input_input': [None, 32, 32, 3]})
    # コンバート
    tflite_model = converter.convert()
    open("model_keras/converted_model.tflite", "wb").write(tflite_model)


if __name__ == '__main__':
    main()

ドキュメントにあまり詳しくない部分があって、ググって試行錯誤してなんとか動きました。
converterを作るときに、input_arraysとかoutput_arraysで名前を指定する必要があるっぽいです。 あと入力層のinput_shapesも指定する必要があるみたいです。(このへんよくわかってないので教えてください)

手順 on Android

Kotlinコードを下記においておきます。
動作確認環境はAndroidStudio20172.3/Kotlin 1.2.71/Zenfone 5/Android 8.0.0です。

準備 (TensorFlowLite)

下記を追記 (src: https://codelabs.developers.google.com/codelabs/tensorflow-for-poets-2-tflite/#6)
- app/build.gradle

android {
    // Add
    aaptOptions {
        noCompress "tflite"
    }
}

dependencies {
    // Add
    implementation 'org.tensorflow:tensorflow-lite:1.12.0'
}

また、作成したモデル(.tfliteファイル)は、AppName/app/src/main/assets/に入れます。

コード

下記のようなコードを書いて、MainActivityから呼びます。   classifyImageFromPath(file) でファイルパスを指定してやって分類する感じです。

package io.github.masaponto.tflitecifarten

import android.app.Activity
import android.graphics.Bitmap
import org.tensorflow.lite.Interpreter
import java.io.FileInputStream
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.MappedByteBuffer
import java.nio.channels.FileChannel
import android.graphics.BitmapFactory
import java.io.File
import java.nio.ByteOrder


class Classifier(activity: Activity) {
    private val MODEL_NAME = "converted_model.tflite"

    private val IMAGE_SIZE = 32
    private val IMAGE_MEAN = 128
    private val IMAGE_STD = 128.0f

    private var tffile: Interpreter
    private var labelProbArray: Array<FloatArray>

    init {
        tffile = Interpreter(loadModelFile(activity)) // deprecated
        labelProbArray = Array(1){FloatArray(10)}
    }


    @Throws(IOException::class)
    private fun loadModelFile(activity: Activity): MappedByteBuffer {
        val fileDescriptor = activity.assets.openFd(MODEL_NAME)
        val inputStream = FileInputStream(fileDescriptor.fileDescriptor)
        val fileChannel = inputStream.channel
        val startOffset = fileDescriptor.startOffset
        val declaredLength = fileDescriptor.declaredLength
        return fileChannel.map(FileChannel.MapMode.READ_ONLY, startOffset, declaredLength)
    }

    fun classifyImageFromPath(path: String): Int {
        val file = File(path)

        if (!file.exists()) {
            throw Exception("Fail to load image")
        }

        // load image
        val bitmap = BitmapFactory.decodeFile(file.path)
        val scaledBitmap = Bitmap.createScaledBitmap(bitmap, IMAGE_SIZE, IMAGE_SIZE,true)

        // convert bitmap to bytebuffer
        val byteBuffer = convertBitmapToByteBuffer(scaledBitmap)

        // classification with TF Lite
        val pred = classifyImage(byteBuffer)

        return onehotToLabel(pred[0])
    }

    private fun convertBitmapToByteBuffer(bitmap: Bitmap): ByteBuffer {
        val byteBuffer = ByteBuffer.allocateDirect( IMAGE_SIZE * IMAGE_SIZE * 3 * 4)
        byteBuffer.order(ByteOrder.nativeOrder())
        val intValues = IntArray(IMAGE_SIZE * IMAGE_SIZE)

        bitmap.getPixels(intValues, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
        var pixel = 0
        for (i in 0 until IMAGE_SIZE) {
            for (j in 0 until IMAGE_SIZE) {
                val v = intValues[pixel++]

                byteBuffer.putFloat((((v.shr(16) and 0xFF) - IMAGE_MEAN) / IMAGE_STD))
                byteBuffer.putFloat((((v.shr(8) and 0xFF) - IMAGE_MEAN) / IMAGE_STD))
                byteBuffer.putFloat((((v and 0xFF) - IMAGE_MEAN) / IMAGE_STD))
            }
        }
        return byteBuffer
    }

    fun classifyImage(bytebuffer: ByteBuffer): Array<FloatArray> {
        tffile.run(bytebuffer, labelProbArray)
        return labelProbArray
    }

    private fun onehotToLabel(floatArray: FloatArray): Int {
        val tmp = floatArray.indices.maxBy { floatArray[it] } ?: -1
        return tmp + 1
    }
}

全体のリポジトリ

github.com

参考: https://github.com/tensorflow/tensorflow/blob/master/tensorflow/lite/examples/android/app/src/main/java/org/tensorflow/demo/TFLiteImageClassifier.java

convertBitmapToByteBufferもよくわかってないです。(つらい..)

動かす

適当に拾った猫ちゃん画像を分類してみましょう。
f:id:masaponto:20181219234316j:plain:w200

f:id:masaponto:20181219234322j:plain:w200
クラスラベルは1:airplane, 2:automobile, 3:bird, 4:cat, 5:deer, 6:dog, 7:frog, 8:horse, 9:ship, 10:truckの順ぽいのでたぶん動いてますね(雑)。

まとめ。

Kerasインターフェースを使って簡単にCNNの学習コードかいて、携帯端末用に変換する事ができました。 ちなみに、tfliteに変換するときにNoneでなくintegerで指定すればバッチサイズを変更指定できます。 Android端末で機械学習ができるので、わりと作れるアプリの幅が広がった気がします。 こんな調子でお仕事がんばるぞい。

参考

機械学習のための実験プログラムについて

Aizu advent calendar 2017 8日目の記事である。

まえがき

本稿では機械学習(または数値解析)における「実験を行うプログラムの作成」から「実験結果資料の作成」までの流れとプログラムの構成を、僕がどのようにやっているかを紹介していく。 僕はPython3を用いて研究を進めているので、いくつかPythonライブラリの紹介もする。 書いているうちに去年書いた記事と似ていて新規性に欠ける気がしてきてけど、今回は過去記事の詳細版ということで。

本題

機械学習アルゴリズムの精度確認のための実験を行う際には、基本的に下記の4つのプログラムに分けて書くようにしている。

  1. 学習アルゴリズムのプログラム
  2. 入力として学習データ及びパラメータを受けとり、それを元に1.のプログラムを動かし、その結果を出力するプログラム
  3. 2.を行うために共通な処理を行うためのプログラム
  4. 入力として2.の結果を受け取り、グラフ画像や表を出力するプログラム

つまり、下の図になる。

f:id:masaponto:20171215095412j:plain
実験用プラグラムの処理の流れ

また、この図を元にしたディレクトリ構成は下記のようになる。

├── docs
│   ├── experiment_01.md
│   └── experiment_02.md
├── experiment_01
│   ├── algorithm.py
│   ├── experiment.py
│   ├── graph_generator.py
│   ├── utils.py
│   ├── graphs/
│   │   ├── {dataset_name}_{prameters}.png
│   │   └── {dataset_name}_{prameters}.eps
│   └── results
│       ├── {dataset_name}_{prameters}.csv
│       └── summary.md
└─── experiment_02
     ├── hogehoge.py
     ├── fugafuga.py
     .......

このようにプログラムを分割しておくと、追加実験による仕様変更に比較的少ないプログラム変更で対応できるかなと思う。
登場するものは、以下の6つである。

  1. 入力するデータセット
  2. 入力するパラメータ
  3. 実験用のプログラム
  4. 実験を助けるユーティリティ関数のプログラム
  5. アルゴリズムのプログラム
  6. グラフ生成用プログラム
  7. 実験資料

これら6つについてそれぞれ書いていく。

入力データセット

僕の研究では、公開データセットを使っている。 基本的には、UCI machine learning repositoryや、LIBSVM Dataで公開されているものである。 データのフォーマットがcsvlibSVM形式などの一般的な形式だと、大抵読み込むライブラリ(scikit-learnなど)が存在している。 しかし、たまに独自フォーマット(僕が知らないだけかもしれないが)のデータががあったりする。(あるいは2クラス分類問題の教師信号が{-1, 1}でない場合がある)その場合は、それ用のプログラムを書かねばならないので、非常にめんどうだったりする。これに限っては頑張るしかないかなと思っている。

Pythonでscikit-learnを使用している場合は、sklearn.datasets.fetch_mldata()という便利な関数がある。 fetch_mldata これはデータセットの名前の文字列を引数に与えるとmldata.orgから、ダウンロードしてきて、データセットのオブジェクト(Bunch object, sklearn.datasets.base.Bunch)で返してくれる。デフォルトでは、自身のホームディレクトリに~/scikit_learn_dataというディレクトリが生成され、その中にデータセットのファイルが入る。

まとめると、とりあえずmldata.orgを探して、あったらfetch_mldata()、なかったらUCILIBSVMから持ってきて、読み込んでBunch objectを返すって流れだと良いと思う。 scikit-learnを使わない場合も同様にデータセットを置いとくディレクトリを作っといて、データセット名を指定すると、データを特定の形式で持ってくるというプログラムを作ればよい。

入力パラメータ

パラメータの入力方法は実験プログラムを実行する際のコマンドライン引数にしている。 僕の場合は標準ライブラリのargparseを使っている。他にもpython-fireClickがあるが僕はまだためしていない。

複数の入力パラメータを用いて、それぞれで実験を試すことはよくあるので、使用したパラメータを実験結果の出力ファイル名にしておくと、わかりやすくて良いと思う。

実験プログラム

上記で紹介したデータセットの読み込み処理と、コマンドライン引数を用いたパラメータ指定は、この実験プログラムに記述する。 つまり、実験を行う際にはこのプログラムを実行し開始することになる。 実験プログラムは、入力にデータセットとパラメータを受けとり、アルゴリズムのプログラムやユーティリティプログラムを適宜呼ぶような構成になる。(アルゴリズムは複数の場合もある)

ユーティリティプログラム

Datasetの読み込みや交差検定、データの前処理など、プログラム中でよく使う関数はutilityとしてまとめたり、ライブラリ化すると便利である。

アルゴリズムのプラグラム

このプログラムは前節の実験プログラムから呼ばれることになる。 scikit-learnに準拠させる場合はBaseEstimatorClassifierMixinを継承させて、学習用の関数fit()と推論用の関数predict()を実装させればよい。 プログラムにおいて(これに限らず)、必要に応じてテストコードを書くとより良い。doctestを用いると関数定義の下に簡単にテストを書くことができるので、おすすめである。 また、ここもアルゴリズムのプラグラムとして独立させておき、ライブラリ化すると便利である。

参考
sklearn準拠モデルの作り方 - Qiita
GitHub - masaponto/Python-ELM
GitHub - masaponto/Python-DBM

実験結果

実験結果はcsvファイル、またはmarkdowntex形式の表にして実験プログラムから出力するようにしている。これは、後のグラフ生成や資料作成を楽にするためである。また、実験結果は、指定ディレクトリに保存すると同時に、ぼっちslackに送るようにしている。簡易的なバックアップやログにもなるし、計算用のサーバで実験を行った最には結果を通知させる役割にもなり非常に便利である。slackに送るにはslackteeを用いると良い。slackteeは標準出力した文字列を任意のslackに投稿する機能を提供する。実験プログラム実行の際に、$ python experiment.py | slacktee.sh -c <chanel-name>とすれば良い。これがなかなか便利で、過去に僕はkamebotを作ったりしたが、全然使わなくなってしまった。ちなみに、kamebotはPythonコードの中の関数にデコレータをつけることで、その関数内の標準出力を指定slackに送るというものである。

一方、表の生成にはpython-tabulateが便利である。markdowntexなどの表形式に対応しているので、何も考えずにそのまま研究資料に貼り付けるだけで済む。

参考: ぼっちsalck

グラフ生成

結果のcsvファイルの読み込みには、Numpynumpy.load_txt()という関数が便利である。ファイルの文字列を引数にとり、Numpy array形式にして返してくれる。また、pandaspandas.read_csv()で読み込むと、csvにヘッダとして文字列が入っていても問題なく読み込むことができる。 グラフの生成にはmatplotlibを使う。この際、epsファイル(などのベクター画像)とpngファイルを生成するようにしている。epsファイルは論文用、pngファイルはmarkdownドキュメントとスライド用である。

グラフを生成する際に注意することは2つある。グラフの軸のラベルを必ずつけることと、グラフのタイトルはつけないことである。 前者、後者ともに読者にわかりやすくするためである。後者の方は、グラフ画像にタイトルをつけると、論文に画像として読み込んだ際に、latexのfigureのcaptionとでグラフの名前の記述が重複してしまい読みにくくなるからである。

実験資料

実験結果として他人に伝えるためには、得られたものを資料にまとめなければならない。 僕はmarkdwonで資料を書いて、pandocを使って、pdfを生成するようにしている。 pandocとはドキュメント向けのファイルを様々な形式で変換できるツールである。また、pdfへの変換にはlatexを経由するので、数式も対応する。(latexの形式で数式を記述する) 適当な資料なら$ pandoc document.md -o document.pdfでpdfが生成されてOKである。 しかし、表紙のついた洒落た資料にしたいときもある。 その場合は、下記のテンプレートと使うと良い。

github.com

これを使ってpandoc on Dockerで資料を作るためのキットを作ってみた。

github.com

Docker環境さえあればどこでも$ makeで動く。

おわりに

3年生のときに研究をはじめて、実験プログラムを作りはじめたとき、よく考えずにファイルを作っていたため、ディレクトリの構成がごちゃごちゃになっていた。 そういう過去があったので、本稿のような記事があれば少し役立つかなと思い、書くことにした。 今でも、追加実験が複数続いたりすると、構成がわかりにくくなってきてしまうことがあり、難しいなと感じている。 Pythonのようにゴリゴリ書けてしまう言語だと余計そうなりやすいのかもしれない。 大切なのは、できるだけ共通部分とそうでない部分に分割し、前者をライブラリ化していくことだと思う。

本稿が機械学習や数値解析を用いた研究を進めていく際の参考の一つになるとよいと思う。

そんな感じで修論がんばるぞい!

おまけに修論用Dockerを置いておく。
修論用 · GitHub

MESHでラズパイにお天気情報をしゃべらせた話

MESHのボタンを押すとRaspberry Pi 3 model Bがその日の天気をしゃべるようにした。本稿はその作業メモである。

構成

<MESH>--<iPad>--<ラズパイ>--<スピーカ>

Rasberry Pi 3

OSはRASPBIAN JESSIE LITEにした。 ローカルサーバにしようと思ったのでGUI環境はいらないからである。 ddコマンドでmicroSDに焼いて終わり。
また、ラズパイの電源確保のためAnkerのUSB充電器を買った。

Open Jtalk

ラズパイに日本語をしゃべらすのにOpen Jtalkを使った。 Open Jtalkで日本語の文字列をしゃべる音声ファイルを生成し、aplayで再生する。 「Raspberry pi Open Jtalk」でググると多くの記事が見つかる。
以下のページを参考にした。
クラゲのIoTテクノロジー
メイちゃんの声にした。

Python2で天気情報をしゃべらせる

以下のページを参考にした。
Raspberry Piにしゃべらせてみた(OpenJTalk 1.08、.htsvoiceファイル対応): Raspberry Piでやってみた
Raspberry Piに現在時刻と、天気をしゃべらせてみた(Python): Raspberry Piでやってみた

天気情報にはWeather Hacksを用いた。 Python bottle, BottleDaemonでサーバを立ててリクエストがきたら天気情報を取得してしゃべるようにした。
実際のコードは以下のとおり。
MESHボタン押すとラズパイが天気喋るよ · GitHub

BottleDaemonにより

$ python app.py start

これでデーモンとして動く。

MESHとの連携

MESHを使用するにはMESH用iOS/Androidアプリと連携させる必要がある。 MESHとiOS/Android端末はBluetoothで接続する。 筆者はiPad 4thを使うことにした。 MESH用iOS/AndroidアプリではMESHからのイベントを取得し、音声再生やメール送信、Javascriptの実行等をGUIプログラミングすることができる。ただし、Javascriptのコードは、別途に書いてMESH SDKに登録する必要がある。なお、アプリはバックグラウンドにしていても動作する。
以下のページを参考にした。
KB_1506/developLog_Software_MESH_Tag.md at master · jphacks/KB_1506 · GitHub

実際のコード以下のとおり。 f:id:masaponto:20170421231707p:plain MESHボタン押すとラズパイが天気喋るよ · GitHub

作業は以上である。

おわり

このページの動画を見て作りたくなった。偶然MESHのボタンタグを入手できたので、今回のことをやろうと思った。これで毎朝、画面を見ることなく天気情報が得られる。今後アイデアがあれば機能を増やしていきたい。