VCL Component」カテゴリーアーカイブ

VCL(Visual Component Library)は、Delphiで Windows アプリケーションの迅速な開発を行うためのビジュアル コンポーネントをまとめたもの

StringGridのデータを印刷プレビューして印刷

Delphiで、どうしても書きたいプログラムがあった。
そのために絶対に越えなければならないハードルが、データの印刷プレビューと印刷だ。

正直、これは、ごく簡単に思えてならないことなんだけれど・・・
今まで何度もチャレンジして、そのたびに失敗してきた。。。

今回、ようやく、自分自身、納得の行くものが書けた。
これはその備忘録。

【もくじ】

1.印刷したいデータを準備
2.罫線も印刷する
3.CSVファイルをStringGridに表示する
4.印刷のコード
5.rect:TRectとしてはいけません!
6.印刷プレビューのコード
7.まとめ
8.お願いとお断り

1.印刷したいデータを準備

表計算ソフトを使って、印刷したいデータを作成し、CSV形式で保存する。
例えば、こんな感じ。

データはすべて架空のもの


書きたい印刷プログラムは、A4版・縦の用紙に収まる範囲の列数で、行数は1ページに最高50行を予定。ただし、データとして50行なので、フィールド名を入れれば1ページあたり51行となる。

CSVファイルの先頭には「フィールド名も保存」する。ただし、フィールド名があるのはファイルの先頭のみ。50行ごとに入れたりはしない。

自分の場合、印刷データが50行を超えることは、まず「ない」・・・のだが、冒頭で述べた「どうしても書きたいプログラム」の使用予定者の中にはそうでない方もいる。

なので、書きたいプログラムでは、データ数(=行数)がどんなに増えても、各ページの先頭行にはフィールド名を入れる仕様とする。これは譲れない自分との約束。

2.罫線も印刷する

これも、どうしても越えたいハードルのひとつ。フォントの大きさに関係なく、1行毎に罫線(=下線)を印刷する。もちろん、「罫線無し」の印刷も可能とするが、「罫線有り」の場合は1ページについて必ず51行分の罫線が引かれるのではなく、印刷する行数に合わせて罫線(=下線)を引くようにしたい。

今まで、これがどうしても「できなかった」。

だから、この壁は必ず乗り越える。これも譲れない自分との約束。


追記(20240901)

フォントの大きさは9ポイントに固定しました。

3.CSVファイルをStringGridに表示する

Delphiを起動。次の構造ペインに示すような形でVCLコントロールをForm上に配置。

最低限必要なVCLコントロール
AlignやVisibleなど、各プロパティは必要に応じて設定


Formは常に最大化して表示されるように設定。FormCreate手続きに次のコードを記述。

procedure TForm1.FormCreate(Sender: TObject);
begin
  //Formを最大化して表示(幅も最大化される)
  Form1.WindowState:=wsMaximized;
end;

この場合は、Form1が親だからこれでOKだが、子の場合は注意が必要。

どこに記述するか?
・自分自身が親Formの場合:FormCreateでOK!
・自分自身が子Formの場合:FormShowに書くこと(FormCreateに書くと一般保護違反のエラーが発生する)
・自分自身が子Formの場合にFormのWindowStateプロパティで直接指定しておいたらMy環境では何の問題もなく動作した。

また、Form1のScaledプロパティをFalseに設定することも忘れない。

これはどんなプログラムでも必ず最初に設定する


これをTrue にすると OS の DPI (ユーザが指定した DPI)によってフォームサイズやコントロールサイズが勝手に変更されてしまう。デフォルトでTrueなので注意が必要。Formを作成したら毎回忘れずに設定する。

で、exeがあるフォルダ以下の構成は次の通り。この階層構造をもとにしてPathを設定する。

ProcはProceed(処理済み)の略(のつもり)


StringGridに読み込むCSVファイルの置き場所は、「\sNameフォルダ」とする。

sNameフォルダ内にCSVファイルを用意する


これを読み込んでStringGridに表示する。OpenDialogをFormに追加する。

Form上に追加


読み込むCSVファイルのデータは次の通り。
(フィールド名が「ない」場合や、データに通し番号がなく、行番号を表示したい場合にも対応できるコードを含めて記述した。必要ない部分はコメントアウトしている)

今回使用するデータは「フィールド名・通し番号あり」


次のコードを記述。

procedure TForm1.Button1Click(Sender: TObject);
var
  //CSVファイルの読み込み
  CSVFileName: string;
  CsvFile:TextFile;
  CsvRowStr: string;
  i: Integer;
  strMsg: string;
  //列幅の調整
  iCOL: Integer;
  MaxColWidth: Integer;
  iROW: Integer;
  TmpColWidth: Integer;
begin

  //表示設定
  StringGrid1.Visible:=False;

  //列数
  StringGrid1.ColCount:=7;

  //OpenDialogのプロパティはExecuteする前に設定しておくこと
  With OpenDialog1 do begin
    //表示するファイルの種類をcsvに設定
    Filter:='CSVファイル(*.csv)|*.csv';
    //データの読込先フォルダを指定
    InitialDir:=ExtractFilePath(Application.ExeName)+'sName';
  end;

  //ダイアログ呼び出し
  if OpenDialog1.Execute then
  begin
    CsvFileName:=OpenDialog1.FileName;
    AssignFile(CsvFile, CsvFileName);
    Reset(CsvFile);
  end else begin
    strMsg:='ユーザーによる処理のキャンセル';
    Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
    Exit;
  end;

  //フィールド名が必要なCSVファイルなら記述する
  //StringGrid1.Rows[0].CommaText:=
  //  '通し番号,氏名,よみがな,年齢,生年月日,性別,血液型';
  //Fixed Colが1列あって、そこに行番号を設定する場合
  //  ',通し番号,氏名,よみがな,年齢,生年月日,性別,血液型';

  //読込み開始行を指定(FixedRowがある場合 -> ない場合は[0]にする)
  i:=0;
  try
    while not EOF(CsvFile) do
    begin
      //CSVファイルを1行読み込み、その1行分を文字列として代入する。
      Readln(CsvFile, CsvRowStr);
      //グリッドの行数が読み込み行数より少なければ、グリッドの行数を追加する。
      if StringGrid1.RowCount <= i then StringGrid1.RowCount := i + 1;
      //グリッドの指定行目に読み込み行を代入
      //[0]列はFixedCol-> 行番号を設定したい場合
      //StringGrid1.Rows[i].CommaText:=IntToStr(i)+','+CsvRowStr;
      StringGrid1.Rows[i].CommaText:=CsvRowStr;
      i := i + 1;
    end;
  finally
    //行番号を設定した場合
    //StringGrid1.Cells[0,0]:='行番号';
    CloseFile(CsvFile);
  end;

  //列幅の自動調整
  for iCOL := 0 to StringGrid1.ColCount-1 do
  begin
    MaxColWidth := 0;
    for iROW := 0 to StringGrid1.RowCount-1 do
    begin
      TmpColWidth := Canvas.TextWidth(StringGrid1.Cells[iCOL,iROW]) + 10;
      if MaxColWidth < TmpColWidth then
        MaxColWidth := TmpColWidth;
    end;
    StringGrid1.ColWidths[iCOL] := MaxColWidth;
  end;

  //表示設定
  StringGrid1.Visible:=True;

end;

実行結果は次の通り。

文字コードはANSI(CP932、Shift_JIS が拡張されたもの?)


以下、データを読み込む上での注意のあれこれ。

まず、CSVファイルの文字コードがUTF-8だと・・・

たいへんなコトに・・・


また、氏名やよみがなの「姓と名の間にあるスペースが全角でなく、半角」だと・・・

このコードでは、半角スペースが区切り文字として認識されてしまう!


CSVファイルのデータ形式には、文字コードも含めて十分、注意する必要がある。

4.印刷のコード

正直に言うと、今回のチャレンジでは「印刷プレビュー」のプログラムの方を先に書いた。
自分自身の感覚に自信などあるわけないが、通常(?)の感覚からすれば「印刷プレビュー」⇨「印刷」という流れが自然であるような気がして、そうなったのだ。

そこで問題になったのがデータ数が多く、印刷(出力)が「複数ページ」となる場合、プレビューの2枚目、3枚目をどう表示するか? という部分。

先に述べた通り、2枚目以降の先頭行にも「フィールド名を表示」するという自分との約束もあったし・・・。この「ページ毎、先頭行にはフィールド名を表示する」処理の方法をいろいろ考え、試してみたが、どうにも上手く行かない。

これだ! と思える処理手順が思いつかないまま、1ページ目だけの表示であれば問題なくできるプログラムを作成。とりあえず、印刷プレビューは1ページ目だけ表示することで妥協して、(仮)印刷プレビュープログラムとしておき、複数ページの印刷に対応した印刷プログラムが完成したら、もう一度、夢見た通りの印刷プレビューとなるよう、ここに戻ってくることにする。

そうして様々な問題をひとつひとつ自分なりに丁寧にクリアして最終的に書き上げたのが、下に掲載した「印刷」のプログラムコード(データ全体の行数や列数は限定せず、汎用的に使える=再利用できるコードを目指したつもりだが、どうだろうか?)。

FormにPrinterSetupDialog、その他のVCLコントロールを追加。

PrinterSetupDialogの方が用紙サイズと印刷方向を選ぶには便利!


if PrinterSetupDialog1.Execute then ~で、呼び出したDialogの画面。

今回の用途なら、出力先のプリンタ・用紙・向きの指定が出来ればOK!
追加したVCLコントロールとそれらに設定した名称
設定した値と状態

Button2は「印刷プレビュー」機能を、Button3は「印刷」機能を、それぞれ割り当てる予定。
なので、Button3をダブルクリックして手続きを作成し、「印刷」プログラムのコードを入力。

implementation

uses
  System.Math,
  Vcl.Printers;
procedure TForm1.Button3Click(Sender: TObject);
var
  //用紙サイズ、縦置き・横置きの設定を知る(Charだと推奨されない警告が表示される->Stringに変更)
  //Device, Driver, Port: array[0..255] of Char;
  Device, Driver, Port: string;
  DeviceMode: THandle;
  DevMode: PDeviceMode;

  //StringGrid->CSVファイル名とそこまでのPathを入れる
  csvFN:string;

  StringList: TStringList;
  i, j, k, MaxWidth: Integer;
  Fields: TStringList;
  FieldWidths: array of Integer;
  ColMargin: Integer;
  MarginX, MarginY: Integer;
  intLoop: Integer;
  FontHeight: Integer;
  eNum: Integer;
  iPlus: Integer;
  myFieldElement: string;
  LowNum: Integer;
  HighNum: Integer;
  MyRect:TRect;
  //平均値・最高値・最低値 -> 汎用性を考えExtended ではなく、Double とした
  DSum: Double;
  DAvg: Double;
  MinValue, MaxValue: Double;
  intDenomin: Double;

  //StringGrid -> CSV File
  procedure SaveStringGridToCSV(StringGrid: TStringGrid; const FileName: string);
  var
    CSVFile: TextFile;
    Row, Col: Integer;
    Line: string;
  begin
    AssignFile(CSVFile, FileName);
    Rewrite(CSVFile);
    try
      for Row := 0 to StringGrid.RowCount - 1 do
      begin
        Line := '';
        for Col := 0 to StringGrid.ColCount - 1 do
        begin
          Line := Line + StringGrid.Cells[Col, Row];
          if Col < StringGrid.ColCount - 1 then
            Line := Line + ',';
        end;
        WriteLn(CSVFile, Line);
      end;
    finally
      CloseFile(CSVFile);
    end;
  end;

  // ビットマップ用印刷ルーチン
  procedure StretchDrawBitmap(Canvas:TCanvas;  // 描画先キャンバス
                              r : TRect;       // 描画先範囲
                              Bitmap:TBitmap); // ビットマップ
  const
    InfoSize = SizeOf(TBitmapInfoHeader) + 4 * 256;
  var
    OldMode   : integer;      // StretchModeの保存用
    pInfo     : PBitmapInfo;  // DIBヘッダ+カラーテーブルへのポインタ

    InfoData  : array[0..InfoSize-1] of Byte; // DIBヘッダ+カラーテーブル
    Image     : array of Byte;// DIBのピクセルデータ
    DC        : HDC;          // GetDIBits 用 Device Context
    OldPal    : HPALETTE;     // パレット保存用
  begin
    pInfo :=@InfoData;

    // 24 Bit DIB の領域を確保
    SetLength(Image, ((Bitmap.Width * 24 + 31) div 32) * 4 * Bitmap.Height);

    // DIB のBitmapInfoHeader を初期化
    with pInfo^.bmiHeader do begin
      biSize := SizeOf(TBitmapInfoHeader);
      biWidth := Bitmap.Width;     biHeight := Bitmap.Height;
      biPlanes := 1;               biBitCount := 24;
      biCompression := BI_RGB;
    end;

    // 24bpp DIB イメージを取得
    DC := GetDC(0);
    try
      OldPal := 0;
      if Bitmap.Palette <> 0 then
        OldPal := SelectPalette(DC, Bitmap.Palette, True);

      GetDIBits(DC, Bitmap.Handle, 0, Bitmap.Height,
                Image, pInfo^, DIB_RGB_COLORS);
      if OldPal <> 0 then SelectPalette(DC, OldPal, True);
    finally
      ReleaseDC(0, DC);
    end;

    // 拡大モードを カラー用に変更
    OldMode:=SetStretchBltMode(Canvas.Handle,COLORONCOLOR);

    // 描画!!
    StretchDIBits(Canvas.Handle,
                  r.Left,r.Top,r.Right-r.Left,r.Bottom-r.Top,
                  0,0,pInfo^.bmiHeader.biWidth,pInfo^.bmiHeader.biHeight,
                  Image,pInfo^,DIB_RGB_COLORS,SRCCOPY);
    // 拡大モードを元に戻す
    SetStretchBltMode(Canvas.Handle,OldMode);
  end;

  procedure GetMinMaxValues(StringGrid: TStringGrid; ColIndex: Integer; out MinValue, MaxValue: Double);
  var
    Row: Integer;
    Value: Double;
  begin
    if StringGrid.RowCount = 0 then
      raise Exception.Create('StringGridにデータがありません。');

    MinValue := MaxDouble;
    MaxValue := -MaxDouble;

    for Row := 1 to StringGrid.RowCount - 1 do
    begin
      if TryStrToFloat(StringGrid.Cells[ColIndex, Row], Value) then
      begin
        if Value < MinValue then
          MinValue := Value;
        if Value > MaxValue then
          MaxValue := Value;
      end;
    end;

    if MinValue = MaxDouble then
      raise Exception.Create('指定された列に数値データがありません。');
  end;

begin

  //複数回クリックを防止する
  Button3.Enabled:=False;

  //初期化
  Image1.Picture:=nil;
  Image2.Picture:=nil;
  Image1.Visible:=False;
  Image2.Visible:=False;

  //印刷設定(用紙・向き)後に印刷
  if PrinterSetupDialog1.Execute then
  begin

    //プリンタの設定を取得
    Printer.GetPrinter(Device, Driver, Port, DeviceMode);
    DevMode := GlobalLock(DeviceMode);
    try
      //用紙サイズをA4に設定
      DevMode^.dmPaperSize := DMPAPER_A4;
      //用紙方向を縦に設定
      DevMode^.dmOrientation := DMORIENT_PORTRAIT;
      //設定をプリンタに反映
      Printer.SetPrinter(Device, Driver, Port, DeviceMode);
    finally
      GlobalUnlock(DeviceMode);
    end;

    //プリンタの解像度を取得
    //DPI := GetDeviceCaps(Printer.Handle, LOGPIXELSX);
    //家庭用のEPSONのプリンタは360DPI
    //業務用のEPSONの複合機は、600DPI
    //FinePrintは、600DPI

    //A4サイズの用紙の寸法は210mm x 297mm。インチに換算:約8.27インチ x 11.69インチ
    //プリンタの解像度(DPI: Dots Per Inch)

    //100DPIの場合
    //幅: 8.27インチ × 100 DPI = 827ピクセル
    //高さ: 11.69インチ × 100 DPI = 1169ピクセル

    //200DPIの場合
    //幅: 8.27インチ × 200 DPI = 1654ピクセル
    //高さ: 11.69インチ × 200 DPI = 2338ピクセル

    //100DPIとして描画したものをStretchDrawする

    //TImageの初期設定
    Image1.Width := 827;
    Image1.Height := 1169;
    Image1.Picture.Bitmap.Width := 827;
    Image1.Picture.Bitmap.Height := 1169;

    //背景を塗りつぶす
    Image1.Picture.Bitmap.Canvas.Brush.Color := clWhite;
    MyRect:=Rect(0, 0, 827, 1169);
    Image1.Picture.Bitmap.Canvas.FillRect(MyRect);

    //使用するフォント(必ず等幅フォントを指定する)
    //数値の右揃え用に追加(20240820)
    Image1.Picture.Bitmap.Canvas.Font.Name:='Consolas';

    //フォントサイズ -> 実際にはComboBoxで指定・選択できるようにする
    Image1.Picture.Bitmap.Canvas.Font.Size:=11;

    //平均値を計算 -> 実際のプログラムではこのような計算も行っている
    {
    DSum:=0;
    for i := 1 to StringGrid1.RowCount do
    begin
      if StringGrid1.Cells[5,i] <> '' then
      begin
        DSum:= DSum + StrToInt(StringGrid1.Cells[5,i]);
      end;
    end;
    DAvg:= SimpleRoundTo(DSum / intDenomin, -2);

    //最高値及び最低値を計算
    GetMinMaxValues(StringGrid1, 5, MinValue, MaxValue);
    }

    //StringGrid -> CSV
    //実際のプログラムでは、sNameフォルダ内のCSVファイルを読み込み、
    //さらに幾つかフィールドを追加して新しいデータを追加している。
    //追加したデータを含めて印刷する仕様

    //実際のプログラムでは、LabelSaveFolderName.Captionは別手続きで取得・表示済み
    LabelSaveFolderName.Caption:='SampleData';

    //保存するフォルダへのPath
    csvFN:=IncludeTrailingPathDelimiter(ExtractFilePath(Application.ExeName))+
      'ProcData\'+LabelSaveFolderName.Caption+'\';

    //フォルダの存在を確認、なければ作成
    if not System.SysUtils.DirectoryExists(ExtractFileDir(csvFN)) then
    begin
      //フォルダ階層を作成
      System.SysUtils.ForceDirectories(ExtractFileDir(csvFN));
    end;

    csvFN:=IncludeTrailingPathDelimiter(ExtractFilePath(Application.ExeName))+
      'ProcData\'+LabelSaveFolderName.Caption+'\'+LabelSaveFolderName.Caption+'.csv';
    SaveStringGridToCSV(StringGrid1, csvFN);

    StringList:=TStringList.Create;
    Fields:=TStringList.Create;

    try

      //Create
      StringList.LoadFromFile(csvFN);
      //Create
      SetLength(FieldWidths, 0);

      //各フィールドの最大幅を計算
      for i := 0 to StringList.Count - 1 do
      begin
        Fields.CommaText := StringList[i];
        if Length(FieldWidths) < Fields.Count then
          SetLength(FieldWidths, Fields.Count);

        for j := 0 to Fields.Count - 1 do
        begin
          //MaxWidth := Printer.Canvas.TextWidth(Fields[j]);
          MaxWidth := Image1.Picture.Bitmap.Canvas.TextWidth(Fields[j]);
          if FieldWidths[j] < MaxWidth then
            FieldWidths[j] := MaxWidth;
        end;
      end;

      eNum:=StringList.Count div 50;

      //51,101,151,201,251,301,・・・,XX1番目にフィールド名を挿入しておく

      //0番目の要素をコピー
      myFieldElement:=StringList[0];
      //要素を挿入(追加)
      if eNum<>0 then
      begin
        for i := 1 to eNum do
        begin
          StringList.Insert((50*i)+1, myFieldElement);
        end;
      end;

      //ここから印刷Loop
      try

        for intLoop := 0 to eNum do
        begin

          //初期化(白紙にする)
          Image1.Picture.Bitmap.Canvas.Brush.Color := clWhite;
          MyRect:=Rect(0, 0, 827, 1169);
          Image1.Picture.Bitmap.Canvas.FillRect(MyRect);

          if intLoop=0 then
          begin
            Printer.BeginDoc;
          end else begin
            Printer.NewPage;
          end;

          //タイトルを描画
          Image1.Picture.Bitmap.Canvas.Font.Color:=clBlue;
          Image1.Picture.Bitmap.Canvas.TextOut(
            StrToInt(EditMarginX.Text), StrToInt(EditMarginY.Text)-30,
            LabelSaveFolderName.Caption);

          //タイトルを描画
          {
          Image1.Picture.Bitmap.Canvas.Font.Color := clBlue;
          Image1.Picture.Bitmap.Canvas.TextOut(
            StrToInt(EditMarginX.Text), StrToInt(EditMarginY.Text)-30,
            LabelSaveFolderName.Caption + ' 【平均値:'+FloatToStr(DAvg)+
            '、最高値:'+ FloatToStr(MaxValue)+
            '、最低値:'+ FloatToStr(MinValue)+'】');
          }

          Image1.Picture.Bitmap.Canvas.Font.Color:=clBlack;

          k:=0;
          MarginX:=StrToInt(EditMarginX.Text);
          MarginY:=StrToInt(EditMarginY.Text);
          ColMargin:=StrToInt(EditColMargin.Text);

          iPlus:=0;
          //次のcase文でelseを使って何らかの値が必ず代入されるようにしたので不要
          //LowNum:=0;
          //HighNum:=0;

          case intLoop of
            0:begin
              LowNum:=0;
              if StringList.Count > 50 then
              begin
                HighNum:=50;
              end else begin
                HighNum:=StringList.Count-1;
              end;
            end;
            {
            1:begin
              LowNum:=51;
              if StringList.Count > 100 then
              begin
                HighNum:=100;
              end else begin
                HighNum:=StringList.Count-1;
              end;
            end;
            2:begin
              LowNum:=101;
              if StringList.Count > 150 then
              begin
                HighNum:=150;
              end else begin
                HighNum:=StringList.Count-1;
              end;
            end;
            }
          else
            //一般化
            LowNum:=(intLoop*50)+1;
            if StringList.Count > (intLoop*50)+50 then
            begin
              HighNum:=(intLoop*50)+50;
            end else begin
              HighNum:=StringList.Count-1;
            end;
          end;

          for i := LowNum to HighNum do
          begin
            Fields.CommaText := StringList[i];
            for j := 0 to Fields.Count - 1 do
            begin
              //処理できる列数を無制限にする
              case j of
                0:k:=0;
              else
                k:=k+FieldWidths[j-1]+ColMargin;
              end;
              //フィールド名に「備考」を追加する
              if i=0 then
              begin
                if j=Fields.Count-1 then
                begin
                  Fields[j]:=Fields[j]+' 備考';
                end;
              end;

              //データを出力(数値の右揃え:なし)
              //Image1.Picture.Bitmap.Canvas.TextOut(MarginX+k,MarginY+(iPlus*20),Fields[j]);

              //データを出力(数値の右揃え:あり))
              //数値の右揃え用に追加(20240820
              if TryStrToInt(Fields[j], intValue) then
              begin
                //数値である -> 右揃えで出力する
                Image1.Picture.Bitmap.Canvas.TextOut(MarginX+k,MarginY+(iPlus*20),
                  Format('%3d', [strToInt(Fields[j])]));
              end else begin
                //数値でない -> 左揃えで出力する
                Image1.Picture.Bitmap.Canvas.TextOut(MarginX+k,MarginY+(iPlus*20),Fields[j]);
              end;

              //罫線を描画
              if cbLine.Checked then
              begin
                Image1.Picture.Bitmap.Canvas.Pen.Color:= clBlack;
                FontHeight:= -1 * Image1.Picture.Bitmap.Canvas.Font.Height;
                Image1.Picture.Bitmap.Canvas.MoveTo(MarginX+k, MarginY+(iPlus*20)+FontHeight+4);
                Image1.Picture.Bitmap.Canvas.LineTo(Image1.Picture.Bitmap.Width-50, MarginY+(iPlus*20)+FontHeight+4);
              end;
            end;
            inc(iPlus);
          end;

          //大きさを指定
          MyRect.Top:=0;
          MyRect.Left:=0;
          MyRect.Bottom:= Trunc((Printer.PageWidth / Image1.Picture.Width) * Image1.Picture.Height);
          MyRect.Right:= Printer.PageWidth;
          //ファイルを描画
          StretchDrawBitmap(Printer.Canvas, MyRect, Image1.Picture.Bitmap);
          Application.ProcessMessages;

        end;  //intLoop

      finally
        Printer.EndDoc;
      end;

    finally
      StringList.Free;
      Fields.Free;
      //複数回クリックを防止する
      Button3.Enabled:=True;
    end;

    //ファイルの完全削除
    DeleteFile(PChar(csvFN));

    //TImageの表示位置を指定
    ScrollBox1.VertScrollBar.Position:=0;
    ScrollBox1.HorzScrollBar.Position:=0;
    Image1.Top:=ScrollBox1.VertScrollBar.Position+14;
    Image1.Left:=ScrollBox1.HorzScrollBar.Position+14;

    //TImageの表示
    Image1.Visible:=True;  //印刷プレビューを実行しなければ不要

    Button2Click(Sender);  //印刷プレビューを表示する

  end else begin
    //キャンセルに対応
    Button2Click(Sender);  //印刷プレビューを表示する
  end;

end;

上のコードには、このサンプルでの処理には不要な部分や、(コードを一般化する前に場合分けして処理手順を考えた)冗長な部分も含まれている。自分のバカさを全世界にPRするようなものだが、計算処理を追加したり、コードを一般化する際の考え方の参考となるよう、敢えてそのまま残した。

そもそも、この記事を書こうと思ったきっかけは、DelphiのStringGridの内容を定型用紙に印刷するサンプルコードが(Web上に)あまりにも少ない気がしたこと。ただ、この例では、いったんCSVファイルにして保存したり、PrinterのCanvasではなくTImageのCanvasに描画したり、普通とは言い難い方法を行って印刷している気がするので、普通(?)の印刷方法を学びたい方にはまるで参考にならないかもですが、万一にでも、どなたかのお役に立てれば何よりの幸いです。

【実行結果】

まず、PrinterSetupDialogが表示されるので、A4・縦を選択する。

プリンター名に「FinePrint」とあるのはお気に入りのプリントユーティリティ
プレビュー的に印刷内容を確認したり、まとめ印刷を行ったり、縮小・両面設定で印刷枚数を減らしたり、とにかく使えるユーティリティ
FinePrintへ出力
FinePrintへの出力の印刷プレビュー部分を拡大


これで数値データを右寄せ表示できれば、大満足なんだけど・・・ 。

追記(20240819)

Format関数を使えば、数値データの右揃えが簡単に実現できることを忘れてた!

(最近は「データを保存する」プログラムばかり書いていて、「データを印刷する」プログラムはほとんど書いたことがないことにあらためて気づいた。

 例1:csv形式で保存 -> 表計算ソフトで読み込んで活用。
 例2:表計算ソフトのファイルにADO接続して、直接書き込み。
 例3:データベースにADO接続して、データを保存、必要な部分をクエリで抽出。みたいな・・・

遠い昔、VBでデータを縦・横罫線付きの一覧表形式で印刷するプログラムをさんざん書いていたのが夢のよう・・・。

てか、今回も最終的に印刷しているのは、プリンターのCanvasに描画した「絵」なんですが。)

「印刷プレビュー」及び「印刷」の手続きを次のように追加・修正。

1.手続きの冒頭で、「等幅フォントを忘れずに指定」する(追加)。

  //使用するフォント(必ず等幅フォントを指定する)
  Image1.Picture.Bitmap.Canvas.Font.Name:='Consolas';

  //フォントサイズ -> 実際にはComboBoxで指定・選択できるようにする
  Image1.Picture.Bitmap.Canvas.Font.Size:=11;

2.データ出力部分を次のように修正する。

  //データを出力
  //Image1.Picture.Bitmap.Canvas.TextOut(MarginX+k,MarginY+(iPlus*20),Fields[j]);

  //数値データは右揃えで出力する
  if TryStrToInt(Fields[j], intValue) then
  begin
    //数値である -> 右揃えで出力する
    Image1.Picture.Bitmap.Canvas.TextOut(MarginX+k,MarginY+(iPlus*20),
      Format('%3d', [strToInt(Fields[j])]));
  end else begin
    //数値でない -> 左揃えで出力する
    Image1.Picture.Bitmap.Canvas.TextOut(MarginX+k,MarginY+(iPlus*20),Fields[j]);
  end;

【実行結果】

通し番号(と年齢)が右揃えになった!

追記(20240819)ここまで

2ページ目以降も先頭行にフィールド名を表示

今までの自分には罫線付きでこのように表示することが、どうしてもできなかった・・・


先頭行にフィールド名を表示する部分は、いちばん悩んだところ。
最終的に変数eNum(LoopのEndNumber)から印刷に必要なページ数を取得し、StringListに格納した印刷データの0番目の要素をコピーして、これをStringListの51、101、151のように、eNumの現在の値( i * 50)+1番目に挿入して行く方法が計算的にも、処理的にも、いちばんラクなのではないか?・・・と考え、このアルゴリズムでプログラムを作成。

こうすれば1ページ目には要素0から50、2ページ目には要素51から100、3ページ目には101から150・・・のように印刷データの割り当てが決まり、プログラムも心もすっきり。0番目の要素(フィールド名)のコピーと所定の位置への挿入さえ行ってしまえば、あとは単純にLoopを廻すだけだ。

      eNum:=StringList.Count div 50;

      //51,101,151,201,251,301・・・番目にフィールド名を挿入

      //0番目の要素をコピー
      myFieldElement:=StringList[0];
      //要素を追加
      if eNum<>0 then
      begin
        for i := 1 to eNum do
        begin
          StringList.Insert((50*i)+1, myFieldElement);
        end;
      end;


最も重要なデータ出力部分は、生成AIに教えてもらった!
次のデータを出力するコードの(iPlus * 20)の部分は、自分では絶対に書けなかったと思う・・・。
ついに、と言うか、とうとう、わからない部分は生成AIに聞きながらプログラムが書ける、夢のような時代がやってきた!!

でも、頼りすぎは禁物。実際、今回もかなり痛い目にあった・・・。
その内容は、後述。

//データを出力
Image1.Picture.Bitmap.Canvas.TextOut(MarginX+k,MarginY+(iPlus*20),Fields[j]);

印刷枚数が1ページだけなら、変数はLoop変数の i がそのまま使えるのだが、複数枚印刷を実行する必要があるので、Loop用の変数 i とは別に iPlus という名前の変数(特に意味はない)を用意し、ページが切り替わる毎にゼロで初期化するように生成AIが教えてくれたプログラムを改良。

それから、これはあった方が親切かな? と考え、先頭行のフィールド名の最後に「備考」も追加。

Loopの様子をわかりやすくしたのが次のコード。
ページ内のデータ印刷作業で、i , j , k を使ってしまったので、いちばん大きな(外側の)ページを切り替えるLoopの変数名をどうするかで悩み l(エルの小文字)はちょっと・・・って感じがしたので、最終的に変数名はintLoopとした。

最終的に、TImageのBitmapに出力したものをPrinterのCanvasにコピーして印刷している。

for intLoop := 0 to eNum do
begin
  k:=0;
  iPlus:=0;
  for i := LowNum to HighNum do
  begin
    for j := 0 to Fields.Count - 1 do
    begin    
      //フィールド名に「備考」を追加する
      if i=0 then
      begin
        if j=Fields.Count-1 then
        begin
          Fields[j]:=Fields[j]+' 備考';
        end;
      end;
      //データを出力
      Image1.Picture.Bitmap.Canvas.TextOut(MarginX+k,MarginY+(iPlus*20),Fields[j]);
    end;
    inc(iPlus);
  end; 

  //大きさを指定
  MyRect.Top:=0;
  MyRect.Left:=0;
  MyRect.Bottom:= Trunc((Printer.PageWidth / Image1.Picture.Width) * Image1.Picture.Height);
  MyRect.Right:= Printer.PageWidth;
  //ファイルを描画
  StretchDrawBitmap(Printer.Canvas, MyRect, Image1.Picture.Bitmap);
  Application.ProcessMessages;

end;  //intLoopの終わり


実は、ここでひと悶着あって・・・

5.rect:TRectとしてはいけません!

実際には、最初に1ページ目だけを表示できる「印刷プレビュー」のプログラムを書き、それを元にして「印刷」プログラムを書いたのだが、いつもの通り、というか、お決まりの「解決策がまったくわからずにトホーに暮れる」・・・ 後から考えれば(理由がわかってみれば)実に「なぁーんだ。そんなコトか」みたいな、でも、それがわかるまでは七転八倒の苦しみとなるイベントに今回も遭遇。

毎回、これが楽しみでプログラムを書いている、そんな気がしないでもないが。

今回のそれは・・・ナニかというと、

「印刷プレビュー」の手続き内では「何の問題もなかった」次のコードだが、

procedure TForm1.Button2Click(Sender: TObject);
begin

  ・・・じんせい、イロイロ・・・

  //背景を塗りつぶす
  Image1.Picture.Bitmap.Canvas.Brush.Color := clWhite;
  Image1.Picture.Bitmap.Canvas.FillRect(rect(0, 0, 827, 1169)); 

  ・・・タコは、イボイボ・・・

end;

これを「印刷」手続き内に複写して、「印刷プレビュー」手続きにはないPrinterのCanvasへの描画コードを追加するなど、あちこちいじっていたら、いつの間にか・・・

エラーの!マークが付いてる(問題が起きた状況を再現)


構造ペインには・・・


『はぁ?』

だって行末にちゃんとセミコロンあるし・・・みたいな感じ。
・・・てか、なんで、こっちの手続きだけ、エラーになるの???

この時点で、早朝2時頃から連続15時間くらいPCと向かい合っていたため、精神的にはもうフラフラの状態。もちろん、エラーの原因は、まったくわからない。

まったく同じプログラムコードが、あっちの手続きではOK! こっちの手続きではダメな理由は、いったい何なんだろう???

この後も、しばらく、がんばって考えたんだけど、原因はさっぱりわからず、


Delphiを再起動したら直るかなー?

もちろん、直るわけもなく・・・。


もしかして、PCを再起動したら直るかなー??

もちろん、再起動してもエラーは消えない。


万策尽きた感じで、とりあえず、いけない水に手を伸ばし・・・ 心は折れたまま、遥かなる夢の国へ。

翌朝、ってか、午前0時前に目覚めたから、日付はまだ今日だけど・・・
とりあえず、すっきりした頭で問題に再挑戦。ようやくエラーの原因が判明。

ほんとに偶然発見したのだけれど、エラーにならない「印刷プレビュー」の手続きでは・・・

このRectはSystem.TypesのRect関数・・・


これに対し、エラーになる「印刷」の手続きでは・・・

このrectはvar宣言したTRect型の変数・・・

あー!!
わかったー☆☆☆ みたいな

1ページ目だけ表示可能な最初に書いた「印刷プレビュー」の手続きをそっくり「印刷」手続きに複写して、「印刷プレビュー」の手続きには「存在しなかった」プリンタのCanvasへの描画コードを追加したのだが・・・ その時、var宣言部でTRect型の変数rectを宣言していたのだ。

これが追加したPrinterのCanvasへ描画するプログラムの主要部分。

