☰
骨骼多类别分割数据集实战:U-Net训练避坑指南
2026/9/26 3:21:51 网站建设 项目流程

简介:面向医学图像分割任务的人体骨骼图像多类别分割数据集,共约3500张图片与对应掩膜标签,标注覆盖L5及头位(1-9)、椎管(10)、L5/S1椎间盘间隙及头位(11-19)等19个类别,适合医学影像分析、深度学习分割模型训练、论文实验和课程设计等场景。数据集已按训练集与测试集划分,其中训练集约2800对、测试集约700对,每个集合均包含images图片目录和masks模板目录;压缩包共2000个文件,以PNG图像为主(1998个),另含1个类别说明txt和1个可视化py脚本,整体约367MB。附带Python可视化脚本,可随机抽取一张样本,将原始图片、GT图像、GT在原图上的蒙板效果一并展示并保存到当前目录,便于快速核验标注质量,也能直观呈现分割任务的输入输出关系。已有339人学习浏览,包内目录结构清晰,按训练/测试组织,可直接用于多类别分割网络的训练、验证与结果对比,也方便二次扩展与标注检查。

1. 人体骨骼多类别分割:3500张图和标签,为什么值得先自己过一遍数据

“医学图像分割数据集”这个词,搜出来大多是下载链接和榜单指标,但真正把一份人体骨骼图像分割数据集用进训练里,卡点往往不在模型而在标签。3500张图像和对应掩膜,量不大不小,做多类别分割时,图像模态、类别编号、掩膜格式、训练验证划分,每一样都能让结果天差地别。这篇笔记把整条流程拆开:多类别骨骼分割和二分类分割差在哪,数据检查与预处理脚本怎么跑,U-Net训练的关键参数和翻车点在哪。适合做骨折检测、骨密度分析、手术规划这类需要先分割出骨骼区域的人,也适合拿多类别分割练手的数据集玩家。

2. 骨骼分割数据集的格式与检查:先盘清图像、标签和类别再动手

拿到一份人体骨骼分割数据集,直接解压开训是最快的翻车方式。多类别骨骼分割里,标签的语义不是简单的前景和背景,而是“这块像素属于哪一块骨”。这份约3500张的数据集,如果连类别编号都没核对过,训练跑出来的指标再漂亮也是自欺欺人。这一章先把数据侧的事情说明白。

2.1 多类别骨骼分割和单类别分割差别在哪:模态与类别体系决定后续一切

单类别分割只回答一个问题:这个像素是不是骨骼。多类别分割要回答:这个像素属于哪一块骨。同样是分割模型,输出通道从2变成N+1,损失从二分类变成多分类,难度不是线性增长,而是类别边界处会互相打架。膝关节数据里股骨远端和胫骨近端在X光片上几乎贴在一起,标注线稍有偏差,训练出来的边界就和金标准对不上。

影像模态决定了这个数据集的难度上限。同样是3500张图,来源不同,模型要学的东西完全不同:

模态边界表现标注难度适合的网络结构
X光(DR)骨骼重叠区域多,软组织干扰明显需要解剖经验,边界主观性强2D网络,依赖增强和更大感受野
CT骨骼与软组织灰度差大,边界清晰切片多,标注工作量大2D网络逐片训练,或用3D网络
MRI骨髓、软骨、肌肉对比复杂边界判定争议大2D网络,需要针对性增强

类别体系也要先确认。不同数据集的“多类别”定义差异很大:有的是按部位分的髋关节数据,标签是“骨盆、股骨近端”;有的是膝关节数据,标签是“股骨、胫骨、腓骨、髌骨”;还有的是全身骨骼按区域编号。类别数越多,小类别被大类别淹没的问题越严重。拿到数据后第一件事就是看类别元信息,确认背景是0还是255,确认有没有未标注的忽略区域,这些直接决定损失函数的写法。

2.2 目录结构和标注格式的常见形态:解压后先画一张数据地图

骨骼分割数据集的目录结构通常长这样:

bones_dataset/ ├── images/ │ ├── case_001.png │ ├── case_002.png │ └── ... ├── labels/ │ ├── case_001.png │ ├── case_002.png │ └── ... ├── train.txt ├── val.txt └── dataset.yaml

