マイブログ リスト

医療言語処理講座

2019年2月25日月曜日

Pyゼミ3.07 ChainerでGPUを使って学習してみる

GPUによる高速な学習を試してみる。


keyword: Chainer, GPU, cupy


大量のデータを使って学習するにはGPU(Graphics Processing Uni)は必須です。
CPUの演算より約10倍も早くなります。
いろいろパラメータやデータなどを変えて実験するにはGPUはなくてはならないハードウェアです。

これまで作成したAIのプログラム(Pyゼミ3.05など)の修正点について説明します。
GPUを使うための環境の構築については説明しません。
環境の構築はインターネット上にたくさんの情報がりますので,OS,CUDAやその他のドライバーなどのバージョンを確認しながら構築してください。


前回Pyゼミ3.05のプログラムをGPU対応にする


ChainerのプログラムをGPU対応するには次の3点の修正を加えます。

  1. numpyの代わりにcupyを使う
  2. modelをGPUに対応させる
  3. UpdaterをGPUに対応させる

これだけです。それぞれについて具体的に説明します。

1. numpyの代わりにcupyを使う

cupyはGPUで演算させるためのライブラリで,行列演算などを効率的に行うnumpyと互換性を持っています。
つまり,numpyで記述しているプログラムをcupyに書き換えればよいわけです。

numpyは次のようにnpという名前でインポートしています。
    import numpy as np

また,GPUの使用,未使用をスイッチするために,uses_device変数を初期化します。
未使用のときはー1を,使用時は0以上の値(通常は0)を設定します。
    uses_device = 0     # GPU 0〜、CPU -1

さて,GPU使用時にnumpyをcupyに書き換える方法ですが,
次のように,GPU使用時(>=0)に,cupyをcpとしてインポートし,
npにcpを代入するだけで,プログラムはGPU用に書き換えられました。
    if uses_device >= 0:
        import cupy as cp
        np = cp


2. modelをGPUに対応させる

ニューラルネットワークのモデルをいつも通りに生成します。
    model = Alexnet(nlbl)

このモデルをGPUに対応させます。
GPU使用時(>=0)には以下の行を実行し,モデルがGPUを使って演算できるようにします。
    if uses_device >= 0:
      chainer.cuda.get_device_from_id(0).use()
      chainer.cuda.check_cuda_available()
      model.to_gpu()


3. UpdaterをGPUに対応させる


パラメータ更新を担当するUpdaterをGPUに対応させます。
引数にdevice=uses_deviceを追加するだけです。
    updater  = training.StandardUpdater(t_iterator, self.opt, device=uses_device) 

また,Pyゼミ3.05のプログラム中のEvaluatorも同様に引数にdevice=uses_deviceを追加します。
    trainer.extend(extensions.Evaluator(v_iterator, self.model, device=uses_device))

以上,3つの追加修正によりGPUに対応したプログラムになります。


プログラムを入力して実行してみよう


このプログラムはPy3.05が基本です。これに3つの修正が加わっています。

dicomAlexnetTrainGPU.py

import os, sys
import numpy as np
import cv2
import chainer
from chainer import Function, report, training, utils, Variable
from chainer import datasets, iterators, optimizers, serializers
from chainer import Link, Chain, ChainList
import chainer.functions as F
import chainer.links as L
from chainer.datasets import tuple_dataset
from chainer.training import extensions
from chainer.datasets import split_dataset_random #new
import pydicom

uses_device = 0     # GPU 0〜、CPU -1

if uses_device >= 0:
    import cupy as cp
    np = cp

def constractTrainData( fname , resz ):
    image = []
    label = []
    for i, line in enumerate(open(fname, 'r')):
        data = line.split(",")        
        print("Train",i, data[0], "Label#",data[1])
        img = readDicom2png(data[0], "tmp.png")
        if resz > 0:                        
            img = cv2.resize(img,(resz, resz))      
        img = img.astype(np.float32)  
        img = (img - 128)/128           #画素値0-255を-1.0から+1.0に変換
        image.append([img])
              
        t = np.array(int(data[1]), dtype=np.int32)
        label.append(t)
              
    train = tuple_dataset.TupleDataset(image, label)      
    return train
  
def readDicom2png(dcmfnm, tmpfnm):
    ds  = pydicom.read_file(dcmfnm)    
    wc  = ds.WindowCenter              
    ww  = ds.WindowWidth                        
    img = ds.pixel_array                        
    max = wc + ww / 2                    
    min = wc - ww / 2    
    img = 255 * (img - min)/(max - min)  
    img[img > 255] = 255                
    img[img < 0  ] = 0                  
    img = img.astype(np.uint8)
    cv2.imwrite(tmpfnm, img)
    return img  

def getLabelName(fname):
    lblnm = []
    for line in open(fname, 'r'):
        data = line.split(",")
        lblnm.append(data[1].strip())
        #lblnm[int(data[0])]= data[1]  
    print("LabelName:",lblnm)            
    return len(lblnm), lblnm
      
class Alexnet(Chain):  ### AlexNet ###
    def __init__(self, n_label, n_channel=1):
        super(Alexnet, self).__init__(
            conv1 = L.Convolution2D(n_channel, 96, 11, stride=4),
            conv2 = L.Convolution2D(96, 256, 5, pad=2),
            conv3 = L.Convolution2D(256, 384, 3, pad=1),
            conv4 = L.Convolution2D(384, 384, 3, pad=1),
            conv5 = L.Convolution2D(384, 256, 3, pad=1),
            fc6 = L.Linear(None, 4096),
            fc7 = L.Linear(4096, 4096),
            fc8 = L.Linear(4096, n_label),
        )

    def __call__(self, x):
        h = F.max_pooling_2d(F.local_response_normalization(
            F.relu(self.conv1(x))), 3, stride=2)
        h = F.max_pooling_2d(F.local_response_normalization(
            F.relu(self.conv2(h))), 3, stride=2)
        h = F.relu(self.conv3(h))
        h = F.relu(self.conv4(h))
        h = F.max_pooling_2d(F.relu(self.conv5(h)), 2, stride=2)
        h = F.dropout(F.relu(self.fc6(h)))
        h = F.dropout(F.relu(self.fc7(h)))
        return self.fc8(h)

class Classifier():
    def __init__(self, mdlnm, nlbl):
        model = Alexnet(nlbl)
        
        if uses_device >= 0:  # GPUを使う
            chainer.cuda.get_device_from_id(0).use()
            chainer.cuda.check_cuda_available()
            model.to_gpu() # GPU用データ形式に変換

        self.model = L.Classifier(model,lossfun=F.softmax_cross_entropy)
        self.opt   = optimizers.Adam()
        self.opt.setup(self.model)      
        self.modelName = mdlnm      
          
    def doTrain(self, nLbl, train, batch_size, n_epoch, modelName):
        trn, vld = split_dataset_random(train, int(len(train)*0.8), seed=0)      
        t_iterator = iterators.SerialIterator(trn, batch_size, shuffle=True)
        v_iterator = iterators.SerialIterator(vld, batch_size, repeat=False, shuffle=False)
        updater  = training.StandardUpdater(t_iterator, self.opt, device=uses_device) #GPU
        trainer  = training.Trainer(updater, (n_epoch, 'epoch'), out='result')

        trainer.extend(extensions.Evaluator(v_iterator, self.model, device=uses_device))  #GPU            
        trainer.extend(extensions.LogReport())
        trainer.extend(extensions.PrintReport(['epoch', 'main/loss', 'main/accuracy', 'validation/main/accuracy']))
        trainer.extend(extensions.ProgressBar())
        trainer.extend(extensions.PlotReport(['main/loss', 'validation/main/loss'], x_key='epoch', file_name='loss.png'))
        trainer.extend(extensions.PlotReport(['main/accuracy', 'validation/main/accuracy'], x_key='epoch', file_name='accuracy.png'))
      
        trainer.run()
              
        print("学習モデル",self.modelName,"を保存します... .   .")
        chainer.serializers.save_hdf5( self.modelName, self.model )
        print("\t... .  .学習モデルを保存しました")

if __name__=='__main__':
    trainfnm = "./TrainTestList/TrainData.0.3.csv"
    labelfnm = "./TrainTestList/labelName.csv"
    resize   = 128       #画像リサイズ 0のときそのままの画素サイズ
    modelnm  = "Alexnet.0.3.hdf5"  #モデル保存名  
    batch_size = 10  # バッチサイズ
    n_epoch    = 30  # エポック数
  
    nLbl, lblName = getLabelName(labelfnm)
    train = constractTrainData(trainfnm, resize)
    print("\ntrain\n")
    print("画像データ数:",len(train),"画像/ラベル:",len(train[0]))
    print("画像Chanel数:",len(train[0][0]),"高さ:",len(train[0][0][0]),"幅:",len(train[0][0][0][0]))
  
    dl = Classifier(modelnm, nLbl)
    dl.doTrain(nLbl, train, batch_size, n_epoch, modelnm)



実行結果


プログラムは,実効時間を計測するためtimeコマンドを付けて実行します。

プログラムはラベルファイルを読み込みラベル名を表示し,DICOM画像を読み込みます。

学習がはじまり30回のエポック終了後に実行時間が表示されます。
timeコマンドは,プログラムの起動から終了までの時間をrealの値で示しています。
わたしのGefoece GTX 1060では0m29.636sでした。約30秒でした。
(実行環境:OS Ubuntu 16.04 LTS,CPU Core i5-6400 2.7GHz, メモリ 8GB,GPU GeForce GTX 1060 6GB)

$ time␣python␣dicomAlexnetTrainGPU.py⏎
LabelName: ['Lung', 'Abdomen', 'Chest', 'Head']
Train 0 ../dcmdir2/Lung/W5305890 Label# 0
Train 1 ../dcmdir2/Lung/K5303593 Label# 0
Train 2 ../dcmdir2/Lung/R5305640 Label# 0
Train 5 ../dcmdir2/Lung/D5304468 Label# 0
     ・・・・・
Train 139 ../dcmdir2/Head/G5221187 Label# 3
Train 140 ../dcmdir2/Head/Q5221671 Label# 3
Train 141 ../dcmdir2/Head/E5221093 Label# 3

train

画像データ数: 142 画像/ラベル: 2
画像Chanel数: 1 高さ: 128 幅: 128
epoch       main/loss   main/accuracy  validation/main/accuracy
1           1.8203      0.283333       0.481481                
2           1.06403     0.545455       0.725926                
3           0.741141    0.581818       0.759259                
4           0.735347    0.7            0.725926                
5           0.639062    0.581818       0.825926
         ・・・・・
28          0.00639354  1              1                        
29          0.0178032   0.990909       1                        
30          0.0548964   0.981818       0.933333                
学習モデル Alexnet.0.3.hdf5 を保存します... .   .
 ... .  .学習モデルを保存しました

real 0m29.636s
user 0m31.316s
sys 0m17.240s


GPU使用・未使用の演算時間の比較


GPU使用時では約30秒の実行時間でしたが,
uses_device = -1 にして,GPUを使用しないで実行時間を計測すると
6分18秒でした。
その差は約12倍です。
明らかに実行速度が向上しています。


real 6m18.688s
user 9m16.004s
sys 8m6.400s



GPUの稼働状況はNVIDIAのツールで確認できます。
引数はハイフン,エルです。

$ nvidia-smi␣-l⏎




中段の右にGPU-Utilが96%と表示され,GPUがフル稼働しているのが分かります。


まとめ


今回はGPUに対応したプログラムの作り方について説明しました。
次の3つについて修正するだけで簡単にGPUが使えることがわかりました。
  1. numpyの代わりにcupyを使う
  2. modelをGPUに対応させる
  3. UpdaterをGPUに対応させる
まだ,GPU未導入の方はぜひ導入を検討するとよいでしょう。

また,使っていない古い読影端末やワークステーションにGPU搭載しているものがあります。
とりあえずこれを流用する方法もあると思います。

2019年2月23日土曜日

Pyゼミ3.06 ChainerのTrainer Extensionを使ってみる

