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