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

矩形検出器を改良

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

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

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

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

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

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

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

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

【もくじ】

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

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

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

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

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

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

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

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

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

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

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

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

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

これが・・・

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

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

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

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

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

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

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

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

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

もぉ T_T

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

どうすればいいか?

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

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

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

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

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

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

  //画像分割処理なし(初期状態)

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

  try
    //Python Script
    strScrList.Add('import cv2');
    strScrList.Add('import numpy as np');
    strScrList.Add('from PIL import Image');
    //strScrList.Add('img = cv2.imread("./ProcData/sample2.jpg")');
    //strScrList.Add('img = cv2.imread(r"'+StatusBar1.SimpleText+'")');
    strScrList.Add('pil_img = Image.open(r"'+StatusBar1.SimpleText+'")');
    strScrList.Add('img = np.array(pil_img)');
    strScrList.Add('gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)');
    strScrList.Add('gray = 255 - gray');
    strScrList.Add('ret, bin_img = cv2.threshold(gray, 20, 255, cv2.THRESH_BINARY)');
    strScrList.Add('contours, hierarchy = cv2.findContours(bin_img, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)');
    strScrList.Add('contours = list(filter(lambda x: cv2.contourArea(x) > '+cmbThreshold.Text+', contours))');
    strScrList.Add('for i in range(len(contours)):');
    strScrList.Add('    im_con = img.copy()');
    strScrList.Add('    x, y, w, h = cv2.boundingRect(contours[i])');
    strScrList.Add('    var1.Value =str(x)+","+str(y)+","+str(x+w)+","+str(y+h)');
    //Scriptを表示
    Memo1.Lines.Assign(strScrList);
    //「0による浮動小数点数除算」のエラーを出ないようにするおまじない
    MaskFPUExceptions(True);
    //Execute
    PythonEngine1.ExecStrings(Memo1.Lines);
    //結果を表示
    Memo2.Lines.Assign(strAnsList);
  finally
    //StringListの解放
    strAnsList.Free;
    strScrList.Free;
  end;

end;

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

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

とりあえず・・・

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

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

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

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

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

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

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

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

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

begin

  ・・・

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

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

  ・・・

end;

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

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

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

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

begin

  //画像分割処理なし(初期状態)
  {
  //初期化
  Memo1.Clear;
  //Scriptを入れるStringList
  strScrList:=TStringList.Create;
  //結果を保存するStringList
  strAnsList:=TStringList.Create;

  try
    //Python Script
    strScrList.Add('import cv2');
    strScrList.Add('import numpy as np');
    strScrList.Add('from PIL import Image');
    //strScrList.Add('img = cv2.imread("./ProcData/sample2.jpg")');
    //strScrList.Add('img = cv2.imread(r"'+StatusBar1.SimpleText+'")');
    strScrList.Add('pil_img = Image.open(r"'+StatusBar1.SimpleText+'")');
    strScrList.Add('img = np.array(pil_img)');
    strScrList.Add('gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)');
    strScrList.Add('gray = 255 - gray');
    strScrList.Add('ret, bin_img = cv2.threshold(gray, 20, 255, cv2.THRESH_BINARY)');
    strScrList.Add('contours, hierarchy = cv2.findContours(bin_img, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)');
    strScrList.Add('contours = list(filter(lambda x: cv2.contourArea(x) > '+cmbThreshold.Text+', contours))');
    strScrList.Add('for i in range(len(contours)):');
    strScrList.Add('    im_con = img.copy()');
    strScrList.Add('    x, y, w, h = cv2.boundingRect(contours[i])');
    strScrList.Add('    var1.Value =str(x)+","+str(y)+","+str(x+w)+","+str(y+h)');
    //Scriptを表示
    Memo1.Lines.Assign(strScrList);
    //「0による浮動小数点数除算」のエラーを出ないようにするおまじない
    MaskFPUExceptions(True);
    //Execute
    PythonEngine1.ExecStrings(Memo1.Lines);
    //結果を表示
    Memo2.Lines.Assign(strAnsList);
  finally
    //StringListの解放
    strAnsList.Free;
    strScrList.Free;
  end;
  }

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

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

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

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

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

  //初期化
  xWidth:=0;

  try

    for i := 0 to iMax-1 do
    begin

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

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

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

      try

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

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

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

    end;

    for i := 0 to iMax-1 do
    begin

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

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

      try
        //Python Script
        strScrList.Add('import cv2');
        strScrList.Add('import numpy as np');
        strScrList.Add('from PIL import Image');
        //strScrList.Add('img = cv2.imread("./ProcData/sample2.jpg")');
        //strScrList.Add('img = cv2.imread(r"'+StatusBar1.SimpleText+'")');
        //strScrList.Add('pil_img = Image.open(r"'+StatusBar1.SimpleText+'")');
        strScrList.Add('pil_img = Image.open(r"'+'CutImage0'+IntToStr(i)+'.jpg'+'")');
        strScrList.Add('img = np.array(pil_img)');
        strScrList.Add('gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)');
        strScrList.Add('gray = 255 - gray');
        strScrList.Add('ret, bin_img = cv2.threshold(gray, 20, 255, cv2.THRESH_BINARY)');
        strScrList.Add('contours, hierarchy = cv2.findContours(bin_img, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)');
        strScrList.Add('contours = list(filter(lambda x: cv2.contourArea(x) > '+cmbThreshold.Text+', contours))');
        strScrList.Add('for i in range(len(contours)):');
        strScrList.Add('    im_con = img.copy()');
        strScrList.Add('    x, y, w, h = cv2.boundingRect(contours[i])');
        strScrList.Add('    var1.Value =str(x)+","+str(y)+","+str(x+w)+","+str(y+h)');
        //Scriptを表示
        Memo1.Clear;
        Memo1.Lines.Assign(strScrList);
        //「0による浮動小数点数除算」のエラーを出ないようにするおまじない
        MaskFPUExceptions(True);
        //Execute
        PythonEngine1.ExecStrings(Memo1.Lines);
        //結果を表示
        if RadioButton1.Checked then
        begin
          //x座標を補正する
          MemoTemp.Lines.Assign(strAnsList);
          if i<>0 then
          begin
            for j := 0 to MemoTemp.Lines.Count-1 do
            begin
              //値を取得
              str1:=GetTokenIndex(MemoTemp.Lines[j],',',0);
              str2:=GetTokenIndex(MemoTemp.Lines[j],',',1);
              str3:=GetTokenIndex(MemoTemp.Lines[j],',',2);
              str4:=GetTokenIndex(MemoTemp.Lines[j],',',3);
              //カンマ区切りの文字列の1,3番目にtop_x値を加える(座標を修正)
              str1:=IntToStr(StrToInt(str1)+top_x);
              str3:=IntToStr(StrToInt(str3)+top_x);
              //書き戻し
              MemoTemp.Lines[j]:=str1+','+str2+','+str3+','+str4;
            end;
          end;
        end else begin
          Memo2.Lines.Assign(strAnsList);
        end;
      finally
        //StringListの解放
        //strAnsList.Free;
        strAnsList.Clear;
        strScrList.Free;
      end;

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

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

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

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

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

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

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

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

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

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

  end;

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

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

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

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

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

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

  GetLinePos;

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

