Programming」カテゴリーアーカイブ

Delphiによるプログラミング関係のTips

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.お願いとお断り

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

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.お願いとお断り

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

BringToFront

最前面に表示する!

TImageに表示したある画像の上を、マウスでドラッグして、矩形選択するプログラムを書いた。

ちょっとだけ、同じセクションの仲間に「今度書いてるプログラムなんだけど。けっこうイイかんじで・・・」みたいな話をしたら、ICカードリーダーを使った出退勤管理のプログラムを作った時と同様に、「あいつがまた、ナニやらおかしなプログラムを書いたらしい」と、同僚から同僚へ、さらに別のセクションの人たちにも「風の便りに聞いたけど、これは内緒の話・・・」みたいな感じで次々に噂が広まり、果ては「あのイヤな仕事をかなり高速に処理できる魔法のプログラムが出来たらしい」と話しに「尾ひれ」が付いて評判となり、その「幻のプログラムの使い方」は職場内で「呪文の伝授」と呼ばれ、果ては公開前なのにすでに伝説化して、頼んでもいないのに積極的に人柱になりたい(=プログラムを試用したい)との申し出が若手の職員から殺到。

そんな時、プログラムの根幹をなす「画像の上をマウスでドラッグして、ラバーバンドを描きながら矩形選択」する部分で大問題が発生。僕自身が行ったテストでは何の問題もなかったのに、なんと、テストを頼んだ同僚が用意した画像上には「絶対にドラッグできない領域」が存在するとの報告が・・・。僕が用意した画像では何の問題もなくドラッグして、矩形選択できたのに・・・。

問題の解決までに2日半、エンエンと悩み続けることに。

以下、Delphiに触れて十数年も経って「初めて」知った「BringToFront」の物語。

1.プログラムの仕様
2.遭遇した摩訶不思議な現象
3.解決までの道のり
4.問題を再現
5.まとめ
6.お願いとお断り

1.プログラムの仕様

作成したプログラムはTImageを2個準備して、そこに表示した別々の画像を切り替えて仕事をするというもの。具体的には、画像Aの一部を範囲指定(矩形選択)してコピー、画像Bに貼り付け、次に、現在のそれとはまた別の画像をAに表示し、一部を矩形選択してコピー、画像Bに貼り付け、これを自動的に繰り返して仕事に必要な画像を一揃い画像Bに表示し、あとはユーザーが目視で内容を確認しながら、一斉に処理するというものだ。

だから、最初にコピーする範囲を指定する必要があり、画像Aで矩形選択ができなければ仕事にならない。よりによって、プログラムのいちばん根幹をなす部分で未知のトラブルに遭遇するとは・・・

2.遭遇した摩訶不思議な現象

僕自身が用意した画像でテストした時は、少なくとも矩形選択に関しては何も問題は起きなかった。他に細かなトラブルはたくさんあったけど、すべて原因がすぐわかり、これまでに培った知識で何とか解決できることばかりだった。テストのテスト的に、ごく近しい同僚に試用してもらった結果も良好だった。

そこで、テストのテストで見えてきた(実際に運用した際に発生すると予想される)エラーの処理をある程度書き加え、実用的なプログラムとして完成に近づいた段階で、職場内の別のセクションの仲間にテストを手伝って欲しいと依頼して、限られたセクションに属する人だけが入れるネットワークフォルダにプログラムを置き、テストを実施。

そして事件が発生。

支給されたノートPCを抱えてやってきた同僚が言うには、画像の一部に絶対に矩形選択できない場所があるというのだ。

そんなバカな! ・・・と思いながら、確かめたら本当だった。

信じたくない。信じたくない。信じたくない。夢なら覚めてくれ・・・。
そう祈りつつ、何遍クリックしても、同僚が用意した画像上の、「その領域」だけは絶対に選択できない。ラバーバンドの「ラ」の字も現れない。不思議なことに、同じ画像上の別の場所ならラバーバンドが普通に表示されるし、矩形選択もできるけど。その画像上の「ある領域」だけは、絶対に選択できない・・・。何度、何遍クリックしても・・・。

この現実は、絶対、信じたくないから、お互いに見つめあって、口をついて出た言葉は、お決まりの・・・

もしかして、マウスが壊れてるんじゃないか?

無線じゃなく、USBヒモの付いてる普段は絶対に使わないマウスに試しに交換。
祈りと願いを込めて画像上のある一点をクリックするも、PCは無反応。OnMouseDownイベントは発生の兆しすらなし。My TImageには、この期待も、祈りも、願いも、すべて届かず・・・

あぁ TImageよ、きみを泣く。
きみ、死にたもうこと なかれ。

なんとゆーことに。毎日午前2時から起きてプログラム書いたのに・・・
なんで夕陽が東から昇るんだって、疑問に感じながら毎朝ハンドル握ったのに・・・
今日の第2部の始まりだって、自分に言い聞かせて「昼間」ずっと働いたのにー

画像を表示し きみなれば
ほかに仕事はなかりしも、
せめてマウスのドラッグに
応える心を持ち給え、

祈りつつ、願いつつ、ドラッグしても、ポイントしても、TImageは期待を完全に無視。

ドラッグ無視してコケよとて
きみを完成間際まで育てしや。

愛してたのにー。 T_T
エラーすら出してくれないDelphiを逆恨み。可愛さあまって憎さ100000000倍。

あぁ 神さま
どうか 夢なら覚めてください・・・

正直、この時はプログラムの公開を目前に控えて「初めて遭遇した現象」に激しく狼狽。

(なんで、エラーすら出ないのかなー)

なんとかしなければならない。そう、なんとかしなければならないのだが、解決方法がまったくわからない。泣いても、喚いても、誰も助けてはくれない。

「プログラム使うの、楽しみにしてるね!」

そう言ってくれた同僚の笑顔が瞼に浮かぶ。

今更、「プログラム。コケました!」・・・なんて、絶対に言えない。

公開すると約束した日まで、あと3日。

仕事上の約束はこれまで必ず守ってきた。
今、この約束は破れない・・・。

3.解決までの道のり

問題が発生したのは金曜日の午後。幸いなことに土日は休日で予定は何も入っていないので、仕事に復帰する月曜日までの丸2日間、問題の解決に専念できる。それだけを唯一の救いにして、問題の原因と解決方法を考えよう・・・

(ほんとに、OnMouseDownイベントが発生してないのかな?)

初めにそれを確認することにして、ログを記録するように設定し、プログラムを走らせて矩形選択できない領域を何度もクリック&ドラッグ。プログラムを停めて、テキスト形式で記録されたログを確認。予想した通り、そのどこにもOnMouseDownイベント発生の記録はない。これでとにかく、「それが発生していない」ことだけは確実にわかった。

次に考えたのは、TImageに表示するJpeg画像の縮小率を変更して試してみること。・・・と言うのも、スキャナーで読み込んだ画像がPCの画面上で表示するのにデカすぎる場合は、任意の大きさに縮小できるよう予めプログラミングしてあり、その縮小率を変更したら、もしかしたら矩形選択できるようになるのではないか? と、思ったのだ。

問題が発生する前、A3サイズの用紙をスキャナーで読み込んでみたが、画像がかなり大きくて50~80%に縮小しないと、そのままでは作業に使えないことが判明。画像の状況により、ユーザーの判断で任意の縮小率を設定できるようにしてあった(プレビュー画面で確認しながら縮小率を設定できるようにプログラミングした)。

問題となった画像の縮小率は75%だったので、これを68%に変更。プログラムを動かして確認すると何の問題もなく動作する(実は、縮小率を変更したため、範囲指定する領域が変化して、たまたま矩形選択できない領域を外れてた・・・つまり、単にラッキーだった・・・だけなんだけど)。でも、これで一安心。原因はわからないが、もし問題が発生した場合は、縮小率を変更して試してもらえば、プログラムは正常に動作する可能性が「ある」ことが確認できた。

こうして「まったく動作しない」という最悪の事態だけは、なんとか、回避できる見込みがついた。しかし、問題の根本的な原因は一向に解明できないまま、土曜、日曜と時間だけが虚しく流れた。

日曜は、朝から机に向かって(あぁでもない・こうでもない)と思いつく限りの方法を試したが、「特定の縮小率を指定して作成したある画像に限って、絶対に矩形選択できない領域がある」という事実に変化はなく、前に困った時のことを思い出して、TImageのEnabledプロパティを間違いなくTrueに設定してもラバーバンドは出現せず、様々にキーワードを変えてGoogle先生に伺いを立てても、その解答に問題解決のそれらしきヒントとなるような内容は見当たらず、万策尽きて・・・。こんなことで時間を無駄にするよりは、まだ作りきってない部分や、手直しが必要な部分もいくつかあるから、そっちの仕事を先にやって・・・みたいな感じで、問題の原因調査とプログラム作成・手直しの間を1日中、行ったり、来たり。

(画像によっては、何の問題もなく処理できるし、問題の起きた画像でも縮小率変えれば使えたりするからー。ユーザーには、問題が起きることもあるけど・・・って、あらかじめ、そう説明して・・・使ってもらえばいいかぁ T_T)

日曜の夕方、折れかけた心を抱えたまま、僕はベッドに倒れ込んで・・・

目覚めたのは日曜の深夜・・・というより、月曜の早朝(?)午前0時すぎ。

(まだ、朝までには数時間ある・・・。選択肢はやるか・やらないか、のみ)

( やるしか、ない・・・ )

Google先生に再び訊ねた検索キーワードは「Delphi Image onMouseDown 起きない」
これはもう何度も使った検索キーワード。でも、もしかしたら、もしかしたら、どこかに見落とした情報があるんじゃないか・・・。やっぱりキーワードはこれしかないだろーって、そう思いながら、あるWebサイトの記事を見た瞬間。視野でない場所に、ナニかが見えた気がした・・・。その記事がこちら

Imageで描画したものが常にTop

https://www.petitmonte.com/bbs/answers?question_id=7660

気になった言葉は、ただ一言。「BringToFrontでは意味がなく…」

BringToFront ってナンだ? オレ、知らないぞ。
Delphiにそんなメソッド、あったっけ?

・・・ ってか、確か Formに配置した順に ・・・
・・・ コントロールは積み木を積むように ・・・
・・・ 上に、上に、重なって ・・・

そうだ。オレ、最初にFormを用意して、
ScrollBoxを置いて、
その上にImageを載せて・・・

そのあと、RadioGroupとか、ListBoxとか、Editとか、
アレも、コレも、たっぷりVCLコントロール積んだカラ。

Imageは 積み木のいちばん下になってる・・・
問題のほんとうの原因はきっと、コレだぁ!!!

あぁ 長かったぁ・・・

BringToFront が、なんのことか、まだわからんけど、
(おおよその挙動は文字から伝わるけど)
それを調べるより先にやることがある。それはナニかと言うと・・・

Imageを切り取って、ScrollBoxの上に貼りなおすコト!
これでImageが積み木のいちばん上にくるはず!!

プログラムを走らせる。
まったく言うことをきいてくれなかった画像を敢えて選ぶ。
絶対にクリック出来なかった領域を、クリック。

夢にまで 見た ラバーバンドが 現れた!!!

あきらめなくて・・・ よかったぁ

そして、さらに気がついたことは・・・
つい、さっきまでImageの上に表示されてた
( 今日:正確には昨日、作ったばかりの )
残り工程数を示すためのLabelが見えなくなってる・・・

調べなくても、これでわかった!
こんな時は、きっとLabelにBringToFrontなんだぁ

“BringToFront”

これまで使ったことないし、その存在さえすら知らなかったけど、
ここで早速、それが存在するもっともな理由を、自然な流れの中で確認&納得。

Imageには、いつも何か画像を表示するだけで、縮小・拡大・回転はしても、切ったり、貼ったりしたのはオレ、今回がたぶん、初めてだもんなー。

プログラムも、僕も、少しだけ
よく なれた かな?

4.問題を再現

簡単な検証プログラムで問題が再現できれば、今回のミスの原因は完全に解明できたことになる・・・。そう考えて作ってみたのが次の検証プログラム。科学する心。

Form上にScrollBox、その上にImageを二つ載せ、CheckBoxを二つ用意。

ここで重要なのは、VCLコントロールを設置する順番。

最初にScrollBox、
次がImage1、
最後にImage2 の順番でなければならない。

わかりやすくするため、TImageのAlignはalNoneのまま、大きさだけを上の図のように、Image1がImage2より大きくなるように設定。Image2は、Image1の上に乗ってる感じで設置。

で、Image1のVisibleプロパティはTrue、Image2のVisibleプロパティはFalseに設定。

それからTImageのEnabledプロパティはどちらもTrueにしておく。

(以前、ユーザーの誤ったクリックを防止するため、ある手続きの処理の最後に、これをFalseに設定し、Trueに戻すのを忘れたまま、次の手続きに進み、今回とは別の理由で矩形が描けなくて大騒ぎ。この場合もエラーメッセージ等は出ないので、原因がわかるまで相当長時間にわたり悩んだことがあった・・・)

ScrollBoxを置いたのは、問題を起こしたプログラムと同じ仕様にするためで、それ以外の特別な意味はない。

ラバーバンドの描画コードは、Mr.XRAYさんのWebサイトにあったコードをそのまま使わせていただく。

154_画像の矩形範囲選択とラバーバンド

http://mrxray.on.coocan.jp/Delphi/plSamples/154_Rubberband.htm#01

上のコードでは、ラバーバンドをFormに描画しているので、これをImageのCanvasに描画するように変更したコードが、以下。

まず、必要な変数の宣言と、Image1のOnMouseDownイベント。

  private
    { Private 宣言 }
    InitX : Integer;
    InitY : Integer;
    PrevX : Integer;
    PrevY : Integer;
    DragFlag : Boolean;

