从 patch 热力图到 U-Net:肝脏识别进度记录
0. 背景
好久没更新博客了。
上一次还在看 nnU-Net 的论文,想着先把数据空间、预处理和输入格式这些基础问题弄明白。后来真正开始做肝脏识别,路线也从一开始的 patch 分类和粗 ROI,慢慢走到了现在的 U-Net 像素级分割。
这篇不打算写成很完整的教程,主要想记一下:
为什么换模型、换完以后做了什么,以及现在到底做到哪一步了。
先说明一下,目前实际训练的是自己写的轻量 2D U-Net baseline,并不是直接运行官方 nnU-Net。完整代码放在公开仓库里:liver-segmentation code。
1. 换模型前,旧方案卡在哪里
换 U-Net 之前,做的是一个监督式对比学习的 patch 分类器。
它会判断一个 96 × 96 的 CT patch 是 liver 还是 non-liver,然后在完整 CT 上滑窗,把每个 patch 的概率重新叠回三维空间,最后得到一张肝脏热力图。
这条路线不是完全没用。它确实能大致找到肝脏,也很适合验证“模型到底有没有学到肝脏区域”。但越往后做,问题越明显:
- 它学的是“这个 patch 里像不像有肝脏”,不是“每一个像素是不是肝脏”;
- 滑窗概率回填以后,边缘容易变厚、变糊;
- 模型只看局部,容易把附近灰度相似的器官和软组织一起圈进来;
- 阈值一改,Recall 和 Precision 就会明显拉扯;
- 最大连通域、闭运算、矢状面过滤都能清掉一些小块,但更像是在补救结果。
旧方案最后选中的是 epoch 40、阈值 0.80。在 4 个完整验证 CT 上,平均 Recall 是 0.9504,Precision 是 0.7266,Dice 是 0.8220。
召回率已经不算低,但误检还是很多。这个时候继续给后处理叠规则,收益越来越有限。后来想明白,问题不只是模型容量不够,而是任务本身没有对齐:
想要的是一张像素级 mask,却一直在训练一个 patch 级分类器。
所以决定换成 U-Net。
2. 先把 U-Net 的最小闭环跑通
这次没有一上来就堆很复杂的结构,而是先写了一个比较小的 2D U-Net:
- 单通道轴向 CT 输入;
- 4 层 encoder 和 decoder;
- skip connection;
- GroupNorm;
- base channels 为 16;
- 总参数量约 194 万。
输出也从“这个 patch 是不是肝脏”,变成了“这一张切片上每个像素属于肝脏的概率”。
损失函数先用了 Soft F2。F2 会比普通 Dice 更看重漏检,比较符合现在的目标:第一阶段先尽量把肝脏完整圈住,再慢慢压低误检。
数据使用 SLIVER07 的 20 个带标注病例,按病例分成:
16 例训练
4 例验证
这里没有把切片随机打散以后再划分,而是直接按病例隔离,避免同一个人的相邻切片同时出现在训练集和验证集。
预处理阶段先把 CT window 固定在 [-100, 250],再缓存成 NumPy 数组。最终训练集一共有 3,134 张轴向切片,验证集有 1,025 张。
正式训练前还先做了一个小 pilot:
512 张训练切片
3 epochs
1 个完整 CT 验证
这个阶段主要不是追求分数,而是检查缓存、缩放、mask、loss、反向传播和整卷推理能不能闭环。跑通以后,又做了一次阈值扫描,最后把全量训练的验证阈值定在 0.30。
3. 全量 baseline 做了什么
完整 baseline 在 CUDA 上训练了 30 epochs,batch size 为 32,输入尺寸为 256 × 256。
和之前只看 patch accuracy 不同,这次每一个 epoch 结束后,都会把 4 个验证病例的完整 CT 全部推理一遍,再统计:
- Recall;
- Precision;
- Dice;
- F2;
- 连通域数量;
- 最大连通域占比。
最佳模型也不再按某一批切片的表现选择,而是按整卷验证的 F2 保存。
最后 best checkpoint 落在 epoch 30。
这个过程里我觉得比较重要的一点是:切片 loss 下降不代表整卷空间结果一定更好。真正要用在三维 CT 上,就应该尽早把整卷验证放进训练流程,而不是最后才随便挑一例看看。
4. 到目前为止的结果
为了和旧方案用同一个口径比较,下面都是在同一组 4 个验证病例上取宏平均:
| 方法 | Recall | Precision | Dice |
|---|---|---|---|
| SupCon patch + 滑窗 | 0.9504 | 0.7266 | 0.8220 |
| 2D U-Net raw mask | 0.9837 | 0.9449 | 0.9639 |
换成 U-Net 后:
- Recall 提高约 3.33 个百分点;
- Precision 提高约 21.83 个百分点;
- Dice 提高约 14.19 个百分点。
4 个验证病例的 Dice 分别是:
sliver07_001 0.9499
sliver07_008 0.9598
sliver07_012 0.9701
sliver07_015 0.9758
更让我意外的是,这一版 U-Net 的数字来自 raw mask,还没有套最大连通域或其他清理;旧方案的结果已经做过连通域后处理。也就是说,目前的提升主要来自训练目标终于和分割任务对上了,而不是靠后处理把图修漂亮。
推理流程现在也已经可以输出:
liver_probability.nii.gz
liver_mask_raw.nii.gz
liver_contour_raw.nii.gz
inference_summary.json
这些 NIfTI 会保留原 CT 的 spacing、origin 和 direction,可以继续放进 3D Slicer 或 ITK-SNAP 里逐层检查。
5. 现在还不能高兴得太早
虽然这次结果比旧方案好很多,但目前还是一个很早期的 baseline。
现在比较明显的问题有:
- 数据只有 20 例,验证也只有 4 例,还没有独立测试集和外部数据集;
- 目前是逐张轴向切片的 2D U-Net,没有利用相邻切片和完整三维上下文;
- 数据增强现在主要是亮度、对比度和噪声,还不够完整;
- raw mask 每例仍有 27~40 个连通块,只是最大连通域已经占预测体积的 99% 以上;
- 目前只统计了 Recall、Precision、Dice 和 F2,还没有 HD95、Surface Dice 这些边界指标;
- 现在的结果只能算内部实验,不能写成已经具备临床泛化能力。
所以这次更准确的结论不是“肝脏分割做完了”,而是:
从一个比较绕的粗定位方案,走到了一个终于能稳定学习像素级肝脏边界的 baseline。
6. 接下来的目标
后面准备按这个顺序继续:
- 先给 4 个验证病例统一做后处理和可视化,确认小连通块到底来自哪里;
- 加入更完整的空间增强,并比较 Soft F2、Dice + BCE、Focal 等损失;
- 做 5-fold 或至少更可靠的独立测试,补上 HD95 和 Surface Dice;
- 尝试 2.5D、3D U-Net,并正式跑一版官方 nnU-Net 作为对照;
- 增加其他肝脏数据集,重点看跨数据集泛化;
- 等肝脏 mask 稳定以后,把它作为 ROI,继续做肝内病灶识别。
最终目标还是没有变:
先稳定找到完整肝脏
↓
缩小病灶搜索范围
↓
再做肝内病灶识别与分割
这次最大的收获大概是,换模型并不只是把一个网络名字换成另一个网络名字。更重要的是,先确认训练目标和真正想解决的问题是不是同一件事。
现在至少终于走在同一条路上了。