Amazon RekognitionとOpenCVで画像の目を大きくするAPIを作成する

先日[目をでかくして誰でもアイドルみたいになれるアプリを作った](https://qiita.com/yuikoito/items/28b47788f9c1590b7903)という記事を書きました。

この記事では、具体的にどのようにしてこの機能を実現したのかを書きます。

## 実装内容

実装内容は以下です。

- [Amazon Rekognition](https://aws.amazon.com/jp/rekognition/)で目を認識
- OpenCVで切り抜いて大きくして合成する
- 合成後自然になるように境界線をぼかす


## [Amazon Rekognition](https://aws.amazon.com/jp/rekognition/)で目を認識

目の場所を検知するためのコードは以下。

```python
import cv2
import boto3
from botocore.config import Config

config = Config(
   retries = {
      'max_attempts': 10,
      'mode': 'standard'
   }
)

def rekog_eye(im):
    #amazon_rekognition に投げる
    client = boto3.client('rekognition','ap-northeast-1',config=config)
    result, buf = cv2.imencode('.jpg', im)

    #rekognition_imageによる顔情報
    faces = client.detect_faces(Image={'Bytes':buf.tobytes()}, Attributes=['ALL'])

    leftEyeLeft = faces['FaceDetails'][0]['Landmarks'][11]
    leftEyeRight = faces['FaceDetails'][0]['Landmarks'][12]
    leftEyeUp = faces['FaceDetails'][0]['Landmarks'][13]
    leftEyeDown = faces['FaceDetails'][0]['Landmarks'][14]
    rightEyeLeft = faces['FaceDetails'][0]['Landmarks'][15]
    rightEyeRight = faces['FaceDetails'][0]['Landmarks'][16]
    rightEyeUp = faces['FaceDetails'][0]['Landmarks'][17]
    rightEyeDown = faces['FaceDetails'][0]['Landmarks'][18]
    EyeList = [leftEyeLeft, leftEyeRight, leftEyeUp, leftEyeDown, rightEyeLeft, rightEyeRight, rightEyeUp, rightEyeDown]

    EyePoints = {}
    h, w, ch = im.shape
    for eyepoint in EyeList:
        EyePoints[eyepoint['Type']] = {'X': int(eyepoint['X']*w), 'Y': int(eyepoint['Y']*h)}

    return EyePoints

```

AWSの各種サービスを簡単に利用できるライブラリboto3を使って実装していきます。
boto3のclientを作成し、使うサービス（今回はrekognition）とリージョンを指定します。
configで何回tryするかを指定しています。今回だと10なので、仮に1回目でサーバーエラーやネットワークエラーなどでうまくRekognitionが動かなくても2回目再度tryし、そして最大10回は試すというようになっています。

```python
client = boto3.client('rekognition','ap-northeast-1',config=config)
```

そしてrekognitionのdetect_facesに画像バイト列を渡して検出します。

```python
faces = client.detect_faces(Image={'Bytes':buf.tobytes()}, Attributes=['ALL'])
```

あとは必要なランドマークをそれぞれ使いやすいように名称をふります。
例えば左目の左端だとleftEyeLeft、左目の右端だとleftEyeRightです。
よりギリギリで切り取って広げてということをしたいので、片目に対して４点を検出しています。

検出できた場所はより使いやすいように配列に入れます。

```python
EyeList = [leftEyeLeft, leftEyeRight, leftEyeUp, leftEyeDown, rightEyeLeft, rightEyeRight, rightEyeUp, rightEyeDown]
```

更に整形してからreturnします。

```python
    EyePoints = {}
    h, w, ch = im.shape
    for eyepoint in EyeList:
        EyePoints[eyepoint['Type']] = {'X': int(eyepoint['X']*w), 'Y': int(eyepoint['Y']*h)}
```

## OpenCVで切り抜いて大きくして合成する

まずは画像をそれぞれbase64からcv2に、また逆にcv2からbase64に変換する関数を用意します。

```python
import numpy as np
import base64

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
```

切り取って合成するためのコードは以下。

```python
def handler(event, context):
    ##############リクエストペイロードからリクエストボディを抜き出す############
    # event['body']は文字列になっているのでdictに変換
    try:
        base_64ed_image = event.get('myimg', 'none')
        magnification = float(event.get('magni', 1.4)
        blur_num = int(event.get('blur', 3)) ##ぼかし機能のため（後述）
        im = base64_to_cv2(base_64ed_image)
        EyePoints = rekog_eye(im)
        bityouseix = 20
        bityouseiy = 5
        leftTop = min([EyePoints['leftEyeUp']['Y'], EyePoints['leftEyeDown']['Y'], EyePoints['leftEyeRight']['Y'], EyePoints['leftEyeLeft']['Y']])
        leftBottom = max([EyePoints['leftEyeUp']['Y'], EyePoints['leftEyeDown']['Y'], EyePoints['leftEyeRight']['Y'], EyePoints['leftEyeLeft']['Y']])
        leftRight = max([EyePoints['leftEyeUp']['X'], EyePoints['leftEyeDown']['X'], EyePoints['leftEyeRight']['X'], EyePoints['leftEyeLeft']['X']])
        leftLeft = min([EyePoints['leftEyeUp']['X'], EyePoints['leftEyeDown']['X'], EyePoints['leftEyeRight']['X'], EyePoints['leftEyeLeft']['X']])
        rightTop = min([EyePoints['rightEyeUp']['Y'], EyePoints['rightEyeDown']['Y'], EyePoints['rightEyeRight']['Y'], EyePoints['rightEyeLeft']['Y']])
        rightBottom = max([EyePoints['rightEyeUp']['Y'], EyePoints['rightEyeDown']['Y'], EyePoints['rightEyeRight']['Y'], EyePoints['rightEyeLeft']['Y']])
        rightRight = max([EyePoints['rightEyeUp']['X'], EyePoints['rightEyeDown']['X'], EyePoints['rightEyeRight']['X'], EyePoints['rightEyeLeft']['X']])
        rightLeft = min([EyePoints['rightEyeUp']['X'], EyePoints['rightEyeDown']['X'], EyePoints['rightEyeRight']['X'], EyePoints['rightEyeLeft']['X']])

        leftEye = im[leftTop:leftBottom+bityouseiy, leftLeft-bityouseix:leftRight+bityouseix]
        leftEye = cv2.resize(leftEye, (leftEye.shape[1], int(leftEye.shape[0]*magnification)))
        rightEye = im[rightTop:rightBottom+bityouseiy, rightLeft-bityouseix:rightRight+bityouseix]
        rightEye = cv2.resize(rightEye, (rightEye.shape[1], int(rightEye.shape[0]*magnification)))

        im[leftTop:leftTop+leftEye.shape[0], leftLeft-bityouseix:leftLeft+leftEye.shape[1]-bityouseix] = leftEye
        im[rightTop:rightTop+rightEye.shape[0], rightLeft-bityouseix:rightLeft+rightEye.shape[1]-bityouseix] = rightEye


        return {'status':200, 'message':'OK', 'img': cv2_to_base64(im)}
    except Exception as e:
        return {'status':500, 'message':str(e)}
```

以下部分で切り取るための座標を再度計算しています。
EyeUpのY座標をそのままTopとしないのは、目が斜めになっている場合を考慮してです。
（ただし、切り取ったあとにそのまま縦に伸ばしているだけなので、歪みはするのですが。）

なので必要であれば、例えば右が最大値になったときは斜めに伸ばす、などをすればもっと自然だと思いますが、今回はクソアプリなのでここまではしてません。

```python
        leftTop = min([EyePoints['leftEyeUp']['Y'], EyePoints['leftEyeDown']['Y'], EyePoints['leftEyeRight']['Y'], EyePoints['leftEyeLeft']['Y']])
        leftBottom = max([EyePoints['leftEyeUp']['Y'], EyePoints['leftEyeDown']['Y'], EyePoints['leftEyeRight']['Y'], EyePoints['leftEyeLeft']['Y']])
        leftRight = max([EyePoints['leftEyeUp']['X'], EyePoints['leftEyeDown']['X'], EyePoints['leftEyeRight']['X'], EyePoints['leftEyeLeft']['X']])
        leftLeft = min([EyePoints['leftEyeUp']['X'], EyePoints['leftEyeDown']['X'], EyePoints['leftEyeRight']['X'], EyePoints['leftEyeLeft']['X']])
        rightTop = min([EyePoints['rightEyeUp']['Y'], EyePoints['rightEyeDown']['Y'], EyePoints['rightEyeRight']['Y'], EyePoints['rightEyeLeft']['Y']])
        rightBottom = max([EyePoints['rightEyeUp']['Y'], EyePoints['rightEyeDown']['Y'], EyePoints['rightEyeRight']['Y'], EyePoints['rightEyeLeft']['Y']])
        rightRight = max([EyePoints['rightEyeUp']['X'], EyePoints['rightEyeDown']['X'], EyePoints['rightEyeRight']['X'], EyePoints['rightEyeLeft']['X']])
        rightLeft = min([EyePoints['rightEyeUp']['X'], EyePoints['rightEyeDown']['X'], EyePoints['rightEyeRight']['X'], EyePoints['rightEyeLeft']['X']])
```

そしてその座標に応じて切り取ります。

```python
        leftEye = im[leftTop:leftBottom+bityouseiy, leftLeft-bityouseix:leftRight+bityouseix]
        rightEye = im[rightTop:rightBottom+bityouseiy, rightLeft-bityouseix:rightRight+bityouseix]
```

切り取った左目と右目をリサイズ。

```python
        leftEye = cv2.resize(leftEye, (leftEye.shape[1], int(leftEye.shape[0]*magnification)))
        rightEye = cv2.resize(rightEye, (rightEye.shape[1], int(rightEye.shape[0]*magnification)))
```

そして合成します。

```python
        im[leftTop:leftTop+leftEye.shape[0], leftLeft-bityouseix:leftLeft+leftEye.shape[1]-bityouseix] = leftEye
        im[rightTop:rightTop+rightEye.shape[0], rightLeft-bityouseix:rightLeft+rightEye.shape[1]-bityouseix] = rightEye
```

## 境界線をぼかす

境界線をぼかすために、OpenCVのガウシアンフィルタを使いました。

参考

http://labs.eecs.tottori-u.ac.jp/sd/Member/oyamada/OpenCV/html/py_tutorials/py_imgproc/py_filtering/py_filtering.html

使い方は``cv2.GaussianBlur()``の引数にぼかす部分(image)とカーネルサイズと標準偏差を指定するだけです。

```python
def mosaic_area(src, x, y, width, height, blur_num):
    dst = src.copy()
    for i in range(blur_num):
        dst[y:y + height, x:x + width] = cv2.GaussianBlur(dst[y:y + height, x:x + width], (3,3),3)
    return dst
```

ちなみにどれぐらいぼかすかをフロント側から制御したかったので、``blur_num``で何回このぼかしを実行するかを指定しています。

あとは上記のhandler関数の中で上記関数を組み込みます。

```python
            im = mosaic_area(im, leftLeft-bityouseix-int(bityouseix/2), leftTop, bityouseix, leftEye.shape[0]+bityouseiy, blur_num)
            im = mosaic_area(im, leftRight+int(bityouseix/2), leftTop, bityouseix, leftEye.shape[0]+bityouseiy, blur_num)
            im = mosaic_area(im, leftLeft-bityouseix, leftTop+leftEye.shape[0]-int(bityouseiy/2), leftEye.shape[1], bityouseiy, blur_num)
            im = mosaic_area(im, rightLeft-bityouseix-int(bityouseix/2), rightTop, bityouseix, rightEye.shape[0]+bityouseiy, blur_num)
            im = mosaic_area(im, rightRight+int(bityouseix/2), rightTop, bityouseix, rightEye.shape[0]+bityouseiy, blur_num)
            im = mosaic_area(im, rightLeft-bityouseix, rightTop+rightEye.shape[0]-int(bityouseiy/2), rightEye.shape[1], bityouseiy, blur_num)
```

今回は目の上の部分は何も変更がないのでぼかす必要はありませんが、目の左、右、下部分は広げて合成しているため境界線が見えてしまいます。それぞれの境界線でぼかしを実行しているため、（左、右、下）×両目部分のぼかしのために上記の６回ぼかしフィルターを使っています。

あとはこれをLambdaにデプロイしてからAPIGatewayでAPI化するなりしたら完成です。
OpenCVをLambdaで動かすためには少しコツがいるので、必要であれば[【LambdaでOpenCVを利用】AWSとOpenCVを利用してポケモン画像でアスキーアート風に変換するAPIを作った](https://qiita.com/yuikoito/items/7dcf1deeed46f46b2f2e)を読んでください。

## あとがき

今回LambdaでAmazon Rekognitionを動かすためにLambdaに対して権限を渡す必要があるのですが、それを忘れていて最初はうまく動かすことができなくて少し苦労しました。笑
AWSでのAPI作成はようやく慣れてきたので今後も色々作っていきたいなと思っています。

最後まで読んでいただきありがとうございました！