news 2026/10/11 11:17:33

岩石裂缝与CT岩心图像语义分割实战:从UNet训练到像素级裂缝识别

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
岩石裂缝与CT岩心图像语义分割实战:从UNet训练到像素级裂缝识别

简介:面向计算机视觉课程设计与期末大作业,这套基于Python的岩石裂缝与CT岩心裂缝语义分割资料包,覆盖从图像预处理到模型训练与验证的关键环节。包内共14个文件,含6张岩石表面、混凝土断面及CT岩心扫描样例图及对应标注图(jpg),3个Python脚本分别用于数据增强、均值计算等预处理,另附备份文件、README说明与gitignore配置,压缩后仅1.13MB,轻量易部署。已有171人学习使用,适合具备一定Python基础、需要完成图像分割任务的学生参考。借助PIL、OpenCV或Keras等常用库,读者可对照源码与标注图理解逐像素分类流程;利用amplifyData扩充样本、calc-mean完成归一化,直接开展裂缝目标提取与CT图像量化分析实验。原图与真值标注成对提供,便于计算IoU等评估指标,为课程报告和答辩提供可复现的完整支撑。

1. 岩石裂缝与CT岩心裂缝语义分割:为什么先做像素级分类而不是画框

做岩石力学或油气储层评价的人,经常面对两类图像:一类是露头或岩心表面的高分辨率照片,另一类是CT扫描得到的灰度切片。裂缝在这些图像里往往只有一两个像素宽,而且形态蜿蜒、对比度不均。用目标检测画矩形框只能告诉你“这里有裂缝”,却给不了裂缝的宽度、走向和面积占比,而这些恰恰是工程分析最需要的参数。所以这几年只要谈到裂缝识别,语义分割几乎成了默认方案——它对每个像素做分类,裂缝像素和非裂缝像素直接分开,再往后统计几何特征就很顺手。

这篇笔记面向想自己训练裂缝分割模型的人,无论你是刚接触Python语义分割的新手,还是已经从分类检测转到分割的老手,都能找到可复用的源码思路和数据集处理经验。我会把数据集怎么标、训练管线怎么写、CT图像有哪些坑讲清楚,最后给出几个能直接抄的进阶技巧。整个方案不依赖某个特定平台,TensorFlow或PyTorch都适用,代码结构按常见工程习惯组织,你拿过去改改就能跑。

2. 裂缝数据集的构建与标注转换:从原始图像到能直接训练的标准格式

2.1 岩石表面照片与CT切片的数据差异

裂缝分割的数据集来源主要有两种。岩石表面照片通常是RGB三通道,纹理丰富,裂缝往往与矿物颗粒边界混在一起,需要人眼仔细区分。CT岩心切片则是单通道灰度图,裂缝表现为低密度区域,灰度值比基质暗,但容易受到环状伪影和金属杂质干扰。这两类图像不能直接混在一起训练,因为通道数、分辨率、对比度特性都不同。常见的做法是分开建两个子集,各自训练或者用域适应方式迁移。

从标注角度看,岩石表面照片可以借助任何通用标注工具,像LabelMe、PPOCRLabel这类,导出为JSON多边形。CT切片更推荐直接在切片软件或ImageJ里做阈值初分割,再由人工修正。因为CT的裂缝灰度分布相对集中,先自动提取候选区,再人工剔除误检,能省一半时间。但要注意,自动初分割只能用做标注辅助,不能直接拿来做训练标签,否则模型会学到标注工具的误差。

2.2 标注工具选择与JSON到掩码的转换

我用得最多的是LabelMe,因为它的多边形标注对细长裂缝非常友好。裂缝宽度只有几个像素时,用矩形框或者画笔都不够灵活,而多边形可以贴着裂缝边缘打点。标注完成后,每个图像会对应一个同名JSON文件,里面记录着每个多边形的顶点坐标和标签名。训练前需要把JSON转成与图像尺寸相同的PNG掩码图,裂缝像素为1,背景为0。

下面这段代码把LabelMe的JSON批量转成PNG掩码,适用于单类裂缝分割:

