病理图像分割实战:HE染色乳腺癌数据集预处理与U-Net训练全流程
2026/9/16 5:49:51 网站建设 项目流程

简介:面向乳腺癌病理图像分析与深度学习研究者,这份数据集提供58张H&E染色组织病理学图像及对应的细胞分割真实标注,可直接用于训练和评估分割模型,并为后续良性/恶性分类提供数据基础。H&E染色使多数原本透明的细胞显影,能清晰呈现细胞核与组织结构,XML标注则记录细胞轮廓或位置信息。压缩包共232个文件,包含116个tif格式图像和116个xml格式标注文件,整体大小约93.7MB,结构简洁、便于下载和预处理。目前已有269人学习/下载,适合正在从事医学图像分割、具备一定深度学习基础的研究者。借助高质量标注,读者可直接开展数据划分、模型训练与指标评估,也可基于这些图像做迁移学习或算法对比,有效节省手动标注时间,聚焦网络设计与实验分析。

1. 拿到的不是“58 张图”,是一套需要拆解的命名协议

先泼一盆冷水:这份乳腺癌 H&E 染色病理数据集一共 58 张原始图像,但直接把整图扔进 U-Net 训练,你会连正常的 loss 曲线都看不到。原因有两点——其一,病理全切片(WSI)尺寸通常在几千乘几千像素,GPU 显存放不下;其二,数据集文件名里藏着一套完整的元信息编码规则,不解析它,你连「良性 / 恶性怎么划分数据集」都做不对。这里说的是一份带真实标注(Ground Truth)的 H&E 染色乳腺癌细胞分割数据集,标注的目标是把细胞核从组织背景中分离出来,为后续的良恶性分类提供输入。

文件名形如ytma49_042203_benign2_ccd.tif,拆开看:ytma49是组织微阵列(TMA)核心编号,042203是采集日期(2003-04-22),benign2是类别加等级,ccd表示采集设备通道。这个命名规则直接决定了你应该按哪个层级切分训练 / 验证集,而绝大多数人拿到数据后的第一个错误,就是把同一组织的相邻切片同时塞进训练集和验证集,导致精度虚高。本文就从这份数据集出发,把病理图像的预处理、U-Net 分割训练、评估验证到滑窗推理完整跑一遍,给出可直接复现的命令和参数。

2. 文件名解析与病理图像预处理:先让数据变得「可训练」

2.1 从文件名里还原标签和病人维度

58 张图里出现了benign1/2/3malignant1/2/3六种组合,说明数据集不止区分良恶性,还保留了组织学分级。这对后续任务很关键:如果你只做二分类分割,等级信息是噪声;但如果你后续要做分级辅助诊断,等级就是弱标签。我一般先把文件名解析成结构化表格,再决定怎么划分数据。

import re import pandas as pd from pathlib import Path def parse_filename(fname: str) -> dict: # 文件名格式:ytma<核心编号>_<日期>_<类别><等级>_ccd.tif pattern = r"ytma(\d+)_(\d{6})_(benign|malignant)(\d)_ccd\.tif" m = re.match(pattern, fname) if not m: raise ValueError(f"无法解析文件名: {fname}") core_id, date_str, label, grade = m.groups() # 042203 -> 2003-04-22,注意年份只有两位 year = int(date_str[0:2]) + 2000 month = int(date_str[2:4]) day = int(date_str[4:6]) return { "filename": fname, "core_id": f"ytma{core_id}", # TMA 核心编号 "date": f"{year:04d}-{month:02d}-{day:02d}", "label": label, # benign / malignant "grade": int(grade), # 1/2/3 组织学分级 "tissue_id": f"ytma{core_id}_{date_str}" } # 批量解析 files = list(Path("./data/images").glob("*.tif")) records = [parse_filename(f.name) for f in files] df = pd.DataFrame(records) print(df.groupby(["label", "grade"]).size())

这段脚本做的事情是:先用正则把文件名拆成五个信息段,再拼接出tissue_id作为「同一组织来源」的唯一标识。注意042203这种日期格式,前两位是年份,正则里用(\d{6})一次抓取后手动切分,比写复杂正则更直观。core_id代表组织微阵列上的物理位置,同一core_id下可能有多张图,而tissue_id追加了日期做区分,两个字段配合可以识别「同一病人不同时间采样」的情况。

