PythonとOpenCVで簡易OMR（マークシートリーダ）を作る



紙のアンケート調査をした場合などにデータ入力をできるだけ効率化したかったので，OMR（Optical Mark Reader）を探していたのですが，なかなか自分の求めていたものがなかったので簡便なものを自分で作ることにしました。もっとスマートなやり方もあるでしょうが，ひとまずそれなりに動作するものができましたので記しておきます。

画像の認識処理には[OpenCV](http://opencv.org)，数値処理にNumPyを使用しています。なお，MacでHomebrewを使ってOpenCV 3をPython 3から使用する方法についてはやや注意が必要です。`brew install`する際に，`--with-python3`オプションをつけないとPython 3からは使用できません。また，`keg only`のパッケージなので，Python 2の場合も3の場合も，自分でライブラリのパスを設定する必要があります。ネットで検索すると手順を紹介しているページがいろいろと出てきますが，わかりやすいのは[こちらのページ](http://blanktar.jp/blog/2016/01/homebrew-python3-opencv3.html)だと思います。

## 大まかな流れ
* 必要なもの
	* Python (2.7系でも3.x系でも。ここでは3.5を使用)
	* 使用パッケージ：NumPy，OpenCV
* 事前準備
	* マークシートの作成
	* ドキュメントスキャナ (ScanSnapなど)でのスキャン 
* 認識処理
	* 画像の切り出しとリサイズ
	* マークの認識
	* 結果の出力

## 事前準備
### マークシートの作成

まず，マークシートがなくては始まりませんので作成します。マーク読み取りの処理をできるだけ単純にしようと思うと，マークシート作成の段階でいくつか注意しておくべき点があります。

#### 注意点1：マークそのものはできるだけ薄く
マークそのものが濃く描かれていると，塗りつぶされているかどうかの判定が難しくなります。マークそれ自体は，見づらくならない程度に薄い色で作成しておくか，かなり細めの線で作成するのがいいでしょう。

マークシート用のマークは，Illustratorなどを使用して作成してもよいですし，そこまでしなくても，MS WordやPagesといったワープロソフトの基本図形を使用して作成することもできます。下の図は，Pagesの角丸長方形を利用して作成したもので，色は40%グレー，数字はヒラギノ角ゴW1の5pt，周囲の線は0.3ptです。マークシート上では，マークのサイズは幅3mm，高さ5mmになっています。

![mark.png](https://qiita-image-store.s3.amazonaws.com/0/106317/9e8de268-52dc-5bf1-7393-91c3ac5eceea.png "mark.png")


#### 注意点2：マークは間を十分に空けて等間隔に
マークの認識処理を単純化するために，それぞれのマークを少し離し気味にして，縦横それぞれで **等間隔** になるように配置します。マークの間隔が十分に広ければ，等間隔から多少ずれたとしても問題ありません。

#### 注意点3：特徴点（マーカー）となるものを用意
マークシート領域を抽出するためには，特徴点（ **マーカー** ）が必要です。比較的単純な形状で，かつ他の文字や記号などと被らないような図形を作成し，認識処理で使用する領域の四隅に2〜4個配置しておきます。

このとき，マーカーの **左上** が認識領域の隅に一致するようにします。サンプルのマークシートでは，マークが配置されている領域の上方にマークの行の高さ3行分，下方に１行分の余白をとった領域を認識領域として，その領域の左上と右上，右下の3箇所にマーカーをつけてあります。マーカーは左上と右下の2箇所だけでも構わないと思うのですが，ページの傾きが誤って判断されてしまうかもしれないと考え，念のため右上にも配置しました。下の画像は，作成したマークシート用紙のサンプルです。

![marksheet.png](https://qiita-image-store.s3.amazonaws.com/0/106317/43273105-bebf-0b7c-4c65-9f3ee0f6c49f.png "marksheet.png")


マーカーの画像ファイルは認識処理に必要ですので，マークシート用紙とは別に，マーカーだけを画像ファイルとして保存しておきます。

![marker.jpg](https://qiita-image-store.s3.amazonaws.com/0/106317/90a4ab14-b477-6b90-f1a2-d759292eaa94.jpeg "marker.jpg") ←　マーカーだけを画像として保存する

これでマークシートの準備は完了です。

### マークシートのスキャン

記入されたマークシートは，ScanSnapなどのドキュメントスキャナを使って **グレースケール** で読み込みます。読み取り画像が傾いているとうまく認識できません。OpenCVで傾き補正を行うこともできますが，その分処理が複雑になってしまいますので，ここではスキャナの読み取りソフトで **傾き補正** をオンにして読み込みます。マークシート用紙の向きが揃っていない場合には，自動回転もオンにしておきます。

下の画像は，サンプルのマークシート用紙に適当にマークしてスキャンした結果です（サイズは縮小してあります）。

![sample001_small.png](https://qiita-image-store.s3.amazonaws.com/0/106317/163c8d67-4ba8-e5c0-ce95-e96ca9c5fb3a.png "sample001_small.png")


## 認識処理
ここからは，PythonとOpenCVを使った画像認識処理です。まず，NumPyとOpenCVをインポートします。

```python
import numpy as np
import cv2
```


### 画像の切り出しとリサイズ
スキャンした画像を読み込み，マーカーを利用して必要な領域だけ切り出します。

まず，マークシートの範囲を切り出すためのマーカーの画像ファイル（ここでは`marker.jpg`）を読み込み，必要な設定を行います。マーカーの位置をOpenCVで特定するにはテンプレートマッチングを使いますが，テンプレート（マーカー）画像の大きさはスキャン画像中のマーカーの大きさと同じでないとうまくいきません。マーカー画像のサイズがスキャン画像における大きさとだいたい一致するように，スキャン解像度に応じてマーカーをリサイズしておきます。あらかじめマーカーのサイズを拡大して保存しておいても構いません。

```
### マーカーの設定

marker_dpi = 72 # 画面解像度(マーカーサイズ)
scan_dpi = 300 # スキャン画像の解像度

# グレースケール (mode = 0)でファイルを読み込む
marker=cv2.imread('marker.jpg',0) 

# マーカーのサイズを取得
w, h = marker.shape[::-1]

# マーカーのサイズを変更
marker = cv2.resize(marker, (int(h*scan_dpi/marker_dpi), int(w*scan_dpi/marker_dpi)))
```

次にスキャンしたマークシート画像を読み込みます。スキャンした画像が`sample001.jpg`という名前で保存されているものとします。

```
### スキャン画像を読み込む
img = cv2.imread('sample001.jpg',0)
```

このスキャン画像から，テンプレートマッチングの関数`matchTemplate()`を用いてマーカーを抽出します。

```
res = cv2.matchTemplate(img, marker, cv2.TM_CCOEFF_NORMED)
```

この`cv2.TM_CCOEFF_NORMED`の部分は，類似を判定するための関数を指定しています。とくに問題なければこのままで構いません。

`matchTemplate()`によるマッチング結果には，スキャン画像中の各座標に対し，テンプレート画像との類似度（最大=1.0）を示すような値が入っています。ここから，ある程度類似度の高い部分だけを抜き出します。

```
threshold = 0.7
loc = np.where( res >= threshold)
```

ここでは類似度が`0.7`以上の座標だけ取り出しています。この値は，必要に応じて調整してください。数値を大きくするほど判定が厳しくなり，小さくするほど判定がゆるくなります。たとえば，テンプレートとなるマーカーのサイズがスキャン画像中のマーカーのサイズと極端に異なっている場合，類似度が低くなってしまうのでこの数値を下げないとうまく認識できません。また，あまりに基準をゆるくしすぎると，マーカーでないものが誤検出されることになります。マーカーのサイズが適切であれば，だいたい0.7前後でよいと思います。

取り出した座標から，認識領域の左上と右下の座標を求めます。左上の座標値は，類似度が高い結果のうち，`x`，`y`ともに最小の座標値，右下の座標値は`x`，`y`ともに最大の座標値です。なお，抜き出した座標値は配列には`y`，`x`の順で格納されているので注意してください。

```
mark_area={}
mark_area['top_x']= min(loc[1])
mark_area['top_y']= min(loc[0])
mark_area['bottom_x']= max(loc[1])
mark_area['bottom_y']= max(loc[0])
```

この座標を元に，スキャン画像を切り出します。画像の切り出しは，単に元画像に対して必要な領域の座標を指定するだけです。ただし，Y座標，X座標の順である点には注意が必要です。

```
img = img[mark_area['top_y']:mark_area['bottom_y'],mark_area['top_x']:mark_area['bottom_x']]
```

きちんと切り出せているかどうか，切り出した範囲を書き出して確かめてみます。

```
cv2.imwrite('res.png',img)
```

![clipped_small.png](https://qiita-image-store.s3.amazonaws.com/0/106317/616f98ff-619b-f531-5b6c-f7d64a0ec75c.png "clipped_small.png")



若干左に空白ができていますが，これぐらいなら問題ありません。

次に，この後の処理をしやすくするため，切り出した画像をマークの列数・行数の整数倍のサイズになるようリサイズします。ここでは，列数・行数の100倍にしています。なお，行数をカウントする際には，マーク領域からマーカーまでの余白も考慮した行数にします。

```
n_col = 7 # 1行あたりのマークの数

n_row = 7 # マークの行数
margin_top = 3 # 上余白行数
margin_bottom = 1 # 下余白行数

n_row = n_row + margin_top + margin_bottom # 行数 (マーク行 7行 + 上余白 3行 + 下余白 1行)

img = cv2.resize(img, (n_col*100, n_row*100))
```

さらに，切り出した画像に対して軽くブラーをかけた上で画像を白黒2値化し，白黒を反転させます。下の例では，Gaussianブラーをかけた上で，明るさ50を基準に2値化しています。白黒の反転は，画像値を255から引くだけです。

```
### ブラーをかける
img = cv2.GaussianBlur(img,(5,5),0)

### 50を閾値として2値化
res, img = cv2.threshold(img, 50, 255, cv2.THRESH_BINARY+cv2.THRESH_OTSU)

### 白黒反転
img = 255 - img
```

これらの処理を行った結果は次のようになります。

![dicho_small.png](https://qiita-image-store.s3.amazonaws.com/0/106317/6fee0dba-bacc-ffb6-5b21-61fc9b98c398.png "dicho_small.png")


### マークの認識
マークの認識は，切り出してリサイズした画像を，さらに行ごとに切り分けて行います。

行ごとに行う処理としては，まず画像を横方向にマークの個数分に分割し，それぞれの画像値の合計を求めます。画像は白黒反転させていますので，色のついている部分が白(`255`)，空白部分が黒(`0`)になっています。つまり画像値の合計は，色がついている部分（マークされている部分）の面積を意味することになります。

そしてマークされている部分の面積の **中央値** を算出し，この中央値をマークされているかどうかを判断する際の **閾値** として用います。マークシートには，印刷されている線や数字など，元々色のついている部分がありますので，マークされていない部分にもある程度色がついています。そこで次の例では，色のついている部分の面積が算出した中央値の3倍以上ある場合を`True`，そうでない場合を`False`として判断しています。この倍数は，必要に応じて調整してください。倍数を大きくするほど判定が厳しく，小さくするほど甘くなります。

なおこの方法は，各行で塗りつぶされるマークは１個または２個程度ということを前提にしています。中央値を基準にしていますので，すべてのマークが塗りつぶされていたりするとうまく動作しません。

```
### 結果を入れる配列を用意
result = []

### 行ごとの処理(余白行を除いて処理を行う)
for row in range(margin_top, n_row - margin_bottom):

    ### 処理する行だけ切り出す
    tmp_img = img [row*100:(row+1)*100,]
    area_sum = [] # 合計値を入れる配列
    
    ### 各マークの処理
    for col in range(n_col):

        ### NumPyで各マーク領域の画像の合計値を求める
        area_sum.append(np.sum(tmp_img[:,col*100:(col+1)*100]))

    ### 画像領域の合計値が，中央値の3倍以上かどうかで判断
    result.append(area_sum > np.median(area_sum) * 3)
```

### 結果の出力
認識結果を出力してみましょう。念のため，同じ行にマークが複数ないかどうか確認しておきます。

```
for x in range(len(result)):
    res = np.where(result[x]==True)[0]+1
    if len(res)>1:
        print('Q%d: ' % (x+1) +str(res)+ ' ## 複数回答 ##')
    elif len(res)==1:
        print('Q%d: ' % (x+1) +str(res))
    else:
        print('Q%d: ** 未回答 **' % (x+1))
```

```
Q1: [1]
Q2: [2]
Q3: [3]
Q4: [4]
Q5: [5]
Q6: [6]
Q7: [7]
```

正しく認識されました。ここでは複数回答の警告は出ていませんが，認識の閾値を少し下げると，誤認識が増えて複数回答の警告が見られるようになります。

認識の閾値を中央値の２倍にして実行したところ，次のようになりました。

```
Q1: [1]
Q2: [2]
Q3: [3]
Q4: [4 7] ## 複数回答
Q5: [5]
Q6: [6]
Q7: [7]
```

認識の閾値を中央値の50倍にして実行したところ，次のようになりました。

```
Q1: [1]
Q2: ** 未回答 **
Q3: ** 未回答 **
Q4: [4]
Q5: ** 未回答 **
Q6: [6]
Q7: ** 未回答 **
```

## まとめ
マークシート用紙さえきちんと作っておけば，ドキュメントスキャナとの併用で比較的簡単にそこそこ認識力のあるマークシートリーダーが作れます。ここで紹介したのはあくまでサンプルですので，複数のスキャン画像を認識できるような処理にはなっていませんが，フォルダ内の画像ファイルをすべて認識することはそれほど難しくないことだと思います。

なお，マークシートのレイアウトが変わればスクリプトの修正が必要になりますが，自分の環境ではマークシートのレイアウトが変わることはまずないため，最初に数回試して閾値の調整をした後はほとんど修正の必要がありません。実際，自分ではこのOMRにいくつか機能を付加したものを使用して，１週あたり数十〜数百枚という処理を1年以上行っていますが，設定値を変更したのはその間に数回しかありません。