import json import numpy as np import cv2 from pathlib import Path def labelme_json_to_mask(json_path, img_size): with open(json_path, 'r', encoding='utf-8') as f: data = json.load(f) mask = np.zeros(img_size, dtype=np.uint8) for shape in data['shapes']: if shape['label'] != 'crack': continue points = np.array(shape['points'], dtype=np.int32) cv2.fillPoly(mask, [points], 1) return mask # 使用示例:遍历标注目录 json_dir = Path('./annotations') for json_file in json_dir.glob('*.json'): mask = labelme_json_to_mask(json_file, (512, 512)) cv2.imwrite(str(json_file.with_suffix('.png')), mask)

这段代码的核心逻辑是遍历JSON里的每个shape,只保留标签为“crack”的多边形,用cv2.fillPoly把多边形内部填充为1。需要注意img_size必须与原图尺寸一致,否则后面DataLoader配对照会错位。我一般会在转换前打印一张原图和掩码叠加图,肉眼确认没有偏移——这一步省下来,后面训练出的模型会学到错误的边缘特征。

2.3 训练集/验证集拆分与数据增强要点

很多人在裂缝分割上翻车,不是因为模型不行,而是数据集拆分不严谨。裂缝图像之间存在很强的相似性,如果同一块岩心的连续切片被同时分到训练集和验证集,验证指标会虚高,真实场景效果却很差。正确的做法是按“样本来源”分组,比如同一根岩心的所有切片归为一组,以组为单位随机划分到训练或验证,而不是按单张图随机分。如果做岩石表面图像,则应按露头区域或采样位置分组。

数据增强方面,裂缝是细线结构,对几何变换特别敏感。常规的随机旋转、水平翻转可以保留,但像弹性形变、随机裁剪这种会改变裂缝连续性的增强慎用。尤其弹性形变,可能把一条完整裂缝扭断,反而让模型学会断断续续的预测。我习惯用以下增强组合:旋转90度、水平翻转、亮度对比度微调、少量高斯噪声。这些都不会破坏裂缝的拓扑形态。如果训练数据量少,可以先用离线方式把裂缝区域裁剪成224或256的小块做平衡,比在线增强更有效。

3. 用Python跑通UNet与DeepLabV3+:最小训练管线与关键参数

3.1 环境依赖与目录结构

裂缝分割属于密集预测,常用模型是UNet和DeepLabV3+。UNet参数少、对小目标敏感,适合裂缝这种细线结构;DeepLabV3+用了空洞卷积,感受野大,对CT切片里的弥散裂缝边缘更鲁棒。如果只有一个GPU且显存有限,优先选UNet;如果数据量大且需要捕捉长距离上下文,再考虑DeepLabV3+。

我把工程目录按下面这种方式组织,方便复现和交接:

crack_seg/ ├── data/ │ ├── images/ │ ├── masks/ │ ├── train.txt │ └── val.txt ├── src/ │ ├── dataset.py │ ├── model.py │ ├── train.py │ └── predict.py ├── weights/ └── config.yaml

train.txt和val.txt每行写一个图像文件的相对路径,不带扩展名,这样换数据集时不用改代码。依赖方面,我通常固定torch、opencv-python、albumentations、tqdm这几个核心包。建议用Python 3.9或3.10,太新的版本偶尔会遇到CUDA扩展编译问题。

3.2 数据加载器实现

数据加载器要做的事很简单:从路径列表里读取图像和掩码,做统一尺寸resize,再转成Tensor。下面是一个可以用到训练脚本里的实现,它同时处理RGB和灰度图:

