モーダルを閉じる工作HardwareHub ロゴ画像

工作HardwareHubは、ロボット工作や電子工作に関する情報やモノが行き交うコミュニティサイトです。さらに詳しく

利用規約プライバシーポリシー に同意したうえでログインしてください。

複数カメラを用いた奥行きマップの作成 (OpenCV)

モーダルを閉じる

ステッカーを選択してください

お支払い手続きへ
モーダルを閉じる

お支払い内容をご確認ください

購入商品
」ステッカーの表示権
メッセージ
料金
(税込)
決済方法
GooglePayマーク
決済プラットフォーム
確認事項

利用規約をご確認のうえお支払いください

※カード情報はGoogleアカウント内に保存されます。本サイトやStripeには保存されません

※記事の執筆者は購入者のユーザー名を知ることができます

※購入後のキャンセルはできません

作成日作成日
2020/02/16
最終更新最終更新
2024/08/29
記事区分記事区分
一般公開

目次

    仕事では Redisを使ったキャッシュ設計や運用を担当

    特徴点が分かっている既知の物体については、単一カメラを用いて位置姿勢が推定できます。物体や全体のシーンが未知である場合は、例えばステレオ形式のカメラ二つを用いることで同様の結果を得ます。具体的には、距離情報を濃淡として保存した距離画像 (Depth Map; 奥行きマップ) を作成します。

    エピポーラ幾何について

    三次元空間の点を二次元画像に投影する際に失われる情報として「カメラから点までの距離」があります。この深さ情報は、複数のカメラから同じ点が含まれる画像を取得することで計算できます。

    Epipolar Geometry

    ピンホールモデルのカメラが左右に二つある状況を考えます。左側のカメラの画像平面だけであれば点 xx の実際の深さが分かりませんが、右側のカメラの画像平面内に対応する一つの点 xx' を見つけることができれば、二つのカメラの物理的な位置関係をもとに幾何的に深さ情報が計算できます。左側のカメラと右側のカメラそれぞれについてキャリブレーションを行うことが可能であれば、この物理的な位置関係は後述のとおりキャリブレーション結果の外部パラメータから計算できます。

    対応する点 xx' は特徴量をマッチングすることで見つけます。上図から明らかなように、対応する点 xx' を探索する範囲は右側の画像平面すべてではなく、線分 ll' だけでよいことが分かります。これをエピポーラ拘束とよびます。このような線分 ll' を見つけることができれば信頼性と計算効率のどちらも高まります。

    • 線分 ll' を点 xx の エピポーラ線 (epiline) とよびます。
    • 平面 XOO' をエピポーラ平面 (Epipolar Plane) とよびます。
    • 二つのカメラ中心 OOOO' による直線と画像平面の交点 eeee' をエピポール (epipole) とよびます。

    任意の点 xx に対応するエピポーラ線 ll' はエピポール ee' を通るため、エピポール ee' は複数のエピポーラ線 ll' の交点として計算できます。エピポールは画像平面内にあるとは限りません。

    最終的な目標である、上図の左側の画像における、点 xx の深さ情報を得るためには、右側の画像において点 xx に対応するエピポーラ線 ll' を見つけて、特徴量マッチングを線分 ll' 上で行えばよいことが分かりました。また、歪みがある場合は事前にキャリブレーションで歪み補正を行っておく必要があります。

    二つのカメラ間の回転行列と平行移動ベクトルの計算 (ステレオキャリブレーション)

    左側のカメラと右側のカメラそれぞれについてキャリブレーションを行うことが可能である状況を考えると、任意の座標系、例えばワールド座標系からそれぞれのカメラ座標系への変換行列 TWorldToCamera1T_{WorldToCamera1}TWorldToCamera2T_{WorldToCamera2} が外部パラメータとして得られます。これを利用すると、左のカメラから右のカメラへの変換行列が計算できます。変換行列には逆行列が存在することが保証されています。

    TCamera1ToCamera2=TWorldToCamera2TCamera1ToWorld=TWorldToCamera2TWorldToCamera11\begin{aligned} T_{Camera1ToCamera2} &= T_{WorldToCamera2} T_{Camera1ToWorld} \\ &= T_{WorldToCamera2} T_{WorldToCamera1}^{-1} \end{aligned}

    この変換行列に相当する情報を持つ行列として、ステレオキャリブレーションでは後述の基本行列 EE Essential Matrix を扱います。以降、TWorldToCamera1T_{WorldToCamera1} の回転行列を RlR_l、平行移動ベクトルを tlt_l とします。TWorldToCamera2T_{WorldToCamera2} の回転行列を RrR_r、平行移動ベクトルを trt_r とします。

    Epipolar Geometry

    ワールド座標系において、左のカメラと右のカメラの投影中心 OlO_lOrO_r の姿勢 (位置と向き) を考えます。ワールド座標系において、位置 OlO_l から位置 OrO_r へのベクトル TT と、OlO_l の向きを OrO_r の向きに変換する回転行列 RR は外部パラメータ RlR_ltlt_lRrR_rtrt_r から計算することができます。注意: 上記の図におけるベクトル OlOrO_l O_rTT です。

    前述のエピポーラ幾何の図における点 XX の左のカメラ座標系における座標を PlP_l、右のカメラ座標系における座標を PrP_r、ワールド座標系における座標を PP とおくと、以下の式が得られます。

    Pl=RlP+tlPr=RrP+tr\begin{aligned} P_l &= R_l P + t_l \\ P_r &= R_r P + t_r \end{aligned}

    また、二つのカメラ座標系の間には、幾何的に以下の関係が成立します。

    Pr=RT(PlT)P_r = R^T (P_l - T)

    回転行列の転置行列は逆行列と一致することも考慮して変形すると以下のようになります。

    RPr=PlTR P_r = P_l - T

    よって

    T=PlRPr=tlRtr+(RlRRr)P\begin{aligned} T &= P_l - R P_r \\ &= t_l - R t_r + (R_l - R R_r) P \end{aligned}

    これは座標 PP によらず成立するため以下の式が成り立ちます。

    RlRRr=0R_l - R R_r = 0

    以上から、ワールド座標系において、左のカメラと右のカメラの投影中心について、位置 OlO_l から位置 OrO_r へのベクトル TTOlO_l の向きを OrO_r の向きに変換する回転行列 RR は、外部パラメータ RlR_ltlt_lRrR_rtrt_r から以下のように計算することができることが分かります。

    T=tlRtrR=RlRrT\begin{aligned} T &= t_l - R t_r \\ R &= R_l R_r^T \end{aligned}

    基本行列 Essential Matrix とエピポーラ線の計算

    上記の回転行列 RR と平行移動ベクトル TT を用いると、左の画像平面上の点 xx から右の画像平面のエピポーラ線 ll' を計算するための行列として利用できる、基本行列 EE Essential Matrix が計算できます。注意: 以下の図におけるベクトル OlOrO_l O_rTT です。

    Epipolar Geometry

    XX の左のカメラ座標系における座標 PlP_l と右のカメラ座標系における座標 PrP_r について、前述のとおり、二つの座標系の幾何的な関係から以下の式が成り立ちます。

    Pr=RT(PlT)P_r = R^T (P_l - T)

    また PlP_l はエピポーラ平面上の点であることを考えると、点 XX の位置に関わらず常に以下の式が成り立ちます。一般的な平面の方程式であり、平面上のベクトルと平面の法線ベクトルの内積が 0 であることを表現しています。

    (PlT)T(Pl×T)=0(P_l - T)^T (P_l \times T) = 0

    上記二つの式について、最初の式を二つ目の式に代入すると以下のようになります。

    (RPr)T(Pl×T)=0PrTRT(Pl×T)=0\begin{aligned} (R P_r)^T (P_l \times T) &= 0 \\ P_r^T R^T (P_l \times T) &= 0 \end{aligned}

    平行移動ベクトル TT の要素をもとに、以下のような行列 SS を定義すると、

    S=(0TzTyTz0TxTyTx0)S = \begin{pmatrix} 0 & -T_z & T_y \\ T_z & 0 & -T_x \\ -T_y & T_x & 0 \end{pmatrix}

    以下の式が成り立ちます。

    T×Pl=SPlT \times P_l = S P_l

    ここまでの結果から以下の式が得られます。

    PrTRTSPl=0P_r^T R^T S P_l = 0

    基本行列 EE を以下のように定義します。

    E=RTSE = R^T S

    これにより以下の式が得られます。

    PrTEPl=0P_r^T E P_l = 0

    更に直線 OXOX と左の画像平面の交点 xx についてベクトル OxOxplp_l とします。同様に右のカメラについては prp_r とします。左のカメラと右のカメラの焦点距離を flf_lfrf_r とし、投影中心 OOOO' から点 XX までの距離を zlz_lzrz_r とすると、以下の式が成り立ちます。

    pl=flzlPlpr=frzrPr\begin{aligned} p_l &= \frac{f_l}{z_l} P_l \\ p_r &= \frac{f_r}{z_r} P_r \end{aligned}

    これを先程の式に代入すると

    (zrfrpr)TEzlflpl=0zrfrzlflprTEpl=0prTEpl=0\begin{aligned} ( \frac{z_r}{f_r} p_r )^T E \frac{z_l}{f_l} p_l &= 0 \\ \frac{z_r}{f_r}\frac{z_l}{f_l} p_r^T E p_l &= 0 \\ p_r^T E p_l &= 0 \\ \end{aligned}

    ここで行列 SS の階数を調べてみると、ランク落ちしていることが分かります。

    行列 SS は正方行列であり、その固有値 λ\lambda は以下の行列式で計算できます。

    det(λIS)=λIS=λTzTyTzλTxTyTxλ=λ3+λ(Tx2+Ty2+Tz2)\begin{aligned} \mathrm{det} (\lambda I - S) &= |\lambda I - S| = \left| \begin{array}{ccc} \lambda & T_z & -T_y \\ -T_z & \lambda & T_x \\ T_y & -T_x & \lambda \end{array} \right| \\ &= \lambda^3 + \lambda (T_x^2 + T_y^2 + T_z^2) \end{aligned}

    よって行列 SS の固有値は以下の三つです。

    λ=0λ=Tx2+Ty2+Tz2iλ=Tx2+Ty2+Tz2i\begin{aligned} \lambda &= 0 \\ \lambda &= \sqrt{T_x^2 + T_y^2 + T_z^2} \, \mathrm{i} \\ \lambda &= -\sqrt{T_x^2 + T_y^2 + T_z^2} \, \mathrm{i} \end{aligned}

    0 でない固有値の個数は 2 であり、行列 SS の階数は 2 です。したがって以下の連立方程式で点 plp_l が指定された場合に prp_r は一意には定まらず、直線の方程式が得られます。これがエピポーラ線です。行列 SS の定義から明らかなように、基本行列は二つのカメラのキャリブレーション結果の外部パラメータから計算できます。

    prTEpl=0p_r^T E p_l = 0

    基礎行列 Fundamental Matrix とエピポーラ線の計算

    しかしながら実際に得られる情報は空間の座標 plp_l ではなくカメラで取得された画像における点 xx のピクセル座標 qlq_l であるため、カメラ座標やワールド座標ではなく、ピクセル座標で二つのカメラの情報を関連付ける行列が必要になります。そのような行列は、基本行列にそれぞれのカメラのキャリブレーションで得られる内部パラメータの情報を加えた行列になります。この行列は基礎行列 FF Fundamental Matrix とよばれます。基礎行列を利用すると、片方の画像における点から、もう一つの画像におけるエピポーラ線を計算できます。

    Epipolar Geometry

    XX について、左のカメラ座標系における座標は PlP_l です。左のカメラの内部パラメータ KlK_l を用いると、左の画像平面内におけるピクセル座標は以下のようになります。

    ql=KlPlq_l = K_l P_l

    同様に右のカメラについて以下が得られます。

    qr=KrPrq_r = K_r P_r

    基本行列の計算で導いた式のうち以下のものを考えます。

    PrTEPl=0P_r^T E P_l = 0

    先程の二つを代入します。

    (Kr1qr)TEKl1ql=0qrT(Kr1)TEKl1ql=0\begin{aligned} (K_r^{-1} q_r)^T E K_l^{-1} q_l &= 0 \\ q_r^T (K_r^{-1})^T E K_l^{-1} q_l &= 0 \end{aligned}

    基礎行列を以下のように定義します。

    F=(Kr1)TEKl1F = (K_r^{-1})^T E K_l^{-1}

    代入して以下の式が得られます。

    qrTFql=0q_r^T F q_l = 0

    基礎行列の階数は基本行列と同様に 2 です。これは左の画像平面におけるピクセル座標 qlq_l が定まると、右の画像平面におけるエピポーラ線がピクセル座標で得られることを意味します。

    キャリブレーションを用いない基礎行列とエピポーラ線の計算

    基礎行列は上述のようにそれぞれのカメラのキャリブレーション結果から計算することができますが、その他の方法として、二つの画像における対応点から計算することもできます。ホモグラフィの計算で対応点を利用するのと同様です。対応点の組は理論上少なくとも 7 個必要です。以下の例では、特徴量のマッチングを行うことで、後者の方法で基礎行列を計算しています。特徴点の検出に SIFT を利用する場合は古い OpenCV が必要になります。

    python -m pip install opencv-python==3.4.1.15
    python -m pip install opencv-contrib-python==3.4.1.15
    

    left.jpg, right.jpg

    エピポール線の交点がエピポーラです。左右の画像の両方について、もう片方のカメラは画像内には存在せず、したがってエピポーラは画像の外に存在することが分かります。

    #!/usr/bin/python
    # -*- coding: utf-8 -*-
    
    import numpy as np
    import cv2 as cv
    
    # カラー画像である必要はないため、グレースケールで画像を読み込みます。
    img1 = cv.imread('left.jpg', cv.IMREAD_GRAYSCALE)
    img2 = cv.imread('right.jpg', cv.IMREAD_GRAYSCALE)
    
    # 特徴点の検出に SIFT アルゴリズムを利用します。
    sift = cv.xfeatures2d.SIFT_create()
    
    # 特徴点 Key Points kp1, kp2
    # 特徴量記述子 Feature Description des1, des2
    kp1, des1 = sift.detectAndCompute(img1, None)
    kp2, des2 = sift.detectAndCompute(img2, None)
    
    # 特徴量を FLANN を利用してマッチングします。
    # マッチング度合いが高い順に二つ (k=2) 取得します。
    FLANN_INDEX_KDTREE = 1
    index_params = dict(algorithm = FLANN_INDEX_KDTREE, trees = 5)
    search_params = dict(checks=50)
    flann = cv.FlannBasedMatcher(index_params, search_params)
    matches = flann.knnMatch(des1, des2, k=2)
    
    # マッチング結果に閾値を設定します。
    # 取得した結果二つのうち、一つをもう一つの閾値として利用しています。
    good = []
    pts1 = []
    pts2 = []
    for i,(m, n) in enumerate(matches):
        if m.distance < 0.8 * n.distance:
            good.append(m)
            pts1.append(kp1[m.queryIdx].pt)
            pts2.append(kp2[m.trainIdx].pt)
    pts1 = np.int32(pts1)
    pts2 = np.int32(pts2)
    
    # 基礎行列と、基礎行列の計算に実際に利用された点の組を返します。
    F, mask = cv.findFundamentalMat(pts1,pts2,cv.FM_LMEDS)
    
    # 実際に使用された対応点のみを描画のために取得します。
    pts1 = pts1[mask.ravel() == 1]
    pts2 = pts2[mask.ravel() == 1]
    
    # 左の画像 (第二引数に1を指定) における特徴点に対応するエピポーラ線を計算してみます。
    epilines = cv.computeCorrespondEpilines(pts1, 1, F)
    epilines = epilines.reshape(-1, 3)
    
    # 色付きの点や線を描画するために BGR に変換します。
    dst_left = cv.cvtColor(img1, cv.COLOR_GRAY2BGR)
    dst_right = cv.cvtColor(img2, cv.COLOR_GRAY2BGR)
    
    # 右側の画像の横幅ピクセル数
    _, width = img2.shape
    
    # 左側の画像の特徴点、右側の画像の対応する特徴点、右側の画像の対応するエピポーラ線
    for pt1, pt2, epiline in zip(pts1, pts2, epilines):
        # 分かりやすさのため乱数で色を決定します。
        color = tuple(np.random.randint(0, 255, 3).tolist())
    
        # 左側の画像の特徴点
        dst_left = cv.circle(dst_left, tuple(pt1), 5, color, -1)
    
        # 右側の画像の対応する特徴点
        dst_right = cv.circle(dst_right, tuple(pt2), 5, color, -1)
    
        # 右側の画像の対応するエピポーラ線 (a x + b y + c = 0)
        # ピクセル座標は int です。描画するために二点指定する必要があります。
        # 最初の点は x=0 として y を計算しています。
        x0, y0 = map(int, [0, -epiline[2] / epiline[1]])
        # 二つ目の点は x=width として y を計算しています。
        x1, y1 = map(int, [width, -(epiline[2] + epiline[0] * width) / epiline[1]])
        dst_right = cv.line(dst_right, (x0, y0), (x1, y1), color, 1)
    
    cv.imshow('left camera', dst_left)
    cv.imshow('right camera', dst_right)
    cv.waitKey(0)
    

    ステレオキャリブレーションによる基礎行列の計算

    前述のとおり、カメラキャリブレーションで基礎行列を計算できます。この場合、基礎行列の計算のために特徴量マッチングを行う必要はありません。

    stereoData

    #!/usr/bin/python
    # -*- coding: utf-8 -*-
    
    import cv2 as cv
    import numpy as np
    from os import path
    
    def Main():
        # チェスボードの白黒の交点 (コーナー) の個数
        boardNx = 9
        boardNy = 6
    
        # ピンホールモデルで表現できるカメラで取得した画像を読み込みます。
        imagesLeft, imagesRight, filenamesLeft, filenamesRight = ReadImages()
    
        # 一般性を失わず、チェスボードのコーナーの位置が x座標と y座標と一致するようなワールド座標を仮定できます。
        objectPoints = GetChessboardObjectPoints(boardNx, boardNy)
    
        # チェスボードのコーナーを検出します。
        cornersLeftList, cornersRightList, objectPointsList = FindChessboardCorners(imagesLeft, imagesRight, filenamesLeft, filenamesRight, boardNx, boardNy, objectPoints)
    
        # ステレオキャリブレーションを行います。
        imageSize = imagesLeft[0].shape[::-1]
        KKLeft, distCoeffsLeft, KKRight, distCoeffsRight, R, T, E, F = RunStereoCalibration(objectPointsList, cornersLeftList, cornersRightList, imageSize)
    
        # ステレオキャリブレーション結果を qr^T*F*ql = 0 で検証します。
        # エピポーラ線を基礎行列 F から計算することでこれに相当する検証を行えます。
        # 基礎行列 F は R, T, E, KK の情報を含むため、F の検証でそれらの検証が行えます。
        avgErr = CheckCalibrationQuality(boardNx, boardNy, cornersLeftList, cornersRightList, KKLeft, distCoeffsLeft, KKRight, distCoeffsRight, F)
    
    def ReadImages(stereoDataDir = './stereoData'):
        imagesLeft = []
        imagesRight = []
        filenamesLeft = []
        filenamesRight = []
        for i in range(1, 15):
            filenameLeft = 'left%02d.jpg' % i
            filenameRight = 'right%02d.jpg' % i
            imagesLeft.append(cv.imread(path.join(stereoDataDir, filenameLeft), cv.IMREAD_GRAYSCALE))
            imagesRight.append(cv.imread(path.join(stereoDataDir, filenameRight), cv.IMREAD_GRAYSCALE))
            filenamesLeft.append(filenameLeft)
            filenamesRight.append(filenameRight)
        return imagesLeft, imagesRight, filenamesLeft, filenamesRight
    
    def GetChessboardObjectPoints(boardNx, boardNy):
        objectPoints = []
        for ny in range(boardNy):
            for nx in range(boardNx):
                objectPoints.append([ny, nx, 0])
        return np.float32(objectPoints)
    
    def FindChessboardCorners(imagesLeft, imagesRight, filenamesLeft, filenamesRight, boardNx, boardNy, objectPoints):
        cornersLeftList = []
        cornersRightList = []
        objectPointsList = []
        criteria = (cv.TERM_CRITERIA_EPS | cv.TERM_CRITERIA_MAX_ITER, 30, 0.001)
        for imageLeft, imageRight, filenameLeft, filenameRight in zip(imagesLeft, imagesRight, filenamesLeft, filenamesRight):
            foundLeft, cornersLeft = cv.findChessboardCorners(imageLeft, (boardNx, boardNy), None)
            foundRight, cornersRight = cv.findChessboardCorners(imageRight, (boardNx, boardNy), None)
            bothExist = foundLeft and foundRight
            bothNone = (not foundLeft) and (not foundRight)
            assert(bothExist or bothNone)
            if not bothExist:
                continue
            corners2Left = cv.cornerSubPix(imageLeft, cornersLeft, (11, 11), (-1, -1), criteria)
            corners2Right = cv.cornerSubPix(imageRight, cornersRight, (11, 11), (-1, -1), criteria)
            cornersLeftList.append(corners2Left)
            cornersRightList.append(corners2Right)
            objectPointsList.append(objectPoints)
            cv.drawChessboardCorners(imageLeft, (boardNx, boardNy), corners2Left, foundLeft)
            cv.drawChessboardCorners(imageRight, (boardNx, boardNy), corners2Right, foundRight)
            cv.imshow(filenameLeft, imageLeft)
            cv.imshow(filenameRight, imageRight)
            cv.waitKey(0)
            cv.destroyAllWindows()
        return cornersLeftList, cornersRightList, objectPointsList
    
    def RunStereoCalibration(objectPoints, cornersLeft, cornersRight, imageSize):
        criteria = (cv.TERM_CRITERIA_COUNT | cv.TERM_CRITERIA_EPS, 100, 1e-5)
        # 注意: 既定では flags が cv.CALIB_FIX_INTRINSIC となっており、内部パラメータ (KK と distCoeffs) の推定が行われません。
        res = cv.stereoCalibrate(objectPoints, cornersLeft, cornersRight, None, None, None, None, imageSize, criteria=criteria, flags=0)
        err, KKLeft, distCoeffsLeft, KKRight, distCoeffsRight, R, T, E, F = res
        print('err = %r' % err)
        print('KKLeft = %r' % KKLeft)
        print('distCoeffsLeft = %r' % distCoeffsLeft)
        print('KKRight = %r' % KKRight)
        print('distCoeffsRight = %r' % distCoeffsRight)
        print('R = %r' % R)
        print('T = %r' % T)
        print('E = %r' % E)
        print('F = %r' % F)
        return KKLeft, distCoeffsLeft, KKRight, distCoeffsRight, R, T, E, F
    
    def CheckCalibrationQuality(boardNx, boardNy, cornersLeftList, cornersRightList, KKLeft, distCoeffsLeft, KKRight, distCoeffsRight, F):
        N = boardNx * boardNy
        avgErr = 0.0
        for cornersLeft, cornersRight in zip(cornersLeftList, cornersRightList):
            cornersLeft = cv.undistortPoints(cornersLeft, KKLeft, distCoeffsLeft, None, KKLeft)
            cornersRight = cv.undistortPoints(cornersRight, KKRight, distCoeffsRight, None, KKRight)
            epilinesForPointsLeft = cv.computeCorrespondEpilines(cornersLeft, 1, F)
            epilinesForPointsRight = cv.computeCorrespondEpilines(cornersRight, 2, F)
            for i in range(N):
                xl = cornersLeft[i][0][0]
                yl = cornersLeft[i][0][1]
                al = epilinesForPointsLeft[i][0][0]
                bl = epilinesForPointsLeft[i][0][1]
                cl = epilinesForPointsLeft[i][0][2]
                xr = cornersRight[i][0][0]
                yr = cornersRight[i][0][1]
                ar = epilinesForPointsRight[i][0][0]
                br = epilinesForPointsRight[i][0][1]
                cr = epilinesForPointsRight[i][0][2]
                err = abs(ar*xl + br*yl + cr) + abs(al*xr + bl*yr + cl)
                avgErr += err
        avgErr = avgErr / (len(cornersLeft) * N)
        print('avgErr = %r' % avgErr)
        return avgErr
    
    if __name__ == '__main__':
        Main()
    

    実行例

    err = 0.4370546667948499
    KKLeft = array([[535.77916141,   0.        , 341.77622024],
           [  0.        , 535.6453919 , 235.15726332],
           [  0.        ,   0.        ,   1.        ]])
    distCoeffsLeft = array([[-0.27071132,  0.01153839,  0.00165472, -0.00042276,  0.11938723]])
    KKRight = array([[539.39949437,   0.        , 327.44298924],
           [  0.        , 539.06024174, 249.06964702],
           [  0.        ,   0.        ,   1.        ]])
    distCoeffsRight = array([[-0.28139545,  0.10398873, -0.00045729,  0.00088085, -0.01748507]])
    R = array([[ 0.99998698,  0.00379143,  0.00341591],
           [-0.00377495,  0.99998127, -0.00481747],
           [-0.00343411,  0.00480451,  0.99998256]])
    T = array([[-3.33734382e+00],
           [ 3.87005742e-02],
           [ 2.47888063e-03]])
    E = array([[-1.23544340e-04, -2.29289681e-03,  3.87118412e-02],
           [-8.98195415e-03,  1.60437093e-02,  3.33729409e+00],
           [-2.61017634e-02, -3.33742805e+00,  1.59453572e-02]])
    F = array([[ 6.31011733e-09,  1.17140625e-07, -1.08906472e-03],
           [ 4.59048581e-07, -8.20164645e-07, -9.13475928e-02],
           [ 6.02708130e-04,  9.21357703e-02,  1.00000000e+00]])
    avgErr = 0.06654294331868489
    

    ステレオキャリブレーション結果を再利用できるように、何らかの形式で保存しておきます。

    np.savez_compressed('stereoCalib', KKLeft=KKLeft, distCoeffsLeft=distCoeffsLeft, KKRight=KKRight, distCoeffsRight=distCoeffsRight, R=R, T=T, E=E, F=F)
    

    生成されたファイル

    $ file stereoCalib.npz
    stereoCalib.npz: Zip archive data, at least v2.0 to extract
    

    ステレオ平行化 Stereo Rectification

    ステレオキャリブレーションによって内部パラメータおよび互いの位置関係が明らかになっている二つのカメラを用いて取得した画像から、対応する点を見つけることができれば、その点の二つのカメラに対する相対的な位置を計算できることは前述のとおりです。片方の画像のある点に対応する点は、対応するエピポーラ線上を探索すれば十分であることも前述のとおりです。

    ここではこれらの処理を簡単にするために、エピポーラ線をもう片方の画像の同じ高さにある行と一致するようにします。これはステレオ平行化とよばれます。OpenCV で実装されているアルゴリズムの詳細とは異なりますが、おおよそ以下のような流れで平行化します

    左のカメラの内部パラメータ KlK_l、外部パラメータの回転行列 RlR_l、外部パラメータの平行移動ベクトル tlt_l はカメラキャリブレーションによって既知です。同様に右のカメラについても KrK_rRrR_rtrt_r は既知です。ここでワールド座標系におけるある点 XX の座標 PP は、左のカメラ座標においては PlP_l、右のカメラ座標においては PrP_r であるとします。この点について以下のような関係式が成り立ちます。

    Pl=RlP+tlPr=RrP+tr\begin{aligned} P_l &= R_l P + t_l \\ P_r &= R_r P + t_r \end{aligned}

    また、実際の画像におけるピクセル座標を左のカメラと右のカメラについてそれぞれ qlq_lqrq_r とすると以下の式が成り立ちます。

    ql=KlPlqr=KrPr\begin{aligned} q_l &= K_l P_l \\ q_r &= K_r P_r \end{aligned}

    二つのカメラ座標系の幾何的な関係から以下も成り立ちます。ここで RRTT は二つのカメラの位置関係を表現する、ステレオキャリブレーションの結果として得られた既知のパラメータです。

    Pr=RT(PlT)P_r = R^T (P_l - T)

    qr=KrPrq_r = K_r P_r に左から Kr1K_r^{-1} をかけて Pr=RT(PlT)P_r = R^T (P_l - T)Pl=RlP+tlP_l = R_l P + t_l を代入すると以下のようになります。

    Kr1qr=Pr=RT(PlT)RKr1qr=PlT=RlP+tlT\begin{aligned} K_r^{-1} q_r &= P_r \\ &= R^T (P_l - T) \\ R K_r^{-1} q_r &= P_l - T \\ &= R_l P + t_l - T \end{aligned}

    平行移動ベクトル TT を左のカメラ座標系における任意の方向に回転させる行列を考えることは常に可能です。ここでは特に画像平面と横方向に平行になるように回転させる行列 LL を考えて、回転後のベクトルを bb とおきます。

    LT=bL T = b

    先程の RKr1qr=RlP+tlTR K_r^{-1} q_r = R_l P + t_l - T に左から KlLK_l L をかけると以下のようになります。

    KlLRKr1qr=Kl(LRlP+Ltlb)K_l L R K_r^{-1} q_r = K_l (L R_l P + L t_l - b)

    同様に ql=KlPl=Kl(RlP+tl)q_l = K_l P_l = K_l (R_l P + t_l) に左から KlLKl1K_l L K_l^{-1} をかけると以下のようになります。

    KlLKl1ql=Kl(LRlP+Ltl)K_l L K_l^{-1} q_l = K_l (L R_l P + L t_l)

    以下のように変数を定義します。

    ql=KlLKl1qlqr=KlLRKr1qrPl=LRlP+Ltl\begin{aligned} q'_l &= K_l L K_l^{-1} q_l \\ q'_r &= K_l L R K_r^{-1} q_r \\ P'_l &= L R_l P + L t_l \end{aligned}

    最終的に以下の式が得られます。

    ql=KlPlqr=Kl(Plb)\begin{aligned} q'_l &= K_l P'_l \\ q'_r &= K_l (P'_l - b) \end{aligned}

    新しいピクセル座標 qlq'_lqrq'_r は座標 PlP'_lPlbP'_l - b が投影されたものであり、これらはともに左のカメラ座標系の点です。bb が左のカメラ座標系の画像平面の横方向と平行になるように LL を定義したので、新しいピクセル座標による画像について、片方の画像の点のエピポーラ線は、もう片方の画像の同じ高さにある行と一致します。

    以下はステレオキャリブレーションのサンプルコードです。

    ステレオ平行化前の画像

    ステレオ平行化後の画像

    #!/usr/bin/python
    
    # -*- coding: utf-8 -*-
    
    import cv2 as cv
    import numpy as np
    from os import path
    
    def Main():
        # ステレオキャリブレーション結果を読み込みます。
        KKLeft, distCoeffsLeft, KKRight, distCoeffsRight, R, T, E, F = LoadParams()
    
        # ステレオキャリブレーション済みのカメラで取得した画像を読み込みます。
        # ここではステレオキャリブレーションに用いた画像を用いていますが、本来その必要はありません。
        imagesLeft, imagesRight, filenamesLeft, filenamesRight = ReadImages()
        imageSize = imagesLeft[0].shape[::-1]
    
        # ステレオ平行化を行います。
        imagesRectifiedLeft, imagesRectifiedRight, Q, validPixROIl, validPixROIr = StereoRectify(imagesLeft, imagesRight, imageSize, KKLeft, distCoeffsLeft, KKRight, distCoeffsRight, R, T)
    
        # 描画してみます。
        CheckRectifiedImages(filenamesLeft, filenamesRight, imageSize, imagesLeft, imagesRight, imagesRectifiedLeft, imagesRectifiedRight)
    
        # 次のステップのためにステレオ平行化を行った画像を保存しておきます。
        SaveRectifiedImages(filenamesLeft, filenamesRight, imagesRectifiedLeft, imagesRectifiedRight, Q, validPixROIl, validPixROIr)
    
    def LoadParams(filepath='stereoCalib.npz'):
        with np.load(filepath) as data:
            KKLeft, distCoeffsLeft, KKRight, distCoeffsRight, R, T, E, F = [data[i] for i in ('KKLeft', 'distCoeffsLeft', 'KKRight', 'distCoeffsRight', 'R', 'T', 'E', 'F')]
        return KKLeft, distCoeffsLeft, KKRight, distCoeffsRight, R, T, E, F
    
    def ReadImages(stereoDataDir = './stereoData'):
        imagesLeft = []
        imagesRight = []
        filenamesLeft = []
        filenamesRight = []
        for i in range(1, 15):
            filenameLeft = 'left%02d.jpg' % i
            filenameRight = 'right%02d.jpg' % i
            imagesLeft.append(cv.imread(path.join(stereoDataDir, filenameLeft), cv.IMREAD_GRAYSCALE))
            imagesRight.append(cv.imread(path.join(stereoDataDir, filenameRight), cv.IMREAD_GRAYSCALE))
            filenamesLeft.append(filenameLeft)
            filenamesRight.append(filenameRight)
        return imagesLeft, imagesRight, filenamesLeft, filenamesRight
    
    def StereoRectify(imagesLeft, imagesRight, imageSize, KKLeft, distCoeffsLeft, KKRight, distCoeffsRight, R, T):
        Rl, Rr, Pl, Pr, Q, validPixROIl, validPixROIr = cv.stereoRectify(KKLeft, distCoeffsLeft, KKRight, distCoeffsRight, imageSize, R, T, alpha=1, flags=0)
        mapXLeft, mapYLeft = cv.initUndistortRectifyMap(KKLeft, distCoeffsLeft, Rl, Pl, imageSize, cv.CV_32FC1)
        mapXRight, mapYRight = cv.initUndistortRectifyMap(KKRight, distCoeffsRight, Rr, Pr, imageSize, cv.CV_32FC1)
        imagesRectifiedLeft = []
        imagesRectifiedRight = []
        for imageLeft in imagesLeft:
            imageRectifiedLeft = cv.remap(imageLeft, mapXLeft, mapYLeft, cv.INTER_LINEAR)
            imagesRectifiedLeft.append(imageRectifiedLeft)
        for imageRight in imagesRight:
            imageRectifiedRight = cv.remap(imageRight, mapXRight, mapYRight, cv.INTER_LINEAR)
            imagesRectifiedRight.append(imageRectifiedRight)
        return imagesRectifiedLeft, imagesRectifiedRight, Q, validPixROIl, validPixROIr
    
    def CheckRectifiedImages(filenamesLeft, filenamesRight, imageSize, imagesLeft, imagesRight, imagesRectifiedLeft, imagesRectifiedRight):
        for filenameLeft, filenameRight, imageLeft, imageRight, imageRectifiedLeft, imageRectifiedRight in zip(
                filenamesLeft, filenamesRight, imagesLeft, imagesRight, imagesRectifiedLeft, imagesRectifiedRight):
            h = imageSize[1]
            w = imageSize[0]
            imageLR = np.zeros((h, 2 * w, 3), dtype=np.uint8)
            imageRectifiedLR = np.zeros((h, 2 * w, 3), dtype=np.uint8)
            imageLR[:, :w, :] = cv.cvtColor(imageLeft, cv.COLOR_GRAY2RGB)
            imageLR[:, w:, :] = cv.cvtColor(imageRight, cv.COLOR_GRAY2RGB)
            imageRectifiedLR[:, :w, :] = cv.cvtColor(imageRectifiedLeft, cv.COLOR_GRAY2RGB)
            imageRectifiedLR[:, w:, :] = cv.cvtColor(imageRectifiedRight, cv.COLOR_GRAY2RGB)
            for i in range(5, h, 20):
                cv.line(imageLR, (0, i), (2 * w, i), (0, 255, 0));
                cv.line(imageRectifiedLR, (0, i), (2 * w, i), (0, 255, 0));
            cv.imshow(filenameLeft + '-' + filenameRight, imageLR)
            cv.imshow(filenameLeft + '-' + filenameRight + '-rectified', imageRectifiedLR)
            cv.waitKey(0)
            cv.destroyAllWindows()
    
    def SaveRectifiedImages(filenamesLeft, filenamesRight, imagesRectifiedLeft, imagesRectifiedRight, Q, validPixROIl, validPixROIr):
        np.savez_compressed('stereoRect', Q=Q, validPixROIl=validPixROIl, validPixROIr=validPixROIr)
        for filenameLeft, filenameRight, imageRectifiedLeft, imageRectifiedRight in zip(
                filenamesLeft, filenamesRight, imagesRectifiedLeft, imagesRectifiedRight):
            cv.imwrite('rect-' + filenameLeft, imageRectifiedLeft)
            cv.imwrite('rect-' + filenameRight, imageRectifiedRight)
    
    if __name__ == '__main__':
        Main()
    

    対応点探索、視差マップの作成、再投影による奥行きマップの作成

    ステレオキャリブレーションおよびステレオ平行化が完了している、以下の図のような状況を考えます。

    Depth Map from Stereo Images

    ステレオキャリブレーションの結果として、投影中心 OO から OO' までの距離 BB は既知です。またステレオ平行化も完了しているため、投影中心から画像平面までの焦点距離 ff は左右のカメラで同じ値となり、その値も既知です。点 XX の左の画像および右の画像におけるピクセル座標をそれぞれ x>0x \gt 0x<0x' \lt 0 とおきます。三角形 XxxX x x'XOOX O O' の相似を考えると、左右のカメラから点 XX までの距離 zz について以下の式が成り立ちます。

    z:zf=B:Bx+xB(zf)=z(Bx+x)Bf=z(xx)z=Bfxx\begin{aligned} z : z - f &= B : B - x + x' \\ B (z - f) &= z (B - x + x') \\ -B f &= -z(x - x') \\ z &= \frac{B f}{x - x'} \end{aligned}

    二つのカメラで取得した画像における各点の対応づけを行うことで、各ピクセルに関する xxx - x' が格納された視差マップを作成します。二つのカメラの幾何的な配置を考慮して、上記三角測量の考え方を用いることで、視差マップを距離情報に変換します。

    以下のサンプルでは対応点の探索のために SGBM (Semi-Global Block Matching) 法を利用しています。その他に OpenCV には BM (Block Matching) 法も実装されています。いずれの場合についても、カメラ近くの物体の視差が必要な場合は numDisparities大きな値を指定する必要があることに注意します。近くの物体については視差が大きくなるため探索する範囲を広げる必要があります。

    #!/usr/bin/python
    # -*- coding: utf-8 -*-
    
    import cv2 as cv
    import numpy as np
    from os import path
    from mpl_toolkits.mplot3d import Axes3D
    import matplotlib.pyplot as plt
    
    def Main():
        # ステレオ平行化済みの画像を読み込みます。
        rectifiedImagesLeft, rectifiedImagesRight, filenamesLeft, filenamesRight = ReadRectifiedImages()
    
        # ステレオ平行化時に出力されたパラメータを読み込みます。
        Q, validPixROI = LoadStereoRectificationParameters()
    
        # 視差マップを作成します。
        # 注意: 片方のカメラ画像における点と対応する点を、もう片方のカメラ画像から探索するにあたり
        # numDisparities ピクセル分のずれの範囲内のみが探索対象となります。カメラに近い物体の視差は大きいため、
        # 近い物体の視差マップが必要な場合は値を大きくする必要があります。ただしその場合、遠くの物体の視差は得られなくなります。
        numDisparitiesList = [110, 220, 220, 220, 220, 110, 110, 220, 220, 220, 220, 220, 220, 220]
        disparityImages = ComputeDisparityImages(rectifiedImagesLeft, rectifiedImagesRight, numDisparitiesList)
    
        # 深さ情報を計算します。
        depthMaps = ComputeDepthMaps(disparityImages, Q)
    
        # 可視化してみます。
        CheckDepthMaps(filenamesLeft, rectifiedImagesLeft, validPixROI, disparityImages, depthMaps)
    
    def ReadRectifiedImages(rectifiedDataDir = '.'):
        rectifiedImagesLeft = []
        rectifiedImagesRight = []
        filenamesLeft = []
        filenamesRight = []
        for i in range(1, 15):
            filenameLeft = 'rect-left%02d.jpg' % i
            filenameRight = 'rect-right%02d.jpg' % i
            rectifiedImagesLeft.append(cv.imread(path.join(rectifiedDataDir, filenameLeft), cv.IMREAD_GRAYSCALE))
            rectifiedImagesRight.append(cv.imread(path.join(rectifiedDataDir, filenameRight), cv.IMREAD_GRAYSCALE))
            filenamesLeft.append(filenameLeft)
            filenamesRight.append(filenameRight)
        return rectifiedImagesLeft, rectifiedImagesRight, filenamesLeft, filenamesRight
    
    def LoadStereoRectificationParameters(filepath='./stereoRect.npz'):
        with np.load(filepath) as data:
            Q, validPixROIl, validPixROIr = [data[i] for i in ('Q', 'validPixROIl', 'validPixROIr')]
        a = max(validPixROIl[0], validPixROIr[0])
        b = max(validPixROIl[1], validPixROIr[1])
        c = min(validPixROIl[2], validPixROIr[2])
        d = min(validPixROIl[3], validPixROIr[3])
        validPixROI = np.array([a, b, c, d])
        return Q, validPixROI
    
    def ComputeDisparityImages(rectifiedImagesLeft, rectifiedImagesRight, numDisparitiesList, windowSize=11):
        disparityImages = []
        for rectifiedImageLeft, rectifiedImageRight, numDisparities in zip(rectifiedImagesLeft, rectifiedImagesRight, numDisparitiesList):
            stereo = cv.StereoSGBM_create(
                numDisparities = numDisparities,
                blockSize = 16,
                P1 = 8 * 3 * windowSize**2,
                P2 = 32 * 3 * windowSize**2,
                disp12MaxDiff = 1,
                uniquenessRatio = 10,
                speckleWindowSize = 100,
                speckleRange = 32
            )
            # int では分解能が荒くなるため、ここでは float を用いています。
            disparity = stereo.compute(rectifiedImageLeft, rectifiedImageRight).astype(np.float32)
            cv.normalize(disparity, disparity, 0, 1.0, cv.NORM_MINMAX)
            disparityImages.append(disparity)
        return disparityImages
    
    def ComputeDepthMaps(disparityImages, Q):
        return map(lambda disparityImage: cv.reprojectImageTo3D(disparityImage, Q), disparityImages)
    
    def CheckDepthMaps(filenamesLeft, rectifiedImagesLeft, validPixROI, disparityImages, depthMaps):
        a, b, c, d = validPixROI
        for filenameLeft, rectifiedImageLeft, disparityImage, depthMap in zip(
                filenamesLeft, rectifiedImagesLeft, disparityImages, depthMaps):
            # ステレオキャリブレーション結果
            cv.imshow('rect-{}'.format(filenameLeft), rectifiedImageLeft[b:d, a:c])
            # 視差マップ
            cv.imshow('disparity-{}'.format(filenameLeft), disparityImage[b:d, a:c])
            # 奥行マップ (各ピクセルに対応する x, y, z 座標)
            xRangeNormalized = depthMap[b:d, a:c, 0].astype(np.int32)
            yRangeNormalized = depthMap[b:d, a:c, 1].astype(np.int32)
            zRangeNormalized = depthMap[b:d, a:c, 2].astype(np.int32)
            cv.normalize(xRangeNormalized, xRangeNormalized, 0, 255, cv.NORM_MINMAX)
            cv.normalize(yRangeNormalized, yRangeNormalized, 0, 255, cv.NORM_MINMAX)
            cv.normalize(zRangeNormalized, zRangeNormalized, 0, 255, cv.NORM_MINMAX)
            cv.imshow('xRange', xRangeNormalized.astype(np.uint8))
            cv.imshow('yRange', yRangeNormalized.astype(np.uint8))
            cv.imshow('zRange', zRangeNormalized.astype(np.uint8))
            cv.waitKey(0)
            # ポイントクラウドとして可視化
            fig = plt.figure()
            ax = fig.add_subplot(111, projection='3d')
            ax.set_xlabel('X')
            ax.set_ylabel('Y')
            ax.set_zlabel('Z')
            points = depthMap[b:d, a:c].reshape(-1, 3)
            zRange = depthMap[b:d, a:c, 2]
            zMin = zRange.min()
            zMax = zRange.max()
            for i, (x, y, z) in enumerate(points):
                # すべて描画すると処理が重いため一部のみ描画しています。
                if i % 16 != 0:
                    continue
                # 見やすさのため、z の値によって色を変えています。
                color = 1.0 - (z - zMin) / (zMax - zMin)
                # 見やすさのために正負を調整
                ax.scatter3D(-x, y, -z, c=np.float32([0, color, 0]))
            plt.show()
            cv.destroyAllWindows()
    
    if __name__ == '__main__':
        Main()
    

    ポイントクラウドとして可視化するためにここでは Matplotlib の mplot3d を用いました。

    ここではステレオキャリブレーションで用いた画像をそのまま利用しましたが、ステレオ平行化済みの画像であればその必要はありません。ステレオキャリブレーションが完了したカメラで取得した画像について、事前にステレオ平行化を行っておくことで、任意の画像について視差マップを作成できます。

    ただし、特徴点が少ない画像の場合は十分な対応点が得られないことがあります。例えば ENSENSO カメラでは二つのカメラとは別にランダムドットを照射するプロジェクタを用いて特徴点を人工的に作り出すことでこれを回避しています。

    Likeボタン(off)0
    詳細設定を開く/閉じる
    アカウント プロフィール画像

    仕事では Redisを使ったキャッシュ設計や運用を担当

    記事の執筆者にステッカーを贈る

    有益な情報に対するお礼として、またはコメント欄における質問への返答に対するお礼として、 記事の読者は、執筆者に有料のステッカーを贈ることができます。

    >>さらに詳しくステッカーを贈る
    ステッカーを贈る コンセプト画像

    Feedbacks

    Feedbacks コンセプト画像

      ログインするとコメントを投稿できます。

      ログインする

      関連記事