ダーツスコア自動化への挑戦 其の壱 - Blenderから生成した合成データを活用して画像分類の精度を向上させよう

## 概要
実データの不足やクラス間のデータ数不均衡という課題に対し、合成データを用いた事前学習を実施しました。その結果、ダーツにおける21エリアの分類タスクにおいて、実データのみでの学習と比較して分類精度1.7倍の向上を達成しました。

## はじめに
AIを活用してダーツボードの分類タスクに取り組んでみました。きっかけは、私が運営する大学のダーツサークルでの課題です。
現在サークルでは2台のダーツボードを運用中です：
* 自動スコア計算機能付き（対戦用）
* 自動スコア計算機能がない（練習用）

ありがたいことではありますが、メンバー増加に伴い、対戦待ち時間が長くなっているのが悩みでした。そこで、自動スコア計算機能がないダーツボードもAIで自動計算化できれば、2台同時に対戦用として活用が可能となり、待ち時間短縮による活動の活性化が期待できます🕺🏽

というわけで、今回は自動計算への第一歩として、ダーツボードのどのエリア(BULL + 1〜20)に矢が刺さっているかを判別するAI開発に着手しました。

## ダーツAIの開発における課題
1. 大量の実データの収集とラベル付けに多大な時間と労力を要すること
2. ダーツのルール上、21種類のエリアに対する投擲頻度に偏りが生じ、収集データにクラス分布の不均衡が発生すること
## 解決策：合成データの活用
3DCGの制作ソフトであるBlenderはpythonコードによる操作の自動化が可能です。そのため、データの収集にかかる人間の手間を減らすことができます。また、生成した画像をクラス別のフォルダに自動保存することで、効率的なラベル付けも実現できます。これにより、均一なクラス分布を持つ大きなデータセットを容易に構築できると考えました。

学習の流れは、
1. 合成データによる事前学習
2. 実データでファインチューニング
です。

実データの不足と分布の偏りを合成データによって補おう！という発想です。

## データセットの作成
3Dモデルの制作について、
ダーツボードはDARTSLIVE HOMEの製品画像を円柱の3Dモデルに配置し、
ダーツは、
https://youtu.be/9WGblHAEg_4?si=okkPK1TtK7zjefOP
を参考に作成しました。

その後、Pythonスクリプトを実行して合成データを生成しました。
この際、以下のパラメータをランダムに変化させています：
- カメラアングル：4つの視点からランダムに選択
- ダーツの刺さる位置
- ダーツの刺さる角度
- 照明の強さ
- 背景色：グレー、青、赤、黄の異なる色調からランダムに選択

なお、製品に企業ロゴが含まれるため、実際に得られた画像は非公開とさせていただきます🙇‍♂️

<details>
  <summary>blender.py実行コード</summary>
  
