柿ピーの比率当てクイズゲームをLINEボットでつくった話

# はじめに
LINE DC Advent Calendar 2023 の7日目の記事です。

柿ピーの画像を見て「柿の種」と「ピーナッツ」の比率を当てるクイズゲームチャットボットを作ったので、その紹介をします。


:::note info
これは [#ヒーローズリーグ 2023 【決勝進出権有】 ハイブリット・ハッカソン](https://mashupawards.connpass.com/event/291510/) にて作ったものを改良したものです。
作品ページ : https://protopedia.net/prototype/4715 
:::

亀田製菓の柿の種の「柿の種:ピーナッツ」比率は、以前は6:4でしたが、現在は7:3だそうです　([参考](https://forbesjapan.com/articles/detail/48845?read_more=1))
数値を知ったら「そうなんだー」って感じですが、食べる時に何対何なのか考えたことはありますか？

これをゲームにしてみたら面白そうだと思い、この比率当てゲームを考えました。

# 作ったもの
こんな感じです。

![RPReplay_Final1701952797.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241369/a0d33976-0fbf-de32-7e04-628caa0d1f20.gif)


# ソースコード
https://github.com/mmclsntr/kakinotane-quiz-line-bot 

# 構成
バックエンドはAWS Lambda、DynamoDB、S3で作ってます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241369/ef34d95e-f92c-ec33-7f51-dfde9f3bf69a.png)

デプロイにはServerless Frameworkを使いました。
OpenCVを使っているので、コンテナイメージでLambda 関数をデプロイしてます。


# 解説
- 言語: Python
- フレームワーク: [Powertools for AWS Lambda](https://docs.powertools.aws.dev/lambda/python/latest/)

## 柿ピー画像生成部
このゲームの核となる、柿ピー画像生成部についてです。

まず、柿の種とピーナッツ画像を用意します。実際にそれぞれ写真を撮って切り抜きました。
![kakinotane_photo.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241369/caf1f6ef-f1ef-fabb-e634-f189906235c2.png) ![peanut_photo.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241369/8f6722c7-2783-a5c9-76d1-b23a88fec992.png)

これらの画像を使用し、OpenCVで任意の比率の柿ピー画像を作ります。

1000✕1000の白画像の上に、埋め尽くすように、かつ、場所・角度をランダムさせて配置します。

```python:kakinotane_image.py
import cv2
import numpy as np
import random


# 画像にシャドウを追加する関数
def add_shadow(image):
    alpha = image[:, :, 3]/255
    alpha = alpha[:, :, np.newaxis]  # alphaを3次元配列に変換
    shadow = cv2.GaussianBlur(image[:, :, :3], (15, 15), 0)
    image[:, :, :3] = alpha * image[:, :, :3] + (1 - alpha) * shadow
    return image

# 画像をランダムに回転させる関数
def rotate_image(image):
    angle = random.randint(0,360)
    angle_rad = angle/180.0*np.pi
    h, w = image.shape[:2]

    # 回転後の画像サイズを計算
    w_rot = int(np.round(h*np.absolute(np.sin(angle_rad))+w*np.absolute(np.cos(angle_rad))))
    h_rot = int(np.round(h*np.absolute(np.cos(angle_rad))+w*np.absolute(np.sin(angle_rad))))
    size_rot = (w_rot, h_rot)

    # 元画像の中心を軸に回転する
    center = (w/2, h/2)
    scale = 1.0
    rotation_matrix = cv2.getRotationMatrix2D(center, angle, scale)

    # 平行移動を加える (rotation + translation)
    affine_matrix = rotation_matrix.copy()
    affine_matrix[0][2] = affine_matrix[0][2] -w/2 + w_rot/2
    affine_matrix[1][2] = affine_matrix[1][2] -h/2 + h_rot/2

    rotated_image = cv2.warpAffine(image, affine_matrix, size_rot)
    return rotated_image

# 透過画像を背景に配置する関数
def place_image(background, img, x, y):
    #img = rotate_image(img)  # 画像回転
    h, w, _ = img.shape
    alpha = img[:, :, 3]/255
    for c in range(3):
        background[y:y+h, x:x+w, c] = alpha * img[:, :, c] + (1 - alpha) * background[y:y+h, x:x+w, c]
    return background

# 画像をランダムに配置する関数
def place_images_randomly(background, img, n):
    bh, bw, _ = background.shape
    for _ in range(n):
        img_rot = rotate_image(img)
        h_rot, w_rot, _ = img_rot.shape
        x = random.randint(0, bw - w_rot)
        y = random.randint(0, bh - h_rot)
        background = place_image(background, img_rot, x, y)
    return background


def create_kakinotane_image(ratio: float,
                            output_file_name: str = "output.jpg",
                            kakinotane_img_file_name: str = "assets/kakinotane_photo.png",
                            peanut_img_file_name: str = "assets/peanut_photo.png",
                            ):
    # 画像の読み込み
    img_a = cv2.imread(kakinotane_img_file_name, cv2.IMREAD_UNCHANGED)
    img_b = cv2.imread(peanut_img_file_name, cv2.IMREAD_UNCHANGED)

    # 画像のリサイズ
    img_a = cv2.resize(img_a, (img_a.shape[1] // 4, img_a.shape[0] // 4))
    img_b = cv2.resize(img_b, (img_b.shape[1] // 4, img_b.shape[0] // 4))

    # 白背景画像の作成
    background = np.ones((1000, 1000, 3), np.uint8) * 255

    # 配置する回数を計算
    n_total = (background.shape[0]//img_a.shape[0]) * (background.shape[1]//img_a.shape[1])

    # 画像aとbの配置比率を設定
    ratio_a = ratio
    ratio_b = 1 - ratio_a

    # 配置する回数を計算
    n_a = round(n_total * ratio_a)
    n_b = n_total - n_a

    # 画像をランダムに配置
    for i in range(3):
        # N回する。
        background = place_images_randomly(background, img_a, n_a)
        background = place_images_randomly(background, img_b, n_b)

    cv2.imwrite(output_file_name, background)
```

柿の種 : ピーナッツ = 7 : 3で生成したものがこちら。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241369/71977dce-ca72-8fab-3b43-6035010c0952.png)

生成した画像を、パブリックアクセスを許可しているS3バケットにアップロードし、そのURLをLINEに返します。

## 選択肢の作成
難易度ごとに、３択をランダムに作ります。

パラメータ

- `step` : 数値のスケール (10%ごと・5%ごとなど)
- `select_range` : 数値のレンジ (中央の値からどれだけ離れた値をあと2つ作るか)

```python
def create_selections(step: int, select_range: int = 20) -> tuple:
    percent_1 = random.randrange(MIN_PERCENT + step * 2, MAX_PERCENT - step * 2, step)

    if percent_1 + select_range >= MAX_PERCENT:
        percent_2 = percent_1 + random.randrange(0, MAX_PERCENT - percent_1, step) + step
    else:
        percent_2 = percent_1 + random.randrange(0, select_range, step) + step

    if percent_1 - select_range <= MIN_PERCENT:
        percent_3 = percent_1 - random.randrange(0, percent_1 - MIN_PERCENT, step) - step
    else:
        percent_3 = percent_1 - random.randrange(0, select_range, step) - step

    return percent_1, percent_2, percent_3
```

クイックリプライにて難易度 (初級・中級・上級) を選んで、その難易度に応じた選択肢を3つ作ります。

そのうち、正解とする選択肢をランダムで選んで、その比率に応じた柿ピー画像を生成します。

```python
# select level
if message_text == "初級":
    step = 10
    sct_range = 20
elif message_text == "中級":
    step = 5
    sct_range = 15
elif message_text == "上級":
    step = 1
    sct_range = 10
else:
    step = 5
    sct_range = 15

# create selections
percent_1, percent_2, percent_3 = create_selections(step, sct_range)

correct_percent = random.choice([percent_1, percent_2, percent_3])
```

正解の選択肢と画像はユーザーごとに持っており、正解はDynamoDBに格納して答え合わせします。

```python:dynamodb.py
def put_items(table_name: str, items: List[dict]):
    """
    Put items from DynamoDB table
    """
    table = boto3.resource("dynamodb").Table(table_name)

    with table.batch_writer() as writer:
        try:
            for item in items:
                _params = dict()
                _params["Item"] = json.loads(json.dumps(item), parse_float=Decimal)

                table.put_item(**_params)
        except botocore.exceptions.ClientError as e:
            if e.response['Error']['Code'] == 'ConditionalCheckFailedException':
                # 条件NG
                pass
            else:
                raise
```

```python
# put database
data = {
    "user_id": event.source.user_id,
    "correct_ans": correct_ans,
}
dynamodb.put_item(table_name, data)

```

その他のコードはリポジトリをご覧ください。

# まとめ
画像生成AIが大流行りしている中、世界初の柿ピー画像生成AI(?)を作ってクイズゲームにしてみました。

ボットはこちらです。ぜひ遊んでみてください。

https://liff.line.me/1645278921-kWRPP32q/?accountId=772ilywi