薬剤師のプログラミング学習日記

プログラミングやコンピュータに関する記事を書いていきます

画像の回転角度をCNNの回帰で予測する

CNNといえば画像の分類問題でよく目にしますが、回帰問題にも用いることができます。分類問題ではデータがどのクラスに属するか、という問題を扱いますが、回帰問題では入力データから連続的な数値の予測を行います。
そこで、回転した画像からその回転角度(何度傾いているか)をCNNで予測し、ゼロ度の画像に補正するようなことができるか試してみることにしました。

f:id:enokisaute:20211003155215j:plain


記事の概要

画像データを説明変数、何度傾いているかを目的変数とした回帰モデルをCNNで作成し、どの程度正しく予測できるかを調べてみました。

学習には以下のデータセットを用いて、それぞれで結果を確認しました。

  • MNISTデータセット
  • sklearnの顔画像データセット

以前の記事『Pythonで画像の傾きを補正して水平にする』では、"できるだけ正確に"画像を水平にしましたが、今回はデータが大量にある小さな画像に対して"大体で"行います。

環境

・tensorflow
・keras
・OpenCV
・sklearn
私は自分のPCのGPU環境であるtensorflowの1.X系のバージョンを使用しましたが、基本的にはバージョンを限定するようなものではありません。Google Colabなどで載せてあるコードを実行される際は、環境に合わせて適宜読み替えていただければと思います。

回転画像データセットの作成

手軽な題材として、まずはMNISTデータセットを使って作成します。
回転画像のデータセットを作成する前には、以下の点を考慮しておく必要があります。

図形を回転させる前と回転させた後とで区別がつかなくなるような画像*1は、データセットには含めないようにします。人間が見ても区別ができないようなものは、いくらディープラーニングでも区別ができるようにはなりません。
たとえば、数字でいえば次のようなものが該当します。

f:id:enokisaute:20210911155213p:plain
「0」や「1」、「8」が駄目というのはわかりやすいですが、「6」と「9」も問題があります。両方が含まれていると、「9」のゼロ度画像なのか「6」の180度回転画像か*2の区別がつかない(つまり、同じような画像であるのに、正解ラベルが180もずれているものが存在することになる)ため、どちらか一方はデータセットから除外する必要があります。
また、「2」は一見出来そうですが、アルファベットの「Z」のように書かれたものは難しそうです。
f:id:enokisaute:20210911152108p:plain
図形ではなく手書き数字なので、このように同じ数字でも区別がつく・つかないにはある程度揺らぎがあります。
今回はMNISTデータセットから0, 1, 6, 8を除くことにしました。(2は含めることにしました)

また、データセット作成にあたってはいくつかの取り決めを行いました。

  • 回転角度はラジアンではなく度(degree)を用いる
  • 元画像をゼロ度(回転)の画像とする
  • 1画像につき-180~+180の範囲でランダムに20個の回転画像を作成し、その回転角度を正解ラベルとする


以上の点に留意して、次のようなプログラムを作成しました。以前の記事『自分の手書き数字のデータセットを作る』で書いたクラスをベースにしています。

import math, os
import pickle
import random
import cv2
import matplotlib.pyplot as plt
import numpy as np
import tensorflow as tf
 
class RotationImage:
    """
    回転させた画像, 回転角度をラベルとしてデータセットを作成する
    """
    def __init__(self):
        self.dataset = {}
 
    # data: (len(data), h, w)
    def load_data(self, save_file, data, normalize=True, flatten=False):
 
        # save_fileがなければデータセットを作成, あればデータを読み込む
        if not os.path.exists(save_file):
            self.create_rot_dataset(save_file, data)
        else:
            print('*** The file exists. ***')
            with open(save_file, 'rb') as f:
                self.dataset = pickle.load(f)
 
        if normalize:
            # float32に変換して0~1の間になるよう正規化
            self.dataset['img'] = self.dataset['img'].astype(np.float32) / 255
 
        if not flatten:
            h = w = int(np.sqrt(self.dataset['img'].shape[1]))
            self.dataset['img'] = self.dataset['img'].reshape(-1, 1, h, w)
 
        return self.dataset['img'], self.dataset['label']
 
    def create_rot_dataset(self, save_file, data):
        new_img, new_label = [], []
 
        for i in range(0, len(data)):
            img = data[i]
            h, w = img.shape
            # 1画像ごとにランダムに回転角度を20個生成
            angle_list = np.array([random.randint(-180, 179) for _ in range(20)], dtype=np.float32)
            # 作成したいデータセットによっては以下のリストをangle_listに用いても良い
            # [-175, -150, -120, -90, -60, -25, 0, 25, 60, 90, 120, 150, 175]
            for degrees in angle_list:
                mat = cv2.getRotationMatrix2D((w / 2, h / 2), degrees, 1.0)
                rot_img = cv2.warpAffine(img, mat, (w, h))
                new_img.append(rot_img.reshape(-1))
                new_label.append(degrees)
 
        self.dataset = {'img': np.array(new_img), 'label': np.array(new_label)}
        with open(save_file, 'wb') as f:
            pickle.dump(self.dataset, f)
 