```blender.py
import os
import bpy
import random
import math
from mathutils import Vector, Euler

class BlenderRandomizer:
    def __init__(self,
                 # ライトの設定
                 light_power_range,
                 # 背景色の選択肢
                 background_colors,
                 # オブジェクトの位置設定
                 position_radius_range,
                 position_angle_range,
                 # オブジェクトの回転設定
                 rotation_x_range,
                 rotation_y_range,
                 rotation_z_range,
                 # カメラの設定
                 camera_names,
                 # オブジェクトとライトの名前
                 target_object_name,
                 area_light_name):
        
        self.light_power_range = light_power_range
        self.background_colors = background_colors
        self.position_radius_range = position_radius_range
        self.position_angle_range = position_angle_range
        self.rotation_x_range = rotation_x_range
        self.rotation_y_range = rotation_y_range
        self.rotation_z_range = rotation_z_range
        self.camera_names = camera_names
        self.target_object_name = target_object_name
        self.area_light_name = area_light_name

    def randomize_light_power(self):
        """エリアライトのパワーをランダムに変更する"""
        power = random.randint(int(self.light_power_range[0]), int(self.light_power_range[1]))
        bpy.data.lights[self.area_light_name].energy = power

    def randomize_background_color(self):
        """背景色をランダムに変更する"""
        color = random.choice(self.background_colors)
        # ワールドの取得とノードの設定
        world = bpy.data.worlds['World']
        world.use_nodes = True
        nodes = world.node_tree.nodes
        links = world.node_tree.links
        
        # 既存のノードをクリア
        nodes.clear()
        
        # 新しいバックグラウンドノードとアウトプットノードを作成
        background = nodes.new('ShaderNodeBackground')
        output = nodes.new('ShaderNodeOutputWorld')
        
        # ノードを接続
        links.new(background.outputs[0], output.inputs[0])
        
        # 色を設定
        background.inputs[0].default_value = color

    def randomize_object_position(self):
        """オブジェクトの位置をランダムに変更する"""
        obj = bpy.data.objects.get(self.target_object_name)
        radius = random.uniform(*self.position_radius_range)     # 半径をランダムに選択
        angle = math.radians(random.uniform(*self.position_angle_range)+90)  # 角度をラジアンに変換, 12時方向が0度になるよう-90度
        
        x = radius * math.cos(angle)  # X座標を極座標から計算
        y = obj.location.y            # Y座標は固定     
        z = radius * math.sin(angle)  # Z座標を極座標から計算
        
        obj.location = Vector((x, y, z))

    def randomize_object_rotation(self):
        """オブジェクトの回転をランダムに変更する"""
        obj = bpy.data.objects.get(self.target_object_name)
        x = math.radians(random.uniform(*self.rotation_x_range))
        y = math.radians(random.uniform(*self.rotation_y_range))
        z = math.radians(random.uniform(*self.rotation_z_range))
        
        obj.rotation_euler = Euler((x, y, z), 'XYZ')

    def render_random_camera(self, output_path):
        """指定されたカメラからランダムに選んでレンダリングする"""
        os.makedirs(os.path.dirname(output_path), exist_ok=True)
        available_cameras = self.camera_names
        camera_name = random.choice(available_cameras)
        camera = bpy.data.objects[camera_name]
        
        bpy.context.scene.camera = camera
        bpy.context.scene.render.image_settings.file_format = 'JPEG'
        bpy.context.scene.render.filepath = output_path
        
        bpy.ops.render.render(write_still=True)

    def randomize_all(self, output_path):
        """すべてのランダム化関数を実行する"""
        self.randomize_light_power()
        self.randomize_background_color()
        self.randomize_object_position()
        self.randomize_object_rotation()
        self.render_random_camera(output_path)

def get_dartboard_angle_range(number):
    """ダーツボードの数字に対応する角度範囲を返す"""
    # 1つの数字あたりの角度
    angle_per_number = 18 #360/20
    # ダーツボードの数字の配置（時計回りに）
    number_positions = [20, 1, 18, 4, 13, 6, 10, 15, 2, 17, 3, 19, 7, 16, 8, 11, 14, 9, 12, 5]
    # 指定された数字の位置を探す
    position = number_positions.index(number)
    # 開始角度を計算（12時が0度）
    start_angle = -position * angle_per_number - 9
    end_angle = start_angle + 18
    return (start_angle, end_angle)
```
```blender.py-Bullのデータを取得
# 基本設定(BULL)
CONFIG = {
    'base_dir': "path",
    'areas': {
        'Inner_BULL': {
            'num_images': 50,
            'radius_range': (0, 0.06)
        },
        'Outer_BULL': {
            'num_images': 50,
            'radius_range': (0.09, 0.16)
        },
    }
}

for area_name, area_config in CONFIG['areas'].items():
    output_dir = os.path.join(CONFIG['base_dir'], f"{area_name}")
    angle_range = (-180,180)
    
    for i in range(area_config['num_images']):
        params = {
            'light_power_range': (100, 500),
            'background_colors': [
                (0.9, 0.9, 0.9, 1),    # 明るいグレー
                (0.2, 0.2, 0.2, 1),    # 濃いグレー
                (0.2, 0.4, 0.8, 1),    # 青
                (0.8, 0.3, 0.3, 1),    # 赤
                (0.8, 0.8, 0.3, 1),    # 黄色
            ],
            'position_radius_range': area_config['radius_range'],
            'position_angle_range': angle_range,
            'rotation_x_range': (70, 110),
            'rotation_y_range': (-180, 180),
            'rotation_z_range': (-10, 10),
            'camera_names': ["Camera.001", "Camera.002", "Camera.003", "Camera.004"],
            'target_object_name': "dart",
            'area_light_name': "area"
        }
        
        output_path = os.path.join(output_dir, f"{i}.jpg")
        randomizer = BlenderRandomizer(**params)
        randomizer.randomize_all(output_path)
```
```blender.py-ナンバーのデータを取得
# 基本設定(ナンバー)
CONFIG = {
    'base_dir': "path",
    'areas': {
        'TRIPLE': {
            'num_images': 50,
            'radius_range': (0.89, 1)
        },
        'Outer_SINGLE': {
            'num_images': 125,
            'radius_range': (1.04, 1.44)
        },
        'DOUBLE': {
            'num_images': 75,
            'radius_range': (1.48, 1.6)
        },
        'Inner_SINGLE': {
            'num_images': 100,
            'radius_range': (0.18, 0.85)
        }
    }
}

for area_name, area_config in CONFIG['areas'].items():
    for number in range(1, 21):
        output_dir = os.path.join(CONFIG['base_dir'], f"{area_name}{number}")
        angle_range = get_dartboard_angle_range(number)
        
        for i in range(area_config['num_images']):
            params = {
                'light_power_range': (100, 500),
                'background_colors': [
                    (0.9, 0.9, 0.9, 1),    # 明るいグレー
                    (0.2, 0.2, 0.2, 1),    # 濃いグレー
                    (0.2, 0.4, 0.8, 1),    # 青
                    (0.8, 0.3, 0.3, 1),    # 赤
                    (0.8, 0.8, 0.3, 1),    # 黄色
                ],
                'position_radius_range': area_config['radius_range'],
                'position_angle_range': angle_range,
                'rotation_x_range': (70, 110),
                'rotation_y_range': (-180, 180),
                'rotation_z_range': (-10, 10),
                'camera_names': ["Camera.001", "Camera.002", "Camera.003", "Camera.004"],
                'target_object_name': "dart",
                'area_light_name': "area"
            }
            
            output_path = os.path.join(output_dir, f"{i}.jpg")
            randomizer = BlenderRandomizer(**params)
            randomizer.randomize_all(output_path)
```
</details>

