首页
/ OpenCV AKAZE 局部特征匹配实战:基于 KAZE 特征的检测、2-NN 匹配与单应内点校验

OpenCV AKAZE 局部特征匹配实战:基于 KAZE 特征的检测、2-NN 匹配与单应内点校验

2026-09-06 18:13:42作者:冯爽妲Honey

AKAZE(Accelerated-KAZE)是一种基于非线性尺度空间的高效局部特征算子,其检测与描述能力在视角变化场景中表现出色。本文以 OpenCV 官方教程 AKAZE 局部特征匹配为主线,结合本仓库配套的 C++/Python/Java 示例源码与测试数据(Grafitti 序列),带你完整走通"检测特征点 → 计算二进制描述子 → 2-NN 暴力匹配 → 比率测试过滤 → 单应矩阵内点校验 → 绘制并统计"的经典特征匹配流水线。读完你将掌握 AKAZE 特征匹配的标准写法、两个关键阈值(nn_match_ratioinlier_threshold)的调优依据,以及如何用给定的 H1to3p 单应矩阵量化匹配质量。

1. 教程背景与本仓库对应关系

本教程源自 OpenCV 官方文档《AKAZE local features matching》(原始作者 Fedor Morozov,兼容 OpenCV >= 3.0),其完整内容已内置在本仓库中,位置为 akaze_matching.markdown。配套的完整示例代码存在以下三份,均实现同一套流程:

语言 仓库内相对路径
C++ AKAZE_match.cpp
Python AKAZE_match.py
Java AKAZEMatchDemo.java

重要前提(来自原教程警告):AKAZE 类目前位于 OpenCV contrib 扩展模块 xfeatures2d 中,编译/运行时需要额外构建该模块。这一点也能在本仓库源码中得到印证:经检索,本仓库主仓库的 modules/features(即经典 OpenCV 中的 features2d 模块)源码与头文件中并不包含 AKAZE 实现,示例代码必须通过 #include "opencv2/xfeatures2d.hpp"(C++)或 cv.xfeatures2d.AKAZE_create()(Python)来访问,并且 C++ 示例用 #ifdef HAVE_OPENCV_XFEATURES2D 做了编译期保护——若未启用 contrib 模块,程序只会输出一行提示而不执行匹配逻辑。

2. 实验数据:Grafitti 序列与已知单应矩阵

教程使用的是一组带已知单应矩阵(homography)的图像对,便于后续用真值客观评估匹配正确率。数据取自 Graffiti 序列(Oxford 视觉几何组数据集)的第 1 帧与第 3 帧,两帧之间存在显著的视角(透视)变化,是考验局部特征旋转/尺度/仿射不变性的标准场景。

在 OpenCV 仓库中这些文件已经随源码分发,直接位于 samples/data 目录:

  • graf1.png(800x640,第一幅灰度图)
  • graf3.png(800x640,第三幅灰度图)
  • H1to3p.xml(用 XML 存储的 3x3 单应矩阵)

Grafitti 序列第 1 帧与第 3 帧图像对

图像 1 到图像 3 的单应矩阵具体数值如下(该矩阵将 graf1 中的齐次坐标点映射到 graf3 中的对应点):

7.6285898e-01  -2.9922929e-01   2.2567123e+02
3.3443473e-01   1.0143901e+00  -7.6999973e+01
3.4663091e-04  -1.4364524e-05   1.0000000e+00

3. 完整流程拆解(C++ / Python 双版本)

整条流水线可划分为六个阶段,下面以仓库内真实源码为蓝本,逐段解释。先看两个贯穿全程的关键常量(AKAZE_match.cpp):

const float inlier_threshold = 2.5f; // Distance threshold to identify inliers with homography check
const float nn_match_ratio = 0.8f;   // Nearest neighbor matching ratio

对应 Python 版本(AKAZE_match.py):

nn_match_ratio = 0.8   # 最近邻匹配比率
inlier_threshold = 2.5 # 单应校验内点距离阈值
  • nn_match_ratio = 0.8:比率测试(ratio test)阈值,用于剔除歧义匹配;
  • inlier_threshold = 2.5:单应内点判定阈值(像素单位),用于判断某个匹配是否与已知单应模型一致。