groupby输出类别分布后,你会发现 benign 和 malignant 的数量不是均衡的,这直接影响 Dice Loss 的权重设置和验证集划分方式。如果某个等级只有一两张图,就不要单独分 val,否则验证结果方差极大。

2.2 H&E 染色的颜色归一化:不做的后果很严重

H&E 染色(苏木素-伊红)的原理是苏木素把细胞核染成蓝紫色,伊红把胞浆和间质染成粉红色。不同实验室、不同批次的染色深浅差异非常大,直接喂给 CNN 会让模型学到染色偏差而不是细胞结构特征。常见做法是 Macenko 颜色归一化,核心思路是把 RGB 图像转换到光学密度域(OD),再用奇异值分解(SVD)估计染色浓度矩阵,最后把源图像的染色浓度映射到目标染色模板上。

import numpy as np import cv2 def stain_normalize_macenko(img_rgb, target_od, beta=0.15, alpha=1.0): # img_rgb: HxWx3 uint8 # target_od: 目标染色矩阵 (3x2),由参考图像统计得到 img_rgb = img_rgb.astype(np.float32) / 255.0 # 避免 log(0),加一个极小正数 img_od = -np.log(np.maximum(img_rgb, 1e-10)) # 去除背景像素:OD 值较低的像素被认为是空白区 mask = np.all(img_od < beta, axis=-1) # 在非背景像素上做 SVD,估计染色方向 non_bg = img_od[~mask] if len(non_bg) < 100: return img_rgb # 中心化后求主成分(即染色方向) mean_vec = non_bg.mean(axis=0) centered = non_bg - mean_vec _, _, vh = np.linalg.svd(centered, full_matrices=False) stain_dir = vh[:2].T # 取前两个主成分 # 浓度 = OD / 染色方向,投影回源空间 conc = centered @ stain_dir # 归一化浓度范围 conc = (conc - conc.mean(axis=0)) / (conc.std(axis=0) + 1e-8) # 重建到目标染色空间 norm_od = conc @ target_od.T + mean_vec norm_rgb = np.exp(-norm_od) return np.clip(norm_rgb * 255.0, 0, 255).astype(np.uint8)

逻辑说明:OD 域的好处是染色浓度与像素值呈线性关系,SVD 的前两个主成分近似对应苏木素和伊红两种染色的方向。beta=0.15是背景阈值,OD 值低于它说明该像素没有有效染色,直接忽略,防止空白区域干扰 SVD。alpha参数可以调节浓度尺度的缩放,一般保持 1.0。target_od需要在一张参考图上预先用同样的 SVD 流程提取,然后对所有训练图统一用它做映射。

实战建议:把归一化好的图像保存成 npy 或 PNG 再训练,不要在线做——58 张图虽然不多,但每张图切 patch 后数量会涨到几万,在线归一化拖慢训练且难以复现。我一般会把归一化后的图像和 mask 一起缓存到preprocessed/目录。

2.3 Patch 切分:滑窗策略与组织占比过滤

病理图无法整图入显存,标准做法是滑窗切 patch。窗口大小 256x256,步长 128 会产生 75% 的重叠,适合训练时做随机裁剪增强;推理时步长改为 256(不重叠)或 192(轻微重叠,后处理做加权平均)。

def extract_patches(image, mask, patch_size=256, stride=128, min_tissue_ratio=0.3): h, w = image.shape[:2] patches_img, patches_mask = [], [] for y in range(0, h - patch_size + 1, stride): for x in range(0, w - patch_size + 1, stride): p_img = image[y:y+patch_size, x:x+patch_size] p_mask = mask[y:y+patch_size, x:x+patch_size] # 计算组织占比:忽略全黑或全白背景 patch tissue_ratio = np.mean(p_img > 20) # 像素值大于 20 视为组织区域 if tissue_ratio < min_tissue_ratio: continue # 去除 mask 全是背景的 patch,避免绝大多数样本无目标 if p_mask.sum() < 50: continue patches_img.append(p_img) patches_mask.append(p_mask) return np.stack(patches_img), np.stack(patches_mask)