def exclude(x, y, num_list):
    for n in num_list:
        x = np.delete(x, np.where(y == n), axis=0)
        y = np.delete(y, np.where(y == n))
    return x, y
 
def display_data(x, w, h):
    m, n = x.shape
    rows = math.floor(np.sqrt(m))
    cols = math.ceil(m / rows)
    pad = 1     # 画像間の余白
    # (縦に並べる枚数分のピクセル)×(横に並べる枚数分のピクセル)の配列を用意. ここに1画像毎の値を詰め込んでいく
    disp_array = np.ones((pad + rows * (h + pad), pad + cols * (w + pad)))
    curr_ex = 0
    for j in range(1, rows + 1):
        for i in range(1, cols + 1):
            h_pos = pad + (j - 1) * (h + pad) + np.array([i for i in range(0, h + 1)])
            w_pos = pad + (i - 1) * (w + pad) + np.array([i for i in range(0, w + 1)])
            disp_array[h_pos[0]:h_pos[-1], w_pos[0]:w_pos[-1]] = \
                x[curr_ex, :].reshape(h, w)
            curr_ex += 1
    fig, ax = plt.subplots(figsize=(6, 6))
    ax.imshow(disp_array, cmap='gray')
    ax.axis("off")
    plt.show()
 
 
if __name__ == '__main__':
    # MNISTをロードして回転画像データセットを作成する
    mnist = tf.keras.datasets.mnist
    (x_train, y_train), (x_test, y_test) = mnist.load_data()
 
    del_list = [0, 1, 6, 8]     # MNISTから削除する数字. (回転前後で区別がつきにくいもの)
    x_train, y_train = exclude(x_train, y_train, del_list)
    x_test, y_test = exclude(x_test, y_test, del_list)
    # 各数字のデータ数を表示
    print([y_train.tolist().count(i) for i in range(10)])
    print([y_test.tolist().count(i) for i in range(10)])
 
    rotation_image = RotationImage()
    x_train, y_train = rotation_image.load_data('./save/rot_mnist_train.pkl',
                            x_train,
                            normalize=True,
                            flatten=True)         # display用にflatに.
    x_test, y_test = rotation_image.load_data('./save/rot_mnist_test.pkl',
                              x_test,
                              normalize=True,
                              flatten=True)
 
    display_data(x_train[:100,], 28, 28)
    display_data(x_test[:100,], 28, 28)
    print(x_train.shape)
    print(y_train)
    print(x_test.shape)
    print(y_test)

MNISTデータセットをロード後、関数exclude()で[0, 1, 6, 8]の数字をデータセットから削除しています。

# 各数字(0~9)のデータ数を表示
print([y_train.tolist().count(i) for i in range(10)])
print([y_test.tolist().count(i) for i in range(10)])
# [0, 0, 5958, 6131, 5842, 5421, 0, 6265, 0, 5949]    train
# [0, 0, 1032, 1010, 982, 892, 0, 1028, 0, 1009]      test


クラスRotationImageは指定したパスにpickleで保存したファイルがあれば読み込み、なければ渡された画像データを回転させ、その回転角度をラベルとしたデータセットを作成します。
MNISTの場合、以下のような画像となります。
f:id:enokisaute:20210911163319j:plain
x_trainの場合、回転前の画像データの数は35566枚でしたが、1枚につき-180~180度の範囲で20個回転した画像を生成するので、作成されるデータ数は35566×20=711320となります。
また、y_train(正解ラベル)には各画像における回転角度が入っています。

