
简介面向岩石裂缝与CT岩心裂缝语义分割任务这份压缩包提供一套基于Python的完整实现与配套数据集适合计算机视觉初学者、毕业设计及课程设计学生使用能够帮助读者从零跑通语义分割流程。压缩包共11个文件主体为3个Python脚本和6张演示图片脚本覆盖训练、预测及数据预处理图片包含岩石与混凝土表面裂缝以及CT岩心图像的真值标注示例另有说明文档和配置文件便于快速部署与复现整体约1.13MB。数据增强与均值统计等预处理逻辑已封装在脚本中可直接修改参数适配新数据真值标注示例也有助于理解语义分割的标签组织与类别映射减少自行采集和标注成本。当前已有225人学习下载压缩包轻量易用适合作为毕业设计、期末大作业或课程设计的高分参考项目。1. 岩石裂缝与CT岩心分割为什么普通语义分割模型在这里容易翻车把CT岩心切片喂给通用语义分割模型第一轮训练结果往往惨不忍睹裂缝在图像里只占不到2%的像素而背景、孔隙、矿物晶体占据了绝大部分。用Python做岩石裂缝与CT岩心语义分割要解决的正是这种极端类别不平衡下的逐像素分类问题——模型要把每一条裂缝从灰度、噪声和伪影中抠出来而不是把整张图分成“有裂缝”和“没裂缝”两大块。这个方向在油气储层评价、岩土工程、数字岩心分析里是刚需裂缝的宽度、走向、连通性直接决定渗透率估算和压裂方案设计。适合的读者是地质或岩石物理方向的研究生、做储层评价的工程师以及想把手头CT数据跑出定量结果的Python使用者。前置要求不高装好Python会读DICOM或RAW格式的CT数据有一个能跑PyTorch的GPU就能从头复现这条链路。2. 数据准备CT切片如何变成能训练的语义分割数据集2.1 CT岩心数据从哪来自采数据与公开数据集的取舍这个方向的数据来源分两条路。第一条是自采数据实验室的Micro-CT或工业CT扫描岩心柱输出格式通常是DICOM序列、RAW体数据或NRRD文件。单块岩心柱扫描下来可能有几千张切片每张是16位灰度图像素尺寸从几个微米到几十个微米不等——这正是裂缝分割的第一道门槛灰度范围极大矿物密度差异会把裂缝信号压得很低。第二条是公开数据集网上可以找到一些岩石CT图像和裂缝标注的开源数据但质量参差不齐有的标注只画了粗裂缝、漏了微裂缝有的连掩码格式都不统一。我的建议是如果条件允许优先用自采数据配合人工标注哪怕切片数量少一些也好过拿脏数据训出一个看起来精度高、实际没法用的模型。拿到数据之后的第一步不是急着做标注而是先做一次切片筛选。CT扫描出来的切片里岩心上下两端通常有夹具痕迹或空气边界中间部分才有分析价值。我会先写一个脚本把整段体数据读进来按灰度均值做一次粗糙筛选把纯空气切片和岩石切片分开再决定用哪些切片做训练。这一步看着简单实际上能省掉后面大量无效标注时间。2.2 DICOM与RAW切片预处理归一化、裁剪与增强预处理的核心目标是让模型看到的是“稳定的岩石纹理”而不是“不同扫描条件造成的灰度差”。CT切片是16位灰度不同批次的扫描条件、重建算法会让灰度分布差很多如果不做规范化模型会把扫描参数当成特征学进去换一台设备扫描就彻底失效。import pydicom import numpy as np import cv2 def load_dicom_to_png(dicom_path, output_size(512, 512)): # 读取DICOM文件 ds pydicom.dcmread(dicom_path) img ds.pixel_array.astype(np.float32) # 裁剪到岩石区域去掉四周空气和背景 # 先做一次阈值粗筛灰度极低的区域是空气或孔隙 bg_mask img (img.min() 10) coords np.argwhere(~bg_mask) if len(coords) 0: return None y_min, x_min coords.min(axis0) y_max, x_max coords.max(axis0) img img[y_min:y_max1, x_min:x_max1] # 归一化到0-1保留灰度相对关系 img - img.min() img / (img.max() - img.min() 1e-8) # 缩放或裁剪到固定尺寸 img cv2.resize(img, output_size, interpolationcv2.INTER_LINEAR) return img.astype(np.float32)这段脚本做的事情用pydicom读入DICOM像素数组转成float32用阈值把空气背景裁掉减少无效区域做最小最大归一化把灰度映射到0到1最后统一缩放到512乘512。需要说明的是归一化用的是单张切片的最小最大值不是全局统一。CT切片之间灰度本身就不同步单张归一化能让每张图都有足够的动态范围模型看到的是纹理差异而不是灰度绝对值。如果你发现单张归一化让背景噪点被过度放大可以改成切片的百分位数截断用2%到98%的灰度范围来做缩放抗噪声能力会好很多。做完裁切和归一化后训练增强也很关键。裂缝方向在地质上有规律但模型不该依赖方向——旋转增强和翻转让模型学到方向无关的裂缝特征。我一般用albumentations库里面直接有旋转、水平翻转、弹性形变和随机亮度增强的组合。弹性形变用得要谨慎岩石裂缝是刚性的过度形变会让裂缝形态偏离真实。我通常只开小幅度alpha控制在10以内。2.3 标注掩码制作从LabelMe多边形到语义分割需要的PNG掩码语义分割模型的标签是单通道PNG掩码每个像素的值是类别编号0是背景1是裂缝。标注工具最常用的是LabelMe它的输出是JSON多边形坐标文件需要转成掩码图。这一步的坑在于LabelMe导出的坐标是归一化坐标还是像素坐标取决于你保存的方式转掩码前要确认坐标类型否则多边形会偏移甚至跑到画面外。import json import numpy as np import cv2 from labelme import utils def labelme_json_to_mask(json_path, output_png, img_shape(512, 512)): # 读取LabelMe标注文件 with open(json_path, r, encodingutf-8) as f: data json.load(f) # 创建一个全背景的单通道掩码 mask np.zeros(img_shape, dtypenp.uint8) # 遍历所有标注区域裂缝标注的label统一设置为1 for shape in data[shapes]: points np.array(shape[points], dtypenp.int32) if shape[label] crack: cv2.fillPoly(mask, [points], color1) # 保存为PNG注意是单通道不是三通道彩色图 cv2.imwrite(output_png, mask)这段代码的逻辑是读取JSON里每个多边形顶点用OpenCV的fillPoly填充多边形内部为1最后存成单通道PNG。这里有一个常见做法是直接把原始切片也导出成PNG和掩码目录一一对应训练时按文件名配对加载。需要留意两个边界第一如果一张切片里有多个裂缝每个裂缝单独标一个多边形没问题最后都会被填充为同一个类别值第二如果裂缝细到只有一到两个像素宽标注时放大图片再画否则掩码里裂缝会断成碎点模型学成“噪声识别器”。标注环节是整个项目里最耗时也最影响精度的部分一个人标一个月不夸张标注一致性直接决定模型上限这部分没有捷径可走。3. 语义分割模型选型与训练从U-Net到DeepLabV3怎么选3.1 为什么优先选U-Net裂缝目标小跳跃连接是保命设计裂缝分割的模型选择我推荐直接上U-Net或者它的变体U-Net。原因不是U-Net最先进而是它在小目标和弱边缘场景下的工程表现最稳定。U-Net的编码器逐层下采样提取语义特征解码器逐层恢复空间分辨率关键设计是每一层都有跳跃连接把编码器的高分辨率低语义特征直接拼到解码器里。裂缝细、边缘弱经过多次下采样后细节基本丢光如果没有跳跃连接把浅层细节传回来解码器恢复出的裂缝边界就糊成一团。DeepLabV3用的是空洞卷积加ASPP模块在语义理解上更强适合大目标或者类别边界清晰的场景。但裂缝的边界定义本身就很模糊岩石CT里裂缝和微孔隙在灰度上接近单纯依赖空洞卷积的多尺度感受野容易把相邻的孔隙簇也激活成裂缝。如果你只有几千张训练切片DeepLab系列参数量大更容易过拟合。相比之下标准U-Net参数量只有几百万小数据集稳定收敛这是它在这个场景一直没被淘汰的根本原因。实际项目中我更常用的是一个很小的改动把U-Net的骨干从普通卷积换成ResNet-34编码器。这样训练速度略慢一些但特征提取能力明显增强特别是在裂缝和矿物边界对比度很低的情况下ResNet残差连接能保留更多梯度信号。如果你在训练时发现loss下降很慢可以考虑这个替换。3.2 核心训练代码数据加载、损失函数与训练循环import torch import torch.nn as nn import torch.nn.functional as F from torch.utils.data import Dataset, DataLoader class CrackDataset(Dataset): def __init__(self, img_dir, mask_dir, file_list): self.img_dir img_dir self.mask_dir mask_dir self.file_list file_list def __len__(self): return len(self.file_list) def __getitem__(self, idx): name self.file_list[idx] # 读取图片和掩码统一为CHW格式 img cv2.imread(f{self.img_dir}/{name}.png, cv2.IMREAD_GRAYSCALE) mask cv2.imread(f{self.mask_dir}/{name}.png, cv2.IMREAD_GRAYSCALE) img torch.from_numpy(img).float().unsqueeze(0) / 255.0 mask torch.from_numpy(mask).long() return img, mask class DiceBCELoss(nn.Module): def __init__(self, weight0.5): super().__init__() self.weight weight def forward(self, logits, target): # BCE处理像素级分类 bce F.binary_cross_entropy_with_logits(logits, target.float()) # Dice处理类别不平衡裂缝只有1%像素也不至于被忽略 pred torch.sigmoid(logits) smooth 1.0 intersection (pred * target.float()).sum() dice 1 - (2 * intersection smooth) / (pred.sum() target.sum() smooth) return self.weight * bce (1 - self.weight) * dice损失函数是裂缝分割里最敏感的组件。单纯用交叉熵背景像素占98%以上模型会倾向把所有像素都预测成背景来获取极低的loss裂缝完全学不到。Dice loss直接计算预测和真值掩码的重叠度天然不敏感于类别不平衡。但是纯Dice在小目标上容易震荡训练初期的梯度方向很不稳定。Dice加上BCE的组合是最稳妥的折中BCE给出稳定的像素级梯度Dice让注意力集中在裂缝区域。权重weight建议设在0.5附近如果裂缝特别细可以调到0.3加大Dice的占比。训练循环本身和常规分割任务没有本质差别但要显式控制几个参数patch size和batch size成反比切片分辨率高时用滑动窗口切成256或384的patch训练。CT切片动辄2048乘2048整图送入显存直接爆掉切patch是必然选择。我一般用256的patchbatch size设8到16初始学习率1e-4配合CosineAnnealing或ReduceLROnPlateau调度器。这个配置能在GTX 3080级别显卡上稳定训练显存占用约6到8GB。3.3 训练参数参考表一套能跑起来的起点配置参数推荐值说明输入patch256 x 256切得太小会截断裂缝连续性太大显存压力高batch size8-16视显存调整小于8会导致梯度波动大初始学习率1e-4Adam优化器下这个量级最稳损失函数DiceBCELoss(weight0.5)类别极度不平衡时的标准解法轮数100-150裂缝分割收敛慢少于50轮基本看不出效果数据增强旋转/翻转/小幅弹性弹性形变alpha控制在10以内骨干网络U-Net ResNet-34小数据集首选参数量适中一个反直觉的经验是训练轮数不要看loss绝对值要看验证集Dice。裂缝分割的loss在40轮之后下降速度已经很慢但Dice可能还在稳步上升因为裂缝边界只要多正确一个像素Dice就涨一点。我在实际项目里至少训100轮每10轮保存一次权重最后选择验证集Dice最高的那个权重而不是最后一轮。4. 评估与推理mIoU之外还要看连通域和裂缝宽度误差4.1 评估指标计算Dice和IoU在裂缝场景下的解读方式语义分割的标准评估指标是mIoU和Dice但裂缝场景里这两个指标的解读需要格外小心。裂缝细预测结果但凡偏移一个像素IoU就会大幅下降导致两个模型一个是72的IoU一个是70的IoU你以为第二个差一些实际上可能只是它在边界处整体偏移了半个像素对后续计算裂缝宽度的影响并不大。我现在的做法是同时看Dice和预测掩码的连通域数量如果一个模型Dice略低但连通域数量和真值更接近它在地质统计里反而更可靠。import numpy as np def compute_dice_iou(pred_mask, true_mask, smooth1.0): # pred_mask和true_mask都是0/1二值数组 pred pred_mask.flatten() true true_mask.flatten() intersection np.logical_and(pred, true).sum() union np.logical_or(pred, true).sum() iou (intersection smooth) / (union smooth) dice (2 * intersection smooth) / (pred.sum() true.sum() smooth) return dice, iou这段代码没有做什么特殊处理就是标准公式。但我要提醒的是测试集切片的选择不要随机抽要保证测试集里覆盖有微裂缝的切片、有宽裂缝的切片、有矿物边界的切片。如果测试集里全是裂缝清晰的切片评估结果会虚高到没有参考价值。4.2 推理后处理从概率图到干净的二值掩码模型输出的是每个像素属于裂缝的概率需要设置阈值转成二值图再做后处理去掉孤立噪点。这一步不做好模型能力再强最终交付的结果也会让地质工程师皱眉。import scipy.ndimage as ndimage def postprocess_prob(prob_map, threshold0.5, min_area5): # 概率图转二值掩码 binary (prob_map threshold).astype(np.uint8) # 中值滤波消除孤立点噪声 binary ndimage.median_filter(binary, size3) # 连通域分析去除面积小于min_area的碎屑 label_image, num_features ndimage.label(binary) for label_idx in range(1, num_features 1): component (label_image label_idx) if component.sum() min_area: binary[component] 0 return binary后处理的逻辑分三步阈值二值化、中值滤波、连通域面积过滤。min_area的选取要看你切片的实际分辨率256像素patch里小于5个像素的连通域基本就是噪声。阈值0.5是一个相对保守的默认值如果模型训练充分、裂缝预测置信度较高可以往下降到0.3来召回更细的微裂缝如果发现预测结果里噪声多就往上升到0.7。这个参数我没有一个固定的推荐值因为它和你的数据信噪比强相关最稳妥的做法是拿三张验证切片扫一遍0.3到0.7的阈值观察裂缝完整性和噪声量级的平衡点。这一步通常一小时内能搞定但它是决定交付效果的关键调参环节。4.3 结果可视化与切片级验证训练和推理做完后我会生成一批带叠加线的对比图原始切片、真值掩码、预测掩码以及它们的边界叠加图。生成叠加图时要保持原始切片的灰度纹理可见预测掩码用半透明红色或者绿色描边这样地质工程师能直接看出裂缝的连通性是否合理。一个值得做的验证是沿岩心深度方向把连续切片的预测结果叠起来形成裂缝在三维空间里的分布图。如果裂缝在相邻切片之间出现大量不连贯的断点说明模型要么把某段裂缝漏掉了要么把噪声识别成了裂缝应该回到那一部分切片的原始图像检查标注质量或CT扫描状态。这种逐切片的纵向一致性检查往往比单看Dice分数更能暴露模型的实际问题。5. 裂缝分割的常见坑与排查清单现象、原因、解决5.1 模型把所有像素都预测成背景训练loss降不下去这是裂缝分割最典型的翻车现场。现象是训练了二十个epoch验证集预测掩码全是黑的Dice分数直接是0。原因几乎都是类别不平衡裂缝像素占比不到1%交叉熵loss会被背景完全主导模型找到的最优解就是输出全背景。解决方法是换用Dice损失或者加权交叉熵我前面给的DiceBCELoss可以直接解决这个问题。如果换了损失函数后前几个epoch仍然全背景检查一下数据加载部分确认掩码里的裂缝值确实是1而不是255很多标注导出流程会把灰度255当目标值模型的类别数就不对了。5.2 CT伪影和微孔隙被当成裂缝预测结果出现大量细碎纹路现象是预测掩码上布满密密麻麻的细线肉眼看去和裂缝很像但地质上完全不连续。原因有两类一是CT扫描时的环状伪影和线束硬化在图像里形成了规则纹理模型把这些纹理学成了裂缝特征二是微孔隙以链条状排列灰度上和裂缝一样暗模型无法区分。解决思路是双管齐下预处理阶段用频域滤波做一次伪影抑制把环状伪影的高频能量压掉训练阶段在标注里明确区分“开裂裂缝”和“孔隙链”不要让标注人员把孔隙链也标成裂缝。后处理阶段可以增加一个裂缝线性度过滤——裂缝通常是长条形的而孔隙团块是等轴的用连通域的长宽比过滤掉短粗的响应区域能救回一部分误检。5.3 标注尺度不统一真值掩码里裂缝粗细因人而异让三个人标注同一批切片一个人把裂缝标到3像素宽另一个人可能标到10像素宽。这个差异在训练时不算大问题但评估时会导致Dice上限被锁死——同一个模型和同一个输入和不同的真值比对分数能差出8个百分点。我经历过的最头疼的时候是标注返工整个数据集的真值要求统一到同一条标准。解决办法是做一个标注规范把规则定死裂缝的外边界由灰度突变的最大梯度位置决定标注线中心沿着最小灰度区域走宽度覆盖到发光晕带上并且每标完50张做一次交叉检查用三人标注的重叠度来校准尺度。5.4 GPU显存不足大尺寸CT切片根本无法训练CT切片动辄2000乘2000以上直接放进GPU必爆显存。现象是torch.cuda.OutOfMemoryError一句话总结就是显存被吃光了。原因很清楚激活值占了显存大头分辨率越高激活值越大。解决方法是切patch让模型在这个场景下能跑起来我用256乘256的patch训练时从每张切片里随机采样几个patch相当于把每张图的训练权重分散到多个局部区域。推理时不用patch拼接直接全图送入模型显存占用只比训练略低一般也没问题。如果你的切片尺寸实在太大可以考虑用大滑动窗口加重叠拼接重叠区取均值避免patch边界上出现断裂痕迹。5.5 模型训练后泛化差换一个岩心就失效同一块岩心训出来的模型换到另一口井的岩心切片上预测效果急剧下降。这是岩石裂缝分割里最常见也最让人沮丧的问题。原因是不同岩心有不同的矿物成分、孔隙结构和灰度分布模型学到的是当前数据集的表面纹理特征。解决思路是引入域随机化训练阶段做大幅度的灰度扰动、噪声注入、对比度变化强迫模型不依赖绝对灰度分布另一方面可以在新岩心上只标注很少量的切片做微调几十张就可以显著恢复效果。微调时的学习率要调低到1e-5防止旧数据学的特征被覆盖掉。6. 进阶用法从裂缝掩码到骨架提取与裂缝宽度统计分割掩码只是起点。地质工程师真正关心的是裂缝带有多宽、连通性多好、整体方向朝向哪。我把分割结果接上骨架提取和距离变换直接输出裂缝宽度直方图。骨架提取用skimage.morphology.skeletonize把裂缝掩码细化成单像素骨架然后用distance_transform_edt计算每个骨架点到最近背景的距离这个距离乘2就是该点的裂缝宽度。from scipy.ndimage import distance_transform_edt from skimage.morphology import skeletonize def crack_width_stats(binary_mask, pixel_size_um10.0): # 骨架提取 skeleton skeletonize(binary_mask.astype(np.uint8) 0) # 每个点离背景最近的距离 dist distance_transform_edt(binary_mask 0) # 骨架位置上的距离值就是裂缝半径 widths dist[skeleton 0] * 2 * pixel_size_um return widths.mean(), widths.median(), widths.max()骨架提取后我还会对骨架上每个点做局部方向拟合方向角聚合成极坐标直方图就能看出裂缝主方向是否和岩心层理面一致。这个统计结果可以直接画进项目报告里。再往上走就是3D重构——把相邻切片的分割结果按扫描间距叠合用连通域在三维空间中的延伸来区分真裂缝和层理面但这一步需要整段岩心的连续切片预测工程量大很多。在这个方向做了一年后我最大的教训是模型训练只占整个项目周期的三分之一数据清洗、标注规范和线下验证才是决定成败的部分。裂缝语义分割不是花活每一份可靠的宽度和连通性统计都需要前面每个环节较真。希望这些踩过的坑能帮你少走一段弯路。本文还有配套的精品资源点击获取