イメージマップメッセージを使って終電に乗り遅れないボットを作りました

こんにちは。LINEでLINE Messaging APIやLINEログイン等のプラットフォームの啓蒙活動を担当している立花です。この記事はLINE Advent Calendar 2017の12日目の記事です。

本記事では、LINE Messaging APIに実装されているイメージマップメッセージ(Imagemap message)の概要と、日頃ハッカソンで最も多くご要望をいただく、タップして詳細を表示できる、ピンがたくさん刺さったマップの表示方法について解説します。

LINE Messaging APIとは

Messaging APIは、ボットを作るためのAPIです。ボットというと、テキストでしかやり取りしかできず不便という印象をお持ちの方もいらっしゃるかもしれません。しかし、Messaging APIを使うと、画像やファイル、さらにはHTMLアプリやネイティブアプリでいうところのAlertViewのようなものも手軽に実装でき、ユーザーにストレスなく使ってもらえるボットを簡単に作ることができます。

画像、動画、ファイル

位置情報

テンプレート

イメージマップメッセージとは

今回紹介するイメージマップメッセージもMessaging APIの一部で、画像を背景として、その上にタップ可能なエリアとタップ時に実行されるアクションを定義したメッセージです。エリアは目に見えませんが、ユーザーがタップするとその座標からエリアが判断され、定義されたアクションが実行されます。詳しくは、LINE Developersサイトのドキュメント「イメージマップメッセージ」を参照してください。

イメージマップメッセージを使うと、スペースが狭くてもタップで操作しやすいUIや、簡単なゲームで遊べるようなボットを作ることができます。

イメージマップメッセージ

ボットの開発

では実際に実装しながら使い方を解説します。実装にはプログラミング言語としてPython 3.6、フレームワークとしてFlaskを利用しています。最新のPython版公式LINE Bot SDKはこちらからダウンロードできます。

今回作るボットについて

今回はイメージマップメッセージを便利に使う一例として、「終電に乗り遅れる」というよくある問題の解決に役立つボットを作成します。

このボットは以下の機能を持ちます。

  • ボットを立ち上げると、位置情報を送信するためのボタンが表示される。
  • 位置情報を送信すると、最寄り駅の出口のうち、終電まで開いているものを検索できる。
  • 検索結果は地図にピンが刺さった状態で表示される。
  • ピンをタップすると駅の詳しい情報が表示され、経路を調べたり、友だちに送信したりできる。

このボットには以下の特長があります。

  • スマホでアプリを検索する手間なくアクセスできる(皆さんのスマホ、アプリで溢れていませんか?)。
  • LINEのシンプルで使い慣れたUIから利用するので、迷わず使えて誤操作がない(やめよう歩きスマホ)。
  • 最寄り駅情報だけではなく、終電まで開いている最寄りの出口のみが表示される。
  • 検索結果は静的画像なので、一瞬で表示される。
  • 検索結果はユーザーに表示されるだけではなく、簡単にLINEのグループ等にシェアできる。

なお、今回は筆者が通勤に利用している新宿三丁目駅のデータのみが収録されています。ご了承ください。

ボットの開設

まず、LINE DevelopersサイトでLINE Botのアカウントを開設します。

詳しい手順については、LINE Developersサイトのドキュメントを参照してください。「Messaging APIを利用するには」および「ボットを作成する」(「ボットを友だち追加する」まで)に記載されています。

開通確認

まず、オウム返しをしてくれるボットを作成しましょう。app.pyを以下のように編集します。なお、スペースの都合上、要点のみを解説し、最後に完成したコードを掲載します。

・
・
@app.route("/", methods=['POST'])
def callback():
    ・
    ・
    return 'OK'

@handler.add(MessageEvent, message=TextMessage)
def handle_message(event):
    if event.type == "message":
        line_bot_api.reply_message(
            event.reply_token,
            [
                TextSendMessage(text=event.message.text),
            ]
        )

if __name__ == "__main__":
    app.run()

終わったらボットに呼びかけ、自分が呼びかけたテキストがそのままオウム返しされることを確認します。

オウム返し

位置情報の受信

次に、ユーザーがボットに送信する位置情報を受け取る処理を追記します。ユーザーが位置情報を送りやすいように、位置情報以外のメッセージを送信してきた場合はURLスキームを返すように変更しておきましょう。

          ・
          ・
          TextSendMessage(text='位置情報を送ると近くで終電まで空いている駅一覧を教えるよ(※絵文字1) '),
          TextSendMessage(text='line://nv/location'),
・
・
@handler.add(MessageEvent, message=LocationMessage)
def handle_location(event):
    line_bot_api.reply_message(
        event.reply_token,
        [
            TextSendMessage(text="{}\n{}\n{}".format(event.message.address, event.message.latitude, event.message.longitude)),
        ]
    )