procedure TForm1.btnPrintASheetClick(Sender: TObject);
var
  i, j: Integer;
  strMsg: string;
  PrintALL: Boolean;
  intLoopNum: Integer;
  rect:TRect;
  StrCaption:String;
  StrPrompt:String;
  StrValue1, StrValue2:String;
  Chr : array [0..255]  of  char;

  // ビットマップ用印刷ルーチン
  procedure StretchDrawBitmap(Canvas:TCanvas;  // 描画先キャンバス
                              r : TRect;       // 描画先範囲
                              Bitmap:TBitmap); // ビットマップ
  ・・・省略・・・

begin
  if PrinterSetupDialog1.Execute then
  begin

    //背景を塗りつぶす
    Image1.Picture.Bitmap.Canvas.Brush.Color := clWhite;
    Image1.Picture.Bitmap.Canvas.FillRect(rect(0, 0, 827, 1169));  //エラーになる部分

    //Info
    strMsg:='全員分印刷しますか?'+#13#10+'(個別印刷は「いいえ」)';
    if Application.MessageBox(PChar(strMsg), PChar('情報'), MB_YESNO or MB_ICONINFORMATION) = mrYes then
    begin
      PrintALL:=True;
    end else begin
      PrintALL:=False;
    end;

    //全部印刷
    if PrintAll then
    begin
      //先頭のデータを表示
      btnFirstClick(Sender);
      for i := 1 to ListBox1.Items.Count do
      begin
        //まず現在のImageを印刷
        with Printer do
        begin
          if i=1 then
          begin
            BeginDoc;
          end else begin
            NewPage;
          end;
          //大きさを指定
          rect.Top:=0;
          rect.Left:= 0;
          rect.Bottom:= Trunc(( PageWidth / Image1.Picture.Width) * Image1.Picture.Height);
          rect.Right:= PageWidth;
          //TImageのBitmapをPrinterのCanvasに描画
          StretchDrawBitmap(Printer.Canvas, rect, Image1.Picture.Bitmap);
          
          if i=ListBox1.Items.Count then
          begin
            EndDoc;
          end;
        end;

        //次を表示
        btnNextClick(Sender);
      end;

      ・・・

だから、結果的に(当然だが)「印刷」手続き内ではコードで意図したSystem.TypesのRect関数は呼ばれずに、「rect」と記述するとそれはvar宣言したrect変数の方を意味(参照)することになって・・・

これはエラーになって当然。Delphiさん、あなたはやっぱり正しかった。

ちなみに、Delphiは大文字・小文字を区別しないから、R でも r でも問題は起きない。問題の根源であるvar宣言部のrect変数の名前を変え、次のようにコードを書き直せば・・・

☆エラーは消えました☆

System.TypesのRect関数と名前が衝突しないように、変数名をMyRectに変える
もしくはSystem.Types.pasのRect関数を明示的に指定する


(試してみたい方は、次のコードをコピペしてください。)

procedure TForm1.Button4Click(Sender: TObject);
var
  MyRect:TRect;
begin

  //背景を塗りつぶす
  Image1.Picture.Bitmap.Canvas.Brush.Color := clWhite;

  //解決方法その1
  MyRect:=rect(0, 0, 827, 1169);
  Image1.Picture.Bitmap.Canvas.FillRect(MyRect);

  //解決方法その2
  Image1.Picture.Bitmap.Canvas.FillRect(System.Types.Rect(0, 0, 827, 1169));

end;

【まとめ】

関数名として使われているような語句をそのまんま変数名として利用するのはNo Good! であります。

6.印刷プレビューのコード

そういう訳で、「印刷」手続きのコードが完成し、問題も解決したので、この完成したコードを「印刷プレビュー」に流用することにした。

基本的に、「印刷」手続きのコードからBeginDocとNewPage、それからEndDocを消し、PrinterのCanvasに描画してる部分をコメントアウトして、1ページ目以降を表示する方法を追加すればOKのはずだ・・・。そう考えて書いたのが次のコード。

procedure TForm1.Button2Click(Sender: TObject);
var
  //用紙サイズ、縦置き・横置きの設定を知る(Charだと推奨されない警告が表示される->Stringに変更)
  //Device, Driver, Port: array[0..255] of Char;
  Device, Driver, Port: string;
  DeviceMode: THandle;
  DevMode: PDeviceMode;

  //StringGrid->CSVファイル名とそこまでのPathを入れる
  csvFN:string;

  StringList: TStringList;
  i, j, k, MaxWidth: Integer;
  Fields: TStringList;
  FieldWidths: array of Integer;
  ColMargin: Integer;
  MarginX, MarginY: Integer;
  intLoop: Integer;
  FontHeight: Integer;
  eNum: Integer;
  iPlus: Integer;
  myFieldElement: string;
  LowNum: Integer;
  HighNum: Integer;
  MyRect:TRect;
  //平均値・最高値・最低値
  //DSum: Double;
  //DAvg: Double;
  //MinValue, MaxValue: Double;

  //StringGrid -> CSV File
  procedure SaveStringGridToCSV(StringGrid: TStringGrid; const FileName: string);
  var
    CSVFile: TextFile;
    Row, Col: Integer;
    Line: string;
  begin
    AssignFile(CSVFile, FileName);
    Rewrite(CSVFile);
    try
      for Row := 0 to StringGrid.RowCount - 1 do
      begin
        Line := '';
        for Col := 0 to StringGrid.ColCount - 1 do
        begin
          Line := Line + StringGrid.Cells[Col, Row];
          if Col < StringGrid.ColCount - 1 then
            Line := Line + ',';
        end;
        WriteLn(CSVFile, Line);
      end;
    finally
      CloseFile(CSVFile);
    end;
  end;

  procedure GetMinMaxValues(StringGrid: TStringGrid; ColIndex: Integer; out MinValue, MaxValue: Double);
  var
    Row: Integer;
    Value: Double;
  begin
    if StringGrid.RowCount = 0 then
      raise Exception.Create('StringGridにデータがありません。');

    MinValue := MaxDouble;
    MaxValue := -MaxDouble;

    for Row := 1 to StringGrid.RowCount - 1 do
    begin
      if TryStrToFloat(StringGrid.Cells[ColIndex, Row], Value) then
      begin
        if Value < MinValue then
          MinValue := Value;
        if Value > MaxValue then
          MaxValue := Value;
      end;
    end;

    if MinValue = MaxDouble then
      raise Exception.Create('指定された列に数値データがありません。');
  end;

  //Image1のBitmapをImage2の指定位置へ複写する
  procedure CopyBitmapToImage(Image1, Image2: TImage; DestX, DestY: Integer);
  var
    SrcRect, DestRect: TRect;
  begin
    // ソースの矩形を設定
    SrcRect := Rect(0, 0, Image1.Picture.Bitmap.Width, Image1.Picture.Bitmap.Height);
    // 目的地の矩形を設定
    DestRect := Rect(DestX, DestY, 
      DestX + Image1.Picture.Bitmap.Width, DestY + Image1.Picture.Bitmap.Height);
    // Image2のCanvasにImage1のBitmapを複写
    Image2.Picture.Bitmap.Canvas.CopyRect(DestRect, Image1.Picture.Bitmap.Canvas, SrcRect);

    //追加(20240820)
    //ページ区切り線を表示するコードを追加
    //ペンの色を青に設定
    Image2.Picture.Bitmap.Canvas.Pen.Color := clGray;
    //ページ区切り線の太さ
    Image2.Picture.Bitmap.Canvas.Pen.Width:=3;
    //ペンのスタイルを点線に設定
    //Image1.Canvas.Pen.Style := psDot;
    Image1.Canvas.Pen.Style := psSolid;
    //線を引く
    Image2.Canvas.MoveTo(0, DestY + Image1.Picture.Bitmap.Height); // 線の開始位置
    Image2.Canvas.LineTo(Image2.Picture.Bitmap.Width, 
      DestY + Image1.Picture.Bitmap.Height); // 線の終了位置
    //ページ区切り線の太さを元に戻す
    Image2.Picture.Bitmap.Canvas.Pen.Width:=1;
    //ペンの色を黒に設定
    Image2.Picture.Bitmap.Canvas.Pen.Color := clBlack;
    //ペンのスタイルを直線に戻す
    //Image1.Canvas.Pen.Style := psSolid;
  end;

begin

  //複数回クリックを防止する
  Button2.Enabled:=False;

  //初期化
  Image1.Picture:=nil;

  //印刷設定(用紙・向き)後に印刷

  //プリンタの設定を取得
  Printer.GetPrinter(Device, Driver, Port, DeviceMode);
  DevMode := GlobalLock(DeviceMode);
  try
    //用紙サイズをA4に設定
    DevMode^.dmPaperSize := DMPAPER_A4;
    //用紙方向を縦に設定
    DevMode^.dmOrientation := DMORIENT_PORTRAIT;
    //設定をプリンタに反映
    Printer.SetPrinter(Device, Driver, Port, DeviceMode);
  finally
    GlobalUnlock(DeviceMode);
  end;

  //TImageの初期設定
  Image1.Width := 827;
  Image1.Height := 1169;
  Image1.Picture.Bitmap.Width := 827;
  Image1.Picture.Bitmap.Height := 1169;

  //背景を塗りつぶす
  Image1.Picture.Bitmap.Canvas.Brush.Color := clWhite;
  MyRect:=Rect(0, 0, 827, 1169);
  Image1.Picture.Bitmap.Canvas.FillRect(MyRect);

  //使用するフォント(必ず等幅フォントを指定する)
  //数値の右揃え用に追加(20240820)
  Image1.Picture.Bitmap.Canvas.Font.Name:='Consolas';

  //フォントサイズ
  Image1.Picture.Bitmap.Canvas.Font.Size:=11;
  //フォントサイズ <- 要らなかった!
  //Image2.Picture.Bitmap.Canvas.Font.Size:=11;

  //平均値を計算  intDenominはグローバル変数として宣言
  {
  DSum:=0;
  for i := 1 to StringGrid1.RowCount do
  begin
    if StringGrid1.Cells[5,i] <> '' then
    begin
      DSum:= DSum + StrToInt(StringGrid1.Cells[5,i]);
    end;
  end;
  DAvg:= SimpleRoundTo(DSum / intDenomin, -2);

  //最高値及び最低値を計算
  GetMinMaxValues(StringGrid1, 5, MinValue, MaxValue);

  //タイトルを描画
  Image1.Picture.Bitmap.Canvas.Font.Color := clBlue;
  Image1.Picture.Bitmap.Canvas.TextOut(
    StrToInt(EditMarginX.Text), StrToInt(EditMarginY.Text)-30, LabelKoza.Caption);

  //フォント色を変更
  Image1.Picture.Bitmap.Canvas.Font.Color := clBlack;
  }

  //Grid -> CSV
    //実際のプログラムでは、sNameフォルダ内のCSVファイルを読み込み、
    //さらに幾つかフィールドを追加して新しいデータを入力している。
    //印刷では、この新しく入力されたデータを含めて印刷している

    //実際のプログラムでは、LabelSaveFolderName.Captionは別手続きで取得・表示済み
    LabelSaveFolderName.Caption:='SampleData';

    //保存するフォルダへのPath
    csvFN:=IncludeTrailingPathDelimiter(ExtractFilePath(Application.ExeName))+
      'ProcData\'+LabelSaveFolderName.Caption+'\';

    //フォルダの存在を確認、なければ作成
    if not System.SysUtils.DirectoryExists(ExtractFileDir(csvFN)) then
    begin
      //フォルダ階層を作成
      System.SysUtils.ForceDirectories(ExtractFileDir(csvFN));
    end;

    csvFN:=IncludeTrailingPathDelimiter(ExtractFilePath(Application.ExeName))+
      'ProcData\'+LabelSaveFolderName.Caption+'\'+LabelSaveFolderName.Caption+'.csv';
    SaveStringGridToCSV(StringGrid1, csvFN);

  StringList:=TStringList.Create;
  Fields:=TStringList.Create;

  try

    //Create
    StringList.LoadFromFile(csvFN);
    //Create
    SetLength(FieldWidths, 0);

    //各フィールドの最大幅を計算
    for i := 0 to StringList.Count - 1 do
    begin
      Fields.CommaText := StringList[i];
      if Length(FieldWidths) < Fields.Count then
        SetLength(FieldWidths, Fields.Count);

      for j := 0 to Fields.Count - 1 do
      begin
        //MaxWidth := Printer.Canvas.TextWidth(Fields[j]);
        MaxWidth := Image1.Picture.Bitmap.Canvas.TextWidth(Fields[j]);
        if FieldWidths[j] < MaxWidth then
          FieldWidths[j] := MaxWidth;
      end;
    end;

    eNum:=StringList.Count div 50;

    //PreView用TImageの初期設定
    Image2.Width := 827;
    case eNum of
      0:Image2.Height := 1169;
    else
      Image2.Height := 1169 * (eNum + 1);
    end;
    Image2.Picture.Bitmap.Width := 827;
    case eNum of
      0:Image2.Picture.Bitmap.Height := 1169;
    else
      Image2.Picture.Bitmap.Height := 1169 * (eNum + 1);
    end;    

    //背景を塗りつぶす
    Image2.Picture.Bitmap.Canvas.Brush.Color := clWhite;
    case eNum of
      0:MyRect:=Rect(0, 0, 827, 1169);
    else
      MyRect:=Rect(0, 0, 827, 1169 * (eNum + 1));
    end;
    Image2.Picture.Bitmap.Canvas.FillRect(MyRect);

    //51,101,151,201,251,301,・・・,XX1番目にフィールド名を挿入しておく

    //0番目の要素をコピー
    myFieldElement:=StringList[0];
    //要素を挿入(追加)
    if eNum<>0 then
    begin
      for i := 1 to eNum do
      begin
        StringList.Insert((50*i)+1, myFieldElement);
      end;
    end;

    //ここから印刷Loop
    try

      for intLoop := 0 to eNum do
      begin

        //初期化(白紙にする)
        Image1.Picture.Bitmap.Canvas.Brush.Color := clWhite;
        MyRect:=Rect(0, 0, 827, 1169);
        Image1.Picture.Bitmap.Canvas.FillRect(MyRect);
        {
        //印刷プレビューだから不要
        if intLoop=0 then
        begin
          Printer.BeginDoc;
        end else begin
          Printer.NewPage;
        end;
        }

        //タイトルを描画
        Image1.Picture.Bitmap.Canvas.Font.Color:=clBlue;
        Image1.Picture.Bitmap.Canvas.TextOut(
          StrToInt(EditMarginX.Text), StrToInt(EditMarginY.Text)-30,
          LabelSaveFolderName.Caption);

        //タイトルを描画(計算が必要な場合の例)
        {
        Image1.Picture.Bitmap.Canvas.Font.Color := clBlue;
        Image1.Picture.Bitmap.Canvas.TextOut(
          StrToInt(EditMarginX.Text), StrToInt(EditMarginY.Text)-30,
          LabelKoza.Caption + ' 【平均値:'+FloatToStr(DAvg)+
          '、最高値:'+ FloatToStr(MaxValue)+
          '、最低値:'+ FloatToStr(MinValue)+'】');
        }

        Image1.Picture.Bitmap.Canvas.Font.Color:=clBlack;

        //水平方向の各フィールドの印字開始位置決定用変数を初期化
        k:=0;

        //水平方向の印字開始位置
        MarginX:=StrToInt(EditMarginX.Text);
        //垂直方向の印字開始位置
        MarginY:=StrToInt(EditMarginY.Text);
        //列(フィールド)と列の(余白的な)間隔
        ColMargin:=StrToInt(EditColMargin.Text);

        //ページが変わったら初期化する
        iPlus:=0;

        case intLoop of
          0:begin
            LowNum:=0;
            if StringList.Count > 50 then
            begin
              HighNum:=50;
            end else begin
              HighNum:=StringList.Count-1;
            end;
          end;
          {
          1:begin
            LowNum:=51;
            if StringList.Count > 100 then
            begin
              HighNum:=100;
            end else begin
              HighNum:=StringList.Count-1;
            end;
          end;
          2:begin
            LowNum:=101;
            if StringList.Count > 150 then
            begin
              HighNum:=150;
            end else begin
              HighNum:=StringList.Count-1;
            end;
          end;
          }
        else
          //一般化
          LowNum:=(intLoop*50)+1;
          if StringList.Count > (intLoop*50)+50 then
          begin
            HighNum:=(intLoop*50)+50;
          end else begin
            HighNum:=StringList.Count-1;
          end;
        end;

        for i := LowNum to HighNum do
        begin
          Fields.CommaText := StringList[i];
          for j := 0 to Fields.Count - 1 do
          begin
            //処理できる列数を無制限にする
            case j of
              0:k:=0;
            else
              k:=k+FieldWidths[j-1]+ColMargin;
            end;
            //フィールド名に「備考」を追加する
            if i=0 then
            begin
              if j=Fields.Count-1 then
              begin
                Fields[j]:=Fields[j]+' 備考';
              end;
            end;

            //データを出力(数値の右揃え:なし)
            //Image1.Picture.Bitmap.Canvas.TextOut(MarginX+k,MarginY+(iPlus*20),Fields[j]);

            //データを出力(数値の右揃え:あり)
            //数値の右揃え用に追加(20240820)
            if TryStrToInt(Fields[j], intValue) then
            begin
              //数値である -> 右揃えで出力する
              Image1.Picture.Bitmap.Canvas.TextOut(MarginX+k,MarginY+(iPlus*20),
                Format('%3d', [strToInt(Fields[j])]));
            end else begin
              //数値でない -> 左揃えで出力する
              Image1.Picture.Bitmap.Canvas.TextOut(MarginX+k,MarginY+(iPlus*20),Fields[j]);
            end;

            //罫線を描画
            if cbLine.Checked then
            begin
              Image1.Picture.Bitmap.Canvas.Pen.Color:= clBlack;
              FontHeight:= -1 * Image1.Picture.Bitmap.Canvas.Font.Height;
              Image1.Picture.Bitmap.Canvas.MoveTo(MarginX+k, MarginY+(iPlus*20)+FontHeight+4);
              Image1.Picture.Bitmap.Canvas.LineTo(Image1.Picture.Bitmap.Width-50, MarginY+(iPlus*20)+FontHeight+4);
            end;
          end;

          inc(iPlus);

        end;

        //Image1のBitmapをImage2の(XX, YY)の位置に複写
        case intLoop of
          0:CopyBitmapToImage(Image1, Image2, 0, 0);
        else
          CopyBitmapToImage(Image1, Image2, 0, 1169 * intLoop);
        end;

      end;

    finally
      //Printer.EndDoc;
    end;

  finally
    StringList.Free;
    Fields.Free;
    //複数回クリックを防止する
    Button2.Enabled:=True;
  end;

  //ファイルの完全削除
  DeleteFile(PChar(csvFN));

  //Imageの高さをScrollBoxのスクロール範囲に反映
  ScrollBox1.VertScrollBar.Range := Image2.Picture.Bitmap.Height;

  //TImageの表示位置を指定
  ScrollBox1.VertScrollBar.Position:=0;
  ScrollBox1.HorzScrollBar.Position:=0;
  Image1.Top:=ScrollBox1.VertScrollBar.Position+14;
  Image1.Left:=ScrollBox1.HorzScrollBar.Position+14;
  Image2.Top:=ScrollBox1.VertScrollBar.Position+14;
  Image2.Left:=ScrollBox1.HorzScrollBar.Position+14;

  //TImageの表示
  Image1.Visible:=False;
  Image2.Visible:=True;

end;


【実行結果】

実行結果は、次の通り。用意したデータ件数は320件。

1ページ目


スクロールして下へ。表示されているのは、最終ページ。
※ マウスのホイールを廻してスクロールさせるには別途コードの記述が必要(後述)。

最終ページ


マウスのホイールを廻して、TImageをスクロールさせるには、FormのOnMouseWheelイベントの手続きを次のように作成する。

procedure TForm1.FormMouseWheel(Sender: TObject; Shift: TShiftState;
  WheelDelta: Integer; MousePos: TPoint; var Handled: Boolean);
var
  LDelta:Integer;
  //追加
  LWinCtrl:TWinControl;
  LCurPos:TPoint;
begin

  {
  //TScrollBox のマウスホイールによるスクロール
  //マウスがTScrollBoxの外にあってもスクロールする・・・ならこちら☆
  LDelta:=WheelDelta div 5;
  if ssCtrl in Shift then
  begin
    ScrollBox1.HorzScrollBar.Position:=ScrollBox1.HorzScrollBar.Position-LDelta;
  end else begin
    ScrollBox1.VertScrollBar.Position:=ScrollBox1.VertScrollBar.Position-LDelta;
  end;
  Handled:=True;
  }

  //マウスカーソルが TScrollBox の領域内にある時だけスクロールを可能にする
  LCurPos := ScrollBox1.Parent.ScreenToClient(MousePos);
  if PtInRect(ScrollBox1.BoundsRect, LCurPos) then
  begin
    LDelta := WheelDelta div 3;
    if ssCtrl in Shift then
    begin
      ScrollBox1.HorzScrollBar.Position := ScrollBox1.HorzScrollBar.Position - LDelta;
    end else begin
      ScrollBox1.VertScrollBar.Position := ScrollBox1.VertScrollBar.Position - LDelta;
      //Memoも連動してスクロールさせる
      {
      if LDelta > 0 then
      begin
        Memo2.Perform(WM_VSCROLL, SB_LINEUP, 0);
      end else begin
        Memo2.Perform(WM_VSCROLL, SB_LINEDOWN, 0);
      end;
      }
    end;
  end else begin
    //マウス直下のコントロールを取得
    LWinCtrl := FindVCLWindow(MousePos);
    //TStringGridの場合
    if LWinCtrl is TStringGrid then
    begin
      if WheelDelta > 0 then
      begin
        LWinCtrl.Perform(WM_VSCROLL, SB_LINEUP, 0);
      end else begin
        LWinCtrl.Perform(WM_VSCROLL, SB_LINEDOWN, 0);
      end;
    end;
  end;

  //この1行を忘れないこと!
  Handled:=True;

end;


【印刷プレビューコードの工夫】

1.ページ毎に作成されるImage1のBitmapを、Image2のCanvasの指定位置に複写する。
2.最初にページ数を調べ、Image2の高さをページ数にあわせて高くしておく。
3.1の複写手続きの最後に、ページ区切り線を描画するコードを追加(20240820)。

1.に関して、Image1のBitmapをImage2のCanvasに複写する手続き

  //Image1のBitmapをImage2の指定位置へ複写する
  procedure CopyBitmapToImage(Image1, Image2: TImage; DestX, DestY: Integer);
  var
    SrcRect, DestRect: TRect;
  begin
    // ソースの矩形を設定
    SrcRect := Rect(0, 0, Image1.Picture.Bitmap.Width, Image1.Picture.Bitmap.Height);

    // 目的地の矩形を設定
    DestRect := Rect(DestX, DestY, DestX + Image1.Picture.Bitmap.Width, DestY + Image1.Picture.Bitmap.Height);

    // Image2のCanvasにImage1のBitmapを複写
    Image2.Picture.Bitmap.Canvas.CopyRect(DestRect, Image1.Picture.Bitmap.Canvas, SrcRect);
  end;

    //追加(20240820)
    //ページ区切り線を表示するコードを追加
    //ペンの色を青に設定
    Image2.Picture.Bitmap.Canvas.Pen.Color := clGray;
    //ページ区切り線の太さ
    Image2.Picture.Bitmap.Canvas.Pen.Width:=3;
    //ペンのスタイルを点線に設定
    //Image1.Canvas.Pen.Style := psDot;
    //ペンのスタイルを直線に設定
    Image1.Canvas.Pen.Style := psSolid;
    //線を引く
    Image2.Canvas.MoveTo(0, DestY + Image1.Picture.Bitmap.Height); // 線の開始位置
    Image2.Canvas.LineTo(Image2.Picture.Bitmap.Width, 
      DestY + Image1.Picture.Bitmap.Height); // 線の終了位置
    //ページ区切り線の太さを元に戻す
    Image2.Picture.Bitmap.Canvas.Pen.Width:=1;
    //ペンの色を黒に設定
    Image2.Picture.Bitmap.Canvas.Pen.Color := clBlack;
    //ペンのスタイルを直線に戻す
    //Image1.Canvas.Pen.Style := psSolid;
  end;

ページ区切り線については、いろいろ試行した結果、やや太い灰色の直線が最も適している(ほどよく自己主張するが、データほどではない)と感じたので、そのように設定。上のコードにはその痕跡を残している。


1.に関して、複写する手続きを呼び出すコード。

        //Image1のBitmapをImage2の(XX, YY)の位置に複写
        case intLoop of
          0:CopyBitmapToImage(Image1, Image2, 0, 0);
        else
          CopyBitmapToImage(Image1, Image2, 0, 1169 * intLoop);
        end;


2.に関して、ページ数に応じてImage2の高さを高くするコード。

    eNum:=StringList.Count div 50;

    //PreView用TImageの初期設定
    Image2.Width := 827;
    case eNum of
      0:Image2.Height := 1169;
    else
      Image2.Height := 1169 * (eNum + 1);
    end;
    Image2.Picture.Bitmap.Width := 827;
    case eNum of
      0:Image2.Picture.Bitmap.Height := 1169;
    else
      Image2.Picture.Bitmap.Height := 1169 * (eNum + 1);
    end;

7.まとめ

(1)StringGridのデータを用紙と向きを指定して罫線付きで印刷するコードを掲載
(2)(1)のコードを流用して、印刷プレビューを表示するコードを掲載
(3)変数名を付ける時は既存の関数名等との衝突に十分に注意する。

8.お願いとお断り

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

無料で使える手書き答案採点補助プログラム

Answer Column Reader

横書き答案の採点実行時の画面です。


スキャナーで読み取った手書き答案のJpeg画像から、大問1の設問(1)なら(1)のみを抽出、一覧表示してイッキに採点。採点記号( 〇・△・× )& 得点付きで元の答案画像に書き戻し、最後に得点合計を自動計算、指定位置に描画して、返却用答案画像(A4サイズに統一/縦・横の指定は可能)を印刷できる無料の手書き答案採点補助プログラムです。

一般的な横書き答案に加え、国語で使用される縦書き答案の採点も可能です。

縦書き答案の採点実行時の画面です。


新教育課程の観点別評価にも対応。もちろん、表計算ソフトを使わずに成績一覧表の作成・印刷・CSVファイルへの出力が可能です(ただし、成績一覧表の出来栄えは、メモ程度)。

画像処理に使用しているPython用OpenCV関連のファイルサイズが巨大ですが、このファイルサイズを許容していただければ、採点現場で十分使えると(複数の高校で使用中)評価していただけました!

もちろん、完全無料。ただし、動作保証は一切ありません。作成者(僕)は開発環境のDelphi(Object Pascal)の大ファンで、この他にも自作のマークシートリーダーなどを開発・このblogの過去記事で公開していますが、学問領域で評価の対象となるようなプログラミングに関しては全くの素人です。ですから、このプログラムのご使用に際しては、あくまでも素人が趣味で作ったものであるということを十分にご理解いただき、ダウンロードから展開・実行までALL自己責任でお願いします(有償販売禁止の他は、それが唯一の使用条件です)。発見できた不具合はすべて改良改善しましたが、取り切れていない未発見の不具合もまだきっとあると思います。それでも、もし、よろしければお使いください。僕の夢のカタチ、Answer Column Reader。

手書き答案採点補助プログラム、名付けて AC_Reader です。

追記(20240929)

当Blogで紹介してきた自作のデジタル採点プログラムを一つにまとめました。次のリンク先にその紹介とダウンロードリンクがあります。この記事で紹介している手書き答案のデジタル採点プログラムAC_Readerもプログラムセットに同梱されています。

【もくじ】

1.使い方
(1)zipファイルを展開
(2)プログラムを起動
(3)スキャンした答案の画像を準備
(4)採点用画像の準備
(5)解答欄の座標を取得
 ・【座標データを追加したい場合は?】
 ・【字数制限のある解答欄座標の簡単な取得方法は?】
 ・【機械が認識しやすい解答欄】
 ・【生徒の番号・氏名も解答横に表示して採点したい】
(6)採点
 ・【全員正解を入力】
 ・【全員不正解を入力】
 ・【個別に採点】
 ・【次の設問を採点】
 ・【定型文を入力】
 ・【入力した定型文の削除・消去方法】
(7)採点状況の確認
(8)返却用答案の印刷
(9)成績一覧表の作成・印刷
2.まとめ
3.お願いとお断り

1.使い方

もくじへ戻る

(1)zipファイルを展開

ダウンロードしたzipファイルをデスクトップ上に展開します(任意の場所に置いても動作すると思いますが)。PCによっては展開(解凍)に20分程度かかることがあるようです。

もくじへ戻る

(2)プログラムを起動

展開されたフォルダ内に「AC_Reader.exe」があります。これをダブルクリックしてプログラムを起動します。

このアイコンをダブルクリックしてプログラムを起動します


初回起動時には、次のメッセージが表示されると思います。その場合は「詳細情報」(画像中、赤い枠で囲んで示した部分)をクリックします(プログラムの発行元が不明である場合に、Windows のDefender機能である SmartScreen がこの表示を出すそうです。自分の責任で実行すれば、次回からこのメッセージは表示されなくなります)。

「詳細情報」をクリックします。


すると、次の画面が表示されます。「実行」(画像中、赤い枠で囲んで示した部分)をクリックしてプログラムを起動してください。

「実行」をクリックします。


プログラムの起動時に、次のメッセージが表示されます。「はい」・「いいえ」のいずれかを選択してください。

差し支えなければ「はい」を選択してください。


Excelの採点シートを使って処理する場合は、拙作マークシートリーダーとこの手書き答案採点補助プログラムを併用することも可能です(その方法についての説明は、今回は行いません)。

マークシート方式と併用することも可能ですが、今回は「はい」をクリックしてください。


国語の試験では縦書きの解答用紙が使われますので、この手書き答案採点補助プログラムも縦書き答案の採点が出来るよう設計しました。デフォルト設定の答案書式は「横書き」です。国語の縦書き答案を採点したい場合はここで設定画面を開き、縦書きを指定してください。

今回は「いいえ」で先に進みます。


ちなみに「はい」を選んだ場合は・・・

答案の書式を指定できます。


答案の書式の設定変更を起動時に問われなくするよう設定できます。

採点する答案の書式が決まっている場合は「はい」を選択してください。


試験は毎日行われているわけではなく、定期考査として2~3か月に1回実施されるのが普通です。これくらい間が空くと、△の付け方などをどうしても忘れてしまいます。「忘れた!」と毎回のように質問がありましたので、プログラムの起動時にメッセージとして、採点方法を表示することにしました。

特に「△」の入力方法を問われることが多かったです!


この入力方法の案内は、画面右下の「終了」ボタンのとなりにある「入力方法のご案内」ボタンをクリックすれば、いつでも再表示できます。

入力方法はいつでも確認できます。

もくじへ戻る

(3)スキャンした答案の画像を準備

答案の画像は必ず「解像度200dpi程度」でスキャンし、「Jpeg画像として保存」してください。

重要 白黒の二値化画像としてスキャンしないでください。

なお、答案をスキャンする際は、次のことにご注意ください。

・答案が出席番号順に並んでいることを必ず確認してください。
・答案の向きは問いませんが、上下が揃っていることを必ず確認してください。
・試験を欠席した生徒がいる場合は、そこに未使用の解答用紙を挿入しておきます。
・消しゴムの屑等はよく払い落としておきます。
・一度に採点できる枚数は100枚を想定しています。
・答案に折り目がある場合は、なるべく平らになるよう折り戻しておきます。

スキャンした答案の画像は、科目名とクラス・講座名がわかるよう適切な名前をつけたフォルダ内に保存し、このフォルダをAC_Reader.exeがあるフォルダの「ScanData」フォルダにコピーしてください。

重要 スキャンした画像は、必ず「ScanData」フォルダ内にフォルダを作成し、保存してください。

重要 ScanDataフォルダ内のフォルダに階層構造を作らないでください。

よい例:

ScanData¥数学Ⅰ_1A

わるい例:

ScanData¥1年¥数学Ⅰ_1A

もくじへ戻る

(4)採点用画像の準備

上記の手順で、スキャンした答案のJpeg画像を所定の場所に準備したものとして説明します。

プログラムの画面右上にある「画像変換」ボタンをクリックします。

重要 採点用画像には、必ずこの画像変換プログラムが生成したJpeg画像を使ってください。それ以外の方法で作成した画像は使用できません!


次のWindowが開きます。「選択」をクリックしてください。

ScanDataフォルダ内に用意した「答案画像を入れたフォルダ」をクリックして選択します。
練習では予め用意されているSampleフォルダを選択してください。

重要 選択するのは「フォルダ」で、「ファイル」ではありません。

採点したい答案画像のあるフォルダをクリックすればOKです。
(ダブルクリックして開ける必要はありません)

サムネイル表示を見て、画像の回転の有無・回転方向を指定します。Sampleの画像で練習する場合は「なし」を指定してください。

次に画像のリサイズの有無を指定します。複合機のスキャナーを使用し、解像度200dpiでスキャンした画像の場合、80%程度に縮小すると採点しやすいと思います。答案画像をプレビューして縮小率を確認しながら作業することができます。

画像のリサイズ設定を行ったら、次に採点用画像の保存先を指定します。「参照」ボタンをクリックしてください。画像の保存先を選択するWindowが表示されます。

採点用画像の保存先は、ScanDataフォルダ内ではなく、「ProcData」フォルダです。

Procはprocessed(処理済み)の略です。

重要 ProcDataフォルダ以外の場所は、作業フォルダに出来ません!

画像の変換元として選んだScanDataフォルダ内のフォルダと同じ名前のフォルダを、プログラムはProcDataフォルダ内に自動的に作成します。ここでは、この自動的に作成されたSampleフォルダをクリックして選択し、OKをクリックしてください。

フォルダは自動的に作成されたものを選びます。

「変換実行」ボタンをクリックすると採点用にリサイズされた画像が上で指定したフォルダ内に作成されます。この処理はGDI+で書きましたので、それなりに高速だと思いますが、答案の枚数が多く、回転を伴う場合は少し時間がかかります。処理が完了するまでしばらくお待ちください。

この処理では用途の異なる2種類の画像を作成します。一つは採点マークのない各解答欄画像の読み取り元として利用する画像、もう一つは採点マークその他必要事項を上書きした返却用答案画像として利用する画像です。このようにすることで、何度でも採点のやり直しができる仕組みを実現しています。

注意していただきたいのは(めったにないことですが)、採点結果を答案画像に書き戻している最中に何らかの原因でプログラムが落ちた(クラッシュ/フリーズ)場合です。プログラムは採点結果を数値データとしてCSVファイルに書き込むと同時に、採点マークを付けて返却用答案画像にも書き込みます。CSVファイルへのデータの書き込みは一瞬ですが、返却用答案画像への書き込みには少し時間がかかります。したがって、この書き込み処理の最中にプログラムが落ちると、確かに採点した(採点データを保存したCSVファイルが存在する)のに、採点結果が正しく書き込まれていない答案画像が出来てしまうといった現象が起こります(過去1回だけ、この現象を確認しました)。このような場合には、それを発見した時点で採点済みのデータを読み込んで、再度(画像への)「書き込み」処理を実行すれば不具合を解消できます。

変換が終了すると、そのことを知らせるメッセージが表示されます。メッセージのOKをクリックすると注意のメッセージが表示されます。この注意のメッセージを確認した後、「終了」ボタンをクリックして、画像変換処理を終了してください。

