背景
典型的2D3D配准的核心任务是求解一个将点云对齐到图像坐标系下的刚体变换(旋转和平移)。
为了实现这一目的,常见的执行步骤如下
(1)建立对应关系
即找出图像的关键点和点云的关键点,然后将位置相同的关键点进行匹配,作为一组对应关系,这些(像素, 3D点)对构成了后续计算的基础。
(2)求解变换
因为上一阶段是一定会存在错误的匹配对的(外点),所以需要使用例如RANSAC之类的鲁棒估计器,该估计器的作用是从可能存在大量噪声的对应关系集中,筛选出一致性最高的内点子集,并基于该子集稳健地计算出最优的变换矩阵。
因此,第一步找到正确的对应关系就显得尤为重要,之前的2D-3D匹配通常是使用先检测后匹配的策略,其中,
2D的计算方式是为每个2D图像关键点提取一个局部图像块,并计算其外观描述子(如SIFT、SURF描述子或学习型图像描述子),该描述子主要编码关键点周围的纹理、梯度等视觉信息。
3D的计算方式是为每个3D点云关键点提取其邻域点,并计算其几何描述子(如FPFH、SHOT描述子或学习型点云描述子),该描述子主要编码关键点周围的表面法线、曲率分布等三维结构信息。
此时找出了2D和3D的关键点,接下来就是进行配准。通过计算2D描述子与3D描述子之间的距离(如欧氏距离、余弦距离),在所有可能的2D-3D关键点对中寻找最近邻或满足互最近邻条件的配对,从而建立初步的2D-3D对应关系。
传统方法的局限性
- 2D外观描述子与3D几何描述子存在模态鸿沟,难以直接匹配
- 手工设计的描述子表达能力有限
- 分别处理2D和3D特征,缺乏跨模态信息交互
立体图像配准
先来介绍一下手工设计描述符和学习描述符,手工描述符是指人为设计的一套固定的数学规则和计算流程,无论哪种情况始终执行这个不变的规则。学习描述符是不依赖人为设计的固定规则,它使用一个深度神经网络(例如卷积神经网络CNN)作为特征提取器(这个网络通过在大量图像匹配数据上进行训练,自动学习什么样的特征对于匹配来说是最有效、最独特的)
传统的立体图像配准方法通常采用检测-匹配流程来提取对应点:
(1)基于手工设计的/学习描述符检测并描述一组稀疏关键点
(2)基于特征相似性进行匹配
而在我们的无检测方法中,这里直接跳过关键点检测部分,通过计算所有特征对之间的相关矩阵进行配准,但是这会导致大量的计算,所以我们提出了先粗匹配再精准匹配。
点云配准
早期的点云配准工作使用FPN和FPFH等手工设计的描述符进行关键点检测。为了绕过关键点检测,CoFiNet引入了由粗到精的策略匹配。GeoTransfrom进一步引入了不变式几何结构嵌入,实现了无需RANSAC的配准方法。
跨模态配准
与之前单模态对比,图像和点云的跨模态配准显得更为困难。以往有两种方法:
(1)通过决策树或神经网络预测每个图像像素的3D坐标。然而这类方法缺乏对新场景的泛化能力。
(2)遵循传统的检测-匹配流程,即先从各模态中找出关键点,再通过关联描述子进行匹配。然而在不同视觉域中定义和描述的关键点使得检测可重复的跨模态关键点困难且不稳定。
这两个方法都还不能解决内点率较低的问题,本文提出了一种方法,通过从粗到细匹配和基于Transformer的多尺度匹配两种方法来解决此问题。
问题
关键点检测方式不同
2D和3D关键点是在不同的视觉域中检测的。2D关键点检测基于纹理和颜色信息,而3D检测则取决于局部几何结构。这使得可重复关键点的检测变得困难。其次,2D和3D描述符编码不同的视觉信息,这阻碍了为匹配像素和点提取一致的描述符。因此,现有的2D-3D匹配方法往往导致内点比例过低,难以实际应用。
特征描述信息不同
从RGB像素和三维坐标中学习到的描述子,其特征空间存在巨大差异。这使得直接在该异构特征空间中进行相似性度量变得异常困难,极易产生错误的匹配,导致最终获取的对应关系集合中内点比率过低。
近期,无检测方法出现在立体匹配和点云配准中,它通过省略关键点检测步骤,借助从粗到细的流程实现了高质量的对应关系,大致就是先在图像或点云块层级建立粗略对应关系,然后将其细化为像素级或点级的精细匹配。由于充分利用了块层级的全局上下文信息,该方法在性能上显著优于基于检测的方法。但是在2D3D中并未实现(由于透视投影导致图像块与点云块之间的尺度模糊性),而本文则解决了此问题。
过程

整体流程如下,首先分别对图像和点云进行下采样,然后进行上采样融合全局+局部信息,Fp和Fi用于提取图像和点云的粗略对应关系。接下来进行多尺度块匹配模块来学习全局上下文约束和跨模态相关性,这里其实就是进行图像和点云的粗匹配。接下来通过Fi和Fp将块对应关系扩展为密集像素-点对应关系,也就是对图像的点和点云中的点进行匹配对应。最终采取PNP-RANSAC算法估计变换。
最终目的
我们最终要实现的就是下面这个公式,它的目标就是恢复他们之间的对齐变换,Xi是点云的点,Yi图像中对应点云的点,R是旋转矩阵,t是平移向量,K是相机的内参矩阵,它决定了3D点如何投射到2D图像上,另一个不规则的K是投影函数,它设定了3D投射到2D的规则,比如3D点(x,y,z)通过相机,变换到那个像素块上,最终我们要让这个公式尽可能的小,也就找出了效果最好的R,t。

