第三章 图像特征提取与匹配

各位同学好,我是你们的讲师。今天我们来聊聊视觉SLAM里最基础也最关键的一环——图像特征提取与匹配。说实话,我刚开始做SLAM的时候,觉得这部分就是调调OpenCV的API,后来踩了不少坑才明白,特征选得好不好,直接决定了整个系统的上限。

咱们先理清一个概念:为什么要提取特征?说白了,就是让机器人能认出「同一个点」。相机拍到的是一堆像素,但像素本身没有语义。我们需要找到那些独特的、稳定的点——比如墙角、桌角、纹理丰富的地方。这些点就是特征点。

3.1 Harris角点检测

Harris角点是最经典的方法之一。它的核心思想很简单:窗口在各个方向移动时,灰度变化都很大,那就是角点。

我记得刚入行时,导师让我用Harris做特征提取。我调了个阈值,结果要么全是角点,要么一个都没有。后来才明白,阈值这东西得根据图像动态调整。你想想看,一张白墙和一张布满纹理的木桌,同样的阈值效果能一样吗?

Harris的数学本质是计算自相关矩阵的特征值。两个特征值都大,就是角点;一个大一个小,是边缘;两个都小,是平坦区域。嗯,这里要注意,Harris对尺度变化很敏感——说白了,你离得近看到的角点,离远了可能就变成边缘了。

// Harris角点检测示例
cv::Mat src = cv::imread("scene.jpg");
cv::Mat gray;
cv::cvtColor(src, gray, cv::COLOR_BGR2GRAY);

cv::Mat harris;
cv::cornerHarris(gray, harris, 2, 3, 0.04);

// 阈值处理
cv::Mat harris_norm;
cv::normalize(harris, harris_norm, 0, 255, cv::NORM_MINMAX);
cv::Mat harris_scaled;
convertScaleAbs(harris_norm, harris_scaled);

// 提取角点坐标
std::vector<cv::Point> corners;
for(int i = 0; i < harris_norm.rows; i++) {
    for(int j = 0; j < harris_norm.cols; j++) {
        if((int)harris_norm.at<float>(i,j) > 200) {
            corners.push_back(cv::Point(j,i));
        }
    }
}
我的经验:Harris适合纹理丰富的场景,比如室内家具、书架。但如果你面对的是白墙或光滑地面,建议直接放弃Harris,换ORB或SIFT。

3.2 SIFT特征

SIFT(尺度不变特征变换)是特征提取界的「老大哥」。它解决了Harris最头疼的问题——尺度变化。为什么SIFT能做到?因为它构建了高斯差分金字塔,在不同尺度下寻找极值点。

我在做室外大场景SLAM时,特别喜欢用SIFT。车辆从远处开到近处,特征点尺度变化很大,但SIFT依然能稳定匹配。代价是什么?慢。非常慢。一个800×600的图像,提取SIFT特征可能要几百毫秒,这在实时SLAM里是致命的。

SIFT的每个特征点有一个128维的描述子。为什么是128维?因为SIFT把特征点周围的16×16区域分成4×4的子块,每个子块统计8个方向的梯度直方图,4×4×8=128。这个设计很巧妙,保证了旋转不变性——你想想看,图像旋转了,梯度方向也跟着转,但相对关系不变。

注意:SIFT是有专利的!商用项目要小心。OpenCV从4.5.1版本开始把SIFT移到了contrib模块,需要额外编译。我曾经有个项目因为没注意这个,差点吃了官司。

3.3 SURF特征

SURF是SIFT的加速版。它用Hessian矩阵的行列式来检测特征点,用Haar小波响应来构建描述子。说白了,就是把SIFT里那些复杂的计算简化了,用积分图像加速。

我个人习惯是:如果硬件性能允许,优先用SIFT;如果追求实时性,用SURF。但说实话,现在ORB出来之后,SURF的位置有点尴尬——比SIFT快但不如SIFT准,比ORB准但不如ORB快。

特征 检测速度 匹配精度 尺度不变 旋转不变 专利
Harris
SIFT
SURF 中高
ORB 极快

3.4 ORB特征

ORB(Oriented FAST and Rotated BRIEF)是目前视觉SLAM里最常用的特征。为什么?因为它快,而且免费。ORB = FAST角点检测 + BRIEF描述子,再加上方向和尺度信息。

FAST角点检测的思路很暴力:一个像素周围有16个像素,如果其中有连续N个像素比中心像素亮或暗,那就是角点。N通常取9或12。这个方法快得离谱,但缺点是没有方向信息,也不抗尺度变化。

ORB的改进在于:用灰度质心法给FAST角点加方向,用rBRIEF(旋转后的BRIEF)做描述子。这样就有了旋转不变性。至于尺度不变性,ORB通过构建图像金字塔来实现——虽然不如SIFT精细,但够用了。

// ORB特征提取示例
cv::Ptr<cv::ORB> orb = cv::ORB::create(500); // 最多提取500个特征点
std::vector<cv::KeyPoint> keypoints;
cv::Mat descriptors;

