特征點法視覺里程計


一、ORB 特征點

ORB(Oriented FAST and BRIEF) 特征是 SLAM 中一種很常用的特征,由於其二進制特性,使得它 可以非常快速地提取與計算 [1]。下面,你將按照本題的指導,自行書寫 ORB 的提取、描述子的計算以及 匹配的代碼。代碼框架參照 computeORB.cpp 文件,圖像見 1.png 文件和 2.png。

1.1 ORB 提取

ORB 即 Oriented FAST 簡稱。它實際上是 FAST 特征再加上一個旋轉量。本習題將使用 OpenCV 自 帶的 FAST 提取算法,但是你要完成旋轉部分的計算。旋轉的計算過程描述如下 [2]:

​ 在一個小圖像塊中,先計算質心。質心是指以圖像塊灰度值作為權重的中心。

  1. 在一個小的圖像塊 B 中,定義圖像塊的矩為:

\(m_{pq} = \sum_{x,y \in B} x^py^qI(x,y), p,q = \{0,1\}\)

  1. 通過矩可以找到圖像塊的質心:

\(C=(\frac{m_{10}}{m_{00}},\frac{m_{01}}{m_{00}})\)

  1. 連接圖像塊的幾何中心 O 與質心 C,得到一個方向向量 \(\overrightarrow{OC}\),於是特征點的方向可以定義為:

\(\theta = \arctan(m_{01}/m_{10})\)

實際上只需計算 \(m_{01}\)\(m_{10}\) 即可。習題中取圖像塊大小為 16x16,即對於任意點 (u,v),圖像塊從 (u−8,v−8) 取到 (u + 7,v + 7) 即可。請在習題的 computeAngle 中,為所有特征點計算這個旋轉角。

提示:

  1. 由於要取圖像 16x16 塊,所以位於邊緣處的點(比如 u < 8 的)對應的圖像塊可能會出界,此時 需要判斷該點是否在邊緣處,並跳過這些點。

  2. 由於矩的定義方式,在畫圖特征點之后,角度看起來總是指向圖像中更亮的地方。

  3. std::atan 和 std::atan2 會返回弧度制的旋轉角,但 OpenCV 中使用角度制,如使用 std::atan 類 函數,請轉換一下。 作為驗證,第一個圖像的特征點如圖 1 所示。看不清可以放大看。

// compute the angle
void computeAngle(const cv::Mat &image, vector<cv::KeyPoint> &keypoints) {
    int half_patch_size = 8;
    for (auto &kp : keypoints) {
        // START YOUR CODE HERE (~7 lines)
        kp.angle = 0; // compute kp.angle 
        int u = kp.pt.x;
        int v = kp.pt.y;  //height y => image row 
        if( u - 8 >= 0 && u + 7 <= image.cols && v - 8 >= 0 && v + 7 <= image.rows){ // vaild keypoint
            int m01 = 0;
            int m10 = 0;
            // (row, col) <=> (h,w) <=> (v,u) 
            for(int j = -8; j < 8; j++){
                for(int i = -8; i < 8; i++){
                    m01 += j*image.at<uchar>(v + j, u + i); // y*Iuv
                    m10 += i*image.at<uchar>(v + j, u + i); // x*Iuv
                }
            }
            kp.angle = (float)atan2(m01, m10)*180/pi;
        }
        // END YOUR CODE HERE
    }
    return;
}

1.2 ORB 描述

ORB 描述即帶旋轉的 BRIEF 描述。所謂 BRIEF 描述是指一個 0-1 組成的字符串(可以取 256 位或 128 位),每一個 bit 表示一次像素間的比較。算法流程如下:

  1. 給定圖像 I 和關鍵點 (u,v),以及該點的轉角 θ。以 256 位描述為例,那么最終描述子

\(d=[d_1, d_2, \cdots, d_{256}]\)

  1. 對任意 i = 1,...,256,\(d_i\) 的計算如下。取 (u,v) 附近任意兩個點 p,q,並按照 θ 進行旋轉:

