<麻雀牌検出器作成記①>麻雀牌教師データセットの生成【オブジェクト検出】

AI・機械学習

はじめに

昨年、麻雀点数計算アプリをリリースした。

麻雀サポーター - Apps on Google Play
A mahjong support app for beginners that detects mahjong hand tiles from a real-time camera, calculates points, detects multi-sided waiting, and detects the mos...

リアルタイムカメラに手牌をかざすと、手牌の麻雀牌をAIが認識し、自動で点数計算する等の便利な機能満載のアプリである。

本アプリ開発の最初のステップとして、麻雀牌検出器の生成があった。

今回は麻雀牌検出器生成記録の第一弾として、オブジェクト検出についての解説と、教師データ収集について記載していく。

オブジェクト検出(物体検出)とは

オブジェクト検出、物体検出、Object Detection等と呼ばれる。
名前の通り、画像や動画から物体を検出する技術。

検出結果として、物体のラベル(分類名)、バウンディングボックス(画像上の四角形の座標)、および信頼度が与えられることにより、どこに何がどれくらいの信頼度で映っているか?が分かる。
画像分類器と異なり、1枚の画像中の複数の物体を検出できる。
顔認識カメラや、自動運転における歩行者検知カメラ等、日常でも様々な場面で使われている。

また手法も複数あり、代表的なものを記載する。

手法特徴
R-CNN認識精度が低く、処理速度が遅い。
Fast R-CNNR-CNNの改良版。R-CNNよりも高速化。
Faster R-CNNFast R-CNNの改良版。Fast R-CNNよりも高速化。
YOLO超高速だが、認識精度はいまいち。
SSD超高速で、YOLOよりも高い認識精度を誇る。
オブジェクト検出5つの手法

ステップ①教師データ作成

まず最初のステップとして、教師データをバンバン作っていく。
本アプリケーションでは、約5000枚の画像を作成した。

1. データ収集

とにかく色んなパターンで麻雀牌がどこかに映っている画像を集める。
自分で撮影したり、使用が許可されているものを拾ってきたり・・・。

2. 正方形化

一般的なモデルは、正方形の画像を入力とするものが多い。
本モデルについても、正方形の画像を用いる。
1では、正方形のものに限らず色んなサイズのものを収集している。
そのため、画像によって以下のいずれかの処理を施す必要がある。

  • 画像の一部を正方形に切り抜く
  • 画像が正方形になるように、上下または左右の余白を黒埋めする
from PIL import Image

# 画像正方形化関数(黒余白)