print(x_train.shape)    # (711320, 784)
print(y_train)          # [ 46.  22. -98. ...  73.  45. -65.]
print(x_test.shape)     # (119060, 784)
print(y_test)           # [-105. -161.   41. ...   67. -145.  -47.]


使用するモデルと学習時の設定

CNNの構造

CNNは『畳み込みニューラルネットワークを自作の手書き数字画像に利用する』でも使用した、VGGを参考にした小さなネットワークです。
通常、分類問題で使用されるCNNでは出力層ではSoftmax関数を用いて各クラスの確率を推定しますが、回帰問題においては予測値を1つ出力するので出力層のニューロンの数は1つとしておき活性化関数は恒等関数('linear'を指定)とするか、「なし」とするようです。
しかし、今回は出力層の活性化関数にsoftsign関数を用いた、カスタム関数を使用することにしました。

softsign関数は、以下のような形をしています。
{y = \cfrac{x}{1 + |x|}
}
f:id:enokisaute:20210930010035p:plain
softsign関数の出力は-1~+1なので*3、それにx180すると、-180~+180が出力として得られるということになります。

from tensorflow.keras import backend as K
 
def cnn(input_shape):
    model = Sequential()
    model.add(Conv2D(16, kernel_size=(3, 3), activation='relu',
                     input_shape=input_shape))
    model.add(Conv2D(32, (3, 3), activation='relu'))
    model.add(MaxPooling2D(pool_size=(2, 2)))
    model.add(Conv2D(64, (3, 3),activation='relu'))
    model.add(Dropout(0.3))
    model.add(Flatten())
    model.add(Dense(500, activation='relu'))
    model.add(BatchNormalization())
    model.add(Dropout(0.3))
    model.add(Dense(10, activation='relu'))
    model.add(Dense(1, activation=custom_softsign))  # 出力層にカスタム関数を使用
    model.summary()
    return model
 
def custom_softsign(x):
    return K.softsign(x) * 180


損失関数

HuberLossを使用しました。
モデルの出力値と正解ラベルとの差が一定以上になると線形に損失が増加するように設計された損失関数です。


\begin{eqnarray}
HuberLoss =
  \begin{cases}
    \frac{1}{2}a^2 &  (|a| \leq \delta) \\
    \delta (|a| - \frac{1}{2}\delta) & ( |a| > \delta )
  \end{cases}
\end{eqnarray}

出力と正解との差(= a)の絶対値がδを下回っていれば差の二乗×0.5を返し、δよりも大きければ直線的に増加する損失を返します。これにより、データに外れ値があった場合でも損失が大きくなり過ぎず、モデルに与える影響を抑えることができます。
どちらの損失を返すかの閾値となるδはデフォルトでは1.0となっていましたが、今回はδ=3.0に設定しました。
f:id:enokisaute:20210928135949p:plain
MNISTの手書き文字は、回転させていなくても、かなり読みにくいものもあります。そのような文字の回転画像の正解ラベルは外れ値として、大きく影響を受けないようにしたかったためMSE(平均二乗誤差)ではなく、HuberLossを選択しました。

評価関数

平均絶対誤差(MAE:Mean Absolute Error)を用いました。
平均と予測の差の絶対値、の平均です。

\displaystyle{MAE = \frac{1}{n}\sum_{k=1}^{n}| t_{k} - {y_{k}} |}

シンプルで解釈しやすいとのことで、これを選択しました。

以下、学習で用いたコードです。

import matplotlib.pyplot as plt
import numpy as np
import tensorflow as tf
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Conv2D, MaxPooling2D
from tensorflow.keras.layers import Flatten, Dense, Dropout, BatchNormalization
from tensorflow.keras.callbacks import TensorBoard, EarlyStopping
from tensorflow.keras import backend as K
 
# 同階層のrotation_image.pyファイルからimport
from rotation_image import RotationImage
from rotation_image import display_data, exclude
 
 
def cnn(input_shape):
    model = Sequential()
    model.add(Conv2D(16, kernel_size=(3, 3), activation='relu',
                     input_shape=input_shape))
    model.add(Conv2D(32, (3, 3), activation='relu'))
    model.add(MaxPooling2D(pool_size=(2, 2)))
    model.add(Conv2D(64, (3, 3),activation='relu'))
    model.add(Dropout(0.3))
    model.add(Flatten())
    model.add(Dense(500, activation='relu'))
    model.add(BatchNormalization())
    model.add(Dropout(0.3))
    model.add(Dense(10, activation='relu'))
    model.add(Dense(1, activation=custom_softsign))     # カスタム関数を使用
    model.summary()
    return model
 
