Google Cloud Platformを使って超解像度化APIを公開してみる

#　はじめに
　この記事は、[こちら](https://aiotadiary.wp.xdomain.jp/2020/03/01/google-cloud-platform%E3%82%92%E4%BD%BF%E3%81%A3%E3%81%A6%E8%B6%85%E8%A7%A3%E5%83%8F%E5%BA%A6%E5%8C%96api%E3%82%92%E5%85%AC%E9%96%8B%E3%81%97%E3%81%A6%E3%81%BF%E3%82%8B/)のブログの記事をqiita用に書き直したものです。ぜひこちらの方でもご覧になってください。
　
　今回はGoogle Cloud Platform 通称GCPを用いてAPIの公開をやっていきたいと思います。ちょっと前にGCPを使うことがあったので、今回はそれを忘れないように記録することと、自分でサービスをデプロイする経験をすることの二つを目的としたいと思います。ちなみにAPIにするのは以前実装したESPCNを用いた超解像度化モデルです。
　今回触れたいと思っているのは、GCPの中でも、Cloud Runという機能です。

# GCPへの登録
 GCPへの登録はGoogleのガイドか、そのほかの記事を参考にすればだいたい大丈夫です。特にスタートガイド通りにしておけば問題ないです。↓の記事が参考になります。
　[これから始めるGCP（GCE）　安全に無料枠を使い倒せ](https://qiita.com/Brutus/items/22dfd31a681b67837a74)
　基本的以下の順番で登録を行います。

　1. GCPにログイン
　![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179010/69fb1386-f792-135f-1ab9-184a406257f6.png)
　2. 上の画面の「無料トライアルに登録」ボタンを押す
　![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179010/c775efaf-fec4-8515-0658-e676559b82b7.png)
　3. 表示される指示に従って情報を記入
　![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179010/add813e9-a9c9-b3ef-b2e2-bf5b5b0e2d57.png)
　ここでは、クレジットカードなどの情報を入力することになりますが、有料アカウントに設定しない限りは勝手に引き落とされてしまうような事はないので安心してください。

## 登録完了
 これで、登録完了です。GCPの無料枠では、f1-microのGCEインスタンスが無料で建てられるので、ぜひ使ってみてください。ちなみに無料トライアルでは12ヶ月の間300$相当のサービスが無料で使えるので、色々試して見ると良いかもしれません。

# Cloud Runを使う
　Cloud RunはDockerのコンテナをデプロイすることのできるサービスで、基本的にContainer RegistryにアップロードされたDocker Imageを使ってサービスをデプロイします。この際に、Githubへのpullをトリガーにコンテナのビルドとデプロイを行うようにする方法があるそうですが、ちょっと難しそうなので今回はローカルでビルドしたイメージをアップロードする形をとりたいと思います。

## gcloud コマンドのインストール
　私はmacを使っていますので以下の説明はmac以外の方には参考になりません。
　まずは以下のアーカイブをダウンロードします。

　[google-cloud-sdk-245.0.0-darwin-x86_64.tar.gz](https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/google-cloud-sdk-245.0.0-darwin-x86_64.tar.gz?hl=ja)

　その後以下のコマンドを実行します。

```shell
$ ./google-cloud-sdk/install.sh 
```

　次にSDKの初期化を行います。

```shell
$ gcloud init
```

　その後ログインしますか？という旨のメッセージが出てくるのでYを入力します。その後接続するプロジェクトを選択しますがプロジェクトが一つだけの時は勝手に選ばれるそうなので安心してください。この後も適当に入力していけばコマンドが使えるようになります。↓は公式のセットアップ方法です。

[macOS 用のクイックスタート](https://cloud.google.com/sdk/docs/quickstart-macos?hl=ja)
[Windows 用のクイックスタート](https://cloud.google.com/sdk/docs/quickstart-windows?hl=ja)
[Linux 用のクイックスタート](https://cloud.google.com/sdk/docs/quickstart-linux?hl=ja)

# Dockerイメージを作成
　ここでは前回実装したESPCNのモデルを用いて解像度を倍にして出力するコードをAPIとして使えるようにしようと思います。使うのはFlaskとgunicornです。

　全体の動作の流れとしては

1. リクエストのjsonのから入力となる画像のbase64をデコード
2. デコードした画像を超解像の関数に入力
3. base64にエンコードしてレスポンス

　という簡単なものです。まずはEspcn-API用のGitリポジトリを作成します(リポジトリはこちら)。ディレクトリ構成は以下のような感じです。

```
ESPCN-API
 ├model
 │ └pre_trained_mode.pth
 ├api.py
 ├requirements.txt
 ├Dockerfile
 └networks.py
test
 ├test.py
 └test.png
```

## テストコード
　まずはテスト用のコードから書いていきます。テストで確認する事は以下の二つです。

　・レスポンスが返ってくる(status_codeが200)
　・画像のサイズが2倍になっている事
　
　これらを確認するための動作を実装していきます。

```python
import sys
import requests
import json
from io import BytesIO
from PIL import Image
import base64

def image_file_to_base64(file_path):
    with open(file_path, "rb") as image_file:
        data = base64.b64encode(image_file.read())
    return 'data:image/png;base64,' + data.decode('utf-8')

def base64_to_img(img_formdata):
    img_base64 = img_formdata.split(',')[1]
    input_bin = base64.b64decode(img_base64)
    with BytesIO(input_bin) as b:
        img = Image.open(b).copy().convert("RGB")
    return img

if __name__ == "__main__":
    source_image_path = "test.png"
    source_image = Image.open(source_image_path)
    source_width, source_height = source_image.size
    print("source_width :", source_width)
    print("source_height :", source_height)
    host_url = "http://0.0.0.0:8000"
    data = {"srcImage":image_file_to_base64(source_image_path)}
    json_data = json.dumps(data)

    response = requests.post(host_url, json_data, headers={'Content-Type': 'application/json'})

    assert response.status_code == 200, "validation error status code should be 200"

    res_json = response.json()
    
    res_image = base64_to_img(res_json["sresoImage"])
    sreso_width, sreso_height = res_image.size
    print("sreso_width :", sreso_width)
    print("sreso_height :", sreso_height)
    assert sreso_width == source_width * 2 and sreso_height == source_height * 2 , \
        "validation error image size should be 2 times of input image"
    res_image.show()
    print("OK")
```

# メインのコード
　次はメインとなるapi.pyを実装していきます。はじめに全体の実装の骨格を作成しました。関数の中身については全く実装していません。

```python
from flask import Flask, request, jsonify
from networks import Espcn
import os
from io import BytesIO
import base64
from torchvision import transforms
from torch import load
import torch
import json
from PIL import Image

device = "cpu"
net = Espcn(upscale=2)
net.load_state_dict(torch.load(opt.model_path, map_location="cpu"))
net.to(device)
net.eval()

def b64_to_PILImage(b64_string):
    """
    process convert base64 string to PIL Image
    input: b64_string: base64 string : data:image/png;base64,{base64 string}
    output: pil_img: PIL Image
    """
    pass

def PILImage_to_b64(pil_img):
    """
    process convert PIL Image to base64
    input: pil_img: PIL Image
    output: b64_string: base64 string : data:image/png;base64,{base64 string}
    """
    pass

def expand(src_image, model=net, device=device):
    pass

@app.route("/", methods=["POST"])
def superResolution():
    pass

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=os.environ.get("PORT", 8000))
```
　expand関数がメインの超解像度化を行い、それをリクエストに対して稼働するsuperResolution内で実行する構造を考えています。また、超解像度化の際に使うモデルは、関数外で定義しておく事で、workerが複数のリクエストを処理する際にモデルを読み込む手間を省く事ができると思っています。

　では、まずは入出力となる~_to_~関数二つを実装します。

```python
def b64_to_PILImage(b64_string):
    """
    process convert base64 string to PIL Image
    input: b64_string: base64 string : data:image/png;base64,{base64 string}
    output: pil_img: PIL Image
    """
    b64_split = b64_string.split(",")[1]
    b64_bin = base64.b64decode(b64_split)
    with BytesIO(b64_bin) as b:
        pil_img = Image.open(b).copy().convert('RGB')
    return pil_img

def PILImage_to_b64(pil_img):
    """
    process convert PIL Image to base64
    input: pil_img: PIL Image
    output: b64_string: base64 string : data:image/png;base64,{base64 string}
    """
    with BytesIO() as b:
        pil_img.save(b, format='png')
        b.seek(0)
        img_base64 = base64.b64encode(b.read()).decode('utf-8')
    img_base64 = 'data:image/png;base64,' + img_base64
    return img_base64
```
　BytesIOの使い方が意外と難しくて苦戦しました。次は、expand関数とメインとなるsuperResolution関数です。

```python
def tensor_to_pil(src_tensor):
    src_tensor = src_tensor.mul(255)
    src_tensor = src_tensor.add_(0.5)
    src_tensor = src_tensor.clamp_(0, 255)
    src_tensor = src_tensor.permute(1, 2, 0)
    src_tensor = src_tensor.to("cpu", torch.uint8).numpy()
    return Image.fromarray(src_tensor)


def expand(src_image, model=net, device=device):
    src_tensor = transforms.ToTensor()(src_image).to(device)
    if src_tensor.dim() == 3:
        src_tensor = src_tensor.unsqueeze(0)
    
    srezo_tensor = model(src_tensor).squeeze()
    srezo_img = tensor_to_pil(srezo_tensor)
    return srezo_img

@app.route("/", methods=["POST"])
def superResolution():
    req_json = json.loads(request.data)
    src_b64 = req_json["srcImage"]

    # main process
    src_img = b64_to_PILImage(src_b64)
    srezo_img = expand(src_img)
    srezo_b64 = PILImage_to_b64(srezo_img)

    results = {"sresoImage":srezo_b64}

    return jsonify(results)


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=os.environ.get("PORT", 8000))
```
　当初は二つの関数に収めようと思ったのですが、torch.tensorからPIL Imageへの変換が、transforms.ToPILImageを使うと色が変な感じになってしまったので、別で定義することにしました。また、最後のapp.runの時の引数ですが、cloud runの仕様上docker containerごとに別のポートが自動で割り振られるため、環境変数PORTを読み取る設定にしておかないといけないらしいためです。

　ではローカルで動作チェックしたこれらをdocker imageとしてビルドします。

```
$ docker build -t gcr.io/[project id]/espcn-api:0 .
```
　次にイメージをgcloud コマンドを用いてcontainer registryにアップロードします。

```
$ gcloud docker -- push gcr.io/[project id]/espcn-api:0
```
　実際にGCP上のContainer Registry上でイメージが確認できると思います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179010/c95e0c4f-15c8-6dff-7940-d71579cd8ff9.png)
　次にアップロードされたイメージを用いてデプロイします。

```
$ gcloud beta run deploy SR-API --image=gcr.io/[project id]/espcn-api:0 
```
　deployの後のSR-APIと入力している部分はサービスの名前なので各自好きなようにつけて問題ないです。また初めて実行する際にはbetaコンポーネントをインストールするらしいです。この時(--platform managed)を入れなければコンソール上で

```
 [1] Cloud Run (fully managed)
 [2] Cloud Run for Anthos deployed on Google Cloud
 [3] Cloud Run for Anthos deployed on VMware
 [4] cancel
Please enter your numeric choice: _
```
　と入力を求められるので1を入力しておきましょう。fully managed でなければ無料で運用する事は難しくなります。次にリージョンを聞かれます。

```
 [1] asia-east1
 [2] asia-northeast1
 [3] europe-north1
 [4] europe-west1
 [5] europe-west4
 [6] us-central1
 [7] us-east1
 [8] us-east4
 [9] us-west1
 [10] cancel
Please enter your numeric choice: _
```
　ここでは、「us-*」を選びましょう。Cloud Run では北米内での下りのネットワーキングは1GBまで無料なので、これをCloud Functionsから呼び出すようにすれば(Cloud Functionsでは下りのネットワーキングはどこに対しても5GBまで無料なので)ほとんど無料で使用できます。

　この後認証されていないアクセスを許可するか聞いてくるのですが、今はとりあえず動かしてみることが目的なのでyesにしておきましょう。以上のように入力していくとサービスがデプロイされ以下のようなメッセージが出力されます。

```
Deploying container to Cloud Run service [espcn-api] in project [studied-brace-261908] region [us-central1]
✓ Deploying new service... Done.                                                                                                                       
  ✓ Creating Revision...                                                                                                                               
  ✓ Routing traffic...                                                                                                                                 
  ✓ Setting IAM Policy...                                                                                                                              
Done.                                                                                                                                                  
Service [espcn-api] revision [espcn-api-00001-guj] has been deployed and is serving 100 percent of traffic at https://espcn-api-~~~-uc.a.run.app
```
　最終的にどこどこのurlでデプロイされたといったメッセージが出てくるので、ここにtest.pyを使ってリクエストを投げてみるときちんとレスポンスが返ってくるのが確認できました。

　この時一つ詰まったことがあったのですが、はじめにリクエストを投げた時にはレスポンスが返ってきませんでした。そこでCloud Runのログの方を確認してみると、

```
Memory limit of 244M exceeded with 272M used. Consider increasing the memory limit, see https://cloud.google.com/run/docs/configuring/memory-limits
```
　メモリーが足りないという旨のメッセージが届きました。cloud runでは使用するメモリが決まっているらしく、指定できるメモリのサイズは128MB~2GBでデフォルトでは256MBが割り当てられているそうです。512 × 512のイメージでこのエラーが発生してしまったので、512MBなどを割り当てることで解決できそうですが、あまり大きいメモリを割り当ててしまうとすぐに無料枠分を超えてしまいそうです。
　ちなみにアプリケーションに割り当てるメモリを変える場合には次のコマンドを使ってください。

```
$ gcloud beta run services update [service name] --memory 512M
```

# 終わりに
　以上APIを作ってCloud Runで公開するところまで行いました。今の状態では、このAPIは何処からでもアクセスできる状態で、セキュリティ的にも財布にも優しくないのでので、次はCloud Runに対するアクセスをGCP内からに制限した上で、Cloud Functionsを使ってこれにリクエストを送る構造に変更していきたいと思います。継続的デプロイについてもいつか触れたいと思います。