iPhone
 Computer >> コンピューター >  >> スマートフォン >> iPhone

畳み込みニューラルネットワークの構築からiOSアプリへの実装まで:手書き日本語OCRをApp Storeにリリースした方法

畳み込みニューラルネットワーク(CNN)の構築から、iOS向けOCRの実装まで

プロジェクトのきっかけ ✍️

数ヶ月前、MNISTデータセットを使ったディープラーニングモデルの作り方を学んでいるうちに、手書き文字を認識できるiOSアプリを作ることになりました。

友人の百瀬カイチ(Kaichi Momose)が日本語学習アプリ「Nukon」を開発しており、偶然にも彼も同じような機能をアプリに組み込みたいと考えていました。そこで私たちは協力し、単純な数字認識よりも高度なもの、つまり日本語の文字(ひらがなとカタカナ)を認識するOCR(光学式文字認識)の開発に取りかかりました。

畳み込みニューラルネットワークの構築からiOSアプリへの実装まで:手書き日本語OCRをApp Storeにリリースした方法
基本的なひらがな・カタカナの認識

Nukonの開発当時、日本語の手書き文字認識に対応したAPIは存在しませんでした。そのため、自前でOCRを構築するしかありませんでした。ゼロから構築することで得られた最大のメリットは、オフラインで動作するという点です。ユーザーはインターネットが繋がらない山奥にいても、Nukonを開いて日課の日本語学習を続けることができます。開発プロセスを通じて多くのことを学べたのはもちろん、何よりユーザーにより良いプロダクトを届けられたことに大きな喜びを感じています。

本記事では、iOSアプリ向けの日本語OCRを構築したプロセスを詳しく解説します。他の言語や記号向けのOCRを作りたい方も、データセットを変更するだけで簡単にカスタマイズできるので、ぜひ参考にしてください。

それでは早速、この記事で扱う内容を見ていきましょう。

パート1️⃣:データセットの入手と画像の前処理
パート2️⃣:CNN(畳み込みニューラルネットワーク)の構築と学習
パート3️⃣:学習済みモデルのiOSへの統合

畳み込みニューラルネットワークの構築からiOSアプリへの実装まで:手書き日本語OCRをApp Storeにリリースした方法
最終的なアプリのイメージ(デモはRecogmizeより)

データセットの入手と画像の前処理

データセットには、手書き文字・記号の画像を9セット収録した「ETL文字データベース」を使用します。ひらがなのOCRを構築するため、ここではETL8のデータセットを採用しました。

畳み込みニューラルネットワークの構築からiOSアプリへの実装まで:手書き日本語OCRをApp Storeにリリースした方法
ETL8に収録された、160人の書き手による「あ」の手書き画像

データベースから画像を取り出すには、画像を読み込んで.npz形式で保存するヘルパー関数が必要です。

import struct
import numpy as np
from PIL import Image

sz_record = 8199

def read_record_ETL8G(f):
    s = f.read(sz_record)
    r = struct.unpack('>2H8sI4B4H2B30x8128s11x', s)
    iF = Image.frombytes('F', (128, 127), r[14], 'bit', 4)
    iL = iF.convert('L')
    return r + (iL,)
  
def read_hiragana():
    # 文字種 = 70、書き手 = 160人、y = 127、x = 128
    ary = np.zeros([71, 160, 127, 128], dtype=np.uint8)

    for j in range(1, 33):
        filename = '../../ETL8G/ETL8G_{:02d}'.format(j)
        with open(filename, 'rb') as f:
            for id_dataset in range(5):
                moji = 0
                for i in range(956):
                    r = read_record_ETL8G(f)
                    if b'.HIRA' in r[2] or b'.WO.' in r[2]:
                        if not b'KAI' in r[2] and not b'HEI' in r[2]:
                            ary[moji, (j - 1) * 5 + id_dataset] = np.array(r[-1])
                            moji += 1
    np.savez_compressed("hiragana.npz", ary)