参数说明:min_tissue_ratio=0.3表示 patch 中至少 30% 像素不是纯黑背景,这个值对病理图很关键——H&E 图像中玻片边缘、气泡、空白区域占比不低,不过滤的话模型会学到大量「背景永远输出 0」的惰性行为。p_mask.sum() < 50过滤掉标注像素极少的 patch,阈值 50 是经验值,你可以用 mask 文件的实际尺寸缩放。

3. 分割模型选型与训练管线:U-Net 仍是病理分割的最稳基线

3.1 为什么不直接用 YOLOv8-seg

很多人会问:YOLOv8 不是自带分割头吗?是的,但 YOLO 的实例分割适合「目标数量少、个体边界清晰」的场景(比如驾驶疲劳检测中的车辆行人)。病理细胞分割的难点在于细胞核大量粘连、边界模糊、染色不均,U-Net 的编码器-解码器结构配合跳跃连接,在像素级语义分割上对小目标更友好。58 张图的规模也撑不起 YOLO 的 anchor 学习和复杂数据增强。这里的选择逻辑是:数据量小、目标密集、边界语义强 —— U-Net 在这种条件下收敛更快,效果更稳。YOLOv8-seg 不是不能用,而是需要先做大量预训练迁移,不划算。

3.2 U-Net 输入输出设计和数据加载器

输入是 256x256x3 的 RGB patch,输出是 256x256x1 的 mask,最后一层用 Sigmoid 输出每个像素属于「细胞核」的概率。数据集 58 张图,按 tissue_id 划分:约 44 张训练、8 张验证、6 张测试,宁可训练少一点也要保证验证集里没有同一组织的切片。

import torch from torch.utils.data import Dataset from torchvision import transforms class BreastPatchDataset(Dataset): def __init__(self, df, img_dir, mask_dir, patch_size=256, stride=128, augment=False): self.df = df.reset_index(drop=True) self.img_dir = img_dir self.mask_dir = mask_dir self.patch_size = patch_size self.stride = stride self.augment = augment # 预先解析出所有 patch 位置,避免训练时重复计算 self.patch_coords = [] for _, row in self.df.iterrows(): img_path = f"{img_dir}/{row['filename']}" h, w = self._load_image_size(img_path) for y in range(0, h - patch_size + 1, stride): for x in range(0, w - patch_size + 1, stride): self.patch_coords.append((row['filename'], y, x)) def _load_image_size(self, path): # 用 cv2 读图头拿尺寸,不加载整图 import cv2 img = cv2.imread(path, cv2.IMREAD_UNCHANGED) return img.shape[:2] def __len__(self): return len(self.patch_coords) def __getitem__(self, idx): fname, y, x = self.patch_coords[idx] img = cv2.imread(f"{self.img_dir}/{fname}") mask = cv2.imread(f"{self.mask_dir}/{fname.replace('.tif', '_mask.tif')}", cv2.IMREAD_GRAYSCALE) p_img = img[y:y+self.patch_size, x:x+self.patch_size] p_mask = mask[y:y+self.patch_size, x:x+self.patch_size] if self.augment: # 随机翻转和旋转 90 度,病理图像方向无先验 if torch.rand(1) > 0.5: p_img = cv2.flip(p_img, 1) p_mask = cv2.flip(p_mask, 1) k = torch.randint(0, 4, (1,)).item() p_img = cv2.rotate(p_img, [cv2.ROTATE_90_CLOCKWISE, cv2.ROTATE_180, cv2.ROTATE_90_COUNTERCLOCKWISE][k]) p_mask = cv2.rotate(p_mask, [cv2.ROTATE_90_CLOCKWISE, cv2.ROTATE_180, cv2.ROTATE_90_COUNTERCLOCKWISE][k]) # 转为 tensor 并归一化到 [0,1] p_img = torch.from_numpy(p_img.transpose(2, 0, 1).copy()).float() / 255.0 p_mask = torch.from_numpy(p_mask.copy()).float().unsqueeze(0) / 255.0 return p_img, p_mask

