Firebaseを活用したiOSアプリ開発事例

こんにちは。新規サービス開発部の中村です。

最近Komercoで販売されている鉄のフライパンが欲しいです。クリエイターさんたちの作品は見ているだけで本当に楽しいですね。

そんなKomercoはバックエンドにFirebaseを活用していますが、実は弊社からKomercoの他にもFirebaseを活用したサービス「Cookin'」をリリースしています。

本稿ではCookin'のFirebaseを活用した事例についてご紹介します。

Cookin'とは

Cookin'は料理動画撮影アプリです。手順ごとに3秒間取るだけで1本の料理動画が投稿できるサービスとして、2017年12月にiOSアプリとしてリリースしています。

このサービスの特徴は、簡単に料理動画が作成できるほかに、投稿から斬新なアイデアを得られたり、コメント欄から料理のコツやポイントを気軽に質問できるところです。

料理をしながら動画撮影するのは難しいですが、慣れると楽しいです。

App Store リンク

誠に勝手ではございますが、Cookin’は提供サービスの見直しにともない、2018年7月末をもちまして、提供を終了いたします。ご利用のお客様には、ご迷惑をおかけいたしますことを深くお詫び申し上げます。サービス終了に関するご案内はこちらをご覧ください。

FIrebaseの活用事例

先ほど述べた通り、Cookin'のバックエンドはFirebaseを活用しています。Firebaseには様々なプロダクトが用意されていますが、Cookin'では以下のプロダクトを活用しています。

アプリの開発とテスト ユーザー層の拡大と利用促進
Authentication Cloud Messaging
Cloud FIrestore Google Analytics for Firebase
Cloud Storage
Cloud Functions

以降ではこれらの概要と活用事例をご紹介します。

Authentication

Authenticationは安全な認証システムを提供しています。メールアドレスとパスワードの組み合わせ、電話番号認証、匿名認証、Google、Twitter、Facebook、Githubのログイン等をサポートしています。

活用事例

ユーザーの認証に匿名認証を活用しています。これにより、データベースやストレージのセキュリティーを堅牢にしつつ、初回起動時のアカウント作成プロセスを省略することでユーザーの離脱を防いでいます。

匿名ユーザーは必要に応じてメールアドレスとパスワードでの認証に切り替えることも可能です。

匿名ユーザーのアカウントを停止したい場合は一手間必要になります。詳細はこちらのブログ記事をご覧ください。

Cloud Firestore

Cloud FirestoreはNoSQLドキュメントデータベースです。オフラインの場合でもデータにアクセスして変更を加えることができ、オンラインに復帰すると自動的に変更したデータを同期します。スケーリングは自動的に行われ、セキュリティールールを書くことでセキュリティーを堅牢にできます。

活用事例

  • 投稿情報、コメント、ユーザー情報等を保存しています。
  • 投稿画面に閲覧しているユーザーのアイコンをリアルタイムで表示しています。このようなリアルタイム機能を素早く実装できることも大きな特徴です。

    閲覧ユーザーアイコンのキャプチャ

  • ユーザーページの投稿一覧は、クエリ機能を利用して全体の投稿から特定のユーザーの投稿を取得して表示しています。また、クエリを利用してソートやページングを実装しています。

    全体の投稿から特定のユーザーの投稿を取得するクエリの例

    postsRef.whereField("authorID", isEqualTo: user.id)

    ユーザーページのキャプチャ

現時点ではCloud Firestoreにバックアップ機能が提供されていないため、バックアップする場合は自前で行う必要があります。

Cloud Storage

Cloud Storageは写真や動画等の容量が大きなファイルを保存できます。Cloud Firestoreと同様オフラインサポートと高いスケーラビリティを備え、セキュリティールールを書くことでセキュリティーを堅牢にできます。

活用事例

  • 動画やサムネイル画像を保存しています。

  • セキュリティールールの例を上げると、Cloud Storageにアップロードされる動画のファイルサイズを1MB以下に制限するために、下記のセキュリティールールを書いています。

セキュリティールールの例

service firebase.storage {
  match /b/{bucket}/o {
    match /version/1 {
      match /video/{videoID}/file/{file} {
      
        // 認証済みユーザーのみ動画ファイルの読み込みが可能
        allow read: if request.auth != null;
        
        // 動画ファイルが書き込まれる際の条件
        allow write: if (request.auth != null && request.resource == null)
                     || (request.auth != null &&
                     // 1MB 以上の動画ファイルは許可しない
                     request.resource.size < 1 * 1024 * 1024 &&
                     request.resource.contentType.matches('video/.*'));
      }
    }
  }
}

Cloud Functions

Cloud FunctionsはCloud FirestoreやCloud Storageへのデータの追加や変更、またはHTTPSリクエストによりトリガーされたイベントに応じてバックエンドコードを自動的に実行できます。

活用事例

  • プッシュ通知やSlackへのメッセージ送信に活用しています。例えば、ある投稿に新しいコメントが書き込まれたときに、投稿者にコメントが書き込まれたことをプッシュ通知で知らせたり、アプリから不適切な投稿が報告されたときにSlackにその報告を流しています。

    Cloud Functionは1つのイベントを元に複数回トリガーされることがあるため、関数は何回実行されても問題ないように実装しておく必要があります。詳細はこちらのブログ記事をご覧ください。

  • 関数のディレクトリ構造・命名は以下のようにしています。これにより、コードを修正をするときにどのイベントから実行される関数なのか把握しやすくしています。

それぞれのイベントから実行される関数が明確になります。

