简介:面向医学影像分割与腰痛脊椎分析任务,这份数据集以CTSpine1K为基础,按轴位面、冠状面、矢状面三个方向切分出2D图像,剔除ROI占比不足3%的切片后,经windowing对比度增强并统一缩放为512×512。图像为jpg,标签为png(8bit灰度、uint8),共5个类别,非0区域为前景、0为背景,类别定义见classes.txt。压缩包共2000个文件,以1998张png标签图为主,附带1个类别说明txt和1个可视化脚本show.py,整体大小472.47MB。数据集按x、y、z三个切面分别组织,对应2113、2272、6404组图像与mask,方便按需加载。已有224人浏览学习。借助可视化代码可快速检查mask与原始影像的叠加效果,适合入门或进阶医学图像分割、需要多切面训练样本的研究者直接使用。
1. 为什么拿到骶骨腰痛数据,要先按3个切面重新划分
做骶骨腰痛相关的医学图像分割时,最头疼的往往不是模型结构,而是数据集本身。这个标题里的医学图像分割数据集,核心不是「有一堆图」,而是把同一个人同一套扫描重建成矢状面、冠状面、横断面三个切面,每个切面都配上像素级5类别标签,再送你一份可视化代码。没有这套东西,你连「数据长什么样」都说不清,后面训练、调参、写论文全都踩在沙子上。
我处理过几套腰椎、骶骨相关的分割数据,这类任务天然适合按切面拆开:矢状面看腰椎序列曲度和滑脱,横断面看椎间盘与神经根压迫,冠状面看侧弯和骶髂关节。与其让模型在三维体素里盲人摸象,不如先把三个方向的图像分别喂给分割网络,每个方向都有自己的分辨率、间距和感受野设定。这篇就按「数据组织 → 标签处理 → 可视化 → 踩坑 → 验证」的顺序,把一套能直接落地的做法讲清楚。新手照着能跑通第一个实验,熟手可以重点看第5章的边界条件和第6章的评估口径。
2. 3个切面5个类别怎么装进文件夹:目录约定与切面重采样
2.1 为什么医学图像分割的数据集要按切面重采样
医学图像原始扫描通常是从CT或MRI设备输出的DICOM序列,体素是各向异性的:横断面层内分辨率往往是0.5mm左右,层间距却可能是1mm、2mm甚至5mm。这种数据直接做三维分割,横断面方向的感受野和其他两个方向完全不对称,模型很容易学到方向相关的伪影。
常见做法是先按三个切面把体素重采样成接近各向同性,再逐一切片保存。矢状面适合看L1到S1的整体序列,主要关注椎体前缘连线、滑脱程度;横断面适合看椎间盘、硬膜囊和神经根出口;冠状面适合看椎体侧方和骶髂关节。三个切面的重采样间距不完全一样,我一般按下面的参数做默认值:
| 切面 | 重建方向 | 推荐重采样间距 | 主要观察目标 |
|---|---|---|---|
| 矢状面(Sagittal) | 沿左右方向逐层切片 | 0.8~1.0mm 等间距 | 腰椎序列、椎间盘高度、骶骨倾斜角 |
| 横断面(Axial) | 沿头脚方向逐层切片 | 层内0.6~0.8mm | 椎间盘、硬膜囊面积、神经根压迫 |
| 冠状面(Coronal) | 沿前后方向逐层切片 | 1.0~1.5mm | 侧弯、骶髂关节、椎体横向偏移 |
重采样工具上,我习惯用SimpleITK。它读NIfTI或DICOM后可以直接拿到spacing和direction矩阵,做Resample时不至于把方向搞反。重采样有两个参数必须显式指定:一个是输出spacing,一个是对齐原点。很多人只改spacing不改origin,结果切面重建出来整体平移了一个体素,最后标签和原图悄悄错位。
2.2 从原始扫描到三切面数据集的目录编排
拿到原始扫描后,先统一格式,我一般全部转成NIfTI再处理。原始DICOM转NIfTI用dcm2niix最省事,一条命令就能把整个序列导出,还能顺带生成JSON侧car文件记录spacing和方位信息。注意转换时加上-o指定输出目录,-f指定文件名模板,不然文件名会是乱序数字,后面配对标签时非常痛苦。
dcm2niix -z y -o ./nifti_raw -f %p_%s ./dicom_input转换完成后,NIfTI的spacing、origin、direction都写进了文件头,接下来用SimpleITK读进来做切面重采样。这里有个很关键的约定:切面方向是按解剖方位命名的,不是按数组维度命名的。横断面在NIfTI数组里通常是第2维(i方向和j方向构成的平面),矢状面是第0维或第1维,具体取决于direction矩阵。写代码时不要写死img_array[0]是矢状面,一定要先打印img.GetDirection()确认。
import SimpleITK as sitk img = sitk.ReadImage("nifti_raw/patient01_T1.nii.gz") print("Spacing:", img.GetSpacing()) print("Direction:", img.GetDirection()) # 统一重采样到各向同性,这里以矢状面为例 resampler = sitk.ResampleImageFilter() resampler.SetOutputSpacing([1.0, 1.0, 1.0]) resampler.SetSize([0] * 3) # 下面根据spacing计算 original_spacing = img.GetSpacing() original_size = img.GetSize() new_size = [ int(round(original_size[0] * original_spacing[0] / 1.0)), int(round(original_size[1] * original_spacing[1] / 1.0)), int(round(original_size[2] * original_spacing[2] / 1.0)), ] resampler.SetSize(new_size) resampler.SetOutputOrigin(img.GetOrigin()) resampler.SetOutputDirection(img.GetDirection()) resampler.SetInterpolator(sitk.sitkLinear) resampled = resampler.Execute(img)参数这里要注意的是:矢状面重采样到1mm等间距,主要是为了让后续能直接按切片顺序做组间对比;横断面我会保留层内0.6~0.8mm,因为椎间盘和神经根这些小结构在高分辨率下边界更清楚。重采样的插值方式只对图像用sitkLinear,对标签图必须换sitkNearestNeighbor,否则会出现新的灰度类别,这个在第5章踩坑里细说。
重采样完成后,按切面拆切片保存。目录结构我强烈建议从一开始就这么定,别等标注完了再改:
dataset_root/ ├── images/ │ ├── axial/patient01_000.png │ ├── axial/patient01_001.png │ ├── coronal/patient01_000.png │ ├── sagittal/patient01_000.png ├── labels/ │ ├── axial/patient01_000.png │ ├── coronal/patient01_000.png │ ├── sagittal/patient01_000.png └── visualization/ ├── overlay/ └── slice_browser.pyimages和labels用完全相同的文件名,这样训练时按文件名配对就行。每个切面单独一个子目录,方便后面用不同的预处理参数。不要把所有切片混在一个目录里——骶骨腰痛任务里横断面和矢状面的类别分布差异非常大,分开目录才能对症下药。
2.3 5个类别到底分割什么:标签值与解剖结构的对应关系
标题里明确了5类别,结合骶骨腰痛的实际临床需求,常见的类别划分是:腰椎椎体、骶骨、椎间盘、椎管(硬膜囊)和棘突附件。背景通常是0,1到5分别给前五类。这个划分的好处是:腰椎椎体和骶骨是退行性疾病的主要承载结构,椎间盘是腰痛的核心病灶,椎管直接影响神经压迫症状,棘突附件则是手术规划的参考点。
| 标签值 | 结构 | 分割难点 |
|---|---|---|
| 0 | 背景 | 与软组织相近,容易误检 |
| 1 | 腰椎椎体(L1-L5) | 边缘骨皮质与骨髓信号差异大 |
| 2 | 骶骨 | 与髂骨连接处边界模糊 |
| 3 | 椎间盘 | 体积小、类别不平衡严重 |
| 4 | 椎管/硬膜囊 | 低信号区域,对比度差 |
| 5 | 棘突/附件 | 形态不规则,标签面积小 |
在NIfTI标签文件里,每个体素存一个0到5的整数,保存在一张灰度PNG里或一个三维NIfTI里都行。关键是要保证标签与图像有完全相同的shape、spacing、origin和direction。很多人图省事把标签存成RGB图,后面转灰度时颜色映射错乱,属于给自己挖坑。我的习惯:中间过程全部用NIfTI保留空间信息,只在训练前按切片导出为PNG,这样最不容易丢坐标。
3. 标签文件怎么处理才不翻车:格式、对齐与类别合并
3.1 灰阶标签图与One-Hot编码的选择
标签文件最稳妥的存储格式是单通道灰度图,每个像素的值就是类别编号。不要存成三通道的彩色PNG,也不要存成JPEG,JPEG压缩会在边缘生成不存在的灰度值,轻则训练时报错,重则把错误类别直接教给模型。横断面、矢状面、冠状面各切一张图,就是标题配的「3个切面划分的数据集」的常见形态。
有些公开数据集喜欢把标签做成One-Hot编码,也就是5个类别的mask分5张图存,每张图只存0和1。这种做法不是不行,但要注意:很多分割网络直接读单通道标签图,你喂5通道的One-Hot进去,输出层要跟着改,推理时还要做argmax,流程上多绕一圈。我一般只在做多任务或者类别之间有重叠时才用One-Hot,常规的骶骨腰痛分割用单通道整数图就够了。
还有一个容易忽略的细节:标签文件的位深。0到5这个取值范围,用uint8足够,但别用有符号的int8,有的框架读int8时会把大于127的值当负数,5不会出错,但标注工具偶尔会写出255这种值,一但出现,int8读出来是-1,损失的是一次训练。规规矩矩用np.uint8保存和读取,这是最没有歧义的做法。
3.2 把多类别标注合并成5类的批处理脚本
标注工具导出的标签往往比5类多,比如把L1到L5分开标成了5个类别,加上骶骨就是6类。这种情况需要合并。下面这个脚本就是把腰椎椎体合并、保留骶骨、处理椎间盘和椎管的通用写法。核心思路是先读标签数组,再用numpy的索引重新映射。
import numpy as np import SimpleITK as sitk def merge_labels(label_array): """ 将细粒度标签合并为5类 + 背景。 约定:L1-L5椎体 -> 1,骶骨 -> 2,椎间盘 -> 3, 椎管/硬膜囊 -> 4,棘突/附件 -> 5,其余 -> 0 """ merged = np.zeros_like(label_array, dtype=np.uint8) # 假设原始标签: 1,2,3,4,5是L1-L5椎体, 6是骶骨, # 7-10是椎间盘, 11是椎管, 12是棘突 merged[label_array == 1] = 1 merged[label_array == 2] = 1 merged[label_array == 3] = 1 merged[label_array == 4] = 1 merged[label_array == 5] = 1 merged[label_array == 6] = 2 merged[label_array == 7] = 3 merged[label_array == 8] = 3 merged[label_array == 9] = 3 merged[label_array == 10] = 3 merged[label_array == 11] = 4 merged[label_array == 12] = 5 return merged img = sitk.ReadImage("training_labels/patient01_label.nii.gz") label_arr = sitk.GetArrayFromImage(img) # shape: (z, y, x) merged_arr = merge_labels(label_arr) merged_img = sitk.GetImageFromArray(merged_arr) merged_img.CopyInformation(img) # 保留空间信息 sitk.WriteImage(merged_img, "training_labels/patient01_label_5class.nii.gz")代码逻辑说明:合并的映射关系必须和标注协议一一对应,如果你的标注工具把L1到L5分别标为101到105,就把条件改成label_array == 101。最关键的一行是CopyInformation(img),它把原图的origin、spacing、direction原样带进新标签,这一步漏了,后面所有配准都会串位。合并完成后一定要跑一遍类别直方图,确认0到5六个值都有且没有被误合并成别的数字。
参数说明:合并脚本的输入是NIfTI,输出也是NIfTI。这个阶段不要把标签切成分片保存,因为NIfTI在三维层面能保持解剖结构完整性,便于做空间一致性检查。等训练前再按切片切分,这样如果你某个切面的标签错了,还能回到三维体素里重新来。
3.3 标签与图像的空间对齐:spacing、origin、direction三个参数
医学图像分割数据集最常见的翻车现场,就是图像和标签明明看着形状一样,叠加后却像是两张图在错位。这个问题十有八九出在spacing、origin、direction三个参数的某一项上。图像重采样了但标签没重采样,spacing自然不一致;两个文件来自不同次扫描,origin差了几个毫米;dcm2niix转换时direction不同,导致图像是RAS方向而标签是LPS方向,叠加时左右翻转。
我在拿到任何一组图像和标签时,第一件事不是看一眼效果,而是打印四个值:
img = sitk.ReadImage("images/sagittal/patient01.nii.gz") label = sitk.ReadImage("labels/sagittal/patient01.nii.gz") print("img :", img.GetSize(), img.GetSpacing(), img.GetOrigin(), img.GetDirection()) print("label :", label.GetSize(), label.GetSpacing(), label.GetOrigin(), label.GetDirection())如果size、spacing、origin、direction四项有任何一项不一样,绝对不要强行用,先把label重采样到与图像一致再做后续。重采样标签用最近邻插值,这段代码应该是所有医学图像分割项目里的常驻工具:
def resample_label_to_image(label, ref_img): resampler = sitk.ResampleImageFilter() resampler.SetReferenceImage(ref_img) # 直接以图像为空间模板 resampler.SetInterpolator(sitk.sitkNearestNeighbor) return resampler.Execute(label)这里只说一点:SetReferenceImage把图像的所有空间属性全部套到label上,不需要你手动指定size和spacing,是最不容易出错的写法。唯一要注意的是它不会重采样灰度值,所以只适用于标签图,用在CT图像上会把密度值插出奇怪的伪影。
4. 可视化代码最小实现:叠mask、存PNG、查穿层
4.1 最小可用的可视化脚本
数据集里配套的可视化代码,通常要解决两个问题:一是把分割结果叠加原图看一眼合不合理,二是快速把某个病人的全部切片导出来,便于发现标注错误。下面这个脚本就是典型的超轻量实现,只依赖numpy、SimpleITK和matplotlib。它读取一个NIfTI图像和对应标签,在三个切面方向各取中间几张切片,把标签作为彩色半透明mask叠加在灰度图上,保存为PNG。
import numpy as np import SimpleITK as sitk import matplotlib.pyplot as plt def visualize_overlay(image_path, label_path, output_path, slice_idx=None, opacity=0.4): img = sitk.ReadImage(image_path) label = sitk.ReadImage(label_path) arr = sitk.GetArrayFromImage(img) # (z, y, x) lab = sitk.GetArrayFromImage(label).astype(np.uint8) # 归一化图像到[0, 255] arr = arr - arr.min() arr = arr / (arr.max() + 1e-8) * 255.0 # 默认取三个方向的中间切片 if slice_idx is None: slice_idx = { "axial": arr.shape[0] // 2, "coronal": arr.shape[1] // 2, "sagittal": arr.shape[2] // 2, } colors = np.array([ [0, 0, 0], # 背景 [1, 0, 0], # 椎体 [0, 1, 0], # 骶骨 [0, 0, 1], # 椎间盘 [1, 1, 0], # 椎管 [1, 0, 1], # 棘突 ], dtype=np.float32) fig, axes = plt.subplots(1, 3, figsize=(15, 5)) views = [] # 横断面: (z, y, x) 取第z层,显示y-x平面 z = slice_idx["axial"] views.append((arr[z, :, :], lab[z, :, :], "Axial z=" + str(z))) # 冠状面: 取第y层,显示z-x平面 y = slice_idx["coronal"] views.append((arr[:, y, :], lab[:, y, :], "Coronal y=" + str(y))) # 矢状面: 取第x层,显示z-y平面 x = slice_idx["sagittal"] views.append((arr[:, :, x], lab[:, :, x], "Sagittal x=" + str(x))) for ax, (img_slice, lab_slice, title) in zip(axes, views): img_display = np.stack([img_slice] * 3, axis=-1) mask = colors[lab_slice] alpha = (lab_slice > 0).astype(np.float32) * opacity overlay = img_display * (1 - alpha[..., None]) + mask * alpha[..., None] ax.imshow(overlay.astype(np.uint8)) ax.set_title(title) ax.axis("off") plt.tight_layout() plt.savefig(output_path, dpi=150) plt.close() visualize_overlay( "images/sagittal/patient01.nii.gz", "labels/sagittal/patient01_5class.nii.gz", "visualization/patient01_overlay.png" )代码逻辑说明:sitk.GetArrayFromImage返回的数组维度顺序是(z, y, x),也就是第一个维度是横断面方向,中间是冠状面方向,最后是矢状面方向。很多人在这一步把维度搞反,把横断面当成矢状面来看,看半天还觉得标签没毛病。切片索引slice_idx是三个方向各取一层的默认设定,实际使用时建议传一个列表,把关键解剖位置都扫一遍。colors数组的索引直接对应标签值1到5,背景全透明,其余类别分别用不同颜色标出。
参数说明:opacity控制mask透明度,0.4在多数情况下既能看清覆盖区域又不会遮住底层解剖结构;如果是对比图,可以调到0.2。dpi=150导出的图够写报告用,不用为了图清晰而无限调大,否则上百张切片会等得血压升高。这段代码可以直接引用成数据集配套的可视化代码,稍微改造就能用于推理结果评估。
4.2 用切片浏览器检查标签有没有穿层
一个NIfTI文件几十层到几百层不等,用上面那张三视图只能看三个中间层,骶骨腰痛这种任务里病灶往往在L4-S1区域,中间层未必是重点。更实际的做法是写一个简单的切片浏览器,用鼠标滚轮切换切片,逐层看。这里用matplotlib的event接口就能实现,不必上专门标注软件。
import matplotlib.pyplot as plt import SimpleITK as sitk import numpy as np class SliceBrowser: def __init__(self, image_path, label_path=None, axis=0): self.img = sitk.GetArrayFromImage(sitk.ReadImage(image_path)) self.lab = sitk.GetArrayFromImage(sitk.ReadImage(label_path)) if label_path else None self.axis = axis self.idx = self.img.shape[axis] // 2 self.fig, self.ax = plt.subplots(figsize=(8, 8)) self.fig.canvas.mpl_connect("scroll_event", self.on_scroll) self.fig.canvas.mpl_connect("key_press_event", self.on_key) self.update() def get_slice(self, data, idx): if self.axis == 0: return data[idx, :, :] elif self.axis == 1: return data[:, idx, :] else: return data[:, :, idx] def update(self): self.ax.clear() img_slice = self.get_slice(self.img, self.idx) img_slice = (img_slice - img_slice.min()) / (img_slice.max() - img_slice.min() + 1e-8) self.ax.imshow(img_slice, cmap="gray") if self.lab is not None: lab_slice = self.get_slice(self.lab, self.idx) self.ax.imshow(lab_slice, cmap="jet", alpha=0.4, vmin=0, vmax=5) self.ax.set_title(f"Axis {self.axis}, Slice {self.idx}/{self.img.shape[self.axis] - 1}") self.fig.canvas.draw() def on_scroll(self, event): if event.button == "up": self.idx = min(self.idx + 1, self.img.shape[self.axis] - 1) elif event.button == "down": self.idx = max(self.idx - 1, 0) self.update() def on_key(self, event): if event.key == "a": self.axis = (self.axis + 1) % 3 self.idx = min(self.idx, self.img.shape[self.axis] - 1) self.update() SliceBrowser("images/axial/patient01.nii.gz", "labels/axial/patient01_5class.nii.gz") plt.show()这段代码比上一段更接近「干活」的形态:滚轮逐层扫,按a键在三个切面之间切换。判断标签穿层主要靠扫的时候眼盯四件事:一是椎体和骶骨是否连接在一起;二是椎间盘是否被错误标进椎体区域;三是横断面上椎管是否连续;四是切片最上下边缘有没有出现孤立杂乱的小色块。如果某个类别的区域在连续几层间突然断开又出现,多半是标注断层,不是模型的问题。
4.3 三切面同一解剖点如何联动核对
分割标注质量检查里,最容易被忽视的是跨切面一致性。横断面显示L4椎体的中心位置,转到矢状面应该是同一个病人的L4上缘;如果两个切面在解剖结构上对不上,多数是重采样或标注时某个环节改变了origin。联动核对的做法不复杂:在三个切面各取指定索引,锁定同一个体素坐标,然后对比三张图上该点的结构。
import numpy as np # 假设体素坐标 (z, y, x),可以在SliceBrowser中点击获取 voxel_coord = (120, 180, 160) # 横断面看 (z, :, :) axial_slice = arr[voxel_coord[0], :, :] # 冠状面看 (:, y, :) 对应z-x平面 coronal_slice = arr[:, voxel_coord[1], :] # 矢状面看 (:, :, x) 对应z-y平面 sagittal_slice = arr[:, :, voxel_coord[2]]这三个切面共享同一个体素坐标点,解剖结构应该同时出现在三张图的交叉位置。如果三张图里该点的类别不一致,或者只有两个切面能看到结构,基本可以判定标签是分切面单独标注后没有做三维一致性融合。这种情况修复成本极高,最好的办法是开始标注之前就把重采样参数锁死,让三个切面共享同一个三维体素空间,分工才能守住。
5. 骶骨腰痛分割数据集避坑指南:5条血泪经验
5.1 切面名字对不上:训练时找不到label
现象:训练脚本读images/axial/patient01_000.png,却去labels/axial/找同名文件,报错FileNotFoundError。检查了半天发现,图像的命名规则是patient01_000.png,标签却是patient01_000_mask.png。
原因:数据集在切分时,images目录和labels目录不是同一套生成脚本,一个带了_mask后缀,另一个没带。或者两个目录分别来自不同的标注人员,文件编号规则没有约定好。
解决:在训练代码里做一次文件名规范化。不要靠人去检查几百个文件名,直接在数据加载入口统一处理——把*_mask.png、*_label.png、*_gt.png全部重命名为和图像名完全一致。用glob找出两个目录的文件列表,对集合做差集,把不匹配的全部打印出来再统一重命名。
import glob import os import shutil img_files = glob.glob("dataset_root/images/axial/*.png") label_files = glob.glob("dataset_root/labels/axial/*.png") img_names = {os.path.basename(f) for f in img_files} label_keys = {} for f in label_files: name = os.path.basename(f) key = name.replace("_mask", "").replace("_label", "").replace("_gt", "") label_keys[key] = f missing = [n for n in img_names if n not in label_keys] if missing: print("Missing labels:", missing[:10])这类问题通常在第一个epoch就暴露,但如果你用了shuffle=True而数据量又大,可能到第3个epoch才触发,那时候已经白白等了几个小时。所以训练前用这个脚本强制做一次全量配对,比出错了再查省心得多。
5.2 mask边缘断层:把插值开到了标签上
现象:图像重采样后,标签边缘出现大量不属于任何类别的灰度值,比如标签值变成255、128,或者类别1的边缘多出一圈类别3。杏色区域看着像把标签糊了。
原因:重采样标签时用了线性插值。线性插值在两个相近类别编号之间会生成中间值,位于3和4之间的体素被插成3.5,四舍五入后随机分配到3或4,边缘就出现锯齿和错乱类别。
解决:标签重采样强制使用sitkNearestNeighbor,这一点怎么强调都不过分。如果已经用了线性插值,别想靠后处理修复,重新做一次重采样比清洗污染标签便宜得多。另外还要注意,某些框架的DataLoader里如果对图像和标签一并做了resize,图像用了双线性而标签忘了改成最近邻,同样会触发这个坑。去DataLoader里检查一下interpolation参数,图像和标签必须分开指定。
5.3 类别不平衡:骶骨目标太大、椎间盘太小
现象:训练出的模型骶骨分割结果接近完美,椎间盘却几乎全预测成背景,整体mDice看着还行,但单独看椎间盘的Dice只有0.2。投影到临床场景就是,模型报的椎间盘区域完全不可用。
原因:5个类别里椎间盘和棘突的区域占比远小于背景和椎体。以矢状面为例,椎间盘在一个切片里可能只占几十个像素,背景却占了几万个。模型的损失函数被大类别主导,稀疏类别几乎学不到特征。
解决:常见做法是从损失函数下手,用带类别权重的Dice Loss或Combo Loss,给类别3和类别5更高的权重。另一个更直接的办法是在数据增强里做类别相关的crop,保证每个训练patch都包含椎间盘区域。还有一条路径是把椎间盘单独拎出来做二分类检测再分割,但成本高,先试权重调整更划算。
class_weights = torch.tensor([0.1, 1.0, 1.0, 3.0, 2.0, 3.0]) # 背景权重压低,椎间盘和棘突权重拉高 criterion = nn.CrossEntropyLoss(weight=class_weights)这里权重具体怎么设要看你数据集里各类别的像素占比,先统计类别直方图,让权重和频率成反比,再根据验证集dice微调。不要一上来就给椎间盘10倍权重,容易让模型把别的区域误判成椎间盘,Dice反而更差。
5.4 标签文件黑匣子:用直方图快速定位断裂类别
现象:训练时模型输出只有背景和类别1,其他类别参与度极低,翻遍了代码以为是网络结构的问题,最后发现标签文件里类别3、4、5一个像素都没有。
原因:合并脚本的映射关系写错了,或者标注工具导出时把某些类别的值统一写成了0。这类问题不跑代码是看不出来的,一张图叠上去视觉上可能只是少了几个区域,你不会注意到某个类别彻底消失。
解决:数据加载完成后,第一件事是打印训练集标签的类别直方图。别等训练完再核对,在数据集类初始化时顺带统计一次。下面这段代码跑出来如果某个类别是0,立即停下查标注源文件。
import numpy as np import SimpleITK as sitk def inspect_label_distribution(label_path): label = sitk.ReadImage(label_path) arr = sitk.GetArrayFromImage(label) unique, counts = np.unique(arr, return_counts=True) dist = dict(zip(unique.tolist(), counts.tolist())) print(label_path.split("/")[-1], dist) return dist直方图只能告诉你有没有类别,不能告诉你边界对不对。边界检查还是得回到第4章的切片浏览器或者三视图可视化,两者结合起来,数据质量才算真正过了一道关。注意不能只看一个病人,至少要随机抽5个以上病人做同样的检查,否则恰好抽到一个标注完整的样本,还是会被误导。
5.5 把背景当作第0类还是255:训练框架的甜蜜陷阱
现象:标签图在保存时背景是0,前景是1到5,用某个框架训练时却报错number of classes mismatch,或者预测时背景变成白色、前景变成黑色。
原因:有些图像处理库读PNG时会把单通道uint8当作灰度图,但是某些标注工具保存时把背景写成了255,或者用了带透明通道的RGBA PNG,导致读出来的shape是(H, W, 4)而不是(H, W),后续处理全乱了。
解决:统一在数据集初始化时强制转成np.uint8的单通道数组,并对像素值做一个显式的映射:把大于5的值全部归到背景,再把所有值裁剪到0到5之间。
def sanitize_label(arr): arr = arr.astype(np.uint8) arr[arr > 5] = 0 # 把异常的255、6+等值清零 return arr这一步看着像多此一举,实际能拦截掉相当一部分标注质量问题。如果你用PyTorch的torchvision.datasets.ImageFolder这种通用接口,它默认做三通道图读取,单通道标签图会被复制成3通道,标签值变成三个一样的值,CrossEntropyLoss虽然也能算,但语义上已经错了。所以数据集类里要单独写标签读取逻辑,别和图像共用一条读取管线。
6. 训练前最后一道验证:切片级Dice与gif核图
6.1 用切片级Dice替代全图Dice
很多人评估医学图像分割模型时只算一个全图Dice,这个值对骶骨腰痛任务不够用。原因很简单:矢状面切片里大部分层是背景,只有中间几层包含椎体和骶骨,全图Dice被大量背景切片拉高或拉低,掩盖了关键层的问题。正确做法是只统计包含前景的切片,并且按类别分开算。
import numpy as np def dice_per_class(pred, label, num_classes=6): dices = {} for c in range(1, num_classes): pred_c = (pred == c) label_c = (label == c) intersection = (pred_c & label_c).sum() denom = pred_c.sum() + label_c.sum() dices[c] = (2.0 * intersection) / (denom + 1e-8) if denom > 0 else 0.0 foreground_mask = label > 0 pred_fg = pred > 0 inter_fg = (pred_fg & foreground_mask).sum() denom_fg = pred_fg.sum() + foreground_mask.sum() dices["foreground"] = (2.0 * inter_fg) / (denom_fg + 1e-8) return dices dices = dice_per_class(pred_arr, label_arr) print("Per-class Dice:", dices)说明:按切片统计时,不要把无前景切片放进类别Dice的计算,否则类别1到5的结果会被大量0除以小数的案例污染。正确做法是只在label_c.sum() > 0的切片上累计Dice,最后求平均。参数上,num_classes=6对应背景加5个前景类别,如果你的项目里第0类是背景,循环从1开始没有问题。这个评估口径写进实验记录里,比一个笼统的全图Dice更能暴露模型在椎间盘、棘突上的短板。
6.2 让可视化代码承担「gif核图」的职责
静态三视图只能给你一个总览,骶骨腰痛分割真正要看的是结构在连续切片间的形态变化。椎间盘厚度从L1到S1逐层递减,椎管在L4-L5处有无受压,这些都藏在序列里。把整个轴位或矢状位序列导出成一个gif,用播放速度模拟滚动查看,往往比一帧帧点鼠标更高效。
import numpy as np import SimpleITK as sitk import imageio def save_slices_gif(image_path, label_path, output_path, axis=0, step=1, fps=6): img_arr = sitk.GetArrayFromImage(sitk.ReadImage(image_path)) lab_arr = sitk.GetArrayFromImage(sitk.ReadImage(label_path)).astype(np.uint8) n_slices = img_arr.shape[axis] frames = [] for idx in range(0, n_slices, step): if axis == 0: img_slice = img_arr[idx, :, :] lab_slice = lab_arr[idx, :, :] elif axis == 1: img_slice = img_arr[:, idx, :] lab_slice = lab_arr[:, idx, :] else: img_slice = img_arr[:, :, idx] lab_slice = lab_arr[:, :, idx] img_slice = (img_slice - img_slice.min()) / (img_slice.max() - img_slice.min() + 1e-8) # 用第4章的overlay逻辑生成叠加图 rgb = np.stack([img_slice] * 3, axis=-1) colors = np.array([[1,0,0],[0,1,0],[0,0,1],[1,1,0],[1,0,1]]) mask = colors[lab_slice] alpha = (lab_slice > 0)[..., None] * 0.4 frame = rgb * (1 - alpha) + mask * alpha frames.append((frame * 255).astype(np.uint8)) imageio.mimsave(output_path, frames, fps=fps) save_slices_gif( "images/sagittal/patient01.nii.gz", "labels/sagittal/patient01_5class.nii.gz", "visualization/patient01_sagittal.gif", axis=2, step=1, fps=6 )代码逻辑说明:step=1导出的gif帧数最多,相当于逐片浏览,代价是文件大、生成慢;对于矢状面这种切片数量在30到80之间的方向,用小步长完全可行。横断面动辄两三百层,建议step=2或step=3,只保留重点结构区域,不然文件上百MB,发给谁都不方便。fps用6到8比较接近人工翻片的节奏,超过10反而看不清楚。
6.3 训练后回头重新审视数据,而不是只盯着网络
模型效果不好时,先不要急着换网络结构。我处理这类医学图像分割数据集的习惯是:训练前的视觉核图花掉时间预算的一半,训练结束后再花半天把bad case翻出来和原图gif对照。很多所谓网络能力不足的问题,实际是标签边界标错了,或者在重采样时embedding偏移累积导致的解码错位。保留每个病人的三切面可视化结果,把它当作项目资产固定下来,改一次数据处理流程就重新生成一遍。实际踩过最值回票价的一个习惯,是每次导出可视化结果时顺手在文件名里写上spacing和是否有标签,三个月后翻旧项目不用重新读代码也能知道这批数据当时是怎么重采样的。希望帮到你。
本文还有配套的精品资源,点击获取