はじめに
昨年、麻雀点数計算アプリをリリースした。
リアルタイムカメラに手牌をかざすと、手牌の麻雀牌をAIが認識し、自動で点数計算する等の便利な機能満載のアプリである。
本アプリ開発の最初のステップとして、麻雀牌検出器の生成があった。
今回は麻雀牌検出器生成記録の第一弾として、オブジェクト検出についての解説と、教師データ収集について記載していく。
オブジェクト検出(物体検出)とは
オブジェクト検出、物体検出、Object Detection等と呼ばれる。
名前の通り、画像や動画から物体を検出する技術。
検出結果として、物体のラベル(分類名)、バウンディングボックス(画像上の四角形の座標)、および信頼度が与えられることにより、どこに何がどれくらいの信頼度で映っているか?が分かる。
画像分類器と異なり、1枚の画像中の複数の物体を検出できる。
顔認識カメラや、自動運転における歩行者検知カメラ等、日常でも様々な場面で使われている。
また手法も複数あり、代表的なものを記載する。
手法 | 特徴 |
---|---|
R-CNN | 認識精度が低く、処理速度が遅い。 |
Fast R-CNN | R-CNNの改良版。R-CNNよりも高速化。 |
Faster R-CNN | Fast R-CNNの改良版。Fast R-CNNよりも高速化。 |
YOLO | 超高速だが、認識精度はいまいち。 |
SSD | 超高速で、YOLOよりも高い認識精度を誇る。 |
ステップ①教師データ作成
まず最初のステップとして、教師データをバンバン作っていく。
本アプリケーションでは、約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」というものを使用した。
これも非常に使いやすいものであり、アノテーションしやすいうえに、データを水増ししてくれるデータ拡張(後述する)についても自動で行ってくれる。
4. データ拡張
データ拡張は、教師データを水増しするためだったり、様々な状況下においても高い認識精度を実現するために行われる。
拡張の種類一覧は以下の記事に詳しく記載があった。
Roboflowでは、3倍まで無料で拡張できた。
今回の麻雀牌検出器では、以下の種類の拡張を用いた。
- 画像回転 (-15° ~ +15°)
⇒ 麻雀牌を多少傾いて撮影しても大丈夫なようにしたい。 - 明度 (-20% ~ +20%)
⇒ 明るい場所・暗い場所問わず認識精度を向上したい。 - 彩度 (-30% ~ +30%)
⇒ なんとなく。 - 露光・露出 (-15% ~ + 15%)
⇒ なんとなく。
1~4の裏技
このように非常に大変な教師データ生成作業だが、ちょっとした裏技があり、本麻雀牌検出器でも何割かはその手法を用いた。
その手法は、
- 麻雀牌が1枚単体で映っている画像をたくさん用意する。(全種類)
- 以下のプログラムを用意し、実行する。
・1枚の正方形背景画像に、ランダムに選択した1の画像を重複しないように上書きする
・同時に、上書きした座標位置とラベルをアノテーションデータファイルに追記していく。
・上記を欲しいデータの数だけ繰り返す。 - 上記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)
最後に
本日はここまで。
次回はこちら。実際にデータの学習を行うステップである。
コメント