本篇文章为上次发的FCN网络文章的续篇,将会带你了解PASCAL VOC数据集,并使用paddle接口搭建起数据接口,供语义分割网络训练及测试(FCN)。
本文代码全过程已公开在百度ai studio平台中,链接:读取PSACAL VOC,训练FCN全流程! - 飞桨AI Studio
点击启动环境, 选择相应GPU,即可运行代码!
下载数据集及简介
PASCAL VOC官网:The PASCAL Visual Object Classes Challenge 2012 (VOC2012)
点击链接后呈现出来的是该页面
此时,我们往下滑,直到Development Kit,点击蓝色字的training/validation data
,即可下载数据集,大小为2GB。
指定路径,下载成功后,解压数据。
其目录结构如下:
VOCdevkit
└── VOC2012
├── Annotations 所有的图像标注信息(XML文件)
├── ImageSets
│ ├── Action 人的行为动作图像信息
│ ├── Layout 人的各个部位图像信息
│ │
│ ├── Main 目标检测分类图像信息
│ │ ├── train.txt 训练集(5717)
│ │ ├── val.txt 验证集(5823)
│ │ └── trainval.txt 训练集+验证集(11540)
│ │
│ └── Segmentation 目标分割图像信息
│ ├── train.txt 训练集(1464)
│ ├── val.txt 验证集(1449)
│ └── trainval.txt 训练集+验证集(2913)
│
├── JPEGImages 所有图像文件
├── SegmentationClass 语义分割png图(基于类别)
└── SegmentationObject 实例分割png图(基于目标)
此处只介绍跟语义分割有关的目录
ImageSets:存放相关图像信息。其下子目录Segmentation的train.txt,val.txt内每一行记录了相应训练集,验证集对应的图片名。
JPEGImages:所有图像文件,jpg格式。是网络训练时的image图像。
SegmentationClass:语义分割的标注图像文件,png格式。是网络训练时的mask(标签)。
读取图片,了解大致信息
如需运行本例代码,建议读者创建ipynb文件运行,我们使用Pillow库进行图片的读取,选取2007_002293该图片
定义该图片的图像路径和标签图像路径(具体路径以实际为准)
from PIL import Image
rt = r"E:\dataset\VOCtrainval_11-May-2012\VOCdevkit\VOC2012"
image = osp.join(rt, r"JPEGImages\2007_002293.jpg")
label = osp.join(rt, r"SegmentationClass\2007_002293.png")
此时使用Image.open接口打开图像
pil_image = Image.open(image)
pil_image # 此时可以看见一张图片
同样的,我们打开其标签图像文件
此处查看其size属性,看看他们的尺寸,对照上图,可知宽度为332,高度为500(PIL图像格式:WH)
import numpy as np
image_array = np.array(pil_image) # PIL转ndarray格式为HWC
label_array = np.array(pil_label)
print(image_array.shape)
print(label_array.shape)
print(image_array.dtype)
print(label_array.dtype)
# 输出为
# (500, 332, 3)
# (500, 332)
# uint8
# uint8
此处可以看到图像尺寸为(500, 332, 3),因为它是RGB格式图像,三通道,而标签文件只有一个通道
关于图像数据格式
打断一下,相信有细心的小伙伴发现了,PIL中图像显示的size为(332, 500),而numpy中图像显示的shape为(500, 332, x)。这是各个库间图像尺寸的不同显示。以下为总结(相当重要):
PIL图像:[W, H](并不会显示通道数)
numpy array图像:[H, W, C]
Tensor图像:[C, H, W]
而深度学习框架里一些处理数据的接口会根据以上图片形式为默认规则来进行处理,因此需要多加留意
又因为我目前在学习医学影像分割,还会涉及到使用SimpleITK读取医学影像,要留意其Image格式([H, W, C])转换至numpy array时会变为[C, H, W],需要手动使用np.transpose方法将顺序改变为numpy array下默认的[H, W, C],否则进行数据处理时会出现很多不明所以的bug
此时使用np.unique
方法查看两个array的取值情况。可以看到图像文件的像素取值从0-255都有。
重要的来了!np.unique
查看label_array。从下图可以看到,只有三种取值,是0,15,255。
什么意思呢?我们回到刚刚打开的那张标签图像文件。
0代表了背景,15为人这个类的类别标签,255为其间比较难分辨的像素点。后续图像处理时,我们需要以下这种方式对图片进行处理。
# 各种标签所对应的颜色
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]]
cm2lbl = np.zeros(256**3) # 创建一个映射关系,三个通道上像素值分别为多少是哪个类
# 枚举的时候i是下标,cm是一个三元组,分别标记了RGB值
for i, cm in enumerate(colormap):
cm2lbl[(cm[0]*256 + cm[1])*256 + cm[2]] = i
# 将标签按照RGB值填入对应类别的下标信息
def image2label(im):
data = np.array(im, dtype="int32")
idx = (data[:,:,0]*256 + data[:,:,1])*256 + data[:,:,2]
return np.array(cm2lbl[idx], dtype="int64")
此时,打开图片时要以convert("RGB")。,以下为示例
pil_label = Image.open(label).convert("RGB")
trans_img = image2label(pil_label)
print(np.unique(trans_img))
plt.imshow(trans_img)
plt.show()
有人就会问:直接open打开图像,然后直接给255的像素值赋0不就好了吗?
这个处理方式我之前也试过,但发现这种处理方式下,我网络根本训练不到正常的水平,还挺玄学的(?)
了解完大致信息后,正式进入下一部分了。
语义分割数据集搭建
注:涉及到深度学习的,下文均使用paddle深度学习框架进行讲解
由于深度学习训练时,会使用DataLoader读取一批次的数据,而DataLoader会要求输入的数据形状相同。而不幸的是,PASCAL VOC2012数据集里面的图片的尺寸并不是一样的,那怎么办呢?我们有两种方法:
- 将图片放大(resize方法)
- 对图片进行裁剪(crop方法)
语义分割对像素精度要求很高,而将图片缩放的resize方法使用了插值方法来重新计算像素,这种计算会带来不必要的像素增加,从而带来误差,从而影响模型的性能。
因此我们更应使用裁剪方法,将图片缩小到统一尺寸,方便训练。
但有个点需要注意,语义分割的标签(以下称为mask)是与原始图像(以下称为image)对应的,因此image和mask需要同时进行数据变换,保持一致性。
如何同步对image和mask进行数据变换?
怎么做到这一点呢?一个简单却又有效的方法是将image和mask在通道维度(C)拼接起来,传入变换的接口进行操作,得到变换后的数据,再分别取出变换后的image和mask。以下为示例:
expanded_label = np.expand_dims(label_array, axis=2)
# 扩展一个C维,效果等同于label_array[:, :, np.newaxis]
print(expanded_label.shape)
concated_data = np.concatenate((image_array, expanded_label), axis=2)
print(concated_data.shape)
拼接起来后,我们使用paddle中提供的图像数据变换接口,进行操作。
import paddle.vision.transforms as T
trans = T.RandomCrop(size=(224, 224)) # 此处使用随机裁剪,裁剪至(224, 224)大小
crop_array = trans(concated_data) # 对数据进行变换
Image.fromarray(crop_array[:, :, :3].astype("uint8"))
# 取出C维中前3个通道(image部分)进行展示
# astype("uint8")是方便转为PIL图像格式进行展示
# Image.fromarray将ndarray格式转为PIL图像格式
plt.imshow(crop_array[:, :, 3].astype("uint8")) # 取出C维中最后一个通道(label部分)
plt.show()
变换后,效果如下图。image和mask确实同步进行了裁剪。
正式搭建我们的数据集
使用paddle深度学习框架进行网络训练时,网络接受和输入的是批次的数据(数据形式为[B, C, H, W]),而批次的数据是通过paddle.io.DataLoader
读取的,而该接口要读取的数据集要继承于paddle.io.Dataset
类,并且必须重写其中的三个方法:
- __init__(self, ...):类中的初始化方法,记录数据集相关信息,进行一定的数据处理工作
- __getitem__(self, idx):根据给定索引获取数据集中指定样本,
paddle.io.DataLoader
中需要使用此函数通过下标获取样本。 - __len__(self):返回数据集样本个数,
paddle.io.BatchSampler
中需要样本个数生成下标序列。
于是我们编写以下数据集
import paddle
import paddle.vision.transforms as T
import os
import os.path as osp
class VOCSegData(paddle.io.Dataset):
def __init__(self, voc_root: str, train: bool = True, crop_size: tuple = (320, 480)):
super(VOCSegData, self).__init__()
self.crop_size = crop_size # 传入的为[H, W]
txt_name = None
if train is True:
txt_name = "train.txt"
else:
txt_name = "val.txt"
image_dir = osp.join(voc_root, 'JPEGImages')
mask_dir = osp.join(voc_root, 'SegmentationClass')
txt_path = osp.join(voc_root, "ImageSets", "Segmentation", txt_name)
f = open(txt_path)
contents = f.readlines()
images = list(map(lambda x: osp.join(image_dir, x.strip() + ".jpg"), contents))
masks = list(map(lambda x: osp.join(mask_dir, x.strip() + ".png"), contents))
self.images = self.filter_size(images)
self.masks = self.filter_size(masks)
print("Read "+ str(len(self.images)), " images. Filter {}".format(len(images)-len(self.images)))
assert (len(self.images) == len(self.masks))
self.trans_both = T.Compose([
T.RandomCrop(self.crop_size),
T.RandomHorizontalFlip(prob=0.5),
T.RandomVerticalFlip(prob=0.5)
]) # 对image和mask共同的变换操作
self.trans_img = T.Compose([
T.ToTensor(),
T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])
def filter_size(self, images_path):
imgs = []
crop_size = self.crop_size[::-1] # PIL图像尺寸为W,H,方便比较
for image_path in images_path:
pil_image = Image.open(image_path)
if pil_image.size[0] > crop_size[0] and pil_image.size[1] > crop_size[1]:
imgs.append(image_path)
else:
continue
return imgs
def __getitem__(self, idx):
# 获取变换操作
trans_both = self.trans_both
trans_img = self.trans_img
# 读取数据
image = np.array(Image.open(self.images[idx]))
mask = image2label(Image.open(self.masks[idx]).convert("RGB")) # 处理255像素值
mask = np.expand_dims(mask, axis=2)
concated_data = np.concatenate((image, mask), axis=2)
trans_array = trans_both(concated_data)
# 取出变换后的数据
image = trans_array[:, :, :3]
mask = trans_array[:, :, 3]
image = trans_img(image.astype("uint8")) # 输出: shape:[C, H, W], dtype:float32
mask = paddle.to_tensor(mask, dtype="int64")
return image, mask
def __len__(self):
return len(self.images)
此时,通过编写的数据集接口,我们读取一个数据集看看image和mask的形式
voc_root = r"work/VOCdevkit/VOC2012"
crop_size = (320, 480)
train_data = VOCSegData(voc_root=voc_root, train=True, crop_size=crop_size)
img, mask = train_data[0]
可以看到image和mask都裁剪到了(320, 480)。image中,原本0-255大小的像素值(通过T.ToTensor
接口对uint8数据的处理和T.Normalize
对数据的标准化处理)缩小到了一个较小的范围,有利于网络的训练。mask中,255像素值被处理掉。
展示一下DataLoader读取的数据形式
其中,batch_size指定批次大小,shuffle=True意味着打乱数据顺序
于是读取数据,生成train_loader和val_loader
voc_root = r"work/VOCdevkit/VOC2012"
crop_size = (320, 480)
train_data = VOCSegData(voc_root=voc_root, train=True, crop_size=crop_size)
val_data = VOCSegData(voc_root=voc_root, train=False, crop_size=crop_size)
train_loader = paddle.io.DataLoader(train_data, batch_size=32, shuffle=True)
val_loader = paddle.io.DataLoader(val_data, batch_size=32)
搭建网络模型
由于此篇为上篇FCN网络的续篇,已在上篇中详细解释过网络搭建,因此在此不过多赘述网络细节,直接贴代码
def bilinear_kernel(in_channels, out_channels, kernel_size):
factor = (kernel_size + 1) // 2
if kernel_size % 2 == 1:
center = factor - 1
else:
center = factor - 0.5
og = np.ogrid[:kernel_size, :kernel_size]
filt = (1 - abs(og[0] - center) / factor) * \
(1 - abs(og[1] - center) / factor)
weight = np.zeros((in_channels, out_channels, kernel_size, kernel_size),
dtype='float32')
weight[range(in_channels), range(out_channels), :, :] = filt
return paddle.to_tensor(weight, dtype="float32")
class FCN8s(nn.Layer):
def __init__(self, num_classes=21):
super(FCN8s, self).__init__()
# num_classes要包含背景,如果是PASCAL VOC则是20+1
self.layer1 = self.make_block(num=2, in_channels=3, out_channels=64)
self.layer2 = self.make_block(num=2, in_channels=64, out_channels=128)
self.layer3 = self.make_block(num=3, in_channels=128, out_channels=256)
self.layer4 = self.make_block(num=3, in_channels=256, out_channels=512)
self.layer5 = self.make_block(num=3, in_channels=512, out_channels=512)
# 下面的两个卷积层代替了原来VGG网络的全连接层(原本为4096,此处可根据gpu性能,设置为其他数,此处设为2048)
mid_channels = 2048
self.conv6 = nn.Conv2D(in_channels=512, out_channels=mid_channels, kernel_size=7, padding=3)
self.conv7 = nn.Conv2D(in_channels=mid_channels, out_channels=mid_channels, kernel_size=1)
# 3个1*1的卷积,用于改变pool的通道数,为了后续融合语义信息
self.score32 = nn.Conv2D(in_channels=mid_channels, out_channels=num_classes, kernel_size=1)
self.score16 = nn.Conv2D(in_channels=512, out_channels=num_classes, kernel_size=1)
self.score8 = nn.Conv2D(in_channels=256, out_channels=num_classes, kernel_size=1)
# 3个转置卷积,用于扩大特征图
# 若参数kernel_size:stride:padding=4:2:1,此时stride为扩大倍数
# 需要为转置卷积初始化weight权重参数,否则很难收敛,且准确率低
weight_8x = paddle.ParamAttr(
initializer=paddle.nn.initializer.Assign(bilinear_kernel(num_classes, num_classes, 16))
)
self.up_sample8x = nn.Conv2DTranspose(
in_channels=num_classes,
out_channels=num_classes,
kernel_size=16, stride=8, padding=4,
weight_attr=weight_8x
)
weight_16x = paddle.ParamAttr(
initializer=paddle.nn.initializer.Assign(bilinear_kernel(num_classes, num_classes, 4))
)
self.up_sample16x = nn.Conv2DTranspose(
in_channels=num_classes,
out_channels=num_classes,
kernel_size=4, stride=2, padding=1,
weight_attr=weight_16x
)
weight_32x = paddle.ParamAttr(
initializer=paddle.nn.initializer.Assign(bilinear_kernel(num_classes, num_classes, 4))
)
self.up_sample32x = nn.Conv2DTranspose(
in_channels=num_classes,
out_channels=num_classes,
kernel_size=4, stride=2, padding=1,
weight_attr=weight_32x
)
def make_block(self, num: int, in_channels: int, out_channels: int, padding=1):
"""根据传入的in,out和需要构建的块数搭建网络块"""
blocks = []
blocks.append(nn.Conv2D(in_channels=in_channels, out_channels=out_channels, kernel_size=3, padding=padding))
blocks.append(nn.ReLU())
for i in range(num-1):
blocks.append(nn.Conv2D(in_channels=out_channels, out_channels=out_channels, kernel_size=3, padding=1))
blocks.append(nn.ReLU())
blocks.append(nn.MaxPool2D(kernel_size=2, stride=2, ceil_mode=True))
return nn.Sequential(*blocks)
def forward(self, inputs):
# inputs [3, 1, 1],以原始输入图像尺寸为1
# features
out = self.layer1(inputs) # [64, 1/2, 1/2],论文这里实际上有padding100,是为了接受各种图片大小
out = self.layer2(out) # [128, 1/4, 1/4]
pool3 = self.layer3(out) # [256, 1/8, 1/8]
pool4 = self.layer4(pool3) # [512, 1/16, 1/16]
pool5 = self.layer5(pool4) # [512, 1/32, 1/32]
x = self.conv6(pool5) # [mid_channels, 1/32, 1/32]
x = self.conv7(x) # [mid_channels, 1/32, 1/32]
score32 = self.score32(x) # [num_classes, 1/32, 1/32]
up_pool16 = self.up_sample32x(score32) # [num_classes, 1/16, 1/16]
score16 = self.score16(pool4) # [num_classes, 1/16, 1/16]
fuse_16 = paddle.add(up_pool16, score16)
up_pool8 = self.up_sample16x(fuse_16) # [num_classes, 1/8, 1/8]
score8 = self.score8(pool3) # [num_classes, 1/8, 1/8]
fuse_8 = paddle.add(up_pool8, score8)
heatmap = self.up_sample8x(fuse_8)
return heatmap
我们可以使用paddle.summary接口查看输入数据在网络中的变化情况及相关参数量
model = FCN8s(num_classes=21)
paddle.summary(model, (5, 3, 320, 480))
训练验证
定义损失函数
loss_fn = nn.NLLLoss()
此处选择的损失函数为NLLLoss,该损失函数接受的输入数据形式如下。
性能指标
对于一个深度学习模型,肯定需要有评价指标,否则怎么知道该模型好不好呢?
上篇文章中提到FCN论文是通过miou指标与其他模型比较的,而语义分割中确实也多以miou指标来评价模型性能。那么这个指标要怎么计算呢?通过混淆矩阵!以下为代码(参考自github上fcn实现)
# 计算混淆矩阵
def _fast_hist(label_true, label_pred, n_class):
# mask在和label_true相对应的索引的位置上填入true或者false
# label_true[mask]会把mask中索引为true的元素输出
mask = (label_true >= 0) & (label_true < n_class)
# np.bincount()会给出索引对应的元素个数
"""
hist是一个混淆矩阵
hist是一个二维数组,可以写成hist[label_true][label_pred]的形式
最后得到的这个数组的意义就是行下标表示的类别预测成列下标类别的数量
比如hist[0][1]就表示类别为1的像素点被预测成类别为0的数量
对角线上就是预测正确的像素点个数
n_class * label_true[mask].astype(int) + label_pred[mask]计算得到的是二维数组元素
变成一位数组元素的时候的地址取值(每个元素大小为1),返回的是一个numpy的list,然后
np.bincount就可以计算各中取值的个数
"""
hist = np.bincount(
n_class * label_true[mask].astype(int) +
label_pred[mask], minlength=n_class ** 2).reshape(n_class, n_class)
return hist
"""
label_trues 正确的标签值
label_preds 模型输出的标签值
n_class 数据集中的分类数
"""
def label_accuracy_score(label_trues, label_preds, n_class):
"""Returns accuracy score evaluation result.
- overall accuracy
- mean accuracy
- mean IU
- fwavacc
"""
hist = np.zeros((n_class, n_class))
# 一个batch里面可能有多个数据
# 通过迭代器将一个个数据进行计算
for lt, lp in zip(label_trues, label_preds):
# numpy.ndarray.flatten将numpy对象拉成1维
hist += _fast_hist(lt.flatten(), lp.flatten(), n_class)
# np.diag(a)假如a是一个二维矩阵,那么会输出矩阵的对角线元素
# np.sum()可以计算出所有元素的和。如果axis=1,则表示按行相加
"""
acc是全局准确率 = 预测正确的像素点个数/总的像素点个数
acc_cls是预测的每一类别的准确率(比如第0行是预测的类别为0的准确率),然后求平均
iu是交并比,mean_iu就是对iu求了一个平均
"""
acc = np.diag(hist).sum() / hist.sum()
acc_cls = np.diag(hist) / hist.sum(axis=1)
# nanmean会自动忽略nan的元素求平均
acc_cls = np.nanmean(acc_cls)
iu = np.diag(hist) / (hist.sum(axis=1) + hist.sum(axis=0) - np.diag(hist))
mean_iu = np.nanmean(iu)
return acc, acc_cls, mean_iu
输入标签的mask和预测的mask即可计算相关性能指标,预测mask如何得到会在训练验证部分讲解
因篇幅限制,具体原理可参考霹雳吧WZ的视频讲解的第四节视频,此处不细讲
定义训练验证函数
num_classes = 21
def train_val(net, opt, loss_fn, train_loader, val_loader, epochs=30, save_path="./params", log_path="log.txt", param_name="miou{}.pdparams"):
if not osp.exists(save_path):
os.mkdir(save_path)
best_miou = 0.0
for epoch in range(epochs):
train_bar = tqdm(train_loader)
f = open(log_path, mode='a')
# 训练阶段
net.train()
for batch_id, data in enumerate(train_bar):
imgs, true_masks = data
out = net(imgs)
out = F.log_softmax(out, axis=1)
loss = loss_fn(out, true_masks)
loss.backward() # 反向传播
opt.step()
opt.clear_grad() # 梯度清零
train_log_info = "Epoch [{}/{}], batch_id {}, loss {}".format(
epoch, epochs, batch_id, loss.item()
)
train_bar.desc = train_log_info
f.write(train_log_info+'\n')
f.close() # 将内容写入
_eval_loss = 0.0
_eval_acc = 0.0
_eval_acc_cls = 0.0
_eval_mean_iu = 0.0
f = open(log_path, mode='a')
# 验证阶段
net.eval()
val_bar = tqdm(val_loader)
for data in val_bar:
with paddle.no_grad():
imgs, true_masks = data
out = net(imgs)
out = F.log_softmax(out, axis=1)
loss = loss_fn(out, true_masks)
_eval_loss += loss.item()
label_pred = out.argmax(axis=1).numpy() # [B, H, W]
label_true = paddle.squeeze(true_masks).numpy() # [B, H, W]
for lbt, lbp in zip(label_pred, label_true):
acc, acc_cls, mean_iu = label_accuracy_score(lbt, lbp, num_classes)
_eval_acc += acc
_eval_acc_cls += acc_cls
_eval_mean_iu += mean_iu
miou = _eval_mean_iu / len(val_data)
if miou > best_miou:
best_miou = miou
paddle.save(net.state_dict(), osp.join(save_path, param_name.format(int(best_miou*100))))
eval_log_info = "Epoch [{}/{}], Valid loss {:.4f}, Valid Acc {:.4f}, Valid mIoU {:.4f}".format(
epoch, epochs, _eval_loss / len(val_data), _eval_acc / len(val_data), miou
)
print(eval_log_info)
f.write(eval_log_info+'\n')
f.close() # 将内容写入
print("Finish!")
这里要说明的几个点:
- 训练前需要加net.train(),验证前需要加net.eval()。该模式的设置,可以让网络层中的某些层开启或关闭(如BatchNormalization和Dropout),即使这些层我们FCN网络没有涉及到,但这是一个好习惯。当在别人预训练模型基础上训练时,若不这样设置,可能会带来不明所以的抖动。
- 网络输出的out在log_softmax处理时为什么要指定axis=1?验证时预测的mask(label_true)为什么是通过out.argmax(axis=1)取出?
不理解这两个axis=1实际上是不理解FCN网络设计。最后输出的数据中通道维为什么设置为num_classes呢?这样设置有什么用呢?我画了一个简易的图助于解释
上图是FCN最后一层的特征图,[num_classes, H, W],有num_classes个通道(PASCAL VOC里则是21个通道,以下以21为例)。
以每个通道的第一个像素点举例(图中橙色块),它们有着其计算值,此时如果我们让这21个像素值经过一个softmax层,那么它们新的输出值则变为了一个[0, 1]之间的概率值,和为1的概率分布
那么经过softmax层后,第0层的橙色块的数值就可以代表着该位置的像素被分为第0类的概率,第1层的橙色块的数值就代表着该位置的像素被分为第1类的概率,以此类推,第i层的橙色块的数值就可以代表着该位置的像素被分为第i类的概率。扩展来讲,一个通道上像素的值,就代表了该像素被分为此类的概率。
那么代码中的out = F.log_softmax(out, axis=1)
中的axis=1,是在要求通道维(因为DataLoader读数据的形式为[B, C, H, W])像上图中的红线那样对每一个位置的像素点进行softmax进行操作,使其满足概率分布,拥有概率意义。log_softmax是softmax后再log,而log计算是不改变其大小关系的。举个例子,本身橙色块(第一个像素点位置)在第5个通道上最大,log后仍然是在第5个通道上值最大。
因此label_pred = out.argmax(axis=1).numpy()
可以看成每个像素点位置在21个通道中选取概率最大的通道序号(等同于类别)作为预测,最后输出整一个预测的heatmap。
整体结构已搭好,进入实战环节!
VGG16(不使用迁移学习)
为了简单地验证我上篇中VGG16难训练的结论,我决定跑个实验测试一下。
第一次训练
参数设置:SGD优化器,learning_rate=1e-4, weight_decay=1e-8
开始训练,最终的训练结果如下图
(由于误删了其训练日志文件...没办法以miou曲线图的形式展示,后两节均有)
在epoch34-36阶段,miou最高跑到24。之后便一直降低
第二次训练
参数设置:learning_rate=0.001,weight_decay=1e-5
跑了25个epoch,loss和miou纹丝不动,直接放弃训练了
VGG16(使用迁移学习)
第一次训练
使用Adam优化器,学习率learning_rate=0.001, weight_decay=1e-5
最终的训练结果
第二次训练
使用SGD优化器,学习率learning_rate=0.003, weight_decay=1e-6
ResNet34(使用迁移学习)
第一次训练
使用SGD优化器,设置learning_rate=0.03,weight_decay=0.01
第二次训练
使用SGD优化器,设置learning_rate=0.008,weight_decay=0.001
第三次训练
使用SGD优化器,设置learning_rate=0.02,weight_decay=0.008
预测
搞图像分割时,我们并不能一味关心性能指标,而不看最后预测的图像质量
我们定义相关函数,查看FCN预测图片时的效果。以下为代码:
# 这里载入我基于resnet34训练的fcn网络,miou指标为52
net.set_state_dict(paddle.load("params/FCN8s_res34_transfered3_miou52.pdparams"))
# 各种标签所对应的颜色
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]]
cm = np.array(colormap).astype('uint8')
def predict(img, label): # 预测结果
img = img.unsqueeze(0)
out = net(img)
pred = out.argmax(axis=1) # .squeeze()
# 将pred的分类值,转换成各个分类对应的RGB值
pred = cm[pred]
# 将numpy转换成PIL对象
pred = Image.fromarray(pred)
label = cm[label.numpy()]
return pred, label
# 这里为了方便,就直接用PIL里的crop了,默认从左上方开始裁剪一定尺寸,不是随机裁剪
def crop(data, label, height, width):
"""
data和lable都是Image对象
"""
box = (0, 0, width, height)
data = data.crop(box)
label = label.crop(box)
return data, label
val_data = VOCSegData(voc_root=voc_root, train=False, crop_size=crop_size)
width = 480
height = 320
num_image = 10 # 预测的图片张数
_, figs = plt.subplots(num_image, 3, figsize=(12, 22))
for i in range(num_image):
img_data, img_label = val_data[i+60] # 这里和以下的60是指从第60张开始预测
pred, label = predict(img_data, img_label)
img_data = Image.open(val_data.images[i+60])
img_label = Image.open(val_data.masks[i+60]).convert("RGB")
img_data, img_label = crop(img_data, img_label, height=height, width=width)
figs[i, 0].imshow(img_data) # 原始图片
figs[i, 0].axes.get_xaxis().set_visible(False) # 去掉x轴
figs[i, 0].axes.get_yaxis().set_visible(False) # 去掉y轴
figs[i, 1].imshow(img_label) # 标签
figs[i, 1].axes.get_xaxis().set_visible(False) # 去掉x轴
figs[i, 1].axes.get_yaxis().set_visible(False) # 去掉y轴
figs[i, 2].imshow(pred) # 模型输出结果
figs[i, 2].axes.get_xaxis().set_visible(False) # 去掉x轴
figs[i, 2].axes.get_yaxis().set_visible(False) # 去掉y轴
# 在最后一行图片下面添加标题
figs[num_image-1, 0].set_title("Image", y=-0.2)
figs[num_image-1, 1].set_title("Label", y=-0.2)
figs[num_image-1, 2].set_title("fcns", y=-0.2)
plt.savefig("predict.png") # 保存预测图片
这是最终fcn的预测效果图:
可以看到,fcn虽然大致的轮廓是正确的,但分割效果较为粗糙。