Pythonで画像処理② Data Augmentation (画像の水増し)

画像の水増し、Data Augmentationと呼ばれる方法になりますが、学習に使う画像に変形を加えたり、ノイズを加えたり、明るさを変えたりといった処理を行う方法を紹介します。学習画像に様々な処理を行うことで認識がロバストになるというメリットがあります。

目次

今回も前回に引き続き、Pythonでの画像処理を紹介していきたいと思います。「細胞種を機械学習で判別する!」( https://lp-tech.net/articles/e0mRJ )でも軽く触れましたが、画像の水増しについて書きたいと思います。

画像を利用した機械学習では大量の画像をもとにニューラルネットワークに学習を行わせますが、しばしば大量の画像を用意するのが難しいときもあり、少ない画像を最大限に活用したいというシチュエーションは比較的多く存在すると思います。そこで、画像の水増し、Data Augmentationと呼ばれる方法になりますが、学習に使う画像に変形を加えたり、ノイズを加えたり、明るさを変えたりといった処理を行う方法を紹介します。学習画像に様々な処理を行うことで認識がロバストになるというメリットがあります。

ヒストグラム

8ビットのグレースケール画像について言えば、画像の中には真っ黒な画素(0)から真っ白な画素(255)までの間の256段階の階調の値が存在します。それをヒストグラムにすると、画像中の画素値の分布がわかります。認識をロバストにする上でヒストグラムの形状が学習画像と、実際に判別させたい画像で大きく異なっていると不都合です。今回は学習画像のヒストグラムを様々に変える方法と、学習画像のヒストグラムを正規化し、実際に判別させたい画像の前処理としてヒストグラムの正規化を前提とする2つの解決策を示します。

学習画像のヒストグラムを様々に変える

学習前の画像のヒストグラムを様々に変えることで、学習済みのモデルが様々なヒストグラム形状を持つ画像に対応できるようにしておこうという考え方です。
ヒストグラムの変え方は色々考えられると思いますが、簡単なものだけ紹介します。
import numpy as np
import cv2

src = cv2.imread('src.png',0)

img = src*1.2 #輝度値が2倍になる
cv2.imwrite("1.png",img)
img = src + 40 #輝度値のベースが40上がる
cv2.imwrite("2.png",img)
img = (img-np.mean(src))/np.std(src)*32+120 #標準偏差32,平均120に変更
cv2.imwrite("3.png",img)
img = (img-np.mean(src))/np.std(src)*16+120 #標準偏差16,平均120に変更
cv2.imwrite("4.png",img)
hist.py

なお、numpyで255以上の画素値を持つ画像をpngなどでcv.imwriteで保存しようとするとおかしな画像が保存されてしまいます。そのような場合には255以上の画素値は255になるように処理します。
img = np.minimum(img,255)
minimum.py

ヒストグラムを正規化する

学習に用いる画像を予め処理し、ヒストグラムの形状を整えておく方法です。この方法を使った場合には、学習済みモデルで推論を行う場合にも画像に同様の前処理を前提とするので注意が必要です。学習に用いた画像に行った処理と推論の対象画像に行う処理が別物のだと期待通りの結果は得られません。
ヒストグラムを正規化する方法では学習の対象の画像の枚数を闇雲に増やすことがないのでよく使われます。一番簡単な正規化の方法は、画素値の分布の平均と分散を揃える方法でしょう。PythonとOpenCVでは、下のようなコードで実現できます。
img = (img - np.mean(img))/np.std(img)*16+64
norm.py

画像の平均輝度を64に、輝度値の標準偏差を16に揃えています。画像の平均はmean関数で、標準偏差はstd関数で求まるので、元画像から平均輝度値を引いて標準偏差で割った時点で平均0、標準偏差1の画像が出来ます。そこにさらに16をかけて、64を足すことで、画像の平均輝度を64に、輝度値の標準偏差を16に揃えています。

CLAHE

CLAHEと呼ばれる手法もあります。CLAHEはヒストグラムができるだけ均等にバラけるように再配分します。OpenCVで実装されているので簡単に試すことができます。
import numpy as np
import cv2

img = cv2.imread('src.png',0)

clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))
cl1 = clahe.apply(img)

cv2.imwrite('dst.jpg',cl1)
clahe.py

ノイズを加える

ごま塩ノイズと呼ばれるノイズを加えてみます。画像の中のランダムな位置に黒(ごま)と白(塩)を撒くイメージです。次のようなコードで実現できます。
img = cv2.imread("a.png", 1)
img = cv2.cvtColor(img,cv2.COLOR_BGR2RGB)
plt.imshow(img)
plt.show()

row,col,ch = img.shape

# 白
pts_x = np.random.randint(0, col-1 , 1000) #0から(col-1)までの乱数を千個作る
pts_y = np.random.randint(0, row-1 , 1000)
img[(pts_y,pts_x)] = (255,255,255) #y,xの順番になることに注意

# 黒
pts_x = np.random.randint(0, col-1 , 1000)
pts_y = np.random.randint(0, row-1 , 1000)
img[(pts_y,pts_x)] = (0,0,0)

plt.imshow(img)
plt.show()
saltpepper.py

画像を歪める

