月別アーカイブ: 2023年1月

Recognize handwritten katakana characters No,2

手書きカタカナ文字をPCに認識させる(その②)

前回の記事では、ETL文字データベースのカタカナ5文字(アイウエオ)+独自に収集した手書きカタカナ文字(アイウエオ各450~650文字)を元に機械学習で作成した学習モデルを用いて、答案の解答欄に書かれた手書きカタカナ1文字(ア~オのいずれか)の識別に挑戦。自分なりに最善を尽くしたと判断した段階での実際の正解率は91%・・・。

今回は、前回とは別の方法で再チャレンジ。前回と同じデータでテストして、正解率95%を達成。自分で言うのもなんだけど、これなら自動採点も可能なんじゃないか・・・と。

【今回の記事の内容】

1.分類器を検索
2.Lobeを使う
3.tflite形式で書きだす
4.書きだしたtfliteファイルをDelphiで・・・(泣)
5.Pythonで再チャレンジ
6.正解率95%!
7.まとめ
8.お願いとお断り

1.分類器を検索

どうしても手書き答案の自動採点をあきらめきれない僕は、昨日も「Delphi 分類器」をキーワードにGoogle先生にお伺いをたてた。実は「分類器」なる言葉を知ったのは最近のことで、これまで機械学習関連の検索キーワードとしてこの言葉を使ったことがなく、これでヒットするページは「ほぼ既読のリンク表示にならない」ので、当分の間、このキーワードで情報を得ようと考えたのだ。

また、Pythonではなく、Delphiとしたのは、2022年の年末からほぼ1か月間、Python関連の機械学習(ライブラリはTensolflowとkerasを使用・言うまでもなくスクリプトはもちろん、ほぼ全部写経!)で手書き文字の自動採点を実現しようと試みたが、どう頑張っても期待したような結果が出せず、とりあえずPythonスクリプト以外の「新しい情報」が「もしあれば」そちらも探してみようと思ったのだ。新しい情報があって、それがDelphi関連なら、すごく・・・ うれしいから。

で、検索すると次のページがトップに表示された。

Tensorflowで数字の分類器を構築する方法

https://blogs.embarcadero.com/ja/how-to-build-a-digit-classifier-in-tensorflow-ja/

「著者: Embarcadero Japan Support  2021年11月09日」ってコトは ・・・

(へぇー! DelphiでもMNISTできるんだ。知らなかったー!!)

(しかも、日付がどちらかと言えば 最近!)

急に興味関心が湧いて、しばらく記事を読んでみる。記事によれば、現在 TensorFlow LiteがDelphiで利用可能とのこと(気分的には TensorFlow Super Heavy の方がマッチするんだけど、残念ながらそれはないようだ)。4年前にPythonでやったのと同じ、マウスで画面に数字を書いて、それが0~9の何なのかを判定するプログラムの画像が掲載され、「プロジェクト全体をダウンロードしてテストすることができます。」とある。

( なつかしいなー あの時はGUI作りにPyQtを使って・・・ 動かすのに苦労したなー )

( 今は数字じゃなくて、オレ、「ア」 って書きたいんだけど・・・ )

( ・・・てか、肝心の学習モデルはどうやって作ってるんだろう? )

なんだかドキドキしてきた!
ページのいちばん下には「下記のリンクにアクセスしてサンプルコードをダウンロードし、実際に試してみてください。」という、うれしい案内が。

思わず、小学生のように、笑顔で、元気よく、「はぁーい」と答えたくなる。

( MNISTは別にして、学習モデルだけでもどうなってるか、確認してみよう・・・ )

結果的には、これが大正解。その存在すら知らなかった「Lobe(ローブ)」に巡り合うきっかけになろうとは・・・。

早速、リンク先からサンプルコードをダウンロード・・・

できませんでした(号泣)

404 This is not the web page you are looking for.

僕の人生は七転八倒。こんなコトには慣れっこさぁ T_T

こんなときのラッキー キーワードはもちろん「 TensorFlow-Lite-Delphi 」
Google先生、たすけてー!

今度は見事にヒット!

Embarcadero / TensorFlow-Lite-Delphi Public archive

https://github.com/Embarcadero/TensorFlow-Lite-Delphi

DLも無事成功! プログラムソースの学習モデルの指定部分を探すと・・・

//DCUnit1.pasより一部を引用

procedure TForm1.Recognize;
var
  i, X, Y: DWORD;
・・・
begin
  try
    var fModelFile := 'mnist3.tflite';
    case rdModel.ItemIndex of
      0: fModelFile := 'mnist.tflite';
      1: fModelFile := 'mnist1.tflite';
      2: fModelFile := 'mnist2.tflite';
      3: fModelFile := 'mnist3.tflite';
    end;

fModelFileとあるから、まずコレが学習モデルの代入先で・・・
んで、入れてるのが『mnistX.tflite』??? あんだ? コレあ?

拡張子 tflite から想像して、学習モデルは TensorFlow Lite で作成したモノのようだ・・・
Pythonの機械学習では見たことない気がするけど。

もし、この tflite 形式で、手書きカタカナ文字の学習モデルが作成できれば・・・
お絵描き部分は、共用可能だから・・・
マウスで「ア」って書いて、PCに「これ、なぁーに?」って!! きけるカモ ↑

コレだ。コレだ。コレだ。コレだ。コレだ。僕は、コレを待っていたんだ!!

即、Google先生にお伺いをたてる。

「Delphi tflite 書き込み」 ポチ!

すると、検索結果の上から3番目くらいに、

[Lobe] Lobeで作成したモデルをTensorflow Lite形式で …

というリンクを発見。

( Lobeってナンだか知らんけど、これで学習モデルが作れる・・・ のかな? )

( で、Tensorflow Lite形式でモデルの保存ができるの・・・ かな? )

とりあえず、クリックだぁ!!

*(^_^)*♪

2.Lobeを使う

リンク先の記事・その他を読んでわかったことは、まず、Lobeは「Microsoftによって公開されている機械学習ツール」であるということ。

Lobe公式サイト

https://www.lobe.ai/

さらにそれは、無料でダウンロードでき、しかも完全にローカルな環境で、コードを1行も書かずに機械学習を実現する、夢のような分類器(ツール)らしい。

(こんなのがあったのか! まるで知らなかった・・・)

Lobeの使い方を紹介したWebサイトの記事を片っ端から読んで、だいたいの作業の流れを理解。次に示すような感じで、自分でも実際にやってみた。

Lobeを起動して、まず、タイトルを設定。

タイトルは、これしかないでしょう!

importするのは、もちろん画像なんだけど・・・

僕の場合は、Datasetがよさげです!

Datasetの説明に Import a structured folder of images. とあるから、これはつまり、「ア」の画像データは、それだけをひとつのフォルダにまとめておけば、フォルダ名でラベル付けして、それをひとつのデータセットとして読み込んでくれるってコト?

「ア」を入れるフォルダ名は、最初「a」にしようかとちょっと思ったけれど、そうすると昇順の並びが「aeiou」になることを思い出し、躊躇。

やっぱり、ここは、後できっとLoopを廻すであろうことを予想して、

「ア」→フォルダ名「0」
「イ」→フォルダ名「1」
「ウ」→フォルダ名「2」
「エ」→フォルダ名「3」
「オ」→フォルダ名「4」

とすることに決定。

モノは試し、最初は様子だけ見てみようということで、データセットはとりあえず自分で集めた手書き文字だけにして実験することに決めたんだけど、ここでデータセットの整理を思い立つ。

・・・というのは、オリジナル手書き文字画像データを作る際に、元の画像から切り抜いたカタカナ文字画像をETL文字データベースのETL1及びETL6の画像サイズに合わせ、幅64・高さ63に成形するPythonのスクリプトを書いたんだけど、その中にはガウシアンフィルタをかけても取り切れないようなシミや黒点がある画像がかなりあること(汚れがすごく目立つ画像は、ひとりで大我慢大会を開催し、「これは修行なんだ」と自分に言い聞かせて、1枚ずつペイントでそれなりにキレイにしたんだけど、それでもまだ汚れの目立つ画像がいくつも残っていた)。