\(\begin{bmatrix} u_p'\\ v_p' \end{bmatrix}= \begin{bmatrix} \cos \theta & -\sin\theta\\ \sin\theta & \cos \theta \end{bmatrix} \begin{bmatrix} u_p\\ v_p \end{bmatrix}\)

其中 $u_p,v_p$ 為 $p$ 的坐標,對 $q$ 亦然。記旋轉后的 $p,q$ 為 $p′,q′$,那么比較 $I(p′)$ 和 $I(q′)$,若前者大,記 $di = 0$ ,反之記 $d_i = 1^1$。

這樣我們就得到了 ORB 的描述。我們在程序中用 256 個 bool 變量表達這個描述2。請你完成 computeORBDesc 函數,實現此處計算。注意,通常我們會固定 p,q 的取法(稱為 ORB 的 pattern),否則每 次都重新隨機選取,會使得描述不穩定。我們在全局變量 ORB_pattern 中定義了 p,q 的取法,格式為 up,vp,uq,vq。請你根據給定的 pattern 完成 ORB 描述的計算。

提示:

  1. p,q 同樣要做邊界檢查,否則會跑出圖像外。如果跑出圖像外,就設這個描述子為空。

  2. 調用 cos 和 sin 時同樣請注意弧度和角度的轉換。

// START YOUR CODE HERE (~7 lines)
d[i] = 0;  // if kp goes outside, set d.clear()
int pattern[4], pattern_R[4]; // up, vp, uq, vq;
for(int j = 0; j < 4; j++){
    pattern[j] = ORB_pattern[i*4 + j];
}
// rotation
pattern_R[0] = pattern[0]*cos(kp.angle/180*pi) - pattern[1]*sin(kp.angle/180*pi);
pattern_R[1] = pattern[0]*sin(kp.angle/180*pi) + pattern[1]*cos(kp.angle/180*pi);
pattern_R[2] = pattern[2]*cos(kp.angle/180*pi) - pattern[3]*sin(kp.angle/180*pi);
pattern_R[3] = pattern[2]*sin(kp.angle/180*pi) + pattern[3]*cos(kp.angle/180*pi);

// point
pattern_R[0] += kp.pt.x;
pattern_R[1] += kp.pt.y;
pattern_R[2] += kp.pt.x;
pattern_R[3] += kp.pt.y;

if(pattern_R[0] < 0 || pattern_R[0] >= image.cols || pattern_R[1] < 0 || pattern_R[1] >= image.rows 
|| pattern_R[2] < 0 || pattern_R[2] >= image.cols || pattern_R[3] < 0 || pattern_R[3] >= image.rows){
    d.clear();
    break;
}else{
    d[i] = (image.at<uchar>(pattern_R[1], pattern_R[0]) > image.at<uchar>(pattern_R[3], pattern_R[2])) ? 0 : 1;
}
// END YOUR CODE HERE

1.3 暴力匹配

在提取描述之后,我們需要根據描述子進行匹配。暴力匹配是一種簡單粗暴的匹配方法,在特征點不多時很有用。下面你將根據習題指導,書寫暴力匹配算法。

所謂暴力匹配思路很簡單。給定兩組描述子 \(P = [p_1,...,p_M]\)\(Q = [q_1,...,q_N]\)。那么,對 \(P\) 中任意一個點,找到 \(Q\) 中對應最小距離點,即算一次匹配。但是這樣做會對每個特征點都找到一個匹配,所以我們通常還會限制一個距離閾值 \(d_{max}\),即認作匹配的特征點距離不應該大於 \(d_{max}\)。下面請你根據上述描述,實現函數 bfMatch,返回給定特征點的匹配情況。實踐中取 \(d_{max} = 50\)

提示:

  1. 你需要按位計算兩個描述子之間的漢明距離。

  2. OpenCV 的 DMatch 結構,queryIdx 為第一圖的特征 ID,trainIdx 為第二個圖的特征 ID。

  3. 作為驗證,匹配之后輸出圖像應如圖 2 所示。