├── functions
│   ├── auth
│   ├── db
│   │   ├── comment
│   │   │   └── onCreate.ts
│   │   ├── feedback
│   │   │   └── onCreate.ts
│   │   ├── post
│   │   │   └── onCreate.ts
│   │   └── report
│   │       └── onCreate.ts
│   └── storage
├── index.ts
├── test

どのイベントから実行される関数なのかファイルの中を確認しないと分からないため把握しにくいです。

├── functions
│   ├── notifyPost.ts
│   ├── notifyComment.ts
│   ├── notifyReport.ts
├── index.ts
├── test

関数のテスト

テストフレームワークはJestを利用しています。

テスト方法はオフラインモードとオンラインモードの2種類あり、オフラインモードで行う場合、データベースの書き込みを全てスタブしなければならないため、オンラインモードで行っています。

オンラインモードではデータベースへの書き込みやユーザーの作成などが実際に行われ、テストコードがその結果を検査できるように、テスト専用のFirebaseプロジェクトとやり取りするテストを作成します。

注意点として、Firebase Test SDKのfirebase-functions-testnpmモジュールをテスト専用のFirebaseプロジェクトの構成値で初期化した後、メイン関数ファイルをモジュールとしてインポートしなければなりません。

この順序を守らないと予期しないFirebaseプロジェクトとやり取り(書き込み・読み込み)が行われてしまうことがありました。

防止策として、JestのsetupFilesのタイミングでfirebase-functions-testnpmモジュールを初期化するようにしています。

Cloud Messaging

Cloud Messagingは、iOS、Android、ウェブ(JavaScript)クライアントアプリに通知メッセージを送信することができます。

活用事例

Cloud FunctionsからAdmin FCM APIを利用してプッシュ通知を送信しています。

ユーザーが料理を始めたことを知らせる通知自分の投稿にコメントが届いたことを知らせる通知の2種類を送信しています。

Google Analytics for Firebase

Google Analytics for Firebaseは、最大で500種類のイベントに関するレポートを無料で無制限に生成できます。Firebaseのコンソールからダッシュボードを見ることができます。

活用事例

ユーザーの動画閲覧から動画投稿までのファネルを作成して活用しています。

その他のライブラリ

以降はFirebaseの他に活用しているライブラリの一部をご紹介します。

Pring

データの読み込み・書き込みにPringを活用しています。

PringはCloud FirestoreとCloud StorageのO/Rマッパーで、Cloud FirestoreとCloud StorageのAPIを意識せずにデータの読み込み・書き込みができます。

Pringを活用すると開発スピードを加速させることができます。

FilterCam

iOS SDKのCore Image FIlterを適用した動画を簡単に作成することができます。

撮影した料理がより魅力的になるように彩度と中間色の明るさをやや上げたフィルターをFilterCamに適用して活用しています。

適用前(左) 適用後(右)

まとめ

本稿を通してiOSアプリのバックエンドにFirebaseを活用した事例をご紹介しました。

Firebaseは開発が活発で新しい機能が追加され続けているので、今後もより使いやすくなることが期待できます。プロダクトの性質に合わせて、うまく活用できれば大きなメリットが得られるのではないでしょうか。

最後になりましたが、冒頭に記載しました通り、Cookin'は7月末をもちまして提供を終了いたします。これまでCookin'を支えてくれた皆さまに感謝申し上げます。ありがとうございました。

x3 Speed Up Android CI at Cookpad

海外事業部の松尾(@Kazu_cocoa)です。こちらは、私たちの x3 Speed Up Android CI at Cookpad に公開した記事の日本語訳です。英語でご覧になりたい方は原文を一読ください。

以下に登場するAndroidアプリは海外版のクックパッドアプリとそれにまつわる環境を指します。国内のものと構成も異なるところがありますので、そのあたりを頭に入れつつ読んでいただければと思います。


この記事では、現在のAndroidアプリ開発向けCI環境の紹介とその環境構築の流れを紹介します。現在の環境では、GitHub上に作成されたプルリクエストへのプッシュ毎にビルド・テストの実行含めて処理が 7分程度 で完了します。これらの処理にはAPKの作成、各種テストの実行が含まれます。

以前は、私たちのCIはプッシュ毎に 合計で25分程度 かかっていました。そのころは、2つの役割の異なるJenkins Jobを並列実行させていました。合計25分はそれらを含めた合計値です。

以下では、どのようにしてこのような環境を構築したのかを知ることができます。テストの実行環境として、エミュレータの並列起動とそれを用いたテスト実行の話を載せています。この記事ではCIにおけるビルド/テスト環境に焦点を当てるため、他の話は行いません。

マルチモジュール、1000以上のテストケース

まずは対象となるAndroidプロジェクトの概要の共有です。

Androidアプリ

  • 20個のモジュールを保持
  • unit/instrumented/Espressoを含む、合計1000以上のテストケースを保持
  • ビルド毎、社内配布用のapkを配布

現在のCI環境

現在のCI環境は以下で構成されています。

  • AWS上に構築されたJenkins環境
    • Master/Slave構成(Android向けのJenkins Slaveには i3.metal instanceを利用)
    • Jenkins環境はAndroid以外のプロジェクトも利用
  • GitHubへのプッシュ毎に実行されるタスク
    • unit/instrumented/Espresso testsの実行
      • 取得可能なものはカバレッジの取得も含む
    • 必要なapkのビルド
  • テスト端末
    • 合計14個のエミュレータを作成・起動し、並列にAndroid APIの必要な instrumented/Espresso テストを全て実行
  • ガイド

擬似的な Gradle タスクを用いて上記を模倣すると、以下のような処理がプッシュ毎に実行されています。