※ 絵文字(1)は0x10002Aに置き換えてください。送信可能な絵文字についてはこちらを参照してください。

line://で始まるURLにはLINEの各種機能が紐付いており、この例の場合は位置情報の選択画面が1タップで開くようになっています。他にも便利なURLスキームがありますので、ぜひ使ってみてください。詳しくは、LINE Developersサイトのドキュメント「LINE URLスキームを使う」を参照してください。

LocationMessageには緯度経度の他に住所も格納されていますので、本番ではそれを利用してある程度検索範囲を絞る処理が可能です。

位置情報の受信

イメージマップメッセージを送信してみる

では次にイメージマップメッセージを送信してみましょう。背景画像は受け取った位置を中心とする地図となります。まずはダミーのエリアを1つだけ置いた状態で送信してみます。

・
・
# (1)
@app.route("/imagemap/<path:url>/<size>")
def imagemap(url, size):
    map_image_url = urllib.parse.unquote(url)
    response = requests.get(map_image_url)
    img = Image.open(BytesIO(response.content))
    img_resize = img.resize((int(size), int(size)))
    byte_io = BytesIO()
    img_resize.save(byte_io, 'PNG')
    byte_io.seek(0)
    return send_file(byte_io, mimetype='image/png')

@handler.add(MessageEvent, message=LocationMessage)
def handle_location(event):
    lat = event.message.latitude
    lon = event.message.longitude

    zoomlevel = 18
    imagesize = 1040

    # (2)
    map_image_url = 'https://maps.googleapis.com/maps/api/staticmap?center={},{}&zoom={}&size=520x520&scale=2&maptype=roadmap&key={}'.format(lat, lon, zoomlevel, 'YOUR_GOOGLE_API_KEY');
    map_image_url += '&markers=color:{}|label:{}|{},{}'.format('blue', '', lat, lon)

    # (3)
    actions = [
        MessageImagemapAction(
            text = 'テスト',
            area = ImagemapArea(
                x = 0,
                y = 0,
                width = 1040,
                height = 1040
        )
    )]
    line_bot_api.reply_message(
        event.reply_token,
        [
            ImagemapSendMessage(
                base_url = 'https://{}/imagemap/{}'.format(request.host, urllib.parse.quote_plus(map_image_url)),
                alt_text = '地図',
                # (4)
                base_size = BaseSize(height=imagesize, width=imagesize),
                actions = actions
            )
        ]
    )
  1. イメージマップメッセージを送信する際には画像のURLを指定します。このURLは、ユーザーのアプリに表示されるタイミングで読み込まれます。その際、端末の画面解像度に応じて要求されるサイズが変わるので、パラメーターから要求されたサイズを取得し、リサイズして返すよう実装する必要があります。
  2. 地図の画像はGoogle Static Maps APIを利用して取得します。keyをご自分のものに置き換えてください。イメージマップメッセージの背景として、要求される最大サイズである1040ピクセルで取得するため、sizeを520、scaleを2としています。
  3. タップ可能なエリアを定義しています。左上が(0,0)となります。
  4. base_sizeには、幅を1040とした場合の縦のサイズを記入します。

イメージマップメッセージ

地図にピンを刺す

では地図に複数のピンを刺してみましょう。

# (1)
pins = [
        [35.690810, 139.704500, 'A1'],
        .
        .
        [35.689421, 139.701877, 'E10'],
        ];
・
・
  if event.type == "message":
      if event.message.text.isdigit():
          line_bot_api.reply_message(
              event.reply_token,
              [
                  # (2)
                  LocationSendMessage(
                      title = pins[int(event.message.text)][2],
                      address = '東京都新宿区',
                      latitude = pins[int(event.message.text)][0],
                      longitude = pins[int(event.message.text)][1]
                  )
              ]
          )
      else:
          ・
          ・
    map_image_url += '&markers=color:{}|label:{}|{},{}'.format('blue', '', lat, lon)

    # (3)
    center_lat_pixel, center_lon_pixel = latlon_to_pixel(lat, lon)

    marker_color = 'red';
    label = 'E';
    pin_width = 60 * 1.5;
    pin_height = 84 * 1.5;

    actions = []
    for i, pin in enumerate(pins):

        target_lat_pixel, target_lon_pixel = latlon_to_pixel(pin[0], pin[1])

        # (4)
        delta_lat_pixel  = (target_lat_pixel - center_lat_pixel) >> (21 - zoomlevel - 1);
        delta_lon_pixel  = (target_lon_pixel - center_lon_pixel) >> (21 - zoomlevel - 1);

        marker_lat_pixel = imagesize / 2 + delta_lat_pixel;
        marker_lon_pixel = imagesize / 2 + delta_lon_pixel;

        x = marker_lat_pixel
        y = marker_lon_pixel

        # (5)
        if(pin_width / 2 < x < imagesize - pin_width / 2 and pin_height < y < imagesize - pin_width):

            map_image_url += '&markers=color:{}|label:{}|{},{}'.format(marker_color, label, pin[0], pin[1])

            actions.append(MessageImagemapAction(
                text = str(i),
                area = ImagemapArea(
                    x = x - pin_width / 2,
                    y = y - pin_height / 2,
                    width = pin_width,
                    height = pin_height
                )
            ))
            if len(actions) > 10:
                break
