Numpyで画像処理をしよう

f:id:monozukuri-bu:20200507152833j:plain

こんにちは、kanaiです。
今回はNumpyを使って画像処理を扱っていきたいと思います。
OpenCVを使えば1行で済む処理かもしれませんが、学習のためにあえて行列で計算を行ってみます。

最初に画像の基本処理について紹介し、次に深層学習の前処理で使われるような画像データの水増し処理について紹介します。

動作環境と画像データ

以下の環境で動作確認しています。

Python       3.7.3
Jupyter Lab  1.2.6
Pillow       7.0.0
Numpy        1.18.1
Matplotlib   3.1.3
Scipy        1.4.1

なお、Jupyter Lab上で実装する前提で進めていきます。
画像は以下の素材をお借りします。

f:id:monozukuri-bu:20200507153446j:plain

モジュールのインポート

まずは使用するモジュールをインポートします。
画像の読み込みにPillow、表示にMatplotlibを使うので、Numpyと同時にインポートしておきましょう。

from PIL import Image
import numpy as np
import matplotlib.pyplot as plt
画像の読み込み

画像データの読み込みはPillowのopen()メソッドを使います。
読み込んだデータをNumpy配列に渡してみましょう。
Numpy配列の形状を確認してみると、3次元配列が得られていることがわかります。

img = np.array(Image.open('cat.jpg'))
print(img.shape)
# (480, 720, 3)

これは左から順に、行(高さ), 列(幅), 色(カラーチャネル)を表しています。
色の並びは (R, G, B)の順番となっており、それぞれの値は0~255の範囲になっています。

ちなみに読み込み時にconvert('L')を使用するとグレースケールに変換されます。
カラーチャネルが不要になるため、先ほどとは違い2次元配列として読み込まれることがわかります。

img_gray = np.array(Image.open('cat.jpg').convert('L'))
print(img_gray.shape)
# (480, 720)
画像の保存

Image.fromarray()を使用すると、引数に指定したndarrayから画像のオブジェクトを得られます。
この画像のオブジェクトに対しsave()メソッドを実行することで画像として保存することができます。

試しに、グレースケールとして読み込んだ画像を保存してみましょう。

img_gray_obj = Image.fromarray(img_gray)
img_gray_obj.save('cat_gray.jpg')

以下のような形で保存ができました。
グレースケールならではの雰囲気を感じられますね。

f:id:monozukuri-bu:20200507155306j:plain

画像の水増し

深層学習などで画像処理に取り組む時には、画像のデータ数が重要になります。
そこで、データを増やすために元の画像を回転・拡大したり明暗の調整をすることでデータを水増しすることを考えます。

反転(水平方向)

まずは水平方向への反転処理です。
水平方向へ反転させるには、列の値が格納されている2つ目の要素を反転させれば良いですね。
そしてNumpyにおいては、スライスで[::-1]とすると反転できます。

これらを組み合わせて実装してみます。

def hrztl_flp(img):

    # 列の要素を反転させる
    return img[:, ::-1, :]

hrztl_img = hrztl_flp(img)
hrztl_img_obj = Image.fromarray(hrztl_img)
hrztl_img_obj.save('hrztl_cat.jpg')

f:id:monozukuri-bu:20200507161729j:plain

実際には画像をランダムに反転させることがあります。
下記ではrand()で0以上1未満の値をランダムに取得し、基準値(デフォルト:0.5)を下回ったら水平方向に反転させ、基準値以上なら反転せずにそのまま値を返す処理を施しています。

def hrztl_flp(img, rate=0.5):

    # 0以上1未満の値をランダムに取得
    # 基準値を下回ったら水平方向に反転させる
    if np.random.rand() < rate:
        return img[:, ::-1, :]

hrztl_img = hrztl_flp(img)
hrztl_img_obj = Image.fromarray(hrztl_img)
hrztl_img_obj.save('hrztl_cat.jpg')
反転(垂直方向)

今度はランダムに垂直方向へ反転させてみましょう。
行の要素を反転させれば良いので、画像の配列の1つ目の要素に対して[::-1]します。

def vtcl_flp(img, rate=0.5):

    if np.random.rand() < rate:
        # 行の要素を反転させる
        return img[::-1, :, :]