Chainerを使った学習(訓練)時の進捗状況を見てみよう


Keyword:DICOM,Chainer,Trainer, Extension,Extend

これまでPyゼミ3.04,3.05ではニューラルネットワークについて説明してきました。
そして学習時に精度と損失の変化を観察して,学習の進捗状況を知る方法を知っています。

本章では学習の進捗状況をグラフ化したり,学習を検証したりする方法を説明します。



学習の進捗を見る


Pytゼミ3.05など既に学習の進捗を見るTrainerのextend関数を使ってきました。
既に使ってきた3つの関数を見てみましょう。

LogReportは各エポックごとに損失や精度などをログに記録します。
ログファイルはresultディレクトリの中に作成されます。
    trainer.extend(extensions.LogReport())

PrintReportは引数で指定した内容(ここでは,エポック数,損失と精度)をターミナルに表示ます。
    trainer.extend(extensions.PrintReport(['epoch' , 'main/loss' , 'main/accuracy']))

ProgressBarは,学習の進捗状況を進捗バーを用いてパーセンテージで表示します。
大量のデータを使って長時間学習するような場合にどこまで,進んだのか進捗を見るのに便利です。
また,1エポックにかかる時間や終了までにかかる時間を示すので,時間のかかる学習には便利です。
     trainer.extend(extensions.ProgressBar())


学習データを訓練用と検証用に分けて学習する


学習中に過学習を起こしていないか確認するために学習データの一部を検証データにします。
学習に用いるもとのデータはtrainに格納されています。
このデータを学習用と検証用の2つに分けます。
データを8割を学習用trnに,残り2割を検証用にvldに,split_dataset_randomを使ってランダムに振り分けます。
seedは乱数のシードで,毎回,同じシード値を用いることで同じ乱数系が生成されます。
つまり毎回同じ組み合わせのtrnとvldのデータが生成されます。
    trn, vld = split_dataset_random(train, int( len(train)*0.8 ), seed = 0) 

学習用trnと検証用vldそれぞれのiteratorを作成します。   
    t_iterator = iterators.SerialIterator(trn, batch_size, shuffle = True)
    v_iterator = iterators.SerialIterator(vld, batch_size, repeat = False, shuffle = False)

検証用のデータを評価に用いるために次の一行を追加します。
    trainer.extend(extensions.Evaluator(v_iterator, self.model))  


学習の進捗と検証データの評価を確認する。


PrintReportに'validation/main/accuracy'を追加することで,学習の進捗と同時に検証の結果もターミナルに表示することができます。
    trainer.extend(extensions.PrintReport(['epoch', 'main/loss', 'main/accuracy', 'validation/main/accuracy']))

進捗状況をグラフにプロットする


学習や検証の進捗状況をPlotReportを使ってグラフにプロットしてリアルタイムで参照することができます。

損失の変化がloss.pngという画像ファイルとしてresultディレクトリ内に記録されます。
画像ファイルは1エポックごとに更新されます。
loss.pngを表示しておくと,エッポックごとに損失の変化を観察することができます。
    trainer.extend(extensions.PlotReport(['main/loss', 'validation/main/loss'], x_key='epoch', file_name='loss.png'))

精度の変化はaccuracy.pngに記録されます。
    trainer.extend(extensions.PlotReport(['main/accuracy', 'validation/main/accuracy'], x_key='epoch', file_name='accuracy.png'))


プログラムを入力実行してみよう。


下に示すプログラムはPyゼミ3.05のAlexnetの一部(先に説明した部分,doTrain関数内)を変更しました。
違いを確かめながら入力し実行してみましょう。



import os, sys
import numpy as np
import cv2
import chainer
from chainer import Function, report, training, utils, Variable
from chainer import datasets, iterators, optimizers, serializers
from chainer import Link, Chain, ChainList
import chainer.functions as F
import chainer.links as L
from chainer.datasets import tuple_dataset
from chainer.training import extensions
from chainer.datasets import split_dataset_random #new
import pydicom

def constractTrainData( fname , resz ):
    image = []
    label = []
    for i, line in enumerate(open(fname, 'r')):
        data = line.split(",")          # 画像ファイル名と正解データに分割
        print("Train",i, data[0], "Label#",data[1])
        img = readDicom2png(data[0], "tmp.png")
        if resz > 0:                    #画像のリサイズ        
            img = cv2.resize(img,(resz, resz))        
        img = img.astype(np.float32)    
        img = (img - 128)/128           #画素値0-255を-1.0から+1.0に変換
        image.append([img])
                
        t = np.array(int(data[1]), dtype=np.int32)
        label.append(t)
               
    train = tuple_dataset.TupleDataset(image, label)        
    return train
    
def readDicom2png(dcmfnm, tmpfnm):
    ds  = pydicom.read_file(dcmfnm)      
    wc  = ds.WindowCenter                
    ww  = ds.WindowWidth                         
    img = ds.pixel_array                         
    max = wc + ww / 2                      
    min = wc - ww / 2      
    img = 255 * (img - min)/(max - min)   
    img[img > 255] = 255                  
    img[img < 0  ] = 0                    
    img = img.astype(np.uint8)
    cv2.imwrite(tmpfnm, img)
    return img   

def getLabelName(fname):
    lblnm = []
    for line in open(fname, 'r'):
        data = line.split(",")
        lblnm.append(data[1].strip())
        #lblnm[int(data[0])]= data[1]   
    print("LabelName:",lblnm)              
    return len(lblnm), lblnm
        
class Alexnet(Chain):  ### AlexNet ###
    def __init__(self, n_label, n_channel=1):
        super(Alexnet, self).__init__(
            conv1 = L.Convolution2D(n_channel, 96, 11, stride=4),
            conv2 = L.Convolution2D(96, 256, 5, pad=2),
            conv3 = L.Convolution2D(256, 384, 3, pad=1),
            conv4 = L.Convolution2D(384, 384, 3, pad=1),
            conv5 = L.Convolution2D(384, 256, 3, pad=1),
            fc6 = L.Linear(None, 4096),
            fc7 = L.Linear(4096, 4096),
            fc8 = L.Linear(4096, n_label),
        )

    def __call__(self, x):
        h = F.max_pooling_2d(F.local_response_normalization(
            F.relu(self.conv1(x))), 3, stride=2)
        h = F.max_pooling_2d(F.local_response_normalization(
            F.relu(self.conv2(h))), 3, stride=2)
        h = F.relu(self.conv3(h))
        h = F.relu(self.conv4(h))
        h = F.max_pooling_2d(F.relu(self.conv5(h)), 2, stride=2)
        h = F.dropout(F.relu(self.fc6(h)))
        h = F.dropout(F.relu(self.fc7(h)))
        return self.fc8(h)

class Classifier():
    def __init__(self, mdlnm, nlbl):
        model = Alexnet(nlbl)
        self.model = L.Classifier(model,lossfun=F.softmax_cross_entropy)
        self.opt   = optimizers.Adam()
        self.opt.setup(self.model)        
        self.modelName = mdlnm       
            
    def doTrain(self, nLbl, train, batch_size, n_epoch, modelName):
        trn, vld = split_dataset_random(train, int(len(train)*0.8), seed=0)       
        t_iterator = iterators.SerialIterator(trn, batch_size, shuffle=True)
        v_iterator = iterators.SerialIterator(vld, batch_size, repeat=False, shuffle=False)
        updater  = training.StandardUpdater(t_iterator, self.opt)
        trainer  = training.Trainer(updater, (n_epoch, 'epoch'), out='result')
 
        trainer.extend(extensions.Evaluator(v_iterator, self.model))                 
        trainer.extend(extensions.LogReport())
        trainer.extend(extensions.PrintReport(['epoch', 'main/loss', 'main/accuracy', 'validation/main/accuracy']))
        trainer.extend(extensions.ProgressBar())
        trainer.extend(extensions.PlotReport(['main/loss', 'validation/main/loss'], x_key='epoch', file_name='loss.png'))
        trainer.extend(extensions.PlotReport(['main/accuracy', 'validation/main/accuracy'], x_key='epoch', file_name='accuracy.png'))
       
        trainer.run()
                
        print("学習モデル",self.modelName,"を保存します... .   .")
        chainer.serializers.save_hdf5( self.modelName, self.model )
        print("\t... .  .学習モデルを保存しました")

if __name__=='__main__':
    trainfnm = "./TrainTestList/TrainData.0.3.csv"
    labelfnm = "./TrainTestList/labelName.csv"
    resize   = 128       #画像リサイズ 0のときそのままの画素サイズ
    modelnm  = "Alexnet.0.3.hdf5"  #モデル保存名    
    batch_size = 10  # バッチサイズ
    n_epoch    = 30  # エポック数
    
    nLbl, lblName = getLabelName(labelfnm)
    train = constractTrainData(trainfnm, resize)
    print("\ntrain\n") 
    print("画像データ数:",len(train),"画像/ラベル:",len(train[0]))
    print("画像Chanel数:",len(train[0][0]),"高さ:",len(train[0][0][0]),"幅:",len(train[0][0][0][0]))
    
    dl = Classifier(modelnm, nLbl)
    dl.doTrain(nLbl, train, batch_size, n_epoch, modelnm)



実行結果


プログラムを実行すると,最初にラベル名のファイルを読み込みLungなど4つのラベル名を表示します。

その後DICOM画像を読み込み,画像ファイル名とラベル番号が表示されます。

そして,学習が始まります。
今回,検証用のvalidation/main/accuracyの項目の値も一緒に表示されています。


$ python␣dicomAlexnetTrainExtd.py⏎
LabelName: ['Lung', 'Abdomen', 'Chest', 'Head']
Train 0 ../dcmdir2/Lung/W5305890 Label# 0
Train 1 ../dcmdir2/Lung/K5303593 Label# 0
Train 2 ../dcmdir2/Lung/R5305640 Label# 0
Train 3 ../dcmdir2/Lung/T5304000 Label# 0
Train 4 ../dcmdir2/Lung/A5304343 Label# 0
Train 5 ../dcmdir2/Lung/D5304468 Label# 0
     ・・・・・
Train 137 ../dcmdir2/Head/D5221046 Label# 3
Train 138 ../dcmdir2/Head/F5221140 Label# 3
Train 139 ../dcmdir2/Head/G5221187 Label# 3
Train 140 ../dcmdir2/Head/Q5221671 Label# 3
Train 141 ../dcmdir2/Head/E5221093 Label# 3

train

画像データ数: 73 画像/ラベル: 2
画像Chanel数: 1 高さ: 128 幅: 128
epoch       main/loss   main/accuracy  validation/main/accuracy
1           3.06279     0.341667       0.481481                  
2           1.19522     0.536364       0.725926                  
3           0.943893    0.6            0.725926                  
4           0.729782    0.675          0.7                       
5           0.612126    0.7            0.488889                 
     ・・・・・
26          0.0378268   0.981818       1                         
27          0.00758042  1              1                         
28          0.0108063   0.990909       1                         
29          0.0802395   0.981818       1                         
30          0.0146818   0.990909       1                      
学習モデル Alexnet.0.3.hdf5 を保存します... .   .
 ... .  .学習モデルを保存しました          


resultディレクトリの中に精度と損失の変化を記録した画像ファイルが保存されます。
学習の進捗をグラフに自動的にプロットできるのはとても助かります。


精度の変化を観察できる。


損失の変化を観察できる。


まとめ


TrainerのExtensionを使った学習の進捗を観察する方法を説明しました。
Extensionにはまだたくさんの機能が提供されています。
たとえば,学習途中のスナップショットを保存したり,学習率をコントロールしたりすることができます。
いろいろ調べて使いこなせるようになると,より最適な学習が可能になると思います。

2019年2月20日水曜日

Pyゼミ3.05 MNISTのネットワークをAlexnetに対応させる

Alexnetに対応する



Keyword:DICOM,Chainer,Alexnet



前章Pyゼミ3.04ではMNISTのネットワークをDICOM画像に適用できるようにしました。
この章ではだ表的なDeep LearningのネットワークのAlexnetに対応したプログラムを作成します。


