データの打ち込みを回避するために手書き文字をスキャンして認識する

今回の目的

こんにちは。

Link-Uの町屋敷です。

今回は下のような手書き文字をパソコンにスキャンして認識するプログラムを作ろうと思います。

テストではどこに文字があるかの情報は全く与えず、文字の場所がどこにあるのかから機械に算出させます。(訓練データもいちいち座標与えるのがめんどくさかったのでそうなりましたが…)

普通のアルファベットと微妙に違うのがわかるでしょうか。

実はこれらは全部cに線を書き足してabcdeにしています。

何のためにこんなものの認識をしたいかというと、外部に出さないようなデータのアノテーション(ラベル付け)をするときに、項目とそれに対応するcが書かれた紙を用意して、項目に該当する部分だけcをaとかeなどに変更してあとAIに任せれば作業効率が上がるかなって。

自分でスキャンされた手書き文字認識を実装してみる

さっそく始めましょう。

画像中の物体を認識するための手法としてR-CNNを参考にします。

画像からの物体認識では大きく二つのパートがあります。画像の中から文字が書かれている領域の候補を選択するパートとその領域内に書かれた文字が何なのかを認識するパートです。

Kerasで一度にR-CNNをできるものもある(より新しいMask-RCNNですら存在する)ようですが、地道に領域をから候補を選択してCNNをするという方法でやります。

Selective Search

画像の中から文字が書かれている領域の候補を選択する方法としてR-CNNの元論文ではSelective searchが使われています。Selective searchにはpythonのパッケージが公開されているのでサンプルを改造して試してみましょう。入力画像は屋上から撮った写真を400*300にリサイズしたものです。

import selectivesearch
from matplotlib import pyplot as plt
import matplotlib.patches as mpatches

def search(img):
    #img = img[0:300,200:800]
    # perform selective search
    img_lbl, regions = selectivesearch.selective_search(
    #    img, scale=5, sigma=0.25, min_size=20)
        img, scale=5, sigma=5, min_size=30)
    candidates = set()
    for r in regions:
        #excluding same rectangle (with different segments)
        if r['rect'] in candidates:
            continue
        # excluding regions smaller than 2000 pixels
        if r['size'] < 30:
            continue
        # distorted rects
        x, y, w, h = r['rect']
        if h == 0 or w == 0:
            continue
        if w / h > 8 or h / w > 8:
            continue
        candidates.add(r['rect'])

    #draw rectangles on the original image
    fig, ax = plt.subplots(ncols=1, nrows=1, figsize=(6, 6))
    ax.imshow(img)
    for x, y, w, h in candidates:
        rect = mpatches.Rectangle(
            (x, y), w, h, fill=False, edgecolor='red', linewidth=1)
        ax.add_patch(rect)
  
    plt.show()
    return candidates

def test_selective_search():
    im = imread("tower.png")
    search(im[:,:,0:3]) #アルファチャンネルを消す

if __name__ == "__main__":
    test_selective_search()
    learn()
    test()

こんな感じで領域の候補が出力されれば成功です。同じ物体に複数の領域が重なっていますがこれが正しい出力結果です。

Selective searchのパッケージが正しく動作していることが分かったので、さっそく訓練データを入れていきましょう。今回の訓練データは、フォントの大きさが違うcが書かれた紙を4枚印刷し、線を付け加えて作ったa,b,d,eの紙をスキャナーで取り込んで縮小したものです。(縮小したものを使わないとSelective Searchの処理が重い)

実際のものがこれです(aのデータ)、折れとか汚れ(スキャン時にできた)とか雑音はいりまくりです。フォントサイズが変わってもAIが対応できるように微妙にフォントの大きさを変えてますが意味があるかは対称を取ってないのでわかりません。

さてこのままさっきのSelective searchに入れてみましょう。

どえらい結果になります。雑音のせいなので取り除きましょう。

今回は背景が白で黒い文字のみの検出を目指しているのでべたに2値化。

    gray = rgb2gray(im)       # グレイスケールに変換
    whiteRegion = gray > 0.8  # 白部分の雑音除去
    gray[whiteRegion] = 1
    whiteRegion = gray <= 0.8  # 黒部分の雑音除去
    gray[whiteRegion] = 0
    im = gray2rgb(gray)

