私は、友達の紹介でさどんでこプロジェクトのIT担当として様々システム構築などを行っています。今回、スタンプラリー作成でかなり大変だったということから、開発秘話をブログにまとめてみました。興味ある方ぜひ御覧ください。

第1話は以下です、合わせてお読みください。

今回のテーマ
- LINEの**イメージマップメッセージ(Imagemap Message)**とは?
- スタンプカード上で「スタンプを押せるようにする」までの工夫
- スタンプごとの領域を計算するロジック
- Numpyを使って、未獲得スタンプをグレー化
- 解像度調整:Imagemap用画像の即時生成
- LINEイメージマップのキャッシュ対策:リプライトークンでURLを変化させる
LINEのImagemap Messageを使ってみた
LINEには「Imagemap Message」という、画像の一部をタップ可能にしてアクションを起こせる機能があります。
{
"type": "imagemap",
"baseUrl": "https://example.com/card",
"altText": "スタンプカード",
"baseSize": { "height": 1040, "width": 1040 },
"actions": [
{
"type": "message",
"text": "stamp_1",
"area": { "x": 100, "y": 200, "width": 200, "height": 200 }
}
]
}
画像上の任意の領域をアクション付きで送信できるのは素晴らしいのですが、ここに大きな落とし穴がありました。
正方形しかクリック領域にできない
Imagemapのarea指定は、長方形または正方形の領域しか指定できません。
鬼太鼓スタンプラリーでは、スタンプは円形であるため、画像サイズでAction領域を設定してしまうと、Action領域が被ってしまい、エラーが起こります。

画像サイズでAction領域を設定するとスタンプ同士でAction領域が被ってしまう

スタンプに収まる最大の正方形でAction領域を設定するとスタンプ同士で被らない
「円に内接する最大の正方形」を算出
ユーザーがスタンプをタップできるようにするため、各スタンプの中央座標と半径から、「タップ領域(正方形)」をPython側で算出しました。

これをもとに以下の関数を作成し、スタンプ画像の中心にぴったり重なるような「触れる」四角領域を作成できます。
def generate_imagemap_actions(request, course_id):
scale = course.stamp_scale or 1.0
positions = CourseStampPosition.objects.filter(course=course).select_related("spot")
actions = []
for pos in positions:
spot = pos.spot
actions.append({
"type": "message",
"text": spot.keyword,
"area": {
"x": int(pos.x + 6 * scale),
"y": int(pos.y + 6 * scale),
"width": int(28 * scale),
"height": int(28 * scale),
}
})
return JsonResponse(actions, safe=False)
他の関数の影響で画像サイズが40px ✕ 倍率(scale)で設定されていたため、
Actionsは
“x” = pos.x + 6 * 倍率
“y” = pos.y + 6 * 倍率
“width” = 28 * 倍率
“height” = 28 * 倍率
となりました。計算途中でルートは1.41で近似し、整数部分だけにしました。
未獲得スタンプはNumpyでグレー化
「まだ獲得していないスタンプは、グレーにしたい」
もともと、1ピクセルずつ計算させる予定でしたが、サーバーのリソースを思ったより多く食うことがわかりました。
そのため、この要望を高速に処理するために、Numpyを用いて画像を一括グレースケール化しました。
# 指定されたスタンプIDと獲得状態でスタンプ画像を返す 未獲得の場合は灰色の画像を返す
def render_stamp_image(request, reply_token, stamp_id, acquired_id):
image_path = os.path.join(settings.MEDIA_ROOT, stamp.image.name)
image = Image.open(image_path).convert("RGBA")
if int(acquired_id) == 0:
# NumPyで処理
gray = image.convert("L")
gray_array = np.array(gray)
alpha_mask = gray_array < 50 # しきい値以下のみ対象
# 新しいRGBA画像(すべて透明)
result_array = np.zeros((image.height, image.width, 4), dtype=np.uint8)
result_array[alpha_mask] = [200, 200, 200, 255] # 灰色で表示
result_image = Image.fromarray(result_array, mode="RGBA")
else:
result_image = image
buffer = BytesIO()
result_image.save(buffer, format="PNG")
buffer.seek(0)
return FileResponse(buffer, content_type="image/png")

