目录
5.1 卷积
5.1.1 二维卷积运算
5.1.2 二维卷积算子
5.1.3 二维卷积的参数量和计算量
5.1.4 感受野
5.1.5 卷积的变种
5.1.5.1 步长(Stride)
5.1.5.2 零填充(Zero Padding)
5.1.6 带步长和零填充的二维卷积算子
5.1.7 使用卷积运算完成图像边缘检测任务
选做题
1、实现一些传统边缘检测算子,如:Roberts、Prewitt、Sobel、Scharr、Kirsch、Robinson、Laplacian
Roberts 算子
Prewitt 算子
Sobel算子
Scharr算子
Krisch 算子
Robinson 算子
Laplacian算子
2、实现的简易的 Canny 边缘检测算法
算法原理
代码实现
总结
卷积神经网络(Convolutional Neural Network,CNN)
- 受生物学上感受野机制的启发而提出。
- 一般是由卷积层、汇聚层和全连接层交叉堆叠而成的前馈神经网络
- 有三个结构上的特性:局部连接、权重共享、汇聚。
- 具有一定程度上的平移、缩放和旋转不变性。
- 和前馈神经网络相比,卷积神经网络的参数更少。
- 主要应用在图像和视频分析的任务上,其准确率一般也远远超出了其他的神经网络模型。
- 近年来卷积神经网络也广泛地应用到自然语言处理、推荐系统等领域。
考虑到使用全连接前馈网络来处理图像时,会出现如下问题:
模型参数过多,容易发生过拟合。 在全连接前馈网络中,隐藏层的每个神经元都要跟该层所有输入的神经元相连接。随着隐藏层神经元数量的增多,参数的规模也会急剧增加,导致整个神经网络的训练效率非常低,也很容易发生过拟合。
难以提取图像中的局部不变性特征。 自然图像中的物体都具有局部不变性特征,比如尺度缩放、平移、旋转等操作不影响其语义信息。而全连接前馈网络很难提取这些局部不变性特征。
卷积神经网络有三个结构上的特性:局部连接、权重共享和汇聚。这些特性使得卷积神经网络具有一定程度上的平移、缩放和旋转不变性。和前馈神经网络相比,卷积神经网络的参数也更少。因此,通常会使用卷积神经网络来处理图像信息。
卷积是分析数学中的一种重要运算,常用于信号处理或图像处理任务。本节以二维卷积为例来进行实践。
在机器学习和图像处理领域,卷积的主要功能是在一个图像(或特征图)上滑动一个卷积核,通过卷积操作得到一组新的特征。在计算卷积的过程中,需要进行卷积核的翻转,而这也会带来一些不必要的操作和开销。因此,在具体实现上,一般会以数学中的互相关(Cross-Correlatio)运算来代替卷积。
在神经网络中,卷积运算的主要作用是抽取特征,卷积核是否进行翻转并不会影响其特征抽取的能力。特别是当卷积核是可学习的参数时,卷积和互相关在能力上是等价的。因此,很多时候,为方便起见,会直接用互相关来代替卷积。
经过卷积运算后,最终输出矩阵大小则为
可以发现,使用卷积处理图像,会有以下两个特性:
在本书后面的实现中,算子都继承paddle.nn.Layer
,并使用支持反向传播的飞桨API进行实现,这样我们就可以不用手工写backword()
的代码实现。
根据公式(5.1),我们首先实现一个简单的二维卷积算子,代码实现如下:
import torch
import torch.nn as nn
import numpy as np
class Conv2D(nn.Module):
def __init__(self, kernel_size,
weight_attr=torch.nn.Parameter(torch.tensor([[0., 1.],[2., 3.]]))):
super(Conv2D, self).__init__()
# 使用'paddle.create_parameter'创建卷积核
# 使用'paddle.ParamAttr'进行参数初始化
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.manual_seed(100)
inputs = torch.tensor([[[1.,2.,3.],[4.,5.,6.],[7.,8.,9.]]])
conv2d = Conv2D(kernel_size=2)
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=)
参数量
由于二维卷积的运算方式为在一个图像(或特征图)上滑动一个卷积核,通过卷积操作得到一组新的特征。所以参数量仅仅与卷积核的尺寸有关,对于一个输入矩阵X∈RM×NX∈RM×N和一个滤波器W∈RU×VW∈RU×V,卷积核的参数量为U×VU×V。
假设有一幅大小为32×3232×32的图像,如果使用全连接前馈网络进行处理,即便第一个隐藏层神经元个数为1,此时该层的参数量也高达10251025个,此时该层的计算过程如 图5.3 所示。
可以想像,随着隐藏层神经元数量的变多以及层数的加深,使用全连接前馈网络处理图像数据时,参数量会急剧增加。
如果使用卷积进行图像处理,当卷积核为3×33×3时,参数量仅为99,相较于全连接前馈网络,参数量少了非常多。
计算量
在卷积神经网络中运算时,通常会统计网络总的乘加运算次数作为计算量(FLOPs,floating point of operations),来衡量整个网络的运算速度。对于单个二维卷积,计算量的统计方式为:
输出特征图上每个点的数值,是由输入图片上大小为U×VU×V的区域的元素与卷积核每个元素相乘再相加得到的,所以输入图像上U×VU×V区域内每个元素数值的改变,都会影响输出点的像素值。我们将这个区域叫做输出特征图上对应点的感受野。感受野内每个元素数值的变动,都会影响输出点的数值变化。比如3×33×3卷积对应的感受野大小就是3×33×3,如 图5.4 所示。
而当通过两层3×33×3的卷积之后,感受野的大小将会增加到5×55×5,如 图5.5 所示。
因此,当增加卷积网络深度的同时,感受野将会增大,输出特征图中的一个像素点将会包含更多的图像语义信息。
在卷积的标准定义基础上,还可以引入卷积核的滑动步长和零填充来增加卷积的多样性,从而更灵活地进行特征抽取。
在卷积运算的过程中,有时会希望跳过一些位置来降低计算的开销,也可以把这一过程看作是对标准卷积运算输出的下采样。
在计算卷积时,可以在所有维度上每间隔SS个元素计算一次,SS称为卷积运算的步长(Stride),也就是卷积核在滑动时的间隔。
在卷积运算中,还可以对输入用零进行填充使得其尺寸变大。根据卷积的定义,如果不进行填充,当卷积核尺寸大于1时,输出特征会缩减。对输入进行零填充则可以对卷积核的宽度和输出的大小进行独立的控制。
在二维卷积运算中,零填充(Zero Padding)是指在输入矩阵周围对称地补上PP个00。图5.7 为使用零填充的示例。
通常情况下,在层数较深的卷积神经网络,比如:VGG、ResNet中,会使用等宽卷积保证输出特征图的大小不会随着层数的变深而快速缩减。例如:当卷积核的大小为3×33×3时,会将步长设置为S=1S=1,两端补零P=1P=1,此时,卷积后的输出尺寸就可以保持不变。在本章后续的案例中,会使用ResNet进行实验。
class Conv2D(nn.Module):
def __init__(self, kernel_size, stride=1, padding=0,weight_attr= torch.ones([3,3])):
super(Conv2D, self).__init__()
self.weight = 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)
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×33×3大小卷积,padding
为1,当stride
=1时,模型的输出特征图可以与输入特征图保持一致;当stride
=2时,输出特征图的宽和高都缩小一倍。
在图像处理任务中,常用拉普拉斯算子对物体边缘进行提取,拉普拉斯算子为一个大小为3×33×3的卷积核,中心元素值是88,其余元素值是−1−1。
考虑到边缘其实就是图像上像素值变化很大的点的集合,因此可以通过计算二阶微分得到,当二阶微分为0时,像素值的变化最大。此时,对xx方向和yy方向分别求取二阶导数:
也就是一个点的四邻域拉普拉斯的算子计算结果是自己像素值的四倍减去上下左右的像素的和,将这个算子旋转45°45°后与原算子相加,就变成八邻域的拉普拉斯算子,也就是一个像素自己值的八倍减去周围一圈八个像素值的和,做为拉普拉斯计算结果,此时,该算子可以表示为:
下面我们利用上面定义的Conv2D
算子,构造一个简单的拉普拉斯算子,并对一张输入的灰度图片进行边缘检测,提取出目标的外形轮廓。
import matplotlib.pyplot as plt
from PIL import Image
import numpy as np
# 读取图片
img = Image.open('cameraman.tif').convert('L').resize((100,100))
# 设置卷积核参数
w = torch.tensor([[-1,-1,-1], [-1,8,-1], [-1,-1,-1]], dtype=torch.float64)
# 创建卷积算子,卷积核大小为3x3,并使用上面的设置好的数值作为卷积核权重的初始化参数
conv = Conv2D(kernel_size=3, stride=1, padding=0)
# 将读入的图片转化为float32类型的numpy.ndarray
inputs = np.array(img).astype('float64')
print("bf to_tensor, inputs:",inputs)
# 将图片转为Tensor
inputs = torch.tensor(inputs)
print("bf unsqueeze, inputs:",inputs)
inputs = torch.unsqueeze(inputs, dim=0)
print("af unsqueeze, inputs:",inputs)
outputs = conv(inputs)
outputs = outputs.detach().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()
得到以下结果:
bf to_tensor, inputs: [[158. 157. 158. ... 156. 154. 153.]
[156. 157. 157. ... 154. 152. 153.]
[156. 156. 156. ... 151. 148. 150.]
...
[121. 122. 119. ... 120. 116. 105.]
[116. 109. 125. ... 101. 108. 118.]
[127. 129. 147. ... 110. 132. 126.]]
bf unsqueeze, inputs: tensor([[158., 157., 158., ..., 156., 154., 153.],
[156., 157., 157., ..., 154., 152., 153.],
[156., 156., 156., ..., 151., 148., 150.],
...,
[121., 122., 119., ..., 120., 116., 105.],
[116., 109., 125., ..., 101., 108., 118.],
[127., 129., 147., ..., 110., 132., 126.]], dtype=torch.float64)
af unsqueeze, inputs: tensor([[[158., 157., 158., ..., 156., 154., 153.],
[156., 157., 157., ..., 154., 152., 153.],
[156., 156., 156., ..., 151., 148., 150.],
...,
[121., 122., 119., ..., 120., 116., 105.],
[116., 109., 125., ..., 101., 108., 118.],
[127., 129., 147., ..., 110., 132., 126.]]], dtype=torch.float64)
这里的图片本来是个256*256的,但是总是不能输出。
进程已结束,退出代码为 -1073741571 (0xC00000FD)
查了好多资料发现是栈溢出的问题,这里参考了网站。(55条消息) Process finished with exit code -1073741571 (0xC00000FD)的解决方案_shaojie_45的博客-CSDN博客_process finished with exit code -1073741571 (0xc00
(55条消息) 解决报错Process finished with exit code -1073741571 (0xC00000FD),修改栈大小_hardcodetest的博客-CSDN博客
后面和室友不是蒋承翰和别被打脸讨论后讨论后,发现resize104*104以下都可以运行,105*105以上溢出。
后来再看代码,发现如果不对outputs更新,而是之间把
outputs.detach().numpy()
放在imshow里是可以运行出来的。
plt.imshow(outputs.detach().numpy().squeeze(), cmap='gray')
这里参考了刘先生TT的代码后发现的他把detach().numpy()放在下面就可以了,然后我就试了试,发现果然可以,很奇怪,希望老师可以讲一下是为什么。
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',bias=False )
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)
图像边缘检测测试函数
import os
import cv2
from PIL import Image
def test_edge_det(kernel, img_path='cameraman.tif'):
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 核卷积,本质上是两个对角方向上的差分,与Roberts135卷积后的结果取绝对值,反应的是 45°方向上的灰度变化率;而与Roberts45卷积后的结果取绝对值,反应的是135°方向上的灰度变化率,利用变化率对边缘强度进行数字衡量。对 Roberts 算子进行改进便可以反响在垂直方向和水平方向上的边缘。
Roberts 边缘检测因为使用了很少的邻域像素来近似边缘强度,因此对图像中的噪声具有高度敏感性。因此,先对图像做平滑处理再进行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()
图像与prewittx卷积后可以反映图像垂直方向上的边缘,与prewitty卷积后可以反映图像水平方向上的边缘。而且,这两个卷积核均是可分离的,其中
从分离结果可以看出,prewitt x算子实际上先对图像进行垂直方向上的非归一化的均值平滑,然后进行水平方向的差分;而prewitty算子实际上先对图像进行水平方向上的非归一化的均值平滑,然后进行垂直方向上的差分。
由于对图像进行了平滑操作,所以对噪声较多的图像进行 Prewitt 边缘检测得到的边缘比 Roberts 要好。可以对标准的 Prewitt 算子进行改进,比如以下两个卷积核反映的是在45°和135°方向上的边缘。这两个卷积核是不可分离的。
从 Roberts 和 Prewitt 边缘检测的效果图可以清晰地理解差分方向(或称梯度方向)与得到的边缘是垂直的,如水平差分方向上的卷积放映的是垂直方向上的边缘。
在图像平滑处理中 ,高斯平滑的效果往往比均值平滑要好,因此把 Prewitt 算子的非归一化的均值卷积核替换成非归一化的高斯卷积核,就可以构建 3 阶的 Sobel 边缘检测算子。
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 算子的标准形式,可以利用二项式展开式的系数构建窗口更大的 Sobel 算子,如 5x5、7x7等,窗口大小为奇数。
构建高阶的 Sobel 算子
Sobel 算子是在一个坐标轴方向上进行非归一化的高斯平滑,在另一个坐标轴方向上进行差分处理。 nxn 的 Sobel 算子是由平滑算子和差分算子 full 卷积而得到的,对于窗口大小为 n 的非归一化的高斯平滑算子等于 n-1 阶的二项式展开式的系数。窗口大小为 n 的差分算子是在 n-2 阶的二项式展开式的系数两侧补零,然后后向差分得到的。举例:构建 5 阶的非归一化的高斯平滑算子,取二项式的指数 n=4,然后计算展开式的系数,即
对于构建 5 阶的差分算子,令二项式的指数 n=5-2=3 ,然后计算展开式的系数,即
两侧补零,接着向后差分,得到差分后的结果即为 5 阶的差分算子,然后和 5 阶的平滑算子 full 卷积,即可得到 5x5 的 Sobel,Sobel平滑算子和差分算子的总结如下所示
n | 窗口大小 | 平滑算子 | 差分算子 |
---|---|---|---|
1 | 2 | 1 1 | 1 -1 |
2 | 3 | 1 2 1 | 1 0 -1 |
3 | 4 | 1 3 3 1 | 1 1 -1 -1 |
4 | 5 | 1 4 6 4 1 | 1 2 0 -2 -1 |
上表中的平滑算子就是帕斯卡三角形。Sobel 边缘检测算子是通过窗口大小为 k 的平滑算子和差分算子与图像卷积而得到的。高阶的 Sobel 边缘检测算子是可分离的。
使用不同尺寸的 Sobel 核边缘检测效果,可以看出,使用高阶的 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 边缘检测算子与 Prewitt 边缘检测算子和 3 阶的 Sobel 边缘检测算子类似,由以下两个卷积核组成,不同的是,这两个卷积核均是不可分离的。图像与水平方向上的scharrx积结果反响的是垂直方向上的边缘强度,与垂直方向上的scharry卷积结果反映的是水平方向上的边缘强度。
同样,Scharr 边缘检测算子也可以扩展到其他方向,比如以下两个反映的是135°和45°方向上的边缘。
与 Prewitt 边缘检测相比,因为 Scharr 卷积核中系数的增大,所以灰度变化较为敏感,即是灰度变化较小的区域,也会得到较强的边缘强度,所以得到的边缘图比 Prewitt 得到的边缘图显得丰富,但是不够细化。
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算子由以下 8 个卷积核组成。图像与每一个核进行卷积,然后取绝对值作为对应方向上的边缘强度的量化。对 8 个卷积结果取绝对值,然后在对应值位置取最大值作为最后输出的边缘强度。
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)
Image.fromarray(concat_res).show()
Robinson 算子也由 8 个卷积核组成。
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()
sobel和scharr都是下面减上面或者右边减左边。实际上他们都是计算的一次,也就是下面减上面一次,右边减左边一次。
而拉普拉斯算子是计算了两次,也就是上减中, 下减中, 左减中, 右减中。
它用到的卷积核是下面这个:
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()
参考链接:OpenCV —— 边缘检测
基于卷积运算的边缘检测算法,比如 Sobel、Prewitt 等,有如下两个缺点:
没有充分利用边缘的梯度方向
最后输出的边缘二值图,只是简单地利用阈值进行处理,显然如果阈值过大,则会损失很多边缘信息;如果阈值过小,则会有很多噪声
而 Canny 边缘检测基于这两点做了改进,提出了:
基于边缘梯度方向的非极大值抑制
双阈值的滞后阈值处理
Canny 是一个经典的图像边缘检测算法,一般包含如下几个步骤:
使用高斯模糊对图像进行模糊降噪处理
基于图像梯度幅值进行图像边缘增强
非极大值抑制处理进行图像边缘细化
图像二值化和边缘连接得到最终的结果
基于 OpenCV 实现快速的 Canny 边缘检测
import cv2
import numpy as np
from PIL import Image
lower = 30 # 最小阈值
upper = 70 # 最大阈值
img_path = 'cameraman.tif' # 指定测试图像路径
gray = cv2.imread(img_path, 0) # 读取灰度图像
edge = cv2.Canny(gray, lower, upper) # Canny 图像边缘检测
contrast = np.concatenate([edge, gray], 1) # 图像拼接
Image.fromarray(contrast).show() # 显示图像
基于 Numpy 模块实现简单的 Canny 检测器
0. 导入必要的包
import cv2
import math
import numpy as np
1. 高斯模糊
def smooth(img_gray, kernel_size=5):
# 生成高斯滤波器
"""
要生成一个 (2k+1)x(2k+1) 的高斯滤波器,滤波器的各个元素计算公式如下:
H[i, j] = (1/(2*pi*sigma**2))*exp(-1/2*sigma**2((i-k-1)**2 + (j-k-1)**2))
"""
sigma1 = sigma2 = 1.4
gau_sum = 0
gaussian = np.zeros([kernel_size, kernel_size])
for i in range(kernel_size):
for j in range(kernel_size):
gaussian[i, j] = math.exp(
(-1 / (2 * sigma1 * sigma2)) *
(np.square(i - 3) + np.square(j-3))
) / (2 * math.pi * sigma1 * sigma2)
gau_sum = gau_sum + gaussian[i, j]
# 归一化处理
gaussian = gaussian / gau_sum
# 高斯滤波
img_gray = np.pad(img_gray, ((kernel_size//2, kernel_size//2), (kernel_size//2, kernel_size//2)), mode='constant')
W, H = img_gray.shape
new_gray = np.zeros([W - kernel_size, H - kernel_size])
for i in range(W-kernel_size):
for j in range(H-kernel_size):
new_gray[i, j] = np.sum(
img_gray[i: i + kernel_size, j: j + kernel_size] * gaussian
)
return new_gray
2. 计算图像的梯度幅值
def gradients(new_gray):
"""
:type: image which after smooth
:rtype:
dx: gradient in the x direction
dy: gradient in the y direction
M: gradient magnitude
theta: gradient direction
"""
W, H = new_gray.shape
dx = np.zeros([W-1, H-1])
dy = np.zeros([W-1, H-1])
M = np.zeros([W-1, H-1])
theta = np.zeros([W-1, H-1])
for i in range(W-1):
for j in range(H-1):
dx[i, j] = new_gray[i+1, j] - new_gray[i, j]
dy[i, j] = new_gray[i, j+1] - new_gray[i, j]
# 图像梯度幅值作为图像强度值
M[i, j] = np.sqrt(np.square(dx[i, j]) + np.square(dy[i, j]))
# 计算 θ - artan(dx/dy)
theta[i, j] = math.atan(dx[i, j] / (dy[i, j] + 0.000000001))
return dx, dy, M, theta
3. 非极大值抑制
def NMS(M, dx, dy):
d = np.copy(M)
W, H = M.shape
NMS = np.copy(d)
NMS[0, :] = NMS[W-1, :] = NMS[:, 0] = NMS[:, H-1] = 0
for i in range(1, W-1):
for j in range(1, H-1):
# 如果当前梯度为0,该点就不是边缘点
if M[i, j] == 0:
NMS[i, j] = 0
else:
gradX = dx[i, j] # 当前点 x 方向导数
gradY = dy[i, j] # 当前点 y 方向导数
gradTemp = d[i, j] # 当前梯度点
# 如果 y 方向梯度值比较大,说明导数方向趋向于 y 分量
if np.abs(gradY) > np.abs(gradX):
weight = np.abs(gradX) / np.abs(gradY) # 权重
grad2 = d[i-1, j]
grad4 = d[i+1, j]
# 如果 x, y 方向导数符号一致
# 像素点位置关系
# g1 g2
# c
# g4 g3
if gradX * gradY > 0:
grad1 = d[i-1, j-1]
grad3 = d[i+1, j+1]
# 如果 x,y 方向导数符号相反
# 像素点位置关系
# g2 g1
# c
# g3 g4
else:
grad1 = d[i-1, j+1]
grad3 = d[i+1, j-1]
# 如果 x 方向梯度值比较大
else:
weight = np.abs(gradY) / np.abs(gradX)
grad2 = d[i, j-1]
grad4 = d[i, j+1]
# 如果 x, y 方向导数符号一致
# 像素点位置关系
# g3
# g2 c g4
# g1
if gradX * gradY > 0:
grad1 = d[i+1, j-1]
grad3 = d[i-1, j+1]
# 如果 x,y 方向导数符号相反
# 像素点位置关系
# g1
# g2 c g4
# g3
else:
grad1 = d[i-1, j-1]
grad3 = d[i+1, j+1]
# 利用 grad1-grad4 对梯度进行插值
gradTemp1 = weight * grad1 + (1 - weight) * grad2
gradTemp2 = weight * grad3 + (1 - weight) * grad4
# 当前像素的梯度是局部的最大值,可能是边缘点
if gradTemp >= gradTemp1 and gradTemp >= gradTemp2:
NMS[i, j] = gradTemp
else:
# 不可能是边缘点
NMS[i, j] = 0
return NMS
4. 图像二值化和边缘连接
def double_threshold(NMS, threshold1, threshold2):
NMS = np.pad(NMS, ((1, 1), (1, 1)), mode='constant')
W, H = NMS.shape
DT = np.zeros([W, H])
# 定义高低阈值
TL = threshold1 * np.max(NMS)
TH = threshold2 * np.max(NMS)
for i in range(1, W-1):
for j in range(1, H-1):
# 双阈值选取
if (NMS[i, j] < TL):
DT[i, j] = 0
elif (NMS[i, j] > TH):
DT[i, j] = 1
# 连接
elif ((NMS[i-1, j-1:j+1] < TH).any() or
(NMS[i+1, j-1:j+1].any() or
(NMS[i, [j-1, j+1]] < TH).any())):
DT[i, j] = 1
return DT
5. Canny 边缘检测
def canny(gray, threshold1, threshold2, kernel_size=5):
norm_gray = gray
gray_smooth = smooth(norm_gray, kernel_size)
dx, dy, M, theta = gradients(gray_smooth)
nms = NMS(M, dx, dy)
DT = double_threshold(nms, threshold1, threshold2)
return DT
6. 代码测试
import cv2
import numpy as np
from PIL import Image
lower = 0.1 # 最小阈值
upper = 0.3 # 最大阈值
img_path = 'football.jpg' # 指定测试图像路径
gray = cv2.imread(img_path, 0) # 读取灰度图像
edge = canny(gray, lower, upper) # Canny 图像边缘检测
edge = (edge * 255).astype(np.uint8) # 反归一化
contrast = np.concatenate([edge, gray], 1) # 图像拼接
Image.fromarray(contrast).show() # 显示图像
本次的实验和上次的作业是差不多,都是对图像进行处理但是这次没有直接用Conv2d函数,而是手写的卷积函数,一步一步的让我对卷积过程和一些参数添加的方法,而不是只是用个nn.Conv2d然后往里面填参数就行了。
这次实验最大的疑惑就是为什么对outputs进行变张量操作会造成栈溢出,256*256的图像真心不大啊,但是为什么不更新outputs,直接在imshow里面用就不会造成溢出问题呢?而且我还用了更大的图片进行测试也不会造成溢出问题。
最后就是老师给出的几篇论文,确实是有些看不懂了,所以也就没敢对这些论文进行总结,还需要提高我的英语水平,一看英语论文啥也看不懂了。
还有还有,太喜欢我们宿舍的学习氛围了。