简介:本资源面向计算机视觉方向的毕业设计学生与深度学习入门者,提供一套基于Transformer的语义分割完整实现方案,重点解决皮肤病变区域的像素级分割问题,适用于医学图像分析场景。压缩包共约2000个文件,整体59.39MB,以5447张jpg皮肤病变图像及对应标注为主,辅以20个py训练与推理脚本、2个pth预训练权重、yml配置文件、m文件及说明文档,覆盖数据预处理、模型搭建、训练调参与结果可视化全流程。项目在编码器-解码器结构中融合卷积神经网络与分层注意力机制,用于捕捉皮肤图像中的细微纹理与多尺度上下文信息,并配有IoU、Precision、Recall等评估代码。目前已有1055人学习下载,读者可据此掌握Transformer在医学语义分割中的落地方法,完成从数据组织到模型评估的完整实践,适合作为毕业设计参考与二次开发基础。
1. 皮肤病变分割这个选题,为什么Transformer比U-Net更值得做毕业设计
如果你正在找毕业设计题目,看到“基于Transformer实现语义分割”这个方向,大概率已经翻过不少U-Net的教程了。皮肤病变分割是医学图像分割里最经典的入门场景之一——ISIC数据集公开可用,任务定义清晰(把病灶区域从皮肤背景里抠出来),评价指标成熟(Dice、IoU)。但问题在于,如果你只拿U-Net跑一遍ISIC,答辩时老师一句“创新点在哪”就能把你问住。
Transformer架构引入语义分割之后,这个局面变了。Vision Transformer和Swin Transformer把自注意力机制带进了密集预测任务,在皮肤病变这种边界模糊、形状不规则、对比度低的场景里,全局建模能力确实比纯卷积网络有优势。更关键的是,这套方案有完整的代码路径可以复现,数据集也是现成的,不需要你自己标注。适合的人群很明确:有一定PyTorch基础、想做医学图像方向、希望毕业设计有技术纵深而不是调包了事的本科生或低年级研究生。
2. 从ISIC数据集到Transformer分割网络:选型与预处理
2.1 为什么皮肤病变分割适合用Transformer打底
皮肤病变分割的核心难点不在“分割”本身,而在病灶边界的模糊性。黑色素瘤的边缘往往和周围正常皮肤渐变过渡,卷积核的局部感受野在这种场景下容易丢失全局上下文。U-Net通过跳跃连接缓解了一部分问题,但本质上仍然是在局部窗口内做特征提取。
Transformer的自注意力机制天然适合处理这种“需要看全局才能判断局部”的任务。每一层注意力都在计算所有patch之间的关系,病灶区域和远端的皮肤纹理可以建立直接关联。Swin Transformer进一步引入了窗口注意力和移位窗口机制,把计算复杂度从平方级降到线性级,同时保留了跨窗口的信息交换能力。这就是为什么近两年的医学图像分割论文里,Swin-UNet、TransUNet这类混合架构频繁出现。
对于毕业设计来说,选Swin Transformer作为编码器、配合一个轻量解码器,是比较稳妥的方案。纯ViT做分割需要大量数据增强和长训练周期,Swin的归纳偏置更强,收敛更快。
2.2 ISIC数据集下载与目录结构整理
ISIC(International Skin Imaging Collaboration)数据集是皮肤病变分割的标准公开数据。常见做法是从ISIC Archive下载2016到2018年的任务数据,包含输入图像和对应的二值mask。原始数据解压后通常是这样的结构:
ISIC2018/ ├── ISIC2018_Task1-2_Training_Input/ │ ├── ISIC_0000000.jpg │ ├── ISIC_0000001.jpg │ └── ... ├── ISIC2018_Task1_Training_GroundTruth/ │ ├── ISIC_0000000_segmentation.png │ ├── ISIC_0000001_segmentation.png │ └── ... └── ISIC2018_Task1-2_Validation_Input/ └── ...我一般会先写一个脚本把数据整理成统一的训练/验证划分,同时检查图像和mask的配对关系。下面这段代码做三件事:遍历输入目录、匹配对应的分割mask、按8:2划分训练集和验证集并写入txt文件。
import os import random from pathlib import Path # 配置路径 data_root = Path("ISIC2018") image_dir = data_root / "ISIC2018_Task1-2_Training_Input" mask_dir = data_root / "ISIC2018_Task1_Training_GroundTruth" output_dir = Path("splits") output_dir.mkdir(exist_ok=True) # 收集所有图像文件名(不含扩展名) image_files = sorted([f.stem for f in image_dir.glob("*.jpg")]) # 检查每个图像是否有对应的mask paired = [] for name in image_files: mask_path = mask_dir / f"{name}_segmentation.png" if mask_path.exists(): paired.append(name) else: print(f"警告:{name} 没有对应的分割mask,已跳过") print(f"有效配对数量:{len(paired)}") # 随机打乱后按8:2划分 random.seed(42) random.shuffle(paired) split_idx = int(len(paired) * 0.8) train_list = paired[:split_idx] val_list = paired[split_idx:] # 写入文件 with open(output_dir / "train.txt", "w") as f: f.write("\n".join(train_list)) with open(output_dir / "val.txt", "w") as f: f.write("\n".join(val_list)) print(f"训练集:{len(train_list)},验证集:{len(val_list)}")这段代码的关键参数是random.seed(42),固定随机种子保证每次划分结果一致,方便复现。split_idx控制划分比例,8:2是医学图像分割里比较常用的配置,数据量少的时候可以调到7:3。输出的是纯文件名列表,后续Dataset类直接读取这个txt来加载数据。
注意:ISIC数据集的mask是PNG格式,像素值只有0和255,加载后需要除以255归一化到0-1之间。图像本身是RGB三通道,尺寸不统一,需要在Dataset里统一resize。
2.3 数据增强策略:皮肤病变场景下哪些增强真正有用
皮肤病变图像的数据增强不能照搬自然图像那套。颜色抖动要慎用,因为病灶的颜色本身就是重要特征;随机裁剪可以用,但要注意保持病灶在裁剪窗口内的比例。我一般会用这几类增强:
水平翻转和垂直翻转是安全的,皮肤病变没有固定的方向性。随机旋转90度也常用。颜色方面,轻微的亮度对比度调整可以,但幅度要小。Cutout和GridMask这类遮挡增强在皮肤病变上效果不稳定,因为病灶区域被遮挡后模型可能学到错误的边界信息。
下面是一个基于Albumentations的增强管道配置:
import albumentations as A from albumentations.pytorch import ToTensorV2 # 训练集增强 train_transform = A.Compose([ A.Resize(256, 256), # 统一尺寸 A.HorizontalFlip(p=0.5), # 水平翻转 A.VerticalFlip(p=0.5), # 垂直翻转 A.RandomRotate90(p=0.5), # 90度旋转 A.ShiftScaleRotate( # 平移缩放旋转 shift_limit=0.1, scale_limit=0.1, rotate_limit=30, p=0.5 ), A.RandomBrightnessContrast( # 轻微亮度对比度调整 brightness_limit=0.1, contrast_limit=0.1, p=0.3 ), A.Normalize( # 标准化 mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225] ), ToTensorV2() ]) # 验证集只做resize和标准化 val_transform = A.Compose([ A.Resize(256, 256), A.Normalize( mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225] ), ToTensorV2() ])Resize(256, 256)是权衡显存和精度的结果,显存够可以上384。ShiftScaleRotate的shift_limit=0.1表示最多平移10%的边长,scale_limit=0.1表示缩放范围是0.9到1.1倍。RandomBrightnessContrast的p=0.3控制触发概率,不要设太高。Normalize用的ImageNet均值方差,如果从头训练可以改成ISIC数据集自己的统计值。
3. Swin Transformer编码器加轻量解码器的完整实现
3.1 模型整体结构:编码器-解码器怎么搭
Swin-UNet的结构思路很直接:用Swin Transformer的四个stage作为编码器,每个stage输出不同分辨率的特征图,然后用一个对称的解码器逐步上采样并融合跳跃连接。和U-Net的区别在于,编码器不再是卷积堆叠,而是窗口注意力和移位窗口注意力交替的Transformer块。
我一般会直接用timm库里的Swin Transformer预训练权重作为编码器初始化,这样收敛快很多。解码器部分自己写,保持轻量,避免参数量爆炸。整体结构是这样的:
- 编码器:Swin-Tiny或Swin-Small,输出四个stage的特征,通道数分别是96、192、384、768
- 瓶颈层:一个卷积块进一步压缩
- 解码器:四个上采样阶段,每个阶段用双线性插值放大特征图,然后和编码器对应stage的特征做通道拼接,再经过两个3x3卷积
- 输出层:1x1卷积把通道数降到1,接Sigmoid得到分割概率图
下面给出编码器加载和解码器定义的代码:
import torch import torch.nn as nn import timm class SwinUNet(nn.Module): def __init__(self, num_classes=1, pretrained=True): super().__init__() # 加载Swin Transformer编码器,features_only模式返回四个stage的输出 self.encoder = timm.create_model( "swin_tiny_patch4_window7_224", pretrained=pretrained, features_only=True, out_indices=(0, 1, 2, 3) ) # 编码器各stage输出通道数 encoder_channels = [96, 192, 384, 768] # 解码器:从最深特征开始逐步上采样 self.decoder4 = self._make_decoder_block(768, 384) self.decoder3 = self._make_decoder_block(384, 192) self.decoder2 = self._make_decoder_block(192, 96) self.decoder1 = self._make_decoder_block(96, 48) # 最终输出层 self.final_conv = nn.Sequential( nn.Conv2d(48, 32, kernel_size=3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.Conv2d(32, num_classes, kernel_size=1) ) def _make_decoder_block(self, in_ch, out_ch): return nn.Sequential( nn.Conv2d(in_ch, out_ch, kernel_size=3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, kernel_size=3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True) ) def forward(self, x): # 编码器前向 features = self.encoder(x) # features是一个列表,包含四个stage的输出 # 形状分别为 [B, 96, H/4, W/4], [B, 192, H/8, W/8], # [B, 384, H/16, W/16], [B, 768, H/32, W/32] f1, f2, f3, f4 = features # 解码器逐级上采样 d4 = self.decoder4(f4) d4 = nn.functional.interpolate(d4, size=f3.shape[2:], mode="bilinear", align_corners=False) d4 = d4 + f3 # 残差连接 d3 = self.decoder3(d4) d3 = nn.functional.interpolate(d3, size=f2.shape[2:], mode="bilinear", align_corners=False) d3 = d3 + f2 d2 = self.decoder2(d3) d2 = nn.functional.interpolate(d2, size=f1.shape[2:], mode="bilinear", align_corners=False) d2 = d2 + f1 d1 = self.decoder1(d2) # 最终上采样回原图尺寸 out = nn.functional.interpolate(d1, size=x.shape[2:], mode="bilinear", align_corners=False) out = self.final_conv(out) return outtimm.create_model的features_only=True让Swin只返回特征图而不是分类logits,out_indices=(0,1,2,3)指定返回四个stage的输出。解码器里用的是加法残差连接而不是通道拼接,这样参数量更小,显存占用低。interpolate的align_corners=False是PyTorch里做双线性上采样的标准配置,避免边缘像素偏移。
注意:Swin Transformer的输入尺寸需要是patch_size的整数倍,patch4_window7要求输入能被4整除。256x256是安全的,如果改成其他尺寸要检查一下。
3.2 损失函数选择:Dice Loss加BCE的组合逻辑
皮肤病变分割里正负样本极度不平衡,病灶区域通常只占整张图的10%到30%。只用BCE Loss会导致模型倾向于预测全背景,Dice系数上不去。常见做法是Dice Loss和BCE Loss加权组合,Dice负责优化重叠区域,BCE负责稳定训练梯度。
class DiceBCELoss(nn.Module): def __init__(self, dice_weight=0.5, bce_weight=0.5): super().__init__() self.dice_weight = dice_weight self.bce_weight = bce_weight self.bce = nn.BCEWithLogitsLoss() def forward(self, pred, target): # pred是logits,target是0/1标签 bce_loss = self.bce(pred, target) # Dice Loss计算 pred_sigmoid = torch.sigmoid(pred) pred_flat = pred_sigmoid.view(-1) target_flat = target.view(-1) intersection = (pred_flat * target_flat).sum() dice_loss = 1 - (2. * intersection + 1e-6) / (pred_flat.sum() + target_flat.sum() + 1e-6) return self.dice_weight * dice_loss + self.bce_weight * bce_lossdice_weight和bce_weight各0.5是起点,如果训练初期loss震荡大,可以把bce_weight调到0.7。1e-6是平滑项,防止分母为零。注意BCEWithLogitsLoss内部已经做了Sigmoid,所以Dice部分要手动加Sigmoid,两者不能混用同一个输入。
3.3 训练循环与学习率调度
训练配置上,我一般用AdamW优化器,初始学习率1e-4,weight decay 1e-4。学习率调度用CosineAnnealingLR,配合 warmup 前5个epoch。Batch size根据显存来,256x256输入下,8GB显存大概能跑8到12。
import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR, LinearLR, SequentialLR def train_one_epoch(model, dataloader, optimizer, criterion, device): model.train() total_loss = 0 for images, masks in dataloader: images = images.to(device) masks = masks.to(device).unsqueeze(1).float() optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, masks) loss.backward() # 梯度裁剪,防止Transformer训练不稳定 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() total_loss += loss.item() return total_loss / len(dataloader) # 优化器和调度器配置 model = SwinUNet(num_classes=1, pretrained=True).to(device) optimizer = optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) # Warmup 5个epoch,然后Cosine退火 warmup = LinearLR(optimizer, start_factor=0.01, total_iters=5) cosine = CosineAnnealingLR(optimizer, T_max=95, eta_min=1e-6) scheduler = SequentialLR(optimizer, schedulers=[warmup, cosine], milestones=[5])clip_grad_norm_的max_norm=1.0是Transformer训练的常规操作,防止梯度爆炸。LinearLR的start_factor=0.01表示从初始学习率的1%开始线性增加到1e-4,total_iters=5表示5个epoch完成warmup。CosineAnnealingLR的T_max=95对应剩余95个epoch,总训练100轮。
4. 评估指标与推理可视化:Dice和IoU到底怎么看
4.1 Dice、IoU、敏感度和特异度的计算方式
皮肤病变分割的评估不能只看一个指标。Dice系数衡量重叠度,IoU是交并比,敏感度反映病灶被正确检出的比例,特异度反映背景被正确排除的比例。这四个指标一起看才能判断模型是真的学到了分割能力还是只是偏向预测背景。
import numpy as np def compute_metrics(pred_mask, gt_mask, threshold=0.5): """ pred_mask: 模型输出的概率图,形状[H, W] gt_mask: 真实标签,形状[H, W],值为0或1 threshold: 二值化阈值 """ pred_binary = (pred_mask > threshold).astype(np.uint8) gt_binary = gt_mask.astype(np.uint8) # 计算TP, FP, FN, TN tp = np.sum((pred_binary == 1) & (gt_binary == 1)) fp = np.sum((pred_binary == 1) & (gt_binary == 0)) fn = np.sum((pred_binary == 0) & (gt_binary == 1)) tn = np.sum((pred_binary == 0) & (gt_binary == 0)) # Dice dice = (2 * tp) / (2 * tp + fp + fn + 1e-6) # IoU iou = tp / (tp + fp + fn + 1e-6) # 敏感度 sensitivity = tp / (tp + fn + 1e-6) # 特异度 specificity = tn / (tn + fp + 1e-6) return { "dice": dice, "iou": iou, "sensitivity": sensitivity, "specificity": specificity }threshold=0.5是默认二值化阈值,实际推理时可以在验证集上扫一遍0.3到0.7,选Dice最高的那个。1e-6同样是防止除零。这四个指标里,敏感度低说明漏检多,特异度低说明误检多,Dice和IoU综合反映分割质量。
4.2 推理结果可视化:叠加原图和预测mask
答辩的时候,一张清晰的可视化图比一堆数字更有说服力。我一般会把原图、真实mask、预测mask和叠加结果拼成一张四格图。
import matplotlib.pyplot as plt def visualize_prediction(image, gt_mask, pred_mask, save_path=None): """ image: 原始RGB图像,形状[H, W, 3],值范围0-255 gt_mask: 真实mask,形状[H, W] pred_mask: 预测概率图,形状[H, W] """ fig, axes = plt.subplots(1, 4, figsize=(16, 4)) axes[0].imshow(image) axes[0].set_title("Original Image") axes[0].axis("off") axes[1].imshow(gt_mask, cmap="gray") axes[1].set_title("Ground Truth") axes[1].axis("off") axes[2].imshow(pred_mask, cmap="jet") axes[2].set_title("Prediction Heatmap") axes[2].axis("off") # 叠加显示:原图 + 预测轮廓 overlay = image.copy() pred_binary = (pred_mask > 0.5).astype(np.uint8) contours = pred_binary - np.roll(pred_binary, 1, axis=0) overlay[contours == 1] = [255, 0, 0] axes[3].imshow(overlay) axes[3].set_title("Overlay") axes[3].axis("off") plt.tight_layout() if save_path: plt.savefig(save_path, dpi=150, bbox_inches="tight") plt.close()cmap="jet"让概率图从蓝色到红色渐变,红色区域是模型认为最可能是病灶的地方。叠加图里用红色轮廓标出预测边界,方便和原图对比。dpi=150保证保存的图片足够清晰。
5. 训练过程中最容易翻车的五个地方
5.1 现象:loss不下降,Dice一直在0.3左右
原因通常是学习率太大或者数据归一化没做对。Swin Transformer对输入分布敏感,如果图像没有用ImageNet均值方差标准化,预训练权重相当于白加载。解决方法是检查Normalize的参数,确认输入范围在0-1之间再减均值除方差。另外把学习率从1e-4降到5e-5试试。
5.2 现象:验证集Dice比训练集高很多
这是典型的过拟合信号,但皮肤病变数据量小的时候反而常见。原因是验证集没有做数据增强,分布更“干净”,模型在验证集上表现反而好。解决方法是增加训练集增强强度,或者加Dropout和Weight Decay。如果训练集Dice已经到0.95但验证集只有0.7,那就是过拟合没跑了。
5.3 现象:预测mask全是背景或者全是病灶
全背景说明正样本权重太低,把Dice Loss的权重从0.5提到0.7。全病灶说明BCE的pos_weight没设对,可以在BCEWithLogitsLoss里加pos_weight参数,值设为负样本数除以正样本数。另一个可能是学习率太大导致模型在初期就崩了,检查前几个epoch的loss曲线。
5.4 现象:训练到一半loss突然变成NaN
Transformer训练里梯度爆炸是常见问题。检查clip_grad_norm_有没有加,max_norm是不是设得太大。另外AdamW的eps默认是1e-8,如果loss还是NaN,可以调到1e-6。混合精度训练也会导致NaN,如果用了amp,先关掉试试。
5.5 现象:推理时显存不够,batch size只能设1
Swin Transformer的显存占用和输入尺寸的平方成正比。256x256输入下Swin-Tiny大概需要4GB显存做推理,Swin-Small要6GB以上。解决办法是推理时用torch.no_grad()上下文,并且把输入尺寸降到224x224。如果还是不够,可以把编码器换成Swin-Tiny的蒸馏版本,参数量更小。
6. 把Dice从0.85推到0.90的三个进阶技巧
第一个技巧是多尺度推理。训练时用256x256,推理时把原图缩放到256、320、384三个尺度分别预测,然后把概率图缩回原尺寸取平均。这个操作不需要重新训练,推理时间增加三倍,但Dice通常能涨1到2个点。代码实现上就是写一个循环,对每个尺度调用模型,收集输出后做torch.mean。
第二个技巧是后处理里的条件随机场。CRF对边界像素的优化效果在皮肤病变上比较明显,尤其是病灶边缘模糊的样本。用pydensecrf库,把模型输出的概率图作为一元势,原图颜色作为二元势,迭代5次。注意CRF比较慢,一张图大概0.5秒,适合答辩演示但不适合大规模推理。
第三个技巧是测试时增强。对验证集的每张图做水平翻转、垂直翻转、90度旋转,每个变换都跑一次模型,把预测结果逆变换后平均。这个和第一个技巧可以叠加使用,但推理次数会变成12次,需要权衡时间。
def tta_predict(model, image, device): """测试时增强推理""" model.eval() preds = [] with torch.no_grad(): # 原始 preds.append(model(image.unsqueeze(0).to(device)).squeeze()) # 水平翻转 preds.append(torch.flip(model(torch.flip(image, [2]).unsqueeze(0).to(device)).squeeze(), [1])) # 垂直翻转 preds.append(torch.flip(model(torch.flip(image, [1]).unsqueeze(0).to(device)).squeeze(), [0])) # 旋转90度 rotated = torch.rot90(image, 1, [1, 2]) pred_rot = model(rotated.unsqueeze(0).to(device)).squeeze() preds.append(torch.rot90(pred_rot, -1, [0, 1])) return torch.stack(preds).mean(dim=0)这段代码里torch.flip和torch.rot90的维度参数要对应好,图像是[C, H, W],翻转H轴用[1],翻转W轴用[2]。逆变换的时候旋转方向要反过来,torch.rot90的-1表示逆时针转回去。最后torch.stack把四个预测叠起来取平均。
我自己的习惯是训练完先跑一遍基础推理,记录Dice,然后逐个加技巧,看每个技巧的实际增益。多尺度推理和TTA的增益比较稳定,CRF有时候会掉点,因为皮肤病变的颜色分布差异大,CRF的二元势不一定能正确引导边界。答辩前留一天时间专门调这些后处理参数,别等到最后一刻才发现CRF把结果搞砸了。希望帮到你。
本文还有配套的精品资源,点击获取