MATLABを使ってみませんか?

第3回:DeepLabv3+を用いた転移学習

設備・情報技術室 AI・メカトロニクス班
木庭 洋介

皆様はMATLABというソフトウェアをご存知でしょうか。MATLABはアルゴリズム開発、データ解析、可視化、数値計算のための統合開発環境です。様々な分野に対応したToolbox(アドオン製品)があり、これらを利用することで、複雑な処理を簡潔なコードで実現できます。
九州大学情報統括本部は、「MATLAB Campus-Wide License(包括ライセンス契約)」の運用を2023年1月4日から開始しました。これにより、MATLAB/Simulinkおよび100以上のToolbox(アドオン製品)の他オンラインツールやサービスを、教職員は有償ですが、学生は無償(!!)で利用できるようになりました。ここでは、「MATLABでこういう事ができます」ということをご紹介していきたいと思います。

今回は以下のアドオン製品を使用します。
Image Processing Toolbox
Computer Vision Toolbox
Deep Learning Toolbox

はじめに

今回は、セマンティックセグメンテーションのためのディープニューラルネットワーク「DeepLabv3+」を転移学習させることで空中写真から水部を抽出するネットワークを作りたいと思います。

セマンティックセグメンテーションとは、画像内の画素毎にクラス分類するアルゴリズムです。例えば、人物と犬が写った画像から人物、犬、背景を分割することができます。この技術は、自動運転、医療用画像解析、工業検査など広い分野で使用されています。そして、DeepLabv3+は、このセマンティックセグメンテーションのためのネットワークとして2018年に公開されたもので、従来の手法より高速で強力だとうたっています。ただし、自分が望むクラス分類を行うためにゼロからネットワークを学習させる場合、大量の教師データ(画像と画素毎にどのクラスに属するかラベル付けしたラベル画像)を用意しなければなりません。

そこで、転移学習という手法を利用します。これは、あらかじめ既存の教師データで精度よく分類できるように学習させたネットワークを初期値にして、自分が望むクラス分類用に再学習させる方法です。既存の教師データと自分が準備する教師データの分類クラスが違いすぎると精度が上がらないという問題がありますが、再学習に必要な教師データと学習コストが少なくて済むという大きなメリットがあるため、試してみる価値がある手法だと思います。

MathWorksは、このDeepLabv3+を使ってPASCAL VOCデータセットから20のクラスを検出するように訓練されているネットワークを提供しています。今回は、これを国土地理院が公開している「CNNによる地物抽出用教師データセット」で転移学習させ、空中写真から水部を抽出するネットワークを作っていきます。

事前学習済みDeepLabv3+ネットワークのダウンロード

以下のリンク先ページの「Code」ボタンをクリックすると「Download ZIP」という項目が現れますので、それをクリックして「pretrained-deeplabv3plus-main.zip」をダウンロードします(下図参照)。

> DeepLabv3+ inference and training in MATLAB for Semantic Segmentation

「pretrained-deeplabv3plus-main.zip」を展開すると「pretrained-deeplabv3plus-main」というフォルダができますので、MATLABでそこまで移動します。MATLABの「現在のフォルダー」タブは以下のようになっていると思います。

コマンドウィンドウで以下のスクリプトを打つと、modelフォルダの下に「deepLabV3Plus-voc.mat」ファイルがダウンロードされます。このファイルを自分の作業フォルダにコピーして使います。

model = helper.downloadPretrainedDeepLabv3Plus;

データセットのダウンロード

以下のリンク先で、国土地理院が提供する「CNNによる地物抽出用教師データセット」がダウンロードできます。これは、空中写真画像を対象とした、道路、水部、太陽光発電設備などの地物をセマンティックセグメンテーションで抽出する際に用いる学習データセットです。今回はこの中の「水部」データセットを使います。

> CNNによる地物抽出用教師データセット(国土地理院)