Alexnetとは


画像認識のコンペティションであるILSVRC(ImageNet Large Scale Visual Recognition Challenge)の2012年の大会で他者を圧倒する認識率をこのAlexnetが出しました。

Alexnetの構造は5層の畳み込み層(CNN, Constitutional Neural Network)と3層の全結合層(FC,Full Connection)からなっています。
特徴としては以下の点が挙げらます。

  • ReLU活性化関数
  • マルチGPU(Graphics Processing Unit)での学習
  • Data augmentation(データ拡張)
  • Dropout

論文[1]より。Alexnetの構成を示す。


一般的に活性関数はシグモイド関数を用いていました。
この関数は大きな入力値に対して出力が1に飽和してしまいますが,ReLU関数は線形変換により大きな入力に対しては大きな値を返ます。
この特徴によりAlexnetは学習が早く進むといわれています。

Alexnetを紹介する論文[1]では2つのGPUを使い,畳み込み演算を同時並行で行い,全結合層で出力を統合して結果を出力しています。

データ拡張はデータにランダムな移動や回転,あるいは反転やノイズなどを加えることでデータの多様性を増します。
これにより未知のデータに対する良い結果を出す汎化性能の向上が期待されるといわれています。

Dropoutは訓練時にランダムに半分のノードしか使わない手法です。
Dropoutは訓練時に過学習を防ぐために用いられています。


訓練プログラム(dicomAlexnetTrain.py)の概要


前章Pyゼミ3.04で作成したdicomNNtrain.pyのネットワークモデルを定義したMyModelクラスをこの章ではAlenxnetクラスに置き換えただけです。
したがって,ネットワークモデルを定義したクラスと入力チャンネル数(n_channel = 1)とモデルの設定(model = Alexnet(nlbl))以外は前回と変わっていません。

前章のプログラムの作成は苦労したかもしれませんが,今後新しいネットワークの設計と定義は,ネットワーク定義のクラスを新しく作るだけです。
インターネット上の様々なネットワークを試してみることができます。


Alexnetクラス


ini関数

ini関数にはネットワークの定義が記述されています。
この関数の引数は2つです。

  • n_label:出力するラベル数です。
  • n_channel:入力データ(画像)のチャンネル数です。CT画像はモノクログレイなので1をディフォルトで設定しています。

実際のネットワークの定義ではChainerのLinkの畳み込みニューラルネットワークを構成するConvolution2Dを用いています。
この関数の引数は次のようになります。
    Convolution2D( 入力チャンネル数,
            出力チャンネル数,
            フィルタサイズ,
            ストライドサイズ,
            パッドサイズ )

  • 入力チャンネル数:前ネットワークからの入力のチャンネル数
  • 出力チャンネル数:現ネットワークから出力されるチャンネル数
  • フィルタサイズ:畳み込みフィルタのサイズ
  • ストライドサイズ:フィルタ処理から次のフィルタ処理までの移動量
  • パッドサイズ:入力の辺縁のパディング処理のサイズ

はじめの畳み込み関数を見てみます。
入力チャンネル数はn_channelでここでは最初に初期化した1になります。
出力チャンネル数は96チャンネルです。
チャンネル数とは,フィルタの数と考えます。
このフィルタの重みを最適化するるように訓練が行われます。
論文の図では,2つのGPUに分散して処理しているため,48チャンネルが2つのGPUで処理されています。
畳み込みフィルタのサイズは11で,11×11のサイズのフィルタを意味しています。
ストライドは4で4画素おきに処理が行われます。
    conv1 = L.Convolution2D(n_channel, 96, 11, stride=4),
         
2つめの畳み込み層は前層conv1の値を受け取るため,
入力チャンネル数は96になります。
出力チャンネル数は256です。
フィルタのサイズは5(5x5)です。
パッドサイズは2で,入力データの周囲に2画素パディング(埋め込み)して5×5のフィルタ処理が無駄なくできるようにしています。
    conv2 = L.Convolution2D(96, 256, 5, pad=2),

以下,同様に入力出力チャンネル数,フィルタサイズとパッドサイズが設定されています。         
    conv3 = L.Convolution2D(256, 384, 3, pad=1),      
    conv4 = L.Convolution2D(384, 384, 3, pad=1),
    conv5 = L.Convolution2D(384, 256, 3, pad=1),
この畳み込み層の最終出力は256チャンネルになります。

次に全結合層です。
同じくChainerのLinkの全結合の処理を行うLinearについて説明します。
    Linear(入力サイズ,出力サイズ)

  • 入力サイズ:入力ベクトルの次元数
  • 出力サイズ:出力ベクトルの次元数


それでは全結合のネットワークfc6を見てみます。
入力ベクトルの次元はNoneになっています。ネットワーク定義のこの段階で次元数は未定です。
しかし,最初のデータが順伝播する際に次元数は初期化されます。
そして,出力ベクトルの次元数は4096になります。
    fc6 = L.Linear(None, 4096),

以下同様にしてfc7,fc8が定義され,最終的にn_labelの次元数のベクトルが出力されます。
    fc7 = L.Linear(4096, 4096),
    fc8 = L.Linear(4096, n_label),


call関数


次にcall関数です。
先にini関数で定義したネットワークを順伝播する形態に接続を記述します。
はじめにChainerのFunctionのmax_pooling_2d関数が使われています。
    max_pooling_2d( 入力変数,
             プーリングウィンドウサイズ,
                                      ストライドサイズ )

  • 入力変数:ChanerのVariable型の変数,実際はベクトルになります。
  • プーリングウィンドウサイズ:k×kの画素中の最大値をとるのがmax_poolingです。このkの値を設定します。
  • ストライドサイズ:プーリング処理を何画素おきに行うのか設定します。

次にlocal_response_normalizationです。
これは,局所近傍のチャンネルの正規化を行います。チャンネルによっては大きな値ばかりになったり,その逆の場合などを正規化して補正します。
     local_response_normalization( 入力変数 )

  • 入力変数:ChanerのVariable型の変数,実際はベクトルになります。


最後に,reluです。reluは活性化関数でRectified Linear Unit functionの略で,次式で表されます。
       f(x)=max(0,x)
入力x(Variable型)と0と比較して大きな値を返します。つまり,xが0未満では0を,0以上ではxの値を返す関数です。
活性化関数によく利用されるSigmoid関数は大きな入力に対して最大1を返しますが,relu関数は入力xに比例した値が返されます。

それでは3つの関数が理解できたところで,入力xについての出力hを見てみましょう。
    h = F.max_pooling_2d(F.local_response_normalization(
                       F.relu(self.conv1(x))), 3, stride=2)
最初に入力xは畳み込みニューラルネットワークのself.conv1に与えらえます。
self.conv1の出力はF.reluの活性化関数に与えられます。
活性化関数F.reluの出力はF.local_response_normalizationに与えられ,局所正規化されます。
正規化されたチャンネルは F.max_pooling_2dに与えられ,この時のプーリングサイズは3ⅹ3で,ストライドが2で処理が行われます。
各注目する3x3の画素中の最大値がプーリング値となり,出力hを得ます。

次は前ネットワークの出力hをself.conv2の入力にして,同じように活性化関数relu, 局所正規化とマックスプーリング処理を行って出力hを得ます。
    h = F.max_pooling_2d(F.local_response_normalization(
                       F.relu(self.conv2(h))), 3, stride=2)

前ネットワークの出力hをself.conv3の入力にして,活性化関数を介して出力hを得たものを次のネットワークself.conv4の入力になり,同様に出力hを得ています。
    h = F.relu(self.conv3(h))
    h = F.relu(self.conv4(h))

次に最後の畳み込みニューラルネットself.conv5に前ネットワークの出力hを与えて,新たな出力hを得ています。
    h = F.max_pooling_2d(F.relu(self.conv5(h)), 2, stride=2)

次のネットワークからは全結合のself.conv6~8の処理を行います。
ここではChainerのFunctionの中のdropoutが使われています。
Dropoutは学習時に重みの半分をないものとして学習を行います。
これにより過学習を予防する効果があるといわれています。
    h = F.dropout(F.relu(self.fc6(h)))
    h = F.dropout(F.relu(self.fc7(h)))
前ネットワークの出力hをself.conv6に与え,活性化関数を介した後,dropoutで学習して出力を得ています。
同様にself.conv7にその出力を与えて新たな出力hを得ています。

最後に全結合のself.conv8に前出力hを与えて得た新たな出力をcall関数は返しています。
    return self.fc8(h)

このようにcall関数はネットワークの定義(conv1~conv8)を使って順伝播を記述しています。


プログラムを入力して実行してみよう


プログラム全体の構成はPyゼミ3.04と同じです。
Pyゼミ3.04の「プログラムのポイント」に記述されているように,訓練データファイル(訓練画像ファイル名とラベル番号のリスト)が存在することが前提です。
参考までにPyゼミ3.04のMyModelクラスをソース中に残してあります。
Pyゼミ3.04から変更されている点は以下の通りです。

  • MyModelクラスの代わりにAlenxnetクラスに置き換わっています。
  • 入力データのチャンネル数を1に設定しています(n_channel = 1)。
  • ClassifyImageクラス内でmodelをAlexnetに設定しています(model = Alexnet(nlbl))。

dicomAlexnetTrain.py


import os, sys
import numpy as np
import cv2
import chainer
from chainer import Function, report, training, utils, Variable
from chainer import datasets, iterators, optimizers, serializers
from chainer import Link, Chain, ChainList
import chainer.functions as F
import chainer.links as L
from chainer.datasets import tuple_dataset
from chainer.training import extensions
import pydicom

def constractTrainData(fname , resz):
    image,label = [], []
    for i, line in enumerate(open(fname, 'r')):
        data = line.split(",")          # 画像ファイル名と正解データに分割
        print("Train",i, data[0], "Label#",data[1])
        img = readDicom2png(data[0], "tmp.png")
        if resz > 0:                    # 画像のリサイズ       
            img = cv2.resize(img,(resz, resz))       
        img = img.astype(np.float32)   
        img = (img - 128)/128           # 画素値0-255を-1.0から+1.0に変換
        image.append([img])
               
        t = np.array(int(data[1]), dtype=np.int32)
        label.append(t)
    train = tuple_dataset.TupleDataset(image, label)       
    return train
   
def readDicom2png(dcmfnm, tmpfnm):
    ds  = pydicom.read_file(dcmfnm)     
    wc  = ds.WindowCenter               
    ww  = ds.WindowWidth                        
    img = ds.pixel_array                        
    max = wc + ww / 2                     
    min = wc - ww / 2     
    img = 255 * (img - min)/(max - min)  
    img[img > 255] = 255                 
    img[img < 0  ] = 0                   
    img = img.astype(np.uint8)
    cv2.imwrite(tmpfnm, img)
    return img  

def getLabelName(fname):
    lblnm = []
    for line in open(fname, 'r'):
        data = line.split(",")
        lblnm.append(data[1].strip())
        #lblnm[int(data[0])]= data[1]  
    print("LabelName:",lblnm)             
    return len(lblnm), lblnm
"""
class MyModel(Chain):                   # MNISTで使ったネットワーク(参考)
    def __init__(self, nlbl):          
        super(MyModel, self).__init__(
            l1 = L.Linear(784,100),      
            l2 = L.Linear(100,100),           
            l3 = L.Linear(100,nlbl),
        )
       
    def __call__(self, x):              # 順伝播計算
        h1 = F.relu(self.l1(x))         # 活性化関数にReLuを定義
        h2 = F.relu(self.l2(h1))
        return self.l3(h2)
"""
class Alexnet(Chain):  ### AlexNet ###
    def __init__(self, n_label, n_channel=1):
        super(Alexnet, self).__init__(
            conv1 = L.Convolution2D(n_channel, 96, 11, stride=4),
            conv2 = L.Convolution2D(96, 256, 5, pad=2),
            conv3 = L.Convolution2D(256, 384, 3, pad=1),
            conv4 = L.Convolution2D(384, 384, 3, pad=1),
            conv5 = L.Convolution2D(384, 256, 3, pad=1),
            fc6 = L.Linear(None, 4096),
            fc7 = L.Linear(4096, 4096),
            fc8 = L.Linear(4096, n_label),
        )

    def __call__(self, x):
        h = F.max_pooling_2d(F.local_response_normalization(
            F.relu(self.conv1(x))), 3, stride=2)
        h = F.max_pooling_2d(F.local_response_normalization(
            F.relu(self.conv2(h))), 3, stride=2)
        h = F.relu(self.conv3(h))
        h = F.relu(self.conv4(h))
        h = F.max_pooling_2d(F.relu(self.conv5(h)), 2, stride=2)
        h = F.dropout(F.relu(self.fc6(h)))
        h = F.dropout(F.relu(self.fc7(h)))
        return self.fc8(h)
       
