【Python】顔を検出してモザイク処理するLINE Botを作ってみた

背景に映り込んだ顔を検出してモザイク処理できるLINE Botが欲しい！ということでモザイクをかける顔を任意に選べるBotをPythonで作ってみました。

今回はFlask、line-bot-sdk、heroku、OpenCV、Pillowなどを使って実装しました。

問題点、改善点等がありましたらコメントお願いします！


#動作環境
```python

Flask==1.1.2
gunicorn==20.0.4
line-bot-sdk==1.16.0
matplotlib==3.3.2
numpy==1.18.5
opencv-python-headless==4.2.0.32
Pillow==7.2.0

python-3.8.5
```


#この記事で書かないこと
・LINEBotのチャンネル作成
・herokuへのデプロイ

手順は以下の記事に大変分かりやすく書いてあるので、ぜひ参考にしてください。
[Python + HerokuでLINE BOTを作ってみた - Qiita](https://qiita.com/shimajiri/items/cf7ccf69d184fdb2fb26)

オウム返しLINE Botについての記事ですが、ソースコードを変えて同じ手順でデプロイをすればたいていのLINE Botは完成します。PythonでLINE Botを作るのが初めてだという人は、一度オウム返しBotを作って正しく動作するかどうか試してみるとよいと思います。なお、上記の記事で「変更した内容を反映する」際にエラーにはまってしまう場合は[こちらのサイト](https://algorithm.joho.info/heroku/heroku-does-not-appear-to-be-a-git-repository/)が参考になるかもしれません。


#作ったもの
画像を送信すると顔を検出し、インデックスを付けて一覧表示してくれます。菅内閣の閣僚人事の写真＜引用元は[こちら](https://michiterasu.jp/contents/3575)＞で試してみましょう。
![S__54403095.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/832523/ebf223ca-65ff-4c62-ce69-2af27afdea97.jpeg)

インデックスを指定すると、指定された顔のみモザイクをかけた画像を返してくれます。
小泉環境大臣にモザイクをかけてみましょう。
![S__54403096.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/832523/1ff46577-fe39-61ee-05e3-0d8ce7417329.jpeg)
![S__54403102.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/832523/d0bcd07e-cd8d-1c52-6ab5-28280dae236f.jpeg)

また、指定した顔以外にモザイクをかけることもできます。
菅首相以外の閣僚にモザイクをかけてみましょう。
![S__54403099.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/832523/42fa5fb6-3b52-932c-3fbf-8bbb671e95c6.jpeg)
![S__54403100.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/832523/7ca897cd-9ec1-63c7-91c6-c8427e1cdf3e.jpeg)
このように任意の顔にモザイクをかけることができます。

このLINE Botの友達追加は以下のQRコードからお願いします。
![messageImage_1603772392474.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/832523/72119bea-5b06-cbb6-e56a-949610f20439.jpeg)

ソースコードはGitHubにプッシュしたので、下のURLからダウンロードできます。
[GitHub - Kitsuya0828/face_mosaic_linebot: A LINE Bot which recognizes faces on the picture and blur any of them you like](https://github.com/Kitsuya0828/face_mosaic_linebot)

#ソースコードの説明

##顔の検出と複数の顔の一覧表示
読み込む画像のパスと保存先のパスを用意しておきます。

```
src : 読み込む画像のパス
desc : 保存先のパス
```
いよいよ顔を検出します。とは言ってもOpenCVのカスケード分類器にカスケードファイルを渡し、グレースケール化した画像を指定するだけです。

```python

# カスケードファイル（特徴量学習済みデータの分類器）のパスの指定
cascade_file = './cascade/haarcascade_frontalface_alt2.xml'

# 画像読み込み
image = cv2.imread(str(src))
# グレースケールに変換
image_gs = cv2.cvtColor(image,cv2.COLOR_BGR2GRAY)

# 顔認識用特徴量ファイルの読み込み
cascade = cv2.CascadeClassifier(cascade_file)
# 顔認識
face_list = cascade.detectMultiScale(image_gs,
                                     scaleFactor=1.1,
                                     minNeighbors=1,
                                     minSize=(20,20))  # 20x20ピクセル以下の範囲は無視。背景を顔と間違えるのを防ぐため
```

これで```face_list```というリストに、検出した顔の座標が入りました。
続いて、顔検出をした座標をもとに顔の画像を切り出し、matplotlibを使って一覧表示を行います。全体として正方形の画像になるように配置しているので、余った部分は白紙の画像(```white.jpg```)を埋めていきます。

```python

length = len(face_list)
# タイル状に pm × pm 枚配置
pm = 1
while pm**2 < length:
    pm += 1

# タイル状に画像を一覧表示させる
fig, ax = plt.subplots(pm, pm, figsize=(10, 10))
fig.subplots_adjust(hspace=0, wspace=0)

for k in range(pm**2):
    i = k // pm  # 縦
    j = k % pm  # 横
    ax[i, j].xaxis.set_major_locator(plt.NullLocator())
    ax[i, j].yaxis.set_major_locator(plt.NullLocator())
    if k < length:
        x,y,w,h = face_list[k]
        # 配列アクセスを利用して顔を切り取る
        # imageの型はNumpyの配列(numpy.ndarray)。使い勝手が良い
        face_img = image[y:y+h,x:x+w]
        face_img = np.asarray(face_img)
        face_img = cv2.resize(face_img, (300, 300), cv2.INTER_LANCZOS4)
        face_img = cv2.cvtColor(face_img, cv2.COLOR_BGR2RGB)
        ax[i, j].text(30, 60, str(pm*i+j), fontsize=30, color='red')
        ax[i, j].imshow(face_img)
    else:
        img = cv2.imread('./white.jpg')
        img = np.asarray(img)
        img = cv2.resize(img, (300, 300), cv2.INTER_LANCZOS4)
        img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
        ax[i, j].imshow(img)

plt.savefig(desc)
```

顔検出を複数回行うと、毎回出力される顔の順番が異なるためユーザーが想定していない顔にモザイクがかかってしまいます。そのため、ユーザーIDで区別されたテキストファイル（```face_coordinates_path```で指定）に1回目で検出した座標を順番通りに保存しておく必要があります。

```python

# 顔座標テキストファイルの保存
with open(face_coordinates_path, "w", encoding='utf-8') as f:
    for i in range(len(face_list)):
        f.write(" ".join([str(x) for x in face_list[i]]) + "\n")
```

##モザイク処理
以下のものを用意しておきます。

```
src : 読み込む画像のパス
desc : 保存先のパス
numberslist : ユーザーが入力した番号のリスト
face_list :  detect_and_lineupで認識した顔座標のリスト
```

それでは、OpenCVを使ってモザイクをかけていきましょう。ユーザーが入力した顔番号に当てはまるならモザイク処理を実行していきます。

```python

for i,f in enumerate(face_list):
    x,y,w,h = f
    if i not in numberslist:
        continue
    # 切り抜いた画像を指定倍率で縮小する
    face_img = image[y:y+h,x:x+w]
    face_img = cv2.resize(face_img, (min(10,w//10),min(10,h//10)))
    # 縮小した画像を元のサイズに戻す
    # どのようにresizeするかを引数interpolationで指定（cv.INTER_LINEARはモザイクの角が目立たない）
    face_img = cv2.resize(face_img,(w,h),interpolation=cv2.INTER_AREA)
    # 元の画像に貼り付け
    image[y:y+h,x:x+w] = face_img   

image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
im = Image.fromarray(image)
im.save(desc)
```

##画像の送信
LINE Botで画像メッセージを送信するときにはオリジナル画像とプレビュー画像の両方のURLを用意しなければなりません。

```python

main_image_path = MAIN_IMAGE_PATH.format(user_id)
preview_image_path = PREVIEW_IMAGE_PATH.format(user_id)
```
画像の基本的な送信方法は以下の通りです。

```python

# 画像の送信
image_message = ImageSendMessage(original_content_url=f"https://<自分のアプリケーション名>.herokuapp.com/{main_image_path}",
                                 preview_image_url=f"https://<自分のアプリケーション名>.herokuapp.com/{preview_image_path}",)
```

#全体のソースコード
```
.
│  Aptfile
│  detect_and_lineup.py
│  main.py
│  mosaic.py
│  Procfile
│  requirements.txt
│  runtime.txt
│  white.jpg
│
├─cascade
│      haarcascade_frontalface_alt2.xml
│
└─static
    └─images
            Hoge
```
<details><summary>main.py</summary><div>

```python
import os
from pathlib import Path
from typing import List
from flask import Flask, abort, request
from linebot import LineBotApi, WebhookHandler
from linebot.exceptions import InvalidSignatureError
from linebot.models import (ImageMessage, ImageSendMessage, MessageEvent, TextMessage, TextSendMessage)

from detect_and_lineup import detect_and_lineup
from mosaic import mosaic

app = Flask(__name__,static_url_path="/static")

YOUR_CHANNEL_ACCESS_TOKEN = os.environ["YOUR_CHANNEL_ACCESS_TOKEN"]
YOUR_CHANNEL_SECRET = os.environ["YOUR_CHANNEL_SECRET"]

line_bot_api = LineBotApi(YOUR_CHANNEL_ACCESS_TOKEN)
handler = WebhookHandler(YOUR_CHANNEL_SECRET)

SRC_IMAGE_PATH = "static/images/{}.jpg"
MAIN_IMAGE_PATH = "static/images/{}_main.jpg"
PREVIEW_IMAGE_PATH = "static/images/{}_preview.jpg"
FACE_COORDINATES_PATH = "{}.txt"

@app.route("/")
def hello_world():
    return "hello world!"


@app.route("/callback", 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)
    app.logger.info("Request body: " + body)

    # handle webhook body
    try:
        handler.handle(body, signature)
    except InvalidSignatureError:
        abort(400)
    return "OK"


@handler.add(MessageEvent, message=TextMessage)
def handle_message(event):
    profile = line_bot_api.get_profile(event.source.user_id)
    user_id = profile.user_id
    if event.message.text == 'レビュー':
        line_bot_api.reply_message(
        event.reply_token, messages=[TextSendMessage(text="<レビューサイトのURL>")]
        )
    else:    
        src_image_path = Path(SRC_IMAGE_PATH.format(user_id)).absolute()
        main_image_path = MAIN_IMAGE_PATH.format(user_id*2)
        preview_image_path = PREVIEW_IMAGE_PATH.format(user_id*2)
        face_coordinates_path = FACE_COORDINATES_PATH.format(user_id)

        numberslist = list(map(int,str(event.message.text).split()))

        with open(face_coordinates_path) as f:
            face_list = [list(map(int,s.strip().split())) for s in f.readlines()]

        mosaic(src=src_image_path, desc=Path(main_image_path).absolute(),numberslist=numberslist,face_list=face_list)
        mosaic(src=src_image_path, desc=Path(preview_image_path).absolute(),numberslist=numberslist,face_list=face_list)
        image_message = ImageSendMessage(
            original_content_url=f"https://<自分のアプリケーション名>.herokuapp.com/{main_image_path}",
            preview_image_url=f"https://<自分のアプリケーション名>.herokuapp.com/{preview_image_path}",
        )
        app.logger.info(f"https://<自分のアプリケーション名>.herokuapp.com/{main_image_path}")
        line_bot_api.reply_message(
            event.reply_token, messages=[image_message,TextSendMessage(text="お気に召さなかったらごめんあそばせ")]
        )
        src_image_path.unlink()
    

@handler.add(MessageEvent, message=ImageMessage)
def handle_image(event):
    message_id = event.message.id
    profile = line_bot_api.get_profile(event.source.user_id)
    user_id = profile.user_id

    src_image_path = Path(SRC_IMAGE_PATH.format(user_id)).absolute()
    main_image_path = MAIN_IMAGE_PATH.format(user_id)
    preview_image_path = PREVIEW_IMAGE_PATH.format(user_id)
    face_coordinates_path = FACE_COORDINATES_PATH.format(user_id)

    # 画像を保存
    save_image(message_id, src_image_path)

    try:
        face_list = detect_and_lineup(src=src_image_path, desc=Path(main_image_path).absolute())
        detect_and_lineup(src=src_image_path, desc=Path(preview_image_path).absolute())
        # 画像の送信
        image_message = ImageSendMessage(
        original_content_url=f"https://<自分のアプリケーション名>.herokuapp.com/{main_image_path}",
        preview_image_url=f"https://<自分のアプリケーション名>![S__54403102.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/832523/9269110c-f218-c528-858d-e3223283b588.jpeg)
.herokuapp.com/{preview_image_path}",
        )
        app.logger.info(f"https://alvinface2.herokuapp.com/{main_image_path}")
        line_bot_api.reply_message(event.reply_token, messages=[image_message, TextSendMessage(text="モザイクをかけたいお顔の番号を半角空白区切りでご入力いただけるかしら？\n例）1番と3番の顔にモザイクをかけたい\n☞「1 3」と入力\n\n'-1'を先頭に付けると残したいお顔の番号を指定することもできますわよ。\n例）0番と2番以外の顔にモザイクをかけたい\n☞「-1 0 2」と入力")])

        # 顔座標テキストファイルの保存
        with open(face_coordinates_path, "w", encoding='utf-8') as f:
            for i in range(len(face_list)):
                f.write(" ".join([str(x) for x in face_list[i]]) + "\n")
        
    except Exception:
        line_bot_api.reply_message(
        event.reply_token, TextSendMessage(text='認識可能なお顔が見つかりませんでしたわ')
        )


def public_attr(obj) -> List[str]:
    return [x for x in obj.__dir__() if not x.startswith("_")]


def save_image(message_id: str, save_path: str) -> None:
    """保存"""
    message_content = line_bot_api.get_message_content(message_id)
    with open(save_path, "wb") as f:
        for chunk in message_content.iter_content():
            f.write(chunk)


if __name__ == "__main__":
    port = int(os.environ.get("PORT", 5000))
    app.run(host="0.0.0.0", port=port)

```

</div></details>
<details><summary>detect_and_lineup.py</summary><div>

```python
import numpy as np
from matplotlib import pyplot as plt
from PIL import Image, ImageDraw, ImageFont
import cv2

def detect_and_lineup(src: str, desc: str) -> None:
    """顔を見つけてリストアップする
    :params src:
        読み込む画像のパス
    :params desc:
        保存先のパス
    """

    # カスケードファイル（特徴量学習済みデータの分類器）のパスの指定
    cascade_file = './cascade/haarcascade_frontalface_alt2.xml'

    # 画像読み込み
    image = cv2.imread(str(src))
    # グレースケールに変換
    image_gs = cv2.cvtColor(image,cv2.COLOR_BGR2GRAY)

    # 顔認識用特徴量ファイルの読み込み
    cascade = cv2.CascadeClassifier(cascade_file)
    # 顔認識
    face_list = cascade.detectMultiScale(image_gs,
                                        scaleFactor=1.1,
                                        minNeighbors=1,
                                        minSize=(20,20))  # 20x20ピクセル以下の範囲は無視。背景を顔と間違えるのを防ぐため
    
    length = len(face_list)
    # タイル状に pm × pm 枚配置
    pm = 1
    while pm**2 < length:
        pm += 1
    
    # タイル状に画像を一覧表示させる
    fig, ax = plt.subplots(pm, pm, figsize=(10, 10))
    fig.subplots_adjust(hspace=0, wspace=0)

    for k in range(pm**2):
        i = k // pm  # 縦
        j = k % pm  # 横
        ax[i, j].xaxis.set_major_locator(plt.NullLocator())
        ax[i, j].yaxis.set_major_locator(plt.NullLocator())
        if k < length:
            x,y,w,h = face_list[k]
            # 配列アクセスを利用して顔を切り取る
            # imageの型はNumpyの配列(numpy.ndarray)。使い勝手が良い
            face_img = image[y:y+h,x:x+w]
            face_img = np.asarray(face_img)
            face_img = cv2.resize(face_img, (300, 300), cv2.INTER_LANCZOS4)
            face_img = cv2.cvtColor(face_img, cv2.COLOR_BGR2RGB)
            ax[i, j].text(30, 60, str(pm*i+j), fontsize=30, color='red')
            ax[i, j].imshow(face_img)
        else:
            img = cv2.imread('./white.jpg')
            img = np.asarray(img)
            img = cv2.resize(img, (300, 300), cv2.INTER_LANCZOS4)
            img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
            ax[i, j].imshow(img)

    plt.savefig(desc)
    return face_list
```
</div></details>

<details><summary>mosaic.py</summary><div>

```python
import numpy as np
from matplotlib import pyplot as plt
from PIL import Image, ImageDraw, ImageFont
import cv2

def mosaic(src: str, desc: str, numberslist=[], face_list=[]) -> None:
    """
    :params src:
        読み込む画像のパス
    :params desc:
        保存先のパス
    :numberslist:
        ユーザーが入力した番号のリスト
    :face_list:
        detect_and_lineupで認識した顔座標のリスト
    """

    # 画像読み込み
    image = cv2.imread(str(src))

    # ユーザーが残したい顔の番号を指定したとき
    new_numberslist = []
    if numberslist[0] == -1:
        for num in range(len(face_list)):
            if num not in numberslist:
                new_numberslist.append(num)
    numberslist = new_numberslist

    for i,f in enumerate(face_list):
        x,y,w,h = f
        if i not in numberslist:
            continue
        # 切り抜いた画像を指定倍率で縮小する
        face_img = image[y:y+h,x:x+w]
        face_img = cv2.resize(face_img, (min(10,w//10),min(10,h//10)))
        # 縮小した画像を元のサイズに戻す
        # どのようにresizeするかを引数interpolationで指定（cv.INTER_LINEARはモザイクの角が目立たない）
        face_img = cv2.resize(face_img,(w,h),interpolation=cv2.INTER_AREA)
        # 元の画像に貼り付け
        image[y:y+h,x:x+w] = face_img   

    image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
    im = Image.fromarray(image)
    im.save(desc)
    return True
```
</div></details>

#つまずいたところ
##Heroku+OpenCVのエラー
プログラムはローカル環境で実行できていたにも関わらず、requirements.txtにopencv-pythonを加えてHerokuにデプロイすると

```
ImportError: libSM.so.6: cannot open shared object file: No such file or directory
```
のようなエラーが出ます。私は以下の2つのことをしてエラーを解消できました。

####①buildpacks(Heroku)とAptfileを追加する
Herokuのbuildpacksに[https://github.com/heroku/heroku-buildpack-apt](https://github.com/heroku/heroku-buildpack-apt)を追加し、Aptfileをプロジェクトフォルダに追加します。

```Aptfile:Aptfile
libsm6
libxrender1
libfontconfig1
libice6
```

以下の記事の通りに実行すれば問題ありません。
[herokuでOpenCVを利用する [Python3] - Qiita](https://qiita.com/haru1843/items/210cb08024195b9d1bc8)

####②opencv-python-headlessを利用する
```
opencv-python-headless==4.2.0.32
```
以下のサイトに同じ悩みを抱えている方を発見し解決できました。
[https://stackoverflow.com/questions/49469764/how-to-use-opencv-with-heroku/51004957](https://stackoverflow.com/questions/49469764/how-to-use-opencv-with-heroku/51004957)


#まとめ
HerokuとOpenCVの相性の悪さに最も悩まされましたが、なんとか作り上げることができました。
あったら便利だなと思うLINE Botを皆さんも自由な発想で作成してみてはいかがでしょうか。
それでは、楽しいプログラミング人生をお過ごしください。

#参考文献
☟Pythonを使ったLINE Botの作り方が本当に分かりやすいです。
[Python + HerokuでLINE BOTを作ってみた - Qiita](https://qiita.com/shimajiri/items/cf7ccf69d184fdb2fb26)

☟画像の保存・送信方法が非常に参考になりました。
[【Python】写真に日付を付けてくれるLINE Bot作った - Qiita](https://qiita.com/tamago324/items/4df361fd6ac5b51a8a07)