##この記事について
飲食店のテーブルなどに置いてあるアンケート用紙。そのアンケートの集計はお店のスタッフがExcelなどの表計算ツールに打ち込んで集計していたりします。こういう非効率な作業を自動化できないかと思って、OCRに関する知識が全くない状態でアンケート用紙を読み取る簡易的なシステムを作って見ました。この記事はその備忘録です。
##システム概要
作成するシステムはざっくり以下のような流れを想定しています。
1.記入済みのアンケートを撮影する
2.撮影されたアンケート画像に対してOCR処理を行う
3.OCRによる読み取り結果を出力する
##アンケートの記入形式
リッカート尺度に基づく11段階評価と5段階評価による記入形式で
該当する数字にマルをつけてもらう形式です。
##OCRの仕組み
アンケート用紙のOCR処理は下記のような仕組みにしました。
① 傾き補正を行う
② 11点尺度&5点尺度の各アンケート項目を画像として切り抜く
③ノイズ処理を行う(平滑化)
④ 切り抜いた画像に対してあらかじめディープラーニング(CNN:Convolutional Neural Network)で学習した画像認識モデルを利用してアンケート項目の記入数値の判定処理を行う
⑤ 判定結果(集計データ)をDBに保存する
##傾き補正
撮影されたアンケート画像が傾いていると、後工程で記入数値の判定を行うモデルを構築する際にその傾きも考慮しておく必要があります。しかし開発環境のリソースは限られていたため、前処理として傾き補正を行なっておく必要がありました。傾き補正は射影変換を用いました。
###射影変換とは
射影変換とはある平面を別の平面に射影することができる変換手法です。例えば、斜めから見たものを、あたかも正面から見たように表現することができます。
どのような仕組みかというと
まず下記のように4点の座標を、それぞれの別の座標に移動させるとします。
それぞれの移動先の座標は移動前の座標を用いて行列で以下のように表すことができます。
上記の変換行列(3×3)は連立方程式によって求めることができます。この変換行列を用いてすべての座標を移動させると移動前の元画像のオブジェクトの形状を維持したまま、まるで違う向きから見たような表示が可能になります。
今回は傾き補正を具体的に以下の手順で行いました。
- アンケート用紙の四隅に"○"マークを記載しておきます。
- OpenCVの
cv2.HoughCircles()
関数を使って円検出を行い、それぞれの座標を取得します。 - 2の座標を元に射影変換を行います。
###1. アンケート用紙の四隅に"○"マークを記載
傾き補正では対象のオブジェクトの輪郭検出を行なった後に頂点の座標を取得する処理がよく行われますが、今回はあらかじめアンケート用紙の四隅に記載した"○"マークを頂点の代わりに利用します。
###2. 円検出
OpenCVのcv2.HoughCircles()
関数を使って四隅の円検出を行います。
アンケートは該当数字にペンでマルをつける記入形式を想定しているので、そのマルを誤検出されないように意図的に画像にぼかしを入れます。ぼかしを入れることで細い線は検出されずらくなります。
#画像を読み込む
img = cv2.imread(image_path, cv2.IMREAD_COLOR)
#画像にぼかしを入れる
gray_image = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
blur_image = cv2.GaussianBlur(gray_image, (29, 29), 0)
cv2.GaussianBlur()
は指定の画像に対してぼかしを入れる関数になります。第2引数はぼかし処理に使うフィルタのサイズ指定になり、この値が大きければ大きいほどぼかしの度合いは強くなります。
この状態で円検出を行うことで四隅のマルを検出されやすくします。
#アンケート用紙の四隅にある円を検出させる
circles = cv2.HoughCircles(blur_image, cv2.HOUGH_GRADIENT, 1, 2400, param1=30, param2=40, minRadius=15, maxRadius=100)
以下、cv2.HoughCircles()
の引数の詳細です。
値 | 内容 | |
---|---|---|
第一引数 | blur_image | 対象画像。 |
第二引数 | cv2.HOUGH_GRADIENT | 変換手法。現在、選択できる手法は cv2.HOUGH_GRADIENT のみ。 |
第三引数 | dp=1 | 処理するときに元画像の解像度を落として検出する場合は増やす。元画像のままなら1を指定。 |
第四引数 | minDist=2400 | 検出される円同士が最低限離れていなければならない距離。 |
第五引数 | param1=30 | 値が低いほどより多くのエッジを検出できるらしいです。 |
第六引数 | param2=30 | 円の中心を検出する際の閾値。低い値だと誤検出が増え、高い値だと未検出が増える。 |
第七引数 | minRadius=15 | 検出する円の半径の下限値。 |
第八引数 | maxRadius=100 | 検出する円の半径の上限値。 |
今回は円検出の精度を上げるためパラメータの数値を変化させながら4つの円が検出されるまで繰り返し処理を行いました。
minDistList = [2600, 2400, 2200]
param1List = [30, 20, 10]
param2List = [40, 30, 20]
for minDist in minDistList:
for param1 in param1List:
for param2 in param2List:
circles = cv2.HoughCircles(blur_image, cv2.HOUGH_GRADIENT, 1, minDist, param1=param1, param2=param2, minRadius=15, maxRadius=100)
if len(circles) == 4:
break
else:
continue
break
else:
continue
break
返値circles
には検出された円の座標と半径が配列として入ります。
[[[ 238.5 3613.5 56. ]
[ 484.5 241.5 54.5]
[2651.5 3717.5 52. ]
[2843.5 465.5 51. ]]]
この状態では各座標が四隅のどの"○"マークの座標かわからないため整理します。
#画像を読み込む
img = cv2.imread(image_path, cv2.IMREAD_COLOR)
#画像ピクセルサイズ取得
h, w, c = img.shape
#画像の横幅に対する高さ比率を取得
height_rate = round((h / w), 2)
#画像の中心座標を求める
center_point = {"x" : round((w / 2), 2), "y" : round((h / 2), 2)}
#cv2.HoughCircles()によって検出された円のx,y座標を取得する
def get_circle_coordinate(circles):
circle_list = []
for circle in circles[0]:
circle_list.append([circle[0], circle[1]])
return circle_list
#画像の中心を軸として右上、右下、左上、左下それぞれの円の座標を確認する
def sort_coordinate(circle_list, center_point):
for i in range(0, 4):
if circle_list[i][0] > center_point["x"] and circle_list[i][1] > center_point["y"]:
upper_right = [circle_list[i][0],circle_list[i][1]]
elif circle_list[i][0] > center_point["x"] and circle_list[i][1] < center_point["y"]:
under_right = [circle_list[i][0],circle_list[i][1]]
elif circle_list[i][0] < center_point["x"] and circle_list[i][1] > center_point["y"]:
upper_left = [circle_list[i][0],circle_list[i][1]]
elif circle_list[i][0] < center_point["x"] and circle_list[i][1] < center_point["y"]:
under_left = [circle_list[i][0],circle_list[i][1]]
return { "upper_right" : upper_right, "under_right" : under_right, "upper_left" : upper_left, "under_left" : under_left }
circle_list = get_circle_coordinate(circles)
circle_list = sort_coordinate(circle_list)
###3. 射影変換
円検出によって四隅の"○"マークの座標が取得できたはずなので、先ほど説明した射影変換を行います。
変換後の出力画像は横幅3000に固定し、縦幅は元画像のアスペクト比により算出しました。
変換行列はcv2.getPerspectiveTransform()
によって生成し、その変換行列を使ってcv2.warpPerspective()
によって射影変換を行なっています。
#取得した円座標を元に射影変換を行う
def perspective_transform(circle_list, height_rate):
height_value = round(3000 * height_rate)
before_pts = np.float32([circle_list["upper_right"], circle_list["under_right"], circle_list["under_left"], circle_list["upper_left"]])
after_pts = np.float32([[3000, height_value], [3000, 0], [0, 0], [0, height_value]])
M = cv2.getPerspectiveTransform(before_pts, after_pts)
dst = cv2.warpPerspective(img, M, (3000, height_value))
return dst
dst = perspective_transform(circle_list, height_rate)
ここまでの実装コードをまとめたものが下記になります。
import cv2
import numpy as np
#cv2.HoughCircles()によって検出された円のx,y座標を取得する
def get_circle_coordinate(circles):
circle_list = []
for circle in circles[0]:
circle_list.append([circle[0], circle[1]])
return circle_list
#画像の中心を軸として右上、右下、左上、左下それぞれの円の座標を確認する
def sort_coordinate(circle_list, center_point):
for i in range(0, 4):
if circle_list[i][0] > center_point["x"] and circle_list[i][1] > center_point["y"]: #右上: x >1000 and y>1000
upper_right = [circle_list[i][0],circle_list[i][1]]
elif circle_list[i][0] > center_point["x"] and circle_list[i][1] < center_point["y"]: #右下: x >1000 and y<1000
under_right = [circle_list[i][0],circle_list[i][1]]
elif circle_list[i][0] < center_point["x"] and circle_list[i][1] > center_point["y"]: #左上: x <1000 and y>1000
upper_left = [circle_list[i][0],circle_list[i][1]]
elif circle_list[i][0] < center_point["x"] and circle_list[i][1] < center_point["y"]: #左下: x <1000 and y<1000
under_left = [circle_list[i][0],circle_list[i][1]]
return { "upper_right" : upper_right, "under_right" : under_right, "upper_left" : upper_left, "under_left" : under_left }
#取得した円座標を元に射影変換を行う
def perspective_transform(circle_list, height_rate):
height_value = round(3000 * height_rate)
before_pts = np.float32([circle_list["upper_right"], circle_list["under_right"], circle_list["under_left"], circle_list["upper_left"]])
after_pts = np.float32([[3000, height_value], [3000, 0], [0, 0], [0, height_value]])
M = cv2.getPerspectiveTransform(before_pts, after_pts)
dst = cv2.warpPerspective(img, M, (3000, height_value))
return dst
#画像サイズ取得(imageは最初にノイズ処理&二値化した画像)
h, w, c = image.shape
#画像の横幅に対する高さ比率を取得
height_rate = round((h / w), 2)
#画像の中心座標を求める
center_point = {"x" : round((w / 2), 2), "y" : round((h / 2), 2)}
#画像にぼかしを入れる
gray_image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
blur_image = cv2.GaussianBlur(gray_image, (47, 47), 0)
#アンケート用紙の四隅にある円を検出させる
minDistList = [2600, 2400, 2200]
param1List = [30, 20, 10]
param2List = [40, 30, 20]
for minDist in minDistList:
for param1 in param1List:
for param2 in param2List:
circles = cv2.HoughCircles(blur_image, cv2.HOUGH_GRADIENT, 1, minDist, param1=param1, param2=param2, minRadius=15, maxRadius=100)
if len(circles) == 4:
break
else:
continue
break
else:
continue
break
if len(circles[0]) != 4 or isinstance(circles,type(None)) == True:
error = '検出エラー'
else:
#検出した4つの円の座標を取得
circle_list = get_circle_coordinate(circles)
#右上,右下,左上,左下それぞれの座標を取得
circle_list = sort_coordinate(circle_list, center_point)
#射影変換による傾き補正
dst = perspective_transform(circle_list, height_rate)
これで傾き補正ができました。
##物体検出
用紙に記載されている各アンケート項目をそれぞれ別画像として切り抜きます。
OpenCVでは特定のオブジェクトを検出する学習器を作成することができます。そこで、各アンケート項目を物体検出することを検討しましたが、大量のサンプルデータが必要であり、検出する際の矩形のサイズのバラ付きがありそうだったため他の方法を検討しました。
採用した方法は以下のような内容です。
- "■"マークをアンケート項目の左側に記載
- "■"マークを検出するための学習器を作成
- "■"マークを物体検出
- 検出した"■"マークを始点として、アンケート項目を切り抜き画像として保存
###1. "■"マークをアンケート項目の左側に記載
"■"マークをアンケート項目の左側に記載し、このマークを物体検出の対象オブジェクトとします。
###2. "■"マークを検出するための学習器を作成
OpenCVでは特定のオブジェクトを検出するカスケード分類器を作成することができます。今回は"■"マークを検出するカスケード分類器を作成しました。
####カスケード分類器とは
物体検出を行う際には、検出したい物体がどのような特徴を有しているのか把握しておく必要があります。そこで、あらかじめ該当する物体を含む画像と含まない画像を用意し、検出したい物体の特徴を抽出しておきます。この抽出された特徴をまとめたものをカスケード分類器と言います。
カスケード分類器の作り方については下記の記事を参照
###3. "■"マークを物体検出
2で作成したカスケード分類器を使って、"■"マークを物体検出します。
import cv2
import numpy as np
import os
#アンケートの項目数
Q_NUMBERS = 7
#物体候補となる矩形が、最低でも含んでいなければならない近傍矩形の数(値が小さいほど誤検出は増え、大きいほど検出漏れが増える)
MIN_NEIGHBORS = [1, 2, 3, 4, 5]
#カスケード分類器を読み込む
Cascade = cv2.CascadeClassifier('cascade.xml')
#傾き補正した画像からアンケート項目を検出する
def detect_questionnaire_topic(dst):
gray_image = cv2.cvtColor(dst, cv2.COLOR_BGR2RGB)
results = False
for min_neightbor in MIN_NEIGHBORS:
point = Cascade.detectMultiScale(gray_image, 1.1, min_neightbor)
if len(point) == Q_NUMBERS:
results = True
break
return results, point
results, point = detect_questionnaire_topic(dst) #引数の"dst"は傾き補正済みの画像
上記のコードを実行させると下図のように"■"マークが検出されます(赤枠の矩形)。
また、変数point
には検出したオブジェクト("■"マーク)の座標が配列で保存されます。ここでいうx座標とy座標は矩形の左上の頂点の座標を指します。
#[x座標, y座標, 横幅, 縦幅]
[[ 91 531 76 76]
[ 96 930 76 76]
[ 92 1479 76 76]
[ 93 1875 74 74]
[ 96 2272 71 71]
[ 96 2667 74 74]
[ 93 3061 73 73]]
###4. 検出した"■"マークを始点としてアンケート項目を切り抜き、画像として保存
3で検出した"■"マークを始点としてアンケート項目を切り抜きます。
始点および縦幅、横幅以下のように設定しました。
項目 | 内容 |
---|---|
始点 | x座標 : "■"マークのx座標 + "■"マークの横幅 y座標 : "■"マークのy座標 + "■"マークの縦幅 |
縦幅 | "■"マークの縦幅 × 4 |
横幅 | 始点から画像の右端まで |
切り抜き方としてはあまり綺麗ではありませんが、リソースが限られていたため、今回はこちらの方法で進めます。
#検出した"■"マークの縦幅を取得
def get_square_height(point):
h_list = [int(p[3]) for p in point]
return min(h_list) * 4
#切り出す画像をy座標を元に昇順に並び替え
def sort_img(img_y_list):
q_img_list = []
for k, v in sorted(img_y_list.items()):
q_img_list.append(str(v))
return q_img_list
#検出した"■"マークを始点にアンケート項目を別画像として切り出す関数
def export_questionnaire_topic_img(dst, point):
q_img_dict = {}
square_height = get_square_height(point)
for i, p in enumerate(point):
save_path = 'images/q_img_'+str(i)+'.jpg'
imgs = dst[p[1] + p[3] : p[1] + square_height, p[0] + p[2] : 3000]
cv2.imwrite(save_path, imgs)
q_img_dict[p[1]] = save_path
return q_img_dict
q_img_dict = export_questionnaire_topic_img(dst, point)
q_img_list = sort_img(q_img_dict)
q_img_dict
は切り出した画像の始点のy座標と画像ファイルのパスが入っています。
{531: 'images/q_img_0.jpg', 930: 'images/q_img_1.jpg', 1479: 'images/q_img_2.jpg', 1875: 'images/q_img_3.jpg', 2272: 'images/q_img_4.jpg', 2667: 'images/q_img_5.jpg', 3061: 'images/q_img_6.jpg'}
この連想配列をsort_img()によってy座標を軸に並び替えます。そして、画像パスの配列q_img_list
を作っておきます。
['images/q_img_0.jpg', 'images/q_img_1.jpg', 'images/q_img_2.jpg', 'images/q_img_3.jpg', 'images/q_img_4.jpg', 'images/q_img_5.jpg', 'images/q_img_6.jpg']
これで各アンケート項目を別画像として切り抜くことができました。
##ノイズ除去&二値化
切り出した画像に対してノイズ除去と二値化を行います。
元々はこれらの処理は考慮していませんでした。しかし、CNNによる数値判定モデルを作成する際に、開発環境でメモリ不足に陥ってしまったため、白黒の二値画像かつノイズを除去したものに学習データを絞ることでメモリ負荷を軽減させる必要が出てきました。それが契機でノイズ除去&二値化を加えました。
ノイズ除去にはモルフォロジー変換という手法を使います。
###モルフォロジー変換
モルフォロジー変換とは、画像中のオブジェクトに対して、「収束」や「膨張」を行うことでオブジェクトの周りあるいはオブジェクトの中に含まれているノイズを除去する手法です。この手法には「オープン処理」「クロージング処理」「モルフォロジー勾配処理」など複数のモードがありますが、今回は収束させたあとに膨張させる処理を行う「オープン処理」を採用しました。(※モルフォロジー変換は、二値画像を対象とするため、最初に対象画像の二値化を行なっておく必要があります。)
####収束
画像に対してカーネル(フィルタ)をスライドさせていきます。画像中の(1か0のどちらかの値を持つ)画素は、カーネルの領域に含まれる画素の画素値が全て1(白)であれば1(白)となり,そうでなければ0(黒)として出力されます。
例えば下記のような二値化された画像があるとします。中央にある白のブロックの周辺に散らばる小さな白色のブロックをノイズだと仮定します。この画像にフィルタ(サイズは3×3とします)をスライドさせて「収束」を行います。
スライドさせた結果、一部のブロックが白から黒に変わり、ノイズが除去されます。
ノイズは除去されましたが、中央の白のブロックは1回り小さくなってしまいました。これを膨張によって元の大きさに近づけます。
####膨張
収束と同じように画像に対してカーネル(フィルタ)をスライドさせていきます。ただし、収束とは逆でカーネルの領域に含まれる画素の画素値が全て0(黒)であれば0(黒)となり,そうでなければ1(白)として出力されます。
スライドさせた結果、一部のブロックが黒から白に変わり、収縮された中央のブロックが元の大きさに近づきます。
このようにモノフォロジー変換(収束→膨張)を行うことで画像内のノイズを除去することができます。
以下が実装コードになります。物体検出で切り出した各アンケートの画像(q_img_list
)に対して二値化とノイズ除去を行なってきます。
#二値化しノイズ除去を行う
def image_thresholding(img_path):
img = cv2.imread(img_path, cv2.IMREAD_COLOR)
#画像を二値化する
gray_image = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
gray_image = cv2.GaussianBlur(gray_image, (9,9), 0)
binary_image = cv2.adaptiveThreshold(gray_image, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 19, 2)
#白黒反転させる
invgray = cv2.bitwise_not(binary_image)
#ノイズ除去を行うフィルターサイズの設定
kernel = np.ones((4,4),np.uint8)
#ノイズ除去処理
none_noiz_img = cv2.morphologyEx(invgray, cv2.MORPH_OPEN, kernel)
#再度反転
image = cv2.bitwise_not(none_noiz_img)
return image
for img_path in q_img_list:
image = image_thresholding(img_path)
cv2.imwrite(img_path, image)
これで画像を白と黒に二値化しノイズ除去もできました。
ここからは、切り出した画像に対してディープラーニングで構築した記入数値判定モデルを使った画像認識を行なっていきます。