TensorflowモデルをクラウドAPI化して LINE BOTから推論する

ディープラーニングの画像認識モデルをクラウド上に置いて、
スマホやパソコンから画像を送信すると、認識結果を返してくれるものを作ります。

![TransferLearningImageClass.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239549/d085c39b-c252-0189-c5f3-39235d7452cf.png)

具体的には、今回はLINEのチャットボットに写真を送ると、画像認識結果を返すようにしました。
<img width="250" alt="スクリーンショット 2020-04-16 23.15.47.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239549/114152c8-de30-c91b-e4f0-13b95e50d6a2.png">


LINE BOT自体は単なる使用例であり、画像認識モデル自体も今回はサンプルをそのまま使います。
TensorflowモデルをクラウドAPI化するというところがメインです。


# 画像認識モデル
ここでは、写真を送ると何が写っているかを認識するものを扱います。

もし認識対象が一般的なものなら、Googleの[Cloud Vision API](https://cloud.google.com/vision?hl=ja)などを使うのが良さそうです。
例えば、自転車とか、テーブルとか。
こういう一般的なものは既に他の人が学習させているので、自分でやる必要はなさそうです。

では、認識対象が一般的なものでない場合はどうでしょうか。
例えば、写真にGoogle Homeが写っているか、Amazon Echo（Alexa）かとか。（いずれもスマートスピーカーの機種です）
こんなのは、たぶんGoogleの汎用のモデルには用意されていないでしょう。
そんなときは、GoogleのAutoML Visionというものを使えば、そんなカスタムの画像認識モデルが簡単に作れます。画像をアップロードするだけ。
また、[過去の記事](https://qiita.com/ikechan/items/f79d1cacad1fb7bd26ba)で紹介したとおり、M5StickVでも同様にお手軽なツールが用意されています。

ですが、学習の際に細かいところをいじりたい場合もあるでしょうし、もう少し基礎的な部分も触ってみたい。
そこで、Tensorflowを使ってモデルをつくります。メジャーなプラットフォームです。


# 学習・デプロイ環境
学習環境は、お手軽なものとしてGoogle Colabを使います。
学習させたモデルをクラウド上にデプロイするには、[AI Platform](https://cloud.google.com/ai-platform/?hl=ja)を使います。
AI Platformはデプロイだけでなく、学習から何から出来るプラットフォームのようですが、とりあえず今回はデプロイだけに使います。


# Tensorflowモデルの準備・ダウンロード
## コード

```python:.ipynb
from __future__ import absolute_import, division, print_function, unicode_literals
try:
  %tensorflow_version 2.x
except Exception:
  pass
import tensorflow as tf
from matplotlib import pyplot as plt
import numpy as np


# ---- 学習済みモデルを読み込み ----
model = tf.keras.applications.MobileNetV2()


# ---- 学習済みモデルに入力・出力の変換処理を加えたものをつくる ----
@tf.function(input_signature=[tf.TensorSpec(shape=[None], dtype=tf.string)])
def serving(input_images):
    def _img_to_array(img_base64):
        img = tf.io.decode_base64(img_base64)# tensor string -> tensor string
        img = tf.io.decode_image(img, dtype=tf.float32)
        img.set_shape((None, None, 3)) # これがないと下のresize()で"no shape"エラー
        img = tf.image.resize(img, (224, 224), method="nearest")

        x = tf.keras.applications.mobilenet.preprocess_input(
            img[tf.newaxis,...])

        # 正規化
        x_fl = tf.reshape(x, [-1])
        x_min = x_fl[tf.argmin(x_fl)]
        x_max = x_fl[tf.argmax(x_fl)]
        x = ((x - x_min) *2 / (x_max - x_min)) -1
        return x

    with tf.device('/cpu:0'): # これがないと”non-DMA-copy..."エラー (https://github.com/tensorflow/tensorflow/issues/28007)
      x = tf.map_fn(_img_to_array, input_images, dtype=tf.float32)
      x = x[0,:,:,:]
      prediction = model(x)

    def _convert_to_label(candidates):
        labels_path = tf.keras.utils.get_file(
            'ImageNetLabels.txt',
            'https://storage.googleapis.com/download.tensorflow.org/data/ImageNetLabels.txt')
        imagenet_labels = np.array(open(labels_path).read().splitlines())
        idx = tf.argsort(candidates)[0,::-1][:5]+1
        label = tf.squeeze(tf.gather(imagenet_labels, idx))
        return label

    with tf.device('/cpu:0'):
      print(prediction)
      return _convert_to_label(prediction)


# ---- SavedModelとして保存 ----
from datetime import datetime
version_number = int(datetime.now().timestamp())
export_dir='/content/mobilenetv2_base64input/{}'.format(version_number)
tf.saved_model.save(
  model,
  export_dir=export_dir,
  signatures=serving)

```


```python:.ipynb
# ---- SavedModelをダウンロード
!zip -r /content/download.zip /content/mobilenetv2_base64input/{上のコードで設定されたversion_number}
# 圧縮した zip ファイルをダウンロードする
from google.colab import files
files.download("/content/download.zip")
```


参考

- 学習済みモデルの部分：　[Transfer learning with a pretrained ConvNet](https://www.tensorflow.org/tutorials/images/transfer_learning)
- SavedModelとして保存する手順：　[Using the SavedModel format](https://www.tensorflow.org/guide/saved_model)
- 入力・出力の変換処理を加える部分（詳細は後述）：　CyberAgent | AI Tech Studioの記事 [TensorFlow2.0時代のTensorFlow Serving向けモデル出力](https://cyberagent.ai/blog/research/10265/)

これらのコードを参考になんとか動く形に出来ましたが、基礎的なところがあまり分かっていないのでイマイチなコードかもしれません。


### モデルの読み出し・実行テスト

```python:.ipynb
# テスト用の画像（LINE Botで実際に受信したbyteデータ）
img_line = b'\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01...(略）'
import base64
img_base64 = base64.urlsafe_b64encode(img_line)

loaded = tf.saved_model.load(export_dir)
infer = loaded.signatures["serving_default"]
output_infer = infer(tf.constant([img_base64, img_base64], dtype=tf.string))
labeling = output_infer["output_0"]
print(labeling)
```
備考）[Grace Hopperの画像](https://storage.googleapis.com/download.tensorflow.org/example_images/grace_hopper.jpg)での実行結果：

```py:実行結果
tf.Tensor([b'military uniform' b'suit' b'ballplayer' b'Windsor tie' b'bearskin'], shape=(5,), dtype=string)
```

## 解説

### 学習済みモデルと転移学習
今回は学習済みモデルをそのまま使います：　tf.keras.applications.MobileNetV2()
認識対象をカスタムしたい場合には、このような学習済みモデルをベースに「転移学習」をするのがよさそうです。
なお転移学習については[こちらのチュートリアル](https://www.tensorflow.org/tutorials/images/transfer_learning)が参考になりそうです。

### 入力・出力の変換処理を加える
画像から認識結果を得るまでは次のような流れとなります。
入力時の処理（画像サイズ変更など）　→　モデルを実行　→　出力の処理（数値列をラベル名に変換など）
この入出力の処理も含めた形でモデルをつくります。そうすれば、クライアント側（例えばチャットボット）で画像サイズ変更やラベル名への変換をする必要がなくなり、ミスも減ります。



# AI Platformにデプロイ
公式ドキュメント：　[モデルのデプロイ](https://cloud.google.com/ml-engine/docs/deploying-models?hl=ja)

手順は次の通り：

 1. SavedModelを Cloud Storage バケットにアップロード
 - AI Platform のモデルリソースを作成
 - AI Platform のバージョン リソースを作成し、保存済みモデルの Cloud Storage パスを指定

## Google Cloud Storageにバケット作成
公式ドキュメントではgsutilコマンドでの作成方法が記載されていますが、なんだか成功しなかったので、GCPのコンソール画面（ウェブブラウザ）から作成しました。
REGIONはus-central1にしました。[AI Platformのリージョンの説明](https://cloud.google.com/ml-engine/docs/regions?hl=ja)を見ると、このリージョンが何かと制約なさそうだったのでとりあえず。
その他の設定は適当に凝ってないっぽい選択肢として、リージョン：Region、アクセス制御：均一にしました。

## Google Cloud Storageにモデルをアップロード
こちらはエラーなく出来たので、gsutilコマンドで実行しました。

上でダウンロードしたSavedModelのあるディレクトリにて、下記を実行。

```sh
gsutil cp -r $SAVEDMODEL_NAME gs://$BUCKET_NAME
```

SAVEDMODEL_NAME：上のコード中のversion_number（例：1587280290）
BUCKET_NAME：上で作成したバケット名

## ローカルで推論実行テスト
デプロイ前に動作チェックします。

```sh
gcloud ai-platform local predict --model-dir $SAVEDMODEL_NAME \
  --json-instances input_json.json \
  --framework tensorflow
```


実行結果の末尾には、先ほどのGoogle Colab上での実行結果と同じものが得られます。

参考）Grace Hopperの画像での実行結果はこちら：

```sh:実行結果
:
:
OUTPUT_0
military uniform
suit
ballplayer
Windsor tie
bearskin
```

以下、解説。

### JSONデータ
上記の"--json-instances"で指定するJSONファイルの例：

```json:input_json.json
{"input_images": "_9j_4AAQSkZJRgAB...(略)"}
```

キー名称"input_images"は、serving()関数の引数に合わせています。

### JSONの形式とb64デコード
[公式ドキュメント](https://cloud.google.com/ml-engine/docs/online-predict?hl=ja#binary_data_in_prediction_input)を見ると、

```py:input_json.json?
{'image_bytes': {'b64': base64.b64encode(jpeg_data).decode()}}
```
の形式のJSONにしておけば、モデル入力部での"tf.io.decode_base64()"の処理は不要になるのかとも思いましたが、エラーになったのでそのような形式にはしていません。

### 都度.pycファイルを削除する
.pycファイルが悪さをして、エラーが出たり、エラーも何もなくコマンドが終了したりします（[Issue](https://github.com/GoogleCloudPlatform/cloudml-samples/issues/415)）ので、.pycファイルを都度削除します。

とりあえずこんな感じでやりました：

```sh
sudo find ~ -name '*.pyc' -delete
```


## デプロイ
```
gcloud ai-platform models create "[YOUR-MODEL-NAME]"
```
[YOUR-MODEL-NAME]は、このタイミングで命名します。

## モデルバージョンの作成
```sh
MODEL_DIR="gs://[BUCKET_NAME]/[SAVEDMODEL_NAME]"
VERSION_NAME="[YOUR-VERSION-NAME]"
MODEL_NAME="[YOUR-MODEL-NAME]"
FRAMEWORK="TENSORFLOW"
```

BUCKET_NAME, SAVEDMODEL_NAMEは、上の「Google Cloud Storageにモデルをアップロード」での値と同じ。
YOUR-MODEL-NAMEは、すぐ上のデプロイのところで設定したもの。
YOUR-VERSION-NAMEは、このタイミングで命名するものです。

```sh
gcloud ai-platform versions create $VERSION_NAME \
  --model $MODEL_NAME \
  --origin $MODEL_DIR \
  --runtime-version=1.14 \
  --framework $FRAMEWORK \
  --python-version=3.5
```


# LINE BOTから推論する
では、実際にデプロイしたモデルを使ってみます。
こちらをベースに作ります：
[30分くらいでClova Extension Kit SDK for Python を使ったClova スキルを作る！（後編：実装編）](https://qiita.com/sumihiro3/items/9e24170cad4ad384f453)
AWS Lambda、zappaを使います。pythonで書きます。

## コード
ディレクトリ構成

```
├── main.py
├── setup.py
├── helper
│   ├── predict.py
│   └── predict_byte_input.py
└── zappa_settings.json
```

### main.py
```py:main.py
from flask import Flask, request, jsonify, abort

import os
import logging

from linebot import LineBotApi, WebhookHandler
from linebot.models import (
    CarouselColumn, CarouselTemplate, ImageMessage,
    MessageEvent, TemplateSendMessage, TextMessage,
    TextSendMessage, URITemplateAction, PostbackAction,
    MessageAction, URIAction, ImageCarouselColumn,
    ImageCarouselTemplate, PostbackEvent
)
from linebot.exceptions import (
    LineBotApiError, 
    InvalidSignatureError
)

from helper import predict_byte_input


# -- Flask --
app = Flask(__name__)

# -- Line bot --
line_access_token = os.environ.get('LINE_BOT_ACCESS_TOKEN')
line_bot_api = LineBotApi(line_access_token)
line_channel_secret = os.environ.get('LINE_BOT_CHANNEL_SECRET')
handler = WebhookHandler(line_channel_secret)

#-- logger --
logging.basicConfig()
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)


@app.route("/line_bot", methods=['POST'])
def callback():
    # get X-Line-Signature header value
    signature = request.headers['X-Line-Signature']

    # get request body as text
    body = request.get_data(as_text=True)

    # handle webhook body
    try:
        handler.handle(body, signature)
    except LineBotApiError as e:
        print("Got exception from LINE Messaging API: %s\n" % e.message)
        for m in e.error.details:
            print("  %s: %s" % (m.property, m.message))
        print("\n")
    except InvalidSignatureError:
        print("InvalidSignatureError")
        abort(400)

    return 'OK'


# -- 画像受信時
@handler.add(MessageEvent, message=(ImageMessage))
def handle_content_message(event):

    # GOOGLE_APPLICATION_CREDENTIALS
    google_credentials_json_text = os.environ.get('GOOGLE_APPLICATION_CREDENTIALS_JSON')
    path_google_credentials = os.environ.get('GOOGLE_APPLICATION_CREDENTIALS')
    import json
    d = json.loads(google_credentials_json_text)
    with open(path_google_credentials, 'w') as f:
        json.dump(d, f, indent=4)

    # 画像の受信
    message_content = line_bot_api.get_message_content(event.message.id)
    print("message_content.content: ", message_content.content)

    # 予測（画像認識）
    predictions_raw = predict_byte_input.predict_byte_input(message_content.content)
    print('predictions_raw: ', predictions_raw)    # [{'output_0': 'military uniform'}, {'output_0': 'suit'}, {'output_0': 'ballplayer'}, {'output_0': 'Windsor tie'}, {'output_0': 'bearskin'}]
    prediction = []
    for prediction_raw in predictions_raw:
        prediction.append(prediction_raw['output_0'])
    print('prediction: ', prediction)

    # テキストメッセージ送信
    message = "認識結果\n" + "\n".join(prediction)
    print('message: ', message)
    line_bot_api.reply_message(
        event.reply_token, TextSendMessage(text=message))


if __name__ == "__main__":
    app.run()

```


### setup.py
helperディレクトリにコードをおくためのものです。

``` py:setup.py
from setuptools import setup

setup(
    name='helper',
    packages=['helper'],
    include_package_data=True,
    install_requires=['flask']
)
```

### predict.py
[https://github.com/GoogleCloudPlatformのコード](https://github.com/GoogleCloudPlatform/python-docs-samples/blob/master/ml_engine/online_prediction/predict.py)をベースに下記部分を変更。
これをしないと、"No module named..."のエラーが出ましたので、[こちらのissue](![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239549/f7a2cfb2-9bbe-aa81-d82e-7fb4696b4041.png)
)を参考に変更しました。

```py
    # service = googleapiclient.discovery.build('ml', 'v1')
    service = googleapiclient.discovery.build('ml', 'v1', cache_discovery=False)
```


### predict_byte_input.py
モデルに送信するデータの作成（b64エンコードなど）と、
predict.pyの実行。

```py
import base64
import os

from helper import predict


def convert_for_predict(message_content_content):
    img_base64 = base64.urlsafe_b64encode(message_content_content)
    img_base64_utf = img_base64.decode('utf-8')
    img_dict = {"input_images": img_base64_utf}

    return img_dict


def predict_byte_input(message_content_content):
    project = os.environ.get('AI_PLATFORM_PROJECT')
    model = os.environ.get('AI_PLATFORM_MODEL')
    user_input = convert_for_predict(message_content_content)

    try:
        predictions = predict.predict_json(
                        project, model, user_input)
    except RuntimeError as err:
        print(str(err))
        return
    else:
        return predictions
```


## 解説
### Googleの認証情報
predict.pyを実行するには、GCPのAPIを使うときにお馴染みの"GOOGLE_APPLICATION_CREDENTIALS"の環境変数が必要になります。
普段ローカルで使うときには、

- GCPコンソールからjsonファイルをダウンロード

- "GOOGLE_APPLICATION_CREDENTIALS"の環境設定として　jsonファイルのパスを定義

という流れです。

では、AWS Lambda上で同様のことをどうやるか？
jsonファイルをアップロードするのは避けたいし、
そもそもコード中に直接環境変数を記載するのは避けたい。

今回やってみた方法：

- jsonファイルの中身をテキストとして環境変数にする
- 環境変数のテキストを読み出して、辞書にして、書き出し（例えば./xxx.json）
- （GOOGLE_APPLICATION_CREDENTIALSは、./xxx.jsonとして定義しておく）


とりあえずは出来ましたが、これでよかったのか自信ないです。
正解があればおしえてください。



# おわりに
Tensorflowに触りなれていなかったのでかなり大変でした。
また、比較的最近バージョン2.0になったのでweb上の情報もあまり見つけられず。
AI Platformも最近出来たものだし（名前が変わった）。

同様のことをやりたい人は多いはずだと思うのですが、上のCyberAgentさんの記事くらいしか見つけられませんでした。この記事がなかったら途中で諦めてた…。感謝です。