vtcl_img = vtcl_flp(img)
vtcl_img_obj = Image.fromarray(vtcl_img)
vtcl_img_obj.save('vtcl_cat.jpg')

f:id:monozukuri-bu:20200522095311j:plain

ただしこの時注意が必要です。
例えば👍というような画像を垂直方向に反転させると👎となり、意味が逆になってしまうため、意図した学習ができなくなってしまいます…。

垂直方向の反転に限らず、データを水増しさせる際にはどのようなモデルを作成したいか考慮した上で実施しましょう。

切り抜き

画像をランダムに切り抜きします。 今回はデフォルトのサイズを(224, 224)として切り抜きを行ってみましょう。

def rndm_crp(img, crp_size=(224, 224)):

    # 画像の形状を取得(色の情報は使わないので_に格納)
    h, w, _ = img.shape
 
    # 行、列のサイズから切り抜くサイズ(224)を引く
    # 0~引いた値の範囲でランダムにtop, leftを決める
    top = np.random.randint(0, h - crp_size[0])
    left = np.random.randint(0, w - crp_size[1])

    # top, leftの値に切り抜くサイズ(224)を足してbottom, rightを決める
    bottom = top + crp_size[0]
    right = left + crp_size[1]

    # top~bottom, left~rightの範囲で画像を切り抜く
    return img[top:bottom, left:right, :]

crp_img = rndm_crp(img)
crp_img_obj = Image.fromarray(crp_img)
crp_img_obj.save('rndm_crp_cat.jpg')

実行すると下記のように画像が切り抜かれました。

f:id:monozukuri-bu:20200507162327j:plain

切り抜き(スケール変化)

今度は画像のサイズスケールをランダムに変化させた上で切り抜きを行ってみましょう。
(256, 256) ~ (512, 512)のサイズで画像をリサイズし、リサイズされた結果に対し、先ほど定義したrndm_crpを実行します。

def scale_arg(img, scale_range=(256, 512), crop_size=224):

    # scele_rangeの範囲でランダム値を取得
    scale_size = np.random.randint(*scale_range)

    # 画像をImageオブジェクトに変換後、scale_sizeの値でリサイズ
    img = np.array(Image.fromarray(img).resize((scale_size, scale_size)))

    # リサイズされた画像からrndm_crp()でランダムに切り抜き
    return rndm_crp(img, (crop_size, crop_size))

scale_arg_img = scale_arg(img)
arg_img_obj = Image.fromarray(scale_arg_img)
arg_img_obj.save('scale_arg_cat.jpg')

リサイズされた状態で画像が切り抜かれました。

f:id:monozukuri-bu:20200507162453j:plain

回転

画像を0~360度の範囲でランダムに取得した角度で回転させてみましょう。
画像を回転させるためにはScipyのrotate()を使用します。

# 画像を回転させるためにScipyのrotateをインポート
from scipy.ndimage.interpolation import rotate

def random_rotate(img, angle_range=(0, 360)):
    
    # 画像の形状を取得(色の情報は使わないので_に格納)
    h, w, _ = img.shape

    # 回転させる角度を0~360度の範囲でランダムに取得
    angle = np.random.randint(*angle_range)
    
    # rotate()を使用して取得した角度で画像を回転させる
    img = rotate(img, angle)
    
    # 回転させることでサイズが変わるので元のサイズに戻す
    return np.array(Image.fromarray(img).resize((h, w)))

rotate_img = random_rotate(img)
rotate_img_obj = Image.fromarray(rotate_img)
rotate_img_obj.save('random_rotate_cat.jpg')

f:id:monozukuri-bu:20200507162534j:plain

見事画像を回転させられました。
回転の角度の範囲は必要なデータに合わせて調整しましょう。

マスキング

画像の一部をマスキングすることによって、正則化の効果が強くなりモデルの過学習を抑えることができます。
ランダムに取得した箇所に対して、画像の画素数の平均値でマスキングしてみましょう。

