1 第十一届 “中国软件杯”百度遥感赛项

1.1 比赛介绍


“中国软件杯”大学生软件设计大赛是一项面向中国在校学生的公益性赛事,是2021年全国普通高校大学生竞赛榜单内竞赛。大赛由国家工业和信息化部、教育部、江苏省人民政府共同主办,致力于正确引导我国在校学生积极参加软件科研活动,切实增强自我创新能力和实际动手能力,为我国软件和信息技术服务业培养出更多高端、优秀的人才。2022年,百度飞桨承办了A组和B组两个赛道,本赛题为A组。

比赛官网链接

1.2 赛题背景

掌握国土资源利用和土地覆盖类型,是地理国情普查与监测的重要内容。高效获取准确、客观的土地利用情况,监测国土变化情况,可以为国家和地方提供地理国情信息决策支撑。随着遥感、传感器技术的发展,特别是多时相高分辨率遥感图像数据的普及,使我们可以足不出户,就能掌握全球任一地表的细微变化。

目前,我国遥感领域已步入了高分辨率影像的快车道,对遥感数据的分析应用服务的需求也与日俱增。传统方式对高分辨率卫星遥感图像的对特征刻画能力差且依赖人工经验工作量巨大。随着人工智能技术的兴起,特别是基于深度学习的图像识别方法获得了极大的发展,相关技术也推动了遥感领域的变革。相对于传统基于人海战术的目视解译方法,基于深度学习的遥感图像识别技术可以自动分析图像中的地物类型,在准确率和效率方面展现出极大的潜力。

此次赛题由百度飞桨和北航LEVIR团队 共同设置,要求选手使用百度AI Studio平台进行训练,基于国产化人工智能框架——百度飞桨PaddlePaddle框架进行开发,设计并开发一个可以通过深度学习技术实现对遥感图像自动解译的WEB系统。

1.3 任务说明

变化检测部分要求参赛者利用提供的训练数据,实现对多时相图像中的建筑变化检测。具体而言,多时相遥感图像建筑物变化检测任务是给定两张不同时间拍摄的相同位置(地理配准)的遥感图像,要求定位出其中建筑变化的区域。

参考链接:什么是遥感影像变化检测?

1.4 数据集介绍

参见数据集链接赛题说明

2 比赛方案

  • 方案主要分为数据处理和模型选择方面
    • 数据处理方面:采用裁剪扩充数据集和快速傅里叶变换来处理
    • 模型选择方面:主要采用DSIFN模型,后期融合DSAMNet模型涨点
    • 数据增强方面:在Baseline的基础上增加了RansomBlur、RandomDistort和RandomSwap

2.1 数据预处理

# 安装第三方库
!pip install scikit-image > /dev/null
!pip install matplotlib==3.4 > /dev/null

# 安装PaddleRS(AI Studio上缓存的版本)
!unzip -o -d /home/aistudio/ /home/aistudio/data/data135375/PaddleRS-develop.zip > /dev/null
!mv /home/aistudio/PaddleRS-develop /home/aistudio/PaddleRS
!pip install -e /home/aistudio/PaddleRS > /dev/null
# 因为`sys.path`可能没有及时更新,这里选择手动更新
import sys
sys.path.append('/home/aistudio/PaddleRS')
  • 解压数据集:该操作涉及大量文件IO,可能需要一些时间
!unzip -o -d /home/aistudio/data/data134796/dataset /home/aistudio/data/data134796/train_data.zip > /dev/null
!unzip -o -d /home/aistudio/data/data134796/dataset /home/aistudio/data/data134796/test_data.zip > /dev/null
# 导入一些需要用到的库
import cv2
from tqdm import tqdm
import random
import os.path as osp
from glob import glob
from PIL import Image
import os
import numpy as np

import os.path as osp
from copy import deepcopy
from functools import partial

import paddle
# import paddlers as pdrs
# from paddlers import transforms as T
# from skimage.io import imread, imsave
from matplotlib import pyplot as plt

2.1.1 快速fft变换

  • 由于两时相遥感数据受传感器、天气等原因导致的光谱差异可能会对检测产生影响,所以采用快速fft变换统一A时相影像与B时相影像光谱特征
# 对影像A进行快速fft变换,并保存至B_fft文件夹中,大约需要10分钟

def style_transfer(source_image, target_image):
    # 快速fft变换
    h, w, c = source_image.shape
    out = []
    for i in range(c):
        source_image_f = np.fft.fft2(source_image[:,:,i])
        source_image_fshift = np.fft.fftshift(source_image_f)
        target_image_f = np.fft.fft2(target_image[:,:,i])
        target_image_fshift = np.fft.fftshift(target_image_f)
        
        change_length = 1
        source_image_fshift[int(h/2)-change_length:int(h/2)+change_length, 
                            int(h/2)-change_length:int(h/2)+change_length] = \
            target_image_fshift[int(h/2)-change_length:int(h/2)+change_length,
                                int(h/2)-change_length:int(h/2)+change_length]
            
        source_image_ifshift = np.fft.ifftshift(source_image_fshift)
        source_image_if = np.fft.ifft2(source_image_ifshift)
        source_image_if = np.abs(source_image_if)
        
        source_image_if[source_image_if>255] = np.max(source_image[:,:,i])
        out.append(source_image_if)
    out = np.array(out)
    out = out.swapaxes(1,0).swapaxes(1,2)
    
    out = out.astype(np.uint8)
    return out