hiragana.npzが保存できたら、ファイルを読み込んで画像のサイズを32×32ピクセルにリサイズし、前処理を始めましょう。さらに、回転やズームを加えた画像を生成するデータ拡張(Data Augmentation)も追加します。さまざまな角度の文字画像でモデルを学習させることで、人の手書き文字への適応力が高まります。

import scipy.misc
from keras.layers import Conv2D, MaxPooling2D
from keras.layers import Dense, Dropout, Activation, Flatten
from keras.models import Sequential
from keras.preprocessing.image import ImageDataGenerator
from keras.utils import np_utils
from sklearn.model_selection import train_test_split

# 71文字
nb_classes = 71
# 入力画像のサイズ
img_rows, img_cols = 32, 32

ary = np.load("hiragana.npz")['arr_0'].reshape([-1, 127, 128]).astype(np.float32) / 15
X_train = np.zeros([nb_classes * 160, img_rows, img_cols], dtype=np.float32)
for i in range(nb_classes * 160):
    X_train[i] = scipy.misc.imresize(ary[i], (img_rows, img_cols), mode='F')
 
y_train = np.repeat(np.arange(nb_classes), 160)

X_train, X_test, y_train, y_test = train_test_split(X_train, y_train, test_size=0.2)

# クラスベクトルをカテゴリカル行列に変換
y_train = np_utils.to_categorical(y_train, nb_classes)
y_test = np_utils.to_categorical(y_test, nb_classes)

# データ拡張
datagen = ImageDataGenerator(rotation_range=15, zoom_range=0.20)
datagen.fit(X_train)

CNNの構築と学習

さて、ここからが楽しいパートです!Kerasを使ってCNN(畳み込みニューラルネットワーク)モデルを構築します。最初にモデルを作ったときは、ハイパーパラメータを何度も実験・調整しました。その結果、以下の組み合わせで最高精度98.77%を達成できました。ぜひ自分でもさまざまなパラメータを試してみてください。

model = Sequential()

def model_6_layers():
    model.add(Conv2D(32, 3, 3, input_shape=input_shape))
    model.add(Activation('relu'))
    model.add(Conv2D(32, 3, 3))
    model.add(Activation('relu'))
    model.add(MaxPooling2D(pool_size=(2, 2)))
    model.add(Dropout(0.5))

    model.add(Conv2D(64, 3, 3))
    model.add(Activation('relu'))
    model.add(Conv2D(64, 3, 3))
    model.add(Activation('relu'))
    model.add(MaxPooling2D(pool_size=(2, 2)))
    model.add(Dropout(0.5))

    model.add(Flatten())
    model.add(Dense(256))
    model.add(Activation('relu'))
    model.add(Dropout(0.5))
    model.add(Dense(nb_classes))
    model.add(Activation('softmax'))

model_6_layers()

model.compile(loss='categorical_crossentropy', 
              optimizer='adam', metrics=['accuracy'])
model.fit_generator(datagen.flow(X_train, y_train, batch_size=16), 
                    samples_per_epoch=X_train.shape[0],
                    nb_epoch=30, validation_data=(X_test, y_test))

学習段階でモデルの精度が思うように出ない場合は、以下のヒントを参考にしてください。

モデルが過学習(オーバーフィッティング)している場合

これは、モデルがうまく汎化できていないことを意味します。直感的な解説については、過学習に関する記事を参照するとよいでしょう。

過学習の検出方法:学習の過程でacc(正解率)は上がり続けているのに、val_acc(検証データの正解率)が逆の動きをしたら過学習のサインです。

過学習への対策:正則化(Dropoutなど)、データ拡張、データセットの品質向上などが有効です。

モデルが「学習」できているかの確認方法

学習を進めてもval_loss(検証データの損失)が上がる、あるいは減少しない場合は、モデルが学習できていません。

TensorBoardを活用しましょう。モデルのパフォーマンスを時系列で可視化してくれるので、エポックごとの値をいちいち目で追って比較するという面倒な作業から解放されます。

精度に満足できたら、重みとモデル構成をファイルとして保存する前に、Dropoutレイヤーを削除しておきます。

for k in model.layers:
    if type(k) is keras.layers.Dropout:
        model.layers.remove(k)
        