# 以下3つは出力層で用いるカスタム関数
# sigmoidの場合は回転角度が0~360になるようデータセットを作る
def custom_sigmoid(x):
    return K.sigmoid(x) * 360
 
def custom_softsign(x):
    return K.softsign(x) * 180
 
def custom_tanh(x):
    return K.tanh(x) * 180
 
def get_huber_loss_fn(**huber_loss_kwargs):
    def custom_huber_loss(y_true, y_pred):
        return tf.losses.huber_loss(y_true, y_pred, **huber_loss_kwargs)
        custom_huber_loss.__name__ = "custom_huber_loss"
    return custom_huber_loss
 
def acc(y_label, y_pred):
    # (ラベル - 予測)の絶対値が5を下回るときを"正解"とする
    return np.mean(np.abs(y_label - y_pred) < 5)
 
def plot_history(hist):
    print(hist.history.keys())
    epochs = hist.epoch
    # loss
    plt.figure(1)
    plt.plot(epochs, hist.history['loss'], label='training loss')
    plt.plot(epochs, hist.history['val_loss'], label='validation loss')
    plt.xlabel('epoch')
    plt.ylabel('loss')
    plt.legend()
 
    # mae
    plt.figure(2)
    plt.plot(epochs, hist.history['mean_absolute_error'], label='training mae')
    plt.plot(epochs, hist.history['val_mean_absolute_error'], label='validation mae')
    plt.xlabel('epoch')
    plt.ylabel('mean absolute error')
    plt.legend()
 
    plt.show()
 
 
if __name__ == '__main__':
    IMAGE_SHAPE = (28, 28, 1)  # MNIST画像フォーマット. 28x28ピクセルのグレースケール画像
    model = cnn(IMAGE_SHAPE)
 
    # MNISTデータセットのロードと前処理
    mnist = tf.keras.datasets.mnist
    (x_train, y_train), (x_test, y_test) = mnist.load_data()
    # 回転の角度によっては見分けが付かなくなるため0, 1, 6(または9), 8は削除
    del_list = [0, 1, 6, 8]
 
    x_train, y_train = exclude(x_train, y_train, del_list)
    x_test, y_test = exclude(x_test, y_test, del_list)
 
    # 事前にRotationImage()でデータセットを作成していれば, 実際にx_train, x_testは渡す必要はない
    rotation_image = RotationImage()
    x_train, y_train = rotation_image.load_data('./save/rot_mnist_train.pkl',
                                                x_train,    # pklファイルがあれば[],としても可
                                                normalize=True,
                                                flatten=True)
 
    x_test, y_test = rotation_image.load_data('./save/rot_mnist_test.pkl',
                                              x_test,    # pklファイルがあれば[],としても可
                                              normalize=True,
                                              flatten=True)
 
    display_data(x_train[:169, ], 28, 28)
    display_data(x_test[:169, ], 28, 28)
    x_train, x_test = x_train.reshape((len(x_train), 28, 28, 1)), x_test.reshape((len(x_test), 28, 28, 1))
 
    # モデルのコンパイルと学習
    # tensorflow2.xではloss=tf.keras.losses.Huber(delta=3.0)が使用できる
    model.compile(optimizer='Adam',
                  loss=get_huber_loss_fn(delta=3.0),
                  metrics=['mean_absolute_error'])
 
    result = model.fit(x_train, y_train, batch_size=1024, epochs=100,
                       validation_split=0.2,
                       callbacks=[EarlyStopping(monitor='val_loss', patience=5)],
                       verbose=1)
 
    model.save('./save/rot_mnist_model.h5')
 
    score = model.evaluate(x_test, y_test, verbose=0)
    print("Test loss: ", score[0])
    print("Test mae: ", score[1])
 
    y_pred_train = np.ravel(model.predict(x_train))
    y_pred_test = np.ravel(model.predict(x_test))
    print('accuracy(less than ±5 degrees)')
    print(acc(y_train, y_pred_train))
    print(acc(y_test, y_pred_test))
    # lossとMAEの推移をプロット
    plot_history(result)


