投稿者「サイト管理者」のアーカイブ

Supports Zero-Starting!

ゼロ始まりに対応!

どんな理由があって、そうなったのか知りませんが、マークシート方式で行われる予定の大学入学共通テストサンプル問題、教科「情報Ⅰ」の解答の選択肢は、始まりが「1」ではなく、「0」からになってます・・・。

ディジタルのイメージから、「0」・「1」とつながる0始まりにしたのか?
それとも他に何か0から始めなければならない必然的な理由があるのか?
サンプル問題を作成した方に、その理由をぜひ伺ってみたい気もしますが・・・

今回は、My MarkSheet Reader を、解答の選択肢「0」始まりに対応させたというお話です。

【今回の記事】

1.なんで0始まりなの?
2.教科「情報」用マークシートを作成
3.ゼロ始まりに対応
4.まとめ
5.お願いとお断り

1.なんで0始まりなの?

大学入学共通テストの理科基礎科目は例外なく、解答の選択肢の始まりは「1」から。
物理も、化学も、生物も、地学も、全部、それが「1」になってるってのは、「選択肢の始まりは1から」って、誰もがそう感じるから・・・だと思うのですが。

教科「情報」では、なぜかそれが「0」からになってる・・・。

検索してみると、そのことに注意を促すWebサイトが複数見つかります。
例えば、選択肢の番号が「0」始まりであることに気付かず、「121」とマークしたつもりが実際には「010」だった。思い込みにはくれぐれも注意しましょう!・・・みたいな。

僕は、僕のマークシートリーダーをよくする云々ではなく、今回だけは、この無視できない現実にとにかく「対応する」ことにしました!

2.教科「情報」用マークシートを作成

まずはマークシートそのものを準備しなければいけません。これが理科の場合なら、選択肢の数は8個もあれば十分です。しかし、教科「情報」では、例えば著作権関係の内容を見てみると、著作者の権利として、氏名表示権、同一性保持権、複製権、上演権、演奏権、公衆送信権、口述権、展示権、頒布権、貸与権、翻訳権、翻案権の12の権利があることがわかります。これを解答の選択肢として準備する場合があり得ますから、選択肢の数は少なくても15程度、十分な余裕を持って設定した方がよさそうです。

幸いなことに僕のマークシートリーダーは、数学での利用を想定して選択肢数は最大16個まで対応できるように作成してあります。そこで、数学用のマークシートを改造して教科「情報」用のマークシートを作成することにしました。

Wordで作成した数学用マークシート

まず、行番号の「アイウエオ・・・」を「12345・・・」に書き換えます。

次に、マークを0始まりで15まで、16個用意します。
1行分作成したら、あとはひたすらコピペします。
これを3列分繰り返して、1列25問×3で75問に対応できるマークシートとしました。

列の行番号が半角のカタカナから、半角の数字2桁になったため、解答欄座標を取得するマーカー■■■(トリプルドット)の位置に関して若干の修正が必要でしたが、何とか思った通りの形に仕上げることができました。

完成した教科「情報」用マークシート

出来れば、問題数は100問まで対応可能としたいところですが、用紙サイズの関係もあり、75問でよしとすることにしました。どうしても75問以上の設問が必要な場合は、数学用途に2枚1セットで採点できるようにプログラムを組んであるので、それを活用すればなんとかなります。まぁ・・・試験時間60分なら、最大75問に対応していればOKでしょう。

完成した教科「情報」用マークシート

とりあえず、マークシートは完成です。ここまでは極めて順調に推移しました。次は、いよいよプログラムの改良です。

3.ゼロ始まりに対応

どのようにプログラムを改良しようかと考えた時、一瞬、教科「情報」専用のマークシートリーダーにしようか・・・と思ったのですが、やはり、そうではなく、1つのプログラムで様々な教科・科目に対応できる方が理想だと思い直し、選択肢の始まりは「0」とするか、「1」とするか、ユーザーが選べるようにプログラムを改良することにしました。

ユーザーが選択肢の始まりを「0」「1」のどちらかに設定できるよう改良

設定欄に、最初の選択肢の番号を指定するVCLコントロールを設置するスペースを何とか作成し、そこにComboBox1個とLabelを2つ貼って、上の図のように選択肢の始まりの番号を指定できるようにしました。もちろん、ここで指定した番号は必要であればイニシャライズファイルに保存して、次回起動時も有効化されるように設定。このComboBoxの名前は思いついたまま適当に「cmbOneZeroSelect」として、読み取りコードを次のように改良します。

  begin
    //StringGridに読み取り結果を表示
    //オリジナルのプログラムは1行で終わってた
    //StringGrid1.Cells[intSG_Col,intSG_Row]:=strAnsList[intSG_k];
    //選択肢の0始まりに対応できるようコードを改良
    if cmbOneZeroSelect.Text='1' then
    begin
      StringGrid1.Cells[intSG_Col,intSG_Row]:=strAnsList[intSG_k];
    end else begin
      if (strAnsList[intSG_k]='99') or (strAnsList[intSG_k]='999') then
      begin
        StringGrid1.Cells[intSG_Col,intSG_Row]:=strAnsList[intSG_k];
      end else begin
        strAnsList[intSG_k]:=IntToStr(StrToInt(strAnsList[intSG_k])-1);
        StringGrid1.Cells[intSG_Col,intSG_Row]:=strAnsList[intSG_k];
      end;
    end;
    ・・・
  end;

早速、実行してみました!

数学用のマークシートを読み込ませて動作確認

よかった☆ 期待通りに動作してくれました!

ちなみに「999」は「空欄」、「99」は「複数マークあり」を意味します。
Gridコントロールをクリックすれば、当該箇所のマーク状態をチェックできます。
赤い色の矩形は、プログラムを実行した際の採点結果チェック実行時に、画面上に実際に表示される矩形です。
サンプル画像を3枚読むのに要した時間は772ミリ秒

1行16選択肢で、1列に25行、これが1枚に3列あるから、マークの数は合計1200個/枚あります。MyPC環境では、マークシート1枚について1200あるマークを約250ミリ秒で読み取ってますから・・・。1秒でほぼ4枚、1クラス40名分なら約10秒で読み取り完了です。

自分で言うのもなんですが、結構、高速に動作しているんじゃないか・・・と。

ただ、10万枚くらいは手作業で採点できそうな時間を開発に費やしてますが・・・。

4.まとめ

ようやく、大学入学共通テストの教科「情報」に対応したマークシートリーダーが出来ました。解答欄の選択肢の始まりが「0」か、「1」か、ただそれだけのことなのですが、両方にきちんと対応するのは、やっぱりそれなりに大変でした。

5.お願いとお断り

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

This updated is support for use with high resolution devices

高DPIに対応しました!

これまでずっとPC画面の解像度は1366×768に固定して、この解像度での使用のみを前提に、僕はプログラムを書いてきた・・・。僕のプログラムが走るマシンは全部、この解像度だったから、それで何も問題は起きなかったのだけれど。

【今回の記事】

1.2880×1920の世界を知る
2.Formの表示がたいへんなコトに・・・
3.問題点を続々と発見!
4.exeを高DPI対応に設定
5.VCLの幅や高さを自動調整
6.まとめ
7.お願いとお断り

1.2880×1920の世界を知る

新しく支給されたPCの画面解像度は2880×1920で、拡大縮小率は150%に設定されてた。持ち運ぶことを考えると、ノートPCの画面サイズはそうそう大きく出来ないから、画面サイズが変わらないまま、解像度だけ上がってしまうと、相対的にアプリや文字の見た目はどんどん小さくなって、目にとてもやさしくない画面になる。だから拡大150%や拡大200%って設定が必要なんだと思うけど・・・。

取り敢えず、この環境で僕のプログラムを動かすとどうなるか、実験してみた。

1366×768、拡大率100%で表示した場合(開発時の設定)
2880×1920、拡大率100%で表示した場合

高解像度画面では、ボタンのCaptionが読めない・・・。

2.Formの表示がたいへんなコトに・・・

しかも、このプログラムから別のFormを呼び出すと・・・

たいへんなコトに・・・

自分的には、こう表示されてほしいのですが・・・
(今までは何の問題もなく、こう表示されていた)

この(今までの)ように表示するには、どうしたらイイ?

やばい。何としても高DPIに対応させないと、職場のみんなにプログラムを使ってもらうどころか、自分ひとりですら使えない。画面が高解像度になっただけで、こんな問題が生まれるなんて・・・。これまで考えたこともなかった。

けっこうショックが大きくて、心がまた折れかけたけど、この問題をクリアすれば、プログラムも、僕も、もっとよくなれるんだって、必死で自分に言い聞かせる。

3.問題点を続々と発見!

高解像度画面で一通り、プログラムの動作検証を行ってみると、見つけられただけで次のような問題が発生することがわかった。

① Formが設計時とは異なる大きさで表示される。
② 画面表示の拡大設定を行わないと、字が読めないくらい小さくなる。
③ 拡大設定時には、VCLコントロール(Toolbar)の幅や高さが意図しないものになる。

まず、①の問題の解決にチャレンジ。

FormCreate手続きでFormの幅を指定しても無駄。
まるで言うことを聞いてくれない。
いったいナニがどうなると、この問題が発生するのか?
これまで、こんな問題に出会ったこと、ないぞ・・・。

そう思いつつ、いろいろ調べてみると、次の情報を発見。

フォームを新規作成したらまずやる事 (Delphi)

https://ht-deko.com/ft1004.html#100408_02

明らかな既視感があったので、以前、どこかで見た情報に間違いないと思うのだけれど、知識として使ったことがなかったので、情報の有用性に気づいてなかった・・・。

この中に、Scaledプロパティに関して、次の記述が・・・

Scaled
常に False。True にすると OS の DPI (ユーザが指定した DPI) によってフォームサイズやコントロールサイズが勝手に変更されてしまいます。

(たぶん、コレだ・・・)

早速、すべてのFormのScaledプロパティをFalseに変更。なんでこんな問題を起こすような設定がデフォルトでTrueなんだ?・・・何か、大切な理由でもあるんだろうか?

動かして確認。

直った!(・・・というか、壊れなくなった)

これで①の問題は解決。思ったより簡単に解決できて、よかった!

4.exeを高DPI対応に設定

②の文字の大きさについて、Google先生にいろいろきいた結果、こちらもベストと思われる対応方法を発見。

Windows11でアプリやメニューが小さい時に行う高DPI設定

https://win11lab.info/win11-high-dpi/

設定方法は、次の通り。

exeを右クリックして表示されるサブメニューの「プロパティ」をクリック
「高DPI設定の変更」をクリック
「高い DPI スケールの動作を上書きします」のチェックボックスをチェックして、
拡大縮小の実行元は「システム」を選択。

で、OK → 適用 → OK と順にボタンを押して画面を閉じ、アプリを再起動すると、Formが適正な倍率で表示されてアプリやメニューが見やすくなった。ちなみに「システム」ではなく、「アプリケーション」では表示に変化がなく、「システム(拡張)」ではFontが高解像度化された感じに。

いちどexeにこの設定を実行しておけば、画面の解像度をいろいろ変更しても常にFormは適正な大きさで表示されるようになり、たいへん便利!

Windowsには、ほんとうにいろんな画面解像度の設定があるから、exeに対するこのおまじないは必須なのかもしれない・・・。これで②の問題も無事解決。

結局、②の問題は、プログラムではなく、OS側の設定の問題だった。

5.VCLの幅や高さを自動調整

最後に残った③の問題に取り組む。まず、これがどういう現象かと言うと・・・

手書き答案採点プログラムで、画面を横にスクロールさせるために作ったToolbarコントロールが、本来なら次のように表示されるはずなのに・・・

ToolButton1,2,3とBevel1の4つのコントロールの幅の合計値がToolbar1の表示サイズの幅となるはず

上の4の設定を行わず、かつ、画面の拡大縮小が100%でない場合には・・・

表示そのものが崩れてしまう・・・

職場のマシンたちは全部!デフォルト設定が「高解像度」で、画面の拡大率150%だから、何にもしないで僕のプログラムを配布されたままの状態で動かしたら、間違いなく、この問題が発生してしまう・・・。

マジ、困った・・・。

すがるような思いでGoogle先生に援けを乞う。すると・・・

03_高 DPI における画像の描画サイズ調整

http://mrxray.on.coocan.jp/Delphi/Others/DisplayDPI_Image.htm#03

またしても、Mr.XRAYさんのサイトに救いとなる情報を発見!

職場では、僕のことを「困った時の〇〇さん・・・」と呼ぶ人がいるけど、
僕にとってMr.XRAYさんは、「本当に困った時のMr.XRAYさん」です。

これまでにいったい何度、僕の窮地を救ってくださったことか・・・。
あらためてMr.XRAYさんに、心から感謝のありがとうです。

Mr.XRAYさんのホームページにあった情報をもとにプログラムを次のように修正。

procedure TFormCollaboration.btnSelectClick(Sender: TObject);

  //--------------------------------------------------------------------------
  //  ディスプレイの拡大縮小の比率を取得
  //  100% の時は 1.0.150% の時は 1.5 を返す
  //--------------------------------------------------------------------------
  function GetDpiRatio: Extended;
  var
    LXDpi : Integer;
  begin
    LXDpi := GetDeviceCaps(GetDC(0), LOGPIXELSX);
    Result := LXDpi / USER_DEFAULT_SCREEN_DPI;
  end;

var
  ・・・
  //高DPIに対応する
  VCL_Width:Extended;
  VCL_Height:Extended;

begin

  ・・・

  //解像度が変わると不具合がでる
  //r.Right := r.Left+ToolBar1.Width;
  //r.Bottom := r.Top+ToolBar1.Height;

  //解像度の変更に対応
  //幅
  VCL_Width := (ToolButton1.Width + 
    ToolButton2.Width + ToolButton3.Width + Bevel1.Width) * GetDpiRatio;
  r.Right := r.Left + Trunc(VCL_Width);
  //高さ
  VCL_Height := ToolBar1.Height * GetDpiRatio;
  r.Bottom := r.Top + Trunc(VCL_Height);

  ・・・

end;

GetDpiRatio関数を使ってディスプレイの拡大・縮小の比率を計算し、これをVCLコントロールの幅と高さに掛けて、コントロールが適切に描画されるように設定。

上記設定を行った後、実行中のToolbar
(フローティング状態で画面の任意の位置に埋め込む)

こうしてプログラム側でも、VCLコントロールの幅や高さを画面の拡大縮小に合わせるように設定しておけば、exeそのものに「高 DPI 設定の変更」を設定しなくても・・・

ちょっとカタチは崩れるけど、使えないレベルではない。
exeに「高 DPI 設定の変更」を設定せず、
拡大150%で実行してみた場合

これで③の問題も無事解決できた!

プログラムも、僕も、よくなれた☆

それは間違いない・・・から、いいんだけれど。
ひとりでも、戦えるかな・・・

Let me see you through.

空を見上げて・・・

I’m missing you.

そう思えてならない時が、あるんだ。

6.まとめ

(1)様々な画面解像度に対応するには、FormのScaledプロパティをFalseに設定。
(2)画面の拡大縮小に対応するにはプロパティの「高 DPI 設定の変更」を利用。
(3)画面の拡大縮小にプログラムコードでも対応可能。

7.お願いとお断り

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

Let’s mount a counterattack from now on!

反撃を開始するぞ!

【今回の記事】

1.色が消えた!
2.BYODがあった
3.反撃開始!
4.お願いとお断り

1.色が消えた!

そのマシンを僕たちはCIJP3号機と呼んでいた・・・。
いちおう、解説すると、3機めのColor Inkjet PrinterだからCIJP3号機。規定の印刷契約枚数まではカラー・モノクロ問わず、同一料金で印刷可能な、愛すべきマシンだったが・・・。

今、僕の隣に CIJP3号機はいない。

あのマシンさえ、あれば・・・。この2週間、何度、その思いを繰り返したか、わからない。
気がつけば空を見上げて。なつかしい人たちの面影を思い浮かべて、涙し、大量印刷可能なマシンは、モノクロの輪転機しかない環境に涙し、毎日、心が折れに、折れまくった。

大量カラー印刷。それが「当たり前」に出来た世界から、「まったく」出来ない世界へ来たのだから、そう思うのは当然だけど。

正直、なんとかなるんじゃないか・・・と、あまく考えていた部分もあって・・・。
とにかく、このままでは命懸けで作った My Secret Weapon※1 が使えない。
マークシートリーダーは、カラー印刷ができなくてもまだなんとかなりそうだけれど・・・。手書き答案採点プログラムの返却用答案画像だけは・・・「赤」が使えないと、どうにもならない。

※1「赤」で採点記号を答案画像に印字する、手書き答案の採点プログラムのこと。

例えがよくないかもしれないが、心境的には、高性能(?)ミサイルはあるんだけれど、その発射装置が破壊されたってところだ。

せめて2色印刷※2ができないか・・・?

※22色印刷は、普通「黒」+カラー1色(例:赤)で行う印刷のこと。フルカラー印刷に比較すれば、ローコストでインパクトのある資料が作成できる。

そう考え、取り敢えず事務方の責任者にそれとなく伺ってみたが、現状、難しいではなく、「無理」である とのこと。しゅんとして孤独な作業部屋に戻り、また、空を見上げて、心に涙の雨を降らす。空は・・・あんなに青く、美しく、晴れて、きれいなのに。

なぜ・・・。

僕は聞きたいよ。これまでのすべてが、嘘だったって、思えばいいの?

やりたいことは、〇や × 、合計点を赤で印刷したいだけなんだけど・・・。

きっと、月面に一人取り残されて、太陽光の散乱のない、真っ暗な空に浮かぶ青い地球をみたら、こんな気持ちになるんじゃないか・・・。

モノクロームの世界から眺める、フルカラーの地球。
あぁ、僕はあそこに住んでいたんだって・・・。

たのしかったなー。(T_T)

こうなったら、いっそのこと・・・

たかが250万だろ?
個人的に購入する分には何の問題もない はずだ。
思い切って、マシンを買うか?

でも、よく考えろ・・・
Delphi の使用許諾ライセンスが10個分買えるぞ。

バカ!
そんなにDelphi買って、どぉすんだ?

Object Pascalの信者は、絶滅危惧種の生存個体数グラフのように減少の一途をたどっているはずだ・・・

(それは〇県の〇科(科目名:〇)の教員数も同じだと思うが・・・)

だから Embarcadero Technologies, Inc. さんの営業収益増に貢献することはきみの悲願だろ? Delphiの未来がかかってるんだ・・・

あぁでも、今は・・・カラー印刷がどぉしてもしたいんだ。
A社の営業担当の方の名刺は、大切に保存してある。
電話して『ボク、1台買います!』って言えば、
それで済む話じゃないか。

おまえには、そのガッツと勇気がないのか・・・

(それは「勇気」ではなく、「無茶・無謀」だよ:亡くなった祖母の言葉)

ばぁちゃん、ごめんよ。
オレ、やっぱり大人になんか、なれないよ・・・

モノクロームの世界では、誰も気にしないことで、また、オレはこんなに悩んでる。なぜ、毎回毎回、オレばかり、誰も悩まないことで悩むんだ。

ふと THE STREET SLIDERS の、あの名曲が心に浮かぶ・・・

この曲、リアルタイムで聴いてたんだ・・・。
確か・・・大学2、3年の頃だから、もう30年も前のことだ・・・。

Please get out of my mind.
Please get out of my mind.
抜け殻になっちまうからさ。

Please get out of my mind.
Please get out of my mind.
何処かへ、消え失せてくれ。

THE STREET SLIDERS 「GET OUT OF MY MIND」より引用

あぁ・・・どうせ折れた心だ。
抜け殻のような気持ちを抱えて、
ひとりで苦しむのは、もうたくさんだ・・・

こんな消え失せて欲しい現実なんかに負けてたまるか・・・
七転八倒の人生でいいじゃないか・・・
何回倒れたって、オレは起き上がって見せる・・・

ここから抜け出すんだ。
今までだって何とかしてきたじゃないか・・・
そうだ・・・ ここに色がないのなら、
オレが色をつくってやる。

この色の消えた世界に・・・

2.BYODがあった

挫けそうな気持ちを抱えたまま、起案文書を書く。どうしても・・・、どうしても、このままでは終われない。My Secret Weapon が「もし使える状態」になれば、働き方改革にも大いに貢献できるはずだ。僕にできる唯一の社会貢献じゃないか。それに・・・

『僕がこの世から消えた後でも、動くプログラムを創る』

この夢を叶えないうちは死ねない・・・。なにか・・・、なにか、いい方法はないか・・・。
また、くすんだ窓ガラスの向こう側の空を見上げる、気がつけば、この2週間ですっかりそれがクセになってしまった・・・。

ふと、空よりずっと近いところ、天井に無線LANのアンテナが見えるのに気づく・・・

あの部屋の前の廊下にも・・・
その隣の部屋の前の廊下にも・・・
その隣の隣の部屋の前の廊下にも・・・それが・・・ある

そうだ。BYOD(Bring Your Own Device)環境があった。
カラー印刷の「紙」じゃなくて、「画像データ」、または「PDF文書」を、個人所有のタブレットに送信するんだ・・・。せっかく整備したBYOD環境の活用にもつながるし・・・。なにより「紙」を大量に消費するという、My Secret Weapon の最大の弱点も解消できる!

最高の解決方法だ!
なんとかなりそうだ!!

3.反撃開始!

スキャナーは幸い、使い慣れたものと同機種の複合機が、これまた幸い、僕の自由になる環境下に1台あった。ただ、スキャンした画像の転送先が設定されていなかったので、記憶を頼りにNASの中に適当なフォルダを作成して、そこに転送されるよう設定。セキュリティの部分でちょっと引っ掛かったけど、なんとか、クリア。そのへんにあったA4の印刷物を使ってテストした結果も良好。これで答案の読み取り準備はOKだ。

次は、僕のプログラムの改良。

まずは、マークシートリーダーで使用するExcelのマクロだ。今のままでは紙への印刷しかできないから、CheckBoxを追加して、これにチェックマークを入れて印刷ボタンをクリックしたら、採点結果を通知するPDFファイルを作成するようにすればイイ。

VBA(Visual Basic for Applications)は、そんなに詳しくないけど、Object Pascal よりメジャーだし、参考資料はWebに山のようにあるはずだ。きっと、なんとか、なる・・・

チェックボックスを追加して、キャプションを「PDFに出力する」に設定

TabIndexも忘れずに変更。開始番号のテキストボックスのTabIndexが0、終了番号のテキストボックスは1、追加したチェックボックスは2、印刷実行のコマンドボタンは3、キャンセルボタンは4に設定する。

なんで、そうなってるのか、理解に苦しむんだけど、Delphiと違って、VBAでは「標準仕様」で、なんにもプログラムコードを書かなくてもEnterキー押し下げでフォーカスがTabIndexで指定したコントロールへ「勝手に」移動する。そのようなonKeyPress イベントが予め実装されているようだ。

Enterキー押し下げで、他の動作をさせたい場合はどうするのか知らないけど、便利と言えば便利な「標準仕様」であることは言うまでもない。でも、Delphiみたいな、すべてをプログラマが制御できる環境に慣れると、VBAの「標準仕様」には驚きというか、恐怖に近いものを感じてしまう。これは僕だけのことだろうか・・・。

この動作はDelphiのObject Pascalなら、次のコードを書かなければ実現できない。

//TEditのonKeyPress イベント
procedure TForm1.Edit1KeyPress(Sender: TObject; var Key: Char);
begin
  //Enterキーで次のコントロールへ
  if key = #13 then begin
    keybd_event(VK_TAB,0,0,0);
    Key := #0;
  end;
  //入力制限する場合 ここに記述する

end;

Form1のKeyPreview を True にする必要があったと思うが、状況によっては、これも使える。

procedure TForm1.FormKeyPress(Sender: TObject; var Key: Char);
begin
  //リターンキーで移動させる
  if Key = #13 then
  begin
    SelectNext(ActiveControl, True, True);
    Key := #0;
  end;
end;

だから、僕が作ったVBAのGUIをマウスなしで操作するなら、設計時にコントロールを移動したい順番にTabIndexを指定しておき、実行時には、まず開始番号をテキストボックスへ入力してEnter、次に終了番号をテキストボックスに入力してEnter、チェックボックスへフォーカスが勝手に移動したら、スペースキーを押し下げてチェックをON/OFFし、Tabキーを押してフォーカスを印刷実行のコマンドボタンへ移動。Enterキー押し下げで、印刷(or PDFファイル作成)が実行という手順になる。

Windows標準のコントロールの移動は、Enterキーではなく、Tabキーであることを忘れてはいけない。

印刷&PDFファイル作成部分は・・・

Private Sub CommandButton1_Click()

    Dim PrintNo1 As Integer
    Dim PrintNo2 As Integer
    Dim i As Integer
    
    'PDF出力用に追加
    Dim Rng As Range
    Dim fName As String
    
    If UserForm1.TextBox1.Text = "" Then
        MsgBox ("開始番号を半角数字で入力してください。")
        TextBox1.SetFocus
        Exit Sub
    End If
    
    If UserForm1.TextBox2.Text = "" Then
        MsgBox ("終了番号を半角数字で入力してください。")
        TextBox2.SetFocus
        Exit Sub
    End If
    
    PrintNo1 = UserForm1.TextBox1.Text
    PrintNo2 = UserForm1.TextBox2.Text
    i = PrintNo1

    For i = PrintNo1 To PrintNo2
        Range("A2").Select
        ActiveCell.FormulaR1C1 = i
        Range("B6:AB38").Select
    
        ActiveSheet.PageSetup.PrintArea = "$B$6:$AB$38"

        'PDFにする範囲を指定
        Set Rng = ActiveSheet.Range("B6:AB38")

        'PDFファイル名
        If i < 10 Then
            fName = Range("C8") & "0" & Range("E8") & "_" & Range("G8")
        Else
            fName = Range("C8") & Range("E8") & "_" & Range("G8")
        End If

        '全角&半角スペースを削除する
        fName = Replace(fName, " ", "")
        fName = Replace(fName, " ", "")
        
        If Not CheckBox1.Value Then
            '紙に印刷(テスト時にはここをコメント化する)
            ActiveWindow.SelectedSheets.PrintOut Copies:=1, Collate:=True
        Else
            'PDF出力
            Rng.ExportAsFixedFormat Type:=xlTypePDF, _
            Filename:=ActiveWorkbook.Path & "\" & fName & ".pdf"
        End If
        
    Next i
    
    Range("A2").Select

End Sub

なんでVBAなのに、PDF作成部分の代入演算子(?)が := なんだ?って疑問が・・・生まれたけど、取り敢えず、動かす方が先。

=と:=の違い

http://officetanaka.net/excel/vba/beginner/02.htm

上の疑問について、調べてみた結果、VBAで、引数に値を設定するときに使用される記号が「:=」であるとのこと。初めて知りました。

サンプルデータだから、超簡略化して・・・

これで実行すると・・・

こうなると、ボタンのCaptionは「実行」だけの方がいいかな?

結果は・・・、次の通り。

できた! できた!!

PDFファイルの内容は・・・

あとは、このPDFファイルをBYOD環境を利用して、個人所有のタブレット端末へ送信する仕組みを構築すれば、いい。それは仲間と協力すれば、必ずできる。

魂の抜け殻みたいな状態の・・・今の僕でも、取り敢えず、ここまでは・・・できた。
明日、朝、目が覚めたら、手書き答案採点プログラムの合計点印刷手続きを改良して、「紙」へ印刷するのではなく、上で行った作業と同様に、今度は指定フォルダ内に返却用答案画像ファイルを作成&保存できるようにしよう。アルゴリズムは同じだから、Delphiでも必ずできるはずだ。

プログラムも、僕も、よくなるんだ・・・。
それで、いいじゃないか。
また、ゼロから創めればいいだけのことじゃないか・・・。

今までだって、全部ゼロから作ってきたんだ。
そうだ。僕は・・・ 精一杯、やった。
その時々で、僕にできることは全部やった。
後悔など、何ひとつない。