def fft_save(data_path):
    img_path_A = os.path.join(data_path,'A')
    img_path_B = os.path.join(data_path,'B')
    img_B_save = os.path.join(data_path,'A_fft')
    names = os.listdir(img_path_A)
    if not os.path.exists(img_B_save):
        os.mkdir(img_B_save)
    for name in tqdm(names):
        img_A = cv2.imread(os.path.join(img_path_A,name))
        img_B = cv2.imread(os.path.join(img_path_B,name))
        img_B_fft = style_transfer(source_image=img_A,target_image=img_B)
        cv2.imwrite(os.path.join(img_B_save,name),img_B_fft)

data_path = '/home/aistudio/data/data134796/dataset/train'
test_path = '/home/aistudio/data/data134796/dataset/test'

fft_save(data_path)
fft_save(test_path)

2.1.3 数据裁剪

  • 原始数据大小为1024*1024,为充分利用数据集,将每张影像裁剪成16张256影像进行模型训练。
# 把image_A 、 image_B 、 label 裁剪成256*256并保存  差不多需要8分钟

CROP_SIZE = 256
train_dir = '/home/aistudio/data/data134796/dataset/train'
def crop_images(data_dir):
    img_path_A = os.path.join(data_dir,'A_fft')
    img_path_B = os.path.join(data_dir,'B')
    label = os.path.join(data_dir,'label')
    A_crop_path = os.path.join(data_dir,'A_fft_crop')
    B_crop_path = os.path.join(data_dir,'B_crop')
    label_crop_path = os.path.join(data_dir,'label_crop')
    if not os.path.exists(A_crop_path):
        os.mkdir(A_crop_path)
    if not os.path.exists(B_crop_path):
        os.mkdir(B_crop_path)
    if not os.path.exists(label_crop_path):
        os.mkdir(label_crop_path)

    names = os.listdir(label)
    for name in tqdm(names):
        label_ = np.array(Image.open(os.path.join(label,name)))
        img_A_ = np.array(Image.open(os.path.join(img_path_A,name)))
        img_B_ = np.array(Image.open(os.path.join(img_path_B,name)))

        h,w = label_.shape[0],label_.shape[1]
        count = 0
        for i in range(h//CROP_SIZE):
            for j in range(w//CROP_SIZE):
                label_crop = label_[i*CROP_SIZE:(i+1)*CROP_SIZE,j*CROP_SIZE:(j+1)*CROP_SIZE]
                img_A_crop = img_A_[i*CROP_SIZE:(i+1)*CROP_SIZE,j*CROP_SIZE:(j+1)*CROP_SIZE,:]
                img_B_crop = img_B_[i*CROP_SIZE:(i+1)*CROP_SIZE,j*CROP_SIZE:(j+1)*CROP_SIZE,:]
                Image.fromarray(img_A_crop).save(os.path.join(A_crop_path,name.split('.')[0]+'_'+str(count)+'.png'))
                Image.fromarray(img_B_crop).save(os.path.join(B_crop_path,name.split('.')[0]+'_'+str(count)+'.png'))
                Image.fromarray(label_crop).save(os.path.join(label_crop_path,name.split('.')[0]+'_'+str(count)+'.png'))
                count += 1


crop_images(train_dir)
100%|██████████| 637/637 [08:58<00:00,  1.18it/s]

2.2.3 划分数据集

  • 按照8:2划分训练集/验证集,并生成文件名列表
# 划分训练集/验证集,并生成文件名列表

# 随机数生成器种子
RNG_SEED = 114514
# 调节此参数控制训练集数据的占比
TRAIN_RATIO = 0.95
# 数据集路径
DATA_DIR = '/home/aistudio/data/data134796/dataset/'


def write_rel_paths(phase, names, out_dir, prefix=''):
    """将文件相对路径存储在txt格式文件中"""
    with open(osp.join(out_dir, phase+'.txt'), 'w') as f:
        for name in names:
            if prefix == 'test':
                f.write(
                    ' '.join([
                        osp.join(prefix, 'A_fft', name),
                        osp.join(prefix, 'B', name),
                        osp.join(prefix, 'label', name)
                    ])
                )
            else:
                f.write(
                    ' '.join([
                        osp.join(prefix, 'A_fft_crop', name),
                        osp.join(prefix, 'B_crop', name),
                        osp.join(prefix, 'label_crop', name)
                    ])
                )
            
            f.write('\n')


random.seed(RNG_SEED)

# 随机划分训练集/验证集
names = list(map(osp.basename, glob(osp.join(DATA_DIR, 'train', 'label_crop', '*.png'))))
# 对文件名进行排序,以确保多次运行结果一致
names.sort()
random.shuffle(names)
len_train = int(len(names)*TRAIN_RATIO) # 向下取整
write_rel_paths('train', names[:len_train], DATA_DIR, prefix='train')
write_rel_paths('val', names[len_train:], DATA_DIR, prefix='train')

# 处理测试集
test_names = map(osp.basename, glob(osp.join(DATA_DIR, 'test', 'A_fft', '*.png')))
test_names = sorted(test_names)
write_rel_paths(
    'test', 
    test_names, 
    DATA_DIR,
    prefix='test'
)

print("数据集划分已完成。")

数据集划分已完成。

2.2 模型选择


  • 本方案使用DSIFN模型进行打榜,后期将DSAMNet模型与之融合

2.2.1 超参数设置

# 可在此处调整实验所用超参数

# 随机种子
SEED = 1919810

# 数据集路径
DATA_DIR = '/home/aistudio/data/data134796/dataset/'
# 实验路径。实验目录下保存输出的模型权重和结果
EXP_DIR = '/home/aistudio/exp/'
# 保存最佳模型的路径
BEST_CKP_PATH = osp.join(EXP_DIR, 'best_model', 'model.pdparams')

# 训练的epoch数
NUM_EPOCHS = 100
# 每多少个epoch保存一次模型权重参数
SAVE_INTERVAL_EPOCHS = 10
# 初始学习率
LR = 0.001
# 学习率衰减步长(注意,单位为迭代次数而非epoch数),即每多少次迭代将学习率衰减一半
DECAY_STEP = 10000
# 训练阶段 batch size
TRAIN_BATCH_SIZE = 8
# 推理阶段 batch size
INFER_BATCH_SIZE = 8
# 加载数据所使用的进程数
NUM_WORKERS = 4
# 裁块大小
CROP_SIZE = 256
# 模型推理阶段使用的滑窗步长
STRIDE = 64
# 影像原始大小
ORIGINAL_SIZE = (256, 256)
# 固定随机种子,尽可能使实验结果可复现

random.seed(SEED)
np.random.seed(SEED)
paddle.seed(SEED)
# 定义一些辅助函数
def info(msg, **kwargs):
    print(msg, **kwargs)


def warn(msg, **kwargs):
    print('\033[0;31m'+msg, **kwargs)


def quantize(arr):
    return (arr*255).astype('uint8')

2.2.2 模型构建

  • model1是BIT模型,用作Baseline,仅做比较,没有实际用到

  • model2是DSIFN模型(backbone换成了Resnet34)

  • model3是DSAMNet模型

    最终将model2和model3进行模型融合

# BIT模型
# 调用PaddleRS API一键构建模型
model1 = pdrs.tasks.BIT(
    # 模型输出类别数
    num_classes=2,
    # 是否使用混合损失函数,默认使用交叉熵损失函数训练
    use_mixed_loss=True,
    # 模型输入通道数
    in_channels=3,
    # 模型使用的骨干网络,支持'resnet18'或'resnet34'
    backbone='resnet34',
    # 骨干网络中的resnet stage数量
    n_stages=4,
    # 是否使用tokenizer获取语义token
    use_tokenizer=True,
    # token的长度
    token_len=4,
    # 若不使用tokenizer,则使用池化方式获取token。此参数设置池化模式,有'max'和'avg'两种选项,分别对应最大池化与平均池化
    pool_mode='max',
    # 池化操作输出特征图的宽和高(池化方式得到的token的长度为pool_size的平方)
    pool_size=2,
    # 是否在Transformer编码器中加入位置编码(positional embedding)
    enc_with_pos=True,
    # Transformer编码器使用的注意力模块(attention block)个数
    enc_depth=1,
    # Transformer编码器中每个注意力头的嵌入维度(embedding dimension)
    enc_head_dim=64,
    # Transformer解码器使用的注意力模块个数
    dec_depth=8,
    # Transformer解码器中每个注意力头的嵌入维度
    dec_head_dim=8
)
W0511 15:55:28.340306   549 device_context.cc:447] Please NOTE: device: 0, GPU Compute Capability: 7.0, Driver API Version: 10.1, Runtime API Version: 10.1
W0511 15:55:28.345744   549 device_context.cc:465] device: 0, cuDNN Version: 7.6.
100%|██████████| 128669/128669 [00:03<00:00, 33477.57it/s]
# DSIFN
# 调用PaddleRS API一键构建模型
model2 = pdrs.tasks.DSIFN(
    # 模型输出类别数
    num_classes=2,
    # 不使用dropout
    use_dropout=False,
    # 是否使用混合损失函数,默认使用交叉熵损失函数训练
    use_mixed_loss=False,
    backbone='resnet34',
    features_bool = True,
)
W0526 22:17:37.634099   166 device_context.cc:447] Please NOTE: device: 0, GPU Compute Capability: 7.0, Driver API Version: 11.2, Runtime API Version: 10.1
W0526 22:17:37.638391   166 device_context.cc:465] device: 0, cuDNN Version: 7.6.
100%|██████████| 128669/128669 [00:02<00:00, 50442.02it/s]
# DSAMNet
# 调用PaddleRS API一键构建模型
model3 = pdrs.tasks.DSAMNet(
    # 模型输出类别数
    in_channels=3,
    num_classes=2,
)
100%|██████████| 69183/69183 [00:01<00:00, 35346.46it/s]