class ClassifyImage():
    def __init__(self, mdlnm, nlbl):
        model = Alexnet(nlbl)
        self.model = L.Classifier(model,lossfun=F.softmax_cross_entropy)
        self.opt   = optimizers.Adam()
        self.opt.setup(self.model)       
        self.modelName = mdlnm      
           
    def doTrain(self, nLbl, train, batch_size, n_epoch, modelName):
        iterator = iterators.SerialIterator(train, batch_size, shuffle=True)
        updater  = training.StandardUpdater(iterator, self.opt)
        trainer  = training.Trainer(updater, (n_epoch, 'epoch'), out='result')
        trainer.extend(extensions.LogReport())    
        trainer.extend(extensions.PrintReport(['epoch', 'main/loss', 'main/accuracy']))     
        trainer.extend(extensions.ProgressBar())
               
        trainer.run()
               
        print("学習モデル",self.modelName,"を保存します... .   .")
        chainer.serializers.save_hdf5( self.modelName, self.model )
        print("\t... .  .学習モデルを保存しました")

if __name__=='__main__':
    trainfnm = "./TrainTestList/TrainData.0.3.csv"
    labelfnm = "./TrainTestList/labelName.csv"
    resize   = 64                   # 画像リサイズ
    modelnm  = "Alexnet.0.3.hdf5"   # モデル保存名   
    batch_size = 10                 # バッチサイズ
    n_epoch    = 10                 # エポック数
   
    nLbl, lblName = getLabelName(labelfnm)
    train = constractTrainData(trainfnm, resize)
    print("\nTrain data information\n")
    print("画像データ数:",len(train))
    print("画像/ラベル:",len(train[0]),"\n画像Chanel数:",len(train[0][0]))
    print("高さ:",len(train[0][0][0]),"\n幅 :",len(train[0][0][0][0]))
   
    ci = ClassifyImage(modelnm, nLbl)
    ci.doTrain(nLbl, train, batch_size, n_epoch, modelnm)



実行結果


プログラムを実行すると,ラベル名のリスト(配列)が表示されます。
インデックス番号がラベル番号になります。
ラベル番号0は’Lung’,ラベル番号3は’Head’になります。

次に142枚の訓練画像がラベル番号と伴に読み込まれます。
画像サイズを64x64にリサイズしています。

学習が始まると,損失(main/loss)は減少し,精度(main/accuracy)は徐々に高くなり,10回目には0.985714になっています。

最終的に学習したパラメータはファイルAlexnet.0.3.hdf5に保存されます。


$ python␣dicomAlexnetTrain.py⏎
LabelName: ['Lung', 'Abdomen', 'Chest', 'Head']
Train 0 ../dcmdir2/Lung/W5305890 Label# 0
Train 1 ../dcmdir2/Lung/K5303593 Label# 0
Train 2 ../dcmdir2/Lung/R5305640 Label# 0
Train 3 ../dcmdir2/Lung/T5304000 Label# 0
Train 4 ../dcmdir2/Lung/A5304343 Label# 0
Train 5 ../dcmdir2/Lung/D5304468 Label# 0

      ・・・・・

Train 136 ../dcmdir2/Head/K5221390 Label# 3
Train 137 ../dcmdir2/Head/D5221046 Label# 3
Train 138 ../dcmdir2/Head/F5221140 Label# 3
Train 139 ../dcmdir2/Head/G5221187 Label# 3
Train 140 ../dcmdir2/Head/Q5221671 Label# 3
Train 141 ../dcmdir2/Head/E5221093 Label# 3

Train data information

画像データ数: 142
画像/ラベル: 2 
画像Chanel数: 1
高さ: 64 
幅 : 64
  epoch       main/loss   main/accuracy
1           1.75138     0.42           
2           0.786748    0.657143       
3           0.685697    0.685714       
4           0.589711    0.778571       
5           0.306332    0.907143       
6           0.329208    0.94           
7           0.0920463   0.992857       
8           0.0648474   0.978571       
9           0.251898    0.935714       
10          0.046363    0.985714       
学習モデル Alexnet.0.3.hdf5 を保存します... .   .
 ... .  .学習モデルを保存しました






テストプログラム(dicomAlexnetTest.py)の概要


このテストプログラムも基本的にPyゼミ3.04のdicomNNtest.pyと同じですが,
Alexnetクラスに置き換わっていること,
入力データ(画像)のチャンネル数が1であること,
ClassfyImageクラス内でmodelにAlexnetを設定していることが異なっています。


プログラムを入力して実行してみよう


Pyゼミ3.04のdicomNNtest.pyと異なる点を修正するだけで実行できます。
  • Alexnetクラスの追加
  • Alexnetクラスのini関数の引数にn_channel = 1の追加
  • model = Alexnet(nlbl)へ変更


dicomAlexnetTest.py



import os, sys
import numpy as np
import cv2
import chainer
import chainer.links as L
import chainer.functions as F
from chainer import optimizers, Chain, Variable, initializers
from chainer.training import extensions
import pydicom

def constructTestData(fname , resz):
    test = []
    for i, line in enumerate(open(fname, 'r')):
        data = line.split(",")
        print("Test",i, data[0], "Label#",data[1])
        img = readDicom2png(data[0], "tmp.png")  
        if resz > 0:                         
            img = cv2.resize(img,(resz, resz))
        img = img.astype(np.float32)   
        img = (img - 128)/128
        test.append([[img], int(data[1]), data[0]])
    return test
   
def readDicom2png(dcmfnm, tmpfnm):
    ds  = pydicom.read_file(dcmfnm)     
    wc  = ds.WindowCenter               
    ww  = ds.WindowWidth                        
    img = ds.pixel_array                        
    max = wc + ww / 2                     
    min = wc - ww / 2     
    img = 255 * (img - min)/(max - min)  
    img[img > 255] = 255                 
    img[img < 0  ] = 0                   
    img = img.astype(np.uint8)
    cv2.imwrite(tmpfnm, img)
    return img  

def getLabelName(fname):
    lblnm = []
    for line in open(fname, 'r'):
        data = line.split(",")
        lblnm.append(data[1].strip()) 
    print("LabelName:",lblnm)             
    return len(lblnm), lblnm

class Alexnet(Chain):  ### AlexNet ###
    def __init__(self, n_label, n_channel=1):
        super(Alexnet, self).__init__(
            conv1 = L.Convolution2D(n_channel, 96, 11, stride=4),
            conv2 = L.Convolution2D(96, 256, 5, pad=2),
            conv3 = L.Convolution2D(256, 384, 3, pad=1),
            conv4 = L.Convolution2D(384, 384, 3, pad=1),
            conv5 = L.Convolution2D(384, 256, 3, pad=1),
            fc6 = L.Linear(None, 4096),
            fc7 = L.Linear(4096, 4096),
            fc8 = L.Linear(4096, n_label),
        )

    def __call__(self, x):
        h = F.max_pooling_2d(F.local_response_normalization(
            F.relu(self.conv1(x))), 3, stride=2)
        h = F.max_pooling_2d(F.local_response_normalization(
            F.relu(self.conv2(h))), 3, stride=2)
        h = F.relu(self.conv3(h))
        h = F.relu(self.conv4(h))
        h = F.max_pooling_2d(F.relu(self.conv5(h)), 2, stride=2)
        h = F.dropout(F.relu(self.fc6(h)))
        h = F.dropout(F.relu(self.fc7(h)))
        return self.fc8(h)

class ClassifyImage:
    def __init__(self, modelname, nlbl):
        model = Alexnet(nlbl)                                                          
        self.model = L.Classifier(model)
               
        print("modelファイル ",modelname," を読み込みます...")
        chainer.serializers.load_hdf5( modelname, self.model )
        print("...ファイルを読み込みました")

    def predict(self, testimg):
        img = np.array([testimg])
        #print(">img>>",type(img),len(img),len(img[0]),len(img[0][0]),len(img[0][0][0]))
        x = Variable(img)
        y = self.model.predictor(x)
        y = F.softmax(y)
        answer = y.data
        answer = np.argmax(answer, axis=1)
        return answer[0], y[0].data
   
if __name__=='__main__':
    testfnm  = "./TrainTestList/TestData.0.3.csv"
    labelfnm = "./TrainTestList/labelName.csv"
    resize   = 64                 # 画像リサイズ
    modelnm  = "Alexnet.0.3.hdf5" # モデル保存名
   
    nLbl, lblName = getLabelName(labelfnm)
    lblCnt = np.zeros(nLbl)    # 各ラベルの正答数を計数
    lblAll = np.zeros(nLbl)    # 各ラベルの総数を計数
   
    data = constructTestData(testfnm, resize)
    print("data:",len(data),len(data[0]))
    print("img :",len(data[0][0]), len(data[0][0][0]),len(data[0][0][0][0]))
   
    ci = ClassifyImage(modelnm, nLbl)  # モデルの読み込み

    ok = 0
    np.set_printoptions(formatter={'float': '{: 0.5f}'.format})
    for i, line in enumerate(data):
        testimg, ans, fnm = line
        prediction, softmax = ci.predict(testimg)  # 推論
        print("\n#",i,"Softmax:",softmax)
        result = "File:"+fnm+"\tAns:"+lblName[ans]+"→ Predict:"+lblName[prediction]
        lblAll[ans] += 1                # ラベルがansの数の計数
        if prediction == ans:           # 推論結果と答えが一致したら
            print(result + "\tOK")
            ok += 1
            lblCnt[prediction] += 1
        else:
            print(result + "\tNG")
   
    print("\n平均正答率:",ok/len(data)) 
    for i in range(nLbl):
        print(str(i), lblName[i],"\t",lblCnt[i]/lblAll[i])



実行結果


実行するとはじめにラベル名が表示されます。

その後,テスト画像とラベル番号が読み込まれます。

73枚のテスト画像が読み込まれた後,dicomAlexnetTrain.pyで保存した学習モデルのファイルAlexnet.0.3.hdf5を読み込みます。

各画像についてテストを行います。
Softmax:の後には各ラベルに対する推測結果が4次元ベクトルで表されます。
ベクトルの最も大きな値のインデックス番号が推測したラベル番号になります。
また,教師データのAnsと推測結果のPredictが一致しているとOK表示され,異なっている場合はNGが表示されます。

最後に平均正答率と,各ラベルの正答率が表示されます。


$ python␣dicomAlexnetTest.py⏎
LabelName: ['Lung', 'Abdomen', 'Chest', 'Head']
Test 0 ../dcmdir2/Lung/E5306265 Label# 0
Test 1 ../dcmdir2/Lung/M5305390 Label# 0
Test 2 ../dcmdir2/Lung/C5304421 Label# 0
Test 3 ../dcmdir2/Lung/O5303765 Label# 0
Test 4 ../dcmdir2/Lung/P5305546 Label# 0
Test 5 ../dcmdir2/Lung/Q5305593 Label# 0

     ・・・・・

Test 68 ../dcmdir2/Head/B5220953 Label# 3
Test 69 ../dcmdir2/Head/P5221625 Label# 3
Test 70 ../dcmdir2/Head/L5221421 Label# 3
Test 71 ../dcmdir2/Head/W5222046 Label# 3
Test 72 ../dcmdir2/Head/X5222093 Label# 3
data: 73 3
img : 1 64 64
modelファイル  Alexnet.0.3.hdf5  を読み込みます...
...ファイルを読み込みました