終了をクリックして、この窓を閉じます。

メッセージの「OK」をクリックすると表示されるメッセージです。

画像のリサイズを行った場合は、その際設定した縮小率を試験で使用した解答用紙の残部などに必ずメモしてください。複数クラスで様式の異なる解答用紙を使って試験を行い、それぞれに74%、87%など細かな値を指定した場合は2日も経てばかなりの確率でその値を忘れます。この値を忘れた場合には、採点設定作業をすべてやり直すことになります。十分注意してください。

もくじへ戻る

(5)解答欄の座標を取得

次に解答欄の座標を取得します。その際、重大な注意事項があります。

重要 実際に試験で使用した解答用紙の画像を使用する

わるい例:
・輪転機で大量に印刷した解答用紙でなく、PCからプリンターに出力した解答用紙を使用

上のわるい例のように、実際に試験で使用した解答用紙とは異なる印刷環境で作成した解答用紙は、解答欄座標の取得には使用しないでください。見た目はほとんど同じでも、ほんのわずかな印刷位置のずれが採点作業のすべてに悪影響を及ぼします。この点には、どうか十分にご注意願います。

最初に開発したバージョンでは、拙作マークシートリーダーと同じように解答用紙に座標原点とするマーカー画像を設け、OpenCVのテンプレートマッチングの機能を利用して、マーカー画像からの距離で解答欄の座標を記録し、解答欄矩形の選択に利用していましたが、解答欄矩形を自動的に認識する方法を学んでからは、マーカー画像を利用し、手動で一つ一つ解答欄矩形を指定するよりも、解答欄矩形を自動認識して採点対象とする矩形の座標データのみを取捨選択して保存した方が、実際の採点に入るまでの準備作業時間を大幅に短縮できることがわかりました。また、輪転機を使用して印刷した解答用紙自体に解答欄の印刷位置のずれはほとんど生じないことも、マーカー画像を利用した解答欄座標の取得から、解答欄矩形を自動認識する方向へ設計を変更する大きな要因となりました。

以上の理由からご理解いただけると思うのですが、この手書き答案採点補助プログラムで使用する解答用紙は「解答欄の印刷位置がすべて揃っているもの」でなければなりません。

前置きが長くなりましたが、その具体的な方法は次の通りです。

最初に画面右上の「採点作業」ボタンをクリックしてください。


以前に使用した採点設定ファイルが見当たらない場合は、次のメッセージが表示されます。

よく読んで、OKをクリックしてください。


以前に使用した採点設定ファイルがある場合は、次のメッセージが表示されます。


使用する採点作業の入力欄に下の例のように入力します。

例:R06_考査①_物理基礎

前の方が見えませんが・・・

重要 採点作業の名称にはクラス名を入れないでください。

同じ採点作業の設定を複数クラスに適用する際、採点作業名に特定のクラスの名称が入っていると、なんとなく違和感を感じませんか?(僕は違和感を感じました)

このプログラムでは、(同一問題で実施した)試験の答案をクラス・講座毎のフォルダに準備して、同じ(一つの)採点設定をそれぞれのクラス・講座に適用して採点します。したがって、採点作業の名称には「クラス名を入れない」ことが望ましいわけです。※ クラス名が入っていても採点作業に使えないわけではありません。

採点作業名を付けたら、入力欄の右側にある「Auto」ボタンをクリックしてください。

ほんとうは「解答欄矩形の自動選択」のような名称にしたかったのですが、スペースが・・・


次のメッセージが表示されます。よく読んでOKをクリックしてください。

AC_Readerとは別に、解答欄矩形を見分けて自動選択するプログラムが起動します。このプログラムもObject Pascal に埋め込んだ Python Script で Python 用の OpenCV の機能を利用して動作します。

重要 RectangleDetector.exeを直接起動しないでください

重要 矩形検出機能はAC_Readerから呼び出して使ってください

解答欄矩形を認識するプログラムの名称は「Rectangle Detector(長方形検出器)」です。最初に画面左下にある「画像選択」ボタンをクリックしてください。


ここではフォルダではなく、「ファイルを選択」するダイアログボックスが表示されます。どれでもよいのですが、欠席者がいる場合は、解答欄に何も書き込まれていない欠席者分の解答用紙の画像を選択した方が、誤検出は明らかに減ると思います。ファイルを選択したら「開く」ボタンをクリックしてください。

重要 ここではフォルダではなく、ファイルを選択します。

重要 実際の試験で使用した解答用紙の画像で作業します。

練習では、添付したSampleフォルダ内のファイルを選択してください

解答用紙の画像が表示されます。上下のスクロールバーを操作して、図のように解答用紙の解答欄の直線部分とRectangleDetectorの画面枠の二つを見比べやすい位置に画像を上下に動かして、解答用紙が大きく傾いていないことを確認します。

スキャナーによっては、その機材特有の「クセ」のようなものがあり、どれほどきちんと解答用紙をセットしても必ず0.3~0.4°くらい読み取った画像が傾いてしまう場合があります。サービスマンの方に相談したところ、「答案に付着した消しゴムの屑がローラー等に詰まって、読み取り結果に悪影響を及ぼしているのではないか?」との意見をいただき、実際、スキャナーの可動部をきれいに清掃して試したところ、読み取り結果が改善された経験があります。しかし、その後、またすぐにその機材で読み取った画像は同じ方向に傾くようになりましたので、毎回クリーニングする必要があるのかもしれません。ただ、可動部をクリーニングしなくても、ほとんど傾かずに読み取ることもあり、結局、「これは運だ!」と割り切って、プログラム側で傾きがあった場合は修正できるよう、傾き補正の機能を追加しました。

傾き補正の機能を追加する際に気づいたのですが、回転させた画像をさらに回転させると、画像の質が著しく劣化し、これを繰り返すほどに全体がぼやけて、解答の読み取りに支障をきたす恐れがあるように感じました。そこで、画像の初期状態を保存しておき、回転は必ず初期状態のものに対して行うようプログラミングしました。「なぜ、少しずつ連続して回転させることができないのだろう?」と疑問に思われる方もいらっしゃるかもしれませんが、これがその疑問への回答です。

赤線部分を見比べて、画像の回転の要/不要を判断します。


上の画像のような状態であれば、傾きの補正は必要ありません。オプションボタンは「実行」をクリックして選択してください。また、あまりにも小さな矩形は「解答欄ではない」と判断できるよう、矩形の面積閾値を設定してあります。こちらはデフォルト設定700のままでお試しください。

傾きの修正が必要な場合は「試行」を選択し、修正量を確認してください。
修正量を確認後、その値で傾きの補正を「実行」してください。
(「試行」を選択した場合は、最終的なデータの保存ができません)


続けて答案の「横書き・縦書き」を指定します。Sampleは横書き答案ですので、オプションボタンは「横書き」をクリックして選択してください。


ブロックというのは(表現に苦しんだのですが)、「解答欄の集合をブロックとして見分けられるか・どうか」という意味です。下の図のような解答用紙の場合、1ブロックと表現しています。


ちなみに、次のような場合が2ブロックです。ご理解いただけましたでしょうか?


傾きの修正が必要な場合は、次のGUIで操作してください。「傾き修正」に✅を入れて、▲は修正値を増やす(回転方向は時計回り)、▼は修正値を減らす(回転方向は反時計回り)、「適用」は回転の実行、「やり直し」は画像を初期状態に戻します。


解答欄を取得する準備が整ったら、「解答欄取得」ボタンをクリックしてください。


誠に心苦しいのですが、PCによっては初回実行時、Python Engineの初期化に異常に時間がかかることがあります(職場のPCでは4分程度)。自分のPC(Panasonic製 Let’s note CF-QV)では数秒で終了する処理がなんでPCによってはとんでもない時間を要する処理になるのか? その理由は未だにわかりません。

とにかく、マウスカーソルが砂時計?表示になっていればプログラムは正常に機能していると思われますので、5分程度お待ちください。いったんPython Engineの初期化に成功すれば、プログラムを終了しない限り、2回目以降の実行は何の問題もなく、ほんの数秒で解答欄座標の取得が完了するはずです。

参考 横書き答案の場合、解答欄矩形の座標はx軸方向については左から右へ、Y軸方向については上から下へという順番で読み取ります。

参考 縦書き答案の場合、解答欄矩形の座標はx軸方向については右から左へ、Y軸方向については上から下へという順番で読み取ります。

ただし、解答用紙の画像が右肩上がりに傾いていた場合、Y軸(上下)方向の座標の上下関係から、より値の小さな(座標原点0,0は解答用紙画像の左上であるため)上の方をプログラムは先に読み取ってしまいます。そのため、横書き答案であっても解答欄矩形の読み取り順が右から左になる現象が発生します。こうなると解答欄矩形の座標の選択作業が著しく煩雑になってしまいます(解答欄矩形の座標自体は読み取れていますから作業ができないわけではありません)。これを防止するために、最初に答案画像の全てに対し、傾きの修正を行う必要があります。

解答欄座標の取得が完了すると、次の図にあるように解答用紙上に赤い矩形が描画されます。小さくてわかりづらいかもしれませんが、画面右上の解答欄座標の値が表示されている部分で、カーソルがある(カーソルが点滅している位置の)解答欄座標が赤の矩形で示されています。ここから必要な座標と、いらない座標を取捨選択する作業を行ってください。


上の図で示されている矩形(座標)は採点には不要です。このまま無視して次へ進んでも構いませんし、面倒でなければ不要な座標は削除することもできます。


「編集」ボタンをクリックすると、キャプションが「編集中」に変わり、カーソル位置の座標が選択された状態になります。DELキーを押し下げして、不要な座標を削除します。

次の図は(削除作業を行わずに)上の図の状態から↓矢印キーを1回押し下げして、カーソルを2行目に移動させた状態を表しています。不要な解答欄座標の削除作業を行った場合は、自動的にこの状態になります(1行目にあった不要な座標は当然消えています)。

カーソルを下の行へ移動させて、解答欄矩形のみを選択(移動)して行きます。


2行目の座標が示す矩形はまさに解答欄ですから、これは必要な座標ということになります。このような座標は「移動」ボタンをクリックして、必要な座標ばかり集めたメモの方へ移動させます。次の図は2行目の座標を移動させた直後の状態です。

必要な解答欄座標のみを選択します。

下向きの矢印キーを押す。必要な座標であれば「移動」ボタンで下のメモに移動する。この作業を繰り返して採点する順番になるよう、解答欄の座標をすべて取得します。次の図は一通り、解答欄の座標を取得した状態です。


続いて正しく解答欄座標が取得できていることを確認します。上の図の移動済み解答欄座標が表示されているメモ(赤枠内)の先頭の座標データをクリックしてください。メモは必要であれば上にスクロールしてください。メモの先頭の座標データをクリックしたら、答案の画像も上にスクロールしてください。画面は、次の図のようになります。

メモ内のフォーカスがある座標データに該当する矩形が赤枠で示されています。


このまま、下向きの矢印キーを次々に押し下げして、赤枠で示される解答欄矩形が必要数あるか・どうか、及び、採点順に並んでいるか・どうかを確認して行きます。

もくじへ戻る

【座標データを追加したい場合は?】

様々な事情から、座標データを後から追加・変更したい場合もあるかと思います。例えば、次の図のように青枠で囲った解答欄AとBを抱き合わせて採点(両方正解で〇等)したい場合です。

青枠部分を抱き合わせて採点したい場合も当然あるかと思います。


このような場合は、該当の座標データの「末尾」にフォーカスした状態で(=座標データの末尾にカーソルを置いて)、「移動」ボタンの隣にある「追加」ボタンをクリックし、さらにEnterキーを1回押し下げして改行します。次の図は、その状態を示します。

「追加」ボタンのキャプションは「追加中」に変わります。


次に、画面の真ん中よりやや右にある追加ボタンをクリックします。


答案画像の上に赤枠の矩形が表示されます。この矩形を新しく解答欄座標を取得したい解答欄に重なるように移動・変形してください。矩形を移動させたい時は、矩形の上の横線中央よりやや右の位置をポイント(マウスのカーソルを載せる)すると、マウスカーソルが上下左右の白い矢印に変わり、ドラッグアンドドロップできる状態になります。

任意の座標を取得可能です。


抱き合わせて採点したい解答欄を矩形で囲んだら(下の図のような状態)、キャプションが「取得」に変わったボタンをクリックします。すると、ボタンの右側に、現在表示されている矩形の座標が表示されます。同時に、この矩形データはクリップボードにも送信されています。


続けて、右側のメモ内の先ほど改行して空行になっている箇所をクリックしてCtrlキーを押しながらVキーを押す(右クリックして表示されるサブメニューから「貼り付け」を選択)等して、取得した座標データを付け加えます。正しくメモに追加できたら、メモの上の「追加中」ボタンをクリックして、キャプションを「追加」に戻します。

上下の矢印キーを押して、解答欄Aの座標を探し、「追加」ボタンをクリックして、メモを編集可能な状態に変更、データを削除します。削除後、編集が終了したことをPCに伝えるため、「追加中」ボタンをクリックして「追加」に切り替えます。

解答欄Bの座標も、解答欄Aと同様に作業してメモから消去します。

注意 「追加中」状態で作業しないとエラーが発生します!

もくじへ戻る

【字数制限のある解答欄座標の簡単な取得方法は?】

例えば、次のような多数の細かい枠で構成された字数制限のある解答欄がある場合、このまま矩形座標の自動取得処理を実行すると一つ一つのマス目の座標をもれなく取得・表示してしまいます。

解答欄を構成する枠がすべて実線の場合、解答欄座標の取得が煩雑になります。


このような場合は、解答欄を作成する段階で、外枠のみ実線で描き、内部の枠はすべて「点線」で描くようにします。点線は、色が薄く、間隔の狭い、細い点線でなく、次の図に示すように、色が濃く、間隔が広い、太い点線を使用してください。

解答欄内部の枠を「点線」で描くとプログラムは外側の枠のみを解答欄座標として認識します。


実は、最初の段階からこの「字数制限のある解答欄の認識処理をどうするか?」という問題は大変気になっていたのですが、親しい国語の教員が作成した解答用紙をスキャンして、解答欄の座標を自動取得する作業を手伝った際、解答用紙の点線部分をプログラムが認識しないことを偶然発見、大喜びしたというのが本当です。最初から、僕に、そのような知識があったわけではありません。

偶然とは言え、僕の不出来なプログラムの動作を信じて、それでも使いたいと言ってくれた彼女に、心から、ほんとうに、こころから、「ありがとう」です。巡り合ってから、もう、30年になりますが、Sさん、ほんとうに、ありがとう! あなたがいてくれて、ほんとうに、よかった!!

ただし、これは「諸刃の剣」で、何らかの原因で解答欄の枠線の一部が途切れていると、プログラムは正直にその部分は「矩形ではない」と判断して、座標データの取得対象から除外します。ですので、解答用紙を印刷する際は、解答欄が完全に実線で囲まれているか・どうかを、よく確認してから印刷する必要があります。

解答欄の枠線の一部が途切れていると座標を取得できません!

もくじへ戻る

【機械が認識しやすい解答欄】

解答欄を構成する矩形は必要最小限度に留めるのが、解答欄座標を自動認識・取得する作業を効率よく進めるための何よりのポイントです。

解答欄を構成する矩形は必要最小限にしてください。

もくじへ戻る

【生徒の番号・氏名も解答横に表示して採点したい】

重要 横書き答案の採点時のみに利用できる機能です。

こちらは同僚からの要望があって付け加えた機能です。解答用紙の氏名欄の画像を取得して、採点時に該当生徒の解答欄の横(位置の指定も可能)に、試験を受けた生徒の出席番号や氏名を表示できます。「追加」ボタンをクリックして赤枠の矩形を描画・適切な位置へ移動後、解答欄矩形としての「取得」の代わりに、「氏名欄取得」のボタンをクリックして、次の図に示すようなかたちで解答用紙の氏名欄の座標を取得してください。ただし、指定する矩形の高さは、解答用紙の解答欄の高さの最小値を超えないよう、十分注意してください。

重要 「解答欄の高さの最小値を超えない高さ」で範囲指定してください。

座標が空欄でなければ、氏名情報ありとして保存されます。


最後に、取得した解答欄の座標を保存して作業は終了です。画面右にある「保存」ボタンをクリックしてください。


次の確認メッセージが表示されます。

「はい」をクリックして、解答欄座標を保存します。


採点作業名として設定した名称で、イニシャライズファイルが作成されています。この採点作業名をクリックするとダイアログの下のファイル名が採点作業の名称に変化します。この状態で「保存」ボタンをクリックしてください。

採点作業名を設定した際にiniファイルも作成されています。
解答用紙の種類に合致するファイルをクリックして選択・上書き保存します。


次のメッセージが表示されます。「はい」をクリックしてください。

既存のiniファイルに上書きします。


解答欄の数によっては、少し(数秒程度)時間が必要です。保存作業が完了すると次のメッセージが表示されます。このメッセージが表示されるまで、何もしないでそのままお待ちください。


画面右下隅にある「閉じる」ボタンをクリックしてプログラムを終了します。解答欄矩形の座標の候補を表示する上のメモにデータがある場合は、「閉じる」をクリックすると、次の確認メッセージが表示されます。「はい」をクリックしてプログラムを終了させてください。


以上で、解答欄の座標の取得作業は完了です。

もくじへ戻る

(6)採点

解答欄座標取得後、すぐに採点を実施する場合は、タスクバーにAC_Readerが眠っていますので、クリックして起こしてください。そうでない場合は、AC_Readerを起動してください。

解答欄矩形取得直後、AC_Readerはタスクバーに眠っています。
タスクバーにある上のアイコンをクリックしてください。
AC_Readerが目覚めます!


画面の右上にある「採点作業」ボタンをクリックしてください。


次のメッセージが表示されます。既存の採点設定を利用して採点しますので「はい」をクリックしてください。


バルーン型のヒントが表示されます。V マークをクリックして表示される選択肢から採点設定ファイルを選んでください。


採点設定ファイルを選んだ直後の状態です。


画面中央には、次のメッセージが表示されます。OKをクリックするとフォルダの選択ダイアログが表示されます。


採点したいクラスのフォルダを選択してOKをクリックしてください。

採点したいクラスのフォルダを選択して、OKをクリックします。


採点結果を記録したCSVファイル(場所はユーザーに提示しません)がない場合には、次のメッセージが表示されます。


画面は次のようになります。

個人識別情報が保存されているので、番号や氏名も表示されています。


画面上方、中央よりやや右に、どこにもドッキングしないフローティング状態の必要最小限の採点機能をまとめたパネルがあります。このパネルのタイトルバーの部分を左クリックしてドラッグ&ドロップすると任意の位置へ移動できます。採点しやすい位置へ移動してお使いください。

もくじへ戻る

【全員正解を入力】

解答をざっと見て、過半数が正解であるような場合は、全員に正解を入力し、後から不正解の解答のみチェックして、採点を × に変更します。

この設問の得点は2点として、全員に2点を入力します。


ComboBoxの選択肢に「2」を指定して、「入力」ボタンを

採点記号の位置や大きさは「設定」から変更できます。


設定画面から、採点記号の表示位置や大きさなど、各種設定を変更・保存できます。

何も変更せず、デフォルト設定のまま、みなさんお使いのようです。

もくじへ戻る

【全員不正解を入力】

フローティングパネルの得点欄に0を設定して、入力をクリックすれば、全員不正解となります。

0(ゼロ)は〇(まる)と見間違える可能性があるため、
デフォルト設定では、不正解の場合、得点0を表示しません。

もくじへ戻る

【個別に採点】

重要 左手で入力作業、右手は選択作業(クリックに専念)

・正解 〇 を入力

まず、個別に採点する際の正解入力は、次のように行います。

解答欄の中心付近をクリックして、得点に相当する数字キーを押します。


解答欄に採点記号〇と得点が描画されます。

・不正解 × を入力

不正解を入力する場合は、次のように操作してください。

× は「Batsu」だから「B」キーに割り当てました。


もちろん、数字キーの0(ゼロ)でも × を入力できます。ただ、0はちょっと位置が遠い・・・

・部分点あり △ を入力

部分点ありの場合は、採点記号△と部分点を入力します。方法は、次の通りです。

「部分点あり」のフラグは「-」記号の有無です。
プログラムは負の数の入力を部分点ありと判定しています。
(合計点は絶対値で計算するので、問題ありません)
部分点ありの場合、採点記号△と得点を表示

重要 最後に「書込」を忘れずにクリックします。

もくじへ戻る

【次の設問を採点】

右向きの三角マークをクリックすると、次の設問の解答欄が表示されます。

上で解説した手順で、採点を行います。

右側の操作パネルからも同じ操作を実行することができます。

もくじへ戻る

【定型文を入力】

記述式の設問等で「ここまで何点」のような定型文を記録しておいて適宜入力できます。

「設定」をクリックして、「入力定型文の編集」にチェックを入れます。


画面左上に次の表示が出ますので、内容を編集します。「記録」ボタンをクリックすると編集内容が保存されます。保存後、「入力定型文の編集」のチェックを外し、編集欄を非表示にします。


定型文を入力したい設問の解答欄を採点します。採点後、定型文を入力したい箇所の左上隅あたりにマウスのカーソルを持ってきて右クリックします。表示されるサブメニューから「定型文入力」を選択(クリック)してください。

重要 採点しないと定型文入力はできません!

「定型文入力」をクリックします。


編集済みの定型文が指定位置に入力されます。

もくじへ戻る

【入力した定型文の削除・消去方法】

入力済みの定型文を削除・消去するには、まず、定型文を削除・消去したい解答欄の真ん中付近をクリックします。次に、右側のGridコントロールの青く反転表示された数値を消去して、Enterキーを押してください。

もくじへ戻る

(7)採点状況の確認

現在の採点状況を、解答用紙全体の画像を表示して確認することができます。次のように操作してください。

画面右側の中ほどにある「返却答案を表示」をクリックします。画面は現在選択されている生徒の解答用紙が表示されます。画面をスクロールして、採点状況を確認してください。


移動のボタンで、別の生徒の答案も確認することができます。

左のボタンで「一枚前へ」、右のボタンで「次へ」移動します。

もくじへ戻る

(8)返却用答案の印刷

採点が終了したら、返却用の答案を印刷します。まず、画面右下のプリンタの選択肢から、出力先のプリンタを選択します。次に「合計の印刷」の有無を指定します。「有」を選択した場合は、次の案内が表示されます。


印刷は採点終了後、最後に実行するので、採点と印刷の処理をお互いに行ったり来たりすることは「ない」と判断し、印刷実行後はプログラムの終了のみ可能となっています。

「いいえ」をクリックした場合は、採点処理が継続されます。「はい」をクリックした場合は、次の案内が表示されます。

出力するプリンタの確認です。


「はい」を選択すると、次に合計点の印刷処理の案内が表示されます。


フォントサイズは、40~50程度が適切な場合が多いように思います。半角の数字で入力してOKをクリックしてください。


OKをクリックすると、次の案内が表示されます。


OKをクリックして、合計点印刷位置を指定します。


クリックした瞬間に自動計算された合計点が指定位置に表示され、次のメッセージが表示されます。


よろしければ「はい」を、位置の指定をやり直す場合は「いいえ」をクリックします。「いいえ」をクリックした場合は、再度、合計点を印刷する位置の指定をやり直してください。その際、前回に指定した位置にゴーストというか、残像のようなものが残りますが、実際の印刷時にはゴースト・残像は印刷されません。

「はい」をクリックした場合は、次のメッセージが表示されます。


画面右下の「印刷」ボタンをクリックしてください。

バルーンヒントが案内します。


「印刷」をクリックすると、次のメッセージが表示されます。


OKをクリックすると、プリンタの設定画面が表示されます。この画面はお使いのプリンタにより異なりますが、重要なチェックポイントは次の3点です。

重要 印刷する用紙がA4版であることを確認する

重要 印刷用紙の縦・横指定を答案に合わせて指定する

重要 両面印刷は必ずOFFに設定する

設定画面を閉じると、次のメッセージが表示されます。


「はい」をクリックした場合は、全員分の返却用答案がプリンタへ出力され、次のメッセージが表示されます。


「いいえ」をクリックした場合は、次のインプットボックスが表示されます。

答案の通し番号を入力してOKをクリックしてください。
採点対象がクラスであれば、出席番号となります。


OKをクリックするとプリンタへ印刷データを送信後、次のメッセージが表示されます。


「はい」をクリックすると、再びインプットボックスが表示され、引き続き単票の印刷処理が継続して行われます。「いいえ」をクリックした場合は印刷処理を終了します。画面右下の「終了」ボタンをクリックして、プログラムを終了してください。その際、次の案内が表示されます。

「はい」をクリックすると、プログラムが終了します。

もくじへ戻る

(9)成績一覧表の作成・印刷

画面右にある「成績一覧表を作成」の「Excelを使わずに作成します!」をクリックします。


画面は成績一覧表作成モードになります。クラス単位の採点である場合は、学年・クラスを指定(選択)します。

重要 予めsNameフォルダに生徒氏名データを用意しておきます。

重要 講座単位の処理の場合も、講座名等で氏名データを準備しておきます。

重要 氏名データの並び順は、答案の並び順と一致させてください。

クラスを指定する場合は、直接入力してください。


講座を指定する場合は、学年・組は「空欄」のまま、「観点区分入力」に進んでください。

設問毎に「知識・技能」は1、「思考・判断・表現」は2を入力します。


観点別評価の区分を入力後、「保存」をクリックしてください。

保存後、「採点結果表示」をクリックして、採点結果の一覧を表示します。

氏名データは架空のもので、得点はダミーデータです。


学年・組を「空欄」で処理していた場合は、ここで「講座等」の名票を選択します。

氏名データは架空のもので、得点はダミーデータです。


次に、合計点が0の生徒について、欠席者であるか(平均点の計算から除きます)・真に0点であるのかを指定する処理を行います。「欠席者を除外」をチェックしてください。


合計点が0の生徒がいる場合は、次のメッセージが表示されます。

試験を欠席していた場合は「はい」を、0点であった場合は「いいえ」をクリックします。
(ここでは「はい」で処理します)


「再計算」ボタンをクリックして、平均点他の再計算を実行します。


プレビューをチェックして、印刷プレビューを表示します。


プレビューをチェックすると、印刷プレビューとともに、次のメッセージが表示されます。

印刷プレビュー画面(氏名データは架空のもので、得点はダミーデータです)


表示されるメッセージ。


プレビューのチェックを外すと、次のバルーンヒントが印刷ボタンを案内します。


「印刷」ボタンをクリックすると、印刷設定のダイアログが表示されます(ダイアログはプリンタにより異なります)。成績一覧表はデフォルトで「A4・縦置き」印刷に設定されます(この設定を変更することはできません)。


OKをクリックすると、印刷データがプリンタへ送信されます。送信が完了すると、次のメッセージが表示されます。


なお、これとは別に、このプログラム用に作成したExcel Book(添付したマクロ有効テンプレートのコピー)へ採点結果を出力し、成績一覧表及び個人成績票を作成する機能もこのプログラムにはありますが、これに関する説明はまた後日、このblogに掲載できたら・・・とも、考えています。が、ほとんど!!どなたにもお読みいただけないであろうMy blogですので、もしかしたらそれは、はるか未来の話になるかもしれません。

ただ、PCの操作及びExcel Bookの扱いに慣れた方なら、このプログラムに添付したマニュアル(以前のバージョンのものなので画面や内容が現行バージョンと若干異なります)と、マクロ有効のExcel Bookの式とマクロをご覧いただければ、操作方法並びに機能の概要はおわかりいただけるのではないかと考えます。

このExcel Bookに対する出力機能は、(ここに掲載した)成績一覧表を独自に作成する機能をこのプログラムに追加する以前に作成し、実際の試験の採点で何回も活用済みのものですが、こちらも動作保証等は一切ありません。もし、お使いになる場合は自己責任でお願いいたします。

以上で、成績一覧表の印刷は終了です。

もくじへ戻る

2.まとめ

今回、掲載した手書き答案採点補助プログラム(新教育課程観点別評価「知識・技能」及び「思考・判断・表現」の評価に対応)の概要は以下の通りです。

【出来ること】

(1)スキャナーで読み取った答案画像から設問ごとに解答欄を抽出して一括採点。
   ※ 答案画像からの解答欄座標の取得は矩形認識プログラムで(半)自動実行。
(2)解答欄画像の隣に受験者氏名等を表示(予め氏名欄等の読み取り設定が必要です)。
(3)記述式の解答に対する定型文コメントの入力。
(4)採点結果を出力した返却用答案画像の作成と印刷(A4版限定・縦横指定は可能)。
   ※ 得点合計を自動計算、返却用答案の指定位置に印刷可。
   ※ B4やA3の答案画像は、A4サイズに縮小して印刷します。
(5)表計算ソフトを使わずに、成績一覧表(教科担任用)を作成。
(6)成績一覧表データをCSVファイルに出力(観点別評価のうち、2観点の評価に対応)。
(7)拙作マークシートリーダーを利用した試験との併用も可。
   ※ マークシートの読み取りプログラム一式も同梱しています。
(8)PDF化した答案画像をJpeg画像化して採点(添付のPdf2Jpg.exeを使用)。

【出来ないこと】

機械学習による手書き文字の認識にも過去にチャレンジ(〇・× 及びカタカナのアイウエオを判定)したことがあるのですが、どう頑張っても認識率が100%にならない(控えめな表現で9割程度は正しく認識するのですが、解答欄からはみ出した文字や、それは「ア」でなく「つ」と「ノ」でしょ!みたいな文字を構成する部品が極端に離れている字?や、大きく傾いた文字は正しく認識できない)ので、残念ですが、この機能は搭載を見送りました。

〇×記号やカタカナ一文字の認識結果を目視でイチイチ確認するのはどう考えても二度手間です。現時点では、ヒトが行った採点結果を機械にチェックさせる方向で活用した方がいいかもしれません。学習モデルの作成については、Pythonを利用した事例がWeb上に読み切れないほど存在しますが、(僕が実験した範囲では)それらよりMicrosoftのLobeで作成した学習モデルの方が高い認識率を示しました。このことについては当blogの過去記事でその例を幾つか紹介しています。ここで紹介した採点補助プログラムには搭載を見送った自動採点機能ですが、僕の実験結果が何かの参考になれば幸いです。

もくじへ戻る

3.お願いとお断り

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

もくじへ戻る

PDFを画像化したい!

PDF文書ファイルをページごとに画像化したくて様々な方法を探し(AND 試し)ましたが、なかなか「コレだ!」と思える方法が見つからず、ほぼ1日を費やして(ダメか・・・)とあきらめかけた、まさにその時、やっと出会えたのが Swanman (id:tales)さんの Blog の次の記事でした。

外部ライブラリ無しでPDFを描画する。

https://lyna.hateblo.jp/entry/20160625/1466783114

「それならWeb上の変換サービスを使えば・・・」というご意見はもっともですが、利用目的が「スキャナーでPDFファイル化した個人情報を含むデータの画像化処理」で、それなりに枚数もあるし・・・どこかのサーバーにアップして・・・というのは絶対に避けたいところです。

Swanman (id:tales)さんのBlogの記事を頼りに、何とか目的を実現することができました。これは、その際行ったことの備忘録です。

【もくじ】

1.FireMonkeyならPDFiumがある
2.WinRT
3.まとめ
4.お願いとお断り

1.FireMonkeyならPDFiumがある

最近、何かを調べる時はCopilotさんにお伺いをたてることが多いです。そこで、今回も早速きいてみました。

自分:
DelphiでPDFを画像化したいときはどうしたらいいですか

するとPDFiumというライブラリがあるとCopilotさんが教えてくれました。ただ、紹介されたのは「PDFium Component Suite for FireMonkey」だったので、どちらかというとWindows専用にVCLコンポーネントを使ってプログラムを書きたい自分的には(FireMonkeyはちょっと・・・)という感じだったのですが・・・、「溺れる者は藁をもつかむ」と、まさにそんな気持ちでありましたから・・・記事に目を通してみることに。

Copilot さんが教えてくれた FireMonkey 用の PDFium の紹介ページはこちら

PDFium – Delphi/C++Builder FireMonkeyアプリケーション向けPDFエンジン

https://blogs.embarcadero.com/ja/pdfium-pdf-engine-for-your-delphi-c-builder-firemonkey-applications-ja/

そこで紹介されていたコードの一部(抜粋して引用)。

  Bitmap := FPdf.RenderPage(0, 0,
    Round(PointsToPixels(FPdf.PageWidth, PixelsPerInch)),
    Round(PointsToPixels(FPdf.PageHeight, PixelsPerInch)));
  try
    Bitmap.SaveToFile(FileName + '_Page' + IntToStr(I) + '.jpg');
  finally
    Bitmap.Free;
  end;

このRenderPageなる手続きが使えれば、目的はカンタンに実現できそう。
それに PDFium はライセンス的にも問題なさそうだし・・・

(VCL版があればなぁ・・・)

ダメ元で探してみるとPDFiumのVCL版を発見。早速、ダウンロード!

ahausladen/PdfiumLib: PDF VCL Control using PDFium

https://github.com/ahausladen/PdfiumLib

大喜びで、使ってみたんだけれど・・・

RenderPageが「なぁーい!」 T_T

なんと、PDFiumのVCL版には RenderPageがありません でした!!

(間違えていたら、ほんとうに、ごめんなさい)

必要なものは全部 uses して、exeと同じフォルダに PDFium.dll も用意したのですが、何か足りないものでもあったのでしょうか?

なら、FireMonkey で書けばいいじゃん!って気持ちにそう簡単にはなれないのが人間です。実は、それなりに頑張ってFireMonkeyでいくつかのプログラムをこれまでに書いてみたのですが、エラーの原因解明に手間取ることが多くて、Windows PCしか使わない自分にとって、無理してまでクロスプラットフォームでプログラムを書くメリットはないように思えてきて・・・やはり、書きなれたVCLの方が自分にはあってる・・・と、どうしても、そう思ってしまい・・・

それに、ここでFireMonkeyにすると、この先もずっとFireMonkeyで書くことに。

現在、書き続けている一連の採点処理用途のプログラムは、すべてVCLで書いてきたこともあり、大変残念ですが PDFium の使用は、ここで断念することに決めました。

2.WinRT

Swanman (id:tales)さんのBlogの記事に紹介されていた Windows Runtime(略称がWinRT)なるものの存在を、これまで僕は知りませんでした。Win32 API なら名前だけは知ってましたが、どうやらそれより新しいAPI であるとのこと。難しいことはわかりませんが、このWinRTでPDFの画像化ができるのであれば、Windowsの機能を使ってそれが実現できるのですから、新規に何かライブラリを追加したりする必要がなく、それこそ理想的です。

ようやく発見したSwanman (id:tales)さんのBlogの記事を読んで、とりあえず、上記の内容だけは確実に理解できました。早速、アップロードされていたユニットとサンプルコードを有難くダウンロードさせていただき、Project2 のサンプルコードを実行(F9)してみました。すると・・・

[dcc32 致命的エラー] PdfDoc.pas(7): F2613 ユニット ‘WinAPI.Foundation.Types’ が見つかりません。


これは困りました。解決方法がまったくわかりません!

(APIだから、もしかしてMicrosoftさんのほうで、ここ数年のうちに何か変更があったのかな・・・)

pas、すなわち必要なユニットはすべてPathの通ったところに置きましたから、これはライブラリ自体が「ない」ということなのかな・・・?

(ない袖は振れないから・・・、ダメもとで外してみようか・・・)

