Skip to content

OpenCV实现的基于SIFT图像特征识别的匹配方法比较与实现

3744字约12分钟

3DVisionOpenCVSfM

2023-09-10

1 匹配器选择

目前常用的匹配器有 BFMatcher and FlannBasedMatcher

1.1 BFMatcher

BFMatcher 全称是 Brute-Force Matcher(直译即为暴力匹配器)

大致原理:

对于 img1 中的每个描述符, BFMatcher 将其与 img2 中的所有描述符进行比较;它计算两个描述符之间的距离度量(例如,欧几里得距离或汉明距离,默认使用欧几里得距离)并跟踪最接近的匹配,具有最小距离的描述符对被认为是潜在的匹配

用法示例:

创建一个 Brute-Force 匹配器,并使用匹配器对两组描述子进行匹配

// 创建匹配器对象
BFMatcher matcher;
// 使用 K 最近邻匹配方法进行特征匹配
vector<vector<DMatch>> matches;
matcher.knnMatch(descriptors1, descriptors2, matches, 2);
总结结论:

BFMatcher 将尝试所有可能性,这种匹配算法非常慢,匹配所需的时间随着添加的特征数量线性增加,这导致更高的计算成本,所以尤其是对于大型数据集BFMatcher 是一种简单但不一定是最有效的匹配方法

1.2 Flann

Flann 全称是 Fast Library for Approximate Nearest Neighbors(直译即为近似近邻快速库)

大致原理:

FLANN 旨在快速查找近似最近邻,尤其是在高维空间中,它不是详尽地搜索所有数据点,而是使用各种技术来更有效地执行搜索,同时提供相当准确的结果;FLANN 会构建了一个高效的数据结构,用于搜索近似邻居,FLANN 比 BFMatcher 快得多,但它只能找到近似的最近邻,这是一个很好的匹配,但不一定是最好的,可以调整 FLANN 的参数以提高精度,但这将以减慢算法速度为代价(鱼和熊掌不可兼得)

用法示例:

创建一个 FLANN 匹配器,并使用匹配器对两组描述子进行匹配

// 创建匹配器对象
FlannBasedMatcher matcher;
// 使用 K 最近邻匹配方法进行特征匹配
vector<vector<DMatch>> matches;
matcher.knnMatch(descriptors1, descriptors2, matches, 2);
总结结论:

FLANN 在牺牲一些精度的情况下,提供了更快的搜索速度,特别是在高维空间或大型数据集中

1.3 比较总结

  1. 准确度
    • BF 匹配器:BF 匹配提供精确的最近邻搜索,这意味着它可以高精度地找到最接近的匹配项;它适用于精度至关重要的任务,例如某些图像识别或对象跟踪任务
    • FLANN:FLANN 执行近似最近邻搜索,这意味着它会找到可能不是精确最近邻的近似匹配。虽然 FLANN 旨在提供良好的结果,但与 BF 匹配相比,速度的权衡可能会导致结果的准确性稍差
  2. 速度
    • BF 匹配器:BF 匹配简单明了,但计算成本可能很高,尤其是在高维空间或处理大型数据集时;它涉及将一个描述符与所有其他描述符进行比较,导致匹配 N 个关键点的时间复杂度为 O(N^2)
    • FLANN 专为高效的最近邻搜索而设计,特别是在高维空间中。它在速度方面通常优于 BF 匹配;FLANN的算法(KD-Tree、K-Means Tree、LSH等)的选择和参数调整可以进一步优化搜索速度

测试:

SIFT+FLANN+比率测试筛选ratio=0.6SIFT+BF+比率测试筛选ratio=0.6
效果img_matches(FLANN+knn0.6)0.338931秒img_matches(BF+knn0.6)0.494063秒
匹配+筛选用时0.338931秒0.494063秒

可以看出,两张图片效果基本相同,都有着极高的质量,但是在时间上,FLANN 比较与 BF 快了很多,效率更高

在许多计算机视觉和机器学习应用中,FLANN 是首选,因为它在速度和准确性之间取得了良好的平衡,使其适用于广泛的任务

所以我们选择 FLANN 作为匹配器

2 筛选方法

对于用不同匹配器得出的结果都会出现很多错误匹配,例如下图直接使用BF匹配器进行匹配:

