☰
ISBI 2015显微图像实例分割与追踪数据预处理及SEG评估
2026/10/24 5:43:32 网站建设 项目流程

简介:围绕 ISBI 2015 挑战赛训练数据整理的医学图像分割数据集,面向希望复现细胞或神经结构分割实验、练习标注解析与结果可视化的学习者,也可作为医学图像分割入门与数据整理参考。压缩包共 234 个文件,约 35.25MB,含 228 个 png 图像帧、4 个 csv 表格、1 个 m 脚本和 1 个 txt 说明;png 可作为模型输入与样本观察,csv 保存逐帧标注信息,文件名能与图像帧对应,m 脚本用于在 MATLAB 中叠加显示标注,便于检查图像与标注是否匹配。训练集约 160 张且带标注,测试集约 160 张因未提供标注而未上传。已有 509 人学习,可用于搭建数据读取、标注格式转换、训练验证划分与分割结果可视化流程,也能帮助理解该挑战赛的数据组织方式、标注质量检查方法和预处理要点,对后续模型评估与结果复现也有帮助,适合在复现实验前核对样本完整性。

1. 拿到 ISBI 2015 数据集,先别急着建模型

把 ISBI 2015 数据集的压缩包解开,多数人第一反应是找 train/val 目录,结果只看到一堆以 img、man_seg、man_track 命名的 tif。它面向的是显微图像里的细胞与神经元实例分割、时序追踪:给一段图像序列,要求逐帧分出每个独立个体,还要在帧与帧之间把同一个体串起来。这批数据规模不大,却把显微图像里最典型的几类问题都包括在内——16 位灰度、极强类别不平衡、目标互相粘连、还有分裂造成的标注跳变。适合两类人:一是刚接触显微图像分割、想找个不臃肿的公开数据把全流程跑通的新手;二是已经在做实例分割、想验证追踪和 lineage 逻辑的老手。这篇先把数据侧读透、切片切干净、指标对账清楚,模型结构留给后面。

2. 拆开目录看标注体系:img、GT、SEG、TRA 到底谁管什么

2.1 一个序列的目录骨架与文件命名规律

这类挑战赛数据集的通用组织形态大致是「一个序列一个目录,图像和标注分开放,标注再按任务拆子目录」。不同年份、不同子任务会有出入,但骨架基本一致,我一般拿到手先跑一遍tree,或者用十几行脚本把层级和文件数打出来,比在文件管理器里手动点要快得多。

# 只看两层目录,统计每类文件的个数,先建立全局印象 find ./data -maxdepth 2 -type d | sort find ./data -type f -name "*.tif" | sed 's#.*/##' | sed 's/[0-9]\+/#/g' | sort | uniq -c

第二条命令的关键是把文件名里的数字替换成占位符再统计,这样man_seg001.tif、man_seg002.tif会被归并成man_seg#.tif一条,你立刻能看出这个序列里有几种命名模式、各自的帧数对不对得上。常见的骨架长这样:

<序列名>/ ├── img/ # 原始图像序列,逐帧 tif ├── 01_GT/ │ ├── SEG/ # 逐帧实例掩膜 man_seg###.tif │ └── TRA/ # 追踪标注 man_track###.tif + man_track.txt ├── 01_ST/ # 银标准(如有) └── 01_RES/ # 结果回填目录,评估脚本从这里读

01_RES这个目录很多人会忽略,它其实是评估流程的一个约定入口:把预测结果按同样的命名规则写进去,评估脚本才能逐个序列对账。早点按这个约定组织输出,后面就不用写一堆格式转换的胶水代码。

2.2 SEG 与 TRA 的分工:实例掩膜和有向 lineage 图

SEG 和 TRA 最容易混淆。SEG 回答的是「这一帧里有哪些个体、各自占哪些像素」,TRA 回答的是「这个个体从第几帧活到第几帧、它的上一代是谁」。SEG 是一张单帧的实例掩膜图,背景为 0,每个实例用一个正整数 ID 标记;TRA 除了逐帧掩膜,还额外带一份文本索引,常见是四列:track ID、起始帧、结束帧、父 track ID(0 表示没有父代,即序列起点或从画面外进入)。

