Help us understand the problem. What is going on with this article?

Tesseract 4.1にLSTMを使って日本語を再学習させる

背景

TesseractはオープンソースのOCRエンジンです。バージョン4.0から深層学習を採用したことで認識精度が大きく上がりました。このTesseractを実務で使ってみて、苦手分野があることが分かりました。

  • 全角英数字
  • 半角カタカナ
  • 手書き文字

今回はこの3つのうち最初の2つの認識精度を上げるため、Tesseractの日本語モデルを再学習させてみます。
なお、手書き文字の再学習についてはTesseract 4.1にLSTMを使って手書き文字を再学習させるにまとめています。

学習方法の選択

LSTMを使ったTesseractの学習方法には大きく分けて2つの方法があります。

  • 新規学習方式 (Training From Scratch):ゼロからモデルを生成する
  • 微調整方式 (Fine Tuning):既存のモデルから転移学習する

新規学習方式は、より多くのイタレーションと学習データが必要となります。このため、今回は微調整方式を採用します。

微調整方式の種類

  • 既存のモデルの学習層を維持する
  • 学習層の一部を取り換える

文字コードを追加するときや新しいフォントに対応させるときなど、小さな変更のときは既存のモデルの学習層を維持する方が有利だそうです。今回のケースはこちらにあてはまります。

他方、十分な学習データがない別の言語に対応させる場合などは、学習層の一部(特に出力層)を取り換えてから学習する方が有利だそうです。

環境設定

AWS EC2 の Ubuntu Server 18.04 LTS AMIを使います。TesseractはCPUの負荷がボトルネックとなることが多く、またデフォルトで4コアに対応しているので、c5.xlargeインスタンスを選択します。c5.2xlargeインスタンスを選択してもLSTM学習では4コアしか使われません。

Tesseractのセットアップ

UbuntuにTesseractをインストールには3つの方法があります。

(1) ソースコードからビルド
(2) 標準レポジトリからapt
(3) Alexander Pozdnyakov氏のPPAからapt

2019年4月3日時点で (1)のバージョンは4.1.0-rc1、(2)は4.0.0-beta.1、(3)は4.1.0-rc1でした。Ubuntuユーザーなら(3)の方法を使うとコンパイル時間を節約しながら最新バージョンをインストールできるので今回は(3)を選択します。

以下、すべてのコマンドをrootとして実行します。
#Ubuntu自体を更新
$ apt update && apt upgrade
#Alexander Pozdnyakov氏のPPA
$ add-apt-repository ppa:alex-p/tesseract-ocr -y && apt update
#Tesseract本体
$ apt install -y tesseract-ocr
#今回の作業ディレクトリ
$ mkdir ~/tess && cd ~/tess
#tesstrain.shを使用するためTesseractのソースコード
$ git clone --depth 1 https://github.com/tesseract-ocr/tesseract.git
#全言語の設定ファイル (約232MB)
$ git clone --depth 1 https://github.com/tesseract-ocr/langdata.git
OCRモデルの設定
#環境変数へ既存モデルへのPATHを設定
$ echo "export TESSDATA_PREFIX=/usr/share/tesseract-ocr/4.00/tessdata/" >> ~/.profile && source ~/.profile
# デフォルトで入っているengに加えて、既存モデル4種(jpn, eng_best, jpn_best, jpn_vert)をダウンロード
# jpn_vert は縦書用モデルだが jpn, jpn_best 実行時に必要
$ wget https://github.com/tesseract-ocr/tessdata/raw/master/jpn.traineddata -P $TESSDATA_PREFIX
$ wget https://github.com/tesseract-ocr/tessdata_best/raw/master/eng.traineddata -O $TESSDATA_PREFIX/eng_best.traineddata
$ wget https://github.com/tesseract-ocr/tessdata_best/raw/master/jpn.traineddata -O $TESSDATA_PREFIX/jpn_best.traineddata
$ wget https://github.com/tesseract-ocr/tessdata_best/raw/master/jpn_vert.traineddata -P $TESSDATA_PREFIX
#既存モデルの確認
$ tesseract --list-langs
#jpn_bestをLSTM学習用のフォーマットへ変換
$ combine_tessdata -e $TESSDATA_PREFIX/jpn_best.traineddata ~/tess/jpn_best.lstm

