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

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

【関連記事】