简介:基于X光的骨头肿瘤图像语义分割数据集,面向医学影像分析与深度学习研究者,可用于训练和评估肿瘤区域分割模型。数据集已划分好训练集和验证集,训练集约1100张图像及对应mask,验证集约500张,标注背景与肿瘤两个类别,像素级标签可参考classes文件,适合直接开展语义分割实验。包内共2000个文件,其中png与jpg图像约1998张,另附类别说明txt文件和可视化脚本py文件,整体压缩后约23.79MB,存储与加载都较为轻量。可视化脚本能随机抽取样本,将原始图、GT掩膜及GT叠加在原图上的效果并列展示,便于快速检查标注质量和模型预测结果,也能辅助理解网络输出与真值间的差异。目前已有133人学习下载,对入门医学图像分割或利用Unet、SwinUnet等网络进行改进验证的读者而言,是一份可直接上手的数据基础。
1. 医学图像分割数据集:X光骨头肿瘤数据集到底能不能直接拿来训模型
做过分割项目的人都知道,找数据集是最磨人的一环。公开的医学影像数据集不少,但要么是CT、要么是MRI,X光平片并且带肿瘤标注的很少,尤其是骨头区域的肿瘤语义分割数据,能直接拿来训练和验证的更是稀缺。这份基于X光的骨头肿瘤图像语义分割数据集,大概是1600张左右图片和对应的mask标签,已经划分好训练集和验证集,还带一个可视化脚本,拿到手就能开始跑分割网络。适合正在做医学图像分割、语义分割算法验证、或者想用Unet、SwinUnet、TransUnet这类模型做实验的人,也适合研究生用来做毕业设计的实验数据支撑。我拆完这套资源后,先说一下总体感受:数据划分合理、标签格式直观,但里面的坑也不少,尤其是mask的像素值解读和类别映射,新手很容易在这里翻车。这篇文章会把数据组织方式、训练注意点、可视化脚本用法和踩坑记录全部写清楚。
2. 数据组织与标签格式:先搞懂images和masks的对应关系
2.1 数据目录结构解析
拿到数据集之后,首先看到的是已经划分好的训练集和验证集目录。常见做法是每个集合下都有两个子目录,一个叫images存放原始X光图片,一个叫masks存放对应的分割标签图。以这份数据集来说,训练集大概是1100张图片和对应的mask图片,验证集是500张左右。
从文件命名来看,图片是数字编号加.jpg后缀,比如162.jpg、224.jpg、788.jpg这类,mask文件的命名通常与原始图片保持一致,只是放在另一个目录下。也就是说,如果你要读取某张图片,直接通过文件名去masks目录找同名文件即可。
# 数据集目录结构示意 ├── train │ ├── images │ │ ├── 162.jpg │ │ ├── 224.jpg │ │ └── ... │ └── masks │ ├── 162.png │ ├── 224.png │ └── ... └── val ├── images │ ├── 788.jpg │ └── ... └── masks ├── 788.png └── ...注意这里有个细节,原始图片是.jpg格式,但mask一般是.png格式。因为.png支持无损压缩,而且可以存储单通道的灰度标签图,不会像.jpg那样引入压缩伪影,影响到像素级别的标签精度。在做分割任务时,mask用.png是行业惯例,看到这个组合基本可以放心。
2.2 标签像素值与classes文件的对应
这个数据集是2类别分割,即背景和肿瘤。关键点是mask图片中的像素值并不一定是0和255,而是具体参考classes文件。这一点非常重要,因为很多人在使用数据集时,习惯性地以为mask里白色区域就是目标、黑色区域就是背景,但实际上有些数据集的标注方式是用0和1、或者0和255来表示,不同数据集的约定不一致。
# 读取classes文件,了解类别与像素值的映射 with open("classes.txt", "r") as f: lines = f.readlines() for idx, line in enumerate(lines): print(f"Class {idx}: {line.strip()}") # 检查mask中实际出现的像素值 import numpy as np from PIL import Image mask_path = "train/masks/162.png" mask = np.array(Image.open(mask_path)) unique_vals = np.unique(mask) print("像素值列表:", unique_vals)这段代码的逻辑很简单,先把classes文件的内容打印出来,得到每个类别对应的标签名称,然后用PIL读取mask并转换成numpy数组,用np.unique统计图片中出现的所有像素值。结果通常会看到比如[0, 255]或[0, 1]这种情况。如果classes文件里写着背景和肿瘤,而mask像素值是0和255,那训练时就需要把255归一化成1。
参数说明:PIL的Image.open不会自动将图片转为灰度模式,如果mask是三通道彩色图,读取得到的数组shape是(H, W, 3),但一般语义分割的mask都是单通道灰度图,shape是(H, W)。如果不确定,可以用mask.mode查看图像模式,常见的是"L"或"P"。
2.3 训练集与验证集的划分逻辑
数据集已经给好了train和val两个目录,省去了自己划分的麻烦。训练集1100张左右、验证集500张左右,比例大约是7:3。训练集图片和mask图片数量保持一致是最基本的质量要求,建议拿到数据后先做一次数量校验,防止文件缺失导致训练报错。
import os train_images = os.listdir("train/images") train_masks = os.listdir("train/masks") val_images = os.listdir("val/images") val_masks = os.listdir("val/masks") print(f"train images: {len(train_images)}, train masks: {len(train_masks)}") print(f"val images: {len(val_images)}, val masks: {len(val_masks)}") # 检查同名文件是否一一对应 for img in train_images: mask_name = img.replace(".jpg", ".png") # 根据实际情况调整后缀 if mask_name not in train_masks: print(f"Missing mask for {img}")这里我一般会做两层校验,第一层是数量是否匹配,第二层是文件名是否一一对应,因为有时候数据打包过程中会漏文件或者命名错位。替换后缀时需要先确认mask的后缀到底是.png还是.jpg,不要盲目替换。
3. 数据加载与预处理:写一个标准的PyTorch数据流水线
3.1 Dataset类的设计与实现
拿到数据集后,最核心的一步是写一个可以正常迭代的Dataset类。在PyTorch框架下,需要继承torch.utils.data.Dataset,实现__len__和__getitem__两个方法。上传图片、读取mask、做数据增强,这是语义分割任务的标配流程。
import torch import numpy as np from torch.utils.data import Dataset from PIL import Image import os class BoneTumorDataset(Dataset): def __init__(self, image_dir, mask_dir, transform=None): self.image_dir = image_dir self.mask_dir = mask_dir self.transform = transform self.image_files = sorted(os.listdir(image_dir)) self.mask_files = sorted(os.listdir(mask_dir)) def __len__(self): return len(self.image_files) def __getitem__(self, idx): img_name = self.image_files[idx] mask_name = img_name.replace(".jpg", ".png") img_path = os.path.join(self.image_dir, img_name) mask_path = os.path.join(self.mask_dir, mask_name) image = Image.open(img_path).convert("RGB") mask = Image.open(mask_path).convert("L") if self.transform: image, mask = self.transform(image, mask) # 将mask像素值映射到0和1 mask = np.array(mask) mask = (mask > 0).astype(np.float32) # 转成Tensor image_tensor = torch.from_numpy(np.array(image)).permute(2, 0, 1).float() / 255.0 mask_tensor = torch.from_numpy(mask).unsqueeze(0) return image_tensor, mask_tensor这个Dataset的精髓在于mask的像素值映射,先把mask读取为灰度模式,然后通过mask > 0把大于0的像素全部变成1,背景变成0。这个操作是有前提的,前提是确认mask中肿瘤区域的像素值不是负数,并且背景像素值为0。如果背景像素值是0、前景像素值是255,这样处理就是安全的。
参数说明:transform参数预留了数据增强的入口,因为有时候需要同步对image和mask做相同的随机裁剪、翻转等操作,不能只对image做增强而mask不做。torch.from_numpy(...).permute(2, 0, 1)是把HWC格式转成CHW格式,float类型除以255是为了归一化到[0,1]区间。
3.2 数据增强的同步处理策略
分割任务与分类任务最大的区别在于,对输入图片做任何形变类增强时,mask必须跟着一起做同样的变化,否则标签和图像内容就会错位。常见的增强包括水平翻转、垂直翻转、随机旋转、随机裁剪和缩放。
import random from PIL import Image class RandomHorizontalFlip: def __call__(self, image, mask): if random.random() < 0.5: image = image.transpose(Image.FLIP_LEFT_RIGHT) mask = mask.transpose(Image.FLIP_LEFT_RIGHT) return image, mask class RandomResizedCrop: def __init__(self, size, scale=(0.8, 1.0)): self.size = size self.scale = scale def __call__(self, image, mask): # 这里简化处理,用中心裁剪代替,实际中可用torchvision.transforms.RandomResizedCrop w, h = image.size new_w, new_h = int(w * random.uniform(self.scale[0], self.scale[1])), int(h * random.uniform(self.scale[0], self.scale[1])) left = random.randint(0, w - new_w) top = random.randint(0, h - new_h) image = image.crop((left, top, left + new_w, top + new_h)).resize((self.size, self.size)) mask = mask.crop((left, top, left + new_w, top + new_h)).resize((self.size, self.size), Image.NEAREST) return image, mask这里特别要注意的是mask的resize方式,必须使用Image.NEAREST最近邻插值,而不能用BILINEAR或BICUBIC。因为mask是离散的类别标签,不是连续的颜色值,用线性插值会在类别边界产生介于两个类别之间的数值,比如0.3、0.7这种无效值,训练时计算loss就会出现问题。
3.3 训练时如何正确计算Dice Loss或CrossEntropy Loss
分割任务常用的损失函数是CrossEntropyLoss和DiceLoss,也有两者结合的方式。如果用CrossEntropyLoss,需要保证模型输出的通道数等于类别数,也就是2,并且mask的shape要和模型输出的空间尺寸一致。如果用DiceLoss,一般输入的是经过sigmoid或softmax后的概率图,以及二值化的GT。
import torch import torch.nn as nn class DiceLoss(nn.Module): def __init__(self, smooth=1.0): super(DiceLoss, self).__init__() self.smooth = smooth def forward(self, pred, target): pred = torch.sigmoid(pred) pred_flat = pred.view(pred.size(0), -1) target_flat = target.view(target.size(0), -1) intersection = (pred_flat * target_flat).sum(dim=1) dice = (2.0 * intersection + self.smooth) / (pred_flat.sum(dim=1) + target_flat.sum(dim=1) + self.smooth) return 1.0 - dice.mean()DiceLoss对类别不平衡敏感度较低,在医学图像分割中特别常用,因为肿瘤区域往往在整张X光片中只占很小比例,前景和背景像素数量可能相差几十倍甚至上百倍。如果只用CrossEntropyLoss,模型很容易偏向预测背景,导致Dice指标上不去。
参数说明:smooth参数是防止分母为0的平滑项,一般取1.0。注意这里pred和target的shape要保持一致,如果mask是从数据流水线中读取的,已经做了unsqueeze(0)操作,shape就是(B, 1, H, W),可以正常计算。
4. 可视化脚本:验证数据标签是否对齐的关键工具
4.1 分析可视化脚本的作用
数据集附带了一个图像分割的可视化脚本,作用是随机提取一张图片,将原始图片、GT图像、GT在原图上的蒙板效果展示出来,并保存到当前目录。这个脚本的价值在于,训练前快速检查数据是否有问题,比如mask是否和原图对齐、肿瘤区域是否在正确的位置。
运行这个脚本后,你会得到类似这样的效果:
# 假设脚本名为visualize.py python visualize.py脚本会在当前目录生成一张包含三个子图的对比图,第一张是原始X光片,第二张是GT mask,第三张是用mask在原图上叠加的半透明蒙板效果。这个蒙板效果是用OpenCV的方式实现的,本质上就是把mask区域的颜色通道赋予一个固定颜色,然后与原始图像混合。
import cv2 import numpy as np import matplotlib.pyplot as plt import os def visualize_sample(image_path, mask_path, save_path): image = cv2.imread(image_path) mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) mask_binary = (mask > 0).astype(np.uint8) # 生成红色蒙板 overlay = image.copy() overlay[mask_binary == 1] = (0, 0, 255) # BGR 红色 blended = cv2.addWeighted(image, 0.7, overlay, 0.3, 0) plt.figure(figsize=(15, 5)) plt.subplot(1, 3, 1) plt.imshow(cv2.cvtColor(image, cv2.COLOR_BGR2RGB)) plt.title("Original") plt.subplot(1, 3, 2) plt.imshow(mask, cmap="gray") plt.title("GT Mask") plt.subplot(1, 3, 3) plt.imshow(cv2.cvtColor(blended, cv2.COLOR_BGR2RGB)) plt.title("Overlay") plt.savefig(save_path, dpi=150, bbox_inches="tight") plt.close()这段代码展示了可视化的核心逻辑,先用阈值把mask二值化,红色蒙板在BGR颜色空间中对应(0, 0, 255),然后用addWeighted混合原图和蒙板,透明度可以自己调节。我在复现过程中一般会把混合比例改成0.6和0.4,因为X光片的背景较亮,蒙板太淡看不清楚位置。
4.2 可视化在检查mask质量问题时的应用
可视化不只是为了看效果,更是一种数据质量检验手段。通过观察叠加后的图片,能发现几类问题:
第一,mask是否有偏移,如果mask与原图的解剖结构错位,说明标注或者数据集打包过程中存在空间对齐问题。第二,mask是否包含噪点,有些生成的数据集在肿瘤边缘会有零散的孤立像素点,这些噪点会影响模型收敛。第三,mask区域是否覆盖了整个肿瘤区域的边界,如果边界存在大面积缺角,说明标签本身在标注时不够精细,训练出来的模型就会学到不完整的边界形状。
我在跑通这份数据时,随机抽了十张图做可视化,其中有一张图明显出现mask比实际肿瘤区域小了一圈的情况,这个在X光片上很难直接判断是标注问题还是原始图像对比度低导致的人为误差。遇到这种情况,可以考虑在训练时对mask做一次膨胀操作,让标签边界更鲁棒一些,但这个操作要谨慎,膨胀过大会引入背景像素。
4.3 自定义可视化为训练服务
如果你不满足于附带的脚本,可以把它改造成一个批量检查工具,把训练集和验证集中所有样本的mask面积统计出来,用来评估类别平衡情况。
import cv2 import os import numpy as np mask_dir = "train/masks" area_list = [] for mask_file in sorted(os.listdir(mask_dir)): mask = cv2.imread(os.path.join(mask_dir, mask_file), cv2.IMREAD_GRAYSCALE) binary = (mask > 0).astype(np.uint8) area = binary.sum() area_list.append(area) print(f"平均肿瘤面积: {np.mean(area_list):.2f} 像素") print(f"最大面积: {np.max(area_list)}, 最小面积: {np.min(area_list)}")这个统计的意义在于,如果肿瘤区域平均面积只有几百像素,而整张图像是2000x1500像素,那背景比例超过99%,这时候训练时必须考虑使用Dice Loss或者在采样时做前景区域裁剪,否则模型会严重偏向背景预测。
5. 避坑指南:这份数据集最常见的五个坑
5.1 坑一:mask像素值是255,不是1
现象:直接用nn.CrossEntropyLoss训练,loss始终不下降或者示数异常高。
原因:mask里的前景像素值是255,而模型输出的logits经过softmax后取值范围是0到1,交叉熵计算时把255当作了一个非常大的类别目标,导致梯度极度不稳定。
解决:在Dataset中把mask缩放到0和1的范围内。
mask = np.array(mask) mask[mask > 0] = 1注意这个操作要在转为Tensor之前完成,否则255的浮点数进入网络后计算loss时会溢出或者产生NaN。
5.2 坑二:mask和图片文件名后缀不一致
现象:数据加载器报错,找不到对应的mask文件,或者加载的mask是另一张图片的。
原因:图片是.jpg,mask可能是.png,如果直接把.jpg后缀替换成.png可能没问题,但有些情况下mask文件名后缀是.jpg或者没有任何后缀,导致匹配失败。
解决:先用脚本列出所有mask文件名,与images目录逐一比对,确认命名规则后再写加载逻辑。
import os img_files = os.listdir("train/images") mask_files = os.listdir("train/masks") img_names = [f.split(".")[0] for f in img_files] mask_names = [f.split(".")[0] for f in mask_files] for name in img_names: if name not in mask_names: print("Mismatch:", name)5.3 坑三:图像尺寸不一致导致batch无法拼接
现象:模型训练时报错,提示stack操作时维度不一致。
原因:X光片的原始尺寸可能多种多样,有的图片是2000x1500,有的是1024x1024,如果Dataset里没有做统一的resize操作,不同样本读取出来的Tensor shape不同,dataloader在堆叠batch时就会报错。
解决:在Dataset的transform阶段统一resize到一个固定尺寸,比如512x512或者256x256。
image = image.resize((512, 512), Image.BILINEAR) mask = mask.resize((512, 512), Image.NEAREST)同理,mask必须用NEAREST插值,这一点在前面已经强调过。
5.4 坑四:验证集上指标正常但实际应用效果差
现象:模型在验证集上Dice达到0.8以上,但拿一张新X光片测试时,预测结果完全不对。
原因:可能是在训练时做了归一化,但推理时没有做同样的预处理,比如没有将图像缩放到[0, 1]、没有resize到统一尺寸,甚至读取时用PIL和用OpenCV的色彩通道顺序不同,导致输入分布与训练不一致。
解决:把训练时的预处理流程封装成同一个函数,推理和训练都用同一套代码。
def preprocess(image_path): image = cv2.imread(image_path) image = cv2.resize(image, (512, 512)) image = image / 255.0 image = image.transpose(2, 0, 1) image = np.expand_dims(image, axis=0).astype(np.float32) return torch.from_numpy(image)5.5 坑五:肿瘤区域过小,模型预测结果全是背景
现象:训练完模型后,对所有测试图片的预测mask全部是空图,没有任何前景像素。
原因:类别极度不平衡,肿瘤区域很小,模型把所有像素预测为背景也能得到很高的准确率,导致Dice Loss或IoU上不去。
解决:使用Dice Loss或者Focal Loss,或者在数据加载时对mask进行过采样,只在包含肿瘤区域的小patch上训练,训练完成后再在全图上推理。我一般会在Dice Loss的基础上加上一个0.1权重的BCE Loss,让模型在前景区域的梯度贡献更大一些。
6. 进阶技巧:用自己的方式验证数据集与模型的有效性
在训练完Baseline模型后,不要急着收工。一个值得做的小实验是统计GT mask的连通域数量,因为每个连通域代表一个独立的肿瘤区域。如果一张X光片中出现多个不连通的小肿瘤区域,模型需要有能力检测出每个位置,而不仅仅是预测出整体形状。
import cv2 import numpy as np mask_path = "val/masks/788.png" mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) binary = (mask > 0).astype(np.uint8) num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(binary, connectivity=8) print(f"连通域数量: {num_labels - 1}") # 去除背景 for i in range(1, num_labels): print(f"区域{i}: 面积={stats[i, cv2.CC_STAT_AREA]}, 质心=({centroids[i][0]:.2f}, {centroids[i][1]:.2f})")这个统计的价值在于它能告诉你肿瘤分布的稠密程度。如果大多数样本只有一个连通域,说明肿瘤大多是单发性的,模型只要学会定位主病灶就能达到不错的效果。如果有很多样本有多个连通域,模型就需要更强的空间感知能力,这时候可以尝试在模型结构上增加注意力机制,比如使用SwinUnet或者TransUnet这类Transformer风格的分割网络,因为这类网络对全局上下文关系的建模能力更强,在处理多个小目标分散排布的场景下比传统UNet更有优势。
关于推理后处理,还有一个常用的技巧是对模型输出的概率图做条件随机场后处理,但这个对于X光片上的肿瘤分割效果一般,因为X光片的噪声较多,CRF容易把肿瘤边界磨掉。我实际测试下来,用简单的形态学闭运算处理预测结果反而更稳定,可以先把小的孤立点去掉,再闭合内部的小孔洞。
import cv2 import numpy as np pred = (torch.sigmoid(output).cpu().numpy() > 0.5).astype(np.uint8) # shape: (1, H, W) 或 (H, W) pred = pred.squeeze() # 形态学去噪 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) pred_clean = cv2.morphologyEx(pred, cv2.MORPH_CLOSE, kernel) pred_clean = cv2.morphologyEx(pred_clean, cv2.MORPH_OPEN, kernel)这里闭运算先做膨胀再做腐蚀,可以填充肿瘤内部的小孔洞;开运算先做腐蚀再做膨胀,可以移除背景上的孤立噪点。这个后处理只能在推理阶段使用,不能用在训练标签上。
说到训练流程,我个人习惯在训练之前先写一个简单的单张图测试,用一张验证集图片跑一遍完整的前向传播,确认输入输出shape正确、loss可以正常回传。不要一上来就跑整个epoch,X光片加上mask的载入速度不算快,一旦中途报错,浪费时间划不来。
数据集本身是否值不值得下载,我的判断是值得的,因为它在医学图像分割这个场景下提供了一个可以直接使用的训练验证组合,不用你花整整一周去整理数据。但前提是你必须理解mask的标签约定,并且训练时处理好类别不平衡问题。这两点做到位,这个数据集足够撑起一个完整的实验流程,从数据加载到训练评估到可视化展示都能走通。
从那以后,我每次拿到一个新的分割数据集,都强制先跑一遍可视化脚本、统计一遍mask像素值分布、校验文件名匹配关系,这三步走完才会动笔写训练代码。这套习惯帮我避开了不少隐形坑。希望帮到你。
本文还有配套的精品资源,点击获取