ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

肝癌影像AI诊断全流程:从NIfTI预处理到U-Net训练与Dice评估

2026/9/23 20:55:01 拓冰建站 浏览量
肝癌影像AI诊断全流程:从NIfTI预处理到U-Net训练与Dice评估 简介面向医疗AI与深度学习初学者的肝癌影像AI诊断代码包定位于用Python实现肝癌影像的智能分析流程适用于基于CT/MRI影像数据的模型训练与实验场景。包内共7个文件以4个Python脚本为核心覆盖数据预处理、数据集构建、模型定义与训练调用等关键环节另有标签文件与说明文档辅助理解。环境依赖已在描述中给出基于TensorFlow 1.8与Python 3.6并注明GPU/CPU安装差异便于读者复现。压缩包仅8KB属于轻量级示例代码适合快速阅读源码结构、学习医学影像分类任务的工程实现。目前已有36人学习可作为医疗影像方向课题开发的入门参考帮助用户理解从数据加载到模型训练的整体代码组织方式其模块划分也可作为扩展诊断模型的基础骨架。1. 大数据医疗不是口号这个肝癌影像AI诊断包把全流程都塞进了 zip大数据医疗从来不是概念落到项目里就是一条医学影像数据流水线加一个能跑通的模型。这个肝癌影像AI诊断项目恰好把从 nifti 原始影像到训练样本、从 U-Net 训练到 Dice 评估的完整链路装进了一个 zip里面七个文件各司其职preprocess.py 负责预处理dataset.py 负责数据加载model.py 定义网络train.py 跑训练标签和 README 把环境和目录结构交代得比较清楚。适合手里有肝脏 CT 或 MRI 数据、想快速把肿瘤分割模型跑起来的人也很适合当大数据或 AI 方向的毕业设计底子。README 里写明是在 Linux 环境下开发的但我在 Windows 上按同样步骤复现过坑基本集中在路径、编码和版本上。拆完这个包最大的感受是门摆脱不高水最深的是预处理和数据加载。2. 先看数据入口preprocess.py 和标签.txt 决定了模型能看到什么2.1 标签文件先读三遍再碰代码拿到压缩包先解压Linux 下用unzip 大数据医疗-肝癌影像AI诊断.zipWindows 下用 7-Zip 解压避免目录编码问题。进去之后别急着找模型先把 README、资源内容.txt、标签.txt 三份文档按顺序读完。标签.txt 一般会写明标签的组织形式是独立 mask 的 nifti还是和图像放在同一个 nii 里的多帧数据0 代表背景1 代表肿瘤区域。有些数据集还会带癌栓、胆管扩张这类次要标签如果只做肿瘤分割预处理时要把其他类别合并成背景。我拿到标签文件会先写两行代码统计每个病例的 mask 体素数占比这个习惯帮我过滤掉不少脏数据。占比低于 0.1% 的样本不要硬留一个肿瘤只占几十个像素的切片训练时既难收敛还会放大类别不平衡。另外注意检查 mask 的方向nifti 的 affine 矩阵决定了体素在物理空间的坐标有些数据采集时方向标记是反的图像和 mask 看起来尺寸一致实际在空间中是对不上的。这类问题靠人眼不好发现后面第 5 章会专门讲。2.2 窗宽窗位、归一化、重采样、切片顺序不能乱preprocess.py 的核心任务是把原始 nifti 转成模型能吃掉的 2D 切片常见处理顺序是读取图像和 mask做窗宽窗位截断归一化到 [0,1]重采样到固定尺寸然后沿 z 轴切片保存。先截窗再重采样是有讲究的重采样插值会把体素值抹匀如果先重采样再做窗宽窗位肝实质和肿瘤的边界对比度会被插值破坏。import nibabel as nib import numpy as np def preprocess_one(nii_path, label_path, target_shape(256, 256)): # 读取图像和masksqueeze去掉维度为1的轴 img nib.load(nii_path).get_fdata().squeeze() mask nib.load(label_path).get_fdata().squeeze() # 肝脏CT常用窗宽窗位窗位40HU窗宽400HU截断到[-200, 400] img np.clip(img, -200, 400) img (img 200.0) / 600.0 # 重采样到固定尺寸mask必须用最近邻插值 from skimage.transform import resize img_resized resize(img, (target_shape[0], target_shape[1], img.shape[2]), order3, preserve_rangeTrue) mask_resized resize(mask.astype(np.float32), img_resized.shape, order0, preserve_rangeTrue) return img_resized, (mask_resized 0.5).astype(np.uint8)这一步有两个参数最容易踩坑。第一窗宽窗位的数值不是通用的上面[-200, 400]只针对腹部 CT 增强扫描如果换到 MR 序列这套截断会把软组织对比度压没MR 数据一般直接做 z-score 标准化不做窗宽窗位第二mask 必须用 order0 最近邻插值用线性或三次插值会把 0/1 边界插出 0.5 之类的灰值后续计算 Dice 或阈值化全是麻烦。代码里的preserve_rangeTrue也要保留否则 resize 会自动把数据归一化到 [-1,1]那 HU 值的物理意义就全丢了。2.3 预处理产物和目录约定预处理输出一般会按 train/val/test 三个子目录存放切片文件名里带上病人 ID 和切片序号比如patient001_slice023.npy。我习惯同时保存原始 mask 和 resize 后的 mask原因是训练时如果发现某个病例切片质量不好可以直接追溯源文件。产物路径内容Shape 约定./npz/patient001/img/*.npy归一化后的图像切片(H, W) 或 (H, W, 1)./npz/patient001/mask/*.npy对应标注切片(H, W)uint80/1./annotations_summary.csv每个病例的切片数、肿瘤占比三列python preprocess.py --data_dir ./raw_data --save_dir ./npz --size 256跑预处理不要闷头执行完就完事我每次都会在结束后扫一眼每个病例的切片数和肿瘤像素占比分布。常见翻车情形是某个 nifti 读出来只有一张切片说明原文件本身可能是个 2D 扫描或者 affine 解析出了问题。这类脏数据趁早删别指望训练时靠数据增强来救。3. dataset.py把切片变成按病人隔离的训练流3.1 TensorFlow 1.8 时代的数据管道这个资源的环境锁定在 TensorFlow 1.8当年 tf.data API 刚出来不久dataset.py 里要干的事情很直接把预处理好的 npy 切片配成带标签的样本做增强再按 batch 吐给模型。和 2.x 相比1.8 的写法啰嗦一些迭代器需要显式创建。import tensorflow as tf def make_dataset(img_files, mask_files, batch_size, is_trainingTrue): # 配对图像和标签shuffle只应该在训练集上打开 dataset tf.data.Dataset.from_tensor_slices((img_files, mask_files)) def parse_func(img_path, mask_path): # 这里不直接读npy先做路径字符串处理文件读取放到map里 img tf.py_func(lambda p: np.load(p.decode()), [img_path], tf.float32) mask tf.py_func(lambda p: np.load(p.decode()), [mask_path], tf.float32) return img, mask dataset dataset.map(parse_func, num_parallel_calls4) if is_training: dataset dataset.shuffle(buffer_size1000) dataset dataset.map(augment, num_parallel_calls4) dataset dataset.batch(batch_size) dataset dataset.prefetch(1) iterator dataset.make_one_shot_iterator() return iterator.get_next()shuffle 的 buffer_size 不要拍脑袋设成 10000它直接影响训练前期的分布。buffer 越大样本洗得越均匀但要占内存buffer 太小比如 100每个 batch 里全是相邻切片训练损失曲线会抖得厉害。prefetch(1)是 GPU 利用率的关键不加的话 GPU 每个 step 都得等 CPU 读完硬盘。tf.py_func的性能不好所以每个文件只放一张切片而不是一个病例的所有切片这样多线程读盘时不容易阻塞。3.2 按病人划分训练验证集而不是按切片划分这是整个资源里最容易翻车的地方也是我血泪教训最深的一次。文件结构如果是patient001_slice002.npy有些同学直接对所有文件夹做 shuffle 再切 8:2训练集和验证集里就会出现同一个病人的相邻切片。人体器官在连续切片上的形态高度相似模型相当于反复见过同一份数据验证集 Dice 虚高到 0.9换个真正的新病人立刻掉到 0.6。import os import numpy as np def patient_split(all_files, train_ratio0.8, val_ratio0.1): # 从文件名里提取病人ID样本隔离必须发生在病人维度 patient_ids sorted(set(f.split(_)[0] for f in all_files)) n len(patient_ids) train_pids set(patient_ids[:int(n * train_ratio)]) val_pids set(patient_ids[int(n * train_ratio):int(n * (train_ratio val_ratio))]) test_pids set(patient_ids[int(n * (train_ratio val_ratio)):]) train_files [f for f in all_files if f.split(_)[0] in train_pids] val_files [f for f in all_files if f.split(_)[0] in val_pids] test_files [f for f in all_files if f.split(_)[0] in test_pids] return train_files, val_files, test_files如果数据集只有一两个病人按病人划分基本分不了验证集这时就别硬拆了用 K 折交叉验证把同一病人的所有切片始终放进同一折。拿一个病人的切片同时进 train 和 val自己骗自己没有意义这类小数据场景下医疗影像的分布漂移本来就严重宁可验证集切小一点也不能让样本交叠。3.3 样本不均衡的第一道防线在线采样还是加权损失肝肿瘤分割的常态是肿瘤区域占整个肝脏体积的 1% 甚至更低。切片化之后大部分切片的全图都是背景。这时候如果训练集里全是背景切片模型几分钟就能学会把全部预测成 0loss 看起来在降Dice 一直为 0。最简单有效的做法是在 dataset.py 里做一个重采样统计每张切片 mask 中前景像素占比训练时给前景占比高的切片分配更高的采样概率。更省事的方法是把这个问题推到 loss 层面preprocess.py 里得到的 mask 是 0/1 数组在损失函数里给前景像素加权重。def weighted_bce(y_true, y_pred, pos_weight5.0): # pos_weight控制正样本权重数值来自mask统计结果 bce tf.nn.weighted_cross_entropy_with_logits( y_true, y_pred, pos_weightpos_weight) return tf.reduce_mean(bce)这个pos_weight不要靠感觉定我用的是预扫描把所有训练 mask 的前景像素数除以背景像素数再线性映射到 5 到 10 之间跑 20 个 epoch 后看 Dice 曲线趋势再微调。数据增强层面也得注意CT 和 MR 是有物理意义的影像对图像做亮度抖动等于伪造 HU 值在分割任务里我很少对医学影像做 brightness 增强翻转、旋转、小角度旋转已经够用。4. 从 model.py 到 train.py2D U-Net 和一套能落地的训练参数4.1 为什么是 2D U-Net而不是别的结构在这个资源和这个数据规模下2D U-Net 几乎是分割模型的默认答案。U-Net 的编码器逐层缩小分辨率提取语义特征解码器再逐层放大恢复空间细节中间用 skip connection 把编码器特征拼到解码器对小目标、边界模糊的器官分割效果很稳定。相比 3D 卷积网络2D U-Net 吃显存少得多切片化后的数据量也足够支撑训练相比 Vision Transformer它不需要海量预训练数据从零训练也能收敛。def down_block(x, filters, is_training): # 编码器基本单元卷积BNReLU第二次卷积后用maxpool降采样 x tf.layers.conv2d(x, filters, 3, paddingsame) x tf.layers.batch_normalization(x, trainingis_training) x tf.nn.relu(x) x tf.layers.conv2d(x, filters, 3, paddingsame) x tf.layers.batch_normalization(x, trainingis_training) x tf.nn.relu(x) return tf.layers.max_pooling2d(x, 2, 2), x参数方面base filters 从 64 起步每个 down block 翻倍到最底层到 512。输入切片单通道灰度图尺寸 256×256输出层用 1 个滤波器加 sigmoid预测的是像素属于肿瘤的概率。BN 层的training参数必须在训练和推理时正确切换有的同学在推理时忘了传is_trainingFalse结果是 batch norm 拿训练时的统计量做推理输出概率整体偏移。这个坑在 TensorFlow 1.8 里尤其隐蔽因为 2.x 的 Keras 会自动处理。4.2 训练入口参数学习率、loss 和 epoch 怎么配置train.py 里我一般关注的参数就那么几个不需要一上来就调神经网络架构参数推荐值说明batch_size816取决于显存256×256 输入在 GTX 1080Ti 上 8 比较稳initial_lr1e-4医学影像分割常用值再大容易震荡lr_decaystep 衰减每 50 epoch 乘 0.5lossdice_loss weighted_bce单用 BCE 会在肿瘤占比极低时被背景带偏epochs200配合 early stopping 用dice_loss 是这个资源里最值得看的部分它直接优化目标指标def dice_loss(y_true, y_pred, smooth1e-5): # y_true和y_pred都是(?, H, W)的二进制图数值在0~1之间 intersection tf.reduce_sum(y_true * y_pred) union tf.reduce_sum(y_true) tf.reduce_sum(y_pred) return 1.0 - (2.0 * intersection smooth) / (union smooth)y_pred是 sigmoid 之后的概率不是 logits算 dice 时要把真实 mask 也变成浮点数。smooth 参数用来防止分母为 01e-5 已经足够。我从不用纯 dice loss而是 dice 加 BCE 各占一半权重原因是 BCE 能提供更稳定的梯度dice 在小目标场景下容易出现梯度方向不稳定两个一加收敛曲线比单独用任何一方都平滑。python train.py --data_dir ./npz --batch_size 8 --lr 1e-4 --epochs 200 --gpu 04.3 模型保存和断点续训训练时别只记每个 epoch 的 loss一定把验证集 Dice 记下来。保存模型的标准是验证 Dice 最高的那一轮不是验证 loss 最低的那一轮这俩在医学影像分割上经常不一致。因为 loss 里带着背景主导的 BCE很可能 background 学习得很好、肿瘤边界却在变差但从 loss 数字上完全看不出来。断点续训的逻辑只有一句话加载 checkpoint 的时候同时把优化器的学习率状态恢复不要简单地把learning_rate重新设为初始值。我从某个版本开始习惯在保存 checkpoint 时把 epoch 号也写进文件名比如model_epoch_120_dice_0.87.ckpt这样中途停了能准确找到该从哪接。TensorFlow 1.8 下用tf.train.Saver也只能按名字恢复变量改过模型结构之后就不能再直接恢复了所以模型结构一旦定下来训练期间尽量别改。5. 避坑清单TensorFlow 1.8 时代跑医学影像的老毛病5.1 TensorFlow 1.8 装不上或装错版本现象pip install tensorflow1.8.0直接报Could not find a version that satisfies the requirement或者装的 CPU 版本占着 GPU 显存不释放。原因TensorFlow 1.8 官方预编译包只认 Python 3.6在 3.7 以上环境里根本找不到对应的 wheel。README 里建议用 Anaconda 创建 python3.6 环境很多人图省事直接在 base 环境里装结果全乱。解决完全按 README 走创建 conda 环境conda create -n tf18 python3.6然后conda install tensorflow-gpu1.8.0让 conda 自动把 CUDA、CUDNN、MKL 一起装上。这种老版本框架conda 的依赖解析比 pip 强太多手动装 CUDA 九成会在运行时炸出libcudart.so.9.0: cannot open shared object file。5.2 Linux 环境下写的路径在 Windows 上直接跑不起来现象预处理没跑几步就报FileNotFoundError报错的路径看起来完全存在。原因代码里用了硬编码正斜杠/或者反斜杠\拼接路径Linux 下写死了/home/user/data这类绝对路径到 Windows 上要么盘符对不上要么分隔符解析出错。还有一种情况是数据集标注文件是 Linux 编码的 UTF-8Windows 记事本打开中文注释变成乱码这不是致命问题但很有干扰性。解决所有路径拼接一律用os.path.join()别自己拼字符串。数据目录和数据文件放进同一个包目录下用os.path.dirname(__file__)获取当前文件绝对路径再往上定位保证换机器不用改代码。标注文件用encodingutf-8显式打开别依赖默认编码。5.3 训练到一半显存炸了现象训练前 500 个 step 好好的第 600 个 step 报错Resource exhausted: OOM when allocating tensor。原因TensorFlow 1.8 默认会在启动时抢占整张 GPU如果开了数据并行或 feed 的 batch 比预期大显存会在某个时刻不够分配。另外一个常见场景是 dataset.py 里tf.py_func返回的 npy 没转成小 float 类型uint8 转 float32 之后每个 batch 占的内存翻四倍。解决给 session 配置显存按需增长config tf.ConfigProto() config.gpu_options.allow_growth True sess tf.Session(configconfig)另外在 dataset.py 里读 npy 后立刻astype(np.float32)mask 转astype(np.uint8)别让 float64 的数据混进来。5.4 nifti 读出来是 4Dsqueeze 之后维度全乱了现象img.shape出来是(512, 512, 150, 1)或(1, 150, 512, 512)直接把它当 3D 切片用训练的时候维度不对疯狂报错。原因部分 nifti 文件的存储维度里带了单例轴或者采集时把时间序列维度留下了。只调squeeze()不加检查当数据本身有 4 个有效维度时会把真实的空间轴挤掉。解决读取后显式检查维度数量4D 才 squeeze 第一个或最后一个维度然后用np.moveaxis把空间轴调整成(H, W, S)顺序再处理。检查 affine 的方向余弦也值得做有些数据是冠状位或矢状位扫的直接切片会得到横七竖八的断面。5.5 模型一直收敛loss 在降Dice 一直为 0现象训练 loss 从 0.5 降到 0.1看起来很健康但验证集 Dice 输出恒为 0。原因两种最常见情况。第一种是 mask 的数值范围不是 0/1而是 0/255Dice 计算的阈值 0.5 把 255 全部过滤掉了第二种是预测输出没有过 sigmoid直接把 logits 丢进 dice 函数负数值全被阈值化清了。解决在 train.py 开始前加断言mask 最大值等于 1预测值在 01 之间。assert mask.max() 1.0这一行能救半条命。模型输出层一定要带tf.nn.sigmoid或者在计算 dice 时显式对 logits 做 sigmoid。5.6 验证集 Dice 高到 0.9临床场景不实用现象自己划分的验证集上 Dice 0.91换一批外部数据直接跌到 0.55整个模型像中了玄学。原因切片的病人间数据泄漏是最大嫌疑第 3.2 节已经说过。另一个常见问题是在预处理时把 mask 和图像一起 resizeresize 之后没检查肿瘤区域是否仍然对齐有些距边缘很远的肿瘤切片在 resize 后只占一两个像素模型学到的基本是噪声。解决固定随机种子把 test 病人彻底隔离训练前把每个病人的切片数、肿瘤占比分布打印出来看一眼。验证集要能反映真实场景用自己数据自测永远是自我感觉良好找医院同事要几个没参与训练的外部数据跑一次才是硬标准。6. 训练完别急着收工推理、指标、可视化验证6.1 推理时最容易漏的事batch norm 切换模型训练完我要做的第一件事不是看指标而是把训练阶段最后保存的 checkpoint 拿来做一次完整推理确认输出 mask 和原图像在同一坐标系下对齐。def predict_case(sess, img_slices, threshold0.5): # img_slices是预处理后的切片序列模拟数据集读取管道 logits model(img_slices, is_trainingFalse) pred tf.nn.sigmoid(logits) pred_np sess.run(pred) return (pred_np threshold).astype(np.uint8)注意is_trainingFalse必须传对这是第五节提过的老坑。另外阈值 0.5 不是不能动的肿瘤占比较小的时候可以把阈值调到 0.4 或 0.6具体用什么阈值看哪一个在验证集上 Dice 和老百姓肉眼都顺我一般是先看 0.5 的结果再按肿瘤大小微调。6.2 用 MedPy 算 Dice 和 Hausdorff医学影像分割的常用评估指标资源依赖里列了 MedPy 0.4.0正合适from medpy.metric.binary import dc, hd95 dice_score dc(pred_mask, gt_mask) hd95_score hd95(pred_mask, gt_mask)Dice 衡量的是区域重叠率Hausdorff 距离衡量的是边界最大偏差。我评估模型时两个都看Dice 0.85 但 HD95 超过 20mm 的模型说明边缘预测极不稳定临床上不可用。HD95 可以传入voxelspacing参数传入实际体素间距把像素距离换成毫米。6.3 把预测结果写回 nifti做可视化核对最后也是最重要的一步把预测切片堆叠回 3D再用读原始图像时的 affine 写回 nifti 文件用 ITK-SNAP 叠加看。这一步不做前面所有分析可能都是纸上谈兵。import nibabel as nib raw_img nib.load(patient001_t2.nii) seg_3d stack_predictions(pred_slices) # shape (H, W, S) out_nii nib.Nifti1Image(seg_3d.astype(np.uint8), affineraw_img.affine) out_nii.to_filename(patient001_seg.nii.gz)写回时用原始图像的affine而非单位矩阵这样 mask 和原图在 ITK-SNAP 里能自动对齐。有一次我贪省事直接np.save了预测结果第二天复查才发现 mask 和原图左右颠倒因为预处理时做过镜像翻转但推理时忘了翻回去。从那以后我的流程强制固定为预处理完成后记录图像的空间朝向推理完成后先写回 nifti 肉眼确认方向再算指标。这个顺序帮我躲过了至少三次返工。希望这份拆解能帮你绕开这些弯路肝肿瘤分割这条路上九成的坑不在模型而在数据进出管道。本文还有配套的精品资源点击获取