// find matches between desc1 and desc2. 
int d1_id = -1;
for(auto &d1: desc1){
    d1_id++;
    if(!d1.empty()){
        vector<vector<int> > d1_match(0, vector<int>(2)); // record [hanmming, d1_id]
        int d2_id = -1;
        for(auto &d2: desc2){
            d2_id++;
            if(!d2.empty()){
                int hamming = 0;// init
                for(int i = 0; i < 256; i++){
                    hamming += (d1[i] == d2[i]) ? 0 : 1;
                }
                d1_match.push_back({hamming, d2_id});
            }
        }
        sort(d1_match.begin(), d1_match.end()); //hamming, order lowest to sort 
        
        if(d1_match[0][0] < d_max){
            cv::DMatch m;
            m.queryIdx = d1_id;
            m.trainIdx = d1_match[0][1]; //d2_id
            m.distance = d1_match[0][0];
            matches.push_back(m);
        }
    }
}
// END YOUR CODE HERE

最后,請結合實驗,回答下面幾個問題:

  1. 為什么說 ORB 是一種二進制特征?

    因為其描述子是以二進制表示的

  2. 為什么在匹配時使用 50 作為閾值,取更大或更小值會怎么樣?

    閾值取得過大會導致誤匹配率增加;閾值取得過小會導致匹配對過少。這兩種情況會導致后續的位姿求解不穩定。

  3. 暴力匹配在你的機器上表現如何?你能想到什么減少計算量的匹配方法嗎?

    在我的機器上秒出,后續可以采用快速最近鄰匹配法。

二、從 E 恢復 R,t

我們在書中講到了單目對極幾何部分,可以通過本質矩陣 E,得到旋轉和平移 R,t,但那時直接使用了 OpenCV 提供的函數。本題中,請你根據數學原理,完成從 E 到 R,t 的計算。程序框架見 code/E2Rt.cpp.

設 Essential 矩陣 E 的取值為(與書上實驗數值相同):

\(E=\begin{bmatrix} −0.0203618550523477 & −0.4007110038118445 & −0.03324074249824097\\ 0.3939270778216369 & −0.03506401846698079 & 0.5857110303721015\\ −0.006788487241438284 & −0.5815434272915686 & −0.01438258684486258 \end{bmatrix}\)

請計算對應的 R,t,流程如下:

  1. 對 E 作 SVD 分解:

\(E=U\sum V^T\)

  1. 處理 Σ 的奇異值。設 Σ = diag(σ1,σ2,σ3) 且 σ1 ≥ σ2 ≥ σ3,那么處理后的 Σ 為:

\(\sum = diag(\frac{\sigma_1 + \sigma_2}{2}, \frac{\sigma_1 + \sigma_2}{2}, 0)\)

3. 共存在四個可能的解:

\(t^∧_1 = UR_Z(\frac{π}{2})ΣU^T\), \(R_1 = UR^T_Z(\frac{π}{2})V^T\)

\(t^∧_2 = UR_Z(−\frac{π}{2})ΣU^T\), \(R_2 = UR^T_Z(−\frac{π}{2})V^T.\)

其中 \(R_Z(\frac{π}{2})\) 表示沿 Z 軸旋轉 90 度得到的旋轉矩陣。同時,由於 −E 和 E 等價,所以對任意一 個 t 或 R 取負號,也會得到同樣的結果。因此,從 E 分解到 t,R 時,一共存在四個可能的解。請 打印這四個可能的 R,t。

提示:用 AngleAxis 或 Sophus::SO3 計算 \(R_Z(\frac{π}{2})\)

​ 注:實際當中,可以利用深度值判斷哪個解是真正的解,不過本題不作要求,只需打印四個可能的解 即可。同時,你也可以驗證 t∧R 應該與 E 只差一個乘法因子,並且與書上的實驗結果亦只差一個乘法因子。

// START YOUR CODE HERE
JacobiSVD<MatrixXd> svd(E, ComputeThinU | ComputeThinV);
Matrix3d U = svd.matrixU();
Matrix3d V = svd.matrixV();
Matrix3d Sigma = U.inverse()*E*V.transpose().inverse();

