NNDL 实验六 卷积神经网络(1)卷积

目录

卷积

二维卷积运算

 二维卷积算子

二维卷积的参数量和计算量

 感受野

卷积的变种

带步长和零填充的二维卷积算子

使用卷积运算完成图像边缘检测任务

选做题

心得体会

参考


卷积神经网络(Convolutional Neural Network,CNN)

  • 受生物学上感受野机制的启发而提出。
  • 一般是由卷积层、汇聚层和全连接层交叉堆叠而成的前馈神经网络
  • 有三个结构上的特性:局部连接、权重共享、汇聚。
  • 具有一定程度上的平移、缩放和旋转不变性。
  • 和前馈神经网络相比,卷积神经网络的参数更少。
  • 主要应用在图像和视频分析的任务上,其准确率一般也远远超出了其他的神经网络模型。
  • 近年来卷积神经网络也广泛地应用到自然语言处理、推荐系统等领域。

卷积

考虑到使用全连接前馈网络来处理图像时,会出现如下问题:

  1. 模型参数过多,容易发生过拟合。 在全连接前馈网络中,隐藏层的每个神经元都要跟该层所有输入的神经元相连接。随着隐藏层神经元数量的增多,参数的规模也会急剧增加,导致整个神经网络的训练效率非常低,也很容易发生过拟合。

  2. 难以提取图像中的局部不变性特征。 自然图像中的物体都具有局部不变性特征,比如尺度缩放、平移、旋转等操作不影响其语义信息。而全连接前馈网络很难提取这些局部不变性特征。

卷积神经网络有三个结构上的特性:局部连接、权重共享和汇聚。这些特性使得卷积神经网络具有一定程度上的平移、缩放和旋转不变性。和前馈神经网络相比,卷积神经网络的参数也更少。因此,通常会使用卷积神经网络来处理图像信息。

卷积是分析数学中的一种重要运算,常用于信号处理或图像处理任务。本节以二维卷积为例来进行实践。

二维卷积运算

卷积运算(Convolution)是通过两个函数 f 和 g 生成第三个函数的一种数学算子,表示函数 f 与经过翻转和平移与 g 的重叠部分的累积。如果将参加卷积的一个函数看作区间的指示函数,卷积还可以被看作是“滑动平均”的推广。
假设: f(x) , g(x) 是R1上的两个可积函数,并且积分是存在的。这样,随着 x 的不同取值,这个积分就定义了一个新函数 h(x) ,称为函数 f 与 g 的卷积,记为 h(x)=(f*g)(x)。

在机器学习和图像处理领域,卷积的主要功能是在一个图像(或特征图)上滑动一个卷积核,通过卷积操作得到一组新的特征。在计算卷积的过程中,需要进行卷积核的翻转,而这也会带来一些不必要的操作和开销。因此,在具体实现上,一般会以数学中的互相关(Cross-Correlatio)运算来代替卷积。
在神经网络中,卷积运算的主要作用是抽取特征,卷积核是否进行翻转并不会影响其特征抽取的能力。特别是当卷积核是可学习的参数时,卷积和互相关在能力上是等价的。因此,很多时候,为方便起见,会直接用互相关来代替卷积。 

 二维卷积算子

 根据此式使用pytorch实现一个简单的自定义算,代码如下:

import torch
import torch.nn as nn

class Conv2D(nn.Module):
    def __init__(self, weight_attr=torch.tensor([[0., 1.], [2., 3.]])):
        super(Conv2D, self).__init__()
        # 使用'torch.Parameter'进行参数初始化
        self.weight = torch.nn.Parameter(weight_attr)

    def forward(self, X):
        """
        输入:
            - X:输入矩阵,shape=[B, M, N],B为样本数量
        输出:
            - output:输出矩阵
        """
        u, v = self.weight.shape
        output = torch.zeros([X.shape[0], X.shape[1] - u + 1, X.shape[2] - v + 1])
        for i in range(output.shape[1]):
            for j in range(output.shape[2]):
                output[:, i, j] = torch.sum(X[:, i:i + u, j:j + v] * self.weight, dim=[1, 2])
        return output

