【LambdaでOpenCVを利用】AWSとOpenCVを利用してポケモン画像でアスキーアート風に変換するAPIを作った

こんにちは、[Yui](https://twitter.com/yui_active)です。

引き続き週イチ発信をしていきます。
今週はAWSとOpenCVを使って、画像処理を行ったのでそのことについて書きます。

以下の件です。

<blockquote class="twitter-tweet" data-partner="tweetdeck"><p lang="ja" dir="ltr">ポケモンAPIとOpenCVを使って、画像をポケモンだけで表すAPI作った <a href="https://t.co/36GBqvqf82">pic.twitter.com/36GBqvqf82</a></p>&mdash; yui ☁ Yuiko Ito (@yui_active) <a href="https://twitter.com/yui_active/status/1411500004896776194?ref_src=twsrc%5Etfw">July 4, 2021</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

このままだとちょっとあら過ぎるかなと思ったので、修正後がこちら。
心の目で見るとちゃんとワニに見えてきますね？笑
（なんか下腹部にピカチュウがあるのがちょっと悪意のある変換な感じがしますが笑）

<blockquote class="twitter-tweet" data-partner="tweetdeck"><p lang="ja" dir="ltr">サイズを調整して少し見やすくした <a href="https://t.co/5h2CRRv7mG">pic.twitter.com/5h2CRRv7mG</a></p>&mdash; yui ☁ Yuiko Ito (@yui_active) <a href="https://twitter.com/yui_active/status/1411529653286625280?ref_src=twsrc%5Etfw">July 4, 2021</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

webアプリ化は来週するとして、今回はこのAPIの作り方に関して書きます。

過去の週イチ発信は以下

- [【React + Typescriptで顔認識】tensorflowを使って画像にマスクをかけるアプリを作った](https://qiita.com/Yui_active/items/b8062b9f89f72c6865ed)
- [【React + Typescript】ボタン一つでコンポーネントのscssをコピーできるサイトを作った](https://qiita.com/Yui_active/items/1288fed0dcc24c71e159)
- [【アップデート】ui-componentsに18個のコンポーネントを追加した](https://qiita.com/yuiko_ito/items/c1d0599e1e7da7fa42e0)
- [【Nuxt.js × Tailwind CSS】ボタン一つで有名絵画風の画像にできるサービスをリリースした！](https://qiita.com/yuikoito/items/126b0e2d36a707bfd36e)
- [【GASでLINE Bot作成】現在地の近くのおすすめのごはん屋さんを教えてくれるLINE Botを作った](https://qiita.com/yuikoito/items/c1aef147cef4458f9d2e)
- [【動的OGP】Next.js + TypeScript + Vercelデプロイで動的OGPを実現する](https://qiita.com/yuikoito/items/619120c592d99f9d3053)

## どんなものを作ったか

まずは色々変換してみた結果です。

![1.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/670446/677c7bea-2923-2a9d-a9b3-1e5dce6029c5.jpeg)

![2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/670446/1f3cf240-af77-089d-5184-ef18d1cc6f3e.jpeg)

![3.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/670446/a33fee59-6f44-1f4a-c98f-227c7bc1ed92.jpeg)

なかなか面影があります。笑
集合体が苦手な人にはなかなかきつい画像かもしれません。

## 構成

今回画像を分割して、それぞれをポケモンの画像と比較して色相が似ているものでリプレイスするという感じで組んでいます。

Lambdaではpip installができないので、OpenCVを使うためにDocker imageを利用しました。

というわけで、今回の構成は以下になっています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/670446/cca514f1-15f2-3dca-cf06-f180049bee51.png)


やったことは以下です。

- [PokeAPI](https://pokeapi.co/)から必要なポケモンの画像をダウンロードする
- 画像処理用のプログラムを書く
- Dockerfileを書く
- Docker imageを作成してECRにpushする
- Lambda関数を作成する（ECRからDocker imageをpull）
- API GatewayでAPI化する

## 必要なポケモンの画像をダウンロードする

まずは[PokeAPI](https://pokeapi.co/)から画像をダウンロードします。
画像を確認すると、余白が結構あるので、余白を削除して、サイズを15px*15pxの状態でダウンロードすることにします。

今回、この処理のために、[Google Colaboratory](https://colab.research.google.com/notebooks/intro.ipynb)を利用しました。
ただ、この辺はお好みで要はダウンロードした画像が用意できれていればOKです。

```python
import requests
import cv2
import numpy as np
import tempfile
import urllib.request
from google.colab import files

# 画像のトリミング
def crop(image):
    # 画像の読み込み
    img = cv2.imread(image)
    # 元画像をグレースケールに変換
    gray_img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    # グレースケール画像を白黒に変換
    ret_, bw_img = cv2.threshold(gray_img, 1, 255, cv2.THRESH_BINARY)
    # 白黒画像の輪郭を抽出
    contours, h_ = cv2.findContours(bw_img, cv2.RETR_EXTERNAL, method=cv2.CHAIN_APPROX_NONE)
    # 輪郭からバンディングボックスを計算
    rect = cv2.boundingRect(contours[-1])
    # バウンディングボックスの座標で元画像をクロップ
    crop_img = img[rect[1]:rect[1]+rect[3]-1, rect[0]:rect[0]+rect[2]-1]
    return crop_img

# lists = [1,4,7,10,16,24,25,26]のように、好きなポケモンの番号を指定します。
# 一気に多くの画像をダウンロードしようとすると、途中で抜けが発生するので、10個ずつぐらいでダウンロードします。
for i in lists:
    url = "https://pokeapi.co/api/v2/pokemon/{}/".format(i)
    tmp_input = '{}.png'.format(i)
    r = requests.get(url, timeout=5)
    r = r.json()
    image =r['sprites']['front_default']
    urllib.request.urlretrieve(image, tmp_input)
    img = crop(tmp_input)
    img2 = cv2.resize(img , (15, 15))
    cv2.imwrite(tmp_input , img2)
    files.download(tmp_input)
```

今回は処理速度の関係で、30種類の画像を用意しました。
私はこれらの画像をpokemonsという名前のフォルダで格納しました。

## 画像処理用のプログラムを書く

やっていることは、以下です。

- 入力された画像（base64形式を想定）をOpenCVで使える形式にデコードする
- デコードされた画像を15px*15pxに分割する
- 分割された画像それぞれとポケモンの画像の色相のヒストグラムを比較する
- 類似度が高いポケモンの画像と入れ替える

コード全文は以下です。

```python:app.py
import json
import cv2
import base64
import numpy as np
import glob

def calc_hue_hist(img):
    # H（色相）S（彩度）V（明度）形式に変換する。
    hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
    #hsv = img
    # 成分ごとに分離する。
    h, s, v = cv2.split(hsv)
    # 画像の画素値を計算したもの（ヒストグラム）
    h_hist = calc_hist(h)
    return h_hist

def calc_hist(img):
    # ヒストグラムを計算する。
    hist = cv2.calcHist([img], channels=[0], mask=None, histSize=[256], ranges=[0, 256])
    # ヒストグラムを正規化する。
    hist = cv2.normalize(hist, hist, 0, 255, cv2.NORM_MINMAX)
    # (n_bins, 1) -> (n_bins,)
    hist = hist.squeeze(axis=-1)
    return hist

def base64_to_cv2(image_base64):
    # base64 image to cv2
    image_bytes = base64.b64decode(image_base64)
    np_array = np.fromstring(image_bytes, np.uint8)
    image_cv2 = cv2.imdecode(np_array, cv2.IMREAD_COLOR)
    return image_cv2

def cv2_to_base64(image_cv2):
    # cv2 image to base64
    image_bytes = cv2.imencode('.jpg', image_cv2)[1].tostring()
    image_base64 = base64.b64encode(image_bytes).decode()
    return image_base64

# eventでjsonを受け取る { 'img': base64img }
def handler(event, context):
    input_img = event['img'] #画つ目の像を読み出しオブジェクトtrimed_imgに代入
    input_img = base64_to_cv2(input_img)
    height, width, channels = input_img.shape[:3]

    max_ruizido = 0
    tmp_num = 5
    crop_size = 15
    ch_names = {0: 'Hue', 1: 'Saturation', 2: 'Brightness'}
    poke_imgs = glob.glob('pokemons/*.png')
    for i in range(height//crop_size):
        for j in range(width//crop_size):
            # 画像をcrop_size*crop_sizeに分割する
            trimed_img = input_img[crop_size*i : crop_size*(i+1), crop_size*j: crop_size*(j+1)]
            max_ruizido = -1
            tmp_num = 5
            h_hist_2 = calc_hue_hist(trimed_img)
            for poke_name in poke_imgs:
                poke_img = cv2.imread(poke_name) #画像を読み出しオブジェクトpoke_imgに代入
                # 画像の Hue 成分のヒストグラムを取得する
                h_hist_1 = calc_hue_hist(poke_img)
                h_comp_hist = cv2.compareHist(h_hist_1, h_hist_2, cv2.HISTCMP_CORREL)
                if max_ruizido < h_comp_hist:
                    max_ruizido = h_comp_hist
                    tmp_num = poke_name
            if j == 0:
                # 一番左の画像
                tmp_img = cv2.imread(tmp_num)
            else:
                # 連結したい画像
                tmp_img2 = cv2.imread(tmp_num)
                # 連結された画像
                tmp_img = cv2.hconcat([tmp_img, tmp_img2])
        if i == 0:
            # 最初の１行が完成したので、次の行に移る
            output_img = tmp_img
        else:
            output_img = cv2.vconcat([output_img, tmp_img])
    
    return {'data': cv2_to_base64(output_img)}
```

## Dockerfileを作る

上記で書いたプログラムを動かすためのDockerfileを作成します。

Lambdaでpythonを使うための基本となるイメージを用意します。

```
FROM public.ecr.aws/lambda/python:3.7
```

次にLambdaではpip installができないので、ここで``pip install opencv-python``や``pip install numpy``を行って、OpenCVや必要なライブラリを使えるようにします。

```
RUN pip install opencv-python
RUN pip install numpy
```

今回作成したapp.pyやポケモン画像（pokemonsフォルダ）をイメージ内にコピーします。

```
COPY app.py /var/task/
COPY pokemons/ /var/task/pokemons/
```

ここで、[公式](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/python-image.html)では、``COPY app.py   ./``と書いてありますが、これだとOpenCVがうまく動かないので、必ず/var/task/を指定してコピーします。

全体はこちら。

```Dockerfile
FROM public.ecr.aws/lambda/python:3.7
RUN pip install opencv-python
RUN pip install numpy
COPY app.py /var/task/
COPY pokemons/ /var/task/pokemons/
CMD [ "app.handler" ]
```

## Docker imageを作成してECRにpushする

ECSのページの左のタブからECRをクリック。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/670446/65eecf50-97cb-3210-adeb-bcb2e5462bef.png)

Create repositoryをクリックしてリポジトリの名前だけ入れて、create repository

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/670446/9e6276d6-47c0-ff28-a246-fcde19075703.png)


リポジトリの中に入ってView push commandをクリックすると、使い方が載っているので、それに従います。

![スクリーンショット 2021-07-04 2.39.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/670446/95f6ae00-71f8-5bef-5b8e-5783736fcae1.png)

- コンソールからawsログイン→Login Succeededを確認する。
- docker build -t <リポジトリネーム> .
- AWS用にタグ付け
- ECRにpush


## Lambda関数を作成する（ECRからDocker imageをpull）

次にECRからDocker imageをpullするためのLambda関数を作ります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/670446/0cef1150-cf81-da37-6d6a-62ef8daba1b7.png)


create functionをクリックして、関数の作成方法はコンテナーイメージから作成を選びます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/670446/708ef052-2ff9-07fa-a635-1facb1008d17.png)

関数の名前とイメージのURLを指定します。
Container image URIの部分に、pushしたDockerImageのURLを登録します。

URLに関してはECRのリポジトリ画面からコピペできます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/670446/8f57751f-b099-4336-2937-67e99d8501c4.png)

IAMのroleがない人はLambda用に作っておきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/670446/b54caa48-7661-6543-e10c-a96b126c770a.png)

初期設定が3秒なので今回はGeneral configurationのEditから30秒に伸ばしました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/670446/375453f8-9cd4-fefd-02e3-f224263cde50.png)


メモリはお好みで。（私は128MBにしました。）

## API GatewayでAPI化する

REST APIのprivateではない方でBuildをクリックします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/670446/6448cb0b-0f64-dc5f-dcc4-b404554fdaf8.png)

Actionsボタンを押し、Create Resourceを選択。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/670446/2362c2d8-1ea1-3a9a-b30e-e9fe069ebd36.png)

リソース名などはお好みで。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/670446/b8747490-9d64-2955-9837-f85e0e274ccc.png)

CORSの許可を忘れないようにします。

ActionsからCreate Methodsを選択、POSTを選びます。
ここで先程作ったLambda関数を選択します。

テストをして通れば、ActionsからDeploy APIを選択して、デプロイします。

これは必須かどうかはわからないのですが、実際に外部からこのAPIを叩こうとしたら、API GatewayからLambdaへのアクセス権限で弾かれてしまったので、私はAPI GatewayからLambdaにアクセスするためのロールを作って付与しました。

これで完成です！

## あとがき

最初はOpenCV.jsを使おうとしていたのですが、OpenCV.jsって今の所CDNでしか使えないらしく、Next.jsでOpenCV.jsを使えるようにするのがめんどくさすぎて、今回バックエンドはpythonでやることにしました。

なんのために作ったかよくわからないAPIとなりましたが、なかなか勉強になって楽しかったです。