作者供图
在我的硕士项目期间,我选修了一门叫做 深度学习在高维数据和图像处理中的应用 的课程。这个名字听起来有点吓人,但实际上这门课是我收获最大的课程之一!其中一个项目要求我们构建一个 U-Net 来从航拍图像中识别并标记建筑物。一开始,这项任务看起来很艰巨,但一旦我投入其中,我其实发现我很享受这个过程。虽然很具挑战性并耗时,但这一切绝对值得。
所以让我们一起进入计算机视觉和高维数据的领域;我将带您了解我是如何使用U-Net架构从航拍图像中提取建筑轮廓线的——这种方法帮助我取得了94.7%的准确率和76.7%的Dice系数。如果您感兴趣,可以在我的GitHub仓库中探索该项目的代码。让我们开始吧!🚀
首先,U-Net是什么呢?🤔U-Net 是一种主要用于图像分割任务的CNN。它最初是为生物医学图像的分割而开发的,U-Net 的架构由一个编码器和一个解码器组成,因此它具有一个有趣的 U 形架构。
- 编码器(收缩路径): 编码器是由一系列卷积层(这些层用来找到边缘和模式等特征)和最大池化层(这些层有助于减少图像大小,帮助模型聚焦于最重要的特征)组成🔍。
- 解码器(扩展路径): 解码器由上采样层(这些层恢复原始图像大小)和更多卷积层组成。解码器基本上是细化并重建输出,以创建一个清晰的分割图🛠。
- 跳过连接: U-Net 使用跳过连接,将编码器和解码器中对应的层链接起来,帮助保持细节并提高分割精度。可以将跳过连接想象成高速列车,从U-Net的一端直接到达另一端,避开所有交通,确保重要细节安全传输到另一端 🚄。
UNET架构图 — 图像_ 原始来源](https://arxiv.org/pdf/1505.04597)
数据准备工作在这个项目中,我使用了以下数据集(如下):数据集(在我的Github仓库里可以找到)。
- 图像: 3,347 张 256×256×3 的彩色像素图像,每张图像代表马萨诸塞州内约 300 平方米的区域。
- 标签: 从 OpenStreetMap(开放街道地图) 建筑轮廓导出的二值掩码,指出哪些像素代表建筑物。
Minh等人(2013年)提供的原始数据可公开访问,链接如下:here,可以在这里下载。
数据集分成70%训练集,15%验证集,和15%测试集。在将图像输入模型之前,我将像素值归一化到[0, 1]区间。
这里有个快速的输入输出可视化,
- 左: 左边的航拍图。
- 右: 黑白掩膜,白色部分表示建筑底图。
作者提供图片
类型不平衡数据集显示了明显的类别不平衡,非建筑物的像素远多于建筑物的像素——这种情况在分割任务中非常普遍。
作者的图片
处理类别不均衡问题我试验了其中的一些技术,但发现对我的模型帮助最大的是创建了一个专门的损失函数,该函数结合了二元交叉熵(BCE)和Dice损失。
这又是如何解决阶级不平衡的呢?
所以损失函数是用来衡量模型预测与实际结果之间的差距——每个模型的目标都是把这种差距减到最小。
因为我们只做的是二值分割,也就是区分建筑和背景,一个很好的选择是使用BCE损失函数,因为它会在像素级别上评估损失,也就是说,它会检查每个预测的像素是否接近真实像素。但是只用这种损失函数的问题是,损失会被背景像素这一多数类主导。
这就轮到Dice Loss出场了——它通过观察预测掩模和真实标签之间的重叠来计算损失。
要理解Dice Loss,首先,让我们先来了解一下预测是如何被评估的:
- 正确阳性 (TP): 🏢✅ 被正确识别为建筑物的建筑。
- 错误阳性 (FP): 🌄❌ 背景中的像素被误标为建筑物。
- 错误阴性 (FN): 🏢❌ 建筑物被误标为背景。
- 正确阴性 (TN): 🌄✅ 背景被正确地标记为背景。
骰子系数(也就是图像分割中的F1分数)平衡了准确度(模型标记建筑物像素的准确程度)和召回率(模型实际识别到的建筑物像素的数量)。
基本上就是说,我们来关注一下——“重点关注建筑物,我们正确地识别了哪些,有没有犯错太多?”
虽然Dice系数(F1分数)是衡量模型性能的一个指标(越高表示越好),Dice损失用于在训练期间减少误差(越低表示越好)。
将交叉熵损失和Dice损失结合起来
通过结合这两个损失函数,我们能够既从像素级别的监督中获益,也能从Dice对重叠部分的重视中获益,确保了对两类的敏感度保持平衡。
组装模型 🧱👷♀️在详细介绍模型架构之前,我想快速过一下两种在训练中经常被忽视但能显著提升模型性能的关键方法——这些方法在训练时常常被忽视。
空间 dropout 层为了帮助防止模型在训练数据上过拟合(过于依赖训练数据以至于无法很好地预测新数据),我在模型中加入了一个空间 dropout 层。在训练过程中,它会随机隐藏每个图像中一定比例的像素。这实际上迫使模型不再依赖个别像素,而是关注大局,学习建筑物周围的模式。
这真是一个挺酷的方法,dropout 不仅仅局限于图像模型中的空间层,还可以把它用在其他类型的神经网络上。
内核初始化我认为在构建神经网络时,最容易被忽视的部分之一就是核初始化。这可能是我在硕士期间最难搞懂的概念之一,但一旦我终于搞懂了这个概念,我就知道它在神经网络中有多重要。
所以,它究竟是什么呢?简单来说,它决定了网络各层权重在训练前该如何初始化。你可以把它想象成房子的地基。如果地基薄弱或不平整,无论房子的设计多么出色,结构也不会稳固。
对于我的U-Net模型来说,最佳的初始化器是LeCun Normal。它按输入层的大小成比例地缩放权重,从而有助于减少梯度在通过网络时出现梯度消失或梯度爆炸的风险。
Keras 的功能接口好的,现在我们来进入构建U-Net模型的核心部分。使用Keras API,我定义了一个简化的U-Net模型。
- Keras 功能 API: 一种超级灵活的在 Python 中构建深度学习模型的超级灵活方式,它允许你通过连接不同的层来定义自定义模型架构。
还记得之前的说明,这是U-Net的收缩过程——它处理输入图像,捕捉特征并减少图像尺寸。
每个块包括以下内容:
- 两个卷积层: 每个层都有
relu
激活函数和lecun_normal
核初始化。 - 空间 dropout 层: 在每个卷积层之后添加空间 dropout 层,丢弃率为 10%。
- 最大池化: 将空间维度减半,减小图像尺寸,但确保保留关键特征不变。
def encoder_block(filters, inputs, dropout_rate=0.1, kernel_initializer='lecun_normal'):
# 定义一个二维卷积层,使用ReLU激活函数和特定的初始化器
x = Conv2D(filters, kernel_size=(3, 3), padding='same', strides=1, activation='relu',
kernel_initializer=kernel_initializer)(inputs)
# 应用空间 dropout,防止过拟合
x = SpatialDropout2D(dropout_rate)(x)
# 再次定义一个二维卷积层,使用ReLU激活函数和特定的初始化器
s = Conv2D(filters, kernel_size=(3, 3), padding='same', strides=1, activation='relu',
kernel_initializer=kernel_initializer)(x)
# 再次应用空间 dropout,防止过拟合
s = SpatialDropout2D(dropout_rate)(s)
# 定义一个最大池化层,使用2x2的池化窗口
p = MaxPooling2D(pool_size=(2, 2), padding='same')(s)
# 返回经过处理的 s 和 p
return s, p
我在模型中使用了四个模块(尝试使用更多模块或更多过滤器时,模型表现更糟糕)。
- 卷积块 1: 32 卷积层
- 卷积块 2: 64 卷积层
- 卷积块 3: 128 卷积层
- 卷积块 4: 256 卷积层
这部分模型充当U-Net的瓶颈,位于编码器和解码器之间,。它能在最小的空间维度上捕捉最深层的特征(真的很酷)。它包含以下部分:
- 两个卷积层
- 空间 dropout 或 dropout
基础层有助于为解码器从这些抽象特征帮助重建高分辨率图像奠定基础。
def 基础层(filters, inputs, dropout_rate=0.1, kernel_initializer='lecun_normal'):
x = Conv2D(filters, kernel_size=(3, 3), padding='same', strides=1, activation='relu',
kernel_initializer=kernel_initializer)(inputs)
x = SpatialDropout2D(dropout_rate)(x)
x = Conv2D(filters, kernel_size=(3, 3), padding='same', strides=1, activation='relu',
kernel_initializer=kernel_initializer)(x)
x = SpatialDropout2D(dropout_rate)(x)
return x
解码器模块
如果你还记得前面提到的,解码器是U-Net中负责扩展的部分,通过上采样和特征合并来重建图像。每个解码器部分包含以下内容:
- 上采样过程:使用带有
relu
激活函数的转置卷积层来将空间维度加倍。 - 跳连(Skip Connections):这些结合了相应编码器块的特征,确保网络保留了在下采样过程中丢失的细粒度细节。
记住这些就像从模型一端到另一端的高速列车。 - 两个卷积层:用于进一步细化上采样的特征。
- 空间 dropout 层:有助于模型泛化,即使是在这一阶段。
def 解码器块(filters, connections, inputs, dropout_rate=0.1, kernel_initializer='lecun_normal'):
# 使用反卷积操作扩增输入特征图
x = Conv2DTranspose(filters, kernel_size=(2, 2), padding='same', activation='relu', strides=2,
kernel_initializer=kernel_initializer)(inputs)
# 将跳过连接与当前层输出进行合并
skip_connections = concatenate([x, connections], axis=-1)
# 使用3x3卷积核进行特征图的进一步处理
x = Conv2D(filters, kernel_size=(3, 3), padding='same', activation='relu',
kernel_initializer=kernel_initializer)(skip_connections)
# 添加空间Dropout以防止过拟合
x = SpatialDropout2D(dropout_rate)(x)
# 再次使用3x3卷积核进行特征图的进一步处理
x = Conv2D(filters, kernel_size=(3, 3), padding='same', activation='relu',
kernel_initializer=kernel_initializer)(x)
# 再次添加空间Dropout以防止过拟合
x = SpatialDropout2D(dropout_rate)(x)
# 返回处理后的特征图
return x
最终输出阶段
最后但同样重要的是,模型的最终一层是一个输出通道为单个并且使用sigmoid激活函数的1x1的卷积层。这一层用于预测每个像素属于目标类别(建筑物)或背景的概率。
outputs = Conv2D(1, 1, activation='sigmoid')(d4)
一起来拼这个大拼图吧! 🧩
我们最后的杰作就是这个。
def unet():
inputs = Input(shape = (256, 256, 3)) #定义输入层和图像的形状
#编码器
s1, p1 = encoder_block(32, inputs = inputs)
s2, p2 = encoder_block(64, inputs = p1)
s3, p3 = encoder_block(128, inputs = p2)
s4, p4 = encoder_block(256, inputs = p3)
#瓶颈
baseline = baseline_layer(512, p4)
#解码器
d1 = decoder_block(256, s4, baseline)
d2 = decoder_block(128, s3, d1)
d3 = decoder_block(64, s2, d2)
d4 = decoder_block(32, s1, d3)
#输出函数,用于像素的二值分类
outputs = Conv2D(1, 1, activation = 'sigmoid')(d4)
#完成模型
model = Model(inputs = inputs, outputs = outputs, name = 'Unet')
return model
训练模型 😄
这是我训练模型的方法:
- 学习率: 我用TensorFlow的
ExponentialDecay
来逐渐减少学习率(从0.001开始)。 - 优化器: 我选择了Adam优化器,它会根据每个参数的过去梯度动态调整学习率。
- 批量大小: 8(降低以避免内存溢出)。
- 周期数: 50(在验证损失连续3个周期没有改善时启用早停)。
训练结束后,我通过绘制训练时的准确率与验证时的准确率和训练时的损失与验证时的损失曲线来检查模型性能。
图片由作者创作
训练准确率和验证准确率都呈上升趋势,这表明模型从训练数据中学到了东西,并在验证数据上表现良好。
训练和验证损失都在稳步下降,这意味着模型有效减少了损失。这里全是好消息哦!
模型评估 🧪虽然准确率显示了整体正确预测的百分比,但它没有考虑到我们数据集中的类别不平衡。例如,如果背景区域远多于建筑物区域,模型只需一直预测“背景”就能达到很高的准确率,因为背景像素远多于建筑物像素。这意味着高准确率不一定表示模型很好地辨识了建筑物。
那么我们怎么评价模型分割建筑物的能力到底如何呢?
这就是Dice Metric再次派上用场的地方!与准确性不同,Dice Metric衡量的是预测掩模与真实掩模之间重叠的程度。Dice得分越高,表示模型性能越好,模型捕捉到了预测和实际建筑区域之间的更多交集。
这是我用来计算Dice系数的代码。
def dice_metric(y_true, y_pred):
"""计算ground truth和预测掩模的Dice系数值."""
y_pred = tf.cast(y_pred > 0.5, tf.float32) #将阈值设为0.5
intersection = tf.reduce_sum(y_true * y_pred)
total_sum = tf.reduce_sum(y_true) + tf.reduce_sum(y_pred)
dice = tf.math.divide_no_nan(2 * intersection, total_sum)
return dice
测试集结果(Test Set Results):
在测试集上测试模型后,我得到了以下这些指标:
- 准确性: 94.7%(预测的总体准确率)
- 精确度: 75.2%(实际为建筑的被预测为建筑的比例)
- 召回率: 78.9%(模型正确识别的实际建筑比例)
- Dice指标: 76.7%(预测掩模与实际掩模重叠程度的度量)
在一篇研究论文中,作者们指出,Dice相似系数超过0.7表示在图像分割任务中表现良好(来源:https://pmc.ncbi.nlm.nih.gov/articles/PMC10449747/#:-The%20threshold%20of%200.7%20was%20a%20good%20overlap%20%5B31%5D)。以76.7%的Dice相似系数,该模型在从背景中分割建筑物方面表现得不错。
所以,虽然准确性可以给出性能的一个总体印象,但像 Dice、精确率和召回率这样的指标能提供更深入的理解,关于模型在建筑分割这一特定任务上的表现。从这些结果来看,我们似乎赢了!🎉
用预测的概率来可视化 🔍为了更好地理解模型的表现,我从多个角度可视化了预测,并将其与真实标签进行比较。这一步有助于看出模型在哪些地方表现优秀,哪些地方有所欠缺,这使它成为评估分割任务(如建筑识别)的重要环节。
作者提供图片
从右上角的预测标签和第二张图片中的测试标签进行对比,可以看出,大多数情况下,模型成功地识别出了建筑物,但仍有不足之处。
假阳性图像突出显示了模型错误地将背景区域识别为建筑物的区域,而假阴性图像则揭示了模型将建筑物误判为背景的区域。这些错误表明仍需改进。然而,总体而言,该模型在建筑轮廓分割上做得相当不错,并且在实际应用中表现出很大的潜力!🚀
那么,从这些事情里我们学到了什么呢? 🧠你做到了!真是太棒了!那么,我们从这次经历中学到了什么呢?
- 模型架构很重要:U-Net架构在图像分割任务中表现得非常出色,特别是在识别建筑边界这类任务上。
- 损失函数起关键作用:通过结合二元交叉熵和Dice损失,确保模型在整体准确性和正确分割少数类别之间取得平衡。这在处理类别不平衡时尤为重要。
- 正则化:空间 Dropout 不仅有助于防止过拟合,还能增强模型对新数据的泛化能力。这一步使得训练过程更加稳健。
- 评估超越准确率:像Dice这样的指标能够更好地反映模型性能,特别是在类别不平衡时简单的准确率指标可能不再适用的情况下。
下一步 ✨📈:总能找到改进的地方,我认为下一步应该是实现数据增强技术。这涉及生成现有图像的轻微修改版本,比如翻转、旋转或调整亮度。通过这种方式,训练数据会更加多样化,模型能够学习到更细微的细节,并从而更好地区分建筑物和其他背景。
感谢大家一路的陪伴,谢谢!
共同学习,写下你的评论
评论加载中...
作者其他优质文章