画像を加工・変形して機械学習のためのデータ拡張をしてみた

# 概要

ディープラーニングでは、効果的な学習用を行なうために大量のデータを準備する必要があります。しかし、大量のデータを準備することが難しい場合、**データ拡張（Data Augmentation）というテクニックによって、少量の手元のデータを水増し（割り増し、増殖）して**学習に利用することがあります。

データが「画像」の場合、次のように **平行移動**、**回転**、**拡大縮小**、**上下反転**、**左右反転**、**明度調整**などの変形や加工を組み合わせた画像処理によってデータ拡張を行ないます。
![f1ex_arr.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152805/e7ada1dc-d41b-be8b-c4bc-dcfadff3b479.png)

Tensorflow(2.x) + Keras の環境では、データ拡張用に `ImageDataGenerator` というクラスが用意されており、これを使えば**ランダムな画像処理を加えたデータ（これを、この記事では「拡張画像」とします）を比較的簡単に生成**することができます。この記事では、この `ImageDataGenerator` を使ったデータ拡張について説明します。

また、`ImageDataGenerator` は使わずに、**OpenCV** と **tf.keras** の**アフィン変換などのライブラリ**（`cv2.warpAffine` と `tensorflow.keras.preprocessing.image.apply_affine_transform`）を使って**平行移動、回転、拡大縮小の処理**を施して、マニュアルでのデータ拡張を試みます。また、両ライブラリの処理速度を比較します（結果は OpenCV の圧勝）。

## 実行環境

Google Colab. 環境で、実行の確認をしています。

```
opencv-python      4.1.2.30
tensorflow         2.1.0rc1
```

# ImageDataGenerator による画像データの拡張

## 準備：ライブラリのインポート

Tesorflow のバージョン切り替えと、ライブラリのインポートをします。また、処理前後の画像を確認するために matplotlib を使うので、それもインポートします。

```py:準備：ライブラリのインポート
%tensorflow_version 2.x
import numpy as np
import tensorflow as tf
import matplotlib.pyplot as plt
```

## 準備：画像データの準備

処理を施すための対象データを取得してきます。ここでは「**CIFAR-10**」のトレーニングデータを利用します。CIFAR-10は「飛行機」「自動車」「トリ」「ネコ」「シカ」「イヌ」「カエル」「ウマ」「船」「トラック」の10種の画像から構成されるデータセットです。

```py:準備：画像データの準備
(x_train, y_train), (x_test, y_test) = tf.keras.datasets.cifar10.load_data()
img_cifar10 = x_train/255.
print(img_cifar10.shape) # -> (50000, 32, 32, 3)  32x32 3ch RGB 
```

`img_cifar10` には、$32\times 32$ px の $3$ch-RGB の $50,000$ 枚の画像が、 numpy.ndarray 形式で格納されています。

## 準備：画像表示関数の定義

画像を表示するための関数を作成します。画像を1枚だけを表示する `showImage(...)` と、画像配列を与えて複数画像を同時表示する `showImageArray(...)` を定義します。

```py:準備：画像表示関数の定義
def showImage(img,title=None):
  plt.figure(figsize=(3, 3))
  plt.gcf().patch.set_facecolor('white')
  plt.xticks([])
  plt.yticks([])
  plt.title(title)
  plt.imshow(img)
  plt.show()
  plt.close()

def showImageArray(img_arry):
  n_cols = 8
  n_rows = ((len(img_arry)-1)//n_cols)+1
  fig, ax = plt.subplots(nrows=n_rows, ncols=n_cols, figsize=(10, 1.25*n_rows))
  fig.patch.set_facecolor('white')
  for i,ax in enumerate( ax.flatten() ):
    if i < len(img_arry):
      ax.imshow(img_arry[i])
      ax.set_xticks([])
      ax.set_yticks([])
    else :
      ax.axis('off') # 余白処理
  plt.show()
  plt.close()
```

これらの関数は、次のように呼び出します。

```py:画像表示関数の呼出し
# img_cifar10 の 12枚目の画像を表示 
showImage(img_cifar10[12],title='CIFAR-10 Train Data [12]')

# 0枚目から39枚目までの画像（=40枚）を表示
showImageArray(img_cifar10[:40])
```

`showImage(...)` には、shape が `(32,32,3)` の numpy.ndarray を引数として与えます。また、`showImageArray(...)` には、shape が `(arr_len,32,32,3)` の numpy.ndarray を引数として与えます（ここで `arr_len` は画像配列の長さです）。

