目录
卷积神经网络
图像特征
卷积神经网络
卷积神经网络 (Convolutional Neural Network, CNN) 由卷积层和池化层组成, 其中卷积层用于提取局部空间特征,池化层用于降采样。多个卷积层和池化层交替堆叠,可以逐步提取更加抽象的空间特征,之后再结合全连接网络 (或者Transformer),最终完成分类等机器学习任务。
由于CNN主要用于处理图像,因此我们从图像入手,讲解CNN的原理和效果。之后基于CNN完成 MNIST数字手写体实验,比较CNN与MLP在图像识别任务上的效果差异,深化对于CNN的理解。
除CNN之外,本模块也介绍了批量归一化 (Batch Normalization) 和高级的参数优化器 (Adam)。这两种技术可以加速收敛,并且可能带来更好的优化效果。我们同样基于MNIST实验展示了它们的效果。
图像特征
图像数据是一个三维或者二维的张量,前两个维度分别表示图像的高度和宽度,第三个维度表示像素特征。彩色图具有三个特征通道,分别为 R, G, B。灰度图只有一个特征通道,因而特征维度也可以被略去。
以下是读入图像数据的代码示例:
# pip install pillow from PIL import Image import numpy as np # 读入图像数据 img = Image.open('mihao.jpeg') # 转换为灰度图 img = img.convert('L') img.show() # 转换为numpy数组 img_data = np.array(img)图像信息的表达依赖像素点之间的空间关系。人类视觉系统在捕捉到光信号之后,首先由视网膜上的神经节细胞对信号进行初步处理,提取边缘、方向等基本特征。再传递到大脑皮层的视觉区域,在那里对视网膜传来的信号提取更复杂的视觉特征 (从纹理、形状和颜色到更复杂的视觉模式),最后在颞叶区域识别出具体的对象和场景。 计算机视觉 (Computer Vision, CV) 算法借鉴了人类视觉系统的机制。在传统CV中,视觉特征是通过人工设计的滤波算子提取的。下边,我们就以sobel算子为例进行说明。
sobel算子是一种用于边缘检测的离散微分算子,通过两个3x3的算子来分别计算图像在垂直方向和水平方向的边缘特征。算子定义如下:
星号表示的是卷积运算,其运算结果 Gx,Gy 也分别为一张“图像”。
from PIL import Image import numpy as np def filter(input : np.ndarray, kernel : np.ndarray) -> np.ndarray: """对输入图像应用二维卷积核(Valid卷积模式)。 该函数通过滑动窗口遍历输入图像,将每个窗口区域与卷积核进行逐元素相乘后求和, 生成卷积输出。不进行边缘填充,输出尺寸小于输入尺寸。 参数: input: 输入的二维图像数组(通常为灰度图),形状为 (H, W)。 kernel: 二维卷积核数组,形状为 (h, w)。 返回: 卷积后的输出数组,形状为 (H - h + 1, W - w + 1)。 """ # 获取卷积核的高度和宽度 h = kernel.shape[0] w = kernel.shape[1] # 获取输入图像的高度和宽度 H = input.shape[0] W = input.shape[1] # 初始化输出数组(Valid卷积模式,无边缘填充) output = np.zeros((H-h+1, W-w+1)) # 滑动窗口遍历图像并计算卷积 for i in range(H-h+1): for j in range(W-w+1): # 截取当前窗口区域,与卷积核逐元素相乘后求和,得到当前位置的卷积值 output[i,j] = (input[i:i+h, j:j+w] * kernel).sum() # pytorch可做并行计算,本质还是线性模型 return output if __name__ == '__main__': # 打开图像文件并转换为灰度模式('L'表示8位灰度图) img = Image.open('cross.png').convert('L') # 将PIL图像转换为numpy数组 data = np.array(img) # 打印图像数组(可选,用于查看原始像素值) print(data) # 定义Sobel X方向边缘检测算子(水平方向梯度) sobel_x = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]]) # 定义Sobel Y方向边缘检测算子(垂直方向梯度,为sobel_x的转置) sobel_y = sobel_x.T # 对输入图像应用Sobel X算子,得到水平边缘响应 output1 = filter(data, sobel_x) # 对输入图像应用Sobel Y算子,得到垂直边缘响应 output2 = filter(data, sobel_y) # 打印output1的最小值和最大值(用于观察响应范围) print(output1.min(), output1.max()) # 对output1进行归一化处理,将值映射到[0, 255]范围,并转换为8位无符号整数 output1 = ((output1 - output1.min()) / (output1.max() - output1.min()) * 255).astype(np.uint8) """ output1的范围————[min, max] (output1 - output1.min())的范围————[0, max - min] (output1 - output1.min()) / (output1.max() - output1.min())的范围————[0, 1] """ # 将numpy数组转换回PIL图像 output_img1 = Image.fromarray(output1) # 显示处理后的图像 output_img1.show()
补全 Sobel Y 方向(垂直边缘检测):
from PIL import Image import numpy as np def filter(input : np.ndarray, kernel : np.ndarray) -> np.ndarray: """对输入图像应用二维卷积核(Valid卷积模式)。 该函数通过滑动窗口遍历输入图像,将每个窗口区域与卷积核进行逐元素相乘后求和, 生成卷积输出。不进行边缘填充,输出尺寸小于输入尺寸。 参数: input: 输入的二维图像数组(通常为灰度图),形状为 (H, W)。 kernel: 二维卷积核数组,形状为 (h, w)。 返回: 卷积后的输出数组,形状为 (H - h + 1, W - w + 1)。 """ # 获取卷积核的高度和宽度 h = kernel.shape[0] w = kernel.shape[1] # 获取输入图像的高度和宽度 H = input.shape[0] W = input.shape[1] # 初始化输出数组(Valid卷积模式,无边缘填充) output = np.zeros((H-h+1, W-w+1)) # 滑动窗口遍历图像并计算卷积 for i in range(H-h+1): for j in range(W-w+1): # 截取当前窗口区域,与卷积核逐元素相乘后求和,得到当前位置的卷积值 output[i,j] = (input[i:i+h, j:j+w] * kernel).sum() return output if __name__ == '__main__': # 打开图像文件并转换为灰度模式('L'表示8位灰度图) img = Image.open('cross.png').convert('L') # 将PIL图像转换为numpy数组 data = np.array(img) # 定义Sobel X方向边缘检测算子(水平方向梯度) sobel_x = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]]) # 定义Sobel Y方向边缘检测算子(垂直方向梯度,为sobel_x的转置) sobel_y = sobel_x.T # 对输入图像应用Sobel X算子,得到水平边缘响应 output1 = filter(data, sobel_x) # 对输入图像应用Sobel Y算子,得到垂直边缘响应 output2 = filter(data, sobel_y) # --- 处理 Sobel X 方向 (output1) --- # 对output1进行归一化处理,将值映射到[0, 255]范围,并转换为8位无符号整数 output1 = ((output1 - output1.min()) / (output1.max() - output1.min()) * 255).astype(np.uint8) # 将numpy数组转换回PIL图像 output_img1 = Image.fromarray(output1) # 显示X方向边缘检测结果 output_img1.show() # --- 处理 Sobel Y 方向 (output2) --- # 对output2进行同样的归一化处理,映射到[0, 255] output2 = ((output2 - output2.min()) / (output2.max() - output2.min()) * 255).astype(np.uint8) # 将numpy数组转换回PIL图像 output_img2 = Image.fromarray(output2) # 显示Y方向边缘检测结果 output_img2.show()
将 Sobel X 和 Sobel Y 的结果结合起来
:
from PIL import Image import numpy as np def filter(input : np.ndarray, kernel : np.ndarray) -> np.ndarray: """对输入图像应用二维卷积核(Valid卷积模式)。""" # 获取卷积核的高度和宽度 h = kernel.shape[0] w = kernel.shape[1] # 获取输入图像的高度和宽度 H = input.shape[0] W = input.shape[1] # 初始化输出数组 output = np.zeros((H-h+1, W-w+1)) # 滑动窗口遍历图像并计算卷积 for i in range(H-h+1): for j in range(W-w+1): output[i,j] = (input[i:i+h, j:j+w] * kernel).sum() return output if __name__ == '__main__': # 1. 读取并预处理图像 img = Image.open('cross.png').convert('L') data = np.array(img) # 2. 定义算子 sobel_x = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]]) sobel_y = sobel_x.T # 3. 分别计算 X 和 Y 方向的梯度 (Gx, Gy) output1 = filter(data, sobel_x) # Gx output2 = filter(data, sobel_y) # Gy # 4. 【关键步骤】计算梯度幅值 (合并两个方向) # 公式:Magnitude = sqrt(Gx^2 + Gy^2) gradient_magnitude = np.sqrt(output1**2 + output2**2) # 5. 归一化处理 (将合并后的结果映射到 [0, 255]) # 为了防止除零,通常在分母加一个极小值 eps,或者像下面一样直接计算 output_combined = ((gradient_magnitude - gradient_magnitude.min()) / (gradient_magnitude.max() - gradient_magnitude.min()) * 255).astype(np.uint8) # 6. 显示最终的合并结果 output_img_combined = Image.fromarray(output_combined) output_img_combined.show() # (可选) 保存图片 # output_img_combined.save('sobel_combined.png')
如果原图像的尺寸为(H, W),则Gx和Gy这两个输出矩阵的尺寸均为(H − 2, W − 2),即。卷积的计算过程可以参考"卷积运算"中的图示。
水平和垂直方向的边缘特征最终可以整合在一起:
from PIL import Image import numpy as np def filter(input : np.ndarray, kernel : np.ndarray) -> np.ndarray: """对输入图像应用二维卷积核(Valid卷积模式,无边缘填充)。 通过滑动窗口遍历输入图像,将每个窗口区域与卷积核逐元素相乘后求和, 生成卷积输出。输出尺寸小于输入尺寸。 参数: input: 输入的二维图像数组(通常为灰度图),形状为 (H, W)。 kernel: 二维卷积核数组,形状为 (h, w)。 返回: 卷积后的输出数组,形状为 (H - h + 1, W - w + 1)。 """ # 获取卷积核的高度和宽度 h = kernel.shape[0] w = kernel.shape[1] # 获取输入图像的高度和宽度 H = input.shape[0] W = input.shape[1] # 初始化输出数组(Valid卷积模式,无边缘填充) output = np.zeros((H-h+1, W-w+1)) # 滑动窗口遍历图像并计算卷积 for i in range(H-h+1): for j in range(W-w+1): # 截取当前窗口区域,与卷积核逐元素相乘后求和,得到当前位置的卷积值 output[i,j] = (input[i:i+h, j:j+w] * kernel).sum() return output def data2img(output1): """将卷积输出归一化并转换为图像标准类型。 先将数据归一化到 [0, 255] 范围,再调整亮度并裁剪到有效范围, 最后转换为 uint8 类型以适配图像显示。 参数: output1: 卷积后的输出数组。 返回: 处理后的图像数组,类型为 np.uint8,范围 [0, 255]。 """ # 归一化到 [0, 255]:先减去最小值,再除以最大值与最小值的差,最后乘以255 output1 = ((output1 - output1.min()) / (output1.max() - output1.min()) * 255) # 调整亮度(减去128)并裁剪到有效范围 [0, 255],转换为图像标准类型 uint8 output1 = np.clip(output1-128, 0, 255).astype(np.uint8) return output1 if __name__ == '__main__': # 读取图像文件并转换为灰度模式('L'表示8位灰度图) img = Image.open('cross.png').convert('L') # 将PIL图像转换为numpy数组 data = np.array(img) # 定义 Sobel X 算子(检测垂直方向边缘,对左右像素差异敏感) sobel_x = np.array([[1, 0, -1], [2, 0, -2], [1, 0, -1]]) # 定义反向 Sobel X 算子(仍检测垂直边缘,仅梯度方向相反) sobel_x2 = -sobel_x # 定义 Sobel Y 算子(检测水平方向边缘,对上下像素差异敏感,为 sobel_x 的转置) sobel_y = sobel_x.T # 定义反向 Sobel Y 算子(仍检测水平边缘,仅梯度方向相反) sobel_y2 = -sobel_y # 对输入图像应用 Sobel X 算子,得到垂直边缘的梯度响应 output1 = filter(data, sobel_x) # 对输入图像应用反向 Sobel X 算子,得到垂直边缘的反向梯度响应 output2 = filter(data, sobel_x2) # 对输入图像应用 Sobel Y 算子,得到水平边缘的梯度响应 output3 = filter(data, sobel_y) # 对输入图像应用反向 Sobel Y 算子,得到水平边缘的反向梯度响应 output4 = filter(data, sobel_y2) # 对每个方向的卷积输出先单独归一化并转换为图像类型,再进行叠加 # 注意:uint8类型直接相加可能会产生数值溢出(超过255) output = data2img(output1) + data2img(output2) + data2img(output3) + data2img(output4) # 将处理后的数组转换为PIL图像 out_img = Image.fromarray((output)) # 显示边缘检测结果图像 out_img.show()
接下来用代码实现一下sobel算子,得到的效果如下图所示:
from PIL import Image import numpy as np def filter(input : np.ndarray, kernel : np.ndarray) -> np.ndarray: """对输入图像应用二维卷积核(Valid卷积模式,无边缘填充)。 通过滑动窗口遍历输入图像,将每个窗口区域与卷积核逐元素相乘后求和, 生成卷积输出。输出尺寸小于输入尺寸。 参数: input: 输入的二维图像数组(通常为灰度图),形状为 (H, W)。 kernel: 二维卷积核数组,形状为 (h, w)。 返回: 卷积后的输出数组,形状为 (H - h + 1, W - w + 1)。 """ # 获取卷积核的高度和宽度 h = kernel.shape[0] w = kernel.shape[1] # 获取输入图像的高度和宽度 H = input.shape[0] W = input.shape[1] # 初始化输出数组(Valid卷积模式,无边缘填充) output = np.zeros((H-h+1, W-w+1)) # 滑动窗口遍历图像并计算卷积 for i in range(H-h+1): for j in range(W-w+1): # 截取当前窗口区域,与卷积核逐元素相乘后求和,得到当前位置的卷积值 output[i,j] = (input[i:i+h, j:j+w] * kernel).sum() return output def data2img(output1): """将卷积输出归一化并转换为图像标准类型。 先将数据归一化到 [0, 255] 范围,再调整亮度并裁剪到有效范围, 最后转换为 uint8 类型以适配图像显示。 参数: output1: 卷积后的输出数组。 返回: 处理后的图像数组,类型为 np.uint8,范围 [0, 255]。 """ # 归一化到 [0, 255]:先减去最小值,再除以最大值与最小值的差,最后乘以255 output1 = ((output1 - output1.min()) / (output1.max() - output1.min()) * 255) # 调整亮度(减去128)并裁剪到有效范围 [0, 255],转换为图像标准类型 uint8 output1 = np.clip(output1-128, 0, 255).astype(np.uint8) return output1 if __name__ == '__main__': # 读取图像文件并转换为灰度模式('L'表示8位灰度图) img = Image.open('./data/mihao.jpeg').convert('L') # 将PIL图像转换为numpy数组 data = np.array(img) # 定义 Sobel X 算子(检测垂直方向边缘,对左右像素差异敏感) sobel_x = np.array([[1, 0, -1], [2, 0, -2], [1, 0, -1]]) # 定义反向 Sobel X 算子(仍检测垂直边缘,仅梯度方向相反) sobel_x2 = -sobel_x # 定义 Sobel Y 算子(检测水平方向边缘,对上下像素差异敏感,为 sobel_x 的转置) sobel_y = sobel_x.T # 定义反向 Sobel Y 算子(仍检测水平边缘,仅梯度方向相反) sobel_y2 = -sobel_y # 对输入图像应用 Sobel X 算子,得到垂直边缘的梯度响应 output1 = filter(data, sobel_x) # 对输入图像应用反向 Sobel X 算子,得到垂直边缘的反向梯度响应 output2 = filter(data, sobel_x2) # 对输入图像应用 Sobel Y 算子,得到水平边缘的梯度响应 output3 = filter(data, sobel_y) # 对输入图像应用反向 Sobel Y 算子,得到水平边缘的反向梯度响应 output4 = filter(data, sobel_y2) # 对每个方向的卷积输出先单独归一化并转换为图像类型,再进行叠加 # 注意:uint8类型直接相加可能会产生数值溢出(超过255) output = data2img(output1) + data2img(output2) + data2img(output3) + data2img(output4) # 将处理后的数组转换为PIL图像 out_img = Image.fromarray((output)) # 显示边缘检测结果图像 out_img.show()
通过矩阵乘法加速执行单卷积核滤波(仅支持单通道输入):
仅支持处理单个卷积核:
from PIL import Image import numpy as np def filter2(input : np.ndarray, kernel : np.ndarray) -> np.ndarray: """通过矩阵乘法加速执行单卷积核滤波(仅支持单通道输入)。 将输入图像的滑动窗口展平为二维矩阵,与展平的卷积核进行矩阵乘法, 一次性完成所有卷积计算,实现比双重循环更高效的滤波。 参数: input: 输入的单通道图像数组,形状为 (H, W)。 kernel: 二维卷积核数组,形状为 (h, w)。 返回: 卷积后的输出数组,形状为 (H - h + 1, W - w + 1)。 """ # 获取卷积核尺寸 h = kernel.shape[0] w = kernel.shape[1] # 获取输入图像尺寸 H = input.shape[0] W = input.shape[1] # 初始化四维滑动窗口数组:(输出高度, 输出宽度, 核高度, 核宽度) # 用于存储原图中每个 h×w 滑动窗口的像素值 inputs = np.zeros((H-h+1, W-w+1, h, w)) for i in range(H-h+1): for j in range(W-w+1): # 将原图中当前位置的滑动窗口赋值到 inputs 的对应位置 inputs[i, j, :, :] = input[i:i+h, j:j+w] # 第一次展平:将每个滑动窗口的 h×w 像素展平为一维向量 # 形状从 (H-h+1, W-w+1, h, w) 变为 (H-h+1, W-w+1, h*w) inputs = inputs.reshape((H-h+1, W-w+1, -1)) # 第二次展平:将所有滑动窗口展平为二维矩阵 # 形状从 (H-h+1, W-w+1, h*w) 变为 (总窗口数, h*w) # 每一行代表一个滑动窗口的像素向量 inputs = inputs.reshape(-1, h*w) # 展平卷积核为列向量:(h*w, 1),便于矩阵乘法 kernel = kernel.reshape(-1, 1) # 矩阵乘法实现批量卷积:(总窗口数, h*w) @ (h*w, 1) = (总窗口数, 1) # 一次性计算所有滑动窗口与卷积核的点积 outputs = np.matmul(inputs, kernel) # 恢复输出形状为二维特征图:(H-h+1, W-w+1) outputs = outputs.reshape(H-h+1, W-w+1) return outputs def data2img(output1): """将卷积输出归一化并转换为图像标准类型。 先将数据归一化到 [0, 255] 范围,再调整亮度并裁剪到有效范围, 最后转换为 uint8 类型以适配图像显示。 参数: output1: 卷积后的输出数组。 返回: 处理后的图像数组,类型为 np.uint8,范围 [0, 255]。 """ output1 = ((output1 - output1.min()) / (output1.max() - output1.min()) * 255) output1 = np.clip(output1-128, 0, 255).astype(np.uint8) return output1 if __name__ == '__main__': # img = Image.open('./data/mihao.jpeg').convert('L') img = Image.open('cross.png').convert('L') data = np.array(img) sobel_x = np.array([[1, 0, -1], [2, 0, -2], [1, 0, -1]]) sobel_x2 = -sobel_x sobel_y = sobel_x.T sobel_y2 = -sobel_y output1 = filter2(data, sobel_x) output2 = filter2(data, sobel_x2) output3 = filter2(data, sobel_y) output4 = filter2(data, sobel_y2) output = data2img(output1) + data2img(output2) + data2img(output3) + data2img(output4) out_img = Image.fromarray((output)) out_img.show()
支持一次性批量处理多个卷积核:
from PIL import Image import numpy as np def filter2(input : np.ndarray, kernels : np.ndarray) -> np.ndarray: """对输入图像应用多个二维卷积核(基于矩阵乘法的高效实现)。 通过将图像窗口和卷积核展平为向量,利用矩阵乘法一次性完成所有卷积计算。 支持同时处理多个卷积核,输出多通道特征图。 参数: input: 输入的二维图像数组(通常为灰度图),形状为 (H, W)。 kernels: 三维卷积核数组,形状为 (channel, h, w), 其中 channel 为卷积核数量,h 和 w 为卷积核尺寸。 返回: 卷积后的输出数组,形状为 (H - h + 1, W - w + 1, channel), 最后一个维度对应不同卷积核的输出。 """ # 获取卷积核参数:数量、高度、宽度 channel = kernels.shape[0] h = kernels.shape[1] w = kernels.shape[2] # 获取输入图像尺寸 H = input.shape[0] W = input.shape[1] # 初始化输入窗口数组,将每个窗口存储为 (h, w) 的块 inputs = np.zeros((H-h+1, W-w+1, h, w)) for i in range(H-h+1): for j in range(W-w+1): inputs[i, j, :, :] = input[i:i+h, j:j+w] # 将窗口展平为向量:(H-h+1, W-w+1, h*w) inputs = inputs.reshape((H-h+1, W-w+1, -1)) # 进一步展平为二维数组:(N, h*w),其中 N 为窗口总数 inputs = inputs.reshape(-1, h*w) # 重塑卷积核:(channel, h*w) -> (h*w, channel),便于矩阵乘法 kernels = kernels.reshape(-1, h*w).T # 矩阵乘法实现批量卷积:(N, h*w) @ (h*w, channel) -> (N, channel) outputs = np.matmul(inputs, kernels) # 重塑输出为三维特征图:(H-h+1, W-w+1, channel) outputs = outputs.reshape(H-h+1, W-w+1, channel) return outputs def data2img(output1): """将卷积输出归一化并转换为图像标准类型。 先将数据归一化到 [0, 255] 范围,再调整亮度并裁剪到有效范围, 最后转换为 uint8 类型以适配图像显示。 参数: output1: 卷积后的输出数组。 返回: 处理后的图像数组,类型为 np.uint8,范围 [0, 255]。 """ output1 = ((output1 - output1.min()) / (output1.max() - output1.min()) * 255) output1 = np.clip(output1-128, 0, 255).astype(np.uint8) return output1 if __name__ == '__main__': # 读取图像并转换为灰度图 img = Image.open('./data/mihao.jpeg').convert('L') # img = Image.open('cross.png').convert('L') data = np.array(img) # 定义 Sobel 边缘检测卷积核 sobel_x = np.array([[1, 0, -1], [2, 0, -2], [1, 0, -1]]) sobel_x2 = -sobel_x sobel_y = sobel_x.T sobel_y2 = -sobel_y # 组合多个卷积核为形状 (4, 3, 3) 的数组 kernels = np.concatenate([ sobel_x.reshape(1, 3, 3), sobel_x2.reshape(1, 3, 3), sobel_y.reshape(1, 3, 3), sobel_y2.reshape(1, 3, 3) ], axis=0) # 应用多卷积核滤波 outputs = filter2(data, kernels) # 提取每个卷积核对应的输出 output1 = outputs[:, :, 0] output2 = outputs[:, :, 1] output3 = outputs[:, :, 2] output4 = outputs[:, :, 3] # 将所有输出归一化后叠加融合 output = data2img(output1) + data2img(output2) + data2img(output3) + data2img(output4) # 转换为图像并显示 out_img = Image.fromarray((output)) out_img.show()
上述滤波算子的计算过程与卷积层是一致的,区别在于卷积神经网络用自动学习的卷积核代替了人工设计的滤波算子。这种网络结构更适合提取图像特征,完成图像相关的学习任务。
再来回顾一下基于MLP的MNIST数字手写体识别实验。全连接层将每一个输入节点看作一种独立的特征,这一假定期望每类图片在各个像素点上的灰度特征尽量一致。如果不一致,则需要更多的训练数据,以及更复杂的网络架构来抓取非线性特征。思考一下:如果将MNIST图片的范围扩大, 并且将原始图片的内容摆放在不同位置上,此时MLP的分类效果会如何呢?