orb->detectAndCompute(image, cv::Mat(), keypoints, descriptors);

// 绘制特征点
cv::Mat img_with_keypoints;
cv::drawKeypoints(image, keypoints, img_with_keypoints, cv::Scalar::all(-1));
避坑指南:ORB的尺度不变性依赖于金字塔层数。默认是8层,但如果你处理的场景尺度变化很大(比如无人机从高空俯拍到低空),建议增加到12层。我曾经在无人机SLAM项目里吃过这个亏,特征匹配率从30%直接掉到5%。

3.5 特征匹配

特征提取完了,接下来就是匹配——把两帧图像里的特征点对应起来。常用的方法有两种:暴力匹配和FLANN匹配。

3.5.1 暴力匹配

暴力匹配(Brute-Force Matching)就是最朴素的思路:对于第一张图的每个特征点,计算它与第二张图所有特征点的距离,取最近的那个。说白了,就是穷举搜索。

暴力匹配的优点是简单、准确。缺点是慢——如果两张图各有1000个特征点,就要计算100万次距离。在实时SLAM里,这通常不可接受。

// 暴力匹配示例
cv::BFMatcher matcher(cv::NORM_HAMMING); // ORB用汉明距离
std::vector<cv::DMatch> matches;
matcher.match(descriptors1, descriptors2, matches);

// 按距离排序,取前50个好的匹配
std::sort(matches.begin(), matches.end());
matches.resize(50);

3.5.2 FLANN匹配

FLANN(Fast Library for Approximate Nearest Neighbors)是近似最近邻搜索。它不保证找到绝对最近的点,但保证找到「足够近」的点,而且速度快得多。

FLANN的核心是构建索引结构,比如KD-Tree或LSH(局部敏感哈希)。对于高维描述子(比如SIFT的128维),KD-Tree的效果不错;对于二值描述子(比如ORB的256位二进制),LSH更合适。

// FLANN匹配示例(适用于SIFT/SURF)
cv::FlannBasedMatcher flann_matcher;
std::vector<cv::DMatch> matches;
flann_matcher.match(descriptors1, descriptors2, matches);

// 或者用knnMatch找最近的两个匹配
std::vector<std::vector<cv::DMatch>> knn_matches;
flann_matcher.knnMatch(descriptors1, descriptors2, knn_matches, 2);
我的建议:在嵌入式设备上(比如树莓派、Jetson Nano),优先用FLANN。在PC上做离线处理,可以用暴力匹配。另外,ORB特征建议用FLANN+LSH,速度能快10倍以上。

3.6 RANSAC去除误匹配

匹配完了,你会发现有很多错误匹配。为什么?因为图像里可能有重复纹理、光照变化、遮挡。这时候就需要RANSAC(随机采样一致性)来剔除误匹配。

RANSAC的思路很巧妙:随机选几对匹配点,计算一个变换模型(比如单应矩阵或基础矩阵),然后看有多少其他匹配点符合这个模型。符合的越多,说明模型越靠谱。重复这个过程,找到最优模型。

我曾经在做一个室内定位项目时,匹配率只有40%左右。用了RANSAC之后,虽然匹配数量减少了,但剩下的都是正确的,位姿估计精度直接翻倍。嗯,这里要提醒大家:RANSAC不是万能的。如果误匹配率超过50%,RANSAC也可能失效。

// RANSAC去除误匹配示例
std::vector<cv::Point2f> pts1, pts2;
for(const auto& match : matches) {
    pts1.push_back(keypoints1[match.queryIdx].pt);
    pts2.push_back(keypoints2[match.trainIdx].pt);
}

cv::Mat mask;
cv::Mat H = cv::findHomography(pts1, pts2, cv::RANSAC, 3.0, mask);

// 筛选内点
std::vector<cv::DMatch> inlier_matches;
for(int i = 0; i < mask.rows; i++) {
    if(mask.at<uchar>(i)) {
        inlier_matches.push_back(matches[i]);
    }
}
关键参数:RANSAC的阈值(上面代码中的3.0)很敏感。阈值太小,会把正确匹配也剔除;阈值太大,误匹配会混进来。我一般先设为3.0,然后根据实际效果微调。对于室内场景,2.0-4.0比较合适;室外大场景,可以放宽到5.0-8.0。

最后总结一下我的个人经验:

  • 实时性要求高(比如无人机、手持设备):用ORB + FLANN + RANSAC
  • 精度要求高(比如建图、三维重建):用SIFT + 暴力匹配 + RANSAC
  • 纹理丰富:Harris或ORB都行
  • 纹理稀疏:SIFT或SURF,因为它们的描述子更鲁棒

特征提取与匹配是视觉SLAM的基石。这一步做不好,后面的位姿估计、回环检测都是空中楼阁。希望大家在实际项目中多试几种组合,找到最适合自己场景的方案。下一章我们会讲光流法,那又是另一种思路了。