end;

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

procedure TForm1.Memo2Click(Sender: TObject);
var
  i:integer;
  //x1,x2,x3,x4:integer;
  //y1,y2,y3,y4:integer;
  p1,p2:TPoint;
  //文字列切り分け///////////////////////////////////////////////////////////////
  function RemoveToken(var s:string;delimiter:string):string;
  var
    p:Integer;
  begin
    p:=Pos(delimiter,s);
    if p=0 then Result:=s
    else Result:=Copy(s,1,p-1);
    s:=Copy(s,Length(Result)+Length(delimiter)+1,Length(s));
  end;

  function GetTokenIndex(s:string;delimiter:string;index:Integer):string;
  var
    i:Integer;
  begin
    Result:='';
    for i:=0 to index do
      Result:=RemoveToken(s,delimiter);
  end;

begin

  if not EditTF then
  begin

    //座標を取得
    i:=Memo2.Perform(EM_LINEFROMCHAR, Memo2.SelStart, 0);
    //ShowMessage(IntToStr(i));

    //エラー対策
    if Memo2.Lines[i]='' then Exit;

    x1:=StrToInt(GetTokenIndex(Memo2.Lines[i],',',0));
    y1:=StrToInt(GetTokenIndex(Memo2.Lines[i],',',1));
    x2:=StrToInt(GetTokenIndex(Memo2.Lines[i],',',2));
    y2:=StrToInt(GetTokenIndex(Memo2.Lines[i],',',3));

    if Assigned(plImage1) then begin
      FreeAndNil(plImage1);
    end;

    //コンポーネントを生成し,イベントを定義し,位置を指定して画像を表示
    plImage1:=TplResizeImage.Create(Self);
    plImage1.Parent:=ScrollBox1;
    plImage1.TransEvent:=True;
    //クライアント座標をスクリーン座標へ変換
    //GetSystemMetrics(SM_CYCAPTION) -> タイトルバーの高さ
    //GetSystemMetrics(SM_CYFRAME) -> ウィンドウの枠幅
    p1.X:=x1-(GetSystemMetrics(SM_CYFRAME) div 2);
    p1.Y:=y1-GetSystemMetrics(SM_CYCAPTION)-(GetSystemMetrics(SM_CYFRAME) div 2);
    p2.X:=x2-(GetSystemMetrics(SM_CYFRAME) div 2);
    p2.Y:=y2-GetSystemMetrics(SM_CYCAPTION)-(GetSystemMetrics(SM_CYFRAME) div 2);
    p1:=Image1.ClientToScreen(p1);
    p2:=Image1.ClientToScreen(p2);
    plImage1.SetBounds(p1.X, p1.Y, p2.X-p1.X, p2.Y-p1.Y);

    //SelectedプロパティをTrueにするとラバーバンドとグラブハンドルが表示される
    plImage1.Selected := True;
    plImage1.BringToFront;

  end;

end;

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

1ブロックめの最初。

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

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

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

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

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

3.まとめ

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

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

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

4.お願いとお断り

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