# 随机构造一个二维输入矩阵
torch.random.manual_seed(100)
inputs = torch.tensor([[[1., 2., 3.], [4., 5., 6.], [7., 8., 9.]]])

conv2d = Conv2D()
outputs = conv2d(inputs)
print("input: {}, \noutput: {}".format(inputs, outputs))

运行结果:

input: tensor([[[1., 2., 3.],
         [4., 5., 6.],
         [7., 8., 9.]]]), 
output: tensor([[[25., 31.],
         [43., 49.]]], grad_fn=<CopySlices>)

二维卷积的参数量和计算量

参数量

由于二维卷积的运算方式为在一个图像(或特征图)上滑动一个卷积核,通过卷积操作得到一组新的特征。所以参数量仅仅与卷积核的尺寸有关,对于一个输入矩阵X\in \mathbb{R}^{M\times N}和一个滤波器W\in \mathbb{R}^{U\times V},卷积核的参数量为U×V。

假设有一幅大小为32×32的图像,如果使用全连接前馈网络进行处理,即便第一个隐藏层神经元个数为1,此时该层的参数量也高达1025个,此时该层的计算过程如图所示。

 

可以想像,随着隐藏层神经元数量的变多以及层数的加深,使用全连接前馈网络处理图像数据时,参数量会急剧增加。

如果使用卷积进行图像处理,当卷积核为3×3时,参数量仅为9,相较于全连接前馈网络,参数量少了非常多。

计算量

在卷积神经网络中运算时,通常会统计网络总的乘加运算次数作为计算量(FLOPs,floating point of operations),来衡量整个网络的运算速度。对于单个二维卷积,计算量的统计方式为:

 其中M′×N′表示输出特征图的尺寸,即输出特征图上每个点都要与卷积核W\in \mathbb{R}^{U\times V}进行U×V次乘加运算。对于一幅大小为32×32的图像,使用3×3的卷积核进行运算可以得到以下的输出特征图尺寸:

 

 此时的计算量为:

 

 感受野

 输出特征图上每个点的数值,是由输入图片上大小为U×V的区域的元素与卷积核每个元素相乘再相加得到的,所以输入图像上U×V区域内每个元素数值的改变,都会影响输出点的像素值。我们将这个区域叫做输出特征图上对应点的感受野。感受野内每个元素数值的变动,都会影响输出点的数值变化。比如3×3卷积对应的感受野大小就是3×3,如图所示。

 而当通过两层3×3的卷积之后,感受野的大小将会增加到5×5,如图所示。

因此,当增加卷积网络深度的同时,感受野将会增大,输出特征图中的一个像素点将会包含更多的图像语义信息。

卷积的变种

在卷积的标准定义基础上,还可以引入卷积核的滑动步长和零填充来增加卷积的多样性,从而更灵活地进行特征抽取。

步长(Stride)

在卷积运算的过程中,有时会希望跳过一些位置来降低计算的开销,也可以把这一过程看作是对标准卷积运算输出的下采样

在计算卷积时,可以在所有维度上每间隔S个元素计算一次,S称为卷积运算的步长(Stride),也就是卷积核在滑动时的间隔。

零填充(Zero Padding)

在卷积运算中,还可以对输入用零进行填充使得其尺寸变大。根据卷积的定义,如果不进行填充,当卷积核尺寸大于1时,输出特征会缩减。对输入进行零填充则可以对卷积核的宽度和输出的大小进行独立的控制。

在二维卷积运算中,零填充(Zero Padding)是指在输入矩阵周围对称地补上P个0。如下图为使用零填充的示例。
 

 

通常情况下,在层数较深的卷积神经网络,比如:VGG、ResNet中,会使用等宽卷积保证输出特征图的大小不会随着层数的变深而快速缩减。例如:当卷积核的大小为3×3时,会将步长设置为S=1,两端补零P=1,此时,卷积后的输出尺寸就可以保持不变。在本章后续的案例中,会使用ResNet进行实验。

带步长和零填充的二维卷积算子

引入步长和零填充后,二维卷积算子代码实现如下:

