RealCat

· 95 min read

论文解读:XoFTR,让可见光与热红外在 LoFTR 里对上话

TL;DR XoFTR 是 METU 与 ROKETSAN 在 CVPR 2024 Image Matching Workshop 上提出的可见光—热红外(TIR)局部特征匹配器[1]:它以 LoFTR 为骨架,把粗匹配的互近邻一对一改成 AdaMatcher 式的一对多分配,用 PMatch 式 MIM 在真实可见—热红外对上预训练,再用余弦变换生成的伪热红外图在 MegaDepth 上微调,最后在 1/2 尺度重匹配并回归亚像素。自建 METU-VisTIR 上,阴—阴相对位姿 AUC@20 为 55.06(DKM 19.17、LoFTR 14.11),阴—晴为 45.03(DKM 23.60);代价是 640×512 单对推理 116 ms(LoFTR 102 ms)。代码、权重与数据集均已开源(代码 Apache-2.0,数据集 CC BY-NC-SA 4.0)。

一、动机:热红外能穿透坏天气,却穿不过模态鸿沟

热红外(thermal infrared,TIR)相机的卖点很直接:它记录的是物体自身的热辐射,不依赖环境光,因此雨、雾、雪、夜间都不至于让图像彻底失效;可见光相机在这些条件下则可能过曝、欠曝或一片模糊。把两者放在一起匹配,是无人机夜间定位、多传感器融合、红外—可见光配准等任务的共同需求。

但“都拍到了同一栋楼”不等于“能匹配”。可见光记录反射光,热红外记录热辐射,同一块墙面在两种图像里的亮度关系是非线性的:沥青路面在可见光里很暗,白天却可能因为吸热而变亮;玻璃幕墙在可见光里纹理丰富,在热红外里却可能是一块均匀的冷区。再叠加 TIR 相机通常分辨率更低、视场更窄,以及无人机视角带来的尺度与视点变化,可见—热红外匹配同时撞上了模态差异与视角差异两堵墙。

现有方法基本分两路。手工描述子(互信息、LGHD 一类)和早期的学习型方法[2]能在强度关系上做文章,但大多只在同视角、小形变的配准设定下验证;Shape-Former、MIVI 这类跨模态方法又往往不做大视角、大尺度的多视图匹配。另一路是可见光匹配器:SuperGlue、LightGlue 这类检测器+图神经网络的方法[3, 4],以及 LoFTR、DKM、ASpanFormer 这类无检测器(detector-free)方法[5, 6, 7],它们在 MegaDepth 上训练、在可见光基准上很强,却默认训练与测试同模态——把热红外图直接喂进去,性能会塌。论文表 1 的分类很直白:LoFTR、DKM、AdaMatcher、PMatch 支持多视图但不支持多模态;Shape-Former、MIVI 支持多模态但不支持多视图;只有 XoFTR 两者都打勾。

作者把缺口归纳成两条:没有带标注的城市场景可见—热红外数据,以及粗匹配的一对一假设与跨模态特征不一致相冲突。围绕这两条,论文的贡献是:

  1. 一套两阶段训练流程:先在真实可见—热红外对上做掩码图像建模(masked image modeling,MIM)预训练,再用伪热红外增强的可见光数据微调;
  2. 一套从 1/8 粗匹配到 1/2 细匹配、再到亚像素回归的匹配管线,粗匹配允许一对多,细匹配用低置信度阈值过滤;
  3. 一个新的城市场景可见—TIR 数据集 METU-VisTIR,带 GT 相机位姿,覆盖阴天与晴天;
  4. 在上述基准和公开单应数据上总体大幅超过 LoFTR、DKM、ASpanFormer、SuperGlue、LightGlue 等强基线。
XoFTR 与 LoFTR 在真实可见—热红外图像对上的匹配对比
图 1论文 Fig. 1。左列为热红外,右列为可见光。上:XoFTR 在 567 条匹配中保留 466 条极线内点(精度 82.2%),平移/旋转误差 0.95°/0.39°;下:LoFTR 只有 7/15(46.7%),平移误差 54.40°。绿线是极线误差小于 的内点,红/黄线误差较大。仅展示 RANSAC 后的内点。

二、核心方法

2.1 总体架构:保留 LoFTR 的注意力,换掉其余假设

XoFTR 的骨架几乎就是 LoFTR:ResNet 风格 CNN 抽三层特征(1/8、1/4、1/2),1/8 特征加正弦位置编码后送入 4 层线性自注意力/交叉注意力(直接复用 LoFTR 的 LocalFeatureTransformer[5]),得到相关后的粗特征;随后粗匹配、细匹配、亚像素精化依次进行。论文没有提出新的注意力块,所有改动都在“怎么选匹配、怎么训练、怎么从粗到细”上——这也解释了为什么它的收益主要来自分配规则与数据,而不是网络容量。

XoFTR 总体架构
图 2论文 Fig. 2。CNN 骨干输出 1/8、1/4、1/2 三层特征;CLMM 在 1/8 上做粗匹配(允许一对一与一对多);FLMM 把粗匹配对应的 1×1、3×3、5×5 窗口沿通道与空间逐级融合,在 1/2 尺度重匹配;SPRM 用 5×5 窗口特征回归四个亚像素偏移。注意 FLMM 同时充当 MIM 预训练的解码器。

一个容易被忽略的实现细节:网络在 forward 里对每张输入图单独做标准化,

image0_mean = data['image0'].mean(dim=[2,3], keepdim=True)
image0_std = data['image0'].std(dim=[2,3], keepdim=True)
image0 = (data['image0'] - image0_mean) / (image0_std + eps)
# image1 同样处理

这不是普通的数据预处理,而是跨模态匹配的必要条件:可见光与热红外的绝对亮度分布完全不同,逐图减均值、除标准差至少把“模态整体偏移”消掉,让网络只看结构。论文正文没有强调这一点,但官方代码把它放在模型内部,推理时也生效。

2.2 粗匹配:为什么要放弃一对一

LoFTR 的粗匹配是标准的一对一:对相似度矩阵做两个方向的 softmax,得到 (每行归一化)与 (每列归一化),再要求一对 patch 同时是对方的行最大值与列最大值(互近邻,mutual nearest neighbor),并且双 softmax 置信度超过阈值。这个规则隐含一个假设:在 1/8 尺度上,一个 patch 只对应另一个模态的一个 patch。

跨模态恰恰破坏这个假设。TIR 分辨率更低、视场更窄,加上视角与尺度差,一个可见光 patch 覆盖的场景区域可能落在好几个 TIR patch 的交界处;反过来,一个 TIR patch 也可能同时“像”好几个可见光 patch。此时行最大值与列最大值指向不同的配对,互近邻会把本来正确的匹配直接丢掉——而且是在最早期、召回率最脆弱的一步丢掉,后面的细匹配再强也救不回来。AdaMatcher 在大尺度/大视角可见光匹配里已经指出过这个问题[8],XoFTR 把它搬到跨模态场景。

具体做法是:相似度仍由线性投影后的内积给出,

两个方向的匹配概率为

但粗匹配集合改成两个方向的行/列最大值并集(下面简称 CLMM):