実データはyoutubeで公開されている試合動画
www.youtube.com/@japanprodarts
から収集しました。

作成した学習データとテストデータの分布は下記の通りです。
![distributionofdataset.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957573/5f34c37a-e5e2-9f8c-64e6-7bdd58677f9e.png)
見ての通り、実データにはかなり分布の偏りがあります。試合で頻繁に登場するBULLやクリケットナンバーと比較して、他のエリアはなかなかデータが得られませんでした。この結果からもプロの正確なコントロールが確認できますね笑

また、実データの学習データには意図的にデータ数0のクラスを含めました。これは、合成データによる実データの分布の不均衡の補正効果を検証するためです。極端な分布の偏りがある条件下での予測精度から、提案手法の有効性をより厳密に評価しました。

最後に画像のpathとクラスの書かれたtxtファイルを下記のコードから得ました。
<details>
  <summary>create_txt.pyファイル実行コード</summary>
  
```create_txt.py
import os

def create_image_list(source_dir, relative_path, output_dir, class_labels, output_filename):
		# 出力ファイルの完全パスを作成
    output_path = os.path.join(output_dir, output_filename)
    os.makedirs(output_dir, exist_ok=True)
    
    with open(output_path, 'w') as f:
        for class_name, label in label_mapping.items():
            # クラスフォルダの完全パスを作成
            class_dir = os.path.join(source_dir, class_name)
            if not os.path.exists(class_dir):
                print(f"Warning: Directory {class_dir} does not exist")
                continue

            image_files = sorted([
                f for f in os.listdir(class_dir)
                if f.lower().endswith('.jpg')
            ])

            # 各画像ファイルのパスとラベルを書き込み
            for image_file in image_files:
                image_path = f"{relative_path}/{class_name}/{image_file}"
                f.write(f"{image_path} {label}\n")

    print(f"Created {output_filename} with {len(label_mapping)} classes")
SOURCE_DIR = "/content/drive/MyDrive/Colab Notebooks/vit/data/train/synthetic_data"
RELATIVE_PATH = "data/train/synthetic_data"
OUTPUT_DIR = "/content/drive/MyDrive/Colab Notebooks/vit/data"
OUTPUT_FILENAME = "train_synthetic_data.txt"

class_labels = {
    "BULL": 0,
    "1": 1,
    "2": 2,
    "3": 3,
    "4": 4,
    "5": 5,
    "6": 6,
    "7": 7,
    "8": 8,
    "9": 9,
    "10": 10,
    "11": 11,
    "12": 12,
    "13": 13,
    "14": 14,
    "15": 15,
    "16": 16,
    "17": 17,
    "18": 18,
    "19": 19,
    "20": 20
}

create_image_dataset_file(
    source_dir=SOURCE_DIR,
    relative_path=RELATIVE_PATH,
    output_dir=OUTPUT_DIR,
    label_mapping=LABEL_MAPPING,
    output_filename=OUTPUT_FILENAME
)
```
```
data/train/real_data/BULL/0.jpg 0
data/train/real_data/BULL/1.jpg 0
data/train/real_data/1/0.jpg 1
```
</details>