model.save('hiraganaModel.h5')

iOSパートに進む前に残されたタスクは、hiraganaModel.h5をCoreMLモデルに変換することだけです。

import coremltools

output_labels = [
'あ', 'い', 'う', 'え', 'お',
'か', 'く', 'こ', 'し', 'せ',
'た', 'つ', 'と', 'に', 'ね',
'は', 'ふ', 'ほ', 'み', 'め',
'や', 'ゆ', 'よ', 'ら', 'り',
'る', 'わ', 'が', 'げ', 'じ',
'ぞ', 'だ', 'ぢ', 'づ', 'で',
'ど', 'ば', 'び',
'ぶ', 'べ', 'ぼ', 'ぱ', 'ぴ',
'ぷ', 'ぺ', 'ぽ',
'き', 'け', 'さ', 'す', 'そ',
'ち', 'て', 'な', 'ぬ', 'の',
'ひ', 'へ', 'ま', 'む', 'も',
'れ', 'を', 'ぎ', 'ご', 'ず',
'ぜ', 'ん', 'ぐ', 'ざ', 'ろ']

scale = 1/255.

coreml_model = coremltools.converters.keras.convert('./hiraganaModel.h5',
                                                    input_names='image',
                                                    image_input_names='image',
                                                    output_names='output',
                                                    class_labels= output_labels,
                                                    image_scale=scale)
coreml_model.author = 'Your Name'
coreml_model.license = 'MIT'
coreml_model.short_description = 'Detect hiragana character from handwriting'
coreml_model.input_description['image'] = 'Grayscale image containing a handwritten character'
coreml_model.output_description['output'] = 'Output a character in hiragana'
coreml_model.save('hiraganaModel.mlmodel')

output_labelsは、後ほどiOS側で表示されるすべての認識結果の候補です。

豆知識:日本語がわかる方ならお気づきかもしれませんが、出力文字の並び順がひらがなの「五十音順」になっていません。ETL8の画像が五十音順に並んでいないことに気づくまでに、私たちは少し時間がかかりました(気づいたのはカイチです)。このデータセットを収録したのは日本の大学なのに…?というのが正直な感想でした。

学習済みモデルのiOSへの統合

いよいよすべてを組み合わせる段階です!hiraganaModel.mlmodelをXcodeプロジェクトにドラッグ&ドロップすると、次のような画面が表示されます。

畳み込みニューラルネットワークの構築からiOSアプリへの実装まで:手書き日本語OCRをApp Storeにリリースした方法
Xcodeワークスペース上のmlmodelの詳細

注意:モデルをコピーすると、Xcodeはワークスペースを作成します。MLモデルを動かすには、コーディング環境をワークスペース側に切り替える必要があります。忘れずに!

最終的なゴールは、画像を渡すとひらがなモデルが文字を予測してくれるようにすることです。そのために、ユーザーが文字を書けるシンプルなUIを作成し、ユーザーの筆跡を画像として保存します。最後に、その画像のピクセル値を取得してモデルに入力します。

順を追って見ていきましょう。

  1. UIBezierPathを使ってUIView上に文字を「描く」
import UIKit

class viewController: UIViewController {

    @IBOutlet weak var canvas: UIView!
    var path = UIBezierPath()
    var startPoint = CGPoint()
    var touchPoint = CGPoint()
  
    override func viewDidLoad() {
        super.viewDidLoad()
        canvas.clipsToBounds = true
        canvas.isMultipleTouchEnabled = true
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        let touch = touches.first
        if let point = touch?.location(in: canvas) {
            startPoint = point
        }
    }
  
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        let touch = touches.first
        if let point = touch?.location(in: canvas) {
            touchPoint = point
        }
        