./gradlew clean
./gradlew checkLicenses
./gradlew testAllUnitAndInstrumentedTests
./gradlew assembleOurInternalApk
./gradlew uploadApkForInternal

GitHubへのプッシュを契機に以下のような流れで処理が Jenkins Slave 上で実行されます。

GitHub <----> Jenkins (master) <-----> Jenkins (slave)
                                        Build/All Tests

旧CI環境

変更する前の環境は以下です。

  • AWS上に構築されたJenkins環境
    • Master/Slave構成(Android向けのJenkins Slaveには i3.metal instance 以外 を利用)
    • このJenkins環境はAndroid以外のプロジェクトも利用
  • GitHubへのプッシュ毎に実行されるタスク
    • unit/instrumented/Espresso testsの実行
      • 取得可能なものはカバレッジの取得も含む
    • 必要なapkのビルド
  • テスト端末
  • ガイド

旧CI環境では、以下のように2つの Jenkins Slave を用意し、実施するテストを分ていました。

GitHub <----> Jenkins (master) <-----> Jenkins (slave) 1
                        |              Build/non-UIテスト
                        |------------> Jenkins (slave) 2
                                        Build/UIテスト

上の図に出ている non-UIテスト とは、unit/instrumentation-based JUnitを指す、UI描画を含まないテストをさします。これらにはGenymotion Cloudを使い、いくつかのビルド含む18~20分かけてビルドしていました。 UIテスト とは、Espressoを使ったテストです。OpenSTFを使いテストを実行していました。いくつかのビルド含む、合計で15分程度かかるジョブです。

2つの異なるJenkins job環境

UIテストはOpenSTFを用いて実行していた、と上記で書いています。

AWS上では、ARMベースのAndroidエミュレータしか実行できませんでした。そのため、UIテストは特に実行/描画速度が遅く、UIの描画を含むテストを安定して実行することが厳しかったです。私たちの環境では、Genymotionも多少なりもとも制限を有していました。そのため、UI描画が不要なテストと、UI描画が必要なテストは分けて実行していました。

各々のJenkins jobは並列して実行されるようにしていました。それぞれ、15分程度は少なくとも実行環境まで時間がかかります。

このくらいの待ち時間になると少しコーヒーで一息つくことができる感じですね。

f:id:kazucocoa:20180705183344j:plain

よりJenkins Jobを細かく分割などすれば時間は短縮することはできますが、Jobの管理などが複雑になってきます。

なぜAWSを使っているか

現在、私たちは可能な限り自分たちで物理リソースを保守したくありません。私たちはAWSを主に利用しています。

強力な物理マシンを用意しそれをCIとして利用できれば今回と同様のことを実現できるでしょう。その場合は環境を拡大するさい、そのつど端末購入が必要になってきます。現在のiOSビルド環境に似たようなものですね。それは保守コストや将来的にもチーム拡大の足枷になるとふんでいました。

そのため、私たちはAWS上で利用可能なそのようなマシンを待っていました。

AWSのベアメタルインスタンス

昨年の終わり頃、Bare Metal Instances for EC2が発表されました。その環境下では、Androidのx86エミュレータによる実行が可能になると期待できていました。Androdエミュレータ向けのVMアクセラレーションの各種機能を利用できることも期待できました。

少し、x86エミュレータについて説明を残しておきます。

Googleは公式のAndroidエミュレータを公開しています。そこから、私たちはいくつかのCPUアーキテクチャ上で動作するエミュレータを利用することが可能です。その中で、 x86 とはInetel Processor上で動作可能なものを指します。

AWS上では、元々はARMエミュレータだけが利用可能で、x86エミュレータは利用不可能でした。一方、ベアメタルインスタンスではx86エミュレータも利用可能です。

新しい環境

Amazon EC2 Bare Metal Instances の一般向け公開をお知らせします によってベアメタルインスタンスが利用可能になったのち、私たちはJenkins Slaveとしてその環境を検証し、使いはじめました。その途中でいくつか問題にぶつかったので、その経験を共有したいと思います。

i3.metalでAndroid環境を構築する

i3.metalの設定

現在、ベアメタルインスタンスは入手可能な地域が限られています。まずは利用したい地域でベアメタルインスタンスを入手可能か確認してください。 ベアメタルインスタンスはEC2向けの通常のインスタンス起動ウィザードから作ることが可能です。ウィザード起動後、AMIを選択してウィザードを次に進めてください。表示されるインスタンス一覧の最下部に i3.metal の表記を見つけることができるはずです。その i3.metal がこのベアメタルインスタンスです。i3.metal 選択後、各々の環境にあった設定を選んでいき、インスタンスを作成してください。

なお、以下ではUbuntuをベースとしたAMIで動作を確認しています。そのほかの環境では手順が異なるところがあるかもしれませんのでお気をつけください。 i3.metal は起動までに少し時間がかかります。その間、エスプレッソでも飲みながら待ちましょう。

f:id:kazucocoa:20180705183349j:plain

Android SDKの入手

まずは Android SDK コマンドラインツールを取得する必要があります。ダウンロードページからLinux向けのものを入手してください。 i3.metal インスタンス上でそれらを展開した後、 ANDROID_HOMEANDROID_TOOLS の設定を忘れずに行なってください。

各種アクセラレーションを有効にする

i3.metal を使った大きな理由は、先でも少し述べたようにAndroidエミュレータの各種アクセラレーション機能を使うことです。この機能はARMエミュレータでは利用できません。そのため、上で述べたように私たちはOpenSFTやGenymotionをAndroid CIに使っていました。

VMアクセラレーション

Configure VM accelerationでは、VMアクセラレーション環境の構築を知ることができます。