img_matches(BF不筛选)

可以看到有数不清的点都进行了匹配,这样的效果非常差,这个时候筛选方法就显得十分重要了,利用好的筛选方法可以帮助我们最大程度上提升图片特征匹配的质量

2.1 距离阈值筛选

距离阈值筛选的原理很简单:它假设距离较近的特征点之间更可能是正确的匹配,而距离较远的特征点之间更可能是错误的匹配

  1. 用BF匹配器找出所有的匹配对

    BFMatcher matcher;
    vector<DMatch>matches;
    matcher.match(descriptors1, descriptors2, matches);
  2. 先通过遍历所有的匹配对,找的匹配中的最小距离

    double minDist = 1000;
    for (int i = 0; i < descriptors1.rows; i++)
    {
        double dist = matches[i].distance;
        if (dist < minDist)
        {
            minDist = dist;
        }
    }
  3. 再次遍历所有的匹配对,筛选出距离在距离阈值内的匹配

    其中 Multiples 是可设置的倍数,我们用最小距离的 Multiples 倍来当作距离阈值

    vector<DMatch>good_matches;
    for (int i = 0; i < descriptors1.rows; i++)
    {
        double dist = matches[i].distance;
        const float Multiples = 2.5; // 倍数参数
        if (dist < Multiples * minDist)
        {
            good_matches.push_back(matches[i]);
        }
    }

测试:SIFT+BF+距离阈值筛选

倍数Multiples结果图片
Multiples = 2.5img_matches(BF+2.5minDist)1
Multiples = 2.75img_matches(BF+2.75minDist)1
Multiples = 3.0img_matches(BF+3.0minDist)
Multiples = 3.25img_matches(BF+3.25minDist)1

2.2 比率测试筛选 Ratio test

比例测试的基本思想是比较最近邻匹配次近邻匹配的距离,并根据它们之间的距离比例来决定是否保留最近邻匹配

基本原理:距离最小的匹配是最近邻匹配,距离第二小的匹配相当于随机噪音,如果最近邻匹配无法与噪音区分开来,那么最近邻匹配就应该被剔除,因为它与噪音一样,没有带来任何有价值的信息

  1. 用BF匹配器找出所有的匹配对

    vector<vector<DMatch>> matches;
    BFMatcher matcher;
    matcher.knnMatch(descriptors1, descriptors2, matches, 2);

    注意这里使用 knnMatch 函数,matches 也需要设置为二维数组

    其返回每个查询描述子的前 K 个最佳匹配;K 被设置为 2,即返回每个查询描述子的两个最佳匹配:其中 matches[i][0] 表示第一个最佳匹配(最近的邻居),matches[i][1] 表示第二个最佳匹配(次相似的特征点)

  2. 设置比例参数 ratio,通常设置为 0.5 左右,筛选出最佳匹配与第二最佳匹配有明显区别的,从而丢弃我们不明确的匹配并保留好的匹配

    vector<DMatch> good_matches;
    for (int i = 0; i < matches.size(); ++i)
    {
    	const float ratio = 0.5; // 比例参数
    	if (matches[i][0].distance < ratio * matches[i][1].distance)
    	{
    		good_matches.push_back(matches[i][0]);
    	}
    }

测试:SIFT+BF+比率测试筛选

比例ratio结果图片
ratio = 0.4img_matches(BF+knn0.4)1
ratio = 0.5img_matches(BF+knn0.5)1
ratio = 0.6
ratio = 0.7img_matches(BF+knn0.7)1

2.3 比较总结

通过两组测试进行对比,在匹配对数量近似相等的情况下,明显比率测试筛选有着明显的优势,Multiples = 3.25 下已经出现了许多的错误,而 ratio = 0.6 在匹配对数量略大的情况下,保持较高的准确率

ratio = 0.6Multiples = 3.25
img_matches(BF+3.25minDist)1

所以最终我们选择比率测试筛选的筛选方法

3 特征点匹配实现(SIFT)

步骤1:导入必要的库和头文件

首先,导入必要的OpenCV库和头文件,设置图片路径:

#include<opencv2/opencv.hpp>
#include<iostream>
 
