视觉SLAM总结——SuperPoint / SuperGlue
视觉SLAM总结——SuperPoint / SuperGlue
在我刚开始接触SLAM算法的时候听到过一个大佬讲:“SLAM其实最重要的是前端,如果特征匹配做得足够鲁棒,后端就可以变得非常简单”,当时自己总结过一篇传统视觉特征的博客视觉SLAM总结——视觉特征子综述,由于当时对深度学习了解不够,因此并没有涵盖基于深度学习的视觉特征匹配方法,而实际上,基于深度学习的特征匹配效果要远优于传统方法。本博客结合代码对2018年提出的SuperPoint和2020年提出的SuperGlue两篇最为经典的深度学习算法进行学习总结。
1. SuperPoint
SuperPoint发表于2018年,原论文名为《SuperPoint: Self-Supervised Interest Point Detection and Description
》,该方法使用无监督的方式训练了一个用于提取图像特征以及特征描述子的网络。
1.1 网络结构
SuperPoint的网络结构如下图所示:
我们知道,图像特征通常包括两部分,特征点已经特征描述子,上图中两个分支即分别用来提取特征点和特征描述子。网络首先使用了VGG-Style的Encoder用于降低图像尺寸提取特征,Encoder部分由卷积层、Max-Pooling层和非线性激活层组成,通过三个Max-Pooling层将图像尺寸变为输出的,代码如下:
# Shared Encoder
x = self.relu(self.conv1a(data['image']))
x = self.relu(self.conv1b(x))
x = self.pool(x)
x = self.relu(self.conv2a(x))
x = self.relu(self.conv2b(x))
x = self.pool(x)
x = self.relu(self.conv3a(x))
x = self.relu(self.conv3b(x))
x = self.pool(x)
x = self.relu(self.conv4a(x))
x = self.relu(self.conv4b(x)) # x的输出维度是(N,128,W/8, H/8)
对于特征点提取部分,网路先将维度的特征处理为
大小,这里的
的含义是特征图的每一个像素表示原图
的局部区域加上一个当局部区域不存在特征点时用于输出的Dustbin通道,通过Softmax以及Reshape的操作,最终特征会恢复为原图大小,代码如下:
# Compute the dense keypoint scores
cPa = self.relu(self.convPa(x)) # x维度是(N,128,W/8, H/8)
scores = self.convPb(cPa) # scores维度是(N,65,W/8, H/8)
scores = torch.nn.functional.softmax(scores, 1)[:, :-1] # scores维度是(N,64,W/8, H/8)
b, _, h, w = scores.shape
scores = scores.permute(0, 2, 3, 1).reshape(b, h, w, 8, 8) # scores维度是(N,W/8, H/8, 8, 8)
scores = scores.permute(0, 1, 3, 2, 4).reshape(b, h*8, w*8) # scores维度是(N,W/8, H/8)
scores = simple_nms(scores, self.config['nms_radius'])
这里指的注意的是我们是先对包括Dustbin通道的特征图进行Softmax操作后再进行Slice的。假如没有Dustbin通道,当的局部区域内没有特征点时,经过Softmax后64维的特征势必还是会有一个相对较大的值输出,但加入Dustbin通道后就可以避免这个问题,因此需要在Softmax操作后再进行Slice。最后再经过NMS后相应较大的位置即为输出的特征点。
对于特征描述子提取部分,网络先将维度的特征处理为
大小,其中
将是我们即将输出的特征的维度。按照通道进行归一化后根据特征点的位置通过双线性插值得到特征向量。
# Compute the dense descriptors
cDa = self.relu(self.convDa(x))
descriptors = self.convDb(cDa) # descriptor的维度是(N,256,8,8)
descriptors = torch.nn.functional.normalize(descriptors, p=2, dim=1) # 按通道进行归一化
# Extract descriptors
descriptors = [sample_descriptors(k[None], d[None], 8)[0]
for k, d in zip(keypoints, descriptors)]
其中双线性插值相关的操作在sample_descriptors中,该函数如下:
def sample_descriptors(keypoints, descriptors, s: int = 8):
""" Interpolate descriptors at keypoint locations """
b, c, h, w = descriptors.shape
keypoints = keypoints - s / 2 + 0.5
keypoints /= torch.tensor([(w*s - s/2 - 0.5), (h*s - s/2 - 0.5)],
).to(keypoints)[None]
# 这里*s的原因是keypoints在(W,H)特征图上提取的,而descriptor目前是在(W/s,H/s)的特征图上提取的
keypoints = keypoints*2 - 1 # normalize to (-1, 1)
args = {'align_corners': True} if torch.__version__ >= '1.3' else {}
descriptors = torch.nn.functional.grid_sample(
descriptors, keypoints.view(b, 1, -1, 2), mode='bilinear', **args) # 双线性插值
descriptors = torch.nn.functional.normalize(
descriptors.reshape(b, c, -1), p=2, dim=1)
return descriptors
以上就完成了SuperPoint前向部分特征点和特征向量的提取过程,接下来看下这个网络是如何训练的?
1.2 损失函数
首先我们看下基于特征点和特征向量是如何建立损失函数的,损失函数公式如下:其中
为特征点相关的损失,
为特征向量相关的损失,其中
、
、
分别为图像上通过网络提取的特征点、特征向量和特征点的真值。
特征点相关损失定义为一个交叉熵损失:
其中
其中
,
;
为真值,即
的方格中哪个Pixel为特征点;
是一个长度为
的特征向量,向量的每一个数值代表相应的Pixel上特征点的响应值。
特征向量相关损失定义为一个合页损失(Hinge-Loss,在SVM算法中用到):
其中
其中
为定义的权重,
为判断是通过单应矩阵关联判断否为同一个特征点的函数:
其中
为
的方格的中心点,当
和
距离小于8个像素时认为匹配成功,一个放个对应的其实是一个特征点。此外我们来分析下合页损失,当匹配成功时,当相似度
大于正样本阈值
时会进行惩罚;当匹配失败时,当相似度
小于负样本阈值
时会进行惩罚。在这样的损失函数作用下,当匹配成功时,相似度就应该很大,匹配失败时,相似度就应该很小。
1.3 自监督训练过程
以上就是网路损失函数的相关定义,在SuperPoint论文中很重要的一部分就是不监督训练,下面我们来看看该网络是如何实无监督训练的。网络训练一共分为如下几部分:
以上就完成SuperPoint算法的基本原理介绍,其效果如下:
2. SuperGlue
SupreGlue发表于2020年,原论文名为《SuperGlue: Learning Feature Matching with Graph Neural Networks》,该论文引入了Transformer实现了一种2D特征点匹配方法。我们知道,在经典的SLAM框架中,前端进行特征提取,后端进行非线性优化,而中间非常重要的一步就是特征匹配,传统的特征匹配通常是结合最近邻、RASANC等一些算法进行处理,SuperGlue的推出是SLAM算法迈向端到端深度学习的一个重要里程碑。由于下面的网络介绍中涉及到Transformer的相关知识,对这部分知识不熟悉的同学可以参考下计算机视觉算法——Transformer学习笔记。
2.1 Sinkhorn算法
SuperGlue论文中给出的算法Pipeline如下图所示:
Sinkhorn Algorithm解决的是最有传输问题,即解决如何以最小代价将一个分布转移为另一个分布的问题,这里我们参考博客Notes on Optimal Transport中的一个例子来说明该算法:
家里有的水果种类和份数如下表所示:
水果类别 | 苹果 | 梨子 |
---|---|---|
份数 | 2 | 1 |
家庭成员对水果的需求量如下表所示:
家庭成员 | 小A | 小B | 小C |
---|---|---|---|
需求数量 | 1 | 1 | 1 |
然后团队成员不同家庭成员对不用水果的喜好程度还不一样,如下表所示:
小A | 小B | 小C | |
---|---|---|---|
苹果 | 0 | 1 | 2 |
梨子 | 2 | 0 | 1 |
那么我们怎样分配水果才能使得整体最优呢?如果我们将该问题当作一个二分图问题的话,那么可以使用匈牙利匹配或者KM算法求解,但是二分图问题要求对象是不可微分的,也就是说水果是不可以切开的。但是Sinkhorn Algorithm解决的是可微分问题,也就是这里我们分水果的时候可以将水果切开分。
下面我们通过公式来定义该问题,我们定义家庭成员对水果的需求为,水果的数量分布为
,接下来我们定义分配矩阵
为
包含了所有分配的可能性,接下来我们根据团队成员对甜点的定义损失矩阵
,我们最佳的分配方案应该满足
注意这里不是矩阵相乘,而是逐个像素相乘再相加。使用Sinkhorn Algorithm求解该问题的流程其实非常简单,如下图所示:
import numpy as np
cost = np.array([[0,1,2],[2,0,1]], dtype=np.float)
row_weight = np.array([2,1])
col_weight = np.array([1,1,1])
for _ in range(100):
cost = cost / np.sum(cost, axis=1).reshape(2, -1) * row_weight.reshape(2, -1)
print(cost)
cost = cost / np.sum(cost, axis=0).reshape(-1, 3) * col_weight.reshape(-1, 3)
print(cost)
最后输出的结果为:
[[0. 1. 0.99340351]
[1. 0. 0.00659649]]
很合理地,算法将苹果分给了更喜欢的小B和小C,而小A得到的是他最爱的梨子,这个算法流程其实非常简单,但是为什么会这样就能得到最优?什么情况下会不收敛?这些问题我暂时没有投入太多时间去深入了解,之后有时间再补上。
2.2 网络结构
下面我们结合代码一步一步解析SuperGlue的网络Pipeline是怎样得到Score矩阵以及SuperGlue算法针对遮挡、噪声等问题实现的一些Trick:
假定有两张图像,分别检测出
和
个特征点,分别记为
和
,每个特征点由
表示,其中
为第
个特征点的(归一化后的)位置和置信度,
为第
个特征点的特征向量。我们首先对输入网络的特征点和特征向量进行编码:
其作用就是特征点的位置和特征向量编码进同一个特征
,使得网络在进行匹配时能够同时考虑到特征描述和位置的相似性,了解Transformer的同学应该想到这其实就是Transformer中普遍用的Positional Encoding。在代码中这个过程如下:
self.kenc = KeypointEncoder(self.descriptor_dim, self.keypoint_encoder)
desc0 = desc0 + self.kenc(kpts0, data['scores0'])
desc1 = desc1 + self.kenc(kpts1, data['scores1'])
class KeypointEncoder(torch.jit.ScriptModule):
""" Joint encoding of visual appearance and location using MLPs"""
def __init__(self, feature_dim, layers):
super().__init__()
self.encoder = MLP([3] + layers + [feature_dim])
nn.init.constant_(self.encoder[-1].bias, 0.0)
@torch.jit.script_method
def forward(self, kpts, scores):
inputs = [kpts.transpose(1, 2), scores.unsqueeze(1)] # 注意这里将Score也Concatenate进输入
return self.encoder(torch.cat(inputs, dim=1))
def MLP(channels: list, do_bn=True):
""" Multi-layer perceptron """
n = len(channels)
layers = []
for i in range(1, n):
layers.append(
nn.Conv1d(channels[i - 1], channels[i], kernel_size=1, bias=True))
if i < (n-1):
if do_bn:
layers.append(nn.BatchNorm1d(channels[i]))
layers.append(nn.ReLU())
return nn.Sequential(*layers)
接下来我们就是将特征输入进Attention Graph Neural Network,作者在这里定义了两种Graph,分别是连接特征点
和同一张图像内的其他特征点的无向图
以及连接特征点
与另一张图像内的特征点的无向图
。我们通过Transformer计算特征点
与图
的信息
,这个“信息”是原论文中"message"的直译,可以理解为通过Transformer提取的特征。信息
计算过程如下:
其中,
、
、
分别为value、query、key,计算过程如下:
代码如下:
self.attn = MultiHeadedAttention(num_heads, feature_dim)
message = self.attn(x, source, source)
class MultiHeadedAttention(torch.jit.ScriptModule):
""" Multi-head attention to increase model expressivitiy """
prob: List[torch.Tensor]
def __init__(self, num_heads: int, d_model: int):
super().__init__()
assert d_model % num_heads == 0
self.dim = d_model // num_heads
self.num_heads = num_heads
self.merge = nn.Conv1d(d_model, d_model, kernel_size=1)
self.proj = nn.ModuleList([deepcopy(self.merge) for _ in range(3)]) # 对应不同的W
self.prob = []
@torch.jit.script_method
def forward(self, query, key, value):
batch_dim = query.size(0)
query, key, value = [l(x).view(batch_dim, self.dim, self.num_heads, -1)
for l, x in zip(self.proj, (query, key, value))] # 通过卷积提取query、key、value
x, prob = attention(query, key, value) # 进行attention计算
self.prob.append(prob)
return self.merge(x.contiguous().view(batch_dim, self.dim*self.num_heads, -1)) # 合并多头结果
def attention(query, key, value):
dim = query.shape[1]
scores = torch.einsum('bdhn,bdhm->bhnm', query, key) / dim**.5
prob = torch.nn.functional.softmax(scores, dim=-1)
return torch.einsum('bhnm,bdhm->bdhn', prob, value), prob
得到信息后,我们进一步更新特征:
其中
为第
层图像
上特征点
对应的特征,
表示的Concatenate操作,值得注意的是,当
为为奇数时计算的
的信息,而
为偶数时计算的是
的信息。这部分代码如下所示:
desc0, desc1 = self.gnn(desc0, desc1)
self.gnn = AttentionalGNN(self.descriptor_dim, self.GNN_layers)
class AttentionalGNN(torch.jit.ScriptModule):
def __init__(self, feature_dim: int, layer_names: list):
super().__init__()
self.layers = nn.ModuleList([
AttentionalPropagation(feature_dim, 4)
for _ in range(len(layer_names))])
self.names = layer_names
@torch.jit.script_method
def forward(self, desc0, desc1):
for i, layer in enumerate(self.layers):
layer.attn.prob = []
if self.names[i] == 'cross':
src0, src1 = desc1, desc0
else: # if name == 'self':
src0, src1 = desc0, desc1
delta0, delta1 = layer(desc0, src0), layer(desc1, src1)
desc0, desc1 = (desc0 + delta0), (desc1 + delta1) # 这里相当于residual相加
return desc0, desc1
class AttentionalPropagation(torch.jit.ScriptModule):
def __init__(self, feature_dim: int, num_heads: int):
super().__init__()
self.attn = MultiHeadedAttention(num_heads, feature_dim)
self.mlp = MLP([feature_dim*2, feature_dim*2, feature_dim])
nn.init.constant_(self.mlp[-1].bias, 0.0)
@torch.jit.script_method
def forward(self, x, source):
message = self.attn(x, source, source)
return self.mlp(torch.cat([x, message], dim=1)) # 这里就是上文公式对应的代码计算
这里反复迭代Self-/Cross-Attention的目的原论文指出是为了模拟人类进行匹配时来回浏览的过程,其实Self-Attention为了使得特征更加具备匹配的特异性,而Cross-Attention是为了这些具备特异性的点在图像间进行相似度比较。这个过程在原论文中作者有很好的可视化出来,如下图所示:
在完成Attention Graph Neural Network计算后,我们对所有特征点的特征进行一层MLP构建最终我们匹配用的Score矩阵:
其中
为点乘操作,对应代码如下:
# Final MLP projection.
mdesc0, mdesc1 = self.final_proj(desc0), self.final_proj(desc1)
# Compute matching descriptor distance.
scores = torch.einsum('bdn,bdm->bnm', mdesc0, mdesc1)
scores = scores / self.descriptor_dim**.5
self.final_proj = nn.Conv1d(self.descriptor_dim, self.descriptor_dim, kernel_size=1, bias=True)
接下来就是对scores矩阵应用Sinkhorn算法:
scores = log_optimal_transport(
scores, self.bin_score,
iters=self.config['sinkhorn_iterations'])
def log_sinkhorn_iterations(Z: torch.Tensor, log_mu: torch.Tensor, log_nu: torch.Tensor, iters: int) -> torch.Tensor:
""" Perform Sinkhorn Normalization in Log-space for stability"""
u, v = torch.zeros_like(log_mu), torch.zeros_like(log_nu)
for _ in range(iters):
u = log_mu - torch.logsumexp(Z + v.unsqueeze(1), dim=2)
v = log_nu - torch.logsumexp(Z + u.unsqueeze(2), dim=1)
return Z + u.unsqueeze(2) + v.unsqueeze(1)
def log_optimal_transport(scores: torch.Tensor, alpha: torch.Tensor, iters: int) -> torch.Tensor:
""" Perform Differentiable Optimal Transport in Log-space for stability"""
b, m, n = scores.shape
one = scores.new_tensor(1)
ms, ns = (m*one).to(scores), (n*one).to(scores)
bins0 = alpha.expand(b, m, 1)
bins1 = alpha.expand(b, 1, n)
alpha = alpha.expand(b, 1, 1)
couplings = torch.cat([torch.cat([scores, bins0], -1),
torch.cat([bins1, alpha], -1)], 1)
norm = - (ms + ns).log()
log_mu = torch.cat([norm.expand(m), ns.log()[None] + norm])
log_nu = torch.cat([norm.expand(n), ms.log()[None] + norm])
log_mu, log_nu = log_mu[None].expand(b, -1), log_nu[None].expand(b, -1)
Z = log_sinkhorn_iterations(couplings, log_mu, log_nu, iters)
Z = Z - norm # multiply probabilities by M+N
return Z
Sinkhorn算法原理在上文中已经介绍过,这里值得注意的一点是算法针对特征匹配失败问题的处理Trick,文章中特别由提到这一部分,如果不考虑特征点匹配失败的情况,那么我们得到的分配矩阵应该满足:
其中待分配向量
和
分别为长度为
和
,元素值为
的向量。为了求得分配矩阵
,我们是通过计算得分矩阵
获得,但是由于真实环境中存在遮挡或者噪声,总会有特征点匹配不上的情况,为此,作者在得分矩阵添加一行和一列作为Dustbin:
此时,待分配向量拓展为
和
,且分配矩阵满足:
其实这也很好理解,对于待匹配点,要求其最多只能与另一个匹配点匹配,但是对于Dustbin,最多能够
或者
个匹配点与之对应,也就是说最坏的情况就是所有的匹配点都没有匹配上。
2.3 损失函数和网络训练
SuperGlue的损失函数如下所示:其中
为匹配点的真值,
和
为图像
和
中没有匹配上的特征点。
关于网络的训练文章在论文的附录中有提到:
(1)基于带有Depth和位姿真值的数据集获取真值,基本方法是通过使用SuperPoint或者SIFT分别在已知位姿的两张图像进行特诊检测,根据Depth将一张图像的特征点投影到另一张图像上,通过重投影误差判断哪些点是匹配成功的点作为真值的正样本。对于负样本,也就是没有匹配上的点,要求重投影误差大于一定的值。
(2)SuperGlue对Indoor、Outdoor和Homograpy变换场景分别训练,Indoor、Outdoor场景训练使用Homograpy变换的模型作为处置。
(3)SuperGlue和SuperPoint的联合训练中,分别使用了两个SuperPoint网络分别进行检测和特征提取的工作,在训练过程中,检测部分的网络权重锁住,只训练特征提取部分的网络。
以上就完成SuperPoint和SuperGlue的总结,除了SuperPoint和SuperGlue,这两年使用NN做特征匹配的方法也有不少,比如:
《OpenGlue: Open Source Graph Neural Net Based Pipeline for Image Matching》
匹配速度更快;
《LoFTR: Detector-Free Local Feature Matching with Transformers》
无特征匹配,对于若纹理区域效果特别好;
《MatchFormer: Interleaving Attention in Transformers for Feature Matching》
改进的LoFTR, 将Attention引入特征编码阶段,大幅度提升特征匹配在大视角变化时的匹配性能
这些算法之后有时间可以再整理下,有问题欢迎交流~
文章出处登录后可见!