直白地说:只要 是 眼里最像的,或者 是 眼里最像的,任意一个方向成立且置信度过阈值,就保留。于是多个可见光 patch 可以同时选中同一个 TIR patch(many-to-one);反过来,一个可见光 patch 也可以作为多个 TIR patch 的列最大值,对应多个 TIR patch(one-to-many)。阈值 ,softmax 温度 。

这样做的代价是粗匹配数量变多、精度下降;论文的赌注是:粗匹配阶段应该保召回,把消歧留给 1/2 尺度的细匹配。细匹配会在每个粗匹配周围 5×5 窗口里重新计算相似度,并用 过滤低置信度,最终每条粗匹配至多产出一个细匹配。消融支持这个赌注:把粗匹配换回一对一(表 5 第 3 行),AUC@5/10/20 从 12.59/27.90/45.03 掉到 9.95/22.60/38.73,分别损失 2.64/5.30/6.30 个点。论文没有报告该变体的运行时间,只把完整模型比 LoFTR 多出的 14 ms 归因于一对多带来的更多粗匹配。

代码:粗匹配的行/列最大值并集(官方 src/xoftr/xoftr_module/coarse_matching.py,原文节选)
        # confidence thresholding
        # {(nearest neighbour for 0 to 1) U (nearest neighbour for 1 to 0)}
        mask = torch.logical_or((conf_matrix_0_to_1 > self.thr) * (conf_matrix_0_to_1 == conf_matrix_0_to_1.max(dim=2, keepdim=True)[0]),
                               (conf_matrix_1_to_0 > self.thr) * (conf_matrix_1_to_0 == conf_matrix_1_to_0.max(dim=1, keepdim=True)[0]))
        
        mask = rearrange(mask, 'b (h0c w0c) (h1c w1c) -> b h0c w0c h1c w1c',
                         **axes_lengths)
        if 'mask0' not in data:
            mask_border(mask, self.border_rm, False)
        else:
            mask_border_with_padding(mask, self.border_rm, False,
                                     data['mask0'], data['mask1'])
        mask = rearrange(mask, 'b h0c w0c h1c w1c -> b (h0c w0c) (h1c w1c)',
                         **axes_lengths)

        # find all valid coarse matches
        b_ids, i_ids, j_ids = mask.nonzero(as_tuple=True)

        mconf = torch.maximum(conf_matrix_0_to_1[b_ids, i_ids, j_ids], conf_matrix_1_to_0[b_ids, i_ids, j_ids])

官方实现还有两个值得对照的细节。其一,训练时分别计算两个方向的 softmax 矩阵;推理时为了省显存,复用同一个 conf_matrix_ 变量,先算行 softmax 选一遍,再算列 softmax 选一遍,最后取并集——数学上等价,但不必同时保留两个完整矩阵。其二,border_rm=2 会把粗特征图最外两圈 patch 的匹配全部屏蔽,避免把图像边界上“半个 patch”的伪匹配带进细匹配。

下面这个玩具模型把分配规则单独拎出来:A 是可见光 patch 网格,B 是热红外 patch 网格,两侧的小圆点是 patch 中心,连线是保留下来的粗匹配,红圈标出有 GT 却被丢掉的 A 点。拖动分辨率比 (TIR 相对更粗时 )和视角偏移,看看一对一与一对多分别剩下多少条正确匹配。

玩具模型说明:相似度用高斯核,softmax 温度 0.12;真实网络在 1/8 特征图上用学习到的线性投影,温度 0.1、阈值 0.3,并额外裁掉 2 格边界。这里只看分配规则本身,不模拟网络噪声。

默认参数下(、、),一对一只剩 15 条匹配、召回 62.5%;一对多保留全部 24 个 A 点,而它们只落在 15 个 B 格点上,召回 100%、没有错误匹配。把 拉回 1.0,两种规则都回到 24/24——差异完全来自尺度/视点造成的多对一。这也说明 XoFTR 的粗匹配增益不是“多加了几条线”,而是把跨模态里本来存在、却被互近邻判死的对应关系保留了下来。

2.3 细匹配:在 1/2 尺度上重开一局

粗匹配只给出 1/8 尺度上的 patch 对,位置误差可达 8 个原图像素;如果直接拿粗匹配去做 RANSAC,精度不够。LoFTR 的做法是在 1/2 尺度上对每个粗匹配周围裁剪固定窗口,用相关特征做 soft-argmax 得到亚像素。XoFTR 同样在 1/2 尺度重匹配,但不是一次 soft-argmax,而是一个小型的窗口 Transformer:

  1. 先把 LoFTR 前后的 1/8 特征沿通道拼接,经 1×1 卷积降到 1/4 尺度特征的通道数(196),再经 3×3 深度可分离卷积与 BN,得到融合了“相关前外观”和“相关后上下文”的 ;
  2. 对每条粗匹配 ,在 、、 上分别裁 、、 窗口;
  3. 把 1×1 窗口降维后与 3×3 窗口拼成 token,做窗口内自注意力,再在 A/B 两图之间做双向交叉注意力;然后把 3×3 输出与 5×5 窗口拼接,重复一遍自注意力+交叉注意力;
  4. 在 5×5 窗口的 25×25 相似度矩阵上做双 softmax,取最大值并过 。

公式上,融合与逐级传递是

5×5 窗口的匹配概率则是

自注意力用普通多头注意力,交叉注意力用双向注意力(query/key 共享投影),窗口本身只有 9 或 25 个 patch token,计算量很小;自注意力时还会把上一级的窗口(1×1 粗特征或 3×3 中间特征)作为额外的 1 或 9 个 token 拼进来,所以实际序列长度是 10 和 34。位置信息由一个两层 MLP 把绝对 2D 坐标编码进特征(隐层 512);论文用的是绝对位置偏置而不是正弦编码,消融里去掉它,AUC@20 掉 1.67。

为什么是 1/2?1/8 太粗,1/4 仍可能把两个相邻结构混在一个 cell 里;1/2 已经接近原图纹理,且只在粗匹配周围开窗,成本与图像面积无关。消融第 4 行把 FLMM+CLMM 整体换成 LoFTR 的粗到细模块(1/2 尺度、一层 5×5 窗口),AUC@20 从 45.03 掉到 26.77,损失 18.26 个点——这是所有模块替换里最重的一刀。第 6 行去掉细匹配的第二个阈值 ,掉 1.47 个点;第 7 行去掉位置偏置,掉 1.67 个点。

代码:FLMM 的窗口注意力顺序(官方 src/xoftr/xoftr_module/fine_process.py,原文节选)
        # self attention (c + m) 
        feat_m_unfold, _ = self.self_attn_m(torch.cat([feat_m0_unfold, feat_m1_unfold], dim=0),
                                                         torch.cat([feat_c0, feat_c1], dim=0))
        feat_m0_unfold, feat_m1_unfold = torch.chunk(feat_m_unfold, 2, dim=0)

        # cross attention (m0 <-> m1)
        feat_m0_unfold, feat_m1_unfold = self.cross_attn_m(feat_m0_unfold, feat_m1_unfold)

        # down proj m
        feat_m_unfold = self.down_proj_m_f(torch.cat([feat_m0_unfold, feat_m1_unfold], dim=0))
        feat_m0_unfold, feat_m1_unfold = torch.chunk(feat_m_unfold, 2, dim=0)

        # self attention (m + f) 
        feat_f_unfold, _ = self.self_attn_f(torch.cat([feat_f0_unfold, feat_f1_unfold], dim=0),
                                                         torch.cat([feat_m0_unfold, feat_m1_unfold], dim=0))
        feat_f0_unfold, feat_f1_unfold = torch.chunk(feat_f_unfold, 2, dim=0)

        # cross attention (f0 <-> f1)
        feat_f0_unfold, feat_f1_unfold = self.cross_attn_f(feat_f0_unfold, feat_f1_unfold)

