1. 程式人生 > >[SLAM](番外篇):一起做RGB-D SLAM(6)

[SLAM](番外篇):一起做RGB-D SLAM(6)

    本文轉自高翔老師的部落格,建議在學完教程的第二講後,插入學習,做到工程快速入門。

    原文連結:https://www.cnblogs.com/gaoxiang12/p/4739934.html


圖優化工具g2o的入門

  在上一講中,我們介紹瞭如何使用兩兩匹配,搭建一個視覺里程計。那麼,這個里程計有什麼不足呢?

  1.  一旦出現了錯誤匹配,整個程式就會跑飛。
  2. 誤差會累積。常見的現象是:相機轉過去的過程能夠做對,但轉回來之後則出現明顯的偏差。
  3. 效率方面不盡如人意。線上的點雲顯示比較費時。

  累積誤差是里程計中不可避免的,後續的相機姿態依賴著前面的姿態。想要保證地圖的準確,必須要保證每次匹配都精確無誤,而這是難以實現的。所以,我們希望用更好的方法來做slam。不僅僅考慮兩幀的資訊,而要把所有整的資訊都考慮進來,成為一個全slam問題(full slam)。下圖為累積誤差的一個例子。右側是原有掃過的地圖,左側是新掃的,可以看到出現了明顯的不重合。

  所以,我們這一講要介紹姿態圖(pose graph),它是目前視覺slam裡最常用的方法之一。


 姿態圖(原理部分)

  姿態圖,顧名思義,就是由相機姿態構成的一個圖(graph)。這裡的圖,是從圖論的意義上來說的。一個圖由節點與邊構成:

G={V,E}.G={V,E}.

在最簡單的情況下,節點代表相機的各個姿態(四元數形式或矩陣形式):

vi=[x,y,z,qx,qy,qz,qw]=Ti=[R3×3O1×3t3×11]ivi=[x,y,z,qx,qy,qz,qw]=Ti=[R3×3t3×1O1×31]i

  而邊指的是兩個節點間的變換:

Ei,j=Ti,j=[R3×3O1×3t3×11]i,j.Ei,j=Ti,j=[R3×3t3×1O1×31]i,j.

  於是乎,我們可以把前面計算的東西都放到了一個圖裡(請勿吐槽畫風)。

  

   對於vo,這個圖應該像這樣(同樣請勿吐槽畫風):

  像vo這樣的圖呢,我們並沒有什麼可以做的。然而,當這個圖不是vo那樣的鏈狀結構時,由於邊Ti,jTi,j中存在誤差,使得所有的邊給出的資料並不一致。這時節,我們就可以優化一個不一致性誤差:

minE=∑i,j∥x∗i−Ti,jx∗j∥22.minE=∑i,j‖xi∗−Ti,jxj∗‖22.

這裡x∗ixi∗表示xixi的估計值。

  小蘿蔔:師兄,什麼叫估計值啊?

  師兄:嗯,每個xixi實質上都是優化變數啦。在優化過程中,它們有一個初始值。然後呢,根據目標函式對xx的梯度:

x∗(t+1)=x∗(t)−η∗∇xEx(t+1)∗=x(t)∗−η∗∇xE

