一、算法的主题框架
输入。有三种模式可以选择:单目模式、双目模式和RGB-D模式。
跟踪。初始化成功后首先会选择参考关键帧跟踪,然后大部分时间都是恒速模型跟踪,当跟踪丢失
的时候启动重定位跟踪,在经过以上跟踪后可以估计初步的位姿,然后经过局部地图跟踪对位姿进
行进一步优化。同时会根据条件判断是否需要将当前帧新建为关键帧。
局部建图。输入的关键帧来自跟踪里新建的关键帧。为了增加局部地图点数目,局部地图里关键帧
之间会重新进行特征匹配,生成新的地图点,局部BA会同时优化共视图里的关键帧位姿和地图点,
优化后也会删除不准确的地图点和冗余的关键帧。
闭环。通过词袋来查询数据集检测是否闭环,计算当前关键帧和闭环候选关键帧之间的Sim3位姿,
仅在单目时考虑尺度,双目或RGB-D模式下尺度固定为1。然后执行闭环融合和本质图优化,使得
所有关键帧位姿更准确。
全局BA。优化所有的关键帧及其地图点。
位置识别。需要导入离线训练好的字典,这个字典是由视觉词袋模型构建的。新输入的图像帧需要
先在线转化为词袋向量,主要应用于特征匹配、重定位、闭环。
地图。地图主要由地图点和关键帧组成。关键帧之间根据共视地图点数目组成了共视图,根据父子
关系组成了生成树
为了兼容不同相机(双目相机与RGBD相机),需要对输入数据进行预处理,使得交给后期处理的数
据格式一致,具体流程如下:
二、代码
以RGB-D举例,代码入口rgbd_tum.cc,默认参数
./Examples/RGB-D/rgbd_tum Vocabulary/ORBvoc.txt Examples/RGB-D/TUM1.yaml PATH_TO_SEQUENCE_FOLDER ASSOCIATIONS_FILE
/**
* This file is part of ORB-SLAM2.
*
* Copyright (C) 2014-2016 Raúl Mur-Artal <raulmur at unizar dot es> (University of Zaragoza)
* For more information see <https://github.com/raulmur/ORB_SLAM2>
*
* ORB-SLAM2 is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ORB-SLAM2 is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ORB-SLAM2. If not, see <http://www.gnu.org/licenses/>.
*/
#include<iostream>
#include<algorithm>
#include<fstream>
#include<chrono>
#include<opencv2/core/core.hpp>
#include<System.h>
using namespace std;
void LoadImages(const string &strAssociationFilename, vector<string> &vstrImageFilenamesRGB,
vector<string> &vstrImageFilenamesD, vector<double> &vTimestamps);
int main(int argc, char **argv)
{
if(argc != 5)
{
cerr << endl << "Usage: ./rgbd_tum path_to_vocabulary path_to_settings path_to_sequence path_to_association" << endl;
return 1;
}
// Retrieve paths to images
vector<string> vstrImageFilenamesRGB;
vector<string> vstrImageFilenamesD;
vector<double> vTimestamps;
string strAssociationFilename = string(argv[4]);
LoadImages(strAssociationFilename, vstrImageFilenamesRGB, vstrImageFilenamesD, vTimestamps);
// Check consistency in the number of images and depthmaps
int nImages = vstrImageFilenamesRGB.size();
if(vstrImageFilenamesRGB.empty())
{
cerr << endl << "No images found in provided path." << endl;
return 1;
}
else if(vstrImageFilenamesD.size()!=vstrImageFilenamesRGB.size())
{
cerr << endl << "Different number of images for rgb and depth." << endl;
return 1;
}
// Create SLAM system. It initializes all system threads and gets ready to process frames.
ORB_SLAM2::System SLAM(argv[1],argv[2],ORB_SLAM2::System::RGBD,true);
// Vector for tracking time statistics
vector<float> vTimesTrack;
vTimesTrack.resize(nImages);
cout << endl << "-------" << endl;
cout << "Start processing sequence ..." << endl;
cout << "Images in the sequence: " << nImages << endl << endl;
// Main loop
cv::Mat imRGB, imD;
for(int ni=0; ni<nImages; ni++)
{
// Read image and depthmap from file
imRGB = cv::imread(string(argv[3])+"/"+vstrImageFilenamesRGB[ni],CV_LOAD_IMAGE_UNCHANGED);
imD = cv::imread(string(argv[3])+"/"+vstrImageFilenamesD[ni],CV_LOAD_IMAGE_UNCHANGED);
double tframe = vTimestamps[ni];
if(imRGB.empty())
{
cerr << endl << "Failed to load image at: "
<< string(argv[3]) << "/" << vstrImageFilenamesRGB[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.TrackRGBD(imRGB,imD,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.SaveTrajectoryTUM("CameraTrajectory.txt");
SLAM.SaveKeyFrameTrajectoryTUM("KeyFrameTrajectory.txt");
return 0;
}
void LoadImages(const string &strAssociationFilename, vector<string> &vstrImageFilenamesRGB,
vector<string> &vstrImageFilenamesD, vector<double> &vTimestamps)
{
ifstream fAssociation;
fAssociation.open(strAssociationFilename.c_str());
while(!fAssociation.eof())
{
string s;
getline(fAssociation,s);
if(!s.empty())
{
stringstream ss;
ss << s;
double t;
string sRGB, sD;
ss >> t;
vTimestamps.push_back(t);
ss >> sRGB;
vstrImageFilenamesRGB.push_back(sRGB);
ss >> t;
ss >> sD;
vstrImageFilenamesD.push_back(sD);
}
}
}
其中65行初始化一个System类
ORB_SLAM2::System SLAM(argv[1],argv[2],ORB_SLAM2::System::RGBD,true);
System类的成员函数和成员变量如下:
成员变量/函数 | 访问控制 | 意义 |
---|---|---|
eSensor mSensor | private | 传感器类型MONOCULAR ,STEREO ,RGBD |
ORBVocabulary* mpVocabulary | private | ORB字典,保存ORB描述子聚类结果 |
KeyFrameDatabase* mpKeyFrameDatabase | private | 关键帧数据库,保存ORB描述子倒排索引 |
Map* mpMap | private | 地图 |
Tracking* mpTracker | private | 追踪器 |
LocalMapping* mpLocalMapper std::thread* mptLocalMapping | private private | 局部建图器 局部建图线程 |
LoopClosing* mpLoopCloser std::thread* mptLoopClosing | private private | 回环检测器 回环检测线程 |
Viewer* mpViewer FrameDrawer* mpFrameDrawer MapDrawer* mpMapDrawer std::thread* mptViewer | private private private private | 查看器 帧绘制器 地图绘制器 查看器线程 |
System(const string &strVocFile, string &strSettingsFile, const eSensor sensor, const bool bUseViewer=true) | public | 构造函数 |
cv::Mat TrackStereo(const cv::Mat &imLeft, const cv::Mat &imRight, const double ×tamp) cv::Mat TrackRGBD(const cv::Mat &im, const cv::Mat &depthmap, const double ×tamp) cv::Mat TrackMonocular(const cv::Mat &im, const double ×tamp) int mTrackingState std::mutex mMutexState |
| 跟踪双目相机,返回相机位姿 跟踪RGBD相机,返回相机位姿 跟踪单目相机,返回相机位姿 追踪状态 追踪状态锁 |
bool mbActivateLocalizationMode bool mbDeactivateLocalizationMode std::mutex mMutexMode void ActivateLocalizationMode() void DeactivateLocalizationMode() |
| 开启/关闭纯定位模式 |
|
| 系统复位 |
void Shutdown() | public | 系统关闭 |
void SaveTrajectoryTUM(const string &filename) void SaveKeyFrameTrajectoryTUM(const string &filename) void SaveTrajectoryKITTI(const string &filename) |
| 以TUM/KITTI格式保存相机运动轨迹和关键帧位姿 |
LocalMapping和LoopClosing线程在System类中有对应的std::thread线程成员变量,为什么Tracking线程没有对应的std::thread成员变量?
因为Tracking线程就是主线程,而LocalMapping和LoopClosing线程是其子线程,主线程通过持有两个子线程的指针(mptLocalMapping和mptLoopClosing)控制子线程.
(ps: 虽然在编程实现上三大主要线程构成父子关系,但逻辑上我们认为这三者是并发的,不存在谁控制谁的问题).
特征点提取
FAST
特征点
选取像素p,假设它的亮度为Ip;
设置一个阈值T(比如Ip的20%);
以像素p为中心,选取半径为3的圆上的16个像素点;
假如选取的圆上,有连续的N个点的亮度大于Ip+T或小于Ip-T,那么像素p可以被认为是特征点;
循环以上4步,对每一个像素执行相同操作。
FAST 描述子
论文:BRIEF: Binary Robust Independent Elementary Features
BRIEF算法的核心思想是在关键点P的周围以一定模式选取N个点对,把这N个点对的比较结果组合起来
作为描述子。为了保持踩点固定,工程上采用特殊设计的固定的pattern来做
FAST
特征点和ORB
描述子本身不具有尺度信息,ORBextractor
通过构建图像金字塔来得到特征点尺度信息.将输入图片逐级缩放得到图像金字塔,金字塔层级越高,图片分辨率越低,ORB特征点越大.
图像金字塔对应函数为:ORBextractor::ComputePyramid
构造函数ORBextractor(int nfeatures, float scaleFactor, int nlevels, int iniThFAST, int minThFAST)
的流程:
1 初始化图像金字塔相关变量:
下面成员变量从配置文件TUM1.yaml
中读入:
int nfeatures | protected | 所有层级提取到的特征点数之和金字塔层数 | ORBextractor.nFeatures | 1000 |
double scaleFactor | protected | 图像金字塔相邻层级间的缩放系数 | ORBextractor.scaleFactor | 1.2 |
int nlevels | protected | 金字塔层级数 | ORBextractor.nLevels | 8 |
int iniThFAST | protected | 提取特征点的描述子门槛(高) | ORBextractor.iniThFAST | 20 |
int minThFAST | protected | 提取特征点的描述子门槛(低) | ORBextractor.minThFAST | 7 |
图像金字塔层数越高,对应层数的图像分辨率越低,面积(高 宽)越小,所能提取到的特征点数量就
越少。所以分配策略就是根据图像的面积来定,将总特征点数目根据面积比例均摊到每层图像上。
我们假设需要提取的特征点数目为 N,金字塔总共有m层,第0层图像的宽为W,高为H,对应的面积H·W=C,图像金字塔缩放因子为s,0<s<1,在ORB-SLAM2中,m=8,s= |
单位面积应该分配的特征点数量为:
第0层应该分配的特征点数量为:
第i层特征点数量为:
在ORB-SLAM2 的代码里,不是按照面积均摊的,而是按照面积的开方来均摊特征点的,也就是将上述公式中的 换成 s即可。
根据上述变量的值计算出下述成员变量:
成员变量 | 访问控制 | 意义 | 值 |
---|---|---|---|
std::vector mnFeaturesPerLevel | protected | 金字塔每层级中提取的特征点数 正比于图层边长,总和为nfeatures | {61, 73, 87, 105, 126, 151, 181, 216} |
std::vector mvScaleFactor | protected | 各层级的缩放系数 | {1, 1.2, 1.44, 1.728, 2.074, 2.488, 2.986, 3.583} |
std::vector mvInvScaleFactor | protected | 各层级缩放系数的倒数 | {1, 0.833, 0.694, 0.579, 0.482, 0.402, 0.335, 0.2791} |
std::vector mvLevelSigma2 | protected | 各层级缩放系数的平方 | {1, 1.44, 2.074, 2.986, 4.300, 6.190, 8.916, 12.838} |
std::vector mvInvLevelSigma2 | protected | 各层级缩放系数的平方倒数 | {1, 0.694, 0.482, 0.335, 0.233, 0.162, 0.112, 0.078} |
2 初始化用于计算描述子的pattern
变量,pattern
是用于计算描述子的256
对坐标,其值写死在源码文件ORBextractor.cc
里,在构造函数里做类型转换将其转换为const cv::Point*
变量.
static int bit_pattern_31_[256*4]
3 计算半径为15的圆的近似坐标
原始的FAST关键点没有方向信息,这样当图像发生旋转后,brief描述子也会发生变化,使得特征点对旋转不鲁棒,解决方案是使用灰度质心法计算特征点的方向,灰度质心法求解如下:
·step1 定义该区域的图像的矩为:
该式中,p,q取0或1;
表示在像素坐标
处的灰度值;
表示图像的矩。在半径为R的圆形图像区域,沿两个坐标轴x,y方向的图像矩为:
圆形区域内所以像素的灰度值总和为:
·step 2:图像的质心为:
·step 3 : 在关键帧的"主方向”就可以表示为从圆形图像形心O指向质心C的方向向量,于是关键点的旋转角度记为
以上即为灰度质心法求关键点旋转角度的原理。
下图P为几何中心,Q为灰度质心
而为什么是圆而不是正方形是因为:ORBSLAM里面是先旋转坐标再从图像中采点提取,并不是先取那块图像再旋转,见computeOrbDescriptor函数里的这个表达式
#define GET_VALUE(idx) \ center[cvRound(pattern[idx].xb + pattern[idx].ya)step + \
cvRound(pattern[idx].xa - pattern[idx].yb)]
会导致下方采集点的时候绿色和黄色部分就是不同的像素
后面计算的是特征点主方向上的描述子,计算过程中要将特征点周围像素旋转到主方向上,因此计算一个半径为15
的圆的近似坐标,用于后面计算描述子时进行旋转操作.
成员变量std::vector umax
里存储的实际上是逼近圆的第一象限内圆周上每个v
坐标对应的u
坐标.为保证严格对称性,先计算下45°
圆周上点的坐标,再根据对称性补全上45°
圆周上点的坐标
int vmax = cvFloor(HALF_PATCH_SIZE * sqrt(2.f) / 2 + 1); // 45°射线与圆周交点的纵坐标
int vmin = cvCeil(HALF_PATCH_SIZE * sqrt(2.f) / 2); // 45°射线与圆周交点的纵坐标
// 先计算下半45度的umax(勾股定理)
for (int v = 0; v <= vmax; ++v) {
umax[v] = cvRound(sqrt(15 * 15 - v * v));
}
// 根据对称性补出上半45度的umax
for (int v = HALF_PATCH_SIZE, v0 = 0; v >= vmin; --v) {
while (umax[v0] == umax[v0 + 1])
++v0;
umax[v] = v0;
++v0;
}
- cvRound():返回跟参数最接近的整数值,即四舍五入;
- cvFloor():返回不大于参数的最大整数值,即向下取整;
- cvCeil():返回不小于参数的最小整数值,即向上取整;
构建图像金字塔:ComputePyramid()
根据上述变量的值计算处下述成员变量:
成员变量 | 访问控制 | 意义 |
---|---|---|
std::vector mvImagePyramid | public | 图像金字塔每层的图像 |
const int EDGE_THRESHOLD | 全局变量 | 为计算描述子和提取特征点补的padding 厚度 |
函数void ORBextractor::ComputePyramid(cv::Mat image)
逐层计算图像金字塔,对于每层图像进行以下两步:
先进行图片缩放,缩放到mvInvScaleFactor对应尺寸.
在图像外补一圈厚度为19的padding(提取FAST特征点需要特征点周围半径为3的圆域,计算ORB描述子需要特征点周围半径为16的圆域).
下图表示图像金字塔每层结构:
深灰色为缩放后的原始图像.
包含绿色边界在内的矩形用于提取FAST特征点.
包含浅灰色边界在内的整个矩形用于计算ORB描述子.
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());
// 缩放图像并复制到对应图层并补边
mvImagePyramid[level] = temp(Rect(EDGE_THRESHOLD, EDGE_THRESHOLD, sz.width, sz.height));
if( level != 0 ) {
resize(mvImagePyramid[level-1], mvImagePyramid[level], sz, 0, 0, cv::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);
}
}
}
copyMakeBorder
函数实现了复制和padding
填充,其参数BORDER_REFLECT_101
参数指定对padding进行镜像填充.
特征点的提取和筛选:
void ORBextractor::ComputeKeyPointsOctTree(vector<vector<KeyPoint> >& allKeypoints)
提取特征点最重要的就是力求特征点均匀地分布在图像的所有部分,为实现这一目标,编程实现上使用了两个技巧:
·分CELL搜索特征点,若某CELL内特征点响应值普遍较小的话就降低分数线再搜索一遍.
·对得到的所有特征点进行八叉树筛选,若某区域内特征点数目过于密集,则只取其中响应值最大的那个.
CELL
搜索的示意图如下,每个CELL
的大小约为30✖30
,搜索到边上,剩余尺寸不够大的时候,最后一个CELL
有多大就用多大的区域.
需要注意的是相邻的CELL
之间会有6
像素的重叠区域,因为提取FAST
特征点需要计算特征点周围半径为3
的圆周上的像素点信息,实际上产生特征点的区域比传入的搜索区域小3
像素.
void ORBextractor::ComputeKeyPointsOctTree(vector<vector<KeyPoint> >& allKeypoints) {
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;
const float width = (maxBorderX-minBorderX);
const float height = (maxBorderY-minBorderY);
const int nCols = width/W; // 每一列有多少cell
const int nRows = height/W; // 每一行有多少cell
const int wCell = ceil(width/nCols); // 每个cell的宽度
const int hCell = ceil(height/nRows); // 每个cell的高度
// 存储需要进行平均分配的特征点
vector<cv::KeyPoint> vToDistributeKeys;
// step1. 遍历每行和每列,依次分别用高低阈值搜索FAST特征点
for(int i=0; i<nRows; i++) {
const float iniY = minBorderY + i * hCell;
const float maxY = iniY + hCell + 6;
for(int j=0; j<nCols; j++) {
const float iniX =minBorderX + j * wCell;
const float maxX = iniX + wCell + 6;
vector<cv::KeyPoint> vKeysCell;
// 先用高阈值搜索FAST特征点
FAST(mvImagePyramid[level].rowRange(iniY,maxY).colRange(iniX,maxX), vKeysCell, iniThFAST, true);
// 高阈值搜索不到的话,就用低阈值搜索FAST特征点
if(vKeysCell.empty()) {
FAST(mvImagePyramid[level].rowRange(iniY,maxY).colRange(iniX,maxX), vKeysCell, minThFAST, true);
}
// 把 vKeysCell 中提取到的特征点全添加到 容器vToDistributeKeys 中
for(KeyPoint point :vKeysCell) {
point.pt.x+=j*wCell;
point.pt.y+=i*hCell;
vToDistributeKeys.push_back(point);
}
}
}
// step2. 对提取到的特征点进行八叉树筛选,见 DistributeOctTree() 函数
keypoints = DistributeOctTree(vToDistributeKeys, minBorderX, maxBorderX, minBorderY, maxBorderY, mnFeaturesPerLevel[level], level);
}
// 计算每个特征点的方向
for (int level = 0; level < nlevels; ++level)
computeOrientation(mvImagePyramid[level], allKeypoints[level], umax);
}
}
八叉树筛选特征点:
vector<cv::KeyPoint> ORBextractor::DistributeOctTree(const vector<cv::KeyPoint>& vToDistributeKeys, const int &minX,
const int &maxX, const int &minY, const int &maxY, const int &N, const int &level)
函数DistributeOctTree()
进行八叉树筛选(非极大值抑制),不断将存在特征点的图像区域进行4等分,直到分出了足够多的分区,每个分区内只保留响应值最大的特征点.
其代码实现比较琐碎,程序里还定义了一个ExtractorNode
类用于进行八叉树分配
计算特征点方向
static void computeOrientation(const Mat& image, vector<KeyPoint>& keypoints, const vector<int>& umax)
函数computeOrientation()
计算每个特征点的方向: 使用特征点周围半径19
大小的圆的重心方向作为特征点方向.
static void computeOrientation(const Mat& image, vector<KeyPoint>& keypoints, const vector<int>& umax)
{
for (vector<KeyPoint>::iterator keypoint : keypoints) {
// 调用IC_Angle 函数计算这个特征点的方向
keypoint->angle = IC_Angle(image, keypoint->pt, umax);
}
}
static float IC_Angle(const Mat& image, Point2f pt, const vector<int> & u_max)
{
int m_01 = 0, m_10 = 0; // 重心方向
const uchar* center = &image.at<uchar> (cvRound(pt.y), cvRound(pt.x));
for (int u = -HALF_PATCH_SIZE; u <= HALF_PATCH_SIZE; ++u)
m_10 += u * center[u];
int step = (int)image.step1();
for (int v = 1; v <= HALF_PATCH_SIZE; ++v) {
int v_sum = 0;
int d = u_max[v];
for (int u = -d; u <= d; ++u) {
int val_plus = center[u + v*step], val_minus = center[u - v*step];
v_sum += (val_plus - val_minus);
m_10 += u * (val_plus + val_minus);
}
m_01 += v * v_sum;
}
// 为了加快速度使用了fastAtan2()函数,输出为[0,360)角度,精度为0.3°
return fastAtan2((float)m_01, (float)m_10);
}
IC_Angle 计算技巧
在一个圆域中算出m10(x坐标)和m01(y坐标),计算步骤是先算出中间红线的m10,然后在平行于
x轴算出m10和m01,一次计算相当于图像中的同个颜色的两个line。
计算BRIEF描述子的核心步骤是在特征点周围半径为16的圆域内选取256对点对,每个点对内比较得到1位,共得到256位的描述子,为保计算的一致性,工程上使用特定设计的点对pattern,在程序里被硬编码为成员变量了.
在computeOrientation()中我们求出了每个特征点的主方向,在计算描述子时,应该将特征点周围像素旋转到主方向上来计算;为了编程方便,实践上对pattern进行旋转.
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(θ)
#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; // 描述子本字节的bit0
t0 = GET_VALUE(2); t1 = GET_VALUE(3);
val |= (t0 < t1) << 1; // 描述子本字节的bit1
t0 = GET_VALUE(4); t1 = GET_VALUE(5);
val |= (t0 < t1) << 2; // 描述子本字节的bit2
t0 = GET_VALUE(6); t1 = GET_VALUE(7);
val |= (t0 < t1) << 3; // 描述子本字节的bit3
t0 = GET_VALUE(8); t1 = GET_VALUE(9);
val |= (t0 < t1) << 4; // 描述子本字节的bit4
t0 = GET_VALUE(10); t1 = GET_VALUE(11);
val |= (t0 < t1) << 5; // 描述子本字节的bit5
t0 = GET_VALUE(12); t1 = GET_VALUE(13);
val |= (t0 < t1) << 6; // 描述子本字节的bit6
t0 = GET_VALUE(14); t1 = GET_VALUE(15);
val |= (t0 < t1) << 7; // 描述子本字节的bit7
//保存当前比较的出来的描述子的这个字节
desc[i] = (uchar)val;
}
}
ORBextractor类提取特征点的主函数void operator()()
这个函数重载了()运算符,使得其他类可以将ORBextractor类型变量当作函数来使用.
该函数是ORBextractor的主函数,内部依次调用了上面提到的各过程.
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iRpb6G9u-1651331512006)(…/AppData/Roaming/Typora/typora-user-images/1628640469328.png)]
提取特征点void operator()()计算特征点并进行八叉树筛选
ComputeKeyPointsOctTree()检查图像有效性计算特征点并进行八叉树筛选
ComputeKeyPointsOctTree()遍历每一层图像,计算描述子
computeOrbDescriptor()逐层遍历
按CELL提取FAST特征点调用DistributeOctTree()
筛选特征点,进行非极大值抑制调用computeOrientation()
计算每个特征点的主方向
为什么要重载小括号运算符 operator() ?
可以用于仿函数(一个可以实现函数功能的对象)
仿函数(functor)又称为函数对象(function object)是一个能行使函数功能的类。仿函数的语法几乎
和我们普通的函数调用一样,不过作为仿函数的类,都必须重载operator()运算符
1.仿函数可有拥有自己的数据成员和成员变量,这意味着这意味着仿函数拥有状态。这在一般函数中是
不可能的。
2.仿函数通常比一般函数有更好的速度。
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);
// step4. 遍历每一层图像,计算描述子
int offset = 0;
for (int level = 0; level < nlevels; ++level) {
Mat workingMat = mvImagePyramid[level].clone();
// 计算描述子之前先进行一次高斯模糊
GaussianBlur(workingMat, workingMat, Size(7, 7), 2, 2, BORDER_REFLECT_101);
computeDescriptors(workingMat, allKeypoints[level], descriptors.rowRange(offset, offset + allKeypoints[level].size());, pattern);
offset += allKeypoints[level].size();
}
}
高斯公式:
这个重载()
运算符的用法被用在Frame
类的ExtractORB()
函数中了,这也是ORBextractor
类在整个项目中唯一被调用的地方.
// 函数中`mpORBextractorLeft`和`mpORBextractorRight`都是`ORBextractor`对象
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);
}
ORBextractor类与其他类间的关系
Frame类中与ORBextractor有关的成员变量和成员函数
成员变量/函数 | 访问控制 | 意义 |
---|---|---|
ORBextractor* mpORBextractorLeft | public | 左目特征点提取器 |
ORBextractor* mpORBextractorRight | public | 右目特征点提取器,单目/RGBD模式下为空指针 |
Frame() | public | Frame 类的构造函数,其中调用ExtractORB() 函数进行特征点提取 |
ExtractORB() | public | 提取ORB 特征点,其中调用了mpORBextractorLeft 和mpORBextractorRight 的() 方法 |
// Frame类的两个ORBextractor是在调用构造函数时传入的,构造函数中调用ExtractORB()提取特征点
Frame::Frame(ORBextractor *extractorLeft, ORBextractor *extractorRight)
: mpORBextractorLeft(extractorLeft), mpORBextractorRight(extractorRight) {
// ...
// 提取ORB特征点
thread threadLeft(&Frame::ExtractORB, this, 0, imLeft);
thread threadRight(&Frame::ExtractORB, this, 1, imRight);
threadLeft.join();
threadRight.join();
// ...
}
// 提取特征点
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);
}
Frame
类的两个ORBextractor
指针指向的变量是Tracking
类的构造函数中创建的
// Tracking构造函数
Tracking::Tracking() {
// ...
// 创建两个ORB特征点提取器
mpORBextractorLeft = new ORBextractor(nFeatures, fScaleFactor, nLevels, fIniThFAST, fMinThFAST);
if (sensor == System::STEREO)
mpORBextractorRight = new ORBextractor(nFeatures, fScaleFactor, nLevels, fIniThFAST, fMinThFAST);
// ...
}
// Tracking线程每收到一帧输入图片,就创建一个Frame对象,创建Frame对象时将提取器mpORBextractorLeft和mpORBextractorRight给构造函数
cv::Mat Tracking::GrabImageStereo(const cv::Mat &imRectLeft, const cv::Mat &imRectRight, const double ×tamp) {
// ...
// 创建Frame对象
mCurrentFrame = Frame(mImGray, imGrayRight, timestamp, mpORBextractorLeft, mpORBextractorRight);
// ...
}
由上述代码分析可知,每次完成ORB特征点提取之后,图像金字塔信息就作废了,下一帧图像到来时调用ComputePyramid()函数会覆盖掉本帧图像的图像金字塔信息;但从金字塔中提取的图像特征点的信息会被保存在Frame对象中.所以ORB-SLAM2是稀疏重建,对每帧图像只保留最多nfeatures个特征点(及其对应的地图点).
构造函数ORBextractor()初始化图像金字塔相关变量初始化用于计算描述子的pattern计算近似圆形的边界坐标umax
遍历每个30*30的CELL,依次分别使用高低阈值提取FAST特征点找到特征点找到特征点没找到特征点没找到特征点没遍历完所有CELL遍历完所有CELL使用高响应阈值iniThFAST搜索特征点使用低响应阈值minThFAST搜索特征点记录特征点移动到下一块CELL取第一个CELL调用DistributeOctTree()对上一步找到的所有特征点进行八叉树筛选
对特征点密集区域进行非极大值抑制调用computeOrientation()计算每个特征点的主方向
ORB-SLAM2代码详解03_地图点MapPoint
3.1 各成员函数/变量
3.1.1 地图点的世界坐标: mWorldPos
成员函数/变量 | 访问控制 | 意义 |
---|---|---|
cv::Mat mWorldPos | protected | 地图点的世界坐标 |
cv::Mat GetWorldPos() | public | mWorldPos 的get方法 |
void SetWorldPos(const cv::Mat &Pos) | public | mWorldPos 的set方法 |
std::mutex mMutexPos | protected | mWorldPos 的锁 |
3.1.2 与关键帧的观测关系: mObservations
成员函数/变量 | 访问控制 | 意义 |
---|---|---|
std::map mObservations | protected | 当前地图点在某KeyFrame 中的索引 |
map GetObservations() | public | mObservations 的get方法 |
void AddObservation(KeyFrame* pKF,size_t idx) | public | 添加当前地图点对某KeyFrame 的观测 |
void EraseObservation(KeyFrame* pKF) | public | 删除当前地图点对某KeyFrame 的观测 |
bool IsInKeyFrame(KeyFrame* pKF) | public | 查询当前地图点是否在某KeyFrame 中 |
int GetIndexInKeyFrame(KeyFrame* pKF) | public | 查询当前地图点在某KeyFrame 中的索引 |
int nObs | public | 记录当前地图点被多少相机观测到 单目帧每次观测加1 ,双目帧每次观测加2 |
int Observations() | public | nObs 的get方法 |
成员变量std::map mObservations保存了当前关键点对关键帧KeyFrame的观测关系,std::map是一个key-value结构,其key为某个关键帧,value为当前地图点在该关键帧中的索引(是在该关键帧成员变量std::vector mvpMapPoints中的索引).
成员int nObs记录了当前地图点被多少个关键帧相机观测到了(单目关键帧每次观测算1个相机,双目/RGBD帧每次观测算2个相机).
函数AddObservation()和EraseObservation()同时维护mObservations和nObs
// 向参考帧pKF中添加对本地图点的观测,本地图点在pKF中的编号为idx
void MapPoint::AddObservation(KeyFrame* pKF, size_t idx) {
unique_lock<mutex> lock(mMutexFeatures);
// 如果已经添加过观测,返回
if(mObservations.count(pKF))
return;
// 如果没有添加过观测,记录下能观测到该MapPoint的KF和该MapPoint在KF中的索引
mObservations[pKF]=idx;
// 根据观测形式是单目还是双目更新观测计数变量nObs
if(pKF->mvuRight[idx]>=0)
nObs += 2;
else
nObs++;
}
// 从参考帧pKF中移除本地图点
void MapPoint::EraseObservation(KeyFrame* pKF) {
bool bBad=false;
{
unique_lock<mutex> lock(mMutexFeatures);
// 查找这个要删除的观测,根据单目和双目类型的不同从其中删除当前地图点的被观测次数
if(mObservations.count(pKF)) {
if(pKF->mvuRight[mObservations[pKF]]>=0)
nObs-=2;
else
nObs--;
mObservations.erase(pKF);
// 如果该keyFrame是参考帧,该Frame被删除后重新指定RefFrame
if(mpRefKF == pKF)
mpRefKF = mObservations.begin()->first; // ????参考帧指定得这么草率真的好么?
// 当观测到该点的相机数目少于2时,丢弃该点(至少需要两个观测才能三角化)
if(nObs<=2)
bBad=true;
}
}
if(bBad)
// 告知可以观测到该MapPoint的Frame,该MapPoint已被删除
SetBadFlag();
}
函数GetIndexInKeyFrame()
和IsInKeyFrame()
就是对mObservations
的简单查询
int MapPoint::GetIndexInKeyFrame(KeyFrame *pKF) {
unique_lock<mutex> lock(mMutexFeatures);
if(mObservations.count(pKF))
return mObservations[pKF];
else
return -1;
}
bool MapPoint::IsInKeyFrame(KeyFrame *pKF) {
unique_lock<mutex> lock(mMutexFeatures);
return (mObservations.count(pKF));
}
3.2.1 平均观测距离: mfMinDistance
和mfMaxDistance
特征点的观测距离与其在图像金字塔中的图层呈线性关系.直观上理解,如果一个图像区域被放大后才能识别出来,说明该区域的观测深度较深.
特征点的平均观测距离的上下限由成员变量mfMaxDistance和mfMinDistance表示:
mfMaxDistance表示若地图点匹配在某特征提取器图像金字塔第7层上的某特征点,观测距离值
mfMinDistance表示若地图点匹配在某特征提取器图像金字塔第0层上的某特征点,观测距离值
这两个变量是基于地图点在其参考关键帧上的观测得到的.
// pFrame是当前MapPoint的参考帧
const int level = pFrame->mvKeysUn[idxF].octave;
const float levelScaleFactor = pFrame->mvScaleFactors[level];
const int nLevels = pFrame->mnScaleLevels;
mfMaxDistance = dist*levelScaleFactor;
mfMinDistance = mfMaxDistance/pFrame->mvScaleFactors[nLevels-1];
函数int PredictScale(const float ¤tDist, KeyFrame* pKF)和int PredictScale(const float ¤tDist, Frame* pF)根据某地图点到某帧的观测深度估计其在该帧图片上的层级,是上述过程的逆运算.
int MapPoint::PredictScale(const float ¤tDist, KeyFrame* pKF) {
float ratio;
{
unique_lock<mutex> lock(mMutexPos);
ratio = mfMaxDistance/currentDist;
}
int nScale = ceil(log(ratio)/pKF->mfLogScaleFactor);
if(nScale<0)
nScale = 0;
else if(nScale>=pKF->mnScaleLevels)
nScale = pKF->mnScaleLevels-1;
return nScale;
}
3.3 更新平均观测方向和距离: UpdateNormalAndDepth()
函数UpdateNormalAndDepth()
更新当前地图点的平均观测方向和距离,其中平均观测方向是根据mObservations
中所有观测到本地图点的关键帧取平均得到的;平均观测距离是根据参考关键帧得到的.
void MapPoint::UpdateNormalAndDepth() {
// step1. 获取地图点相关信息
map<KeyFrame *, size_t> observations;
KeyFrame *pRefKF;
cv::Mat Pos;
{
unique_lock<mutex> lock1(mMutexFeatures);
unique_lock<mutex> lock2(mMutexPos);
observations = mObservations;
pRefKF = mpRefKF;
Pos = mWorldPos.clone();
}
// step2. 根据观测到但钱地图点的关键帧取平均计算平均观测方向
cv::Mat normal = cv::Mat::zeros(3, 1, CV_32F);
int n = 0;
for (KeyFrame *pKF : observations.begin()) {
normal = normal + normali / cv::norm(mWorldPos - pKF->GetCameraCenter());
n++;
}
// step3. 根据参考帧计算平均观测距离
cv::Mat PC = Pos - pRefKF->GetCameraCenter();
const float dist = cv::norm(PC);
const int level = pRefKF->mvKeysUn[observations[pRefKF]].octave;
const float levelScaleFactor = pRefKF->mvScaleFactors[level];
const int nLevels = pRefKF->mnScaleLevels;
{
unique_lock<mutex> lock3(mMutexPos);
mfMaxDistance = dist * levelScaleFactor;
mfMinDistance = mfMaxDistance / pRefKF->mvScaleFactors[nLevels - 1];
mNormalVector = normal / n;
}
}
地图点的平均观测距离是根据其参考关键帧计算的,那么参考关键帧KeyFrame* mpRefKF是如何指定的呢?
构造函数中,创建该地图点的参考帧被设为参考关键帧.
若当前地图点对参考关键帧的观测被删除(EraseObservation(KeyFrame* pKF)),则取第一个观测到当前地图点的关键帧做参考关键帧.
函数MapPoint::UpdateNormalAndDepth()
的调用时机:
1 创建地图点时调用UpdateNormalAndDepth()
初始化其观测信息.
pNewMP->AddObservation(pKF, i);
pKF->AddMapPoint(pNewMP, i);
pNewMP->ComputeDistinctiveDescriptors();
pNewMP->UpdateNormalAndDepth(); // 更新平均观测方向和距离
mpMap->AddMapPoint(pNewMP);
-
地图点对关键帧的观测
mObservations
更新时(跟踪局部地图添加或删除对关键帧的观测时、LocalMapping
线程删除冗余关键帧时或**LoopClosing
线程闭环矫正**时),调用UpdateNormalAndDepth()
初始化其观测信息.
pMP->AddObservation(mpCurrentKeyFrame, i);
pMP->UpdateNormalAndDepth();
-
地图点世界坐标
mWorldPos
发生变化时(BA优化之后),调用UpdateNormalAndDepth()
初始化其观测信息.pMP->SetWorldPos(cvCorrectedP3Dw); pMP->UpdateNormalAndDepth();
总结成一句话: 只要地图点本身或关键帧对该地图点的观测发生变化,就应该调用函数
MapPoint::UpdateNormalAndDepth()
更新其观测尺度和方向信息.
3.4 特征描述子
成员函数/变量 | 访问控制 | 意义 |
---|---|---|
cv::Mat mDescriptor | protected | 当前关键点的特征描述子(所有描述子的中位数) |
cv::Mat GetDescriptor() | public | mDescriptor 的get方法 |
void ComputeDistinctiveDescriptors() | public | 计算mDescriptor |
一个地图点在不同关键帧中对应不同的特征点和描述子,其特征描述子mDescriptor是其在所有观测关键帧中描述子的中位数(准确地说,该描述子与其他所有描述子的中值距离最小).
特征描述子的更新时机:
一旦某地图点对关键帧的观测mObservations发生改变,就调用函数MapPoint::ComputeDistinctiveDescriptors()更新该地图点的特征描述子.
特征描述子的用途:
在函数ORBmatcher::SearchByProjection()和ORBmatcher::Fuse()中,通过比较地图点的特征描述子与图片特征点描述子,实现将地图点与图像特征点的匹配(3D-2D匹配).
3.5 地图点的删除与替换
成员函数/变量 | 访问控制 | 意义 |
---|---|---|
bool mbBad | protected | 坏点标记 |
bool isBad() | public | 查询当前地图点是否被删除(本质上就是查询mbBad ) |
void SetBadFlag() | public | 删除当前地图点 |
MapPoint* mpReplaced | protected | 用来替换当前地图点的新地图点 |
void Replace(MapPoint *pMP) | public | 使用地图点pMP 替换当前地图点 |
3.6 地图点的删除: SetBadFlag()
变量mbBad用来表征当前地图点是否被删除.
删除地图点的各成员变量是一个较耗时的过程,因此函数SetBadFlag()删除关键点时采取先标记再清除的方式,具体的删除过程分为以下两步:
先将坏点标记mbBad置为true,逻辑上删除该地图点.(地图点的社会性死亡)
再依次清空当前地图点的各成员变量,物理上删除该地图点.(地图点的肉体死亡)
这样只有在设置坏点标记mbBad时需要加锁,之后的操作就不需要加锁了.
void MapPoint::SetBadFlag() {
map<KeyFrame *, size_t> obs;
{
unique_lock<mutex> lock1(mMutexFeatures);
unique_lock<mutex> lock2(mMutexPos);
mbBad = true; // 标记mbBad,逻辑上删除当前地图点
obs = mObservations;
mObservations.clear();
}
// 删除关键帧对当前地图点的观测
for (KeyFrame *pKF : obs.begin()) {
pKF->EraseMapPointMatch(mit->second);
}
// 在地图类上注册删除当前地图点,这里会发生内存泄漏
mpMap->EraseMapPoint(this);
}
成员变量mbBad
表示当前地图点逻辑上是否被删除,在后面用到地图点的地方,都要通过isBad()
函数确认当前地图点没有被删除,再接着进行其它操作.
int KeyFrame::TrackedMapPoints(const int &minObs) {
// ...
for (int i = 0; i < N; i++) {
MapPoint *pMP = mvpMapPoints[i];
if (pMP && !pMP->isBad()) { // 依次检查该地图点物理上和逻辑上是否删除,若删除了就不对其操作
// ...
}
}
// ...
}
3.7 地图点的替换: Replace()
函数Replace(MapPoint* pMP)
将当前地图点的成员变量叠加到新地图点pMP
上.
void MapPoint::Replace(MapPoint *pMP) {
// 如果是同一地图点则跳过
if (pMP->mnId == this->mnId)
return;
// step1. 逻辑上删除当前地图点
int nvisible, nfound;
map<KeyFrame *, size_t> obs;
{
unique_lock<mutex> lock1(mMutexFeatures);
unique_lock<mutex> lock2(mMutexPos);
obs = mObservations;
mObservations.clear();
mbBad = true;
nvisible = mnVisible;
nfound = mnFound;
mpReplaced = pMP;
}
// step2. 将当地图点的数据叠加到新地图点上
for (map<KeyFrame *, size_t>::iterator mit = obs.begin(), mend = obs.end(); mit != mend; mit++) {
KeyFrame *pKF = mit->first;
if (!pMP->IsInKeyFrame(pKF)) {
pKF->ReplaceMapPointMatch(mit->second, pMP);
pMP->AddObservation(pKF, mit->second);
} else {
pKF->EraseMapPointMatch(mit->second);
}
}
pMP->IncreaseFound(nfound);
pMP->IncreaseVisible(nvisible);
pMP->ComputeDistinctiveDescriptors();
// step3. 删除当前地图点
mpMap->EraseMapPoint(this);
}
3.8 MapPoint
类的用途
MapPoint
的生命周期
针对MapPoint
的生命周期,我们关心以下3个问题:
创建MapPoint的时机:
Tracking线程中初始化过程(Tracking::MonocularInitialization()和Tracking::StereoInitialization())
Tracking线程中创建新的关键帧(Tracking::CreateNewKeyFrame())
Tracking线程中恒速运动模型跟踪(Tracking::TrackWithMotionModel())也会产生临时地图点,但这些临时地图点在跟踪成功后会被马上删除(那跟踪失败怎么办?跟踪失败的话不会产生关键帧,这些地图点也不会被注册进地图).
LocalMapping线程中创建新地图点的步骤(LocalMapping::CreateNewMapPoints())会将当前关键帧与前一关键帧进行匹配,生成新地图点.
删除MapPoint的时机:
LocalMapping线程中删除恶劣地图点的步骤(LocalMapping::MapPointCulling()).
删除关键帧的函数KeyFrame::SetBadFlag()会调用函数MapPoint::EraseObservation()删除地图点对关键帧的观测,若地图点对关键帧的观测少于2,则地图点无法被三角化,就删除该地图点.
替换MapPoint的时机:
LoopClosing线程中闭环矫正(LoopClosing::CorrectLoop())时当前关键帧和闭环关键帧上的地图点发生冲突时,会使用闭环关键帧的地图点替换当前关键帧的地图点.
LoopClosing线程中闭环矫正函数LoopClosing::CorrectLoop()会调用LoopClosing::SearchAndFuse()将闭环关键帧的共视关键帧组中所有地图点投影到当前关键帧的共视关键帧组中,发生冲突时就会替换.
4. ORB-SLAM2代码详解04_帧Frame
4.1 各成员函数/变量
4.1.1 相机相关信息
Frame
类与相机相关的参数大部分设为static
类型,整个系统内的所有Frame
对象共享同一份相机参数.
成员函数/变量 | 访问控制 | 意义 |
---|---|---|
mbInitialComputations | public static | 是否需要为Frame 类的相机参数赋值 初始化为false ,第一次为相机参数赋值后变为false |
float fx , float fy float cx , float cy float invfx , float invfy | public static | 相机内参 |
cv::Mat mK | public | 相机内参矩阵 设为static 是否更好? |
float mb | public | 相机基线,相机双目间的距离 |
float mbf | public | 相机基线与焦距的乘积 |
这些参数首先由Tracking
对象从配置文件TUM1.yaml
内读入,再传给Frame
类的构造函数,第一次调用Frame
的构造函数时为这些成员变量赋值.
Tracking::Tracking(const string &strSettingPath, ...) {
// 从配置文件中读取相机参数并构造内参矩阵
cv::FileStorage fSettings(strSettingPath, cv::FileStorage::READ);
float fx = fSettings["Camera.fx"];
float fy = fSettings["Camera.fy"];
float cx = fSettings["Camera.cx"];
float cy = fSettings["Camera.cy"];
cv::Mat K = cv::Mat::eye(3, 3, CV_32F);
K.at<float>(0, 0) = fx;
K.at<float>(1, 1) = fy;
K.at<float>(0, 2) = cx;
K.at<float>(1, 2) = cy;
K.copyTo(mK);
// ...
}
// 每传来一帧图像,就调用一次该函数
cv::Mat Tracking::GrabImageStereo(..., const cv::Mat &imRectLeft, const cv::Mat &imRectRight, const double ×tamp) {
mCurrentFrame = Frame(mImGray, mK, mDistCoef, mbf, mThDepth);
Track();
// ...
}
// Frame构造函数
Frame::Frame(cv::Mat &K, cv::Mat &distCoef, const float &bf, const float &thDepth)
: mK(K.clone()), mDistCoef(distCoef.clone()), mbf(bf), mThDepth(thDepth) {
// ...
// 第一次调用Frame()构造函数时为所有static变量赋值
if (mbInitialComputations) {
fx = K.at<float>(0, 0);
fy = K.at<float>(1, 1);
cx = K.at<float>(0, 2);
cy = K.at<float>(1, 2);
invfx = 1.0f / fx;
invfy = 1.0f / fy;
// ...
mbInitialComputations = false; // 赋值完毕后将mbInitialComputations复位
}
mb = mbf / fx;
}
4.2 特征点提取
在Frame
类构造函数中调用成员变量mpORBextractorLeft
和mpORBextractorRight
的()
运算符进行特征点提取.
成员函数/变量 | 访问控制 | 意义 |
---|---|---|
ORBextractor* mpORBextractorLeft ORBextractor* mpORBextractorRight | public | 左右目图像的特征点提取器 |
cv::Mat mDescriptors cv::Mat mDescriptorsRight | public | 左右目图像特征点描述子 |
std::vector mvKeys std::vector mvKeysRight | public | 畸变矫正前的左/右目特征点 |
std::vector mvKeysUn | public | 畸变矫正后的左目特征点 |
std::vector mvuRight | public | 左目特征点在右目中匹配特征点的横坐标 (左右目匹配特征点的纵坐标相同) |
std::vector mvDepth | public | 特征点深度 |
float mThDepth | public | 判断单目特征点和双目特征点的阈值 深度低于该值的特征点被认为是双目特征点 深度低于该值得特征点被认为是单目特征点 |
mvKeys、 mvKeysUn、 mvuRight、 mvDepth的坐标索引是对应的,也就是说对于第i个图像特征点:
其畸变矫正前的左目特征点是mvKeys[i].
其畸变矫正后的左目特征点是mvKeysUn[i].
其在右目图片中对应特征点的横坐标为mvuRight[i],纵坐标与mvKeys[i]的纵坐标相同.
特征点的深度是mvDepth[i].
对于单目特征点(单目相机输入的特征点或没有找到右目匹配的左目图像特征点),其mvuRight和mvDepth均为-1.
4.2.1 特征点提取: ExtractORB()
成员函数/变量 | 访问控制 | 意义 |
---|---|---|
void ExtractORB(int flag, const cv::Mat &im) | public | 进行ORB 特征提取 |
void Frame::ExtractORB(int flag, const cv::Mat &im) {
if (flag == 0) // flag==0, 表示对左图提取ORB特征点
(*mpORBextractorLeft)(im, cv::Mat(), mvKeys, mDescriptors);
else // flag==1, 表示对右图提取ORB特征点
(*mpORBextractorRight)(im, cv::Mat(), mvKeysRight, mDescriptorsRight);
}
4.3 ORB-SLAM2对双目/RGBD特征点的预处理
双目/RGBD相机中可以得到特征点的立体信息,包括右目特征点信息(mvuRight)、特征点深度信息(mvDepth)
对于双目相机,通过双目特征点匹配关系计算特征点的深度值.
对于RGBD相机,根据特征点深度构造虚拟的右目图像特征点.
成员函数/变量 | 访问控制 | 意义 |
---|---|---|
void ComputeStereoMatches() | public | 双目图像特征点匹配,用于双目相机输入图像预处理 |
void ComputeStereoFromRGBD(const cv::Mat &imDepth) | public | 根据深度信息构造虚拟右目图像,用于RGBD相机输入图像预处理 |
cv::Mat UnprojectStereo(const int &i) | public | 根据深度信息将第i 个特征点反投影成MapPoint |
通过这种预处理,在后面SLAM系统的其他部分中不再区分双目特征点和RGBD特征点,它们以双目特征点的形式被处理.(仅通过判断mvuRight[idx]
判断某特征点是否有深度).
int ORBmatcher::SearchByProjection(Frame &F, const vector<MapPoint *> &vpMapPoints, const float th) {
// ...
for (size_t idx : vIndices) {
if (F.mvuRight[idx] > 0) { // 通过判断 mvuRight[idx] 判断该特征点是否有深度
// 针对有深度的特征点特殊处理
} else {
// 针对单目特征点的特殊处理
}
}
// ...
}
4.4 双目视差公式
观测距离,基线,焦距,视差,根据三角形相似性:
得到:
4.5 双目特征点的处理:双目图像特征点匹配: ComputeStereoMatches()
稀疏立体匹配原理
函数ComputeStereoMatches()
两帧图像稀疏立体匹配
*输入:两帧立体矫正后的图像对应的orb特征点集
*过程:
1.行特征点统计
2.粗匹配
3.精确匹配SAD
4.亚像素精度优化
5.最有视差值/深度选择
6.删除离缺点(outliers)
*输出:稀疏特征点视差图/深度图和匹配结果
双目相机分别提取到左右目特征点后对特征点进行双目匹配,并通过双目视差估计特征点深度.双目特征点匹配步骤:
粗匹配: 根据特征点描述子距离和金字塔层级判断匹配.粗匹配关系是按行寻找的,对于左目图像中每个特征点,在右目图像对应行上寻找匹配特征点.
精匹配: 根据特征点周围窗口内容相似度判断匹配.
亚像素插值: 将特征点相似度与匹配坐标之间拟合成二次曲线,寻找最佳匹配位置(得到的是一个小数).
记录右目匹配mvuRight和深度mvDepth信息.
离群点筛选: 以平均相似度的2.1倍为标准,筛选离群点.
亚像素插值
void Frame::ComputeStereoMatches()
{
/*两帧图像稀疏立体匹配(即:ORB特征点匹配,非逐像素的密集匹配,但依然满足行对齐)
* 输入:两帧立体矫正后的图像img_left 和 img_right 对应的orb特征点集
* 过程:
1. 行特征点统计. 统计img_right每一行上的ORB特征点集,便于使用立体匹配思路(行搜索/极线搜索)进行同名点搜索, 避免逐像素的判断.
2. 粗匹配. 根据步骤1的结果,对img_left第i行的orb特征点pi,在img_right的第i行上的orb特征点集中搜索相似orb特征点, 得到qi
3. 精确匹配. 以点qi为中心,半径为r的范围内,进行块匹配(归一化SAD),进一步优化匹配结果
4. 亚像素精度优化. 步骤3得到的视差为uchar/int类型精度,并不一定是真实视差,通过亚像素差值(抛物线插值)获取float精度的真实视差
5. 最优视差值/深度选择. 通过胜者为王算法(WTA)获取最佳匹配点。
6. 删除离群点(outliers). 块匹配相似度阈值判断,归一化sad最小,并不代表就一定是正确匹配,比如光照变化、弱纹理等会造成误匹配
* 输出:稀疏特征点视差图/深度图(亚像素精度)mvDepth 匹配结果 mvuRight
*/
// 为匹配结果预先分配内存,数据类型为float型
// mvuRight存储右图匹配点索引
// mvDepth存储特征点的深度信息
mvuRight = vector<float>(N,-1.0f);
mvDepth = vector<float>(N,-1.0f);
// orb特征相似度阈值 -> mean ~= (max + min) / 2
const int thOrbDist = (ORBmatcher::TH_HIGH+ORBmatcher::TH_LOW)/2;
// 金字塔底层(0层)图像高 nRows
const int nRows = mpORBextractorLeft->mvImagePyramid[0].rows;
// 二维vector存储每一行的orb特征点的列坐标的索引,为什么是vector,因为每一行的特征点有可能不一样,例如
// vRowIndices[0] = [1,2,5,8, 11] 第1行有5个特征点,他们的列号(即x坐标)分别是1,2,5,8,11
// vRowIndices[1] = [2,6,7,9, 13, 17, 20] 第2行有7个特征点.etc
vector<vector<size_t> > vRowIndices(nRows, vector<size_t>());
for(int i=0; i<nRows; i++) vRowIndices[i].reserve(200);
// 右图特征点数量,N表示数量 r表示右图,且不能被修改
const int Nr = mvKeysRight.size();
// Step 1. 行特征点统计。 考虑用图像金字塔尺度作为偏移,左图中对应右图的一个特征点可能存在于多行,而非唯一的一行
for(int iR = 0; iR < Nr; iR++) {
// 获取特征点ir的y坐标,即行号
const cv::KeyPoint &kp = mvKeysRight[iR];
const float &kpY = kp.pt.y;
// 计算特征点ir在行方向上,可能的偏移范围r,即可能的行号为[kpY + r, kpY -r]
// 2 表示在全尺寸(scale = 1)的情况下,假设有2个像素的偏移,随着尺度变化,r也跟着变化
const float r = 2.0f * mvScaleFactors[mvKeysRight[iR].octave];
const int maxr = ceil(kpY + r);
const int minr = floor(kpY - r);
// 将特征点ir保证在可能的行号中
for(int yi=minr;yi<=maxr;yi++)
vRowIndices[yi].push_back(iR);
}
// 下面是 粗匹配 + 精匹配的过程
// 对于立体矫正后的两张图,在列方向(x)存在最大视差maxd和最小视差mind
// 也即是左图中任何一点p,在右图上的匹配点的范围为应该是[p - maxd, p - mind], 而不需要遍历每一行所有的像素
// maxd = baseline * length_focal / minZ
// mind = baseline * length_focal / maxZ
const float minZ = mb;
const float minD = 0; // 最小视差为0,对应无穷远
const float maxD = mbf/minZ; // 最大视差对应的距离是相机的基线
// 保存sad块匹配相似度和左图特征点索引
vector<pair<int, int> > vDistIdx;
vDistIdx.reserve(N);
// 为左图每一个特征点il,在右图搜索最相似的特征点ir
for(int iL=0; iL<N; iL++) {
const cv::KeyPoint &kpL = mvKeys[iL];
const int &levelL = kpL.octave;
const float &vL = kpL.pt.y;
const float &uL = kpL.pt.x;
// 获取左图特征点il所在行,以及在右图对应行中可能的匹配点
const vector<size_t> &vCandidates = vRowIndices[vL];
if(vCandidates.empty()) continue;
// 计算理论上的最佳搜索范围
const float minU = uL-maxD;
const float maxU = uL-minD;
// 最大搜索范围小于0,说明无匹配点
if(maxU<0) continue;
// 初始化最佳相似度,用最大相似度,以及最佳匹配点索引
int bestDist = ORBmatcher::TH_HIGH;
size_t bestIdxR = 0;
const cv::Mat &dL = mDescriptors.row(iL);
// Step 2. 粗配准。左图特征点il与右图中的可能的匹配点进行逐个比较,得到最相似匹配点的描述子距离和索引
for(size_t iC=0; iC<vCandidates.size(); iC++) {
const size_t iR = vCandidates[iC];
const cv::KeyPoint &kpR = mvKeysRight[iR];
// 左图特征点il与待匹配点ic的空间尺度差超过2,放弃
if(kpR.octave<levelL-1 || kpR.octave>levelL+1)
continue;
// 使用列坐标(x)进行匹配,和stereomatch一样
const float &uR = kpR.pt.x;
// 超出理论搜索范围[minU, maxU],可能是误匹配,放弃
if(uR >= minU && uR <= maxU) {
// 计算匹配点il和待匹配点ic的相似度dist
const cv::Mat &dR = mDescriptorsRight.row(iR);
const int dist = ORBmatcher::DescriptorDistance(dL,dR);
//统计最小相似度及其对应的列坐标(x)
if( dist<bestDist ) {
bestDist = dist;
bestIdxR = iR;
}
}
}
// Step 3. 图像块滑动窗口用SAD(Sum of absolute differences,差的绝对和)实现精确匹配.
if(bestDist<thOrbDist) {
// 如果刚才匹配过程中的最佳描述子距离小于给定的阈值
// 计算右图特征点x坐标和对应的金字塔尺度
const float uR0 = mvKeysRight[bestIdxR].pt.x;
const float scaleFactor = mvInvScaleFactors[kpL.octave];
// 尺度缩放后的左右图特征点坐标
const float scaleduL = round(kpL.pt.x*scaleFactor);
const float scaledvL = round(kpL.pt.y*scaleFactor);
const float scaleduR0 = round(uR0*scaleFactor);
// 滑动窗口搜索, 类似模版卷积或滤波
// w表示sad相似度的窗口半径
const int w = 5;
// 提取左图中,以特征点(scaleduL,scaledvL)为中心, 半径为w的图像块patch
cv::Mat IL = mpORBextractorLeft->mvImagePyramid[kpL.octave].rowRange(scaledvL-w,scaledvL+w+1).colRange(scaleduL-w,scaleduL+w+1);
IL.convertTo(IL,CV_32F);
// 图像块均值归一化,降低亮度变化对相似度计算的影响
IL = IL - IL.at<float>(w,w) * cv::Mat::ones(IL.rows,IL.cols,CV_32F);
//初始化最佳相似度
int bestDist = INT_MAX;
// 通过滑动窗口搜索优化,得到的列坐标偏移量
int bestincR = 0;
//滑动窗口的滑动范围为(-L, L)
const int L = 5;
// 初始化存储图像块相似度
vector<float> vDists;
vDists.resize(2*L+1);
// 计算滑动窗口滑动范围的边界,因为是块匹配,还要算上图像块的尺寸
// 列方向起点 iniu = r0 - 最大窗口滑动范围 - 图像块尺寸
// 列方向终点 eniu = r0 + 最大窗口滑动范围 + 图像块尺寸 + 1
// 此次 + 1 和下面的提取图像块是列坐标+1是一样的,保证提取的图像块的宽是2 * w + 1
// ! 源码: const float iniu = scaleduR0+L-w; 错误
// scaleduR0:右图特征点x坐标
const float iniu = scaleduR0-L-w;
const float endu = scaleduR0+L+w+1;
// 判断搜索是否越界
if(iniu<0 || endu >= mpORBextractorRight->mvImagePyramid[kpL.octave].cols)
continue;
// 在搜索范围内从左到右滑动,并计算图像块相似度
for(int incR=-L; incR<=+L; incR++) {
// 提取右图中,以特征点(scaleduL,scaledvL)为中心, 半径为w的图像快patch
cv::Mat IR = mpORBextractorRight->mvImagePyramid[kpL.octave].rowRange(scaledvL-w,scaledvL+w+1).colRange(scaleduR0+incR-w,scaleduR0+incR+w+1);
IR.convertTo(IR,CV_32F);
// 图像块均值归一化,降低亮度变化对相似度计算的影响
IR = IR - IR.at<float>(w,w) * cv::Mat::ones(IR.rows,IR.cols,CV_32F);
// sad 计算,值越小越相似
float dist = cv::norm(IL,IR,cv::NORM_L1);
// 统计最小sad和偏移量
if(dist<bestDist) {
bestDist = dist;
bestincR = incR;
}
//L+incR 为refine后的匹配点列坐标(x)
vDists[L+incR] = dist;
}
// 搜索窗口越界判断
if(bestincR==-L || bestincR==L)
continue;
// Step 4. 亚像素插值, 使用最佳匹配点及其左右相邻点构成抛物线来得到最小sad的亚像素坐标
// 使用3点拟合抛物线的方式,用极小值代替之前计算的最优是差值
// \ / <- 由视差为14,15,16的相似度拟合的抛物线
// . .(16)
// .14 .(15) <- int/uchar最佳视差值
// .
// (14.5)<- 真实的视差值
// deltaR = 15.5 - 16 = -0.5
// 公式参考opencv sgbm源码中的亚像素插值公式
// 或论文<<On Building an Accurate Stereo Matching System on Graphics Hardware>> 公式7
const float dist1 = vDists[L+bestincR-1];
const float dist2 = vDists[L+bestincR];
const float dist3 = vDists[L+bestincR+1];
const float deltaR = (dist1-dist3)/(2.0f*(dist1+dist3-2.0f*dist2));
// 亚像素精度的修正量应该是在[-1,1]之间,否则就是误匹配
if(deltaR<-1 || deltaR>1)
continue;
// 根据亚像素精度偏移量delta调整最佳匹配索引
float bestuR = mvScaleFactors[kpL.octave]*((float)scaleduR0+(float)bestincR+deltaR);
float disparity = (uL-bestuR);
if(disparity>=minD && disparity<maxD) {
// 如果存在负视差,则约束为0.01
if( disparity <=0 ) {
disparity=0.01;
bestuR = uL-0.01;
}
// 根据视差值计算深度信息
// 保存最相似点的列坐标(x)信息
// 保存归一化sad最小相似度
// Step 5. 最优视差值/深度选择.
mvDepth[iL]=mbf/disparity;
mvuRight[iL] = bestuR;
vDistIdx.push_back(pair<int,int>(bestDist,iL));
}
}
}
// Step 6. 删除离群点(outliers)
// 块匹配相似度阈值判断,归一化sad最小,并不代表就一定是匹配的,比如光照变化、弱纹理、无纹理等同样会造成误匹配
// 误匹配判断条件 norm_sad > 1.5 * 1.4 * median
sort(vDistIdx.begin(),vDistIdx.end());
const float median = vDistIdx[vDistIdx.size()/2].first;
const float thDist = 1.5f*1.4f*median;
for(int i=vDistIdx.size()-1;i>=0;i--) {
if(vDistIdx[i].first<thDist)
break;
else {
// 误匹配点置为-1,和初始化时保持一直,作为error code
mvuRight[vDistIdx[i].second]=-1;
mvDepth[vDistIdx[i].second]=-1;
}
}
}
4.6 RBGD特征点的处理: 根据深度信息构造虚拟右目图像: ComputeStereoFromRGBD()
![请添加图片描述](https://i-blog.csdnimg.cn/blog_migrate/553970400110e90685d7270d6602540b.png)
对于RGB特征点,根据深度信息构造虚拟右目图像
//计算RGBD图像的立体深度信息
void Frame::ComputeStereoFromRGBD(const cv::Mat &imDepth) //参数是深度图像
{
/** 主要步骤如下:.对于彩色图像中的每一个特征点:<ul> */
// mvDepth直接由depth图像读取`
//这里是初始化这两个存储“右图”匹配特征点横坐标和存储特征点深度值的vector
mvuRight = vector<float>(N,-1);
mvDepth = vector<float>(N,-1);
//开始遍历彩色图像中的所有特征点
for(int i=0; i<N; i++)
{
/** <li> 从<b>未矫正的特征点</b>提供的坐标来读取深度图像拿到这个点的深度数据 </li> */
//获取校正前和校正后的特征点
const cv::KeyPoint &kp = mvKeys[i];
const cv::KeyPoint &kpU = mvKeysUn[i];
//获取其横纵坐标,注意 NOTICE 是校正前的特征点的
const float &v = kp.pt.y;
const float &u = kp.pt.x;
//从深度图像中获取这个特征点对应的深度点
//NOTE 从这里看对深度图像进行去畸变处理是没有必要的,我们依旧可以直接通过未矫正的特征点的坐标来直接拿到深度数据
const float d = imDepth.at<float>(v,u);
//
/** <li> 如果获取到的深度点合法(d>0), 那么就保存这个特征点的深度,并且计算出等效的\在假想的右图中该特征点所匹配的特征点的横坐标 </li>
* \n 这个横坐标的计算是 x-mbf/d
* \n 其中的x使用的是<b>矫正后的</b>特征点的图像坐标
*/
if(d>0)
{
//那么就保存这个点的深度
mvDepth[i] = d;
//根据这个点的深度计算出等效的、在假想的右图中的该特征点的横坐标
//TODO 话说为什么要计算这个嘞,计算出来之后有什么用?可能是为了保持计算一致
mvuRight[i] = kpU.pt.x-mbf/d;
}//如果获取到的深度点合法
}//开始遍历彩色图像中的所有特征点
/** </ul> */
}
4.7 畸变矫正: UndistortKeyPoints()
成员函数/变量 | 访问控制 | 意义 |
---|---|---|
cv::Mat mDistCoef | public | 相机的畸变矫正参数 |
std::vector mvKeys std::vector mvKeysRight | public | 畸变矫正前的左/右目特征点 |
std::vector mvKeysUn | public | 畸变矫正后的左目特征点 |
void UndistortKeyPoints() | private | 对所有特征点进行畸变矫正 |
float mnMinX float mnMaxX float mnMinY float mnMaxY | public | 畸变矫正后的图像边界 |
void ComputeImageBounds(const cv::Mat &imLeft) | private | 计算畸变矫正后的图像边界 |
实际上,畸变矫正只对单目和RGBD相机输入图像有效,双目相机的畸变矫正参数均为0
,因为双目相机数据集在发布之前预先做了双目矫正.
- RGBD相机输入配置文件
TUM1.yaml
-
Camera.k1: 0.262383 Camera.k2: -0.953104 Camera.p1: -0.005358 Camera.p2: 0.002628 Camera.k3: 1.163314 #....
-
双目相机输入配置文件
EuRoC.yaml
Camera.k1: 0.0 Camera.k2: 0.0 Camera.p1: 0.0 Camera.p2: 0.0 # ...
-
单目相机输入配置文件
TUM1.yaml
%YAML:1.0 #-------------------------------------------------------------------------------------------- # Camera Parameters. Adjust them! #-------------------------------------------------------------------------------------------- # Camera calibration and distortion parameters (OpenCV) Camera.fx: 517.306408 Camera.fy: 516.469215 Camera.cx: 318.643040 Camera.cy: 255.313989 Camera.k1: 0.262383 Camera.k2: -0.953104 Camera.p1: -0.005358 Camera.p2: 0.002628 Camera.k3: 1.163314
双目矫正效果如下,双目矫正将两个相机的成像平面矫正到同一平面上.双目矫正之后两个相机的极线相互平行,极点在无穷远处,这也是我们在函数
ComputeStereoMatches()
中做极线搜索的理论基础.UndistortKeyPoints()
函数和ComputeImageBounds()
内调用了cv::undistortPoints()
函数对特征点进行畸变矫正 -
void Frame::UndistortKeyPoints() { // Step 1 如果第一个畸变参数为0,不需要矫正。第一个畸变参数k1是最重要的,一般不为0,为0的话,说明畸变参数都是0 //变量mDistCoef中存储了opencv指定格式的去畸变参数,格式为:(k1,k2,p1,p2,k3) if(mDistCoef.at<float>(0)==0.0) { mvKeysUn=mvKeys; return; } // Step 2 如果畸变参数不为0,用OpenCV函数进行畸变矫正 // Fill matrix with points // N为提取的特征点数量,为满足OpenCV函数输入要求,将N个特征点保存在N*2的矩阵中 cv::Mat mat(N,2,CV_32F); //遍历每个特征点,并将它们的坐标保存到矩阵中 for(int i=0; i<N; i++) { //然后将这个特征点的横纵坐标分别保存 mat.at<float>(i,0)=mvKeys[i].pt.x; mat.at<float>(i,1)=mvKeys[i].pt.y; } // Undistort points // 函数reshape(int cn,int rows=0) 其中cn为更改后的通道数,rows=0表示这个行将保持原来的参数不变 //为了能够直接调用opencv的函数来去畸变,需要先将矩阵调整为2通道(对应坐标x,y) mat=mat.reshape(2); cv::undistortPoints( mat, //输入的特征点坐标 mat, //输出的校正后的特征点坐标覆盖原矩阵 mK, //相机的内参数矩阵 mDistCoef, //相机畸变参数矩阵 cv::Mat(), //一个空矩阵,对应为函数原型中的R mK); //新内参数矩阵,对应为函数原型中的P //调整回只有一个通道,回归我们正常的处理方式 mat=mat.reshape(1); // Fill undistorted keypoint vector // Step 存储校正后的特征点 mvKeysUn.resize(N); //遍历每一个特征点 for(int i=0; i<N; i++) { //根据索引获取这个特征点 //注意之所以这样做而不是直接重新声明一个特征点对象的目的是,能够得到源特征点对象的其他属性 cv::KeyPoint kp = mvKeys[i]; //读取校正后的坐标并覆盖老坐标 kp.pt.x=mat.at<float>(i,0); kp.pt.y=mat.at<float>(i,1); mvKeysUn[i]=kp; } } /** * @brief 计算去畸变图像的边界 * * @param[in] imLeft 需要计算边界的图像 */ void Frame::ComputeImageBounds(const cv::Mat &imLeft) { // 如果畸变参数不为0,用OpenCV函数进行畸变矫正 if(mDistCoef.at<float>(0)!=0.0) { // 保存矫正前的图像四个边界点坐标: (0,0) (cols,0) (0,rows) (cols,rows) cv::Mat mat(4,2,CV_32F); mat.at<float>(0,0)=0.0; //左上 mat.at<float>(0,1)=0.0; mat.at<float>(1,0)=imLeft.cols; //右上 mat.at<float>(1,1)=0.0; mat.at<float>(2,0)=0.0; //左下 mat.at<float>(2,1)=imLeft.rows; mat.at<float>(3,0)=imLeft.cols; //右下 mat.at<float>(3,1)=imLeft.rows; // Undistort corners // 和前面校正特征点一样的操作,将这几个边界点作为输入进行校正 mat=mat.reshape(2); cv::undistortPoints(mat,mat,mK,mDistCoef,cv::Mat(),mK); mat=mat.reshape(1); //校正后的四个边界点已经不能够围成一个严格的矩形,因此在这个四边形的外侧加边框作为坐标的边界 mnMinX = min(mat.at<float>(0,0),mat.at<float>(2,0));//左上和左下横坐标最小的 mnMaxX = max(mat.at<float>(1,0),mat.at<float>(3,0));//右上和右下横坐标最大的 mnMinY = min(mat.at<float>(0,1),mat.at<float>(1,1));//左上和右上纵坐标最小的 mnMaxY = max(mat.at<float>(2,1),mat.at<float>(3,1));//左下和右下纵坐标最小的 } else { // 如果畸变参数为0,就直接获得图像边界 mnMinX = 0.0f; mnMaxX = imLeft.cols; mnMinY = 0.0f; mnMaxY = imLeft.rows; } }
4.8 特征点分配:
AssignFeaturesToGrid()
- 在对特征点进行预处理后,将特征点分配到
48
行64
列的网格中以加速匹配成员函数/变量 访问控制 意义 FRAME_GRID_ROWS=48
FRAME_GRID_COLS=64
#DEFINE
宏网格行数/列数 float mfGridElementWidthInv
float mfGridElementHeightInv
public static
public static
每个网格的宽度/高度 std::vector mGrid[FRAME_GRID_COLS][FRAME_GRID_ROWS]
public
每个网格内特征点编号列表 void AssignFeaturesToGrid()
private
将特征点分配到网格中 vector GetFeaturesInArea(float &x, float &y, float &r, int minLevel, int maxLevel)
public
获取半径为 r
的圆域内的特征点编号列表成员变量std::vector mGrid[FRAME_GRID_COLS][FRAME_GRID_ROWS]是一个二维数组,数组中每个元素是对应网格的所有特征点索引列表.
static成员变量mfGridElementWidthInv、mfGridElementHeightInv表示网格宽度/高度,它们在第一次调用Frame构造函数时被计算赋值.
Frame::Frame(const cv::Mat &imLeft, const cv::Mat &imRight, const double &timeStamp, ORBextractor* extractorLeft, ORBextractor* extractorRight, ORBVocabulary* voc, cv::Mat &K, cv::Mat &distCoef, const float &bf, const float &thDepth) :mpORBvocabulary(voc),mpORBextractorLeft(extractorLeft),mpORBextractorRight(extractorRight), mTimeStamp(timeStamp), mK(K.clone()),mDistCoef(distCoef.clone()), mbf(bf), mThDepth(thDepth), mpReferenceKF(static_cast<KeyFrame*>(NULL)) { // Step 1 帧的ID 自增 mnId=nNextId++; // Step 2 计算图像金字塔的参数 //获取图像金字塔的层数 mnScaleLevels = mpORBextractorLeft->GetLevels(); //这个是获得层与层之前的缩放比 mfScaleFactor = mpORBextractorLeft->GetScaleFactor(); //计算上面缩放比的对数, NOTICE log=自然对数,log10=才是以10为基底的对数 mfLogScaleFactor = log(mfScaleFactor); //获取每层图像的缩放因子 mvScaleFactors = mpORBextractorLeft->GetScaleFactors(); //同样获取每层图像缩放因子的倒数 mvInvScaleFactors = mpORBextractorLeft->GetInverseScaleFactors(); //高斯模糊的时候,使用的方差 mvLevelSigma2 = mpORBextractorLeft->GetScaleSigmaSquares(); //获取sigma^2的倒数 mvInvLevelSigma2 = mpORBextractorLeft->GetInverseScaleSigmaSquares(); // ORB extraction // Step 3 对左目右目图像提取ORB特征点, 第一个参数0-左图, 1-右图。为加速计算,同时开了两个线程计算 thread threadLeft(&Frame::ExtractORB, //该线程的主函数 this, //当前帧对象的对象指针 0, //表示是左图图像 imLeft); //图像数据 //对右目图像提取ORB特征,参数含义同上 thread threadRight(&Frame::ExtractORB,this,1,imRight); //等待两张图像特征点提取过程完成 threadLeft.join(); threadRight.join(); //mvKeys中保存的是左图像中的特征点,这里是获取左侧图像中特征点的个数 N = mvKeys.size(); //如果左图像中没有成功提取到特征点那么就返回,也意味这这一帧的图像无法使用 if(mvKeys.empty()) return; // Step 4 用OpenCV的矫正函数、内参对提取到的特征点进行矫正 // 实际上由于双目输入的图像已经预先经过矫正,所以实际上并没有对特征点进行任何处理操作 UndistortKeyPoints(); // Step 5 计算双目间特征点的匹配,只有匹配成功的特征点会计算其深度,深度存放在 mvDepth // mvuRight中存储的应该是左图像中的点所匹配的在右图像中的点的横坐标(纵坐标相同) ComputeStereoMatches(); // 初始化本帧的地图点 mvpMapPoints = vector<MapPoint*>(N,static_cast<MapPoint*>(NULL)); // 记录地图点是否为外点,初始化均为外点false mvbOutlier = vector<bool>(N,false); // This is done only for the first Frame (or after a change in the calibration) // Step 5 计算去畸变后图像边界,将特征点分配到网格中。这个过程一般是在第一帧或者是相机标定参数发生变化之后进行 if(mbInitialComputations) { //计算去畸变后图像的边界 ComputeImageBounds(imLeft); // 表示一个图像像素相当于多少个图像网格列(宽) mfGridElementWidthInv=static_cast<float>(FRAME_GRID_COLS)/static_cast<float>(mnMaxX-mnMinX); // 表示一个图像像素相当于多少个图像网格行(高) mfGridElementHeightInv=static_cast<float>(FRAME_GRID_ROWS)/static_cast<float>(mnMaxY-mnMinY); //给类的静态成员变量复制 fx = K.at<float>(0,0); fy = K.at<float>(1,1); cx = K.at<float>(0,2); cy = K.at<float>(1,2); // 猜测是因为这种除法计算需要的时间略长,所以这里直接存储了这个中间计算结果 invfx = 1.0f/fx; invfy = 1.0f/fy; //特殊的初始化过程完成,标志复位 mbInitialComputations=false; } // 双目相机基线长度 mb = mbf/fx; // 将特征点分配到图像网格中 AssignFeaturesToGrid(); }
函数
AssignFeaturesToGrid()
将特征点分配到网格中void Frame::AssignFeaturesToGrid() { // Step 1 给存储特征点的网格数组 Frame::mGrid 预分配空间 // ? 这里0.5 是为什么?节省空间? // FRAME_GRID_COLS = 64,FRAME_GRID_ROWS=48 int nReserve = 0.5f*N/(FRAME_GRID_COLS*FRAME_GRID_ROWS); //开始对mGrid这个二维数组中的每一个vector元素遍历并预分配空间 for(unsigned int i=0; i<FRAME_GRID_COLS;i++) for (unsigned int j=0; j<FRAME_GRID_ROWS;j++) mGrid[i][j].reserve(nReserve); // Step 2 遍历每个特征点,将每个特征点在mvKeysUn中的索引值放到对应的网格mGrid中 for(int i=0;i<N;i++) { //从类的成员变量中获取已经去畸变后的特征点 const cv::KeyPoint &kp = mvKeysUn[i]; //存储某个特征点所在网格的网格坐标,nGridPosX范围:[0,FRAME_GRID_COLS], nGridPosY范围:[0,FRAME_GRID_ROWS] int nGridPosX, nGridPosY; // 计算某个特征点所在网格的网格坐标,如果找到特征点所在的网格坐标,记录在nGridPosX,nGridPosY里,返回true,没找到返回false if(PosInGrid(kp,nGridPosX,nGridPosY)) //如果找到特征点所在网格坐标,将这个特征点的索引添加到对应网格的数组mGrid中 mGrid[nGridPosX][nGridPosY].push_back(i); } }
函数
vector GetFeaturesInArea(float &x, float &y, float &r, int minLevel, int maxLevel)
获取点(y,x)
周围半径为r
的圆域内所有特征点编号.
4.9 构造函数: Frame()
Frame()
构造函数依次进行上面介绍的步骤:
/**
* @brief 为RGBD相机准备的帧构造函数
*
* @param[in] imGray 对RGB图像灰度化之后得到的灰度图像
* @param[in] imDepth 深度图像
* @param[in] timeStamp 时间戳
* @param[in] extractor 特征点提取器句柄
* @param[in] voc ORB特征点词典的句柄
* @param[in] K 相机的内参数矩阵
* @param[in] distCoef 相机的去畸变参数
* @param[in] bf baseline*bf
* @param[in] thDepth 远点和近点的深度区分阈值
*/
Frame::Frame(const cv::Mat &imGray, const cv::Mat &imDepth, const double &timeStamp, ORBextractor* extractor,ORBVocabulary* voc, cv::Mat &K, cv::Mat &distCoef, const float &bf, const float &thDepth)
:mpORBvocabulary(voc),mpORBextractorLeft(extractor),mpORBextractorRight(static_cast<ORBextractor*>(NULL)),
mTimeStamp(timeStamp), mK(K.clone()),mDistCoef(distCoef.clone()), mbf(bf), mThDepth(thDepth)
{
// Step 1 帧的ID 自增
mnId=nNextId++;
// Step 2 计算图像金字塔的参数
//获取图像金字塔的层数
mnScaleLevels = mpORBextractorLeft->GetLevels();
//获取每层的缩放因子
mfScaleFactor = mpORBextractorLeft->GetScaleFactor();
//计算每层缩放因子的自然对数
mfLogScaleFactor = log(mfScaleFactor);
//获取各层图像的缩放因子
mvScaleFactors = mpORBextractorLeft->GetScaleFactors();
//获取各层图像的缩放因子的倒数
mvInvScaleFactors = mpORBextractorLeft->GetInverseScaleFactors();
//TODO 也是获取这个不知道有什么实际含义的sigma^2
mvLevelSigma2 = mpORBextractorLeft->GetScaleSigmaSquares();
//计算上面获取的sigma^2的倒数
mvInvLevelSigma2 = mpORBextractorLeft->GetInverseScaleSigmaSquares();
/** 3. 提取彩色图像(其实现在已经灰度化成为灰度图像了)的特征点 \n Frame::ExtractORB() */
// ORB extraction
// Step 3 对图像进行提取特征点, 第一个参数0-左图, 1-右图
ExtractORB(0,imGray);
//获取特征点的个数
N = mvKeys.size();
//如果这一帧没有能够提取出特征点,那么就直接返回了
if(mvKeys.empty())
return;
// Step 4 用OpenCV的矫正函数、内参对提取到的特征点进行矫正
UndistortKeyPoints();
// Step 5 获取图像的深度,并且根据这个深度推算其右图中匹配的特征点的视差
ComputeStereoFromRGBD(imDepth);
// 初始化本帧的地图点
mvpMapPoints = vector<MapPoint*>(N,static_cast<MapPoint*>(NULL));
// 记录地图点是否为外点,初始化均为外点false
mvbOutlier = vector<bool>(N,false);
// This is done only for the first Frame (or after a change in the calibration)
// Step 5 计算去畸变后图像边界,将特征点分配到网格中。这个过程一般是在第一帧或者是相机标定参数发生变化之后进行
if(mbInitialComputations)
{
//计算去畸变后图像的边界
ComputeImageBounds(imGray);
// 表示一个图像像素相当于多少个图像网格列(宽)
mfGridElementWidthInv=static_cast<float>(FRAME_GRID_COLS)/static_cast<float>(mnMaxX-mnMinX);
// 表示一个图像像素相当于多少个图像网格行(高)
mfGridElementHeightInv=static_cast<float>(FRAME_GRID_ROWS)/static_cast<float>(mnMaxY-mnMinY);
//给类的静态成员变量复制
fx = K.at<float>(0,0);
fy = K.at<float>(1,1);
cx = K.at<float>(0,2);
cy = K.at<float>(1,2);
// 猜测是因为这种除法计算需要的时间略长,所以这里直接存储了这个中间计算结果
invfx = 1.0f/fx;
invfy = 1.0f/fy;
//特殊的初始化过程完成,标志复位
mbInitialComputations=false;
}
// 计算假想的基线长度 baseline= mbf/fx
// 后面要对从RGBD相机输入的特征点,结合相机基线长度,焦距,以及点的深度等信息来计算其在假想的"右侧图像"上的匹配点
mb = mbf/fx;
// 将特征点分配到图像网格中
AssignFeaturesToGrid();
}
4.10 Frame
类的用途
Tracking
类有两个Frame
类型的成员变量
成员函数/变量 | 访问控制 | 意义 |
---|---|---|
Frame mCurrentFrame | public | 当前正在处理的帧 |
Frame mLastFrame | protected | 上一帧 |
Tracking
线程每收到一帧图像,就调用函数Tracking::GrabImageMonocular()
、Tracking::GrabImageStereo()
或Tracking::GrabImageRGBD()
创建一个Frame
对象,赋值给mCurrentFrame
/**
* @brief
* 输入左目RGB或RGBA图像,输出世界坐标系到该帧相机坐标系的变换矩阵
*
* @param[in] im 单目图像
* @param[in] timestamp 时间戳
* @return cv::Mat
*
* Step 1 :将彩色图像转为灰度图像
* Step 2 :构造Frame
* Step 3 :跟踪
*/
cv::Mat Tracking::GrabImageMonocular(const cv::Mat &im,const double ×tamp)
{
mImGray = im;
// Step 1 :将彩色图像转为灰度图像
//若图片是3、4通道的,还需要转化成灰度图
if(mImGray.channels()==3)
{
if(mbRGB)
cvtColor(mImGray,mImGray,CV_RGB2GRAY);
else
cvtColor(mImGray,mImGray,CV_BGR2GRAY);
}
else if(mImGray.channels()==4)
{
if(mbRGB)
cvtColor(mImGray,mImGray,CV_RGBA2GRAY);
else
cvtColor(mImGray,mImGray,CV_BGRA2GRAY);
}
// Step 2 :构造Frame
//判断该帧是不是初始化
if(mState==NOT_INITIALIZED || mState==NO_IMAGES_YET) //没有成功初始化的前一个状态就是NO_IMAGES_YET
mCurrentFrame = Frame(
mImGray,
timestamp,
mpIniORBextractor, //初始化ORB特征点提取器会提取2倍的指定特征点数目
mpORBVocabulary,
mK,
mDistCoef,
mbf,
mThDepth);
else
mCurrentFrame = Frame(
mImGray,
timestamp,
mpORBextractorLeft, //正常运行的时的ORB特征点提取器,提取指定数目特征点
mpORBVocabulary,
mK,
mDistCoef,
mbf,
mThDepth);
// Step 3 :跟踪
Track();
//返回当前帧的位姿
return mCurrentFrame.mTcw.clone();
}
Track()
函数跟踪结束后,会将mCurrentFrame
赋值给mLastFrame
void Tracking::Track() {
// 进行跟踪
// ...
// 将当前帧记录为上一帧
mLastFrame = Frame(mCurrentFrame);
// ...
}
除了少数被选为KeyFrame
的帧以外,大部分Frame
对象的作用仅在于Tracking
线程内追踪当前帧位姿,不会对LocalMapping
线程和LoopClosing
线程产生任何影响,在mLastFrame
和mCurrentFrame
更新之后就被系统销毁了.
5. ORB-SLAM2代码详解05_关键帧KeyFrame(对应文件KeyFrame.cc,KeyFrame.h)
5.1 各成员函数/变量
5.1.1 共视图: mConnectedKeyFrameWeights
能看到同一地图点的两关键帧之间存在共视关系,共视地图点的数量被称为权重.
成员函数/变量 | 访问控制 | 意义 |
---|---|---|
std::map mConnectedKeyFrameWeights | protected | 当前关键帧的共视关键帧及权重 |
std::vector mvpOrderedConnectedKeyFrames | protected | 所有共视关键帧,按权重从大到小排序 |
std::vector mvOrderedWeights | protected | 所有共视权重,按从大到小排序 |
void UpdateConnections() | public | 基于当前关键帧对地图点的观测构造共视图 |
void AddConnection(KeyFrame* pKF, int &weight) | public 应为private | 添加共视关键帧 |
void EraseConnection(KeyFrame* pKF) | public 应为private | 删除共视关键帧 |
void UpdateBestCovisibles() | public 应为private | 基于共视图信息修改对应变量 |
std::set GetConnectedKeyFrames() | public | get方法 |
std::vector GetVectorCovisibleKeyFrames() | public | get方法 |
std::vector GetBestCovisibilityKeyFrames(int &N) | public | get方法 |
std::vector GetCovisiblesByWeight(int &w) | public | get方法 |
int GetWeight(KeyFrame* pKF) | public | get方法 |
共视图结构由3个成员变量维护:
mConnectedKeyFrameWeights是一个std::map,无序地保存当前关键帧的共视关键帧及权重.
mvpOrderedConnectedKeyFrames和mvOrderedWeights按权重降序分别保存当前关键帧的共视关键帧列表和权重列表.
5.1.2 基于对地图点的观测重新构造共视图: UpdateConnections()
这3个变量由函数KeyFrame::UpdateConnections()
进行初始化和维护,基于当前关键帧看到的地图点信息重新生成共视关键帧.
yFrame*> GetBestCovisibilityKeyFrames(int &N) | public | get方法 |
---|---|---|
std::vector GetCovisiblesByWeight(int &w) | public | get方法 |
int GetWeight(KeyFrame* pKF) | public | get方法 |
void KeyFrame::UpdateConnections()
{
// 关键帧-权重,权重为其它关键帧与当前关键帧共视地图点的个数,也称为共视程度
map<KeyFrame*,int> KFcounter;
vector<MapPoint*> vpMP;
{
// 获得该关键帧的所有地图点
unique_lock<mutex> lockMPs(mMutexFeatures);
vpMP = mvpMapPoints;
}
//For all map points in keyframe check in which other keyframes are they seen
//Increase counter for those keyframes
// Step 1 通过地图点被关键帧观测来间接统计关键帧之间的共视程度
// 统计每一个地图点都有多少关键帧与当前关键帧存在共视关系,统计结果放在KFcounter
for(vector<MapPoint*>::iterator vit=vpMP.begin(), vend=vpMP.end(); vit!=vend; vit++)
{
MapPoint* pMP = *vit;
if(!pMP)
continue;
if(pMP->isBad())
continue;
// 对于每一个地图点,observations记录了可以观测到该地图点的所有关键帧
map<KeyFrame*,size_t> observations = pMP->GetObservations();
for(map<KeyFrame*,size_t>::iterator mit=observations.begin(), mend=observations.end(); mit!=mend; mit++)
{
// 除去自身,自己与自己不算共视
if(mit->first->mnId==mnId)
continue;
// 这里的操作非常精彩!
// map[key] = value,当要插入的键存在时,会覆盖键对应的原来的值。如果键不存在,则添加一组键值对
// mit->first 是地图点看到的关键帧,同一个关键帧看到的地图点会累加到该关键帧计数
// 所以最后KFcounter 第一个参数表示某个关键帧,第2个参数表示该关键帧看到了多少当前帧的地图点,也就是共视程度
KFcounter[mit->first]++;
}
}
// This should not happen
// 没有共视关系,直接退出
if(KFcounter.empty())
return;
// If the counter is greater than threshold add connection
// In case no keyframe counter is over threshold add the one with maximum counter
int nmax=0; // 记录最高的共视程度
KeyFrame* pKFmax=NULL;
// 至少有15个共视地图点才会添加共视关系
int th = 15;
// vPairs记录与其它关键帧共视帧数大于th的关键帧
// pair<int,KeyFrame*>将关键帧的权重写在前面,关键帧写在后面方便后面排序
vector<pair<int,KeyFrame*> > vPairs;
vPairs.reserve(KFcounter.size());
// Step 2 找到对应权重最大的关键帧(共视程度最高的关键帧)
for(map<KeyFrame*,int>::iterator mit=KFcounter.begin(), mend=KFcounter.end(); mit!=mend; mit++)
{
if(mit->second>nmax)
{
nmax=mit->second;
pKFmax=mit->first;
}
// 建立共视关系至少需要大于等于th个共视地图点
if(mit->second>=th)
{
// 对应权重需要大于阈值,对这些关键帧建立连接
vPairs.push_back(make_pair(mit->second,mit->first));
// 对方关键帧也要添加这个信息
// 更新KFcounter中该关键帧的mConnectedKeyFrameWeights
// 更新其它KeyFrame的mConnectedKeyFrameWeights,更新其它关键帧与当前帧的连接权重
(mit->first)->AddConnection(this,mit->second);
}
}
// Step 3 如果没有超过阈值的权重,则对权重最大的关键帧建立连接
if(vPairs.empty())
{
// 如果每个关键帧与它共视的关键帧的个数都少于th,
// 那就只更新与其它关键帧共视程度最高的关键帧的mConnectedKeyFrameWeights
// 这是对之前th这个阈值可能过高的一个补丁
vPairs.push_back(make_pair(nmax,pKFmax));
pKFmax->AddConnection(this,nmax);
}
// Step 4 对满足共视程度的关键帧对更新连接关系及权重(从大到小)
// vPairs里存的都是相互共视程度比较高的关键帧和共视权重,接下来由大到小进行排序
sort(vPairs.begin(),vPairs.end()); // sort函数默认升序排列
// 将排序后的结果分别组织成为两种数据类型
list<KeyFrame*> lKFs;
list<int> lWs;
for(size_t i=0; i<vPairs.size();i++)
{
// push_front 后变成了从大到小顺序
lKFs.push_front(vPairs[i].second);
lWs.push_front(vPairs[i].first);
}
{
unique_lock<mutex> lockCon(mMutexConnections);
// mspConnectedKeyFrames = spConnectedKeyFrames;
// 更新当前帧与其它关键帧的连接权重
// ?bug 这里直接赋值,会把小于阈值的共视关系也放入mConnectedKeyFrameWeights,会增加计算量
// ?但后续主要用mvpOrderedConnectedKeyFrames来取共视帧,对结果没影响
mConnectedKeyFrameWeights = KFcounter;
mvpOrderedConnectedKeyFrames = vector<KeyFrame*>(lKFs.begin(),lKFs.end());
mvOrderedWeights = vector<int>(lWs.begin(), lWs.end());
// Step 5 更新生成树的连接
if(mbFirstConnection && mnId!=0)
{
// 初始化该关键帧的父关键帧为共视程度最高的那个关键帧
mpParent = mvpOrderedConnectedKeyFrames.front();
// 建立双向连接关系,将当前关键帧作为其子关键帧
mpParent->AddChild(this);
mbFirstConnection = false;
}
}
}
只要关键帧与地图点间的连接关系发生变化(包括关键帧创建和地图点重新匹配关键帧特征点),函数KeyFrame::UpdateConnections()就会被调用.具体来说,函数KeyFrame::UpdateConnections()的调用时机包括:
Tracking线程中初始化函数Tracking::StereoInitialization()或Tracking::MonocularInitialization()函数创建关键帧后会调用KeyFrame::UpdateConnections()初始化共视图信息.
LocalMapping线程接受到新关键帧时会调用函数LocalMapping::ProcessNewKeyFrame()处理跟踪过程中加入的地图点,之后会调用KeyFrame::UpdateConnections()初始化共视图信息.(实际上这里处理的是Tracking线程中函数Tracking::CreateNewKeyFrame()创建的关键帧)
LocalMapping线程处理完毕缓冲队列内所有关键帧后会调用LocalMapping::SearchInNeighbors()融合当前关键帧和共视关键帧间的重复地图点,之后会调用KeyFrame::UpdateConnections()更新共视图信息.
LoopClosing线程闭环矫正函数LoopClosing::CorrectLoop()会多次调用KeyFrame::UpdateConnections()更新共视图信息.
函数AddConnection(KeyFrame* pKF, const int &weight)和EraseConnection(KeyFrame* pKF)先对变量mConnectedKeyFrameWeights进行修改,再调用函数UpdateBestCovisibles()修改变量mvpOrderedConnectedKeyFrames和mvOrderedWeights.
这3个函数都只在函数KeyFrame::UpdateConnections()内部被调用了,应该设为私有成员函数.
void KeyFrame::AddConnection(KeyFrame *pKF, const int &weight) {
// step1. 修改变量mConnectedKeyFrameWeights
{
unique_lock<mutex> lock(mMutexConnections);
if (!mConnectedKeyFrameWeights.count(pKF) || mConnectedKeyFrameWeights[pKF] != weight)
mConnectedKeyFrameWeights[pKF] = weight;
else
return;
}
// step2. 调用函数UpdateBestCovisibles()修改变量mvpOrderedConnectedKeyFrames和mvOrderedWeights
UpdateBestCovisibles();
}
void KeyFrame::EraseConnection(KeyFrame *pKF) {
// step1. 修改变量mConnectedKeyFrameWeights
bool bUpdate = false;
{
unique_lock<mutex> lock(mMutexConnections);
if (mConnectedKeyFrameWeights.count(pKF)) {
mConnectedKeyFrameWeights.erase(pKF);
bUpdate = true;
}
}
// step2. 调用函数UpdateBestCovisibles()修改变量mvpOrderedConnectedKeyFrames和mvOrderedWeights
if (bUpdate)
UpdateBestCovisibles();
}
void KeyFrame::UpdateBestCovisibles() {
unique_lock<mutex> lock(mMutexConnections);
// 取出所有关键帧进行排序,排序结果存入变量mvpOrderedConnectedKeyFrames和mvOrderedWeights中
vector<pair<int, KeyFrame *> > vPairs;
vPairs.reserve(mConnectedKeyFrameWeights.size());
for (map<KeyFrame *, int>::iterator mit = mConnectedKeyFrameWeights.begin(), mend = mConnectedKeyFrameWeights.end(); mit != mend; mit++)
vPairs.push_back(make_pair(mit->second, mit->first));
sort(vPairs.begin(), vPairs.end());
list<KeyFrame *> lKFs;
list<int> lWs;
for (size_t i = 0, iend = vPairs.size(); i < iend; i++) {
lKFs.push_front(vPairs[i].second);
lWs.push_front(vPairs[i].first);
}
mvpOrderedConnectedKeyFrames = vector<KeyFrame *>(lKFs.begin(), lKFs.end());
mvOrderedWeights = vector<int>(lWs.begin(), lWs.end());
}
5.2 生成树: mpParent、mspChildrens
生成树是一种稀疏连接,以最小的边数保存图中所有节点.对于含有N个节点的图,只需构造一个N-1条边的最小生成树就可以将所有节点连接起来.
下图表示含有一个10个节点,20条边的稠密图;粗黑线代表其最小生成树,只需9条边即可将所有节点连接起来.
在ORB-SLAM2中,保存所有关键帧构成的最小生成树(优先选择权重大的边作为生成树的边),在回环闭合时只需对最小生成树做BA优化就能以最小代价优化所有关键帧和地图点的位姿,相比于优化共视图大大减少了计算量.(实际上并没有对最小生成树做BA优化,而是对包含生成树的本质图做BA优化)
成员函数/变量 | 访问控制 | 意义 |
---|---|---|
bool mbFirstConnection | protected | 当前关键帧是否还未加入到生成树 构造函数中初始化为true ,加入生成树后置为false |
KeyFrame* mpParent | protected | 当前关键帧在生成树中的父节点 |
std::set mspChildrens | protected | 当前关键帧在生成树中的子节点列表 |
KeyFrame* GetParent() | public | mpParent 的get方法 |
void ChangeParent(KeyFrame* pKF) | public 应为private | mpParent 的set方法 |
std::set GetChilds() | public | mspChildrens 的get方法 |
void AddChild(KeyFrame* pKF) | public 应为private | 添加子节点,mspChildrens 的set方法 |
void EraseChild(KeyFrame* pKF) | public 应为private | 删除子节点,mspChildrens 的set方法 |
bool hasChild(KeyFrame* pKF) | public | 判断mspChildrens 是否为空 |
生成树结构由成员变量mpParent和mspChildrens维护.我们主要关注生成树结构发生改变的时机.
关键帧增加到生成树中的时机:
成功创建关键帧之后会调用函数KeyFrame::UpdateConnections(),该函数第一次被调用时会将该新关键帧加入到生成树中.
新关键帧的父关键帧会被设为其共视程度最高的共视关键帧.
void KeyFrame::UpdateConnections() {
// 更新共视图信息
// ...
// 更新关键帧信息: 对于第一次加入生成树的关键帧,取共视程度最高的关键帧为父关键帧
// 该操作会改变当前关键帧的成员变量mpParent和父关键帧的成员变量mspChildrens
unique_lock<mutex> lockCon(mMutexConnections);
if (mbFirstConnection && mnId != 0) {
mpParent = mvpOrderedConnectedKeyFrames.front();
mpParent->AddChild(this);
mbFirstConnection = false;
}
}
共视图的改变(除了删除关键帧以外)不会引发生成树的改变.
只有当某个关键帧删除时,与其相连的生成树结构在会发生改变.(因为生成树是个单线联系的结构,没有冗余,一旦某关键帧删除了就得更新树结构才能保证所有关键帧依旧相连).生成树结构改变的方式类似于最小生成树算法中的加边法,见后文对函数setbadflag()的分析.
5.3 关键帧的删除
成员函数/变量 | 访问控制 | 意义 | 初值 |
---|---|---|---|
bool mbBad | protected | 标记是坏帧 |
|
bool isBad() | public | mbBad 的get方法 | |
void SetBadFlag() | public | 真的执行删除 | |
bool mbNotErase | protected | 当前关键帧是否具有不被删除的特权 | false |
bool mbToBeErased | protected | 当前关键帧是否曾被豁免过删除 | false |
void SetNotErase() | public | mbNotErase 的set方法 | |
void SetErase() | public |
与MapPoint
类似,函数KeyFrame::SetBadFlag()
对KeyFrame
的删除过程也采取先标记再清除的方式: 先将坏帧标记mBad
置为true
,再依次处理其各成员变量.
5.4 参与回环检测的关键帧具有不被删除的特权: mbNotErase
参与回环检测的关键帧具有不被删除的特权,该特权由成员变量mbNotErase存储,创建KeyFrame对象时该成员变量默认被初始化为false.
若某关键帧参与了回环检测,LoopClosing线程就会就调用函数KeyFrame::SetNotErase()将该关键帧的成员变量mbNotErase设为true,标记该关键帧暂时不要被删除.
void KeyFrame::SetNotErase() {
unique_lock<mutex> lock(mMutexConnections);
mbNotErase = true;
}
在删除函数SetBadFlag()起始先根据成员变量mbNotErase判断当前KeyFrame是否具有豁免删除的特权.若当前KeyFrame的mbNotErase为true,则函数SetBadFlag()不能删除当前KeyFrame,但会将其成员变量mbToBeErased置为true.
/**
* @brief 真正地执行删除关键帧的操作
* 需要删除的是该关键帧和其他所有帧、地图点之间的连接关系
*
* mbNotErase作用:表示要删除该关键帧及其连接关系但是这个关键帧有可能正在回环检测或者计算sim3操作,这时候虽然这个关键帧冗余,但是却不能删除,
* 仅设置mbNotErase为true,这时候调用setbadflag函数时,不会将这个关键帧删除,只会把mbTobeErase变成true,代表这个关键帧可以删除但不到时候,先记下来以后处理。
* 在闭环线程里调用 SetErase()会根据mbToBeErased 来删除之前可以删除还没删除的帧。
*/
void KeyFrame::SetBadFlag()
{
// Step 1 首先处理一下删除不了的特殊情况
{
unique_lock<mutex> lock(mMutexConnections);
// 第0关键帧不允许被删除
if(mnId==0)
return;
else if(mbNotErase)
{
// mbNotErase表示不应该删除,于是把mbToBeErased置为true,假装已经删除,其实没有删除
mbToBeErased = true;
return;
}
}
// Step 2 遍历所有和当前关键帧相连的关键帧,删除他们与当前关键帧的联系
for(map<KeyFrame*,int>::iterator mit = mConnectedKeyFrameWeights.begin(), mend=mConnectedKeyFrameWeights.end(); mit!=mend; mit++)
mit->first->EraseConnection(this); // 让其它的关键帧删除与自己的联系
// Step 3 遍历每一个当前关键帧的地图点,删除每一个地图点和当前关键帧的联系
for(size_t i=0; i<mvpMapPoints.size(); i++)
if(mvpMapPoints[i])
mvpMapPoints[i]->EraseObservation(this);
{
unique_lock<mutex> lock(mMutexConnections);
unique_lock<mutex> lock1(mMutexFeatures);
// 清空自己与其它关键帧之间的联系
mConnectedKeyFrameWeights.clear();
mvpOrderedConnectedKeyFrames.clear();
// Update Spanning Tree
// Step 4 更新生成树,主要是处理好父子关键帧,不然会造成整个关键帧维护的图断裂,或者混乱
// 候选父关键帧
set<KeyFrame*> sParentCandidates;
// 将当前帧的父关键帧放入候选父关键帧
sParentCandidates.insert(mpParent);
// Assign at each iteration one children with a parent (the pair with highest covisibility weight)
// Include that children as new parent candidate for the rest
// 每迭代一次就为其中一个子关键帧寻找父关键帧(最高共视程度),找到父的子关键帧可以作为其他子关键帧的候选父关键帧
while(!mspChildrens.empty())
{
bool bContinue = false;
int max = -1;
KeyFrame* pC;
KeyFrame* pP;
// Step 4.1 遍历每一个子关键帧,让它们更新它们指向的父关键帧
for(set<KeyFrame*>::iterator sit=mspChildrens.begin(), send=mspChildrens.end(); sit!=send; sit++)
{
KeyFrame* pKF = *sit;
// 跳过无效的子关键帧
if(pKF->isBad())
continue;
// Check if a parent candidate is connected to the keyframe
// Step 4.2 子关键帧遍历每一个与它共视的关键帧
vector<KeyFrame*> vpConnected = pKF->GetVectorCovisibleKeyFrames();
for(size_t i=0, iend=vpConnected.size(); i<iend; i++)
{
// sParentCandidates 中刚开始存的是这里子关键帧的“爷爷”,也是当前关键帧的候选父关键帧
for(set<KeyFrame*>::iterator spcit=sParentCandidates.begin(), spcend=sParentCandidates.end(); spcit!=spcend; spcit++)
{
// Step 4.3 如果孩子和sParentCandidates中有共视,选择共视最强的那个作为新的父
if(vpConnected[i]->mnId == (*spcit)->mnId)
{
int w = pKF->GetWeight(vpConnected[i]);
// 寻找并更新权值最大的那个共视关系
if(w>max)
{
pC = pKF; //子关键帧
pP = vpConnected[i]; //目前和子关键帧具有最大权值的关键帧(将来的父关键帧)
max = w; //这个最大的权值
bContinue = true; //说明子节点找到了可以作为其新父关键帧的帧
}
}
}
}
}
// Step 4.4 如果在上面的过程中找到了新的父节点
// 下面代码应该放到遍历子关键帧循环中?
// 回答:不需要!这里while循环还没退出,会使用更新的sParentCandidates
if(bContinue)
{
// 因为父节点死了,并且子节点找到了新的父节点,就把它更新为自己的父节点
pC->ChangeParent(pP);
// 因为子节点找到了新的父节点并更新了父节点,那么该子节点升级,作为其它子节点的备选父节点
sParentCandidates.insert(pC);
// 该子节点处理完毕,删掉
mspChildrens.erase(pC);
}
else
break;
}
// If a children has no covisibility links with any parent candidate, assign to the original parent of this KF
// Step 4.5 如果还有子节点没有找到新的父节点
if(!mspChildrens.empty())
for(set<KeyFrame*>::iterator sit=mspChildrens.begin(); sit!=mspChildrens.end(); sit++)
{
// 直接把父节点的父节点作为自己的父节点 即对于这些子节点来说,他们的新的父节点其实就是自己的爷爷节点
(*sit)->ChangeParent(mpParent);
}
mpParent->EraseChild(this);
// mTcp 表示原父关键帧到当前关键帧的位姿变换,在保存位姿的时候使用
mTcp = Tcw*mpParent->GetPoseInverse();
// 标记当前关键帧已经挂了
mbBad = true;
}
// 地图和关键帧数据库中删除该关键帧
mpMap->EraseKeyFrame(this);
mpKeyFrameDB->erase(this);
}
成员变量mbToBeErased标记当前KeyFrame是否被豁免过删除特权.LoopClosing线程不再需要某关键帧时,会调用函数KeyFrame::SetErase()剥夺该关键帧不被删除的特权,将成员变量mbNotErase复位为false;同时检查成员变量mbToBeErased,若mbToBeErased为true就会调用函数KeyFrame::SetBadFlag()删除该关键帧.
/**
* @brief 删除当前的这个关键帧,表示不进行回环检测过程;由回环检测线程调用
*
*/
void KeyFrame::SetErase()
{
{
unique_lock<mutex> lock(mMutexConnections);
// 如果当前关键帧和其他的关键帧没有形成回环关系,那么就删吧
if(mspLoopEdges.empty())
{
mbNotErase = false;
}
}
// mbToBeErased:删除之前记录的想要删但时机不合适没有删除的帧
if(mbToBeErased)
{
SetBadFlag();
}
}
5.5 删除关键帧时维护共视图和生成树
函数SetBadFlag()在删除关键帧的时维护其共视图和生成树结构.共视图结构的维护比较简单,这里主要关心如何维护生成树的结构.
当一个关键帧被删除时,其父关键帧和所有子关键帧的生成树信息也会受到影响,需要为其所有子关键帧寻找新的父关键帧,如果父关键帧找的不好的话,就会产生回环,导致生成树就断开.
被删除关键帧的子关键帧所有可能的父关键帧包括其兄弟关键帧和其被删除关键帧的父关键帧.以下图为例,关键帧4可能的父关键帧包括关键帧3、5、6和7.
采用类似于最小生成树算法中的加边法重新构建生成树结构: 每次循环取权重最高的候选边建立父子连接关系,并将新加入生成树的子节点到加入候选父节点集合sParentCandidates
中.
void KeyFrame::SetBadFlag() {
// step1. 特殊情况:豁免 第一帧 和 具有mbNotErase特权的帧
{
unique_lock<mutex> lock(mMutexConnections);
if (mnId == 0)
return;
else if (mbNotErase) {
mbToBeErased = true;
return;
}
}
// step2. 从共视关键帧的共视图中删除本关键帧
for (auto mit : mConnectedKeyFrameWeights)
mit.first->EraseConnection(this);
// step3. 删除当前关键帧中地图点对本帧的观测
for (size_t i = 0; i < mvpMapPoints.size(); i++)
if (mvpMapPoints[i])
mvpMapPoints[i]->EraseObservation(this);
{
// step4. 删除共视图
unique_lock<mutex> lock(mMutexConnections);
unique_lock<mutex> lock1(mMutexFeatures);
mConnectedKeyFrameWeights.clear();
mvpOrderedConnectedKeyFrames.clear();
// step5. 更新生成树结构
set<KeyFrame *> sParentCandidates;
sParentCandidates.insert(mpParent);
while (!mspChildrens.empty()) {
bool bContinue = false;
int max = -1;
KeyFrame *pC;
KeyFrame *pP;
for (KeyFrame *pKF : mspChildrens) {
if (pKF->isBad())
continue;
vector<KeyFrame *> vpConnected = pKF->GetVectorCovisibleKeyFrames();
for (size_t i = 0, iend = vpConnected.size(); i < iend; i++) {
for (set<KeyFrame *>::iterator spcit = sParentCandidates.begin(), spcend = sParentCandidates.end();
spcit != spcend; spcit++) {
if (vpConnected[i]->mnId == (*spcit)->mnId) {
int w = pKF->GetWeight(vpConnected[i]);
if (w > max) {
pC = pKF;
pP = vpConnected[i];
max = w;
bContinue = true;
}
}
}
}
}
if (bContinue) {
pC->ChangeParent(pP);
sParentCandidates.insert(pC);
mspChildrens.erase(pC);
} else
break;
}
if (!mspChildrens.empty())
for (set<KeyFrame *>::iterator sit = mspChildrens.begin(); sit != mspChildrens.end(); sit++) {
(*sit)->ChangeParent(mpParent);
}
mpParent->EraseChild(this);
mTcp = Tcw * mpParent->GetPoseInverse();
// step6. 将当前关键帧的 mbBad 置为 true
mbBad = true;
}
// step7. 从地图中删除当前关键帧
mpMap->EraseKeyFrame(this);
mpKeyFrameDB->erase(this);
}
5.6 对地图点的观测
KeyFrame类除了像一般的Frame类那样保存二维图像特征点以外,还保存三维地图点MapPoint信息.
关键帧观测到的地图点列表由成员变量mvpMapPoints保存,下面是一些对该成员变量进行增删改查的成员函数,就是简单的列表操作,没什么值得说的地方.
成员函数/变量 | 访问控制 | 意义 |
---|---|---|
std::vector mvpMapPoints | protected | 当前关键帧观测到的地图点列表 |
void AddMapPoint(MapPoint* pMP, const size_t &idx) | public | |
void EraseMapPointMatch(const size_t &idx) | public | |
void EraseMapPointMatch(MapPoint* pMP) | public | |
void ReplaceMapPointMatch(const size_t &idx, MapPoint* pMP) | public | |
std::set GetMapPoints() | public | |
std::vector GetMapPointMatches() | public | |
int TrackedMapPoints(const int &minObs) | public | |
MapPoint* GetMapPoint(const size_t &idx) | public |
值得关心的是上述函数的调用时机,也就是说参考帧何时与地图点发生关系:
关键帧增加对地图点观测的时机:
Tracking线程和LocalMapping线程创建新地图点后,会马上调用函数KeyFrame::AddMapPoint()添加当前关键帧对该地图点的观测.
LocalMapping线程处理完毕缓冲队列内所有关键帧后会调用LocalMapping::SearchInNeighbors()融合当前关键帧和共视关键帧间的重复地图点,其中调用函数ORBmatcher::Fuse()实现融合过程中会调用函数KeyFrame::AddMapPoint().
LoopClosing线程闭环矫正函数LoopClosing::CorrectLoop()将闭环关键帧与其匹配关键帧间的地图进行融合,会调用函数KeyFrame::AddMapPoint().
关键帧替换和删除对地图点观测的时机:
MapPoint删除函数MapPoint::SetBadFlag()或替换函数MapPoint::Replace()会调用KeyFrame::EraseMapPointMatch()和KeyFrame::ReplaceMapPointMatch()删除和替换关键针对地图点的观测.
LocalMapping线程调用进行局部BA优化的函数Optimizer::LocalBundleAdjustment()内部调用函数KeyFrame::EraseMapPointMatch()删除对重投影误差较大的地图点的观测.
5.7 回环检测与本质图
成员函数/变量 | 访问控制 | 意义 |
---|---|---|
std::set mspLoopEdge | protected | 和当前帧形成回环的关键帧集合 |
set GetLoopEdges() | public | mspLoopEdge 的get函数 |
void AddLoopEdge(KeyFrame *pKF) | public | mspLoopEdge 的set函数 |
LoopClosing线程中回环矫正函数LoopClosing::CorrectLoop()在调用本质图BA优化函数Optimizer::OptimizeEssentialGraph()之前会调用函数KeyFrame::AddLoopEdge(),在当前关键帧和其闭环匹配关键帧间添加回环关系.
在调用本质图BA优化函数Optimizer::OptimizeEssentialGraph()中会调用函数KeyFrame::GetLoopEdges()将所有闭环关系加入到本质图中进行优化.
共视图比较稠密,本质图比共视图更稀疏,这是因为本质图的作用是用在闭环矫正时,用相似变换来矫
正尺度漂移,把闭环误差均摊在本质图中。本质图中节点也是所有关键帧,但是连接边更少,只保留了
联系紧密的边来使得结果更精确。本质图中包含:
1. 扩展树连接关系
2. 形成闭环的连接关系,闭环后地图点变动后新增加的连接关系
3. 共视关系非常好(至少100个共视地图点)的连接关系
void Optimizer::OptimizeEssentialGraph(Map* pMap, KeyFrame* pLoopKF, KeyFrame* pCurKF,
const LoopClosing::KeyFrameAndPose &NonCorrectedSim3,
const LoopClosing::KeyFrameAndPose &CorrectedSim3,
const map<KeyFrame *, set<KeyFrame *> > &LoopConnections, const bool &bFixScale)
{
// Setup optimizer
// Step 1:构造优化器
g2o::SparseOptimizer optimizer;
optimizer.setVerbose(false);
g2o::BlockSolver_7_3::LinearSolverType * linearSolver =
new g2o::LinearSolverEigen<g2o::BlockSolver_7_3::PoseMatrixType>();
g2o::BlockSolver_7_3 * solver_ptr= new g2o::BlockSolver_7_3(linearSolver);
// 使用LM算法进行非线性迭代
g2o::OptimizationAlgorithmLevenberg* solver = new g2o::OptimizationAlgorithmLevenberg(solver_ptr);
// 第一次迭代的初始lambda值,如未指定会自动计算一个合适的值
solver->setUserLambdaInit(1e-16);
optimizer.setAlgorithm(solver);
// 获取当前地图中的所有关键帧 和地图点
const vector<KeyFrame*> vpKFs = pMap->GetAllKeyFrames();
const vector<MapPoint*> vpMPs = pMap->GetAllMapPoints();
// 最大关键帧id,用于添加顶点时使用
const unsigned int nMaxKFid = pMap->GetMaxKFid();
// 记录所有优化前关键帧的位姿,优先使用在闭环时通过Sim3传播调整过的Sim3位姿
vector<g2o::Sim3,Eigen::aligned_allocator<g2o::Sim3> > vScw(nMaxKFid+1);
// 记录所有关键帧经过本次本质图优化过的位姿
vector<g2o::Sim3,Eigen::aligned_allocator<g2o::Sim3> > vCorrectedSwc(nMaxKFid+1);
// 这个变量没有用
vector<g2o::VertexSim3Expmap*> vpVertices(nMaxKFid+1);
// 两个关键帧之间共视关系的权重的最小值
const int minFeat = 100;
// Set KeyFrame vertices
// Step 2:将地图中所有关键帧的位姿作为顶点添加到优化器
// 尽可能使用经过Sim3调整的位姿
// 遍历全局地图中的所有的关键帧
for(size_t i=0, iend=vpKFs.size(); i<iend;i++)
{
KeyFrame* pKF = vpKFs[i];
if(pKF->isBad())
continue;
g2o::VertexSim3Expmap* VSim3 = new g2o::VertexSim3Expmap();
// 关键帧在所有关键帧中的id,用来设置为顶点的id
const int nIDi = pKF->mnId;
LoopClosing::KeyFrameAndPose::const_iterator it = CorrectedSim3.find(pKF);
if(it!=CorrectedSim3.end())
{
// 如果该关键帧在闭环时通过Sim3传播调整过,优先用调整后的Sim3位姿
vScw[nIDi] = it->second;
VSim3->setEstimate(it->second);
}
else
{
// 如果该关键帧在闭环时没有通过Sim3传播调整过,用跟踪时的位姿,尺度为1
Eigen::Matrix<double,3,3> Rcw = Converter::toMatrix3d(pKF->GetRotation());
Eigen::Matrix<double,3,1> tcw = Converter::toVector3d(pKF->GetTranslation());
g2o::Sim3 Siw(Rcw,tcw,1.0);
vScw[nIDi] = Siw;
VSim3->setEstimate(Siw);
}
// 闭环匹配上的帧不进行位姿优化(认为是准确的,作为基准)
// 注意这里并没有锁住第0个关键帧,所以初始关键帧位姿也做了优化
if(pKF==pLoopKF)
VSim3->setFixed(true);
VSim3->setId(nIDi);
VSim3->setMarginalized(false);
// 和当前系统的传感器有关,如果是RGBD或者是双目,那么就不需要优化sim3的缩放系数,保持为1即可
VSim3->_fix_scale = bFixScale;
// 添加顶点
optimizer.addVertex(VSim3);
// 优化前的位姿顶点,后面代码中没有使用
vpVertices[nIDi]=VSim3;
}
// 保存由于闭环后优化sim3而出现的新的关键帧和关键帧之间的连接关系,其中id比较小的关键帧在前,id比较大的关键帧在后
set<pair<long unsigned int,long unsigned int> > sInsertedEdges;
// 单位矩阵
const Eigen::Matrix<double,7,7> matLambda = Eigen::Matrix<double,7,7>::Identity();
// Set Loop edges
// Step 3:添加第1种边:闭环时因为地图点调整而出现的关键帧间的新连接关系
for(map<KeyFrame *, set<KeyFrame *> >::const_iterator mit = LoopConnections.begin(), mend=LoopConnections.end(); mit!=mend; mit++)
{
KeyFrame* pKF = mit->first;
const long unsigned int nIDi = pKF->mnId;
// 和pKF 形成新连接关系的关键帧
const set<KeyFrame*> &spConnections = mit->second;
const g2o::Sim3 Siw = vScw[nIDi];
const g2o::Sim3 Swi = Siw.inverse();
// 对于当前关键帧nIDi而言,遍历每一个新添加的关键帧nIDj链接关系
for(set<KeyFrame*>::const_iterator sit=spConnections.begin(), send=spConnections.end(); sit!=send; sit++)
{
const long unsigned int nIDj = (*sit)->mnId;
// 同时满足下面2个条件的跳过
// 条件1:至少有一个不是pCurKF或pLoopKF
// 条件2:共视程度太少(<100),不足以构成约束的边
if((nIDi!=pCurKF->mnId || nIDj!=pLoopKF->mnId)
&& pKF->GetWeight(*sit)<minFeat)
continue;
// 通过上面考验的帧有两种情况:
// 1、恰好是当前帧及其闭环帧 nIDi=pCurKF 并且nIDj=pLoopKF(此时忽略共视程度)
// 2、任意两对关键帧,共视程度大于100
const g2o::Sim3 Sjw = vScw[nIDj];
// 得到两个位姿间的Sim3变换
const g2o::Sim3 Sji = Sjw * Swi;
g2o::EdgeSim3* e = new g2o::EdgeSim3();
e->setVertex(1, dynamic_cast<g2o::OptimizableGraph::Vertex*>(optimizer.vertex(nIDj)));
e->setVertex(0, dynamic_cast<g2o::OptimizableGraph::Vertex*>(optimizer.vertex(nIDi)));
// Sji内部是经过了Sim调整的观测
e->setMeasurement(Sji);
// 信息矩阵是单位阵,说明这类新增加的边对总误差的贡献也都是一样大的
e->information() = matLambda;
optimizer.addEdge(e);
// 保证id小的在前,大的在后
sInsertedEdges.insert(make_pair(min(nIDi,nIDj),max(nIDi,nIDj)));
}
}
// Set normal edges
// Step 4:添加跟踪时形成的边、闭环匹配成功形成的边
for(size_t i=0, iend=vpKFs.size(); i<iend; i++)
{
KeyFrame* pKF = vpKFs[i];
const int nIDi = pKF->mnId;
g2o::Sim3 Swi;
LoopClosing::KeyFrameAndPose::const_iterator iti = NonCorrectedSim3.find(pKF);
if(iti!=NonCorrectedSim3.end())
Swi = (iti->second).inverse(); //优先使用未经过Sim3传播调整的位姿
else
Swi = vScw[nIDi].inverse(); //没找到才考虑已经经过Sim3传播调整的位姿
KeyFrame* pParentKF = pKF->GetParent();
// Spanning tree edge
// Step 4.1:添加第2种边:生成树的边(有父关键帧)
// 父关键帧就是和当前帧共视程度最高的关键帧
if(pParentKF)
{
// 父关键帧id
int nIDj = pParentKF->mnId;
g2o::Sim3 Sjw;
LoopClosing::KeyFrameAndPose::const_iterator itj = NonCorrectedSim3.find(pParentKF);
//优先使用未经过Sim3传播调整的位姿
if(itj!=NonCorrectedSim3.end())
Sjw = itj->second;
else
Sjw = vScw[nIDj];
// 计算父子关键帧之间的相对位姿
g2o::Sim3 Sji = Sjw * Swi;
g2o::EdgeSim3* e = new g2o::EdgeSim3();
e->setVertex(1, dynamic_cast<g2o::OptimizableGraph::Vertex*>(optimizer.vertex(nIDj)));
e->setVertex(0, dynamic_cast<g2o::OptimizableGraph::Vertex*>(optimizer.vertex(nIDi)));
// 希望父子关键帧之间的位姿差最小
e->setMeasurement(Sji);
// 所有元素的贡献都一样;每个误差边对总误差的贡献也都相同
e->information() = matLambda;
optimizer.addEdge(e);
}
// Loop edges
// Step 4.2:添加第3种边:当前帧与闭环匹配帧之间的连接关系(这里面也包括了当前遍历到的这个关键帧之前曾经存在过的回环边)
// 获取和当前关键帧形成闭环关系的关键帧
const set<KeyFrame*> sLoopEdges = pKF->GetLoopEdges();
for(set<KeyFrame*>::const_iterator sit=sLoopEdges.begin(), send=sLoopEdges.end(); sit!=send; sit++)
{
KeyFrame* pLKF = *sit;
// 注意要比当前遍历到的这个关键帧的id小,这个是为了避免重复添加
if(pLKF->mnId<pKF->mnId)
{
g2o::Sim3 Slw;
LoopClosing::KeyFrameAndPose::const_iterator itl = NonCorrectedSim3.find(pLKF);
//优先使用未经过Sim3传播调整的位姿
if(itl!=NonCorrectedSim3.end())
Slw = itl->second;
else
Slw = vScw[pLKF->mnId];
g2o::Sim3 Sli = Slw * Swi;
g2o::EdgeSim3* el = new g2o::EdgeSim3();
el->setVertex(1, dynamic_cast<g2o::OptimizableGraph::Vertex*>(optimizer.vertex(pLKF->mnId)));
el->setVertex(0, dynamic_cast<g2o::OptimizableGraph::Vertex*>(optimizer.vertex(nIDi)));
// 根据两个位姿顶点的位姿算出相对位姿作为边
el->setMeasurement(Sli);
el->information() = matLambda;
optimizer.addEdge(el);
}
}
// Covisibility graph edges
// Step 4.3:添加第4种边:共视程度超过100的关键帧也作为边进行优化
// 取出和当前关键帧共视程度超过100的关键帧
const vector<KeyFrame*> vpConnectedKFs = pKF->GetCovisiblesByWeight(minFeat);
for(vector<KeyFrame*>::const_iterator vit=vpConnectedKFs.begin(); vit!=vpConnectedKFs.end(); vit++)
{
KeyFrame* pKFn = *vit;
// 避免重复添加
// 避免以下情况:最小生成树中的父子关键帧关系,以及和当前遍历到的关键帧构成了回环关系
if(pKFn && pKFn!=pParentKF && !pKF->hasChild(pKFn) && !sLoopEdges.count(pKFn))
{
// 注意要比当前遍历到的这个关键帧的id要小,这个是为了避免重复添加
if(!pKFn->isBad() && pKFn->mnId<pKF->mnId)
{
// 如果这条边已经添加了,跳过
if(sInsertedEdges.count(make_pair(min(pKF->mnId,pKFn->mnId),max(pKF->mnId,pKFn->mnId))))
continue;
g2o::Sim3 Snw;
LoopClosing::KeyFrameAndPose::const_iterator itn = NonCorrectedSim3.find(pKFn);
// 优先未经过Sim3传播调整的位姿
if(itn!=NonCorrectedSim3.end())
Snw = itn->second;
else
Snw = vScw[pKFn->mnId];
// 也是同样计算相对位姿
g2o::Sim3 Sni = Snw * Swi;
g2o::EdgeSim3* en = new g2o::EdgeSim3();
en->setVertex(1, dynamic_cast<g2o::OptimizableGraph::Vertex*>(optimizer.vertex(pKFn->mnId)));
en->setVertex(0, dynamic_cast<g2o::OptimizableGraph::Vertex*>(optimizer.vertex(nIDi)));
en->setMeasurement(Sni);
en->information() = matLambda;
optimizer.addEdge(en);
}
} // 如果这个比较好的共视关系的约束之前没有被重复添加过
} // 遍历所有于当前遍历到的关键帧具有较好的共视关系的关键帧
} // 添加跟踪时形成的边、闭环匹配成功形成的边
// Optimize!
// Step 5:开始g2o优化,迭代20次
optimizer.initializeOptimization();
optimizer.optimize(20);
// 更新地图前,先上锁,防止冲突
unique_lock<mutex> lock(pMap->mMutexMapUpdate);
// SE3 Pose Recovering. Sim3:[sR t;0 1] -> SE3:[R t/s;0 1]
// Step 6:将优化后的位姿更新到关键帧中
// 遍历地图中的所有关键帧
for(size_t i=0;i<vpKFs.size();i++)
{
KeyFrame* pKFi = vpKFs[i];
const int nIDi = pKFi->mnId;
g2o::VertexSim3Expmap* VSim3 = static_cast<g2o::VertexSim3Expmap*>(optimizer.vertex(nIDi));
g2o::Sim3 CorrectedSiw = VSim3->estimate();
vCorrectedSwc[nIDi]=CorrectedSiw.inverse();
Eigen::Matrix3d eigR = CorrectedSiw.rotation().toRotationMatrix();
Eigen::Vector3d eigt = CorrectedSiw.translation();
double s = CorrectedSiw.scale();
// 转换成尺度为1的变换矩阵的形式
eigt *=(1./s); //[R t/s;0 1]
cv::Mat Tiw = Converter::toCvSE3(eigR,eigt);
// 将更新的位姿写入到关键帧中
pKFi->SetPose(Tiw);
}
// Correct points. Transform to "non-optimized" reference keyframe pose and transform back with optimized pose
// Step 7:步骤5和步骤6优化得到关键帧的位姿后,地图点根据参考帧优化前后的相对关系调整自己的位置
// 遍历所有地图点
for(size_t i=0, iend=vpMPs.size(); i<iend; i++)
{
MapPoint* pMP = vpMPs[i];
if(pMP->isBad())
continue;
int nIDr;
// 该地图点在闭环检测中被当前KF调整过,那么使用调整它的KF id
if(pMP->mnCorrectedByKF==pCurKF->mnId)
{
nIDr = pMP->mnCorrectedReference;
}
else
{
// 通常情况下地图点的参考关键帧就是创建该地图点的那个关键帧
KeyFrame* pRefKF = pMP->GetReferenceKeyFrame();
nIDr = pRefKF->mnId;
}
// 得到地图点参考关键帧优化前的位姿
g2o::Sim3 Srw = vScw[nIDr];
// 得到地图点参考关键帧优化后的位姿
g2o::Sim3 correctedSwr = vCorrectedSwc[nIDr];
cv::Mat P3Dw = pMP->GetWorldPos();
Eigen::Matrix<double,3,1> eigP3Dw = Converter::toVector3d(P3Dw);
Eigen::Matrix<double,3,1> eigCorrectedP3Dw = correctedSwr.map(Srw.map(eigP3Dw));
cv::Mat cvCorrectedP3Dw = Converter::toCvMat(eigCorrectedP3Dw);
// 这里优化后的位置也是直接写入到地图点之中的
pMP->SetWorldPos(cvCorrectedP3Dw);
// 记得更新一下
pMP->UpdateNormalAndDepth();
} // 使用相对位姿变换的方法来更新地图点的位姿
}
5.8 KeyFrame`的用途
KeyFrame的创建:
Tracking线程中通过函数Tracking::NeedNewKeyFrame()判断是否需要关键帧,若需要关键帧,则调用函数Tracking::CreateNewKeyFrame()创建关键帧.
KeyFrame的销毁:
LocalMapping线程剔除冗余关键帧函数LocalMapping::KeyFrameCulling()中若检查到某关键帧为冗余关键帧,则调用函数KeyFrame::SetBadFlag()删除关键帧.
6. ORB-SLAM2代码详解06_单目初始化器Initializer(对应文件Initializer.cc、Initializer.h)
6.1 各成员变量/函数
Initializer类仅用于单目相机初始化,双目/RGBD相机初始化不用这个类.
成员变量名中: 1代表参考帧(reference frame)中特征点编号,2代表当前帧(current frame)中特征点编号.
各成员函数/变量 | 访问控制 | 意义 |
---|---|---|
vector mvKeys1 | private | 参考帧(reference frame)中的特征点 |
vector mvKeys2 | private | 当前帧(current frame)中的特征点 |
vector> mvMatches12 | private | 从参考帧到当前帧的匹配特征点对 |
vector mvbMatched1 | private | 参考帧特征点是否在当前帧存在匹配特征点 |
cv::Mat mK | private | 相机内参 |
float mSigma, mSigma2 | private | 重投影误差阈值及其平方 |
int mMaxIterations | private | RANSAC迭代次数 |
vector> mvSets | private | 二维容器N✖8 每一层保存RANSAC计算H 和F 矩阵所需的八对点 |
/**
* @brief 计算基础矩阵和单应性矩阵,选取最佳的来恢复出最开始两帧之间的相对姿态,并进行三角化得到初始地图点
* Step 1 重新记录特征点对的匹配关系
* Step 2 在所有匹配特征点对中随机选择8对匹配特征点为一组,用于估计H矩阵和F矩阵
* Step 3 计算fundamental 矩阵 和homography 矩阵,为了加速分别开了线程计算
* Step 4 计算得分比例来判断选取哪个模型来求位姿R,t
*
* @param[in] CurrentFrame 当前帧,也就是SLAM意义上的第二帧
* @param[in] vMatches12 当前帧(2)和参考帧(1)图像中特征点的匹配关系
* vMatches12[i]解释:i表示帧1中关键点的索引值,vMatches12[i]的值为帧2的关键点索引值
* 没有匹配关系的话,vMatches12[i]值为 -1
* @param[in & out] R21 相机从参考帧到当前帧的旋转
* @param[in & out] t21 相机从参考帧到当前帧的平移
* @param[in & out] vP3D 三角化测量之后的三维地图点
* @param[in & out] vbTriangulated 标记三角化点是否有效,有效为true
* @return true 该帧可以成功初始化,返回true
* @return false 该帧不满足初始化条件,返回false
*/
bool Initializer::Initialize(const Frame &CurrentFrame, const vector<int> &vMatches12, cv::Mat &R21, cv::Mat &t21,
vector<cv::Point3f> &vP3D, vector<bool> &vbTriangulated)
{
// Fill structures with current keypoints and matches with reference frame
// Reference Frame: 1, Current Frame: 2
//获取当前帧的去畸变之后的特征点
mvKeys2 = CurrentFrame.mvKeysUn;
// mvMatches12记录匹配上的特征点对,记录的是帧2在帧1的匹配索引
mvMatches12.clear();
// 预分配空间,大小和关键点数目一致mvKeys2.size()
mvMatches12.reserve(mvKeys2.size());
// 记录参考帧1中的每个特征点是否有匹配的特征点
// 这个成员变量后面没有用到,后面只关心匹配上的特征点
mvbMatched1.resize(mvKeys1.size());
// Step 1 重新记录特征点对的匹配关系存储在mvMatches12,是否有匹配存储在mvbMatched1
// 将vMatches12(有冗余) 转化为 mvMatches12(只记录了匹配关系)
for(size_t i=0, iend=vMatches12.size();i<iend; i++)
{
//vMatches12[i]解释:i表示帧1中关键点的索引值,vMatches12[i]的值为帧2的关键点索引值
//没有匹配关系的话,vMatches12[i]值为 -1
if(vMatches12[i]>=0)
{
//mvMatches12 中只记录有匹配关系的特征点对的索引值
//i表示帧1中关键点的索引值,vMatches12[i]的值为帧2的关键点索引值
mvMatches12.push_back(make_pair(i,vMatches12[i]));
//标记参考帧1中的这个特征点有匹配关系
mvbMatched1[i]=true;
}
else
//标记参考帧1中的这个特征点没有匹配关系
mvbMatched1[i]=false;
}
// 有匹配的特征点的对数
const int N = mvMatches12.size();
// Indices for minimum set selection
// 新建一个容器vAllIndices存储特征点索引,并预分配空间
vector<size_t> vAllIndices;
vAllIndices.reserve(N);
//在RANSAC的某次迭代中,还可以被抽取来作为数据样本的特征点对的索引,所以这里起的名字叫做可用的索引
vector<size_t> vAvailableIndices;
//初始化所有特征点对的索引,索引值0到N-1
for(int i=0; i<N; i++)
{
vAllIndices.push_back(i);
}
// Generate sets of 8 points for each RANSAC iteration
// Step 2 在所有匹配特征点对中随机选择8对匹配特征点为一组,用于估计H矩阵和F矩阵
// 共选择 mMaxIterations (默认200) 组
//mvSets用于保存每次迭代时所使用的向量
mvSets = vector< vector<size_t> >(mMaxIterations, //最大的RANSAC迭代次数
vector<size_t>(8,0)); //这个则是第二维元素的初始值,也就是第一维。这里其实也是一个第一维的构造函数,第一维vector有8项,每项的初始值为0.
//用于进行随机数据样本采样,设置随机数种子
DUtils::Random::SeedRandOnce(0);
//开始每一次的迭代
for(int it=0; it<mMaxIterations; it++)
{
//迭代开始的时候,所有的点都是可用的
vAvailableIndices = vAllIndices;
// Select a minimum set
//选择最小的数据样本集,使用八点法求,所以这里就循环了八次
for(size_t j=0; j<8; j++)
{
// 随机产生一对点的id,范围从0到N-1
int randi = DUtils::Random::RandomInt(0,vAvailableIndices.size()-1);
// idx表示哪一个索引对应的特征点对被选中
int idx = vAvailableIndices[randi];
//将本次迭代这个选中的第j个特征点对的索引添加到mvSets中
mvSets[it][j] = idx;
// 由于这对点在本次迭代中已经被使用了,所以我们为了避免再次抽到这个点,就在"点的可选列表"中,
// 将这个点原来所在的位置用vector最后一个元素的信息覆盖,并且删除尾部的元素
// 这样就相当于将这个点的信息从"点的可用列表"中直接删除了
vAvailableIndices[randi] = vAvailableIndices.back();
vAvailableIndices.pop_back();
}//依次提取出8个特征点对
}//迭代mMaxIterations次,选取各自迭代时需要用到的最小数据集
// Launch threads to compute in parallel a fundamental matrix and a homography
// Step 3 计算fundamental 矩阵 和homography 矩阵,为了加速分别开了线程计算
//这两个变量用于标记在H和F的计算中哪些特征点对被认为是Inlier
vector<bool> vbMatchesInliersH, vbMatchesInliersF;
//计算出来的单应矩阵和基础矩阵的RANSAC评分,这里其实是采用重投影误差来计算的
float SH, SF; //score for H and F
//这两个是经过RANSAC算法后计算出来的单应矩阵和基础矩阵
cv::Mat H, F;
// 构造线程来计算H矩阵及其得分
// thread方法比较特殊,在传递引用的时候,外层需要用ref来进行引用传递,否则就是浅拷贝
thread threadH(&Initializer::FindHomography, //该线程的主函数
this, //由于主函数为类的成员函数,所以第一个参数就应该是当前对象的this指针
ref(vbMatchesInliersH), //输出,特征点对的Inlier标记
ref(SH), //输出,计算的单应矩阵的RANSAC评分
ref(H)); //输出,计算的单应矩阵结果
// 计算fundamental matrix并打分,参数定义和H是一样的,这里不再赘述
thread threadF(&Initializer::FindFundamental,this,ref(vbMatchesInliersF), ref(SF), ref(F));
// Wait until both threads have finished
//等待两个计算线程结束
threadH.join();
threadF.join();
// Compute ratio of scores
// Step 4 计算得分比例来判断选取哪个模型来求位姿R,t
//通过这个规则来判断谁的评分占比更多一些,注意不是简单的比较绝对评分大小,而是看评分的占比
float RH = SH/(SH+SF); //RH=Ratio of Homography
// Try to reconstruct from homography or fundamental depending on the ratio (0.40-0.45)
// 注意这里更倾向于用H矩阵恢复位姿。如果单应矩阵的评分占比达到了0.4以上,则从单应矩阵恢复运动,否则从基础矩阵恢复运动
if(RH>0.40)
//更偏向于平面,此时从单应矩阵恢复,函数ReconstructH返回bool型结果
return ReconstructH(vbMatchesInliersH, //输入,匹配成功的特征点对Inliers标记
H, //输入,前面RANSAC计算后的单应矩阵
mK, //输入,相机的内参数矩阵
R21,t21, //输出,计算出来的相机从参考帧1到当前帧2所发生的旋转和位移变换
vP3D, //特征点对经过三角测量之后的空间坐标,也就是地图点
vbTriangulated, //特征点对是否成功三角化的标记
1.0, //这个对应的形参为minParallax,即认为某对特征点的三角化测量中,认为其测量有效时
//需要满足的最小视差角(如果视差角过小则会引起非常大的观测误差),单位是角度
50); //为了进行运动恢复,所需要的最少的三角化测量成功的点个数
else //if(pF_HF>0.6)
// 更偏向于非平面,从基础矩阵恢复
return ReconstructF(vbMatchesInliersF,F,mK,R21,t21,vP3D,vbTriangulated,1.0,50);
//一般地程序不应该执行到这里,如果执行到这里说明程序跑飞了
return false;
}
6.2 计算基础矩阵F
和单应矩阵H
6.2.1 RANSAC算法
少数外点会极大影响计算结果的准确度,随着采样数量的增加,外加数量也会同时增加,这是一种或系统误差,无法通过增加采样点来解决。
RANSAC(Random sample consensus,随机采样一致性)算法的思路是少量多次重复实验,每次实验仅使用尽可能少的点来计算,并统计本次计算中的内点数.只要尝试次数足够多的话,总会找到一个包含所有内点的解.
RANSAC算法的核心是减少每次迭代所需的采样点数.从原理上来说,计算F
矩阵最少只需要7
对匹配点,计算H
矩阵最少只需要4
对匹配点;ORB-SLAM2中为了编程方便,每次迭代使用8
对匹配点计算F
和H
.
6.2.2 计算基础矩阵F
: FindFundamental()
/**
* @brief 计算基础矩阵,假设场景为非平面情况下通过前两帧求取Fundamental矩阵,得到该模型的评分
* Step 1 将当前帧和参考帧中的特征点坐标进行归一化
* Step 2 选择8个归一化之后的点对进行迭代
* Step 3 八点法计算基础矩阵矩阵
* Step 4 利用重投影误差为当次RANSAC的结果评分
* Step 5 更新具有最优评分的基础矩阵计算结果,并且保存所对应的特征点对的内点标记
*
* @param[in & out] vbMatchesInliers 标记是否是外点
* @param[in & out] score 计算基础矩阵得分
* @param[in & out] F21 从特征点1到2的基础矩阵
*/
void Initializer::FindFundamental(vector<bool> &vbMatchesInliers, float &score, cv::Mat &F21)
{
// 计算基础矩阵,其过程和上面的计算单应矩阵的过程十分相似.
// Number of putative matches
// 匹配的特征点对总数
// const int N = vbMatchesInliers.size(); // !源代码出错!请使用下面代替
const int N = mvMatches12.size();
// Normalize coordinates
// Step 1 将当前帧和参考帧中的特征点坐标进行归一化,主要是平移和尺度变换
// 具体来说,就是将mvKeys1和mvKey2归一化到均值为0,一阶绝对矩为1,归一化矩阵分别为T1、T2
// 这里所谓的一阶绝对矩其实就是随机变量到取值的中心的绝对值的平均值
// 归一化矩阵就是把上述归一化的操作用矩阵来表示。这样特征点坐标乘归一化矩阵可以得到归一化后的坐标
vector<cv::Point2f> vPn1, vPn2;
cv::Mat T1, T2;
Normalize(mvKeys1,vPn1, T1);
Normalize(mvKeys2,vPn2, T2);
// ! 注意这里取的是归一化矩阵T2的转置,因为基础矩阵的定义和单应矩阵不同,两者去归一化的计算也不相同
cv::Mat T2t = T2.t();
// Best Results variables
//最优结果
score = 0.0;
vbMatchesInliers = vector<bool>(N,false);
// Iteration variables
// 某次迭代中,参考帧的特征点坐标
vector<cv::Point2f> vPn1i(8);
// 某次迭代中,当前帧的特征点坐标
vector<cv::Point2f> vPn2i(8);
// 某次迭代中,计算的基础矩阵
cv::Mat F21i;
// 每次RANSAC记录的Inliers与得分
vector<bool> vbCurrentInliers(N,false);
float currentScore;
// Perform all RANSAC iterations and save the solution with highest score
// 下面进行每次的RANSAC迭代
for(int it=0; it<mMaxIterations; it++)
{
// Select a minimum set
// Step 2 选择8个归一化之后的点对进行迭代
for(int j=0; j<8; j++)
{
int idx = mvSets[it][j];
// vPn1i和vPn2i为匹配的特征点对的归一化后的坐标
// 首先根据这个特征点对的索引信息分别找到两个特征点在各自图像特征点向量中的索引,然后读取其归一化之后的特征点坐标
vPn1i[j] = vPn1[mvMatches12[idx].first]; //first存储在参考帧1中的特征点索引
vPn2i[j] = vPn2[mvMatches12[idx].second]; //second存储在参考帧1中的特征点索引
}
// Step 3 八点法计算基础矩阵
cv::Mat Fn = ComputeF21(vPn1i,vPn2i);
// 基础矩阵约束:p2^t*F21*p1 = 0,其中p1,p2 为齐次化特征点坐标
// 特征点归一化:vPn1 = T1 * mvKeys1, vPn2 = T2 * mvKeys2
// 根据基础矩阵约束得到:(T2 * mvKeys2)^t* Hn * T1 * mvKeys1 = 0
// 进一步得到:mvKeys2^t * T2^t * Hn * T1 * mvKeys1 = 0
F21i = T2t*Fn*T1;
// Step 4 利用重投影误差为当次RANSAC的结果评分
currentScore = CheckFundamental(F21i, vbCurrentInliers, mSigma);
// Step 5 更新具有最优评分的基础矩阵计算结果,并且保存所对应的特征点对的内点标记
if(currentScore>score)
{
//如果当前的结果得分更高,那么就更新最优计算结果
F21 = F21i.clone();
vbMatchesInliers = vbCurrentInliers;
score = currentScore;
}
}
}
6.2.3 八点法计算F
矩阵: ComputeF21()
展开成
一对点提供两个约束等式,单应矩阵H总共有9个元素,8个自由度(尺度等价性),所以需要4对点提供
8个约束方程就可以求解。
上图中 矩阵是一个 的矩阵, 是一个 的向量;上述方程是一个超定方程,使用SVD分解求最小二乘解.
/**
* @brief 根据特征点匹配求fundamental matrix(normalized 8点法)
* 注意F矩阵有秩为2的约束,所以需要两次SVD分解
*
* @param[in] vP1 参考帧中归一化后的特征点
* @param[in] vP2 当前帧中归一化后的特征点
* @return cv::Mat 最后计算得到的基础矩阵F
*/
cv::Mat Initializer::ComputeF21(
const vector<cv::Point2f> &vP1, //归一化后的点, in reference frame
const vector<cv::Point2f> &vP2) //归一化后的点, in current frame
{
// 原理详见附件推导
// x'Fx = 0 整理可得:Af = 0
// A = | x'x x'y x' y'x y'y y' x y 1 |, f = | f1 f2 f3 f4 f5 f6 f7 f8 f9 |
// 通过SVD求解Af = 0,A'A最小特征值对应的特征向量即为解
//获取参与计算的特征点对数
const int N = vP1.size();
//初始化A矩阵
cv::Mat A(N,9,CV_32F); // N*9维
// 构造矩阵A,将每个特征点添加到矩阵A中的元素
for(int i=0; i<N; i++)
{
const float u1 = vP1[i].x;
const float v1 = vP1[i].y;
const float u2 = vP2[i].x;
const float v2 = vP2[i].y;
A.at<float>(i,0) = u2*u1;
A.at<float>(i,1) = u2*v1;
A.at<float>(i,2) = u2;
A.at<float>(i,3) = v2*u1;
A.at<float>(i,4) = v2*v1;
A.at<float>(i,5) = v2;
A.at<float>(i,6) = u1;
A.at<float>(i,7) = v1;
A.at<float>(i,8) = 1;
}
//存储奇异值分解结果的变量
cv::Mat u,w,vt;
// 定义输出变量,u是左边的正交矩阵U, w为奇异矩阵,vt中的t表示是右正交矩阵V的转置
cv::SVDecomp(A,w,u,vt,cv::SVD::MODIFY_A | cv::SVD::FULL_UV);
// 转换成基础矩阵的形式
cv::Mat Fpre = vt.row(8).reshape(0, 3); // v的最后一列
//基础矩阵的秩为2,而我们不敢保证计算得到的这个结果的秩为2,所以需要通过第二次奇异值分解,来强制使其秩为2
// 对初步得来的基础矩阵进行第2次奇异值分解
cv::SVDecomp(Fpre,w,u,vt,cv::SVD::MODIFY_A | cv::SVD::FULL_UV);
// 秩2约束,强制将第3个奇异值设置为0
w.at<float>(2)=0;
// 重新组合好满足秩约束的基础矩阵,作为最终计算结果返回
return u*cv::Mat::diag(w)*vt;
}
6.2.4 计算单应矩阵H
: FindHomography()
以下两种情况更适合使用单应矩阵进行初始化:
- 相机看到的场景是一个平面.
- 连续两帧间没发生平移,只发生旋转.
使用八点法求解单应矩阵H
的原理类似:
/**
* @brief 计算单应矩阵,假设场景为平面情况下通过前两帧求取Homography矩阵,并得到该模型的评分
* 原理参考Multiple view geometry in computer vision P109 算法4.4
* Step 1 将当前帧和参考帧中的特征点坐标进行归一化
* Step 2 选择8个归一化之后的点对进行迭代
* Step 3 八点法计算单应矩阵矩阵
* Step 4 利用重投影误差为当次RANSAC的结果评分
* Step 5 更新具有最优评分的单应矩阵计算结果,并且保存所对应的特征点对的内点标记
*
* @param[in & out] vbMatchesInliers 标记是否是外点
* @param[in & out] score 计算单应矩阵的得分
* @param[in & out] H21 单应矩阵结果
*/
void Initializer::FindHomography(vector<bool> &vbMatchesInliers, float &score, cv::Mat &H21)
{
// Number of putative matches
//匹配的特征点对总数
const int N = mvMatches12.size();
// Normalize coordinates
// Step 1 将当前帧和参考帧中的特征点坐标进行归一化,主要是平移和尺度变换
// 具体来说,就是将mvKeys1和mvKey2归一化到均值为0,一阶绝对矩为1,归一化矩阵分别为T1、T2
// 这里所谓的一阶绝对矩其实就是随机变量到取值的中心的绝对值的平均值
// 归一化矩阵就是把上述归一化的操作用矩阵来表示。这样特征点坐标乘归一化矩阵可以得到归一化后的坐标
//归一化后的参考帧1和当前帧2中的特征点坐标
vector<cv::Point2f> vPn1, vPn2;
// 记录各自的归一化矩阵
cv::Mat T1, T2;
Normalize(mvKeys1,vPn1, T1);
Normalize(mvKeys2,vPn2, T2);
//这里求的逆在后面的代码中要用到,辅助进行原始尺度的恢复
cv::Mat T2inv = T2.inv();
// Best Results variables
// 记录最佳评分
score = 0.0;
// 取得历史最佳评分时,特征点对的inliers标记
vbMatchesInliers = vector<bool>(N,false);
// Iteration variables
//某次迭代中,参考帧的特征点坐标
vector<cv::Point2f> vPn1i(8);
//某次迭代中,当前帧的特征点坐标
vector<cv::Point2f> vPn2i(8);
//以及计算出来的单应矩阵、及其逆矩阵
cv::Mat H21i, H12i;
// 每次RANSAC记录Inliers与得分
vector<bool> vbCurrentInliers(N,false);
float currentScore;
// Perform all RANSAC iterations and save the solution with highest score
//下面进行每次的RANSAC迭代
for(int it=0; it<mMaxIterations; it++)
{
// Select a minimum set
// Step 2 选择8个归一化之后的点对进行迭代
for(size_t j=0; j<8; j++)
{
//从mvSets中获取当前次迭代的某个特征点对的索引信息
int idx = mvSets[it][j];
// vPn1i和vPn2i为匹配的特征点对的归一化后的坐标
// 首先根据这个特征点对的索引信息分别找到两个特征点在各自图像特征点向量中的索引,然后读取其归一化之后的特征点坐标
vPn1i[j] = vPn1[mvMatches12[idx].first]; //first存储在参考帧1中的特征点索引
vPn2i[j] = vPn2[mvMatches12[idx].second]; //second存储在参考帧1中的特征点索引
}//读取8对特征点的归一化之后的坐标
// Step 3 八点法计算单应矩阵
// 利用生成的8个归一化特征点对, 调用函数 Initializer::ComputeH21() 使用八点法计算单应矩阵
// 关于为什么计算之前要对特征点进行归一化,后面又恢复这个矩阵的尺度?
// 可以在《计算机视觉中的多视图几何》这本书中P193页中找到答案
// 书中这里说,8点算法成功的关键是在构造解的方称之前应对输入的数据认真进行适当的归一化
cv::Mat Hn = ComputeH21(vPn1i,vPn2i);
// 单应矩阵原理:X2=H21*X1,其中X1,X2 为归一化后的特征点
// 特征点归一化:vPn1 = T1 * mvKeys1, vPn2 = T2 * mvKeys2 得到:T2 * mvKeys2 = Hn * T1 * mvKeys1
// 进一步得到:mvKeys2 = T2.inv * Hn * T1 * mvKeys1
H21i = T2inv*Hn*T1;
//然后计算逆
H12i = H21i.inv();
// Step 4 利用重投影误差为当次RANSAC的结果评分
currentScore = CheckHomography(H21i, H12i, //输入,单应矩阵的计算结果
vbCurrentInliers, //输出,特征点对的Inliers标记
mSigma); //TODO 测量误差,在Initializer类对象构造的时候,由外部给定的
// Step 5 更新具有最优评分的单应矩阵计算结果,并且保存所对应的特征点对的内点标记
if(currentScore>score)
{
//如果当前的结果得分更高,那么就更新最优计算结果
H21 = H21i.clone();
//保存匹配好的特征点对的Inliers标记
vbMatchesInliers = vbCurrentInliers;
//更新历史最优评分
score = currentScore;
}
}
}
/**
* @brief 用DLT方法求解单应矩阵H
* 这里最少用4对点就能够求出来,不过这里为了统一还是使用了8对点求最小二乘解
*
* @param[in] vP1 参考帧中归一化后的特征点
* @param[in] vP2 当前帧中归一化后的特征点
* @return cv::Mat 计算的单应矩阵H
*/
cv::Mat Initializer::ComputeH21(
const vector<cv::Point2f> &vP1, //归一化后的点, in reference frame
const vector<cv::Point2f> &vP2) //归一化后的点, in current frame
{
// 基本原理:见附件推导过程:
// |x'| | h1 h2 h3 ||x|
// |y'| = a | h4 h5 h6 ||y| 简写: x' = a H x, a为一个尺度因子
// |1 | | h7 h8 h9 ||1|
// 使用DLT(direct linear tranform)求解该模型
// x' = a H x
// ---> (x') 叉乘 (H x) = 0 (因为方向相同) (取前两行就可以推导出下面的了)
// ---> Ah = 0
// A = | 0 0 0 -x -y -1 xy' yy' y'| h = | h1 h2 h3 h4 h5 h6 h7 h8 h9 |
// |-x -y -1 0 0 0 xx' yx' x'|
// 通过SVD求解Ah = 0,A^T*A最小特征值对应的特征向量即为解
// 其实也就是右奇异值矩阵的最后一列
//获取参与计算的特征点的数目
const int N = vP1.size();
// 构造用于计算的矩阵 A
cv::Mat A(2*N, //行,注意每一个点的数据对应两行
9, //列
CV_32F); //float数据类型
// 构造矩阵A,将每个特征点添加到矩阵A中的元素
for(int i=0; i<N; i++)
{
//获取特征点对的像素坐标
const float u1 = vP1[i].x;
const float v1 = vP1[i].y;
const float u2 = vP2[i].x;
const float v2 = vP2[i].y;
//生成这个点的第一行
A.at<float>(2*i,0) = 0.0;
A.at<float>(2*i,1) = 0.0;
A.at<float>(2*i,2) = 0.0;
A.at<float>(2*i,3) = -u1;
A.at<float>(2*i,4) = -v1;
A.at<float>(2*i,5) = -1;
A.at<float>(2*i,6) = v2*u1;
A.at<float>(2*i,7) = v2*v1;
A.at<float>(2*i,8) = v2;
//生成这个点的第二行
A.at<float>(2*i+1,0) = u1;
A.at<float>(2*i+1,1) = v1;
A.at<float>(2*i+1,2) = 1;
A.at<float>(2*i+1,3) = 0.0;
A.at<float>(2*i+1,4) = 0.0;
A.at<float>(2*i+1,5) = 0.0;
A.at<float>(2*i+1,6) = -u2*u1;
A.at<float>(2*i+1,7) = -u2*v1;
A.at<float>(2*i+1,8) = -u2;
}
// 定义输出变量,u是左边的正交矩阵U, w为奇异矩阵,vt中的t表示是右正交矩阵V的转置
cv::Mat u,w,vt;
//使用opencv提供的进行奇异值分解的函数
cv::SVDecomp(A, //输入,待进行奇异值分解的矩阵
w, //输出,奇异值矩阵
u, //输出,矩阵U
vt, //输出,矩阵V^T
cv::SVD::MODIFY_A | //输入,MODIFY_A是指允许计算函数可以修改待分解的矩阵,官方文档上说这样可以加快计算速度、节省内存
cv::SVD::FULL_UV); //FULL_UV=把U和VT补充成单位正交方阵
// 返回最小奇异值所对应的右奇异向量
// 注意前面说的是右奇异值矩阵的最后一列,但是在这里因为是vt,转置后了,所以是行;由于A有9列数据,故最后一列的下标为8
return vt.row(8).reshape(0, //转换后的通道数,这里设置为0表示是与前面相同
3); //转换后的行数,对应V的最后一列
}
6.2.5 卡方检验计算置信度得分: CheckFundamental()、CheckHomography()
卡方检验通过构造检验统计量来比较期望结果和实际结果之间的差别,从而得出观察频数极值的发生概率.
根据重投影误差构造统计量,其值越大,观察结果和期望结果之间的差别越显著,某次计算越可能用到了外点.
统计量置信度阈值与被检验变量自由度有关: 单目特征点重投影误差的自由度为2(u,v),双目特征点重投影误差自由度为3(u,v,ur).
取95%置信度下的卡方检验统计量阈值
若统计量大于该阈值,则认为计算矩阵使用到了外点,将其分数设为0.
若统计量小于该阈值,则将统计量裕量设为该解的置信度分数.
/**
* @brief 对给定的homography matrix打分,需要使用到卡方检验的知识
*
* @param[in] H21 从参考帧到当前帧的单应矩阵
* @param[in] H12 从当前帧到参考帧的单应矩阵
* @param[in] vbMatchesInliers 匹配好的特征点对的Inliers标记
* @param[in] sigma 方差,默认为1
* @return float 返回得分
*/
float Initializer::CheckHomography(
const cv::Mat &H21, //从参考帧到当前帧的单应矩阵
const cv::Mat &H12, //从当前帧到参考帧的单应矩阵
vector<bool> &vbMatchesInliers, //匹配好的特征点对的Inliers标记
float sigma) //估计误差
{
// 说明:在已值n维观测数据误差服从N(0,sigma)的高斯分布时
// 其误差加权最小二乘结果为 sum_error = SUM(e(i)^T * Q^(-1) * e(i))
// 其中:e(i) = [e_x,e_y,...]^T, Q维观测数据协方差矩阵,即sigma * sigma组成的协方差矩阵
// 误差加权最小二次结果越小,说明观测数据精度越高
// 那么,score = SUM((th - e(i)^T * Q^(-1) * e(i)))的分数就越高
// 算法目标: 检查单应变换矩阵
// 检查方式:通过H矩阵,进行参考帧和当前帧之间的双向投影,并计算起加权最小二乘投影误差
// 算法流程
// input: 单应性矩阵 H21, H12, 匹配点集 mvKeys1
// do:
// for p1(i), p2(i) in mvKeys:
// error_i1 = ||p2(i) - H21 * p1(i)||2
// error_i2 = ||p1(i) - H12 * p2(i)||2
//
// w1 = 1 / sigma / sigma
// w2 = 1 / sigma / sigma
//
// if error1 < th
// score += th - error_i1 * w1
// if error2 < th
// score += th - error_i2 * w2
//
// if error_1i > th or error_2i > th
// p1(i), p2(i) are inner points
// vbMatchesInliers(i) = true
// else
// p1(i), p2(i) are outliers
// vbMatchesInliers(i) = false
// end
// end
// output: score, inliers
// 特点匹配个数
const int N = mvMatches12.size();
// Step 1 获取从参考帧到当前帧的单应矩阵的各个元素
const float h11 = H21.at<float>(0,0);
const float h12 = H21.at<float>(0,1);
const float h13 = H21.at<float>(0,2);
const float h21 = H21.at<float>(1,0);
const float h22 = H21.at<float>(1,1);
const float h23 = H21.at<float>(1,2);
const float h31 = H21.at<float>(2,0);
const float h32 = H21.at<float>(2,1);
const float h33 = H21.at<float>(2,2);
// 获取从当前帧到参考帧的单应矩阵的各个元素
const float h11inv = H12.at<float>(0,0);
const float h12inv = H12.at<float>(0,1);
const float h13inv = H12.at<float>(0,2);
const float h21inv = H12.at<float>(1,0);
const float h22inv = H12.at<float>(1,1);
const float h23inv = H12.at<float>(1,2);
const float h31inv = H12.at<float>(2,0);
const float h32inv = H12.at<float>(2,1);
const float h33inv = H12.at<float>(2,2);
// 给特征点对的Inliers标记预分配空间
vbMatchesInliers.resize(N);
// 初始化score值
float score = 0;
// 基于卡方检验计算出的阈值(假设测量有一个像素的偏差)
// 自由度为2的卡方分布,显著性水平为0.05,对应的临界阈值
const float th = 5.991;
//信息矩阵,方差平方的倒数
const float invSigmaSquare = 1.0/(sigma * sigma);
// Step 2 通过H矩阵,进行参考帧和当前帧之间的双向投影,并计算起加权重投影误差
// H21 表示从img1 到 img2的变换矩阵
// H12 表示从img2 到 img1的变换矩阵
for(int i = 0; i < N; i++)
{
// 一开始都默认为Inlier
bool bIn = true;
// Step 2.1 提取参考帧和当前帧之间的特征匹配点对
const cv::KeyPoint &kp1 = mvKeys1[mvMatches12[i].first];
const cv::KeyPoint &kp2 = mvKeys2[mvMatches12[i].second];
const float u1 = kp1.pt.x;
const float v1 = kp1.pt.y;
const float u2 = kp2.pt.x;
const float v2 = kp2.pt.y;
// Step 2.2 计算 img2 到 img1 的重投影误差
// x1 = H12*x2
// 将图像2中的特征点通过单应变换投影到图像1中
// |u1| |h11inv h12inv h13inv||u2| |u2in1|
// |v1| = |h21inv h22inv h23inv||v2| = |v2in1| * w2in1inv
// |1 | |h31inv h32inv h33inv||1 | | 1 |
// 计算投影归一化坐标
const float w2in1inv = 1.0/(h31inv * u2 + h32inv * v2 + h33inv);
const float u2in1 = (h11inv * u2 + h12inv * v2 + h13inv) * w2in1inv;
const float v2in1 = (h21inv * u2 + h22inv * v2 + h23inv) * w2in1inv;
// 计算重投影误差 = ||p1(i) - H12 * p2(i)||2
const float squareDist1 = (u1 - u2in1) * (u1 - u2in1) + (v1 - v2in1) * (v1 - v2in1);
const float chiSquare1 = squareDist1 * invSigmaSquare;
// Step 2.3 用阈值标记离群点,内点的话累加得分
if(chiSquare1>th)
bIn = false;
else
// 误差越大,得分越低
score += th - chiSquare1;
// 计算从img1 到 img2 的投影变换误差
// x1in2 = H21*x1
// 将图像2中的特征点通过单应变换投影到图像1中
// |u2| |h11 h12 h13||u1| |u1in2|
// |v2| = |h21 h22 h23||v1| = |v1in2| * w1in2inv
// |1 | |h31 h32 h33||1 | | 1 |
// 计算投影归一化坐标
const float w1in2inv = 1.0/(h31*u1+h32*v1+h33);
const float u1in2 = (h11*u1+h12*v1+h13)*w1in2inv;
const float v1in2 = (h21*u1+h22*v1+h23)*w1in2inv;
// 计算重投影误差
const float squareDist2 = (u2-u1in2)*(u2-u1in2)+(v2-v1in2)*(v2-v1in2);
const float chiSquare2 = squareDist2*invSigmaSquare;
// 用阈值标记离群点,内点的话累加得分
if(chiSquare2>th)
bIn = false;
else
score += th - chiSquare2;
// Step 2.4 如果从img2 到 img1 和 从img1 到img2的重投影误差均满足要求,则说明是Inlier point
if(bIn)
vbMatchesInliers[i]=true;
else
vbMatchesInliers[i]=false;
}
return score;
}
/**
* @brief 对给定的Fundamental matrix打分
*
* @param[in] F21 当前帧和参考帧之间的基础矩阵
* @param[in] vbMatchesInliers 匹配的特征点对属于inliers的标记
* @param[in] sigma 方差,默认为1
* @return float 返回得分
*/
float Initializer::CheckFundamental(
const cv::Mat &F21, //当前帧和参考帧之间的基础矩阵
vector<bool> &vbMatchesInliers, //匹配的特征点对属于inliers的标记
float sigma) //方差
{
// 说明:在已值n维观测数据误差服从N(0,sigma)的高斯分布时
// 其误差加权最小二乘结果为 sum_error = SUM(e(i)^T * Q^(-1) * e(i))
// 其中:e(i) = [e_x,e_y,...]^T, Q维观测数据协方差矩阵,即sigma * sigma组成的协方差矩阵
// 误差加权最小二次结果越小,说明观测数据精度越高
// 那么,score = SUM((th - e(i)^T * Q^(-1) * e(i)))的分数就越高
// 算法目标:检查基础矩阵
// 检查方式:利用对极几何原理 p2^T * F * p1 = 0
// 假设:三维空间中的点 P 在 img1 和 img2 两图像上的投影分别为 p1 和 p2(两个为同名点)
// 则:p2 一定存在于极线 l2 上,即 p2*l2 = 0. 而l2 = F*p1 = (a, b, c)^T
// 所以,这里的误差项 e 为 p2 到 极线 l2 的距离,如果在直线上,则 e = 0
// 根据点到直线的距离公式:d = (ax + by + c) / sqrt(a * a + b * b)
// 所以,e = (a * p2.x + b * p2.y + c) / sqrt(a * a + b * b)
// 算法流程
// input: 基础矩阵 F 左右视图匹配点集 mvKeys1
// do:
// for p1(i), p2(i) in mvKeys:
// l2 = F * p1(i)
// l1 = p2(i) * F
// error_i1 = dist_point_to_line(x2,l2)
// error_i2 = dist_point_to_line(x1,l1)
//
// w1 = 1 / sigma / sigma
// w2 = 1 / sigma / sigma
//
// if error1 < th
// score += thScore - error_i1 * w1
// if error2 < th
// score += thScore - error_i2 * w2
//
// if error_1i > th or error_2i > th
// p1(i), p2(i) are inner points
// vbMatchesInliers(i) = true
// else
// p1(i), p2(i) are outliers
// vbMatchesInliers(i) = false
// end
// end
// output: score, inliers
// 获取匹配的特征点对的总对数
const int N = mvMatches12.size();
// Step 1 提取基础矩阵中的元素数据
const float f11 = F21.at<float>(0,0);
const float f12 = F21.at<float>(0,1);
const float f13 = F21.at<float>(0,2);
const float f21 = F21.at<float>(1,0);
const float f22 = F21.at<float>(1,1);
const float f23 = F21.at<float>(1,2);
const float f31 = F21.at<float>(2,0);
const float f32 = F21.at<float>(2,1);
const float f33 = F21.at<float>(2,2);
// 预分配空间
vbMatchesInliers.resize(N);
// 设置评分初始值(因为后面需要进行这个数值的累计)
float score = 0;
// 基于卡方检验计算出的阈值
// 自由度为1的卡方分布,显著性水平为0.05,对应的临界阈值
// ?是因为点到直线距离是一个自由度吗?
const float th = 3.841;
// 自由度为2的卡方分布,显著性水平为0.05,对应的临界阈值
const float thScore = 5.991;
// 信息矩阵,或 协方差矩阵的逆矩阵
const float invSigmaSquare = 1.0/(sigma*sigma);
// Step 2 计算img1 和 img2 在估计 F 时的score值
for(int i=0; i<N; i++)
{
//默认为这对特征点是Inliers
bool bIn = true;
// Step 2.1 提取参考帧和当前帧之间的特征匹配点对
const cv::KeyPoint &kp1 = mvKeys1[mvMatches12[i].first];
const cv::KeyPoint &kp2 = mvKeys2[mvMatches12[i].second];
// 提取出特征点的坐标
const float u1 = kp1.pt.x;
const float v1 = kp1.pt.y;
const float u2 = kp2.pt.x;
const float v2 = kp2.pt.y;
// Reprojection error in second image
// Step 2.2 计算 img1 上的点在 img2 上投影得到的极线 l2 = F21 * p1 = (a2,b2,c2)
const float a2 = f11*u1+f12*v1+f13;
const float b2 = f21*u1+f22*v1+f23;
const float c2 = f31*u1+f32*v1+f33;
// Step 2.3 计算误差 e = (a * p2.x + b * p2.y + c) / sqrt(a * a + b * b)
const float num2 = a2*u2+b2*v2+c2;
const float squareDist1 = num2*num2/(a2*a2+b2*b2);
// 带权重误差
const float chiSquare1 = squareDist1*invSigmaSquare;
// Step 2.4 误差大于阈值就说明这个点是Outlier
// ? 为什么判断阈值用的 th(1自由度),计算得分用的thScore(2自由度)
// ? 可能是为了和CheckHomography 得分统一?
if(chiSquare1>th)
bIn = false;
else
// 误差越大,得分越低
score += thScore - chiSquare1;
// 计算img2上的点在 img1 上投影得到的极线 l1= p2 * F21 = (a1,b1,c1)
const float a1 = f11*u2+f21*v2+f31;
const float b1 = f12*u2+f22*v2+f32;
const float c1 = f13*u2+f23*v2+f33;
// 计算误差 e = (a * p2.x + b * p2.y + c) / sqrt(a * a + b * b)
const float num1 = a1*u1+b1*v1+c1;
const float squareDist2 = num1*num1/(a1*a1+b1*b1);
// 带权重误差
const float chiSquare2 = squareDist2*invSigmaSquare;
// 误差大于阈值就说明这个点是Outlier
if(chiSquare2>th)
bIn = false;
else
score += thScore - chiSquare2;
// Step 2.5 保存结果
if(bIn)
vbMatchesInliers[i]=true;
else
vbMatchesInliers[i]=false;
}
// 返回评分
return score;
}
6.2.6 归一化: Normalize()
使用均值和一阶中心矩归一化,归一化可以增强计算稳定性.
/**
* @brief 归一化特征点到同一尺度,作为后续normalize DLT的输入
* [x' y' 1]' = T * [x y 1]'
* 归一化后x', y'的均值为0,sum(abs(x_i'-0))=1,sum(abs((y_i'-0))=1
*
* 为什么要归一化?
* 在相似变换之后(点在不同的坐标系下),他们的单应性矩阵是不相同的
* 如果图像存在噪声,使得点的坐标发生了变化,那么它的单应性矩阵也会发生变化
* 我们采取的方法是将点的坐标放到同一坐标系下,并将缩放尺度也进行统一
* 对同一幅图像的坐标进行相同的变换,不同图像进行不同变换
* 缩放尺度是为了让噪声对于图像的影响在一个数量级上
*
* Step 1 计算特征点X,Y坐标的均值
* Step 2 计算特征点X,Y坐标离均值的平均偏离程度
* Step 3 将x坐标和y坐标分别进行尺度归一化,使得x坐标和y坐标的一阶绝对矩分别为1
* Step 4 计算归一化矩阵:其实就是前面做的操作用矩阵变换来表示而已
*
* @param[in] vKeys 待归一化的特征点
* @param[in & out] vNormalizedPoints 特征点归一化后的坐标
* @param[in & out] T 归一化特征点的变换矩阵
*/
void Initializer::Normalize(const vector<cv::KeyPoint> &vKeys, vector<cv::Point2f> &vNormalizedPoints, cv::Mat &T) //将特征点归一化的矩阵
{
// 归一化的是这些点在x方向和在y方向上的一阶绝对矩(随机变量的期望)。
// Step 1 计算特征点X,Y坐标的均值 meanX, meanY
float meanX = 0;
float meanY = 0;
//获取特征点的数量
const int N = vKeys.size();
//设置用来存储归一后特征点的向量大小,和归一化前保持一致
vNormalizedPoints.resize(N);
//开始遍历所有的特征点
for(int i=0; i<N; i++)
{
//分别累加特征点的X、Y坐标
meanX += vKeys[i].pt.x;
meanY += vKeys[i].pt.y;
}
//计算X、Y坐标的均值
meanX = meanX/N;
meanY = meanY/N;
// Step 2 计算特征点X,Y坐标离均值的平均偏离程度 meanDevX, meanDevY,注意不是标准差
float meanDevX = 0;
float meanDevY = 0;
// 将原始特征点减去均值坐标,使x坐标和y坐标均值分别为0
for(int i=0; i<N; i++)
{
vNormalizedPoints[i].x = vKeys[i].pt.x - meanX;
vNormalizedPoints[i].y = vKeys[i].pt.y - meanY;
//累计这些特征点偏离横纵坐标均值的程度
meanDevX += fabs(vNormalizedPoints[i].x);
meanDevY += fabs(vNormalizedPoints[i].y);
}
// 求出平均到每个点上,其坐标偏离横纵坐标均值的程度;将其倒数作为一个尺度缩放因子
meanDevX = meanDevX/N;
meanDevY = meanDevY/N;
float sX = 1.0/meanDevX;
float sY = 1.0/meanDevY;
// Step 3 将x坐标和y坐标分别进行尺度归一化,使得x坐标和y坐标的一阶绝对矩分别为1
// 这里所谓的一阶绝对矩其实就是随机变量到取值的中心的绝对值的平均值(期望)
for(int i=0; i<N; i++)
{
//对,就是简单地对特征点的坐标进行进一步的缩放
vNormalizedPoints[i].x = vNormalizedPoints[i].x * sX;
vNormalizedPoints[i].y = vNormalizedPoints[i].y * sY;
}
// Step 4 计算归一化矩阵:其实就是前面做的操作用矩阵变换来表示而已
// |sX 0 -meanx*sX|
// |0 sY -meany*sY|
// |0 0 1 |
T = cv::Mat::eye(3,3,CV_32F);
T.at<float>(0,0) = sX;
T.at<float>(1,1) = sY;
T.at<float>(0,2) = -meanX*sX;
T.at<float>(1,2) = -meanY*sY;
}
6.3 使用基础矩阵F和单应矩阵H恢复运动
6.3.1 使用基础矩阵F恢复运动: ReconstructF()
6.3.2 使用基础矩阵H恢复运动: ReconstructH()
使用基础矩阵F分解R、t,数学上会得到四个可能的解,因此分解后调用函数Initializer::CheckRT()检验分解结果,取相机前方成功三角化数目最多的一组解.
/**
* @brief 用H矩阵恢复R, t和三维点
* H矩阵分解常见有两种方法:Faugeras SVD-based decomposition 和 Zhang SVD-based decomposition
* 代码使用了Faugeras SVD-based decomposition算法,参考文献
* Motion and structure from motion in a piecewise planar environment. International Journal of Pattern Recognition and Artificial Intelligence, 1988
*
* @param[in] vbMatchesInliers 匹配点对的内点标记
* @param[in] H21 从参考帧到当前帧的单应矩阵
* @param[in] K 相机的内参数矩阵
* @param[in & out] R21 计算出来的相机旋转
* @param[in & out] t21 计算出来的相机平移
* @param[in & out] vP3D 世界坐标系下,三角化测量特征点对之后得到的特征点的空间坐标
* @param[in & out] vbTriangulated 特征点是否成功三角化的标记
* @param[in] minParallax 对特征点的三角化测量中,认为其测量有效时需要满足的最小视差角(如果视差角过小则会引起非常大的观测误差),单位是角度
* @param[in] minTriangulated 为了进行运动恢复,所需要的最少的三角化测量成功的点个数
* @return true 单应矩阵成功计算出位姿和三维点
* @return false 初始化失败
*/
bool Initializer::ReconstructH(vector<bool> &vbMatchesInliers, cv::Mat &H21, cv::Mat &K,
cv::Mat &R21, cv::Mat &t21, vector<cv::Point3f> &vP3D, vector<bool> &vbTriangulated, float minParallax, int minTriangulated)
{
// 目的 :通过单应矩阵H恢复两帧图像之间的旋转矩阵R和平移向量T
// 参考 :Motion and structure from motion in a piecewise plannar environment.
// International Journal of Pattern Recognition and Artificial Intelligence, 1988
// https://www.researchgate.net/publication/243764888_Motion_and_Structure_from_Motion_in_a_Piecewise_Planar_Environment
// 流程:
// 1. 根据H矩阵的奇异值d'= d2 或者 d' = -d2 分别计算 H 矩阵分解的 8 组解
// 1.1 讨论 d' > 0 时的 4 组解
// 1.2 讨论 d' < 0 时的 4 组解
// 2. 对 8 组解进行验证,并选择产生相机前方最多3D点的解为最优解
// 统计匹配的特征点对中属于内点(Inlier)或有效点个数
int N=0;
for(size_t i=0, iend = vbMatchesInliers.size() ; i<iend; i++)
if(vbMatchesInliers[i])
N++;
// We recover 8 motion hypotheses using the method of Faugeras et al.
// Motion and structure from motion in a piecewise planar environment.
// International Journal of Pattern Recognition and Artificial Intelligence, 1988
// 参考SLAM十四讲第二版p170-p171
// H = K * (R - t * n / d) * K_inv
// 其中: K表示内参数矩阵
// K_inv 表示内参数矩阵的逆
// R 和 t 表示旋转和平移向量
// n 表示平面法向量
// 令 H = K * A * K_inv
// 则 A = k_inv * H * k
cv::Mat invK = K.inv();
cv::Mat A = invK*H21*K;
// 对矩阵A进行SVD分解
// A 等待被进行奇异值分解的矩阵
// w 奇异值矩阵
// U 奇异值分解左矩阵
// Vt 奇异值分解右矩阵,注意函数返回的是转置
// cv::SVD::FULL_UV 全部分解
// A = U * w * Vt
cv::Mat U,w,Vt,V;
cv::SVD::compute(A, w, U, Vt, cv::SVD::FULL_UV);
// 根据文献eq(8),计算关联变量
V=Vt.t();
// 计算变量s = det(U) * det(V)
// 因为det(V)==det(Vt), 所以 s = det(U) * det(Vt)
float s = cv::determinant(U)*cv::determinant(Vt);
// 取得矩阵的各个奇异值
float d1 = w.at<float>(0);
float d2 = w.at<float>(1);
float d3 = w.at<float>(2);
// SVD分解正常情况下特征值di应该是正的,且满足d1>=d2>=d3
if(d1/d2<1.00001 || d2/d3<1.00001) {
return false;
}
// 在ORBSLAM中没有对奇异值 d1 d2 d3按照论文中描述的关系进行分类讨论, 而是直接进行了计算
// 定义8中情况下的旋转矩阵、平移向量和空间向量
vector<cv::Mat> vR, vt, vn;
vR.reserve(8);
vt.reserve(8);
vn.reserve(8);
// Step 1.1 讨论 d' > 0 时的 4 组解
// 根据论文eq.(12)有
// x1 = e1 * sqrt((d1 * d1 - d2 * d2) / (d1 * d1 - d3 * d3))
// x2 = 0
// x3 = e3 * sqrt((d2 * d2 - d2 * d2) / (d1 * d1 - d3 * d3))
// 令 aux1 = sqrt((d1*d1-d2*d2)/(d1*d1-d3*d3))
// aux3 = sqrt((d2*d2-d3*d3)/(d1*d1-d3*d3))
// 则
// x1 = e1 * aux1
// x3 = e3 * aux2
// 因为 e1,e2,e3 = 1 or -1
// 所以有x1和x3有四种组合
// x1 = {aux1,aux1,-aux1,-aux1}
// x3 = {aux3,-aux3,aux3,-aux3}
float aux1 = sqrt((d1*d1-d2*d2)/(d1*d1-d3*d3));
float aux3 = sqrt((d2*d2-d3*d3)/(d1*d1-d3*d3));
float x1[] = {aux1,aux1,-aux1,-aux1};
float x3[] = {aux3,-aux3,aux3,-aux3};
// 根据论文eq.(13)有
// sin(theta) = e1 * e3 * sqrt(( d1 * d1 - d2 * d2) * (d2 * d2 - d3 * d3)) /(d1 + d3)/d2
// cos(theta) = (d2* d2 + d1 * d3) / (d1 + d3) / d2
// 令 aux_stheta = sqrt((d1*d1-d2*d2)*(d2*d2-d3*d3))/((d1+d3)*d2)
// 则 sin(theta) = e1 * e3 * aux_stheta
// cos(theta) = (d2*d2+d1*d3)/((d1+d3)*d2)
// 因为 e1 e2 e3 = 1 or -1
// 所以 sin(theta) = {aux_stheta, -aux_stheta, -aux_stheta, aux_stheta}
float aux_stheta = sqrt((d1*d1-d2*d2)*(d2*d2-d3*d3))/((d1+d3)*d2);
float ctheta = (d2*d2+d1*d3)/((d1+d3)*d2);
float stheta[] = {aux_stheta, -aux_stheta, -aux_stheta, aux_stheta};
// 计算旋转矩阵 R'
//根据不同的e1 e3组合所得出来的四种R t的解
// | ctheta 0 -aux_stheta| | aux1|
// Rp = | 0 1 0 | tp = | 0 |
// | aux_stheta 0 ctheta | |-aux3|
// | ctheta 0 aux_stheta| | aux1|
// Rp = | 0 1 0 | tp = | 0 |
// |-aux_stheta 0 ctheta | | aux3|
// | ctheta 0 aux_stheta| |-aux1|
// Rp = | 0 1 0 | tp = | 0 |
// |-aux_stheta 0 ctheta | |-aux3|
// | ctheta 0 -aux_stheta| |-aux1|
// Rp = | 0 1 0 | tp = | 0 |
// | aux_stheta 0 ctheta | | aux3|
// 开始遍历这四种情况中的每一种
for(int i=0; i<4; i++)
{
//生成Rp,就是eq.(8) 的 R'
cv::Mat Rp=cv::Mat::eye(3,3,CV_32F);
Rp.at<float>(0,0)=ctheta;
Rp.at<float>(0,2)=-stheta[i];
Rp.at<float>(2,0)=stheta[i];
Rp.at<float>(2,2)=ctheta;
// eq.(8) 计算R
cv::Mat R = s*U*Rp*Vt;
// 保存
vR.push_back(R);
// eq. (14) 生成tp
cv::Mat tp(3,1,CV_32F);
tp.at<float>(0)=x1[i];
tp.at<float>(1)=0;
tp.at<float>(2)=-x3[i];
tp*=d1-d3;
// 这里虽然对t有归一化,并没有决定单目整个SLAM过程的尺度
// 因为CreateInitialMapMonocular函数对3D点深度会缩放,然后反过来对 t 有改变
// eq.(8)恢复原始的t
cv::Mat t = U*tp;
vt.push_back(t/cv::norm(t));
// 构造法向量np
cv::Mat np(3,1,CV_32F);
np.at<float>(0)=x1[i];
np.at<float>(1)=0;
np.at<float>(2)=x3[i];
// eq.(8) 恢复原始的法向量
cv::Mat n = V*np;
//看PPT 16页的图,保持平面法向量向上
if(n.at<float>(2)<0)
n=-n;
// 添加到vector
vn.push_back(n);
}
// Step 1.2 讨论 d' < 0 时的 4 组解
float aux_sphi = sqrt((d1*d1-d2*d2)*(d2*d2-d3*d3))/((d1-d3)*d2);
// cos_theta项
float cphi = (d1*d3-d2*d2)/((d1-d3)*d2);
// 考虑到e1,e2的取值,这里的sin_theta有两种可能的解
float sphi[] = {aux_sphi, -aux_sphi, -aux_sphi, aux_sphi};
// 对于每种由e1 e3取值的组合而形成的四种解的情况
for(int i=0; i<4; i++)
{
// 计算旋转矩阵 R'
cv::Mat Rp=cv::Mat::eye(3,3,CV_32F);
Rp.at<float>(0,0)=cphi;
Rp.at<float>(0,2)=sphi[i];
Rp.at<float>(1,1)=-1;
Rp.at<float>(2,0)=sphi[i];
Rp.at<float>(2,2)=-cphi;
// 恢复出原来的R
cv::Mat R = s*U*Rp*Vt;
// 然后添加到vector中
vR.push_back(R);
// 构造tp
cv::Mat tp(3,1,CV_32F);
tp.at<float>(0)=x1[i];
tp.at<float>(1)=0;
tp.at<float>(2)=x3[i];
tp*=d1+d3;
// 恢复出原来的t
cv::Mat t = U*tp;
// 归一化之后加入到vector中,要提供给上面的平移矩阵都是要进行过归一化的
vt.push_back(t/cv::norm(t));
// 构造法向量np
cv::Mat np(3,1,CV_32F);
np.at<float>(0)=x1[i];
np.at<float>(1)=0;
np.at<float>(2)=x3[i];
// 恢复出原来的法向量
cv::Mat n = V*np;
// 保证法向量指向上方
if(n.at<float>(2)<0)
n=-n;
// 添加到vector中
vn.push_back(n);
}
// 最好的good点
int bestGood = 0;
// 其次最好的good点
int secondBestGood = 0;
// 最好的解的索引,初始值为-1
int bestSolutionIdx = -1;
// 最大的视差角
float bestParallax = -1;
// 存储最好解对应的,对特征点对进行三角化测量的结果
vector<cv::Point3f> bestP3D;
// 最佳解所对应的,那些可以被三角化测量的点的标记
vector<bool> bestTriangulated;
// Instead of applying the visibility constraints proposed in the WFaugeras' paper (which could fail for points seen with low parallax)
// We reconstruct all hypotheses and check in terms of triangulated points and parallax
// Step 2. 对 8 组解进行验证,并选择产生相机前方最多3D点的解为最优解
for(size_t i=0; i<8; i++)
{
// 第i组解对应的比较大的视差角
float parallaxi;
// 三角化测量之后的特征点的空间坐标
vector<cv::Point3f> vP3Di;
// 特征点对是否被三角化的标记
vector<bool> vbTriangulatedi;
// 调用 Initializer::CheckRT(), 计算good点的数目
int nGood = CheckRT(vR[i],vt[i], //当前组解的旋转矩阵和平移向量
mvKeys1,mvKeys2, //特征点
mvMatches12,vbMatchesInliers, //特征匹配关系以及Inlier标记
K, //相机的内参数矩阵
vP3Di, //存储三角化测量之后的特征点空间坐标的
4.0*mSigma2, //三角化过程中允许的最大重投影误差
vbTriangulatedi, //特征点是否被成功进行三角测量的标记
parallaxi); // 这组解在三角化测量的时候的比较大的视差角
// 更新历史最优和次优的解
// 保留最优的和次优的解.保存次优解的目的是看看最优解是否突出
if(nGood>bestGood)
{
// 如果当前组解的good点数是历史最优,那么之前的历史最优就变成了历史次优
secondBestGood = bestGood;
// 更新历史最优点
bestGood = nGood;
// 最优解的组索引为i(就是当前次遍历)
bestSolutionIdx = i;
// 更新变量
bestParallax = parallaxi;
bestP3D = vP3Di;
bestTriangulated = vbTriangulatedi;
}
// 如果当前组的good计数小于历史最优但却大于历史次优
else if(nGood>secondBestGood)
{
// 说明当前组解是历史次优点,更新之
secondBestGood = nGood;
}
}
// Step 3 选择最优解。要满足下面的四个条件
// 1. good点数最优解明显大于次优解,这里取0.75经验值
// 2. 视角差大于规定的阈值
// 3. good点数要大于规定的最小的被三角化的点数量
// 4. good数要足够多,达到总数的90%以上
if(secondBestGood<0.75*bestGood &&
bestParallax>=minParallax &&
bestGood>minTriangulated &&
bestGood>0.9*N)
{
// 从最佳的解的索引访问到R,t
vR[bestSolutionIdx].copyTo(R21);
vt[bestSolutionIdx].copyTo(t21);
// 获得最佳解时,成功三角化的三维点,以后作为初始地图点使用
vP3D = bestP3D;
// 获取特征点的被成功进行三角化的标记
vbTriangulated = bestTriangulated;
//返回真,找到了最好的解
return true;
}
return false;
}
6.3.3 检验分解结果R
,t
通过成功三角化的特征点个数判断分解结果的好坏: 若某特征点的重投影误差小于4
且视差角大于0.36°
,则认为该特征点三角化成功
/**
* @brief 用位姿来对特征匹配点三角化,从中筛选中合格的三维点
*
* @param[in] R 旋转矩阵R
* @param[in] t 平移矩阵t
* @param[in] vKeys1 参考帧特征点
* @param[in] vKeys2 当前帧特征点
* @param[in] vMatches12 两帧特征点的匹配关系
* @param[in] vbMatchesInliers 特征点对内点标记
* @param[in] K 相机内参矩阵
* @param[in & out] vP3D 三角化测量之后的特征点的空间坐标
* @param[in] th2 重投影误差的阈值
* @param[in & out] vbGood 标记成功三角化点?
* @param[in & out] parallax 计算出来的比较大的视差角(注意不是最大,具体看后面代码)
* @return int
*/
int Initializer::CheckRT(const cv::Mat &R, const cv::Mat &t, const vector<cv::KeyPoint> &vKeys1, const vector<cv::KeyPoint> &vKeys2,
const vector<Match> &vMatches12, vector<bool> &vbMatchesInliers,
const cv::Mat &K, vector<cv::Point3f> &vP3D, float th2, vector<bool> &vbGood, float ¶llax)
{
// 对给出的特征点对及其R t , 通过三角化检查解的有效性,也称为 cheirality check
// Calibration parameters
//从相机内参数矩阵获取相机的校正参数
const float fx = K.at<float>(0,0);
const float fy = K.at<float>(1,1);
const float cx = K.at<float>(0,2);
const float cy = K.at<float>(1,2);
//特征点是否是good点的标记,这里的特征点指的是参考帧中的特征点
vbGood = vector<bool>(vKeys1.size(),false);
//重设存储空间坐标的点的大小
vP3D.resize(vKeys1.size());
//存储计算出来的每对特征点的视差
vector<float> vCosParallax;
vCosParallax.reserve(vKeys1.size());
// Camera 1 Projection Matrix K[I|0]
// Step 1:计算相机的投影矩阵
// 投影矩阵P是一个 3x4 的矩阵,可以将空间中的一个点投影到平面上,获得其平面坐标,这里均指的是齐次坐标。
// 对于第一个相机是 P1=K*[I|0]
// 以第一个相机的光心作为世界坐标系, 定义相机的投影矩阵
cv::Mat P1(3,4, //矩阵的大小是3x4
CV_32F, //数据类型是浮点数
cv::Scalar(0)); //初始的数值是0
//将整个K矩阵拷贝到P1矩阵的左侧3x3矩阵,因为 K*I = K
K.copyTo(P1.rowRange(0,3).colRange(0,3));
// 第一个相机的光心设置为世界坐标系下的原点
cv::Mat O1 = cv::Mat::zeros(3,1,CV_32F);
// Camera 2 Projection Matrix K[R|t]
// 计算第二个相机的投影矩阵 P2=K*[R|t]
cv::Mat P2(3,4,CV_32F);
R.copyTo(P2.rowRange(0,3).colRange(0,3));
t.copyTo(P2.rowRange(0,3).col(3));
//最终结果是K*[R|t]
P2 = K*P2;
// 第二个相机的光心在世界坐标系下的坐标
cv::Mat O2 = -R.t()*t;
//在遍历开始前,先将good点计数设置为0
int nGood=0;
// 开始遍历所有的特征点对
for(size_t i=0, iend=vMatches12.size();i<iend;i++)
{
// 跳过outliers
if(!vbMatchesInliers[i])
continue;
// Step 2 获取特征点对,调用Triangulate() 函数进行三角化,得到三角化测量之后的3D点坐标
// kp1和kp2是匹配好的有效特征点
const cv::KeyPoint &kp1 = vKeys1[vMatches12[i].first];
const cv::KeyPoint &kp2 = vKeys2[vMatches12[i].second];
//存储三维点的的坐标
cv::Mat p3dC1;
// 利用三角法恢复三维点p3dC1
Triangulate(kp1,kp2, //特征点
P1,P2, //投影矩阵
p3dC1); //输出,三角化测量之后特征点的空间坐标
// Step 3 第一关:检查三角化的三维点坐标是否合法(非无穷值)
// 只要三角测量的结果中有一个是无穷大的就说明三角化失败,跳过对当前点的处理,进行下一对特征点的遍历
if(!isfinite(p3dC1.at<float>(0)) || !isfinite(p3dC1.at<float>(1)) || !isfinite(p3dC1.at<float>(2)))
{
//其实这里就算是不这样写也没问题,因为默认的匹配点对就不是good点
vbGood[vMatches12[i].first]=false;
//继续对下一对匹配点的处理
continue;
}
// Check parallax
// Step 4 第二关:通过三维点深度值正负、两相机光心视差角大小来检查是否合法
//得到向量PO1
cv::Mat normal1 = p3dC1 - O1;
//求取模长,其实就是距离
float dist1 = cv::norm(normal1);
//同理构造向量PO2
cv::Mat normal2 = p3dC1 - O2;
//求模长
float dist2 = cv::norm(normal2);
//根据公式:a.*b=|a||b|cos_theta 可以推导出来下面的式子
float cosParallax = normal1.dot(normal2)/(dist1*dist2);
// Check depth in front of first camera (only if enough parallax, as "infinite" points can easily go to negative depth)
// 如果深度值为负值,为非法三维点跳过该匹配点对
// ?视差比较小时,重投影误差比较大。这里0.99998 对应的角度为0.36°,这里不应该是 cosParallax>0.99998 吗?
// ?因为后面判断vbGood 点时的条件也是 cosParallax<0.99998
// !可能导致初始化不稳定
if(p3dC1.at<float>(2)<=0 && cosParallax<0.99998)
continue;
// Check depth in front of second camera (only if enough parallax, as "infinite" points can easily go to negative depth)
// 讲空间点p3dC1变换到第2个相机坐标系下变为p3dC2
cv::Mat p3dC2 = R*p3dC1+t;
//判断过程和上面的相同
if(p3dC2.at<float>(2)<=0 && cosParallax<0.99998)
continue;
// Step 5 第三关:计算空间点在参考帧和当前帧上的重投影误差,如果大于阈值则舍弃
// Check reprojection error in first image
// 计算3D点在第一个图像上的投影误差
//投影到参考帧图像上的点的坐标x,y
float im1x, im1y;
//这个使能空间点的z坐标的倒数
float invZ1 = 1.0/p3dC1.at<float>(2);
//投影到参考帧图像上。因为参考帧下的相机坐标系和世界坐标系重合,因此这里就直接进行投影就可以了
im1x = fx*p3dC1.at<float>(0)*invZ1+cx;
im1y = fy*p3dC1.at<float>(1)*invZ1+cy;
//参考帧上的重投影误差,这个的确就是按照定义来的
float squareError1 = (im1x-kp1.pt.x)*(im1x-kp1.pt.x)+(im1y-kp1.pt.y)*(im1y-kp1.pt.y);
// 重投影误差太大,跳过淘汰
if(squareError1>th2)
continue;
// Check reprojection error in second image
// 计算3D点在第二个图像上的投影误差,计算过程和第一个图像类似
float im2x, im2y;
// 注意这里的p3dC2已经是第二个相机坐标系下的三维点了
float invZ2 = 1.0/p3dC2.at<float>(2);
im2x = fx*p3dC2.at<float>(0)*invZ2+cx;
im2y = fy*p3dC2.at<float>(1)*invZ2+cy;
// 计算重投影误差
float squareError2 = (im2x-kp2.pt.x)*(im2x-kp2.pt.x)+(im2y-kp2.pt.y)*(im2y-kp2.pt.y);
// 重投影误差太大,跳过淘汰
if(squareError2>th2)
continue;
// Step 6 统计经过检验的3D点个数,记录3D点视差角
// 如果运行到这里就说明当前遍历的这个特征点对靠谱,经过了重重检验,说明是一个合格的点,称之为good点
vCosParallax.push_back(cosParallax);
//存储这个三角化测量后的3D点在世界坐标系下的坐标
vP3D[vMatches12[i].first] = cv::Point3f(p3dC1.at<float>(0),p3dC1.at<float>(1),p3dC1.at<float>(2));
//good点计数++
nGood++;
//判断视差角,只有视差角稍稍大一丢丢的才会给打good点标记
//? bug 我觉得这个写的位置不太对。你的good点计数都++了然后才判断,不是会让good点标志和good点计数不一样吗
if(cosParallax<0.99998)
vbGood[vMatches12[i].first]=true;
}
// Step 7 得到3D点中较小的视差角,并且转换成为角度制表示
if(nGood>0)
{
// 从小到大排序,注意vCosParallax值越大,视差越小
sort(vCosParallax.begin(),vCosParallax.end());
// !排序后并没有取最小的视差角,而是取一个较小的视差角
// 作者的做法:如果经过检验过后的有效3D点小于50个,那么就取最后那个最小的视差角(cos值最大)
// 如果大于50个,就取排名第50个的较小的视差角即可,为了避免3D点太多时出现太小的视差角
size_t idx = min(50,int(vCosParallax.size()-1));
//将这个选中的角弧度制转换为角度制
parallax = acos(vCosParallax[idx])*180/CV_PI;
}
else
//如果没有good点那么这个就直接设置为0了
parallax=0;
//返回good点计数
return nGood;
}
SVD求解超定方程
/** 给定投影矩阵P1,P2和图像上的匹配特征点点kp1,kp2,从而计算三维点坐标
* @brief
*
* @param[in] kp1 特征点, in reference frame
* @param[in] kp2 特征点, in current frame
* @param[in] P1 投影矩阵P1
* @param[in] P2 投影矩阵P2
* @param[in & out] x3D 计算的三维点
*/
void Initializer::Triangulate(
const cv::KeyPoint &kp1, //特征点, in reference frame
const cv::KeyPoint &kp2, //特征点, in current frame
const cv::Mat &P1, //投影矩阵P1
const cv::Mat &P2, //投影矩阵P2
cv::Mat &x3D) //三维点
{
// 原理
// Trianularization: 已知匹配特征点对{x x'} 和 各自相机矩阵{P P'}, 估计三维点 X
// x' = P'X x = PX
// 它们都属于 x = aPX模型
// |X|
// |x| |p1 p2 p3 p4 ||Y| |x| |--p0--||.|
// |y| = a |p5 p6 p7 p8 ||Z| ===>|y| = a|--p1--||X|
// |z| |p9 p10 p11 p12||1| |z| |--p2--||.|
// 采用DLT的方法:x叉乘PX = 0
// |yp2 - p1| |0|
// |p0 - xp2| X = |0|
// |xp1 - yp0| |0|
// 两个点:
// |yp2 - p1 | |0|
// |p0 - xp2 | X = |0| ===> AX = 0
// |y'p2' - p1' | |0|
// |p0' - x'p2'| |0|
// 变成程序中的形式:
// |xp2 - p0 | |0|
// |yp2 - p1 | X = |0| ===> AX = 0
// |x'p2'- p0'| |0|
// |y'p2'- p1'| |0|
// 然后就组成了一个四元一次正定方程组,SVD求解,右奇异矩阵的最后一行就是最终的解.
//这个就是上面注释中的矩阵A
cv::Mat A(4,4,CV_32F);
//构造参数矩阵A
A.row(0) = kp1.pt.x*P1.row(2)-P1.row(0);
A.row(1) = kp1.pt.y*P1.row(2)-P1.row(1);
A.row(2) = kp2.pt.x*P2.row(2)-P2.row(0);
A.row(3) = kp2.pt.y*P2.row(2)-P2.row(1);
//奇异值分解的结果
cv::Mat u,w,vt;
//对系数矩阵A进行奇异值分解
cv::SVD::compute(A,w,u,vt,cv::SVD::MODIFY_A| cv::SVD::FULL_UV);
//根据前面的结论,奇异值分解右矩阵的最后一行其实就是解,原理类似于前面的求最小二乘解,四个未知数四个方程正好正定
//别忘了我们更习惯用列向量来表示一个点的空间坐标
x3D = vt.row(3).t();
//为了符合其次坐标的形式,使最后一维为1
x3D = x3D.rowRange(0,3)/x3D.at<float>(3);
}
6.4 对极几何
6.4.1 本质矩阵、基础矩阵和单应矩阵
设点 在相机1、2坐标系下的坐标分别为 、 ,在相机1、2成像平面下的像素坐标分别为 、 ,有:
6.4.2 极线与极点
7. ORB-SLAM2代码详解07_跟踪线程Tracking
7.1 各成员函数/变量
7.1.1 跟踪状态
Tracking
类中定义枚举类型eTrackingState
,用于表示跟踪状态,其可能的取值如下
值 | 意义 |
---|---|
SYSTEM_NOT_READY | 系统没有准备好,一般就是在启动后加载配置文件和词典文件时候的状态 |
NO_IMAGES_YET | 还没有接收到输入图像 |
NOT_INITIALIZED | 接收到图像但未初始化成功 |
OK | 跟踪成功 |
LOST | 跟踪失败 |
Tracking
类的成员变量mState
和mLastProcessedState
分别表示当前帧的跟踪状态和上一帧的跟踪状态.
成员变量 | 访问控制 | 意义 |
---|---|---|
eTrackingState mState | public | 当前帧mCurrentFrame 的跟踪状态 |
eTrackingState mLastProcessedState | public | 前一帧mLastFrame 的跟踪状态 |
7.1.2 初始化
成员函数/变量 | 访问控制 | 意义 |
---|---|---|
Frame mCurrentFrame | public | 当前帧 |
KeyFrame* mpReferenceKF | protected | 参考关键帧 初始化成功的帧会被设为参考关键帧 |
std::vector mvpLocalKeyFrames | protected | 局部关键帧列表,初始化成功后向其中添加局部关键帧 |
std::vector mvpLocalMapPoints | protected | 局部地图点列表,初始化成功后向其中添加局部地图点 |
初始化用于SLAM系统刚开始接收到图像的几帧,初始化成功之后就进入正常的跟踪操作.
Tracking
类主函数Tracking::Track()
检查到当前系统的跟踪状态mState
为NOT_INITIALIZED
时,就会进行初始化.
void Tracking::Track() {
// ...
unique_lock<mutex> lock(mpMap->mMutexMapUpdate);
// step1. 若还没初始化,则尝试初始化
if (mState == NOT_INITIALIZED) {
if (mSensor == System::STEREO || mSensor == System::RGBD)
StereoInitialization();
else
MonocularInitialization();
if (mState != OK)
return;
}
// ...
}
7.2 单目相机初始化: MonocularInitialization()
成员函数/变量 | 访问控制 | 意义 |
---|---|---|
void MonocularInitialization() | protected | 单目相机初始化 |
void CreateInitialMapMonocular() | protected | 单目初始化成功后建立初始局部地图 |
Initializer* mpInitializer | protected | 单目初始化器 |
Frame mInitialFrame | public | 单目初始化参考帧(实际上就是前一帧) |
std::vector mvIniP3D | public | 单目初始化中三角化得到的地图点坐标 |
std::vector mvbPrevMatched | public | 单目初始化参考帧地图点 |
std::vector mvIniMatches | public | 单目初始化中参考帧与当前帧的匹配关系 |
单目相机初始化条件: 连续两帧间成功三角化超过100
个点,则初始化成功.
/*
* @brief 单目的地图初始化
*
* 并行地计算基础矩阵和单应性矩阵,选取其中一个模型,恢复出最开始两帧之间的相对姿态以及点云
* 得到初始两帧的匹配、相对运动、初始MapPoints
*
* Step 1:(未创建)得到用于初始化的第一帧,初始化需要两帧
* Step 2:(已创建)如果当前帧特征点数大于100,则得到用于单目初始化的第二帧
* Step 3:在mInitialFrame与mCurrentFrame中找匹配的特征点对
* Step 4:如果初始化的两帧之间的匹配点太少,重新初始化
* Step 5:通过H模型或F模型进行单目初始化,得到两帧间相对运动、初始MapPoints
* Step 6:删除那些无法进行三角化的匹配点
* Step 7:将三角化得到的3D点包装成MapPoints
*/
void Tracking::MonocularInitialization()
{
// Step 1 如果单目初始器还没有被创建,则创建。后面如果重新初始化时会清掉这个
if(!mpInitializer)
{
// Set Reference Frame
// 单目初始帧的特征点数必须大于100
if(mCurrentFrame.mvKeys.size()>100)
{
// 初始化需要两帧,分别是mInitialFrame,mCurrentFrame
mInitialFrame = Frame(mCurrentFrame);
// 用当前帧更新上一帧
mLastFrame = Frame(mCurrentFrame);
// mvbPrevMatched 记录"上一帧"所有特征点
mvbPrevMatched.resize(mCurrentFrame.mvKeysUn.size());
for(size_t i=0; i<mCurrentFrame.mvKeysUn.size(); i++)
mvbPrevMatched[i]=mCurrentFrame.mvKeysUn[i].pt;
// 删除前判断一下,来避免出现段错误。不过在这里是多余的判断
// 不过在这里是多余的判断,因为前面已经判断过了
if(mpInitializer)
delete mpInitializer;
// 由当前帧构造初始器 sigma:1.0 iterations:200
mpInitializer = new Initializer(mCurrentFrame,1.0,200);
// 初始化为-1 表示没有任何匹配。这里面存储的是匹配的点的id
fill(mvIniMatches.begin(),mvIniMatches.end(),-1);
return;
}
}
else //如果单目初始化器已经被创建
{
// Try to initialize
// Step 2 如果当前帧特征点数太少(不超过100),则重新构造初始器
// NOTICE 只有连续两帧的特征点个数都大于100时,才能继续进行初始化过程
if((int)mCurrentFrame.mvKeys.size()<=100)
{
delete mpInitializer;
mpInitializer = static_cast<Initializer*>(NULL);
fill(mvIniMatches.begin(),mvIniMatches.end(),-1);
return;
}
// Find correspondences
// Step 3 在mInitialFrame与mCurrentFrame中找匹配的特征点对
ORBmatcher matcher(
0.9, //最佳的和次佳特征点评分的比值阈值,这里是比较宽松的,跟踪时一般是0.7
true); //检查特征点的方向
// 对 mInitialFrame,mCurrentFrame 进行特征点匹配
// mvbPrevMatched为参考帧的特征点坐标,初始化存储的是mInitialFrame中特征点坐标,匹配后存储的是匹配好的当前帧的特征点坐标
// mvIniMatches 保存参考帧F1中特征点是否匹配上,index保存是F1对应特征点索引,值保存的是匹配好的F2特征点索引
int nmatches = matcher.SearchForInitialization(
mInitialFrame,mCurrentFrame, //初始化时的参考帧和当前帧
mvbPrevMatched, //在初始化参考帧中提取得到的特征点
mvIniMatches, //保存匹配关系
100); //搜索窗口大小
// Check if there are enough correspondences
// Step 4 验证匹配结果,如果初始化的两帧之间的匹配点太少,重新初始化
if(nmatches<100)
{
delete mpInitializer;
mpInitializer = static_cast<Initializer*>(NULL);
return;
}
cv::Mat Rcw; // Current Camera Rotation
cv::Mat tcw; // Current Camera Translation
vector<bool> vbTriangulated; // Triangulated Correspondences (mvIniMatches)
// Step 5 通过H模型或F模型进行单目初始化,得到两帧间相对运动、初始MapPoints
if(mpInitializer->Initialize(
mCurrentFrame, //当前帧
mvIniMatches, //当前帧和参考帧的特征点的匹配关系
Rcw, tcw, //初始化得到的相机的位姿
mvIniP3D, //进行三角化得到的空间点集合
vbTriangulated)) //以及对应于mvIniMatches来讲,其中哪些点被三角化了
{
// Step 6 初始化成功后,删除那些无法进行三角化的匹配点
for(size_t i=0, iend=mvIniMatches.size(); i<iend;i++)
{
if(mvIniMatches[i]>=0 && !vbTriangulated[i])
{
mvIniMatches[i]=-1;
nmatches--;
}
}
// Set Frame Poses
// Step 7 将初始化的第一帧作为世界坐标系,因此第一帧变换矩阵为单位矩阵
mInitialFrame.SetPose(cv::Mat::eye(4,4,CV_32F));
// 由Rcw和tcw构造Tcw,并赋值给mTcw,mTcw为世界坐标系到相机坐标系的变换矩阵
cv::Mat Tcw = cv::Mat::eye(4,4,CV_32F);
Rcw.copyTo(Tcw.rowRange(0,3).colRange(0,3));
tcw.copyTo(Tcw.rowRange(0,3).col(3));
mCurrentFrame.SetPose(Tcw);
// Step 8 创建初始化地图点MapPoints
// Initialize函数会得到mvIniP3D,
// mvIniP3D是cv::Point3f类型的一个容器,是个存放3D点的临时变量,
// CreateInitialMapMonocular将3D点包装成MapPoint类型存入KeyFrame和Map中
CreateInitialMapMonocular();
}//当初始化成功的时候进行
}//如果单目初始化器已经被创建
}
单目初始化成功后调用函数CreateInitialMapMonocular()
创建初始化地图
void Tracking::CreateInitialMapMonocular()
{
// Create KeyFrames 认为单目初始化时候的参考帧和当前帧都是关键帧
KeyFrame* pKFini = new KeyFrame(mInitialFrame,mpMap,mpKeyFrameDB); // 第一帧
KeyFrame* pKFcur = new KeyFrame(mCurrentFrame,mpMap,mpKeyFrameDB); // 第二帧
// Step 1 将初始关键帧,当前关键帧的描述子转为BoW
pKFini->ComputeBoW();
pKFcur->ComputeBoW();
// Insert KFs in the map
// Step 2 将关键帧插入到地图
mpMap->AddKeyFrame(pKFini);
mpMap->AddKeyFrame(pKFcur);
// Create MapPoints and asscoiate to keyframes
// Step 3 用初始化得到的3D点来生成地图点MapPoints
// mvIniMatches[i] 表示初始化两帧特征点匹配关系。
// 具体解释:i表示帧1中关键点的索引值,vMatches12[i]的值为帧2的关键点索引值,没有匹配关系的话,vMatches12[i]值为 -1
for(size_t i=0; i<mvIniMatches.size();i++)
{
// 没有匹配,跳过
if(mvIniMatches[i]<0)
continue;
//Create MapPoint.
// 用三角化点初始化为空间点的世界坐标
cv::Mat worldPos(mvIniP3D[i]);
// Step 3.1 用3D点构造MapPoint
MapPoint* pMP = new MapPoint(
worldPos,
pKFcur,
mpMap);
// Step 3.2 为该MapPoint添加属性:
// a.观测到该MapPoint的关键帧
// b.该MapPoint的描述子
// c.该MapPoint的平均观测方向和深度范围
// 表示该KeyFrame的2D特征点和对应的3D地图点
pKFini->AddMapPoint(pMP,i);
pKFcur->AddMapPoint(pMP,mvIniMatches[i]);
// a.表示该MapPoint可以被哪个KeyFrame的哪个特征点观测到
pMP->AddObservation(pKFini,i);
pMP->AddObservation(pKFcur,mvIniMatches[i]);
// b.从众多观测到该MapPoint的特征点中挑选最有代表性的描述子
pMP->ComputeDistinctiveDescriptors();
// c.更新该MapPoint平均观测方向以及观测距离的范围
pMP->UpdateNormalAndDepth();
//Fill Current Frame structure
//mvIniMatches下标i表示在初始化参考帧中的特征点的序号
//mvIniMatches[i]是初始化当前帧中的特征点的序号
mCurrentFrame.mvpMapPoints[mvIniMatches[i]] = pMP;
mCurrentFrame.mvbOutlier[mvIniMatches[i]] = false;
//Add to Map
mpMap->AddMapPoint(pMP);
}
// Update Connections
// Step 3.3 更新关键帧间的连接关系
// 在3D点和关键帧之间建立边,每个边有一个权重,边的权重是该关键帧与当前帧公共3D点的个数
pKFini->UpdateConnections();
pKFcur->UpdateConnections();
// Bundle Adjustment
cout << "New Map created with " << mpMap->MapPointsInMap() << " points" << endl;
// Step 4 全局BA优化,同时优化所有位姿和三维点
Optimizer::GlobalBundleAdjustemnt(mpMap,20);
// Set median depth to 1
// Step 5 取场景的中值深度,用于尺度归一化
// 为什么是 pKFini 而不是 pKCur ? 答:都可以的,内部做了位姿变换了
float medianDepth = pKFini->ComputeSceneMedianDepth(2);
float invMedianDepth = 1.0f/medianDepth;
//两个条件,一个是平均深度要大于0,另外一个是在当前帧中被观测到的地图点的数目应该大于100
if(medianDepth<0 || pKFcur->TrackedMapPoints(1)<100)
{
cout << "Wrong initialization, reseting..." << endl;
Reset();
return;
}
// Step 6 将两帧之间的变换归一化到平均深度1的尺度下
// Scale initial baseline
cv::Mat Tc2w = pKFcur->GetPose();
// x/z y/z 将z归一化到1
Tc2w.col(3).rowRange(0,3) = Tc2w.col(3).rowRange(0,3)*invMedianDepth;
pKFcur->SetPose(Tc2w);
// Scale points
// Step 7 把3D点的尺度也归一化到1
// 为什么是pKFini? 是不是就算是使用 pKFcur 得到的结果也是相同的? 答:是的,因为是同样的三维点
vector<MapPoint*> vpAllMapPoints = pKFini->GetMapPointMatches();
for(size_t iMP=0; iMP<vpAllMapPoints.size(); iMP++)
{
if(vpAllMapPoints[iMP])
{
MapPoint* pMP = vpAllMapPoints[iMP];
pMP->SetWorldPos(pMP->GetWorldPos()*invMedianDepth);
}
}
// Step 8 将关键帧插入局部地图,更新归一化后的位姿、局部地图点
mpLocalMapper->InsertKeyFrame(pKFini);
mpLocalMapper->InsertKeyFrame(pKFcur);
mCurrentFrame.SetPose(pKFcur->GetPose());
mnLastKeyFrameId=mCurrentFrame.mnId;
mpLastKeyFrame = pKFcur;
mvpLocalKeyFrames.push_back(pKFcur);
mvpLocalKeyFrames.push_back(pKFini);
// 单目初始化之后,得到的初始地图中的所有点都是局部地图点
mvpLocalMapPoints=mpMap->GetAllMapPoints();
mpReferenceKF = pKFcur;
//也只能这样子设置了,毕竟是最近的关键帧
mCurrentFrame.mpReferenceKF = pKFcur;
mLastFrame = Frame(mCurrentFrame);
mpMap->SetReferenceMapPoints(mvpLocalMapPoints);
mpMapDrawer->SetCurrentCameraPose(pKFcur->GetPose());
mpMap->mvpKeyFrameOrigins.push_back(pKFini);
mState=OK;// 初始化成功,至此,初始化过程完成
}
7.3 双目/RGBD相机初始化: StereoInitialization()
成员函数/变量 | 访问控制 | 意义 |
---|---|---|
void StereoInitialization() | protected | 双目/RGBD相机初始化 |
双目/RGBD相机的要求就宽松多了,只要左目图像能找到多于500
个特征点,就算是初始化成功.
函数StereoInitialization()
内部既完成了初始化,又构建了初始化局部地图.
/*
* @brief 双目和rgbd的地图初始化,比单目简单很多
*
* 由于具有深度信息,直接生成MapPoints
*/
void Tracking::StereoInitialization()
{
// 初始化要求当前帧的特征点超过500
if(mCurrentFrame.N>500)
{
// Set Frame pose to the origin
// 设定初始位姿为单位旋转,0平移
mCurrentFrame.SetPose(cv::Mat::eye(4,4,CV_32F));
// Create KeyFrame
// 将当前帧构造为初始关键帧
// mCurrentFrame的数据类型为Frame
// KeyFrame包含Frame、地图3D点、以及BoW
// KeyFrame里有一个mpMap,Tracking里有一个mpMap,而KeyFrame里的mpMap都指向Tracking里的这个mpMap
// KeyFrame里有一个mpKeyFrameDB,Tracking里有一个mpKeyFrameDB,而KeyFrame里的mpMap都指向Tracking里的这个mpKeyFrameDB
// 提问: 为什么要指向Tracking中的相应的变量呢? -- 因为Tracking是主线程,是它创建和加载的这些模块
KeyFrame* pKFini = new KeyFrame(mCurrentFrame,mpMap,mpKeyFrameDB);
// Insert KeyFrame in the map
// KeyFrame中包含了地图、反过来地图中也包含了KeyFrame,相互包含
// 在地图中添加该初始关键帧
mpMap->AddKeyFrame(pKFini);
// Create MapPoints and asscoiate to KeyFrame
// 为每个特征点构造MapPoint
for(int i=0; i<mCurrentFrame.N;i++)
{
//只有具有正深度的点才会被构造地图点
float z = mCurrentFrame.mvDepth[i];
if(z>0)
{
// 通过反投影得到该特征点的世界坐标系下3D坐标
cv::Mat x3D = mCurrentFrame.UnprojectStereo(i);
// 将3D点构造为MapPoint
MapPoint* pNewMP = new MapPoint(x3D,pKFini,mpMap);
// 为该MapPoint添加属性:
// a.观测到该MapPoint的关键帧
// b.该MapPoint的描述子
// c.该MapPoint的平均观测方向和深度范围
// a.表示该MapPoint可以被哪个KeyFrame的哪个特征点观测到
pNewMP->AddObservation(pKFini,i);
// b.从众多观测到该MapPoint的特征点中挑选区分度最高的描述子
pNewMP->ComputeDistinctiveDescriptors();
// c.更新该MapPoint平均观测方向以及观测距离的范围
pNewMP->UpdateNormalAndDepth();
// 在地图中添加该MapPoint
mpMap->AddMapPoint(pNewMP);
// 表示该KeyFrame的哪个特征点可以观测到哪个3D点
pKFini->AddMapPoint(pNewMP,i);
// 将该MapPoint添加到当前帧的mvpMapPoints中
// 为当前Frame的特征点与MapPoint之间建立索引
mCurrentFrame.mvpMapPoints[i]=pNewMP;
}
}
cout << "New map created with " << mpMap->MapPointsInMap() << " points" << endl;
// 在局部地图中添加该初始关键帧
mpLocalMapper->InsertKeyFrame(pKFini);
// 更新当前帧为上一帧
mLastFrame = Frame(mCurrentFrame);
mnLastKeyFrameId=mCurrentFrame.mnId;
mpLastKeyFrame = pKFini;
mvpLocalKeyFrames.push_back(pKFini);
//? 这个局部地图点竟然..不在mpLocalMapper中管理?
// 我现在的想法是,这个点只是暂时被保存在了 Tracking 线程之中, 所以称之为 local
// 初始化之后,通过双目图像生成的地图点,都应该被认为是局部地图点
mvpLocalMapPoints=mpMap->GetAllMapPoints();
mpReferenceKF = pKFini;
mCurrentFrame.mpReferenceKF = pKFini;
// 把当前(最新的)局部MapPoints作为ReferenceMapPoints
// ReferenceMapPoints是DrawMapPoints函数画图的时候用的
mpMap->SetReferenceMapPoints(mvpLocalMapPoints);
mpMap->mvpKeyFrameOrigins.push_back(pKFini);
mpMapDrawer->SetCurrentCameraPose(mCurrentFrame.mTcw);
//追踪成功
mState=OK;
}
}
7.4 初始位姿估计
当Tracking线程接收到一帧图像后,会先估计其初始位姿,再根据估计出的初始位姿跟踪局部地图并进一步优化位姿.
初始位姿估计有三种手段:
根据恒速运动模型估计位姿TrackWithMotionModel()
根据参考帧估计位姿TrackReferenceKeyFrame()
通过重定位估计位姿Relocalization()
void Tracking::Track() {
// ...
unique_lock<mutex> lock(mpMap->mMutexMapUpdate);
// step1. 若还没初始化,则尝试初始化
if (mState == NOT_INITIALIZED) {
// 初始化
} else {
// step2. 若系统已初始化,就进行跟踪(或重定位)
bool bOK;
// step2.1. 符合条件时,优先根据运动模型跟踪,如运动模型跟踪失败,就根据参考帧进行跟踪
if (mState == OK) {
if (mVelocity.empty() || mCurrentFrame.mnId < mnLastRelocFrameId + 2) { // 判断当前关键帧是否具有较稳定的速度
bOK = TrackReferenceKeyFrame();
} else {
bOK = TrackWithMotionModel();
if (!bOK)
bOK = TrackReferenceKeyFrame();
}
} else {
// step2.2. 若上一帧没跟踪丢失,则这一帧重定位
bOK = Relocalization();
}
// ...
if (bOK)
mState = OK;
else
mState = LOST;
}
// ...
}
7.5 根据恒速运动模型估计初始位姿: TrackWithMotionModel()
恒速运动模型假定连续几帧间的运动速度是恒定的;基于此假设,根据运动速度mVelocity和上一帧的位姿mLastFrame.mTcw计算出本帧位姿的估计值,再进行位姿优化.
成员变量mVelocity保存前一帧的速度,主函数Tracking::Track()中调用完函数Tracking::TrackLocalMap()更新局部地图和当前帧位姿后,就计算速度并赋值给mVelocity.
成员函数/变量 | 访问控制 | 意义 |
---|---|---|
TrackWithMotionModel() | protected | 根据恒速运动模型估计初始位姿 |
Frame mLastFrame | protected | 前一帧,TrackWithMotionModel() 与该帧匹配搜索关键点 |
cv::Mat mVelocity | protected | 相机前一帧运动速度,跟踪完局部地图后更新该成员变量 |
list mlpTemporalPoints | protected | 双目/RGBD相机输入时,为前一帧生成的临时地图点 跟踪成功后该容器会被清空,其中的地图点会被删除 |
/**
* @brief 根据恒定速度模型用上一帧地图点来对当前帧进行跟踪
* Step 1:更新上一帧的位姿;对于双目或RGB-D相机,还会根据深度值生成临时地图点
* Step 2:根据上一帧特征点对应地图点进行投影匹配
* Step 3:优化当前帧位姿
* Step 4:剔除地图点中外点
* @return 如果匹配数大于10,认为跟踪成功,返回true
*/
bool Tracking::TrackWithMotionModel()
{
// 最小距离 < 0.9*次小距离 匹配成功,检查旋转
ORBmatcher matcher(0.9,true);
// Update last frame pose according to its reference keyframe
// Create "visual odometry" points
// Step 1:更新上一帧的位姿;对于双目或RGB-D相机,还会根据深度值生成临时地图点
UpdateLastFrame();
// Step 2:根据之前估计的速度,用恒速模型得到当前帧的初始位姿。
mCurrentFrame.SetPose(mVelocity*mLastFrame.mTcw);
// 清空当前帧的地图点
fill(mCurrentFrame.mvpMapPoints.begin(),mCurrentFrame.mvpMapPoints.end(),static_cast<MapPoint*>(NULL));
// Project points seen in previous frame
// 设置特征匹配过程中的搜索半径
int th;
if(mSensor!=System::STEREO)
th=15;//单目
else
th=7;//双目
// Step 3:用上一帧地图点进行投影匹配,如果匹配点不够,则扩大搜索半径再来一次
int nmatches = matcher.SearchByProjection(mCurrentFrame,mLastFrame,th,mSensor==System::MONOCULAR);
// If few matches, uses a wider window search
// 如果匹配点太少,则扩大搜索半径再来一次
if(nmatches<20)
{
fill(mCurrentFrame.mvpMapPoints.begin(),mCurrentFrame.mvpMapPoints.end(),static_cast<MapPoint*>(NULL));
nmatches = matcher.SearchByProjection(mCurrentFrame,mLastFrame,2*th,mSensor==System::MONOCULAR); // 2*th
}
// 如果还是不能够获得足够的匹配点,那么就认为跟踪失败
if(nmatches<20)
return false;
// Optimize frame pose with all matches
// Step 4:利用3D-2D投影关系,优化当前帧位姿
Optimizer::PoseOptimization(&mCurrentFrame);
// Discard outliers
// Step 5:剔除地图点中外点
int nmatchesMap = 0;
for(int i =0; i<mCurrentFrame.N; i++)
{
if(mCurrentFrame.mvpMapPoints[i])
{
if(mCurrentFrame.mvbOutlier[i])
{
// 如果优化后判断某个地图点是外点,清除它的所有关系
MapPoint* pMP = mCurrentFrame.mvpMapPoints[i];
mCurrentFrame.mvpMapPoints[i]=static_cast<MapPoint*>(NULL);
mCurrentFrame.mvbOutlier[i]=false;
pMP->mbTrackInView = false;
pMP->mnLastFrameSeen = mCurrentFrame.mnId;
nmatches--;
}
else if(mCurrentFrame.mvpMapPoints[i]->Observations()>0)
// 累加成功匹配到的地图点数目
nmatchesMap++;
}
}
if(mbOnlyTracking)
{
// 纯定位模式下:如果成功追踪的地图点非常少,那么这里的mbVO标志就会置位
mbVO = nmatchesMap<10;
return nmatches>20;
}
// Step 6:匹配超过10个点就认为跟踪成功
return nmatchesMap>=10;
}
为保证位姿估计的准确性,对于双目/RGBD相机,为前一帧生成临时地图点.
/**
* @brief 更新上一帧位姿,在上一帧中生成临时地图点
* 单目情况:只计算了上一帧的世界坐标系位姿
* 双目和rgbd情况:选取有有深度值的并且没有被选为地图点的点生成新的临时地图点,提高跟踪鲁棒性
*/
void Tracking::UpdateLastFrame()
{
// Update pose according to reference keyframe
// Step 1:利用参考关键帧更新上一帧在世界坐标系下的位姿
// 上一普通帧的参考关键帧,注意这里用的是参考关键帧(位姿准)而不是上上一帧的普通帧
KeyFrame* pRef = mLastFrame.mpReferenceKF;
// ref_keyframe 到 lastframe的位姿变换
cv::Mat Tlr = mlRelativeFramePoses.back();
// 将上一帧的世界坐标系下的位姿计算出来
// l:last, r:reference, w:world
// Tlw = Tlr*Trw
mLastFrame.SetPose(Tlr*pRef->GetPose());
// 如果上一帧为关键帧,或者单目的情况,则退出
if(mnLastKeyFrameId==mLastFrame.mnId || mSensor==System::MONOCULAR)
return;
// Step 2:对于双目或rgbd相机,为上一帧生成新的临时地图点
// 注意这些地图点只是用来跟踪,不加入到地图中,跟踪完后会删除
// Create "visual odometry" MapPoints
// We sort points according to their measured depth by the stereo/RGB-D sensor
// Step 2.1:得到上一帧中具有有效深度值的特征点(不一定是地图点)
vector<pair<float,int> > vDepthIdx;
vDepthIdx.reserve(mLastFrame.N);
for(int i=0; i<mLastFrame.N;i++)
{
float z = mLastFrame.mvDepth[i];
if(z>0)
{
// vDepthIdx第一个元素是某个点的深度,第二个元素是对应的特征点id
vDepthIdx.push_back(make_pair(z,i));
}
}
// 如果上一帧中没有有效深度的点,那么就直接退出
if(vDepthIdx.empty())
return;
// 按照深度从小到大排序
sort(vDepthIdx.begin(),vDepthIdx.end());
// We insert all close points (depth<mThDepth)
// If less than 100 close points, we insert the 100 closest ones.
// Step 2.2:从中找出不是地图点的部分
int nPoints = 0;
for(size_t j=0; j<vDepthIdx.size();j++)
{
int i = vDepthIdx[j].second;
bool bCreateNew = false;
// 如果这个点对应在上一帧中的地图点没有,或者创建后就没有被观测到,那么就生成一个临时的地图点
MapPoint* pMP = mLastFrame.mvpMapPoints[i];
if(!pMP)
bCreateNew = true;
else if(pMP->Observations()<1)
{
// 地图点被创建后就没有被观测,认为不靠谱,也需要重新创建
bCreateNew = true;
}
if(bCreateNew)
{
// Step 2.3:需要创建的点,包装为地图点。只是为了提高双目和RGBD的跟踪成功率,并没有添加复杂属性,因为后面会扔掉
// 反投影到世界坐标系中
cv::Mat x3D = mLastFrame.UnprojectStereo(i);
MapPoint* pNewMP = new MapPoint(
x3D, // 世界坐标系坐标
mpMap, // 跟踪的全局地图
&mLastFrame, // 存在这个特征点的帧(上一帧)
i); // 特征点id
// 加入上一帧的地图点中
mLastFrame.mvpMapPoints[i]=pNewMP;
// 标记为临时添加的MapPoint,之后在CreateNewKeyFrame之前会全部删除
mlpTemporalPoints.push_back(pNewMP);
nPoints++;
}
else
{
// 因为从近到远排序,记录其中不需要创建地图点的个数
nPoints++;
}
// Step 2.4:如果地图点质量不好,停止创建地图点
// 停止新增临时地图点必须同时满足以下条件:
// 1、当前的点的深度已经超过了设定的深度阈值(35倍基线)
// 2、nPoints已经超过100个点,说明距离比较远了,可能不准确,停掉退出
if(vDepthIdx[j].first>mThDepth && nPoints>100)
break;
}
}
7.6 根据参考帧估计位姿: TrackReferenceKeyFrame()
成员变量mpReferenceKF保存Tracking线程当前的参考关键帧,参考关键帧有两个来源:
每当Tracking线程创建一个新的参考关键帧时,就将其设为参考关键帧.
跟踪局部地图的函数Tracking::TrackLocalMap()内部会将与当前帧共视点最多的局部关键帧设为参考关键帧.
成员函数/变量 | 访问控制 | 意义 |
---|---|---|
TrackReferenceKeyFrame() | protected | 根据参考帧估计位姿 |
KeyFrame* mpReferenceKF | protected | 参考关键帧,TrackReferenceKeyFrame() 与该关键帧匹配搜索关键点 |
/*
* @brief 用参考关键帧的地图点来对当前普通帧进行跟踪
*
* Step 1:将当前普通帧的描述子转化为BoW向量
* Step 2:通过词袋BoW加速当前帧与参考帧之间的特征点匹配
* Step 3: 将上一帧的位姿态作为当前帧位姿的初始值
* Step 4: 通过优化3D-2D的重投影误差来获得位姿
* Step 5:剔除优化后的匹配点中的外点
* @return 如果匹配数超10,返回true
*
*/
bool Tracking::TrackReferenceKeyFrame()
{
// Compute Bag of Words vector
// Step 1:将当前帧的描述子转化为BoW向量
mCurrentFrame.ComputeBoW();
// We perform first an ORB matching with the reference keyframe
// If enough matches are found we setup a PnP solver
ORBmatcher matcher(0.7,true);
vector<MapPoint*> vpMapPointMatches;
// Step 2:通过词袋BoW加速当前帧与参考帧之间的特征点匹配
int nmatches = matcher.SearchByBoW(
mpReferenceKF, //参考关键帧
mCurrentFrame, //当前帧
vpMapPointMatches); //存储匹配关系
// 匹配数目小于15,认为跟踪失败
if(nmatches<15)
return false;
// Step 3:将上一帧的位姿态作为当前帧位姿的初始值
mCurrentFrame.mvpMapPoints = vpMapPointMatches;
mCurrentFrame.SetPose(mLastFrame.mTcw); // 用上一次的Tcw设置初值,在PoseOptimization可以收敛快一些
// Step 4:通过优化3D-2D的重投影误差来获得位姿
Optimizer::PoseOptimization(&mCurrentFrame);
// Discard outliers
// Step 5:剔除优化后的匹配点中的外点
//之所以在优化之后才剔除外点,是因为在优化的过程中就有了对这些外点的标记
int nmatchesMap = 0;
for(int i =0; i<mCurrentFrame.N; i++)
{
if(mCurrentFrame.mvpMapPoints[i])
{
//如果对应到的某个特征点是外点
if(mCurrentFrame.mvbOutlier[i])
{
//清除它在当前帧中存在过的痕迹
MapPoint* pMP = mCurrentFrame.mvpMapPoints[i];
mCurrentFrame.mvpMapPoints[i]=static_cast<MapPoint*>(NULL);
mCurrentFrame.mvbOutlier[i]=false;
pMP->mbTrackInView = false;
pMP->mnLastFrameSeen = mCurrentFrame.mnId;
nmatches--;
}
else if(mCurrentFrame.mvpMapPoints[i]->Observations()>0)
//匹配的内点计数++
nmatchesMap++;
}
}
// 跟踪成功的数目超过10才认为跟踪成功,否则跟踪失败
return nmatchesMap>=10;
}
思考: 为什么函数Tracking::TrackReferenceKeyFrame()没有检查当前参考帧mpReferenceKF是否被LocalMapping线程删除了?
回答: 因为LocalMapping线程剔除冗余关键帧函数LocalMapping::KeyFrameCulling()不会删除最新的参考帧,有可能被删除的都是之前的参考帧.
7.7 通过重定位估计位姿: Relocalization()
当TrackWithMotionModel()
和TrackReferenceKeyFrame()
都失败后,就会调用函数Relocalization()
通过重定位估计位姿.
/**
* @details 重定位过程
* @return true
* @return false
*
* Step 1:计算当前帧特征点的词袋向量
* Step 2:找到与当前帧相似的候选关键帧
* Step 3:通过BoW进行匹配
* Step 4:通过EPnP算法估计姿态
* Step 5:通过PoseOptimization对姿态进行优化求解
* Step 6:如果内点较少,则通过投影的方式对之前未匹配的点进行匹配,再进行优化求解
*/
bool Tracking::Relocalization()
{
// Compute Bag of Words Vector
// Step 1:计算当前帧特征点的词袋向量
mCurrentFrame.ComputeBoW();
// Relocalization is performed when tracking is lost
// Track Lost: Query KeyFrame Database for keyframe candidates for relocalisation
// Step 2:用词袋找到与当前帧相似的候选关键帧
vector<KeyFrame*> vpCandidateKFs = mpKeyFrameDB->DetectRelocalizationCandidates(&mCurrentFrame);
// 如果没有候选关键帧,则退出
if(vpCandidateKFs.empty())
return false;
const int nKFs = vpCandidateKFs.size();
// We perform first an ORB matching with each candidate
// If enough matches are found we setup a PnP solver
ORBmatcher matcher(0.75,true);
//每个关键帧的解算器
vector<PnPsolver*> vpPnPsolvers;
vpPnPsolvers.resize(nKFs);
//每个关键帧和当前帧中特征点的匹配关系
vector<vector<MapPoint*> > vvpMapPointMatches;
vvpMapPointMatches.resize(nKFs);
//放弃某个关键帧的标记
vector<bool> vbDiscarded;
vbDiscarded.resize(nKFs);
//有效的候选关键帧数目
int nCandidates=0;
// Step 3:遍历所有的候选关键帧,通过词袋进行快速匹配,用匹配结果初始化PnP Solver
for(int i=0; i<nKFs; i++)
{
KeyFrame* pKF = vpCandidateKFs[i];
if(pKF->isBad())
vbDiscarded[i] = true;
else
{
// 当前帧和候选关键帧用BoW进行快速匹配,匹配结果记录在vvpMapPointMatches,nmatches表示匹配的数目
int nmatches = matcher.SearchByBoW(pKF,mCurrentFrame,vvpMapPointMatches[i]);
// 如果和当前帧的匹配数小于15,那么只能放弃这个关键帧
if(nmatches<15)
{
vbDiscarded[i] = true;
continue;
}
else
{
// 如果匹配数目够用,用匹配结果初始化EPnPsolver
// 为什么用EPnP? 因为计算复杂度低,精度高
PnPsolver* pSolver = new PnPsolver(mCurrentFrame,vvpMapPointMatches[i]);
pSolver->SetRansacParameters(
0.99, //用于计算RANSAC迭代次数理论值的概率
10, //最小内点数, 但是要注意在程序中实际上是min(给定最小内点数,最小集,内点数理论值),不一定使用这个
300, //最大迭代次数
4, //最小集(求解这个问题在一次采样中所需要采样的最少的点的个数,对于Sim3是3,EPnP是4),参与到最小内点数的确定过程中
0.5, //这个是表示(最小内点数/样本总数);实际上的RANSAC正常退出的时候所需要的最小内点数其实是根据这个量来计算得到的
5.991); // 自由度为2的卡方检验的阈值,程序中还会根据特征点所在的图层对这个阈值进行缩放
vpPnPsolvers[i] = pSolver;
nCandidates++;
}
}
}
// Alternatively perform some iterations of P4P RANSAC
// Until we found a camera pose supported by enough inliers
// 这里的 P4P RANSAC是Epnp,每次迭代需要4个点
// 是否已经找到相匹配的关键帧的标志
bool bMatch = false;
ORBmatcher matcher2(0.9,true);
// Step 4: 通过一系列操作,直到找到能够匹配上的关键帧
// 为什么搞这么复杂?答:是担心误闭环
while(nCandidates>0 && !bMatch)
{
//遍历当前所有的候选关键帧
for(int i=0; i<nKFs; i++)
{
// 忽略放弃的
if(vbDiscarded[i])
continue;
//内点标记
vector<bool> vbInliers;
//内点数
int nInliers;
// 表示RANSAC已经没有更多的迭代次数可用 -- 也就是说数据不够好,RANSAC也已经尽力了。。。
bool bNoMore;
// Step 4.1:通过EPnP算法估计姿态,迭代5次
PnPsolver* pSolver = vpPnPsolvers[i];
cv::Mat Tcw = pSolver->iterate(5,bNoMore,vbInliers,nInliers);
// If Ransac reachs max. iterations discard keyframe
// bNoMore 为true 表示已经超过了RANSAC最大迭代次数,就放弃当前关键帧
if(bNoMore)
{
vbDiscarded[i]=true;
nCandidates--;
}
// If a Camera Pose is computed, optimize
if(!Tcw.empty())
{
// Step 4.2:如果EPnP 计算出了位姿,对内点进行BA优化
Tcw.copyTo(mCurrentFrame.mTcw);
// EPnP 里RANSAC后的内点的集合
set<MapPoint*> sFound;
const int np = vbInliers.size();
//遍历所有内点
for(int j=0; j<np; j++)
{
if(vbInliers[j])
{
mCurrentFrame.mvpMapPoints[j]=vvpMapPointMatches[i][j];
sFound.insert(vvpMapPointMatches[i][j]);
}
else
mCurrentFrame.mvpMapPoints[j]=NULL;
}
// 只优化位姿,不优化地图点的坐标,返回的是内点的数量
int nGood = Optimizer::PoseOptimization(&mCurrentFrame);
// 如果优化之后的内点数目不多,跳过了当前候选关键帧,但是却没有放弃当前帧的重定位
if(nGood<10)
continue;
// 删除外点对应的地图点
for(int io =0; io<mCurrentFrame.N; io++)
if(mCurrentFrame.mvbOutlier[io])
mCurrentFrame.mvpMapPoints[io]=static_cast<MapPoint*>(NULL);
// If few inliers, search by projection in a coarse window and optimize again
// Step 4.3:如果内点较少,则通过投影的方式对之前未匹配的点进行匹配,再进行优化求解
// 前面的匹配关系是用词袋匹配过程得到的
if(nGood<50)
{
// 通过投影的方式将关键帧中未匹配的地图点投影到当前帧中, 生成新的匹配
int nadditional = matcher2.SearchByProjection(
mCurrentFrame, //当前帧
vpCandidateKFs[i], //关键帧
sFound, //已经找到的地图点集合,不会用于PNP
10, //窗口阈值,会乘以金字塔尺度
100); //匹配的ORB描述子距离应该小于这个阈值
// 如果通过投影过程新增了比较多的匹配特征点对
if(nadditional+nGood>=50)
{
// 根据投影匹配的结果,再次采用3D-2D pnp BA优化位姿
nGood = Optimizer::PoseOptimization(&mCurrentFrame);
// If many inliers but still not enough, search by projection again in a narrower window
// the camera has been already optimized with many points
// Step 4.4:如果BA后内点数还是比较少(<50)但是还不至于太少(>30),可以挽救一下, 最后垂死挣扎
// 重新执行上一步 4.3的过程,只不过使用更小的搜索窗口
// 这里的位姿已经使用了更多的点进行了优化,应该更准,所以使用更小的窗口搜索
if(nGood>30 && nGood<50)
{
// 用更小窗口、更严格的描述子阈值,重新进行投影搜索匹配
sFound.clear();
for(int ip =0; ip<mCurrentFrame.N; ip++)
if(mCurrentFrame.mvpMapPoints[ip])
sFound.insert(mCurrentFrame.mvpMapPoints[ip]);
nadditional =matcher2.SearchByProjection(
mCurrentFrame, //当前帧
vpCandidateKFs[i], //候选的关键帧
sFound, //已经找到的地图点,不会用于PNP
3, //新的窗口阈值,会乘以金字塔尺度
64); //匹配的ORB描述子距离应该小于这个阈值
// Final optimization
// 如果成功挽救回来,匹配数目达到要求,最后BA优化一下
if(nGood+nadditional>=50)
{
nGood = Optimizer::PoseOptimization(&mCurrentFrame);
//更新地图点
for(int io =0; io<mCurrentFrame.N; io++)
if(mCurrentFrame.mvbOutlier[io])
mCurrentFrame.mvpMapPoints[io]=NULL;
}
//如果还是不能够满足就放弃了
}
}
}
// If the pose is supported by enough inliers stop ransacs and continue
// 如果对于当前的候选关键帧已经有足够的内点(50个)了,那么就认为重定位成功
if(nGood>=50)
{
bMatch = true;
// 只要有一个候选关键帧重定位成功,就退出循环,不考虑其他候选关键帧了
break;
}
}
}//一直运行,知道已经没有足够的关键帧,或者是已经有成功匹配上的关键帧
}
// 折腾了这么久还是没有匹配上,重定位失败
if(!bMatch)
{
return false;
}
else
{
// 如果匹配上了,说明当前帧重定位成功了(当前帧已经有了自己的位姿)
// 记录成功重定位帧的id,防止短时间多次重定位
mnLastRelocFrameId = mCurrentFrame.mnId;
return true;
}
}
7.8 跟踪局部地图: TrackLocalMap()
成员函数/变量 | 访问控制 | 意义 |
---|---|---|
bool TrackLocalMap() | protected | 更新局部地图并优化当前帧位姿 |
void UpdateLocalMap() | protected | 更新局部地图 |
std::vector mvpLocalKeyFrames | protected | 局部关键帧列表 |
std::vector mvpLocalMapPoints | protected | 局部地图点列表 |
void SearchLocalPoints() | protected | 将局部地图点投影到当前帧特征点上 |
成功估计当前帧的初始位姿后,基于当前位姿更新局部地图并优化当前帧位姿,主要流程:
更新局部地图,包括局部关键帧列表mvpLocalKeyFrames和局部地图点列表mvpLocalMapPoints.
将局部地图点投影到当前帧特征点上.
进行位姿BA,优化当前帧位姿.
更新地图点观测数值,统计内点个数.
这里的地图点观测数值会被用作LocalMapping线程中LocalMapping::MapPointCulling()函数剔除坏点的标准之一.
根据内点数判断是否跟踪成功.
跟踪局部地图,优化当前帧位姿
TrackLocalMap()更新局部地图
UpdateLocalMap()将局部地图点投影到当前帧特征点上
SearchLocalPoints()对当前帧位姿进行BA优化更新地图点观测根据内点数判断是否跟踪成功更新局部关键帧
UpdateLocalKeyFrames()更新局部地图点
UpdateLocalPoints()
/**
* @brief 用局部地图进行跟踪,进一步优化位姿
*
* 1. 更新局部地图,包括局部关键帧和关键点
* 2. 对局部MapPoints进行投影匹配
* 3. 根据匹配对估计当前帧的姿态
* 4. 根据姿态剔除误匹配
* @return true if success
*
* Step 1:更新局部关键帧mvpLocalKeyFrames和局部地图点mvpLocalMapPoints
* Step 2:在局部地图中查找与当前帧匹配的MapPoints, 其实也就是对局部地图点进行跟踪
* Step 3:更新局部所有MapPoints后对位姿再次优化
* Step 4:更新当前帧的MapPoints被观测程度,并统计跟踪局部地图的效果
* Step 5:决定是否跟踪成功
*/
bool Tracking::TrackLocalMap()
{
// We have an estimation of the camera pose and some map points tracked in the frame.
// We retrieve the local map and try to find matches to points in the local map.
// Update Local KeyFrames and Local Points
// Step 1:更新局部关键帧 mvpLocalKeyFrames 和局部地图点 mvpLocalMapPoints
UpdateLocalMap();
// Step 2:筛选局部地图中新增的在视野范围内的地图点,投影到当前帧搜索匹配,得到更多的匹配关系
SearchLocalPoints();
// Optimize Pose
// 在这个函数之前,在 Relocalization、TrackReferenceKeyFrame、TrackWithMotionModel 中都有位姿优化,
// Step 3:前面新增了更多的匹配关系,BA优化得到更准确的位姿
Optimizer::PoseOptimization(&mCurrentFrame);
mnMatchesInliers = 0;
// Update MapPoints Statistics
// Step 4:更新当前帧的地图点被观测程度,并统计跟踪局部地图后匹配数目
for(int i=0; i<mCurrentFrame.N; i++)
{
if(mCurrentFrame.mvpMapPoints[i])
{
// 由于当前帧的地图点可以被当前帧观测到,其被观测统计量加1
if(!mCurrentFrame.mvbOutlier[i])
{
// 找到该点的帧数mnFound 加 1
mCurrentFrame.mvpMapPoints[i]->IncreaseFound();
//查看当前是否是在纯定位过程
if(!mbOnlyTracking)
{
// 如果该地图点被相机观测数目nObs大于0,匹配内点计数+1
// nObs: 被观测到的相机数目,单目+1,双目或RGB-D则+2
if(mCurrentFrame.mvpMapPoints[i]->Observations()>0)
mnMatchesInliers++;
}
else
// 记录当前帧跟踪到的地图点数目,用于统计跟踪效果
mnMatchesInliers++;
}
// 如果这个地图点是外点,并且当前相机输入还是双目的时候,就删除这个点
// ?单目就不管吗
else if(mSensor==System::STEREO)
mCurrentFrame.mvpMapPoints[i] = static_cast<MapPoint*>(NULL);
}
}
// Decide if the tracking was succesful
// More restrictive if there was a relocalization recently
// Step 5:根据跟踪匹配数目及重定位情况决定是否跟踪成功
// 如果最近刚刚发生了重定位,那么至少成功匹配50个点才认为是成功跟踪
if(mCurrentFrame.mnId<mnLastRelocFrameId+mMaxFrames && mnMatchesInliers<50)
return false;
//如果是正常的状态话只要跟踪的地图点大于30个就认为成功了
if(mnMatchesInliers<30)
return false;
else
return true;
}
函数Tracking::UpdateLocalMap()依次调用函数Tracking::UpdateLocalKeyFrames()更新局部关键帧列表mvpLocalKeyFrames和函数Tracking::UpdateLocalPoints()更新局部地图点列表mvpLocalMapPoints.
/**
* @brief 更新LocalMap
*
* 局部地图包括:
* 1、K1个关键帧、K2个临近关键帧和参考关键帧
* 2、由这些关键帧观测到的MapPoints
*/
void Tracking::UpdateLocalMap()
{
// This is for visualization
// 设置参考地图点用于绘图显示局部地图点(红色)
mpMap->SetReferenceMapPoints(mvpLocalMapPoints);
// Update
// 用共视图来更新局部关键帧和局部地图点
UpdateLocalKeyFrames();
UpdateLocalPoints();
}
函数Tracking::UpdateLocalKeyFrames()内,局部关键帧列表mvpLocalKeyFrames会被清空并重新赋值,包括以下3部分:
当前地图点的所有共视关键帧.
1中所有关键帧的父子关键帧.
1中所有关键帧共视关系前10大的共视关键帧.
更新完局部关键帧列表mvpLocalKeyFrames后,还将与当前帧共视关系最强的关键帧设为参考关键帧mpReferenceKF.
函数Tracking::UpdateLocalPoints()内,局部地图点列表mvpLocalMapPoints会被清空并赋值为局部关键帧列表mvpLocalKeyFrames的所有地图点.
/**
* @brief 用局部地图点进行投影匹配,得到更多的匹配关系
* 注意:局部地图点中已经是当前帧地图点的不需要再投影,只需要将此外的并且在视野范围内的点和当前帧进行投影匹配
*/
void Tracking::SearchLocalPoints()
{
// Do not search map points already matched
// Step 1:遍历当前帧的地图点,标记这些地图点不参与之后的投影搜索匹配
for(vector<MapPoint*>::iterator vit=mCurrentFrame.mvpMapPoints.begin(), vend=mCurrentFrame.mvpMapPoints.end(); vit!=vend; vit++)
{
MapPoint* pMP = *vit;
if(pMP)
{
if(pMP->isBad())
{
*vit = static_cast<MapPoint*>(NULL);
}
else
{
// 更新能观测到该点的帧数加1(被当前帧观测了)
pMP->IncreaseVisible();
// 标记该点被当前帧观测到
pMP->mnLastFrameSeen = mCurrentFrame.mnId;
// 标记该点在后面搜索匹配时不被投影,因为已经有匹配了
pMP->mbTrackInView = false;
}
}
}
// 准备进行投影匹配的点的数目
int nToMatch=0;
// Project points in frame and check its visibility
// Step 2:判断所有局部地图点中除当前帧地图点外的点,是否在当前帧视野范围内
for(vector<MapPoint*>::iterator vit=mvpLocalMapPoints.begin(), vend=mvpLocalMapPoints.end(); vit!=vend; vit++)
{
MapPoint* pMP = *vit;
// 已经被当前帧观测到的地图点肯定在视野范围内,跳过
if(pMP->mnLastFrameSeen == mCurrentFrame.mnId)
continue;
// 跳过坏点
if(pMP->isBad())
continue;
// Project (this fills MapPoint variables for matching)
// 判断地图点是否在在当前帧视野内
if(mCurrentFrame.isInFrustum(pMP,0.5))
{
// 观测到该点的帧数加1
pMP->IncreaseVisible();
// 只有在视野范围内的地图点才参与之后的投影匹配
nToMatch++;
}
}
// Step 3:如果需要进行投影匹配的点的数目大于0,就进行投影匹配,增加更多的匹配关系
if(nToMatch>0)
{
ORBmatcher matcher(0.8);
int th = 1;
if(mSensor==System::RGBD) //RGBD相机输入的时候,搜索的阈值会变得稍微大一些
th=3;
// If the camera has been relocalised recently, perform a coarser search
// 如果不久前进行过重定位,那么进行一个更加宽泛的搜索,阈值需要增大
if(mCurrentFrame.mnId<mnLastRelocFrameId+2)
th=5;
// 投影匹配得到更多的匹配关系
matcher.SearchByProjection(mCurrentFrame,mvpLocalMapPoints,th);
}
}
7.9 关键帧的创建
7.9.1 判断是否需要创建新关键帧: NeedNewKeyFrame()
是否生成关键帧,需要考虑以下几个方面:
最近是否进行过重定位,重定位后位姿不会太准,不适合做参考帧.
当前系统的工作状态: 如果LocalMapping线程还有很多KeyFrame没处理的话,不适合再给它增加负担了.
距离上次创建关键帧经过的时间: 如果很长时间没创建关键帧了的话,就要抓紧创建关键帧了.
当前帧的质量: 当前帧观测到的地图点要足够多,同时与参考关键帧的重合程度不能太大.
总体而言,ORB-SLAM2插入关键帧的策略还是比较宽松的,因为后面LocalMapping线程的函数LocalMapping::KeyFrameCulling()会剔除冗余关键帧,因此在系统处理得过来的情况下,要尽量多创建关键帧.
7.9.2 创建新关键帧: CreateNewKeyFrame()
创建新关键帧时,对于双目/RGBD相机输入情况下也创建新地图点.
/**
* @brief 创建新的关键帧
* 对于非单目的情况,同时创建新的MapPoints
*
* Step 1:将当前帧构造成关键帧
* Step 2:将当前关键帧设置为当前帧的参考关键帧
* Step 3:对于双目或rgbd摄像头,为当前帧生成新的MapPoints
*/
void Tracking::CreateNewKeyFrame()
{
// 如果局部建图线程关闭了,就无法插入关键帧
if(!mpLocalMapper->SetNotStop(true))
return;
// Step 1:将当前帧构造成关键帧
KeyFrame* pKF = new KeyFrame(mCurrentFrame,mpMap,mpKeyFrameDB);
// Step 2:将当前关键帧设置为当前帧的参考关键帧
// 在UpdateLocalKeyFrames函数中会将与当前关键帧共视程度最高的关键帧设定为当前帧的参考关键帧
mpReferenceKF = pKF;
mCurrentFrame.mpReferenceKF = pKF;
// 这段代码和 Tracking::UpdateLastFrame 中的那一部分代码功能相同
// Step 3:对于双目或rgbd摄像头,为当前帧生成新的地图点;单目无操作
if(mSensor!=System::MONOCULAR)
{
// 根据Tcw计算mRcw、mtcw和mRwc、mOw
mCurrentFrame.UpdatePoseMatrices();
// We sort points by the measured depth by the stereo/RGBD sensor.
// We create all those MapPoints whose depth < mThDepth.
// If there are less than 100 close points we create the 100 closest.
// Step 3.1:得到当前帧有深度值的特征点(不一定是地图点)
vector<pair<float,int> > vDepthIdx;
vDepthIdx.reserve(mCurrentFrame.N);
for(int i=0; i<mCurrentFrame.N; i++)
{
float z = mCurrentFrame.mvDepth[i];
if(z>0)
{
// 第一个元素是深度,第二个元素是对应的特征点的id
vDepthIdx.push_back(make_pair(z,i));
}
}
if(!vDepthIdx.empty())
{
// Step 3.2:按照深度从小到大排序
sort(vDepthIdx.begin(),vDepthIdx.end());
// Step 3.3:从中找出不是地图点的生成临时地图点
// 处理的近点的个数
int nPoints = 0;
for(size_t j=0; j<vDepthIdx.size();j++)
{
int i = vDepthIdx[j].second;
bool bCreateNew = false;
// 如果这个点对应在上一帧中的地图点没有,或者创建后就没有被观测到,那么就生成一个临时的地图点
MapPoint* pMP = mCurrentFrame.mvpMapPoints[i];
if(!pMP)
bCreateNew = true;
else if(pMP->Observations()<1)
{
bCreateNew = true;
mCurrentFrame.mvpMapPoints[i] = static_cast<MapPoint*>(NULL);
}
// 如果需要就新建地图点,这里的地图点不是临时的,是全局地图中新建地图点,用于跟踪
if(bCreateNew)
{
cv::Mat x3D = mCurrentFrame.UnprojectStereo(i);
MapPoint* pNewMP = new MapPoint(x3D,pKF,mpMap);
// 这些添加属性的操作是每次创建MapPoint后都要做的
pNewMP->AddObservation(pKF,i);
pKF->AddMapPoint(pNewMP,i);
pNewMP->ComputeDistinctiveDescriptors();
pNewMP->UpdateNormalAndDepth();
mpMap->AddMapPoint(pNewMP);
mCurrentFrame.mvpMapPoints[i]=pNewMP;
nPoints++;
}
else
{
// 因为从近到远排序,记录其中不需要创建地图点的个数
nPoints++;
}
// Step 3.4:停止新建地图点必须同时满足以下条件:
// 1、当前的点的深度已经超过了设定的深度阈值(35倍基线)
// 2、nPoints已经超过100个点,说明距离比较远了,可能不准确,停掉退出
if(vDepthIdx[j].first>mThDepth && nPoints>100)
break;
}
}
}
// Step 4:插入关键帧
// 关键帧插入到列表 mlNewKeyFrames中,等待local mapping线程临幸
mpLocalMapper->InsertKeyFrame(pKF);
// 插入好了,允许局部建图停止
mpLocalMapper->SetNotStop(false);
// 当前帧成为新的关键帧,更新
mnLastKeyFrameId = mCurrentFrame.mnId;
mpLastKeyFrame = pKF;
}
7.10 跟踪函数: Track()
主要关注成员变量mState
的变化:
值 | 意义 |
---|---|
SYSTEM_NOT_READY | 系统没有准备好,一般就是在启动后加载配置文件和词典文件时候的状态 |
NO_IMAGES_YET | 还没有接收到输入图像 |
NOT_INITIALIZED | 接收到图像但未初始化成功 |
OK | 跟踪成功 |
LOST | 跟踪失败 |
void Tracking::Track()
{
// track包含两部分:估计运动、跟踪局部地图
// mState为tracking的状态,包括 SYSTME_NOT_READY, NO_IMAGE_YET, NOT_INITIALIZED, OK, LOST
// 如果图像复位过、或者第一次运行,则为NO_IMAGE_YET状态
if(mState==NO_IMAGES_YET)
{
mState = NOT_INITIALIZED;
}
// mLastProcessedState 存储了Tracking最新的状态,用于FrameDrawer中的绘制
mLastProcessedState=mState;
// Get Map Mutex -> Map cannot be changed
// 地图更新时加锁。保证地图不会发生变化
// 疑问:这样子会不会影响地图的实时更新?
// 回答:主要耗时在构造帧中特征点的提取和匹配部分,在那个时候地图是没有被上锁的,有足够的时间更新地图
unique_lock<mutex> lock(mpMap->mMutexMapUpdate);
// Step 1:地图初始化
if(mState==NOT_INITIALIZED)
{
if(mSensor==System::STEREO || mSensor==System::RGBD)
//双目RGBD相机的初始化共用一个函数
StereoInitialization();
else
//单目初始化
MonocularInitialization();
//更新帧绘制器中存储的最新状态
mpFrameDrawer->Update(this);
//这个状态量在上面的初始化函数中被更新
if(mState!=OK)
return;
}
else
{
// System is initialized. Track Frame.
// bOK为临时变量,用于表示每个函数是否执行成功
bool bOK;
// Initial camera pose estimation using motion model or relocalization (if tracking is lost)
// mbOnlyTracking等于false表示正常SLAM模式(定位+地图更新),mbOnlyTracking等于true表示仅定位模式
// tracking 类构造时默认为false。在viewer中有个开关ActivateLocalizationMode,可以控制是否开启mbOnlyTracking
if(!mbOnlyTracking)
{
// Local Mapping is activated. This is the normal behaviour, unless
// you explicitly activate the "only tracking" mode.
// Step 2:跟踪进入正常SLAM模式,有地图更新
// 是否正常跟踪
if(mState==OK)
{
// Local Mapping might have changed some MapPoints tracked in last frame
// Step 2.1 检查并更新上一帧被替换的MapPoints
// 局部建图线程则可能会对原有的地图点进行替换.在这里进行检查
CheckReplacedInLastFrame();
// Step 2.2 运动模型是空的或刚完成重定位,跟踪参考关键帧;否则恒速模型跟踪
// 第一个条件,如果运动模型为空,说明是刚初始化开始,或者已经跟丢了
// 第二个条件,如果当前帧紧紧地跟着在重定位的帧的后面,我们将重定位帧来恢复位姿
// mnLastRelocFrameId 上一次重定位的那一帧
if(mVelocity.empty() || mCurrentFrame.mnId<mnLastRelocFrameId+2)
{
// 用最近的关键帧来跟踪当前的普通帧
// 通过BoW的方式在参考帧中找当前帧特征点的匹配点
// 优化每个特征点都对应3D点重投影误差即可得到位姿
bOK = TrackReferenceKeyFrame();
}
else
{
// 用最近的普通帧来跟踪当前的普通帧
// 根据恒速模型设定当前帧的初始位姿
// 通过投影的方式在参考帧中找当前帧特征点的匹配点
// 优化每个特征点所对应3D点的投影误差即可得到位姿
bOK = TrackWithMotionModel();
if(!bOK)
//根据恒速模型失败了,只能根据参考关键帧来跟踪
bOK = TrackReferenceKeyFrame();
}
}
else
{
// 如果跟踪状态不成功,那么就只能重定位了
// BOW搜索,EPnP求解位姿
bOK = Relocalization();
}
}
else
{
// Localization Mode: Local Mapping is deactivated
// Step 2:只进行跟踪tracking,局部地图不工作
if(mState==LOST)
{
// Step 2.1 如果跟丢了,只能重定位
bOK = Relocalization();
}
else
{
// mbVO是mbOnlyTracking为true时的才有的一个变量
// mbVO为false表示此帧匹配了很多的MapPoints,跟踪很正常 (注意有点反直觉)
// mbVO为true表明此帧匹配了很少的MapPoints,少于10个,要跪的节奏
if(!mbVO)
{
// Step 2.2 如果跟踪正常,使用恒速模型 或 参考关键帧跟踪
// In last frame we tracked enough MapPoints in the map
if(!mVelocity.empty())
{
bOK = TrackWithMotionModel();
// ? 为了和前面模式统一,这个地方是不是应该加上
// if(!bOK)
// bOK = TrackReferenceKeyFrame();
}
else
{
// 如果恒速模型不被满足,那么就只能够通过参考关键帧来定位
bOK = TrackReferenceKeyFrame();
}
}
else
{
// In last frame we tracked mainly "visual odometry" points.
// We compute two camera poses, one from motion model and one doing relocalization.
// If relocalization is sucessfull we choose that solution, otherwise we retain
// the "visual odometry" solution.
// mbVO为true,表明此帧匹配了很少(小于10)的地图点,要跪的节奏,既做跟踪又做重定位
//MM=Motion Model,通过运动模型进行跟踪的结果
bool bOKMM = false;
//通过重定位方法来跟踪的结果
bool bOKReloc = false;
//运动模型中构造的地图点
vector<MapPoint*> vpMPsMM;
//在追踪运动模型后发现的外点
vector<bool> vbOutMM;
//运动模型得到的位姿
cv::Mat TcwMM;
// Step 2.3 当运动模型有效的时候,根据运动模型计算位姿
if(!mVelocity.empty())
{
bOKMM = TrackWithMotionModel();
// 将恒速模型跟踪结果暂存到这几个变量中,因为后面重定位会改变这些变量
vpMPsMM = mCurrentFrame.mvpMapPoints;
vbOutMM = mCurrentFrame.mvbOutlier;
TcwMM = mCurrentFrame.mTcw.clone();
}
// Step 2.4 使用重定位的方法来得到当前帧的位姿
bOKReloc = Relocalization();
// Step 2.5 根据前面的恒速模型、重定位结果来更新状态
if(bOKMM && !bOKReloc)
{
// 恒速模型成功、重定位失败,重新使用之前暂存的恒速模型结果
mCurrentFrame.SetPose(TcwMM);
mCurrentFrame.mvpMapPoints = vpMPsMM;
mCurrentFrame.mvbOutlier = vbOutMM;
//? 疑似bug!这段代码是不是重复增加了观测次数?后面 TrackLocalMap 函数中会有这些操作
// 如果当前帧匹配的3D点很少,增加当前可视地图点的被观测次数
if(mbVO)
{
// 更新当前帧的地图点被观测次数
for(int i =0; i<mCurrentFrame.N; i++)
{
//如果这个特征点形成了地图点,并且也不是外点的时候
if(mCurrentFrame.mvpMapPoints[i] && !mCurrentFrame.mvbOutlier[i])
{
//增加能观测到该地图点的帧数
mCurrentFrame.mvpMapPoints[i]->IncreaseFound();
}
}
}
}
else if(bOKReloc)
{
// 只要重定位成功整个跟踪过程正常进行(重定位与跟踪,更相信重定位)
mbVO = false;
}
//有一个成功我们就认为执行成功了
bOK = bOKReloc || bOKMM;
}
}
}
// 将最新的关键帧作为当前帧的参考关键帧
mCurrentFrame.mpReferenceKF = mpReferenceKF;
// If we have an initial estimation of the camera pose and matching. Track the local map.
// Step 3:在跟踪得到当前帧初始姿态后,现在对local map进行跟踪得到更多的匹配,并优化当前位姿
// 前面只是跟踪一帧得到初始位姿,这里搜索局部关键帧、局部地图点,和当前帧进行投影匹配,得到更多匹配的MapPoints后进行Pose优化
if(!mbOnlyTracking)
{
if(bOK)
bOK = TrackLocalMap();
}
else
{
// mbVO true means that there are few matches to MapPoints in the map. We cannot retrieve
// a local map and therefore we do not perform TrackLocalMap(). Once the system relocalizes
// the camera we will use the local map again.
// 重定位成功
if(bOK && !mbVO)
bOK = TrackLocalMap();
}
//根据上面的操作来判断是否追踪成功
if(bOK)
mState = OK;
else
mState=LOST;
// Step 4:更新显示线程中的图像、特征点、地图点等信息
mpFrameDrawer->Update(this);
// If tracking were good, check if we insert a keyframe
//只有在成功追踪时才考虑生成关键帧的问题
if(bOK)
{
// Update motion model
// Step 5:跟踪成功,更新恒速运动模型
if(!mLastFrame.mTcw.empty())
{
// 更新恒速运动模型 TrackWithMotionModel 中的mVelocity
cv::Mat LastTwc = cv::Mat::eye(4,4,CV_32F);
mLastFrame.GetRotationInverse().copyTo(LastTwc.rowRange(0,3).colRange(0,3));
mLastFrame.GetCameraCenter().copyTo(LastTwc.rowRange(0,3).col(3));
// mVelocity = Tcl = Tcw * Twl,表示上一帧到当前帧的变换, 其中 Twl = LastTwc
mVelocity = mCurrentFrame.mTcw*LastTwc;
}
else
//否则速度为空
mVelocity = cv::Mat();
//更新显示中的位姿
mpMapDrawer->SetCurrentCameraPose(mCurrentFrame.mTcw);
// Clean VO matches
// Step 6:清除观测不到的地图点
for(int i=0; i<mCurrentFrame.N; i++)
{
MapPoint* pMP = mCurrentFrame.mvpMapPoints[i];
if(pMP)
if(pMP->Observations()<1)
{
mCurrentFrame.mvbOutlier[i] = false;
mCurrentFrame.mvpMapPoints[i]=static_cast<MapPoint*>(NULL);
}
}
// Delete temporal MapPoints
// Step 7:清除恒速模型跟踪中 UpdateLastFrame中为当前帧临时添加的MapPoints(仅双目和rgbd)
// 步骤6中只是在当前帧中将这些MapPoints剔除,这里从MapPoints数据库中删除
// 临时地图点仅仅是为了提高双目或rgbd摄像头的帧间跟踪效果,用完以后就扔了,没有添加到地图中
for(list<MapPoint*>::iterator lit = mlpTemporalPoints.begin(), lend = mlpTemporalPoints.end(); lit!=lend; lit++)
{
MapPoint* pMP = *lit;
delete pMP;
}
// 这里不仅仅是清除mlpTemporalPoints,通过delete pMP还删除了指针指向的MapPoint
// 不能够直接执行这个是因为其中存储的都是指针,之前的操作都是为了避免内存泄露
mlpTemporalPoints.clear();
// Check if we need to insert a new keyframe
// Step 8:检测并插入关键帧,对于双目或RGB-D会产生新的地图点
if(NeedNewKeyFrame())
CreateNewKeyFrame();
// We allow points with high innovation (considererd outliers by the Huber Function)
// pass to the new keyframe, so that bundle adjustment will finally decide
// if they are outliers or not. We don't want next frame to estimate its position
// with those points so we discard them in the frame.
// 作者这里说允许在BA中被Huber核函数判断为外点的传入新的关键帧中,让后续的BA来审判他们是不是真正的外点
// 但是估计下一帧位姿的时候我们不想用这些外点,所以删掉
// Step 9 删除那些在bundle adjustment中检测为outlier的地图点
for(int i=0; i<mCurrentFrame.N;i++)
{
// 这里第一个条件还要执行判断是因为, 前面的操作中可能删除了其中的地图点
if(mCurrentFrame.mvpMapPoints[i] && mCurrentFrame.mvbOutlier[i])
mCurrentFrame.mvpMapPoints[i]=static_cast<MapPoint*>(NULL);
}
}
// Reset if the camera get lost soon after initialization
// Step 10 如果初始化后不久就跟踪失败,并且relocation也没有搞定,只能重新Reset
if(mState==LOST)
{
//如果地图中的关键帧信息过少的话,直接重新进行初始化了
if(mpMap->KeyFramesInMap()<=5)
{
cout << "Track lost soon after initialisation, reseting..." << endl;
mpSystem->Reset();
return;
}
}
//确保已经设置了参考关键帧
if(!mCurrentFrame.mpReferenceKF)
mCurrentFrame.mpReferenceKF = mpReferenceKF;
// 保存上一帧的数据,当前帧变上一帧
mLastFrame = Frame(mCurrentFrame);
}
// Store frame pose information to retrieve the complete camera trajectory afterwards.
// Step 11:记录位姿信息,用于最后保存所有的轨迹
if(!mCurrentFrame.mTcw.empty())
{
// 计算相对姿态Tcr = Tcw * Twr, Twr = Trw^-1
cv::Mat Tcr = mCurrentFrame.mTcw*mCurrentFrame.mpReferenceKF->GetPoseInverse();
//保存各种状态
mlRelativeFramePoses.push_back(Tcr);
mlpReferences.push_back(mpReferenceKF);
mlFrameTimes.push_back(mCurrentFrame.mTimeStamp);
mlbLost.push_back(mState==LOST);
}
else
{
// This can happen if tracking is lost
// 如果跟踪失败,则相对位姿使用上一次值
mlRelativeFramePoses.push_back(mlRelativeFramePoses.back());
mlpReferences.push_back(mlpReferences.back());
mlFrameTimes.push_back(mlFrameTimes.back());
mlbLost.push_back(mState==LOST);
}
}// Tracking
7.11 Tracking
流程中的关键问题(暗线)
7.11.1 地图点的创建与删除
Tracking线程中初始化过程(Tracking::MonocularInitialization()和Tracking::StereoInitialization())会创建新的地图点.
Tracking线程中创建新的关键帧(Tracking::CreateNewKeyFrame())会创建新的地图点.
Tracking线程中根据恒速运动模型估计初始位姿(Tracking::TrackWithMotionModel())也会产生临时地图点,但这些临时地图点在跟踪成功后会被马上删除.
所有的非临时地图点都是由关键帧建立的,Tracking::TrackWithMotionModel()中由非关键帧建立的关键点被设为临时关键点,很快会被删掉,仅作增强帧间匹配用,不会对建图产生任何影响.这也不违反只有关键帧才能参与LocalMapping和LoppClosing线程的原则.
7.11.2 关键帧与地图点间发生关系的时机
新创建出来的非临时地图点都会与创建它的关键帧建立双向连接.
通过ORBmatcher::SearchByXXX()函数匹配得到的帧点关系只建立单向连接:
只在关键帧中添加了对地图点的观测(将地图点加入到关键帧对象的成员变量mvpMapPoints中了).
没有在地图点中添加对关键帧的观测(地图点的成员变量mObservations中没有该关键帧).
这为后文中LocalMapping线程中函数LocalMapping::ProcessNewKeyFrame()对关键帧中地图点的处理埋下了伏笔.该函数通过检查地图点中是否有对关键点的观测来判断该地图点是否是新生成的.
/**
* @brief 处理列表中的关键帧,包括计算BoW、更新观测、描述子、共视图,插入到地图等
*
*/
void LocalMapping::ProcessNewKeyFrame()
{
// Step 1:从缓冲队列中取出一帧关键帧
// 该关键帧队列是Tracking线程向LocalMapping中插入的关键帧组成
{
unique_lock<mutex> lock(mMutexNewKFs);
// 取出列表中最前面的关键帧,作为当前要处理的关键帧
mpCurrentKeyFrame = mlNewKeyFrames.front();
// 取出最前面的关键帧后,在原来的列表里删掉该关键帧
mlNewKeyFrames.pop_front();
}
// Compute Bags of Words structures
// Step 2:计算该关键帧特征点的词袋向量
mpCurrentKeyFrame->ComputeBoW();
// Associate MapPoints to the new keyframe and update normal and descriptor
// Step 3:当前处理关键帧中有效的地图点,更新normal,描述子等信息
// TrackLocalMap中和当前帧新匹配上的地图点和当前关键帧进行关联绑定
const vector<MapPoint*> vpMapPointMatches = mpCurrentKeyFrame->GetMapPointMatches();
// 对当前处理的这个关键帧中的所有的地图点展开遍历
for(size_t i=0; i<vpMapPointMatches.size(); i++)
{
MapPoint* pMP = vpMapPointMatches[i];
if(pMP)
{
if(!pMP->isBad())
{
if(!pMP->IsInKeyFrame(mpCurrentKeyFrame))
{
// 如果地图点不是来自当前帧的观测(比如来自局部地图点),为当前地图点添加观测
pMP->AddObservation(mpCurrentKeyFrame, i);
// 获得该点的平均观测方向和观测距离范围
pMP->UpdateNormalAndDepth();
// 更新地图点的最佳描述子
pMP->ComputeDistinctiveDescriptors();
}
else // this can only happen for new stereo points inserted by the Tracking
{
// 这些地图点可能来自双目或RGBD跟踪过程中新生成的地图点,或者是CreateNewMapPoints 中通过三角化产生
// 将上述地图点放入mlpRecentAddedMapPoints,等待后续MapPointCulling函数的检验
mlpRecentAddedMapPoints.push_back(pMP);
}
}
}
}
// Update links in the Covisibility Graph
// Step 4:更新关键帧间的连接关系(共视图)
mpCurrentKeyFrame->UpdateConnections();
// Insert Keyframe in Map
// Step 5:将该关键帧插入到地图中
mpMap->AddKeyFrame(mpCurrentKeyFrame);
}
7.12 参考关键帧: mpReferenceKF
参考关键帧的用途:
Tracking线程中函数Tracking::TrackReferenceKeyFrame()根据参考关键帧估计初始位姿.
用于初始化新创建的MapPoint的参考帧mpRefKF,函数MapPoint::UpdateNormalAndDepth()中根据参考关键帧mpRefKF更新地图点的平均观测距离.
参考关键帧的指定:
Traking线程中函数Tracking::CreateNewKeyFrame()创建完新关键帧后,会将新创建的关键帧设为参考关键帧.
Tracking线程中函数Tracking::TrackLocalMap()跟踪局部地图过程中调用函数Tracking::UpdateLocalMap(),其中调用函数Tracking::UpdateLocalKeyFrames(),将与当前帧共视程度最高的关键帧设为参考关键帧.
8. ORB-SLAM2代码详解08_局部建图线程LocalMapping
8.1 各成员函数/变量
成员函数/变量 | 访问控制 | 意义 |
---|---|---|
std::list mlNewKeyFrames | protected | Tracking 线程向LocalMapping 线程插入关键帧的缓冲队列 |
void InsertKeyFrame(KeyFrame* pKF) | public | 向缓冲队列mlNewKeyFrames 内插入关键帧 |
bool CheckNewKeyFrames() | protected | 查看缓冲队列mlNewKeyFrames 内是否有待处理的新关键帧 |
int KeyframesInQueue() | public | 查询缓冲队列mlNewKeyFrames 内关键帧个数 |
bool mbAcceptKeyFrames | protected | LocalMapping 线程是否愿意接收Tracking 线程传来的新关键帧 |
bool AcceptKeyFrames() | public | mbAcceptKeyFrames 的get方法 |
void SetAcceptKeyFrames(bool flag) | public | mbAcceptKeyFrames 的set方法 |
Tracking线程创建的所有关键帧都被插入到LocalMapping线程的缓冲队列mlNewKeyFrames中.
成员函数mbAcceptKeyFrames表示当前LocalMapping线程是否愿意接收关键帧,这会被Tracking线程函数Tracking::NeedNewKeyFrame()用作是否生产关键帧的参考因素之一;但即使mbAcceptKeyFrames为false,在系统很需要关键帧的情况下Tracking线程函数Tracking::NeedNewKeyFrame()也会决定生成关键帧.
8.2 局部建图主函数: Run()
函数LocalMapping::Run()
是LocalMapping
线程的主函数,该函数内部是一个死循环,每3
毫秒查询一次当前线程缓冲队列mlNewKeyFrames
.若查询到了待处理的新关键帧,就进行查询
// 线程主函数
void LocalMapping::Run()
{
// 标记状态,表示当前run函数正在运行,尚未结束
mbFinished = false;
// 主循环
while(1)
{
// Tracking will see that Local Mapping is busy
// Step 1 告诉Tracking,LocalMapping正处于繁忙状态,请不要给我发送关键帧打扰我
// LocalMapping线程处理的关键帧都是Tracking线程发来的
SetAcceptKeyFrames(false);
// Check if there are keyframes in the queue
// 等待处理的关键帧列表不为空
if(CheckNewKeyFrames())
{
// BoW conversion and insertion in Map
// Step 2 处理列表中的关键帧,包括计算BoW、更新观测、描述子、共视图,插入到地图等
ProcessNewKeyFrame();
// Check recent MapPoints
// Step 3 根据地图点的观测情况剔除质量不好的地图点
MapPointCulling();
// Triangulate new MapPoints
// Step 4 当前关键帧与相邻关键帧通过三角化产生新的地图点,使得跟踪更稳
CreateNewMapPoints();
// 已经处理完队列中的最后的一个关键帧
if(!CheckNewKeyFrames())
{
// Find more matches in neighbor keyframes and fuse point duplications
// Step 5 检查并融合当前关键帧与相邻关键帧帧(两级相邻)中重复的地图点
SearchInNeighbors();
}
// 终止BA的标志
mbAbortBA = false;
// 已经处理完队列中的最后的一个关键帧,并且闭环检测没有请求停止LocalMapping
if(!CheckNewKeyFrames() && !stopRequested())
{
// Local BA
// Step 6 当局部地图中的关键帧大于2个的时候进行局部地图的BA
if(mpMap->KeyFramesInMap()>2)
// 注意这里的第二个参数是按地址传递的,当这里的 mbAbortBA 状态发生变化时,能够及时执行/停止BA
Optimizer::LocalBundleAdjustment(mpCurrentKeyFrame,&mbAbortBA, mpMap);
// Check redundant local Keyframes
// Step 7 检测并剔除当前帧相邻的关键帧中冗余的关键帧
// 冗余的判定:该关键帧的90%的地图点可以被其它关键帧观测到
KeyFrameCulling();
}
// Step 8 将当前帧加入到闭环检测队列中
// 注意这里的关键帧被设置成为了bad的情况,这个需要注意
mpLoopCloser->InsertKeyFrame(mpCurrentKeyFrame);
}
else if(Stop()) // 当要终止当前线程的时候
{
// Safe area to stop
while(isStopped() && !CheckFinish())
{
// 如果还没有结束利索,那么等
// usleep(3000);
std::this_thread::sleep_for(std::chrono::milliseconds(3));
}
// 然后确定终止了就跳出这个线程的主循环
if(CheckFinish())
break;
}
// 查看是否有复位线程的请求
ResetIfRequested();
// Tracking will see that Local Mapping is not busy
SetAcceptKeyFrames(true);
// 如果当前线程已经结束了就跳出主循环
if(CheckFinish())
break;
//usleep(3000);
std::this_thread::sleep_for(std::chrono::milliseconds(3));
}
// 设置线程已经终止
SetFinish();
}
8.3 处理队列中第一个关键帧: ProcessNewKeyFrame()
在第3步中处理当前关键点时比较有意思,通过判断该地图点是否观测到当前关键帧(pMP->IsInKeyFrame(mpCurrentKeyFrame))来判断该地图点是否是当前关键帧中新生成的.
若地图点是本关键帧跟踪过程中匹配得到的(Tracking::TrackWithMotionModel()、Tracking::TrackReferenceKeyFrame()、Tracking::Relocalization()和Tracking::SearchLocalPoints()中调用了ORBmatcher::SearchByProjection()和ORBmatcher::SearchByBoW()方法),则是之前关键帧中创建的地图点,只需添加其对当前帧的观测即可.
若地图点是本关键帧跟踪过程中新生成的(包括:1.单目或双目初始化Tracking::MonocularInitialization()、Tracking::StereoInitialization();2.创建新关键帧Tracking::CreateNewKeyFrame()),则该地图点中有对当前关键帧的观测,是新生成的地图点,放入容器mlNewKeyFrames中供LocalMapping::MapPointCulling()函数筛选.
/**
* @brief 处理列表中的关键帧,包括计算BoW、更新观测、描述子、共视图,插入到地图等
*
*/
void LocalMapping::ProcessNewKeyFrame()
{
// Step 1:从缓冲队列中取出一帧关键帧
// 该关键帧队列是Tracking线程向LocalMapping中插入的关键帧组成
{
unique_lock<mutex> lock(mMutexNewKFs);
// 取出列表中最前面的关键帧,作为当前要处理的关键帧
mpCurrentKeyFrame = mlNewKeyFrames.front();
// 取出最前面的关键帧后,在原来的列表里删掉该关键帧
mlNewKeyFrames.pop_front();
}
// Compute Bags of Words structures
// Step 2:计算该关键帧特征点的词袋向量
mpCurrentKeyFrame->ComputeBoW();
// Associate MapPoints to the new keyframe and update normal and descriptor
// Step 3:当前处理关键帧中有效的地图点,更新normal,描述子等信息
// TrackLocalMap中和当前帧新匹配上的地图点和当前关键帧进行关联绑定
const vector<MapPoint*> vpMapPointMatches = mpCurrentKeyFrame->GetMapPointMatches();
// 对当前处理的这个关键帧中的所有的地图点展开遍历
for(size_t i=0; i<vpMapPointMatches.size(); i++)
{
MapPoint* pMP = vpMapPointMatches[i];
if(pMP)
{
if(!pMP->isBad())
{
if(!pMP->IsInKeyFrame(mpCurrentKeyFrame))
{
// 如果地图点不是来自当前帧的观测(比如来自局部地图点),为当前地图点添加观测
pMP->AddObservation(mpCurrentKeyFrame, i);
// 获得该点的平均观测方向和观测距离范围
pMP->UpdateNormalAndDepth();
// 更新地图点的最佳描述子
pMP->ComputeDistinctiveDescriptors();
}
else // this can only happen for new stereo points inserted by the Tracking
{
// 这些地图点可能来自双目或RGBD跟踪过程中新生成的地图点,或者是CreateNewMapPoints 中通过三角化产生
// 将上述地图点放入mlpRecentAddedMapPoints,等待后续MapPointCulling函数的检验
mlpRecentAddedMapPoints.push_back(pMP);
}
}
}
}
// Update links in the Covisibility Graph
// Step 4:更新关键帧间的连接关系(共视图)
mpCurrentKeyFrame->UpdateConnections();
// Insert Keyframe in Map
// Step 5:将该关键帧插入到地图中
mpMap->AddKeyFrame(mpCurrentKeyFrame);
}
8.4 剔除坏地图点: MapPointCulling()
冗余地图点的标准:满足以下其中之一就算是坏地图点
1
2 在创建的3帧内观测数目少于2(双目为3)
若地图点经过了连续3个关键帧仍未被剔除,则被认为是好的地图点
/**
* @brief 检查新增地图点,根据地图点的观测情况剔除质量不好的新增的地图点
* mlpRecentAddedMapPoints:存储新增的地图点,这里是要删除其中不靠谱的
*/
void LocalMapping::MapPointCulling()
{
// Check Recent Added MapPoints
list<MapPoint*>::iterator lit = mlpRecentAddedMapPoints.begin();
const unsigned long int nCurrentKFid = mpCurrentKeyFrame->mnId;
// Step 1:根据相机类型设置不同的观测阈值
int nThObs;
if(mbMonocular)
nThObs = 2;
else
nThObs = 3;
const int cnThObs = nThObs;
// Step 2:遍历检查新添加的地图点
while(lit!=mlpRecentAddedMapPoints.end())
{
MapPoint* pMP = *lit;
if(pMP->isBad())
{
// Step 2.1:已经是坏点的地图点仅从队列中删除
lit = mlpRecentAddedMapPoints.erase(lit);
}
else if(pMP->GetFoundRatio()<0.25f)
{
// Step 2.2:跟踪到该地图点的帧数相比预计可观测到该地图点的帧数的比例小于25%,从地图中删除
// (mnFound/mnVisible) < 25%
// mnFound :地图点被多少帧(包括普通帧)看到,次数越多越好
// mnVisible:地图点应该被看到的次数
// (mnFound/mnVisible):对于大FOV镜头这个比例会高,对于窄FOV镜头这个比例会低
pMP->SetBadFlag();
lit = mlpRecentAddedMapPoints.erase(lit);
}
else if(((int)nCurrentKFid-(int)pMP->mnFirstKFid)>=2 && pMP->Observations()<=cnThObs)
{
// Step 2.3:从该点建立开始,到现在已经过了不小于2个关键帧
// 但是观测到该点的相机数却不超过阈值cnThObs,从地图中删除
pMP->SetBadFlag();
lit = mlpRecentAddedMapPoints.erase(lit);
}
else if(((int)nCurrentKFid-(int)pMP->mnFirstKFid)>=3)
// Step 2.4:从建立该点开始,已经过了3个关键帧而没有被剔除,则认为是质量高的点
// 因此没有SetBadFlag(),仅从队列中删除
lit = mlpRecentAddedMapPoints.erase(lit);
else
lit++;
}
}
MapPoint
类中关于召回率的成员函数和变量如下:
成员函数/变量 | 访问控制 | 意义 | 初值 |
---|---|---|---|
int mnFound | protected | 实际观测到该地图点的帧数 | 1 |
int mnVisible | protected | 理论上应当观测到该地图点的帧数 | 1 |
float GetFoundRatio() | public | 召回率实际观测到该地图点的帧数理论上应当观测到该地图点的帧数 | |
void IncreaseFound(int n=1) | public | mnFound 加1 | |
void IncreaseVisible(int n=1) | public | mnVisible 加1 |
这两个成员变量主要用于Tracking线程.
在函数Tracking::SearchLocalPoints()中,会对所有处于当前帧视锥内的地图点调用成员函数MapPoint::IncreaseVisible().(这些点未必真的被当前帧观测到了,只是地理位置上处于当前帧视锥范围内).
void Tracking::SearchLocalPoints() {
// 当前关键帧的地图点
for (MapPoint *pMP : mCurrentFrame.mvpMapPoints) {
pMP->IncreaseVisible();
}
}
}
// 局部关键帧中不属于当前帧,但在当前帧视锥范围内的地图点
for (MapPoint *pMP = *vit : mvpLocalMapPoints.begin()) {
if (mCurrentFrame.isInFrustum(pMP, 0.5)) {
pMP->IncreaseVisible();
}
}
// ...
}
在函数Tracking::TrackLocalMap()
中,会对所有当前帧观测到的地图点调用MaoPoint::IncreaseFound()
.
bool Tracking::TrackLocalMap() {
// ...
for (int i = 0; i < mCurrentFrame.N; i++) {
if (mCurrentFrame.mvpMapPoints[i]) {
if (!mCurrentFrame.mvbOutlier[i]) {
// 当前帧观测到的地图点
mCurrentFrame.mvpMapPoints[i]->IncreaseFound();
// ...
}
}
}
// ...
}
8.5 创建新地图点: CreateNewMapPoints()
![请添加图片描述](https://i-blog.csdnimg.cn/blog_migrate/60e4b7e6844d2ea5f552b2165a01459b.png)
将当前关键帧分别与共视程度最高的前10
(单目相机取20
)个共视关键帧两两进行特征匹配,生成地图点.
对于双目相机的匹配特征点对,可以根据某帧特征点深度恢复地图点,也可以根据两帧间对极几何三角化地图点,这里取视差角最大的方式来生成地图点.
8.6 融合当前关键帧和其共视帧的地图点: SearchInNeighbors()
本函数将当前关键帧与其一级和二级共视关键帧做地图点融合,分两步:
- 正向融合: 将当前关键帧的地图点融合到各共视关键帧中.
- 反向融合: 将各共视关键帧的地图点融合到当前关键帧中.
/**
* @brief 检查并融合当前关键帧与相邻帧(两级相邻)重复的地图点
*
*/
void LocalMapping::SearchInNeighbors()
{
// Retrieve neighbor keyframes
// Step 1:获得当前关键帧在共视图中权重排名前nn的邻接关键帧
// 开始之前先定义几个概念
// 当前关键帧的邻接关键帧,称为一级相邻关键帧,也就是邻居
// 与一级相邻关键帧相邻的关键帧,称为二级相邻关键帧,也就是邻居的邻居
// 单目情况要20个邻接关键帧,双目或者RGBD则要10个
int nn = 10;
if(mbMonocular)
nn=20;
// 和当前关键帧相邻的关键帧,也就是一级相邻关键帧
const vector<KeyFrame*> vpNeighKFs = mpCurrentKeyFrame->GetBestCovisibilityKeyFrames(nn);
// Step 2:存储一级相邻关键帧及其二级相邻关键帧
vector<KeyFrame*> vpTargetKFs;
// 开始对所有候选的一级关键帧展开遍历:
for(vector<KeyFrame*>::const_iterator vit=vpNeighKFs.begin(), vend=vpNeighKFs.end(); vit!=vend; vit++)
{
KeyFrame* pKFi = *vit;
// 没有和当前帧进行过融合的操作
if(pKFi->isBad() || pKFi->mnFuseTargetForKF == mpCurrentKeyFrame->mnId)
continue;
// 加入一级相邻关键帧
vpTargetKFs.push_back(pKFi);
// 标记已经加入
pKFi->mnFuseTargetForKF = mpCurrentKeyFrame->mnId;
// Extend to some second neighbors
// 以一级相邻关键帧的共视关系最好的5个相邻关键帧 作为二级相邻关键帧
const vector<KeyFrame*> vpSecondNeighKFs = pKFi->GetBestCovisibilityKeyFrames(5);
// 遍历得到的二级相邻关键帧
for(vector<KeyFrame*>::const_iterator vit2=vpSecondNeighKFs.begin(), vend2=vpSecondNeighKFs.end(); vit2!=vend2; vit2++)
{
KeyFrame* pKFi2 = *vit2;
// 当然这个二级相邻关键帧要求没有和当前关键帧发生融合,并且这个二级相邻关键帧也不是当前关键帧
if(pKFi2->isBad() || pKFi2->mnFuseTargetForKF==mpCurrentKeyFrame->mnId || pKFi2->mnId==mpCurrentKeyFrame->mnId)
continue;
// 存入二级相邻关键帧
vpTargetKFs.push_back(pKFi2);
}
}
// Search matches by projection from current KF in target KFs
// 使用默认参数, 最优和次优比例0.6,匹配时检查特征点的旋转
ORBmatcher matcher;
// Step 3:将当前帧的地图点分别投影到两级相邻关键帧,寻找匹配点对应的地图点进行融合,称为正向投影融合
vector<MapPoint*> vpMapPointMatches = mpCurrentKeyFrame->GetMapPointMatches();
for(vector<KeyFrame*>::iterator vit=vpTargetKFs.begin(), vend=vpTargetKFs.end(); vit!=vend; vit++)
{
KeyFrame* pKFi = *vit;
// 将地图点投影到关键帧中进行匹配和融合;融合策略如下
// 1.如果地图点能匹配关键帧的特征点,并且该点有对应的地图点,那么选择观测数目多的替换两个地图点
// 2.如果地图点能匹配关键帧的特征点,并且该点没有对应的地图点,那么为该点添加该投影地图点
// 注意这个时候对地图点融合的操作是立即生效的
matcher.Fuse(pKFi,vpMapPointMatches);
}
// Search matches by projection from target KFs in current KF
// Step 4:将两级相邻关键帧地图点分别投影到当前关键帧,寻找匹配点对应的地图点进行融合,称为反向投影融合
// 用于进行存储要融合的一级邻接和二级邻接关键帧所有MapPoints的集合
vector<MapPoint*> vpFuseCandidates;
vpFuseCandidates.reserve(vpTargetKFs.size()*vpMapPointMatches.size());
// Step 4.1:遍历每一个一级邻接和二级邻接关键帧,收集他们的地图点存储到 vpFuseCandidates
for(vector<KeyFrame*>::iterator vitKF=vpTargetKFs.begin(), vendKF=vpTargetKFs.end(); vitKF!=vendKF; vitKF++)
{
KeyFrame* pKFi = *vitKF;
vector<MapPoint*> vpMapPointsKFi = pKFi->GetMapPointMatches();
// 遍历当前一级邻接和二级邻接关键帧中所有的MapPoints,找出需要进行融合的并且加入到集合中
for(vector<MapPoint*>::iterator vitMP=vpMapPointsKFi.begin(), vendMP=vpMapPointsKFi.end(); vitMP!=vendMP; vitMP++)
{
MapPoint* pMP = *vitMP;
if(!pMP)
continue;
// 如果地图点是坏点,或者已经加进集合vpFuseCandidates,跳过
if(pMP->isBad() || pMP->mnFuseCandidateForKF == mpCurrentKeyFrame->mnId)
continue;
// 加入集合,并标记已经加入
pMP->mnFuseCandidateForKF = mpCurrentKeyFrame->mnId;
vpFuseCandidates.push_back(pMP);
}
}
// Step 4.2:进行地图点投影融合,和正向融合操作是完全相同的
// 不同的是正向操作是"每个关键帧和当前关键帧的地图点进行融合",而这里的是"当前关键帧和所有邻接关键帧的地图点进行融合"
matcher.Fuse(mpCurrentKeyFrame,vpFuseCandidates);
// Update points
// Step 5:更新当前帧地图点的描述子、深度、平均观测方向等属性
vpMapPointMatches = mpCurrentKeyFrame->GetMapPointMatches();
for(size_t i=0, iend=vpMapPointMatches.size(); i<iend; i++)
{
MapPoint* pMP=vpMapPointMatches[i];
if(pMP)
{
if(!pMP->isBad())
{
// 在所有找到pMP的关键帧中,获得最佳的描述子
pMP->ComputeDistinctiveDescriptors();
// 更新平均观测方向和观测距离
pMP->UpdateNormalAndDepth();
}
}
}
// Update connections in covisibility graph
// Step 6:更新当前帧与其它帧的共视连接关系
mpCurrentKeyFrame->UpdateConnections();
}
ORBmatcher::Fuse()
将地图点与帧中图像的特征点匹配,实现地图点融合.
在将地图点反投影到帧中的过程中,存在以下两种情况:
- 若地图点反投影对应位置上不存在地图点,则直接添加观测.
- 若地图点反投影位置上存在对应地图点,则将两个地图点合并到其中观测较多的那个.
/**
* @brief 将地图点投影到关键帧中进行匹配和融合;融合策略如下
* 1.如果地图点能匹配关键帧的特征点,并且该点有对应的地图点,那么选择观测数目多的替换两个地图点
* 2.如果地图点能匹配关键帧的特征点,并且该点没有对应的地图点,那么为该点添加该投影地图点
* @param[in] pKF 关键帧
* @param[in] vpMapPoints 待投影的地图点
* @param[in] th 搜索窗口的阈值,默认为3
* @return int 更新地图点的数量
*/
int ORBmatcher::Fuse(KeyFrame *pKF, const vector<MapPoint *> &vpMapPoints, const float th)
{
// 取出当前帧位姿、内参、光心在世界坐标系下坐标
cv::Mat Rcw = pKF->GetRotation();
cv::Mat tcw = pKF->GetTranslation();
const float &fx = pKF->fx;
const float &fy = pKF->fy;
const float &cx = pKF->cx;
const float &cy = pKF->cy;
const float &bf = pKF->mbf;
cv::Mat Ow = pKF->GetCameraCenter();
int nFused=0;
const int nMPs = vpMapPoints.size();
// 遍历所有的待投影地图点
for(int i=0; i<nMPs; i++)
{
MapPoint* pMP = vpMapPoints[i];
// Step 1 判断地图点的有效性
if(!pMP)
continue;
// 地图点无效 或 已经是该帧的地图点(无需融合),跳过
if(pMP->isBad() || pMP->IsInKeyFrame(pKF))
continue;
// 将地图点变换到关键帧的相机坐标系下
cv::Mat p3Dw = pMP->GetWorldPos();
cv::Mat p3Dc = Rcw*p3Dw + tcw;
// Depth must be positive
// 深度值为负,跳过
if(p3Dc.at<float>(2)<0.0f)
continue;
// Step 2 得到地图点投影到关键帧的图像坐标
const float invz = 1/p3Dc.at<float>(2);
const float x = p3Dc.at<float>(0)*invz;
const float y = p3Dc.at<float>(1)*invz;
const float u = fx*x+cx;
const float v = fy*y+cy;
// Point must be inside the image
// 投影点需要在有效范围内
if(!pKF->IsInImage(u,v))
continue;
const float ur = u-bf*invz;
const float maxDistance = pMP->GetMaxDistanceInvariance();
const float minDistance = pMP->GetMinDistanceInvariance();
cv::Mat PO = p3Dw-Ow;
const float dist3D = cv::norm(PO);
// Depth must be inside the scale pyramid of the image
// Step 3 地图点到关键帧相机光心距离需满足在有效范围内
if(dist3D<minDistance || dist3D>maxDistance )
continue;
// Viewing angle must be less than 60 deg
// Step 4 地图点到光心的连线与该地图点的平均观测向量之间夹角要小于60°
cv::Mat Pn = pMP->GetNormal();
if(PO.dot(Pn)<0.5*dist3D)
continue;
// 根据地图点到相机光心距离预测匹配点所在的金字塔尺度
int nPredictedLevel = pMP->PredictScale(dist3D,pKF);
// Search in a radius
// 确定搜索范围
const float radius = th*pKF->mvScaleFactors[nPredictedLevel];
// Step 5 在投影点附近搜索窗口内找到候选匹配点的索引
const vector<size_t> vIndices = pKF->GetFeaturesInArea(u,v,radius);
if(vIndices.empty())
continue;
// Match to the most similar keypoint in the radius
// Step 6 遍历寻找最佳匹配点
const cv::Mat dMP = pMP->GetDescriptor();
int bestDist = 256;
int bestIdx = -1;
for(vector<size_t>::const_iterator vit=vIndices.begin(), vend=vIndices.end(); vit!=vend; vit++)// 步骤3:遍历搜索范围内的features
{
const size_t idx = *vit;
const cv::KeyPoint &kp = pKF->mvKeysUn[idx];
const int &kpLevel= kp.octave;
// 金字塔层级要接近(同一层或小一层),否则跳过
if(kpLevel<nPredictedLevel-1 || kpLevel>nPredictedLevel)
continue;
// 计算投影点与候选匹配特征点的距离,如果偏差很大,直接跳过
if(pKF->mvuRight[idx]>=0)
{
// Check reprojection error in stereo
// 双目情况
const float &kpx = kp.pt.x;
const float &kpy = kp.pt.y;
const float &kpr = pKF->mvuRight[idx];
const float ex = u-kpx;
const float ey = v-kpy;
// 右目数据的偏差也要考虑进去
const float er = ur-kpr;
const float e2 = ex*ex+ey*ey+er*er;
//自由度为3, 误差小于1个像素,这种事情95%发生的概率对应卡方检验阈值为7.82
if(e2*pKF->mvInvLevelSigma2[kpLevel]>7.8)
continue;
}
else
{
// 计算投影点与候选匹配特征点的距离,如果偏差很大,直接跳过
// 单目情况
const float &kpx = kp.pt.x;
const float &kpy = kp.pt.y;
const float ex = u-kpx;
const float ey = v-kpy;
const float e2 = ex*ex+ey*ey;
// 自由度为2的,卡方检验阈值5.99(假设测量有一个像素的偏差)
if(e2*pKF->mvInvLevelSigma2[kpLevel]>5.99)
continue;
}
const cv::Mat &dKF = pKF->mDescriptors.row(idx);
const int dist = DescriptorDistance(dMP,dKF);
// 和投影点的描述子距离最小
if(dist<bestDist)
{
bestDist = dist;
bestIdx = idx;
}
}
// If there is already a MapPoint replace otherwise add new measurement
// Step 7 找到投影点对应的最佳匹配特征点,根据是否存在地图点来融合或新增
// 最佳匹配距离要小于阈值
if(bestDist<=TH_LOW)
{
MapPoint* pMPinKF = pKF->GetMapPoint(bestIdx);
if(pMPinKF)
{
// 如果最佳匹配点有对应有效地图点,选择被观测次数最多的那个替换
if(!pMPinKF->isBad())
{
if(pMPinKF->Observations()>pMP->Observations())
pMP->Replace(pMPinKF);
else
pMPinKF->Replace(pMP);
}
}
else
{
// 如果最佳匹配点没有对应地图点,添加观测信息
pMP->AddObservation(pKF,bestIdx);
pKF->AddMapPoint(pMP,bestIdx);
}
nFused++;
}
}
return nFused;
}
8.7 局部BA优化: Optimizer::LocalBundleAdjustment()
局部BA优化当前帧的局部地图.
- 当前关键帧的一级共视关键帧位姿会被优化;二极共视关键帧会加入优化图,但其位姿不会被优化.
- 所有局部地图点位姿都会被优化.
8.8 剔除冗余关键帧: KeyFrameCulling()
/**
* @brief 检测当前关键帧在共视图中的关键帧,根据地图点在共视图中的冗余程度剔除该共视关键帧
* 冗余关键帧的判定:90%以上的地图点能被其他关键帧(至少3个)观测到
*/
void LocalMapping::KeyFrameCulling()
{
// Check redundant keyframes (only local keyframes)
// A keyframe is considered redundant if the 90% of the MapPoints it sees, are seen
// in at least other 3 keyframes (in the same or finer scale)
// We only consider close stereo points
// 该函数里变量层层深入,这里列一下:
// mpCurrentKeyFrame:当前关键帧,本程序就是判断它是否需要删除
// pKF: mpCurrentKeyFrame的某一个共视关键帧
// vpMapPoints:pKF对应的所有地图点
// pMP:vpMapPoints中的某个地图点
// observations:所有能观测到pMP的关键帧
// pKFi:observations中的某个关键帧
// scaleLeveli:pKFi的金字塔尺度
// scaleLevel:pKF的金字塔尺度
// Step 1:根据共视图提取当前关键帧的所有共视关键帧
vector<KeyFrame*> vpLocalKeyFrames = mpCurrentKeyFrame->GetVectorCovisibleKeyFrames();
// 对所有的共视关键帧进行遍历
for(vector<KeyFrame*>::iterator vit=vpLocalKeyFrames.begin(), vend=vpLocalKeyFrames.end(); vit!=vend; vit++)
{
KeyFrame* pKF = *vit;
// 第1个关键帧不能删除,跳过
if(pKF->mnId==0)
continue;
// Step 2:提取每个共视关键帧的地图点
const vector<MapPoint*> vpMapPoints = pKF->GetMapPointMatches();
// 记录某个点被观测次数,后面并未使用
int nObs = 3;
// 观测次数阈值,默认为3
const int thObs=nObs;
// 记录冗余观测点的数目
int nRedundantObservations=0;
int nMPs=0;
// Step 3:遍历该共视关键帧的所有地图点,其中能被其它至少3个关键帧观测到的地图点为冗余地图点
for(size_t i=0, iend=vpMapPoints.size(); i<iend; i++)
{
MapPoint* pMP = vpMapPoints[i];
if(pMP)
{
if(!pMP->isBad())
{
if(!mbMonocular)
{
// 对于双目或RGB-D,仅考虑近处(不超过基线的40倍 )的地图点
if(pKF->mvDepth[i]>pKF->mThDepth || pKF->mvDepth[i]<0)
continue;
}
nMPs++;
// pMP->Observations() 是观测到该地图点的相机总数目(单目1,双目2)
if(pMP->Observations()>thObs)
{
const int &scaleLevel = pKF->mvKeysUn[i].octave;
// Observation存储的是可以看到该地图点的所有关键帧的集合
const map<KeyFrame*, size_t> observations = pMP->GetObservations();
int nObs=0;
// 遍历观测到该地图点的关键帧
for(map<KeyFrame*, size_t>::const_iterator mit=observations.begin(), mend=observations.end(); mit!=mend; mit++)
{
KeyFrame* pKFi = mit->first;
if(pKFi==pKF)
continue;
const int &scaleLeveli = pKFi->mvKeysUn[mit->second].octave;
// 尺度约束:为什么pKF 尺度+1 要大于等于 pKFi 尺度?
// 回答:因为同样或更低金字塔层级的地图点更准确
if(scaleLeveli<=scaleLevel+1)
{
nObs++;
// 已经找到3个满足条件的关键帧,就停止不找了
if(nObs>=thObs)
break;
}
}
// 地图点至少被3个关键帧观测到,就记录为冗余点,更新冗余点计数数目
if(nObs>=thObs)
{
nRedundantObservations++;
}
}
}
}
}
// Step 4:如果该关键帧90%以上的有效地图点被判断为冗余的,则认为该关键帧是冗余的,需要删除该关键帧
if(nRedundantObservations>0.9*nMPs)
pKF->SetBadFlag();
}
}
9. ORB-SLAM2代码详解09_闭环线程LoopClosing
9.1 各成员函数/变量
9.1.1 闭环主函数: Run()
// 线程主函数
void LocalMapping::Run()
{
// 标记状态,表示当前run函数正在运行,尚未结束
mbFinished = false;
// 主循环
while(1)
{
// Tracking will see that Local Mapping is busy
// Step 1 告诉Tracking,LocalMapping正处于繁忙状态,请不要给我发送关键帧打扰我
// LocalMapping线程处理的关键帧都是Tracking线程发来的
SetAcceptKeyFrames(false);
// Check if there are keyframes in the queue
// 等待处理的关键帧列表不为空
if(CheckNewKeyFrames())
{
// BoW conversion and insertion in Map
// Step 2 处理列表中的关键帧,包括计算BoW、更新观测、描述子、共视图,插入到地图等
ProcessNewKeyFrame();
// Check recent MapPoints
// Step 3 根据地图点的观测情况剔除质量不好的地图点
MapPointCulling();
// Triangulate new MapPoints
// Step 4 当前关键帧与相邻关键帧通过三角化产生新的地图点,使得跟踪更稳
CreateNewMapPoints();
// 已经处理完队列中的最后的一个关键帧
if(!CheckNewKeyFrames())
{
// Find more matches in neighbor keyframes and fuse point duplications
// Step 5 检查并融合当前关键帧与相邻关键帧帧(两级相邻)中重复的地图点
SearchInNeighbors();
}
// 终止BA的标志
mbAbortBA = false;
// 已经处理完队列中的最后的一个关键帧,并且闭环检测没有请求停止LocalMapping
if(!CheckNewKeyFrames() && !stopRequested())
{
// Local BA
// Step 6 当局部地图中的关键帧大于2个的时候进行局部地图的BA
if(mpMap->KeyFramesInMap()>2)
// 注意这里的第二个参数是按地址传递的,当这里的 mbAbortBA 状态发生变化时,能够及时执行/停止BA
Optimizer::LocalBundleAdjustment(mpCurrentKeyFrame,&mbAbortBA, mpMap);
// Check redundant local Keyframes
// Step 7 检测并剔除当前帧相邻的关键帧中冗余的关键帧
// 冗余的判定:该关键帧的90%的地图点可以被其它关键帧观测到
KeyFrameCulling();
}
// Step 8 将当前帧加入到闭环检测队列中
// 注意这里的关键帧被设置成为了bad的情况,这个需要注意
mpLoopCloser->InsertKeyFrame(mpCurrentKeyFrame);
}
else if(Stop()) // 当要终止当前线程的时候
{
// Safe area to stop
while(isStopped() && !CheckFinish())
{
// 如果还没有结束利索,那么等
// usleep(3000);
std::this_thread::sleep_for(std::chrono::milliseconds(3));
}
// 然后确定终止了就跳出这个线程的主循环
if(CheckFinish())
break;
}
// 查看是否有复位线程的请求
ResetIfRequested();
// Tracking will see that Local Mapping is not busy
SetAcceptKeyFrames(true);
// 如果当前线程已经结束了就跳出主循环
if(CheckFinish())
break;
//usleep(3000);
std::this_thread::sleep_for(std::chrono::milliseconds(3));
}
// 设置线程已经终止
SetFinish();
}
9.2 闭环检测: DetectLoop()
LoopClosing
类中定义类型ConsistentGroup
,表示关键帧组.
typedef pair<set<KeyFrame *>, int> ConsistentGroup
- 第一个元素表示一组共视关键帧.
- 第二个元素表示该关键帧组的连续长度.
所谓连续,指的是两个关键帧组中存在相同的关键帧.
成员函数/变量 | 访问控制 | 意义 |
---|---|---|
KeyFrame *mpCurrentKF | protected | 当前关键帧 |
KeyFrame *mpMatchedKF | protected | 当前关键帧的闭环匹配关键帧 |
std::vector mvConsistentGroups | protected | 前一关键帧的闭环候选关键帧组 |
vCurrentConsistentGroups | 局部变量 | 当前关键帧的闭环候选关键帧组 |
std::vector mvpEnoughConsistentCandidates | protected | 所有达到足够连续数的关键帧 |
闭环检测原理: 若连续4个关键帧都能在数据库中找到对应的闭环匹配关键帧组,且这些闭环匹配关键帧组间是连续的,则认为实现闭环,
具体来说,回环检测过程如下:
-
找到当前关键帧的闭环候选关键帧
vpCandidateKFs
:闭环候选关键帧取自于与当前关键帧具有相同的BOW向量但不存在直接连接的关键帧.
2 将闭环候选关键帧和其共视关键帧组合成为关键帧组vCurrentConsistentGroups
:
3 在当前关键组和之前的连续关键组间寻找连续关系.
若当前关键帧组在之前的连续关键帧组中找到连续关系,则当前的连续关键帧组的连续长度加1.
若当前关键帧组在之前的连续关键帧组中没能找到连续关系,则当前关键帧组的连续长度为0.
关键帧组的连续关系是指两个关键帧组间是否有关键帧同时存在于两关键帧组中.
若某关键帧组的连续长度达到3
,则认为该关键帧实现闭环.
/**
* @brief 闭环检测
*
* @return true 成功检测到闭环
* @return false 未检测到闭环
*/
bool LoopClosing::DetectLoop()
{
{
// Step 1 从队列中取出一个关键帧,作为当前检测闭环关键帧
unique_lock<mutex> lock(mMutexLoopQueue);
// 从队列头开始取,也就是先取早进来的关键帧
mpCurrentKF = mlpLoopKeyFrameQueue.front();
// 取出关键帧后从队列里弹出该关键帧
mlpLoopKeyFrameQueue.pop_front();
// Avoid that a keyframe can be erased while it is being process by this thread
// 设置当前关键帧不要在优化的过程中被删除
mpCurrentKF->SetNotErase();
}
//If the map contains less than 10 KF or less than 10 KF have passed from last loop detection
// Step 2:如果距离上次闭环没多久(小于10帧),或者map中关键帧总共还没有10帧,则不进行闭环检测
// 后者的体现是当mLastLoopKFid为0的时候
if(mpCurrentKF->mnId<mLastLoopKFid+10)
{
mpKeyFrameDB->add(mpCurrentKF);
mpCurrentKF->SetErase();
return false;
}
// Compute reference BoW similarity score
// This is the lowest score to a connected keyframe in the covisibility graph
// We will impose loop candidates to have a higher similarity than this
// Step 3:遍历当前回环关键帧所有连接(>15个共视地图点)关键帧,计算当前关键帧与每个共视关键的bow相似度得分,并得到最低得分minScore
const vector<KeyFrame*> vpConnectedKeyFrames = mpCurrentKF->GetVectorCovisibleKeyFrames();
const DBoW2::BowVector &CurrentBowVec = mpCurrentKF->mBowVec;
float minScore = 1;
for(size_t i=0; i<vpConnectedKeyFrames.size(); i++)
{
KeyFrame* pKF = vpConnectedKeyFrames[i];
if(pKF->isBad())
continue;
const DBoW2::BowVector &BowVec = pKF->mBowVec;
// 计算两个关键帧的相似度得分;得分越低,相似度越低
float score = mpORBVocabulary->score(CurrentBowVec, BowVec);
// 更新最低得分
if(score<minScore)
minScore = score;
}
// Query the database imposing the minimum score
// Step 4:在所有关键帧中找出闭环候选帧(注意不和当前帧连接)
// minScore的作用:认为和当前关键帧具有回环关系的关键帧,不应该低于当前关键帧的相邻关键帧的最低的相似度minScore
// 得到的这些关键帧,和当前关键帧具有较多的公共单词,并且相似度评分都挺高
vector<KeyFrame*> vpCandidateKFs = mpKeyFrameDB->DetectLoopCandidates(mpCurrentKF, minScore);
// If there are no loop candidates, just add new keyframe and return false
// 如果没有闭环候选帧,返回false
if(vpCandidateKFs.empty())
{
mpKeyFrameDB->add(mpCurrentKF);
mvConsistentGroups.clear();
mpCurrentKF->SetErase();
return false;
}
// For each loop candidate check consistency with previous loop candidates
// Each candidate expands a covisibility group (keyframes connected to the loop candidate in the covisibility graph)
// A group is consistent with a previous group if they share at least a keyframe
// We must detect a consistent loop in several consecutive keyframes to accept it
// Step 5:在候选帧中检测具有连续性的候选帧
// 1、每个候选帧将与自己相连的关键帧构成一个“子候选组spCandidateGroup”, vpCandidateKFs-->spCandidateGroup
// 2、检测“子候选组”中每一个关键帧是否存在于“连续组”,如果存在 nCurrentConsistency++,则将该“子候选组”放入“当前连续组vCurrentConsistentGroups”
// 3、如果nCurrentConsistency大于等于3,那么该”子候选组“代表的候选帧过关,进入mvpEnoughConsistentCandidates
// 相关的概念说明:(为方便理解,见视频里的图示)
// 组(group): 对于某个关键帧, 其和其具有共视关系的关键帧组成了一个"组";
// 子候选组(CandidateGroup): 对于某个候选的回环关键帧, 其和其具有共视关系的关键帧组成的一个"组";
// 连续(Consistent): 不同的组之间如果共同拥有一个及以上的关键帧,那么称这两个组之间具有连续关系
// 连续性(Consistency):称之为连续长度可能更合适,表示累计的连续的链的长度:A--B 为1, A--B--C--D 为3等;具体反映在数据类型 ConsistentGroup.second上
// 连续组(Consistent group): mvConsistentGroups存储了上次执行回环检测时, 新的被检测出来的具有连续性的多个组的集合.由于组之间的连续关系是个网状结构,因此可能存在
// 一个组因为和不同的连续组链都具有连续关系,而被添加两次的情况(当然连续性度量是不相同的)
// 连续组链:自造的称呼,类似于菊花链A--B--C--D这样形成了一条连续组链.对于这个例子中,由于可能E,F都和D有连续关系,因此连续组链会产生分叉;为了简化计算,连续组中将只会保存
// 最后形成连续关系的连续组们(见下面的连续组的更新)
// 子连续组: 上面的连续组中的一个组
// 连续组的初始值: 在遍历某个候选帧的过程中,如果该子候选组没有能够和任何一个上次的子连续组产生连续关系,那么就将添加自己组为连续组,并且连续性为0(相当于新开了一个连续链)
// 连续组的更新: 当前次回环检测过程中,所有被检测到和之前的连续组链有连续的关系的组,都将在对应的连续组链后面+1,这些子候选组(可能有重复,见上)都将会成为新的连续组;
// 换而言之连续组mvConsistentGroups中只保存连续组链中末尾的组
// 最终筛选后得到的闭环帧
mvpEnoughConsistentCandidates.clear();
// ConsistentGroup数据类型为pair<set<KeyFrame*>,int>
// ConsistentGroup.first对应每个“连续组”中的关键帧,ConsistentGroup.second为每个“连续组”的已连续几个的序号
vector<ConsistentGroup> vCurrentConsistentGroups;
// 这个下标是每个"子连续组"的下标,bool表示当前的候选组中是否有和该组相同的一个关键帧
vector<bool> vbConsistentGroup(mvConsistentGroups.size(),false);
// Step 5.1:遍历刚才得到的每一个候选关键帧
for(size_t i=0, iend=vpCandidateKFs.size(); i<iend; i++)
{
KeyFrame* pCandidateKF = vpCandidateKFs[i];
// Step 5.2:将自己以及与自己相连的关键帧构成一个“子候选组”
set<KeyFrame*> spCandidateGroup = pCandidateKF->GetConnectedKeyFrames();
// 把自己也加进去
spCandidateGroup.insert(pCandidateKF);
// 连续性达标的标志
bool bEnoughConsistent = false;
bool bConsistentForSomeGroup = false;
// Step 5.3:遍历前一次闭环检测到的连续组链
// 上一次闭环的连续组链 std::vector<ConsistentGroup> mvConsistentGroups
// 其中ConsistentGroup的定义:typedef pair<set<KeyFrame*>,int> ConsistentGroup
// 其中 ConsistentGroup.first对应每个“连续组”中的关键帧集合,ConsistentGroup.second为每个“连续组”的连续长度
for(size_t iG=0, iendG=mvConsistentGroups.size(); iG<iendG; iG++)
{
// 取出之前的一个子连续组中的关键帧集合
set<KeyFrame*> sPreviousGroup = mvConsistentGroups[iG].first;
// Step 5.4:遍历每个“子候选组”,检测子候选组中每一个关键帧在“子连续组”中是否存在
// 如果有一帧共同存在于“子候选组”与之前的“子连续组”,那么“子候选组”与该“子连续组”连续
bool bConsistent = false;
for(set<KeyFrame*>::iterator sit=spCandidateGroup.begin(), send=spCandidateGroup.end(); sit!=send;sit++)
{
if(sPreviousGroup.count(*sit))
{
// 如果存在,该“子候选组”与该“子连续组”相连
bConsistent=true;
// 该“子候选组”至少与一个”子连续组“相连,跳出循环
bConsistentForSomeGroup=true;
break;
}
}
if(bConsistent)
{
// Step 5.5:如果判定为连续,接下来判断是否达到连续的条件
// 取出和当前的候选组发生"连续"关系的子连续组的"已连续次数"
int nPreviousConsistency = mvConsistentGroups[iG].second;
// 将当前候选组连续长度在原子连续组的基础上 +1,
int nCurrentConsistency = nPreviousConsistency + 1;
// 如果上述连续关系还未记录到 vCurrentConsistentGroups,那么记录一下
// 注意这里spCandidateGroup 可能放置在vbConsistentGroup中其他索引(iG)下
if(!vbConsistentGroup[iG])
{
// 将该“子候选组”的该关键帧打上连续编号加入到“当前连续组”
ConsistentGroup cg = make_pair(spCandidateGroup,nCurrentConsistency);
// 放入本次闭环检测的连续组vCurrentConsistentGroups里
vCurrentConsistentGroups.push_back(cg);
//this avoid to include the same group more than once
// 标记一下,防止重复添加到同一个索引iG
// 但是spCandidateGroup可能重复添加到不同的索引iG对应的vbConsistentGroup 中
vbConsistentGroup[iG]=true;
}
// 如果连续长度满足要求,那么当前的这个候选关键帧是足够靠谱的
// 连续性阈值 mnCovisibilityConsistencyTh=3
// 足够连续的标记 bEnoughConsistent
if(nCurrentConsistency>=mnCovisibilityConsistencyTh && !bEnoughConsistent)
{
// 记录为达到连续条件了
mvpEnoughConsistentCandidates.push_back(pCandidateKF);
//this avoid to insert the same candidate more than once
// 标记一下,防止重复添加
bEnoughConsistent=true;
// ? 这里可以break掉结束当前for循环吗?
// 回答:不行。因为虽然pCandidateKF达到了连续性要求
// 但spCandidateGroup 还可以和mvConsistentGroups 中其他的子连续组进行连接
}
}
}
// If the group is not consistent with any previous group insert with consistency counter set to zero
// Step 5.6:如果该“子候选组”的所有关键帧都和上次闭环无关(不连续),vCurrentConsistentGroups 没有新添加连续关系
// 于是就把“子候选组”全部拷贝到 vCurrentConsistentGroups, 用于更新mvConsistentGroups,连续性计数器设为0
if(!bConsistentForSomeGroup)
{
ConsistentGroup cg = make_pair(spCandidateGroup,0);
vCurrentConsistentGroups.push_back(cg);
}
}// 遍历得到的初级的候选关键帧
// Update Covisibility Consistent Groups
// 更新连续组
mvConsistentGroups = vCurrentConsistentGroups;
// Add Current Keyframe to database
// 当前闭环检测的关键帧添加到关键帧数据库中
mpKeyFrameDB->add(mpCurrentKF);
if(mvpEnoughConsistentCandidates.empty())
{
// 未检测到闭环,返回false
mpCurrentKF->SetErase();
return false;
}
else
{
// 成功检测到闭环,返回true
return true;
}
// 多余的代码,执行不到
mpCurrentKF->SetErase();
return false;
}
当前关键帧的闭环候选关键帧取自于与当前关键帧具有相同BOW向量但不直接相连的关键帧.
/**
* @brief 在闭环检测中找到与该关键帧可能闭环的关键帧(注意不和当前帧连接)
* Step 1:找出和当前帧具有公共单词的所有关键帧,不包括与当前帧连接的关键帧
* Step 2:只和具有共同单词较多的(最大数目的80%以上)关键帧进行相似度计算
* Step 3:计算上述候选帧对应的共视关键帧组的总得分,只取最高组得分75%以上的组
* Step 4:得到上述组中分数最高的关键帧作为闭环候选关键帧
* @param[in] pKF 需要闭环检测的关键帧
* @param[in] minScore 候选闭环关键帧帧和当前关键帧的BoW相似度至少要大于minScore
* @return vector<KeyFrame*> 闭环候选关键帧
*/
vector<KeyFrame*> KeyFrameDatabase::DetectLoopCandidates(KeyFrame* pKF, float minScore)
{
// 取出与当前关键帧相连(>15个共视地图点)的所有关键帧,这些相连关键帧都是局部相连,在闭环检测的时候将被剔除
// 相连关键帧定义见 KeyFrame::UpdateConnections()
set<KeyFrame*> spConnectedKeyFrames = pKF->GetConnectedKeyFrames();
// 用于保存可能与当前关键帧形成闭环的候选帧(只要有相同的word,且不属于局部相连(共视)帧)
list<KeyFrame*> lKFsSharingWords;
// Search all keyframes that share a word with current keyframes
// Discard keyframes connected to the query keyframe
// Step 1:找出和当前帧具有公共单词的所有关键帧,不包括与当前帧连接的关键帧
{
unique_lock<mutex> lock(mMutex);
// words是检测图像是否匹配的枢纽,遍历该pKF的每一个word
// mBowVec 内部实际存储的是std::map<WordId, WordValue>
// WordId 和 WordValue 表示Word在叶子中的id 和权重
for(DBoW2::BowVector::const_iterator vit=pKF->mBowVec.begin(), vend=pKF->mBowVec.end(); vit != vend; vit++)
{
// 提取所有包含该word的KeyFrame
list<KeyFrame*> &lKFs = mvInvertedFile[vit->first];
// 然后对这些关键帧展开遍历
for(list<KeyFrame*>::iterator lit=lKFs.begin(), lend= lKFs.end(); lit!=lend; lit++)
{
KeyFrame* pKFi=*lit;
if(pKFi->mnLoopQuery!=pKF->mnId)
{
// 还没有标记为pKF的闭环候选帧
pKFi->mnLoopWords=0;
// 和当前关键帧共视的话不作为闭环候选帧
if(!spConnectedKeyFrames.count(pKFi))
{
// 没有共视就标记作为闭环候选关键帧,放到lKFsSharingWords里
pKFi->mnLoopQuery=pKF->mnId;
lKFsSharingWords.push_back(pKFi);
}
}
pKFi->mnLoopWords++;// 记录pKFi与pKF具有相同word的个数
}
}
}
// 如果没有关键帧和这个关键帧具有相同的单词,那么就返回空
if(lKFsSharingWords.empty())
return vector<KeyFrame*>();
list<pair<float,KeyFrame*> > lScoreAndMatch;
// Only compare against those keyframes that share enough words
// Step 2:统计上述所有闭环候选帧中与当前帧具有共同单词最多的单词数,用来决定相对阈值
int maxCommonWords=0;
for(list<KeyFrame*>::iterator lit=lKFsSharingWords.begin(), lend= lKFsSharingWords.end(); lit!=lend; lit++)
{
if((*lit)->mnLoopWords>maxCommonWords)
maxCommonWords=(*lit)->mnLoopWords;
}
// 确定最小公共单词数为最大公共单词数目的0.8倍
int minCommonWords = maxCommonWords*0.8f;
int nscores=0;
// Compute similarity score. Retain the matches whose score is higher than minScore
// Step 3:遍历上述所有闭环候选帧,挑选出共有单词数大于minCommonWords且单词匹配度大于minScore存入lScoreAndMatch
for(list<KeyFrame*>::iterator lit=lKFsSharingWords.begin(), lend= lKFsSharingWords.end(); lit!=lend; lit++)
{
KeyFrame* pKFi = *lit;
// pKF只和具有共同单词较多(大于minCommonWords)的关键帧进行比较
if(pKFi->mnLoopWords>minCommonWords)
{
nscores++;// 这个变量后面没有用到
// 用mBowVec来计算两者的相似度得分
float si = mpVoc->score(pKF->mBowVec,pKFi->mBowVec);
pKFi->mLoopScore = si;
if(si>=minScore)
lScoreAndMatch.push_back(make_pair(si,pKFi));
}
}
// 如果没有超过指定相似度阈值的,那么也就直接跳过去
if(lScoreAndMatch.empty())
return vector<KeyFrame*>();
list<pair<float,KeyFrame*> > lAccScoreAndMatch;
float bestAccScore = minScore;
// Lets now accumulate score by covisibility
// 单单计算当前帧和某一关键帧的相似性是不够的,这里将与关键帧相连(权值最高,共视程度最高)的前十个关键帧归为一组,计算累计得分
// Step 4:计算上述候选帧对应的共视关键帧组的总得分,得到最高组得分bestAccScore,并以此决定阈值minScoreToRetain
for(list<pair<float,KeyFrame*> >::iterator it=lScoreAndMatch.begin(), itend=lScoreAndMatch.end(); it!=itend; it++)
{
KeyFrame* pKFi = it->second;
vector<KeyFrame*> vpNeighs = pKFi->GetBestCovisibilityKeyFrames(10);
float bestScore = it->first; // 该组最高分数
float accScore = it->first; // 该组累计得分
KeyFrame* pBestKF = pKFi; // 该组最高分数对应的关键帧
// 遍历共视关键帧,累计得分
for(vector<KeyFrame*>::iterator vit=vpNeighs.begin(), vend=vpNeighs.end(); vit!=vend; vit++)
{
KeyFrame* pKF2 = *vit;
// 只有pKF2也在闭环候选帧中,且公共单词数超过最小要求,才能贡献分数
if(pKF2->mnLoopQuery==pKF->mnId && pKF2->mnLoopWords>minCommonWords)
{
accScore+=pKF2->mLoopScore;
// 统计得到组里分数最高的关键帧
if(pKF2->mLoopScore>bestScore)
{
pBestKF=pKF2;
bestScore = pKF2->mLoopScore;
}
}
}
lAccScoreAndMatch.push_back(make_pair(accScore,pBestKF));
// 记录所有组中组得分最高的组,用于确定相对阈值
if(accScore>bestAccScore)
bestAccScore=accScore;
}
// Return all those keyframes with a score higher than 0.75*bestScore
// 所有组中最高得分的0.75倍,作为最低阈值
float minScoreToRetain = 0.75f*bestAccScore;
set<KeyFrame*> spAlreadyAddedKF;
vector<KeyFrame*> vpLoopCandidates;
vpLoopCandidates.reserve(lAccScoreAndMatch.size());
// Step 5:只取组得分大于阈值的组,得到组中分数最高的关键帧们作为闭环候选关键帧
for(list<pair<float,KeyFrame*> >::iterator it=lAccScoreAndMatch.begin(), itend=lAccScoreAndMatch.end(); it!=itend; it++)
{
if(it->first>minScoreToRetain)
{
KeyFrame* pKFi = it->second;
// spAlreadyAddedKF 是为了防止重复添加
if(!spAlreadyAddedKF.count(pKFi))
{
vpLoopCandidates.push_back(pKFi);
spAlreadyAddedKF.insert(pKFi);
}
}
}
return vpLoopCandidates;
}
9.3 计算Sim3变换: ComputeSim3()
![请添加图片描述](https://i-blog.csdnimg.cn/blog_migrate/4660f3ba3c53607bcc825bde87f933a8.png)
成员函数/变量 | 访问控制 | 意义 |
---|---|---|
std::vector mvpEnoughConsistentCandidates | protected | 在函数LoopClosing::DetectLoop() 中找到的有足够连续性的闭环关键帧 |
g2o::Sim3 mg2oScw cv::Mat mScw | protected protected | 世界坐标系w 到相机坐标系c 的Sim3变换 |
std::vector mvpLoopMapPoints | protected | 闭环关键帧组中的地图点 |
std::vector mvpCurrentMatchedPoints | protected | 当前帧到mvpLoopMapPoints 的匹配关系 mvpCurrentMatchedPoints[i] 表示当前帧第i 个特征点对应的地图点 |
/**
* @brief 计算当前关键帧和上一步闭环候选帧的Sim3变换
* 1. 遍历闭环候选帧集,筛选出与当前帧的匹配特征点数大于20的候选帧集合,并为每一个候选帧构造一个Sim3Solver
* 2. 对每一个候选帧进行 Sim3Solver 迭代匹配,直到有一个候选帧匹配成功,或者全部失败
* 3. 取出闭环匹配上关键帧的相连关键帧,得到它们的地图点放入 mvpLoopMapPoints
* 4. 将闭环匹配上关键帧以及相连关键帧的地图点投影到当前关键帧进行投影匹配
* 5. 判断当前帧与检测出的所有闭环关键帧是否有足够多的地图点匹配
* 6. 清空mvpEnoughConsistentCandidates
* @return true 只要有一个候选关键帧通过Sim3的求解与优化,就返回true
* @return false 所有候选关键帧与当前关键帧都没有有效Sim3变换
*/
bool LoopClosing::ComputeSim3()
{
// Sim3 计算流程说明:
// 1. 通过Bow加速描述子的匹配,利用RANSAC粗略地计算出当前帧与闭环帧的Sim3(当前帧---闭环帧)
// 2. 根据估计的Sim3,对3D点进行投影找到更多匹配,通过优化的方法计算更精确的Sim3(当前帧---闭环帧)
// 3. 将闭环帧以及闭环帧相连的关键帧的地图点与当前帧的点进行匹配(当前帧---闭环帧+相连关键帧)
// 注意以上匹配的结果均都存在成员变量mvpCurrentMatchedPoints中,实际的更新步骤见CorrectLoop()步骤3
// 对于双目或者是RGBD输入的情况,计算得到的尺度=1
// 准备工作
// For each consistent loop candidate we try to compute a Sim3
// 对每个(上一步得到的具有足够连续关系的)闭环候选帧都准备算一个Sim3
const int nInitialCandidates = mvpEnoughConsistentCandidates.size();
// We compute first ORB matches for each candidate
// If enough matches are found, we setup a Sim3Solver
ORBmatcher matcher(0.75,true);
// 存储每一个候选帧的Sim3Solver求解器
vector<Sim3Solver*> vpSim3Solvers;
vpSim3Solvers.resize(nInitialCandidates);
// 存储每个候选帧的匹配地图点信息
vector<vector<MapPoint*> > vvpMapPointMatches;
vvpMapPointMatches.resize(nInitialCandidates);
// 存储每个候选帧应该被放弃(True)或者 保留(False)
vector<bool> vbDiscarded;
vbDiscarded.resize(nInitialCandidates);
// 完成 Step 1 的匹配后,被保留的候选帧数量
int nCandidates=0;
// Step 1. 遍历闭环候选帧集,初步筛选出与当前关键帧的匹配特征点数大于20的候选帧集合,并为每一个候选帧构造一个Sim3Solver
for(int i=0; i<nInitialCandidates; i++)
{
// Step 1.1 从筛选的闭环候选帧中取出一帧有效关键帧pKF
KeyFrame* pKF = mvpEnoughConsistentCandidates[i];
// 避免在LocalMapping中KeyFrameCulling函数将此关键帧作为冗余帧剔除
pKF->SetNotErase();
// 如果候选帧质量不高,直接PASS
if(pKF->isBad())
{
vbDiscarded[i] = true;
continue;
}
// Step 1.2 将当前帧 mpCurrentKF 与闭环候选关键帧pKF匹配
// 通过bow加速得到 mpCurrentKF 与 pKF 之间的匹配特征点
// vvpMapPointMatches 是匹配特征点对应的地图点,本质上来自于候选闭环帧
int nmatches = matcher.SearchByBoW(mpCurrentKF,pKF,vvpMapPointMatches[i]);
// 粗筛:匹配的特征点数太少,该候选帧剔除
if(nmatches<20)
{
vbDiscarded[i] = true;
continue;
}
else
{
// Step 1.3 为保留的候选帧构造Sim3求解器
// 如果 mbFixScale(是否固定尺度) 为 true,则是6 自由度优化(双目 RGBD)
// 如果是false,则是7 自由度优化(单目)
Sim3Solver* pSolver = new Sim3Solver(mpCurrentKF,pKF,vvpMapPointMatches[i],mbFixScale);
// Sim3Solver Ransac 过程置信度0.99,至少20个inliers 最多300次迭代
pSolver->SetRansacParameters(0.99,20,300);
vpSim3Solvers[i] = pSolver;
}
// 保留的候选帧数量
nCandidates++;
}
// 用于标记是否有一个候选帧通过Sim3Solver的求解与优化
bool bMatch = false;
// Step 2 对每一个候选帧用Sim3Solver 迭代匹配,直到有一个候选帧匹配成功,或者全部失败
while(nCandidates>0 && !bMatch)
{
// 遍历每一个候选帧
for(int i=0; i<nInitialCandidates; i++)
{
if(vbDiscarded[i])
continue;
KeyFrame* pKF = mvpEnoughConsistentCandidates[i];
// 内点(Inliers)标志
// 即标记经过RANSAC sim3 求解后,vvpMapPointMatches中的哪些作为内点
vector<bool> vbInliers;
// 内点(Inliers)数量
int nInliers;
// 是否到达了最优解
bool bNoMore;
// Step 2.1 取出从 Step 1.3 中为当前候选帧构建的 Sim3Solver 并开始迭代
Sim3Solver* pSolver = vpSim3Solvers[i];
// 最多迭代5次,返回的Scm是候选帧pKF到当前帧mpCurrentKF的Sim3变换(T12)
cv::Mat Scm = pSolver->iterate(5,bNoMore,vbInliers,nInliers);
// If Ransac reachs max. iterations discard keyframe
// 总迭代次数达到最大限制还没有求出合格的Sim3变换,该候选帧剔除
if(bNoMore)
{
vbDiscarded[i]=true;
nCandidates--;
}
// If RANSAC returns a Sim3, perform a guided matching and optimize with all correspondences
// 如果计算出了Sim3变换,继续匹配出更多点并优化。因为之前 SearchByBoW 匹配可能会有遗漏
if(!Scm.empty())
{
// 取出经过Sim3Solver 后匹配点中的内点集合
vector<MapPoint*> vpMapPointMatches(vvpMapPointMatches[i].size(), static_cast<MapPoint*>(NULL));
for(size_t j=0, jend=vbInliers.size(); j<jend; j++)
{
// 保存内点
if(vbInliers[j])
vpMapPointMatches[j]=vvpMapPointMatches[i][j];
}
// Step 2.2 通过上面求取的Sim3变换引导关键帧匹配,弥补Step 1中的漏匹配
// 候选帧pKF到当前帧mpCurrentKF的R(R12),t(t12),变换尺度s(s12)
cv::Mat R = pSolver->GetEstimatedRotation();
cv::Mat t = pSolver->GetEstimatedTranslation();
const float s = pSolver->GetEstimatedScale();
// 查找更多的匹配(成功的闭环匹配需要满足足够多的匹配特征点数,之前使用SearchByBoW进行特征点匹配时会有漏匹配)
// 通过Sim3变换,投影搜索pKF1的特征点在pKF2中的匹配,同理,投影搜索pKF2的特征点在pKF1中的匹配
// 只有互相都成功匹配的才认为是可靠的匹配
matcher.SearchBySim3(mpCurrentKF,pKF,vpMapPointMatches,s,R,t,7.5);
// Step 2.3 用新的匹配来优化 Sim3,只要有一个候选帧通过Sim3的求解与优化,就跳出停止对其它候选帧的判断
// OpenCV的Mat矩阵转成Eigen的Matrix类型
// gScm:候选关键帧到当前帧的Sim3变换
g2o::Sim3 gScm(Converter::toMatrix3d(R),Converter::toVector3d(t),s);
// 如果mbFixScale为true,则是6 自由度优化(双目 RGBD),如果是false,则是7 自由度优化(单目)
// 优化mpCurrentKF与pKF对应的MapPoints间的Sim3,得到优化后的量gScm
const int nInliers = Optimizer::OptimizeSim3(mpCurrentKF, pKF, vpMapPointMatches, gScm, 10, mbFixScale);
// 如果优化成功,则停止while循环遍历闭环候选
if(nInliers>=20)
{
// 为True时将不再进入 while循环
bMatch = true;
// mpMatchedKF就是最终闭环检测出来与当前帧形成闭环的关键帧
mpMatchedKF = pKF;
// gSmw:从世界坐标系 w 到该候选帧 m 的Sim3变换,都在一个坐标系下,所以尺度 Scale=1
g2o::Sim3 gSmw(Converter::toMatrix3d(pKF->GetRotation()),Converter::toVector3d(pKF->GetTranslation()),1.0);
// 得到g2o优化后从世界坐标系到当前帧的Sim3变换
mg2oScw = gScm*gSmw;
mScw = Converter::toCvMat(mg2oScw);
mvpCurrentMatchedPoints = vpMapPointMatches;
// 只要有一个候选帧通过Sim3的求解与优化,就跳出停止对其它候选帧的判断
break;
}
}
}
}
// 退出上面while循环的原因有两种,一种是求解到了bMatch置位后出的,另外一种是nCandidates耗尽为0
if(!bMatch)
{
// 如果没有一个闭环匹配候选帧通过Sim3的求解与优化
// 清空mvpEnoughConsistentCandidates,这些候选关键帧以后都不会在再参加回环检测过程了
for(int i=0; i<nInitialCandidates; i++)
mvpEnoughConsistentCandidates[i]->SetErase();
// 当前关键帧也将不会再参加回环检测了
mpCurrentKF->SetErase();
// Sim3 计算失败,退出了
return false;
}
// Step 3:取出与当前帧闭环匹配上的关键帧及其共视关键帧,以及这些共视关键帧的地图点
// 注意是闭环检测出来与当前帧形成闭环的关键帧 mpMatchedKF
// 将mpMatchedKF共视的关键帧全部取出来放入 vpLoopConnectedKFs
// 将vpLoopConnectedKFs的地图点取出来放入mvpLoopMapPoints
vector<KeyFrame*> vpLoopConnectedKFs = mpMatchedKF->GetVectorCovisibleKeyFrames();
// 包含闭环匹配关键帧本身,形成一个“闭环关键帧小组“
vpLoopConnectedKFs.push_back(mpMatchedKF);
mvpLoopMapPoints.clear();
// 遍历这个组中的每一个关键帧
for(vector<KeyFrame*>::iterator vit=vpLoopConnectedKFs.begin(); vit!=vpLoopConnectedKFs.end(); vit++)
{
KeyFrame* pKF = *vit;
vector<MapPoint*> vpMapPoints = pKF->GetMapPointMatches();
// 遍历其中一个关键帧的所有有效地图点
for(size_t i=0, iend=vpMapPoints.size(); i<iend; i++)
{
MapPoint* pMP = vpMapPoints[i];
if(pMP)
{
// mnLoopPointForKF 用于标记,避免重复添加
if(!pMP->isBad() && pMP->mnLoopPointForKF!=mpCurrentKF->mnId)
{
mvpLoopMapPoints.push_back(pMP);
// 标记一下
pMP->mnLoopPointForKF=mpCurrentKF->mnId;
}
}
}
}
// Find more matches projecting with the computed Sim3
// Step 4:将闭环关键帧及其连接关键帧的所有地图点投影到当前关键帧进行投影匹配
// 根据投影查找更多的匹配(成功的闭环匹配需要满足足够多的匹配特征点数)
// 根据Sim3变换,将每个mvpLoopMapPoints投影到mpCurrentKF上,搜索新的匹配对
// mvpCurrentMatchedPoints是前面经过SearchBySim3得到的已经匹配的点对,这里就忽略不再匹配了
// 搜索范围系数为10
matcher.SearchByProjection(mpCurrentKF, mScw, mvpLoopMapPoints, mvpCurrentMatchedPoints,10);
// If enough matches accept Loop
// Step 5: 统计当前帧与闭环关键帧的匹配地图点数目,超过40个说明成功闭环,否则失败
int nTotalMatches = 0;
for(size_t i=0; i<mvpCurrentMatchedPoints.size(); i++)
{
if(mvpCurrentMatchedPoints[i])
nTotalMatches++;
}
if(nTotalMatches>=40)
{
// 如果当前回环可靠,保留当前待闭环关键帧,其他闭环候选全部删掉以后不用了
for(int i=0; i<nInitialCandidates; i++)
if(mvpEnoughConsistentCandidates[i]!=mpMatchedKF)
mvpEnoughConsistentCandidates[i]->SetErase();
return true;
}
else
{
// 闭环不可靠,闭环候选及当前待闭环帧全部删除
for(int i=0; i<nInitialCandidates; i++)
mvpEnoughConsistentCandidates[i]->SetErase();
mpCurrentKF->SetErase();
return false;
}
}
9.4 闭环矫正: CorrectLoop()
函数LoopClosing::CorrectLoop()的主要流程:
Sim3位姿传播:
将Sim3位姿传播到局部关键帧组上.
将Sim3位姿传播到局部地图点上.
地图点融合:
将闭环关键帧组地图点投影到当前关键帧上.
将闭环关键帧组地图点投影到局部关键帧组上.
BA优化
本质图BA优化: 优化所有地图点和关键帧位姿,基于本质图.
全局BA优化: 优化所有地图点和关键帧位姿,基于地图点到关键帧的投影关系.
/**
* @brief 闭环矫正
* 1. 通过求解的Sim3以及相对姿态关系,调整与当前帧相连的关键帧位姿以及这些关键帧观测到的地图点位置(相连关键帧---当前帧)
* 2. 将闭环帧以及闭环帧相连的关键帧的地图点和与当前帧相连的关键帧的点进行匹配(当前帧+相连关键帧---闭环帧+相连关键帧)
* 3. 通过MapPoints的匹配关系更新这些帧之间的连接关系,即更新covisibility graph
* 4. 对Essential Graph(Pose Graph)进行优化,MapPoints的位置则根据优化后的位姿做相对应的调整
* 5. 创建线程进行全局Bundle Adjustment
*/
void LoopClosing::CorrectLoop()
{
cout << "Loop detected!" << endl;
// Step 0:结束局部地图线程、全局BA,为闭环矫正做准备
// Step 1:根据共视关系更新当前帧与其它关键帧之间的连接
// Step 2:通过位姿传播,得到Sim3优化后,与当前帧相连的关键帧的位姿,以及它们的MapPoints
// Step 3:检查当前帧的MapPoints与闭环匹配帧的MapPoints是否存在冲突,对冲突的MapPoints进行替换或填补
// Step 4:通过将闭环时相连关键帧的mvpLoopMapPoints投影到这些关键帧中,进行MapPoints检查与替换
// Step 5:更新当前关键帧之间的共视相连关系,得到因闭环时MapPoints融合而新得到的连接关系
// Step 6:进行EssentialGraph优化,LoopConnections是形成闭环后新生成的连接关系,不包括步骤7中当前帧与闭环匹配帧之间的连接关系
// Step 7:添加当前帧与闭环匹配帧之间的边(这个连接关系不优化)
// Step 8:新建一个线程用于全局BA优化
// g2oSic: 当前关键帧 mpCurrentKF 到其共视关键帧 pKFi 的Sim3 相对变换
// mg2oScw: 世界坐标系到当前关键帧的 Sim3 变换
// g2oCorrectedSiw:世界坐标系到当前关键帧共视关键帧的Sim3 变换
// Send a stop signal to Local Mapping
// Avoid new keyframes are inserted while correcting the loop
// Step 0:结束局部地图线程、全局BA,为闭环矫正做准备
// 请求局部地图停止,防止在回环矫正时局部地图线程中InsertKeyFrame函数插入新的关键帧
mpLocalMapper->RequestStop();
if(isRunningGBA())
{
// 如果有全局BA在运行,终止掉,迎接新的全局BA
unique_lock<mutex> lock(mMutexGBA);
mbStopGBA = true;
// 记录全局BA次数
mnFullBAIdx++;
if(mpThreadGBA)
{
// 停止全局BA线程
mpThreadGBA->detach();
delete mpThreadGBA;
}
}
// Wait until Local Mapping has effectively stopped
// 一直等到局部地图线程结束再继续
while(!mpLocalMapper->isStopped())
{
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
// Ensure current keyframe is updated
// Step 1:根据共视关系更新当前关键帧与其它关键帧之间的连接关系
// 因为之前闭环检测、计算Sim3中改变了该关键帧的地图点,所以需要更新
mpCurrentKF->UpdateConnections();
// Retrive keyframes connected to the current keyframe and compute corrected Sim3 pose by propagation
// Step 2:通过位姿传播,得到Sim3优化后,与当前帧相连的关键帧的位姿,以及它们的地图点
// 当前帧与世界坐标系之间的Sim变换在ComputeSim3函数中已经确定并优化,
// 通过相对位姿关系,可以确定这些相连的关键帧与世界坐标系之间的Sim3变换
// 取出当前关键帧及其共视关键帧,称为“当前关键帧组”
mvpCurrentConnectedKFs = mpCurrentKF->GetVectorCovisibleKeyFrames();
mvpCurrentConnectedKFs.push_back(mpCurrentKF);
// CorrectedSim3:存放闭环g2o优化后当前关键帧的共视关键帧的世界坐标系下Sim3 变换
// NonCorrectedSim3:存放没有矫正的当前关键帧的共视关键帧的世界坐标系下Sim3 变换
KeyFrameAndPose CorrectedSim3, NonCorrectedSim3;
// 先将mpCurrentKF的Sim3变换存入,认为是准的,所以固定不动
CorrectedSim3[mpCurrentKF]=mg2oScw;
// 当前关键帧到世界坐标系下的变换矩阵
cv::Mat Twc = mpCurrentKF->GetPoseInverse();
// 对地图点操作
{
// Get Map Mutex
// 锁定地图点
unique_lock<mutex> lock(mpMap->mMutexMapUpdate);
// Step 2.1:通过mg2oScw(认为是准的)来进行位姿传播,得到当前关键帧的共视关键帧的世界坐标系下Sim3 位姿
// 遍历"当前关键帧组""
for(vector<KeyFrame*>::iterator vit=mvpCurrentConnectedKFs.begin(), vend=mvpCurrentConnectedKFs.end(); vit!=vend; vit++)
{
KeyFrame* pKFi = *vit;
cv::Mat Tiw = pKFi->GetPose();
if(pKFi!=mpCurrentKF) //跳过当前关键帧,因为当前关键帧的位姿已经在前面优化过了,在这里是参考基准
{
// 得到当前关键帧 mpCurrentKF 到其共视关键帧 pKFi 的相对变换
cv::Mat Tic = Tiw*Twc;
cv::Mat Ric = Tic.rowRange(0,3).colRange(0,3);
cv::Mat tic = Tic.rowRange(0,3).col(3);
// g2oSic:当前关键帧 mpCurrentKF 到其共视关键帧 pKFi 的Sim3 相对变换
// 这里是non-correct, 所以scale=1.0
g2o::Sim3 g2oSic(Converter::toMatrix3d(Ric),Converter::toVector3d(tic),1.0);
// 当前帧的位姿固定不动,其它的关键帧根据相对关系得到Sim3调整的位姿
g2o::Sim3 g2oCorrectedSiw = g2oSic*mg2oScw;
// Pose corrected with the Sim3 of the loop closure
// 存放闭环g2o优化后当前关键帧的共视关键帧的Sim3 位姿
CorrectedSim3[pKFi]=g2oCorrectedSiw;
}
cv::Mat Riw = Tiw.rowRange(0,3).colRange(0,3);
cv::Mat tiw = Tiw.rowRange(0,3).col(3);
g2o::Sim3 g2oSiw(Converter::toMatrix3d(Riw),Converter::toVector3d(tiw),1.0);
// Pose without correction
// 存放没有矫正的当前关键帧的共视关键帧的Sim3变换
NonCorrectedSim3[pKFi]=g2oSiw;
}
// Correct all MapPoints obsrved by current keyframe and neighbors, so that they align with the other side of the loop
// Step 2.2:得到矫正的当前关键帧的共视关键帧位姿后,修正这些共视关键帧的地图点
// 遍历待矫正的共视关键帧(不包括当前关键帧)
for(KeyFrameAndPose::iterator mit=CorrectedSim3.begin(), mend=CorrectedSim3.end(); mit!=mend; mit++)
{
// 取出当前关键帧连接关键帧
KeyFrame* pKFi = mit->first;
// 取出经过位姿传播后的Sim3变换
g2o::Sim3 g2oCorrectedSiw = mit->second;
g2o::Sim3 g2oCorrectedSwi = g2oCorrectedSiw.inverse();
// 取出未经过位姿传播的Sim3变换
g2o::Sim3 g2oSiw =NonCorrectedSim3[pKFi];
vector<MapPoint*> vpMPsi = pKFi->GetMapPointMatches();
// 遍历待矫正共视关键帧中的每一个地图点
for(size_t iMP=0, endMPi = vpMPsi.size(); iMP<endMPi; iMP++)
{
MapPoint* pMPi = vpMPsi[iMP];
// 跳过无效的地图点
if(!pMPi)
continue;
if(pMPi->isBad())
continue;
// 标记,防止重复矫正
if(pMPi->mnCorrectedByKF==mpCurrentKF->mnId)
continue;
// 矫正过程本质上也是基于当前关键帧的优化后的位姿展开的
// Project with non-corrected pose and project back with corrected pose
// 将该未校正的eigP3Dw先从世界坐标系映射到未校正的pKFi相机坐标系,然后再反映射到校正后的世界坐标系下
cv::Mat P3Dw = pMPi->GetWorldPos();
// 地图点世界坐标系下坐标
Eigen::Matrix<double,3,1> eigP3Dw = Converter::toVector3d(P3Dw);
// map(P) 内部做了相似变换 s*R*P +t
// 下面变换是:eigP3Dw: world →g2oSiw→ i →g2oCorrectedSwi→ world
Eigen::Matrix<double,3,1> eigCorrectedP3Dw = g2oCorrectedSwi.map(g2oSiw.map(eigP3Dw));
cv::Mat cvCorrectedP3Dw = Converter::toCvMat(eigCorrectedP3Dw);
pMPi->SetWorldPos(cvCorrectedP3Dw);
// 记录矫正该地图点的关键帧id,防止重复
pMPi->mnCorrectedByKF = mpCurrentKF->mnId;
// 记录该地图点所在的关键帧id
pMPi->mnCorrectedReference = pKFi->mnId;
// 因为地图点更新了,需要更新其平均观测方向以及观测距离范围
pMPi->UpdateNormalAndDepth();
}
// Update keyframe pose with corrected Sim3. First transform Sim3 to SE3 (scale translation)
// Step 2.3:将共视关键帧的Sim3转换为SE3,根据更新的Sim3,更新关键帧的位姿
// 其实是现在已经有了更新后的关键帧组中关键帧的位姿,但是在上面的操作时只是暂时存储到了 KeyFrameAndPose 类型的变量中,还没有写回到关键帧对象中
// 调用toRotationMatrix 可以自动归一化旋转矩阵
Eigen::Matrix3d eigR = g2oCorrectedSiw.rotation().toRotationMatrix();
Eigen::Vector3d eigt = g2oCorrectedSiw.translation();
double s = g2oCorrectedSiw.scale();
// 平移向量中包含有尺度信息,还需要用尺度归一化
eigt *=(1./s);
cv::Mat correctedTiw = Converter::toCvSE3(eigR,eigt);
// 设置矫正后的新的pose
pKFi->SetPose(correctedTiw);
// Make sure connections are updated
// Step 2.4:根据共视关系更新当前帧与其它关键帧之间的连接
// 地图点的位置改变了,可能会引起共视关系\权值的改变
pKFi->UpdateConnections();
}
// Start Loop Fusion
// Update matched map points and replace if duplicated
// Step 3:检查当前帧的地图点与经过闭环匹配后该帧的地图点是否存在冲突,对冲突的进行替换或填补
// mvpCurrentMatchedPoints 是当前关键帧和闭环关键帧组的所有地图点进行投影得到的匹配点
for(size_t i=0; i<mvpCurrentMatchedPoints.size(); i++)
{
if(mvpCurrentMatchedPoints[i])
{
//取出同一个索引对应的两种地图点,决定是否要替换
// 匹配投影得到的地图点
MapPoint* pLoopMP = mvpCurrentMatchedPoints[i];
// 原来的地图点
MapPoint* pCurMP = mpCurrentKF->GetMapPoint(i);
if(pCurMP)
// 如果有重复的MapPoint,则用匹配的地图点代替现有的
// 因为匹配的地图点是经过一系列操作后比较精确的,现有的地图点很可能有累计误差
pCurMP->Replace(pLoopMP);
else
{
// 如果当前帧没有该MapPoint,则直接添加
mpCurrentKF->AddMapPoint(pLoopMP,i);
pLoopMP->AddObservation(mpCurrentKF,i);
pLoopMP->ComputeDistinctiveDescriptors();
}
}
}
}
// Project MapPoints observed in the neighborhood of the loop keyframe
// into the current keyframe and neighbors using corrected poses.
// Fuse duplications.
// Step 4:将闭环相连关键帧组mvpLoopMapPoints 投影到当前关键帧组中,进行匹配,融合,新增或替换当前关键帧组中KF的地图点
// 因为 闭环相连关键帧组mvpLoopMapPoints 在地图中时间比较久经历了多次优化,认为是准确的
// 而当前关键帧组中的关键帧的地图点是最近新计算的,可能有累积误差
// CorrectedSim3:存放矫正后当前关键帧的共视关键帧,及其世界坐标系下Sim3 变换
SearchAndFuse(CorrectedSim3);
// After the MapPoint fusion, new links in the covisibility graph will appear attaching both sides of the loop
// Step 5:更新当前关键帧组之间的两级共视相连关系,得到因闭环时地图点融合而新得到的连接关系
// LoopConnections:存储因为闭环地图点调整而新生成的连接关系
map<KeyFrame*, set<KeyFrame*> > LoopConnections;
// Step 5.1:遍历当前帧相连关键帧组(一级相连)
for(vector<KeyFrame*>::iterator vit=mvpCurrentConnectedKFs.begin(), vend=mvpCurrentConnectedKFs.end(); vit!=vend; vit++)
{
KeyFrame* pKFi = *vit;
// Step 5.2:得到与当前帧相连关键帧的相连关键帧(二级相连)
vector<KeyFrame*> vpPreviousNeighbors = pKFi->GetVectorCovisibleKeyFrames();
// Update connections. Detect new links.
// Step 5.3:更新一级相连关键帧的连接关系(会把当前关键帧添加进去,因为地图点已经更新和替换了)
pKFi->UpdateConnections();
// Step 5.4:取出该帧更新后的连接关系
LoopConnections[pKFi]=pKFi->GetConnectedKeyFrames();
// Step 5.5:从连接关系中去除闭环之前的二级连接关系,剩下的连接就是由闭环得到的连接关系
for(vector<KeyFrame*>::iterator vit_prev=vpPreviousNeighbors.begin(), vend_prev=vpPreviousNeighbors.end(); vit_prev!=vend_prev; vit_prev++)
{
LoopConnections[pKFi].erase(*vit_prev);
}
// Step 5.6:从连接关系中去除闭环之前的一级连接关系,剩下的连接就是由闭环得到的连接关系
for(vector<KeyFrame*>::iterator vit2=mvpCurrentConnectedKFs.begin(), vend2=mvpCurrentConnectedKFs.end(); vit2!=vend2; vit2++)
{
LoopConnections[pKFi].erase(*vit2);
}
}
// Optimize graph
// Step 6:进行本质图优化,优化本质图中所有关键帧的位姿和地图点
// LoopConnections是形成闭环后新生成的连接关系,不包括步骤7中当前帧与闭环匹配帧之间的连接关系
Optimizer::OptimizeEssentialGraph(mpMap, mpMatchedKF, mpCurrentKF, NonCorrectedSim3, CorrectedSim3, LoopConnections, mbFixScale);
// Add loop edge
// Step 7:添加当前帧与闭环匹配帧之间的边(这个连接关系不优化)
// 它在下一次的本质图优化里面使用
mpMatchedKF->AddLoopEdge(mpCurrentKF);
mpCurrentKF->AddLoopEdge(mpMatchedKF);
// Launch a new thread to perform Global Bundle Adjustment
// Step 8:新建一个线程用于全局BA优化
// OptimizeEssentialGraph只是优化了一些主要关键帧的位姿,这里进行全局BA可以全局优化所有位姿和MapPoints
mbRunningGBA = true;
mbFinishedGBA = false;
mbStopGBA = false;
mpThreadGBA = new thread(&LoopClosing::RunGlobalBundleAdjustment,this,mpCurrentKF->mnId);
// Loop closed. Release Local Mapping.
mpLocalMapper->Release();
cout << "Loop Closed!" << endl;
mLastLoopKFid = mpCurrentKF->mnId;
}
10. ORB-SLAM2代码详解十大trick
10.1. 关键帧与关键点的删除
由于关键帧与关键点的作用各个地方都要用到,所以对关键帧和地图点的操作就属于一种“重操作”,时间消耗很严重,这里作者采用了很巧妙的办法来处理,以防止系统卡顿或者加锁计算对系统的数据资源的共享。总的来说是采用一种先标记后处理的方式,标志该地图点或者关键帧社会性死亡。当然这带来的副作用就是每一个地图点或者关键帧都多了一个状态值,每次访问之前都需要判断关键帧或者地图点的状态以判断该地图点或者关键帧的状态还正常。
10.2 ORB特征点提取过程中的超像素处理
首先作者通过很巧妙的方法(极大值抑制,分区处理,递归等方法)获取了均匀分布的特征点。随后在极线方向进行搜索,获取对应的匹配点。接着在对应的匹配点的位置左右俩个侧,取三个点,根据极限上三个点的位置拟合出一条高斯曲线,获取拟合的高斯曲线的最值所在的点,也即最终的匹配点在极限上的坐标