class Conv2D(nn.Module):
    def __init__(self, kernel_size, stride=1, padding=0, weight_attr=False):
        super(Conv2D, self).__init__()
        if type(weight_attr) == bool:
            weight_attr = torch.ones(size=(kernel_size, kernel_size))
        self.weight = torch.nn.Parameter(weight_attr)
        # 步长
        self.stride = stride
        # 零填充
        self.padding = padding

    def forward(self, X):
        # 零填充
        new_X = torch.zeros([X.shape[0], X.shape[1] + 2 * self.padding, X.shape[2] + 2 * self.padding])
        new_X[:, self.padding:X.shape[1] + self.padding, self.padding:X.shape[2] + self.padding] = X
        u, v = self.weight.shape
        output_w = (new_X.shape[1] - u) // self.stride + 1
        output_h = (new_X.shape[2] - v) // self.stride + 1
        output = torch.zeros([X.shape[0], output_w, output_h])
        for i in range(0, output.shape[1]):
            for j in range(0, output.shape[2]):
                output[:, i, j] = torch.sum(
                    new_X[:, self.stride * i:self.stride * i + u, self.stride * j:self.stride * j + v] * self.weight,
                    dim=[1, 2])
        return output


inputs = torch.randn(size=[2, 8, 8])
conv2d_padding = Conv2D(kernel_size=3, padding=1, weight_attr=torch.zeros((3, 3)))
outputs = conv2d_padding(inputs)
print(
    "When kernel_size=3, padding=1 stride=1, input's shape: {}, output's shape: {}".format(inputs.shape, outputs.shape))
conv2d_stride = Conv2D(kernel_size=3, stride=2, padding=1)
outputs = conv2d_stride(inputs)
print(
    "When kernel_size=3, padding=1 stride=2, input's shape: {}, output's shape: {}".format(inputs.shape, outputs.shape))

运行结果:

When kernel_size=3, padding=1 stride=1, input's shape: torch.Size([2, 8, 8]), output's shape: torch.Size([2, 8, 8])
When kernel_size=3, padding=1 stride=2, input's shape: torch.Size([2, 8, 8]), output's shape: torch.Size([2, 4, 4])

从输出结果看出,使用3×3大小卷积,padding为1,当stride=1时,模型的输出特征图可以与输入特征图保持一致;当stride=2时,输出特征图的宽和高都缩小一倍。

使用卷积运算完成图像边缘检测任务

在图像处理任务中,常用拉普拉斯算子对物体边缘进行提取,拉普拉斯算子为一个大小为3×3的卷积核,中心元素值是8,其余元素值是−1。

下面我们利用上面定义的Conv2D算子,构造一个简单的拉普拉斯算子,并对一张输入的灰度图片进行边缘检测,提取出目标的外形轮廓。

代码实现:

import matplotlib.pyplot as plt
from PIL import Image
import numpy as np
import os
os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE"

# 读取图片
img = Image.open('./.number.png').convert('L').resize((32, 32))

# 设置卷积核参数
w = np.array([[-1, -1, -1], [-1, 8, -1], [-1, -1, -1]], dtype='float32')
# 创建卷积算子,卷积核大小为3x3,并使用上面的设置好的数值作为卷积核权重的初始化参数
conv = Conv2D(kernel_size=3, stride=1, padding=0, weight_attr=torch.tensor(w))

# 将读入的图片转化为float32类型的numpy.ndarray
inputs = np.array(img).astype('float32')
print("bf to_tensor, inputs:", inputs)
# 将图片转为Tensor
inputs = torch.tensor(inputs)
print("bf unsqueeze, inputs:", inputs)
inputs = torch.unsqueeze(inputs, 0)
print("af unsqueeze, inputs:", inputs)
outputs = conv(inputs)
outputs = outputs.detach().numpy()

# outputs = outputs.data.squeeze().numpy()
# 可视化结果
plt.figure(figsize=(8, 4))
f = plt.subplot(121)
f.set_title('input image', fontsize=15)
plt.imshow(img)
f = plt.subplot(122)
f.set_title('output feature map', fontsize=15)
plt.imshow(outputs.squeeze(), cmap='gray')
plt.savefig('conv-vis.pdf')
plt.show()