フォントのインストール

Tesseractの学習データ作成用の標準フォントを調べます。

$ less ~/tess/tesseract/src/training/language-specific.sh

日本語用には以下のフォントが挙がっています。

  • TakaoExGothic
  • TakaoExMincho
  • TakaoGothic
  • TakaoMincho
  • TakaoPGothic
  • TakaoPMincho
  • VL Gothic
  • VL PGothic
  • Noto Sans Japanese Bold
  • Noto Sans Japanese Light

このうち、TakaoExGothicTakaoExMinchoは縦書用フォントなので今回は対象外となります。日本語のconfigファイルから縦書き関係の設定を削除します。

$ sed -i '/vert/d' ~/tess/langdata/jpn/jpn.config

必要なフォントをインストールします。

日本語用標準フォントとIPAフォント
$ apt install -y fonts-noto-cjk fonts-takao fonts-vlgothic fonts-ipafont
インストール済フォントの確認
$ text2image --list_available_fonts --fonts_dir /usr/share/fonts

なお/usr/share/fontsディレクトリーへフォントをコピーすると使えるフォントが増えます。サブフォルダごと入れても大丈夫です。

チューニング方法と評価方法の計画

全角英数字と半角カタカナの認識精度を上げるのが目標です。

チューニング方法

通常は以下の3ステップが必要です。
(1) tesstrain.shで学習データを生成
(2) (1)で作った学習データへlstmtrainingを実行して既存モデルを再学習
(3) 再学習したモデルをlstmtrainingでcheckpoint形式からtraineddata形式に書き換え、環境変数TESSDATA_PREFIXに設定したフォルダへ出力

今回は、OCR後に半角<->全角変換処理を行うのは面倒なので、以下のように半角英数字と全角カタカナを統一します。

画像 OCR後の文字
半角英数字 半角英数字
全角英数字 半角英数字
全角カタカナ 全角カタカナ
半角カタカナ 全角カタカナ

このためにはステップ(1)を細分化して、作業を追加することとします。
(1-A) 追加 tesstrain.shに読み込ませる学習用テキストを半角全角ミックスで作成
(1-B) 学習用テキストとフォントから(TIFF)画像ファイルと(BOX)文字座標ファイルを生成
(1-C) 追加 (BOX)の全角英数字を半角英数字へ、半角カタカナを全角カタカナへ変換
(1-D) (TIFF)と(BOX)から(LSTMF)学習データファイルを生成

学習用テキストの用意 (1-A)

Tesseract 4.0で日本語の認識をチューニングしようを参考にさせていただきました。今回はforbidden_charactersに含まれている▲, △, 半角カタカナ、全角英数字を除外せずに学習用テキストに使う文字コードのリストを作成します。

create_code_list.py
import urllib.request
import re

# add_listの文字と半角カタカナ、全角英数字はlangdata/jpn/forbidden_charactersに含まれていてもchars.txtへ出力する
add_list = [
                0x25b2,  # ▲
                0x25b3,  # △
            ]
chars = {}

with urllib.request.urlopen('http://x0213.org/codetable/sjis-0213-2004-std.txt') as f:
    for line in f.read().decode('ascii').splitlines():
        if line[0] == '#':
            continue
        else:
            m = re.search('U\+([0-9a-f]{4})', line, flags=re.I)
            if m:
                code = int(m.group(1), base=16)
                if code > 0x20:
                    chars[code] = True

del_list = {}
with open('/root/tess/langdata/jpn/forbidden_characters') as f:
    for line in f:
        m = re.search('0x([0-9a-f]{2,4})(-0x([0-9a-f]{2,4}))?\s*$', line, flags=re.I)
        if m:
            if m.group(2):
                range_s = [int(m.group(1), base=16), int(m.group(3), base=16)]
            else:
                range_s = [int(m.group(1), base=16), int(m.group(1), base=16)]
        for c in chars:
            if range_s[0] <= c <= range_s[1]:
                if not (ord('。') <= c <= ord('゚')  # NOT 半角カタカナ
                        or ord('!') <= c <= ord('}')):  # NOT 全角英数字
                    print("%s excluded as %x - %x" % (chr(c), range_s[0], range_s[1]))
                    del_list[c] = True

