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

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

浮動小数点

学生の頃から、2進数が苦手だった・・・。
浮動小数点も、本当のところはよくわからなかった・・・。

それがわからなくても、日常生活で苦労することは皆無だったし、
PCに触れている時でも、これと言って計算に困るような出来事もなかった。

そう、これまでは・・・

ただ、職場が変わって、状況が変化。
苦手だった2進数や、浮動小数点を、ほっとけなくなっちゃった・・・。

もくじ

1.10進数を2進数に変換
2.浮動小数点
3.0.1(10)を2進数に変換
4.まとめ
5.お願いとお断り

1.10進数を2進数に変換

これが苦手で、小テストとかあって、本当に困ってるひとが、もし、いたら、
僕なりに苦しい中から見出した絶対忘れない変換方法をお伝え出来たらと思って、
この記事を書いています。

まず、僕は、よく見るコレが『なぜか苦手』でした(今でも)。
例えば7(10)を2進数に変換する場合、

10進数を2進数に変換(その1)

答えは、図の矢印の順に読んで、0111(2) 。だけど・・・
この書き方。すごく、思い出しにくい。
特に焦ったりすると、何か、どこかで混乱して、僕は必ず間違えてしまう。

自分なりに考えて出した「結論」は・・・この書き方、普段ほとんど使わないから・・・かな? みたいな

そこで、小学校以来慣れ親しんだ書き方がよかろうと思い、
自分では、より『素直』に思えるカタチに書き方を変更。
結果、これなら絶対間違えないと思えました。
自分的には、この書き方の方が、なぜかとても安心感があります。なんでかな?

10進数を2進数に変換(その2)

自分に対して、自信が持てない本当の理由は、わかってます。
答えを出す方法だけが知りたくて、『なんで2で割り算するのか?』考えたことがなかったからです。
つまり、『ほんとうのこと』から、僕は目を反らし続けてきた・・・から。

理由はいくらでもあげられます。

めんどくさかったから
考えたくなかったから
試験に通ればそれでよかったし
そんなこと、どうでもよかったカラ

でも、とうとう、ここで、それは通用しなくなりました。
『2で割り算する』、その理由を僕は説明しなければならない。

誰もがわかるように・・・。

とりあえず、わかりやすい10進数で考えてみます。
10進数の基数は「10」、では基数で割り算するってどういうことなのか?

基数で割り算した余りが、元の10進数の一の位、十の位、百の位、千の位と一致します。
つまり、各「位(くらい)」の数を求めていたわけですね!

2進数の場合、基数は「2」ですから、基数で割り算すれば、余りは「0」か、「1」のいずれかになります。これが、その桁の2が「あるか・ないか」を教えてくれるわけです。

例えば、10進数の「123」を2進数に変換する場合、


01111011(2)の0と1はそれぞれ、その桁の2が「あるか・ないか」だから

電卓の種類を「プログラマー」に変更して、ラクして確認。

こんなイイモノがあったんだ!

手計算でも確認(これが16進数で桁数が多かったら、挫けそうだなー)。

2進数でも、余りは、そのまま、各位(くらい)の数になりました。
よかった。よかった。

2.浮動小数点

数値のどこまでが信頼できる桁であるのかを表すのに、有効数字を用いますが、例えば


有効数字の 1.234 の部分を「仮数(かすう)」というそうです。

2進法では、次のような場合を考えると、


2倍すると1桁、位が上がりますから、2の2乗倍=4倍すれば2桁、位(くらい)が上がることになります。これを小数点の位置で言えば、2をかけるたびに、小数点の位置が右へ移動するわけです。

(だから「浮動小数点」って言うのかぁ・・・)

ここでの仮数は「1.0111」ですが、実は仮数には重要な決まりがあって、「整数部分を1桁とし、そこに0以外のいちばん上の位の数を置く」のだそうです(⇨ IEEE754 という方式に準拠した場合?)。

・・・と、いうことは、2進数なら「0」と「1」しかないから、仮数の整数部分は必ず「1」になることになります。

64ビットの浮動小数点数(2進数の場合)では、符号ビットは1ビット、指数部は11ビット、仮数部は52ビットで表わされることが多いそうで、符号は+とーのどちらかだから1ビットでOKとして、仮数部ではその整数部分「1」を省略してしまうとのこと。なんで?

仮数部で整数部分の「1」を省略する理由がわからなかったので調べてみました。結果を知って納得。

整数部分の1を省略すれば、仮数部を1ビット増やすことができるので、浮動小数点形式の精度を向上させることができる。

(すごーい!)

仮数の整数部分を必ず「1」とすることで、そんなことも可能になるわけですね!

3.0.1(10)を2進数に変換

すーぱー 苦しんだのが、コレです。

10進数の小数を2進数に変換するなんて、もう長いことやってない。
やったことがあるとしても、15年以上前です。その記憶の欠片すら、残ってません。

表し方を調べてみると・・・、整数でやったコレを、そのまま2のマイナス1乗みたく、右側へ拡張して小数点以下を表現するとのこと。


そー言えば、はるか、むかし。
なんで2のゼロ乗が1になるのか、さっぱりわからず、悩んだことがあったよーな・・・。
わかってみれば、カンタンだったけど。


この関係を含めて10進数の小数を位(くらい)の数として表せば、


2進数でも、考え方は同じ。
違うのは、それがいくつあるか? ではなく、単に「ある」か・「ない」か が、「ある」=「1」、「ない」=0で示されること。あとあと、これがかなり重要な理解のポイントになります!


特に、0.X とした場合の小数点以下の部分 は(1の位が0であることを明示して表せば)、


この関係がそのまま使えれば、話はカンタン。
例えば10進数の 0.625 を2進数に変換したい場合、0.625 が 0.5 と 0.125の和であることに気付けば、


んじゃ、10進数の 0.1 を2進数に変換するには?
理解のために、全桁「1」で数値が「ある」ものとし、位(くらい)の数を書き出して考えます・・・


2進数で計算すると、このような誤差が必ず生まれてしまう!

だから、0.1 に少しずつ近づくように(0.1 を超えないように)、足し算可能な、よりちいさな数を求め、どこまでもこれを繰り返して(=循環する理由)行くわけですか・・・

(それはわかったけど、0.1(10) を2進数に変換するわかりやすい方法は・・・)

Google先生に、いろいろたずねても、コレだぁ!・・・みたいな答えは教えてくれない!!

それでも、いろんなWebサイトさんの断片的な情報を集めて、ようやく変換方法だけはわかりました☆

それが、こちら(10進数の 0.1 を2進数に変換)

基数で割り算でなく、基数を「掛け算」!


なんで、これで変換できるのか?
次の式を書き出して、ひたすら考えます・・・(この場合、左辺のxは もちろん 0.1)。


10進数の 0.1 を2進数に変換するとき、なんで割り算でなく、基数を掛け算してるのか? まず、それがさっぱりわからないけれど、ここでまず思い出したことは、これは2進数表現だから、an は必ず0か1のいずれかになるということ。

つまり、an が「1」ならその部分は数値が「あり」で、an が「0」なら「ない」わけです。

一歩、前進。

問題は基数を「掛け算」する理由。

基数を掛け算する・・・、つまり、2倍するってことは、意味的にはナニをしているのかというと、2倍して1桁、位を上げていることになる。おそらく、この部分に重大な意味があるような気が・・・。

てか、ナンで桁を上げる必要があるのか?

それがアタマにこびりついて、離れません。

(あぁ ナニを見ても0と1に見える・・・)

誰か、教えてくれないかなー

ブラウザのタブを切り替えながら、既に何度も眺めたWeb上の情報にもう一度目を通します。

(なにか、見落としてること、あるんじゃないかなー)

あるWebサイトにあった次の文がなぜかキラキラ輝いて見えます。でも、なんで輝いて見えるのか、それがわかりません。

2進数で表された1.0と、10進数で表された1.0は、数の重みが等しい(同じ数!)

なんか、ものすごく大切なことを伝えてくれている気がするのですが、その「ものすごく大切なこと」がナンなのか? それがどうしてもわかりません!

0.1 かぁ・・・ これは10進数の小数で・・・

それを2進数にしたいんだよなぁ・・・


左辺の 0.1 は、10進数の領域にあって・・・、右辺は、2進数の領域にあると考えれば・・・

右辺のan は絶対に「1」か「0」のどちらかになる(2進数だから)。

「1」なら「数値あり」で、「0」なら「ない」。

で・・・

1 は2倍すれば、一の位に現れる・・・

2 は22=4倍すれば、一の位に現れる・・・

3 は23=8倍すれば、一の位に現れる・・・

2進数も、10進数も、一の位だけは・・・1が意味合い的に同じ「ひとつだけあるの1(イチ)」。

・・・って、コトは、

二つの異なる領域をイコールで結ぶために必要な、その条件が一の位・・・

そうか、一の位じゃないと・・・「0」か、「1」かが、きっと 見えない んだ!

だから、どんどん2倍して、
1、a2、a3・・・が、次々に一の位になるように、桁を上げるんだ☆

一の位になって初めて、それが「0」なのか、「1」なのかが 見える んだ!!

あー☆ わかったカモー!!

だから、(上の計算で)基数を掛け算した結果、整数部に「1」が出てきた時は、それが「ある」ことさえわかればイイから、その存在を無視して、小数部だけを基数倍(2倍)してるんだ!

コレ、考えた人、天才だ☆

4.まとめ

今回は内容が多岐にわたり、コレが最も適切かと・・・思われます。

5.お願いとお断り

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

FireMonkeyのMessageDialog

FMX事始め

1.FMXでMessageDialogを表示する
2.TMsgDlgTypeに指定できる値はVCLと同じ
3.表示できるボタン
4.表示できないボタン
5.ダイアログ右上の閉じるボタンの挙動
6.まとめ
7.お願いとお断り

1.FMXでMessageDialogを表示する

いろいろな事情からFMXプラットフォームで、あるプログラムを書くことになった。使い慣れたVCLと違って、FireMonkeyはずっと以前に一度だけWAVファイルの再生プログラムを作った時触れたことがあるだけで、まともに触るのは今回が初めて。

最初にいちばん困ったのはユーザーへのメッセージの出し方。なんでかわからないけれど、普通にShowMessageすると、その直後、FMXプラットフォームでは、結構な頻度でエラーが発生する気が・・・。

だから、最初に書いたデータベース接続のプログラムは、極力ShowMessageを使わない方向で書いたんだけど、2作目のテキスト入力練習プログラムではそうも行かず、良い機会だと思ってShowMessageより見た目が華やかなMessageDialogの正しい使い方を調べてみた。

思った以上に情報が少ない気がしたので、学んだことを備忘録として、まとめておく。

まずは、iマーク付きのMessageDialogの出し方。

implementation

uses
  FMX.Platform, FMX.DialogService;

{$R *.fmx}

procedure TForm1.Button1Click(Sender: TObject);
var
  ASyncService:IFMXDialogServiceASync;
begin
  //mtConfirmationだとBeep音が鳴らないが、mtInformationだとBeep音が鳴る
  if TPlatformServices.Current.SupportsPlatformService (IFMXDialogServiceAsync,
    IInterface(ASyncService)) then
  begin
    TDialogService.MessageDialog('Do you know Delphi?',
      TMsgDlgType.mtInformation, [TMsgDlgBtn.mbOK], TMsgDlgBtn.mbOK, 0,
      procedure(const AResult: TModalResult)
      begin
        if AResult = mrOK then
        begin

        end;
      end);
  end;
end;

実行すると・・・

ここにたどり着くまで、結構長かった・・・
ほんとに、ようやくって感じ。

調べてわかったことは・・・

var
  ASyncService:IFMXDialogServiceASync;

・・・と宣言するためには、

uses
  FMX.Platform;

uses に FMX.Platform が必要で、さらに、サポートの有無を調査するif文の・・・

if TPlatformServices.Current.SupportsPlatformService (IFMXDialogServiceAsync,
    IInterface(ASyncService)) then

TPlatformServices も FMX.Platform を参照している。

で、本命の MessageDialog を表示するには、さらに・・・

uses
  FMX.Platform, FMX.DialogService;

uses に FMX.DialogService も追加しなければならない。

2.TMsgDlgTypeに指定できる値はVCLと同じ

上のユーザーへの情報提供(Info)に加えて、ユーザーに確認する場合は、

TMsgDlgType.mtConfirmation

ユーザーに警告。

TMsgDlgType.mtWarning

ユーザーにエラーを報告。

TMsgDlgType.mtError

mtCustomってのもあったけど・・・

TMsgDlgType.mtCustom
画像は、何も出てこなかった・・・。
キャプションもProject1(アプリケーション名)になってる・・・。

実質、「情報提供・確認・警告・エラー」の4つ型があり、これはVCLと変わらない。

3.表示できるボタン

ユーザーの応答が「OKボタン押し下げのみ」であれば、MessageDialogの最後の引数を別手続きにして、それを呼び出す形にすればいいのかと・・・

  private
    { private 宣言 }
    procedure MsgDlgProc(const AResult: TModalResult);

として、Shift+Ctrl+Cで手続きを作成。

procedure TForm1.MsgDlgProc(const AResult: TModalResult);
begin
  //何もしない手続き

end;

応答が「OK」のみの場合は、これを呼び出し。

procedure TForm1.Button2Click(Sender: TObject);
var
  ASyncService:IFMXDialogServiceASync;
begin
  if TPlatformServices.Current.SupportsPlatformService (IFMXDialogServiceAsync, IInterface(ASyncService)) then begin
    TDialogService.MessageDialog('Do you know Delphi?',
      TMsgDlgType.mtInformation, 
      [TMsgDlgBtn.mbOK], TMsgDlgBtn.mbOK, 0, MsgDlgProc);
  end;
end;

コードが短くなって、なんとなくすっきりした。

でも、「はい」・「いいえ」・「キャンセル」のようにボタンを複数表示するとそうもいかない。

procedure TForm1.Button3Click(Sender: TObject);
var
  ASyncService:IFMXDialogServiceASync;
begin
  if TPlatformServices.Current.SupportsPlatformService (IFMXDialogServiceAsync, IInterface(ASyncService)) then
  begin
    TDialogService.MessageDialog('Do you know Delphi?',
      TMsgDlgType.mtInformation, [TMsgDlgBtn.mbYes, TMsgDlgBtn.mbNo, TMsgDlgBtn.mbCancel], TMsgDlgBtn.mbYes, 0,
      procedure(const AResult: TModalResult)
      begin
        if AResult = mrYes then
        begin
          ShowMessage('Goooooooood!');
        end;
        if AResult = mrNo then
        begin
          ShowMessage('No Good!');
        end;
        if AResult = mrCancel then
        begin
          ShowMessage('Cancel');
        end;
      end);
  end;
end;

case文でもよいようだ。

procedure TForm1.Button3Click(Sender: TObject);
var
  ASyncService:IFMXDialogServiceASync;
begin
  if TPlatformServices.Current.SupportsPlatformService (IFMXDialogServiceAsync, IInterface(ASyncService)) then
  begin
    TDialogService.MessageDialog('Do you know Delphi?',
      TMsgDlgType.mtInformation, [TMsgDlgBtn.mbYes, TMsgDlgBtn.mbNo, TMsgDlgBtn.mbCancel], TMsgDlgBtn.mbYes, 0,
      procedure(const AResult: TModalResult)
      begin
        case AResult of
          mrYes:ShowMessage('Goooooooood!');
          mrNo:ShowMessage('No Good!');
          mrCancel:ShowMessage('Cancel');
        end;
      end);
  end;
end;

caseのリストが表す値は、case文内で一意、部分範囲またはリストの重複がなければ昇順とかリストの並びは関係ないようだ。また、このようによく使用されるボタン値は、セットになった定数として用意されていて、例えば上の場合は次のように指定できる。

TMsgDlgType.mtInformation, mbYesNoCancel, TMsgDlgBtn.mbYes, 0,
こっちの方がカンタン!

embarcaderoさんのWebサイトでは、TMsgDlgBtnは種類がたくさん紹介されていて、

定数意味
mrNone0結果なし。ユーザーがフォームを終了するまでのデフォルト値として使用されます。
mrOkidOK = 1ユーザーは[OK]ボタンでフォームを終了しました。
mrCancelidCancel = 2ユーザーは[キャンセル]ボタンでフォームを終了しました。
mrAbortidAbort = 3ユーザーは[中止]ボタンでフォームを終了しました。
mrRetryidRetry = 4ユーザーは[再試行]ボタンでフォームを終了しました。
mrIgnoreidIgnore = 5ユーザーは[無視]ボタンでフォームを終了しました。
mrYesidYes = 6ユーザーは[はい]ボタンでフォームを終了しました。
mrNoidNo = 7ユーザーは[いいえ]ボタンでフォームを終了しました。
mrCloseidClose = 8ユーザーは[閉じる]ボタンでフォームを終了しました。
mrHelpidHelp = 9ユーザーは[ヘルプ]ボタンでフォームを終了しました。
mrTryAgainidTryAgain = 10ユーザーは[やり直し]ボタンでフォームを終了しました。
mrContinueidContinue = 11ユーザーは[続行]ボタンでフォームを終了しました。
mrAllmrContinue + 1(12 つまり $C)ユーザーは[すべて]ボタンでフォームを終了しました。
mrNoToAllmrAll + 1(13 つまり $D)ユーザーは[すべていいえ]ボタンでフォームを終了しました。
mrYesToAllmrNoToAll + 1(14 つまり $E)ユーザーは[すべてはい]ボタンでフォームを終了しました。
https://docwiki.embarcadero.com/Libraries/Sydney/ja/FMX.StdCtrls.TCustomButton.ModalResultより引用

さらに、セットになった定数が5つあるとのこと。

定数意味
mbYesNoCancelmbYes、mbNo、および mbCancel
mbYesAllNoAllCancelmbYes、mbYesToAll、mbNo、mbNoToAll、および mbCancel
mbOKCancelmbOK および mbCancel
mbAbortRetryIgnorembAbort、mbRetry、および mbIgnore
mbAbortIgnorembAbort、mbIgnore
https://docwiki.embarcadero.com/Libraries/Alexandria/ja/Vcl.Dialogs.TMsgDlgBtnより引用

4.表示できないボタン

僕のPCだけ、そうなのかもしれないけど。中には表示できないボタンが・・・。例えば、

procedure TForm1.Button7Click(Sender: TObject);
var
  ASyncService:IFMXDialogServiceASync;
begin
  if TPlatformServices.Current.SupportsPlatformService (IFMXDialogServiceAsync, IInterface(ASyncService)) then
  begin
    TDialogService.MessageDialog('Do you know Delphi?',
      TMsgDlgType.mtInformation,[TMsgDlgBtn.mbRetry],TMsgDlgBtn.mbRetry,0,
      procedure(const AResult: TModalResult)
      begin
        case AResult of
          mrOK:ShowMessage('OK!:了解');
          mrCancel:ShowMessage('Cancel:取消');
          mrAbort:ShowMessage('Abort:中止');
          mrRetry:ShowMessage('Retry:再試行');
          mrIgnore:ShowMessage('Ignore:無視');
          mrYes:ShowMessage('Yes:はい');
          mrNo:ShowMessage('No:いいえ');
          mrClose:ShowMessage('Close:閉じる');
          mrHelp:ShowMessage('Help:要援助');
          mrAll:ShowMessage('All:すべて');
          mrNoToAll:ShowMessage('NoToAll:すべていいえ');
          mrYesToAll:ShowMessage('YesToAll:すべてはい');
        else
          //ないと思うけど、
          ShowMessage(IntToStr(AResult));
        end;
      end);
  end;
end;

ボタンに mbRetry を指定しても、上の手続きを実行すると表示されたダイアログは・・・

普通のOKボタン!

で、OKを押し下げ。・・・ると

なんでかなー?

でも、次のように指定すると、

TMsgDlgType.mtInformation, [TMsgDlgBtn.mbCancel,TMsgDlgBtn.mbRetry]
指定した順番と並びが逆だけど、「再試行」ボタンが表示された!

で、「再試行(R)」を押し下げ。・・・ると

表示できる場合とできない場合があるらしい。つまり、これはダイアログに表示可能なボタンの設定(組み合わせ)を FMX の MessageDialog は内部的に持っているということ? それから、キャンセルボタンは必ず右側へ設置される?・・・から、ボタンの表示される順番もまた、決まっているという理解でいいのかな?・・・みたいな。

他にも、mbAbortRetryIgnore を指定して、デフォルトで選択状態にするボタンに mbRetry を指定しても・・・

TMsgDlgType.mtInformation, mbAbortRetryIgnore, TMsgDlgBtn.mbRetry, 0,
なぜか「再試行」ボタンが表示されない!

しかも、ダイアログ右上の「閉じる」ボタンが押せなくなってる(勝手にEnabled?がFalseに設定されてしまう)。これは、キャンセルがないから、閉じるボタンはその必要がないという意味に思えてくる・・・。だから、閉じるボタンの無効化も、VCLならその方法が紹介されているんだけど、FMXでの情報は見当たらないのか・・・

実は、この記事を書こうと思ったのは、この閉じるボタンをクリックした時の戻り値が何なのか、どんなに調べても(僕が調べた範囲では)見つけることが出来なかったので、実験してみた結果を記録しておこうと思ったことがきっかけというか、はじまり。

FMX の MessageDialog を設計した人の気持ちがだんだん、わかってきた!

5.ダイアログ右上の閉じるボタンの挙動

実験結果から見えてきたこと。それは MessageDialog の閉じるボタンは、そのクリックの可否がダイアログに表示するボタンの組み合わせによって、内部的に制御されているんじゃないか?ってこと。

まず、OKボタンのみの場合、

procedure TForm1.Button9Click(Sender: TObject);
var
  ASyncService:IFMXDialogServiceASync;
begin
  if TPlatformServices.Current.SupportsPlatformService (IFMXDialogServiceAsync, IInterface(ASyncService)) then
  begin
    TDialogService.MessageDialog('Do you know Delphi?',
      TMsgDlgType.mtInformation, [TMsgDlgBtn.mbOK], TMsgDlgBtn.mbOK, 0,
      procedure(const AResult: TModalResult)
      begin
        case AResult of
          mrOK:ShowMessage('OK!:了解');
          mrCancel:ShowMessage('Cancel:取消');
          mrAbort:ShowMessage('Abort:中止');
          mrRetry:ShowMessage('Retry:再試行');
          mrIgnore:ShowMessage('Ignore:無視');
          mrYes:ShowMessage('Yes:はい');
          mrNo:ShowMessage('No:いいえ');
          mrClose:ShowMessage('Close:閉じる');
          mrHelp:ShowMessage('Help:要援助');
          mrAll:ShowMessage('All:すべて');
          mrNoToAll:ShowMessage('NoToAll:すべていいえ');
          mrYesToAll:ShowMessage('YesToAll:すべてはい');
        else
          ShowMessage(IntToStr(AResult));
        end;
      end);
  end;