关键点:augment只做翻转和旋转,不做随机裁剪或颜色抖动——因为 H&E 染色图像的颜色分布是有诊断意义的,过多的颜色扰动会让模型对染色差异过度鲁棒,反而不利于真实病理数据上的表现。_load_image_size用 cv2 读图头拿尺寸,而不是提前把所有大图常驻内存,58 张图每张可能上百 MB,全量加载会把内存撑爆。

3.3 损失函数与训练超参:Dice Loss + BCE 的组合为什么有效

细胞分割的难点是前景背景极不均衡,一张 256x256 patch 里细胞核可能只占 5%~15%。交叉熵在这种场景下会导致模型倾向全输出背景。Dice Loss 直接优化区域重叠度,但单独用 Dice Loss 在小目标上梯度不稳定。标准做法是两者加权求和:

def dice_bce_loss(pred, target, smooth=1.0, dice_weight=0.7): # pred: sigmoid 输出 [B,1,H,W] # target: 0/1 mask [B,1,H,W] # BCE 部分 bce = torch.nn.functional.binary_cross_entropy(pred, target, reduction='mean') # Dice 部分,拉平到 [B, N] pred_flat = pred.reshape(pred.size(0), -1) target_flat = target.reshape(target.size(0), -1) intersection = (pred_flat * target_flat).sum(dim=1) union = pred_flat.sum(dim=1) + target_flat.sum(dim=1) dice = 1.0 - (2 * intersection + smooth) / (union + smooth) dice = dice.mean() return dice_weight * dice + (1.0 - dice_weight) * bce

dice_weight=0.7表示 Dice 占大头,BCE 作为梯度稳定器。这个值不需要调得很精细,0.6~0.8 之间对 58 张图的规模影响不大。训练时用 AdamW 优化器,学习率 1e-4,权重衰减 1e-5,batch size 16(单卡 12GB 显存够用),训练 200 epoch,每 5 个 epoch 在验证集上算一次 Dice。

为什么不加 Focal Loss?因为 H&E 图像中细胞核的大小和形状比较一致,难易样本的不平衡没有自然图像里那么极端,Dice+BCE 已经足够。加了 Focal Loss 反而会因为 γ 参数引入新的需要调的超参数,在小数据集上过拟合风险更高。

4. 验证集划分与训练日志解读:怎么判断模型真的在工作

4.1 按 tissue_id 划分训练/验证集,而不是按文件

这是一个隐蔽但致命的坑。58 张图里,ytma49_042203_benign2ytma49_042203_malignant3来自同一个 TMA 核心的相邻切片,它们的背景纹理高度相似。如果按文件名随机划分,验证集会包含训练集同源切片,模型在验证集上的 Dice 会比真实场景虚高 5~10 个点。正确做法是:

from sklearn.model_selection import GroupShuffleSplit # df 已经解析好,tissue_id 是组合了 core_id 和日期的唯一组织来源 gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_idx, val_idx = next(gss.split(df, groups=df["tissue_id"])) train_df = df.iloc[train_idx] val_df = df.iloc[val_idx] # 检查划分后类别是否均衡 print("训练集类别分布:") print(train_df["label"].value_counts()) print("验证集类别分布:") print(val_df["label"].value_counts())

GroupShuffleSplit保证同一tissue_id下的所有图像不会同时出现在训练和验证集。如果某个类别的验证图像数量太少,比如恶性验证集只有 1 张图,就把test_size调大或改用 StratifiedGroupKFold 做交叉验证。58 张图的规模下,我更推荐直接 5 折交叉验证,每折约 11~12 张验证图,最终报告 5 折的平均 Dice。

4.2 训练时盯住哪几个指标:不是只有 loss

训练脚本里我一般每 5 个 epoch 在验证集上输出四个指标:loss、Dice、IoU、以及两个类别的召回率(benign 和 malignant 分别算)。原因是 Dice 和 IoU 对「预测 mask 和真实 mask 的整体重叠度」敏感,但对「小细胞核是否被漏掉」不敏感——一个模型漏掉所有直径小于 10 像素的小细胞核,Dice 可能只掉 2~3 个点,但你后续做良恶性分类时,漏掉的小核恰好是关键特征。