vector<double>  diag = {Sigma(0,0), Sigma(1,1), Sigma(2,2)};
sort(diag.begin(), diag.end());
double tau = (diag[2] + diag[1])*0.5;

Matrix3d Sigma_tau = Matrix3d::Zero();
Sigma_tau(0,0) = tau;
Sigma_tau(1,1) = tau;

Matrix3d R_Z1 = AngleAxisd(M_PI/2, Vector3d(0,0,1)).matrix();
Matrix3d R_Z2 = AngleAxisd(-M_PI/2, Vector3d(0,0,1)).matrix();
// END YOUR CODE HERE

// set t1, t2, R1, R2 
// START YOUR CODE HERE
Matrix3d t_wedge1 = U*R_Z1*Sigma_tau*U.transpose();    //t_hat
Matrix3d t_wedge2 = U*R_Z2*Sigma_tau*U.transpose();

Matrix3d R1 = U*R_Z1*V.transpose();
Matrix3d R2 = U*R_Z2*V.transpose();
// END YOUR CODE HERE

運行結果:

kieranych@kieranych-ThinkPad-Edge-E431:~/vslam/hw5/L5/code/E2Rt/build$ ./E2Rt 
R1 =  -0.998596  0.0516992 -0.0115267
-0.0513961   -0.99836 -0.0252005
 0.0128107  0.0245727  -0.999616
R2 =   -0.365887  -0.0584576    0.928822
-0.00287462    0.998092   0.0616848
   0.930655  -0.0198996    0.365356
t1 =  -0.581301
-0.0231206
  0.401938
t2 =  0.581301
0.0231206
-0.401938
t^R =  0.0203619   0.400711  0.0332407
 -0.393927   0.035064  -0.585711
0.00678849   0.581543  0.0143826

三、用 G-N 實現 Bundle Adjustment 中的位姿估計

Bundle Adjustment 並不神秘,它僅是一個目標函數為重投影誤差的最小二乘。我們演示了 Bundle Adjustment 可以由 Ceres 和 g2o 實現,並可用於 PnP 當中的位姿估計。本題,你需要自己書寫一個高斯牛頓法,實現用 Bundle Adjustment 優化位姿的功能,求出相機位姿。嚴格來說,這是 Bundle Adjustment 的一部分,因為我們僅考慮了位姿,沒有考慮點的更新。完整的 BA 需要用到矩陣的稀疏性,我們留到第 七節課介紹。

假設一組點的 3D 坐標為 \(P = {p_i}\),它們在相機中的坐標為 \(U = {u_i}\)\(∀i = 1,...n\)。在文件 p3d.txt 和 p2d.txt 中給出了這兩組點的值。同時,設待估計的位姿為 T∈SE(3),內參矩陣為:

\(K=\begin{bmatrix} 520.9 & 0 & 325.1\\ 0& 521.0 & 249.7\\ 0 & 0 & 1 \end{bmatrix}\)

請你根據上述條件,用 G-N 法求出最優位姿,初始估計為 \(T_0 = I\)。程序 GN-BA.cpp 文件提供了大致的 框架,請填寫剩下的內容。 在書寫程序過程中,回答下列問題:

  1. 如何定義重投影誤差?
  2. 該誤差關於自變量的雅可比矩陣是什么?
  3. 解出更新量之后,如何更新至之前的估計上?

作為驗證,最后估計得到的位姿應該接近:

\(T^* =\begin{bmatrix} 0.9978 & −0.0517 & 0.0399 & −0.1272\\ 0.0506 & 0.9983 &0.0274 & −0.007\\ −0.0412 & −0.0253 & 0.9977 & 0.0617\\ 0& 0 & 0 & 1 \end{bmatrix}\)