def expand2square(pil_img, background_color=(0, 0, 0)):
    width, height = pil_img.size
    if width == height:
        return pil_img
    elif width > height:
        result = Image.new(pil_img.mode, (width, width), background_color)
        result.paste(pil_img, (0, (width - height) // 2))
        return result
    else:
        result = Image.new(pil_img.mode, (height, height), background_color)
        result.paste(pil_img, ((height - width) // 2, 0))
        return result

3. アノテーション

画像の中の、どこに何の正解データが映っているかを手動で教えてやる作業。
画像内の各物体について、座標範囲、そして正解ラベルを入力してやる。
アノテーションしたデータセットの出力形式には色々あるが、例えばPascalVocと呼ばれる形式だと、以下のようなxmlファイルとなる。

<annotation>
    <!-- 画像データの場所 -->
    <folder>xxx</folder>
    <filename>xxx.jpg</filename>

    <!-- 画像データの詳細(サイズ、次元) -->
    <size>
        <width>1000</width>
        <height>1000</height>
        <depth>3</depth>
    </size>

    <!-- ここから各オブジェクトの情報 -->
    <object>
        <name>circle_1</name> <!-- オブジェクトのラベル(例は一筒) -->
        <difficult>0</difficult>
        <bndbox>
            <xmin>100</xmin> <!-- 開始X座標(左) -->
            <ymin>100</ymin> <!-- 開始Y座標(上) -->
            <xmax>160</xmax> <!-- 終了X座標(右) -->
            <ymax>180</ymax> <!-- 終了Y座標(下) -->
        </bndbox>
    </object>

    <object>
        <name>character_9</name> <!-- 例は九萬 -->
        <difficult>0</difficult>
        <bndbox>
            <xmin>200</xmin>
            <ymin>200</ymin>
            <xmax>260</xmax>
            <ymax>280</ymax>
        </bndbox>
    </object>
</annotation>

特に大変なステップであるが、GUIで比較的楽にさせてくれるツールがたくさんある。
VOTTと呼ばれるツールをよく聞く。
今回私は、「Roboflow」というものを使用した。
これも非常に使いやすいものであり、アノテーションしやすいうえに、データを水増ししてくれるデータ拡張(後述する)についても自動で行ってくれる。

Roboflow: Give your software the power to see objects in images and video
Everything you need to build and deploy computer vision models.

4. データ拡張

データ拡張は、教師データを水増しするためだったり、様々な状況下においても高い認識精度を実現するために行われる。
拡張の種類一覧は以下の記事に詳しく記載があった。

【初心者】データの拡張を調べてみた - Qiita
背景・目的私は、現在データエンジニアリングを生業としています。普段は、データ基盤の構築やパフォーマンスチューニングなどに従事しています。ビックデータの収集や、蓄積、分析などの環境構築の経験はそこ…

Roboflowでは、3倍まで無料で拡張できた。
今回の麻雀牌検出器では、以下の種類の拡張を用いた。

  • 画像回転 (-15° ~ +15°)
    ⇒ 麻雀牌を多少傾いて撮影しても大丈夫なようにしたい。
  • 明度 (-20% ~ +20%)
    ⇒ 明るい場所・暗い場所問わず認識精度を向上したい。
  • 彩度 (-30% ~ +30%)
    ⇒ なんとなく。
  • 露光・露出 (-15% ~ + 15%)
    ⇒ なんとなく。

1~4の裏技

このように非常に大変な教師データ生成作業だが、ちょっとした裏技があり、本麻雀牌検出器でも何割かはその手法を用いた。
その手法は、

  1. 麻雀牌が1枚単体で映っている画像をたくさん用意する。(全種類)
  2. 以下のプログラムを用意し、実行する。
    ・1枚の正方形背景画像に、ランダムに選択した1の画像を重複しないように上書きする
    ・同時に、上書きした座標位置とラベルをアノテーションデータファイルに追記していく。
    ・上記を欲しいデータの数だけ繰り返す。
  3. 上記2によってたくさんの画像と、アノテーションデータができたら、Roboflowにアップロードし、確認&好きなデータ形式に変換する。

上記によって、非常に短時間でたくさんのデータが出来上がる。

例えば以下は、麻雀牌をランダムに配置した複数の画像データと、配置した画像のラベル・座標情報を記載したJSONファイルを生成するために今回使用した関数である。

import cv2
from IPython.display import Image, display
import random
import os
import json

# アスペクト比を固定したまま、imgを指定したwidthにリサイズする関数
def scale_to_width(img, width):
    h, w = img.shape[:2]
    height = round(h * (width / w)) # リサイズ後のheightを計算
    dst = cv2.resize(img, dsize=(width, height))

    return dst

# 配置したい麻雀牌のサイズ(リストに指定した中から、ランダムに1つ選ばれる)
sizes = [51, 54, 57, 60, 63]
# データセットJSONに記載するラベル(上から筒子、索子、萬子、字牌)
labels = ["circle_1", "circle_2", "circle_3", "circle_4", "circle_5", "circle_6", "circle_7", "circle_8", "circle_9", \
          "bamboo_1", "bamboo_2", "bamboo_3", "bamboo_4", "bamboo_5", "bamboo_6", "bamboo_7", "bamboo_8", "bamboo_9", \
          "chara_1", "chara_2", "chara_3", "chara_4", "chara_5", "chara_6", "chara_7", "chara_8", "chara_9", \
          "east", "south", "west", "north", "red", "green", "white"]

width_size = random.choice(sizes)

# 1枚単体で大きく映った麻雀牌画像のディレクトリパス
# このディレクトリの直下に、さらにそれぞれラベル(牌)ごとのディレクトリを配置し、
# その各ディレクトリの下にそのラベル(牌)の画像を配置する
dataset_dir_path = os.listdir("./dataset_org/")

json_dump_list = []

back_img_size = 512

# 1000枚のデータセットを作る
for i in range(1000):
    loop_i = 0
    # 麻雀牌画像を配置する背景画像のディレクトリパス(麻雀マットをイメージして、緑一面の画像とかでよい)
    # 512 × 512 の画像とする
    back_img = cv2.imread("./mat.jpg")
    image_infos = []

    # 1画像あたり、10枚の麻雀牌を配置する
    for _ in range(10):
        # 画像に配置するラベル(牌)をランダムに選ぶ
        dataset_path = random.choice(dataset_dir_path)
        images_path = f'./dataset_org/{dataset_path}'
        images = os.listdir(images_path)
        image_path =f'{images_path}/{random.choice(images)}'
    
        fore_img = cv2.imread(image_path)

        # 画像のサイズをアスペクトを保持したままリサイズする
        fore_img = scale_to_width(fore_img, width_size)

        # 50%の確立で、180°回転させる
        if random.random() > 0.5:
            fore_img = cv2.rotate(fore_img, cv2.ROTATE_180)
        
        # 10%の確立で、90°回転させる
        if random.random() > 0.9:
            fore_img = cv2.rotate(fore_img, cv2.ROTATE_90_CLOCKWISE)

        # 配置する麻雀牌のサイズ(リサイズ後)
        fore_h, fore_w, fore_c = fore_img.shape

        # 配置場所開始候補(x, y)をランダムに決める
        rand_x = random.randint(0, back_img_size-1-fore_w)
        rand_y = random.randint(0, back_img_size-1-fore_h)

        # 50%の確立で前回の画像と隣に配置する(512の背景画像をはみ出さなければ)
        if len(image_infos) > 0 and random.random() >= 0.5 and\
            image_infos[-1]["end_x"] + fore_w +1 <= back_img_size-1 and image_infos[-1]["start_y"] + fore_h <= back_img_size-1:
            # 開始候補を隣の位置に更新
            rand_x = image_infos[-1]["end_x"] +1
            rand_y = image_infos[-1]["start_y"]    

        # 配置失敗時(画像をはみ出すとき、既に配置した画像と重複するとき)のために、
        # 100回までの間で、成功するまで試行する
        while loop_i < 100:
            ng_flag = False # 配置失敗時のフラグ
            # 既に配置した麻雀牌と重複するかどうかをチェックする
            for info in image_infos:
                if (rand_x >= info["start_x"] and rand_x <= info["end_x"]) or\
                    (rand_x + fore_w >= info["start_x"] and rand_x + fore_w <= info["end_x"]):
                        if (rand_y >= info["start_y"] and rand_y <= info["end_y"]) or\
                            (rand_y + fore_h >= info["start_y"] and rand_y + fore_h <= info["end_y"]) or\
                            (info["start_y"] >= rand_y and info["start_y"] <= rand_y + fore_h) or\
                            (info["end_y"] >= rand_y and info["end_y"] <= rand_y + fore_h):
                            ng_flag = True # 失敗時はNG

            loop_i += 1        

            # NGフラグがONのときは、配置場所開始候補をランダムに再決定する
            if ng_flag:
                rand_x = random.randint(0, back_img_size-1-fore_w)
                rand_y = random.randint(0, back_img_size-1-fore_h)
                continue

            # NGフラグがOFF(配置成功)のときは、1枚の牌の配置情報を追加し、
            # 実際に画像に牌を上塗りし、100回チャンレジから抜ける
            else:
                image_infos.append({
                    "num": dataset_path,
                    "start_x": rand_x,
                    "end_x": rand_x + fore_w, 
                    "start_y": rand_y,
                    "end_y": rand_y + fore_h
                })
                back_img[rand_y:rand_y+fore_h, rand_x:rand_x+fore_w] = fore_img
                break
       
    # 10枚の麻雀牌を配置し終えたら、1枚の画像として保存する
    cv2.imwrite(f'./dataset/{i}.jpg', back_img)
    
    # JSONに情報を追加する
    # 一番上に画像ファイル名
    json_dump_list.append({
        "image": f'{i}.jpg',
        "annotations": []
    })
    # JSONのannotationsに配置した麻雀牌の座標情報を追加していく
    for info in image_infos:
        json_dump_list[-1]["annotations"].append(
            {
                "label": labels[int(info["num"])-1],
                "coordinates": {
                    "x": info["start_x"] + (info["end_x"] - info["start_x"])/2,
                    "y": info["start_y"] + (info["end_y"] - info["start_y"])/2 ,
                    "width": info["end_x"]-info["start_x"],
                    "height": info["end_y"]-info["start_y"]
                }
            }
        )

# JSONファイルを保存する
with open('./dataset.json', 'w') as f:
    json.dump(json_dump_list, f)

最後に

本日はここまで。


次回はこちら。実際にデータの学習を行うステップである。

コメント

タイトルとURLをコピーしました