# 0 Softmax: [ 1.00000  0.00000  0.00000  0.00000]
File:../dcmdir2/Lung/E5306265 Ans:Lung→ Predict:Lung OK

# 1 Softmax: [ 1.00000  0.00000  0.00000  0.00000]
File:../dcmdir2/Lung/M5305390 Ans:Lung→ Predict:Lung OK

# 2 Softmax: [ 1.00000  0.00000  0.00000  0.00000]
File:../dcmdir2/Lung/C5304421 Ans:Lung→ Predict:Lung OK

# 3 Softmax: [ 1.00000  0.00000  0.00000  0.00000]
File:../dcmdir2/Lung/O5303765 Ans:Lung→ Predict:Lung OK
      ・・・・・
# 70 Softmax: [ 0.00000  0.00000  0.00000  1.00000] File:../dcmdir2/Head/L5221421 Ans:Head→ Predict:Head OK # 71 Softmax: [ 0.00002  0.00000  0.00000  0.99998] File:../dcmdir2/Head/W5222046 Ans:Head→ Predict:Head OK # 72 Softmax: [ 0.00000  0.00000  0.00000  1.00000] File:../dcmdir2/Head/X5222093 Ans:Head→ Predict:Head OK 平均正答率: 1.0 0 Lung 1.0 1 Abdomen 1.0 2 Chest 1.0 3 Head 1.0

10回の学習で得たモデルを使って,テストでは良い正答率が出ています。
これは同一人物のデータなので当然の結果です。
いろいろなDICOM画像データで試してみるとよいでしょう。


まとめ


Pyゼミ3.05ではニューラルネットワークを記述したクラスを交換することで簡単にAlexnetに対応することができました。
このようにニューラルネットワークのみを変更することで様々なDeep Neural Networkに発展させることができます。
また,ネットワーク定義においてチャンネル数を変えたり,畳み込み層を増やしたりいろいろと自分オリジナルのネットワークを作ることができます。


参考文献

[1] Alex Krizhevsky, Ilya Sutskever, Geoffrey E. Hinton :ImageNet Classification with Deep Convolutional Neural Networks. Advances in Neural Information Processing Systems 25 (NIPS 2012)


2019年2月17日日曜日

Pyゼミ3.04 手書き文字認識のニューラルネットワークをDICOMに拡張する

MNISTのニューラルネットワークを使ってDICOM画像の部位を分類してみる


Keyword:DICOM,Chainer,MNIST


3.01でMNISTのネットワークを用いて手書き文字の分類を行いました。
3.02ではMNISTの手書き文字のデータ構造を解析してDICOM画像の訓練データの作り方を調べました。
そして3.03でDICOM画像を訓練データの画像リストファイルととテストデータの画像リストファイルに分けるプログラムを作成しました。

この章では,これらのプログラムを使って訓練データファイルからCT画像の部位(Head,Lung,Chest,Abdomenの4種類)の分類問題を学習,テストするプログラムを作成します。

実験用のDICOM画像はここにあります。私のCT画像です。実験にのみ使用し,他の目的で使用しないでください。


プログラムのポイント


  • 前提として./TrainTestListディレクトリに訓練データファイルTrainData.0.3.csvとテストデータファイルTestData.0.3.csvが存在するとします。
  • この章では学習プログラムdicomNNtrain.pyとテストプログラムdicomNNtest.pyを作成します。これらのプログラムは前章のmnistNN.py(Pyゼミ3.01参照)のネットワークモデルクラスを使用しますが,さらに分類するためのクラスClassifyImageクラスを作成しました。
  • 学習プログラムdicomNNtrain.pyは訓練データファイルTrainData.0.3.csvの中に記述された画像ファイルを用いて学習を行い,学習が終了すると,学習モデルをMyModel.hdf5にファイル出力します。
  • テストプログラムdicomNNtest.pyは学習モデルMyModel.hdf5ファイルを読み込み,TestData.0.3.csvファイルに指定されたテストDICOM画像について分類テストを行います。



学習プログラム(dicomNNtrain.py)の概要


import

DICOM画像を読み込むためpydicomが追加されています。

constractTrainData関数

この関数はPyゼミ3.02のプログラムと同じです。
訓練用のDICOM画像のリストファイル(画像ファイル名,ラベル番号とラベル名のリスト)を引数に受け取り,訓練データを作成して返します。

readDicom2png関数

この関数はPyゼミ1.02のプログラムと同じです。
DICOM画像を適正な濃度にウィンドニングして画素値を返します。

getLabelName関数

Pyゼミ3.03で作成したラベル名のCSVファイルlabelName.csvを読み込み,ラベル数(分類すべき数)と配列(ラベル番号とラベル名)を返します。

MyModelクラス


ニューラルネットワークを定義するクラスです。
Pyゼミ3.01の手書き文字認識のMyModelクラスとほとんど同じですが,init関数がラベル数nlblを引数に持ち,出力層l3に設定しています。
手書き文字認識は10種類の数字ですが,今回のCT画像の部位分類では4種類になります。
    l3 = L.Linear( 100, nlbl )

call関数はPyゼミ3.01と同じです。

ClassifyImageクラス


今回学習のために新しく作成したクラスです。2つの関数を持ちます。
init関数はニューラルネットワークモデルの初期化と設定を行っています。
ClassifierはChainerが提供する関数で,引数に与えたネットワークモデルに対して損失や精度を計算します。
ここでは損失関数にソフトマックスークロスエントロピイF.softmax_cross_entropyを指定しています。

doTrain関数は訓練を実行するときに呼ばれる関数です。
trainer.run関数により訓練が行われ,終了後に学習モデルをファイルmodelNameに保存します。
    chainer.serializers.save_hdf5( self.modelName, self.model )


プログラム実行の流れを見てみる


main関数を見てみましょう。

変数trainfnmlabelfnmにそれぞれ訓練画像ファイルのリストファイル名とラベル名のファイルが代入されている。

画像のリサイズresizeの値を読み込みます。
CT画像は512×512ですが,MNISTのネットワークは28x28(入力ノード数784)なのでこの値を設定して画像をリサイズします。

学習後に保存するモデルの名前をmodelnmに設定します。

学習単位のデータ数であるbatch_sizeと全データの学習の回数n_epochをそれぞれ10と25に設定します。

次にgetLabelName関数を呼んで,ラベル数nLblとラベル名配列lblNameを得ます。

次にconstractTrainData関数を呼んで訓練データをtrainに代入します。

次に保存するモデル名とラベル数をClassifyImageクラスに与えてインスタンスciを作成します。

最後にClassifyImageクラスのdoTrain関数を呼び出して,訓練を行います。


プログラムを入力して実行してみよう


プログラムは約110行ありますが,これまでのPyゼミ3.01から3.03で扱ってきた関数です。
新しいコードは少ないです。

dicomNNtrain.py


import os, sys
import numpy as np
import cv2
import chainer
from chainer import Function, report, training, utils, Variable
from chainer import datasets, iterators, optimizers, serializers
from chainer import Link, Chain, ChainList
import chainer.functions as F
import chainer.links as L
from chainer.datasets import tuple_dataset
from chainer.training import extensions
import pydicom

def constractTrainData(fname , resz):
    image,label = [], []
    for i, line in enumerate(open(fname, 'r')):
        data = line.split(",")          # 画像ファイル名と正解データに分割
        print("Train",i, data[0], "Label#",data[1])
        img = readDicom2png(data[0], "tmp.png")
        if resz > 0:                    #画像のリサイズ        
            img = cv2.resize(img,(resz, resz))        
        img = img.astype(np.float32)    
        img = (img - 128)/128           #画素値0-255を-1.0から+1.0に変換
        image.append([img])
                
        t = np.array(int(data[1]), dtype=np.int32)
        label.append(t)
    train = tuple_dataset.TupleDataset(image, label)        
    return train
    
def readDicom2png(dcmfnm, tmpfnm):
    ds  = pydicom.read_file(dcmfnm)      
    wc  = ds.WindowCenter                
    ww  = ds.WindowWidth                         
    img = ds.pixel_array                         
    max = wc + ww / 2                      
    min = wc - ww / 2      
    img = 255 * (img - min)/(max - min)   
    img[img > 255] = 255                  
    img[img < 0  ] = 0                    
    img = img.astype(np.uint8)
    cv2.imwrite(tmpfnm, img)
    return img   

def getLabelName(fname):
    lblnm = []
    for line in open(fname, 'r'):
        data = line.split(",")
        lblnm.append(data[1].strip())  
    print("LabelName:",lblnm)              
    return len(lblnm), lblnm

class MyModel(Chain):                   #ネットワークモデルを定義する
    def __init__(self, nlbl):           
        super(MyModel, self).__init__(
            l1 = L.Linear(784,100),       
            l2 = L.Linear(100,100),            
            l3 = L.Linear(100,nlbl),
        )
        
    def __call__(self, x):              #順伝播計算
        h1 = F.relu(self.l1(x))         #活性化関数にReLuを定義
        h2 = F.relu(self.l2(h1))
        return self.l3(h2)
        
class ClassifyImage():
    def __init__(self, mdlnm, nlbl):
        model = MyModel(nlbl)
        self.model = L.Classifier(model,lossfun=F.softmax_cross_entropy)
        self.opt   = optimizers.Adam()
        self.opt.setup(self.model)        
        self.modelName = mdlnm       
            
    def doTrain(self, nLbl, train, batch_size, n_epoch, modelName):
        iterator = iterators.SerialIterator(train, batch_size, shuffle=True)
        updater  = training.StandardUpdater(iterator, self.opt)
        trainer  = training.Trainer(updater, (n_epoch, 'epoch'), out='result')
        trainer.extend(extensions.LogReport())     
        trainer.extend(extensions.PrintReport(['epoch', 'main/loss', 'main/accuracy']))      
        trainer.extend(extensions.ProgressBar())
                
        trainer.run()
                
        print("学習モデル",self.modelName,"を保存します... .   .")
        chainer.serializers.save_hdf5( self.modelName, self.model )
        print("\t... .  .学習モデルを保存しました")

if __name__=='__main__':
    trainfnm = "./TrainTestList/TrainData.0.3.csv"
    labelfnm = "./TrainTestList/labelName.csv"
    resize   = 28       #画像リサイズ
    modelnm  = "MyModel.0.3.hdf5"  #モデル保存名    
    batch_size = 10  # バッチサイズ
    n_epoch    = 15  # エポック数
    
    nLbl, lblName = getLabelName(labelfnm)
    train = constractTrainData(trainfnm, resize)
    print("\nTrain data information\n") 
    print("画像データ数:",len(train))
    print("画像/ラベル:",len(train[0]),"\n画像Chanel数:",len(train[0][0]))
    print("高さ:",len(train[0][0][0]),"\n幅 :",len(train[0][0][0][0]))
    
    ci = ClassifyImage(modelnm, nLbl)
    ci.doTrain(nLbl, train, batch_size, n_epoch, modelnm)


実行結果


最初にラベル名labelName.csvを読み込みラベル名をターミナルに表示します。

その後訓練画像を読み込み,画像ファイル名とラベル番号をターミナルに表示します。

訓練データ情報としてデータ数142や画像のサイズ28x28などが表示され,訓練が始まります。

学習を重ねるうちに損失(main/loss)が低下し,精度(main/accuracy)が向上していることがわかります。
最終的に学習モデルMyModel.0.3.hdf5が保存されます。

$ python dicomNNtrain.py
LabelName: ['Lung', 'Abdomen', 'Chest', 'Head']
Train 0 ../dcmdir2/Lung/W5305890 Label# 0
Train 1 ../dcmdir2/Lung/K5303593 Label# 0
Train 2 ../dcmdir2/Lung/R5305640 Label# 0
Train 3 ../dcmdir2/Lung/T5304000 Label# 0
Train 4 ../dcmdir2/Lung/A5304343 Label# 0
Train 5 ../dcmdir2/Lung/D5304468 Label# 0
     ・・・・・
