背景に映り込んだ顔を検出してモザイク処理できるLINE Botが欲しい!ということでモザイクをかける顔を任意に選べるBotをPythonで作ってみました。
今回はFlask、line-bot-sdk、heroku、OpenCV、Pillowなどを使って実装しました。
問題点、改善点等がありましたらコメントお願いします!
動作環境
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
オウム返しLINE Botについての記事ですが、ソースコードを変えて同じ手順でデプロイをすればたいていのLINE Botは完成します。PythonでLINE Botを作るのが初めてだという人は、一度オウム返しBotを作って正しく動作するかどうか試してみるとよいと思います。なお、上記の記事で「変更した内容を反映する」際にエラーにはまってしまう場合はこちらのサイトが参考になるかもしれません。
作ったもの
画像を送信すると顔を検出し、インデックスを付けて一覧表示してくれます。菅内閣の閣僚人事の写真<引用元はこちら>で試してみましょう。
インデックスを指定すると、指定された顔のみモザイクをかけた画像を返してくれます。
小泉環境大臣にモザイクをかけてみましょう。
また、指定した顔以外にモザイクをかけることもできます。
菅首相以外の閣僚にモザイクをかけてみましょう。
このように任意の顔にモザイクをかけることができます。
このLINE Botの友達追加は以下のQRコードからお願いします。
ソースコードはGitHubにプッシュしたので、下のURLからダウンロードできます。
GitHub - Kitsuya0828/face_mosaic_linebot: A LINE Bot which recognizes faces on the picture and blur any of them you like
ソースコードの説明
顔の検出と複数の顔の一覧表示
読み込む画像のパスと保存先のパスを用意しておきます。
src : 読み込む画像のパス
desc : 保存先のパス
いよいよ顔を検出します。とは言ってもOpenCVのカスケード分類器にカスケードファイルを渡し、グレースケール化した画像を指定するだけです。
# カスケードファイル(特徴量学習済みデータの分類器)のパスの指定
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
)を埋めていきます。
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回目で検出した座標を順番通りに保存しておく必要があります。
# 顔座標テキストファイルの保存
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を使ってモザイクをかけていきましょう。ユーザーが入力した顔番号に当てはまるならモザイク処理を実行していきます。
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を用意しなければなりません。
main_image_path = MAIN_IMAGE_PATH.format(user_id)
preview_image_path = PREVIEW_IMAGE_PATH.format(user_id)
画像の基本的な送信方法は以下の通りです。
# 画像の送信
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
main.py
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://<自分のアプリケーション名>
.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)
detect_and_lineup.py
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
mosaic.py
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
つまずいたところ
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を追加し、Aptfileをプロジェクトフォルダに追加します。
libsm6
libxrender1
libfontconfig1
libice6
以下の記事の通りに実行すれば問題ありません。
herokuでOpenCVを利用する [Python3] - Qiita
②opencv-python-headlessを利用する
opencv-python-headless==4.2.0.32
以下のサイトに同じ悩みを抱えている方を発見し解決できました。
https://stackoverflow.com/questions/49469764/how-to-use-opencv-with-heroku/51004957
まとめ
HerokuとOpenCVの相性の悪さに最も悩まされましたが、なんとか作り上げることができました。
あったら便利だなと思うLINE Botを皆さんも自由な発想で作成してみてはいかがでしょうか。
それでは、楽しいプログラミング人生をお過ごしください。
参考文献
☟Pythonを使ったLINE Botの作り方が本当に分かりやすいです。
Python + HerokuでLINE BOTを作ってみた - Qiita
☟画像の保存・送信方法が非常に参考になりました。
【Python】写真に日付を付けてくれるLINE Bot作った - Qiita