さてこのままさっきのSelective searchに入れてみましょう。<img class=”alignnone wp-image-337 size-full” src=”https://tech.link-u.co.jp/wp-content/
また、Selective searchの設定も変えましょう。

1文字の認識には長方形の領域はいらないので消去、

また上のほうの小さい文字が認識できてないのでomegaを変更して感度も上げ、画像全体が領域として選択されているのでこれも弾きます。

img_lbl, regions = selectivesearch.selective_search(
img, scale=5, sigma=0.25, min_size=20)
#img, scale=5, sigma=8, min_size=30)
candidates = set()
for r in regions:
#excluding same rectangle (with different segments)
if r['rect'] in candidates:
continue
#excluding regions smaller than 2000 pixels
if r['size'] < 20 or r['size'] > 3000:
continue
# distorted rects
x, y, w, h = r['rect']
if h == 0 or w == 0:
continue
if w / h > 2 or h / w > 2:
continue
candidates.add(r['rect'])

まともになりました。

しかしよく見て見ると一つの文字に複数の領域が重なっています。

もとのR-CNNの論文ではgreedy non-maximum suppressionなどを駆使して除去していますが。今回は汚れなどを除いて背景がなく、文字が重なることもなく、また各文字の大きさも一定なので、単純にある領域に近い領域か存在した場合、領域の中心座標だけ残して領域は全部消し、その点から上下左右16pixを新しい領域にしました。

def get_candidate(img_path):
    def is_checked(checked, test_point,r):
        for c in checked:
            if np.linalg.norm(c - test_point) < r:
                return True
        return False

    im = imread(img_path)
    gray = rgb2gray(im)       # グレイスケールに変換
    whiteRegion = gray > 0.8  # 白部分の雑音除去
    gray[whiteRegion] = 1
    whiteRegion = gray <= 0.8  # 黒部分の雑音除去
    gray[whiteRegion] = 0
    # = median(gray, disk(1))
    #gray = denoise_wavelet(gray) #ごましお雑音除去
    im = gray2rgb(gray)
    cand = search(im)
    print(np.shape(im))
    g_img = np.asarray(gray)
    checked_points = []
    images = []
    ren = 16
    for c in cand:
        xc = int(c[0] + c[2]/2)
        yc = int(c[1] + c[3]/2)
        if len(checked_points) is not 0:
            if is_checked(checked_points, np.array([xc,yc]), 10):
                continue
        image = resize(g_img[yc-ren:yc+ren,xc-ren:xc+ren], (28,28))
        images.append(image)
        #X = np.array([image])

        #plt.imshow(X[0], cmap='gray')
        checked_points.append(np.array([xc,yc]))
        #plt.show()
    return images, checked_points

これをa,b,c,d,eの5枚に行います。これで教師データとして画像データを取り出すことができます。

しかし、ラベルデータがないので

CNN

作ったデータセットでCNNの識別器を作ります。

スクリプトは以下の通り。

def learn():
    import glob
    img_pathes = glob.glob("..\\..\\Documents\\dataset\\abcde\\learn\\scan\\*.*")

    x = np.array([])
    y = np.array([])
    for ip in img_pathes:
        print(ip)
        if "a_" in ip:
            i = 0
        elif "b_" in ip:
            i = 1
        elif "c_" in ip:
            i = 2
        elif "d_" in ip:
            i = 3
        elif "e_" in ip:
            i = 4
        else:
            raise("Unexpected File is Loaded")


        imgs, _ =  get_candidate(ip)
        nimgs = np.array(imgs)
        x = np.append(x, nimgs)
        print(len(x))
        y = np.append(y, np.array(len(imgs) * [i]))
        print(len(y))
    x = np.reshape(x, (-1,28,28))
    seed = 123
    (X_train, X_valid, Y_train, Y_valid) = train_test_split(x, y, test_size=0.15, random_state=seed)
    learn_cnn(X_train, X_valid, Y_train, Y_valid)
    py = load_predict(x)
    for a,b,c in zip(x,y,py):
        if not b == np.argmax(c):
            print((b,c))
            plt.imshow(a, cmap='gray')
            plt.show()