## AIの実装
モデルは、
https://qiita.com/magokorokun/items/4acb31131930d89ab84c
を参考にし、vision transformerを採用しました。

実行環境はGoogle Colabで、T4 GPUを使用しました。
ディレクトリ構造とモデルの学習・評価部分のコードは下記の通りです：
```
.
├── main.ipynb              # モデルの学習・評価のコード
├── data/
│   ├── train/              # 学習データセット
│   │   ├── synthetic_data/  
│   │   │   ├── BULL/
│   │   │   ├── 1/
│   │   │   ├── 2/
│   │   │   └── ︙
│   │   └── real_data/    
│   │       ├── BULL/
│   │       ├── 1/
│   │       ├── 2/
│   │       └── ︙
│   ├── test/              # テストデータセット
│   │   ├── synthetic_data/ 
│   │   │   ├── BULL/
│   │   │   ├── 1/
│   │   │   ├── 2/
│   │   │   └── ︙
│   │   └── real_data/ 
│   │       ├── BULL/
│   │       ├── 1/
│   │       ├── 2/
│   │       └── ︙
│   ├── train_synthetic_data.txt
│   ├── train_real_data.txt
│   ├── test_synthetic_data.txt
│   └── test_real_data.txt
└── result/                # 学習結果の出力ディレクトリ
```

<details>
  <summary>main.ipynb</summary>

