简介:面向Python计算机视觉与地质图像分析领域的课程设计、期末大作业场景,这份资源围绕岩石裂缝与CT岩心裂缝的语义分割问题,提供了一套可运行的Python源码及配套数据集。语义分割需对图像逐像素分类,项目涉及PIL、OpenCV、TensorFlow/PyTorch等常用工具,并包含CT岩心无损检测图像的裂缝识别与量化思路,便于学习者将计算机视觉方法落地到实际岩土工程场景。压缩包共14个文件,以JPG图像样本、Python脚本、备份文件及Markdown说明文档为主,整体大小约1.13MB;其中图像样本涵盖岩石、混凝土、CT扫描原始图及对应的标注图,脚本则覆盖数据增强、均值计算等预处理环节,辅助完成从数据准备到模型训练的分割流程。目前已有171人学习下载,适合需要快速上手语义分割项目、完成课程设计或期末作业的Python及深度学习初学者参考复用。
1. 岩心CT裂缝分割:为什么通用分割模型直接跑会翻车
拿到一份“Python岩石裂缝与CT岩心裂缝语义分割源码及数据集”,本质上就是给你一套能跑的Python分割工程:输入是工业CT扫描得到的岩心切片,输出是逐像素的裂缝掩膜。真正动手后你会发现,这和你平时在自然照片上做的语义分割完全不是一个难度量级。CT切片是16位灰度图,裂缝往往只有几个像素宽,裂缝面积可能占不到整张图的1%,直接用现成的DeepLabV3+权重去推理,常常整张图预测成背景,裂缝全部漏掉。这套资源适合两类人:一类是拿它做课程设计或期末大作业的学生,另一类是做岩石物理、地质分析但刚接触分割模型的从业者。想复现出能用的效果,需要按顺序过数据关、训练关和推理关。
2. 数据集整理与标注转换:从原始CT切片到可训练样本
2.1 数据集目录结构与命名约定
拿到源码包之后,第一件事不是急着读模型代码,而是先清点数据集目录。CT岩心裂缝数据集的常见组织方式是图像和掩膜分开放,命名一一对应。比如:
data/ images/ sample_001.tif sample_002.tif masks/ sample_001.png sample_002.png这里最容易踩的第一个坑是图像格式。images目录里的CT切片经常是16位TIFF,甚至有些原始数据是DICOM格式,而masks目录里的标签是8位PNG。如果直接用cv2.imread默认参数读图,16位灰度会被直接截断成8位,裂缝的灰度细节丢失,后面训练效果会打折扣。
我习惯先用一段脚本把数据集的真实情况摸清楚:
import cv2 import glob image_paths = sorted(glob.glob("data/images/*.tif")) mask_paths = sorted(glob.glob("data/masks/*.png")) for img_p, mask_p in zip(image_paths[:5], mask_paths[:5]): img = cv2.imread(img_p, cv2.IMREAD_UNCHANGED) mask = cv2.imread(mask_p, cv2.IMREAD_GRAYSCALE) print("img", img_p, img.shape, img.dtype, img.min(), img.max()) print("mask", mask_p, mask.shape, mask.dtype, mask.min(), mask.max())这段代码有两个关键点:读图像时用cv2.IMREAD_UNCHANGED,这样才能把16位TIFF的原始灰度范围保留下来;读掩膜时用cv2.IMREAD_GRAYSCALE,确保标签是单通道。输出里如果mask的最大值是255而不是1,说明标注脚本保存时用了255代表前景,后面在计算损失函数时需要统一除以255,否则二分类标签不是[0,1],Dice Loss计算会出错。
命名对应关系同样需要确认。sorted只能保证文件名按字符串排序,但如果有某个文件缺失或者命名后缀不一致,用zip对齐时会悄悄错位。建议在正式训练前随机取5组数据,用下面这段代码把图像和掩膜叠加起来保存成预览图,肉眼确认裂缝位置是否对齐:
import cv2 import numpy as np def check_alignment(img_path, mask_path, out_path): img = cv2.imread(img_path, cv2.IMREAD_UNCHANGED) mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) # 16位转8位预览 img_8u = ((img - img.min()) / (img.max() - img.min()) * 255).astype(np.uint8) img_bgr = cv2.cvtColor(img_8u, cv2.COLOR_GRAY2BGR) img_bgr[mask > 0] = (0, 0, 255) cv2.imwrite(out_path, img_bgr) check_alignment("data/images/sample_001.tif", "data/masks/sample_001.png", "check_001.png")这一步虽然简单,但能省掉后面排查标签错位的几个小时。CT岩心切片有个特殊性:相邻切片的空间相关性很强,同一块岩心连续切出来的几十张图内容非常相似。如果按图像文件随机划分训练集和验证集,验证集会间接混入训练集的信息,导致验证Dice虚高。正确做法是按岩心块ID划分数据,后面避坑章节会详细展开。
2.2 标注格式确认与转换:JSON Polygon 转 PNG Masks
如果源码包里的标签已经是PNG掩膜,这步可以直接跳过。但很多课程设计和论文复现场景,标注是用LabelMe这类工具画的,导出的是JSON文件。JSON里存的是每个裂缝的多边形坐标,训练前必须转成稠密的像素级掩膜。
下面这段是我常用的转换脚本:
import json import cv2 import numpy as np def labelme_json_to_mask(json_path, img_shape, out_mask_path): with open(json_path, "r", encoding="utf-8") as f: data = json.load(f) # img_shape 取原图的高和宽 mask = np.zeros((img_shape[0], img_shape[1]), dtype=np.uint8) for shape in data["shapes"]: # 有的标注会把裂缝标成 polygon,有的标成 line if shape["label"] in ("fracture", "crack", "裂缝"): points = np.array(shape["points"], dtype=np.int32) # 用多边形填充,保证裂缝内部全部是前景 cv2.fillPoly(mask, [points], 1) cv2.imwrite(out_mask_path, mask) with open("label.json", "r", encoding="utf-8") as f: data = json.load(f) img_shape = (data["imageHeight"], data["imageWidth"]) labelme_json_to_mask("label.json", img_shape, "mask.png")这里有两个容易翻车的细节。第一个是points的坐标类型必须是np.int32,fillPoly不接受浮点数,很多人直接传原始坐标导致报错。第二个是标签值统一填充为1,这样后面训练时mask就是[0,1]二值图,不用再在损失函数里做一次除法。如果你拿到的JSON是COCO格式而不是LabelMe格式,思路一样:先从annotations里取出多边形坐标,再fillPoly,只是字段名不同。
转换完成后还要检查一次mask的完整性。裂缝是细长条状,偶尔标注时一个裂缝被分成了多个多边形,中间留了缝隙,fillPoly之后会出现断裂。这种断裂会在训练时给模型输出不一致的监督信号,建议转换后用形态学闭运算把距离很近的裂缝段连起来,但这一步要克制,kernel用3x3就够了。
2.3 灰度统计与预处理:归一化、CLAHE 与窗宽窗位
工业CT岩心切片和自然图像最大的区别是动态范围。CT数据通常以16位存储,灰度值范围可能是0到几万,但有效信息集中在很窄的一段。比如裂缝和孔隙表现为低密度区域,灰度值偏低,而岩石基质和矿物晶体灰度值很高。如果直接把16位数据除以65535归一化到[0,1],裂缝区域会被压缩到几个灰度级,模型根本学不到裂缝和背景的差异。
常见做法是先做百分位裁剪,模仿CT诊断里的窗宽窗位操作:
import numpy as np def normalize_ct_window(img, low_percent=2, high_percent=98): lo = np.percentile(img, low_percent) hi = np.percentile(img, high_percent) img = np.clip(img, lo, hi) img = (img - lo) / (hi - lo) return (img * 255).astype(np.uint8)low_percent=2和high_percent=98的意思是去掉灰度值最低和最高的2%像素,这些通常是CT扫描引入的极值噪声。裁剪后再线性映射到0-255,裂缝的对比度才能被拉开。如果你做的是低剂量CT或者工业CT,噪声更大,百分位可以再收紧到1和99,具体要根据直方图观察。
裁剪后的图片虽然对比度正常了,但裂缝边缘和岩石基质的灰度差异仍然可能很弱。下一步我一般会叠加CLAHE局部对比度增强:
import cv2 clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) img = clahe.apply(img)CLAHE的原理是在tileGridSize划分的局部区块内做直方图均衡化,clipLimit=2.0控制对比度放大上限。裂缝在CT切片里往往是低密度黑线,周边是灰度较高的基质,局部增强后这条黑线会更明显。这个参数不要调得过大,clipLimit超过4.0会把噪点也放大成伪裂缝。
预处理函数要作为训练流程的一部分,而不是保存成图片文件。原因很简单:如果用同样的CLAHE参数在训练前把整批数据全部增强并保存,数据增强的随机性就丢了,而且不同实验之间想对比不同的预处理参数,还得重新生成一份数据。
2.4 数据增强:针对低对比度裂缝的增强组合
语义分割的数据增强有个硬性要求:图像和掩膜必须做完全相同的空间变换。手写实现很麻烦,我一般直接用albumentations库。裂缝分割场景,随机旋转、翻转、弹性变形都有必要,因为岩心切片的裂缝方向不一定和图像坐标系对齐。
import albumentations as A train_transform = A.Compose([ A.RandomRotate90(p=0.5), A.HorizontalFlip(p=0.5), A.VerticalFlip(p=0.5), A.CLAHE(clip_limit=2.0, tile_grid_size=(8, 8), p=0.3), A.ElasticTransform(alpha=1.0, sigma=50.0, p=0.2), A.RandomCrop(height=256, width=256, p=1.0), ]) valid_transform = A.Compose([ A.CenterCrop(height=256, width=256, p=1.0), ])注意RandomCrop我放在变换列表的最后,因为前面的旋转、翻转、弹性变换都需要在全尺寸图像上进行,先裁剪再做这些变换会损失有效信息。ElasticTransform的alpha=1.0是变形强度上限,sigma=50.0是平滑程度。岩心内部如果有细微的层理弯曲,这个变换能模拟出来,但经验上alpha不要超过2,否则细裂缝会被拉伸成断断续续的虚线,相当于给训练数据增加错误标注。
验证集只用CenterCrop,不要混入随机增强。一个容易被忽略的细节是albumentations的随机增强会影响标签值,如果用双线性插值的方式旋转图像,mask也会被线性插值,产生位于0和1之间的中间值。上面这套变换里RandomRotate90和翻转不会引起插值,但ElasticTransform底层用了插值,如果发现训练时mask出现了非二值像素,需要给变换额外传mask参数时保持interpolation=cv2.INTER_NEAREST,或者在损失函数里加一个mask.round()操作。比较省事的做法是在A.Compose的构建参数里加additional_targets并显式指定mask的插值方式。
做完数据增强,数据关基本就打通了。整个阶段的目标是确认:图像和mask一一对应、灰度范围没有截断、裂缝信息没有被预处理抹掉。这三件事只要有一件出问题,后面模型训练再怎么调参都是白费。
3. 分割模型选型与训练配置:U-Net 小样本与 DeepLabV3+ 的取舍
3.1 模型选型:为什么先试 U-Net 而不是 Transformer
CT岩心裂缝数据集通常不会太大,几十张到几百张之间,而裂缝本身的像素占比又非常低。在这种数据规模下,直接上Vision Transformer或者Swin Transformer大概率会过拟合,因为它们需要大量数据来学习全局注意力模式。最稳妥的起点是U-Net,或者它的变体。U-Net的编码器逐层下采样提取多尺度特征,解码器通过跳跃连接把编码器每一层的空间细节融合回来,这种设计特别适合裂缝这种“边界细节比语义更重要”的任务。
搭建模型我一般用segmentation_models_pytorch这个库。它封装了U-Net、DeepLabV3+、FPN等主流结构,而且支持不同的编码器backbone:
import segmentation_models_pytorch as smp model = smp.Unet( encoder_name="resnet34", encoder_weights="imagenet", in_channels=3, classes=1, activation=None, )关键参数是in_channels=3。你的原始输入是单通道灰度图,但resnet34的ImageNet预训练权重是在三通道RGB图上训出来的,直接改成in_channels=1就加载不了预训练权重。常见做法是在预处理阶段把灰度图原地复制三次变成三通道。训练和推理都要走同一个转换逻辑,这一点必须统一,否则到推理阶段会出现通道数不匹配的诡异错误。
classes=1表示只分割裂缝一个类别,输出是单通道的logits图。activation=None是因为后面损失函数用的是BCEWithLogitsLoss,它在内部做了sigmoid,不需要在模型末尾手动加激活层。
对比一下U-Net和DeepLabV3+的选择逻辑:DeepLabV3+的空洞卷积带来更大的感受野,对“整条裂缝跨度很大、贯穿整个视野”的场景有优势,但它需要更多的样本量来校准空洞卷积的权重,而且对小目标的边界恢复不如U-Net的直接跳跃连接。实际项目里,我一般先用U-Net跑通基线,如果裂缝出现大量断片而模型又学不会长距离关联,再切到DeepLabV3+。
3.2 损失函数:Dice Loss 与加权交叉熵
裂缝分割最容易出现的训练现象是:模型快速收敛到一个“全部预测为背景”的状态,验证集Dice为0,但交叉熵损失仍然在下降。原因是裂缝占整张图的比例可能不足1%,普通BCELoss计算时,背景类贡献了绝大部分loss,模型发现预测背景就能把loss压得足够低。
解决办法是使用Dice Loss。Dice系数本身衡量预测和真实标注在像素集合上的重叠程度,对类别不平衡不敏感。单独使用Dice Loss也有问题:梯度在预测概率接近0或1时会变得很小,训练初期收敛慢。最稳的组合是“交叉熵 + Dice”两个损失加权求和:
import torch import torch.nn as nn class CombinedLoss(nn.Module): def __init__(self, bce_weight=0.5, dice_weight=0.5): super().__init__() self.bce_weight = bce_weight self.dice_weight = dice_weight self.bce = nn.BCEWithLogitsLoss() def dice_loss(self, logits, targets): probs = torch.sigmoid(logits) smooth = 1.0 intersection = (probs * targets).sum() union = probs.sum() + targets.sum() return 1 - (2.0 * intersection + smooth) / (union + smooth) def forward(self, logits, targets): bce = self.bce(logits, targets.float()) dice = self.dice_loss(logits, targets) return self.bce_weight * bce + self.dice_weight * dice代码里的smooth=1.0是平滑项,防止overlap为0时除法出问题,也能让训练初期梯度更稳定。targets必须是浮点型的[0,1]张量,如果原始mask是(B, 1, H, W)的uint8,需要先.float()。两个权重默认各取0.5,实际使用中如果发现Dice提升很慢,可以尝试bce_weight=0.3, dice_weight=0.7,让模型更快关注裂缝区域。
还有一个更直接的方案是BCEWithLogitsLoss的pos_weight参数,给正样本一个加权系数。比如裂缝像素占比约0.5%,那pos_weight可以取50到100。但经验是这类极端不平衡任务里,Dice Loss的收敛稳定性更好,pos_weight调不好反而会带来震荡。
3.3 训练超参数:batch size、学习率与早停
训练配置里,batch size、输入分辨率、初始学习率四者互相制约。CT大图的原始分辨率经常是1024×1024甚至更高,如果直接整图输入,显存根本扛不住。我一般先把图像裁剪到256×256或512×512再做模型输入。
crop_size = 256 batch_size = 8 initial_lr = 1e-4 epochs = 120 accumulate_steps = 2这个配置的含义是:每批次加载8张256×256的图,accumulate_steps=2表示每2次反向传播才更新一次参数,等效batch size是16。在U-Net + resnet34的规模下,这个组合在8GB显存的卡上能跑得动。如果你只有6GB显存,把batch_size降到4,accumulate_steps提到4,效果接近,但训练时间会变长。
学习率用Adam优化器配1e-4是分割任务里比较稳的起点。注意从头训练和加载预训练权重的学习率策略不同:加载ImageNet权重时,编码器部分已经学到了通用特征,1e-4能让它在原有特征上做小幅调整;如果是完全随机初始化,这个学习率偏大,可能收敛不稳,需要降到3e-5或5e-5。
学习率调度我习惯用ReduceLROnPlateau:
from torch.optim.lr_scheduler import ReduceLROnPlateau scheduler = ReduceLROnPlateau( optimizer, mode="max", factor=0.5, patience=8, min_lr=1e-6, )mode="max"表示监控目标是验证集Dice,Dice提升才认定模型在变好。patience=8是连续8轮Dice没有突破后学习率减半。整个训练过程里,最关键的决策是只保存验证集Dice最高的权重文件,不要用训练loss作为保存依据,因为训练loss下降可能只说明背景类拟合得好,裂缝类可能仍然一塌糊涂。
3.4 训练主流程代码
训练循环本身不复杂,把数据加载、损失计算、梯度累积、模型保存串起来就行:
best_dice = 0.0 patience = 0 early_stop_patience = 15 for epoch in range(epochs): model.train() train_loss_sum = 0.0 optimizer.zero_grad() for step, (imgs, masks) in enumerate(train_loader): imgs = imgs.cuda() masks = masks.cuda() logits = model(imgs) loss = criterion(logits, masks) loss.backward() if (step + 1) % accumulate_steps == 0: optimizer.step() optimizer.zero_grad() train_loss_sum += loss.item() val_dice = evaluate_dice(model, valid_loader) scheduler.step(val_dice) if val_dice > best_dice: best_dice = val_dice torch.save(model.state_dict(), "best_unet_ct.pth") patience = 0 else: patience += 1 if patience >= early_stop_patience: print(f"early stop at epoch {epoch}") break代码里有两个细节值得说明。第一,optimizer.zero_grad()放在了epoch循环开头,配合梯度累积时只在满足accumulate_steps条件才调用optimizer.step(),否则梯度会持续累加到下一轮。第二,evaluate_dice是验证集Dice的汇总函数,计算时模型必须处于model.eval()状态并关闭梯度。
如果你显存还是不够,可以考虑在训练循环外包装一层torch.cuda.amp:
scaler = torch.cuda.amp.GradScaler() with torch.cuda.amp.autocast(): logits = model(imgs) loss = criterion(logits, masks) scaler.scale(loss).backward()半精度训练能显著降低显存占用,代价是约10%的精度损失,在裂缝分割这种任务里通常可以接受。需要注意半精度模式下,Dice Loss中的torch.sigmoid计算也可能变成半精度,必要时在损失函数里显式加torch.float32转换,避免数值精度不足导致的Dice震荡。
4. 评估与推理部署:IoU 计算、滑动窗口与预测结果可视化
4.1 评估指标实现的坑:IoU与Dice的类别维度
训练结束后的第一个问题通常不是“效果好不好”,而是“指标是怎么算出来的”。很多人直接把sklearn的jaccard_score拿来用,然后在二分类标签上得到异常高的IoU,原因在于average参数默认是binary,只关注正样本类别,背景类被排除在计算外。裂缝占比极低时,这种IoU会虚高到90%以上,实际上模型只预测出了几条裂缝边缘。
正确的做法是自己手写像素级别的二分类IoU和Dice:
def compute_iou_dice(pred_mask, true_mask): pred_mask = (pred_mask > 0.5).astype(int) true_mask = (true_mask > 0.5).astype(int) intersection = (pred_mask * true_mask).sum() union = pred_mask.sum() + true_mask.sum() - intersection iou = intersection / (union + 1e-7) pred_sum = pred_mask.sum() true_sum = true_mask.sum() dice = (2 * intersection) / (pred_sum + true_sum + 1e-7) return iou, dice加1e-7是防止除零,同时不影响指标值。这个函数返回的是全局二类指标的近似,本质是把裂缝视为正类、背景视为负类的全局像素统计。对于裂缝这种类别极度不平衡的任务,它比类平均IoU更悲观,也更真实。如果你要写论文,建议同时报全局IoU和Dice,审稿人更认可这种方法。
4.2 推理阶段的滑动窗口与重叠策略
训练时输入是256×256的裁剪块,但推理时我们面对的是一整张原始CT切片,可能有1024×1024甚至更大。直接把全图放进去模型会OOM,即便不OOM,全图推理的结果也经常在裂缝边界处出现奇怪伪影。原因是模型在固定分辨率下学习到的空间模式,在更大视野下不一定能保持。
滑动窗口推理是分割任务的标准做法,重叠区域预测结果取平均,能有效消除patch边缘的接缝效应:
import numpy as np import torch def predict_full_scan(model, image_3ch, patch_size=256, overlap=32, device="cuda"): model.eval() h, w = image_3ch.shape[:2] stride = patch_size - overlap pad_h = (stride - h % stride) % stride pad_w = (stride - w % stride) % stride padded = np.pad( image_3ch, ((0, pad_h), (0, pad_w), (0, 0)), mode="reflect", ) heatmap = np.zeros((padded.shape[0], padded.shape[1]), dtype=np.float32) weight_map = np.zeros_like(heatmap) for y in range(0, padded.shape[0] - patch_size + 1, stride): for x in range(0, padded.shape[1] - patch_size + 1, stride): patch = padded[y:y+patch_size, x:x+patch_size] patch_tensor = torch.from_numpy(patch.transpose(2, 0, 1)) patch_tensor = patch_tensor.unsqueeze(0).float().to(device) with torch.no_grad(): logits = model(patch_tensor) prob = torch.sigmoid(logits).squeeze().cpu().numpy() heatmap[y:y+patch_size, x:x+patch_size] += prob weight_map[y:y+patch_size, x:x+patch_size] += 1.0 heatmap /= np.maximum(weight_map, 1.0) return heatmap[:h, :w]参数patch_size=256必须和训练时的输入尺寸一致,overlap=32控制相邻采样块的重叠宽度。重叠区域被多次预测并求平均,理论上重叠越大结果越平滑,但推理时间也成倍增长。对256的patch,32像素重叠是性价比比较高的折中。
np.pad的reflect模式是给原图边界补边用的,避免裂缝贴边时滑动窗口覆盖不到。返回前把heatmap裁剪回原始高度和宽度。
4.3 预测结果后处理:去假阳性与裂缝连通域修补
模型输出的是0到1之间的概率图,先选阈值二值化。很多人直接用0.5,但裂缝边缘模糊时,概率值经常在0.4附近浮动。我通常先用连通域分析过滤小面积噪声,再把阈值降低到0.45或者0.4,因为噪声块面积小,通过连通域过滤能去掉大部分。
from scipy import ndimage import numpy as np binary = (prob_map > 0.45).astype(np.uint8) labeled, num_features = ndimage.label(binary) min_area = 30 for label_id in range(1, num_features + 1): area = (labeled == label_id).sum() if area < min_area: binary[labeled == label_id] = 0min_area=30的意思是小于30个像素的前景对象全部视为假阳性置为背景。这个参数取决于图像分辨率:256×256的patch下30像素大约是1%的patch面积,岩心CT里单块噪声这样的大小很常见;如果是1024分辨率的全图,min_area要放到100以上。
裂缝断裂是指同一条裂缝在预测结果中被分成了几段,中间隔着几个像素的间隙。形态学闭运算可以桥接细小断裂:
from skimage.morphology import binary_closing, disk connected = binary_closing(binary, disk(2))disk(2)是半径2的圆形结构元,能连接间距在4像素以内的断裂。这个操作要非常克制,用disk(3)或更大就会把相邻但实际不相交的裂缝错误连通,导致后续裂缝长度统计严重失真。我自己的习惯是先连通域过滤,后闭运算,这样噪声在闭运算之前已经被清除,避免了把噪声和真实裂缝桥接成一个对象。
5. 避坑指南:灰度图三通道、标签错位、显存炸了怎么办
5.1 灰度图被三通道读入,标签值乱套
现象:训练时loss下降很快,但预测结果全是灰色噪点,或者mask中出现了0、1、255之外的奇怪值。
原因:cv2.imread默认用IMREAD_COLOR读取图片,一张单通道灰度图和单通道掩膜会被读成三通道BGR。如果mask也被读成三通道,模型输出的单通道概率图和它计算损失时会发生广播,标签语义完全错乱。还有一种情况是刻意做了灰度转三通道,但训练时复制加通道的顺序和推理时不一致。
解决:读图像和mask分别使用cv2.IMREAD_UNCHANGED与cv2.IMREAD_GRAYSCALE。如果为了加载ImageNet预训练权重而复制成三通道,写一个统一的grayscale_to_3ch函数,在训练和推理的预处理里都必须调用,保证通道变换逻辑唯一。
5.2 验证集按张随机划分,裂缝信息泄漏
现象:验证集Dice高达0.92,但拿同一份权重去预测一块新岩心的CT切片,效果立刻掉到0.4以下。
原因:CT岩心是连续切片,相邻两张图的裂缝形态高度相似。按文件随机划分训练集和验证集,很可能同一块岩心的相邻切片分布到了两侧,验证集没有真正测试模型对未知样本的泛化能力。
解决:按岩心块ID划分数据集。先统计文件名里属于哪块岩心,把同一岩心ID的所有切片放在同一个集合里,再按岩心ID比例划分训练、验证。比如有10块岩心,取8块做训练,2块做验证。宁可训练数据少一些,也不能让验证集泄漏。
5.3 归一化后裂缝对比度反而消失
现象:模型训练了十几个epoch,裂缝仍然基本漏掉,可视化输入图像发现裂缝区域和背景灰度特别接近。
原因:对16位CT图直接用img / 65535归一化。CT值动态范围很大,高密度矿物区占用了大部分灰度区间,裂缝区域被挤压到最后几个灰度级。或者用百分位裁剪时参数选得太宽,比如设置low_percent=0, high_percent=100,等价于没裁剪,极值噪声继续拉宽灰度范围。
解决:先画直方图,观察像素集中分布在哪个区间,再把low_percent调到2、high_percent到98,之后叠加CLAHE增强。验证预处理效果的方法是保存几张处理后的图,肉眼看裂缝是否清晰可见,数值上可以检查裂缝区域灰度均值与周边区域的差值是否大于20。
5.4 整张图预测成全黑,Dice为0
现象:训练过程loss正常下降,但验证Dice始终接近0,模型输出全部是背景。
原因:绝大多数情况是损失函数里只有交叉熵,没有Dice Loss或正样本加权。裂缝像素占比太低,模型只要预测全部背景,交叉熵loss也能保持很低,于是训练陷入了局部最优。
解决:改用前面写的CombinedLoss,让交叉熵和Dice各占一半。换损失函数后头几个epoch可能看到Dice波动变大,这是正常的,说明模型开始尝试预测裂缝区域。另外检查mask是否有问题——如果某张mask因为标注转换失败全是0,这个样本会进一步加剧全背景预测。
5.5 显存不足,batch size调小后训练效果变差
现象:设置batch_size=8训练到一半 CUDA OOM,改成batch_size=2后能跑,但验证Dice明显下降。
原因:batch size减小到2后,梯度的随机性变大,BN层的统计量也不稳定,效果自然会受影响。这不是模型问题,是工程配置问题。
解决:用梯度累积恢复等效batch size,batch_size=2+accumulate_steps=8等效于batch_size=16。同时开启torch.cuda.amp半精度,显存占用能下降约30%。如果显存还是不够,把输入从512降成448,或者关闭cudnn.benchmark,虽然慢一点但更稳定。
6. 进阶:裂缝连通域拆解与骨架提取
分割出裂缝掩膜其实只完成了一半工作,很多课程设计和论文还需要从掩膜里提取裂缝的长度、开度、方向这些定量参数。我常用的流程是:先连通域标记,再提取单像素骨架,最后对骨架做几何统计。
6.1 裂缝长度与开度粗估
from skimage.morphology import skeletonize from scipy import ndimage import numpy as np # binary 是后处理过的裂缝掩膜 skeleton = skeletonize(binary).astype(np.uint8) labeled, num = ndimage.label(skeleton) for label_id in range(1, num + 1): mask_i = (labeled == label_id) length = mask_i.sum() area = binary[mask_i].sum() width = area / length print("裂缝ID", label_id, "长度(像素)", length, "平均开度(像素)", width)骨架提取后,裂缝像素的个数近似等于裂缝的几何长度,原始掩膜面积除以骨架长度,就是平均开度。这个数值受分辨率影响,写报告时要除以CT图像的单像素物理尺寸,换算成毫米。
6.2 批量可视化:原图、标注、预测三拼图
手动一盘盘查看结果太费时间,我一般在预测之后直接生成三拼图,原图、真实mask、预测mask各占一列,按文件名批量保存。之后写期末大作业时直接把图粘贴进报告,一眼就能看出模型哪里漏了、哪里误检,不用反复打开数组翻看。
从那以后,我每次训练完都会强制走一遍“检查预处理直方图、检查验证集划分、确认mask值和预测阈值”这三件事,再往下游做定量分析。裂缝分割很容易被表面指标骗过去,只有把每一步的中间结果都打开看一眼,才能在翻车前拦住问题。希望帮到你。
本文还有配套的精品资源,点击获取