细匹配的选择逻辑与粗匹配不同:每个 5×5 窗口只取全局最大值(denser=False 时),并要求它过 。论文把这一步解释为“在 1/2 尺度上重新评估置信度、过滤低置信度匹配”,图 2 也标注 ,即设计上每条粗匹配对应一个细匹配;实现里过不了 的会被丢掉。这带来一个实现层面的细节:如果两条粗匹配共享同一个 A 点(一对多的典型情形),它们各自独立地在自己的 5×5 窗口里取 argmax,代码并没有跨粗匹配做全局去重,最终仍可能输出重复的 A 点。论文说“细匹配+亚像素精化可以防止一个点匹配多个点”,更准确的理解是“每条粗匹配只保留一个细匹配,且低置信度的会被阈值滤掉”,而不是严格的全局互斥。这一点论文没有展开,算是我读代码时补的注脚。

代码:细匹配的阈值与最大值选择(官方 src/xoftr/xoftr_module/fine_matching.py,原文节选)
            # 1. confidence thresholding
            mask = conf_matrix_fine > self.fine_thr

            if mask.sum() == 0:
                mask[0,0,0] = 1
                conf_matrix_fine[0,0,0] = 1

            if not self.denser:
                # match only the highest confidence
                mask = mask \
                    * (conf_matrix_fine == conf_matrix_fine.amax(dim=[1,2], keepdim=True))
            else:
                # 2. mutual nearest, match all features in fine window
                mask = mask \
                    * (conf_matrix_fine == conf_matrix_fine.max(dim=2, keepdim=True)[0]) \
                    * (conf_matrix_fine == conf_matrix_fine.max(dim=1, keepdim=True)[0])

            # 3. find all valid fine matches
            # this only works when at most one `True` in each row
            mask_v, all_j_ids = mask.max(dim=2)
            b_ids, i_ids = torch.where(mask_v)
            j_ids = all_j_ids[b_ids, i_ids]
            mconf = conf_matrix_fine[b_ids, i_ids, j_ids]

2.4 亚像素精化:一次回归四个偏移

细匹配给出的仍然是 1/2 尺度上的离散格点,换算回原图后误差约 1 像素。SPRM 把匹配点上的 5×5 特征向量拼起来,过两层 MLP,再用 tanh 回归四个数:

和只回归单侧坐标的做法不同,这里同时回归 A、B 两张图的坐标,因此两端的误差可以联合优化。论文把“细匹配 + 亚像素精化”整体视为防止一点多配的机制(实际能保证多少,见 2.3 节的代码注脚)。代码里 tanh 之后又乘了 0.5:

        sub_ref = self.subpixel_mlp(torch.cat([feat_f0_unfold[b_ids, i_ids],
                                                     feat_f1_unfold[b_ids, j_ids]], dim=-1))
        sub_ref0, sub_ref1 = torch.chunk(sub_ref, 2, dim=-1)
        sub_ref0 = torch.tanh(sub_ref0) * 0.5 
        sub_ref1 = torch.tanh(sub_ref1) * 0.5

这意味着偏移被限制在 ±0.5 个 1/2 尺度像素,即原图 ±1 像素以内。论文公式 (7) 没有写这个 0.5,是代码与公式的一处小差异;如果照公式理解成 ±1 个 1/2 像素(原图 ±2 像素),会高估它的修正范围。

SPRM 的监督不需要像素级 GT,而是用 GT 位姿算出的本质矩阵 ,最小化对称极线距离:

这是一个“把点拉回极线”的弱监督:不需要知道精确对应,只要匹配点满足对极几何即可。代码里还加了一道过滤:只对对称极线距离小于 的匹配计算损失,避免用已经跑偏的匹配去训练回归头。消融第 5 行去掉 SPRM,AUC@20 从 45.03 掉到 44.68,只损失 0.35——它是精度上的“最后一公里”,不是召回的主力。

代码:对称极线距离损失(官方 src/losses/xoftr_loss.py,原文照录)
    def _symmetric_epipolar_distance(self, pts0, pts1, E, K0, K1):
            """Squared symmetric epipolar distance.
            This can be seen as a biased estimation of the reprojection error.
            Args:
                pts0 (torch.Tensor): [N, 2]
                E (torch.Tensor): [3, 3]
            """
            pts0 = (pts0 - K0[:, [0, 1], [2, 2]]) / K0[:, [0, 1], [0, 1]]
            pts1 = (pts1 - K1[:, [0, 1], [2, 2]]) / K1[:, [0, 1], [0, 1]]
            pts0 = convert_points_to_homogeneous(pts0)
            pts1 = convert_points_to_homogeneous(pts1)

            Ep0 = (pts0[:,None,:] @ E.transpose(-2,-1)).squeeze(1)  # [N, 3]
            p1Ep0 = torch.sum(pts1 * Ep0, -1)  # [N,]
            Etp1 = (pts1[:,None,:] @ E).squeeze(1)  # [N, 3]

            d = p1Ep0**2 * (1.0 / (Ep0[:, 0]**2 + Ep0[:, 1]**2 + 1e-9) + 1.0 / (Etp1[:, 0]**2 + Etp1[:, 1]**2 + 1e-9))  # N
            return d
    
    def compute_sub_pixel_loss(self, data):
        """ symmetric epipolar distance loss.
        Args:
        data (dict): {
            m_bids (torch.Tensor): (N)
            T_0to1 (torch.Tensor): (B, 4, 4)
            mkpts0_f_train (torch.Tensor): (N, 2) 
            mkpts1_f_train (torch.Tensor): (N, 2) 
            }
        """

        Tx = numeric.cross_product_matrix(data['T_0to1'][:, :3, 3])
        E_mat = Tx @ data['T_0to1'][:, :3, :3]

        m_bids = data['m_bids']
        pts0 = data['mkpts0_f_train']
        pts1 = data['mkpts1_f_train']
        
        sym_dist = self._symmetric_epipolar_distance(pts0, pts1, E_mat[m_bids], data['K0'][m_bids], data['K1'][m_bids])
        # filter matches with high epipolar error (only train approximately correct fine-level matches)
        loss = sym_dist[sym_dist<1e-4]
        if len(loss) == 0:
            return torch.zeros(1, device=loss.device, requires_grad=False)[0]
        return loss.mean()

2.5 两阶段训练(一):MIM 预训练

