前回、猛暑の中で年代物のGPUを連続運転することを躊躇していましたが、少し涼しい日が続くとの予報が出たので再学習を最後までまわしました。
データセットのスクリーニング
涼しくなるのを待っている間、人力でデータセットのスクリーニングを行いました。
学習に不適な文字の削除
- 書き損じの訂正箇所を削除して行を分割(例1)
- 分割後の行が短い場合(おおむね2文字以下)は単に削除(例2)
- 人が判読可能な程度のなぞり書きはそのまま(例3、4)
| 例1 削除して分割 |
例2 削除 |
例3、4 削除しない |
|---|---|---|
![]() |
![]() |
![]() |
行の出力順不正への対応
NDL古典籍OCRから出力される行の並び順が不正な場合があります。前回の「カタカナ混じりの例」がそれで、6行目と7行目が逆順で出力されます。(下記画像参照。赤字が行番号)
1行データセットの画像と翻刻テキストの対応を入れ替えて対処しました。
データセット全体で並び順の不正はこの1件だけであり、まれな事象のようです。並び順が保証されない仕様なのか、レイアウト認識処理の問題なのかは未確認です。
このほか、未判読文字が含まれてデータセットから除外していた中に読めた(翻刻できた)ものがいくつかあり復活させました。
一連の作業の結果、1行データセットの件数は学習用409、評価用221になりました。
再学習、認識結果の比較
ローカル環境のGTX 1070で実行し4日と11時間かかりました。CERとLossの推移グラフは以下の通りです。
CERが前回より小さくなったのは、主に上記の「行の出力順不正への対応」が効いたのではないかと思います。(当該データは評価用に分配されている)
epochが100を超えたあたりからLossがサチってCERが漸増との推移です。
以下の5セットの文字列認識モデルの認識結果を比較してみました。
- CER最小 → A、B、C
- Lossが収束した中でCERが極小 → D
- (せっかく最後までまわしたので参考用に)CER最大 → E
| epoch | Loss | CER | 文字列認識モデル |
|---|---|---|---|
| 17 | 2.14E-01 | 0.25% | A |
| 22 | 1.49E-01 | 0.25% | B |
| 42 | 9.56E-02 | 0.25% | C |
| 118 | 1.59E-02 | 0.51% | D |
| 261 | 1.13E-02 | 4.80% | E |
カタカナ混じりの例(評価用に分配)
ひらがな混じりの例(評価用に分配)
従来のOCRでは距離値や尤度などの指標が学習の収束判断に用いられていましたが、それらにかわるものとしてLossやCERが利用できることを期待してモデルCやDが最善ではないかと予想しましたがそうはなりませんでした。Lossが最小のCよりもAやBのほうが翻刻文との文字単位の対応が良く、翻刻作業の支援用途に適しています。
従来のOCRでは学習の反復により(過学習が生じない限り)汎化が進んで良質な辞書が生成される(未学習データに対し高い性能が得られる)との経験則がありましたが、どうやらこのような古典的な捉え方はAI OCRには通用しないようです。また、CERは文字切り出しの機能を含まない個別文字認識OCRには有用な性能指標ですが、文字列認識OCRには不向きなのではないかと感じました。
ソースコード等
文字列認識モデルの再学習に用いたプログラム(src/text_kotenseki_recognition/train.py)の、リリース版との差分は以下の通りです。
- 1行データセットは、人手により訓練データとテストデータに分配して別々のフォルダに格納する(train_test_split関数による自動分配はしない)
- GeForce GTX 1070(RAM 8GB)で実行するため、BATCH_SIZEを24から20に変更
52,53c52,54
< ONE_LINE_DATASET_DIRECTORY="/root/honkoku_oneline/"
< PRETRAIN_MODEL=None
---
> ONE_LINE_DATASET_TRAIN="/shared/ndl/ndlkotenocr_cli/oneline_tb_train/"
> ONE_LINE_DATASET_TEST="/shared/ndl/ndlkotenocr_cli/oneline_tb_test/"
> #PRETRAIN_MODEL=None
55,57c56,58
< #PRETRAIN_MODEL="/root/kotenocr_cli/src/text_kotenseki_recognition/model-ver2/kotenseki-trocr-honkoku-ver2/"
< OUTPUT_DIR="/root/kotenocr_cli/src/text_kotenseki_recognition/"
< BATCH_SIZE=24
---
> PRETRAIN_MODEL="/root/kotenocr_cli/src/text_kotenseki_recognition/model-ver2/kotenseki-trocr-honkoku-ver2/"
> OUTPUT_DIR="/shared/ndl/ndlkotenocr_cli/out1/"
> BATCH_SIZE=20
64,66c65,67
< inputfpathlist=[]
< textlist=[]
< for inputimgpath in tqdm(glob.glob(os.path.join(ONE_LINE_DATASET_DIRECTORY,"*"))):
---
> inputfpathlist0=[]
> textlist0=[]
> for inputimgpath in tqdm(glob.glob(os.path.join(ONE_LINE_DATASET_TRAIN,"*.jpg"))):
70,72c71,73
< inputfpathlist.append(inputimgpath)
< textlist.append(text.replace("\n",""))
< print(len(textlist))
---
> inputfpathlist0.append(inputimgpath)
> textlist0.append(text.replace("\n",""))
> train_df=pd.DataFrame({"file_path":inputfpathlist0,"text":textlist0})
74,77c75,86
< df=pd.DataFrame({"file_path":inputfpathlist,"text":textlist})
< train_df, test_df = train_test_split(df, test_size=0.1,random_state=777)
< train_df.reset_index(drop=True, inplace=True)
< test_df.reset_index(drop=True, inplace=True)
---
> inputfpathlist1=[]
> textlist1=[]
> for inputimgpath in tqdm(glob.glob(os.path.join(ONE_LINE_DATASET_TEST,"*.jpg"))):
> inputtxtpath=inputimgpath.replace(".jpg",".txt")
> with open(inputtxtpath,"r") as f:
> text=f.read()
> inputfpathlist1.append(inputimgpath)
> textlist1.append(text.replace("\n",""))
> test_df=pd.DataFrame({"file_path":inputfpathlist1,"text":textlist1})
>
> print(train_df)
> print(test_df)
1行データセットの作成には、OCR学習用データセット(みんなで翻刻) に同梱されているsrc/makeonelinedataset.pyをベースに、プレインテキストの翻刻データとNDL古典籍OCRの出力のjsonを入力出来るようにした以下のプログラムを用いました。
※jsonの行情報はプレインテキスト翻刻データと対応するものとします(しないと実行エラーになったりします...)
import sys
import os
import glob
import cv2
import json
if __name__ == '__main__':
args = sys.argv
outputdir = "oneline"
os.makedirs(outputdir, exist_ok=True)
for imgfilepath in glob.glob(os.path.join("img", "*.png")):
print(imgfilepath)
jsonfilepath = imgfilepath.replace("img", "json")
jsonfilepath = jsonfilepath.replace(".png", ".json")
print(jsonfilepath)
txtfilepath = imgfilepath.replace("img", "txt")
txtfilepath = txtfilepath.replace(".png", ".txt")
print(txtfilepath)
img = cv2.imread(imgfilepath)
with open(jsonfilepath, "r", encoding="utf-8") as jsonfile_open:
jsonfile_load = json.load(jsonfile_open)
with open(txtfilepath, "r", encoding="utf-8") as rf:
intext_lines = rf.readlines()
for lines in jsonfile_load:
for index, line in enumerate(lines):
print(index)
xmin = line[0]
ymin = line[1]
xmax = line[2]
ymax = line[3]
outtext = line[4]
outputimgpath = os.path.join(outputdir, "{}_{}.jpg".format(
os.path.basename(imgfilepath).split(".")[0],index))
outputtxtpath = outputimgpath.replace(".jpg", ".txt")
print(outputimgpath)
print(outputtxtpath)
cv2.imwrite(outputimgpath, img[ymin:ymax, xmin:xmax])
with open(outputtxtpath, "w", encoding="utf-8") as wf:
wf.write(intext_lines[index])































