見出し画像

YOLOで6枚の画像から奇跡の6,144枚データ拡張とその壮絶な結末

はじめに

先日、YOLOを使用して10ファイルほどの画像を元にアノテーションファイルを作成し、自分で「重み付けファイル」を生成しました。その後、「学習」「検証」、そして最終的に「検出」を実施しましたが、期待していたような結果を得ることができませんでした。
その主な原因として、画像データの圧倒的な不足が挙げられます。

この記事では、画像ファイルとアノテーションファイルを自動生成してデータを増やし、改めて「学習」「検証」「検出」に挑戦しました。

データ拡張

この記事を書く過程で初めて知ったのですが、この分野では「データ拡張」という技術が広く使われています。これは少し俗な言い方をすれば、画像データを“水増し”する方法です。
具体的には、撮影または収集した画像ファイルに対し、回転させたり、上下左右に位置をずらしたりすることで、データ量を増やし、検出精度を向上させる技術です。これにより、少ない画像データで学習した場合よりも、精度が向上する可能性が高まります。

今回はアノテーションファイルの位置情報をそのままにして、画像のみを加工する方法を選びました。これにより、5000枚以上の画像ファイルを自動生成しました。


プログラム(データ拡張)

まず、`yolov7.pt`ファイルをGitHubからダウンロードします。以下のコマンドを実行してください。

git clone https://github.com/WongKinYiu/yolov7.git

もしこのコマンドが使用できない場合は、GitHubのサイトから直接ダウンロードしても構いません。

以下に、データ拡張のプログラムコードを示します。

import os
import cv2
import random

img_dir =   'val/images/'  # labelImgで結果を出力したディレクトリ
label_dir = 'val/labels/'  # labelImgで結果を出力したディレクトリ

# =======================================================
def flipped_y(img, filenm_base, label_info):
    img_flip_ud = cv2.flip(img, 0)
    cv2.imwrite(img_dir + filenm_base + '_flipped_y.jpg', img_flip_ud)

    with open(label_dir + filenm_base + '_flipped_y.txt', 'w') as f:
        for data in label_info:
            label, x_coordinate, y_coordinate, x_size, y_size = data.split()
            x_coordinate, y_coordinate, x_size, y_size = randomize_values(x_coordinate, y_coordinate, x_size, y_size)
            f.write(label + ' ' + x_coordinate + ' ' + turn_over(y_coordinate) + ' ' + x_size + ' ' + y_size + '\n')

# -------------------------------------------------------
def flipped_x(img, filenm_base, label_info):
    img_flip_lr = cv2.flip(img, 1)
    cv2.imwrite(img_dir + filenm_base + '_flipped_x.jpg', img_flip_lr)

    with open(label_dir + filenm_base + '_flipped_x.txt', 'w') as f:
        for data in label_info:
            label, x_coordinate, y_coordinate, x_size, y_size = data.split()
            x_coordinate, y_coordinate, x_size, y_size = randomize_values(x_coordinate, y_coordinate, x_size, y_size)
            f.write(label + ' ' + turn_over(x_coordinate) + ' ' + y_coordinate + ' ' + x_size + ' ' + y_size + '\n')

# -------------------------------------------------------
def flipped_xy(img, filenm_base, label_info):
    img_flip_ud_lr = cv2.flip(img, -1)
    cv2.imwrite(img_dir + filenm_base + '_flipped_xy.jpg', img_flip_ud_lr)

    with open(label_dir + filenm_base + '_flipped_xy.txt', 'w') as f:
        for data in label_info:
            label, x_coordinate, y_coordinate, x_size, y_size = data.split()
            x_coordinate, y_coordinate, x_size, y_size = randomize_values(x_coordinate, y_coordinate, x_size, y_size)
            f.write(label + ' ' + turn_over(x_coordinate) + ' ' + turn_over(y_coordinate) + ' ' + x_size + ' ' + y_size + '\n')