2.2.3 数据增强

  • 在Baseline的基础上尤其考虑到光谱特征的影响,加入了RandomBlur和RandomDistort,后考虑变化检测的多样性,加入了常见的RandomSwap和MixupImage数据增强策略
# 构建需要使用的数据变换(数据增强、预处理)
# 使用Compose组合多种变换方式。Compose中包含的变换将按顺序串行执行
train_transforms = T.Compose([
    # 随机裁剪
    T.RandomCrop(
        # 裁剪区域将被缩放到此大小(裁剪成256小图后CROP_SIZE=IMAGE_SIZE)
        crop_size=CROP_SIZE,
        # 将裁剪区域的横纵比固定为1
        aspect_ratio=[1.0, 1.0],
        # 裁剪区域相对原始影像长宽比例在一定范围内变动,最小不低于原始长宽的1/5
       scaling=[0.2, 1.0]
    ),
    # 以50%的概率实施随机水平翻转
    T.RandomHorizontalFlip(prob=0.5),
    # 以50%的概率实施随机垂直翻转
    T.RandomVerticalFlip(prob=0.5),
    T.RandomBlur(prob=0.5),
    T.RandomDistort(
        brightness_prob=0.5,
        contrast_prob=0.5,
        saturation_prob=0.5),
    T.RandomSwap(prob=0.2),
    T.MixupImage(alpha=1.5,beta=1.5),
    # 数据归一化到[-1,1]
    T.Normalize(
        mean=[0.5, 0.5, 0.5],
        std=[0.5, 0.5, 0.5]
    )
])
eval_transforms = T.Compose([
    # 在验证阶段,输入原始尺寸影像,对输入影像仅进行归一化处理
    # 验证阶段与训练阶段的数据归一化方式必须相同
    T.Normalize(
        mean=[0.5, 0.5, 0.5],
        std=[0.5, 0.5, 0.5]
    )
])

