【Python】写真に日付を付けてくれるLINE Bot作った

写真に日付を付けてくれるやつほしい！

ということで、学習も兼ねて作ってみた！

今回はFlaskとline-bot-sdkとherokuとPillowを使って実装した

なんとか動くところまでいったから、記事にしてみた

初めて使うものが多かったので、間違っていたり、こう書いたほうがいいよ！とかあったらコメントお願いします！

## 動作環境

```sh
$ pipenv graph 
(省略)
Flask==1.0.2
line-bot-sdk==1.8.0
Pillow==5.4.1

$ pipenv run python --version
Python 3.7.1
```

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

- LINEBotのチャンネル作成
- ngrokを使ったローカルでの確認
- herokuへのデプロイ

他の方がわかりやすく書いているので、そちらを参考にしてみてください

## 作ったもの

画像を送信すると日付を入れて返してくれる

![line-bot.jpg](https://qiita-image-store.s3.amazonaws.com/0/116551/39adf7b8-1f77-ef52-a78b-ff9a3ae307e0.jpeg)

ソースコードは https://github.com/tamago324/date_the_image においた

この画像、どこかで見たことあると思ったら、[Vim, Me and Community - Google スライド](https://docs.google.com/presentation/d/14pViuMI_X_PiNwQD8nuGRG72GUqSeKDqoJqjAZWS39U/edit#slide=id.g28fa7f227c_0_221)のやつだ！

## ソースコードの說明

### 画像の取得、保存

```python
@handler.add(MessageEvent, message=ImageMessage)
def handle_image(event):
    message_id = event.message.id

    # message_idから画像のバイナリデータを取得
    message_content = line_bot_api.get_message_content(message_id)

    with open(Path(f"static/images/{message_id}.jpg").absolute(), "wb") as f:
        # バイナリを1024バイトずつ書き込む
        for chunk in message_content.iter_content():
            f.write(chunk)
```

`handler.add(MessageEvent, message=ImageMessage)`のように`message`に`ImageMessage`を指定すると、画像が送信されたときの処理が記述できる

[line_bot_api.get_message_content(message_id)](https://github.com/line/line-bot-sdk-python#get_message_contentself-message_id-timeoutnone)で画像データの取得ができる。実際には`/v2/bot/message/{message_id}/content`を呼び出して、データを取得している

`message_content.iter_content()`でバイナリデータを1024バイトずつファイルに書き込んでいる

### 画像の処理

<details><summary>ソースコード</summary><div>

```python
from PIL import Image, ImageDraw, ImageFont

im = Image.open("apple.png")
draw = ImageDraw.Draw(im)

size = 800
if im.width > size:
    proportion = size / im.width
    im = im.resize((int(im.width * proportion), int(im.height * proportion)))

text = "apple!"

# フォントの読み込み
font = ImageFont.truetype("./fonts/Harlow Solid Regular.ttf", 60)

# テキストの周りの5pxずつに余白を作る
x = 10
y = 10
margin = 5
text_width = draw.textsize(text, font=font)[0] + margin
text_height = draw.textsize(text, font=font)[1] + margin
draw.rectangle(
    (x - margin, y - margin, x + text_width, y + text_height), fill=(255, 255, 255)
)
# テキスト描画
draw.text((x, y), text, fill=(0, 0, 0), font=font)

im.save("apple_apple.png")
```

</div></details>


#### 画像読み込み、オブジェクト生成

```python
im = Image.open("apple.jpg")
draw = ImageDraw.Draw(im)
```

`Image.open()`で処理対象の画像を読み込む。`ImageDraw.Draw(im)`で画像オブジェクトを編集するためのオブジェクトを生成する

#### サイズ変更

```python
size = 800

proportion = size / im.width
im = im.resize((int(im.width * proportion), int(im.height * proportion)))
```

LINE Botで送信できる画像の最大が 1024 x 1024 のため、少し小さめの 800 x ? にサイズ変更する

画像の幅は`im.width`、高さは`im.height`で取得。画像のサイズ変更は`im.resize(x, y)`でできる

#### テキストの後ろに四角形を描画

```python
text = "apple!"

# フォントの読み込み
font = ImageFont.truetype("./fonts/Harlow Solid Regular.ttf", 60)

# テキストの周りの5pxずつに余白を作る
x = 10
y = 10
margin = 5
text_width = draw.textsize(text, font=font)[0] + margin
text_height = draw.textsize(text, font=font)[1] + margin
draw.rectangle(
    (x - margin, y - margin, x + text_width, y + text_height), fill=(255, 255, 255)
)
```

テキストが黒、背景が白がシンプルで見やすいため、四角形を描画する

`ImageFont.truetype(フォントのパス, フォントサイズ)`で[Harlow Solid Regular](http://fontsgeek.com/fonts/Harlow-Solid-Regular)というちょっとおしゃれなフォントのオブジェクトを生成する

`draw.textsize(text, font)`を使い、`text`を`font`で描画したときのサイズ(`(x, y)`のタプル)を取得し、余白を計算する。`draw.rectangle(左上のx座標, 左上のy座標, 右下のx座標, 右下のy座標, fill=色)`で四角形を描画

`draw.textsize()`でテキストのサイズを取得できるの便利

#### テキストの描画

```python
text = "apple!"
x = 10
y = 10

draw.text((x, y), text, fill=(0, 0, 0), font=font)
```

そして、`draw.text()`でテキストを描画

![apple_apple.png](https://qiita-image-store.s3.amazonaws.com/0/116551/a396b06c-67c5-3f9c-5cbc-5d6f2b16a0ba.png)


#### 画像の保存

```python
im.save("保存先のパス")
```

### 画像の送信

```python
@handler.add(MessageEvent, message=ImageMessage)
def handle_image(event):
    ...
    main_image_path = f"static/images/{message_id}_main.jpg"
    preview_image_path = f"static/images/{message_id}_preview.jpg"

    # 画像の送信
    image_message = ImageSendMessage(
        original_content_url=f"https://date-the-image.herokuapp.com/{main_image_path}",
        preview_image_url=f"https://date-the-image.herokuapp.com/{preview_image_path}",
    )

    line_bot_api.reply_message(event.reply_token, image_message)
```

`ImageMessage`を生成し、`line_bot_api.reply_message()`で返すだけ

### 全体のソースコード

```
$ tree
.
├── Pipfile
├── Pipfile.lock
├── Procfile
├── README.md
├── app.py
├── date_the_image.py
├── fonts
│   └── Harlow\ Solid\ Regular.ttf
├── fruit_apple_date.png
└── static
    └── images
```

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

```python
# app.py

import os
from pathlib import Path

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 date_the_image import date_the_image

app = Flask(__name__)

line_bot_api = LineBotApi(os.environ["CHANNEL_ACCESS_TOKEN"])
handler = WebhookHandler(os.environ["CAHNNEL_SECRET"])

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


@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):
    line_bot_api.reply_message(
        event.reply_token, TextSendMessage(text=event.message.text)
    )


@handler.add(MessageEvent, message=ImageMessage)
def handle_image(event):
    message_id = 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)

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

    # 画像の加工、保存
    date_the_image(src=src_image_path, desc=Path(main_image_path).absolute())
    date_the_image(src=src_image_path, desc=Path(preview_image_path).absolute())

    # 画像の送信
    image_message = ImageSendMessage(
        original_content_url=f"https://date-the-image.herokuapp.com/{main_image_path}",
        preview_image_url=f"https://date-the-image.herokuapp.com/{preview_image_path}",
    )

    app.logger.info(f"https://date-the-image.herokuapp.com/{main_image_path}")
    line_bot_api.reply_message(event.reply_token, image_message)

    # 画像を削除する
    src_image_path.unlink()


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>date_the_image.py</summary><div>

```python
from datetime import datetime

from PIL import Image, ImageDraw, ImageFont


def date_the_image(src: str, desc: str, size=800) -> None:
    """日付を付けて、保存する

    :params src: 読み込む画像のパス
    :params desc:
        保存先のパス
    :params size:
        変換後の画像のサイズ
    """
    # 開く
    im = Image.open(src)

    # 800 x Height の比率にする
    if im.width > size:
        proportion = size / im.width
        im = im.resize((int(im.width * proportion), int(im.height * proportion)))

    draw = ImageDraw.Draw(im)

    font = ImageFont.truetype("./fonts/Harlow Solid Regular.ttf", 60)
    text = datetime.now().strftime("%Y/%m/%d")

    # 図形を描画
    x = 10
    y = 10
    margin = 5
    text_width = draw.textsize(text, font=font)[0] + margin
    text_height = draw.textsize(text, font=font)[1] + margin
    draw.rectangle(
        (x - margin, y - margin, x + text_width, y + text_height), fill=(255, 255, 255)
    )

    draw.text((x, y), text, fill=(0, 0, 0), font=font)

    # 保存
    im.save(desc)
```

</div></details>

<details><summary>.env</summary><div>

```ini
CAHNNEL_SECRET={Channel Secret}
CHANNEL_ACCESS_TOKEN={アクセストークン（ロングターム）}
FLASK_APP=app
FLASK_DEBUG=1
LANG=ja_JP.UTF-8
TZ=Asia/Tokyo
```

</div></details>

## 躓いたところ

### `.env`に記述した環境変数をherokuに適用する

Pipenvで環境変数を`.env`に記述していて、そのままHerokuの環境にも適用したかったので、調べてみた。

[heroku-config](https://github.com/xavdid/heroku-config)というherokuのpluginを使うと実現できる

インストール

```
$ heroku plugins:install heroku-config
```

使い方

- `heroku config:push`: `.env`の記述をherokuの環境変数に反映する
- `heroku config:pull`: herokuの設定を取得する(`.env`の記述を上書き)
    - デフォルトでは`"`で値が囲まれる(`KEY=`)

`.env`の記述をherokuの環境に反映させる

```
$ cat .env
KEY=hioea90r12jiofeaj

$ heroku config:push
Successfully wrote settings to Heroku!

$ heroku config
=== date-the-image Config Vars
KEY: hioea90r12jiofeaj
```

### LINE DeveloppersのWebhook URLの接続確認でエラーは無視する

Herokuにデプロイして、正常に動作しているのに接続確認でエラーになってしまう。

[GAEでLINE Message API(line-bot-sdk-python)を試してみた - Qiita](https://qiita.com/mochan_tk/items/db3fd4e4867dd3fb6540#20161120-sun-1400)にも、そんなことが書いてあるから、気にしなくてもいいのかも

### Webhook送信が有効にする

LINEから画像を送信しても、何も起きなかった。調べたところ、Webhook送信の設定が正しくなかった。

Webhook送信が「利用する」にすることで、Webhook送信が使えるようになった

![webhook.png](https://qiita-image-store.s3.amazonaws.com/0/116551/a809f8e3-a050-0971-fac0-3923a3d07ddf.png)![uploading-0]()


### 送信する画像の置き場所がわからなかった

Flaskは`static/`ディレクトリ以下に静的ファイルを置くことで、Flask側が処理してくれる。今回は、送信したい画像を`static/images/{image_name}`に保存するようにした

少し考えればわかることだった...

### 日本の日付になっていない

深夜1時頃に画像を送信してみても前日の日付になっていた。

Herokuの環境変数を変えたらうまくいった。

```
# .env
TZ=Asia/Tokyo
LANG=ja_JP.UTF-8
```

### `static/images/image_name.jpg`をが作成できない

送信された画像を保存するときに、`static/images/`に保存しようとするけど、Herokuの環境には`static/images/`が存在しなかったため、エラーになってしまった

対応としては、ローカルに`static/images/hoge`のようなファイルを作成し、commit push することでHeroku側にも作成された

## まとめ

line-bot-sdkを使うとすごい簡単にLINE APIが使える。line-bot-sdkに感謝しまくりです。

`original_content_url`と`preview_image_url`を固定にしてしまっているため、ngrokが使えない...環境変数から取得すればいいのか！！ってこの記事書いてから気づいた...(いつかやろう、いつか...)

懸念点としては、送信した画像がサーバー内に溜まってしまうこと。これはしょうがないのかな...解決方法を知ってる方いましたら、コメントやTwitterで教えていただけると嬉しいです！！！

## 参考文献

- [FlaskでLINE botを実装し，herokuにdeployするまで - Qiita](https://qiita.com/suigin/items/0deb9451f45e351acf92)
- [Messaging APIリファレンス](https://developers.line.biz/ja/reference/messaging-api/)
- [Pillow の ImageDraw#textsize() が間違った値を返している件 - Hack like a rolling stone](https://tk0miya.hatenablog.com/entry/2014/07/12/005327)
- [Python, Pillowで画像を一括リサイズ（拡大・縮小） | note.nkmk.me](https://note.nkmk.me/python-pillow-image-resize/)
- [Herokuで本番環境の環境変数(config vars)を.envファイルで設定する - dackdive's blog](https://dackdive.hateblo.jp/entry/2016/01/26/121900)
- [Heroku + Node.js アプリの環境変数の管理に heroku-config と dotenv を使う - Corredor](http://neos21.hatenablog.com/entry/2018/12/10/080000)
- [GAEでLINE Message API(line-bot-sdk-python)を試してみた - Qiita](https://qiita.com/mochan_tk/items/db3fd4e4867dd3fb6540#20161120-sun-1400)
- [ImageDraw Module — Pillow (PIL Fork) 5.1.0 documentation](https://pillow.readthedocs.io/en/5.1.x/reference/ImageDraw.html)