简介:ISBI 2015 数据集(上)面向医学显微图像分割、检测与目标追踪方向的学习者和研究者,提供一套可直接用于算法验证的带标注训练样本。训练集约160张图像并配有对应标注,测试集约160张因不含标注未纳入,适合复现实验、训练分割或检测模型,以及理解常见标注格式与数据组织方式。压缩包共234个文件,以228个PNG图像为主体,另含4个CSV表格、1个MATLAB脚本和1个TXT说明,整体约35.25MB;图像、表格与脚本相互配合,便于按帧或视野核对标注、检查数据完整性并搭建基线实验。已有509人学习,附带的标注显示脚本和表格可帮助快速查看标注结果,减少从原始图像到可训练数据之间的整理成本,也能为课程作业、论文复现和挑战赛预研提供现成素材。
1. 30 张切片、两套标签:这份电镜数据集到底难在哪
第一次拿到 ISBI 2015 这个数据集,我以为是"下下来、转成 npy、喂给 U-Net"三步走的事,结果在标签读成全黑、验证分数虚高、推理图一片灰这三个坑里各栽了一次。它属于序列切片电镜下的神经结构分割任务:每张 512×512 的灰度图,配一份膜(membrane)二值标签和一份对象/边界标签,规模小到单卡就能跑完整个流程,但每个环节都藏着"你不看参数就会翻车"的细节。这份数据集最适合两类人:一类是已经能跑通现成分割模型、但没亲手判断过数据质量的;另一类是做医学或生物影像、第一次碰各向异性电镜数据的。它不负责教你卷积怎么算,它负责逼你把"数据到指标"这条链路走通一遍。
2. 拆包先看参数:4 nm 像素、50 nm 层厚决定了后面所有设计
很多人拆完压缩包第一件事是cv2.imread然后直接 resize 到 256,这一步就把数据集最值钱的信息丢掉了。电镜序列切片和自然图像最大的差别不在纹理,而在于它有三个彼此独立的物理量:面内像素尺寸、层厚、灰度响应。这三个数不搞清楚,后面 patch 怎么切、网络下采样几次、2.5D 输入堆几层,全是拍脑袋。
2.1 三个物理量:像素尺寸、层厚、灰度位深
这份数据集的公开描述里,面内采样是 4 nm × 4 nm,切片层厚 50 nm,单张 512×512、8 bit 灰度。把它们换算一下就能得到几条硬约束:z 方向比 xy 方向粗 12.5 倍(50 / 4),所以你不能把相邻切片当成各向同性的三个通道去堆;细胞膜在 4 nm/px 下只有几个像素宽,任何一次 stride 为 2 的下采样都会让膜从"一条线"退化成"一层雾",四次下采样之后基本就没了;8 bit 灰度则意味着不同切片之间的整体亮度和对比度差异可以很大,因为切片本身的染色、成像条件并不完全一致。
| 参数 | 典型值 | 直接影响 |
|---|---|---|
| 单张尺寸 | 512 × 512 px | patch 大小上限、滑窗 stride 的取值空间 |
| 面内像素分辨率 | 4 nm × 4 nm | 膜仅数像素宽,下采样超过 2 倍基本报废 |
| 层厚 | 50 nm | z 向各向异性约 12.5 倍,2.5D 堆叠不能按 1:1 |
| 位深 | 8 bit 单通道 | 切片间对比度漂移,必须逐图归一化 |
| 标注 | 膜二值图 + 对象/边界图 | 两套标签语义不同,不能互相验证 |
我把这张表贴在显示器边上用了很久。它的价值不在于记住数字,而在于每次想改网络结构时能反问一句:这个改动会不会破坏膜的空间尺度先验。比如把输入 resize 到 128×128 图省事,膜宽就从 5 个像素变成 1.25 个像素,标签本身就接近噪声了,训练 loss 再低也没意义。
2.2 膜标签和对象标签不是一回事
打开训练集你会看到至少两类标注文件。第一类是膜标签,二值的,膜上为 1、其余为 0,它对应的是"像素级二分类"任务,可以直接拿来做 BCE 或 Dice 的监督目标。第二类是对象/边界标签,它来自一次独立的人工分割,标的是不同神经结构之间的分界,语义上更接近"实例边界"。
新手最容易犯的错,是把这两套标签当成同一份标注的两种存法,然后拿膜标签去校验对象标签,发现局部对不上就以为数据坏了。实际上它们本来就来自不同的标注过程,边缘处有 1~2 像素的不一致是正常的。正确的用法是:膜标签负责训练像素分类器,对象标签负责评估最终的实例分割质量(Rand 误差这类指标就建立在实例划分上)。训练时混用,等于给模型喂了两套互相矛盾的标准。
注意:如果你的流程里同时用到了这两套标签,务必在配置里显式区分字段名(比如
membrane_dir和object_dir),不要靠"哪个目录文件多"来猜。
2.3 训练/测试的切分方式,以及为什么要在本地再切一刀
公开划分里,训练部分是有图有标签的,测试部分只给图像,评分在官方侧完成。这意味着你在本地拿不到测试集的真值,所有"我这个模型大概能排多少"的判断都是猜的。常见的做法是从训练集里自己切一份 hold-out 出来做验证。
这里有个关键细节:必须按"连续切片块"切,不能随机抽单片。相邻切片之间的相关性极高——同一根神经突在连续几张里位置几乎不变,你随机抽 20% 做验证,剩下的训练集里几乎每一张都能在验证集里找到"近邻",验证分数会虚高十几个点。我一般按编号顺序切成三段,前两段训练、最后一段验证,或者反过来,保证验证集和训练集之间至少隔开若干张切片。代价是验证集样本变少、方差变大,但那个数字是真的。
3. 把数据读对:目录扫描、灰度归一化与标签对齐检查
第二章讲的是"应该怎么想",这一章讲"手上怎么动"。我把读取阶段拆成三步:先扫描确认文件层面没缺没重,再看灰度分布决定归一化方式,最后叠图肉眼核对标签和图像是否对齐。这三步加起来不到半小时,能省掉后面几天的排查。
3.1 先扫一遍目录,确认切片编号连续
序列切片数据最常见的隐性问题是缺片和编号不连续。缺一张,你的 2.5D 输入就跨越了一个不存在的物理间隔;编号重复,你的训练集里就混进了重复样本。所以第一段代码不读像素,只读文件名。
from pathlib import Path import re root = Path("data/isbi2015_em") # 换成你解压后的实际根目录 def sid(p: Path) -> int: """从文件名里抠出切片编号,用于排序与连续性检查""" nums = re.findall(r"\d+", p.stem) return int(nums[-1]) if nums else -1 def scan(split: str, sub: str): d = root / split / sub return sorted(d.glob("*.png"), key=sid) if d.exists() else [] for split in ("train", "test"): imgs = scan(split, "image") ids = [sid(p) for p in imgs] print(f"[{split}] 图像数={len(imgs)} 编号 {ids[0]}~{ids[-1]}") gap = sorted(set(range(ids[0], ids[-1] + 1)) - set(ids)) if gap: print(" 缺片编号:", gap) # 2.5D 堆叠前必须处理 dup = [i for i in set(ids) if ids.count(i) > 1] if dup: print(" 重复编号:", dup)这段代码只做一件事:把"文件系统里有什么"变成"数据在物理上是否连续"。sid取文件名里最后一个数字段,是因为不同来源的文件命名前缀不一样,但末尾通常是切片序号。gap那段是重点——一旦发现缺片,后面做 2.5D 堆叠时就必须用"最近有效切片"回填,而不是简单地i-1, i, i+1直接索引,否则你会在不知不觉中把两张相隔很远的切片当成相邻层喂给网络。
3.2 灰度分布:不要假设所有切片长一个样
第二段代码看灰度。这里最典型的坑是 PNG 的存储模式:有的文件是 8 bit 灰度(L),有的是调色板(P),有的是 16 bit(I;16),还有的被存成了三通道 RGB。直接np.asarray(Image.open(p))拿到的东西,可能是索引值而不是亮度值,也可能是 shape 为(H, W, 3)的数组。
import numpy as np from PIL import Image def read_gray(path): im = Image.open(path) if im.mode != "L": # P 调色板 / RGB / I;16 一律转 8bit 灰度 im = im.convert("L") return np.asarray(im) def read_mask(path): a = read_gray(path).astype(np.float32) # 有的标签是 0/1,有的是 0/255,统一成 0/1 return (a > 127).astype(np.float32) if a.max() > 1 else (a > 0).astype(np.float32) for p in scan("train", "image")[:8]: a = read_gray(p) print(p.name, a.shape, a.dtype, "mean=%.1f std=%.1f p1=%.0f p99=%.0f" % (a.mean(), a.std(), np.percentile(a, 1), np.percentile(a, 99)))跑完这一段,你会看到各切片的mean和std并不一致,p1/p99的区间宽度也不一样。这就是为什么必须逐图归一化,而不是对全数据集算一个全局均值方差。我一般用逐图 z-score:
def normalize(a, eps=1e-6): a = a.astype(np.float32) return (a - a.mean()) / (a.std() + eps)eps是防除零的,别省。这个函数有个隐性要求:训练和推理必须调用同一个实现。我见过太多"训练时 z-score、推理时除以 255"的组合,表现就是训练曲线漂漂亮亮,一推理整张图糊成灰的。
3.3 叠图核对:三张图错半像素,后面全白干
最后一步是肉眼检查。把图像、膜标签、对象标签叠成一张 RGB 图看一眼,能发现 90% 的对齐问题。
import matplotlib.pyplot as plt def overlay(img, mem, obj): rgb = np.stack([img] * 3, -1).astype(np.float32) / 255.0 rgb[..., 0] = np.clip(rgb[..., 0] + 0.6 * mem, 0, 1) # 膜染红 rgb[..., 2] = np.clip(rgb[..., 2] + 0.6 * obj, 0, 1) # 对象边界染蓝 return rgb i = 0 img = read_gray(scan("train", "image")[i]) mem = read_mask(scan("train", "label")[i]) obj = read_mask(scan("train", "object")[i]) plt.imshow(overlay(img, mem, obj)); plt.axis("off"); plt.show()判断标准很朴素:红色的膜线应该压在图像里那些深色的细线上,蓝色的边界应该落在两个亮区之间的暗带里。如果红线和暗线整体偏移了一两个像素,说明标签和图像在制作流程中被错位过(比如裁剪窗口不一致),这种数据要么修,要么整片丢掉,绝不能"训练时让网络自己学"。我吃过这个亏:一批错位两像素的样本混进去,模型收敛得挺好,但预测出来的膜永远是"双线",指标怎么调都上不去。
4. 训练侧的最小闭环:patch 采样、正样本加权与滑窗推理
数据读对之后,剩下的就是把一个能被验证的最小闭环搭起来。这一步我倾向于先把所有花哨的东西砍掉:不加注意力、不做多尺度、不用复杂后处理,只用最朴素的 patch 训练 + 滑窗推理,先拿到一个能复现的基线分数。基线站住了,后面每一步改动才有对照。
4.1 为什么不整图送进去
512×512 听起来不大,但麻烦在三点。第一,样本量太少。训练集就几十张切片,整图训练等于每轮只有几十个梯度步,模型几乎必然过拟合到纹理和亮度上,泛化到没见过的切片就崩。第二,显存和感受野不匹配。膜分割需要的是局部上下文——判断一个像素是不是膜,看周围几十个像素足够;整图训练时大部分计算花在了无关区域。第三,patch 本身就是一种数据增广,同一张图能切出上百个位置不同的样本,等于把数据集放大了一个量级。
我的默认配置是 patch 256×256、batch 8~16。这个尺寸下膜的空间尺度(数像素宽)和感受野(百像素级)比较匹配,显存也宽裕到能开混合精度。
4.2 采样器:一半 patch 必须落在膜上
膜像素在整张图里占比并不高,纯随机切 patch,有相当比例的样本里几乎全是背景。网络看多了这种样本,会学到一个非常"划算"的退化解:全部预测为 0,像素准确率依然很高,F1 却接近 0。所以采样器必须做正样本偏置。
def sample_patch(img, msk, size=256, pos_ratio=0.5, rng=None): rng = rng or np.random.default_rng(0) H, W = img.shape if rng.random() < pos_ratio: ys, xs = np.nonzero(msk > 0) # 以膜像素为中心 if len(ys) == 0: cy, cx = rng.integers(H), rng.integers(W) else: k = rng.integers(len(ys)) cy, cx = ys[k], xs[k] else: cy, cx = rng.integers(H), rng.integers(W) # 保留一半纯随机,维持背景分布 y0 = int(np.clip(cy - size // 2, 0, max(H - size, 0))) x0 = int(np.clip(cx - size // 2, 0, max(W - size, 0))) return img[y0:y0 + size, x0:x0 + size], msk[y0:y0 + size, x0:x0 + size]pos_ratio是核心参数,我先用 0.5,观察验证集上的召回率:召回偏低就往上调到 0.6~0.7,精确率掉得厉害就回调。另一半纯随机采样不能省,否则模型会忘记背景长什么样,预测时到处"幻觉"出膜。np.clip那两行保证贴边时不会越界,代价是边缘区域的采样中心被压缩,如果边缘样本重要,可以改成 reflect padding 之后再切。
配合采样器的损失函数,我一般用 BCE + Dice 的组合:
import torch import torch.nn.functional as F def bce_dice(logits, target, pos_weight=8.0): pw = torch.tensor(pos_weight, device=logits.device) bce = F.binary_cross_entropy_with_logits(logits, target, pos_weight=pw) p = torch.sigmoid(logits) inter = (p * target).sum() dice = 1.0 - (2 * inter + 1.0) / (p.sum() + target.sum() + 1.0) return bce + dicepos_weight从 8 起,具体值取决于你统计出来的膜像素占比——占比越低,这个权重越大,但超过 15 之后训练会明显不稳,梯度会被少数正样本主导。+1.0是 Dice 的平滑项,防止某个 batch 里没有正样本时除零。
4.3 滑窗推理与拼接:stride、padding 和融合权重
训练用 patch,推理就必须回答"整图怎么拼"。最省事的做法是滑窗,每个位置预测一块,重叠区取平均。stride 取 patch 的 1/4 是我常用的折中:重叠足够多,拼接缝基本看不出来,计算量又是可接受的。
def sliding_window_predict(model, img, size=256, stride=64, device="cuda"): model.eval() H, W = img.shape x = np.pad(img, ((0, max(size - H, 0)), (0, max(size - W, 0))), mode="reflect") acc = np.zeros_like(x, dtype=np.float32) cnt = np.zeros_like(x, dtype=np.float32) with torch.no_grad(): for y in range(0, x.shape[0] - size + 1, stride): for xx in range(0, x.shape[1] - size + 1, stride): patch = torch.from_numpy(x[y:y+size, xx:xx+size])[None, None].float().to(device) prob = torch.sigmoid(model(patch))[0, 0].cpu().numpy() acc[y:y+size, xx:xx+size] += prob # 累加,不是覆盖 cnt[y:y+size, xx:xx+size] += 1.0 return (acc / np.maximum(cnt, 1e-6))[:H, :W]三个参数值得说清楚。size必须和训练时一致,训练 256 推理 512 会让 BatchNorm 统计量完全错位。stride越小越平滑但越慢,64 是 256 的 1/4,重叠 75%。mode="reflect"是贴边策略,用零填充会在图像四周造出一圈人为的暗边,网络会把它们当成膜。还有一点:这里用的是"累加再除以次数",不能用np.maximum做融合——max 融合会把每块 patch 的边缘高响应保留下来,拼出来的图有明显网格。
5. 排查清单:五个把我卡过半天的现场
这一章是我自己的错误日志,按"现象 → 原因 → 解决"记。如果你正在同一个坑里,直接对号入座。
5.1 标签读进来全是 0,mask.sum()等于零
现象:可视化时标签是纯黑,np.unique只有一个值,但用图片查看器打开明明能看到白色的膜线。原因:标签 PNG 存成了调色板模式(P),np.asarray拿到的是调色板索引而不是亮度;另一种情况是标签实际值域是 0/1,被后续的/255归一化直接压成了 0 和 0.004。解决:统一走Image.open(p).convert("L"),再用read_mask里那个a.max() > 1的判断决定除不除 255。加一条断言assert msk.max() > 0,在读数据的入口就把问题拦住,比训练三轮之后发现 loss 不降要省事得多。
5.2 验证集 0.97,换一批切片直接掉到 0.75
现象:本地验证 F1 高得离谱,换用另一段切片测试时断崖式下跌。原因:按单片随机划分训练/验证,相邻切片高度相关,验证集里的图和训练集里的图几乎"长得一样",模型实际上是在做记忆而不是泛化。解决:改成按连续切片块划分,验证集和训练集之间留出间隔。同时把验证指标从像素 F1 换成更能反映结构质量的指标(见最后一章),因为像素 F1 对"膜整体偏移一像素"这种系统性错误很不敏感。
5.3 训练 loss 正常下降,推理出来整张图一片灰
现象:验证时把预测概率图存成 PNG,肉眼看基本是均匀的中灰色,阈值化之后要么全 1 要么全 0。原因:归一化不一致。训练时的 Dataset 里做了逐图 z-score,推理脚本里图省事写了img / 255.0,输入分布差了十几倍,网络输出全部挤在 sigmoid 的饱和区。解决:把归一化封成一个函数,训练和推理都调它,绝不允许第二份实现。进一步的保险是保存模型时把归一化参数一起存进 checkpoint,加载时直接用。
5.4 大图拼接出现规则网格状的缝
现象:拼回整图后,在 stride 的整数倍位置能看到淡淡的方格。原因:重叠区用了覆盖写或者 max 融合;或者stride == size,patch 之间根本不重叠。解决:改成累加 + 计数平均(4.3 的写法),stride取size // 4。如果还有缝,说明 patch 边缘本身预测质量差,可以给每块 patch 乘一个中间高、四周低的权重窗再累加,能进一步压掉边界效应。
5.5 膜断成虚线,拓扑类指标崩得厉害
现象:预测的膜在视觉上"该连的地方断开了",像素召回不低,但基于连通性的指标很差。原因:阈值定在 0.5 是拍脑袋的,而模型输出的概率分布往往偏向保守;另外膜本身极细,训练时的下采样把细结构抹掉了,上采样也补不回来。解决:在验证集上扫一遍阈值(0.3 到 0.7,步长 0.05),挑指标最好的那个,别迷信 0.5;网络里减少一次下采样或者把第一层卷积的感受野调小;后处理加一个 3×3 的闭运算接掉小断点,代价是轻微增厚,需要和指标一起权衡。
6. 提交之前的最后两道关:本地指标和 TTA 自检
膜分割的像素 F1 只能说明"大概对",说明不了"结构对"。官方评分里有基于连通性和拓扑的指标,本地要近似的话,最实用的是把膜二值图取反后做连通域标记,把每个非膜区域当成一个"对象",然后算自适应 Rand 误差。
from scipy import ndimage as ndi def to_inst(mem_binary): return ndi.label(mem_binary == 0)[0] # 膜为边界,其余按连通域编号 def adapted_rand_error(pred_inst, gt_inst): table = np.zeros((pred_inst.max() + 1, gt_inst.max() + 1), dtype=np.float64) np.add.at(table, (pred_inst.ravel(), gt_inst.ravel()), 1) p = table / table.sum() a, b = p.sum(1), p.sum(0) return 1.0 - 2.0 * (p ** 2).sum() / ((a ** 2).sum() + (b ** 2).sum())这个实现用列联表算,复杂度跟像素总数线性相关,512×512 的图跑起来是毫秒级。to_inst里把膜当作 0 值来取连通域,是把"边界"转成"实例"的最省事做法,缺点是对膜上的小断口很敏感——一个像素的缺口就会让两个本应分开的区域连成一片,指标掉得比肉眼看出来的严重。所以实际用的时候我会同时看两个数:阈值扫描下的 F1,和这个 Rand 误差,两者同向变化才敢提交。
第二个关卡是 TTA(测试时增广)。切片内的旋转和翻转不改变数据的物理意义,四次 90° 旋转加一次水平翻转平均,通常能稳定带来一到两个点的提升,而且几乎不增加实现复杂度。
def predict_tta(model, img, **kw): outs = [] for k in range(4): # 4 个 90 度旋转 p = sliding_window_predict(model, np.rot90(img, k), **kw) outs.append(np.rot90(p, -k)) for k in range(4): p = sliding_window_predict(model, np.fliplr(np.rot90(img, k)), **kw) outs.append(np.rot90(np.fliplr(p), -k)) return np.mean(outs, axis=0)注意np.rot90还原时的方向:正向转 k 次,还原就要转 -k 次,写反了不会报错,只会让 TTA 变成"把四张不同方向的图平均",指标反而下降。上下翻转我一般不开,因为组织本身有明确的方向性,翻转后的样本在训练集里几乎不存在,属于分布外输入。
提交前的自检清单我固定成四条:归一化函数是否与训练完全一致、滑窗size是否等于训练 patch、阈值是否在验证集上扫过、TTA 的逆变换方向是否验证过。这四条我吃过其中三条的亏,尤其是第一条——那次调了整整两天,最后发现差异只有一行/255.0。从那以后,我每次搭新流程都会先把"读一张图 → 训一个 overfit 单样本 → 推理同一张图"这条最小链路跑通,确认输出能和输入对上,再往数据集上铺。这套习惯救过我很多次,希望帮到你。
本文还有配套的精品资源,点击获取