# 实例化数据集
train_dataset = pdrs.datasets.CDDataset(
    data_dir=DATA_DIR,
    file_list=osp.join(DATA_DIR, 'train.txt'),
    label_list=None,
    transforms=train_transforms,
    num_workers=NUM_WORKERS,
    shuffle=True,
    binarize_labels=True
)
eval_dataset = pdrs.datasets.CDDataset(
    data_dir=DATA_DIR,
    file_list=osp.join(DATA_DIR, 'val.txt'),
    label_list=None,
    transforms=eval_transforms,
    num_workers=0,
    shuffle=False,
    binarize_labels=True
)
2022-05-26 22:17:45 [INFO]	9682 samples in file /home/aistudio/data/data134796/dataset/train.txt
2022-05-26 22:17:45 [INFO]	510 samples in file /home/aistudio/data/data134796/dataset/val.txt

2.2.4 模型训练

使用AI Studio高级版硬件配置(16G V100)和默认的超参数,训练总时长约为50分钟,训练结束时验证集上最高的mIoU指标约为0.89(参考值,实际值可能存在波动)。

如果在训练中启用了VisualDL日志记录的功能(默认开启),则可以在“数据模型可视化”页签中查看可视化结果,请将logdir设置为EXP_DIR目录下的vdl_log子目录。在notebook中使用VisualDL的相关教程可参考此处

需要注意的是,PaddleRS默认以mIoU评价验证集上的最优模型,而赛事官方则选用F1分数作为评价指标。

变化检测任务的mIoU与F1分数指标定义:

m I o U = 1 2 ( T P F N + F P + T P + T N F P + F N + T N ) mIoU=\frac{1}{2}\left(\frac{TP}{FN+FP+TP}+\frac{TN}{FP+FN+TN}\right) mIoU=21(FN+FP+TPTP+FP+FN+TNTN)
F 1 = 2 ⋅ T P 2 ⋅ T P + F N + F P F1=\frac{2 \cdot TP}{2 \cdot TP + FN + FP} F1=2TP+FN+FP2TP

式中, T P TP TP表示预测为变化且实际为变化的样本数, T N TN TN表示预测为不变且实际为不变的样本数, F P FP FP表示预测为变化但实际为不变的样本数, F N FN FN表示预测为不变但实际为变化的样本数。

此外,PaddleRS在验证集上汇报针对每一类的指标,因此对于二类变化检测来说,category_acc、category_F1-score等指标均存在两个数据项,以列表形式体现。由于变化检测任务主要关注变化类,因此观察和比较每种指标的第二个数据项(即列表的第二个元素)是更有意义的。

# 若实验目录不存在,则新建之(递归创建目录)
if not osp.exists(EXP_DIR):
    os.makedirs(EXP_DIR)
# 构建学习率调度器和优化器
# 学习率策略有所变化
# 制定定步长学习率衰减策略
lr_scheduler1 = paddle.optimizer.lr.StepDecay(
    LR,
    # 多少个steps衰减一次学习率
    step_size=DECAY_STEP,
    # 学习率衰减比率
    gamma=0.8
)