3.1 阶段一:加载灰度图像与单应矩阵

原教程说明:此处加载的是灰度图,单应矩阵从 XML 文件经 FileStorage 读入。

C++ 版本(对应源码 AKAZE_match.cpp[load] 片段):

CommandLineParser parser(argc, argv,
                         "{@img1 | graf1.png | input image 1}"
                         "{@img2 | graf3.png | input image 2}"
                         "{@homography | H1to3p.xml | homography matrix}");
Mat img1 = imread( samples::findFile( parser.get<String>("@img1") ), IMREAD_GRAYSCALE);
Mat img2 = imread( samples::findFile( parser.get<String>("@img2") ), IMREAD_GRAYSCALE);

Mat homography;
FileStorage fs( samples::findFile( parser.get<String>("@homography") ), FileStorage::READ);
fs.getFirstTopLevelNode() >> homography;

Python 版本:

parser = argparse.ArgumentParser(description='Code for AKAZE local features matching tutorial.')
parser.add_argument('--input1', help='Path to input image 1.', default='graf1.png')
parser.add_argument('--input2', help='Path to input image 2.', default='graf3.png')
parser.add_argument('--homography', help='Path to the homography matrix.', default='H1to3p.xml')
args = parser.parse_args()

img1 = cv.imread(cv.samples.findFile(args.input1), cv.IMREAD_GRAYSCALE)
img2 = cv.imread(cv.samples.findFile(args.input2), cv.IMREAD_GRAYSCALE)
if img1 is None or img2 is None:
    print('Could not open or find the images!')
    exit(0)

fs = cv.FileStorage(cv.samples.findFile(args.homography), cv.FILE_STORAGE_READ)
homography = fs.getFirstTopLevelNode().mat()

实现细节说明:

  • 三个输入参数(图像 1、图像 2、单应文件)都提供默认值,因此直接运行即可复现教程结果;如需换数据,可通过命令行位置参数(C++)或 --input1/--input2/--homography(Python)指定。
  • samples::findFile / cv.samples.findFile 会先在当前目录查找,再在 OpenCV 安装时的 samples 数据目录中回退查找,所以即使不把 graf1.png 拷到当前目录也能定位到 samples/data 下的默认数据。
  • H1to3p.xml 内部以 <opencv_storage> 顶层节点保存矩阵,故读取时用 getFirstTopLevelNode()(C++ 经 >> 运算符、Python 经 .mat())即可还原为 Mat / np.ndarray

3.2 阶段二:用 AKAZE 检测关键点并计算描述子

原教程说明:创建 AKAZE 后对两幅图分别执行 detect 与 compute;由于本流程不需要掩膜参数,因此传入 noArray()(Python 传 None)。

C++ 版本(源码中 [AKAZE] 片段):

vector<KeyPoint> kpts1, kpts2;
Mat desc1, desc2;

Ptr<xfeatures2d::AKAZE> akaze = xfeatures2d::AKAZE::create();
akaze->detectAndCompute(img1, noArray(), kpts1, desc1);
akaze->detectAndCompute(img2, noArray(), kpts2, desc2);

Python 版本:

akaze = cv.xfeatures2d.AKAZE_create()
kpts1, desc1 = akaze.detectAndCompute(img1, None)
kpts2, desc2 = akaze.detectAndCompute(img2, None)

值得展开的技术背景(可从 OpenCV contrib 中 xfeatures2d 的 AKAZE 模块推导):

  • AKAZE 由 Pablo F. Alcantarilla 等人提出(原教程引用 ANB13),是 KAZE 的加速版。它基于非线性扩散滤波构造的金字塔构建尺度空间,相比 SIFT 的高斯差分金字塔,能更好地在保留边缘细节的同时完成尺度归一化;
  • 描述子默认是二进制类型(MLDB,Modified-Local-Difference-Binary),这一点直接决定了后续匹配必须使用**汉明距离(Hamming distance)**而非欧氏距离——这是原教程强调"因为 AKAZE 默认使用二进制描述子,所以我们采用 Hamming 距离"的根本原因;
  • detectAndCompute 将检测与描述计算合并为一次调用,掩膜参数(mask)置空表示全图参与处理。