images目录放原始图像,labels目录放同名的分割掩膜,train.txt和val.txt是官方划分。有的数据集会直接按train/val子目录组织。文件清单一般涉及这几类:

文件/目录用途注意事项
images/原始图像多为灰度图,注意位深,8bit还是16bit直接影响读取方式
labels/分割掩膜与图像同名,像素值等于类别编号,需确认0是否代表背景
train.txt / val.txt数据划分列表没有划分文件时要自己按患者划分,不能随机划分
dataset.yaml类别名称与编号有的话先读它,能省掉猜标签含义的时间

标注格式最常遇到两种:PNG掩膜和COCO JSON。PNG掩膜是单通道图像,每个像素值就是类别编号,U-Net系模型直接用,扫一眼就能确认格式是否合理。COCO JSON是用多边形坐标描述每个实例,灵活但需要先转成掩膜才能训练。骨骼分割数据集中PNG掩膜更常见,但如果拿到的是JSON,就得准备好转换脚本。

提示:不管哪种格式,先用训练脚本之外的小工具确认“图像、掩膜、类别”三者能对上,再谈训练。这个习惯能省掉后面大量排查时间。

2.3 用Python跑一遍数据完整性检查:缺图、错尺寸、脏标签一次暴露

我每次拿到新数据都会先跑一份完整性检查脚本,20分钟能写完,能挡住后面一周的坑。

import os from PIL import Image import numpy as np img_dir = "images" label_dir = "labels" img_files = sorted(os.listdir(img_dir)) label_files = sorted(os.listdir(label_dir)) # 1. 文件一一对应检查 img_names = {f.rsplit(".", 1)[0] for f in img_files} label_names = {f.rsplit(".", 1)[0] for f in label_files} missing = img_names - label_names print("缺标签的图像数量:", len(missing)) # 2. 尺寸一致性抽查 for f in img_files[:20]: img = Image.open(os.path.join(img_dir, f)) lbl_name = f.rsplit(".", 1)[0] + ".png" lbl_path = os.path.join(label_dir, lbl_name) if not os.path.exists(lbl_path): continue lbl = Image.open(lbl_path) if img.size != lbl.size: print("尺寸不一致:", f, img.size, lbl.size) # 3. 标签像素值分布抽查 for f in label_files[:10]: lbl = np.array(Image.open(os.path.join(label_dir, f))) print(f, "类别值:", np.unique(lbl))

这段脚本的逻辑分三段:先用文件名匹配查出缺标签的样本,这类问题在数据搬运过程中最常见;再抽查图像和掩膜尺寸是否一致,很多时候预处理脚本单独对图片做了resize,标签没跟上,后面训练时索引就会错位;最后看标签的像素值分布,重点确认类别编号是否连续、是否只有0和1、有没有出现255这种异常值。

如果数据里有DICOM格式的CT图像,PIL读不了,需要先用pydicom把像素数组解出来并转成PNG或npy再进这个流程。这一步做在前,后面省的是大把训练时间和无效debug。

3. 把骨骼分割数据集喂进U-Net:预处理、标签映射与基准训练配置

数据检查通过后,下一步是把图像和标签整理成模型能吃的样子。骨骼分割和自然图像分割最大的不同在于:原始图像是灰度图,标签是稀疏的类别编号,预处理每一步都要保证图像和标签对齐。U-Net是医学图像分割里最稳的基准模型,3500张这个规模用它做实验正合适。

3.1 预处理管线:灰度归一化、尺寸标准化与同步增强

常见做法是统一尺寸到512×512或256×256,灰度图归一化到[0,1]区间,训练时做同步增强。这里最容易被忽略的是:图像resize用双线性插值没问题,标签resize必须用最近邻插值,否则会插出“1.5类”这种根本不存在的像素值。

import cv2 import numpy as np import albumentations as A def load_and_preprocess(img_path, label_path, size=512): img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) img = cv2.resize(img, (size, size), interpolation=cv2.INTER_LINEAR) img = img.astype(np.float32) / 255.0 label = cv2.imread(label_path, cv2.IMREAD_GRAYSCALE) label = cv2.resize(label, (size, size), interpolation=cv2.INTER_NEAREST) return img, label # 同步增强:图像和标签必须用同一组随机变换 transform = A.Compose([ A.RandomRotate90(p=0.5), A.HorizontalFlip(p=0.5), A.ElasticTransform( alpha=120, sigma=120 * 0.05, alpha_affine=0, p=0.3, ), ]) def augment(img, label): out = transform(image=img, mask=label) return out["image"], out["mask"]