end;


ダイアログ右上の「×」をクリックすると表示されたのは・・・

AResult は mrOKだった!

つまり、OKをクリックするしか、選択肢がない(未来をプログラマ自身が選択した)のだから、閉じるボタンが押された時の戻り値もmrOKでよい・・・ということか!

次に、表示するボタンを「OK」と「キャンセル」にして、デフォルト選択ボタンは「キャンセル」に指定して、再度実行。

TMsgDlgType.mtInformation, [TMsgDlgBtn.mbOK, TMsgDlgBtn.mbCancel], TMsgDlgBtn.mbCancel, 0,

右上の「×」をクリックすると・・・

AResult は mrCancel だった!

思った通り、mrCancel が設定されていた! プログラマが「キャンセルという選択肢を未来に与えた」んだから、閉じるボタンが押された時は「キャンセル」と判断してよい・・・ということ?

デフォルト選択ボタンをOKに変えてみた・・・

TMsgDlgType.mtInformation, [TMsgDlgBtn.mbOK, TMsgDlgBtn.mbCancel], TMsgDlgBtn.mbOK, 0,

右上の「×」をクリックすると・・・

AResult はmrCancel!

思った通りだ。

ボタンを「はい」・「いいえ」・「キャンセル」の3つにし、デフォルト選択を「はい」に指定して実験・・・

TMsgDlgType.mtInformation, [TMsgDlgBtn.mbYes, TMsgDlgBtn.mbNo, TMsgDlgBtn.mbCancel], TMsgDlgBtn.mbYes, 0,

右上の「×」をクリックすると・・・

AResult は mrCancel!

やっぱり、mrCancelが戻り値に設定されている!

ならば、ボタンを「はい」・「いいえ」の2つだけにすると、閉じるボタンは使用不可になるはずだ・・・。だって、プログラマの意向として、未来に「キャンセル」という選択肢は与えられていないから!

実際に動かして確認。

TMsgDlgType.mtInformation, [TMsgDlgBtn.mbYes, TMsgDlgBtn.mbNo], TMsgDlgBtn.mbYes, 0,
思った通り「×」はクリックできない!

僕の中に生まれた予測は、ここで「確信」に変わった!

これはVCLのMessageDlgでも同じなんだろうか?
今度、実験だ。

6.まとめ

(1)OKボタンのみ設置したダイアログでは、閉じるボタンクリックでmrOKが返る。
(2)ダイアログにキャンセルボタンを設置した場合は、閉じるボタンもクリック可能。
(3)キャンセルボタンがある場合、閉じるボタンクリックで返る値はmrCancelになる。
(4)キャンセルボタンがない場合、閉じるボタンはクリックできない。
(5)ボタンの組み合わせは内部的に不可もある(不可でもエラーにはならない)。

7.お願いとお断り

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

範囲チェックエラーが出た時は?

{$R-}で範囲チェックさせない!

Delphiで、画像をグレースケール変換するプログラムを作成。実行すると、

表示されたエラーメッセージ

プログラムのコードは、次の通り。
Image1に表示した画像をグレースケールに変換してImage2に表示するというモノ。

unit Unit1;

interface

uses
  Winapi.Windows, Winapi.Messages, System.SysUtils, System.Variants, System.Classes, Vcl.Graphics,
  Vcl.Controls, Vcl.Forms, Vcl.Dialogs, Vcl.StdCtrls, Vcl.ExtCtrls, Jpeg,
  Vcl.ComCtrls;

type
  TForm1 = class(TForm)
    Image1: TImage;
    Image2: TImage;
    Button1: TButton;
    StatusBar1: TStatusBar;
    procedure FormCreate(Sender: TObject);
    procedure Button1Click(Sender: TObject);
  private
    { Private 宣言 }
  public
    { Public 宣言 }
  end;

var
  Form1: TForm1;

implementation

{$R *.dfm}

function CreateGrayScalePalette(Tone:Byte): HPALETTE;
var
  Palette: ^TLogPalette;
  i: Integer;
begin
  GetMem(Palette, SizeOf(TLogPalette) + SizeOf(TPaletteEntry) * Tone );
  Palette^.palNumEntries:=Tone+1;
  Palette^.palVersion:=$0300;
  for i := 0 to Tone - 1 do begin
    Palette^.palPalEntry[i].peRed:= Tone - i;
    Palette^.palPalEntry[i].peGreen:= Tone - i;
    Palette^.palPalEntry[i].peBlue:= Tone - i;
    Palette^.palPalEntry[i].peFlags:= 0;
  end;
  Result:=CreatePalette(Palette^);
  FreeMem(Palette);
end;

procedure TForm1.Button1Click(Sender: TObject);
var
  X, Y: Integer;
  Bmp: TBitmap;
  P: PByte;
begin
  Bmp := TBitmap.Create;
  try
   Bmp.Assign(Image1.Picture.Bitmap);
   Bmp.PixelFormat := pf8bit;
   Bmp.Palette := CreateGrayScalePalette(255);
   Image2.Picture.Bitmap := Bmp;
  finally
   Bmp.Free;
  end;
  Image2.Width:=Image2.Picture.Bitmap.Width;
  Image2.Height:=Image2.Picture.Bitmap.Height;
  Image2.Visible:=True;
end;

procedure TForm1.FormCreate(Sender: TObject);
var
  jpg: TJPEGImage;
begin
  StatusBar1.SimplePanel:=true;
  // TJPEGImageオブジェクトをインスタンス化
  jpg := TJPEGImage.Create;
  try
    // Jpegファイル読み込み
    jpg.LoadFromFile('Image.jpg');
    // Image1に割り当てる
    Image1.Picture.Bitmap.Assign(jpg);
    Image1.Width:=Image1.Picture.Bitmap.Width;
    Image1.Height:=Image1.Picture.Bitmap.Height;
    //StatusBar1.SimpleText:=IntToStr(Image1.Width)+'/'+IntToStr(Image1.Height);
  finally
    // TJPEGImageオブジェクトを破棄
    jpg.Free;
  end;
end;

end.

グレースケール変換実行のボタン(Button1)をクリックすると・・・
このButton1Click手続き内で呼び出しているCreateGrayScalePalette関数でエラーが発生。

ブレークして確認すると、エラーになるのはココ。

でも、なんでエラーになるのか、わからない・・・

Google先生に訊くと、次のような情報を発見。

[Delphi?][ネタ]透明に見えるパターンを描く

https://qiita.com/pik/items/25276e49fb131425db07

早速、範囲チェックさせないコンパイラ指令 {$R-} を追加。

ナニがどうして、そうなるのか?
原因も、理由も、皆目わからないけれど・・・

範囲チェックエラーは出なくなりました!

範囲チェックを実行しないというコンパイラ指令 {$R-} は知りませんでした。
同じ理由で困ってる方もいるかもしれないと思い、記録だけUpしました。

なお、画像のグレースケール化にあたっては、次のWebサイト様にあった情報を使わせていただきました。24bitのフルカラー画像を256階調のモノクロ画像に変換(グレースケール変換)する処理を行う際に役立つ情報が、そのアルゴリズムも含めて、多数紹介されています。

カラー画像をモノクロ画像に変換

http://rakasaka.fc2web.com/delphi/grayscale.html

また、上のWebサイト様で紹介されている配列要素を動的に確保する必要のない、Delphiが独自に定義しているTMaxLogPalette構造体を使用したCreateGrayScalePalette関数を利用した場合は、範囲チェックエラーは発生しませんでした。

お願いとお断り

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

矩形検出器を改良

今までのアルゴリズムで僕の矩形検出器がユーザーに提示する「次に採点する解答欄候補」の順番は、だいたい、こんなイメージ・・・

これまでの僕のアルゴリズムでは、実際の採点順とは、まるで違う順番で
「次の採点候補」とする解答欄をユーザーへ提示してしまう・・・

今回、ほぼ採点する順番の通りに「次の採点候補」の解答欄を赤枠で囲んでユーザーに提示できるよう、解答欄矩形の座標を採点順に並べ替えるアルゴリズムを改良。その結果、矢印キー押し下げ時の「次の採点候補とする解答欄座標」を示す赤枠矩形の動きのイメージは次のようになった。

こんな僕の書いた稚拙で、頼りないプログラムでも、喜んで使ってくださる方がいる。
こんなに重たい事実はない。

プログラムが良くなることは、きっと・・・、僕自身が良くなることだ。
そう思いつつ、遅ればせながら、矩形検出器のアルゴリズムをようやく改良できた。

(矩形検出の成功率は100%で、OpenCVの性能は最高!です)

解答用紙の様式パターンの研究がまったく足りていなかったことが今回の改良が必要になったいちばんの原因。

開発の最初の段階で、解答欄矩形を余すところなく認識出来て、舞い上がってしまった自分が、幼かったんだなー。

【もくじ】

1.僕のアルゴリズムの問題点
2.解答欄をブロック化して認識処理を実行
 【間違えポイント①:範囲を指定して画像を切り出す】
 【間違えポイント②:OpenCVのfilenameはPAnsiChar型】
3.まとめ
4.お願いとお断り

1.僕のアルゴリズムの問題点

手書き答案をスキャンして得た画像データから同一設問の解答欄のみを抽出して一覧表示し、採点後、返却用答案画像に採点結果を書き戻すプログラムを書いた。

その際、スキャンした答案画像の解答欄を自動認識する矩形検出器も作成。採点プログラムに同梱して配布。同僚に使ってもらったのだけれど・・・。

解答用紙の解答欄が複数列(?)存在するような形式の解答用紙では、問題が発生。

それは、どんな問題かと言うと・・・

例えば、次のような横書き形式の解答用紙であった場合に、Myプログラムで矩形検出を実行すると・・・

検出した矩形データ(座標群)のうち、最も左上の矩形を最初に赤枠(ラバーバンドで囲って)表示、ユーザーが解答欄であるか・どうかを判定(選択)、座標自体はMemoに数値で一覧表示してあるから ↓ 矢印キーで次の矩形へ・・・という流れで、採点に必要な解答欄座標のみを取得するように設定。

解答欄と、その座標をGUIで表示する(実際の画像)。

ところが、次のような解答用紙の場合・・・

左の画像を右へコピペしたので、設問番号がオカシイのは無視してください・・・

・・・のですが、矩形検出を実行後、検出された座標群から、僕のアルゴリズムで解答欄の選択を実行すると・・・

解答欄矩形の座標自体は、確実に取得できているから、矢印キーを駆使して、採点したい向きに解答欄の座標が並ぶように、座標を選択していけばいいだけの話なんだけれど。

これが・・・

超絶。すーぱーめんどくさい!

さらに、どんなにまっすぐ解答用紙をセットしても、必ず右肩上がり(画像が左に0.05度くらい傾いた状態で)でスキャンしてくれるという、メインで使用しているスキャナーならではのヘンなクセもあり、しかも、その画像に対して「Y座標の小さい順に赤枠で囲む」という僕のアルゴリズムは「正しく」機能するから、解答欄を上から下へ、行単位では左から右へという夢見た処理の流れは完全に逆転。採点候補の解答欄は左右に飛び、行単位でも右から左へ、想定とは真逆の順番で次の採点候補矩形が延々と表示される結果に・・・。こんな状態で、提示(表示)された解答欄矩形の座標を、採点順に正しく選択することは、年配の同僚にはほぼ不可能・・・

解答欄矩形、それ自体は 100% 正しく検出できているのですが・・・
あまりにも、こちらの気持ちを無視したプログラムの挙動を目の当たりにして・・・

責任者を出せ!って
怒鳴りたくなるんだけど・・・

ちょっと・・・、待って。

責任者。オレじゃん、
みたいな・・・

解答用紙によっては、さらに・・・

この例だと、Y座標がムチャだから、解答欄の選択作業はさらに困難を極め・・・

もっと・・・、発展(?)して

もぉ T_T

2.解答欄をブロック化して認識処理を実行

どうすればいいか?

答えはひとつしかありません。そうです!

解答用紙の解答欄を、採点順になるよう「まとまりのブロック」に分けて、ブロックごとに解答欄座標の取得手続きを行えばいいのです。

これで「一部がCutされた解答欄」は、矩形として認識されないので、最後に手動で座標データを追加すれば、なんとかなります(手動設定のプログラムは期待通りに動作しているから安心だし、上の図のような特殊な解答欄は最後の方にある場合が多い)。

この新方針のもとで、プログラムを見直してみると・・・

もともとのアルゴリズムは画像全体を1ブロックとして扱って、解答欄の矩形座標を検出しているから・・・

procedure TForm1.btnGetSquareClick(Sender: TObject);
var
  //PythonのScriptを入れる
  strScrList:TStringList;
  //Pythonから送られたデータを保存する -> グローバル変数化
  //strAnsList:TStringList;
  //Sort
  i,j: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('from PIL import Image');
    //strScrList.Add('img = cv2.imread("./ProcData/sample2.jpg")');
    //strScrList.Add('img = cv2.imread(r"'+StatusBar1.SimpleText+'")');
    strScrList.Add('pil_img = Image.open(r"'+StatusBar1.SimpleText+'")');
    strScrList.Add('img = np.array(pil_img)');
    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);
    //「0による浮動小数点数除算」のエラーを出ないようにするおまじない
    MaskFPUExceptions(True);
    //Execute
    PythonEngine1.ExecStrings(Memo1.Lines);
    //結果を表示
    Memo2.Lines.Assign(strAnsList);
  finally
    //StringListの解放
    strAnsList.Free;
    strScrList.Free;
  end;

end;

・・・という感じで、かなりシンプル!

このあと、横書き・縦書きという解答欄の書き方に応じて、解答欄座標の並べ替えを行っている。どちらかというと、解答欄矩形の検出作業はOpenCVにおまかせで、並べ替えのアルゴリズムの方を工夫した記憶が・・・。

とりあえず・・・

横書き解答用紙が選択された時のみ、解答欄を何ブロックに分割して処理するか、GUIで選択できるようにして・・・

ブロック数分Loopを廻す中で、OpenCVのcvSetImageROI関数を用いて答案画像を分割、結果を一時Memoに書き込んで、2ブロック以降の座標値に対しては、そのx座標を答案画像上での値に修正(←実はコレを忘れていて、動作確認の際 ??? なことになり、初めて取得した座標値の修正作業の必要性に気づく)、で、最後に採点する順番になるよう座標を並べ替えてユーザーに提示する準備を実行。さらに、横書き解答用紙の場合は、一時Memoから座標データ提示用Memoへデータを移動して終了。・・・みたいな手続きのカタチにプログラムを半日程度かけて修正。

【間違えポイント①:範囲を指定して画像を切り出す】

cvSetImageROI関数の使い方は、その内部に入れてるcvRect関数の第1引数が切り出し位置の左上x座標、第2引数が切り出し位置左上のy座標、第3引数が切り出す幅、第4引数が切り出す高さとなっている。

最初、よく考えずにcvRect関数の第3、4引数を切り出し位置右下のx、y座標だと思い込んで設定し、切り出した画像の幅が変化することから、設定の誤りに気づく。前にマークシートリーダーを開発した時(Python環境を導入する前の段階で)、Windows用のOpenCVで画像処理していたときに、この関数のお世話になったはずなんだけど、もぉすっかり忘れてしまっていたようです。

  //指定範囲の画像を切り出して保存
  //cvRect(x, y, Width, Height)
  cvSetImageROI(sourceImage, cvRect(top_x, top_y, xWidth, yHeight));

【間違えポイント②:OpenCVのfilenameはPAnsiChar型】

それから、画像データへのPathとファイル名を入れる変数p1が PAnsiChar 型であることを、こちらもすっかり忘れていて、String型で引数を指定してエラーになって初めてそれを思い出す。変数に値を代入する際、いったん AnsiString 型でキャストして更に PAnsiChar でキャスト。

  //画像データのファイル名
  p1:PAnsiChar;

begin

  ・・・

  //String 型の文字列を PAnsiChar 型の文字列に変換
  //AnsiString 型でキャストして更に PAnsiChar でキャスト
  p1:=PAnsiChar(AnsiString('CutImage0'+IntToStr(i)+'.jpg'));

  //画像を保存する
  cvSaveImage(p1, sourceImage);

  ・・・

end;

完成した手続きがこちら(変数名等は思いつくまま、意図した通りに動けば 可 とした)

procedure TForm1.btnGetSquareClick(Sender: TObject);
var
  //PythonのScriptを入れる
  strScrList:TStringList;
  //Pythonから送られたデータを保存する -> グローバル変数化
  //strAnsList:TStringList;
  //Sort
  i,j:integer;
  //strFileName:string;
  strList:TStringList;

  //画像の等幅分割
  //切り出し領域
  top_x, top_y:integer;
  yHeight:integer;
  //xの増分
  xWidth, iMax:integer;
  //for Imageの読み込み
  sourceImage: PIplImage;
  //画像データのファイル名
  p1:PAnsiChar;

  //x座標の補正
  str1, str2, str3, str4:string;

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('from PIL import Image');
    //strScrList.Add('img = cv2.imread("./ProcData/sample2.jpg")');
    //strScrList.Add('img = cv2.imread(r"'+StatusBar1.SimpleText+'")');
    strScrList.Add('pil_img = Image.open(r"'+StatusBar1.SimpleText+'")');
    strScrList.Add('img = np.array(pil_img)');
    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);
    //「0による浮動小数点数除算」のエラーを出ないようにするおまじない
    MaskFPUExceptions(True);
    //Execute
    PythonEngine1.ExecStrings(Memo1.Lines);
    //結果を表示
    Memo2.Lines.Assign(strAnsList);
  finally
    //StringListの解放
    strAnsList.Free;
    strScrList.Free;
  end;
  }

  //画像分割処理ここから

  //初期化
  //Memo1.Clear;
  Memo2.Clear;
  MemoTemp.Clear;

  //初期化(定数的に利用する)
  top_y:=0;

  //分割数
  iMax:=StrToInt(cmbPartition.Text);

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

  //初期化
  xWidth:=0;

  try

    for i := 0 to iMax-1 do
    begin

      //画像を読み込む(Bitmap・JPEGどちらも読み込み可能)
      p1:=PAnsiChar(AnsiString(StatusBar1.SimpleText));
      sourceImage := cvLoadImage(p1, CV_LOAD_IMAGE_ANYDEPTH or CV_LOAD_IMAGE_ANYCOLOR);

      //intとTruncは小数点以下を切り捨て。異なるのは、戻り値がintは実数、Truncは整数になること
      xWidth:=Trunc(SimpleRoundTo(sourceImage.Width/iMax,0));
      yHeight:=sourceImage.Height;

      //切り出す座標を指定
      top_x:= xWidth * i;

      try

        //指定範囲の画像を切り出して保存
        //cvRect(x, y, Width, Height)
        cvSetImageROI(sourceImage,cvRect(top_x, top_y, xWidth, yHeight));

        //String 型の文字列を PAnsiChar 型の文字列に変換
        //AnsiString 型でキャストして更に PAnsiChar でキャスト
        p1:=PAnsiChar(AnsiString('CutImage0'+IntToStr(i)+'.jpg'));
        //画像を保存する
        cvSaveImage(p1, sourceImage);

      finally
        //イメージの解放
        cvReleaseImage(sourceImage);
      end;

    end;

    for i := 0 to iMax-1 do
    begin

      //Scriptを入れるStringList
      strScrList:=TStringList.Create;

      //x座標の補正値を計算
      top_x:= xWidth * i;

      try
        //Python Script
        strScrList.Add('import cv2');
        strScrList.Add('import numpy as np');
        strScrList.Add('from PIL import Image');
        //strScrList.Add('img = cv2.imread("./ProcData/sample2.jpg")');
        //strScrList.Add('img = cv2.imread(r"'+StatusBar1.SimpleText+'")');
        //strScrList.Add('pil_img = Image.open(r"'+StatusBar1.SimpleText+'")');
        strScrList.Add('pil_img = Image.open(r"'+'CutImage0'+IntToStr(i)+'.jpg'+'")');
        strScrList.Add('img = np.array(pil_img)');
        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.Clear;
        Memo1.Lines.Assign(strScrList);
        //「0による浮動小数点数除算」のエラーを出ないようにするおまじない
        MaskFPUExceptions(True);
        //Execute
        PythonEngine1.ExecStrings(Memo1.Lines);
        //結果を表示
        if RadioButton1.Checked then
        begin
          //x座標を補正する
          MemoTemp.Lines.Assign(strAnsList);
          if i<>0 then
          begin
            for j := 0 to MemoTemp.Lines.Count-1 do
            begin
              //値を取得
              str1:=GetTokenIndex(MemoTemp.Lines[j],',',0);
              str2:=GetTokenIndex(MemoTemp.Lines[j],',',1);
              str3:=GetTokenIndex(MemoTemp.Lines[j],',',2);
              str4:=GetTokenIndex(MemoTemp.Lines[j],',',3);
              //カンマ区切りの文字列の1,3番目にtop_x値を加える(座標を修正)
              str1:=IntToStr(StrToInt(str1)+top_x);
              str3:=IntToStr(StrToInt(str3)+top_x);
              //書き戻し
              MemoTemp.Lines[j]:=str1+','+str2+','+str3+','+str4;
            end;
          end;
        end else begin
          Memo2.Lines.Assign(strAnsList);
        end;
      finally
        //StringListの解放
        //strAnsList.Free;
        strAnsList.Clear;
        strScrList.Free;
      end;

      //横書きの場合のみ実行
      if RadioButton1.Checked then
      begin

        //strFileName:=ExtractFilePath(StatusBar1.SimpleText)+'Temp.csv';
        //MemoTemp.Lines.SaveToFile(strFileName);

        strList := TStringList.Create;
        try
          for j := 0 to MemoTemp.Lines.Count-1 do
          begin
            strList.Add(MemoTemp.Lines[j]);
          end;
          //並び替え 降順 -> True
          //if RadioButton1.Checked then
          //begin
            fAscending := False;
            fIndex := 1; //2番目の項目を
            fStyle := ssInteger; //整数型でソート
            strList.CustomSort(MyCustomSort); //ソート開始
          //end else begin
          //  fAscending := True;
          //  fIndex := 0; //1番目の項目を
          //  fStyle := ssInteger; //整数型でソート
          //  strList.CustomSort(MyCustomSort); //ソート開始
          //end;

          //データ抽出
          //Memo2.Clear;
          for j := 0 to strList.Count - 1 do
          begin
            Memo2.Lines.Add(strList[j]);
          end;
        finally
          MemoTemp.Clear;
          strList.Free;
        end;
      end;
    end;

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

  //画像分割処理ここまで

  //縦書きの場合のみ実行
  if RadioButton2.Checked then
  begin

    //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;
      //並び替え 降順 -> True
      //if RadioButton2.Checked then
      //begin
      //  fAscending := False;
      //  fIndex := 1; //2番目の項目を
      //  fStyle := ssInteger; //整数型でソート
      //  strList.CustomSort(MyCustomSort); //ソート開始
      //end else begin
        fAscending := True;
        fIndex := 0; //1番目の項目を
        fStyle := ssInteger; //整数型でソート
        strList.CustomSort(MyCustomSort); //ソート開始
      //end;

      //データ抽出
      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;

  if RadioButton2.Checked then
  begin
    ScrollBox1.HorzScrollBar.Position:=ScrollBox1.HorzScrollBar.Range;
  end else begin
    //ScrollBarが表示されていなくてもエラーにならない
    ScrollBox1.HorzScrollBar.Position:=0;
  end;

  //表示
  LBRow.Visible:=True;
  LBRow2.Visible:=True;

  //操作可能に設定
  btnOpen.Enabled:=True;
  btnSave.Enabled:=True;

  //操作不可に設定
  btnGetSquare.Enabled:=False;

  //先頭へスクロール
  Memo2.Perform(WM_VSCROLL,SB_TOP,0);

  //先頭行へ
  Memo2.SelStart:=SendMessage(Memo2.Handle, EM_LineIndex, 0, 0);
  Memo2.Perform(EM_SCROLLCARET, 0, 0);  //キャレット位置までスクロール
  Memo2.SetFocus;

  GetLinePos;

  //矩形を表示
  Memo2Click(Sender);