唯一、思いついた解決方法にならない強引な戦法で前進?することに決め、問題の行をコメントアウト。幸い、誰もこのライブラリを参照していなかったようで、「未定義の識別子」エラーは表示されません。これはラッキー!とばかりに、このまま再度、実行(F9)します。

すると・・・、別のユニットでもうひとつ同じエラーが発生。

[dcc32 致命的エラー] WinAPI.Data.Pdf.pas(21): F2613 ユニット ‘Winapi.Foundation.Types’ が見つかりません。


もう、ムチャを承知で無理やり、前進! 前進!!
この行も // コメントアウト。こちらもラッキーなことに「未定義の識別子」エラーは表示されません。これ幸いと、再び、実行(F9)。

このプログラムが動かなくて困るのは僕だけです。他に誰一人、悲しい思いをする人はいません。それだけが唯一の救いです。

結果は・・・

何の問題もなく、
プログラムは無事に動作しました!

開いたPDF文書はページごとにJpeg画像として保存できました☆

PDFファイルの指定ページをJpeg画像として保存できました!


ちなみに Types の他にはどんなメンバーがいるのかと思い、WinAPI.Foundation をコピペして、n の後ろに「 . 」を入力してみると、入力補完機能が表示した選択肢は・・・

Collectionしかありませんでした


もちろん、誰もこのユニットを参照していないことは明らかなので、次のようにしても動作しました。

存在するからusesしてもエラーにはなりません・・・
(usesするとexeの大きさは少しだけ大きくなりました)


(もしかして、必要なユニットがまだ他にあるのかな・・・)

(でも、どこからも参照されていなかったし・・・)

(何で uses されてたんだろー?)


結局、Winapi.Foundation.Types の謎は解けませんでしたが、PDF文書ファイルの各ページをJpeg画像として保存したいという目的は実現することができました。

今回、僕が探した範囲ではDelphiを使ってPDF文書の画像化を実現する方法は、FireMonkeyでPDFiumを使う方法と、このWinRTを使う方法の二つしか見つけられませんでした。したがって、FireMonkeyという選択肢を選ばないのであれば、WinRTを使うこの方法しか、選択肢はありません。

そのような意味で、Swanman (id:tales)さんのBlogの記事にあった情報は大変貴重な情報であると思いました。Swanman (id:tales)さんにこころから感謝しております。ありがとうございました。

3.まとめ

(1)Delphiで、PDF文書の各ページを画像化する方法はある。
(2)FireMonkeyでPDFiumを使用。
(3)WinRTのAPIを使う方法もある(VCLで動作確認/FireMonkeyでの動作は未確認)。

今回の記事を書くにあたって、プログラムの動作確認に使用したPCの環境は次の通りです。

【デバイスの仕様】
プロセッサ 11th Gen Intel(R) Core(TM) i7-1185G7 @ 3.00GHz 3.00 GHz
実装 RAM 32.0 GB (31.7 GB 使用可能)
システムの種類 64 ビット オペレーティング システム

【Windowsの仕様】
エディション Windows 11 Pro
バージョン 23H2
OS ビルド 22631.3296
エクスペリエンス Windows Feature Experience Pack 1000.22687.1000.0

【Delphiのバージョン】
Embarcadero® Delphi 12 バージョン 29.0.50491.5718

4.お願いとお断り

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

0.1ずつ増減したい!

画像をほんの少しだけ回転させるプログラムを書いた。そこでの角度の指定は、最大でも1°未満を想定(整数値だと大きすぎ)。そこで、回転角度の値を指定するTEditへ、キーボードから直接入力する場合は、0.01刻みでのインプットを可能としたが、TEditとTUpDown等の数量を扱うVCLを組み合わせての入力、つまり、マウスで▲・▼マークをカチカチクリックして値を増減させて入力する場合には、TEditに設定する値は「0.01」刻みでは小さすぎるから、「0.1刻みで増減」させようと思った・・・んだけれど、これが難しかった。

いちばんの問題はコレ(差し引きゼロなら、ゼロと表示すること)
整数値なら、なんでもないことなのに・・・

【訂正】 これが難しかった → これが、(僕には)難しかった。

最初はTEditとTUpDownを組み合わせて、これを実現しようとしたが中々上手く行かず、仕方がないから、TEdit1個とTButton2個を組み合わせて、なんとか当初の目的を実現。その後、当初、気がつきませんでしたが、TEditとTUpDownの組み合わせで、Float値を増減させる方法も見つけました。これはその覚え書きです。

【もくじ】

1.TEditとTUpDownでチャレンジ
2.TEditとTButton2個でチャレンジ
3.増減値が0にならない理由
4.コードを修正
5.0.1ずつ増減
6.まとめ
7.お願いとお断り

1.TEditとTUpDown

とりあえず定番と思われるTEditとTUpDownの組み合わせ。

FormにTEditを1つおいて、Textプロパティに0を代入。
TUpDownも1つ用意。

UpDown1のAssociateプロパティにEdit1を設定

Edit1にUpDown1がくっついた!

くっつく位置は左右いずれかを指定できるらしい。

udRightなら右にくっつく

画像の回転が目的だから、とりあえずUpDown1のMaxプロパティは90、Minプロパティは-90に設定。
(実際に使用する値は1°未満の予定)

MInプロパティを0のままにして設定し忘れると、大変なコトに・・・
(負の数が入力できなくなります)

で、Edit1の値を0.1ずつ増減させる「UpDown1Click手続き」を次のように作成。

procedure TForm1.UpDown1Click(Sender: TObject; Button: TUDBtnType);
var
  Value: Real;
begin
  //注意:このコードは、期待通りに動作しません
  Value := StrToFloatDef(Edit1.Text, 0);
  case Button of
    btNext: Value := Value + 0.1;
    btPrev: Value := Value - 0.1;
  end;
  Edit1.Text := FloatToStrF(Value, ffNumber, 1, 1);
end;

実行(F9)して、▲ボタンを3回クリックしたところ

クリック1回目
クリック2回目
クリック3回目

1ずつ、増加してる・・・。

なんでかなーって、ちょっと思ったけど、UpDown1のincrementプロパティを見て納得。

Incrementがデフォルト「1」になってる・・・

足したり、引いたりしている 0.1 はどこに消えたのか・・・?

とりあえず、原因の一つはコレだ☆
やりたいのは「1」ずつじゃなくて「0.1」ずつ増減だから、そう!

Increment プロパティを 0.1 に変えてみた *(^_^)*♪

そしたら、Delphiに怒られた (T_T)

ひー(心の悲鳴)

Incrementプロパティには、整数しか設定できないようだ。

整数・・・ そぉか、0ならOK?
Value(=Editに表示されている値) に 0.1足したり、引いたりしてるから、これでイケる?

Increment 0 を設定 やった! これで完璧だ☆

実行(F9)すると・・・

▲・▼どっちを何回クリックしても「0」のまま・・・

Value + 0.1や、Value – 0.1はどこに消えた?

1.1 とかになってたから、完全に無視されてるわけでもなさそうだけど・・・???

Increment プロパティが整数値指定だとわかった時点で、なんか嫌な予感がしたんだよなー
他にも、Maxとか、Minとか、入力を制限する値(こちらも整数値で指定)もあるし・・・