それが今、また、ゼロに戻っただけだ。

この・・・色のない世界に、色を創るのは僕だ。
僕にしか、出来ない仕事だ。
僕が色を創るんだ。

僕があきらめない限り、
この夢は・・・実現できる可能性を残している。

僕にしか、出来ない仕事をしてみせる・・・。

あきらめるもんか。
やるぞ!

4.お願いとお断り

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

タンデム・シート

2023年3月31日(金)、僕は自由になった。

だったら、4月1日(土)は、

どこへ行こうか・・・
何をしようか・・・

先週の終末は、雨だった。
今週末、この地域は・・・晴れの予報だ。
それなら土曜日の、いちばん、は・・・ 16歳の頃から決まってる。

ずっと雨だったから、止まったままの・・・
ダブル・オーバー・ヘッド・カムシャフト・・・ インライン4。
そうだ。僕の空冷四気筒DOHCエンジンに、火を入れよう。

右のリアショックからは、オイルが漏れてる・・・
30年モノだもの、壊れないほうが不思議。

空冷の四気筒エンジンが、当たり前に存在した時代があったことを、
聞き伝えではなく、原体験として語れるのは・・
もしかして、僕らの世代が・・・最後、なんじゃないか・・・?

30年を・・・さらに越えて、時はこんなに流れても・・・
きみの見た目は、なんにも、変わらない。

初めて出会った、あの日のままさ・・・。
タンクも、それから、エンジンも、まだ輝きを残してる。

でも、お互い、年齢を重ねた・・・。
ウソだろって・・・本気で、思うよ。
出会って、もう、30年・・・。

燃料チャージはキャブレターだから、駆けだすには十分な暖気運転が必要だし、
例え、オイルが熱くなっても、今はもう、1万回転以上は怖くて回せない・・・

きみを壊したくない気持ちは、もちろん、本当だけど。
僕も、もう、若くないんだ。身体がエンジンの回転数に追いつかない・・・
いつも、動きが一瞬遅れるのを感じる・・・それも、本当なんだ・・・。

僕だけの偏見かな?
きみが大好きな理由さ・・・

キャブレター吸気のエンジンには・・・、うまく言葉に出来ないんだけれど、
インジェクター仕様のエンジンには絶対にない、楽しさ があるんだ。
チョークを引いて、掛けるエンジンなんて・・・
もしかしたら今は、知らない人のほうが、多いんじゃないか。

「掛かるかな・・・?」って、スターターを廻す度に感じる、ドキドキ感。
そして、掛かった瞬間の、言葉にできない・・・うれしさ。

きみのエンジンに、もし、キックペダルがあったら、
エンジンを掛ける度に・・・僕は泣き崩れていた・・・かも、しれない。

こんなに好きなのに、
もう二度とラインアップされることのない・・・
空冷四気筒DOHCエンジン。

・・・だから、僕は、繰り返してしまう。

きみへの想いと、
過去と、
現在とが、
交差する時を。

雨の日に、火酒を片手に、これまでにいったい何時間・・・
きみのエンジンを眺めたことだろう。

晴れた日には、16歳だった・・・、あの日に還って。
きみのエンジンの咆哮を、何度、たまらない想いで・・・聴いたことだろう。

お互いに年齢を重ねた・・・だから、スライダーもありかな・・・って

こんなに美しい機械を、僕は他に知らない。
これを造ったひとは、いったい、どんなひとなんだろう。

焼けたオイルの匂いが好き

憧れてたんだ・・・

『あいつとララバイ』の研二くんの駆る『Z2』に・・・

僕のは・・・1990年2月発売の KAWASAKI ZEP400 Type C2。
オリジナルのメーターはC3以降のと違って、砲弾型じゃなかったから、
SUZUKIのバンディットのを、無理やり、くっつけた。
心配した車検も、このままで、通った!

正直、C3以降のオリジナルよりインパクトがあるんじゃないかな?
このメーター。あんまりないでしょう?
白い文字盤と、オレンジの指針の組み合わせが、好きなんだ。

別の日のスナップ。オイルが漏れていたリアショックをオーリンズに交換。

ミラーも角ばったノーマルのじゃなくて、
丸いZ2タイプのショートミラーに変えた。
おかげで、後ろは、なぁーんにも見えなくなったけど、気にしてない。
昔は、右側だけ付けてたけど・・・、規則では左もないとダメみたいだ・・・。

ウインカーは、オリジナルのはプラスチックが劣化して折れちゃったから、
一昨年、ずっと憧れてた・・・、小さなヨーロピアンタイプのに、替えた。

ステップは、BEET 工業の『スーパーバンク・バックステップ』
ノーマルのは、おとなしすぎたから、ステップ位置を少し後ろ、少し、上へ変えた。
タンデムステップが付くのが、うれしかった・・・。

彼女は、真夜中に一度だけ、乗ってくれた。
あれからタンデムは1回もしてないけど・・・。

ずっとセパレートだった・・・ハンドル。
取り回しと、何より車検がたいへんだから、今はノーマルに戻した。
あの頃は毎週末、上野のバイク街に、通いつめて、夢に見たカタチを探したんだ。

セパレート・ハンドルにしてた頃は・・・
トップブリッジ外して、ハンドル付けて・・・
タンクに干渉しないか、何度も確かめてから、ハンドル位置を決めて・・・

ウインカーやチョークの操作ユニットがセットできるよう、
そうだ。ハンドルに穴を開ける加工を、ドリル片手にやったんだ・・・。

ガレージのそこだけ、光が当たって、輝いてる感じだった。

組み上がったハンドルを握った、その瞬間。
今でも覚えてる。

きみと、ひとつになれた・・・気がした。

マフラーは、定番の、モリワキの直管。
焼けて錆びる度に、サンドペーパーをかけて、耐熱塗料を塗り直した・・・。
アクセルを開ければ・・・車検対応って、マジ、ウソだろ・・・ってくらいの・・・いい音。
3速、8000回転あたりのエキゾースト・ノートが最高・・・気持ちイイ。

雨の日は絶対に乗らないから、後ろのフェンダーは取り外した。
ナンバーを取り付けるための代替部品は、厚さ2mmのアルミ板を半日かけて、糸鋸で切り出して作った。気が遠くなるような作業だったけど・・・

すごく、楽しかったな・・・。

サイドカウルとシールも、テールカウルも、それから、尾灯も・・・
全部、Z2タイプの、それに、変えた。

もちろん、タンクのエンブレムは旧字体の KAWASAKI に。
古い両面テープはギター用のオレンジ・オイルで剥がした。

あの夜は・・・ ガレージの中が、いい匂いだったな・・・。

全部、よく見なければ、わからないけど。
自己満足だから、イイんだ。

オイルクーラーは、今はノーマルだけど・・・
前は EARL’S のそれを付けてた。
ホースの部品が劣化して割れちゃってから、交換して、そのままになってる・・・

認めたくなんか、ないけど・・・
そうだ、確かに、30年という「時」が、流れたんんだ。

時に、激しく、
時に、静かに。

きみが、赤茶けたサビなんて、まだ知らずに輝いてた、あの日から・・・

今は、もうお互いに、時代遅れさ・・・。でも、それでいいじゃないか・・・

永遠にとまるな・・・
きみの鼓動。

僕は・・・ きみが、大好きだ。

4月1日の空は、気持ちよく、晴れた

『タンデム・シートに乗らないか・・・』

 隣で、クルマのハンドルを握る彼女に、そう、聞いてみた。

『怖いから、イイ』

 きみは、覚えてるか・・・

 菜の花が、咲いてたよな・・・
 すごい、気持ちよかった。

 彼女が待つ場所へ、
 きみと走った・・・。

 あの日を。

自己一致

幼い頃は、欲しかったおもちゃを買ってもらえたら、もうそれだけで幸せでした。
ひとは成長するにつれて、欲しいものを手に入れたり、内なる希望を実現するには、苦労を伴う努力が必要なことを知ります。

ひとがなぜそのように成長するのか?
あくまでも「僕にとって」という但し書き付きなのですが、その答えを知る「きっかけ」となったのがマズローの心理学との出会いです。

Abraham Harold Maslow(アメリカ合衆国の心理学者、1908 – 1970)

また、すべての物事には、始まりに必ず、何らかの「きっかけ」があり、「きっかけ」を得て初めて、ひとの「心」に変化が起きるわけですから・・・このプロセスが非常に重要で、ひとの想いと行動を考える上では、物事の始まりとなる「きっかけ」は、絶対に見落としてはならない、重要な要素であると思います。

マズローは自身の発表した欲求5段階説で、人間の欲求を「生理的欲求」「安全の欲求」「社会的欲求」「承認の欲求」「自己実現の欲求」の5つの階層に分類し(後に、自己実現の欲求の、さらに1階層上に「自己超越の欲求」を付け加えますが)、人はより低次元の欲求が満たされて初めて、より高い次元の欲求の実現を求めるようになると、定義しました。

【マズローの欲求5段階説】

6.自己超越の欲求・・・自らを犠牲にしてもよいから、貢献する/理念の実現を図る。
5.自己実現の欲求・・・自分らしい自分を生きるために満たしたい欲求。
4.承認の欲求・・・他者から認められたいという欲求。
3.社会的欲求・・・人間社会の集団に所属したい欲求(所属と愛情の欲求とも)。
2.安全の欲求・・・安心かつ安全な環境の中で暮らしたいという欲求。
1.生理的欲求・・・睡眠等、生きるために最低限満たされる必要がある欲求。

ひとの成長期の前半から後半にかけて、このマズローの欲求5段階説の3階層目及び4階層目である「社会的欲求」や「承認の欲求」が現れ、ひとはその欲求を満たすため、様々に努力します。

社会の仕組みそのものが、人間の発達段階に合わせて作られているから、ちょうどこの時期に入試や入社・採用試験が集中するのは、当然です。

現実社会には様々な矛盾があり、現実が理想に一致しない状態・・・そう、私たちが「ふしあわせ」と呼ぶ状態が存在することも、私たちはこの時期に(否応なく)知ることになります。もちろん、それとは逆に、自らが理想とする状態と、現実が合致している状態が「しあわせ」であることも・・・。

そのような意味での「しあわせ・ふしあわせ」は、「周囲から見て、ある基準と比較して相対的に判断される」ものではなく、あくまでも本人の中での「理想」と「現実」の一致、いわゆる「自己一致」の有無のみによって決まるものと言えます。

だから、常人には耐え難い苦痛を伴うことが明白なオリンピックのマラソン競技でも、それに出場することを目指した選手にとっては、オリンピックに出て金メダルを取ること(自らの理想)と、それを実現するために苦しい練習が必要なことは当然(現実)だから、彼らが日々行う「一般人から見れば苦痛以外の何ものでもない過酷な練習」も、本人的には、その必要性において完全に「自己一致」しています。この「自己一致」があるから、マラソンの選手は、普通の人ならば確実に死んでしまうような練習を毎日普通に行い、しかも、その苦痛に満ちた状態を「しあわせだ!」と表現できてしまったりするわけです。

10代後半における、所属と承認の欲求の実現の具体例をひとつ挙げよと問われたら、多くの人が、その最たるものは「大学入試である」と答えるのではないでしょうか?

次に示すのは、ある教員が書いた先輩教師への手紙です。

最近、こんな出来事がありましたので、お伝えしたく、筆を取りました。

昨年、秋が深まった頃、模擬試験の問題がわからないと、質問に来てくれた子がいました。

出来る限り、丁寧に解説して、理解が深まるようアドバイスしたのですが・・・、解説の最後に「わかったかい?」と、そう訊ねると、その子は俯いたまま、涙をポロポロこぼして言いました。

(どんなに努力しても、模試を何回受けても、毎回同じ点数で成績が全然上がらない)

(いったい、どうすれば応用問題が解けるようになるんですか?)

あぁ・・・ 僕でよければ、代わりに試験を受けてあげたい。
そう、思ったのですが、それはできません。

(このまま、この子を帰すわけにはいかない。今、自己一致させるには、どうすればいい・・・)

僕は、死に物狂いで考えました。
そして、こう、言いました。

「わからない問題があったから、もっとよくなりたくて、きみは質問にきた。
 今、きみのやってることは、間違いか。それとも、正しいことか、どっちだい?」

その子は、泣きながら、でも、はっきり答えました。

「正しいことです。」

「ならば、それを続けるしかないじゃないか。
 わからないことがあれば、悩んでる暇なんか、ない。すぐにおいで。
 きみがわかったと言うまで、きみにつきあうぞ。
 僕もわからない問題なら、わかるまでいっしょに考える。
 それだけは、絶対に、約束する。」

「もし、それでダメなら、僕を一生恨んだらいいじゃないか。」


今年度の大学入学共通テストが行われた翌週の月曜、放課後、
午後5時を過ぎてあたりがすっかり暗くなった頃、
その子が僕の準備室をたずねてくれました。


(できたかい?)

ほんとうは、そう言いたかったのですが、この場合、そうは言えません。

「全力を出し切れたかい?」

僕は、そう訊ねました。

「はい。47点取れました。先生のおかげです。」
(この科目の満点は50点です)

(僕は、何もしていない・・・)

僕は、本当に、何もしていない。ただ、できる限り、丁寧に、その子の質問に答えただけなのですが、人は本当に不思議な生き物です。

どうしてこんなにも、胸が熱くなるのか、その理由を、僕は、今も、言葉にできません。

手紙の中の彼女が「ある教員」のところへ質問に来た時、彼女の中に「自己一致」がありませんでした。つまり、応用問題が解ける状態(=理想)と、応用問題が解けない状態(=現実)の不一致が、彼女の「不幸な状態」を作っていたわけです。

「ある教員」は、彼自身のこれまでの経験から、彼女の中で「自己一致が失われている」ことが苦しみの最大の原因であることに気づき、短時間で、何とかして、それを作り出そうとします。

現実の中で「よくなりたい」から質問に来た。それを確認することで、引き続き「よくなる」ためにはそれが最善の方法だと再確認がとれ、自らの行いの正しさを再認識することで、現実的には「まだ応用問題が解けるようにはなっていない」にもかかわらず、彼女の中に「この正しいことを繰り返して行けば応用問題が解けるようになるかもしれない」という「希望」が生まれます。すると現実的には「何ひとつ変わっていない」のに、「不幸せ」だった状態が「自分は正しい」という「幸福」な状態に変化します。

これが「自己一致」です。

さらに「ある教員」は、彼女の特別な味方であることを強調しています。相手の弱さや苦しみを自分の中に見て、相手の行動が正しい場合には、肩を並べ、ともに生きる姿勢を示すことで、相手の中に「生きるちから」を再生することができます。

最後の「一生恨んだらいい」には、カウンセリング的な意味はありません。
「ある教員」独特の個性でしょう。

相手への最大限の誠意の上に、カウンセリングの知識と技法を乗せれば、相手に「しあわせ」をプレゼントすることができます。これが「言葉の魔法」です。

ここに書いたことに加えて「共感的理解」・「受容と許容」について学べば、より一層知識を深めることができます。

この物語の彼女は、卒業式当日、「ある教員」へ手紙を書いてきてくれました。

 あの日、私の中で何かガラっと変わった気がします。

たったひとつ、自己一致が生まれた、それだけでひとはこんなにも強くなれるのですね。

現在、長時間労働等の問題がクローズアップされ、教員志望者が減少し、教員不足が深刻化していると聞きました。教員の仕事は、日々、自らの学びを深め、技能を向上させることで、児童・生徒のみなさんのためだけでなく、職場の同僚に対しても貢献でき、人間として感じられるこれ以上ない「よろこび」と「感動」に出会える仕事だと思います。

教員希望者のみなさん! がんばってください。
世はきみが立つのを待っています!!

【お断り】

この物語に登場した「彼女」は実在する、とてもすてきな女の子です。
本人の掲載許可を得た上で、ここに書かせていただきました。
また、このエピソードはある「公」の場で語られたものであり、個人を特定してその秘密を公開する意図はなく、法的な守秘義務にも反しないものと判断して掲載しています。

私にとって、愛とは・・・

なぜ、ひとに「悲しみ」という感情があるのか・・・
僕はその理由を知りません。

悲しみなんて、すべてなくなればいいのに・・・ と、そう願いながらも、
悲しみのない世界がどんな世界なのか、
僕には、その世界を想像することができません。

もし、悲しみがなくなれば・・・
ひとは、ほんとうにしあわせになれるのでしょうか・・・

この問いかけが許されるなら、神なる存在に、そう尋ねてみたい気がします。

「愛」もそうです。

ただ、「悲しみ」とは違い、「愛のない世界」を知ることはできます。
さまざまな作家が「愛のない世界」を描いていますが、僕の心に最も残った作品は、遠藤周作さんが「女の一生 二部・サチ子の場合」で描いた、第二次世界大戦中、ポーランドの古都クラクフ郊外にあった「アウシュビッツ強制収容所」の風景です。

「ここに・・・・・・愛があるのかね、神父さん」
ひきつった声でその囚人は笑った。
「あんた、本当にそんなことを信じているのか」

「ここに愛がないのなら・・・・・・」
と神父はかすれた声で言った。
「我々が愛をつくらねば・・・・・・」

新潮文庫「女の一生 二部・サチ子の場合」 遠藤周作 著より引用

なぜ、ナチス・ドイツはその「収容所」を作ったのか。
ユダヤの人々に対する、ヒトラーの「歪んだ思い」はどこから生まれたのか。
そんな彼を、ドイツの人々は、なぜ「支持」したのか。
また、彼は実際に「どのような政治」を行ったのか。

もしかしたら、そのような(教科書に書かれていないことも含めて)歴史の背景を学んでから、この作品を読んだ方がいい・・・のかもしれませんが、たとえ、そうでなくても・・・

ここに登場する・・・マキシミリアン・コルベ神父の言う「愛」が容易くないことは明らかです。容易い「愛」などないと、今は思いますが、自らの内なるものの何が「愛」なのか、僕はずっと理解していませんでした。自らの内なるものの何が「愛」であるのかを知り、その重さを初めて理解したのは、二十歳を過ぎてからのことでした。

人生では、縁も、所縁もない人と、偶然同じ場所で、同じ時を生きることがあります。そして、その隣人と生涯をともにするわけでも、なんでもないのに、その人の「現在」を例えようもなく、重たく感じることがあります。

例えば、学校の先生になって・・・
クラスの子どもたちに対して感じる気持ちが、そうです。

まだ、若かった頃のことです。

僕は、他者への気持ちが、際限なく重たくなる理由がわかりませんでした。
例えようもなく、他者(クラスの子どもたち)への気持ちが重たくなるのを感じたある夜、郷里の父に電話して、その重さの理由を尋ねたことがありました。

どんなに心を尽くしても、こちらのしてほしくないことばかりする、クラスの子どもたちを『なんで心配してしまう』のか、その本当の理由が知りたかったのです。

郷里の父の部屋の机の上には、十字架があり、聖書と祈祷書が常にきちんと置かれていました。部屋の壁には作り付けの本棚があり、青年期に入った僕はそこから遠藤周作さんの「沈黙」や「海と毒薬」、八木重吉さんの「貧しき信徒」などを手にすることになります。父に連れられて教会へ行くことも、子ども心には不思議でしたが、僕の家では年中行事のひとつでした。父にとってイエス・キリストは、とても大切な存在であったようです。僕は、そんな家庭で育ちました。

その晩、電話の向こう側で、父は、ただ黙って、僕の話をきいていました。
なぜ、彼がずっと黙っていたのか、今はその理由がわかるような気がしますが・・・。
父がいつまでも黙っていることに耐えかねて、僕は彼にこう尋ねました。

「お父さんの信じる神なら、こんな時は何て言うの?」

「救い・・・って、何」

それまで黙っていた父が、即答しました。

「救いなんて、ない」

「それなら、なんなの。遠藤(周作)さんの『沈黙』と同じじゃないか。
 やっぱり、お父さんの神も黙ってるの?」

僕は、いちばん知りたかった疑問を重ねて彼にぶつけました。

「なんで、こんなに、赤の他人の人生が重いの?」

それから、父がゆっくりと、まるでひとりごとを言うかのように、答えてくれた言葉を、僕は今でも覚えています。それが、次の言葉でした。

「(私の名前)・・・な、ちんけな、安っぽい言葉に、聞こえるかもしれん。
 ちんけな、安っぽい言葉に、聞こえるかもしれんけど・・・」

「私は、それが愛だと思う。」

電話はそこで切れました。

内なる悲しみを分かち合うことは困難ですが、内なるこの愛は共有できます。

「女の一生 二部・サチ子の場合」 遠藤周作 著

青年期に読むべき、価値ある一冊だと思います。
初版は昭和61年ですが、現在でも重版されており、新しい本が入手できます。

ぜひ、手に取ってみてください。

花が咲いたよ

朝、仕事に行こうとして家の玄関を出たら・・・

スイセンの花が咲いていた。

僕は、大好きな、八木重吉さんの詩を思い出した・・・

 花

花はなぜ美しいか
ひとすじの気持ちで咲いているからだ

僕は、悲しみの塊のような人間だから、
どうしても過ちを繰り返してしまう・・・

たとえ、ひとすじの気持ちであっても・・・
それが自分にとって、どんなに美しいものであっても・・・
だれかを傷つけるなら、それは間違いだ。

空を見上げて、きみに心から謝りたいです。
ほんとうに、ほんとうに、ごめんなさい。

そう思ったら・・・
大好きな、八木さんの詩を、もうひとつ、思い出せた。

 ひかる人

私をぬぐうてしまい
そこのとこへひかるような人をたたせたい

ほんとうに、その通りだ。
それができたら、どんなにか、こころが明るく、軽く、なるだろう・・・

こんな くだらない僕を・・・
ひと思いに、ぬぐいさってしまえたら。

それが僕の ほんとう であるのに・・・
それが僕の 偽りなき ほんとう であるのに・・・

昨日の報道発表を聞いて
たくさんのひとが・・・僕に会いに来てくれて
美しい涙を流してくださった・・・

ぬぐいさってしまいたいような・・・こんな 僕のために。

わたしのまちがいだった
わたしのまちがいだった
こうして草にすわれば
それがわかる

あと何度、混乱を繰り返したら、美しい気持ちになれるだろう・・・
どれだけ思い悩んだら、この僕を卒業できるだろう・・・

どこに向かって歩けば、いちばん僕らしく、歩けるだろう

誰ひとり、傷つけずに・・・
誰ひとり、困らせずに・・・
静かに美しく咲いていた・・・

今朝、見た あのスイセンの花のように。

花が咲いたよ
花が咲いたよ
幼かった僕なら、きっと・・・笑顔で
そう、今の僕に言ったに違いないけど。

Begin

今日は、出張だった。

遅れてはならない大切な打ち合わせだったから、時間には十分余裕を持って出た。
昼を食べていなかったので、途中、コンビニでパンを買って・・・
僕は、なつかしい公園の駐車場にクルマを止めた。

最初から、そうしようと決めていたんだ。
数分間でもいい。思い切り、思い出に浸りたかった・・・。
新しい風景を見る、その前に。

(街路樹が・・・なくなってる?)

たしか、あそこには、ポプラ並木があった・・・はずだ。
僕だけじゃなくて、公園もいつしか年齢を重ねてた。

今の職場に6年・・・
その前の職場には4年・・・
この公園によく立ち寄ったのは、さらにその前の職場にいた頃だから、もう10年以上前のことだ。

あの頃、言葉にできないことがあると・・・
帰り道、ここでよく空を見上げた。
空は青かったこともあるし、茜色だったことも、星が瞬いていたこともあった。

樹があった場所に、10年の歳月と、今がある気がする・・・

(空港を出発したばかりの飛行機だ・・・)

そうだ・・・みんなを乗せて・・・ 遠い、とおい、ところへ・・・

優しかった微笑み
あの日の拍手
それから・・・ それから・・・

思い出せることすべてが泣きたくなるほどに、美しいのはなぜ・・・

思えば、胸いっぱいに広がるさみしさと、別れのかなしみを感じながら
大切に思えてならない多くのひとのしあわせを祈り続けてきた・・・たくさんの三月。

これが・・・ 僕の仕事なんだ。

今は、まだ、どうしても、うつむきたくなるけれど・・・
きっと思い出せるはずだ。

Begin

そう、Begin

美しい思い出とともに、また、きっと・・・歩き出せる。

Object Pascalと、僕と。

Conditional Compilation

条件付きコンパイル

Delphiからembeddable Pythonを利用するプログラムを書いた。これまでは32bit環境しか利用(作成)しなかったので、条件付きコンパイル(条件コンパイル)には縁がなかったが、64bit環境を利用しなければならない事情ができたので、初めてこれに挑戦。

結論だけ言うと、すごぉーく、カンタンだったー☆

【今回の記事】

1.ターゲットプラットフォームを切り替える
2.条件付きコンパイルやってみた
3.まとめ
4.お願いとお断り

1.ターゲットプラットフォームを切り替える

以前、スキャナーで読み込んで画像データ化した答案用紙の解答欄座標を、自動的に取得できるプログラムを書いた。もちろん、GUIはDelphiで作成。・・・とある事情から、コレを64bit化しなければならなくなった(「・・・とある事情」は次の記事以降のMyBlogに掲載)。

簡単に言うと、「手書きカタカナ文字の自動採点」をTensorFlowを使って実現しようとしたためです。正解率がどうがんばっても100%にならず、手書き答案の採点ソフトへの実装は見送りましたが・・・

このプログラムの核となる矩形(輪郭)検出部分は、OpenCVのfindContours関数を使い、座標取得部分は同じくOpenCVのboundingRect関数を使っている。OpenCV自体は、Delphi用ではなく、Python用のライブラリをembeddable Pythonにインストール。で、DelphiのObject PascalにPythonのScriptを埋め込み、必要な時にGUIの向こう側で走らせて、矩形検出を行っている。

32bitバージョンのembeddable Pythonを入れたフォルダの名称は「Python39-32」、64bitバージョンのembeddable Pythonを入れたフォルダの名称は「Python39-64」としてこのフォルダの名称以外はまったく同じコードでWin32版とWin64版の解答欄矩形検出プログラムとしてそれぞれコンパイル、同じくDelphiで書いた手書き答案採点プログラムから、採点準備の際に呼び出して利用している。フォルダ名の「39」だが、これはPythonのメジャーバージョンが「3」、マイナーバージョンが「9」であることを表しているのは言うまでもない。

Python環境を入れたフォルダの名称が異なるだけで、その他は32bit版も64bit版も一字一句まったく同じプログラム。これを別個のプロジェクトとして保存して、改良のための変更やバグの修正があった場合に、同じ内容を二度書くのはどう考えても効率が悪い。何かしら上手い方法はないのか・・・。

そこで、最も簡単にターゲットプラットフォームを切り替えるにはどうしたらいいかを調べたところ、条件コンパイル(条件付きコンパイル)という方法があることを知った。早速、見様見真似でやってみた☆

2.条件付きコンパイルやってみた

「Delphi ターゲットプラットフォーム 取得」をキーワードにして、Google先生に質問してみた。実は、この時点では、僕はまだ「条件付きコンパイル」なるものの存在を知らない。現在選択しているターゲットプラットフォームをプログラムコードから取得し、条件分岐でembeddable PythonへのPathを指定しようと思ったのだ。

  //手動でEmbeddable PythonへのPathを切り替え(存在の有無を調査)
  AppDataDir:=ExtractFilePath(Application.ExeName)+'Python39-32';
  //AppDataDir:=ExtractFilePath(Application.ExeName)+'Python39-64';
  if DirectoryExists(AppDataDir) then
  begin

  end;

Google先生が教えてくれたWebサイトへ行ってみると・・・

異なるバージョンの Delphi でソースを共有する

https://ht-deko.com/tech001.html

記事に『条件コンパイルを設定すればバージョンで異なるソースを記述できます。』と書かれている。

そんなの、あったんだー☆

