薬剤師のプログラミング学習日記

プログラミングやコンピュータに関する記事を書いていきます

YOLOv3のData Augmentationとモデル評価

前回はとりあえずオリジナルのデータセット(錠剤とカプセルの画像)で学習して、実際にそれらが検出できるかというところまでやってみました。しかし、Lossの落ちやモデル評価までは踏み込めていなかったので、今回はこれらについて少し掘り下げてみようと思います。


記事の概要

Python keras版YOLOv3で錠剤・カプセルの画像でオリジナルのデータセットを作成して2クラスの物体検出を行いました。
www.yakupro.info

今回は以下の点について自分なりにいろいろ実験したり、調べてみました。

  • keras版YOLOv3のData Augmentationはどのようなものか。
  • オリジナルのデータセットで必要な画像枚数はどれくらいか。
  • モデル精度の向上のためにはどのような手法があるか。
  • Lossが下げ止まった原因は何か。


keras版YOLOv3のData Augmentation

学習時にリアルタイムにデフォルト*1で行われるData Augmentationについて見てみます。
以下の内容はkeras-yolo3-master/yolo3フォルダのutils.pyにあるget_random_data関数が行う処理について書いています。

resize image(画像サイズの変更)とplace image(画像位置の変更)
# iw, ihはデータ画像のサイズ
new_ar = iw/ih * rand(1-jitter,1+jitter)/rand(1-jitter,1+jitter)
scale = rand(.25, 2)

jitter=0.3(デフォルト値)の場合、元のデータ画像のアスペクト比を中心として±0.3くらいがボリュームゾーンになるような値を返しています。
例えばiw/ih=1.3(画像のアスペクト比1.3)として10000回new_arの値を発生させた場合のヒストグラムは下図のようになります。
f:id:enokisaute:20210814114722p:plain
この値を新しいアスペクト比として0.25~2倍のスケールでランダムにリサイズしています。
その後place image(画像位置の変更)のところではネットワークの入力サイズ(416, 416)の灰色で塗りつぶされた画像に貼り付けを行っています。この際、dx, dyの値によっては画像の一部が切り取られたような画像が生成されています。
以下にこれらの処理で生成される画像の例を示します。
f:id:enokisaute:20210813001840p:plain

flip image or not(左右の反転)
flip = rand()<.5
if flip: image = image.transpose(Image.FLIP_LEFT_RIGHT)

画像の上下はそのままで左右を反転する処理を1/2の確率で行います。
f:id:enokisaute:20210814103407p:plain

distort image(色相、彩度、明度の変更)

この部分では画像の色相(hue)、彩度(sat)、明度(val)を変化させています。各値は次の範囲の値を取ります。
・hue:デフォルト値 0.1、-hue~hue
・sat:デフォルト値 1.5、1/sat~sat
・val:デフォルト値 1.5、1/val~val

f:id:enokisaute:20210814112430p:plain
錠剤やカプセルのように、形はほぼ同じでも様々な色を持つ物体を検出したい場合には意味を持つデータ拡張であると考えられます。逆に、色でクラスを区別したい、といった場合には行わない方が良い処理でしょうか。get_random_data関数の引数で調整することができます。

correct boxes

この部分では画像の変形(リサイズや反転)に応じて、アノテーションしたバウンディングボックスの位置・大きさの補正を行っています。切り抜き等によって画像に入らなかったボックスは捨てられています。

画像の回転・上下反転の処理をData Augmentationに加える

上で見たようにYOLOv3のData Augmentationでは画像の左右反転が行われています。しかし、(今回私が使用しているような)学習データの種類によっては、画像の上下反転や回転を行うことで、より学習データのバリエーションを増やすことが可能です*2。もちろん、上下反転や回転画像を訓練画像に含めることがあまり意味をなさないケースもあります。(左右反転のみが実装されているのは、そういうことなのでしょう)

ということで、左右反転に加え、上下反転、(時計回り)90度回転、270度回転、回転・反転なし、をそれぞれ1/5の確率で行う関数を作成して学習時に実行できるようにしました。

