車のナンバープレート黒塗りBOTを作ってみた

# 概要
車のナンバープレートが写っている写真に対して、そのナンバープレートを検知して黒塗りで隠してくれるLINE BOTを作ってみたのでその過程を記事にしてみました。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2727975/ecbdb0b5-5779-dcee-b178-aab9b3e130ea.jpeg" width="100%">

# 今回やったこと全容
* __YOLOv5モデルの学習__

* __Pythonコードからモデルを実行してみる__

* __学習済みモデルをSagemaker Serverless Inference上に配置__

* __LINE BOTから推論を叩いて結果を得る__

# モデルの学習
### 教師データの用意
今回は画像から物体検出を行うということで、YOLOv5モデルを利用することにしてみました。
https://github.com/ultralytics/yolov5

教師データに関しては、Youtubeに上がっている車載動画からフレームごとに画像を切り出して、その中から適当に見繕って用意しました。
それらの画像にVOTTを使ってラベリングを行い教師データの作成をします。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2727975/0dd47671-aa74-7a82-4586-2d4b8c949341.png" width="100%">

ただし、VOTTで吐き出される教師データの形式はそのままではYOLOv5の学習には使えない為、少し変換をかます必要があります。
そこで今回はRoboflowというサービスを利用してYOLOv5に利用できる形式に変換してもらうことにしました。
https://roboflow.com/

また、このサービスでは教師データに対してある程度のオーグメンテーションも行ってくれる為それも試してみました。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2727975/d2e7314f-256f-51eb-a4ec-1670bf21dabd.png" width="70%">

拡縮や平行移動などの簡単なものは行ってくれるみたいです。

教師データの枚数の違いやオーグメンテーションの有無によっての認識精度の差を見てみたかったので、112枚で学習させたパターンと、それにオーグメンテーションを行なって336枚で学習させたパターンと、更に元画像を255枚に増やした上でオーグメンテーションを行なって648枚で学習させたパターンをそれぞれ検証してみました。

学習はこちらを利用してGoogle Colabolatory上で行いました。
```
python train.py --data <教師データへのパス> --epochs <エポック数> --weights <転移学習元の重み> --batch-size <バッチサイズ>
```

### 認識精度比較
112枚で学習させたパターンと336枚で学習させたパターンはバッチサイズを32、エポック数を20で学習させましたが、ほとんどナンバーを検知することが出来ませんでした。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2727975/2fee419c-71d7-5889-92ed-3e90fac6f8f0.jpeg" width="80%">

648枚の教師データを学習させたパターンは、Colab上だとエポック数を増やしすぎると途中でランタイム接続が切れてしまう為、10エポックずつで学習をさせていきました。最初の10エポック目でも先ほどとは違う結果が見えてきました。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2727975/0f20c228-a143-0c0c-1a3d-37d70c51461a.jpeg" width="80%">

上手く認識できそうな兆しがあったので、このまま70エポックまで学習を進めた結果、十分機能する精度まで高めることができました。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2727975/2282a548-e1e6-4719-bf7b-3094131dc309.png" width="100%">


# 推論の実行
今まで学習や推論はコマンドライン上で行っていましたが、実際にサービスで推論を動かすとなるとpythonコード上でモデルを読み込み、推論をかける必要があります。なので、先ほど学習させた重みを使ってColab上で試しにモデルの読み込みから推論までを通してやってみることにします。
基本的にはGithubに上がっているYOLOv5のコードを参考にして進めていきます。

### モデルの読み込み
まずはモデルのロードですが、これはpytorchの`torch.load()`で行いました。モデルを学習させた結果の重みは`best.pt`として保存されているのでこれを読み込みに行くのですが、その際にYOLOv5のmodelフォルダとutilフォルダが丸々必要となってくるので、`best.pt`ファイルと同じ階層に用意しておきます。これは重み保存を`torch.save()`によって行っているのが原因らしいのですが、この辺りのフォルダ構成はきっちりしておかないとモデル読み込み時にエラーが起きます。

