简介:医学图像分割在医学影像分析中作用关键。基于Python与深度学习框架实现的这套分割系统源自个人毕业设计,答辩评审达98分;面向计算机、人工智能、自动化等专业学生与从业者,可作为课程设计、大作业或毕业设计的参考,代码经调试运行验证,可直接启动并二次扩展。压缩包共136个文件,含120张图像样本、6个Python源码文件、6个XML配置文件及说明文档等,图像用作训练数据,Python代码实现模型训练与预测流程,整体结构完整清晰,包体仅13.75MB,轻量易部署。已有316人学习浏览,项目具备较高参考价值,下载后可获得完整源码、配套数据集、使用文档与配置信息,既能帮助理解医学图像分割的实现流程,又可为相关课题设计提供可复用框架。
1. 医学图像分割系统到底是什么:吃透标题里的「超经典」三个字
作为一线做图像算法的人,我见过太多人拿到这种"基于Python+深度学习的超经典医学图像分割系统源码+数据集"项目后,第一件事就是双击train.py,结果不是缺依赖就是CUDA版本不对,再不然就是作者写死的绝对路径把你的训练卡死。所谓"超经典",并不等于界面花哨,而是指架构足够扎实——U-Net这类编码器-解码器结构,再配上公开的医学影像数据集,是医学分割领域被验证过最多次、最容易迁移到CT、MRI、病理切片等场景的组合。对正在做课程设计、毕业设计,或者在医院科研岗上需要快速搭一套分割管线的工程师来说,这个方向是最值得复现的起点。这篇笔记我会按理论、复现、调优、避坑的顺序讲,最后落到一个能直接用的推理脚本上。
2. 动手前先立理论:为什么选Python+深度学习做分割,以及三个绕不开的关键点
2.1 从像素分类到U-Net:医学图像分割的任务边界
图像分割不是把图里的目标"裁出来",而是对每一个像素做语义分类。输入一张512x512的单通道CT灰度图,输出同样尺寸的单通道图,但每个像素的值代表"背景0 / 器官1 / 病灶2"。这个任务和自然场景分割最不一样的地方在于条件苛刻:医学图像样本少、边界弱、类别极度不平衡。比如一张256x256的肺结节图,病灶可能只有几千个像素,剩下几万个像素全是背景。如果用普通交叉熵损失去训练,模型只要把全部像素预测成背景,loss看起来就很低,可在临床上毫无价值。
这也解释了为什么U-Net在医学影像领域里成了"超经典"。它由编码器和解码器组成:编码器通过卷积和池化逐步压缩特征图,解码器通过上采样把特征图恢复到原始分辨率,中间再通过跳连接把编码器每一层的高分辨率特征拼到解码器对应层。这样网络既能知道"哪里有器官",也能看到"器官边界长什么样",对小目标和弱边界的处理能力特别强。后来出现的Attention U-Net、ResU-Net等变体,主线始终没有跳出这个思路。
拿到"源码+数据集"时,先别急着跑,把源码里的model.py打开,确认网络是不是U-Net,以及in_channels和num_classes是否匹配你的数据。如果源码把输入写死成3通道,而你的CT是单通道,要么改成in_channels=1,要么把单通道复制成三通道。前者省显存,后者不用改结构,但会白白多算两倍数据。我一般直接改模型参数,因为后面换数据集更方便。
2.2 数据集选型:标题里「数据集」三字的分量
医学分割项目真正的价值很大一部分在数据上。公开数据集常见的有ISIC皮肤镜分割、DRIVE视网膜血管、LIDC肺结节、Synapse多器官CT。它们的命名和标注格式差异很大:ISIC的mask是单通道二值图;Synapse的标签是多个器官的索引图,比如1代表脾脏、2代表右肾。如果源码配套的是这类已有数据集,建议直接用官方的train/val划分,别自己重新切分;如果不是,需要额外写一个格式兼容层。
我拿到数据的第一件事是统计文件列表,确认图像和mask一一对应。一个能用的检查脚本长这样:
import os from collections import Counter img_dir = "data/images" mask_dir = "data/masks" img_names = set(os.listdir(img_dir)) mask_names = set(os.listdir(mask_dir)) print("图像数:", len(img_names), "掩码数:", len(mask_names)) # 看掩码后缀和图像后缀是否一致 suffix_counts = Counter([os.path.splitext(n)[-1] for n in mask_names]) print("掩码后缀分布:", suffix_counts) # 找出缺少对应掩码的图像 missing = [n for n in img_names if n.replace(".png", "_mask.png") not in mask_names] print("缺失掩码示例:", missing[:5])跑完你一般会遇到三类情况:掩码命名不是xxx_mask而是mask_xxx;mask后缀和图像后缀不同,一张.jpg对应一张.png;mask是RGB三通道但依然叫.png。如果缺失列表为空,说明命名规则匹配。接下来随手挑几张图,用matplotlib把图像和mask叠画,确认空间位置是否对齐。这个步骤看着土,但能挡住后面一半莫名其妙的训练报错。
我之前也接触过把这类U-Net源码改造成"基于深度学习的口腔疾病图像识别系统"的需求,本质上就是换数据集和改输出类别数,核心管线完全一样。可以说,只要你把数据集这一关打通,剩下的就是换参数和调loss。
2.3 环境搭建:Python版本、CUDA、框架的搭配误区
现在的医学分割源码十有八九用PyTorch。但装环境这件事别照着旧教程乱来。很多"高分项目"是两三年前写的,作者当时用Python 3.7和PyTorch 1.7,你现在装Python 3.11和PyTorch 2.2,源码里许多接口可能已经变了。我的经验是用conda隔离环境,不污染系统Python。
conda create -n medseg python=3.10 -y conda activate medseg pip install torch==2.0.1 torchvision==0.15.2 --index-url https://download.pytorch.org/whl/cu118 pip install numpy opencv-python scikit-image tqdm tensorboard albumentations这里要注意四点:第一,cu118后面的数字要看你显卡驱动支持的CUDA版本,驱动是12.x就用cu121,不确定就先用CPU版本把流程跑通再换GPU。第二,不要直接pip install -r requirements.txt全盘接受,那些版本号是作者发布时的环境快照,和现在的新系统大概率有兼容冲突。第三,装完用一行命令验证:python -c "import torch; print(torch.cuda.is_available())",输出False就说明后面所有训练都在CPU上龟速爬,慢到怀疑人生。第四,看requirements.txt里有没有pydicom、SimpleITK,如果数据是DICOM格式,训练脚本会自动读,但这几个库不装齐,会报出极其奇怪的AttributeError。环境问题占了项目跑通失败原因的30%,值得认真对待。
3. 把源码跑起来:从下载到训练的最小复现路径
3.1 数据集目录结构:训练前必须改的路径参数
源码里的config.py或main.py通常写死了一个data_root路径。高分项目作者喜欢把数据放在./data/下,但不排除他把自己的绝对路径直接写在代码里,第一个要改的就是这里。另外,train/val划分可能不是按文件夹,而是靠.txt文件记录文件名。
一个保守的目录结构长这样:
project/ ├── train.py ├── model.py ├── dataset.py ├── config.py └── data/ ├── images/ │ ├── train/ │ └── val/ └── masks/ ├── train/ └── val/如果你的数据和这个结构不一样,比如全部在images和labels下且没有train/val子目录,那就自己按比例切分,或者把dataset.py里的glob逻辑改一下。我先会写一个快速脚本,统计每个子目录里的文件数,防止复制数据时漏拷:
import os def count_files(path): total = 0 for root, dirs, files in os.walk(path): total += len(files) print(f"{root}: {len(files)}") return total print("images total:", count_files("data/images")) print("masks total:", count_files("data/masks"))这个检查的意义在于:很多源码用glob.glob(os.path.join(data_root, "images", "*"))取路径,然后假设mask路径就是把images替换成masks。如果某个子目录多拷了几个文件,文件名排序会发生错位,训练时图像和mask张冠李戴。再看一个常见的dataset.py实现:
import glob import cv2 import numpy as np import torch class MedicalDataset(torch.utils.data.Dataset): def __init__(self, img_dir, mask_dir, transform=None): self.img_paths = sorted(glob.glob(os.path.join(img_dir, "*.png"))) self.mask_paths = sorted(glob.glob(os.path.join(mask_dir, "*.png"))) self.transform = transform def __len__(self): return len(self.img_paths) def __getitem__(self, idx): img = cv2.imread(self.img_paths[idx], cv2.IMREAD_GRAYSCALE) mask = cv2.imread(self.mask_paths[idx], cv2.IMREAD_GRAYSCALE) mask = np.where(mask > 127, 1, 0).astype(np.uint8) if self.transform: augmented = self.transform(image=img, mask=mask) img = augmented["image"] mask = augmented["mask"] return img, mask这里的np.where(mask > 127, 1, 0)特别关键。很多标注软件导出的mask值域是0和255,如果不归一化,模型会把255当成第二类。但有些源码里标签本来就是0和1,这行要删掉,否则会做二次归一化,训练时指标反而下降。所以拿到数据后先打开几幅mask,用np.unique看实际像素值,再决定加不加这行。
3.2 训练脚本逐段拆解:loss、优化器、评估指标
训练脚本是整个源码的核心,不建议直接全量复制运行,最好一行行过。以U-Net二分类为例,训练主线通常是这样的:
model = UNet(in_channels=1, num_classes=2).to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-4) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode="max")num_classes是包含背景的总类数。二分类背景加前景就是2;如果源码是三分类,但你只提供0/1标签,loss计算时就会出现IndexError。解决方法是保证标签从0开始连续编码,并把网络最后一层输出改成实际类别数。
训练循环里有个高频细节:模型输出是(B, C, H, W)的logits,交叉熵期望的目标维度是(B, H, W)。如果dataset.py返回的mask带上了通道维度(B, 1, H, W),在算loss前要squeeze(1)。我的习惯是在__getitem__里直接返回(H, W)的整数数组,省去后面各种维度折腾。
评估指标这里也有隐性要求。如果compute_dice写在utils.py里,先看它对背景类怎么处理。很多老项目直接把所有类都算进去,导致Dice虚高。正确做法是只对前景类算:
def compute_dice(pred, mask, num_classes=2): dice = 0.0 for cls in range(1, num_classes): p = (pred == cls).float() t = (mask == cls).float() inter = (p * t).sum() union = p.sum() + t.sum() dice += (2 * inter + 1) / (union + 1) return dice / (num_classes - 1)这里的+1是smooth,避免除零。如果验证集某个类完全没出现,算出来会整体偏低。这种情况建议用macro平均而不是简单累加。
3.3 训练中实时看分割效果:用TensorBoard还是自写回调
训练日志里每轮打印的loss只是标量,不能反映分割质量。我强烈建议在验证阶段把输入图、真值mask、预测mask拼成一幅图保存,用TensorBoard盯变化。
from torch.utils.tensorboard import SummaryWriter import torchvision writer = SummaryWriter("runs/exp1") def visualize_batch(writer, images, masks, preds, global_step, tag="val"): # 取batch里的第一张 img = images[0].float() if img.shape[0] == 1: img = img.repeat(3, 1, 1) img = (img - img.min()) / (img.max() - img.min() + 1e-8) gt = masks[0].float().unsqueeze(0) pd = preds[0].float().unsqueeze(0) grid = torchvision.utils.make_grid([img, gt, pd], nrow=3, padding=10) writer.add_image(f"{tag}/sample", grid, global_step)训练时在每轮验证后调用visualize_batch,浏览器里就能看到分割轮廓随epoch的变化。如果你嫌TensorBoard麻烦,在验证后直接用PIL拼接保存图片也行,但目标一致:宁可多花10分钟做可视化,也别让训练在夜里空跑100轮,第二天起来发现梯度全炸了。
4. 调优与验证:让分割精度从「能跑」到「高分」的四个必调参数
4.1 损失函数:Dice Loss与交叉熵的取舍
很多源码默认用nn.CrossEntropyLoss,但医学分割领域几乎都会叠加Dice Loss。交叉熵对每个像素一视同仁,背景占比高时梯度被背景主导;Dice Loss直接优化区域重叠,即使前景很小,也不会被背景淹没。
一个常用的混合损失实现如下:
def mixed_loss(pred, target): ce = nn.CrossEntropyLoss()(pred, target) pred_soft = torch.softmax(pred, dim=1) dice = dice_loss(pred_soft, target) return ce + 0.5 * diceDice项的权重0.5怎么调?我给个经验原则:前景越小,Dice权重越大;但太大又会让训练在头几个epoch震荡。常见范围是0.1到1.0。分割小目标如血管、肺结节时,可以把Dice权重推到1.0;分割大器官如肝脏、肾脏时,0.2到0.3就够了。这个参数没有银弹,我通常先在验证集上跑三五个epoch做小对比,看Dice上升速度,再用最终权重跑完整训练。
| 分割场景 | Dice Loss权重 | 原因 |
|---|---|---|
| 大器官(肝、肾) | 0.1 ~ 0.3 | 交叉熵提供稳定的逐像素梯度 |
| 小病灶(结节、血管) | 0.5 ~ 1.0 | 避免前景被背景淹没 |
| 极不平衡数据 | 1.0 起调 | 配合连通域过滤一起用 |
4.2 数据增强:旋转、弹性形变、亮度扰动怎么设
数据增强是后期提分性价比最高的手段。医学图像增强要考虑解剖学合理性:左右翻转对很多器官是合理的,但上下翻转会把肝脏翻到胸腔位置,在某些任务里要禁用。弹性形变特别重要,因为它能模拟器官在不同个体间的形变。参考配置:
import albumentations as A train_aug = A.Compose([ A.Resize(256, 256), A.HorizontalFlip(p=0.5), A.RandomBrightnessContrast(p=0.3, brightness_limit=0.1, contrast_limit=0.1), A.ElasticTransform(alpha=1, sigma=50, alpha_affine=50, p=0.3), A.ShiftScaleRotate(shift_limit=0.05, scale_limit=0.1, rotate_limit=10), ], is_check_shapes=False)最值得调的是ElasticTransform的alpha和sigma。alpha控制形变强度,sigma控制形变平滑度。我惯用alpha=1, sigma=50,对小目标友好;如果sigma太小,图像会出现明显网格状扭曲,模型学到的不是真实形变而是伪影。RandomBrightnessContrast的brightness_limit不要超过0.2,因为医学图像亮度突变往往意味着不同设备,过强增强会让模型把灰度变化当成特征,推理时反而掉点。
增强必须同步作用于图像和mask。用albumentations时传入image和mask两个参数,它内部会用同一个随机种子;如果自己写cv2.warpAffine,不小心为图像和mask各生成一次随机变换,就会造成标签错位,这是最坑的那种玄学Bug。
4.3 后处理:连通域过滤与形态学闭运算
模型输出概率图后直接取argmax,通常会有零散噪点和小孔洞。两个动作能微调精度:连通域过滤,去掉孤立的噪声点;形态学闭运算,填补目标内部孔洞。
def clean_mask(mask, min_size=64): # 找到所有连通域 num, labels, stats, _ = cv2.connectedComponentsWithStats(mask, 8) # 保留面积大于min_size的分量 clean = np.zeros_like(mask) for i in range(1, num): if stats[i, cv2.CC_STAT_AREA] >= min_size: clean[labels == i] = 1 # 闭运算:先膨胀再腐蚀,填小洞 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) clean = cv2.morphologyEx(clean, cv2.MORPH_CLOSE, kernel, iterations=2) return cleanmin_size不是随便定的。如果知道目标在512x512图上大约占直径30像素的圆,那面积下限可以设为3.14 * 15 * 15 ≈ 707。设太大会把小病灶直接删掉,设太小又没效果。在验证集上抽几个case跑一遍,直观对比后处理前后的mask,比自动调参更靠谱。
4.4 验证指标:Dice、IoU、HD95到底看哪个
源码往往只输出Dice,但高分项目的评价体系应该更全面。Dice对大面积目标偏乐观:如果目标面积1000像素,预测重叠800像素,Dice也能有88%。HD95是边界距离的95%分位数,对大目标更苛刻,也是论文审稿人喜欢看的指标。
我通常在每个epoch的验证集上同时算Dice、IoU、HD95。HD95可以直接用medpy.metric.binary.hd95,输入二值数组、前景为1;不想引依赖的话,用scipy.ndimage.distance_transform_edt手动算也可以。不管用什么,都要注意背景不包括在指标内。
另外在这里要提醒一句,我遇到过"Dice很高但医生一眼看出分割边界完全不对"的情况。数值会骗人,所以报告里必须附上可视化的预测图。对医学分割系统来说,可信度比一个虚高的分数重要得多。
5. 避坑实录:医学图像分割最常见的5个翻车现场
5.1 现象:训练loss降到0.05,预测却是全黑图
训练日志里loss降得很漂亮,但测试图打开全是黑色,没有前景像素。
原因:类别极度不平衡,背景占比接近99%,交叉熵在背景像素上的梯度压倒了前景。模型全预测背景,loss照样很低。
解决:在损失函数里加Dice项,并在训练中监控验证集预测值的np.unique(pred)。如果连续多个epoch预测只有一个类别,立刻停。坚持用交叉熵的话,就给每个类别设权重,把前景权重调到背景的5到10倍。改完损失后,第一个epoch的loss可能反而不降,这是正常现象,要看验证Dice是否起来。
5.2 现象:GPU显存溢出,batch_size设2还是崩
batch_size已经调到2了,照样OOM,报错通常出现在U-Net的跳连接拼接处。
原因:问题不在batch,而在输入尺寸太大和优化器状态占用。U-Net在512x512输入、batch=2时大约需要6GB显存,4GB显卡根本扛不住。
解决:把图片缩到256x256训练,很多源码默认也是256。必须用512分辨率时,开torch.utils.checkpoint.checkpoint重计算中间激活,或用torch.cuda.amp.autocast()混合精度训练,显存占用能减一半。还有一点,验证阶段一定要包with torch.no_grad(),漏掉的话验证同样保存梯度,显存峰值可能比训练还高。
5.3 现象:换了一个设备采集的图,效果断崖式下跌
训练集是设备A拍的,测试集换到设备B,Dice直接从0.9掉到0.5。
原因:这是典型的域偏移。CT值分布取决于扫描参数,MRI的灰度更是没有统一度量。模型把设备A的灰度特征当成了判别标准,换个设备马上失效。
解决:对CT数据做窗宽窗位标准化,比如把像素值截断到[-200, 400]再归一化;对病理切片做色彩归一化;在训练集里混入多设备样本,哪怕只有几十张,泛化性也会明显提升。
5.4 现象:标签只有几十个像素,Dice却很高
血管分割项目里Dice算到0.95,但医生看结果全是噪点。
原因:前景区域太小,一个随机噪点碰巧和真值小目标重叠,Dice也会高。大目标上缓慢的边界偏差,在小目标上会变成毁灭性的错误。
解决:加后处理过滤掉小连通域,并把HD95加进评估。如果HD95在几十甚至上百像素量级,说明边界差得远,Dice高只是巧合。以后遇到小目标分割,别只盯着Dice,务必看预测可视化。
5.5 现象:源码里的权重文件加载报错,提示size mismatch
加载.pth时看到size mismatch for conv1.weight这类报错。
原因:你改了in_channels或num_classes,或者源码里的模型类和当前model.py不是同一个版本。权重里最后一层的shape和模型对不上,整个加载就失败。
解决:写一个只加载公共层权重的函数,跳过最后一层:
def load_partial(model, state_dict): new_state = model.state_dict() for k, v in state_dict.items(): if k in new_state and new_state[k].shape == v.shape: new_state[k] = v model.load_state_dict(new_state)这样最后一层随机初始化,前面层的预训练参数全部保留,比自己从头训快很多。另外,老权重加载时加上map_location="cpu",可以避免GPU驱动版本不匹配的报错。
6. 从复现到自己的分割项目:一个可复用的推理脚本与经验收尾
6.1 用训练好的权重写一个最小推理脚本
训练脚本没法直接用于单张预测,因为耦合了DataLoader和验证逻辑。我会单独写一个infer.py,只做预处理、加载权重、输出掩码:
import torch import numpy as np from PIL import Image from model import UNet device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = UNet(in_channels=1, num_classes=2) model.load_state_dict(torch.load("best_model.pth", map_location="cpu")) model.to(device).eval() def infer(image_path): img = Image.open(image_path).convert("L").resize((256, 256)) arr = np.array(img, dtype=np.float32) / 255.0 tensor = torch.from_numpy(arr).unsqueeze(0).unsqueeze(0).to(device) with torch.no_grad(): logits = model(tensor) pred = torch.argmax(logits, dim=1).squeeze(0).cpu().numpy() return (pred * 255).astype(np.uint8) Image.fromarray(infer("sample.png")).save("sample_mask.png")这里最大的坑是预处理要复刻训练时的归一化。如果用了我前文提到的窗宽窗位标准化,这一段也要对应改成先clip再除以区间长度。
6.2 把灰度图和标签的预处理统一封装
训练和推理的预处理分开写,基本等于给自己埋雷。我现在习惯把预处理放进preprocess.py,两边都from preprocess import apply_transform,至少不会出现"训练时归一化,推理时忘了归一化"的翻车。代码量不大,但能堵住一个最隐蔽的bug来源。
6.3 我的几个习惯:随机种子、保存最佳模型、日志里留参数
最后分享三个保命习惯。第一,训练开头设torch.manual_seed(42)和np.random.seed(42),否则同样的代码跑两次结果不同,后面调参完全无法比较。第二,用验证Dice持续刷新为best_model.pth,不要等最后一轮再存。第三,在每个epoch的日志里记录lr、loss、dice和当前时间,回头分析曲线时能看出是哪一段数据出了问题。
医学图像分割项目说到底不是比代码多花哨,而是比数据对齐、指标诚实、实验可复现。以上是我从这类经典源码里总结出的实用经验,希望帮到你,也欢迎你带着新的数据集去实践一次,把每个参数亲手调一遍。
本文还有配套的精品资源,点击获取