基本設定
```main.ipynb
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
from torch.optim.lr_scheduler import StepLR
from torchvision import transforms
from PIL import Image
from tqdm import tqdm
import timm
import numpy as np
import os

# ハイパーパラメータ
batch_size_synthetic = 16  # 合成データのバッチサイズ
batch_size_real = 8       # 実データのバッチサイズ
epochs_synthetic = 5      # 事前学習のエポック数
epochs_real = 20         # ファインチューニングのエポック数
lr = 3e-5
gamma = 0.7
dropout_rate = 0.1
weight_decay = 1e-4

# デバイス設定
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

# データ拡張の設定
train_transforms = transforms.Compose([
    transforms.Resize((300, 300)),
    transforms.RandomCrop(280),
    transforms.Resize(384),
    transforms.RandomRotation(degrees=3),
    transforms.ToTensor(),
])

test_transforms = transforms.Compose([
    transforms.CenterCrop(280),
    transforms.Resize(384),
    transforms.ToTensor(),
])
```
データ処理
```main.ipynb
class DartsDataset(Dataset):
    def __init__(self, txt_file, transform=None, base_dir=None):
        self.transform = transform
        self.data = []
        self.base_dir = base_dir
        
        with open(txt_file, 'r') as f:
            for line in f:
                img_path, label = line.strip().split()
                if not img_path.startswith('/'):
                    img_path = os.path.join(self.base_dir, img_path)
                self.data.append((img_path, int(label)))

    def __len__(self):
        return len(self.data)

    def __getitem__(self, idx):
        img_path, label = self.data[idx]
        image = Image.open(img_path).convert('RGB')
        
        if self.transform:
            image = self.transform(image)
        return image, label
```
学習ループと評価
```main.ipynb
def train_epoch(model, loader, criterion, optimizer, device, num_classes=21):
    model.train()
    epoch_loss = 0
    all_metrics = {
        'accuracy': 0,
        'class_accuracy': np.zeros(num_classes),
        'precision': 0,
        'recall': 0,
        'f1': 0
    }
    num_batches = len(loader)

    for data, label in loader:
        data, label = data.to(device), label.to(device)
        
        output = model(data)
        loss = criterion(output, label)
        
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        
        batch_metrics = calculate_metrics(output, label, num_classes)
        for key in all_metrics:
            if isinstance(all_metrics[key], np.ndarray):
                all_metrics[key] += batch_metrics[key] / num_batches
            else:
                all_metrics[key] += batch_metrics[key] / num_batches
        
        epoch_loss += loss.item() / num_batches
        
    return epoch_loss, all_metrics

def evaluate(model, loader, criterion, device, num_classes=21):
    model.eval()
    epoch_loss = 0
    all_metrics = {
        'accuracy': 0,
        'class_accuracy': np.zeros(num_classes),
        'precision': 0,
        'recall': 0,
        'f1': 0
    }
    num_batches = len(loader)

    with torch.no_grad():
        for data, label in loader:
            data, label = data.to(device), label.to(device)
            output = model(data)
            loss = criterion(output, label)
            
            batch_metrics = calculate_metrics(output, label, num_classes)
            for key in all_metrics:
                    all_metrics[key] += batch_metrics[key] / num_batches
            
            epoch_loss += loss.item() / num_batches
            
    return epoch_loss, all_metrics

def main():
    # データセットの読み込みと情報確認
    print("Loading datasets...")
    train_synthetic = DartsDataset(TRAIN_SYNTHETIC_PATH, transform=train_transforms)
    test_synthetic = DartsDataset(TEST_SYNTHETIC_PATH, transform=test_transforms)
    train_real = DartsDataset(TRAIN_REAL_PATH, transform=train_transforms)
    test_real = DartsDataset(TEST_REAL_PATH, transform=test_transforms)

    # データローダーの作成
    print("\nCreating data loaders...")
    train_synthetic_loader = DataLoader(train_synthetic, batch_size=batch_size_synthetic, shuffle=True)
    test_synthetic_loader = DataLoader(test_synthetic, batch_size=batch_size_synthetic)
    train_real_loader = DataLoader(train_real, batch_size=batch_size_real, shuffle=True)
    test_real_loader = DataLoader(test_real, batch_size=batch_size_real)

    # モデルの初期化
    model = timm.create_model('swin_base_patch4_window12_384', 
                            pretrained=True, 
                            num_classes=21, 
                            drop_rate=dropout_rate)
    model = model.to(device)
    
    criterion = nn.CrossEntropyLoss()
    optimizer = optim.Adam(model.parameters(), lr=lr, weight_decay=weight_decay)
    scheduler = StepLR(optimizer, step_size=1, gamma=gamma)
    
    # フェーズ1: 合成データでの事前学習
    print("フェーズ1: 合成データでの事前学習開始")
    for epoch in range(epochs_synthetic):
        train_loss, train_metrics = train_epoch(model, train_synthetic_loader, 
                                              criterion, optimizer, device)
        test_loss, test_metrics = evaluate(model, test_synthetic_loader, 
                                         criterion, device)
        scheduler.step()
        
        # 最良モデルの保存
        if test_loss < best_synthetic_loss:
            best_synthetic_loss = test_loss
            torch.save({
                'model_state_dict': model.state_dict(),
                'optimizer_state_dict': optimizer.state_dict(),
                'loss': best_synthetic_loss,
                'metrics': test_metrics
            }, os.path.join(RESULT_DIR, 'best_synthetic_darts.pth'))
    
    # フェーズ2: 実データでのファインチューニング
    print("フェーズ2: 実データでのファインチューニング開始")
    for epoch in range(epochs_real):
        train_loss, train_metrics = train_epoch(model, train_real_loader, 
                                              criterion, optimizer, device)
        test_loss, test_metrics = evaluate(model, test_real_loader, 
                                         criterion, device)
        scheduler.step()
        
        if test_loss < best_real_loss:
            best_real_loss = test_loss
            torch.save({
                'model_state_dict': model.state_dict(),
                'optimizer_state_dict': optimizer.state_dict(),
                'loss': best_real_loss,
                'metrics': test_metrics
            }, os.path.join(RESULT_DIR, 'best_real_darts.pth'))

main()
```
</details>