水部のページに移動し、ダウンロードの項目から「H1-No18-572.zip」をダウンロードし展開します。展開された「H1-No18-572」フォルダの下には「org」、「val」というフォルダがあります。「org」フォルダには空中写真画像、「val」フォルダには水部を青色(RGB:#0000FF)で塗ったラベル画像が保存されています。

転移学習による水部を抽出するネットワークの作成

それでは、水部を抽出するネットワークを作っていきます。今回の例では以下のような作業フォルダ構成にしています。「deepLabV3Plus-voc.mat」は事前学習済みネットワーク、「imagefiles」というフォルダ以下に水部データセット「H1-No18-572」があります。

データセットの準備

データセットは画像とラベル画像のペアで構成されるのですが、それぞれの画像サイズが揃っていないといけません。今回ダウンロードした水部データセットは、ラベル画像(valフォルダ内)の「554.png」だけサイズが574×574ピクセルになっています(他の画像はすべて572×572ピクセル)。ですので、画像とラベル画像の「554.png」は削除します。その後、データセットのリストを作成します。

% データフォルダのパスを指定
dataFolderPath = fullfile('./', 'imagefiles', 'H1-No18-572');

% データセットのリストを作成
imageFiles = dir(fullfile(dataFolderPath, 'org', '*.png')); % 空中写真画像
valFiles = dir(fullfile(dataFolderPath, 'val', '*.png')); % ラベル画像

ラベル画像の修正

水部データセットのラベル画像は水部を青色で塗っていますが、それ以外の部分は写真のままです。水部以外は「その他」というクラスにしたいので、水部以外を黒色(RGB:#000000)で塗った画像を新たに作成し、「label」というフォルダに保存します。

% 「label」フォルダがなければ作成する
if exist(fullfile(dataFolderPath, 'label'), "dir") ~=7
    mkdir(fullfile(dataFolderPath, 'label'))
end

for i=1:length(valFiles)
    % オリジナルラベル画像を読み込む
    tmpimg = imread(fullfile(valFiles(i).folder, valFiles(i).name));
    % 水部以外の画素を取得
    mask = ~all(tmpimg == reshape([0 0 255], [1 1 3]), 3);
    % 水部以外を黒にする
    tmpimg(repmat(mask, [1 1 3])) = 0;
    % 'label'という名前のフォルダに新たに作ったラベル画像を保存
    imwrite(tmpimg, fullfile(dataFolderPath, 'label', valFiles(i).name));
end

データストアを作成する

機械学習などで大量のデータを扱う場合、全データをメモリに読み込もうとするとメモリに収まらない恐れがあります。そこで、ファイル情報などを格納したデータストアというリポジトリを作成します。これを使うことでメモリに収まるサイズで読み込み、学習させるということが簡単にできます。

画像データのデータストアの作成にはimageDatastore関数を使います。各画像データのファイルパスを格納したcell配列を引数として与えます。ラベル画像のデータストアの作成にはPixelLabelDatastore関数を使います。各ラベル画像データのファイルパスを格納したcell配列とクラス名、クラス名と関連付ける値を引数として与えます。今回の例では、「Water」(水部)と「Other」(その他)の2つのクラスを学習させます。水部は青色、その他は黒色で塗っていますので、その色に対応したRGB値[0 0 255]、[0 0 0]を設定します。

% ラベル画像のリストを作成
labelFiles = dir(fullfile(dataFolderPath, 'label', '*.png'));
numImg = length(labelFiles);

% 画像とラベル画像までのパスを格納したセル配列を作成する
imageFilepaths = cell(numImg, 1);
labelFilepaths = cell(numImg, 1);
for i=1:numImg
    imageFilepaths{i} = fullfile(imageFiles(i).folder, imageFiles(i).name);
    labelFilepaths{i} = fullfile(labelFiles(i).folder, labelFiles(i).name);
end

classes = ["Water" "Other"]; % クラス名の設定
pixelLabelID = {[0 0 255] [0 0 0]}; % クラスに対応するラベル値の設定

% 画像データストアの作成
imds = imageDatastore(imageFilepaths);
% ラベル画像データストアの作成
pxds = pixelLabelDatastore(labelFilepaths, classes, pixelLabelID);

データセットの解析

countEachLabel関数を使うとラベル画像に含まれるクラスごとのピクセル数をカウントできます。出力のNameはクラス名、PixelCountはそのクラスの総ピクセル数、ImagePixelCountはそのクラスを含む画像の総ピクセル数を表します。PixelCountを見ると、水部よりその他が4.4倍程度多いことがわかります。クラスに偏りがあると学習精度に悪影響を与えますので、分類層に重み付けして偏りの影響を低減します(後述)。ImagePixelCountが異なるのは、水部だけの画像や水部がまったく存在しない画像があるためです。

tbl = countEachLabel(pxds)
tbl =
  2×3 table
      Name       PixelCount    ImagePixelCount
    _________    __________    _______________
    {'Water'}    7.5727e+07      2.2608e+08   
    {'Other'}    3.3293e+08      3.9262e+08   

データストアを学習、検証、テスト用データストアに分割

学習、検証、テスト用にデータストアを60%、20%、20%の割合で分割します。まずdividerand関数を使い、1から全データ数までの数値を60%、20%、20%の割合でランダムに分割します。この分割された数値をファイルインデックスとし、全データストアからそれぞれのデータストアへ抽出します。

% 各データストア用にランダムなインデックスの作成
[IndTrain, IndVal, IndTest] = dividerand(length(imds.Files), 0.6, 0.2, 0.2);

% データストアの作成
imdsTrain = imageDatastore(imds.Files(IndTrain));
pxdsTrain = pixelLabelDatastore(pxds.Files(IndTrain), classes, pixelLabelID);
imdsVal = imageDatastore(imds.Files(IndVal));
pxdsVal = pixelLabelDatastore(pxds.Files(IndVal), classes, pixelLabelID);
imdsTest = imageDatastore(imds.Files(IndTest));
pxdsTest = pixelLabelDatastore(pxds.Files(IndTest), classes, pixelLabelID);

データ数を確認すると、学習データ数(numTrainingImages)が749、検証データ数(numValImages)が250、テストデータ数(numTestingImages)が250となっています。

% データ数の確認
numTrainingImages = numel(imdsTrain.Files)
numValImages = numel(imdsVal.Files)
numTestingImages = numel(imdsTest.Files)
numTrainingImages = 749
numValImages = 250
numTestingImages = 250

以上で、データセットの準備は終わりました。次は転移学習のためにネットワークの修正を行います。

事前学習済みネットワークの修正

まず、事前学習済みネットワークを読み込み、そこからグラフオブジェクトを抽出します。

load('deepLabV3Plus-voc.mat');
lgraph = layerGraph(net);

変数lgraphがグラフオブジェクトです。ネットワークを構成する各層の情報や層同士の接続情報が含まれており、これを編集することで自分の望む形にネットワークを修正することができます。

analyzeNetwork関数は下図のようにネットワークの可視化や解析ができ、ネットワークに問題があれば、エラーを出力してくれます。出力を見ると、376層のネットワークでパラメータ数は58.8M、入力層のタイプは画像で、そのサイズは513×513×3であることがわかります。

analyzeNetwork(lgraph);

それでは、ネットワークの修正を行います。まず、最後の畳み込み層を水部、その他の2クラスに対応させます。lgraphやanalyzeNetwork関数で最後の畳み込み層を調べると、’node_398’という名前であることがわかりました。その層を新しく作った畳み込み層に置き換えます。

numClasses = numel(classes); % 新しいクラス数の取得

% 新しい畳み込み層を作成する
convLayer = convolution2dLayer([1 1], numClasses,'Name', 'node_398');
% 畳み込み層を置き換える
lgraph = replaceLayer(lgraph,"node_398",convLayer);

次に、ピクセル分類層を2クラス用に修正します。このとき、データセットにおけるクラスの偏りの影響を少なくするためにクラスごとに重み付けをします。

% 各クラスに与える重みを計算する
imageFreq = tbl.PixelCount ./ sum(tbl.PixelCount);
classWeights = median(imageFreq) ./ imageFreq;

% ピクセル分類層を、重み付けした新しい分類層で置き換える
pxLayer = pixelClassificationLayer('Name','labels','Classes',tbl.Name,'ClassWeights',classWeights);
lgraph = replaceLayer(lgraph,"labels",pxLayer);

修正後はanalyzeNetwork関数で望んだとおりに修正できているか確認しましょう。続いて、学習オプションの設定をします。

学習オプションの設定

ネットワークの入力層のサイズは513×513×3ですが、水部データセットの画像サイズは572×572×3ですので、このまま与えるとエラーになります。そこで、randomPatchExtractionDatastore関数を使って、データセットのランダムな位置で513×513×3サイズの画像を抽出するためのデータストアを作成します。引数に画像とラベル画像のデータストア、切り取るサイズ([513 513])、抽出する画像数(’PatchesPerImage’)を与えます。’PatchesPerImage’を大きくすると1枚の画像から抽出する画像数が増やせますので、例えば、大きな画像からランダムな位置で切り取った小さな画像をたくさん作るといった使い方ができます。また、’DataAugmentation’オプションを使えば、ランダムな画像の回転や反転といった前処理を適用することもできます。これは、ネットワークの過適合の防止に役立ちます。詳しくは以下のリンクを参照してください。

> randomPatchExtractionDatastore

dsTrain = randomPatchExtractionDatastore(imdsTrain,pxdsTrain, [513 513], 'PatchesPerImage',1);
dsVal = randomPatchExtractionDatastore(imdsVal,pxdsVal,[513 513],'PatchesPerImage',1);

学習オプションは以下のように設定しました。今回、ファインチューニングという手法を使います。これは事前学習済みネットワークのパラメータを初期値として、新しいクラスに対応するようにパラメータを微調整するものです。初期学習率(’InitialLearnRate’)を小さくすることで実現します。また、’LearnRateDropPeriod’を5、’LearnRateDropFactor’を0.5に設定し、5エポック経過するごとに学習率を0.5倍するようにしました。これは、学習が進むほどパラメータの変化が少なくなるようにするためです。

最大エポック数(’MaxEpochs’)は10、出力するネットワーク(’OutputNetwork’)は学習終了時点で最小の検証損失を出したネットワークにしています。その他、いくつか設定をしていますが、学習オプションの詳細は以下のリンクを参考にしてください。

> trainingoptions

options = trainingOptions('sgdm', ...
    'LearnRateSchedule','piecewise',...
    'LearnRateDropPeriod',5,...
    'LearnRateDropFactor',0.5,...
    'Momentum',0.9, ...
    'InitialLearnRate',0.001, ... % 初期学習率を小さくする
    'L2Regularization',0.005, ...
    'ValidationData',dsVal, ...
    'MaxEpochs',10, ...
    'MiniBatchSize',6, ... % メモリエラーが出るときはサイズを減らす
    'Shuffle','every-epoch', ...
    'CheckpointPath', tempdir, ...
    'VerboseFrequency',50, ...
    'ValidationFrequency',50, ...
    'Plots','training-progress', ...
    'OutputNetwork','best-validation-loss', ...
    'ExecutionEnvironment','gpu' ...
    );

学習開始

それでは、学習させてみましょう。以下のコマンドを実行すると、下図のようなウインドウが開き、学習の進捗状況を表示します。10GBのメモリを搭載したNVIDIA GeForce RTX 3080で計算させましたが、約30分かかりました。GPUメモリがこれよりも少ない場合はメモリ不足が発生すると思います。その時は学習オプションの’MiniBatchSize’を小さくしてください。それでも不足する場合は、層数の少ないネットワークへの変更や、入力層のサイズ縮小を検討する必要があります。

[net, info] = trainNetwork(dsTrain,lgraph,options);

テストイメージを使ったネットワークの評価

最後に、テストイメージを使って、ファインチューニングされたネットワークのテストをします。pxdsResultsにセマンティックセグメンテーションの結果が出力されます。その結果と正解ラベルを比較するのがevaluateSemanticSegmentation関数です。metricsにはConfusionMatrix、NormalizedConfusionMatrix、DataSetMetrics、ClassMetrics、ImageMetricsの5つの評価結果が出力されます。それぞれの評価項目については以下のリンクを参考にしてください。今回はClassMetricsを出力させています。

> semanticSegmentationMetrics

pxdsResults = semanticseg(imdsTest,net,"WriteLocation",tempdir); % テストの実行

% 分類結果の評価
metrics = evaluateSemanticSegmentation(pxdsResults,pxdsTest);
metrics.ClassMetrics % 各クラスの評価(Accuracy、IoU、MeanBFScore)
GlobalAccuracy    MeanAccuracy    MeanIoU    WeightedIoU    MeanBFScore
______________    ____________    _______    ___________    ___________
0.91578          0.92705       0.79753      0.85466        0.72192  

テスト結果のうち一つを抽出し、正解ラベルと重ねて表示させてみます。下図は左からテスト画像、正解ラベル画像、テスト結果、正解ラベルとテスト結果の比較画像です。正解ラベルとテスト結果は水部のみ青色になるように表示させています。正解ラベルとテスト結果の比較画像は水部を正しく推定できている部分を白色、その他を正しく推定できている部分を黒色、水部をその他と推定している部分をマゼンタ、その他を水部と推定している部分を緑色で表示させています。

resNo = 1; % 表示させるテスト結果のインデックス番号
resImage = readimage(imdsTest, resNo); % 画像の読み込み
resLabel = readimage(pxdsTest, resNo); % 正解ラベルの読み込み
C = imread(pxdsResults.Files{resNo}); % テスト結果の読み込み

cmap = [0 0 255; 0 0 0] ./255; % カラーマップの設定
% 画像と正解ラベルを重ねた画像の作成
GroundTruth = labeloverlay(resImage,resLabel,'Colormap',cmap,'Transparency',0.6);
% 画像とテスト結果を重ねた画像の作成
Estimated = labeloverlay(resImage,C,'Colormap',cmap,'Transparency',0.6);

tiledlayout(1, 4, "TileSpacing", "tight")
nexttile; imshow(resImage) % 画像の表示
title('テスト画像')
nexttile; imshow(GroundTruth) % 正解ラベルの表示
title('正解')
nexttile; imshow(Estimated) % テスト結果の表示
title('推定結果')
nexttile; imshowpair(C == find(classes == 'Water'), resLabel=='Water') % 正解とテスト結果の比較
title('正解と推定結果の比較')


評価の結果をみると、精度はそこまで高くはありませんでした。今回使った事前学習済みネットワークは、乗り物、家具、動物などで学習させたものですので、地物とは違いすぎたのが原因かもしれません。あるいは、ファインチューニング以外の方法で良くなるかもしれませんし、学習オプション(各種パラメータや損失関数)を変えると良くなるかもしれません。いろいろと検討事項はありますが、今回はこれで終了とさせていただきます。

おわりに

MATLABを使って転移学習を行う流れについてご紹介しました。本記事ではMATLABが提供する事前学習済みのDeepLabv3+ネットワークを使用しましたが、TensorFlowやPyTorchなど他のモデル形式のものもインポートして使用することができます。詳しくは以下のリンクをご参考にしてください。

> ニューラル ネットワークのインポートとエクスポート

最後までお読みいただきありがとうございました。本記事ではご理解できない点が多々あるかと思いますので、ご遠慮なく技術部までご相談いただきますようよろしくお願い申し上げます。また、内容に誤りがございましたらお教えいただけますと幸いです。