#define IMG_PATH1 "test_img\\1\\B23.jpg"
#define IMG_PATH2 "test_img\\1\\B24.jpg"
#define SAVE_PATH "test_img\\1\\img_matches.jpg"

using namespace std;
using namespace cv;

步骤2:加载图像

加载两幅要进行特征点匹配的图像。确保图像文件存在,并将它们放在项目目录下,或者根据您的文件路径进行适当的更改。

Mat img1 = imread(IMG_PATH1, IMREAD_GRAYSCALE);
Mat img2 = imread(IMG_PATH2, IMREAD_GRAYSCALE);
if (img1.empty() || img2.empty())
{
    cout << "Can't read image" << endl;
    return -1;
}

步骤3:创建SIFT检测器

创建SIFT检测器对象,可以选择不同的参数,例如最大特征点数量、尺度等。在本例中,我们使用默认参数。

Ptr<SIFT> sift = SIFT::create();

Ptr 是 OpenCV 提供的智能指针类,用于管理动态分配的对象,帮助防止内存泄漏和减少手动内存管理的负担

Ptr 主要用于管理 OpenCV 中的各种对象,例如图像、特征检测器、匹配器等;它可以自动跟踪和管理对象的引用计数,当对象不再需要时,会自动释放对象的内存,以确保资源被正确释放

步骤4:检测关键点和计算描述子

使用SIFT检测器在两幅图像中检测关键点,并计算它们的描述子。

vector<KeyPoint> keypoints1, keypoints2;
Mat descriptors1, descriptors2;

sift->detectAndCompute(img1, noArray(), keypoints1, descriptors1);
sift->detectAndCompute(img2, noArray(), keypoints2, descriptors2);

KeyPoint 是 OpenCV 中用于表示图像中关键点的类,关键点通常用于描述图像中的特征,例如角点、边缘、斑点等;KeyPoint 包含了关键点的位置、尺度、方向和响应等信息,它是计算机视觉中常用的数据结构之一

KeyPoint 类的主要成员包括:

  1. pt:一个 Point2f 类型的成员,表示关键点在图像中的二维坐标位置(x,y)
  2. size:一个浮点数,表示关键点的尺度,尺度通常表示关键点的特征区域大小
  3. angle:一个浮点数,表示关键点的方向,通常用于指示关键点的主要方向
  4. response:一个浮点数,表示关键点的响应值,响应值通常用于评估关键点的质量
  5. octave:一个整数,表示关键点所在的金字塔层级(octave),这是与尺度空间相关的信息
  6. class_id:一个整数,可用于标识关键点所属的类别或其他信息

detectAndCompute 是 OpenCV 中常用的函数,通常与特征检测和描述子计算相关。这个函数结合了特征检测和描述子计算两个步骤,用于从图像中检测关键点并计算关键点的描述子;它的主要作用是在一次操作中完成这两个关键的计算步骤,以提高计算效率和代码简洁性

其中第二参数是 mask 掩膜,抠出指定区域,我们先不用设置为空参数noArray() 或者 Mat()

descriptors 描述子通常是一个二维矩阵(或多维矩阵),是一个 Mat 对象,其中每一行代表一个关键点的描述子,描述子的维度取决于特征检测算法和配置参数,在 SIFT 中,描述子通常是128维的向量

步骤5:特征点匹配

创建一个Brute-Force匹配器,并使用匹配器对两组描述子进行匹配。

// 创建匹配器对象
FlannBasedMatcher matcher;
// 使用 K 最近邻匹配方法进行特征匹配
vector<vector<DMatch>> matches;
matcher.knnMatch(descriptors1, descriptors2, matches, 2);

DMatch 是 OpenCV 中用于存储特征匹配信息的结构体,它包含了两个特征点之间(一对)的匹配信息,包括以下成员:

  1. queryIdx:特征描述子在查询图像中的索引,这个索引对应于查询图像中的一个特征点
  2. trainIdx:特征描述子在训练图像中的索引,这个索引对应于训练图像中的一个特征点
  3. distance:描述了两个特征描述子之间的距离或相似性度量,通常,距离越小,表示两个特征点越相似

步骤6:筛选匹配点

筛选匹配点以获取最佳匹配,可以使用阈值来过滤掉不好的匹配

