前回は、牌画像を貼り付けるベース画像に対して、牌を配置したい場所を4点でアノテーションするツールを作りました。
ここまでで、
撮影画像
↓
矩形アノテーション
↓
牌画像を切り出す
↓
ベース画像の牌配置領域を4点アノテーション
まで準備できました。
今回は、いよいよこれらを組み合わせて、YOLOの学習に使う画像とラベルを自動生成します。
今回作成したdataset_create.pyでは、
切り出した牌画像
+
4点アノテーションしたベース画像
↓
牌画像を射影変換
↓
ベース画像へ貼り付け
↓
YOLOラベルを自動生成
↓
明るさ・色・ノイズなどをランダムに変更
↓
画像を移動・回転
↓
train / val / test に分けて保存
までをまとめて自動化しています。
通常のYOLO学習では、撮影した画像を1枚ずつアノテーションしてデータを増やしていきます。
今回は、これまでの記事で準備してきた牌素材とベース画像を組み合わせて、新しい学習画像を大量に生成するという独自の方法を使います。
この記事の後半には、今回使用しているdataset_create.pyのコード全文も掲載しています。
今回のプログラムでやること
今回のプログラムは、これまで作ってきたツールをつなぐ役割を持っています。
大きく分けると、次の処理を行っています。
ベース画像と4点座標を読み込む
↓
切り出した牌画像をランダムに選ぶ
↓
4点の形へ射影変換して貼り付ける
↓
貼り付け位置からYOLOラベルを作る
↓
画像全体へデータ拡張をかける
↓
ラベルも画像の変形に合わせて更新する
↓
train / val / testへ保存
これによって、同じベース画像でも使用する牌や画像処理が毎回変わり、異なる学習画像を作れるようにしています。
4点座標に合わせて牌画像を射影変換する
前回の4点アノテーションで保存したTXTファイルを読み込みます。
TXTには、
x1,y1,x2,y2,x3,y3,x4,y4という形で、牌を配置したい領域の4点が保存されています。
プログラムでは、この座標をload_quads()で読み込んでいます。
def load_quads(txt_path):
quads = []
if not os.path.exists(txt_path):
return quads
with open(txt_path, "r") as f:
for line in f:
line = line.strip()
if not line:
continue
vals = list(map(float, line.split(",")))
if len(vals) != 8:
continue
quad = np.float32([
[vals[0], vals[1]],
[vals[2], vals[3]],
[vals[4], vals[5]],
[vals[6], vals[7]],
])
quads.append(quad)
return quadsそして、切り出した牌画像の四隅と、ベース画像側の4点から射影変換行列を作ります。
H = cv2.getPerspectiveTransform(
src_pts,
dst_pts
)その変換行列を使って、
warped_img = cv2.warpPerspective(
crop_pad,
H,
(base_w, base_h),
flags=cv2.INTER_LINEAR
)とすることで、切り出した牌画像をベース画像上の形に合わせて変形します。
これにより、単純に画像を長方形のまま貼り付けるのではなく、ベース画像の角度や遠近感に合わせた形で牌を配置できます。
貼り付け部分が不自然にならないようにする
単純に牌画像を射影変換して貼り付けるだけでは、画像の境界が目立つことがあります。
そこで今回のプログラムでは、貼り付け先周辺の色を取得しています。
mean_color = get_surrounding_color(
base_img,
dst_pts
)その色を使って、牌画像の周囲へパディングを追加します。
crop_pad = cv2.copyMakeBorder(
crop_img,
pad,
pad,
pad,
pad,
cv2.BORDER_CONSTANT,
value=mean_color
)さらに、貼り付け後の境界部分だけを抽出して、
blurred = cv2.GaussianBlur(
result,
(
EDGE_BLUR_SIZE,
EDGE_BLUR_SIZE
),
0
)少しぼかしています。
これによって、牌画像とベース画像の境界が目立ちにくくなるようにしています。
貼り付けた位置からYOLOラベルも自動生成する
画像だけを自動生成しても、YOLOで学習するにはラベルが必要です。
そこで、前回指定した4点座標から、自動的にYOLO形式の矩形ラベルも作成します。
def quad_to_yolo(quad, img_w, img_h):
x_min = np.min(quad[:, 0]) - 2
x_max = np.max(quad[:, 0]) + 2
y_min = np.min(quad[:, 1]) - 10
y_max = np.max(quad[:, 1]) + 10
x_center = (x_min + x_max) / 2.0
y_center = (y_min + y_max) / 2.0
width = x_max - x_min
height = y_max - y_min
x_center /= img_w
y_center /= img_h
width /= img_w
height /= img_h
return (
x_center,
y_center,
width,
height
)4点を囲む外接矩形を作り、
class_id x_center y_center width heightというYOLO形式へ変換します。
今回のモデルでは牌の種類をYOLOで分類するのではなく、牌そのものを検出するため、クラスIDは0で保存しています。
label_lines.append(
f"0 "
f"{x_center:.6f} "
f"{y_center:.6f} "
f"{width:.6f} "
f"{height:.6f}"
)これによって、画像を作るたびに人が矩形アノテーションをする必要がありません。
画像生成とラベル生成を同時に自動化できることが、この方法の大きなメリットです。
同じ牌ばかりにならないようランダムに選ぶ
各配置場所には、croppedフォルダにある牌画像からランダムに1枚を選んで貼り付けます。
crop_path = random.choice(
available_crops
)ただし、完全にランダムにすると同じ牌ばかり選ばれる可能性があります。
そこで、1枚の生成画像内で同じ牌素材を使える回数を、
MAX_DUPLICATE = 4に制限しています。(実際同じ柄の牌は4枚までなので)
available_crops = [
path
for path in crop_files
if crop_use_count[path] < MAX_DUPLICATE
]また、50%の確率で牌画像を180度回転させています。
if random.random() < 0.5:
crop_img = cv2.rotate(
crop_img,
cv2.ROTATE_180
)これによって、同じ牌素材でも少し違った状態の画像を作れるようにしています。
画像全体にもデータ拡張をかける
牌を貼り付けたあと、画像全体にもランダムなデータ拡張をかけています。
今回設定している項目は次のとおりです。
AUGMENT_CONFIG = {
"brightness": (0.7, 1.3),
"contrast": (0.7, 1.3),
"color": (0.7, 1.3),
"sharpness": (0.5, 2.0),
"gamma": (0.8, 1.2),
"noise_sigma": (0, 10),
"jpeg_quality": (50, 100),
"rotate_deg": (-5, 5),
"shift_x": (-0.05, 0.05),
"shift_y": (-0.05, 0.05),
}具体的には、
- 明るさ
- コントラスト
- 彩度
- シャープネス
- ガンマ
- ノイズ
- JPEG圧縮
- 回転
- 上下左右への移動
をランダムに変更しています。
同じベース画像から作った画像でも、毎回少しずつ見え方が変わるため、学習データのバリエーションを増やせます。
回転や移動に合わせてラベルも更新する
画像を移動したり回転したりすると、当然YOLOの矩形位置も変わります。
そのため今回のプログラムでは、画像処理だけでなくラベルも同時に変換しています。
例えば画像を移動するときは、
img, new_labels = shift_image_and_boxes(
img,
label_lines
)画像の移動量に合わせて矩形座標も移動します。
さらに回転するときは、
img, new_labels_ = rotate_image_and_boxes(
img,
new_labels
)矩形の4頂点を画像中心のまわりに回転させ、その後の外接矩形を新しいYOLOラベルとして保存しています。
これによって、
画像だけ回転
↓
ラベル位置がずれる
という問題を防いでいます。
train・val・testも自動で振り分ける
生成した画像は、YOLOでそのまま使いやすいように、
train
val
testへ自動で振り分けています。
比率は次のように設定しています。
TRAIN_RATIO = 0.8
VAL_RATIO = 0.1
TEST_RATIO = 0.1生成されたフォルダは、
sample/
└── out_dir/
├── images/
│ ├── train/
│ ├── val/
│ └── test/
│
└── labels/
├── train/
├── val/
└── test/という構成になります。
画像とラベルが最初からYOLO向けの構成で保存されるため、この後の学習へそのまま進められます。
1枚のベース画像から複数枚生成する
生成枚数は、
NUM = 10で指定しています。
つまり現在の設定では、1枚のベース画像から10枚の学習画像を生成します。
例えばベース画像が100枚あれば、単純計算で1000枚の画像を作れます。
さらに、それぞれで、
- 使用する牌
- 180度回転
- 明るさ
- コントラスト
- 色
- ノイズ
- 位置
- 回転角度
などがランダムに変化します。
これが、今回この自動生成方式を作った一番大きな目的です。
実際に自動生成した学習画像
ここまで説明してきた処理を使って、実際に生成した学習画像がこちらです。

