简介:这份乳腺癌细胞分割图片数据集面向医学图像处理、病理AI与深度学习方向的学习者和研究者,用于解决H&E染色组织病理图像中细胞分割及良性、恶性细胞分类的典型难题。数据集包含58张真实H&E染色组织病理学图像,图像经苏木素和伊红染色以凸显原本近乎透明的细胞结构,配套真实标注数据,适合开展分割模型训练、分类算法验证与医学图像预处理练习。压缩包共232个文件,由116个tif图像与116个xml标注文件组成,tif用于承载原始病理切片图像,xml则记录对应的细胞标注信息,整体约93.7MB,结构规整便于直接读取与建模。目前已有270人学习关注,可作为乳腺癌细胞分割与良恶性分类任务的入门到进阶实践素材,帮助读者快速搭建训练与评估流程,理解病理图像中细胞边界提取和类别判别的关键环节。
1. 58 张 H&E 病理图能干什么:先看清这份乳腺癌细胞分割数据集的底子
如果你正在找一份能直接跑通「细胞分割 → 良恶性分类」全流程的小体量病理数据集,这份 58 张 H&E 染色组织病理学图像大概率能省掉你不少前期找数据的时间。它解决的不是「数据量够不够训大模型」的问题,而是「我能不能先在一个干净、带标注、格式统一的小数据集上把 pipeline 跑通」的问题。每张图都是 CCD 相机拍的组织切片,文件名里直接编码了样本来源、日期和良恶性标签,比如ytma49_042203_benign2_ccd.tif和ytma12_010804_malignant2_ccd.tif,这种命名方式对批量处理和标签提取非常友好。适合谁?做医学图像分割入门的研究生、需要快速验证 U-Net 类模型效果的算法工程师,以及想理解 H&E 染色图像特点的深度学习从业者。不适合指望靠它刷 SOTA 的人——58 张图连一个像样的训练集都撑不起来,但用来做数据增强策略对比、损失函数消融或者标注质量检查,它足够真实。
2. 从文件名到标签张量:把 TIFF 图喂进分割网络前的四步预处理
2.1 为什么 H&E 图像不能直接 resize 就完事
H&E 染色的核心是苏木素把细胞核染成蓝紫色,伊红把细胞质和细胞间质染成粉红色。这个染色过程本身就有批次差异,同一张切片不同区域的颜色深浅都可能不一样。如果你直接把 RGB 三通道丢进网络,模型很可能先学会「蓝色多就是恶性」这种伪特征,而不是真正的细胞形态差异。常见做法是在训练前做颜色归一化,把每张图的染色分布对齐到一个参考模板上。我一般用 Reinhard 方法做 lab 空间的均值方差匹配,或者用 Macenko 方法做 SVD 分解后重新映射。这一步不做,后面分割出来的 mask 边界会非常毛糙,良恶性分类的准确率也上不去。
另一个坑是 TIFF 格式的位深。病理图常见 8 位和 16 位两种,16 位图的像素值范围是 0-65535,直接转成 8 位会丢失暗部细节。用tifffile读的时候先看一眼dtype,如果是uint16,要么线性拉伸到 0-255,要么在归一化时除以 65535。别用 PIL 默认读,PIL 对 16 位 TIFF 的支持一直不太稳。
2.2 用 Python 批量读取并解析文件名标签
文件名里的benign和malignant就是现成的弱标签,benign2、malignant3后面的数字大概率是样本编号或区域编号。下面这段脚本把目录下所有 TIFF 读进来,同时把标签和样本 ID 抽出来存成 DataFrame,方便后面按样本划分训练集和验证集。
import os import re import tifffile import numpy as np import pandas as pd def parse_filename(fname): # 匹配 ytma49_042203_benign2_ccd.tif 这种格式 pattern = r'(ytma\d+)_(\d+)_(benign|malignant)(\d+)_ccd\.tif' m = re.match(pattern, fname) if m: return { 'sample_id': m.group(1), 'date': m.group(2), 'label': 0 if m.group(3) == 'benign' else 1, 'region': int(m.group(4)), 'filename': fname } return None data_dir = './breast_cancer_dataset' records = [] for f in os.listdir(data_dir): if f.endswith('.tif'): info = parse_filename(f) if info: img = tifffile.imread(os.path.join(data_dir, f)) info['shape'] = img.shape info['dtype'] = str(img.dtype) records.append(info) df = pd.DataFrame(records) print(df[['filename', 'label', 'shape', 'dtype']].head()) print(f"总样本数: {len(df)}, 良性: {sum(df.label==0)}, 恶性: {sum(df.label==1)}")这段代码的关键在正则的捕获组设计。ytma\d+匹配样本来源编号,\d+匹配日期,(benign|malignant)直接映射成 0/1 标签。跑完先看shape和dtype两列,如果 shape 不一致,说明图像尺寸有差异,后面要么统一 pad 到最大尺寸,要么在 DataLoader 里做动态裁剪。dtype 如果是 uint16,记得在归一化时用 65535 做分母。
2.3 按样本划分训练验证集,别按图像随机分
58 张图如果随机按 8:2 分,很可能同一个样本的不同区域同时出现在训练集和验证集里。比如ytma49这个样本有 benign1、benign2、benign3 多张图,随机分的话验证集里的 benign2 和训练集里的 benign1 来自同一个组织块,染色特征高度相似,验证指标会虚高。正确做法是按sample_id分组划分,用GroupShuffleSplit或者手动按样本 ID 切分。我一般留出 2-3 个完整样本做验证,剩下的做训练。58 张图本身就不多,验证集控制在 10-15 张比较合适。
from sklearn.model_selection import GroupShuffleSplit gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_idx, val_idx = next(gss.split(df, groups=df['sample_id'])) train_df = df.iloc[train_idx] val_df = df.iloc[val_idx] print(f"训练集样本来源: {train_df.sample_id.unique()}") print(f"验证集样本来源: {val_df.sample_id.unique()}")跑完检查一下两个集合的sample_id有没有交集,有交集就说明划分逻辑写错了。这个细节在论文里经常被审稿人揪出来,提前做好能省很多返工时间。
2.4 数据增强:旋转和翻转够用,颜色抖动要克制
病理图像的方向没有语义意义,旋转 90 度、180 度、270 度和水平垂直翻转都是安全的增强手段。但颜色抖动要小心,H&E 的染色差异是真实存在的生物学变异,过度抖动会让模型把染色差异当成噪声忽略掉,反而降低泛化能力。我一般只做轻微的亮度对比度扰动,幅度控制在 ±10% 以内。另外弹性形变对细胞分割有帮助,但参数要调小,形变太强会把细胞核拉成奇怪形状,标注 mask 对不上。
import albumentations as A train_transform = A.Compose([ A.RandomRotate90(p=0.5), A.HorizontalFlip(p=0.5), A.VerticalFlip(p=0.5), A.RandomBrightnessContrast(brightness_limit=0.1, contrast_limit=0.1, p=0.3), A.ElasticTransform(alpha=1, sigma=50, alpha_affine=50, p=0.2) ])ElasticTransform的alpha控制形变强度,sigma控制平滑程度,alpha_affine控制仿射变换幅度。这三个参数在细胞分割任务里建议从alpha=1, sigma=50起步,跑几个 epoch 看 mask 边界有没有明显扭曲再微调。
3. U-Net 还是 DeepLabV3+:小数据集上的分割模型选型与训练参数
3.1 为什么 58 张图反而更适合 U-Net 类结构
DeepLabV3+ 和 Mask R-CNN 这些模型参数量大,在 58 张图上训很容易过拟合。U-Net 的编码器-解码器结构加跳跃连接,能在小数据集上更快收敛,而且对边界细节的保留更好。如果你非要用预训练权重,ImageNet 上训的 ResNet 编码器可以拿来初始化,但要注意病理图和自然图像的域差异很大,冻结编码器前几层、只微调后面几层效果更稳。我试过在 58 张图上从头训一个轻量 U-Net,用 Dice Loss 加 BCE 的组合损失,30 个 epoch 左右验证集 Dice 能到 0.75 上下,再往上就过拟合了。
3.2 损失函数选 Dice + BCE,别只用交叉熵
细胞分割的本质是像素级二分类,但前景(细胞)和背景的面积比可能到 1:10 甚至更悬殊。只用 BCE 的话模型会倾向于全预测背景,准确率看着高但 Dice 很低。Dice Loss 直接优化重叠区域,对类别不平衡更鲁棒。组合损失里 BCE 负责稳定梯度,Dice 负责拉高重叠度,权重一般设 0.5:0.5 或者 0.3:0.7。下面是一个可以直接用的损失函数实现。
import torch import torch.nn as nn class DiceBCELoss(nn.Module): def __init__(self, weight_bce=0.5, weight_dice=0.5): super().__init__() self.weight_bce = weight_bce self.weight_dice = weight_dice self.bce = nn.BCEWithLogitsLoss() def forward(self, pred, target): # pred: (B, 1, H, W) logits, target: (B, 1, H, W) 0/1 bce_loss = self.bce(pred, target) pred_sigmoid = torch.sigmoid(pred) intersection = (pred_sigmoid * target).sum(dim=(2, 3)) union = pred_sigmoid.sum(dim=(2, 3)) + target.sum(dim=(2, 3)) dice_loss = 1 - (2 * intersection + 1e-6) / (union + 1e-6) dice_loss = dice_loss.mean() return self.weight_bce * bce_loss + self.weight_dice * dice_lossBCEWithLogitsLoss内部做了 sigmoid 和 BCE 的数值稳定合并,比先 sigmoid 再 BCE 更安全。Dice 计算里加1e-6是防止分母为零,intersection和union在 H、W 维度求和后得到每个样本的 Dice,再取平均。如果显存够,可以把dim=(2,3)改成dim=(1,2,3)做全局 Dice,但小批量下全局 Dice 方差大,逐样本平均更稳。
3.3 学习率用余弦退火,初始值别超过 1e-3
小数据集上 Adam 的默认学习率 1e-3 有时候会震荡,我一般从 3e-4 起步,配合 CosineAnnealingLR 让学习率在训练后期降到 1e-6 附近。Batch size 受显存限制的话用 4 或 8 都行,但要注意 BatchNorm 在 batch size 小于 8 时统计量不准,可以换成 GroupNorm 或者 InstanceNorm。下面是一个训练循环的骨架,重点看学习率调度和验证指标的计算。
from torch.optim.lr_scheduler import CosineAnnealingLR model = UNet(in_channels=3, out_channels=1).cuda() optimizer = torch.optim.Adam(model.parameters(), lr=3e-4) scheduler = CosineAnnealingLR(optimizer, T_max=50, eta_min=1e-6) criterion = DiceBCELoss(weight_bce=0.5, weight_dice=0.5) for epoch in range(50): model.train() for imgs, masks in train_loader: imgs, masks = imgs.cuda(), masks.cuda() preds = model(imgs) loss = criterion(preds, masks) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step() # 验证阶段算 Dice model.eval() dice_scores = [] with torch.no_grad(): for imgs, masks in val_loader: imgs, masks = imgs.cuda(), masks.cuda() preds = torch.sigmoid(model(imgs)) preds_bin = (preds > 0.5).float() inter = (preds_bin * masks).sum() union = preds_bin.sum() + masks.sum() dice_scores.append((2 * inter / (union + 1e-6)).item()) print(f"Epoch {epoch}, Val Dice: {np.mean(dice_scores):.4f}")T_max=50表示 50 个 epoch 完成一个余弦周期,eta_min是最小学习率。验证时先 sigmoid 再阈值 0.5 二值化,Dice 计算用整个验证集的累加 intersection 和 union,比逐样本平均更能反映全局分割质量。如果验证 Dice 在 20 个 epoch 后还在涨但训练 Dice 已经接近 1,说明过拟合了,早点停。
3.4 从分割 mask 到良恶性分类:两步走还是端到端
这份数据集的最终目标是良性/恶性分类,分割只是中间步骤。两条路线:一是先训分割网络得到细胞 mask,再在 mask 区域上提取形态学特征(面积、周长、圆形度、核质比)喂给 SVM 或随机森林做分类;二是直接训一个多任务网络,共享编码器,一个头输出分割 mask,一个头输出分类 logits。58 张图的情况下,两步走更稳,因为分类器可以用分割结果做特征工程,解释性也强。端到端多任务学习需要更多数据才能让两个任务互相促进而不是互相拖累。我一般先用 U-Net 把 mask 训到 Dice 0.7 以上,再把 mask 叠加到原图上裁出细胞区域,提取 10-15 个形态特征,用 5 折交叉验证训一个梯度提升树,分类准确率通常比直接端到端高 5-10 个百分点。
4. 避坑与排查:58 张图训分割模型时最容易翻车的五个地方
4.1 验证集 Dice 很高但测试时一塌糊涂
现象:训练时验证集 Dice 到 0.85,换一批新图跑推理,mask 全是噪点或者全黑。原因:验证集和训练集来自同一个样本,染色分布和细胞密度几乎一样,模型只是记住了这个样本的纹理。解决:按sample_id分组划分,确保验证集的样本来源在训练集里完全没出现过。如果样本数太少没法分组,至少做 5 折交叉验证,每折留一个样本做验证。
4.2 损失降到很低但 mask 边界像锯齿
现象:训练 loss 收敛到 0.05 以下,但预测的细胞边界呈阶梯状,不光滑。原因:下采样过程中空间信息丢失,U-Net 的跳跃连接虽然能补回来一些,但如果输入图像分辨率本身很低(比如 256x256),细胞核只有几个像素宽,边界自然粗糙。解决:把输入尺寸提到 512x512 或 1024x1024,或者在解码器最后加一个亚像素卷积层做上采样。另外检查一下标注 mask 本身是不是就有锯齿,如果标注质量差,模型学出来的边界也好不了。
4.3 显存不够,batch size 只能设 1
现象:512x512 的图 batch size 设 2 就 OOM,设 1 又导致 BatchNorm 统计量不准,训练震荡。原因:U-Net 在浅层特征图通道数多,显存占用大。解决:把 BatchNorm 换成 GroupNorm,GroupNorm 对 batch size 不敏感。或者用梯度累积,每 4 个 batch 更新一次参数,等效 batch size 等于 4。再不行就用混合精度训练,torch.cuda.amp能省 30%-40% 显存。
4.4 颜色归一化后图像发灰,细胞核看不清
现象:做了 Reinhard 颜色归一化后,原本蓝紫色的细胞核变成灰蒙蒙一片,对比度下降。原因:参考模板选得不好,或者 lab 空间的均值方差匹配过度校正了。解决:换一个染色均匀、细胞核清晰的图做参考模板,或者改用 Macenko 方法,它对 H&E 的特定染色通道做分解,保留的生物学信息更多。归一化后记得目视检查几张图,别只看指标。
4.5 推理时忘记把 logits 转成概率
现象:模型输出一堆负数和小数,直接当 mask 用全是黑的。原因:训练时用了BCEWithLogitsLoss,模型输出的是 logits,推理时忘了加 sigmoid。解决:推理代码里显式加torch.sigmoid(preds)再阈值化。这个坑很低级但经常发生,尤其是从训练脚本复制到推理脚本时容易漏掉。
5. 把 58 张图用到极致:交叉验证、伪标签与形态学后处理的组合拳
58 张图确实少,但少不等于不能用。我的习惯是先用 5 折交叉验证把 U-Net 训出 5 个模型,每折留一个样本做验证,这样每个样本都有一次当验证集的机会。5 个模型的预测结果做平均,mask 的鲁棒性比单模型高不少。然后拿这 5 个模型去预测所有训练图,把预测概率高于 0.9 的像素当伪标签,低于 0.1 的当背景,中间不确定的区域忽略掉,扩充出一批带噪声的标注,再微调一轮。这一步能把有效训练像素数翻两三倍,Dice 通常能再涨 3-5 个点。
形态学后处理也别忽略。分割出来的 mask 经常有孤立的小噪点或者细胞核内部空洞,用cv2.morphologyEx做开运算去掉小噪点,再做闭运算填内部空洞。核的大小根据细胞核的实际像素直径来定,一般 3x3 或 5x5 的椭圆核就够。下面这段后处理代码可以直接接在推理后面。
import cv2 import numpy as np def postprocess_mask(mask, kernel_size=3): # mask: 0/1 二值图,uint8 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size)) # 开运算去噪点 opened = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) # 闭运算填空洞 closed = cv2.morphologyEx(opened, cv2.MORPH_CLOSE, kernel) return closed # 对每个预测 mask 做后处理 pred_mask = (torch.sigmoid(model(img)) > 0.5).cpu().numpy().astype(np.uint8) clean_mask = postprocess_mask(pred_mask[0, 0], kernel_size=3)MORPH_OPEN先腐蚀后膨胀,去掉比核小的亮斑;MORPH_CLOSE先膨胀后腐蚀,填掉比核小的暗洞。kernel_size别设太大,3 或 5 就够了,再大会把相邻细胞核粘在一起。后处理完再算 Dice,通常能比原始输出高 1-2 个点,尤其是边界区域。
还有一个技巧是把分割和分类的标签一致性检查加进去。如果一张图文件名标的是malignant,但分割出来的细胞核形态特征(比如核质比、圆形度)跟良性细胞更接近,就把这张图挑出来人工复核。58 张图里我遇到过两三张标签存疑的,复核后发现确实是标注错误。这种一致性检查在数据量小的时候特别值得做,能避免模型学到错误信号。
从那以后我每次拿到小样本病理数据集,都强制先跑一遍文件名解析和标签一致性检查,再开始训模型。这个习惯帮我省掉了至少两次因为标签错误导致的整轮返工。希望帮到你。
本文还有配套的精品资源,点击获取