私たちはUbuntuベースのi3.metal インスタンスを構築しています。そのため、Ubuntu KVM Installationに沿って環境構築を進めました。環境構築の後、以下のような入力に対して出力が得られたのであればでは、VMアクセラレーション環境環境の構築は完了です!

$ sudo /home/ubuntu/android/tools/emulator -accel-check
  # accel:ad
  # 0
  # KVM (version 12) is installed and usable.
  # accel

SwiftRenderによる描画のアクセラレーション

描画に対するアクセラレーション環境はいくつかの種類提供されています。 VMアクセラレーションだけでinstrumented testsに対する高速化は十分です。ただ、UIの描画のからむEspressoのテストも含んでくるとそれでは足りません。この環境構築が必要となってきます。

この中で、私は1つ問題に出くわしました。

OpenGL 関係の描画問題

まず、私は host モードを使ってみました。OpenGLが利用可能であれば i3.metal でも macOS など同様の描画が可能だと期待したためです。しかし、Could not initialize OpenglES emulationというエラーに出くわし、エミュレータを起動することはできませんでした。

Elastic GPUを用いることができるかと期待しましたが、この時は利用することができませんでした。

次に、私はエミュレータの -no-window モードを利用しました。エミュレータの起動には成功したのですが、Espressoのテストを実行すると以下の例外が発生してテストが中断されました。

E AndroidRuntime: DeadSystemException: The system died; earlier logs will point to the root cause
W System.err: java.lang.Throwable: tname=main - android.os.DeadSystemException
W System.err:    at adgh.a(PG:17)
W System.err:    at adgh.uncaughtException(PG:20)
W System.err:    at java.lang.Thread.dispatchUncaughtException(Thread.java:1955)
W System.err: Caused by: java.lang.RuntimeException: android.os.DeadSystemException
W System.err:    at android.app.ContextImpl.registerReceiverInternal(ContextImpl.java:1442)
W System.err:    at android.app.ContextImpl.registerReceiver(ContextImpl.java:1394)
W System.err:    at android.app.ContextImpl.registerReceiver(ContextImpl.java:1382)
W System.err:    at android.content.ContextWrapper.registerReceiver(ContextWrapper.java:609)
W System.err:    at ajmt.a(Unknown Source:10)
W System.err:    at akmp.a(Unknown Source:117)
W System.err:    at akma.a(Unknown Source:12)
W System.err:    at akmu.a(Unknown Source:7)
W System.err:    at aklm.a(Unknown Source:8)
W System.err:    at ajqf.a(Unknown Source:2)
W System.err:    at ajpo.handleMessage(Unknown Source:11)
W System.err:    at android.os.Handler.dispatchMessage(Handler.java:106)
W System.err:    at android.os.Looper.loop(Looper.java:164)
W System.err:    at android.app.ActivityThread.main(ActivityThread.java:6494)
W System.err:    at java.lang.reflect.Method.invoke(Native Method)
W System.err:    at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:438)
W System.err:    at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:807)
W System.err: Caused by: android.os.DeadSystemException
W System.err:    ... 17 more
I Process : Sending signal. PID: 3501 SIG: 9

これはQtの問題だと予測したので、apt-get install xorg openboxQT_QPA_PLATFORM='offscreen' を試してみました。しかしそれでも改善はしませんでした。

解決策を試行錯誤している中で、最終的にSwiftRenderが助けてくれました。no-windowオプションと合わせてエミュレータを起動した結果、正しくエミュレータが起動し、Espressoのテスト実行も完了することができました。 Configuring graphics acceleration on the command line にも書かれている通り、これは swiftshader_indirect モードによるエミュレータ起動で利用することができるものでした。このライブラリはOpenGL ESをCPU処理を使い実現するものです。

まとめ

ここの章は少し長かったので軽くまとめます。

  • VMアクセラレーションを有効にするためにKVMをインストールする
  • 描画のアクセラレーションを有効にするためにSwiftRenderを有効にする
    • このライブラリは $ANDROID_HOME/emulator/lib64/gles_swiftshader に見つけることもできます
  • エミュレータは "-no-boot-anim -no-snapshot-save -netfast -noaudio -accel on -no-window -gpu swiftshader_indirect" のオプションで起動する

ここで、 i3.metal インスタンスのAndorid向けビルド環境構築の説明は終わりです。こちらをJenkins Slaveとして利用可能にすると、Android向け Jenkinsインスタンスとして利用可能になります。

最後に1つ、 i3.metal インスタンスの良い点を以下にあげておきます。実は、個人的には以下の環境を手に入れることがベアメタルインスタンスの、もう一つの大きな目的でした。これにより、短時間のAndroidテスト環境を構築することができます。

CI上でテストを並列実行する

i3.metal インスタンスは非常に高性能です。72コアや、大量のメモリを搭載しています。そのため、苦無く複数エミュレータを同時に起動可能です。

エミュレータの複数同時起動により、Androidテストを実行するときに必要となる adb install の1つのエミュレータに対する実行頻度を下げることができます。shardingの機能も利用することができます。エミュレータの起動数を上げることで、テストの並列実行数をあげることもできます。

テスト対象となるapkのインストール時間はAndroidテストの実行の中で時間を必要とする処理です。私たちのプロジェクトにはモジュールが20個あります。その中で、14個のモジュールにAndroidテストが存在します。そのため、14モジュール分のandroidTest実行における adb install が処理される必要があります。それが直列で行われる場合、それなりに時間がかかってしまいます。それを解決するために、各々のモジュール1つに対して1つ専用のエミュレータを作成し、並列してそれらを実行できるようにしました。