結果

損失関数とMAEの推移は次のようになりました。
f:id:enokisaute:20210928192609p:plain

テストデータのlossとMAEです。

score = model.evaluate(x_test, y_test, verbose=0)
print("Test loss: ", score[0])  # 13.775686554241517
print("Test mae: ", score[1])   # 5.68471

テストデータでの予測と正解の平均的なずれはだいたい6度くらい、というところでしょうか。

回帰問題では、カテゴリを予測する分類問題のように正解率(accuracy)という指標は使えません。しかし、ここではモデルの予測が正解ラベルの±5度未満の場合を”正解”として、正解率を次のコードで見てみることにしました。

def acc(y_label, y_pred):
    # (ラベル - 予測)の絶対値が5未満のときを"正解"とする
    return np.mean(np.abs(y_label - y_pred) < 5)
 
# 学習後のモデルでtrain, testデータを予測
y_pred_train = np.ravel(model.predict(x_train))
y_pred_test = np.ravel(model.predict(x_test))
 
print('accuracy(less than ±5 degrees)')
print(acc(y_train, y_pred_train))  # 0.903946184558286
print(acc(y_test, y_pred_test))    # 0.8637157735595498

普通、まっすぐなものが左右どちらかに5度傾いていると、傾いているということはすぐにわかりますが、手書き数字の場合は最初から傾いているように見えるクセのある文字もあるということを考慮すると、この”正解率”はなかなか良い数字ではと思いました。

下画像は、テスト画像をモデルの予測した回転角度だけ逆回転させたものです。
f:id:enokisaute:20210929191155p:plain
テストデータに対する±5度以内の正解率が約86%ですから、100個表示させると86個はこの範囲に入るということですが、ほとんどの画像はそのまま違和感なく読めるくらいに補正できています。

下図は、テストデータにおいて横軸を正解ラベル、縦軸をモデルの予測とした散布図と箱ひげ図です。(この図のデータセットはここまでのランダムに回転したものではなく、[-175, -150, -120, -90, -60, -25, 0, 25, 60, 90, 120, 150, 175]で回転させたx_testとy_testを使用)
f:id:enokisaute:20211002161320p:plain
左の散布図では一見、正解に対して予測が大きくずれているものが多くあるように見えますが*4、右の箱ひげ図を見ると、どの角度においても予測の中央50%*5はほぼ正解付近にあることがわかります。(箱ひげ図の方は外れ値が多すぎたので、表示させていません)
正解ラベルにおける予測の中央値を表示させてみると次のようになりました。

y_test:  -175.0 , median:  -174.1925811767578
y_test:  -150.0 , median:  -148.95684814453125
y_test:  -120.0 , median:  -119.7777099609375
y_test:  -90.0 , median:  -87.63899230957031
y_test:  -60.0 , median:  -59.084983825683594
y_test:  -25.0 , median:  -25.887537002563477
y_test:  0.0 , median:  -4.0531086921691895
y_test:  25.0 , median:  24.630748748779297
y_test:  60.0 , median:  59.92182540893555
y_test:  90.0 , median:  91.47259521484375
y_test:  120.0 , median:  120.12688446044922
y_test:  150.0 , median:  149.8029327392578
y_test:  175.0 , median:  174.04014587402344


異なる活性化関数による比較

今回、出力層の活性化関数にはsoftsign関数に×180したカスタム関数を使用しましたが、これを他の活性化関数のtanh、sigmoidと「なし」の4パターンでそれぞれ3回学習を行い、エポック数、train, testの”正解率”の平均を比較してみました。違いは出力層のみです。
なお、sigmoidの場合は出力が0~1となりますので×360のカスタム関数とし、データセット作成の際には0~359の範囲で1枚につきランダムに20枚回転画像を作成しました。また「なし」の場合のデータセットは-180~+180のもので学習を行いました。*6

softsign × 180 tanh × 180 sigmoid × 360 なし
epoch数 57.0 51.0 53.7 66.7
trainの"正解率" 0.896 0.861 0.891 0.853
testの"正解率" 0.850 0.815 0.843 0.779