def masking(img, mask_size):

    # 画素数の平均値を取得
    mask_val = img.mean()
    
    # 画像の形状を取得(色の情報は使わないので_に格納)
    h, w, _ = img.shape
    
    # マスク対象箇所のtop, lightの値をランダムに取得し、bottom, rightの値も取得
    top = np.random.randint(0 - mask_size // 3, h - mask_size)
    left = np.random.randint(0 - mask_size // 3, w - mask_size)
    bottom = top + mask_size
    right = left + mask_size
    
    # top, leftが負の値になる可能性がある.その場合0を代入
    if top < 0:
        top = 0
    if left < 0:
        left = 0
        
    # マスク箇所の画素数を平均値とする
    img[top:bottom, left:right, :] = mask_val
    return img

mask_img = masking(img, 240)
mask_img_obj = Image.fromarray(mask_img)
mask_img_obj.save('masking_cat.jpg')

f:id:monozukuri-bu:20200507162602j:plain

画像を一部マスキングすることで正則化の効果があるなんて面白いですね。

ガンマ補正

ディスプレイやプリンタ、スキャナなどで色のついた画像を表示する際、ハードウェアはそれぞれ発色特性という癖のようなものを持っています。
例えば、あるディスプレイできれいにみえた画像が他のディスプレイでは白っぽく見えたり、逆に暗めに見えてしまったりします。
これがハードウェアの発色特性によるもので、どのくらい画像を変質させるか決めるのがガンマ値となります。

どの程度変質させるかはガンマ値をべき乗することで計算でき、下記の数式で表せます。


Output=Input^{Gamma}

ガンマ値が 1.0, 2.2 の場合のグラフはそれぞれ下記となります。

f:id:monozukuri-bu:20200507163024j:plain

このように変質してしまう画像に対し、元の状態に戻すことをガンマ補正と呼びます。
例えば、別のディスプレイで表示した時に画像が暗めに表示されるのであれば、あらかじめ明るめのデータを送ることでちょうど良い明るさで表示できます。

発色特性によってガンマ値をべき乗したものがアウトプットされてくるので、ガンマ補正を行う場合はガンマ値の逆数をべき乗すれば良いことになりますね。
なおこの際、入力値を正規化(0~1の範囲に収める)し、ガンマ補正後に正規化した分を元に戻す必要がありますので、下記の数式を適用すれば良いことになります。
※Maxは画素の最大値


Output = Max * (\frac{Input}{Max}) ^ {\frac{1}{Gamma}}

ガンマ値を2.2と仮定して実装すると下記のようになります。

# ガンマ値
gamma = 2.2
# 画素の最大値
img_max = img.max()

# ガンマ補正
img_gamma = img_max * (img / img_max) ** (1 / gamma) 
img_gamma_obj = Image.fromarray(np.uint8(img_gamma))
img_gamma_obj.save('gamma_cat.jpg')

f:id:monozukuri-bu:20200507163105j:plain

輝度調整

画像において輝度の調整を行いたい場合は、下記のような積和演算を行います。
αはコントラスト、βは明るさを指し、両方とも定数となります。


Output = α * Input + β

実装に落とし込んでみましょう。
注意点として、上記の数式をそのまま適用すると画素の最大、最小の範囲を超えてしまう値があるので、Numpyのclip()を使用することで範囲内に収まるようにします。

def chng_brt(img, alpha, beta):
    # 積和演算
    brt_img = alpha * img + beta
    
    # 画素の最大値、最小値を取得
    max = img.max()
    min = img.min()

    # 画素を最小~最大の範囲に収める
    brt_img = np.clip(brt_img, min, max)
    return Image.fromarray(np.uint8(brt_img))

まずは画像を暗くしてみます。

alpha = 0.8
beta = -50
brt_img = chng_brt(img, alpha, beta)
drk_img_obj = Image.fromarray(np.uint8(drk_img))
drk_img_obj.save('dark_cat.jpg')

f:id:monozukuri-bu:20200507163148j:plain

今度は画像を明るくしてみましょう。

alpha = 1.2
beta = 50
brt_img = chng_brt(img, alpha, beta)
brt_img_obj = Image.fromarray(np.uint8(brt_img))
brt_img_obj.save('bright_cat.jpg')

f:id:monozukuri-bu:20200507163159j:plain

まとめ

今回はNumpyを使った画像の基本処理と、水増し処理を紹介させていただきました。
学習のためにあえてNumpyを使用しましたが、OpenCVが使える環境であればもっと簡単に実装出来ると思います。

また、今回紹介したような方法を複数組み合わせて、画像のパターンを増やすことが出来ますね。
作成したいモデルに応じて、効果的な手法を取捨選択していきたいです。