跨模态匹配最缺的是“同一场景、不同模态、带对应关系”的数据。可见光有 MegaDepth 这种百万级 SfM 数据集[9],热红外没有对应的城市场景大规模标注。XoFTR 的第一阶段用 KAIST 多光谱行人数据集[10]:9.5 万对车载可见—热红外图像,原始分辨率 640×512,取顶部 640×480 区域(避开下半部分路面),不做匹配标签,只做掩码图像重建。

做法沿用 PMatch 的配对 MIM[11],并吸收 MAE、SimMIM、ConvNeXt V2 的掩码策略[12, 13, 14]:论文把 MIM 输入设为 640×410,随机挖掉 50% 的 64×64 patch,把二值掩码直接乘在输入图上(防止卷积看到被遮区域),过 CNN 后在 1/8、1/4、1/2 三层用可学习的 mask token 替换被遮位置;LoFTR 粗注意力之后,把 FLMM 当作解码器,对每个被遮的粗 token 取 5×5 窗口,经一个线性层映射成 10×10 像素块,用 MSE 监督两幅图的重建。因为 KAIST 的可见—热红外对已经配准、视差很小,两幅图使用相同位置的窗口,交叉注意力可以直接从另一个模态借上下文。

不过官方代码里 PRETRAIN_IMG_RESIZE=640、PRETRAIN_IMG_PAD=True,实际做法是长边缩到 640 后补成正方形(KAIST 的 640×512 会补成 640×640);论文的 640×410 与代码不完全一致,复现时以配置为准。

MIM 预训练的重建示例
图 3论文 Fig. 3。左为掩码后的输入,右为重建结果;上排热红外,下排可见光灰度。遮住 50% 的 64×64 块后,网络仍能补出建筑轮廓、行人和车辆——它必须学会跨光谱稳定的结构,而不是记住某个模态的亮度。

这套预训练要的不是“画出热红外图”,而是让骨干、粗注意力和 FLMM 解码器在真实跨模态对上先学会“哪里是边缘、哪里是同一栋楼”,为第二阶段提供一个对强度变化不敏感的初始化。成本是 9 个 epoch、2 张 A5000 上 24 小时。效果见消融第 1 行:不做预训练、直接微调,AUC@5/10/20 为 11.81/26.51/42.93;完整模型是 12.59/27.90/45.03,预训练贡献 +0.78/+1.39/+2.10。

这个增益不算大,作者也没有回避:它比伪热红外增强小了整整一个量级。我的理解是,9 个 epoch 的 KAIST 预训练只能覆盖车载行人场景的有限外观,而第二阶段的伪热红外增强本身就在强行制造强度不变性,两者功能有重叠;如果预训练数据换成多城市、多传感器、多天气的可见—TIR 对,MIM 的潜力应该还没吃完。

代码:MIM 的逐层掩码与 mask token 替换(官方 src/xoftr/xoftr_pretrain.py,原文照录)
    def mask_layer(self, feat, mae_mask, mae_mask_scale, mask=None, mask_scale=None, mask_token=None):
        """ Mask the feature map and replace with trainable inpu tokens if available
        Args:
            feat (torch.Tensor): [N, C, H, W]
            mae_mask (torch.Tensor): (N, L) mask for masked image modeling
            mae_mask_scale (int): the scale of layer to mae mask
            mask (torch.Generator): mask for padded input image
            mask_scale (int): the scale of layer to mask (mask is created on course scale)
            mask_token (torch.Tensor): [1, C, 1, 1] learnable mae mask token
        Returns:
            feat (torch.Tensor): [N, C, H, W]
        """ 
        mae_mask = self.upsample_mae_mask(mae_mask, mae_mask_scale)
        mae_mask = mae_mask.unsqueeze(1).type_as(feat)
        if mask is not None:
            mask =  self.upsample_mask(mask, mask_scale)
            mask = mask.unsqueeze(1).type_as(feat)
            mae_mask = mask * mae_mask
        feat = feat * (1. - mae_mask)
        if mask_token is not None:
            mask_token = mask_token.repeat(feat.shape[0], 1, feat.shape[2], feat.shape[3])
            feat += mask_token * mae_mask
        return feat
代码:MIM 的 MSE 重建损失(官方 src/losses/xoftr_loss_pretrain.py,原文照录)
import torch
import torch.nn as nn
import torch.nn.functional as F

class XoFTRLossPretrain(nn.Module):
    def __init__(self, config):
        super().__init__()
        self.config = config
        self.W_f = config["xoftr"]['fine_window_size']
    
    def forward(self, data):
        """
        Update:
            data (dict): update{
                'loss': [1] the reduced loss across a batch,
                'loss_scalars' (dict): loss scalars for tensorboard_record
                }
        """
        loss_scalars = {}

        pred0, pred1 = data["pred0"], data["pred1"]
        target0, target1 = data["target0"], data["target1"]
        target0 = target0[[data['b_ids'], data['i_ids']]]
        target1 = target1[[data['b_ids'], data['j_ids']]]
        
        # get correct indices
        pred0 = pred0[data["ids_image0"]]
        pred1 = pred1[data["ids_image1"]]
        target0 = target0[data["ids_image0"]]
        target1 = target1[data["ids_image1"]]
        
        loss0 = (pred0 - target0)**2
        loss1 = (pred1 - target1)**2
        loss = loss0.mean() + loss1.mean()
        
        loss_scalars.update({'loss': loss.clone().detach().cpu()})
        data.update({"loss": loss, "loss_scalars": loss_scalars})

2.6 两阶段训练(二):伪热红外微调

第二阶段要解决的是“没有城市场景可见—TIR 训练对”。作者没有去收集更多数据,而是把 MegaDepth 的可见光图在线转换成伪热红外图,让网络在微调时不断见到“同一场景、不同强度映射”的样本。转换分四步:

  1. 随机 HSV 抖动(色相 ±90、饱和度 ±30、明度 ±30,概率 0.9)与随机模糊(核 2–4,概率 0.7);
  2. 转灰度,归一化到 (代码里等价地先算 再做余弦,公式里不再重复减);
  3. 余弦变换: 其中 、、, 服从正态分布;
  4. 最小-最大归一化(论文归一到 ,代码乘 255 存成 uint8),再转回三通道灰度图。

这里有两处论文与代码的小出入:论文写的是 5×5 高斯模糊,代码用的是 albumentations 的 A.Blur,核大小 2–4、概率 0.7;论文公式把结果归一到 ,代码最后乘 255 存成 uint8(只差一个缩放)。

在微调时,增强随机作用在图像对中的一张(要么可见光那张保持原样、另一张变伪热红外,要么反过来),于是网络见到的既有“真实可见光 vs 伪热红外”,也有“伪热红外 vs 真实可见光”。

伪热红外增强示例
图 4论文 Fig. 4。左为 RGB,中为真实 TIR,右两幅为余弦变换生成的伪热红外。转换把可见光里的亮天空压暗、把暗的建筑/树干提亮,模拟了热红外的基本强度反转;但它没有真实的温度物理,玻璃、金属等材质的异常热特征不会出现,这是“便宜”换来的局限。