ただ、文字情報とは関係のない黒い点なんかは、多少あった方が過学習を防止するのに役立つカモ(?)という観点から、小さなシミのある画像は敢えてそのままにしたものもそれなりにある。このへんは画像を見た感じでテキトーに判断(理論的なコトは勉強していないので、まったくわかりません)。

それと最初に書いた画像成形のスクリプトが不完全で文字の一部が欠けてしまった画像も若干含まれていることなど、今、ちょっと冷静になって振り返ってみると、自分では慎重に処理を進めてきたつもりでも、やはり無我夢中でやってると、その時々は気づかなかった「さらに良くすることができた・より良くすべきだった」見落としがポロポロあり、ここで、ようやく僕は、見落とし箇所の改善を思い立ったのだ。

例えば・・・シミや汚れ取りは(ある文字の一部を部分的に拡大)

左がシミと汚れがある画像 / 右がクリーニング(手作業)後の画像

※ 実際には、この程度のシミと汚れは過学習防止用に敢えて残した画像も多数あり。

機械学習の事前準備処理の中で、学習用データとして使用する画像に、ガウスぼかしをかけたり、二値化したりして、上の左の画像に見られるようなごく薄い汚れは自動的に消えるから、すべての画像について徹底的にクリーニングする必要はないと思うのだけれど、ただ、明らかに濃度の高いシミ等は、なるべく消しておいた方が真っ白な気持ちで学びを開始するキカイに、少しはやさしいかな・・・みたいな気もするし *^_^*

反面、学習させたいデータ(例えば「ア」)とは直接関係のない黒いシミがある画像が多少は混じっていたほうが、過学習が起こりにくいのかなー? みたいな気もするし・・・

パラメータが利用できる場合は、ドロップアウトを何%にするかで、そういうことも含めて調整できるのかなー? みたいな気もするし・・・

ちゃんと勉強しなさい!という
神さまのドデカイ声が、力いっぱい
聴こえるような気もするケド・・・

『いったい、何が幸いするのか』まったくわからん。機械学習はほんとに難しい・・・などと、イロイロ考え(自分を誤魔化し)ながら、極端に大きなシミがある画像はとりあえずクリーニングし、また、これは不要と思われる文字(例:崩しすぎた文字、極端に小さな文字、ぼやけ・かすみの激しい文字等)を「テキトー」に削除した結果、ア~オ各文字のデータ数にばらつきが生じてしまった・・・。

たしか、MNISTだって各数字の総数はそろってなかったような気がするけど、今、僕が用意できたデータはMNISTの約1/100しかないから、質はともかく量的には明らかに不足しているはず・・・

今、手元にあるデータの数は・・・

ア:641
イ:653
ウ:652
エ:459
オ:575

「エ」がちょっと少ないのが気になると言えば、気になるけど、これしか集められなかったんだから仕方ない。せっかく集めたデータを利用しないのは嫌だし・・・。とりあえず、各文字の個数を水増しスクリプトで700に統一しようか・・・

データの水増しに使用するPythonスクリプトは、次のWebサイト様の情報を参照して作成じゃなくてほぼ写経(Pythonスクリプトの全容は、引用させていただいたWebサイト様の情報をご参照ください)。

ImageDataGenerator

https://keras.io/ja/preprocessing/image/#imagedatagenerator

薬剤師のプログラミング学習日記

https://www.yakupro.info/entry/digit-dataset
# ライブラリのインポートとパラメータの設定部分のみ
# Error
# from keras.preprocessing.image import ImageDataGenerator, load_img, img_to_array
# OK!
from keras_preprocessing.image import ImageDataGenerator, load_img, img_to_array

if __name__ == '__main__':
    generator = ImageDataGenerator(
        rotation_range=3,  # ランダムに回転する回転範囲
        width_shift_range=0.1,  # 水平方向にランダムでシフト(横幅に対する割合)
        height_shift_range=0.1,  # 垂直方向にランダムでシフト(縦幅に対する割合)
        #zoom_range=0.1,  # 文字のハミ出しを防止するため設定せず
        shear_range=0.5,  # 斜め方向に引っ張る
        fill_mode='nearest',  # デフォルト設定(入力画像の境界周りを埋める)
    )

    sample_num = 700   # 各ラベルの画像がこの数になるよう拡張する

これでア~オの各文字約700個ずつのデータセットができた!

Lobeを起動し、ImportからDatasetを選び、0(アが入っている)~4(オが入っている)のフォルダの親フォルダを指定する。

Choose Datasetをクリックするとフォルダ選択ダイアログが表示される

で、画像を Import すれば、あとは何にもしなくても、勝手に学習が始まるようだ。わずか(?)3500個のデータであるが、それなりに処理時間は必要(読ませた画像のサイズは幅64×高さ63で統一)。他のことをしながら処理が終わるのを待ったので、実際に何分かかったのか、定かではない(20~30分くらいか)。気がついたら終わっていた感じ。

表示が Training ⇨ Train になったら、終了 らしい。

学習結果は、次の通り。

自動で分類できなかったデータはわずか1%!

自動で分類できなかった文字は、ラベル付けして再学習も可能なようだが、今回はLobeが自動認識できなかった文字はそのままにして先へ進むことにした。

・・・と言うか、自動認識できなかった文字をクリックしてなんかテキトーにいじったら、その文字だけでなく、他の文字の処理も再び始まり(Train ⇨ Training に変化)、99%だった進行状況を表す数値がいきなりガクンと低下して、84%とかになってしまった。

自動認識できなかった文字は1%と言っても、数にすれば約30個あるから、その全てをこんなふうに再学習させたら、間違いなく日が暮れてしまう・・・。中には「コレが ア なら、7も1」、「雰囲気が『ア』ですー」みたいな文字も混じっているから、先へ急ぎたい僕は(本格的な再学習は次の機会に行うことにして)学習モデルの書き出し処理を優先することにしたのだ。

3.tflite形式で書きだす

Training が完了したら、次のように操作して学習モデルを tflite 形式で書き出し。

Useをクリック!
Export ⇨ TensorFlow Lite をクリック
任意のフォルダを指定して、Exportをクリック
普通は右側を選ぶのかなー?(右を選んだ場合、最適化に結構時間がかかります)
書出し処理中の画面(Just Exportを選択した場合)
書出し終了の画面
指定したフォルダ内に書き出されたファイル
exampleフォルダ内に書き出されたファイル

なんか、ものすごくかんたんに、tflite 形式で学習モデルができちゃったけど。
これでイイのかなー???

それから、ちょっと気になったので、tflite_example.py の内容をエディタでチラ見。

# tflite_example.pyの一部を引用

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Predict a label for an image.")
    parser.add_argument("image", help="Path to your image file.")
    args = parser.parse_args()
    dir_path = os.getcwd()

    if os.path.isfile(args.image):
        image = Image.open(args.image)
        model = TFLiteModel(dir_path=dir_path)
        model.load()
        outputs = model.predict(image)
        print(f"Predicted: {outputs}")
    else:
        print(f"Couldn't find image file {args.image}")

outputs = model.predict(image) ・・・ってコトは、学習モデルにイメージ(=画像)を predict(=予測)させ、outputs に代入(=出力)してるから、

うわー すごいおまけがついてる!

コレもあとから試してみよう!

※ 自分の中では、tflite形式で出力された学習モデルをDelphiから直接呼び出して文字認識を実行する方が、この時はあくまでも優先でした。

4.書き出したtfliteファイルをDelphiで・・・(泣)

早速、出来上がった(書き出された) saved_model.tflite ファイルを、DelphiのDebugフォルダにコピー。

saved_model.tfliteファイルの表示部分までの切り抜き(この他にもファイルあり)