import torch import cv2 import albumentations as A from torch.utils.data import Dataset class CrackDataset(Dataset): def __init__(self, img_dir, mask_dir, file_list, augment=None): self.img_dir = img_dir self.mask_dir = mask_dir self.file_list = [line.strip() for line in open(file_list)] self.augment = augment def __len__(self): return len(self.file_list) def __getitem__(self, idx): name = self.file_list[idx] img = cv2.imread(f'{self.img_dir}/{name}.jpg') mask = cv2.imread(f'{self.mask_dir}/{name}.png', cv2.IMREAD_GRAYSCALE) mask = (mask > 0).astype('uint8') if self.augment: aug = self.augment(image=img, mask=mask) img, mask = aug['image'], aug['mask'] img = torch.from_numpy(img.transpose(2, 0, 1)).float() / 255.0 mask = torch.from_numpy(mask).long() return img, mask # 使用示意 aug = A.Compose([ A.Resize(512, 512), A.HorizontalFlip(p=0.5), A.RandomBrightnessContrast(p=0.2), ])

这段代码的灵活之处在于把缩放和增强都交给Albumentations处理,cv2.imread读取灰度掩码后通过阈值转成二值,避免标注时边缘抖动造成灰度值不干净。torch.from_numpy(img.transpose(2,0,1))把HWC转成CHW格式,是PyTorch训练的默认输入顺序。注意灰度图也会被cv2.imread自动加载成三通道,这样统一处理反而省事,但后面使用预训练权重时要注意通道匹配。

3.3 训练脚本主循环

训练主循环不需要花哨,稳定跑通是第一目标。我通常用交叉熵损失配合Dice系数的混合损失,优化器选AdamW,初始学习率1e-4,配合余弦退火。下面是一个精简但完整的训练循环:

import torch import torch.nn as nn from torch.utils.data import DataLoader from tqdm import tqdm def dice_loss(pred, target, smooth=1.0): pred = torch.softmax(pred, dim=1)[:, 1] intersection = (pred * target).sum() return 1 - (2 * intersection + smooth) / (pred.sum() + target.sum() + smooth) model = UNet(in_channels=3, num_classes=2).cuda() optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=50) criterion = nn.CrossEntropyLoss() train_loader = DataLoader(dataset, batch_size=8, shuffle=True, num_workers=4) for epoch in range(50): model.train() total_loss = 0 for imgs, masks in tqdm(train_loader, desc=f'Epoch {epoch+1}'): imgs, masks = imgs.cuda(), masks.cuda() preds = model(imgs) loss = criterion(preds, masks) + dice_loss(preds, masks) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() scheduler.step() print(f'Epoch {epoch+1}, Loss: {total_loss/len(train_loader):.4f}')

这里的dice_loss在裂缝场景里非常关键。因为裂缝像素占比通常只有1%到3%,普通交叉熵损失会让模型倾向于预测全背景。混合Dice后,模型必须把裂缝区域真正抠出来才能降低损失。参数T_max=50表示余弦退火的周期与训练轮数一致,如果你只训30轮,记得把T_max也改成30,否则学习率还没有降到最低就停止,效果差一截。batch_size=8适合12G显存,如果报OOM,先降到4并同时把Resize尺寸改为256。

3.4 推理与结果可视化

推理阶段,我需要输出两类结果:一类是PNG掩码,另一类是原图上叠加红色半透明的可视化图。可视化对向地质人员汇报很有用,他们看得懂裂缝但看不懂灰度图。下面代码是单张图推理的标准写法:

import cv2 import numpy as np import torch def predict_single(model, img_path, device='cuda', save_path=None): img = cv2.imread(img_path) h, w = img.shape[:2] resized = cv2.resize(img, (512, 512)) tensor = torch.from_numpy(resized.transpose(2,0,1)).float().unsqueeze(0) / 255.0 tensor = tensor.to(device) with torch.no_grad(): probs = torch.softmax(model(tensor), dim=1)[0, 1] probs = probs.cpu().numpy() mask = (probs > 0.5).astype('uint8') mask = cv2.resize(mask, (w, h)) overlay = img.copy() overlay[mask == 1] = (0, 0, 255) result = cv2.addWeighted(img, 0.6, overlay, 0.4, 0) cv2.imwrite(save_path, result) return mask

