【深度学习】可变形卷积V1和V2

参考链接:

论文及代码详解——可变形卷积(DCNv1)

DCN V1代码阅读笔记

再思考可变形卷积

可变形卷积:DCNv1and DCNv2

论文:《Deformable Convolutional Networks》

DCN V1

原理

可变形卷积顾名思义就是卷积的位置是可变形的,并非在传统的N × N的网格上做卷积,这样的好处就是更准确地提取到我们想要的特征(传统的卷积仅仅只能提取到矩形框的特征
DCN v1的核心思想在于它认为卷积核不应该是一个简简单单的矩形
在不同的阶段,不同的特征图,甚至不同的像素点上都可能有其最优的卷积核结构。
因此DCN v1提出在方形卷积核上的每个点学习一个偏移(offset),卷积核可以根据不同的数据学习不同的卷积核结构,如图1所示。

图1:可变形卷积核。(a)是标准的3×3卷积。(b),( c),(d)是给普通卷积加上偏移之后形成的可变形的卷积核,其中蓝色的是新的卷积点,箭头是位移方向。

可变形卷积的结构可以分为上下两个部分:

  • 上面那部分是基于输入的特征图生成x,y方向的offset
  • 下面那部分是基于特征图和offset通过可变形卷积获得输出特征图

假设输入的特征图宽高分别为w,h,下面那部分的卷积核尺寸是k_hk_w,那么上面那部分卷积层的卷积核数量应该是2\times k_h\times k_w,其中2代表x,y两个方向的offset。

并且,这里输出特征图的维度和输入特征图的维度一样,那么offset的维度就是[batch,2\times k_h\times k_w,h,w],假设下面那部分设置了group参数(代码实现中默认为4),那么第一部分的卷积核数量就是2\times k_h\times k_w\times group,即每一个group共用一套offset。下面的可变形卷积可以看作先基于上面那部分生成的offset做了一个插值操作,然后再执行普通的卷积。
 

数学表达

普通卷积的数学表达

普通的二维卷积包括两个步骤:
1)在输入特征图x上使用regular gird R进行采样;
2)以w加权的采样值的总和。网格R定义接收域的大小和扩张。例如,

                                        \mathcal{R} =\{(-1,-1),(-1,0),\dots ,(0,1),(1,1)\}

定义了一个dilation=1, 3x3的卷积。
对于输出特征图y上的每个位置p_0
Eq(1)

                                        \mathbf{y}\left(\mathbf{p}_{0}\right)=\sum_{\mathbf{p}_{n} \in \mathcal{R}} \mathbf{w}\left(\mathbf{p}_{n}\right) \cdot \mathbf{x}\left(\mathbf{p}_{0}+\mathbf{p}_{n}\right),

其中p_n穷举了\mathcal{R}中的所有位置。

可变形卷积的数学表达

在可变形卷积中,regular grid R用偏移量\{\bigtriangleup p_n|n=1,\dots ,N\}进行增广, 其中N=|\mathcal{R} |

上述的式子就变成了:

Eq(2)

                                ​​​​​​​        \mathbf{y}\left(\mathbf{p}_{0}\right)=\sum_{\mathbf{p}_{n} \in \mathcal{R}} \mathbf{w}\left(\mathbf{p}_{n}\right) \cdot \mathbf{x}\left(\mathbf{p}_{0}+\mathbf{p}_{n}+\Delta \mathbf{p}_{n}\right)

现在,采样是在不规则和偏移位置\mathbf{p}_{n}+\Delta \mathbf{p}_{n}上。由于偏移量\Delta \mathbf{p}_{n}通常为分数阶,因此通过双线性插值实现式(3)为:

Eq(3)

        ​​​​​​​        ​​​​​​​        ​​​​​​​        ​​​​​​​        ​​​​​​​        x(p)=\sum_{q}G(q,p)\cdot x(q)

其中p表示任意(分数)位置(对于Eq. (2) \mathbf{p}=\mathbf{p}_{0}+\mathbf{p}_{n}+\Delta \mathbf{p}_{n}),q枚举特征映射x中所有积分空间位置,G(\cdot ,\cdot )为双线性插值核。注意G是二维的。它被分成两个一维的核:

Eq(4)

        ​​​​​​​        ​​​​​​​        ​​​​​​​        ​​​​​​​        ​​​​​​​        G(q,p)=g(q_x,p_x)\cdot g(q_y,p_y)

其中g(a,b)=max(0,1-|a-b|)。Eq.(3)的计算速度很快,因为G(p,q)仅在几个q中是非零的。

如图2所示,偏移量是通过在相同的输入特征图上应用卷积层获得的。

