目录
1 案例介绍
遥感技术已成为获取地表覆盖信息最为行之有效的手段,遥感技术已经成功应用于地表覆盖检测、植被面积检测和建筑物检测任务。本赛题使用航拍数据,需要参赛选手完成地表建筑物识别,将地表航拍图像素划分为有建筑物和无建筑物两类。
如下图,左边为原始航拍图,右边为对应的建筑物标注。
本案例训练集为航拍的地标建筑物,训练集图像为30000张图片。其中训练集的标签为rle序列的csv文档。测试集为2500个图像。
2 数据预处理
2.1 rle编码转换
RLE编码是微软开发为AVI格式开发的一种编码。假设一个图像的像素色彩值是这样排列的:红红红红红红红红红红红红蓝蓝蓝蓝蓝蓝绿绿绿绿,经过RLE压缩后就成为了:红12蓝6绿4。这样既保证了压缩的可行性,而且不会有损失。而且可以看到,当颜色数越少时,压缩效率会更高。
在本案例中,我们首先要对rle编码进行读取,将其转换为jpg格式的图片。
官方给出的解码文件可以将rle编码序列转化为一个numpy矩阵。转码函数如下:
我们首先对csv文件进行读取,保存到一个二维数组中。
train_mask = pd.read_csv('../dataset/train_mask.csv/train_mask.csv', sep='\t', names=['name', 'mask'])
# 读取第一张图,并将对于的rle解码为mask矩阵
img = cv2.imread('../dataset/train/' + train_mask['name'].iloc[0]) # name列的第0行
mask = rle_decode(train_mask['mask'].iloc[0])
print(train_mask.head())
train_mask['name'].lioc[0]:lioc用于提取行数据,整体含义为name列第0行数据
names字段作用:命名csv文件列名
train_mask.head()输出检验列名,我们可以看到csv文件如下:
我们通过观察发现,转码后的变量是一个矩阵,我们将矩阵转化为一个二值图,再将其做为标签存放。需要注意的是,矩阵中的值都是0或1,而二值图的8位编码范围为0-255,这样我们在观察标签的时候会看到几乎全黑的情况。所以我们在得到输出后的矩阵,一定要将其乘上255。
要注意的是二值图和灰度图的区别。二值图是一种单通道图像,其矩阵形式只可表现为两个数值;灰度图是一种RGB三通道图像,每个通道的数值相等,它相比于二值图更多的保留了原始图像的信息。
for i in range(30000):
try:
train_mask = rle_decode(train_rle['mask'].iloc[i])
print(type(train_mask)) # 矩阵形式
train_mask = train_mask * 255
train_mask = train_mask.astype(np.uint8)
cv2.imwrite('D:\\00Com_TianChi\\dataset\\train\\build_label\\' + train_rle['name'].iloc[i], train_mask)
except:
pass
train_mask = np.zeros((512, 512)).astype('uint8')
train_mask = train_mask * 255
cv2.imwrite('D:\\00Com_TianChi\\dataset\\train\\build_label\\' + train_rle['name'].iloc[i], train_mask)
其中将矩阵转为numpy格式并存储成图片的转换函数为astype()。
使用方法为 train_mask = train_mask.astype(np.uint8)
在训练集中有很多异常数据,对于异常数据,我们使用try-except语法来进行处理。
try:正常情况
except:数据异常情况
2.2 数据扩增
数据扩增是一种有效的正则化方法,可以防止模型过拟合,在深度学习模型的训练过程中应用广泛。数据扩增的目的是增加数据集中样本的数据量,同时也可以有效增加样本的语义空间。
在语义分割领域,我们通常将训练集的图像与标签进行同步的图像变换,这样可以对模型进行有效的训练。
本案例利用albumentations库进行数据扩增。albumentations是基于OpenCV的快速训练数据增强库,拥有非常简单且强大的可以用于多种任务(分割、检测)的接口,易于定制且添加其他框架非常方便。
# ---------------数据扩增部分---------------
aug_data = 'D:\\00Com_TianChi\\dataset\\train_aug\\'
image_build_aug = "build_image_aug"
label_build_aug = "build_label_aug"
# 扩增img和扩增label的路径
image_build_aug_path = os.path.join(aug_data, image_build_aug)
label_build_aug_path = os.path.join(aug_data, label_build_aug)
# 原始图像的名称 build_dataset.image_list[0] build_dataset.label_list[0]
# 路径测试
# print(os.path.join(root_dir, image_build, build_dataset.image_list[0]))
# print( os.path.join(image_build_aug_path, 'scale' + build_dataset.image_list[0]))
for i in range(0, 5):
print(i)
# 将 原始图像和原始标签路径 放入函数 得到路径
img_path = os.path.join(root_dir, image_build, build_dataset.image_list[i])
label_path = os.path.join(root_dir, label_build, build_dataset.label_list[i])
# 根据路径加载图片 转为np类
trans_img = np.asarray(Image.open(img_path))
trans_label = np.asarray(Image.open(label_path))
# 水平翻转操作
augments = aug.HorizontalFlip(p=1)(image=trans_img, mask=trans_label)
img_aug_hor, mask_aug_hor = augments['image'], augments['mask']
# 随即裁剪操作
augments = aug.RandomCrop(p=1, height=256, width=256)(image=trans_img, mask=trans_label)
img_aug_ran, mask_aug_ran = augments['image'], augments['mask']
# 旋转操作
augments = aug.ShiftScaleRotate(p=1)(image=trans_img, mask=trans_label)
img_aug_rot, mask_aug_rot = augments['image'], augments['mask']
# 复合操作
trfm = aug.Compose([
aug.Resize(256, 256),
aug.HorizontalFlip(p=0.5),
aug.VerticalFlip(p=0.5),
aug.RandomRotate90(),
])
augments = trfm(image=trans_img, mask=trans_label)
img_aug_mix, mask_aug_mix = augments['image'], augments['mask']
# 保存路径 变换后的文件名
# 水平翻转
save_hor_path_img = os.path.join(image_build_aug_path, 'hor' + build_dataset.image_list[i])
save_hor_path_label = os.path.join(label_build_aug_path, 'hor' + build_dataset.label_list[i])
cv2.imwrite(save_hor_path_img, img_aug_hor)
cv2.imwrite(save_hor_path_label, mask_aug_hor)
# 随即裁剪
save_ran_path_img = os.path.join(image_build_aug_path, 'ran' + build_dataset.image_list[i])
save_ran_path_label = os.path.join(label_build_aug_path, 'ran' + build_dataset.label_list[i])
cv2.imwrite(save_ran_path_img, img_aug_ran)
cv2.imwrite(save_ran_path_label, mask_aug_ran)
# 旋转操作
save_rot_path_img = os.path.join(image_build_aug_path, 'rot' + build_dataset.image_list[i])
save_rot_path_label = os.path.join(label_build_aug_path, 'rot' + build_dataset.label_list[i])
cv2.imwrite(save_rot_path_img, img_aug_rot)
cv2.imwrite(save_rot_path_label, mask_aug_rot)
# 复合操作
save_mix_path_img = os.path.join(image_build_aug_path, 'rot' + build_dataset.image_list[i])
save_mix_path_label = os.path.join(label_build_aug_path, 'rot' + build_dataset.label_list[i])
cv2.imwrite(save_mix_path_img, img_aug_mix)
cv2.imwrite(save_mix_path_label, mask_aug_mix)
2.3 异常数据的处理
在rle转mask编码的处理中,我们将异常rle数据转换成全黑图片处理。可是在后面的训练中发现,损失函数的振荡较大,于是考虑将异常数据全部剔除,再次训练函数观察损失函数的变化。
后来经过观察又发现,文件中数据异常的rle序列对应的图像即是没有建筑物的图像,输出全黑的标签是没有问题的。
3 自定义数据库类
在数据预处理后,我们进行数据库类的定义。在每次进行模型训练前,我们要将训练集的数据输入给一个类中,这样能够使我们清晰地有条理地利用好我们的训练集数据。本案例的数据库类定义如下。
class MyData(Dataset):
def __init__(self, root_dir, image_dir, label_dir, transform):
self.root_dir = root_dir
self.image_dir = image_dir
self.label_dir = label_dir
self.label_path = os.path.join(self.root_dir, self.label_dir)
self.image_path = os.path.join(self.root_dir, self.image_dir)
self.image_list = os.listdir(self.image_path)
self.label_list = os.listdir(self.label_path)
self.transform = transform
# 因为label 和 Image文件名相同,进行一样的排序,可以保证取出的数据和label是一一对应的
self.image_list.sort()
self.label_list.sort()
def __getitem__(self, idx):
img_name = self.image_list[idx]
label_name = self.label_list[idx]
img_item_path = os.path.join(self.root_dir, self.image_dir, img_name)
label_item_path = os.path.join(self.root_dir, self.label_dir, label_name)
img = Image.open(img_item_path)
label = Image.open(label_item_path)
# label = self.label_dir
trans_tensor = transforms.ToTensor()
img = trans_tensor(img) # 将图片变为tensor格式
label = trans_tensor(label)
return img, label
# with open(label_item_path, 'r') as f:
# label = f.readline()
#
# # img = np.array(img)
# img = self.transform(img)
# sample = {'img': img, 'label': label}
# return sample
def __len__(self):
assert len(self.image_list) == len(self.label_list)
return len(self.image_list)
函数有四个输入变量:
- root_dir:为数据集根目录
- train_dir:为训练集目录
- text_dir:为测试集目录
- transform:为对数据集做的transform
我们利用os对路径进行整合,这一部分有很多实用的数据转换代码,在这里小结一下。
存图片
cv2.imwrite('D:\\00Com_TianChi\\dataset\\train\\build_label\\' + train_rle['name'].iloc[i], train_mask)
加载一张图片:
# 加载后图片格式为PIL.JpegImagePlugin.JpegImageFile
img = Image.open(img_item_path)
将PIL.JpegImagePlugin.JpegImageFile类型转为数组
# 转换后变量的数据类型为np型
img = np.asarray(img)
将数组转为torch.Tensor类:
img = torch.tensor(img)
注意transforms.ToTensor和torch.Tensor的区别:
- transforms.ToTensor:可以将np或PIL类型的图片转为tensor型,但是转换的同时也会将其归一化,因为transform封装的函数中将tensor型变量中的每个量设置的范围为[0,1]。且图片的tensor数据排列顺序为通道数在前,用一个512×512的RGB图片举例:torch.Size([3, 512, 512])。
- torch.Tensor:这个函数和transforms.ToTensor的功能类似,但是没有将张量归一化。且图片的tensor数据排列顺序为通道数在后,用一个512×512的RGB图片举例:torch.Size([512, 512, 3])。
下面对数据库类实例化。
# 定义训练集
transform = transforms.Compose([transforms.Resize((512, 512)), transforms.ToTensor()])
root_dir = "D:/00Com_TianChi/dataset/train/"
image_build = "build_image"
label_build = "build_label"
build_dataset = MyData(root_dir, image_build, label_build, transform=transform)
# 定义测试集
test_dir = "D:/00Com_TianChi/dataset/test/"
image_build_test = "img"
label_build_test = "label"
test_dataset = MyData(test_dir, image_build_test, label_build_test, transform=transform)
在对数据库类进行实例化后,我们定义模型的data_loader,批处理量定位8。
train_dataloader = DataLoader(build_dataset, batch_size=8, shuffle=True, num_workers=4)
4 模型训练
4.1 网络模型
通常CNN网络在卷积层之后会接上若干个全连接层, 将卷积层产生的特征图(feature map)映射成一个固定长度的特征向量。与经典的CNN不同,FCN可以接受任意尺寸的输入图像,采用反卷积层对最后一个卷积层的feature map进行上采样, 使它恢复到输入图像相同的尺寸,从而可以对每个像素都产生了一个预测, 同时保留了原始输入图像中的空间信息, 最后在上采样的特征图上进行逐像素分类。最后逐个像素计算softmax分类的损失, 相当于每一个像素对应一个训练样本。
下面为模型的架构,参考了一篇文章的代码,引用来源已附在文章结尾。
ranges = {
'vgg11': ((0, 3), (3, 6), (6, 11), (11, 16), (16, 21)),
'vgg13': ((0, 5), (5, 10), (10, 15), (15, 20), (20, 25)),
'vgg16': ((0, 5), (5, 10), (10, 17), (17, 24), (24, 31)),
'vgg19': ((0, 5), (5, 10), (10, 19), (19, 28), (28, 37))
}
# Vgg网络结构配置(数字代表经过卷积后的channel数,‘M’代表卷积层)
cfg = {
'vgg11': [64, 'M', 128, 'M', 256, 256, 'M', 512, 512, 'M', 512, 512, 'M'],
'vgg13': [64, 64, 'M', 128, 128, 'M', 256, 256, 'M', 512, 512, 'M', 512, 512, 'M'],
'vgg16': [64, 64, 'M', 128, 128, 'M', 256, 256, 256, 'M', 512, 512, 512, 'M', 512, 512, 512, 'M'],
'vgg19': [64, 64, 'M', 128, 128, 'M', 256, 256, 256, 256, 'M', 512, 512, 512, 512, 'M', 512, 512, 512, 512, 'M'],
}
# 由cfg构建vgg-Net的卷积层和池化层(block1-block5)
def make_layers(cfg, batch_norm=False):
layers = []
in_channels = 3
for v in cfg:
if v == 'M':
layers += [nn.MaxPool2d(kernel_size=2, stride=2)]
else:
conv2d = nn.Conv2d(in_channels, v, kernel_size=3, padding=1)
if batch_norm:
layers += [conv2d, nn.BatchNorm2d(v), nn.ReLU(inplace=True)]
else:
layers += [conv2d, nn.ReLU(inplace=True)]
in_channels = v
return nn.Sequential(*layers)
# 下面开始构建VGGnet
class VGGNet(VGG):
def __init__(self, pretrained=True, model='vgg16', requires_grad=True, remove_fc=True, show_params=False):
super().__init__(make_layers(cfg[model]))
self.ranges = ranges[model]
# 获取VGG模型训练好的参数,并加载(第一次执行需要下载一段时间)
if pretrained:
exec("self.load_state_dict(models.%s(pretrained=True).state_dict())" % model)
if not requires_grad:
for param in super().parameters():
param.requires_grad = False
# 去掉vgg最后的全连接层(classifier)
if remove_fc:
del self.classifier
if show_params:
for name, param in self.named_parameters():
print(name, param.size())
def forward(self, x):
output = {}
# 利用之前定义的ranges获取每个maxpooling层输出的特征图
for idx, (begin, end) in enumerate(self.ranges):
# self.ranges = ((0, 5), (5, 10), (10, 17), (17, 24), (24, 31)) (vgg16 examples)
for layer in range(begin, end):
x = self.features[layer](x)
output["x%d" % (idx + 1)] = x
# output 为一个字典键x1d对应第一个maxpooling输出的特征图,x2...x5类推
return output
# 下面由VGG构建FCN8s
class FCN8s(nn.Module):
def __init__(self, pretrained_net, n_class):
super().__init__()
self.n_class = n_class
self.pretrained_net = pretrained_net
self.conv6 = nn.Conv2d(512, 512, kernel_size=1, stride=1, padding=0, dilation=1)
self.conv7 = nn.Conv2d(512, 512, kernel_size=1, stride=1, padding=0, dilation=1)
self.relu = nn.ReLU(inplace=True)
self.deconv1 = nn.ConvTranspose2d(512, 512, kernel_size=3, stride=2, padding=1, dilation=1, output_padding=1)
self.bn1 = nn.BatchNorm2d(512)
self.deconv2 = nn.ConvTranspose2d(512, 256, kernel_size=3, stride=2, padding=1, dilation=1, output_padding=1)
self.bn2 = nn.BatchNorm2d(256)
self.deconv3 = nn.ConvTranspose2d(256, 128, kernel_size=3, stride=2, padding=1, dilation=1, output_padding=1)
self.bn3 = nn.BatchNorm2d(128)
self.deconv4 = nn.ConvTranspose2d(128, 64, kernel_size=3, stride=2, padding=1, dilation=1, output_padding=1)
self.bn4 = nn.BatchNorm2d(64)
self.deconv5 = nn.ConvTranspose2d(64, 32, kernel_size=3, stride=2, padding=1, dilation=1, output_padding=1)
self.bn5 = nn.BatchNorm2d(32)
self.classifier = nn.Conv2d(32, n_class, kernel_size=1)
def forward(self, x):
output = self.pretrained_net(x)
x5 = output['x5'] # maxpooling5的feature map (1/32)
x4 = output['x4'] # maxpooling4的feature map (1/16)
x3 = output['x3'] # maxpooling3的feature map (1/8)
score = self.relu(self.conv6(x5)) # conv6 size不变 (1/32)
score = self.relu(self.conv7(score)) # conv7 size不变 (1/32)
score = self.relu(self.deconv1(x5)) # out_size = 2*in_size (1/16)
score = self.bn1(score + x4)
score = self.relu(self.deconv2(score)) # out_size = 2*in_size (1/8)
score = self.bn2(score + x3)
score = self.bn3(self.relu(self.deconv3(score))) # out_size = 2*in_size (1/4)
score = self.bn4(self.relu(self.deconv4(score))) # out_size = 2*in_size (1/2)
score = self.bn5(self.relu(self.deconv5(score))) # out_size = 2*in_size (1)
score = self.classifier(score) # size不变,使输出的channel等于类别数
return score
4.2 模型的训练
将数据集的图像放入模型进行训练,这里要强调的地方是,在放入损失函数的时候,要注意output与labels的尺寸和数据类型。我在尝试训练的时候,因为张量大小问题和数据类型问题踩了很多坑。在本次训练中,我们使用的损失函数为CrossEntropyLoss(),output的数据类型为torch.float32,labels要转换为(8, 512, 512)。
def train(epo_num, show_vgg_params=False):
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
# 构建VGG模型 根据VGG模型构建FCN8s模型
vgg_model = VGGNet(requires_grad=True, show_params=show_vgg_params)
fcn_model = FCN8s(pretrained_net=vgg_model, n_class=2)
fcn_model = fcn_model.to(device)
# 这里只有两类,采用二分类常用的损失函数BCE
criterion = nn.CrossEntropyLoss().to(device)
# 随机梯度下降优化,学习率0.001,惯性分数0.7
optimizer = optim.SGD(fcn_model.parameters(), lr=1e-3, momentum=0.7)
# 记录训练过程相关指标
all_train_iter_loss = []
all_test_iter_loss = []
test_Acc = []
test_mIou = []
# start timing
prev_time = datetime.now()
for epo in range(epo_num):
# 训练
train_loss = 0
fcn_model.train()
for index, (imgs, labels) in enumerate(train_dataloader):
# bag.shape is torch.Size([4, 3, 160, 160])
# bag_msk.shape is torch.Size([4, 2, 160, 160])
imgs = imgs.to(device)
labels = labels.to(device)
optimizer.zero_grad()
output = fcn_model(imgs.to(torch.float32))
output = torch.sigmoid(output) # output.shape is torch.Size([4, 2, 160, 160])
print(output.size())
print(labels.size())
labels = labels.view(8, 512, 512) # labels降低1维
labels = torch.LongTensor(labels.numpy()) # 标签改为长整型
loss = criterion(output, labels)
loss.backward() # 需要计算导数,则调用backward
iter_loss = loss.item() # .item()返回一个具体的值,一般用于loss和acc
all_train_iter_loss.append(iter_loss)
train_loss += iter_loss
optimizer.step()
output_np = output.cpu().detach().numpy().copy()
output_np = np.argmin(output_np, axis=1)
label_np = labels.cpu().detach().numpy().copy()
blabel_np = np.argmin(label_np, axis=1)
# 每15个bacth,输出一次训练过程的数据
if np.mod(index, 15) == 0:
print('epoch:{}, 当前批次/总批次:{}/{},train loss is {}'.format(epo, index, len(train_dataloader), iter_loss))
return fcn_model
4.3 训练结果
在训练的一开始我们可以将epo设定为较高的值,损失函数的变化。通过观察我们可以发现,损失函数在15轮左右逐渐稳定下来。所以我们可以将模型训练20轮后,将模型保存到根目录下。
model = train(epo_num=20, show_vgg_params=False)
torch.save(model, "FCN_model.pth")
5 语义分割的准确率评价方法
在语言分割的评价方法中,我们主要利用混淆矩阵对模型准确率进行评价。在前几期的博客中已经对混淆矩阵进行了介绍,我们再次来回顾一下混淆矩阵的概念,并尝试从语义分割领域对混淆军阵进行新的理解。
我们已经了解到,经模型输出后图像能够根据预测的结果分为不同的mask,每一类mask就是模型输出的某一个类别,或者也可以成为某一个通道。当我们对背景感兴趣时,图(b)中真实值=1的情况则为全部的背景,即图中清晰部分;模型输出中预测值=1的部分为正确的预测,即图中紫色部分;模型输出中预测值=0的部分为错误的预测,即黄色的部分。
当我们对人物感兴趣时也是同理,图(c)中真实值=1时,则为我们感兴趣的部分,即人物;当预测值=1时,则为预测正确的部分,这张图恰巧精确度很高,图中黄紫蓝组成的颜色则为预测值=1时的情况。
那么问题来了——当真实值=0时,该是哪个区域呢?当我们对于人物感兴趣时,真实值=1为人物,那么真实值=0时则为人物以外的区域,则为背景区域。图(d)中当真实值=0,预测值=1时,则为黑色线条圈出来的部分,通俗的讲可以理解为:本该预测成背景,可是预测错了。
5.1 像素准确率(PA)
- 预测类别正确的像素数占总像素数的比例
- PA = (TP + TN) / (TP + TN + FP + FN)
5.2 类别像素准确率(CPA)
在类别 i 的预测值中,真实属于 i 类的像素准确率,换言之:模型对类别 i 的预测值有很多,其中有对有错,预测对的值占预测总值的比例。
P1 = TP / (TP + FP)
5.3 类别平均像素准确率(MPA)
分别计算每个类被正确分类像素数的比例,即:CPA,然后累加求平均
- 每个类别像素准确率为:Pi(计算:对角线值 / 对应列的像素总数)
- MPA = sum(Pi) / 类别数
5.4 交并比(IoU)
- 模型对某一类别预测结果和真实值的交集与并集的比值
- 混淆矩阵计算:
- IoU = TP / (TP + FP + FN)
5.5 平均交并比(MIoU)
模型对每一类交并比,求和再平均的结果。
6 模型验证
我们通常将一个数据集分为三部分。
- 训练集(Train Set):模型用于训练和调整模型参数。
- 验证集(Validation Set):用来验证模型精度和调整模型超参数。
- 测试集(Test Set):验证模型的泛化能力。
因为训练集和验证集是分开的,所以模型在验证集上面的精度在一定程度上可以反映模型的泛化能力。在划分验证集的时候,需要注意验证集的分布应该与测试集尽量保持一致,不然模型在验证集上的精度就失去了指导意义。
训练集和测试集赛题方已经给出,我们需要从训练集中划分出验证集。一般划分验证集的方法有三种:
-
留出法(Hold-Out) 直接将训练集划分成两部分,新的训练集和验证集。这种划分方式的优点是最为直接简单;缺点是只得到了一份验证集,有可能导致模型在验证集上过拟合。留出法应用场景是数据量比较大的情况。
-
交叉验证法(Cross Validation,CV) 将训练集划分成K份,将其中的K-1份作为训练集,剩余的1份作为验证集,循环K训练。这种划分方式是所有的训练集都是验证集,最终模型验证精度是K份平均得到。这种方式的优点是验证集精度比较可靠,训练K次可以得到K个有多样性差异的模型;CV验证的缺点是需要训练K次,不适合数据量很大的情况。
-
自助采样法(BootStrap) 通过有放回的采样方式得到新的训练集和验证集,每次的训练集和验证集都是有区别的。这种划分方式一般适用于数据量较小的情况。
我们的训练集数据量较大,为了更快的得出验证集,我们使用留出法得到验证集,但是由于案例在训练模型之前没有划分训练集,所有只能在训练之后从训练集中提取一部分数据作为验证集。这样可能对评估训练集的泛化能力有一定的影响。
pa_sum = 0
cpa_sum = 0
mpa_sum = 0
mIoU_sum = 0
dice_sum = 0
with torch.no_grad():
for index, (imgs, labels) in enumerate(ver_dataloader):
print("正在计算第{}/{}个验证集准确率".format(index, len(ver_dataloader)))
# 加载模型
model = torch.load("FCN_model.pth", map_location='cpu')
output = model(imgs) # 输出值
output = torch.sigmoid(output) # 归一化后的概率
output_np = output.cpu().detach().numpy().copy()
output_np = np.argmax(output_np, axis=1)
labels_np = labels.cpu().detach().numpy().copy().astype(int)
output_np = output_np.astype(int)
output_tensor = torch.tensor(output_np)
metric = SegmentationMetric(2) # 类的实例化 输入分类的类别数
metric.addBatch(output_np, labels_np) # 输出 与 真值放入评价类
pa = metric.pixelAccuracy()
cpa = metric.classPixelAccuracy()
mpa = metric.meanPixelAccuracy()
mIoU = metric.meanIntersectionOverUnion()
dice = metric.dice_coeff(output_tensor, labels)
print("pa:{}".format(pa))
print("cpa:{}".format(cpa))
print("mpa:{}".format(mpa))
print("mIoU:{}".format(mIoU))
print("dice:{}".format(dice))
# 计算总数
pa_sum = pa_sum + pa
cpa_sum = cpa_sum + cpa
mpa_sum = mpa_sum + mpa
mIoU_sum = mIoU_sum + mIoU
dice_sum = dice_sum + dice
# 平均值
mpa = pa_sum / 313
mcpa = cpa_sum / 313
mmpa = mpa_sum / 313
mmIoU = mIoU_sum / 313
mdice = dice_sum / 313
print('mpa is : %f' % mpa)
print('mcpa is :{}'.format(mcpa)) # 列表
print('mmpa is : %f' % mmpa)
print('mmIoU is : %f' % mmIoU)
print('mdice is : %f' % mdice)
我们通过五种不同的评价指标来对验证集进行评估,其中dice为赛题方给出的评价指标。
赛题使用Dice coefficient来衡量选手结果与真实标签的差异性,Dice coefficient可以按像素差异性来比较结果的差异性。Dice coefficient的具体计算方式如下:
其中X是预测结果,Y为真实标签的结果。当X与Y完全相同时Dice coefficient为1,排行榜使用所有测试集图片的平均Dice coefficient来衡量,分数值越大越好。
在对模型验证时,几种评级指标的平均值如下。
后期会再次进行整理,通过可视化的方式将其展现。下面给出几组验证集的图像与标签对比图。
通过对比可以看到,输出的标签图较为粗糙,本案例使用的是FCN网络,参数和网络架构的调整还有待考虑,后期会继续更新模型架构,提高评估的准确率。
7 模型测试
最后读入模型的测试集,利用保存好的模型对其进行测试。
利用官方给出的文档将输出的np矩阵转换为rle编码存在csv文件中,下面是官方给出的编码函数。
def rle_encode(img, min_max_threshold=1e-3, max_mean_threshold=None):
'''
img: numpy array, 1 - mask, 0 - background
Returns run length as string formated
'''
if np.max(img) < min_max_threshold:
return '' ## no need to encode if it's all zeros
if max_mean_threshold and np.mean(img) > max_mean_threshold:
return '' ## ignore overfilled mask
pixels = img.T.flatten()
pixels = np.concatenate([[0], pixels, [0]])
runs = np.where(pixels[1:] != pixels[:-1])[0] + 1
runs[1::2] -= runs[::2]
return ' '.join(str(x) for x in runs)
下面是输入子函数。
def input_img(i):
test_list = pd.read_csv('D:\\00Com_TianChi\\dataset\\test_a_samplesubmit.csv',
sep='\t', names=['name', 'mask'])
img = Image.open("D:\\00Com_TianChi\\dataset\\test\\img\\" + test_list['name'].iloc[i])
transform = transforms.Compose([transforms.Resize((512, 512)), transforms.ToTensor(),
transforms.Normalize([0.5, 0.5, 0.5], [0.5, 0.5, 0.5])])
img = transform(img)
return img, test_list
本来一开始打算用dataloader进行遍历,但设置了batch_size输出就是多通道不好处理,所以直接利用索引进行循环遍历,不知道还有没有更优的方法对input进行处理。
def text_model(model):
# 训练好的模型
with torch.no_grad():
with open("output_03.csv", "w", newline='') as csvfile:
filenames = ['label', 'mask']
writer = csv.DictWriter(csvfile, fieldnames=filenames)
writer.writeheader()
# for index, imgs in enumerate(test_dataloader):
for index in range(2500):
print("正在计算第{}/2500个测试集图像".format(index))
imgs, test_list = input_img(index)
imgs = imgs.reshape(1, 3, 512, 512)
# 加载模型
model = torch.load("FCN_model.pth", map_location='cpu')
output = model(imgs.to(torch.float32)) # 输出值
output = torch.sigmoid(output) # 归一化后的概率
output_np = output.cpu().detach().numpy().copy()
output_np = np.argmax(output_np, axis=1)
output_np = output_np.astype(int)
output_np = output_np.astype(np.uint8)
output_np = output_np.reshape(512, 512)
print(type(output_np))
print(output_np.shape)
#cv2.imwrite('D:\\00Com_TianChi\\dataset\\test\\output\\' + test_dataset.image_list[index], output_np)
rle = rle_encode(output_np)
writer.writerow({'label': test_list['name'].iloc[index], 'mask': rle})
8 结果提交
提交结果要求如下:
提交前请确保预测结果的格式与test_sample_submit.csv
中的格式一致,以及提交文件后缀名为csv。
注意事项:
- 第一列为test图片名称,第二列为rle编码;
- 如测试集图没有识别出结果,也需要提交空字符串;
- 测试集图片顺序需要与
test_sample_submit.csv
保持一致;
在将输出的np矩阵进行rle编码后存入csv文件,发现输出的文件大小约50M,而上交的要求不大于20M。文件的大小应该与输出rle序列大小有关,输出图片的大小为512×512,压缩函数为官方给出的rle编码函数,目前还不清楚是什么原因导致的文件大小有误。
如果你有解决办法,请在下方留言。
参考资料
02 地表建筑物识别-天池大赛-阿里云天池 (aliyun.com)