为什么用余弦而不是 GAN?论文的理由是可靠性:GAN 在测试分布偏离训练数据时会产生伪影和像素级不准确,而匹配需要的正是像素级可靠的结构;余弦变换虽然“不像真正的热红外”,但它是一个可控、无幻觉的非线性强度重映射,足以逼迫网络学会对强度变化不变。消融第 2 行把增强关掉,AUC@5/10/20 从 12.59/27.90/45.03 暴跌到 2.92/7.43/14.94,损失 9.67/20.47/30.09——这是全文最大的单项差距,比预训练、一对多分配、FLMM 都重要。换句话说,跨模态匹配的第一性问题是数据里的模态多样性,不是网络结构。

代码:伪热红外的 HSV、灰度与余弦变换(官方 src/utils/augment.py,原文节选)
    def augment_pseudo_thermal(self, image):
        
        # HSV augmentation
        image = self.hsv(image=image)["image"]
        
        # Random blur
        image = self.blur(image=image)["image"]
        
        # Convert the image to the gray scale
        image = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)  

        # Normalize the image between (-0.5, 0.5)
        image = image / 255 - 0.5 # 8 bit color

        # Random phase and freq for the cosine transform
        phase = np.pi / 2 + np.random.randn(1) * self.theta_r
        w = self.w_0 + np.abs(np.random.randn(1)) *  self.w_r
        
        # Cosine transform
        image = np.cos(image * w + phase) 

        # Min-max normalization for the transformed image
        image = (image - image.min()) / (image.max() - image.min()) * 255

        # 3 channel gray
        image = cv2.cvtColor(image.astype(np.uint8), cv2.COLOR_GRAY2RGB)

        return image

下面的部件把两件事放在一起:上半部分切换表 5 的三个训练配置,下半部分拧余弦变换的频率与相位。先看默认状态——完整两阶段(③)是 12.59/27.90/45.03;切到 ②(有 MIM、无伪热红外)会看到 AUC@20 只剩 14.94,而切到 ①(无 MIM、有伪热红外)是 42.93。也就是说,伪热红外增强贡献了约 30 个点,MIM 贡献约 2 个点。

说明:AUC 数字取自论文表 5,训练配置与表中三行一一对应;余弦曲线用与论文公式 (8) 相同的参数化,但输入是 0 到 1 的灰度条,不是真实图像,曲线形状只用于说明强度重映射。

2.7 损失函数

总损失是三项加权:

  • 粗匹配损失 :对 、 分别做 focal loss,GT 是 1/8 网格用深度和位姿投影得到的匹配矩阵。与 LoFTR 不同,构造 GT 时不加互近邻约束,允许 many-to-one / one-to-many,和粗匹配的分配规则一致。
  • 细匹配损失 :对每条粗匹配的 25×25 窗口做 focal loss,GT 在 1/2 尺度上构造,并加互近邻约束,只允许一对一。也就是说,粗阶段允许模糊,细阶段要求明确。
  • 亚像素损失 :式 (11) 的对称极线距离,权重 。从代码只保留 的匹配看,归一化坐标下的平方距离本来就在 量级,不放大梯度会被前两项淹没——这是我的理解,论文没有解释这个权重的来历。代码里这道过滤本身也等于先筛掉明显错误的粗匹配,再回归。

focal loss 的超参是 、,正负样本权重各为 1。两种损失的正负样本处理并不一样:粗匹配损失只对正样本计算 focal 项(pos_mask),细匹配损失则对正、负样本都计算;读代码时这一点比公式更容易被忽略。

代码:三项损失的权重与调用顺序(官方 src/losses/xoftr_loss.py,原文节选)
        # 0. compute element-wise loss weight
        c_weight = self.compute_c_weight(data)

        # 1. coarse-level loss
        loss_c = self.compute_coarse_loss(data, weight=c_weight)
        loss_c *= self.loss_config['coarse_weight'] 
        loss = loss_c 
        loss_scalars.update({"loss_c": loss_c.clone().detach().cpu()})

        # 2. fine-level matching loss for windows
        loss_f_match = self.compute_fine_matching_loss(data)
        loss_f_match *= self.loss_config['fine_weight'] 
        loss = loss + loss_f_match 
        loss_scalars.update({"loss_f":  loss_f_match.clone().detach().cpu()})

        # 3. sub-pixel refinement loss 
        loss_sub = self.compute_sub_pixel_loss(data)
        loss_sub *= self.loss_config['sub_weight'] 
        loss = loss + loss_sub 
        loss_scalars.update({"loss_sub":  loss_sub.clone().detach().cpu()})

2.8 METU-VisTIR:一个带位姿的城市场景可见—TIR 基准

为了评估跨模态匹配,作者用 DJI Mavic 3 Thermal 无人机采集了 6 个场景的可见—TIR 序列。两颗相机物理位置接近,但参数差异不小:

项目热红外相机可见光相机
分辨率640×5123840×2160
视场角61°84°
波段8–14 µm(LWIR)RGB

6 个场景中 4 个同时拍了阴天与晴天,2 个只有阴天。GT 位姿用 COLMAP[15] + HLOC[16] 在可见光图像上做 SfM 恢复;由于相机是自动对焦,作者用同模态 COLMAP 得到两路相机的内参,再用标定板估计畸变。自动对焦会带来轻微的 GT 参数误差,论文明确承认这一点。最终构造两个测试集:阴—阴 1382 对,阴—晴 1208 对,共 2590 对。

METU-VisTIR 数据集示例
图 5论文 Fig. 5。6 个场景的可见光与热红外示例;上两行为阴天,下两行为晴天(只有 4 个场景有晴天数据)。场景包括建筑、雕塑、道路与停车场,视角变化明显。

这是论文最有价值的部分之一:在此之前,可见—TIR 匹配缺少一个公开的、带 GT 位姿的城市场景多视图基准,大家只能在单应配准或自己的私有数据上比较。它的局限也同样明显:6 个场景、一台无人机、一种 TIR 波段,2590 对测试图像;没有夜间、雨雾、雪天,也没有跨季节。论文说 “comprehensive”,但按数据规模看,它更像一个“有代表性的小型基准”,而不是 MegaDepth 级别的训练/评测资源。

三、实验结果

3.1 相对位姿估计:METU-VisTIR

评测协议沿用 LoFTR:把图像长边缩到 640,用匹配点 + RANSAC(阈值 1.5 像素)估计本质矩阵,计算旋转与平移的最大角误差,报告 AUC@5°/10°/20°。两个测试集分别评测;官方评测脚本对每个场景单独算 AUC,再在阴—阴、阴—晴两组内对场景取平均。

结果非常悬殊。阴—阴集上,XoFTR 的 AUC@5/10/20 是 22.03/39.03/55.06;第二名 DKM 只有 5.79/11.47/19.17,LoFTR 是 2.63/6.55/14.11,ASpanFormer 1.82/4.73/10.60。阴—晴集上,XoFTR 是 12.59/27.90/45.03,DKM 7.26/14.63/23.60,LoFTR 2.77/7.71/16.36。也就是说,在最宽松的 20° 阈值下,XoFTR 比 DKM 高 35.89(阴—阴)和 21.43(阴—晴)个点,比 LoFTR 高 40.95 和 28.67 个点。

