
简介基于DenseUnet的岩石盐体图像分割实战项目为深度学习入门者与地质遥感研究人员提供了一条完整可复现的路径。资源包含Python训练、评估、预测三个核心脚本代码注释详尽配合约20MB轻量压缩包可快速完成从数据准备到模型推理的全流程。包内共2000个文件以1984张PNG图像为主涵盖岩石盐体原始图与标注掩膜另有8个py脚本、5个xml配置文件、2个txt说明和1个README文档便于理解项目结构与自定义扩展。训练脚本自动生成loss、IoU、学习率衰减曲线及训练日志帮助用户直观监控模型收敛情况评估脚本计算测试集的IoU、召回率、精确率等指标预测脚本输出GT与叠加掩膜图像方便结果可视化。目前已有56人学习使用适合需要动手实践分割算法、或希望替换自有数据集进行训练的用户参照README即可一键运行。1. DenseUnet盐体分割从地震剖面到像素级掩膜的实战起点盐体是油气勘探中的重点构造但它在二维地震剖面上往往表现为一团高振幅、弱层理的杂乱反射区边界被断层和低速带磨得模棱两可。基于DenseUnet的盐体图像分割本质上是把问题转成逐像素二分类每个点要么属于盐体要么属于围岩。相比普通U-NetDenseUnet在编码器里用稠密连接替代单路卷积让深层网络仍能拿回浅层的细纹理解码器再用跳跃连接把高层语义与低层空间细节拼在一起。下面会从网络结构、数据预处理、损失函数到推理后处理把这套方案完整走一遍。适用对象是已经跑通U-Net、想在低信噪比图像上提升分割边界质量同时习惯看参数和具体踩坑点的工程师。2. DenseUnet网络结构拆解Dense Block、Transition Down 与跳跃连接2.1 Dense Block 的稠密连接每一层都“见过”前面所有层DenseNet 的核心思想是一个Block内每一层卷积的输出都会和之前所有层的输出在通道维上拼接而不是像ResNet那样做加法。设Block初始输入通道数为 K0growth rate 为 g每层产出 g 个新通道则第 L 层输入通道数为 K0 (L-1)*g。这样的好处很直接反向传播时梯度可以从网络深度任意位置直接流向早期层缓解了深层语义网络常见的梯度消失。对盐体分割来说盐体内部纹理变化不大真正难的是边界处微弱振幅差异而稠密连接相当于把不同感受野、不同抽象程度的特征全部暴露在卷积层面前特征重用率高同等效果下所需参数更少。落地到 Keras一个简洁的Dense Block函数如下def dense_block(x, growth_rate16, n_layers4): Dense Block每一层输出 growth_rate 个特征图并拼到主干上。 for _ in range(n_layers): y tf.keras.layers.BatchNormalization()(x) y tf.keras.layers.ReLU()(y) y tf.keras.layers.Conv2D(4 * growth_rate, 1, paddingsame)(y) # 降维 y tf.keras.layers.BatchNormalization()(y) y tf.keras.layers.ReLU()(y) y tf.keras.layers.Conv2D(growth_rate, 3, paddingsame)(y) # 只新增 g 个通道 x tf.keras.layers.Concatenate()([x, y]) # 通道维拼接 return x这里每个子层先经过BN和ReLU再用1x1卷积把通道压到 4*g目的是降低3x3卷积的计算量随后3x3卷积真正产出 g 个特征图并与输入 x 拼接。参数上growth_rate 建议在12~24之间取n_layers 即Block内卷积层数取4~6层。注意 g 不要贪大否则第一个Block之后通道数快速膨胀后层的显存占用会明显增加在101x101分辨率的地震剖面上g16、深度4层的配置通常比 g32 更稳后面我会给出完整参数表。2.2 Transition Down 与 Transition Up编码器如何降采样、解码器如何恢复分辨率U形结构的关键在于两件事编码器逐步把空间尺寸降下来换取更大的感受野解码器再逐步把特征图还原到原图尺寸。DenseUnet里编码器每个Dense Block之后接Transition Down常见做法是 BN ReLU 1x1卷积 2x2平均池化或最大池化解码器则用转置卷积或双线性上采样做Transition Up并把对应编码器输出拼回来。我一般用平均池化做降采样因为盐体边界是连续的灰度变化平均池化能保留背景趋势而最大池化会更突出局部极值容易放大噪声。这个阶段的Keras实现片段def transition_down(x, out_channels): x tf.keras.layers.BatchNormalization()(x) x tf.keras.layers.ReLU()(x) x tf.keras.layers.Conv2D(out_channels, 1, paddingsame)(x) x tf.keras.layers.AveragePooling2D(2, strides2)(x) return x def transition_up(x, out_channels): x tf.keras.layers.Conv2DTranspose(out_channels, 3, strides2, paddingsame)(x) return xTransition Down的1x1卷积不只是降维它会在降采样前对Dense Block产出的冗余通道做一次压缩例如把通道压到上一层的 2/3控制解码器拼接后的计算量。Transition Up的输出通道一般设成下一层Dense Block的输入或者编码器对应层的通道数这样跳跃连接才能对齐通道数直接concat。实际工程里转置卷积的kernel可以取2或3取2步长2不会重叠但缺少跨像素信息取3步长2会有1个像素重叠对边界更友好我推荐3。2.3 为什么盐体图像分割选DenseUnet而不是普通U-Net普通U-Net每个层只有一条前向路径信息要逐层“压缩-恢复”深层特征图会丢掉很多高频细节DenseUnet把每个Block内部的特征通道全部保留下来解码器上采样后拿到的跳跃连接里既有高层语义也有局部纹理。对盐体分割来说最典型的问题是盐体边界并不总是落在灰度突变处有时要依靠周围沉积层位的形状推断DenseNet式连接让网络更容易捕捉到这些跨尺度的上下文。另一个差别在参数效率要达到相近精度DenseUnet往往比同深度U-Net少20%到30%的参数但训练时显存占用反而更高因为中间特征图全都被保留下来用于拼接。所以在显存有限时我通常会把growth_rate调小而不是减少Block层数这样精度的损失相对可控。训练时如果发现GPU占用告警优先检查是否在保存特征图时显存峰值翻倍而不是急着换更大的显卡。2.4 DenseUnet关键参数表下表是盐体分割任务上比较常用的一组起点参数参数推荐值说明growth_rate16每层新增通道数影响显存和容量每Block层数4深度6层以上要配合dropoutBlock数量4编码器4个Dense Block对应4次下采样初始通道32第一层卷积通道数据集小就降到24Transition Down压缩率0.5把通道压到原来的一半drop_rate0.1~0.2仅在深层Block使用输入尺寸128x128训练时统一尺寸推理可滑窗工程上常见的做法是先把这组参数跑通再调growth_rate和Block数量。如果测试IOU在0.5以下徘徊优先看数据预处理而不是网络层数。后面第4章还会给出训练相关参数。3. 盐体数据集准备与预处理尺寸归一化、数据增强与生成器写法3.1 数据目录结构、尺寸与mask格式盐体分割数据集一般包含两类文件地震剖面灰度图和同名mask图。地震剖面通常以16位或32位浮点保存灰度范围不稳定不能直接除以255作为归一化常见做法是先把每张剖面按2%和98%分位数做截断再映射到[0,1]这样可以顶住噪声尖峰。图像尺寸上公开的盐体挑战赛数据集的剖面是101x101但对于DenseUnet我建议用随机裁剪或双线性插值统一到128x128方便4次下采样后特征图尺寸为8x8。mask是单通道二值图0为围岩1为盐体。目录结构上保持简单salt_dataset/ train/ images/ # 地震剖面命名 001.png masks/ # 同名mask命名 001.png val/ images/ masks/这类数据的特殊点在于同一个数据集里可能有的剖面完全没有盐体有的盐体占了大半张图。如果直接按文件顺序切训练集和验证集很容易把某一类样本全分到验证集里。常见做法是按“是否含盐”分层抽样确保训练和验证都同时包含两类样本比例尽量接近。3.2 用 Keras Sequence 写数据生成器数据量不大但建议写生成器避免一次性载入内存。用tf.keras.utils.Sequence的子类实现import tensorflow as tf import numpy as np import cv2 class SaltGenerator(tf.keras.utils.Sequence): def __init__(self, image_paths, mask_paths, batch_size8, size(128, 128), augmentFalse): self.images image_paths self.masks mask_paths self.batch_size batch_size self.size size self.augment augment def __len__(self): return int(np.ceil(len(self.images) / self.batch_size)) def __getitem__(self, idx): batch_img [] batch_mask [] for i in range(idx * self.batch_size, min((idx 1) * self.batch_size, len(self.images))): img cv2.imread(self.images[i], cv2.IMREAD_UNCHANGED) msk cv2.imread(self.masks[i], cv2.IMREAD_GRAYSCALE) img, msk self._preprocess(img, msk) batch_img.append(img) batch_mask.append(msk) x np.stack(batch_img, axis0) y np.stack(batch_mask, axis0)[..., np.newaxis] # 变成 HxWx1 return x, y def _preprocess(self, img, msk): if img.ndim 2: img img[..., np.newaxis] # 统一尺寸 img cv2.resize(img, self.size, interpolationcv2.INTER_LINEAR) msk cv2.resize(msk, self.size, interpolationcv2.INTER_NEAREST) # 分位数截断归一化 lo, hi np.percentile(img, [2, 98]) img np.clip((img - lo) / (hi - lo 1e-6), 0, 1) if self.augment: img, msk self._augment(img, msk) return img.astype(np.float32), msk.astype(np.float32)这段代码的要点是mask使用INTER_NEAREST最近邻插值避免缩放时盐体边界被插出中间值图像用双线性插值保留灰度渐变。__getitem__返回的 y 会补一个通道维因为二分类输出头是1个通道的sigmoid。归一化放在每个样本内做而不是全数据集统一计算原因在于地震剖面之间测井增益不一致逐样本截断更符合实际资料特点但代价是不同剖面的相对灰度强弱被抹掉了如果地质背景差异不大问题不大。3.3 盐体分割专用的数据增强组合盐体剖面与其他遥感图像不一样翻转有明确物理意义地质剖面可以左右平移但不能随便转90度因为地震数据有垂直深度方向。推荐的增强组合是随机水平翻转概率0.5、随机平移/缩放8%以内、随机0~10度旋转以及轻度对比度扰动。不建议用弹性形变因为会破坏层位的平行关系。代码里可以在_augment方法中使用cv2.warpAffine配合随机变换矩阵。def _augment(self, img, msk): rows, cols img.shape[:2] # 水平翻转 if np.random.rand() 0.5: img cv2.flip(img, 1) msk cv2.flip(msk, 1) # 小角度旋转和平移 angle np.random.uniform(-10, 10) dx np.random.uniform(-0.08, 0.08) * cols dy np.random.uniform(-0.08, 0.08) * rows mat cv2.getRotationMatrix2D((cols/2, rows/2), angle, 1.0) mat[0, 2] dx mat[1, 2] dy img cv2.warpAffine(img, mat, (cols, rows), flagscv2.INTER_LINEAR) msk cv2.warpAffine(msk, mat, (cols, rows), flagscv2.INTER_NEAREST) return img, mskangle取±10度而不是90度是因为地震剖面的垂向是深度旋转90度后的样本物理上不成立。若你的数据源包含三维地震体的inline切片才可以把90度旋转当作数据扩充。增强概率和幅度不宜过大否则模型会花容量去学习无关形变判别增强是否合适的标准是增强后的mask和图像边界是否仍然大致吻合。如果发现盐体边缘被旋转“切”出锯齿可以加一点mask腐蚀后处理。增强方式参数说明水平翻转p0.5沿测线方向对称保留层理旋转±10度超出范围会破坏深度方向平移缩放±8%模拟剖面位置轻微偏移对比度扰动增益0.8~1.2补偿不同测井增益差异4. 训练DenseUnet的关键配置损失函数、学习率与类别不平衡4.1 BCE Dice组合损失函数怎么写盐体在整幅图里通常只占10%到30%很多样本甚至完全没有盐体普通二分类交叉熵BCE会让网络快速学会“全部预测为0”因为空图的loss最低。Dice Loss对前景占比不敏感更适合小目标但单独用Dice会导致训练早期梯度不平滑。常见做法是把两者按比例相加def dice_loss(y_true, y_pred, smooth1e-6): y_true tf.cast(y_true, tf.float32) y_pred tf.sigmoid(y_pred) intersection tf.reduce_sum(y_true * y_pred) return 1 - (2.0 * intersection smooth) / (tf.reduce_sum(y_true) tf.reduce_sum(y_pred) smooth) def combined_loss(y_true, y_pred): bce tf.keras.losses.BinaryCrossentropy()(y_true, y_pred) return bce 0.5 * dice_loss(y_true, y_pred)这里的 y_pred 是未过sigmoid的logitsdice_loss 内部计算sigmoid避免与BCE的数值稳定性冲突。权重0.5表示Dice部分只起辅助作用如果空盐体样本占比超过50%可以把权重提到1.0或改成 BCE 2*Dice让网络更重视形态匹配。观察训练日志时注意loss数值的绝对值没有直接意义要看训练集与验证集的Dice或IOU趋势。4.2 模型编译与训练调参表我一般用Adam初始学习率1e-3配合ReduceLROnPlateau在验证损失三个epoch不降时把学习率减半。batch size在512MB显存上取8128x128输入下相对稳妥。model.compile(optimizertf.keras.optimizers.Adam(1e-3), losscombined_loss, metrics[tf.keras.metrics.MeanIoU(num_classes2, namemiou), tf.keras.metrics.BinaryAccuracy(nameacc)]) callbacks [ tf.keras.callbacks.ReduceLROnPlateau(monitorval_loss, factor0.5, patience3, min_lr1e-6), tf.keras.callbacks.EarlyStopping(monitorval_miou, patience10, restore_best_weightsTrue), tf.keras.callbacks.ModelCheckpoint(denseunet_salt_{epoch:02d}_{val_miou:.3f}.h5, monitorval_miou, save_best_onlyTrue) ] model.fit(train_gen, validation_dataval_gen, epochs60, callbackscallbacks)MeanIoU在这里的 num_classes2但输出通道只有1个所以IOU计算会自动把预测概率以0.5为阈值转成类别再统计。实测下来训练集miou超过0.7、验证集还在0.5附近徘徊多半是过拟合解决办法不是加更多数据而是把 drop_rate 从0.1提高到0.3或在Transition Down里加SpatialDropout2D。如果验证集一直小于0.4先检查mask是否错位或者归一化是否把盐体区域压没了。训练参数推荐值适配场景optimizerAdam默认即可不需要Nadam初始学习率1e-3配合衰减batch_size8128x128输入显存紧张就降到4最大epochs60配合EarlyStopping使用学习率衰减验证损失3轮不降减半盐体分割用loss比miou更稳4.3 空盐体样本如何处理空盐体样本不能直接丢弃因为它教会模型“什么不是盐”。但大批量空样本会让Dice Loss变成常数干扰优化。常见的做法有两个一是按“含盐样本与空样本比约为2:1”重新采样训练集二是在损失里加一个类别权重比如给正样本更高的权重。我用的是重新采样在构造数据集时先统计每个mask的面积把含盐样本复制两份并入列表。另一种更简单的方式是在生成器里按空样本概率跳过但要注意不能跳过太多否则每个epoch的有效样本数不等于列表长度。空盐体占比处理方案适用阶段小于20%不必特殊处理小规模试跑20% ~ 50%损失加权或重采样正常训练大于50%重采样确保每个batch至少有1张含盐图工程落地4.4 训练曲线怎么看、什么时候停重点看 val_miou 和 train_miou 的gap。gap小于0.05说明模型刚从欠拟合走出来可以继续训练gap在0.1到0.2之间说明开始过拟合EarlyStopping生效后会自动恢复最佳权重gap超过0.25基本可以断定数据分布出了问题。盐体分割里最常见的诡异曲线是前几个epoch miou是0因为Dice Loss在初始阶段梯度很小logits接近0.5附近预测出的mask全是半透明灰色IOU按0.5阈值一算就是0。这时不要停等10个epoch左右再看。如果BCE Loss在降但Dice不降把学习率调低一半再续训。5. 推理与后处理把预测掩膜变成干净盐体区域的三个技巧5.1 滑窗推理避免显存瓶颈训练用的128x128输入到了推理阶段不一定够。完整地震剖面通常有几百到几千列直接整图预测很容易超出显存或丢失盐体的整体连续性。常见做法是128x128滑窗、重叠32像素把每个窗口的预测概率按位置平均后再拼回原图。重叠区域取平均可以有效抑制窗口边缘的条纹伪影这在地震剖面上特别明显。stride 96 probs np.zeros((H, W), dtypenp.float32) weight np.zeros_like(probs) for y in range(0, H - size 1, stride): for x in range(0, W - size 1, stride): patch cv2.resize(img[y:ysize, x:xsize], (128, 128)) p model.predict(patch[..., np.newaxis][np.newaxis, ...], verbose0) probs[y:ysize, x:xsize] p[0, ..., 0] weight[y:ysize, x:xsize] 1.0 probs / np.maximum(weight, 1e-6)stride96表示步长小于窗口尺寸即窗口间有32像素重叠相当于每张剖面最终输出概率是多个窗口预测的加权平均。注意边缘不满128的窗口需要pad推理后再裁掉否则剖面上下边界会出现异常高亮。显存充足时可以把步长减小到64重叠更多结果更平滑但推理时间会翻倍。5.2 后处理三件套阈值、连通域过滤、形态学闭合模型输出的连续概率要先转二值。阈值不是固定的0.5我常用0.55或0.6因为盐体识别偏保守更实用宁可漏掉边缘也不能把非盐体圈进来建议在验证集上扫一遍0.4到0.8选出最优阈值。第二步是基于连通域过滤统计每个盐体连通域的面积删除小于面积阈值的孤立点比如小于100像素的“盐体”多半是噪声。第三步是形态学闭合用5x5或7x7的结构元素把盐体内部断裂的小孔隙填上但不要做腐蚀因为盐体边界已经很细腐蚀会把真实边缘削掉。ret, binary cv2.threshold(prob, 0.55, 1, cv2.THRESH_BINARY) n, labels, stats, _ cv2.connectedComponentsWithStats((binary * 255).astype(np.uint8), 8) for i in range(1, n): if stats[i, cv2.CC_STAT_AREA] 100: # 小连通域当噪声删除 binary[labels i] 0 kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) binary cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel)连通域按8邻域统计面积阈值要按图像分辨率调整如果把剖面插值到512宽面积阈值可以提到500。处理完后再对比原始地震剖面重点看盐体边界是否把断层切出来的非盐区圈进来了。如果出现又大又散的异常连通域多半是阈值偏低先用0.6再跑一遍。5.3 验证用IOU复算掩膜质量后处理改变了预测结果所以不能再用训练时的miou来说明最终效果。常见做法是单独写一个函数对验证集逐样本计算标准IOU同时统计“空盐体预测为空”的准确率。容易被忽略的是IOU会把大盐体和小盐体等同看待如果验证集里有一张盐体面积达到全图30%的大目标它的IOU会主导平均值掩盖小盐体效果差的问题。所以我会按mask面积分层统计把验证集分成1000像素以下、1000到5000像素、5000像素以上三档分别报IOU。DenseUnet通常在5000像素以上的大盐体上表现最好小盐体更多靠后处理保留。把验证脚本保留下来跑一遍完整验证集记录每一档的IOU分布再决定要不要回去调growth_rate或后处理阈值。本文还有配套的精品资源点击获取