正解率としてはsoftsignとsigmoidを使用したものはほぼ同じくらいで、tanhと「なし」の場合はそれらよりも若干劣る、という結果でした。目立ったのは「なし」の場合はtrainとtestの正解率で差が大きく、他のものよりも過学習の傾向が強く見られました。出力が関数によって押し込められない分、trainデータに過剰に適合したためこのような結果となったのでしょうか。
また「なし」では学習にかかるエポック数も他のものよりも少し多くなりました。関数を通すよりも直接大きな数を出力するため、ネットワークのパラメータもある程度大きな数になる必要があったから?
活性化関数を用いた3つの正解率はほぼ横並びですが、tanhは出力に対応する入力の幅が狭かったから(細かい出力の調節がしにくいから)正解率が他の2つよりも少しだけですが悪く(誤差レベル?)なったのでしょうか。
f:id:enokisaute:20210930005635p:plain

・・と、自分なりにいろいろ考えてみましたが、通常の回帰モデルのような出力層の活性化関数が「なし」の場合でも平均で数%程度低いだけですし、CNNの構造次第ではこれらの結果はまったく変わってくるかもしれません。

あと、ここまで書いてきてなんですが、角度の出力に活性化関数を通すというのはそもそも理屈としておかしい、という気がしないでもないです。というのは、+179度の画像と-179度(あるいは1度と359度)の画像というのは、見た目(入力)的にはほとんど違わないにもかかわらず、活性化関数に通す直前の入力としては正負対極にあるような入力を必要としていることになってしまうからです。

とはいえ、大雑把な予測としてはある程度良好な結果が出ているので、CNNは画像データの形状(空間的情報)を理解し、ラベルとの対応を正しく学習できたということになるのでしょうか。

この項目で用いたコードはこちら。

import matplotlib.pyplot as plt
import numpy as np
import cv2
from tensorflow.keras.models import load_model
 
# 同階層の以下のpyファイルからimport
from rotation_image import RotationImage
from rotation_image import display_data
from train_rot_mnist import custom_sigmoid, custom_softsign, custom_tanh
from train_rot_mnist import get_huber_loss_fn
 
import seaborn as sns
import pandas as pd
 
 
# y_predをもとにx_dataを1枚ずつ逆回転する
def rotate_img(x_data, y_pred):
    rot_data = np.empty_like(x_data)
    for i in range(0, len(x_data)):
        img = x_data[i]
        h, w = img.shape
        mat = cv2.getRotationMatrix2D((w / 2, h / 2), int(y_pred[i]) * -1, 1.0)
        rot_data[i] = cv2.warpAffine(img, mat, (w, h))
    return rot_data
 
 
if __name__ == '__main__':
    # tensorflow2.xでtf.keras.losses.Huber(delta=3.0)を用いた場合は 'custom_huber_loss'の
    # 行は必要ない
    model = load_model('./save/rot_mnist_model.h5',
                       custom_objects={
                            'custom_softsign': custom_softsign,
                            'custom_huber_loss': get_huber_loss_fn()})
 
    rotation_image = RotationImage()
    x_test, y_test = rotation_image.load_data('./save/rot_mnist_test.pkl',
                                              [],   # 事前に作成してファイルがあればMNISTのx_testを渡す必要はない
                                              normalize=True,
                                              flatten=False)
 
    rand_idx = np.random.RandomState(123).permutation(len(x_test))
    x_test, y_test = x_test[rand_idx], y_test[rand_idx]
 
    # 学習したモデルでテスト画像の回転角度を予測
    y_pred = model.predict(x_test.reshape(len(x_test), 28, 28, 1))
    # 予測した回転角度だけ逆回転を行う
    x_test = x_test.reshape(len(x_test), 28, 28)
    rot_img = rotate_img(x_test, y_pred)
 
    # display_data()のためにreshape
    x_test = x_test.reshape(len(x_test), 784)
    rot_img = rot_img.reshape(len(rot_img), 784)
 
    # 回転前と回転後の画像を表示
    display_data(x_test[:100], 28, 28)
    display_data(rot_img[:100], 28, 28)
 
    # 事前にRotationImageを使って[-175, -150, -120, -90, -60, -25, 0, 25, 60, 90, 120, 150, 175]で
    # pklファイルを作成しておく.
    x_test, y_test = rotation_image.load_data('./save/rot_not_random_test.pkl',
                                              [],
                                              normalize=True,
                                              flatten=False)
 
    idx = np.argsort(y_test)    # 角度が小さい順に並べ替えしたインデックスを取得
    y_pred = model.predict(x_test.reshape(len(x_test), 28, 28, 1))[idx]
 
    df = pd.DataFrame(data=zip(y_test[idx], np.ravel(y_pred)), columns=['y_test', 'predict'])
    degrees = sorted(set(y_test))
 
    # 散布図
    plt.figure(1)
    sns.scatterplot(data=df, x='y_test', y='predict')
    plt.xticks(degrees)
    plt.yticks(degrees)
    plt.grid(ls='--')
    for i in degrees:
        plt.scatter(i, i, c='red')
 
    # 箱ひげ図
    plt.figure(2)
    sns.boxplot(data=df, x='y_test', y='predict', sym='')
    plt.yticks(degrees)
    plt.grid(ls='--')
    plt.show()
 
    # 各ラベルにおける予測の中央値を表示
    import statistics
    for d in degrees:
        p = df.values[df.values[:, 0] == d]
        print('y_test: ', p[0, 0], ', median: ', statistics.median(p[:, 1]))



