OpenCVによる寸法測定


+ この記事は[OpenCV Advent Calendar 2021](https://qiita.com/advent-calendar/2021/opencv)の22日目の記事です。
+ ひょんなことから現場で使える寸法測定装置を作ることになりました。試行錯誤の末、何とか形になったので備忘録を兼ねて記事にします。

長い記事になってしまったのでざっくりまとめます。

:::note info
ARマーカーで検査面を作ることでカメラの位置決め作業を省略しました。
測定サンプルの形状、回転の影響を受けない方法を模索しました。
測定精度はテストピースにおいて75±1.5mmまで上げる事が出来ました。
:::

# まとめ

寸法測定結果は下図のようになります。
右下の赤枠が測定結果で75mm角のサンプルに対し
幅　：76.3mm
高さ：76.0mm
の結果となりました。
リアルタイム測定のため、ノイズによって75±1.5mm程度誤差が見られました。

![キャプチャ24.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/853365/3850c771-dd96-33d2-573d-6b21820b170a.png)
# はじめに
検査対象について説明します。

+ 検査対象はA4サイズのシート状の製品です。
+ 測定精度の目標値は±1mm、予算は一台当たり10万円以下を想定しました。
+ 製品は網目状になっています。
+ 人の手での搬送を想定して流れ方向における製品の傾きはバラバラとしました。
+ 検証用テストピースとしてパワポで7.5cmに印刷したサンプルを作りました。

テストピース①
![キャプチャ.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/853365/2141de76-70ae-ccf0-3943-de13867b8d6e.jpeg)
テストピース②（製品想定）
![キャプチャ25.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/853365/ec9f2732-fd61-459c-31b2-d40c188d22f6.png)

# 環境

今回はPCにUSBカメラを繋いで実行しました。（将来的にはラズパイで実装予定）

+ OS：windows10
+ python：3.6.8
+ numpy：1.17.5 
+ opencv-contrib-python：4.1.2.30
+ USBカメラ　130万画素　Cマウント／CSマウント仕様 
+ 低歪レンズ

# 参考にしたもの

「OpenCV 寸法測定」でググれば一発かと思いましたが意外と出てきませんでした。
検索した中でも以下の記事が大変参考になりました。

https://www.pyimagesearch.com/2016/03/28/measuring-size-of-objects-in-an-image-with-opencv/

英文ですが、高めの精度で寸法測定を行っている記事です。
概ねこの記事を基礎に寸法測定のプログラムを作っています。
寸法の精度ばらつきについて、記事内では以下のように述べています。

①精度低下の原因の一つは写真の歪みです。鳥瞰図のような真上からのアングルで撮影しないと、被写体の大きさが歪んで見えることがあります。
②カメラのキャリブレーションを行わないとレンズの半径方向と接線方向の歪みが発生して精度が悪くなります。

キャリブレーションはチェスボードを使えば可能ですが、現場で逐一キャリブレーションすると手間になりそうです。
細かい作業が多いと現場は使ってくれないので、キャリブレーションしないで出来るように以下の記事のARマーカーを検査面にする方法を導入しました。

https://qiita.com/code0327/items/c6e468da7007734c897f

OpenCVのArUcoというモジュールでカメラから得られた画像を真上から見たように変換する、というものです。
また、ARマーカーで囲った面を検査面にすることで実寸を合わせる作業の簡略化が出来ると考えました。

#装置概略
ごちゃごちゃしてますが、こんな感じのデモ機を作りました。
カメラの位置や向きはテキトーです。
![装置図.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/853365/cdea4843-a9c3-2d71-19fd-a48c0de9a215.jpeg)
ARマーカーが囲んだ面が検査面になっておりこの中に測定対象を置くと寸法が測定されます。
座標の位置関係は下図です。「縦」「横」は実寸で正確に採寸する必要があります。
今回はパワポでマーカーを印刷して使いましたが、実寸が正確にとれる場合、マーカーをラベルにしてしまえば間隔は自由です。

![図1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/853365/6b9f7102-8766-1875-0b7f-460eb870f688.png)


#エッジ検出について

今回は
①グレースケール変換
②ガウシアンフィルタ
③二値化
の流れでエッジ検出をしました。

    # グレースケール変換
    tmp = cv2.cvtColor(tmp, cv2.COLOR_BGR2GRAY)
    cv2.imshow('gray',tmp)

    # ぼかし処理
    tmp = cv2.GaussianBlur(tmp, (5, 5), 0)
    cv2.imshow('blur',tmp)

    # 二値化処理
    gamma = cv2.getTrackbarPos('threshold','binary')
    th = cv2.getTrackbarPos('threshold','binary')
    _,tmp = cv2.threshold(tmp,th,255,cv2.THRESH_BINARY_INV)
    cv2.imshow('binary',tmp)

    # 輪郭検出
    contours, hierarchy = cv2.findContours(tmp, cv2.RETR_EXTERNAL,cv2.CHAIN_APPROX_SIMPLE)
    rect = cv2.minAreaRect(contours[0])

Cannyを使う手も考えました。
この場合、サンプル①は以下のようになります。

![キャプチャ12.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/853365/14fa4667-1606-5f30-3990-2eabbd310d4e.png)

一方網目状のサンプル②は網目のエッジを拾ってごちゃごちゃしています。
(わかりにくかったので別途網目を広くしたサンプルを作って撮影しました)

![キャプチャ13.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/853365/82657113-3c75-1eff-1191-4bae570915ed.png)

もちろん網目のエッジを消す方法もあるのですが、今回はガウシアンフィルタ→二値化で網目を消すことにしました。
Cannyの閾値がいまいち調整しにくかったというのが本音です。
（面倒くさい調整は現場が嫌がります）

#傾きの影響について

傾いているサンプルの幅と高さを測るのであれば``cv2.minAreaRect``で良いと考えていました。

サンプルを画面に対し水平に置いた場合、一見成功しているように見えます。

![キャプチャ19.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/853365/7bb9540c-f737-fd13-2814-89decda250e0.png)

ところが、サンプルを傾けた途端に寸法が全く合わなくなってしまいました。

![キャプチャ20.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/853365/ad56e204-1128-8a70-a0a7-88d3f3b61a5b.png)

この時、回転角が60°で幅が75mm→42.6mmで1/√3となっていることから回転の影響によるものと推測しました。

傾きの影響をなくすため以下のような処置を行いました。

①初めに検査面(tmp)を作成します。

    p_dict = aruco.getPredefinedDictionary(aruco.DICT_4X4_50) # ArUcoマーカーのdict取得(50ピクセル)
    corners, ids, rejectedImgPoints = aruco.detectMarkers(img, p_dict) # カメラ画像からArUcoマーカー検出

    # 時計回りで左上から順に表示画像の座標をmに格納
    m = np.empty((4,2)) # [x,y]の配列を4点分
    corners2 = [np.empty((1,4,2))]*4
    for i,c in zip(ids.ravel(), corners):
      corners2[i] = c.copy()
    m[0] = corners2[0][0][2] # マーカー0の右下
    m[1] = corners2[1][0][3] # マーカー1の左下
    m[2] = corners2[2][0][0] # マーカー2の左上
    m[3] = corners2[3][0][1] # マーカー3の右上

    width, height = (x_dis*size,y_dis*size) # 変形後画像サイズ
    x_ratio = width/x_dis
    y_ratio = height/y_dis
    marker_coordinates = np.float32(m)
    true_coordinates   = np.float32([[0,0],[width,0],[width,height],[0,height]]) 
    mat = cv2.getPerspectiveTransform(marker_coordinates,true_coordinates) # 画像サイズを任意の大きさに合わせる
    img_trans = cv2.warpPerspective(img,mat,(width, height))
    tmp = img_trans.copy()

![図3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/853365/1c56adcd-c140-e221-2762-03a768255068.png)

②検査面(tmp)のエッジを検出し、``cv2.minAreaRect``で外接矩形を作成します。
ここで得られる返り値は下図になります。

    # グレースケール変換
    tmp = cv2.cvtColor(tmp, cv2.COLOR_BGR2GRAY)
    cv2.imshow('gray',tmp)

    # ぼかし処理
    tmp = cv2.GaussianBlur(tmp, (blur, blur), 0)
    cv2.imshow('blur',tmp)

    # 二値化処理
    gamma = cv2.getTrackbarPos('threshold','binary')
    th = cv2.getTrackbarPos('threshold','binary')
    _,tmp = cv2.threshold(tmp,th,255,cv2.THRESH_BINARY_INV)
    cv2.imshow('binary',tmp)

    # 輪郭検出
    contours, hierarchy = cv2.findContours(tmp, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    rect = cv2.minAreaRect(contours[0])
    box = cv2.boxPoints(rect)
    box = np.int0(box)

![図4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/853365/ed700aa7-aa51-142a-7022-d69b1b269ead.png)

③``cv2.connectedComponentsWithStats``で矩形の中心を取得します。
先の回転角と矩形の中心を使って``cv2.warpAffine``でアフィン変換により検査面自体を回転させます。
これで傾きの補正ができました。

    n, img_label, data, center = cv2.connectedComponentsWithStats(tmp)
    x, y = box[0]
    center =int(center[0][0]),int(center[0][1])
    angle = rect[2]
    scale = 1.0
    mat = cv2.getRotationMatrix2D(center, angle , scale)
    img_trans = cv2.warpAffine(img_trans, mat , (width,height)) #アフィン変換

![図5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/853365/4487cabe-273b-3ea3-f144-46858c7b3120.png)
④視野外にマスクをかけて白くします。

    color_lower = np.array([0, 0, 0]) # 抽出する色の下限(BGR形式)
    color_upper = np.array([0, 0, 0]) # 抽出する色の上限(BGR形式)
    img_mask = cv2.inRange(img_trans, color_lower, color_upper) # 範囲からマスク画像を作成
    img_trans = cv2.bitwise_not(img_trans, img_trans, mask=img_mask) # 元画像とマスク画像の演算(背景を白くする)
    img_trans_mesure = img_trans.copy()
    img_trans = cv2.cvtColor(img_trans, cv2.COLOR_BGR2GRAY)
![図6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/853365/d621b5ee-b250-7ad1-1529-2a56fc3029e2.png)
⑤最後に傾けた検査面で再度②～③の処理を行います。
ここでは傾きの影響は排除したので通常の外接矩形``cv2.boundingRect``を使って幅、高さを取得します。

![図7.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/853365/588bd715-60d4-7605-5bfe-91335aedd63a.png)

サンプルを傾けても測定が可能になりました！

![キャプチャ18.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/853365/b21ac0ed-d79f-3c7b-0546-056ab95c4aa0.png)

![キャプチャ17.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/853365/5b57826d-0ffe-e4b1-4d37-e6d9ea3e30c8.png)

#つまづいたところ

検査面のサイズを決めるところで実寸(x_dis, y_dis)を等倍しているのですが、
ここを任意の値にしていてかなりハマっていました。

    width, height = (x_dis*size,y_dis*size) # 変形後画像サイズ

これも傾けるとわかるのですが、

![キャプチャ15.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/853365/f0427e6a-7fb3-6340-9000-08008d9a16c7.png)

![キャプチャ16.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/853365/ff5511b3-0da6-fd87-4560-2a5a1a4c32ef.png)

検査面のサイズを等倍にしないと傾けた際サンプルが平行四辺形になります。

#プログラム全文

```dimension_measurement.py
    import cv2
    import numpy as np

    # パラメータ
    blur = 11 # ぼかし
    x_dis, y_dis = (145,110) # ARマーカー間の実寸
    size= 3 # 表示画像サイズ＝ARマーカー間の実寸×size
    th = 130 # 閾値の初期値

    cap = cv2.VideoCapture(0) # カメラ番号取得
    aruco = cv2.aruco

    def nothing(x):
        pass

    cv2.namedWindow('binary')
    cv2.createTrackbar('threshold','binary', th , 256, nothing)

    while True:
        try:
            ret, img = cap.read() # 戻り値 = ,カメラ画像
            p_dict = aruco.getPredefinedDictionary(aruco.DICT_4X4_50) # ArUcoマーカーのdict取得(50ピクセル)
            corners, ids, rejectedImgPoints = aruco.detectMarkers(img, p_dict) # カメラ画像からArUcoマーカー検出
                                               
            # 時計回りで左上から順に表示画像の座標をmに格納
            m = np.empty((4,2)) # [x,y]の配列を4点分
            corners2 = [np.empty((1,4,2))]*4
            for i,c in zip(ids.ravel(), corners):
              corners2[i] = c.copy()
            m[0] = corners2[0][0][2] # マーカー0の右下
            m[1] = corners2[1][0][3] # マーカー1の左下
            m[2] = corners2[2][0][0] # マーカー2の左上
            m[3] = corners2[3][0][1] # マーカー3の右上

            width, height = (x_dis*size,y_dis*size) # 変形後画像サイズ
            x_ratio = width/x_dis
            y_ratio = height/y_dis

            marker_coordinates = np.float32(m)
            true_coordinates   = np.float32([[0,0],[width,0],[width,height],[0,height]]) 
            mat = cv2.getPerspectiveTransform(marker_coordinates,true_coordinates) # 画像サイズを任意の大きさに合わせる
            img_trans = cv2.warpPerspective(img,mat,(width, height))
            tmp = img_trans.copy()

            # グレースケール変換
            tmp = cv2.cvtColor(tmp, cv2.COLOR_BGR2GRAY)
            cv2.imshow('gray',tmp)

            # ぼかし処理
            tmp = cv2.GaussianBlur(tmp, (blur, blur), 0)
            cv2.imshow('blur',tmp)

            # 二値化処理
            gamma = cv2.getTrackbarPos('threshold','binary')
            th = cv2.getTrackbarPos('threshold','binary')
            _,tmp = cv2.threshold(tmp,th,255,cv2.THRESH_BINARY_INV)
            cv2.imshow('binary',tmp)
            
            # 輪郭検出
            contours, hierarchy = cv2.findContours(tmp, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
            rect = cv2.minAreaRect(contours[0])
            box = cv2.boxPoints(rect)
            box = np.int0(box)
            n, img_label, data, center = cv2.connectedComponentsWithStats(tmp)

            x, y = box[0]
            center =int(center[0][0]),int(center[0][1])
            angle = rect[2]
            scale = 1.0
            mat = cv2.getRotationMatrix2D(center, angle , scale)
            img_trans = cv2.warpAffine(img_trans, mat , (width,height)) #アフィン変換
            
            color_lower = np.array([0, 0, 0]) # 抽出する色の下限(BGR形式)
            color_upper = np.array([0, 0, 0]) # 抽出する色の上限(BGR形式)
            img_mask = cv2.inRange(img_trans, color_lower, color_upper) # 範囲からマスク画像を作成
            img_trans = cv2.bitwise_not(img_trans, img_trans, mask=img_mask) # 元画像とマスク画像の演算(背景を白くする)
            img_trans_mesure = img_trans.copy()
            img_trans = cv2.cvtColor(img_trans, cv2.COLOR_BGR2GRAY)

            # ぼかし処理
            img_trans = cv2.GaussianBlur(img_trans, (blur, blur), 0)

            # 二値化処理
            gamma = cv2.getTrackbarPos('threshold','binary')
            th = cv2.getTrackbarPos('threshold','binary')
            _,img_trans = cv2.threshold(img_trans,th,255,cv2.THRESH_BINARY_INV)
            contours, hierarchy = cv2.findContours(img_trans, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
            x, y, w, h = cv2.boundingRect(contours[0])
            img_trans_mesure = cv2.rectangle(img_trans_mesure, (x, y), (x+w, y+h), (0, 0, 255), 2)
            
            cv2.putText(img_trans_mesure, "width={:.1f}mm".format(w/x_ratio),(int(0), int(30)), cv2.FONT_HERSHEY_SIMPLEX,0.65, (0, 0, 255), 2)
            cv2.putText(img_trans_mesure, "hight={:.1f}mm".format(h/y_ratio),(int(0), int(50)), cv2.FONT_HERSHEY_SIMPLEX,0.65, (0, 0, 255), 2)
            
            cv2.imshow('raw',img)
            cv2.imshow('image',img_trans_mesure)
            print(w/x_ratio,h/y_ratio)
            
            key = cv2.waitKey(1)
            if key == ord("q"):
                    break

        # ARマーカーが隠れた場合のエラーを無視する
        except ValueError:
            print("ValueError")
        except IndexError:
            print("IndexError")
        except AttributeError:
            print("AttributeError")

    cv2.destroyAllWindows()

```

#おわりに

実は投稿直前までサンプルが平行四辺形になるところでハマっていました。
なんとか形にはなりましたが、検査精度±1.0mmの目標が達成できなかったことが心残りです。

現在GaussianBlurを弄ったりCannyを使ったり更なる高精度化を検証しています。
高精度化が出来れば将来的にはラズパイ＋カメラモジュールの組み合わせで1万円以下での実装を目指したいと思います。

#参考

https://python-academia.com/opencv-aruco/

https://www.macnica.co.jp/business/semiconductor/articles/nvidia/136335/