生産現場IoTへの挑戦 #09　～Raspberry PiとUSBカメラで外観検査装置を作る　後編～

# 1.はじめに

[前編](https://qiita.com/airpocket/items/493fd7fffdf2604f6c4c)でカメラパラメータの確認をしましたので、いよいよ外観検査を実行します。


# 2.実行結果

実行結果は次の様になります。
左上のimageが撮影画像に形状認識結果を上書きした画像、左下は2値化画像、右下が外形認識及び判定結果画像、右上が判定のベースとなるテンプレート画像です。この写真ではOK判定されています。
![Concat30.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/496179/c40457ac-5dc2-0972-614d-e17a600812db.jpeg)

この画像はNG判定の例です、右下の判定結果にdifferという値が表示されていますが、これは輪郭形状の類似度判定です。この値は差異が大きくなるほど大きな値を示し、0.0719はまあまあ似ている程度の数値です。この数値だけではバラツキが大きく判定ができません。
今回のプログラムでは輪郭形状の類似度でおおよその形の判定を行い、さらに凹み欠陥を検出することでNG判定をしています。左上の輪郭形状認識結果の画面に赤い点が表示されている部分が製品に凹み欠陥があることを示しています。
![Concat34.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/496179/2044bca9-9313-d21a-ee90-935ac0609548.jpeg)

このワークの場合、1.5×0.5mm程度のかなり小さな欠損ですが、ワークの向きを変えても精度良く検出できています。

![Concat31.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/496179/ad6309da-6176-b2b0-012f-fcfe9acb9bd2.jpeg)
![Concat32.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/496179/2a1d7746-4922-9e2e-a837-929b92dc4fe1.jpeg)
![Concat33.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/496179/9d4bf1c8-78cc-f6ca-ca44-c7bda28d8e38.jpeg)
![Concat35.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/496179/991443ff-5445-e846-3e9b-67cbff0f4a4a.jpeg)



# 2.プログラムのポイント

プログラムは少々長いので最後に掲載しています。

# 2.1.カメラ設定

[前編で解説した](https://qiita.com/airpocket/items/493fd7fffdf2604f6c4c)カメラの露出及びホワイトバランスのマニュアル設定を行っています。
カメラ解像度は1280x720としましたが、見たいワークやその欠陥の大きさにより適宜変更します。

```python
#カメラの露出、ホワイトバランスを固定
os.system('v4l2-ctl -d /dev/video0 -c exposure_auto=1 -c exposure_absolute=300')
os.system('v4l2-ctl -d /dev/video0 -c white_balance_temperature_auto=0')
os.system('v4l2-ctl -d /dev/video0 -c white_balance_temperature=4600')

#カメラ解像度指定。カメラによって使える解像度は異なる。
WIDTH  = 1280  #320/640/800/1024/1280/1920
HEIGHT = 720   #240/480/600/ 576/ 720/1080

```

# 2.2.カメラのキャリブレーション

取得した画像は、カメラのレンズの特性により歪みを受けています。画像の歪みは写っている場所により異なりますので、ワークの場所が画像のどこに写っているかによってワークの輪郭も歪み、判定誤差の要因となります。

キャリブレーションにはカメラ固有の係数が必要となります。詳しくはこちらのサイトを参考にしてください。
http://labs.eecs.tottori-u.ac.jp/sd/Member/oyamada/OpenCV/html/py_tutorials/py_calib3d/py_calibration/py_calibration.html
https://qiita.com/ReoNagai/items/5da95dea149c66ddbbdd

ここでは得られた係数を変数mtxとdistに入力して補正しています。

```python
#camera歪みキャリブレーション USBカメラ、1280×720サイズで調整
#カメラ解像度、カメラ変更の場合はcameratestUndist01.pyで係数測定が必要
#https://qiita.com/ReoNagai/items/5da95dea149c66ddbbdd

mtx  = np.array([[4.88537936e+03,0.0,6.24772642e+02],[0.0,4.84481440e+03,3.54634299e+02],[0.0,0.0,1.0]])
dist = np.array([9.40016759e+00,-7.86181791e+02,3.50155397e-02,-6.47058512e-02,2.08359575e+04])

alpha = 1
newKK, roiSize = cv2.getOptimalNewCameraMatrix(mtx, dist, (WIDTH,HEIGHT), alpha, (WIDTH,HEIGHT))
mapX, mapY = cv2.initUndistortRectifyMap(mtx, dist, None, newKK, (WIDTH,HEIGHT), cv2.CV_32FC1)
```
# 2.3.画像処理、判定用しきい値設定

画像処理や合否判定に用いるしきい値類です。一部の閾値はキーボードから変更可能です。

```python
#しきい値設定
th1 = 300               #cannyフィルタ用しきい値　u/jで+/-
th2 = 1000              #cannyフィルタ用しきい値  i/kで+/-
ksize = 1               #median blur用しきい値 -1でフィルタ不使用 y/hで+/-
binTh = 100             #binary処理用しきい値  o/lで+/-
maxValue = 255          #binary処理用しきい値
differTh = 0.10         #類似度判定のしきい値  t/gで+/-
dentNum = 4             #OK品の凹形状数
nomalDepthTh = 16000    #OK品の凹深さしきい値
ngDepthTh = 500         #NG品の凹深さしきい値
waitCycle = 3           #カメラ安定までの待ちサイクル数
counter = waitCycle + 1 #待ちサイクル値の初期化
```

# 2.4.前処理
判定を行う前に、入力画像を前処理します。
前処理はpreprocess()で実行しています。
元画像(frame)を入力し、各段階の処理済み画像を返します。

```python
#画像前処理
frame,gray,binary,edge = preprocess(frame)
```

前処理は次の5段階でエッジ抽出を行います。
①レンズによる歪みを補正
②グレイ画像に変換
③cv2.medianBlur()でノイズ低減
④cv2.threshold()で二値化
⑤cv2.Canny()でエッジ抽出


# 2.5.輪郭データ作成

輪郭データ作成はcontours()内のcv2.findContours()関数で実行しています。
Cannyで得られた輪郭画像から、輪郭データを作成しています。複数の輪郭データが得られますが、もっとも面積の大きい輪郭をワークの輪郭として採用しています（正確には最も大きな面積の外接矩形を持つ輪郭です）。輪郭データであるcntと、エッジ情報を追加したframe画像、輪郭データを画像化したcimgを返しています。

```python()
#輪郭データ作成
cnt,frame,cimg = contours(frame,edge)
```

# 2.6.輪郭データによる合否判定

judgeShape()内のcv2.matchShapes()関数で、輪郭データの類似度を判定しています。
この関数はサイズや回転による形状変化をキャンセルできる仕様ですが、レンズ歪みなどの影響により必ずしも安定した数値を返してくれませんのでおおよその形状マッチのみ行います。ある程度製品形状がにている（極端に大きな欠損がない）ことを設定したしきい値をもとに判定しています。

```python()
#エッジデータの類似度判定
shapeResult,match = judgeShape(tempCnt,cnt)
```
# 2.7.凹み形状のデータにより欠陥を探す

judgeDent()内のcv2.convexHull()関数で輪郭形状の外接多角形を描画します。
さらにcv2.convexityDefects()により、ワーク輪郭形状が外接多角形からへこんでいる部位とその凹み量をを計算します。
OK品に存在する凹み形状の数(dentNum)と、その目安となるしきい値(normalDepthTh)をあらかじめ設定しておき、NGとする凹みの大きさ(ngDepthTh)を超える深さの凹みがある場合、NG判定とします。

```python
#凹みデータによる判定
dentResult,frame = judgeDent(cnt,dentNum,nomalDepthTh,ngDepthTh)
```

# 2.8.総合判定

最終的な合否判定は輪郭データと凹み形状のデータの両者の組み合わせで判定しています。
輪郭データを用いておおまかな形状の類似を判定したうえで凹みデータで小さな欠損を検出しています。
当初は輪郭データのみを用いて形状判定を行おうと考えていましたが、matchShapes()だけでは異なるワークや大きな欠損は検出できるものの、測定値のばらつきが大きく小さな欠損は検出できませんでした。convexityDefects()は、小さな凹みも精度よく測定できるものの数値化できるのは欠損の数と大きさのみのため、全く異なる形状のワークでも合格としてしまう可能性があります。この両者の判定を組み合わせることで高い検出精度を確保しました。


```python
#総合判定
comprehensiveResult,color = comprehensiveJudge(shapeResult,dentResult)
```

# 3.使い方

1.プログラムをraspberry Piの任意のフォルダに保存します。
2.同じフォルダにTemplate.jpgというファイルを保存します。最初はどんな画像でもOKです。
3.プログラムを実行します。
4.ワークがimage画像の中央に表示される位置に調整し、カメラのフォーカスを合わせます。
5.oとrで画像の2値化のしきい値を変更します。ワークの外形がはっきり判別できる状態に調整してください。
　ワーク以外の反射光などが含まれていても問題ありません。
　ただしワークが一番大きな画像になるようにしきい値と照明を調節してください。
6.ワーク外形を検出すると、へこみを認識した箇所が赤もしくは青の丸で表示されます。良品を置いて検出された凹みの数を数えてdentNumに入れてください。凹み形状があるにも関わらず検出を示す〇印が表示されていない場合はngDepthThを小さくしてください。この際、検出したngDepthTh
7.良品の凹みの深さの閾値nomalDepthThを徐々に小さくして、良品の凹み検出の印が青○になるようにします。
8.良品の外形が判別できる状態でPを押すとテンプレートを保存します。
　Template画像がワークの外形を正しく抽出できていることを確認してください。
9.カメラ前に検査したいワークを置きます。

ワークの向きの違いは吸収できる設計ですが、テンプレートと同じ位置、同じ向き、同じ大きさに撮影した方が検出精度がアップします。

出力はOK/NG/NDの3水準です。NDはワークの輪郭が正確にとれていない可能性がある場合などです。

# 4.プログラム

```python

# ワークの輪郭を抽出して、テンプレートとの類似度を計算。しきい値以上の類似度の場合はNGとする
# ワークの凹み形状を数値化し、規定の凹み形状よりも多くの凹みが検出された場合は、NGと判断する
# 
#
# t/g　     類似度合否判定のしきい値
# y/h       median blurのしきい値
# u/j       th1
# i/k       th2
# o/l       binTh
# p         テンプレート更新
# ESC       プログラム終了


import cv2
import numpy as np
from PIL import Image as im
import time
import os

#カメラの露出、ホワイトバランスを固定
os.system('v4l2-ctl -d /dev/video0 -c exposure_auto=1 -c exposure_absolute=300')
os.system('v4l2-ctl -d /dev/video0 -c white_balance_temperature_auto=0')
os.system('v4l2-ctl -d /dev/video0 -c white_balance_temperature=4600')

#カメラ解像度指定。カメラによって使える解像度は異なる。
WIDTH  = 1280  #320/640/800/1024/1280/1920
HEIGHT = 720   #240/480/600/ 576/ 720/1080

#camera歪みキャリブレーション USBカメラ、1280×720サイズで調整
#カメラ解像度、カメラ変更の場合はcameratestUndist01.pyで係数測定が必要
#https://qiita.com/ReoNagai/items/5da95dea149c66ddbbdd

mtx  = np.array([[4.88537936e+03,0.0,6.24772642e+02],[0.0,4.84481440e+03,3.54634299e+02],[0.0,0.0,1.0]])
dist = np.array([9.40016759e+00,-7.86181791e+02,3.50155397e-02,-6.47058512e-02,2.08359575e+04])

alpha = 1
newKK, roiSize = cv2.getOptimalNewCameraMatrix(mtx, dist, (WIDTH,HEIGHT), alpha, (WIDTH,HEIGHT))
mapX, mapY = cv2.initUndistortRectifyMap(mtx, dist, None, newKK, (WIDTH,HEIGHT), cv2.CV_32FC1)

#しきい値設定
th1 = 300               #cannyフィルタ用しきい値
th2 = 1000              #cannyフィルタ用しきい値
ksize = 1               #median blur用しきい値 -1でフィルタ不使用
binTh = 100             #binary処理用しきい値
maxValue = 255          #binary処理用しきい値
differTh = 0.10         #類似度判定のしきい値
dentNum = 4             #OK品の凹形状数
normalDepthTh = 16000    #OK品の凹深さしきい値
ngDepthTh = 500         #NG品の凹深さしきい値
waitCycle = 5           #カメラ安定までの待ちサイクル数
counter = waitCycle + 1 #待ちサイクル値の初期化

#描画設定
drawCnt = True
drawDent = True

#カメラ設定
capture = cv2.VideoCapture(0)
capture.set(cv2.CAP_PROP_FRAME_WIDTH, WIDTH)
capture.set(cv2.CAP_PROP_FRAME_HEIGHT, HEIGHT)
#capture.set(cv2.CAP_PROP_EXPOSURE,-100.0)

if capture.isOpened() is False:
    raise IOError

def makeTemplateContours():     #OK品テンプレートの輪郭データ作成

    #テンプレート画像読み込み、サイズ変更
    template = cv2.imread("./Template.jpg")
    template = cv2.resize(template, (WIDTH, HEIGHT))
    #テンプレート画像の輪郭抽出（Cannyフィルタ）
    edgeTemp = cv2.Canny(template,threshold1=th1,threshold2=th2)
    #輪郭線データを取得
    contours, hierarchy = cv2.findContours(edgeTemp,cv2.RETR_TREE,cv2.CHAIN_APPROX_SIMPLE)

    maxRect = 0
    cntNum = 0
    for i in range(len(contours)):
        x,y,w,h = cv2.boundingRect(contours[i])
        if (w * h) > maxRect:
            maxRect = w * h
            cntNum = i
    
    #黒背景作成
    cimg = np.zeros((edgeTemp.shape[0],edgeTemp.shape[1],3))

    try:        #輪郭データを得られた場合は、輪郭画像を描画、輪郭データと輪郭画像を返す
        cimg = cv2.drawContours(cimg, contours, cntNum, (255,255,255), 1)
        return(contours[cntNum],cimg)
    except:     #輪郭データを得られなかった場合
        print("no contours.")
        return(False, cimg)

def preprocess(frame):  #画像の前処理。

    #カメラ画像の歪補正処理
    undistFrame = cv2.remap(frame, mapX, mapY, cv2.INTER_LINEAR)
    
    #カメラ画像をグレースケールに変換
    gray = cv2.cvtColor(undistFrame,cv2.COLOR_BGR2GRAY)

    #medidan blurでノイズ除去
    if ksize >= 1:
        gray = cv2.medianBlur(gray,ksize)
    
    #画像を二値化
    th, binary = cv2.threshold(gray, binTh, maxValue, cv2.THRESH_BINARY)

    edge = cv2.Canny(binary,threshold1=th1,threshold2=th2)

    return(undistFrame,gray,binary,edge)

def contours(frame,edge):             #輪郭データ作成

    #輪郭線データを取得
    contours, hierarchy = cv2.findContours(edge,cv2.RETR_TREE,cv2.CHAIN_APPROX_SIMPLE)
    
    #輪郭線データのうち、もっとも大きな外接矩形を持つものを選択    
    maxRect = 0
    cntNum = 0
    for i in range(len(contours)):
        x,y,w,h = cv2.boundingRect(contours[i])
        if (w * h) > maxRect:
            maxRect = w * h
            cntNum = i
    
    #黒背景作成
    cimg = np.zeros((edge.shape[0],edge.shape[1],3))

    try:        #輪郭データを得られた場合は、輪郭画像を描画、輪郭データと輪郭画像を返す

        cimg = cv2.drawContours(cimg, contours, cntNum, (255,255,255), 1)
        if drawCnt is True:
            frame = cv2.drawContours(frame, contours, cntNum, (0,255,0), 3)
        return(contours[cntNum],frame,cimg)
    except:     #輪郭データを得られなかった場合
        print("no contours.")
        return(False, frame,cimg)

def judgeShape(tempCnt,cnt):        #マッチシェイプでの判定

    if cnt is not False:            #画像データの輪郭データを取得できている場合
        match = cv2.matchShapes(tempCnt, cnt, cv2.CONTOURS_MATCH_I1, 0)
        if match < differTh:        #類似度がしきい値以下ならOK
            shapeResult = "OK"
            color = (255,0,0)
        else:                       #類似度がしきい値以上ならNG
            shapeResult = "NG"
            color = (0,0,255)
    else:                           #輪郭データが取得できていない場合は合否判別なし
        match = 0
        shapeResult = "ND"
        color = (255,255,255)

    return(shapeResult,match)

def judgeDent(cnt,dentNum,normalDepthTh,ngDepthTh):         #凹形状により判定する
    if cnt is not False:                                    #画像データの輪郭データを取得できている場合
        hull = cv2.convexHull(cnt,returnPoints = False)     #外接多角形を規定する

        try:
            defects = cv2.convexityDefects(cnt,hull)        #凹み形状のリストを取得

            if defects is not None:
                depthList = []                              #凹みの深さリスト初期化
                for i in range(defects.shape[0]):
                    s,e,f,d = defects[i,0]
                    depthList.append(d)                     #凹みの深さリスト追加
                    start = tuple(cnt[s][0])
                    end = tuple(cnt[e][0])
                    far = tuple(cnt[f][0])
                    if drawDent == True:                                #凹み形状をframeに上書き
                        cv2.line(frame,start,end,[0,255,0],2)           #外接多角形を描画

                        if d > normalDepthTh:                            #正しい凹み点を描画
                            cv2.circle(frame,far,5,[255,0,0],-1)
                        elif d <= normalDepthTh and d> ngDepthTh:        #異常な凹み点を描画
                            cv2.circle(frame,far,5,[0,0,255],-1)    

                depthList = sorted(depthList, reverse = True)           #凹み深さを降順にソート

                if len(depthList) < dentNum:                            #凹み形状の数が正規の数より少ない場合はND
                    dentResult = "ND"
                elif len(depthList) == dentNum:                         #凹み形状の数が正規の数と等しい場合
                    if depthList[dentNum - 1] > normalDepthTh:
                        dentResult = "OK"                               #最も小さい凹みの深さが既定値以上であればOk
                    else:
                        dentResult = "ND"                               #最も小さい凹みの深さが既定値以下であればNG
                else:                                                   #凹み形状の数が正規の数より大きい場合
                    if depthList[dentNum - 1] > normalDepthTh:           #正規の凹みの最も小さいものが、既定値よりも大きくて
                        if depthList[dentNum] < ngDepthTh:              #正規の凹みの数の次の凹みがNG判定の凹みより小さければOK
                            dentResult = "OK"
                        else:                                           #正規の凹みの数の次の凹みがNG判定の凹みより大きければNG
                            dentResult = "NG"
                    else:                                               #世紀の凹みの最も小さいものが、既定値よりも小さければND
                        dentResult = "ND"
                return(dentResult,frame)
            else:
                dentResult = "ND"
                return(dentResult,frame)

        except cv2.error:
#            print("******cnvexityDefects error catched!")
            dentResult = "ND"
            return(dentResult,frame)

    dentResult = "--"
    print("dentJudgement miss")
    return(dentResult,frame)

def comprehensiveJudge(shapeResult,dentResult):

    comprehensiveResult = "ND"
    if dentResult == "ND":
        comprehnsiveResult = "ND"
        textColor = (255,255,255)

    elif dentResult != "ND":
        if shapeResult == "OK" and dentResult == "OK":
            comprehensiveResult = "OK"
            textColor = (255,0,0)

        else:
            comprehensiveResult = "NG"
            textColor = (0,0,255)
#    print("Comprehensive result = ", comprehensiveResult)
    return(comprehensiveResult,textColor)

def changeThre(key):    #しきい値変更処理
    global th1,th2,binTh,ksize,differTh

    if key == ord("u"): 
        th1 += 10
        print("th1 = ",th1)
    elif key == ord("j"):
        th1 -= 10
        print("th1 = ",th1)
    elif key == ord("i"):
        th2 += 10
        print("th2 = ",th2)
    elif key == ord("k"):
        th2 -= 10
        print("th2 = ",th2)
    elif key == ord("o"):
        binTh += 10
        print("binTh = ",binTh)
    elif key == ord("l"):
        binTh -= 10
        print("binTh = ",binTh)
    elif key == ord("y"):
        ksize += 2
        print("ksize = ",ksize)
    elif key == ord("h"):
        ksize -= 2
        if ksize <= -1:
            ksize = -1
        print("ksize = ",ksize)
    elif key == ord("t"):
        differTh = round(differTh + 0.001,4)
        print("differTh = ",differTh)
    elif key == ord("g"):
        differTh = round(differTh - 0.001,4)
        print("differTh = ",differTh)

#テンプレートの輪郭データを取得
tempCnt,tempCimg = makeTemplateContours()

while True:
    #カメラ画像取得
    ret, frame = capture.read()

    if ret is False:
        raise IOError

    #画像前処理
    frame,gray,binary,edge = preprocess(frame)

    #輪郭データ作成
    cnt,frame,cimg = contours(frame,edge)

    #エッジデータの類似度判定
    shapeResult,match = judgeShape(tempCnt,cnt)

    #凹みデータによる判定
    dentResult,frame = judgeDent(cnt,dentNum,nomalDepthTh,ngDepthTh)

    #総合判定
    comprehensiveResult,color = comprehensiveJudge(shapeResult,dentResult)


    #表示画像に画像ラベル記入
    cv2.putText(tempCimg,"Template",(30,90),cv2.FONT_HERSHEY_SIMPLEX,3.0,(0,255,0),2,cv2.LINE_AA)
    cv2.putText(cimg,comprehensiveResult + " / differ = " + "{:.4f}".format(match),(30,90),cv2.FONT_HERSHEY_SIMPLEX,3.0,color,2,cv2.LINE_AA)
    cv2.putText(frame,"image",(30,90),cv2.FONT_HERSHEY_SIMPLEX,3.0,(0,255,0),2,cv2.LINE_AA)   

    #グレー画像、2値化画像、輪郭画像、テンプレート画像を結合して表示
    binary = cv2.cvtColor(binary,cv2.COLOR_GRAY2RGB)
    concat1 = cv2.vconcat([frame,binary]).astype('uint8')
#    concat1 = cv2.cvtColor(cv2.vconcat([gray,binary]),cv2.COLOR_GRAY2RGB)
    concat2 = cv2.vconcat([tempCimg,cimg]).astype('uint8')
    concat3 = cv2.hconcat([concat1,concat2])
    concat3 = cv2.resize(concat3 , (int(concat3.shape[1]*0.5), int(concat3.shape[0]*0.5)))
    cv2.imshow('result',concat3)

    #キー入力の処理　ESC：終了　　P：テンプレート更新
    key = cv2.waitKey(1)
    if key == 27: #ESC
        break
    elif key == ord("p"):
        cv2.imwrite("Template.jpg",binary)
        tempCnt,tempCimg = makeTemplateContours()
    elif key == ord(";"):
        cv2.imwrite("Concat3.jpg",concat3)
    elif key == ord("r"):
        counter = 0

    if counter < waitCycle:
        counter += 1
    elif counter == waitCycle:
        if comprehensiveResult != "ND":
            print("Comprehensive result = ", comprehensiveResult)
        else:
            print("please adjust camera setting")
        counter += 1

    #キー入力の処理　しきい値変更
    changeThre(key)

capture.release()
cv2.destroyAllWindows()
```

# 5.まとめ
今回は、opencvを用いた外観検査装置を作ってみました。
覚書レベルなので、余裕があればもう少し解説を加える、、、かもしれません。