Train 137 ../dcmdir2/Head/D5221046 Label# 3
Train 138 ../dcmdir2/Head/F5221140 Label# 3
Train 139 ../dcmdir2/Head/G5221187 Label# 3
Train 140 ../dcmdir2/Head/Q5221671 Label# 3
Train 141 ../dcmdir2/Head/E5221093 Label# 3

Train data information

画像データ数: 142
画像/ラベル: 2
画像Chanel数: 1
高さ: 28
幅 : 28
epoch       main/loss   main/accuracy
1           0.807113    0.713333      
2           0.222487    0.95          
3           0.106435    0.992857      
4           0.07357     0.985714      
5           0.0498589   0.992857      
     ・・・・・
21          0.0171901   0.993333      
22          0.00569069  1            
23          0.0104065   0.992857      
24          0.0069965   0.992857      
25          0.0046676   1          
学習モデル MyModel.0.3.hdf5 を保存します... .   .
 ... .  .学習モデルを保存しました



テストプログラム(dicomNNtest.py)の作成


訓練プログラムで保存された学習モデルのファイルを読み込み,さらにテスト画像を読み込み部位の分類のテストを行うプログラムを作成します。

テストプログラムの概要


import

訓練プログラムと同様にpydicomをインポートします。

constructTestData関数

テスト画像ファイルのリストファイル(画像ファイル名,ラベル番号とラベル名のリスト)から画像データ,ラベル番号と画像ファイル名の配列testを返します。

imgは2次元配列の画像,data[1]はラベル番号を整数に変換しています。
data[0]は画像ファイル名です。
この3つを配列の要素として,配列testに追加しています。
    test.append( [ img, int( data[1] ), data[0] ] )

readDicom2png関数

訓練プログラムdicomNNtrain.pyの関数と同じです。

getLabelName関数

訓練プログラムdicomNNtrain.pyの関数と同じです。

MyModelクラス

訓練プログラムdicomNNtrain.pyの関数と同じです。

ClassifyImageクラス

init関数でモデルを定義し,学習モデルmyModel.hdf5を読み込みます。

predict関数はテスト画像testimgを引数に持ち,np.arrayに変換したあと,ChainerのVariable型に変換します。
predictorメソッドを使って,推論を行いSoftmax関数を用いて確率に変換します。
    img = np.array( [ testimg ] )
    x = Variable( img )
    y = self.model.predictor( x )
    y = F.softmax( y )
確率のベクトルyで最も高い値をargmaxメソッド使って求めて推論結果(ラベル番号)返します。
    answer = y.data
    answer = np.argmax(answer, axis=1)


プログラム実行の流れを見てみる


main関数を見てみましょう。

変数testfnmとlabelfnmにそれぞれテスト画像ファイルのリストファイル名とラベルファイル名を代入します。
resizeはMNISTの手書き画像にリサイズするため28に設定します。
modelnmは訓練プログラムで保存されたモデル名を設定します。

次にgetLabelName関数を呼んで,ラベル数をnLblにラベル名を配列lblNameに格納します。
配列lblNameのインデックスはラベル番号に一致する。つまりラベル番号0のラベル名はlblName[0]になります。

各ラベルの正答数を計数するため配列を作成します。
例えば,ラベル番号が1のデータが正答するとlblCnt[1]をインクリメントします。
    lblCnt = np.zeros( nLbl )    #各ラベルの正答数を計数
    lblAll = np.zeros( nLbl )    #各ラベルの総数を計数 

次にconstructTestData関数を呼び,すべてのテスト画像,正解ラベルと画像ファイル名を配列dataに格納します。

ClassifyImageクラスを呼びインスタンスciを作成します。

全体の正答数を計数する変数okを初期化します。
次にfor文にて配列dataからひとつづつデータを取り出し,テスト画像,正解ラベルと画像ファイル名をtestimg, ans, fnmに代入します。

テスト画像testimgをモデルciのpredict関数に渡し,推論を行い,推論結果predictionと確率分布softmaxを得ます。

もし推論結果predictionと正答ラベルansが一致していれば,その正答ラベルの配列をインクリメントします。

最後に,全体の平均正答率と個々のラベルの正答率を表示します。


プログラムを入力して実行してみよう


プログラムは100行を超えていますが,訓練プログラムと共通のところが多くあります。共通部分と異なる部分を意識しながら入力してみましょう。



import os, sys
import numpy as np
import cv2
import chainer
import chainer.links as L
import chainer.functions as F
from chainer import optimizers, Chain, Variable, initializers
from chainer.training import extensions
import pydicom

def constructTestData(fname , resz):
    test = []
    for i, line in enumerate(open(fname, 'r')):
        data = line.split(",") 
        print("Test",i, data[0], "Label#",data[1])
        img = readDicom2png(data[0], "tmp.png")   
        if resz > 0:                          
            img = cv2.resize(img,(resz, resz))
        img = img.astype(np.float32)    
        img = (img - 128)/128
        test.append([[img], int(data[1]), data[0]])
    return test
    
def readDicom2png(dcmfnm, tmpfnm):
    ds  = pydicom.read_file(dcmfnm)      
    wc  = ds.WindowCenter                
    ww  = ds.WindowWidth                         
    img = ds.pixel_array                         
    max = wc + ww / 2                      
    min = wc - ww / 2      
    img = 255 * (img - min)/(max - min)   
    img[img > 255] = 255                  
    img[img < 0  ] = 0                    
    img = img.astype(np.uint8)
    cv2.imwrite(tmpfnm, img)
    return img   

def getLabelName(fname):
    lblnm = []
    for line in open(fname, 'r'):
        data = line.split(",")
        lblnm.append(data[1].strip())  
    print("LabelName:",lblnm)              
    return len(lblnm), lblnm

class MyModel(Chain):                   #ネットワークモデルを定義する
    def __init__(self, nlbl):           
        super(MyModel, self).__init__(
            l1 = L.Linear(784,100),       
            l2 = L.Linear(100,100),            
            l3 = L.Linear(100,nlbl),
        )
        
    def __call__(self, x):              #順伝播計算
        h1 = F.relu(self.l1(x))         #活性化関数にReLuを定義
        h2 = F.relu(self.l2(h1))
        return self.l3(h2)

class ClassifyImage:
    def __init__(self, modelname, nlbl):
        model = MyModel(nlbl)                              
        self.model = L.Classifier(model)
                
        print("modelファイル ",modelname," を読み込みます...")
        chainer.serializers.load_hdf5( modelname, self.model )
        print("...ファイルを読み込みました")

    def predict(self, testimg):
        img = np.array([testimg])
        #print(">img>>",type(img),len(img),len(img[0]),len(img[0][0]),len(img[0][0][0]))
        x = Variable(img)
        y = self.model.predictor(x)
        y = F.softmax(y)
        answer = y.data
        answer = np.argmax(answer, axis=1)
        return answer[0], y[0].data
    
if __name__=='__main__':
    testfnm  = "./TrainTestList/TestData.0.3.csv"
    labelfnm = "./TrainTestList/labelName.csv"
    resize   = 28                 #画像リサイズ
    modelnm  = "MyModel.0.3.hdf5" #モデル保存名
    
    nLbl, lblName = getLabelName(labelfnm)
    lblCnt = np.zeros(nLbl)    #各ラベルの正答数を計数
    lblAll = np.zeros(nLbl)    #各ラベルの総数を計数 
    
    data = constructTestData(testfnm, resize)
    print("data:",len(data),len(data[0]))
    print("img :",len(data[0][0]), len(data[0][0][0]),len(data[0][0][0][0]))
    
    ci = ClassifyImage(modelnm, nLbl)  #モデルの読み込み

    ok = 0
    np.set_printoptions(formatter={'float': '{: 0.5f}'.format})
    for i, line in enumerate(data):
        testimg, ans, fnm = line
        prediction, softmax = ci.predict(testimg)  #推論
        print("\n#",i,"Softmax:",softmax)
        result = "File:"+fnm+"\tAns:"+lblName[ans]+"→ Predict:"+lblName[prediction]
        lblAll[ans] += 1            #ラベルがansの数の計数
        if prediction == ans:          #推論結果と答えが一致したら
            print(result + "\tOK")
            ok += 1
            lblCnt[prediction] += 1
        else:
            print(result + "\tNG")
    
    print("\n平均正答率:",ok/len(data))  
    for i in range(nLbl):
        print(str(i), lblName[i],"\t",lblCnt[i]/lblAll[i])



実行結果


最初にラベル名labelName.csvを読み込みラベル名をターミナルに表示します。

次にテスト画像と正解ラベル番号が読み込み,ターミナルに表示します。
その後,学習モデルを読み込み,各画像について推論を行い,正答ラベルと推論結果を表示します。
両者が一致していればOKが,そうでなければNGが表示されます。

最終的に平均正答率と各ラベルの正答率を表示します。


$ python dicomNNtest.py
LabelName: ['Lung', 'Abdomen', 'Chest', 'Head']
Test 0 ../dcmdir2/Lung/E5306265 Label# 0
Test 1 ../dcmdir2/Lung/M5305390 Label# 0
Test 2 ../dcmdir2/Lung/C5304421 Label# 0
Test 3 ../dcmdir2/Lung/O5303765 Label# 0
Test 4 ../dcmdir2/Lung/P5305546 Label# 0
Test 5 ../dcmdir2/Lung/Q5305593 Label# 0
     ・・・・・
Test 68 ../dcmdir2/Head/B5220953 Label# 3
Test 69 ../dcmdir2/Head/P5221625 Label# 3
Test 70 ../dcmdir2/Head/L5221421 Label# 3
Test 71 ../dcmdir2/Head/W5222046 Label# 3
Test 72 ../dcmdir2/Head/X5222093 Label# 3
data: 73 3 28
modelファイル  myModel.0.3.hdf5  を読み込みます...
...ファイルを読み込みました

# 0 Softmax: [ 0.99994  0.00006  0.00000  0.00000]
File:../dcmdir2/Lung/E5306265 Ans:Lung→ Predict:Lung OK

# 1 Softmax: [ 0.99971  0.00001  0.00024  0.00004]
File:../dcmdir2/Lung/M5305390 Ans:Lung→ Predict:Lung OK

# 2 Softmax: [ 0.99986  0.00000  0.00011  0.00002]
File:../dcmdir2/Lung/C5304421 Ans:Lung→ Predict:Lung OK

# 3 Softmax: [ 0.99996  0.00000  0.00003  0.00001]
File:../dcmdir2/Lung/O5303765 Ans:Lung→ Predict:Lung OK
     ・・・・・
# 70 Softmax: [ 0.00002  0.00011  0.00002  0.99985]
File:../dcmdir2/Head/L5221421 Ans:Head→ Predict:Head OK

# 71 Softmax: [ 0.00023  0.00061  0.00009  0.99908]
File:../dcmdir2/Head/W5222046 Ans:Head→ Predict:Head OK

# 72 Softmax: [ 0.00084  0.00582  0.00023  0.99311]
File:../dcmdir2/Head/X5222093 Ans:Head→ Predict:Head OK

平均正答率: 0.9863013698630136
0 Lung   1.0
1 Abdomen   1.0
2 Chest   0.933333333333
3 Head   1.0



このプログラムは訓練プログラムと共通する部分が多いのでプログラム概要は割愛します。


おわりに


mnistのニューラルネットワークをDICOM画像に発展して適用しました。
かなりシンプルなネットワークで,しかも本来512x512のCT画像を28x28にリサイズして学習していますが,正答率は良い結果が得られています。
(ただし,同一人物のCT画像なので上手く行って当然ですね)

次に,Deep Neural Networkの代表であるAlexnetについてPyゼミ3.05で実験してみたいと思います。今回のプログラムが完成すると,次はモデルを変えるだけです。

Pyゼミ2.03 PyQt5によるDICOM画像のセグメンテーション

DICOM画像の注目する領域をセグメンテーションしてみよう

Keyword: DICOM, PyQt,マウスイベント, セグメンテーション, 二値画像