读取统一走OpenCV的灰度模式,避免PIL把16bit图像自动转成8bit导致灰度信息丢失。归一化用整体最大值还是直接用255,取决于图像本身的位深。如果原始图就是8bit PNG,直接除255就行。如果是从DICOM转出来的,像素值范围可能是-1024到3071这种CT值区间,那就不能粗暴除以255,需要对窗宽窗位裁剪后再归一化。

增强这块直接用albumentations的Compose,它的内部机制会保证image和mask走同一组随机变换。ElasticTransform对骨骼这类形态变化有天然优势,弹性形变模拟关节弯曲带来的结构变化。但要注意alpha和sigma参数不能乱给,alpha太大图像会扭曲到解剖结构失真,alpha太小等于没增强。120这个值对512×512图像是一个比较稳的起点,实际使用时可以调成alpha=80试一版对比。

3.2 标签映射:从标注像素值到模型输出通道

做多类别分割,模型输出通道数等于“类别数+1”(背景也要占一个通道)。如果数据集给的标签恰好是0到N-1且连续,那直接当索引用就行。但现实往往不这么听话,常见的是0和255、缺号、类别编号从5开始。此时就需要重映射。

# 假设原标签只有背景0和前景255,改成二分类的0/1 label_map = {0: 0, 255: 1} # 假设原标签是 0, 1, 2, 5(缺了3和4),压成 0, 1, 2, 3 label_map = {0: 0, 1: 1, 2: 2, 5: 3} remapped = np.zeros_like(label, dtype=np.uint8) for old, new in label_map.items(): remapped[label == old] = new

注意这个映射不能用原地替换,比如把5改成3,如果label里本来就有3的像素,第一次循环就把原3覆盖了,第二次循环又覆盖成新3,逻辑全乱。用新数组按old值匹配写入,才是最稳的写法。

如果拿到的是COCO JSON标注,需要把多边形点集先转成掩膜再进训练流程。转换时用多边形填充后的掩膜做类别判断,注意多边形自相交导致的问题,常见的shapely库或OpenCV的fillPoly都能处理,但填充前要先确认多边形坐标是归一化坐标还是绝对像素坐标,这个看数据集说明即可。

做过yolov8训练自己的数据集的读者,刚接触这类医学分割数据时会下意识去找TXT标签文件。其实PNG掩膜才是分割数据集的常见答案,目标检测的框和分割掩膜在标签表示上完全是两个体系,在数据加载阶段就要把思维切换过来。

如果数据集中部分边界区域标注本身就模糊,可以考虑给边界像素做软标签。把边界几个像素的one-hot编码从“0或1”改成“0.7/0.3”的过渡值。软标签相当于告诉模型“这块区域标注本身不确定,你别太自信”,能提升边界稳定性,但只建议在标注质量确实一般时用。数据集标注质量高的话,软标签反而会让边界变钝。

3.3 U-Net基准训练:损失函数、学习率与类别权重怎么设

3500张图的规模,U-Net从零训练加上预训练迁移都可行。我的建议是:先跑一个纯U-Net从零训练的基线,把训练流程和数据处理验证通了,再考虑加载ImageNet预训练权重或做更大模型。骨架代码和关键参数如下:

model = UNet(in_channels=1, out_channels=5) # 背景 + 4个骨骼类别 # 类别权重按像素占比倒数计算,防止大骨骼区域淹没小骨骼区域 freq = np.bincount(all_label_ravel, minlength=5) class_weight = torch.tensor(1.0 / np.maximum(freq, 1), dtype=torch.float32) class_weight = class_weight / class_weight.sum() * len(class_weight) criterion = nn.CrossEntropyLoss(weight=class_weight) dice_loss = DiceLoss() # 逐类算Dice后取平均 optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-5) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode="max", factor=0.5, patience=10 ) # 每个epoch后验证,保存验证Dice最高的权重 for epoch in range(60): train_one_epoch(model, train_loader, criterion, dice_loss, optimizer) val_dice = validate(model, val_loader) scheduler.step(val_dice)