調整xx的值使得EE縮小。最後,如果這個問題收斂的話,xx的變化就會越來越小,EE也收斂到一個極小值。在這個迭代的過程中,xx那不斷變化的值就是x∗x∗啦。

  小蘿蔔:哦我明白了!是不是運籌學書裡講的非線性優化就是這個啊?

  師兄:對!根據迭代策略的不同,又可分為Gauss-Netwon(GN)下山法,Levenberg-Marquardt(LM)方法等等。這個問題也稱為Bundle Adjustment(BA),我們通常使用LM方法優化這個非線性平方誤差函式。

  BA方法是近年來視覺slam裡用的很多的方法(所以很多研究者吐槽slam和sfm(structure from motion)越來越像了)。早些年間(2005以前),人們還認為用BA求解slam非常困難,因為計算量太大。不過06年之後,人們注意到slam構建的ba問題的稀疏性質,所以用稀疏的BA演算法(sparse BA)求解這個圖,才使BA在slam裡廣泛地應用起來。

  為什麼說slam裡的BA問題稀疏呢?因為同樣的場景很少出現在許多位置中。這導致上面的pose graph中,圖GG離全圖很遠,只有少部分的節點存在直接邊的聯絡。這就是姿態圖的稀疏性。

  求解BA的軟體包有很多,感興趣的讀者可以去看wiki: https://en.wikipedia.org/wiki/Bundle_adjustment。我們這裡介紹的g2o(Generalized Graph Optimizer),就是近年很流行的一個圖優化求解軟體包。下面我們通過例項程式碼,幫助大家入門g2o。


 姿態圖(實現部分)

  • 安裝g2o:

  要使用g2o,首先你需要下載並安裝它:https://github.com/RainerKuemmerle/g2o。 在ubuntu 12.04下,安裝g2o步驟如下:

  1. 安裝依賴項:
    1 sudo apt-get install libeigen3-dev libsuitesparse-dev libqt4-dev qt4-qmake libqglviewer-qt4-dev

    1404或1604的最後一項改為 libqglviewer-dev 即可。

  2. 解壓g2o並編譯安裝:
    進入g2o的程式碼目錄,並:
    mkdir build
    cd build 
    cmake ..
    make
    sudo make install

     多說兩句,你可以安裝cmake-curses-gui這個包,通過gui來選擇你想編譯的g2o模組並設定cmake編譯過程中的flags。例如,當你實在裝不好上面的libqglviewer時,你可以選擇不編譯g2o視覺化模組(把G2O_BUILD_APPS關掉),這樣即使沒有libqglviewer,你也能編譯過g2o。

    1 cd build
    2 ccmake ..
    3 make
    4 sudo make install

    

  安裝成功後,你可以在/usr/local/include/g2o中找到它的標頭檔案,而在/usr/local/lib中找到它的庫檔案。


  •  使用g2o

  安裝完成後,我們把g2o引入自己的cmake工程:

複製程式碼

# 新增g2o的依賴
# 因為g2o不是常用庫,要新增它的findg2o.cmake檔案
LIST( APPEND CMAKE_MODULE_PATH ${PROJECT_SOURCE_DIR}/cmake_modules )
SET( G2O_ROOT /usr/local/include/g2o )
FIND_PACKAGE( G2O )
# CSparse
FIND_PACKAGE( CSparse )
INCLUDE_DIRECTORIES( ${G2O_INCLUDE_DIR} ${CSPARSE_INCLUDE_DIR} )

複製程式碼

  同時,在程式碼根目錄下新建cmake_modules資料夾,把g2o程式碼目錄下的cmake_modules裡的東西都拷進來,保證cmake能夠順利找到g2o。

  現在,複製一個上一講的visualOdometry.cpp,我們把它改成slamEnd.cpp:

  src/slamEnd.cpp