特定の領域を抽出する学習には訓練データと正解領域の教師データが必要になります。
例えば,臓器や病変部を抽出すようなとき,対象の訓練データに対して正解となる二値画像の教師データが必要です。

このプログラムでは,DICOM画像の注目する領域をマウスでポイントして囲み,囲まれた領域を二値画像としてpngファイルに保存することを考えます。

プログラムの概要


はじめに,マウスでポンとした座標を保存するリスト(配列)をイニシャライザ内で初期化します。
    self.points = [ ]

GUIのプログラムをinitUI関数内に記述します。
ここで画像上でマウスがクリックされたイベントを取る必要があります。
画像を貼り付けたラベルlblimgにマウスのイベントと処理を追加します。
          self.lblimg.setMouseTracking( True )
          self.lblimg.installEventFilter( self )

マウスイベントの取得はeventFilter関数で,以下の処理を行います。
イベントタイプが,QEvent.MouseButtonPressのとき,つまりマウスでポンとしたとき、クリックした座標をevent.posメソッドで取得します。
クリックした座標をリストpointsに追加していきます。         
                self.points.append( [ pos.x( ),  pos.y( ) ] )

さらに,リストpoints内の座標を画像に表示するためredisplay()関数を呼び出しています。
この関数はマウスがクリックされるたびに呼び出され、画像上の点を線で結びます。

最後に二値画像に保存するSaveボタンをクリックすると,二値画像がsegment.pngというファイル名で保存します。

プログラム起動時のWindow(左),マウスクリック時の多角形表示(中央)と二値画像(右)



プログラムを入力して実行みよう


dicomSegmentation.py

import sys,os
import numpy as np
import cv2
import pydicom
from PyQt5.QtCore import *
from PyQt5.QtGui import *
from PyQt5.QtWidgets import *
from PyQt5.Qt import *

class DicomSegmentation(QWidget):

    def __init__(self, filename):
        super().__init__()
        self.dcmfnm = filename    # DICOMファイル名
        self.points = []          #マウスでポイトした座標を格納する配列
        self.initUI()

    def initUI(self):
        # ファイル名を表示するラベルの作成
        lblfnm = QLabel(" File Name :  \t" + self.dcmfnm)
        fnmbox = QVBoxLayout()
        fnmbox.addWidget(lblfnm)

        # 画像読み込みラベルにセットする
        self.readDicom2png(self.dcmfnm, './dcm.png')    #DICOMを読んでpngに出力
        pixmap = QPixmap('./dcm.png')
        self.lblimg = QLabel()
        self.lblimg.setPixmap(pixmap)
        print("w=",pixmap.width(), "h=",pixmap.height())
        self.w = pixmap.width()
        self.h = pixmap.height()
        # 画像上のマウスイベントを取得する
        self.lblimg.setMouseTracking(True)
        self.lblimg.installEventFilter(self)
        # 画像表示レイアウトの設定
        imgbox = QVBoxLayout()
        imgbox.addWidget(self.lblimg)
       
        # Point Data保存
        pntSvBtn = QPushButton("Point Save")
        pntSvBtn.clicked.connect(self.pointSaveButtonClicked)
        # Point Clear ボタン
        pntClrBtn = QPushButton("Pont Clear")
        pntClrBtn.clicked.connect(self.pointClearButtonClicked)
        # ボタンレイアウト
        btnbox = QHBoxLayout()
        btnbox.addWidget(pntSvBtn)
        btnbox.addWidget(pntClrBtn)
       
        # 各パーツを配置する垂直なボックスを作成
        mainVbox = QVBoxLayout()       
        mainVbox.addLayout(fnmbox)
        mainVbox.addLayout(imgbox)
        mainVbox.addLayout(btnbox)

        # 垂直ボックスをウィンドウにレイアウトする
        self.setLayout(mainVbox)   
        self.setWindowTitle('DICOM Segmentation')   
        self.show()
       
    #画像上でマウスクリックした座標を取得し,保存して画像表示する
    def eventFilter(self, object, event):
        if event.type() == QEvent.MouseButtonPress:     #イベントタイプ
            pos = event.pos()                           #座標の取得                         
            if object is self.lblimg:                   #lblimg上ででマウスがクリックされたら            
                self.points.append([pos.x(), pos.y()])  #x,y座標を配列に追加する
                print(">>points=",self.points)
                self.redisplay()                        #pointsを画像上に表示する  
            
        return QWidget.eventFilter(self, object, event)
                
    #セグメント領域を画像中に多角形として重ね合わせ表示する    
    def redisplay(self):
        img = cv2.imread("./dcm.png", 1)                #切り出した画像の読込
        pnt = np.array(self.points, dtype = np.int64)   #配列を変換
        cv2.polylines(img, [pnt], True, (64,128,255))            
        cv2.imwrite("./fusion.png", img)                #重ねあわせ画像を保存する
        pixmap = QPixmap("./fusion.png")    
        self.lblimg.setPixmap(pixmap)       
        self.show()


    # セグメンテーション画像データの保存   
    def pointSaveButtonClicked(self):
        print("Save point data:", self.points)
        #セグメント画像の作成と保存   
        img = np.full((self.w, self.h, 3), 0, dtype = np.uint8)   #空の画像imgの作成          
        pnt = np.array(self.points, dtype = np.int64) #ポイント座標をndarrayに変換
        cv2.fillPoly(img, [pnt], (255,255,255))       #画像img上にセグメント領域を描く
        cv2.imwrite("./segment.png", img)             #セグメンテーション画像を保存
       
    # ポイント座標をクリアする
    def pointClearButtonClicked(self):
        print("Clear all points")
        self.points = []      #Point座標を初期化
        self.redisplay()      #Pointを画像上に表示する           
    
   
    # DICOM画像読み込んでウィンドニング後PNGに保存      
    def readDicom2png(self, dcmfnm, tmpfnm):
        ds  = pydicom.read_file(dcmfnm)      #DICOM画像を読み込む
        wc  = ds.WindowCenter                #ウィンドウセンター値を代入
        ww  = ds.WindowWidth                 #ウィンドウ幅を代入       
        img = ds.pixel_array                 #画素値を代入       
        #表示画素値の最大と最小を計算する
        max = wc + ww / 2                     
        min = wc - ww / 2     
        #ウインドニング処理
        img = 255 * (img - min)/(max - min)   #最大と最小画素値を0から255に変換
        img[img > 255] = 255                  #255より大きい画素値は255に変換
        img[img < 0  ] = 0                    #0より小さい画素値は0に変換
        img = img.astype(np.uint8)
        cv2.imwrite(tmpfnm, img)

if __name__ == '__main__':
    app = QApplication(sys.argv)   
    filename = "../dcmdir1/Brain02"
    ex = DicomSegmentation(filename)
    sys.exit(app.exec_())



実行結果


GUI上から対象領域をクリックすると各ポイントがオレンジの線で結ばれます。




マウスで画像をクリックすたびに次のように座標がターミナルに表示されます。

$ python␣dicomSegmentation.py⏎
w= 512 h= 512
>>points= [[215, 88]]
>>points= [[215, 88], [212, 99]]
>>points= [[215, 88], [212, 99], [193, 113]]
>>points= [[215, 88], [212, 99], [193, 113], [180, 117]]
>>points= [[215, 88], [212, 99], [193, 113], [180, 117], [163, 106]]
>>points= [[215, 88], [212, 99], [193, 113], [180, 117], [163, 106], [160, 82]]



saveボタンを押すと2値画像のsegment.pngファイルが出力されます。




プログラム解説


__main__では,表示するDICOM画像のファイル名をfilenameに設定し,これを引数にDicomSegmentationクラスを呼び出しています。


__init__関数

ファイル名をself.dcmfnmに代入します。
マウスがポイントした一連の座標を格納するリストself.pointsを初期化します。

initUI関数

最初に,ファイル名などの情報を表示するためのラベルlblfnmとそれをレイアウトするfnmboxを作成します。

次に,DICOM画像を読み込みラベルlblimgに貼り付けます。
DICOM画像は,readDicom2png関数を使って,一旦pngファイルに書き出します。
このpngファイルをsetPixmapを使ってラベルlblimgにセットしています。
このpixmapの画像の幅と高さをそれぞれself.wとself.hに代入しています。
この幅と高さの値は二値画像で出力する際,同じサイズの画像を作成するに使います。

画像上のマウスイベントを有効にするため,setMouseTrackingをTrueにし,クリック時の処理を有効にするため,installEventFilterによりラベルlblimgに設定しています。

lblimgをレイアウトするにimgboxを作成して,lblimgをaddWidgetでセットしています。

次に,結果を二値画像として保存するボタンpntSvBtnと画像上の座標をクリアするボタンpntClrBtnを作成しています。
両ボタンともQPushButtonにより作成します。
クリックされた時の処理、呼び出される関数pointSaveButtonClicked関数とpointClearButtonClicked関数をclicked.connectにより結合しています。

これらのボタンはQHBoxLayoutにより水平に並べて配置するようにbtnboxに設定します。


最後に,すべてのBoxを垂直に配置するようにQVBoxLayoutによりmainVboxを作成して,fnmbox,imgboxとbtnboxをレイアウトします。
そしてmainVboxをウィンドにセットして表示しています。

eventFilter関数

マウスクリック時のイベントを取得する関数です。
イベントタイプがマウスがクリックされた(押された)QEvent.MouseButtonPressのとき,処理が行われます。

画像ラベルlblimg上でのクリックであることを判断するため次のif文を入れています。
    if object is self.lblimg:
今回,画像は1枚なので,このif文はなくとも問題ありませんが,複数の画像などを制御する場合には必要になるので,参考までにこのif文を入れておきました。

座標x,yをリストpointsに追加します。
    self.points.append( [ pos.x( ), pos.y( ) ] )
例えば,3つの座標がpointsに格納されている時,次のように二次元のリストで格納されています。
    [ [ x0,y0 ], [ x1, y1 ], [ x2, y2 ] ]
つまり,self.points[1]は [ x1, y1] のように取り出すことができます。

redisplay関数

マウスがポイントするたびに呼び出され,画像上の情報を更新します。

はじめのDICOM画像をpngに変換した画像dcm.pngをimgに読み込みます
    img = cv2.imread( "./dcm.png", 1 )
ここで,引数はファイル名"./dcm.png"とRGBカラーで読み込むことを「1」で指定しています。
RGBにするのは色のついた多角形をこの画像の上に描くためです。

polylinesメソッドは画像imgと座標pntを引数に渡して多角形を描画します。
    cv2.polylines(img, [pnt], True, (64,128,255))
このとき,描画する色RGBを(64,128,255)で指定しています。
お好みで変えてみると良いでしょう。

この画像imgを一旦pngに書きだしたあとQPixmapで読み込んでlblimgにセットしています。
この画像のラベルはクラス内で共有されているため,セットしたあと,self.show()を実行することにより画像が更新されます。

pointSaveButtonClicked関数

マウスでクリックして囲まれた領域を二値画像で保存する関数です。
画素値がすべてゼロの画像imgを作成します。
    img = np.full( ( self.w, self.h, 3 ), 0, dtype=np.uint8 )
最初の引数(self.w, self.h, 3)は画像の大きさをself.wとself.hで決定し,3チャンネルの画像(カラー画像)を表します。
次の引数「0」は画素が0であることを表します。
最後の引数のデータタイプdtypeは非符号8ビット整数(uint8)であることを表しています。

座標が格納されたリストpointsをfillPolyメソッドに渡すためにndarrayに変換します。
    pnt = np.array( self.points,  dtype = np.int64 )
ndarrayで変換する際、データタイプdtypeは64ビット整数に変換されます。

ndarrayの座標値pntと画像imgを引数に二値画像を作成します。
    cv2.fillPoly( img, [ pnt ], ( 255,255,255 ) )
fillPolyメソッドは座標点を結ぶ多角形(pnt)内をRGBを255,255,255で塗りつぶします。
ここでは画像ビュワで表示して確認しやすくするため,255を用いましたが,0と1の二値画像を作成したい場合は255を1にして(1,1,1)とします。

