SuperPoint(utils)
这个文件实现了一个完整的 SuperPoint 风格的局部特征(关键点 + 描述子)检测与匹配框架,基于 TensorFlow 1.x。整个模型包括检测头(detector head)、描述子头(descriptor head)、对应的损失函数以及后处理中的非极大值抑制(NMS)。下面我将分模块详细分析。
1. 整体框架概览
文件结构主要包含以下几个部分:
2. 检测头(Detector Head)
2.1 网络结构
def detector_head(inputs, **config):
# 两个 vgg_block
x = vgg_block(inputs, 256, 3, 'conv1', activation=tf.nn.relu, ...)
x = vgg_block(x, 1+grid_size², 1, 'conv2', activation=None, ...)
输入:特征图(通常是 backbone 的输出,如 VGG 的中间层)。
输出通道数:1 + grid_size²。其中 grid_size 是超参数,典型值为 8。
每个空间位置预测 grid_size² + 1 个 logits:grid_size² 对应一个局部 grid_size × grid_size 区域内的每个偏移位置,多出的一个通道作为“无兴趣点”的 dustbin。
2.2 解码为全分辨率概率图
prob = tf.nn.softmax(x, axis=cindex)
prob = prob[:, :-1, :, :] # 去掉 dustbin 通道
prob = tf.depth_to_space(prob, grid_size) # 将通道重新排列为空间块
prob = tf.squeeze(prob, axis=cindex) # 去掉单通道维度
先 softmax 得到每个网格内 grid_size²+1 个类的概率分布。
移除 dustbin 后,剩余的 grid_size² 个通道对应局部偏移的“存在概率”。
depth_to_space 将 [H, W, grid_size²] 变成 [H * grid_size, W * grid_size, 1],即恢复到与原始输入图像相同的分辨率。
最终 prob 的形状为 [N, Hgrid_size, Wgrid_size],每个像素的值表示该位置是兴趣点的概率。
这种设计使得网络可以在低分辨率特征图上进行高效计算,同时输出高分辨率的概率图。
3. 描述子头(Descriptor Head)
def descriptor_head(inputs, **config):
x = vgg_block(inputs, 256, 3, 'conv1', activation=tf.nn.relu, ...)
x = vgg_block(x, descriptor_size, 1, 'conv2', activation=None, ...)
# 双线性插值上采样到 grid_size 倍
desc = tf.image.resize_bilinear(desc, grid_size * tf.shape(desc)[1:3])
desc = tf.nn.l2_normalize(desc, axis=cindex)
1、同样先经过两个卷积块,输出通道数为 descriptor_size(如 256)。
2、由于描述子也需要在原始图像分辨率上匹配关键点,因此将特征图通过双线性插值上采样到 grid_size 倍。
3、最后沿通道方向做 L2 归一化,使得描述子的范数为 1,便于后续点积相似度计算。
输出 descriptors 的形状为 [N, descriptor_size, Hgs, Wgs](若 channels_first)或 [N, Hgs, Wgs, descriptor_size](若 channels_last)。
4. 检测器损失(Detector Loss)
def detector_loss(keypoint_map, logits, valid_mask=None, **config):
keypoint_map:二值标签图,形状 [N, H, W],1 表示该像素是兴趣点,0 表示不是。
logits:检测头第二个卷积块的原始输出(未经过 softmax),形状 [N, 1+gs², Hc, Wc]。
4.1 将真值标签转换为网格级别的索引
labels = tf.space_to_depth(keypoint_map[..., tf.newaxis], grid_size) # [N, Hc, Wc, 1]
labels = tf.concat([2*labels, tf.ones(shape)], axis=3) # [N, Hc, Wc, 2]
labels = tf.argmax(labels + tf.random_uniform(...), axis=3)
1、将二值标签图用 space_to_depth 下采样到 [N, Hc, Wc, 1],每个网格内如果有任何兴趣点,则该位置的值≥1?实际上 keypoint_map 是二值的,space_to_depth 会对每个 gs×gs 块内求和,结果可能是 0 或 >0。乘以 2 后变成 0 或 2。
2、然后在通道维拼接一个全 1 的通道(对应 dustbin 类)。这样每个网格位置上,如果原图中有兴趣点,则标签为 0(因为 [2, 1] 中 argmax 是 0);如果没有兴趣点,则两个值分别是 [0, 1],argmax 是 1。因此标签 0 表示“该网格内存在兴趣点(且具体偏移量后续由网络自己学习)”,标签 1 表示“不存在兴趣点(dustbin)”。
3、添加小随机数是为了防止 argmax 在数值相等时的不确定行为。
4.2 掩码处理
valid_mask = tf.space_to_depth(valid_mask, grid_size)
valid_mask = tf.reduce_prod(valid_mask, axis=3) # 如果一个网格内有一个无效像素,整个网格无效
用于屏蔽图像边界附近的像素(例如由于 warp 产生的无效区域)。
4.3 损失计算
loss = tf.losses.sparse_softmax_cross_entropy(labels=labels, logits=logits, weights=valid_mask)
标准的交叉熵损失,每个网格位置预测 (1+gs²) 个类,但真实标签只有两个类(存在点 / 不存在点)。然而网络实际上还需要预测点在该网格内的具体偏移位置——这里的损失并没有监督偏移量!这说明该检测头依赖于一种“隐式”的学习方式:网络会自动学会将最大的激活分配给正确的偏移位置,因为 softmax 会让模型在多个类上产生分布,而损失只要求正确分类“有点/无点”。那么偏移位置的预测如何学习?实际上在 SuperPoint 中,检测头通过虚拟的“网格内偏移”分类来间接学习,但这里的损失函数似乎缺少了对偏移量真值的监督。通常 SuperPoint 会为每个网格内的兴趣点分配一个 one-hot 标签(grid_size² 个位置中的哪一个),但这里只用了二分类。这可能是简化版本,或者依赖数据集中兴趣点的稀疏性,让网络通过重建损失或其他方式学习。
注意:在原始 SuperPoint 中,检测头输出的 grid_size²+1 个通道会通过一个 depth_to_space 后与真实兴趣点图计算交叉熵损失(像素级分类)。而这里将真实兴趣点图下采样到网格级别,只判断网格内有没有点,丢失了偏移信息。这意味着模型可能无法学习到精确的亚网格位置,而是将所有概率均匀分配给网格内的所有像素?需要结合训练数据进一步确认。不过也可能作者在后面将概率图通过 depth_to_space 后,再与原始标签图计算损失,但这里并没有。所以这段代码的损失函数是不完整的,仅用于判断“网格内有兴趣点”的二分类任务,实际应用中可能需要改进。
5. 描述子损失(Descriptor Loss)
描述子损失基于单应性变换,强制同一物理点在变换前后具有相似的描述子,而不同点具有不同的描述子。
5.1 计算匹配对应关系
coord_cells = ... # 每个网格中心在原始图像中的坐标
warped_coord_cells = warp_points(coord_cells, homographies) # 变换后的坐标
# 构建一个距离矩阵 (N, Hc, Wc, Hc, Wc)
cell_distances = tf.norm(coord_cells - warped_coord_cells, axis=-1)
s = tf.to_float(tf.less_equal(cell_distances, grid_size - 0.5))
coord_cells 是每个 grid_size × grid_size 网格的中心像素坐标(原始图)。
应用单应性矩阵,得到这些中心点在目标图像中的位置。
对每个原网格中心,计算它与所有目标网格中心的距离,若距离 < grid_size - 0.5 则认为是一个正确的匹配对,s=1;否则 s=0。这相当于建立了一个软匹配矩阵
5.2 描述子点积及 margin 损失
dot_product_desc = ... # 原图描述子与 warped 描述子的点积(经过两次 L2 归一化)
positive_dist = tf.maximum(0., positive_margin - dot_product_desc)
negative_dist = tf.maximum(0., dot_product_desc - negative_margin)
loss = lambda_d * s * positive_dist + (1 - s) * negative_dist
1、对于正匹配对(s=1),希望点积尽可能大,最小化 positive_margin - dot(典型 positive_margin=1)。
2、对于负匹配对(s=0),希望点积尽可能小,最小化 dot - negative_margin(典型 negative_margin=0.2)。
这是经典的对比损失(contrastive loss)。
5.3 掩码与归一化
valid_mask = tf.space_to_depth(valid_mask, grid_size)
valid_mask = tf.reduce_prod(valid_mask, axis=3)
normalization = tf.reduce_sum(valid_mask) * (Hc * Wc)
loss = tf.reduce_sum(valid_mask * loss) / normalization
同样使用网格级掩码,避免边界像素的影响。
6. 非极大值抑制(NMS)
6.1 spatial_nms
pooled = tf.nn.max_pool(prob, ksize=[1, size, size, 1], strides=1, padding='SAME')
prob = tf.where(tf.equal(prob, pooled), prob, tf.zeros_like(prob))
利用最大池化,只有与池化窗口内最大值相等的像素才保留,其余置 0。速度快,但可能保留多个相邻的相同极大值。
6.2 box_nms
pts = tf.where(prob >= min_prob) # 获取候选点坐标
boxes = tf.concat([pts-size/2, pts+size/2], axis=1) # 以点为中心构造一个正方形框
indices = tf.image.non_max_suppression(boxes, scores, max_output_size, iou_threshold)
1、将每个点视为一个框,然后调用 TensorFlow 内置的 NMS 算法(基于 IoU)。可选择保留 top_k 个得分最高的点。
2、 更精确,但计算量较大,适合后处理阶段。
7. 总结与注意事项
整体架构:典型的自监督特征点检测与描述网络。检测头通过网格划分和 depth_to_space 高效输出全分辨率概率图;描述头在低分辨率特征图上采样并归一化。
损失函数:检测损失使用二分类交叉熵,仅监督网格内是否存在兴趣点,缺少偏移量监督;描述损失使用基于单应性的对比损失。这种设计可能依赖于特殊的数据预处理或网络收敛特性,实际使用时需谨慎验证。
代码风格:TensorFlow 1.x 风格,使用 tf.variable_scope、tf.AUTO_REUSE 等。vgg_block 是自定义的卷积块(包含 BN、激活等)。warp_points 用于对点集应用单应性变换。
潜在改进:检测损失可以改为像素级交叉熵(将 prob 与 keypoint_map 直接计算损失),从而监督每个像素。描述子损失中的 dot_product_desc 被两次 L2 归一化可能冗余,实际一次就够。
更多推荐




所有评论(0)