简介:本资源面向计算机视觉方向的本科与硕士毕业生,提供一套基于Transformer的皮肤病变图像语义分割完整毕业设计实现方案,帮助解决医学图像中细微差异与复杂纹理难以精确分割的问题。压缩包共约2000个文件,整体59.39MB,以5447张jpg皮肤病变图像及像素级标注为主体,辅以20个py训练与推理脚本、2个pth预训练权重、yml配置文件、m与txt说明文档,并附有评估指标与结果可视化代码,覆盖数据预处理、模型搭建、训练调优到性能评估的完整链路。项目在编码器-解码器结构中融合CNN与分层注意力机制,可捕捉更丰富的上下文信息,提升IoU、Precision、Recall等指标表现。目前已有1055人学习下载,适合希望深入掌握Transformer在医学图像分割中应用、需要完整代码与数据集快速开展课题的读者参考。
1. 从一张皮肤镜照片说起:Transformer 语义分割到底在解决什么问题
皮肤镜拍下一张痣的照片,医生要判断的是「这块色素区域边界在哪、是不是恶性」。传统做法靠肉眼勾边,同一张图两个医生能画出两条不一样的线。语义分割要干的事,就是让模型对每个像素打标签,把病灶区域从背景皮肤里抠出来。而基于 Transformer 的语义分割,是这两年被反复提起的路线——它不再只靠卷积核一层层堆局部感受野,而是用自注意力直接建模任意两个像素之间的关系,边界贴合度往往比纯 CNN 更稳。
这个标题对应的是一套完整的毕业设计级方案:用 Transformer 系语义分割网络,在皮肤病变数据集上训练、评估、出图,附带可运行代码和数据集组织方式。适合两类人:一是要交毕设、需要跑通全流程的学生;二是想从 U-Net 切到 Transformer 分割、但不知道从哪下手的一线开发者。下面按「数据怎么摆 → 模型怎么搭 → 训练怎么调 → 坑在哪 → 怎么验证」推一遍。
2. 皮肤病变数据集怎么组织:从 ISIC 原始图到可训练掩码
2.1 为什么皮肤病变分割对数据格式特别敏感
皮肤病变分割的公开数据里,ISIC 系列是最常被拿来用的。原始压缩包里通常是三样东西:ISIC_xxxxxxx.jpg原图、ISIC_xxxxxxx_segmentation.png二值掩码、一个metadata.csv记录病灶类型。掩码里病灶是白色(像素值 255),背景是黑色(0)。很多人第一次跑翻车,不是模型问题,是掩码读进来之后没做二值化——PNG 带抗锯齿边缘,像素值可能是 254、128 这种灰阶,直接当类别标签用,loss 会算得莫名其妙。
常见做法是统一转成 0/1 的uint8,并且把原图和掩码按同一随机种子做同步增强。这里给一个最小可用的数据组织脚本,把散落的原图和掩码整理成images/和masks/两个平行目录:
import os import shutil import numpy as np from PIL import Image RAW_DIR = "./raw_isic" # 解压后的原始目录 OUT_IMG = "./dataset/images" OUT_MSK = "./dataset/masks" os.makedirs(OUT_IMG, exist_ok=True) os.makedirs(OUT_MSK, exist_ok=True) for name in os.listdir(RAW_DIR): if name.endswith(".jpg"): stem = name[:-4] mask_name = stem + "_segmentation.png" mask_path = os.path.join(RAW_DIR, mask_name) if not os.path.exists(mask_path): continue # 没有对应掩码的图直接跳过 # 掩码二值化:>127 视为病灶 m = np.array(Image.open(mask_path).convert("L")) m = (m > 127).astype(np.uint8) * 255 Image.fromarray(m).save(os.path.join(OUT_MSK, stem + ".png")) shutil.copy(os.path.join(RAW_DIR, name), os.path.join(OUT_IMG, stem + ".jpg")) print("done")逻辑说明:遍历原图,找同名掩码,读成灰度图后按 127 阈值二值化,再乘 255 存回 PNG,保证掩码只有 0 和 255 两个值。参数上,阈值 127 是经验值,如果掩码边缘毛刺多,可以调到 100 让病灶区域略大一点,宁可多包一点也别漏掉边界。原图和掩码用同一个stem命名,是为了后面 Dataset 里能靠文件名直接配对,省掉维护一张映射表的麻烦。
2.2 划分训练验证集时最容易忽略的类别泄漏
皮肤病变数据有个隐蔽问题:同一个人可能有多张不同角度的照片,如果随机按图划分,同一个病灶的图可能同时进训练集和验证集,验证指标会虚高。稳妥做法是按病灶 ID 或患者 ID 分组划分,而不是按图片随机划分。metadata 里一般有lesion_id字段,用它做 group split。
import pandas as pd from sklearn.model_selection import GroupShuffleSplit meta = pd.read_csv("./raw_isic/metadata.csv") # 只保留有掩码的样本 meta = meta[meta["image"].apply( lambda x: os.path.exists(f"./dataset/masks/{x[:-4]}.png"))] gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_idx, val_idx = next(gss.split(meta, groups=meta["lesion_id"])) meta.iloc[train_idx][["image"]].to_csv("./dataset/train.csv", index=False) meta.iloc[val_idx][["image"]].to_csv("./dataset/val.csv", index=False)参数说明:test_size=0.2是验证集比例,毕设场景数据量通常几千张,0.2 够评估稳定性;random_state固定住,保证每次划分一致,方便复现。groups传lesion_id,同一个病灶的所有图只会落在一边。这一步不做,后面 Dice 冲到 0.95 也别高兴,大概率是泄漏。
3. Transformer 分割网络怎么搭:以 SegFormer 思路写一个最小实现
3.1 为什么选分层 Transformer 而不是原版 ViT
原版 ViT 把图切成 16×16 的 patch,全程保持同一分辨率,做分割时上采样回来边界很糊。语义分割要的是多尺度特征,所以主流做法是分层结构:像 Swin、SegFormer 那样,stage 越深 patch 越大、通道越多,最后把四个 stage 的特征统一上采样融合。皮肤病变目标尺度变化大,小痣和大面积病灶都有,分层结构比单尺度更扛得住。
下面写一个精简版的分层 Transformer 编码器,用 PyTorch 实现,重点是讲清 patch embedding 和自注意力的形状变化,不追求堆到 SOTA。
import torch import torch.nn as nn class PatchEmbed(nn.Module): """把 HxW 图切成 patch,映射到 embed_dim""" def __init__(self, in_ch=3, embed_dim=64, patch=4): super().__init__() self.proj = nn.Conv2d(in_ch, embed_dim, kernel_size=patch, stride=patch) self.norm = nn.LayerNorm(embed_dim) def forward(self, x): x = self.proj(x) # B, C, H/p, W/p B, C, H, W = x.shape x = x.flatten(2).transpose(1, 2) # B, N, C return self.norm(x), (H, W) class SelfAttention(nn.Module): def __init__(self, dim, heads=4): super().__init__() self.heads = heads self.scale = (dim // heads) ** -0.5 self.qkv = nn.Linear(dim, dim * 3) self.proj = nn.Linear(dim, dim) def forward(self, x): B, N, C = x.shape qkv = self.qkv(x).reshape(B, N, 3, self.heads, C // self.heads) q, k, v = qkv.permute(2, 0, 3, 1, 4) # 各 B, heads, N, C/heads attn = (q @ k.transpose(-2, -1)) * self.scale attn = attn.softmax(dim=-1) out = (attn @ v).transpose(1, 2).reshape(B, N, C) return self.proj(out)逻辑说明:PatchEmbed用 stride 等于 kernel 的卷积完成切块和线性映射,比手动 unfold 快;输出 reshape 成B, N, C才能喂给注意力。SelfAttention里scale是 1/sqrt(head_dim),防止点积过大导致 softmax 梯度消失,这是 Transformer 的标配。heads=4是毕设显存下的稳妥值,显存够可以上 8。注意qkv一次线性出三份再切分,比写三个 Linear 省显存。
3.2 解码器怎么把 Transformer 特征还原成像素级掩码
编码器给的是序列特征,解码器要把它变回 H×W 的分割图。最省事的做法是:把序列 reshape 回二维,过几层卷积上采样,最后 1×1 卷积输出单通道 logits。如果做多 stage 融合,就把每个 stage 的特征都上采样到原图 1/4 大小再拼接。
class SegHead(nn.Module): def __init__(self, dim=64, num_classes=1): super().__init__() self.up = nn.Sequential( nn.ConvTranspose2d(dim, dim // 2, 2, stride=2), nn.BatchNorm2d(dim // 2), nn.ReLU(inplace=True), nn.ConvTranspose2d(dim // 2, dim // 4, 2, stride=2), nn.BatchNorm2d(dim // 4), nn.ReLU(inplace=True), ) self.cls = nn.Conv2d(dim // 4, num_classes, 1) def forward(self, x, hw): B, N, C = x.shape H, W = hw x = x.transpose(1, 2).reshape(B, C, H, W) x = self.up(x) # 上采样 4 倍回到原图分辨率 return self.cls(x) # B, 1, H*4, W*4参数说明:两次ConvTranspose2dstride=2,正好把 patch=4 带来的 1/4 分辨率还原回原图。num_classes=1是二分类分割,输出 logits 配BCEWithLogitsLoss;如果做多类病灶,改成类别数并换CrossEntropyLoss。上采样用转置卷积而不是直接插值,是为了让网络自己学怎么补边界细节,代价是可能产生棋盘格伪影,介意的话换成nn.Upsample加普通卷积。
4. 训练参数怎么设:皮肤病变分割的 loss、学习率与显存权衡
4.1 Dice + BCE 组合 loss 为什么比单 BCE 稳
皮肤病变区域通常只占整图一小块,背景像素远多于病灶,纯 BCE 会让模型倾向于全预测背景也能拿到很低的 loss,结果就是掩码全黑。Dice loss 直接优化预测和真值的重叠度,对类别不平衡不敏感。常见做法是两者加权相加,BCE 管像素级稳定,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) probs = probs.flatten(1) targets = targets.flatten(1) inter = (probs * targets).sum(dim=1) dice = 1 - (2 * inter + 1e-6) / (probs.sum(1) + targets.sum(1) + 1e-6) return self.bce_weight * bce + (1 - self.bce_weight) * dice.mean()参数说明:bce_weight=0.5是起点,如果训练早期掩码大面积塌成全黑,把它降到 0.3 让 Dice 主导;如果边界抖动厉害,升到 0.7 让 BCE 稳住像素。1e-6是防止分母为零的平滑项,别省。flatten(1)把空间维压平,按样本算 Dice 再平均,比整批一起算更公平。
4.2 学习率、batch size 与显存的实际取值
Transformer 分割网络比同规模 CNN 更吃显存,因为自注意力是 N² 复杂度。毕设常见的单卡 8G 或 12G 显存下,输入 256×256、patch=4 时 N=4096,注意力矩阵 4096×4096 已经不小。稳妥配置是 batch size 4 到 8,学习率用 AdamW 从 6e-5 起,配 cosine 退火。
| 参数 | 推荐值 | 调整方向 |
|---|---|---|
| 输入尺寸 | 256×256 | 显存不够降到 224,别低于 192 |
| batch size | 4~8 | 显存爆了先降它,别先降分辨率 |
| 初始学习率 | 6e-5 | 不收敛降到 3e-5,震荡升到 1e-4 |
| weight decay | 0.01 | Transformer 标配,别设 0 |
| 训练轮数 | 80~120 | 看验证 Dice 平台期,别死磕 |
| 预热轮数 | 5 | 前几轮线性升温,防早期发散 |
学习率用 warmup 很关键,Transformer 对初始学习率敏感,前 5 轮从 0 线性升到 6e-5,能明显减少早期 loss 爆炸。优化器选 AdamW 而不是 Adam,是因为权重衰减解耦后对 Transformer 更友好,这是踩过坑才知道的。
5. 训练与推理常见问题排查:皮肤病变分割的 5 个血泪坑
5.1 掩码全黑或全白
现象:训练几个 epoch 后,验证集预测出来的掩码要么整张黑,要么整张白,Dice 卡在 0.1 以下不动。原因:类别极度不平衡加上 loss 权重没调好,模型找到了「全预测背景」这个局部最优。解决:把 Dice loss 权重提上来,bce_weight降到 0.3;同时检查掩码二值化是不是反了——有的数据集病灶是黑底白字,有的是白底黑字,读进来先np.unique看一眼像素值分布。
5.2 验证 Dice 高得离谱
现象:验证集 Dice 0.97,但拿新图一测惨不忍睹。原因:按图随机划分导致同一病灶泄漏,或者验证集做了和训练集一样的强增强(比如随机翻转后掩码没同步)。解决:按lesion_id分组划分;验证集只做 resize 和归一化,不做随机翻转旋转;增强时原图和掩码必须用同一个随机参数。
5.3 显存溢出但 batch 已经很小
现象:batch 降到 2 还是 OOM。原因:注意力矩阵随分辨率平方增长,输入 512×512 时 N=16384,注意力矩阵直接爆。解决:先把输入降到 256;或者改用带窗口注意力的结构,把全局注意力限制在局部窗口内;再不行就上梯度累积,用 batch 1 累积 8 步模拟大 batch。
5.4 边界毛刺、分割区域不贴合
现象:病灶主体分对了,但边缘一圈锯齿,Dice 上不去。原因:上采样用最近邻插值,或者解码器太浅没学到边界细节。解决:上采样换成转置卷积或双线性插值加卷积;在 loss 里加边界加权,对掩码边缘像素给更高权重;训练后期把学习率降下来精修边界。
5.5 推理速度慢到没法演示
现象:单张图推理要好几秒,答辩演示卡顿。原因:没切 eval 模式、没关梯度,或者模型还在 GPU 上做无用计算。解决:推理时model.eval()加torch.no_grad();把模型转成半精度model.half();输入尺寸按需降,演示用 256 足够。这几步下来单张通常能压到百毫秒级。
6. 怎么验证这套方案真的能用:Dice、IoU 与可视化对照
训练跑完不能只看一个 loss 数字,得用分割指标加可视化双重验证。Dice 和 IoU 是最常用的两个,Dice 对重叠更敏感,IoU 更严格。下面这段评估代码按图算指标再平均,避免整批混算带来的偏差。
@torch.no_grad() def evaluate(model, loader, device): model.eval() dice_list, iou_list = [], [] for imgs, masks in loader: imgs = imgs.to(device) logits = model(imgs) pred = (torch.sigmoid(logits) > 0.5).float() for p, m in zip(pred, masks): p, m = p.flatten(), m.flatten().to(device) inter = (p * m).sum() dice = (2 * inter + 1e-6) / (p.sum() + m.sum() + 1e-6) iou = (inter + 1e-6) / (p.sum() + m.sum() - inter + 1e-6) dice_list.append(dice.item()) iou_list.append(iou.item()) return sum(dice_list) / len(dice_list), sum(iou_list) / len(iou_list)参数说明:阈值 0.5 是二分类默认,如果发现漏检多就降到 0.4,误检多就升到 0.6,这个阈值要在验证集上试出来,别拍脑袋。1e-6同样防除零。按图循环而不是整批算,是因为每张图病灶大小差异大,整批混算会被大病灶主导。
可视化对照更直观:把原图、真值掩码、预测掩码三张并排存成一张图,肉眼扫一遍就能发现模型是不是在「猜」。我一般会挑 Dice 最低的 10 张单独看,翻车往往集中在毛发遮挡、低对比度、病灶贴边这几种情况。如果这几类占比高,说明数据增强里该加毛发模拟和对比度扰动,而不是继续调学习率。
最后说个习惯:每次改完数据划分或 loss,先把随机种子固定,跑一遍小规模过拟合测试——拿 20 张图训练,看能不能把训练 Dice 干到 0.99。过拟合都上不去,说明网络或数据管道有 bug,别急着上全量数据烧卡。这套流程走下来,毕设的完整代码和数据集组织基本就立住了,剩下的就是耐心调。希望帮到你。
本文还有配套的精品资源,点击获取