这个推理函数里有两个细节容易踩坑。第一,输入图像resize成512后,输出的mask必须再resize回原图尺寸,否则和原图叠加会错位。第二,掩码resize默认采用最近的插值算法,因为cv2.resize对二值图使用线性插值会产生非0和1的中间值,影响叠加。如果你发现结果里裂缝变得断续,多半是这里用了默认插值。把cv2.resize的interpolation参数显式写成cv2.INTER_NEAREST即可。

4. CT岩心裂缝的专属处理:灰度权重、伪影抑制与切片连续性

4.1 灰度图像为什么不能直接套RGB预训练权重

CT岩心切片是单通道灰度图,但很多预训练模型(如ImageNet上的ResNet、DeepLabV3+)要求输入三通道。一个常见的偷懒做法是把灰度图复制三份变成伪RGB,然后加载预训练权重。这个办法在数据量大的时候能跑,但效果不一定好——因为预训练权重学的是自然图像的色彩纹理模式,而CT图像的灰度分布和自然照片完全不同,用预训练权重等于把模型先往错误方向带了一把,还得靠后面大量迭代拉回来。

我处理CT数据时有两种更靠谱的方案。第一种是从零训练单通道输入模型,把编码器第一层卷积的输入通道改成1,放弃预训练权重,训练轮数适当增加到80到100轮。第二种是如果非要用预训练权重,则把原图先做CLAHE对比度增强,再做伪RGB三通道输入,并且只解锁模型后半部分网络,前半部分只做特征提取器的微调。实际对比下来,对于裂缝这种纹理结构,方案一在小数据集上更稳,不容易过拟合。

4.2 连通域分析与裂缝形态后处理

模型直接输出的二值分割图往往包括很多孤立噪声点,尤其在CT伪影区域。这些噪声点面积小、分布零散,而真实裂缝是连通的。后处理第一步可以做连通域分析,去掉面积小于阈值的区域。下面是一个基于OpenCV的简单后处理函数:

def remove_small_cc(mask, min_area=25): num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(mask, connectivity=8) cleaned = np.zeros_like(mask) for i in range(1, num_labels): if stats[i, cv2.CC_STAT_AREA] >= min_area: cleaned[labels == i] = 1 return cleaned

连通域分析的最小面积阈值需要根据图像分辨率调整。如果切片尺寸是1024×1024,裂缝噪声点普遍在10到20像素之间,min_area=25能滤掉大部分噪声;但如果有细长裂缝的一段只有5像素宽、几十像素长,面积超过25会被保留,所以这个值相对安全。处理完连通域后,还可以用形态学闭运算把同一裂缝的断点连接起来,但要注意结构核不能太大,3×3就够了,否则会把不相干的邻近区域也连上。

4.3 3D连续性校验

CT岩心是一个立方体,切片之间有天然的连续性。如果神经网络在相邻切片上预测出的裂缝位置突然消失又出现,那么大概率是模型把伪影或微孔隙误判成了裂缝。常见做法是加载连续10到20张切片的预测掩码,做一个逐像素的中值滤波或多数投票,把出现频率低于半数的像素点剔除。这个操作在工程上叫时间维平滑,虽然会增加一点计算量,但对结果可信度的提升非常明显。

实现3D投票时要注意内存,不要一次性加载整个岩心几百张切片,按滑动窗口处理。窗口大小一般取11到15张,对每张中间位置的切片做投票修正,效果最好。如果地质人员希望保留更细微的裂缝信息,也可以把投票阈值从50%降到30%,宁可多留一些候选,也别让真实裂缝被平滑掉。这一步没有统一答案,我建议做一个简单的AB对比:生成两张后处理图,交给项目组评审哪种更符合人工解释。

5. 裂缝分割训练避坑指南:6个血泪经验与排查清单

5.1 裂缝太细导致损失函数不收敛

现象:训练loss下降正常,但验证集F1始终在0.3左右,预测图中裂缝断成一节一节。

原因:裂缝宽度只有1到3像素,下采样时信息丢失,而且交叉熵损失对细小目标不敏感。模型学到的可能是背景区域的轮廓,而非裂缝本身。