這和書中使用 g2o 優化的結果很接近$^3$。
int main(int argc, char **argv) {

    VecVector2d p2d;
    VecVector3d p3d;
    Matrix3d K;
    double fx = 520.9, fy = 521.0, cx = 325.1, cy = 249.7;
    K << fx, 0, cx, 0, fy, cy, 0, 0, 1;

    // load points in to p3d and p2d 
    // START YOUR CODE HERE
    ifstream p2d_fin;   //(uv)
    p2d_fin.open(p2d_file);
    string s;
    while(getline(p2d_fin, s) && !s.empty()){
        istringstream p2d_data(s);
        Vector2d p2d_temp;
        p2d_data >> p2d_temp[0] >> p2d_temp[1];
        p2d.push_back(p2d_temp);
    }
    p2d_fin.close();

    ifstream p3d_fin;   //(xyz)
    p3d_fin.open(p3d_file);

    while(getline(p3d_fin, s) && !s.empty()){
        istringstream p3d_data(s);
        Vector3d p3d_temp;
        p3d_data >> p3d_temp[0] >> p3d_temp[1] >> p3d_temp[2];
        p3d.push_back(p3d_temp);
    }
    p3d_fin.close();
    // END YOUR CODE HERE
    assert(p3d.size() == p2d.size());

    int iterations = 100;
    double cost = 0, lastCost = 0;
    int nPoints = p3d.size();
    cout << "points: " << nPoints << endl;

    Sophus::SE3 T_esti; // estimated pose

    for (int iter = 0; iter < iterations; iter++) {

        Matrix<double, 6, 6> H = Matrix<double, 6, 6>::Zero();
        Vector6d b = Vector6d::Zero();

        cost = 0;
        // compute cost
        for (int i = 0; i < nPoints; i++) {
            // compute cost for p3d[I] and p2d[I]
            // START YOUR CODE HERE 
            Vector3d p_cam = T_esti*p3d[i];
            Vector3d e_3d = Vector3d(p2d[i][0], p2d[i][1], 1) - K*p_cam/p_cam[2]; // (x/z, y/z, 1)
            Vector2d e(e_3d[0], e_3d[1]);  //e_3d[2] = 0 
            cost += 0.5*e.transpose()*e;   //0.5*e^2  
	        // END YOUR CODE HERE

	        // compute jacobian
            Matrix<double, 2, 6> J;
            // START YOUR CODE HERE
            double x = p_cam[0], y = p_cam[1], z = p_cam[2];
            J(0, 0) = -fx/z;
            J(0, 2) = fx*x/(z*z);
            J(0, 3) = fx*x*y/(z*z);
            J(0, 4) = -fx - fx*(x*x)/(z*z);
            J(0, 5) =  fx*y/z;
            J(1, 1) = -fy/z;
            J(1, 2) = fy*y/(z*z);
            J(1, 3) = fy + fy*(y*y)/(z*z);
            J(1, 4) = -fy*x*y/(z*z);
            J(1, 5) = -fy*x/z;
	        // END YOUR CODE HERE
            // J^T J dx = -J^T e
            H += J.transpose() * J;
            b += -J.transpose() * e;
        }

	    // solve dx 
        Vector6d dx;

        // START YOUR CODE HERE 
        dx = H.ldlt().solve(b);
        // END YOUR CODE HERE

        if (isnan(dx[0])) {
            cout << "result is nan!" << endl;
            break;
        }

        if (iter > 0 && cost >= lastCost) {
            // cost increase, update is not good
            cout << "cost: " << cost << ", last cost: " << lastCost << endl;
            break;
        }

        // update your estimation
        // START YOUR CODE HERE 
        T_esti = Sophus::SE3::exp(dx)*T_esti;
        // END YOUR CODE HERE
        
        lastCost = cost;

        cout << "iteration " << iter << " cost=" << cout.precision(12) << cost << endl;
    }

    cout << "estimated pose: \n" << T_esti.matrix() << endl;
    return 0;
}

運行結果:

