☰
掌骨X射线分割数据集:带标签掩码与训练脚本的二分割实践
2026/10/6 12:43:20 网站建设 项目流程

简介:面向医学影像与计算机视觉学习者,这套X光掌骨分割数据包提供完整的二分割数据集、类别说明与可视化脚本,适合用于骨分割模型的训练、验证与效果检查。数据集中训练集含1486张jpg图像与对应png掩膜,测试集含92对图像,原图分辨率统一为256×512,采用0/1阈值标注,前景区域清晰;同时对部分样本做了缩放与翻转增广,适应更多训练场景。包体共2000个文件,包括1486个png掩膜、512个jpg原图、1个txt类别文件及1个python可视化脚本,压缩后约15.47MB,结构简洁便于使用。配套可视化脚本可直接运行,随机抽取一张图并展示原始图像、GT掩膜及原图叠加蒙板的效果,帮助快速核对数据质量。目前已有193人学习下载,适合希望获得现成掌骨分割数据与调试工具的初学者及研究人员。

1. 掌骨X射线分割数据集:带标签、带脚本、能直接跑的二分割样本

图像分割项目里,最耗时间的一环往往是数据整理,而不是模型结构。真正上手做医学影像项目的人才懂:训练脚本半小时写完,清洗标签反而要熬两三个晚上。我拿到这套掌骨X射线分割数据集时只扫了一眼目录就判断它能用——jpg原图、png掩码、classes文件、可视化脚本全齐了,训练集1486张,测试集92张,分辨率统一为256×512,前景用0/1阈值把掌骨区域和背景分开,边界干净,还带了数据增强样本。适合医学影像方向的学生、想快速验证分割算法的研究者,以及需要小规模二分割基准做迁移实验的工程师。它省掉的是从原始X光片到标注掩码之间最烦琐的那段路。

2. 数据集结构与classes语义:1486张训练图像的类别组织与二值约定

2.1 目录划分与文件名配对:images和masks怎样才算一一对应

拿到压缩包之后先不要急着跑训练脚本,第一步是把目录结构看清楚。数据集按train和test分成两个独立大目录,每个大目录下都有一对images和masks子目录。训练集里images放1486张jpg原图,masks放1486张png掩码,两者通过文件名字前缀一一配对;测试集同理,92对。图像和掩码之间的命名关系是同名不同扩展名,比如图像文件M2-110--16-4-12_jpg.rf.d8659a31b287295de44cfcdda8dd55af.jpg,在masks目录里对应的掩码就是M2-110--16-4-12_jpg.rf.d8659a31b287295de44cfcdda8dd55af.png,把后缀从jpg替换成png就能定位到掩码。这套规则在分割数据集里很常见,也是自动化加载逻辑里最简单可靠的对齐方式。

文件名里那一串_jpg.rf.开头的哈希,是数据从标注平台导出时留下的身份标记,对训练本身没有任何影响。我见过有人试图把这串哈希当成图像特征参与建模,没必要;分割任务里文件名只是索引,真正决定数据质量的是图像与掩码的几何对齐关系。建议先写一段配对校验逻辑,遍历images目录里的jpg文件,检查masks里是否有同名的png,缺失数量不为零就要回头处理数据,不能带着缺漏直接开始训练,否则训练过程中会在某个epoch突然抛FileNotFoundError,那时候再排查目录结构代价就高了。

注意:不要用os.listdir的返回值顺序作为配对依据,那不可靠。要以jpg文件列表为准,用os.path.splitext把扩展名替换成png,再构造掩码路径去检查。

2.2 classes文件与0/1前景语义:单通道掩码为什么更稳

