【実践、OpenCV画像処理】SDカードの認識
February 25, 2022
OpenCV 外観検査 python目次
はじめに
おうちにある身近な題材でpython+OpenCVによる外観検査の画像処理を楽しむ本企画。今回はおうちにころがっているSDカードの認識に挑戦してみます。
本記事では、
pythonとOpenCVを使用しますので、本記事を追試する場合は、お手元のpython環境にOpenCVをインストールしておいてください。
$ pip install opencv-python
$ pip install opencv-contrib-python
一口でSDカードの認識と言っても、以下のようなシチュエーションが考えられます。概ね下に行くほど難易度が高くなっています。
- 正立して置かれたSDカードが1枚だけ写った画像からその位置を認識する
- 任意の姿勢で置かれたSDカードが1枚だけ写った画像からその位置と姿勢を認識する
- 複数枚のSDカードがそれぞれ離れて置かれた画像から、それぞれの位置と姿勢を認識する
- 複数枚のSDカードが裏表バラバラに離れて置かれた画像から、それぞれの位置と姿勢と表裏を認識する
- 複数枚のSDカードが互いに接触して置かれた画像から、それぞれの位置と姿勢と表裏を認識する
例えば、一番上の、正立して置かれたSDカードが1枚だけ写った画像からその位置を認識するには、テンプレートマッチングだけで位置を認識できるでしょうから、pythonで数ステップのコードを書くだけで実現できます。サンプルコードもネットで簡単に拾うことができます。
本記事では、無謀にも一番下の、複数枚のSDカードが互いに接触して置かれた画像から、それぞれの位置と姿勢と表裏を認識することに挑戦してみます。人の目には簡単なんですが、コードで実現するにはけっこう大変なんですよ。別にSDカードを認識させることが重要という訳ではないんです。SDカードは単なる素材。
ネットにころがっているOpenCVのサンプルプログラムはシンプルでわかりやすいんですが、それはあくまで限定的な状況でのみ動作するサンプルであり、実用的に使うためにはコードが膨らんでくというところを本記事で実感していただければよろしいかと。
本記事では、このように複数枚のSDカードをスマホで撮った画像から、
SDカードそれぞれの位置と姿勢と表裏を認識していきます。青枠は表面で赤枠は裏面ということで。
本記事では、OpenCVでちまちまとプログラミングして無理矢理SDカードを認識させましたが、こんな旧態然とした方法は興味がないという方は、機械学習でSDカードを認識させる方法についても記事にしていますのでそちらを御覧ください。
画像の台形補正
まずは検査対象をスマホで撮る訳ですが、三脚なんかを使わなければ、どんなに頑張っても検査で使用するには不向きな歪んだ画像しか撮れないと思います。実際の検査ではカメラは固定するでしょうが、おうちでは手持ちですから、撮った画像の歪を取り除き、正面から撮ったように補正する必要があります。
具体的には、カメラ手持ちで撮った画像
これを真上から撮ったように補正します。尚、本記事に掲載する画像は、リンクをクリックするとフル解像度の画像に切り替わりますので、それをローカルにダウンロードすると、記事中のサンプルプログラムで使用可能です。
この補正方法は、本サイトの別記事にまとめています。
検査で使用するために、画像の解像度も一定値になるようにしています。今回はキリのいい1mm=20pixel(508dpi)になるようにしました。この解像度では、SDカードはサイズがW24×H32なんで、画像中では480pixel×640pixelになります。
SDカード領域の抽出
今回の実験では、青色や黒色のSDカードを白い紙の上の置いていますので、しきい値を固定した2値化処理で容易にSDカード領域を抽出することができます。
import cv2
img_orig = cv2.imread('sd_cards_upright.jpg')
img_gray = cv2.cvtColor(img_orig, cv2.COLOR_BGR2GRAY)
img_gray = cv2.medianBlur(img_gray,5)
# 2値化
ret, img_bin = cv2.threshold(img_gray,120,255,cv2.THRESH_BINARY)
img_bin = cv2.bitwise_not(img_bin) # 白黒反転
cv2.imwrite('sd_cards_upright_bin.png', img_bin)
結果はコレ。接しているSDカードは一体となった1つの領域になってしまいます。これだけではSDカード領域が抽出できたことになはりません。
この領域の輪郭線を抽出し輪郭線を囲む正立外接矩形、傾きを考慮した外接矩形を描いてみると、SDカードが互いに接していなければうまく抽出できています。
import cv2
import numpy as np
img_orig = cv2.imread('sd_cards_upright.jpg')
img_bin = cv2.imread('sd_cards_upright_bin.png', 0)
# 輪郭抽出
contours, hierarchy = cv2.findContours(img_bin, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# 輪郭を描画
img_con = img_orig.copy()
for i in range(len(contours)):
# 外接矩形(正立)
x, y, w, h = cv2.boundingRect(contours[i])
cv2.rectangle(img_con, (x, y), (x + w, y + h), (255, 0, 0), 7)
# 外接矩形(傾きを考慮)
rect = cv2.minAreaRect(contours[i])
box = cv2.boxPoints(rect)
box = np.int0(box)
img_con = cv2.drawContours(img_con, [box],0,(0,0,255),7)
cv2.imwrite('sd_cards_contours1.jpg', img_con)
輪郭線を囲む外接矩形
抽出した輪郭線を描いてみると、
import cv2
img_orig = cv2.imread('sd_cards_upright.jpg')
img_bin = cv2.imread('sd_cards_upright_bin.png', 0)
# 輪郭抽出
contours, hierarchy = cv2.findContours(img_bin, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# 輪郭を描画
img_con = img_orig.copy()
for i in range(len(contours)):
img_con = cv2.drawContours(img_con, [contours[i]], 0, (0, 0, 255), 8)
cv2.imwrite('sd_cards_contours2.jpg', img_con)
これで接しているSDカード以外はSDカード領域を抽出することができましたが…
テンプレートマッチングによるSDカードの認識
今回はテンプレートマッチングで画像中の複数のSDカードを見つけていきたいと思います。SDカードの表裏も認識させたいのですが、これは表面のテンプレートと裏面のテンプレートの2枚のテンプレートを用意しそれぞれマッチングを試みれば解決できます。問題なのは、SDカードが常に正立して置かれている訳ではないという点です。通常のテンプレートマッチングでは正立姿勢のテンプレートに対して、被検対象が回転しているとマッチしません。
特徴量マッチングであれば回転している被検対象でもマッチするのですが、SDカード裏面は同じ電極が並んでいる程度しか特徴がなく、表面に至ってはメーカーによって特徴はバラバラで、結局切り欠きくらいしか特徴はありません。特徴量マッチングもなかなか厳しそうです。
まずテンプレート画像を用意します。SDカードの表面と裏面それぞれの正立画像をテンプレートとします。Wikipediaの「SDメモリーカード」の図を頂戴しました。今回の実験は対象画像が508dpiでやってますので、Wikipediaの画像を508dpiにリサイズします。(SDカードのサイズは32mmx24mmなんで、640pixelx480pixelになるようにリサイズします。)
用意した裏面テンプレートがコレ。
表面テンプレートに関してはSDカードの銘柄毎に絵柄が異なるので、絵柄が異なる部分を除外するマスクも一緒に用意します。
表面テンプレートマスク。黒い領域がマッチング計算対象外。
このテンプレートを使用して、マッチングさせてみます。
import cv2
# 画像の読み込み + グレースケール化
img_orig = cv2.imread('./sd_cards_upright.jpg')
img_gray = cv2.cvtColor(img_orig, cv2.COLOR_BGR2GRAY)
# 表面テンプレート
template_front = cv2.imread('sdcard_front_template.png', 0)
mask_front = cv2.imread('./sdcard_front_template_mask.png', 0)
# 裏面テンプレート
template_back = cv2.imread('sdcard_back_template.png', 0)
# 表面テンプレートマッチング
res_front = cv2.matchTemplate(img_gray, template_front, cv2.TM_CCOEFF_NORMED, mask=mask_front)
# 裏面テンプレートマッチング
res_back = cv2.matchTemplate(img_gray, template_back, cv2.TM_CCOEFF_NORMED)
img_result = img_orig.copy()
# 表面マッチング結果矩形描画
minVal, maxVal, minLoc, maxLoc = cv2.minMaxLoc(res_front)
print(maxVal)
tl = maxLoc[0], maxLoc[1]
br = maxLoc[0] + template_front.shape[1], maxLoc[1] + template_front.shape[0]
cv2.rectangle(img_result, tl, br, color=(255, 0, 0), thickness=4)
# 裏面マッチング結果矩形描画
minVal, maxVal, minLoc, maxLoc = cv2.minMaxLoc(res_back)
print(maxVal)
tl = maxLoc[0], maxLoc[1]
br = maxLoc[0] + template_back.shape[1], maxLoc[1] + template_back.shape[0]
cv2.rectangle(img_result, tl, br, color=(0, 0, 255), thickness=4)
cv2.imwrite('./matching_result.jpg', img_result)
表面テンプレートとのマッチングの結果スコア値が最大だった領域が青色の矩形、裏面のスコア値が最大だった領域が赤色の矩形です。スコア値は表面が0.485、裏面が0.672でした。スコア値は1に近いほど一致度が高いことを表していますが、画像が若干回転しているので、あまり高いスコアにはなりませんでした。裏面に至っては誤検出でした。散々な結果です。
元画像を少しづつ回転させて力づくでマッチングさせるようなことも考えられますが、例えば10度おきくらいにやったとしても36倍の時間がかかり処理時間もバカになりません。そこで今回は、SDカードの傾き角を検出しその角度だけ画像を逆回転させ、対象を正立させた上でマッチングさせるという方針で臨みます。
SDカードの傾き角検出と正立回転後のマッチング
ここまで見てきたとおり、テンプレートマッチングでは、被検対象(SDカード)が回転しているとマッチしないのですが、幸いなことに、SDカードの輪郭線は抽出できているので、輪郭線からSDカードが何度回転しているのかを知ることができます。この回転角度を検出し、被検対象画像をこの回転角度分だけ逆方向に回転させてSDカードを正立させてからマッチングさせるとうまくいきそうです。
処理の流れは次のようになります。
抽出した輪郭線をクラスタリングし、クラスタ毎分離したに輪郭線を囲む外接矩形を作成します。
輪郭線を囲む外接矩形毎に画像を切り出します。
それぞれの輪郭線の仰角を求め、切り出した画像を仰角分だけ逆回転させて正立させます。例えば、1個めの外接矩形から切り出した画像は、SDカードの長辺と短辺の輪郭線の仰角から、次の正立画像を生成します。
2個めの外接矩形から切り出した画像は、SDカードが2枚写っているので、仰角は4つ抽出されるので、次の4正立画像を生成します。
それぞれの画像を対象に表裏テンプレートでそれぞれテンプレートマッチングを行い、マッチング結果が最大の領域を抽出します。マッチしなかった画像は仰角が違っていたということで無視。
最後にマッチング領域矩形をアフィン変換し元の画像の座標系に戻します。
以上の手順で、任意の姿勢で置かれたSDカードが裏表の違いも含め認識できました。
テンプレートマッチングは正立画像でしかマッチングできないのですが、正立していない画像でも、輪郭線の仰角をヒントに必要最低限の画像回転で漏れなくマッチングさせることができました。
コードは長めです。座標変換のtrans_coord()あたりはpythonだともっと簡潔に書けるはずなんですが…python遣いでないので断念!
'''
テンプレートマッチングでSDカードを認識
'''
import cv2
import numpy as np
import math
def prepare_obj_image():
'''
認識対象画像読み込みと前画像処理
'''
# 正立画像をグレースケールで読み込み
img_orig = cv2.imread('sd_cards_upright.jpg')
img_gray = cv2.cvtColor(img_orig, cv2.COLOR_BGR2GRAY)
img_gray = cv2.medianBlur(img_gray,5)
# 2値化
ret, img_bin = cv2.threshold(img_gray,120,255,cv2.THRESH_BINARY)
img_bin = cv2.bitwise_not(img_bin) # 白黒反転
#cv2.imwrite('sd_cards_upright_bin.jpg', img_bin)
return img_orig, img_bin
def load_template_images():
'''
SDカード裏表のマッチングテンプレート読み込み
'''
# 表面
template_front = cv2.imread('sdcard_front_template.png', 0)
mask_front = cv2.imread('./sdcard_front_template_mask.png', 0)
# 裏面
template_back = cv2.imread('sdcard_back_template.png', 0)
return template_front, mask_front, template_back
def extract_contours(img_orig, img_bin):
'''
SDカードの輪郭抽出
'''
contours, hierarchy = cv2.findContours(img_bin, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# 輪郭線を間引き
contours = list(map(lambda x: cv2.approxPolyDP(x, 20, True), contours))
return contours
def match_template(img_obj_rot, template_front, mask_front, template_back):
'''
SDカード裏表のテンプレートマッチング
'''
try:
# 表面
res_front = cv2.matchTemplate(img_obj_rot, template_front, cv2.TM_CCOEFF_NORMED, mask=mask_front)
front_min_val, front_max_val, front_min_loc, front_max_loc = cv2.minMaxLoc(res_front)
# 裏面
res_back = cv2.matchTemplate(img_obj_rot, template_back, cv2.TM_CCOEFF_NORMED)
back_min_val, back_max_val, back_min_loc, back_max_loc = cv2.minMaxLoc(res_back)
except:
return 0, 0.0, (0, 0)
#print(front_max_val, back_max_val)
if back_max_val>front_max_val and back_max_val>0.7:
return 2, back_max_val, back_max_loc # 裏面
elif front_max_val>back_max_val and front_max_val>0.7:
return 1, front_max_val, front_max_loc # 表面
return 0, 0.0, (0, 0)
def devide_contours(img_orig, contours, template_front, mask_front, template_back):
'''
閉輪郭線毎に画像を切り出し正立させてテンプレートマッチング
'''
img_gray = cv2.cvtColor(img_orig, cv2.COLOR_BGR2GRAY)
img_obj_ext_rects = img_orig.copy()
img_sdcard_recog = img_orig.copy()
for i in range(len(contours)):
pts = contours[i]
if len(pts)>=4:
# 外接矩形(正立)
x, y, w, h = cv2.boundingRect(contours[i])
cv2.rectangle(img_obj_ext_rects, (x, y), (x + w, y + h), (0, 0, 255), thickness=5)
d = 100
img_obj = img_gray[y-d:y+h+d,x-d:x+w+d]
#cv2.imwrite('sd_card_{}.png'.format(i), img_obj)
height = img_obj.shape[0]
width = img_obj.shape[1]
rot_img_size = int(max(width, height) *1.0)
center = (int(rot_img_size/2), int(rot_img_size/2))
obj_center = (x+int(width/2), y+int(height/2))
matched_objs = MatchedObjs()
for j in range(len(pts)-1):
x0, y0 = pts[j][0]
x1, y1 = pts[j+1][0]
if x0==x1 and (y1-y0)>0:
angle = -90.0
elif x0==x1 and (y1-y0)<0:
angle = 90.0
else:
angle = math.degrees(math.atan(-(y1-y0)/(x1-x0)))
length = math.sqrt((x1-x0)*(x1-x0)+(y1-y0)*(y1-y0))
if length>300:
trans = cv2.getRotationMatrix2D(center, -angle , 1.0)
image_for_rotate = np.zeros((rot_img_size, rot_img_size), np.uint8)
image_for_rotate.fill(img_obj[1,1])
dx = int((rot_img_size - width)/2.0)
dy = int((rot_img_size - height)/2.0)
image_for_rotate[dy:height+dy, dx:width+dx] = img_obj
# 正立させてマッチング
img_obj_rot = cv2.warpAffine(image_for_rotate, trans, (rot_img_size, rot_img_size))
#cv2.imwrite('sd_card_{}_{}_0.png'.format(i, j), img_obj_rot)
face, score, loc = match_template(img_obj_rot, template_front, mask_front, template_back)
if face>=1:
tpts, global_loc = trans_coord(loc, angle, center, d, obj_center, template_front)
matched_objs.add_matched_obj(global_loc, loc, score, face, img_obj_rot, tpts)
# 180度回転して再度マッチング
trans = cv2.getRotationMatrix2D(center, -angle+180.0 , 1.0)
img_obj_rot = cv2.warpAffine(image_for_rotate, trans, (rot_img_size, rot_img_size))
#cv2.imwrite('sd_card_{}_{}_180.png'.format(i, j), img_obj_rot)
face, score, loc = match_template(img_obj_rot, template_front, mask_front, template_back)
if face>=1:
tpts, global_loc = trans_coord(loc, angle-180.0, center, d, obj_center, template_front)
matched_objs.add_matched_obj(global_loc, loc, score, face, img_obj_rot, tpts)
if matched_objs.num_matched>0:
for k in range(matched_objs.num_matched):
print(i, k, matched_objs.matched_faces[k], matched_objs.matched_locs[k], matched_objs.matched_scores[k])
if matched_objs.matched_faces[k]==1:
cv2.polylines(img_sdcard_recog, [matched_objs.matched_pts[k]], True, (255, 0, 0), thickness=7)
elif matched_objs.matched_faces[k]==2:
cv2.polylines(img_sdcard_recog, [matched_objs.matched_pts[k]], True, (0, 0, 255), thickness=7)
cv2.imwrite('sd_card_recog_result.jpg', img_sdcard_recog)
return
class MatchedObjs:
'''
マッチした領域を保持するクラス
'''
def __init__(self):
self.num_matched = 0
self.matched_global_locs = []
self.matched_locs = []
self.matched_scores = []
self.matched_faces = []
self.matched_imgs = []
self.matched_pts = []
def add_matched_obj(self, global_loc, loc, score, face, img, pts):
idx = -1
x0, y0 = global_loc
for i in range(len(self.matched_global_locs)):
x, y = self.matched_global_locs[i]
length = math.sqrt((x-x0)*(x-x0)+(y-y0)*(y-y0))
if length<320:
idx = i
break
if idx==-1:
self.matched_global_locs.append(global_loc)
self.matched_locs.append(loc)
self.matched_scores.append(score)
self.matched_faces.append(face)
self.matched_imgs.append(img)
self.matched_pts.append(pts)
self.num_matched = self.num_matched + 1
elif self.matched_scores[idx]<score:
self.matched_global_locs[idx] = global_loc
self.matched_locs[idx] = loc
self.matched_scores[idx] = score
self.matched_faces[idx] = face
self.matched_imgs[idx] = img
self.matched_pts[idx] = pts
return self.num_matched
def trans_coord(locs, angle, center, d, obj_center, img_template):
'''
正立画像のマッチング矩形をアフィン変換して正立前の矩形に戻す
'''
x1, y1 = locs # top-left
x1 = x1 - center[0]
y1 = y1 - center[1]
w, h = img_template.shape[::-1]
x3, y3 = (x1+w, y1+h)
theta = math.radians(angle)
mx1 = int( x1*math.cos(theta) + y1*math.sin(theta) + obj_center[0] -d)
my1 = int(-x1*math.sin(theta) + y1*math.cos(theta) + obj_center[1] -d)
mx2 = int( x1*math.cos(theta) + y3*math.sin(theta) + obj_center[0] -d)
my2 = int(-x1*math.sin(theta) + y3*math.cos(theta) + obj_center[1] -d)
mx3 = int( x3*math.cos(theta) + y3*math.sin(theta) + obj_center[0] -d)
my3 = int(-x3*math.sin(theta) + y3*math.cos(theta) + obj_center[1] -d)
mx4 = int( x3*math.cos(theta) + y1*math.sin(theta) + obj_center[0] -d)
my4 = int(-x3*math.sin(theta) + y1*math.cos(theta) + obj_center[1] -d)
pts = np.array(((mx1, my1), (mx2, my2), (mx3, my3), (mx4, my4)))
xc = (x1 + x3)/2.0
yc = (y1 + y3)/2.0
mxc = int( xc*math.cos(theta) + yc*math.sin(theta) + obj_center[0] -d)
myc = int(-xc*math.sin(theta) + yc*math.cos(theta) + obj_center[1] -d)
return pts, (mxc, myc)
def main():
template_front, mask_front, template_back = load_template_images()
img_orig, img_bin = prepare_obj_image()
contours = extract_contours(img_orig, img_bin)
devide_contours(img_orig, contours, template_front, mask_front, template_back)
if __name__ == '__main__':
main()
このコードは、今回使用した画像でSDカードの認識が出来たというだけで、一般的な対象が回転していてもテンプレートマッチングができるようになった訳ではありません。
先にも述べましたが、対象が回転している場合は、テンプレートマッチングではなく特徴量マッチングを検討すべきなのですが、今回の対象のSDカードは特徴点も少なく、銘柄が異なっていてもマッチさせたいという特殊な案件でしたので、特徴量マッチングは馴染みませんでした。そこで、無理矢理テンプレートマッチングを適用してみました。
本記事を通して言いたいことは、OpenCVのサンプルプログラムはシンプルでわかりやすいんですが、それはあくまで限定的な状況でのみ動作するサンプルであり、実用的に使うためにはコードが膨らんでくというと。これを本記事で実感していただけたと思います。
別の画像でもやってみました。ただし認識スコアのしきい値は変えています。1個認識に失敗していますが、これは表面全面に絵柄が描かれているからマッチングスコアが低いという理由もありますが、そもそも今回のコードでは同じ傾きのSDカードが複数個接している場合は、スコアが最も高い1個しかマッチしないためです。
Halconの想い出
ここまでやってみて、だいぶ昔に触ったHalconを思い出してしまいました。
OpenCVでは正立対象をマッチさせるだけのテンプレートマッチングだと、
result_image = cv2.matchTemplate(obj_image, template_image, cv2.TM_CCOEFF_NORMED)
front_min_val, front_max_val, front_min_loc, front_max_loc = cv2.minMaxLoc(result_image)
これだけのコードで結果が得られますが、対象の姿勢が変化したり対象そのものが変形や変色していたりするとうまくマッチせず、無理矢理やってコードを膨らましたり、特徴量マッチングを併用したりと試行錯誤の繰り返しになってしまいます。
ところが、記憶がおぼろげでコードは覚えていないのですが、Halconを使うとテンプレート画像を用意して、マッチングの関数を呼び出すだけで、対象が回転していようが、変形していようが、色合いが変わったり白黒反転していようが、すべて見つけてくれます。しかも超高速に。例えば律儀にテンプレートマッチングをさせると数秒かかるような画像サイズでも、Halconのマッチングなら一瞬でした。
これはどうやって実現しているのかと想像を巡らして、
- 画像の解像度を落として高速にテンプレートマッチングをして当たりをつけたものの中から詳細に見ている
- 特徴量マッチングを併用している
- 何通りかの方法でエッジを抽出してエッジの頂点を特徴点として特徴量マッチングをしている
要は優れた秘密のマッチングアルゴリズムがある訳でなく、いろいろなアルゴリズムの合せ技で実現しているのではないかと思った次第。たとえそうだったとしても、どういうテンプレートでどういう対象画像のときどのアルゴリズムを使うかの判断は内部で勝手にやっている訳で、神業とも思えます。テンプレートマッチング1つとっても恐るべしHalconなのです。
Halconはpython+opencvのようなスクリプト言語であり、HDelelopと呼ばれるよく出来た開発環境もありました。pythonで言うとJupyterやSpyderのような統合開発環境です。HDelelopは画像処理に特化していて使い勝手が良かった。
ただ、お値段も半端なく、開発環境が100万円近くして、ランタイムライブラリでも数万円~数十万円だったような。高価ではありますが、その性能を考えると、例えばなんちゃって画像処理プログラマーでも2ヶ月も雇うと軽く100万円くらい飛んじゃうし、なんちゃって画像処理プログラマーがHalcon並の画像処理プログラムが作れる訳でもないんで、そう考えると100万円のHalconはむしろお安いのかも。製品に載せる画像処理なら数万円~数十万円ランタイムライブラリ代払っても元を取れるには相当高価な製品でなければダメでしょうが、生産ラインの監視とか1点ものの画像処理システムならopencvでチマチマやるより思い切ってHalcon使ったほうが結局安くつきそう。
個人だと高価すぎて気軽に試せないっていうのが難点ですが、会社だったら作ったスクリプトがセーブできないだけでフル機能が使えるHDelelop評価版が貰えるハズなんで、それで試せます。