複製程式碼

  1 /*************************************************************************
  2     > File Name: rgbd-slam-tutorial-gx/part V/src/visualOdometry.cpp
  3     > Author: xiang gao
  4     > Mail: [email protected]
  5     > Created Time: 2015年08月15日 星期六 15時35分42秒
  6     * add g2o slam end to visual odometry
  7  ************************************************************************/
  8 
  9 #include <iostream>
 10 #include <fstream>
 11 #include <sstream>
 12 using namespace std;
 13 
 14 #include "slamBase.h"
 15 
 16 //g2o的標頭檔案
 17 #include <g2o/types/slam3d/types_slam3d.h> //頂點型別
 18 #include <g2o/core/sparse_optimizer.h>
 19 #include <g2o/core/block_solver.h>
 20 #include <g2o/core/factory.h>
 21 #include <g2o/core/optimization_algorithm_factory.h>
 22 #include <g2o/core/optimization_algorithm_gauss_newton.h>
 23 #include <g2o/solvers/csparse/linear_solver_csparse.h>
 24 #include <g2o/core/robust_kernel.h>
 25 #include <g2o/core/robust_kernel_factory.h>
 26 #include <g2o/core/optimization_algorithm_levenberg.h>
 27 
 28 
 29 // 給定index,讀取一幀資料
 30 FRAME readFrame( int index, ParameterReader& pd );
 31 // 估計一個運動的大小
 32 double normofTransform( cv::Mat rvec, cv::Mat tvec );
 33 
 34 int main( int argc, char** argv )
 35 {
 36     // 前面部分和vo是一樣的
 37     ParameterReader pd;
 38     int startIndex  =   atoi( pd.getData( "start_index" ).c_str() );
 39     int endIndex    =   atoi( pd.getData( "end_index"   ).c_str() );
 40 
 41     // initialize
 42     cout<<"Initializing ..."<<endl;
 43     int currIndex = startIndex; // 當前索引為currIndex
 44     FRAME lastFrame = readFrame( currIndex, pd ); // 上一幀資料
 45     // 我們總是在比較currFrame和lastFrame
 46     string detector = pd.getData( "detector" );
 47     string descriptor = pd.getData( "descriptor" );
 48     CAMERA_INTRINSIC_PARAMETERS camera = getDefaultCamera();
 49     computeKeyPointsAndDesp( lastFrame, detector, descriptor );
 50     PointCloud::Ptr cloud = image2PointCloud( lastFrame.rgb, lastFrame.depth, camera );
 51     
 52     pcl::visualization::CloudViewer viewer("viewer");
 53 
 54     // 是否顯示點雲
 55     bool visualize = pd.getData("visualize_pointcloud")==string("yes");
 56 
 57     int min_inliers = atoi( pd.getData("min_inliers").c_str() );
 58     double max_norm = atof( pd.getData("max_norm").c_str() );
 59     
 60     /******************************* 
 61     // 新增:有關g2o的初始化
 62     *******************************/
 63     // 選擇優化方法
 64     typedef g2o::BlockSolver_6_3 SlamBlockSolver; 
 65     typedef g2o::LinearSolverCSparse< SlamBlockSolver::PoseMatrixType > SlamLinearSolver; 
 66 
 67     // 初始化求解器
 68     SlamLinearSolver* linearSolver = new SlamLinearSolver();
 69     linearSolver->setBlockOrdering( false );
 70     SlamBlockSolver* blockSolver = new SlamBlockSolver( linearSolver );
 71     g2o::OptimizationAlgorithmLevenberg* solver = new g2o::OptimizationAlgorithmLevenberg( blockSolver );
 72 
 73     g2o::SparseOptimizer globalOptimizer;  // 最後用的就是這個東東
 74     globalOptimizer.setAlgorithm( solver ); 
 75     // 不要輸出除錯資訊
 76     globalOptimizer.setVerbose( false );
 77 
 78     // 向globalOptimizer增加第一個頂點
 79     g2o::VertexSE3* v = new g2o::VertexSE3();
 80     v->setId( currIndex );
 81     v->setEstimate( Eigen::Isometry3d::Identity() ); //估計為單位矩陣
 82     v->setFixed( true ); //第一個頂點固定,不用優化
 83     globalOptimizer.addVertex( v );
 84 
 85     int lastIndex = currIndex; // 上一幀的id
 86 
 87     for ( currIndex=startIndex+1; currIndex<endIndex; currIndex++ )
 88     {
 89         cout<<"Reading files "<<currIndex<<endl;
 90         FRAME currFrame = readFrame( currIndex,pd ); // 讀取currFrame
 91         computeKeyPointsAndDesp( currFrame, detector, descriptor );
 92         // 比較currFrame 和 lastFrame
 93         RESULT_OF_PNP result = estimateMotion( lastFrame, currFrame, camera );
 94         if ( result.inliers < min_inliers ) //inliers不夠,放棄該幀
 95             continue;
 96         // 計算運動範圍是否太大
 97         double norm = normofTransform(result.rvec, result.tvec);
 98         cout<<"norm = "<<norm<<endl;
 99         if ( norm >= max_norm )
100             continue;
101         Eigen::Isometry3d T = cvMat2Eigen( result.rvec, result.tvec );
102         cout<<"T="<<T.matrix()<<endl;
103         
104         // cloud = joinPointCloud( cloud, currFrame, T, camera );
105 
106         // 向g2o中增加這個頂點與上一幀聯絡的邊
107         // 頂點部分
108         // 頂點只需設定id即可
109         g2o::VertexSE3 *v = new g2o::VertexSE3();
110         v->setId( currIndex );
111         v->setEstimate( Eigen::Isometry3d::Identity() );
112         globalOptimizer.addVertex(v);
113         // 邊部分
114         g2o::EdgeSE3* edge = new g2o::EdgeSE3();
115         // 連線此邊的兩個頂點id
116         edge->vertices() [0] = globalOptimizer.vertex( lastIndex );
117         edge->vertices() [1] = globalOptimizer.vertex( currIndex );
118         // 資訊矩陣
119         Eigen::Matrix<double, 6, 6> information = Eigen::Matrix< double, 6,6 >::Identity();
120         // 資訊矩陣是協方差矩陣的逆,表示我們對邊的精度的預先估計
121         // 因為pose為6D的,資訊矩陣是6*6的陣,假設位置和角度的估計精度均為0.1且互相獨立
122         // 那麼協方差則為對角為0.01的矩陣,資訊陣則為100的矩陣
123         information(0,0) = information(1,1) = information(2,2) = 100;
124         information(3,3) = information(4,4) = information(5,5) = 100;
125         // 也可以將角度設大一些,表示對角度的估計更加準確
126         edge->setInformation( information );
127         // 邊的估計即是pnp求解之結果
128         edge->setMeasurement( T );
129         // 將此邊加入圖中
130         globalOptimizer.addEdge(edge);
131 
132         lastFrame = currFrame;
133         lastIndex = currIndex;
134 
135     }
136 
137     // pcl::io::savePCDFile( "data/result.pcd", *cloud );
138     
139     // 優化所有邊
140     cout<<"optimizing pose graph, vertices: "<<globalOptimizer.vertices().size()<<endl;
141     globalOptimizer.save("./data/result_before.g2o");
142     globalOptimizer.initializeOptimization();
143     globalOptimizer.optimize( 100 ); //可以指定優化步數
144     globalOptimizer.save( "./data/result_after.g2o" );
145     cout<<"Optimization done."<<endl;
146 
147     globalOptimizer.clear();
148 
149     return 0;
150 }
151 
152 FRAME readFrame( int index, ParameterReader& pd )
153 {
154     FRAME f;
155     string rgbDir   =   pd.getData("rgb_dir");
156     string depthDir =   pd.getData("depth_dir");
157     
158     string rgbExt   =   pd.getData("rgb_extension");
159     string depthExt =   pd.getData("depth_extension");
160 
161     stringstream ss;
162     ss<<rgbDir<<index<<rgbExt;
163     string filename;
164     ss>>filename;
165     f.rgb = cv::imread( filename );
166 
167     ss.clear();
168     filename.clear();
169     ss<<depthDir<<index<<depthExt;
170     ss>>filename;
171 
172     f.depth = cv::imread( filename, -1 );
173     f.frameID = index;
174     return f;
175 }
176 
177 double normofTransform( cv::Mat rvec, cv::Mat tvec )
178 {
179     return fabs(min(cv::norm(rvec), 2*M_PI-cv::norm(rvec)))+ fabs(cv::norm(tvec));
180 }