# 制定自适应学习率策略
lr_scheduler2 = paddle.optimizer.lr.LinearWarmup(
    LR,
    warmup_steps=600,
    start_lr=0,
    end_lr=LR,
)

lr_scheduler = lr_scheduler1

# 构造Adam优化器
optimizer = paddle.optimizer.Adam(
    learning_rate=lr_scheduler,
    parameters=model.net.parameters()
)
# 调用PaddleRS API实现一键训练
model.train(
    num_epochs=NUM_EPOCHS,
    train_dataset=train_dataset,
    train_batch_size=TRAIN_BATCH_SIZE,
    eval_dataset=eval_dataset,
    optimizer=optimizer,
    save_interval_epochs=SAVE_INTERVAL_EPOCHS,
    # 每多少次迭代记录一次日志
    log_interval_steps=10,  
    save_dir=EXP_DIR,
    # 是否使用early stopping策略,当精度不再改善时提前终止训练
    early_stop=False,
    # 是否启用VisualDL日志功能
    use_vdl=True,
    # 指定从某个检查点继续训练
    resume_checkpoint=None
)
# 查看实验目录中存储的已训练好的模型
# `best_model`子目录对应验证集上指标最好的模型
# 其中,`eval_details.json`包含验证阶段记录的混淆矩阵信息;`model.pdopt`包含训练过程中使用到的优化器的状态参数;
# `model.pdparams`包含模型的权重参数;`model.yml`包含模型的配置文件(包括预处理参数、模型规格参数等)
!ls /home/aistudio/exp_isfin_fft_crop/best_model/
eval_details.json  model.pdopt	model.pdparams	model.yml

2.2.5 模型推理

使用AI Studio高级版硬件配置(16G V100)和默认的超参数,推理总时长约为4分钟。

推理脚本使用固定阈值法从变化概率图获取二值变化图(binary change map),默认阈值为0.5,可根据模型实际表现调整阈值。当然,也可以换用Otsu法k-means聚类法等更先进的阈值分割算法。

模型前向推理结果存储在EXP_DIR目录下的out子目录中,可将该子目录内的文件打包、并将压缩文件重命名后提交到比赛系统。在提交结果前,请仔细阅读提交规范

# 定义推理阶段使用的数据集
ORIGINAL_SIZE = (1024, 1024)

class InferDataset(paddle.io.Dataset):
    """
    变化检测推理数据集。

    Args:
        data_dir (str): 数据集所在的目录路径。
        transforms (paddlers.transforms.Compose): 需要执行的数据变换操作。
    """

    def __init__(
        self,
        data_dir,
        transforms
    ):
        super().__init__()

        self.data_dir = data_dir
        self.transforms = deepcopy(transforms)

        pdrs.transforms.arrange_transforms(
            model_type='changedetector',
            transforms=self.transforms,
            mode='test'
        )

        with open(osp.join(data_dir, 'test.txt'), 'r') as f:
            lines = f.read()
            lines = lines.strip().split('\n')

        samples = []
        names = []
        for line in lines:
            items = line.strip().split(' ')
            items = list(map(pdrs.utils.path_normalization, items))
            item_dict = {
                'image_t1': osp.join(data_dir, items[0]),
                'image_t2': osp.join(data_dir, items[1])
            }
            samples.append(item_dict)
            names.append(osp.basename(items[0]))

        self.samples = samples
        self.names = names

    def __getitem__(self, idx):
        name = self.names[idx]
        sample = deepcopy(self.samples[idx])
        output = self.transforms(sample)
        return name, \
               paddle.to_tensor(output[0]), \
               paddle.to_tensor(output[1]),

    def __len__(self):
        return len(self.samples)
# 考虑到原始影像尺寸较大,以下类和函数与影像裁块-拼接有关。

class WindowGenerator:
    def __init__(self, h, w, ch, cw, si=1, sj=1):
        self.h = h
        self.w = w
        self.ch = ch
        self.cw = cw
        if self.h < self.ch or self.w < self.cw:
            raise NotImplementedError
        self.si = si
        self.sj = sj
        self._i, self._j = 0, 0

    def __next__(self):
        # 列优先移动(C-order)
        if self._i > self.h:
            raise StopIteration
        
        bottom = min(self._i+self.ch, self.h)
        right = min(self._j+self.cw, self.w)
        top = max(0, bottom-self.ch)
        left = max(0, right-self.cw)

        if self._j >= self.w-self.cw:
            if self._i >= self.h-self.ch:
                # 设置一个非法值,使得迭代可以early stop
                self._i = self.h+1
            self._goto_next_row()
        else:
            self._j += self.sj
            if self._j > self.w:
                self._goto_next_row()

        return slice(top, bottom, 1), slice(left, right, 1)

    def __iter__(self):
        return self

    def _goto_next_row(self):
        self._i += self.si
        self._j = 0

    
