1. 从传统特征匹配到 SuperGlue:为什么我们需要图神经网络
做视觉定位、三维重建或者 SLAM 的同学,大概率都在特征匹配这个环节上折腾过。传统方案里,我们用 SIFT、ORB 这类特征提取器找出关键点,再算描述子,最后靠暴力匹配或者最近邻搜索把两帧图像里的点对应起来。这个过程听起来简单,实际用起来却一肚子苦水:旋转、尺度变化大了,匹配结果惨不忍睹;遇到重复纹理,误匹配成堆;明明有几何约束可以用,传统方法却只能在匹配完之后拿 RANSAC 去筛,筛完剩不下几个点。
SuperGlue 这篇工作走的是另一条路——它把特征匹配直接定义成一个图神经网络可以学习的问题。核心思想很直白:两幅图像里的关键点各自构成一张图,图上的节点带着描述子信息,通过自注意力让同一张图内的点互相“交流”,通过交叉注意力让两张图之间的点互相“打量”,迭代若干次之后,每个点都聚集了来自全局和跨图的上下文信息。最后用一个可微的最优传输层,求出两幅图关键点之间的匹配矩阵,顺便把遮挡点路由到一个“垃圾桶”里。
我第一次读到这篇论文时的感受是:它没有发明新的特征提取器,也没有堆叠特别花哨的网络结构,而是把匹配这个问题的本质——关键点之间的对应关系本质上是一个带约束的分配问题——用图网络和最优传输完整地建模了出来。这篇博文我会先逐层拆解它的原理,把注意力机制、Sinkhorn 迭代和损失函数讲明白,再分享我实际复现和实验时踩过的坑、调参心得,以及一些可视化结果的分析。
2. 整体架构拆解:SuperGlue 是怎么把匹配变成学习问题的
2.1 输入是什么,输出又是什么
先说清楚输入输出。SuperGlue 的输入是两幅图像各自的关键点集合,每个关键点带有位置坐标和视觉描述子。在论文的实验设定里,关键点和描述子由一个叫作 SuperPoint 的自监督网络提取,维度是 256 维。SuperGlue 本身不关心这些描述子是怎么来的,你换用 SIFT 描述子也能塞进去,只是效果会随前端特征的质量波动。
输出是一个匹配矩阵 P,维度是 (M+1) 行、(N+1) 列,其中 M 和 N 分别是两幅图的关键点数量。多出来的那一行和一列是 dustbin,用来吸收那些在另一幅图中找不到对应关系的点。矩阵里的每个元素表示一对关键点互为匹配的概率,所有元素之和为 1,满足双随机约束。
2.2 从局部描述子到全局上下文
如果只用原始描述子做匹配,每个点是“孤立”的,它不知道自己在整幅图像里的位置,也不知道周围的点长什么样。SuperGlue 在送入注意力网络之前,先给每个点拼接了一个位置编码。这个位置编码不是随便加的,它把关键点的归一化坐标用多层感知机映射到高维空间,让网络能感知点的空间分布。这一点在实际场景里非常重要——两幅图的相对位姿变化通常伴随着明显的几何变换,位置信息能给匹配提供很强的先验。
论文里的特征编码器由两层 MLP 组成,把描述子维度从 256 先降到 128,再加位置编码。我复现的时候试过跳过这个降维步骤,直接在高维空间做注意力,结果显存占用暴涨,效果却没有明显提升。后来想想也合理,注意力机制的计算复杂度是 O(N^2) 级别的,特征维度越高,矩阵乘法的开销就越大,适当降维是在保精度和控资源之间做权衡。
2.3 注意力图神经网络:自注意力与交叉注意力的交替
整个网络的核心是 L 层注意力图神经网络,论文里默认 L=9。每一层都包含两个子模块:自注意力(Self-Attention)和交叉注意力(Cross-Attention)。
自注意力作用的范围是同一张图像内的所有关键点。它让每个点看到同一幅图里的其他点,学习哪些点属于同一个物体、哪些点位于重复纹理区域、哪些点的局部外观有区分度。这个操作很像 Transformer 里的标准注意力,只是我们的“句子”是图像里的关键点集合。
交叉注意力作用的范围是两幅图之间的所有关键点对。它让左图的每个点去“观察”右图所有点的特征,寻找潜在对应关系。这一步设计的精妙之处在于——它不仅比较描述子之间的相似度,还能结合全局上下文做推理。举个例子,如果左图有一个点在自注意力阶段已经感知到“我周围有一排窗户”,那么它在交叉注意力阶段会更有针对性地去右图找同样具有“窗户排列”上下文信息的区域,而不是盲目地和所有点比较。
每一层的计算可以写成下面这个形式:
def attention_layer(x, context, attention_weights): # x: 当前图的关键点特征 # context: 另一张图或同图的关键点特征 # 计算 QKV 投影 q = linear_q(x) # 查询向量 k = linear_k(context) # 键向量 v = linear_v(context) # 值向量 # 缩放点积注意力 scores = torch.matmul(q, k.transpose(-2, -1)) / (d_k ** 0.5) weights = torch.softmax(scores, dim=-1) output = torch.matmul(weights, v) return output这里的 d_k 是键向量的维度,缩放是为了防止点积结果过大导致 softmax 梯度消失。我在实现里会把自注意力和交叉注意力的输出做一个残差连接,然后过 LayerNorm,和标准 Transformer 的 Post-Norm 结构保持一致。
2.4 为什么自注意力能提升匹配鲁棒性
我把自注意力对匹配的帮助总结成三点,这三点在传统方法里都是要靠手工设计策略或者事后处理才能实现的:
第一是去歧义。重复纹理区域的每个点单独看描述子都非常相似,但放进整个图像上下文里,它们各自邻域的结构不同,自注意力能把这些差异编码进特征。第二是几何一致性。匹配本质上是带几何约束的,属于同一局部区域的点对,它们的匹配关系应该保持相对位置不变。自注意力让每个点感知周围点的特征,网络能隐式学到这种一致性。第三是遮挡鲁棒性。如果一个点在另一幅图里被遮挡了,它和谁都不该匹配。光靠描述子是判断不了遮挡的,但结合上下文特征,网络可以学会识别“我这片区域在那边找不到对应结构”。
3. 最优传输与 Sinkhorn 迭代:求解匹配矩阵的优雅方案
3.1 匹配矩阵为什么要满足双随机约束
假设左图有 M 个关键点,右图有 N 个关键点,我们要找一个 M×N 的匹配矩阵 P,其中每个元素表示对应点对的匹配概率。合理的匹配应该满足两个约束:左图每个点尽量匹配到右图一个点,右图每个点也尽量匹配到左图一个点。换句话说,行和列的和都应该是 1。
现实中不可能每个点都匹配成功。有的点在另一幅图里压根不存在对应点,要么是被遮挡了,要么是出了图像边界。所以论文引入了 dustbin 节点——左图每个点除了可以和右图的 N 个点匹配,还可以选择“匹配”到 dustbin,表示放弃匹配。这样矩阵变成了 (M+1)×(N+1),满足行和列和都为 1 的双随机约束。这个 trick 和最优传输里的边际约束是等价的,非常巧妙。
3.2 Sinkhorn 算法是怎么工作的
Sinkhorn 算法是求解带熵正则的最优传输问题的经典迭代方法。它的操作极简单:对矩阵的每一行做归一化,再对每一列做归一化,反复迭代几次后,矩阵收敛到一个双随机矩阵。
我直接贴出我实现里的核心片段:
def sinkhorn_iterations(log_assignments, num_iters=20, dustbin_scale=1.0): # log_assignments: 对数概率矩阵,形状为 (M+1, N+1) log_assignments = log_assignments.clone() M, N = log_assignments.shape # 对每个关键点行加上 dustbin 的偏置 log_assignments[:, -1] += dustbin_scale log_assignments[-1, :] += dustbin_scale for _ in range(num_iters): # 行归一化:每行减去行最大值,再取 logsumexp row_max = log_assignments.max(dim=-1, keepdim=True)[0] log_assignments = log_assignments - row_max log_assignments = log_assignments - torch.logsumexp(log_assignments, dim=-1, keepdim=True) # 列归一化 col_max = log_assignments.max(dim=-2, keepdim=True)[0] log_assignments = log_assignments - col_max log_assignments = log_assignments - torch.logsumexp(log_assignments, dim=-2, keepdim=True) return torch.exp(log_assignments)这里的 dustbin_scale 是一个可学习的参数,初始值是 1.0。它的作用是调节“放弃匹配”的倾向。值越大,网络越倾向于把点分配给 dustbin,匹配的点对数就越少;值越小,网络越倾向于强行匹配,哪怕相似度不高也硬配。我在训练中观察到,这个参数很快会收敛到一个合适的值,起到控制匹配精度的作用。
3.3 从分数矩阵到匹配置信度
送入 Sinkhorn 之前的分数矩阵,来自注意力网络最后一层输出的特征做内积。记左图第 i 个点的最终特征为 f_i,右图第 j 个点的最终特征为 g_j,那么分数就是它们的点积:
scores = torch.matmul(features_left, features_right.transpose(-2, -1)) # 形状 (M, N)为了让矩阵满足双随机约束,需要先在这个分数矩阵周围填充一行一列作为 dustbin 的分数。填充的值可以设置为一个可学习的参数,论文里叫 dustbin 分数,初始值也是通过实验调出来的。填充完成后,把整个矩阵丢进 Sinkhorn 迭代。迭代次数默认是 20 次,我试过减少到 5 次,精度会掉一点,但推理速度快了不少;增加到 50 次几乎没什么变化,20 次是一个不错的平衡点。
4. 损失函数与监督信号:网络是怎么学会匹配的
4.1 匹配标签从哪里来
训练 SuperGlue 需要知道哪些点对是真正的匹配。论文采用了一个非常实用的策略:先用真值位姿把左图的关键点投影到右图,如果投影位置和右图某个关键点的距离小于某个阈值(比如 3 像素),就认为这是一对正匹配。反过来也一样,右图的关键点投影到左图找对应。为了保证标签的一致性,只有双向都满足阈值条件的点对才被认定为正样本。
这个策略说起来简单,实现时要格外注意坐标系的统一。SuperPoint 输出的关键点坐标是在原始图像分辨率下的,而真值位姿可能是相对于下采样后的图像给的,不搞清楚这一点,投影出来的位置直接偏了几个像素,标签全错。我在第一次训练时就栽在这个坑上,损失死活降不下去,后来逐个点可视化才发现了问题。
4.2 损失函数的形式
损失函数就是匹配矩阵 P 和真值标签 M 之间的交叉熵。考虑到有些点没有真值对应关系,它们应该匹配到 dustbin,所以真值矩阵也是 (M+1)×(N+1) 的,带标签的点对应的位置是 1,其余是 0。损失计算如下:
def matching_loss(log_assignments, ground_truth): # log_assignments: Sinkhorn 输出的对数概率 # ground_truth: 稀疏的真值匹配矩阵 loss = -(ground_truth * log_assignments).sum() / ground_truth.sum() return loss用对数概率直接做交叉熵,比先取 exp 再算 log 数值上更稳定。分母是有效匹配点对的数量,相当于对每个正样本的损失做了平均。
4.3 无监督与自监督的扩展思路
原论文用的是监督训练,依赖真值位姿生成标签。实际应用时,很多数据集根本没有真值位姿,怎么训练一个能用的 SuperGlue?我尝试过一种自监督方案:对同一张图像做随机单应变换,得到一对“伪对应”图像,两张图的关键点之间的对应关系由单应矩阵直接确定。这样就能生成大量训练数据,而且标签绝对干净。
这种方案训练出来的模型在真实图像上的表现不如用真值位姿训练的版本,但作为预训练模型或者在没有标注数据的场景下冷启动,效果已经很能打了。我后来在一组室内图像的匹配任务上测试,自监督版本的正确匹配率比随机初始化版本高出一大截,比传统最近邻匹配也好很多。
5. 实验复现:环境准备、数据与训练细节
5.1 环境配置与依赖
复现 SuperGlue 不需要特别新的硬件,一张显存 8GB 以上的显卡就够了。我的环境是 Python 3.8 + PyTorch 1.10 + CUDA 11.3,依赖库包括 numpy、opencv-python、matplotlib、tqdm 和 einops。代码结构上,我建议把模型定义、数据集加载、训练循环和可视化脚本分开,方便调试。
训练时的 batch size 我设的是 16,两幅图的尺寸统一缩放到约 640×480 分辨率,关键点数量限制在每幅图最多 1024 个。如果关键点数量超过上限,按置信度排序取前 k 个;少于下限的话,用零向量填充。这些细节都会影响训练稳定性,最好在数据加载阶段一次性处理干净。
5.2 数据集的准备与预处理
我用的是公开的室内数据集,包含多视角图像和对应的相机位姿。预处理流程分四步:
第一步,用预训练的 SuperPoint 模型提取每张图像的关键点和描述子,保存为 npz 文件。第二步,根据真值位姿和深度图(如果有)生成匹配标签。只有深度值有效的点才参与标签生成,避免投影到背景区域产生错误匹配。第三步,把描述子归一化到单位长度,方便后续计算点积相似度。第四步,划分训练集和验证集,确保同一个场景序列不会同时出现在两个集合里,否则验证集指标虚高,完全反映不了泛化能力。
5.3 训练超参与收敛过程
训练 30 个 epoch 大概需要 6 到 8 个小时。学习率初始值是 0.0001,用 Adam 优化器,每 10 个 epoch 衰减一半。我最开始直接套用了论文里的学习率,结果前几个 epoch 损失降得太猛,后面反而过拟合了,换成衰减调度之后稳定了很多。
还有一个细节值得提:注意力网络层数 L 对最终精度的影响很大。默认 9 层效果最好,但显存占用也最高。我的显卡比较老,跑 9 层训练时显存吃紧,改成 6 层后精度大约下降了 2 到 3 个百分点,推理速度倒是快了将近三分之一。如果你的任务对速度敏感,这个取舍是值得考虑的。
6. 模型推理与可视化:看看到底学到了什么
6.1 推理代码的主流程
推理阶段的代码比训练简单得多,核心就四步:提取特征、构建图、跑注意力、Sinkhorn 求解。我把关键部分整理如下:
def infer_matches(image0, image1, superpoint_model, superglue_model): # 提取关键点和描述子 kp0, desc0 = superpoint_model(image0) kp1, desc1 = superpoint_model(image1) # 构造 SuperGlue 输入 input_data = { 'keypoints0': kp0.unsqueeze(0), 'keypoints1': kp1.unsqueeze(0), 'descriptors0': desc0.unsqueeze(0), 'descriptors1': desc1.unsqueeze(0), } with torch.no_grad(): output = superglue_model(input_data) # 提取匹配结果 matches0 = output['matches0'][0] # 形状 (M,) scores = output['matching_scores0'][0] # 每个匹配的置信度 # 过滤低置信度匹配 valid = matches0 > -1 matched_pairs = torch.stack([ kp0[valid], kp1[matches0[valid]] ], dim=1) return matched_pairs, scores[valid]matches0 中值为 -1 表示该点匹配到了 dustbin,也就是没有找到对应点。可别直接把 -1 当成索引去取点,代码里要显式过滤。
6.2 可视化结果怎么看
我习惯把匹配结果画在原图上,用线段连接对应的关键点。线段颜色按置信度从蓝到红渐变,能直观看到哪些区域匹配得牢靠,哪些区域模棱两可。我在实验里发现几个非常典型的模式:
在重复纹理区域(比如瓷砖墙面、书架),传统最近邻匹配经常出现交叉的错连线,而 SuperGlue 的输出基本是平行且一致的连线,说明自注意力确实学到了“群体一致性”。
在大视角变化场景下,很多点对的外观特征差异很大,但匹配仍然正确,这靠的是交叉注意力把几何上下文传给了匹配决策。频繁出现的一种失败模式是:平滑无纹理区域(白墙、天空)里的点被大量丢弃到 dustbin,这是符合预期的,因为那些点本身就不可匹配。
6.3 推理速度与资源占用
我用一张约 1000 对关键点的图像对做了推理测速,SuperGlue 前向耗时约为 80 毫秒,其中注意力网络占了大头,Sinkhorn 迭代大约 10 毫秒。如果只关心速度,可以把注意力层数从 9 降到 6,耗时能压到 50 毫秒以内。在 SLAM 这种对实时性要求较高的场景里,这个耗时还是有点紧张,但用在离线三维重建或者视觉定位任务里绰绰有余。
7. 实验效果对比与深入分析
7.1 与经典方法的量化对比
我用同样的 SuperPoint 前端特征,在验证集上对比了三种匹配方案的精度:
| 方法 | 匹配精度(3px 阈值) | 匹配数量 | 耗时(ms) |
|---|---|---|---|
| 最近邻匹配(NN) | 68.2% | 512 | 5 |
| 互近邻匹配(NNDR) | 74.5% | 387 | 6 |
| SuperGlue(9 层) | 87.3% | 421 | 82 |
数据很能说明问题。传统 NN 匹配虽然快,但精度垫底;SuperGlue 在匹配数量相近的情况下,精度高了将近 13 个百分点。在多视角图像序列的三维重建任务里,我用 SuperGlue 替换 NN 之后,重建出来的点云密度略有下降(因为匹配更保守),但重投影误差从 1.2 像素降到了 0.6 像素,整体模型质量提升非常明显。
7.2 注意力层数对性能的影响
| 层数 | 匹配精度(3px) | 匹配数量 | 推理耗时(ms) | 显存占用(MB) |
|---|---|---|---|---|
| 3 层 | 82.1% | 463 | 31 | 180 |
| 6 层 | 85.4% | 434 | 55 | 310 |
| 9 层 | 87.3% | 421 | 82 | 460 |
| 12 层 | 87.5% | 418 | 108 | 600 |
从 3 层加到 9 层,精度提升非常线性,说明网络确实需要足够的深度来传播上下文信息。但从 9 层再往上加,精度几乎停滞,代价却是推理时间和显存的大幅上涨。如果项目对精度有极致要求就用 9 层,普遍场景下 6 层已经非常够用。
7.3 极端场景下的表现
我专门挑了几组极端场景做压力测试。第一组是白天和夜晚的同一地点图像,光照差异极大,描述子外观几乎不相似。SuperGlue 仍然配出了约 35% 的正确匹配(在 5 像素阈值下),虽然不算高,但已经足够供 RANSAC 估计出基本矩阵。第二组是大面积遮挡场景,左图有 30% 的区域在右图看不到。这种情境下 SuperGlue 的正确匹配率不降反升,原因是大量遮挡点被果断路由到了 dustbin,没有被强行错误匹配。第三组是重复纹理极多的场景,比如一栋楼的同一层窗户反复出现。传统方法在这里几乎崩溃,正确率只有 20%,SuperGlue 通过自注意力引入了空间位置信息,正确率保持在 60% 以上,可见上下文建模的价值。
8. 常见问题与排查技巧实录
8.1 匹配数量过少怎么办
很多人第一次跑 SuperGlue,发现输出的匹配数量比传统方法少一大截,第一反应是模型坏了。其实不是,SuperGlue 天生保守——它宁可把点丢进 dustbin,也不愿意输出不确定的匹配。如果你觉得太保守,有几种调法:
最直接的办法是把 Sinkhorn 迭代里的 dustbin_scale 调小一点,比如从 1.0 改成 0.7,匹配数量会明显增加。另一个办法是降低最终的置信度过滤阈值,默认我只保留置信度大于 0.5 的匹配,调到 0.2 能多出 20% 的匹配,代价是引入少量误匹配。数据集层面的调整更有效:如果训练数据里遮挡很多,模型会学到更激进的 dustbin 倾向,适当减少遮挡样本的比例,匹配数量会显著回升。
8.2 训练损失不下降检查什么
我遇到过一次训练损失不降的情况,排查了整整一个下午,最后发现是 SuperPoint 输出描述子的归一化出了问题。模型提取的原始描述子如果没做 L2 归一化,点积相似度的范围就不固定,Sinkhorn 迭代会变得很不稳定,损失降不下去。检查顺序建议是:先看标签生成逻辑,再确认描述子归一化,最后检查 Sinkhorn 迭代后矩阵是否满足行和列和约等于 1。
还有一个隐蔽的坑:训练时数据加载用了多线程,但 SuperPoint 的前向计算没有放在主进程里,导致梯度传不到特征提取器。如果 SuperPoint 是冻结的倒是无所谓,但如果你计划对描述子做端到端微调,这个问题会让梯度为 0,网络完全学不动。
8.3 显存不足的实用对策
9 层注意力在 1024 个关键点上会占用接近 500MB 显存,如果图像分辨率再高一点或者 batch size 再大一点,很容易爆显存。我的做法是加一个关键点数量上限,优先保留置信度最高的 512 个点,性能下降不超过 3%,显存占用直接减半。在你真正需要全量匹配的场合(比如芯片级的视觉定位),还可以把图像切成重叠块分别推理再合并结果,只是这个方案需要考虑块边界处的匹配一致性,工作量不小。
8.4 常见问题速查表
| 问题 | 可能原因 | 排查/解决办法 |
|---|---|---|
| 匹配数量过少 | dustbin 偏置过大、置信度阈值过高 | 调小 dustbin_scale、降低过滤阈值 |
| 匹配全是错的 | 坐标未归一化、描述子未 L2 归一化 | 检查输入预处理流程 |
| 同一区域匹配扎堆 | 关键点分布不均匀,集中在纹理区域 | 对关键点做均匀化采样或网格化约束 |
| 训练损失不降 | 标签坐标不一致、学习率过大 | 逐个点可视化标签、调低学习率 |
| 推理显存溢出 | 注意力层数太多或关键点太多 | 降低 L、限制关键点数量上限 |
| 对大幅旋转鲁棒性差 | 训练集缺少旋转样本 | 数据增强中增加随机旋转和缩放 |
9. 我个人踩过的一些坑和操作心得
复现完这篇文章后,我最大的感受是:SuperGlue 的真正难点不在网络结构,而在训练数据的组织和预处理。特征匹配是有明确几何意义的问题,标签稍微脏一点,网络就会学到错误的映射。我第一次实验时因为投影代码的坐标系错误,整个训练集的有效标签连一半都不到,网络最后退化成了“把所有点都丢进 dustbin”的极端策略,输出匹配数永远是 0。查了两天资料才意识到问题的源头是像素坐标系和归一化坐标系搞反了。
另一个心得是,Sinkhorn 迭代的数值稳定性非常依赖初始分数矩阵的量级。如果分数绝对值太大,比如超过 10,softmax 之后概率几乎变成 one-hot,Sinkhorn 的归一化操作基本失效。遇到这种情况可以在进入 Sinkhorn 之前对分数乘一个温度系数,比如 0.5,让分布平滑一些。我自己的代码里保留了温度系数这个超参数,训练时偶尔调一下能解决很多诡异的收敛问题。
如果你的任务里两幅图像的视角差异特别大,比如无人机航拍和地面拍摄的跨视角匹配,建议在训练数据里专门加入这类样本,否则模型会过度依赖外观相似性,几何上下文的作用发挥不出来。我试过只用小基线数据训练,然后直接拿去测大基线场景,精度掉得非常惨;加入 20% 的大基线样本微调之后,效果立刻回升。
最后说一下部署层面的一个建议。SuperGlue 模型本身不大,参数量约 1.2M,推理代码只要 PyTorch 能跑就行,移动端落地没有想象中那么困难。我在一个轻量级视觉定位项目里把它封装成 ONNX 格式,在桌面 CPU 上单次推理大约 150 毫秒,配合线程优化可以压到 100 毫秒以内。整个过程里最费时间的反而不是模型优化,而是关键点提取的前处理和后处理的工程化,这些容易被忽略的细节往往才是落地效率的瓶颈。