out_channels有5个,因为多类别分割要把背景也算一个类别。CrossEntropyLoss处理类别均衡问题,类别权重按像素占比倒数算,这样才能让胫骨这类中等大小的骨骼不被股骨完全压住。DiceLoss在医学分割里几乎是标配,和CrossEntropy联合使用是常见做法,前期CE提供稳定梯度,后期Dice精细调整边界。

AdamW配1e-4是医学图像小数据集上比较稳的组合,Adam默认的1e-3在这个场景下容易把模型震荡到边界不收敛。ReduceLROnPlateau按验证Dice是否停滞来降学习率,比按epoch硬降更实用。epoch设60是合理预算,3500张图用512分辨率加batch size 8,单卡24G显存大约几小时能跑完一版,足够把数据处理问题暴露干净。

这个阶段如果验证Dice一直很低,别急着调学习率。先回数据侧查:标签是不是错位了、类别编号是不是映射错了、增强是不是把标签破坏了。模型结构本身很成熟,绝大多数训练失败都指向数据和标签,这算是做分割数据集项目的一条血泪经验。

4. 避坑:骨骼多类别分割数据最常见的5个翻车点

这一章写的都是真实训练时最容易翻车的五个场景,每个都按现象、原因、解决的顺序说清楚。新手照着排查,熟手做自查清单也够用。

4.1 掩膜里全是0和255:类别通道对不上,loss直接崩

现象:数据检查时用看图软件打开掩膜,图像上骨骼区域是白的,背景是黑的,看起来很正常。训练时loss要么一路nan,要么不下降。打印标签的np.unique发现只有[0, 255]。

原因:很多标注工具导出的PNG掩膜是二值图,最终保存成了8bit单通道,背景0、前景255。模型输出通道一共只用5个(0-4),255这个值本质上等效于把类别索引写到越界位置,CrossEntropyLoss按索引取值时直接飞到无效区域。

解决:所有训练流程接入之前,先把标签统一重映射成从0开始的连续编号。二分类就映射成0和1,多分类则按照2.3节的安全映射脚本处理。映射后再跑一次np.unique确认类别编号连续且最大编号等于输出通道数减一。

4.2 标签和图像尺寸不一致:resize把掩膜插出了“1.5类”

现象:验证集Dice看着正常,但可视化预测结果时发现整个掩膜向左上角偏移,或者骨骼边缘全部错位一个固定像素量。单看一个样本可能不太明显,多对比几个就发现错位是系统性的。

原因:图像和标签分别做了resize,图像用双线性插值,标签也用了同样的插值方法。最近邻以外的插值方法会在类别边界处产生“过渡像素值”,比如类别1和类别2之间出现1.3、1.7这种非整数。模型输出是离散类别,和这种带小数标签对不上,训练时梯度反复拉扯。

解决:标签resize一律用cv2.INTER_NEAREST,代码写在3.1节里了。更彻底的排查方法是写一段断言,确保标签数组里每个值都是整数且在合法类别范围内,在Dataset的__getitem__入口加一次校验,可以挡住绝大多数脏数据。

4.3 验证集随机划分:同一个患者的片子被拆进两边,指标虚高

现象:训练Dice和验证Dice都到0.9以上,但换到外部数据或者新患者上效果断崖式下跌。查看数据发现一个患者有多张图像,比如一个膝关节X光正侧位就2张,这个患者的部分片子在训练集,另一部分在验证集。

原因:随机划分样本时没有按照患者ID分组。模型在训练时见过同一个患者的其他片子,相当于提前偷看了答案。医学图像分割数据集中这是一个极容易被忽略的泄漏点,症状就是验证指标好看但落地效果差。

解决:划分数据集时先提取患者ID字段,再按患者ID分组划分。把同一个患者的所有图像全部放进训练集或验证集,不能用sample级别随机。检查自己数据集里有没有患者维度,如果没有明显ID,就按序列前缀(比如文件名前几位的case号)分组。

4.4 小骨骼类别整片丢失:Dice再高也没有临床价值