具体流程
  • 原始图片数据(维度是b\times c\times h\times w),记为U(图2中的最左边的input feature map)。经过一个普通卷积,填充方式为same,对应的输出结果维度是(b\times h\times w\times 2N),记作V。V是原始图像数据中每个像素的偏移量(因为有xy两个方向,所以是2N)。(N是指卷积核的大小,如果是3x3的卷积核,则N=9 )

  • 将U中图片的像素索引值(即坐标)与V(坐标偏移量)相加,得到偏移后的position(即在原始图片U中的坐标值),需要将position值限定为图片大小以内。position的大小为(b\times h\times w\times 2N),但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

具体做法:

\triangle p_k 和\triangle m_k都是通过在相同的输入feature map 上应用的单独卷积层获得的。 该卷积层具有与当前卷积层相同的空间分辨率。 输出为3K通道,其中前2K通道对应于学习的偏移,剩余的K通道进一步馈送到sigmoid层以获得调制量

对于deformable RoIPooling,DCNV2的修改类似:

 

\triangle p_k 和\triangle m_k 的值由输入特征图上的分支产生。 在这个分支中,RoIpooling在RoI上生成特征,然后是两个1024-D的fc层。 额外的fc层产生3K通道的输出(权重被初始化为零)。 前2K通道是可学习偏移\triangle p_k, 剩余的K个通道由sigmoid层标准化以产生\triangle m_k

知识蒸馏

代码实现​​​​​​​

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
### 可变形卷积及其应用 #### 什么是可变形卷积可变形卷积是一种改进的传统卷积操作,通过引入可学习的偏移量来增强模型对几何变换的适应能力[^2]。这种机制使得网络能够在处理不规则形状的对象或特征时更加灵活。 #### 可变形卷积的工作原理 传统的卷积操作假设输入数据的空间布局是固定的,即滤波器在固定的位置上滑动。然而,在实际场景中,目标可能由于视角、姿态或其他因素的变化而不遵循这一假设。为解决此问题,Dai等人提出的可变形卷积通过学习额外的偏移向量,使滤波器可以在动态位置上采样,从而提高对复杂几何结构的学习能力[^3]。 #### 应用领域 可变形卷积被广泛应用于多种计算机视觉任务中,包括但不限于: - **视频对象检测**:通过对齐不同时间步上的特征图,提升检测精度。 - **动作识别**:捕捉复杂的时空模式。 - **语义分割**:处理边界模糊的目标区域。 - **视频超分辨率**:无需显式的运动估计即可完成帧间对齐。 #### D3DNet 的实现思路 为了整合视频中的时空信息,研究者提出了一种基于可变形3D卷积(Deformable 3D Convolution, D3D)的方法——D3DNet。这种方法的核心在于利用可变形卷积捕获更丰富的时空上下文关系[^1]。具体而言,D3DConv不仅考虑了空间维度的信息,还加入了时间维度的建模能力,使其更适合于视频分析任务。 #### InternImage 中的大规模扩展策略 在构建大规模参数的基础模型方面,InternImage 提供了一个成功的案例。该框架重新设计了DCNv2版本的可变形卷积算子,旨在适配长距离依赖并减少归纳偏差的影响。此外,它还将这些优化后的组件与其他先进的技术结合起来形成基础单元模块,并进一步探讨如何有效堆叠放大这些模块以支持更大规模的数据训练过程[^4]。 #### 多图片架构下的重建方案 针对特定应用场景如视频隔行消除与去马赛克等问题,有学者开发出了全新的多图片架构。这个系统结合了平行工作的可变形卷积层以及高效的Top-K 自注意力机制(KSA),用来同时获取局部及时空范围内的关联特性。最终借助专门定制好的重建区块来进行不同类型丢失资料估算作业[^5]。 ```python import torch.nn as nn class DeformableConv2d(nn.Module): def __init__(self, inc, outc, kernel_size=3, padding=1, stride=1, bias=None): super(DeformableConv2d,self).__init__() self.kernel_size = (kernel_size,kernel_size) self.padding = padding # Offset generation network self.offset_conv = nn.Conv2d( inc,out_channels=2 * kernel_size * kernel_size, kernel_size=self.kernel_size,stride=stride,padding=self.padding,bias=bias) # Main deformable convolution layer self.deform_conv = nn.Conv2d( inc,outc,kernel_size=(kernel_size,kernel_size), stride=stride,padding=self.padding,bias=bias) def forward(self,x): offset = self.offset_conv(x) output = self.deform_conv(input=x,offset=offset) return output ``` 上述代码片段展示了一个简单的二维可变形卷积层定义方式。其中`offset_conv`负责计算每个像素点处应该移动多少单位长度;而真正的核心部分则是由`deform_conv`执行带位移补偿的标准卷积运算。 ---
评论 10
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值