## 結果
比較実験として実データのみの学習も行いました。
30エポックで実データのみを学習させた結果が下記の通りです。
![training_curves_Real_Only.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957573/0994c9a1-31c1-08bf-27d4-0865fbe94605.png)
accuracyは **約40%** でした。過学習も気になりますね。

クラスごとの精度を見ると、学習データが少ないエリアについては予測がうまくいっていないことがわかるかと思います（※テストデータ自体も少ないため、参考値として捉える必要があります）。
|class  |accuracy  |
|---|---|
|1  |0.2  |
|2  |0  |
|3  |0  |
|4  |0  |
|5  |0  |
|6  |0  |
|7  |0  |
|8  |0  |
|9  |0  |
|10  |0  |
|11  |0  |
|12  |0  |
|13  |0  |
|14  |0  |
|15  |0.54  |
|16  |0.62  |
|17  |0.46  |
|18  |0.38  |
|19  |0.77  |
|20  |0.54  |
|BULL  |0.62  |

次に、
5エポックで合成データによる事前学習→20エポックで実データによるファインチューニング
をした結果が下記の通りです。
![training_curves_Real.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957573/e0e73f40-b4cf-1c09-0d77-4456ca522f76.png)
同様に過学習してるのは気になりますが、最終的な実画像に対するaccuracyは **約70%** と実画像のみで学習させた時と比較して1.7倍程精度が向上しました。

クラスごとの精度を見ると、いくつかのクラスではまだ予測が難しいものの、全体的に予測精度が向上していることが確認できました。
|class  |accuracy  |
|---|---|
|1  |0.8  |
|2  |0.25  |
|3  |0.25  |
|4  |0  |
|5  |0.5  |
|6  |0.67  |
|7  |0.33  |
|8  |0.33  |
|9  |0  |
|10  |0  |
|11  |0  |
|12  |0.5  |
|13  |0.33  |
|14  |0  |
|15  |1  |
|16  |0.69  |
|17  |0.85  |
|18  |0.69  |
|19  |0.92  |
|20  |1  |
|BULL  |0.77  |

さらなる検証として、合成データによる事前学習を15エポックに増やし、その後20エポックの実データでのファインチューニングを実施しました。しかし、結果としてaccuracyは42%に留まり、5エポックの事前学習を行った場合と比べて大きく下回る結果となりました。

この結果から、合成データでの事前学習を長く行いすぎると、モデルが合成データ特有の特徴に過度に適応してしまい、実データへの汎化性能が低下することがわかりました。

 **事前学習は学習が完全に収束する前の段階で終了させ、合成データから画像分類に必要な本質的な特徴のみを学習させる** ことが、後に続く実データでのファインチューニングの成功に繋がるのだと考えています。

## 結論
実データの不足やクラス間でデータ数に偏りがある状況においても、合成データを効果的に活用することで分類精度を大きく向上させることができました。

今後は、現在のシングルスコアの分類に加えて、トリプルやダブルの分類も可能にしたいと思っています。現在のモデルに加えて、トリプル/ダブル/シングルを分類する別モデルを構築し、これらの二つのモデルの出力を統合することで、達成できそうです。

あとは精度ももっと向上させたいですね笑

おわり