んで、コードを書き替えて・・・

//コードを書き換えた部分
procedure TForm1.Recognize;
var
  ・・・
  //fOutput: array [0 .. 10 - 1] of Float32;
  fOutput: array [0 .. 5 - 1] of Float32;
  ・・・
begin
  ・・・
  try
    {var fModelFile := 'mnist3.tflite';
    case rdModel.ItemIndex of
      0: fModelFile := 'mnist.tflite';
      1: fModelFile := 'mnist1.tflite';
      2: fModelFile := 'mnist2.tflite';
      3: fModelFile := 'mnist3.tflite';
    end;}
    var fModelFile := 'saved_model.tflite';
    case rdModel.ItemIndex of
      0: fModelFile := 'saved_model.tflite';
      1: fModelFile := 'saved_model.tflite';
      2: fModelFile := 'saved_model.tflite';
      3: fModelFile := 'saved_model.tflite';
    end;

この他には、関係ありそうな箇所は見当たらないから、きっとこれで準備OK! *^_^*

実行して、「ア」の上の「つ」部分を描いたところでマウスの左ボタンを離す・・・と、

うわーん!( T_T )

君の見ている風景は・・・
どこまでも すべてが 涙色

君を悲しませるもの
その理由は もう 聞かないよ・・・

それでも僕は・・・
Delphi きみが大好きだ!

※ ファイルどうしの依存関係とか、よくわからんけど、もしかしたらそれがあるカモと考え、saved_model.tflite と同じフォルダに書き出されてた labels.txt や signature.json もDebugフォルダ内へ全部コピーして実行しても同じ結果でした。

イロイロ調べてみると、tflite 形式のファイルにもイロイロあるようで・・・

同じ .tflite のファイルでも違いがいろいろ:メタデータまわりについてTeachable Machine、Lobe、TensorFlow Hub等で出力した画像分類用のものを例に

https://qiita.com/youtoy/items/e58c02c1e32c56358d03

たぶん、saved_model.tflite と同じフォルダに書き出されてた labels.txt や signature.json の内容が tflite ファイル内に必要なんじゃないかなー。よくわかんないけど。

tfliteファイルを編集する知識なんて、僕にあるわけないし・・・
(ちょっと調べてみたら、PythonでTF Lite SupportのAPIを利用して、ラベル等の情報をtfliteファイル内に追加することができるようなんだけど、回り道が長すぎる気が・・・)

いずれにしても、どこかしら邪な、このチャレンジは失敗。

 ちなみに labels.txt の内容は・・・

0
1
2
3
4
ちなみに signature.json の内容は