同じベース画像を使っていても、
- 貼り付けられる麻雀牌
- 牌の向き
- 明るさ
- コントラスト
- 色味
- ノイズ
- JPEG圧縮
- 画像全体の回転
- 上下左右への移動
がランダムに変わるため、それぞれ少しずつ異なる画像になります。
例えば、元になるベース画像が同じでも、
ベース画像
↓
生成①:明るめ・少し回転
生成②:暗め・ノイズあり
生成③:色味を変更
生成④:位置を少し移動
...
というように、複数のパターンを自動で作ることができます。
そして重要なのは、画像だけでなく、その画像に対応するYOLOラベルも同時に生成・変換されていることです。
そのため、生成後に改めて1枚ずつ矩形アノテーションする必要はありません。
現在は、
NUM = 10
としているため、1枚のベース画像から10パターン生成しています。
この値を増やせば、同じ素材からさらに多くの学習画像を作ることもできます。
今回この仕組みを作ったことで、
「素材を準備するところまでは人が行い、その後の大量生成とアノテーションはプログラムに任せる」
という形にすることができました。
完成したコード
今回使用したdataset_create.pyの全文です。
コードが長いため、デフォルトでは折りたたんでいます。必要に応じて「dataset_create.py の全文を見る」をクリックしてください。
dataset_create.py の全文を見る
import io
import os
import cv2
import random
import numpy as np
from PIL import Image, ImageEnhance
# 設定
BASE_DIR = "./sample/base_dir"
CROP_DIR = "./sample/crop_dir"
OUT_ROOT = "./sample/out_dir"
PAD = 5
EDGE_KERNEL_SIZE = 5
EDGE_BLUR_SIZE = 5
NUM = 10
MAX_DUPLICATE = 4
TRAIN_RATIO = 0.8
VAL_RATIO = 0.1
TEST_RATIO = 0.1
for split in ["train", "val", "test"]:
os.makedirs(os.path.join(OUT_ROOT, "images", split), exist_ok=True)
os.makedirs(os.path.join(OUT_ROOT, "labels", split), exist_ok=True)
# augmentation設定
AUGMENT_CONFIG = {
"brightness": (0.7, 1.3),
"contrast": (0.7, 1.3),
"color": (0.7, 1.3),
"sharpness": (0.5, 2.0),
"gamma": (0.8, 1.2),
"noise_sigma": (0, 10),
"jpeg_quality": (50, 100),
"rotate_deg": (-5, 5),
"shift_x": (-0.05, 0.05),
"shift_y": (-0.05, 0.05),
}
def clip_box(x1, y1, x2, y2, w, h):
x1 = max(0, min(x1, w - 1))
y1 = max(0, min(y1, h - 1))
x2 = max(0, min(x2, w - 1))
y2 = max(0, min(y2, h - 1))
return x1, y1, x2, y2
def xyxy_to_yolo(x1, y1, x2, y2, w, h):
xc = ((x1 + x2) / 2) / w
yc = ((y1 + y2) / 2) / h
bw = abs(x2 - x1) / w
bh = abs(y2 - y1) / h
return xc, yc, bw, bh
def apply_gamma(img, gamma):
arr = np.array(img).astype(np.float32) / 255.0
arr = np.power(arr, gamma)
arr = np.clip(arr * 255, 0, 255).astype(np.uint8)
return Image.fromarray(arr)
def apply_noise(img, sigma):
arr = np.array(img).astype(np.float32)
noise = np.random.normal(0, sigma, arr.shape)
arr += noise
arr = np.clip(arr, 0, 255).astype(np.uint8)
return Image.fromarray(arr)
def apply_jpeg(img, quality):
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=int(quality))
buf.seek(0)
return Image.open(buf).convert("RGB")
def augment_image(img):
img = ImageEnhance.Brightness(img).enhance(
random.uniform(*AUGMENT_CONFIG["brightness"])
)
img = ImageEnhance.Contrast(img).enhance(
random.uniform(*AUGMENT_CONFIG["contrast"])
)
img = ImageEnhance.Color(img).enhance(
random.uniform(*AUGMENT_CONFIG["color"])
)
img = ImageEnhance.Sharpness(img).enhance(
random.uniform(*AUGMENT_CONFIG["sharpness"])
)
img = apply_gamma(
img,
random.uniform(*AUGMENT_CONFIG["gamma"])
)
img = apply_noise(
img,
random.uniform(*AUGMENT_CONFIG["noise_sigma"])
)
img = apply_jpeg(
img,
random.randint(*AUGMENT_CONFIG["jpeg_quality"])
)
return img
def shift_image_and_boxes(img, labels):
w, h = img.size
dx = int(random.uniform(*AUGMENT_CONFIG["shift_x"]) * w)
dy = int(random.uniform(*AUGMENT_CONFIG["shift_y"]) * h)
shifted = Image.new("RGB", (w, h), (0, 0, 0))
shifted.paste(img, (dx, dy))
out = []
for line in labels:
cid, xc, yc, bw, bh = map(float, line.split())
x1 = (xc - bw/2) * w + dx
y1 = (yc - bh/2) * h + dy
x2 = (xc + bw/2) * w + dx
y2 = (yc + bh/2) * h + dy
x1,y1,x2,y2 = clip_box(x1,y1,x2,y2,w,h)
if x2 <= x1 or y2 <= y1:
continue
xc2,yc2,bw2,bh2 = xyxy_to_yolo(x1,y1,x2,y2,w,h)
out.append(f"{int(cid)} {xc2:.6f} {yc2:.6f} {bw2:.6f} {bh2:.6f}")
return shifted, out
def rotate_image_and_boxes(img, labels):
w, h = img.size
angle = random.uniform(*AUGMENT_CONFIG["rotate_deg"])
rotated = img.rotate(
angle,
expand=False,
resample=Image.BICUBIC
)
rad = np.deg2rad(-angle)
cx = w / 2.0
cy = h / 2.0
cos_r = np.cos(rad)
sin_r = np.sin(rad)
out = []
for line in labels:
cid, xc, yc, bw, bh = map(float, line.split())
# YOLO -> xyxy
box_w = bw * w
box_h = bh * h
x_center = xc * w
y_center = yc * h
x1 = x_center - box_w / 2
y1 = y_center - box_h / 2
x2 = x_center + box_w / 2
y2 = y_center + box_h / 2
# 4頂点
corners = np.array([
[x1, y1],
[x2, y1],
[x2, y2],
[x1, y2]
])
# 中心基準へ移動
corners[:, 0] -= cx
corners[:, 1] -= cy
# 回転
rotated_corners = np.zeros_like(corners)
rotated_corners[:, 0] = (
corners[:, 0] * cos_r -
corners[:, 1] * sin_r
)
rotated_corners[:, 1] = (
corners[:, 0] * sin_r +
corners[:, 1] * cos_r
)
# 元の座標系へ戻す
rotated_corners[:, 0] += cx
rotated_corners[:, 1] += cy
# 回転後の外接矩形
new_x1 = rotated_corners[:, 0].min()
new_y1 = rotated_corners[:, 1].min()
new_x2 = rotated_corners[:, 0].max()
new_y2 = rotated_corners[:, 1].max()
# 画像外クリップ
new_x1, new_y1, new_x2, new_y2 = clip_box(
new_x1,
new_y1,
new_x2,
new_y2,
w,
h
)
if new_x2 <= new_x1 or new_y2 <= new_y1:
continue
xc2, yc2, bw2, bh2 = xyxy_to_yolo(
new_x1,
new_y1,
new_x2,
new_y2,
w,
h
)
out.append(f"{int(cid)} {xc2:.6f} {yc2:.6f} {bw2:.6f} {bh2:.6f}")
return rotated, out
def load_quads(txt_path):
quads = []
if not os.path.exists(txt_path):
return quads
with open(txt_path, "r") as f:
for line in f:
line = line.strip()
if not line:
continue
vals = list(map(float, line.split(",")))
if len(vals) != 8:
continue
quad = np.float32([
[vals[0], vals[1]],
[vals[2], vals[3]],
[vals[4], vals[5]],
[vals[6], vals[7]],
])
quads.append(quad)
return quads
def get_surrounding_color(base_img, dst_pts):
h, w = base_img.shape[:2]
x_min = int(np.min(dst_pts[:, 0]))
x_max = int(np.max(dst_pts[:, 0]))
y_min = int(np.min(dst_pts[:, 1]))
y_max = int(np.max(dst_pts[:, 1]))
margin = 20
x1 = max(0, x_min - margin)
x2 = min(w, x_max + margin)
y1 = max(0, y_min - margin)
y2 = min(h, y_max + margin)
region = base_img[y1:y2, x1:x2]
if region.size == 0:
return (127, 127, 127)
mean_color = tuple(
int(v)
for v in region.mean(axis=(0, 1))
)
return mean_color
def paste_perspective(base_img, crop_img, dst_pts, pad=100,):
base_h, base_w = base_img.shape[:2]
crop_h, crop_w = crop_img.shape[:2]
mean_color = get_surrounding_color(
base_img,
dst_pts
)
crop_pad = cv2.copyMakeBorder(
crop_img,
pad,
pad,
pad,
pad,
cv2.BORDER_CONSTANT,
value=mean_color
)
pad_h, pad_w = crop_pad.shape[:2]
mask = np.zeros((pad_h, pad_w), dtype=np.uint8)
mask[pad:pad + crop_h, pad:pad + crop_w] = 255
src_pts = np.float32([
[pad, pad],
[pad + crop_w - 1, pad],
[pad + crop_w - 1, pad + crop_h - 1],
[pad, pad + crop_h - 1]
])
H = cv2.getPerspectiveTransform(
src_pts,
dst_pts
)
warped_img = cv2.warpPerspective(
crop_pad,
H,
(base_w, base_h),
flags=cv2.INTER_LINEAR
)
warped_mask = cv2.warpPerspective(
mask,
H,
(base_w, base_h),
flags=cv2.INTER_NEAREST
)
result = base_img.copy()
result[warped_mask > 0] = warped_img[warped_mask > 0]
kernel = np.ones(
(
EDGE_KERNEL_SIZE,
EDGE_KERNEL_SIZE
),
np.uint8
)
edge_mask = cv2.morphologyEx(
warped_mask,
cv2.MORPH_GRADIENT,
kernel
)
blurred = cv2.GaussianBlur(result, (EDGE_BLUR_SIZE,EDGE_BLUR_SIZE), 0)
result[edge_mask > 0] = blurred[
edge_mask > 0
]
return result
def quad_to_yolo(quad, img_w, img_h):
x_min = np.min(quad[:, 0]) - 2
x_max = np.max(quad[:, 0]) + 2
y_min = np.min(quad[:, 1]) - 10
y_max = np.max(quad[:, 1]) + 10
x_center = (x_min + x_max) / 2.0
y_center = (y_min + y_max) / 2.0
width = x_max - x_min
height = y_max - y_min
x_center /= img_w
y_center /= img_h
width /= img_w
height /= img_h
return (
x_center,
y_center,
width,
height
)
def get_random_split():
r = random.random()
if r < TRAIN_RATIO:
return "train"
elif r < TRAIN_RATIO + VAL_RATIO:
return "val"
else:
return "test"
crop_files = sorted([
os.path.join(CROP_DIR, f)
for f in os.listdir(CROP_DIR)
if f.lower().endswith(".jpg")
])
if len(crop_files) == 0:
raise RuntimeError(
"crop jpg が見つかりません"
)
base_files = sorted([
f
for f in os.listdir(BASE_DIR)
if f.lower().endswith(".jpg")
])
for idx, file_name in enumerate(base_files):
print(f"{idx} / {len(base_files)}")
base_path = os.path.join(BASE_DIR,file_name)
txt_path = os.path.splitext(base_path)[0] + ".txt"
if not os.path.exists(txt_path):
print("skip (txtなし):", file_name)
continue
print("processing:", file_name)
base_img = cv2.imread(base_path)
if base_img is None:
print("skip (読込失敗):", file_name)
continue
quads = load_quads(txt_path)
stem = os.path.splitext(file_name)[0]
for aug_idx in range(NUM):
result_img = base_img.copy()
# crop使用回数管理
crop_use_count = {
path: 0
for path in crop_files
}
label_lines = []
for quad in quads:
available_crops = [
path
for path in crop_files
if crop_use_count[path] < MAX_DUPLICATE
]
if len(available_crops) == 0:
break
crop_path = random.choice(available_crops)
crop_use_count[crop_path] += 1
crop_img = cv2.imread(crop_path)
if crop_img is None:
continue
if random.random() < 0.5:
crop_img = cv2.rotate(
crop_img,
cv2.ROTATE_180
)
result_img = paste_perspective(
result_img,
crop_img,
quad,
pad=PAD
)
img_h, img_w = result_img.shape[:2]
x_center, y_center, width, height = quad_to_yolo(
quad,
img_w,
img_h
)
label_lines.append(
f"0 "
f"{x_center:.6f} "
f"{y_center:.6f} "
f"{width:.6f} "
f"{height:.6f}"
)
# 合成後に画像処理で変換
result_img
img = Image.fromarray(cv2.cvtColor(result_img, cv2.COLOR_BGR2RGB))
img = augment_image(img)
img, new_labels = shift_image_and_boxes(img, label_lines)
img, new_labels_ = rotate_image_and_boxes(img, new_labels)
split = get_random_split()
img.save(os.path.join(OUT_ROOT, "images", split, f"{stem}_{aug_idx:03d}.jpg"))
with open(os.path.join(OUT_ROOT, "labels", split, f"{stem}_{aug_idx:03d}.txt"), "w") as f:
f.write("\n".join(new_labels_))
print("finished")実行方法
最後に、今回の学習データ自動生成プログラムの実行方法を紹介します。
必要なライブラリ
今回使用している主なライブラリは、
- OpenCV
- NumPy
- Pillow
です。
入っていない場合はインストールします。
pip install opencv-python numpy pillow素材を用意する
まず、これまでの記事で作成した2種類のデータを用意します。
① 切り出した牌画像
② 4点アノテーション済みのベース画像
コードでは、次のフォルダを使用しています。
BASE_DIR = "./sample/base_dir"
CROP_DIR = "./sample/crop_dir"ベース画像側は、画像と同名のTXTファイルを配置します。
base_001.jpg
base_001.txt
base_002.jpg
base_002.txtTXTには、前回作成した4点座標が入っています。
出力先を設定する
生成したデータの保存先は、
OUT_ROOT = "./sample/out_dir"で指定しています。
必要に応じて好きなフォルダへ変更します。
生成枚数を設定する
1枚のベース画像から生成する枚数は、
NUM = 10で変更できます。
例えば、
NUM = 100にすれば、1枚のベース画像から100枚生成します。
プログラムを実行する
設定が終わったら、
python dataset_create.pyを実行します。
環境によっては、
python3 dataset_create.pyで実行します。
処理が完了すると、
out_dir/
├── images/
│ ├── train/
│ ├── val/
│ └── test/
│
└── labels/
├── train/
├── val/
└── test/へ画像とYOLOラベルが保存されます。
これで、YOLOを学習するためのデータセットが完成です。
まとめ
今回は、これまで準備してきた牌画像とベース画像を使って、YOLO学習データを自動生成するプログラムを作りました。
今回の処理では、
切り出した牌画像
↓
ベース画像の4点へ射影変換
↓
貼り付け
↓
YOLOラベルを自動生成
↓
画像をデータ拡張
↓
ラベルも同時に変換
↓
train / val / testへ保存
までを自動化しています。
この仕組みを作ったことで、1枚ずつ撮影してアノテーションするだけではなく、一度用意した素材から大量の学習データを作れるようになりました。
今回のシリーズで作ってきた、
撮影
↓
矩形アノテーション
↓
牌画像を切り出す
↓
ベース画像を4点アノテーション
↓
データセットを自動生成
という独自の学習データ作成の流れが、ここでつながります。
次回はいよいよ、今回作成したデータセットを使ってUltralytics YOLOを実際に学習します。
YAMLファイルの設定から学習、学習結果の確認、推論までをまとめて紹介します。


コメント