语义分割
语义分割将图片中的每个像素分类到对应的类别
通常来说现在的会议软件的背景虚化这个功能用的就是语义分割技术
无人车进行路面识别也是语义分割技术
语义分割 vs 实例分割
-
语义分割将图像划分为若干组成区域,这类问题的方法通常利用图像中像素之间的相关性。它在训练时不需要有关图像像素的标签信息,在预测时也无法保证分割出的区域具有我们希望得到的语义。以 上图的猫和狗的图像 作为输入,图像分割可能会将狗分为两个区域:一个覆盖以黑色为主的嘴和眼睛,另一个覆盖以黄色为主的其余部分身体。
-
实例分割也叫同时检测并分割(simultaneous detection and segmentation),它研究如何识别图像中各个目标实例的像素级区域。与语义分割不同,实例分割不仅需要区分语义,还要区分不同的目标实例。例如,如果图像中有两条狗,则实例分割需要区分像素属于的两条狗中的哪一条。
实例分割可以理解成目标检测的进化版本:
目标检测是把你每个目标检测出来,实例分割把你物体的边缘还检测出来
语义分割数据集
在语义分割里面最重要的数据集之一是Pascal VOC2012
Pascal是一个组织,VOC是一个竞赛,这个数据集是组织在12年做的一个竞赛数据集
为什么着重选择12年,因为可以认为后面的竞赛都是在12年的基础上做了修改
下载下来2GB的样子
下载&解压
%matplotlib inline
import os
import torch
import torchvision
from d2l import torch as d2l# 从网址上把原始数据集下载下来,解压放在文件夹里面
d2l.DATA_HUB['voc2012'] = (d2l.DATA_URL + 'VOCtrainval_11-May-2012.tar','4e443f8a2eca6b1dac8a6c57641b67dd40621a49')voc_dir = d2l.download_extract('voc2012', 'VOCdevkit/VOC2012')
将所有输入的图像和标签读入内存
一个很暴力的方法,通常很大的数据集不会这样做
进入数据集之后,我们可以看到数据集的不同组件。 ImageSets/Segmentation
路径包含用于训练和测试样本的文本文件,而JPEGImages
和SegmentationClass
路径分别存储着每个示例的输入图像和标签。
此处的标签也采用图像格式,其尺寸和它所标注的输入图像的尺寸相同。 此外,标签中颜色相同的像素属于同一个语义类别。
语义分割不同的地方在于需要对每个像素有一个label。最好的存储方法就是存成一张图片,但是如果存成JPEG会对图片有一些边缘的模糊;所以最好的方法就是存成一张PNG的图片
下面将read_voc_images
函数定义为将所有输入的图像和标签读入内存。
def read_voc_images(voc_dir, is_train=True):"""读取所有VOC图像并标注"""txt_fname = os.path.join(voc_dir, 'ImageSets', 'Segmentation','train.txt' if is_train else 'val.txt')mode = torchvision.io.image.ImageReadMode.RGBwith open(txt_fname, 'r') as f:images = f.read().split()features, labels = [], []for i, fname in enumerate(images):# 读取原始文件:JPEGfeatures.append(torchvision.io.read_image(os.path.join(voc_dir, 'JPEGImages', f'{fname}.jpg')))# 读取label:PNGlabels.append(torchvision.io.read_image(os.path.join(voc_dir, 'SegmentationClass' ,f'{fname}.png'), mode))return features, labelstrain_features, train_labels = read_voc_images(voc_dir, True)
可能会有朋友觉得把标签存成一张图很难接受。为了便于理解,我们可视化一下输入图像及其标签
在标签图像中,白色和黑色分别表示边框和背景,而其他颜色则对应不同的类别
n = 5
imgs = train_features[0:n] + train_labels[0:n]
# 画的时候需要把channel permute到最后
imgs = [img.permute(1,2,0) for img in imgs]
d2l.show_images(imgs, 2, n);
第一张图片:飞机(红色像素),飞机的边框(白色的像素),背景(黑色像素)
第二张图片:显示器(蓝色),背景(黑色),边界线(白色)
第三张图片:凳子(红色),猫(紫色),边界线(白色)
......
列举RGB颜色值和类名
我们接下来就需要知道每个RGB的数值表示的类是什么
通过下面定义的两个常量,我们可以方便地查找标签中每个像素的类索引。
数据集的readme会告诉我们这个信息
VOC_COLORMAP = [[0, 0, 0], [128, 0, 0], [0, 128, 0], [128, 128, 0],[0, 0, 128], [128, 0, 128], [0, 128, 128], [128, 128, 128],[64, 0, 0], [192, 0, 0], [64, 128, 0], [192, 128, 0],[64, 0, 128], [192, 0, 128], [64, 128, 128], [192, 128, 128],[0, 64, 0], [128, 64, 0], [0, 192, 0], [128, 192, 0],[0, 64, 128]]VOC_CLASSES = ['background', 'aeroplane', 'bicycle', 'bird', 'boat','bottle', 'bus', 'car', 'cat', 'chair', 'cow','diningtable', 'dog', 'horse', 'motorbike', 'person','potted plant', 'sheep', 'sofa', 'train', 'tv/monitor']
查询标签中每个像素的类索引
做两个辅助函数来帮助我们从RGB的值换算成类别标号,以及把标号换算会RGB值
下面定义了voc_colormap2label
函数来构建从上述RGB颜色值到类别索引的映射,而voc_label_indices
函数将RGB值映射到在Pascal VOC2012数据集中的类别索引。
没那么简单,如果用简单的python来做,会发现性能很差。因为图片下来几万个像素,一个个去算是一件很慢的事情
def voc_colormap2label():"""构建从RGB到VOC类别索引的映射"""# 先开一个非常大的tensorcolormap2label = torch.zeros(256 ** 3, dtype=torch.long)for i, colormap in enumerate(VOC_COLORMAP):# 乘258可以理解成左移8位(即做了一个256进制,因为像素最多是从0-255)# 通过这样计算,把label换算成一个整型(换算成10进制),再把tensor中刚刚算出来的index对应的数值换成icolormap2label[(colormap[0] * 256 + colormap[1]) * 256 + colormap[2]] = i# 最终返回一个类似于字典一样的东西return colormap2labeldef voc_label_indices(colormap, colormap2label):"""将VOC标签中的RGB值映射到它们的类别索引"""colormap = colormap.permute(1, 2, 0).numpy().astype('int32')idx = ((colormap[:, :, 0] * 256 + colormap[:, :, 1]) * 256+ colormap[:, :, 2])return colormap2label[idx]
预处理数据
在图片增广技术介绍中,我们通过再缩放图像使其符合模型的输入形状。在语义分割中,这样做需要将预测的像素类别重新映射回原始尺寸的输入图像。 这样的映射可能不够精确,尤其在不同语义的分割区域。 为了避免这个问题,我们将图像裁剪为固定尺寸,而不是再缩放。 具体来说,我们使用图像增广中的随机裁剪,裁剪输入图像和标签的相同区域。
def voc_rand_crop(feature, label, height, width):"""随机裁剪特征和标签图像"""# get_params():可以返回裁剪的边框rect = torchvision.transforms.RandomCrop.get_params(feature, (height, width))# 调用真正的crop()来裁剪(*rect:把框的四个坐标展开,用于裁剪)feature = torchvision.transforms.functional.crop(feature, *rect)# 同样的道理对标号也处理一下label = torchvision.transforms.functional.crop(label, *rect)return feature, labelimgs = []
# n在上面的代码里赋值为5
# 即随机做了5次的RandomCrop
for _ in range(n):imgs += voc_rand_crop(train_features[0], train_labels[0], 200, 300)# 可视化结果
imgs = [img.permute(1, 2, 0) for img in imgs]
d2l.show_images(imgs[::2] + imgs[1::2], 2, n);
自定义语义分割数据集类
我们通过继承高级API提供的Dataset
类,自定义了一个语义分割数据集类VOCSegDataset
。
通过实现__getitem__
函数,我们可以任意访问数据集中索引为idx
的输入图像及其每个像素的类别索引。
由于数据集中有些图像的尺寸可能小于随机裁剪所指定的输出尺寸,这些样本可以通过自定义的filter
函数移除掉。
此外,我们还定义了normalize_image
函数,从而对输入图像的RGB三个通道的值分别做标准化。
class VOCSegDataset(torch.utils.data.Dataset):"""一个用于加载VOC数据集的自定义数据集"""def __init__(self, is_train, crop_size, voc_dir):# 做一次RGB三个channal的均值方差normalize(均值方差是从ImageNet拿来的,因为后面想用ImageNet那个模型)self.transform = torchvision.transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])# 存一下crop_sizeself.crop_size = crop_size# 读入数据、标签features, labels = read_voc_images(voc_dir, is_train=is_train)# 先filter一下# 然后normalizeself.features = [self.normalize_image(feature)for feature in self.filter(features)]self.labels = self.filter(labels)# 构造好字典self.colormap2label = voc_colormap2label()print('read ' + str(len(self.features)) + ' examples')# 把RGB规范化一下def normalize_image(self, img):return self.transform(img.float() / 255)# 筛掉一些图片原本尺寸比我的裁剪尺寸还要小的数据def filter(self, imgs):return [img for img in imgs if (img.shape[1] >= self.crop_size[0] andimg.shape[2] >= self.crop_size[1])]# __getitem__:每次返回第i个样本要干什么事情def __getitem__(self, idx):# 做一次RandomCrop()feature, label = voc_rand_crop(self.features[idx], self.labels[idx],*self.crop_size)# 把RGB换成标号return (feature, voc_label_indices(label, self.colormap2label))def __len__(self):return len(self.features)
读取数据集
我们通过自定义的VOCSegDataset
类来分别创建训练集和测试集的实例。
假设我们指定随机裁剪的输出图像的形状为320×480, 下面我们可以查看训练集和测试集所保留的样本个数。
crop_size = (320, 480)
voc_train = VOCSegDataset(True, crop_size, voc_dir)
voc_test = VOCSegDataset(False, crop_size, voc_dir)# 输出
read 1114 examples
read 1078 examples
可以看出不是一个很大的数据集。
通常来说 语义分割 的数据集会比 图片分类&目标检测 的数据集小很多,因为标起来很贵!!!!
举例子:找人标一张图片分类,一分钱两分钱;标一个目标检测,一毛钱;标一个图片分割就得几块钱
所以现在语义分割的数据集,主要集中在无人车那块(无人车大家都不缺钱......标的数据相对来说多一些)
设批量大小为64,我们定义训练集的迭代器。 打印第一个小批量的形状会发现:与图像分类或目标检测不同,这里的标签是一个三维数组。
batch_size = 64
train_iter = torch.utils.data.DataLoader(voc_train, batch_size, shuffle=True,drop_last=True,num_workers=d2l.get_dataloader_workers())
for X, Y in train_iter:print(X.shape)print(Y.shape)break# 输出
torch.Size([64, 3, 320, 480]) #(batch_size, channel(RGB), 高, 宽)
torch.Size([64, 320, 480]) #这里已经换算成了标号的整型,所以没有了3这个维度
整合所有组件
最后,我们定义以下load_data_voc
函数来下载并读取Pascal VOC2012语义分割数据集。
它返回训练集和测试集的数据迭代器。
def load_data_voc(batch_size, crop_size):"""加载VOC语义分割数据集"""voc_dir = d2l.download_extract('voc2012', os.path.join('VOCdevkit', 'VOC2012'))num_workers = d2l.get_dataloader_workers()train_iter = torch.utils.data.DataLoader(VOCSegDataset(True, crop_size, voc_dir), batch_size,shuffle=True, drop_last=True, num_workers=num_workers)test_iter = torch.utils.data.DataLoader(VOCSegDataset(False, crop_size, voc_dir), batch_size,drop_last=True, num_workers=num_workers)return train_iter, test_iter
小结
-
语义分割通过将图像划分为属于不同语义类别的区域,来识别并理解图像中像素级别的内容。
-
语义分割的一个重要的数据集叫做Pascal VOC2012。
-
由于语义分割的输入图像和标签在像素上一一对应,输入图像会被随机裁剪为固定尺寸而不是缩放。
转置卷积
到目前为止,我们所见到的卷积神经网络层,例如 卷积层 和 Pooling层,通常会减少下采样输入图像的空间维度(高和宽)。
然而如果输入和输出图像的空间维度相同,在以像素级分类的语义分割中将会很方便。 例如,输出像素所处的通道维可以保有输入像素在同一位置上的分类结果。
为了实现这一点,尤其是在空间维度被卷积神经网络层缩小后,我们可以使用另一种类型的卷积神经网络层,它可以增加上采样中间层特征图的空间维度。即转置卷积(transposed convolution)用于逆转下采样导致的空间尺寸减小。
基本操作
- 卷积不会增大输入的高宽,通常要么不变、要么减半
- 转置卷积则可以用来增大输入高宽
当然你可以padding,但是如果你padding了很多0,输出也是0
其实无法很有效的增加你的输出
让我们暂时忽略通道,从基本的转置卷积开始,设步幅为1且没有填充。
假设我们有一个 × 的输入张量和一个 x 的卷积核。 以步幅为1滑动卷积核窗口,每行n次,每列次,共产生个中间结果。每个中间结果都是一个(+−1)×(+−1)的张量,初始化为0。
为了计算每个中间张量,输入张量中的每个元素都要乘以卷积核,从而使所得的×张量替换中间张量的一部分。 请注意,每个中间张量被替换部分的位置与输入张量中元素的位置相对应。最后,所有中间结果相加以获得最终结果。
有点感觉跟卷积反过来了的操作:
卷积:核大小的输入区域和核相乘再相加写进每个单个的格子里面;
转置卷积:每单个元素与核的每个元素相乘,写进核大小的格子区域;
(转置卷积的padding是在输出上面padding,等下我们在代码上面将padding更好理解一些)
为什么称之为“转置”
-
对于卷积 Y = X ⭐ W
-
可以对 W 构造一个 V,使得卷积等价于矩阵乘法 =
-
这里,是Y,X对应的向量版本
-
-
转置卷积则等价于 =
-
如果卷积将输入从(h,w)变成了(,)
-
同样超参数的转置卷积则从(,)变成(h,w)
-
代码实现
import torch
from torch import nn
from d2l import torch as d2l
实现基本的转置卷积运算
我们可以对输入矩阵X
和卷积核矩阵K
实现基本的转置卷积运算trans_conv
def trans_conv(X, K):h, w = K.shapeY = torch.zeros((X.shape[0] + h - 1, X.shape[1] + w - 1))for i in range(X.shape[0]):for j in range(X.shape[1]):Y[i: i + h, j: j + w] += X[i, j] * Kreturn Y# 验证上述实现
X = torch.tensor([[0.0, 1.0], [2.0, 3.0]])
K = torch.tensor([[0.0, 1.0], [2.0, 3.0]])
trans_conv(X, K)# 输出
tensor([[ 0., 0., 1.],[ 0., 4., 6.],[ 4., 12., 9.]])
与通过卷积核“减少”输入元素的常规卷积相比,转置卷积通过卷积核“广播”输入元素,从而产生大于输入的输出。此实现是基本的二维转置卷积运算。
使用高级API获得相同的结果
当输入X
和卷积核K
都是四维张量时,我们可以使用高级API获得相同的结果。
X, K = X.reshape(1, 1, 2, 2), K.reshape(1, 1, 2, 2)
tconv = nn.ConvTranspose2d(1, 1, kernel_size=2, bias=False)
tconv.weight.data = K
tconv(X)# 输出
tensor([[[[ 0., 0., 1.],[ 0., 4., 6.],[ 4., 12., 9.]]]], grad_fn=<ConvolutionBackward0>)
填充、步幅和多通道
填充
与常规卷积不同,在转置卷积中,填充被应用于的输出(常规卷积将填充应用于输入)。
例如,当将高和宽两侧的填充数指定为1时,转置卷积的输出中将删除第一和最后的行与列。
tconv = nn.ConvTranspose2d(1, 1, kernel_size=2, padding=1, bias=False)
tconv.weight.data = K
tconv(X)# 输出
tensor([[[[4.]]]], grad_fn=<ConvolutionBackward0>)
步幅
在转置卷积中,步幅被指定为中间结果(输出),而不是输入。
将步幅从1更改为2会增加中间张量的高和权重
tconv = nn.ConvTranspose2d(1, 1, kernel_size=2, stride=2, bias=False)
tconv.weight.data = K
tconv(X)# 输出
tensor([[[[0., 0., 0., 1.],[0., 0., 2., 3.],[0., 2., 0., 3.],[4., 6., 6., 9.]]]], grad_fn=<ConvolutionBackward0>)
多通道
对于多个输入和输出通道,转置卷积与常规卷积以相同方式运作。
假设输入有个通道,且转置卷积为每个输入通道分配了一个 x 的卷积核张量。 当指定多个输出通道时,每个输出通道将有一个× x 的卷积核。
同样,如果我们将 X 代入卷积层 f 来输出Y=f(X),并创建一个与 f 具有相同的超参数、但输出通道数量是 X 中通道数的转置卷积层 g,那么 g(Y) 的形状将与 X 相同。 下面的示例可以解释这一点。
X = torch.rand(size=(1, 10, 16, 16))
conv = nn.Conv2d(10, 20, kernel_size=5, padding=2, stride=3)
tconv = nn.ConvTranspose2d(20, 10, kernel_size=5, padding=2, stride=3)
tconv(conv(X)).shape == X.shape# 输出
True
注意我们这里说的是shape!是形状!!而不是完全还原卷积,和内在的值没关系的!!
它确实可以通过学习,来还原卷积,但是转置卷积不是用来干这个的!!
也不属于上采样!!!