ここで謎を追いかけて、無駄に時間を使うより、TUpDownは「整数値専用」と決めて、他の方法を試すことに決定。※ 実際、TEditとTUpDownの組み合わせで、小数値の増減も可能です(後述

2.TEditとTButton2個でチャレンジ

Edit2の右に、Buttonを2つ置いて、こんなふうにしてみた。

見た目の美しさは二の次。目標の実現が最優先。

で、コードは Copilotさん に教えてもらった☆ その質問内容と答えのコードがこちら。
(コード部分はコピー可能だったから著作権的な問題はないと判断)

Q:DelphiでTEditの値を0.1ずつ増減するにはどうしたらいいですか?

A:Copilotさんが教えてくれたコード。

procedure TForm1.Button1Click(Sender: TObject);
var
  Value: Double;
begin
  if TryStrToFloat(Edit2.Text, Value) then
  begin
    Value := Value + 0.1;
    Edit2.Text := FloatToStr(Value);
  end
  else
    ShowMessage('Invalid number');
end;

procedure TForm1.Button2Click(Sender: TObject);
var
  Value: Double;
begin
  if TryStrToFloat(Edit2.Text, Value) then
  begin
    Value := Value - 0.1;
    Edit2.Text := FloatToStr(Value);
  end
  else
    ShowMessage('Invalid number');
end;

これなら間になんにも入らず、直接Editを編集してるから、絶対、大丈夫 だろ?
最初からコレでよかったんだ・・・ みたいな気持ちで、コピペして編集し、実行(F9)。

▲をカチカチ、2回クリックしました。イイ感じです!

で、減算(▼クリック)は、初期値が上の 0.2 のところから始めると・・・

クリック1回目。そうそう、それでOK!

次の▼クリックで、値は「0」になるはず。

ところが・・・

クリック2回目。

はぁ? 0.1 – 0.1 = 0.0(ゼロ)になるはずなんだけど・・・
ナニ、コレ?

クリック3回目。これはOKなんだけど・・・

3.増減値が0にならない理由

そうでした。コンピュータは小数の演算が苦手でした。

僕自身、前にさんざん苦しみました。なんで上の計算が「0」にならないのか?

その理由は、次の記事をご参照ください。

Win11の23H2より前のバージョンなら、正しいと思える答えにたどり着くまでGoogle先生に質問を繰り返すのがこれまでの問題解決の定番と言える方法でしたが、今はとなりにCopilotさんがいてくれます。

期待を込めて、訊いてみました。

Q:Delphiで0ではなく、5.54975987041018E-18と表示されます

以下、Copilotさんの返答の概要です。

・浮動小数点数(DoubleやSingle型など)は完全な精度で表現されない。
・これは、すべてのプログラミング言語で共通の問題。
・解決策の一つは、ある閾値(epsilon)よりも結果が小さい場合は 0 と表示。

完全に納得。

Copilotさん、優秀!
ぼく、きみのファンになりました☆

で、以下、Copilotさんが教えてくれた閾値を設定したコードです(コメント文は短縮)。

var
  Value: Double;
  Epsilon: Double;
begin
  Epsilon := 1E-15;  //閾値を設定
  Value := SomeCalculation();  //計算を実行
  if Abs(Value) < Epsilon then
    Value := 0;
  Edit1.Text := FloatToStr(Value);
end;

4.コードを修正

Copilotさんが教えてくれたコードを読んで、「0.0」と表示されるように修正しました。

procedure TForm1.Button3Click(Sender: TObject);
var
  Value: Double;
  Epsilon: Double;
begin
  Epsilon := 1E-15;  //閾値を設定
  if TryStrToFloat(Edit3.Text, Value) then
  begin
    Value := Value + 0.1;
    if Abs(Value) < Epsilon then
    begin
      Value := 0;
      Edit3.Text := '0.0';
    end else begin
      Edit3.Text := FloatToStr(Value);
    end;
  end;

end;

procedure TForm1.Button4Click(Sender: TObject);
var
  Value: Double;
  Epsilon: Double;
begin
  Epsilon := 1E-15;  //閾値を設定
  if TryStrToFloat(Edit3.Text, Value) then
  begin
    Value := Value - 0.1;
    if Abs(Value) < Epsilon then
    begin
      Value := 0;
      Edit3.Text := '0.0';
    end else begin
      Edit3.Text := FloatToStr(Value);
    end;
  end;
end;

ゼロの時は、’0’ではなく、’0.0′ と文字列指定しているところに「こだわり」ました!

5.0.1ずつ増減

上記コードの実行結果です☆(初期値は 0.2)

▼をクリック
▼をクリック
▼をクリック

閾値が効きました☆

逆(▲クリック)も正しく動作することを確認。

【TEditとTUpDownの組み合わせで実現】

TUpDownのMax及びMinプロパティの値(特にMin)を適切な値に設定し、適切なコードを記述することで、TEditとTUpDownというVCLコンポーネントの組み合わせでも 0.1 刻みの選択肢設定が可能であることを確認しました。その方法は以下の通りです。

※ オブジェクトインスペクタで Increment プロパティの値を予め設定しておくという前提で、

【重要】 負数の入力を想定する場合は、TUpDownのMinプロパティの値に適切な負数を設定します!

【重要】 Increment プロパティに 1 を設定 → ▲をクリック → 値が 0.1 ずつ増加

ちなみに Increment プロパティに 2 を設定した場合は、値が 0.2 ずつ増減します。

procedure TForm1.UpDown1Click(Sender: TObject; Button: TUDBtnType);
begin
  Assert(Sender is TUpDown);
  with TUpDown(Sender) do
  begin
    Assert(Associate is TEdit);
    TEdit(Associate).Text := FloatToStrF(Position / 10, ffNumber, 1, 1);
  end;
end;

上記コードの出典は、こちら

Delphi-PRAXiS

https://www.delphipraxis.net/143779-tupdown-floats.html

貴重な情報をご教示くださいましたこと、投稿者様に対し、心より感謝申し上げます。

上のコードを参考に、使っているVCLの名前を指定して、もっと短くして書けば・・・

procedure TForm1.UpDown1Click(Sender: TObject; Button: TUDBtnType);
begin
  Edit1.Text := FloatToStrF(UpDown1.Position / 10, ffNumber, 1, 1);
end;

増減値に 0.1 ではなく、0.2 以上の値を設定したい場合、Increment プロパティの値は予めオブジェクトインスペクタで指定しておいた方が良いようです。

ちなみにオブジェクトインスペクタのIncrement プロパティに設定してある値は「1」のまま、コードで「2」を指定した場合は・・・

procedure TForm1.UpDown1Click(Sender: TObject; Button: TUDBtnType);
begin
  UpDown1.Increment := 2;
  Edit1.Text := FloatToStrF(UpDown1.Position / 10, ffNumber, 1, 1);
end;

実行(F9)してみるとエラーにはなりませんが、最初の▲押し下げ時に 0.2 ではなく、0.1 と表示されます。また、実行(F9)後、最初の▼押し下げ時には -0.2 ではなく、-0.1 と表示されます。それ以降(▲・▼2回目押し下げ以降)は正しく表示(意図した通り表示)されます。

オブジェクトインスペクタのIncrement プロパティに設定してある値が「1」のまま、コードで値を設定・変更する場合は(実行途中での変更はできませんが)FormCreate時に設定しておけば期待通りに動作するようです(もし、違っていたら、ごめんなさい)。

procedure TForm1.FormCreate(Sender: TObject);
begin
  UpDown1.Increment:=2;
end;

procedure TForm1.UpDown1Click(Sender: TObject; Button: TUDBtnType);
begin
  //UpDown1.Increment:=2;
  Edit1.Text := FloatToStrF(UpDown1.Position / 10, ffNumber, 1, 1);
end;

PCの画面で確認すれば、設定状況がわかりやすいと思います。

この設定なら期待通りに 0.2 ずつ増減しました


以上が、TEditとTUpDownの組み合わせで小数値を増減させる方法のまとめです。

6.まとめ

(1)TEditとTUpDownの組み合わせで0.1刻みの数値の増減は可能。
(2)TUpDownで負の数を扱う場合は、Minプロパティの値も適切に設定する。
(3)TEditとTButtonの組み合わせで小数値の増減計算を行う場合は値「0」に注意する。

7.お願いとお断り

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

フローティングパネル

浮かんだままがいいときもあったりして!

【もくじ】

1.フローティングパネル
2.ドッキングさせたくない
3.メモリが解放できない
4.メモリが解放できた(その1)
5.メモリが解放できた(その2)
6.まとめ
7.お願いとお断り
8.【追記】

1.フローティングパネル

手書き答案の採点プログラムで、常にフォーム上に浮かべたまま、自由にその位置を変更可能な、作業補助用のGUI(フローティングパネル)が欲しくなった。

前回、縦書き答案の採点用に作成した横スクロールのフローティングGUIはControlBarの上にToolBarを置いて、その上にToolButtonやBevelを並べたけれど、今回はその名の通り、ベースとなるVCLはTPanelを選択。この上に効率よく採点作業を行うために最低限必要なButton他を置いて、ユーザーの視線やマウスの移動量を極めて限られた範囲に限定。操作に払う注意とストレスをできるだけ軽減して、より快適な作業環境を実現したい・・・。

そう考えて作成したGUIがコレ。

搭載した機能は、ほんとに必要最低限

画面をタッチして操作することも考え、各Buttonの高さは44ピクセルに設定。
これを設問毎に、答案画像から切り抜いた画像を並べて表示したTImageのとなりに表示する。

フローティング状態だとTPanelの上に「閉じる」ボタンのあるタイトルバー?(キャプションバー?)が表示される。この部分をクリックして、そのままドラッグすれば、TPanelをForm上の任意の位置へ移動できる。ただし、Formにドッキング(FormのDockSiteプロパティをTrueに設定)した瞬間、タイトルバー?はかき消されたかのように消えてしまう・・・

Formにドッキングしない状態ではPanelの上部にタイトルバーのようなものが表示される


以前作った(縦書き答案の採点を効率よく行うために左右方向のスクロールを行う)ドッキング・コントロールは、ドラッグ&ドロップでFormにドッキングするように設計。必要であれば、Bevelをクリックしてそのままドラッグすることでドッキングを解除。任意の位置へ移動できる仕様とした。そんなに頻繁に位置を変更するようなモノでもなかったし。

ところが、今回はFormにドッキングさせると、いろいろ不都合が起きることが判明。

例えば、解答欄の大きさ(特に幅)に合わせて、この入力補助Toolの位置を頻繁に変えるような場合、クリックしてそのままドラッグできる「タイトルバー(キャプションバー)」がないと極めて不便。Formにドッキングした瞬間にこれが消えてしまうと、掴みどころがなくてほんとうに困るのだ。

Formにドッキングして、掴みどころがなくなった状態(フローティングしてないパネル)


それから、Formへドッキングしている状態から、引きはがしてフローティング状態になるときの挙動がとにかく急で! うまく文字に表現できないのだけれど、感覚的には「うわっ」て感じ。GUIをクリックした瞬間に、「びっくりして飛び起きる」イメージでフローティングするのだ。

(数学風?に言えば、GUI上のクリックした位置が、GUIの左上の座標原点(0,0)の位置になるように、Button等のコントロールを載せているPanelの左上隅が瞬時にクリックした位置へ移動する)

また、最初の解答欄を表示している場合、1つ前に戻る「◀」ボタンはEnabledプロパティをFalseに設定し、クリックできないようにしてあるが、この Enabled:= False 状態のボタンをクリックすると上で述べたGUIが「びっくりして飛び起きる」ように瞬間移動する現象がおきてしまう。

さらに(原因はわからないけれど)、縦書き答案の採点時にFormにドッキングさせると、解答欄の表示が部分的にずれて二重に表示されてしまう(ドッキングさせなければ、この現象は起こらない)。

あれや、これやで、どぉーにもドッキング時の挙動が気に入らない。

じゃあ、
ドッキングさせなければいいじゃん!

・・・

そのとおり・・・

そのとおり、なんだけど・・・

そうすると・・・ コレが ・・・

フローティング状態で位置を変更するたびに、メモリーリークが・・・

コレが・・・ どぉーしても 消えなくて・・・

T_T

2.ドッキングさせたくない

メモリーリークの原因はわかってる。フローティングさせたTPanelをドラッグし始めた時に発生するStartDock手続きでCreateしているTToolDockObjectだ。

procedure TFormXXX.PanelXStartDock(Sender: TObject;
  var DragObject: TDragDockObject);
begin
  DragObject:= TToolDockObject.Create(Sender as TPanel);
end;

実は、これがなくても、フローティング動作は出来る。出来るんだけど、見た目に問題があって、高解像度画面で表示倍率を200%拡大のように設定している場合、ドラッグ(=移動)中は灰色の枠だけのフローティングパネルのゴースト?が現れる。(画面のハードコピーがうまくとれなかったので)ゴースト?は、次の図のような感じ。

ドラッグ時に現れるフローティングパネルのゴースト?
(これは練習用に作ったプログラムの実行時画面)


そして、このゴーストが目に痛い感じでちらつきながら移動する。さらに困ったことに、ドラッグ中はフローティングパネルの本体(ゴースト?でない方)は表示されない。ドラッグしてドロップした瞬間に、まるでテレポーテーションしたかのように(突如として)ドロップした位置にパネルが出現するのだ。

ただし、メモリーリークは起きない。
(なんにもCreateしていないから、起きるわけがない)

一方、メモリーリークを起こすとわかっていても、ドッキング可能なコントロールのドラッグ操作を専門的に管理するTToolDockObjectをCreateして動かすと、動きがたいへんスムーズ! 気持ちイイ☆

だから、どうしてもドッキングさせたくない!
フローティングさせたまま、使いたい!!

  DragObject:= TToolDockObject.Create(Sender as TPanel);

そうなると、この1行はどうしても削れない・・・。

ただし、裏側では「メモリーリーク」がフローティングさせたパネルをドラッグする(=位置を変える)度に、発生・・・

表面的には何事もなく、静かなんだけど。

3.メモリが解放できない

解決策はただひとつ。

ドラッグが終了した時点で、StartDock時に確保したメモリを解放すればいい。

ところが、FormのDockSiteプロパティがFalseのままだと
それが、どうにも、こうにも、難しい・・・

ちなみに次のように書いてみたんだけど、上の条件下では、これは、どうやら無効!!!のよう。

procedure TFormXXX.FormDockDrop(Sender: TObject;
  Source: TDragDockObject; X, Y: Integer);
begin
  if IsDragObject(Source) then
  begin
    Source.Free;
  end;
end;

procedure TFormXXX.FormDockOver(Sender: TObject;
  Source: TDragDockObject; X, Y: Integer; State: TDragState;
  var Accept: Boolean);
begin
  Accept:=IsDragObject(Source);
end;

OnDockDropは、「別のコントロールがコントロールにドッキングした際に発生」つまり「DockSite が True の際にのみ、発生する」ということで、そもそもFormのDockSiteプロパティは意図的にFalseに設定してあるんだから、OnDockDropイベントが起きるわけがない

OnDockOverもおんなじで、「DockSite が True の際にのみ、発生する」とのこと。100万回ドラッグしようとFormのDockSiteプロパティがFalseである限り、OnDockOverイベントも絶対に起きない。

ダメ元で、OnDragDrop手続きと、OnDragOver手続きを作成して上と同じコードを書いてみたんだけど、手続きの引数をよく見たら・・・

procedure TFormXXX.FormDragDrop(Sender, Source: TObject; X, Y: Integer);

Sourceが、TDragDockObjectじゃなくて、TObjectになってる・・・。ってコトは、こっちで受け取るためには、型キャストが必要ってコト? なのかなーって思いつつ、FormのDockSiteプロパティをFalseに設定したまま、型キャストなしで次のコードを書いてみたが、Panelをドロップしても反応がない。どうやら、この2つのイベントは、FormのDockSiteプロパティがFalseだとTToolDockObjectのドロップに対しては発生しないようだ。⇦ 間違いだったら、ごめんなさい!

procedure TFormXXX.FormDragDrop(Sender, Source: TObject; X, Y: Integer);
begin
  if IsDragObject(Source) then
  begin
    Source.Free;
  end;
end;

procedure TFormXXX.FormDragOver(Sender, Source: TObject; X,
  Y: Integer; State: TDragState; var Accept: Boolean);
begin
  Accept := IsDragObject(Source);
end;

その他、FormのDockSiteプロパティをFalseに設定したまま、フローティングさせたPanelをドッキングさせずにドロップ時にメモリを解放する方法はないかとさんざん 悩んだが、解決策が見つからない。まさに七転八倒。終いには、ナニをどういじったらそうなったのか、自分でもわからないのだけれど、フローティング状態のパネルをクリックしただけで一般保護違反のエラーが出るようになり、元に戻せなくなってしまった・・・。

フローティング部品がない状態のバックアップをとっていて、ほんとうによかった。

【これが間違いであっても、前に進むために出した、自分なりの結論】

TToolDockObjectを使いたいならOnDockDropとOnDockOverイベント側で、ドラッグ&ドロップを受け取るしかない。

4.メモリが解放できた(その1)

何かをCreateして使うプログラムを書くとき、FormCreate時に、次のようにメモリーリークがあれば検出する設定を僕は付け加えることに決めている。多数の画像を読み書きする答案処理のプログラムを書いた際に、Createしたオブジェクトの解放を書き忘れ、あとからCreateしている箇所を全部点検することになった「痛い経験」から学んだ予防的措置だ。

procedure TFormXXX.FormCreate(Sender: TObject);
begin
  //メモリーリークがあれば検出
  ReportMemoryLeaksOnShutdown:=True;
end;

メモリーリークがあった場合、実行(F9)したプログラムを終了させると(メインフォームを閉じると)、次の画面がリークを起こした回数付きで表示される。

回数なんか数えなくていいから、メモリーリークを止めてくれ!!

丸1日がんばって、(この方法じゃダメなんだ)ということがはっきりわかったところで、少し休もうと思い、ベッドに倒れて・・・ そのまま、眠ってしまったようだ。

なんにも考えない時間が数時間あって・・・

目覚めたのは日付が変わる直前。眠って疲れがとれたからか? その原因はわからないけど、目覚めた瞬間に、あることを思いついた。それは何かと言うと・・・

FormのDockSiteプロパティをTrueに設定すれば、OnDockDropとOnDockOverの2つのイベントが間違いなく? Panelのドラッグ&ドロップに反応してくれるから・・・

ドラッグしてるときだけ、FormのDockSiteプロパティをTrueに切り替えて、ドロップした瞬間の位置座標を取得し、そこへPanelをManualFloatさせて、最後にメモリを解放、DockSiteプロパティをFalseに戻せばいいんじゃない? ってこと。

いろいろ実験的に書いていて(プロパティをあちこち変更)、設定忘れがあると困るので、ドラッグ開始時、コードの中でFormのDockSiteプロパティをTrueに設定。

procedure TFormXXX.PanelXStartDock(Sender: TObject;
  var DragObject: TDragDockObject);
begin
  //これでちらつかなくなった
  DragObject:= TToolDockObject.Create(Sender as TPanel);
  //設定し忘れないための予防的措置
  if not FormXXX.DockSite then
  begin
    FormXXX.DockSite:=True;
  end;
end;

ドロップ時のOnDockDropイベントは・・・

procedure TFormXXX.FormDockDrop(Sender: TObject;
  Source: TDragDockObject; X, Y: Integer);
var
  r:TRect;
begin
  if IsDragObject(Source) then
  begin
    r.Left:=X;
    r.Top:=Y;
    r.Right:=X+PanelX.Width;
    r.Bottom:=Y+PanelX.Height;
    PanelX.ManualFloat(r);
    //解放
    Source.Free;
    if FormXXX.DockSite then
    begin
      FormXXX.DockSite:=False;
    end;
  end;
end;

FormのDockSiteプロパティはFalseに設定。

実行(F9)して、フローティングパネルをドラッグ&ドロップ。ちょっと気になったのはドロップ時のManualFloat(r)時の挙動。ドロップした場所でなく、ドロップ時にマウスポインタのカーソルがあった位置へ移動して表示されてしまう。次の画像の上がドロップ時、下がマウスの左ボタンを離した際にフローティングパネルが表示される位置。

マウスのカーソル位置を取得しているから、パネルのドロップ位置から少しずれて表示される。


ドロップ時、XにはPanelのLeftの値、YにはPanelのTopの値を取得するよう、プログラムを修正。


    r.Left:=PanelX.Left;
    r.Top:=PanelX.Top;
    r.Right:=r.Left+PanelX.Width;
    r.Bottom:=r.Top+PanelX.Height;
    PanelX.ManualFloat(r);
    //解放
    Source.Free;

これで表示位置に関する問題は解決できたが、今度は表示される際の挙動が気に入らない。ドロップ位置でPanelの画像が一瞬、最小化され(閉じるボタンだけになり)、それから全体が表示されるので、ドロップするたびにPanelが1回点滅するように見えるのだ。

それはそれで何とかするとして、いちばん何とかしなきゃいけないのはメモリーリーク。

恐る恐るプログラムを終了。

DelphiのIDE画面だけが表示され、リークの警告画面は出ない!
ようやく問題を解決できた・・・。

ただし、一難去ってまた一難。
表示方法を何とかしなきゃ・・・

5.メモリが解放できた(その2)

点滅の原因はわかっている。ドロップ時の ManualFloat(r) だ。

  DragObject:= TToolDockObject.Create(Sender as TPanel);

メモリーリークは起きるけど、上の一行だけでフローティングさせ、ドラッグ&ドロップしている時は点滅なんてしなかった。要は ManualFloat させなければいいのだ。

(かならず、解決方法はある)

そう信じて、OnStartDockイベントの手続き部分でShift+Ctrl+1、OnDockDropイベントの手続き部分でShift+Ctrl+2を実行して、Ctrl+1、Ctrl+2でそれぞれの手続きへ移動できるように設定。

2つの手続き間を行きつ戻りつしながら解決方法を考える・・・

どう考えてみても、OnDockDropイベント側でなんとかするのは無理そう・・・

TToolDockObjectをCreateして移動させてる時は、ドロップ時に点滅しなかった・・・

表示された位置も、ドロップした場所だった・・・ だから、ManualFloat は不要?

確保したメモリを最後に解放すれば、メモリーリークは起こらない・・・

『最後に解放』?

そうだ「 try ・・・ finally ・・・ end; 」があった。

プロシージャを思った通りに書き換える。

procedure TFormCollaboration.PanelXStartDock(Sender: TObject;
  var DragObject: TDragDockObject);
begin
  DragObject:= TToolDockObject.Create(Sender as TPanel);
  try
    if not FormXXX.DockSite then
    begin
      FormXXX.DockSite:=True;
      Application.ProcessMessages;  //おまじない
    end;
  finally
    DragObject.Free;  //メモリの解放
  end;
  FormXXX.DockSite:=False;
end;

実行(F9)

フローティングパネルは快適に移動し、ドロップ時の挙動もごく自然・・・

祈りながらFormをClose.

見慣れたIDEだけが現れる。

メモリーリークは 起きてない。

やっと、思っていたとおりの ・・・ プログラムになった?

・・・ってか、ちょっと待て。オレ、OnDockDrop手続きの ManualFloat 消してないぞ。

もしかして・・・ OnDockDrop手続き 呼ばれていないんじゃないか?

自分の書いたプログラムを、もう一度、よく読んで考える・・・

  DragObject:= TToolDockObject.Create(Sender as TPanel);
  try
    if not FormXXX.DockSite then
    begin
      FormXXX.DockSite:=True;
      Application.ProcessMessages;  //おまじない
    end;
  finally
    DragObject.Free;  //メモリの解放
  end;

DragObject を、最後に Free してるから、ドロップ時にはもう TToolDockObject は消えてる・・・。
消えてるんだから・・・ もう、『ない』んだから、呼ばれてないんじゃなくて・・・

OnDockDropイベントは起こらない!

・・・ってコトは、もしかして

DragObject:= TToolDockObject.Create(Sender as TPanel);
try
  //何もしない
Finally
  DragObject.Free;
end;  

Createして、Freeするだけで

よかったの?

6.まとめ

今回の状況を何かに例えるならそれはナニ?って、生成AIに訊ねたところ、

 “Still waters run deep”

そう答えてくれました。深く、静かに、感動。

7.お願いとお断り

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

8.【追記】

  finally
    //DragObject.Free;  //メモリの解放のみ->インスタンス(オブジェクトの実体)はあるので参照可能
    FreeAndNil(DragObject);  //インスタンスも消える!
  end;

.Freeでなく、FreeAndNilすると、何もしなかったコトと同じになってしまいます。
注意してください!!

RAD Studio 12.0にPython4Delphiをインストールする!

追記(20231208)

さらにカンタンな方法がありました!

https://coding-tips-memoranda.com/rad-studio-12-0%e3%81%abpython4delphi%e3%82%92%e3%82%a4%e3%83%b3%e3%82%b9%e3%83%88%e3%83%bc%e3%83%ab%e3%81%99%e3%82%8b%ef%bc%81%ef%bc%88%e3%81%9d%e3%81%ae%ef%bc%92%ef%bc%89/

以下、苦労を伴うインストール方法の記録です(お読みいただく価値のない情報です)。 T_T

2023年11月8日、RAD Studio 12.0(僕にとってはDelphi 12.0)がリリースされた(ようです)。
アップデート・サブスクリプションの支払いを終え(個人で購入しているのは僕くらいだろうが・・・)、届いたメールの製品アップデートリンクをクリックして、最新の更新をチェックしたら、12.0が!

(誰も教えてくれないから、リリースされたこと自体、まったく知りませんでした! T_T )

そういえば・・・RAD Studioのメジャーアップデートは毎年この時期だったような。

あわわわわわわわわわわ ひー!ひー!(驚愕的感動を表現)

早速、Web Installを実行。

Delphi 12.0 のインストールは何の問題もなく、15分くらいで終了(XEの頃は時間がかかったけど)。

11.2 が入っている環境にインストールしたためか(?)、シリアルナンバーの入力なども一切ありませんでした! カンタン。気持ちいい。

続けて、Python4Delphiも最新版(RAD Studio 12.0対応版)をインストール。

以下、その時のメモです!

【もくじ】

1.Python4Delphiの最新版をダウンロードして展開する
2.フォルダ構成を整える
3.Python4Delphiの最新版(RAD Studio 12.0対応版)をインストール
4.ライブラリパスを確認
5.まとめ
6.お願いとお断り

1.Python4Delphiの最新版をダウンロードして展開する

まず最初に、Python for Delphi(P4D)をGitHubから入手してDelphiにインストール。

P4Dの入手先URL https://github.com/pyscripter/python4delphi

Codeをクリックすると表示されるサブメニューのいちばん下にDownLoad ZIPがあるので、これをクリックしてZIPファイルをダウンロードし、任意の場所(フォルダ)に解凍する(ここではダウンロードするフォルダの名前を「P4D」として説明)。

Download ZIPをクリックして最新版を入手する

ダウンロードが完了したら、ダウンロード先フォルダにはコレがあるはず。

python4delphi-master.zipを任意の場所に「P4D」フォルダを作成して、そこへコピペする

P4D」フォルダを作成するのは、できればあまり階層の深くない、絶対に忘れない場所がよいと思います。理由は、後からそこにライブラリパスを通すから。バックアップなど取る時にも、忘れないような場所に作成してください。

zipファイルを右クリックして、表示されるサブメニューの「すべて展開」をクリック。

zipファイルを展開(解凍)

そのまま P4D フォルダの直下に展開(解凍)する。

Pathは敢えていじらずに、そのまま「展開」をクリック

展開(解凍)が完了すると、P4D フォルダの下に「python4delphi-master」フォルダができ、その下に同じ名前でもうひとつ「python4delphi-master」フォルダができる

この中に7匹のヘビがいる。はやく会いたい。

2.フォルダ構成を整える

この時点でフォルダ構成は・・・ちょっとややこしいが、次のようになっている(はず)。

¥任意の場所¥P4Dpython4delphi-masterpython4delphi-master

とりあえず、いちばん下の python4delphi-master フォルダをダブルクリックして開き、中にあるものすべてを CTRL+A で全選択して、CTRL+X で切り取り、ひとつ上の階層の python4delphi-master フォルダ内に CTRL+V(貼り付け)する。

で、いちばん下の階層の python4delphi-master フォルダは不要なので消去(削除)する。

さらに、上の階層の python4delphi-master フォルダの名前を手動で「P4D」に変更(リネーム)する。

これでフォルダ構成は、次のようになる。

¥任意の場所¥P4DP4D

いちばん下の P4D フォルダをダブルクリックして開くと・・・

Install フォルダ内にある「README.md」に、実は重要な情報が書かれている

【README.md】※ 原文のまま

## P4D Installation using [MultiInstaller](https://github.com/pyscripter/MultiInstaller)

Use for Delphi Seattle (10.4) or later to install all packages in one step. 

1. Clone or copy the Python4Delphi git repository to a folder of your choice.  **The setup.ini file assumes that the folder is called "P4D"**.  If you chose to name your folder differently then modify the "Folder" option in setup.ini.
2. Close all Delphi IDEs running.
3. Run MultiInstaller.exe
4. Select the packages you want and press Next
5. In the dialog box specify the _**parent folder**_ of "P4D" (i.e. the folder containing the directory to which you have copied Python4Delphi) and the Delphi target version.  Then press Next to install the components

Google先生、曰く・・・(文字に色付けしたのは僕です)

## [MultiInstaller]を使用したP4Dのインストール(https://github.com/pyscripter/MultiInstaller)

Delphi Seattle (10.4) 以降の場合は、すべてのパッケージを 1 ステップでインストールするために使用します。

1. Python4Delphi git リポジトリを選択したフォルダーにクローンまたはコピーします。 **setup.ini ファイルでは、フォルダーの名前が「P4D」であると想定しています**。フォルダーに別の名前を付けることを選択した場合は、setup.ini の「フォルダー」オプションを変更します。
2. 実行中のすべての Delphi IDE を閉じます。※ コレも重要!な注意点のひとつかと・・・
3. MultiInstaller.exe を実行します。
4. 必要なパッケージを選択し、「次へ」を押します
5. ダイアログ ボックスで、「P4D」の _**親フォルダー**_ (つまり、Python4Delphi をコピーしたディレクトリを含むフォルダー) と Delphi ターゲット バージョンを指定します。次に、「次へ」を押してコンポーネントをインストールします

僕なりの解釈は(間違ってるカモだけど)・・・

Python4Delphi をコピーしたフォルダ名は「P4D」であり(であることを想定しており)、
さらに、インストール時に表示されるダイアログボックスでは・・・

P4D」の _**親フォルダー**_ を指定

つまり、「その親フォルダ(階層がいちばん上の P4D )を指定せよ」

と言っている・・・。

フォルダ構成を README.md の指示通りに整えたところで、

¥任意の場所¥P4DP4D¥Install フォルダを開き、

そこにある MultiInstaller.exe をダブルクリックして実行する。

Install フォルダにある MultiInstaller.exe をダブルクリック

ちなみに、拡張子md は、「Web 用のドキュメントの作成によく使用される、読み書きしやすいように設計されたプレーンテキスト」に使う拡張子だそう。

ちなみに「プレーンテキスト」は、「文字だけで構成され、レイアウト情報や装飾情報などを持たないデータのこと」だそうで。

勉強になりますー。

3.Python4Delphiの最新版(RAD Studio 12.0対応版)をインストール

こうしてインストール前の最大の難関?を乗り越え、早速、Python4Delphi をインストール。

Install フォルダにある MultiInstaller.exe をダブルクリック(再掲)

次の画面が表示される。

フォルダの選択ダイアログ

Select Destination directory to install all the component packages. ・・・

こちらもGoogle先生曰く、

「すべてのコンポーネント パッケージをインストールするには、宛先ディレクトリを選択します。」

どうも、この、「宛先」という訳がピンとこないけど・・・。

まぁ、「宛先」は「参照元」に読み替えて・・・。

それが、先ほどの「README.md」に書かれていた「Python4Delphi をコピーしたディレクトリを含むフォルダー」・・・つまり、「P4D」フォルダなんだろうな・・・ みたいな・・・

ってか、もっと正直に言うと・・・、RAD Studio 12.0 をインストールしたから、唯一、僕が必要とするサードパーティー製コンポーネント Python4Delphi も入れなきゃって思って、前回の(11.2 への)インストール作業後、大切に保存しておいた P4D¥Installフォルダ内の MultiInstaller.exe を起動したら・・・

RAD Studio 12 Athens がインストール先の候補として出てこない!

つまり、この MultiInstaller.exe は RAD Studio 12 Athens のインストールパスを拾って・・・「ない」。

このダイアログを見たとき、一瞬、(もうダメだ・・・)と思ったのですが、その直後、このインストーラー自体が1年前のものだったことを思い出し、・・・だとすれば、RAD Studio 12 Athens が表示されなくて、むしろ当然・・・。ここで初めて Python4Delphi も最新版が必要だと気づき・・・

さらに、オプションボタンがこのダイアログに「1つしかない」意味まで見えた気が・・・

(オプションボタンだから、インストール対象としてパスを通すのは、1バージョンに限定ってことなんだろうけれど・・・)

(それよりも・・・、ダイアログのCompile packages ~の余白が有り余ってるのは、RAD Studio のメジャーバージョンアップを見越して、後からボタンを追加できるよう、予め余裕を持って設計したから?)

(・・・もし、そうなら12.0対応版があるに違いない。いや、きっとある!)

あわてて GitHub へ行って12.0 対応版の有無を確かめたというのが事の真相。

思った通り、GitHub の Python4Delphi は、12.0のリリースに合わせて最新版にアップデートされてました・・・。作者の方に心から感謝!

Go To 「3.Python4Delphiの最新版(RAD Studio 12.0対応版)をインストール

これで無限Loop に。Blogまでスパゲッティ化しちゃった・・・。

↑ コレは古い時代のプログラマーにしか、通じない言葉かな?

取り敢えず、無限Loopはなんとかして乗り越えたコトにして・・・

宇宙のはじまりだって、トンネル効果が起きた時、虚数時間が流れていて、上り坂が下り坂になった・・・みたいな話を、聴いたような・・・。聴かなかったような。で、宇宙って、通れないはずの壁から果たして沁み出すものなんだろーか。

ハイゼンベルクさんは、連合軍の科学者たちのことを、どう思っていたんだろう・・・

RAD Studio 12.0 対応版のP4D付属 MultiInstaller.exe を起動して表示されるフォルダの選択ダイアログは、前掲の通り。

Browseボタンをクリックして・・・

Compile packages ~の欄には RAD Studio 12 Athens が増えましたが、欄の上下に「まだまだ余裕」があります。これを見て、先ほどの予感は大きく自信を得て・・・「これはつまり、今後数十年以上先までDelphiのメジャーバージョンアップが続々と行われることを見通して、必要十分と思われる余白を予め用意した先見の明溢れる非常に大胆な先進的設計である」という確信に変わりました。

是非、そうであって欲しい・・・と、心から願っています!

ダイアログがこのままの大きさでも、

1、2、3・・・と、近未来、確実にそこに入るであろうオプションボタン位置を予想してみると、Delphi のメジャーバージョンアップにあと10回は余裕で対応できそうです*(^_^)*♪

いいぞ。さすが、P4D!

こういう応援の仕方もあったのか・・・

こんどから、

僕も真似しよー!!

解凍先フォルダの階層Topにある P4D フォルダを指定
Compile packages and install on IDEにチェックして、RAD Studio 12 Athensを選択

あとは Next ボタンをクリックしてインストーラーにすべておまかせで、P4Dをインストールするだけ。
無事完了すれば、次のようなダイアログが表示される(画像は前バージョンのもの)。

作業の記録をとり忘れたので、これは 11.2 に P4D をインストールしたときの画像

最後に Finish をクリックしてインストール作業終了。

Delphi 12.0 のIDEを起動して、パレットを確認。

7匹のヘビを無事発見。

4.ライブラリパスを確認

Delphi のIDEを起動し、「ツール」→「オプション」→「言語」→「Delphi」→「ライブラリ」の順にクリックして下の画面を表示。

プラットフォームを選択して、ライブラリパスの「…」ボタンをクリックする

ライブラリパスの一覧が表示されるので、そのいちばん下に P4D へのパスの設定があることを確認する(パスはインストール時に自動で設定されるようだ)。

Library パスの一覧の下から3つが P4D へのパス(自動で設定される)

上の画面では 「Windows 32ビット」 のプラットフォームに対する設定を確認している。念のため、「Windows 64ビット」 のプラットフォームに対しての設定も確認する。

プログラムのコンパイルを実行すると、Delphiはいちばん最初にプロジェクトファイル(.dproj)のあるフォルダ(ここはパスが通っているから登録は不要)を検索し、必要なユニットファイル等の有無を確認。もし、そこに必要なファイルがなければ、この画面に登録したライブラリパスを検索するようだ。

5.まとめ

(1)RAD Studio 12.0 のリリースに合わせ、Python4Delphi もアップデートされていた。
(2)Python4Delphi のインストールは専用の「MultiInstaller.exe」で実行する。
(3)Python4Delphi のデータは「¥任意のフォルダ¥P4D¥P4D」フォルダ内に置く。
(4)インストール後、念のため、ライブラリへのパスが設定されていることを確認する。

6.お願いとお断り

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

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

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

Rectangle Detector

矩形検出器

手書き答案をスキャナーで画像化して採点するソフトを書いた。概ね、思った通りにカタチになったが、解答欄の位置座標を取得するのに、解答欄の数だけ、その左上隅から右下隅へマウスでドラッグする作業を繰り返さなくてはならない。(もし、これが自動化できたら・・・) そう思って書いたのが、このプログラム。

1.矩形の検出方法
2.字数制限のある解答欄の作り方
3.GUIはDelphiで作成
4.矩形検出器の使い方
5.まとめ
6.お願いとお断り

1.矩形の検出方法

キーワードを『矩形 検出』にしてGoogle先生にお伺いをたてると、思った通りOpenCVを活用する方法がいくつもヒットする。しかも、そのほとんどすべてがPythonでの活用方法だ。Delphi用のOpenCVもあるようだけれど、次の理由から矩形の検出はPython用のOpenCVで行うことにした。

Pythonを使う利点は、まず、何と言っても、情報が豊富なことだ。マイ・プログラミング環境では、わからないことはすべてGoogle先生に教えてもらうしかないので、情報が入手しやすいことは、他のすべてに優先する。

(メインの開発環境がDelphiなのは、上記の内容と大いに矛盾しますが・・・)

さらに、手書き答案の採点ソフトより前に、マークシートリーダーを作った時、マーク欄の座標を得るために、やはりPythonとOpenCVのお世話になった。マークシートリーダーも、手書き答案の採点ソフトも、embeddable pythonに入れたOpenCVと一緒のフォルダに詰め込んでユーザーに配布しているから、Pythonを内包して使う環境は既に完成済み。PythonのスクリプトをDelphiのコードに埋め込んで、PythonForDelphiを使って実行する方法は勉強済みだから安心。Delphi用のOpenCVは、情報も少ないし、何よりその使い方がわからない・・・。

他人様に使っていただくプログラムはDelphiで書くけれど、自分専用のToolはPython環境を利用して作ることが多い。ちょっと特別なことをしたい時、Pythonはとても便利だ。いろいろ紆余曲折はあったけれど、現在はSDカードにWinPythonとAtomエディタを入れて持ち運べるPython環境を作っている。

そのSDカードに入れたPython環境で、いつものようにAtomを起動し、Web上にあったいくつものScriptをコピペして試してみる。

まず、OpenCVで「ハフ変換」なるものを利用する例だが、ハフ変換はノイズの除去で苦労しそうだ。ノイズの発生源が多数存在する解答用紙の矩形検出でパラメータを適切に設定することが果たしてできるだろうか? 経験がない自分にはちょっと厳しそうだ。

次に、LSD(Line Segment Detectorの略とのこと)という直線検出器を試した。試した瞬間、(もう、これしかない!)と思うほど、これは凄かった。使い方も超カンタンで、LSDをこれでもか!とばかりに並べるだけ。

from pylsd.lsd import lsd
Mylines = lsd(picture)

【検出結果】

LSDで検出できた矩形の例

さらに驚くべきことに、こういう作業には付き物の引数も一切ない。つまり、パラメータを調整する必要など『ない』ということなのだろう・・・。ただ、LSDはそのライセンス形態がAGPLであると知り、使用を断念。MITやBSDでないと自分的にはやはり困る・・・。

最後に試したのが、OpenCVのfindContours関数。これを使うには前処理として、まず、画像をグレースケールに変換し、さらに白黒反転させて二値化しなければならない。

import cv2
import numpy as np
from PIL import Image

# Pillowで画像ファイルを開く(全角文字対応の確認用にファイル名は「ひらがな」)
pil_img = Image.open("./img/さんぷる.jpg")
# PillowからNumPyへ変換
img = np.array(pil_img)

# グレースケールに変換する
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

# 白黒を反転
gray = 255 - gray
# 2値化する
ret, bin_img = cv2.threshold(gray, 20, 255, cv2.THRESH_BINARY)

Pillowで画像ファイルを開いているのは、OpenCVのimread関数が日本語(全角文字)に対して拒絶反応を示すので、これを回避するため。もし、ファイル名とそこまでのPathに全角文字が含まれないという確実な保証があるなら、次のようにしてもいいようだ。これなら1行で済む。

# 8ビット1チャンネルのグレースケールとして画像を読み込む
img = cv2.imread("全角文字のないPathと画像ファイル名", cv2.IMREAD_GRAYSCALE) 

で、準備が出来たらfindContours関数を使って輪郭を検出する。

# すべての輪郭を同じ階層として取得する
contours, hierarchy = cv2.findContours(bin_img, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)

解答欄には、その性格上、小さな矩形が多く使われることが多いので、閾値以下の面積の矩形は削除する。※ 閾値は整数型の数値で指定する。

# 閾値以下の面積の矩形(小さい輪郭)は削除
contours = list(filter(lambda x: cv2.contourArea(x) > 閾値, contours))

よりスムーズに作業するためには、予め、小さな矩形を消去した機械読み取り用の解答欄(解答用紙)をヒト用の解答用紙のコピーから作成し、これを用いて解答欄座標を取得した方がよい(国語の縦書き解答用紙は、ワープロソフトではなく、表計算ソフトで作成する方法が業界では一般的らしいので、機械読み取り用の解答用紙はそれほど手間をかけなくても、カンタンに作成できる・・・はず)。

解答欄矩形をちゃんと認識できているか・どうかを確認するため、検出した輪郭を描画する。このPythonのスクリプトをDelphiのObject Pascalに埋め込んで実行する際は、ここが最大の「見せ場」になる。検出した矩形をグラブハンドル付きのラバーバンドで表示する方法は後述。

# 検出した輪郭を描画する
cv2.drawContours(img, contours, -1, color=(0, 0, 255), thickness=2)

最後に解答欄矩形の座標を取得する(これが最終的な目標)。取得した座標は、採点順になるよう、並べ替えて表示する(並べ替え方法は後述)。

# 矩形の座標を表示(左上の座標, 右下の座標)
for i in range(len(contours)):
    x, y, w, h = cv2.boundingRect(contours[i])
    print(str(x)+','+str(y)+','+str(x+w)+','+str(y+h))

数値より、画像(絵)で見た方がわかりやすいのは言うまでもない。

# 保存
cv2.imwrite('./img/lined.jpg', img)
# 画像を表示
cv2.imshow("Image", img)
# キー入力で終了
cv2.waitKey()
画像を表示して、解答欄矩形の取得状況を確認

ここまでの Python Script をまとめて示せば、次の通り。

import cv2
import numpy as np
from PIL import Image

# Pillowで画像ファイルを開く
pil_img = Image.open("./img/さんぷる.jpg")
# PillowからNumPyへ変換
img = np.array(pil_img)

# グレースケールに変換する
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# 白黒を反転
gray = 255 - gray
# 2値化する
ret, bin_img = cv2.threshold(gray, 20, 255, cv2.THRESH_BINARY)

# すべての輪郭を同じ階層として取得する
contours, hierarchy = cv2.findContours(bin_img, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)

# 閾値以下の面積の矩形(小さい輪郭)は削除
contours = list(filter(lambda x: cv2.contourArea(x) > 数値, contours))

# 検出した輪郭を描画する
cv2.drawContours(img, contours, -1, color=(0, 0, 255), thickness=2)

# 矩形の座標を表示(左上の座標, 右下の座標)
for i in range(len(contours)):
    x, y, w, h = cv2.boundingRect(contours[i])
    print(str(x)+','+str(y)+','+str(x+w)+','+str(y+h))

# 保存
cv2.imwrite('./img/lined.jpg', img)
# 画像を表示
cv2.imshow("Image", img)
# キー入力で終了
cv2.waitKey()

OpenCVのfindContours関数を使って検出した輪郭(=解答欄の矩形)の例。
(解答用紙画像はLSDを試した時と同じものを使用)

矩形を検出しやすいように作った解答用紙なら、この結果はまさに『ブラボー!』

解答用紙中の ■ や □ を検出しないよう、検出下限の閾値を設定したこともあり、期待した通りの満足できる結果が得られた。OpenCVのハフ変換や、LSDでは日本語に対する反応が見られたが、findContours関数は(適切な閾値を設定してあげれば)日本語に反応しないようだ。

答案の「答」には「口」、問にも「口」、漢字にはたくさんの矩形が使われている。適切な閾値を設定することで、誤認識を減らせることも理想的。

【実験してみた!】

閾値を「700」として、□ に対する反応を実験して確認した。結果は次の通り。

26×26=676、28×28=784 だから・・・機械は正確に反応している

28ポイントの「□」から反応するが、40ポイントの「問」には無反応。通常使用される解答用紙であれば、フォントの大きさに制限を設ける必要性はなさそう。

もう少し細かい矩形を使った解答用紙で、閾値700で実験すると・・・

解答欄の矩形をさらに細かく分割したサンプルを作成してテスト
解答欄の番号の矩形に反応してしまう・・・

閾値1400までは・・・

解答欄の番号の矩形に反応するが

閾値を1500にすると・・・

解答欄の番号の矩形には反応しなくなる☆

少し、細かい矩形を用いた解答用紙であれば、閾値1500くらいから試せば狙った通りに解答欄の座標だけを取得することができそうだ。

閾値に上限を設定すれば、さらに良い結果を得られるかも・・・と思ったが、数学の解答用紙には他の教科ではあり得ない巨大な矩形が普通に使用される。矩形を取得できなければ、検出器とは言えない。さらに、解答欄全体を一つの大きな矩形として認識してしまうのはプログラムの性格上、絶対に回避できないから、閾値の上限は設けずに、むしろ、不要な矩形の座標を削除しやすいプログラム(GUIを作成)を書けばいいと気づく。

さらに、ユーザーが矩形座標の編集(修正)を自由にできるようにプログラムを工夫すれば、理想的な矩形検出器ができるはず。

これでDelphiでGUIを作成する際の方向性も見えてきた。

2.字数制限のある解答欄の作り方

解答欄の矩形を検出する上で、大きなハードルになるだろうと予想していたのが『字数制限が設定された解答欄』。

機械読み取り用に作成した解答用紙であっても・・・

上の解答用紙は、ヒト用の解答用紙の問題番号部分にあった小さな矩形を消去して、機械読み取り処理用に作成した解答用紙。この状態で矩形を検出(閾値1500)すると・・・

それでも削除しなければならない矩形座標が多すぎ・・・

閾値を「3100」に設定して、ようやく・・・

閾値をどんどん大きくすれば、何とかなることはわかった!

閾値を大きく設定すれば、何とかなることは上の例でわかったが、閾値を大きくすれば当然必要な解答欄の座標を取得できなくなる可能性も生じてくるわけで・・・。

ところが別の国語用解答用紙を処理している際に、閾値を気にせずに字数制限のある解答欄を作成する良い方法があることを偶然発見。それは・・・

罫線に「点線」を利用した解答用紙

字数制限を設定したり、完全解答で正解としたい解答欄は内側の罫線を点線にする!

閾値「700」で実験した結果

これなら問題2の(1)・(2)が作る大きな矩形の座標のみ削除すればOK!
点線を活用することで、一番大きな問題を難なくクリアできることが判明。
やったー☆

【embeddable Pythonのバージョンとインストールしたライブラリの一覧】

Python 3.9.9

Package Version
numpy 1.21.5
opencv-python 4.5.4.60
Pillow 9.3.0
pip 22.3.1
setuptools 60.1.0
wheel 0.37.1

3.GUIはDelphiで作成

取得した解答欄の座標を編集するGUIはDelphiで作成。最終的にはこうなった。

検出した矩形の確認と編集を行うGUIはDelphiで作成

画面下の「操作」グループ内のVCLを左から右へ順にクリックして行けば、解答用紙画像から解答欄の矩形が取得・表示できる仕組み。

左から右へ順に操作して解答欄矩形の座標を取得する。

取得した解答欄矩形の座標は、画面右上に一覧形式で採点順に表示されるようにプログラミングした。

取得した座標の一覧を表示

横書き答案が指定された場合は、y座標の値が昇順になるよう並べ替え(y座標が同じなら、x座標でさらに昇順に並べ替え)。

縦書き答案が指定された場合は、x座標の値が降順になるよう並べ替え(x座標が同じなら、y座標でさらに昇順に並べ替え)。

こうすれば、座標の並び方が「ほぼ採点する順番になる」はず。なお、並べ替えはカンマで区切った解答欄矩形の座標を入れたStringListを対象として実行(解答欄数は多くても100未満のはず・・・だから、並べ替えの速度はまったく考えていない)。そのアルゴリズムは次の通り。まず、グローバルに使う変数、ソート用のプロパティと関数を準備。

  private
    { Private 宣言 }
    x1,x2:integer;
    y1,y2:integer;
    //Pythonから送られたデータを保存する
    strAnsList:TStringList;

var
  Form1: TForm1;

type TSStyle = (ssText,ssInteger);
var
  //ソート用のプロパティ
  fAscending : Boolean;
  fIndex : Integer; //項目番号
  fStyle : TSStyle; //テキストか整数か

implementation

uses
  System.UITypes;
function GetCommaText(aStr:String; aIndex:Integer):string;
  var
    subList:TStringList;
begin
  subList := TStringList.Create;
  subList.Delimiter := ',';
  subList.DelimitedText := aStr;
  Result := subList.Strings[aIndex];
  subList.Free;
end;
function MyCustomSort(List: TStringList; Index1, Index2: Integer): Integer;
begin
  case fStyle of
    ssText:begin
      Result:=CompareText(GetCommaText(List.Strings[Index1],
      fIndex),
      GetCommaText(List.Strings[Index2],fIndex));
    end;
    ssInteger:begin
      //一重ソート
      //Result:=StrToInt(GetCommaText(List.Strings[Index1],fIndex))
      //          -StrToInt(GetCommaText(List.Strings[Index2],fIndex));
      //二重ソート
      Result:=StrToInt(GetCommaText(List.Strings[Index1],fIndex))
                -StrToInt(GetCommaText(List.Strings[Index2],fIndex));
      if Result=0 then
        //-1することで1番目の項目がソートキーになる
        Result:=StrToInt(GetCommaText(List.Strings[Index1],fIndex-1))  
                  -StrToInt(GetCommaText(List.Strings[Index2],fIndex-1));
      if fAscending then
      begin
        Result:=Result*-1;
      end else begin
        Result:=Result*1;
      end;
    end;
  else
    //これを入れておかないとコンパイラが警告を表示する
    Result:=0;
  end;
end;

で、「解答欄座標を取得」ボタンがクリックされたら、PythonForDelphiを通じてPythonのScriptを内部的に実行して座標を取得し、上記関数を呼び出して並べ替えを実行、結果をMemo2に表示する。

procedure TForm1.btnGetSquareClick(Sender: TObject);
var
  //PythonのScriptを入れる
  strScrList:TStringList;
  //Pythonから送られたデータを保存する -> グローバル変数化
  //strAnsList:TStringList;
  //Sort
  i:integer;
  strFileName:string;
  strList:TStringList;
begin
  //初期化
  Memo1.Clear;
  //Scriptを入れるStringList
  strScrList:=TStringList.Create;
  //結果を保存するStringList
  strAnsList:=TStringList.Create;

  try
    //Python Script
    strScrList.Add('import cv2');
    strScrList.Add('import numpy as np');
    //strScrList.Add('img = cv2.imread("./ProcData/sample2.jpg")');
    strScrList.Add('img = cv2.imread(r"./ProcData/'+ExtractFileName(StatusBar1.SimpleText)+'")');
    strScrList.Add('gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)');
    strScrList.Add('gray = 255 - gray');
    strScrList.Add('ret, bin_img = cv2.threshold(gray, 20, 255, cv2.THRESH_BINARY)');
    strScrList.Add('contours, hierarchy = cv2.findContours(bin_img, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)');
    strScrList.Add('contours = list(filter(lambda x: cv2.contourArea(x) > '+cmbThreshold.Text+', contours))');
    strScrList.Add('for i in range(len(contours)):');
    strScrList.Add('    im_con = img.copy()');
    strScrList.Add('    x, y, w, h = cv2.boundingRect(contours[i])');
    strScrList.Add('    var1.Value =str(x)+","+str(y)+","+str(x+w)+","+str(y+h)');
    //Scriptを表示
    Memo1.Lines.Assign(strScrList);
    //Execute
    PythonEngine1.ExecStrings(Memo1.Lines);
    //結果を表示
    Memo2.Lines.Assign(strAnsList);
  finally
    //StringListの解放
    strAnsList.Free;
    strScrList.Free;
  end;

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

  strList := TStringList.Create;
  try
    for i := 0 to Memo2.Lines.Count-1 do
    begin
      strList.Add(Memo2.Lines[i]);
    end;
    //fAscending := True; //昇順で
    fAscending := False;
    fIndex := 1; //2番目の項目を
    fStyle := ssInteger; //整数型でソート
    strList.CustomSort(MyCustomSort); //ソート
    //データ抽出
    Memo2.Clear;
    for i := 0 to strList.Count - 1 do
    begin
      //Memo2.Lines.Add(GetCommaText(strList.Strings[i],fIndex));
      Memo2.Lines.Add(strList[i]);
    end;
  finally
    strList.Free;
  end;

end;

上記のアルゴリズムは、次のWebサイトに紹介されていた情報を元に作成。
カンマ区切りのデータの並べ替えは初めて行った。採点順に座標を並べたかったので、プログラムコードをよく読んで、二重ソートになるよう工夫した。
貴重な情報を投稿してくださった方に心から感謝申し上げます。

[delphi-users:1175] カンマ区切りのデータの並べ替え

https://groups.google.com/g/delphi-users/c/Ck2mQXNFTvw

4.矩形検出器の使い方

ここまでの操作で解答欄の座標はすべて取得できたはずなので、不要な矩形のデータをいかに効率よく削除するかを主眼に、GUIの操作方法を考えた。

まず、取得できた座標データの先頭にセットフォーカスし、そのデータが示す矩形を赤いラバーバンドで囲んで表示する。ユーザーは、ラバーバンドで囲まれた矩形を見て、その要・不要を判断。

この矩形は不要

不要な矩形であった場合は、「編集」ボタンをクリック。不要なデータを自動で選択状態に設定。

Memoの一行全部を選択状態に設定

手続きは次の通り。

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

  //行番号をLines[i]で取得
  i:=StrToInt(LBRow.Caption)-1;

  EditTF:= not EditTF;
  if EditTF then
  begin
    BitBtn1.Caption:='編集中';
    BitBtn1.Font.Color:=clRed;
    Memo2.ReadOnly:=False;
    btnSave.Enabled:=False;

    //i行目の文字全てを選択状態にしたい場合
    //先頭にカーソルをセット
    Memo2.SelStart:=Memo2.Perform(EM_LINEINDEX, i, 0);
    //全ての文字を選択
    Memo2.SelLength:=Length(WideString(Memo2.Lines[i]));
    //Memo2.Perform(WM_VSCROLL,SB_TOP,0); //先頭にスクロール

  end else begin

    BitBtn1.Caption:='編 集';
    BitBtn1.Font.Color:=clBlack;
    Memo2.ReadOnly:=True;
    Memo2.SelStart:=SendMessage(Memo2.Handle,EM_LineIndex,i,0);
    btnSave.Enabled:=True;
    Memo2Click(Sender);

  end;

  //SetFocus
  Memo2.SetFocus;

end;

Delete or Backspaceキーで不要なデータを削除すると同時に、Memoの行も削除する。で、ボタンを「編集」(=意味的には「編集したい場合はクリックせよ」)に戻す。次のデータをラバーバンドで囲む。この一連の動作がすべて自動的に流れ作業で行われるように手続きを作成。

コードは次の通り。

procedure TForm1.Memo2KeyUp(Sender: TObject; var Key: Word; Shift: TShiftState);
var
  LineNo:integer;
begin
  //現在、カーソルがある行を取得
  LineNo:=Memo2.Perform(EM_LINEFROMCHAR, UINT(-1), 0);
  //空欄なら行を削除
  if Memo2.Lines[LineNo]='' then
  begin
    Memo2.Lines.Delete(LineNo);
  end;
  //表示
  GetLinePos;
  if not EditTF then
  begin
    Memo2Click(Sender);
  end else begin
    BitBtn1Click(Sender);
  end;
end;
procedure TForm1.GetLinePos;
var
  CurPos,Line:Integer;
begin
  with Memo2 do
  begin
    CurPos:=SelStart;
    Line:=Perform(EM_LINEFROMCHAR, CurPos, 0);
    //LBRowは現在フォーカスがある行番号を表示するラベル
    LBRow.Caption:=Format('%d', [Line+1]);
    LBRow2.Left:=LBRow.Left+LBRow.Width;
    LBRow2.Caption:='行目';
  end;
end;
procedure TForm1.Memo2Click(Sender: TObject);
var
  i:integer;
  p1,p2:TPoint;

  function RemoveToken(var s:string;delimiter:string):string;
  var
    p:Integer;
  begin
    p:=Pos(delimiter,s);
    if p=0 then Result:=s
    else Result:=Copy(s,1,p-1);
    s:=Copy(s,Length(Result)+Length(delimiter)+1,Length(s));
  end;

  function GetTokenIndex(s:string;delimiter:string;index:Integer):string;
  var
    i:Integer;
  begin
    Result:='';
    for i:=0 to index do
      Result:=RemoveToken(s,delimiter);
  end;

begin

  if not EditTF then
  begin

    //座標を取得
    i:=Memo2.Perform(EM_LINEFROMCHAR, Memo2.SelStart, 0);

    //エラー対策
    if Memo2.Lines[i]='' then Exit;

    x1:=StrToInt(GetTokenIndex(Memo2.Lines[i],',',0));
    y1:=StrToInt(GetTokenIndex(Memo2.Lines[i],',',1));
    x2:=StrToInt(GetTokenIndex(Memo2.Lines[i],',',2));
    y2:=StrToInt(GetTokenIndex(Memo2.Lines[i],',',3));

    if Assigned(plImage1) then begin
      FreeAndNil(plImage1);
    end;

    //コンポーネントを生成し,イベントを定義し,位置を指定して画像を表示
    plImage1:=TplResizeImage.Create(Self);
    plImage1.Parent:=ScrollBox1;
    plImage1.TransEvent:=True;
    //クライアント座標をスクリーン座標へ変換
    //GetSystemMetrics(SM_CYCAPTION) -> タイトルバーの高さ
    //GetSystemMetrics(SM_CYFRAME) -> ウィンドウの枠幅
    p1.X:=x1-(GetSystemMetrics(SM_CYFRAME) div 2);
    p1.Y:=y1-GetSystemMetrics(SM_CYCAPTION)-(GetSystemMetrics(SM_CYFRAME) div 2);
    p2.X:=x2-(GetSystemMetrics(SM_CYFRAME) div 2);
    p2.Y:=y2-GetSystemMetrics(SM_CYCAPTION)-(GetSystemMetrics(SM_CYFRAME) div 2);
    p1:=Image1.ClientToScreen(p1);
    p2:=Image1.ClientToScreen(p2);
    plImage1.SetBounds(p1.X, p1.Y, p2.X-p1.X, p2.Y-p1.Y);

    //SelectedプロパティをTrueにするとラバーバンドとグラブハンドルが表示される
    plImage1.Selected := True;
    plImage1.BringToFront;

  end;

end;

ラバーバンドはMr.XRAYさんのWebサイトにあったplResizeImageを使わせていただいて作成。これまでにもどれだけ助けていただいたことか・・・。このような素晴らしい素材を提供し続けてくださっているMr.XRAYさんに今回も心から感謝申し上げます。

157_移動リサイズ可能な TImage   ラバーバンドとグラブハンドル

http://mrxray.on.coocan.jp/Delphi/plSamples/157_MoveResize_GrabHandle.htm

ラバーバンドで囲まれた矩形が必要な矩形であった場合は、下のMemo3へ「移動」ボタンをクリックしてデータを移す。で、次の矩形をラバーバンドで囲んで表示する。

次の矩形の要・不要を判断
必要な矩形であれば下のMemo3へ移動する

この作業を順次繰り返すと、最終的に必要な矩形の座標のみがMemo3に移動。不要な矩形の座標はすべて削除されることになる。

必要な矩形の座標のみ、採点順に取得できた!

最終的に過不足がないか・どうか、Memo3の先頭座標データをクリック、ラバーバンドで該当矩形を囲んで表示、下向きの矢印キーを次へ次へと押して、フォーカスを下の座標データへ移動、ラバーバンドを表示して確認、これを最後の座標データまで繰り返し。

採点順を含めて、必要な座標データがすべて揃っていることを先頭データから順に確認する。

必要な座標がすべて取得できていることを確認したら、「保存」ボタンをクリックして手書き答案採点ソフトが実行時に読み込む、様々な採点設定を記録するための iniファイルに解答欄の座標データを保存する。

データの保存

【任意の範囲を指定したい場合】

複数の解答欄を抱き合わせて、完全解答で正解としたい場合などに対応するため、任意の範囲を矩形選択できるようにした。

画面中央左の追加ボタンをクリックすると、画面の中央にラバーバンドが表示される。これを任意の位置へドラッグする。

追加ボタンをクリックしてラバーバンドを表示
画面の中央にラバーバンドを表示、これを任意の位置へドラッグ。

ボタンのCaptionは、自動で「取得」に変更。

ボタンのCaptionを変更

任意の範囲をラバーバンドで囲んだら(=範囲指定完了)、「取得」ボタンをクリック。取得された座標がボタンの右のEditに表示され、同時にクリップボードへ送られる。

任意の範囲を指定して座標を取得

Memo3上の「追加」ボタンをクリックすると、Memo3が編集可能になるので、採点順を確認して、適切な行に座標のデータを追加(クリップボードから貼り付けても、データを見ながら手動入力してもよい)。

適切な位置に座標のデータを入力する

ラバーバンドを使わなくても、解答欄の左上と右下を、それぞれポイントすればその座標をラベルに表示する機能も追加したので、上の図のように、Memo3を編集モードにして、座標を任意の行へ直接入力することも可能。

マウスでポイントした場所の座標をリアルタイムで表示する

クライアント座標の取得と表示を行う手続きは、次の通り。

procedure TForm1.Image1MouseMove(Sender: TObject; Shift: TShiftState; X,
  Y: Integer);
var
  PtInput:TPoint;
begin
  //スクリーン座標を取得
  GetCursorPos(PtInput);
  //で、そのコントロールのクライアント領域に対するカーソルの座標を取得
  PtInput := Image1.ScreenToClient(PtInput);

  //補正する必要はない(確認済み)
  //表示
  Label2.Caption:=
    Format(' クライアント座標  '+'X : %d, Y : %d', [PtInput.X, PtInput.Y]);
end;

【矢印キーの押し下げを拾う】

最も難しかったのが、フォーカスが「どこにあるか」で矢印キーの挙動を制御すること。以前にStringGridのセルのフォーカスの移動を制限した時に学んだ内容が今回も役に立った。

今回は、Memoにフォーカスがある場合と、ラバーバンドにフォーカスがある場合、さらにラバーバンドにフォーカスがある場合のうち、Shiftキーと同時に矢印キーが押し下げられているのか(=ラバーバンドの大きさの変更)、それとも矢印キーが単独で押し下げられているのか(=ラバーバンドの表示位置の移動)、この3パターンを見分けてそれぞれにあった動作を行わせたいと考えた。最終的には次のコードで対応。

  private
    { Private 宣言 }

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

上のように手続きを宣言して、Shift+Ctrl+Cで手続きを生成。

procedure TForm1.AppMessage(var Msg: TMsg; var Handled: Boolean);
var
  StrText: string;
begin
  //何かキーが押し下げられたら
  if Msg.message = WM_KEYDOWN then
  begin
    try
      if ActiveControl is TMemo then
      begin
        //キー操作を「通常動作」にするおまじない
        case Msg.Message of
          WM_USER + $0500:
          Handled := True;
        end;
      end else begin
        //上位ビットが1ならShiftキーが押されている
        if GetKeyState(VK_SHIFT) and $8000 <> 0 then
        begin
          if plImage1.Visible then
          begin
            //右矢印キー
            if Msg.wParam=VK_RIGHT then
            begin
              plImage1.Width := plImage1.Width + 1;
              Msg.wParam:=0;
            end;
            //左矢印キー
            if Msg.wParam=VK_LEFT then
            begin
              plImage1.Width := plImage1.Width - 1;
              Msg.wParam:=0;
            end;
            //上矢印キー
            if Msg.wParam=VK_UP then
            begin
              plImage1.Height := plImage1.Height - 1;
              Msg.wParam:=0;
            end;
            //下矢印キー
            if Msg.wParam=VK_DOWN then
            begin
              plImage1.Height := plImage1.Height + 1;
              Msg.wParam:=0;
            end;
          end;
        end else begin
          //Shiftキーは押されていない
          //対象を限定(どちらでも動いた)
          //if TplResizeImage(ActiveControl).Visible then
          if plImage1.Visible then
          begin
            //右矢印キー
            if Msg.wParam=VK_RIGHT then
            begin
              plImage1.Left := plImage1.Left +1;
              Msg.wParam:=0;
            end;
            //左矢印キー
            if Msg.wParam=VK_LEFT then
            begin
              plImage1.Left := plImage1.Left -1;
              Msg.wParam:=0;
            end;
            //上矢印キー
            if Msg.wParam=VK_UP then
            begin
              plImage1.Top := plImage1.Top - 1;
              Msg.wParam:=0;
            end;
            //下矢印キー
            if Msg.wParam=VK_DOWN then
            begin
              plImage1.Top := plImage1.Top + 1;
              Msg.wParam:=0;
            end;
            //Deleteキー
            if Msg.wParam=VK_DELETE then
            begin
              //plImage1を解放
              if Assigned(plImage1) then begin
                FreeAndNil(plImage1);
              end;
              Msg.wParam:=0;
            end;
          end;
        end;
      end;
    except
      on E: Exception do
      begin
        StrText := E.ClassName + sLineBreak + E.Message;
        Application.MessageBox(PChar(StrText), '情報', MB_ICONINFORMATION);
      end;
    end;
  end;
end;

plImage1が生成されないうちに上の手続きが呼ばれると、当然、一般保護違反のエラーが発生するので、FormCreate時にplImage1を生成しておく。

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

  //メモリーリークがあれば検出
  ReportMemoryLeaksOnShutdown:=True;

  //有効にする(忘れないこと!)
  Application.OnMessage := AppMessage;

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

  //コンポーネントを生成 -> インスタンス(実体)をつくる
  // = 一般保護違反エラーの防止
  //plImage1はグローバル変数として宣言しているから未定義の識別子エラーは発生しない
  //でも、Create(生成)してからでなければ使えない!
  plImage1:=TplResizeImage.Create(Self);

  //編集フラグ(編集中ではない)
  EditTF:=False;
  PlusTF:=False;
  Memo2.ReadOnly:=True;

  //StatusBar1の設定
  StatusBar1.SimplePanel:=True;

  //Formを最大化して表示(幅も最大化される)
  Form1.WindowState:=wsMaximized;

  //Embeddable Pythonの存在の有無を調査
  AppDataDir:=ExtractFilePath(Application.ExeName)+'Python39-32';
  if DirectoryExists(AppDataDir) then
  begin
    //フォルダが存在したときの処理    
    PythonEngine1.AutoLoad:=True;
    PythonEngine1.IO:=PythonGUIInputOutput1;
    PythonEngine1.DllPath:=AppDataDir;
    PythonEngine1.SetPythonHome(PythonEngine1.DllPath);
    PythonEngine1.LoadDll;
    //PythonDelphiVar1のOnSeDataイベントを利用する
    PythonDelphiVar1.Engine:=PythonEngine1;
    PythonDelphiVar1.VarName:=AnsiString('var1');  //プロパティで直接指定済み
    //初期化
    PythonEngine1.Py_Initialize;
  end else begin    
    PythonEngine1.AutoLoad:=False;
  end;

  //面積の閾値の選択肢を設定
  for i := 1 to 200 do
  begin
    cmbThreshold.Items.Add(IntToStr(i*100));
  end;

  //画面のちらつきを防止する
  DoubleBuffered := True;

end;

で、メモリーリーク発生の原因とならないよう、アプリの終了時に忘れずに解放。

procedure TForm1.FormCloseQuery(Sender: TObject; var CanClose: Boolean);
begin
  //メモリーリークを防止する
  PythonEngine1.Py_Finalize;
  PythonDelphiVar1.Finalize;
  FreeAndNil(plImage1);
end;

5.まとめ

(1)矩形の検出は、OpenCVのfindContours関数を利用する。
(2)矩形の検出を回避するには「点線」を利用する。
(3)GUIはDelphiで作成し、必要な座標だけ保存できるように工夫。
(4)「フォーカスがどこにあるか」で矢印キーの動作を制御。
(5)コントロール生成のタイミングと確実な破棄にも注意する。

6.お願いとお断り

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

Link Image click position with Grid control

「画像のクリック位置とグリッドコントロールの連動」

画像を表示したTImageの任意の1点をクリックしたら、そのY座標に応じてStringGridコントロールの適切なセルを選択するようなプログラムが書けないかなー。・・・と思って、実際に書いてみたら、あまりにも簡単に実現できてしまったお話。

1.やりたかったこと
2.作成したプログラム
3.まとめ
4.お願いとお断り

1.やりたかったこと

次のようなGUIのある採点プログラムを作成した。プログラムはスキャナーで読み込んだ複数枚の答案画像から、設問ごとに解答欄をかき集めて表示するもの(答案画像への書き戻しも可能)。で、採点作業をより一層効率的に行うには、各々の解答欄画像をクリックした際、自動的に採点結果を入力するGridコントロールのセルが選択されたらイイなーと思ったことが、この連動プログラムを書こうとしたきっかけです。

画像をクリックした時に得られるY座標を元に、Gridコントロールの最適なセルを選択したい!

2.作成したプログラム

まず、思ったことはTImageのOnMouseDownイベントを利用すればイイ(画像上でクリックした位置のX, Y座標が取得できる)ということです。早速、imgAnswerという名前をつけたTImageをクリックして選択し、OnMouseDownイベントの右側をダブルクリックして、imgAnswerMouseDown手続きを作成。

次のようなコードを書いてみました!

procedure TFormCollaboration.imgAnswerMouseDown(Sender: TObject;
  Button: TMouseButton; Shift: TShiftState; X, Y: Integer);
begin
  //imgAnswerの高さは、解答欄1つの高さ × 解答用紙数 だから
  //クリックした場所の(Y座標 div 解答欄1つの高さ) + 1 が解答用紙の番号になるはず
  OutPutDebugString(PChar(IntToStr((Y div 解答欄1つの高さ) + 1)));   
end;

実行(F9)して、確認。

No,5の解答欄をクリックすると、確かに「5」と表示されている! やった☆

必要と思われる変数 i, j を宣言して・・・
あとは Gridコントロールへ SetFocus するコードを書くだけ!

procedure TFormCollaboration.imgAnswerMouseDown(Sender: TObject;
  Button: TMouseButton; Shift: TShiftState; X, Y: Integer);
var
  i,j:integer;
begin
  //imgAnswerの高さは、解答欄1つの高さ × 解答用紙数 だから
  //クリックした場所の(Y座標 div 解答欄1つの高さ) + 1 が解答用紙の番号になるはず
  OutPutDebugString(PChar(IntToStr((Y div 解答欄1つの高さ) + 1)));
  i:= (Y div 解答欄1つの高さ) + 1;
  //SetFocus
  j:= StrToInt(現在選択している解答欄の番号);
  StringGrid1.Col:= j;
  StringGrid1.Row:= i;
  StringGrid1.SetFocus;   
end;

上のコードでは、わかりやすさのため変数(記号)ではなく、そのかわりに「解答欄1つの高さ」等のように説明の文字列で記述しています。また、採点ミスを防止する観点から、Gridコントロールに表示する入力可能な列は、現在選択(=画像として表示)している解答欄に対応する1列のみとしています。

これで理想としていたカタチの採点プログラムに一歩近づけたように思います。ユーザーに案内したい採点方法は、サっと見て全員が良く出来ている設問であれば、最初に全員正解として得点を一括自動入力し、誤りの解答のみ、解答欄画像を見ながらチェックしてクリック、自動選択された採点欄のセルにゼロ(得点)を入力する方法です。

3.まとめ

案ずるよりナントカといいますが、ほんとうにその通りで、内心、ずっとできるかなー? なんて思っていたことが、案外、基本的な知識だけで簡単に実現できてしまいました!

//Integer 型で、割り算をしたいときは、整数除算の演算を行う div を使う
A div B  //A ÷ B の商が取得できる

あとは、やりたいことを実現するアルゴリズムを考えるだけですね!
プログラミングしていて、いちばんしあわせで、楽しい時間を過ごせました☆

【注意!】
画像の表示倍率の変更が伴う場合には、表示倍率に合わせてプログラムの変更が必要
です。完璧には動作しませんが、ある程度使える(と思われる)表示倍率の変更に対応するコードは次の通りです(クリック位置が画像の下へ行くほど、誤差が大きくなります)。

procedure TFormCollaboration.imgAnswerMouseDown(Sender: TObject;
  Button: TMouseButton; Shift: TShiftState; X, Y: Integer);
var
  i,j,w:integer;
  k:double;
begin
  //imgAnswerの高さは、解答欄1つの高さ × 解答用紙数 だから
  //クリックした場所の(Y座標 div 解答欄1つの高さ) + 1 が解答用紙の番号になるはず
  OutPutDebugString(PChar(IntToStr((Y div 解答欄1つの高さ) + 1)));
  i:= (Y div 解答欄1つの高さ) + 1;
  //表示倍率が100%の時
  if Edit1.Text='100' then
  begin
    i:=(Y div 解答欄1つの高さ)+1;  //このアルゴリズムは記録として残したい
  end else begin
    //表示倍率が100%ではない時
    k:= 解答欄1つの高さ * (StrToFloat(Edit1.Text)/100);
    i:=Ceil(Float(Y)/k);  // <- とりあえずこれで使えるカモ?
  end;

4.お願いとお断り

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

Mark Sheet Reader (Basic version)

「マークシートリーダーをつくる(基礎編)」

DelphiでGUIを作成、マークシート画像はPythonにインストールしたOpenCVとNumpyで読み取り&計算処理して、結果をMemoに表示するマークシートリーダーの練習プログラム。

0.準備
1.使用するプログラムとマークシート画像について
2.マークシート画像を読み込む
3.マークシート読み取り処理のアルゴリズム
4.マークシート読み取り処理の実際(Object Pascalのコード)
5.さらに進化
6.著作権表示の記載方法
7.お願いとお断り

ここで紹介している練習用プログラムを、実際の採点業務で使用できるようにした拙作マークシートリーダーです。

0.準備

マークシートリーダー作成にあたって、以下の事前準備が必要です。

・PythonForDelphiのインストール
・Embeddable Pythonのダウンロードと必要なライブラリのインストール
(作業後、このプログラムへの埋め込み用にフォルダ名を「Python39-32」に変えて、このプログラム(マークシートリーダー)のexeがある場所へコピーする)
・アプリケーションの表示画面のリサイズ対応(縦編)

(いずれも、当Blogの記事で過去に紹介)

重要 上の記事の手順で、OpenCVとNumpyをインストールしたEmbeddable Pythonが入ったフォルダを「Python39-32」という名前で、以下のフォルダ内にコピーする。

C:\Users\ xxx \ Project1.dprojファイルのあるフォルダ \Win32\Debug\

1.使用するプログラムとマークシート画像について

当Blogの過去記事『~主として「高さ」の変更に関する覚書~』で作成したDelphiのGUIをそのまま使用します。

必要なVCLとその構造(親子関係)

画面サイズの変更に対応できるよう、以下のコードを記述。

unit Unit1;

interface

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

type
  TForm1 = class(TForm)
    Panel1: TPanel;
    Panel2: TPanel;
    Panel3: TPanel;
    Splitter1: TSplitter;
    ScrollBox1: TScrollBox;
    Image1: TImage;
    Memo1: TMemo;
    procedure FormCreate(Sender: TObject);
    procedure FormResize(Sender: TObject);
    procedure Splitter1Moved(Sender: TObject);
  private
    { Private 宣言 }
    //Panel1の幅とFormの高さを記憶する変数
    intPH, intFH:integer;
    //Formの表示終了イベントを取得
    procedure CMShowingChanged(var Msg:TMessage); message CM_SHOWINGCHANGED;
  public
    { Public 宣言 }
  end;

var
  Form1: TForm1;

implementation

{$R *.dfm}

{ TForm1 }

procedure TForm1.CMShowingChanged(var Msg: TMessage);
begin
  inherited; {通常の CMShowingChagenedをまず実行}
  if Visible then
  begin
    Update; {完全に描画}
    //Formの表示終了時に以下を実行
    Panel1.Height:=intPH;
    intPH:=Panel1.Height;
    intFH:=Form1.Height;
  end;
end;

procedure TForm1.FormCreate(Sender: TObject);
begin
  //Panel1とFormの高さを記憶する変数を初期化
  intPH:=200;
  intFH:=480;
end;

procedure TForm1.FormResize(Sender: TObject);
begin
  //比率を維持してPanel1の高さを変更
  Panel1.Height:=Trunc(Form1.Height * intPH/intFH);
end;

procedure TForm1.Splitter1Moved(Sender: TObject);
begin
  //Panel1とFormの高さを取得
  intPH:=Panel1.Height;
  intFH:=Form1.Height;
end;

end.

マークシート画像は、以下の画像を使用。

「ms01.Jpg」

マークシート画像は、以下の場所に「MarkSheet」という名前のフォルダを作成して、その中に保存。

C:\Users\ xxx \ Project1.dprojファイルのあるフォルダ \Win32\Debug\Marksheet

2.マークシート画像を読み込む

Delphiを起動して、Project1.dproj(マークシート読み取り用GUIの保存してあるフォルダ内のDelphiのプロジェクトファイル)を開き、Panel3をクリックして選択しておいて、Panel3上にButton1を作成。Button1のNameプロパティはButton1のまま、Captionプロパティを「画像を表示」に変更。Button1の位置は下図を参照。

Captionプロパティを「画像を表示」に変更
Button1の位置は画面下・Panel3の左に寄せる

OpenDialog1をForm上に置く。

OpenDialogをダブルクリック
Form上のOpenDialog1

次に、Form上のButton1をダブルクリックして、procedure TForm1.Button1Click(Sender: TObject);を作成。

procedure TForm1.Button1Click(Sender: TObject);
begin

end;

作成した手続きではJpeg画像を扱うので、画面を上にスクロールして、implementation部の下に Vcl.Imaging.Jpeg を uses する。

implementation

uses
  Vcl.Imaging.Jpeg; //Jpeg画像を読み込む

{$R *.dfm}

Button1Clickプロシージャにvar宣言を追加して、Jpeg画像読み込み用の変数jpgを宣言。

procedure TForm1.Button1Click(Sender: TObject);
var
  jpg: TJPEGImage;
begin

end;

beginとend;の間に、以下のコードを記述。

  //OpenDialogのプロパティはExecuteする前に設定
  With OpenDialog1 do begin
    //表示するファイルの種類を設定
    Filter:='JPEG Files (*.jpg, *.jpeg)|*.jpg;*.jpeg';
    //データの読込先フォルダを指定
    InitialDir:=ExtractFilePath(Application.ExeName)+'MarkSheet';
  end;

  if not OpenDialog1.Execute then Exit;  //キャンセルに対応
  //オブジェクトを生成
  jpg := TJPEGImage.Create;
  try
    //読み込み
    jpg.LoadFromFile(OpenDialog1.FileName);
    //Image1に表示
    Image1.Picture.Assign(jpg);
  finally
    //オブジェクトを破棄
    jpg.Free;
  end;

上書き保存(Ctrl+S)して、実行(F9)。データの読み込み先を指定しておくと、目的のフォルダが一発で開くので便利。

マークシート画像が表示される。が、ごく一部しか見えない。

これはImage1のAutoSizeプロパティがデフォルトFalseに設定されているため。 Image1 のAutoSizeプロパティをTrueにするコードを追加(オブジェクトインスペクタで Image1 のAutoSizeプロパティを 直接指定してもOK)。

  try

    //読み込み
    jpg.LoadFromFile(OpenDialog1.FileName);
    //Image1に表示
    Image1.Picture.Assign(jpg);

    //追加
    Image1.AutoSize:=True;

  finally

上書き保存(Ctrl+S)して、実行(F9) 。画像の表示を確認する。

うまくいったように見える。Formを最大化してSplitterを下げて、さらに確認。
画像の表示位置を修正する必要がありそうだ

画像が表示される位置を、画面の左側へ移動するコードを手続きの先頭に追加する。

begin

  //Imageの表示位置を指定
  Image1.Top := 25;
  Image1.Left := 40;

  //OpenDialogのプロパティはExecuteする前に設定しておくこと
  With OpenDialog1 do begin

上書き保存(Ctrl+S)して、実行(F9) 。画像の表示を再度確認する。

ほぼイメージに近い出来栄え?

参考:画像読み込みのコード(全体)

implementation

uses
  Vcl.Imaging.Jpeg; //Jpeg画像を読み込む

{$R *.dfm}

{ TForm1 }

procedure TForm1.Button1Click(Sender: TObject);
var
  jpg: TJPEGImage;
begin

  //Imageの表示位置を指定
  Image1.Top := 25;
  Image1.Left := 40;

  //OpenDialogのプロパティはExecuteする前に設定しておく
  With OpenDialog1 do begin
    //表示するファイルの種類を設定
    Filter:='JPEG Files (*.jpg, *.jpeg)|*.jpg;*.jpeg';
    //データの読込先フォルダを指定
    InitialDir:=ExtractFilePath(Application.ExeName)+'MarkSheet';
  end;

  if not OpenDialog1.Execute then Exit;  //キャンセルに対応
  //オブジェクトを生成
  jpg := TJPEGImage.Create;
  try

    //読み込み
    jpg.LoadFromFile(OpenDialog1.FileName);
    //Image1に表示
    Image1.Picture.Assign(jpg);

    //追加
    Image1.AutoSize:=True;

  finally
    //オブジェクトを破棄
    jpg.Free;
  end;

end;

3.マークシート読み取り処理のアルゴリズム

まず最初にマークシートの左上にある特徴点(マーカー)画像: ■■■(トリプルドット)をOpenCVのテンプレートマッチングで探す。

特徴点(マーカー)画像が見つかったら、 特徴点(マーカー)画像左上位置を基準にして、「マークシートの周囲の枠部分のみ」を矩形選択して切り出し。

参考①:あらかじめ測定しておいた特徴点(マーカー)画像の位置(単位はピクセル)
左上のX座標=65
左上のY座標=28
右下のX座標=121(マークシート矩形の座標計算には使用しない)
右下のY座標=43(マークシート矩形の座標計算には使用しない)

参考②:あらかじめ測定しておいたマークシート矩形の座標 (単位はピクセル)
左上の X座標=65
左上の Y座標=61
右下の X 座標=419
右下の Y 座標=497

参考 上記の各座標をマークシート画像から計測し、テンプレートとして用意したマークシートごとに登録(座標値を保存)するプログラムを別途作成した。なお、座標原点(0,0)は画像の左上である(使い慣れた数学の座標系とちょっと違うことに注意!)。

赤が左上、青が右下の座標で、緑がマークシート枠の矩形

この座標を元にして、 特徴点(マーカー)画像からの距離で、マークシート矩形を切り出す。

マークシート矩形において、(W1、H1)が左上位置を、(W2、H2)が右下位置を示す座標となる。

上の例では、マークシートの列数は「1」、行数は「10」と数えることにする。列数が「1」の場合、W1は「ほぼ0(ゼロ)」になり、値としての意味がないように思われるが、このプログラムを実用化した場合は、下の例のように、複数の列があるマークシートを用いることになるので、2列めのマークシート矩形の座標は、左上が(W3,H3)、右下が(W4,H4)、3列めのマークシート矩形の座標は左上が (W5,H5)、右下が(W6,H6)のように指定でき、W値が0ではない場合が生じる。

マークシート用紙の作成に、私はWordを用いたが、Wordのバージョンによっては、あろうことか、上書き保存時に、マーカー画像(■■■)の位置が数ミリ程度、勝手に左へ移動するという予期しないトラブル(Wordの仕様?)が発生。このような点も考慮して、W1の座標は敢えて(0として)定数化していない。

マークシートの作成例(実験用に使用)
列数3、1列あたりの行数25、1行あたりの選択肢の数は16
この用紙の場合、総マーク数は3×25×16=1200個/枚となる
つまり用紙1枚につき、1200回マークの有無の判定が必要

実際の作業では、マークシート画像をスキャナーで読み取って、グレースケールのJpeg画像としてデータ化するので、マークシート(用紙)に「しわ」があったり、状況によっては「折られ」ていたりする関係上、読み取り画像を1枚ずつ比較すると、その上下・左右にどうしても微妙なブレ・ズレが生じてしまう。しかし、同じ印刷機で、同時に印刷したマークシートであれば、特徴点(マーカー)画像とマークシートの行列位置の関係は絶対であり、これが1枚ごとに変化することはありえない。つまり、スキャンした画像が余程大きく傾きでもしていない限り、テンプレートマッチングで、特徴点(マーカー)画像さえ発見できれば、予め測定・記録しておいた座標の相対的位置関係からマークシート矩形は容易に切り出せる。

次の画像は、別データとして保存してある特徴点(マーカー)画像を元に、OpenCVのテンプレートマッチングをマークシート画像に対して行ったもの。類似度の高い部分を赤枠で囲んで示すようプログラミングしている。

マーカー
テンプレートマッチングを行った画像

次に、上に述べた方法で計算したマークシート矩形を列単位で切り出す。切り出した画像は、マークの(=列)数・行数の整数倍のサイズになるようリサイズする(これは、このあと画像を細かく分割して処理するので、切り出す行や列の計算を簡単にするための工夫 → 整数倍にリサイズすれば、列数分&行数分廻すLoop処理の中で処理しやすい)。

列単位で切り出したマークシート矩形

マークシート用紙は、一般的なマークシート用紙のような厚みのある(高級感あふれる)専用紙でなく、ホームセンターでも「売ってない!」ような見た目が灰色の再生紙を用いている。このためか、あちらこちらにゴミのような黒い点や、細いすじが入っていることがある。これらの黒点やすじを判定プログラムが「マークあり」と誤認しないようにするため、次に「平滑化(ボカシ)処理」を行う。

平滑化(ボカシ)処理には「ガウシアンフィルタ」を用いた。これは、正規(ガウス)分布を利用して「注目画素からの距離に応じて近傍の画素値に重みをかける」という処理を行うもので、自然な平滑化が実現できるとのこと。次の画像は、上の切り出したマークシート矩形に対して、この平滑化処理を行ったもの。

img = cv2.GaussianBlur(img,(35,35),0) ※引数は奇数を指定する必要がある

引数の値が大きいほど正規分布のピークが低く、広がりは広くなる(=より均一に、より全体にボカシがかかる)。ここでは引数をかなり大きめにとり「35」としている。こうすることで、ゴミやシミを画像からほぼ完全に除去できる。

ガウシアンフィルタ処理を行い、ゴミやシミを除去する

さらに、この画像を「ある閾値」を元に白と黒に二値化処理する。この処理で枠線やマークされていないマーク部分が「すべて白」になり、鉛筆で濃くマークされている部分だけが「黒」になった白黒画像が得られる。当初は、以下のように引数を指定して二値化画像を作成した。

ret, img = cv2.threshold(img, 140, 255, cv2.THRESH_BINARY)

現在は、次のように閾値の設定を自動で行う「大津の二値化」を利用している。

ret, img = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)