画像を回転させたり、反転させたりするのはOpenCVを使えば簡単にできます。
src = cv2.imread("img.jpg", 1)
horizontal_flip = cv2.flip(src, 1) #水平方向に反転
vertical_flip = cv2.flip(src, 0) #垂直方向に反転
flip.py
今回はもっと複雑な変形をやってみたいと思います。

透視変換

正方形を任意の四角形に変形する一次変換のことです。アフィン変換は三角形を変形するときに使いますが、これのさらに一つ上の概念が透視変換です。次に紹介するコードでは正方形の入力画像を変形させています。

手書き文字の学習画像など、反転を行うのがふさわしくない場合にも使うことができます。
重要な箇所として、

pts1 = np.float32([[0,0],[0,100],[100,100],[100,0]])
pts2 = np.float32([[0,0],[0, 99],[101,101],[100,0]])

ここの箇所がもとの正方形を構成する点と、それらの点が変換後どこに移動するかを指定している部分です。移動前と移動後の点のセットからcv2.getPerspectiveTransform関数によって変換行列を計算し、cv2.warpPerspective関数によって画像を変換しています。
import cv2
import numpy as np
import random
import matplotlib.pyplot as plt
%matplotlib inline

img = cv2.imread("img2.png",0)
rows,cols=img.shape

pts1 = np.float32([[0,0],[0,100],[100,100],[100,0]])
pts2 = np.float32([[0,0],[0, 99],[101,101],[100,0]])

M = cv2.getPerspectiveTransform(pts1,pts2)
dst = cv2.warpPerspective(img,M,(cols,rows))

plt.subplot(121),plt.imshow(img),plt.title('Input')
plt.subplot(122),plt.imshow(dst),plt.title('Output')
plt.show()
warpPerspective.py

重ね合わせてみると。。

ずれているのがわかります。同様のことを百枚分処理して重ね合わせてみます。
import cv2
import numpy as np
import random
import matplotlib.pyplot as plt
%matplotlib inline

img = cv2.imread("img2.png",0)
rows,cols=img.shape
sumimg = img/255.0
for i in range(100):
    var=20
    pts1 = np.float32([[0,0],[0,rows],[cols,rows],[cols,0]])
    pts2 = np.float32([[random.randint(-var,var),random.randint(-var,var)],
                       [random.randint(-var,var), rows+random.randint(-var,var)],
                       [cols+random.randint(-var,var),rows+random.randint(-var,var)],
                       [cols+random.randint(-var,var),random.randint(-var,var)]])

    M = cv2.getPerspectiveTransform(pts1,pts2)
    dst = cv2.warpPerspective(img,M,(cols,rows))
    sumimg+=dst/255.0

plt.imshow(sumimg)
plt.colorbar()
plt.show()
100.py

var=20となっているところをvar=40とすると、ブレが大きくなります。

画像を分割し、それぞれで異なる変換行列で変換を行う

このような画像全体を不規則に歪める処理を実装してみます。
import cv2
import numpy as np
import random
import matplotlib.pyplot as plt
%matplotlib inline

img = cv2.imread("img2.png",0)

def transform(img):
    N=12
    S=16
    originalSize = img.shape
    img = cv2.resize(img,(S*N+1,S*N+1))
    rows,cols=(S*N+1,S*N+1)
    points=[]
    #N*Nのグリッドを作成
    for i in range(0,rows,S):
        tmp=[]
        for j in range(0,cols,S):
            tmp.append([i,j])
        points.append(tmp)

    points_t=[]
    #10*10のグリッドを作成
    for i in range(0,rows,S):
        tmp=[]
        for j in range(0,cols,S):
            n=6
            tmp.append([i+random.uniform(-S//n,S//n),j+random.uniform(-S//n,S//n)])
        points_t.append(tmp)

    t = img*0

    for i in range(N):
        for j in range(N):
            pts1 = np.float32([points[i][j],points[i+1][j],points[i+1][j+1],points[i][j+1]])
            pts2 = np.float32([points_t[i][j],points_t[i+1][j],points_t[i+1][j+1],points_t[i][j+1]])

            M = cv2.getPerspectiveTransform(pts1,pts2)
            dst = cv2.warpPerspective(img,M,(cols,rows))
            pts1_ = np.int32(pts1).reshape((-1,1,2))
            pts2_ = np.int32(pts2).reshape((-1,1,2))
            mask = dst*0

            cv2.fillPoly(mask,[pts2_],1)
            t = cv2.bitwise_or(t,dst*mask)
    t = cv2.resize(t,originalSize)
    return t

t=transform(img)
plt.subplot(121),plt.imshow(img),plt.title('Input')
plt.subplot(122),plt.imshow(t),plt.title('Output')
plt.show()
transform.py
5枚まとめて処理して重ね合わせてみます。
sumimg=img*0.0
for i in range(5):
    sumimg+=transform(img)/255.0
plt.imshow(sumimg)
plt.show()
sum.py

画像を分割しそれぞれで異なる変換行列で変換を行うと、画像全体を一つの変換行列で変換したときと比べると位置のばらつきが小さくなっているように見えますが、線がクネクネと曲がるようになりました。

適材適所で使い分けられるかもしれません。

まとめ

画像にノイズを加えたり、明るさを変えたり、変形したりしました。推論のときにはどのような入力が与えられるのかわからないので、このようなデータの水増しはロバストな推論を実現する上でとても重要だと思います。

それでは、また次回、お楽しみに!!