几个值得注意的现象:

  • 检测器类方法整体不行。SuperPoint+SuperGlue 在阴—阴只有 3.90/8.75/16.35,D2-Net+NN 2.16/6.01/12.80,ReDFeat 2.36/5.45/11.26。跨模态下关键点本身就不重复,检测器+描述子的范式在第一步就输了;DKM、LoFTR 这类无检测器方法靠稠密特征和注意力,至少能保住一部分召回。
  • “用真实 TIR 训练”不等于“跨域可用”。LoFTR-MTV 是在真实航空可见—TIR 对上训练的 LoFTR,按理说模态对得上,但在 METU 上只有 1.54/3.89/8.80(阴—阴),比原始 LoFTR 还低。作者的解释是训练域/场景不匹配;这也说明跨模态匹配的瓶颈不只是“有没有 TIR 数据”,还有数据分布与训练配方。
  • 天气一变,性能确实掉。同一个模型从阴—阴到阴—晴,AUC@20 从 55.06 掉到 45.03,@5 从 22.03 掉到 12.59。论文把原因归为阳光带来的阴影、反光与温度变化;这也是它没有专门做去光照归一化的结果。
  • DKM 是唯一像样的基线。它的稠密匹配在阴—阴能到 19.17,阴—晴也只有 23.60,仍不到 XoFTR 的一半;且从定性图看匹配数量巨大、精度不高(见 3.4)。XoFTR 的优势是“数量与精度同时高”。

下面这个表格浏览器把论文表 2、表 3、表 4 放在一起,按钮切换数据集与阈值。默认是阴—晴、AUC@20:XoFTR 45.03,第二名 DKM 23.60。

说明:数值逐项取自论文表 2、表 3、表 4。表 4 的三列表头写作 @5°/@10°/@20°,但同节正文先说角点误差是 3、5、10 像素,结果段又讨论 10 和 20 像素阈值,三处口径不一致;部件里的 5/10/20 只是沿用表头数字,不对应严格的像素或角度含义。

3.2 单应估计:跨出自家数据集还能打吗

为了证明不是只在自己的数据集上有效,作者在公开的 LGHD LWIR/RGB(44 对建筑)和 FusionDN RoadScene(221 对道路)上合并构造了 1325 个单应测试对:每对原图随机生成 5 个单应,包含 0.8–1.2 的缩放、±0.15 的透视、±15° 的旋转。评测指标是角点误差累积曲线的 AUC。

XoFTR 在 @10 和 @20 两列上大幅领先:23.37 和 48.15;@10 的第二名是 ASpanFormer(18.87),@20 的第二名是 SP+SuperGlue(37.95);DKM 只有 9.55 和 25.87。但在最严格的 @5 列上,XoFTR 的 8.19 输给 ASpanFormer 的 9.50(差 1.31)。论文对此的表述是 “surpasses … for 10 and 20 pixel error thresholds”,没有掩盖 @5 的失利。这个结果其实很符合直觉:XoFTR 的粗匹配偏召回、细匹配再过滤,误差分布尾部更薄但头部不一定最尖;ASpanFormer 的自适应注意力跨度在干净、近单应的场景里更容易给出极高精度的匹配。

单应估计的定性对比
图 6论文 Fig. 7。左为可见光,右为热红外(单应 warp 后的视图,黑色区域是 warp 出界的部分)。上:XoFTR 在整条道路与远处植被上都有密集匹配;下:LoFTR 只有稀疏的几条,且有一条明显的长距离错误匹配(红线)。

3.3 消融与运行时间

论文在 METU-VisTIR 阴—晴集上做了 7 组消融,全部以相对位姿 AUC 衡量:

变体@5°@10°@20°与完整模型的差(@20°)
(1) 无 MIM 预训练11.8126.5142.93−2.10
(2) 无伪热红外增强2.927.4314.94−30.09
(3) 粗匹配只用一对一9.9522.6038.73−6.30
(4) 换成 LoFTR 的粗到细模块6.3114.4626.77−18.26
(5) 无 SPRM12.3127.3944.68−0.35
(6) 无细匹配第二阈值 θ_f11.5826.0843.56−1.47
(7) FLMM 无绝对位置偏置12.2326.9943.36−1.67
完整 XoFTR12.5927.9045.03—

排序很清楚:伪热红外增强(−30.09)> 细匹配管线(−18.26)> 一对一分配(−6.30)> MIM(−2.10)> 位置偏置(−1.67)> 第二阈值(−1.47)> SPRM(−0.35)。这几乎是全文的论点排序:跨模态的第一瓶颈是数据里的模态多样性,第二是把粗匹配可靠地推到高分辨率,第三才是匹配分配规则;MIM 和 SPRM 是锦上添花,不是雪中送炭。

运行时间方面,A5000 上 640×512 的单对推理,XoFTR 116 ms,LoFTR 102 ms,多出 14 ms(约 13.7%)。作者归因于一对多分配带来的更多粗匹配——每条粗匹配都要走一遍 FLMM 窗口。论文没有报告参数量、FLOPs 或显存占用;考虑到它复用了 LoFTR 的骨干与粗注意力,参数量应该接近 LoFTR,主要增量在 FLMM 的窗口注意力和掩码预训练头(推理时不使用)。

3.4 定性结果:数量、精度与失败模式

LoFTR、DKM、XoFTR 在三个场景上的匹配对比
图 7论文 Fig. 6,三行分别是三个场景,三列分别是 LoFTR、DKM、XoFTR;每行内三列使用同一对图像。图中只画 RANSAC 后的内点,匹配按极线误差着色;论文只给了绿色的阈值(小于 ),黄/红线误差更大。第一行:LoFTR 8/23(精度 34.8%)、DKM 1216/1692(71.9%)、XoFTR 696/822(84.7%);第二行:14/26(53.8%)、289/590(49.0%)、135/160(84.4%);第三行:12/20(60.0%)、120/991(12.1%)、148/151(98.0%)。

这张图把三种失败模式摆得很清楚:

  • LoFTR 是“太少”:每对图像只有十几到二十几条匹配,RANSAC 勉强能解,但一旦遇到大尺度或低纹理就崩;第一行它的平移误差是 20.76°,第三行 20.75°。
  • DKM 是“多而杂”:第一行给出 1692 条匹配,精度 71.9%,看起来还行;第三行 991 条里只有 12.1% 是极线内点,大量红线横穿图像,RANSAC 被淹没。稠密匹配的召回很高,但跨模态下的误匹配也成片出现。
  • XoFTR 是“多而准”:696/822(84.7%)、135/160(84.4%)、148/151(98.0%),三条场景的平移误差分别只有 2.31°、7.16°、6.52°。它的匹配集中在建筑立面、树枝、车辆轮廓这些跨模态仍然稳定的结构上,而不是热红外里均匀的墙面。

失败模式也不是没有。第三行 XoFTR 的 148/151 精度极高,但匹配几乎全集中在雕塑和右侧建筑,左侧草地与道路几乎没有匹配——热红外里这些区域太平坦,特征本身就弱。这类“局部极好、覆盖不全”的行为,对需要全局分布匹配的 SfM 来说可能比精度更麻烦。

四、局限性

4.1 论文自己承认的

