Python OpenCVを使ってマークシートの読み取り作成(うまく読み取りするコツ)

紙のアンケートを仕事で行うことになったため、主にこちらの記事([PythonとOpenCVで簡易OMR（マークシートリーダ）を作る](https://qiita.com/sbtseiji/items/6438ec2bf970d63817b8))を参考に作ってみました。空白行を誤認識することが多かったので、参考にしていただければ幸いです。
※他にもいろいろと参考していますが、それは個別で書いています


# アンケート全体の流れ
1. ExcelでQRコードを埋め込んだアンケートの用紙の作成
    アンケート用紙のページ番号や個人を特定するためにQRコードに情報入れて埋め込みました

2. 印刷して配布します

3. 回収したアンケート用紙をスキャンして、PDF→JPGへ
    ネットにあるフリーの変換サイトで適当に変換しました

4. 変換されたJPGファイルからアンケート結果を読み取り

# 環境 
## 1. アンケート用紙作成
* Python 3.5
* QrCode
* pillow
* Excel

## 2. スキャン
* 職場のスキャナー（複合機）
* PDF =>　JPG変換サイト　例：https://pdftoimage.com/ja/

## 3. マークシート読み取り
* Python 3.5
* OpenCV-Contrib-Python 4.4
* numpy 1.15

# ポイント
##1. アンケート用紙作成時のポイント
 * 位置マーカー(特徴点)のサイズは用紙とプログラム参照用の位置マーカーの画像ファイルはサイズを合わせる
 * アンケート用紙内のマーク位置を等間隔に設置する
 * 上側の位置マーカー(特徴点)はきっちりマーカー位置と定数倍の等間隔になるよう配置する

##2. スキャン時のポイント
 * ファイル名に全角文字を入れない。OpenCVが対応してない。
なんか処理入れれば対応できると思いますが、途中ファイルなので全角入れなきゃいいだけやし、何もしませんでした

##3. マークシート読み取り時のポイント
 * 回答条件（複数選択の有無、未回答の有無）により抽出条件の設定をする
今回は複数選択なし、未回答ありで行いました

#詳細
##1. アンケート用紙作成

### 1. QRコードの作成

今回は設問用紙が複数枚あるため、どの設問用しか、その設問用紙が誰が記載したのかを判別するため、QRコードを設問用紙に埋め込んで読み込み時にその情報ととともに回答をCSVで取り込むことにしました。

- 参考：[Python, Pillow, qrcodeでQRコード画像を生成、保存](https://note.nkmk.me/python-pillow-qrcode/)

```Python
def makeQr(qrMessage, fileName='result.png', filePath='resultQrCode/'):
    """引数qrMessageとなるQRコードを作成し、resultQrCodeに保存する
    Args:
        qrMessage (str): 作るQRコード
        fileName (str, optional): 出力ファイル名. Defaults to 'result.png'.
        filePath (str, optional): 出力ファイルパス　※要末尾に「/」. Defaults to 'resultQrCode/'.
    """
    import qrcode
    import os

    img = qrcode.make(qrMessage)
    if not os.path.isdir(filePath):
        os.makedirs(filePath)
    if not(filePath[-1] == '\\' or filePath[-1] == '/'):
        filePath = filePath + '\\'
    
    img.save(filePath + fileName)
    print('File out:' + filePath + fileName)

if __name__ == '__main__':
    import re
    import sys
    
    args = sys.argv
    if 1 < len(args):
        if re.findall('[/:*?"<>|]', args[1]):
            print('[Error]禁則文字「/:*?"<>|」')
        elif 2 == len(args):
            makeQr(args[1])
        elif re.findall('[:*?"<>|]', args[2]):
            print('[Error]ファイル名に禁則文字「:*?"<>|」')
        elif 3 == len(args):
            makeQr(args[1],args[2])
        elif re.findall('[*?"<>|]', args[3]):
            print('[Error]フォルダ名に禁則文字「*?"<>|」') 
        elif 4 == len(args):
            makeQr(args[1],args[2],args[3])
        else:
            qrMessage = args[1]
            for qrMessageList in args[4:]:
                qrMessage = qrMessage + '\r\n' + qrMessageList
            makeQr(qrMessage,args[2],args[3])
    else:
        print('error: args is one')
        print('usage1: makeQr.exe [QR_Text]')
        print('usage2: makeQr.exe [QR_Text] [Output_FileName]')
        print('usage3: makeQr.exe [QR_Text] [Output_FileName] [Output_FilePath]')
        print('usage4: makeQr.exe [QR_Text] [Output_FileName] [Output_FilePath] [QR_Text_Line2...]')

```
あとで誰でも使えるようにこれを。py2exeでexe化しました。(pyinstallerでもいいですが、重かったのでここでは、py2exeにしました。)
- 参考：[py2exeをpython 3.5 - 3.7で使う](https://note.com/noelrecords/n/n0dc600acf1c0)

###2.マーカーの用意

設問とマークシートを一緒にするため、マークシートの範囲を切り取る。切り取る箇所の四隅に特徴のある白黒画像を用意する。今回はQRコードを使用するため、下図QRコードで使用しているマーカー(赤枠で囲った)は使用できません。また、Excelを使用しているため、図形（オートシェイプ）でテキストとして★にすれば、栗なものとなると判断し、図形として★を用意しました。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/496025/1fa6b854-69c5-d9c4-a786-f8aeffb9e29b.png" width=10%>
解析プログラムにマーカである★を画像ファイルとして渡す必要があるため、ペイントなどでExcelのシェープを張り付けて保存しました。サイズや余白がオートシェイプと同じ必要があるため、一旦縦横幅を最小にしてから張り付けるといいです。

###3. マークの用意
Excelで設問とマークを用意します。ポイントは以下の通り。

* マークするセル及びマーカーの左上を高さ、幅を等間隔にする
* マーカーの左上は高さがはみ出さないようにする
* マーク記号（［a］等）は薄い文字、縦書き

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/496025/87ab9bea-7975-c31d-7f67-1c4184035d5a.png" width=50%>


###4.用紙幅に合わせてマーカーをリサイズとマーカーの貼り付け

Excelなので、印刷を拡大縮小してしまい、画像として保存したマーカーと印刷時のマーカーのサイズが異なり、うまく認識できなくなることがあるため、シートごとに拡大倍率を取得(参照)する必要があります。
-参照[ExecuteExcel4Macro "Page.Setup()"](https://blog.goo.ne.jp/end-u/e/a5b23cefb88514430451d23930c38320)

```VB
Public Function getPrintZoomPer(sheetName As String) As Integer
    Worksheets(sheetName).Activate
    ExecuteExcel4Macro "Page.Setup(,,,,,,,,,,,,{1,#N/A})"
    ExecuteExcel4Macro "Page.Setup(,,,,,,,,,,,,{#N/A,#N/A})"
    getPrintZoomPer = ExecuteExcel4Macro("Get.Document(62)")
End Function
```
どこかのシートにマーカーソースを置き、そのマーカーソースを張り付けたいシートの４角に貼り付ける。マーカー貼り付け時には取得した拡大率を逆数でかける。設問数とか選択肢が可変するので、固定化せずvbaで作りました。

```VB
Public Sub insertMaker(sheetName As String, pasteCellStr As String, _
         printZoomPer As Integer)
    ' sheetName:貼り付け先のシート名
    ' paseteCellStr:貼り付け先のセル文字列　例：A4、B1等
    Dim srcShape As shape
    Set srcShape = Worksheets("sheet1").Shapes("marker") 
    ' sheet1:貼り付け元のマーカーShapeがあるシート名
    ' marker:貼り付け元のマーカーShapenamae名前
    srcShape.Copy
    With Worksheets(sheetName).Pictures.Paste
        .Top = Worksheets(sheetName).Range(pasteCellStr).Top
        .Left = Worksheets(sheetName).Range(pasteCellStr).Left
        .Name = "marker"
        .Width = .Width * 100 / printZoomPer
    End With
End Sub
```
###5.QRコードの挿入
QRコードに「アンケート種類+アンケートページ番号+支店+人の番号+選択肢数+設問数」を入れ作成します。Excelマクロから「1.」で作ったexeファイルをWScript.Shellで呼び出します。

```VB
Public Function makeQr(ByVal QrMessage As String, ByVal fileName As String) As String
    Dim WSH, wExec, sCmd As String
    Set WSH = CreateObject("WScript.Shell")
    
    sCmd = ThisWorkbook.Path & "makeQR.exe " & QrMessage & " " & fileName & " " & _
           ThisWorkbook.Path & "resultQrCode"
    Set wExec = WSH.Exec("%ComSpec% /c " & sCmd)
    
    Do While wExec.Status = 0
        DoEvents
    Loop
    makeQr = wExec.StdOut.readall

    Set wExec = Nothing
    Set WSH = Nothing

End Function
```

いろいろとやって、出来上がったのが下の通り
![Enquete.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/496025/20ed7feb-14c9-ce0a-eea1-3d0633338d65.png)


こんな感じで作ったものを印刷して配布します。

##2. スキャン
アンケート終了後、スキャンを行います。マークシート読み取り側で解像度の設定があるため、200dpi固定に今回はしました。（なお、職場の複合機は直接jpg形式に保存できなかったので、[PDFで保存しPDF =>　JPG変換サイト](https://pdftoimage.com/ja/)で変換しました。
また、マークシート読み取り側でopenCVを使うのですが、ファイル名に全角文字が使えなかったので、注意してください。

##3. マークシート読み取り
###1.QRコードの読み取り

集計したJPGファイルたちを一つのフォルダに入れて、QRコードを読み取り、引数で返します。

```Python
def qrCodeToStr(filePath):
"""QRコードから文字列を読み取る
Args:
    filePath (String): QRコードを含む画像ファイルのパス
Returns:
    String: QRコードを読み取った結果(失敗したnullString)
"""
import cv2

img = cv2.imread(filePath, cv2.IMREAD_GRAYSCALE)
# QRコードデコード
qr = cv2.QRCodeDetector()
data,_,_ = qr.detectAndDecode(img)

if data == '':
    print('[ERROR]' + filePath + 'からQRコードが見つかりませんでした')
else:
    print(data)
return data
```

###2.マークシート読み取り

ここは、こちらの記事([PythonとOpenCVで簡易OMR（マークシートリーダ）を作る](https://qiita.com/sbtseiji/items/6438ec2bf970d63817b8))を参考にしてますので、ほぼ同じです。
少し変えているのは、以下の通りです。

- threshold値をfor文で高いものからループし、うまくいく値を探っている
- 塗っている、塗っていないかの判定を今回は未回答を認め、複数回答を認めていないので、平均値の4倍以上でいったん抽出し、回答が複数あった場合は、最大値の半分以上とした。

```Python
def changeMarkToStr(scanFilePath, n_col, n_row, message):
    """マークシートの読み取り、結果をFalse,Trueの2次元配列で返す
    Args:
        scanFilePath (String): マークシート形式を含むJPEGファイルのパス
        n_col (int): 選択肢の数(列数)
        n_row (int): 設問の数(行数)
    Returns:
        list: マークシートの読み取った結果　False,Trueの2次元配列
    """
    ### n_col = 6 # 1行あたりのマークの数
    ### n_row = 9 # マークの行数
    import numpy as np
    import cv2

    ### マーカーの設定
    marker_dpi = 120 # 画面解像度(マーカーサイズ)
    scan_dpi = 200 # スキャン画像の解像度

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

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

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

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

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

    ## makerの3点から抜き出すのを繰り返す 抜き出すときの条件は以下の通り
    margin_top = 1 # 上余白行数
    margin_bottom = 0 # 下余白行数
 
    for threshold in [0.8, 0.75, 0.7, 0.65, 0.6]:
    
        loc = np.where( res >= threshold)
        mark_area={}
        try:
            mark_area['top_x']= sorted(loc[1])[0]
            mark_area['top_y']= sorted(loc[0])[0]
            mark_area['bottom_x']= sorted(loc[1])[-1]
            mark_area['bottom_y']= sorted(loc[0])[-1]

            topX_error = sorted(loc[1])[1] - sorted(loc[1])[0]
            bottomX_error = sorted(loc[1])[-1] - sorted(loc[1])[-2]
            topY_error = sorted(loc[0])[1] - sorted(loc[0])[0]
            bottomY_error = sorted(loc[0])[-1] - sorted(loc[0])[-2]
            img = img[mark_area['top_y']:mark_area['bottom_y'],mark_area['top_x']:mark_area['bottom_x']]

            if (topX_error < 5 and bottomX_error < 5 and topY_error < 5 and bottomY_error < 5):    
                break
        except:
            continue

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

    n_row = n_row + margin_top + margin_bottom
    img = cv2.resize(img, (n_col*100, n_row*100))

    ### ブラーをかける
    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
    cv2.imwrite('img/res.png',img)

    # マークの認識

    ### 結果を入れる配列を用意
    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]))

        ### 画像領域の合計値が，平均値の4倍以上かどうかで判断
        ### 実際にマークを縫っている場合、4.9倍から6倍　全く塗っていないので3倍があった
        ### 中央値の3倍だと、0が続いたときに使えない
        ressss = (area_sum > np.average(area_sum) * 4)
        # 上記条件だと複数条件を抽出しやすいため、最大値の半分以上を抽出
        if np.sum(ressss == True) > 1:
            ressss = (area_sum > np.max(area_sum) * 0.5)
        result.append(ressss)

    for x in range(len(result)):
        res = np.where(result[x]==True)[0]+1
        if len(res)>1:
            message.append('multi answer:' + str(res))
        elif len(res)==1:
            message.append(res[0])
        else:
            message.append('None')
    message.insert(0,scanFilePath)
    print(message)
    return message
```

自分のメモ用なのでだいぶ走り書きですが、参考になれば