{
  "doc_id": "9276cb74-46aa-435d-9edd-c0dcfa978a77", 
  "doc_name": "aiueo", 
  "doc_version": "fb48bd091c2950039e3841e1204230f2", 
  "format": "tf_lite", 
  "version": 45, 
  "inputs": {
    "Image": {
      "dtype": "float32", 
      "shape": [null, 224, 224, 3], 
      "name": "Image"
  }
} みたいな感じで、よくわかりません(以下、略)

5.Pythonで再チャレンジ

Delphiで tflite ファイルを直接読み込んでの文字認識に失敗して、すぐに思い出したのは先に見た『 tflite_example.py 』

PythonのスクリプトをDelphiのObject Pascal に埋め込んで実行することなら僕にもできるから、tflite_example.py で学習モデルがまだ見たことのないカタカナ文字画像の認識に成功すれば、最終的な目標の実現は可能だ。

すごいまわり道になりそうだけど、Delphiから直接読み込めるように tflite ファイルを編集する方法だってまだ残されている。ただ、僕はものごとを理解するのが遅く、学習には普通のヒトの何倍もの時間が必要だから、これはいよいよとなった時の最終手段だ。

SDカードに入れたWinPythonとAtomエディタで、僕は持ち運べるPython実行環境を作っている。Atomが開発中止になってしまったのはちょっとイタいけど、取り敢えずPythonスクリプトを書いて、実行するのに今のところ何ひとつ不自由はない。そのSDカードへLobeが書き出したファイルをフォルダごとコピーする。

Atomを起動。今、コピーしたexampleフォルダを開く。オリジナルの tflite_example.py をコピーして tflite_example2.py を作成。Atomに入れたパッケージ「script」から実行できるようにスクリプトを少し変更。exampleフォルダ内に次の画像を用意して・・・

用意した手書きの「ア」画像(Wordで作成)

スクリプトをクリックしてアクティブにしておいて、Shift + Ctrl + B で実行・・・

# 実行結果

Predicted: {'predictions': [
{'label': '0', 'confidence': 0.9855427742004395}, 
{'label': '3', 'confidence': 0.008924075402319431}, 
{'label': '1', 'confidence': 0.005291116423904896}, 
{'label': '4', 'confidence': 0.00012611295096576214}, 
{'label': '2', 'confidence': 0.0001159566527348943}]}
[Finished in 14.759s]

学習モデルが予測したラベルは「0」つまり「ア」、信頼性は99%!
やった。成功だ。待ちに待った瞬間が、ついに訪れた・・・ ありがとう Lobe!

次々に画像を変えて実験。

# 実行結果

Predicted: {'predictions': [
{'label': '1', 'confidence': 0.8973742723464966}, 
{'label': '4', 'confidence': 0.10129619389772415}, 
{'label': '2', 'confidence': 0.0012468534987419844}, 
{'label': '3', 'confidence': 4.6186032705008984e-05}, 
{'label': '0', 'confidence': 3.642921365099028e-05}]}
[Finished in 3.313s]
# 実行結果

Predicted: {'predictions': [
{'label': '2', 'confidence': 0.9924760460853577}, 
{'label': '1', 'confidence': 0.0038044601678848267}, 
{'label': '0', 'confidence': 0.0017367065884172916}, 
{'label': '3', 'confidence': 0.0010746866464614868}, 
{'label': '4', 'confidence': 0.0009080663439817727}]}
[Finished in 13.591s]
# 実行結果

Predicted: {'predictions': [
{'label': '3', 'confidence': 0.9999231100082397}, 
{'label': '1', 'confidence': 7.657476089661941e-05}, 
{'label': '4', 'confidence': 2.250336166298439e-07}, 
{'label': '0', 'confidence': 7.755971154210783e-08}, 
{'label': '2', 'confidence': 6.385280215681632e-08}]}
[Finished in 15.323s]
# 実行結果

Predicted: {'predictions': [
{'label': '4', 'confidence': 1.0}, 
{'label': '3', 'confidence': 1.7214372288743007e-11}, 
{'label': '1', 'confidence': 4.185582436200264e-12}, 
{'label': '0', 'confidence': 8.478809288784556e-14}, 
{'label': '2', 'confidence': 4.801435060631208e-14}]}
[Finished in 13.506s]

すべて、正解 ・・・

なんだか、こころがカラッポになった。
そう、夢が叶う瞬間は、いつも・・・

6.正解率95%!

チャレンジの総仕上げとして、前回の実験では正解率91%だった手書きカタカナ「アイウエオ」画像37セットをLoopで判定し、認識結果を「アイウエオ」で出力できるよう、スクリプトを準備。

結果を信じて、実行。

# 実行結果

ア:〇
イ:〇
ウ:〇
エ:〇
オ:〇
ア:〇
イ:〇
ウ:〇
エ:〇
オ:〇
ア:〇
イ:〇
ウ:〇
エ:〇
オ:〇
ア:〇
イ:〇
ウ:〇
エ:〇
オ:〇
ア:〇
イ:〇
ウ:〇
エ:〇
オ:〇
ア:〇
イ:〇
ウ:〇
エ:〇
オ:〇
ア:〇
イ:〇
ウ:〇
エ:〇
オ:〇
イ:×
イ:〇
エ:×
エ:〇
オ:〇
ア:〇
イ:〇
ウ:〇
エ:〇
オ:〇
ア:〇
イ:〇
ウ:〇
エ:〇
オ:〇
ア:〇
イ:〇
ウ:〇
エ:〇
オ:〇
ア:〇
イ:〇
ウ:〇
エ:〇
オ:〇
ア:〇
イ:〇
ウ:〇
エ:〇
オ:〇
ア:〇
イ:〇
ウ:〇
エ:〇
オ:〇
イ:×
イ:〇
ウ:〇
エ:〇
オ:〇
ア:〇
イ:〇
ウ:〇
エ:〇
オ:〇
ア:〇
イ:〇
ウ:〇
エ:〇
オ:〇
ア:〇
イ:〇
ウ:〇
エ:〇
オ:〇
ア:〇
イ:〇
ウ:〇
エ:〇
オ:〇
ア:〇
イ:〇
ウ:〇
エ:〇
オ:〇
ア:〇
イ:〇
ウ:〇
エ:〇
オ:〇
ア:〇
イ:〇
ウ:〇
エ:〇
オ:〇
ア:〇
イ:〇
ウ:〇
エ:〇
オ:〇
ア:〇
イ:〇
ウ:〇
エ:〇
オ:〇
ア:〇
イ:〇
ウ:〇
エ:〇
オ:〇
ア:〇
イ:〇
イ:×
エ:〇
オ:〇
ア:〇
イ:〇
ウ:〇
エ:〇
オ:〇
ア:〇
イ:〇
ウ:〇
エ:〇
オ:〇
ア:〇
イ:〇
ウ:〇
エ:〇
オ:〇
ア:〇
イ:〇
ウ:〇
エ:〇
オ:〇
ア:〇
イ:〇
ウ:〇
エ:〇
オ:〇
ア:〇
イ:〇
イ:×
エ:〇
オ:〇
ア:〇
イ:〇
エ:×
エ:〇
オ:〇
エ:×
イ:〇
ア:×
エ:〇
オ:〇
ア:〇
イ:〇
ウ:〇
エ:〇
オ:〇
ア:〇
イ:〇
エ:×
エ:〇
オ:〇
ア:〇
イ:〇
ウ:〇
エ:〇
オ:〇
[Finished in 18.27s]

正解率をExcelで計算。

全体の95%を正しく認識できた!

手書きアイウエオ画像185枚のうち、176枚を正しく認識できた。
これなら、自動採点に使える。
とうとう、やった。

夢の実現へ、大きく一歩を踏み出せた!

7.まとめ

無料で利用できるOCR技術を利用した手書き文字認識は、自分が試した範囲では、現状まだ実用には程遠い感触であった。

そこで、次に、手書き文字の座標を輪郭検出で取得し、文字を矩形選択して、解答欄のスキャン画像中から切り抜いて画像データ化、Web上に大量に情報が溢れているPythonライブラリ(TensorFlow+keras)を使用した機械学習で処理して学習モデルを作成(読み取り対象文字はアイウエオの5文字に限定)、この学習モデルを使っての文字認識にチャレンジしたが、学習データ数及びパラメータ設定を様々に工夫しても実際の検証データに対する正解率は91%より上昇することはなく、最終的な目標としていた自動採点に繋げることはできなかった。

そこで、今回は情報の収集範囲を広げて再チャレンジ。Lobeという分類器の存在を知る。このLobeに約3500枚(総文字数)の手書きカタカナ画像を読ませて tflite 形式の学習モデルを作成。これに前回の実験で使用したのと同じ手書きカタカナ文字(アイウエオの5文字 × 37セット=185枚)を見せたところ、95%の文字を正しく認識することができた。

この結果より、今後、より多くの良質な機械学習用手書き文字画像を集め、Lobeを利用して全自動で学習モデルを生成、さらにLobeが自動分類できなかった画像の質をチェックし、もし必要と判断される場合はラベル付けして追加学習を行い、より良くトレーニングされた学習モデルを準備できれば、最終的に人が必ずチェックするという条件の元で、機械と協働しての答案の自動採点は十分可能であると、僕は感じた。

なぜ、それを実現したいのか?
僕の中で、その理由はひとつしか、ない。
地位も、名誉も、富も、その前で、輝きを失う言葉に、僕は巡り合えたからだ。

The purpose of life is to contribute in some way to making things better.
人生の目的は、ものごとを良くすることに対して何らかの貢献をすることだ。

Robert F. Kennedy

DelphiやPythonと力を合わせれば、夢見たことを実現できる。そして、僕がひとりで夢見たことが、本当に、本当になったとして、それが僕自身だけでなく、偶然でもかまわないから・・・、知らない人でもいい、僕でない、他の誰かのために、もし、役立ったなら・・・、その時、こんな僕の拙く幼い学びにも、そこに、初めて「意味」や「価値」が生まれるんだ、と・・・。僕は本気で、そう信じている。

採点プログラムへのチャレンジは、再生紙に印刷したマークシートを、複合機のスキャナーでスキャンして電子データ化、このスキャン画像から、ほぼ100%正しくマークを読み取れるマークシートリーダーを作ることから始まった(完成したプログラムは任意の選択肢数を設定可能な一般用の他、選択肢の最大数を記号-、±、数字0~9、文字A~Dの計16 として「読み取り結果を抱き合わせての採点も可能」な数学採点用途にも対応)。開発当初は読み取りパラメータの最適な設定がわからず、読み取り解像度も高くする必要があったが、最終的にはパラメータ設定を工夫し、職場内の複数個所に設置されている複合機のスキャナーのデフォルト設定である200dpiの解像度で読み取ったJpeg画像でエラーなく稼働するものを実用化できた。これを職場のみんなに提供できた時、僕は本当に、心からうれしい気持ちになれた。人生の師と仰ぐ、ロバート・フランシス・ケネディの言葉をほんの少しだけ、僕にも実践できたかもしれない・・・と、そう本気で思えたからだ。

次に、マークシートとも併用可能な、手書き答案の採点ソフト作りにチャレンジした。最初は横書きの答案から始め、最終的には国語の縦書き答案も採点できるものに仕上げた。採点記号も 〇 や × だけでなく、負の数をフラグに使うことで、部分点ありの△も利用可能とし、コメント挿入機能や、現在採点している解答を書いた児童生徒の氏名も解答欄画像の左(or 右)に表示できるように工夫した(横書き答案のみ)。もちろん、合計点は自動計算。返却用の答案画像の印刷機能も必要十分なものを実装できた。採点作業が最も大変な、国語科7クラス分の答案を、午後の勤務時間内で全部採点出来たと聞いた時は、胸がたまらなく熱くなった・・・。

その次のチャレンジは、解答用紙の解答欄の自動認識機能の搭載だった。手書き答案採点プログラムを使うユーザーを見ていて、いちばん強く感じたことは、PCに解答欄の位置座標を教えるため、解答欄の数だけ矩形選択を繰り返さなくてはならない採点準備作業を何とかして低減、せめて半自動化できないか・・・ということだった。ここではOpenCVの優秀な輪郭検出器に巡り合い、点線を活用するなど解答欄の作成方法を工夫することで、全自動とまではいかないが、取り敢えず解答用紙中の全矩形の位置座標を自動取得し、必要な解答欄矩形の座標のみ、ユーザーが取捨選択できるプログラムを作成・提供できた。

そして、今、僕は、手書き・カタカナ1文字(アイウエオ限定)の自動採点にチャレンジしている・・・。

Ask and it will be given to you.

この言葉を信じ、失敗の山を築きながら、

次はきっと・・・

誓って自分に言い続けて。

生きるちからを失くしたときが、このチャレンジの終わり。
でも、僕は、僕がこの世から消えたあとも、
動くプログラムを作るんだ・・・

正直、今回、最終的に自分でやったことはアイウエオの画像データの準備だけ・・・みたいな感じになっちゃったけど、結局、Lobeとの出会いがすべてだったけれど、もし、途中で夢をあきらめていたら、絶対にLobeには出会えなかった・・・。

さぁ 次はアイウエオ限定の自動採点機能の実装だ。
Delphiが笑顔で、僕を待ってる・・・

8.お願いとお断り

このサイトの内容を利用される場合は、自己責任でお願いします。記載した内容を利用した結果、利用者および第三者に損害が発生したとしても、このサイトの管理者は一切責任を負えません。予め、ご了承ください。また、本記事内で紹介させていただいた実験結果は、あくまでも私自身が用意した文字データに対してのものであり、別データで実験した場合、同様の結果が得られることを保証するものではありません。

Recognize handwritten katakana characters

手書きカタカナ文字をPCに認識させる(その①)

【お断りと注意】

これは「失敗の記録」です。最後までお読みいただいても、ここに述べた方法では手書きカタカナ文字を100%正確にPCで識別することはできません。できないのですが・・・「この方法ではダメなんだ」という、失敗の例として公開します。僕自身は、今より良くなりたいので、継続して手書きカタカナ文字の認識プログラム作りに挑戦します。その結果は、まとまり次第、手書きカタカナ文字をPCに認識させる(その②)として報告します。

【研究と構想の概要】

カタカナ一文字の自動採点を最終的な目標に、まず、解答欄に書かれた手書きのカタカナ文字の位置座標を取得し、これをもとにカタカナ文字を矩形選択・切り取って、画像ファイルとして保存する(前回の記事)。

次に、手書きカタカナ5文字(ア・イ・ウ・エ・オに限定)を学習済みの文字認識プログラムで、この画像ファイル中の文字が何という文字であるのかを判別(今回の記事)し、採点。

カタカナ文字の矩形選択と画像ファイル化、判別まではObject PascalにPythonスクリプトを埋め込んでPython側で処理し、ユーザーから見える部分はDelphiでGUI等を作成する。もし、夢見たような記号部分の自動採点プログラムが作れたら、先に作成済みのマークシートリーダー&手書き答案採点ソフトの「おまけ」としてユーザーにプレゼント・・・できたらいいなぁ!

このような手順を考えた理由は、前回の記事に書いたように、無料で利用可能な、AIを利用しないOCRでは、手書き文字の認識がまだ不安定で、現時点では採点用途には利用できないと実験の結果から判断したため(2022年末現在)。

【今回の記事の内容】

1.手書きのカタカナ文字データベースを探す
2.カタカナ文字の機械学習
  【学習モデル作成上の工夫 その1
  【学習モデル作成上の工夫 その2
  【学習モデル作成上の工夫 その3
3.手書きのカタカナ文字を判別
4.まとめ
5.お願いとお断り

1.手書きのカタカナ文字データベースを探す

4年前、いろいろあって機械学習を勉強する必要が生じ、興味半分・仕事半分で、あの超有名なMNISTデータベースを使って数字を機械学習、ユーザーがマウスで画面に描いた数字が0~9の何なのかをボタンクリックで判断、アニメ音声で出力するプログラムを書いた。

今年は同様の内容で、PCに話しかけるとその言葉(日本語音声)に応じて、それを英語に翻訳して英語音声で出力したり、動画を再生したり、Web検索した結果を表示(例:天気予報など)して日本語で音声出力したりする・・・みたいなプログラムをまた書いた(?)んだ・・・けど。

ほぼ全部、写経の組合せ。
情報は溢れているし、やりたいこと、すぐできるし、表向きPython最高! なんだけど。
他言語でやったら、間違いなく、すごい! こと・・・やってるのに(なぜか、自分的には、全然、面白くなかった・・・)。

そういう意味では、OCRを使わない日本語の「文字」認識プログラム作りは難しい。
MNISTデータベース関連の情報に比べたら、検索結果は極端に少なくなる。

検索結果が少ないと、(困ったー!)と思う反面、(・・・開拓されてない・・・)そう感じて、未知の世界の扉の前に立っているような気もする・・・。

情報そのものが少ない中で、今回利用させていただいた日本語文字情報DBは、産業技術総合研究所が公開している「ETL文字データベース(このDBは、手書きまたは印刷の英数字、記号、ひらがな、カタカナ、教育漢字、JIS 第1水準漢字など、約 120万の文字画像データを収集しているとのこと)」。

データベースを利用したい場合は、次のリンク先の画面上部にある「DOWNLOAD」をクリックすると表示される「Download Request」画面に必要事項を入力して、送信ボタンをクリックすると、連絡先として入力したメールアドレスに電子メールが送付されるので、以後の手続きはそのメールを参照して行う。

ETL文字データベース

http://etlcdb.db.aist.go.jp/?lang-ja

【重要】
このETL文字データベースは、研究目的に限り無料で使用可なので、商用利用が必要な場合は別途問い合わせが必要とのこと。ですので、ダウンロード&ご使用にあたっては、くれぐれも、使用規約をご確認いただき、無償での利用の可否について、ご判断ください。

ETL文字データベースのダウンロードと、その利用方法については、次のWebサイト様で紹介されていた情報を参考にさせていただきました。

実践型AIプログラミング特講#27

https://financewalker.jp/programming20431/

2.カタカナ文字の機械学習

学習済みのモデル作成までの作業の流れは、次の通り。

(1)ETL文字データベース内のファイルからpng形式の画像を抽出。
(2)画像をリサイズしてkatakana.pickleファイルに保存。
(3)このpickleファイルからカタカナ文字の学習を実行して、学習済みモデル(モデル構造と学習済みの重み)の保存。

この一連の作業は、次のWebサイト様に掲載のあったPythonスクリプトを利用させていただきました。

畳み込みニューラルネットワークを利用したカタカナのOCR

https://github.com/devinoue/Katakana_OCR

上記リンク先にあるPythonスクリプトを次の順番で実行(スクリプト内のファイル等へのPathは、実行環境に合わせて適宜変更)。

(1)preprocessing_image_enhance.pyを実行
(2)preprocessing_image_resize.pyを実行
(3)katakana_cnn.pyを実行

なお、(3)については、モデルの評価のスクリプトの後に、次の一行を加え、学習済みのモデルが保存されるようにしてから実行した方がイイかも。

# 学習済みモデルの保存
model.save('任意の名称_model.h5')

今回、実験に使用した学習済みモデルは、独自に手書き文字の追加学習用データを作成、このデータに対して『水増し処理』(=Data Augmentation)を実行したオリジナルh5ファイルを使用。

batch_size=128、epochs=12とした場合の学習結果は、次の通り。

参考:学習は、データセットを幾つかのサブセットに分けて行われる(学習データとテストデータに分けるのとは別)。このサブセットに含まれるデータの数がバッチサイズ。(数千件程度のデータに対しては128とか、256あたりがよく使われる値?)

参考:データを何グループに分けたかが「イテレーション」。これは上のバッチサイズが決まれば自動的に決まる値。

参考:モデルが何回全データを見たか(=何回、学習したか)がエポック数。

まずはAccuracy(正解率)について、

Dropout(0.30)とした場合

最後の部分で過学習が発生しているようだ。なので、Dropoutを0.25として再実行。

こんな感じで良いのではなかろうか?

続いて、Loss(損失関数の出力する値)は、次の通り。

Dropout(0.30)とした場合

やはり、学習の最終段階で過学習が発生している。で、Dropoutを0.25にすると・・・

まぁ良いのではなかろうか?

Loss(損失関数の出力する値)は、『「正解値」と、学習モデルが出力した「予測値」とのズレの大きさ(=Loss:損失)』を意味するとのこと。そこから考えると、イイ感じに学習できているのではないだろうか?

参考:Dropoutは、「ある特定のレイヤーの出力を学習時にランダムにゼロにしてしまう」ことで、これを行うことで「学習モデルがデータの欠損に対して強く」なり、「学習データの局所特徴の過剰評価が抑止」され、「学習モデルのロバスト性が向上」する。

【学習モデル作成上の工夫 その1】

実験の当初は、ETL文字データベースのカタカナ文字すべてについて機械学習を行ったが、これにより得られた学習モデルで、手書きカタカナ文字の読み取り実験を行うと、実際のテストでは解答用記号にまず使用されない「例:ワ・ヲ・ン」などで(誤)認識結果が出力されることに気付いた。そこで思い当たったのが「選択肢1/50音(学習は46文字)とするより、1/5で判定した方が正解率が高まるのでは?」ということ。

選択肢ア・イ・ウ・エ・オの5つで実験して上手く行ったら、選択肢カ・キ・ク・ケ・コや、選択肢サ・シ・ス・セ・ソに特化した学習モデルも作成。後からDelphiで作るGUIで設問の解答を設定する際に、正解の入力欄の下にComboBoxを利用して、例えば「正解:ア~オ」の設問については、判定に「ア行の学習モデル」を使用できるようにすればいいのではないか?

設定がひと手間増えるが、ここは採点の正確さを高める方が優先。選択肢数が5を超えるような設問は自動採点の対象外とするか、問題そのものを分割する等、自動採点用の問題となるよう、ユーザーに工夫してもらえばいい。

早速、「ア・イ・ウ・エ・オ」の5文字のみ、学習したモデルとなるよう、次のようにそれぞれのスクリプトを変更して、学習モデル作りを再実行。※ 上に掲載した正解率と損失関数の出力値のグラフは、この形式で作成した学習モデルで得られたもの。正解率が0.99以上になっているのは、正解の選択肢を1/46から1/5に変更したためと解釈(当初の選択肢1/46のモデルでの正解率は、パラメータの設定で変動するが最高で0.96程度)。

preprocessing_image_resize.py

# カタカナの画像が入っているディレクトリから画像を取得(オリジナル)
# kanadir = list(range177,220+1)
# kanadir.append(166)#ヲ
# kanadir.append(221)#ン

# カタカナの画像が入っているディレクトリから画像を取得(変更)
kanadir = list(range(177, 180+1))  # 177フォルダに「ア」が入っている
kanadir.append(181)	# オ
katakana_cnn.py

# out_size=46 #ア~ンまでの文字数(オリジナル)
out_size=5 #ア~オまでの文字数(変更)

【学習モデル作成上の工夫 その2】

ETL文字データベースのカタカナ文字を眺めていて気がついたことは、確かに手書き文字なんだけれど、非常に丁寧に書かれた感のある手書き文字で、MNISTの数字のような、実際のテストの際に書かれる、時間に追われてます感満載の、あの書きなぐったような「部分部分がつながってない文字」が案外少ないということだ(著作権その他の問題を考慮して、例に挙げた汚いカタカナ文字はWordのペン機能で自作)。

例えば、「ア」は「ノ」の部分が大きく離れているような「ア」が多い。

「イ」も同じ。

「ウ」はむしろ、上の「|」が下の「ワ」に付いていない方が圧倒的多数。

「エ」は上の「ー」に「|」が付かない場合が多い。

さらに、急いで書くためか、右肩上がりの斜めになった「エ」も多い。

「オ」も「ノ」が切れたり、

出るべき場所が、ほとんど出ていなかったり、

それは「オ」じゃなくて「才」だろ・・・みたいな

これらの実際の手書き文字に見られる傾向を追加学習できるよう、ア・イ・ウ・エ・オの各文字について104文字ずつ、追加学習用の手書きカタカナ文字データを作成。

なぜ各104文字なのかというと、もっと多くの追加学習用データを集めたいのはヤマヤマだったのですが、時間的な制約と、104文字ずつ集めたところで本人が「力尽き」ました。(この実験が上手く行ったら、あとからたっぷり時間をかけて、さらに大量のデータを集めて再度、学習モデルを作成する予定。この後、最終的には少ない文字で450~多い文字で650程度まで増加させた。)

上記の処理は、機械学習について学んだ範囲で、何とか学習データを効率よく増やせないかと考えて工夫・実験的に行ったもので、手法的に正しいのかという点も含め、その効果はまったく保証できないものであることにご注意ください。左右90°回転とか、左右・上下反転というような、現実に存在しない「水増し」データではないから、多少は意味や効果があるかなー? と考えた次第。

【学習モデル作成上の工夫 その3】

文字画像データを作成する際、いちばん困ったのが、例えば「ア」の「ノ」部分が離れている場合、文字を矩形選択して切り抜くプログラムが(機能的には正しく動作しているのだが)こちらの期待に反する挙動を示すこと。つまり、次の例のように

「ノ」部分が完全に離れている「ア」を矩形選択しようとすると、結果は・・・
「ノ」の部分だけが選択されてしまう・・・

この問題に「どう対応するか?」
世の中の誰一人悩んでないだろう、こんな問題で悩めるなんてある意味とても幸せな気もしますが、解答は自分で探すしかありません。

探すと言うか、自分で何とかするしかないのですが、その方法がわかりません。

しかし、解答は思わぬところに落ちてました。

例えば、次の構成部品の全てが離れた「ウ」を切り抜く(=画像化)するとき、

全ての部品がつながってない「ウ」

マイ切り抜きスクリプトで矩形選択すると、こうなって・・・

赤枠が輪郭検出された範囲で、緑枠が切り抜きマージン

上は、余白(=マージン)設定「5」で実行。まだ「ウ」全体が入らない・・・。

そこで最適なマージンを設定するために、Loop用変数の値がそのままマージンになるようなスクリプトを準備し、かつ、その一つ一つの切り抜き画像について、学習済みモデルがどう判定するかを実験してみようと思い立ち、早速、書いたのが次のスクリプト。

# ある文字について、その最適なマージンを調べる & 文字を判定
import sys
import os
os.environ['TF_CPP_MIN_LOG_LEVEL']='2'
import cv2
import numpy as np
from PIL import Image
from keras.models import load_model
from tensorflow.keras.preprocessing.image import load_img, img_to_array

# 余白を追加する関数
def add_margin(pil_img, top, right, bottom, left, color):
    width, height = pil_img.size
    new_width = width + right + left
    new_height = height + top + bottom
    result = Image.new(pil_img.mode, (new_width, new_height), color)
    result.paste(pil_img, (left, top))
    return result

for j in range(50):
    file_path = r".\img\u01.jpg"
    org_img = Image.open(file_path)
    # 十分な余白を上下左右に追加しておく
    pil_img = add_margin(org_img, 50, 50, 50, 50, (255, 255, 255))
    img = np.array(pil_img)
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    blur = cv2.GaussianBlur(gray, (5, 5), 0)
    thresh = cv2.adaptiveThreshold(blur, 255, 1, 1, 11, 2)
    #contours = cv2.findContours(thresh, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)[0]
    contours = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[0]
    num = len(contours)
    mylist = np.zeros((num, 4))
    i = 0
    #red = (0, 0, 255)
    for cnt in contours:
        x, y, w, h = cv2.boundingRect(cnt)
        if h < 20:  # 高さが小さすぎる輪郭は見えなかったコトに
            mylist[i][0] = 0
            mylist[i][1] = 0
            mylist[i][2] = 0
            mylist[i][3] = 0
        else:
            mylist[i][0] = x
            mylist[i][1] = y
            mylist[i][2] = x + w
            mylist[i][3] = y + h
            #cv2.rectangle(img, (x, y), (x+w, y+h), red, 2)
        i += 1

    pw, ph = pil_img.size
    if pw > ph:
        mylist_sort = mylist[np.argsort(mylist[:, 0])]
    else:
        mylist_sort = mylist[np.argsort(mylist[:, 1])]

    # マージン指定用に準備した変数には(とりあえず消さずに)0を代入
    intTweak = 0

    # 切り抜く範囲の座標を取得
    for i in range(num):
        if mylist_sort[i][0] != 0:
            x1=int(mylist_sort[i][0]-intTweak-j)
            y1=int(mylist_sort[i][1]-intTweak-j)
            x2=int(mylist_sort[i][2]+intTweak+j)
            y2=int(mylist_sort[i][3]+intTweak+j)
            break

    img_crop = pil_img.crop((x1,y1,x2,y2))
    # 読み込んだ画像の幅、高さを変更(幅64, 高さ63)
    (width, height) = (64, 63)
    # 画像をリサイズする
    img_resized = img_crop.resize((width, height))
    img_resized.save(r".\img2\crop"+str(j)+".jpg")

model_file_name="katakana_model.h5"
model=load_model(model_file_name)

for j in range(50):
    # ここに書いてLoopを回すと警告される
    # model_file_name="katakana_model.h5"
    # model=load_model(model_file_name)
    img_path = (r".\img2\crop"+str(j)+".jpg")
    img = img_to_array(load_img(img_path, color_mode = "grayscale", target_size=(25,25)))
    img_nad = img_to_array(img)/255
    img_nad = img_nad[None, ...]
    label=["ア","イ","ウ","エ","オ","カ","キ","ク","ケ","コ","サ","シ","ス","セ","ソ","タ","チ","ツ","テ","ト","ナ","ニ","ヌ","ネ","ノ","ハ","ヒ","フ","ヘ","ホ","マ","ミ","ム","メ","モ","ヤ","ユ","ヨ","ラ","リ","ル","レ","ロ","ワ","ヲ","ン"]
    pred = model.predict(img_nad, batch_size=1, verbose=0)
    pred_label = label[np.argmax(pred[0])]
    print(str(j)+": "+"name:",pred_label)

※ 画像をリサイズしていいのか? という疑問を持たれる方もいらっしゃるかもしれませんが、PCに見せる画像(学習用・判定用ともに)は、例外なくすべてこのスクリプトで処理するので問題ないんじゃないかと・・・。

で、このスクリプトを実行すると・・・

Loopが10回程度まわったところで「ウ」全体が見えてくる

Loopがまわる度にマージンが大きくなり、ある程度Loopを廻せば文字全体をなんとか切り抜けることを発見。やったー!  これでほぼ問題をクリア!

さらに、判定結果は・・・驚くべきことに、

0: name: ウ
1: name: ウ
2: name: ウ
3: name: ウ
4: name: ウ
5: name: ウ
6: name: ウ
7: name: ウ
8: name: ウ
9: name: 
10: name: ウ
11: name: ウ
12: name: ウ
13: name: ウ
14: name: ウ
15: name: ウ
16: name: ウ
17: name: ウ
18: name: ウ
19: name: ウ
20: name: ウ
21: name: ウ
22: name: ウ
23: name: ウ
24: name: ウ
25: name: ウ
26: name: ウ
27: name: ウ
28: name: ウ
29: name: ウ
30: name: ウ
31: name: ウ
32: name: ウ
33: name: ウ
34: name: ウ
35: name: ウ
36: name: ウ
37: name: ウ
38: name: ウ
39: name: ウ
40: name: ウ
41: name: ウ
42: name: ウ
43: name: ウ
44: name: ウ
45: name: ウ
46: name: ウ
47: name: ウ
48: name: ウ
49: name: ウ
[Finished in 33.656s]

10回廻ったところで、なぜか「ア」と判断しているが、それ以外は全部「ウ」と判定。

(最初見たときは『なんて優秀なんだ!』と激しく感動したが、あまりにも優秀すぎる結果に対し、実験を重ねるごとにナニかが違うなんかオカシイと思い始め、最終的に気がついたことは、文字が小さくなると、この学習モデルは「エ」以外は何でも「ウ」にしたがるということ。わかんなかったら、とりあえず「ウ」だ!と、機械が思ってるわけではないんでしょうが・・・とにかく これより、ある程度Loopが廻り、画像中の文字が小さくなると、判定結果の信頼性も著しく低下すると考えていいことがわかりました)

この他にも状態が良さげ(=上手に見える)手書き文字について、上の切り抜き実験を繰り返し行い、結果を表にして最適な余白設定を考える。とほーもない量の実験データがあれば、統計的に処理して最適な余白設定が行えるのだろうけど、計算に時間もかかるし、ア~オの各文字について各10文字ずつ、実験するのが自分的な限界。これだけでほぼ1日費やした。がんばったなー

次の表は、「オ」に関する実験結果。

○印が判定「オ」で、正しく答えられたことを示す

たった10個でこんなこと言っちゃいけない(=判定しちゃいけない)と自分でも間違いなく思うけど、だけど、オレにはこのデータしかないから、これを信頼するしか「ない」。

ぱっと見、25回以上Loopが廻ったところで、「ウ」が大好きなマイ学習モデルはちからいっぱい「ウ」に見えますとPRしてくる・・・から、26回目以降のデータは「全て無視」することにして、文字全体がだいたい見えてくる13回目あたりが余白設定に適切な値なのではないかと・・・。

この「オ」の例では5・6・7Loop目の成績が良いが、この程度のLoopだと、まだ文字全体が見えてない場合があることも考慮して、共通して設定する余白は「13」に決定。誤判定が多くなり始める「26」の半分の値にしておけば(いちばん、安心かな?)みたいな。

ちなみに「ア」は・・・

文字が小さくなると、なんで「ウ」と判定するのだろう?

Loop回数が少ないのに読み取れないのは・・・

こうなっている場合が多い

ちなみに「イ」は・・・

いちばんカンタンな「イ」だが、小さくなると「ウ」になってしまう・・・

ちなみに「ウ」は・・・(ペール・オレンジ部分は文字全体が見えないので削除)

すごすぎる正解率が、信頼性を疑わせてしまうことに、機械は「なぜ気がつかない」のか?
ペール・オレンジ部分の「ウ」はこうなっている・・・。機械には、これが「エ」に見えるらしい。

ちなみに「エ」は・・・

「エ」は小さくても「ウ」にならない? 読み取れているということなのか?

Loop回数が少ないのに読み取れない「エ」は・・・

こうなっている場合が多い

そんなこんなで、極めていい加減ではありますが、追加学習データ用の切り抜き画像に共通して適用する余白設定は「13」に決定。

実は、最初は余白を適当に設定しておりまして、何となく「13」あたりに設定した時の判定結果が良いことに気付いていたのですが、きちんと視覚化して見て、あらためて納得。

3.手書きのカタカナ文字を判別

上で、もうすでにやってますが、最終的に完成した手書きカタカナ文字画像を認識&判別するスクリプトは次の通り。ここでは連続して5枚の画像を判定できるようLoop処理しているが、Loopを外せば1枚の画像処理用に使える。

いま、ふと思ったんだけど、PCの処理性能が高く、時間的にも問題がなければ、同じ画像に対して余白設定を少しずつ変更してLoopを廻し、結果の多数決で文字を判別するような用途にも使えるかもしれない(実験はしていません)。

いずれにしても、「正解でない手書き文字(=解答)」を「正解である」と誤判定して、つまり、正答が「ア」である設問で、解答欄に実際に書いてある文字は「ウ」であるのに、これを「ア」であると認識し、誤った採点を行う可能性が多分にあるので、最終的にはヒトの目視によるチェックが必須であることは、言うまでもありません(当然、この逆、つまり誤った解答を正答であると誤判定する場合もあり得ます)。

だったら・・・自動採点じゃない かな?
でも、ヒトだってかなり間違えて採点するから、プログラムの信頼性を十分実用になるレベルまで高めて、その上でPCと協働採点すれば、答案を二重にチェックしたことになるし、ここで挫けたら、この段階の次(複数文字=文字列=文章の自動採点)に進めない・・・。だから、プログラムが完成したら、ユーザーにはチェックの必要性について、ここで述べたように説明して理解してもらおう。

また、実際の答案画像の処理を行う場合には、「解答が書かれていない」=「空欄」であった場合への対応が必須です。検出された輪郭数が0(ゼロ)であったら処理しないというような条件分岐を追加してください。

# 画像の中の文字を検出して、最初の一文字の画像を保存し、その文字がカタカナの何であるかを判定するScript
# アイウエオの5文字セット用 -> Loop処理を外せば一文字を判定可能
# 判定用の手書きカタカナ文字画像は答案画像から切り出して用意しておく
# 画像はJpeg形式で用意(png形式なら拡張子の書き換えが必要)
# 文字のない(空白の)画像には未対応であることに注意
# 実行には予めア~オの手書き文字を機械学習した「学習済みモデル」が必要
# 判定結果は用意した学習済みモデルの性能次第
# (学習済みモデル名は "katakana_model.h5 "とした)

import sys
import os
os.environ['TF_CPP_MIN_LOG_LEVEL']='2'  # 警告を表示しない
import cv2
import numpy as np
from PIL import Image
from keras.models import load_model
from tensorflow.keras.preprocessing.image import load_img, img_to_array

# 余白を追加する関数
def add_margin(pil_img, top, right, bottom, left, color):
    width, height = pil_img.size
    new_width = width + right + left
    new_height = height + top + bottom
    result = Image.new(pil_img.mode, (new_width, new_height), color)
    result.paste(pil_img, (left, top))
    return result

for j in range(5):
    if j == 0:
        file_path = r".\img\a.jpg"
    elif j == 1:
        file_path = r".\img\i.jpg"
    elif j == 2:
        file_path = r".\img\u.jpg"
    elif j == 3:
        file_path = r".\img\e.jpg"
    elif j == 4:
        file_path = r".\img\o.jpg"

    org_img = Image.open(file_path)
    # 十分な余白を上下左右に追加しておく
    pil_img = add_margin(org_img, 50, 50, 50, 50, (255, 255, 255))
    img = np.array(pil_img)
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    blur = cv2.GaussianBlur(gray, (5, 5), 0)
    thresh = cv2.adaptiveThreshold(blur, 255, 1, 1, 11, 2)
    # # 白の輪郭、黒の輪郭、内側、外側関係なく、同じ階層で輪郭を抽出する
    #contours = cv2.findContours(thresh, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)[0]
    # 最も外側の輪郭のみを抽出する
    contours = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[0]
    num = len(contours)
    mylist = np.zeros((num, 4))
    i = 0
    #red = (0, 0, 255)
    for cnt in contours:
        x, y, w, h = cv2.boundingRect(cnt)
        if h < 20:  # 高さが小さすぎる輪郭は見えなかったコトに
            mylist[i][0] = 0
            mylist[i][1] = 0
            mylist[i][2] = 0
            mylist[i][3] = 0
        else:
            mylist[i][0] = x
            mylist[i][1] = y
            mylist[i][2] = x + w
            mylist[i][3] = y + h
            #cv2.rectangle(img, (x, y), (x+w, y+h), red, 2)
        i += 1

    pw, ph = pil_img.size
    if pw > ph:
        mylist_sort = mylist[np.argsort(mylist[:, 0])]
    else:
        mylist_sort = mylist[np.argsort(mylist[:, 1])]

    # マージン調整
    intTweak = 13

    # 最初に見つかった文字が最も左にある文字
    for i in range(num):
        if mylist_sort[i][0] != 0:
            x1=int(mylist_sort[i][0]-intTweak)
            y1=int(mylist_sort[i][1]-intTweak)
            x2=int(mylist_sort[i][2]+intTweak)
            y2=int(mylist_sort[i][3]+intTweak)
            #cv2.rectangle(img, (x1,y1), (x2, y2), green, 2)
            break

    img_crop = pil_img.crop((x1,y1,x2,y2))
    # 読み込んだ画像の幅、高さを変更(幅64, 高さ63)
    (width, height) = (64, 63)
    # 画像をリサイズする
    img_resized = img_crop.resize((width, height))
    img_resized.save(r".\img\crop"+str(j)+".jpg")

    # オブジェクトの明示的な解放
    #del mylist

model_file_name="katakana_model.h5"
model=load_model(model_file_name)

for j in range(5):
    # ここに書いてLoopを回すと警告される
    # model_file_name="katakana_model.h5"
    # model=load_model(model_file_name)
    img_path = (r".\img\crop"+str(j)+".jpg")
    img = img_to_array(load_img(img_path, color_mode = "grayscale", target_size=(25,25)))
    img_nad = img_to_array(img)/255
    img_nad = img_nad[None, ...]
    # 発展的な未来を信じて、五十音を全部用意してあります・・・
    label=["ア","イ","ウ","エ","オ","カ","キ","ク","ケ","コ","サ","シ","ス","セ","ソ","タ","チ","ツ","テ","ト","ナ","ニ","ヌ","ネ","ノ","ハ","ヒ","フ","ヘ","ホ","マ","ミ","ム","メ","モ","ヤ","ユ","ヨ","ラ","リ","ル","レ","ロ","ワ","ヲ","ン"]
    pred = model.predict(img_nad, batch_size=1, verbose=0)
    pred_label = label[np.argmax(pred[0])]
    print('name:',pred_label)

こうしてimgフォルダ内に用意した「a,i,u,e,o」の各Jpeg画像の手書き文字を正しく認識できるか・どうか、繰り返し実験したが、正解率は90%くらいの感じで、学習モデル作成の際にAccuracyとして表示された99%には程遠い。

そのうち、imgフォルダ内の手書き文字画像をいちいち入れ替えて実験するのが面倒になってきたので、01~37という名称のフォルダを作成し、この中にアイウエオの手書き文字画像を入れ、37回連続でさまざまな手書きアイウエオ画像を認識・判定するスクリプトを書いて、実際的な正解率を出してみた。次がその結果。

正解率は、何度実験しても最高で91%・・・

ヒトの場合、テストで91点採れれば、ほめられることはあっても、叱られることはまずないと思うが・・・。それは平均点がだいたい6割程度に設定されている前提があるからで、これが機械の場合、平均点の前提条件は言うまでもなく100点。なので、1割以上確実に間違える自動採点プログラムは・・・

誰も誉めてくれませんし、使いたくもありません!

これが小学校の夏休みの自由研究なら、校内発表で誇らしげに発表し、感動の伴わない拍手を(3秒くらい)してもらえたカモ・・・しれませんが。

ここまでに要した時間は、ほぼ1か月。実に様々なことを新しく学び、発見し、悩み、時に喜びもありましたが、最終的に、自分自身の感触として「この方法ではダメなんだ」とはっきり悟りました。手書き文字の自動採点という目標を実現できる、別の方法を探すことにします。

あきらめない限り、もしかしたら夢は・・・ 実現できるかもしれませんから。

4.まとめ

残念ながら、ここに記載した方法では、手書きカタカナ文字画像を100%正しく読み取ることはできなかった。学習モデル作成時にトレーニング用データとして使用する手書きカタカナ画像の量を変化(ETL文字データベースに収められた画像+自分で集めた文字画像ア~オ各文字650個をベースに、これを様々に水増しして使用)させたり、学習モデルを作成するスクリプトの各種パラメータ設定を様々に変更して実験したが、実際の手書きカタカナ文字に対するMy学習モデルの正解率は最高で91%であった。

※ 実験で使用した手書きカタカナ文字は全て幅64ドット、高さ63ドットの矩形内にほぼ収まるように、元画像から切り抜いてデータ化する際に拡大・縮小処理を行っている。

5.お願いとお断り

この記事で使用した手書き文字は、使用の承諾を得た家族及び自分自身で書いたものです。使用に許諾が必要と思われる第三者の書いた手書き文字は一切使用しておりません。また、本記事内で紹介させていただいた実験結果は、あくまでも私自身が用意した文字データに対してのものであり、別データで実験した場合、同様の結果が得られることを保証するものではありません。

このサイトの内容を利用される場合は、自己責任でお願いします。記載した内容を利用した結果、利用者および第三者に損害が発生したとしても、このサイトの管理者は一切責任を負えません。予め、ご了承ください。