# rand_numの値に応じて画像を変形(回転/反転)する
def rotate_img_or_not(img, rand_num):
    state = 'no_change'
    if rand_num < 0.2:
        # 左右反転
        state = 'h_flip'
        image = img.transpose(Image.FLIP_LEFT_RIGHT)
    elif 0.2 <= rand_num < 0.4:
        state = 'v_flip'
        # 上下反転
        image = img.transpose(Image.FLIP_TOP_BOTTOM)
    elif 0.4 <= rand_num < 0.6:
        state = '90_rot'
        # 時計回り90度回転(=半時計回り270回転)
        image = img.transpose(Image.ROTATE_270)
    elif 0.6 <= rand_num < 0.8:
        state = '270_rot'
        # 時計回り270度回転(=半時計回り90度回転)
        image = img.transpose(Image.ROTATE_90)
    else:
        # 反転/回転なし
        image = img
    return image, state
 
# 画像の回転(反転)に応じてボックス座標を変換する
def convert_box_coodinate(state, box, w, h):
    if state == 'h_flip':
        box[:, [0, 2]] = w - box[:, [2, 0]]
    elif state == 'v_flip':
        box[:, [1, 3]] = h - box[:, [3, 1]]
    elif state == '90_rot':
        temp_y = box[:, [3, 1]]
        box[:, [1, 3]] = box[:, [0, 2]]
        box[:, [0, 2]] = h - temp_y
    elif state == '270_rot':
        temp_x = box[:, [2, 0]]
        box[:, [0, 2]] = box[:, [1, 3]]
        box[:, [1, 3]] = w - temp_x
    return box

これらの関数をkeras-yolo3-master/yolo3フォルダのutils.pyに加え、コードを以下のように修正します。
137行目あたり

# flip image or not
# flip = rand()<.5
# if flip: image = image.transpose(Image.FLIP_LEFT_RIGHT)
image, state = rotate_img_or_not(image, rand())

162行目あたり

#if flip: box[:, [0,2]] = w - box[:, [2,0]]
box = convert_box_coodinate(state, box, w, h)

以下はこれらの関数を用いて変換される画像の例です。変換に伴って補正される物体のボックス位置も表示しています。
f:id:enokisaute:20210814134134j:plain
学習の際はこれらの処理を加えることにしました。

学習に必要な画像の枚数について

ネットで調べていると1カテゴリにつき1000枚、という目安が示されていましたが、カメラが置かれているシチュエーションによってもこれは大きく変わってきそうです。
こちらの参考サイトでは3つの状況を用いて説明されていました。
Programming Comments - Darknet FAQ

大まかにいうと、

  1. 工場のベルトコンベヤー上を見ているカメラによる画像
  2. 屋外の固定された場所(交差点など)に取り付けられたカメラによる画像
  3. 屋外を飛び回るドローンに取り付けられたカメラによる画像

1が最も単純で、カメラが見るのは限られた少数のものだけで背景は常に同じ、2は背景は固定されていますが、時間や季節による変動が加わり、カメラが見る物体の種類も多くなります。そして3が一番複雑で、背景や見る物体の種類も格段に変化に富んでいます。
言うまでもなく、学習に必要な画像の枚数は3番目のケースが最も多くなります。

今回私がやりたい錠剤(とカプセル)の物体検出は、固定カメラで背景は錠剤用のトレイ、検出するクラスは2クラスのみになるので、上の例でいうと1の状況に該当しそうです。
また検出する物体も錠剤は裏表の違いはあっても、上から見ると形は円か楕円とほぼ決まっており、カプセルは長さがありますが形はだいたい同じです。

このようにタスクが簡単だったためか、 錠剤AP、カプセルAPとも割とすぐに100%近くに達しました。カプセルはサンプルが少なかったためかやや不安定な伸びでしたが、実際いろいろと試してみたところ、画像枚数は1カテゴリ150~200くらいでも私の用途ではそこそこ事足りる、という結果でした。

LossとmAPのログを確認する

コードはこちらを使わせていただきました。
GitHub - tfukumori/keras-yolo3: A Keras implementation of YOLOv3 (Tensorflow backend)