### モデルへの入力
次は画像をモデルへ入力するまでの前処理を行っていきます。
まずはPillowで画像を読み込みます。
```
im = Image.open(“Googleドライブ内の画像パス”)
```

今回のYOLOv5モデルは640サイズの画像を入力とするため、そのサイズに直します。
また、元画像の縦横比を保つために余白を追加しつつリサイズをしていきます。
```
# リサイズ、余白追加
image_padding = Image.new('RGB', [max(im.size)] * 2, (0, 0, 0))
image_padding.paste(im, (0, 0, im.width, im.height))
image_resized = image_padding.resize((640, 640), Image.ANTIALIAS)
```

numpy配列に直してから、pytorchのtensor型に変換していきます。
今回は最初Pillowで画像を読み込んだ為に色情報がBGR型となっているのですが、これだと都合が悪いので途中でRGB型に直したりしています。
```
im = np.array(image_resized)
im = cv2.cvtColor(im, cv2.COLOR_BGR2RGB)
im = torch.from_numpy(im)
```

数値を0~1の範囲に正規化します。
```
im /= 255  # 0 - 255 to 0.0 - 1.0
```

この処理が少し特徴的だと思うのですが、tensorのサイズを`(1, 3, 640, 640)`としてリサイズする必要があります。
通常Pillow等で読み込んだ際は`(640, 640, 3)`の様になっていると思うので、これをモデルに入力できる形へと整えていきます。
```
if len(im.shape) == 3:
    im = im[None]  # expand for batch dim
im = torch.permute(im, (0, 3, 1, 2))
```

以上で前処理が完了です。推論を実行してみましょう！
pytorchで読み込んだモデルはディクショナリー型になっていたので以下の様な使い方になります。
```
pred = model['model'](im, False, False)
```

### モデルからの出力
次は推論結果がどうなっているか見てみます。
まずは上記で得られたpredのサイズですが、
```
([[1, 25200, 6], [[1, 3, 80, 80, 6], [1, 3, 40, 40, 6], [1, 3, 20, 20, 6]]])
```

の様になっていました。
データの中身に関しては、
```
[ 3.99102e-01,  5.78214e-01,  1.90415e+00, -5.36558e-01, -8.57918e+00,  1.00173e+01], …
```

の様な形で6要素が並んでいます。これはそれぞれ、
`[center_x, center_y, width, height, 確度, 識別クラス係数]`

を表していて、xとy座標が画像の左上から右下に向かってすこしずつズレていくのが数値から見て取れます。つまり画像内の25200個のバウンディングボックスに対して、推論の確度が示されているようです。
また、今回はナンバーだけを検知するモデルのため識別クラスは1つですが、複数の物体を検知するモデルだとそれに応じて要素数が増えます。
つまり、これらのデータを適切に処理して推論結果を導いていく必要があります。
```
 [[1, 3, 80, 80, 6], [1, 3, 40, 40, 6], [1, 3, 20, 20, 6]]
```

の部分に関しては損失関数の出力と記載があったのですが、今回使わない事もあってそれ以上の詳しい中身は見ませんでした。

### 推論結果の処理
推論結果の処理にはNon-Maximum Suppression(NMS)という手法を使います。
ここでの詳しい説明は省きますが、重複しているバウンディングボックスを適切に処理してひとつのバウンディングボックスとして推論をまとめる様な処理を行っています。
（YOLOv5のGithubにNMSを行うコードが上がっていたので拝借しました）
```
pred = non_max_suppression(pred)
print("処理後: ", pred)
```
```
処理後:  [tensor([[292.75226, 326.84778, 355.07587, 358.43054,   0.93183,   0.00000],
        [561.17792, 160.74695, 582.67120, 173.13055,   0.88415,   0.00000]])]
```
NMSを行った結果を見てみるとこのような形になっています。
要素が示す内容としては、
`[top_left_x, top_left_y, bottom_right_x, bottom_right_y, 確度, クラス]`

です。今回で言うと2つのナンバープレートが検知されていますね。