for c in del_list:
    del chars[c]

for c in add_list:
    chars[c] = True

with open('chars.txt', 'w') as wf:
    for code in sorted(chars):
        print("0x%x,%s" % (code, chr(code)), file=wf)

この文字コード一覧(chars.txt)を基準として、Mecab Neologd の単語辞書を元に学習用テキストを作成します。

#Neologd
$ git clone --depth 1 git://github.com/neologd/mecab-ipadic-neologd.git
#Neologdの辞書CSVを解凍
$ xz -dv ~/mecab-ipadic-neologd/seed/*.xz
create_training_text.py
import glob
import random
import sys
import textwrap
from collections import Counter

def read_chars(filename):
    # 文字種ごとの出現回数
    count = Counter()
    with open(filename) as chars:
        for line in chars:
            count[int(line.split(',')[0],base=16)] = 0
    return count

def read_all_words(dir_s):
    words = {}
    files = glob.glob(dir_s + '/*.csv')
    for filename in files:
        with open(filename, encoding='utf-8') as file:
            for line in file:
                word = line.split(',')[0]
                words[word] = True
    return list(words.keys())

def main():
    # training_bs.txt
    text = ''
    count_required = 20
    chars = read_chars('chars.txt')
    words = read_all_words('mecab-ipadic-neologd/seed')
    print("Total words %d" % len(words))
    training = open('training_bs.txt', 'w', encoding='utf-8')
    random.shuffle(words)
    for word in words:
        min_count = 10000
        skip = False
        # wordに含まれる文字の中で出現回数が最少のもの
        for c in word:
            code = ord(c)
            if code not in chars:
                # 文字種リストに含まれない文字がある場合はスキップ
                skip = True
                # スキップの場合は警告表示
                print("skipped %s by %s" % (word, c), file=sys.stderr)
                break
            count = chars[code] + 1
            if count < min_count:
                min_count = count
        # 最少出現回数が20回以下なら、この単語は「使う」
        if not skip and min_count <= count_required:
            text += word
            # 使ったら出現回数をアップデート
            for c in word:
                code = ord(c)
                chars[code] += 1
    # まとめて出力
    training.write("\n".join(textwrap.wrap(text, width=40)))
    training.close()

    # 1回も使われなかった文字
    with open('unused_chars.txt', 'w', encoding='utf-8') as uc:
        for c in chars:
            if chars[c] == 0:
                print('0x%x,%s' % (c, chr(c)), file=uc)

if __name__ == '__main__':
    main()

このPythonを実行してtraining_bs.txtを生成すれば、ステップ(1-A)は完了です。

英数字を半角、カタカナを全角へ (1-D)

ステップ(1-B)へ進みたいところですが、少しお待ちください。(1-B)~(1-D)は tesstrain.sh がまとめて処理してくれます。

まずはステップ(1-D)の準備をしましょう。tesstrain.sh実行時に、文字座標(BOX)ファイルの全角英数字を半角英数字へ、半角カタカナを全角カタカナへ置換するよう設定します。

処理のコアとなる normalize_text.py を用意します。

normalize_text.py
import sys
import glob

KATAKANA = ('。「」、・ヲァィゥェォャョラッー'
            'アイウエオカキクケコサシスセソタチツテトナニヌネノ'
            'ハヒフヘホマミムメモヤユヨラリルレロワヲン')
args = sys.argv  # text2imageを実行した先の一時フォルダ名を第1変数として渡す

# 半角カナ→全角カナ, 全角英数記号→半角
# 日本語的におかしい濁点・半濁点などが含まれる場合は厳密には考慮してない(前の文字によって変な結果になる)
# new_linesの項目削除で文字単位のbox座標が崩れるが、LSTM学習用に行ごとに座標をまとめるので結果的に影響しない
def normalize_text(lines):
    new_lines = []
    for i in range(len(lines)):
        code = ord(lines[i][0])
        if ord('!') <= code <= ord('}') and code != ord('\'):  # 全角英数
            new_lines.append(chr(code - 0xfee0) + lines[i][1:])
        elif code == ord('゙'):  # 濁点
            if len(new_lines) > 0:
                code_prev = ord(new_lines[-1][0])
                if code_prev == ord('ウ'):
                    del new_lines[-1]
                    new_lines.append('ヴ' + lines[i][1:])
                elif ord('か') <= code_prev <= ord('ホ'):
                    del new_lines[-1]
                    new_lines.append(chr(code_prev + 1) + lines[i][1:])
                else:
                    new_lines.append('゛' + lines[i][1:])
            else:
                new_lines.append('゛' + lines[i][1:])
        elif code == ord('゚'):  # 半濁点
            if len(new_lines) > 0:
                code_prev = ord(new_lines[-1][0])
                if ord('は') <= code_prev <= ord('ホ'):
                    del new_lines[-1]
                    new_lines.append(chr(code_prev + 2) + lines[i][1:])
                else:
                    new_lines.append('゜' + lines[i][1:])
            else:
                new_lines.append('゜' + lines[i][1:])
        elif ord('。') <= code <= ord('ン'):
            new_lines.append(KATAKANA[code - 0xff61] + lines[i][1:])
        elif code == ord('\\') or code == ord('¥'):
            new_lines.append('¥' + lines[i][1:])
        else:
            new_lines.append(lines[i])
    return new_lines

def main():
    files = glob.glob(args[1] + '/*.box')
    for filename in files:
        with open(filename, 'r+', encoding='utf-8') as file:
            lines = file.readlines()
            file.seek(0)  # 先頭に書き込み位置を移動
            file.write(''.join(normalize_text(lines)))
            file.truncate()  # 書き込んだ位置までで切り詰める

if __name__ == '__main__':
    main()

このPythonをどこで実行すればいいでしょうか?
tesstrain.shを読んでみましょう。

~/tess/tesseract/src/training/tesstrain.sh抜粋
phase_I_generate_image 8
phase_UP_generate_unicharset
if $LINEDATA; then
  phase_E_extract_features " --psm 6  lstm.train " 8 "lstmf"
  make__lstmdata

どうもtesstrain.shtesstrain_utils.shphase_I_generate_image関数を呼び出しているようです。

~/tess/tesseract/src/training/tesstrain_utils.sh抜粋
# Helper function for phaseI_generate_image. Generates the image for a single
# language/font combination in a way that can be run in parallel.
generate_font_image() {
    ()
    run_command text2image ${common_args} --font="${font}" \
        --text=${TRAINING_TEXT}  ${TEXT2IMAGE_EXTRA_ARGS:-}
    ()
}

# Phase I : Generate (I)mages from training text for each font.
phase_I_generate_image() {
    ()
    generate_font_image "${font}" &

ここでtesstrain_utils.shを読んでみると、phase_I_generate_image関数は、text2imageで(A)画像(TIFFファイル)と(B)文字座標(BOXファイル)を生成していることが分かりました。

Phase I が(1-B)、Phase UP 以降が(1-D)に相当するので、Phase I の直後に(1-C)を挿入すればよさそうです。
そこで、phase_I_generate_image関数の最後(執筆時点ではtesstrain_utils.sh 329行目の}の前)に以下を挿入します。

run_command python3 /home/ubuntu/normalize_text.py ${TRAINING_DIR}

評価方法

全角英数字と半角カタカナが入ったIPAゴシックの評価用画像を用意します。
ipa_katakana.png

この画像に対し、既存モデルで精度を見てみましょう。

eng_best.traineddata
$ tesseract -l eng_best '画像へのパス' stdout
0123456789
0123456789
abcdefghi jk Lmnoparstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ
abcdefghijklmnopgrstuvwxyz
ABCDEFGHI JKLMNOPQRSTUVWXYZ
TADIAAFITAGS REINS FYTH
FTZXRI/NETIARIILAEYIISY LOTT Y
FAYIAARY YI ALYIFIT MRR NEINFIIAXEYIAFYNLATTY

英語用モデルなのでカタカナがアルファベットになってます。
また、半角アルファベットの lL 、全角アルファベットの qg と認識されています。

jpn.traineddata
$ tesseract -l jpn '画像へのパス' stdout
0①②③④⑤⑥⑦⑧⑨
0 ① ② ③ ④ ⑤ ⑥ ⑦ ⑧ ⑨
abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ
a b c d e f g h i ① k ① m n o p q r S t リ v ツ x ッ z
ABCDEF GH ①J K LMN O P Q R STUVWXY Z
ア イ ウ エ オ カ キ ク ケ コ サ シ ス セ ト タ チ ウ ツ テ ト
ナ ニ ヌ ネ ノ ハ ヒ フ へ ホ マ ミ ム メ モ ヤ ユ ヨ ラ リ ル レ ロ ワ ヲ ン
⑦ イ ⑨ エ ね れ も コ ⑨ ラ ス り ダ チ ヅ テ ト に ② ネ ノ ②A れ ミ ムt ヤ ③ ラ リ ル ル H ヲ ツ

数字が丸文字になっていますし、全角アルファベットと半角カタカナが崩れています。

jpn_best.traineddata
$ tesseract -l jpn_best '画像へのパス' stdout
0123456789
0123456789
abcdefghijkLmnopqrstuvwxyz
ABCDEFGHTJKLMNOPQRSTUVWXYZ
abcdefghijkImnopqrstuvwxy<z
ABCDEFGHIJKLMNOPQRSTUVWXYZ
アイ ウエ オカ キク ケ コ サシ ス セ ト タ チ ツテ ト
ナニ ヌ ネ ノ ハ ヒ フ ヘ へ ホ マ ミ ム メ モヤ ユ ヨ ラリ ルレ ロワ ラ ヲ ン
アイ ウエ カキ カケ コ サッ ス セ リタ ナチ ツテ トナ ニ ヌ ネ ノ ハル レヒ 7 ハ ホ ャ マミ ム メ モヤ ユ ヨ ラリ ルレ ロリ ヲ ツ

このままでもそこそこの精度ですが、半角アルファベットの IT と認識されていたり、半角カタカナの が抜けていたり、 と認識されていたり、改善の余地がありますね。

今回はこのjpn_best.traineddataを元にチューニングを行いましょう。

LSTMによるチューニング

やっと準備が整いました。さぁ始めましょう。

学習データの生成

学習用データを作成します。コマンドは3.X系とほとんど変わりませんが --linedata_only オプションを加えるとLSTM用のデータを吐き出します。

学習用データの作成
#ディレクトリの作成
$ mkdir ~/tess/training_bs/
#tesstrain.shによるLSTM学習データの生成
$ nohup time bash ~/tess/tesseract/src/training/tesstrain.sh --fonts_dir /usr/share/fonts --lang jpn  --fontlist "TakaoGothic" "TakaoPGothic" "VL Gothic" "VL PGothic" "Noto Sans CJK JP Bold" "Noto Sans CJK JP" '必要に応じてフォントを追加' --linedata_only --training_text ~/training_bs.txt  --langdata_dir ~/tess/langdata --noextract_font_properties --output_dir ~/tess/training_bs > ~/tess/training_bs/generate.log 2>&1 &
#経過をモニタ
$ tail -f ~/tess/training_bs/generate.log

--output_dir で指定したディレクトリに 各フォントに対応する lstmfファイル が生成されます。
eng.traineddataosd.traineddata が初期化の段階で必要となりますが、すでにTESSDATA_PREFIX環境変数を設定しているため--tessdata_dirを指定する必要はありません。

イタレーション

#ディレクトリの作成
$ mkdir ~/tess/katakana
#まずは400回のイタレーション
$ nohup time lstmtraining --model_output ~/tess/katakana/400 --continue_from ~/tess/jpn_best.lstm --old_traineddata $TESSDATA_PREFIX/jpn_best.traineddata --traineddata ~/tess/training_bs/jpn/jpn.traineddata --train_listfile ~/tess/training_bs/jpn.training_files.txt --max_iterations 400 >> ~/tess/katakana/train.log 2>&1 &
#経過をモニタ
$ tail -f ~/tess/katakana/train.log
#LSTMモデルをtraineddata形式に書き換え
$ lstmtraining --stop_training --continue_from ~/tess/katakana/400_checkpoint --traineddata ~/tess/training_bs/jpn/jpn.traineddata --model_output $TESSDATA_PREFIX/katakana_400.traineddata

テスト画像で学習結果を評価してみます。

400回
$ tesseract -l katakana_400 '画像へのパス' stdout
0123456789
0O123456789
abcdefghijkLmnopqrstuvwxyz
ABCDEFGHTJKLMNOPQRSTUVWXYZ
abcdefghijkIlmnopaqgrstuvwxy<z
ABCDEFGHIJKLMNOPQRSTUVWXYZ
アイウエオカキクケコサシスセトタチツッテト
ナニヌネノハヒフヘホマミムメモヤユヨラリルレロワラヲン
アイウエオカキクガコサッスセリタチリテトナニメネノハルレ7ヘホマミムメモャユヨラリルレロリラッ

元モデルのjpn_bestから あまり改善してないので追加のイタレーションを行います。

$ nohup time lstmtraining --model_output ~/tess/katakana/600 --continue_from ~/tess/katakana/400_checkpoint --traineddata ~/tess/training_bs/jpn/jpn.traineddata --train_listfile ~/tess/training_bs/jpn.training_files.txt --max_iterations 200 >> ~/tess/katakana/train.log 2>&1 &

評価

こんな感じでイタレーションを増やしていった結果、次のようになりました。

5,000回
0123456789
0O123456789
abcdefghijkLlmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ
abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ
アイウエオカキクケコサシスセトタチツテト
ナニヌネノハヒフへヘホマミムメモヤユヨラリルレロワラヲン
アイウエオカキクケコサッスセソタチツテトナニヌネノハルヒ7ハホマミムメモヤユヨラリルレロリヲッ
10,000回
9123456789
0O123456789
abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ
abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ
アイウエオカキクケコサシスセトタチツテト
ナニヌネノハヒフヘホマミムメモヤユヨラリルレロワラヲン
アイウエオカキクケコサッスセソタチツテトナニヌネノハルヒ7ハホマミムメモヤユヨラリルレロリヲッ

全角 0 O123 の O(オー)は400回からずっと消えず。半角の ハヒフヘホ が ハ7ハホ に。
O123 や ハヒ7ホ ならOCR誤りと分かりやすいですが、
0Oのように同じ文字を2通りに認識したら2文字とも出力する癖があるようです。

元のモデル(jpn_best)と比べてみましょう。

jpn_best
0123456789
0123456789
abcdefghijkLmnopqrstuvwxyz
ABCDEFGHTJKLMNOPQRSTUVWXYZ
abcdefghijkImnopqrstuvwxy<z
ABCDEFGHIJKLMNOPQRSTUVWXYZ
アイ ウエ オカ キク ケ コ サシ ス セ ト タ チ ツテ ト
ナニ ヌ ネ ノ ハ ヒ フ ヘ へ ホ マ ミ ム メ モヤ ユ ヨ ラリ ルレ ロワ ラ ヲ ン
アイ ウエ カキ カケ コ サッ ス セ リタ ナチ ツテ トナ ニ ヌ ネ ノ ハル レヒ 7 ハ ホ ャ マミ ム メ モヤ ユ ヨ ラリ ルレ ロリ ヲ ツ
間違い文字数 jpn_best 400回 5,000回 10,000回
半角英数字 2 2 1 1
全角英数字 2 5 1 1
全角カタカナ 3 2 2 1
半角カタカナ 13 12 6 6
20 21 10 9

徐々に精度が上がっていますが、例えば10,000回に登場する半角 0123 -> 9123 など、元のモデルでは合っていたところを間違えだしました。引っ込んだと思ったら別の場所が飛び出してくる。モグラ叩きみたいですね...

初期値がランダムに設定されるので、最初から10,000回のイタレーションをやり直しても同じ結果にはなりません。もちろん追加するフォントを変えれば違う結果になります。
引き続きいろいろと試してみようと思います。

もし何かいいアイディアがあれば、お気軽にコメントください!

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away