スタンプをグレー化
解像度調整:Imagemap用画像の即時生成
イメージマップメッセージで使用する画像は、以下の要件を満たす必要があります。
- 画像フォーマット: JPEGまたはPNG
- 画像の幅:240px、300px、460px、700px、および1040px
- 最大ファイルサイズ:10MB
要するにBaseurlがhttps://example.com/image/の場合
例:https://example.com/image/460
のように参照されるということです。
なので、URLの中にSizeを入れ、
path("course/<int:course_id>/<int:user_id>/<int:size>", views.render_user_stamp_card, name="render_user_stamp_card"),
スタンプカードにスタンプを合成+即時生成・リサイズするようにしました。
# 指定されたコースとユーザーのスタンプカードを生成
def render_user_stamp_card(request, course_id, user_id, size):
course = get_object_or_404(StampCourse, id=course_id)
user = get_object_or_404(User, id=user_id)
scale = course.stamp_scale or 1.0
# 獲得済みスタンプのID(ユーザーが獲得済み、かつこのコースに含まれる)
acquired_spots = UserStamp.objects.filter(user=user).values_list("stamp_id", flat=True)
valid_spot_ids = course.spots.filter(id__in=acquired_spots).values_list("id", flat=True)
# 元画像を読み込み
base_path = os.path.join(settings.MEDIA_ROOT, course.image.name)
base_image = Image.open(base_path).convert("RGBA")
orig_width, orig_height = base_image.size
canvas = base_image.copy()
# スタンプを合成
positions = CourseStampPosition.objects.filter(course=course, spot_id__in=valid_spot_ids).select_related("spot")
for pos in positions:
stamp_path = os.path.join(settings.MEDIA_ROOT, pos.spot.image.name)
stamp = Image.open(stamp_path).convert("RGBA")
size_px = int(40 * scale)
stamp = stamp.resize((size_px, size_px), Image.LANCZOS)
canvas.paste(stamp, (pos.x, pos.y), stamp)
# 指定サイズにリサイズ(横幅基準)
if size != orig_width:
ratio = size / orig_width
new_height = int(orig_height * ratio)
canvas = canvas.resize((size, new_height), Image.LANCZOS)
# 出力
buffer = BytesIO()
canvas.save(buffer, format="PNG")
buffer.seek(0)
return FileResponse(buffer, content_type="image/png")
LINEのキャッシュ対策:リプライトークンでURLを変化させる
LINEイメージマップは画像をキャッシュする仕様があるため、一度送信した画像が更新されても表示が変わらない問題が発生しました。
これを解決するため、毎回画像のURLにリプライトークン(replyToken)を挟むことで、LINE側から見たURLを都度変化させてキャッシュを回避しました。
path("course/<str:reply_token>/<int:course_id>/<int:user_id>/<int:size>", views.render_user_stamp_card, name="render_user_stamp_card"),
バックエンドでは、トークンを無視して同一画像を返す処理を用意。これにより、常に最新のスタンプカードが表示されるようになりました。
完成形:スマホでスタンプを「押す」体験
この仕組みを組み合わせたことで、以下の体験が可能に:
- ユーザーがLINEでスタンプカードを開く
- スタンプ部分をタップ
- LINE Botがタップ情報を受け取り、獲得処理を実行
- 最新のスタンプカード画像を生成 → 返信
画像合成 × 座標計算 × LINE API × キャッシュ対策が絶妙に連携した瞬間でした。
次回予告:スタンプの位置情報設定 Google Maps × Django管理画面の裏側
次回は、スタンプの位置情報を地図上で登録・編集できる管理画面の制作秘話をお届けします。
- Django + Google Mapsでスポットを登録する仕組み
- スポットの重複・近接の確認
- 管理者用UIのこだわり
どんな苦労があったのか…ぜひお楽しみに!


コメント
コメント一覧 (3件)
[…] 第2話:画像に仕込む魔法!LINEイメージマップとスタンプの格闘記 […]
[…] あわせて読みたい 第2話:画像に仕込む魔法!LINEイメージマップとスタンプの格闘記 […]
[…] あわせて読みたい 第2話:画像に仕込む魔法!LINEイメージマップとスタンプの格闘記 […]