这套数据的核心约定是前景区域用0和1做阈值分割,落到实际文件里,就是掩码以单通道PNG存储,像素值为1的区域代表掌骨,为0的区域代表背景,X光片里骨组织与软组织的灰度对比明显,所以分割边界不需要人工二次修正。classes文件在这时候起两个作用:记录类别名与索引的对应关系,同时告诉后续使用方背景和前景的排序约定。大多数二分割任务的惯例是类别索引0对应背景、1对应前景,如果你的训练代码从1开始编类索引,Loss计算时类别就全体错位,验证集Dice直接飘到负数。

单通道0/1掩码比三通道RGB掩码省事得多。RGB掩码保存时经常带调色板,工程中常见的是用红色区域代表前景,加载时还得做颜色到类别的像素映射;而单通道0/1掩码本身就是标签值,读进来就能用。读取方式一般是用PIL的Image.open(掩码路径).convert("L")转成灰度,转成numpy数组后要注意取值范围:有些工具链会把掩码写成0和255,而不是0和1。如果是0/255,必须先除以255或者做一次阈值化,把它拉回0/1再送进二值交叉熵或Dice损失。这个细节我在YOLOv8训练自己的分割任务时踩过一次,PNG被读成0/255后整个掩码参与计算时全部被判成前景,损失曲线看起来正常,但Dice指标一直是0,属于最隐蔽的翻车点。

2.3 数据增强痕迹:缩放、翻转过的掩码不能独立变换

摘要里说“对部分数据进行了缩放、翻转图像增广”,意思是这批掌骨数据里已经有一部分样本做过离线增强,而且增强后的掩码是同步变换过的,几何关系自洽。这也带来一个隐藏信息:你无法从文件名直接看出哪些样本是增强出来的,所以后续如果要继续做在线增强,必须对图像和掩码做同步变换,不能把两路数据当成独立样本分别随机处理。

我一般推荐用albumentations库处理这类分割增强,它内部保证image和mask使用同一套变换参数;如果用torchvision的RandomHorizontalFlip,就要让image和mask共享同一个随机状态,保证翻转向量一致。还有一个容易忽视的边界:离线增强里的缩放操作如果填充成黑色,会在图像边缘引入一圈纯黑像素,这些像素会被当成背景参与训练。掌骨区域在256×512画面里本身占比不高,边缘黑边会进一步稀释前景的学习信号。我的做法是在加载图像时加一个尺寸断言,如果发现图像宽高不是512和256,先记录文件名统一做一次resize,而不是依赖训练管线隐式处理。

3. 可视化脚本:一次运行同时确认原图、GT掩码与叠加效果

3.1 脚本逻辑拆解:随机采样、三图并列与保存

数据集附带的可视化脚本解决的核心问题是“标签能不能直接用”。它会从训练集里随机抽一张图,把原始X光图、GT掩码图、以及GT叠加在原图上的效果并排画出来,最后保存成一张图片。这个过程比直接看文件列表直观得多:你能快速确认掩码是否贴合骨骼边缘、有没有空洞,前景0/1的边界是不是干净,顺便还能发现某些样本是否存在翻转导致的错位。

典型实现可以拆成四步:扫描images和masks目录构造同名配对列表;用random.choice从列表里抽一张;用PIL分别读入图像和掩码,掩码按单通道灰度读入并转成布尔类型;最后用matplotlib画三个子图并保存。下面这段代码可以当作参考模板,拿到数据集后对照它检查自己的目录名是否一致:

import os import random import numpy as np import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt from PIL import Image image_dir = "train/images" mask_dir = "train/masks" # 构造同名配对,不依赖listdir返回顺序 pairs = [] for f in sorted(os.listdir(image_dir)): if not f.endswith(".jpg"): continue mask_name = os.path.splitext(f)[0] + ".png" if os.path.exists(os.path.join(mask_dir, mask_name)): pairs.append((f, mask_name)) assert len(pairs) > 0, "没有找到任何图像-掩码配对,请检查目录名" # 随机抽一张样本 img_name, mask_name = random.choice(pairs) img = np.array(Image.open(os.path.join(image_dir, img_name)).convert("RGB")) mask = np.array(Image.open(os.path.join(mask_dir, mask_name)).convert("L")) mask_bool = mask > 0 # 不管原掩码是0/1还是0/255,统一变成布尔掩码 # 在原始图像上叠加半透明绿色蒙版 overlay = img.copy() overlay[mask_bool] = ( overlay[mask_bool] * 0.4 + np.array([0, 255, 0]) * 0.6 ).astype(np.uint8) fig, axes = plt.subplots(1, 3, figsize=(15, 5)) axes[0].imshow(img) axes[0].set_title("Original X-ray") axes[1].imshow(mask, cmap="gray") axes[1].set_title("GT mask") axes[2].imshow(overlay) axes[2].set_title("GT overlay") for ax in axes: ax.axis("off") plt.tight_layout() plt.savefig("vis_random_sample.png", dpi=150) print(f"样本已保存:{img_name}")

代码里的配对逻辑不依赖listdir的返回顺序,而是先对jpg文件名排序,再替换扩展名去构造掩码路径,从机制上保证图像和掩码一定对齐。读取掩码时用convert("L")转成单通道灰度,再做mask > 0的布尔化,这样存储值是0/1还是0/255都不影响叠加结果。叠加部分用的是绿色通道加权融合,实际就是把掌骨区域在原图上做了标亮,这张图可以作为快速验收样本,也可以直接拿去给临床同事做粗糙预览。

3.2 运行方式与依赖:不改代码直接运行的边界条件

如果你下载解压后的目录结构跟上面一致,也就是train/images和train/masks都存在,那么直接执行python visualize.py就能得到一张vis_random_sample.png,保存在当前工作目录下。运行前只要确认Python环境里有pillow、matplotlib、numpy三个库,缺少哪个用pip补哪个,版本没有特殊要求,Python 3.8以上都能跑。脚本里特意加了assert检查,避免目录配错时用空列表悄悄跑完、最后存一张空白面板的情况——那种错误最难排查。

我习惯把这第一张可视化图当作数据集验收的第一道手续。如果GT覆盖区域跟X光片上可辨认的掌骨轮廓基本重合,再进入下一步;如果看到掩码覆盖到了软组织甚至图像边框,就要怀疑掩码在导出时发生了平移。数据里已经有离线增强样本,所以偶尔抽到一张翻转过的图是正常的,只要掩码和骨骼位置仍然对得上,就可以放心用。

4. 把该数据集接入分割训练:从Dataset类到损失函数的选择

4.1 自定义PyTorch Dataset:配对读取、尺寸校验与二值转换

可视化脚本解决的是“数据能不能用”的问题,但让数据真正参与训练,还需要一个稳定的数据加载器。下面这个Dataset类专门针对这套掌骨数据设计,兼容PIL和PyTorch,读入后完成resize、归一化和二值化三个动作。关键点在于掩码的类别语义永远是前景1、背景0,所以从PNG里读出来的像素值不能直接当标签用,必须先做阈值判断:

import os import numpy as np import torch from torch.utils.data import Dataset from PIL import Image class BoneSegDataset(Dataset): def __init__(self, image_dir, mask_dir, size=(256, 512)): self.image_dir = image_dir self.mask_dir = mask_dir self.size = size self.names = [] for f in sorted(os.listdir(image_dir)): if f.endswith(".jpg") and os.path.exists( os.path.join(mask_dir, os.path.splitext(f)[0] + ".png") ): self.names.append(f) def __len__(self): return len(self.names) def __getitem__(self, idx): name = self.names[idx] mask_name = os.path.splitext(name)[0] + ".png" img = Image.open(os.path.join(self.image_dir, name)).convert("RGB") mask = Image.open(os.path.join(self.mask_dir, mask_name)).convert("L") img = img.resize(self.size, Image.BILINEAR) mask = mask.resize(self.size, Image.NEAREST) # 掩码不能用平滑插值 x = np.array(img).astype(np.float32) / 255.0 x = torch.from_numpy(x).permute(2, 0, 1) y = np.array(mask).astype(np.float32) y = (y > 0).astype(np.float32) # 统一转成0/1标签 y = torch.from_numpy(y).unsqueeze(0) return x, y

