参考大牛讲堂|SLAM第一篇:基础知识。
SLAM全称为Simultaneous Localization and Mapping(即时定位与地图构建技术)
传感器分为激光和视觉两大类。基于视觉传感器的SLAM称为视觉SLAM。视觉SLAM又分为单目、双目(多目)和RGB-D。实现难度依次降低。
视觉SLAM分为四个模块:视觉里程计(VO)、后端、建图和回环检测。
视觉里程计估计两个时刻机器人的相对运动。对于相机而言,它在欧式空间运动,通常需要估计一个三维空间的变换矩阵。求解这个矩阵是VO的核心问题。
求解的思路分为基于特征和不基于特征的方法。
基于特征的方法是目前VO的主流。简单来说,对于两幅图像,首先提取图像中的特征,然后根据两幅图像的特征匹配,计算相机的变换矩阵。最常用的是点特征,比如Harris角点、SIFT、ORB…
如果使用RGB-D相机,利用已知深度的特征点,可以直接估计相机的运动。
给定一组特征点以及他们自建的配对关系,来求解相机的姿态,该问题被称为PnP问题。
在单目成像中,我们只知道这两组点的像素坐标,而在双目和RGB-D的配置中,还知道特征点离相机的距离。所以该问题就有了很多形式:
综上所述,VO之求解相对运动的过程是:
另外,VO还需要完成对于局部地图的估计。
知道了相机的运动之后,机器人的轨迹也显而易见了。但是视觉里程计和普通里程计存在着累积误差的问题,因此,需要将帧间的相对运动放到后端中进行加工处理。
早期的SLAM后端采用滤波器的方式。21世纪之后,引入了Bundle Adjustment(捆集优化),考虑过去所有帧中的信息,通过优化将误差平均分配到每一次观测中。因为其常常以图的形式给出,也称之为Graph Optimization。
指的是机器人识别曾到达场景的能力。如果检测成功,则可以显著地减小累积误差。
ORB_SLAM是一个基于特征点的实时单目(Monocular)SLAM。其包括的模块有:
ORB_SLAM2在其基础上支持标定后的双目相机和RGB-D相机。
详细的介绍可以参考ORB-SLAM2详解(一)简介。
Tracking:1. 从图像中提取ORB特征;2. 根据上一帧进行姿态估计或者通过全局重定位初始化位姿; 3. 跟踪已经重建的局部地图;4. 确定新的关键帧(人大代表从人民群众中来)。
LOCAL MAPPING属于局部优化部分及建图、LOOP CLOSING属于回环检测部分,在本文不做介绍。
参考内容有代码文字介绍以及代码视频介绍。
接下来,我们随着数据流,对部分代码进行剖析。
此代码的核心是多线程思想,选取多线程的理由在于:
./Examples/Monocular/mono_tum Vocabulary/ORBvoc.txt Examples/Monocular/TUM1.yaml ./Data/rgbd_dataset/freiburg1_xyz
可以知道,我们运行了mono_tum.cpp文件,其代码如下:
void LoadImages(const string &strFile, vector<string> &vstrImageFilenames,
vector<double> &vTimestamps);
int main(int argc, char **argv)
{
if(argc != 4) // 在命令行中,我们需要输入四个指令 1.运行文件;2.不知道;3.参数文件路径;4.数据集路径
{
cerr << endl << "Usage: ./mono_tum path_to_vocabulary path_to_settings path_to_sequence" << endl;
return 1;
}
// Retrieve paths to images
vector<string> vstrImageFilenames; // filename数组
vector<double> vTimestamps; // timestamp数组
string strFile = string(argv[3])+"/rgb.txt"; // 文件路径 该文件下记录着每一帧的时间戳和名字
LoadImages(strFile, vstrImageFilenames, vTimestamps); // 组织数据
int nImages = vstrImageFilenames.size();
// Create SLAM system. It initializes all system threads and gets ready to process frames.
// 将System类实例化,其中的三个线程也跟着启动
// ORB词库的路径、配置文件的路径、传感器类型、是否使用Viwer
ORB_SLAM2::System SLAM(argv[1],argv[2],ORB_SLAM2::System::MONOCULAR,true);
// Vector for tracking time statistics
vector<float> vTimesTrack;
vTimesTrack.resize(nImages); // vector.resize(n,element)
cout << endl << "-------" << endl;
cout << "Start processing sequence ..." << endl;
cout << "Images in the sequence: " << nImages << endl << endl;
// Main loop
cv::Mat im;
// 开始一帧一帧读入图像
for(int ni=0; ni<nImages; ni++)
{
// Read image from file
im = cv::imread(string(argv[3])+"/"+vstrImageFilenames[ni],CV_LOAD_IMAGE_UNCHANGED);
double tframe = vTimestamps[ni];
if(im.empty())
{
cerr << endl << "Failed to load image at: "
<< string(argv[3]) << "/" << vstrImageFilenames[ni] << endl;
return 1;
}
#ifdef COMPILEDWITHC11
std::chrono::steady_clock::time_point t1 = std::chrono::steady_clock::now();
#else
std::chrono::monotonic_clock::time_point t1 = std::chrono::monotonic_clock::now();
#endif
// Pass the image to the SLAM system
SLAM.TrackMonocular(im,tframe);
#ifdef COMPILEDWITHC11
std::chrono::steady_clock::time_point t2 = std::chrono::steady_clock::now();
#else
std::chrono::monotonic_clock::time_point t2 = std::chrono::monotonic_clock::now();
#endif
double ttrack= std::chrono::duration_cast<std::chrono::duration<double> >(t2 - t1).count();
vTimesTrack[ni]=ttrack;
// Wait to load the next frame
double T=0;
if(ni<nImages-1)
T = vTimestamps[ni+1]-tframe;
else if(ni>0)
T = tframe-vTimestamps[ni-1];
if(ttrack<T)
usleep((T-ttrack)*1e6);
}
// Stop all threads
SLAM.Shutdown();
// Tracking time statistics
sort(vTimesTrack.begin(),vTimesTrack.end());
float totaltime = 0;
for(int ni=0; ni<nImages; ni++)
{
totaltime+=vTimesTrack[ni];
}
cout << "-------" << endl << endl;
cout << "median tracking time: " << vTimesTrack[nImages/2] << endl;
cout << "mean tracking time: " << totaltime/nImages << endl;
// Save camera trajectory
SLAM.SaveKeyFrameTrajectoryTUM("KeyFrameTrajectory.txt");
return 0;
}
void LoadImages(const string &strFile, vector<string> &vstrImageFilenames, vector<double> &vTimestamps)
{
// 文件路径;存储文件名称的数组;存储时间戳的数组
ifstream f;
f.open(strFile.c_str());
// skip first three lines
// 把三行注释给读掉
string s0;
getline(f,s0);
getline(f,s0);
getline(f,s0);
// 把这些数据放到两个数组中去
while(!f.eof())
{
string s;
getline(f,s);
if(!s.empty())
{
stringstream ss;
ss << s;
double t;
string sRGB;
ss >> t;
vTimestamps.push_back(t);
ss >> sRGB;
vstrImageFilenames.push_back(sRGB);
}
}
}
// system文件中初始化Tracking类
mpTracker = new Tracking(this, mpVocabulary, mpFrameDrawer, mpMapDrawer,
mpMap, mpKeyFrameDatabase, strSettingsFile, mSensor);
// 读入图像和时间戳
cv::Mat System::TrackMonocular(const cv::Mat &im, const double ×tamp)
{
if(mSensor!=MONOCULAR)
{
cerr << "ERROR: you called TrackMonocular but input sensor was not set to Monocular." << endl;
exit(-1);
}
// Check mode change
{
unique_lock<mutex> lock(mMutexMode);
if(mbActivateLocalizationMode)
{
mpLocalMapper->RequestStop();
// Wait until Local Mapping has effectively stopped
while(!mpLocalMapper->isStopped())
{
usleep(1000);
}
mpTracker->InformOnlyTracking(true);
mbActivateLocalizationMode = false;
}
if(mbDeactivateLocalizationMode)
{
mpTracker->InformOnlyTracking(false);
mpLocalMapper->Release();
mbDeactivateLocalizationMode = false;
}
}
// Check reset
{
unique_lock<mutex> lock(mMutexReset);
if(mbReset)
{
mpTracker->Reset();
mbReset = false;
}
}
// 分析一下图像
cv::Mat Tcw = mpTracker->GrabImageMonocular(im,timestamp);
unique_lock<mutex> lock2(mMutexState);
// 更新一下跟踪的状态
mTrackingState = mpTracker->mState;
// 更新一下地图点
mTrackedMapPoints = mpTracker->mCurrentFrame.mvpMapPoints;
// 更新一下关键点
mTrackedKeyPointsUn = mpTracker->mCurrentFrame.mvKeysUn;
return Tcw;
}
参考ORB特征提取详解。
ORB(Oriented Fast and Rotated BRIEF)是一种快速的特征点提取/定位和描述的算法。
显而易见,其分为特征点提取(从每一幅图像中寻找那些能在其他图像中较好匹配的位置)和特征点描述(把检测到的关键点周围的每一个区域转化成一个更紧凑和稳定的描述子,通常是一个字串,二进制或十进制)两大部分。
在这里我们更宏观地介绍一下这些计算机视觉中的概念:为了获得特征点及其之间的对应关系,主要有两种方法。第一种是在第一幅图像中寻找那些可以使用局部搜索方法来精确跟踪的特征;第二种方法是在所有考察的图像中独立地检测特征点然后基于他们的局部表现进行匹配。
前一种方式在图像以相近的视角或被快速地连续拍摄时更为合适;后者适合于图像中存在大量的运动或者表观变化的时候。
后一种方式经历:特征检测、特征描述以及特征匹配。
特征点指的是有代表性的点:像素变化大、像素梯度大;
特征点有三个特性:尺度不变性、亮度不变性和旋转不变性。
好的描述子的性质:
常见的特征描述子:HOG、SIFT、SURF、GLOH、BRIEF、ORB、BRISK、FREAK。
描述子就相当于一个学号,不同图的相同特征点的描述子应该是接近甚至一样的。
不同二进制描述子的主要区别在于选择像素对的策略,即像素对的选择方式和选择顺序。
增加了旋转不变性(BRIEF不具备)。
算出来点对之后,乘以了一个旋转矩阵。这个旋转矩阵是在oFAST部分计算得到的。
将得到的新的点对再按照BRIEF的操作。
rBRIEF的二进制串均值没有很多接近0.5,这说明了二进制串里面1和0是有偏向性分布的,1比较多或者0比较多,所以两者之间区分度不够。
sreered BRIEF的缺陷:描述子之间相似,相关性高,区分度低。
假设有一张图像,其中有五个特征,每个特征的邻域里面取很多点对。于是我们有了一个矩阵,其shape为5*256,每一行都是一个BRIEF串。
我们对每一列计算一个均值,然后依据接近0.5的程度进行排序,越接近0.5越靠前。
把第一个列的数拿出来放到R矩阵,然后第二列和它做一个相关性计算,如果小于K,我们就认为这一列数据不怎么相关,比较好,把它放进R。然后下一列和R中所有向量计算相关性。不断遍历,直到R的大小到达256(可以设置)。
在应用中,对这些点还进行旋转,所以称为ORB描述子。
上一节已经介绍完了ORB特征提取的一个理论性算法。在这一章,我们介绍一下其是如何实现的。
参考ORB-SLAM2代码详解02: 特征点提取器ORBextractor。
在图像进来之后呢,会先进到Frame类中,此时,图像就开始与ORBExtractor打交道了。
在Frame.cpp文件中的ExtractORB操作着图像的提取器。
void Frame::ExtractORB(int flag, const cv::Mat &im)
{
if(flag==0)
(*mpORBextractorLeft)(im,cv::Mat(),mvKeys,mDescriptors);
else
(*mpORBextractorRight)(im,cv::Mat(),mvKeysRight,mDescriptorsRight);
}
这两个类型其实是对类指针取出类,执行了一个operator操作。这来到了ORBextractor.cpp。
void ORBextractor::operator()( InputArray _image, InputArray _mask, vector<KeyPoint>& _keypoints,
OutputArray _descriptors)
{
// step1 检查图像的有效性
if(_image.empty())
return;
Mat image = _image.getMat();
assert(image.type() == CV_8UC1 );
// step2 构建图像金字塔
ComputePyramid(image);
// step3 计算特征点进行八叉树筛选
vector < vector<KeyPoint> > allKeypoints;
ComputeKeyPointsOctTree(allKeypoints);
Mat descriptors;
int nkeypoints = 0;
for (int level = 0; level < nlevels; ++level)
nkeypoints += (int)allKeypoints[level].size();
if( nkeypoints == 0 )
_descriptors.release();
else
{
_descriptors.create(nkeypoints, 32, CV_8U);
descriptors = _descriptors.getMat();
}
_keypoints.clear();
_keypoints.reserve(nkeypoints);
// step4 遍历每一层图像,计算描述子
int offset = 0;
for (int level = 0; level < nlevels; ++level)
{
vector<KeyPoint>& keypoints = allKeypoints[level];
int nkeypointsLevel = (int)keypoints.size();
if(nkeypointsLevel==0)
continue;
// preprocess the resized image
Mat workingMat = mvImagePyramid[level].clone();
// 计算描述子之前先进行一次高斯模糊
GaussianBlur(workingMat, workingMat, Size(7, 7), 2, 2, BORDER_REFLECT_101);
// Compute the descriptors
Mat desc = descriptors.rowRange(offset, offset + nkeypointsLevel);
computeDescriptors(workingMat, keypoints, desc, pattern);
offset += nkeypointsLevel;
// Scale keypoint coordinates
if (level != 0)
{
float scale = mvScaleFactor[level]; //getScale(level, firstLevel, scaleFactor);
for (vector<KeyPoint>::iterator keypoint = keypoints.begin(),
keypointEnd = keypoints.end(); keypoint != keypointEnd; ++keypoint)
keypoint->pt *= scale;
}
// And add the keypoints to the output
_keypoints.insert(_keypoints.end(), keypoints.begin(), keypoints.end());
}
}
对于每层图像,执行两步操作:
void ORBextractor::ComputePyramid(cv::Mat image)
{
for (int level = 0; level < nlevels; ++level)
{
// 计算缩放和补padding之后的图像尺寸
float scale = mvInvScaleFactor[level];
Size sz(cvRound((float)image.cols*scale), cvRound((float)image.rows*scale));
Size wholeSize(sz.width + EDGE_THRESHOLD*2, sz.height + EDGE_THRESHOLD*2);
Mat temp(wholeSize, image.type()), masktemp;
// mvImagePyramid用于存储初始图像
mvImagePyramid[level] = temp(Rect(EDGE_THRESHOLD, EDGE_THRESHOLD, sz.width, sz.height));
// Compute the resized image
// 通过插值的手段为这些将图像逐步缩放到初始图像
if( level != 0 )
{
resize(mvImagePyramid[level-1], mvImagePyramid[level], sz, 0, 0, INTER_LINEAR);
copyMakeBorder(mvImagePyramid[level], temp, EDGE_THRESHOLD, EDGE_THRESHOLD, EDGE_THRESHOLD, EDGE_THRESHOLD,
BORDER_REFLECT_101+BORDER_ISOLATED);
}
else
{
copyMakeBorder(image, temp, EDGE_THRESHOLD, EDGE_THRESHOLD, EDGE_THRESHOLD, EDGE_THRESHOLD,
BORDER_REFLECT_101);
}
}
}
找特征点的方法到很简单,但其融入了一个很重要的思想:要求特征点均匀分布在图像中的所有部分。
在这里用了两个技巧:
void ORBextractor::ComputeKeyPointsOctTree(vector<vector<KeyPoint> >& allKeypoints)
{
allKeypoints.resize(nlevels); // 存了每一层最后的特征点
const float W = 30;
for (int level = 0; level < nlevels; ++level)
{
// 计算图像边界
const int minBorderX = EDGE_THRESHOLD-3;
const int minBorderY = minBorderX;
const int maxBorderX = mvImagePyramid[level].cols-EDGE_THRESHOLD+3;
const int maxBorderY = mvImagePyramid[level].rows-EDGE_THRESHOLD+3;
// 存储需要平均分配的特征点
vector<cv::KeyPoint> vToDistributeKeys;
vToDistributeKeys.reserve(nfeatures*10);
const float width = (maxBorderX-minBorderX);
const float height = (maxBorderY-minBorderY);
const int nCols = width/W;
const int nRows = height/W;
const int wCell = ceil(width/nCols);
const int hCell = ceil(height/nRows);
// step1 遍历每行和每列,依次分别用高低阈值搜索FAST特征点
for(int i=0; i<nRows; i++)
{
const float iniY =minBorderY+i*hCell;
float maxY = iniY+hCell+6;
if(iniY>=maxBorderY-3)
continue;
if(maxY>maxBorderY)
maxY = maxBorderY;
for(int j=0; j<nCols; j++)
{
const float iniX =minBorderX+j*wCell;
float maxX = iniX+wCell+6;
if(iniX>=maxBorderX-6)
continue;
if(maxX>maxBorderX)
maxX = maxBorderX;
vector<cv::KeyPoint> vKeysCell;
// 先用高阈值搜索FAST特征点
// 该函数本身实现了高效计算和非极大值抑制,尺度不变性是ORB额外写的。
FAST(mvImagePyramid[level].rowRange(iniY,maxY).colRange(iniX,maxX),
vKeysCell,iniThFAST,true);
// 如果没找到,再用低阈值搜索
if(vKeysCell.empty())
{
FAST(mvImagePyramid[level].rowRange(iniY,maxY).colRange(iniX,maxX),
vKeysCell,minThFAST,true);
}
if(!vKeysCell.empty())
{
for(vector<cv::KeyPoint>::iterator vit=vKeysCell.begin(); vit!=vKeysCell.end();vit++)
{
(*vit).pt.x+=j*wCell;
(*vit).pt.y+=i*hCell;
vToDistributeKeys.push_back(*vit);
}
}
}
}
vector<KeyPoint> & keypoints = allKeypoints[level];
keypoints.reserve(nfeatures);
// step2 对提取到的特征点进行八叉树筛选
keypoints = DistributeOctTree(vToDistributeKeys, minBorderX, maxBorderX,
minBorderY, maxBorderY,mnFeaturesPerLevel[level], level);
const int scaledPatchSize = PATCH_SIZE*mvScaleFactor[level];
// Add border to coordinates and scale information
const int nkps = keypoints.size();
for(int i=0; i<nkps ; i++)
{
keypoints[i].pt.x+=minBorderX;
keypoints[i].pt.y+=minBorderY;
keypoints[i].octave=level;
keypoints[i].size = scaledPatchSize;
}
}
// compute orientations
// 计算每个特征点的方向
for (int level = 0; level < nlevels; ++level)
computeOrientation(mvImagePyramid[level], allKeypoints[level], umax);
}
使用特征点与其周围半径为19的圆的重心的连线为该特征点的方向。
将特征点假设为原点,x轴正方向为0,采用FastAtan2函数,计算得到向量和重心的夹角,范围为 [ − π , π ] [-\pi,\pi] [−π,π]。
计算BRIEF描述子的核心步骤是在特征点周围半径为16的圆域内选取256对点对,每个点对内比较得到1位,共得到256位的描述子,为保计算的一致性,工程上使用特定设计的点对pattern,在程序里被硬编码为成员变量了.
所以我们预先设定好的坐标集实际上是一个数组,共有25622个数。
在computeOrientation()中我们求出了每个特征点的主方向,在计算描述子时,应该将特征点周围像素旋转到主方向上来计算;为了编程方便,实践上对pattern进行旋转.
static void computeDescriptors(const Mat& image, vector<KeyPoint>& keypoints, Mat& descriptors,
const vector<Point>& pattern)
{
descriptors = Mat::zeros((int)keypoints.size(), 32, CV_8UC1);
// 对每一个keypoint
for (size_t i = 0; i < keypoints.size(); i++)
computeOrbDescriptor(keypoints[i], image, &pattern[0], descriptors.ptr((int)i));
}
static void computeOrbDescriptor(const KeyPoint& kpt,
const Mat& img, const Point* pattern,
uchar* desc)
{
float angle = (float)kpt.angle*factorPI;
float a = (float)cos(angle), b = (float)sin(angle);
const uchar* center = &img.at<uchar>(cvRound(kpt.pt.y), cvRound(kpt.pt.x));
const int step = (int)img.step;
// 旋转公式
// x'= xcos(θ) - ysin(θ)
// y'= xsin(θ) + ycos(θ)
// cvRound 返回和参数最接近的整数值,即四舍五入
#define GET_VALUE(idx) \
center[cvRound(pattern[idx].x*b + pattern[idx].y*a)*step + \
cvRound(pattern[idx].x*a - pattern[idx].y*b)]
for (int i = 0; i < 32; ++i, pattern += 16)
{
int t0, t1, val;
t0 = GET_VALUE(0); t1 = GET_VALUE(1);
val = t0 < t1;
t0 = GET_VALUE(2); t1 = GET_VALUE(3);
val |= (t0 < t1) << 1;
t0 = GET_VALUE(4); t1 = GET_VALUE(5);
val |= (t0 < t1) << 2;
t0 = GET_VALUE(6); t1 = GET_VALUE(7);
val |= (t0 < t1) << 3;
t0 = GET_VALUE(8); t1 = GET_VALUE(9);
val |= (t0 < t1) << 4;
t0 = GET_VALUE(10); t1 = GET_VALUE(11);
val |= (t0 < t1) << 5;
t0 = GET_VALUE(12); t1 = GET_VALUE(13);
val |= (t0 < t1) << 6;
t0 = GET_VALUE(14); t1 = GET_VALUE(15);
val |= (t0 < t1) << 7;
desc[i] = (uchar)val; // desc为该特征点的串,是按照字节存储的,
}
#undef GET_VALUE
}