これはなに?
紙の本を裁断してスキャンしたデータを、綺麗に見やすく整える必要があり、そのための傾き補正について、まずは基本的な考えが間違っていないかをトイデータで検証してみた記録です。
やりたいこと
紙の本はわりとアバウトな作りになっていて、製本時から既に、紙面の長方形に対して、文面が傾いていたりします。本を読むときに、我々はこの傾きを無意識に補正していて、ほとんどの場合は気にならないようです。
しかし、その本をスキャンし、データ化したものを電子端末でいざ読もうとすると、この傾きが暴力的なまでに効いてきます。非常に気持ち悪く、意識が散漫となり、とても読み進めることができません。
幸いに半手動でこの傾きを補正するようなツール*1があり、これを使って手動で補正していたのですが、電子書籍の普及とともに、この作業の価値が低下し…、ようするに面倒くさくなったのです。AIにこの作業をこなしてもらえれば非常に助かります。
Fashion-MNIST
手元にはそこそこの量のデータがあり、これを使って学習することもできますが、いきなり大量のデータを使ってディープラーニングを行うのは無謀です。私が学習に使えるGPUはGTX 1070が一枚。それだけです。ほぼ徒手空拳であり、これでサクサク学習が進むデータセットといえばMNISTぐらいです。
MNISTといえば、28x28ピクセルの手書きの数字画像が70000枚(訓練60000枚, テスト10000枚)…なのですが、今回の用途には適しません。なぜかというと、手書き文字には個々人の癖が反映されていて、傾きという要素もその中に織り込まれているからです。
MNISTの例(うまくいくようには見えない)
そこでFashion-MNISTです。MNISTと同じフォーマットでありながら、衣服などからなる10クラスで構成されていて、素晴らしいことに、傾きが補正されています。
Fashion-MNISTの例(綺麗に補正されている)
これを使って、まずは本来のラベル(商品のジャンルからなる10クラス)を捨てて、359度・359クラス・1度単位の角度をラベルとして与えたデータを考えます。 このとき、画像はラベルの角度で回転操作をされています。つまり、オブジェクトの回転角度を推定するタスクです。
0-359度、ランダムに回転したオブジェクト
多層パーセプトロン
359度・359クラス・1度単位
まずは単純な多層パーセプトロンで試してみます。GTX 1070でも、5並列ぐらいでパラメータの違う学習を進めることができて捗ります。
import tensorflow as tf from tensorflow import keras from tensorflow.keras import backend as K import numpy as np import matplotlib.pyplot as plt print("tf.version={0}".format(tf.__version__)) (train_images, train_labels), (test_images, test_labels) = keras.datasets.fashion_mnist.load_data() num_classes = 359 classes = range(num_classes) def generate_rotated_dataset(images): def _rotate(img, step): if step == 0: return img return rotate(img, angle=step, reshape=False, mode="constant", cval=0) new_images = [] labels = [] for img in images: label = np.random.choice(classes) new_images.append(_rotate(img, label)) labels.append(label) return (np.array(new_images), np.array(labels)) train_images, train_labels = generate_rotated_dataset(train_images) test_images, test_labels = generate_rotated_dataset(test_images) train_images = train_images / 255.0 test_images = test_images / 255.0 from tensorflow.keras.layers import Flatten, Dense, Dropout, Activation from tensorflow.keras.optimizers import Adam def gpu_memory_lazy_allocate(): config = tf.ConfigProto() config.gpu_options.allow_growth = True sess = tf.Session(config=config) K.set_session(sess) gpu_memory_lazy_allocate() def get_model(): model = Sequential() model.add(Flatten(input_shape=(28, 28))) model.add(Dense(640)) model.add(Activation('relu')) model.add(Dropout(0.2)) model.add(Dense(640)) model.add(Activation('relu')) model.add(Dropout(0.2)) model.add(Dense(num_classes)) model.add(Activation('softmax')) return model model = get_model() optimizer = tf.keras.optimizers.Adam(lr=0.0001) model.compile(optimizer=optimizer, loss='sparse_categorical_crossentropy', metrics=['accuracy']) reduce_lr = tf.keras.callbacks.ReduceLROnPlateau( monitor='val_loss', factor=0.5, patience=20, verbose=1, mode='auto', min_delta=0.0001, cooldown=10, min_lr=0) history = model.fit_generator( x=train_images, y=train_labels, validation_data=(test_images, test_labels), callbacks=[reduce_lr], epochs=100000)
…うまくいきません。
これは訓練データの角度が常に一定であるからだと考えて、以下のように動的にデータを生成するようにします。 それに加えて、オーグメンテーションとして、左右反転を入れています。
from tensorflow.keras.models import Sequential batch_size = 32 def generate_rotated_dataset(images): def _rotate(img, step): if step == 0: return img return rotate(img, angle=step, reshape=False, mode="constant", cval=0) new_images = [] labels = [] for img in images: label = np.random.choice(classes) new_images.append(_rotate(img, label)) labels.append(label) return np.array(new_images), np.array(labels) class TrainImageSequence(Sequence): def __init__(self, images, batch_size=1): self.images = images self.batch_size = batch_size def on_epoch_end(self): np.random.shuffle(self.images) def _horizontal_flip(self, img): if np.random.choice([True, False]): return cv2.flip(img, 1) return img def __getitem__(self, idx): start = idx * self.batch_size end = start + self.batch_size batch_x = self.images[start:end] batch_x = [self._horizontal_flip(x) for x in batch_x] return generate_rotated_dataset(batch_x) def __len__(self): return math.ceil(len(self.images) / self.batch_size) class TestImageSequence(Sequence): def __init__(self, images, batch_size=1): self.x, self.y = generate_rotated_dataset(images) self.batch_size = batch_size def __getitem__(self, idx): start = idx * self.batch_size end = start + self.batch_size batch_x = self.x[start:end] batch_y = self.y[start:end] return batch_x, batch_y def __len__(self): return math.ceil(len(self.x) / self.batch_size) train_gen = TrainImageSequence(train_images, batch_size=batch_size) test_gen = TestImageSequence(test_images, batch_size=batch_size)
history = model.fit_generator(
generator=train_gen,
validation_data=test_gen,
callbacks=[reduce_lr],
epochs=100000)
先頭の6件
うまくいったようです。ちょっと見にくいですが、左から推定角度、真の角度、確信度です。
15度・600クラス・0.025単位。
さて、次のステップです。本題に戻って考えると、スキャンされたドキュメントというものは、おおよそ方向が揃っています。359度を考える必要はなく、紙面がこのぐらいは傾いてるかもしれない、という部分で解像度を高めてみます。
15度・600クラス・0.025単位を試します。この値は特に意味のあるものではなく、フィーリングで選びました。私が見分けられる傾きが0.1度ぐらいなので、0.025単位でうまく精度が出せるなら、素晴らしいのではないでしょうか。
参考: 上から順に1, 0.5, 0.25, 0.1 の傾き。
以下の設定でデータを生成します。モデルは前と同じです。なお、ここからは画像のサイズを縦横それぞれ2倍にしています。多少ですが、実際のシーンに近づけるためです。
batch_size = 32 num_classes = 600 image_shape = (56, 56) classes = range(num_classes) class_values = np.linspace(-7.5, 7.5, num_classes) def generate_rotated_dataset(images): def _resize(img, size): return cv2.resize(img, size) def _rotate(img, step): if step == 0: return img return rotate(img, angle=step, reshape=False, mode="constant", cval=0) new_images = [] labels = [] for img in images: label = np.random.choice(classes) img = _resize(img, image_shape) img = _rotate(img, class_values[label]) img = img.reshape(*image_shape, 1) new_images.append(img) labels.append(label) return np.array(new_images), np.array(labels)
Top 1: 0.0795 Top 2: 0.1577 Top 3: 0.2344 Top 4: 0.3077 Top 5: 0.3801 Mean error: 0.1690
TopNの精度がビシッと出ておらず、ピークがぼやけている感じです。
先頭の6件
このモデルだと、必ず600クラスのうちから候補を選ぶことになるので、わからないときはわからないと判断してもらったほうがよいかもしれません。 600クラスに、-7.5 ~ 7.5 以外の角度からランダムにチョイスした1クラスを追加してみます。
15度・600 + 1クラス・0.025単位。
batch_size = 32 num_classes = 600 image_shape = (56, 56) classes = range(num_classes) class_values = np.linspace(-7.5, 7.5, num_classes) def generate_rotated_dataset(images): def _resize(img, size): return cv2.resize(img, size) def _rotate(img, step): if step == 0: return img return rotate(img, angle=step, reshape=False, mode="constant", cval=0) new_images = [] labels = [] for img in images: if np.random.uniform(0, 1) > 0.1: label = np.random.choice(classes) angle = class_values[label] else: # 範囲外のクラス label = num_classes angle = np.random.uniform(7.5 + K.epsilon(), 352.5 - K.epsilon()) img = _resize(img, image_shape) img = _rotate(img, angle) img = img.reshape(*image_shape, 1) new_images.append(img) labels.append(label) return np.array(new_images), np.array(labels)
Top 1: 0.0915 Top 2: 0.1821 Top 3: 0.2681 Top 4: 0.3505 Top 5: 0.4240 Mean error: 0.1394 False positive: 70 (0.0078) False negative: 24 (0.0240)
15度内の画像を誤って外れクラスだと識別する確率が0.0078あり、その場合はTopNの計算からは省いています。 若干ですが、TopNの精度と平均誤差が改善しました。
VGG
さて、GPU負荷が軽い多層パーセプトロンでおおよその目星がついたので、やや重いVGGモデルで精度を高めていきます。オリジナルのVGGにバッチノーマリゼーションを加えています。
from tensorflow.keras.models import Model from tensorflow.keras.layers import Input, Flatten, Dense, Dropout, Activation, BatchNormalization from tensorflow.keras.optimizers import Adam from tensorflow.keras.applications import VGG16 def gpu_memory_lazy_allocate(): config = tf.ConfigProto() config.gpu_options.allow_growth = True sess = tf.Session(config=config) K.set_session(sess) gpu_memory_lazy_allocate() def get_model(): mnist_input = Input(shape=(*image_shape, 1), name='mnist_input') vgg16 = VGG16(input_shape=(*image_shape, 1), weights=None, include_top=False) vgg16.summary() vgg16_output = vgg16(mnist_input) x = Flatten(name='flatten')(vgg16_output) x = Dropout(0.8)(x) x = Dense(1024, activation='relu', name='fc1')(x) x = BatchNormalization()(x) x = Dropout(0.8)(x) x = Dense(1024, activation='relu', name='fc2')(x) x = BatchNormalization()(x) x = Dropout(0.8)(x) x = Dense(num_classes + 1, activation='softmax', name='predictions')(x) return Model(inputs=mnist_input, outputs=x) model = get_model() optimizer = tf.keras.optimizers.Adam(lr=0.001) model.compile(optimizer=optimizer, loss='sparse_categorical_crossentropy', metrics=['accuracy']) reduce_lr = tf.keras.callbacks.ReduceLROnPlateau( monitor='val_loss', factor=0.5, patience=20, verbose=1, mode='auto', min_delta=0.0001, min_lr=0) history = model.fit_generator( generator=train_gen, validation_data=test_gen, max_queue_size=12*2, workers=12, use_multiprocessing=True, callbacks=[reduce_lr], epochs=100000)
Top 1: 0.4724 Top 2: 0.8104 Top 3: 0.9597 Top 4: 0.9906 Top 5: 0.9980 Mean error: 0.0148 False positive: 0 (0.0000) False negative: 2 (0.1333)
途中で打ち切ったのですが、精度はやはり段違いです。 False-Positiveな要素がない(うまく識別できないものを外れクラスに追い出せていない)のが気になるので、この部分をロス関数で緩和してみます。
外れクラスへの誤答のペナルティを緩和したロス関数
def normalized_loss(y_true, y_pred): y_true_class = K.cast(y_true, dtype='float32') y_pred_class = K.cast(K.argmax(y_pred), dtype='float32') e = K.ones_like(y_true, dtype='float32') * num_classes a = K.equal(y_true_class, e) # y_true_class == error_class b = K.equal(y_pred_class, e) # y_pred_class == error_class a_eq_b = K.equal(a, b) a_ne_b = K.not_equal(a, b) case_0 = K.cast(a_eq_b, dtype='float32') # a == b case_1 = K.minimum(K.cast(a, dtype='float32'), K.cast(a_ne_b, dtype='float32')) # a AND (a != b) case_2 = K.minimum(K.cast(b, dtype='float32'), K.cast(a_ne_b, dtype='float32')) # b AND (a != b) w = 1 - y_pred[:, num_classes] * 0.2 loss = K.sparse_categorical_crossentropy(y_true, y_pred) return loss * (case_0 + case_1 + case_2 * w) model.compile(optimizer=optimizer, loss=normalized_loss, metrics=['sparse_categorical_accuracy'])
Top 1: 0.7478 Top 2: 0.9483 Top 3: 0.9902 Top 4: 0.9972 Top 5: 0.9992 Mean error: 0.0066 False positive: 9 (0.0010) False negative: 103 (0.1017)
誤差が0.1を超えたのは1個だけになりました。よさそうです。
先頭の6件
誤差の大きい順に6件
外れクラスだと誤識別された9件