Python实现岩石CT岩心裂缝语义分割:从数据到U-Net实战
2026/9/23 14:41:47 网站建设 项目流程

简介:这份资源面向地质、石油工程与计算机视觉方向的学习者,提供一套基于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直接进数据集,注意位深
带标注的掩膜PNG(8 位)、npy确认 0/1 还是 0/255
体素级标注npy、h5需按层导出成二维

我一般会先写个脚本把整个目录的尺寸、位深、灰度范围扫一遍。这一步别省,后面 80% 的翻车都源于「以为数据是干净的」。

import os import numpy as np from PIL import Image def scan_dataset(img_dir, mask_dir=None): """扫描图像与掩膜的基本统计信息,提前暴露位深/尺寸不一致问题""" 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_clip=2.0, tile=8): """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(clipLimit=clahe_clip, tileGridSize=(tile, tile)) return clahe.apply(img)

参数说明:clip_percent=(1,99)表示掐掉最暗 1% 和最亮 1%,CT 里这些通常是噪点或高密度矿物;clahe_clip=2.0是经验值,调太高会把噪声也放大成假裂缝,我一般从 2.0 试到 3.0;tile=8表示把图分成 8×8 块分别均衡,块太小会引入块状伪影。注意:训练集和验证集必须用同一套预处理参数,否则验证指标会虚高。

2.3 数据集划分与增强:别让同一条裂缝同时出现在训练和验证里

这是最容易踩的坑。CT 切片是连续的,相邻切片几乎一样。如果你随机按张划分,训练集里的第 100 张和验证集里的第 101 张是同一根岩心的连续层,模型等于「见过答案」,验证 IoU 会高得离谱,上线就崩。

正确做法是按岩心样本(或按连续层段)划分,同一根岩心的切片要么全在训练,要么全在验证。增强方面,裂缝分割对几何变换敏感,我一般只用水平/垂直翻转和 90 度旋转,不做大角度仿射——裂缝的走向是有地质意义的,乱转会破坏先验。

import random def split_by_core(samples, val_ratio=0.2, seed=42): """按岩心编号分组划分,避免相邻切片泄漏""" 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, val

val_ratio=0.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, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), ) def forward(self, x): return self.net(x) class UNet(nn.Module): def __init__(self, in_ch=1, out_ch=1, base=32): 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, stride=2) self.d4 = DoubleConv(base * 16, base * 8) self.up3 = nn.ConvTranspose2d(base * 8, base * 4, 2, stride=2) self.d3 = DoubleConv(base * 8, base * 4) self.up2 = nn.ConvTranspose2d(base * 4, base * 2, 2, stride=2) self.d2 = DoubleConv(base * 4, base * 2) self.up1 = nn.ConvTranspose2d(base * 2, base, 2, stride=2) 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], dim=1)) d3 = self.d3(torch.cat([self.up3(d4), e3], dim=1)) d2 = self.d2(torch.cat([self.up2(d3), e2], dim=1)) d1 = self.d1(torch.cat([self.up1(d2), e1], dim=1)) return self.out(d1)

in_ch=1因为 CT 是灰度图;out_ch=1输出单通道 logits,配合 BCE 损失。base=32是宽度基数,显存不够就降到 16,但别低于 16,否则细裂缝特征提取能力明显下降。跳跃连接是 U-Net 的命根子,它把编码器的高分辨率细节直接送到解码器,裂缝这种细结构全靠它。

3.3 损失函数:Dice + BCE 组合是裂缝分割的标配

裂缝像素占比极低,纯 BCE 会被背景主导。Dice 损失直接优化重叠度,对不平衡更鲁棒。我一般用0.5 * BCE + 0.5 * Dice

class DiceBCELoss(nn.Module): def __init__(self, bce_weight=0.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) * dice

bce_weight=0.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, epochs=80, lr=1e-3, batch_size=8, device="cuda"): model.to(device) opt = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-4) sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=epochs) criterion = DiceBCELoss(bce_weight=0.5) train_loader = DataLoader(train_ds, batch_size=batch_size, shuffle=True, num_workers=4) val_loader = DataLoader(val_ds, batch_size=batch_size, shuffle=False, num_workers=4) 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(f"epoch {ep+1} val_iou {iou:.4f} best {best_iou:.4f}") return best_iou

超参说明:lr=1e-3配 AdamW 是 U-Net 的常规起点,loss 震荡就降到 3e-4;weight_decay=1e-4抑制过拟合;CosineAnnealingLR让学习率平滑衰减,比阶梯衰减更稳;batch_size=8在 512×512 输入、base=32 下大约占 6-8G 显存,显存小就减到 4 并同步降 lr。epochs=80是经验值,裂缝数据集通常 50-100 轮收敛,看验证 IoU 不再涨就可以停。

4.2 评估指标:IoU 之外必须看召回率

裂缝分割里,IoU 高不代表好用。如果模型漏掉细缝但把粗缝画得很准,IoU 可能还行,但地质上细缝恰恰是关键渗流通道。所以必须同时看召回率(Recall)F1

@torch.no_grad() def evaluate(model, loader, device, thr=0.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 # 综合分,用于选最优模型

thr=0.5是默认阈值,但裂缝分割里这个阈值往往偏高——模型对细缝的响应弱,0.5 会漏。我一般会在验证集上扫 0.3-0.6,选召回和 IoU 平衡最好的那个。这个综合分只是用来选模型,报告里还是要分别给出 IoU 和 Recall。

4.3 类别不平衡的三种处理手段对比

手段做法适用场景副作用
损失加权BCE 里给前景更高权重前景占比 1%-5%权重过高导致误检增多
Dice 损失直接优化重叠度前景 < 1%训练初期不稳定
难例挖掘对预测错的像素加大权重细缝漏检严重实现复杂,易过拟合难例

我的习惯是先用 Dice+BCE 组合,如果细缝召回还是低,再上难例挖掘。别一上来就三管齐下,调不动的时候你都不知道是哪个在起作用。

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, window=512, stride=384, device="cuda", thr=0.4): """滑窗推理,重叠区域取平均,避免拼接缝""" model.eval() h, w = img.shape[:2] prob = np.zeros((h, w), dtype=np.float32) count = np.zeros((h, w), dtype=np.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, iterations=1) 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 clean

window=512stride=384意味着相邻窗口有 128 像素重叠,重叠区取平均能消除拼接痕迹。thr=0.4比训练时的 0.5 低,是为了捞回细缝响应。闭运算的 3×3 椭圆核能把断成几截的细缝连起来,但iterations别超过 1,否则会把两条平行缝糊成一条。面积阈值 20 是经验值,取决于你的分辨率——分辨率高就调大。

验证这套流程是否靠谱,我有个笨办法但很有效:挑 5 张验证集切片,把模型预测和人工标注叠在一起看,裂缝用红色、标注用绿色、重合用黄色。黄色越多越好,红色成片说明误检,绿色成片说明漏检。这个可视化比任何指标都直观,也是我每次交付前必做的一步。

最后说个习惯:我从来不直接信验证集上的最高分模型,而是把最后 10 个 epoch 的权重都存下来,挑召回率最高的那个。裂缝分割里,漏一条缝的代价远大于多画一条假缝——假缝可以靠形态学过滤,漏掉的缝是找不回来的。这套流程从数据扫描到滑窗推理,跑通一遍大概两三天,值得投入。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询