特征提取
对于图像和点云的配对,我们这里首先进行第一步,也就是进行特征提取,我们采用采用两个模态特定的编解码器骨干网络进行分层特征。
对于图像提取,我们使用带有FPN的Resnet生成多尺度图像特征。
首先看一下它的残差网络实现
class BasicBlock(nn.Module):def __init__(self,in_channels: int,out_channels: int,stride: int = 1,dilation: int = 1,norm_cfg: Union[str, dict] = "GroupNorm",#分组进行归一化act_cfg: Union[str, dict] = "LeakyReLU",):super().__init__()self.conv1 = ConvBlock(in_channels,#输入通道数out_channels,#输出通道数kernel_size=3,#卷积核padding=1,#填充值stride=stride,#步长dilation=dilation,#空洞率,它的作用是扩大感受野而不增加参数conv_cfg="Conv2d",#指定卷积类型norm_cfg=norm_cfg,#归一化,用于加速训练,提高稳定性act_cfg=act_cfg,#引入非线性激活函数,增强表达能力)self.conv2 = ConvBlock(out_channels,out_channels,kernel_size=3,padding=1,dilation=dilation,conv_cfg="Conv2d",norm_cfg=norm_cfg,act_cfg="None",)if stride == 1:self.identity = nn.Identity()#恒等映射函数,保持不变,即output = inputelse:self.identity = ConvBlock(#用于下采样,通过卷积调整尺寸,保持残差连接尺寸匹配in_channels,out_channels,kernel_size=3,padding=1,stride=stride,dilation=dilation,conv_cfg="Conv2d",norm_cfg=norm_cfg,act_cfg="None",)self.act = build_act_layer(act_cfg)def forward(self, x):residual = self.conv1(x)residual = self.conv2(residual)identity = self.identity(x)output = self.act(identity + residual)return output
接下来是图像的IamgeBackbone实现
class ImageBackbone(nn.Module):def __init__(self,in_channels: int,out_channels: int,base_channels: int,dilation: int = 1,norm_cfg: Union[str, dict] = "GroupNorm",act_cfg: Union[str, dict] = "LeakyReLU",):super().__init__()self.encoder1 = ConvBlock(#分辨率变1/2,H = ((H-7+2*3)/2+1)=((H-1)/2+1)≈2/H,代入256即可发现结果向下取整为128in_channels,base_channels * 1,kernel_size=7,padding=3,stride=2,conv_cfg="Conv2d",norm_cfg=norm_cfg,act_cfg=act_cfg,)self.encoder2 = nn.Sequential(#分辨率保持不变,深化当前特征提取BasicBlock(base_channels * 1,base_channels * 1,stride=1,dilation=dilation,norm_cfg=norm_cfg,act_cfg=act_cfg,),BasicBlock(base_channels * 1,base_channels * 1,stride=1,dilation=dilation,norm_cfg=norm_cfg,act_cfg=act_cfg,),)self.encoder3 = nn.Sequential(#继续下采样,分辨率由1/2-->1/4BasicBlock(#通道数翻倍,步长为2,分辨率减半base_channels * 1,base_channels * 2,stride=2,dilation=dilation,norm_cfg=norm_cfg,act_cfg=act_cfg,),BasicBlock(#保持当前分辨率,深化当前特征提取base_channels * 2,base_channels * 2,stride=1,dilation=dilation,norm_cfg=norm_cfg,act_cfg=act_cfg,),)self.encoder4 = nn.Sequential(#继续下采样,分辨率由原来的1/4-->1/8BasicBlock(base_channels * 2,base_channels * 4,#通道数翻倍stride=2,#步长为2,分辨率减半dilation=dilation,norm_cfg=norm_cfg,act_cfg=act_cfg,),BasicBlock(base_channels * 4,base_channels * 4,#通道数不变stride=1,#步长不变,继续深化当前特征dilation=dilation,norm_cfg=norm_cfg,act_cfg=act_cfg,),)self.decoder4_1 = ConvBlock(#对最深层进行线性投影,准备融合base_channels * 4,base_channels * 4,#通道数未改变kernel_size=1,#步长为1,分辨率大小不变conv_cfg="Conv2d",norm_cfg="None",act_cfg="None",)self.decoder3_1 = ConvBlock(base_channels * 2,# 输入:encoder3的输出通道数 [B, C×2, H/4, W/4]base_channels * 4,# 输出:扩展到4倍通道,与上层特征对齐 [B, C×4, H/4, W/4]kernel_size=1,conv_cfg="Conv2d",norm_cfg="None",act_cfg="None",)self.decoder3_2 = nn.Sequential(ConvBlock(base_channels * 4,base_channels * 4,#保持原维度,深化当前特征kernel_size=3,padding=1,conv_cfg="Conv2d",norm_cfg=norm_cfg,act_cfg=act_cfg,),ConvBlock(base_channels * 4,base_channels * 2,# 减少通道数,恢复到合理规模 [B, C×2, H/4, W/4]kernel_size=3,padding=1,conv_cfg="Conv2d",norm_cfg="None",act_cfg="None",),)self.decoder2_1 = ConvBlock(#输入为[B,C,H/2,W/2],base_channels * 1,base_channels * 2,#通道数扩大,[B,C*2,H/2,W/2]kernel_size=1,conv_cfg="Conv2d",norm_cfg="None",act_cfg="None",)self.decoder2_2 = nn.Sequential(ConvBlock(base_channels * 2,base_channels * 2,#通道数不变,保持当前维度进行深度优化kernel_size=3,padding=1,conv_cfg="Conv2d",norm_cfg=norm_cfg,act_cfg=act_cfg,),ConvBlock(base_channels * 2,base_channels * 1,#恢复原通道,[B,C*2,H/2,W/2]-->[B,C,H/2,W/2]kernel_size=3,padding=1,conv_cfg="Conv2d",norm_cfg="None",act_cfg="None",),)self.decoder1_1 = ConvBlock(base_channels * 1,base_channels * 1,#保持原维度,深化特征,作投影准备kernel_size=1,conv_cfg="Conv2d",norm_cfg="None",act_cfg="None",)self.decoder1_2 = nn.Sequential(ConvBlock(#深化特征base_channels * 1,base_channels * 1,kernel_size=3,padding=1,conv_cfg="Conv2d",norm_cfg=norm_cfg,act_cfg=act_cfg,),ConvBlock(base_channels * 1,base_channels * 1,kernel_size=3,padding=1,conv_cfg="Conv2d",norm_cfg="None",act_cfg="None",),)self.out_proj = ConvBlock(#最终输出投影层base_channels * 1,out_channels,kernel_size=1,conv_cfg="Conv2d",norm_cfg="None",act_cfg="None",)def forward(self, x):feats_list = []# encoderfeats_s1 = self.encoder1(x) # [B,C,H,W]-->[B,C,H/2,W/2]feats_s2 = self.encoder2(feats_s1) # [B,C,H/2,W/2]-->[B,C,H/2,W/2],这一层主要是深化特征feats_s3 = self.encoder3(feats_s2) # [B,C,H/2,W/2]-->[B,C*2,H/4,W/4]feats_s4 = self.encoder4(feats_s3) # [B,C*2,H/4,W/4]-->[B,C*4,H/8,W/8]# decoderlatent_s4 = self.decoder4_1(feats_s4) # [B,C*4,H/8,W/8]-->[B,C*4,H/8,W/8]feats_list.append(latent_s4)#加入多尺度#feats_s3.shape[2:]指的是encoder第三层的尺寸,即[H/4,W/4],这里是将第四层的尺寸通过上采样变为第三层的,所以最终变成[B,C*4,H/4,W/4]interp_s3 = F.interpolate(latent_s4, size=feats_s3.shape[2:], mode="bilinear", align_corners=True)latent_s3 = self.decoder3_1(feats_s3)#[B,C*2,H/4,W/4]-->[B,C*4,H/4,W/4]latent_s3 = self.decoder3_2(latent_s3 + interp_s3)#将两个通道尺寸都相同的进行融合,这里的decoder同时进行了通道减半feats_list.append(latent_s3)#将融合后尺寸为[B,C*2,H/4,W/4]的第三层加入多尺度#将第三层尺寸变为第二层的尺寸,最终为[B,C*2,H/4,W/4]-->[B,C*2,H/2,W/2]interp_s2 = F.interpolate(latent_s3, size=feats_s2.shape[2:], mode="bilinear", align_corners=True)latent_s2 = self.decoder2_1(feats_s2)#[B,C,H/2,W/2]-->[B,C*2,H/2,W/2]latent_s2 = self.decoder2_2(latent_s2 + interp_s2)#将上采样的和原本的(只经过decoder2_1深化特征处理的)进行融合,里面也进行了通道减半feats_list.append(latent_s2)#加入多尺度,尺寸是[B,C,H/2,W/2]latent_s1 = self.decoder1_1(feats_s1) + latent_s2#相同尺度通道数,直接相加interp_s1 = F.interpolate(latent_s1, size=x.shape[2:], mode="bilinear", align_corners=True)#这里尺寸从1/2变为1latent_s1 = self.decoder1_2(interp_s1)#深化特征latent_s1 = self.out_proj(latent_s1)#最终输出feats_list.append(latent_s1)#加入多尺度层feats_list.reverse()return feats_list
这个时候已经实现了图像的多尺度信息保存。接下来还有一个FeaturePyramid函数,它可以从单尺度输入生成3个空间尺度
class FeaturePyramid(nn.Module):def __init__(self, d_model, norm_cfg="GroupNorm", act_cfg="LeakyReLU"):super().__init__()#对每个尺度进行投影,调整特征表示,保持通道数不变self.latent_1 = nn.Conv2d(d_model, d_model, kernel_size=1)self.latent_2 = nn.Conv2d(d_model, d_model, kernel_size=1)self.latent_3 = nn.Conv2d(d_model, d_model, kernel_size=1)#缩小尺寸,实现2倍下采样self.downsample1 = BasicBlock(d_model, d_model, stride=2, norm_cfg=norm_cfg, act_cfg=act_cfg)self.downsample2 = BasicBlock(d_model, d_model, stride=2, norm_cfg=norm_cfg, act_cfg=act_cfg)def forward(self, feats):feats_s1 = feats#原始尺度feats_s2 = self.downsample1(feats_s1)#1/2feats_s3 = self.downsample2(feats_s2)#1/4feats_s1 = self.latent_1(feats_s1)feats_s2 = self.latent_2(feats_s2)feats_s3 = self.latent_3(feats_s3)return feats_s1, feats_s2, feats_s3
对于点云,我们采用KPFCNN的方法学习3D特征。KPFCNN通过网格下采样动态处理。
class PointBackbone(nn.Module):def __init__(self, input_dim, output_dim, init_dim, kernel_size, init_radius, init_sigma):super().__init__()self.encoder1_1 = KPConvBlock(input_dim, init_dim, kernel_size, init_radius, init_sigma)#使用核点卷积处理,特征提取self.encoder1_2 = KPResidualBlock(init_dim, init_dim * 2, kernel_size, init_radius, init_sigma)#通道数翻倍,C-->C*2分辨率保持不变self.encoder2_1 = KPResidualBlock(#strided=True表示下采样,分辨率1-->1/2init_dim * 2, init_dim * 2, kernel_size, init_radius, init_sigma, strided=True)self.encoder2_2 = KPResidualBlock(init_dim * 2, init_dim * 4, kernel_size, init_radius * 2, init_sigma * 2)#通道数扩大一倍,2C-->4C,感受野扩大self.encoder2_3 = KPResidualBlock(init_dim * 4, init_dim * 4, kernel_size, init_radius * 2, init_sigma * 2)#深化当前特征self.encoder3_1 = KPResidualBlock(#下采样,分辨率1/2-->1/4init_dim * 4, init_dim * 4, kernel_size, init_radius * 2, init_sigma * 2, strided=True)self.encoder3_2 = KPResidualBlock(init_dim * 4, init_dim * 8, kernel_size, init_radius * 4, init_sigma * 4)#通道数翻倍,4C-->8C,感受野翻倍self.encoder3_3 = KPResidualBlock(init_dim * 8, init_dim * 8, kernel_size, init_radius * 4, init_sigma * 4)#深化特征self.encoder4_1 = KPResidualBlock(#下采样,分辨率1/4-->1/8init_dim * 8, init_dim * 8, kernel_size, init_radius * 4, init_sigma * 4, strided=True)self.encoder4_2 = KPResidualBlock(init_dim * 8, init_dim * 16, kernel_size, init_radius * 8, init_sigma * 8)#通道数翻倍,8C-->16C,感受野翻倍self.encoder4_3 = KPResidualBlock(init_dim * 16, init_dim * 16, kernel_size, init_radius * 8, init_sigma * 8)#深化特征self.decoder3 = UnaryBlockPackMode(init_dim * 24, init_dim * 8)#通道数从24C-->8Cself.decoder2 = UnaryBlockPackMode(init_dim * 12, init_dim * 4)#通道数12C-->4Cself.decoder1 = UnaryBlockPackMode(init_dim * 6, init_dim * 2)#通道数6C-->2Cself.out_proj = nn.Linear(init_dim * 2, output_dim)#线性层,输出def forward(self, feats, data_dict):feats_list = []points_list = data_dict["points"]neighbors_list = data_dict["neighbors"]subsampling_list = data_dict["subsampling"]upsampling_list = data_dict["upsampling"]feats_s1 = feats#接收原始输入feats_s1 = self.encoder1_1(points_list[0], points_list[0], feats_s1, neighbors_list[0])#使用核点卷积进行特征提取feats_s1 = self.encoder1_2(points_list[0], points_list[0], feats_s1, neighbors_list[0])#通道数翻倍,C-->2Cfeats_s2 = self.encoder2_1(points_list[1], points_list[0], feats_s1, subsampling_list[0])#下采样,分辨率减半,1/2feats_s2 = self.encoder2_2(points_list[1], points_list[1], feats_s2, neighbors_list[1])#通道数翻倍.2C-->4Cfeats_s2 = self.encoder2_3(points_list[1], points_list[1], feats_s2, neighbors_list[1])#深化当前特征feats_s3 = self.encoder3_1(points_list[2], points_list[1], feats_s2, subsampling_list[1])#下采样,分辨率减半,1/4feats_s3 = self.encoder3_2(points_list[2], points_list[2], feats_s3, neighbors_list[2])#通道数翻倍,4C-->8Cfeats_s3 = self.encoder3_3(points_list[2], points_list[2], feats_s3, neighbors_list[2])#深化特征feats_s4 = self.encoder4_1(points_list[3], points_list[2], feats_s3, subsampling_list[2])#下采样,分辨率减半,1/8feats_s4 = self.encoder4_2(points_list[3], points_list[3], feats_s4, neighbors_list[3])#通道数翻倍,8C-->16Cfeats_s4 = self.encoder4_3(points_list[3], points_list[3], feats_s4, neighbors_list[3])#深化特征latent_s4 = feats_s4feats_list.append(latent_s4)#将第四层加入多尺度层latent_s3 = knn_interpolate_pack_mode(points_list[2], points_list[3], latent_s4, upsampling_list[2])#上采样,将Level4进行上采样获取到Level3的分辨率latent_s3 = torch.cat([latent_s3, feats_s3], dim=1)#融合高层语义和底层信息,此时的通道数是16C+8C=24Clatent_s3 = self.decoder3(latent_s3)#改变通道数,特征融合feats_list.append(latent_s3)#将level3加入多尺度层latent_s2 = knn_interpolate_pack_mode(points_list[1], points_list[2], latent_s3, upsampling_list[1])#上采样,融合第二层latent_s2 = torch.cat([latent_s2, feats_s2], dim=1)latent_s2 = self.decoder2(latent_s2)feats_list.append(latent_s2)latent_s1 = knn_interpolate_pack_mode(points_list[0], points_list[1], latent_s2, upsampling_list[0])#上采样,融合lever1latent_s1 = torch.cat([latent_s1, feats_s1], dim=1)latent_s1 = self.decoder1(latent_s1)latent_s1 = self.out_proj(latent_s1)feats_list.append(latent_s1)feats_list.reverse()#反转顺序return feats_list
多尺度块匹配
本论文设计了一个基于Transfrom的特征细化模块来学习全局上下文约束和跨模态相关性。

这里首先使用傅里叶级数进行2D和3D的信息增强,然后使用自注意力机制分别对2D和3D进行处理,以此获取全局上下文信息,接下来就是交叉注意力,这里是跨模态的核心,它将2D和3D的信息进行交流互换,通过这个操作,图像块的特征会越来越接近它对应的点云的点的特征,这时候就得到了Hp和Hi,对Hp进行多尺度采样与匹配策略,也就是下采样+上采样获取信息,然后这个{Hk}会保存所有的尺度特征,再将这个所有的尺度特征依次和点云特征Hp进行相似度计算,通过Max over Scales策略自动选出最合适的匹配尺度,最终进行Mutual Top-k选出前K个值较大的作为匹配对。
其中,我们看到了使用傅里叶级数进行信息增强,为什么要使用傅里叶级数呢,因为对机器来说,他是无法理解x=2与x=1的位置关系的,它可能认为他们之间只是一个二倍的关系,而认识不到顺序等复杂的关系,AI给出的更生动的例子如下

正因此,这里使用了傅里叶级数,公式如下:

这里既保留了原来的位置信息x,又增加了正弦和余弦函数,将原始坐标和所有这些不同频率的正余弦函数值拼接在一起,就得到了一个丰富的、多尺度的位置特征向量。
这个Max over Scales策略实现了文中所说的为每个点块在适当的分辨率找出最佳匹配图模块,那么它是如何实现的呢,其实它就是计算了多个尺度的图像与对应点云的点的相似度,然后选出了最大的一个作为最佳匹配。
这个Mutual Top-k可以简单理解为每个点云块在图像选出前K个与自己相像的像素块,然后每个图像的像素块也是在点云中选出前K个与自己相像的点云块,然后我们找出其中互相选择了的,然后保存这些互相选择的点对。
上述是理论,接下来看具体的实现。
首先是为图像和点云进行编码,为空间位置信息编码,让模型理解像素和点的空间关系。其中,FourierEmbedding就是傅里叶编码的实现
self.use_embedding = use_embedding
if self.use_embedding:self.embedding = FourierEmbedding(embedding_dim, use_pi=False, use_input=True)#傅里叶编码self.img_emb_proj = nn.Linear(embedding_dim * 4 + 2, hidden_dim) # 2D位置编码,统一隐藏维度self.pcd_emb_proj = nn.Linear(embedding_dim * 6 + 3, hidden_dim) # 3D位置编码,统一隐藏维度
else:self.embedding = Noneself.img_emb_proj = Noneself.pcd_emb_proj = None
接下来是将不同维度的图像和点云特征映射到统一的隐藏维度,以及融合特征,然后投影到目标维度
self.img_in_proj = nn.Linear(img_input_dim, hidden_dim) # 图像特征投影
self.pcd_in_proj = nn.Linear(pcd_input_dim, hidden_dim) # 点云特征投影
self.out_proj = nn.Linear(hidden_dim, output_dim)#将融合后的特征投影到目标输出维度。
还有就是注意力机制了,这里构建交替的自注意力和交叉注意力层。
self.blocks = blocks # 如: ["self", "cross", "self", "cross", "self", "cross"]
layers = []
for block in self.blocks:layers.append(TransformerLayer(hidden_dim, num_heads, dropout=dropout, act_cfg=activation_fn))
self.transformer = nn.ModuleList(layers)
刚刚是初始化里给定的一些操作,接下来这里创建了两个函数,分别用于2D和3D坐标生成位置感知的特征编码。
def create_2d_embedding(self, pixels):# pixels: (B, HxW, 2) - 归一化像素坐标embeddings = self.embedding(pixels) # 傅里叶特征: (B, HxW, embedding_dim*4)embeddings = self.img_emb_proj(embeddings) # 投影到hidden_dimreturn embeddingsdef create_3d_embedding(self, points):points = points - points.mean(dim=1) # 中心化,增强平移不变性embeddings = self.embedding(points) # 傅里叶特征: (B, N, embedding_dim*6) embeddings = self.pcd_emb_proj(embeddings) # 投影到hidden_dimreturn embeddings
接下来是前向传播,看一下在定义完这些函数之后它到底是如何进行多尺度融合的。
首先第一步是进行特征投影,统一了通道数
img_tokens = self.img_in_proj(img_feats) # (B, HxW, Ci) -> (B, HxW, C)
pcd_tokens = self.pcd_in_proj(pcd_feats) # (B, N, Cp) -> (B, N, C)
接下来进行了位置编码,此时就是内容特征与位置编码的融合编码了
if self.use_embedding:img_embeddings = self.create_2d_embedding(img_pixels) # 2D位置编码img_tokens = img_tokens + img_embeddings # 特征 + 位置信息pcd_embeddings = self.create_3d_embedding(pcd_points) # 3D位置编码pcd_tokens = pcd_tokens + pcd_embeddings # 特征 + 位置信息
第三步是进行注意力机制
for i, block in enumerate(self.blocks):if block == "self":# 自注意力:模态内部特征增强img_tokens = self.transformer[i](img_tokens, img_tokens, img_tokens, k_masks=img_masks)pcd_tokens = self.transformer[i](pcd_tokens, pcd_tokens, pcd_tokens, k_masks=pcd_masks)else:# 交叉注意力:模态间特征融合img_tokens = self.transformer[i](img_tokens, pcd_tokens, pcd_tokens, k_masks=pcd_masks)pcd_tokens = self.transformer[i](pcd_tokens, img_tokens, img_tokens, k_masks=img_masks)
最后进行了投影输出
img_feats = self.out_proj(img_tokens) # (B, HxW, C) -> (B, HxW, Co)
pcd_feats = self.out_proj(pcd_tokens) # (B, N, C) -> (B, N, Co)
至此就完成了多尺度的特征融合,接下来我们要进行块之间的匹配。
在model.py中,这里直接定义了刚刚所讲的函数,定义为transfrom函数对图像和点云进行特征融合
# 3.2 Cross-modal fusion transformerimg_feats_c, pcd_feats_c = self.transformer(img_feats_c.unsqueeze(0),#图像特征img_pixels_c.unsqueeze(0),#图像坐标pcd_feats_c.unsqueeze(0),#点云特征pcd_points_c.unsqueeze(0),#点云3D坐标)
接下来就是2D的形状重塑,输出多尺度的特征图列表
# 3.3 Post-transformer image feature pyramidimg_feats_c = img_feats_c.transpose(1, 2).contiguous().view(1, -1, self.img_h_c, self.img_w_c)#重塑形状all_img_feats_c = self.img_pyramid(img_feats_c)#生成多尺度的图像all_img_feats_c = [x.squeeze(0).view(x.shape[1], -1).transpose(0, 1).contiguous() for x in all_img_feats_c]#进行序列化,转为序列形式img_feats_c = torch.cat(all_img_feats_c, dim=0)#多尺度特征融合
3D点云特征后处理和特征归一化
# 3.4 Post-processing for point featurespcd_feats_c = pcd_feats_c.squeeze(0)img_feats_c = F.normalize(img_feats_c, p=2, dim=1)pcd_feats_c = F.normalize(pcd_feats_c, p=2, dim=1)output_dict["img_feats_c"] = img_feats_coutput_dict["pcd_feats_c"] = pcd_feats_c
此时3D点云和2D的图像就可以确保特征在相同的度量空间中进行比对。
然后我们进行3D块的生成,将精细点云划分为以粗糙点为中心的局部块
_, pcd_node_sizes, pcd_node_masks, pcd_node_knn_indices, pcd_node_knn_masks = point_to_node_partition(pcd_points_f, # 精细点云: (Nf, 3)pcd_points_c, # 粗糙点云/节点中心: (Nc, 3) self.pcd_num_points_in_patch, # 每个节点的点数gather_points=True,return_count=True,
)
进行点云节点有效性过滤
pcd_node_masks = torch.logical_and(pcd_node_masks, torch.gt(pcd_node_sizes, self.pcd_min_node_size))
进行点云坐标填充和索引
pcd_padded_points_f = torch.cat([pcd_points_f, torch.ones_like(pcd_points_f[:1]) * 1e10], dim=0)
pcd_node_knn_points = index_select(pcd_padded_points_f, pcd_node_knn_indices, dim=0)
获取点云在图像上的映射坐标
pcd_padded_pixels_f = torch.cat([pcd_pixels_f, torch.ones_like(pcd_pixels_f[:1]) * 1e10], dim=0)
pcd_node_knn_pixels = index_select(pcd_padded_pixels_f, pcd_node_knn_indices, dim=0)
此时3D块就生成完成,接下来是2D块的生成。
首先进行初始化存储信息,具体如下
# 存储每个尺度的块信息
all_img_node_knn_points = [] # 每个块的3D点坐标
all_img_node_knn_pixels = [] # 每个块的像素坐标
all_img_node_knn_indices = [] # 每个块的点索引
all_img_node_knn_masks = [] # 每个块的点有效掩码
all_img_node_masks = [] # 每个块的有效性掩码
all_img_node_levels = [] # 每个块的尺度级别
all_img_num_nodes = [] # 每个尺度的节点数量
all_img_total_nodes = [] # 每个尺度的累计节点偏移量# 存储真值对应关系信息
all_gt_img_node_corr_levels = [] # 对应关系的尺度级别
all_gt_img_node_corr_indices = [] # 图像节点索引
all_gt_pcd_node_corr_indices = [] # 点云节点索引
all_gt_img_node_corr_overlaps = [] # 图像节点重叠度
all_gt_pcd_node_corr_overlaps = [] # 点云节点重叠度
因为有多个尺度,所以这里需要进行遍历,实现块的划分,patchify函数将图像划分为网格块,每个块包含局部区域内的点
img_h_c = self.img_h_c # 初始高度: 24
img_w_c = self.img_w_c # 初始宽度: 32for i in range(self.img_num_levels_c): # 通常为3个尺度(img_node_knn_points, # (N, Ki, 3) - 每个块的3D点坐标img_node_knn_pixels, # (N, Ki, 2) - 每个块的像素坐标img_node_knn_indices, # (N, Ki) - 每个块的点索引img_node_knn_masks, # (N, Ki) - 每个块的点有效掩码img_node_masks, # (N) - 块的有效性掩码
) = patchify(img_points_f, # 精细3D点坐标 (H×W, 3)img_pixels_f, # 精细像素坐标 (H×W, 2) img_masks_f, # 有效点掩码 (H×W)img_h_f, img_w_f, # 原始图像高宽 (480, 640)img_h_c, img_w_c, # 当前尺度高宽 (逐级减半)stride=2, # 步长
)
接着记录有多少个图像块,并存储信息(其实就是给初始化的赋值,不再写)
img_num_nodes = img_h_c * img_w_c # 当前尺度的节点总数
img_node_levels = torch.full(size=(img_num_nodes,), fill_value=i, dtype=torch.long).cuda()
3D块和2D块都有了就可以进行计算了
使用get_2d3d_node_correspondences函数进行2D图像块和3D点云块之间的真值对应关系,真值其实就是指2D和3D的哪部分是正好对应的
(gt_img_node_corr_indices, # 图像节点索引gt_pcd_node_corr_indices, # 点云节点索引 gt_img_node_corr_overlaps, # 图像节点重叠度gt_pcd_node_corr_overlaps, # 点云节点重叠度
) = get_2d3d_node_correspondences(img_node_masks, # 图像节点掩码img_node_knn_points, # 图像节点3D点坐标img_node_knn_pixels, # 图像节点像素坐标img_node_knn_masks, # 图像节点点掩码pcd_node_masks, # 点云节点掩码pcd_node_knn_points, # 点云节点3D点坐标pcd_node_knn_pixels, # 点云节点像素坐标pcd_node_knn_masks, # 点云节点点掩码transform, # 变换矩阵self.matching_radius_2d, # 2D匹配半径self.matching_radius_3d, # 3D匹配半径
)
gt_img_node_corr_indices += total_img_num_nodes#将当前尺度的图像节点索引转换为全局索引
gt_img_node_corr_levels = torch.full_like(gt_img_node_corr_indices, fill_value=i)#为每个对应关系标记所属的尺度级别
接着就是存储真值并进行尺度更新
all_gt_img_node_corr_levels.append(gt_img_node_corr_levels)
all_gt_img_node_corr_indices.append(gt_img_node_corr_indices)
all_gt_pcd_node_corr_indices.append(gt_pcd_node_corr_indices)
all_gt_img_node_corr_overlaps.append(gt_img_node_corr_overlaps)
all_gt_pcd_node_corr_overlaps.append(gt_pcd_node_corr_overlaps)
img_h_c //= 2
img_w_c //= 2
total_img_num_nodes += img_num_nodes
然后是多尺度的信息合并,记录总节点数,合并真值,最大和最小重叠度的计算。
img_node_masks = torch.cat(all_img_node_masks, dim=0) # 所有尺度的节点掩码
img_node_levels = torch.cat(all_img_node_levels, dim=0) # 所有尺度的节点级别
output_dict["img_num_nodes"] = total_img_num_nodes # 总图像节点数
output_dict["pcd_num_nodes"] = pcd_points_c.shape[0] # 点云节点数
gt_img_node_corr_levels = torch.cat(all_gt_img_node_corr_levels, dim=0)
gt_img_node_corr_indices = torch.cat(all_gt_img_node_corr_indices, dim=0)
gt_pcd_node_corr_indices = torch.cat(all_gt_pcd_node_corr_indices, dim=0)
gt_img_node_corr_overlaps = torch.cat(all_gt_img_node_corr_overlaps, dim=0)
gt_pcd_node_corr_overlaps = torch.cat(all_gt_pcd_node_corr_overlaps, dim=0)
gt_node_corr_min_overlaps = torch.minimum(gt_img_node_corr_overlaps, gt_pcd_node_corr_overlaps)
gt_node_corr_max_overlaps = torch.maximum(gt_img_node_corr_overlaps, gt_pcd_node_corr_overlaps)
对图像进行特征重塑和归一化
img_channels_f = img_feats_f.shape[1] # 获取特征通道数
img_feats_f = img_feats_f.squeeze(0).view(img_channels_f, -1).transpose(0, 1).contiguous()
img_feats_f = F.normalize(img_feats_f, p=2, dim=1)
pcd_feats_f = F.normalize(pcd_feats_f, p=2, dim=1)
output_dict["img_feats_f"] = img_feats_f # 形状: (H×W, C)
output_dict["pcd_feats_f"] = pcd_feats_f # 形状: (Nf, C)
执行粗匹配,它将会输出哪些图像块与哪些点云块匹配
if not self.training:(img_node_corr_indices, # 匹配的图像节点索引pcd_node_corr_indices, # 匹配的点云节点索引 node_corr_scores, # 匹配置信度分数) = self.coarse_matching(img_feats_c, pcd_feats_c, img_node_masks, pcd_node_masks)
接下来进行每个匹配对应的尺度级别并保存
img_node_corr_levels = img_node_levels[img_node_corr_indices]
output_dict["img_node_corr_indices"] = img_node_corr_indices
output_dict["pcd_node_corr_indices"] = pcd_node_corr_indices
output_dict["img_node_corr_levels"] = img_node_corr_levels
此时就完成了粗匹配。
密集像素点匹配
在获取了块的像素匹配关系之后,我们这里进一步细化为密集的像素点对应关系,可以理解为之前是进行了粗匹配,这里我们进行细匹配,具体步骤如下
(1)我们从Fi和Fp中提取像素和点的2D和3D特征,分别表示为Fi_i和Fp_i
(2)因为全部像素计算量过大,所以我们使用均匀采样1/4,节省了计算量
(3)对Fi_i和Fp_i进行归一化,通过双向top-k选择出匹配的像素和点
(4)进行聚合,将多尺度块匹配中的点对中进行的密集像素点匹配进行聚合,放入集合C中(因为刚刚的只是在一个像素块中的操作)
具体实现如下
首先存储索引
all_img_corr_indices = [] # 存储所有尺度的图像点索引
all_pcd_corr_indices = [] # 存储所有尺度的点云点索引
多尺度,所以进行遍历
for i in range(self.img_num_levels_c): # 遍历3个尺度node_corr_masks = torch.eq(img_node_corr_levels, i) # 筛选当前尺度的匹配对cur_img_node_corr_indices = img_node_corr_indices[node_corr_masks] - all_img_total_nodes[i]#将全局节点索引转换为当前尺度的局部索引cur_pcd_node_corr_indices = pcd_node_corr_indices[node_corr_masks]# 图像节点内的点特征img_node_corr_knn_indices = index_select(img_node_knn_indices, cur_img_node_corr_indices, dim=0)img_node_corr_knn_feats = index_select(img_feats_f, img_node_corr_knn_indices, dim=0)# 点云节点内的点特征 pcd_node_corr_knn_indices = pcd_node_knn_indices[cur_pcd_node_corr_indices]pcd_node_corr_knn_feats = index_select(pcd_padded_feats_f, pcd_node_corr_knn_indices, dim=0)#进行相似度的计算similarity_mat = pairwise_cosine_similarity(img_node_corr_knn_feats, pcd_node_corr_knn_feats, normalized=True
)
接下来进行Top-k算法找出匹配效果最好的(以下部分也是在for循环中)
batch_indices, row_indices, col_indices, _ = batch_mutual_topk_select(similarity_mat,k=2, # 每个点选择top2匹配row_masks=img_node_corr_knn_masks,col_masks=pcd_node_corr_knn_masks, threshold=0.75, # 相似度阈值largest=True, # 选择最大相似度mutual=True, # 双向一致匹配
)
获取点级对应关系
img_corr_indices = img_node_corr_knn_indices[batch_indices, row_indices] # 图像点索引
pcd_corr_indices = pcd_node_corr_knn_indices[batch_indices, col_indices] # 点云点索引
此时就找到了点和图像的对应。后续就是点的合并和去除重复信息,提取点的坐标,计算他们的置信度
img_corr_indices = torch.cat(all_img_corr_indices, dim=0) # 所有尺度的图像点索引
pcd_corr_indices = torch.cat(all_pcd_corr_indices, dim=0) # 所有尺度的点云点索引
num_points_f = pcd_points_f.shape[0]
corr_indices = img_corr_indices * num_points_f + pcd_corr_indices # 编码为一维索引
unique_corr_indices = torch.unique(corr_indices) # 去重
img_corr_indices = torch.div(unique_corr_indices, num_points_f, rounding_mode="floor") # 解码图像索引
pcd_corr_indices = unique_corr_indices % num_points_f # 解码点云索引
img_corr_points = img_points_f[img_corr_indices] # 匹配的图像点3D坐标
img_corr_pixels = img_pixels_f[img_corr_indices] # 匹配的图像像素坐标
pcd_corr_points = pcd_points_f[pcd_corr_indices] # 匹配的点云点3D坐标
pcd_corr_pixels = pcd_pixels_f[pcd_corr_indices] # 匹配的点云投影像素坐标
img_corr_feats = img_feats_f[img_corr_indices]
pcd_corr_feats = pcd_feats_f[pcd_corr_indices]
corr_scores = (img_corr_feats * pcd_corr_feats).sum(1) # 特征点积作为置信度
损失函数
本论文使用的损失函数是圆形损失函数,是一种用于度量学习的先进损失函数(论文通过度量学习方式训练),它的核心思想是让正样本更靠近锚点,让负样本更远离锚点,并且通过一个圆形决策边界来自适应地调整每个样本对的权重。

各个参数解释如下

当正样本距离大于△时,正指数,惩罚较大;小于△时,负指数,惩罚较小。
当负样本距离大于△时,负指数,惩罚较小;小于△时,正指数,惩罚较大。
正负样本如何界定呢?本论文中规定的是如果2D和3D重叠比率≥30%,则为正样本,低于20%则为负样本。