# (6)
offset = 268435456;
radius = offset / numpy.pi;

def latlon_to_pixel(lat, lon):
    lat_pixel = round(offset + radius * lon * numpy.pi / 180);
    lon_pixel = round(offset - radius * math.log((1 + math.sin(lat * numpy.pi / 180)) / (1 - math.sin(lat * numpy.pi / 180))) / 2);
    return lat_pixel, lon_pixel
  1. 新宿三丁目駅の出口の中から、終電まで空いている出口のみを格納したリストです。
  2. ピンがタップされたら、その出口の位置情報をLocationMessageとして返信します。
  3. ピンが画像上のどの座標に刺さっているかを取得するため、緯度経度の差をピクセルに変換する必要があります。
  4. 取得したピクセルの差分はzoomが21の時のものなので、ズーム数の差の分* 0.5する必要があります。今回はscaleに2を指定して画像サイズが2倍のものを取得しているため、実際のズーム数から1を減算しています。
  5. 取得した座標を元に、画像に配置できる出口のみを追加します。
  6. Google Mapはメルカトル図法による投影のため、逆グーデルマン関数を利用し緯度経度をピクセルに変換できます。offsetはGoogle Static Maps APIにおける、ズームレベル21の時の赤道半周の値のピクセル値です。

ピン

送られた位置情報をタップすると全画面で地図が開き、メニューボタンからGoogle Mapで開いたり、経路検索したり、LINEのトークやグループに送信したりできます。

位置情報のオプション

完成コード

これで、最低限の機能を持つボットが完成しました。ここまでのコードを掲載します。

# -*- coding: utf-8 -*-
import sys
import os
from flask import Flask, request, abort, send_file
from linebot import LineBotApi, WebhookHandler
from linebot.exceptions import InvalidSignatureError
from linebot.models import (
    MessageEvent, TextMessage, LocationMessage, LocationSendMessage,TextSendMessage, StickerSendMessage, MessageImagemapAction, ImagemapArea, ImagemapSendMessage, BaseSize
)
from io import BytesIO, StringIO
from PIL import Image
import requests
import urllib.parse
import numpy
import math

app = Flask(__name__)

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