後は、検知したナンバープレートを黒塗りで隠したいのでこれらの情報を元に加工を行っていきます。
```
# ナンバー黒塗り
for det in pred[0]:
  det = det * scale_ratio
  image_original = np.array(image_original)
  cv2.rectangle(image_original, pt1=(int(det[0]), int(det[1])), pt2=(int(det[2]), int(det[3])), color=(0, 0, 0), thickness=-1)
```

モデルへの入力画像は640x640でしたが、元画像の大きさがそうだとは限らない為画像の拡縮率を保存しておいて推論結果のサイズ感を元画像の方に合わせています。

これでようやくpythonコード上から推論を実行する流れが出来上がりました！次はこのモデルと処理をAWS Sagemakerに載せてみたいと思います。

# Sagemaker Serverless Inferenceを使う
2022年の4月頃、AWS SagemakerにServerless Inferenceというサービスが追加されました。
https://docs.aws.amazon.com/ja_jp/sagemaker/latest/dg/serverless-endpoints.html

推論を叩く時だけ推論コンテナを起動して課金されるタイプのものです。これに興味を持ったので今回使ってみることにします。

また、Sagemakerを使うならNotebookインスタンスを作ってそのまま学習、モデルのアップロードまで行う流れが自然かと思いますが、Notebookインスタンスを立ち上げると時間単位でお金がかかってしまうので、今回はカスタムコンテナをサーバーレスで立ち上げ、推論エンドポイントを用意するというお財布に優しい方法で試してみることにします。

### S3へモデルのアップロード
既に学習済みのモデルを利用する際は、事前にS3にアップロードしておく必要があります。今回は重みデータ以外にmodelとutilフォルダのコードもまとめて置いておきたいので、まとめてtar.gz形式でアップロードしておきます。

ここでアップロードした重みデータは後ほど推論エンドポイントを作成する際に指定する事で、カスタムコンテナを立ち上げる際に自動的にコンテナ内にダウンロードしてくれるようになります。

ただし注意点として、S3へアップロードする重みやモデルのデータ量が大きすぎるとコンテナに読み込む際にメモリ不足のエラーが起きるみたいです。自分の場合はtar.gzに圧縮した後で200MBくらいある重みデータを利用しようとした際にメモリ不足だと言われてコンテナが上手く立ち上がりませんでした。

### カスタムコンテナの実装
AWS ECRに自前のコンテナをアップロードしておく事で、それを推論コンテナとして利用することが出来ます。
Serverless Inferenceで利用する推論コンテナとして満たすべき仕様があるみたいで、実装はこちらを参考にしました。
https://github.com/aws/amazon-sagemaker-examples/tree/main/advanced_functionality/scikit_bring_your_own/container

満たすべき仕様は、
* /pingリクエストに応答できること（生きているならステータス200を返す）
* /invocationsリクエストに応答できること（推論実行時に叩かれる）
の二つです。

Serverless Inferenceの推論が呼ばれるとこのコンテナが起動され、`/opt/ml/model/`以下に先ほどS3へアップロードしたファイル達がダウンロードされます。
後はColab上で行った手順通りにモデルの読み込み、データの前処理、推論の実行と結果の後処理を行うコードを組めば推論コンテナが実装できます。

また、推論エンドポイントに送られてくる画像データのリクエスト形式を考える必要がありますが、今回はbase64形式で送ってもらうことにしました。
推論後にナンバープレートを黒塗りした画像はランダムな文字列で命名してS3に保存し、その画像URLをレスポンスとして返すようにしています。

### AWSコンソールでの設定手順
ここまで出来たらいよいよAWSコンソールで推論エンドポイントを作成していきます。

Amazon Sagemakerページの左タブから、推論 > モデルを押して遷移していき、モデルの作成ボタンを押します。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2727975/68943052-afd2-b052-125f-d58c881ce175.png" width="80%">

推論コードイメージの場所にはECRへとアップロードした推論コンテナのパスを記載し、アーティファクトの場所にはS3へとアップロードしたモデルの重みデータ等のパスを記載します。