        path.move(to: startPoint)
        path.addLine(to: touchPoint)
        startPoint = touchPoint
        draw()
    }
    
    func draw() {
        let strokeLayer = CAShapeLayer()
        strokeLayer.fillColor = nil
        strokeLayer.lineWidth = 8
        strokeLayer.strokeColor = UIColor.orange.cgColor
        strokeLayer.path = path.cgPath
        canvas.layer.addSublayer(strokeLayer)
    }
    
    // ビュー上の描画をクリア
    @IBAction func clearPressed(_ sender: UIButton) {
        path.removeAllPoints()
        canvas.layer.sublayers = nil
        canvas.setNeedsDisplay()
    }
}

strokeLayer.strokeColorは任意の色で構いません。ただし、canvasの背景色はにする必要があります。学習画像は白背景・黒線のスタイルですが、実はMLモデルはそのスタイルの入力画像にはうまく反応しないのです。

2. UIViewUIImageに変換し、CVPixelBufferでピクセル値を取得する

extension内に2つのヘルパー関数を用意します。この2つを組み合わせることで、画像をピクセルバッファ(ピクセル値そのもの)に変換できます。入力のwidthheightはどちらも32にしてください。モデルの入力サイズが32×32ピクセルだからです。

pixelBufferが手に入ったら、model.prediction()を呼び出してpixelBufferを渡すだけです。これでclassLabelとして認識結果が出力されます!

@IBAction func recognizePressed(_ sender: UIButton) {
        // ビューを画像に変換
        let resultImage = UIImage.init(view: canvas)
        let pixelBuffer = resultImage.pixelBufferGray(width: 32, height: 32)
        let model = hiraganaModel3()
        // ひらがなの文字を出力
        let output = try? model.prediction(image: pixelBuffer!)
        print(output?.classLabel)
}

extension UIImage {
    // 画像をwidth×heightにリサイズし、グレースケールのCVPixelBufferに変換する
    func pixelBufferGray(width: Int, height: Int) -> CVPixelBuffer? {
        return _pixelBuffer(width: width, height: height,
                           pixelFormatType: kCVPixelFormatType_OneComponent8,
                           colorSpace: CGColorSpaceCreateDeviceGray(),
                           alphaInfo: .none)
    }
    
    func _pixelBuffer(width: Int, height: Int, pixelFormatType: OSType,
                     colorSpace: CGColorSpace, alphaInfo: CGImageAlphaInfo) -> CVPixelBuffer? {
        var maybePixelBuffer: CVPixelBuffer?
        let attrs = [kCVPixelBufferCGImageCompatibilityKey: kCFBooleanTrue,
                     kCVPixelBufferCGBitmapContextCompatibilityKey: kCFBooleanTrue]
        let status = CVPixelBufferCreate(kCFAllocatorDefault,
                                         width,
                                         height,
                                         pixelFormatType,
                                         attrs as CFDictionary,
                                         &maybePixelBuffer)
        
        guard status == kCVReturnSuccess, let pixelBuffer = maybePixelBuffer else {
            return nil
        }
        
        CVPixelBufferLockBaseAddress(pixelBuffer, CVPixelBufferLockFlags(rawValue: 0))
        let pixelData = CVPixelBufferGetBaseAddress(pixelBuffer)
        
        guard let context = CGContext(data: pixelData,
                                      width: width,
                                      height: height,
                                      bitsPerComponent: 8,
                                      bytesPerRow: CVPixelBufferGetBytesPerRow(pixelBuffer),
                                      space: colorSpace,
                                      bitmapInfo: alphaInfo.rawValue)
            else {
                return nil
        }
        
        UIGraphicsPushContext(context)
        context.translateBy(x: 0, y: CGFloat(height))
        context.scaleBy(x: 1, y: -1)
        self.draw(in: CGRect(x: 0, y: 0, width: width, height: height))
        UIGraphicsPopContext()
        
        CVPixelBufferUnlockBaseAddress(pixelBuffer, CVPixelBufferLockFlags(rawValue: 0))
        return pixelBuffer
    }
}

3. UIAlertControllerで結果を表示する

このステップは完全にオプションです。冒頭のGIFで見せたように、私は認識結果を伝えるためにアラートコントローラーを追加しました。