3.3 阶段三:暴力匹配器求 2-NN(最近邻与次近邻)

原教程说明:使用暴力匹配(Brute-Force)配合 Hamming 距离求每对描述子的两个最近邻。

C++ 版本(源码中 [2-nn matching] 片段):

BFMatcher matcher(NORM_HAMMING);
vector< vector<DMatch> > nn_matches;
matcher.knnMatch(desc1, desc2, nn_matches, 2);

Python 版本:

matcher = cv.DescriptorMatcher_create(cv.DescriptorMatcher_BRUTEFORCE_HAMMING)
nn_matches = matcher.knnMatch(desc1, desc2, 2)

说明:

  • NORM_HAMMING / BRUTEFORCE_HAMMING 表示按二进制描述子的汉明距离(即逐位异或后统计 1 的个数)度量相似度。若误用欧氏范数(NORM_L2),匹配结果会完全失真;
  • knnMatch(..., 2)desc1 中的每个特征在 desc2 中寻找距离最小的两个候选,得到 nn_matches[i][0](最近邻)与 nn_matches[i][1](次近邻),供下一阶段的比率测试使用。若某点找不到两个候选,则该行长度不足 2,比率测试时应防御性跳过(示例中遍历时通过取下标 [0]/[1] 隐含了这一约束)。

3.4 阶段四:比率测试过滤歧义匹配

原教程说明:若最近邻距离显著小于次近邻距离,说明该匹配是"明确、无歧义"的,予以保留。

C++ 版本(源码中 [ratio test filtering] 片段):

vector<KeyPoint> matched1, matched2;
for(size_t i = 0; i < nn_matches.size(); i++) {
    DMatch first = nn_matches[i][0];
    float dist1 = nn_matches[i][0].distance;
    float dist2 = nn_matches[i][1].distance;

    if(dist1 < nn_match_ratio * dist2) {
        matched1.push_back(kpts1[first.queryIdx]);
        matched2.push_back(kpts2[first.trainIdx]);
    }
}

Python 版本:

matched1 = []
matched2 = []
nn_match_ratio = 0.8 # Nearest neighbor matching ratio
for m, n in nn_matches:
    if m.distance < nn_match_ratio * n.distance:
        matched1.append(kpts1[m.queryIdx])
        matched2.append(kpts2[m.trainIdx])

原理与调参:

  • 该思想源于 D. Lowe 的经典 ratio test:当 dist1(最优匹配距离)与 dist2(次优匹配距离)非常接近时,说明特征点具有周期性或重复纹理,最优匹配很可能选错;只有当 dist1 < 0.8 × dist2 时才认为匹配可靠;
  • nn_match_ratio 越小过滤越严格、误匹配越少,但可能丢弃部分正确匹配;越大则保留越多匹配、但噪声随之上升。0.8 是兼顾召回率与准确率的常用经验值,在视角变化剧烈的场景(如本教程的 Graffiti 序列)下尤为合适;
  • 过滤后按 queryIdx / trainIdx 回查 kpts1 / kpts2,把保留下来的关键点分别存入 matched1matched2(两者按索引一一对应)。

3.5 阶段五:用已知单应矩阵做内点(Inlier)校验

原教程说明:将第一幅图中匹配点的坐标用单应矩阵投影到第二幅图,若投影点与第二幅图匹配点的距离小于阈值,则认为该匹配符合单应模型,即内点。

C++ 版本(源码中 [homography check] 片段):