# 训练脚本关键输出示例(每 5 epoch) Wandb run started, syncing... epoch 20/200 | train_loss 0.2314 | val_loss 0.2873 | val_dice 0.8431 | val_iou 0.7294 benign_recall: 0.8923 | malignant_recall: 0.7748 epoch 25/200 | train_loss 0.2118 | val_loss 0.2902 | val_dice 0.8455 | val_iou 0.7321 benign_recall: 0.8910 | malignant_recall: 0.7802

解读方法:如果train_loss持续下降但val_loss在 30 epoch 后不再下降甚至回升,说明开始过拟合,这时候不要等 200 epoch,直接把学习率降到 1e-5 再训 20 epoch。如果val_diceval_iou都在涨,但 benign 和 malignant 的 recall 差值超过 0.1,说明模型偏向形状规整的良细胞,需要考虑在损失函数里对恶性样本加权。

4.3 可视化验证:把预测结果叠加到原图上检查边界质量

指标只能告诉你数字,不能告诉你模型把细胞核边界预测成了锯齿状还是平滑曲线。每轮验证结束,我习惯随机挑 8 张验证 patch,把原图、真实 mask、预测 mask 三列并排存成一张对比图。检查三个位置:细胞核紧密排列的区域是否出现黏连、单个大核的边界是否完整、染色极浅的区域是否误报。

import matplotlib.pyplot as plt def visualize_results(model, val_dataset, device, save_path="val_vis/epoch_25.png"): model.eval() fig, axes = plt.subplots(4, 6, figsize=(18, 12)) with torch.no_grad(): for i in range(4): img, true_mask = val_dataset[i] pred = torch.sigmoid(model(img.unsqueeze(0).to(device))).cpu().squeeze() pred_bin = (pred > 0.5).float() # 第一列:原图,第二列:真实 mask,第三列:预测 mask axes[i, 0].imshow(img.permute(1, 2, 0).numpy()) axes[i, 1].imshow(true_mask.squeeze().numpy(), cmap="gray") axes[i, 2].imshow(pred_bin.numpy(), cmap="gray") # 重叠显示:绿色是预测正确区域,红色是误报,蓝色是漏报 overlay = np.zeros((*true_mask.shape[1:], 3)) correct = (true_mask.squeeze().numpy() == pred_bin.numpy()) & (true_mask.squeeze().numpy() == 1) false_pos = (pred_bin.numpy() == 1) & (true_mask.squeeze().numpy() == 0) false_neg = (pred_bin.numpy() == 0) & (true_mask.squeeze().numpy() == 1) overlay[correct, 1] = 1.0 overlay[false_pos, 0] = 1.0 overlay[false_neg, 0] = 1.0 overlay[false_neg, 2] = 1.0 axes[i, 3].imshow(overlay) axes[i, 0].set_title("Image") axes[i, 1].set_title("GT") axes[i, 2].set_title("Pred") axes[i, 3].set_title("Overlay") plt.tight_layout() plt.savefig(save_path, dpi=150)

这个可视化脚本的重点是第四列 overlay:绿色=预测正确阳性,红色=误报,蓝色=漏报。不需要看前三列也能快速定位模型失效模式——如果红色集中在染色偏暗的区域,说明颜色归一化的beta阈值太低,把背景噪声当成了组织;如果蓝色连成大片,说明模型对小细胞核不敏感,需要调整损失权重或减小 patch 尺寸。

5. 滑窗推理、连通域后处理与 H&E 图上的检查技巧

5.1 重叠滑窗推理:避免伪影图块边缘出现马赛克接缝

训练时用 256x256 patch,推理时直接在整张图上滑窗。如果步长等于 patch 尺寸,相邻 patch 的预测在边界处会有明显的接缝感,原因是网络在 patch 边缘的卷积感受野不完整。解决办法是重叠推理——步长设为 128,四个重叠 patch 在重叠区域取预测概率均值:

def sliding_window_infer(model, img, patch_size=256, stride=128, device="cuda"): h, w = img.shape[:2] # 对原图做镜像 padding,保证边缘像素也能被完整覆盖 pad_top = (patch_size - h % patch_size) % patch_size pad_bottom = (patch_size - h % patch_size) % patch_size pad_left = (patch_size - w % patch_size) % patch_size pad_right = (patch_size - w % patch_size) % patch_size img_pad = cv2.copyMakeBorder(img, pad_top, pad_bottom, pad_left, pad_right, cv2.BORDER_REFLECT_101) prob_map = np.zeros((img_pad.shape[0], img_pad.shape[1]), dtype=np.float32) count_map = np.zeros_like(prob_map) model.eval() with torch.no_grad(): for y in range(0, img_pad.shape[0] - patch_size + 1, stride): for x in range(0, img_pad.shape[1] - patch_size + 1, stride): patch = img_pad[y:y+patch_size, x:x+patch_size] patch_t = torch.from_numpy(patch.transpose(2, 0, 1)[None]).float().to(device) / 255.0 prob = torch.sigmoid(model(patch_t)).cpu().squeeze().numpy() prob_map[y:y+patch_size, x:x+patch_size] += prob count_map[y:y+patch_size, x:x+patch_size] += 1.0 # 取均值,并裁掉 padding prob_map /= np.maximum(count_map, 1.0) return prob_map[pad_top:h+pad_top, pad_left:w+pad_left]

这里的cv2.BORDER_REFLECT_101是镜像 padding,对面边缘像素做反射而不是补 0——病理图中补 0 会在图像边缘产生一道黑色「假边界」,模型会倾向于把这块区域预测为背景。重叠权重图上,中心区域被多个 patch 覆盖,概率均值让边界平滑过渡。最后一步的count_map保证了所有像素都被至少一个 patch 覆盖,而不是简单除以 patch 数量。

5.2 形态学后处理:分离粘连核与过滤噪声

分割输出是概率图,二值化之后需要两步后处理:第一,用连通域分析过滤面积过小的孤点;第二,对粘连的细胞核做分水岭分离。病理细胞分割里细胞核粘连是最常见的现象,H&E 染色下相邻核的边界在图像上是一条细线,预测 mask 往往会把两个核连成一个连通域。

import cv2 import numpy as np def postprocess_mask(prob, threshold=0.5, min_area=30): # 1. 阈值二值化 binary = (prob > threshold).astype(np.uint8) # 2. 形态学开运算:去除小的噪声点,保持核形状 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) binary = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel, iterations=1) # 3. 连通域过滤 num_labels, labels, stats, centroid = cv2.connectedComponentsWithStats(binary, connectivity=8) clean = np.zeros_like(binary) for i in range(1, num_labels): area = stats[i, cv2.CC_STAT_AREA] if area >= min_area: clean[labels == i] = 1 # 4. 距离变换 + 分水岭分离粘连核 dist = cv2.distanceTransform(clean, cv2.DIST_L2, 5) # 拓展:对 dist 做阈值拿到局部极大值作为分水岭的 seed _, dist_bin = cv2.threshold(dist, 0.3 * dist.max(), 255, cv2.THRESH_BINARY) dist_bin = dist_bin.astype(np.uint8) _, markers = cv2.connectedComponents(dist_bin) markers = (markers + 1).astype(np.int32) # +1 保证背景为 0 watershed = cv2.watershed(cv2.cvtColor((prob * 255).astype(np.uint8), cv2.COLOR_GRAY2BGR), markers) # watershed 返回的边界为 -1,把边界像素置 0 clean[watershed == -1] = 0 return clean

min_area=30是经验值,30 像素在 256x256 patch 里大约对应 3~5 微米直径的核,再小的基本是染色噪声。距离变换的0.3 * dist.max()阈值决定分水岭的 seed 数量——阈值越低 seed 越多,粘连核被切开的概率更高,但也更容易把一个完整核劈成两半。对 H&E 图来说,0.3 是个比较均衡的起点。

最后检查结果有个最实用的技巧:把后处理 mask 和原图以 50% 透明度叠加,放大看细胞核密集区域,确认粘连是否被合理切开、小核是否被误过滤。对 H&E 染色图要特别看三处——染色过深导致核边界几乎不可分的暗区、核浆比高的恶性细胞聚集区、以及边缘处被镜像 padding 拉长的伪影。三处都正常,这份数据集才算真正吃透了。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询