其中读取图片语句需要进行转置,即

# 读取图片
img = Image.open('./.number.png').convert('L').resize((256, 256))

不然会出现目标与张量数据不匹配的问题

The expanded size of the tensor (256) must match the existing size (4) at non-singleton dimension 2.  Target sizes: [1, 256, 256].  Tensor sizes: [256, 256, 4]

大概意思是,张量的扩展大小(256)必须与非单一维度2的现有大小(4)匹配。目标大小:[1,256,256]。张量大小:[256,256,4]

修改后发现出现另一个问题:

Can't call numpy() on Tensor that requires grad. Use tensor.detach().numpy() instead.

即无法对需要grad的Tensor调用numpy()。使用tensor.detach()。改为numpy()。

这里我们根据错误进行修改,

将outputs = outputs.numpy()修改为outputs = outputs.detach().numpy()

然后运行会发现出现栈区溢出问题,修改图像栈大小

img=Image.open('./.number.png').convert('L').resize((32, 32))

bf to_tensor, inputs: [[255. 255. 255. ... 255. 255. 255.]
 [255. 255. 255. ... 255. 255. 255.]
 [255. 255. 255. ... 198. 247. 255.]
 ...
 [255. 255. 255. ... 206. 231. 254.]
 [255. 255. 255. ... 207. 228. 255.]
 [255. 255. 255. ... 255. 255. 255.]]
bf unsqueeze, inputs: tensor([[255., 255., 255.,  ..., 255., 255., 255.],
        [255., 255., 255.,  ..., 255., 255., 255.],
        [255., 255., 255.,  ..., 198., 247., 255.],
        ...,
        [255., 255., 255.,  ..., 206., 231., 254.],
        [255., 255., 255.,  ..., 207., 228., 255.],
        [255., 255., 255.,  ..., 255., 255., 255.]])
af unsqueeze, inputs: tensor([[[255., 255., 255.,  ..., 255., 255., 255.],
         [255., 255., 255.,  ..., 255., 255., 255.],
         [255., 255., 255.,  ..., 198., 247., 255.],
         ...,
         [255., 255., 255.,  ..., 206., 231., 254.],
         [255., 255., 255.,  ..., 207., 228., 255.],
         [255., 255., 255.,  ..., 255., 255., 255.]]])

Process finished with exit code 0

从输出结果看,使用拉普拉斯算子,目标的边缘可以成功被检测出来。 

关于栈区溢出问题可参考:

https://blog.csdn.net/qq_41320433/article/details/104299812

选做题

实现一些传统边缘检测算子,如:Roberts、Prewitt、Sobel、Scharr、Kirsch、Robinson、Laplacian

  

构造传统边缘检测算子,算子有许多种类,但是这些算子在本质上都是通过卷积计算实现的,只是所使用到的卷积核参数有所不同,所以可以构建一个通用的计算算子,只需要传入对应的卷积核参数即可实现不同的边缘检测,并且在后处理时集成了上述的四种计算最终边缘强度的方式

import os
import cv2
import matplotlib.pyplot as plt
import torch
from PIL import Image
import numpy as np

import torch
import torch.nn as nn

class EdgeOP(nn.Module):
    def __init__(self, kernel):
        '''
        kernel: shape(out_channels, in_channels, h, w)
        '''
        super(EdgeOP, self).__init__()
        out_channels, in_channels, h, w = kernel.shape
        self.filter = nn.Conv2d(in_channels=in_channels, out_channels=out_channels, kernel_size=(h, w),
                                padding='same', )
        self.filter.weight.data = torch.tensor(kernel, dtype=torch.float32)

    @staticmethod
    def postprocess(outputs, mode=0, weight=None):
        '''
        Input: NCHW
        Output: NHW(mode==1-3) or NCHW(mode==4)
        Params:
            mode: switch output mode(0-4)
            weight: weight when mode==3
        '''
        if mode == 0:
            results = torch.sum(torch.abs(outputs), dim=1)
        elif mode == 1:
            results = torch.sqrt(torch.sum(torch.pow(outputs, 2), dim=1))
        elif mode == 2:
            results = torch.max(torch.abs(outputs), dim=1)[0]
        elif mode == 3:
            if weight is None:
                C = outputs.shape[1]
                weight = torch.tensor([1 / C] * C, dtype=torch.float32)
            else:
                weight = torch.tensor(weight, dtype=torch.float32)
            results = torch.einsum('nchw, c -> nhw', torch.abs(outputs), weight)
        elif mode == 4:
            results = torch.abs(outputs)
        return torch.clip(results, 0, 255).to(torch.uint8)

    @torch.no_grad()
    def forward(self, images, mode=0, weight=None):
        outputs = self.filter(images)
        return self.postprocess(outputs, mode, weight)