式中の第2引数は閾値だが、大津の二値化では自動計算させるので0(ゼロ)を指定。第3引数は0-255の256段階でグレースケール化しているから、最大値の255を指定する。これによって、次の画像が得られる。

大津の二値化で作成した白黒画像

さらに、これを白黒反転させた画像を作成する。式は以下の通り。

img = 255 - img

これにより、次の画像が得られる。

マーク部分を「白」に変換した画像

次に、この画像を「行」単位に分割して切り出す。

1行目を切り出した画像

次に、選択肢の数で、均等に分割する。ここでは選択肢の数が「8」なので、上の画像を等幅で8個に分割する。下は、その1個目の切り出し画像である。

このように細かく分割して切り出した画像1つ1つについて、画素が白なら値を255・黒なら0として面積あたりの合計値を計算し、マークされている部分の面積の中央値を算出、これを閾値として、下の式では、マークされている(白い部分の)面積が他より3倍以上あるものを「マークあり!」と判定している。この数値が大きいほど、判定はきびしくなる。

result.append(area_sum > np.median(area_sum) * 3)

このマークシート読み取り処理のアルゴリズムの主要部分は全て、GitHubの次の記事に紹介されていたものです。素晴らしい記事を投稿してくださった作成者の方に、心から感謝申し上げます。

PythonとOpenCVで簡易OMR(マークシートリーダ)を作る

URL:https://qiita.com/sbtseiji/items/6438ec2bf970d63817b8

参考 列が複数あるマークシートの読み取り処理について

上記記事では、特徴点(マーカー)画像をマークシートの上下に複数個用意し、テンプレートマッチングを行っています。確かに、マークシートの左上と右下に特徴点(マーカー)画像を用意すれば、より簡単にマークシート矩形の切り出しが可能でした。これは素晴らしいアイデアです。

私も当初は特徴点(マーカー)画像を複数個用意してマークシートを作成していたのですが、列数を2列、3列と増やすと、さまざまな問題が生じることに気が付きました。

第一に、特徴点(マーカー)画像を変えないと、列ごとの切り出しが困難だということです。つまり、3列あるマークシートでは、最も左の列用の特徴点を■■■、真ん中の列用の特徴点を■□■、最も右側の列用の特徴点を■□□として、Loop処理の中でテンプレートマッチングに使用する特徴点(マーカー)画像を切り替えて、目的とするマークシート矩形を切り出せるようにしてみた(□□■や□□□も含めればさらに多くの列が作成可能)のですが、この方法では、うまく特徴点(マーカー)画像を認識してくれないことがあり、安定感に欠ける気がしました。

第二に、万一、回答者が特徴点(マーカー)画像に意図的に変更を加える(例: ■□□ → ■■□)等の暴挙に出た場合、対応が難しいこと。

第三に、マーカー画像が多いと、マークシートの見た目もなんだか騒がしくて、個人的にはマーカー画像を複数個用意する方法はなるべく避けたいと考えたこと。

これらの理由から、「なんとか特徴点(マーカー)画像が1個で済まないか」と、私なりに工夫して、当ブログで紹介した方法を考えました。

創意工夫の過程で一時は、回答者が意図的に変更できるようなマーカー(例: □ )がなければOKかとも思い、別の特徴点(マーカー)画像も使ってみたのですが、それはそれでまた別の問題を起こすことがわかりました。

例えば、下のように、ヒトなら簡単に両者の違いを判別できる画像を用意します。

用意した特徴点(マーカー)画像

これに対して、左側の画像でテンプレートマッチングを行うと・・・

機械はヒトと違うモノの見方をしていることが、大変良くわかりました。

4.マークシート読み取り処理の実際(Object Pascalのコード)

Form上に、Buttonを1つ、PythonForDelphi関連のVCLコンポーネントを3つ配置する。Button2は、Panel3の中央付近に置き、Nameプロパティはそのまま、Captionプロパティを「読み取り」に変更する。PythonForDelphi関連のVCLコンポーネントは、すべて非ビジュアルコンポーネントなので、位置はどこでもよく、Nameプロパティもデフォルトのままとする。 PythonForDelphi関連で配置するコンポーネントは以下の通り。

以下のように、PythonForDelphi関連のコンポーネントのプロパティとイベントを設定

・PythonEngine1のAutoLoadプロパティはFalseに設定。

・PythonEngine1のDllNameプロパティはpython39.dllを指定(埋め込みPythonのバージョンに合わせて設定する)。ここでは3.9.9以下のバージョンのPythonでないとNumpyが非対応(2021年12月現在)であり、用意した埋め込みPythonのバージョンは3.9.9なのでpython39.dllに変更する。

・PythonEngine1のIOにはPythonGUIInputOutput1を指定。

・PythonGUIInputOutput1は他で利用するならプロパティのOutPutに「Memo1」などとするところだけれど、ここでは何も設定しない。

・PythonDelphiVar1のVarNameはプログラムコードの記述に合わせて「var1」とする。var1と入力後、Enterで確定すること!(青く反転表示されるのを確認する)

Formが生成される時、PythonEngine1を初期化する。Formのタイトルバーの上をクリックして選択し、オブジェクトインスペクタのイベントタブをクリックしてOnCreateイベントの右に表示されている「FormCreate」をダブルクリックして、コードの入力に切り替える。

参考:エラー対応方法(20220724追加)

P4D使用時にImageコントロールの bsClear を使うとエラーが発生します。

[dcc32 エラー] Unit02_MSReader.pas(1199): E2010 'TBrushStyle' と 'Enumeration' には互換性がありません

これはPythonEngine.pasの中で bsClear が定義(使用)されているためです。次に示す例のように、Image1の方のbsClearを明示的に Vcl.Graphics.bsClear として対応します。

  //矩形を描画
  with Image1 do
  begin
    //Canvas.Brush.Style:=bsClear;
    Canvas.Brush.Style:=Vcl.Graphics.bsClear;
  end;

以上、エラー対応でした。解説を続けます。

表示は次のようになっている(はず)。ここにコードを追加する。

procedure TForm1.FormCreate(Sender: TObject);
begin

  //Panel1とFormの高さを記憶する変数を初期化
  intPH:=200;
  intFH:=480;

end;

追加するコード

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

  //Panel1とFormの高さを記憶する変数を初期化
  intPH:=200;
  intFH:=480;

  //以下のコードを追加
  //embPythonの存在の有無を調査
  AppDataDir:=ExtractFilePath(Application.ExeName)+'Python39-32';

  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;

ここでMessageDlgを使用しているので、以下のように System.UITypes を uses に追加する。

implementation

uses
  Vcl.Imaging.Jpeg, System.UITypes;  // <-追加

  //Jpeg:Jpeg画像を読み込む
  //System.UITypesはMessageDlgの表示に必要

{$R *.dfm}

プライベートメンバー変数 intCnt(カウンタとして利用する)と strAnsList(Pythonから返された計算結果を保存する) を2つ、Private宣言で新しく宣言する。

  private
    { Private 宣言 }

    //for Python(追加)
    //Counter
    intCnt:integer;
    //Pythonから送られたデータを保存
    strAnsList:TStringList;

    //Panel1の幅とFormの高さを記憶する変数
    intPH, intFH:integer;
    //Formの表示終了イベントを取得
    procedure CMShowingChanged(var Msg:TMessage); message CM_SHOWINGCHANGED;

  public
    { Public 宣言 }
  end;

Form上のButton2(読み取りボタン)をダブルクリックして、手続きを作成し、以下の内容を入力する。

procedure TForm1.Button2Click(Sender: TObject);
var
  StrList:TStringList;
  strJCnt,strColCnt,strRowCnt,strSelCnt:String;
  TopLX, TopLY, TLX1, TLY1, BRX1, BRY1:integer;
  strPicName:string;
