ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Python实现岩石CT岩心裂缝语义分割:从数据到U-Net实战

2026/9/23 14:41:50 拓冰建站 浏览量
Python实现岩石CT岩心裂缝语义分割:从数据到U-Net实战 简介这份资源面向地质、石油工程与计算机视觉方向的学习者提供一套基于Python的岩石裂缝与CT岩心裂缝语义分割完整实践材料帮助读者掌握从图像读取、标注解析到模型训练与评估的全流程。压缩包共10个文件包含6张jpg示例图像岩石、混凝土及CT岩心原图与对应标注图、3个py脚本数据增强与均值计算等预处理工具以及1份md说明文档整体约1.12MB体量轻便便于快速上手。目前已有207人学习下载。资源以U-Net类编码器-解码器结构为核心覆盖数据增强、归一化、训练集与验证集划分、IoU评估等关键环节读者可据此复现裂缝分割实验理解CT岩心无损检测中裂缝识别对储层渗透率与储油能力分析的意义并在此基础上调整超参数、更换损失函数或引入跳跃连接以优化分割精度适合作为深度学习在地质图像领域落地的入门与进阶参考。1. 从一张 CT 岩心切片说起岩石裂缝语义分割到底在解决什么钻井取心拿到手的岩心肉眼能看到的裂缝往往只是冰山一角。真正决定储层渗流能力的是那些在 CT 扫描下才显形的微裂缝、层理缝和溶蚀缝。问题在于一台工业 CT 扫一根岩心动辄产生几百到上千张切片靠地质人员一张张描裂缝既慢又不一致——同一条缝两个人画出来的宽度能差一倍。基于 Python 的岩石裂缝与 CT 岩心裂缝语义分割要干的就是把「像素级判断这个点是不是裂缝」这件事交给模型输出一张和原图同尺寸的掩膜mask裂缝为白、基质为黑。这套东西适合三类人做数字岩心、岩石力学、储层表征的研究生和工程师手里已经有一批 CT 切片、想快速统计裂缝面积率和走向的人以及想拿一个真实工业场景练语义分割的算法同学。它不神秘本质是二分类语义分割难点全在数据本身——裂缝细、对比度低、样本极不平衡。下面我按「数据怎么整 → 模型怎么搭 → 怎么训 → 坑在哪 → 怎么验证」的顺序把一条能复现的路子讲清楚。2. 数据准备CT 岩心切片从原始格式到可训练掩膜2.1 先搞清楚你手里的是什么数据CT 岩心数据常见的几种形态处理方式完全不同先对号入座再动手数据形态典型格式处理要点原始投影/重建体数据TIFF 序列、RAW、DICOM需要先切片、做灰度归一化已切好的二维切片PNG、JPG、BMP直接进数据集注意位深带标注的掩膜PNG8 位、npy确认 0/1 还是 0/255体素级标注npy、h5需按层导出成二维我一般会先写个脚本把整个目录的尺寸、位深、灰度范围扫一遍。这一步别省后面 80% 的翻车都源于「以为数据是干净的」。import os import numpy as np from PIL import Image def scan_dataset(img_dir, mask_dirNone): 扫描图像与掩膜的基本统计信息提前暴露位深/尺寸不一致问题 stats [] for name in sorted(os.listdir(img_dir)): path os.path.join(img_dir, name) img np.array(Image.open(path)) info { name: name, shape: img.shape, dtype: str(img.dtype), min: int(img.min()), max: int(img.max()), } if mask_dir: mpath os.path.join(mask_dir, name) if os.path.exists(mpath): m np.array(Image.open(mpath)) # 统计前景像素占比判断类别是否极度不平衡 info[fg_ratio] round(float((m 0).mean()), 4) info[mask_vals] np.unique(m).tolist()[:5] stats.append(info) return stats if __name__ __main__: for s in scan_dataset(data/images, data/masks)[:5]: print(s)这段代码做三件事读图、记录形状和位深、统计掩膜前景占比。fg_ratio是关键指标——如果普遍低于 0.02说明裂缝像素占比不到 2%后面损失函数必须做加权否则模型会直接学会「全预测背景」也能拿到 98% 准确率。mask_vals用来确认标注是 0/1 还是 0/255这决定你要不要做二值化。2.2 灰度归一化与 CLAHE让裂缝从背景里浮出来CT 切片常见问题是整体偏暗、裂缝和基质灰度接近。直接送进网络模型很难学。我习惯做两步先按全局百分位裁剪避免个别极亮噪点拉偏再做 CLAHE限制对比度自适应直方图均衡。import cv2 import numpy as np def preprocess_ct(img, clip_percent(1, 99), clahe_clip2.0, tile8): CT 切片预处理百分位裁剪 CLAHE 增强 if img.dtype ! np.uint8: # 先线性拉伸到 0-255保留相对灰度关系 lo, hi np.percentile(img, clip_percent) img np.clip((img - lo) / (hi - lo 1e-6), 0, 1) img (img * 255).astype(np.uint8) clahe cv2.createCLAHE(clipLimitclahe_clip, tileGridSize(tile, tile)) return clahe.apply(img)参数说明clip_percent(1,99)表示掐掉最暗 1% 和最亮 1%CT 里这些通常是噪点或高密度矿物clahe_clip2.0是经验值调太高会把噪声也放大成假裂缝我一般从 2.0 试到 3.0tile8表示把图分成 8×8 块分别均衡块太小会引入块状伪影。注意训练集和验证集必须用同一套预处理参数否则验证指标会虚高。2.3 数据集划分与增强别让同一条裂缝同时出现在训练和验证里这是最容易踩的坑。CT 切片是连续的相邻切片几乎一样。如果你随机按张划分训练集里的第 100 张和验证集里的第 101 张是同一根岩心的连续层模型等于「见过答案」验证 IoU 会高得离谱上线就崩。正确做法是按岩心样本或按连续层段划分同一根岩心的切片要么全在训练要么全在验证。增强方面裂缝分割对几何变换敏感我一般只用水平/垂直翻转和 90 度旋转不做大角度仿射——裂缝的走向是有地质意义的乱转会破坏先验。import random def split_by_core(samples, val_ratio0.2, seed42): 按岩心编号分组划分避免相邻切片泄漏 cores {} for s in samples: core_id s.split(_)[0] # 假设命名如 core01_slice003.png cores.setdefault(core_id, []).append(s) core_ids list(cores.keys()) random.Random(seed).shuffle(core_ids) n_val max(1, int(len(core_ids) * val_ratio)) val_cores set(core_ids[:n_val]) train [s for c in core_ids if c not in val_cores for s in cores[c]] val [s for c in val_cores for s in cores[c]] return train, valval_ratio0.2是常规起点样本少时可以到 0.3。seed固定保证可复现。如果你的命名没有岩心编号那就退而求其次按文件序号分段划分但一定要在论文或报告里写清楚划分方式否则结果没法复现。3. 模型选型U-Net 为什么仍是裂缝分割的稳妥起点3.1 在 U-Net、DeepLabV3 和 SAM 之间怎么选热搜里 deeplabv3语义分割、耕地识别 sam语义分割 都很火但放到 CT 岩心裂缝这个场景选型逻辑不一样U-Net编码器-解码器 跳跃连接对小目标、细结构友好参数量可控几百张图就能训出可用结果。裂缝分割的首选。DeepLabV3空洞卷积扩大感受野适合大场景语义但裂缝是细长结构空洞卷积容易把细缝「漏掉」且对标注量要求更高。SAM 类基础模型零样本能力强但 CT 灰度图和自然图像分布差异大直接推理往往把层理、矿物边界也当成裂缝需要微调成本不低。我的建议先用 U-Net 跑通 baseline拿到可信的 IoU再考虑换 backbone 或加注意力。别一上来就上大模型数据没整明白换什么模型都是玄学。3.2 一个够用的 U-Net 实现import torch import torch.nn as nn class DoubleConv(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.net nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), ) def forward(self, x): return self.net(x) class UNet(nn.Module): def __init__(self, in_ch1, out_ch1, base32): super().__init__() # 编码器逐层下采样base 控制模型宽度 self.e1 DoubleConv(in_ch, base) self.e2 DoubleConv(base, base * 2) self.e3 DoubleConv(base * 2, base * 4) self.e4 DoubleConv(base * 4, base * 8) self.pool nn.MaxPool2d(2) self.bottleneck DoubleConv(base * 8, base * 16) # 解码器转置卷积上采样 跳跃连接拼接 self.up4 nn.ConvTranspose2d(base * 16, base * 8, 2, stride2) self.d4 DoubleConv(base * 16, base * 8) self.up3 nn.ConvTranspose2d(base * 8, base * 4, 2, stride2) self.d3 DoubleConv(base * 8, base * 4) self.up2 nn.ConvTranspose2d(base * 4, base * 2, 2, stride2) self.d2 DoubleConv(base * 4, base * 2) self.up1 nn.ConvTranspose2d(base * 2, base, 2, stride2) self.d1 DoubleConv(base * 2, base) self.out nn.Conv2d(base, out_ch, 1) def forward(self, x): e1 self.e1(x) e2 self.e2(self.pool(e1)) e3 self.e3(self.pool(e2)) e4 self.e4(self.pool(e3)) b self.bottleneck(self.pool(e4)) d4 self.d4(torch.cat([self.up4(b), e4], dim1)) d3 self.d3(torch.cat([self.up3(d4), e3], dim1)) d2 self.d2(torch.cat([self.up2(d3), e2], dim1)) d1 self.d1(torch.cat([self.up1(d2), e1], dim1)) return self.out(d1)in_ch1因为 CT 是灰度图out_ch1输出单通道 logits配合 BCE 损失。base32是宽度基数显存不够就降到 16但别低于 16否则细裂缝特征提取能力明显下降。跳跃连接是 U-Net 的命根子它把编码器的高分辨率细节直接送到解码器裂缝这种细结构全靠它。3.3 损失函数Dice BCE 组合是裂缝分割的标配裂缝像素占比极低纯 BCE 会被背景主导。Dice 损失直接优化重叠度对不平衡更鲁棒。我一般用0.5 * BCE 0.5 * Dice。class DiceBCELoss(nn.Module): def __init__(self, bce_weight0.5): super().__init__() self.bce nn.BCEWithLogitsLoss() self.bce_weight bce_weight def forward(self, logits, targets): bce self.bce(logits, targets) probs torch.sigmoid(logits) # 按样本算 Dice 再平均避免大图主导 num 2 * (probs * targets).sum(dim(1, 2, 3)) den probs.sum(dim(1, 2, 3)) targets.sum(dim(1, 2, 3)) 1e-6 dice 1 - (num / den).mean() return self.bce_weight * bce (1 - self.bce_weight) * dicebce_weight0.5是平衡点如果前景占比低于 1%可以调到 0.3 让 Dice 主导。1e-6防止除零。注意 Dice 是按样本算再平均不是全 batch 一起算否则大图会压过小图。4. 训练与调参让模型真正学会「找缝」而不是「猜背景」4.1 训练循环与关键超参import torch from torch.utils.data import DataLoader from dataset import CrackDataset # 自定义 Dataset返回 (img, mask) def train(model, train_ds, val_ds, epochs80, lr1e-3, batch_size8, devicecuda): model.to(device) opt torch.optim.AdamW(model.parameters(), lrlr, weight_decay1e-4) sched torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_maxepochs) criterion DiceBCELoss(bce_weight0.5) train_loader DataLoader(train_ds, batch_sizebatch_size, shuffleTrue, num_workers4) val_loader DataLoader(val_ds, batch_sizebatch_size, shuffleFalse, num_workers4) best_iou 0.0 for ep in range(epochs): model.train() for img, mask in train_loader: img, mask img.to(device), mask.to(device) opt.zero_grad() loss criterion(model(img), mask) loss.backward() opt.step() sched.step() iou evaluate(model, val_loader, device) if iou best_iou: best_iou iou torch.save(model.state_dict(), best_unet.pth) print(fepoch {ep1} val_iou {iou:.4f} best {best_iou:.4f}) return best_iou超参说明lr1e-3配 AdamW 是 U-Net 的常规起点loss 震荡就降到 3e-4weight_decay1e-4抑制过拟合CosineAnnealingLR让学习率平滑衰减比阶梯衰减更稳batch_size8在 512×512 输入、base32 下大约占 6-8G 显存显存小就减到 4 并同步降 lr。epochs80是经验值裂缝数据集通常 50-100 轮收敛看验证 IoU 不再涨就可以停。4.2 评估指标IoU 之外必须看召回率裂缝分割里IoU 高不代表好用。如果模型漏掉细缝但把粗缝画得很准IoU 可能还行但地质上细缝恰恰是关键渗流通道。所以必须同时看召回率Recall和F1。torch.no_grad() def evaluate(model, loader, device, thr0.5): model.eval() inter union tp fn 0 for img, mask in loader: img, mask img.to(device), mask.to(device) pred (torch.sigmoid(model(img)) thr).float() inter (pred * mask).sum().item() union ((pred mask) 0).sum().item() tp (pred * mask).sum().item() fn ((1 - pred) * mask).sum().item() iou inter / (union 1e-6) recall tp / (tp fn 1e-6) return iou * 0.5 recall * 0.5 # 综合分用于选最优模型thr0.5是默认阈值但裂缝分割里这个阈值往往偏高——模型对细缝的响应弱0.5 会漏。我一般会在验证集上扫 0.3-0.6选召回和 IoU 平衡最好的那个。这个综合分只是用来选模型报告里还是要分别给出 IoU 和 Recall。4.3 类别不平衡的三种处理手段对比手段做法适用场景副作用损失加权BCE 里给前景更高权重前景占比 1%-5%权重过高导致误检增多Dice 损失直接优化重叠度前景 1%训练初期不稳定难例挖掘对预测错的像素加大权重细缝漏检严重实现复杂易过拟合难例我的习惯是先用 DiceBCE 组合如果细缝召回还是低再上难例挖掘。别一上来就三管齐下调不动的时候你都不知道是哪个在起作用。5. 避坑与排查裂缝分割里最常见的 5 个翻车现场5.1 验证 IoU 0.9上线一塌糊涂现象验证集 IoU 高得离谱换一批新岩心切片模型几乎全预测背景。原因按张随机划分导致相邻切片泄漏模型记住了训练样本。解决改成按岩心编号分组划分见 2.3重新训练。划分后 IoU 通常会掉 0.1-0.2那才是真实水平。5.2 模型把所有暗色区域都当成裂缝现象预测掩膜里出现大片连通的「假裂缝」其实是低密度基质或孔洞。原因预处理 CLAHE 参数过激把基质噪声也放大了或者训练集里负样本太少。解决把clahe_clip从 3.0 降到 2.0 甚至 1.5检查训练集是否只有含裂缝的切片补入纯基质切片作为负样本。5.3 细裂缝整条消失现象粗缝画得挺好1-2 像素宽的微裂缝完全漏掉。原因下采样过程中细结构信息丢失或者 BCE 权重让模型偏向「保守预测」。解决把base从 16 提到 32 或 48损失里提高 Dice 占比bce_weight降到 0.3推理阈值从 0.5 降到 0.35 试试。5.4 训练 loss 不降或震荡现象loss 在前几轮就卡住或者来回跳。原因学习率太大或者掩膜没做二值化标签里有 0/255 混着 0/1。解决lr 降到 3e-4在 Dataset 里强制mask (mask 0).float()统一成 0/1。这个坑我踩过不止一次标签不干净调什么参数都白搭。5.5 显存爆了但 batch_size 已经很小现象batch_size 降到 2 还是 OOM。原因输入分辨率太大比如 1024×1024或者 num_workers 开太多导致内存泄漏。解决训练时随机裁剪到 512×512 或 384×384推理时再滑窗拼接num_workers设成 2-4 就够别开 16。6. 进阶技巧用滑窗推理和形态学后处理把细缝捞回来训完模型只是第一步真正决定交付质量的是推理阶段。CT 切片分辨率往往很大直接缩放会丢细缝我一般用滑窗推理 重叠拼接再配一套轻量后处理。import numpy as np import cv2 import torch def sliding_inference(model, img, window512, stride384, devicecuda, thr0.4): 滑窗推理重叠区域取平均避免拼接缝 model.eval() h, w img.shape[:2] prob np.zeros((h, w), dtypenp.float32) count np.zeros((h, w), dtypenp.float32) for y in range(0, h, stride): for x in range(0, w, stride): y2, x2 min(y window, h), min(x window, w) y1, x1 max(0, y2 - window), max(0, x2 - window) patch img[y1:y2, x1:x2] t torch.from_numpy(patch).float()[None, None].to(device) / 255.0 with torch.no_grad(): p torch.sigmoid(model(t))[0, 0].cpu().numpy() prob[y1:y2, x1:x2] p count[y1:y2, x1:x2] 1 prob / np.maximum(count, 1e-6) mask (prob thr).astype(np.uint8) # 形态学后处理闭运算连接断裂细缝再去掉孤立小噪点 kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations1) num, labels, stats, _ cv2.connectedComponentsWithStats(mask, 8) clean np.zeros_like(mask) for i in range(1, num): if stats[i, cv2.CC_STAT_AREA] 20: # 去掉面积小于 20 像素的噪点 clean[labels i] 1 return cleanwindow512和stride384意味着相邻窗口有 128 像素重叠重叠区取平均能消除拼接痕迹。thr0.4比训练时的 0.5 低是为了捞回细缝响应。闭运算的 3×3 椭圆核能把断成几截的细缝连起来但iterations别超过 1否则会把两条平行缝糊成一条。面积阈值 20 是经验值取决于你的分辨率——分辨率高就调大。验证这套流程是否靠谱我有个笨办法但很有效挑 5 张验证集切片把模型预测和人工标注叠在一起看裂缝用红色、标注用绿色、重合用黄色。黄色越多越好红色成片说明误检绿色成片说明漏检。这个可视化比任何指标都直观也是我每次交付前必做的一步。最后说个习惯我从来不直接信验证集上的最高分模型而是把最后 10 个 epoch 的权重都存下来挑召回率最高的那个。裂缝分割里漏一条缝的代价远大于多画一条假缝——假缝可以靠形态学过滤漏掉的缝是找不回来的。这套流程从数据扫描到滑窗推理跑通一遍大概两三天值得投入。希望帮到你。本文还有配套的精品资源点击获取