物体検出器の精度を測定する際に使われる指標であるmAP(Mean Average Precision:全てのクラスのAPの平均)、各クラスAPのログをLoss同様にTensorBorardで確認することができます。
以下のコマンドでkeras-yolo3-masterのtrain_v2.pyを実行します。

 
python train_v2.py --yolo_train_file 2007_train.txt --anchors_file model_data/yolo_anchors.txt --yolo_val_file nano

余談ですが、画像サイズが大きすぎたためか、train_v2.pyをそのまま実行すると次のようなエラーが発生しました。

 
train_v2.py:320: RuntimeWarning: overflow encountered in long_scalars
  font_size = math.sqrt((new_right - new_left) * (new_bottom - new_top)) / 50

これにはtrain_v2.pyの314行目あたりのastype('int32')をastype('int64')とすることで対処しました。
また、学習がエラーなどで途中で止まってから再度やり直す場合は、フォルダ内にある3つのtmpフォルダを削除してから実行します。
あとは、データ数に応じてバッチサイズ(step1、step2、test)も適宜調整してやります。

モデルの精度向上のために試してみたこと

枚数を減らしたデータセットをベースラインとして実験を行い、モデルの精度(各クラスのAP)に良い影響を与えるであろうという手法を学習時に取り入れることにしました。

しかし、実際に条件を変えては繰り返し学習したのですが、ある回は良くてもある回はそれほどでもなかったりと、結果が安定しなかったため具体的な数字を載せることは控えることにしました。学習が収束しきっていなかったためか、あるいは検証データの数も少なかったので、ちょっとしたことで結果が変わってしまったのかもしれません。
オリジナルのデータセットではっきりと効果が確認できなかったのが残念です。

ネットワークの入力サイズを大きくする

これは精度が上昇する方法としてあちこちで目にしました。
(416, 416)を(576, 576)に変えて実験しました(32で割り切れる数にする必要があります)。
サイズを大きくすると多くのGPUメモリが必要となり、学習中にバッチサイズが大きくメモリが足りないとのエラーが出たので、train_v2.pyのself.step2_batch_size を4に 落として行いました。
学習に時間がかかる割に私のデータセットでは2回実験したうち1回はmAPが4~5%上昇したものの、もう1回はベースラインとあまり変わらず。デフォルトの(416, 416)でも不足はないように感じました。

ネガティブサンプル(検出対象の物体が写っていない画像)をデータセットに加える

参考サイトではこれを含めることが重要と書かれていたので試してみました。
データセット画像にある背景と同じで錠剤、カプセルが写っていない画像を13枚(3000x3000サイズ)用意してデータセットに加えました。
なお、こちらのページでは『オブジェクトが写っている画像と同数のネガティブサンプルの画像を使用』とありましたが、もともと背景にはそこまでバリエーションがなかったので、少なくしました。
また、アノテーションツールにはlabelimgを用いていますが、ツール上で一旦適当にアノテーションを作成→削除→保存するというやり方でオブジェクトのないxmlファイルを作成しています。
これも2回試したうち1回は3%ほど上昇しましたが、効果は微妙でした。少なくとも、悪くはならなかったので、これは取り入れることにしました。

画像の上下反転・回転処理をData Augmentationに加える

上で作成した関数を使用したものです。これも微妙な結果になりましたが(上昇があったり変わらなかったり)、これは取り入れることにしました。加えて、私のデータセットの場合はオブジェクトが同じ形で色とりどりのものが多いので、utils.pyのget_random_data関数のパラメータhueとsatの値をそれぞれ0.1→0.5、1.5→1.8へと変更して色のバリエーションに若干幅が出るようにしました。

データ画像のサイズを統一する

データセットの画像すべてのサイズを1760x1760に統一して学習しました。アスペクト比が1:1だった画像については、オブジェクトのボックスの座標を比率で計算しなおすコードを書いてxmlファイルを修正しました。他の画像ではオブジェクトが写っている部分を正方形にトリミングし、アノテーションをやり直して実験しました。しかし、用いたデータが悪かったのか、カプセルのAPは逆に下がってしまうという結果になりました。
データセット中の画像サイズが極端に小さい・大きいの差がなければ、あまり気にしなくても良いのではないかと思います。
画像サイズを統一せずにできたモデルでも、特に精度が悪いということはなさそうだったのでこれはやらずに学習することにしました。

Lossが下げ止まる原因について