解决:把输入分辨率从512提高到768或1024,尽量保留裂缝细节。同时替换损失为Dice Loss或Tversky Loss,这种重边界损失会放大少量裂缝像素的影响。如果显存不允许,也可以先用512把模型训到收敛,再用768的输入微调10轮,效果接近直接大分辨率训练。

5.2 类别不平衡与Focal Loss

现象:模型预测结果里很少有裂缝像素,即便有也是零散噪声,整体偏向全背景。

原因:裂缝像素占图像面积往往不到2%,普通交叉熵被背景梯度主导,模型找到的最优解就是全预测为背景。

解决:除了换Dice损失,还可以给交叉熵加上类别权重,例如nn.CrossEntropyLoss(weight=torch.tensor([0.1, 0.9])),把裂缝类别的权重抬高。更激进的做法是使用Focal Loss,它的γ参数会降低易分类样本的损失贡献。我用Focal Loss时一般设置α=0.75、γ=2,先跑20轮观察,如果召回率上升但精确率下降,再把α调低到0.6。

5.3 验证集指标虚高但实际效果差

现象:验证集上mIoU达到0.85,但拿到新的岩心CT切片上预测结果惨不忍睹。

原因:最常见的是数据拆分时没有按岩心分组,同一岩心的相邻切片同时出现在训练和验证集中,模型相当于“背”住了这些切片的纹理特征,并没有学到泛化规律。

解决:严格按岩心样本ID分组,确保一张岩心的全部切片要么进训练,要么进验证。如果岩心数量太少,宁可减少训练数据量,也要保证验证集完全独立。之后观察训练集与验证集之间的loss差距,如果训练集loss持续下降而验证集不降,就说明过拟合,需要增加数据增强或降低模型容量。

5.4 CT伪影被误判为裂缝

现象:分割结果中,岩心边缘出现一圈环形高亮区域被识别为裂缝,而真实裂缝反而被忽略。

原因:CT扫描中的环状伪影和束硬化伪影在灰度上表现出与裂缝类似的低密度特征,尤其靠近岩心边缘的地区,灰度差异很大。模型可能把强烈的灰度梯度当作裂缝边缘。

解决:输入图像先做中值滤波或非局部均值滤波抑制伪影,再交给模型。在标注阶段就要避免把伪影归为裂缝。如果伪影位置固定,也可以做预处理裁掉边缘区域。更有效的办法是在训练时把伪影区域单独标一个背景类别,让模型明确知道这不是裂缝。

5.5 数据集泄露:来自同一岩心的切片被同时分到训练和验证

现象:训练时验证loss一直低于训练loss,甚至在验证集上几乎100%准确。

原因:与5.3类似,但更隐蔽。当数据列表按文件名排序后,同一岩心的切片文件名前缀相同,会在切分时自然落在一起。如果不做分组,就是把同源数据塞进了两个集合。

解决:在生成数据列表时,用文件名中的岩心ID作为分组键,用GroupShuffleSplit或StratifiedGroupKFold操作。具体做法是给每一行数据增加一个group_id字段,拆分的整体单位是group_id,而不是单独每个样本。手动检查时,打印出验证样本的文件名前缀,确认没有与训练集交集。

5.6 显存溢出与BatchSize调参

现象:训练刚开始就出现CUDA out of memory,把batch_size调到2仍然报错。

原因:除了模型大小外,输入尺寸、通道数、损失计算中的特征图都会被显存。3通道512×512输入比单通道CT切片的显存占用高得多。

解决:先用torch.cuda.empty_cache()清理缓存,然后按顺序降低以下选项:batch_size减半、分辨率改为256、模型从DeepLabV3+换成UNet、关闭混合精度训练。我建议直接使用AMP混合精度训练,在torch.cuda.amp加持下显存占用可以减少约40%,且裂缝分割这类任务对精度损失不敏感。训练脚本里加上scaler.scale(loss).backward()和scaler.step(optimizer)即可。

6. 进阶:从分割结果到裂缝定量分析的落地技巧

6.1 实验追踪与参数管理