begin

  //初期化
  Memo1.Clear;
  intCnt:=1;

  //座標
  TopLX:=65;
  TopLY:=28;
  //BtmRX:=121;
  //BtmRY:=43;
  TLX1:=65;
  TLY1:=61;
  BRX1:=419;
  BRY1:=497;

  //マークシート数Check(+1することを忘れない)
  strJCnt:=IntToStr(2);

  //列数Check(+1することを忘れない)
  strColCnt:=IntToStr(2);

  //1列あたりの行数Check
  strRowCnt:=IntToStr(10);

  //選択肢数Check
  strSelCnt:=IntToStr(8);

  //マークシート名
  strPicName:='ms';

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

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

  try

    //Python Script
    StrList.Add('import cv2');
    StrList.Add('import numpy as np');

    //for JPN(日本語に対応)
    StrList.Add('def imread(filename, flags=cv2.IMREAD_GRAYSCALE, dtype=np.uint8):');
    StrList.Add('    try:');
    StrList.Add('        n = np.fromfile(filename, dtype)');
    StrList.Add('        img = cv2.imdecode(n, flags)');
    StrList.Add('        return img');
    StrList.Add('    except Exception as e:');
    StrList.Add('        return None');

    //マーカー画像を読み込む
    StrList.Add('template = imread("marker.png", cv2.IMREAD_GRAYSCALE)');

    //マークシートの枚数
    StrList.Add('for j in range(1,'+strJCnt+'):');

    //列数
    StrList.Add('    for i in range(1,'+strColCnt+'):');

    //マークシートへのパスを取得
    StrList.Add('        if j < 10:');
    StrList.Add('            MS_Name = r".\Marksheet\'+ strPicName +'0"+ str(j) +".jpg"');
    StrList.Add('        else:');
    StrList.Add('            MS_Name = r".\Marksheet\'+ strPicName +'"+ str(j) +".jpg"');

    //画像を読み込む
    StrList.Add('        img = imread(MS_Name)');
    //画像をグレースケールで読み込む
    StrList.Add('        img_gray = imread(MS_Name, 0)');

    //テンプレートマッチングの実行(比較方法cv2.TM_CCORR_NORMED)
    StrList.Add('        result = cv2.matchTemplate(img, template, cv2.TM_CCORR_NORMED)');

    //類似度が最小,最大となる画素の類似度、位置を調べ代入する
    StrList.Add('        min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result)');
    //最も似ている領域の左上の座標を取得
    StrList.Add('        top_left = max_loc');
    StrList.Add('        if i == 1:');

    //補正値を取得(高さ)
    StrList.Add('            h1 = ' + IntToStr(TLY1 - TopLY));
    StrList.Add('            h2 = ' + IntToStr(BRY1 - TopLY));
    //補正値を取得(幅)
    StrList.Add('            w1 = ' + IntToStr(TLX1 - TopLX));
    StrList.Add('            w2 = ' + IntToStr(BRX1 - TopLX));

    //矩形の左上の座標を計算 [0]-> X, [1]-> Y
    StrList.Add('        TL = (top_left[0] + w1, top_left[1] + h1)');
    //矩形の右下の座標を計算
    StrList.Add('        BR = (top_left[0] + w2, top_left[1] + h2)');
    //画像を切り出し img[top_Y : bottom_Y, left_X : right_X]
    StrList.Add('        img = img_gray[TL[1] : BR[1], TL[0] : BR[0]]');

    //選択肢数
    StrList.Add('        n_col = '+ strSelCnt);

    //解答欄1列あたりの行数
    StrList.Add('        n_row = '+ strRowCnt);
    StrList.Add('        margin_top = 0');
    StrList.Add('        margin_bottom = 0');
    StrList.Add('        n_row = n_row + margin_top + margin_bottom');

    //マークの列数・行数の整数倍のサイズになるようリサイズ
    StrList.Add('        img = cv2.resize(img, (n_col*100, n_row*100))');

    //保存して確認
    //StrList.Add('        cv2.imwrite("01_ReSize.png", img)');

    //平滑化の度合い
    StrList.Add('        img = cv2.GaussianBlur(img,(35,35),0)');

    //保存して確認
    //StrList.Add('        cv2.imwrite("02_GaussianBlur.png", img)');

    //二値化の閾値
    //50を閾値として2値化
    //imgはグレースケール画像でなければならない
    //第2引数はしきい値で,
    //画素値を識別するために使用(指定)
    //第3引数は最大値でしきい値以上
    //(指定するフラグ次第では以下)の値を持つ
    //画素に対して割り当てられる値
    //StrList.Add('        ret, img = cv2.threshold(img, 140, 255, cv2.THRESH_BINARY)');

    //大津の二値化で閾値の設定を自動化
    //第1引数には画像データを設定
    //(グレースケール画像でなければならない)
    //第2引数はしきいだが自動計算させるので0(ゼロ)を指定
    //第3引数は0-255の256段階でグレースケール化しているから
    //最大値の255を指定
    StrList.Add('        ret, img = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)');

    //保存して確認
    //StrList.Add('        cv2.imwrite("03_threshold.png", img)');

    //白黒を反転
    StrList.Add('        img = 255 - img');

    //保存して確認(追加)
    StrList.Add('        cv2.imwrite("04_threshold.png", img)');

    //全マークを判定
    StrList.Add('        result = []');
    StrList.Add('        for row in range(margin_top, n_row - margin_bottom):');
    StrList.Add('            tmp_img = img [row*100:(row+1)*100,]');
    StrList.Add('            area_sum = []');
    StrList.Add('            for col in range(n_col):');
    StrList.Add('                area_sum.append(np.sum(tmp_img[:,col*100:(col+1)*100]))');
    StrList.Add('            result.append(area_sum > np.median(area_sum) * 3)');

    //判定結果を出力
    StrList.Add('        for x in range(len(result)):');
    StrList.Add('            res = np.where(result[x]==True)[0]+1');
    StrList.Add('            if len(res)>1:');
    StrList.Add('                var1.Value = "99"');
    StrList.Add('            elif len(res)==1:');
    StrList.Add('                s = str(res)');
    StrList.Add('                var1.Value = s[1]');
    StrList.Add('            else:');
    StrList.Add('                var1.Value = "999"');

    //Execute
    PythonEngine1.ExecStrings(StrList);

    //結果を表示
    Memo1.Lines.Assign(strAnsList);

    //Userへ案内
    MessageDlg('読み取り完了!', mtInformation, [mbOk] , 0);

  finally
    //解放
    StrList.Free;
    strAnsList.Free;
  end;

end;

Pythonから返された計算結果を受け取るため、PythonDelphiVar1のOnSetDataイベントの手続きを作成する。Form上のPythonDelphiVar1をクリックして選択し、オブジェクトインスペクタのOnSetDataイベントの右側をダブルクリックして、コード入力画面で以下の内容を入力する。

procedure TForm1.PythonDelphiVar1SetData(Sender: TObject; Data: Variant);
begin
  //値がセットされたら動的配列に値を追加
  strAnsList.Add(Data);
  intCnt:=intCnt+1;
  Application.ProcessMessages;
end;
表示の「999」は空欄、「99」は複数マークであることを意味する。

上書き保存(Ctrl+S)して、実行(F9)。次の画像のように、マークシートが正しく読み取り処理されることを確認する。

複数マークを許可する場合には、判定結果を出力する部分のコードを次のように変更する。マークシートの読み取り結果をCSVファイルに出力したり、Excelに書き出したりして利用する場合には、複数回答は99、未回答は999のように処理した方が、後々の処理がラクになる(・・・と思う)。

    //判定結果を出力(複数回答は99、未回答は999で表示)
    {コメント化ここから
    StrList.Add('        for x in range(len(result)):');
    StrList.Add('            res = np.where(result[x]==True)[0]+1');
    StrList.Add('            if len(res)>1:');
    StrList.Add('                var1.Value = "99"');
    StrList.Add('            elif len(res)==1:');
    StrList.Add('                s = str(res)');
    StrList.Add('                var1.Value = s[1]');
    StrList.Add('            else:');
    StrList.Add('                var1.Value = "999"');
    ここまで}

    //判定結果を出力(複数回答の詳細を表示)
    StrList.Add('        for x in range(len(result)):');
    StrList.Add('            res = np.where(result[x]==True)[0]+1');
    StrList.Add('            if len(res)>1:');
    StrList.Add('                var1.Value = str(res)+ '+'"!複数回答!"');
    StrList.Add('            elif len(res)==1:');
    StrList.Add('                s = str(res)');
    StrList.Add('                var1.Value = s[1]');
    StrList.Add('            else:');
    StrList.Add('                var1.Value = " *未回答*"');

PythonEngineが正しく初期化され、Embeddable Pythonが利用できることが確認できたら、このメッセージは必要ないのでコメント化しておく。

procedure TForm1.FormCreate(Sender: TObject);
var
  //Python39-32へのPath
  AppDataDir:string;
begin
  ・・・
  if DirectoryExists(AppDataDir) then
  begin
    //フォルダが存在したときの処理(コメント化)
    //MessageDlg('Embeddable Pythonが利用可能です。',
    //  mtInformation, [mbOk] , 0);
    PythonEngine1.AutoLoad:=True;

5.さらに進化

さまざまな機能を追加したマークシートリーダー
(ファイルの名称を連番で変更/画像の回転/グリッド指示位置と画像の連動/グリッド指示位置を画像上で矩形選択/閾値等各種パラメータの調整と保存機能/音声読み上げ関連機能の搭載/回答チェック機能(空欄&複数回答対応)/CSV形式でのデータ出力/ExcelBookへのデータ出力/様式の異なるマークシートをテンプレートとして登録して利用可能/抱き合わせ採点の実施機能/共通テスト(数学の様式)に対応等、考えつく限りの機能を搭載/さらに進化します!)

このプログラムでは、「マークシート画像の表示」と、「読み取り処理」の間に何も関連がないが、このプログラムをさらに発展させて、複数枚数の処理を可能にし、読み取り結果を画面上で確認するような機能を追加する際には、マークシート画像の表示はどうしても必要な機能になる。

さらに、画面の左側などに読み込んだマークシートがリスト形式で表示されるようにして、ここから任意のマークシート画像を選んで表示できるような機能も追加するとよいと思う。

読み取り結果も、ここではMemoに表示しているが、CSVやExcelへ出力して利用することを考えると、ここはGridコントロールに変更したい。

Gridコントロール上で選択したデータの該当回答欄に相当する画像が自動的に画面上に表示され、かつ、表示されたマークシート画像上の該当回答欄が矩形で選択され、ユーザーがチェックしやすいGUIにするとなお良いだろう。

また、チェック時にはユーザーがマークシート画像を見ながら確認作業が行えるよう、Gridコントロールの数字をアナウンスしてくれる音声読み上げ機能があると大変便利だ。それから、回答の必要がない、全マークシートが空欄となっている部分は、予め指定することで、チェックから除外できる機能も欲しい。

さらに、スキャナーから読み込んだ画像データを回転させたり、連番で扱いやすい名前に変更したり、様式の異なるマークシートをテンプレートとして登録できるような機能も搭載したい。

より一層ユーザーに優しい、夢に見たようなマークシートリーダーを開発したい。この希望の実現に向けて、日々努力する私でありたい。

Web上に貴重な資料を公開してくださった多くの皆さまに心より深く御礼申し上げます。ほんとうにありがとうございました。

6.著作権表示の記載方法

参考:Python4DelphiのLicenseについて

GitHubのPython4Delphiのダウンロードページには「The project is licensed under the MIT License.」とある。これは「改変・再配布・商用利用・有料販売すべてが自由かつ無料」であること、及び使用するにあたっての必須条件はPython4Delphiの「著作権を表示すること」と「MITライセンスの全文」or 「 MITライセンス全文へのLink」をソフトウェアに記載する、もしくは、別ファイルとして同梱しなさい・・・ということを意味する。

したがってPython4Delphiを利用したプログラムの配布にあたっては、ソフトウェアの中で、次のような著作権表示を行うか、もしくは P4DフォルダのルートにあるLicenseフォルダをプログラムに同梱して配布すればよいことになる。

Python4Delphiを利用した場合の著作権表示の記載例:

Copyright (c) 2018 Dietmar Budelsky, Morgan Martinet, Kiriakos Vlahos
Released under the MIT license
https://opensource.org/licenses/mit-license.php

7.お願いとお断り

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

【関連記事】

Installing The Splitter & Resizing Height of the VCL Components

「~ 主として「高さ」の変更に関する覚書 ~」

0.準備
1.最も簡単なリサイズ対応(高さの変更)・・・ AlignプロパティとSplitterの利用法
2.さまざまなVCLコンポーネントを追加する
3.画面サイズの変更に追随(主として高さ)
4.まとめ
5.ご案内
6.お願いとお断り

DelphiのVCLコンポーネントTSplitterの使い方と画面のリサイズ対応の覚書Part2。
ここでは、主として「高さ」に関する設定を取り上げる。

0.準備

Delphiを起動して、新規プロジェクトを作成後、任意のフォルダに「プロジェクトに名前を付けて保存」する。※ 同じフォルダにプロジェクトとは別名で、Unitファイルも保存する(Unitが1つしかないプログラムでも、プロジェクトとは別名でUnitファイルを保存する必要がある)。ここではデフォルト設定の名称をそのまま利用する。

・プロジェクトファイル名:Project1.dproj
・ユニットファイル名:Unit1.pas

1.最も簡単なリサイズ対応(高さの変更)・・・ AlignプロパティとSplitterの利用法

FormにPanelを3つドラッグ&ドロップする。もし、 ドラッグ&ドロップ ではなく、PanelをダブルクリックしてFormに置く場合は、操作手順に要注意。パレットのPanelを連続してダブルクリックすると、Formではなく、Panel1の上に次々と新しいPanelが乗っかってしまう。Panel1が出現したら、いったん(Panel2の親となる)Formをクリックして選択してから、パレットのPanelを再度ダブルクリックする。

PanelはStandardに入っている
Formへドラッグ&ドロップする

画面は、次のようになる。

Panel1をクリックして選択したら、次の図のように操作してPanel1のAlignプロパティをalTopに設定する。

画面は次のようになる。

ここでFormをクリックして選択し、Form(親)がアクティブな状態で、このForm(親)に対して、Splitterコンポーネントを(子として)設置する。この「何が親で、何が子なのか」をまず明確にして、かつ、「それぞれの子の状態は、親に対してどうなのか=どんなGUIにするのか」を考えながら作業すると混乱を防げる。

構造をみれば親子関係がわかる

下の図はSplitterを置いたところ。Alignプロパティのデフォルト設定が「alLeft」なのでSplitterはFormの左端に貼りついている。ここで、Splitterを選択したまま、SplitterのAlignプロパティを 「alTop」 に設定すると、Panel1の下に貼り付くように、Splitterの位置が変化して、それと同時に、SplitterのCursorプロパティが上下分割カーソルを意味する「crVSplit」に自動的に変更される。このようにして「親」に対する、「子」の状態を適切に決めて行く。

この状態で SplitterのAlignプロパティを 「alTop」 に設定する
SplitterのAlignプロパティをalTopに設定すると、Cursorプロパティも連動して「crVSplit」に変化する

さらに、実行時のSplitterの動作をわかりやすくするため、SplitterのAutoSnapプロパティをFalseに設定し、MinSizeを「30(デフォルト設定値)」にする。実際の操作としては、SplitterのAutoSnapプロパティをFalseに設定し、下方へスクロールすれば、MinSizeは30になっている(はず)。

AutoSnapプロパティをFalseに設定

次に、Panel3をクリックして選択し、 次の図のように操作してPanel3のAlignプロパティをalBottomに設定する。

画面は次のようになる。

次に、Panel2をクリックして選択し、 次の図のように操作してPanel2のAlignプロパティをalClientに設定する。

Panel2のAlignプロパティをalClientに設定

次にPanel1をクリックして選択し、下のハンドルをドラッグして(Panel1の)高さを少し大きくして下の図のようにする。この状態で上書き保存(Ctrl+S)して実行(F9)し、Splitterが意図した通りに動作することを確かめる。

実行(F9)して、Splitterの動作を確認する

2.さまざまなVCLコンポーネントを追加する

ここで、Panel1~3のCaptionプロパティを「空欄」にして、Panelの名前が表示されないように設定する。さらに、Panel1をクリックして選択し(親にして)、Panel1の上にScrollBoxを載せ、ScrollBoxのAlignプロパティをalClientに設定する。さらに、その上にImageを1つ載せる。ImageのAlignプロパティは「None」のままでよい。

次に、Panel2をクリックして選択し、Memoを1つ載せ、MemoのAlignプロパティをal Clientに設定する。

VCLコンポーネントの配置について慣れないうちは、かなり混乱するが、何が親で、どれが子になって、どういう状況で仕事をさせたいか(このVCLは常に画面の下方に固定で・・・とか、親の残りのスペース全部=alClientで・・・など)を、「よーく考えながら」作業すると、必要なコンポーネントだけでなく、それを設置する順番も見えてくる。

必要な各VCLコンポーネントがパレットのどこにあるのか? もし、場所を忘れてしまっていても、 コンポーネントの名前で検索すれば、検索窓に3~4文字入れた時点で、ほぼ見つかるので、設置したいVCLコンポーネント の機能と名前さえ思い出せれば、そのパレット内の配置に関しては、まったく覚えていなくても、何とかなる。

むしろ、このようなシーンで重要なのは、「実現したい処理にはどんなVCLコンポーネントが最適なのか?」そして「どのコンポーネントを、どう配置すれば、ユーザーに最も使いやすいGUI環境を提供できるのか?」の2点だと思う。

GUI作成に関しては、プログラマ個々のデザインのセンスの良し悪しも当然あると思うが、これに加えて、そのプログラマが「どれだけ修羅場を経験したか・・・」というような、個々のバックグラウンドにある経験も、もしかしたら重要な要素のひとつかもしれない・・・。

VCLコンポーネントの検索例

各VCLコンポーネントの親子関係を「構造」で確認。

VCLコンポーネントの親子関係がよくわかる

いちばん下の階層にあるPanelは、画面では他のコントロールに隠されて見えない。

画面を見ただけでは、各コンポーネントの階層構造はわからない

上書き保存(Ctrl+S)して実行(F9)し、Splitterの動作やFormを最大化した際の各コントロールの見え方等を確認する。

3.画面サイズの変更に追随(主として高さ)

さらに、Formの大きさが変わっても、その時点でのPanel1とFormの高さの比率が維持されるようにプログラミングしてみた。 まず、Private宣言部で整数型の変数2つと、Formが完全に表示された時点で実行される表示終了イベントを取得する手続き procedure CMShowingChanged を宣言。

Formの 表示終了イベントを取得するprocedureの実現部は、以下に記載したコードをミスのないように入力し(文法的に誤りのない状態で)、procedure CMSShowingChanged~行のどこか(付近でも可)にフォーカスがある(=カーソルがある)状態で、Shift+Ctrl+C操作を行うと、手続きが自動的に生成される。

Shift+Ctrl+C:キーボード左側のShiftキーとCtrlキーを左手で同時に押して、さらに右手でCキーを押す

また、宣言の順番も大切。プライベートメンバー変数と手続き(procedure)の宣言の順番が逆になってはいけない。 プライベートメンバー変数の宣言を、手続きの宣言より必ず先に行う必要がある。

  private
    { Private 宣言 }
    //Panel1の幅とFormの高さを記憶する変数
    intPH, intFH:integer;
    //Formの表示終了イベントを取得
    procedure CMShowingChanged(var Msg:TMessage); message CM_SHOWINGCHANGED;
  public
    { Public 宣言 }
  end;

Formの表示終了イベントを取得して、その時点でのPanel1とFormの高さを記憶する。

フォームの表示完了時に処理する(くろねこ研究所さん)

URL:https://www.blackcat.xyz/article.php/ProgramingFAQ_del0049より引用
procedure TForm1.CMShowingChanged(var Msg: TMessage);
begin
  inherited; {通常の CMShowingChagenedをまず実行}
  if Visible then
  begin
    Update; {完全に描画}
    //Formの表示終了時に以下を実行
    Panel1.Height:=intPH;
    intPH:=Panel1.Height;
    intFH:=Form1.Height;
  end;
end;

Formが生成される際に、Panel1とFormの高さをプログラムから指示して決定。

procedure TForm1.FormCreate(Sender: TObject);
begin
  //Panel1とFormの高さを記憶する変数を初期化
  intPH:=200;
  intFH:=480;
end;

Formの大きさの変更イベントに合わせて、Panel1の高さを計算して決定。

procedure TForm1.FormResize(Sender: TObject);
begin
  //比率を維持してPanel1の高さを変更
  Panel1.Height:=Trunc(Form1.Height * intPH/intFH);
end;

ここがいちばん重要か? Splitterが動かされたら(=Movedイベントが発生)、それが動かされた時点(=動かされる直前)でのFormとPanel1の高さを取得。この値をもとにしてFormとPanel1の高さの比率を計算し、さらに、この比率をもとにFormのResize時に Panel1 の高さを計算、その計算結果の小数点以下を切り捨てた整数値を Panel1.Heightプロパティに設定している。※ Heightは整数値で、小数点以下の値にこだわる必要はまったくないから。

procedure TForm1.Splitter1Moved(Sender: TObject);
begin
  //Panel1とFormの高さを取得
  intPH:=Panel1.Height;
  intFH:=Form1.Height;
end;

上書き保存(Ctrl+S)し、実行(F9)して、Panel1の高さを変更し、Formの大きさを最大化して、Formと Panel1の高さの比率が維持されることを確認する。

プログラム起動時の画面
最大化した状態(縦の比率が維持されていることを確認)

4.まとめ

Formに置いたVCLコンポーネントの高さを実行時に調整できるようにするには、Splitterを利用する。手順は以下の通り。

(1)Panelを3つ、Formに設置。上位のPanel1のAlignプロパティを alTop に設定。
(2)Form(親)をクリックして選択後、Splitter(子)を設置。
(3)Panel3のAlignプロパティを alBottomに設定(=Panel3は固定する)。
(4)Panel2の AlignプロパティをalClientに設定。

5.ご案内

今回作成したプログラムを利用して、次回、マークシートリーダー作成の練習プログラムを紹介します。プログラムのGUIはDelphiで今回作成したものをそのまま使い、マークシート読み取りと計算処理は、このBlogでこれまでに紹介してきた PythonForDelphi と Embeddable Python を用いて行います。練習用なので、マークシートの読み取り枚数は1枚で、読み取り結果の表示にはMemoを利用します。

実用化するには、複数シートを読み取れるよう、さらにLoop処理を加えたり、読み取り結果のCSVファイル等への出力も考慮して、結果表示用にMemoではなく、Gridコントロールを用いる等、さらなる工夫が必要ですが、最も重要な「マークシートを読み取る」というプログラムの核心部分を丁寧に紹介します。興味のある方はぜひ、ご覧ください。

6.お願いとお断り

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

【関連記事】

Installing the Splitter & Resizing Width of the VCL Components

「~ 主として「幅」の変更に関する覚書 ~」

0.準備
1.最も簡単なリサイズ対応(幅の変更)・・・ AlignプロパティとSplitterの利用法
2.最も簡単なリサイズ対応(幅の変更)・・・ Anchorsプロパティの利用法
3.画面サイズの変更に追随(主として幅)
4.まとめ
5.お願いとお断り

DelphiのVCLコンポーネントTSplitterの使い方と画面のリサイズ対応の覚書。

Formの大きさは決め打ちで、決してResizeしない前提でプログラミングできれば、ある意味、それがいちばんラクなんだけど・・・。百歩譲って、Formの大きさはResizeしなくても、Formの中にあるVclコンポーネントの幅・高さは変更できた方がうれしい場合は多い。

例えば、 マークシート画像を読み取って処理する場合、マークシート画像と読み取り結果を比較(チェック)するには、画像と読み取り結果の両方が1つの画面上に表示されていることが望ましい。このような場合に備えて、必要に応じて「画像」と「データ」の表示領域を思いのまま手動で広げたり、狭めたり出来る機能をユーザーに提供したいと考えるのは、全プログラマに共通の思いだろう・・・。

そのような私自身の経験を基に、何に使うかはアイデア次第として、画面の左右や上下にさまざまなVCLコンポーネントが置かれている時、 Splitterを使ってその幅や高さを自由に変える方法をここでは取り上げた。

さらに、Formの大きさの変化に合わせ、Form上に置いた各コンポーネントの大きさ(主として幅)が追随して変化するようなプログラミングにも(自我流で恥ずかしい限りだが)チャレンジしてみた。

主として「幅」の変更に関する覚書

0.準備
1.最も簡単なリサイズ対応(幅の変更)・・・ AlignプロパティとSplitterの利用法
2.最も簡単なリサイズ対応(幅の変更)・・・ Anchorsプロパティの利用法
3.画面サイズの変更に追随(主として幅)
4.まとめ
5.お願いとお断り

0.準備

Delphiを起動して、新規プロジェクトを作成後、任意のフォルダに「プロジェクトに名前を付けて保存」する。※ 同じフォルダにプロジェクトとは別名で、Unitファイルも保存する(Unitが1つしかないプログラムでも、プロジェクトとは別名でUnitファイルを保存する必要がある)。ここではデフォルト設定の名称をそのまま利用する。

・プロジェクトファイル名:Project1.dproj
・ユニットファイル名:Unit1.pas

1.最も簡単なリサイズ対応(幅の変更)・・・ AlignプロパティとSplitterの利用法

最も簡単なリサイズ対応は、Formに置いたPanelなどのAlignプロパティをalNoneからalClientに変更することだ。これでFormの大きさの変更に合わせて(幅・高さ共に)、Form上のPanelの大きさも、親Formの大きさの変更に追随して変わるようになる。
もし、Panel上にButtonを1つだけ置いて使用するのであれば、Buttonの Anchorsプロパティを適切に指定するだけで、画面のリサイズに対応したGUIが完成する。

何に使うかはアイデア次第として、ここではサンプルとして画面の左にMemo、右にPanel(いろいろなコンポーネントを置くベース)がある条件で、Memoの幅を自由に変える方法を取り上げる。さらに、Formの大きさが変わっても、その時点でのMemoとPanelの幅の比率が維持されるような(自我流の)プログラミング例も掲載した。

新規作成したプロジェクトのForm上に、MemoとPanelを1つずつ置く。
MemoもPanelもパレットのStandardにあるので、まずStandardを開く。

>をクリックして開く
MemoとPanelをそれぞれFormへドラッグ&ドロップ
(それぞれをダブルクリックしてもよい)
Form上にMemoとPanelがのる

Memo1をクリックして選択し、下図のように操作してMemoのAlignプロパティをalLeftに設定する。

画面は次のようになる。

重要 ここでFormをクリックして選択する(Formをアクティブにする)。

ハンドル(水色の枠)がMemoからFormに移動し、Formの方がアクティブになる。

Formをアクティブにした状態で、パレットでsplitterを検索する。splitterはAdditionalにあるコントロールで、これを2つのコントロールの間に追加すると、ユーザーが実行時にそのコントロールのサイズを変更できるようになる。

「split」未満の入力で見つかるはず

見つけたらTSplitterをダブルクリックしてFormに配置する。

Memoの右側にSplitterが配置される。
デフォルトでは「幅」の変更用になっている

Cursorプロパティに設定された「crHSplit」は「左右分割カーソル」を意味する。ちなみに、「crVSplit」を指定すると「上下分割カーソル」になる。

最後に、PanelコンポーネントのAlignプロパティを変更する。Panelをクリックして選択し、オブジェクトインスペクタのAlignプロパティを alClient に設定する。

これでMemoの幅の大きさを自由に変更できるはず

Ctrl+Sで上書き保存、F9を押して実行。意図した通りに操作できるか、確認する。

設計時にMemoの幅を適切に指定することで、実行時の初期画面を意図した通りに作成できる。

参考:SplitterのAutoSnapプロパティをFalseすると、幅を小さくしたとき、 Splitterの MinSizeプロパティで設定した値以下に変更されなくなる。これを用いると、MemoやPanelが完全に隠されてしまう事態を予め防止できる。

SplitterのAutoSnapをFalse、MinSizeを30に設定
MemoもPanelも幅がMinSize以下にならない

2.最も簡単なリサイズ対応(幅の変更)・・・ Anchorsプロパティの利用法

では、Panelの上にButtonをのせたら、画面サイズの変更に合わせてButtonの位置はどのように変わるのだろうか?

これを検証してみる。Panel1をクリックして選択し、その上にButtonを1つ設置する。

Panel1が選択されている状態でダブルクリック

設置したButtonの位置を変更する(下図のようにFormの右下隅の方へドラッグして移動)。Buttonのプロパティはデフォルト設定のままにしておく。

この状態で上書き保存(Ctrl+S)し、実行(F9)して、Formの大きさを最大化してみる。

右上の × をクリックして画面を閉じる

Button1の位置を常識的な位置へ自動的に変化させる最も簡単な方法は、「Anchorsプロパティ」の利用である。Button1をクリックして選択し、オブジェクトインスペクタのAnchorsプロパティの設定を次のように変更する。

上書き保存(Ctrl+S)し、実行(F9)して、Formの大きさを最大化して確認する。

右上の × をクリックして画面を閉じる

ちなみに下図のように設定すると・・・

AnchorsプロパティをすべてTrueに設定
もしかしたら、場合によってはアリかも・・・。 右上の × をクリックして画面を閉じる

ちなみに、コレだと・・・

akTopのみFalseに設定
「年越しそば」的な挙動を見せました。ほそーく、ながーく なりました。ある意味これがベスト?

3.画面サイズの変更に追随(主として幅)

さらに、Formの大きさが変わっても、その時点でのMemoとPanelの幅の比率が維持されるようにプログラミングしてみた。 まず、Private宣言部で整数型の変数2つと、Formが完全に表示された時点で実行される表示終了イベントを取得する手続き procedure CMShowingChangedを宣言。

Formの 表示終了イベントを取得するprocedureの実現部は、以下に記載したコードをミスのないように入力し(文法的に誤りのない状態で)、procedure CMSShowingChanged ~行のどこか(付近でも可)にフォーカスがある(=カーソルがある)状態で、Shift+Ctrl+C 操作を行うと、手続きが自動的に生成される。

Shift+Ctrl+C:キーボード左側のShiftキーとCtrlキーを左手で同時に押して、さらに右手でCキーを押す

また、宣言の順番も大切。プライベートメンバー変数と手続き(procedure)の宣言の順番が逆になってはいけない。 プライベートメンバー変数の宣言を、手続きの宣言より必ず先に行う必要がある。

  private
    { Private 宣言 }
    //Memoの幅とFormの幅を記憶する変数
    intMW, intFW:integer;
    //Formの表示終了イベントを取得
    procedure CMShowingChanged(var Msg:TMessage); message CM_SHOWINGCHANGED;
  public
    { Public 宣言 }
  end;

Formの表示終了イベントを取得して、その時点でのMemoとFormの幅を記憶する。

フォームの表示完了時に処理する(くろねこ研究所さん)

URL:https://www.blackcat.xyz/article.php/ProgramingFAQ_del0049より引用
procedure TForm1.CMShowingChanged(var Msg: TMessage);
begin
  inherited; {通常の CMShowingChagenedをまず実行}
  if Visible then
  begin
    Update; {完全に描画}
    //Formの表示終了時に以下を実行
    Memo1.Width:=intMW;
    intMW:=Memo1.Width;
    intFW:=Form1.Width;
  end;
end;

Formが生成される際に、MemoとFormの大きさをプログラムから指示して決定。

procedure TForm1.FormCreate(Sender: TObject);
begin
  //MemoとFormの幅を記憶する変数を初期化
  intMW:=480;
  intFW:=640;
end;

Formの大きさの変更イベントに合わせて、Memoの幅を計算して決定。

procedure TForm1.FormResize(Sender: TObject);
begin
  //比率を維持してMemoの幅を変更
  Memo1.Width:=Trunc(Form1.Width*intMW/intFW);
end;

ここがいちばん重要か? Splitterが動かされたら(=Movedイベントが発生)、それが動かされた時点(=動かされる直前)でのFormとMemoの幅を取得。この値をもとにしてFormとMemoの幅の比率を計算し、さらに、この比率をもとにFormのResize時にMemoの幅を計算、その計算結果の小数点以下を切り捨てた整数値をMemo1.Widthプロパティに設定している。※ Widthは整数値で、小数点以下の値にこだわる必要はまったくないから。

procedure TForm1.Splitter1Moved(Sender: TObject);
begin
  //MemoとFormの幅を取得
  intMW:=Memo1.Width;
  intFW:=Form1.Width;
end;

上書き保存(Ctrl+S)し、実行(F9)して、Memoの幅を変更し、Formの大きさを最大化して、FormとMemoの幅の比率が維持されることを確認する。

Buttonコントロールの幅の違いに注目(上と下の画像は、同じ画像ではありません)

Formの幅は640ピクセル、Memoの幅は480ピクセルで表示
画面を最大化してみた。Formの幅とMemoの幅の比率は保たれているように見える。

数値的にはどうかと思い、FResize前のFormとMemoの幅を表示してみた。

procedure TForm1.FormResize(Sender: TObject);
begin
  //比率を維持してMemoの幅を変更
  Memo1.Width:=Trunc(Form1.Width*intMW/intFW);
  ShowMessage('Memoの幅 / Formの幅:'+IntToStr(intMW)+
    ' / '+IntToStr(intFW));
end;

画面を最大化してから、元の大きさに戻した時のようす。

Formの幅は1382ピクセルと表示されている

参考:私のPCは、画面の解像度を1366×768に設定している(Formが表示される際、Screen.Widthを調査すると1366と表示された)。そこで、FormのWidthを1366に設定すると、ClientWidthはそれより小さくなり、設計時に画面の横幅いっぱいに配置したVCLコンポーネントの右側に実行時余白が生まれる。
FormのClientWidthを1366に設定すると、Widthは1382となり、画面の解像度より大きくなるが、VCLコンポーネントの位置は設計時も実行時も同じになった。
この経験から、画面の解像度はClientWidth×ClientHeightを意味するものと、私は理解しているのだが、これでいいのだろうか?

4.まとめ

Formに置いたVCLコンポーネント(例:Memo)の幅を実行時に調整できるようにするには、Splitterを利用する。手順は以下の通り。

(1)MemoコンポーネントのAlignプロパティを alLeft に設定。
(2)Formを選択後、Splitterを設置。
(3)Panelコンポーネントを置いて、Alignプロパティを alClient に設定。

Panelの上に乗せたButtonなどのコンポーネントは、 Anchorsプロパティを適切に設定することでFormのリサイズに合わせて、その表示位置を変更できる。

5.お願いとお断り

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

【関連記事】

How to use Python4Delphi

「PythonForDelphiの使い方(Delphiのプログラム内でPythonを動かす)」

1.Delphiで埋め込みPythonを使う
2.準備
3.ノートPCの電池残量を表示するプログラムを作成
4.PythonEngineのメモリリーク
5.Delphi11のIDEが真っ白になってしまう問題への対応方法
6.著作権表示の記載方法
7.お願いとお断り

こちらで紹介した方法の応用版として、自作のマークシートリーダーの読み取り速度をPython4Delphiで高速化。プログラムのダウンロード(無料)も可能です。もし、よかったら次のリンク先記事もご参照ください。

1.Delphiで埋め込みPythonを使う

ノートPCの電池残量を表示する練習プログラムを、埋め込みPythonを使ってDelphiで書いてみる。
埋め込み用途のembeddable pythonをDelphiで使うには? というテーマで悩んでいらっしゃる方の参考になれば、望外の喜びです。なお、以下の内容はDelphiで開発経験のある方を対象としています。IDEの基本的な操作方法等は省略していますので、予めご了承ください。

2.準備

(1)DelphiにPython4Delphi(P4D)のパッケージを予めインストールしておく。

(2)埋め込み用のEmbeddable Pythonをダウンロードし、各種ライブラリをインストール(下記リンク先ではNumpyとOpenCVライブラリをインストール)。

(3)Embeddable PythonにノートPCの電池残量を表示するため、psutilライブラリをインストール( Embeddable Python のダウンロードと設定方法は上の(2)を参照してください)。

「python -m pip install psutil」と入力してEnterキーを押す

(4)Delphiを起動して「ファイル」→「新規作成」→「Windows VCL アプリケーション」の順にクリックして新しいプロジェクトを準備する。

VCLアプリケーションの新規作成(Delphi11の場合)

3.ノートPCの電池残量を表示するプログラムを作成

(1)プロジェクトに名前を付けて保存する
(2)GUIを作成
(3)コンパイル & Python環境をコピー
(4)Python関連のVCLコンポーネントを配置
(5)Python関連のVCLコンポーネントのプロパティを設定
(6)エラー対応(ライブラリパスの確認)
(7)閉じるボタンのコードを書く
(8)FormのCreateでPython39-32の有無を確認する
(9)Messageダイアログを使う
(10)埋め込みPythonと接続する
(11)OnSetDataイベントを利用する
(12)プログラムの完成と動作確認

(1)プロジェクトに名前を付けて保存する

新しいフォルダを作成(名称は任意:ここではBTRC_byP4Dとしている)し、Unit1.pasを保存(Unit1を別名にしてもよいが、名称をメモしておく)。

参考 BTR:Battery(電池) / C:Charging(充電)/ P4D:PythonForDelphi

つづけて、プロジェクトファイル(Project1.dproj)を同じフォルダに保存。
Project1は別名にしてもよいが、上のpasファイルと同じ名称にしないこと。
また、別名にした場合は、名称を忘れないようにメモしておく。

(2)GUIを作成

画面にVCLコンポーネントを配置してGUIを作る。
Memoを2つ(Memo1とMemo2)、
Buttonを2つ(Button1とButton2)が最低限必要。

パレットのTMemoとTButtonをそれぞれ2つずつ、FormへD&Dする。

DelphiのIDEの基本的な操作方法や、VCLコンポーネントの配置方法は、次のリンク先の解説がわかりやすい。

はじめてのDelphiアプリケーション (VCL Form編) (Delphi プログラミング)

URL:https://www.ipentec.com/document/delphi-first-application-vcl-form-application



※ Formの大きさの変更にMemoの大きさやButtonの表示位置を追随させる方法は、別途解説する予定。

各VCLコンポーネントの名称はデフォルト設定のまま

Button1のCaptionプロパティを「実行」に変更。
Button2のCaptionプロパティを「終了」に変更。

Button1のCaptionプロパティを「実行」に変更。 Button2も同様にして「終了」に変更する。
ボタンのCaptionプロパティを変更

(3)コンパイル & Python環境をコピー

ビルド構成(Debug)のまま、ここで1回コンパイルしてexeを生成。

Ctrl+F9(Ctrlキーを押しながらF9キーを押す)でもOK!
コンパイル成功を確認→OKをクリック

※ ツールバーの実行(F9)をクリックして実行した場合は、生成されたexeが実行されてFormが表示されるので、表示されたFormを右上の閉じるボタンをクリックして閉じる。

ツールバーの実行(F9)から実行する場合
右上の「閉じる」ボタンでFormを閉じる

コンパイルに成功すると、BTRC_byP4Dフォルダの中にWin32フォルダが、さらにその下にDebugフォルダがそれぞれ自動的に作成される。このDebugフォルダを開き、別途作成しておいたEnbeddable Pythonの入ったフォルダをコピーして、貼り付ける(下の例では Enbeddable Pythonの入ったフォルダ名をpython39-32としている)。

Enbeddable Pythonの入ったフォルダを
ここへ貼り付ける。
フォルダとファイルの構造はこうなる。

Embeddable Pythonのダウンロードと各種ライブラリのインストール方法は以下のリンク先を参照してください。

(4)Python関連のVCLコンポーネントを配置

DelphiにPythonのスクリプトを埋め込んで実行するには、PythonForDelphiが必要。
PythonForDelphi(またはPython4Delphi さらに略すと P4D)をDelphiにセットアップする方法は以下のリンク先で解説。

(Python4Delphiのパッケージがインストールされた)Delphiのパレットのいちばん下にPython4Delphiの非ビジュアルコンポーネントがあるので、この中から次の3つのコンポーネント

「PythonEngine、PythonGUIInputOutput、PythonDelphiVar」

をForm上にドラッグ&ドロップ(各非ビジュアルコンポーネントをダブルクリックしてもよい)。

※ 非ビジュアルとは、「実行時に見えなくなる」コンポーネントを意味する。

Python4Delphiの非ビジュアルコンポーネント
非ビジュアルコンポーネントなので画面の任意の位置へD&DすればOK!
非ビジュアルコンポーネントは表示しない設定にすることも出来る(忘れっぽい私は常に表示している)。

(5) Python関連のVCLコンポーネントのプロパティを設定

・PythonEngine1のAutoLoadプロパティをFalseに設定

Form上にパレットからPythonEngineコンポーネントをドラッグ&ドロップすると、名称は自動的に PythonEngine1になる。上の図のようにこれをクリックして選択すると、オブジェクトインスペクタにPythonEngine1のプロパティが表示されるので、その中のAutoLoadプロパティをFalseに変更する(デフォルトTrueに設定されているので、チェックボックスのチェックを外す)。

AutoLoadプロパティをFalseに変更

練習ではなく、本格的にプログラミングする際、私はビジュアルコンポーネントについては、その名称を必ず変更するようにしている。理由はButtonコントールなどは使用数が多く、わかりやすい名前を付けておいた方がプログラミングしやすいからだ。

 例:OKボタンなら、そのNameプロパティを button1→btnOK へ変更

しかし、非ビジュアルコンポーネントの場合は、同じコンポーネントを複数配置することは稀なので、Delphiが自動的に割り振った名前をそのまま利用している。ここでもその例にならって、非ビジュアルコンポーネントの名称は Delphiが自動的に割り振った名前をそのまま利用することにする。

・PythonEngine1のDllNameプロパティは、python39.dllを予め指定(組み込み用のPythonのバージョンに合わせて設定する)。

最新版のPython4Delphiでは「python310.dll」がデフォルト値になっていた。

python39.dllは、上でDebugフォルダ内に張り付けたPython39-32フォルダ内にある。

・PythonEngine1のIOプロパティにはPythonGUIInputOutput1を指定する。

IOプロパティのデフォルト設定は「空欄」になっていた。

・PythonGUIInputOutput1のOutPutプロパティに「Memo2」のように出力先を指定したくなるが、ここでは敢えて何も設定しない。

・PythonDelphiVar1のVarNameプロパティは、プログラムコードの記述に合わせるため「var1」とする。※var1と入力後、Enterで確定すること!(青く反転表示されるのを確認する)

「var1」と入力後、Enterキーを押さないと変更が反映されない。

・この状態で実行(F9)した際に「Python Engineが見つかりません」というようなエラーメッセージが表示される場合は、P4Dのパッケージをインストールした際のライブラリパス設定に誤りがないか、確認する

画面下のメッセージ欄の表示:[dcc32 致命的エラー] Unit1.pas(7): F2613 ユニット ‘PythonEngine’ が見つかりません。
コンパイルエラー発生時のUnit1画面

(6)エラー対応(ライブラリパスの確認)

GitHubから入手したPython4DelphiのフォルダのSourceフォルダ以下にある、このプログラムの動作に必要なファイルへのライブラリパスが正しく設定されていることを確認する。設定されていない場合は、(灰色で表示されている誤ったパスを削除して)ライブラリパスを再設定する。

「ツール」→「オプション」の順にクリックして、次の画面を表示する。

「言語」→「Delphi」→「ライブラリ」とクリックして、赤枠囲みの中をクリック。

ライブラリパスを正しく設定する。

PCを新しくした場合等、再設定する必要があるかもしれないので、
設定内容をメモしておく。

ライブラリパスの設定が完了したら、再度コンパイル(実行:F9)してエラーが発生しないことを確認する。

(右上の閉じるボタンで終了)

参考:コンパイルとビルドの違い

・メニューの「プロジェクト」 →「Project1をコンパイル」
 (ショートカットは「Ctrl+F9」)

前回のビルド以降に変更されたファイルと、それに依存するファイルのみをコンパイルして EXE を生成するが、アプリケーションは起動しない。

・メニューの「プロジェクト」 →「Project1をビルド」
 (ショートカットは「Shift+F9」)

変更の有無に関わらず、全てのユニットを再コンパイルして EXE を生成するが、アプリケーションは起動しない。ユニット数が多ければ当然それだけ遅くなる。

・実行(ショートカットはF9)

変更されたソースコードをすべてコンパイルする。コンパイルが成功した場合は、アプリケーションを実行するので、そのアプリケーションを IDE でテストできるようになる。

・デバッガを使わずに実行。(ショートカットは「Shift+Ctrl+F9」)

変更があったユニットだけをコンパイルしてexeを生成し、 アプリケーションを起動する(exe単体での起動と同じ)。

(7)閉じるボタンのコードを書く

Formの「終了」ボタンをダブルクリックすると画面は次のようになる。ここに終了ボタン(Button2)がクリックされた時のProcedure(手続き)を記述する。

procedure TForm1.Button2Click(Sender: TObject);
begin

end;

beginとend;の間に次のように記入する。

procedure TForm1.Button2Click(Sender: TObject);
begin
  //プログラムの終了
  Close;
end;

//は1行をコメント化(コンパイラはコメント部分を無視する)

Closeは、Formを閉じる命令(正確にはメソッドだから方法?)。アプリケーションのメインフォームを閉じると、そのアプリケーションは終了する。
(ここはApplication.TerminateでもOKだが、 Windowsでは、Application.Terminate でアプリケーションを強制終了させた場合には、OnCloseQueryイベントが実行されない仕様になっているとのこと)。← これは不具合ではなく、Windowsの仕様。

もし、アプリケーション終了時(Windowsの終了やログアウト時も含む)に、何らかの終了処理(中止を含む)を行いたい場合は、OnCloseQueryイベントが実行されるCloseを使用する。(今回は行わないがForm生成時に、例えばTStringListをCreateしてプログラム内で利用するような場合には、CreateしてTry文で使用(~Finally ここで解放 End;)の一般的流れが使えないので、 OnCloseQueryイベントもしくはOnDestroyイベントで、TStringList.Freeのようにして確実に解放しなければならない。)

実行(F9)してFormが表示されたら、「終了」ボタンでアプリケーションを終了できることを確認する。

(8)FormのCreateでPython39-32の有無を確認する

FormがCreateされる時に、Embeddable Python(Python39-32 フォルダ)があることを確認し、必要な諸設定を行う。F12を押すとFormとUnitの表示を交互に切り替えることができる。画面をFormに切り替え、アクティブ(Formのどこかをシングルクリック)にし、オブジェクトインスペクタのイベントタブをクリックして、下にスクロールさせ、OnCreateイベントの右の空白部分をダブルクリックする。自動的にUnit画面に表示が切り替わり、下のようにForm.Create手続き部が生成される。

procedure TForm1.FormCreate(Sender: TObject);
begin

end;

Python39-32フォルダのパスを入れる変数を宣言する。procedureとbeginの間にvar(宣言)を入力して、改行&字下げを行い、文字列型変数AppDataDirを宣言する。必要であればコメントで変数の用途を書いておく。

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

end;

次に、beginとend;の間にForm.Create手続きで行いたい内容を記述する。

begin

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

  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;

Ctrl+Sでコードを上書き保存。保存したら実行(F9)。
ここまでの操作にミスがなければ次のメッセージが表示される。

「OK」をクリックして閉じる

続けてFormが表示されるので、終了ボタンをクリックして閉じる。
画面下のメッセージ欄に次のヒントが表示されることを確認する。

(9) Messageダイアログを使う

[dcc32 ヒント] Unit1.pas(118): H2443 インライン関数 ‘MessageDlg’ はユニット ‘System.UITypes’ が USES リストで指定されていないため展開されません

ヒントの言う通り、 ‘System.UITypes’ を USES リストで指定する。以下のように、30行目付近の implementation (実装・実現部)宣言と、その下の コンパイラ指令 {$R *.dfm}の間が空白行になっているので、ここに「uses」と「 System.UITypes ;」を記述。なお、System.UITypes の後ろには行末を意味するセミコロン;を半角で入力する。

implementation

{$R *.dfm}

implementation の下に「uses」と入力してEnter & 字下げ(TABキー)、
で、次の行に「System.UITypes;」を記述。

implementation

uses
  System.UITypes;  // <-入力する

{$R *.dfm}

{$R *.dfm} はコメントではなく、dfmファイルを見つけて 実行ファイルにリンクさせるコンパイラ指令(命令)。「不要なコメントである」と勘違いして、消してはいけない。

以上が入力した状態。上書き保存(Ctrl+S)して、実行(F9)。メッセージにヒントが表示されないことを確認。 表示されたらメッセージ欄を確認。確認後、Formを閉じる。

警告もヒントも表示されない

(10) 埋め込みPythonと接続する

次に、いよいよ埋め込みPythonと接続する。Unitが表示されている場合はF12キーを押してFormの画面に切り替え、左下の「実行」ボタンをダブルクリックする。表示は自動的に以下のように、Button1Click手続きに切り替わる。

procedure TForm1.Button1Click(Sender: TObject);
begin

end;

初めにPythonのスクリプトを入れる文字列型リストと、Pythonから送られたデータを保存する文字列型リストをローカル変数として、以下のように宣言する。

procedure TForm1.Button1Click(Sender: TObject);
var
  //PythonのScriptを入れる
  strScrList:TStringList;
  //Pythonから送られたデータを保存する
  strAnsList:TStringList;
begin

end;

最初に、Memo1を初期化し、データの入れ物をそれぞれ準備する。

begin

  //初期化
  Memo1.Clear;

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

end;

準備したStringListが処理の最後にきちんと解放されるよう、try文を用いて処理する。
tryと入力してEnterキーを押すと、次の画面のようにfinallyとend;が自動入力される。

begin

  //初期化
  Memo1.Clear;

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

  try

  finally

  end;

end;

StringListの解放処理を先に書いてしまう。これで万一、トラブルが発生しても必ずStringListは処理の最後に解放(メモリが空く)される。

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

  try

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

最後に、バッテリー残量を取得するPython Scriptを文字列型リストへ、1行ずつ書き込んで、Memo1に表示、Python側でMemo1に表示されたスクリプトを実行し、返ってきた結果を文字列型リストに読み込んで、Memo2に表示するコードを記述する。

  try
    //バッテリー残量を取得するPython Script
    strScrList.Add('import psutil');
    //バッテリー残量
    strScrList.Add('btr = psutil.sensors_battery()');
    //バッテリー残量を表示
    strScrList.Add('var1.Value = str("残量:") 
      + str(btr.percent) + str("%")');
    //Scriptを表示
    Memo1.Lines.Assign(strScrList);
    //Execute
    PythonEngine1.ExecStrings(Memo1.Lines);
    //結果を表示
    Memo2.Lines.Assign(strAnsList);
  finally
    //StringListの解放
    strAnsList.Free;
    strScrList.Free;
  end;

入力したら上書き保存(Ctrl+S)して、実行(F9)する。Formが表示されたら、Form上の「実行」ボタンをクリックする。結果は次のようになる。

Memo1には、意図した通り、StringListに入れたPythonのScritが表示されているが、
Memo2は空欄のままである。

Object Pascalのコードをよく読むとPythonEngineをExecuteしてPythonに電池残量を計算させるところまではOKだが、Pythonが計算した結果を「Delphi側が受け取れていない」ことがわかる。

    //Execute
    PythonEngine1.ExecStrings(Memo1.Lines);

    { ここでPythonからの結果通知を受け取る必要がある }

    //結果を表示
    Memo2.Lines.Assign(strAnsList);

(11) OnSetDataイベントを利用する

では、Pythonからの結果通知を受け取るにはどうしたらいいかというと、残念ながらその処理はこのprocedure内には書けない。

結論から言うと、Pythonの返した結果は、Formに配置したPythonDelphiVar1コンポーネントのOnSetDataイベントで受け取ることができる。その処理を実現するため、プログラムに必要な変更を加える。

まず、実行ボタンがクリックされた時の手続きの冒頭で、「結果を保存するStringList」として「strAnsList」というローカル変数を宣言したが、今、結果は「PythonDelphiVar1のOnSetDataイベントで受け取る」ことにした=つまり「別の手続きの中で受け取る」ことになるから、この変数をプログラムのあちこちから使える(見える)プライベートメンバー変数(クラス内部でのみ利用可能な変数) に変更することにする。以下、その処理を示す。

まず、 Button1Click手続きでローカル変数として宣言したstrAnsList変数をコメント化する。

procedure TForm1.Button1Click(Sender: TObject);
var
  //PythonのScriptを入れる
  strScrList:TStringList;
  //Pythonから送られたデータを保存する
  //strAnsList:TStringList;  //コメント化してしまう
begin

22行目付近のprivate部に、このクラス内部でのみ利用可能な プライベートメンバー変数として、strAnsList変数を再宣言する。

  private
    { Private 宣言 }
    //Pythonから送られたデータを保存する
    strAnsList:TStringList;
  public
    { Public 宣言 }
  end;

これでstrAnsList変数は、プライベートメンバー(クラス内部でのみ利用)化され、異なる手続きの中でアクセスできるようになった。

続けて、PythonDelphiVar1のOnSetDataイベントの処理を実装する。F12を押して画面をFormの方に切り替えて、PythonDelphiVar1をクリックして選択する。

選択する

画面左下のオブジェクトインスペクタにPythonDelphiVar1が表示されていることを確認して、イベントタブをクリックし、下にスクロールしてOnSetDataイベント部分の右の空白をダブルクリックする。

OnSetDataの右の空白をダブルクリック

PythonDelphiVar1SetData手続きが自動的に生成されるので、次のコードを記述する。

procedure TForm1.PythonDelphiVar1SetData(Sender: TObject; Data: Variant);
begin
  //値がセットされたら文字列リストに値を追加
  strAnsList.Add(Data);
  Application.ProcessMessages;
end;

これでPython側からDelphi側へ、計算結果を渡せるようになった。ここでは単純な処理しかしていないので実質不要であるが、例えばループ処理を行って何度も結果が返るなど、より複雑な計算処理をPython側で行わせる場合に、確実に結果を受け取れるよう、 Application.ProcessMessagesを「おまじない」として入れてある。

Application.ProcessMessages メソッドは、「Windows がイベントに応答できるようアプリケーションの実行を一時的に停止」する命令であるとのこと。このメソッドについては下記リンク先の説明が詳しい。

Article: 待ち関数の必要性

URL:http://gumina.sakura.ne.jp/CREATION/OLD/COLUMN/CD1MATI.htm

(12)プログラムの完成と動作確認

これで、最低限の機能だけは組み込んだノートPCの電池の残容量を表示するプログラムの完成である。上書き保存(Ctrl+S)して、実行(F9)し、結果を確認する。

電池の残量が表示された

4. PythonEngineのメモリリーク

参考 PythonEngineのメモリリークが起きた時は・・・

別のプログラムでPythonEngineがメモリリークを起こしたことがある。この問題について、次のようにFormのOnDestroyイベントでFinalize処理を行うよう対応したところ、メモリリークは解消された。備忘録として記しておく。

procedure TFormZZZ.FormDestroy(Sender: TObject);
begin
  //これでメモリーリークは発生しなくなった
  //PythonDLLによって割り当てられたすべてのメモリが解放される
  //旧バージョンのPythonEngineの場合
  //PythonEngine1.Finalize;
  //最新バージョン(2021年12月現在)のPythonEngineの場合
  PythonEngine1.Py_Finalize;
  PythonDelphiVar1.Finalize;
end;

5. Delphi11のIDEが真っ白になってしまう問題への対応方法

参考リンク Delphi11のIDEが真っ白になってしまう問題への対応方法

RAD Studio 11のプロジェクトファイル(.dproj、.cbproj)をダブルクリックしてIDEを起動し、デバッグ実行すると、IDEの各ウィンドウが白く表示される

URL:上のLinkをクリックしてください。

6.著作権表示の記載方法

参考:Python4DelphiのLicenseについて

GitHubのPython4Delphiのダウンロードページには「The project is licensed under the MIT License.」とある。これは「改変・再配布・商用利用・有料販売すべてが自由かつ無料」であること、及び使用するにあたっての必須条件はPython4Delphiの「著作権を表示すること」と「MITライセンスの全文」or 「 MITライセンス全文へのLink」をソフトウェアに記載する、もしくは、別ファイルとして同梱しなさい・・・ということを意味する。

したがってPython4Delphiを利用したプログラムの配布にあたっては、ソフトウェアの中で、次のような著作権表示を行うか、もしくは P4DフォルダのルートにあるLicenseフォルダをプログラムに同梱して配布すればよいことになる。

Python4Delphiを利用した場合の著作権表示の記載例:

Copyright (c) 2018 Dietmar Budelsky, Morgan Martinet, Kiriakos Vlahos
Released under the MIT license
https://opensource.org/licenses/mit-license.php

7.お願いとお断り

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

【関連記事】

Setup Old Python4Delphi

「Delphiで古いPythonForDelphiを使う(おすすめしません)」

OpenCVとNumpyをインストールしたembeddable pythonをDelphiから利用できるようにした。これはその覚書その2。タイトルにあるように古いPython4Delphiをセットアップした時の記録。

1.どなたにもおすすめしません(最新版が便利です)
2.旧バージョンのインストール方法
3.まとめ
4.著作権表示の記載方法
5.お願いとお断り

1.どなたにもおすすめしません(最新版が便利です)

今はどこを探しても、この古いPython4Delphiはダウンロードできないが、もし、それが入手できて、使わなければならなくなった時には参考になる(カモ)。

ちなみに、ずっと愛用していた(10年以上前のバージョン?の)Python4Delphiは最新のDelphi11に、ここに記載した方法でほぼ問題なくインストールでき、かつ、期待通りに(VCLコンポーネントとして)動作した。←が、どなたにもおすすめしません。

最新のPython4DelphiをDelphi10.3以降のバージョンにインストールする方法は・・・

2.旧バージョンのインストール方法

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

まず、困ったことに、ここで取り上げているPython4Delphiのバージョンがいくつなのか、どれくらい前にリリースされたものなのか、いつ、どこから入手したものなのか、いずれもわからない。

気が付いた時には、My PCの中にいた・・・。そんな存在である。

python4delphi-master\PythonForDelphiにあるDeployment.txtには、See document “Deploying P4D.PDF” first.・・・とあるので、これを読むとドキュメントの日付は「5/1/2005」となっている。もしかしたら、それくらい前のものかもしれない。

fmxには非対応のようで、vcl関連のファイルのみで構成されている。Readme.txtで紹介されているファイルとフォルダの構成は以下の通り。

FILES:
Readme.txt This file.
Python.txt Infos about Python, and further references.
Changes.txt List of all changes since the first release.
Tutorial.txt A simple tutorial to use the PythonEngine
To do.txt A to do list.
Deploying P4D.pdf Notes on the Deployment of your applications using Python for Delphi.
C++ Builder Notes.txt Notes on using C++Builder with the Python for Delphi components.
PythonAtom.hlp A help file explaining the use of TPythonAtom
Demos A folder containing several demos of Python for Delphi.
Components\Python.* The “Python for Delphi” packages.
Components\Sources\Core The source folder of the core “Python for Delphi”.
Lib Library of Python modules.
PythonIDE A Python developpment environment written in Delphi.
See PythonIDE\Readme.txt for the required components.
Modules Contains the Delphi\Delphi.dpr project that creates the Modules\Delphi.pyd Python module
that allows you to interact with Delphi VCL objects from Python.

同じく Readme.txt にあるインストール方法は、次の通り。この手順でDelphi10.4にインストール。

INSTALLATION:
install the Python for Windows distribution (http://www.python.org/).

1) Install the core components
For recent versions of Delphi, install the “Python_d” package located in the
Components folder and add the folder “…\Components\Sources\Core” to the library path.

1) コアコンポーネントのインストール

Components フォルダにある “Python_d” パッケージをインストールし、ライブラリパスに “…\Components\Sources\Core” フォルダを追加してください。

注意:異なるバージョンのDelphiがインストールされている環境では、Python_D.dpkをダブルクリックすると拡張子dpkに関連付けされたバージョンのDelphiが起動してしまう(あたりまえ)。このような場合は、P4D環境をインストールしたいDelphiを起動し、ファイルメニューの「開く」からPython_D.dpkを指定してパッケージをインストールする。

また、「開く」のは「Python_D.dpk」で、「Python_D.dproj」ではないことにも注意する。で、「Python_D.dpk」を開いたら・・・

プロジェクトマネージャーに表示されたPython_D.bplを右クリックして、表示されたサブメニューの「インストール」をクリック。

【Delphi10.4の場合】

この方法でエラーなくインストールできた。(・・・気がするだけかもしれない)

【Delphi11の場合】

次のエラーが発生!

[dcc32 エラー] PythonEngine.pas(63): E2029 ‘INTERFACE’ が必要な場所に 識別子 ‘Error’ があります。

エラーが起きている場所を確認すると・・・

unit PythonEngine;

{ TODO -oMMM : implement tp_as_buffer slot }
{ TODO -oMMM : implement Attribute descriptor and subclassing stuff }

{$IFNDEF FPC}
{$IFNDEF DELPHI2010_OR_HIGHER}
  Error! Delphi 2010 or higher is required! ←ここでエラーが発生!
{$ENDIF}
{$ENDIF}

とりあえず、この1行をコメント化して再実行。

{$IFNDEF FPC}
{$IFNDEF DELPHI2010_OR_HIGHER}
  //Error! Delphi 2010 or higher is required!
{$ENDIF}
{$ENDIF}

エラーは発生せず。表示されたメッセージを読み、インストールの成功を確認。

もう一度Python_D.bplを右クリックして、表示されたサブメニューの「上書き保存」をクリック。これでパッケージのインストールは完了。

「ライブラリパスに “…\Components\Sources\Core” フォルダを追加・・・」とあるが、パスを追加しなくてもプログラムの動作に必要な.pasファイルをプロジェクトファイルのあるフォルダにコピーすれば動くから、ここでは「追加しない」ことを選択。

重要 特別な理由のない限り、最新版のPython4Delphiを選択することをお勧めします。
(最新版のP4Dパッケージを登録する場合は、ライブラリパスをきちんと設定しましょう)

2) Install the VCL components (this is optional)

For recent versions of Delphi, install the “PythonVCL_d” package located in the Components folder and add the folder “…\Components\Sources\Core” to the library path.

2) this is optional ・・・とあるので、オプションならやらなくてもいいか!ということで実行しない。