def crop_patches(dataloader, ori_size, window_size, stride):
    """
    将`dataloader`中的数据裁块。

    Args:
        dataloader (paddle.io.DataLoader): 可迭代对象,能够产生原始样本(每个样本中包含任意数量影像)。
        ori_size (tuple): 原始影像的长和宽,表示为二元组形式(h,w)。
        window_size (int): 裁块大小。
        stride (int): 裁块使用的滑窗每次在水平或垂直方向上移动的像素数。

    Returns:
        一个生成器,能够产生iter(`dataloader`)中每一项的裁块结果。一幅图像产生的块在batch维度拼接。例如,当`ori_size`为1024,而
            `window_size`和`stride`均为512时,`crop_patches`返回的每一项的batch_size都将是iter(`dataloader`)中对应项的4倍。
    """

    for name, *ims in dataloader:
        ims = list(ims)
        h, w = ori_size
        win_gen = WindowGenerator(h, w, window_size, window_size, stride, stride)
        all_patches = []
        for rows, cols in win_gen:
            # NOTE: 此处不能使用生成器,否则因为lazy evaluation的缘故会导致结果不是预期的
            patches = [im[...,rows,cols] for im in ims]
            all_patches.append(patches)
        yield name[0], tuple(map(partial(paddle.concat, axis=0), zip(*all_patches)))


def recons_prob_map(patches, ori_size, window_size, stride):
    """从裁块结果重建原始尺寸影像,与`crop_patches`相对应"""
    # NOTE: 目前只能处理batch size为1的情况
    h, w = ori_size
    win_gen = WindowGenerator(h, w, window_size, window_size, stride, stride)
    prob_map = np.zeros((h,w), dtype=np.float)
    cnt = np.zeros((h,w), dtype=np.float)
    # XXX: 需要保证win_gen与patches具有相同长度。此处未做检查
    for (rows, cols), patch in zip(win_gen, patches):
        prob_map[rows, cols] += patch
        cnt[rows, cols] += 1
    prob_map /= cnt
    return prob_map
# 若输出目录不存在,则新建之(递归创建目录)
out_dir = osp.join(EXP_DIR, 'out')
if not osp.exists(out_dir):
    os.makedirs(out_dir)

# 为模型加载历史最佳权重
#state_dict = paddle.load(BEST_CKP_PATH)
# 同样通过net属性访问组网对象
#model.net.set_state_dict(state_dict)

# 实例化测试集
test_dataset = InferDataset(
    DATA_DIR,
    # 注意,测试阶段使用的归一化方式需与训练时相同
    T.Compose([
        T.Normalize(
            mean=[0.5, 0.5, 0.5],
            std=[0.5, 0.5, 0.5]
        )
    ])
)

# 创建DataLoader
test_dataloader = paddle.io.DataLoader(
    test_dataset,
    batch_size=1,
    shuffle=False,
    num_workers=0,
    drop_last=False,
    return_list=True
)
  • 尝试采用skimage中的后处理,效果并不明显
# 影像后处理,涨点无明显变化
from skimage import morphology
def area_connection(result, n_class=2, area_threshold=64, ):
    """
    result:预测影像
    area_threshold:最小连通尺寸,小于该尺寸的都删掉
    """
    # result = to_categorical(result, num_classes=n_class, dtype='uint8')  # 转为one-hot
    result = np.eye(n_class)[result]
    for i in range(n_class):
        # 去除小物体
        result[:, :, i] = morphology.remove_small_objects(result[:, :, i] == 1, min_size=area_threshold,
                                                                  connectivity=1, in_place=True)
        # 去除孔洞
        result[:, :, i] = morphology.remove_small_holes(result[:, :, i] == 1,
                                                                area_threshold=area_threshold, connectivity=1,
                                                                in_place=True)
        # 获取最终label
    result = np.argmax(result, axis=2).astype(np.uint8)


    return result
  • 融合DSIFN和DSAMNet模型

    将两个model输出的output相加求平均,将本项目中两模型融合后涨点显著

# 推理过程主循环
info("模型推理开始")

model2.net.load_dict(paddle.load('/home/aistudio/exp_isfin_fft_crop/best_model/model.pdparams'))
model3.net.load_dict(paddle.load('/home/aistudio/exp_DSAMNet_fft_crop/best_model/model.pdparams'))


model2.net.eval()
model3.net.eval()

len_test = len(test_dataset)
test_patches = crop_patches(
    test_dataloader,
    ORIGINAL_SIZE,
    CROP_SIZE,
    STRIDE
)

with paddle.no_grad():
    for name, (t1, t2) in tqdm(test_patches, total=len_test):
        shape = paddle.shape(t1)
        pred = paddle.zeros(shape=(shape[0],2,*shape[2:]))
        for i in range(0, shape[0], INFER_BATCH_SIZE):
            pred[i:i+INFER_BATCH_SIZE] = (model2.net(t1[i:i+INFER_BATCH_SIZE], t2[i:i+INFER_BATCH_SIZE])[0] + \
                                         model3.net(t1[i:i+INFER_BATCH_SIZE], t2[i:i+INFER_BATCH_SIZE])[0])/2.0
        # 取softmax结果的第1(从0开始计数)个通道的输出作为变化概率
        prob = paddle.nn.functional.softmax(pred, axis=1)[:,1]
        # 由patch重建完整概率图
        prob = recons_prob_map(prob.numpy(), ORIGINAL_SIZE, CROP_SIZE, STRIDE)
        # 默认将阈值设置为0.5,即,将变化概率大于0.5的像素点分为变化类
        out = quantize(prob>0.5)

        imsave(osp.join(out_dir, name), out, check_contrast=False)