以下のGIFアニメーションが並列実行の例です。現在は、このような状況が合計で14個、Jenkins Slave上で動作しています。並列実行を達成するためにcomposerswarmerを利用しました。(感謝も含めて、何か問題や追加したい機能があればPRを送るなども行いたいですね。)

f:id:kazucocoa:20180705183355g:plain

以下のスクリプトは複数のサブプロセスを操作するbashスクリプトの例です。これにより、シェルのサブプロセスでバックグラウンド実行するAndoridテストの終了を待ち、結果を集計してJenkinsの成功/失敗をまとめることができるようになっています。

function wait_and_get_exit_codes() {
    children=("$@")
    EXIT_CODE=0
    for job in "${children[@]}"; do
       echo "PID => ${job}"
       CODE=0;
       wait ${job} || CODE=$?
       if [[ "${CODE}" != "0" ]]; then
           EXIT_CODE=1;
       fi
    done
}

EXIT_CODE=0
children_pids=()

# composerスクリプトを `&` 指定で実行
children_pids+=("$!")

wait_and_get_exit_codes "${children_pids[@]}"

exit "${EXIT_CODE}" # いずれかのサブプロセスの終了コードが1の場合だと、ここが1になる

Conclusion

読んでいただいてありがとうございます。

上記により、 i3.metal インスタンスを使ったAndroid CIの環境構築方法を知ることができました。また、複数エミュレータ起動によるテストの並列実行の様子も見ることができたかと思います。

これらの対策により、AndroidのCI環境は今までよりも高速に、安定するようになりました。全てのプッシュに対するCI実行は7分程度で完了します。まだチューニングの余地はあるので、必要であればより最適化する余地もあります。ベアメタルインスタンスは価格がほかのインスタンスよりも高価なので、より効率的な利用ができるようにしていくことも将来的な挑戦でもあります。

最後に、検証・本番環境構築にKohei Suzukiさん、 Takayuki Watanabeさんの協力もいただきました。ありがとうございます。

Firebase ML Kitで自作のカスタムモデルを使って料理・非料理画像を判定できるようにした

会員事業部の山下(@farmanlab)です。 Androidエンジニアとしてクックパッドアプリの開発を担当しています。

今回はGoogle I/O 2018で新しく発表されたML Kitをクックパッドのデータで学習したモデルを使って検証した話をします。

機械学習モデルの利用にあたって、研究開発部の菊田(@yohei_kikuta)の協力の元で検証を行いました。

これからお話する内容がイメージしやすいよう、 クックパッドの料理・非料理を判別するモデルを動かした実機デモをお見せします。

これは料理と判定された確率がfood、料理ではないと判定された確率がnon-foodというラベルのスコアで表示されているデモです。 (非)料理画像において(non-)foodのラベルのスコアが大きくなり正しく判別できていることが分かります。

f:id:farmlanlabdev:20180704182402g:plain

  • モデルは MobileNetV2
  • tensorflow-gpu==1.7.1で学習してTOCOでTensorFlow Liteのモデルを作成
  • アプリ側では入力データやモデルの重みはfloatで処理

ML Kitとは

ML Kitとはモバイルアプリ向けに機械学習機能を組み込むことができるSDKです。 Firebaseの機能の一つとして提供されており、Android/iOSに簡単に導入することができます。 大まかに以下のような特徴があります。

  • デフォルトで以下の機能が利用可能

    • 文字認識
    • 画像ラベリング
    • バーコードスキャン
    • 顔検出
    • ランドマーク認識
  • オンデバイスモードとクラウドモードで利用可能

オンデバイスモードでは端末にモデルをダウンロードすることでオフラインで動作し、無料で使うことができます。 クラウドモードはCloud Vision APIを使ってオンデバイスよりも精度の高い情報を得ることができる代わりに Firebaseの課金プランをBlazeにする必要があり、一定回数以上の利用は有料です。

  • 独自のカスタムモデルを利用可能

デフォルトで提供される機能以外に独自の機械学習モデルを利用することができます。 ちなみにカスタムモデルの利用にあたってはFirebaseの課金プランをBlazeにする必要はありません。

  • Android Neural Networks API(NNAPI)との連携

ML KitはAndroid8.1で導入されたNNAPIとの連携がSDKに含まれているため、 開発者がNNAPIに関するコードを書く必要がありません。

ML Kitとカスタムモデルを導入するまでの流れ

以下のステップでカスタムモデルをML Kitで使えるようにします

  1. アプリの依存関係にML Kitを追加
  2. TensorFlowの機械学習モデルをTensorFlow Liteのモデルに変換する
  3. FirebaseでTensorFlow Liteのモデルをホストする
  4. アプリでFirebaseのモデルをバンドルする
  5. バンドルしたモデルを利用して推論する

1と3については公式に詳しいので、説明はそちらに譲ります。 ここでは2と4と5について掘り下げていきましょう。

ML Kitはクイックスタートサンプルが用意されているので、 このサンプルに元にしつつ、自分たちのモデルを動かす上で必要になるポイントも加えて説明していきます。

2.TensorFlowの機械学習モデルをTensorFlow Liteのモデルに変換する

サンプルを動かす場合はGitHubのレポジトリmobilenet_quant_v1_224.tfliteが用意されているので、特に準備をする必要はありません。

自分のモデルを使う場合は、一言で言えばTOCOというTensorFlowのモデルからTensorFlow Liteのモデルに変換するツールを使えばいいのですが、自分で学習したモデルを使うには注意を要します。 ここではその部分を詳しく解説します。