kieranych@kieranych-ThinkPad-Edge-E431:~/vslam/hw5/L5/code/GN-BA/build$ ./gn-ba
points: 76
iteration 0 cost=622769.1141257
iteration 1 cost=12206.604278533
iteration 2 cost=12150.675954556
iteration 3 cost=12150.6753269
iteration 4 cost=12150.6753269
cost: 150.6753269, last cost: 150.6753269
estimated pose: 
   0.997866186837  -0.0516724392948   0.0399128072711      -0.127226621
   0.050595918872    0.998339770315   0.0275273682291 -0.00750679765351
 -0.0412689491074  -0.0254492048098    0.998823914318    0.061386084881
                0                 0                 0                 1

四、用 ICP 實現軌跡對齊

在實際當中,我們經常需要比較兩條軌跡之間的誤差。第三節課習題中,你已經完成了兩條軌跡之間 的 RMSE 誤差計算。但是,由於 ground-truth 軌跡與相機軌跡很可能不在一個參考系中,它們得到的軌 跡並不能直接比較。這時,我們可以用 ICP 來計算兩條軌跡之間的相對旋轉與平移,從而估計出兩個參考 系之間的差異。

設真實軌跡為 \(T_g\),估計軌跡為 \(T_e\),二者皆以 \(T_{WC}\) 格式存儲。但是真實軌跡的坐標原點定義於外部 某參考系中(取決於真實軌跡的采集方式,如 Vicon 系統可能以某攝像頭中心為參考系,見圖 3),而估計 軌跡則以相機出發點為參考系(在視覺 SLAM 中很常見)。由於這個原因,理論上的真實軌跡點與估計軌 跡點應滿足:

\(T_{g,i} = T_{ge}T_{e,i}\)

其中 i 表示軌跡中的第 i 條記錄,\(T_{ge} ∈SE(3)\) 為兩個坐標系之間的變換矩陣,該矩陣在整條軌跡中保持 不變。\(T_{ge}\) 可以通過兩條軌跡數據估計得到,但方法可能有若干種:

  1. 認為初始化時兩個坐標系的差異就是 \(T_{ge}\),即:

\(T_{ge} = T_{g,1}T^{−1}_{e,1}\).

2. 在整條軌跡上利用最小二乘計算 $T_{ge}$:

\(T_{ge} = \arg \underset{T_{ge}}{ min} \sum_{i=1}{n}||log(T^{−1}_{gi}T_{ge}T_{e,i})^∨ ||_2\)

  1. 把兩條軌跡的平移部分看作點集,然后求點集之間的 ICP,得到兩組點之間的變換。 其中第三種也是實踐中用的最廣的一種。現在請你書寫 ICP 程序,估計兩條軌跡之間的差異。軌跡文 件在 compare.txt 文件中,格式為:

\(time_e,t_e,q_e,time_g,t_g,q_g,\)

其中 t 表示平移,q 表示單位四元數。請計算兩條軌跡之間的變換,然后將它們統一到一個參考系,並畫 在 pangolin 中。軌跡的格式與先前相同,即以時間,平移,旋轉四元數方式存儲。

本題不提供代碼框架,你可以利用之前的作業完成本題。圖 4 顯示了對准前與對准后的兩條軌跡。

運行結果:

kieranych@kieranych-ThinkPad-Edge-E431:~/vslam/hw5/L5/code/compare/build$ ./draw_gt_est 
3d-3d pairs: 612
W= 476.887  31.8495  -128.09
 88.5167 -39.3434  34.6791
-3.32456 -8.22012 -1.70694
U=  0.988585  -0.150113 -0.0128977
  0.150527   0.987718  0.0418997
-0.0064496  0.0433628  -0.999039
V= 0.968777  0.221248  0.111896
 0.051191 -0.620082  0.782865
-0.242592  0.752694  0.612047
ICP via SVD results: 
R =  0.923063  0.133592 -0.360706
 0.369047 -0.571958  0.732576
-0.108443 -0.809331 -0.577255
t =  1.54022
0.932743
 1.44744
(poses_gt, poses_est): RMSE: 3.74174
(poses_gt, poses_est_cor): RMSE: 0.041552

1)坐標系未對齊: RMSE: 3.74174

  1. 坐標系對齊: RMSE: 0.041552


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM