セマンティックセグメンテーションできゅうりを認識してみた(U-Net) | そう備忘録

セマンティックセグメンテーションできゅうりを認識してみた(U-Net)

セマンティックセグメンテーション

Deep Learning による物体検知はバウンディングボックスという四角い枠で囲んで物体の位置と名称を検知する方法の他にピクセル単位で物体を検知するセマンティックセグメンテーションというアルゴリズムがある。

以前の記事では Jetson Nano で学習済みのモデルを使ってのセマンティックセグメンテーションを試してみた。

※都市の景観が得意な Cityscapes モデルを使用した例

セマンティックセグメンテーション

セマンティックセグメンテーションは物体の輪郭を正確に把握したい場合、例えば自動運転や農業で農作物の形状を把握したい時などに利用される事がある。

今回は、学習済みのモデルの利用ではなく独自の物体を学習させる所から試してみた。

学習させた物体

今回はきゅうりを学習させてみた。

キュウリのセマンティックセグメンテーション

知人がきゅうり農家をやっていて、新人や体験者、子供達にも簡単に収穫可能なきゅうりかどうかを見分けられるような装置が欲しいと言っていたので、まずは画像からセマンティックセグメンテーションが可能かどうかを試してみることにした。

画像(映像)からきゅうりの輪郭を正確に認識できれば、収穫の可否、サイズ、等級などの判定にもつながるのではないかと考えた。

きゅうり

U-Net

アルゴリズムは U-Net を使用した。

下記のように U型をしているので U-Net と名付けられたとの事。

左側でエンコード、右側でデコードされる。

U-Net: Convolutional Networks for Biomedical Image Segmentation より引用

U-Net構造

U-Net のアルゴリズムは 2015年に発表されているので Deep Learning の中では “古め” のアルゴリズムだ。

その後、U-Net の応用の U-Net++R2U-Net3D U-Net 等が発表されたが、今回は基本の U-Net で試している。

環境

試した環境は以下の通り。

画像にラベル付けをするアノテーションまでは Windows環境(内のLinux)、その後の学習・予測は Google Colaboratory にて行った。

アノテーション

パソコン

DELL G7 15 7588

CPU:CoreTM i7-8750H

メモリ:16GB

ビデオカード:NVIDIA GeForce GTX 1060

OS

Windows11 Home

アノテーションツール

CVAT(Computer Vision Annotation Tool)を下記の Linux 環境にインストールして使用した。

マウスで囲みながら多角形でアノテーションが可能なのと、複数の出力形式に対応している。

Windows 環境では動作しないのでWSL、Ubuntu などのインストールが必要。

CVAT の GitHub はこちら

WSL

Windows Subsystem for Linux を設定した。

WSL についてはこちらの記事を参照してほしい。

WSL のディストリビューション

Linux ディストリビューション は Ubuntu 20.04 を選択している

Docker

Windowsアプリの Docker Desktop on Windows をインストールした。

関連記事はこちら

コマンド入力用

CVAT の起動、終了コマンドの入力の為に git for Windows を使用した。

学習・予測

学習、予測のスクリプトは Google Colaboratory 上で実行した。

512 × 512 Pixels の画像を学習させようとした所、自分のパソコンだと “ResourceExhaustedError”(リソース枯渇エラー)で実行出来なかったからだ。

大まかな手順

大まかな手順は以下の通り。

1.環境を整える

Windows11 環境に WSL(Ubuntu 20.04)、Docker Desktop on Windows、git for Windows、CVAT を設定する。

CVAT のインストール&起動は別途記事で紹介する。

2.CVAT でアノテーション

Windows11 環境下の Linux(Ubuntu 20.04)で CVAT を起動して学習用画像のアノテーションを行う。

具体的な手順は後述するが、画像のキュウリ部分を多角形で囲んでキュウリの位置をピクセル単位でラベル付けする作業だ。

これが地味でつらい。

ラベル付をしたら Segmentation Mask 1.1 形式で出力する。

3.学習・予測

CVAT で学習した画像を U-Net モデルで学習する。

元の画像数が少なかったのでデータ拡張(水増し)している。

CVATでアノテーション

プロジェクトの作成

CVAT を起動して上部のメニューから Projects を選択して、Create new project ボタンをクリックする。

New project

Name欄に “Cucumber semantic segmentation” を入力して「Add label」ボタンでラベルを追加する(ラベルは後からでも追加はできる)

プロジェクトの作成

cucumber(きゅうり)ラベルを追加して色を指定する。

色は分かりやすように赤にした。

複数のモノをアノテーションしたいのであれば続けてラベルを追加する。

今回はキュウリだけなので「Done」ボタンをクリックする。

ラベルの追加

「Submit」ボタンをクリックする。

プロジェクトが追加されたのでクリックする。

プロジェクトが追加された

タスクの追加

「Create new task」をクリックする。

Create new task

Name:Cucumber annotation

を入力してアノテーションする画像を “Click or drag file to this area” にドラッグ・アンド・ドロップする。

尚、この際に全ての画像をアノテーションするのでは無く、テスト用の画像は残しておく。

※学習していない画像で正しくキュウリの識別ができるかどうかを検証するため。

名前を付けて画像を追加する

30枚の画像を追加した。

「Submit」ボタンをクリックする。

ファイルの追加

アノテーション

上部のメニューで “Tasks” を選択して先程作成したタスクをオープンする。

タスクのオープン

Job #数字 をクリックする。

Job

アノテーションはフルスクリーンの方がやりやすいので、右上の “Fullscreen” アイコンをクリックする。

中央上部のナビゲーターをクリックする事で前後の画像に移動が出来る。

またマウスホイールで画像の拡大縮小ができる。

左のメニューから多角形アイコンをクリックする。

アノテーション画面

右にメニューが現れるので Label は cucumber が選択されているのでそのまま、 “Shape” を選択する。

shapeを選択する

マウスで左クリックをしながらキュウリを多角形で囲む。

間違えた時は右クリックで一つ前の選択が解除される。

全体を囲ったら “N” キーで選択が確定されて色が変わる。

Ctrl+Zで全体の取り消し。

右上(奥)にもキュウリが写っているので続けてアノテーションを行う。

再び左のメニューから多角形アイコンをクリックしても良いのだが、”N” キーで新たに多角形が選択出来るモードに切り替わるのでそちらの方が簡単だ。

キュウリを多角形で囲む

右奥のキュウリもアノテーションした。

アノテーション中もマウスホイールで拡大縮小が出来るので細かい部分は拡大をしながら選択を行う。

注意点としてはカーソルの位置によって拡大縮小する時の中心位置が変わる事だ。(カーソル位置を中心に拡大縮小する)

これは慣れないと中々に難しい。

カーソル位置上下左右に変えながらホイールで拡大縮小をして感覚を掴む必要がある。

また、確定した後でも画面上の赤丸をドラッグ・アンド・ドロップで移動する事で修正できるので、拡大表示をしてみてズレているようであれば後から修正する。

続けてアノテーション

尚、後方に写っている多少ピンボケの画像であっても人の目でキュウリと判別できるものはアノテーションを行った。

こちらにラベル付け時の7つのヒントが載っていたので参考にさせて貰った。

  • 画像に写っている全てのオブジェクトにアノテーションする(多少ピントが合っていないモノも含める)
  • オブジェクト全体にラベル付けする
  • 遮蔽されていて全体が写っていないオブジェクトもラベル付けする(一部でもアノテーション)
  • アノテーション領域が重なり合っても良い
  • なるべくギリギリを選択する(但しオブジェクトが切り取られてしまっては駄目)
  • ラベル名をより一般的な名前にする
  • 明確なラベル付けの指示(ルール)を維持する(途中でルールを変えない)
  • CVAT、LabelImg、Roboflowなどのラベリングツールを使用する

同じ要領で残りの29枚の画像のアノテーションを行った。

JOB State の変更

アノテーションが終了したら JOB Status の変更を行う。

Menu から “Change job state” ー> completed に変更する。

Job Statusの変更

確認画面が表示されるので “Continue” をクリックする。

確認画面

タスクのステージの変更

State が Completed に変更されたので、続いてメニューから “Tasks” を選択する。

ステータスが変更された

「Open」ボタンでタスクをオープンする。

タスクのオープン

“Stage” をプルダウンで “annotation” から “acceptance” に変更して “Job #数字” をクリックする。

Stageの変更

Export

ステージを変更することによりデータエクスポートができるようになる。

Menu から “Export task dataset” を選択する。

Export task dataset
  • Export format:Segmentation mask 1.1
  • Save images:チェックした
  • Custom name:cucumber(.zip)

で「OK」ボタンをクリックする。

エクスポート

ファイルダイアログボックスが表示されるので保存する。

データ量によってはダイアログ・ボックスが表示されるまで時間がかかるので注意。

アノテーションデータ

ダウンロードした zip ファイルを解凍すると以下のフォルダー構成になっている。

├─cucumber
│  │      
│  ├─ImageSets
│  │  ├──Segmentation
│  │      default.txt・・・画像ファイル名の一覧
│  │
│  ├─JPEGImages
│  │  元画像(Save imagesにチェックした為)
│  │
│  ├─SegmentationClass
│  │  png形式のアノテーションした画像1
│  │
│  ├─SegmentationObject
│  │  png形式のアノテーションした画像2
│  │

今回は SegmentationClass 配下の画像1を使用する。

赤くなっている部分がキュウリの位置だ。

アノテーションした画像

ちなみに SegmentationObject 配下のファイルは以下の様になっている。

一つの画像に複数のキュウリがある場合、異なる色(クラス)に分類されているので今回は使用しない。

SegmentationObject

事前準備

JPEGImages 配下の画像をトレーニングデータ、SegmentationClass 配下の画像を教師データとして U-Netで学習を行う。

U-Net はこちらの GitHub のコードを参考にさせて貰った。

フォルダー構成

上記の GitHub からコード類をダウンロードして Google Drive 上に以下のフォルダを構成する。

メインコードは Google Colaboratory に記述するが学習用画像や model.py、data.py は GitHub のコードを Google Drive に保存して Colaboratory から呼び出して使うことにする。

JPEGImages 配下のファイルを image へ、SegmentationClass 配下のファイルを label へコピーする。

そして推論用にアノテーションしていない画像を適当に見繕って test_org 配下にコピーする。

尚、この画像に対して直接推論を行うのでは無く、グレースケール化やリサイズを行って test フォルダーに格納した後、推論を行っている。

