可变形卷积 即DCN(Deformable ConvNets)提出于ICCV 2017的paper:Deformable Convolutional Networks
论文地址:Deformable_Convolutional_Networks
1.原理
可形变卷积,卷积核的形状是可变的,也就是感受野可以变化,但注意感受野的元素是“不变”的。传统的卷积采用固定尺寸的卷积核,不能很好地适应几何形变
(a)是普通的卷积操作。(b)、(c)、(d)是可变形卷积。
可变形卷积实际是指标准卷积操作中采样位置增加了一个偏移量offset,这样卷积核就能在训练过程中扩展到很大的范围。(c)(d)是(b)的特例,表明可变形卷积推广了尺度、长宽比和旋转的各种变换。
卷积单元(卷积核)对输入的特征图在固定的位置进行采样;池化层不断减小着特征图的尺寸。然而,这样做会产生一些问题,比如,卷积核权重的固定导致同一CNN在处理一张图的不同位置区域的时候感受野尺寸都相同,这对于编码位置信息的深层卷积神经网络是不合理的,因为不同的位置可能对应有不同尺度或者不同形变的物体,这些层需要能够自动调整尺度或者感受野的方法。再比如,目标检测的效果很大程度上依赖于基于特征提取的边界框,这并不是最优的方法,尤其是对于非网格状的物体而言。
左图是正常卷积,右图是可变形卷积,最上面的图像是在大小不同的物体上的激活单元
中间层是为了得到顶层激活单元所进行的采样过程(可以看作是一个卷积操作,3*3卷积核对应九个点九个数最后得到上面一个点即一个数值),左图是标准的3x3方阵采样,右图是非标准形状的采样,但是采样的点依然是3x3,符合3*3卷积的广义定义。
最下面一层是为了得到中间层进行的采样区域,同理,明显发现,可变形卷积在采样时可以更贴近物体的形状和尺寸,更具有鲁棒性,而标准卷积无法做到这一点。
可变形卷积实现
偏差(offsets)通过一个卷积层(conv)获得,输入特征图(feature map),输出偏差,生成通道维度是2N,其中的2分别对应X和Y这两个2D偏移,N具体是靠卷积核大小计算得到。比如常见的3*3卷积,9个参数那么N=9。
一共有两种卷积核:卷积核和卷积核学习offsets对应的卷积层内的卷积核,这两种卷积核通过双线性插值反向传播同时进行参数更新。这种实现方式相当于于比正常的卷积操作多学习了卷积核的偏移offset。
总的来说,DConv具体操作流程是:
①和正常的卷积神经网络一样,根据输入的图像利用传统的卷积核提取特征图。
②把①得到的特征图作为输入,对特征图再施加一个卷积层,这么做是为了得到可变形卷积的变形的偏移量(offsets)。其中,偏移层是2N,因为我们在平面上做平移,需要改变x值和y值两个方向
③在训练的时候,用于生成输出特征的卷积核和用于生成偏移量的卷积核是同步学习的。其中偏移量的学习是利用插值算法,通过反向传播进行学习
其中,代表输入特征映射中全部的空间位置,表示在特征图中全部整数位置上点的取值,为一个二维核的双线性插值核函数,可分解成两个一维核公式为
其中
最后给每一个采集点增加一个偏移量。计算公式为:
在实际操作时,并不是真正地把卷积核进行扩展,而是对卷积前图片的像素重新整合,变相地实现卷积核的扩张。也即是,实际上变的是每次进行卷积后得到的带偏移值的坐标值,根据这些坐标取像素点,然后双线性差值,得到新特征图(feature map),然后作为输出并成为下一层的新输入。
在图片像素整合时,需要对像素进行偏移操作,偏移量的生成会产生浮点数类型,而偏移量又必须转换为整形,直接对偏移量取整的话无法进行反向传播,这时采用双线性差值的方式来得到对应的像素。
跟可变形卷积的区别就是输入是经过普通RoI池化后的feature map,进入一个全连接层(不是卷积层),得到一个偏移。注意为了匹配RoI的尺寸,这里的偏移量要归一化。可变形RoI池化可用的场景也较少,因此就不作过多介绍,只放上图
2.源码
pyrotch版本源码为:
class DeformConv2d(nn.Module):
def __init__(self, inc, outc, kernel_size=3, padding=1, stride=1, bias=None, modulation=False):
"""
新增modulation 参数: 是DCNv2中引入的调制标量
"""
super(DeformConv2d, self).__init__()
self.kernel_size = kernel_size
self.padding = padding
self.stride = stride
self.zero_padding = nn.ZeroPad2d(padding)
self.conv = nn.Conv2d(inc, outc, kernel_size=kernel_size, stride=kernel_size, bias=bias)
self.p_conv = nn.Conv2d(inc, 2 * kernel_size * kernel_size, kernel_size=3, padding=1, stride=stride)
# 输出通道是2N
nn.init.constant_(self.p_conv.weight, 0) # 权重初始化为0
self.p_conv.register_full_backward_hook(self._set_lr)
self.modulation = modulation
if modulation: # 如果需要进行调制
# 输出通道是N
self.m_conv = nn.Conv2d(inc, kernel_size * kernel_size, kernel_size=3, padding=1, stride=stride)
nn.init.constant_(self.m_conv.weight, 0)
self.m_conv.register_full_backward_hook(self._set_lr) # 在指定网络层执行完backward()之后调用钩子函数
@staticmethod
def _set_lr(module, grad_input, grad_output): # 设置学习率的大小
grad_input = (grad_input[i] * 0.1 for i in range(len(grad_input)))
grad_output = (grad_output[i] * 0.1 for i in range(len(grad_output)))
def _get_p_n(self, N, dtype):
p_n_x, p_n_y = torch.meshgrid(
torch.arange(-(self.kernel_size - 1) // 2, (self.kernel_size - 1) // 2 + 1),
torch.arange(-(self.kernel_size - 1) // 2, (self.kernel_size - 1) // 2 + 1),
indexing='xy')
# (2N, 1)
p_n = torch.cat([torch.flatten(p_n_x), torch.flatten(p_n_y)], 0)
p_n = p_n.view(1, 2 * N, 1, 1).type(dtype)
return p_n
def _get_p_0(self, h, w, N, dtype):
p_0_x, p_0_y = torch.meshgrid(
torch.arange(1, h * self.stride + 1, self.stride),
torch.arange(1, w * self.stride + 1, self.stride))
p_0_x = torch.flatten(p_0_x).view(1, 1, h, w).repeat(1, N, 1, 1)
p_0_y = torch.flatten(p_0_y).view(1, 1, h, w).repeat(1, N, 1, 1)
p_0 = torch.cat([p_0_x, p_0_y], 1).type(dtype)
return p_0
def _get_p(self, offset, dtype):
# N = 18 / 2 = 9,表示卷积核的参数数量
# h = 32
# w = 32
N, h, w = offset.size(1) // 2, offset.size(2), offset.size(3)
# (1, 2N, 1, 1),获得N个卷积点的偏移
p_n = self._get_p_n(N, dtype)
# (1, 2N, h, w),获得每个像素点的坐标
p_0 = self._get_p_0(h, w, N, dtype)
# 卷积坐标加上之前学习出的offset后就是论文提出的公式(2)也就是加上了偏置后的卷积操作。
# 比如p(在N=0时)p_0就是中心坐标,而p_n=(-1,-1),所以此时的p就是卷积核中心坐标加上
# (-1,-1)(即红色块左上方的块)再加上offset。同理可得N=1,N=2...分别代表了一个卷积核
# 上各个元素。
p = p_0 + p_n + offset
# 在进行offset的加法时,会产生广播操作,最终的结果p--[B, H, W, 2N]
return p
def _get_x_q(self, x, q, N):
b, h, w, _ = q.size()
# 获得x_la的宽度
padded_w = x.size(3)
c = x.size(1)
# (b, c, h*w)
x = x.contiguous().view(b, c, -1)
# (b, h, w, N)
index = q[..., :N] * padded_w + q[..., N:] # offset_x*w + offset_y
# (b, c, h*w*N)
index = index.contiguous().unsqueeze(dim=1).expand(-1, c, -1, -1, -1).contiguous().view(b, c, -1)
x_offset = x.gather(dim=-1, index=index).contiguous().view(b, c, h, w, N)
return x_offset
@staticmethod
def _reshape_x_offset(x_offset, ks):
b, c, h, w, N = x_offset.size()
x_offset = torch.cat([x_offset[..., s:s + ks].contiguous().view(b, c, h, w * ks) for s in range(0, N, ks)],
dim=-1)
x_offset = x_offset.contiguous().view(b, c, h * ks, w * ks)
return x_offset
# 前向传播函数
def forward(self, x):
# 获得输入特征图x的偏移量
# 假设输入特征图shape是[1,3,32,32],然后卷积核是3x3,
# 输出通道数为32,那么offset的shape是[1,2*3*3,32]
offset = self.p_conv(x)
# 如果是DCNv2那么还需要获得输入特征图x偏移特征图的权重项
# 假设输入特征图shape是[1,3,32,32],然后卷积核是3x3,
# 输出通道数为32,那么offset的权重shape是[1,3*3,32]
if self.modulation:
m = torch.sigmoid(self.m_conv(x))
# 这个sigmoid用的很酷,可以增加非线性
# dtype = torch.float32
dtype = offset.data.type()
# 卷积核尺寸大小
ks = self.kernel_size
# N=2*3*3/2=3*3=9
N = offset.size(1) // 2
# 如果需要Padding就先Padding
if self.padding:
x = self.zero_padding(x)
# self.zero_padding实际上就是nn.ZeroPad2d()函数
# p的shape为[1, 2N, H, W]
# 这个函数用来获取所有的卷积核偏移之后相对于原始特征图x的坐标(现在是浮点数)
p = self._get_p(offset, dtype)
# 我们学习出的量是float类型的,而像素坐标都是整数类型的,
# 所以我们还要用双线性插值的方法去推算相应的值
# 维度转换,现在p的维度为[B, 2N, H, W]
p = p.contiguous().permute(0, 2, 3, 1)
# 转换之后为[B, H, W, 2N]
# l:left, r:right; t:top, b:bottom
# floor是向下取整
q_lt = p.detach().floor()
# +1相当于原始坐标向上取整
q_rb = q_lt + 1
# 将q_lt即左上角坐标的值限制在图像范围内,torch.clamp用来限定张量值的范围
q_lt = torch.cat([torch.clamp(q_lt[..., :N], 0, x.size(2) - 1), torch.clamp(q_lt[..., N:], 0, x.size(3) - 1)],
dim=-1).long()
# 将q_rb即右下角坐标的值限制在图像范围内
q_rb = torch.cat([torch.clamp(q_rb[..., :N], 0, x.size(2) - 1), torch.clamp(q_rb[..., N:], 0, x.size(3) - 1)],
dim=-1).long()
# 用q_lt的前半部分坐标q_lt_x和q_rb的后半部分q_rb_y组合成q_lb
q_lb = torch.cat([q_lt[..., :N], q_rb[..., N:]], dim=-1)
# dim=-1即按照维度N进行concat
# 同理
q_rt = torch.cat([q_rb[..., :N], q_lt[..., N:]], dim=-1)
# 将p的坐标也要限制在图像范围内
p = torch.cat([torch.clamp(p[..., :N], 0, x.size(2) - 1), torch.clamp(p[..., N:], 0, x.size(3) - 1)], dim=-1)
# bilinear kernel (b, h, w, N)
# 双线性插值的4个系数
g_lt = (1 + (q_lt[..., :N].type_as(p) - p[..., :N])) * (1 + (q_lt[..., N:].type_as(p) - p[..., N:]))
g_rb = (1 - (q_rb[..., :N].type_as(p) - p[..., :N])) * (1 - (q_rb[..., N:].type_as(p) - p[..., N:]))
g_lb = (1 + (q_lb[..., :N].type_as(p) - p[..., :N])) * (1 - (q_lb[..., N:].type_as(p) - p[..., N:]))
g_rt = (1 - (q_rt[..., :N].type_as(p) - p[..., :N])) * (1 + (q_rt[..., N:].type_as(p) - p[..., N:]))
# (b, c, h, w, N)
# 现在只获取了坐标值,我们最终目的是获取相应坐标上的值,
# 这里我们通过self._get_x_q()获取相应值。
# x:
# q_lt:[B, H, W, 2N]
x_q_lt = self._get_x_q(x, q_lt, N)
x_q_rb = self._get_x_q(x, q_rb, N)
x_q_lb = self._get_x_q(x, q_lb, N)
x_q_rt = self._get_x_q(x, q_rt, N)
# (b, c, h, w, N)
# 双线性插值计算
x_offset = g_lt.unsqueeze(dim=1) * x_q_lt + \
g_rb.unsqueeze(dim=1) * x_q_rb + \
g_lb.unsqueeze(dim=1) * x_q_lb + \
g_rt.unsqueeze(dim=1) * x_q_rt
# modulation
if self.modulation:
m = m.contiguous().permute(0, 2, 3, 1)
m = m.unsqueeze(dim=1)
m = torch.cat([m for _ in range(x_offset.size(1))], dim=1)
x_offset *= m
# 在获取所有值后我们计算出x_offset,但是x_offset的size
# 是(b,c,h,w,N),我们的目的是将最终的输出结果的size变
# 成和x一致即(b,c,h,w),所以在最后用了一个reshape的操作。
# 这里ks=3
x_offset = self._reshape_x_offset(x_offset, ks)
out = self.conv(x_offset)
return out