複製程式碼

  其中,大部分程式碼和上一講是一樣的,此外新增了幾段g2o的初始化與簡單使用。

  使用g2o圖優化的簡要步驟:第一步,構建一個求解器:globalOptimizer

複製程式碼

 1     // 選擇優化方法
 2     typedef g2o::BlockSolver_6_3 SlamBlockSolver; 
 3     typedef g2o::LinearSolverCSparse< SlamBlockSolver::PoseMatrixType > SlamLinearSolver; 
 4 
 5     // 初始化求解器
 6     SlamLinearSolver* linearSolver = new SlamLinearSolver();
 7     linearSolver->setBlockOrdering( false );
 8     SlamBlockSolver* blockSolver = new SlamBlockSolver( linearSolver );
 9     g2o::OptimizationAlgorithmLevenberg* solver = new g2o::OptimizationAlgorithmLevenberg( blockSolver );
10 
11     g2o::SparseOptimizer globalOptimizer;  // 最後用的就是這個東東
12     globalOptimizer.setAlgorithm( solver ); 
13     // 不要輸出除錯資訊
14     globalOptimizer.setVerbose( false );

複製程式碼

   然後,在求解器內新增點和邊:

複製程式碼

1 // 新增點
2 g2o::VertexSE3* v = new g2o::VertexSE3();
3 // 設定點v ...
4 globalOptimizer.addVertex( v );
5 
6 // 新增邊
7 g2o::EdgeSE3* edge = new g2o::EdgeSE3();
8 // 設定邊 edge ...
9 globalOptimizer.addEdge(edge);