├─Google Drive
│  │      
│  ├─unet-master
│  │  │ data.py:各種データ操作用関数(GitHubより)
│  │  │ model.py:U-Netモデル(GitHubより)
│  │  │
│  │  ├──data
│  │  │  │
│  │  │  ├─Cucumber
│  │  │  │  │
│  │  │  │  ├─model:学習済みモデルを格納するフォルダー
│  │  │  │  │
│  │  │  │  ├─test:テスト用画像を推論しやすい様に加工している
│  │  │  │  │
│  │  │  │  ├─test_org:テスト・推論用画像(元データ)
│  │  │  │  │
│  │  │  │  ├─train:学習用画像
│  │  │  │  │  ├─aug:image、labelのデータ拡張後のフォルダ
│  │  │  │  │  │
│  │  │  │  │  ├─image:前述のJPEGImagesフォルダの画像をここに格納する
│  │  │  │  │  │
│  │  │  │  │  ├─label:前述のSegmentationClassフォルダの画像をここに格納する

model.pyの修正と説明

GitHub からダウンロードした model.py に U-Net の構成が定義されている。

import numpy as np 
import os
import skimage.io as io
import skimage.transform as trans
import numpy as np
from tensorflow.keras.models import *
from tensorflow.keras.layers import *
from tensorflow.keras.optimizers import *
from tensorflow.keras.callbacks import ModelCheckpoint, EarlyStopping, LearningRateScheduler
from tensorflow.keras import backend as keras
from tensorflow.keras import optimizers


