参考链接:
DCN V1
原理
可变形卷积顾名思义就是卷积的位置是可变形的,并非在传统的N × N的网格上做卷积,这样的好处就是更准确地提取到我们想要的特征(传统的卷积仅仅只能提取到矩形框的特征)
DCN v1的核心思想在于它认为卷积核不应该是一个简简单单的矩形
在不同的阶段,不同的特征图,甚至不同的像素点上都可能有其最优的卷积核结构。
因此DCN v1提出在方形卷积核上的每个点学习一个偏移(offset),卷积核可以根据不同的数据学习不同的卷积核结构,如图1所示。
图1:可变形卷积核。(a)是标准的3×3卷积。(b),( c),(d)是给普通卷积加上偏移之后形成的可变形的卷积核,其中蓝色的是新的卷积点,箭头是位移方向。
可变形卷积的结构可以分为上下两个部分:
- 上面那部分是基于输入的特征图生成x,y方向的offset
- 下面那部分是基于特征图和offset通过可变形卷积获得输出特征图
假设输入的特征图宽高分别为
,下面那部分的卷积核尺寸是
和
,那么上面那部分卷积层的卷积核数量应该是
,其中2代表x,y两个方向的offset。
并且,这里输出特征图的维度和输入特征图的维度一样,那么offset的维度就是
,假设下面那部分设置了group参数(代码实现中默认为4),那么第一部分的卷积核数量就是
,即每一个group共用一套offset。下面的可变形卷积可以看作先基于上面那部分生成的offset做了一个插值操作,然后再执行普通的卷积。
数学表达
普通卷积的数学表达
普通的二维卷积包括两个步骤:
1)在输入特征图x上使用regular gird R进行采样;
2)以w加权的采样值的总和。网格R定义接收域的大小和扩张。例如,
定义了一个dilation=1, 3x3的卷积。
对于输出特征图上的每个位置
,
Eq(1)
,
其中穷举了
中的所有位置。
可变形卷积的数学表达
在可变形卷积中,regular grid R用偏移量进行增广, 其中
上述的式子就变成了:
Eq(2)
现在,采样是在不规则和偏移位置上。由于偏移量
通常为分数阶,因此通过双线性插值实现式(3)为:
Eq(3)
其中表示任意(分数)位置(对于Eq. (2)
),
枚举特征映射x中所有积分空间位置,
为双线性插值核。注意
是二维的。它被分成两个一维的核:
Eq(4)
其中。Eq.(3)的计算速度很快,因为
仅在几个q中是非零的。
如图2所示,偏移量是通过在相同的输入特征图上应用卷积层获得的。
具体流程
-
原始图片数据(维度是
),记为U(图2中的最左边的input feature map)。经过一个普通卷积,填充方式为same,对应的输出结果维度是
,记作V。V是原始图像数据中每个像素的偏移量(因为有
和
两个方向,所以是
)。(
是指卷积核的大小,如果是3x3的卷积核,则N=9 )
-
将U中图片的像素索引值(即坐标)与V(坐标偏移量)相加,得到偏移后的position(即在原始图片U中的坐标值),需要将position值限定为图片大小以内。position的大小为
,但position只是一个坐标值,而且还是float类型的,我们需要这些float类型的坐标值获取像素。
-
举个例子,如上图所示,只有蓝色的和红色的点存在一个像素值(它们的横纵坐标值均为整数)
假设黄色的点是我们偏移后的position, 它的坐标值设为(a,b), 其中a,b均是浮点数,此时黄色的点的位置是不存在像素值的。
那么怎样才可以得到黄色点对应的像素值呢?我们利用黄色点周围的4个像素点(红色的点)进行双线性插值。 红色点对应的坐标是坐标(floor(a),floor(b)), ((floor(a),ceil(b)), ((ceil(a),floor(b)), ((ceil(a),ceil(b));这四对坐标每个坐标都对应U中的一个像素值, 而我们需要得到(a,b)的像素值, 这里采用双线性差值的方式计算(一方面是因为获得的像素更精确,另外一方面是因为可以进行反向传播)。双线性插值参考:《超分任务中常见的上采样方式》 -
在得到position的所有像素后,即得到了一个新图片M,将这个新图片M作为输入数据输入到别的层中,如普通卷积。
小结
DCN的卷积过程和普通卷积一样,如上图所示,假设有个2x2的kernel, 它也是以一个2x2的滑窗的形式(绿色的框)在原始图片上从左到右,从上到下进行滑动 。
和普通的卷积的区别在于,当滑动到当前位置时,普通卷积会将卷积核(绿点)和原始像素(红点)对应的值相乘后相加,得到输出的值。
可变形卷积是将卷积核(绿点)和采样点(彩色圆圈)对应的值相乘后相加。其中采样点的像素值就是通过上一步的双线性插值得到的。
思考
为什么叫做可变形卷积呢
可变形卷积就是给每个卷积核添加一个方向向量,使得卷积核可以自适应的变成任意的形状,因此也叫做”可变形卷积。
可变形卷积有什么用呢?
同一层的CNN的激活单元的感受野尺度相同,传统的方法的感受野位置受限。
不同的位置对应着不同尺度和形变的物体,卷积层需要能够自动的调整尺度和感受野,更好的提取输入的特征
最上层是在大小不同的两个物体上的一个激活单元,中间层和底层是为了得到上一层激活单元的采样过程。可以看到可变形卷积在采样时可以更贴近物体的形状和尺寸。
代码实现
可变形卷积的实现如下:
class DeformConv2D(nn.Module):
def __init__(self, inc, outc, kernel_size=3, padding=1, bias=None):
super(DeformConv2D, self).__init__()
self.kernel_size = kernel_size # 卷积核的大小
self.padding = padding # 填充大小
self.zero_padding = nn.ZeroPad2d(padding) # 用0去填充
self.conv_kernel = nn.Conv2d(inc, outc, kernel_size=kernel_size, stride=kernel_size, bias=bias) # 二维卷积
def forward(self, x, offset):
'''
x: 原始输入 [b,c,h,w]
offset: 每个像素点的偏移 [b,2*N,h,w]
N:kernel中元素的个数 = k*k
offset 和 x的宽高相同,表示的是对应位置像素点的偏移
offset 的第二个维度大小是2N, 当N=9时,排列顺序是(x1, y1, x2, y2, ...x18,y18)
xi表示一个大小为[h,w]的张量,表示对于原始图像中的每个点,对于kernel中的第i个元素,在x轴方向的偏移量
yi表示一个大小为[h,w]的张量,表示对于原始图像中的每个点,对于kernel中的第i个元素,在y轴方向的偏移量
'''
dtype = offset.data.type() #获取偏移 offset 张量的数据类型,通常会与输入 x 的数据类型相匹配
ks = self.kernel_size
N = offset.size(1) // 2
'''
下面这段代码的整体功能:将offset中第二维度的顺序从[x1, y1, x2, y2, ...] 变成[x1, x2, .... y1, y2, ...]
'''
# 创建一个索引张量 offsets_index,用于重新排列 offset 张量中的偏移项的顺序,将 x 和 y 分量分开排列
offsets_index = torch.Tensor(torch.cat([torch.arange(0, 2 * N, 2), torch.arange(1, 2 * N + 1, 2)]),
requires_grad=False).type_as(x).long()
# torch.arange(0, 2*N, 2):这部分代码创建了一个从 0 到 2*N-1 的整数序列,步长为 2。这个序列包含了偏移项的 x 分量的索引,因为在 offset 张量中,x 和 y 分量是交替存储的。所以这部分代码创建了一个形如 [0, 2, 4, ...] 的整数序列。
# torch.arange(1, 2*N+1, 2):这部分代码创建了一个从 1 到 2*N 的整数序列,步长为 2。这个序列包含了偏移项的 y 分量的索引,也是交替存储的。所以这部分代码创建了一个形如 [1, 3, 5, ...] 的整数序列。
# torch.cat([..., ...]):torch.cat 函数用于将两个张量连接在一起,这里将上面两个整数序列连接起来,得到一个包含 x 和 y 分量索引的整数序列。结果形如 [0, 2, 4, ..., 1, 3, 5, ...]。
# Variable(...):将上述整数序列转换为 PyTorch 的 Variable 对象,这是为了能够在 PyTorch 中进行计算。requires_grad=False 表示这个 Variable 对象不需要计算梯度。
# .type_as(x):将数据类型设置为与输入张量 x 相同的数据类型,以确保数据类型一致。
# .long():将整数类型转换为长整数类型,以适应后续的索引操作。
# 最终,offsets_index 是一个包含了偏移项 x 和 y 分量的索引的张量,它的大小为 [1, 2*N, 1, 1],其中 N 表示偏移项的数量,通常是卷积核的大小。这个索引张量将在后续代码中用于重新排列 offset 张量的顺序,以方便后续计算。
# 当b=1,N=9时,offsets_index=[ 0, 2, 4, 6, 8, 10, 12, 14, 16, 1, 3, 5, 7, 9, 11, 13, 15, 17]
# offsets_index的大小为[18]
offsets_index = offsets_index.unsqueeze(dim=0).unsqueeze(dim=-1).unsqueeze(dim=-1).expand(*offset.size()) # 将 offsets_index 调整为与 offset 张量相同的形状
# offsets_index.unsqueeze(dim=0):这一步在 offsets_index 上应用 unsqueeze 操作,将维度 0 扩展(增加)一次。这使得 offsets_index 从原来的形状 (18,) 变为 (1, 18),其中 1 是新的维度。
# offsets_index.unsqueeze(dim=-1):接下来,在 offsets_index 上再次应用 unsqueeze 操作,但这次是在最后一个维度上扩展。这将使 offsets_index 的形状从 (1, 18) 变为 (1, 18, 1)。
# offsets_index.unsqueeze(dim=-1).expand(*offset.size()):最后,使用 expand 函数将 offsets_index 扩展到与 offset 相同的形状。这通过在 offsets_index 上进行广播操作,使其形状变为 (batch_size, 18, height, width),其中 batch_size 是输入 offset 张量的批处理大小,而 18 是因为偏移项有 18 个元
# 然后unsqueeze扩展维度,offsets_index大小为[1,18,1,1]
# expand后,offsets_index的大小为[1,18,h,w]
offset = torch.gather(offset, dim=1, index=offsets_index) # 重新排列 offset 张量的维度顺序,将偏移项的 x 和 y 分量排列在一起,而不是交替排列
# offset: 原始的偏移张量,其形状为 [batch_size, 2*N, height, width],其中 N 是偏移项的数量,每个偏移项包括 x 和 y 两个分量。
# dim=1: 这是 torch.gather 函数中的维度参数,表示在哪个维度上进行索引和收集操作。在这里,dim=1 表示我们要在 offset 的第二维度(从0开始计数)上进行索引和收集操作。
# index=offsets_index: 这是用于索引的索引张量,它告诉 torch.gather 函数应该如何重新排列原始的 offset 张量。offsets_index 的形状为 [1, 18, height, width],其中每个元素是一个整数索引,用于指定如何重新排列原始偏移项的顺序。这个索引张量的值控制了 x 和 y 分量的排列顺序,使它们排列在一起
# 根据维度dim按照索引列表index将offset重新排序,得到[x1, x2, .... y1, y2, ...]这样顺序的offset
# ------------------------------------------------------------------------
# 对输入x进行padding
if self.padding:
x = self.zero_padding(x)
# p表示求偏置后,每个点的位置
p = self._get_p(offset, dtype) # (b, 2N, h, w)
# p.contiguous(): 这一步是为了确保张量 p 在内存中是连续的。PyTorch 中的张量可以以不同的存储方式存在,有些情况下可能不是连续的。这一步会重新排列存储顺序,以确保数据是按顺序排列的,这在后续操作中往往是必要的
p = p.contiguous().permute(0, 2, 3, 1) # (b,h,w,2N)
q_lt = torch.Tensor(p.data, requires_grad=False).floor() # floor是向下取整 因为在程序中(0,0)点在左上角
q_rb = q_lt + 1 # 上取整
# +1相当于向上取整,这里为什么不用向上取整函数呢?是因为如果正好是整数的话,向上取整跟向下取整就重合了,这是我们不想看到的。
# q_lt[..., :N]代表x方向坐标,大小[b,h,w,N], clamp将值限制在0~h-1
# q_lt[..., N:]代表y方向坐标, 大小[b,h,w,N], clamp将值限制在0~w-1
# cat后,还原成原大小[b,h,w,2N]
# 确保左上角点q_lt和右下q_rb的 x 和 y 坐标不超出输入图像的范围
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_lt中的值控制在图像大小范围内 [b,h,w,2N]
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_rt中的值控制在图像大小范围内 [b,h,w,2N]
'''
获取采样后的点周围4个方向的像素点
q_lt: left_top 左上
q_rb: right_below 右下
q_lb: left_below 左下
q_rt: right_top 右上
'''
# 获得lb 左上角x坐标与右下角y坐标拼接
q_lb = torch.cat([q_lt[..., :N], q_rb[..., N:]], -1) # [b,h,w,2N]
# 获得rt
q_rt = torch.cat([q_rb[..., :N], q_lt[..., N:]], -1) # [b,h,w,2N]
'''
插值的时候需要考虑一下padding对原始索引的影响
p[..., :N] 采样点在x方向(h)的位置 大小[b,h,w,N]
p[..., :N].lt(self.padding) : p[..., :N]中小于padding 的元素,对应的mask为true
p[..., :N].gt(x.size(2)-1-self.padding): p[..., :N]中大于h-1-padding 的元素,对应的mask为true
图像的宽(或高)度我们假设为W,填充值我们设为pad,填充后图像的实际宽度为 W+2*pad。因此小于pad大于填充后图像的实际宽度-pad-1的就是在原始图像外的东西
如图像宽度W=5,填充pad=1,那么填充后图像宽度为5+2*1=7,原图像点索引范围是1-5;当索引大于7-1-1=5时,就超出了原图像边界
p[..., N:] 采样点在y方向(w)的位置 大小[b,h,w,N]
p[..., N:].lt(self.padding) : p[..., N:]中小于padding 的元素,对应的mask为true
p[..., N:].gt(x.size(2)-1-self.padding): p[..., N:]中大于w-1-padding 的元素,对应的mask为true
cat之后,大小为[b,h,w,2N]
'''
mask = torch.cat([p[..., :N].lt(self.padding) + p[..., :N].gt(x.size(2) - 1 - self.padding),
p[..., N:].lt(self.padding) + p[..., N:].gt(x.size(3) - 1 - self.padding)], dim=-1).type_as(p) #
# mask不需要反向传播
mask = mask.detach()
# p - (p - torch.floor(p))相当于torch.floor(p)
floor_p = p - (p - torch.floor(p))
'''
mask为1的区域就是padding的区域
p*(1-mask) : mask为0的 非padding区域的p被保留
floor_p*mask: mask为1的 padding区域的floor_p被保留
可变形卷积引入了一个新的因素,即采样点的偏移。偏移后的采样点可能会落在填充区域内,这时应该如何处理这些点的位置信息呢?
对于非填充区域的采样点,我们希望保持原有的位置信息,因为这些点是图像中的有效信息。
对于填充区域的采样点,由于它们落在填充区域内,直接使用原始位置信息可能不合适,因为这些点在填充区域可能没有意义。此时,取整后的位置信息更符合填充区域的特性,因为它将采样点约束在填充区域内的整数坐标上,以适应卷积操作。
'''
p = p * (1 - mask) + floor_p * mask
# 修正坐标信息 p,以确保采样位置不会超出输入图像的边界
p = torch.cat([torch.clamp(p[..., :N], 0, x.size(2) - 1), torch.clamp(p[..., N:], 0, x.size(3) - 1)], dim=-1)
# 双线性插值的系数 大小均为 (b, h, w, N)
g_lt = (1 + (q_lt[..., :N].type_as(p) - p[..., :N])) * (1 + (q_lt[..., N:].type_as(p) - p[..., N:]))
# (1+左上角的点x - 原始采样点x)*(1+左上角的点y - 原始采样点y) 代表左上角的权重
g_rb = (1 - (q_rb[..., :N].type_as(p) - p[..., :N])) * (1 - (q_rb[..., N:].type_as(p) - p[..., N:]))
# (1-(右下角的点x - 原始采样点x))*(1-(右下角的点y - 原始采样点y)) 代表右下角的权重
g_lb = (1 + (q_lb[..., :N].type_as(p) - p[..., :N])) * (1 - (q_lb[..., N:].type_as(p) - p[..., N:]))
# (1+左下角的点x - 原始采样点x)*(1+左上角的点y - 原始采样点y) 代表左下角的权重
g_rt = (1 - (q_rt[..., :N].type_as(p) - p[..., :N])) * (1 + (q_rt[..., N:].type_as(p) - p[..., N:]))
# (1-(右上角的点x - 原始采样点x))*(1-(右上角的点y - 原始采样点y)) 代表右上角的权重
# (b, c, h, w, N)
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) # 右上角的点在原始图片中对应的真实像素值
# 双线性插值算法
# x_offset : 偏移后的点再双线性插值后的值 大小(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
'''
偏置点含有九个方向的偏置,_reshape_x_offset() 把每个点9个方向的偏置转化成 3×3 的形式,
于是就可以用 3×3 stride=3 的卷积核进行 Deformable Convolution,
它等价于使用 1×1 的正常卷积核(包含了这个点9个方向的 context)对原特征直接进行卷积。
'''
x_offset = self._reshape_x_offset(x_offset, ks) # (b,c,h*ks,w*ks)
out = self.conv_kernel(x_offset)
return out
# 功能:求每个点的偏置方向.在可变形卷积中,每个像素点需要学习的是对应卷积核的若干个方向的偏置,这些偏置方向在 _get_p_n 方法中生成
def _get_p_n(self, N, dtype):
# N=kernel_size*kernel_size
# 生成了 p_n_x 和 p_n_y,它们表示了一个二维网格的 x 和 y 坐标值。这个网格的大小是 (kernel_size, kernel_size),并且它的中心点是 (0, 0)。meshgrid 函数用于生成这个坐标网格
p_n_x, p_n_y = np.meshgrid(range(-(self.kernel_size - 1) // 2, (self.kernel_size - 1) // 2 + 1),
range(-(self.kernel_size - 1) // 2, (self.kernel_size - 1) // 2 + 1), indexing='ij')
# (2N, 1)
# 通过 np.concatenate 将 p_n_x 和 p_n_y 沿着指定的轴(默认为第0轴,即按行连接)连接在一起,形成一个大小为 (2N, 1) 的一维数组 p_n。这个一维数组包含了所有方向的偏置
p_n = np.concatenate((p_n_x.flatten(), p_n_y.flatten()))
# 使用 np.reshape 将 p_n 重新形状为 (1, 2*N, 1, 1),使其符合 PyTorch 张量的形状要求。这是一个四维张量,第一个维度为 1,表示 batch size,第二个维度为 2*N,表示偏置方向的个数,而后两个维度为 1,表示空间维度
p_n = np.reshape(p_n, (1, 2 * N, 1, 1))
# 将 p_n 转换为 PyTorch 张量,并使用 Variable 包装它。dtype 参数用于指定张量的数据类型,而 requires_grad 设置为 False 表示这个张量不需要梯度计算
p_n = torch.Tensor(torch.from_numpy(p_n).type(dtype), requires_grad=False)
return p_n # [1,2*N,1,1]
@staticmethod
# 功能:求每个点的坐标
def _get_p_0(h, w, N, dtype):
# 通过 np.meshgrid 创建两个网格,其中一个包含从 1 到 h 的整数,另一个包含从 1 到 w 的整数。其中 p_0_x 包含了高度方向上的坐标,p_0_y 包含了宽度方向上的坐标。这两个变量形成了图像上每个像素点的 x 和 y 坐标信息
p_0_x, p_0_y = np.meshgrid(range(1, h + 1), range(1, w + 1), indexing='ij')
# 这两行将坐标信息展平,并在相应的维度上重复 N 次,以便与偏移的维度匹配
p_0_x = p_0_x.flatten().reshape(1, 1, h, w).repeat(N, axis=1) # (1,N,h,w)
p_0_y = p_0_y.flatten().reshape(1, 1, h, w).repeat(N, axis=1) # (1,N,h,w)
# 将 p_0_x 和 p_0_y 沿着第一个维度(axis=1,即通道维度)拼接在一起,得到形状为 (1, 2*N, h, w) 的张量 p_0
p_0 = np.concatenate((p_0_x, p_0_y), axis=1)
# 将 p_0 转换为 PyTorch 的张量,并设置其数据类型为 dtype。使用 Variable 包装这个张量,以便在 PyTorch 中使用它,并设置 requires_grad 为 False,表示不需要计算梯度
p_0 = torch.Tensor(torch.from_numpy(p_0).type(dtype), requires_grad=False)
return p_0 # (1,2*N,h,w)
# 求最后的偏置后的点=每个点的坐标+偏置方向+偏置
def _get_p(self, offset, dtype):
'''
offset: 每个像素点的偏移 [b,2*N,h,w]
N:kernel中元素的个数 = k*k
offset 和 x的宽高相同,表示的是对应位置像素点的偏移
offset 的第二个维度大小是2N, 当N=9时,排列顺序是(x1, y1, x2, y2, ...x18,y18)
xi表示一个大小为[h,w]的张量,表示对于原始图像中的每个点,对于kernel中的第i个元素,在x轴方向的偏移量
yi表示一个大小为[h,w]的张量,表示对于原始图像中的每个点,对于kernel中的第i个元素,在y轴方向的偏移量
'''
N, h, w = offset.size(1) // 2, offset.size(2), offset.size(3)
p_n = self._get_p_n(N, dtype) # 偏置方向:(1, 2N, 1, 1)
p_0 = self._get_p_0(h, w, N, dtype) # 每个点的坐标:(1, 2N, h, w)
p = p_0 + p_n + offset # 最终点的位置
return p # (1,2N,h,w)
# 求出p点周围四个点的像素
# 获取偏移后的点在原始图像中对应的真实像素值,即根据偏移后的位置信息,获取原始图像中相应点的像素值
def _get_x_q(self, x, q, N):
# x:[b,c,h',w']
# q:[b,h,w,2N]
# q可能为q_lt,q_rt,q_lb,q_rb
b, h, w, _ = q.size()
padded_w = x.size(3) # w'
c = x.size(1)
x = x.contiguous().view(b, c, -1) # (b, c, h*w)
# 将图片压缩到1维,方便后面的按照index索引提取
# q[...,:N] (b,h,w,N) 原始图像中(h_i,w_j)的点在偏移后,向左上角取整对应的点,在N个区域中,x方向的偏移量
# q[...,N:] (b,h,w,N) 原始图像中(h_i,w_j)的点在偏移后,向左上角取整对应的点,在N个区域中,y方向的偏移量
# index: (b,h,w,N) 原始图像中(h_i,w_j)的点在偏移后,向左上角取整对应的点,在N个区域中,x*w + y
index = q[..., :N] * padded_w + q[..., N:] # 大小(b, h, w, N)
# 这个目的就是将index索引均匀扩增到图片一样的h*w大小
index = index.contiguous().unsqueeze(dim=1).expand(-1, c, -1, -1, -1).contiguous().view(b, c, -1)
'''
unsqueeze后 (b,1,h,w,N)
expand后 (b,c,h,w,N)
view后 (b, c, h*w*N) 其中每一个值对应一个index
'''
# 双线性插值法就是4个点再乘以对应与 p 点的距离。获得偏置点 p 的值,这个 p 点是 9 个方向的偏置所以最后的 x_offset 是 b×c×h×w×9。
x_offset = x.gather(dim=-1, index=index).contiguous().view(b, c, h, w, N)
# x :(b,c,h*w)
# gather后: (b,c,h*w*N)
# view后:(b,c,h,w,N)
return x_offset # (b,c,h,w,N) 左上角的点在原始图像中对应的像素值
# _reshape_x_offset() 把每个点9个方向的偏置转化成 3×3 的形式
# 将 x_offset 张量中的像素值重新排列,使每个像素点周围都包含了 ks*ks 个方向的像素值,以便进行可变形卷积操作
@staticmethod
def _reshape_x_offset(x_offset, ks):
# x_offset : (b, c, h, w, N)
# ks: kernel_size
# N=ks*ks
b, c, h, w, N = x_offset.size()
'''
当ks=3,N=9时:
s=0 [...,0:3] (b,c,h,w,3)->(b,c,h,w*3)
s=3 [...,3:6] (b,c,h,w,3)->(b,c,h,w*3)
s=6 [...,6:9] (b,c,h,w,3)->(b,c,h,w*3)
cat 后 (b,c,h,w*9)
view 后(b,c,h*3,w*3)
'''
# x_offset[..., s:s + ks] 表示在前面的维度(通常是前三维)保持不变的情况下,对最后一个维度进行切片操作
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) # (b,c,h*3,w*3)
return x_offset # (b,c,h*ks,w*ks)
DCN V2
DCNv2 是在DCNv1的基础上的改进版。
改进
DCNv2的改进主要包括如下三点
- 增加更多的可变形卷积层
- 除了让模型学习采样点的偏移,还要学习每个采样点的权重,这是对减轻无关因素干扰的最重要的工作
- 使用R-CNN对Faster R-CNN进行知识蒸馏
- 目前只能实现3*3大小的卷积
增加更多的可变形卷积层
在DCN v2将conv3到conv5 block的 3*3卷积全部替换为了可变形卷积,因此可变形卷积层数达到了 12个。这一操作在场景更复杂的COCO数据集有着比较明显的性能提升。
- DCNv1:ResNet-50 Conv5里边的3×3的卷积层都使用可变形卷积替换。Aligned RoI pooling 由 Deformable RoI Pooling取代。
- DCNv2:在Conv3, Conv4, Conv5中所有的3×3的卷积层全部被替换掉。
加权采样点偏移
- 在DCNV1里,Deformable Conv只学习offset:
- 在DCNV2里,加入了对每个采样点的权重:
为了解决引入了一些无关区域的问题,在DCNV2中我们不只添加每一个采样点的偏移,还添加了一个权重系数m,来区分我们引入的区域是否为我们感兴趣的区域,假如这个采样点的区域我们不感兴趣,则把权重学习为0
具体做法:
和
都是通过在相同的输入feature map 上应用的单独卷积层获得的。 该卷积层具有与当前卷积层相同的空间分辨率。 输出为3K通道,其中前2K通道对应于学习的偏移,剩余的K通道进一步馈送到sigmoid层以获得调制量。
对于deformable RoIPooling,DCNV2的修改类似:
和
的值由输入特征图上的分支产生。 在这个分支中,RoIpooling在RoI上生成特征,然后是两个1024-D的fc层。 额外的fc层产生3K通道的输出(权重被初始化为零)。 前2K通道是可学习偏移
, 剩余的K个通道由sigmoid层标准化以产生
。
知识蒸馏
代码实现
DCNv2的代码就是在DCNv1的基础上加了权重项sigmoid。
import torch
from torch import nn
class DeformConv2d(nn.Module):
# inc表示输入通道数
# outc 表示输出通道数
# kernel_size表示卷积核尺寸
# stride 卷积核滑动步长
# bias 偏置
# modulation DCNV1还是DCNV2的开关
def __init__(self, inc, outc, kernel_size=3, padding=1, stride=1, bias=None, modulation=False):
"""
Args:
modulation (bool, optional): If True, Modulated Defomable Convolution (Deformable ConvNets v2).
新增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)
# 获得偏移量,卷积核的通道数应该为2xkernel_sizexkernel_size
self.p_conv = nn.Conv2d(inc, 2*kernel_size*kernel_size, kernel_size=3, padding=1, stride=stride)
# 偏移量初始化为0
nn.init.constant_(self.p_conv.weight, 0)
# 注册module反向传播的hook函数, 可以查看当前层参数的梯度
self.p_conv.register_backward_hook(self._set_lr)
# 将modulation赋值给当前类
self.modulation = modulation
if modulation:
# 如果是DCN V2,还多了一个权重参数,用m_conv来表示
self.m_conv = nn.Conv2d(inc, kernel_size*kernel_size, kernel_size=3, padding=1, stride=stride) # 输出通道是N
nn.init.constant_(self.m_conv.weight, 0)
# 注册module反向传播的hook函数, 可以查看当前层参数的梯度
self.m_conv.register_backward_hook(self._set_lr)
# 静态方法 类或实例均可调用,这函数的结合hook可以输出你想要的Variable的梯度
@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 forward(self, x):
# 获得输入特征图x的偏移量
# 假设输入特征图shape是[1,3,32,32],然后卷积核是3x3,
# 输出通道数为32,那么offset的shape是[1,2*3*3,32]
offset = self.p_conv(x)
# 如果是DCN V2那么还需要获得输入特征图x偏移量的权重项
# 假设输入特征图shape是[1,3,32,32],然后卷积核是3x3,
# 输出通道数为32,那么offset的权重shape是[1,3*3,32]
if self.modulation:
m = torch.sigmoid(self.m_conv(x)) # (b,N,h,w) 学习到的N个调制标量
# 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)
# p的shape为(b, 2N, h, w)
# 这个函数用来获取所有的卷积核偏移之后相对于原始特征图x的坐标(现在是浮点数)
p = self._get_p(offset, dtype)
# 我们学习出的量是float类型的,而像素坐标都是整数类型的,
# 所以我们还要用双线性插值的方法去推算相应的值
# 维度转换,现在p的维度为(b, h, w, 2N)
p = p.contiguous().permute(0, 2, 3, 1)
# floor是向下取整
q_lt = p.detach().floor()
# +1相当于原始坐标向上取整
q_rb = q_lt + 1
# 将q_lt即左上角坐标的值限制在图像范围内
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)
# 同理
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 = 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
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))
# (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, h, w = offset.size(1)//2, offset.size(2), offset.size(3)
# (1, 2N, 1, 1)
p_n = self._get_p_n(N, dtype)
# (1, 2N, h, w)
p_0 = self._get_p_0(h, w, N, dtype)
p = p_0 + p_n + offset
return p
def _get_x_q(self, x, q, N):
b, h, w, _ = q.size()
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