LSD-SLAM的Tracking是算法框架中三大部分之一。其主要实现函数为SlamSystem::trackFrame
void SlamSystem::trackFrame(uchar* image, unsigned int frameID, bool blockUntilMapped, double timestamp)
这个函数的代码主要分为如下几个步骤实现:
接下来主要介绍SE3求解和关键帧筛选
LSD-SLAM在位姿估计中采用了直接法,也就是通过最小化光度误差来求解两帧图像间的位姿变化。并且采用了LM算法迭代求解。LSD-SLAM在求解两帧图像间的SE3变换主要在SE3Tracker
类中进行实现。该类中有四个函数比较重要,分别为
函数SE3Tracker::trackFrame
需要传入三个形参,从参考帧跟踪到当前帧,首先是两帧图像帧的实例,另外就是给定的初始位姿。对于初始位姿,LSD-SLAM中使用了上一帧和参考帧间的位姿作为当前位姿估计的初值(见上面的第3个步骤)。
SE3 SE3Tracker::trackFrame(
TrackingReference* reference,
Frame* frame,
const SE3& frameToReference_initialEstimate);
SE3Tracker::trackFrame
函数的主体是一个for循环,从图像金字塔的高层level-4开始遍历直到底层level-1。在循环内实现加权高斯牛顿优化算法(Weighted Gauss-Newton Optimization),其实就是增加了鲁棒函数的高斯牛顿。首先看一下SE3Tracker::trackFrame函数的代码结构,可以归纳为如下:
图像金字塔迭代level-4到level-1
Step1: 对参考帧当前层构造点云(reference->makePointCloud)
Step2: 计算变换到当前帧的残差和梯度(calcResidualAndBuffers)
Step3: 计算法方差归一化残差(calcWeightsAndResidual)
Step4: 计算雅克比向量以及A和b(calculateWarpUpdate)
Step5: 计算得到收敛的delta,并且更新SE3(inc = A.ldlt().solve(b))
重复Step2-Step5直到收敛或者达到最大迭代次数
计算下一层金字塔
接下来首先对SE3求解的算法原理做个详细的介绍。
在LSD-SLAM论文的3.3节介绍了这个对齐算法。首先给出优化模型(这里的标号与论文中的统一),论文采用了最小化归一化方差的光度误差(variance-normalized photometric error):
我们考虑,这里的归一化方差是什么意思?论文中在估计两帧间位姿变换的时候,把所有有逆深度假设的像素都用上了。但是每个逆深度的确定性不同,也就是有些逆深度比较准确,有些不准确。而准确与否则体现在逆深度的方差上了。因此在公式 (12) 中在残差上除以了方差做了归一化。在论文中考虑了两个方面的方差,一个是由逆深度估计不准确引入的,另外一个是由图像高斯噪声引起的。也即是说 (14) 式前面的是连个图像的图像高斯噪声,后面的是逆深度造成的误差。这里逆深度误差不确定性是根据下式计算得到的:
论文中给出了3D投影变换 ω 的表达式,也就是论文中的公式 (3) :
论文中讨论的是对如下形式优化的高斯牛顿算法,也就是基本的光度误差,优化目标是使得下式最小:
在GN算法中,通常需要在每次更新了估计值之后重新计算一下在新估计值下的雅克比,而计算雅克比就需要图像梯度等数据,由于论文采用的是图像对齐算法中的正向加法(Lucas-Kanades Algorithm),所以在每一次迭代的过程中都需要重新计算雅可比,也需要计算从模版图像(参考帧)上点变换到当前图像(当前帧)上对应位置的灰度以及梯度数据。计算雅可比前的这些预备工作就是在函数SE3Tracker::calcResidualAndBuffers和SE3Tracker::calcWeightsAndResidual中进行处理的。
首先我们看一下函数calcResidualAndBuffers
:
float SE3Tracker::calcResidualAndBuffers(
const Eigen::Vector3f* refPoint,
const Eigen::Vector2f* refColVar,
int* idxBuf,
int refNum,
Frame* frame,
const Sophus::SE3f& referenceToFrame,
int level,
bool plotResidual)
{
calcResidualAndBuffers_debugStart();
if(plotResidual)
debugImageResiduals.setTo(0);
int w = frame->width(level);
int h = frame->height(level);
Eigen::Matrix3f KLvl = frame->K(level);
float fx_l = KLvl(0,0);
float fy_l = KLvl(1,1);
float cx_l = KLvl(0,2);
float cy_l = KLvl(1,2);
Eigen::Matrix3f rotMat = referenceToFrame.rotationMatrix();
Eigen::Vector3f transVec = referenceToFrame.translation();
const Eigen::Vector3f* refPoint_max = refPoint + refNum;
const Eigen::Vector4f* frame_gradients = frame->gradients(level);
int idx=0;
float sumResUnweighted = 0;
bool* isGoodOutBuffer = idxBuf != 0 ? frame->refPixelWasGood() : 0;
int goodCount = 0;
int badCount = 0;
float sumSignedRes = 0;
float sxx=0,syy=0,sx=0,sy=0,sw=0;
float usageCount = 0;
for(;refPointfloat u_new = (Wxp[0]/Wxp[2])*fx_l + cx_l;
float v_new = (Wxp[1]/Wxp[2])*fy_l + cy_l;
// step 1a: coordinates have to be in image:
// (inverse test to exclude NANs)
if(!(u_new > 1 && v_new > 1 && u_new < w-2 && v_new < h-2))
{
if(isGoodOutBuffer != 0)
isGoodOutBuffer[*idxBuf] = false;
continue;
}
Eigen::Vector3f resInterp = getInterpolatedElement43(frame_gradients, u_new, v_new, w);
float c1 = affineEstimation_a * (*refColVar)[0] + affineEstimation_b;
float c2 = resInterp[2];
float residual = c1 - c2;
float weight = fabsf(residual) < 5.0f ? 1 : 5.0f / fabsf(residual);
sxx += c1*c1*weight;
syy += c2*c2*weight;
sx += c1*weight;
sy += c2*weight;
sw += weight;
bool isGood = residual*residual / (MAX_DIFF_CONSTANT + MAX_DIFF_GRAD_MULT*(resInterp[0]*resInterp[0] + resInterp[1]*resInterp[1])) < 1;
if(isGoodOutBuffer != 0)
isGoodOutBuffer[*idxBuf] = isGood;
*(buf_warped_x+idx) = Wxp(0);
*(buf_warped_y+idx) = Wxp(1);
*(buf_warped_z+idx) = Wxp(2);
*(buf_warped_dx+idx) = fx_l * resInterp[0];
*(buf_warped_dy+idx) = fy_l * resInterp[1];
*(buf_warped_residual+idx) = residual;
*(buf_d+idx) = 1.0f / (*refPoint)[2];
*(buf_idepthVar+idx) = (*refColVar)[1];
idx++;
if(isGood)
{
sumResUnweighted += residual*residual;
sumSignedRes += residual;
goodCount++;
}
else
badCount++;
float depthChange = (*refPoint)[2] / Wxp[2]; // if depth becomes larger: pixel becomes "smaller", hence count it less.
usageCount += depthChange < 1 ? depthChange : 1;
// DEBUG STUFF
if(plotTrackingIterationInfo || plotResidual)
{
// for debug plot only: find x,y again.
// horribly inefficient, but who cares at this point...
Eigen::Vector3f point = KLvl * (*refPoint);
int x = point[0] / point[2] + 0.5f;
int y = point[1] / point[2] + 0.5f;
if(plotTrackingIterationInfo)
{
setPixelInCvMat(&debugImageOldImageSource,getGrayCvPixel((float)resInterp[2]),u_new+0.5,v_new+0.5,(width/w));
setPixelInCvMat(&debugImageOldImageWarped,getGrayCvPixel((float)resInterp[2]),x,y,(width/w));
}
if(isGood)
setPixelInCvMat(&debugImageResiduals,getGrayCvPixel(residual+128),x,y,(width/w));
else
setPixelInCvMat(&debugImageResiduals,cv::Vec3b(0,0,255),x,y,(width/w));
}
}
buf_warped_size = idx;
pointUsage = usageCount / (float)refNum;
lastGoodCount = goodCount;
lastBadCount = badCount;
lastMeanRes = sumSignedRes / goodCount;
affineEstimation_a_lastIt = sqrtf((syy - sy*sy/sw) / (sxx - sx*sx/sw));
affineEstimation_b_lastIt = (sy - affineEstimation_a_lastIt*sx)/sw;
calcResidualAndBuffers_debugFinish(w);
return sumResUnweighted / goodCount;
}
该函数主要做了如下几件事:
buf_warped_x
、buf_warped_y
、buf_warped_z
buf_warped_dx
、buf_warped_dx
,以及残差buf_warped_residual
buf_d
以及(逆深度的)方差buf_idepthVar
这里有一点,就是现在求得的残差只是纯粹的光度误差,是把参考帧中3D点对应的灰度与投影到当前帧位置处得到的灰度(通过双线性插值)做差得到的,此时并没有乘以权重(代码L62-L66):
Eigen::Vector3f resInterp = getInterpolatedElement43(frame_gradients, u_new, v_new, w);
float c1 = affineEstimation_a * (*refColVar)[0] + affineEstimation_b;
float c2 = resInterp[2];
float residual = c1 - c2;
而公式 (12) 中的归一化方差以及Huber-weight将在函数calcWeightsAndResidual
中计算。
疑问1:代码中对参考帧的灰度做了一个仿射变换的处理,这里的原理是什么?在SVO代码中的确有考虑到两帧见位姿相差过大,因此通过在空间上的仿射变换之后再求灰度的操作。但是在这里的代码中没有看出具体原理。
需要注意的是,在给梯度变量赋值的时候(上面代码L84-L85),这里乘了焦距:
*(buf_warped_dx+idx) = fx_l * resInterp[0];
*(buf_warped_dy+idx) = fy_l * resInterp[1];
这样做的原因在函数calcWeightsAndResidual
分析的时候会解释。
接下来看函数SE3Tracker::calcWeightsAndResidual
。该函数的功能是计算归一化方差的光度误差系数,也就是计算公式 (14) ,并且乘以了Huber-weight,最终把这个系数存在数组buf_weight_p
中。
接下来我们看具体实现。可能是考虑参考帧到当前帧的位姿变换比较小,所以作者只考虑了位移 t 而忽略旋转 R 。这样使得式子 (14) 中的偏导的形式简单了很多。这里先给出代码。
float SE3Tracker::calcWeightsAndResidual(
const Sophus::SE3f& referenceToFrame)
{
float tx = referenceToFrame.translation()[0];
float ty = referenceToFrame.translation()[1];
float tz = referenceToFrame.translation()[2];
float sumRes = 0;
for(int i=0;ifloat px = *(buf_warped_x+i); // x'
float py = *(buf_warped_y+i); // y'
float pz = *(buf_warped_z+i); // z'
float d = *(buf_d+i); // d
float rp = *(buf_warped_residual+i); // r_p
float gx = *(buf_warped_dx+i); // \delta_x I
float gy = *(buf_warped_dy+i); // \delta_y I
float s = settings.var_weight * *(buf_idepthVar+i); // \sigma_d^2
// calc dw/dd (first 2 components):
float g0 = (tx * pz - tz * px) / (pz*pz*d);
float g1 = (ty * pz - tz * py) / (pz*pz*d);
// calc w_p
float drpdd = gx * g0 + gy * g1; // ommitting the minus
float w_p = 1.0f / ((cameraPixelNoise2) + s * drpdd * drpdd);
float weighted_rp = fabs(rp*sqrtf(w_p));
float wh = fabs(weighted_rp < (settings.huber_d/2) ? 1 : (settings.huber_d/2) / weighted_rp);
sumRes += wh * w_p * rp*rp;
*(buf_weight_p+i) = wh * w_p;
}
return sumRes / buf_warped_size;
}
在代码中22-23行是计算式 (14) 中的偏导数,考虑只有位移,对于SE3变换,也就是 (3) 右边的式子则有:
dx
和
dy
分别是点
p 在图像
Ij 投影位置处两个方向的梯度;而第二个偏导是在点
p 在参考帧中的逆深度
Di(p) 处求的,所有最终我们得到式子
(∗) 中的结果。
接下来我们回到代码,如下两行就是第二个偏导的结果(本来是一个三维的列向量,最后一维是 0 ,所以直接忽略了)
// calc dw/dd (first 2 components):
float g0 = (tx * pz - tz * px) / (pz*pz*d);
float g1 = (ty * pz - tz * py) / (pz*pz*d);
接下来是求的是整个 (∗) 式:
// calc w_p
float drpdd = gx * g0 + gy * g1; // ommitting the minus
注意:正如源码所注释,这里省略了负号,另外,
gx
和gy
是梯度没错,但是少了两个焦距fx
和fy
。这里需要说明的是,梯度变量buf_warped_dx
和buf_warped_dy
在函数SE3Tracker::calcResidualAndBuffers中已经乘以焦距了。同时我们看 (∗) 式第一个图像函数对单位化平面上的点求偏导的结果,就是梯度和焦距乘在了一起,这就不难理解为什么在代码中作者这么处理的原因了:
*(buf_warped_dx+idx) = fx_l * resInterp[0];
*(buf_warped_dy+idx) = fy_l * resInterp[1];
最后一个函数SE3Tracker::calculateWarpUpdate
是计算雅可比以及最小二乘方程的系数
Vector6 SE3Tracker::calculateWarpUpdate(
NormalEquationsLeastSquares &ls)
{
// weightEstimator.reset();
// weightEstimator.estimateDistribution(buf_warped_residual, buf_warped_size);
// weightEstimator.calcWeights(buf_warped_residual, buf_warped_weights, buf_warped_size);
//
ls.initialize(width*height);
for(int i=0;ifloat px = *(buf_warped_x+i);
float py = *(buf_warped_y+i);
float pz = *(buf_warped_z+i);
float r = *(buf_warped_residual+i);
float gx = *(buf_warped_dx+i);
float gy = *(buf_warped_dy+i);
// step 3 + step 5 comp 6d error vector
float z = 1.0f / pz;
float z_sqr = 1.0f / (pz*pz);
Vector6 v;
v[0] = z*gx + 0;
v[1] = 0 + z*gy;
v[2] = (-px * z_sqr) * gx +
(-py * z_sqr) * gy;
v[3] = (-px * py * z_sqr) * gx +
(-(1.0 + py * py * z_sqr)) * gy;
v[4] = (1.0 + px * px * z_sqr) * gx +
(px * py * z_sqr) * gy;
v[5] = (-py * z) * gx +
(px * z) * gy;
// step 6: integrate into A and b:
ls.update(v, r, *(buf_weight_p+i));
}
Vector6 result;
// solve ls
ls.finish();
ls.solve(result);
return result;
}
首先看下这里的雅可比,回顾一下 (12) 式,这个求雅可比?这么复杂?分子分母都和要求的SE3有关。
v[0] = z*gx + 0;
v[1] = 0 + z*gy;
v[2] = (-px * z_sqr) * gx +
(-py * z_sqr) * gy;
v[3] = (-px * py * z_sqr) * gx +
(-(1.0 + py * py * z_sqr)) * gy;
v[4] = (1.0 + px * px * z_sqr) * gx +
(px * py * z_sqr) * gy;
v[5] = (-py * z) * gx +
(px * z) * gy;
这里和函数calcWeightsAndResidual
一样,gx
和gy
是已经乘了焦距的梯度,因此代码和我们的推导结果一致。
注意:这里的雅可比和实际我们推导出来的差了一个负号!最终解方程的时候需要注意!
函数的最后就是更新最小二乘系数矩阵 A 和向量 b ,我们查看调用的最小二乘update
函数:
inline void NormalEquationsLeastSquares::update(const Vector6& J, const float& res, const float& weight)
{
// printf("up: %f, %f, %f, %f, %f, %f; res: %f; w: %f\n",
// J[0],J[1],J[2],J[3],J[4],J[5],res, weight);
A_opt.rankUpdate(J, weight);
//MathSse::addOuterProduct(A, J, factor);
//A += J * J.transpose() * factor;
//MathSse::add(b, J, -res * factor); // not much difference :(
b -= J * (res * weight);
error += res * res * weight;
num_constraints += 1;
}
这里做的就是计算式子 (6∗) 的 A 和 b 。
在函数的最后,调用finish()函数:
void NormalEquationsLeastSquares::finish()
{
A_opt.toEigen(A);
A /= (float) num_constraints;
b /= (float) num_constraints;
error /= (float) num_constraints;
}
这里对矩阵的每个元素都除以了num_constraints
,这也就是点的个数。
疑问2:为什么要除以这个系数,有什么区别吗?
得到最小二乘形式的方程 Aδξ=b 后,就是解出我们的 δξ !这部分代码在SE3Tracker::trackFrame的GN迭代中:
// solve LS system with current lambda
Vector6 b = -ls.b;
Matrix6x6 A = ls.A;
for(int i=0;i<6;i++) A(i,i) *= 1+LM_lambda;
Vector6 inc = A.ldlt().solve(b);
这里很明白了,调用了Eigen的LDLT来求解,并且这里把向量 b 取了负号,这样就和之前推导的结果一致了!
SE3Tracker::trackFrame
函数的主体是一个for循环,从图像金字塔的高层level-4开始遍历直到底层level-1。每一层都进行LM优化迭代,则是另外一个for循环。
上面讲的哪些步骤都是GN的方法,LM的方法区别就在于对于解最小二乘 Ax=b 的时候,增加了一个系数 λ ,也就是 (A+λI)x=b 。在代码中,作者只在迭代结果使得误差发散的时候给 λ 赋予一个非零的值,同时不断增加 λ 的值进行求解,直到解得的结果是收敛的,才进行下一次迭代。
在论文中的3.4节说明了如何选取关键帧。论文的意思是根据运动距离来确定,如果当前相机运动距离参考帧过远则把当前帧创建为关键帧。并且给出了距离函数:
其实我们在看代码时发现,关键帧筛选策略和论文中说的还是有点不一样的。代码中有两处构建新关键帧的地方。一个是正常系统运行的时候,一个是在重定位的时候。
首先看重定位部分,在函数SlamSystem::trackFrame中对关键帧进行筛选,代码如下:
if (!my_createNewKeyframe && currentKeyFrame->numMappedOnThisTotal > MIN_NUM_MAPPED)
{
Sophus::Vector3d dist = newRefToFrame_poseUpdate.translation() * currentKeyFrame->meanIdepth;
float minVal = fmin(0.2f + keyFrameGraph->keyframesAll.size() * 0.8f / INITIALIZATION_PHASE_COUNT,1.0f);
if(keyFrameGraph->keyframesAll.size() < INITIALIZATION_PHASE_COUNT) minVal *= 0.7;
lastTrackingClosenessScore = trackableKeyFrameSearch->getRefFrameScore(dist.dot(dist), tracker->pointUsage);
if (lastTrackingClosenessScore > minVal)
{
createNewKeyFrame = true;
if(enablePrintDebugInfo && printKeyframeSelectionInfo)
printf("SELECT %d on %d! dist %.3f + usage %.3f = %.3f > 1\n",trackingNewFrame->id(),trackingNewFrame->getTrackingParent()->id(), dist.dot(dist), tracker->pointUsage, trackableKeyFrameSearch->getRefFrameScore(dist.dot(dist), tracker->pointUsage));
}
else
{
if(enablePrintDebugInfo && printKeyframeSelectionInfo)
printf("SKIPPD %d on %d! dist %.3f + usage %.3f = %.3f > 1\n",trackingNewFrame->id(),trackingNewFrame->getTrackingParent()->id(), dist.dot(dist), tracker->pointUsage, trackableKeyFrameSearch->getRefFrameScore(dist.dot(dist), tracker->pointUsage));
}
}
第一个if
判断的条件是当前建图线程是否已经更新好上一帧关键帧以及跟踪最近的关键帧(参考帧)的图像帧个数不小于一定值(MIN_NUM_MAPPED
,为 5 )。如果满足上述的条件则计算得分,得分大于一定阈值则确定构建新的关键帧。
首先我们看一下得分是怎么计算的。这里的得分和当前帧和参考帧之间的位移大小有关。计算得分的函数TrackableKeyFrameSearch::getRefFrameScore如下:
inline float getRefFrameScore(float distanceSquared, float usage)
{
return distanceSquared*KFDistWeight*KFDistWeight
+ (1-usage)*(1-usage) * KFUsageWeight * KFUsageWeight;
}
该函数第一个参数是运动距离;第二个参数是当前帧跟踪参考帧所用的3D点占参考帧所有3D点的比例。tracker->pointUsage
这个变量在函数SE3Tracker::calcResidualAndBuffers中进行计算,可以理解为当前帧和参考帧重叠区域的比例。看得分的计算方法,可以看出,当位移越大以及当前帧和参考帧重叠度越低,则得分越大(KFUsageWeight
默认为4)。
这里有一点需要注意,在计算运动距离的时候,这里采用来位移向量先乘以一个平均场景逆深度:
Sophus::Vector3d dist = newRefToFrame_poseUpdate.translation() * currentKeyFrame->meanIdepth;
显然,大场景的逆深度会小,小场景的逆深度会大,这相当于一个权重,对于相同的位移,认为小场景的运动程度比较大。
然后是阈值变量:
float minVal = fmin(0.2f + keyFrameGraph->keyframesAll.size() * 0.8f / INITIALIZATION_PHASE_COUNT,1.0f);
if(keyFrameGraph->keyframesAll.size() < INITIALIZATION_PHASE_COUNT) minVal *= 0.7;
这里的两个操作都是使得初始化阶段当关键帧比较少的时候(INITIALIZATION_PHASE_COUNT
为5
)放宽了阈值,之后就是 1 。
正常情况下,则是在函数SlamSystem::changeKeyframe中改变当前参考关键帧。在changeKeyframe
函数中通过调用函数TrackableKeyFrameSearch::findRePositionCandidate来找一帧参考帧,如果没有符合的就创建一个新的关键帧。同样也用函数TrackableKeyFrameSearch::getRefFrameScore来计算得分。
在这部分算法和代码中,对于实际设计SLAM系统有几个可以借鉴的地方:
以及存在一个疑问:
参考文献