で、今度は「Delphi 条件コンパイル」をキーワードにして再度、検索。新しいターゲットプラットフォームの追加方法と条件付きコンパイル(条件コンパイル)の記述方法を学ぶ。

ターゲットプラットフォーム(64ビットWindows)を追加するには、プロジェクトマネージャの「ターゲットプラットフォーム(Win32)」部分をポイントして右クリック、表示されたサブメニューの「プラットフォームの追加」をクリックすると次のWindowが表示されるので、追加したいターゲットプラットフォームを選択(されているけど)して、「OK」をクリックする。

「プラットフォームの選択」画面

すると、プロジェクトマネージャの表示は・・・

「Windows64ビット」が追加された!

条件付きコンパイルの記述方法は、処理を切り替えたいところで・・・

  {$IFDEF WIN32}
  //32bit環境での処理

  {$ELSE}
  //64bit環境での処理
  
  {$ENDIF}

・・・とすればいいだけ!とのこと。走召カンタン

$IFDEF に続く WIN32 部分を「シンボル」というらしい。もちろん、WIN32はターゲットプラットフォームが32ビット環境であることを意味する。他にもたくさんのシンボルが指定でき、例えばここを IOS とすれば、ターゲットプラットフォームが iOS であるかを判断できるとのこと。まぁ、僕がiOS用のプログラムを書くことはないだろうけれど・・・。

うれしい気持ちで、書いてみた!

うわー! 色が変わったぁー☆

どうやら、現在選択されていないプラットフォーム用のコードは色が薄くなるしくみらしい。DelphiのIDEの評価ってどうなのか、よく知らないけれど、コレはイイ!

ターゲットプラットフォームを切り替えると、コードの色も変わる

うれしくなって、何度も無意味に切り替えてしまう・・・

むかし、上野動物園でデカい白くまが「プールに落ちる」動作(飛び込みではない・単に落下するだけ!)を何度も何度も繰り返すのを見たけど、今、ようやく、あの時の白くまの気持ちがわかった気がする・・・。上野動物園、行きたいなー☆

うわー 壊れたー T_T

状況によっては、ターゲットプラットフォームを切り替えても、コードエディタの文字の色が切り替えに追随しなくなることがあるようだ。あわててDelphiのIDEを再起動。ターゲットプラットフォームを切り替えて直ったことを確認。よかったぁ・・・

これで別プロジェクトに分けなくても、プロジェクトマネージャーでターゲットプラットフォームを切り替えてコンパイルするだけで、32bit/64bitいずれの実行ファイルも生成できるようになった!

今回は、近所の小学生でもわかるような内容だったけれど・・・

すごくよくなれた気がする*(^_^)*♪

3.まとめ