モデルを作成し終えたら次は推論 > エンドポイント設定ページからエンドポイント設定の作成を行います。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2727975/8605c705-c73c-ddba-72ea-7c23ab7c502f.png" width="80%">
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2727975/3113ab1f-e072-54dd-00a5-ff01d9fccfa3.png" width="80%">

今回はサーバーレスで行うのでエンドポイントのタイプはサーバーレスにします。
本番稼働用バリアントを作成ボタンから、先ほど作成したモデルを選択します。
モデルサイズと最大同時実行数を選択できますが、サーバーレスの場合メモリサイズは最大3GBまで、最大同時実行数は10までに抑えておかないと次ステップでエンドポイントの作成を行う時にエラーが出ます。

エンドポイント設定が終わったら、最後に推論 > エンドポイントページからエンドポイントの作成を行います。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2727975/ea3f024b-574b-1509-671a-253ba60e4ffb.png" width="80%">

エンドポイント設定の欄に先ほど作成した設定が出てくるのでこれを選択し、エンドポイントを作成します。
これで推論エンドポイントの作成は完了です！

# LINE BOTまわり
最後に今回のフロント部分となるLINE BOTを実装していきます。
と言っても実装は[AWS LambdaとServerless Frameworkを利用してLINE BOTを作ってみた](https://qiita.com/MKen_bu/items/311e8c6d406f001611bf)と同じ形で行っているので、ここでの詳しい説明は省きます。

今回は画像メッセージを受け取ったら画像メッセージを返す、という形を作りたいので
https://developers.line.biz/ja/reference/messaging-api/#get-content
https://developers.line.biz/ja/reference/messaging-api/#image-message

この辺りを参考に実装していきます。
LINEのトーク上に画像を貼ると、画像はBOTに直接渡されるのではなく一旦LINEサーバー上に保存されます。従って、BOT側では`messageId`を参照してLINEサーバーへと画像コンテンツの取得リクエストを送る必要があります。
```
client.getMessageContent(event.message.id)
  .then((stream) => {
    let image_binary = [];
    stream.on('data', (chunk) => {
      image_binary.push(chunk);
    });
    stream.on('end', () => {
      image_binary = Buffer.concat(image_binary);
      const image_b64 = Buffer.from(image_binary).toString('base64');
      const param = {'data':image_b64};
      const apiURL = 'lambdaのURL';
      axios.post(apiURL, param)
      .then(response => {
        const message = createImageMessage(response.data.body);
        …
      })
    });
  });
```

LINEサーバーから送られてくる画像データはバイナリデータになっています。
今回はLINE BOTから直接推論エンドポイントを叩くのではなく、エンドポイントを叩く用のLambdaを用意したのでbase64エンコードをしてから一旦そちらに画像データを渡しています。

Lambda上ではboto3を使って推論エンドポイントを叩いています。
```
def lambda_handler(event, context) :
    sagemaker_runtime = boto3.client("sagemaker-runtime")
    obj = event['data']
    # 推論エンドポイントにリクエスト
    response = sagemaker_runtime.invoke_endpoint(
                            EndpointName="推論エンドポイント名",
                            Body=obj,
                            ContentType="application/json",
                            Accept="application/json"
    )
```

LINE BOTから画像メッセージを表示させるには画像のURLが必要なので、推論のレスポンスとして帰ってきた画像URLをそのままLINE BOTにレスポンスとして返して、後はLINE BOT上で画像メッセージとして送信すれば実装完了です！
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2727975/2aea83e5-6aa3-1e67-1c36-9cfb9411c41b.png" width="100%">


こちらが画像を貼ってから大体2秒程度でレスポンスが送られてきます。
ただし、Serverless Inferenceを利用しているとどうしてもコールドスタートの問題が避けられず、長時間推論エンドポイントが叩かれない状態が続くと次回起動時はまた重みデータの読み込みから始まるので、30秒くらい処理に時間がかかってしまいます。実際にサービスで利用しようとするならこの問題は対処する必要がありそうです。