今回はクックパッドで使われている料理・非料理判別モデルを実装します。 モデルはサンプルに倣って基本的にはMobileNetV1を使います。 冒頭で示したようにMobileNetV2でも実装ができていますが、これはV1の実装ができれば(モデルアーキテクチャ以外)全く同様にできるため、ここでは試行錯誤の過程を紹介する意味でもV1の話をします。 それぞれのモデルの詳細はこの記事では解説しませんが、学習済みのモデルはV1はこちらV2はこちらにあります。

自分たちが準備したデータでモデルを学習する部分には新しいことはなく、tensor-for-poets-2のコードtensorflow/hubのコードがそのまま使えます。 今回は料理・非料理の二値分類を対象としました。

ここで作成したTensorFlowのモデルからML Kitで適切に動作するTensorFlow Liteのモデルを作るところで苦戦したので、気をつけるべき点と共に手順を紹介します。

TensorFlow Liteモデル(.tflite)の作り方

ここでは、TensorFlowで学習して作成した xxx.pb ファイルから model.tflite ファイルへ変換することを考えます。 例えば、model_graph.pbmodel.tfliteファイルに変換するコマンドは以下のようになるイメージです。

toco \
  --input_file=/tmp/model_graph.pb \
  --input_format=TENSORFLOW_GRAPHDEF \
  --output_file=/tmp/model.tflite \
  --output_format=TFLITE \
  --input_arrays=input \
  --output_arrays=final_result

このように、TensorFlowでモデルの学習をする場合はそのままTOCOを使えば.tfliteを作れるため、変換それ自体に困難はありません。 ただし、TensorFlowのバージョン依存性が強いので注意が必要です。 この記事における我々の結果は全てtensorflow-gpu==1.7.1で実行したものとなります。

他のフレームワークでモデルを学習する場合は一旦TensorFlowのモデルに変換する必要がありますが、変換用のライブラリは色々出てるので、標準的なoperationのみを使っていれば可能だと思います。 後述しますが、TensorFlow Liteではでサポートしているoperationはまだ限定的なので、特殊なoperationを含むモデルを使う場合は自分でTensorFlow Lite側の実装をする必要があります。

また、コンバーターであるTOCOは重みの量子化などのオプションも有していて、これを使ってfloatで重みを扱うモデルから量子化して扱うモデルを作ることもできます(正確には、Fake quantizationという、重みはuint8で扱うが出力はfloat32として扱う機能が提供されています)。

サンプルと同じようにやってみて上手くいかなかった話

単純に考えれば、サンプルで動いているモデルに基づき、自分たちのデータを使って再学習したモデルをTOCOを使って.tfliteファイルに変換するだけで上手くいくはずです。 ML Kitのサンプルではmobilenet_quant_v1_224.tfliteという重みが量子化されたMobileNetV1が使われているので、とりあえずMobileNetV1の量子化バージョンMobilenet_1.0_224_quantから再学習したretrained_graph.pbを使いfood-non-food.tfliteを作成します。 変換コマンドは以下のものを使用しました。

IMAGE_SIZE=224
toco \
  --allow_custom_ops \
  --input_file=/tmp/retrained_graph.pb \
  --input_format=TENSORFLOW_GRAPHDEF \
  --output_file=/tmp/food-non-food.tflite \
  --output_format=TFLITE \
  --input_shapes=1,${IMAGE_SIZE},${IMAGE_SIZE},3 \
  --mean_values=128 \
  --std_values=128 \
  --inference_type=QUANTIZED_UINT8 \
  --input_arrays=input \
  --output_arrays=final_result

先程の例と比べると入力のshape指定や値の標準化なども入っています。 オプションの--allow_custom_opsに関しては、これをつけないとcustom opがないというエラーが出るのでつけています。 「それでは動かないのでは?」という自然な疑問が湧きますが、一方でサンプルで動いているモデルと同じだ(と思われる)ので動くだろうという期待もそれほど悪くないものに思えます。

しかしながら、結果はダメで、サンプルのモデルだけ置き換えると例えばDidn't find custom op for name Dequantizeなどというエラーを吐きます。 これはTensorFlow Lite側で計算グラフのoperationが実装されていないことを意味しています。 operationが無いということで、選択肢は自分で頑張って実装するかサポートされているoperationだけでモデルを作るかです。 そもそもちゃんと動くか分からない状況なので、試すまでのスピードや余計なバグの原因を混入させないという意図で、後者の方法で進めることにしました。

以降では重みをfloatで扱うモデル(以下floatモデル)をどのようにすれば動かせるかを紹介しますが、そもそもサンプルにおいて量子化されたモデルがどのように動いているかに関してはよく分かっていません。

floatモデルを動かすまでの試行錯誤

まず試したのは、floatモデルをfake quantizationして扱うという方法です。 ML Kitのサンプルが量子化されたモデルを扱っているのでこれが既存のスクリプトを書き換えずに実行する近道に思えます。 Mobilenet_1.0_224を元に再学習したretrained_graph.pbを以下のコマンドで量子化されたfood-non-food.tfliteに変換します。

IMAGE_SIZE=224
toco \
  --input_file=/tmp/retrained_graph.pb \
  --input_format=TENSORFLOW_GRAPHDEF \
  --output_file=/tmp/food-non-food.tflite \
  --output_format=TFLITE \
  --input_shapes=1,${IMAGE_SIZE},${IMAGE_SIZE},3 \
  --mean_values=128 \
  --std_values=128 \
  --default_ranges_min=0 \
  --default_ranges_max=6 \
  --inference_type=QUANTIZED_UINT8 \
  --input_arrays=input \
  --output_arrays=final_result