图像边缘检测测试函数

为了方便测试就构建了如下的测试函数,测试同一张图片不同算子/不同边缘强度计算方法的边缘检测效果

def test_edge_det(kernel, img_path='./.number.png'):
    img = cv2.imread(img_path, 0)
    img_tensor = torch.tensor(img, dtype=torch.float32)[None, None, ...]
    op = EdgeOP(kernel)
    all_results = []
    for mode in range(4):
        results = op(img_tensor, mode=mode)
        all_results.append(results.numpy()[0])
 
    results = op(img_tensor, mode=4)
    for result in results.numpy()[0]:
        all_results.append(result)
    return all_results, np.concatenate(all_results, 1)

Roberts 算子

 

roberts_kernel = np.array([
    [[
        [1, 0],
        [0, -1]
    ]],
    [[
        [0, -1],
        [1, 0]
    ]]
])
 
_, concat_res = test_edge_det(roberts_kernel)
Image.fromarray(concat_res).show()

Prewitt 算子

 prewitt_kernel = np.array([
    [[
        [-1, -1, -1],
        [ 0,  0,  0],
        [ 1,  1,  1]
    ]],
    [[
        [-1,  0,  1],
        [-1,  0,  1],
        [-1,  0,  1]
    ]],
    [[
        [ 0,  1,  1],
        [-1,  0,  1],
        [-1, -1,  0]
    ]],
    [[
        [ -1, -1,  0],
        [ -1,  0,  1],
        [  0,  1,  1]
    ]]
])
 
_, concat_res = test_edge_det(prewitt_kernel)
Image.fromarray(concat_res).show()

Sobel 算子

 sobel_kernel = np.array([
    [[
        [-1, -2, -1],
        [ 0,  0,  0],
        [ 1,  2,  1]
    ]],
    [[
        [-1,  0,  1],
        [-2,  0,  2],
        [-1,  0,  1]
    ]],
    [[
        [ 0,  1,  2],
        [-1,  0,  1],
        [-2, -1,  0]
    ]],
    [[
        [ -2, -1,  0],
        [ -1,  0,  1],
        [  0,  1,  2]
    ]]
])
 
_, concat_res = test_edge_det(sobel_kernel)
Image.fromarray(concat_res).show()

Scharr 算子

 scharr_kernel = np.array([
    [[
        [-3, -10, -3],
        [ 0,   0,  0],
        [ 3,  10,  3]
    ]],
    [[
        [-3,  0,   3],
        [-10, 0,  10],
        [-3,  0,   3]
    ]],
    [[
        [ 0,  3,  10],
        [-3,  0,  3],
        [-10, -3,  0]
    ]],
    [[
        [ -10, -3, 0],
        [ -3,  0, 3],
        [ 0,  3,  10]
    ]]
])
 
_, concat_res = test_edge_det(scharr_kernel)
Image.fromarray(concat_res).show()

Krisch 算子

 Krisch_kernel = np.array([
    [[
        [5, 5, 5],
        [-3,0,-3],
        [-3,-3,-3]
    ]],
    [[
        [-3, 5,5],
        [-3,0,5],
        [-3,-3,-3]
    ]],
    [[
        [-3,-3,5],
        [-3,0,5],
        [-3,-3,5]
    ]],
    [[
        [-3,-3,-3],
        [-3,0,5],
        [-3,5,5]
    ]],
    [[
        [-3, -3, -3],
        [-3,0,-3],
        [5,5,5]
    ]],
    [[
        [-3, -3, -3],
        [5,0,-3],
        [5,5,-3]
    ]],
    [[
        [5, -3, -3],
        [5,0,-3],
        [5,-3,-3]
    ]],
    [[
        [5, 5, -3],
        [5,0,-3],
        [-3,-3,-3]
    ]],
])
 