データセットの画像枚数が150くらいからでもmAPは100%近くあり、テスト画像でもそこそこうまく検出はできていましたが、Lossは20~25くらいのところで下げ止まっていました。
データセットの画像がまだまだ足りないのかな、と思って数十枚ずつ増やしては最終的に400枚近くまでなりましたが、Loss自体にはあまり変化はみられませんでした。
これはどういことなのかを調べてみました。

試しに、データセットの画像を1画像中に写っているオブジェクト(錠剤とカプセル)の数の合計が4以下のものだけにしてみたところ、画像枚数は64枚と少なくなりましたが、Lossは13.89まで下がりました。
逆に、画像枚数が同じ64枚でも、1画像中の錠剤とカプセルの合計の数を7以上の画像だけを対象にしてみたところ、Lossは31.76と大きな違いがありました。
何度やっても両方ともこのくらいになります。

YOLOv3のLossは次の各値が合わさっています。コードではmodel.pyのyolo_loss()に書かれています。

  • バウンディングボックスのx, y座標、幅と高さの損失
  • 信頼スコア(予測の正解ボックスのIoU(重なり具合))
  • クラス予測の損失

1画像中に検出する対象となるバウンディングボックスが多くなれば、その分Lossも大きくなるため、上記のような違いが出たようです。クラス数が多い場合も同様のことが起こりそうです。
あと、クラスについては錠剤とカプセルの2クラスでしたが、下画像のような区別することが難しいものがあったこともLossが下がらなかったことに影響しているかもしれません。
f:id:enokisaute:20210829141739p:plain

他に考えられる原因としては、YOLOは画像をグリッドに分割してその領域ごとに物体を検出するので、グリッド内に物体同士が密集し過ぎていたことなどがあります。

Lossがなかなか下がらず思うような精度が出ない場合には、データセットにおいてクラス分類が無理のないものなのか、オブジェクトが密集し過ぎていないか等を見直した方がよいかもしれません。

最終結果

ここまでのことを踏まえて、次のようなデータセットと設定で学習を行いました。
・データセットの画像枚数:288枚
・全画像に含まれる物体の数:錠剤 1284個, カプセル 174個(重複あり)
・画像1枚中の錠剤とカプセルの数は0~7個
・画像のサイズは3000x3000:202枚、 4000x3000:49枚、1936x1936:17枚、 4000x2250:14枚、他6枚が混在
・step1_batch = 16, step2_batch = 8, test_batch = 8
・学習回数:デフォルト
・val_split = 0.3

各クラスのAPは以下のようになりました。
(※図中ではmAPとなっていますが、クラスのAPです)
f:id:enokisaute:20210829144313p:plain
mAP 98.04
tablet AP 98.87
capsule AP 97.21

データセットの画像枚数は多いとは言えませんが、mAPがこれだけ出ていれば充分ではないかと思います。

f:id:enokisaute:20210829145119p:plain
training lossは18.33でした。

では学習したモデルで実際に物体検出をさせてみます。
(以下の画像は学習データにはなかったものを用いています。)
f:id:enokisaute:20210829161427j:plain
一見うまくいっているようですが、下の白いカプセルが"tablet"と"capsule"の両方で検出されていました。他にも似たようなケースがあったので、(このデータセットにおいては)両者の区別は難しかったのかも。

密集していると駄目とは言っても、下の画像は大丈夫でした*3
錠剤91個、カプセル9個の3000x3000の画像です。
f:id:enokisaute:20210830160442j:plain
見やすくするため、ボックスのラベル名を表示させないようにしています。*4
真ん中より左下の茶色の錠剤と白いカプセルのクラスを間違えてはいますが、その他のオブジェクトの位置はほぼ正確に検出できています。
オブジェクトが画像中にはっきり大きく写っているから(?)でしょうか。

使用したコード

今回は次のような2つのコードも書きました。

データセットの画像をリサイズ(縦横1:1のファイルのみ)する

対応するアノテーションファイルのバウンディングボックスの位置も比率計算で変更したサイズに修正します。

データセット内の画像のオブジェクトの最大数を調整する

オブジェクトが多過ぎる画像をまとめて削除するのに使いました。削除した画像ファイルに合わせて、xmlファイル、train.txtの更新も行います。