info("模型推理完成")
        
  • 增强预测模块仅进行多尺度预测后叠加,但是实验中效果并不明显
# 增强预测:多尺度预测然后叠加(效果不明显)
info("模型推理开始")
aug = True
model.net.eval()
len_test = len(test_dataset)

# CROP_SIZE=256 原封不动
crop_size1 = 256
stride1 = 128
test_patches1 = crop_patches(
    test_dataloader,
    ORIGINAL_SIZE,
    crop_size1,
    stride1
)

crop_size2 = 512
stride2 = 256
# CROP_SIZE=128 缩小一倍
test_patches2 = crop_patches(
    test_dataloader,
    ORIGINAL_SIZE,
    crop_size2,
    stride2
)

crop_size3 = 384
stride3 = 128
# CROP_SIZE=512 放大一倍
test_patches3 = crop_patches(
    test_dataloader,
    ORIGINAL_SIZE,
    crop_size3,
    stride3
)

prob1 = []

with paddle.no_grad():
    for name, (t1, t2) in tqdm(test_patches1, total=len_test):
        shape = paddle.shape(t1)
        pred = paddle.zeros(shape=(shape[0],2,*shape[2:]))
        for i in range(0, shape[0], INFER_BATCH_SIZE):
            pred[i:i+INFER_BATCH_SIZE] = model.net(t1[i:i+INFER_BATCH_SIZE], t2[i:i+INFER_BATCH_SIZE])[0]
        # 取softmax结果的第1(从0开始计数)个通道的输出作为变化概率
        prob = paddle.nn.functional.softmax(pred, axis=1)[:,1]
        # 由patch重建完整概率图
        prob = prob.numpy()
        prob1.append(recons_prob_map(prob, ORIGINAL_SIZE, crop_size1, STRIDE))
    
    count = 0
    for name, (t1, t2) in tqdm(test_patches2, total=len_test):
        shape = paddle.shape(t1)
        pred = paddle.zeros(shape=(shape[0],2,*shape[2:]))
        for i in range(0, shape[0], INFER_BATCH_SIZE):
            pred[i:i+INFER_BATCH_SIZE] = model.net(t1[i:i+INFER_BATCH_SIZE], t2[i:i+INFER_BATCH_SIZE])[0]
        # 取softmax结果的第1(从0开始计数)个通道的输出作为变化概率
        prob = paddle.nn.functional.softmax(pred, axis=1)[:,1]
        prob = prob.numpy()
        # 由patch重建完整概率图
        prob1[count] += recons_prob_map(prob, ORIGINAL_SIZE, crop_size2, STRIDE)
        count += 1
    count = 0
    for name, (t1, t2) in tqdm(test_patches3, total=len_test):
        shape = paddle.shape(t1)
        pred = paddle.zeros(shape=(shape[0],2,*shape[2:]))
        for i in range(0, shape[0], INFER_BATCH_SIZE):
            pred[i:i+INFER_BATCH_SIZE] = model.net(t1[i:i+INFER_BATCH_SIZE], t2[i:i+INFER_BATCH_SIZE])[0]
        # 取softmax结果的第1(从0开始计数)个通道的输出作为变化概率
        prob = paddle.nn.functional.softmax(pred, axis=1)[:,1]
        # 由patch重建完整概率图
        prob = prob.numpy()
        prob1[count] += recons_prob_map(prob, ORIGINAL_SIZE, crop_size3, STRIDE)
        # 默认将阈值设置为0.5,即,将变化概率大于0.5的像素点分为变化类
        prob = prob1[count]/3.0
        count += 1
        out = quantize(prob>0.5)
        imsave(osp.join(out_dir, name), out, check_contrast=False)

info("模型推理完成")
# 推理结果展示
# 重复运行本单元可以查看不同结果

def show_images_in_row(im_paths, fig, title=''):
    n = len(im_paths)
    fig.suptitle(title)
    axs = fig.subplots(nrows=1, ncols=n)
    for idx, (path, ax) in enumerate(zip(im_paths, axs)):
        # 去掉刻度线和边框
        ax.spines['top'].set_visible(False)
        ax.spines['right'].set_visible(False)
        ax.spines['bottom'].set_visible(False)
        ax.spines['left'].set_visible(False)
        ax.get_xaxis().set_ticks([])
        ax.get_yaxis().set_ticks([])

        im = imread(path)
        ax.imshow(im)


# 需要展示的样本个数
num_imgs_to_show = 4
# 随机抽取样本
chosen_indices = random.choices(range(len_test), k=num_imgs_to_show)

# 参考 https://stackoverflow.com/a/68209152
fig = plt.figure(constrained_layout=True)
fig.suptitle("Inference Results")

