简介:面向深度学习与医学图像处理的毕业设计场景,提供一套基于Transformer实现语义分割的完整工程,核心任务是皮肤病变区域分割。压缩包内含2000个文件,共59.39MB,其中以5447张JPG皮肤图像作为训练/验证数据集,并配有像素级标注;20个Python源码覆盖Transformer模型搭建、改进编码器-解码器结构、训练验证与评估流程;2个.pth预训练权重便于直接部署或微调,另有YAML配置、Markdown说明、PDF文档及分割结果可视化图。已有1054人学习使用。项目将Transformer自注意力机制与CNN融合,针对皮肤病变图像中复杂纹理和细微差异进行优化,可计算IoU、Precision、Recall等指标,并支持更换数据集迁移到其他语义分割任务,适合用于课程设计、论文复现或进一步研究。
1. 皮肤病变分割为什么盯上Transformer
在皮肤镜图像上,普通UNet的Dice往往卡在0.78左右,而把编码器换成Transformer后,很多脱敏数据集上能明显推高到0.85以上。这个差距对临床来说就是漏检边界的位置变化。语义分割在医疗影像里的位置很特殊:它既不是目标检测那样的框级粗粒度,也不是分类的图级语义,而是每个像素都要判断。皮肤病变分割就是典型,病灶边缘颜色接近、形状不规则,局部卷积很容易被纹理欺骗,Transformer的全局自注意力可以缓解这类问题。这篇文章拆解一份名为Medical-Transformer-Improvement的工程包,包含完整PyTorch代码、皮肤病变图像与像素级标注、训练配置文件。围绕编解码结构、数据集组织、训练评估闭环和最后的微创新,把一整套可落地的流程讲清楚,适合正在做毕业设计或想快速从CNN切到Transformer的研究生。
2. 从CNN到Transformer:编解码结构怎么搭
2.1 为什么医疗图像分割需要全局注意力
卷积神经网络在图像分割里统治了很多年。UNet、DeepLabV3实例都依赖局部感受野一层层堆叠,虽然3x3卷积堆到十几层以后理论上能看到全局,但实际操作中远距离信息要经过多层才能传播,而且浅层特征里包含的全局上下文很少。对皮肤病变分割来说,这不够用:一张皮肤镜图像里,病变区域可能只占几个百分点,但判断它是良性还是恶性、边界落在哪里,需要参考周围皮肤纹理、血管走向和肤色渐变,而不是只看局部颜色。
Transformer的核心是自注意力。理解Transformer模型详解会发现,自注意力层里每个token都可以和所有其他token计算关联权重,因此在一层内就能把视野拉满。Vision Transformer(ViT)把图像切成一串patch token,直接送入标准Transformer encoder。这套思路被移植到语义分割后,最直接的表现就是长距离依赖处理得更干净。在项目中常见的做法是保留UNet的编码器-解码器整体框架,但把编码器换成Transformer模块,这样既能继承Transformer的全局建模,又保留了解码器的上采样和跳跃连接能力。
我拿到的这个工程包,目录结构中可以看到model/encoder.py、model/decoder.py这类文件。从命名上看,它做的正是类似TransUNet的改进:CNN下采样加Transformer编码器,再配一个逐步上采样的解码器。
2.2 编码器侧Patch Embedding与位置编码
Transformer不直接吃原始像素,它吃的是patch序列。以16x16的patch大小为例,一张512x512的图像会被切成1024个patch,每个patch经过线性投影变成256维的token。代码实现并不复杂,常见做法是直接用一个stride等于patch_size的卷积完成embedding。
import torch import torch.nn as nn class PatchEmbedding(nn.Module): def __init__(self, in_channels=3, embed_dim=256, patch_size=16): super().__init__() self.patch_size = patch_size # 用卷积把每个patch投影成embedding向量 self.proj = nn.Conv2d(in_channels, embed_dim, kernel_size=patch_size, stride=patch_size) def forward(self, x): # x: [B, 3, H, W] -> [B, embed_dim, H/patch, W/patch] x = self.proj(x) # 展平:把H/patch * W/patch压缩成长度为N的序列 x = x.flatten(2) # [B, D, N] x = x.transpose(1, 2) # [B, N, D] return x这里的关键参数是patch_size。patch越小,序列越长,计算量越大,但细节保留得越好。在皮肤病变分割上,我一般会用16,如果显存不够再换成32。输入图像尺寸也直接影响序列长度,例如512x512、patch16,序列长度是32x32=1024,这个长度对于Transformer encoder是完全可以接受的。
embedding之后还要加位置编码,否则模型分辨不出patch在图像中的位置。项目里常见的是用sine-cosine绝对位置编码,或者干脆用可学习的nn.Parameter。在PyTorch中:
import math import torch import torch.nn as nn class PositionalEncoding(nn.Module): def __init__(self, max_len=2048, embed_dim=256, learnable=True): super().__init__() if learnable: self.pos = nn.Parameter(torch.randn(1, max_len, embed_dim)) else: self.register_buffer("pos", self._build_sincos(max_len, embed_dim)) def _build_sincos(self, max_len, d_model): pe = torch.zeros(max_len, d_model) position = torch.arange(0, max_len).unsqueeze(1).float() div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)) pe[:, 0::2] = torch.sin(position * div_term) pe[:, 1::2] = torch.cos(position * div_term) return pe.unsqueeze(0) def forward(self, x): # x: [B, N, D] return x + self.pos[:, :x.size(1)]这里我更推荐可学习位置编码。它在训练集上可以自由调整,对图像平移和尺度变化比固定编码更宽容,对于皮肤病变图像里的随机裁剪场景尤其友好。注意max_len要大于实际序列长度,一般给到2048足够。
2.3 解码器:从token序列回到像素掩码
编码器输出是[B, N, D]的token序列,要得到像素级分割图,必须把它reshape回二维特征图,再做多次上采样。比较省事的方案是先把token reshape成[B, H/patch, W/patch, D],转成通道在后,然后用转置卷积或双线性上采样+卷积把分辨率逐级抬高到原图大小。
import torch import torch.nn as nn import torch.nn.functional as F class SimpleDecoder(nn.Module): def __init__(self, embed_dim=256, num_classes=1, img_size=512, patch_size=16): super().__init__() self.img_size = img_size self.patch_size = patch_size h, w = img_size // patch_size, img_size // patch_size self.hw = (h, w) self.head = nn.Sequential( nn.Conv2d(embed_dim, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.ConvTranspose2d(128, 64, kernel_size=2, stride=2), nn.ReLU(inplace=True), nn.ConvTranspose2d(64, 32, kernel_size=2, stride=2), nn.ReLU(inplace=True), nn.Conv2d(32, num_classes, kernel_size=1) ) def forward(self, x): # x: [B, N, D] B, N, D = x.shape x = x.transpose(1, 2).reshape(B, D, *self.hw) # [B, D, H, W] x = self.head(x) # 上采样到原图尺寸 x = F.interpolate(x, size=(self.img_size, self.img_size), mode="bilinear", align_corners=False) return x这个解码器比较朴素,适合快速跑通。真正效果好的工程包会引入UNet风格的跳跃连接,也就是把浅层CNN特征和Transformer输出在对应尺度上拼接。比如先用一个CNN stem生成1/4分辨率的特征图,再把它和Transformer恢复出来的特征拼接,这样能弥补Transformer丢失的局部细节。这也是工程包中“Improvement”这层含义的核心:不是简单地用Transformer替代所有卷积,而是让它和CNN特征互补。
2.4 改进方向:CNN Stem与分层注意力
纯粹堆叠Transformer encoder并不总是最优。在医学分割上,常见的几个改进思路是:CNN stem(先用几个卷积下采样到1/4,再切patch),分层注意力(像Swin Transformer那样在不同stage间改变分辨率),以及混合编码器(浅层用CNN,深层用Transformer)。下面这张表列出了几个常见backbone在皮肤病变分割场景下的特点:
| Backbone | 感受野 | 计算量 | 典型用途 |
|---|---|---|---|
| ViT-Base | 全局,从第一层开始 | 高 | 长依赖优先,适合大图 |
| Swin-Tiny | 窗口局部+跨窗口 | 低 | 显存受限,兼顾局部 |
| CNN stem + ViT | 浅层局部+深层全局 | 中 | 医疗分割常见改进 |
| TransUNet式 | 全局+多尺度跳跃 | 中高 | 复杂病灶边界恢复 |
从经验上看,皮肤病变中病毒疣、黑色素瘤等样本边界不规则,窗口注意力如果窗口太小,效果会打折。所以在工程包里,我建议优先尝试CNN stem + ViT的组合,它既保留了卷积对边缘细节的敏感,又引入Transformer对整体结构的目标建模。如果你机器显存不够大,再考虑Swin-Tiny。
3. 皮肤病变数据集的组织与预处理
3.1 目录结构与读取方式
工程包里的数据集一般按images和masks两个目录组织,对应原图和像素级标注。标注通常是PNG灰度图,背景为0,病灶区域为1或255。还会有train.txt、val.txt、test.txt三个文本文件,记录每个样本的名称或路径。这种组织方式比把所有数据塞进一个npz更直观,也方便增量式训练。
dataset/ ├── images/ │ ├── 0001.jpg │ └── ... ├── masks/ │ ├── 0001.png │ └── ... ├── train.txt ├── val.txt └── test.txt其中train.txt每一行是文件名,比如0001,不含扩展名。读取时用os.listdir或者按txt逐行收集。一般我会写一个函数读取这些列表,再和images、masks拼接出完整路径。在皮肤病变数据上,还需注意图像格式:一些公开数据集会有_segmentation.png这类后缀,要统一处理。
3.2 自定义Dataset的实现
在PyTorch中,自定义Dataset是标准做法。代码里需要同时返回图像和对应的mask,并且mask要与图像做相同的几何变换,否则训练时错位,评价指标直接崩。
import os import cv2 import torch from torch.utils.data import Dataset class SkinLesionDataset(Dataset): def __init__(self, img_dir, mask_dir, file_list, transform=None): self.img_dir = img_dir self.mask_dir = mask_dir self.file_list = [line.strip() for line in open(file_list)] self.transform = transform def __len__(self): return len(self.file_list) def __getitem__(self, idx): name = self.file_list[idx] img_path = os.path.join(self.img_dir, name + ".jpg") mask_path = os.path.join(self.mask_dir, name + ".png") image = cv2.imread(img_path) image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) # 统一尺寸,避免每张图像大小不一致带来的batch问题 image = cv2.resize(image, (512, 512), interpolation=cv2.INTER_LINEAR) mask = cv2.resize(mask, (512, 512), interpolation=cv2.INTER_NEAREST) # mask归一化到0/1 mask = (mask > 0).astype("float32") if self.transform: # 这里只做了非几何增强,几何增强在外部定义 image = self.transform(image) # 转成Tensor,像素值归一到0-1 image = torch.from_numpy(image.transpose(2,0,1)).float() / 255.0 mask = torch.from_numpy(mask).unsqueeze(0).float() return image, mask这个类里有个容易踩坑的点:cv2.resize对mask必须用最近邻插值INTER_NEAREST,如果用了线性插值,mask会变成0到1之间的浮点,训练时的损失函数就会在边界附近产生错误梯度。另外,mask > 0这一步是为了兼容标注是255灰度值的情况,把255统一成1。图像归一化到0-1,但没有做标准化,如果你用预训练backbone,建议加载一个ImageNet均值方差做标准化,否则预训练权重相当于没利用上。
3.3 数据增强:医学图像要克制
语义分割的数据增强要同时作用于图像和mask。常见增强有水平翻转、垂直翻转、旋转、随机裁剪、亮度对比度微调。在医学数据集上,我一般不用过于夸张的色彩扰动,因为皮肤颜色本身就是诊断依据。增强的主要作用是提高模型对旋转和平移的鲁棒性,而不是改变颜色语义。
下面给出一个针对皮肤病变的增强策略表:
| 增强操作 | 概率 | 参数范围 | 作用 |
|---|---|---|---|
| 随机水平翻转 | 0.5 | - | 增加对称不变性 |
| 随机旋转 | 0.6 | 0~30度 | 适应临床拍摄角度差异 |
| 随机缩放 | 0.3 | 0.9~1.1倍 | 模拟镜头远近变化 |
| 亮度/对比度 | 0.3 | 0.95~1.05 | 轻度色彩扰动 |
| 随机裁剪 | 0.2 | 0.8倍区域 | 增大有效样本数 |
这些增强可以用albumentations库或者PyTorch的torchvision.transforms实现。注意在增强时,mask和image的种子要一致,也就是说同一张图像的随机参数绑定。用albumentations的Compose很方便,因为它自动处理重采样和mask同步。如果不想引入额外依赖,也可以用torchvision.transforms.functional自己写同步旋转,但容易在mask上留下黑边,需要配合随机中心裁剪一起用。
3.4 训练/验证/测试划分与类别平衡
数据集规模不大时,按7:2:1或8:1:1划分即可。用一个简单脚本就可以完成划分。
python -c " import os, random ids = [f.split('.')[0] for f in os.listdir('dataset/images')] random.seed(42) random.shuffle(ids) n = len(ids) with open('dataset/train.txt','w') as f: f.write('\n'.join(ids[:int(n*0.7)])) with open('dataset/val.txt','w') as f: f.write('\n'.join(ids[int(n*0.7):int(n*0.8)])) with open('dataset/test.txt','w') as f: f.write('\n'.join(ids[int(n*0.8):])) "这个命令会生成三个文本文件,但严格看这里30%划分可能太小。更推荐的做法是在进入训练循环前统计mask中前景像素占比,如果小于5%,就属于严重类别不平衡。应对方法有两种:一是用加权损失函数,二是用torch.utils.data.WeightedRandomSampler给前景样本更高的采样权重。皮肤病变分割通常前景占比在10%上下,Dice Loss本身就能缓解不平衡,不必每次都用WeightedRandomSampler,否则训练波动会变大。
4. 训练评估闭环:损失、指标和调参
4.1 损失函数选择:Dice Loss还是Focal Loss
语义分割损失函数里最常用的三类:交叉熵、Dice Loss、Focal Loss。交叉熵对每个像素独立计算,在类别不平衡时会被多数类主导;Dice Loss直接优化Dice系数,天然对前景占比不敏感;Focal Loss降低易分类像素的损失权重,让模型更关注难例。在皮肤病变分割上,我一般不会单独用Dice Loss,因为它和Softmax的数值范围有冲突,容易训练震荡。常见组合是Dice Loss加一个带gamma的Focal Loss,或者Dice和交叉熵各占一半权重。
下面是一段简单但实用的组合损失代码:
import torch import torch.nn as nn import torch.nn.functional as F class DiceFocalLoss(nn.Module): def __init__(self, focal_gamma=2.0, dice_weight=0.5, focal_weight=0.5): super().__init__() self.gamma = focal_gamma self.dice_weight = dice_weight self.focal_weight = focal_weight def forward(self, pred_logits, target): # pred_logits: [B, 1, H, W] 未经过sigmoid # target: [B, 1, H, W] 0/1 pred = torch.sigmoid(pred_logits) b, c, h, w = pred.shape target = target.view(b, 1, h, w) # Dice Loss smooth = 1.0 intersection = (pred * target).sum(dim=(2,3)) dice = (2.0 * intersection + smooth) / (pred.sum(dim=(2,3)) + target.sum(dim=(2,3)) + smooth) dice_loss = 1.0 - dice.mean() # Focal Loss (二分类) pt = pred * target + (1 - pred) * (1 - target) focal_weight_tensor = ((1 - pt) ** self.gamma).detach() bce = F.binary_cross_entropy_with_logits(pred_logits, target, reduction="none") focal_loss = (focal_weight_tensor * bce).mean() return self.dice_weight * dice_loss + self.focal_weight * focal_loss这段代码里,dice_weight和focal_weight控制两个损失的占比。我的经验是让两者各0.5,如果发现训练中期Dice值卡住,可以把focal_weight降一点,让Dice主导。注意pt和预测概率相关,但focal_weight_tensor这里用detach(),这样梯度只作用于BCE部分,实现更稳定。
4.2 训练脚本核心逻辑
项目工程包通常提供一个train.py,内部核心循环并不复杂。需要关注的是优化器选择和调度器。Transformer模型对学习率极其敏感,常见做法是用AdamW配合Warmup Cosine。常规的初始学习率可以设在1e-4到5e-5之间,warmup步数占训练总步数的5%左右。下面给出一段训练循环的核心代码:
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=0.01) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs) for epoch in range(epochs): model.train() train_loss = 0.0 for images, masks in train_loader: images, masks = images.cuda(), masks.cuda() pred = model(images) loss = criterion(pred, masks) optimizer.zero_grad() loss.backward() optimizer.step() train_loss += loss.item() scheduler.step() # 每个epoch后在验证集上计算IoU和Dice iou, dice = validate(model, val_loader) print(f"Epoch {epoch}: loss={train_loss/len(train_loader):.4f}" f" IoU={iou:.3f} Dice={dice:.3f}")代码中用了CosineAnnealingLR,能有效避免后期学习率过大导致的参数震荡。训练时一般先冻结预训练backbone,只训练解码器几个epoch,然后全量微调,效果比一上来就全量训练好。也可以使用混合精度训练,在PyTorch里就是torch.cuda.amp.autocast()和GradScaler(),能省接近一半显存,尤其适合Transformer这种大模型。
4.3 评估指标:IoU、Dice、Precision/Recall
毕业设计最常被问到的就是IoU和Dice。IoU也叫Jaccard系数,是交集比上并集;Dice是两倍交集比上两个面积之和。代码实现时要小心平滑因子。官方比赛里常用不加平滑的版本,但在训练损失中通常加平滑。下面是二分类指标计算的一个可复用函数:
def segmentation_metrics(pred_mask, true_mask, thresh=0.5, smooth=1e-6): # pred_mask: [B, H, W] 概率值;true_mask: [B, H, W] 0/1 pred = (pred_mask > thresh).float() true = true_mask.float() intersection = (pred * true).sum(dim=(1,2)) union = pred.sum(dim=(1,2)) + true.sum(dim=(1,2)) - intersection dice = (2.0 * intersection + smooth) / (pred.sum(dim=(1,2)) + true.sum(dim=(1,2)) + smooth) iou = intersection / union return iou.mean().item(), dice.mean().item()在验证集上,通常把验证集所有样本的预测mask按阈值0.5转成二值,然后累计计算全局IoU。注意不要在计算时把批次平均和全局平均混淆。有些同学直接在循环里打印每个batch的IoU再取平均,会受样本数量影响,导致和最终测试集指标不一致。最好维护一个全局的intersection和union累加器,在循环结束后一次性计算。
4.4 踩坑记录与常见错误
第一个坑是输入尺寸不一致。很多原始皮肤镜图像是1920x1080的高分辨率,直接resize到512x512虽然能跑,但细节丢失严重。我的经验是先用大尺寸(如768x768)训练,如果显存不够再用patch_size=32,或者用随机裁剪配合多尺度训练。第二个坑是预训练权重加载不匹配,Transformer的encoder通常加载ImageNet预训练权重,但patch_embedding的卷积核大小不同会导致shape不匹配,需要手动截断或随机初始化。第三个坑是类别不平衡导致验证Loss很低但视觉上漏检明显,这时应该观察Precision和Recall,而不只是看Dice。下面给出一个超参数参考表:
| 超参数 | 推荐值 | 说明 |
|---|---|---|
| 输入尺寸 | 512x512 | 显存与精度的折中 |
| patch_size | 16 | 细节要求高用16 |
| 编码器层数 | 8~12 | 受训练数据规模限制 |
| 学习率 | 1e-4 (预热后) | 过高导致不收敛 |
| 批大小 | 8(3090) | Transformer显存开销大 |
| 训练epoch | 80~120 | 配合验证集早停 |
如果训练时损失曲线没有下降,先检查数据加载有没有问题:mask是否与图像对应、是否归一化到0/1。再检查损失函数里的pred是否经过sigmoid。常见错误是把DiceFocalLoss里对pred做了一次sigmoid,后面BCE又用binary_cross_entropy_with_logits,导致梯度翻转,模型学不动。调用validate时也要记得torch.no_grad(),否则显存会被验证过程占满。
5. 把课题做成可答辩的微创新
5.1 替换编码器为Swin-Tiny
我遇到的很多工程包默认用ViT-Base,但对毕业设计来说,ViT-Base训练太慢。更实用的做法是换成Swin-Tiny,它会大幅降低显存压力,并提升小病灶的边界效果。替换编码器的主要工作量在patch embedding:Swin用窗口注意力,不需要绝对位置编码,所以要把模型里self.pos_embed相关的部分去掉。如果你用的原版ViT是预训练权重,可以直接保留其encoder层,只修改patch_embedding输入通道和预测头的分类维度。替换后通常不需要从零重新训练,用同一套数据集和损失函数就能收敛。
5.2 推理时用TTA和后处理拉高指标
测试时增强(TTA)是最划算的涨点手段。常见做法是推理时对原图、水平翻转、垂直翻转分别预测,把三个概率图取平均,再阈值0.5。下面是一段推理脚本:
def predict_with_tta(model, image, patch=512): model.eval() batch = torch.from_numpy(image.transpose(2,0,1)).float().cuda().unsqueeze(0) flip_lr = torch.flip(batch, dims=[3]) flip_ud = torch.flip(batch, dims=[2]) with torch.no_grad(): p0 = torch.sigmoid(model(batch)) p1 = torch.sigmoid(model(flip_lr)) p2 = torch.sigmoid(model(flip_ud)) p = (p0 + p1 + p2) / 3.0 return p[0, 0].cpu().numpy()推理之后再加一步简单的形态学后处理:用3x3或5x5的核做一次开运算,去掉孤立的假阳性小点;然后取最大连通域作为最终mask。对于皮肤病变,最大连通域假设是成立的,因为病灶通常是连续的一片区域。这一步在验证集上经常能拿到0.5-1个点的IoU提升。
5.3 模型预测结果的可视化与答辩展示
毕业设计答辩时,一张图胜过十句话。建议把原图、真实mask、预测mask、以及差值图横向拼接成一张对比图,便于评委看到模型改进的地方。输出可以用下面这段简单代码:
import matplotlib.pyplot as plt def show_prediction(image, true_mask, pred_mask, save_path="output.png"): plt.figure(figsize=(12, 4)) plt.subplot(1, 3, 1) plt.imshow(image) plt.title("Input") plt.subplot(1, 3, 2) plt.imshow(true_mask, cmap="gray") plt.title("GT") plt.subplot(1, 3, 3) plt.imshow(pred_mask, cmap="gray") plt.title("Prediction") plt.savefig(save_path, bbox_inches="tight") plt.close()可以用cv2.imwrite或matplotlib保存,注意在保存前把pred_mask的0-1浮点值乘255转成0-255灰度图,否则输出是全黑的。同时,建议把几个经典困难样本的预测结果单独整理成文件夹,作为答辩PPT的素材,证明模型对边界模糊区域的处理能力。这比单纯列出IoU数字更有说服力。最后别忘了把训练日志和指标曲线导出成CSV,方便做消融实验的对比图。
本文还有配套的精品资源,点击获取