以下のようなフォルダ構造のとき、 keras-yolo3-masterに置いて実行します。

└── keras-yolo3-master
    ├── VOCdevkit
    │   └── VOC2007
    │      ├── Annotations ── 001.xml, 002.xml, ...
    │      ├── ImageSets
    │      │   └── Main ── train.txt
    │      └── JPEGImages ── 001.jpg, 002.jpg, ...

import glob, os
import xml.etree.ElementTree as ET
from PIL import Image
 
# 1. データセットの画像サイズが1:1のjpgファイルを縦横new_sizeにリサイズする.
# 対応するアノテーションファイルのbボックス位置も変更する.
 
# 与えられたフォルダのファイルのフルパスをリストで返す
def get_files(dir_path):
    return glob.glob(dir_path)
 
new_size = 640
current_dir = os.path.dirname(os.path.abspath(__file__))
jpeg_files = get_files(current_dir + '/VOCdevkit/VOC2007/JPEGImages/*')
xml_dir = './VOCdevkit/VOC2007/Annotations/'
 
for jpeg_file in jpeg_files:
        print(jpeg_file)
        image = Image.open(jpeg_file)
        w, h = image.size
        f_name, ext = os.path.basename(jpeg_file).split('.')
        xml_file = os.path.join(*[xml_dir, f_name + '.xml'])
        # 縦横比1:1の画像だけを対象とする
        if w == h:
            image = image.resize((new_size, new_size), Image.LANCZOS)
            image.save(jpeg_file, quality=95)
 
            # xmlファイルを読み込む
            tree = ET.parse(xml_file)
            root = tree.getroot()
 
            # 画像サイズを変更
            root.find('.//width').text = str(new_size)
            root.find('.//height').text = str(new_size)
 
            # xmin, ymin, xmax, ymaxの値を比率計算で変更サイズに修正する
            for pos in root.iter('bndbox'):
                for p in pos:
                    p.text = str(int(p.text) * new_size // w)
 
            tree.write(xml_file, encoding='UTF-8')
        else:
            image.close()
            # 対象外のファイルは削除
            os.remove(jpeg_file)
            os.remove(xml_file)
 

import glob, os
 
# 2. データセットのオブジェクトの数がmax_numを超える画像ファイルを削除し,
# それに合わせてxmlファイル, train.txtも更新する.
 
# 与えられたフォルダのファイルのフルパスをリストで返す
def get_files(dir_path):
    return glob.glob(dir_path)
 
current_dir = os.path.dirname(os.path.abspath(__file__))
xml_files = get_files(current_dir + '/VOCdevkit/VOC2007/Annotations/*')
img_dir = './VOCdevkit/VOC2007/JPEGImages/'
 
# 1画像中のオブジェクトの最大数
max_num = 7
 
for xml_file in xml_files:
    with open(xml_file) as f:
        lines = f.read()
 
    # xmlファイル中のオブジェクトのバウンディングボックスの数をカウントする
    cap_num = lines.count('<name>capsule</name>')
    tab_num = lines.count('<name>tablet</name>')
    cnt = cap_num + tab_num
 
    # max_numを超えているファイルを削除
    if cnt > max_num:
        print(xml_file)
        f_name, ext = os.path.basename(xml_file).split('.')
        img_file = os.path.join(*[img_dir, f_name + '.jpg'])
        os.remove(xml_file)     # xmlファイルを削除
        os.remove(img_file)     # jpegファイルを削除
 
# train.txtを更新する
traintxt_file = current_dir + '/VOCdevkit/VOC2007/ImageSets/Main/train.txt'
os.remove(traintxt_file)
jpeg_files = get_files(img_dir + '*')
 
for jpeg_file in jpeg_files:
    f_name, ext = os.path.basename(jpeg_file).split('.')
    with open(traintxt_file, mode="a") as f:
        f.write(f_name + '\n')
 


*1:train_v2.pyのrandom=Trueのとき

*2:画像データの水増し方法としては特別なものではなく、一般的に行われることです

*3:model.pyのyolo_eval()のmax_boxesを変更しています

*4:yolo.pyの159~162行目をコメントアウト