顔画像の回転角度を補正

今度は、顔画像でも同じことができるかやってみます。
以前書いた記事『主成分分析(PCA)による次元削減』でも用いたことのあるsklearnの顔画像データセットを使用しました。
データセットの概要は以下の通りです。

  • 画像は全部で400枚あり、1枚の画像は64×64ピクセルで構成されている
  • 1人につき10枚(=40人分)あるが、同じ人物の画像でも1枚1枚は顔の角度や眼鏡をかけていたりなど、どれも微妙に違う

このデータセットとRotationImageクラスを使って、MNISTでやったのと同様に回転画像と回転角度のラベルのデータセットを作成します。

# import文省略(MNISTのものと同じ)
from sklearn.datasets import fetch_olivetti_faces

class RotationImage:
    # 省略(〃)
def display_data(x, w, h):
    # 省略(〃)
 
if __name__ == '__main__':
    faces = fetch_olivetti_faces()
    m = faces.images.shape[0]  # 画像の枚数 400
    h = faces.images.shape[1]  # 高さ 64
    w = faces.images.shape[2]  # 幅 64
    X = faces.images.reshape(m, h * w)

    print('X.shape: ', X.shape)  # (400, 4096)
    display_data(X[np.arange(0, 360, 10),], h, w)    # 前から異なる36人を表示

    X = X.reshape(m, h, w)          # rotation_imageに渡すためreshape
    rotation_image = RotationImage()
    _X, _y = rotation_image.load_data('./save/rot_face.pkl',
                   X,
                   normalize=False, # 最初から0~1に正規化されているのでTrueにしない
                   flatten=True)    # display用にflatに.

    print(_X.shape)     # (8000, 4096)
    print(_y)           # [ -6. 143.  59. ... 140. -19.  29.]
    display_data(_X[np.arange(0, 7200, 200),], h, w)  # 前から異なる36人を表示

f:id:enokisaute:20211003115637j:plain

左が元画像、右が作成したデータセットの画像です。
今度はこのデータセットでCNNを学習します。
MNISTデータセットのときと異なる点は以下の4点です。

  1. MNISTの画像データは値が0~255だったためnormalize=Trueとして0~1の範囲になるよう正規化してからモデルに渡しましたが、この顔画像データの値は最初から0~1になっているので、normalize=Falseのままで学習を行います。そうでないと、これらの値をさらに255で割ることになるので、値が小さくなり過ぎ、うまく学習が進みません。
  2. CNNの構造は少し変更しました(下のコード参照)。MNISTで用いたものと同じものを使ってもある程度の精度は出ましたが、変えた方が良くなりました。違いは、ネットワーク後半の全結合層のニューロンの数を増やしたことです。
  3. 同じ人物の画像はtrainとtest両方に入らないようにしました。作成した8000のデータを7:3に分割するため、前半5600枚(=前から28人)をtrainデータ、後半2400枚(=後ろの12人)をtestデータとしました。
  4. データセットのサイズが小さくなったため、batch_sizeは1024→32としました。


結果は次のようになりました。

score = model.evaluate(x_test, y_test, verbose=0)
print("Test loss: ", score[0])   # 4.7190753809611
print("Test mae: ", score[1])    # 2.713826
 
