原文:Kernel Transformer Networks for Compact Spherical Convolution
原文链接:IEEE Xplore Full-Text PDF:
代码链接:SLOF (siamlof.github.io)
原理
卷积核通过学习使得自身能够进行空间变换,来克服360°图像经过EPR投影以后在极坐标附近的失真状况。
从上图可以看出在靠近两极的地方失真十分严重
当前遇见的问题:
1.360°全景图像存在严重的极区域失真,图像发生了弯曲等几何变形
2.普通的CNN卷积核形状固定,不能很好适应全景图像不同区域的变形
KTN引入了可学习的核变换模块,可以改变卷积核的形状、方向等。
KTN的核变换是可学习的,可以通过反向传播学习对全景图像变形较优的卷积核变换。经过变换,卷积核可以自适应不同区域的形变,增强模型的鲁棒性。最终使用变形后的卷积核进行特征提取,实现对全景图像的可变形卷积。
主要的思想:
1.在源卷积核后面加入Kernel Transformer模块,这是KTN的核心组件
2.Kernel Transformer模块对源卷积核进行空间变换,如缩放、旋转等,输出目标卷积核
3.目标卷积核的形状、方向等会发生改变,从而更好地适应输入特征图的形状
4.ktn是可学习的,通过反向传播可以训练学习到对特征图变形较优的核变换方式
5.经过变换,卷积核可以自适应不同区域的形变
其中K是源CNN的卷积核,红色的是形变后的
代码部分:
KTN..py
代码的输入为x和对应的行号row,对x进行相应的线性插值,来改变卷积核在行方向上的形状
class RowBilinear(nn.Module): #行方向的线性插值
def __init__(self, n_in, kernel_shapes, pad=0):
super(RowBilinear, self).__init__()
n_transform = kernel_shapes.size(0)
weights = []
self.pad = pad
for i in range(n_transform):
kH = kernel_shapes[i,0].item()
kW = kernel_shapes[i,1].item()
n_out = (kH + 2 * pad) * (kW + 2 * pad)
#print(n_out, n_in)
weight = nn.Parameter(torch.Tensor(n_out, n_in)) #初始化一个线性层参数作为kernel权重
weights.append(weight)
# self.weights = nn.ParameterList(weights)
self.weights = weights
# setattr(parameter_list_module, 'weights', weights)
def forward(self, x, row): #row表示需要插值的行数
weight = self.weights[row]
weight = weight.to(x.device)
return F.linear(x, weight) #F.linear对特定行进行线性插值
下面是个小case,可以看出x会随着学习到的权重发生改变
ResidualKTN主要构建了KTN的残差结构,结构依次为行投影层,1*1卷积层,深度卷积层
class ResidualKTN(BilinearKTN):
def initialize_ktn(self, kernel_size):
self.bilinear = RowBilinear(kernel_size, self.kernel_shapes) #行方向进行双线性插值进行核大小变换
self.res1 = RowBilinear(kernel_size, self.kernel_shapes, pad=2) #
self.res2 = nn.Conv2d(self.n_in, self.n_in, 1) #调整通道数
self.res3 = nn.Conv2d(1, 1, 3, padding=0) #用来提取特征
self.res4 = nn.Conv2d(self.n_in, self.n_in, 1)
self.res5 = nn.Conv2d(1, 1, 3, padding=0)
def apply_ktn(self, x, row): #根据不同的角度,变化核
base = self.bilinear(x, row) #进行核变换根据不同的角度,这是图中的下面一层
okH, okW = self.kernel_shapes[row] #获得原始的kw,kh
x = self.res1(x, row)
x = x.view(-1, self.n_in, okH+4, okW+4)
x = self.res2(self.activation(x))
x = x.view(-1, 1, okH+4, okW+4)
x = self.res3(self.activation(x))
x = x.view(-1, self.n_in, okH+2, okW+2)
x = self.res4(self.activation(x))
x = x.view(-1, 1, okH+2, okW+2)
x = self.res5(self.activation(x))
x = x.view(base.size())
x = x + base
return x
def initialize_weight(self, **kwargs):
for name, param in self.named_parameters():
if name[-5:] == ".bias":
param.data.zero_()
elif name[-7:] == ".weight":
param.data.normal_(std=0.01)
self.initialize_bilinear(self.bilinear, **kwargs)
self.initialize_bilinear(self.res1, **kwargs)
具体的结构如上图所示,值得注意的是通过两次此结构,不同的是感受野不同,可以更好捕捉特征。
下面代码主要实现了
1.遍历目标核的每一个像素坐标
2.计算映射到球面坐标的行列号
3.获取投影向量p并赋值给权重参数
这样可以使用犬座标的参数初始化行方向插值核的权重
class BilinearKTN(KTN):
def initialize_ktn(self, kernel_size):
self.bilinear = RowBilinear(kernel_size, self.kernel_shapes) #创建RowBilinear对象作为现在插值层
def apply_ktn(self, x, row):
x = self.bilinear(x, row) #对x进行线性插值
return x
def initialize_weight(self, **kwargs):
for name, param in self.named_parameters():
if name[-5:] == ".bias":
param.data.zero_()
elif name[-7:] == ".weight":
param.data.normal_(std=0.01)
self.initialize_bilinear(self.bilinear, **kwargs) #对于bilinear参数初始化
def initialize_bilinear(self,
bilinear,
sphereH=320,
fov=65.5,
imgW=640,
dilation=1,
tied_weights=5):
kH = self.src_kernel.size(2)
sphereW = sphereH * 2
projection = SphereProjection(kernel_size=kH,
sphereH=sphereH,
sphereW=sphereW,
view_angle=fov,
imgW=imgW)
center = sphereW / 2
for i, param in enumerate(bilinear.weights):
param.data.zero_()
tilt = i * tied_weights + tied_weights / 2 #计算俯仰角
P = projection.buildP(tilt=tilt).transpose() #产生投影矩阵
okH = self.kernel_shapes[i,0].item() #原始核的高度
okW = self.kernel_shapes[i,1].item()
okH += bilinear.pad * 2 #计算出插值后的核的大小
okW += bilinear.pad * 2
sH = tilt - okH / 2
sW = center - okW / 2
for y in range(okH): #遍历每个像素计算映射到球面上的坐标,并根据投影矩阵赋值给权重
row = y + sH
if row < 0 or row >= sphereH:
continue
for x in range(okW):
col = x + sW
if col < 0 or col >= sphereW:
continue
pixel = int(row * sphereW + col)
p = P[pixel]
if p.nnz == 0:
continue
j = y * okW + x
for k in range(p.shape[1]):
param.data[j,k] = p[0,k]
首先计算出核变换后的大小形状,双线性插值后核的形状和大小必然改变,因此后面必须增加padding,然后在计算卷积核的参数。接下来就是经过下图一个遍历的过程,将p投影向量的参数赋值到对应索引的位置。