vector<DMatch> good_matches;
vector<KeyPoint> inliers1, inliers2;
for(size_t i = 0; i < matched1.size(); i++) {
    Mat col = Mat::ones(3, 1, CV_64F);
    col.at<double>(0) = matched1[i].pt.x;
    col.at<double>(1) = matched1[i].pt.y;

    col = homography * col;
    col /= col.at<double>(2);
    double dist = sqrt( pow(col.at<double>(0) - matched2[i].pt.x, 2) +
                        pow(col.at<double>(1) - matched2[i].pt.y, 2));

    if(dist < inlier_threshold) {
        int new_i = static_cast<int>(inliers1.size());
        inliers1.push_back(matched1[i]);
        inliers2.push_back(matched2[i]);
        good_matches.push_back(DMatch(new_i, new_i, 0));
    }
}

Python 版本:

inliers1 = []
inliers2 = []
good_matches = []
inlier_threshold = 2.5 # Distance threshold to identify inliers with homography check
for i, m in enumerate(matched1):
    col = np.ones((3,1), dtype=np.float64)
    col[0:2,0] = m.pt

    col = np.dot(homography, col)
    col /= col[2,0]
    dist = sqrt(pow(col[0,0] - matched2[i].pt[0], 2) +\
                pow(col[1,0] - matched2[i].pt[1], 2))

    if dist < inlier_threshold:
        good_matches.append(cv.DMatch(len(inliers1), len(inliers2), 0))
        inliers1.append(matched1[i])
        inliers2.append(matched2[i])

数学细节逐行解读:

  1. 将匹配点构造为齐次坐标列向量 col = (x, y, 1)^T
  2. 左乘单应矩阵:col' = H * col
  3. 由于单应结果仍是齐次坐标,需除以第三个分量 col' /= col'(2) 归一化为欧氏坐标 (x', y')
  4. 计算投影点 (x', y') 与第二幅图匹配点 (x, y) 的欧氏距离 dist
  5. dist < inlier_threshold(2.5 像素)时判为内点。

关于 good_matches 的构造(原教程特别强调):绘制函数 drawMatches 需要传入的 DMatch 索引在各自新集合内连续,因此这里新建一个内点集合,并用当前内点序号 new_i 构造 DMatch(new_i, new_i, 0),queryIdx 与 trainIdx 都指向内点序列自身,避免因原始索引稀疏而绘制出错。教程中提到"创建新的内点匹配集,是因为绘制函数要求如此",正是针对该细节。

3.6 阶段六:绘制并输出统计结果

原教程说明:保存结果图像并打印统计信息。

C++ 版本(源码中 [draw final matches] 片段):

Mat res;
drawMatches(img1, inliers1, img2, inliers2, good_matches, res);
imwrite("akaze_result.png", res);

double inlier_ratio = inliers1.size() / (double) matched1.size();
cout << "A-KAZE Matching Results" << endl;
cout << "*******************************" << endl;
cout << "# Keypoints 1:                        \t" << kpts1.size() << endl;
cout << "# Keypoints 2:                        \t" << kpts2.size() << endl;
cout << "# Matches:                            \t" << matched1.size() << endl;
cout << "# Inliers:                            \t" << inliers1.size() << endl;
cout << "# Inliers Ratio:                      \t" << inlier_ratio << endl;

imshow("result", res);
waitKey();

Python 版本:

res = np.empty((max(img1.shape[0], img2.shape[0]), img1.shape[1]+img2.shape[1], 3), dtype=np.uint8)
cv.drawMatches(img1, inliers1, img2, inliers2, good_matches, res)
cv.imwrite("akaze_result.png", res)

inlier_ratio = len(inliers1) / float(len(matched1))
print('A-KAZE Matching Results')
print('*******************************')
print('# Keypoints 1:                        \t', len(kpts1))
print('# Keypoints 2:                        \t', len(kpts2))
print('# Matches:                            \t', len(matched1))
print('# Inliers:                            \t', len(inliers1))
print('# Inliers Ratio:                      \t', inlier_ratio)

cv.imshow('result', res)
cv.waitKey()

