ORB-SLAM3 单目地图初始化(终结篇)
干货第一时间送达
一、前言
二、CreateInitialMapMonocular 函数的总体流程
1. 将初始关键帧,当前关键帧的描述子转为BoW;
2. 将关键帧插入到地图;
3. 用三角测量初始化得到的3D点来生成地图点,更新关键帧间的连接关系;
4. 全局BA优化,同时优化所有位姿和三维点;
5. 取场景的中值深度,用于尺度归一化;
6. 将两帧之间的变换归一化到平均深度1的尺度下;
7. 把3D点的尺度也归一化到1;
8. 将关键帧插入局部地图,更新归一化后的位姿、局部地图点。
三、必备知识
1. 为什么单目需要专门策略生成初始化地图
1) 单目SLAM系统需要设计专门的策略来生成初始化地图,这也是为什么代码中单独设计一个CreateInitialMapMonocular()函数来实现单目初始化,也是我们这篇文章要讨论的。为什么要单独设计呢?就是因为单目没有深度信息。
2) 怎么解决单目没有深度信息问题?有2种,论文用的是第二种,用一个具有高不确定度的逆深度参数来初始化点的深度信息,该参数会在后期逐渐收敛到真值。
3) 说了ORB-SLAM为什么要同时计算基础矩阵F和单应矩阵H的原因:这两种摄像头位姿重构方法在低视差下都没有很好的约束,所以提出了一个新的基于模型选择的自动初始化方法,对平面场景算法选择单应性矩阵,而对于非平面场景,算法选择基础矩阵。
4)说了ORB-SLAM初始化容易失败的原因:(条件比较苛刻)在平面的情况下,为了保险起见,如果最终存在双重歧义,则算法避免进行初始化,因为可能会因为错误选择而导致算法崩溃。因此,我们会延迟初始化过程,直到所选的模型在明显的视差下产生唯一的解。
2. 共视图 Covisibility Graph
2.1 共视图定义
2.2 共视图作用
2.2.1 跟踪局部地图,扩大搜索范围
2.2.2 局部建图里关键帧之间新建地图点
2.2.3 闭环检测、重定位检测
2.2.4 优化
3. 地图点 MapPoint 和关键帧 KeyFrame
4. 图优化 Graph SLAM
5. g2o使用方法
5.1创建一个线性求解器LinearSolver
5.2创建BlockSolver,并用上面定义的线性求解器LinearSolver初始化
5.3创建总求解器solver,并从GN, LM, DogLeg 中选一个,再用上述块求解器BlockSolver初始化
5.4创建终极大boss 稀疏优化器(SparseOptimizer),并用已定义的总求解器solver作为求解方法
5.5定义图的顶点和边,并添加到稀疏优化器(SparseOptimizer)中
5.6设置优化参数,开始执行优化
四、代码
1. 将初始关键帧,当前关键帧的描述子转为BoW
pKFini->ComputeBoW();
pKFcur->ComputeBoW();
2. 向地图添加关键帧
mpAtlas->AddKeyFrame(pKFini);
mpAtlas->AddKeyFrame(pKFcur);
3. 生成地图点,更新图(节点和边)
3.1 遍历
for(size_t i=0; i<mvIniMatches.size();i++)
3.2 检查
if(mvIniMatches[i]<0)
continue;
3.3 构造点
cv::Mat worldPos(mvIniP3D[i]);
MapPoint* pMP = new MapPoint(worldPos,pKFcur,mpAtlas->GetCurrentMap());
3.4 修改点属性
3.4.1 添加可以观测到该地图点pMP的关键帧
pMP->AddObservation(pKFini,i);
pMP->AddObservation(pKFcur,mvIniMatches[i]);
3.4.2 计算该地图点pMP的描述子
pMP->ComputeDistinctiveDescriptors();
3.4.3 更新该地图点pMP的平均观测方向和深度范围
pMP->UpdateNormalAndDepth();
3.4.3.1 获取地图点信息
observations=mObservations; // 获得观测到该地图点的所有关键帧
pRefKF=mpRefKF; // 观测到该点的参考关键帧(第一次创建时的关键帧)
Pos = mWorldPos.clone(); // 地图点在世界坐标系中的位置
3.4.3.2 计算该地图点的法向量
cv::Mat normal = cv::Mat::zeros(3,1,CV_32F);
int n=0;
for(map<KeyFrame*,size_t>::iterator mit=observations.begin(), mend=observations.end(); mit!=mend; mit++)
{KeyFrame* pKF = mit->first;
tuple<int,int> indexes = mit -> second;
int leftIndex = get<0>(indexes), rightIndex = get<1>(indexes);
if(leftIndex != -1){cv::Mat Owi = pKF->GetCameraCenter();
cv::Mat normali = mWorldPos - Owi;normal = normal + normali/cv::norm(normali);
n++;
}
if(rightIndex != -1){cv::Mat Owi = pKF->GetRightCameraCenter();
cv::Mat normali = mWorldPos - Owi;normal = normal + normali/cv::norm(normali);
n++;
}
}
3.4.3.3 计算该地图点到图像的距离
cv::Mat PC = Pos - pRefKF->GetCameraCenter();
const float dist = cv::norm(PC);
3.4.3.4 更新该地图点的距离上下限
// 观测到该地图点的当前帧的特征点在金字塔的第几层
tuple<int ,int> indexes = observations[pRefKF];
int leftIndex = get<0>(indexes), rightIndex = get<1>(indexes);
int level;
if(pRefKF -> NLeft == -1){
level = pRefKF->mvKeysUn[leftIndex].octave;
}
else if(leftIndex != -1){
level = pRefKF -> mvKeys[leftIndex].octave;
}
else{
level = pRefKF -> mvKeysRight[rightIndex - pRefKF -> NLeft].octave;
}
//const int level = pRefKF->mvKeysUn[observations[pRefKF]].octave;
const float levelScaleFactor = pRefKF->mvScaleFactors[level]; // 当前金字塔层对应的缩放倍数
const int nLevels = pRefKF->mnScaleLevels; // 金字塔层数
{
unique_lock<mutex> lock3(mMutexPos);
// 使用方法见PredictScale函数前的注释
mfMaxDistance = dist*levelScaleFactor; // 观测到该点的距离上限
mfMinDistance = mfMaxDistance/pRefKF->mvScaleFactors[nLevels-1]; // 观测到该点的距离下限
mNormalVector = normal/n; // 获得地图点平均的观测方向
}
3.5 添加地图点到地图
mpAtlas->AddMapPoint(pMP);
3.6 更新图
3.6.1 统计共视帧
// 遍历每一个地图点
for(vector<MapPoint*>::iterator vit=vpMP.begin(), vend=vpMP.end(); vit!=vend; vit++)
...
// 统计与当前关键帧存在共视关系的其他帧
map<KeyFrame*,size_t> observations = pMP->GetObservations();
for(map<KeyFrame*,size_t>::iterator mit=observations.begin(), mend=observations.end(); mit!=mend; mit++)
...
// 体现了作者的编程功底,很强
KFcounter[mit->first]++;
3.6.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; } if(mit->second>=th) { // 更新共视关系大于一定阈值的边 vPairs.push_back(make_pair(mit->second,mit->first)); // 更新其它关键帧与当前帧的连接权重 (mit->first)->AddConnection(this,mit->second); } }
3.6.3 如果没有连接到关键帧(没有超过阈值的权重),则连接权重最大的关键帧
if(vPairs.empty())
{
vPairs.push_back(make_pair(nmax,pKFmax));
pKFmax->AddConnection(this,nmax);
}
3.6.4 对共视程度比较高的关键帧对更新连接关系及权重(从大到小)
sort(vPairs.begin(),vPairs.end()); // 将排序后的结果分别组织成为两种数据类型 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); }
3.6.5 更新当前帧的信息
...
mConnectedKeyFrameWeights = KFcounter;
mvpOrderedConnectedKeyFrames = vector<KeyFrame*>(lKFs.begin(),lKFs.end());
mvOrderedWeights = vector<int>(lWs.begin(), lWs.end());
3.6.6 更新生成树的连接
... if(mbFirstConnection && mnId!=mpMap->GetInitKFid()) { // 初始化该关键帧的父关键帧为共视程度最高的那个关键帧 mpParent = mvpOrderedConnectedKeyFrames.front(); // 建立双向连接关系,将当前关键帧作为其子关键帧 mpParent->AddChild(this); mbFirstConnection = false; }
4. 全局BA
// 调用
Optimizer::GlobalBundleAdjustemnt(mpAtlas->GetCurrentMap(),20);
// 定义
void Optimizer::GlobalBundleAdjustemnt(Map* pMap, int nIterations, bool* pbStopFlag, const unsigned long nLoopKF, const bool bRobust)
//调用
vector<KeyFrame*> vpKFs = pMap->GetAllKeyFrames();
vector<MapPoint*> vpMP = pMap->GetAllMapPoints();
BundleAdjustment(vpKFs,vpMP,nIterations,pbStopFlag, nLoopKF, bRobust);
// 定义
void Optimizer::BundleAdjustment(const vector<KeyFrame *> &vpKFs, const vector<MapPoint *> &vpMP, int nIterations, bool* pbStopFlag, const unsigned long nLoopKF, const bool bRobust)
4.1 方程求解器 LinearSolver
g2o::BlockSolver_6_3::LinearSolverType * linearSolver; linearSolver = new g2o::LinearSolverEigen<g2o::BlockSolver_6_3::PoseMatrixType>();
4.2 矩阵求解器 BlockSolver
g2o::BlockSolver_6_3 * solver_ptr = new g2o::BlockSolver_6_3(linearSolver);
typedef BlockSolver< BlockSolverTraits<6, 3> > BlockSolver_6_3;
4.3 算法求解器 AlgorithmSolver
g2o::OptimizationAlgorithmLevenberg* solver = new g2o::OptimizationAlgorithmLevenberg(solver_ptr);
4.4 稀疏优化器 SparseOptimizer
g2o::SparseOptimizer optimizer; optimizer.setAlgorithm(solver);// 用前面定义好的求解器作为求解方法 optimizer.setVerbose(false);// 设置优化过程输出信息用的
4.5 定义图的顶点和边,添加到稀疏优化器SparseOptimizer
4.5.1 图的定义
4.5.1.1 图的节点和边
结合代码,可以看看下图的示意(点击查看高清大图):
4.5.1.2 设置节点和边的步骤
4.5.1.3 ORB-SLAM3新增部分
4.5.2 误差模型
其中,
是观测误差,对应到代码中就是,用观测值【即校正后的特征点坐标mvKeysUn,是Frame类的UndistortKeyPoints函数获取的】,减去其估计值【即地图点mvIniP3D,该点是ReconstructF或者ReconstructH中,利用三角测量得到空间点坐标,之后把该地图点mvIniP3D投影到图像上,得到估计的特征点坐标P(u,v)】。Q是观测噪声,对应到代码中就是协方差矩阵sigma(而且还和特征点所在金字塔层数有关,层数越高,噪声越大)。
4.5.3 步骤一,添加关键帧位姿顶点
// 对于当前地图中的所有关键帧
for(size_t i=0; i<vpKFs.size(); i++)
{
KeyFrame* pKF = vpKFs[i];
// 去除无效的
if(pKF->isBad())
continue;
// 对于每一个能用的关键帧构造SE3顶点,其实就是当前关键帧的位姿
g2o::VertexSE3Expmap * vSE3 = new g2o::VertexSE3Expmap();
vSE3->setEstimate(Converter::toSE3Quat(pKF->GetPose()));
vSE3->setId(pKF->mnId);
// 只有第0帧关键帧不优化(参考基准)
vSE3->setFixed(pKF->mnId==0);
// 向优化器中添加顶点,并且更新maxKFid
optimizer.addVertex(vSE3);
if(pKF->mnId>maxKFid)
maxKFid=pKF->mnId;
}
4.5.4 步骤二,添加地图点位姿顶点
// 卡方分布 95% 以上可信度的时候的阈值 const float thHuber2D = sqrt(5.99); // 自由度为2 const float thHuber3D = sqrt(7.815); // 自由度为3 // Set MapPoint vertices // Step 2.2:向优化器添加MapPoints顶点 // 遍历地图中的所有地图点 for(size_t i=0; i<vpMP.size(); i++) { MapPoint* pMP = vpMP[i]; // 跳过无效地图点 if(pMP->isBad()) continue; // 创建顶 g2o::VertexSBAPointXYZ* vPoint = new g2o::VertexSBAPointXYZ(); // 注意由于地图点的位置是使用cv::Mat数据类型表示的,这里需要转换成为Eigen::Vector3d类型 vPoint->setEstimate(Converter::toVector3d(pMP->GetWorldPos())); // 前面记录maxKFid 是在这里使用的 const int id = pMP->mnId+maxKFid+1; vPoint->setId(id); // g2o在做BA的优化时必须将其所有地图点全部schur掉,否则会出错。 // 原因是使用了g2o::LinearSolver<BalBlockSolver::PoseMatrixType>这个类型来指定linearsolver, // 其中模板参数当中的位姿矩阵类型在程序中为相机姿态参数的维度,于是BA当中schur消元后解得线性方程组必须是只含有相机姿态变量。 // Ceres库则没有这样的限制 vPoint->setMarginalized(true); optimizer.addVertex(vPoint);
4.5.5 步骤三,添加边
// 边的关系,其实就是点和关键帧之间观测的关系
const map<KeyFrame*,size_t> observations = pMP->GetObservations();
// 边计数
int nEdges = 0;
//SET EDGES
// Step 3:向优化器添加投影边(是在遍历地图点、添加地图点的顶点的时候顺便添加的)
// 遍历观察到当前地图点的所有关键帧
for(map<KeyFrame*,size_t>::const_iterator mit=observations.begin(); mit!=observations.end(); mit++)
{
KeyFrame* pKF = mit->first;
// 滤出不合法的关键帧
if(pKF->isBad() || pKF->mnId>maxKFid)
continue;
nEdges++;
const cv::KeyPoint &kpUn = pKF->mvKeysUn[mit->second];
if(pKF->mvuRight[mit->second]<0)
{
// 如果是单目相机按照下面操作
// 构造观测
Eigen::Matrix<double,2,1> obs;
obs << kpUn.pt.x, kpUn.pt.y;
// 创建边
g2o::EdgeSE3ProjectXYZ* e = new g2o::EdgeSE3ProjectXYZ();
// 填充数据,构造约束关系
e->setVertex(0, dynamic_cast<g2o::OptimizableGraph::Vertex*>(optimizer.vertex(id)));
e->setVertex(1, dynamic_cast<g2o::OptimizableGraph::Vertex*>(optimizer.vertex(pKF->mnId)));
e->setMeasurement(obs);
// 信息矩阵,也是协方差,表明了这个约束的观测在各个维度(x,y)上的可信程度,在我们这里对于具体的一个点,两个坐标的可信程度都是相同的,
// 其可信程度受到特征点在图像金字塔中的图层有关,图层越高,可信度越差
// 为了避免出现信息矩阵中元素为负数的情况,这里使用的是sigma^(-2)
const float &invSigma2 = pKF->mvInvLevelSigma2[kpUn.octave];
e->setInformation(Eigen::Matrix2d::Identity()*invSigma2);
// 如果要使用鲁棒核函数
if(bRobust)
{
g2o::RobustKernelHuber* rk = new g2o::RobustKernelHuber;
e->setRobustKernel(rk);
// 这里的重投影误差,自由度为2,所以这里设置为卡方分布中自由度为2的阈值,如果重投影的误差大约大于1个像素的时候,就认为不太靠谱的点了,
// 核函数是为了避免其误差的平方项出现数值上过大的增长
rk->setDelta(thHuber2D);
}
// 设置相机内参
// ORB-SLAM2的做法
//e->fx = pKF->fx;
//e->fy = pKF->fy;
//e->cx = pKF->cx;
//e->cy = pKF->cy;
// ORB-SLAM3的改变
e->pCamera = pKF->mpCamera;
optimizer.addEdge(e);
}
else
{
// 双目或RGBD相机按照下面操作
......这里忽略,只讲单目
}
} // 向优化器添加投影边,也就是遍历所有观测到当前地图点的关键帧
// 如果因为一些特殊原因,实际上并没有任何关键帧观测到当前的这个地图点,那么就删除掉这个顶点,并且这个地图点也就不参与优化
if(nEdges==0)
{
optimizer.removeVertex(vPoint);
vbNotIncludedMP[i]=true;
}
else
{
vbNotIncludedMP[i]=false;
}
}
4.5.6 优化
optimizer.initializeOptimization(); optimizer.optimize(nIterations);
5. 计算深度中值
float medianDepth = pKFini->ComputeSceneMedianDepth(2);
float invMedianDepth = 1.0f/medianDepth;
5.1 相机模型与坐标系转换
再来弄清楚各个坐标系之间的转换关系,认真研究下图,懂了之后能解决很多心里的疑问(点击查看高清大图):
其中,zc是相机坐标系下的坐标;dx和dy分别表示每个像素在横轴x和纵轴y的物理尺寸,单位为毫米/像素;u0,v0表示的是图像的中心像素坐标和图像圆点像素坐标之间相差的横向和纵向像素数;f是相机的焦距,R,T是旋转矩阵和平移矩阵,xw,yw,zw是世界坐标系下的坐标。
5.2 归一化平面
6. 归一化两帧变换到平均深度为1
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);
7. 3D点的尺度归一化
vector<MapPointPtr> vpAllMapPoints = pKFini->GetMapPointMatches();
for (size_t iMP = 0; iMP < vpAllMapPoints.size(); iMP++)
{
if (vpAllMapPoints[iMP])
{
MapPointPtr pMP = vpAllMapPoints[iMP];
if(!pMP->isBad())
pMP->SetWorldPos(pMP->GetWorldPos() * invMedianDepth);
}
}
8. 将关键帧插入局部地图
mpLocalMapper->InsertKeyFrame(pKFini); mpLocalMapper->InsertKeyFrame(pKFcur); mCurrentFrame.SetPose(pKFcur->GetPose()); mnLastKeyFrameId = pKFcur->mnId; mnLastKeyFrameFrameId=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); { unique_lock<mutex> lock(mMutexState); mState = eTrackingState::OK; } mpMap->calculateAvgZ(); // 初始化成功,至此,初始化过程完成
五、总结
1. 理解图优化的概念,包括ORB-SLAM3是如何定义图的,顶点和边到底是什么,他们有什么关系,产生这种关系背后的公式是什么,搞清楚这些,图优化就算入门了吧,也可以看得懂地图初始化部分了;
2. 相机模型,以及各个坐标系之间的关系,大多数人还是停留在大概理解的层面,需要结合代码实际来加深对它的理解,因为整个视觉SLAM就是多视图几何理论的天下,不懂这些就扎近茫茫代码中,很容易迷失。
六、参考
赞 (0)