最後にOpneCVのメソッドimwriteを使ってpng画像に出力します。
    cv2.imwrite( "./segment.png", img )

pointClearButtonClicked関数

画像上のポイントされた情報を消す関数です。
リストpointsの値を初期化して,その状態でredisplay関数を呼び出すことにより、消えます。

readDicom2png関数

DICOM画像を読み込みpngファイルに出力します(2.01を参考)。


まとめ

注目する領域をマウスでクリックして二値画像を作成するプログラムを作成しました。
マウスでクリックした座標を取得する方法を学びました。
クリックした座標値をリストに追加して保持することがこのプログラムの重要な点です。
多角形を表示するOpenCVのfillPolyメソッドを容易に利用することができます。



課 題

1)ボタンを押すと,直近の点から一つづつ削除していく”Remove Point"ボタンを作成しなさい。

2)ボタンを押すと,ポイントした多角形の表示をOn/Offする”On/Off”ボタンを作成しなさい。




2019年2月5日火曜日

Pyゼミ2.04 PyQt5によるDICOM画像の拡大表示と保存

DICOM画像の一部を拡大表示して画像を保存してみよう



Keyword: DICOM, マウスイベント, 画像拡大

小さな領域を拡大表示して観察したり,小さな領域を切り出して保存したりするプログラムを作ってみます。
画像中のある点をクリックすると,その点(座標x,y)を中心に決められたサイズの矩形領域を元の画像と同じ大きさに拡大して表示します。
拡大表示と同時に,矩形領域をpngファイルに保存します。
このとき画像ファイル名に座標x,yを付加します。


プログラム概要


はじめに イニシャライザ(__init__関数)は次の引数を持ちます。

  • 対象のDICOM画像ファイルのパス名:filename
  • 切り出す矩形領域のサイズ:cropSize


DICOM画像ファイルパス名からファイル名だけを取り出し,self.bsfnmに代入します。
    self.bsfnm  = os.path.basename( filename )

このself.bsfnmは矩形領域を画像保存するときのファイル名に利用します。

次に,initUI関数では,画像ラベルlblimg上でマウスのクリックイベントを得るために,次の2行を記述します。
    self.lblimg.setMouseTracking( True )
    self.lblimg.installEventFilter( self )

画像上でマウスをクリックすると呼び出されるeventFilter関数は,次の処理が行われます。
①マウスのクリックしした座標の取得
    pos = event.pos()
    x = pos.x()
    y = pos.y()
②切り出す矩形領域の左上の座標(topx, topy)と右下の座標(btmx, btmy)の算出
                topx = x - self.crpsz // 2
                topy = y - self.crpsz // 2
                btmx = x + self.crpsz // 2
                btmy = y + self.crpsz // 2
③矩形領域を画像imgから抽出してcrpimgへ代入
    crpimg = img[ topy : btmy ,  topx : btmx ]
リストimgからスライシングして矩形領域を得ます。
このとき,xとyが逆です。つまり行列の行はy,列はxとなるので,画像と行列は逆の関係にあるので注意が必要です。


起動すると


指定されたDICOM画像が左のウィンドウに現れれます。


画像中の任意の位置(バグあり,課題参照)でマウスをクリックすると右のウィンドウに拡大表示され,画像を保存するかダイアログが表示されます。



OKボタンをクリックすると,画像が保存される。





プログラムを入力して実行してみよう


dicomMagnify.py


import sys,os
import numpy as np
import cv2
import pydicom
from PyQt5.QtCore import *
from PyQt5.QtGui import *
from PyQt5.QtWidgets import *
from PyQt5.Qt import *

class DicomMagnify(QWidget):

    def __init__(self, filename, cropSize):
        super().__init__()
        self.dcmfnm = filename                  # DICOMファイル名(パス)
        self.bsfnm  = os.path.basename(filename)# DICOMファイル名
        self.crpsz  = cropSize                  # 切り出しサイズ
        self.initUI()

    def initUI(self):
        # ファイル名を表示するラベルの作成
        lblfnm = QLabel(" File Name :  \t" + self.dcmfnm)
        fnmbox = QVBoxLayout()
        fnmbox.addWidget(lblfnm)

        # DICOM画像読み込みラベルにセットする
        self.readDicom2png(self.dcmfnm, './dcm.png')    # DICOMを読んでpngに出力
        pixmap = QPixmap('./dcm.png')
        self.lblimg = QLabel()
        self.lblimg.setPixmap(pixmap)
        self.w = pixmap.width()
        self.h = pixmap.height()
        # 画像上のマウスイベントを取得する
        self.lblimg.setMouseTracking(True)
        self.lblimg.installEventFilter(self)
       
        #拡大画像表示ラベルの作成
        img = np.full((self.w, self.h, 1), 128,dtype = np.uint8)
        cv2.imwrite("./magnify.png", img)
        pixmap = QPixmap("./magnify.png")
        self.lblmag = QLabel()
        self.lblmag.setPixmap(pixmap)
       
        # 画像表示レイアウトの設定
        imgbox = QHBoxLayout()
        imgbox.addWidget(self.lblimg)
        imgbox.addWidget(self.lblmag)
       
        # 各パーツを配置する垂直なボックスを作成
        mainVbox = QVBoxLayout()       
        mainVbox.addLayout(fnmbox)
        mainVbox.addLayout(imgbox)

        # 垂直ボックスをウィンドウにレイアウトする
        self.setLayout(mainVbox)   
        self.setWindowTitle('DICOM Magnification')   
        self.show()
             
    #画像上でマウスクリックした座標を取得し,保存して画像表示する
    def eventFilter(self, object, event):
        if event.type() == QEvent.MouseButtonPress: # イベントタイプ
            pos = event.pos()                       # 座標の取得                        
            if object is self.lblimg:               # lblimg上でマウスがクリックされたら           
                x = pos.x()
                y = pos.y()
                print("x,y=",x, y)               
                topx = x - self.crpsz//2
                topy = y - self.crpsz//2
                btmx = x + self.crpsz//2
                btmy = y + self.crpsz//2
               
                img = cv2.imread("./dcm.png")
                crpimg = img[topy:btmy, topx:btmx]   # 画像の切り出し
                magimg = cv2.resize(crpimg, (self.h, self.w)) 
                cv2.imwrite("./magnify.png", magimg)   
                pixmap = QPixmap("./magnify.png")
                self.lblmag.setPixmap(pixmap)
                self.show()
                   
                #保存確認メッセージ
                svfnm = self.bsfnm+".x."+str(x)+".y."+str(y)+".png"
                btnMsg = QMessageBox.question(self, 'Save Image',
                    'Save Image?\nFile name: '+svfnm, 
                    QMessageBox.No | QMessageBox.Yes, QMessageBox.No)
                self.show()
                if btnMsg == QMessageBox.Yes:
                    cv2.imwrite(svfnm, crpimg)      
               
        return QWidget.eventFilter(self, object, event)
       
    # DICOM画像読み込んでウィンドニング後PNGに保存      
    def readDicom2png(self, dcmfnm, tmpfnm):
        ds  = pydicom.read_file(dcmfnm)     
        wc  = ds.WindowCenter               
        ww  = ds.WindowWidth                        
        img = ds.pixel_array                        
        #表示画素値の最大と最小を計算する
        max = wc + ww / 2                     
        min = wc - ww / 2     
        #ウインドニング処理
        img = 255 * (img - min)/(max - min)  
        img[img > 255] = 255                 
        img[img < 0  ] = 0                   
        img = img.astype(np.uint8)
        cv2.imwrite(tmpfnm, img)

if __name__ == '__main__':
    app = QApplication(sys.argv)   
    filename = "../dcmdir1/Brain02"
    cropSize = 128
    ex = DicomMagnify(filename, cropSize)
    sys.exit(app.exec_())



実行結果


画像上でマウスをクリックすると,その座標を中心に拡大表示されます。
画像保存ダイアログが表示されるので,OKボタンをクリックするとDICOM画像名に座標が付加されたpngファイルが作成されます。


$ python␣dicomMagnify.py⏎
x,y= 190 89



プログラム解説


initUI関数

最初に,ファイル名などの情報を表示するためのラベルlblfnmとそのラベルをレイアウトするfnmboxを作成しまし。

次に,DICOM画像を読み込みラベルlblimgに貼り付けています。
DICOM画像は,readDicom2png関数を使って,一旦pngファイルに書き出します。
このpngファイルをsetPixmapを使ってラベルlblimgにセットしています。
このpixmapの画像の幅と高さをそれぞれself.wとself.hに代入しています。

画像上のマウスイベントを有効にするため,setMouseTrackingをTrueにし,クリック時の処理を有効にするため,installEventFilterによりラベルlblimgに設定しています。

画像をレイアウトするためimgboxを作成して,lblimgをaddWidgetでセットしています。


次に拡大表示用のラベルlblmagを作成します。
初期状態ではグレーの空画像を貼り付けます。
この空画像の大きさは元画像と同じself.w, self.hで,1チャンネルの画像,濃度は128の8ビット符号なし整数です。
    img = np.full( ( self.w, self.h,1), 128, dtype = np.uint8)
この画像を一旦,magnify.pngとして保存して,QPixmapで読み込み,ラベルlblmagに貼り付けています。

2つの画像ラベルはimgboxに水平にレイアウトします。

最後にmainVboxにfnmbox, imgboxをレイアウトします。


eventFilter関数

マウスがクリックされた(QEvent.MouseButtonPress)とき,この関数の処理が実行されます。
また,マウスのクリックがDICOM画像上(lblimg上)で行われたことを次のように知り処理が行われます。
    if object is self.lblimg:

クリックした座標を変数x,yに代入し,切り出す矩形の座標を計算します。

DICOM画像imgからスライシングを用いて次のように切り出します。
    crpimg = img[ topy : btmy,  topx : btmx ]

切り出した矩形画像を一旦,magnify.pngとして保存して,拡大画像表示ラベルlblmagにセットして,表示します。

画像の保存は,DICOM画像名bsfnmに座標xとyを付加して保存ファイル名svfnmを作成しました。
QMessageBox.questionを使って保存確認ダイアログを表示します。
このとき,画像ファイル名svfnmを表示します。
Yesボタンをクリックすると,crpimgがファイルに保存されます。


readDicom2png関数

DICOM画像を読み込みpngファイルに出力します。(2.01を参考)


おわりに

ここでは3つのこと学びました。

  1. 画像上のマウスイベントの設定の仕方
  2. マウスをクリックしたときのイベント処理
  3. 画像から任意の領域を抽出する方法
基本的な処理を学びましたが,イベントのタイプを変えることでいろいろな処理に対応できます。どのようなことができるのか考えてみましょう。


課 題

1)このプログラにはバグが存在する。もし,マウスが座標0,0をクリックした時,切り出す右上の座標はどちらも負の値をもち,切り出す範囲を超えてしまうためスライシングが上手くできない。同様にx,yが511,511のときも同じことが起きる。
切り出す矩形範囲が元画像の範囲からはみ出さないように処理をくわえなさい。
(※はみ出すとき警告を出し,切り出す処理を行わないようにしなさい)

>>> import cv2
>>>
>>> img = cv2.imread("./dcm.png") # DICOM画像の読み込み
>>> img.shape[:2]         # 画像サイズの表示
(512, 512)
>>> 
>>> x, y = 0, 0      # マウスクリックの座標
>>> 
>>> crpsz = 128//2    # 切り出すサイズ
>>> topx = x - crpsz
>>> topy = y - crpsz
>>> topx, topy      # 左上の座標
(-64, -64)        # 切り出す範囲が負の値になりはみ出している
>>> btmx = x + crpsz
>>> btmy = y + crpsz
>>> btmx,btmy       # 右下の座標
(64, 64)
>>> 
>>> crpimg = img[topy:btmy, topx:btmx] # 切り出し,エラーは起きない
>>> crpimg.shape[:2]   # 切り出した画像のサイズ
(0, 0)          # サイズが0,0なので上手く切りだされていない