オプションのdefault rangeがfake quantizationに必要な情報で、活性化レイヤーでの値の取りうる範囲を指定して量子化の際の情報として使います。 理想的には学習時の結果を保持して使うものですが、MobileNetV1はReLU6を使っているためこのように指定できます。

これで作ったモデルはエラーは吐きませんが、予測のスコアが[0.7,0.3]辺りをうろついてあまり変化しないという結果になりました。 この結果から推察するに入力の画像の取り扱いや重みがちゃんと入ってるかなどが怪しいところですが、いくつか調べてみても解決法は見つかりませんでした。 ML Kitの世界に行ってしまうとどこに問題があるか(モデル変換にバグがあるのかアプリ側にバグがあるのか)のデバッグが難しいということもあります。

ということで残りは素直にfloatモデルを作ってアプリ側でもfloatモデルを扱うように変更するという方法です。 モデル変換は以下で実施しました。

IMAGE_SIZE=224
toco \
  --input_file=/tmp/retrained_graph.pb \
  --input_format=TENSORFLOW_GRAPHDEF \
  --output_file=/tmp/food-non-food.tflite \
  --output_format=TFLITE \
  --input_shapes=1,${IMAGE_SIZE},${IMAGE_SIZE},3 \
  --mean_values=128 \
  --std_values=128 \
  --inference_type=FLOAT \
  --input_arrays=input \
  --output_arrays=final_result

これで得られたモデルをそのまま動かすとInput 0 should have 150528 bytes, but found 602112 bytesというエラーに遭遇します。 モデル的にはfloat32で扱うところを入力としてはuint8を想定しているために不整合が起こっているように見えます。

これは元々アプリ側では量子化されたものを扱おうとしていたのだから自然なエラーだと思われ、アプリ側を適切に変更すれば動くことが期待できます。 いずれにせよ、モデルを作る側にできるのはここまでなので、以降でこのモデルのアプリへの取り込みとアプリ側でどのように扱えば正しく動かせるのかを説明していきます。

4.アプリでFirebaseのモデルをバンドルする

では、作成したモデルをML Kitに取り込んでみましょう。 ここでの手順は他のカスタムモデルを取り込む手順と違いはありません。

ML Kitでカスタムモデルを使う際の大まかな構成は以下のようになっています。 ML Kit構成

アプリにモデルをバンドルするときにはFirebaseModelManagerを利用します。

FirebaseModelManagerに定義されている、FirebaseLocalModelSourceFirebaseCloudModelSourceのインスタンスをそれぞれ引数に取る registerLocalModelSourceregisterCloudModelSourceメソッドを使って利用するモデルのバンドルを行います。 もちろん、ローカルモデルのみ、クラウドモデルのみを利用することも可能です。

ローカルモデルの指定

val localSource = FirebaseLocalModelSource.Builder("food-non-food")
    .setAssetFilePath("food-non-food.tflite")
    .build()

Builderのコンストラクタにはモデルを識別するための任意の文字列を渡します。 Assetsフォルダ内のtffileを参照する場合にはsetAssetFilePathを、それ以外のフォルダを参照する場合はsetFilePathでファイルを指定します。

クラウドモデルの指定

val conditions = FirebaseModelDownloadConditions.Builder().requireWifi().build()
val cloudSource = FirebaseCloudModelSource.Builder("food-non-food")
    .setInitialDownloadConditions(conditions)
    .setUpdatesDownloadConditions(conditions)
    .enableModelUpdates(true)
    .build()

FirebaseModelDownloadConditionクラスでCloudモデルをダウンロードするための条件を設定することができます。 FirebaseCloudModelSource.Builderのコンストラクタにはステップ3でFirebaseにホストしたモデルの名前を指定します。 enableModelUpdatesをtrueにするとFirebaseにホストしたモデルに更新があった場合にモデルをFirebaseから更新するようになります。 この仕組みのおかげでアプリをアップデートすることなく最新の学習モデルを利用することが可能です。

モデルの登録

FirebaseModelManager.getInstance().apply {
    registerLocalModelSource(foodNonFoodLocalSource)
    registerLocalModelSource(attractivenessLocalSource)
    registerCloudModelSource(foodNonFoodCloudSource)
    registerCloudModelSource(attractivenessCloudSource)
}

FirebaseModelManagerのインスタンスを取得して、登録メソッドで渡します。 ML Kitの構成にもあるように複数の機械学習モデルを利用することも可能です。 ここでは料理・非料理の判別モデルと料理の魅力度推定モデルを登録しています。

推論モデルの指定

val options = FirebaseModelOptions.Builder()
    .setLocalModelName("food-non-food")
    .setCloudModelName("food-non-food")
    .build()
val interpreter = FirebaseModelInterpreter.getInstance(options)

FirebaseModelOptionsクラスで推定を行う機械学習モデルの指定を行います。 Firebase(Cloud|Local)ModelSource.Builderのコンストラクタに指定した名前を指定することで、 FirebaseModelManagerに登録した機械学習モデルを使用することができます。

このFirebaseModelOptionsを使って、実際に推定を行うFirebaseModelInterpreterのインスタンスを取得します。

5.モデルを使って推定する

いよいよ、カスタムモデルを使って推定を行います。

入出力のデータを指定する

FirebaseModelInputOutputOptionsを使って、学習モデルのinputとoutputのデータを指定します。

クイックスタートサンプルではbyte値を扱うコードが紹介されていますが、 今回利用する学習モデルはfloat値を扱うように作成しているので、floatの多次元配列がinputデータになります。