def learn_cnn(X_train, X_test, Y_train, Y_test):
    X_train = X_train.reshape(X_train.shape[0], 28, 28, 1).astype('float32')
    X_test  = X_test.reshape(X_test.shape[0], 28, 28, 1).astype('float32')

    #X_train = X_train / 255
    #X_test  = X_test / 255
    # one hot encode outputs
    Y_train = np_utils.to_categorical(Y_train)
    Y_test  = np_utils.to_categorical(Y_test)

    num_classes = Y_test.shape[1]


    # create model
    model = Sequential()
    model.add(Conv2D(64, (5, 5), input_shape=(28, 28, 1), activation='relu'))
    model.add(MaxPooling2D(pool_size=(4, 4)))
    model.add(Conv2D(32, (3, 3), activation='relu'))
    model.add(MaxPooling2D(pool_size=(2, 2)))
    model.add(Dropout(0.2))
    model.add(Flatten())
    model.add(Dense(128, activation='relu'))
    model.add(Dense(5, activation='softmax'))

    # Compile model
    model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['accuracy'])
    model.fit(X_train, Y_train, validation_data=(X_test, Y_test), epochs=500, batch_size=200, verbose=2)

    # Final evaluation of the model
    scores = model.evaluate(X_test,Y_test, verbose=0)
    print("CNN Error: %.2f%%" % (100-scores[1]*100))

    model.save('weights.model')

Kerasのサンプルに畳み込み層とプーリング層を追加したものです。多分これが最善ではないです。

パラメータを変えるなど各種改造をするときは、testデータを使わずtrainデータを分割して評価して最善のものを目指します。

このような結果が出力されます。テストデータのうち2パーセント間違えたことがわかります。

また、このスクリプトは、学習後に間違えたデータを表示します。

この2つのデータを間違えたようです。(実際は左がa右がd)

試行錯誤繰り返し、満足したところでtestデータを使って認識結果を出力してみましょう。

教師データを分割していましたが、もったいないのですべてを教師データとしてもう一度CNNを学習させます。

learn_cnn(x, X_valid, y, Y_valid)

テストデータは最初に貼ったデータと

このスパースなデータです。

いつものGPUのideapadS720のスピードの差はこちら

GPUのほうが6倍くらい速いです。

一回大量データかつモデルが複雑な場合もやらねばなあ。

で、文字の識別結果は以下の通り。

画像の中の?はCNNの出力を見てAIに自信がなさそうな時に付けています。自信度がなぜわかるか具体的に書くと今回CNNの出力は5クラスの識別問題なので5次元のベクトルで、それぞれが0から1の値を持っています。5つの次元は[a,b,c,d,e]を表します。

例えば、わかりやすいaの画像が入力された場合5次元のベクトルは[1, 0 , 0, 0, 0]に近い値を出力します。少しdとまぎらわしいaが入力された場合[0.8, 0, 0, 0.2, 0]のような値を出します。このベクトルの数値を見ることによってAIの自信度がわかるということです。

1つめは4ミスでしたが2つ目はノーミスでした。もう少し教師データを増やせば2つぐらいは減らせると思います。丁寧に書けば使えるかなといった感じです。

まとめ

今回はスキャンした手書き文字を認識するプログラムを作りました。

字をきれいに書けば使える程度のものは作れたと思います。

プログラム全文

from keras.models import Sequential
from keras.layers import Dense
from keras.layers import Dropout
from keras.layers import Flatten
from keras.layers.convolutional import Conv2D
from keras.layers.convolutional import MaxPooling2D
from keras import backend as K
from keras.utils import np_utils
from sklearn.model_selection import train_test_split
import numpy as np
from matplotlib import pyplot as plt
import matplotlib.patches as mpatches
from decimal import *
import skimage
from skimage.filters.rank import median
from skimage.morphology import disk
from skimage.transform import resize
from skimage.restoration import denoise_wavelet
from skimage.io import imread
from skimage.color import rgb2gray, gray2rgb
from PIL import Image, ImageDraw, ImageFont
import selectivesearch
import time