裂缝分割不是跑一次就能拿结果,往往要试十几次模型配置。我用MLflow做实验追踪,每次跑完自动记录loss、mIoU、学习率等指标,并保存最优权重。如果你的团队更喜欢轻量的方案,直接在训练脚本里把关键指标写入一个JSON文件也行,但等到做对比时你会后悔没配追踪工具。注意在追踪时记录数据增强参数、损失函数类型、分辨率等超参数,而不是只记录指标,否则复现时还是得翻代码。

6.2 裂缝面积与开度计算

分割完成后,量化裂缝参数是工程需要的核心产出。最常见的两个指标是裂缝面积占比和裂缝开度。面积占比可以直接对二值掩码做像素统计,除以图像总像素数即可。开度则复杂一些,需要提取裂缝骨架后计算局部宽度。我一般先对掩码做距离变换,再沿骨架线提取距离值,乘以两倍即为局部宽度。

下面是一段计算裂缝面积占比的简单代码:

def crack_area_ratio(mask): crack_pixels = int((mask > 0).sum()) total_pixels = mask.size return crack_pixels / total_pixels

如果要做开度统计,建议用OpenCV的distanceTransform配合skeletonize(来自skimage),先裁剪连通域,再对每个区域单独计算,避免把不同裂缝之间的间距也算进去。开度结果通常以像素为单位输出,需要根据CT分辨率换算成毫米,这一步务必在项目开始前确认,否则报告里的数据没法用。

6.3 把模型集成到自己的工程中

最后,我习惯把训练好的模型封装成一个类,对外只暴露predict(image_path)方法,内部处理灰度图、尺寸变换、后处理和结果输出。这样在写自动化处理脚本时,不需要关心模型细节。封装时要注意固定输入尺寸和归一化方式,确保与训练时完全一致。预测阶段不推荐再使用数据增强库,统一用OpenCV即可,减少依赖。

我在实际项目中栽过跟头:模型训练时用了albumentations的归一化,推理时却直接用cv2.imread除以255,导致输入分布完全变了,模型输出几乎全黑。后来把所有预处理统一放到一个函数里,训练和推理共用,就再没翻过车。希望这些经验能帮你省掉几个晚上的调试时间。裂缝分割这个方向,只要数据规范、避坑到位,性能提升是稳定的,值得把它沉淀成自己工程里的一把利器。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/11 11:14:49

macOS OCR开发:Tesseract的Objective-C包装器指南

简介:面向macOS开发者的OCR集成资源,以Objective-C封装开源引擎Tesseract,使开发者能通过Xcode在原生应用中快速调用文字识别能力,适合需要处理截图取词、图片文本提取或构建轻量OCR工具的场景。压缩包共108个文件,大小…

作者头像 李华
网站建设 2026/10/11 11:12:29

安装最新的Trae没有插件选项,把settings改到TaoToken后能恢复吗?

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/11 11:12:22

风电随机性动态经济调度:Matlab+Yalmip建模与场景削减实战

风电随机性的动态经济调度,这个题目我前后玩了有一阵子。做电力系统优化的人应该都有感触,传统的经济调度模型,大多基于确定性负荷预测,给一组固定的机组出力。但风电一旦接入,情况就完全不一样了——风速本身是个随机…

作者头像 李华
网站建设 2026/10/11 11:12:16

C#端侧智能体实战:ASP.NET MVC+本地LLM+NanoFramework

1. 从标题拆解这个项目的真实意图1.1 标题里藏着三层技术栈第一次看到这个标题,信息密度确实大。我把它拆成三个独立但互相咬合的部分来看:上位机侧:在 Visual Studio 里用 C# 和 ASP.NET MVC 搭一套 Web 应用,负责界面、业务逻辑…

作者头像 李华
网站建设 2026/10/11 11:12:12

QNX vmstat内存分析:微内核下物理池与虚拟地址空间的诊断逻辑

1. 项目概述:为什么在QNX环境下,vmstat不是“看内存”的万能钥匙?QNX内存分析——vmstat探究,这个标题乍一看像是在教你怎么用一个命令查内存,但实际踩进去才发现,它根本不是Linux里那个熟悉的vmstat。我第…

作者头像 李华