_, concat_res = test_edge_det(Krisch_kernel)

Robinson算子

 robinson_kernel = np.array([
    [[
        [1, 2, 1],
        [0, 0, 0],
        [-1, -2, -1]
    ]],
    [[
        [0, 1, 2],
        [-1, 0, 1],
        [-2, -1, 0]
    ]],
    [[
        [-1, 0, 1],
        [-2, 0, 2],
        [-1, 0, 1]
    ]],
    [[
        [-2, -1, 0],
        [-1, 0, 1],
        [0, 1, 2]
    ]],
    [[
        [-1, -2, -1],
        [0, 0, 0],
        [1, 2, 1]
    ]],
    [[
        [0, -1, -2],
        [1, 0, -1],
        [2, 1, 0]
    ]],
    [[
        [1, 0, -1],
        [2, 0, -2],
        [1, 0, -1]
    ]],
    [[
        [2, 1, 0],
        [1, 0, -1],
        [0, -1, -2]
    ]],
])
 
_, concat_res = test_edge_det(robinson_kernel)
Image.fromarray(concat_res).show()

Laplacian 算子

 laplacian_kernel = np.array([
    [[
        [1, 1, 1],
        [1, -8, 1],
        [1, 1, 1]
    ]],
    [[
        [0, 1, 0],
        [1, -4, 1],
        [0, 1, 0]
    ]]
])
 
_, concat_res = test_edge_det(laplacian_kernel)
Image.fromarray(concat_res).show()

 

Roberts算子采用对角线方向相邻两像素之差近似的梯度幅值来检测边缘。该算子定位较准确,但对噪声比较敏感,检测水平和竖直边缘效果好于斜向边缘。

Sobel算子根据图像的像素点上下、左右邻点灰度加权差在边缘处达到极值这一特点来检测边缘。该算子对噪声有较好的平滑作用,能提供建准确的边缘方向信息,但是边缘定位精度不高。

Prewitt算子边缘检测的思路与Sobel算子类似,也是在一个掩模中定义微分运算。算子对噪声具有平滑作用,同样定位精度不够高。

更为先进的边缘检测技术

1、Marr-Hildreth算法(拉普拉斯算子)

(1)采用高斯低通滤波器对图像进行滤波;

(2)采用拉普拉斯模板对进行卷积;

(3)找到步骤(2)所得图像的零交叉。

该算子是二阶微分算子,利用边缘点处二阶导函数出现零交叉原理来检测图像的边缘。对灰度突变及噪声较敏感,不具有方向性,不能获得图像边缘的方向信息。

2、Canny算子

Canny边缘检测算法步骤:

(1)用一个高斯滤波器平滑输入图像

(2)计算梯度幅值图像和角度图像

(3)对梯度幅值图像进行非最大抑制

(4)用双阈值处理和连接分析来检测并连接边缘

Canny算子是上述中效果最好的算子,该算子去噪能力强,在连续性、细度和笔直度等线的质量方面也很出众。但是Canny算子的性能带来的问题是:连接起来更复杂、执行时间较长。

心得体会

      本次实验是使用卷积对图像内容进行提取,因为之前做过类似的实验,所以对于实验中所遇到的问题解决起来还是比较轻松的,在本次实验中又知道了一种新的问题——栈区溢出问题,虽然是知道了如何修改问题使程序成功运行,但是对于它的原理还是不了解,后面会找时间将这部分知识进行了解。

参考

魏老师csdn:https://blog.csdn.net/qq_38975453/article/details/126521281?spm=1001.2014.3001.5502

栈区溢出问题解决:https://blog.csdn.net/qq_41320433/article/details/104299812

AI Studio:边缘检测系列1:传统边缘检测算子 - 飞桨AI Studio

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值