def load_predict(X):
    X  = X.reshape(X.shape[0], 28, 28, 1).astype('float32')
    #X = X / 255
    # create model
    model = Sequential()
    model.add(Conv2D(64, (5, 5), input_shape=(28, 28, 1), activation='relu'))
    model.add(MaxPooling2D(pool_size=(4, 4)))
    model.add(Conv2D(32, (3, 3), activation='relu'))
    model.add(MaxPooling2D(pool_size=(2, 2)))
    model.add(Dropout(0.2))
    model.add(Flatten())
    model.add(Dense(128, activation='relu'))
    model.add(Dense(5, activation='softmax'))

    # Compile model
    model.compile(loss='sparse_categorical_crossentropy', optimizer='adam', metrics=['accuracy'])
    model.load_weights('weights.model')
    return model.predict(X)

def search(img):
    #img = img[0:300,200:800]
    # perform selective search
    img_lbl, regions = selectivesearch.selective_search(
        img, scale=5, sigma=0.25, min_size=20)
        #img, scale=5, sigma=8, min_size=30)
    candidates = set()
    for r in regions:
        #excluding same rectangle (with different segments)
        if r['rect'] in candidates:
            continue
        # excluding regions smaller than 2000 pixels
        if r['size'] < 20 or r['size'] > 3000:
            continue
        # distorted rects
        x, y, w, h = r['rect']
        if h == 0 or w == 0:
            continue
        if w / h > 2 or h / w > 2:
            continue
        candidates.add(r['rect'])

    #draw rectangles on the original image
    #fig, ax = plt.subplots(ncols=1, nrows=1, figsize=(6, 6))
    #ax.imshow(img)
    #===========================================================================
    # for x, y, w, h in candidates:
    #     rect = mpatches.Rectangle(
    #         (x, y), w, h, fill=False, edgecolor='red', linewidth=1)
    #     ax.add_patch(rect)
    #===========================================================================

    #plt.show()
    return candidates

def test_selective_search():
    im = imread("a_scan.png")
    search(im[:,:,0:3])

def learn():
    import glob
    #img_pathes = glob.glob("..\\..\\Documents\\dataset\\abcde\\learn\\*.*")
    #img_pathes += glob.glob("..\\..\\Documents\\dataset\\abcde\\learn\\scan\\*.*")
    img_pathes = glob.glob("..\\..\\Documents\\dataset\\abcde\\learn\\scan\\*.*")

    x = np.array([])
    y = np.array([])
    for ip in img_pathes:
        print(ip)
        if "a_" in ip:
            i = 0
        elif "b_" in ip:
            i = 1
        elif "c_" in ip:
            i = 2
        elif "d_" in ip:
            i = 3
        elif "e_" in ip:
            i = 4
        else:
            raise("Unexpected File is Loaded")


        imgs, _ =  get_candidate(ip)
        nimgs = np.array(imgs)
        x = np.append(x, nimgs)
        print(len(x))
        y = np.append(y, np.array(len(imgs) * [i]))
        print(len(y))
    x = np.reshape(x, (-1,28,28))
    seed = 123
    (X_train, X_valid, Y_train, Y_valid) = train_test_split(x, y, test_size=0.15, random_state=seed)
    #learn_cnn(x, X_valid, y, Y_valid)
    learn_cnn(x, X_valid, y, Y_valid)
    py = load_predict(x)
    for a,b,c in zip(x,y,py):
        if not b == np.argmax(c):
            print((b,c))
            plt.imshow(a, cmap='gray')
            plt.show()

def learn_cnn(X_train, X_test, Y_train, Y_test):
    t = time.time()
    X_train = X_train.reshape(X_train.shape[0], 28, 28, 1).astype('float32')
    X_test  = X_test.reshape(X_test.shape[0], 28, 28, 1).astype('float32')

    #X_train = X_train / 255
    #X_test  = X_test / 255
    # one hot encode outputs
    Y_train = np_utils.to_categorical(Y_train)
    Y_test  = np_utils.to_categorical(Y_test)

    num_classes = Y_test.shape[1]


    # create model
    model = Sequential()
    model.add(Conv2D(64, (5, 5), input_shape=(28, 28, 1), activation='relu'))
    model.add(MaxPooling2D(pool_size=(4, 4)))
    model.add(Conv2D(32, (3, 3), activation='relu'))
    model.add(MaxPooling2D(pool_size=(2, 2)))
    model.add(Dropout(0.2))
    model.add(Flatten())
    model.add(Dense(128, activation='relu'))
    model.add(Dense(5, activation='softmax'))

    # Compile model
    model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['accuracy'])
    model.fit(X_train, Y_train, validation_data=(X_test, Y_test), epochs=500, batch_size=200, verbose=2)

    # Final evaluation of the model
    scores = model.evaluate(X_test,Y_test, verbose=0)
    print("CNN Error: %.2f%%" % (100-scores[1]*100))

    model.save('weights.model')
    print(time.time()-t)