说明:

  • drawMatches 把两幅图并排拼在一张画布上,用连线标记匹配对,连线只包含通过全部校验的内点;
  • 统计口径:Keypoints 为 AKAZE 直接检测出的特征总数;Matches 为通过比率测试的匹配数;Inliers 为同时通过单应校验的匹配数;Inlier Ratio = Inliers / Matches 是衡量整套特征(含描述子)质量与匹配正确率的综合指标;
  • 运行后当前目录会生成 akaze_result.png,同时弹出显示窗口,按任意键退出。

AKAZE 匹配最终结果(内点连线可视化)

4. 编译与运行

C++(以本仓库示例为例)

需在编译 OpenCV 时同时启用 contrib 的 xfeatures2d 模块(保证 HAVE_OPENCV_XFEATURES2D 宏生效)。之后将 AKAZE_match.cpp 编译并与 opencv_xfeatures2dopencv_features2dopencv_imgprocopencv_highgui 等库链接,典型编译命令形如:

g++ AKAZE_match.cpp -o akaze_match $(pkg-config --cflags --libs opencv4)

运行(默认参数会通过 samples::findFile 自动定位 samples/data 中的 graf1.pnggraf3.pngH1to3p.xml):

./akaze_match

如需显式指定输入,可改用位置参数传入图片与单应文件路径。

Python

直接以脚本方式运行即可(前提:安装的 cv2 由带 xfeatures2d 的 OpenCV contrib 构建):

python3 AKAZE_match.py --input1 graf1.png --input2 graf3.png --homography H1to3p.xml

同样,缺省时 cv.samples.findFile 会回退查找仓库 samples/data 下的默认数据;若脚本报 Could not open or find the images!,请确认数据路径或手动拷贝到当前目录。

5. 参考结果与质量评估

原教程给出的参考输出(数值会因 OpenCV 版本、AKAZE 参数与图像处理细节略有浮动,但量级一致):

Keypoints 1:   2943
Keypoints 2:   3511
Matches:       447
Inliers:       308
Inlier Ratio: 0.689038

解读这份结果:

  • 近 3000~3500 个关键点说明 AKAZE 在纹理丰富的 Graffiti 墙面场景下有很强的检测能力;
  • 447 个匹配经过比率测试留下,其中 308 个被已知单应真值验证为内点,内点率约 68.9%——在如此剧烈的透视变化下仍能保持较高的正确率,直观体现了 AKAZE 在视角鲁棒性上的优势;
  • 若发现内点率明显偏低,可依次排查:是否误用了非汉明范数、nn_match_ratio 是否过大、图像是否被错误地彩色加载、以及单应矩阵方向是否与图像顺序一致(本教程严格按 img1→img3 使用 H1to3p)。

6. 扩展阅读

本教程在 OpenCV 文档序列中位于"平面物体检测 → AKAZE 匹配 → AKAZE 跟踪"的链条中间,仓库内配套文档可继续深入:

  • 平面物体检测教程:演示如何用 findHomography + 特征匹配实时检测平面物体,与本教程的"已知单应校验"形成互补(前者是从匹配估计单应,后者是用真值单应验证匹配);
  • AKAZE 跟踪教程:在前一篇(本教程)的基础上继续,将 AKAZE 匹配用于视频帧间的目标跟踪;
  • 完整特性模块索引可参见 feature 系列教程目录,其中还包含 SIFT 描述子、FLANN 匹配、findHomography 等相邻主题;
  • 本教程实验数据(graf1.pnggraf3.pngH1to3p.xml)均可在仓库 samples/data 目录找到,方便直接替换路径复现。

小结:从本仓库的文档与源码可以确认一条完整的 AKAZE 匹配链路——AKAZE 检测与二进制描述计算(依赖 contrib 的 xfeatures2d)→ BRUTEFORCE_HAMMING 的 2-NN 匹配 → 0.8 比率测试剔除歧义 → 用 2.5 像素阈值配合真值单应筛选内点 → 绘制并统计内点率。这套"已知单应验证匹配"的框架同样适用于评测其他特征(SIFT、ORB 等):只需替换特征工厂与匹配范数,评估逻辑即可原样复用,是特征选型对比实验的理想起点。

登录后查看全文
热门项目推荐
相关项目推荐