def unet(pretrained_weights = None,input_size = (256,256,1)):
    inputs = Input(input_size)
    conv1 = Conv2D(64, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(inputs)
    conv1 = Conv2D(64, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(conv1)
    pool1 = MaxPooling2D(pool_size=(2, 2))(conv1)
    conv2 = Conv2D(128, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(pool1)
    conv2 = Conv2D(128, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(conv2)
    pool2 = MaxPooling2D(pool_size=(2, 2))(conv2)
    conv3 = Conv2D(256, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(pool2)
    conv3 = Conv2D(256, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(conv3)
    pool3 = MaxPooling2D(pool_size=(2, 2))(conv3)
    conv4 = Conv2D(512, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(pool3)
    conv4 = Conv2D(512, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(conv4)
    drop4 = Dropout(0.5)(conv4)
    pool4 = MaxPooling2D(pool_size=(2, 2))(drop4)

    conv5 = Conv2D(1024, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(pool4)
    conv5 = Conv2D(1024, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(conv5)
    drop5 = Dropout(0.5)(conv5)

    up6 = Conv2D(512, 2, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(UpSampling2D(size = (2,2))(drop5))
    merge6 = concatenate([drop4,up6], axis = 3)
    conv6 = Conv2D(512, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(merge6)
    conv6 = Conv2D(512, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(conv6)

    up7 = Conv2D(256, 2, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(UpSampling2D(size = (2,2))(conv6))
    merge7 = concatenate([conv3,up7], axis = 3)
    conv7 = Conv2D(256, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(merge7)
    conv7 = Conv2D(256, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(conv7)

    up8 = Conv2D(128, 2, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(UpSampling2D(size = (2,2))(conv7))
    merge8 = concatenate([conv2,up8], axis = 3)
    conv8 = Conv2D(128, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(merge8)
    conv8 = Conv2D(128, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(conv8)

    up9 = Conv2D(64, 2, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(UpSampling2D(size = (2,2))(conv8))
    merge9 = concatenate([conv1,up9], axis = 3)
    conv9 = Conv2D(64, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(merge9)
    conv9 = Conv2D(64, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(conv9)
    conv9 = Conv2D(2, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(conv9)
    conv10 = Conv2D(1, 1, activation = 'sigmoid')(conv9)

    #model = Model(input = inputs, output = conv10)
    model = Model(inputs, outputs = conv10)

    #model.compile(optimizer = optimizers.Adam(lr = 1e-4), loss = 'binary_crossentropy', metrics = ['accuracy'])
    model.compile(optimizer = optimizers.Adam(lr = 5e-4), loss = 'binary_crossentropy', metrics = ['accuracy'])
    
    #model.summary()

    if(pretrained_weights):
    	model.load_weights(pretrained_weights)

    return model

15行目から54行目にて U-Net の定義をしている。

また Google Colaboratory の kears のバージョンだとそのままでは動作しないコードがあるので修正する。

56行目をコメントアウトして以下のように修正する。

    #model = Model(input = inputs, output = conv10)
    model = Model(inputs, outputs = conv10)

また59行目の最適化パラメーターは若干変更した。

    #model.compile(optimizer = optimizers.Adam(lr = 1e-4), loss = 'binary_crossentropy', metrics = ['accuracy'])
    model.compile(optimizer = optimizers.Adam(lr = 5e-4), loss = 'binary_crossentropy', metrics = ['accuracy'])

Adam は最適化手法の一つで過去の勾配および勾配の2乗の指数関数的減衰平均を使用している。

lr(学習率)のデフォルトは0.001(1e-3) なのだが元々のコードは0.0001(1e-4)が指定されていた。

学習率は高くしすぎると発散して収束しない、一方低くしすぎると収束に時間がかかったり局所解にとらわれる可能性が出てくる。

学習率は最初は低い学習率から学習率を上げていくという手法で何回か試してみて最適な値が得られたらモデルを初期化して得られた学習率でモデルを訓練すると良いとの事。

0.0001 から 0.001 まで一通り試して今回の画像では 0.0005(5e-4) に落ち着いた。

尚、最適なパラメータの検索は RandomizedSearchCV を使う方法もある。

詳しくは以前の記事を参照してほしい。

また最初は高い学習率でスタートして損失(loss)の減少ペースが下がったら学習率を下げる方法もある。

上記の知識は以下の書籍の11章(深層ニューラルネットワークの訓練)から得られた。

分厚くて読み応えがあり途中で挫折してしまいがちだが、基本的な事がしっかりと書いてあるので何回も読み直している。

data.pyの説明

GitHub からダウンロードした data.py にデータ操作用の関数が定義されている。

from __future__ import print_function
from keras.preprocessing.image import ImageDataGenerator
import numpy as np 
import os
import glob
import skimage.io as io
import skimage.transform as trans

Sky = [128,128,128]
Building = [128,0,0]
Pole = [192,192,128]
Road = [128,64,128]
Pavement = [60,40,222]
Tree = [128,128,0]
SignSymbol = [192,128,128]
Fence = [64,64,128]
Car = [64,0,128]
Pedestrian = [64,64,0]
Bicyclist = [0,128,192]
Unlabelled = [0,0,0]

COLOR_DICT = np.array([Sky, Building, Pole, Road, Pavement,
                          Tree, SignSymbol, Fence, Car, Pedestrian, Bicyclist, Unlabelled])


def adjustData(img,mask,flag_multi_class,num_class):
    if(flag_multi_class):
        img = img / 255
        mask = mask[:,:,:,0] if(len(mask.shape) == 4) else mask[:,:,0]
        new_mask = np.zeros(mask.shape + (num_class,))
        for i in range(num_class):
            #for one pixel in the image, find the class in mask and convert it into one-hot vector
            #index = np.where(mask == i)
            #index_mask = (index[0],index[1],index[2],np.zeros(len(index[0]),dtype = np.int64) + i) if (len(mask.shape) == 4) else (index[0],index[1],np.zeros(len(index[0]),dtype = np.int64) + i)
            #new_mask[index_mask] = 1
            new_mask[mask == i,i] = 1
        new_mask = np.reshape(new_mask,(new_mask.shape[0],new_mask.shape[1]*new_mask.shape[2],new_mask.shape[3])) if flag_multi_class else np.reshape(new_mask,(new_mask.shape[0]*new_mask.shape[1],new_mask.shape[2]))
        mask = new_mask
    elif(np.max(img) > 1):
        img = img / 255
        mask = mask /255
        mask[mask > 0.5] = 1
        mask[mask <= 0.5] = 0
    return (img,mask)



def trainGenerator(batch_size,train_path,image_folder,mask_folder,aug_dict,image_color_mode = "grayscale",
                    mask_color_mode = "grayscale",image_save_prefix  = "image",mask_save_prefix  = "mask",
                    flag_multi_class = False,num_class = 2,save_to_dir = None,target_size = (256,256),seed = 1):
    '''
    データ拡張をしてデータを水増しする
    画像とラベルの両方を拡張する
    can generate image and mask at the same time
    use the same seed for image_datagen and mask_datagen to ensure the transformation for image and mask is the same
    if you want to visualize the results of generator, set save_to_dir = "your path"
    '''
    image_datagen = ImageDataGenerator(**aug_dict)
    mask_datagen = ImageDataGenerator(**aug_dict)
    image_generator = image_datagen.flow_from_directory(
        train_path,
        classes = [image_folder],
        class_mode = None,
        color_mode = image_color_mode,
        target_size = target_size,
        batch_size = batch_size,
        save_to_dir = save_to_dir,
        save_prefix  = image_save_prefix,
        seed = seed)
    mask_generator = mask_datagen.flow_from_directory(
        train_path,
        classes = [mask_folder],
        class_mode = None,
        color_mode = mask_color_mode,
        target_size = target_size,
        batch_size = batch_size,
        save_to_dir = save_to_dir,
        save_prefix  = mask_save_prefix,
        seed = seed)
    train_generator = zip(image_generator, mask_generator)
    for (img,mask) in train_generator:
        img,mask = adjustData(img,mask,flag_multi_class,num_class)
        yield (img,mask)


# 指定されたpathのn.pngファイルを順番に読んでリサイズして返す
def testGenerator(test_path,num_image = 30,target_size = (256,256),flag_multi_class = False,as_gray = True):
    for i in range(num_image):
        img = io.imread(os.path.join(test_path,"%d.png"%i),as_gray = as_gray)
        img = img / 255
        img = trans.resize(img,target_size)
        img = np.reshape(img,img.shape+(1,)) if (not flag_multi_class) else img
        img = np.reshape(img,(1,)+img.shape)
        yield img # returnだとfor文が全て終了してから返すがyieldだと都度返して続きから始める(メモリの消費を抑える)


# 指定されたpathのimageファイル(トレーニングデータ)とmaskファイル(正解ラベル)をセットにして配列で返す
def geneTrainNpy(image_path,mask_path,flag_multi_class = False,num_class = 2,image_prefix = "image",mask_prefix = "mask",image_as_gray = True,mask_as_gray = True):
    image_name_arr = glob.glob(os.path.join(image_path,"%s*.png"%image_prefix))
    image_arr = []
    mask_arr = []
    for index,item in enumerate(image_name_arr):
        img = io.imread(item,as_gray = image_as_gray)
        img = np.reshape(img,img.shape + (1,)) if image_as_gray else img
        mask = io.imread(item.replace(image_path,mask_path).replace(image_prefix,mask_prefix),as_gray = mask_as_gray)
        mask = np.reshape(mask,mask.shape + (1,)) if mask_as_gray else mask
        img,mask = adjustData(img,mask,flag_multi_class,num_class)
        image_arr.append(img)
        mask_arr.append(mask)
    image_arr = np.array(image_arr)
    mask_arr = np.array(mask_arr)
    return image_arr,mask_arr


def labelVisualize(num_class,color_dict,img):
    img = img[:,:,0] if len(img.shape) == 3 else img
    img_out = np.zeros(img.shape + (3,))
    for i in range(num_class):
        img_out[img == i,:] = color_dict[i]
    return img_out / 255


# 結果を保存する
def saveResult(save_path,npyfile,flag_multi_class = False,num_class = 2):
    for i,item in enumerate(npyfile):
        img = labelVisualize(num_class,COLOR_DICT,item) if flag_multi_class else item[:,:,0]
        io.imsave(os.path.join(save_path,"%d_predict.png"%i),img)

今回使用した関数について説明をしておく。

trainGenerator

学習用のデータが少ない時にデータ拡張をしてデータの水増しする。

尚、その際に元画像とラベルの両方を拡張する。

デフォルトだと学習用画像のファイル名に “image” 正解ラベルに “mask” のプリフィックスを付加すると同時にグレースケール化する。

色の情報を学習させるかは迷ったのだが周囲の葉、つるがほぼキュウリ本体と同じ色なので色による識別はそれほど重要では無いのでは?と考えた。

また、学習画像の解像度を上げたかったのでリソースを考慮してグレースケール化を行った。

testGenerator

指定された path配下にある pngファイルを順番に読んでリサイズして返す。

デフォルトだと 256 × 256 にリサイズするが、この関数を呼ぶ際に target_size パラメータを指定すればサイズは変更可能だ。

今回は 512 × 512 を使用した。

geneTrainNpy

指定された path配下に imageファイル(トレーニングデータ)とmaskファイル(正解ラベル)をセットにして配列で返す。

学習時に使用する。

saveResult

推論結果を保存する為の関数。

学習・推論

Google Colaboratory の実行スクリプトは以下の通り。

データ拡張

学習用にアノテーションした画像は30枚と少ないのでデータ拡張(水増し)を行うコード。

"""
trainフォルダーのimage、labelフォルダーからデータを読み込んでデータを拡張してaugフォルダーに保存する。

@souichirou kikuchi
"""
OBJ = 'Cucumber' # 学習する物体
GDRIVE = '/content/drive/MyDrive/unet-master' # Google colab

import sys # 自作モジュールへのPathを追加する
ROOT_PATH = GDRIVE
sys.path.append(ROOT_PATH)

from data import *

# データ拡張 trainから読み込んでデータ拡張してaugに保存する
data_gen_args = dict(rotation_range = 0.2, #回転
                    width_shift_range = 0.05, #水平移動
                    height_shift_range = 0.05, #垂直移動
                    shear_range = 0.05, #シアー変換
                    zoom_range = 0.05, #ズーム
                    horizontal_flip = True, #左右反転
                    fill_mode='nearest')
myGenerator = trainGenerator(20,
                             GDRIVE + '/data/' + OBJ + '/train','image','label',
                             data_gen_args,
                             save_to_dir = GDRIVE + '/data/' + OBJ + '/train/aug',
                             target_size = (512,512))
num_batch = 3 # 3回繰り返す
for i,batch in enumerate(myGenerator):
    if(i >= num_batch):
        break

補足説明

以下、上記のコードの補足説明。

OBJ = ‘Cucumber’

data フォルダー配下の認識したい物体の定数。

異なる物体を学習したい時に data フォルダ配下に同じ構成で学習用データを格納すれば動作するように定数で指定している。

GDRIVE

Google Colaboratory から Google Drive を参照する時の Path を指定する。

Google Drive のマウントと Path を知る方法は以下の通り。

Google Colaboratory の左のメニューからファイルアイコンを選択して”ドライブをマウント”アイコンをクリックする。

Drive への接続許可の確認が表示されたら「Google ドライブに接続」をクリックする。

Google ドライブに接続

sample_data と同階層に drive フォルダが表示される。

ドライブが表示される

drive フォルダの階層を辿って unet-master を選択したら、右クリックー>パスをコピーでGoogle Colaboratory から該当の Google Drive までの Path が取得できるので GDRIVE 定数にセットした。

パスをコピー

path の追加

9 ~ 11 行目は Google Colaboratory から Google Drive に格納した data.py や model.py を参照する為に Path を通している。

パラメータ

データ拡張の為のパラメータ。

このパラメーターに基づいてランダムに画像を生成して学習用画像のデータ量を増やす。

  • rotation_range:画像をランダムに回転する回転範囲
  • width_shift_range:ランダムに水平シフトする範囲
  • height_shift_range:ランダムに垂直シフトする範囲
  • shear_range:シアー強度
  • zoom_range:ランダムにズームする範囲
  • horizontal_flip:水平方向に入力をランダムに反転
  • fill_mode:指定されたモードに応じて入力画像の境界周りを埋める

fill_mode は以下の4種類が指定できる。

keras のドキュメントより。

“constant”: kkkkkkkk|abcd|kkkkkkkk (cval=k)
“nearest”: aaaaaaaa|abcd|dddddddd
“reflect”: abcddcba|abcd|dcbaabcd
“wrap”: abcdabcd|abcd|abcdabcd

trainGenerator

data.py 内の関数。

内部では keras の ImageDataGenerator クラスを呼び出してデータを拡張している。

デフォルトで生成する画像のサイズは 256×256 なので、target_size = (512,512) を指定して 512×512 Pixel の画像を生成している。

学習

学習用データが揃ったら U-Net で学習を行う。

"""
augフォルダーの画像を学習して最適なモデルを保存する

@souichirou kikuchi
"""
VERSION = '_v01' # モデルのバージョン
OBJ = 'Cucumber' # 学習する物体
GDRIVE = '/content/drive/MyDrive/unet-master' # Google colab

import sys # 自作モジュールへのPathを追加する(colabで必要)
ROOT_PATH = GDRIVE
sys.path.append(ROOT_PATH)

from model import * # u-net model
from data import *

# u-netで学習
model = unet(input_size = (512, 512, 1)) # Input size指定

# チェックポイント
cp = ModelCheckpoint(GDRIVE + '/data/' + OBJ + '/model/unet_' + OBJ + VERSION + '.hdf5', # チェックポイント
                     monitor = 'val_loss', # 監視する値
                     verbose = 1, # 結果表示
                     save_best_only = True)

#過学習を防ぐための早期終了
es = EarlyStopping(monitor = 'val_loss',
                   patience = 50, # このepoch数、性能が向上しなければストップ
                   restore_best_weights = True)

imgs_train,imgs_mask_train = geneTrainNpy(GDRIVE + '/data/' + OBJ + '/train/aug/', GDRIVE + '/data/' + OBJ + '/train/aug/')
history = model.fit(imgs_train,
                    imgs_mask_train,
                    batch_size = 4,
                    epochs = 500,
                    verbose = 1,
                    validation_split = 0.2, 
                    shuffle = True,
                    callbacks = # チェックポイント、早期終了
                    [cp,
                     es])

import matplotlib.pyplot as plt

# Plot training & validation accuracy values
plt.plot(history.history['accuracy'])
plt.plot(history.history['val_accuracy'])
plt.title('Model accuracy')
plt.ylabel('accuracy')
plt.xlabel('Epoch')
plt.legend(['Train', 'Test'], loc='upper left')
plt.show()

# Plot training & validation loss values
plt.plot(history.history['loss'])
plt.plot(history.history['val_loss'])
plt.title('Model loss')
plt.ylabel('Loss')
plt.xlabel('Epoch')
plt.legend(['Train', 'Test'], loc='upper left')
plt.show()

補足説明

以下、上記のコードの補足説明。

VERSION

学習済みモデルを保存する時のバージョン。

何度かパラメータを変更して学習を繰り返す際に以前のバージョンのモデルを残しつつ新しいパラメータで試したい時がある。

その際にモデルのファイル名に VERSION を埋め込んで管理する。

ModelCheckpoint

学習途中の最良のモデルを保存するためにチェックポイントを指定した。

monitor は監視する値を指定する。

  • acc:正解率
  • loss:損失(正解との誤差)
  • val_acc:バリデーションデータの正解率
  • val_loss:バリデーションデータの損失

が指定可能だが val_loss を指定した。(つまり Val_loss が最適なモデルだけを保存する)

後述するが学習用データはトレーニング用に8割、バリデーション(検証)用に2割をランダムに割り当てている。

トレーニングデータでの損失(正解との誤差)が少ないよりもバリデーションデータでの損失が少ないほうが汎用性が高いと判断して val_loss を指定している。

save_best_only は True を指定しているので上記の val_loss が最良のモデルだけを保存する。

この辺の事は先程の書籍の10章(人口ニューラルネットワークとKerasの初歩)に載っている。

EarlyStopping

学習が進まなかった時に早期に終了するように EarlyStopping を指定した。

具体的には patience で指定した 50 エポック連続して val_loss が向上しなった際には学習を停止する。

model.fit

fit メソッドで学習を行う。

epochs は Checkpoint と EarlyStopping を指定しているので最後まで進むことはまず無いと判断して大きめの値(500)を指定した。

validation_split でバリデーション(検証用)データの割合を指定する。

0.2 なのでトレーニング用8割、検証用2割となる。

shuffle = True を指定することでエポックごとに訓練データがシャッフルされてランダムな学習が行われる。

学習結果の表示

45行目以降で学習結果(推移)を表示してる。

正解率の推移
正解率の推移

オレンジ色の test と表示されているのは Validation データの事(紛らわしいラベル名にしてしまった)

ある一定の所で train(学習用)データの正解率が向上しても Validation データの正解率が向上しなくなってしまっている事が分かる。

損失の推移
損失の推移

val_loss が指数関数的にゼロに近づいて収束するようなグラフになるのが理想なのだが、残念ながらそうならなかった。

学習データの量など様々な要因が考えられるのでもう少し試行錯誤が必要だと思う。

特に60 エポックを過ぎた辺りから train データの損失が減っているのにも関わらず Validation データの損失が上がるという過学習の傾向が出ている。

テスト画像変換

未学習の画像を10枚ピックアップして学習済みモデルでキュウリを正しく認識できるかを試す。

その前に元画像は 5472 x 3080 Pixels の RGB、JPG画像なので推論しやすい様に 512 × 512 のグレースケール、pngファイルに変換する。

test_org フォルダー内の JPG画像を全て読み込んでリサイズして test フォルダーに出力している。

またその際にファイル名を 0.png、1.png と数値の連番のファイル名に変更している。

"""
test_org配下の画像を読み込んでリサイズ、グレースケール化してtestフォルダに保存する

@souichirou kikuchi
"""

OBJ = 'Cucumber' # 学習する物体
GDRIVE = '/content/drive/MyDrive/unet-master' # Google colab

import argparse
import cv2
import os
import glob

input_dir = GDRIVE + '/data/' + OBJ + '/test_org'
output_dir = GDRIVE + '/data/' + OBJ + '/test/'

try:
    files = glob.glob(input_dir + '/*.JPG')
    i = 0
    for file in files:
        infile = cv2.imread(file)
        ofile_name = output_dir + str(i) + '.png' # ファイル名を0からの連番、拡張子をpngに変更
        infile = cv2.resize(infile, dsize=(512, 512)) # リサイズ
        otfile = cv2.cvtColor(infile, cv2.COLOR_BGR2GRAY) # グレースケール
        cv2.imwrite(ofile_name, otfile)
        print('ofile_name = ', ofile_name)
        i += 1
finally:
    pass

推論

保存しておいた学習済みモデルを呼び出して推論(画像からキュウリの位置を判別する)を行う。

2024/01/16 追記

saveResult の仕様が変わったのでコードを一部修正

"""
test配下の画像を読み込んで推論する

@souichirou kikuchi
"""

VERSION = '_v01' # モデルのバージョン
OBJ = 'Cucumber' # 学習する物体
SAMPLE = 10 # ファイル数
GDRIVE = '/content/drive/MyDrive/unet-master' # Google colab

import sys # 自作モジュールへのPathを追加する(colabで必要)
ROOT_PATH = GDRIVE
sys.path.append(ROOT_PATH)

from model import * # u-net model
from data import *
import numpy as np
from PIL import Image
import os

def saveResultx(save_path, npyfile, flag_multi_class=False, num_class=2):
    for i, item in enumerate(npyfile):
        img = item[:,:,0]
        img = (img - np.min(img)) / (np.max(img) - np.min(img))  # 正規化
        img = np.uint8(img * 255)  # 0から255の範囲にスケーリング
        im = Image.fromarray(img)
        if not os.path.exists(save_path):
            os.makedirs(save_path)
        im.save(os.path.join(save_path, "%d_predict.png"%i))

# 学習済みモデルの読み込み
from tensorflow import keras
model = keras.models.load_model(GDRIVE + '/data/' + OBJ + '/model/unet_' + OBJ + VERSION + '.hdf5')

# testフォルダーのデータを呼び出す
testGene = testGenerator(GDRIVE + '/data/' + OBJ + '/test',
                         target_size = (512, 512))

results = model.predict(testGene,
                        steps = SAMPLE, # サンプルの総数
                        verbose = 1)
# 予測結果を_predict.pngで保存
saveResultx(GDRIVE + '/data/' + OBJ + '/test', results)

補足説明

以下、上記のコードの補足説明。

SAMPLE

推論したい画像のファイル数。

load_model

保存しておいた val_loss が最適なモデルを呼び出す。

testGenerator

test フォルダー配下の png ファイルを順番に読んでリサイズして返す。

predict

予測を行う。

saveResult

予測した結果画像を保存する関数。

結果

10枚の画像を予測させてみた所、「まぁまぁ」の精度で予測が出来た。

まぁまぁと言うのは精度が良い画像とそうでない画像でばらつきが出てしまったからだ。

精度が良い画像の例
良い精度の例1

その2

上記の画像についてはほぼ正しいキュウリの位置が判別出来ている。

全体の 7 ~ 8割の画像が上記のような精度だった。

精度が悪い画像の例
精度が悪い例

こちらは手で掴んで横にしている。

元々キュウリを横にした時の画像は学習していないのと、前述の rotation_range でも 0.2 を指定していて、縦に上からぶら下がったキュウリのみを学習しているのでこれは致し方ない所。

この画像を正しく判別するには学習用画像を工夫する必要がある。

精度が悪い例2

こちらの画像も精度が悪い例。

キュウリの右側に光が当たって反射している部分がキュウリの本体として認識されていない。

学習用データを見直した所、上記の様な光を反射して明るくなっている画像は見当たらなかった。

学習用データのバリエーションをもっと増やして学習させる必要がある。

その他

U-Net でも試行錯誤を繰り返すことで精度を向上させる事が可能だと分かった。

またU-Net以外では、SegNet や PSPNet(Pyramid Scene Parsing Network)は試してみたい。

以上で今回の記事は終了とする。

最後に

この記事が何処かで誰かの役に立つことを願っている。

尚、当記事中の商品へのリンクはAmazonアソシエイトへのリンクが含まれています。Amazonのアソシエイトとして、当メディアは適格販売により収入を得ていますのでご了承ください。

souichirou

やった事を忘れない為の備忘録 同じような事をやりたい人の参考になればと思ってブログにしてます。 主にレゴ、AWS(Amazon Web Services)、WordPress、Deep Learning、RaspberryPiに関するブログを書いています。 仕事では工場に協働ロボットの導入や中小企業へのAI/IoT導入のアドバイザーをやっています。 2019年7月にJDLA(一般社団法人 日本デイープラーニング協会)Deep Learning for GENERALに合格しました。 質問は記事一番下にあるコメントかメニュー上部の問い合わせからお願いします。

10件のフィードバック

  1. ひで より:

    とても親切な解説で勉強になります!!
    大変恐縮なのですが、モデルの学習のコードでエラーが出てしまします、、、
    お力添えいただけないでしょうか???
    google calaboratory環境で、エラーは

    NameError Traceback (most recent call last)
    in
    25
    26 #過学習を防ぐための早期終了
    —> 27 es = EarlyStopping(monitor = ‘val_loss’,
    28 patience = 50, # このepoch数、性能が向上しなければストップ
    29 restore_best_weights = True)

    NameError: name ‘EarlyStopping’ is not defined

    です、、、

    • souichirou より:

      ひでさん コメントありがとうございます。

      githubからダウンロードした model.py は正しく読み込まれていますか?
      model.pyの9行目に

      from tensorflow.keras.callbacks import ModelCheckpoint, EarlyStopping, LearningRateScheduler

      の行がありますが、ここでEarlyStoppingを読み込んでいます。
      何らかの原因でtensorflow.kerasのcallback関数であるEarlyStopping が読み込めなかったので not define(定義されていない)になっているのだと思います。

      • ひで より:

        ご返信頂き、ありがとうございます。
        なぜ読み込まれていなかったのか謎ですが、、、

        from keras.callbacks import ModelCheckpoint, EarlyStopping
        をコードの1行目に直接追記することで、とりあえずは解決しました。。。

        model.pyの方も再度確認してみます!
        また今後つまづいた際お力添えいただけると幸いです。

        お忙しいところご返信頂きありがとうございました。
        ブログ応援しています。

        • souichirou より:

          ひで さん

          model.py ですが zhixuhao氏のgithubを使わせて貰っています。
          自分が記事を書いた時のmodel.pyと若干内容が変わっている様ですね。
          今のコードでは EarlyStopping を読み込む行が無くなっていましたね。

  2. K より:

    とても参考になる記事、ありがとうございます。
    大変恐縮ですが、エラーが出てしまう点がありお伺いしたいです。
    google calaboratory環境で、推論時に以下のエラーが生じてしまいます。

    —————————————————————————
    KeyError Traceback (most recent call last)
    /usr/local/lib/python3.10/dist-packages/PIL/PngImagePlugin.py in _save(im, fp, filename, chunk, save_all)
    1298 try:
    -> 1299 rawmode, mode = _OUTMODES[mode]
    1300 except KeyError as e:
    KeyError: ‘F’

    The above exception was the direct cause of the following exception:

    OSError Traceback (most recent call last)
    9 frames
    /usr/local/lib/python3.10/dist-packages/PIL/PngImagePlugin.py in _save(im, fp, filename, chunk, save_all)
    1300 except KeyError as e:
    1301 msg = f”cannot write mode {mode} as PNG”
    -> 1302 raise OSError(msg) from e
    1303
    1304 #
    OSError: cannot write mode F as PNG
    —————————————————————————

    素人のため何をしたらいいか見当もつかない状況です…
    お手数をおかけしてしまい申し訳ございませんが、お力添えいただけますと幸いです。

    • souichirou より:

      予測結果の画像を保存する際にエラーとなっているようですね。
      GDRIVE + ‘/data/’ + OBJ + ‘/test’, results)
      ※OBJ は “Cucumber”
      のコードで保存しているのですが、フォルダーを予め作成しておいたらどうでしょう。

      • K より:

        ご返信いただき、ありがとうございます。
        ご教示いただいたコードでは、結果はtestフォルダーに保存されるかと思いますがうまくいかず、結果保存用のフォルダーを作成して実行してみましたが同様のエラーが生じました。
        何度も大変申し訳ございませんが、何か改善方法等ご存じでしたら、お力添えいただけますと幸いです…

        • souichirou より:

          確認できました。
          saveResult の仕様が変わってチェックが厳しくなり、0~1の浮動小数点数(resultsの結果)の配列だとpngファイルに保存できなくなった様です。
          0~255の数値に変換して保存する必要があるようです。
          以下のコードを from data import * の下に入れてください。

          import numpy as np
          from PIL import Image
          import os
          
          def saveResultx(save_path, npyfile, flag_multi_class=False, num_class=2):
              for i, item in enumerate(npyfile):
                  img = item[:,:,0]
                  img = (img - np.min(img)) / (np.max(img) - np.min(img))  # 正規化
                  img = np.uint8(img * 255)  # 0から255の範囲にスケーリング
                  im = Image.fromarray(img)
                  if not os.path.exists(save_path):
                      os.makedirs(save_path)
                  im.save(os.path.join(save_path, "%d_predict.png"%i))

          そしてsaveResult を saveResultx に変更して実行してみてください。
          ※元記事も修正しておきました

          • K より:

            ご返信ありがとうございます。
            ご教示いただきました通り修正したところ、エラーなく動作し結果を保存することができました。
            また、エラー内容や修正方法についても、とても勉強になりました。
            お忙しいところ丁寧にご教示いただき、ありがとうございました。

          • souichirou より:

            無事に動作したようで何よりでした!

souichirou へ返信する コメントをキャンセル

名前、メール、サイト欄は任意です。
またメールアドレスは公開されません。