ディープラーニングを用いたオブジェクトの傾き補正

これはなに?

紙の本を裁断してスキャンしたデータを、綺麗に見やすく整える必要があり、そのための傾き補正について、まずは基本的な考えが間違っていないかをトイデータで検証してみた記録です。

やりたいこと

紙の本はわりとアバウトな作りになっていて、製本時から既に、紙面の長方形に対して、文面が傾いていたりします。本を読むときに、我々はこの傾きを無意識に補正していて、ほとんどの場合は気にならないようです。

しかし、その本をスキャンし、データ化したものを電子端末でいざ読もうとすると、この傾きが暴力的なまでに効いてきます。非常に気持ち悪く、意識が散漫となり、とても読み進めることができません。

幸いに半手動でこの傾きを補正するようなツール*1があり、これを使って手動で補正していたのですが、電子書籍の普及とともに、この作業の価値が低下し…、ようするに面倒くさくなったのです。AIにこの作業をこなしてもらえれば非常に助かります。

Fashion-MNIST

手元にはそこそこの量のデータがあり、これを使って学習することもできますが、いきなり大量のデータを使ってディープラーニングを行うのは無謀です。私が学習に使えるGPUはGTX 1070が一枚。それだけです。ほぼ徒手空拳であり、これでサクサク学習が進むデータセットといえばMNISTぐらいです。

MNISTといえば、28x28ピクセルの手書きの数字画像が70000枚(訓練60000枚, テスト10000枚)…なのですが、今回の用途には適しません。なぜかというと、手書き文字には個々人の癖が反映されていて、傾きという要素もその中に織り込まれているからです。

MNISTの例(うまくいくようには見えない)

f:id:ruby-U:20181003215622p:plain

そこでFashion-MNISTです。MNISTと同じフォーマットでありながら、衣服などからなる10クラスで構成されていて、素晴らしいことに、傾きが補正されています。

Fashion-MNISTの例(綺麗に補正されている)

f:id:ruby-U:20181003215629p:plain

これを使って、まずは本来のラベル(商品のジャンルからなる10クラス)を捨てて、359度・359クラス・1度単位の角度をラベルとして与えたデータを考えます。 このとき、画像はラベルの角度で回転操作をされています。つまり、オブジェクトの回転角度を推定するタスクです。

0-359度、ランダムに回転したオブジェクト

f:id:ruby-U:20181004231232p:plain

多層パーセプトロン

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)

…うまくいきません。

f:id:ruby-U:20181003134250p:plain

これは訓練データの角度が常に一定であるからだと考えて、以下のように動的にデータを生成するようにします。 それに加えて、オーグメンテーションとして、左右反転を入れています。

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)

f:id:ruby-U:20181003134057p:plain

先頭の6件

f:id:ruby-U:20181003134107p:plain

うまくいったようです。ちょっと見にくいですが、左から推定角度、真の角度、確信度です。

15度・600クラス・0.025単位。

さて、次のステップです。本題に戻って考えると、スキャンされたドキュメントというものは、おおよそ方向が揃っています。359度を考える必要はなく、紙面がこのぐらいは傾いてるかもしれない、という部分で解像度を高めてみます。

15度・600クラス・0.025単位を試します。この値は特に意味のあるものではなく、フィーリングで選びました。私が見分けられる傾きが0.1度ぐらいなので、0.025単位でうまく精度が出せるなら、素晴らしいのではないでしょうか。

参考: 上から順に1, 0.5, 0.25, 0.1 の傾き。

f:id:ruby-U:20181003140628p:plain f:id:ruby-U:20181003140635p:plain f:id:ruby-U:20181003140633p:plain f:id:ruby-U:20181003140631p:plain

以下の設定でデータを生成します。モデルは前と同じです。なお、ここからは画像のサイズを縦横それぞれ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件

f:id:ruby-U:20181004231755p:plain

このモデルだと、必ず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件

f:id:ruby-U:20181003145915p:plain

誤差の大きい順に6件

f:id:ruby-U:20181003145948p:plain

外れクラスだと誤識別された9件

f:id:ruby-U:20181003145953p:plain