これはなに?
紙の本を裁断してスキャンしたデータを、綺麗に見やすく整える必要があり、そのための傾き補正について、まずは基本的な考えが間違っていないかをトイデータで検証してみた記録です。
やりたいこと
紙の本はわりとアバウトな作りになっていて、製本時から既に、紙面の長方形に対して、文面が傾いていたりします。本を読むときに、我々はこの傾きを無意識に補正していて、ほとんどの場合は気にならないようです。
しかし、その本をスキャンし、データ化したものを電子端末でいざ読もうとすると、この傾きが暴力的なまでに効いてきます。非常に気持ち悪く、意識が散漫となり、とても読み進めることができません。
幸いに半手動でこの傾きを補正するようなツール*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)
b = K.equal(y_pred_class, e)
a_eq_b = K.equal(a, b)
a_ne_b = K.not_equal(a, b)
case_0 = K.cast(a_eq_b, dtype='float32')
case_1 = K.minimum(K.cast(a, dtype='float32'), K.cast(a_ne_b, dtype='float32'))
case_2 = K.minimum(K.cast(b, dtype='float32'), K.cast(a_ne_b, dtype='float32'))
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件