風を切ろう

ゼファー400のレストア動画を見た。

13編全部、最高だったけど。

何より胸に残ったのは・・・

「もう一度、風を切ろう・・・」って、言葉。

この言葉のほんとうは、二輪車乗りにしか、わからないんじゃないかな・・・

( いい言葉だな )って、

つい ・・・ 瞳がうるんでしまった。

ドキドキしながら

きらめく風の中・・・

初めてアクセルを開けた日・・・

僕は、まだ16歳だった・・・。

このレストア動画の中に・・・ そんなシーンは、1秒もないのに。

なぜか、懐かしい・・・風の匂いまで、僕は思い出せた・・・。

「必死だった」って、

レストアシリーズとは別の動画で、この動画の作者は、実にさりげなく語ってたけど、

こんなに「オートバイが好き」って、その気持ちが伝わってくる映像を・・・

僕は、これまでに、ほんとうに・・・ ほんとうに見たことがない。

もちろん、これからも、ないだろう。

そう、断言できるくらい、心が動いたから、言う。

しせい、てんにつうず。

相手が人でなく、それが「機械」であっても・・・ それはきっと同じ。

真心を伝えたい 相手が、人でなく、たとえ機械であったとしても、

伝えたい「想い」が、間違いなく、「ほんとう」なら・・・

嘘も、偽りもない、その「想い」に勝るものなんか、絶対にない。

だから、敬意を込めてその総集編へのリンクを、ここに貼ります。

ふみっちょさん。心から、本気で、ほんとうの、ありがとうです。

あなたの動画を見て、

オートバイに乗りたかった気持ちの原点を、僕は、思い出しました。

16歳だった・・・ 初めて、風を切った日の・・・

アクセルを開けた、あの一瞬の・・・記憶。

そう。あの瞬間から、僕は・・・ オートバイが大好きに・・・

そんな想いを抱きしめて、眠ったから・・・ かな・・・?

8月のある朝、目が覚めていちばん最初に思い出したのは・・・

まだ、バイクで行ったことのない、家のすぐ近くにある、大好きな場所だった。

何年も前から、クルマでは、何度も訪れた場所なのに、なぜか・・・

そこへは一度も、バイクで行ったことはなかった・・・。

( 今日のために、とっておいた? )

まぁ、いい。今、考えるのはよそう。

人生には、いろんな不思議があっていい。

きみと、そこへ行こう。

そう、風を切って。

何度も、何度も、ここで、近くの空港に降りる飛行機を数えた。
初めてここへきたきみは、はしゃいでるみたいに輝いて。

でも、33歳。

いっぱい、壊れたよな・・・

フロントフォークからのオイル漏れ
キャブレターのオーバーフロー

そうだ、コンビニへ入ろうとしたら
サイドスタンドが落ちてなかったこともあった。

ウインカーのプラスチックが経年劣化して、根本から折れたことも
リアショックのオイルが全部抜けたこともあった。

セルが廻らなくなったことも
ブレーキが噛んだことも

立ちゴケしてクラッチレバーを折ったり、
エンジンを傷つけたこともあった・・・

レバーは交換すればよかったけど
エンジンは、涙目になりながら、必死で磨いて再生したんだ・・・

そうだ

カムチェーンテンショナーの押しが足りなくなって
アクセル戻す度にエンジンからすごいガラガラ音が聞こえてきた時は
心が折れそうになったな・・・

やっとの思いで、一度も外したことがないキャブレターを外して・・・
取り出せたカムチェーンテンショナーは、Webで見た写真とは全然違う旧型で・・・
どうやって調整したらいいのか、
まったく、わからなかった。

自分でもなんとかなりそうな、C3型以降用(?)のテンショナーを手に入れて・・・
ノッチの押し込みを調整しながら・・・震える手で装着。
汗まみれになってキャブレターを元に戻し、
祈るような気持ちで、スターターボタンを押したんだ。

エンジンのガラガラ音が完全に消えた時は、天にも昇る心地だった。

でも、熱にさらされる場所は、どうしても酸化が進んで・・・

何度も磨いて、耐熱塗料を塗り直したマフラー。
それほど高温にならない後ろ部分は耐熱塗料でなくてもOKなことを
経験から知ったけど。

さすがに、エンジンに、それは通用せず・・・
オリジナルの塗装は、もうほとんど残ってない・・・。

みんな、サビとの戦いで、消えてしまった・・・

でも、まだ、走れるよね。

走れる限り、ふたりで、風を切ろう。

もうすぐ、秋だね。

ふたり、黄金色の風の中、

アクセルを開けて・・・

どこまでも、

そして、いつまでも・・・

風を切って・・・

走ろう☆

Installer

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

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

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

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

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

【目次】

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

1.困ったこと

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

implementation

{$R *.dfm}

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

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

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

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

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

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

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

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

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

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

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

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

end;

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

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

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

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

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

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

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

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

end;

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

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

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

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

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

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

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

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

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

end;

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

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

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

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

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

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

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

end;

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

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

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

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

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

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

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

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

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

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

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

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

end;

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

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

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

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

5.まとめ

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

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

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

6.お願いとお断り

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