def acc(y_label, y_pred):
    # (ラベル - 予測)の絶対値が5を下回るときを"正解"とする
    return np.mean(np.abs(y_label - y_pred) < 5)
 
y_pred_train = np.ravel(model.predict(x_train))
y_pred_test = np.ravel(model.predict(x_test))
print('accuracy(less than ±5 degrees)')
print(acc(y_train, y_pred_train))  # 0.90375
print(acc(y_test, y_pred_test))    # 0.8620833333333333

何回か試してみたところ、MNISTのときよりもやや高めの精度が出やすいようです。感覚的にですが、手書きの数字と違って最初から左右に傾いているなどが少ない分、顔画像の方が間違えにくいのではと思いました。

この学習したモデルを使って、テスト画像をモデルの予測した回転角度だけ逆回転させたものが、ページ冒頭の画像になります。

数字と同じように、顔画像でも出来そうです。

コードはMNISTのものとほぼ同じになりますので、if __name__ == '__main__':以下で上と違いがある部分だけ書いています。

# import文, class, 関数は省略
if __name__ == '__main__':
    rotation_image = RotationImage()
    _X, _y = rotation_image.load_data('./save/rotation_face.pkl',
                                      [],   # 一度作成していればデータを渡す必要はない
                                      normalize=False, # 最初から0~1に正規化されているのでTrueにしない
                                      flatten=False)
 
    print(_X.shape) # (8000, 1, 64, 64)
    _, _, h, w = _X.shape
 
    IMAGE_SHAPE = (h, w, 1)  # 画像フォーマット. 64x64ピクセルのグレースケール画像
    model = cnn_face(IMAGE_SHAPE)
 
    split = int(len(_X) * 0.7)    # 8000のデータを7:3に分ける
 
    x_train, y_train = _X[:split], _y[:split]
    x_test, y_test = _X[split:], _y[split:]
 
    x_train, x_test = x_train.reshape((len(x_train), h, w, 1)), x_test.reshape((len(x_test), h, w, 1))
 
    # モデルのコンパイルと学習
    # tensorflow2.xではloss=tf.keras.losses.Huber(delta=3.0)が使用できる
    model.compile(optimizer='Adam',
                  loss=get_huber_loss_fn(delta=3.0),
                  metrics=['mean_absolute_error'])
 
    result = model.fit(x_train, y_train, batch_size=32, epochs=100,
                       validation_split=0.2,
                       callbacks=[EarlyStopping(monitor='val_loss', patience=5)],
                       verbose=1)
 
    model.save('./save/rot_face.h5')  # './save/rot_mnist6.h5'   85, 81
 
    score = model.evaluate(x_test, y_test, verbose=0)
    print("Test loss: ", score[0])
    print("Test mae: ", score[1])
 
    y_pred_train = np.ravel(model.predict(x_train))
    y_pred_test = np.ravel(model.predict(x_test))
    print('accuracy(less than ±5 degrees)')
    print(acc(y_train, y_pred_train))
    print(acc(y_test, y_pred_test))
 
    plot_history(result)
 
    rand_idx = np.random.RandomState(123).permutation(len(x_test))
    x_test, y_test = x_test[rand_idx], y_test[rand_idx]
 
    # 学習したモデルでテスト画像の回転角度を予測
    y_pred = model.predict(x_test.reshape(len(x_test), h, w, 1))
    # 予測した回転角度だけ逆回転を行う
    x_test = x_test.reshape(len(x_test), h, w)
    rot_img = rotate_img(x_test, y_pred)
    # display_data()のためにreshape
    x_test = x_test.reshape(len(x_test), h * w)
    rot_img = rot_img.reshape(len(rot_img), h * w)
    # 回転前と回転後の画像を表示
    display_data(x_test[:36], h, w)
    display_data(rot_img[:36], h, w)
    plt.show()


*1:図形では「回転対称」というそうです。

*2:あるいはその逆なのか

*3:この数字に近づくのは入力がだいぶ大きな数のときですが

*4:データ数はx_testだけで119060個ありますから、ある程度は仕方ないですね

*5:箱の下から上の範囲

*6:ちなみに、角度をラジアンとして出力層の活性化関数「なし」でも試してみましたが、こちらはうまく学習が進まず断念しました。