end;

ちなみに、最後の解答欄矩形を表示する処理は・・・

procedure TForm1.Memo2Click(Sender: TObject);
var
  i:integer;
  //x1,x2,x3,x4:integer;
  //y1,y2,y3,y4: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);
    //ShowMessage(IntToStr(i));

    //エラー対策
    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;

最終的に完成したコードはまわりくどくて、汚いけど、動きは期待したとおり、例えば3ブロックある解答用紙での処理は・・・

1ブロックめの最初。

このまま、下方向へ解答欄矩形の座標データを選択して、いちばん下の座標まで移動すると、次の矢印キー押し下げと同時に赤枠は2ブロックめの先頭へ移動。

2ブロックめは上の例だと2列分あるので、ちょっと処理が面倒だけど、実際の解答用紙ではこんな例はまずないので大丈夫ということにしておいて、とりあえず、いちばん下の座標まで移動したところで次の矢印キー押し下げ、同時に赤枠は3ブロックめの先頭へ移動。

で、3ブロックめの解答欄矩形も余すところなく、選択。実に、イイかんじ。

コレだ! コレだ!!
コレを実現したかったんだ☆

やったー!!
できた!!!

3.まとめ

複数ブロックからなる解答用紙の解答欄矩形検出は(考えてみれば当たり前ですが)、次のように処理するとうまく行きます。

(1)解答用紙の画像を予め複数ブロックに分割して別画像として保存
(2)それぞれのブロックごとに解答欄の矩形を検出&採点する順番に並べ替え
(3)ブロックごとに取得した座標値を解答用紙画像全体の中での座標値に変換
(4)全座標値を結合して定義ファイル等に保存

今回は、上の(3)の処理を失念してプログラミングしていたので、必要だった修正は、ブロックごとの値として取得したx座標を、解答用紙画像全体の中でのx座標に変換する処理を追加するだけという、この修正わずか1回で期待したとおりに動作するプログラムを完成できました。これは僕的には極めて稀有な例で、言うのも恥ずかしい事実ですが、いつも七転八倒状態を延々と繰り返してなんとか思ったとおりの動作を実現しているので、たまにはこんなコトがあってもいいかなー。みたいな♪

4.お願いとお断り

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

Installer

・・・って言えるのかな?

正直、レジストリは汚したくない。でも、プログラムの動作に必要なユーザーの情報や設定は保存して再利用したい・・・そんな時、役立つのが定義ファイル。

今時、レジストリを使わずに定義ファイル(iniファイル)を使うなんて、完全に時代遅れなのかもしれないが、2つか、3つの設定内容を記録して利用するには、すごく便利なのは事実。ただ、ひとつだけ問題があるとすれば、exeファイルの周辺にユーザーの知らないファイルが生成されること。

【参考】
以前、この問題の解決方法として、パブリックのドキュメント(C:\Users\Public\Documents)に定義ファイル他を保存して、プログラムから利用したこともあった。それがスマートか、どうか、は別にして、それなりに目的は実現できたけど・・・なんか、どこか、すっきりしない感じが残って(毎回コレで行こう!みたいな気持ちになれなかった)。ユーザーに意識させたくない部分を意図的に「隠した」って、自分的には、どうしても思っちゃうからかなー。

今回は、その「困ったこと」を僕なりにどう解決したか? ・・・というお話。

【目次】

1.困ったこと
2.自分的解決策はただ一つ
3.作ってみた①(全自動)
4.作ってみた②(マニュアル)
5.まとめ
6.お願いとお断り

1.困ったこと

iniファイルを使用したり、リソースに埋め込んだDLL、もしくは画像やデータベースその他のファイルをプログラムからexeの周辺に生成して利用する場合、例えばデスクトップにexeファイルを置くと、プログラムの起動と同時に、ユーザーから見て「何、コレ?」みたいなファイル(or フォルダ)が EXE の周辺に出来てしまう。

例えば、次のようにリソースに埋め込んだDLLがインストール先フォルダになければ、それを EXE のある場所に生成する場合がそうだ。

procedure TForm1.FormCreate(Sender: TObject);
var
  dllFileName:string;
begin
  //リソースからDLLを(なければ)生成
  dllFileName:=ExtractFilePath(Application.ExeName)+'XXX.dll';
  //ファイルの存在を確認
  if not FileExists(dllFilename) then
  begin
    //リソースを再生
    with TResourceStream.Create(hInstance, 'Resource_1', RT_RCDATA) do
    begin
      try
        SaveToFile(dllFileName);
      finally
        Free;
      end;
    end;
  end;
end;

プログラムを終了しても、当然、それらはexeの周辺に残っている。これらはユーザーから見れば、突然生まれた不審なファイル(or フォルダ)としか思えなくても不思議はない。

特にデスクトップにiniファイルやDLLを生成するEXEを置いた場合には、キレイ好きなユーザーから見れば、「この画面を汚すEXE、なに?」ってことにもなりかねない。

2.自分的解決策はただ一つ

ユーザーに対して、このような不安を与えないようにするなら、プログラム配布専用のインストールプログラムを作り、まず、そのリソースに配布したいプログラム(EXE)を埋め込む。で、このインストール専用のプログラムを起動したら、例えばユーザーのマイドキュメント内に適切な名前のフォルダを作成して、そこにexeをリソースから生成してコピー。最後に、そのEXEへのショートカットをデスクトップに自動的に作る・・・みたいなインストール専用のプログラム(=Installer)を書けばいいのかな? ・・・って。

こうしておけば、ユーザーはデスクトップのショートカットをダブルクリックするだけでプログラムを使えるし、ユーザーに見せたくないプログラムの動作に必要な情報も、その存在を隠しながら、マイドキュメント等に作った専用フォルダ内に生成できるはず。

3.作ってみた①(全自動)

予め、リソースにインストールしたい完成した配布用EXEを埋め込んでおく。DelphiのIDEの「プロジェクト」→「リソースと画像」の順にクリックして、埋め込むEXEを指定。

埋め込むEXEは、後の混乱を避ける意味でも、このインストールプログラムのプロジェクトフォルダに「Resource」等の専用フォルダを作成して、そこに完成した配布用EXEをコピーしておき、それを指定するのが方法的には Best かと。

このEXEの中には、当該プログラムの動作に必要なDLL等が全て埋め込まれている

GUIは、こんな感じで作成(実行時の画面)。

基本的に「全自動でインストール」内のボタン1ClickでOK!(の予定)

わかりやすい、とか、わかりにくい、とか、そういう問題とは別に、Enterキーひと押しで完全に動作すれば、インストールプログラムのインターフェイスの良し悪しは、特に問題にならないはず。

で、「マイドキュメントに専用~」ボタンをクリックした時の手続きは次の通り。

  private
    { Private 宣言 }
    Setup_FolderPath:string;
    Setup_ExeName:string;

implementation

{$R *.dfm}

uses
  Winapi.ShlObj, Vcl.FileCtrl, System.UITypes, plShortcutUtils;

  //ShlObjはSHGetKnownFolderPath関数を使用するために追加
  //ShellExecute関数を使用してフォルダを開いて表示する場合はWinapi.ShellAPIも追加する

  //Vcl.FileCtrlは、新しいフォルダ作成ボタン付きフォルダの選択ダイアログの表示に必要

procedure TForm1.btnAutoClick(Sender: TObject);
var
  FolderID:TGUID;
  FolderPath:PChar;
  rsFileName:string;
  LDir:String;
