[Python]写真に任意の日付を描画するラインボットの作成

# 作ったもの
画像を送ると日付選択アクションを返し、選んだ日付を画像に描画してくれるラインボットを作成しました。
基本的にはこちらの記事の日付部分を自分で選べるようにしただけですが、色々と躓いた所があったので今回まとめていこうと思います。
参考：[【Python】写真に日付を付けてくれるLINE Bot作った](https://qiita.com/tamago324/items/4df361fd6ac5b51a8a07)

※現在は追加機能を作成中なので公開はしていません。

# 環境

- macOS
- Python3.7.1
- flask1.1.1
- line-bot-sdk1.14.0

# この記事で書かないこと

- LineBotのチャンネル作成
- herokuへのデプロイ

# 全文
<details><summary>main.py</summary><div>

```python:main.py
from flask import Flask, request, abort
from linebot import LineBotApi, WebhookHandler
from linebot.exceptions import InvalidSignatureError
from linebot.models import (PostbackEvent, TemplateSendMessage, ButtonsTemplate, DatetimePickerTemplateAction,
                            ImageMessage, ImageSendMessage, MessageEvent, TextMessage, TextSendMessage)

from pathlib import Path
from PIL import Image, ImageDraw, ImageFont, ImageFilter
import datetime
import os
import re

app = Flask(__name__)
app.debug = False

#環境変数取得
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"

@app.route("/callback", methods=['POST'])
def callback():
    signature = request.headers['X-Line-Signature']

    body = request.get_data(as_text=True)
    app.logger.info("Request body: " + body)

    try:
        handler.handle(body, signature)
    except InvalidSignatureError:
        abort(400)

    return 'OK'

#フォローイベント
@handler.add(FollowEvent)
def handle_follow(event):
    line_bot_api.reply_message(
        event.reply_token,
        TextSendMessage(text=
        "友達登録ありがとう。画像を送信して撮影日を教えてくれたら、その日付を画像に書き込むよ"))

#テキストのオウム返し
@handler.add(MessageEvent, message=TextMessage)
def handle_message(event):
    line_bot_api.reply_message(
        event.reply_token,
        TextSendMessage(text=event.message.text))

#画像の受け取り
@handler.add(MessageEvent, message=ImageMessage)
def get_image(event):
    global message_id

    #message_idを取得
    message_id = event.message.id
    
    #ファイル名をmessage_idに変換したパス
    src_image_path = Path(SRC_IMAGE_PATH.format(message_id)).absolute()

    # 画像をherokuへ一時保存
    save_image(message_id, src_image_path)
    
    #日時選択時に表示する為に画像として保存
    im = Image.open(src_image_path)
    im.save(src_image_path)
    
    #撮影日の選択
    date_picker = TemplateSendMessage(
        alt_text='撮影日を選択してね',
        template=ButtonsTemplate(
            text='撮影日を選択してね',
            thumbnail_image_url=f"https://<herokuのアプリ名>.herokuapp.com/{src_image_path}",
            actions=[
                DatetimePickerTemplateAction(
                    label='選択',
                    data='action=buy&itemid=1',
                    mode='date',
                    initial=str(datetime.date.today()),
                    max=str(datetime.date.today())
                )
            ]
        )
    )
    
    line_bot_api.reply_message(
        event.reply_token,
        date_picker
    )

#画像を処理して送信
@handler.add(PostbackEvent)
def handle_postback(event):
    #ファイル名をmessage_idに変換したパス
    src_image_path = Path(SRC_IMAGE_PATH.format(message_id)).absolute()
    main_image_path = MAIN_IMAGE_PATH.format(message_id)
    preview_image_path = PREVIEW_IMAGE_PATH.format(message_id)
    
    #画像処理
    date_the_image(src_image_path, Path(main_image_path).absolute(), event)
    date_the_image(src_image_path, Path(preview_image_path).absolute(), event)

    # 画像の送信
    image_message = ImageSendMessage(
            original_content_url=f"https://<herokuのアプリ名>.herokuapp.com/{main_image_path}",
            preview_image_url=f"https://<herokuのアプリ名>.herokuapp.com/{preview_image_path}"
    )
    
    #ログの取得
    app.logger.info(f"https://<herokuのアプリ名>.herokuapp.com/{main_image_path}")
    
    line_bot_api.reply_message(event.reply_token, image_message)

#画像保存関数
def save_image(message_id: str, save_path: str) -> None:
    # message_idから画像のバイナリデータを取得
    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)

#画像処理関数
def date_the_image(src: str, desc: str, event) -> None:
    im = Image.open(src)
    draw = ImageDraw.Draw(im)
    font = ImageFont.truetype("./fonts/Helvetica.ttc", 50)
    #日時選択アクションの日付を取得
    text = event.postback.params['date']
    #正規表現での文字列置換
    text_mod = re.sub("-", "/", text)
    #テキストのサイズ
    text_width = draw.textsize(text_mod, font=font)[0]
    text_height = draw.textsize(text_mod, font=font)[1]
    
    margin = 10
    x = im.width - text_width
    y = im.height - text_height
    #描画する矩形のサイズ
    rect_size = ((text_width + margin * 6), (text_height + margin * 2))
    #矩形の描画
    rect = Image.new("RGB", rect_size, (0, 0, 0))
    #矩形を透明にする為のマスク
    mask = Image.new("L", rect_size, 128)
    
    #画像に矩形とマスクを貼り付け
    im.paste(rect, (x - margin * 6, y - margin * 3), mask)
    #テキストの書き込み
    draw.text((x - margin * 3, y - margin * 2), text_mod, fill=(255, 255, 255), font=font)
    im.save(desc)

if __name__ == "__main__":
    #app.run()
    port = int(os.getenv("PORT", 5000))
    app.run(host="0.0.0.0", port=port)
```
</div></details>

# 解説
## 準備

```python
from flask import Flask, request, abort
from linebot import LineBotApi, WebhookHandler
from linebot.exceptions import InvalidSignatureError
from linebot.models import (PostbackEvent, TemplateSendMessage, ButtonsTemplate, DatetimePickerTemplateAction,
                            ImageMessage, ImageSendMessage, MessageEvent, TextMessage, TextSendMessage)

from pathlib import Path
from PIL import Image, ImageDraw, ImageFont, ImageFilter
import datetime
import os
import re

app = Flask(__name__)
app.debug = False

#環境変数取得
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"

@app.route("/callback", methods=['POST'])
def callback():
    signature = request.headers['X-Line-Signature']

    body = request.get_data(as_text=True)
    app.logger.info("Request body: " + body)

    try:
        handler.handle(body, signature)
    except InvalidSignatureError:
        abort(400)

    return 'OK'
```
モジュールのインポートと事前に設定しておいた環境変数を取得しますが、詳しい役割は適宜調べて頂ければと思います。
後述する画像参照元のパスを設定し、~~`message_id`を格納する空のリストを作っておきます。~~
`{}`の部分を画像を受け取った時の`message_id`に置き変えます。

## フォロー時にテキストを送信

```python
#フォローイベント
@handler.add(FollowEvent)
def handle_follow(event):
    line_bot_api.reply_message(
        event.reply_token,
        TextSendMessage(text=
        "友達登録ありがとう。画像を送信して撮影日を教えてくれたら、その日付を画像に書き込むよ"))
```
ユーザーが友達追加時にメッセージを送ります。

## テキストのオウム返し

```python
#テキストのオウム返し
@handler.add(MessageEvent, message=TextMessage)
def handle_message(event):
    line_bot_api.reply_message(
        event.reply_token,
        TextSendMessage(text=event.message.text))
```
テキストのオウム返しをしますが、特に必要はないです。

## 画像の受け取り

```python
#画像の受け取り
@handler.add(MessageEvent, message=ImageMessage)
def get_image(event):
    global message_id

    #message_idを取得
    message_id = event.message.id
    
    #ファイル名をmessage_idに変換したパス
    src_image_path = Path(SRC_IMAGE_PATH.format(message_id)).absolute()

    # 画像をherokuへ一時保存
    save_image(message_id, src_image_path)
    
    #日時選択時に表示する為に画像として保存
    im = Image.open(src_image_path)
    im.save(src_image_path)
    
    #撮影日の選択
    date_picker = TemplateSendMessage(
        alt_text='撮影日を選択してね',
        template=ButtonsTemplate(
            text='撮影日を選択してね',
            thumbnail_image_url=f"https://<herokuのアプリ名>.herokuapp.com/{src_image_path}",
            actions=[
                DatetimePickerTemplateAction(
                    label='選択',
                    data='action=buy&itemid=1',
                    mode='date',
                    initial=str(datetime.date.today()),
                    max=str(datetime.date.today())
                )
            ]
        )
    )
    
    line_bot_api.reply_message(
        event.reply_token,
        date_picker
    )
```
`event.message_id`でメッセージごとに割り振られるidを取得し、他のイベントでも使うためグローバルに設定します。。
~~このidは他のイベントでは取得することができないので、事前に作っておいた空の`message_list`に格納します。~~
herokuにデータを一時保存した後、日付選択アクション時に表示するために保存したデータを一旦開き、画像として再度保存します。
`TemplateSendMessage`で日付選択アクションを返しユーザーに撮影日を選択してもらう時に、保存先のURLを`thumbnail_image_url`で指定すれば表示されます。

## 画像送信

```python
#画像を処理して送信
@handler.add(PostbackEvent)
def handle_postback(event):
    #ファイル名をmessage_idに変換したパス
    src_image_path = Path(SRC_IMAGE_PATH.format(message_id)).absolute()
    main_image_path = MAIN_IMAGE_PATH.format(message_id)
    preview_image_path = PREVIEW_IMAGE_PATH.format(message_id)
    
    #画像処理
    date_the_image(src_image_path, Path(main_image_path).absolute(), event)
    date_the_image(src_image_path, Path(preview_image_path).absolute(), event)

    # 画像の送信
    image_message = ImageSendMessage(
            original_content_url=f"https://<herokuのアプリ名>.herokuapp.com/{main_image_path}",
            preview_image_url=f"https://<herokuのアプリ名>.herokuapp.com/{preview_image_path}"
    )
    
    #ログの取得
    app.logger.info(f"https://<herokuのアプリ名>.herokuapp.com/{main_image_path}")
    
    line_bot_api.reply_message(event.reply_token, image_message)
```
前述のMessageEvent(ImageMessage)で受け取った画像を取得します。~~判別するために、`message_list`から画像の名前となるidを取得します。
もし連続で画像が送られた際、データが初期化する前に新しいidが格納されていくので`message_list[-1]`で最後尾（最新）のものを指定します。
`handl_postback(event)`でユーザーが選択した日付を取得し、テキストなどを書き込む関数`data_the_image`の`text`に代入します。
処理が終わったら画像を保存してユーザーに送り返して終了です。

## 画像保存関数

```python
#画像保存関数
def save_image(message_id: str, save_path: str) -> None:
    # message_idから画像のバイナリデータを取得
    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)
```
本当は画像のExif情報を取得し自動で撮影日を入れたかったのですが、この方法だと取得できなかったので苦肉の策として上記の日付選択アクションという形をとりました。
もしやり方を知っている人がいたら教えてください。

## 画像処理関数

```python
#画像処理関数
def date_the_image(src: str, desc: str, event) -> None:
    im = Image.open(src)
    draw = ImageDraw.Draw(im)
    font = ImageFont.truetype("./fonts/Helvetica.ttc", 50)
    #日時選択アクションの日付を取得
    text = event.postback.params['date']
    #正規表現での文字列置換
    text_mod = re.sub("-", "/", text)
    #テキストのサイズ
    text_width = draw.textsize(text_mod, font=font)[0]
    text_height = draw.textsize(text_mod, font=font)[1]
    
    margin = 10
    x = im.width - text_width
    y = im.height - text_height
    #描画する矩形のサイズ
    rect_size = ((text_width + margin * 6), (text_height + margin * 2))
    #矩形の描画
    rect = Image.new("RGB", rect_size, (0, 0, 0))
    #矩形を透明にする為のマスク
    mask = Image.new("L", rect_size, 128)
    
    #画像に矩形とマスクを貼り付け
    im.paste(rect, (x - margin * 6, y - margin * 3), mask)
    #テキストの書き込み
    draw.text((x - margin * 3, y - margin * 2), text_mod, fill=(255, 255, 255), font=font)
    im.save(desc)
```
ある程度見栄えも良くしたかったので、自分なりに処理を加えています。
`event.postback.params['date']`でユーザーが選んだ日付を取得し、`re.sub("-", "/", text)`で"YYYY-MM-DD"を"YYYY/MM/DD"という書式に変換します。
`((text_width + margin * 6), (text_height + margin * 2))`で矩形とマスクのサイズを、テキストの左右30px上下10pxずつ余白をとります。
最後に、`im.paste(rect, (x - margin * 6, y - margin * 3), mask)`で矩形とマスクの位置を指定し貼り付け、その中心になるように`draw.text((x - margin * 3, y - margin * 2), text_mod, fill=(255, 255, 255), font=font)`でテキストを書き込み完成です。

## 実行

```python
if __name__ == "__main__":
    #app.run()
    port = int(os.getenv("PORT", 5000))
    app.run(host="0.0.0.0", port=port)
```

# ファイル構成

- app
    - main.py
    - Procfile
    - requirements
    - runtime
    - static
        - images
            - hoge
    - font
        - Helvetica.ttf

# まとめ
今回のラインボットは独学後初めて作ったアプリでしたが、ここまで作るのに約半年（1日平均1～2時間程度）もかかってしまいました。
製作時間の内、約8割が分からないところを調べることに費やし、かなり苦しい時期もありました。
実際、1週間くらいパソコンを開かない時もありましたが、プログラミング自体を諦めようと思ったことは一度も無かったので、なんだかんだ楽しんでやれてたのではないかなと思います。

実は最初に作ろうと思ったものから紆余曲折ありこの形に落ち着いたのですが、その中である程度事前に全体の流れを作ることがとても重要だということを学びました。
ただ、経験の少ない内はどのようなことがどれくらいの労力で出来るということが掴みにくいので、結局はトライ＆エラーで地道に進めていくしかないのかと思います。

現在はデータベースの勉強をしており、写真に写っている人の名前と生年月日を保存し、撮影日から逆算して何歳ごろの写真かを表示できるようにしているところなので、完成次第公開しようと思います。

# 参考
- [【Python】写真に日付を付けてくれるLINE Bot作った](https://qiita.com/tamago324/items/4df361fd6ac5b51a8a07)
- [line-bot-sdk-pythonを使ったDatetime picker actionの実装およびImage Carouselの実装サンプル](https://qiita.com/nnsnodnb/items/d07a768eeea7be6cec02)
- [Python, Pillowで透過png画像を作成するputalpha](https://note.nkmk.me/python-pillow-putalpha/)