func informResultPopUp(message: String) {
        let alertController = UIAlertController(title: message, 
                                                message: nil, 
                                                preferredStyle: .alert)
        let ok = UIAlertAction(title: "Ok", style: .default, handler: { action in
            self.dismiss(animated: true, completion: nil)
        })
        alertController.addAction(ok)
        self.present(alertController, animated: true) { () in
        }
}

これで完成です!デモ展示できる(そしてApp Storeにリリースできる)レベルのOCRができあがりました!

まとめ

OCRの構築は、思ったほど難しいものではありません。この記事で紹介したのは、プロジェクトを進める中で私が直面した手順や問題の数々です。Pythonで書いたコードをiOSと繋げてデモ可能なものにするプロセスはとても楽しく、これからも続けていきたいと思います。

OCRを作ってみたいけれど何から始めればいいかわからない、という方にとって、この記事が少しでも役立つ情報を提供できていれば幸いです。

ソースコードはこちらから確認できます。

おまけ:浅い(シンプルな)機械学習アルゴリズムで実験してみたい方は、この先もぜひ読んでみてください!

【おまけ】浅いアルゴリズムでの学習に挑戦

CNNを実装する前に、カイチと私は他の機械学習アルゴリズムでもこの課題が解決できるか(そして計算コストを節約できるか!)を検証しました。選んだのはKNNとランダムフォレストです。

パフォーマンスを評価するため、ベースラインの正解率を1/71 = 0.014と定義しました。つまり、日本語をまったく知らない人が文字を当てられる確率は1.4%程度だと仮定したわけです。

したがって、モデルの正解率が1.4%を超えられれば十分に機能していると言えます。果たしてどうなったでしょうか。

KNN

畳み込みニューラルネットワークの構築からiOSアプリへの実装まで:手書き日本語OCRをApp Storeにリリースした方法
KNNで学習した結果

最終的な正解率は54.84%でした。すでに1.4%を大きく上回っています!

ランダムフォレスト

畳み込みニューラルネットワークの構築からiOSアプリへの実装まで:手書き日本語OCRをApp Storeにリリースした方法
ランダムフォレストで学習した結果

正解率は79.23%。ランダムフォレストは私たちの期待を上回る結果を出しました。ハイパーパラメータを調整する中で、推定器(木)の本数と木の深さを増やすとより良い結果が得られることがわかりました。森の中の木(推定器)が多いほど画像の多くの特徴を学習でき、また木が深いほど特徴からより細かいディテールを学習できると考えられます。

さらに詳しく知りたい方は、ランダムフォレストによる画像分類を扱った論文が参考になります。

最後までお読みいただきありがとうございました。ご意見やフィードバックをお待ちしています!

  1. iOS 11のApp Storeの使い方徹底ガイド|新機能からアップデート設定まで

    iOS 11では、App Storeが史上最大となるリニューアルを遂げました。オリジナルコンテンツの充実を軸に、新しいアプリやゲームを探してダウンロードしやすくなっています。デザインだけでなく、App Storeのアイコンまですっきりとした新しいものに変わりました。歓迎すべき変更ばかりですが(特にiOSゲーマーにとっては嬉しい限り)、iOS 11のApp Storeには従来と異なる操作方法がいくつかあります。ここでは、新機能の概要とiOS 11でのApp Storeの使い方をわかりやすく解説します。iOS 11のApp Storeで新しいコンテンツを見つける今回のリニューアルの魅力はどこにある

  2. iPhone・iPadでApp Storeの地域を英国(UK)に変更する方法

    iPhoneやiPadのApp Storeが米国(US)ストアのままになっていませんか?アプリや音楽の価格がドルやユーロで表示されていたり、地理的な制限によって本来英国で利用できるはずのコンテンツにアクセスできない――そんな経験はありませんか? この記事では、iPhoneやiPadのApp Storeの地域設定を英国(UK)に変更し、英国価格でアプリ、音楽、TV番組、映画などを購入できるようにする手順を、初心者にもわかりやすく解説します。 地域設定の問題とは 多くのMacworld読者にとっておなじみのトラブルでしょう。iPhoneやiPadが、自分が米国やその他の国にいると誤認識してしまうケ