一个常见错误是掩码resize时也用BILINEAR甚至BICUBIC,这会在类别边界产生过渡灰阶,而二分割掩码的类别边界应该是硬边,所以这里用Image.NEAREST是必须的。图像除以255归一化到0到1,掩码通过(y > 0)统一成0/1,这样原始掩码是0/1还是0/255都不影响标签值域。返回时掩码加上了单通道维度,形状是(1, 256, 512),跟模型输出的(B, 1, H, W)对齐,后面直接接BCEWithLogitsLoss时不需要再压缩维度。

这里要额外提醒一点:不要对torch.from_numpy的结果直接做mask > 0判断,因为布尔张量会打断梯度链;先转成float再比较,是为了让掩码只参与Loss计算、不进入反向传播路径。这个细节在自定义数据集里经常被忽略,等训练到第二个epoch突然报RuntimeError时,想回头找问题就得翻一遍整个数据加载链路。

4.2 训练配置:BCE、Dice损失与掌骨小目标的评估口径

掌骨分割是典型的类不平衡二分割任务,掌骨区域在256×512的图像里通常只占10%到20%,背景占绝对多数。直接用普通交叉熵训练,模型很容易收敛到“全预测为背景”的退化解,因为这种解已经能拿到80%以上的准确率。实际训练时要把BCE和Dice损失联合使用,Dice损失直接优化区域重叠程度,对前景召回更敏感:

import torch.nn.functional as F def bce_dice_loss(pred, target): pred_prob = pred.sigmoid() bce = F.binary_cross_entropy(pred_prob, target) inter = (pred_prob * target).sum(dim=(2, 3)) union = pred_prob.sum(dim=(2, 3)) + target.sum(dim=(2, 3)) dice = 1.0 - (2.0 * inter + 1.0) / (union + 1.0) return bce + dice.mean()

这个函数先对logits做sigmoid转成概率,再分别算BCE和软Dice。inter和union按单样本在宽高维度聚合,所以返回的Loss是batch维度上的均值。分子分母里加1.0的平滑常量,是为了避免某个batch里target恰好全为零时出现除零,这在掌骨分割的边缘样本里确实会遇到。一般来说BCE和Dice两部分量级接近,不需要额外加权;如果训练初期Loss下降很慢,可以给Dice部分乘1.5,其余超参不变。

输入分辨率256×512对大多数分割模型都很友好。U-Net这类编码器-解码器结构在这个尺度下感受野足够覆盖整个掌骨区域;即使换成MobileNetV3这类轻量骨干,这个尺度也能保证骨骼边缘细节不丢失太多。训练时batch size建议8到16,更大的batch在小样本数据集上会加剧过拟合,毕竟训练集只有1486张;优化器用Adam,初始学习率1e-4起步,配合ReduceLROnPlateau在Dice连续5个epoch不涨时降学习率,这是我在类似医学分割项目里验证过比较稳的参数组合。

5. 避坑与常见问题:掩码全黑、尺寸不一致与增强错位的四项排查

5.1 现象:掩码读出来全黑或全白

跑完可视化脚本,GT掩码面板要么纯黑要么纯白,完全看不到骨骼结构。原因通常是掩码的存储值域和读取方式不匹配。如果掩码以0/255存储,代码里直接拿原始值做灰度显示,纯黑和纯白会同时出现,因为0和255对应灰度条的两端;如果掩码以0/1存储,但显示时用了默认灰度映射,视觉上也会偏黑。解决方式统一收敛到一条:读入后先执行mask > 0转成布尔掩码,再做显示或者参与Loss计算。我在这个数据集上的实践是不信任任何原始值,所有掩码进入模型前都强制过这一道,能省掉后面一整类疑难杂症。