begin

  //マイドキュメントフォルダへのPathを取得する
  FolderID:=StringToGUID('{FDD39AD0-238F-46AF-ADB4-6C85480369C7}');
  if SHGetKnownFolderPath(FolderID,0,0,FolderPath)= S_OK then
  begin
    Setup_FolderPath := FolderPath;
  end;

  //インストール先フォルダの有無を調査->なければ作成
  if not System.SysUtils.DirectoryExists(ExtractFileDir(Setup_FolderPath+'\'+Setup_ExeName+'\')) then
  begin
    //フォルダ階層を作成
    System.SysUtils.ForceDirectories(ExtractFileDir(Setup_FolderPath+
      '\'+Setup_ExeName+'\'));
  end;

  //Path
  rsFileName:=Setup_FolderPath+'\'+Setup_ExeName+'\'+Setup_ExeName+'.exe';

  //ファイルがある場合は削除
  if FileExists(rsFilename) then
  begin
   //ファイルが存在したときの処理
    DeleteFile(rsfileName);
  end;

  //リソースを再生
  with TResourceStream.Create(hInstance, 'Resource_1', RT_RCDATA) do
  begin
    try
      SaveToFile(rsFileName);
    finally
      Free;
    end;
  end;

  //デスクトップにこのプログラムのショートカットを作成
  if CheckCreateShortCut.Checked then
  begin
    //plShortcutUtilsユニット内の関数類を使用
    //CSIDL_DESKTOP等の定数名の使用にはusesにShlObjが必要
    //CSIDLの値からフルパスを取得
    //ショートカットを作成する場所
    LDir := GetDirectoryFromCSIDL(CSIDL_DESKTOP);

    if CreateShortCutLink(rsFileName, LDir, Setup_ExeName) then begin
      //ショートカットの作成場所によっては,以下のコードで更新が必要
      //SendMessage(HWND_BROADCAST, WM_SETTINGCHANGE, 0, 0);
    end;

    MessageDlg('Done!', mtInformation, [mbOk] , 0);
  end;

end;

ショートカットの作成方法は、Mr.XRAYさんのWebページにある方法をコピペしました。

880_ショートカットの作成と削除

http://mrxray.on.coocan.jp/Delphi/plSamples/880_CreateShortcut.htm

Private 宣言した Setup_FolderPath には、FormCreate手続きで次のようにして(初期表示のため、取り敢えず)マイドキュメントフォルダへのPathを入れておきます・・・。

procedure TForm1.FormCreate(Sender: TObject);
var
  FolderID:TGUID;
  FolderPath:PChar;
begin

  //インストールするEXEの名前
  Setup_ExeName:=EditExeName.Text;

  //マイドキュメントフォルダへのPathを取得する
  FolderID:=StringToGUID('{FDD39AD0-238F-46AF-ADB4-6C85480369C7}');

  if SHGetKnownFolderPath(FolderID,0,0,FolderPath)= S_OK then
  begin
    Setup_FolderPath := FolderPath;
    EditPath.Text:= Setup_FolderPath;
  end;

end;

それから、インストールするExeの名前はForm上で非表示のGUI(EditExeName.Text)に設定しています(FormCreate時にグローバル変数に名称を読み込んで利用)。

こうしておけば、リソースに組み込むExeファイルを変更した時も、InstallするExeの名称を変更するだけで、このインストールプログラムを使えます。

設計時の画面左下に、実行時には非表示のLabelとEditコントロールを配置。このEditコントロールのTextプロパティにインストールするExeの名称を設定。

InstallするExeの名称Labelとその右のEditコントロールのVisibleプロパティはFalse

動作を検証した結果、プログラムは期待通りに動作しました。
ただ、32bitバージョンを作成した際に、実行形式ファイルを作成出来なくなるエラーが何回かありましたが・・・(原因がよくわかりません)。

4.作ってみた②(マニュアル)

もし、ユーザーが「おまかせインストール」ではなく、「フォルダを指定してインストール」の方を選択した場合の「ルートディレクトリの指定」に関する手続きは・・・

procedure TForm1.RadioGroup1Click(Sender: TObject);
var
  FolderID:TGUID;
  FolderPath:PChar;
begin

  case RadioGroup1.ItemIndex of
    0:begin
      //マイドキュメントフォルダへのPathを取得する
      FolderID:=StringToGUID('{FDD39AD0-238F-46AF-ADB4-6C85480369C7}');
      if SHGetKnownFolderPath(FolderID,0,0,FolderPath)= S_OK then
      begin
        Setup_FolderPath := FolderPath;
        EditPath.Text:= Setup_FolderPath;
      end;
    end;
    1:begin
      //マイコンピュータへのPathを取得する
      Setup_FolderPath := 'C:\';
      EditPath.Text:= Setup_FolderPath;
    end;
  end;

end;

ちなみに、PCを選択した場合に表示される「フォルダーの参照」ダイアログは・・・

PCのフォルダ構成に詳しい人向きの表示になります・・・

で、インストール先を選ぶ「変更」ボタンをクリックした際の挙動は・・・

procedure TForm1.btnGetPathClick(Sender: TObject);
var
  SelectDir: String;
begin

  case RadioGroup1.ItemIndex of
    0:begin
      //フォルダを選択 -> MyDocumentsを指定
      //if SelectDirectory('', '::' + GUIDToString(CLSID_MyDocuments), SelectDir) then

      //MyDocumentsを指定 -> MyDocumentsを指定 & 新しいフォルダ作成ボタン付き
      if SelectDirectory('', '::' + GUIDToString(CLSID_MyDocuments), SelectDir,
        [sdNewUI, sdNewFolder, sdShowEdit], Self) then
      begin
        EditPath.Text:=SelectDir;
        Setup_FolderPath:=EditPath.Text;
      end;
    end;
    1:begin
      //フォルダを選択 -> を指定
      //if SelectDirectory('', '::' + GUIDToString(CLSID_MyComputer), SelectDir) then

      //MyMyComputerを指定 -> MyMyComputerを指定 & 新しいフォルダ作成ボタン付き
      if SelectDirectory('', '::' + GUIDToString(CLSID_MyComputer), SelectDir,
        [sdNewUI, sdNewFolder, sdShowEdit], Self) then
      begin
        EditPath.Text:=SelectDir;
        Setup_FolderPath:=EditPath.Text;
      end;
    end;
  end;

end;

上の手続きで使用しているGUIDToString関数の引数CLSID_XXXには、その種類に制限があるようです。ShlObj.pas内のGUID定義を見てみると・・・

const
  CLSID_NetworkDomain: TGUID     = '{46E06680-4BF0-11D1-83EE-00A0C90DC849}';
  {$EXTERNALSYM CLSID_NetworkDomain}
  CLSID_NetworkServer: TGUID     = '{C0542A90-4BF0-11D1-83EE-00A0C90DC849}';
  {$EXTERNALSYM CLSID_NetworkServer}
  CLSID_NetworkShare: TGUID      = '{54A754C0-4BF0-11D1-83EE-00A0C90DC849}';
  {$EXTERNALSYM CLSID_NetworkShare}
  CLSID_MyComputer: TGUID        = '{20D04FE0-3AEA-1069-A2D8-08002B30309D}';
  {$EXTERNALSYM CLSID_MyComputer}
  CLSID_Internet: TGUID          = '{871C5380-42A0-1069-A2EA-08002B30309D}';
  {$EXTERNALSYM CLSID_Internet}
  CLSID_RecycleBin: TGUID        = '{645FF040-5081-101B-9F08-00AA002F954E}';
  {$EXTERNALSYM CLSID_RecycleBin}
  CLSID_ControlPanel: TGUID      = '{21EC2020-3AEA-1069-A2DD-08002B30309D}';
  {$EXTERNALSYM CLSID_ControlPanel}
  CLSID_Printers: TGUID          = '{2227A280-3AEA-1069-A2DE-08002B30309D}';
  {$EXTERNALSYM CLSID_Printers}
  CLSID_MyDocuments: TGUID       = '{450D8FBA-AD25-11D0-98A8-0800361B1103}';
  {$EXTERNALSYM CLSID_MyDocuments}

自分的に使いたいなーって思う定義は、MyComputerとMyDocumentsぐらいしかありません(Desktopがない!)。まぁ、ない袖は振れない・・・ということでしょう。

どうしてもデスクトップを指定したい場合は、上で使用した GetDirectoryFromCSIDL(CSIDL_DESKTOP) のように、CLSID_XXX ではなく、CSIDL_XXX を使える形式に書き直す必要がありそうです(今回は、書き換えずに進めることにします)。

で、「実行」ボタンの挙動は、ほとんど再掲ですが・・・

procedure TForm1.btnOKClick(Sender: TObject);
var
  rsFileName:string;
  LDir:String;
begin

  //Path
  rsFileName:=Setup_FolderPath+'\'+Setup_ExeName+'.exe';

  //ファイルがある場合は削除
  if FileExists(rsFilename) then
  begin
   //ファイルが存在したときの処理
    DeleteFile(rsfileName);
  end;

  //リソースを再生
  with TResourceStream.Create(hInstance, 'Resource_1', RT_RCDATA) do
  begin
    try
      SaveToFile(rsFileName);
      //MessageDlg('Generate!', mtInformation, [mbOk] , 0);
    finally
      Free;
    end;
  end;

  //デスクトップにこのプログラムのショートカットを作成
  if CheckCreateShortCut.Checked then
  begin
    //plShortcutUtilsユニット内の関数類を使用
    //CSIDL_DESKTOP等の定数名の使用にはusesにShlObjが必要
    //CSIDLの値からフルパスを取得
    //ショートカットを作成する場所
    LDir := GetDirectoryFromCSIDL(CSIDL_DESKTOP);

    if CreateShortCutLink(rsFileName, LDir, Setup_ExeName) then begin
      //ショートカットの作成場所によっては,以下のコードで更新が必要
      //SendMessage(HWND_BROADCAST, WM_SETTINGCHANGE, 0, 0);
    end;

    MessageDlg('Done!', mtInformation, [mbOk] , 0);
  end;

end;

案外簡単に、思った通りのインストールプログラムが作れました!

ふと疑問に思い、今回、調べて初めて知ったのですが、「インストール」と「セットアップ」は意味的に異なるようです。

セットアップはよく、「インストール」と同義語として解説されることもありますが、インストールは、ソフトウェアを動かすためのプログラムやデータなどの各種ファイルをコンピュータにコピーすることであり、セットアップは、インストール後に自分のコンピュータに合わせて必要な設定をすることまでを指す言葉です。

ネット用語辞典(https://bb-navi.jp/netjiten/sa25.html)より引用

5.まとめ

iniファイルを使用したり、リソースに埋め込んだDLLその他のファイルをインストール先フォルダ等に生成して使うようなプログラムを配布する場合、ユーザーに優しいプログラムとするため、必要のないファイルその他を見せない工夫があった方がよいのではないか? と思い、マイドキュメントフォルダ等に専用フォルダを作成して、そこへExeをインストールするプログラムを書いてみた。

これまでユーザーのPCに手作業でEXEのインストール作業を行ってきたが、このようなインストーラにExeを埋め込んで配布すれば、その作業がいらなくなる?

取り敢えず、現場で運用して見ます!

6.お願いとお断り

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

サインイン 4 アプリからオンラインのOneDriveを表示

これまで「サインイン」と題して、オンラインのOneDriveへ、いかに楽してサインインするか・・・という内容の記事を3つ書いた。

それはアプリのOneDriveから、簡単にオンラインのOneDriveを表示する方法がわからなかったから。で、ことここに至ってようやく、その方法を発見。

結局、これまでの全ては、オンラインのOneDriveへ「いかに苦労してサインインするか」に変わった気が・・・。

【目次】

1.アプリからオンラインのOneDriveを表示
2.アカウントの切り替えも簡単
3.まとめ
4.お願いとお断り

1.アプリからオンラインのOneDriveを表示

なんで今まで気がつかなかったんだろう・・・。アプリのOneDriveの「フォルダーを開く」の右隣にオンラインのOneDriveを表示する「オンラインで表示」があった!

こんなコマンドがあったなんて・・・ちっとも気がつきませんでした。

さらに、アプリのOneDriveの「フォルダーを開く」で表示されるエクスプローラーの右上の「同期しています」をクリックすると表示されるサブメニューの右下にも「オンラインで表示」が存在!(ここのキャプションは、その時々の状況を伝えるほかの文字列「例:エラー」等になることもあるようだ)

ここにも「オンラインで表示」があった!

いずれもクリックすると、Webブラウザが起動してオンラインのOneDriveが表示される。

データ交換用のUSBメモリのようにオンラインのOneDriveを使用したい時は、このWebブラウザに表示されたオンラインのOneDriveへ、必要なデータをアップロードして、別のPCで同様にオンラインのOneDriveにサインインして、必要なデータをダウンロードすればいい。

オンラインのOneDriveへデータをアップロード

追記(20230829)

回線速度とは別に、使用するWebブラウザによりダウンロード速度に違いが生じることがあるのだろうか? 昨日、150MB程度のZipファイルをOneDriveからダウンロードしたのだが、Myプログラムから実行したそれは「あまりにも」遅く、耐え難かったので、ChromeからOneDriveに接続してダウンロードしてみたら「ものすごく」速かった・・・です。

遅かったのはTWebBroeserを使ったプログラムだったので、TEdgeBrowserに変更した新しいプログラムで試してみたら、ChromeでOneDriveに接続した場合と変わらないダウンロード速度で快適に作業できました!

2.アカウントの切り替えも簡単

個人のアカウントから、組織のアカウントへ(もちろん、その逆も)の切り替えも簡単でした。オンラインで表示したOneDrive画面右上のアカウントマネージャーをクリックして切り替えたいアカウントを選択するだけです。

アカウントの切り替え画面

3.まとめ

(1)アプリのOneDriveからオンライン表示への切り替えは走召簡単(泣)
(2)複数のアカウントがある場合、アカウントマネージャーで切り替え可能
(3)間違った思い込みは無駄な苦労の元。アプリの使い方をよく勉強しよう!

4.お願いとお断り

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

サインイン 3

追記(20230827 OneDriveアプリからオンライン表示へ切り替え)

無駄にプログラムなんか書く必要はありませんでした!

以下、『いかに苦労してOneDriveにサインインするか』という、上記サインイン 4に辿り着くまでの、長いながいまわり道の記録です。なので、お読みいただく価値がないことを最初に申し添えます。m(__)m

この記事は、アプリとして実行(タスクトレイに常駐)するOneDriveではなく、Web上のOneDriveへ直接データをアップロードし、別のPCでそのデータをダウンロードする、言わばデータ交換用USBメモリのようにOneDriveを使用する方法の一例です。PC内のOneDriveフォルダにあるデータと、クラウド上のOneDriveにあるデータの同期などは、まったく考慮しておりませんので、その点にはどうかご注意願います。

プロローグ

2023年8月のある日を境に、OneDriveの挙動が変わったことに気づいた。組織アカウントと個人アカウントの両方で同一ID(メールアドレス)を使用している場合、個人アカウントとしてサインインしようとすると、個人アカウント用のサインイン画面が新たに表示され、その都度、パスワードの入力が必須になったようだ・・・。

1.あれは夢だったのか・・・?
2.IDの入力を2回求められるようになった・・・
3.イロイロ調べてみた!
4.パスワードも自動入力!
5.画面の表示設定
6.まとめ
7.お願いとお断り

1.あれは夢だったのか・・・?

これまでに、過去の記事として「サインイン」、「サインイン 2」と題し、クラウド上のOneDriveへ、いかに「楽して入るか」ということについて自分なりに工夫した点のまとめの記事を(プログラミングの一つの区切りでもあったし)掲載した。

実行形式ファイルを配布していないから、動作の確認のしようがないじゃないか・・・という声が聞こえてくるような気もするけど、exeの配布が僕の目的ではなく、夢中になったことのプログラミング記録を残すことがこのBlogを書く目的なので、そこは悪しからずご了解ください(バグに満ちた?実行形式ファイルを配って、多くの方にご迷惑をおかけしたくないという思いも当然あります)。

「サインイン 2」で思った通りのプログラムが完成して、「使うぞー!」という段階に入った8月中旬、これまでと異なる挙動をWebブラウザが示すことに僕は気づいた。

サインイン2で作成した接続専用プログラムを起動し、IDを自動入力して「次へ」をクリックして「個人用アカウント」をクリックしてもOneDriveに素直に入れないのだ・・・。例外なく新しい画面でIDの入力を再度求められ、さらに毎回パスワードも入力しなければならない。

今まではこんなことなかったのに・・・
IDを入力するだけで入れた、あのOneDriveは夢だったのか・・・?

2.IDの入力を2回求められるようになった・・・

具体的にはどうなるのか、画面をつけて説明すると以下の通り。
FormCreate手続きの最後で、次のようにOneDriveのサインイン画面を呼び出して・・・IDとして利用しているメールアドレスを自動入力。

  //Navigate
  EdgeBrowser1.Navigate('https://onedrive.live.com/about/ja-jp/signin/');
IDとして利用するメールアドレスを入力して「次へ」をクリック(もちろん入力は自動化)

すると、次の画面が表示されて、これまでなら「個人用アカウント」をクリックするだけでOneDriveにサインインできた(過去にパスワードを入力してサインインに成功していればそのCookieが残っているから?)。

過去(90日間以内?)にサインインに成功していれば、パスワード入力は必要なかった・・・はず。
この画面が今回の問題の根本的な原因がここにあることを示唆している(気がする)。

ところが、2023年8月中旬(頃?)からは、「個人用アカウント」をクリックすると、なんと・・・(おそらく、ここでサインインするアカウントが「個人用アカウント」であることがユーザーによって確定されたということで、Microsoftさん的には、今度は安心して・・・再度、個人アカウント専用のOneDriveへのサインイン画面を表示して、サインインしてください・・・という意味なのだと思いますが)

サインインの最初の画面に戻ってしまう・・・ 感覚的には、ウソだろ、なんで? って感じ。

「戻ってしまう・・・」と書いたが、正確にはカーソルのキャレットの点滅位置(スクリーン座標)が異なっていることと、その下に「サインイン オプション」なる最初の画面にはない表示があることから気づいたのだが、(最初に表示されたのとは)「別」の(=個人用アカウントの?)サインイン画面が表示されるのだ(サインイン画面のURLも確認したが、当然最初のサインイン画面とは異なっている)。

IDとして利用するメールアドレスは、この段階では疑似クリック&貼り付け失敗に備えてクリップボード上に送ってあるから、キャレットの点滅を確認し、Ctrl+Vして入力欄に貼り付けて「次へ」をクリックすると、さらに、これまでは出てこなかったパスワードの入力画面が表示される・・・。

Cookieの存在など忘れたかのようだ

これまで利用していたはずのCookieは何処へ消えたのか・・・不思議に思いながら、パスワードを入力してサインインボタンをクリックすると、やっとOneDriveに入れる・・・。

しかも、最後に表示される画面で、「今後このメッセージを表示しない」をチェックして「はい」を選択(クリック)しても、このメッセージは毎回必ず表示される・・・つまり、個人用アカウントでサインインした場合は、「サインインの状態は維持されない」。

My 環境では、「はい」をクリックしても設定は維持されない

拝啓 マイクロソフト様

オレみたいな輩がいるから、こんな仕様になったんですか?

Webブラウザが見えないところでやってることなんて、何にもわかりません。わからないけど・・・

「ボクのお父さんは、桃太郎というやつに殺されました。」

あの手紙を読んだときと同じくらいショックでした。

悪いことをするつもりはまったくありません。

いつも使ってるID(=メールアドレス)で、

OneDriveに楽して入りたいだけなんです。

「それが大きな間違いだ・・・」と言われたら、素直に「はい」と言うしかありませんが・・・

3.イロイロ調べてみた!

(期待したことなど一度もないが)これまで通りの七転八倒の結末に、今回も大いに落ち込む。が、唯一の救いは「OneDriveにサインインできなくなったわけではない」という部分だ。設定が変わって、セキュリティがより厳しくなった・・・というか、組織アカウントと個人アカウントの区分がより厳密になった・・・と言えばいいのかな? この問題の全体像は、多分、僕には把握できないだろうけれど、とりあえず、僕がわかるところまでOneDriveへサインインする仕組みについて調べてみることにした。

その結果、いちばんわかりやすかったのが、こちらの記事。

『サインインの状態を維持しますか ?』のオン/オフをユーザーごとに制御する

https://itbeginner.tech/2020/07/25/keep-me-signed-in/

上記Webサイト様の記事によれば、「有効期限が切れるシナリオ以外に、サインイン画面が表示される代表的な例」は次の5つがあるとのこと。

  • ユーザーのパスワードが変更されている
  • サインインの際に prompt=login パラメーターが付与されている
  • 多要素認証 (MFA) を実施する必要がある
  • inPrivate モードのブラウザでサインインしている
  • ブラウザが Cookie を保存できない、送信できない … など。

https://itbeginner.tech/2020/07/25/keep-me-signed-in/より引用

パスワードは変更してないから、それ以外の4つのうち、個人用アカウントでサインインする場合には、どの理由が該当するのか(自分的には、組織アカウントと個人のアカウントの両方に登録されているIDが使用された場合に、どちらのアカウントでのサインインであるかを確定することがサインイン画面が2回表示される最大の目的だと思うのだけれど)、いずれにしても原因がはっきりわかっても、そこから先が独力では解決できそうにありません。

おそらく、組織アカウントと個人アカウントを明確に切り分けない限り、現段階でこの問題の解決策はないように思えてきました。

また、上記Webサイト様の記事では『Fiddler』(フィドラー?)というHTTPS通信を解析するソフトが紹介されており、記事を読んで(僕には絶対に結果を上手く扱えない・・・)と直感的に思ったけれど、取り敢えずLink先へ飛んでプログラムをダウンロードしてMy PCにインストール。動かしてみた結果が次の通り。

HTTPS通信の内容(My IDやPW)が表示されてる・・・ すごいー!!

『Fiddler』のインストールと使い方は、次の記事を参照して行いました!

HTTPS パケット キャプチャ ツール Fiddler のインストールから使用開始まで。

https://qiita.com/Shinya-Yamaguchi/items/37347ec532824c2dccad

で、せっかくインストールして動かしてみた『Fiddler』ですが、この『Fiddler』が表示してくれているHTTPS通信の内容を、Delphi の Object Pascal で書く OneDrive への接続プログラムで活用する方法がわかりません・・・。残念ながら僕には、現時点でそれだけのプログラミングスキルが・・・悔しいけれどありません。それをイチから学ぶには、とんでもない時間がかかりそうです・・・

もはやこれまで・・・
あきらめるしかないかぁ・・・

っと、思ったところで気がつきました!

何をあきらめるというのだろう?
Cookieを利用した形でのパスワード入力を回避できないなら、
パスワードも自前で暗号化して定義ファイルに保存しといて、
自動入力すればイイだけのことじゃないか・・・

サインイン画面が再び表示されたらCtrl+Vで、クリップボードにあるIDのデータを貼ればいいだけだし、さらに続けてパスワード入力が要求される場面があっても、僕のプログラム側で対応して、ID入力同様にパスワード入力を半自動化してしまえばいい。

負け惜しみじゃなく、すべてを手入力するよりか、はるかにラクだ!
貼り付けのショートカットだって、Ctrl+Vだけなら覚えて貰えるはず・・・
目の前に見えてるボタンのクリックなら、なんの問題もない。

要は、困った時のサポートと、「慣れ」だ。

OneDriveにサインインする「敷居」さえ、もっと低くできれば・・・
みんなに やさしい プログラムになる。

風邪などのウイルス性疾患全般に効く特効薬はないみたいだけれど、たとえウイルスは退治できなくでも、ウイルスの引き起こす様々な症状への対症療法ならたくさんある。

それと同じように、Windows Hello や Cookie を利用してパスワード入力そのものを回避するというような根本的な問題解決は(今の僕には)出来ないけれど、サインインのID入力画面が表示されたら、IDを自動入力、クリックで進めるところは素直にクリックして次へ進み、もしパスワード入力画面が表示されたら、そこでまたパスワードを半自動入力するという、いわば対症療法的な方法で少しでも「楽に」サインインする方法が実現できるよう頑張ればいい。それだって、IDやパスワードを毎回全部手入力するよりは、ずっとラクなはずだ・・・。

繰り返せば、やがて「慣れ」という免疫ができる・・・。

それに、実験してて気がついたんだけど、自動的にCookieが適用?されて、パスワード入力を求められない(パスワード入力の画面が表示されない)IDもあるようだ。

詳しくは書かないけど、サインイン後の画面そのものがIDによって・・・違う。

なんでIDにより、Webブラウザの挙動が異なるのか?

おそらく、僕がOneDriveへのサインイン時に、IDとして使用しているメールアドレスは、Office 365の申し込み時にもその登録に使用したから当然Azure ADアカウント(=職場や学校のアカウント:組織アカウント)になっていて、さらに、同じメールアドレスが昔のLive IDつながりのMicrosoftアカウント(=個人のアカウント)としても登録されているから、このアカウントの二重登録状態をなんとか解消したい(させたい)Microsoft社の意向があって、こういうことになっているんだと思うんだけど・・・。

そうか、組織アカウントなら・・・。

ただ、僕のように、個人のアカウントのメールアドレスをどうしても変更したくない場合は、どうしたらよいのだろう?

そのへんの違いと仕組みは、これからの成長課題としておいて・・・、今は、今の僕に出来るいちばんイイことをしよう!

4.パスワードも自動入力!

さっそく、次のようにGUIを修正。

ID=メールアドレス入力用のGUIはそのまま利用(前回、作成したもの)

上のGUIの「待ち時間:1500(ミリ秒)」ComboBoxの右隣りに下のGUIを追加。

パスワード入力用のGUIを追加

上のように、パスワードをマスクするには、次のように設定。

パスワードを入力させるとき、入力した文字が他人に見られないように*などを表示(現在は黒丸●が標準?)するには、PasswordCharプロパティに * を設定するだけでOK!

//Password入力用文字列に'*'を設定
Edit1.PasswordChar:='*';

//Password入力用文字列設定を解除(''で#0を囲まないこと!)
Edit1.PasswordChar := #0;

【注意】
Editコントロールのプロパティで直接指定する場合は、アスタリスクをシングルクオートで囲んで ‘*’ としないこと! 「プロパティ値が違います」と即エラーになる。

シングルクオート囲みなし、単に #0 or * を入力すればOK!

マスク解除のプロパティでの指定例

パスワードのマスクを、CheckBoxのチェックと連動させるのであれば・・・

「確認」チェックボックスのチェックに連動してマスク状態が変化する
procedure TForm1.chkPWClick(Sender: TObject);
begin
  if chkPW.Checked then
  begin
    EditPW.PasswordChar := #0;
  end else begin
    EditPW.PasswordChar := '*';
  end;
end;

【再掲:マウスカーソルの現在位置座標の取得方法】

座標チェックに☑すると、マウスカーソルの現在位置のスクリーン座標がリアルタイムで表示される。その方法は前回も示しましたが、次の通り。

マウスのカーソルが現在置かれている位置のスクリーン座標を取得してLabelに表示。

procedure TForm1.chkZahyoClick(Sender: TObject);
begin
  if chkZahyo.Checked then
  begin
    //Enabled
    Timer1.Enabled:=True;
  end else begin
    //Enabled
    Timer1.Enabled:=False;
    LabelXY.Caption:='[X座標, Y座標]';
  end;
end;

Timer1のOnTimerプロパティをダブルクリックして作成されたTimer1Timer手続きに次のコードを記述。これでほぼリアルタイムにカーソルの位置座標を取得して表示できる。

procedure TForm1.Timer1Timer(Sender: TObject);
var
  lh_Handle:  HWND;
  lpt_Pos:    TPoint;
  lrc_Rect:   TRect;
  lrg_Region: HRGN;
  li_Ret:     Integer;
begin
  if chkZahyo.Checked then
  begin
    //マウスカーソル位置をスクリーン座標で取得
    GetCursorPos(lpt_Pos);
    //自身のウィンドウリージョンを調べる
    lh_Handle := Self.Handle;

    //ウィンドウリージョン取得のため空のリージョンを作っておく
    lrg_Region := CreateRectRgn(0,0,0,0);
    try
      //ウィンドウリージョン取得
      li_Ret := GetWindowRgn(lh_Handle, lrg_Region);
      if (li_Ret <> ERROR) then begin
        //ウィンドウのRectを取得
        GetWindowRect(lh_Handle, lrc_Rect);
        //スクリーン座標からウィンドウの左上を原点とした座標に変換
        lpt_Pos.X := lpt_Pos.X - lrc_Rect.Left;
        lpt_Pos.Y := lpt_Pos.Y - lrc_Rect.Top;
        //ウィンドウリージョン内にマウスカーソルがあるかテスト
        if (PtInRegion(lrg_Region, lpt_Pos.X, lpt_Pos.Y)) then begin
          LabelXY.Caption:=Format('OK %d (%d-%d)', [li_Ret, lpt_Pos.X, lpt_Pos.Y]);
        end else begin
          LabelXY.Caption:=Format('NG %d (%d-%d)', [li_Ret, lpt_Pos.X, lpt_Pos.Y]);
        end;
      end else begin
        LabelXY.Caption:=Format('[X:%d, Y:%d]', [lpt_Pos.X, lpt_Pos.Y]);
      end;
    finally
      DeleteObject(lrg_Region);
    end;
  end;
end;

これでパスワード入力欄のスクリーン座標を取得・保存しておいて、実際はパスワード入力画面が表示されたら、「入力」ボタンをクリック(パスワード入力画面が表示されなければ、GUIそのものを表示する必要もないので、接続環境に合わせてGUIそのものの表示もON/OFFできるようにした)。もちろん、GUIの表示状態そのものを保存可能。

パスワードはクリップボードに送信せず、Editコントロールにマスクをかけて表示しておき、入力が必要であればボタンクリックで実行できるように設定。

入力ボタンをクリックすると指定座標位置へパスワードを送信

パスワードの半自動入力は、次のコードで実行(前回のID入力用コードを修正)。

procedure TForm1.btnCopyClick(Sender: TObject);
var
  dwFlags : DWORD;
  X,Y : Integer;
  LKeyByte : Byte;
begin

  boolInput:=False;

  //Information
  if chkInfo.Checked then
  begin
    if MessageDlg('パスワード入力画面が見えていて、入力欄は空欄ですか?', mtInformation, [mbYes, mbNo], 0) = mrYes then
    begin

      try

        //クリップボードを初期化
        Clipboard.Clear;
        //文字列をクリップボードへ
        Clipboard.AsText:=EditPW.Text;

        dwFlags:=MOUSEEVENTF_MOVE or MOUSEEVENTF_ABSOLUTE;
        X:=Trunc(StrToInt(EditPWX.Text)/Screen.Width*65537);
        Y:=Trunc(StrToInt(EditPWY.Text)/Screen.Height*65535);

        //移動
        Mouse_Event(dwFlags,X,Y,0,0);
        //クリック
        Mouse_Event(MOUSEEVENTF_LEFTDOWN,0,0,0,0);
        //Mouse_Event(MOUSEEVENTF_LEFTUP,0,0,0,0);
        Application.ProcessMessages;

        WaitTime(StrToInt(cmbWaitTime.Text));

        Mouse_Event(MOUSEEVENTF_LEFTDOWN,0,0,0,0);
        Mouse_Event(MOUSEEVENTF_LEFTUP,0,0,0,0);
        Application.ProcessMessages;

        // [Ctrl] + [V] のキー操作
        LKeyByte := Ord('V');
        keybd_event(VK_CONTROL, MapVirtualKey(VK_CONTROL, 0), 0, 0);
        keybd_event(LKeyByte, MapVirtualKey(LKeyByte, 0), 0, 0);
        keybd_event(LKeyByte, MapVirtualKey(LKeyByte, 0), KEYEVENTF_KEYUP, 0);
        keybd_event(VK_CONTROL,MapVirtualKey(VK_CONTROL,0),KEYEVENTF_KEYUP, 0);

        boolInput:=True;


      except

        boolInput:=False;

      end;

      end else begin

        MessageDlg('パスワード入力画面を表示し、入力欄が空欄の状態で、再度実行してください。', mtInformation, [mbOk] , 0);

    end;

  end else begin

    try

      //クリップボードを初期化
      Clipboard.Clear;
      //文字列をクリップボードへ
      Clipboard.AsText:=EditPW.Text;

      dwFlags:=MOUSEEVENTF_MOVE or MOUSEEVENTF_ABSOLUTE;
      X:=Trunc(StrToInt(EditPWX.Text)/Screen.Width*65537);
      Y:=Trunc(StrToInt(EditPWY.Text)/Screen.Height*65535);

      //移動
      Mouse_Event(dwFlags,X,Y,0,0);
      //クリック
      Mouse_Event(MOUSEEVENTF_LEFTDOWN,0,0,0,0);
      //Mouse_Event(MOUSEEVENTF_LEFTUP,0,0,0,0);
      Application.ProcessMessages;

      WaitTime(StrToInt(cmbWaitTime.Text));

      Mouse_Event(MOUSEEVENTF_LEFTDOWN,0,0,0,0);
      Mouse_Event(MOUSEEVENTF_LEFTUP,0,0,0,0);
      Application.ProcessMessages;

      // [Ctrl] + [V] のキー操作
      LKeyByte := Ord('V');
      keybd_event(VK_CONTROL, MapVirtualKey(VK_CONTROL, 0), 0, 0);
      keybd_event(LKeyByte, MapVirtualKey(LKeyByte, 0), 0, 0);
      keybd_event(LKeyByte, MapVirtualKey(LKeyByte, 0), KEYEVENTF_KEYUP,0);
      keybd_event(VK_CONTROL,MapVirtualKey(VK_CONTROL,0),KEYEVENTF_KEYUP,0);

      boolInput:=True;

    except

      boolInput:=False;

    end;
  end;
end;

5.画面の表示設定

IDのみの入力でOneDriveにサインイン可能な場合は、不要なGUIは表示せずに運用。

これが理想的画面

ID&パスワードの入力が必要な場合は、画面左上の設定ボタンをクリックしてGUIを表示。初回のみ、ID&パスワード入力欄のスクリーン座標を計測&保存して、次回以降は半自動入力でサインイン。

対症療法的&非理想的画面(GUIを活用してサインイン)

現実世界に追従するカタチでのプログラミングは、夢を追いかけて・・・ではなくて、正直、必要に追われて・・・って感じで、書いていて楽しくはないけど。

でも、もし、これが誰かの役に立つなら・・・

OneDriveが使えなくて、すごく困っている人の役に立つなら・・・

僕のしたことに、ほんの少しだけ

意味や価値を見出せる気がします。

そうだ・・・。今、思い出せた・・・。

プロが書いた、見た目も美しい、あらゆる要求に対応した高価なプログラムではなく、
こんな僕の書いた、みすぼらしい、しかも機能限定のプログラムがいいと・・・

二者択一の場面で、僕のプログラムを選んでくださる人がいることを。

うん。そうだ。
きみも言ってくれたね。

「生きていれば必ず前進できます。
 もっとよくなれるんです。

 ・・・

 お互い 夢の実現に向けて、自分らしく歩きましょう。」

今、信じなくて、いつ、信じるんだ。
1ミリでもかまわない。
前へ行くんだ。

僕に今できる、唯一、確かなことをするんだ・・・

6.まとめ

(1)OneDriveへのサインインはIDによりその挙動が異なる。
(2)対症療法的にプログラミングすれば半自動ログインはできる。
(3)全自動ログインには、Cookieの利用を含めたさらなる学習が必要。

7.お願いとお断り

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

サインイン 2

追記(20230827 OneDriveアプリからオンライン表示へ切り替え)

無駄にプログラムなんか書く必要はありませんでした!

以下、『いかに苦労してOneDriveにサインインするか』という、上記サインイン 4に辿り着くまでの、長いながいまわり道の記録です。なので、お読みいただく価値がないことを最初に申し添えます。m(__)m

この記事は、アプリとして実行(タスクトレイに常駐)するOneDriveではなく、Web上のOneDriveへ直接データをアップロードし、別のPCでそのデータをダウンロードする、言わばデータ交換用USBメモリのようにOneDriveを使用する方法の一例です。PC内のOneDriveフォルダにあるデータと、クラウド上のOneDriveにあるデータの同期などは、まったく考慮しておりませんので、その点にはどうかご注意願います。

また、サインイン画面でのID(Microsoftアカウントに登録したメールアドレス)入力後のOneDriveの応答について、いろいろ調べたのですが、起こり得る個々の問題一つ一つについては、僕のプログラミングスキルでは到底対応できないと感じました。そこで、ID入力後にパスワード入力が必要になった場合の処理について、後日、自分なりのスーパー・ローレベル対応方法として「サインイン3」というタイトルで書きたいと思います。

コンピューターと同期する OneDrive フォルダーを選択する

クラウド上のOneDriveとPCのデータの同期については上のリンク先記事をご参照ください。

OneDriveのサインイン画面に、IDとして利用するメールアドレスを自動入力するプログラムを前回、作成した。目的通りのプログラムが出来たことは出来たが、WebView4Delphiコンポーネント(MITライセンス)のdemoフォルダにあったSampleをそのまま使わせてもらったため、「ID(メールアドレス)を自動入力する」以外にも様々な機能が実装(Sampleなんだから当然と言えば当然)されており、ただプログラムを起動するだけで、exeを置いたフォルダ内に容量がおよそ200MBくらいある「CustomCache」という名前のフォルダが出来てしまう。無視すればイイと言ってしまえば・・・それまでかもしれないけど・・・。

WebView4Delphi

https://github.com/salvadordf/WebView4Delphi

とりあえず、自分的には使わない機能をカットしようと思い、プログラムソースを読んでみたんだけれど・・・、コードどうしの関連がよくわからない。ヘタにいじって不具合とバグの山を築くより、「IDの自動入力」という初心に帰って、プログラムをイチから作り直した方がいい気がしてきた。

で、作ったのがコレ。

OneDriveのサインイン画面にID(メールアドレス)を自動入力する機能だけを搭載

【作成の手順】

1.TEdgeBrowserを使う
2.GUIの設計とプログラムコード
(1)GUIの設計
(2)VCLコントロールの表示/非表示を切り替え
(3)入力値の保存/読み込みと暗号化
(4)カーソル位置の座標を取得
(5)プログラムコードから指定位置をクリック
(6)ダウンロードフォルダを開く
(7)リソースにDLLを埋め込む
(8)操作方法の案内
3.まとめ
4.お願いとお断り

1.TEdgeBrowserを使う

Delphiで、Web コンテンツやローカルに置いたhtmlファイルの読み込みと表示を行うためのビジュアル コンポーネントには、TEdgeBrowser や TWebBrowser があるけれど、表示したいWebページがJavaScript のダイアログ ボックス、パネル、その他要素を使用しているとTWebBrowser では Web ページを正しく表示できないことがあるようだ。

正直に言うと、Edge には印刷その他の不具合でかつて悩まされた記憶があり、個人的にあまり良いイメージを持っていなかったので、新しい TEdgeBrowser コンポーネントではなく、古い TWebBrowser コンポーネントの方を使いたかった。だから、最初は、次のリンク先にあるような情報を参考にして、TWebBrowser コンポーネントで OneDrive のサインイン画面を表示するプログラムを書いてみたのだが・・・

Delphi / C++Builder Starter Edition の VCL で WebBrowser コンポーネントを使う

https://qiita.com/ht_deko/items/c69902d644ea03f61deb

上のリンク先記事のおしまいの部分でも述べられている通り、TWebBrowser コンポーネントを使って OneDrive のサインイン画面を表示するコードを書くと、結構盛大にスクリプトエラー発生のメッセージが表示される。

なんとかならないか・・・と思い、Google先生にお伺いをたてると、FMX版の TWebBrowser の記事ではあるが、本家本元embarcaderoさん提供のスクリプトエラー発生回避策を発見。

FMX.WebBrowser.TWebBrowser

https://docwiki.embarcadero.com/Libraries/Sydney/ja/FMX.WebBrowser.TWebBrowser

それによれば「この問題を回避するには、アプリケーションは、Internet Explorer の FEATURE_BROWSER_EMULATION 機能を使用して、Web ページを IE11 エッジ モードで表示しなければなりません。」と説明があり、具体的な回避策として「FormCreate イベント ハンドラで、TForm1.SetPermissions メソッドを呼び出す」方法がソースコード付きで掲載されていた。

早速、スクリプトエラー回避策なるそのコードをコピペして実行してみたが、ナニがよくないのか、スクリプトエラーは発生状況に変化は見られなかった。

上のリンク先ページでは、スクリプトエラー回避策コードの下に「メモ: レジストリに対するこれらの変更は、アプリケーションが開始する前に適宜行わなければなりません。最初にアプリケーションを開始した際には、それを一度閉じ、再度開始します。」という説明があるので、プログラム起動時にレジストリに対する変更を行って、いったんプログラムを終了し、再度実行すればOKなのか? とも思ったが、原因の究明に時間を割くより、新しいTEdgeBrowserコンポーネントでOneDriveのサインイン画面を表示する方法を試した方が賢い気がして、ここで方針を変更。素直にTEdgeBrowserコンポーネントを使うことにする。その際、参考にさせていただいた記事がこちら

TEdgeBrowserでWebView2を使う ~Delphiソースコード集

https://mam-mam.net/delphi/tedgebrowser.html

OSがWindows11であれば、動作に必要なMicrosoft WebView2 ランタイムは、既に入っているので、インストール不要とのこと。作成するプログラムを動かす予定のPCのOSはすべてWindows11なので、その点は心配ないが、いちおうReadme.txtファイルを用意して、OSがWindows10の場合にはMicrosoft WebView2 ランタイムのインストールが必要であることを案内した方がよさそうだ。

2.GUIの設計とプログラムコード

(1)GUIの設計

Delphiを起動し、「ファイル」→「新規作成」→「Windows VCLアプリケーション」と辿って、表示されたFormにPanelを3つ図のように配置する。

Panel1が階層構造的にはいちばん下にあり、AlignプロパティはalTopを指定。その上にPanel2及び 3 を乗せて、Panel2のAlignプロパティはalLeft、Panel3のAlignプロパティはalClientをそれぞれ指定する。このようにプロパティを設定しておけば、Formの大きさが変化しても、Panel2の大きさ(幅と高さ)は変わらず、Panel3の高さはそのままで幅がFormの大きさ(幅)に合わせて自動的にサイズが変化し、各VCLコントロールの位置はFormの左上を原点とした設計時の位置に固定されて表示される。

VCLコントロールの階層構造

この後、Panel2の上には「ダウンロードフォルダを開く」ボタン、Panel3の上には暗号化してiniファイルに保存する予定の「ID」入力用のEditその他のVCLコントロールを次のように配置する。

必要と思われる最小限度のVCLコントロールを使い勝手を考えながら配置する

(2)VCLコントロールの表示/非表示を切り替え

ID(メールアドレス)や、自動的にクリックする座標値はいったん設定・保存してしまえば、通常の使用の際には必要ないので、普段は非表示に設定。つまり、□設定チェックボックス以外のVisibleプロパティはすべてFalseを指定する。で、設定を☑したときだけ、表示されるように設定。

procedure TForm1.chkSettingClick(Sender: TObject);
begin
  if chkSetting.Checked then
  begin
    LabelID.Visible:=True;
    btnCopy.Visible:=True;
    btnCopy.Enabled:=True;
    Edit1.Visible:=True;
    LabelX.Visible:=True;
    EditX.Visible:=True;
    LabelY.Visible:=True;
    EditY.Visible:=True;
    btnSave.Visible:=True;
    chkZahyo.Visible:=True;
    LabelXY.Visible:=True;
    LabelWaitTime.Visible:=True;
    cmbWaitTime.Visible:=True;
  end else begin
    LabelID.Visible:=False;
    btnCopy.Visible:=False;
    Edit1.Visible:=False;
    LabelX.Visible:=False;
    EditX.Visible:=False;
    LabelY.Visible:=False;
    EditY.Visible:=False;
    btnSave.Visible:=False;
    chkZahyo.Visible:=False;
    LabelXY.Visible:=False;
    LabelWaitTime.Visible:=False;
    cmbWaitTime.Visible:=False;
  end;
end;

(3)入力値の保存/読み込みと暗号化

各VCLコントロールに入力された値は、必要な個所は暗号化してiniファイルに保存する。

uses
  System.IniFiles;

procedure TForm1.btnSaveClick(Sender: TObject);
var
  strID:string;
  Ini:TIniFile;
begin

  //入力の有無をCheck
  if Edit1.Text='' then
  begin
    MessageDlg('IDとして利用するメールアドレスを入力してください', mtInformation, [mbOk] , 0);
    Edit1.SetFocus;
    Exit;
  end;

  if (EditX.Text='') or (EditY.Text='') then
  begin
    if EditX.Text='' then
    begin
      MessageDlg('自動クリックするX座標を入力してください', mtInformation, [mbOk] , 0);
      EditX.SetFocus;
    end;
    if EditY.Text='' then
    begin
      MessageDlg('自動クリックするY座標を入力してください', mtInformation, [mbOk] , 0);
      EditY.SetFocus;
    end;
    Exit;
  end;

  if cmbWaitTime.Text='' then
  begin
    MessageDlg('カーソル移動の待機時間をミリ秒単位で入力してください', mtInformation, [mbOk] , 0);
    cmbWaitTime.SetFocus;
    Exit;
  end;

  //暗号化
  strID:=EDText(Edit1.Text, IntToStr(HashOf('XXXXXXXX')), True);

  //iniファイルに保存
  Ini := TIniFile.Create(ChangeFileExt(Application.ExeName, '.ini'));
  try
    //保存
    Ini.WriteString('Section', 'ID', strID);
    Ini.WriteString('Section', 'IchiX', EditX.Text);
    Ini.WriteString('Section', 'IchiY', EditY.Text);
    Ini.WriteString('Section', 'WaitTime', cmbWaitTime.Text);
    //Userに通知
    MessageDlg('現在の設定を保存しました!', mtInformation, [mbOk] , 0);

    if not btnCopy.Enabled then btnCopy.Enabled:=True;

  finally
    Ini.Free;
  end;

end;

コードの中で使用しているEDText関数はテキスト暗号化の関数。

  private
    { Private 宣言 }
    //HashNameMBCS(Create hashed values from a Unicode string)
    //MBCS:Multibyte Character Set=マルチバイト文字セット
    function HashOf(const key: string): cardinal;

    //テキスト暗号化/復号化
    Function EDText(KeyStr,PassW:string; EncOrDec:Boolean):string;
    //KeyStr:平文 or 暗号化文のいずれかを指定
    //PassW:パスワード
    //EncOrDec:True -> Encode / False -> Decode

  public
    { Public 宣言 }
  end;

function TForm1.HashOf(const key: string): cardinal;
var
  I: integer;
begin
  Result := 0;
  for I := 1 to length(key) do
  begin
    Result := (Result shl 5) or (Result shr 27);
    Result := Result xor Cardinal(key[I]);
  end;
end;

function TForm1.EDText(KeyStr, PassW: string; EncOrDec: Boolean): string;
var
  {暗号化用変数}
  Source, Dest, Password:TStringBuilder;
  lpSource, lpPass:Integer;
  PassValue, SourceValue, EDValue:Word;
  {共用変数}
  //乱数の種
  Seed1,Seed2,Seed3:integer;
  //実数の一様乱数
  RandNum:Double;
  //秘密鍵Seed
  Seed:string;
  {復号化用変数}
  DecSource:string;
begin
  //1.シード値を準備
  // (1)Passwordを整数へ変換→シード値1へ代入
  Password := TStringBuilder.Create;
  //Seed1を初期化
  //Seed1:=0;
  try
    Password.Append(PassW);
    PassValue := 0;
    for lpPass := 0 to Password.Length - 1 do
    begin
      //パスワード→整数
      PassValue := PassValue + Word(Password.Chars[lpPass]);
    end;
    Seed1:=PassValue;
  finally
    Password.Free;
  end;

  // (2)パスワード文字列の長さを取得→シード値2へ代入
  Seed2:= ElementToCharLen(PassW,Length(PassW));

  // (3)シード値1とシード値2の排他的論理和を計算して、シード値3へ代入
  Seed3 := Seed1 xor Seed2;

  //2.実数の一様乱数を計算
  //---------------------------------------------------------------------------
  // 0より大きく1より小さい実数の一様乱数を発生する関数
  // B.A.Wichmann and I.D.Hill, Applied Statistics, 31, 1982, p.188 に基づく
  // Seed1-3に入れる初期値(整数)は16bit長(maxint=32767)で十分
  // Seed1-3には1から30000までの任意の整数値を準備する(0ではいけない)
  //---------------------------------------------------------------------------

  //Seed1:=171*Seed1 mod 30269 と同値
  Seed1:=(Seed1 mod 177)*171-(Seed1 div 177)* 2;
  if Seed1<0 then Seed1:=Seed1+30269;
  //Seed2:=172*Seed1 mod 30307 と同値
  Seed2:=(Seed2 mod 176)*172-(Seed2 div 176)* 35;
  if Seed2<0 then Seed2:=Seed2+30307;
  //Seed1:=170*Seed1 mod 30323 と同値
  Seed3:=(Seed3 mod 178)*170-(Seed3 div 178)* 63;
  if Seed3<0 then Seed3:=Seed3+30323;
  //See1-3それぞれの乱数を0<RandNum<1となるように
  //計算結果が0より大きく、1未満の実数に直し、和の小数部分をとる
  RandNum:=(Seed1/30269.0) + (Seed2/30307.0) + (Seed3/30323.0);
  while RandNum>=1 do RandNum:=RandNum-1;

  //3.秘密鍵を生成

  //整数の一様乱数の上限値を決めて、整数の一様乱数を生成し、
  //これに上で計算した実数の一様乱数を加えて秘密鍵を生成する
  //Seedが秘密鍵(文字列として利用)となる
  Seed:= FloatToStr(RandNum + trunc((Seed1+Seed2+Seed3)*RandNum));

  //4.暗号化 / 復号化
  if (EncOrDec) then
  begin
    //暗号化(Encode)
    Source := TStringBuilder.Create;
    Dest := TStringBuilder.Create;
    Password := TStringBuilder.Create;
    try
      Source.Append(KeyStr);
      //秘密鍵をセット
      Password.Append(Seed);
      lpPass := 0;
      //テキストのエンコード
      for lpSource := 0 to Source.Length - 1 do
      begin
        //パスワード→整数
        if Password.Length = 0 then
          PassValue := 0
        else begin
          PassValue := Word(Password.Chars[lpPass]);
          Inc(lpPass);
          if lpPass >= Password.Length then lpPass := 0;
        end;
        //テキスト→整数
        SourceValue := Word(Source.Chars[lpSource]);
        //XOR演算
        EDValue := PassValue xor SourceValue;
        //16進数文字列に変換
        Dest.Append(IntToHex(EDValue, 4));
        //処理結果を返り値にセット
        Result:=Dest.ToString;
      end;
    finally
      Password.Free;
      Dest.Free;
      Source.Free;
    end;
  end else begin
    //復号化(Decode)
    DecSource:=keyStr;
    Dest := TStringBuilder.Create;
    Password := TStringBuilder.Create;
    try
      //暗号化テキストのデコード
      Dest.Clear;
      Password.Clear;
      //秘密鍵をセット
      Password.Append(Seed);
      lpPass := 0;
      for lpSource := 1 to Length(DecSource) div 4 do
      begin
        SourceValue := StrToInt('$' + Copy(DecSource, (lpSource - 1) * 4 + 1, 4));
        if Password.Length = 0 then
          PassValue := 0
        else
        begin
          PassValue := Word(Password.Chars[lpPass]);
          Inc(lpPass);
          if lpPass >= Password.Length then lpPass := 0;
        end;
        EDValue := SourceValue xor PassValue;
        Dest.Append(Char(EDValue));
      end;
      //処理結果を返り値にセット
      Result:=Dest.ToString;
    finally
      Password.Free;
      Dest.Free;
    end;
  end;
end;

サインイン時にIDとして入力するメールアドレスは暗号化されてiniファイルに保存され、FormCreate時にこれを復号して、Editコントロールに表示する。

procedure TForm1.FormCreate(Sender: TObject);
var
  Ini: TIniFile;
  strID, strX, strY, strWaitTime: String;
  i:integer;
begin

  //Formを最大化して表示
  Form1.WindowState:=wsMaximized;

  //待ち時間の選択肢(100~3000ミリ秒を100ミリ秒単位で用意)
  for i := 1 to 30 do
  begin
    cmbWaitTime.Items.Add(IntToStr(i*100));
  end;

  //iniファイルの存在を確認
  if FileExists(ChangeFileExt(Application.ExeName, '.ini')) then
  begin
    //iniファイルからデータを読込み
    Ini := TIniFile.Create(ChangeFileExt(Application.ExeName, '.ini'));
    try
      strID:=Ini.ReadString('Section', 'ID', '');
      strX:=Ini.ReadString('Section', 'IchiX', '580');
      strY:=Ini.ReadString('Section', 'IchiY', '420');
      strWaitTime:=Ini.ReadString('Section', 'WaitTime', '500');
    finally
      Ini.Free;
    end;
    //復号して表示
    Edit1.Text:=EDText(strID, IntToStr(HashOf('XXXXXXXX')), False);
    EditX.Text:=strX;
    EditY.Text:=strY;
    cmbWaitTime.Text:=strWaitTime;
  end;

  //Navigate
  EdgeBrowser1.Navigate('https://onedrive.live.com/about/ja-jp/signin/');

end;

(4)カーソル位置の座標を取得

マウスのカーソルが現在置かれている位置のスクリーン座標を取得してLabelに表示。

procedure TForm1.chkZahyoClick(Sender: TObject);
begin
  if chkZahyo.Checked then
  begin
    //Enabled
    Timer1.Enabled:=True;
  end else begin
    //Enabled
    Timer1.Enabled:=False;
    LabelXY.Caption:='[X座標, Y座標]';
  end;
end;

Timer1のOnTimerプロパティをダブルクリックして作成されたTimer1Timer手続きに次のコードを記述。これでほぼリアルタイムにカーソルの位置座標を取得して表示できる。

procedure TForm1.Timer1Timer(Sender: TObject);
var
  lh_Handle:  HWND;
  lpt_Pos:    TPoint;
  lrc_Rect:   TRect;
  lrg_Region: HRGN;
  li_Ret:     Integer;
begin
  if chkZahyo.Checked then
  begin
    //マウスカーソル位置をスクリーン座標で取得
    GetCursorPos(lpt_Pos);
    //自身のウィンドウリージョンを調べる
    lh_Handle := Self.Handle;

    //ウィンドウリージョン取得のため空のリージョンを作っておく
    lrg_Region := CreateRectRgn(0,0,0,0);
    try
      //ウィンドウリージョン取得
      li_Ret := GetWindowRgn(lh_Handle, lrg_Region);
      if (li_Ret <> ERROR) then begin
        //ウィンドウのRectを取得
        GetWindowRect(lh_Handle, lrc_Rect);
        //スクリーン座標からウィンドウの左上を原点とした座標に変換
        lpt_Pos.X := lpt_Pos.X - lrc_Rect.Left;
        lpt_Pos.Y := lpt_Pos.Y - lrc_Rect.Top;
        //ウィンドウリージョン内にマウスカーソルがあるかテスト
        if (PtInRegion(lrg_Region, lpt_Pos.X, lpt_Pos.Y)) then begin
          LabelXY.Caption:=Format('OK %d (%d-%d)', [li_Ret, lpt_Pos.X, lpt_Pos.Y]);
        end else begin
          LabelXY.Caption:=Format('NG %d (%d-%d)', [li_Ret, lpt_Pos.X, lpt_Pos.Y]);
        end;
      end else begin
        LabelXY.Caption:=Format('[X:%d, Y:%d]', [lpt_Pos.X, lpt_Pos.Y]);
      end;
    finally
      DeleteObject(lrg_Region);
    end;
  end;
end;

(5)プログラムコードから指定位置をクリック

前回作成したプログラムでいちばん、悩んだのがここ。最初はサインイン画面のウィンドウハンドルを取得して文字列を送信しようと思ったんだけれど・・・これがうまくいかない。その詳細は前回の記事を参照してください。

さんざん悩んで、ようやく思いついた方法がプログラムコードで画面上の任意の位置をクリックする方法。Formが完全に描画された段階で、指定位置のクリックと、その位置への文字列の入力を実行している。そのコードを再掲。

  private
    { Private 宣言 }

    //アドレス貼り付け実行の成否
    boolInput:boolean;

    fgWaitBreak : boolean;  //変数は「functionより先に定義」する

    //待ち関数  指定カウントが経過すれば True, 中断されたならば False
    function WaitTime(const t: integer): Boolean;

    //Formの表示終了イベントを取得
    procedure CMShowingChanged(var Msg:TMessage); message CM_SHOWINGCHANGED;


//待機関数
function TForm1.WaitTime(const t: integer): Boolean;
var
  Timeout: TDateTime;
begin
  //待ち関数  指定カウントが経過すれば True, 中断されたならば False
  fgWaitBreak := False;
  Timeout := Now + t/24/3600/1000;
  while (Now < Timeout)and not fgWaitBreak do begin
    Application.ProcessMessages;
    Sleep(1);
  end;
  Result := not fgWaitBreak;
end;


procedure TForm1.CMShowingChanged(var Msg: TMessage);
var
  dwFlags : DWORD;
  X,Y : Integer;
  LKeyByte : Byte;
begin
  inherited; {通常の CMShowingChagenedをまず実行}
  if Visible then
  begin

    Update; {完全に描画}

    if Edit1.Text='' then
    begin
      Edit1.SetFocus;
      Exit;
    end;

    if (EditX.Text='') or (EditY.Text='') then
    begin
      if EditX.Text='' then EditX.SetFocus;
      if EditY.Text='' then EditY.SetFocus;
      Exit;
    end;

    fgWaitBreak:=False;

    //さらに念のためちょっと待機
    WaitTime(StrToInt(cmbWaitTime.Text));

    dwFlags:=MOUSEEVENTF_MOVE or MOUSEEVENTF_ABSOLUTE;

    //クリック位置を取得
    X:=Trunc(StrToInt(EditX.Text)/Screen.Width*65537);
    Y:=Trunc(StrToInt(EditY.Text)/Screen.Height*65535);

    //移動
    Mouse_Event(dwFlags,X,Y,0,0);
    Application.ProcessMessages;

    //クリック
    Mouse_Event(MOUSEEVENTF_LEFTDOWN,0,0,0,0);
    Application.ProcessMessages;

    WaitTime(StrToInt(cmbWaitTime.Text));

    Mouse_Event(MOUSEEVENTF_LEFTDOWN,0,0,0,0);
    Application.ProcessMessages;
    Mouse_Event(MOUSEEVENTF_LEFTUP,0,0,0,0);

    //文字列を送信
    boolInput:=False;
    try

      //クリップボードを初期化
      Clipboard.Clear;

      //文字列をクリップボードへ
      Clipboard.AsText:=Edit1.Text;
      
      //[Ctrl] + [V] のキー操作
      LKeyByte := Ord('V');
      keybd_event(VK_CONTROL, MapVirtualKey(VK_CONTROL, 0), 0, 0);
      keybd_event(LKeyByte,   MapVirtualKey(LKeyByte, 0),   0, 0);
      keybd_event(LKeyByte,   MapVirtualKey(LKeyByte, 0),   KEYEVENTF_KEYUP, 0);
      keybd_event(VK_CONTROL, MapVirtualKey(VK_CONTROL, 0), KEYEVENTF_KEYUP, 0);
      //操作に成功
      boolInput:=True;
    except
      //操作に失敗
      boolInput:=False;
    end;

    //貼り付け操作に成功した場合は入力ボタンを操作不可に設定
    if boolInput then btnCopy.Enabled:=False;

  end;

end;

{入力ボタンのClick手続きは、確認メッセージの表示以外は上のコードとほとんど同じ}

また、予期せぬ事故を防止するため、プログラムの終了時にはクリップボードを空に(初期化)する。

procedure TForm1.FormDestroy(Sender: TObject);
begin
  //クリップボードを初期化
  Clipboard.Clear;
end;

(6)ダウンロードフォルダを開く

OneDriveからデータのダウンロードが無事終了すれば、次のようにダウンロードフォルダを開くリンク付きのWindowが表示されるから、特殊なフォルダである「ダウンロードフォルダを開く」ボタンは、別になくてもかまわない気もするけど。

このWindowは移動できない?

もしかしたら任意のタイミングで、それを開きたい時があるかもしれない。エクスプローラーを開けばいいじゃないかという意見は、ここでは聞かなかったことに。

ダウンロードフォルダを開くプログラムコード。

uses
  Vcl.Clipbrd, System.IniFiles, System.UITypes,
  Winapi.ShlObj, Winapi.KnownFolders, Winapi.ShellAPI;

procedure TForm1.btnOpenDLFolderClick(Sender: TObject);
var
  FolderID:TGUID;
  FolderPath:PChar;
  D_FolderPath, ExeFileName:string;
  LhInst:Cardinal;
begin
  FolderID:=StringToGUID('{374DE290-123F-4565-9164-39C4925E467B}');
  if SHGetKnownFolderPath(FolderID,0,0,FolderPath)= S_OK then
  begin
    D_FolderPath := FolderPath;
    //確認
    //ShowMessage(D_FolderPath);
    //ダウンロードフォルダを開く
    ExeFileName:= 'explorer.exe';
    LhInst:=ShellExecute(Handle, 'open', PChar(ExeFileName), PChar(D_FolderPath), nil, SW_SHOW);
    if LhInst <= 32 then
    begin
      MessageBox(Handle, '起動に失敗しました.', '情報', MB_ICONINFORMATION);
    end;
  end;
end;

(7)リソースにDLLを埋め込む

このプログラムの動作には「WebView2Loader.dll」が必須(WebView2Loader.dll は、アプリがデバイス上で WebView2 ランタイム (Microsoft Edge プレビュー チャネル) を見つけるのに役立つコンポーネントであるとのこと)。

WebView2 アプリを 1 つの実行可能ファイルとして配布する

https://learn.microsoft.com/ja-jp/microsoft-edge/webview2/how-to/static

このDLLがないと困るので、添付忘れを防止するため、リソースに埋め込んでおいて、プログラムの実行時にexeのある場所にその有無を確認し、なければリソースから生成するように設定。

メニューの「プロジェクト」→「リソースと画像」で埋め込むDLLを指定

で、FormCreate時に有無を確認、なければexeのある場所に生成。

procedure TForm1.FormCreate(Sender: TObject);
var
  Ini: TIniFile;
  strID, strX, strY, strWaitTime: String;
  i:integer;
  dllFileName:string;
begin

  //リソースからDLLを(なければ)生成
  //rijnファイルの位置を指定
  dllFileName:=ExtractFilePath(Application.ExeName)+'WebView2Loader.dll';
  //rijnファイルの存在を確認
  if not FileExists(dllFilename) then
  begin
    //リソースを再生
    with TResourceStream.Create(hInstance, 'Resource_1', RT_RCDATA) do
    begin
      try
        SaveToFile(dllFileName);
      finally
        Free;
      end;
    end;
  end;

  ・・・

end;

(8)操作方法の案内

この他に、画面最下部に設置したStatusBarに次のような案内を表示できるようにした。

操作方法の案内をStatusBarに表示(OKをクリックすると消える)
案内を表示する/しないはユーザーが選択して、その設定状態の保存も可能に

操作方法の案内の表示/非表示の切り替え。

procedure TForm1.chkInfoClick(Sender: TObject);
var
  strInfo:string;
  strWidth:integer;
begin
  if chkInfo.Checked then
  begin
    //表示する文字列
    strInfo:='ID(メールアドレス)が自動入力されないときは、Ctrl+V で入力できます!';
    strWidth:=StatusBar1.Canvas.TextWidth(strInfo);
    btnOK.Visible:=True;
    with btnOK do
    begin
      Parent:=StatusBar1;
      Left:=strWidth-20;
      Top:=1;
    end;
    //StatusBar1の設定(重要:このプロパティがFalseだとStatusBarにテキストが表示されない)
    StatusBar1.SimplePanel:=True;
    //Info
    StatusBar1.SimpleText:=strInfo;
  end else begin
    StatusBar1.SimpleText:='';
    btnOK.Visible:=False;
  end;
end;

案内を「表示する」が選ばれていた場合はFormCreate時に案内表示を出すよう設定。

procedure TForm1.FormCreate(Sender: TObject);
var
  Ini: TIniFile;
  strID, strX, strY, strWaitTime: String;
  i:integer;
  dllFileName:string;
  strWidth:Integer;
  strInfo:string;
  boolInfo:boolean;
begin

  if chkInfo.Checked then
  begin
    //表示する文字列
    strInfo:='ID(メールアドレス)が自動入力されないときは、Ctrl+V で入力できます!';
    strWidth:=StatusBar1.Canvas.TextWidth(strInfo);
    with btnOK do
    begin
      Parent:=StatusBar1;
      Left:=strWidth-20;
      Top:=1;
    end;
    //StatusBar1の設定(重要:このプロパティがFalseだとStatusBarにテキストが表示されない)
    StatusBar1.SimplePanel:=True;
    //Info
    StatusBar1.SimpleText:=strInfo;
  end;

  ・・・

  //iniファイルの存在を確認
  if FileExists(ChangeFileExt(Application.ExeName, '.ini')) then
  begin
    //iniファイルからデータを読込み
    Ini := TIniFile.Create(ChangeFileExt(Application.ExeName, '.ini'));
    try
      strID:=Ini.ReadString('Section', 'ID', '');
      strX:=Ini.ReadString('Section', 'IchiX', '580');
      strY:=Ini.ReadString('Section', 'IchiY', '420');
      strWaitTime:=Ini.ReadString('Section', 'WaitTime', '500');
      boolInfo:=Ini.ReadBool('Section','Info',True);
    finally
      Ini.Free;
    end;
    //復号して表示
    Edit1.Text:=EDText(strID, IntToStr(HashOf('adminy')), False);
    EditX.Text:=strX;
    EditY.Text:=strY;
    cmbWaitTime.Text:=strWaitTime;
    chkInfo.Checked:=boolInfo;
  end;

  ・・・

end;

案内そのものを表示したくない場合は、ユーザーの自由意思でその設定も可能に。

procedure TForm1.btnSaveClick(Sender: TObject);
var
  strID:string;
  Ini:TIniFile;
begin

  //入力の有無をCheck
  ・・・

  //暗号化
  strID:=EDText(Edit1.Text, IntToStr(HashOf('adminy')), True);

  //iniファイルに保存
  Ini := TIniFile.Create(ChangeFileExt(Application.ExeName, '.ini'));
  try
    //保存
    Ini.WriteString('Section', 'ID', strID);
    Ini.WriteString('Section', 'IchiX', EditX.Text);
    Ini.WriteString('Section', 'IchiY', EditY.Text);
    Ini.WriteString('Section', 'WaitTime', cmbWaitTime.Text);
    Ini.WriteBool('Section','Info',chkInfo.Checked);
    //Userに通知
    MessageDlg('現在の設定を保存しました!', mtInformation, [mbOk] , 0);

    if not btnCopy.Enabled then btnCopy.Enabled:=True;

  finally
    Ini.Free;
  end;

end;

3.まとめ

(1)TEdgeBrowserを使えばOneDriveのサインイン画面をエラーなしで表示できる。
(2)サインイン画面へのIDの入力はプログラムコードで実行可能。
(3)IDはクリップボードに送信しておき、Ctrl+Vでも貼り付け可能に設定。

4.お願いとお断り

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

サインイン

追記(20230827 OneDriveアプリからオンライン表示へ切り替え)

無駄にプログラムなんか書く必要はありませんでした!

以下、『いかに苦労してOneDriveにサインインするか』という、上記サインイン 4に辿り着くまでの、長いながいまわり道の記録です。なので、お読みいただく価値がないことを最初に申し添えます。m(__)m

OneDrive接続専用Browserを作る!

OneDriveのサインイン画面にメールアドレスを自動入力
あとはEnterキーを叩くだけ
どうしてもコレを実現したかった!

【今回の記事】

1.動機は同期
2.WebView4Delphiコンポーネントを使う
3.プログラムでクリックを実行
4.特殊なフォルダを表示する
5.まとめ
6.お願いとお断り

1.動機は同期

この春から勤務先が変わり、それに伴って職場のPC環境も大きく変化して、ファイルのやり取りにOneDriveを利用することが多くなった。今までは、Windows11のアプリとして設定されているOneDriveを常に起動しておいて、必要な時、タスクバーから呼び出して使っていたが、バックアップ的な意味合いで利用することが多く、ファイル交換用途での利用はそれほど多くなかった。

複数のPC間でのデータのやりとりにOneDriveを利用するしかない現在の環境では、今まであまり考えたことがなかった同期のタイミングが問題になってきた。特に、それが『今すぐ』別のPCで使いたいファイルの場合、こっちのPCからクラウドにデータをアップロードして、直ちに、あっちのPCでそのデータをダウンロードしたいのだけれど、アプリのOneDriveでそれを実現する方法がわからない。

Google先生に尋ねても、『コレだ!』という答えは見つからず・・・。
(OneDriveの正しい使い方を私が知らないだけなのかもしれませんが)

仕方がないからアプリではなく、WebブラウザからOneDriveにサインインして、別のPCですぐに使いたいファイル(やフォルダ)をアップロード。別PC側でも同様にしてWebブラウザからOneDriveにサインイン、目的のファイル(やフォルダ)をダウンロードしていたんだけど・・・。

いったん接続してしまえば、ほっといても特に問題はないし、最新の状態に表示を更新したければ F5キー を押すだけだから、いいっちゃいいんだけど・・・。

OneDriveへのサインイン時にメールアドレスを入力するのが、かなりめんどくさい。

特に急いでいる時に入力を間違えたりすると、余計、イライラして、精神的に非常によろしくない。メモ帳にID替わりのメールアドレスを入力、デスクトップに保存しておいて、それをコピペすればいいかと思ったのだけれど、それすら面倒に感じてしまう自分がいよいよ情けなくなった・・・。

魂が腐っている・・・

他に作りたいプログラムも特に今はないし、仕事もそんなに忙しくはないから、思い切ってOneDriveへデータを送受信できる専用ブラウザを作ることにした。

仕様は単純明快。起動したらOneDriveのサインイン画面を表示、メールアドレスを自動入力、あとはEnterキーを叩くだけ。これさえ出来れば、仕事はかなり快適に。

Delphiと力を合わせれば、そんなの1日でできるー!(・・・と、いつも思う)
かくしてOneDrive接続専用Browser作りが日曜日の朝、スタートしたのでした。

2.WebView4Delphiコンポーネントを使う

すぐに思い出したのは、(いつか、いじってみたい)と思っていたWebView4Delphiコンポーネント。

何かでその存在を知り、ダウンロードして、ちょっと触れてみたのは・・・確か、去年のことだった・・・と思いながら、2022年の作業を記録したフォルダ内をさがすとやっぱりあったWebView4Delphiコンポーネント。さっそく、これを今年の作業フォルダへコピーする。試しにdemoフォルダ内のプログラムを動かしてみると、問題なく動く。去年、ダウンロードした際に、コンポーネントのインストールまで行っていたようだ。

WebView4Delphiは、GitHub の salvadordf / WebView4Delphi からダウンロードできる。

salvadordf / WebView4Delphi

https://github.com/salvadordf/WebView4Delphi

Aboutには次の記述があり、

WebView4Delphi is an open source project created by Salvador Díaz Fau to embed Chromium-based browsers in applications made with Delphi or Lazarus/FPC for Windows.

https://github.com/salvadordf/WebView4Delphi

ライセンスはMITだから、利用にあたっては「著作権表示および許諾表示をソフトウェアのすべての複製または重要な部分に記載」すればOK! 面倒なことは一切ない。

MITライセンスの正しい著作権表示および許諾表示の入れ方を教えてくれるWebサイト様もある。なんと有難いことか。作成者の方に心から感謝。

40代からプログラミング!
MITライセンスとは?無料ツール・テンプレートの利用方法と注意点

https://biz.addisteria.com/mit-license-1/

さっそく、WebView4Delphi コンポーネントに添付されている

demos\Delphi_VCL\MiniBrowser

これをベースにして、OneDrive接続専用Browser作りを開始する。

まず、次のようにGUIを作成。メールアドレスの自動入力という「目的」に合わせてVCLコンポーネントを追加する。

「ダウンロードフォルダを開く」は、後で説明。実行時、その右側のチェックボックス「設定」をチェックすると「入力ボタン」より右側のコントロールが出現する。

で、FormCreate手続きに自分用のコードを追加。

implementation

{$R *.dfm}

uses
  uTextViewerForm,
  uWVCoreWebView2WebResourceResponseView, uWVCoreWebView2HttpResponseHeaders,
  uWVCoreWebView2HttpHeadersCollectionIterator,
  uWVCoreWebView2ProcessInfoCollection, uWVCoreWebView2ProcessInfo,
  uWVCoreWebView2Delegates,
  //以下を追加
  Vcl.Clipbrd,
  System.IniFiles, System.UITypes,
  Winapi.ShlObj, Winapi.KnownFolders,
  Winapi.ShellAPI;

procedure TMiniBrowserFrm.FormCreate(Sender: TObject);
var
  Ini: TIniFile;
  strID, strX, strY: String;
begin
  FGetHeaders             := True;
  FHeaders                := TStringList.Create;
  FFileStream             := nil;
  FUserAuthFrm            := nil;
  FResourceContents       := nil;
  FBlockImages            := False;
  FDownloadIDGen          := 0;
  FDownloadOperation      := nil;
  WVBrowser1.DefaultURL   := URLCbx.Text;

  //Formを最大化して表示
  MiniBrowserFrm.WindowState:=wsMaximized;

  //iniファイルの存在を確認
  if FileExists(ChangeFileExt(Application.ExeName, '.ini')) then
  begin
    //iniファイルからデータを読込み
    Ini := TIniFile.Create(ChangeFileExt(Application.ExeName, '.ini'));
    try
      strID:=Ini.ReadString('Section', 'ID', '');
      strX:=Ini.ReadString('Section', 'IchiX', '580');
      strY:=Ini.ReadString('Section', 'IchiY', '420');
    finally
      Ini.Free;
    end;
    //復号して表示(実際のプログラムではメールアドレスは暗号化処理している)
    Edit1.Text:=strID;
    EditX.Text:=strX;
    EditY.Text:=strY;
  end;

end;

あと、EXE の実行には、EXE のあるフォルダ内 に WebView2Loader.dll が必要。今回作成するのは64 ビット版の EXE だから bin64 フォルダ内の 64 ビット用DLL を使用しなければならない。

実際の動作の様子。実行時画面では・・・

「設定」にチェックすると・・・

ここにiniファイルから読み取ったサインイン用のID(メールアドレス)と、プログラムからクリックするアドレス入力欄の座標を表示(入力)する。

画面解像度が異なるPCでは、当然、フォーカスを当てたいサインイン用のメールアドレスを入力するフレームの表示位置が異なるから、最初にその位置座標を調べる必要がある。座標Checkにチェックを入れるとマウスポインタの現在位置のスクリーン座標が画面右上にリアルタイムで表示される仕組みだ。

マウスポインタが現在ある位置の座標を取得して表示するコードは・・・

procedure TMiniBrowserFrm.Timer2Timer(Sender: TObject);
var
  lh_Handle:  HWND;
  lpt_Pos:    TPoint;
  lrc_Rect:   TRect;
  lrg_Region: HRGN;
  li_Ret:     Integer;
begin
  if chkZahyo.Checked then
  begin
    //マウスカーソル位置をスクリーン座標で取得
    GetCursorPos(lpt_Pos);
    //自身のウィンドウリージョンを調べる
    lh_Handle := Self.Handle;

    //ウィンドウリージョン取得のため空のリージョンを作っておく
    lrg_Region := CreateRectRgn(0,0,0,0);
    try
      //ウィンドウリージョン取得
      li_Ret := GetWindowRgn(lh_Handle, lrg_Region);
      if (li_Ret <> ERROR) then begin
        //ウィンドウのRectを取得
        GetWindowRect(lh_Handle, lrc_Rect);
        //スクリーン座標からウィンドウの左上を原点とした座標に変換
        lpt_Pos.X := lpt_Pos.X - lrc_Rect.Left;
        lpt_Pos.Y := lpt_Pos.Y - lrc_Rect.Top;
        //ウィンドウリージョン内にマウスカーソルがあるかテスト
        if (PtInRegion(lrg_Region, lpt_Pos.X, lpt_Pos.Y)) then begin
          LabelXY.Caption:=Format('OK %d (%d-%d)', 
            [li_Ret, lpt_Pos.X, lpt_Pos.Y]);
        end else begin
          LabelXY.Caption:=Format('NG %d (%d-%d)', 
            [li_Ret, lpt_Pos.X, lpt_Pos.Y]);
        end;
      end else begin
        LabelXY.Caption:=Format('X:%d, Y:%d', [lpt_Pos.X, lpt_Pos.Y]);
      end;
    finally
      DeleteObject(lrg_Region);
    end;
  end;
end;

で、最適なクリックポイントのX座標とY座標を読み取り、Editに入力、保存ボタンをクリックでiniファイルに保存する。

クライアント座標でなく、スクリーン座標としたのは、話をカンタンにするため。だからFormCreate手続きでFormを最大化して表示するように設定している。こうすればどんな解像度のPCでも、画面左上からのスクリーン座標でメールアドレスを入力するフレームの位置が決定できると考えたのだ。

実は、最初は「他のアプリへ文字列を送信」する方法で、サインイン画面を狙い撃ちしようと思っていたのだが、ブラウザの入力欄は「ウィンドウではない」ようで、目印にするハンドルがなく、簡単には文字列を送信できないことがわかった。

朝5時くらいから作業を始めて、半日くらいイロイロ悩んだのだけれど、お昼過ぎにようやくサインインのフレームが表示されている位置をプログラムからクリックして、フォーカスを当て、さらにプログラムからCtrl+V(貼り付け)の操作を行い、iniファイルからクリップボードに読み込んでおいたメールアドレスを流し込めばイイと気づく。

そこで大きく方針を転換。文字列を送信ではなく、サインインのフレームをクリックしてメールアドレスを貼り付ける方向でGUIも、プログラムも準備した。

さらに「保存」ボタンは、メールアドレスとPCごとに異なるサインインのフレームの座標をiniファイルに書き込んで記録するために設置。

これで OneDrive接続専用Browser のGUIは完成。あとはプログラムを書くだけに。

※ 実際のプログラムでは、iniファイルにメールアドレスを保存する際に、さらにひと手間かけて暗号化、iniファイルから読みだす際に復号している。

3.プログラムでクリックを実行

普段、僕がいちばんよく利用するWebブラウザはFirefoxだ。インターネット黎明期、そう誰もが Netscape Navigator を使っていた頃からのファンなのだ。

プログラムを何度も動かして動作確認しているうちに、あることに気づく。それは何かと言うと、OneDriveのサインイン画面を表示した時の挙動が、Firefoxと作成中のOneDrive接続専用Browserではちょっと違うのだ。

FirefoxでOneDriveのサインイン画面を表示した場合は、メールアドレスを入力するフレームにセットフォーカスされた状態でサインイン画面が表示されるのに対し、作成中のOneDrive接続専用Browserではそうならない。しかも、アドレス入力欄を1回クリックしただけではダメ(セットフォーカスされない)で、2回クリックしないと入力待機状態にならない(1回めのクリックで、一瞬セットフォーカスされたように見えるが、その後、キャレットが消失してしまい、点滅状態にならない)。

DelphiのIDEから、Ctrl+F で「SetFocus」を検索キーワードにコード全体を確認しても、それは「見つからなかった」。なぜ、2回クリックしないと入力待機状態にならないのか、その原因はさっぱりわからない。

原因はわからなくても、とにかくそれが現実だから、Webにあった情報を頼りにプログラムからアドレス入力欄をクリックするコードを書いてみた。

動的にマウスをクリックするには?

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

予め、先に示したマウスカーソルの位置座標を調べるコードで、アドレス入力欄の座標を調査・記録しておいて、Formが完全に描画されたところでプログラムコードからアドレス入力欄をクリックする。

  private
    { Private 宣言 }
    //アドレス貼り付け実行の成否
    boolInput:boolean;
    //Formの表示終了イベントを取得
    procedure CMShowingChanged(var Msg:TMessage); message CM_SHOWINGCHANGED;

procedure TMiniBrowserFrm.CMShowingChanged(var Msg: TMessage);
var
  dwFlags : DWORD;
  X,Y : Integer;
  //LKeyByte : Byte;
begin
  inherited; {通常の CMShowingChagenedをまず実行}
  if Visible then
  begin
    Update; {完全に描画}
    fgWaitBreak:=False;
    WaitTime(1000);
    //クリップボードを初期化
    Clipboard.Clear;
    //文字列をクリップボードに格納
    Clipboard.AsText:=Edit1.Text;

    dwFlags:=MOUSEEVENTF_MOVE or MOUSEEVENTF_ABSOLUTE;
    X:=Trunc(StrToInt(EditX.Text)/Screen.Width*65537);
    Y:=Trunc(StrToInt(EditY.Text)/Screen.Height*65535);

    //移動
    Mouse_Event(dwFlags,X,Y,0,0);
    Application.ProcessMessages;

    //クリック
    Mouse_Event(MOUSEEVENTF_LEFTDOWN,0,0,0,0);
    Application.ProcessMessages;

    WaitTime(300);

    Mouse_Event(MOUSEEVENTF_LEFTDOWN,0,0,0,0);
    Application.ProcessMessages;

    Mouse_Event(MOUSEEVENTF_LEFTUP,0,0,0,0);

    // [Ctrl] + [V] のキー操作 -> btnCopyClick手続きで実行
    {
    LKeyByte := Ord('V');
    keybd_event(VK_CONTROL, MapVirtualKey(VK_CONTROL, 0), 0, 0);
    keybd_event(LKeyByte,   MapVirtualKey(LKeyByte, 0),   0, 0);
    keybd_event(LKeyByte,   MapVirtualKey(LKeyByte, 0),   KEYEVENTF_KEYUP, 0);
    keybd_event(VK_CONTROL, MapVirtualKey(VK_CONTROL, 0), KEYEVENTF_KEYUP, 0);
    }
    WaitTime(1500);
    btnCopyClick(nil);
    if boolInput then btnCopy.Enabled:=False;
  end;
end;

WaitTimeなる関数は、次のWebサイト様で紹介されていたものをそのまま使用。

待ち関数の必要性

https://gumina.sakura.ne.jp/CREATION/OLD/COLUMN/CD1MATI.htm
function TMiniBrowserFrm.WaitTime(const t: integer): Boolean;
var
  Timeout: TDateTime;
begin
  //待ち関数  指定カウントが経過すれば True, 中断されたならば False
  fgWaitBreak := False;
  Timeout := Now + t/24/3600/1000;
  while (Now < Timeout)and not fgWaitBreak do begin
    Application.ProcessMessages;
    Sleep(1);
  end;
  Result := not fgWaitBreak;
end;

WaitTime関数の引数の値を様々に変えて実行してみると、MyPCでは、上記の数値で確実にアドレス入力欄をプログラムコードからクリックすることに成功!

メールアドレスをCtrl+Vする部分は、GUIの「入力」ボタンと共用だからボタンのClick手続き側に記述して呼び出す(実行する)ことにする。これだと都合4回、アドレス入力欄をクリックすることになるが、単にクリックするだけだから2回でも、4回でも大差ないだろう。むしろ、確実に動かすための保険だと考え、このままにする。

procedure TMiniBrowserFrm.btnCopyClick(Sender: TObject);
var
  dwFlags : DWORD;
  X,Y : Integer;
  LKeyByte : Byte;
begin

  boolInput:=False;

  try

    //クリップボードを初期化
    Clipboard.Clear;
    //文字列をクリップボードに格納
    Clipboard.AsText:=Edit1.Text;

    dwFlags:=MOUSEEVENTF_MOVE or MOUSEEVENTF_ABSOLUTE;
    X:=Trunc(StrToInt(EditX.Text)/Screen.Width*65537);
    Y:=Trunc(StrToInt(EditY.Text)/Screen.Height*65535);

    //移動
    Mouse_Event(dwFlags,X,Y,0,0);
    //クリック
    Mouse_Event(MOUSEEVENTF_LEFTDOWN,0,0,0,0);
    //Mouse_Event(MOUSEEVENTF_LEFTUP,0,0,0,0);
    Application.ProcessMessages;
    WaitTime(500);
    Mouse_Event(MOUSEEVENTF_LEFTDOWN,0,0,0,0);
    Mouse_Event(MOUSEEVENTF_LEFTUP,0,0,0,0);
    Application.ProcessMessages;

    // [Ctrl] + [V] のキー操作
    LKeyByte := Ord('V');
    keybd_event(VK_CONTROL, MapVirtualKey(VK_CONTROL, 0), 0, 0);
    keybd_event(LKeyByte,   MapVirtualKey(LKeyByte, 0),   0, 0);
    keybd_event(LKeyByte,   MapVirtualKey(LKeyByte, 0),   KEYEVENTF_KEYUP, 0);
    keybd_event(VK_CONTROL, MapVirtualKey(VK_CONTROL, 0), KEYEVENTF_KEYUP, 0);

    boolInput:=True;

  except

    boolInput:=False;

  end;

end;

動作確認すると、ごく、たまーにメールアドレスが自動入力されないこともあるが、9割方予期した通りに動作してくれる。メールアドレスが自動入力されない場合でも、クリップボードにデータは間違いなく読み込まれているから、手動でCtrl+Vすればいいだけだ。

いちいち、メールアドレスを入力することに比べれば、Ctrl+VしてEnterキーを押す方がずっとカンタンだ。イイ感じになってきた。

4.特殊なフォルダを表示する

実際にファイルをOneDriveへ、アップロードしたり、ダウンロードしたり、動作確認を行ってみると、任意のタイミングでダウンロードフォルダを開く機能が欲しくなった。なので、これをボタンクリックで実行できるようにする。

ダウンロードフォルダができたのはWindows Vista以降のようで、この特殊なフォルダへのPathを取得するには、SHGetKnownFolderPath 関数を使うらしい。コードはいつもお世話になるMr.XRAYさんのWebサイトの記事を参考にして書く。

460_特殊フォルダのフルパスを取得

http://mrxray.on.coocan.jp/Delphi/plSamples/460_SpecialFolderPath.htm#08

GUIDの一覧は、次のWebサイトにあった。こちらの情報でダウンロードフォルダのGUIDがわかった。

ファイル ダイアログ ボックスのカスタム プレイス用既知のフォルダー GUID

https://learn.microsoft.com/ja-jp/dotnet/desktop/winforms/controls/known-folder-guids-for-file-dialog-custom-places?view=netframeworkdesktop-4.8

ボタンクリックで、ダウンロードフォルダを開く手続きは次の通り。

procedure TMiniBrowserFrm.btnOpenDLFolderClick(Sender: TObject);
var
  FolderID:TGUID;
  FolderPath:PChar;
  D_FolderPath, ExeFileName:string;
  LhInst:Cardinal;
begin
  //ダウンロードフォルダのGUIDを指定
  FolderID:=StringToGUID('{374DE290-123F-4565-9164-39C4925E467B}');
  if SHGetKnownFolderPath(FolderID,0,0,FolderPath)= S_OK then
  begin
    D_FolderPath := FolderPath;
    //ダウンロードフォルダを開く
    ExeFileName:= 'explorer.exe';
    LhInst:=ShellExecute(Handle, 'open', PChar(ExeFileName), 
      PChar(D_FolderPath), nil, SW_SHOW);
    if LhInst <= 32 then
    begin
      MessageBox(Handle, '起動に失敗しました.', '情報', MB_ICONINFORMATION);
    end;
  end;
end;

5.まとめ

WebView4Delphiコンポーネントのdemoにあるサンプル(MiniBrowser)に、以下の内容を追加したプログラムを使えばOneDrive自動サインインは可能。その手順は次の通り。

(1)プログラムコードでアドレス入力欄をクリックしてセットフォーカス。
(2)クリップボードに送ったメールアドレスを、プログラムコードでCtrl+V。
(3)Enterキー押し下げでOneDriveへ接続。データは即送受信可能になる。

さらに、SHGetKnownFolderPath 関数でダウンロードフォルダへのPathを取得して、エクスプローラで表示すればより一層便利に使えそう。

6.お願いとお断り

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

面談日程表の自動作成方法

Googleカレンダーを利用して、三者面談の日程表を自動的に作成する方法を学んだ。

【Gooleカレンダーとフォーム】全自動・三者面談の日程調整をつくる

https://www.fy1203.com/2020/03/21/calendar-form/

やりたいこと、そのものスバリの情報に大感謝!
ただ、残念なことに一部、情報が古くなってしまっており、そこでちょっと試行錯誤があったので、最新の情報を含めるカタチで、作成方法をここにメモ。

【今回の記事】

1.専用カレンダーの作成
2.入力フォームの準備
3.スプレッドシートを準備する
4.スクリプトを用意する
5.トリガーを設定する
6.プレビューで動作確認
7.まとめ
8.お願いとお断り

1.専用カレンダーの作成

(1)Googleアカウントがなければ作成。

(2)Googleのトップページの右上のGoogleアプリから、「カレンダー」をクリック

(3)カレンダーが開くので、左下の他のカレンダーを追加「+」をクリック

(4)「新しいカレンダーを作成」をクリック

(5)カレンダーの名前と説明を決めて、「カレンダーを作成」をクリック

2.入力フォームの準備

(1)次は入力フォームです。まず、フォルダを作成しておきます。

(2)Googleドライブの左上の「新規」をクリックして、サブメニューを表示します。

(3)「新しいフォルダ」をクリックして、

(4)フォルダに最適な名前を付けます。この場合、「三者面談」としました。

(5)そのフォルダに入り、「新規」→「Googleフォーム」と順にクリックします。

(6)新しいフォームの作成画面が開きます。

(7)このGoogleフォームで、入力フォームを作成します。タイトルと説明を入力し、画面中央右の設定ボタンをクリックします。

(8)私は、次のように設定してみました(誤りがあるかもしれません)。

(9)設定が終了したら、質問タブをクリック して、画面を切り替えます。

(10)1問目の出席番号は、「プルダウン」から選択するように設定しました。

(11)入力を「必須」にするため、状態を「ON」にします。

(12)質問を追加します。画面右上の 〇囲みの+マークをクリック します。

(13)2つめの問は名前です。次のように「記述式」にして、「必須」は「ON」の状態にします。終わったら、右の 〇囲みの+マークをクリック します。

(14)3つめの問は予約日です。次のように入力します(日付は適当です)。

(15)メニューから「年を含める」のチェックを外します(年を含めるをクリックすれば、チェックが外れます)。

(16)これで入力が「月、日」だけになりました。さらに・・・

(17)4つめの問は開始時間です。「時刻」にして、「必須」は「ON」の状態にします。

これで、フォームの作成は終了です。

3.スプレッドシートを準備する

(1)次に、回答を整理するスプレッドシートを用意します。画面上の「回答」→「スプレッドシートにリンク」をクリックします。

(2)下の画面が表示されるので、次のようにタイトルを入力して「作成」をクリック。

これで、スプレッドシートが作成されます。

4.スクリプトを用意する

(1)画面の表示が「スプレッドシートにリンク」から「スプレッドシートで表示」に変わっていることを確認して、「スプレッドシートで表示」をクリックします。

(2)新しいページにスプレッドシートが表示されます。ここに必要なスクリプトを追加します。

※ 追加するスクリプトは次のWebサイト様で紹介されているものです。

【Gooleカレンダーとフォーム】全自動・三者面談の日程調整をつくる

https://www.fy1203.com/2020/03/21/calendar-form/

(3)スプレッドシートの画面上から、「拡張機能」をクリックして、表示されるサブメニューにある「App Script」をクリックします。(2023年6月現在の操作方法です。ここが上記Webサイト様の記事と異なります。いつの間にか、現在の形式へと変更されたようです。)

(4)新しいページが開き、次の画面が表示されます。

(5)クッキーの使用を許可します。

(6)「無題のプロジェクト」をクリックして、プロジェクト名を「1年A組面談希望調査」に変更して、下の「名前の変更」をクリックします。

(7)次のようなスクリプトの入力画面が表示されます。

(8)次の引用リンク先のWebサイト様で紹介されているスクリプトを1行目から範囲選択して、クリップボードにコピーし、入力画面に(既存のテキスト全体を選択しておいて)上書きします(関数の名前がMyFunctionからsendToCalendarになり、引数(ひきすう)も「空」でなく「e」になります)。

【Gooleカレンダーとフォーム】全自動・三者面談の日程調整をつくる

https://www.fy1203.com/2020/03/21/calendar-form/

(9)コピペしたスクリプトを3か所「確認」または「変更」します。

【その1】

・4行目のYear指定を確認してください。必要であれば、現在の西暦年に変更します。

【その2】

16行目「カレンダーID」を変更します。

最初のカレンダーの画面を開き、画面左の「1年A組三者面談」の右にある「…」をクリックします。

Googleのトップページの右上から、「カレンダー」をクリック。

画面の左下にある「1年A組三者面談」をポイントすると表示される縦の「・・・」をクリックします。

表示されるサブメニューから「設定と共有」をクリックします。

「カレンダーの設定」画面が表示されます。下へスクロールすると、かなり下の方にカレンダーIDがあります。これをコピーします。

コピーしたIDをスクリプトの16行目に貼り付けます。

※ 「***カレンダーIDを入力***」部分とカレンダーIDを入れ替えます。

【その3】

31行目の面談時間を「確認」または「変更」します。

以上でスクリプトの確認と変更は完了です。

5.トリガーを設定する

(1)スクリプトが自動的に実行されるように設定します。画面右にある<>部分をポイントすると、時計のマークの「トリガー」が現れるので、この「トリガー」をクリックします。

(2)トリガー設定画面が開くので、画面右下にある「トリガーを追加」をクリックします。

(3)イベントの種類を「フォーム送信時」に設定して、「保存」をクリックします。

※ この画面で「実行する関数を選択」が空欄になっている場合は、スクリプトのコピペが1行目を含んだ形で(正しく)行われているかどうか、また、スクリプトが保存されているかどうか、確認してください。

(4)次のエラーメッセージが表示された場合は、ブラウザのポップアップブロック(別Windowを自動的に開かない設定がおそらくデフォルトになっている?)を、このトリガー設定ページからの保存操作であれば解除されるように設定してください(操作方法はブラウザにより異なります)。

(5)紐づけるアカウントをクリックします。

(6)My環境では、Googleからの確認画面が表示されました。画面左下の「Advanced(詳細?)」をクリックします。

(7)(必要であれば、画面を下にスクロールして)「unsafe:安全ではない」と表示されている「1年A組面談希望調査」へのリンクをクリックします。

(8)次の画面が表示されます。

Allow(許可)をクリックします。これで入力フォームからGoogleカレンダーへデータが送信されたときに、自動的にスクリプトが実行されるようになります。

6.プレビューで動作確認

(1)画面右上の「プレビュー(目のマーク)」をクリックします。

(2)入力フォームに必要事項を入力して、「送信」ボタンをクリックします。

(3)送信されたメールの内容を確認します。

(4)Googleカレンダーに予約状況が表示されます。

(5)カレンダーに予約状況が表示されない時は、トリガーの設定が正しく保存されているか、どうか、確認してください。(私が最初にテストした際は、Googleカレンダーに予約状況が表示されませんでした。ちょっと焦りましたが、手順を一つひとつ確認して行く中で、トリガーの設定が正しく保存されていなかったことに気づき、この部分の設定作業をやり直したところ、無事、予約状況がGoogleカレンダーに表示されるようになりました。)

7.まとめ

(1)Googleカレンダーを使えば、三者面談自動予約システムは作成可能。
(2)Webには多くの情報があるが、機能の更新が頻繁にあるので時々見直す必要あり?
(3)最終的な面談時刻の確認は、別の連絡手段で確実に行う必要がある。

8.お願いとお断り

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

Reorganization

「再編」

そこに、1台のNASがあった。

時とともに アクセスする人も変わり
記録方法も 場所も いつしか ランダムに・・・

参照したい「データ」 それが「どこ」にあるのか
わかりづらくなってしまった NAS・・・

エントロピーは 必ず 増大するから
長期間に及ぶ運用の結果として 「生まれた」 この状況は
理論的には 正しい ・・・ のかもしれない・・・。

ただ・・・ あの日、確かに・・・

なんとか 出来ないのか?

叫ぶような 声を聞いた。
それは 僕に向けての 声ではなかったけれど・・・

僕は 応えようと 思った。
これまでに培ったネットワークドライブ接続に関する知識。

そのすべてを 賭けて。

【目次】

1.ネットワークドライブ
2.システムエラー1219
3.アカウントに読み書き権限を付与
4.まとめ
5.お願いとお断り

1.ネットワークドライブ

かつて、別セグメントにあるファイルサーバに接続して、その共有フォルダをネットワークドライブとして、マイコンピュータに表示するプログラムを書いたことがあった。

そうだ。あのセグメント越えのプログラムを書いたのは・・・ もう10年以上、前のことだ。

あの時は、ファイルサーバを別セグメントに用意する必要があったけれど。
今、共有フォルダを再構成したいNASは、みんながログオンするセグメント上にある・・・。
なんとか・・・しなければならない敷居は、10年前より、ずっと低い。

もちろん、自分的にいちばん、イイのは・・・ 新しいファイルサーバを用意して、現在のファイル共有の仕組みそのものをイチから作り直すこと、なんだけれど。

でも、それは無理だ。今年度、そんな予算は1円だって計上されてない。
今の環境と機材の中で、なんとかするしか、ない。

今あるNASをなんとか創意工夫して、運用するしか、ないのだ。

( 今のファイル共有を維持したまま、新しい共有環境を作るには、どうしたらいい? )

自問自答を繰り返す。
無理だという前提は一切排除する。
あきらめない限り・・・何とか出来る方法が、必ずあるはずだ。
僕は毎日、あらゆる意味で、いちばんイイ方法を考え続けた。

( 再編する共有フォルダのみ ネットワークドライブとして表示すれば いい )

( 再編に必要なフォルダは 予め用意して・・・ )

( そこへ必要なファイルだけを移動させるんだ・・・ )

朝、出勤途中、クルマを運転しながら、そんな考えが浮かんだ。
ようやく答えに繋がるヒントが見えた・・・ 気がした。

職場に着いた僕は、NASを管理するDSMを起動してみた。前の職場では、自前でサーバ機を用意して、アクティブディレクトリを使ったファイル共有の仕組みを作っていたが、それがたまらなく懐かしくなる・・・。

おいおい アクティブディレクトリ・・・ きみは嫌いだったんじゃ ないか?

自分の気持ちの変化に驚きながら、管理画面を見ていて( あれ? )って思った。

( リサイクルフォルダがものすごい容量を喰ってる・・・ )

・・・ってことは、このNAS自体のバックアップの仕組みがどうなっているのか、それはわからないけれど、とにかくNAS全体でゴミ箱の設定が有効になってるわけだ。よくよく見ると一般ユーザーはそこにアクセスできないけれど、管理者にはそれができるようだ・・・。もしかして、バックアップの代わりに、ゴミ箱を有効化してる・・・?

それなら、話は簡単だ。
バックアップ用の媒体を別に用意して、そちらに日々のバックアップをとり、リサイクルフォルダを使わない設定に変更すればいい。これでNASの空き容量を増やせるはずだ。

そうだ。どこかにバックアップさえ、きちんと作れたら・・・
ファイルサーバを新しく用意しなくても、なんとか、なるんじゃないか?

そう思った瞬間、この問題の完全な解決方法が「見えた」気がした。

リサイクルフォルダを使わない設定にすれば、今より確実に空き容量は増える。で、NASのルートに、これまで使われていない「共有」って名前のフォルダを新しく作り、その下に、クライアントPCのマイコンピュータにネットワークドライブとして表示する新しいフォルダ群を用意して、必要なフォルダとファイルだけ、そこに「コピーではなく、移動」すれば、現在のファイル共有環境を生かしたまま、新しいファイル共有環境を同じNASの中に再構成することが出来るんじゃないか・・・。

お金と手間をかければ、もっといい方法もあるのかもしれないが、それが無理な現状ではおそらく、これがベストに近い解決策なんじゃないか? ・・・そう思えてきた。

で、バックアップは・・・ どこへ とればいい?

取り敢えず、僕の手元には、前任者から引き継いだ、用途を限定せずに利用できる空き容量1TBのSSDがある。これに最も重要なデータのバックアップをとろう。で、事務方の責任者に必要十分な容量のバックアップメディアがどうしても必要なことを説明して、理解が得られたら、速やかに可能な限り大容量のバックアップ用HDDを購入してもらおう。

それから、万一の火災等の事故への対応も考えなければならない。
バックアップのバックアップは、どこに、作ればいい?

僕は、先日、複合機のスキャナーでスキャンした画像データを出力する設定を行ったばかりの・・・ 別の部署にあるNASにかなりの空き容量があったことを思い出した。

新しく再編する共有フォルダだけをバックアップするなら、あのNASを利用すればなんとかなるんじゃないか?

これで、だいたいの見通しが立った。あとはやるだけだ。

まず、バックアップ(のバックアップ)用途に使いたいNASの保管場所を、安全な場所に変えなければならない。現在でも夜間はアラームのかかる部屋に、そのNASは設置されているのだが、これを24時間、施錠された部屋に移設することにした。

幸いにしてバックアップ(のバックアップ)用途に使いたいNASが設置されている部屋は、最初から情報処理用途に準備された部屋なので、床下がネットワークの配線に使える。

床の四角いカーペットを剥いで、その下の床板を外し、LEDライトを片手に、24時間施錠された小部屋までの経路を探ってみる。

床下に障害となるような構造物はない。なんとか、なりそうだ。竹製の1m物差しを何本も用意して、床下に差し入れ、LANケーブルを物差しの先端に養生テープで固定して、鍵のかかる小部屋へ向けて1本、2本とそれを養生テープで繋ぎ、少しずつ、慎重にLANケーブルを送る。

無事、LANケーブルは小部屋へ到達。
なんで汗まみれになるのか、知らないが。

バックアップ用のNASのユーティリティの起動方法がわからないので、覚悟を決めて、NASの電源ボタンを長押し。するとWebの解説にあった通り、Beep音が鳴ってNASはシャットダウンされた。

このNASには、もう一つ、何か別用途でのNASが接続されていた。シャットダウン時、果たしてどうなるか心配したけれど、そちらも同時に電源が切れた。なんだか、わからないけれど、取り敢えず二つのNASの電源は連動しているようだ。

NASに繋がっている電源とLANケーブルを全て外し、二つのNASをこの上なく大切に抱えて、24時間施錠された小部屋へ移設する。

鍵のかかる小部屋の床には、幸いにして電源コンセントが用意されていた。部屋の状況から判断しておそらく、以前はここにサーバ機が置かれていたのだろう・・・。

雷対策が施された電源の延長コードをコンセントに差し込んで、これにNASの電源ケーブルを繋ぐ。

続けてLANケーブルも接続。
接続状態に問題がないことを何度も確認し、祈るような気持ちで、NASの電源スイッチをONにする。

何事もなかったかのように、2台のNASが無事、再起動した。
これでハードウェアの準備はOKだ。

次は、NASの共有フォルダをネットワークドライブとして各クライアントPCのマイコンピュータに表示する、オリジナルプログラムを用意しなくてはならない。

僕は以前にDelphiで書いたネットワークドライブ接続のプログラムをバックアップ用のHDDから探し出し、プロジェクトを My PC のデスクトップにコピーした。

Delphiを起動すると、十数年前に作った、懐かしいGUIが現れた。

現在の状況に合わせて、必要な部分を書き換える。
ユーザーが自分の自由意思で、メイン画面からスタートアップに登録できるようにする。
あと、IDとPasswordも暗号化してイニシャライズファイルに保存。プログラム起動時に自動的に読み込んで表示するように設定を変更。

このNASの共有フォルダをネットワークドライブとして表示するプログラムの「接続」ボタンにたどり着くためには、ユーザーは生体認証とPIN入力の2段階認証を潜り抜ける必要があるから、この設定で、セキュリティ的に問題はないはずだ。

ネットワークドライブ接続のインターフェイス

このプログラムを書いた時は、別セグメントにあるファイルサーバに接続できるよう、確か、RouteADDコマンドを使ってクライアント機のルーティングテーブルを書き換えたんだ・・・。で、その際、設定の変更を残す(=記録する)pオプションはわざと指定せずに、シャットダウンすれば自動的に設定が、ルーティングテーブルを書き換える前の状態に戻るようにしたんだ。

このRouteADDコマンドを実行するBATファイルを、プログラム内部で生成して動かすところがすごく難しかったんだ・・・。RunAsAdmin・・・ 確か・・・夏に思い立って、プログラムが完成したのは秋が深まったころだった。

今回、接続したいNASは同じセグメント内にあるから、PCのルーティングテーブルを書き換える必要はない。NASの共有フォルダをクライアント機のマイコンピュータにネットワークドライブとして表示するだけだから、その手続きはより簡単に済む。

あの時、表示する共有フォルダは、「個人フォルダ」・「校内共有」・「校務分掌」・「教科」に設定した気がする。今回、個人フォルダはどうするか・・・?

NASの設定を調べてみるとラッキーなことに、新しくユーザーアカウントを作るとhomesフォルダにそのユーザー専用のホームフォルダが用意されることがわかった。試しに「test-u」というアカウントを作成してみると、homesフォルダに「test-uフォルダ」が確かに出来ている。これを「個人フォルダ」ネットワークドライブとして表示すればいい。

NASのルートには「共有」という名前のフォルダはなかったので、早速それを作成し、その下に「校内共有」・「校務分掌」・「教科」の各フォルダを準備する。あとは接続プログラム側で各共有フォルダへのPathを接続情報として指定すればOKのはずだ。

この画面を表示するにはパスワードが必要

DSMで確認したら、NASのドメイン名は指定されていなかった。koumu.localとでも設定しようかと思ったが、現状の変更を最小限に留めておくことにし、やめておくことにした。

ネットワークドライブを設定して表示するプログラムは次の通り。

procedure TForm1.Button1Click(Sender: TObject); // ネットワークドライブの接続
var
  PW,ID:string;
  //iniファイル読込み
  Ini: TIniFile;
  strW,StrX,StrY,strZ:String;
  strDomainName:String;
  //ネットワークドライブ名変更
  X,D:Variant;
  InfoStr1,InfoStr2,InfoStr3:string;
  //スタートアップに登録
  MyObject : IUnknown;
  MySLink  : IShellLink; // ShlObj
  MyPFile  : IPersistFile; // ActiveX
  Directory : String;
  WFileName : WideString;

const
    MyRegFile : string = 'Software\Microsoft\Windows\CurrentVersion\Explorer';
    MyMessage : string = 'スタートアップに登録しますか?';
    MyFolders : string = 'Startup';

begin

  //接続先ドメイン名を取得
  //iniファイル読込み
  Ini:=TIniFile.Create(ChangeFileExt(Application.ExeName, '.ini'));
  try
    //実際にはマスターパスワードを暗号化文字列から復元している
    MasterPassW:='XXXXXXX';
    //テキスト暗号化設定情報を読込み
    strDomainName:=暗号化文字列から複合する関数(Ini.ReadString('セクション', 'DomainName', 'デフォルト値'),
      MasterPassW, False);
  finally
    Ini.Free;
  end;

  //【準備作業】Password,IDを確認して変数へ取得
  try
    //カーソルを待機状態に変更
    Screen.Cursor:=crHourGlass;

    //UserName(ID)確認
    if Edit1.Text='' then
    begin
      MessageDlg('IDが無効です!', mtInformation, [mbOk] , 0);
      Edit1.SetFocus;
      Exit;
    end else begin
      //有効なドメイン名がある場合
      //ID:=strDomainName+'\'+JTrim(Edit1.Text);
      //ドメイン名がない場合
      ID:=JTrim(Edit1.Text);
    end;

    //パスワードを確認
    if Edit2.Text='' then
    begin
      MessageDlg('パスワードが無効です!', mtInformation, [mbOk] , 0);
      Edit2.SetFocus;
      Exit;
    end else begin
      PW:=JTrim(Edit2.Text);
    end;
  finally
    Screen.Cursor:=crDefault;
  end;

  //【第1段階】ルーティング情報の設定を実行

  // 今回接続するNASは同じセグメントにあるので
  //ルーティングテーブルの書き換えは不要

  //【第2段階】ネットワークドライブを追加

  //カーソルを待機状態に変更
  Screen.Cursor:=crHourGlass;

  //ドライブ設定情報読込み
  //iniファイル読込み
  Ini:=TIniFile.Create(ChangeFileExt(Application.ExeName, '.ini'));
  try
    //マスターパスワード
    MasterPassW:='XXXXXXX';
    //ShowMessage(MasterPassW);
    //テキスト暗号化設定情報を読込み
    strW:=暗号化文字列から複合する関数(Ini.ReadString('セクション', 'W_Drive', 'デフォルト値'), MasterPassW, False)+JTrim(Edit1.Text);
    strX:=暗号化文字列から複合する関数(Ini.ReadString('セクション', 'X_Drive', 'デフォルト値'), MasterPassW, False);
    strY:=暗号化文字列から複合する関数(Ini.ReadString('セクション', 'Y_Drive', 'デフォルト値'), MasterPassW, False);

    if ComboBox1.Text<>'' then
    begin
      //ComboBox1が空欄でなければ教科のドライブにも接続
      strZ:=EDText(Ini.ReadString('セクション', 'Z_Drive', 'デフォルト値'),
        MasterPassW, False)+ComboBox1.Text;
    end else begin
      StrZ:='';
    end;
  finally
    Ini.Free;
  end;

  //ネットワークドライブを切断
  NetDel;

  //ネットワークドライブ接続確認用変数を初期化
  NetDrvError:=False;

  try

    //Userへの通知
    ProgressBar1.Visible:=True;
    if ComboBox1.Text<>'' then
    begin
      ProgressBar1.Max:=4;
    end else begin
      ProgressBar1.Max:=3;
    end;
    ProgressBar1.Position:=0;

    //ネットワークドライブ(個人フォルダ)を追加
    AddNetworkDrive('W:', strW, '', PW, ID);
    if not (NetDrvError) then
    begin
      //ネットワークドライブ名を変更
      X:=CreateOleObject('Shell.Application');
      D:=X.NameSpace('W:\');
      D.Items.Item.Name:='個人フォルダ';
      ProgressBar1.Position:=ProgressBar1.Position+1;
    end else begin
      NetDrvError:=False;
      MessageDlg('Error:個人フォルダに接続できません!', mtError, [mbOk] , 0);
      Exit;
    end;

    //ネットワークドライブ(校内共有)を追加
    AddNetworkDrive('X:', strX, '', PW, ID);
    if not (NetDrvError) then
    begin
      //ネットワークドライブ名を変更
      X:=CreateOleObject('Shell.Application');
      D:=X.NameSpace('X:\');
      D.Items.Item.Name:='校内共有';
      ProgressBar1.Position:=ProgressBar1.Position+1;
    end else begin
      NetDrvError:=False;
      MessageDlg('Error:校内共有に接続できません!', mtError, [mbOk] , 0);
      Exit;
    end;

    //ネットワークドライブ(校務分掌)を追加
    AddNetworkDrive('Y:', strY, '', PW, ID);
    if not (NetDrvError) then
    begin
      //ネットワークドライブ名を変更
      X:=CreateOleObject('Shell.Application');
      D:=X.NameSpace('Y:\');
      D.Items.Item.Name:='校務分掌';
      ProgressBar1.Position:=ProgressBar1.Position+1;
    end else begin
      NetDrvError:=False;
      MessageDlg('Error:校務分掌に接続できません!', mtError, [mbOk] , 0);
      Exit;
    end;

    //ネットワークドライブ(教科)を追加
    if ComboBox1.Text<>'' then
    begin
      AddNetworkDrive('Z:', strZ, '', PW, ID);
      if not (NetDrvError) then
      begin
        //ネットワークドライブ名を変更
        X:=CreateOleObject('Shell.Application');
        D:=X.NameSpace('Z:\');
        D.Items.Item.Name:=ComboBox1.Text;
        ProgressBar1.Position:=ProgressBar1.Position+1;
      end else begin
        NetDrvError:=False;
        MessageDlg('Error:'+ComboBox1.Text+
          'フォルダに接続できません!', mtError, [mbOk] , 0);
        Exit;
      end;
    end;

    if not (NetDrvError) then
    begin
      //接続ボタンを使用不可に設定
      Button1.Enabled:=False;
      //接続状態の表示を設定
      Label4.Caption:='状態:接続中';
      Label4.Transparent:=False;
      Label4.Color:=clLime;
    end;

  finally
    Screen.Cursor:=crDefault;
    ProgressBar1.Position:=0;
    ProgressBar1.Visible:=False;
  end;

  //【第3段階】最終処理

  //コンピュータを開く
  ShellExecute(Handle, 'Open','EXPLORER.EXE','::{20D04FE0-3AEA-1069-A2D8-08002B30309D}','',SW_SHOW);

  //タスクトレイへ常駐する手続きを呼び出し
  MovetoTasktray;

  //カーソルを元の状態に変更
  Screen.Cursor:=crDefault;

  Ini := TIniFile.Create(ChangeFileExt(Application.ExeName, '.ini'));
  try
    //設定情報を初期化
    InfoStr1:='';
    InfoStr2:='';
    InfoStr3:='';
    //テキスト暗号化
    InfoStr1:=暗号化文字列を作成する関数(Edit1.Text,Unit1.MasterPassW, True);
    InfoStr2:=暗号化文字列を作成する関数(Edit2.Text,Unit1.MasterPassW, True);
    InfoStr3:=暗号化文字列を作成する関数(ComboBox1.Text,Unit1.MasterPassW, True);
    //ネットワークドライブ設定情報
    //iniファイルに保存
    Ini.WriteString('セクション', 'UserID', InfoStr1);
    Ini.WriteString('セクション', 'UserPW', InfoStr2);
    Ini.WriteString('セクション', 'UserSubject', InfoStr3);
  finally
    Ini.Free;
  end;

  //スタートアップに登録
  {
  if Application.MessageBox(PChar(MyMessage),'確認',
    MB_YesNo + MB_IconQuestion) = IdNo then exit; // Noなら何もしないで終わり
  }

  if chkStartup.Checked then
  begin
    //Yesなら
    MyObject := CreateComObject(CLSID_ShellLink);
    MySLink  := MyObject as IShellLink;
    MyPFile  := MyObject as IPersistFile;
    MySLink.SetPath(PChar(Application.ExeName));
    with TRegIniFile.Create(MyRegFile) do
    try
      Directory := ReadString('Shell Folders',MyFolders,'') + '\';
      WFileName := Directory + Application.Title + '.Lnk';
      MyPFile.Save(PWChar(WFileName),False);
    finally
      Free;
    end;
  end;

end;

ネットワークドライブの割り当て手続きは次の通り。

procedure AddNetworkDrive(Drive, UNC, Comment, Password, UserName: string);
var
  NetResource: TNetResource;
{$IFDEF UNICODE}
  user, pass: PWideChar;
{$ELSE}
  user, pass: PChar;
{$ENDIF}
begin
{$IFDEF UNICODE}
	with NetResource do
  begin
  	dwType := RESOURCETYPE_DISK;
    lpLocalName := PWideChar(Drive);
    lpRemoteName := PWideChar(UNC);
    lpComment := PWideChar(Comment);
    lpProvider := nil;
  end;
  if (Password = '') then
    pass := nil
  else
    pass := PWideChar(password);
  if (UserName = '') then
    user := nil
  else
    user := PWideChar(UserName);
{$ELSE}
	with NetResource do
  begin
  	dwType := RESOURCETYPE_DISK;
    lpLocalName := PChar(Drive);
    lpRemoteName := PChar(UNC);
    lpComment := PChar(Comment);
    lpProvider := nil;
  end;
  if (Password = '') then
    pass := nil
  else
    pass := PChar(password);
  if (UserName = '') then
    user := nil
  else
    user := PChar(UserName);
{$ENDIF}
  if (WNetAddConnection2(NetResource, pass, user, 0) <> NO_ERROR) then
  begin
    //エラー発生時の処理
  	NetErrorProc(GetLastError);
    NetDrvError:=True;
  end else begin
    //エラーが発生しなかった場合の処理
    NetDrvError:=False;
  end;
end;

ネットワークドライブの接続解除手続きは次の通り。

procedure RemoveNetworkDrive(Drive: string);
begin
  if (Drive = '') then exit;
{$IFDEF UNICODE}
	WNetCancelConnection2(PWideChar(Drive), 0, true);
{$ELSE}
	WNetCancelConnection2(PChar(Drive), 0, false);
{$ENDIF}
end;

ネットワークドライブを切断する手続きは次の通り。

procedure TForm1.NetDel;
begin
  //Network Driveを切断
  RemoveNetworkDrive('W:');
  RemoveNetworkDrive('X:');
  RemoveNetworkDrive('Y:');
  RemoveNetworkDrive('Z:');
end;

プログラムをコンパイルして出来たexeをクラウド経由で支給されたノートPCへ送った僕は、プログラムを起動して、必要事項を入力し、祈るような気持ちで「接続」ボタンをクリックした・・・。

テスト用のユーザーアカウントを作成して接続実験を行った

2.システムエラー 1219

こんなエラーメッセージが表示された(記憶を頼りにエラーを再現した画像)

最初から上手く行くとは思っていなかったけど、やはりエラーは心に痛い。しかも、数字しか表示されてないから、接続プログラム内に僕が予め用意したエラーの通知文にはないエラーだ。

procedure NetErrorProc(err: DWORD);
var
  s: String;
begin
  case err of
    ERROR_ACCESS_DENIED:  s := ERR_ACCESS_DENIED;
    ERROR_ALREADY_ASSIGNED:  s := ERR_ALREADY_ASSIGNED;
    ERROR_BAD_DEV_TYPE:  s := ERR_BAD_DEV_TYPE;
    ERROR_BAD_NET_NAME:  s := ERR_BAD_NET_NAME;
    ERROR_BAD_PROFILE:  s := ERR_BAD_PROFILE;
    ERROR_BAD_PROVIDER:  s := ERR_BAD_PROVIDER;
    ERROR_BUSY:  s := ERR_BUSY;
    ERROR_CANCELLED:  s := ERR_CANCELLED;
    ERROR_CANNOT_OPEN_PROFILE:  s := ERR_CANNOT_OPEN_PROFILE;
    ERROR_DEVICE_ALREADY_REMEMBERED:  s := ERR_DEVICE_ALREADY_REMEMBERED;
    ERROR_EXTENDED_ERROR:  s := ERR_EXTENDED_ERROR;
    ERROR_INVALID_PASSWORD:  s := ERR_INVALID_PASSWORD;
    ERROR_NO_NET_OR_BAD_PATH:  s := ERR_NO_NET_OR_BAD_PATH;
    ERROR_NO_NETWORK:  s := ERR_NO_NETWORK;
    //次の行はエラーメッセージから調べて追加
    53:               s := ERROR_BAD_NETPATH;
    1200:             s := ERROR_BAD_DEVICE;
    2202:             s := NERR_BadUsername;
  else
    s := IntToStr(err);
  end;
  MessageDlg(s, mtError, [mbOk], 0);
end;

Google先生にお伺いをたてると・・・

「システム エラー 1219 同じユーザーによる、サーバーまたは共有リソースへの複数のユーザー名での複数の接続は許可されません。」とのこと。

なんのこっちゃ? と思ったが、さらに調べてみると「Windows資格情報」が既に登録されているとこのエラーが発生するらしいことがわかった。そこで「コントロール パネル ⇨ ユーザー アカウント ⇨ 資格情報マネージャー」の順に辿って、Windows資格情報を確認するとNASのIPアドレスとともに、僕のIDとパスワードが登録されていた。いちばん最初にNASに接続した際に自動的に登録されたらしい。これを取り敢えず、削除してみる。

Windows資格情報を初期化

で、ID:test-uで再チャレンジするが「システムエラー1219」が再度表示され、NASの共有フォルダはネットワークドライブとして表示されない。

実在する一般ユーザーのアカウント設定を参照して作ったテスト用ユーザーだから、設定に間違いがあるとは思えないのだが・・・。

ふと、思い立って(=揮発性メモリにWindows資格情報が残っているためかと考えた、ここでいったんPCを再起動すれば、古いWindows資格情報は消えるはず・・・)Myアカウントで試してみる。僕のアカウントは管理者用のアカウントで何でもできるから、テストには不向きと考え、敢えて使わなかったのだ。

Myアカウントを入力して、接続テストを実行。すると・・・

無事、NASの共有フォルダへの接続に成功!
期待通りにネットワークドライブとして表示できたが、空き容量表示の色が「赤」なのが痛々しい・・・。

3.アカウントに読み書き権限を付与

Myアカウントなら繋がることはわかったので、ひと安心したが、なぜ一般ユーザーアカウントで繋がらないのかがわからない。まさか、全ユーザーのアカウント設定を管理者に昇格させるわけにも行かず(入れないフォルダがあるわけではないので、それでも運用上は特に問題は起こらないと思うのだが)、何としてもその原因を確かめないといけない。

DSMを起動して、ユーザーを選択し、「編集」⇨「権限」でMyアカウントと一般ユーザーアカウントの違いを見比べてみる。違いは一目瞭然。Myアカウントには「homes」と「共有」に「読込み/書込み」があるが、test-uアカウントにはそれがない・・・。

これかー!!

DSM ⇨ コントロールパネル ⇨ グループ と辿って、何か適当なグループはないか検討してみると、職員全員と説明のあるグループを発見。さっそくこれを編集して、「homes」と「共有」に「読込み/書込み」権限を付与(設定)する。

続けて、test-uアカウントに「職員全部」が所属するグループを追加する。

これでtest-uアカウントは「homes」と「共有」各フォルダに対する読み書きが可能に。

今度は、test-uアカウントでも無事接続でき、NASの共有フォルダがネットワークドライブとして表示された。やった。目標を実現できた!

あとは、このプログラムを含むネットワーク環境改善案を全体に提案して共通理解を持ち、共有資産を再編すればいい。

クライアントPCの数は100に満たない。これくらいなら僕ひとりで接続プログラムの導入と設定は十分可能だ。

接続プログラムを動かして、ネットワークドライブが見えている状態であれば、Windows資格情報が消されていても、揮発性メモリには接続先のIPアドレスだけでなく、ユーザーIDとパスワードも書き込まれるらしく、クライアントPCのデスクトップ上にあるNASへのショートカットも機能することがわかった。

作業の途中、NASのルートはネットワークドライブに指定できない(何らかの共有フォルダを指定しなければならない)という事実を初めて知り「愕然」とする瞬間もあったが、ネットワークドライブ接続後にデスクトップにあるNASへのショートカットが機能すれば何の問題もない。共有資産の再編作業は滞りなく実行できるはずだ。

クライアントPC1機を複数人で運用するのであれば、Windows資格情報を残したままだと他の人が接続しようとした時に「システムエラー1219」が発生するのは間違いないが、現状一人一台の生体認証でログインするクライアントPCだから、マシンを割り当てられた職員以外の人の使用は考えにくく、Windows資格情報は消去せずにそのままにしておいても大丈夫かもしれない。

いずれにしても、明日以降、試験的に運用しながら、もし問題点があるようならそれを発見・改善し、組織全体がよりよくなれるよう、力を尽くそう。

この方法がいちばん良い方法であるとは思えないが、今の僕にできるベストであることは間違いない。ならば、自分にできるいちばんよいことをする。それをずっと繰り返すしか、ないじゃないか・・・。

4.まとめ

現在のファイル共有環境を生かしたまま、新しいファイル共有環境を再構成するには、現在のファイル共有システムの中に、新しい共有フォルダを準備してそこへ古い環境から必要なファイルだけを移動する方法がよいのではないか? と今回の経験から思った。実際の運用は、これからだけれど・・・

5.お願いとお断り

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

Supports Zero-Starting!

ゼロ始まりに対応!

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

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

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

【今回の記事】

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

3.ゼロ始まりに対応

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

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

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

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

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

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

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

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

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

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

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

4.まとめ

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

5.お願いとお断り

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

This updated is support for use with high resolution devices

高DPIに対応しました!

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

【今回の記事】

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

1.2880×1920の世界を知る

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

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

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

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

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

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

たいへんなコトに・・・

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

動かして確認。

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

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

4.exeを高DPI対応に設定

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

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

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

設定方法は、次の通り。

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

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

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

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

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

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

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

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

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

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

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

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

マジ、困った・・・。

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

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

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

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

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

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

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

procedure TFormCollaboration.btnSelectClick(Sender: TObject);

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

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

begin

  ・・・

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

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

  ・・・

end;

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

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

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

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

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

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

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

Let me see you through.

空を見上げて・・・

I’m missing you.

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

6.まとめ

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

7.お願いとお断り

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

Let’s mount a counterattack from now on!

反撃を開始するぞ!

【今回の記事】

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

1.色が消えた!

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

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

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

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

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

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

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

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

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

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

なぜ・・・。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

2.BYODがあった

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

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

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

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

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

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

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

3.反撃開始!

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

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

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

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

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

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

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

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

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

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

end;

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

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

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

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

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

Private Sub CommandButton1_Click()

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

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

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

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

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

End Sub

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

=と:=の違い

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

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

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

これで実行すると・・・

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

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

できた! できた!!

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

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

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

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

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

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

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

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

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

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

4.お願いとお断り

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

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

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

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