理解这个结构的关键在于:SEG 里的 ID 是帧内编号,换一帧可能就重排了;TRA 里的 ID 是跨帧的全局编号,同一个体的 ID 在整个序列里保持不变,分裂时子代拿新 ID 并把父 ID 写进第四列。这两套编号体系混用,是后面训练和评估出问题的头号来源。我一般会在读数据阶段就建立一张映射表:

文件 / 字段承载信息典型取值易错点
img/*.tif原始灰度图uint16,实际动态范围可能只占几百被当成 8 位读,全黑
SEG/man_seg###.tif帧内实例掩膜0 为背景,正整数为实例 IDID 不连续,直接当通道数会爆显存
TRA/man_track###.tif跨帧实例掩膜全局 track ID与 SEG 的帧内 ID 不同源
TRA/man_track.txtlineage 索引4 列,含父 ID忽略父列,分裂段追踪必崩

2.3 先做一次数据体检:帧数、位深、标注覆盖率

在写任何模型代码之前,我会先跑一份体检报告,把五个数字记下来:总帧数、图像 dtype、强度分位数、有标注的帧占比、每帧实例数分布。这五个数决定了后面的归一化方式、采样策略和损失权重,漏掉任何一个都可能要在训练三轮之后回头重来。

import glob import numpy as np import tifffile img_paths = sorted(glob.glob("./data/*/img/*.tif")) seg_paths = sorted(glob.glob("./data/*/01_GT/SEG/*.tif")) # 位深与强度分布:抽样 10 帧即可,别一次全读进内存 sample = np.stack([tifffile.imread(p) for p in img_paths[::max(1, len(img_paths)//10)]]) print("dtype:", sample.dtype, "shape:", sample.shape) print("min/max:", sample.min(), sample.max()) print("p1/p50/p99:", np.percentile(sample, [1, 50, 99])) # 标注覆盖率与实例数:空标注帧要单独记下来 cover, counts = 0, [] for p in seg_paths: m = tifffile.imread(p) ids = np.unique(m) ids = ids[ids != 0] if len(ids): cover += 1 counts.append(len(ids)) print("有标注帧占比:", cover / len(seg_paths)) print("每帧实例数 中位数/最大:", np.median(counts), max(counts))

这段代码有两个刻意的设计。抽样而不是全量读取,是因为 16 位体数据一多,内存很容易被占满;单独统计空标注帧,是因为空帧在分割任务里是天然的负样本,但如果它在训练集里占比过高,模型会迅速退化成「全预测背景」,而你在训练曲线上看到的 loss 下降会非常好看。每帧实例数的最大值还要用来估算 ID 上限,后面构造输出通道或者做 ID 重映射时要用到。

体检报告出来之后,先别急着调模型,把这几件事定下来:强度归一化窗口、patch 大小、正负样本比例、训练/验证集划分方式。这四件事任何一件定错,后面调参都是在错误的基线上做无用功。

3. 用 Python 把 16 位 tif 序列读对:位深、强度归一化与各向异性

3.1 读错位深是第一个翻车点

显微图像基本都是 16 位无符号整数,但实际强度值往往只落在 0 到几千这个窄区间里。用只支持 8 位的库去读,结果有两种:要么直接抛出异常,要么更糟,静默截断成 0 到 255,图像看起来「有东西」但细节全丢。判断方法很简单,读完立刻检查 dtype 和极值,别信肉眼看到的预览图。

import glob import numpy as np import tifffile def load_sequence(pattern): """按文件名排序读入一段 tif 序列,返回 (T, H, W) 数组""" paths = sorted(glob.glob(pattern)) if not paths: raise FileNotFoundError(pattern) stack = np.stack([tifffile.imread(p) for p in paths]) if stack.ndim != 3: raise ValueError(f"期望 (T,H,W),实际 {stack.shape}") return stack, paths stack, paths = load_sequence("./data/*/img/*.tif") print(stack.dtype, stack.shape, stack.min(), stack.max()) assert stack.dtype == np.uint16, "位深不对,先查读取库"

tifffile.imread会原样保留位深,不像某些通用图像库在遇到 16 位灰度时会做降位处理。sorted不能省,文件名里带零填充的序号时字典序和数值序一致,但如果序号没有补零,frame10会排在frame2前面,序列顺序一乱,追踪部分的标注对不上,排查起来非常费时间。assert那行是有意留的检查,让问题在数据加载阶段暴露出来,别等到 loss 不收敛才回头查。

3.2 强度归一化:百分位裁剪加局部 z-score

全局 min-max 归一化在这类数据上经常失效,原因是极少数过亮噪点或标记沉积会把上界拉到很远,正常组织被压成一个窄带。稳妥做法是先按百分位裁剪,再做归一化:

def normalize_volume(vol, p_low=1.0, p_high=99.9, eps=1e-6): """整段序列统一裁剪窗口,保证时序上强度尺度一致""" vol = vol.astype(np.float32) lo, hi = np.percentile(vol, [p_low, p_high]) vol = np.clip(vol, lo, hi) return (vol - lo) / (hi - lo + eps) def local_zscore(img, size=31, eps=1e-6): """局部均值方差归一化,压掉大视野下的照明梯度""" from scipy.ndimage import uniform_filter img = img.astype(np.float32) mu = uniform_filter(img, size) mu2 = uniform_filter(img * img, size) var = np.maximum(mu2 - mu * mu, 0.0) return (img - mu) / np.sqrt(var + eps)

参数上,p_low/p_high取 1 和 99.9 是我比较常用的起点,前景占比高的序列可以放宽到 0.5 和 99.5。裁剪窗口一定要在整段序列上统一计算,逐帧各算各的会让同一结构在不同帧呈现不同灰度,模型学到的时序一致性直接被打散。

local_zscore的size要跟目标尺寸挂钩:大约是单个目标直径的 3 到 5 倍。取太小,目标内部的纹理被放大成噪声,网络学到一堆高频伪影;取太大,退化成全局 z-score,照明梯度又回来了。我一般先用中位数实例面积估个直径,再反推窗口,比拍脑袋定 31 靠谱。

3.3 体数据与各向异性:spacing 要跟着数据走

序列一旦从 2D 变成 3D 体数据,xy 平面和 z 轴的物理间距往往差好几倍,比如 xy 是 0.1 微米而 z 是 0.4 微米。这种情况下用各向同性的 3×3×3 卷积核,等于假设一个目标在 z 方向被压扁了四倍,边界会糊成一片,实例之间连在一起,分割指标怎么调都上不去。

处理方式两条路。一是重采样到近似各向同性再训练,代价是 z 方向插值会引入模糊,小目标容易丢;二是保留原始分辨率,把卷积核和池化改成各向异性,比如 3×3×1 或 1×3×3 的组合堆叠。我倾向后者,因为插值损失不可逆,而各向异性核只是多写几行配置。不管选哪条,都先把 spacing 记录下来并在配置文件里显式声明,别让它散落在各个脚本的注释里。

4. 从标注到训练样本:切片、正负采样与边界权重图

4.1 滑窗切片的三个参数:patch、stride、padding

整帧塞进网络在这类数据上不现实,滑窗切片是标准做法。三个参数里,patch决定单个样本的感受野,stride决定样本数量和重叠程度,padding决定边缘怎么补齐。

参数常用取值影响调整建议
patch128 / 256太小丢上下文,太大显存吃紧先按目标直径的 4-8 倍估
stridepatch/2 或 patch/4越小样本越多、重叠越大训练用 patch/4,推理用 patch/2
paddingreflect边缘样本不被零填充污染边界目标多时必开

padding用reflect而不是补零,是因为这类图像背景本身不是纯黑,补零会在切片边缘造出一圈假边界,模型会把「靠近边缘」当成一种特征学进去,推理时整图效果就废了。

import numpy as np def sliding_windows(shape, patch=256, stride=128, pad=None): """生成滑窗左上角坐标,pad 为 None 时不做边缘补齐""" h, w = shape if pad: h, w = h + 2 * pad, w + 2 * pad ys = list(range(0, max(h - patch, 0) + 1, stride)) xs = list(range(0, max(w - patch, 0) + 1, stride)) if ys[-1] != h - patch: ys.append(max(h - patch, 0)) if xs[-1] != w - patch: xs.append(max(w - patch, 0)) return [(y, x) for y in ys for x in xs]

补最后一块的逻辑不能省。不做这一步,图像右下角一窄条永远采不到,验证时那片区域的指标会莫名其妙地低,你还以为是模型对边缘不敏感。

4.2 正负样本比例与难例挖掘

显微图像里前景像素占比常常不到 10%,有些序列甚至只有 2% 到 3%。在这种分布下随机切片,绝大部分 patch 是全背景,损失被负样本主导,模型会收敛到一个「全预测背景」的退化解,训练 loss 一路下降,验证指标一片零。这就是典型的看曲线开心、看结果崩溃。

我一般按 patch 内的前景比例分三档采样:前景占比高于 20% 的算正样本,5% 到 20% 的算混合样本,低于 5% 的算负样本,采样时按 1:1:1 或者 2:1:1 配比。混合样本对边界学习特别有用,纯正样本反而容易让模型只学到目标的「内部纹理」而学不会分开粘连处。

难例挖掘的做法更省事一些:第一轮用均匀采样训一个基线模型,把训练集上损失最高的那批 patch 的坐标存下来,第二轮按 50% 难例加 50% 均匀采样重训。代价是要多训一轮,收益是边界处的错误率明显下降。要注意难例别采得太狠,全喂难例会让模型对简单样本过拟合不足,泛化反而变差,这个度我一般控制在不超过一半。

4.3 边界权重图:让模型学会分开粘连细胞

细胞粘连是这类数据的核心难点。两个紧贴的个体在像素层面几乎连成一片,普通交叉熵对边界像素和内部像素一视同仁,模型没有动力去区分。加权损失是最直接的解法,权重图按到最近边界的距离构造:

import numpy as np from scipy.ndimage import distance_transform_edt def boundary_weight(mask, w0=10.0, sigma=5.0): """mask: 实例掩膜,0 为背景。返回逐像素损失权重""" fg = mask > 0 # 简化为到前景/背景边界的距离之和,严格版需算到第二近实例的边界 d_bg = distance_transform_edt(~fg) d_fg = distance_transform_edt(fg) d = d_bg + d_fg return 1.0 + w0 * np.exp(-(d ** 2) / (2 * sigma ** 2))

w0控制边界权重的放大倍数,10 是常见的起点,粘连严重的序列可以加到 20,但别超过 30,否则损失几乎全部集中在边界,内部出现空洞。sigma控制权重衰减的宽度,取值应该和边界的模糊程度匹配,大约是图像里边界过渡带宽度的一半。

这个实现是简化版:严格做法是分别算到最近实例边界和第二近实例边界的距离再相加,只有这样才能真正把「两个不同个体之间的缝」和「同一个体内部的纹理」区分开。简化版在粘连不严重时够用,一旦遇到大片贴合的目标,还是得换成严格版。这一点我最初没在意,后来在一次密集序列上翻了车——权重图看着很漂亮,粘连处的预测依然糊成一团,换成严格版才算解决。

5. 避坑与排查:五个把训练拖垮的细节

5.1 读出来一片全黑:16 位图被当成 8 位

现象是生成的预览图几乎全黑,或者只剩几个亮点,但用别的软件打开同一张 tif 又是正常的。原因是某个通用图像库在读取 16 位灰度时做了降位处理,把高 8 位丢掉或者做了一次缩放。解决办法很直接:统一用tifffile或者明确支持 16 位的读取器,读完立刻用assert卡住 dtype 和极值。我现在的习惯是在数据加载模块里加一行统计,把每段序列的 min/max 打到日志里,一旦某段突然变成 0 到 255 的整数,一眼就能看出来。

5.2 验证集指标高得离谱:时序数据随机划分造成泄漏

现象是训练两三个 epoch 验证集 Dice 就到 0.95 以上,但换一段序列推理效果一塌糊涂。根本原因是把同一段序列的相邻帧随机分到了训练集和验证集——显微序列相邻帧之间差异极小,几乎是同一张图的轻微扰动,模型只要记住像素就能拿高分。正确做法是按序列划分,或者至少按帧间隔分段划分,保证验证集里的帧在时间上和训练集隔开足够远。我一般按「前 80% 帧训练、后 20% 帧验证」,再加一段完全独立的序列做最终测试。

5.3 显存瞬间爆掉:标注 ID 不连续被当成通道数

现象是读标注一切正常,模型一构建就显存不足,报错还很含糊。原因通常是取了mask.max()当类别数去构造输出通道,而 SEG 掩膜里的实例 ID 是从 1 开始但中间有空号,有的序列最大 ID 到了几百甚至上千,通道数直接爆炸。处理办法是先做 ID 重映射,把用到的实例重新编号成 1 到 N,或者干脆换成分割范式(预测边界/距离场再后处理连成实例),不做逐实例的多通道输出。这个坑我在第一次接触实例掩膜时踩得结结实实,血泪经验是:拿到 ID 先跑一次len(np.unique(mask)),跟mask.max()比一比,两个数差异大就说明有空洞。

5.4 分裂帧附近追踪指标崩盘:lineage 断链

现象是分割指标正常,追踪指标在分裂发生的几帧附近断崖下跌。原因是分裂时子代拿到新 ID,父代在最后一帧消失,如果只在帧间做简单的最近邻匹配,分裂点会被判成「一个目标消失、两个目标凭空出现」,代价函数里全是增删操作。解决办法是在后处理里显式处理 lineage:读取 track 索引表,把父代的结束帧和子代的起始帧对齐,分裂帧允许「一对一变一对二」。这一步不做,追踪部分的分数基本拿不到。

5.5 3D 模型 z 方向糊成一片:各向异性没处理

现象是 2D 逐帧推理效果不错,换成 3D 网络之后 z 方向边界全糊,实例在垂直方向连成柱状。原因是卷积核各向同性而体素间距各向异性,网络在 z 方向的实际感受野被压缩了好几倍。解决要么按物理间距重采样,要么把卷积核换成各向异性组合(比如 z 方向只用 1 或 3 的核尺寸)。判断方法很土但有效:拿一个已知直径的目标量一下它在 z 方向占几层、xy 方向占几个像素,比值就是你要补偿的各向异性系数。

6. 本地复现 SEG 指标:把「看起来不错」变成可对账的数字

6.1 一个不到 30 行的 SEG 实现

训练跑通之后,最危险的状态是「看着挺好」。没有本地指标,你无法判断一次改动是真提升还是随机波动。SEG 的定义不复杂:对每个真值实例,找到预测中与它重叠最大的实例,若重叠像素占该真值实例的比例超过 0.5,则认为匹配成功,取两者的交并比作为该实例得分,最后对所有真值实例取平均。

import numpy as np from scipy.ndimage import label def seg_score(pred_prob, gt, thr=0.5, min_size=10): """pred_prob: 前景概率图; gt: 实例掩膜,0 为背景""" fg = (pred_prob > 0.5).astype(np.uint8) pred, n = label(fg) # 二值图转实例编号 sizes = np.bincount(pred.ravel()) small = np.where(sizes < min_size)[0] # 过滤碎块 pred[np.isin(pred, small)] = 0 scores = [] for gid in np.unique(gt): if gid == 0: continue g = gt == gid g_area = g.sum() best = 0.0 for pid in np.unique(pred[g]): if pid == 0: continue p = pred == pid inter = np.logical_and(g, p).sum() if inter / g_area <= thr: # 注意:判据用占真值比例,不是 IoU continue best = max(best, inter / (g_area + p.sum() - inter)) scores.append(best) return float(np.mean(scores)) if scores else 0.0

两个细节值得单独说。第一,重叠判据用的是「交集占真值实例的比例大于 0.5」,不是「IoU 大于 0.5」,很多自实现把这两个搞混,算出来的分数系统性偏低,然后你以为是模型不行,其实是评估代码写错了。第二,min_size过滤不能省,概率图上几个像素的孤立噪点会被label当成独立实例,虽然匹配不上,但会干扰你判断预测实例数量的合理性。

6.2 每次改动前先跑一遍基线

我的习惯是:任何一次结构调整、归一化改动、增广开关,都先把当前基线在这个脚本上跑一遍,把每个序列的分数单独记下来,而不是只看总体平均。总体平均很容易掩盖问题——某一段序列掉了 0.2、另一段涨了 0.15,平均下来看着没变化,实际上模型在某些形态上已经崩了。

还有一点,指标脚本本身也要做回归测试:拿真值当预测喂进去,SEG 应该是 1.0;拿全零当预测,应该是 0.0。这两条都过了,才说明评估代码没有写反。我吃过一次亏,交集和并集的位置写颠倒了,白调了两天参数,最后发现是评估脚本的锅。数据侧这摊事看起来琐碎,但它决定了后面所有实验的可信度,先把这块做扎实,再往上堆模型才踏实。希望帮到你。

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

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

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

立即咨询