5.2 现象:增强后掩码与骨骼区域错位

训练时加了水平翻转和随机旋转,跑几个epoch后发现验证集Dice异常低,抽看预测结果,骨骼位置跟原图对不上。原因是在线增强时图像和掩码没用同一套随机变换参数,或者旋转时掩码的填充值设置不对。解决方式是改用albumentations,它内部保证image和mask同步变换;如果坚持用torchvision,先把随机种子固定成同一个值,再分别对图像和掩码调用同一个随机翻转函数。最容易出问题的是旋转,旋转之后的边缘填充值默认补0,这部分会被当成背景参与训练,累积下来会让分割区域向内收缩。我的做法是旋转角度不超过15度,并且显式指定填充值。

5.3 现象:训练损失下降但Dice接近0

这是最隐蔽的一种翻车。Loss曲线一直在降,训练集准确率也很高,但Dice算出来就是0,原因大概率是标签值范围不一致:网络输出经过sigmoid后取值在0到1之间,掩码如果还是0/255,两个张量几乎不会产生交集,Dice自然趋近于0。另一个常见原因是在评估阶段把阈值设成了0,导致所有像素都被判定为前景。解决方式是在评估函数里统一执行(torch.sigmoid(pred) > 0.5),掩码统一执行(mask > 0),两边都落在布尔域里再算交集和并集,这样算出来的Dice才有对比意义。这条坑我踩过不止一次,现在每次写评估脚本都先确认阈值和标签域。

5.4 现象:脚本报FileNotFoundError,目录里明明有文件

错误信息提示找不到文件,但资源管理器里能看到对应的jpg和png。原因大多是工作目录和数据集目录不在同一层。可视化脚本普遍用相对路径train/images做寻址,如果把脚本放在项目根目录、数据集解压到别处,运行时就会找不到。解决方式是把目录结构理顺,让train文件夹跟脚本同级;或者在脚本开头用一个可配置的路径变量,统一管理数据根目录。我一般先执行一次ls train/masks | head -5看一下目录是否存在,再跑训练脚本,这一条小习惯能让一半的文件读取问题直接消失。

6. 进阶:用Dice与mIoU把二分割结果量化为可信数值

可视化只能证明“分割图看起来像”,要交付结果还得有数值指标。下面这段代码对模型输出的logits和GT掩码计算Dice与mIoU,两个指标都按类别分别计算后取平均,背景类也纳入统计。注意两个细节:预测概率阈值取0.5,背景类的预测由1减去前景概率推导出来,这样不用单独算背景分支:

import numpy as np def seg_metrics(pred, mask_bool, eps=1e-7): pred_bool = pred > 0.5 dice_list = [] iou_list = [] for c in range(2): pred_c = pred_bool if c == 1 else ~pred_bool mask_c = mask_bool if c == 1 else ~mask_bool inter = (pred_c & mask_c).sum() union = (pred_c | mask_c).sum() dice_list.append(2.0 * inter / (pred_c.sum() + mask_c.sum() + eps)) iou_list.append(inter / (union + eps)) return np.mean(dice_list), np.mean(iou_list)

背景和前景分别算IoU再取平均,能更均衡地反映分割质量;掌骨这类小面积目标,背景IoU往往在0.98以上,前景IoU可能只有0.75,只看均值会掩盖前景的真实水平,所以我通常前景IoU单独看一遍,均值只作为总体参考。

从那以后我每次拿到新的分割数据集,第一件事都是强制自己跑一遍可视化脚本,确认样本配对完整、掩码值域正常,再往下写数据加载和训练代码。这套掌骨数据让我把“先验收标签、再碰模型”这个习惯彻底固化下来了,希望帮到你。

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

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

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

立即咨询