简介这份资源面向医学影像处理与深度学习入门者提供X光手掌骨骼的2分类分割数据集可用于训练掌骨区域提取模型适合图像分割课程实验、算法验证及小规模医学影像项目练手。包内共2000个文件以1486个png掩膜、512个jpg图像为主另含1个txt类别说明与1个py可视化脚本压缩包约15.47MB。数据已按训练集与测试集划分训练集含1486张原图及对应mask测试集含92张原图及对应mask图像分辨率统一为256×512前景采用0/1阈值标注边界清晰并已做缩放、翻转等增广处理。配套脚本无需修改即可运行随机抽取一张图片同时展示原始图像、GT图像以及GT叠加在原图上的蒙板效果并自动保存到当前目录便于快速核验标注质量。目前已有193人学习适合希望低成本上手骨骼分割任务、快速搭建训练与可视化流程的读者。1. 图像分割数据集X光掌骨分割到底在解决什么问题拍一张手部X光片骨科医生想量的是掌骨宽度、骨皮质厚度、关节间隙这些指标但原始影像里骨骼、软组织、背景全糊在一起直接测量误差极大。图像分割数据集X光线下的掌骨分割2分割要干的事就是把每张X光片里的每个像素判成两类——掌骨区域和背景区域输出一张和原图同尺寸的二值掩膜。所谓2分割不是把图像切成两块而是语义类别数为2前景是掌骨背景是其余一切。这个数据集适合三类人做医学影像分割入门练手的算法工程师需要快速验证U-Net系改进点的研究者以及想把骨密度、骨龄评估流程自动化的从业者。它比肺结节、眼底血管那些数据集友好得多——目标结构清晰、边界相对锐利、样本获取门槛低但坑也不少后面几章会逐个拆。先记住一件事拿到数据集别急着训模型先把掩膜和原图对齐关系搞清楚否则后面全是玄学。2. 数据集结构与classes文件先看懂再动手2.1 目录组织与文件命名约定这类掌骨分割数据集常见的组织方式是按图像-掩膜成对存放图像放一个目录掩膜放另一个目录文件名主干保持一致。典型结构长这样dataset/ ├── images/ │ ├── case_0001.png │ ├── case_0002.png │ └── ... ├── masks/ │ ├── case_0001.png │ ├── case_0002.png │ └── ... └── classes.txt掩膜是单通道灰度图像素值只有0和1或0和2550代表背景非0代表掌骨。这里第一个高频翻车点有些数据集掩膜存成0/255有些存成0/1还有些存成调色板PNG。你直接拿cv2.imread读进来默认是BGR三通道值域也不对训练时loss会莫名其妙不下降。稳妥做法是统一用灰度模式读再做二值化。import cv2 import numpy as np # 灰度模式读取掩膜避免三通道干扰 mask cv2.imread(dataset/masks/case_0001.png, cv2.IMREAD_GRAYSCALE) print(原始唯一值:, np.unique(mask)) # 可能是 [0 255] 或 [0 1] # 统一二值化到 0/1阈值取中间值最稳 mask_bin (mask 127).astype(np.uint8) if mask.max() 1 else mask print(二值化后唯一值:, np.unique(mask_bin))逻辑说明先看np.unique输出判断掩膜编码方式。如果最大值是255用127做阈值如果最大值就是1说明已经是0/1编码直接用。参数上阈值不要拍脑袋设成200掌骨边缘像素在X光下是渐变的阈值太高会把骨皮质薄的地方切掉导致掩膜比真实骨骼小一圈。2.2 classes文件怎么读、怎么用classes文件通常就两行一行一个类别名顺序对应标签索引background metacarpal索引0是背景索引1是掌骨。这个文件本身不参与训练但它是你写推理后处理代码时的字典。很多人训完模型做可视化把预测结果直接乘255显示结果前景背景反了就是因为没对照classes确认索引顺序。我一般会在数据加载类里加一句断言确保类别数和模型输出通道数一致def load_classes(path): with open(path, r, encodingutf-8) as f: classes [line.strip() for line in f if line.strip()] return classes classes load_classes(dataset/classes.txt) num_classes len(classes) # 2分割这里应为2 assert num_classes 2, f类别数异常: {num_classes} print(类别映射:, {i: c for i, c in enumerate(classes)})参数说明num_classes决定模型最后一层卷积输出通道数。二分类分割有两种建模方式——输出1通道配SigmoidBCE或输出2通道配SoftmaxCE。这个数据集类别极不平衡背景远多于掌骨我更推荐2通道Softmax配合后面讲的Dice Loss收敛更稳。2.3 图像与掩膜的尺寸、位深核对动手前必须核对三件事图像和掩膜尺寸是否一致、位深是多少、有没有方向错位。X光片常见位深是8位或16位16位直接送进网络会溢出。写个脚本批量扫一遍import os import cv2 import numpy as np img_dir, mask_dir dataset/images, dataset/masks for name in sorted(os.listdir(img_dir))[:5]: img cv2.imread(os.path.join(img_dir, name), cv2.IMREAD_UNCHANGED) mask cv2.imread(os.path.join(mask_dir, name), cv2.IMREAD_GRAYSCALE) print(name, img:, img.shape, img.dtype, mask:, mask.shape, mask.dtype)如果发现图像是16位用cv2.normalize或手动除以最大值归一到0-255再转8位。尺寸不一致的情况较少但存在遇到就用cv2.resize统一注意掩膜必须用最近邻插值INTER_NEAREST用双线性会把边界插出0.5这种非法标签值。3. 数据可视化代码三分钟看出数据集质量3.1 叠加可视化原图掩膜轮廓光看掩膜黑乎乎一片看不出问题把掩膜轮廓叠到原图上才直观。下面这段代码生成叠加图掌骨边界用红色描出来import cv2 import numpy as np import matplotlib.pyplot as plt def overlay_mask(img_path, mask_path, save_pathNone): img cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) mask cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) mask_bin (mask 127).astype(np.uint8) if mask.max() 1 else mask # 找轮廓并画到彩色原图上 contours, _ cv2.findContours(mask_bin, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) vis cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) cv2.drawContours(vis, contours, -1, (0, 0, 255), 1) if save_path: cv2.imwrite(save_path, vis) return vis vis overlay_mask(dataset/images/case_0001.png, dataset/masks/case_0001.png) plt.imshow(cv2.cvtColor(vis, cv2.COLOR_BGR2RGB)) plt.axis(off) plt.show()逻辑说明findContours在OpenCV不同版本返回值个数不同4.x返回两个值3.x返回三个这里按4.x写。线宽设1像素足够太粗会盖住骨骼边缘影响判断。参数上RETR_EXTERNAL只取外轮廓掌骨如果有内部空洞比如骨髓腔被误标就看不出来想查这个换成RETR_CCOMP。3.2 前景占比统计判断类别不平衡程度类别不平衡直接决定loss怎么选。写个循环统计所有掩膜的前景像素占比import os import cv2 import numpy as np mask_dir dataset/masks ratios [] for name in os.listdir(mask_dir): mask cv2.imread(os.path.join(mask_dir, name), cv2.IMREAD_GRAYSCALE) mask_bin (mask 127).astype(np.uint8) if mask.max() 1 else mask ratios.append(mask_bin.mean()) ratios np.array(ratios) print(f前景占比 均值:{ratios.mean():.3f} 最小:{ratios.min():.3f} 最大:{ratios.max():.3f})掌骨分割的前景占比通常在0.05到0.25之间均值0.1左右。如果某张图占比超过0.5八成是掩膜标反了或者整张图被误标成前景这种样本要单独拎出来人工复核。我一般会把占比低于0.02和高于0.4的样本列成清单这批就是脏数据候选。3.3 批量网格预览一次看几十张单张看效率太低拼成网格一次扫一批import os import cv2 import numpy as np import matplotlib.pyplot as plt def grid_preview(img_dir, mask_dir, n8, cols4): names sorted(os.listdir(img_dir))[:n] rows (n cols - 1) // cols fig, axes plt.subplots(rows, cols, figsize(cols * 3, rows * 3)) for ax, name in zip(axes.ravel(), names): img cv2.imread(os.path.join(img_dir, name), cv2.IMREAD_GRAYSCALE) mask cv2.imread(os.path.join(mask_dir, name), cv2.IMREAD_GRAYSCALE) mask_bin (mask 127).astype(np.uint8) if mask.max() 1 else mask ax.imshow(img, cmapgray) ax.imshow(np.ma.masked_where(mask_bin 0, mask_bin), cmapjet, alpha0.4) ax.set_title(name, fontsize8) ax.axis(off) plt.tight_layout() plt.show() grid_preview(dataset/images, dataset/masks)逻辑说明用np.ma.masked_where把背景遮掉只显示前景热力图alpha0.4保证底下的X光纹理还能看见。这一步能快速发现掩膜整体偏移、旋转、缩放不一致的问题——如果红色区域和骨骼位置对不上说明图像和掩膜在预处理阶段被做了不同的几何变换这是最隐蔽也最致命的坑。4. 从数据集到训练最小可跑通流程4.1 自定义Dataset与增强策略PyTorch的Dataset要保证图像和掩膜同步做几何增强。旋转、翻转可以同步但颜色抖动只能作用于图像掩膜不能动import cv2 import numpy as np import torch from torch.utils.data import Dataset import random class MetacarpalDataset(Dataset): def __init__(self, img_dir, mask_dir, names, size256): self.img_dir, self.mask_dir img_dir, mask_dir self.names, self.size names, size def __len__(self): return len(self.names) def __getitem__(self, idx): name self.names[idx] img cv2.imread(f{self.img_dir}/{name}, cv2.IMREAD_GRAYSCALE) mask cv2.imread(f{self.mask_dir}/{name}, cv2.IMREAD_GRAYSCALE) mask (mask 127).astype(np.uint8) if mask.max() 1 else mask img cv2.resize(img, (self.size, self.size), interpolationcv2.INTER_LINEAR) mask cv2.resize(mask, (self.size, self.size), interpolationcv2.INTER_NEAREST) # 同步随机水平翻转 if random.random() 0.5: img np.fliplr(img).copy() mask np.fliplr(mask).copy() img img.astype(np.float32) / 255.0 img (img - img.mean()) / (img.std() 1e-6) # 逐样本标准化 return torch.from_numpy(img).unsqueeze(0), torch.from_numpy(mask).long()逻辑说明掩膜resize必须用INTER_NEAREST这是血泪经验用双线性会引入0.3、0.7这种非整数标签long()转换后直接变成0边界被悄悄腐蚀。标准化用逐样本均值方差而不是全局固定值因为不同设备拍的X光片亮度差异大。参数size256是权衡显存和精度的常用值掌骨结构不算细256够用显存紧张可以降到192。4.2 损失函数选择DiceCE组合前面统计过前景占比只有0.1左右纯CE会被背景主导。组合损失是常规做法import torch import torch.nn as nn import torch.nn.functional as F class DiceLoss(nn.Module): def __init__(self, smooth1.0): super().__init__() self.smooth smooth def forward(self, logits, target): prob F.softmax(logits, dim1)[:, 1] # 取掌骨通道 target (target 1).float() inter (prob * target).sum() union prob.sum() target.sum() return 1 - (2 * inter self.smooth) / (union self.smooth) # 组合CE管像素级分类Dice管前景重叠度 ce nn.CrossEntropyLoss() dice DiceLoss() loss ce(logits, target) dice(logits, target)参数说明smooth1.0防止分母为0前景极小的样本上这个值可以调到0.1让梯度更敏感。CE和Dice的权重我一般设1:1如果验证集Dice上不去而loss在降说明CE占比过大把Dice权重提到2。注意DiceLoss里取的是softmax后的掌骨通道别取错通道取成背景通道loss会反向优化。4.3 训练循环与验证指标验证阶段别只看loss要看Dice和IoUdef evaluate(model, loader, device): model.eval() dice_sum, iou_sum, n 0.0, 0.0, 0 with torch.no_grad(): for img, mask in loader: img, mask img.to(device), mask.to(device) pred model(img).argmax(dim1) for p, t in zip(pred, mask): p, t (p 1), (t 1) inter (p t).sum().item() union (p | t).sum().item() dice_sum (2 * inter) / (p.sum().item() t.sum().item() 1e-6) iou_sum inter / (union 1e-6) n 1 return dice_sum / n, iou_sum / n逻辑说明逐样本算Dice再平均比整批算更公平避免大前景样本主导指标。argmax(dim1)把2通道输出转成0/1预测图。掌骨分割上Dice能到0.90以上算合格0.93以上算不错IoU通常比Dice低3到5个百分点这是正常的别看到IoU低就慌。5. 避坑与排查掌骨分割里最容易翻车的五件事5.1 掩膜和原图空间错位现象训练loss能降但验证Dice卡在0.6上不去可视化一看预测区域整体偏移几个像素。原因图像和掩膜在预处理时用了不同的resize插值或不同的裁剪参数或者数据集本身标注时就存在系统性偏移。解决写一个互相关脚本把掩膜和图像的边缘做配准检查偏移超过2像素的样本直接剔除或重新对齐。别指望模型能学会这种全局偏移它学不会。5.2 前景占比异常样本污染训练集现象某几个epoch后模型突然开始大面积预测前景Dice暴跌。原因训练集里混进了掩膜标反或整图误标的样本模型被带偏。解决用3.2节的占比统计把占比低于0.02和高于0.4的样本列出来人工过一遍。我一般会把这批样本单独放一个目录先不参与训练等baseline跑通后再决定是否清洗后加回。5.3 16位图像未归一化导致梯度爆炸现象第一个epoch loss就是nan。原因X光原图是16位像素值上万直接除255还是几十送进网络激活值爆炸。解决读图时用IMREAD_UNCHANGED看dtype16位的话先减最小值再除以最大值范围或者用cv2.normalize归一到0-255。别偷懒直接astype(np.float32)/25516位除255还是大数。5.4 验证集指标虚高数据泄漏现象验证Dice 0.97一上测试集掉到0.7。原因划分数据集时同一病例的多张片子被分到了训练和验证两边模型记住了这个病例的特征。解决按病例ID划分不是按图像文件划分。如果数据集没给病例ID用文件名前缀或拍摄时间聚类保证同一来源的片子只出现在一边。5.5 推理时预处理不一致现象训练时指标很好部署推理结果一塌糊涂。原因推理代码里的resize尺寸、标准化参数和训练时不一致。解决把预处理逻辑抽成一个独立函数训练和推理共用同一份代码别在两处各写一遍。这个坑我踩过不止一次后来养成习惯——预处理函数只写一次谁调用都走它。6. 进阶技巧把2分割结果用起来的三个方向第一个方向是后处理细化边界。模型输出的概率图直接argmax边界往往有锯齿。用条件随机场或者简单的形态学闭运算能平滑边界但注意闭运算的核别超过3x3太大会把掌骨之间的缝隙填死。我一般先做一次3x3开运算去孤立噪点再做一次3x3闭运算补小空洞顺序反了效果差很多。第二个方向是把分割掩膜转成测量指标。掌骨分割的最终目的往往是量宽度、算骨密度。拿到二值掩膜后沿骨干方向做垂直投影统计每行的前景像素数就是该位置的骨宽。这里有个技巧先对掩膜做骨架化提取中心线再沿中心线法向测量比直接按行统计准得多因为手掌有自然弯曲。from skimage.morphology import skeletonize import numpy as np def bone_width_profile(mask_bin): skel skeletonize(mask_bin.astype(bool)) widths [] ys, xs np.where(skel) for y, x in zip(ys, xs): # 沿水平方向统计该骨架点所在行的前景宽度 row mask_bin[y] left x while left 0 and row[left - 1]: left - 1 right x while right len(row) - 1 and row[right 1]: right 1 widths.append(right - left 1) return np.array(widths)逻辑说明skeletonize把掌骨细化成单像素中心线然后对每个骨架点沿水平方向向两侧扩展统计宽度。这个方法在骨干近似竖直时很准如果掌骨倾斜角度大需要先做旋转校正。返回的宽度数组可以算均值、最大值、标准差作为骨形态特征送进下游分类或回归模型。第三个方向是半监督扩展。掌骨分割的标注成本不低如果手头有大量未标注X光片可以用已训练模型生成伪标签置信度高的样本加入训练集迭代。伪标签阈值我一般设0.95低于这个的样本宁可不用。迭代两轮就够了再多容易过拟合到模型自己的偏差上。最后说个习惯每次拿到新的分割数据集我第一件事不是写模型而是花半小时把第3章那三段可视化代码跑一遍把前景占比分布、叠加图、网格预览都看一遍。这半小时能省掉后面至少两天的排查时间。数据集的质量决定了模型的上限模型只是逼近这个上限而已。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?