论文没有单独的 Limitations 章节,但正文里明确提到了几处:

  • 运行时间增加:116 ms vs LoFTR 102 ms,原因是一对多粗匹配带来更多细匹配计算(5.4 节)。
  • 天气变化导致性能下降:阴—晴比阴—阴低约 10 个 AUC@20 点,作者归因于阳光、阴影与温度变化(5.2 节)。
  • GT 位姿不完美:相机自动对焦,内参由同模态 COLMAP + 标定板估计,可能存在轻微误差(4 节)。
  • 伪热红外“没有真正的热特性”:论文在 2 节明确说余弦变换简单、计算轻,但产生的图像缺乏真实热红外特征;选它是因为 GAN 会产生伪影、像素不准确。
  • 数据集规模:论文称 METU-VisTIR 为 “comprehensive”,但没有讨论 6 个场景是否足够代表城市场景。

4.2 我自己再补几条

  • 数据覆盖比论文标题听起来窄:只有 6 个场景,一台 DJI Mavic 3 Thermal,一种 LWIR 波段(8–14 µm),只有阴/晴两种天气;TIR 最擅长的夜间、雨、雾、雪完全没有测试。所谓“鲁棒于恶劣天气”是动机,不是这篇论文的实验结论。
  • 训练/测试域跨度很大,但没有拆解:预训练在 KAIST(车载、行人、640×512 取顶部 640×480),微调在 MegaDepth(可见光、地面视角、伪 TIR),测试在 METU(无人机、俯视、真实 TIR)。到底是哪个源域贡献最大、KAIST 换成别的 TIR 数据会怎样,论文没有做迁移消融。
  • MIM 的增益可能被训练预算混淆:无预训练变体(表 5 第 1 行)是直接微调,没有额外的预训练算力;完整模型多了 9 个 epoch × 2 张 A5000。要证明“MIM 本身有用”,更公平的对照是给无预训练变体同样的算力或更长的微调。+2.10 个点的差距不算大,也没有多 seed 方差。
  • “防止一对多”在代码里不是全局保证:如 2.3 节所说,细匹配对每条粗匹配独立取窗口 argmax,没有跨粗匹配去重;共享同一 A 点的两条粗匹配仍可能输出重复点。论文的表述容易被读成严格互斥,实际是“每条粗匹配只产出一个细匹配 + 阈值过滤”。
  • SPRM 的修正范围被代码悄悄限死:tanh 后乘 0.5,最多修正 ±1 原图像素;论文公式 (7) 没写这个 0.5。对于 1/8 粗匹配、1/2 细匹配的误差量级,±1 像素够用,但如果输入分辨率更高或粗匹配更差,这个上限会变成瓶颈。
  • 速度与部署:116 ms/对(A5000,640×512)约 8.6 FPS,离无人机实时视频(30 FPS)还有距离;论文没有 ONNX/TensorRT、半精度、剪枝或 LightGlue 式早退实验。实际系统里可能要配合关键帧策略,而不是每帧都匹配。
  • 没有下游任务:只做了两视图相对位姿和单应,没有接入 HLOC 做视觉定位,也没有做 SfM/三维重建。匹配指标好不等于重建好,尤其在匹配分布不均匀(见 3.4 第三行)时。
  • 基线选择有时代局限:Shape-Former、MIVI 因无公开代码/难以复现未被比较;MINIMA(CVPR 2025)用 4.8 亿对合成多模态数据把跨模态匹配又推进了一大步[17]。这不是作者的错,但读者应知道 2024 年的“SOTA”在 2025 年已经不是终点。
  • 单应 @5 不是第一:8.19 vs ASpanFormer 9.50;论文原文也只声称在 10 和 20 像素阈值上领先,并没有说 @5 也第一。
  • 表 4 的单位有笔误:表头写 @5°/@10°/@20°,正文却说是 3/5/10 像素、并讨论 “10 and 20 pixel error thresholds”。按正文理解是像素;这类小不一致不影响结论,但复现时要注意。

五、结论与展望

XoFTR 的故事线其实很朴素:LoFTR 的注意力本身已经足够通用,跨模态匹配的瓶颈不在网络结构,而在“分配假设”和“训练数据”。作者没有发明新的注意力,而是做了三件便宜但有效的事:

  1. 把粗匹配的互近邻一对一换成行/列最大值并集,承认跨模态下多对一是常态,把消歧留给高分辨率细匹配;
  2. 用真实可见—TIR 对做 MIM,让网络在无匹配标签的情况下先学会跨光谱稳定的结构;
  3. 用余弦变换把可见光图变成伪热红外,以近乎零成本制造模态多样性,补上城市场景没有 TIR 训练数据的缺口。

消融给出的排序也值得记住:伪热红外增强(−30.09 @20)远大于细匹配管线(−18.26)和一对多分配(−6.30),MIM 只有 −2.10。对做跨模态匹配的实践者,这意味着先想办法把数据里的模态变化造出来,再谈网络设计;一个便宜的强度重映射,可能比一个精巧的模块更值钱。

给要用它的人几条建议:

  • 直接推理:用官方 640 权重 + DataIOWrapper,输入灰度图、长边 640、保持长宽能被 8 整除;模型内部会做逐图标准化,不要再手工减均值。
  • 阈值:默认 、 适合 RANSAC;如果下游需要更多匹配(例如低纹理场景),可以把 denser=True 打开,让每个细窗口输出多条互相近邻匹配,但精度会下降。
  • 换传感器:如果你的 TIR 不是 8–14 µm、或分辨率/视场与 DJI 差别很大,先在自己的数据上验证;必要时用本文的伪热红外增强在自己的可见光数据上微调,这比重新设计网络更划算。
  • 做 SfM/定位:接到 HLOC 里时注意匹配分布,XoFTR 可能在某些区域没有匹配;可以用多尺度或关键帧补充,不要指望单对图像覆盖全图。
  • 别的模态:NIR、SAR、深度、事件相机与可见光的差异不再是“亮度反转”,余弦变换的假设不成立;这时 MINIMA 那种“用生成模型合成多种伪模态 + 大规模数据”的路线更合适[17],或者针对具体模态设计预训练任务。

未来可以做的方向:更大规模、多城市、多传感器、含夜间/雨雾的可见—TIR 数据集;把 MIM 预训练扩展到更多模态与更长时间;用蒸馏/早退/量化把 116 ms 压到实时;把匹配器接入 SfM/定位管线做端到端评估;以及研究“匹配分布均匀性”这类目前被 AUC 掩盖的指标。

六、上手

官方仓库提供了完整推理、评测与训练代码(Apache-2.0),权重有 640 和 840 两个分辨率版本,METU-VisTIR 数据集单独发布(CC BY-NC-SA 4.0,仅限非商业)。环境是 Python 3.8 + PyTorch 2.0.1 + CUDA 11.8。

安装(conda 环境,版本要对上)
conda env create -f environment.yaml
conda activate xoftr

权重与数据需要从 README 里的 Google Drive 链接手动下载:

  • 权重:weights_xoftr_640.ckpt / weights_xoftr_840.ckpt;
  • 数据集:METU-VisTIR,解压后把 METU_VisTIR 软链到 data/ 下;
  • 预训练权重(可选):pretrain_weights/epoch=8-.ckpt,微调脚本默认从这里初始化。

最小推理示例(基于 notebooks/xoftr_demo.ipynb,这里把绘图换成一次 MAGSAC 过滤):