现象:整体Dice达到0.9,按类别看,股骨Dice0.96,髌骨Dice只有0.2。模型几乎没把髌骨分割出来,但总的平均Dice被大骨骼拉高,很难发现。

原因:骨骼数据天然类别不平衡。股骨和胫骨区域大,髌骨区域小,如果损失函数对所有像素一视同仁,模型把大部分精力用来优化大区域,小类别即使整片丢掉,总体损失也只增加很小一点。

解决:多类别Dice必须逐类计算再平均,不能把所有类别当成一个整体算全局Dice。损失函数层面,CrossEntropyLoss加上按像素占比倒数计算的类别权重,训练日志里每个epoch都打印各类别的Dice,而不是只看平均值。只要发现某个类别Dice明显低于其他类别,就要考虑小类别增强策略,比如对小骨头区域做裁剪训练。

4.5 数据增强把标签改坏了:旋转和填充引入了幽灵类别

现象:翻转和旋转增强开了之后,训练过程中出现过“意想不到的类别值”,比如本来只有4类骨骼加背景,训练时报错出现类别5。

原因:任意角度旋转时,图像四角会露出空白区域,需要用常量值填充。如果填充值设成和某个骨骼类别一样,标签在边界处就会被写成对应类别的编号;如果填充后标签区域产生了非整数插值结果,那就是新的非法值。更隐蔽的情况是旋转过程中掩膜被插值成了非整数。

解决:增强配置里,一是优先使用90度倍数的旋转,比如A.RandomRotate90,它不会产生角部空白问题;二是必须用A.ElasticTransform这类弹性变换时,albumentations内部会保持mask插值为最近邻,但填充值要显式指定。检查方法也很简单:每个epoch随机抽样几个增强后的标签,打印np.unique的值集合,确认它和原始类别集合完全一致。

人的肉眼很难从可视化图上看出1像素级别的标签破坏,所以这类问题要靠数据管线里的断言来防。我的习惯是在增强后加一行代码:assert set(np.unique(label)) <= valid_classes,集合判断只要有一次越界就直接中断训练,留着跑总比带着病跑完一个epoch再回头查强。

5. 用Dice和边界距离验证分割:从指标到下游可用的最后一步

Dice是医学图像分割最常用的指标,但多类别场景下怎么算、算哪个类,直接决定你看到的数字有没有意义。

def dice_per_class(pred, gt, num_classes): dice_list = [] for c in range(num_classes): p = (pred == c).astype(np.uint8) g = (gt == c).astype(np.uint8) inter = np.sum(p * g) union = np.sum(p) + np.sum(g) dice_list.append((2.0 * inter) / (union + 1e-6)) return dice_list

预测输出是argmax后的类别索引,与GT逐类别比较。返回的列表里每个元素对应一个类别的Dice,这才是你要着重看的指标。整体Dice再高,也不能掩盖髌骨这类小骨头Dice只有0.2的问题。

Dice对小结构不敏感,这是它固有的短板。一个直径10像素的小骨骼,即使完全分割错位,对整体Dice影响可能不到0.01。想验证模型真的学到了骨骼边界,建议加上边界距离指标HD95(Hausdorff距离的第95百分位)。计算预测边界和真实边界之间的最远距离,单位是像素,对边缘质量特别敏感。边界错位几个像素,Dice可能只掉0.01,HD95能直观反映出毫米级别的偏移。

验证通过后再谈下游。骨骼分割很少是终点,常见下游方向包括:分割掩膜用于骨折区域定位,把多边形区域输入分类网络判断骨折类型;分割结果做中心线提取,辅助测量骨角度和长度;连续切片的掩膜堆叠起来做三维重建,用于手术规划。3500张数据训练的模型,如果验证集Dice过0.9、HD95在合理像素范围内,完全可以作为下游任务的输入,不用再回头折腾分割模型。

我自己吃过的教训是:指标和可视化必须一起看。Dice再高,也要随机抽20个验证集样本把预测和GT叠在一起看边界,这一步能看到所有数值指标都看不到的结构性错误。拿到新数据集先跑一遍完整性检查,再谈训练,这个习惯省下来的时间足够跑完两轮消融实验。希望帮到你。

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

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

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

立即咨询