@app.route("/", 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'

pins = [
        [35.690810, 139.704500, 'A1'],
        [35.691321, 139.703438, 'A5'],
        [35.691074, 139.705056, 'B2'],
        [35.691172, 139.704962, 'B3'],
        [35.691209, 139.704300, 'B4'],
        [35.692279, 139.702208, 'B10'],
        [35.690521, 139.705810, 'C4'],
        [35.690621, 139.706777, 'C5'],
        [35.691267, 139.706879, 'C6'],
        [35.691502, 139.707242, 'C7'],
        [35.693777, 139.706166, 'E1'],
        [35.693143, 139.706104, 'E2'],
        [35.689273, 139.703907, 'E5'],
        [35.688629, 139.703212, 'E6'],
        [35.688497, 139.703397, 'E7'],
        [35.689831, 139.703384, 'E9'],
        [35.689421, 139.701877, 'E10'],
        ];

@handler.add(MessageEvent, message=TextMessage)
def handle_message(event):
    if event.message.text.isdigit():
        line_bot_api.reply_message(
            event.reply_token,
            [
                LocationSendMessage(
                    title = pins[int(event.message.text)][2],
                    address = '東京都新宿区',
                    latitude = pins[int(event.message.text)][0],
                    longitude = pins[int(event.message.text)][1]
                )
            ]
        )
    else:
        line_bot_api.reply_message(
            event.reply_token,
            [
                TextSendMessage(text='位置情報を送ると近くで終電まで空いている駅一覧を教えるよ(※絵文字1) '),
                TextSendMessage(text='line://nv/location'),
            ]
        )

@app.route("/imagemap/<path:url>/<size>")
def imagemap(url, size):
    map_image_url = urllib.parse.unquote(url)
    response = requests.get(map_image_url)
    img = Image.open(BytesIO(response.content))
    img_resize = img.resize((int(size), int(size)))
    byte_io = BytesIO()
    img_resize.save(byte_io, 'PNG')
    byte_io.seek(0)
    return send_file(byte_io, mimetype='image/png')

@handler.add(MessageEvent, message=LocationMessage)
def handle_location(event):
    lat = event.message.latitude
    lon = event.message.longitude

    zoomlevel = 18
    imagesize = 1040

    map_image_url = 'https://maps.googleapis.com/maps/api/staticmap?center={},{}&zoom={}&size=520x520&scale=2&maptype=roadmap&key={}'.format(lat, lon, zoomlevel, 'YOUR_GOOGLE_API_KEY');
    map_image_url += '&markers=color:{}|label:{}|{},{}'.format('blue', '', lat, lon)

    center_lat_pixel, center_lon_pixel = latlon_to_pixel(lat, lon)

    marker_color = 'red';
    label = 'E';
    pin_width = 60 * 1.5;
    pin_height = 84 * 1.5;

    actions = []
    for i, pin in enumerate(pins):

        target_lat_pixel, target_lon_pixel = latlon_to_pixel(pin[0], pin[1])

        delta_lat_pixel  = (target_lat_pixel - center_lat_pixel) >> (21 - zoomlevel - 1);
        delta_lon_pixel  = (target_lon_pixel - center_lon_pixel) >> (21 - zoomlevel - 1);

        marker_lat_pixel = imagesize / 2 + delta_lat_pixel;
        marker_lon_pixel = imagesize / 2 + delta_lon_pixel;

        x = marker_lat_pixel
        y = marker_lon_pixel

        if(pin_width / 2 < x < imagesize - pin_width / 2 and pin_height < y < imagesize - pin_width):

            map_image_url += '&markers=color:{}|label:{}|{},{}'.format(marker_color, label, pin[0], pin[1])

            actions.append(MessageImagemapAction(
                text = str(i),
                area = ImagemapArea(
                    x = x - pin_width / 2,
                    y = y - pin_height / 2,
                    width = pin_width,
                    height = pin_height
                )
            ))
            if len(actions) > 10:
                break

    message = ImagemapSendMessage(
        base_url = 'https://{}/imagemap/{}'.format(request.host, urllib.parse.quote_plus(map_image_url)),
        alt_text = '地図',
        base_size = BaseSize(height=imagesize, width=imagesize),
        actions = actions
    )
    line_bot_api.reply_message(
        event.reply_token,
        [
            TextSendMessage(text='終電まで空いている出口一覧です(※絵文字2)'),
            message
        ]
    )

offset = 268435456;
radius = offset / numpy.pi;

def latlon_to_pixel(lat, lon):
    lat_pixel = round(offset + radius * lon * numpy.pi / 180);
    lon_pixel = round(offset - radius * math.log((1 + math.sin(lat * numpy.pi / 180)) / (1 - math.sin(lat * numpy.pi / 180))) / 2);
    return lat_pixel, lon_pixel

if __name__ == "__main__":
    app.run()

※ 絵文字(2)は0x100047に置き換えてください。送信可能な絵文字についてはこちらを参照してください。

リッチメニュー

機能自体は実装できました。最後にリッチメニューを作成し、さらに使いやすくしましょう。

LINE@マネージャーにアクセスし、作成したボットアカウントの左サイドバーにある「リッチコンテンツ作成」をクリックします。右上の「新規作成」から新しいリッチメニューを作成できます。

今回はシンプルに素早く操作できることが肝心なので、ボタンは1個だけにし、初期表示もオン、リンクには「line://nv/location」を指定します。ついでに「アカウント設定」から「アカウントページメニュー」を非表示にしておきます。

リッチメニューの設定が終わったらトーク画面を開き直します。画面下部にメニューが表示され、タップすると位置情報の選択画面が開きます。

リッチメニュー

なお、リッチメニューはLINE@マネージャーで設定するだけでなく、メニューを動的に作成したり、切り替えたりできるAPIも用意されています。こちらも用途に合わせてお使いください。

まとめ

いかがでしたか?とても簡単に便利なUIが実現できたと思います。今回は一例としてイメージマップメッセージをご紹介しましたが、LINE Messaging APIには、ユーザーがストレスなくボットとコミュニケートできる機能が数多く備わっています。

今年は年末年始のお休みが長めの方も多いと思います。LINE Botは開発だけでなく、公開するのも、友だちにおすすめして使ってもらうのも簡単です。ネイティブアプリを作るまでもない身近な課題をお持ちですよね?ぜひ、LINE Botで解決してみてください。

なお、LINEプラットフォーム関連のTwitterハッシュタグは「#LINE_API」ですので、ボットを作ったらぜひこのタグをつけてツイートしてください。皆様のボットにお目にかかれることを楽しみにしております!

参考資料

LINE Developers公式ドキュメント

Messaging APIリファレンス

明日は上村さんによる「類似文字列検索ライブラリResemblaを公開しました」です。お楽しみに!

Related Post