procedure TForm1.Image1MouseDown(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
begin

  if Button = mbLeft then
  begin
    //TImageのCanvasの描画をクリア
    Image1.Canvas.Brush.Style := bsSolid;
    Image1.Canvas.Brush.Color := Self.Color;
    Image1.Canvas.FillRect(ClientRect);
    //クリック位置の座標を取得
    InitX := X;
    InitY := Y;
    PrevX := X;
    PrevY := Y;
    DragFlag := True;
    //描画設定
    Image1.Canvas.Pen.Mode := pmNotXor;
    Image1.Canvas.Pen.Color := clBlue;
    Image1.Canvas.Pen.Width := 1;
    Image1.Canvas.Pen.Style := psDot;
    Image1.Canvas.Brush.Style := bsClear;
    //矩形を描画
    Image1.Canvas.Rectangle(InitX, InitY, PrevX, PrevY);
  end;

end;

同じくImage1のOnMouseMoveイベント。

procedure TForm1.Image1MouseMove(Sender: TObject; Shift: TShiftState; X,
  Y: Integer);
begin

  if not DragFlag then exit;

  //不要な部分を消去
  Image1.Canvas.Rectangle(InitX, InitY, PrevX, PrevY);
  //新しい矩形を描画
  Image1.Canvas.Rectangle(InitX, InitY, X, Y);
  //現在値を取得
  PrevX := X;
  PrevY := Y;

end;

最後にImage1のOnMouseUpイベント。

procedure TForm1.Image1MouseUp(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
begin

  if not DragFlag then exit;

  //ラバーバンドの描画終了
  DragFlag := false;

  //不要な部分を消去
  Image1.Canvas.Rectangle(InitX, InitY, PrevX, PrevY);

  //ラバーバンドの最終サイズで矩形を描画
  Image1.Canvas.Pen.Color := clBlue;
  Image1.Canvas.Pen.Width := 3;
  Image1.Canvas.Rectangle(InitX, InitY, PrevX, PrevY);

end;

この状態で保存して実行すると・・・

ドラッグ中は、クリック位置(矩形の左上)を起点に、点線で矩形が描画される
マウスの左ボタンを離すと、選択範囲に太い実線で矩形が描画される

で、問題の再現。

Form右上に置いたCheckBox1のCaptionを「Image2を表示」にして、そのOnClickイベントに次のコードを書く。

procedure TForm1.CheckBox1Click(Sender: TObject);
begin
  if CheckBox1.Checked then
  begin
    Image2.Visible := True;
  end else begin
    Image2.Visible := False;
  end;
end;

プログラムを保存して、実行。CheckBox1にチェックを入れて、(見えないけど)Image2を表示。この状態でさっきと同じ場所をドラッグして、矩形選択しようとしても・・・

あたりまえですが、エラーも起きません!

ここで、いよいよ “BringToFront” の出番。

CheckBox2のCaptionを「Image1を最前面に表示」として、OnClickイベントには次のコードを記述。

procedure TForm1.CheckBox2Click(Sender: TObject);
begin
  if CheckBox2.Checked then
  begin
    Image1.BringToFront;
  end else begin
    Image1.SendToBack;
  end;
end;

保存して実行。

最初に、ChekBox1をチェック -> Image2が表示される(見えないけど)。

いちおうImage2があると思しきあたりをドラッグして矩形選択できないことを確認。

次に、CheckBox2をチェック -> Image1が最前面に表示される(はず)。

で、Image2があると思しきあたりをドラッグすると、今度は矩形が描かれる。

Image2のVisibleプロパティがTrueでも、Image1をBringToFrontすれば矩形が描画される

わかってしまえば、ほんとに、なんでもないこと・・・なんだけれど。
それが「わかる」までは(こんなことで悩むのは僕だけかもしれませんが)、
ほんとうに、苦しかった。

僕が調べた範囲では、Web上に「こんなことがあったよ!」っていう情報はなかったので、ここに “BringToFrontの物語” として記録しておけば、100万人にひとりくらい、もしかしたらいるかもしれない、同じ問題で困っている人へのヒントになるかもしれない・・・。

そう思ったのです。

5.まとめ

画面に描画処理を行う必要があるTImageを含む、複数のTImageを切り替えて使うプログラムを作るときは、描画処理対象のTImageを呼び出す(VisibleプロパティをTrueにする)際に必ず、次のようにして、これを最前面に表示しておく。

  //最前面に表示(持ってくる)
  Image1.BringToFront;

この設定を忘れると、他のVCLコントロールの設置状況によるが、矩形が描画できない領域が生じることがある。

この “BringToFront” と対をなすメソッドは “SendToBack” 。
こちらの使い方は、例えばCheckBoxのチェックの有無に対応させて、

procedure TForm1.CheckBox2Click(Sender: TObject);
begin
  if CheckBox2.Checked then
  begin
    Image1.BringToFront;
  end else begin
    Image1.SendToBack;
  end;
end;

Checkedの時は “BringToFront” でいいけど、not Checkedの時はどうしたらイイ?
・・・と思って調べて、”SendToBack” メソッドの存在も知りました!

6.お願いとお断り

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

I don’t want to press the enter key to confirm the input.

「入力確定のEnterキーは押したくない!」

TStringGridを使って何らかの入力作業を行う時、任意のあるキーを押したら直ちに、予め指定した内容をアクティブなセルに入力し(入力を確定)、次のセル(右 or 下)へフォーカスを移したいことがある。これは、そんな時のための備忘録。

1.Bキー押し下げでゼロを入力したい理由
2.StringGridを準備する
3.Bキー押し下げでゼロ入力を実装(その1)
4.任意の1文字+数字の入力を負の数に変換
5.Bキー押し下げでゼロ入力を実装(その2)
6.まとめ
7.お願いとお断り

1.Bキー押し下げでゼロを入力したい理由

手書き答案をスキャナーで読み込み、採点するプログラムを書いた。元の答案画像から設問ごとに解答欄をかき集めて一覧表示し、まとめて採点すれば効率よく採点できると思ったのだ(実際、試してみたら驚くほど速く採点できた!)。その手順を紹介。

ぱっと見て「よく出来てるなー」と思ったら、全員分採点記号と得点を一括入力
誤りの解答だけ採点記号を × にして、得点はゼロに変更

採点スタイルとして予定した(考えた)のは、「左手で入力作業、右手はマウス操作(解答欄のクリックと画像のスクロール)に専念する」というカタチ。

解答欄画像をクリックしたら、その座標から解答番号を計算し、採点欄のフォーカスが自動で移動するようプログラミング。(その方法は以下のリンク先を参照してください)

で、正の数値を入力したら、そのまま採点欄に、その数値が入力され、
Qとか、Sとか、何か文字を入力して確定したら、採点欄には0(ゼロ)が入り、さらに

オプション設定で「マイナス(ー)」記号に変換する文字を指定

上の設定であれば、aキーに続けて数字を入力して確定した場合は、採点欄に負の値が入力されるようにプログラミング。なぜaなのかというと、左手小指のホームポジションだからまず間違えずに(位置を確かめずに)押し下げ可能だと思ったから。

そもそも、なんで、こんな仕様(入力値が正負の数およびゼロ)にしたかというと、採点欄への数値入力と同時に、入力された数値に応じて、解答欄画像の方にも、採点記号と得点を(透過状態で)表示するプログラムにしたかったから。具体的な表示内容は次の通り。

(1)入力が正の数なら、解答欄画像の上に採点記号と得点を表示、
(2)入力が0(ゼロ)なら、解答欄画像の上に採点記号 × のみを表示
  (ゼロは〇:まるとまぎらわしいのでデフォルト設定では表示しない)、
(3)入力が負の数なら、解答欄画像の上に採点記号と部分点を表示。

当初、この採点補助プログラムでは、採点記号として〇と × しか利用できなかった(△とした場合に、それを見分ける良いフラグが用意できなかった)が、コピペしたプログラムコード中に残していた負の数は赤で表示するコードを見て、負の数を「部分点あり」のフラグとして利用できることに気づき、「部分点あり」の採点記号△も使えるように改良。

部分点を与える時は採点欄にマイナス記号に変換する文字と部分点になる数値を入力し、Enterキー押し下げ
負の数で「部分点あり」を表現(合計点は絶対値で計算すればイイ)

マウスは右手で操作する(左利きの方も?)ので、自ずと採点は左手で行うことに。

多くの場合、1問あたりの得点は5点未満だろうから、これらの数字キーはキーボードの左側にあって押しやすい。もし、数値でなく文字が入力された場合は、有無を言わさず0(ゼロ)に変換してしまえば、左手側にある1~5の数字キーの下には押しやすい文字キーがたくさんあるから、キーボード右側にあって、左手が届きにくい0(ゼロ)キーは押さなくてすむ。

あとは右手でマウスを操作し、解答欄画像を次々にクリックして、採点欄のフォーカスを切り替えて(=入力を確定して)行けば・・・

採点補助プログラムとして、十分使えるかなー?っと思ったんだけれど、

実際使ってみたら、入力後、次の解答欄画像をいちいちクリックして( or Enterキーを押し下げして)入力を確定 & 次の採点欄へフォーカスを移動させるのが、非常にめんどくさい。

せめて × の場合だけでも、採点欄に0(ゼロ)を入力した瞬間に、解答欄画像上に × を表示し、フォーカスが自動で次のセルへ移動するようにできないか?

そんな理由から、採点記号「 × 」は「ばってん」だから、BATTENで、Bキー押し下げ、即、0(ゼロ)を入力 & 確定、フォーカスは次のセルへ自動で移動するプログラムを書くことに決めました(Bキーも左手で押しやすい位置にあるのがうれしい!)。

2.StringGridを準備する

Bキー押し下げ、即、入力確定のプログラム自体は、前にStringGridで矢印キーの動作を制限したことがあったので、その時学んだテクニックを応用すれば、きっと書けると思ったので全然心配はなかったが、それを設定する対象のTStringGridは実に設定し甲斐のあるコントロールで、ある目的を実現(実装)しようとすると、そこに行きつくまでの工程が何段階も必要だったりする。

今回、この記事を書くのにあたり、いい機会だからStringGridの設定について(自分自身の勉強の復習の意味も込めて)まとめてみた。練習用に手間をかけずに作成したFormとコントロールは次の通り(Formに各VCLコントロールを置いただけ!)。

Form上に、StringGrid、Label、ComboBox、CheckBoxを各1個ずつ用意

で、FormCreate時の手続きは・・・

procedure TForm1.FormCreate(Sender: TObject);
begin

  //[Enter]でコントロールを移動させるために、Form上のコンポーネント
  //より先にキーボードイベントを取得する。
  KeyPreview := True;

  //描画処理は自前で行わずDelphiにおまかせ
  StringGrid1.DefaultDrawing := True;

  //Fixed(固定セル)のスタイル
  //現在のオペレーティングシステムのテーマを使用
  StringGrid1.DrawingStyle := gdsThemed;
  //標準のテーマの指定がないスタイル
  //StringGrid1.DrawingStyle := gdsClassic;
  //グラデーションのあるスタイル
  //StringGrid1.DrawingStyle := gdsGradient;

  //セルを強調表示
  StringGrid1.Options := StringGrid1.Options + [goDrawFocusSelected];

  //Clickでセル編集を可能にする-> [goEditing]をTrueに設定(方法は以下の通り)
  StringGrid1.Options := StringGrid1.Options + [goEditing];
  //常に編集可能に設定
  StringGrid1.Options := StringGrid1.Options + [goAlwaysShowEditor];

end;

KeyPreview := True の設定の他は、すべてStringGrid関係。僕がこのコントロールを使う時は、常に編集可能状態で起動するように設定することがほとんど。

続けてFormShow手続き。

procedure TForm1.FormShow(Sender: TObject);
var
  i : integer;
begin

  //行数と列数を適当に指定(Fixedセルを除いて10行10列あればテスト用途には十分)
  StringGrid1.RowCount := 11;
  StringGrid1.ColCount := 11;

  //FixedCols & FixedRows(固定列と固定行)を設定
  StringGrid1.FixedCols := 1;
  StringGrid1.FixedRows := 1;

  //フィールド名をセット(Rowに一括設定)
  //',Aの[,]に注意!-> セル[0,0]は空欄(フィールド名は入れない)
  //プログラムが長くなる時は['+]を使用してフィールド名を設定する
  StringGrid1.Rows[0].CommaText := ',A,B,C,D,E,F,'+
    'G,H,I,J';

  //FixedRows(固定行)に値をセット
  for i := 1 to 10 do
  begin
    StringGrid1.Rows[i].Append(IntToStr(i));
  end;

  //StringGrid1へフォーカスを移す。
  //下のようにまずフォーカスを移してからCol, Rowを指定。
  //でないとエラーになる。
  StringGrid1.SetFocus;
  StringGrid1.Col := 1;
  StringGrid1.Row := 1;
  StringGrid1.SetFocus;
  //カーソルが見えるようにする
  StringGrid1.EditorMode := True;

end;

さらにStringGrid1DrawCell手続きで、Fixed(固定)セルの表示方法と、入力された数値の右寄せ表示を指定。

implementation

uses
  Vcl.GraphUtil;

  //GraphUtilはFixedセルのセンタリング用に追加

procedure TForm1.StringGrid1DrawCell(Sender: TObject; ACol, ARow: Integer;
  Rect: TRect; State: TGridDrawState);
var
  i : integer;
begin
  //Fixedセルをセンタリング
  with StringGrid1 do
  begin
    if (gdFixed in State) then
    begin
      //usesにGraphUtilを追加(Vcl.GraphUtilではないことに注意!)
      //->Vcl.GraphUtilとすると「未定義の識別子エラー」になる!
      //GraphUtil.GradientFillCanvas(Canvas, GradientStartColor,
      //  GradientEndColor, Rect,gdVertical);
      //Vcl.GraphUtilとusesした場合
      //これは未定義の識別子エラーにならない
      Vcl.GraphUtil.GradientFillCanvas(Canvas, GradientStartColor,
        GradientEndColor, Rect,gdVertical);
      //センタリング
      DrawText(Canvas.Handle, PChar(Cells[ACol, ARow]),
        -1, Rect, DT_CENTER OR DT_VCENTER OR DT_SINGLELINE);
    end;
  end;

  //セルの表示を制御
  if not (gdFixed in state) then
  begin
    if StringGrid1.Cells[ACol,ARow] <> '' then
    begin
      //数値であるかどうかをCheck
      if not TryStrToInt(StringGrid1.Cells[ACol,ARow],i) then Exit;
      {数値である場合}
      //背景色を白に設定
      StringGrid1.Canvas.Brush.Color := clWhite;
      //正負をチェック
      if StrToInt(StringGrid1.Cells[ACol,ARow]) < 0 then
      begin
        StringGrid1.Canvas.Font.Color := clRed;
      end else begin
        StringGrid1.Canvas.Font.Color := clBlack;
      end;
      //セルを塗りつぶす
      StringGrid1.Canvas.FillRect(Rect);
      //数値は中央寄せで表示
      {DrawText(StringGrid1.Canvas.Handle,
              PChar(StringGrid1.Cells[ACol,ARow]),
              //[+1]は数値描画位置の調整のため
              Length(StringGrid1.Cells[ACol,ARow])+1,Rect,
              DT_CENTER or DT_VCENTER or DT_SINGLELINE);}
      //数値は右寄せで表示
      DrawText(StringGrid1.Canvas.Handle,
              PChar(StringGrid1.Cells[ACol,ARow]),
              //[+1]は数値描画位置の調整のため
              Length(StringGrid1.Cells[ACol,ARow])+1,Rect,
              DT_RIGHT or DT_VCENTER or DT_SINGLELINE);
    end;
  end;
end;

ついでにIMEも設定(IME ONの列は任意指定)。まず、次のように宣言しておいて・・・

//Col毎のIMEの制御(制御内容はStringGrid1GetEditTextを参照)
type
  _TGrid = class(TCustomGrid);

var
  Form1: TForm1;

implementation

StringGrid1GetEditText手続きで、次のように設定。

procedure TForm1.StringGrid1GetEditText(Sender: TObject; ACol, ARow: Integer;
  var Value: string);
begin
  //IMEの制御
  with TEdit(_TGrid(Sender).InplaceEditor) do
  begin
    case ACol of  //最初のAColは「 0 」
      2: ImeMode := imHira; //日本語入力ON
    else
      //ImeMode := imClose;   //日本語入力OFF-> ×
      ImeMode := imDisable;   //日本語入力OFFは imDisable
    end;
  end;
end;

ここまでの設定で、実行時の画面は、こんな感じ。

某有名表計算ソフト風の画面が出現

Enterキーでフォーカスを移動するために、FormKeyPress手続きで、次のように設定。

procedure TForm1.FormKeyPress(Sender: TObject; var Key: Char);
begin
  //[Enter]キーでコントロールを移動
  //StringGridは編集可能にFormCreateで設定しておく
  //->忘れるとセルの移動にEnter×2回必要!
  //この方法を使う時はKeyPreview:=True;をFormCreateで指定。
  if Ord(Key) = VK_RETURN then
  begin
    if ActiveControl is TStringGrid then
    begin
      if TStringGrid(ActiveControl).EditorMode then
      begin
        //VK_TABではカーソルがレコードの項目を右へ移動。
        //ActiveControl.Perform(WM_KEYDOWN,VK_TAB,0);
        //VK_DOWNにすると同じ項目の次のレコードへ移動。
        //if intStringGrid1ActiveRow < StringGrid1.RowCount-1 then
        if TargetRow < StringGrid1.RowCount-1 then
        begin
          ActiveControl.Perform(WM_KEYDOWN,VK_DOWN,0);
        end else begin
          ActiveControl.Perform(WM_KEYDOWN,VK_UP,0);
        end;
        Key := #0;
      end;
    end else begin
      SelectNext(ActiveControl,True,True);
      Key := #0;
    end;
  end;
end;

さらに、列幅を自動調整したい場合は・・・

procedure TForm1.CheckBox1Click(Sender: TObject);
var
  iCOL: Integer;
  iROW: Integer;
  MaxColWidth: Integer;
  TmpColWidth: Integer;
begin
  //DefaultColWidthを設定(これでCheck OFF時に元に戻る!)
  StringGrid1.DefaultColWidth:=64;
  //AutoAllColFit(全列幅の自動調整)
  if CheckBox1.Checked then
  begin
    for iCOL := 0 to StringGrid1.ColCount-1 do begin
      MaxColWidth := 0;
      for iROW := 0 to StringGrid1.RowCount-1 do
      begin
        //数字は列幅の調整用
        TmpColWidth := Canvas.TextWidth(StringGrid1.Cells[iCOL,iROW]) + 40;
        if MaxColWidth < TmpColWidth then
          MaxColWidth := TmpColWidth;
      end;
      StringGrid1.ColWidths[iCOL] := MaxColWidth;
    end;
  end;
end;

列幅自動調整実行時の画面は・・・(チェックOFFで、列幅は元に戻る)

列幅調整用の数値を調整して好みの幅に設定(上の画像では40を使用)

これでStringGridの準備が完了!

3.Bキー押し下げでゼロ入力を実装(その1)

※ 各セルに対して10以上の値の入力がないことが前提です!

この機能の実装にあたり、次のWebサイトにあった情報を参考にさせていただきました。質問者様と解答者様のご両名に対して、心から厚く御礼申し上げます。

@NIFTY FDELPHI Delphi Users’ Forum15番会議室「FAQ編纂委員会」に寄せられた「よくある質問の答え」

http://delfusa.main.jp/delfusafloor/archive/www.nifty.ne.jp_forum_fdelphi/faq/00075.htm

StringGrid 行移動の把握

https://www.petitmonte.com/bbs/answers?question_id=7289

Private宣言に、次のローカル変数とAppMessage手続きを追加。

  private
    { Private 宣言 }
    //入力=確定&フォーカスの移動用に追加
    //行・列位置を記憶する変数
    TargetRow:integer;
    TargetCol:integer;
    //ある(矢印他)キーが押されたことを知る
    procedure AppMessage(var Msg: TMsg; var Handled: Boolean);

Shift+Ctrl+C で AppMessage手続きを作成して、次の内容を設定。

※ usesに System.UITypes を追加するのを忘れないこと!(忘れるとBキーを意味するVKBが「未定義の識別子エラー」になる。

重要 次のコードでは、各セルに対して10以上の数値の入力は「ない」ものとしている。

implementation

uses
  Vcl.GraphUtil,
  System.UITypes;

  //GraphUtilはFixedセルのセンタリング用に追加
  //System.UITypesはキーコードでBキー(=VKB)を指定するために追加

{$R *.dfm}

procedure TForm1.AppMessage(var Msg: TMsg; var Handled: Boolean);
begin
  //任意のキーの押し下げをキャッチ
  if Msg.message = WM_KEYDOWN then
  begin
    //StringGridがアクティブだったら
    if ActiveControl is TStringGrid then
    begin
      //StringGridが編集可能だったら
      if TStringGrid(ActiveControl).EditorMode then
      begin
        //Bキー or 0キー押し下げでゼロを入力(入力値は10未満であることが前提)
        if (Msg.wParam=VKB) or (Msg.wParam=VK0) then
        begin
          //keybd_event(VK_TAB,0,0,0);
          //VK_TABではカーソルがレコードの項目を右へ移動。
          //ActiveControl.Perform(WM_KEYDOWN,VK_TAB,0);
          //VK_DOWNにすると同じ項目の次のレコードへ移動。
          if TargetRow < StringGrid1.RowCount-1 then
          begin
            //アクティブなセルが最終行でない場合はフォーカスは下へ移動
            StringGrid1.Cells[TargetCol, TargetRow]:='0';
            ActiveControl.Perform(WM_KEYDOWN,VK_DOWN,0);
          end else begin
            //最終行ならフォーカスは上へ移動
            StringGrid1.Cells[TargetCol, TargetRow]:='0';
            ActiveControl.Perform(WM_KEYDOWN,VK_UP,0);
          end;
          //Msg.wParam:=#0; //エラーになる
          Msg.wParam:=0;
        end;
      end;
    end;
  end;
end;

FormCreate時に、AppMessageを有効にする。これを忘れると動かない!

procedure TForm1.FormCreate(Sender: TObject);
begin

  ・・・ 省略(StringGridその他の初期設定) ・・・

  //入力=確定&フォーカスの移動用に追加
  //StringGridの初期位置の設定
  TargetRow := 1;
  TargetCol := 1;
  //AppMessageを有効にする
  Application.OnMessage := AppMessage;

end;

AppMessage手続きの引数にはACol, ARowがないから、その代わりにStringGrid1SelectCell手続きの最後で、行列位置を変数に取得。

procedure TForm1.StringGrid1SelectCell(Sender: TObject; ACol, ARow: Integer;
  var CanSelect: Boolean);
begin
  //入力=確定&フォーカスの移動用に追加
  //セルを選んだときに行位置を記憶
  TargetRow := ARow;
  //セルを選んだときに列位置を記憶
  TargetCol := ACol;
end;

実行時の様子は・・・

Bキーもしくは0キーの入力と同時にフォーカスは下のセルに移動する

4.任意の1文字+数字の入力を負の数に変換

Formに用意したLabel1のCaptionプロパティには「マイナス記号に置換する文字:」を設定し、ComboBox1のTextプロパティに「a」を設定。

FormCreate手続きの最後で、マイナス記号に置換する文字の選択肢を準備。

procedure TForm1.FormCreate(Sender: TObject);
begin

  ・・・ 省略 ・・・

  //入力=確定&フォーカスの移動用に追加
  //StringGridの初期位置の設定
  TargetRow := 1;
  TargetCol := 1;
  //AppMessageを有効にする
  Application.OnMessage := AppMessage;

  //マイナス記号に変換する文字の選択肢
  ComboBox1.Items.Add('q');
  ComboBox1.Items.Add('a');
  ComboBox1.Items.Add('z');

end;

StringGrid1DrawCell手続きに、次の赤字の部分を追加。
(1列目であったら、文字の入力はすべてゼロに変換する処理も追加している)

procedure TForm1.StringGrid1DrawCell(Sender: TObject; ACol, ARow: Integer;
  Rect: TRect; State: TGridDrawState);
var
  i: integer;
  str1, str2: string;
begin

  ・・・ 省略 ・・・

  //セルの表示を制御(中央寄せ・負の数は赤で表示)
  if not (gdFixed in state) then
  begin
    if StringGrid1.Cells[ACol,ARow] <> '' then
    begin

      //文字数が2文字なら実行
      if Length(WideString(StringGrid1.Cells[ACol,ARow])) = 2 then
      begin
        //指定文字が入力されたら'-'に変換
        str1 := LowerCase(Copy(StringGrid1.Cells[ACol,ARow],1,1));
        str2 := Copy(StringGrid1.Cells[ACol,ARow],2,1);
        if str1 = LowerCase(ComboBox1.Text) then
        begin
          StringGrid1.Cells[ACol,ARow] := '-'+str2;
        end;
      end;

      if ACol = 1 then
      begin
        //「文字」はすべて'0'に変換
        if not TryStrToInt(StringGrid1.Cells[ACol,ARow], i) then
        begin
          StringGrid1.Cells[ACol,ARow] := '0';
        end;
      end;

  ・・・ 省略 ・・・

実行時の様子は・・・

a2と入力してEnterキー押し下げで確定
無事、目的を達成

左手だけで、視線をキーボードに落とすことなく、負の数も簡単に入力できるようになった☆

ただし、上の手続きでは、StringGridのセルへの入力が3桁であった場合に対応できない。こと採点に関しては、部分点が2桁の数値になることは、多分アリエナイから、採点補助プログラム用のアルゴリズムとしての利用に限れば、上の手続きでも、まず問題は起きないと思うが・・・もし、どうしても3桁以上の入力値に対応させたいなら、コードを次のように変更すればOK!

//StringReplaceuses関数を使用するので uses節に System.SysUtils を追加
uses
  System.SysUtils
procedure TForm1.StringGrid1DrawCell(Sender: TObject; ACol, ARow: Integer;
  Rect: TRect; State: TGridDrawState);
var
  i: integer;
  str1, str2: string;
begin

  ・・・ 省略 ・・・

  //セルの表示を制御(中央寄せ・負の数は赤で表示)
  if not (gdFixed in state) then
  begin
    if StringGrid1.Cells[ACol,ARow] <> '' then
    begin

      //文字数が2文字なら実行 -> コメント化
      {if Length(WideString(StringGrid1.Cells[ACol,ARow])) = 2 then
      begin
        //指定文字が入力されたら'-'に変換
        str1 := LowerCase(Copy(StringGrid1.Cells[ACol,ARow],1,1));
        str2 := Copy(StringGrid1.Cells[ACol,ARow],2,1);
        if str1 = LowerCase(ComboBox1.Text) then
        begin
          StringGrid1.Cells[ACol,ARow] := '-' + str2;
        end;
      end;}

      //文字数が2文字以上なら実行
      if Length(WideString(StringGrid1.Cells[ACol,ARow])) >= 2 then
      begin
        //指定文字が入力されたら'-'に変換
        str1 := LowerCase(Copy(StringGrid1.Cells[ACol,ARow],1,1));
        //2桁以上の入力値に対応
        str2 := StringReplace(
          LowerCase(StringGrid1.Cells[ACol,ARow]), 
          str1, '', [rfReplaceAll, rfIgnoreCase]);
        if str1=LowerCase(ComboBox1.Text) then
        begin
          StringGrid1.Cells[ACol,ARow] := '-'+str2;
        end;
      end;

      if ACol = 1 then
      begin
        //「文字」はすべて'0'に変換
        if not TryStrToInt(StringGrid1.Cells[ACol,ARow], i) then
        begin
          StringGrid1.Cells[ACol,ARow] := '0';
        end;
      end;

  ・・・ 省略 ・・・
適当な値を入力してEnterキーを押し下げて確定
採点プログラムとしての実用性は感じられないが・・・プログラム的には目的を達成

5.Bキー押し下げでゼロ入力を実装(その2)

※ 各セルに対して10以上の値の入力がある場合

各セルに対して10以上の値の入力がある場合は、入力された0(ゼロ)が不正解の0(ゼロ)なのか、10の2桁目の0(ゼロ)なのか、判定する工夫が必要になるが、良い判定方法が思いつかなかった。

そこで思い切って問題を単純化し、「高速入力モード」を作成して、それが ON の場合は入力値を0-9に限定し、ユーザーがそのことを理解した上で操作できるように工夫してみた。もし、各セルに対して10以上の値の入力がある場合は、「高速入力モード」は OFF で使用して貰い、Bキーが押された場合のみ、0(ゼロ)に変換して入力確定 ⇨ フォーカスを移動することにして、数字キーの0(ゼロ)の入力に対しては、直ちに入力の確定としないことにした。

あと、ついでだから、「高速入力モード」の名に恥じないよう、それが ON の場合は、0-9の数字キー押し下げで、直ちに入力確定、次のセルへフォーカスが移動する処理も追加してみた。以下、その実装。

CheckBox2を追加し、Captionを設定
procedure TForm1.AppMessage(var Msg: TMsg; var Handled: Boolean);
var
  str1:string;
begin
  //任意のキーの押し下げをキャッチ
  if Msg.message = WM_KEYDOWN then
  begin
    //StringGridがアクティブだったら
    if ActiveControl is TStringGrid then
    begin
      //StringGridが編集可能だったら
      if TStringGrid(ActiveControl).EditorMode then
      begin

        //高速入力使用の有無で処理を切り替え
        if not CheckBox2.Checked then
        begin

          //高速入力を使用しない場合の処理
          //Bキー押し下げでゼロを入力
          //0キー押し下げは無視
          if (Msg.wParam=VKB) then
          begin
            //keybd_event(VK_TAB,0,0,0);
            //VK_TABではカーソルがレコードの項目を右へ移動。
            //ActiveControl.Perform(WM_KEYDOWN,VK_TAB,0);
            //VK_DOWNにすると同じ項目の次のレコードへ移動。
            if TargetRow < StringGrid1.RowCount-1 then
            begin
              //下のセルへ移動
              StringGrid1.Cells[TargetCol, TargetRow]:='0';
              ActiveControl.Perform(WM_KEYDOWN,VK_DOWN,0);
            end else begin
              //上のセルへ移動
              StringGrid1.Cells[TargetCol, TargetRow]:='0';
              ActiveControl.Perform(WM_KEYDOWN,VK_UP,0);
            end;
            //Msg.wParam:=#0; //エラーになる
            Msg.wParam:=0;
          end;

        end else begin

          //高速入力を使用する場合の処理
          //Bキー押し下げでゼロを入力
          //0キー押し下げにも対応
          if (Msg.wParam=VKB) or (Msg.wParam=VK0) then
          begin
            //keybd_event(VK_TAB,0,0,0);
            //VK_TABではカーソルがレコードの項目を右へ移動。
            //ActiveControl.Perform(WM_KEYDOWN,VK_TAB,0);
            //VK_DOWNにすると同じ項目の次のレコードへ移動。
            if TargetRow < StringGrid1.RowCount-1 then
            begin
              //下のセルへ移動
              StringGrid1.Cells[TargetCol, TargetRow]:='0';
              ActiveControl.Perform(WM_KEYDOWN,VK_DOWN,0);
            end else begin
              //上のセルへ移動
              StringGrid1.Cells[TargetCol, TargetRow]:='0';
              ActiveControl.Perform(WM_KEYDOWN,VK_UP,0);
            end;
            //Msg.wParam := #0; //エラーになる
            Msg.wParam := 0;
          end;

          //1-9の入力があった場合
          if StringGrid1.Cells[TargetCol, TargetRow] <> '' then
          begin
            str1:=Copy(StringGrid1.Cells[TargetCol, TargetRow],1,1);
          end else begin
            str1 := '';
          end;

          //任意の1文字+数字の入力を負の数に変換する処理用に追加
          if (str1 <> '-') and (str1 <> ComboBox1.Text) then
          begin
            if (Msg.wParam = VK1) then
            begin
              //keybd_event(VK_TAB,0,0,0);
              //VK_TABではカーソルがレコードの項目を右へ移動。
              //ActiveControl.Perform(WM_KEYDOWN,VK_TAB,0);
              //VK_DOWNにすると同じ項目の次のレコードへ移動。
              //if intStringGrid1ActiveRow < StringGrid1.RowCount-1 then
              if TargetRow < StringGrid1.RowCount-1 then
              begin
                //下のセルに移動
                StringGrid1.Cells[TargetCol, TargetRow] := '1';
                ActiveControl.Perform(WM_KEYDOWN,VK_DOWN,0);
              end else begin
                //上のセルに移動
                StringGrid1.Cells[TargetCol, TargetRow] := '1';
                ActiveControl.Perform(WM_KEYDOWN,VK_UP,0);
              end;
              //Msg.wParam := #0; //エラーになる
              Msg.wParam := 0;
            end;

            if (Msg.wParam = VK2) then
            begin
              if TargetRow < StringGrid1.RowCount-1 then
              begin
                //下のセルに移動
                StringGrid1.Cells[TargetCol, TargetRow] := '2';
                ActiveControl.Perform(WM_KEYDOWN,VK_DOWN,0);
              end else begin
                //上のセルに移動
                StringGrid1.Cells[TargetCol, TargetRow] := '2';
                ActiveControl.Perform(WM_KEYDOWN,VK_UP,0);
              end;
              Msg.wParam := 0;
            end;

            if (Msg.wParam = VK3) then
            begin
              if TargetRow < StringGrid1.RowCount-1 then
              begin
                //下のセルに移動
                StringGrid1.Cells[TargetCol, TargetRow] := '3';
                ActiveControl.Perform(WM_KEYDOWN,VK_DOWN,0);
              end else begin
                //上のセルに移動
                StringGrid1.Cells[TargetCol, TargetRow] := '3';
                ActiveControl.Perform(WM_KEYDOWN,VK_UP,0);
              end;
              Msg.wParam := 0;
            end;

            if (Msg.wParam = VK4) then
            begin
              if TargetRow < StringGrid1.RowCount-1 then
              begin
                //下のセルに移動
                StringGrid1.Cells[TargetCol, TargetRow] := '4';
                ActiveControl.Perform(WM_KEYDOWN,VK_DOWN,0);
              end else begin
                //上のセルに移動
                StringGrid1.Cells[TargetCol, TargetRow] := '4';
                ActiveControl.Perform(WM_KEYDOWN,VK_UP,0);
              end;
              Msg.wParam := 0;
            end;

            if (Msg.wParam = VK5) then
            begin
              if TargetRow < StringGrid1.RowCount-1 then
              begin
                //下のセルに移動
                StringGrid1.Cells[TargetCol, TargetRow] := '5';
                ActiveControl.Perform(WM_KEYDOWN,VK_DOWN,0);
              end else begin
                //上のセルに移動
                StringGrid1.Cells[TargetCol, TargetRow] := '5';
                ActiveControl.Perform(WM_KEYDOWN,VK_UP,0);
              end;
              Msg.wParam := 0;
            end;

            if (Msg.wParam = VK6) then
            begin
              if TargetRow < StringGrid1.RowCount-1 then
              begin
                //下のセルに移動
                StringGrid1.Cells[TargetCol, TargetRow] := '6';
                ActiveControl.Perform(WM_KEYDOWN,VK_DOWN,0);
              end else begin
                //上のセルに移動
                StringGrid1.Cells[TargetCol, TargetRow] := '6';
                ActiveControl.Perform(WM_KEYDOWN,VK_UP,0);
              end;
              Msg.wParam := 0;
            end;

            if (Msg.wParam = VK7) then
            begin
              if TargetRow < StringGrid1.RowCount-1 then
              begin
                //下のセルに移動
                StringGrid1.Cells[TargetCol, TargetRow] := '7';
                ActiveControl.Perform(WM_KEYDOWN,VK_DOWN,0);
              end else begin
                //上のセルに移動
                StringGrid1.Cells[TargetCol, TargetRow] := '7';
                ActiveControl.Perform(WM_KEYDOWN,VK_UP,0);
              end;
              Msg.wParam := 0;
            end;

            if (Msg.wParam = VK8) then
            begin
              if TargetRow < StringGrid1.RowCount-1 then
              begin
                //下のセルに移動
                StringGrid1.Cells[TargetCol, TargetRow] := '8';
                ActiveControl.Perform(WM_KEYDOWN,VK_DOWN,0);
              end else begin
                //上のセルに移動
                StringGrid1.Cells[TargetCol, TargetRow] := '8';
                ActiveControl.Perform(WM_KEYDOWN,VK_UP,0);
              end;
              Msg.wParam := 0;
            end;

            if (Msg.wParam = VK9) then
            begin
              if TargetRow < StringGrid1.RowCount-1 then
              begin
                //下のセルに移動
                StringGrid1.Cells[TargetCol, TargetRow] := '9';
                ActiveControl.Perform(WM_KEYDOWN,VK_DOWN,0);
              end else begin
                //上のセルに移動
                StringGrid1.Cells[TargetCol, TargetRow] := '9';
                ActiveControl.Perform(WM_KEYDOWN,VK_UP,0);
              end;
              Msg.wParam := 0;
            end;

          end;
        end;
      end;
    end;
  end;
end;

6.まとめ

重要 各セルへの入力値が10未満であることが前提のコードです!

Bキーを押すだけでStringGridのアクティブなセルにゼロを入力し、フォーカスを次のセルへ移動するプログラムで、必要な変数と手続きは次の通り。

各セルへの入力値が10以上の場合、「まとめ」のコードは期待通りに動作しません。
10以上の入力値にも対応させたい場合は、「5.各セルに対して10以上の値の入力がある場合」が参考になるかもしれません。

  private
    { Private 宣言 }
    //行・列位置を記憶する変数
    TargetRow:integer;
    TargetCol:integer;

    //ある(矢印他)キーが押されたことを知る
    procedure AppMessage(var Msg: TMsg; var Handled: Boolean);

//Col毎のIMEの制御(制御内容はStringGrid1GetEditTextを参照)
type
  _TGrid = class(TCustomGrid);

var
  Form1: TForm1;

implementation

uses
  Vcl.GraphUtil,
  System.UITypes;

  //GraphUtilはFixedセルのセンタリング用に追加
  //System.UITypesはキーコードでBキー(=VKB)を指定するために追加

{$R *.dfm}

procedure TFormCollaboration.FormCreate(Sender: TObject);
begin
  //StringGridの初期位置の設定
  TargetRow := 1;
  TargetCol := 1;
    //AppMessageを有効にする <- 忘れないこと!
  Application.OnMessage := AppMessage;
  //[Enter]でコントロールを移動させるために、Form上のコンポーネント
  //より先にFormがキーボードイベントを取得する。
  KeyPreview := True;
end;

procedure TForm1.StringGrid1SelectCell(Sender: TObject; ACol, ARow: Integer;
  var CanSelect: Boolean);
begin
  //入力=確定&フォーカスの移動用に追加
  //セルを選んだときに行位置を記憶
  TargetRow := ARow;
  //セルを選んだときに列位置を記憶
  TargetCol := ACol;
end;

procedure TForm1.AppMessage(var Msg: TMsg; var Handled: Boolean);
begin
  //任意のキーの押し下げをキャッチ
  if Msg.message = WM_KEYDOWN then
  begin
    //StringGridがアクティブだったら
    if ActiveControl is TStringGrid then
    begin
      //StringGridが編集可能だったら
      if TStringGrid(ActiveControl).EditorMode then
      begin
        //Bキー or 0キー押し下げでゼロを入力(入力値は10未満であることが前提)
        if (Msg.wParam=VKB) or (Msg.wParam=VK0) then
        begin
          //keybd_event(VK_TAB,0,0,0);
          //VK_TABではカーソルがレコードの項目を右へ移動。
          //ActiveControl.Perform(WM_KEYDOWN,VK_TAB,0);
          //VK_DOWNにすると同じ項目の次のレコードへ移動。
          if TargetRow < StringGrid1.RowCount-1 then
          begin
            //アクティブなセルが最終行でない場合はフォーカスは下へ移動
            StringGrid1.Cells[TargetCol, TargetRow] := '0';
            ActiveControl.Perform(WM_KEYDOWN,VK_DOWN,0);
          end else begin
            //最終行ならフォーカスは上へ移動
            StringGrid1.Cells[TargetCol, TargetRow] := '0';
            ActiveControl.Perform(WM_KEYDOWN,VK_UP,0);
          end;
          //Msg.wParam := #0; //エラーになる
          Msg.wParam := 0;
        end;
      end;
    end;
  end;
end;

7.お願いとお断り

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

Causes of hard-to-find bugs

「また、やっちゃった。発見困難なバグの原因(は自分)」

数十枚の画像を次々に切り貼りして保存するプログラムを書いた際に、少しでも高速に処理するため、VCLのBmpをGDI+のBmpに変換して保存する方法を採用した。その際、var宣言に画像処理用の変数を、付け足し、付け足し・・・してプログラムを書いたら、自ら発見困難なバグを作り出してしまった・・・というお話。

1.不思議な現象が発生(バグその1)
2.原因を解明(したはずだった)
3.さらに不思議な現象が発生(バグその2)
4.バグ作成の元になった状況を再現
5.Createしないで使った場合は・・・
6.まとめ
7.お願いとお断り

1.不思議な現象が発生(バグその1)

TImageに数十枚の画像を切り貼りする処理は、それなりに時間がかかる。なので、処理が完了するまでは、ButtonのEnabledプロパティをFalseに設定して、気の短いユーザーに何度もボタンをクリックされるのを防止する。

このような場合、try ~ finally ~ end; を使って・・・

begin
  Button1.Enabled := False;
  try
    //処理
  finally
    Button1.Enabled := True;
  end;
end;

処理の途中で、なにかエラーがあっても、最終的にはTButtonのEnabledプロパティがTrueになるように組むことが基本だと学んだ。

同様に、TImageをマウスでクリックした際のイベントを拾う処理でも、間違ったクリックを拾うのを防止するため、ユーザーのクリックを拾った場合はTImageのEnabledプロパティを、一時的にFalseに設定して、メッセージを表示し、ユーザーの受け答えに応じてEnabledプロパティをTrue/Falseのいずれかに設定するようにしたのだが、この処理のどこかでTImageのEnabledプロパティがFalseのままになってしまって、いて・・・。

そのため「ある特定の画像処理手続き(その1)」を実行した後では、TImageのEnabledプロパティがFalseになっているから、TImageの画像をクリックすると走る「ある特定の画像処理手続き(その2)」が絶対に実行できない。プログラムを再起動して、手続き(その1)を実行せずに、手続き(その2)を実行した場合は、何でもなかったかのように問題なく手続き(その2)が実行できる。・・・という、理由がわかってみれば当たり前なんだけど、原因がわかるまでは何とも摩訶不思議な現象が発生(これがSetFocusならエラーが発生するから、話はまた別なんだけど・・・)。

2.原因を解明(したはずだった)

プログラムが完成に近づいたところで、(なんでかなー?)って、真剣に考えてようやくTImageのEnabledプロパティ設定の切り替え忘れだと気づき、あわててTImageに対する処理の直前に Image1.Enabled := True; を入れてプログラムを修正。

こんなことにならないよう、画像処理(その2)の手続きの最初に、先に述べたように、Image1.Enabled := True; と記述して強制的にエラー防止策をとるか、「TImageのEnabledプロパティがFalseで変更できません!」みたいなエラーメッセージが表示されるよう、if not Image1.Enabled then のようなエラー回避の処理を入れておくべきだったのだ。そうすれば、もっと早く間違いを発見できたと思うのだが、実際には、EnabledプロパティがFalse状態のTImageをクリックしても「何も起こらない」(もちろんエラーも起きない)ので、Enabledプロパティの設定が原因だと気づくまでに(なんでかなー?)っと、考えに考え、それなりに時間がかかってしまったのだ。

これで原因は解明され、バグは消えた(・・・と僕は思っていた)。

3.さらに不思議な現象が発生(バグその2)

TImageのEnabledプロパティ設定を修正したプログラムを実行してみると、今度は画像処理(その1)を行ったあと、連続して画像処理(その2)を確実に実行できるようになった。

MyPCで最初にテストした時は、画像処理(その1)に続けて、画像処理(その3)も確かにエラーなく実行できた。何回か、その後もMyPCでテストを繰り返し、僕は問題が完全に解決できたと信じ、MyPCではない、このプログラムを実際に実行(運用)する予定の業務用ノートPCで試しにプログラムを動かしてみた。すると・・・

MyPCではエラーを起こしたことは1回もなかったのに、業務用のノートPCでは画像処理(その3)で時々エラーが発生する。しかも、それが毎回必ず発生するわけではなく、起きる時と、起きない時があり、どちらかと言えば、起きるほうが少ない。画像処理(その3)は数十枚の画像に変更を加えて、さらにそれを1枚ずつ保存する時間のかかる重たい処理なので、途中で何らかの障害が発生してエラーになるのかと思ったが、エラーが起きなくても(エラーメッセージが明示的に表示されなくても)、画像に対して行った変更が「まったく保存されていない場合がある」ことにも気づく。同じループの中で処理した画像なのに、変更が保存される場合と、されない場合の2通りがあるなんて! しかも、ランダムに。これはもう、完全に想定外。・・・てか、Delphi環境下、Object Pascalで書いたプログラムで、まさか、こんなことが起きるなんて・・・信じられない。Delphiとの思い出を過去20年遡って、こんなエラーを、僕は、これまでに経験したことが「ない」。

混乱の中で思いついたことは、GDI+を使った保存処理の記述のどこかに問題があるのは間違いないから、いったん、GDI+で処理していた部分をコメント化して、旧来のオーソドックスなJpeg画像の保存処理に変更してみることだった。これでエラーが起こらずに、変更を加えた画像データがきちんと保存できれば、最後の一手だけは確保できる。

procedure TForm1.ButtonXClick(Sender: TObject);
var
  jpg: TJPEGImage;
  s, strText: string;
begin
  //エラーが発生しても処理を止めない
  try
    for i := 1 to StringGrid1.RowCount-1 do
    begin
      S := ChangeFileExt(ListBox2.Items[i-1], '.jpg');
      Jpeg := TJPEGImage.Create;
      try
        //Jpeg.Assign(Image1.Picture.Bitmap);
        Jpeg.Assign(Image1.Picture.Graphic);
        Jpeg.Compress;
        Jpeg.SaveToFile(S);
      finally
        Screen.Cursor := crDefault;
        Jpeg.Free;
      end;
    end;
  except
    //エラー発生時の処理
    on E: Exception do begin
      strText := E.ClassName + sLineBreak + E.Message;
      Application.MessageBox(PChar(strText), '情報', MB_ICONINFORMATION);
    end;
  end;
end;

期待した通り、これなら、まったくエラーは起きない。ただし、GDI+を使った画像の高速な保存処理に慣れてしまった自分には、耐え難いほど処理速度が遅い・・・

すごく悩む。
時々、エラーは起こすけど、とりあえず動くし、何より速いGDI+のままで行くか、
それとも、遅いけど、確実に動作する旧来のJpeg画像の保存方法に変えるか、
それとも、いっそのこと、ユーザーが画像の保存処理方法を選択できるようにするか、
それとも、エラーが起きた時だけ、旧来の保存方法に戻そうか、
でも、明示的なエラーが起こらずに、変更した画像が保存されてない場合もあるし・・・

どうしよう・・・

困ったことにGDI+を使った方法では、もし明示的なエラーが発生しても、続けてもう一度保存処理を実行すれば「何事もなかったか」のようにプログラムは走り、多くの場合、「何事もなかったか」のように画像が保存されるのだ。ただ、ループ処理10回に1回くらいの割合で、エラーが出ないにもかかわらず、しかも同一ループの中で保存処理する画像全部ではなく、そのうちの数枚だけ、加えた変更が「なぜか、反映されない」不思議な現象がランダムに起こってしまう。

いくらGDI+で保存する処理のプログラムを眺めても、原因が見出せない。
(この時はVCLからGDI+へのビットマップの変換部分に原因があると思っていた)

GDI+の保存処理のどこに原因があるのか、それがどうしてもわからなくて困った僕は、要するにJpegで保存するから、圧縮に時間がかかって遅いんだと考え、試しにビットマップ画像で保存する処理も試してみることにした。

  S:=ChangeFileExt(ListBox2.Items[i-1], '.bmp');
  tmpBmp:=TBitmap.Create;
  try
    tmpBmp.Assign(Image1.Picture.graphic);
    tmpBmp.SaveToFile(S);
  finally
    tmpBmp.Free;
  end;

エラーも出ず、全ての画像が確実に保存され、かつ、処理速度も速い。
ただ、Jpegで保存すれば、1枚あたり数百KBしかなかった画像が、わずか1枚で10MBを超える容量を食ってしまう・・・。ここさえ目をつぶればBMPも「あり」なんだけど。

だが、作業する度にとんでもない容量を食いつぶす画像データが生成されることを考えると、いくらGBオーダーのSSDを積んでるって言っても、やっぱりBMPでの保存は無理だ。

ここにきて、ようやく僕はTImageのEnabledプロパティの設定以外の、より重大で、深刻なバグが自分の書いたプログラムのどこかに潜んでいることに気づく。

溺れる者は何とかで(せめて、毎回、確実にエラーが起きてくれれば・・・)と、とんでもないことまで考えてしまう(これまでいろんなプログラムを作ったけれど、エラーが発生して欲しいと、心から願ったことは多分なかった気がする・・・)。

何度、プログラムの怪しいと思われる(ビットマップへの変換処理)部分を見返しても原因がわからない。援けてくれる人は誰もいない。泣いても、喚いても、自分で何とかするしかない。今までにも数限りなく、これを繰り返してきたんだけれど、ここでまた・・・

選択肢は次のいずれか。
GDI+をあきらめる、か、あきらめないか だ。
自分で決めるしかない。どちらをとる? 決心するための、自問自答を繰り返す。

( あきらめたら、僕は、もう、よくなれない )

答えは一つしかない。それは最初からわかっている。大量の画像を保存するから、処理速度が速いことが、絶対条件だ。ならば、今、自分が知っている最良・最速の処理方法であるGDI+を使うしかない。GDI+のプログラム自体に間違いがあるとは思えないから、データの保存処理の記述を見直して、誤りを発見・修正し、その保存の確実性を100%にすればいいだけだ。少なくとも、今、画像データの保存処理のどこかに重大な問題が隠れていることだけは、わかった。

( きっと、もう少しだ )

このプログラムより先にGDI+を使って書いた1枚の画像を保存する処理は、確実に成功して、エラーが発生したことは1度もない。だから、GDI+のプログラム自体には絶対に間違いはない。自分の書き方のどこかに問題がある。もし、GDI+の使用をあきらめないなら、時々エラーになるその原因を探し、修正すること以外に、その解決方法はない。

( いま、僕に、できることは・・・? )
( 原因がわかるまで、最初から1行ずつ、プログラムを見直すんだ・・・。 )

そう決心した僕は、試しに書いたTJPEGImageを使う保存手続きをコメント化して、もう一度、GDI+を使った保存手続きを最初から1行ずつ、読んでみることに決めた。

4.バグ作成の元になった状況を再現

次のプログラムが「バグ作成!の元になった状況を再現」したもの。どこに重大な問題があるのか、すぐに気がつかないのは僕だけかもしれないが・・・。
(実際には、この他にもその他の変数の宣言、数々のエラー処理や、ループの中で複数の画像を加工する処理が書かれている。また、ファイルの保存パスはプログラム内で明示的に指定し、SaveDialogは使用していない)

重要 コピペ厳禁!!(このプログラムには重大な誤りがあります)

implementation

uses
  System.IOUtils,
  Winapi.GDIPAPI, Winapi.GDIPOBJ, Winapi.GDIPUTIL;

  //System.IOUtilsはPathから拡張子を取得するTPath.GetExtensionを使うために追加
  //GDIPAPI, GDIPOBJ はGDI+を利用した描画に資料するために必要
  //GDIPUTILを宣言すればGetEncoderClsid関数を利用してGUIDを取得できる

{$R *.dfm}

procedure TForm1.Button1Click(Sender: TObject);
var
  //TImageへの画像読み込み用に使用
  bmp:TGPBitmap;
  //VCL TBitmapからGDI+ Bitmapへの変換に使用
  Graphics:TGPGraphics;
  srcBMP:TBitmap;
  dstBMP:TGPBitmap;
  stream:TMemoryStream;
  //拡張子を取得するために使用
  dotExt, strExt:string;
  //GetEncoderClsid関数の利用とTGUIDを使用するには、usesにWinapi.GDIPUTILが必要
  ImgGUID:TGUID;
begin

  //Create
  bmp:=TGPBitmap.Create;
  try
    //TImageへ画像を読み込む処理
  finally
    //確実に解放
    bmp.Free;
  end;

  {TImageに読み込んだ画像に数々の変更を加える処理}

  //SaveDialogのプロパティはExecuteする前に設定しておくこと
  With SaveDialog1 do begin
    //デフォルトのファイル名を設定
    FileName:='Test';
    //表示するファイルの種類をcsvに設定
    //Filter:='コンマ区切りテキストファイル(*.csv)|*.csv';
    //表示するファイルの種類を設定
    //Filter:='JPEG Files (*.jpg, *.jpeg)|*.jpg;*.jpeg';
    Filter:='画像ファイル|*.png;*.jpg;*.gif;*.bmp;*.tif;*.emf;*.wmf;*.ico' +
    '|*.png|*.png' +
    '|*.jpg|*.jpg' +
    '|*.gif|*.gif' +
    '|*.bmp|*.bmp' +
    '|*.tif|*.tif';
    //データの読込先フォルダを指定
    InitialDir:=ExtractFilePath(Application.ExeName)+'Data';
    //拡張子の指定がなかった場合に付加される拡張子を指定
    DefaultExt:='jpg';
    //上書き保存の確認の設定
    Options:=[ofOverWritePrompt];
  end;

  if not SaveDialog1.Execute then Exit; //キャンセルに対応

  //保存(VCL TBitmap -> GDI+ Bitmap)
  srcBMP:=TBitmap.Create;
  srcBMP.Width:=Image1.Width;
  srcBMP.Height:=Image1.Height;
  srcBMP.Assign(Image1.Picture.graphic);
  //データ受け渡し用のストリームを生成して保存
  stream:=TMemoryStream.Create;
  srcbmp.SaveToStream(stream);
  //保存GDI+のBMPを生成
  dstbmp:=TGPBitmap.Create(TStreamAdapter.Create(stream));
  ////変更できるのはBitmapを含む画像のみですのエラーが発生
  //Graphics:=TGPGraphics.Create(Image1.Canvas.Handle);
  //これならエラーは発生しない
  Graphics:=TGPGraphics.Create(Image1.Picture.Bitmap.Canvas.Handle);
  try
    Graphics.DrawImage(dstbmp,0,0);
    //拡張子を小文字に変換して取得(.XXX形式:Dotが付いている)
    dotExt:=LowerCase(TPath.GetExtension(SaveDialog1.FileName));
    //JPEGに対応する
    if dotExt='.jpg' then begin
      strExt:='jpeg';
    end;
    //指定された拡張子を付けて保存
    if GetEncoderClsid('image/'+strExt, ImgGUID) >= 0 then
    begin
      bmp.Save(ChangeFileExt(SaveDialog1.FileName, dotExt), ImgGUID);
    end;
  finally
    Graphics.Free;
    srcbmp.Free;
    dstBMP.Free;
    stream.Free;
  end;
end;

上のコードのもとのプログラムは、SaveDialogでファイル名を含めた保存パスを取得してGDI+で保存処理するものだった。だから、ビットマップ変換用の変数は必要なく、一つだけ、ビットマップデータを入れるTGPBitmap型の変数bmpを用意すれば事足りた。

GDI+を使った画像の保存処理を実現するために、どうしても必要だったのが「VCLのビットマップ」を「GDI+のビットマップ」に変換する作業で、これが出来なかった僕はさんざん悩みながら、Web上の情報に援けてもらって、この変換処理を行う方法を学んだ(その詳細は、次のリンク先を参照)。

で、僕はビットマップ変換処理用に、srcBMP:TBitmap; dstBMP:TGPBitmap; stream:TMemoryStream; 等の変数を今回のプログラムに追加した。

GDI+で書いた元々のプログラムは、ファイルとして存在する画像データをOpenDialogを使ってGDI+ビットマップに読み込み、SaveDialogでファイル名を含めて保存パスを指定して処理するものだった。だから、ビットマップ変換用の変数は必要なく、bmp:TGPBitmap; として、ビットマップデータを入れる変数を1つだけ var 宣言して、もちろん、読み込み時にも、書き込み時にも、それぞれの手続きで同じように、これをローカル変数として使用した。

読み込み、書き込みの手続きはそれぞれ独立していたから try 文のfinallyブロックで、bmp.Free として最後に確実に解放すれば、何も問題は起きなかった。

しかし、このプログラムの保存手続きでは、GDI+を利用して、高速にTImageへ画像を読み込み、その画像に変更を加え、TImageのVCLのビットマップからGDI+のビットマップに変換して、保存処理を行っている。

1行ずつプログラムを確認して行く。そして、ついにバグの原因に気づく。

var
  //TImageへの画像読み込み用に使用
  bmp:TGPBitmap;
  //VCL TBitmapからGDI+ Bitmapへの変換に使用
  dstBMP:TGPBitmap;
  ・・・ 省略 ・・・
begin
  //Create
  bmp:=TGPBitmap.Create;
  try
    //TImageへ画像を読み込む処理
  finally
    //確実に解放
    bmp.Free;
  end;

画像の読み込み完了時に、変数bmpは解放済みだから、この変数は再度Createしない限り、もう使えない。しかし、この手続き内で有効な変数としてvar宣言してあるので、Freeした後の保存手続き内でうっかり(Createの有無にかかわらず)記述してしまっても、「未定義の識別子エラー」にはならない。もちろん、コンパイルも警告なしで通る・・・。

【誤りのあるコード】

    //指定された拡張子を付けて保存
    if GetEncoderClsid('image/'+strExt, ImgGUID) >= 0 then
    begin
      bmp.Save(ChangeFileExt(SaveDialog1.FileName, dotExt), ImgGUID);
    end;

【正しいコード】

    //指定された拡張子を付けて保存
    if GetEncoderClsid('image/'+strExt, ImgGUID) >= 0 then
    begin
      //bmp.Save(ChangeFileExt(SaveDialog1.FileName, dotExt), ImgGUID);
      dstbmp.Save(ChangeFileExt(SaveDialog1.FileName, dotExt), ImgGUID);
    end;

同じ手続き内の最後の部分で、僕は間違えて(というか、おそらくビットマップ変換処理を追加した際に書き換えるのを忘れて)本来、ここでは使えないはずの変数bmpを指定したまま、そのSaveメソッドを使った画像の保存処理を書いてしまっている(正しくは、ビットマップ変換用に用意した変数dstbmpを指定しなければならない)。

論理的に明らかな誤りを含んだこのプログラムは、しかし、記述時に「未定義の識別子エラー」は出ず、実行時のコンパイルも問題なく通る。

(その理由は僕にはわからないが)
さらに恐ろしいことに、かなりの確率でデータの保存にも成功!してしまう。
明示的なエラーが発生するのは、ループを数百回まわして1回程度。

元にした保存処理のコードが確実に動作することは確認済みだから、(間違ってない)ビットマップ変換のどこかに誤りがあることを疑いはしても、まさかSaveメソッドの変数名が「誤り」で、バグの原因になっているとは(そこは絶対!大丈夫)と思い込んでいるから、疑ってもみない・・・。

絶対、大丈夫。
そう思っていた部分に誤りがあったことは、これまでにも無数にあったのに。

5.Createしないで使った場合は・・・

手続き最初の画像読み込み部分をコメント化して実行した場合は、Createしていないからインスタンスのない変数bmpのSaveメソッドを使うことになるので、次のような警告が表示され、さらに、プログラムで保存の手続きを実行した場合は、ほとんどの場合(明示的なエラーは発生せずに)ファイルの保存に成功するが、プログラム終了後に、しばらくしてからエラーメッセージが表示される。

  //Create
  {
  bmp:=TGPBitmap.Create;
  try
    //何らかの処理

  finally
    //解放してしまう
    bmp.Free;
  end;
  }
コンパイルは通っても警告がきちんと表示される。
プログラムを終了してしばらくすると、エラーメッセージが表示される。

6.まとめ

Createして使用後、Freeした変数を再宣言しないで使うと使えてしまうことがあることをこの例から初めて学んだ。この場合は、Delphiのデフォルト設定のままでは、警告も、エラーメッセージも出ない。さらに、その理由はわからないが、多くの場合Saveメソッドは成功し、データは実際に保存される(時々、保存されないこともある)。明らかに誤りのあるロジックを構築したのは僕だから、Delphiのコンパイラにはまったく責任はない。コーディングの決まりを無視して、想定外のコードを書いたのは僕自身だ。今回の問題解決の経験から、あらためて、Delphiのコンパイラが発見困難なバグの真の原因は自分が作り出していることを学んだ。

たまたま、偶然、変数の修正を忘れたままになっていることに気がつけたからよかったが・・・(不幸中の幸いとは、まさにこのこと)。万一、真の原因が解明できないまま、バグの原因究明をあきらめて、誤りを含んだまま、このプログラムをユーザーに配布していたらと思うと・・・。

あらためて、思った・・・。
少しでも不具合がある場合は、徹底的に原因を解明して100%確実に動作する状態であることを確認しなきゃいけないって。僕は、自分自身と大切な約束を交わした。それは・・・

バグ探しのポイントは、(絶対、大丈夫)と思い込んでる場所を重点的に確認すること。

なぜ、MyPCではまったくエラーが起きなかったのに、業務用のPCではそれなりにエラーが起きたのか、それは今でも謎のままなんだけれど・・・(その後、MyPCでもテスト中に明示的なエラーが発生することを1回確認)。

MyPCで動作確認して問題なかったプログラムが、業務用PCで走らせるとエラーを起こす不思議は、これまでにも何回もあった。職場の業務用PCに、明日、(ありがとう)って感謝の気持ちを伝えよう。

7.お願いとお断り

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

When the date and time display is hidden

「日付と時刻の表示が隠れた時は・・・」

Windows11を使うようになってから、画面右下の日付と時刻の表示が半分隠れて見えなくなってしまう現象を、しばしば目にするようになった。(10の時はなかったけどなー)・・・と思いながら、再起動してみたり、直し方を調べてみたりしたけど・・・。

偶然、発見したいちばんカンタンな、その直し方とは?(※ 僕にとって、です)

1.時々発生する困った現象
2.偶然発見したいちばんカンタンな直し方
3.まとめ
4.お願いとお断り

1.時々発生する困った現象

Windows11になってから、タスクバーの設定の自由度が失われてしまったことを最初の頃はとても残念に感じていたんだけれど、いつの間にか「その仕様」に慣れて、タスクバーの設定はほとんどいじらずに、今はデフォルト状態のまま。

で、時々、発生するのが、次の現象。
気がつくと、いつの間にか、日付と時刻の表示の右側が切れちゃってる・・・。

いつの間にか、日付と時刻の表示が半分になっちゃった・・・
ほんの少しだけ、切れちゃってることも。

2.偶然発見したいちばんカンタンな直し方

Google先生に直し方を尋ねても、「これだ!」みたいな直し方はヒットせず。実害というほどの実害もないし、いつの間にか、直っていたりするから、気にしないでいたんだけど、偶然、走召!カンタンな直し方を発見。

「隠れているインジケーターを表示します」をクリック!

上の図に示したように、画面右下の「隠れているインジケーターを表示します」の「∧」マークをクリックすると、クリックした瞬間に正しい表示に戻ります(※ MyPCでは)。

3.まとめ

Windows11で、画面右下の「日付と時刻の表示」がオカしくなった時は、「隠れているインジケーターを表示します」の「∧」マークをクリックすると、クリックした瞬間に正しい表示に戻る(※ MyPCでは)。

拝啓 Microsoft OS開発ご担当者 様
バグなら、早くなおして欲しい・・・です。

4.お願いとお断り

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

Vertical alignment of Grid control

「Gridコントロールの縦方向のアライメントを設定したい」

手書き答案を採点するプログラムで、「答案(の各解答欄)画像の高さ」と「得点を入力するStringGridのセルの高さ」が同じになるように設定したら、編集モード時に、データがセルの左上に表示されるのが何だか気になった(実用上は問題ない。あくまでも気分の問題)。編集してない時は、データはセルの中央に表示されてるので、編集中も垂直方向はセルの中央のまま、水平方向のみ左へ移動する形でデータを表示したい・・・と考えた。

セルの水平方向のアライメントはプロパティで設定できることは知ってたが、調べてみると、縦方向のアライメントは標準のStringGridでは設定できない(?)ようだ。なるべくなら、新しくコンポーネントをインストールしてこれを実現することは避けたい(PCを新しくした場合や、Delphi自体のバージョンアップ等の際に、再セットアップが必要なコンポーネントはなるべく少ない方がいい)ので、何とかならないかなーと思って調べてみた。そしたら、Web上に諸先輩が公開してくださっている数々のお知恵にすがりつきまくることで、案外、カンタンに、何とかなっちゃったというお話。

ここで利用させていただいた知恵のすべてを、自分で最初から作るとしたら、きっと途中で挫折するだろうし、もし、挫折しなくても、完成までには、「とほー」もない時間が必要なことだけは間違いありません。思い立ってわずか1時間で希望のプログラムができたのは、参照させていただいた資料を公開してくださっている皆様のおかげです。心から厚く御礼申し上げます。ほんとうにありがとうございました。

1.これをなんとかしたかった!
2.コンポーネントをインストールせずに使う方法
3.画像のスクロールとGridコントロールの連動
4.まとめ
5.お願いとお断り

1.これをなんとかしたかった!

作成した手書き答案採点プログラムの実行時の画像は、以下の通り。

答案画像から設問毎に解答画像をかきあつめて、受験者全員分をまとめて表示している

答案用紙画像から切り出した各解答欄の画像の高さと、StringGridのセルの高さを同じにした方が採点しやすいだろうと考えたので、次のコードでこれを設定。

  StringGrid1.RowHeights[0]:=24;
  for i := 1 to StringGrid1.RowCount-1 do
  begin
    //SrcRectは解答欄画像の矩形
    StringGrid1.RowHeights[i] := SrcRect.Height;;
  end;

で、なんとかしたい部分が、こちら。

編集中の採点欄のデータがセルの左上に表示されている。縦方向のアライメントも真ん中にしたい!

調べた限り・・・のことなので、もしかしたら間違ってるかもしれないが、標準のStringGridでは縦方向のアライメント設定はできないようだ・・・(もしかしたら、できるのかな?)。半分くらい、あきらめモードで(やっぱり、無理かなー? まぁいいかー)って思いつつもあきらめきれず、Web上の多くの資料に目を通していると、Mr.XRAYさんのWebサイトの「055_ドロップダウンリストを実装した TStringGrid コンポーネント」というページの中に、「06_インプレイスエディタの縦方向のアライメントと左右のインデント」という、まさに実現したいこと、そのものずばりの記事を発見。

055_ドロップダウンリストを実装した TStringGrid コンポーネント

http://mrxray.on.coocan.jp/Delphi/plSamples/055_TplDropStringGrid.htm

上記ページで、Mr.XRAYさんがドロップダウンリストの機能付きのTStringGrid コンポーネントとして公開してくださっているplDropStringGrid.pasには、インプレイスエディタ関係のプロパティとイベント類が追加されており、これをインストールすれば、StringGridで編集モード時に起動するインプレイスエディタの縦方向のアライメントが設定できるとのこと。

これで「夢見たことは実現可能であることがわかった」が、もし、できることなら、コンポーネントをインストールせずに使えないか? とさらに欲張りなことを考えてしまった・・・。理由はたった一つ。StringGridのセルの高さを変えるようなプログラムは、今後、たぶん書かないんじゃないかなーって、思ったから。

・・・ということで、今度は「コンポーネントをインストールせずに使う方法」を探してみた(探しつつ、前に見たことがあるような気がした)。

2.コンポーネントをインストールせずに使う方法

こちらも、そのものずばりの方法が次のWebサイトに公開されていました。作者の方に心から感謝申し上げます。

コンポーネントをインストールせずに使う方法

http://delfusa.main.jp/delfusafloor/technic/technic/024_ChangeComponent.html

上記Webサイトにあった情報をもとに、夢を実現。

まず、上記Mr.XRAYさんのWebサイトから「055_TplDropStringGrid.zip」をダウンロードして解凍。中に含まれている「plDropStringGrid.pas」をコピーして、Delphiのプロジェクトファイル(*.dproj)があるフォルダに貼り付け。

プログラムには、次のコードを加えた。

uses
  ・・・ 省略 ・・・
  plDropStringGrid, System.TypInfo;

  //plDropStringGrid, System.TypInfoは、実行時にコンポーネントを交換するために追加
  //-> StringGridの縦のアライメントを設定する目的

{$R *.dfm}

こちらの「コンポーネントを交換する関数」は、記事にあったものをそのまま、コピペ!

//コンポーネントを交換する関数
//usesにTypInfoの追加が必要
function ChangeComponent(Original: TComponent; NewClass: TComponentClass): TComponent;
var
  New: TComponent;
  Stream: TStream;
  Methods: array of TMethod;
  aPPropInfo: array of PPropInfo;
  MethodCount, i: Integer;
begin
  SetLength(aPPropInfo, 16379);
  MethodCount := GetPropList(Original.ClassInfo, [tkMethod], @aPPropInfo[0]);
  SetLength(Methods, MethodCount);
  for i := 0 to MethodCount - 1 do
    Methods[i] := GetMethodProp(Original, aPPropInfo[i]);

  Stream := TMemoryStream.Create;
  try
    Stream.WriteComponent(Original);
    New := NewClass.Create(Original.Owner);
    if New is TControl then
      TControl(New).Parent := TControl(Original).Parent;
    Original.Free;
    Stream.Position := 0;
    Stream.ReadComponent(New);
  finally
    Stream.free
  end;

  for i := 0 to MethodCount - 1 do
    SetMethodProp(New, aPPropInfo[i], Methods[i]);
  Result := New;
end;

この関数を、FormCreate時に呼び出して、実行。

procedure TFormCollaboration.FormCreate(Sender: TObject);
begin
  //コンポーネントを交換する関数を実行
  StringGrid1:= TStringGrid(ChangeComponent(StringGrid1, TplDropStringGrid));
end;

ここまでが準備で、縦のアライメントの設定は、次のたった1行(赤字)を追加するのみ!

procedure TFormCollaboration.StringGrid1GetEditText(Sender: TObject; ACol,
  ARow: Integer; var Value: string);
begin

  //縦のアライメントを設定
  TplDropStringGrid(StringGrid1).EditVertAlignment := vaCenter;

  //IMEの制御
  with TEdit(_TGrid(Sender).InplaceEditor) do
  begin
    //ImeMode := imClose;   //日本語入力OFF-> ×
    ImeMode := imDisable;   //日本語入力OFFは imDisable
  end;

  //現在Activeな行番号を取得
  intStringGrid1ActiveRow:=ARow;

end;

実行結果です。

インプレイスエディタ起動時、アライメント設定は「水平方向は左・垂直方向は中央」

旅行先で、ちょっと時間ができたので、前にユーザーと話しをする中で思い立った解答欄画像の高さと採点欄の高さを同じにするコードをちょこちょこっと書いて、動作を確認。そしたら今度は、編集モードでのセルの挙動が気になり、翌朝、早く目覚めたので、まさか旅先で書くとは思わなかったけど、PCは持参していたのでこれ幸いと、お日さまが昇るころまでにここまでの内容を記述(・・・というかほぼ全部コピペ)。

3.画像のスクロールとGridコントロールの連動

次に気になったのがTImageに表示した答案画像と、StringGridのスクロールの連動(同期)。

実はこれも前から気になっていたコトだったんだけれど、いろんな事情から、とりあえずプログラムを使える状態にすることが最優先だったので、ずっと後回しにしてきた課題。

今回、解答欄画像の高さと、採点欄の高さを揃えたら、以前にも増して同期の必要性を痛感。まだ、期待通りの動きになった・・・とは言い難い状態なんだけど、現在のコードは次の通り(こちらもずっと以前にMr.XRAYさんのWebサイトにあった記事を参考にさせていただいて書いたプログラムからコピペしたコードだったような記憶が・・・)。参考にさせていただいたのは、おそらく次のページ。

078_コントロールのマウスホイール操作によるスクロール

http://mrxray.on.coocan.jp/Delphi/plSamples/078_Control_MouseWheel.htm
調整値1-10を設定するComboBox

マウスに関する諸設定は、環境により異なるので、調整値は固定値にしないで、ユーザーが自由に設定できるようにした(つもり)。My PC環境で試したところ、次のコードでは、調整値「7~8」くらいが期待に近い動きをするようだ。

procedure TFormCollaboration.FormMouseWheel(Sender: TObject; Shift: TShiftState;
  WheelDelta: Integer; MousePos: TPoint; var Handled: Boolean);
var
  LDelta:Integer;
  LWinCtrl:TWinControl;
  LCurPos:TPoint;
  //スクロール量の調整(SA:Scroll Amount)
  intSA:integer;
begin

  //マウスカーソルが TScrollBox の領域内にある時だけスクロールを可能にする
  //(解答欄画像を表示しているTImageはTScrollBoxの上に配置)
  LCurPos := ScrollBox1.Parent.ScreenToClient(MousePos);
  if PtInRect(ScrollBox1.BoundsRect, LCurPos) then
  begin
    //スクロール量の調整
    if not TryStrToInt(調整値1-10を設定するComboBoxの値, intSA) then
    begin
      intSA:=1;
    end;
    //心配なので、念のために設定その1
    if 調整値1-10を設定するComboBoxの値 ='0' then
    begin
      intSA:=1;
    end;
    //心配なので、念のために設定その2
    if StrToInt(調整値1-10を設定するComboBoxの値) < 0 then
    begin
      intSA:=1;
    end;
    //大きい数値を選ぶとスクロール量も大きくなるように設定
    intSA:=11-intSA;
    LDelta := WheelDelta div intSA;
    if ssCtrl in Shift then
    begin
      ScrollBox1.HorzScrollBar.Position := 
        ScrollBox1.HorzScrollBar.Position - LDelta;
    end else begin
      ScrollBox1.VertScrollBar.Position := 
        ScrollBox1.VertScrollBar.Position - LDelta;
      //StringGridも連動してスクロールさせる
      if LDelta > 0 then
      begin
        StringGrid1.Perform(WM_VSCROLL, SB_LINEUP, 0);
      end else begin
        StringGrid1.Perform(WM_VSCROLL, SB_LINEDOWN, 0);
      end;
    end;
  end else begin
    //マウス直下のコントロールを取得
    LWinCtrl:=FindVCLWindow(MousePos);
    //TStringGridの場合
    if LWinCtrl is TStringGrid then
    begin
      if WheelDelta > 0 then
      begin
        LWinCtrl.Perform(WM_VSCROLL, SB_LINEUP, 0);
      end else begin
        LWinCtrl.Perform(WM_VSCROLL, SB_LINEDOWN, 0);
      end;
    end;
  end;
  //この1行を忘れないこと!
  Handled:=True;

end;

テストしたPCのマウス関連の諸設定は、以下の通り。

テストしたPCのマウス関連の諸設定①
テストしたPCのマウス関連の諸設定②
テストしたPCのマウス関連の諸設定③

4.まとめ

StringGridで編集モード時に、縦のアライメントを設定するには、標準のStringGridでは機能的に難しいので、それが可能な標準のStringGridを継承したコンポーネントを利用する。コンポーネントのインストールが難しい場合は、実行時に標準のStringGridと入れ替える形で、そのコンポーネントを動的に生成することで、目的を実現できる可能性がある(実行時の動的な生成で、目的を実現できるか・どうかは、十分なテストを行って確認する)。

5.お願いとお断り

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

I also want to enter the triangle mark!

「(採点ソフトで)〇と × だけじゃなく△も入力したい!」

手書き答案を採点するプログラムを書いた。当初の予定では、採点記号は正解(〇)か、不正解(×)のみとして、正解(〇)の場合は、その得点を採点記号の右に表示できるように設定したから、その点数の大小によってそれが完全正解なのか、△(部分点あり)なのかを判別できればイイ、「だから△なんてイラナイ」と、僕は考えていたんだけれど・・・。

今にして思えば、弱い自分への言い訳でした・・・ T_T

ほぼ完成に近づいたMy手書き答案採点プログラムのイメージ。
(この時点で、本人は「完成した」と思っていた・・・)

No,2とNo,6の -5x は完全正解の半分の2点しかあげないけど、でも × じゃないよー。みたいな・・・

でも、そんな時、偶然、Webのニュースで見ちゃったんだけど、東京都が公的に導入した業務改善用の採点ソフトでは・・・

あたりまえのコトですが、
採点記号に△も使ってるんですよ!

僕のプログラムでは、絶対に「表示できない」△マーク。
別に△マークがあったって、エラくなんか、ないもん・・・。うぐぐ。

でも、それって、
走召!ぐやじい!!!
じゃありませんか。

僕のプログラムの完成なんて、誰ひとり、待ってないケド・・・

一般庶民のフツーの感覚で言えば(僕の感覚と常識が正しいとして)、一昨年、
一般庶民には買えない価格のDelphiを、「個人で購入するという暴挙に出た」僕です

それはDelphiが、Object Pascalが好きだから。
出会った時から、ずっと 大好きな・・・Delphiの・・・
この文化が消えないように、この言語がいつまでも残るように、
Delphiと、Object Pascalが、ほんとうに大好きだからやったことなんだけど・・・。

あれだけの初期使用料と、高額な年度ごとのサブスクリプション代金を支払っても・・・。
僕はイイから。

(結婚した時、印鑑といっしょに、彼女に取り上げられた通帳の、今はまったく自由にならない預金口座からの引き落としだから、実は痛くもかゆくもないんだケド・・・ *(^_^)*♪ )


こんなに・・・。
どうしようもない、くらい、こんなに・・・。

こんなに Delphiが好きなのに、
たかが△マークすら表示できない・・・
なんて・・・


許せないよ・・・
絶対に許せない・・・


アマチュアとか、プロとか、関係なく、
△マークの表示が、東京都の御用達プログラムに出来て、
僕に出来ない理由なんて、
それをあきらめる場合以外には、探したくないし、
あきらめなければ、僕にもきっと出来るはずです。

アマチュアとか、プロであるとか、は関係ない。
△マークの有無が問題なのだ。

それが「ない」プログラムは、
決して、良い採点プログラムとは言えない!

なんでこんな大事なコトに今まで気づかなかったんだ・・・
(アンタにとって、それはいつものことでしょ)

よぉぉぉぉぉぉぉぉぉぉっし、
俺はやるぞ!!!

そう思ったら、思い出せました。

よくなりたい、自分を。

(長すぎる前置きですが、どうしても、話したかったことはここから・・・)

【もくじ】

1.△の使用をあきらめた理由
2.マイナスの点数は通常ありえないコトに気づく
3.採点アルゴリズムを改良
4.合計点の計算と印刷
5.まとめ
6.お願いとお断り

1.△の使用をあきらめた理由

手書き答案をスキャナーで読み取り、各設問毎に画像を切り出して合成、素早く・効率よく採点、で、採点記号&得点付き画像を元の答案画像へ書き戻し、合計点を付加(任意の位置に表示)して返却用答案を印刷するプログラムを作成した。

下の画像はその実行時のイメージ。Gridコントロールへ入力した数値に応じて答案画像の上に採点記号(〇 もしくは × )と、〇の場合は得点を表示している。× の場合に得点の表示がないのは、0と〇がよく似ていて、×0という表記は間違いなく混乱を招くと考えたため。いちおう、オプション設定で表示の有無を選択できるようにはしてあるが、デフォルト設定で「得点0は表示しない」のチェックはON。

×0は混乱のもと!(表示する選択肢は提供)
No,2とNo,6には 本当は△を表示 したい・・・

当初は次のような理由から採点記号△の使用を断念してしまった・・・。

ほんとうは 〇・× の他に採点記号として△も使いたかったのだ。が、採点の基本としたアルゴリズムではGridコントロールに「正の数が入力」された場合は正解で採点記号は「 〇 」、「0(ゼロ)が入力」されていたら不正解で採点記号は「 × 」、「空欄」の場合は何もしないと決めていたので、△の入り込む隙が見出せなかった・・・というのが一つ。

また、これは直接△とは関係ないけれど、人間である以上、採点ミスはつきもの!で、答案画像に採点記号と部分点を埋め込むのは最後の最後。返却用答案画像を作成する直前でなければならない。それまでは、Gridコントロールへの得点入力に応じて、採点記号を付加した答案画像をいつでも修正可能な状態にしておく必要がある。

もし、強引に「部分点あり」の採点記号を「△」にするなら、Gridコントロールへの入力値から、この「△」を見分ける手段を考えなければならない。この手段を思いつかなかったというのが一つ(当初から考えなかったわけではないが・・・、スマートな方法をどうしても見いだせなかった)。

さらに得点入力のしやすさを考えると、テンキーがあってもなくても、0(ゼロ)はキーボードの右側にあって、どうにも押しにくい(マウスを操作する右手は、マウスから離したくない & 左手で何回も0を押すのは、かなりめんどくさい)から、数値以外の入力はすべて0(ゼロ)と見なすプログラムを書けば、A・S・Dあたりのキーを押すことで理想的(?)に0(ゼロ)を入力できる。また、答案画像のクリック位置とGridコントロールのフォーカス位置が連動するようにプログラミングすれば、ぱっと見、全体的に出来の良い設問への得点入力は、プログラムから一括で行い、あとは間違いの解答だけ、その画像をクリックしてAキーあたりを押して0(ゼロ)を入力すれば、いちばん効率よく採点できる・・・はず。逆に、ぱっと見、全体的に出来が悪そうなら、一括して0(ゼロ)を入力し、正解の解答だけ選択して得点を入力すればいい。多くの場合、正解の得点は5点未満だろうから、これらのキーはキーボードの左にあり、左手で押しやすい。

百歩ゆずって、あるキー(例:「さんかく」だから「s」キーとか)を押した場合だけ、採点記号を△とするのは容易だが、後々やっかいな問題が生じる。

プログラムは最終的に、Gridコントロール上のデータから、合計点を計算して返却用答案画像のどこかに印刷する仕様。で、その際、データに余計な文字があれば除外して計算することも出来なくはないが、予期せぬ間違いの元になるような要素は、なるべくなら最初から排除しておきたい。かといって、△マークであることを示すなんらかのフラグをデータとして持っていなければ、データを再読み込みした際に、画像上に△記号を表示することはできない・・・。しかし、そのために、Gridコントロール上に「採点記号・部分点」を意味する「s1」みたいな表示をするのは、できるだけ避けたい。Gridコントロール上に数値以外の文字が「ない」のが、最初からの理想なのだ。

実は、見えないGridコントロールをもう一つ、別に準備してここに「〇・△・×」の情報を記録しようか・・・とも考え、実際にやってみたんだけど、これだとアルゴリズム他をかなり修正しないといけないことに気づく。なので、この案は却下。

で、八方塞がり状態に・・・

2.マイナスの点数は通常ありえないコトに気づく

(やっぱり、ダメかぁー)

そう思いながら、それでもあきらめきれずに、なんとなくStringGrid1DrawCell手続きのコードを眺めていて、次のコードを残したままだったことに気づく。

  //正負をチェック
  if StrToInt(StringGrid1.Cells[ACol,ARow])< 0 then
  begin
    StringGrid1.Canvas.Font.Color := clRed;
  end else begin
    StringGrid1.Canvas.Font.Color := clBlack;
  end;

これは、いつか他のプログラムで使用したコードを、このプログラムにコピーした際、そのままになっていたものだ。別に問題を起こすようにも見えなかったし、通常の採点でマイナス点の入力はアリエナイから、誤って? 負の数が入力されたら赤く表示した方が入力ミス?が防げてかえってイイか・・・くらいの気持ちで、消さずに残しておいたのだ。

何度も実行して検証したプログラムコードだけれど、このプログラムでは「負の数の入力」は最初から予定に「ない」ので、負の数は一度も入力したことがなかった・・・し、このコードを消さずに残しておいたこと自体を、その存在に気づくまで、僕は忘れていた。

(こんなコードも入ってたんだ・・・)

その瞬間、何かが、ひらめいた気がした・・・

(そうか! 負の数をフラグに使う手があった☆)

本質的に文字だと計算上、いろいろ問題が起きるけど、負の数なら絶対値をとってしまえば合計点の計算は何の問題もなくできるし、さらに良いことに、これまで何よりも問題だった△を意味するフラグとして、-の記号を利用できる!!

赤で表示する設定になってることも、ユーザーにとって親切だし・・・。僕的に言えば、「△なら部分点に-(マイナス)記号をつけて入力」なんだけど、これを一般的に言えば「部分点を与える場合は、負の数として入力してください」ってことで、これならユーザーに確実に伝わるし、かつ覚えやすい。

さらに採点アルゴリズムも全体の大幅な見直しは不要で、Gridコントロールのデータが負の数であった場合の処理だけを追加すればよさそうだ。

なんで、こんなイイことに最初から気づかなかったのか、それは僕が足りないせいだけど、そんなことはどーでもイイ。なんだがうれしくなってキタ。

やったぁ♪ これで「東京都御用達の採点プログラム」に負けないのが作れる☆☆☆
(ハナから相手にされてないのは十分わかってます・・・)

ただ、純粋によくなろうとした自分が久しぶりに愛しい。

3.採点アルゴリズムを改良

で、採点アルゴリズムを次のように改良。なお、採点マークと設問毎の得点の表示設定は、図の「表示」オプションから採点者が選択する仕様。なお、プログラムは、Gridコントロールが空欄である場合、すなわち、入力値がない場合は、答案画像に対する処理は何も行わない。

※ Captionが「種類」となっているRadioGroupがコード内のRadioGroup4。
※ →X,↓Yが表示位置調整用の各ComboBox。矢印は意味を視覚的に伝える工夫。
※ Sizeで採点記号及び得点のFontの大きさを指定。

表示のデフォルト設定は「採点記号も得点も両方表示する」
//Gridコントロールへの入力値がない場合は「何もしない」
procedure TFormCollaboration.StringGrid1DrawCell(Sender: TObject; ACol,
  ARow: Integer; Rect: TRect; State: TGridDrawState);
var
  ・・・ 必要な変数を宣言 ・・・
  //例
  intValue : integer;
begin

  // 以下、実際のプログラムコードから必要な部分のみ抜粋

  if StringGrid1.Cells[ACol,ARow]<>'' then
  begin
    // 誤入力'00'があれば'0'に変換
    if StringGrid1.Cells[ACol,ARow]='00' then
    begin
      StringGrid1.Cells[ACol,ARow]:='0';
    end;

    // 入力文字数が3文字以上なら'0'に変換
    if Length(WideString(StringGrid1.Cells[ACol,ARow])) > 2 then
    begin
      StringGrid1.Cells[ACol,ARow]:='0';
    end;

    // 入力値が「数値」に変換できなかった場合はすべて'0'に変換
    if not TryStrToInt(StringGrid1.Cells[ACol,ARow], intValue) then
    begin
      StringGrid1.Cells[ACol,ARow]:='0';
    end;

    //背景色を白に設定
    StringGrid1.Canvas.Brush.Color:=clWhite;

    //正負をチェック
    if StrToInt(StringGrid1.Cells[ACol,ARow])< 0 then
    begin
      StringGrid1.Canvas.Font.Color:=clRed;
    end else begin
      StringGrid1.Canvas.Font.Color:=clBlack;
    end;

    //セルを塗りつぶす
    StringGrid1.Canvas.FillRect(Rect);

    //テキストを表示(中央寄せ)
    DrawText(StringGrid1.Canvas.Handle,
      PChar(StringGrid1.Cells[ACol,ARow]),
      //[+1]は数値描画位置の調整のため
      Length(StringGrid1.Cells[ACol,ARow])+1,Rect,
      DT_CENTER or DT_VCENTER or DT_SINGLELINE);

  end;

  //Cellの値が0ではなかった場合の処理
  if not (StringGrid1.Cells[ACol,ARow]='0') then
  begin
    //Cellの値が正だった場合(完全正答〇の処理)
    if StrToInt(StringGrid1.Cells[ACol,ARow]) > 0 then
    begin

      //imgAnswerは答案画像を表示するTImage

      //Windows APIのSetBkMode関数でTRANSPARENTを指定
      SetBkMode(imgAnswer.Canvas.Handle, TRANSPARENT);
      imgAnswer.Canvas.Font.Color := clRed;
      imgAnswer.Canvas.Font.Size  := StrToInt(FontSize指定用ComboBox.Text);

      case RadioGroup4.ItemIndex of
        0:begin
          //cmbX, cmbYは表示位置調節用の値を入力するComboBox
          imgAnswer.Canvas.TextOut(DestRect.Left+StrToInt(cmbX.Text),
            DestRect.Top+StrToInt(cmbY.Text), '○');
        end;
        1:begin
          imgAnswer.Canvas.TextOut(DestRect.Left+StrToInt(cmbX.Text),
            DestRect.Top+StrToInt(cmbY.Text), StringGrid1.Cells[ACol,ARow]);
        end;
        2:begin
          imgAnswer.Canvas.TextOut(DestRect.Left+StrToInt(cmbX.Text),
            DestRect.Top+StrToInt(cmbY.Text), '○'+StringGrid1.Cells[ACol,ARow]);
        end;
      end;

    end else begin

      //Cellの値が負だった場合(△)-> この部分を新規に追加
      if StrToInt(StringGrid1.Cells[ACol,ARow]) < 0 then
      begin
        //Windows APIのSetBkMode関数でTRANSPARENTを指定
        SetBkMode(imgAnswer.Canvas.Handle, TRANSPARENT);        
        imgAnswer.Canvas.Font.Color := clRed;
        imgAnswer.Canvas.Font.Size  := StrToInt(FontSize指定用ComboBox.Text);

        case RadioGroup4.ItemIndex of
          0:begin
            imgAnswer.Canvas.TextOut(DestRect.Left+StrToInt(cmbX.Text),
              DestRect.Top+StrToInt(cmbY.Text), '△');
          end;
          1:begin
            imgAnswer.Canvas.TextOut(DestRect.Left+StrToInt(cmbX.Text),
              DestRect.Top+StrToInt(cmbY.Text),
              IntToStr(Abs(StrToInt(StringGrid1.Cells[ACol,ARow]))));
          end;
          2:begin
            imgAnswer.Canvas.TextOut(DestRect.Left+StrToInt(cmbX.Text),
              DestRect.Top+StrToInt(cmbY.Text), '△'+
              IntToStr(Abs(StrToInt(StringGrid1.Cells[ACol,ARow]))));
          end;
        end;
      end;
    end;

  end else begin

    //不正解の場合の処理(×)
    //Windows APIのSetBkMode関数でTRANSPARENTを指定
    SetBkMode(imgAnswer.Canvas.Handle, TRANSPARENT);    
    imgAnswer.Canvas.Font.Color := clRed;
    imgAnswer.Canvas.Font.Size  := StrToInt(FontSize指定用ComboBox.Text);

    case RadioGroup4.ItemIndex of
      0:begin
        imgAnswer.Canvas.TextOut(DestRect.Left+StrToInt(cmbX.Text),
          DestRect.Top+StrToInt(cmbY.Text), '×');
      end;
      1:begin
        imgAnswer.Canvas.TextOut(DestRect.Left+StrToInt(cmbX.Text),
          DestRect.Top+StrToInt(cmbY.Text), StringGrid1.Cells[ACol,ARow]);
      end;
      2:begin
        //chkZeroはCaption「得点0は表示しない」のCheckBox
        if not chkZero.Checked then
        begin
          imgAnswer.Canvas.TextOut(DestRect.Left+StrToInt(cmbX.Text),
            DestRect.Top+StrToInt(cmbY.Text), '×'+StringGrid1.Cells[ACol,ARow]);
        end else begin
          imgAnswer.Canvas.TextOut(DestRect.Left+StrToInt(cmbX.Text),
          DestRect.Top+StrToInt(cmbY.Text), '×');
        end;
      end;
    end;
  end;
end;

で、実行結果は・・・(解答はテキトーなので、それ自体に意味はありません。ここでは+5xを正解で得点4点とし、-5xを△で部分点2点としている)。

採点欄への入力が正の数なら〇、ゼロなら×、負の数なら採点記号は△を表示

今、こうしてコードを眺めて見れば、別に変わったコトなんてなぁーんにもしてない、ほんとに単純なif文のネストにすぎないんだ・・・けど。

ここに、たどり着くまでは、ほんとうに長かったなぁ

あらためて(あたりまえのことですが)、処理の基礎となる考え方・・・アルゴリズムの重要性がわかった気がしました。

こんな、なんでもないような工夫で、自分の中ではかなり大きかった(△マークが使えない)という問題を解決できるんだ。ただ、そこにたどり着くためには、残念ながら、僕にはすごく「時間」がかかるんだ。でも、あきらめずに(時間はかかるけど)出来るまで頑張れば、プロが書いたプログラムと同じことが、僕にもできるんだ・・・って。

多くの人にとって、おそらく、まったく参考にならない、こんなことを、お金までかけて公開するのは、つまり、もしかしたら、どこかにいる、かもしれない、僕と同じような気持ちでいる誰かに、(あきらめないで)って伝えたかったから・・・かも、しれない。

あなたの夢を、あきらめないで・・・って。

4.合計点の計算と印刷

続いて、合計点の計算・その印刷位置の指定から返却用答案画像の印刷へと繋げる部分。まず、絶対値に換算して合計を計算するようにコードを修正。ただ、Abs( )を追加しただけで、あんなに悩んだ△マークの処理が実現できるなんて、なんだか、夢のよう。

var
  i,j,k : integer;
begin
  //合計点を入れる変数kを初期化
  k := 0;
  //合計点を計算
  for i := 1 to StringGrid1.RowCount-1 do
  begin
    for j := 1 to StrToInt(解答欄数.Text) do
    begin
      if StringGrid1.Cells[j,i] <> '' then
      begin
        //△に非対応
        //k := K + StrToInt(StringGrid1.Cells[j,i]);
        //△は負の数で入力しているから絶対値で計算
        k := K + Abs(StrToInt(StringGrid1.Cells[j,i]));
      end;
    end;
    //合計点を保存(StringGrid.Cells[列, 行])
    StringGrid1.Cells[StrToInt(解答欄数.Text)+1, i] := IntToStr(k);
    //合計点を初期化
    k := 0;
  end;
end;

返却用答案画像の印刷にあたっては、合計点表示の有無を選択しないと印刷ボタンをクリックできない仕様として・・・。ユーザーが合計点「有り」を選択した場合は、合計点を上記コードで計算後、返却用答案画像をTImageに表示し、このTImageへのMouseDownイベントを利用して、ユーザーに合計点印刷位置を指示してもらい、合計点入りのサンプル画像を提示(合計点の印刷位置の修正は、ユーザーが納得できるまで何回でも可能)。最終的に位置が決まったら印刷ボタンへフォーカスを移して、クリックで印刷という流れ。

合計点の印刷の有無を指定し、「有り」の場合は必要な処理を行わないと、印刷ボタンはクリックできない。
ユーザーのクリックした位置を左上座標(0,0)として合計点を挿入&返却用答案のサンプルを提示。
(サンプル画像にある矩形は、実際には印刷されない)
「いいえ」をクリックすれば何回でも位置指定のやり直しが可能。

以下のコードが合計点の印刷位置決め部分。「いいえ」を選択した場合は、合計点サンプルを表示したのと同じ場所に、同じ内容を「赤」ではなく、「白」で再描画して消去したように見せかけている(よく見ると若干、先に赤で表示した合計点の輪郭が残っているのがわかる。その原因は不明。今後、原因を調べたい)。

「いいえ」を選択した場合の画像(白で上書きした合計点の輪郭が残ってしまう)
//合計点VCLはLabel
//フォントサイズ指定VCL、解答欄数VCLはComboBox
//変数bSumは、合計点印刷の有無を確認するBoolean型変数
procedure TFormCollaboration.Image1MouseDown(Sender: TObject;
  Button: TMouseButton; Shift: TShiftState; X, Y: Integer);
var
  MyPath : string;
  TempBmp : TBitmap;
  //MessageDlgの押されたボタンを知る
  Ans : Word;
  //BalloonHintの表示
  LTitle : string;
  LText  : string;
  LhIcon : HICON;
  LPos   : TPoint;
  LArrow : TBalloonArrow;

  //普通の四捨五入を行う関数を設定
  function Roundoff(X: Extended): Longint;
  begin
    if x >= 0 then Result := Trunc(x + 0.5)
              else Result := Trunc(x - 0.5);
  end;

  procedure GetXY(iX,iY:Integer);
  begin
    //合計点印刷位置の座標を取得
    iX:=Roundoff(iX/(TrackBar1.Position/100));
    iY:=Roundoff(iY/(TrackBar1.Position/100));

    //Imageに画像をセットする際、自動でサイズ調整を行っている
    合計点の位置X:=iX;
    合計点の位置Y:=iY;

    //矩形を描画
    with Image1 do
    begin
      //Canvas.Brush.Style:=bsClear;  //Pythonを使っていない時はこれでOK!
      //Pythonを使っている時は明示的に書く必要がある
      //(Python.pasにもbsClearが定義されている)
      Canvas.Brush.Style:=Vcl.Graphics.bsClear;
      Canvas.Pen.Color:=clRed;
      Canvas.Pen.Width:=3;
      //矩形を描画
      合計点VCL.Font.Size:=StrToInt(フォントサイズ指定VCL.Text);
      Canvas.Rectangle(合計点の位置X, 合計点の位置Y, 
        合計点の位置X+合計点VCL.Width, 合計点の位置Y+合計点VCL.Height);
      Canvas.Font.Color:=clRed;
      Canvas.Font.Size:=StrToInt(フォントサイズ指定VCL.Text);
      //LabelにStringGridから合計点を取得しておく
      合計点VCL.Caption:=StringGrid1.Cells[StrToInt(解答欄数VCL.Text)+1, 1];
      Canvas.TextOut(合計点の位置X, 合計点の位置Y, 合計点VCL.Caption);
    end;
  end;

begin

  if bSum then
  begin

    //合計点の印刷位置の座標を指定&取得
    //Imageをクリックするたびに、GetXY(X,Y)が呼び出される(実行される)
    GetXY(X,Y);

    Ans:= MessageDlg('印刷位置は、この位置でよろしいですか?'+#13#10+#13#10+
      '(左寄せで印刷。矩形は印刷されません。)',
      mtInformation, [mbYes, mbNo, mbCancel], 0);

    if Ans = mrYes then
    begin

      //[はい]が選ばれた時

      //案内
      MessageDlg('印刷ボタンをクリックしてください。', mtInformation,[mbOK],0);

      //バルーンヒントのタイトルとヒントの内容
      LTitle := '印刷ボタン';
      LText  := 'ココです!' + sLineBreak + 'クリックしてください';

      //バルーンヒントの表示のとスタイル
      LArrow:= baTopLeft;       //VCLの上・左へ向けて表示
      //LArrow:= baTopCenter;     //VCLの上・中央
      //LArrow:= baTopRight;        //VCLの上・右へ向けて表示
      //LArrow := baBottomRight;  //VCLの下・右へ向けて表示
      //LArrow := baBottomCenter; //VCLの下・中央
      //LArrow := baBottomLeft;   //VCLの下・左へ向けて表示

      //バルーンヒントの吹き出しの始点
      LPos:=印刷ボタン.ClientToScreen(Point(Trunc(印刷ボタン.Width div 2), 0));

      //システムのInfoアイコンを使用
      LhIcon := LoadIcon(0, IDI_INFORMATION);

      try
        //引数はタイトル、ヒント、アイコン、表示位置、吹き出しの始点、時間はミリ秒
        BalloonHint(LTitle, LText, LhIcon, LArrow, LPos, 5000);
      finally
        DestroyIcon(LhIcon);
      end;

      //カーソルを元に戻す
      Screen.Cursor:=crDefault;
      Image1.Visible:=False;
      Image1.Picture.Assign(nil);
      //SetFocus
      印刷ボタン.Enabled:=True;
      印刷ボタン.SetFocus;
    end;

    if Ans = mrNo then
    begin
      //[いいえ]が選ばれた時
      with Image1 do
      begin
        //Canvas.Brush.Style:=bsClear;  //Pythonを使っていない時はこれでOK!
        //Pythonを使っている時は明示的に書く必要がある
        //(Python.pasにもbsClearが定義されている)
        //クリック位置に同じ内容を「白で上書き」してサンプルを消去
        Canvas.Brush.Style:=Vcl.Graphics.bsClear;  
        Canvas.Pen.Color:=clWhite;
        Canvas.Pen.Width:=3;
        合計点VCL.Font.Size:=StrToInt(フォントサイズ指定VCL.Text);
        Canvas.Rectangle(合計点の位置X, 合計点の位置Y, 
          合計点の位置X+合計点VCL.Width, 合計点の位置Y+合計点VCL.Height);
        Canvas.Font.Color:=clWhite;
        Canvas.Font.Size:=StrToInt(フォントサイズ指定VCL.Text);
        Canvas.TextOut(合計点の位置X, 合計点の位置Y, 合計点VCL.Caption);
      end;
    end;

    if Ans = mrCancel then
    begin
      //キャンセルが選ばれた時
      //カーソルを元に戻す
      Screen.Cursor:=crDefault;

      //その他の処理

    end;
  end;
end;

5.まとめ

数値のみを用いて、〇・△・× を表現する。解決までに2ヶ月近くを要した課題だった。最終的には、「Gridコントロールへの入力が、正の数なら〇、負の数なら△(マイナス記号はフラグとして利用)、0(ゼロ)なら × 、空欄なら何もしない。」として解決。

この単純なアルゴリズムにたどり着くまで、僕はあきらめかけたり、再びチャレンジしたり、様々に思い悩んだ。夢見た通り、プログラムはよくなったが、果たして僕自身は成長したのだろうか・・・

僕は天才でも、なんでもない。
特別なことなんて何一つできない。
他の誰かより優れたモノなんて
何ひとつ、持たない・・・。

何をやらせてもトロいし、
物事の理解にかける時間は、ヒトの何倍も必要だけど、
でも、時間さえかければ、
僕にも、かたちにできるものは、ある・・・

いつか、TVで見たんだ。

若き日の山中 伸弥先生が、利根川 進先生に質問してた。
「日本では研究の継続性が大切だと言われますが、先生はどうお考えですか?」 と。

利根川先生は即答してた。
「重要で、面白い研究であれば何でもいいじゃないか」と。

人々に、社会に、貢献「したか・しなかったか」が、すべてなんだと。

RFKも、同じ言葉を残してる。

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

さらに・・・

You’re happiest while you’re making the greatest contribution.
「最高の貢献を成そうとする時、あなたは最高の幸福を知る。」

とも(命の使い方を、彼自身の人生が代弁している気がするけど・・・)。

ようやくカタチにできた、僕の夢を、
職場のみんなに自由に使ってもらえるプログラムとして公開する。
たったひとり、でもいい。
このプログラムでしあわせを手にする人が、どうか、いてほしい。

それをもし、貢献と呼んでもらえるなら、
どんなにか、うれしいだろう・・・

そして、僕がこの世界から消えた後まで、
これまでにかたちにしたいくつかの夢を・・・もし、残せたら
どんなにか、しあわせだろう・・・

Delphiといっしょに、
Object Pascalで組んだ、
夢のかたち。

そう、夢のかたち・・・。

この胸にずっと、思い描いてきた
僕の夢のかたちを。

6.お願いとお断り

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

Organize items displayed in recently opened files

「最近開いたファイルに表示される項目を整理する」

不要になったプロジェクトをバックアップ後、フォルダごと削除したり、プロジェクトファイルを入れたフォルダの名前そのものを変更してしまったりすると、Delphi起動直後に表示される「ウェルカムページ」の「最近開いたファイル」の項目も整理したくなる。その方法を調べた。これはその覚え書き。

1.「最近開いたファイル」の項目の整理方法
2.「ウェルカムページ」そのものを表示しない
3.まとめ
4.お願いとお断り

1.「最近開いたファイル」の項目の整理方法

最新のバージョン11.2の場合、次のように操作する。IDEの[ファイル]->[最近開いたファイル]->[プロパティ](旧バージョンの場合は、[ファイル] -> [開き直す] -> [プロパティ]の順のようだ)。

[ファイル] -> [最近開いたファイル] -> [プロパティ]の順にクリック

[開き直す]メニューのプロパティが表示される。

「存在しないファイルの削除」をクリックすれば、(Pathの有無を確認しているのでしょう)全自動で項目を整理してくれる。これはすごい便利!!

任意の項目を選んで「削除」したり、「クリア」ボタンで履歴を全部消すこともできる。ちなみに「クリア」ボタンをクリックした場合は・・・

確認メッセージが表示される

「はい」をクリックすると・・・

全部消えた!

2.「ウェルカムページ」そのものを表示しない

ウェルカムページの必要性を感じない場合は、IDE起動時に「表示しない」ように設定することもできるようだ。

Delphiへのショートカットを右クリックしてプロパティを表示し、「ショートカット」タブのリンク先(T):「”C:\Program Files (x86)\Embarcadero\Studio\22.0\bin\bds.exe” -pDelphi」の「-pDelphi」の後ろに「(半角スペース)-np」を追加して、「”C:\Program Files (x86)\Embarcadero\Studio\22.0\bin\bds.exe” -pDelphi -np」にする。

Delphi 11.2 Alexandriaの場合

「OK」もしくは「適用」をクリックすると、確認のメッセージが表示される。

あなたの責任だよ!ってコト?

「続行」をクリック。で、次回の起動時からは・・・

すっきりー!

ウェルカムページを表示する設定に戻すには、Delphiへのショートカットを右クリックしてプロパティを表示し、先ほど追加した「(半角スペース)-np」を削除して「OK」をクリック。表示されるメッセージの「続行」をクリックすれば、次回のIDE起動時からウェルカムページが再び表示されるようになる。

3.まとめ

(1)ウェルカムページに表示される項目の整理方法は、次の通り。

 ・[ファイル] -> [開き直す] -> [プロパティ]から項目の整理ができる。

(2)ウェルカムページそのものを表示しない設定も可能。

 ① Delphiへのショートカットを右クリックしてプロパティを表示。
 ② ショートカットタブのリンク先(T)末尾に「 -np」を追加。

4.お願いとお断り

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