複製程式碼

   最後,完成優化並存儲優化結果:

1 globalOptimizer.save("./data/result_before.g2o");
2 globalOptimizer.initializeOptimization();
3 globalOptimizer.optimize( 100 ); //可以指定優化步數
4 globalOptimizer.save( "./data/result_after.g2o" );

  大致就是這樣啦。


關於程式碼的一些解釋:

  1. 頂點和邊的型別
    頂點和邊有不同的型別,這要看我們想求解什麼問題。由於我們是3D的slam,所以頂點取成了相機姿態:g2o::VertexSE3,而邊則是連線兩個VertexSE3的邊:g2o::EdgeSE3。如果你想用別的型別的頂點(如2Dslam,路標點),你可以看看/usr/local/include/g2o/types/下的檔案,基本上涵蓋了各種slam的應用,應該能滿足你的需求。
    小蘿蔔:師兄,什麼是SE3呢?
    師兄:簡單地說,就是4×44×4的變換矩陣啦,也就是我們這裡用的相機姿態了。更深層的解釋需要李代數裡的知識。相應的,2D slam就要用SE2作為姿態節點了。在我們引用

    <g2o/types/slam3d/types_slam3d.h>
    時,就已經把相關的點和邊都包含進來了哦。

  2. 優化方法
    g2o允許你使用不同的優化求解器(然而實際效果似乎差別不大)。你可以選用csparse, pcg, cholmod等等。我們這裡使用csparse為例啦。
  3. g2o檔案
    g2o的優化結果是儲存在一個.g2o的文字檔案裡的,你可以用gedit等編輯軟體開啟它,結構是這樣的:

    嗯,這個檔案前面是頂點的定義,包含 ID, x,y,z,qx,qy,qz,qw。後邊則是邊的定義:ID1, ID2, dx, T 以及資訊陣的上半形。實際上,你也可以自己寫個程式去生成這樣一個檔案,交給g2o去優化,寫文字檔案不會有啥困難的啦。
    這個檔案也可以用g2o_viewer開啟,你還能直觀地看到裡面的節點與邊的位置。同時你可以選一個優化方法對該圖進行優化,這樣你可以直觀地看到優化的過程哦。然而對於我們現在的VO例子來說,似乎沒什麼可以優化的……

結束語

  好了,因為篇幅已經有些長了,本講到這裡先告一段落。在這一講中,我們給讀者介紹了g2o的安裝與基本使用方法。為保證程式簡單易懂,我們暫時沒有用它構建實用的圖程式,這會在下一講中實現。同時,g2o也可以用來做迴環檢測,丟失恢復等工作,使得slam過程更加穩定可靠,真是一個方便的工具呢!

  本講程式碼:https://github.com/gaoxiang12/rgbd-slam-tutorial-gx/tree/master/part%20VI

  資料請見上一講。

未完待續


TIPS

  • 現在(2016.10)github上的g2o已經可以在14.04下正常編譯安裝了,所以本文當中有些迂迴的安裝步驟就沒必要了。請讀者按照g2o的readme檔案進行編譯安裝即可。