简介:基于Python的岩石裂缝与CT岩心裂缝语义分割项目,面向岩土工程与计算机视觉交叉领域的毕业设计、课程设计学习者,也适合需要快速上手的初学者;资源将岩石表面裂缝与CT岩心内部裂缝统一处理,解决裂缝目标提取、标注数据不足和模型训练门槛等问题。压缩包共11个文件、大小约1.13MB,主要包含3个Python脚本(承担数据增强、均值计算等预处理功能)、6张岩石/CT裂缝原始图像与对应语义标注图,以及项目说明文档和Git版本管理配置。代码注释详实,从数据准备、脚本运行到分割结果展示都有说明,新手也能看懂;解压后稍作环境配置即可运行,便于对照实际图像检验分割效果。目前已有226人学习参考,适合需要搭建高分毕业设计或期末大作业方案,以及希望借助真实裂缝图像数据集熟悉语义分割完整流程的研究者与开发者。
1. 岩石裂缝与CT岩心裂缝语义分割:源码和数据集能解决什么问题
岩石裂缝与CT岩心裂缝语义分割,就是把岩心样本的CT横切片喂给Python训练的分割模型,让模型把每一条裂缝的像素轮廓从岩石基质里剥离出来。做这件事的现实意义很直接:裂缝的位置、宽度和连通关系一旦变成像素级输出,就可以被下游程序量化,渗透率估算、裂缝密度统计分析乃至三维裂缝网络重建都依赖这个输入质量。
做这个方向最常见的痛苦不是缺模型,而是缺一份能直接用的数据组织和一套能跑通的源码。很多人拿到CT图像后发现,原始TIFF是16位灰度,标注工具导出却是8位JPEG,光是对齐就翻了车。适合两类人:一类是手里有CT切片、需要把裂缝从基质里定量分出来的地质岩土工程师;另一类是刚开始学语义分割,想找一个真实场景的数据和代码落地的CV新手。
2. 用Python准备CT岩心与岩石裂缝数据集:预处理与标注格式
2.1 灰度分布决定预处理方式:先用numpy读一遍直方图
CT岩心图像不是一张普通的自然照片。常见来源是微米级CT扫描仪输出的16位TIFF序列或DICOM栈,单张图像宽度往往在1024到2048像素之间,灰度层级比普通8位图片多得多。如果你直接把cv2.imread读回来的数组丢给模型,大多数情况下图像会是漆黑一片,因为有效灰度值只占了整个uint16区间里很小的一段。
我处理这类数据的第一步从来不是写增强函数,而是先跑一段numpy统计,把灰度分布看明白。这一步能省掉后面大量返工,尤其是当你拿到的是别人重新导出的数据集时,位深和范围往往和原始扫描不一致。
import numpy as np import cv2 from pathlib import Path img_path = Path("data/ct_core/slice_0042.tiff") img = cv2.imread(str(img_path), cv2.IMREAD_UNCHANGED) print("dtype:", img.dtype, "shape:", img.shape, "unique:", np.unique(img).size) lo, hi = np.percentile(img, (1, 99.9)) print("1%分位点:", lo, "99.9%分位点:", hi)代码逻辑说明:cv2.IMREAD_UNCHANGED保证读到的不是被库强制转成8位的数组,从而避免16位像素值被截断。之后percentile用于观察全局灰度分布,两个分位数的跨度是后面做截断归一化的依据。若分位点跨度很窄,比如只有几百到几千,说明大多数像素的灰度聚集在一个小范围内,这时候不能做min-max归一化,否则裂缝这种极暗结构会被整体抹平。
参数说明:这一节有两处关键点。一是读取时的IMREAD_UNCHANGED标志,二是观察直方图所用的分位数。分位数的选择直接影响训练时的对比度控制,我默认用1%和99.9%,在裂缝数据集上比较稳,原因是裂缝属于极小占比结构,如果分位数取到5%以下,会把裂缝分布的尾巴和噪声一起截掉;如果取到99%以上,又会把CT环状伪影和孔隙噪声放大,训练时背景差距被压缩,分割结果反而变差。
拿到灰度统计后,归一化有两种落地习惯。第一种是线性截断后映射到0到255,再把结果存成8位PNG,好处是数据规模小、读起来快;第二种是直接在训练阶段按分位数实时归一化,不落盘。当图像数量过万时,第一种更省内存,但我一般倾向实时归一化,因为落盘转换会引入一次重新保存时的位深损失,而这种损失对裂缝这种弱对比结构是致命的。
还需要注意图像的对齐和伪影问题。CT序列在扫描时会有运动伪影和环状伪影,预处理时最好加一步可选的高斯低通滤波来抑制环形噪声。但滤波核大小不要超过3×3,因为裂缝宽度往往只有2到5个像素,大核会把裂缝直接抹平,这个参数在我的项目里是踩过坑后才定下来的。
2.2 标注格式与数据集目录:让每张图配好一份mask
岩石裂缝标注和普通目标检测标注有明显差别:标注结果是像素级的,不能用矩形框代替。常见落地工具是LabelMe,它导出JSON文件,把多边形坐标记录在文件里,随后通过脚本转换成单通道mask。另一种来自医学影像领域的习惯是直接用ImageJ标注,把裂缝区域打上ROI后导出为8位mask。两种工具我都在用,只要最终落盘格式统一就行。
无论标注工具是什么,最终落到硬盘上的数据集格式务必要方便Dataset加载器直接读取。我惯用的目录组织方式是这样:
data/ train/ images/ core_0001.png core_0002.png masks/ core_0001.png core_0002.png val/ images/ core_0043.png masks/ core_0043.png test/ images/ core_0097.png masks/ core_0097.png在这个结构里,train/val/test按样本划分而不是按扫描序列划分,避免同一根岩心内部的相邻切片出现在训练和验证两个集合里,防止验证指标虚高。每个图像和mask文件名严格一致,mask使用PNG而不是JPEG,因为JPEG压缩会在裂缝边缘产生伪影,这些伪影会作为噪声进入分割输出。
这里有一个非常容易忽略的细节:mask的像素值必须检查。许多标注工具导出时背景是255、目标区域是0,或者标签值不连续,若不先做一次像素值统计就直接进入训练,最后的损失值会怎么看怎么不对。
import numpy as np import cv2 from pathlib import Path for mask_file in Path("data/train/masks").glob("*.png"): mask = cv2.imread(str(mask_file), cv2.IMREAD_UNCHANGED) cls, counts = np.unique(mask, return_counts=True) count_dict = dict(zip(cls, counts)) if len(count_dict) > 3: raise ValueError("%s 类别超预期,需检查标注" % mask_file.name)这段代码跑一次,能发现标注导出时背景和前景颠倒的问题,也能发现是否存在中间值。比如某个项目里标注工具的橡皮擦会留下255的纯白像素,明明每张图只该有两个类别,结果硬是出现第三类,不处理的话模型会学着把白色区域也算成一类。
数据集划分比例我一般取8:1:1,但如果裂缝样本太少,验证集和测试集会遇到完全没有裂缝像素的情况,这时候验证指标会退化成一个全背景预测都能拿高分的假结果,需要人为保证每个集合里都有含裂缝的样本。一个简单做法是在划分时统计各集合mask中的正样本数,让三个集合的正样本比例落在合理区间。
提示:拿到任何别人分享的裂缝分割数据集,第一件事是检查图像位深、mask类别数和图像mask是否同名对齐。三样都没问题,再谈训练。
3. 用Python搭起裂缝分割源码:U-Net结构选择与训练参数
3.1 为什么裂缝分割优先选U-Net而不是DeepLabV3
裂缝语义分割在CV领域属于像素级稠密预测任务,可用模型有FCN、U-Net、DeepLab系列、SegNet和近期基于Transformer的架构。在CT岩心这种弱对比、细线结构的场景里,我一般先推U-Net,而不是一上来就上更大的DeepLabV3。
原因有三个。第一,裂缝宽度只有几个像素,这种细线结构需要浅层高分辨率特征来保持边缘定位精度,而U-Net的编码器-解码器结构会把每层的特征图与对应尺寸的解码端拼接,把浅层的位置信息直接传回解码端,细线不容易丢。第二,U-Net对训练样本量不敏感,在几百张切片的数据规模下就能收敛到可用结果;DeepLabV3的ASPP模块虽然对多尺度语义更友好,但需要更多数据才能发挥出威力。第三,U-Net的推理速度快,可解释性强,后处理改造灵活,对工业落地和科研复现都友好。
相比之下,遥感图像语义分割里常用的那些大模型,到了CT岩心这种前景占比极低、背景高度均匀的图像上,效果未必比U-Net好多少,显存消耗却是成倍增加。要从源码层面快速验证方案,U-Net是最短路径。
3.2 最小可跑的训练源码与关键参数说明
在工程里我用PyTorch实现,原因很简单:torch的Dataset类写起来清晰,灵活性高。下面先展示U-Net编码器的核心结构,帮助理解为什么它适合裂缝分割。
import torch import torch.nn as nn import torch.nn.functional as F class UNetEncoder(nn.Module): def __init__(self, in_ch=1, base_ch=64): super().__init__() self.conv1 = nn.Conv2d(in_ch, base_ch, 3, padding=1) self.conv2 = nn.Conv2d(base_ch, base_ch, 3, padding=1) self.pool = nn.MaxPool2d(2) self.conv3 = nn.Conv2d(base_ch, base_ch * 2, 3, padding=1) self.conv4 = nn.Conv2d(base_ch * 2, base_ch * 2, 3, padding=1) self.conv5 = nn.Conv2d(base_ch * 2, base_ch * 4, 3, padding=1) self.conv6 = nn.Conv2d(base_ch * 4, base_ch * 4, 3, padding=1) def forward(self, x): e1 = F.relu(self.conv1(x)) e1 = F.relu(self.conv2(e1)) e2 = self.pool(e1) e2 = F.relu(self.conv3(e2)) e2 = F.relu(self.conv4(e2)) e3 = self.pool(e2) e3 = F.relu(self.conv5(e3)) e3 = F.relu(self.conv6(e3)) return e1, e2, e3代码逻辑说明:这个编码器就是"卷积+池化"的交替堆叠,每次池化后特征图尺寸减半、通道数翻倍,e1、e2、e3分别保存三层不同分辨率的特征。U-Net解码时会把这些特征通过跳跃连接与解码端对应尺寸的特征图拼接,从而把高分辨率空间信息保留下来,这也是裂缝边缘定位准的根本原因。写这个示例是为了讲清原理,实际落地我推荐直接用开源U-Net实现,避免手写结构出错。
训练源码里最核心的参数包括输入裁剪尺寸、批大小、初始学习率、损失函数类型。以下是我在裂缝分割上稳定跑通的一组基线:
| 参数 | 建议值 | 说明 |
|---|---|---|
| 输入尺寸 | 512×512 | CT切片过大时用随机裁剪去中心采样 |
| 批大小 | 8 | 视显卡显存增减,8GB显存时不要超过8 |
| 学习率 | 1e-4 | Adam优化器下比较保守 |
| 损失 | Dice + BCE组合 | 裂缝像素占比低时必须用组合损失 |
| 迭代轮数 | 150-300 | 验证IoU连续不升时提前停止 |
class DiceBCELoss(nn.Module): def __init__(self, smooth=1.0): super().__init__() self.smooth = smooth def forward(self, pred, target): pred = torch.sigmoid(pred) bce = F.binary_cross_entropy(pred, target, reduction="mean") pred_flat = pred.view(pred.size(0), -1) target_flat = target.view(target.size(0), -1) intersection = (pred_flat * target_flat).sum(dim=1) dice = 1 - (2 * intersection + self.smooth) / ( pred_flat.sum(dim=1) + target_flat.sum(dim=1) + self.smooth ) return bce + dice.mean()代码逻辑说明:组合损失把BCE和Dice同时作为学习信号。BCE对每个像素做独立判断,即使裂缝像素很少也能提供梯度;Dice损失则直接以预测mask与真值mask的重叠程度为优化目标,防止网络在裂缝像素占比极低时直接预测全背景。smooth参数是为了避免分母为0,默认取1.0,在交集很小的小样本场景里也能稳定求导。
为什么不用纯Dice损失呢?纯Dice损失在裂缝目标占0.5%以下时会因为梯度被少数像素主导而收敛波折,训练重启好几次结果都不一样,这在分割训练里算一个典型的"玄学"问题,其实背后就是正负样本梯度失衡。
4. 裂缝语义分割常见问题与避坑:翻车现象、根因与修法
4.1 训练loss下降但IoU几乎为零
现象:训练时loss从0.7降到0.2,看着一切正常,但每个epoch结束跑验证集,IoU一直在0.05以下,预测结果全是背景。
原因:这里往往不是网络结构问题,而是数据对齐问题。常见原因是预处理时只对灰度图像做了旋转增强,却没对mask做同角度旋转,或者训练集和标签来源于不同的切片编号。另一种情况是mask在存盘时被保存软件以有损压缩重写,裂缝边缘的标注区域被过度平滑,真实像素也发生了偏移。
解决:在训练集里随机挑10张图,把image和mask用numpy横向拼接输出成一张核查图,肉眼比对。更稳妥的办法是在数据集加载器里对image和mask做相同的随机变换,并把验证集的变换固定到确定性状态。
4.2 裂缝像素太少导致loss震荡不收敛
现象:有几张图裂缝面积占整幅图像不到0.3%,训练loss曲线在0.4到0.7之间反复震荡,预览预测结果全是背景,怎么调学习率都没用。
原因:像素级正样本占比太低,BCE loss被背景像素主导,网络更新方向被背景梯度吃掉。这是小目标分割的通病,裂缝这类线性结构尤其明显,因为裂缝在CT切片里是一条连续细线,像素占比极低。
解决:把loss换成Dice+BCE组合,同时控制负样本占比。此外可以做一个裂缝patch采样:在训练数据加载时,优先裁剪包含裂缝像素区域附近的图块,而不仅仅是全图随机裁剪。这样每个训练批次里裂缝像素比例从0.3%提升到3%到10%,收敛稳定性立刻改善。
4.3 灰度归一化范围错误让裂缝消失
现象:明明同一批数据,换了台机器或者换了个读图库之后,预测结果变成一片白或者一片黑,全图和mask貌似对不上。
原因:根源在CT数据的位深和灰度范围。16位TIFF里的有效值可能只在1000到4000之间,如果代码里直接用了图像的最大最小值做归一化,两张切片各自的灰度范围不同,模型学习到的映射就完全不可靠。
解决:固定归一化参数。在训练集上先统计全局的第1和99.9分位数,把这两个数字写死到配置里,推理时也使用同一组统计值,保证训练和推理的预处理完全一致。
4.4 推理阶段出现大量碎片化裂缝噪声
现象:预测出的mask里,裂缝旁边散布着很多孤立亮点,形态学处理后还是不干净,面积不大但数量极多。
原因:裂缝在CT图像中是低对比度目标,网络推理时对每个像素独立输出概率,单看某个点的邻域上下文就会把部分噪声点误判成裂缝。另一个来源是训练样本过少,网络没有学到裂缝的连续性先验。
解决:在推理后接一个基于连通域的后处理。将预测概率经过阈值0.5后,做一次连通域标记,把面积小于设定阈值的连通区域剔除,因为真实裂缝几乎不可能只有几个孤立像素构成。面积阈值通常设置为总像素数的0.1%。
5. 裂缝分割推理与评估:IoU、Dice与滑动窗口后处理
5.1 像素级评估指标的正确打开方式
分割任务里最常用的指标是IoU和Dice系数,但裂缝场景里单纯的IoU有时会骗人。裂缝只有一个类别且面积占比太低,如果模型把裂缝预测得稍微宽几个像素,IoU会因为交集像素变化剧烈而骤降,即使裂缝轮廓的主观效果看起来完全可用。所以我一般不只盯IoU,还会同时看边界IoU和裂缝召回率。
边界IoU只看边缘像素附近的预测正确率,如果预测比真值宽一个像素,普通IoU可能掉到0.5以下,边界IoU会更敏感地反映出边缘偏移。裂缝召回率则回答一个问题:真裂缝里被模型找到的比例是多少。三者的组合才能判断模型到底是位置错、宽度错,还是完全漏检。
import numpy as np def compute_iou(pred, target, eps=1e-6): pred = (pred > 0.5).astype(np.uint8) inter = np.logical_and(pred, target).sum() union = np.logical_or(pred, target).sum() return (inter + eps) / (union + eps) def compute_dice(pred, target, eps=1e-6): pred = (pred > 0.5).astype(np.uint8) inter = np.logical_and(pred, target).sum() return (2 * inter + eps) / (pred.sum() + target.sum() + eps)代码逻辑说明:先做阈值二值化,再统计交集和并集。eps的引入是为了避免空预测或空真值导致分母为0,典型的除零保护写法。边界IoU计算时需要先提取边界像素,常见做法是用形态学膨胀把边界带留出来再做交集运算,这一项我给的权重比普通IoU更高,因为裂缝识别最值钱的是位置准确性而不是完整覆盖。
5.2 大尺寸CT切片的推理:滑动窗口与后处理
CT切片原始尺寸往往超过模型输入尺寸,比如2048×2048,没法直接用整图推理。常规做法是滑动窗口推理:以步长裁剪固定大小的图块,单独推理,再把概率图拼回去。裁剪时必须考虑相邻图块的重叠程度,如果重叠为0,落在窗口边缘的裂缝会被截断丢失。
我的实际建议如下:窗口大小固定为训练时的输入尺寸512×512,步长设置为窗口大小的1/2,即相邻窗口重叠50%,保证裂缝连续性。推理完成后再把概率图按位置拼接,重叠区域取多次推理的平均值,避免边缘处的不连续感。
如果显存允许,可以用FP16半精度推理,显存缩小一半,批量处理更多图块。对典型的2560×2048岩心CT切片,我用512窗口、步长256、批大小4推理,拼回的概率图边界平滑度比不重叠版本好不少。
注意:推理阶段必须使用与训练阶段完全相同的归一化方式。训练时统计的分位数要写死在配置里,推理时不能再重新算一遍全图分布,否则两个阶段的输入分布不一致,预测结果会系统性偏移。
6. 验证分割效果的技巧:随机切块可视化与三元检查
最常见的验证方式是把预测mask和标注mask按像素叠加成一张三通道图:红色通道是预测,绿色通道是标注,两者重叠则显示为黄色。这张图一眼能看出模型是偏保守还是偏激进,是预测多了还是预测少了,比看IoU数字直观得多。我每次跑完模型都会出一批这样的对比图,数量保持20到30张,覆盖不同深度、不同岩性和不同裂缝密度。
更进一步的验证是量化单条裂缝的连续性指标。从预测mask的连通域列表里,计算最大连通区域的长度、面积、以及它和真值的重合程度,这一步可以直接排查模型是否把长裂缝断成几段。若存在这样的问题,后处理里增加一步基于裂缝方向的关键点连接往往就能修复。
另一个习惯是把验证集切块分类统计。我会抽出一部分随机图块,按包含裂缝像素的占比分成0到0.1%、0.1%到1%和1%以上三档,逐一统计IoU。这样你会发现模型在低裂缝占比的图块上表现远低于高占比图块,如果想继续优化,就针对低占比区做patch采样和增强就够了,方向比盲目换骨干网络清晰得多。
我以前也迷信过"换一个更强的解码器就能解决一切",后来发现对裂缝这种弱对比细线结构,归一化一致性、patch采样的裂缝比例均衡和推理后处理带来的提升要远远大于换backbone。每拿到一批新CT数据,我先跑完前几轮检查上面的验证图,再谈训练调整,这个流程至今还在用。希望帮到你。
本文还有配套的精品资源,点击获取