3) Build Modules\Delphi\Delphi.dpr (This is optional and unsupported)

Once the project is build you can either extend the Python path with ..\Modules or copy ..Modules\Delphi.pyd to C:\Python24\DLLs, to be able to import the Delphi module from Python.

Note that you can try this module by invoking the ..\Modules\TestApp.py script.

3) This is optional and unsupported ・・・とあり、オプションである上にサポートなしとあるので、これも実行しない。

3.まとめ

(1) Readme.txt の INSTALLATION の手順1)のみ実行すればOKだった。

(2)DelphiのXXX.dprojファイルのあるフォルダへコピーするPython関係のファイルは以下の通り。他のプロジェクトでも利用する場合は、ライブラリパスへ登録した方が使いやすくなるが、このP4Dは最新版ではないので、このようにして利用した(←過去形であることに注意)。

動作に必要なファイル

4. 著作権表示の記載例

参考:Python4DelphiのLicenseについて

GitHubのPython4Delphiのダウンロードページには「The project is licensed under the MIT License.」とある。これは「改変・再配布・商用利用・有料販売すべてが自由かつ無料」であること、及び使用するにあたっての必須条件はPython4Delphiの「著作権を表示すること」と「MITライセンスの全文」or 「 MITライセンス全文へのLink」をソフトウェアに記載する、もしくは、別ファイルとして同梱しなさい・・・ということを意味する。

したがってPython4Delphiを利用したプログラムの配布にあたっては、ソフトウェアの中で、次のような著作権表示を行うか、もしくは P4DフォルダのルートにあるLicenseフォルダをプログラムに同梱して配布すればよいことになる。

Python4Delphiを利用した場合の著作権表示の記載例:

Copyright (c) 2018 Dietmar Budelsky, Morgan Martinet, Kiriakos Vlahos
Released under the MIT license
https://opensource.org/licenses/mit-license.php

5.お願いとお断り

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

【関連記事】

Setup Python4Delphi

「DelphiからPythonを使えるようにする」

追々記(20231208)

さらにカンタンな方法がありました!

以下、上記の方法にたどり着くまでの、長い長い歩みの記録です。

追記(20231126)

RAD Studio 12.0(Delphi 12.0 Athens)のリリースに合わせ、Python4Delphi も更新されました。
RAD Studio 12.0(Delphi 12.0 Athens)へのインストールに対応した Python4Delphi (20231109版)のインストール記事は、以下のリンク先にあります。

RAD Studio 12.0(Delphi 12.0 Athens)に Python4Delphi をインストールされる場合は、こちらをご参照ください。

以下、2021年12月31日に掲載した記事です(内容は当時のままです)。

OpenCVとNumpyをインストールしたembeddable pythonをDelphiから利用できるようにした。これはその覚書。

1.Python4Delphiのダウンロード
2.P4DパッケージをDelphiにインストール
3.ライブラリパスの設定
4.P4Dの著作権表示の記載例
5.お願いとお断り

1.Python4Delphiのダウンロード

まず最初に、Python for Delphi(P4D)をGitHubから入手してDelphiにインストール。

P4Dの入手先URL https://github.com/pyscripter/python4delphi

Git Bashがない場合は、Codeをクリックすると表示されるサブメニューのいちばん下にDownLoad ZIPがあるので、これをクリックしてZIPファイルをダウンロードし、任意の場所(フォルダ)に解凍する(ここではダウンロードするフォルダの名前を「P4D」として説明)。

Download ZIPをクリック

Git Bashがある場合は・・・

Codeをクリック → 表示されるサブメニューからURLをコピー

で、Git Bashがあれば開く。

Git Bashは公式サイト https://gitforwindows.org/ から入手可能。

Git Bashでは、ls(エルエス)コマンドで今いる場所が表示され、cdコマンドでディレクトリの移動ができる。今、いる場所の直下のフォルダに移動するのであれば、Git Bashの画面に直接「cd フォルダ名」と入力してEnterキーを押す。

今、いる場所の直下に新しくフォルダを作成する場合は「mkdir フォルダ名」と入力してEnterキーを押す。

1階層上に移動したい場合は、「cd ../」と入力してEnterキーを押す。

階層の深いフォルダへ移動したい場合は、「cd」+半角スペースを入力後、そのフォルダをGit Bashの画面上へドラッグ&ドロップすればOK!

ここでは、Git Hubのリポジトリをクローンする「中が空の任意のフォルダ」を選ぶ(上の図ではあらかじめ作成しておいたP4Dフォルダを選んでいる)。

フォルダの内容が空であれば、フォルダの名前は何でもOKだが、後のPython4Delphiのインストール時に、「インストール元フォルダとして選択するフォルダとなる」ことに十分注意(フォルダ名を忘れないように)する(ここではフォルダ名を「P4D」としている)。

Git cloneと入力し、半角スペースを入れ、画面を右クリックして表示されるサブメニューのPasteをクリック。Enterキーを押すとダウンロードが始まる。

Enterキーを押してダウンロード開始。
ダウンロード終了時の画面

※ Git Bashがない場合は、Codeをクリックすると表示されるサブメニューのいちばん下にDownLoad ZIPがあるので、これをクリックしてZIPファイルをダウンロードし、任意の場所(フォルダ)に解凍する。(再掲)

2.P4DパッケージをDelphiにインストール

ここで超重要ポイントがひとつ

ダウンロードが無事完了すると、P4Dフォルダの中には「python4delphi」フォルダが出来ている。

(ZIPファイルを解凍した場合は「python4delphi-master」フォルダが出来る)

このフォルダの名前を手動で「P4D」に変更(リネーム)する。

「P4D」フォルダの中(1階層下)に「P4D」フォルダがあることになるが、これでOK! 理由は以下の通り。

C:\XXX\P4D\P4D\Install\Readme.mdには、以下の記述が・・・

P4D Installation using MultiInstaller

Use for Delphi Seattle (10.4) or later to install all packages in one step.

  1. Clone or copy the Python4Delphi git repository to a folder of your choice. The setup.ini file assumes that the folder is called “P4D”. If you chose to name your folder differently then modify the “Folder” option in setup.ini.
  2. Close all Delphi IDEs running.
  3. Run MultiInstaller.exe
  4. Select the packages you want and press Next
  5. In the dialog box specify the parent folder of “P4D” (i.e. the folder containing the directory to which you have copied Python4Delphi) and the Delphi target version. Then press Next to install the components

Google先生曰く・・・

MultiInstallerを使用したP4Dインストール

Delphi Seattle(10.4)以降で使用して、すべてのパッケージを1つのステップでインストールします。

  1. Python4Delphigitリポジトリのクローンを作成するか選択したフォルダーにコピーします。 setup.iniファイルは、フォルダが「P4D」と呼ばれることを前提としています。フォルダに別の名前を付けることを選択した場合は、setup.iniの[フォルダ]オプションを変更します。
  2. 実行中のすべてのDelphiIDEを閉じます。
  3. MultiInstaller.exeを実行します。
  4. 必要なパッケージを選択して、[次へ]を押します。
  5. ダイアログボックスで、「P4D」の親フォルダ(つまり、Python4Delphiをコピーしたディレクトリを含むフォルダ)とDelphiのターゲットバージョンを指定します。次に、[次へ]を押してコンポーネントをインストールします。

Python4Delphiをコピーしたディレクトリを「P4D」にリネームして、さらに、インストール時に表示されるダイアログボックスでは・・・

「その親フォルダを指定せよ」

と言っている・・・。

C:\XXX\P4D\P4D\Installにある「MultiInstaller.exe」を起動
デフォルトですべてにチェックが入っている。そのままNextをクリック。
親の方のP4Dフォルダを選択し、OKをクリック
Compile packages and install on IDE にチェック(My環境ではDelphi11しかないので、チェックを入れると自動的にRAD Studio 11 Alexandriaのオプションが選択された)。

複数バージョンのDelphiがインストールされている環境であれば、インストールしたいバージョンを選択することになるはず。

Nextをクリックして続行。無事終了すれば下のような画面が表示される。

Delphi11にも無事インストールできた(あらかじめInstallフォルダ内にあったSetup.iniを確認したところ、Pathの設定に10.4+とあるから大丈夫と思ったが)。Finishをクリックしてインストール終了。

DelphiのIDEを起動して確認。

パレットに7匹のヘビを無事発見。

3.ライブラリパスの設定(確認)

追加したP4Dのパッケージを使用する場合、パッケージをインストールした後で、
「ツール」→「オプション」→「言語」→「Delphiオプション」→「ライブラリ」の順にクリックして下の画面を表示する(Delphi11の場合)。

「ツール」→「オプション」で上の画面が表示されるので、左ペインでさらに「言語」→「Delphi」→「ライブラリ」と進み、次に右ペインのライブラリパス(B)の赤枠囲みの…をクリックする。

表示される画面で、ライブラリのSourceファイルがあるフォルダのパスを登録する。ライブラリのパスの設定はターゲットにするそれらのプラットフォームごとに設定する必要がある。上の画面では 「Windows 32ビット」 のプラットフォームに対して設定している。 必要であれば、「Windows 64ビット」 のプラットフォームに対しても設定する。

ライブラリのSourceファイルは、PCを変更した場合でも容易に参照できるよう、
絶対に忘れない場所に置くようにしている。
また、上の例では最新版のP4DのSourceが階層構造を持っているため、共通利用するものとそうでないもの(vcl/fmx)を、それぞれ分けて登録している。

コンパイルを実行すると、Delphiはいちばん最初にプロジェクトファイル(.dproj)のあるフォルダ(ここはパスが通っているから登録は不要)を検索し、必要なユニットファイル等の有無を確認。もし、そこに必要なファイルがなければ、この画面に登録したライブラリパスを検索するようだ。

まとめ

(1)Python4Delphiをダウンロードするフォルダの名称は任意だが、そこに作られるフォルダ「 python4delphi 」は「P4D」にリネームする。

(2)MultiInstaller.exeを実行してインストール先フォルダを指定する際には、上でリネームした「P4D」フォルダの1階層上のフォルダを指定する。

(3)パッケージのインストール後、コンパイル時に必要なSourceファイルのある場所をライブラリパスに登録する。

4.P4Dの 著作権表示の記載例

参考:Python4DelphiのLicenseについて

GitHubのPython4Delphiのダウンロードページには「The project is licensed under the MIT License.」とある。これは「改変・再配布・商用利用・有料販売すべてが自由かつ無料」であること、及び使用するにあたっての必須条件はPython4Delphiの「著作権を表示すること」と「MITライセンスの全文」or 「 MITライセンス全文へのLink」をソフトウェアに記載する、もしくは、別ファイルとして同梱しなさい・・・ということを意味する。

したがってPython4Delphiを利用したプログラムの配布にあたっては、ソフトウェアの中で、次のような著作権表示を行うか、もしくは P4DフォルダのルートにあるLicenseファイルをプログラムに同梱して配布すればよいことになる。

Python4Delphiを利用した場合の著作権表示の記載例:

Copyright (c) 2018 Dietmar Budelsky, Morgan Martinet, Kiriakos Vlahos
Released under the MIT license
https://opensource.org/licenses/mit-license.php

5.お願いとお断り

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

【関連記事】