outputデータは画像がモデルによって予測されるカテゴリのいずれかの確率であるfloat値の多次元配列(softmaxの出力)です。 カテゴリ一覧を表すテキストファイルをassetsフォルダなどに配置して読み込みます。 今回は、料理・非料理判別モデルのカテゴリを表す

food
non-food

という内容のテキストをlabel.txtというファイル名でassetsフォルダに配置したと仮定します。

val labelList = activity.assets.open("label.txt").reader().use {
    it.readText()
}.split(System.lineSeparator())

val ioOptions = FirebaseModelInputOutputOptions.Builder()
    .setInputFormat(0, FirebaseModelDataType.FLOAT32, intArrayOf(1, 224, 224, 3))
    .setOutputFormat(0, FirebaseModelDataType.FLOAT32, intArrayOf(1, labelList.size))
    .build()

Bitmapからinputデータを作成する

まずはinputデータを格納するための多次元配列を作成します。 なお、執筆時点でのML KitはByteBufferには対応していますが、FloatBufferには対応していませんでした。

// 定数値
val IMAGE_MEAN = 128
val IMAGE_STD = 128.0f

// inputデータを格納する配列を作成
val imageData = Array(1) { Array(224) { Array(224) { FloatArray(3) } } }

val imageValues = IntArray(224 * 224)

// 224×224にリサイズしたBitmapからpixel値を取得
resizedBitmap.getPixels(imageValues, 0, resizedBitmap.width, 0, 0, resizedBitmap.width, resizedBitmap.height)
var pixel = 0
for (i in 0 until 224) {
    for (j in 0 until 224) {
       imageValues[pixel++].let {
           imageData[0][i][j][0] = (Color.red(it) - IMAGE_MEAN) / IMAGE_STD
           imageData[0][i][j][1] = (Color.green(it) - IMAGE_MEAN) / IMAGE_STD
           imageData[0][i][j][2] = (Color.blue(it) - IMAGE_MEAN) / IMAGE_STD
       }
    }
}

val inputs = FirebaseModelInputs.Builder()
            .add(imageData)
            .build()

次に学習モデルをTOCOで作成したときの IMAGE_SIZE=224 に合わせて224×224サイズにリサイズし、リサイズしたBitmapからpixel値を取り出します。

ここで重要なのが、pixelの各RGB値に対して、IMAGE_MEANとIMAGE_STDを使って演算をしている点です。 tfliteへの変換時に

toco \
  ...
  --mean_values=128 \
  --std_values=128 \
  ...

とMEANとSTDの値を指定しているので、モデル推論時にいい感じにやってくれるように思います。 しかし、実際には予め計算した値をinputとして与える必要があります。 こうして得られたinputの多次元配列データを FirebaseModelInputs.Builderaddメソッドに渡してやります。

推論結果を得る

入出力のデータを指定するで指定したoptionと、Bitmapからinputデータを作成するで得たinputデータを FirebaseModelInterpreterrunメソッドに渡すと推論が実行されます。

interpreter.run(inputs, options)
.addOnSuccessListener { outputs ->
    val result = outputs.getOutput<Array<FloatArray>>(0)[0]
    result.mapIndexed { index, value ->
        Pair(labelList[index], value)
    }
}

addOnSuccessListenerが受け取るTaskからgetOutputすることで推論結果を得ることができます。 今回はfloatのモデルを使ったのでgetOutputで得られる型はfloatの多次元配列です。 ここではlabel.txtで指定したカテゴリのインデックスと出力値をマッピングしています。

結果

実際に実機で動かしたデモをお見せします。

一つ目は冒頭でもお見せした料理・非料理判別です。 MobilenetV1とMovileNetV2の両方で実装しましたが、大きく変わるところはないので後者の結果のみを改めてお見せします。 実機デモ1

餃子やグラタンやパスタといった料理画像ではfoodのスコアが高くなり、ゴリラや紫陽花などの非料理画像ではnon-foodのスコアが高くなっていることが確認できます。

二つ目は魅力度推定です。 これは学習データとして料理の見栄えを5段階評価(数字が高いほど見栄えが良い)したものを準備し、回帰モデルを学習したものになります。 こちらはMobileNetV1のみで実装しましたのでその結果をお見せします。 実機でも2

数枚でかつ主観的な評価とはなりますが相対的に見栄えの良いと思われる画像に高いスコアが付与されており、モデルが期待通りに動いていることが確認できます。

ということで自分たちで作成した分類モデルを回帰モデルがML Kitを使って実機で動かすことができました! 今回は動かすことが目的であったため正答率や処理速度などの各種指標はまだ詳細には調べていませんが、これは単なるアプリのプロファイリングの話なので難しいところはありません。

まとめ 

今回、Google I/O 2018で発表された最新技術であるML Kitの現状をクックパッドの機械学習モデルを使って検証した話をしました。

ML Kitはまだβ機能として提供されているので、対応しているモデルのオペレータが少なかったり、 量子化されたモデルを上手く動かす情報が不足していたり、発展途上であることは確かです。 しかし、一度モデルを構築してしまえばオンデバイスで動作させることができますし、Firebase経由でモデルのアップデートも簡単にできます。

一方でオンデバイスで動作することは、常に最新のモデルを利用するようにコントロールできないということでもあるので、 設計する上で注意しなければなりません。

今後ますます機械学習を活用したサービスや事例が多く出てくると思いますし、 ML Kitは機械学習機能をモバイルに組み込むための有効な手段の一つであると感じました。

クックパッドではML Kitのような最新技術を利用したモバイルアプリ開発や研究開発がしたい!というエンジニアを募集しています。

興味がある方は採用ページ、または@farmanlab or @yohei_kikutaまで!