実行結果は、次のようになります。
![f1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152805/0cb06925-8f03-205b-6172-1b6402acccd5.png)
![f2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152805/8a371ad0-a7db-96ab-f18e-0dd19f4c244f.png)

## ImageDataGenerator による画像データの拡張

[ImageDataGeneratorクラス](https://www.tensorflow.org/api_docs/python/tf/keras/preprocessing/image/ImageDataGenerator
) は、指定ディレクトリにある画像ファイルをロードしたり、データ拡張したりすることができます。ここでは、データ拡張の用途で使用します。

データ拡張に使用する場合、初期化時に「**どんな処理（移動？回転？拡大？）を、どの程度の強度でランダムに行なうのか**」のパラメータを与えます。例えば、次のようにパラメータを与えて初期化します。

```py:ImageDataGeneratorの初期化
ImageDataGenerator = tf.keras.preprocessing.image.ImageDataGenerator
image_data_generator = ImageDataGenerator(
  rotation_range=20,       # ランダムに±20度範囲で回転 
  width_shift_range=8,     # ランダムに±8px範囲で左右方向移動
  height_shift_range=4,    # ランダムに±4px範囲で上下方向移動
  zoom_range=(0.8, 1.2),   # ランダムに0.8～1.2倍の範囲でズーム
  horizontal_flip=True,    # ランダムで左右反転する
  channel_shift_range=0.2) # チャンネル値（明度）のランダムシフト範囲
```

`width_shift_range` と `height_shift_range` は $1$ 未満の小数値で与えると、画像サイズに対する割合でランダム範囲を指定できます。また、今回は使っていませんが `vertical_flip=True` で上下反転の処理も加えることができます。

### 拡張画像を1枚だけ生成

このジェネレータを使って、`img_cifar10[12]` の「ウマ」の画像を対象にデータ拡張をしてみます（まずは**1枚だけ**拡張画像を生成します）。次のコードを実行すると、上記の初期化で与えたパラメータ範囲内で、ランダムに画像処理が行われます。

```py:拡張画像を1枚だけ生成
org_img = img_cifar10[12].copy() # ターゲットは12番目の「ウマ」
ex_img  = image_data_generator.flow( org_img.reshape(1,32,32,3), batch_size=1)[0][0]
print(ex_img.shape) # -> (32, 32, 3)
showImage(ex_img,title='CIFAR-10 Train Data [12] Ex')
```