subfigs = fig.subfigures(nrows=3, ncols=1)

# 读入第一时相影像
im_paths = [osp.join(DATA_DIR, test_dataset.samples[idx]['image_t1']) for idx in chosen_indices]
show_images_in_row(im_paths, subfigs[0], title='Image 1')

# 读入第二时相影像
im_paths = [osp.join(DATA_DIR, test_dataset.samples[idx]['image_t2']) for idx in chosen_indices]
show_images_in_row(im_paths, subfigs[1], title='Image 2')

# 读入变化图
im_paths = [osp.join(out_dir, test_dataset.names[idx]) for idx in chosen_indices]
show_images_in_row(im_paths, subfigs[2], title='Change Map')

# 渲染结果
fig.canvas.draw()
Image.frombytes('RGB', fig.canvas.get_width_height(), fig.canvas.tostring_rgb())

在这里插入图片描述

# 将推理结果打包并压缩为zip文件。如果修改了默认输出目录,也需要在此指令中做出对应修改。
# 官方typo: submission -> submisson
!zip -j submisson.zip /home/aistudio/exp_DSAMNet_fft_crop/out/* > /dev/null

在这里插入图片描述

3 模型部署

!python /home/aistudio/PaddleRS/deploy/export/export_model.py --model_dir=/home/aistudio/exp/best_model --save_dir=/home/aistudio/exp/inference_model/ --fixed_input_shape=[256,256]
/opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages/paddle/tensor/creation.py:130: DeprecationWarning: `np.object` is a deprecated alias for the builtin `object`. To silence this warning, use `object` by itself. Doing this will not modify any behavior and is safe. 
Deprecated in NumPy 1.20; for more details and guidance: https://numpy.org/devdocs/release/1.20.0-notes.html#deprecations
  if data.dtype == np.object:
[32m[05-17 10:12:28 MainThread @logger.py:242][0m Argv: /home/aistudio/PaddleRS/deploy/export/export_model.py --model_dir=/home/aistudio/exp/best_model --save_dir=/home/aistudio/exp/inference_model/ --fixed_input_shape=[256,256]
[0m[33m[05-17 10:12:28 MainThread @utils.py:79][0m [5m[33mWRN[0m paddlepaddle version: 2.2.2. The dynamic graph version of PARL is under development, not fully tested and supported
[0mW0517 10:12:28.526501  2130 device_context.cc:447] Please NOTE: device: 0, GPU Compute Capability: 7.0, Driver API Version: 11.2, Runtime API Version: 10.1
W0517 10:12:28.530683  2130 device_context.cc:465] device: 0, cuDNN Version: 7.6.
2022-05-17 10:12:31 [INFO]	Model[DSIFN] loaded.[0m
[0m2022-05-17 10:12:37 [INFO]	The model for the inference deployment is saved in /home/aistudio/exp/inference_model/.[0m
[0m[0m[0m
import paddlers as pdrs
image1 = '/home/aistudio/data/data134796/dataset/test/A/test_1.png'
image2 = '/home/aistudio/data/data134796/dataset/test/B/test_1.png'

predictor = pdrs.deploy.Predictor('/home/aistudio/exp/inference_model')
# img_file参数指定输入图像路径
result = predictor.predict(img_file=(image1,image2), warmup_iters=1,repeats=2)
import numpy as np
from PIL import Image
score = result[0]['score_map']
print(score.shape)
score1 = np.argmax(score,axis=-1)
print(np.unique(score1))
print(score1.shape)
# Image.fromarray(result[0]['label_map']).show()
score1= np.expand_dims(score1,-1)
Image.fromarray(np.array(score1)*255).convert('RGB').save('/home/aistudio/pic.png')

4 小结

4.1 上分流程

上分过程如表所示

  • crop:将影像和标签裁剪成 256 * 256
  • fft:傅里叶变换
  • [DSIFN+DSAMNet]:模型融合
模型线上精度
BIT0.75145
DSIFN0.7841
DSIFN+crop0.83245
DSIFN+crop+fft0.8502
[DSIFN + DSAMNet]+crop+fft0.87278

4.2 其他

实验过程中尝试过其他trick,效果都不是很明显

  • (1)裁剪影像时尝试过裁剪成512 * 512,效果没有256 * 256好,不考虑效率下选择后者
  • (2)测试过程采用多尺度预测,对DSIFN模型涨点有限,对DSAMNet没用
  • (3)尝试腐蚀膨胀形态学后处理,效果不明显

可以做以下尝试

  • (1)数据上 : 可能是裁剪成256*256的小图后导致TTA效果不明显,考虑适当增大裁剪的大小后再尝试TTA
  • (2)模型上 : 考虑将模型各层特征图可视化来感性观察模型质量随深度的变化,针对不同大小的建筑(变化)对象采用不同深度的 encoder

参考资料


此文仅为搬运,原作链接:https://aistudio.baidu.com/aistudio/projectdetail/3932418

Logo

学大模型,用大模型上飞桨星河社区!每天8点V100G算力免费领!免费领取ERNIE 4.0 100w Token >>>

更多推荐