简介:乳腺癌细胞分割数据集面向医学图像分析与深度学习领域的研究者和初学者,提供58张经H&E染色的乳腺癌组织病理学图像及对应xml格式的真实标注文件,可用于训练细胞分割模型,并衔接后续的良性与恶性细胞分类任务。染色使细胞核等结构清晰可见,但细胞密集、形态差异大,分割本身具有典型挑战性,适合作为语义分割、实例分割等算法的验证数据。压缩包共232个文件,包含116张tif原图与116个xml标注,整体约93.7MB;文件命名包含benign/malignant及病例编号,便于按类别划分训练集与测试集。目前已有270人学习下载,可直接对接PyTorch、TensorFlow等框架完成数据加载与模型评测,帮助使用者省去病理图像采集和手工标注成本,快速开展乳腺癌细胞检测相关实验。
1. 乳腺癌细胞分割图片数据集:先搞懂它在解决什么问题
病理科医生在显微镜下数癌细胞核,一天看几百张图,眼睛酸是小事,漏掉几个不典型的核才是大事。乳腺癌细胞分割这个方向,本质上就是让模型在 H&E 染色的病理切片上,把每个细胞核的边界自动勾出来。而“图片数据集”这四个字,决定了这件事能不能落地——没有像素级标注的图片,分割模型训得再好也只是论文里的演示。这个标题指向的,不是某个单一文件包,而是一整套“原始病理图 + 掩膜标注 + 训练划分”的组合资产,服务的是语义分割、实例分割这类任务的训练与评测。适合正在做医学影像 AI、病理辅助诊断,或者拿公开数据集打比赛的算法工程师:知道它是什么、怎么建、怎么用、坑在哪,比到处找“现成下载地址”更重要。
2. 数据从哪来:公开集选型与自建标注流程
做细胞分割的数据集,第一步永远不是写模型,而是搞清楚你手上的数据到底是什么粒度。整张 WSI(全切片扫描图)和单细胞核标注是两回事,组织区域分级和细胞级分割也是两回事。标题里“细胞分割”四个字,直接把任务锁死在细胞核这一级,这意味着标注成本高、标注标准敏感、模型输入输出都是像素级。想少走弯路,先是选对数据来源,再是规范自建流程,最后是科学划分数据集,这三件事决定了后面训练阶段是顺风还是逆风。
2.1 先看标注粒度:公开集选型的三个标准
圈内常见的公开病理数据集里,BACH、TNBC、MoNuSeg、NuInsSeg 这类名字经常被放在一起讨论,但它们的标注粒度完全不同。BACH 偏组织学图像分级,给的是整张图的类别;TNBC 是乳腺癌肿瘤细胞核分割的经典选择,直接给核掩膜;MoNuSeg 覆盖多个器官的细胞核分割;NuInsSeg 更偏小目标密集场景。选型时只看“文件名带不带 breast”远远不够,我用三个标准过一遍:
第一,标注粒度是不是“细胞核边界”。要做实例分割或语义分割的细胞任务,必须选带像素级掩膜的数据集,区域级标签再标准也用不上。第二,染色风格和你待处理的病理图是否接近。不同医院、不同制片批次的 H&E 染色差异很大,公开集是浅染还是深染,直接决定要不要额外做染色归一化。第三,有没有配套的评测协议。很多公开集自带划分好的训练/验证/测试集和官方指标,比如 F1、Dice、AJI,跟着协议走,你的模型分数才能和别人横向比较。
| 关注点 | 选型检查项 | 常见误用 |
|---|---|---|
| 标注粒度 | 是否有细胞核级掩膜 | 拿组织分级标签当细胞分割标签 |
| 染色风格 | H&E 深浅、核质比 | 忽略染色差异导致跨集泛化差 |
| 评测协议 | 是否自带划分和指标 | 自己随意 split,分数不可比 |
选公开集只是第一步。实际项目里,公开集往往不够用——细胞形态、染色风格、癌与非癌比例都和你自己的临床场景有差距。这时候就得自建标注流程,这也是数据资产真正值钱的地方。
2.2 自建标注:从 WSI 切 patch 到细胞级勾画
病理科拿到的原始文件通常是 WSI,一张几十亿像素的 svs/ndpi 文件。整张图喂给语义分割模型不现实,显存放不下,而且细胞核在 40 倍物镜下才清晰。自建数据集的第一个动作,是按固定物理尺寸切 patch。常见做法是先用 OpenSlide 读 WSI,把“目标倍数下对应的像素尺寸”算出来,再按固定 stride 滑窗保存,只保留细胞密度达标的 patch,避免把大片空白背景也存进去。
import openslide import cv2 import numpy as np from pathlib import Path slide_path = "case_001.svs" out_dir = Path("patches/case_001") out_dir.mkdir(parents=True, exist_ok=True) slide = openslide.OpenSlide(slide_path) # 40x 物镜下 patch 尺寸为 512x512,对应 0.25 微米/像素 target_mpp = 0.25 native_mpp = float(slide.properties[openslide.PROPERTY_NAME_MPP_X]) downsample = native_mpp / target_mpp level = slide.get_best_level_for_downsample(downsample) # 该 level 下实际放大倍数 scale = slide.level_downsamples[level] patch_size = 512 step = 256 # 步长 256,patch 之间 50% 重叠,保留上下文 for y in range(0, slide.level_dimensions[level][1], step): for x in range(0, slide.level_dimensions[level][0], step): # 读入后缩放到 0.25 mpp 分辨率 img = slide.read_region((int(x * scale), int(y * scale)), level, (patch_size, patch_size)) arr = np.array(img.convert("RGB")) # 简单过滤:细胞密集区域才保留,背景占比超过 80% 则丢弃 gray = cv2.cvtColor(arr, cv2.COLOR_RGB2GRAY) bg_ratio = np.sum(gray > 245) / (patch_size * patch_size) if bg_ratio > 0.8: continue cv2.imwrite(str(out_dir / f"patch_{x}_{y}.png"), arr)这段代码的逻辑有三个点要说明。第一,get_best_level_for_downsample是根据目标物理分辨率选最合适的金字塔层级,不要自己硬算 level,避免读出模糊影像。第二,read_region的坐标和尺寸必须以 level 0 为基准,代码里把x * scale传进去就是保证坐标基准不错位。第三,背景过滤用灰度阈值并非常规分割,只用来在存储阶段省空间和减少无效标注。step = 256这种重叠滑窗会在推理阶段造成重复预测,训练阶段倒没问题,能让模型在不同位置上见过同一细胞,相当于隐式增强。
切出 patch 后,标注工具我在用 QuPath 和 Labelme 之间切换。QuPath 适合先跑一个自动检测初稿再人工修正,Labelme 适合纯手动勾多边形。标注准则比工具更重要:细胞核边界以核膜连续处为准,核浆重叠的紧密接触细胞必须单独标实例,不能图省事画一个合并多边形。做语义分割时可以接受“核粘连区域合并为同一前景”,但一旦后面想切到实例分割,那些合并标注全部要返工。
2.3 数据集划分:按患者划分而不是按 patch 划分
这是自建数据集最容易被忽略、却又最影响模型可信度的一步。很多人随手用 sklearn 的train_test_split把几千张 patch 随机划分,结果同一个患者的上百个 patch 同时出现在训练集和验证集里。由于同一患者的切片在染色、组织背景、细胞形态上高度相似,验证集分数会虚高,等到换一家医院的数据测试,立刻翻车。
import pandas as pd from sklearn.model_selection import train_test_split df = pd.DataFrame({ "patch_path": ["patches/case_001/patch_0_0.png", "patches/case_001/patch_0_256.png", "patches/case_002/..."], "patient_id": ["case_001", "case_001", "case_002"] }) # 以患者为粒度划分:同一患者的所有 patch 全部进同一集合 unique_patients = df["patient_id"].unique() train_patients, val_patients = train_test_split( unique_patients, test_size=0.2, random_state=42 ) train_df = df[df["patient_id"].isin(train_patients)] val_df = df[df["patient_id"].isin(val_patients)] print(f"train patches: {len(train_df)}, val patches: {len(val_df)}")这里的参数只有一个random_state,但它的作用不只是可复现。种子固定后,后续增删 patch 或更换预处理流程,划分边界不会漂移,实验结果才能直接对比。test_size = 0.2是惯例值,但病理场景我更建议按“患者数”而不是“patch 数”来配比:如果总共只有 10 个患者,至少留 3 个做验证,哪怕这样验证 patch 数量变少。数据量实在不够时,宁可用 K 折交叉验证按患者分组,也别用随机划分来刷一个虚高的分数。
3. 把标注变成模型能吃的数据:格式转换与预处理
标注完成后,你手上是一堆 JSON 多边形,而模型需要的是和原图尺寸对齐的掩膜图。这一步的转换质量,直接决定后面语义分割训练是快速收敛还是反复炸 loss。这里有三件必做的事:多边形转掩膜、染色归一化、数据增强。前两件事做不好是硬伤,第三件事做过头是玄学,但都能在早期埋下肉眼可见的隐患。
3.1 从 polygon 到单通道掩膜:两个格式坑
Labelme 导出的 JSON 里,每个细胞的轮廓是一串多边形顶点。转换掩膜时最常见的两个坑:一是顶点坐标对应的图形尺寸和原图不一致,二是多边形没有按顺序排列导致填充区域错乱。前者一般是因为 QuPath 导出时坐标基准在 40 倍而原图存的是 20 倍;后者则要检查顶点排序是否逆时针连续。
import json import cv2 import numpy as np def json_to_mask(json_path, img_h=512, img_w=512): with open(json_path, "r", encoding="utf-8") as f: data = json.load(f) # 语义分割掩膜:前景为 1,背景为 0 semantic_mask = np.zeros((img_h, img_w), dtype=np.uint8) # 实例分割掩膜:每个细胞一个递增 id,从 1 开始 instance_mask = np.zeros((img_h, img_w), dtype=np.int32) for idx, shape in enumerate(data["shapes"], start=1): points = np.array(shape["points"], dtype=np.float32) # 关键:坐标可能带小数,必须转成 int 且检查是否在图像范围内 points = np.round(points).astype(np.int32) points[:, 0] = np.clip(points[:, 0], 0, img_w - 1) points[:, 1] = np.clip(points[:, 1], 0, img_h - 1) cv2.fillPoly(semantic_mask, [points], color=1) cv2.fillPoly(instance_mask, [points], color=idx) # 检查掩膜与原图是否对齐:打印细胞数量与前景占比 n_cells = instance_mask.max() fg_ratio = semantic_mask.mean() print(f"json: {json_path}, cells={n_cells}, fg_ratio={fg_ratio:.3f}") return semantic_mask, instance_mask这段代码保留了两份掩膜:语义分割只用 0/1 前景,实例分割用递增 id。参数里最值得留意的是np.clip,它把越界顶点拉回图像边界,防止fillPoly因为顶点自交或越界画出错误的形状。每次转换后打印fg_ratio是一个习惯,细胞核在 512x512 patch 里的面积占比一般在 5%–30%,如果某张图突然显示 0.5% 或 80%,几乎可以肯定是原图和标注对不上或者坐标基准错位,趁早回查标注比等训练完再排查省时间。
3.2 染色归一化:跨院泛化的第一道关
H&E 染色的深浅在每家医院都不一样,同一家医院不同批次的玻片也有肉眼可见的色差。模型在深紫色的数据集上训练,拿到浅粉色切片上推理,IoU 能掉十几个点。语义分割模型对颜色分布极其敏感,所以染色归一化不是锦上添花,而是细胞分割数据集预处理的标准动作。常见做法是 Macenko 或 Reinhard 算法,前者通过颜色反卷积分解染色矩阵,后者直接把目标图的均值和标准差匹配上来。
import cv2 import numpy as np def reihard_stain_norm(src_img, target_mean, target_std): """Reinhard 染色归一化:把 src 的颜色分布匹配到 target 的分布""" lab_src = cv2.cvtColor(src_img, cv2.COLOR_RGB2LAB).astype(np.float32) lab_target_mean = np.array(target_mean, dtype=np.float32) lab_target_std = np.array(target_std, dtype=np.float32) for c in range(3): ch = lab_src[:, :, c] ch -= ch.mean() ch *= (lab_target_std[c] / (ch.std() + 1e-6)) ch += lab_target_mean[c] lab_src[:, :, c] = np.clip(ch, 0, 255) return cv2.cvtColor(lab_src.astype(np.uint8), cv2.COLOR_LAB2RGB) # 目标统计量:从一张“理想染色”的参考图里算出来 ref_img = cv2.imread("reference_stain.png") ref_lab = cv2.cvtColor(ref_img, cv2.COLOR_BGR2LAB).astype(np.float32) target_mean = ref_lab.mean(axis=(0, 1)) target_std = ref_lab.std(axis=(0, 1)) norm_img = reihard_stain_norm(cv2.imread("case_patch.png"), target_mean, target_std)这段实现里有两个参数是必须人工盯住的:target_mean和target_std必须来自同一张参考图,且参考图要选染色标准、核边界清晰的切片;1e-6是防御性加项,防止某通道标准差为零时除零。注意测试阶段也要用同一份参考图统计量,不能训练用 A 图、测试又换 B 图,否则数据分布又变了。Macenko 比 Reinhard 更稳健,因为它在颜色反卷积空间操作,但实现也更长;工程上先用 Reinhard 跑通流程,效果不够再换 Macenko。
3.3 数据增强:哪些有效、哪些是玄学
细胞分割任务里,增强策略和其他自然图像任务不太一样。旋转、翻转、弹性形变是确定有效的,因为细胞核在切片里方向随机,组织在制片过程中也会有形变;而色彩抖动对 H&E 图像的增强效果受染色归一化影响,做过头直接失真。albumentations 是顺手且易用的选择,参数设置上有几个值得记住的边界。
import albumentations as A train_transform = A.Compose([ A.RandomRotate90(p=0.5), A.HorizontalFlip(p=0.5), A.VerticalFlip(p=0.5), A.ElasticTransform( alpha=1.0, sigma=50.0, alpha_affine=15, p=0.3, border_mode=cv2.BORDER_REFLECT_101 ), A.RandomBrightnessContrast(brightness_limit=0.1, contrast_limit=0.1, p=0.3), A.RandomGamma(gamma_limit=(80, 120), p=0.2), ], additional_targets={"mask": "mask"})这里RandomRotate90只做 90 度的整数倍旋转,避免了插值带来的边界模糊;ElasticTransform是细胞分割中最值得开的增强,alpha 控制变形幅度,1.0 是安全值,调太大会把细胞核扯成不自然的形状;亮度对比度限制在 0.1 以内,因为 H&E 染色本身对比度变化剧烈,再加强度容易毁掉核质边界信息。border_mode用BORDER_REFLECT_101,比默认补零好,不会在 patch 边缘产生黑色伪影干扰模型。增强不要什么都加,该舍弃的场景我会直接写进代码注释:高斯模糊、光学畸变、粗粒度的 Cutout 对细胞核这类毫米级小目标都是负优化。
4. 让语义分割模型在数据集上跑起来:YOLOv8 与 nnU-Net 两条路线
数据和预处理就绪后,选模型成了下一个决策点。细胞分割界没有“最好”的模型,只有最顺手的路线。YOLOv8 自带实例分割分支,适合工程落地和快速验证;nnU-Net 是医学影像语义分割的标配,自动化程度高,适合追求指标的学术验证。这两条路线的取舍逻辑,我会按从业者的视角讲清楚。
4.1 算法选型:语义分割还是实例分割
标题里的“细胞分割图片数据集”可以喂给两种模型:语义分割输出像素级前景/背景,实例分割还要区分每个细胞。绝大多数病理场景需要实例信息——细胞核的数量、密度、形态学特征都依赖实例边界。但语义分割也不该直接否定,它更适合后续做“区域分割”或“先分割再后处理”的流程。
| 模型路线 | 输出形式 | 后处理成本 | 对细胞粘连的适应度 | 工程化程度 |
|---|---|---|---|---|
| U-Net / nnU-Net | 逐像素类别 | 需分水岭等实例拆分 | 一般 | 中 |
| YOLOv8-seg | 轮廓掩膜 + 实例 id | 低,自带 NMS | 好 | 高 |
| Cellpose | 向量场 + 掩膜 | 中 | 好 | 中 |
我的经验是:纯粹做研究和指标对比,优先 nnU-Net;做产业落地或远程会诊系统,优先 YOLOv8-seg,因为它自带实例 id 和置信度,后处理链路短。Cellpose 是细胞分割专用模型,内置尺寸模型,但它的训练范式比较特殊,和标准分割数据集的组织方式不太一样,这里不展开。
4.2 用 YOLOv8 训练自己的数据集:最小命令集
YOLOv8-seg 训练细胞分割数据集,需要把掩膜转成 YOLO 格式的文本标注。常见做法是遍历每张掩膜图,用cv2.findContours提取每个实例的外轮廓,把归一化坐标写进 txt 文件。转换脚本逻辑不复杂,但有个细节:轮廓顶点过多会导致训练时数据加载变慢,可以先用cv2.approxPolyDP做轮廓简化。
# 组织数据目录 # datasets/breast_cell/ # train/images/ train/labels/ # val/images/ val/labels/ # breast_cell.yaml # breast_cell.yaml 内容 # train: datasets/breast_cell/train/images # val: datasets/breast_cell/val/images # nc: 1 # names: ['cell']然后用下面的命令训练:
yolo task=segment mode=train \ model=yolov8s-seg.pt \ data=datasets/breast_cell/breast_cell.yaml \ imgsz=512 \ epochs=100 \ batch=8 \ device=0 \ patience=20 \ project=runs/breast_seg \ name=exp1这几个参数值得仔细说。imgsz=512对应 2.2 节切 patch 的尺寸,不要在 512 的 patch 上强行训练 640,小目标反而会被放大。batch=8是 12G 显存的安全值;显存不够时优先关patience而不是降batch,因为早停被关掉后模型可能过拟合到后段。device=0指定单卡,多卡用0,1即可,但数据量不大时多卡收益有限。训练完成后用下面的命令验证:
yolo task=segment mode=val \ model=runs/breast_seg/exp1/weights/best.pt \ data=datasets/breast_cell/breast_cell.yaml \ imgsz=512YOLOv8 自带的 mask 指标会输出 mAP50-95 和 mask precision/recall。细胞分割场景里,mAP50 比 mAP50-95 更接近真实感受,因为细胞边界本身的标注误差就存在,过严的 IoU 阈值会低估模型能力。建议以 mAP50 和 mask recall 为主,mAP50-95 作为辅助参考。
4.3 nnU-Net 路线:三步命令与自动配置
如果目标是发论文或参加医学影像算法比赛,nnU-Net 是更省心的选择。它的核心逻辑是自动根据数据集的 spacing、图像尺寸和显存来制定训练计划,不需要手动调 batch、lr 这些超参。先把数据集按它的要求组织:imagesTr、labelsTr、imagesTs 目录,图像命名带 case 编号,标签为 png/ni 格式。
# 1. 规划与预处理:自动分析数据集并生成训练配置 nnUNetv2_plan_and_preprocess -d DATASET_ID -c 2d # 2. 训练:fold 0 用于快速验证,全量训练用 all nnUNetv2_train DATASET_ID 2d fold0 --np 4 # 3. 推理:指定训练好的模型和输入输出目录 nnUNetv2_predict -i /path/to/input_images \ -o /path/to/predictions \ -d DATASET_ID -c 2d -f fold0-c 2d表示 2D 训练,因为细胞分割的病理图天然是二维平面结构,用 3D 模式反而把 z 维度强行拉进来增加开销。--np 4是数据加载进程数,数值不要超过 CPU 核数的一半,否则预处理瓶颈会挡住 GPU 训练。nnU-Net 会自动做归一化和数据增强,在病理分割任务上往往能比手工调参的 U-Net 高出 3–5 个点 Dice,代价是训练时间更长、配置灵活性更低。先跑一遍 fold0 确认 pipeline 没报错,再启动全量训练,这是省时间的习惯。
5. 细胞分割数据集训练的五大坑:从掩膜错位到染色偏差
细胞分割数据集的训练过程,翻车点比自然图像任务多且隐蔽。下面这五条,每一条我都踩过:有的让 loss 一路不降,有的让验证集指标虚高到不敢相信。按“现象 → 原因 → 解决”写清楚,算是给后来者的一份排雷手册。
5.1 掩膜与原图尺寸对不上,模型 loss 不降
现象:训练 loss 在第一个 epoch 就卡在 0.6 上下不动,训练集上预测结果和标注明显错位,差几个像素到几十个像素不等。原因:最常见的是 WSI 切 patch 时read_region的坐标参考和标注导出的坐标参考不一致;QuPath 标注在 40 倍下进行,而切图用了 level 1 又没做坐标换算。解决:统一以 patch 图像保存的物理分辨率为基准,重新确认比例因子;在转换脚本里打印掩膜和原图的尺寸及前景分布,肉眼抽查三到五张图确认边缘对齐。
def assert_mask_alignment(image_path, mask_path, tolerance_px=2): img = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) if img.shape != mask.shape: raise ValueError(f"shape mismatch: {img.shape} vs {mask.shape}") # 取细胞核密度最高的区域做边缘对比 edges_img = cv2.Canny(img, 30, 100) edges_mask = cv2.Canny(mask, 30, 100) overlap = np.sum((edges_img > 0) & (edges_mask > 0)) union = np.sum((edges_img > 0) | (edges_mask > 0)) if union > 0 and (overlap / union) < 0.5: print(f"WARNING: {image_path} alignment suspicious")这段脚本的核心是tolerance_px边界容忍度,细胞核边缘本身就有标注者之间的人为差异,2–3 像素内都算正常,低于 0.5 的边界重合率就该回查。每次转换完标注跑一遍,能拦下大部分错位问题,比等到训练完再排查省几十个小时。
5.2 细胞像素占比太少,模型全预测背景
现象:Dice 在验证集上看起来还行,但预测图几乎全黑,只有零星几个白点。原因:细胞核在 512x512 的图像里占比普遍只有 5%–15%,如果 loss 函数没有做类别均衡,模型学到的“最优解”就是把所有像素预测为背景。解决:用带权重的 Dice loss 或 Focal loss,同时配合 2.2 节的背景过滤,把前景占比过低的 patch 在标注阶段就删掉。
import torch import torch.nn.functional as F def weighted_dice_loss(pred, target, smooth=1.0): # pred: [B, 1, H, W] 概率图,target: [B, 1, H, W] 二值掩膜 pred = torch.sigmoid(pred) intersection = (pred * target).sum(dim=(2, 3)) union = pred.sum(dim=(2, 3)) + target.sum(dim=(2, 3)) dice = (2 * intersection + smooth) / (union + smooth) # 对前景占比小的样本加大权重 foreground_ratio = target.mean(dim=(2, 3)) weight = torch.where(foreground_ratio < 0.1, 1.5, 1.0).unsqueeze(1) return (1 - dice) * weight.mean()smooth设为 1.0 是数值稳定性考虑,不建议完全置 0。weight的 1.5 倍是我在细胞分割上多次试出的平衡值;如果你用 U-Net 类模型,直接在损失函数里这样加权即可,不用改网络结构。如果换用 YOLOv8-seg 路线,它的 loss 自带前景权重,一般不需要额外处理。
5.3 polygon 自交或不闭合,转 mask 时轮廓消失
现象:标注的细胞在 JSON 里看是完整的,转出来的掩膜却缺了一个角,甚至整个实例没填充。原因:多边形顶点不严格按照顺/逆时针排列,有自相交;或顶点之间存在微小缝隙,fillPoly在处理异常轮廓时直接跳过。解决:在转换脚本里加一步shapely.make_valid修复几何,再重新提取外环顶点。
from shapely.geometry import Polygon from shapely.validation import make_valid def repair_polygon(points): polygon = Polygon(points) if not polygon.is_valid: polygon = make_valid(polygon) # 取最大外环多边形,排除内部孔洞干扰 repaired = polygon.convex_hull coords = list(repaired.exterior.coords) return np.array(coords, dtype=np.int32)make_valid是 shapely 里的修复入口,它会自动处理自交和坐标折叠,但会引入凸包化,导致原本凹陷的细胞边界被拉平。所以这条修复只作为兜底,转换后要逐张抽查掩膜和原图的贴合度。真正干净的方案是在标注阶段要求标注员闭合多边形,只有少数历史数据需要靠这种脚本挽救。
5.4 在公开集训练很好,在自己的数据上翻车
现象:在 TNBC 上能到 0.85 Dice,一换自己医院的切片直接掉到 0.6。原因:染色风格、切片厚度、核形态分布都不一样,这是典型的域漂移。解决:先用 3.2 节的染色归一化把公开集和自有数据拉到同一颜色空间,再在训练时把公开集数据按一定比例混合进来;如果数据量允许,用自己的数据做微调是见效最快的方式。
# 假设公开集在 public/,自有数据在 private/ # 混合训练时按 1:1 比例交替采样 # 每个 epoch 内:一半 batch 来自 public,一半来自 private python train.py --public-dir datasets/public \ --private-dir datasets/private \ --mix-ratio 0.5mix-ratio是混合训练时的关键参数。0.5 对半混合适合私有数据量不足的情况;私有数据量充足时升到 0.8 以上。同时注意两张图不要来自同一个患者,避免域内数据高度重合造成过拟合。
5.5 相邻细胞融合成一个实例,评估分数上不去
现象:预测结果里紧密接触的细胞核被模型画成一个整体,实例 id 数量比实际少很多,AJI(Aggregated Jaccard Index)分数只有 0.5 出头。原因:语义分割 loss 只看像素级重叠,不看实例边界;两个细胞核的膜在低分辨率下贴在一起,模型分不开。解决:切换到实例分割模型,或在原有预测上做分水岭后处理。
import cv2 def split_touching_cells(binary_mask, min_distance=3): # 距离变换后做分水岭:把粘连的细胞核切开 dist = cv2.distanceTransform(binary_mask, cv2.DIST_L2, 5) _, markers = cv2.connectedComponents(binary_mask) markers = markers.astype(np.int32) # 给背景留标记,否则分水岭会把整个边界都当盆地 markers[binary_mask == 0] = 0 ws = cv2.watershed(cv2.cvtColor(binary_mask * 255, cv2.COLOR_GRAY2BGR), markers) return wsmin_distance是分水岭切割的最小细胞间距,设得越小,越能在密集区域切出实例,但也越容易把单个细胞一切两半。这个参数需要根据标注数据的平均核直径来调,细胞核直径 10–15 像素时,3 是安全的起点。分水岭是语义分割输出的常见补救方案,绕不开,但也不是万能的——细胞核膜完全重叠时,靠后处理永远不如换个实例分割模型。
6. 评估与进阶:别只看 Dice,细胞级指标才是关键
细胞分割的评估比自然图像分割更苛刻。Dice 只反映像素级重叠,两个预测实例合并成一个时,Dice 可能只降几个点,但临床上这种错误不可接受。业内更看重 AJI 和 PQ(Panoptic Quality)这类实例级指标。AJI 对“合并错误”的惩罚极其严厉,两个核被预测成一个,整个实例的匹配就失败,分数直接掉一截。
def compute_aji(pred_mask, gt_mask): """Aggregated Jaccard Index:实例级重合度""" pred_ids = np.unique(pred_mask)[1:] gt_ids = np.unique(gt_mask)[1:] matched_preds = set() total_inter, total_union = 0, 0 for g in gt_ids: gt_area = gt_mask == g best_iou, best_pred = 0, None for p in pred_ids: if p in matched_preds: continue inter = np.sum(gt_area & (pred_mask == p)) union = np.sum(gt_area | (pred_mask == p)) iou = inter / union if union > 0 else 0 if iou > best_iou: best_iou, best_pred = iou, p if best_pred is not None and best_iou > 0.5: matched_preds.add(best_pred) total_inter += np.sum(gt_area & (pred_mask == best_pred)) total_union += np.sum(gt_area | (pred_mask == best_pred)) else: total_union += np.sum(gt_area) unmatched_pred = np.sum(np.isin(pred_mask, list(set(pred_ids) - matched_preds))) total_union += unmatched_pred return total_inter / total_union if total_union > 0 else 0iou > 0.5是匹配阈值,和 YOLO 的 mAP50 对齐,是病理分割领域默认选择。AJI 的实现细节在各论文中略有差异,但核心思想一致:未匹配的真值实例和预测实例都要计入分母,只有匹配成功的交集计入分子,因此合并错误和分裂错误都会被严重惩罚。
进阶方向上,我的建议是伪标签半监督。细胞分割的瓶颈几乎都在标注量,用训练好的模型在高置信度区域生成伪标签,再把伪标签和真实标注混合训练第二轮,通常能在不增加标注成本的情况下提升 2–3 个点 AJI。操作时要控制伪标签占比,建议不超过 30%,且只保留模型预测中实例内部置信度大于 0.9 的细胞核。另一个进阶方向是 WSI 全片推理,直接对整个 WSI 预测时要注意 patch 之间重叠区域的预测不一致,用重叠区域平均或几何平均融合比简单拼接更稳。
我第一次做乳腺癌细胞分割时,只盯 Dice,模型跑出 0.9 就以为能上了,直到病理医生指出“这个细胞被分成了两半,这个和旁边那个又没分开”。后来换成 AJI 和 PQ 做验收,模型才真正变得可用。指标选得对不对,决定了你整个 pipeline 值不值得信。希望帮到你。
本文还有配套的精品资源,点击获取