最小推理:加载权重、读两张图、输出匹配
import cv2
import torch
import numpy as np

from src.xoftr import XoFTR
from src.config.default import get_cfg_defaults
from src.utils.misc import lower_config
from src.utils.data_io import DataIOWrapper

config = get_cfg_defaults(inference=True)
config = lower_config(config)
config['xoftr']['match_coarse']['thr'] = 0.3   # θc
config['xoftr']['fine']['thr'] = 0.1           # θf
config['xoftr']['fine']['denser'] = False
config['test']['img0_resize'] = 640
config['test']['img1_resize'] = 640

matcher = XoFTR(config=config["xoftr"])
matcher = DataIOWrapper(matcher, config=config["test"], ckpt="weights/weights_xoftr_640.ckpt")

out = matcher.from_paths(
    "assets/METU_VisTIR_samples/cloudy/scene_7/visible/images/IM_04525.jpg",
    "assets/METU_VisTIR_samples/cloudy/scene_7/thermal/images/IM_01139.jpg",
    read_color=True,
)
mkpts0, mkpts1, mconf = out['mkpts0'], out['mkpts1'], out['mconf']

# 用 MAGSAC 估计基础矩阵并过滤
F, inlier_mask = cv2.findFundamentalMat(
    mkpts0, mkpts1, cv2.USAC_MAGSAC,
    ransacReprojThreshold=1, maxIters=10000, confidence=0.9999,
)
inlier_mask = inlier_mask.ravel() > 0
mkpts0, mkpts1, mconf = mkpts0[inlier_mask], mkpts1[inlier_mask], mconf[inlier_mask]
print(len(mkpts0), "inliers")

如果不想用 DataIOWrapper,也可以直接喂张量:图像转灰度、除以 255、尺寸能被 8 整除,调用 matcher(batch) 后从 batch['mkpts0_f']、batch['mkpts1_f']、batch['mconf_f'] 取结果。注意模型内部会做逐图标准化,输入保持原始灰度即可。

评测相对位姿:

在 METU-VisTIR 上复现表 2 / 表 3
python test_relative_pose.py xoftr --ckpt weights/weights_xoftr_640.ckpt
# 保存匹配可视化
python test_relative_pose.py xoftr --ckpt weights/weights_xoftr_640.ckpt --save_figs

脚本默认 --match_threshold 0.3、--fine_threshold 0.1、--ransac_thres 1.5,结果写到 results_relative_pose/。数据集目录结构要求 index/scene_info_test/、index/val_test_list/test_list.txt 和 cloudy/、sunny/,下载包里已经带好。

训练分两段,官方给了复现脚本,但作者明确说换 GPU/ batch size 后不保证完全复现:

两阶段训练:MIM 预训练 + MegaDepth 微调
# 阶段一:KAIST 上 MIM 预训练(2×A5000,约 24 小时)
scripts/reproduce_train/pretrain.sh

# 阶段二:MegaDepth + 伪热红外微调(8×A100,约 24 小时)
scripts/reproduce_train/visible_thermal.sh

pretrain.sh 里 batch size 是每卡 2、共 2 卡;visible_thermal.sh 是 8 卡、每卡 2,并默认从 pretrain_weights/epoch=8-.ckpt 初始化。数据准备(MegaDepth、KAIST、索引文件、软链接)见 docs/TRAINING.md;MegaDepth 的索引文件需要单独下载,不能只下原图。

参数与论文概念的对应关系:

代码 / 参数论文符号默认值作用
match_coarse.thr0.3粗匹配置信度阈值
fine.thr0.1细匹配第二阈值
fine.denser—FalseTrue 时每个细窗口输出多条互相近邻匹配
fine_window_size5细匹配窗口大小
medium_window_size3中间层窗口大小
test.img0_resize / img1_resize—640推理时长边缩放
ransac_thres—1.5 px评测时 RANSAC 阈值

权重协议:代码是 Apache-2.0;数据集是 CC BY-NC-SA 4.0,禁止商业使用;权重文件放在 Google Drive,仓库没有单独声明权重许可证,商用前需要自行确认。仓库最后更新于 2025-08-21,训练代码、评测脚本、demo notebook 都在,未集成到 HLOC 或其它 SfM 管线。

参考

  1. Tuzcuoğlu, Köksal, Sofu, Kalkan, Alatan. XoFTR: Cross-modal Feature Matching Transformer. CVPR Workshops (Image Matching Workshop) 2024. arXiv · 代码
  2. Deng, Ma. ReDFeat: Recoupling Detection and Description for Multimodal Feature Learning. IEEE TIP 2023. arXiv
  3. Sarlin, DeTone, Malisiewicz, Rabinovich. SuperGlue: Learning Feature Matching with Graph Neural Networks. CVPR 2020. arXiv · 代码
  4. Lindenberger, Sarlin, Pollefeys. LightGlue: Local Feature Matching at Light Speed. ICCV 2023. arXiv · 代码
  5. Sun, Shen, Wang, Bao, Zhou. LoFTR: Detector-Free Local Feature Matching with Transformers. CVPR 2021. arXiv · 代码
  6. Edstedt, Athanasiadis, Wadenbäck, Felsberg. DKM: Dense Kernelized Feature Matching for Geometry Estimation. CVPR 2023. arXiv · 代码
  7. Chen, Luo, Zhou, Tian, Zhen, Fang, McKinnon, Tsin, Quan. ASpanFormer: Detector-Free Image Matching with Adaptive Span Transformer. ECCV 2022. arXiv · 代码
  8. Huang, Chen, Liu, Liu, Xu, Wu, Ding, Tang, Wang. Adaptive Assignment for Geometry Aware Local Feature Matching. CVPR 2023. arXiv · 代码
  9. Li, Snavely. MegaDepth: Learning Single-View Depth Prediction from Internet Photos. CVPR 2018. arXiv
  10. Hwang, Park, Kim, Choi, Kweon. Multispectral Pedestrian Detection: Benchmark Dataset and Baseline. CVPR 2015. CVF
  11. Zhu, Liu. PMatch: Paired Masked Image Modeling for Dense Geometric Matching. CVPR 2023. arXiv
  12. He, Chen, Xie, Li, Dollár, Girshick. Masked Autoencoders Are Scalable Vision Learners. CVPR 2022. arXiv
  13. Xie, Zhang, Cao, Lin, Bao, Yao, Dai, Hu. SimMIM: A Simple Framework for Masked Image Modeling. CVPR 2022. arXiv
  14. Woo, Debnath, Hu, Chen, Liu, Kweon, Xie. ConvNeXt V2: Co-designing and Scaling ConvNets with Masked Autoencoders. CVPR 2023. arXiv
  15. Schönberger, Frahm. Structure-from-Motion Revisited (COLMAP). CVPR 2016. 项目页 · 代码
  16. Sarlin, Cadena, Siegwart, Dymczyk. From Coarse to Fine: Robust Hierarchical Localization at Large Scale (hloc). CVPR 2019. arXiv · 代码
  17. Ren, Jiang, Li, Liang, Zhou, Bai. MINIMA: Modality Invariant Image Matching. CVPR 2025. arXiv · 代码

Comments