def get_candidate(img_path):
    def is_checked(checked, test_point,r):
        for c in checked:
            if np.linalg.norm(c - test_point) < r:
                return True
        return False

    im = imread(img_path)
    gray = rgb2gray(im)       # グレイスケールに変換
    whiteRegion = gray > 0.8  # 白部分の雑音除去
    gray[whiteRegion] = 1
    whiteRegion = gray <= 0.8  # 黒部分の雑音除去
    gray[whiteRegion] = 0
    # = median(gray, disk(1))
    #gray = denoise_wavelet(gray) #ごましお雑音除去
    im = gray2rgb(gray)
    cand = search(im)
    print(np.shape(im))
    g_img = np.asarray(gray)
    checked_points = []
    images = []
    ren = 16
    for c in cand:
        xc = int(c[0] + c[2]/2)
        yc = int(c[1] + c[3]/2)
        if len(checked_points) is not 0:
            if is_checked(checked_points, np.array([xc,yc]), 10):
                continue
        image = resize(g_img[yc-ren:yc+ren,xc-ren:xc+ren], (28,28))
        images.append(image)
        #X = np.array([image])

        #plt.imshow(X[0], cmap='gray')
        checked_points.append(np.array([xc,yc]))
        #plt.show()
    return images, checked_points

def test():
    import glob
    img_pathes = glob.glob("..\\..\\Documents\\dataset\\abcde\\test\\*")

    label_to_char = ["a", "b", "c", "d", "e"]
    i = -1
    for ip in img_pathes:
        x = np.array([])
        y = np.array([])
        print(ip)
        imgs, points =  get_candidate(ip)
        nimgs = np.array(imgs)
        x = np.append(x, nimgs)
        print(len(x))
        y = np.append(y, np.array(len(imgs) * [i]))
        print(len(y))

        x = np.reshape(x, (-1,28,28))
        seed = 123
        pre_y = load_predict(x)
        im = Image.open(ip)
        draw = ImageDraw.Draw(im)
        font = ImageFont.truetype("C:\\Windows\\Fonts\\meiryob.ttc", 16)
        for py, p in zip(pre_y, points):
            pst = np.argsort(py)
            max_val = np.max(py)
            am1, am2 = pst[-1], pst[-2]
            if   max_val > 0.95:
                draw.text((p[0]+16, p[1]), label_to_char[am1], fill=(255, 0, 0), font=font)
            elif max_val > 0.8:
                draw.text((p[0]+16, p[1]), label_to_char[am1] +"," + label_to_char[am2] + "?", fill=(255, 0, 0), font=font)
            elif max_val > 0.4:
                draw.text((p[0]+16, p[1]), label_to_char[am1] +"," + label_to_char[am2] + "??", fill=(255, 0, 0), font=font)
            else:
                draw.text((p[0]+16, p[1]), "?", fill=(255, 0, 0), font=font)
        im.save("hand_only_sparse.png")
        im.show()

if __name__ == "__main__":
    #test_selective_search()
    learn()
    test()

コメントを残す

以下に詳細を記入するか、アイコンをクリックしてログインしてください。

WordPress.com ロゴ

WordPress.com アカウントを使ってコメントしています。 ログアウト /  変更 )

Google フォト

Google アカウントを使ってコメントしています。 ログアウト /  変更 )

Twitter 画像

Twitter アカウントを使ってコメントしています。 ログアウト /  変更 )

Facebook の写真

Facebook アカウントを使ってコメントしています。 ログアウト /  変更 )

%s と連携中