简介:ST-Net是一个面向组织病理学图像与空间转录组联合分析的开源实现,主要服务计算病理、肿瘤微环境与空间组学研究的算法工程师、科研人员。该模型直接从苏木精-伊红染色全切片出发,预测空间基因表达与乳腺肿瘤形态,弥补常规病理分析缺乏分子层信息的短板。资源包共四十个文件,以三十三个Python脚本为主体,覆盖数据缓存、JPEG转金字塔切片、模型训练、交叉验证、聚类与可视化的完整链路;另有两个配置文件、一个Shell脚本及说明文档,并采用灵活的配置优先级机制,便于调整本地数据路径。压缩包仅六十四KB,结构轻量而清晰。目前已有七百一十二人学习下载,适合具备Python和深度学习基础、希望复现ST-Net的中高级学习者。研读代码可快速掌握多尺度切片读取、空间标签构建、模型拟合与评估等工程化写法,为迁移到自有数据集或改进网络结构提供可直接修改的参考实现。
1. 组织病理学图像的深度学习是什么:一张10亿像素的图,模型怎么“看”
组织病理学图像的深度学习,就是让神经网络替病理医生先把玻璃切片“粗筛”一遍。病理科扫出来的全切片图像(WSI)单张经常超过10亿像素,直接塞进CNN是不现实的;而传统特征工程又处理不了H&E染色下复杂的细胞形态和腺体结构。现在的主流做法很统一:用Python把WSI切成小patch、用深度学习模型训练patch级的分类或分割、再把patch级预测聚合成切片级结论。这篇文章就是一套能直接拷走的Python代码方案,从openslide读图到热力图输出全链路覆盖,并且会把那些让“训练AUC 0.98、验证AUC 0.65”的典型坑一次讲透。适合想做病理AI落地的算法工程师、病理科研究者,以及刚入手医学图像的学生。
2. 先读病理图像再写代码:WSI存储结构与Python读取方案
2.1 WSI金字塔结构与openslide:为什么不能直接cv2.imread
组织病理切片的数字化产物是WSI(Whole Slide Image),常见封装格式是.svs、.ndpi、.kfb。它和普通照片最大的区别在于分辨率。一张40倍物镜扫出来的切片,宽高经常在100000×100000像素以上,RGB三通道直接解压就是几十GB,cv2.imread根本读不进来,就算读进来也没有哪张显卡能直接吃下。
所以WSI在存储时普遍采用金字塔结构:底层是最高分辨率原图,往上每一层是逐级降采样的缩略图。读取的时候按坐标从指定层级取一块矩形区域即可,不需要全文载入。PyPI上的openslide-python库就是干这件事的事实标准,底层封装了C++的OpenSlide,支持绝大多数病理扫描仪厂商格式。
我在Windows和Linux上都部署过这套方案,实际项目里最常用的就是两个Python包:openslide-python负责读取,numpy负责把读出来的图像块转成模型能输入的数组。tiffslide是它的一个纯轮子替代品,如果你的环境装不上openslide系统依赖,可以试试tiffslide,但默认首选还是openslide,社区资料最多。
2.2 最小代码:用openslide把一整张切片切成可用patch
先看一段我常用的切patch代码,它做的事情是:打开WSI、读取元数据、按给定步长滑窗、剔除空白区域、把patch存成npy或图片。下面这段建议直接存成dump_patches.py:
import os import numpy as np import openslide import cv2 from tqdm import tqdm def tissue_mask_ratio(patch_rgb, threshold=0.8): """计算patch中有组织区域的比例,用于滤除空白背景。 把RGB转到灰度,再统计像素值大于15(深色组织)的比例。 """ gray = cv2.cvtColor(patch_rgb, cv2.COLOR_RGB2GRAY) tissue = (gray > 15).astype(np.uint8) return tissue.mean() def dump_patches(slide_path, save_dir, patch_size=512, stride=512, level=0): slide = openslide.OpenSlide(slide_path) w, h = slide.level_dimensions[level] os.makedirs(save_dir, exist_ok=True) idx = 0 for y in range(0, h, stride): for x in range(0, w, stride): # 防止最后一行越界,直接跳过不完整patch if x + patch_size > w or y + patch_size > h: continue patch = np.array(slide.read_region((x, y), level, (patch_size, patch_size))[:, :, :3]) if tissue_mask_ratio(patch) < 0.2: continue # 空白区域直接跳过,可省下大量磁盘空间 np.save(os.path.join(save_dir, f"{idx:06d}_x{x}_y{y}.npy"), patch) idx += 1 slide.close() print(f"保存patch数: {idx}") if __name__ == "__main__": dump_patches("case01.svs", "./patches/case01", patch_size=512, stride=512)这段代码有两个关键参数。第一个是level,病理图像层级从0开始计数,0代表最高分辨率。如果只是做肿瘤区域粗筛而不需要细胞细节,用level=1或level=2把图像缩小4倍或16倍,patch数量直接少一个量级,训练速度天差地别,AUC通常不会掉太多。第二个是stride,等于patch_size时patch之间无重叠,信息量最小但训练最快;要提升模型对边界的稳健性,用256的步长配合512的patch,也就是50%重叠,代价是patch数量翻倍。
read_region返回的是PIL.Image对象,而且有RGBA四个通道,转numpy时取前三通道。这里有一个容易忽略的细节:很多病理切片在scan时会把背景扫成白色,但也有的扫成灰色甚至偏蓝,判断空白不能用“等于白色”,用灰度大于15或灰度大于30这种低阈值更稳妥。具体数值你可以看一张patch统计灰度直方图再定。
2.3 染色差异是第一个真坑:用staintools做染色归一化
H&E染色的切片不是一个稳定的视觉输入。不同医院的染色流程不同,同一台机器不同批次也会有色差。深度学习对这类色差极其敏感,模型很容易学到“这家医院的切片偏紫→阳性,另一家偏粉红→阴性”这种伪特征。处理染色差异的常见做法是染色归一化。
目前最常用的是Macenko方法和Reinhard方法。staintools库把Macenko的实现封装得很简洁,用法如下:
import staintools # 先用参考切片计算"标准染色向量",再对所有patch做变换 ref = staintools.read_image("reference.png") # 找一张染色均匀的典型切片 ref = staintools.LuminosityStandardizer.standardize(ref) normalizer = staintools.StainNormalizer(method="macenko") normalizer.fit(ref) patch = staintools.read_image("input_patch.png") patch_norm = normalizer.transform(patch)实际工程里我不会对每个patch单独fit,而是固定一张质量好的reference计算染色矩阵,之后所有训练、验证、测试patch都用同一个normalizer变换,否则相当于给每个样本做了不同的数据扰动,模型学不到稳定的染色模式。还有一点要提前想好:推理阶段的临床切片和你训练集来自不同医院的话,特征分布很可能有偏移,此时要么在训练时加入染色增强(随机改变色相、饱和度、亮度),要么在部署前用新医院的切片做一次小样本微调。这是病理AI落地最现实的工程问题之一,比模型结构的选择更影响最终性能。
3. 用Python跑通第一个肿瘤分类模型:从patch到WSI标签
3.1 选模型还是选基座:ResNet、EfficientNet还是ViT
病理patch分类任务,模型选型没有太多玄学。我见过很多初学者上来就用ViT,结果小数据集上训练根本收不敛,然后反过来怀疑代码写错了。真实情况是:病理patch分类本质上还是图像分类,数据集规模决定模型复杂度。
- 数据量小于5万patch:ResNet-50或ResNet-34最稳,预训练权重好找,训练速度快,调参经验丰富。
- 数据量5万到20万:EfficientNet-B3/B4,计算效率高,能跑出比ResNet更好的AUC。
- 数据量超过20万且有充足GPU:可以考虑timm里的ViT-Small或EfficientFormer,但收益未必比ResNet大多少,推理成本却高不少。
我最近在做的项目里,ResNet-50用8万patch训练,肿瘤区域分类AUC能到0.93,换成ViT-Small性能持平,但显存占用和推理时间是ResNet的3倍以上。所以在病理领域,基座选择更实在的价值是稳定复现和快速迭代,而不是刷性能榜。
3.2 完整训练代码:Dataset、DataLoader、训练循环一次讲清
下面这份代码是病理patch二分类(肿瘤/非肿瘤)的最小可运行框架。我习惯把所有配置写在一个config.py里,方便复现和调参:
# config.py class Config: data_dir = "./patches" # patch目录 csv_path = "./labels.csv" # patch名, label model_name = "resnet50" # timm支持的模型名 num_classes = 2 batch_size = 64 lr = 3e-4 epochs = 30 input_size = 512 # patch缩放到该尺寸输入 num_workers = 8 seed = 42 device = "cuda"# dataset.py import os import numpy as np import torch from torch.utils.data import Dataset import cv2 class PatchDataset(Dataset): def __init__(self, df, input_size=512, augment=False): self.df = df self.input_size = input_size self.augment = augment def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.iloc[idx] patch = np.load(os.path.join(self.data_dir, row["patch_name"])) # shape: H, W, 3 patch = cv2.resize(patch, (self.input_size, self.input_size)) if self.augment: # 只用轻量增强:翻转+旋转,不使用随机裁剪,避免破坏组织结构 if np.random.rand() > 0.5: patch = patch[:, ::-1, :] if np.random.rand() > 0.5: patch = patch[::-1, :, :] k = np.random.randint(0, 4) patch = np.rot90(patch, k) patch = torch.from_numpy(patch).permute(2, 0, 1).float() / 255.0 label = torch.tensor(row["label"], dtype=torch.long) return patch, label训练循环有一个临床场景特有的提醒:病理patch尺寸我建议至少512×512或更大,128甚至224的patch会丢失太多组织纹理信息,尤其是判断腺体异型性时,局部结构上下文很重要。坐标数据已经在patch文件名里了,这一步不要丢。
3.3 弱监督聚合:patch预测怎么聚合成WSI级别结论
训练完patch分类模型后,面临的下一个问题很现实:一张WSI会切出几千个patch,医生要看的是整张切片“到底是不是癌”,不是一个patch一个结论。如果每个patch都预测一次再投票,模型会把少量强阳性patch淹没在大量阴性patch里。
常见做法是取patch预测概率的top-k均值,或者直接用max pooling。更正规一点的做法是多实例学习(MIL),CLAM这类方法已经在病理领域被广泛验证。它的思路是:一张WSI是bag,其中的patch是instance,用attention网络把实例特征加权聚合成bag特征,再做切片级分类。训练时只需要切片级标签,不需要patch级标注,这对病理项目太重要了,因为patch级标注的成本极高。
下面是一个极简的attention pooling代码片段,可直接替换简单的mean池化:
import torch.nn as nn import torch.nn.functional as F class AttentionPooling(nn.Module): """把若干patch特征聚合为WSI级特征。 输入: patch_feats [B, N, D],B为WSI数,N为patch数,D为特征维度 输出: wsi_feat [B, D] """ def __init__(self, feat_dim=512): super().__init__() self.attn = nn.Sequential( nn.Linear(feat_dim, 128), nn.Tanh(), nn.Linear(128, 1) ) def forward(self, patch_feats): attn_logits = self.attn(patch_feats).squeeze(-1) # [B, N] attn_weights = F.softmax(attn_logits, dim=1).unsqueeze(-1) wsi_feat = (patch_feats * attn_weights).sum(dim=1) return wsi_feat用attention pooling时,建议patch特征来自训练好的分类模型倒数第二层,而不是直接用分类概率。特征维度一般取512或1024,然后接一个线性分类头。训练时梯度要回传到特征提取层,所以在显存允许的情况下,需要加载整个模型的权重做端到端微调。如果GPU不够,就冻结backbone只训练attention和分类头,效果会稍差一点,但非常省显存。
4. 数据与标签工程:病理深度学习的成败不在模型在数据
4.1 按患者切分、K折交叉验证:防数据泄漏的代码与习惯
病理数据有一个常规视觉任务里少见的泄漏来源:同一患者的切片或patch高度相似。如果你按patch随机划分训练集和验证集,同一张切片的patch会同时出现在两边,模型实际上等于见过答案,验证AUC会虚高得离谱。这也是“训练0.98、验证0.65”最常见的根源。
正确做法是保证任何一个患者的所有patch只出现在同一个数据折中。sklearn的GroupKFold就是干这个的,分组依据是患者ID或WSI文件名:
from sklearn.model_selection import GroupKFold # df里必须包含列: patient_id df = load_dataset() gkf = GroupKFold(n_splits=5) for fold, (train_idx, val_idx) in enumerate(gkf.split(df, y=df["label"], groups=df["patient_id"])): train_df = df.iloc[train_idx] val_df = df.iloc[val_idx] # 断言患者不交叉 assert not set(train_df["patient_id"]) & set(val_df["patient_id"]) train_df.to_csv(f"fold{fold}_train.csv", index=False) val_df.to_csv(f"fold{fold}_val.csv", index=False)这里有个工程习惯要养成:任何划分代码里都加断言,防止上游数据变更后悄悄产生泄漏。如果数据集是TMA组织微阵列,还要额外注意同一患者可能有多块组织芯,患者ID必须精确到个体,不能把同一患者的不同组织芯分到不同折。
4.2 类别不平衡与硬负样本挖掘:TMA和WSI里的阳性比
病理切片中肿瘤区域往往只占一小部分。以肺腺癌的WSI为例,肿瘤区域可能只有5%到15%,其余都是正常肺泡和间质。如果直接按patch采样训练,模型会严重偏向阴性。常见处理方式有三种:
第一种是重采样。在Dataset里控制采样权重,让阳性patch和阴性patch的比例接近1:1到1:2,而不是让模型看原始分布。第二种是loss加权,给阳性样本更高的权重,公式很简单:weight = (1 - class_freq) ** 0.5。第三种是Focal Loss,对难分样本天然鲁棒,在病理数据上效果通常优于CE Loss。
还有一个我在临床上验证过多次的策略:硬负样本挖掘。第一轮训练完,把验证集中预测置信度最高的错误阴性patch挑出来,加进第二轮的训练集。这些patch往往是形态学上很接近肿瘤的炎症细胞或增生区域,模型正是靠这些“刁钻”样本才把假阴性压下来。这个策略比无脑堆数据更有效,因为病理数据的标注瓶颈通常不在数量而在质量。
4.3 标注策略:全标注、弱标签还是多实例学习
病理AI的标注决策会直接影响项目周期。大病种如乳腺癌、前列腺癌,公开数据集和已有模型较多,直接用迁移学习即可。但临床应用场景常常是冷门染色或罕见亚型,这时标注策略要从头考虑。
逐patch标注是成本最高的方案,一名病理医生标一张含500个patch的切片大约需要1到2小时,且主观性很强。弱标签方案只标注切片级别的诊断结论,是医院HIS系统里现成的信息,不需要额外花费医生时间,然后用MIL框架训练。这个方案在多数癌种分类任务中能达到全标注模型80%到95%的效果。
我的建议是:上项目之前先问病理科要“历史诊断报告”,这些报告就是现成的弱标签。与其花两个月标patch,不如先用弱标签跑通baseline,再根据bad case让医生重点标注几十张最难的切片。这样医生的工作量小,模型的提升也比较精准。另外,标注模板最好做成“勾选主要诊断+次要发现”的结构化表单,不要直接让医生在整张图上画区域,收效会高很多。
5. 病理深度学习避坑清单:5个让人白干一个月的血泪问题
5.1 现象:训练AUC 0.98,测试只有0.65
这是我被问得最多的一个现象。模型在训练集和验证集上表现优秀,换到外院数据集直接崩盘。
原因通常出在数据划分泄漏或数据源单一。patch级随机划分让同一WSI的patch出现在两个集合里,模型其实“见过”验证集。另一种情况是训练集全部来自同一台扫描仪或者同一家医院,模型把染色风格当成判别特征。
解决:严格按患者做GroupKFold,并且跨中心评估。如果项目目标是跨院泛化,训练阶段必须有至少两个不同来源的数据,或者对数据做染色增强。在训练集上可以留一小部分模拟“外院风格”的patch做对抗验证,判断模型学的是形态还是颜色。
5.2 现象:模型把染色差异当特征,热力图分布很怪异
训练过程一切正常,但热力图显示某些区域被激活,肉眼看去和肿瘤形态完全无关,反而和染色深浅区域吻合。
原因是H&E染色在不同批次之间变化很大,模型在特征层面混淆了染色与形态。这类模型在你自己的测试集上可能不露馅,因为训练和测试切片来自同一批染色。
解决:训练前对所有patch做染色归一化,同时引入染色增强。增强不要只做颜色抖动,更有效的是用staintools随机生成不同染色风格。具体操作是随机从训练集中挑3到5张参考图,然后对每个训练patch随机选择一个参考图做归一化。这样模型见过的“染色分布”大幅扩增,对色差的稳健性会有质的提升。
5.3 现象:训练时显存爆炸,OOM频繁出现
病理patch动辄512×512甚至1024×1024,加上batch size稍大,显示内存就崩了。
原因:输入尺寸过大和batch过大叠加,backbone的中间特征图占满了显存。
解决:显存不足时按“先降batch、再降输入尺寸”的顺序调整。batch降到16仍不够时,把patch缩放到384×384训练,推理时再用512或更高分辨率。另一个非常有效的办法是混合精度训练,PyTorch中只需要加一行:
scaler = torch.cuda.amp.GradScaler() with torch.cuda.amp.autocast(): outputs = model(images) loss = criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()梯度累积也能解决显存瓶颈,概念上就是攒了几个batch的梯度再更新一次权重,效果等价于增大了batch,代码上也不复杂。如果显存只有8G,建议直接选用ResNet-18或者EfficientNet-B0做基座,512×512输入、batch为8,再加上混合精度,是可以跑通的。
5.4 现象:模型几乎把所有patch都预测为阴性
遇到这类情况,病理科同事会抱怨不如他直接看。模型收敛到全部输出阴性,在医学图像分类里非常典型。
原因是类别极度不均衡,阳性patch占比可能只有3%,交叉熵损失被阴性主导,梯度方向对阳性类几乎不更新。另一个潜在原因是训练时用了默认的随机采样,每个batch里可能全是阴性patch。
解决:自定义WeightedRandomSampler,让每个batch的阳性比例维持在40%左右;或者直接使用Focal Loss。我通常在重采样基础上仍然使用CE Loss,因为Focal Loss的超参(γ、α)需要花时间调,看诊数据不充足时不如重采样可靠。
5.5 现象:同一份数据每次训练AUC波动超过0.03
模型训练三遍,每次验证结果都不同,报告也没法写。
原因是训练样本量相对模型容量偏小,随机初始化权重和数据加载顺序对最终结果影响变大;PyTorch的增强、dropout、多线程加载也会引入随机性。
解决:固定随机种子并用torch.backends.cudnn.deterministic=True;或者更实用的做法是直接报告多次独立训练的平均值和标准差,这个信息对审稿和临床应用反而更有价值。我现在的习惯是每次提交结果都跑三个seed,报告“AUC均值±标准差”,跨项目复用下来,基本没有为复现性问题返工过。
6. 把模型落回病理用途:推理、热力图与报告产出
6.1 用训练好的模型跑全切片推理并输出热力图
模型训练完,最直观的产出是把肿瘤概率叠加回WSI上,生成一张病理医生看得懂的“概率热力图”。推理时按切patch相同的坐标滑窗,每个patch得到阳性概率,然后拼回原图坐标。为了消除patch边缘的跳变,我推荐使用“高斯加权拼接”或按步长overlap后取平均。下面是一份基于步长取均值的热力图生成代码:
def infer_wsi(slide_path, model, patch_size=512, stride=256, scale=32): slide = openslide.OpenSlide(slide_path) w, h = slide.level_dimensions[0] # 热力图按原图缩小scale倍,reduce内存 map_w, map_h = w // scale, h // scale heatmap = np.zeros((map_h, map_w), dtype=np.float32) count = np.zeros((map_h, map_w), dtype=np.float32) for y in range(0, h - patch_size, stride): for x in range(0, w - patch_size, stride): patch = np.array(slide.read_region((x, y), 0, (patch_size, patch_size))[:, :, :3]) patch = cv2.resize(patch, (model_input_size, model_input_size)) patch_tensor = preprocess(patch).unsqueeze(0).to(device) with torch.no_grad(): prob = torch.softmax(model(patch_tensor), dim=1)[0, 1].item() # 将概率写到热力图上对应的区域 x0, y0 = x // scale, y // scale x1, y1 = (x + patch_size) // scale, (y + patch_size) // scale heatmap[y0:y1, x0:x1] += prob count[y0:y1, x0:x1] += 1 heatmap = heatmap / np.maximum(count, 1) heatmap = cv2.applyColorMap((heatmap * 255).astype(np.uint8), cv2.COLORMAP_JET) cv2.imwrite("heatmap.jpg", heatmap)stride=256、patch_size=512意味着每个像素位置被预测了多次,重叠区域取平均,热力图平滑很多。scale=32是把热力图缩小32倍,一张5万×5万的WSI输出大约1600×1600的png,医生在浏览器里缩放查看足够了。如果直接把热力图叠加到原图上,文件会非常大,反而不好用。
6.2 与病理工作流衔接:patch坐标映射与报告生成
热力图只是中间产物,临床报告需要的是可追溯的区域坐标。所以推理时一定要保存patch的原始坐标和预测概率到CSV或JSON,方便病理医生在阅片软件里直接定位到具体区域:
import json results = [] for (x, y, prob) in patch_predictions: results.append({"x": x, "y": y, "prob": round(prob, 4)}) with open("predictions.json", "w") as f: json.dump(results, f, indent=2)坐标映射的坑在于level。如果你在level=1做了推理,那么坐标要换算回level=0就得乘以slide.level_downsamples[1];而read_region期望的坐标始终是level=0坐标系,一不小心就会把坐标用错导致区域错位。我吃过一次亏:用level=1推理,代码里忘了乘downsample,热力图整体偏移了一个视野,后来我加了坐标断言,在输出前抽样验证patch区域的坐标是否对得上原始图像的位置,才彻底解决。
最后留一个习惯:所有病理模型交付时,我都在报告里附上“适用边界”——比如只适用于福尔马林固定石蜡包埋的H&E切片、不适用于冰冻切片、不建议直接用于穿刺微活检。这些边界条件写清楚,比把AUC写得更漂亮更能维护模型在临床端的声誉。希望这篇代码方案能帮你少走一遍我走过的弯路,也希望你能尽早跑通自己的第一版病理深度学习流程。
本文还有配套的精品资源,点击获取