十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

病理图像分割实战:HE染色乳腺癌数据集预处理与U-Net训练全流程

病理图像分割实战:HE染色乳腺癌数据集预处理与U-Net训练全流程 简介面向乳腺癌病理图像分析与深度学习研究者这份数据集提供58张HE染色组织病理学图像及对应的细胞分割真实标注可直接用于训练和评估分割模型并为后续良性/恶性分类提供数据基础。HE染色使多数原本透明的细胞显影能清晰呈现细胞核与组织结构XML标注则记录细胞轮廓或位置信息。压缩包共232个文件包含116个tif格式图像和116个xml格式标注文件整体大小约93.7MB结构简洁、便于下载和预处理。目前已有269人学习/下载适合正在从事医学图像分割、具备一定深度学习基础的研究者。借助高质量标注读者可直接开展数据划分、模型训练与指标评估也可基于这些图像做迁移学习或算法对比有效节省手动标注时间聚焦网络设计与实验分析。1. 拿到的不是“58 张图”是一套需要拆解的命名协议先泼一盆冷水这份乳腺癌 HE 染色病理数据集一共 58 张原始图像但直接把整图扔进 U-Net 训练你会连正常的 loss 曲线都看不到。原因有两点——其一病理全切片WSI尺寸通常在几千乘几千像素GPU 显存放不下其二数据集文件名里藏着一套完整的元信息编码规则不解析它你连「良性 / 恶性怎么划分数据集」都做不对。这里说的是一份带真实标注Ground Truth的 HE 染色乳腺癌细胞分割数据集标注的目标是把细胞核从组织背景中分离出来为后续的良恶性分类提供输入。文件名形如ytma49_042203_benign2_ccd.tif拆开看ytma49是组织微阵列TMA核心编号042203是采集日期2003-04-22benign2是类别加等级ccd表示采集设备通道。这个命名规则直接决定了你应该按哪个层级切分训练 / 验证集而绝大多数人拿到数据后的第一个错误就是把同一组织的相邻切片同时塞进训练集和验证集导致精度虚高。本文就从这份数据集出发把病理图像的预处理、U-Net 分割训练、评估验证到滑窗推理完整跑一遍给出可直接复现的命令和参数。2. 文件名解析与病理图像预处理先让数据变得「可训练」2.1 从文件名里还原标签和病人维度58 张图里出现了benign1/2/3和malignant1/2/3六种组合说明数据集不止区分良恶性还保留了组织学分级。这对后续任务很关键如果你只做二分类分割等级信息是噪声但如果你后续要做分级辅助诊断等级就是弱标签。我一般先把文件名解析成结构化表格再决定怎么划分数据。import re import pandas as pd from pathlib import Path def parse_filename(fname: str) - dict: # 文件名格式ytma核心编号_日期_类别等级_ccd.tif pattern rytma(\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: fytma{core_id}, # TMA 核心编号 date: f{year:04d}-{month:02d}-{day:02d}, label: label, # benign / malignant grade: int(grade), # 1/2/3 组织学分级 tissue_id: fytma{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 HE 染色的颜色归一化不做的后果很严重HE 染色苏木素-伊红的原理是苏木素把细胞核染成蓝紫色伊红把胞浆和间质染成粉红色。不同实验室、不同批次的染色深浅差异非常大直接喂给 CNN 会让模型学到染色偏差而不是细胞结构特征。常见做法是 Macenko 颜色归一化核心思路是把 RGB 图像转换到光学密度域OD再用奇异值分解SVD估计染色浓度矩阵最后把源图像的染色浓度映射到目标染色模板上。import numpy as np import cv2 def stain_normalize_macenko(img_rgb, target_od, beta0.15, alpha1.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(axis0) centered non_bg - mean_vec _, _, vh np.linalg.svd(centered, full_matricesFalse) stain_dir vh[:2].T # 取前两个主成分 # 浓度 OD / 染色方向投影回源空间 conc centered stain_dir # 归一化浓度范围 conc (conc - conc.mean(axis0)) / (conc.std(axis0) 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 的前两个主成分近似对应苏木素和伊红两种染色的方向。beta0.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_size256, stride128, min_tissue_ratio0.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:ypatch_size, x:xpatch_size] p_mask mask[y:ypatch_size, x:xpatch_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_ratio0.3表示 patch 中至少 30% 像素不是纯黑背景这个值对病理图很关键——HE 图像中玻片边缘、气泡、空白区域占比不低不过滤的话模型会学到大量「背景永远输出 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_size256, stride128, augmentFalse): self.df df.reset_index(dropTrue) 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:yself.patch_size, x:xself.patch_size] p_mask mask[y:yself.patch_size, x:xself.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只做翻转和旋转不做随机裁剪或颜色抖动——因为 HE 染色图像的颜色分布是有诊断意义的过多的颜色扰动会让模型对染色差异过度鲁棒反而不利于真实病理数据上的表现。_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, smooth1.0, dice_weight0.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, reductionmean) # 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(dim1) union pred_flat.sum(dim1) target_flat.sum(dim1) dice 1.0 - (2 * intersection smooth) / (union smooth) dice dice.mean() return dice_weight * dice (1.0 - dice_weight) * bcedice_weight0.7表示 Dice 占大头BCE 作为梯度稳定器。这个值不需要调得很精细0.6~0.8 之间对 58 张图的规模影响不大。训练时用 AdamW 优化器学习率 1e-4权重衰减 1e-5batch size 16单卡 12GB 显存够用训练 200 epoch每 5 个 epoch 在验证集上算一次 Dice。为什么不加 Focal Loss因为 HE 图像中细胞核的大小和形状比较一致难易样本的不平衡没有自然图像里那么极端DiceBCE 已经足够。加了 Focal Loss 反而会因为 γ 参数引入新的需要调的超参数在小数据集上过拟合风险更高。4. 验证集划分与训练日志解读怎么判断模型真的在工作4.1 按 tissue_id 划分训练/验证集而不是按文件这是一个隐蔽但致命的坑。58 张图里ytma49_042203_benign2和ytma49_042203_malignant3来自同一个 TMA 核心的相邻切片它们的背景纹理高度相似。如果按文件名随机划分验证集会包含训练集同源切片模型在验证集上的 Dice 会比真实场景虚高 5~10 个点。正确做法是from sklearn.model_selection import GroupShuffleSplit # df 已经解析好tissue_id 是组合了 core_id 和日期的唯一组织来源 gss GroupShuffleSplit(n_splits1, test_size0.2, random_state42) train_idx, val_idx next(gss.split(df, groupsdf[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_dice和val_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_pathval_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(), cmapgray) axes[i, 2].imshow(pred_bin.numpy(), cmapgray) # 重叠显示绿色是预测正确区域红色是误报蓝色是漏报 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, dpi150)这个可视化脚本的重点是第四列 overlay绿色预测正确阳性红色误报蓝色漏报。不需要看前三列也能快速定位模型失效模式——如果红色集中在染色偏暗的区域说明颜色归一化的beta阈值太低把背景噪声当成了组织如果蓝色连成大片说明模型对小细胞核不敏感需要调整损失权重或减小 patch 尺寸。5. 滑窗推理、连通域后处理与 HE 图上的检查技巧5.1 重叠滑窗推理避免伪影图块边缘出现马赛克接缝训练时用 256x256 patch推理时直接在整张图上滑窗。如果步长等于 patch 尺寸相邻 patch 的预测在边界处会有明显的接缝感原因是网络在 patch 边缘的卷积感受野不完整。解决办法是重叠推理——步长设为 128四个重叠 patch 在重叠区域取预测概率均值def sliding_window_infer(model, img, patch_size256, stride128, devicecuda): 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]), dtypenp.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:ypatch_size, x:xpatch_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:ypatch_size, x:xpatch_size] prob count_map[y:ypatch_size, x:xpatch_size] 1.0 # 取均值并裁掉 padding prob_map / np.maximum(count_map, 1.0) return prob_map[pad_top:hpad_top, pad_left:wpad_left]这里的cv2.BORDER_REFLECT_101是镜像 padding对面边缘像素做反射而不是补 0——病理图中补 0 会在图像边缘产生一道黑色「假边界」模型会倾向于把这块区域预测为背景。重叠权重图上中心区域被多个 patch 覆盖概率均值让边界平滑过渡。最后一步的count_map保证了所有像素都被至少一个 patch 覆盖而不是简单除以 patch 数量。5.2 形态学后处理分离粘连核与过滤噪声分割输出是概率图二值化之后需要两步后处理第一用连通域分析过滤面积过小的孤点第二对粘连的细胞核做分水岭分离。病理细胞分割里细胞核粘连是最常见的现象HE 染色下相邻核的边界在图像上是一条细线预测 mask 往往会把两个核连成一个连通域。import cv2 import numpy as np def postprocess_mask(prob, threshold0.5, min_area30): # 1. 阈值二值化 binary (prob threshold).astype(np.uint8) # 2. 形态学开运算去除小的噪声点保持核形状 kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) binary cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel, iterations1) # 3. 连通域过滤 num_labels, labels, stats, centroid cv2.connectedComponentsWithStats(binary, connectivity8) 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 cleanmin_area30是经验值30 像素在 256x256 patch 里大约对应 3~5 微米直径的核再小的基本是染色噪声。距离变换的0.3 * dist.max()阈值决定分水岭的 seed 数量——阈值越低 seed 越多粘连核被切开的概率更高但也更容易把一个完整核劈成两半。对 HE 图来说0.3 是个比较均衡的起点。最后检查结果有个最实用的技巧把后处理 mask 和原图以 50% 透明度叠加放大看细胞核密集区域确认粘连是否被合理切开、小核是否被误过滤。对 HE 染色图要特别看三处——染色过深导致核边界几乎不可分的暗区、核浆比高的恶性细胞聚集区、以及边缘处被镜像 padding 拉长的伪影。三处都正常这份数据集才算真正吃透了。本文还有配套的精品资源点击获取
返回列表