# -------------------------------------------------------
def flipped_rotate(img, filenm_base, label_info):
    (h, w) = img.shape[:2]  # 高さと幅
    angle = randomize_rotate_values()
    center = (w // 2, h // 2)
    M = cv2.getRotationMatrix2D(center, angle, scale=1.0)
    rotated = cv2.warpAffine(img, M, (w, h))
    cv2.imwrite(img_dir + filenm_base + '_flipped_rotate.jpg', rotated)
    with open(label_dir + filenm_base + '_flipped_rotate.txt', 'w') as f:
        for data in label_info:
            label, x_coordinate, y_coordinate, x_size, y_size = data.split()
            x_coordinate, y_coordinate, x_size, y_size = randomize_values(x_coordinate, y_coordinate, x_size, y_size)
            f.write(label + ' ' + turn_over(x_coordinate) + ' ' + turn_over(y_coordinate) + ' ' + x_size + ' ' + y_size + '\n')


# -------------------------------------------------------
def turn_over(coordinate):
    val = 1 - float(coordinate)
    return '{:.6f}'.format(val)

# -------------------------------------------------------
def randomize_values(x_coordinate, y_coordinate, x_size, y_size):
    """
    座標とサイズをランダムにずらす。
    - 座標: ±0.05 の範囲でランダムなオフセット
    - サイズ: ±0.02 の範囲でランダムな変動
    """
    x_coordinate = clamp(float(x_coordinate) + random.uniform(-0.05, 0.05))
    y_coordinate = clamp(float(y_coordinate) + random.uniform(-0.05, 0.05))
    x_size = clamp(float(x_size) + random.uniform(-0.02, 0.02))
    y_size = clamp(float(y_size) + random.uniform(-0.02, 0.02))

    return (
        '{:.6f}'.format(x_coordinate),
        '{:.6f}'.format(y_coordinate),
        '{:.6f}'.format(x_size),
        '{:.6f}'.format(y_size)
    )

# -------------------------------------------------------
def randomize_rotate_values(min_degree=-10.0, max_degree=10.0):
    degree = random.uniform(min_degree, max_degree)
    return (
        int(degree)
        #'{:.6f}'.format(degree)
    )

# -------------------------------------------------------
def clamp(value, min_value=0.0, max_value=1.0):
    """値を [min_value, max_value] の範囲に収める"""
    return max(min(value, max_value), min_value)

# -------------------------------------------------------
def load_labeldata(filenm):
    try:
        with open(label_dir + filenm + '.txt', 'r') as f:
            return f.readlines()
    except Exception as e:
        print(filenm)
        print(e)
        return []



# =========================================================
if __name__ == '__main__':
    
    for file in os.listdir(img_dir):
        name, ext = os.path.splitext(file)
        print('base:' + name + '  ext:' + ext)
        if ext == '.jpg':
            img = cv2.imread(img_dir + file)
            label_info = load_labeldata(name)
            flipped_y(img, name, label_info)
            flipped_x(img, name, label_info)
            flipped_xy(img, name, label_info)
            flipped_rotate(img, name, label_info)

以下では、それぞれの処理について説明します。

`flipped_y`、`flipped_x`、`flipped_xy`は、それぞれ「画像をY軸方向に移動」「画像をX軸方向に移動」「画像を反転させる」処理を行っています。また、`flipped_rotate`は、画像を中心に少し回転させる機能です。

すべての関数で`randomize_values`を利用しており、移動や回転の値はランダムに設定されています。これにより、多様な画像データが生成されます。

データ拡張の実装

以下は、座標とサイズをランダムに変動させる関数のコード例です。この関数では、座標を±0.05、サイズを±0.02の範囲でランダムに変更します。

def randomize_values(x_coordinate, y_coordinate, x_size, y_size):
    """
    座標とサイズをランダムに変動させる関数。
    - 座標: ±0.05 の範囲でランダムに変更
    - サイズ: ±0.02 の範囲でランダムに変更
    """
    x_coordinate = clamp(float(x_coordinate) + random.uniform(-0.05, 0.05))
    y_coordinate = clamp(float(y_coordinate) + random.uniform(-0.05, 0.05))
    x_size = clamp(float(x_size) + random.uniform(-0.02, 0.02))
    y_size = clamp(float(y_size) + random.uniform(-0.02, 0.02))

    return (
        '{:.6f}'.format(x_coordinate),
        '{:.6f}'.format(y_coordinate),
        '{:.6f}'.format(x_size),
        '{:.6f}'.format(y_size)
    )

また、以下のように画像ファイルアノテーションファイルの保存先を指定します。画像ファイルには拡張子`.jpg`を、アノテーションファイルには拡張子`.txt`を付けて保存します。

img_dir =   'val/images/'  # labelImgで出力された画像フォルダ
label_dir = 'val/labels/'  # labelImgで出力されたアノテーションフォルダ

データ増加の流れ

元々6枚しかなかった画像が、プログラムを1回実行すると4倍の16枚、2回実行するとさらに4倍の64枚、5回実行すると最終的に6,144枚にまで増加します。このように、倍々ゲームのようにデータが増殖する仕組みです。この拡張処理は、「学習用」(Train)と「検証用」(Validate)のデータ両方に適用しました。
最終的に、「学習用画像」は6,144枚、「検証用画像」は4,096枚に達しました。

出来上がりの画像群はこんな感じです。

プログラム(学習・検証、検出)

ここからは、「学習」「検証」、そして「検出」のプログラムについて解説します。

学習・検証

まず、「学習」(Train)と「検証」(Validate)のプログラムです。以下のコマンドを実行します。

python train.py --data data.yaml --cfg cfg/training/yolov7-tiny.yaml --weights '' --batch-size 2 --epochs 2

なお、私のPCはGPUに対応していないため、`--device 0`のオプションを省いています。CPUのみを利用する場合は、明示的に`--device cpu`を追加しても問題ありません。

このコマンドを実行すると、`runs/train/exp(※)`ディレクトリ以下にさまざまなファイルが生成されます。特に重要なのは`weights`フォルダ内の`last.pt`ファイルです。このファイルを使って次の「検出」(Detect)を実施します。

なお、私のPCは性能が低いため、この処理には約3日間もかかってしまいました……。

【注意】
複数回`train`を実行すると、`runs/train`以下に`exp`、`exp2`、`exp3`といったフォルダが作成されます。実行回数に応じて適切な設定を変更してください。

検出

次に「検出」(detect)のプロセスです。

ここでは、先ほどの「学習・検証」で生成した重みファイル (`runs/train/weight/exp/last.pt`) を適用しました。また、`data.yaml`ファイルは前回の投稿記事で使用したものをそのまま利用しています。

以下のコマンドを実行します:

python detect.py --weights runs/train/weight/exp/last.pt --source test/images --data data/data.yaml --device cpu

このコマンドを実行すると、`runs/detect/exp(※)`ディレクトリに、矩形マークが付けられた検出結果の画像が保存されるはずです。ところが、出力された画像は加工前と変わらず、そのままの状態でした。どうやら設定に問題があるようです。

学習・検証結果の確認

3日間かけて生成した`last.pt`ファイルは本当に正しく動作しているのでしょうか?まずはその信頼性を疑い、学習・検証結果を確認することにしました。これらの結果は、`runs/train`フォルダ内に保存されています。

confusion_matrix.png

この図は「混同行列」を表しています。縦軸が「予測されたクラス (Predicted)」、横軸が「実際のクラス (True)」です。今回は3×3のマトリックスになっていますが、すべての予測が「背景 (background FN)」として分類されています。また、値が「1.00」となっており、すべて背景として検出されていることを意味しています。

F1_curve.png

このグラフは「Confidence vs. F1 Score」(信頼度とF1スコア)の関係を表しています。F1スコアはモデルがバランス良く物体を検出しているかを示す指標です。結果は散々で、F1スコアはほぼゼロに近い状態です。

P_curve.png

このグラフは「Confidence vs. Precision」(信頼度と適合率)の関係を示しています。適合率(Precision)が高いほど、モデルの検出が正確であることを意味します。今回の結果では適合率が高いものの、グラフが横軸に近い位置に張り付いており、検出精度に課題があることが分かります。

PR_curve.png

このグラフは「Precision-Recall Curve」(精度と再現率の関係)を示します。横軸(Recall)が大きいほど見逃しが少なく、縦軸(Precision)が大きいほど誤検知が少ないことを意味します。理想的には右上に分布するべきですが、今回の結果は左下に集中しています。

R_curve.png

このグラフは「Confidence-Recall Curve」(信頼度と再現率の関係)を示します。横軸(Confidence)が大きいほどモデルの確信度が高いことを示し、縦軸(Recall)が大きいほど正確に検出できていることを示します。理想的な結果は右上にグラフが集中することですが、今回はその逆で、左下に偏っています。

おわりに

今回のデータ拡張では期待した成果を得られませんでした。おそらく「エポック数」や「バッチサイズ」が不足していることが原因かもしれませんが、私のPCではこれらの値を上げるとすぐに`OOM`(Out Of Memory)が発生してしまいます。ウィッシュ!のダイゴさんではなく、メモリ不足のことです(笑)。

次回は、豊富な画像データとアノテーションを提供しているサイト、Roboflowからデータを利用し、再挑戦してみたいと思います。

いいなと思ったら応援しよう!