拡張した画像を得るためには `flow(...)` メソッドを使用します。引数として、オリジナルの画像**配列**（単一入力には対応していないので `.reshape(1,32,32,3)` で1要素を持つ配列に変換して）を与えます。戻り値は、[NumpyArrayIterator](https://www.tensorflow.org/api_docs/python/tf/keras/preprocessing/image/NumpyArrayIterator) になるので、`[0][0]` で評価と0番目要素の取得をして、`ex_img` に格納しています。

実行結果は、次のようになります。左右が反転し、水平移動がかかり、全体的に明るくなっています（実行結果は、実行毎に変化します）。
![f1ex.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152805/740f7d1c-0911-5a0d-6b79-7a1db0424b80.png)

ところで、画像を回転させたり、上下左右に移動させたりすると隙間ができますが、そこは自動的に画像端が引き伸ばされて埋められます（自然な画像に仕上がります）。この処理を適用したくない場合は、初期化で `fill_mode='constant'` を指定します。すると、次のように余白は黒色で埋められます。そのほかに、`fill_mode='reflect'` や `fill_mode='wrap'`、`fill_mode='nearest'` （デフォルト）が指定できます。
![f1ex2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152805/10534562-f241-201e-badf-ab9ae16ce13a.png)

### 拡張画像を複数枚生成

つづいて、`img_cifar10[12]` の「ウマ」の画像から39枚の拡張画像を生成します。`image_data_generator.flow(...)` は、`NumpyArrayIterator` を返すので、`next()` を使って順次画像を取り出します。

```py:拡張画像を1複数枚生成
org_img = img_cifar10[12].copy()
ex_img = np.empty([40, 32, 32, 3]) # オリジナルを含め40枚格納可能な領域を準備
ex_img[0,:,:,:] = org_img          # 0枚目にオリジナルを格納
iter_ = image_data_generator.flow( org_img.reshape(1,32,32,3), batch_size=1)
for i in range(1,40):
  ex_img[i,:,:,:] = iter_.next()[0] # 生成した画像を逐次格納
showImageArray(ex_img)
```

実行結果は、次のようになります。
![f1ex_arr.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152805/e7ada1dc-d41b-be8b-c4bc-dcfadff3b479.png)

###  CIFAR-10 の 0～23枚目までの画像について、各1枚の拡張画像を生成

前セクションでは、1枚の画像について複数枚の拡張画像を生成しました。今度は、CIFAR-10 のトレーニングデータの 0～23枚目までの40枚の画像について、各1枚の拡張画像を生成します。

```py:画像配列に対して各1枚の拡張画像を生成
showImageArray(img_cifar10[:24]) # CIFAR-10 オリジナル 0～23枚目を表示

ex_img = np.empty([24, 32, 32, 3])
ex_img = image_data_generator.flow(img_cifar10[:24], batch_size=24, shuffle=False)[0]
showImageArray(ex_img)           # CIFAR-10 拡張 0～23枚目 を表示
```

実行結果は次のようになります。まず、こちらが CIFAR-10 のトレーニングデータの 0～23枚目までのオリジナルデータです。
![cifar10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152805/d2f2d262-4576-c8b3-9c40-4427a74efb64.png)

つづいて、こちらが ImageDataGenerator で拡張した画像になります。各画像に対して、それぞれ異なる処理（移動、回転、拡大縮小、反転などの組合せ）が適用されていることが分かります。
![cifar10ex.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152805/6c2244ff-655c-0584-eea2-03bb678efce2.png)

なお、`flow(...)` の引数に、`shuffle=False` を指定しないと、出力される画像配列の順番がシャッフルされます。

# OpenCV と tf.keras による処理の比較

移動量や回転角、倍率をマニュアルで指定し、OpenCV と tf.keras の各ライブラリで拡張画像を生成します。また、処理時間の比較をします。

## 回転

画像を**<font color='red'>反時計方向に</font>45度回転**させる処理を行ないます。回転軸は、画像の中心とします。

OpenCV のライブラリを使うプログラムは次のようになります。OpenCV では反時計方向が「正」なので、そのまま回転角`45`を与えています。`cv2.warpAffine(...)` が処理の本体になります。`borderMode=cv2.BORDER_REPLICATE` を指定しないと隙間が黒で塗りつぶされます。

```py:OpenCV版のCIFAR10の50,000枚の回転処理
import time
import cv2
deg = 45  # 反時計方向が「正」
w, h = 32, 32  # 画像サイズ
m = cv2.getRotationMatrix2D((w/2,h/2), deg, 1) # 変換行列
img_cifar10_ex = np.empty_like(img_cifar10) # 結果の格納先 np.empty([50000,32,32,3])と同じ
t1 = time.time()
for i,img in enumerate( img_cifar10 ) :
  img = cv2.warpAffine(img, m, (w,h), borderMode=cv2.BORDER_REPLICATE)
  img_cifar10_ex[i,:,:,:] = img
t2 = time.time()
print(f'処理時間 {t2-t1:.1f}[sec]')
showImageArray(img_cifar10_ex[:24]) # 24枚だけを表示
```

実行結果は次のようになります。また、処理時間は **1.3[sec]** でした。
![cv_r.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152805/77fdf679-6a56-d3e5-a56c-58744f7b2e41.png)

一方、tf.keras のライブラリを使ったプログラムは次のようになります。こちらは、反時計方向が「負」になるので、回転角には `-45` を与えています。

```py:tf.keras版のCIFAR10の50,000枚の回転処理
import time
from tensorflow.keras.preprocessing.image import apply_affine_transform
deg = -45  # 反時計方向が「負」
img_cifar10_ex = np.empty_like(img_cifar10) # 結果の格納先
t1 = time.time()
for i,img in enumerate( img_cifar10 ) :
  img = apply_affine_transform(img, theta=deg)
  img_cifar10_ex[i,:,:,:] = img
t2 = time.time()
print(f'処理時間 {t2-t1:.1f}[sec]')
showImageArray(img_cifar10_ex[:24])
```

実行結果は次のようになります。また、処理時間は **16.2[sec]** でした。<font color='red'>処理速度は、圧倒的に OpenCV のほうが**高速**でした</font>。
![tf_r.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152805/2218c02f-bc4e-a815-dd9f-0d518f041fed.png)

## 平行移動

画像を、**右方向**に $2$px、**下方向**に $5$px 移動させる処理を行ないます。

まずは、OpenCV版です。変換行列 `m` の内容が違うだけで、処理はさきほどの「回転」と同じです。

```py:OpenCV版のCIFAR10の50,000枚の移動処理
import time
import cv2
tx, ty = 2, 5 
w, h = 32, 32  # 画像サイズ
m = np.float32([[1,0,tx],[0,1,ty]]) # 変換行列
img_cifar10_ex = np.empty_like(img_cifar10) # 結果の格納先
t1 = time.time()
for i,img in enumerate( img_cifar10 ) :
  img = cv2.warpAffine(img, m, (w,h), borderMode=cv2.BORDER_REPLICATE)
  img_cifar10_ex[i,:,:,:] = img
t2 = time.time()
print(f'処理時間 {t2-t1:.1f}[sec]')
showImageArray(img_cifar10_ex[:24])
```

実行結果は、次のようになります。処理時間は **1.3[sec]** でした（本質的な処理は「回転」と同じなので、実行時間も変わりません）。
![cv_m.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152805/231eaafd-d511-6238-8620-14a38c87ee24.png)

次に、tf.keras 版です。どうしてそのような設計になっているのか分からなかったのですが「右方向に2px、下方向に5px移動させるためには、`apply_affine_transform(img, tx=-5, ty=-2)` のように」指定しないといけません。謎です。

 - 参考：[Keras ImageDataGenerator apply_transform() method shifts the image in opposite direction](https://stackoverflow.com/questions/56580076/keras-imagedatagenerator-apply-transform-method-shifts-the-image-in-opposite-d)

```py:tf.keras版のCIFAR10の50,000枚の回転処理
import time
from tensorflow.keras.preprocessing.image import apply_affine_transform
tx, ty = 2, 5 
img_cifar10_ex = np.empty_like(img_cifar10) # 結果の格納先
t1 = time.time()
for i,img in enumerate( img_cifar10 ) :
  img = apply_affine_transform(img, tx=-ty, ty=-tx) # 引数の与え方に注意
  img_cifar10_ex[i,:,:,:] = img
t2 = time.time()
print(f'処理時間 {t2-t1:.1f}[sec]')
showImageArray(img_cifar10_ex[:24])
```

実行結果は、次のようになります。処理時間は **13.9[sec]** で、さきほどと同様に OpenCV の処理時間よりも約10倍の時間がかかっています。
![tf_m.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152805/4de14eab-546e-fa10-50d0-ff9088a0473c.png)

## 拡大

画像を、1.2倍に拡大する処理を行ないます（拡大しても画像サイズは変化させません）。

まずは、OpenCV版です。`resize` で拡大して、その後、$32\times 32$ px にトリミングしています。

```py:OpenCV版のCIFAR10の50,000枚の拡大処理
import cv2
f = 1.2 
w, h = 32, 32               # 画像サイズ
w2, h2 = int(w*f), int(h*f) # 拡大画像のサイズ
tx, ty = int((w2-w)/2),int((h2-h)/2) # トリミングの開始座標
m = np.float32([[1,0,tx],[0,1,ty]]) # 変換行列
img_cifar10_ex = np.empty_like(img_cifar10) # 結果の格納先
t1 = time.time()
for i,img in enumerate( img_cifar10 ) :
  img = cv2.resize(img,(w2,h2)) 
  img_cifar10_ex[i,:,:,:] = img[tx:tx+w,ty:ty+h,:] # 拡大画像から32x32をトリミング
t2 = time.time()
print(f'処理時間 {t2-t1:.1f}[sec]')
showImageArray(img_cifar10_ex[:24])
```

実行結果は、次のようになります。処理時間は **1.2[sec]** でした。
![cv_f.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152805/72c3ac6d-ac8a-6712-7660-65a3cdcbdcc5.png)


次に、tf.keras 版です。先ほど同様に大変に分かりにくいのですが、`apply_affine_transform(img, zx=1/f, zy=1/f)` のように引数 `zx` と `zy` には「倍率の**逆数**」を与えます。

```py:tf.keras版のCIFAR10の50,000枚の拡大処理
import time
from tensorflow.keras.preprocessing.image import apply_affine_transform
f = 1.2
img_cifar10_ex = np.empty_like(img_cifar10) # 結果の格納先
t1 = time.time()
for i,img in enumerate( img_cifar10 ) :
  img = apply_affine_transform(img, zx=1/f, zy=1/f) # 引数注意
  img_cifar10_ex[i,:,:,:] = img
t2 = time.time()
print(f'処理時間 {t2-t1:.1f}[sec]')
showImageArray(img_cifar10_ex[:24])
```

処理時間は 13.4[sec] で、結果は次のようになります。
![tf_f.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152805/013e26cf-60f6-afa7-29aa-2575a8146a71.png)