简介:面向医学影像与深度学习分割入门者,这份实战项目以 Unet 为框架、Resnet 为backbone,完成子宫颈细胞核二分类分割;压缩包将数据集、训练代码与已训练权重打包,经测试可直接运行,适合快速上手多尺度训练与多类别扩展。包体共804个文件,以jpg/png图像数据为主,另有py训练推理脚本、txt类别映射、xml标注、pth权重及readme说明,整体113.33MB,已有228人学习下载。价值点在于:train脚本默认开启多尺度训练,自动将输入随机缩放到设定尺寸的0.5-1.5倍,utils中的compute_gray函数把mask灰度值写入txt并自动设定Unet输出通道,方便扩展到多类别分割;学习率采用cosine衰减,run_results内提供训练/测试loss与iou曲线,日志可查看各类别iou、recall、precision及全局像素准确率。实测仅训练50个epochs,全局像素准确度达0.89,miou为0.72,若增加训练轮数仍有提升空间;推理时只需把图片放入inference目录并运行predict脚本,配合README新手也能顺利跑通。
1. 把 Unet+Resnet 跑通自己的分割数据:这份细胞核分割项目能直接改着用
做过分割任务的人都知道,Unet 是骨架,backbone 用 Resnet 还是 VGG,直接决定模型上限和收敛速度。这份子宫颈细胞核分割项目,属于少有的「代码、数据、权重三件套齐全」的实战资源——下载下来解压就能训练,训练脚本里已经把多尺度、多类别、学习率衰减这些训练 trick 都封好了。我拆完之后比较惊喜的一点是:项目只训了 50 个 epochs,全局像素准确度到了 0.89,miou 到 0.72,如果加大训练轮数还有上升空间。也就是说拿来做毕设、课程设计或者入门 Unet 分割工程,它是一个能跑通且有明确结果预期的起点。适合两类人:一类是想看 Unet+Resnet 完整训练流程的初学者,另一类是准备做医学图像细胞分割但还没搭好基础代码的开发者。
2. 从目录到训练脚本:先搞清楚这套代码怎么组织
2.1 项目文件的构成与职责划分
解压之后,你能看到图片文件是以 数字_数字_宽_高.jpg 形式命名的,这是医学图像常见的裁剪切片命名方式,图像来自原始病理涂片切出来的小图。整个项目的核心不是这些图片,而是 train 脚本、predict 脚本、utils 工具目录和 run_results 输出目录。
train 脚本负责完整训练流程,predict 脚本负责推理,utils 里的 compute_gray 函数是关键枢纽——它负责解析 mask 灰度值并自动配置 Unet 的输出类别数。run_results 目录存放训练日志、损失曲线、iou 曲线和最好的权重文件。这里面有一套设计思路值得学:把数据预处理、类别配置和网络结构解耦,由 mask 驱动模型输出,这样从二分类切到多分类时不需要改网络结构代码。
2.2 训练流程的入口与关键逻辑
训练时直接运行 train.py,常见做法是在项目根目录执行:
python train.py脚本会自动读取数据目录中的原图和 mask,不需要你手动指定训练集、验证集的划分比例,也没有额外配置文件。代码会自动把数据按设定尺寸随机缩放到 0.5-1.5 倍之间,实现多尺度训练。这样做的好处很直接:同一张图在训练过程中多次出现,但每次尺寸不同、感受野对应关系不停变化,相当于免费做了数据增强,让模型对细胞核大小差异更鲁棒。
注意几个参数含义:设定尺寸是你预先指定的输入分辨率,代码在此基础上做随机缩放;0.5-1.5 这个倍率区间其实覆盖了缩放为一半到一点五倍的全部中间值。这样的多尺度策略特别适合细胞核分割场景,因为染片厚度、成像设备差异会导致核的大小在不同图里差别很大,固定分辨率训练很容易过拟合到特定尺度。
2.3 mask 灰度值如何自动决定输出 channel
这里重点讲 utils 中 compute_gray 函数的工作原理。它遍历 mask 图像,提取所有出现的灰度值,写入 txt 文本。与此同时,它根据这些灰度值的数量自动为 Unet 网络设置输出 channel 数。
比如二分类场景下,mask 灰度值只有 0 和 255,那么输出 channel 为 2。如果做多分类,比如想要分出核、细胞质和背景,mask 里有 0、128、255 三个灰度值,输出 channel 自动变为 3。这就是这套项目支持多类别分割的机制。
# 这段逻辑等价于项目 utils 中的核心思路 def compute_gray(mask_dir): gray_values = set() # 扫描所有 mask 图片 for mask_name in os.listdir(mask_dir): mask = cv2.imread(os.path.join(mask_dir, mask_name), cv2.IMREAD_GRAYSCALE) # 统计这张 mask 中出现了哪些灰度值 gray_values.update(np.unique(mask).tolist()) # 将灰度值写入 txt 文件 with open("gray_values.txt", "w") as f: f.write(",".join([str(v) for v in sorted(gray_values)])) # 输出 channel = 灰度值类别数 return len(gray_values)这段代码的逻辑是:先用 set 收集所有 mask 中出现的灰度值,去重之后写入文本文件,返回类别数量。注意每个 mask 里未标注的区域灰度值为 0,在二分类时背景即类别 0,所以灰度值集合长度为 2,对应输出 channel 为 2。类别顺序与灰度值大小的一般对应关系是:灰度值越小越靠前。在多分类扩展时要注意,mask 中每个类别必须用单一且固定的灰度值,不能出现两个类用同一个灰度值的情况。
2.4 训练过程中的学习率调度与指标记录
学习率采用的是 cosine 衰减策略。训练初期学习率较大、收敛速度快,随着 epoch 推进学习率平滑下降,在后期更精细地逼近最优解。对于医学小数据集来说,这个策略能够有效避免震荡。
每轮训练结束后,代码会计算训练集和测试集的损失和 iou,用 matplotlib 绘制曲线图并保存到 run_results。同时保存训练日志,日志内容包括每个类别的 iou、recall、precision,以及全局像素点的准确率。这些指标全部以可读文本形式记录,训练完成后你打开 run_results 目录里的日志文件就能看到。
关于权重文件的使用:训练过程中会保存最好的权重,也就是验证集 iou 最高的那个模型,存放在 run_results 中。推理时 predict 脚本会自动加载最好的权重文件,不需要手动指定路径。这一点对新手来说很友好,整个流程做到了零参数配置运行。
3. 训练与推理实操:从数据准备到结果验证的完整路径
3.1 数据目录的组织方式与格式要求
这套代码能直接跑通,但它对数据组织有隐约的约定。核心要求是原图和 mask 文件名保持一一对应,且 mask 是单通道灰度图,尺寸与原图一致。文件名的前缀数字串是病理涂片的样本编号,宽高表示裁剪尺寸,同一编号下不同宽高的图来自同一张切片的多个视野。
实操中我一般把数据整理成下面这种结构:
dataset/ images/ # 原图,jpg 格式 masks/ # 标签图,png 或 bmp 格式,单通道灰度原图一般为 RGB 三通道彩色图,模型输入时会做归一化。mask 必须是单通道灰度图,背景灰度值为 0,前景目标灰度值为 255(二分类场景)。这套项目里的 mask 是单通道灰度标签,网络在推理时会输出每个像素属于各个类别的概率,取最大概率对应的类别作为预测结果。
3.2 predict 脚本的推理流程
推理是整个项目里最简单的一步。把待推理图像放在 inference 目录下,也就是放一张或多张需要预测的图片,然后在项目根目录执行:
python predict.py脚本会自动遍历 inference 目录中的所有图片,加载 run_results 中的最好权重执行推理,并把预测结果写到指定的输出目录。这里有一个使用习惯值得养成:在放图片进去之前,先确认图片尺寸和训练设定尺寸不要差太远。虽然 Unet 是全卷积网络,能处理任意尺寸输入,但如果输入尺寸和训练尺寸差一个数量级,容易出现分割碎片化的问题。
预测输出的图像中,像素值对应模型预测的类别编号,背景保持黑色,前景为白色。如果你用的是多分类权重,则每个类别显示为对应灰度值。
3.3 训练前后的验证闭环怎么打
训练完成后不要急着看曲线就完事。我会先看全局像素准确率和 miou 之间的关系,再去看单独类别的 recall 和 precision。对于细胞核分割来说,recall 低意味着漏检了核,precision 低则说明把杂质当成核了。
校验预测效果有一个比较直接的方法,把某张测试图的原图和 mask 放在一起对比观察,再看 predict 脚本输出的预测掩膜。如果整体轮廓对得上,边缘有小锯齿是正常的,如果出现大面积误判,先回看数据前处理或类别灰度值是否填对。
# 用 Python 快速检查一张 mask 的灰度值 import cv2 import numpy as np mask = cv2.imread("test_mask.png", cv2.IMREAD_GRAYSCALE) unique_vals = np.unique(mask) print("mask 灰度值类别:", unique_vals)这段代码用来判断 mask 是否符合项目的白名单机制——如果打印出 0、255 之外的值,比如 254 或 1,说明标注软件在做压缩或抗锯齿处理,需要修正 mask 中的灰度值。常见做法是先阈值化再保存,确保二分类 mask 严格只有两个灰度值。
3.4 训练日志中每个指标的含义与解读方法
训练日志通常包含每轮的训练损失、验证损失、各类别 iou、全局准确率等。重点看这三个指标:
全局像素准确率为预测正确的像素数占全部像素数的比例,这个指标容易虚高,因为背景像素占比大,哪怕只预测背景也能得到不错的准确率。miou 是各类别 iou 的平均,该指标更均衡。单独类别的 recall、precision 用来发现类别不均衡问题,如果某一个类别 recall 极低,基本可以判定是该类样本量太少。
日志中还有每个类别的 iou 数值,观察这个数值从第 1 epoch 到第 50 epoch 的上升幅度,如果训练到后期每个类别的 iou 还在明显上升,说明加长 epoch 确实能进一步涨点,这与摘要结论是吻合的。
4. 避坑与排查:细胞核分割中常见的项目级问题
4.1 predict 之后得到全黑图像
现象:推理完,输出的预测结果全黑,看不到任何分割目标。
原因:最常见是加载的权重文件与当前推理类别数不匹配。比如权重是二分类的,但 inference 目录里放的图是灰度图或做了不恰当的归一化,导致所有像素落到背景类。其次是输入图像的通道顺序问题,如果图像被误读成单通道灰度图,模型仍然能跑,但特征全部偏移。
解决:先确认权重文件来自 run_results 中的最好权重,确认训练时 mask 的灰度值配置。对于输入图,以 RGB 彩色读入并归一化到 0-1 区间;不要用灰度图直接送进网络。如果模型支持单通道输出,某些库的下采样会把通道数压缩,可以检查输入 tensor 是否经统一预处理。
4.2 多尺度训练导致显存溢出
现象:训练中途报 CUDA out of memory,并不是一开始就溢出,而是某个随机缩放后的较大尺寸样本崩溃。
原因:多尺度训练把输入随机缩放到设定尺寸的 0.5-1.5 倍,意味着 batch 中每张图的尺寸不同,最大尺寸是设定值的 1.5 倍。如果设定尺寸是 512,最大图的边长达 768,特征图相应变大,显存占用增长是非线性的。
解决:把训练批次减小一半,或者把设定尺寸调小,比如从 512 改成 384。还有一个技巧是在代码里做尺寸裁剪保证最长边落在某个范围。对于只有一块 8G 显存的显卡,设定尺寸 384 配 batch size 4 是比较稳妥的组合。
4.3 mask 出现了非标准灰度值
现象:训练时类别数莫名变多,比如二分类项目突然输出 3 个或更多 channel。
原因:mask 中有像素值不是标准的 0 和 255,而是如 128、254、1 等中间值。这通常来自标注工具导出时的抗锯齿、JPEG 压缩伪影或画图工具修改痕迹。
解决:训练前让 compute_gray 机制先跑一遍并观察输出的类别数量。如果类别数多于预期,对 mask 做阈值处理强制二值化,或者检查标注导出设置换用 PNG 无损格式保存。从那以后我每次准备新数据集时,都会在计算灰度值后额外打印一份各类像素数量分布,防患于未然。
4.4 训练损失下降但 miou 不涨
现象:训练损失在持续下降,但验证集 miou 到 20 个 epoch 后就不动了,甚至偶尔往下走。
原因:典型的多类别不均衡和数据增强过度。多尺度缩放虽然增加了泛化能力,但某些尺寸下细胞核的数量极多,小目标被严重压缩,模型学到的是大核特征,小核的 iou 提不上去。另一种可能是类别权重没配,损失函数里背景类和前景类对梯度的贡献等同,前景类存在小幅欠拟合。
解决:先给损失函数中的前景类别加权重,常用做法是权重设为背景类的两倍,或者使用 focal loss 替代标准交叉熵。再观察每个类别的 iou,如果小核的 recall 过低,可以把缩放倍率改成 0.8-1.2 区间,缩窄尺度变化范围。
4.5 loss 曲线恢复正常但预测结果呈网格状伪影
现象:预测输出的分割图上有明显的块状或网格效应,边缘呈锯齿状。
原因:数据在预处理阶段可能做了随机 resize,推理时原图直接输入,模型看到了和训练时略有差异的尺度,导致感受野覆盖不一致。Unet 结构经过多层池化后特征图的感受野位置偏移,在小尺寸输入上表现尤其明显。
解决:推理前把输入图 resize 到训练时的标准设定尺寸,而不是直接原图尺寸输入;必要时也采用多尺度推理取平均。这是 Unet 系列的老毛病——训练多尺度,但推理单尺度,效果会打折扣。
5. 从二分类切到多分类:灰度值映射与输出层改造的完整方法
5.1 为什么要从二分类扩到多分类
摘要中指出该项目支持多类别分割,只要类别数量在计算灰度值时自适应即可。在实际病理应用中,往往不只是分割细胞核,还需要同时分出细胞质、鳞状上皮细胞、中性粒细胞等结构。这类多分类标签的 mask 图里,每个类别固定一个灰度值——比如背景为 0,细胞核为 64,细胞质为 128,鳞状上皮为 192——网络输出通道直接等于灰度值类别数。
从二分类扩展的好处是:不需要重写任何训练代码,只需要替换数据集的 mask 文件并重新执行训练脚本。utils 中的 compute_gray 函数会自动识别新出现灰度值的数量并调整输出通道,这种设计解决了很多教程中手动改网络输出层的痛点。
5.2 多类别 mask 制作时的规则与规范
制作多分类 mask 时有三条硬性规定需要遵守。第一,每个类别必须使用固定且唯一的灰度值,同一类所有 mask 中该值不变,更不要出现两个类别共用灰度值的情况。第二,mask 统一保存为 PNG 格式,PNG 是无损压缩,不会像 JPEG 那样改变像素值。第三,灰度值之间要留间隔,常用方案是 0、85、170、255 四个值均匀分布,避免后续做颜色映射时混淆。
很多标注工具导出 mask 时会自动把标签映射成 RGB 彩色保存,而代码读的是单通道灰度图,遇到这种情况一定要在导出时选择 label 模式而不是 RGB 模式。如果拿到的是 RGB 标签图,需要手动转换:
import cv2 import numpy as np # 读取 RGB 标签图 img = cv2.imread("label.png") # BGR 通道 # 将 RGB 标签转换为灰度索引 gray = np.zeros((img.shape[0], img.shape[1]), dtype=np.uint8) # 例如:背景(0,0,0) -> 0,核(255,0,0) -> 1,质(0,255,0) -> 2 gray[np.all(img == [0, 0, 0], axis=-1)] = 0 gray[np.all(img == [255, 0, 0], axis=-1)] = 1 gray[np.all(img == [0, 255, 0], axis=-1)] = 2 cv2.imwrite("mask_gray.png", gray)这段代码用像素级 RGB 比较完成颜色到类别索引的映射。注意 OpenCV 读图默认是 BGR 顺序,所以比较时要按 BGR 来写。类别 0、1、2 分别对应灰度值,代码中不设置缩放因子的话保持原欧氏距离,运行后 mask_gray.png 就是能直接被项目读取的训练标签。
5.3 多类别分割中的调参与参数设定的差异点
多分类相比二分类有几个地方要改。第一是数据划分策略,如果某类样本特别少,建议固定随机种子并采用分层采样,确保训练集、验证集每个类别都有。第二是训练轮数需要适当增加,多分类收敛速度会慢于二分类,因为输出空间变大、类别间的边界更难学。第三是学习率可以调低一些,比如初始学习率减半,防止类别间梯度竞争导致震荡。
另一个比较重要的是评价指标的选择。二分类里 miou 是一个直观指标,多分类里全局准确率会被背景主导。阅读日志时,更值得关注的是每个类别的 recall,特别是前景小类别的 recall。如果某个类 recall 明显低于其他类,优先检查该类的样本量和质量,其次考虑在数据增强中对该类做针对性处理。
5.4 推理脚本在多分类下的使用
训练完多分类模型之后,predict 脚本仍然不需要改参数,它会根据权重文件中的输出通道自动调整。输出掩膜中是每个类别的灰度值,如果你要在视觉上展示不同类别,一般会在展示脚本里给每个灰度值映射一个颜色。
# 将预测掩膜渲染为彩色图像 import numpy as np import cv2 # 定义类别颜色映射 color_map = { 0: (0, 0, 0), # 背景 黑色 1: (0, 0, 255), # 细胞核 红色 2: (0, 255, 0), # 细胞质 绿色 3: (255, 0, 0), # 其他 蓝色 } pred = cv2.imread("predicted_mask.png", cv2.IMREAD_GRAYSCALE) height, width = pred.shape render = np.zeros((height, width, 3), dtype=np.uint8) for gray_val, color in color_map.items(): render[pred == gray_val] = color cv2.imwrite("visualized_result.png", render)这里用最原始的逐像素方式做颜色映射。渲染后的图像适合论文插图或做定性对比实验,能直观看到每个类别的输出区域。
6. 权重选择策略与生命周期管理:训练完成后要做的四件事
训练完成并拿到权重文件后,直接进入推理阶段不算结束。以这个项目的 run_results 目录为例,里面往往有最优权重和最后一次权重,两者要区分使用。最优权重是验证集 miou 最大时保存的模型,适合推理以及后续微调;最后一次权重则用于对比观察模型在训练集和验证集上的差距,判断是否过拟合。
第一件事是记录训练日志末尾的指标汇总数据,包括全局像素准确率、miou、各个类别的 recall 和 precision,把它们整理到实验记录表里。同一份数据换一次超参数就再记录一次,积累十组以上后,你能清楚地看到多尺度缩放区间和学习率衰减策略对最终分割效果的影响趋势。
第二件事是用随机抽取的测试图做一次预测,并统计该图上每个类别的 recall 和 precision。这些指标应该和其他测试图保持同一水平。如果某张图异常低,大概率是这张图的染色或成像风格与训练数据差异显著,医学图像里这种例子很常见。我会把这类特殊图片单独归入 hard set,后续做针对性增强或微调,从那里开始模型有一个持续迭代的闭环。
第三件事是保存训练时的配置信息。包括输入尺寸、缩放区间、学习率初值、batch size、epoch 数和数据目录结构说明。这些信息写入一个简单的 config.txt 放在 run_results 下,方便后续重新加载权重时对比。因为权重文件只包含模型参数,不包含超参数,没有配置文件的话,隔两周再回来做实验会很混乱。
第四件事是决定是否做权重压缩或转换。PyTorch 格式的权重直接用于研究最方便,但如果你要在工程环境部署,比如用 OpenCV DNN 模块推理,你需要把权重转换到 ONNX 格式。
python torch2onnx.py --weights run_results/best_model.pth --output model.onnx --input-size 384 384转换成功之后,可以用 ONNX Runtime 直接推理。注意 ONNX 转换时需要指定输入尺寸,如果训练用了多尺度,转换时选择一个居中尺寸;转换完成后最好用一张真实测试图对比原权重和 ONNX 模型的输出差异,差异小于 1% 像素就算合格。如果推理节点使用 float16 量化,还需额外检查精度损失,细胞核边缘检测对量化误差比较敏感。
做完这四件事,一份训练好的权重文件就有了相对完整的生命周期管理。后续不管是继续训、部署还是发给其他人复现,都有据可查。这个项目当前的 50 个 epoch 结果已经不错,但从 miou 曲线图来看如果继续训练到 150 个 epoch,全局像素准确率和 miou 大概率还能再上一层。多尺度训练配合 Resnet backbone 的初始化特征提取能力,属于性价比很高的基础配置方案,尤其在细胞核这类相对规则的目标上,稳定性值得信任。
希望这份拆解笔记对你有用。我从这个项目里学到最有价值的一点就是:类别数由 mask 自动推导的设计,把数据、标签和网络结构三者之间的耦合降到了很低,换数据集、换分类数时都不用再改动核心代码,整套流程也因此有了工程层面的可复制性——希望你把它用在自己手头的数据上时,也能有这个感觉。
本文还有配套的精品资源,点击获取