// 存储较好的匹配
vector<DMatch> good_matches;
for (int i = 0; i < matches.size(); ++i) {
    const float ratio = 0.5; // 比例参数
    // 仅保留比例参数内的较好匹配
    if (matches[i][0].distance < ratio * matches[i][1].distance) {
        good_matches.push_back(matches[i][0]);
    }
}

步骤7:绘制匹配结果

绘制匹配结果,将特征点匹配可视化。

// 绘制好的匹配结果并保存
Mat img_matches;
drawMatches(img1, keypoints1, img2, keypoints2, good_matches, img_matches);
imwrite("test_img\\img_matches.jpg", img_matches);

// 显示匹配结果的窗口
namedWindow("matches", WINDOW_NORMAL);
imshow("matches", img_matches);

// 等待用户按下任意键以关闭窗口
waitKey(0);

完整测试代码:

#include<opencv2/opencv.hpp>
#include<iostream>
#include <chrono> // 用于测试耗时
#define IMG_PATH1 "test_img\\1\\B23.jpg"
#define IMG_PATH2 "test_img\\1\\B24.jpg"
#define SAVE_PATH "test_img\\1\\img_matches.jpg"

using namespace std;
using namespace cv;
using namespace chrono; // 用于测试耗时

int main()
{
	Mat img1 = imread(IMG_PATH1, IMREAD_GRAYSCALE);
	Mat img2 = imread(IMG_PATH2, IMREAD_GRAYSCALE);
	if (img1.empty() || img2.empty())
	{
		cout << "Can't read image" << endl;
		return -1;
	}

	Ptr<SIFT> sift = SIFT::create();
	vector<KeyPoint> keypoints1, keypoints2;
	Mat descriptors1, descriptors2;

	sift->detectAndCompute(img1, noArray(), keypoints1, descriptors1);
	sift->detectAndCompute(img2, noArray(), keypoints2, descriptors2);
	
	//auto start = system_clock::now();	
	
	//--------------
	// BF+knn
	//--------------
	//vector<vector<DMatch>> matches;
	//cv::BFMatcher matcher;
	//matcher.knnMatch(descriptors1, descriptors2, matches, 2);
	//vector<DMatch> good_matches;
	//for (int i = 0; i < matches.size(); ++i)
	//{
	//	const float ratio = 0.6; // 比例参数
	//	if (matches[i][0].distance < ratio * matches[i][1].distance)
	//	{
	//		good_matches.push_back(matches[i][0]);
	//	}
	//}


	//--------------
	// FLANN+knn
	//--------------
	FlannBasedMatcher matcher;
	vector<vector<DMatch>> matches;
	matcher.knnMatch(descriptors1, descriptors2, matches, 2);
	vector<cv::DMatch> good_matches;
	for (int i = 0; i < matches.size(); ++i)
	{
		const float ratio = 0.8; // 比例参数
		if (matches[i][0].distance < ratio * matches[i][1].distance)
		{
			good_matches.push_back(matches[i][0]);
		}
	}
	
	//--------------
	// BF + 距离阈值筛选
	//--------------
	//BFMatcher matcher;
	//vector<DMatch>matches;
	//matcher.match(descriptors1, descriptors2, matches);
	//
	//double minDist = 1000;
	//for (int i = 0; i < descriptors1.rows; i++)
	//{
	//	double dist = matches[i].distance;

	//	if (dist < minDist)
	//	{
	//		minDist = dist;
	//	}
	//}

	//vector<DMatch>good_matches;
	//for (int i = 0; i < descriptors1.rows; i++)
	//{
	//	double dist = matches[i].distance;
	//	const float Multiples = 2.5; // 倍数参数
	//	if (dist < Multiples * minDist)
	//	{
	//		good_matches.push_back(matches[i]);
	//	}
	//}
    
    
	//auto end = system_clock::now();
	//auto duration = duration_cast<microseconds>(end - start);
	//cout << "花费了"
	//	 << double(duration.count()) * microseconds::period::num / microseconds::period::den
	//	 << "秒" << endl;
    
	Mat img_matches;
	drawMatches(img1, keypoints1, img2, keypoints2, good_matches, img_matches);

	imwrite(SAVE_PATH, img_matches);
	namedWindow("matches", WINDOW_NORMAL);
	imshow("matches", img_matches);
	waitKey(0);
}