(1)条件付きコンパイルを使えばターゲットプラットフォームの切り替えは簡単。
(2)コンパイラ指令は、コメント開始区切り記号{の後に $を先頭文字として記述。
(3){$IFDEF WIN32} //32bit環境での処理 {$ELSE} //64bit環境での処理 {$ENDIF}

4.お願いとお断り

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

I did it again!

また、やっちゃった!

2022年が終わる頃から、およそ2ヵ月半を費やして(カタカナのア~オと、記号の〇と×限定ではあるが)手書き文字(答案)の自動採点プログラムを、GUIはDelphi・自動採点部分はPythonで作成。職場の仲間たちみんなにプレゼントしたのはいいんだけれど・・・。

なんで、なんで、いつも最後に、思ってもみなかったコトが起きるんだろー T_T
Python ・・・ きみと仲良くしたい時は、いつも・・・

【今回の記事】

1.ついにできた!!!
2.なんで起動しない?
3.また、やっちゃった!
4.それでも動かしたい僕は・・・
5.全角文字を探し出すには?
6.まとめ
7.お願いとお断り

1.ついにできた!!!

夢にまでは出てこなかったケド、手書き答案の自動採点プログラムがついに完成( した・・・ と自分的には思っている)。

プログラム添付の取り扱い説明用PDF文書の一部を抜粋

パラメータの設定さえ上手く行けば、上の画像のように、解答欄中の設問番号などには目もくれず、手書きのカタカナ文字だけを識別してほぼ期待通りに動作( してほしい くれる ・・・と自分的には思っている)。

しかも、このプログラムは、自分史上初となる64ビットバージョン。

まぁコレは内部的に呼び出して使用しているTensorFlowに32ビット版がないから、仕方なく64ビット化した・・・というのが、ほんとうだけど。

自動採点部分は「オマケ」程度に考えてもらえれば、採点記号&得点の入力や合計点の自動計算、返却用答案画像の印刷、さらに(こちらも自作の)マークシートリーダーとの併用等、採点プログラムとして必須の機能は間違いなく動作するから、職場の仲間たちによろこんで使ってもらえるはず・・・

みんなが、寄って集って(しかも、あろうことか、実戦で)動作検証してくれるから、ある意味でプログラマーにとってこれ以上しあわせな環境は「ない」カモしれない・・・

(僕がプログラマーと言えるか、どうか、それはまた別の問題として)

仲間たちの信頼を裏切らないための、自分に出来得る限りの動作検証は、もちろん必要だけど・・・

これで、またひとつ、夢をカタチにできた!
でも、うれしいより、なんだか カラッポ になっちゃった感じ。
(がんばった後は、いつもそうなんだけど)

すごーく高い山の頂上にたどり着いて、上を見上げたら
そこには、もっと高い、きれいな青い空があることに気づいたような・・・

確かに積み上げたと思ったもの、すべてが消えて
胸の中がカラッポになってしまったような・・・

でも、僕は、この感覚がとても好き。

My自動採点プログラムの実行結果
ここでの正解は「エ」に設定。ここでは1つも間違っていない。
(画面は合成です)

パラメータ設定さえ、決まれば・・・上の画像のように非常に良好な結果が出せる。
ただ、どんな答案に対しても「常に」良好な結果が出せるような・・・共通して利用できるパラメータ設定は、現在のところ、まだ見いだせない。

マークシートリーダーを作った時も、最後の最後で、この最適なパラメータ設定を見つけるという問題の解決のために、本番と同時進行で、ドキドキしながら試行錯誤を繰り返したんだ・・・。

プログラム添付の取り扱い説明用PDF文書の一部を抜粋

いろんな解答欄に、共通して利用できる設定さえ、見つけることができれば・・・

さらに答案画像の解像度や縮小率も関係するので・・・パラメータの最適な組合せがまだ見えない!

まぁ、ここまでくれば・・・
最初に夢見た自動採点を、「実現できた!」と言っていい はずだ。

そう、はずだった・・・んだ、ケド。

2.なんで起動しない?

採点プログラムの動作に必要なモノすべてをReleaseフォルダに詰め込んだら、Releaseフォルダごと、職場のファイルサーバーの公開フォルダにコピーする。

で、フォルダ名が “Release” のままでは内容がわからないから、フォルダ名を「採点プログラム」のような、誰が見てもわかりやすい名前に変更する。

あとは使いたい人が自分のPCのデスクトップ等に、そのフォルダごとコピペして、使いたい時にフォルダ内にあるAC_Reader.exeをダブルクリックして採点プログラムを起動してもらえば、それでOK!

(ACは “Answer column” の略)

・・・だったはず、なんだけど。。。

起動時にいきなり「学習モデルが開けない!」エラーが発生

エラーメッセージは、「値のエラー:採点プログラムフォルダ内のsaved_model_mb.tfliteファイルを開くことができません」と訴えている。

僕はFormCreate時にPythonEngineを初期化し、自動採点機能を1回だけ動かしている。これはユーザーがFormの自動採点ボタンを押した時に「動作が重たい」と感じないようにするために行っていることだ。同じ内容を同じようにembeddable Python を組み込んだマークシートリーダー(Win32版)でも行って、これまで一度も問題は起きなかった・・・。

PythonEngineの初期化手続きは・・・

  //embPythonの存在の有無を調査
  AppDataDir:=ExtractFilePath(Application.ExeName)+'Python39-64';

  if DirectoryExists(AppDataDir) then
  begin
    //フォルダが存在したときの処理
    //MessageDlg('Embeddable Pythonが利用可能です。', mtInformation, [mbOk] , 0);
    PythonEngine1.AutoLoad:=True;
    PythonEngine1.IO:=PythonGUIInputOutput1;
    PythonEngine1.DllPath:=AppDataDir;
    PythonEngine1.SetPythonHome(PythonEngine1.DllPath);
    PythonEngine1.LoadDll;
    //PythonDelphiVar1のOnSeDataイベントを利用する
    PythonDelphiVar1.Engine:=PythonEngine1;
    PythonDelphiVar1.VarName:=AnsiString('var1');  //プロパティで直接指定済み
    //初期化
    PythonEngine1.Py_Initialize;
  end else begin
    //MessageDlg('Embeddable Pythonが見つかりません!',mtInformation,[mbOk],0);
    PythonEngine1.AutoLoad:=False;
  end;

ここでエラーは起きていないようだ。次が自動採点の実行。最初の1回目だけはそれなりに時間(数秒だけど)がかかるから、FormCreate時に1回だけ実行しておけば、ユーザーにとっての1回目の自動採点の実行は、実は2回目になり、待ち時間ほぼゼロで動くようになる。そのために、どうしても必要な部分なんだけれど・・・

メッセージから考えて、エラーはStringListを作成して実行しているPythonスクリプトの “model.load()” おそらくここで起こってる・・・

if os.path.isfile(img):
    image = Image.open(img)
    model = TFLiteModel(dir_path=dir_path)
    model.load()
    outputs = model.predict(image)

プログラムでは、起動時にリソースに埋め込んだ〇記号の画像を再生し、これを読み込んで機械学習モデルと照合する「誰にもわからない」自動採点を一度だけ行っている。

“model.load()” 時に呼び出される〇×記号の学習モデルが saved_model_mb.tflite だ・・・。
このtflite形式の学習モデルはLobeが生成したjsonファイル内で、次のように・・・

"filename": "saved_model_mb.tflite"

filename変数に代入されて参照できるように設定され、このjsonファイルをPythonスクリプトではTFLiteModelクラスの中で開いている・・・

with open(os.path.join(model_dir, "signature_mb.json"), "r") as f:

なぜ、ここで saved_model_mb.tflite が開けないんだ?
My PC では、同じプログラムが「何の問題もなく動作する」のに・・・

ナニが違う・・・?

3.また、やっちゃった!

クライアントPCで起動時に表示されるエラーメッセージを、もう一度見つめて考える・・・

「E:¥採点プログラム」 僕のPCとの違いはここだけだ・・・

ここで、ようやく僕は気づいた・・・。そうだ。exeへのPathが違うんだ・・・
Pathに全角文字が・・・、日本語が含まれていると・・・OpenCVでもファイルの読み込みに失敗してた・・・だからPillowを使ってファイル読み込み時のエラーを回避したじゃないか。

わかった。これがエラーの原因だ。では、どう対応したらいい?

Delphiに埋め込む前のPythonスクリプトの状態で、自動採点部分のみを動かして対応策を考える。

スクリプトを入れたフォルダの名前を「採点プログラム」に変更

スクリプトを走らせる。すると「期待通り」にエラーが発生。

Traceback (most recent call last):
  File "E:\採点プログラム\xxx.py", line 138, in <module>
    model.load()
  File "E:\採点プログラム\xxx.py", line 58, in load
    self.interpreter = tflite.Interpreter(model_path=self.model_file)
  File "E:\WPy64-3980\python-3.9.8.amd64\lib\site-packages\tensorflow\lite\python\interpreter.py", line 455, in __init__
    _interpreter_wrapper.CreateWrapperFromFile(
ValueError: Could not open 'E:\採点プログラム\saved_model.tflite'.
[Finished in 5.174s]

まずい・・・。エラーはTensorFlowの内部で起きている・・・
この壁は、今の僕の実力では越えられない・・・。

また、やっちゃった・・・
Python環境と全角文字の組み合わせは、要注意!だったはずなんだ・・・
すっかり、そのことを忘れてた・・・

2ヵ月半もかけて、ここまでたどり着いたのに
まさか、最後に、こんな・・・

もしかしたら・・・と思い、読み込み時の文字コードをUTF-8に指定して見たけれど。

with open(os.path.join(model_dir, "signature.json"), "r", encoding="utf-8") as f:

状況は変わらず・・・。つまり・・・

Pathに日本語があると
僕のプログラムは起動しない・・・

4.それでも動かしたい僕は・・・

現在の僕の実力では、この問題に対応できないことはわかった。もっとよくなるためには勉強しなければいけないけれど、それには時間が必要だ。

とりあえず、今、できることは何か?

答えはわかっている。

Pathに日本語(全角文字)が
なければイイ!

この世から日本語を消す・・・

壮大すぎる・・・、実現不可能な挑戦だ。
第一、僕は日本語を愛している。それを消し去るなんて出来るわけがない。
そんなことをするくらいなら、僕が死んだ方がいい。

でも、この前、とても大切に思う人から「長生きしてください」って、言ってもらえた
今すぐ死んでもいいと思うほどうれしかった。だから、僕は、まだ死ねない。

そう、生きる「ちから」の、ある限り・・・

では、どうする?

そうだ。プログラム起動時にPathをチェックするんだ・・・。
Pathをチェックして全角文字が含まれていないことを確認できたら、PythonEngineを初期化する。もし、Pathのどこかに全角文字があれば、メッセージを表示して「Pathから全角文字を取り除いてもらえるよう」ユーザーにお願いすればいい。

ひとに何かをお願いするのは、大嫌いだけれど・・・これだけは仕方ない。

もっとよくなって、TensorFlowの内部をなんとかできるようになるまでは・・・

5.全角文字を探し出すには?

文字列に含まれる全角文字を探すには、どうしたらいいか?

これまでの学びの中でByteType関数を使う方法を勉強済みだ。早速、僕はFormCreate手続きのいちばん最初に、Pathに全角文字が含まれているかどうか、確認するプログラムを追加した。

implementation

uses
  System.AnsiStrings;
  //System.AnsiStringsは、起動Path中の全角文字の有無を調査するために追加

procedure TFormCollaboration.FormCreate(Sender: TObject);
var
  i,j:integer;
  ・・・ 略 ・・・

  //引数に指定した文字列が半角か全角かチェックする
  function OnlySingleByte(const S: AnsiString): Boolean;
  var
    i: Integer;
  begin
    for i:=1 to Length(S) do
      //System.SysUtilsのByteType関数(非推奨)が呼び出される
      //if ByteType(S, i) <> mbSingleByte then
      //usesにSystem.AnsiStringsが必要
      if System.AnsiStrings.ByteType(S, i) <> mbSingleByte then
      begin
        Result := False;
        Exit;
      end;
      Result := True;
  end;

begin

  //起動Path中の全角文字の有無を調査
  //[dcc64 警告]データ損失の可能性がある文字列の暗黙のキャスト ('string' から 
  //'AnsiString')を表示しないようにAnsiString()で明示的に型キャストした
  if not OnlySingleByte(AnsiString(Application.ExeName)) then
  begin
    MessageDlg('AC_Reader.exeへのPath中に全角文字が含まれていないか、'+
    '確認してください。'+
    '全角文字が含まれているとPythonEngineの初期化作業を行うことができません。'+#13#10+#13#10+
    '全角文字を含まないPathに変更後、再度実行してください。',mtError,[mbOk],0);
    //プログラムの終了
    //Close;  //止まらない!
    Application.ShowMainForm:=False;
    Application.Terminate;  //停止するが、エラーが発生する
    //halt;  //停止するが、エラーが発生する
  end else begin
    //カーソルを待機状態にする
    Screen.Cursor := crHourGlass;

    ・・・ 略 ・・・

  end;
end;

テスト用にReleaseフォルダの名前を変更する。

「あ」をフォルダ名の末尾に追加

で、フォルダを開けて、exeを直接ダブルクリックして実行。

意図した通り、Path中の全角文字「あ」をつかまえた!

しかし、そのあとがよくない。Close命令では、なぜか止まらず、Application.Terminateやhaltではプログラムは停止するが、次のように実行違反のエラーが発生する。

原因のわからないエラーが発生!

これには、ちょっと困った・・・。どう処理しようか、しばし考える。で、確か、エラーメッセージの表示を抑制する方法があったことを思い出す。階層化テキストエディターのNanaTreeに記録してある方法で、エラーメッセージの表示を代替するメッセージボックスを表示し、エラーメッセージの表示を出さないように設定すればいいと気づく。

重要! エラーの原因がわからないので、処理としては正しくないと思います!

NanaTreeに記録してあるTipsは、次のWebサイトにあった記事を参考にして作成したもの。このTipsを参照しながら、コードを次のように変更。

エラーメッセージを示すダイアログの表示の制御

https://www.gesource.jp/weblog/?p=7116
  private
    //エラーメッセージを表示しない
    procedure ExceptionEvent(Sender: TObject; E: Exception);

implementation

uses
  System.AnsiStrings;
  //System.AnsiStringsは、起動Path中の全角文字の有無を調査するために追加

procedure TFormCollaboration.ExceptionEvent(Sender: TObject; E: Exception);
begin
  //ShowMessage('Event OnException: ' + E.Message);  //<-このかわりに下を表示
  MessageDlg('AC_Reader.exeへのPath中に全角文字が含まれていないか、確認してください。'+'全角文字が含まれているとPythonEngineの初期化作業を行うことができません。'+#13#10+#13#10+
  '全角文字を含まないPathに変更後、再度実行してください。', mtError, [mbOk] , 0);
end;

procedure TFormCollaboration.FormCreate(Sender: TObject);
var
  i,j:integer;

  //半角か全角かチェックするプログラム
  function OnlySingleByte(const S: AnsiString): Boolean;
  var
    i: Integer;
  begin
    for i:=1 to Length(S) do
      //System.SysUtilsのByteType関数(非推奨)が呼び出される
      //if ByteType(S, i) <> mbSingleByte then
      //usesにSystem.AnsiStringsが必要
      if System.AnsiStrings.ByteType(S, i) <> mbSingleByte then
      begin
        Result := False;
        Exit;
      end;
      Result := True;
  end;

begin

  //エラーメッセージを示すダイアログの表示の制御
  Application.OnException := ExceptionEvent;

  //起動Path中の全角文字の有無を調査
  //[dcc64 警告]データ損失の可能性がある文字列の暗黙のキャスト ('string' から 'AnsiString')を
  //表示しないようにAnsiString()で明示的に型キャストした
  if not OnlySingleByte(AnsiString(Application.ExeName)) then
  begin
    //ExceptionEvent手続きへ、そのままコピペ
    {
    MessageDlg('AC_Reader.exeへのPath中に全角文字が含まれていないか、確認してください。'+'全角文字が含まれているとPythonEngineの初期化作業を行うことができません。'+#13#10+#13#10+
    '全角文字を含まないPathに変更後、再度実行してください。',mtError,[mbOk],0);
    }
    //プログラムの終了
    Application.ShowMainForm:=False;
    Application.Terminate;
  end else begin
    ・・・ 略 ・・・
  end;
end;

これでエラーメッセージは表示されなくなる・・・

6.まとめ

(1)TensorFlowを利用する際は、exeへのPathに全角文字があってはいけない。
(2)自力でエラーを回避できないので、Path中の全角文字の有無を調査して対応。
(3)エラーメッセージを表示しない方法も学んでおくと役に立つかも?

7.お願いとお断り

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

『有終』

日本語には、たまらなく美しい言葉がある。
僕は、言葉たちに触れる度、いつも、その美しさを思う。

有終。

この言葉は、ことのほか、美しく、哀しみに溢れて、そして儚い。

卒業の日はいつも・・・
この言葉を、思い出してきたけれど・・・

きみは、今日まで、どれほどの悲しみにたえてきたことだろう。

今日、きみの話をきいて・・・
僕の理解の、はるか向こう側に、きみの深い悲しみがある気がした。

それでも、きみは、今日へ向かって、精一杯に歩いたんだね。
それだけは、僕にも理解できたよ。

きみは決して負けなかった。
ほんとうに、よく、がんばったね。

目指したことの終わり。その終わり方が「大切」なのはもちろんだね。
では、目指したことの、終わりへ向かって、どう「歩くか」。
その「歩き方」を、この冬の経験から、きみは確かに学んだはずだ。

若いんだ。一度くらい、がむしゃらになってもいい。
思い切り転んだっていい。口惜しさに、涙することが・・・ あっても、いい。

でも、最後に努力が報われて、
笑顔のきみに会えて、ほんとうに・・・、本当に・・・、よかった。

合格。心から、おめでとう。
僕自身、壊れそうなくらい、うれしい。

これだけの経験をしたんだ。
失くしたものよりも、きみが得たものは大きいはずだ。
それを『 何よりも 』大切にして、これからは、もっと きみらしく 歩くんだ。

ものごとには、必ず、終わりがある。
それが「いつ」訪れるのか、多くの場合、それもまた、見えている。
明日からは、きみが得たものが、そこへ向かう「歩き方」を きみに教えてくれるはずだ。

やがて、きみは、レディになる。
そう・・・ お父さんが愛した、きみのお母さんのような、すてきな、レディに。

自分も、それから周りの人たちも・・・
みんながしあわせになれる歩き方を、レディは最初に考える。

レディになったきみに会えないのは、とても残念だけれど・・・、
ひとつだけ、信じ切れることがあるから・・・

その時、きみのとなりには、きみを心から愛してくれる、
すてきな彼が、必ずいてくれるはずだ・・・。

たとえ、レディになったきみであっても・・・

彼の前でなら、もう表情を隠さなくてもいい。
彼の前でなら、もう無理して微笑まなくてもいい。
そう・・・、彼の胸でなら、安心して声をあげて泣いていい。

きみのほんとうを、心を、
これまできみにあったことのすべてを包む・・・
彼の優しさと、きみへの愛を、僕は心から信じている。

さっきは、必ずと言ったけれど・・・
僕の中には、唯一、終わることのない、永遠もある。

きみと僕との運命の線は、ここで交差し、再び離れ、日々その距離を増し、
もう二度と交わることはないだろう。それでも・・・。

今日、うまく言葉にできなかったけど、
これが、きみに、伝えたかったことなんだ・・・

きみと、きみのお父さん、弟さん、おばあちゃん、
そして・・・、きみのお母さん

きみと、きみにつながるすべての人のしあわせを願う
この想いは、永遠なんだ。

僕の中で、間違いなく、永遠なんだ。
終わりなんて、ない。

だから、空を見上げるたびに・・・
きみのしあわせを願い、祈っている。

いつも、そして

いつまでも ・・・

Recognize handwritten katakana characters No,4

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

前回の記事で作成した手書きカタカナ文字「アイウエオ」の学習モデルを、My手書き答案採点プログラムで利用できるようにした。自動採点用のGUIを作成して、実際の手書き文字をどの程度正しく認識できるか検証。ついでに、ふと思い立って、「〇」記号と「×」記号の学習モデルも作成。こちらについても、正しく認識できるかどうか、実験してみた。結果は「アイウエオ」、「〇×」とも100%正しく認識することはできなかったが、よく考えれば、リアルな文字認識にチャレンジするのは今回が初めて。ここまでが長かったので、自分的には終了感満載だったけど、ここからが本当のチャレンジの始まりなんだ・・・と気づく。これまでにやってきたことは、言わば準備作業。現段階で、僕の「自動採点」は、採点作業の「補助」くらいには、使えるんじゃないか・・・と。

1.それは「イ」じゃないんですけど・・・問題への対応を考える
2.プログラムに自動採点のGUIを追加
3.自動採点を実行!(その1)
4.自動採点を実行!(その2)
5.〇×記号の学習モデルを作成
6.〇×記号の解答も自動採点
7.FormCreateでPythonEngineを初期化
8.まとめ
9.お願いとお断り

1.それは「イ」じゃないんですけど・・・問題への対応を考える

まずは、前回の記事で最後に紹介した「問題」への対応から。

前回は、学習モデルの性能を確認するため、PCの画面にマウスで描いたカタカナ文字をLobeで作成したMy学習モデルが「どの程度正しく認識できるか」を試すプログラムをDelphiで作成して検証(文字認識部分は内部に埋め込んだPythonスクリプトで実行)。

My学習モデルは、上の文字すべてを正しく認識してみせた

あまりにもGoooooooooooooooooooooooooooooooooood!な結果に、この結果にたどり着くまでの長かった道のりを思い出し、本人涙ぐむシーンもあったが・・・、スキャナーでスキャンした画像にみられるシミや汚れへの反応をみるため、試しに画面をワンクリックして「点」を入力し、それを認識させてみたところ・・・

信頼度は99.9%・・・でもLobeさん、それ、「イ」じゃないと思うんですけど・・・。

このあまりにも楽しい結果に、今度は涙ぐむほど大笑い。さすがMy学習モデル。夏休みの自由研究レベルをしっかりと維持しています・・・。

で、どう対策したか?

さすがにこのままでは実戦に投入できないので、文字画像に「大津の二値化」を適用した後、OpenCVのcountNonZero()関数を利用して、全ピクセルのうち、値が0(=黒)でないピクセルの合計を求め、画像中の白黒の面積を計算。イロイロ、テストした結果、上記の画像で白面積(=文字面積)が1.5%より大きい画像を「文字情報あり」と判断して、輪郭検出するようスクリプトを修正。これで、この問題は無事クリア☆

# 読み込んだイメージにOpenCVのcountNonZero関数を適用、白面積を計算。
wPixels = cv2.countNonZero(img)

※ 上の画像では、文字が「白」なので白面積を計算している。

2.プログラムに自動採点のGUIを追加

My手書き答案採点プログラムに自動採点のGUIを付け加えるにあたり、プログラムの64ビット化(プログラムに同梱したembeddable PythonにインストールしたTensorFlowは64ビット版しか存在しないため)と、解答欄矩形の自動検出機能の実装で不要になったGUIの整理を行った。で、空いたスペースに自動採点のGUIを作成。

TensorFlowに合わせ、プログラムは64ビット化☆
My手書き答案採点プログラムを実行中の画面

操作パネルのGUIを32ビットバージョンから、次のように変更。準備段階でしか使わなかった部品があらかた消えて、(自分的には)画面がかなり「すっきり」した気が。

解答欄矩形の手動設定関連のGUIを削除して、空いたスペースに自動採点のGUIを作成

3.自動採点を実行!(その1)

(1)学習モデルを指定

学習モデル「ア行」を選択する

選択肢だけは、たくさん用意してあるけど、現在利用できるのは「○×」と「ア行」のみ。(「カ行」以降は、もしかしたら永遠に利用できないカモ・・・)

自前で機械学習の訓練用データを作成するのは、本当に、本当に、本当に、すーぱーたいへん! 答案をスキャンした画像から、文字画像の切り抜き&クリーニング作業を、またン千枚もやるかと思うと・・・。

ポキッ あっ! 心の折れた音が。

(2)正解ラベルを指定

正解ラベルを選択

設問ごとに、正解ラベルを選択。学習モデルの識別結果と、ここで選択指定した正解ラベルを比較して、〇・× を判定。で、得点欄に入力(選択)した値を採点記号とともに解答欄の指定位置に表示する。プログラム起動後、初回の実行時にはPython Engineの初期化に数秒かかるが、2回目以降、採点自体は35枚を1秒程度で処理できた☆ だから処理時間に起因するストレスはまったく感じない。Python Engineの初期化だけ、あとで何とかしよう・・・。

(3)自動採点を実行

解答用紙のサンプル(これを35枚書いた☆)

「アイウエオ」の文字データは、集めたサンプルに似せて全部自分で手書きしたもの。文字の大小、濃淡、線の太さ等なるべく不揃いになるようにした(つもり)。解答用紙は新品はもったいないので、職場にあった反故紙の裏面に解答欄を印刷して利用。ホントは、もっとたくさん作成するつもりだったんだけど、35枚書いたところでなんか用事が入り、もうその後は作業を再開する気が失せて、作業を放棄。そのような理由から、とりあえず35枚で実験することに。

ウソ偽りのない採点結果の一例は、次の通り(「ア」を正解とした場合)。

サンプルを真似たアイウエオを書いて、My手書き答案採点プログラムで自動採点した結果

自動採点へのチャレンジを始めたのは2022年の12月下旬だから、ここにたどり着くまでに2ヵ月かかっている・・・。途中、(もはや、これまで)みたいなシーンも何度かあったけど、そのたびに『誰も待ってないけど、オレはやるぞ』と自分自身を叱咤激励。

「オレはやるぞ」と言えば・・・

高校生だった頃、芸術選択はめったにない「工芸」で、すごく楽しくて・・・。焼き物の時間に、みんなは指示された通り、湯飲みとか作ってたけど、僕は「オレはやるぞ!」って文字を刻んだ粘土板(看板)を岩石風の土台に張り付けた、何の役にも立たないモニュメントを製作して、大満足。先生は笑いながらも、僕の作品(?)を炉のすみっこに入れて焼いてくださった。高校生活、最高だったなー☆

解答欄画像の切り抜きとは別に、プログラム内部では(罫線の影響を排除して)、個々の解答欄画像中の文字をOpenCVの輪郭検出で探し出し、幅64×高さ63で切り抜いて、次に示すような画像データを作成している。

解答欄画像から輪郭検出で切り抜いた文字画像

なんで「イ」だけ「字の一部分だけが取得」されてるのか、そこは???なんだけど、その他の文字は、比較的よく検出できているのではないか・・・と思うのですが、いかがでしょう?

輪郭検出のスクリプトは、次のサイトに紹介されていたものを参考に、罫線が入らないようにするなど、様々に工夫を加えて作成。(このスクリプトの作者の方に、心から厚く御礼申し上げます)

[AIOCR]手書き日本語OCRデータセットを自動生成する[etlcdb]

https://www.12-technology.com/2021/11/aiocrocretlcdb.html

実際にキカイがどんな画像を見ているのか、気になったので調べてみると・・・

切り出し処理の途中の画像を保存してみた

そのうちの1枚を拡大してみたところ。

けっこう汚れている・・・

この二値化の処理には、また別のWebサイトにあった次のコードを当てたんだけど・・・

thresh = 
cv2.adaptiveThreshold(blur,255,cv2.ADAPTIVE_THRESH_MEAN_C,cv2.THRESH_BINARY,11,2)

これは「濃淡の大きな画像に対しては大変有効な処理」のようだけれど、僕の用意した文字画像の処理には向かなかったようで、そこで、ここは思い切って次のように変更。

threshold = 220
ret, thresh = cv2.threshold(blur, threshold, 255, cv2.THRESH_BINARY)

上記のように変更した結果、キカイが処理の途中で見ている画像は・・・

かなりキレイになった☆

さっき拡大した画像は・・・

おー!キレイになった。実にイイ感じ!

左の方に、小さなシミがまだ残っているけど、これは次のようにして輪郭として検出しないように設定。

contours = cv2.findContours(thresh, cv2.RETR_LIST, 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 < '+cmbStrHeight.Text+': <- Delphi埋め込み用
    if h < 30:
        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

まとめとしては(自分的には)、「ア」のみについて見れば、この設問20問のうち、15問正解で正解率は75%と決して高くはないけれど、「ア」以外のデータはちゃんと見分けているから、ほんとに満足。悔しい気持ちとか、全然、湧いてこない。2022年末のチャレンジで正解率91%だった時は、もう口惜しさの塊みたいになってたのに。なんで全然悔しくないんだろー? 人間ってほんと不思議。

まぁ、これに「自動採点」と銘打って、誰かに販売してお金もらったら完全な詐欺だと思うけど、『発展途上の自動採点モード付き手書き答案採点補助プログラムです。こんなんでも、もし、よかったら、使ってくださいねー! 』・・・というスタンスで仲間にタダでプレゼントする分には(合計点自動計算機能や返却用答案印刷機能等、採点プログラムとしての必須機能が完全に動作すれば)何の問題もないかと・・・。

さらに自動採点と言いながらも、採点の最後にヒトのチェックが必ず必要なのは言うまでもないので、その時、キカイが間違えた5問については、ヒトが「違うよー☆」ってやさしく訂正してあげれば、それこそヒトとキカイの美しい協働・・・じゃないのかなー☆☆☆

いいえ。
そういうのを世間一般には
「言い訳」と言います。

ってか、ここまでは全部、自動採点の準備作業で、ここからが本質的には「始まり」・・・なんだけど、自分的には、かなりヘトヘトになって終了感満載・・・

もしかして、ぼくは、とほーもないことにチャレンジしているのではないか? と、コトここに至って初めて気づく・・・

だって、「アイウエオ」と「〇×」のたった7つPCに教えるのに2ヵ月かかったんだよ。「点くのが遅い蛍光灯のようなお子さんですね」と担任の先生に評された(母親談)という、小学校低学年の児童生徒だったぼくでも、アイウエオくらいは半日で覚えたぞ・・・。

あぁ カー カー キクケコ
サシスセソー

まだ いっぱい あるー☆

4.自動採点を実行!(その2)

文字や記号が印刷された解答欄への対応も、実際問題としては必須。
例えば、次のような画像。

上に示したスクリプトがうまく動作してくれるとイイのだけれど。そう思いながら祈るような気持ちで、上の画像の設問に対して自動採点を実行・・・(正解ラベルは「エ」)。

一部、ヘンなところもあるけど、だいたいうまく切り出せた☆

で、結果は?
なんと100%正解。もしかして、夏休みの自由研究レベルじゃなかった?
予想外の成果に、僕はもう、大満足☆

設問番号「(4)」が解答欄にあっても自動採点可能でした!

スキャナーで読み込む際の縮小率とかの問題は未検証だけど、9ポイント程度の大きさで設問番号等は印刷してもらえば、だいたいOKのようだ。手書き文字が小さすぎる場合はどうしようもないけれど、それは事前に「ちいさな文字で解答してはいけません!」と案内しておけば、ある程度は防げるハズ。それでも、ちいさな文字で書くヒトは「チャレンジャー」と見なして・・・

5.〇×記号の学習モデルを作成

2月末、自動採点のGUIを作成しようと、いつもの通り、午前2時に起きて(ジジィは朝が好き / でも出勤はいちばん遅い)「さぁ、やるか」と思った時、なぜか前の晩、眠るときにふと、〇×記号の自動採点用の学習モデルならすぐ作れるんじゃないか・・・と思ったことを思い出し、GUI作りは後回しにして、朝までの4時間で〇×記号の学習モデルを作成することに、当日第1部の予定を変更。

「〇」記号は、ETLデータベースにあったような気がしたので、まずはこちらから。

ETL1の「48」フォルダに1423枚のお宝画像が入っていた!

解凍? してあったETL文字データベースの文字・記号が入ったフォルダを一つずつ開けて内容を確認。「48」のフォルダ内に目的の画像を発見。これが1423枚もあれば、訓練用データとしては十分だろうと思い、このデータを機械学習用に加工。

まず、すべてのファイルが連番になるよう、リネーム。

import os
import glob

path = r".\(Pathを指定)\maru"
files = glob.glob(path + '/*')

files = glob.glob(path + '/*')

for i, f in enumerate(files):
    # すべてのファイルを連番でリネームする
    os.rename(f, os.path.join(path, "maru"+'{0:04d}'.format(i) + '.png'))
ファイル名が連番になるようリネーム

次に「輝度反転」。

# 輝度反転
from PIL import Image
import numpy as np
from matplotlib import pylab as plt

for i in range(1423):

    # 画像の読み込み
    im = np.array(Image.open(r".\(Pathを指定)\maru"+r"\maru"+"{0:04d}".format(i) + ".png").convert("L"))

    # 読み込んだ画像は、uint8型なので 0~255 の値をとる
    # 輝度反転するためには、入力画像の画素値を 255 から引く
    im = 255 - im[:,:]

    print(im.shape, im.dtype)

    #保存
    Image.fromarray(im).save(r".\(Pathを指定)\maru"+r"\r_maru"+"{0:04d}".format(i) + ".png")
輝度を反転

さらに、二値化する。
もしかしたら、上の輝度を反転させた画像のまま、機械学習を実行してもいいのかも? とチラっと思ったが、一度、最も極端な方向(=二値化で白黒にする)に振ってみて実験し、その結果を見てから判断することに決めて、二値化を実行。

import cv2
import os
import glob

path = r".\(Pathを指定)\maru_nichika"
files = glob.glob(path + '/*')

for f in files:
    # 読み込み
    im = cv2.imread(f)

    # グレースケールに変換
    im_gray = cv2.cvtColor(im, cv2.COLOR_BGR2GRAY)

    # 大津の二値化
    th, im_gray_th_otsu = cv2.threshold(im_gray, 0, 255, cv2.THRESH_OTSU)

    # 書き込み
    cv2.imwrite(f, im_gray_th_otsu)
二値化

二値化した画像中に訓練用データとして不適切な画像がないか、念のため、チェックしたところ、いくつかの不適切なデータを発見したため、それらは削除した。

訓練用データとして、不適切と思われる画像その①(いちばん左の画像は複数枚存在する)
訓練用データとして、不適切と思われる画像その②

これで「〇」記号の訓練用データは完成。次は「×」記号。

残念ながら、「×」記号のデータはETL文字データベースにはないようだ・・・。しかし、代替できそうなデータを「43」のフォルダに発見。それは「+」記号。これを45度ほど右か左へ回転させてあげれば、「×」に見えるんじゃないか? と・・・。

「+」記号を1444枚発見!

画像の回転スクリプトは・・・

from PIL import Image
import os
import glob

path = r".\(Pathを指定)\batsu"
files = glob.glob(path + '/*')

for f in files:
    # ファイルを開く
    im = Image.open(f)

    # 回転
    im_rotate = im.rotate(45)

    # グレースケールへ変換
    img_gray = im_rotate.convert("L")

    # 画像のファイル保存
    img_gray.save(f)
「×」記号ではあるけど、倒れかかった十字架のようで、なんとなく違和感がある・・・。

普通の「×」記号は、「\」が短くて、「/」が長い。上の画像は、ことごとくそれが逆だから違和感を覚えるんだと気づき、さらに90度回転させる。

イイ感じ!

で、「〇」記号と同様に、リネーム & 輝度反転させて、二値化。

八角形になっちゃったデータが複数あるので、これは全部削除した。

次は、Lobeで機械学習を実行。「〇:maru」と「×:batsu」だから「mb」という名前のフォルダを作成。「〇」記号はフォルダ名を半角数字の「0:ゼロ」、「×」記号はフォルダ名を半角数字の「1」に設定(認識結果の正解ラベルが 0 or 1 で返るようにするため)。

正解ラベル名のフォルダを作成して、訓練データをその中へコピー。

データが準備できたので、Lobeを起動。機械学習を実行。最終的に用意できた訓練データは「〇」記号が「1406」、「×」記号が「1323」。ここまで、なんだ・かんだで3時間半。さらに待つこと30分。東の空が明るくなる頃、ついに「〇×」記号の学習モデルが完成した。シャワーを浴びて出勤。さぁ 今日も第2部の始まりだー☆

6.〇×記号の解答も自動採点

プログラムの中では、次のようにして、採点対象を切り替えている。

  strScrList.Add('    if 黒の面積 > 1.5:');  # 白->黒へ訂正(20230306)
                          ・・・画像ファイルへのPathを設定等・・・
  strScrList.Add('        if os.path.isfile(img):');
                              ・・・画像ファイルを開く・・・
  if cmbAS.Text='○×' then
  begin
    strScrList.Add('            if outputs["label"] == "0":');
    strScrList.Add('                var1.Value = str("○") + "," + ・・・ 
    strScrList.Add('            elif outputs["label"] == "1":');
    strScrList.Add('                var1.Value = str("×") + "," + ・・・ 
    strScrList.Add('            else:');
    strScrList.Add('                var1.Value = str("Unrecognizable")');
    strScrList.Add('        else:');
    strScrList.Add('            var1.Value = str("Could not find image file")');
    strScrList.Add('    else:');
    strScrList.Add('        var1.Value = str("XXX")');
  end;

  if cmbAS.Text='ア行' then
  begin
    strScrList.Add('            if outputs["label"] == "0":');
    strScrList.Add('                var1.Value = str("ア") + "," + ・・・
    strScrList.Add('            elif outputs["label"] == "1":');
    strScrList.Add('                var1.Value = str("イ") + "," + ・・・
    strScrList.Add('            elif outputs["label"] == "2":');
    strScrList.Add('                var1.Value = str("ウ") + "," + ・・・
    strScrList.Add('            elif outputs["label"] == "3":');
    strScrList.Add('                var1.Value = str("エ") + "," + ・・・
    strScrList.Add('            elif outputs["label"] == "4":');
    strScrList.Add('                var1.Value = str("オ") + "," + ・・・
    strScrList.Add('            else:');
    strScrList.Add('                var1.Value = str("Unrecognizable")');
    strScrList.Add('        else:');
    strScrList.Add('            var1.Value = str("Could not find image file")');
    strScrList.Add('    else:');
    strScrList.Add('        var1.Value = str("XXX")');
  end;

正解を「〇」記号として、自動採点してみた結果は・・・

何とも理解に苦しむ摩訶不思議な採点結果が2個あるが、その他は良好と言っていい結果になった。

空欄であるにもかかわらず、正解となっている画像をよく調べてみると・・・

画像の中に小さなL字型のシミを発見

高さが30未満である場合は、輪郭検出しない設定のはずなんだが・・・。他には何にも見つけられないので、原因はコレしか考えられない。いったいナニがどうなっているんだろう??? 結局、コレは謎のままに。

同じデータに対して、正解を「×」記号として自動採点すると・・・

10個めのデータが呪われている気が・・・

10個目のデータの切り抜き画像を調べてみると・・・

微妙なトコロで、画像が欠けている・・・

どうやら元画像の「色が薄い」 or 「画像の線が太い」と問題が発生する傾向が強い気がしてきた。僕はこの実験に「えんぴつ」を使ったが、普通、試験時解答に使うのはシャーペンだから線が太くなることはあまり考えられない、むしろ、なるべく濃く書くことを注意事項に入れるべきかもしれない。なお、幅が狭くなっているように見えるのは、画像を強制的に幅64×高さ63にリサイズしているためだ。

「アイウエオ」同様、「〇×」記号の自動採点も残念ながらヒトの最終チェックがどうしても必要だという結果になった。が、こちらも「採点補助」程度には使えるぞ。

7.FormCreateでPythonEngineを初期化

何度も実験していると、プログラム起動後、初回の自動採点実行時、Python Engineの初期化に数秒を要するところを何とかしたくなってきた。これは起動後、毎回必ず発生する現象なので、マウスカーソルを待機状態にするとか、そういうレベルで誤魔化せる話ではない。なるべくユーザーの気づかないところで(ソッと)初期化してしまわなくてはならない。

いちばんイイのはプログラム起動時だ。マークシートリーダーを作った時にもこのことが気になったため、スプラッシュ画面を表示して(画像は自前で準備した画像ではなく、Webで販売している画像を購入して使用するという暴挙に出た)、その裏側で初期化作業を行うよう設定。今回も、このやり方を踏襲。

(1)初期化に使う画像をリソースに準備

Python Engineを初期化するには画像が必要なので、専用画像をリソースに準備。

心をこめて製作したmaru.png
マークシートリーダー用のPython Engine初期化用画像もまだ残ってた!

(2)初期化処理を実行

プログラム起動時、FormCreate手続きの中で、次のように初期化処理を実行。

まず、リソースに埋め込んだ初期化用画像ファイルを再生。

    //リソースに読み込んだ初期化用ファイルを再生

    //ファイルの位置を指定
    strFileName:=ExtractFilePath(Application.ExeName)+'imgAuto\tmp\maru.png';

    //ファイルの存在を確認
    if not FileExists(strFilename) then
    begin
      //リソースを再生
      with TResourceStream.Create(hInstance, 'pngImage_1', RT_RCDATA) do
      begin
        try
          SaveToFile(strFileName);
        finally
          Free;
        end;
      end;
    end;

次に、Python Engineそのものを初期化。

    //embPythonの存在の有無を調査
    AppDataDir:=ExtractFilePath(Application.ExeName)+'Python39-64';

    if DirectoryExists(AppDataDir) then
    begin
      //フォルダが存在したときの処理
      PythonEngine1.AutoLoad := True;
      PythonEngine1.IO := PythonGUIInputOutput1;
      PythonEngine1.DllPath := AppDataDir;
      PythonEngine1.SetPythonHome(PythonEngine1.DllPath);
      PythonEngine1.LoadDll;
      //PythonDelphiVar1のOnSeDataイベントを利用する
      PythonDelphiVar1.Engine := PythonEngine1;
      PythonDelphiVar1.VarName := AnsiString('var1');
      //初期化
      PythonEngine1.Py_Initialize;
    end else begin
      //MessageDlg('Python実行環境が見つかりません!',mtInformation,[mbOk], 0);
      PythonEngine1.AutoLoad := False;
    end;

最後に初期化用画像を読み込んで、1回だけ自動採点を実行する。

    //スプラッシュ画面を表示してPython Engineを初期化
    try
      theSplashForm.Show;
      theSplashForm.Refresh

      //Scriptを入れるStringList
      strScrList := TStringList.Create;
      //結果を保存するStringList
      strAnsList := TStringList.Create;

      try
        strScrList.Add('import json');
        ・・・略(自動採点用のPythonスクリプトをStringListに作成)・・・

        //0による浮動小数除算の例外をマスクする
        MaskFPUExceptions(True);
        //Execute
        PythonEngine1.ExecStrings(strScrList);
        
        //先頭に認識した文字が入っている
        if GetTokenIndex(strAnsList[0],',',0)='○' then
        begin
          //ShowMessage('The Python engine is now on standby!');
          theSplashForm.StandbyLabel.Font.Color:=clBlue;
          theSplashForm.StandbyLabel.Caption:='The P_Engine is now on standby!';
          theSplashForm.StandbyLabel.Visible:=True;
          Application.ProcessMessages;
          //カウントダウン
          for j:= 2 downto 1 do
          begin
            theSplashForm.TimeLabel.Caption:=Format('起動まであと%d秒', [j]);
            Application.ProcessMessages;
            Sleep(1000);
          end;
        end else begin
          ShowMessage('Unable to initialize python engine!');
          MessageDlg('Auto-scoring is not available!'+#13#10+
          'Please contact your system administrator.',mtInformation,[mbOk],0);
        end;

      finally
        //StringListの解放
        strAnsList.Free;
        strScrList.Free;
      end;

    finally
      theSplashForm.Close;
      theSplashForm.Destroy;
    end;

これで「自動採点GroupBox」内の「実行」ボタンをクリックした際の処理が、ほぼ待ち時間なしで行われるようになった。これをやっておくのと、おかないのとでは、プログラムの使用感がまったく異なってくる・・・。上記のプログラムの for j := 2 downto 1 do 部分を「ムダ」だと思う方もいらっしゃるかもしれませんが、「画像の使用権を購入」してまで表示したスプラッシュ画面なので、せめて2秒間だけ!必要以上に長く表示させてください・・・。

8.まとめ

準備に2ヵ月を要したが、なんとか手書きカタカナ文字の自動採点まで到達。結果は自分的には概ね満足できるものであったが、「実用に適するか」という点では、まだまだブラッシュアップが必要。今回の実験で得たことは、学習モデルを適用する「文字画像の切り抜き精度」の重要性。Lobeで作成した学習モデルは間違いなく優秀。その性能を遺憾なく発揮させる「場」を、僕は準備・提供しなければならない。これこそが今後の課題。

あいん つばい どらい
唯 歩めば至る・・・

コトここに至ってようやく・・・
これは、とほーもないチャレンジだと気づいたけれど。

もう行くしか ない 。
僕も、プログラムも、きっともっとよくなれる。

よくなるんだ!

9.お願いとお断り

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

本記事内で紹介させていただいた実験結果は、あくまでも私自身が用意した文字データに対してのものであり、別データで実験した場合、同様の結果が得られることを保証するものではありません。

Recognize handwritten katakana characters No,3

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

様々な経緯から、自前での手書き文字認識プログラムの作成(と言いつつ内容はほぼ写経)を断念した僕は、Microsoft社がBeta版として公開している画像分類器「Lobe」を使ってカタカナ「ア」~「オ」の学習モデルを作成。その性能をLobeが生成したPythonスクリプトで確認。Lobeの素晴らしい実力を知った(前回の記事)。今回は、DelphiでGUIを作成し、Object Pascalに埋め込んだPython Script内から、Lobeで作成した学習モデルのさらなる発展型(?)を呼び出して、手書きカタカナ文字認識(ただし、文字は「アイウエオ」の5文字に限定)に挑戦。

【学習モデルのさらなる発展型(?)を作った理由】

前回作った学習モデルでは、なぜか「ア」の認識率が異様に低いことが実験を繰り返す中で判明。そこで、これを改善するため、学習データを増やして学習モデルを再作成(アイウエオ各700文字→1700文字へ)。ただし・・・僕は素人も素人。機械学習のイロハも知らない、アタマの中は・・・言わば「機械学習のファンですー☆」みたいなキラキラ状態ですから、理論的な裏付けなどは超絶皆無の、自分史上最高レベルに!いい加減かつ、その場・その場での思いつき(代表例:学習データ数や二値化の閾値など)で素晴らしく適当に作り上げた・・・ホントに、こんなんでいいのか? みたいな・・・オリジナルと呼ぶのも恐ろしいデータセットを使い、言わば「カン」と「思い込み」と「自動採点に賭ける、もはや常人には理解し難い領域に達した・・・」ほとんど「狂気」に近い「異常な情熱」が相まって、ごくわかりやすく表現すれば「バカの一念の塊」のような学習モデルを作成することと、あいなりました。これは、自分的にはこれまでの経緯から見て、必然と言えば必然、当然と言えばあまりにも当然の結果なのですが。

・・・ただ、信じてもらえないカモですが、この学習モデルを使った認識実験の結果自体は、ウソだろー!と叫びたいくらいに・・・

So So So Gooooooooooooooooooooooooooooooooooooooooooooooooooooooood!

終わりよければすべてよし!(日本語には、ほんとに、いい言葉があるなー☆)もちろんこれは、ほとんど全部、Lobeのおかげですが、だからLobe ありがとう! とにかく、この学習モデルは僕の宝物です!

【今回の記事の内容】

1.embeddable pythonに必要なライブラリを入れる
2.TensorFlowを入れる時、パスが長すぎるとエラーが発生
3.Lobeで学習モデルを作る
4.浮動小数点例外をマスクする
5.手書きカタカナ文字をかなり正しく認識できた!
6.まとめ
7.お願いとお断り

1.embeddable pythonに必要なライブラリを入れる

重要 TensorFlowは「64bit環境」にしか入らない!

もしかして「常識」とでも言うべき?、上記の事実をまったく知らない僕は、手持ちの32bit用のembeddable pythonにTensorFlowを入れようと四苦八苦(前回の記事で、SDカード内にインストールして動かしていたWinPythonは64bitバージョンであったので、TensolFlowが32bit環境に対応していないことに、まったく気づかなかった・・・)。

TnsorFlowは64bit環境専用とのこと。32bit環境にインストールしようとすると上記のエラーが発生!

Google先生に質問を繰り返す中で、エラーの真の原因をようやく理解した僕は、64bit版のembeddable pythonのダウンロードからやり直し。そう言えば、いつか、近所を走っていたトラックの屋根にも「イチから出直します」って書いてあったなー☆

https://www.python.org/downloads/windows/

上記ページの「Python 3.9.10 – Jan. 14, 2022」のダウンロードリンク「 Windows embeddable package (64-bit) 」をクリックして入手。

バージョン3.9.10を選んだ理由は、SDカードに入れたWinPythonが「3.9.8」だったので、これに近いバージョンの方がライブラリのインストールも含めて安心だと考えたため。

DLした「python-3.9.10-embed-amd64.zip」を適当なフォルダに解凍して、フォルダ名を「python39-64」に変更。こうしておけば、Pythonのバージョンと動作環境は一目瞭然。

スタートボタンを右クリック。「ファイル名を指定して実行」からコマンドプロンプトを起動して、まず、マークシートリーダー&手書き答案採点用embeddable python(32bit環境)にインストールしたライブラリを調査(64bit環境にも同じライブラリをインストールするため)。

「ファイル名を指定して実行」からコマンドプロンプトを起動する

アクティブなディレクトリをPythonのインストールされているフォルダに変更して、「python -m pip list 」で現在インストールされているライブラリを確認&メモ。

32bit環境のembeddable pythonにインストールしたライブラリを確認

pipは各種ライブラリをインストールするために必須だから最初にインストール。

numpy と opencv-python と pillow がマークシートリーダーや手書き答案採点用にインストールしたライブラリ。Pillowは画像処理用のライブラリだけど、僕はOpenCVへの画像読み込み時にエラーが起きないよう、Pillowで画像を読み込んでから、画像データをOpenCVに渡すようにしている・・・ので、そのために必要になり、後から追加・・・。

追加した理由は、確か・・・OpenCVのimread関数で、全角文字を含んだPathの先にある画像ファイルを読み込もうとすると失敗してしまうことに気づいて、それでこの失敗を回避する方法を調べて、「Pillowで画像を開き、NumPyへ変換して、それからOpenCVの画像形式に変更すればエラーにならない」ことを知り、あわてて実装したような・・・。

psutil は以前、いちばんカンタンなembeddable pythonの使い方をこのブログで紹介した時に入れたもの。

その他は入れた記憶がないので、最初から入っていたんじゃないかと。で、全体で158MBほど(DLして解凍したばかりのembeddable pythonは確か15~16MBくらいだった)。

64bit環境のembeddable pythonにも、これと同じライブラリを入れ、さらにTFLite形式でLobeが書きだした学習モデルを扱えるよう、TensorFlowもインストールする。各種ライブラリを入れるための準備と、ライブラリの実際のインストールのようすは次の通り。

embeddabel pythonに各種ライブラリをインストールするための準備は、上記ページをご参照ください。

pipを使えるようにしてから、64bit環境のembeddable pythonに各種ライブラリをインストール。コマンドプロンプトを起動し、先に作成しておいたpython39-64フォルダをカレントディレクトリにして、次のコマンドを入力。

python -m pip list
pipのパッケージを入れた直後の状態を確認

最初にNumpyを入れる。

python -m pip install numpy
numpyをインストール。Pathに関する警告は気にしない・・・

次にOpenCVを入れる。

python -m pip install opencv-python
OpenCVをインストール。

全角文字入りのPathに対応するため、Pillowも入れる。

python -m pip install pillow
pillowをインストール。ここまでは順調。

2.TensorFlowを入れる時、パスが長すぎるとエラーが発生

次は、いよいよTensorFlowだ。
ちょっとドキドキしながら、おまじないを入力。で、Enterキーを叩く。

python -m pip install tensorflow

しばらくは、順調そうな感じだったけれど・・・、突然、画面ニ赤ヒ、文字ガ、浮カビ・・・。
そう、Pythonのライブラリをいじるときは、いつも・・・これが、どこかで出現・・・

するんだなー もう(>_<)

あろうことか、途中でエラーが発生。まっ、いつもお決まりのパターンだけど・・・

OSError ってナニ?
No such file or directory: ってコトは・・・つまり、とんでもなくながーいPathの最後にある「bufferizableopinterface.cpp.inc」が見えないってコトなのかなー???

いったい、なんじゃろー。。。

とりあえず、検索キーワードを「bufferizableopinterface.cpp.inc 」にして、Google先生にきいてみよう!(こんなんで、挫けてる場合じゃない。ここまでで、夢の実現に、もう二ヶ月かかってる・・・)

トップに表示されたのは、まさに僕と同じエラーで困った方からの質問

あった! あった!
きっと、コレだ。

迷わず「このページを訳す」の方をクリックすると、以下の記事が・・・

問題は、パスの文字数制限です。Windows でこの制限を無効にすることが解決策です。

それかー☆

パスの文字数制限を解除するには、下記のように操作するとのこと。

スタートボタンの右隣りにある「検索」で、「グループポリシーの編集」と入力して、「ローカルグループポリシーエディター」を表示。で、「コンピューターの構成」→「管理用テンプレート」→「システム」→「ファイル システム」とたどって、右側、ファイルシステム「設定」画面の「Win32の長いパスを有効にする」をダブルクリック。

「Win32の長いパスを有効にする」をダブルクリック。

「Win32の長いパスを有効にする」というCaptionのWindowが表示されるので、画面左にある「有効」のラジオボタンをチェックして「OK」をクリック。

Win32の長いパスを有効にする

やったー☆ きっと、もう大丈夫!
で、あらためて、おまじないを実行。

python -m pip install tensorflow

今度は・・・

Collecting tensorflow
  Using cached tensorflow-2.11.0-cp39-cp39-win_amd64.whl (1.9 kB)
Collecting tensorflow-intel==2.11.0
  Using cached tensorflow_intel-2.11.0-cp39-cp39-win_amd64.whl (266.3 MB)
Requirement already satisfied: ・・・
Requirement already satisfied: ・・・
Requirement already satisfied: ・・・
・・・これがたくさん出て・・・

・・・パスを通すか、警告を制御しなさい・・・というお決まりのアレが出て・・・
・・・でも、最後にこれが出たら成功だから、このメッセージは僕にとって吉兆・・・
  Consider adding this directory to PATH or, if you prefer to suppress this warning, use --no-warn-script-location.

Successfully installed tensorflow-2.11.0 tensorflow-intel-2.11.0

やったー!! 入ったー☆☆☆

入ったケド・・・ Python39-64フォルダの容量は全体で「1.27GB」になっちゃった。
ちょっと、デカくない? ってか、ヒトにあげるのは躊躇するくらいの大きさだなー。
不要なファイルの削り方が知りたい・・・。

3.Lobeで学習モデルを作る

実際の処理の流れでいうと、これがいちばん先なんだけれど・・・

前回、機械学習用に準備した手書きカタカナ文字画像(水増しを含む)700枚は、そのまま今回も使用することにして、新たにETL文字データベースのア~オを学習用データに利用することにした。

ただし、そのままでは使用できないので、輝度反転し、さらに二値化して学習用データとする。輝度反転のスクリプトは次の通り。※ファイル名には、例えば「ア」の画像であれば「a0000.png」のように「a+0埋めした4桁の通し番号」を付けている。

# 輝度反転
from PIL import Image
import numpy as np
from matplotlib import pylab as plt

for i in range(2898):

    # 画像の読み込み
    im = np.array(Image.open(r"X:\Path"+r"\a"+'{0:04d}'.format(i)+".png").convert('L'))

    # 輝度反転
    im = 255 - im[:,:]

    # print(im.shape, im.dtype)

    #保存
    Image.fromarray(im).save(r"X:\Path"+r"\a"+'{0:04d}'.format(i)+".png")

上記スクリプトを実行して輝度反転すると・・・

輝度反転した画像を部分拡大

文字以外の部分に、かなり濃い灰色の部分がたくさん生じてしまう。このまま学習用データとして利用した方がいい(ロバスト性が高まる?)のか、それともさらに二値化して、完全に白と黒の極端方向に振った学習データとするか、しばし、悩む。自分の中に理論的な裏付けなど何もないので、悩むと言っても、選択肢は二つのうちのどちらかしかないんだけど。

ここで思い出したのが、前回、学習用に用意した画像の特徴・・・。職場の各階の要所に備え付けられている複合機のスキャナーで読み取ったグレースケールの画像(読み取り解像度200dpi)は二値化していないので、空白(文字のない)部分はごく薄い、少しだけ濃淡のある灰白色で構成され、また、文字部分も完全な黒ではない。

上の画像ほどではないにしても、それなりに濃淡がある・・・

理論的な根拠なんか、なーんにもないけど、学習モデルのロバスト性を高める分には、コレが700枚もあれば必要にして十分なんじゃないか?

新しく用意する画像は、二値化で白と黒だけにして、最も極端な方向で学習データを作成してみて、これに前の実験で使った700枚の画像を加えて機械学習を実行。その結果を見て必要であれば、また次の方法を考えたらいいんじゃないか?

みたいな感じで、だんだん、考えがまとまってきた!

とりあえず、新しく用意した画像データを二値化してみる。閾値は試行錯誤を数回繰り返して「200」とした。

# 二値化
import cv2

for i in range(2898):

    # 画像の読み込み
    img = cv2.imread(r"X:\Path"+r"\a"+'{0:04d}'.format(i)+".png", 0)

    # 閾値の設定
    threshold = 200

    # 二値化(閾値を超えた画素を255にする。)
    ret, img_thresh = cv2.threshold(img, threshold, 255, cv2.THRESH_BINARY)

    #保存
    cv2.imwrite(r"X:\Path"+r"\a"+'{0:04d}'.format(i)+".png", img_thresh)

二値化した画像を部分拡大。ビシっと見事に白と黒に分かれた(当然)。これが吉と出るか、凶と出るか、結果は神のみぞ知る・・・

二値化した画像を部分的に拡大

それから、二値化して困ったのは、次のようなシミ・汚れが多数ある画像の取り扱い。

閾値を「200」としても、このようなシミが一部の画像には残った・・・

様々に思い悩んだが、結局、新規に作成する文字データは、白 or 黒いずれかの色のみで構成すると決めたので、中途半端なシミや汚れは排除することにして、シミや汚れのある画像を徹底的に手動で削除。これは修行だと考えて、1万枚を超える画像を1枚ずつチェックしながら、一心不乱に作業した。せっせ、せっせ、せっせ、せっせ・・・

あー! 消さなくてもイイ画像 消しちゃった!! みたいな >_<

こうして最終的に「ア~オ」の各文字約3000枚程度の機械学習用画像データができた。これをフォルダ名を半角数字で「0~4」とした五つのフォルダに入れ、Lobeで機械学習を実行。

1分・・・、 3分・・・、 5分・・・ 時は静かに流れる・・・

ひまだなー。ひまだなー。

10分・・・、 30分・・・、1時間・・・ Lobeさん、まぁだぁ???

ものすごぉーく、ひまなんだケドー

2時間・・・ たっても、
まだ1/4しか進行状況を示すインジケーターが進んでない!!

ご主人様、
もぉ 無理。

待ちくたびれました。

忠犬ハチ公もこんな気持ちだったのか・・・、なー☆

前回、各文字700枚で処理した時は、20~30分くらいで機械学習が終了した気がするんだけど・・・。各文字3000枚だと、もしかして数日必要だったりするとか・・・???

うわー! そんなんイヤだ。
やり直そう。

・・・ということで、残念ながらいったんLobeを強制終了。プロジェクトは削除する。

あらためて学習させる文字データの数について再考。前回作成した文字データ700枚に、今回新たに作成した文字データを1000枚加えて、各文字1700枚、合計8500枚で学習モデルを作成してみることに決定(理由は特になし)。

Lobeを再起動し、プロジェクト名を「aiueo2」にして、用意した学習データを再度読み込ませ、機械学習を開始。今度は約2時間50分でトレーニングが終了。続けて、Lobeの推奨設定の通りに「最適化」してTFLite形式の学習モデルを書き出す。これに要した時間がさらに2時間40~50分。およそ半日を要したが、ついに念願(?)の学習モデルが完成した。

1%程度の画像が分類不可とされたが、これらの画像は再学習の対象とせず、破棄することにした(前回、再学習させようとしたら、トレーニングの進行状況の表示が99%からいきなりガクンと落ちて40%台になり、Lobeはそこから学習を再開。これを分類不可とされた文字ごとにエンエンと繰り返しながら、根気強くトレーニングの完了を待つ気にはどうしてもなれなかったことを思い出した。今回は画像数も倍増させたし・・・。どうしてもその必要性を感じたら、その時、考えればイイかぁ・・・みたいな)。

4.浮動小数点例外をマスクする

カタカナ画像ファイルを直接読み込み、準備した学習モデルが書かれている文字を認識、ユーザーに対してその結果を表示する・・・、もし、ショー的な要素が必要なければ、それだけのプログラムで全然OKなんだけど、演示実験的にShowすることも目的に含めてテストするには、やはりマウスで直接、PC画面にリアルタイムでカタカナ文字を書いて、ボタンクリックで画像中の文字を認識、結果を表示するという流れが好ましい。

そこで、手書きカタカナ文字認識のGUIは、前回の記事を書いたときに見つけたエンバカデロさんのMNISTの手書き数字認識プログラムの紹介記事にあったGUIを参考にして作成。4年前にMNISTの手書き数字認識を試したときは、PyQtでさんざん苦労してGUIを作成したけど、今度はDelphi☆ ヒトとキカイの間を取り持つインターフェイスはすぐに完成!

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

https://blogs.embarcadero.com/ja/how-to-build-a-digit-classifier-in-tensorflow-ja/
設計時のGUI画面

ターゲットプラットフォームは64bitに設定。

すべてはTensorFlowのために・・・

動作に最低限必要と思われるプログラムを書いて、デバッガを使わずに実行してみた・・・。

とりあえずマウスで「ア」と書いて、「文字として認識」をクリック。

「ア」に見えるように丁寧に「ア」と書いて、「文字として認識」をクリックしたら・・・

予想もしなかった結果が。

それは「想定外」みたいな・・・

・・・てか、想定外以外のナニモノでもないんだケド。
32bit環境では、見た記憶のないエラーメッセージ。

で、「デバッガを使わずに実行(Shift+Ctrl+F9)」ではなく、デバッガ例外通知のある「実行(F9)」で状況を再確認。

なんで、こうなるのか?

メッセージにある「c000008e FLOAT_DIVIDE_BY_ZERO」を検索キーワードにしてGoogle先生に質問。すると・・・

FLOAT_DIVIDE_BY_ZERO (Windows 64 ビット)

https://bitbucket-org.translate.goog/chromiumembedded/cef/issues/2166/float_divide_by_zero-windows-64-bit?_x_tr_sl=en&_x_tr_tl=ja&_x_tr_hl=ja&_x_tr_pto=sc

もちろん、英語はじっくり読まないとわからないから「このページを訳す」をクリック。そして、表示されたWebページの中に次の語句を発見。

Delphi - Set8087CW、SetMXCSR、および TWebBrowser を使用した浮動小数点例外のマスキング」を参照してください。

Delphi – Set8087CW、SetMXCSR、および TWebBrowser を使用した浮動小数点例外のマスキング」を参照してください。

https://stackoverflow-com.translate.goog/questions/19187479/masking-floating-point-exceptions-with-set8087cw-setmxcsr-and-twebbrowser?_x_tr_sl=en&_x_tr_tl=ja&_x_tr_hl=ja&_x_tr_pto=sc

(マスキング・・・ってことは、つまり、覆い隠すってコト?)

リンク先を読んでみると・・・(上記Webサイトより引用)

最も簡単な方法は、初期化コードのある時点で例外をマスクすることです。
これを行う最良の方法は次のとおりです。

SetExceptionMask(exAllArithmeticExceptions);

ナニがどうしてエラーが起きているのか、その本当の原因は、まるでわかんないけど、取り敢えず、対応方法だけはわかった気がする! 調べてみると、SetExceptionMaskuses は、Mathユニットにあるようなので、uses に System.Math を追加して・・・

implementation

uses
  System.Math,

  //System.Mathは
  //PythonEngine1.ExecStrings()で「0による浮動小数点数除算」のエラーを出ないようにするおまじない
  //SetExceptionMask(exAllArithmeticExceptions)を実行するために追加

{$R *.dfm}

で、FormCreateに次の1行を追加。

procedure TForm1.FormCreate(Sender: TObject);
var
  //Python39-64へのPath
  AppDataDir:string;
begin

  //「0による浮動小数点数除算」のエラーを出ないようにするおまじない
  SetExceptionMask(exAllArithmeticExceptions);

  //Embeddable Pythonの存在の有無を調査
  AppDataDir:=ExtractFilePath(Application.ExeName)+'Python39-64';

  if DirectoryExists(AppDataDir) then
  begin
    //フォルダが存在したときの処理
    //MessageDlg('Embeddable Pythonが利用可能です。', mtInformation, [mbOk] , 0);
    PythonEngine1.AutoLoad:=True;
    PythonEngine1.IO:=PythonGUIInputOutput1;
    PythonEngine1.DllPath:=AppDataDir;
    PythonEngine1.SetPythonHome(PythonEngine1.DllPath);
    PythonEngine1.LoadDll;
    //PythonDelphiVar1のOnSeDataイベントを利用する
    PythonDelphiVar1.Engine:=PythonEngine1;
    PythonDelphiVar1.VarName:=AnsiString('var1');  //プロパティで直接指定済み
    //初期化
    PythonEngine1.Py_Initialize;
  end else begin
    MessageDlg('Embeddable Pythonが見つかりません!',
      mtInformation, [mbOk] , 0);
    PythonEngine1.AutoLoad:=False;
  end;
end;

こうなったら、実行以外の選択肢はない。結果を信じて、実行!
マウスで「ア」って書いて、「文字として認識」をクリック☆

エラーがマスクされ、結果が「正解」で返ってキタ。でも、これでいいのかなー?

なんとか動くようになったところで、ナニがどうなって、エラーが起きているのか、確かめてみることに。

コードを追いかけてみると、どうやら「文字として認識(button2)」ボタンをクリックした際に実行される手続き内の PythonEngine1.ExecStrings 部分でエラーが発生しているようだ。・・・ってコトは、Python4Delphi側の問題?

Delphi PythonEngine1.ExecStrings エラー

上記の検索キーワードで、Google先生にお伺いを立てると・・・

Help!!!It is strange #124

https://github.com/pyscripter/python4delphi/issues/124

上のリンク先Webサイトに、新たな情報を発見!(上記Webサイトより引用)

this is the example where you need to put the "MaskFPUExceptions(True);"

procedure TForm1.Button1Click(Sender: TObject);
begin
  MaskFPUExceptions(True);
  PythonEngine1.ExecStrings( Memo1.Lines );
end;

そういう方法もあったのか・・・。

ってか、この関数、Webからコピペしただけなのに未定義の識別子エラーにならない? ・・・オレ、新しいユニットは何にもusesしてないぞ・・・ってことは、もしかして、この関数は、Python4Delphiに、はじめから備わってる関数なんじゃないの? ふと、そんな気が。マウスでMaskFPUExceptionsをポイントすると・・・

その通りだったようで。それなら、こっちを使う方がいいのかなー?

そもそも「FPU」なるモノは、「Floating-point number Processing Unit:浮動小数点数演算装置」の略称とのことで、その「Exceptions」=例外の「Mask」だから、「浮動小数点例外のマスク」ということでMaskFPUExceptions。実に覚えやすい良い名前。

一方の、SetExceptionMask関数も名前は良いんだけど、System.Mathをusesしなければ使えないところが玉にキズ。しばらく時間を置いた後は(あれ、usesするユニットはなんだったっけ?)みたいなコトになりそう・・・。

結局、なぜ、浮動小数点例外が発生するのかは、わからずじまいだったけれど。プログラムが動作しないという最悪の事態だけは回避できました☆

5.手書きカタカナ文字をかなり正しく認識できた!

手書きカタカナ文字を認識する部分のPythonのスクリプトは、LobeからTFLite形式で学習モデルを書き出した際、一緒に作られたexampleフォルダにあった「tflite_example.py」を参考にして作成。

正解のlabelは「ア」を入れたフォルダ名を「0」、「イ」を「1」、「ウ」を「2」、「エ」を「3」、「オ」を「4」としたので「0~4」。先に述べたように、このフォルダ名&構成のままLobeにDatasetとして読み込ませて機械学習を実行。その学習モデルをTFLite形式で出力。その際に自動的に生成された、TFLite形式の学習モデルと、正解ラベルのテキストファイルと、ラベル名や入力形式など学習モデルに関する様々な情報が入っているらしい signature.json ファイルをexeと同じフォルダにコピー。※下図で赤い下線を引いたファイル

コピーしたファイルの名称は、わかりやすさのためオリジナルのままにしている

作成したLobeの学習モデルは、カタカナ文字の認識結果を、上記1~4の数字で返すので、次のようにPythonからDelphiへ値を渡す処理を記述。

Formに置いたPythonDelphiVar1のVarNameプロパティに「var1」を指定
        outputs = model.predict(image)

        if outputs["label"] == "0":
            var1.Value = str("ア") + "," + str(outputs["confidence"])
        elif outputs["label"] == "1":
            var1.Value = str("イ") + "," + str(outputs["confidence"])
        elif outputs["label"] == "2":
            var1.Value = str("ウ") + "," + str(outputs["confidence"])
        elif outputs["label"] == "3":
            var1.Value = str("エ") + "," + str(outputs["confidence"])
        elif outputs["label"] == "4":
            var1.Value = str("オ") + "," + str(outputs["confidence"])

Delphi側で、var1にセットされた値を受け取り、文字列リストに追加。

  private
    { Private 宣言 }
    //Pythonから送られたデータを保存する
    strAnsList:TStringList;

procedure TForm1.PythonDelphiVar1SetData(Sender: TObject; Data: Variant);
begin
  //値がセットされたら文字列リストに値を追加
  strAnsList.Add(Data);
  Application.ProcessMessages;
end;

最終的な処理として、戻り値に入れておいたカンマを区切り記号として文字列を分割。
認識結果と信頼度のTEditにそれぞれの値を表示。

こうしてGUIはDelphiで、内部的な文字認識はPythonで実行する手書き文字認識プログラムができた☆ さっそく実行!!

結果は次の通り。

「ア」の認識

それは、ひらがなの「つ」と、カタカナの「ノ」だろ! みたいな「ア」も、しっかり認識してくれました☆

ひらがなの「つ」と、カタカナの「ノ」ではありません。カタカナの「ア」のつもりです!

数字の「3」みたいな「ア」も、無事認識☆

多少、小さくても、はじっこに片寄っても、大丈夫☆

気のながそーな「ア」もイケました☆

もちろん、標準的(?)な「ア」なら、信頼度は99.9% 不安感はゼロ☆

Pythonのみで、ほとんど写経で作った学習モデルをもとにしたカタカナ文字認識を試したときは、うまく判定してくれるかどうか、毎回、祈るような気持ちで、判定結果を見つめた・・・もの。そこには、With かなりのドキドキ感(・・・と言うか、なんかダメそう・・・みたいなネガティブな感覚)が常につきまとって、それを最後まで拭い去ることはできなかったのですが、今回、Lobeで作成した学習モデルには、そのような不安感をほぼ感じません。Lobeの学習モデルには、ほぼ正しく手書きカタカナ文字を認識してくれるという、ナニモノにも代えがたい安心感があります。

「イ」の認識

アルファベットの「T」みたいな「イ」であっても、学習モデルは正しく「イ」と判定。

こちらも、多少の片寄りは、ほぼ問題なし。

少しくらいなら、傾いても、大丈夫。タイトルは「酔っぱらったイ」。

末広がり(?)な「イ」も・・・

僕のような、やせっぽちの「イ」も、平気でした☆

「ウ」の認識

まずは、無難な「ウ」

砕け散った「ウ」も・・・

やせっぽちの「ウ」も・・・

「ラ」に恋をしたような「ウ」も・・・(実際に、こんな「ウ」があった)

斜体の「ウ」も正しく認識。

「エ」の認識

まずは、普通の「エ」。信頼度は99.99%!

片寄ってもOK!

伸びてもOK!

傾いてもOK!

「オ」の認識

普通の「オ」。

それは漢字の「才」だろ・・・みたいな「オ」も

出ているハズの部分が、ほとんど出てない「オ」も・・・

斜体の「オ」も・・・

伸びても・・・

Lobe ほんとに優秀!

ただし、新たに気づいてしまった、ある問題を除いては・・・

信頼度は99.9%・・・でもLobeさん、それ、「イ」じゃないと思うんですけど・・・。

僕の自由研究はつづく・・・

6.まとめ

(1)TensorFlowは「64bit環境」にしか入らない!
(2)TensorFlowを入れる時、パスの文字数制限の解除が必要になる場合もある。
(3)学習データが大量にある場合、機械学習にも、最適化にも時間がかかる。
(4)0による浮動小数除算エラーが発生する場合は、例外をマスクする。
(5)学習モデルが未学習のデータに出会った時の挙動は今後要研究。

7.お願いとお断り

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

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.お願いとお断り

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

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

Get first character from string in image

画像の中の文字列から最初の一文字を取得する

最終的な目標は、カタカナ1文字で解答が書かれた解答欄の『自動採点』。
これを実現するには、まず、解答欄画像からカタカナを探して、それを切り抜き、それが何という文字なのかを判定しなければならない。これは、そのチャレンジの第一歩。
記事(内容)は最初Pythonですが、最終的にPythonのスクリプトは、Object Pascal に埋め込んで内部的に呼び出して利用するので、やがてDelphiに変わります・・・

1.手書き文字は認識できない?
2.文字の位置座標を取得するには?(その①)
3.文字の位置座標を取得するには?(その②)
4.まとめ
5.お願いとお断り

1.手書き文字は認識できない?

本当は、手書きの日本語でも、英語でも、数字でも、とにかく解答欄に書いてある内容を自由自在に読み取ってデータ化し、採点等、様々な目的に利用したいのだが、残念ながらそれはできない。

「それは無理・できるわけない」と思っていることこそ、本当にチャレンジし甲斐のある課題だと、いつも思うし、実際、過去5年間にやってきたことはいつも「できない」を「できる」に変えることへの挑戦だった。

上司に言われて作ったICカードを利用した出退勤の記録を管理するプログラムや、ほとんど自分のために作ったマークシートリーダー、それから、喜んでくれる人が多そうだから書いた(自分では絶対に使わないけど)手書き答案の採点プログラム・・・。「できない」を「できる」に変えて行く、その過程がたまらなくおもしろいことを、僕は知ってる。

しかし、記述式の解答欄を自由自在に読み取って自動採点することは、現時点では絶対に不可能だし、チャレンジしてもハードルが高すぎて、間違いなく途中で挫折する。それはやるまでもなく実現不可能な夢だと自分でもわかる。でも、カタカナ1文字を読み取って、その正誤を判定することならできるんじゃないか? そう思う僕がいる。

確か・・・4年前に、マウスでドラッグして画面に描いた(手書き)数字が0~9のいずれかを判定するプログラムを作成したことがあった。あれから4年経ってるから、認識技術も格段に進歩しているじゃないかって、期待する気持ちもある。

OCRを使った文字認識について、ここ数日間かけて調べたり、実際にプログラムを写経して動かしてみてわかったことは・・・『手書き文字の認識は難しい』ってこと。

『Python 文字認識』のようなキーワードで検索すると、PyOCRとTesseractを使った例が読み切れないほどヒットする。解答欄に見立てた画像を用意して、実際に試してみた・・・。

# PyOCRとTesseractを使用して手書き文字画像を認識
import os
import sys
from PIL import Image
import pyocr

tesseract_path = "C:\Program Files\Tesseract-OCR"
if tesseract_path not in os.environ["PATH"].split(os.pathsep):
    os.environ["PATH"] += os.pathsep + tesseract_path

tools = pyocr.get_available_tools()
if len(tools) == 0:
    sys.exit(1)
else:
    tool = tools[0]

img = Image.open("Image/画像ファイル名.jpg")
# デフォルト設定
builder = pyocr.builders.TextBuilder(tesseract_layout=3)
result = tool.image_to_string(img,lang="jpn",builder=builder)
print(result)
最もよく認識できた手書き文字の画像(実際に使用する解答欄同様に作成)
# 結果
ss アイ ウェ オ
[Finished in 1.953s]

①は ss に、デフォルト設定の tesseract_layout=3 で、エ は ェ として認識された。他のパラメータ設定ではどうだろうか? 次々と変更して試すことにする。結果は、次の通り・・・

# テキストの単一ブロックと仮定して認識
builder = pyocr.builders.TextBuilder(tesseract_layout=6)
# 結果
ss アイ ウェ オ
[Finished in 1.959s]

変化なし!

# 画像を1行の文字列として認識
builder = pyocr.builders.TextBuilder(tesseract_layout=7)
# 結果
ss アイ ウェ オ
[Finished in 0.423s]

これも変化なし!

# 文字が散らばっているテキストとして認識
builder = pyocr.builders.TextBuilder(tesseract_layout=11)
# 結果
oe アイ ウェ イオ
[Finished in 0.419s]

上記のテストが最も上手く認識できた例で、他にも複数の手書き文字を試したが、この例ほど正しく認識することは出来なかった。いちばん上手く認識できた例でも、完全ではないことから、誰もが容易に、かつ無償で利用できる環境での手書き文字認識には、まだ難しい部分があるようだ。

追記 正しく認識できなかった例

例えば、次の画像。比較的よく認識できた上の例と、それほど違いはないと思うのだが・・・

# デフォルト設定
builder = pyocr.builders.TextBuilder(tesseract_layout=3)
# 結果
Y ィ ウエ オォ
[Finished in 2.258s]

デフォルト設定の3では、ア が Y となってしまった。イ も小さな ィ になっている。それから、オはなぜ、オとォになるのだろう?

# テキストの単一ブロックと仮定して認識
builder = pyocr.builders.TextBuilder(tesseract_layout=6)
# 結果
oe マイ ウエ オ
[Finished in 0.435s]

6だと、①は oe に、ア は マ と認識された。残りは正解。

# 画像を1行の文字列として認識
builder = pyocr.builders.TextBuilder(tesseract_layout=7)
# 結果
o アマ アイウエオ
[Finished in 1.966s]

7では、① が o 、その後ろの アマ はどこからやってきたんだろう?

# 文字が散らばっているテキストとして認識
builder = pyocr.builders.TextBuilder(tesseract_layout=11)
# 結果
Y ィ ウエ オォ
[Finished in 0.44s]

11では、デフォルト設定と同じ結果になった。

ただ、PyOCRとTesseractの名誉のために、これは重要なことだと思うので掲載。認識する対象画像が手書き文字でなければ、必要かつ十分な素晴らしい性能を発揮する。

手書きでない画像を読ませてみる
# テキストの単一ブロックと仮定して認識
builder = pyocr.builders.TextBuilder(tesseract_layout=6)
# 結果
1 アイ ウエ オ
[Finished in 2.254s]

①は「(半角の)1」として読み取り、あとは正解。「文字」でなく、「画像」から文字の部分を判別し、しかも、その個々の文字を見分けているのですから、素直にすごいです!

# 文字が散らばっているテキストとして認識
builder = pyocr.builders.TextBuilder(tesseract_layout=11)
# 結果
アイ ウエ オ
[Finished in 2.419s]

①は認識せず。あとは正解。では、Fontを変更すると・・・?

別のFontで試すとどうなるか?
# テキストの単一ブロックと仮定して認識
builder = pyocr.builders.TextBuilder(tesseract_layout=6)
# 結果
"アイウエオ
[Finished in 0.36s]

①が ” に変わった? でも、あとは正解。

# 文字が散らばっているテキストとして認識
builder = pyocr.builders.TextBuilder(tesseract_layout=11)
# 結果
アイ ウエ オ
[Finished in 2.376s]

①は認識されない。あとは正解。さらに、別のFontでは・・・?

さらに別のFontでは?
# テキストの単一ブロックと仮定して認識
builder = pyocr.builders.TextBuilder(tesseract_layout=6)
# 結果
アイ ウエ オ
[Finished in 0.779s]
# 文字が散らばっているテキストとして認識
builder = pyocr.builders.TextBuilder(tesseract_layout=11)
# 結果
アイ ウツ ウエ オ
[Finished in 1.944s]

なんだか、むせてるような雰囲気を感じますが。元気? だいじょうぶ?

これから作成する自動採点プログラムで使用する解答欄画像には、設問番号や解答方法の指示が必ず含まれる予定なので、OCRを利用した手書き文字認識をそのまま読み取りに使用するのは少し難しそうだ。どうにかして、①などの設問番号への反応を読み取り結果から除外する方策を考えなければならない。

あと、認識結果のところどころにスペースが入るのは仕様なのか?(この点、不勉強で、よくわかりません)、まぁ、スペースは置換して取り除けばそれでOKだから気にしなくてもいいけど。

いずれにしても「手書きでない文字画像の認識」であれば、現在のOCR(光学的文字認識)は十分、実用になるレベルに達しているのは間違いない。これを無償で提供してもらえることに対し、プログラムの作成者と提供の仕事に関わった人々へ心から感謝。

2.文字の位置座標を取得するには?(その①)

次に考えたことは、手書き文字そのものを完全に認識(読み取って判別すること)は難しくても、「文字の位置」であれば座標というかたちで取得できるのではないか? ということ。僕の場合、そこに文字列があったとしても、最初の一文字だけ座標が取得できればOKなので、そこを重要視してテストを実行。

調べてみると、pyocrはbuilderというオプション部分の設定を変更することで、様々な読み取り形式を指定できるようだ。

※ pyocr のライセンスは GPL v3 なので、個人がローカルな環境で「使用」するだけであればソースコードの公開義務等は発生しないが、複製や改変、頒布は「利用」にあたり、ソースコードの公開義務等が発生することに十分留意する必要がある。

【builderに指定可能なオプション】(省略した場合はTextBuilderになる)

1.TextBuilder 文字列を認識
2.WordBoxBuilder 単語単位で文字認識
3.LineBoxBuilder 行単位で文字認識
4.DigitBuilder 数字 / 記号を認識
5.DigitLineBoxBuilder 数字 / 記号を認識

注意:DigitBuilderおよびDigitLineBoxBuilderは認識対象を数字に限定してTesseractを動作させる。ただし、新しい認識エンジン(Tesseract4.0系)では動作しない。

このオプションで、座標を取得するために利用できそうなのは、「2」と「3」だ。WordBoxBuilderが認識するのが「文字」ではなく、「単語」という部分がちょっと気になったが、「アイウエオ」は単語ではなく、「単なる文字列」だと思うので、どうなるか、ちょっと実験してみた。ア と イ と ウ と エ と オ を全部、別々の単語として認識してくれるとうれしいのだけれど。字と字の距離も離れているし・・・

手書きカタカナ画像
import sys
import os

from PIL import Image
from PIL import ImageDraw
from PIL import ImageFont

import numpy as np
import pyocr

img = Image.open(r".\img\画像ファイル名.jpg")

#tool = pyocr.get_available_tools()[0]

tesseract_path = "C:\Program Files\Tesseract-OCR"
if tesseract_path not in os.environ["PATH"].split(os.pathsep):
    os.environ["PATH"] += os.pathsep + tesseract_path

tools = pyocr.get_available_tools()
if len(tools) == 0:
    print("OCRエンジンが指定されていません")
    sys.exit(1)
else:
    tool = tools[0]

builder = pyocr.builders.WordBoxBuilder(tesseract_layout=6)
boxies = tool.image_to_string(img, lang="jpn", builder=builder)
draw = ImageDraw.Draw(img)

for box in boxies:
    text = box.content
    pos = box.position
    draw.rectangle(pos, fill=None, outline=(255, 0, 0))
    print(pos)

img.save(r".\img\out.jpg")

結果は・・・

# 出力された座標
((6, 0), (82, 98))
((67, 32), (181, 77))
((271, 27), (379, 82))
((417, 17), (481, 72))
[Finished in 3.125s]

WordBoxBuilderで最初の一文字の座標を取得するという目的の実現は困難なようだ。「使い方の方向性が間違ってる」と、作成者の方には叱られそう・・・。その通りです。すみません。

では、LineBoxBuilder の方はどうだろうか?

# builderオプションの設定を変更
# builder = pyocr.builders.WordBoxBuilder(tesseract_layout=6)
builder = pyocr.builders.LineBoxBuilder(tesseract_layout=6)

左x座標の起点が①の左にあるのは理解できるが、左y座標はどのようにして決めているのだろう? 左y座標は、点線の上限のようにも見えるが・・・。右x座標はオの右端だと思うが、右y座標はなぜ画像の外に・・・? もしかして、画像中の点線と何か、関係がある?

残念ながら、LineBoxBuilder も手書き文字一文字めの座標を取得する用途には向かないようだ。誤解のないように言っておくが、これは各Builderオプションが「使えない」という意味ではない。Builderは正しく機能している。僕の使い方=「手書き文字への適応」が「間違っている」ということに他ならない。

Builderオプションの名誉のため、手書きでない画像の①と点線を消して、WordBoxBuilder をもう一度実行して見ると・・・

使用目的によっては、日本語の単語をどの程度認識するか? その認識率が問題になりそうだが、文字や文字列の座標は間違いなく取得できる。すばらしい性能だ。

LineBoxBuilder では・・・

こちらはさらにすばらしい。手書きでない画像であれば、行単位での文字列の認識・切り出し用途に十分使えそうだ。要は、万能選手的な働き方はその性格上できないので、適材適所な使い方をすれば、大変優秀な働きを示してくれるということを、使う側が十分認識して、Toolがその性能を100%発揮できる場面を提供してあげることが大切なのだ。

3.文字の位置座標を取得するには?(その②)

では、手書き文字の最初の一文字目の座標を取得するにはどうしたらいいのか?
いちばんのヒントを与えてくれたのは、次のWebサイト様で紹介されていた画像内の数字を連続して認識するスクリプト。

PYTHONとRPAで遊ぶ 画像OCR(連続文字の認識)

https://ailog.site/2019/08/17/ocr1/

上記Webサイト様で紹介されているScriptをそのまま実行しようとすると、次のようなエラーメッセージが表示されることがあるようだ。これは、OpenCVの仕様変更のため?

# 表示されたエラーメッセージ
Traceback (most recent call last):
  File "X:\XXX\Sample.py", line XX, in <module>
    x, y, w, h = cv2.boundingRect(cnt)
cv2.error: OpenCV(4.5.5) X:\a\opencv-python\opencv-python\opencv\modules\imgproc\src\shapedescr.cpp:874: error: (-215:Assertion failed) npoints >= 0 && (depth == CV_32F || depth == CV_32S) in function 'cv::pointSetBoundingRect'

[Finished in 2.412s]

エラーの修正方法は、次の通り(赤字部分の[1] を [0] に修正)。また、状況によっては、いちばん外側の輪郭だけを検出するように、RETR_LISTRETR_EXTERNAL に修正しておいた方がよいとのこと。

#contours = cv2.findContours(
#    thresh, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)[1]

# エラーが出なくなるように修正
#contours = cv2.findContours(
#    thresh, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)[0]

# 領域の一番外側だけを検出するように修正
contours = cv2.findContours(
    thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[0]

紹介記事にあったスクリプトを元にして、解答欄の画像にある文字列から最初の一文字目の文字を取得するスクリプトを書いた。処理の流れとスクリプトは次の通り。

  • 検出した輪郭矩形の数だけ、その位置座標を二次元配列に取得(高さが小さすぎる輪郭は座標をゼロにしてないものとする)。
  • 横書き答案の場合はx座標の小さなものから昇順に、縦書き答案の場合はy座標の小さなものから昇順に並べ替えを行う処理を追加し、文字列の最初の一文字目の座標を取得。
  • さらにこれを囲む輪郭矩形の大きさを少し大きく補正して、一文字目の文字だけを切り出して保存する。
# 画像の中の文字を検出して、最初の一文字の画像を保存するScript

import sys
import numpy as np
import cv2
from PIL import Image

# 画像の読み込み(Pathとファイル名に日本語があるとエラーになる)
#im = cv2.imread(r".\img\numbers.png")

# 画像の読み込み(pillowで日本語に対応)
file_path = r".\img\Sample.jpg"
pil_img = Image.open(file_path)

# PillowからNumPyへ変換
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.cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[0]

# 検出した輪郭の数
num = len(contours)
# 表示して確認
print(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 < 30:  # 高さが小さい場合は検出しない
        # (ここを調整すれば設問番号や指示内容を無視できる)
        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)
        # 最後の出力がいちばん手前の矩形の座標というのは勝手な思い込み
        # 最初の一文字目の座標を確認するために取得した座標を表示
        print(str(x)+", "+str(y)+", "+str(x+w)+", "+str(y+h))

    i += 1

# 配列を並べ替え(キー列は0:x1、1:y1・昇順)
pw, ph = pil_img.size
if pw > ph:
    # 横書きと判断
    mylist_sort = mylist[np.argsort(mylist[:, 0])]
else:
    # 縦書きと判断
    mylist_sort = mylist[np.argsort(mylist[:, 1])]

# 表示して確認
print(mylist_sort)

# 緑色を指定
green = (0,255,0)

# 輪郭枠の大きさを微調整するための変数
intTweak = 5

# 最も左の文字を緑の枠で囲んで示す
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)
        print(str(x1)+", "+str(y1)+", "+str(x2)+", "+str(y2))
        cv2.rectangle(img, (x1,y1), (x2, y2), green, 2)
        break

# 輪郭枠付きの画像を保存
cv2.imwrite(r".\img\result.png", img)
# 最初の一文字を切り抜き
img_crop = pil_img.crop((x1,y1,x2,y2))
# 最初の一文字を保存
img_crop.save(r".\img\crop.jpg")

# 表示
cv2.imshow('rect', img)
cv2.waitKey(0)
cv2.destroyAllWindows()
【実行結果】

【検出した輪郭の数】
 23

【最初の一文字目の座標を確認するために取得した座標を表示】
262, 36, 296, 83
332, 35, 380, 68
66, 34, 105, 78 <- これがほしかった座標!
146, 32, 181, 77
416, 16, 482, 73

【並べ替えを実行した後の二次元配列の内容】
[[  0.   0.   0.   0.]
 [  0.   0.   0.   0.]
 [  0.   0.   0.   0.]
 [  0.   0.   0.   0.]
 [  0.   0.   0.   0.]
 [  0.   0.   0.   0.]
 [  0.   0.   0.   0.]
 [  0.   0.   0.   0.]
 [  0.   0.   0.   0.]
 [  0.   0.   0.   0.]
 [  0.   0.   0.   0.]
 [  0.   0.   0.   0.]
 [  0.   0.   0.   0.]
 [  0.   0.   0.   0.]
 [  0.   0.   0.   0.]
 [  0.   0.   0.   0.]
 [  0.   0.   0.   0.]
 [  0.   0.   0.   0.]
 [ 66.  34. 105.  78.]
 [146.  32. 181.  77.]
 [262.  36. 296.  83.]
 [332.  35. 380.  68.]
 [416.  16. 482.  73.]]

【少し輪郭矩形を大きくした切り抜き用の座標】
 61, 29, 110, 83

[Finished in 11.51s]
ようやく最初の一文字目を取得できた!
正誤判定用に一文字目を切り抜いて保存
縦書きの解答欄の場合もOK!

追記 読み取りに失敗(?)した例

文字の一部が「切れている・つながっていない」場合、読み取りに失敗(?)してしまうことがあるようだ(ヒトは、その期待に反している結果を「失敗」と感じてしまうが、キカイ的には間違いなく正確に輪郭を検出している)。だから、これも僕が運用方法を間違えているだけと言えなくもない。例えば、次の画像で試すと・・・

ア・ウ・エとも、文字の一部がつながっていない
「輪郭」を検出しているわけだから、プログラム的には大正解のはず

高さの閾値をゼロにして、プログラムが正しく動作していることを確認する。

OpenCVの性能は素晴らしい!

この読み取り方法での(自分的に工夫はしたが)限界がこの辺りにあることがわかったので、運用する際はこれを「読み取りエラー」として処理し、ヒトが目視で確認するように案内することに決め、これ以上、この問題には深入りしないことにする。確かに「自動採点が目標」なんだけど、最終的にはヒトの確認作業が必ず必要。機械と協働するのだからお互いが気持ちよく働ければいいのだ・・・と、自分自身に言い聞かせて、先へ進む。

(追記ここまで_20221231)

スクリプトを書いていて、すごくうれしくなったのは、解答欄に欠かせない設問番号や指示内容を小さめに作成(印刷)すれば、高さの閾値を用いて、その存在を無視できること。これを発見した時は、もう小躍りしたいほど、うれしかった!

1回の計算に必要な時間が長いような気もするが、Loopを作って複数の解答欄画像を連続して処理して見ると2回目からは初期化が必要なくなるためか、計算時間はグンと短くなった。

次の課題は、切り出して保存したカタカナ画像の文字が何であるかを判定するスクリプトを完成させること。4年ぶりにチャレンジする機械学習だが、この4年間でどれくらい進化したのだろう? 4年前はMNISTデータベースを活用して手書き数字を認識・判定する方法を学んだが、今回のターゲットは言語的にはマイナーすぎるカタカナ・・・

もし、この夢が実現できてもプログラムを商品化するつもりなどまったくないので、研究用に無料で利用できるカタカナデータベースがどこかにあるとよいのだが。

4.まとめ

(1)画像内の手書き文字をOCRで完全に認識させるのは、現段階では難しい。
(2)OpenCVの輪郭検出を用いれば画像内の文字位置の座標を取得可能。
(3)座標から文字の輪郭矩形を切り出して保存。機械学習で文字種を判定する。

5.お願いとお断り

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

Rectangle Detector

矩形検出器

手書き答案をスキャナーで画像化して採点するソフトを書いた。概ね、思った通りにカタチになったが、解答欄の位置座標を取得するのに、解答欄の数だけ、その左上隅から右下隅へマウスでドラッグする作業を繰り返さなくてはならない。(もし、これが自動化できたら・・・) そう思って書いたのが、このプログラム。

1.矩形の検出方法
2.字数制限のある解答欄の作り方
3.GUIはDelphiで作成
4.矩形検出器の使い方
5.まとめ
6.お願いとお断り

1.矩形の検出方法

キーワードを『矩形 検出』にしてGoogle先生にお伺いをたてると、思った通りOpenCVを活用する方法がいくつもヒットする。しかも、そのほとんどすべてがPythonでの活用方法だ。Delphi用のOpenCVもあるようだけれど、次の理由から矩形の検出はPython用のOpenCVで行うことにした。

Pythonを使う利点は、まず、何と言っても、情報が豊富なことだ。マイ・プログラミング環境では、わからないことはすべてGoogle先生に教えてもらうしかないので、情報が入手しやすいことは、他のすべてに優先する。

(メインの開発環境がDelphiなのは、上記の内容と大いに矛盾しますが・・・)

さらに、手書き答案の採点ソフトより前に、マークシートリーダーを作った時、マーク欄の座標を得るために、やはりPythonとOpenCVのお世話になった。マークシートリーダーも、手書き答案の採点ソフトも、embeddable pythonに入れたOpenCVと一緒のフォルダに詰め込んでユーザーに配布しているから、Pythonを内包して使う環境は既に完成済み。PythonのスクリプトをDelphiのコードに埋め込んで、PythonForDelphiを使って実行する方法は勉強済みだから安心。Delphi用のOpenCVは、情報も少ないし、何よりその使い方がわからない・・・。

他人様に使っていただくプログラムはDelphiで書くけれど、自分専用のToolはPython環境を利用して作ることが多い。ちょっと特別なことをしたい時、Pythonはとても便利だ。いろいろ紆余曲折はあったけれど、現在はSDカードにWinPythonとAtomエディタを入れて持ち運べるPython環境を作っている。

そのSDカードに入れたPython環境で、いつものようにAtomを起動し、Web上にあったいくつものScriptをコピペして試してみる。

まず、OpenCVで「ハフ変換」なるものを利用する例だが、ハフ変換はノイズの除去で苦労しそうだ。ノイズの発生源が多数存在する解答用紙の矩形検出でパラメータを適切に設定することが果たしてできるだろうか? 経験がない自分にはちょっと厳しそうだ。

次に、LSD(Line Segment Detectorの略とのこと)という直線検出器を試した。試した瞬間、(もう、これしかない!)と思うほど、これは凄かった。使い方も超カンタンで、LSDをこれでもか!とばかりに並べるだけ。

from pylsd.lsd import lsd
Mylines = lsd(picture)

【検出結果】

LSDで検出できた矩形の例

さらに驚くべきことに、こういう作業には付き物の引数も一切ない。つまり、パラメータを調整する必要など『ない』ということなのだろう・・・。ただ、LSDはそのライセンス形態がAGPLであると知り、使用を断念。MITやBSDでないと自分的にはやはり困る・・・。

最後に試したのが、OpenCVのfindContours関数。これを使うには前処理として、まず、画像をグレースケールに変換し、さらに白黒反転させて二値化しなければならない。

import cv2
import numpy as np
from PIL import Image

# Pillowで画像ファイルを開く(全角文字対応の確認用にファイル名は「ひらがな」)
pil_img = Image.open("./img/さんぷる.jpg")
# PillowからNumPyへ変換
img = np.array(pil_img)

# グレースケールに変換する
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

# 白黒を反転
gray = 255 - gray
# 2値化する
ret, bin_img = cv2.threshold(gray, 20, 255, cv2.THRESH_BINARY)

Pillowで画像ファイルを開いているのは、OpenCVのimread関数が日本語(全角文字)に対して拒絶反応を示すので、これを回避するため。もし、ファイル名とそこまでのPathに全角文字が含まれないという確実な保証があるなら、次のようにしてもいいようだ。これなら1行で済む。

# 8ビット1チャンネルのグレースケールとして画像を読み込む
img = cv2.imread("全角文字のないPathと画像ファイル名", cv2.IMREAD_GRAYSCALE) 

で、準備が出来たらfindContours関数を使って輪郭を検出する。

# すべての輪郭を同じ階層として取得する
contours, hierarchy = cv2.findContours(bin_img, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)

解答欄には、その性格上、小さな矩形が多く使われることが多いので、閾値以下の面積の矩形は削除する。※ 閾値は整数型の数値で指定する。

# 閾値以下の面積の矩形(小さい輪郭)は削除
contours = list(filter(lambda x: cv2.contourArea(x) > 閾値, contours))

よりスムーズに作業するためには、予め、小さな矩形を消去した機械読み取り用の解答欄(解答用紙)をヒト用の解答用紙のコピーから作成し、これを用いて解答欄座標を取得した方がよい(国語の縦書き解答用紙は、ワープロソフトではなく、表計算ソフトで作成する方法が業界では一般的らしいので、機械読み取り用の解答用紙はそれほど手間をかけなくても、カンタンに作成できる・・・はず)。

解答欄矩形をちゃんと認識できているか・どうかを確認するため、検出した輪郭を描画する。このPythonのスクリプトをDelphiのObject Pascalに埋め込んで実行する際は、ここが最大の「見せ場」になる。検出した矩形をグラブハンドル付きのラバーバンドで表示する方法は後述。

# 検出した輪郭を描画する
cv2.drawContours(img, contours, -1, color=(0, 0, 255), thickness=2)

最後に解答欄矩形の座標を取得する(これが最終的な目標)。取得した座標は、採点順になるよう、並べ替えて表示する(並べ替え方法は後述)。

# 矩形の座標を表示(左上の座標, 右下の座標)
for i in range(len(contours)):
    x, y, w, h = cv2.boundingRect(contours[i])
    print(str(x)+','+str(y)+','+str(x+w)+','+str(y+h))

数値より、画像(絵)で見た方がわかりやすいのは言うまでもない。

# 保存
cv2.imwrite('./img/lined.jpg', img)
# 画像を表示
cv2.imshow("Image", img)
# キー入力で終了
cv2.waitKey()
画像を表示して、解答欄矩形の取得状況を確認

ここまでの Python Script をまとめて示せば、次の通り。

import cv2
import numpy as np
from PIL import Image

# Pillowで画像ファイルを開く
pil_img = Image.open("./img/さんぷる.jpg")
# PillowからNumPyへ変換
img = np.array(pil_img)

# グレースケールに変換する
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# 白黒を反転
gray = 255 - gray
# 2値化する
ret, bin_img = cv2.threshold(gray, 20, 255, cv2.THRESH_BINARY)

# すべての輪郭を同じ階層として取得する
contours, hierarchy = cv2.findContours(bin_img, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)

# 閾値以下の面積の矩形(小さい輪郭)は削除
contours = list(filter(lambda x: cv2.contourArea(x) > 数値, contours))

# 検出した輪郭を描画する
cv2.drawContours(img, contours, -1, color=(0, 0, 255), thickness=2)

# 矩形の座標を表示(左上の座標, 右下の座標)
for i in range(len(contours)):
    x, y, w, h = cv2.boundingRect(contours[i])
    print(str(x)+','+str(y)+','+str(x+w)+','+str(y+h))

# 保存
cv2.imwrite('./img/lined.jpg', img)
# 画像を表示
cv2.imshow("Image", img)
# キー入力で終了
cv2.waitKey()

OpenCVのfindContours関数を使って検出した輪郭(=解答欄の矩形)の例。
(解答用紙画像はLSDを試した時と同じものを使用)

矩形を検出しやすいように作った解答用紙なら、この結果はまさに『ブラボー!』

解答用紙中の ■ や □ を検出しないよう、検出下限の閾値を設定したこともあり、期待した通りの満足できる結果が得られた。OpenCVのハフ変換や、LSDでは日本語に対する反応が見られたが、findContours関数は(適切な閾値を設定してあげれば)日本語に反応しないようだ。

答案の「答」には「口」、問にも「口」、漢字にはたくさんの矩形が使われている。適切な閾値を設定することで、誤認識を減らせることも理想的。

【実験してみた!】

閾値を「700」として、□ に対する反応を実験して確認した。結果は次の通り。

26×26=676、28×28=784 だから・・・機械は正確に反応している

28ポイントの「□」から反応するが、40ポイントの「問」には無反応。通常使用される解答用紙であれば、フォントの大きさに制限を設ける必要性はなさそう。

もう少し細かい矩形を使った解答用紙で、閾値700で実験すると・・・

解答欄の矩形をさらに細かく分割したサンプルを作成してテスト
解答欄の番号の矩形に反応してしまう・・・

閾値1400までは・・・

解答欄の番号の矩形に反応するが

閾値を1500にすると・・・

解答欄の番号の矩形には反応しなくなる☆

少し、細かい矩形を用いた解答用紙であれば、閾値1500くらいから試せば狙った通りに解答欄の座標だけを取得することができそうだ。

閾値に上限を設定すれば、さらに良い結果を得られるかも・・・と思ったが、数学の解答用紙には他の教科ではあり得ない巨大な矩形が普通に使用される。矩形を取得できなければ、検出器とは言えない。さらに、解答欄全体を一つの大きな矩形として認識してしまうのはプログラムの性格上、絶対に回避できないから、閾値の上限は設けずに、むしろ、不要な矩形の座標を削除しやすいプログラム(GUIを作成)を書けばいいと気づく。

さらに、ユーザーが矩形座標の編集(修正)を自由にできるようにプログラムを工夫すれば、理想的な矩形検出器ができるはず。

これでDelphiでGUIを作成する際の方向性も見えてきた。

2.字数制限のある解答欄の作り方

解答欄の矩形を検出する上で、大きなハードルになるだろうと予想していたのが『字数制限が設定された解答欄』。

機械読み取り用に作成した解答用紙であっても・・・

上の解答用紙は、ヒト用の解答用紙の問題番号部分にあった小さな矩形を消去して、機械読み取り処理用に作成した解答用紙。この状態で矩形を検出(閾値1500)すると・・・

それでも削除しなければならない矩形座標が多すぎ・・・

閾値を「3100」に設定して、ようやく・・・

閾値をどんどん大きくすれば、何とかなることはわかった!

閾値を大きく設定すれば、何とかなることは上の例でわかったが、閾値を大きくすれば当然必要な解答欄の座標を取得できなくなる可能性も生じてくるわけで・・・。

ところが別の国語用解答用紙を処理している際に、閾値を気にせずに字数制限のある解答欄を作成する良い方法があることを偶然発見。それは・・・

罫線に「点線」を利用した解答用紙

字数制限を設定したり、完全解答で正解としたい解答欄は内側の罫線を点線にする!

閾値「700」で実験した結果

これなら問題2の(1)・(2)が作る大きな矩形の座標のみ削除すればOK!
点線を活用することで、一番大きな問題を難なくクリアできることが判明。
やったー☆

【embeddable Pythonのバージョンとインストールしたライブラリの一覧】

Python 3.9.9

Package Version
numpy 1.21.5
opencv-python 4.5.4.60
Pillow 9.3.0
pip 22.3.1
setuptools 60.1.0
wheel 0.37.1

3.GUIはDelphiで作成

取得した解答欄の座標を編集するGUIはDelphiで作成。最終的にはこうなった。

検出した矩形の確認と編集を行うGUIはDelphiで作成

画面下の「操作」グループ内のVCLを左から右へ順にクリックして行けば、解答用紙画像から解答欄の矩形が取得・表示できる仕組み。

左から右へ順に操作して解答欄矩形の座標を取得する。

取得した解答欄矩形の座標は、画面右上に一覧形式で採点順に表示されるようにプログラミングした。

取得した座標の一覧を表示

横書き答案が指定された場合は、y座標の値が昇順になるよう並べ替え(y座標が同じなら、x座標でさらに昇順に並べ替え)。

縦書き答案が指定された場合は、x座標の値が降順になるよう並べ替え(x座標が同じなら、y座標でさらに昇順に並べ替え)。

こうすれば、座標の並び方が「ほぼ採点する順番になる」はず。なお、並べ替えはカンマで区切った解答欄矩形の座標を入れたStringListを対象として実行(解答欄数は多くても100未満のはず・・・だから、並べ替えの速度はまったく考えていない)。そのアルゴリズムは次の通り。まず、グローバルに使う変数、ソート用のプロパティと関数を準備。

  private
    { Private 宣言 }
    x1,x2:integer;
    y1,y2:integer;
    //Pythonから送られたデータを保存する
    strAnsList:TStringList;

var
  Form1: TForm1;

type TSStyle = (ssText,ssInteger);
var
  //ソート用のプロパティ
  fAscending : Boolean;
  fIndex : Integer; //項目番号
  fStyle : TSStyle; //テキストか整数か

implementation

uses
  System.UITypes;
function GetCommaText(aStr:String; aIndex:Integer):string;
  var
    subList:TStringList;
begin
  subList := TStringList.Create;
  subList.Delimiter := ',';
  subList.DelimitedText := aStr;
  Result := subList.Strings[aIndex];
  subList.Free;
end;
function MyCustomSort(List: TStringList; Index1, Index2: Integer): Integer;
begin
  case fStyle of
    ssText:begin
      Result:=CompareText(GetCommaText(List.Strings[Index1],
      fIndex),
      GetCommaText(List.Strings[Index2],fIndex));
    end;
    ssInteger:begin
      //一重ソート
      //Result:=StrToInt(GetCommaText(List.Strings[Index1],fIndex))
      //          -StrToInt(GetCommaText(List.Strings[Index2],fIndex));
      //二重ソート
      Result:=StrToInt(GetCommaText(List.Strings[Index1],fIndex))
                -StrToInt(GetCommaText(List.Strings[Index2],fIndex));
      if Result=0 then
        //-1することで1番目の項目がソートキーになる
        Result:=StrToInt(GetCommaText(List.Strings[Index1],fIndex-1))  
                  -StrToInt(GetCommaText(List.Strings[Index2],fIndex-1));
      if fAscending then
      begin
        Result:=Result*-1;
      end else begin
        Result:=Result*1;
      end;
    end;
  else
    //これを入れておかないとコンパイラが警告を表示する
    Result:=0;
  end;
end;

で、「解答欄座標を取得」ボタンがクリックされたら、PythonForDelphiを通じてPythonのScriptを内部的に実行して座標を取得し、上記関数を呼び出して並べ替えを実行、結果をMemo2に表示する。

procedure TForm1.btnGetSquareClick(Sender: TObject);
var
  //PythonのScriptを入れる
  strScrList:TStringList;
  //Pythonから送られたデータを保存する -> グローバル変数化
  //strAnsList:TStringList;
  //Sort
  i:integer;
  strFileName:string;
  strList:TStringList;
begin
  //初期化
  Memo1.Clear;
  //Scriptを入れるStringList
  strScrList:=TStringList.Create;
  //結果を保存するStringList
  strAnsList:=TStringList.Create;

  try
    //Python Script
    strScrList.Add('import cv2');
    strScrList.Add('import numpy as np');
    //strScrList.Add('img = cv2.imread("./ProcData/sample2.jpg")');
    strScrList.Add('img = cv2.imread(r"./ProcData/'+ExtractFileName(StatusBar1.SimpleText)+'")');
    strScrList.Add('gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)');
    strScrList.Add('gray = 255 - gray');
    strScrList.Add('ret, bin_img = cv2.threshold(gray, 20, 255, cv2.THRESH_BINARY)');
    strScrList.Add('contours, hierarchy = cv2.findContours(bin_img, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)');
    strScrList.Add('contours = list(filter(lambda x: cv2.contourArea(x) > '+cmbThreshold.Text+', contours))');
    strScrList.Add('for i in range(len(contours)):');
    strScrList.Add('    im_con = img.copy()');
    strScrList.Add('    x, y, w, h = cv2.boundingRect(contours[i])');
    strScrList.Add('    var1.Value =str(x)+","+str(y)+","+str(x+w)+","+str(y+h)');
    //Scriptを表示
    Memo1.Lines.Assign(strScrList);
    //Execute
    PythonEngine1.ExecStrings(Memo1.Lines);
    //結果を表示
    Memo2.Lines.Assign(strAnsList);
  finally
    //StringListの解放
    strAnsList.Free;
    strScrList.Free;
  end;

  strFileName:=ExtractFilePath(StatusBar1.SimpleText)+'Temp.csv';
  Memo2.Lines.SaveToFile(strFileName);

  strList := TStringList.Create;
  try
    for i := 0 to Memo2.Lines.Count-1 do
    begin
      strList.Add(Memo2.Lines[i]);
    end;
    //fAscending := True; //昇順で
    fAscending := False;
    fIndex := 1; //2番目の項目を
    fStyle := ssInteger; //整数型でソート
    strList.CustomSort(MyCustomSort); //ソート
    //データ抽出
    Memo2.Clear;
    for i := 0 to strList.Count - 1 do
    begin
      //Memo2.Lines.Add(GetCommaText(strList.Strings[i],fIndex));
      Memo2.Lines.Add(strList[i]);
    end;
  finally
    strList.Free;
  end;

end;

上記のアルゴリズムは、次のWebサイトに紹介されていた情報を元に作成。
カンマ区切りのデータの並べ替えは初めて行った。採点順に座標を並べたかったので、プログラムコードをよく読んで、二重ソートになるよう工夫した。
貴重な情報を投稿してくださった方に心から感謝申し上げます。

[delphi-users:1175] カンマ区切りのデータの並べ替え

https://groups.google.com/g/delphi-users/c/Ck2mQXNFTvw

4.矩形検出器の使い方

ここまでの操作で解答欄の座標はすべて取得できたはずなので、不要な矩形のデータをいかに効率よく削除するかを主眼に、GUIの操作方法を考えた。

まず、取得できた座標データの先頭にセットフォーカスし、そのデータが示す矩形を赤いラバーバンドで囲んで表示する。ユーザーは、ラバーバンドで囲まれた矩形を見て、その要・不要を判断。

この矩形は不要

不要な矩形であった場合は、「編集」ボタンをクリック。不要なデータを自動で選択状態に設定。

Memoの一行全部を選択状態に設定

手続きは次の通り。

procedure TForm1.BitBtn1Click(Sender: TObject);
var
  i:integer;
begin

  //行番号をLines[i]で取得
  i:=StrToInt(LBRow.Caption)-1;

  EditTF:= not EditTF;
  if EditTF then
  begin
    BitBtn1.Caption:='編集中';
    BitBtn1.Font.Color:=clRed;
    Memo2.ReadOnly:=False;
    btnSave.Enabled:=False;

    //i行目の文字全てを選択状態にしたい場合
    //先頭にカーソルをセット
    Memo2.SelStart:=Memo2.Perform(EM_LINEINDEX, i, 0);
    //全ての文字を選択
    Memo2.SelLength:=Length(WideString(Memo2.Lines[i]));
    //Memo2.Perform(WM_VSCROLL,SB_TOP,0); //先頭にスクロール

  end else begin

    BitBtn1.Caption:='編 集';
    BitBtn1.Font.Color:=clBlack;
    Memo2.ReadOnly:=True;
    Memo2.SelStart:=SendMessage(Memo2.Handle,EM_LineIndex,i,0);
    btnSave.Enabled:=True;
    Memo2Click(Sender);

  end;

  //SetFocus
  Memo2.SetFocus;

end;

Delete or Backspaceキーで不要なデータを削除すると同時に、Memoの行も削除する。で、ボタンを「編集」(=意味的には「編集したい場合はクリックせよ」)に戻す。次のデータをラバーバンドで囲む。この一連の動作がすべて自動的に流れ作業で行われるように手続きを作成。

コードは次の通り。

procedure TForm1.Memo2KeyUp(Sender: TObject; var Key: Word; Shift: TShiftState);
var
  LineNo:integer;
begin
  //現在、カーソルがある行を取得
  LineNo:=Memo2.Perform(EM_LINEFROMCHAR, UINT(-1), 0);
  //空欄なら行を削除
  if Memo2.Lines[LineNo]='' then
  begin
    Memo2.Lines.Delete(LineNo);
  end;
  //表示
  GetLinePos;
  if not EditTF then
  begin
    Memo2Click(Sender);
  end else begin
    BitBtn1Click(Sender);
  end;
end;
procedure TForm1.GetLinePos;
var
  CurPos,Line:Integer;
begin
  with Memo2 do
  begin
    CurPos:=SelStart;
    Line:=Perform(EM_LINEFROMCHAR, CurPos, 0);
    //LBRowは現在フォーカスがある行番号を表示するラベル
    LBRow.Caption:=Format('%d', [Line+1]);
    LBRow2.Left:=LBRow.Left+LBRow.Width;
    LBRow2.Caption:='行目';
  end;
end;
procedure TForm1.Memo2Click(Sender: TObject);
var
  i:integer;
  p1,p2:TPoint;

  function RemoveToken(var s:string;delimiter:string):string;
  var
    p:Integer;
  begin
    p:=Pos(delimiter,s);
    if p=0 then Result:=s
    else Result:=Copy(s,1,p-1);
    s:=Copy(s,Length(Result)+Length(delimiter)+1,Length(s));
  end;

  function GetTokenIndex(s:string;delimiter:string;index:Integer):string;
  var
    i:Integer;
  begin
    Result:='';
    for i:=0 to index do
      Result:=RemoveToken(s,delimiter);
  end;

begin

  if not EditTF then
  begin

    //座標を取得
    i:=Memo2.Perform(EM_LINEFROMCHAR, Memo2.SelStart, 0);

    //エラー対策
    if Memo2.Lines[i]='' then Exit;

    x1:=StrToInt(GetTokenIndex(Memo2.Lines[i],',',0));
    y1:=StrToInt(GetTokenIndex(Memo2.Lines[i],',',1));
    x2:=StrToInt(GetTokenIndex(Memo2.Lines[i],',',2));
    y2:=StrToInt(GetTokenIndex(Memo2.Lines[i],',',3));

    if Assigned(plImage1) then begin
      FreeAndNil(plImage1);
    end;

    //コンポーネントを生成し,イベントを定義し,位置を指定して画像を表示
    plImage1:=TplResizeImage.Create(Self);
    plImage1.Parent:=ScrollBox1;
    plImage1.TransEvent:=True;
    //クライアント座標をスクリーン座標へ変換
    //GetSystemMetrics(SM_CYCAPTION) -> タイトルバーの高さ
    //GetSystemMetrics(SM_CYFRAME) -> ウィンドウの枠幅
    p1.X:=x1-(GetSystemMetrics(SM_CYFRAME) div 2);
    p1.Y:=y1-GetSystemMetrics(SM_CYCAPTION)-(GetSystemMetrics(SM_CYFRAME) div 2);
    p2.X:=x2-(GetSystemMetrics(SM_CYFRAME) div 2);
    p2.Y:=y2-GetSystemMetrics(SM_CYCAPTION)-(GetSystemMetrics(SM_CYFRAME) div 2);
    p1:=Image1.ClientToScreen(p1);
    p2:=Image1.ClientToScreen(p2);
    plImage1.SetBounds(p1.X, p1.Y, p2.X-p1.X, p2.Y-p1.Y);

    //SelectedプロパティをTrueにするとラバーバンドとグラブハンドルが表示される
    plImage1.Selected := True;
    plImage1.BringToFront;

  end;

end;

ラバーバンドはMr.XRAYさんのWebサイトにあったplResizeImageを使わせていただいて作成。これまでにもどれだけ助けていただいたことか・・・。このような素晴らしい素材を提供し続けてくださっているMr.XRAYさんに今回も心から感謝申し上げます。

157_移動リサイズ可能な TImage   ラバーバンドとグラブハンドル

http://mrxray.on.coocan.jp/Delphi/plSamples/157_MoveResize_GrabHandle.htm

ラバーバンドで囲まれた矩形が必要な矩形であった場合は、下のMemo3へ「移動」ボタンをクリックしてデータを移す。で、次の矩形をラバーバンドで囲んで表示する。

次の矩形の要・不要を判断
必要な矩形であれば下のMemo3へ移動する

この作業を順次繰り返すと、最終的に必要な矩形の座標のみがMemo3に移動。不要な矩形の座標はすべて削除されることになる。

必要な矩形の座標のみ、採点順に取得できた!

最終的に過不足がないか・どうか、Memo3の先頭座標データをクリック、ラバーバンドで該当矩形を囲んで表示、下向きの矢印キーを次へ次へと押して、フォーカスを下の座標データへ移動、ラバーバンドを表示して確認、これを最後の座標データまで繰り返し。

採点順を含めて、必要な座標データがすべて揃っていることを先頭データから順に確認する。

必要な座標がすべて取得できていることを確認したら、「保存」ボタンをクリックして手書き答案採点ソフトが実行時に読み込む、様々な採点設定を記録するための iniファイルに解答欄の座標データを保存する。

データの保存

【任意の範囲を指定したい場合】

複数の解答欄を抱き合わせて、完全解答で正解としたい場合などに対応するため、任意の範囲を矩形選択できるようにした。

画面中央左の追加ボタンをクリックすると、画面の中央にラバーバンドが表示される。これを任意の位置へドラッグする。

追加ボタンをクリックしてラバーバンドを表示
画面の中央にラバーバンドを表示、これを任意の位置へドラッグ。

ボタンのCaptionは、自動で「取得」に変更。

ボタンのCaptionを変更

任意の範囲をラバーバンドで囲んだら(=範囲指定完了)、「取得」ボタンをクリック。取得された座標がボタンの右のEditに表示され、同時にクリップボードへ送られる。

任意の範囲を指定して座標を取得

Memo3上の「追加」ボタンをクリックすると、Memo3が編集可能になるので、採点順を確認して、適切な行に座標のデータを追加(クリップボードから貼り付けても、データを見ながら手動入力してもよい)。

適切な位置に座標のデータを入力する

ラバーバンドを使わなくても、解答欄の左上と右下を、それぞれポイントすればその座標をラベルに表示する機能も追加したので、上の図のように、Memo3を編集モードにして、座標を任意の行へ直接入力することも可能。

マウスでポイントした場所の座標をリアルタイムで表示する

クライアント座標の取得と表示を行う手続きは、次の通り。

procedure TForm1.Image1MouseMove(Sender: TObject; Shift: TShiftState; X,
  Y: Integer);
var
  PtInput:TPoint;
begin
  //スクリーン座標を取得
  GetCursorPos(PtInput);
  //で、そのコントロールのクライアント領域に対するカーソルの座標を取得
  PtInput := Image1.ScreenToClient(PtInput);

  //補正する必要はない(確認済み)
  //表示
  Label2.Caption:=
    Format(' クライアント座標  '+'X : %d, Y : %d', [PtInput.X, PtInput.Y]);
end;

【矢印キーの押し下げを拾う】

最も難しかったのが、フォーカスが「どこにあるか」で矢印キーの挙動を制御すること。以前にStringGridのセルのフォーカスの移動を制限した時に学んだ内容が今回も役に立った。

今回は、Memoにフォーカスがある場合と、ラバーバンドにフォーカスがある場合、さらにラバーバンドにフォーカスがある場合のうち、Shiftキーと同時に矢印キーが押し下げられているのか(=ラバーバンドの大きさの変更)、それとも矢印キーが単独で押し下げられているのか(=ラバーバンドの表示位置の移動)、この3パターンを見分けてそれぞれにあった動作を行わせたいと考えた。最終的には次のコードで対応。

  private
    { Private 宣言 }

    //ある(矢印他)キーが押されたことを知る
    procedure AppMessage(var Msg: TMsg; var Handled: Boolean);

上のように手続きを宣言して、Shift+Ctrl+Cで手続きを生成。

procedure TForm1.AppMessage(var Msg: TMsg; var Handled: Boolean);
var
  StrText: string;
begin
  //何かキーが押し下げられたら
  if Msg.message = WM_KEYDOWN then
  begin
    try
      if ActiveControl is TMemo then
      begin
        //キー操作を「通常動作」にするおまじない
        case Msg.Message of
          WM_USER + $0500:
          Handled := True;
        end;
      end else begin
        //上位ビットが1ならShiftキーが押されている
        if GetKeyState(VK_SHIFT) and $8000 <> 0 then
        begin
          if plImage1.Visible then
          begin
            //右矢印キー
            if Msg.wParam=VK_RIGHT then
            begin
              plImage1.Width := plImage1.Width + 1;
              Msg.wParam:=0;
            end;
            //左矢印キー
            if Msg.wParam=VK_LEFT then
            begin
              plImage1.Width := plImage1.Width - 1;
              Msg.wParam:=0;
            end;
            //上矢印キー
            if Msg.wParam=VK_UP then
            begin
              plImage1.Height := plImage1.Height - 1;
              Msg.wParam:=0;
            end;
            //下矢印キー
            if Msg.wParam=VK_DOWN then
            begin
              plImage1.Height := plImage1.Height + 1;
              Msg.wParam:=0;
            end;
          end;
        end else begin
          //Shiftキーは押されていない
          //対象を限定(どちらでも動いた)
          //if TplResizeImage(ActiveControl).Visible then
          if plImage1.Visible then
          begin
            //右矢印キー
            if Msg.wParam=VK_RIGHT then
            begin
              plImage1.Left := plImage1.Left +1;
              Msg.wParam:=0;
            end;
            //左矢印キー
            if Msg.wParam=VK_LEFT then
            begin
              plImage1.Left := plImage1.Left -1;
              Msg.wParam:=0;
            end;
            //上矢印キー
            if Msg.wParam=VK_UP then
            begin
              plImage1.Top := plImage1.Top - 1;
              Msg.wParam:=0;
            end;
            //下矢印キー
            if Msg.wParam=VK_DOWN then
            begin
              plImage1.Top := plImage1.Top + 1;
              Msg.wParam:=0;
            end;
            //Deleteキー
            if Msg.wParam=VK_DELETE then
            begin
              //plImage1を解放
              if Assigned(plImage1) then begin
                FreeAndNil(plImage1);
              end;
              Msg.wParam:=0;
            end;
          end;
        end;
      end;
    except
      on E: Exception do
      begin
        StrText := E.ClassName + sLineBreak + E.Message;
        Application.MessageBox(PChar(StrText), '情報', MB_ICONINFORMATION);
      end;
    end;
  end;
end;

plImage1が生成されないうちに上の手続きが呼ばれると、当然、一般保護違反のエラーが発生するので、FormCreate時にplImage1を生成しておく。

procedure TForm1.FormCreate(Sender: TObject);
var
  //Python39-32へのPath
  AppDataDir:string;
  i:integer;
begin

  //メモリーリークがあれば検出
  ReportMemoryLeaksOnShutdown:=True;

  //有効にする(忘れないこと!)
  Application.OnMessage := AppMessage;

  //[Enter]でコントロールを移動させるために、Form上のコンポーネント
  //より先にFormがキーボードイベントを取得する。
  KeyPreview:=True;

  //コンポーネントを生成 -> インスタンス(実体)をつくる
  // = 一般保護違反エラーの防止
  //plImage1はグローバル変数として宣言しているから未定義の識別子エラーは発生しない
  //でも、Create(生成)してからでなければ使えない!
  plImage1:=TplResizeImage.Create(Self);

  //編集フラグ(編集中ではない)
  EditTF:=False;
  PlusTF:=False;
  Memo2.ReadOnly:=True;

  //StatusBar1の設定
  StatusBar1.SimplePanel:=True;

  //Formを最大化して表示(幅も最大化される)
  Form1.WindowState:=wsMaximized;

  //Embeddable Pythonの存在の有無を調査
  AppDataDir:=ExtractFilePath(Application.ExeName)+'Python39-32';
  if DirectoryExists(AppDataDir) then
  begin
    //フォルダが存在したときの処理    
    PythonEngine1.AutoLoad:=True;
    PythonEngine1.IO:=PythonGUIInputOutput1;
    PythonEngine1.DllPath:=AppDataDir;
    PythonEngine1.SetPythonHome(PythonEngine1.DllPath);
    PythonEngine1.LoadDll;
    //PythonDelphiVar1のOnSeDataイベントを利用する
    PythonDelphiVar1.Engine:=PythonEngine1;
    PythonDelphiVar1.VarName:=AnsiString('var1');  //プロパティで直接指定済み
    //初期化
    PythonEngine1.Py_Initialize;
  end else begin    
    PythonEngine1.AutoLoad:=False;
  end;

  //面積の閾値の選択肢を設定
  for i := 1 to 200 do
  begin
    cmbThreshold.Items.Add(IntToStr(i*100));
  end;

  //画面のちらつきを防止する
  DoubleBuffered := True;

end;

で、メモリーリーク発生の原因とならないよう、アプリの終了時に忘れずに解放。

procedure TForm1.FormCloseQuery(Sender: TObject; var CanClose: Boolean);
begin
  //メモリーリークを防止する
  PythonEngine1.Py_Finalize;
  PythonDelphiVar1.Finalize;
  FreeAndNil(plImage1);
end;

5.まとめ

(1)矩形の検出は、OpenCVのfindContours関数を利用する。
(2)矩形の検出を回避するには「点線」を利用する。
(3)GUIはDelphiで作成し、必要な座標だけ保存できるように工夫。
(4)「フォーカスがどこにあるか」で矢印キーの動作を制御。
(5)コントロール生成のタイミングと確実な破棄にも注意する。

6.お願いとお断り

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

Controlling Message Dialog Buttons

メッセージダイアログのボタンを制御する!

メッセージダイアログのボタンを「状況によってはクリックできないように設定」する必要に迫られて、その方法を調べてみたのだけれど、探した範囲では見つからず、よくよくコードを眺めたら、ボタンにはEnabledプロパティがあることに気付き、簡単に実現できちゃった・・・というお話。

1.ヒトはよく間違える
2.ボタンをクリック不可に設定
3.まとめ
4.お願いとお断り

1.ヒトはよく間違える

TImageに表示した画像上で、連続して矩形選択するプログラムを書いた。ユーザーが画像上で矩形選択する毎に次のメッセージダイアログを表示し、続けて矩形選択する場合は「はい」や「やり直し」、指定した選択範囲をすべて保存して終了する場合は「終了」、設定内容を保存せずに終了する場合は「キャンセル」を、それぞれ選択できるようにプログラミングしたのだが・・・

説明をよく読んで作業してもらえば大丈夫だと思ったんだけど・・・

50回とか、それくらい連続して矩形選択を繰り返すと、『終了』をクリックしなければならない場面で、つい・うっかり、『はい』をクリックしてしまうという、いかにも人間らしい失敗があちこちで発生。この問題が起きるのを防止するため、次のようにラベルに残りの選択数を表示して注意を促したが・・・

ラベルに残りの選択回数を表示

「あー!まちがえちゃったー☆」という声が増えはしても、減ることはなく、根本的な間違いクリック防止対策を施す必要性を痛感。で、行うべき対策はただひとつ。矩形選択が残り0回になったら、表示するメッセージダイアログの「はい」ボタンをクリック不可能に設定する & それまでは「はい」ボタンを初期選択状態としていたのを「終了」ボタンに変更する。これだけ!

早速、『Delphi メッセージダイアログ ボタン Enabled』を検索キーワードにしてGoogle先生にお伺いをたてたが探した範囲では、参考となる情報は見つからず。

仕方がないので、残り0回になったら「はい」ボタンのないメッセージダイアログを表示することにしようか・・・と思いつつ、コードを眺めていたら、

TButton(Dlg.FindComponent('YES')).Caption := 'はい';

TButton・・・? の
Captionプロパティを「はい」に設定してる・・・?

・・・ってコトは、当然、Enabledプロパティも設定できるはず! と気づき、早速設定☆

2.ボタンをクリック不可に設定

矩形選択の残りの回数がゼロになった時点で「はい」ボタンのEnabledプロパティをFalseに設定し、これをクリックできないようにする。さらにそれまでは「はい」ボタンが初期選択状態であったのを「終了」ボタンに変更してみた。その方法は次の通り。

var
  msg : string;
  rc : integer;
  Dlg : TForm;
begin
  Dlg:=CreateMessageDialog(msg,mtConfirmation,[mbYes,mbNo,mbOK,mbCancel]);
  try
    //フォームの中央に表示
    Dlg.Left:=Form1.Left+(Form1.Width -Dlg.Width ) div 2;
    Dlg.Top:=Form1.Top +(Form1.Height-Dlg.Height) div 2;
    //ボタンの文字を変更
    TButton(Dlg.FindComponent('YES')).Caption := 'はい';
    TButton(Dlg.FindComponent('NO')).Caption := 'やり直し';
    TButton(Dlg.FindComponent('OK')).Caption := '終了';
    TButton(Dlg.FindComponent('CANCEL')).Caption := 'キャンセル';
    //選択(クリック)の可否を設定
    if StrToInt(矩形設定数.Text) - 矩形選択数 <> 0 then
    begin
      //「はい」ボタンを選択できる
      TButton(Dlg.FindComponent('YES')).Enabled := True;
      //初期選択状態のボタンを「はい」にする
      Dlg.ActiveControl := TWinControl(Dlg.FindComponent('YES'));
    end else begin
      //「はい」ボタンは選べない
      TButton(Dlg.FindComponent('YES')).Enabled := False;
      //初期選択状態のボタンを「終了」にする
      Dlg.ActiveControl := TWinControl(Dlg.FindComponent('OK'));
    end;
    //表示
    rc := Dlg.ShowModal;
  finally
    Dlg.Free;
  end;

  //必要に応じて記述
  case rc of
    mrYes: begin
      //「はい」が選択された場合の処理

    end;
    ・・・省略・・・
  end;

end;

テストしてみました!
矩形選択数が残り0(ゼロ)になると・・・

「はい」ボタンはクリックできない!

これでもう大丈夫☆
このシーンで間違って「キャンセル」をクリックするヒトは、そんなに多くないはず・・・

3.まとめ

メッセージダイアログに表示した特定のボタンをクリックできないようにするには・・・

//「はい」ボタンをクリック不可に設定
TButton(Dlg.FindComponent('YES')).Enabled := False;

メッセージダイアログは何度も使ってきたけれど、ボタンのEnabledを設定したのは今回が初めての経験でした!!

4.お願いとお断り

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

If you want to show ScrollBar.

スクロールバーを表示したい時は・・・

複数のTImageを切り替えて表示し、あんな処理やこんな処理やそんな処理をそれぞれの場面で実行する場合、「あんな処理」ではGoodだったことが、「こんな処理」ではBadになっちゃうことがある。・・・んで、プログラムをイロイロいじって矛盾をなんとか解消し、さらに、エラー対策をこれでもか!と詰め込み、はたまた新たに要望のあった新機能を追加・・・、もう自分でも全体像がつかめないほど矜羯羅がってスパゲッティ状態になったプログラムに、総仕上げの「そんな処理」を書き足し、その中でTImageに画像を表示したら、自動的に出るはずの『スクロールバーが出ない!』みたいなー (T_T)

これまでにも、何度もこの問題で悩み、苦しんだ末に、とうとう解決方法を見つけたというお話。

1.問題が起きる状況
2.解決方法
3.お願いとお断り

1.問題が起きる状況

Formに、ScrollBoxをのせ、その上に複数のImageを載せた状態で、状況に応じてImageを切り替えながら作業するような場合、『どこか』で・『なにか』を(設定)やってしまっていて、FormCreate時にスクロールバーが自動的に出るよう、

ScrollBox1.AutoScroll:=True;

と、設定してあるにもかかわらず、ScrollBoxよりはるかに大きな画像を表示しても、垂直・水平両方向のスクロールバーが『出ない!』みたいな・・・

2.解決方法

どこで、なにをやったのか、徹底的に調べて問題を解決するのが本当なんだろうけれど(この正しい解決方法にチャレンジした結果)、あっちをイジったら、こっちがオカしくなり、こっちを直したら、あっちがコケた!みたいなことになるのが怖い。

そこで「これまでに書いたコードには一切変更を加えずに問題を解決する方法」を模索。

そもそも、ScrollBoxにScrollBarが表示される仕組み自体がわからない。その仕組みを調べてみると・・・

Vcl.Forms.TControlScrollBar.Range

https://docwiki.embarcadero.com/Libraries/Alexandria/ja/Vcl.Forms.TControlScrollBar.Rangeより引用

『水平スクロールバーの Range がフォームまたはスクロールボックスの幅より小さい場合,水平スクロールバーは表示されません。垂直スクロールバーの Range がフォームまたはスクロールボックスの高さより小さい場合,垂直スクロールバーは表示されません。』

・・・と説明されている。ってコトは、Imageコントロールに画像をセットした状態で、上の『スクロールバーが表示されない状態を明示的に回避(Rangeの値を手動で大きく設定)』してあげれば、スクロールバーが必ず表示されるはず。そう思って書いたのが次のコード。

  //水平スクロールバーの Range がスクロールボックスの幅より小さい場合,
  //水平スクロールバーは表示されない
  if ScrollBox1.HorzScrollBar.Range < ScrollBox1.Width then
  begin
    //表示したい画像の幅をRangeの値に設定
    ScrollBox1.HorzScrollBar.Range := Image1.Picture.Bitmap.Width;
  end;

  //垂直スクロールバーの Range がスクロールボックスの高さより小さい場合,
  //垂直スクロールバーは表示されない
  if ScrollBox1.VertScrollBar.Range < ScrollBox1.Height then
  begin
    //表示したい画像の高さをRangeの値に設定
    ScrollBox1.VertScrollBar.Range := Image1.Picture.Bitmap.Height;
  end;

  //ScrollBarを最も上へ&最も左へ移動
  ScrollBox1.VertScrollBar.Position := 0;
  ScrollBox1.HorzScrollBar.Position := 0;

  //ScrollBarを最も下へ&最も右へ移動
  //ScrollBox1.VertScrollBar.Position := VertPositionMax(ScrollBox1);
  //ScrollBox1.HorzScrollBar.Position := HorzPositionMax(ScrollBox1);

  //画像を表示
  Image1.Visible := True;

このように「これまでに書いたコードには一切変更を加えずに問題を解決する」ことに、
一応成功☆

もしかしたら、もしかしたら、同じ問題で悩んでいる人が、
どこかにいるかもしれないから・・・

3.お願いとお断り

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

Button to Scroll Horizontally

横スクロールボタン

縦書きの手書き答案をスキャンして画像化し、設問毎に解答欄画像をかき集め、PCとコラボして採点するプログラムを書いた。ちなみに実行時の画面はこんな感じ。

縦書き答案をスキャンして、PCとコラボして採点

縦書き答案は、基本的に国語のテストで使用されるアレだ(国語以外の教科・科目ではまず使わない?)。通常、設問1の解答欄が最も右側にあり、解答は縦書きで、左へ向かって順次記入する形式になっている。プログラムの仕様をこれに合わせる必要もないかと思ったが、取り敢えず、郷に入ればなんとやらで、答案の形式に倣って、スキャンした答案画像から設問1の解答欄画像をかき集めて右から左へ、出席番号順に並べて表示してみることにした。PCで何か測る場合の座標原点は、左上を(0,0)にするのが普通だから、計算式を考えるのに著しく、いや、激しく頭が混乱したが、頑張って、なんとかこれを実現。

西洋式機械文明と和の精神文化の見事なる融合がここに結実。よかよか *(^_^)*♪
えぇ天気じゃのー

・・・ってか、正直、なんか画面の並びにワタシ、微妙な違和感があるんですけど・・・
まぁ 今さら気にしても仕方ない。深く考えずに、次へ

困ったのはその次。横書き答案の場合は、マウスのホイールをクルクル回せば、画像が縦にどんどんスクロールするから何の問題もないのだが、縦書き答案の場合、採点する際、横のスクロールバーをいちいちドラッグして、画面を左へ・左へと動かさなくてはならない。これがいまいち、どうにも使いづらい(気がした)。

マウスのホイールを廻して、縦ではなく横方向にスクロール・・・って方法もあるんじゃないかと思い、調べてみると「Ctrl+Shift+ホイール回転」で横スクロールできるらしい。

実際に試してみると(何もプログラムを書かなくても)、右へ・左へ、答案画像は確かに横にスクロールする。つまり、これはWindowsの標準仕様なのだ。

でも、一般的に広く認知されている方法ではない(と思う)し、何よりMy採点プログラムは「スクロールは右手・採点は左手が担当する」ことが設計のポイント(こだわり)。私的には、ここでそれを曲げるわけにはいかない・・・(もちろん、ユーザーに「Ctrl+Shift+ホイール回転」で横スクロールすることは案内するつもりだけれど、それに頼らない方法も準備したい・・・)。

そこで思いついたのが、ボタンを使って移動できないか? ということ。設定部に用意したComboBoxで移動人数10名を指定すれば10名分、ScrollBoxではなく、Formにドッキングさせた「スクロールに追従しない」ボタンで、Imageに表示した解答欄画像を左へ(or 右へ)スクロールするプログラムが出来たら使い勝手がよかろーということで、これは、その作成にチャレンジした記録。

1.フローティングで行こう!
2.ToolBarの「閉じる」ボタンを無効化
3.初めてBevelを使う
4.まとめ
5.お願いとお断り

1.フローティングで行こう!

実は前からやってみたかったんだ。VCLコンポーネントのフローティング。
でも、その機会になかなか恵まれなくて、今回、初めて、それにチャレンジ!

移動用のボタンを作成するには、どうするのがいちばんイイのか、調べてみると、ControlBarの上にToolBarを載せ、このToolBar内にToolButtonを作る方法がいちばん良さそうだ。これでやってみてダメだったら、その他の方法を考えることにして、まず、この方法で作ってみることに決定。

復習を兼ねて、作り方を以下に再現。

解答欄の画像はScrollBoxの上に載せたImageに表示している。まず、練習用のFormにPanelを1つ載せて、AlignをalRightに設定。次に、FormにScrollBoxを1つ載せて、AlignをalClientに設定。さらに、このScrollBoxの上にImageを1つ載せる(AlignはalNone)。

FormにVCLコンポーネントを3つ載せ、それぞれにAlignを設定

次に、ScrollBoxをクリックして選択して、その上にControlBarを1つ載せる。

ScrollBoxにControlBarを載せ、そのAlignをalTopに指定

このControlBarの各種プロパティは、以下のように設定。

  1. Alignは「alTop」を指定。
  2. このままだと存在感がありすぎるので、BevelKindプロパティを「bkNone」にして立体感(=境界線)を消す。
  3. AutoSizeプロパティを 「True」にして、ツールバーが複数ある場合に大きさ(幅と高さ)が自動的に変わるように設定。
  4. ドッキングを受け入れる側なので、DockSiteプロパティを「True」に設定。

構造ペインとオブジェクトインスペクタの様子は・・・

構造ペイン

ちなみにペイン(Pane)とは、枠や区画のことなんだそうな。ずっと「痛(イテ)ぇ」だと思ってたのは私だけ? そっちは「Pain」で同音異義語とのこと。だから「構造痛ぇ」じゃなくて「構造枠・区画」でした・・・。英語もイロイロむずかしいな。

ControlBar1のオブジェクトインスペクタ(その1)
ControlBar1のオブジェクトインスペクタ(その2)

次に、ControlBarをクリックして選択し、その上にToolBarを1つ載せる。

ControlBar(見えない)の上に、ToolBarを載せたところ

ToolBarの各種プロパティは、以下のように設定。

  1. AlignプロパティをalNoneにして、大きさを小さくする。
  2. ShowCaptions プロパティを True にする。
  3. ToolBar1を右クリックし、表示されるポップアップメニューの「ボタンの新規作成」を選択。これでツールバーの上に[ToolButton1]が作成される。
  4. 続けて右クリックして、表示されるポップアップメニューから「セパレータ新規作成」を選択すると[ToolButton2]という名前のセパレータが出来る(名前は気にしない)。
  5. さらに右クリックして、表示されるポップアップメニューの「ボタンの新規作成」を選択。これでツールバーの上に[ToolButton3]が作成される。
  6. 構造ペインでToolBarをクリックし、オブジェクトインスペクタのWidthプロパティの値を「180」に変更(フローティングさせた時、ユーザーが扱いやすくなるよう、一工夫)
  7. ドッキングに対応させるため、DragKindプロパティを「dkDock」に設定。
  8. ドッキングに対応させるため、DragModeプロパティを「dmAutomatic」に設定。

4.でセパレータを作成するときの画面

5.で[ToolButton3]を作り、さらに6.で幅を広げた時の画面

構造ペインとオブジェクトインスペクタの様子は・・・

セパレータの名前が[ToolButton2]なのが気になるが、見なかったコトに・・・

セパレータの名前が「どうしても気になる」場合は、構造ペインでToolButton2を選択し、オブジェクトインスペクタのNameプロパティやCaptionプロパティを「MySeparator」等に変更し、さらにToolButton3のNameプロパティやCaptionプロパティを「ToolButton2」にすると満足できるかもしれません・・・。が、説明の都合上、私はこのままで行きます(変更しません)。

7.と8.を設定した時の画面

これはドッキングさせるためのお呪いみたいなもんかなー

へぇー。「おまじない」って漢字で書くと「お呪い」なんだー。
これ、読めって言われたら、私は間違いなく「おのろい」と読んだと思いますが。
日本語もイロイロですな・・・

コードはまだ何にも書いてないけど、この状態で保存して、実行すると・・・

ドッキング状態でToolBarが表示されている
ToolBarは、左右にドラッグして移動することが出来る。静かに感動。
ToolBarを取り外してフローティング状態にすることも出来る。感動を超えて感激。
(さらにフォーム画面上部に移動すれば、またドッキングしちゃったりする)

オレ、プログラム1行も書いてないけど。Delphiすげー!!

ToolBarをフローティングさせたり、移動する際、画面枠の線だけが表示される。
動きがスムーズでなく、かなり「ぎくしゃく」している。感動に疑問符が・・・

このフローティングさせた時の動きが、なんか、気に入らない。チラついてる感があって、ぎくしゃくしていて、かつ、鈍重な感じ。調べてみると、これは改善できるらしい。ToolBar1 の OnStartDock イベントで、以下を記述。

OnStartDock の右の空白部分をダブルクリック
procedure TForm1.ToolBar1StartDock(Sender: TObject;
  var DragObject: TDragDockObject);
begin
  DragObject := TToolDockObject.Create(Sender as TToolBar);
end;

保存して、実行。ToolBarをフローティングさせると、ちらつく枠線でなく、ToolBar本体が表示されたまま、スムーズに移動する。最初からこうしておいてほしかった!!

ちらつく枠線じゃなく、フローティングしているToolBarがスムーズに移動することを確認

今はToolBarしかないから、特に必要じゃないけど、他にもVCLコンポーネントがある場合は、コントロールバーにドラッグされたVCLコンポーネントがToolBarであった場合のみ、ドッキング可としなければならない。これを実現するには、ControlBarのOnGetSiteInfoイベントを次のように設定。

OnGetSiteInfoの右の空白部分をダブルクリック
procedure TForm1.ControlBar1GetSiteInfo(Sender: TObject; DockClient: TControl;
  var InfluenceRect: TRect; MousePos: TPoint; var CanDock: Boolean);
begin
  if Not (DockClient is TToolBar) then
  Candock := False;
end;

ここまでの内容は、次のWebサイト様で紹介されていた内容を引用&参考にさせていただきました。作者様に心より感謝申し上げます。

ドッキングコントロールを使ってみる

http://www.surveytec.com/prog/delphi/del4rev/dock.html

Delphi2010 コントロールバー(ControlBar)

http://afsoft.jp/program/del2010/p11_033.html

で、最後にToolBarの受け入れ先をFormに設定(いつまでもフローティングさせておくわけにもいかない)。Formを選択しておいて、オブジェクトインスペクタのDockSiteプロパティを「True」にするだけでOK!

ToolBarのドッキング先(受け入れ先)をFormに設定

ここまでの設定をテストするには、何でもいいのでFormに設置したScrollBoxより大きな画像を用意(例:デスクトップをそのままキャプチャーして保存するとか)して、Image1のPictureプロパティでこの画像を指定する。で、Image1のAutoSizeプロパティを「True」に設定。また、ScrollBoxのAutoScrollプロパティがTrueに設定してあることも確認。

Image1のPictureプロパティで大きな画像を指定

参考:画像なんて準備できない・・・という場合

ScrollBoxのAutoScrollプロパティがTrueに設定してあれば、内部に配置したVCLコンポーネントのサイズが枠内に表示しきれないほど大きくなると、自動でスクロールバーが表示される(AutoScrollプロパティがFalseだとスクロールバーは現れない)。

やってみた!
ScrollBoxの幅と高さより、Imageの幅と高さを大きく設定すれば、ScrollBarが自動的に表示されるはず。

Imageが小さい場合、ScrollBarは表示されない
Imageの幅をScrollBoxの幅より大きくすると、横のスクロールバーが表示される
Imageの幅に加え、高さも大きく設定。すると縦のスクロールバーも追加される。
スクロールバーは、表示されるだけでなく、実際にスクロールすることもできる!

参考:画像なんて準備できない・・・ は、ここまで

ScrollBoxのプロパティについて、学んだことをちょっとまとめた!

【ScrollBoxのRangeプロパティ】
スクロールボックス内部に作成される(仮想的な)表示領域のサイズと考えればいいようだ。このRangeのサイズがScrollBoxのサイズより大きくなると、スクロールバーが自動的に現れる。

【ScrollBoxのMarginプロパティ】
スクロールボックスの右下端の「余白」領域のことで、内部のコンポーネントとスクロールボックスの端との距離が、この値より小さくなると、スクロールバーが自動的に現れる。

【ScrollBoxのTrackingプロパティ】
ついでにScrollBoxの縦・横のスクロールバーのTrackingプロパティを「True」にして、スクロールバーを移動させた時、表示されている画像も同時に動くように設定を変更。もし、これを行わない(デフォルト設定のFalseのままだ)と、スクロールバーを動かしている最中は画像は動かず、バーを動かし終えた瞬間に、バーの移動量だけ、画像の表示位置が飛ぶようにずれるスクロールになる。

ScrollBox1の縦のスクロールバーのTrackingプロパティをTrueに設定
ScrollBox1の横のスクロールバーのTrackingプロパティをTrueに設定

同じことを、コードで設定する場合は・・・

  //滑らかぁーにスクロール
  Scrollbox1.VertScrollBar.Tracking := True;
  Scrollbox1.HorzScrollBar.Tracking := True;

保存して、実行する。スクロールボックスより十分に大きい画像を準備したので、縦・横ともにスクロールバーが自動的に表示される。

スクロールバーが自動的に表示されない時は・・・?(間違いかもしれない私の経験)

私がどこかで、なにかを、間違えているのかも、知れないが(自分が絶対に正しいという自信はまるでないけれど)、オブジェクトインスペクタで予め、ScrollBoxのAutoScrollプロパティを「True」に設定してあるにもかかわらず、画像を表示する際に、プログラムコードの中でこれを明示的に指定しないと、「横の」スクロールバーが表示されなくなる現象が、これまでに少なくても2回あった(両方出ないならまだしも、この現象に遭遇した時、縦のスクロールバーは2回とも自動的に表示されていた)。

私がどこかで、なにかを、間違えていたのかも、知れないが、原因が皆目わからず、もちろん検討もつかず、途方に暮れ、悩みに悩んでようやく発見したスクロールバーが自動で表示されなかったトラブルの解決方法なので、いちおう、ここに書いておきます・・・。

  //オブジェクトインスペクタのプロパティでTrueに指定してあっても
  //再指定しないと横スクロールバーは表示されない!
  ScrollBox1.AutoScroll:=True;

もしかしたら、RangeプロパティやMarginプロパティ関係の設定値のどこかに真の原因があったのかもしれないが・・・。あの時、ものすごく、困ったことは本当で、この方法で解決できたことも、本当だから。もしかしたら、同じことで悩んでいる人が・・・どこかに・・・

ToolBarのフローティングとドッキングを確認!

ToolBarをフローティングさせ、Formにドッキングしたところ
画像をスクロールしても、ToolBarの位置は変わらない

2.ToolBarの「閉じる」ボタンを無効化

ここまでの設定だと、プログラム起動時のToolBarは、下のように、画面のTopにあるControlBar(見えない!)にドッキングしている状態で表示される。

プログラム起動時、ToolBarはControlBarにドッキングしている状態で表示される

実は、自分的にはこれがちょっと気に入らない。なぜかというと、作った採点プログラムは横書き答案でも、縦書き答案でも、どちらも採点可能なプログラム。

で、横書き答案を採点する場合には、マウスのホイールをブンブン廻す「縦のスクロール」で解答欄画像を次々に表示できるから、「ボタンでスクロール(表示を移動)」させる機能はオプション設定で、ユーザーが明示的に選択した場合だけ使えれば十分。必要がなければToolBarのVisibleはFalseに設定し、「最初から表示しない」くらいでちょうどいい。

一方、縦書き答案を採点する場合は、ToolButtonのCaptionを「左へ移動」・「右へ移動」に設定したToolBarを最初から表示し、これを使って横にスクロールする機能をユーザーに提供したい。それだとControlBarにドッキングさせた状態(=画像と一緒にスクロールしてしまう)ではなく、最初からフローティングさせて表示し、「横スクロールはこれだよ!」ってユーザーに積極的にPRしたい。

また、ToolBarが「フローティング」すること自体を知らないユーザーも当然いるはずだし、何より、ControlBarからフローティングさせる手順を説明して理解してもらい、ユーザーにそれをやってもらうのは、ユーザーの手間を増やすだけで、ユーザーにとってのメリットは何一つない。必ず使ってもらうなら、最初からフローティングさせておきたい。

最初からFormにドッキングさせておくのも却下。それだと地味で存在を見落としてしまいそうだし、何より、Formの任意の位置に「ユーザーがドッキングさせる」ことが理想的な使用方法だから、フローティング状態からのドッキング位置はユーザー自身が任意の位置に決めてほしい(解答欄の画像は千差万別で高さや幅がことごとく変化するから当然余白の位置も変わり、また、コントロールを操作しやすいと感じる上下左右の位置は、人により異なって当然だろう)。

そこで、PanelにButtonを一つ追加して、ToolBarをフローティング状態で表示できるように、次のコードを書いてみた・・・

Panelの上にButtonを一つ追加
procedure TForm1.Button1Click(Sender: TObject);
var
  r:TRect;
begin
  r.Left:=ScrollBox1.Width div 2;
  r.Top:=ScrollBox1.Height div 2;
  r.Right:=r.Left+ToolBar1.Width;
  r.Bottom:=r.Top+ToolBar1.Height;
  ToolBar1.ManualFloat(r);
end;

保存して実行し、ボタンをクリックすると・・・

実際のプログラムでは、「縦書き答案」が選ばれた時だけフローティング状態で表示する

無事、フローティングした状態でToolBarを表示することができた☆

重要 この逆をやってはイケナイ。

コードでドッキングさせる実験を面白半分でやったら大変なコトに!
エラーメッセージも何もなく、いきなりプログラムが落ちて(Delphiらしくないけど)、PCを再起動しないと、どーにもならなくなっちゃった T_T いったいナンだったんだろー。理由はわかりません。

皆様に謹んで報告申し上げます m(_ _)m

(追伸 アブナイからコードは書きません)

それよりなにより、ここでひとつ、すごく気になったコトがあって、それはせっかくフローティング状態で表示したToolBarだけど、もしユーザーが間違って(或いは故意に)「閉じる」ボタンを押しちゃったら、どう対応するか? ってコト。

地味な灰色のToolBar上で、由々しい存在感を示す「閉じる」ボタン

「由々しい」の意味:そのままにしてはおけない重大な事柄であるさま

実用日本語表現辞典より引用

上の画面で見る限り、ボタンに見えないボタンより、閉じるの [×] マークの方がよほど魅力的に感じます。つい、クリックしたくなるのが人情というもの。やはり、人間は「知」より「情」ですな・・・

で、[×] の誘惑に負けて、ついクリックすると・・・

「さよなら」も言わずに、ToolBarはあっさりと消えた・・・。最後まで無口なイイ奴だったが・・・。

この練習プログラムなら、右上のボタンをクリックすればToolBarは復活するけど、実際の採点プログラムには「ToolBarを表示する」ためだけのボタンを設置する場所はない。ことここに至っては、とるべき方法はただ一つ。何としてもToolBarの [×] ボタンの機能を停止し、ToolBarが非表示になるのを断固阻止しなければならない。よぉーし、オレはやるぞー

・・・ということで、どのように閉じるボタンの機能を停止するか、足りないアタマで考えた。

ボタンそのものを消してしまうのは、調べてみると難しそうだ。最初からグレイアウトさせるのは出来そうだけれど、なんとなーく、面白くない気がした。ちっちゃくても、アレだけ存在感のある [×] 閉じるボタンだもの。ユーザーの皆様にも1度はその真の勇姿を眺めていただき、ぜひ、ついクリックしたくなる「特別な衝動」を感じてもらいたい。

やっぱり、そのなんて言うか、そう!アレだ。アレ。アレで行こう!!
某OSを供給している世界的超有名大企業がよく使う「アンタの責任だよ!」って、アレだ。アレ。あの〇ACに代表される・・・、アレを真似しよう。

〇AC:解答欄(Answer Column)に〇という意味ではありません!

ユーザーが閉じるボタンをクリックしたら、「閉じるボタンを無効化しました!」とメッセージを表示して、それからあらためてボタンをグレイアウトしてクリックできなくする。最初から強制的に使えなくなってたら、「押したいのに、なんでだっ」と、気分を悪くする人もいるかもだけど、試しにクリックしたところ「無効化しました!」というメッセージが出て、それからグレイアウトすれば、きっと「使えたんだけど、無効にしたのはワタシなのね」と、主体的な観点で納得してもらえるんじゃないか? と。

実は「 閉じるボタンを無効化しますか? [はい]・[いいえ]・[キャンセル] 」でもよかったんだけど、さすがにアホらしい気がして、これはボツにしました☆

だって、ナニが選ばれても、ユーザーの意思に関係なく、全部「無効化するつもり」でしたから!

以下、ToolBarの閉じるボタンの機能を停止する方法。

  private
    { Private 宣言 }
  public
    { Public 宣言 }
  end;

  //ToolBarの閉じるボタンを無効化(赤字部分を書いてShift+Ctrl+C)
  TToolDockSite = class(TToolDockForm)
  private
    procedure WMSysCommand(var Msg: TWMSysCommand);
      message WM_SYSCOMMAND;
    end;

uses
  System.UITypes;

  //System.UITypesはMessageDlgを使うために追加

{$R *.dfm}
//手続きを記述
procedure TToolDockSite.WMSysCommand(var Msg: TWMSysCommand);
var
  hBarHandle : HMENU;
begin
  case Msg.CmdType of
    SC_CLOSE: begin
      //処理
      MessageDlg('閉じるボタンを無効化しました!', mtInformation, [mbOk] , 0);

      //閉じたい場合
      //inherited;

      //閉じたくない場合
      //ハンドルを取得
      hBarHandle := GetSystemMenu(Self.Handle,False);

      if hBarHandle <> 0 then
      begin
        //閉じるボタンを無効化する
        EnableMenuItem(hBarHandle, SC_CLOSE, 
          (MF_BYCOMMAND or MF_DISABLED or MF_GRAYED));
        //グレイアウトして無効化されるが、削除はできない
        //DeleteMenu(hBarHandle, SC_CLOSE, 
        //  (MF_BYCOMMAND or MF_DISABLED or MF_GRAYED));
      end;
      DrawMenuBar(Self.Handle);
      //メッセージは「なかった」ことにする
      Msg.Result:=0;

    end;
  else
    inherited;
  end;
end;

で、FormCreate時に、ドッキングを制御するクラスを指定。

procedure TForm1.FormCreate(Sender: TObject);
begin
  //ToolBarの閉じるボタンを無効化
  ToolBar1.FloatingDockSiteClass:=TToolDockSite;
end;

保存して、実行すると・・・

閉じるボタンをクリックすると「無効化」のメッセージを表示
[×]部分がグレイアウトして無効化されている

「ToolBarの閉じるボタンの無効化」は、こちらで紹介されていた記事を参考にして、さらに、この練習プログラムで必要なコードと情報を追加しました。質問者様と回答者様に厚く御礼申し上げます。

TOOLバーのFloating解除

https://www.petitmonte.com/bbs/answers?question_id=4452

実際の採点プログラムでは、最初に設定画面で「採点する答案の書式」・「画面を横にスクロールさせる移動ボタン利用の有無」・「1クリックで移動する解答欄数(=人数)」を指定してもらい、採点を実行。

縦書き答案で、移動ボタンを利用し、1クリックでの移動数は10名分を指定する例
Formにドッキングさせる前のToolBar
右から左へ採点、No,15まで来たら「左へ移動」ボタンをクリック
画面の右端がNo,1からNo,11になる(1クリックで10名分スクロールする)

計算方法は簡単。
個々の解答欄画像1個分の幅を記憶している動的配列から該当解答欄の幅を取得。
これにスクロールする解答欄数を掛ける。
この値を、現在の水平スクロールバーのPositionから引き、
得られた値を水平スクロールバーのPositionに代入する。これだけ!

//縦書き(左へ移動)
//現在、処理中の解答欄番号を取得(-1するのは動的配列要素を考慮)
int解答欄番号 := StrToInt(現在採点している解答欄番号.Text)-1;
//幅の増分 -> 幅の異なる解答欄に対応
XPlus := arryPX[解答欄番号] - arryIX[解答欄番号];
ScrollBox1.HorzScrollBar.Position :=
  ScrollBox1.HorzScrollBar.Position - (XPlus * StrToInt(移動人数.Text));

採点結果は、元の答案画像へ書き戻して、最後に合計点を自動計算。ユーザーが指示した場所へ書き込み。受験者へ返却する答案画像として印刷して、採点終了。やったー!

採点結果を書き戻した答案画像。これを印刷して受験者へ返却する。

3.初めてBevelを使う

目標は実現できた。あとはバグ取りはもちろんのこと、ユーザーになるべくやさしいインターフェイスを提供したい。そのようなユーザー目線で使ってみるといろんなことに気がつく(もちろん、ユーザーに言われて初めて気がつくことも、あるけれど・・・>_< )。

たとえば、コレ!

縦に長い解答欄画像の中に、移動ボタンが埋もれちゃった!

このような場合は、愛しの移動ボタンを「救出」して、他の適切な位置へ素早く配置転換しなければならない。

☆障害物がない場所へ無事移動できました☆

でも、ドッキング時のカタチがコレでは・・・

「かくれんぼ」なら、かなり強者になれそうだが・・・
普通のヒトは困ってしまうレベルのわかりにくさ。すでにボタンにも見えない。

「ここ」をクリックして、
ドラッグすればイイんだなー みたいなモノがあれば・・・☆
どうしたら、いいかなー?

そうだ☆ つかみどころが「ない」んだ!
つかみどころを作ろう!

でも、どぉすれば いい?

ワクだ。枠。枠がほしい。
でも、枠のVCLは、どこにある?

そういえば・・・パラパラっと、この前眺めた参考書になんかあったような・・・
確か、ベバルとか、ベビルとか、ベブルとか、んー! なんだったっけ?

正解は「Bevel(ベベル)」でしたー☆ 舌噛んだー

言葉の意味としては、多種多様な業界で使われ、実に様々な意味をとることが多いそうなのですが、PC業界ではおそらく「デザイン性を上げるための作業を指すのがベベルという言葉です。見た目の雰囲気を変えるのが面取りです・・・」というあるWebサイトにあった言葉が最も適切な気が・・・。

ベベルの意味を用語ごとに3種紹介|一般的な意味と辞書の意味とは

https://lostash.jp/sales/business-skill/1088812#toc17より引用

まぁ ここではとりあえず「枠」で行きます!
さっそくサイズ20×23のBevelをToolBarに配置。

ToolBarの右部分に余白を作っておいたのはこのためです。

Shapeプロパティは、「枠」なんだから迷わず「bsFrame」を指定。
Styleプロパティは、盛り下がる(=凹む)「bsLowered」と、盛り上がる「bsRaised」のふたつにひとつだから、有無を言わさず盛り上がる「bsRaised」に決定。

プロパティを設定

気になる実行時画面は・・・

フローティング時のBevelさん
Formにドッキングした時のBevelさん

Bevelさん、どちらも「しっかり」自己主張してます。

実に、イイ感じ。
誰がどう見ても、この枠内をクリックすれば、
何か、起きそうです。

ここをクリックしてドラッグ・・・なんて言わなくても、
ヒントも表示しなくても、
きっと、大丈夫。

ただ、Bevelさん、あなた・・・
盛り下がってる ようにしか、見えないのですが。
私の気のせいですか・・・?

それは、置いといて
実は、ものすごーく、大切なコトがあるのです。
このままではBevelさんをクリックしてもナニも起きません!

どこを探しても「Enabled」プロパティがありませんが・・・

オブジェクト インスペクタに表示されてませんが、
Bevelさんには、なんと! Enabledプロパティがあって、
しかもそれはデフォルト:True (=有効)で、
このままでは、Bevelさんの下にいるToolBarくんに、
クリックが伝わらないのです。

では、どぉすればいいかと言うと・・・
FormCreate時に、

  //クリックイベントをToolBarへ届ける
  Bevel1.Enabled:=False;

これでOK!
Bevelさんは自己主張しつつ、自らを無効化。
「枠」という役割に専念してくれるようになります!

4.まとめ

  1. Formの上にControlBarを置き、ControlBarの上にToolBarを配置すれば、ToolBarをフローティングさせることができる。ToolBarプロパティは、DragKindプロパティを「dkDock」に、DragModeプロパティを「dmAutomatic」に設定する。
  2. ControlBarやFormなど、ドッキングを受け入れる側は、DockSiteプロパティをTrueに設定するだけでOK!
  3. ToolBarの閉じるボタンは、プログラムコードを書いて無効化できる。
  4. Bevelは「枠」として利用できるが、Enabledプロパティがデフォルトで有効(True)になっているので、クリックを下のVCLに通知するためには、FormCreate時にEnabledをFalseに設定して、Bevel自身を無効化する必要がある。

5.お願いとお断り

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