4、ORB特征提取(上):ORBextractor类设计、图像金字塔构建、FAST角点检测源码逐行解析
各位同学,咱们今天开始啃ORB特征提取这块硬骨头。说实话,ORB-SLAM3里最核心的模块之一就是特征提取,你想想看,整个系统能不能跑得稳,很大程度上取决于特征点提取得好不好。我在项目里踩过不少坑,比如特征点分布不均匀导致位姿估计失败,或者金字塔层数设置不对导致尺度适应性差。今天咱们就把ORBextractor这个类从头到尾捋一遍。
4.1 ORBextractor类设计:一个精心设计的特征工厂
先看类设计。ORBextractor这个类,说白了就是一个特征点生产工厂。它负责两件事:检测角点、计算描述子。但你别小看它,里面的设计细节非常多。
核心成员变量一览:
| 变量名 | 类型 | 作用 |
|---|---|---|
| nfeatures | int | 期望提取的特征点总数 |
| scaleFactor | float | 金字塔相邻层之间的缩放因子 |
| nlevels | int | 金字塔总层数 |
| iniThFAST | int | 初始FAST角点检测阈值 |
| minThFAST | int | 最小FAST角点检测阈值(当初始阈值检测不足时使用) |
嗯,这里要注意一个细节:scaleFactor的默认值是1.2。为什么是1.2?我刚开始也觉得随便设个值就行,后来发现这个值直接影响尺度空间的覆盖范围。1.2这个值经过大量实验验证,能在计算量和尺度覆盖之间取得比较好的平衡。
4.2 图像金字塔构建:让特征点具备尺度不变性
为什么要建金字塔?很简单——相机离物体远近不同,同一个特征点在图像上的大小就不同。没有金字塔,特征点就只能在单一尺度下工作,换个距离就匹配不上了。
咱们看看源码里是怎么构建金字塔的:
void ORBextractor::ComputePyramid(cv::Mat image) {
// 第0层就是原始图像
mvImagePyramid[0] = image;
// 逐层缩放
for(int level = 1; level < nlevels; level++) {
// 计算当前层的缩放系数
float scale = 1.0f / pow(scaleFactor, level);
// 计算缩放后的尺寸
Size sz(cvRound(image.cols * scale),
cvRound(image.rows * scale));
// 高斯模糊 + 降采样
pyrDown(mvImagePyramid[level-1],
mvImagePyramid[level],
sz);
}
}
这段代码看起来简单,但有个坑:pyrDown内部会先做高斯模糊再降采样。我遇到过一个问题,就是金字塔层数太多导致最顶层图像太小,特征点几乎提不出来。一般建议nlevels设为8,图像尺寸小于30x30时就可以停了。
避坑指南:我曾经在嵌入式平台上移植ORB-SLAM3,发现金字塔构建耗时严重。后来发现是pyrDown的高斯核大小固定为5x5,对于小尺寸图像来说计算量占比很大。建议在资源受限的场景下,适当减少金字塔层数或者增大scaleFactor。
4.3 FAST角点检测:速度与质量的博弈
FAST角点检测的核心思想很简单:如果一个像素点与周围一圈像素的亮度差异足够大,那它就是一个角点。但具体实现时有很多门道。
源码里是这样实现的:
void ORBextractor::computeKeyPointsOctTree(
vector<vector<KeyPoint>>& allKeypoints) {
// 对每一层金字塔分别检测
for(int level = 0; level < nlevels; level++) {
const int minBorderX = EDGE_THRESHOLD;
const int minBorderY = EDGE_THRESHOLD;
const int maxBorderX = mvImagePyramid[level].cols - EDGE_THRESHOLD;
const int maxBorderY = mvImagePyramid[level].rows - EDGE_THRESHOLD;
vector<KeyPoint> vToDistributeKeys;
vToDistributeKeys.reserve(nfeatures * 10);
// 用网格遍历,保证特征点分布均匀
const int width = maxBorderX - minBorderX;
const int height = maxBorderY - minBorderY;
// 遍历每个网格
for(int i = 0; i < width; i += cellSize) {
for(int j = 0; j < height; j += cellSize) {
// 在当前网格内检测FAST角点
vector<KeyPoint> cellKeys;
FAST(mvImagePyramid[level].rowRange(
j, j + cellSize).colRange(i, i + cellSize),
cellKeys, iniThFAST, true);
// 如果检测到的角点太少,降低阈值再试一次
if(cellKeys.empty()) {
FAST(mvImagePyramid[level].rowRange(
j, j + cellSize).colRange(i, i + cellSize),
cellKeys, minThFAST, true);
}
// 将检测到的角点坐标转换回原图坐标系
for(KeyPoint& kp : cellKeys) {
kp.pt.x += i;
kp.pt.y += j;
vToDistributeKeys.push_back(kp);
}
}
}
}
}
这里有个设计很巧妙:先用较大的阈值iniThFAST检测,如果检测不到角点,再用较小的阈值minThFAST。为什么这么做?因为纹理丰富的区域角点很多,用大阈值可以筛选出质量较高的角点;而纹理贫瘠的区域角点本来就少,用小阈值能保证至少有一些特征点可用。
注意:FAST角点检测有个边界问题。检测时需要比较中心像素与周围16个像素的亮度,如果中心像素太靠近图像边缘,周围像素就会越界。源码里用EDGE_THRESHOLD(默认19像素)来规避这个问题。我见过有人把这个值设成0,结果程序直接崩溃——数组越界了。
4.4 四叉树均匀化:让特征点分布更合理
检测完FAST角点后,你会发现一个问题:角点都扎堆在纹理丰富的区域,比如建筑物的边缘、窗户的角落。而纹理平坦的区域,比如墙面、天空,一个角点都没有。这会导致位姿估计时,特征点集中在图像的一小部分,其他区域的信息被浪费了。
ORB-SLAM3用四叉树来解决这个问题。说白了,就是把图像不断四等分,直到每个区域的特征点数量满足要求。这样就能保证特征点在整个图像上均匀分布。
vector<KeyPoint> ORBextractor::DistributeOctTree(
const vector<KeyPoint>& vToDistributeKeys,
const int& minX, const int& maxX,
const int& minY, const int& maxY,
const int& nFeatures, const int& level) {
// 计算初始节点数量(根据图像宽高比)
const int nIni = round(static_cast<float>(maxX - minX) /
(maxY - minY));
// 创建初始节点列表
list<ExtractorNode> lNodes;
// ... 节点分裂逻辑
// 不断分裂节点,直到节点数满足要求
while(/* 节点数不够 */) {
// 对每个节点尝试分裂
for(auto& node : lNodes) {
if(node.vKeys.size() == 1) {
// 只有一个特征点,不再分裂
continue;
}
// 四等分当前节点
node.divideNode(lNodes);
}
}
// 从每个节点中选出一个响应值最高的特征点
vector<KeyPoint> result;
for(auto& node : lNodes) {
// 找到节点内响应值最大的特征点
KeyPoint bestKP = /* 遍历节点内所有特征点 */;
result.push_back(bestKP);
}
return result;
}
这段代码的逻辑其实不复杂:先把图像分成若干初始区域,然后不断四等分,直到区域数量达到期望的特征点数量。最后从每个区域里挑一个响应值最高的特征点。这样就能保证特征点分布均匀,而且每个特征点都是局部最优的。
个人经验:我在做视觉惯性里程计的时候,发现四叉树均匀化对系统稳定性影响很大。有一次在走廊场景测试,特征点全集中在墙上的海报和消防栓上,天花板和地面几乎没有特征点。用了四叉树之后,特征点分布明显改善,位姿估计的抖动也小了很多。
4.5 本章小结
咱们今天把ORB特征提取的上半部分讲完了。核心就三件事:
- ORBextractor类设计:参数配置、成员变量、接口设计
- 图像金字塔构建:多尺度表达、缩放因子选择、边界处理
- FAST角点检测:阈值策略、网格遍历、边界保护
这些代码看起来简单,但每个细节都经过精心设计。比如金字塔的缩放因子为什么是1.2而不是1.5?FAST阈值为什么设两个?四叉树分裂的终止条件是什么?这些问题背后都有工程实践的考量。
下一节咱们继续讲ORB描述子的计算和特征点方向的分配,那才是真正体现ORB特色的地方。到时候我会结合源码,把BRIEF描述子的二进制特性、旋转不变性的实现原理讲清楚。
公众号:蓝海资料掘金营,微信deep3321