简介这份头颅侧位片蝶鞍形态数据集面向医学影像分析与语义分割方向的研究者和开发者尤其适合从事内分泌疾病辅助诊断、X光片解剖结构分割的初中级算法人员。数据集聚焦头颅侧位X光片中蝶鞍区域的精确分割蝶鞍作为脑垂体的容纳结构其形态异常常与多种内分泌疾病相关因此该数据可用于训练模型区分蝶鞍与周围组织为临床诊断提供辅助依据。资源包共1558个文件包含779个json标注文件与779个jpg图像文件json由labelme手动标注生成记录蝶鞍轮廓的感兴趣区域信息jpg为对应的原始侧位片压缩包整体约671.16MB。目前已有123人学习下载。手工标注方式保留了蝶鞍区域的细微特征有助于开发高精度的语义分割模型下载后可直接用于模型训练与验证省去繁琐的数据采集与标注环节降低医学图像分割研究的入门门槛。1. 蝶鞍分割数据集从 10 张侧位片到可训练掩码的落地路径头颅侧位片里蝶鞍这个位置说大不大说小不小偏偏是内分泌疾病影像评估绕不开的结构。真到要训一个语义分割模型的时候很多人卡在第一步——找不到带像素级标注的蝶鞍数据。公开数据集要么是全景片、要么是 CBCT侧位片上的蝶鞍掩码少得可怜。这份头颅侧位片蝶鞍形态数据集就是冲着这个缺口来的10 张侧位片全部用 labelme 手工勾了蝶鞍轮廓导出后可直接喂给语义分割模型训练。它适合两类人——一类是想验证 U-Net、DeepLabV3 这类分割网络在医学小目标上表现的算法工程师另一类是做口腔正畸或内分泌辅助诊断、需要快速搭一个蝶鞍分割 baseline 的研究者。数据量不大但胜在标注干净、格式通用拿来跑通流程、调通参数、看清蝶鞍分割到底难在哪足够了。2. 数据到手先别急着训labelme 标注格式拆解与转换2.1 这份数据集里到底有什么从项目正文列出的文件名看图像是 643.jpg、668.jpg、452.jpg、478.jpg、700.jpg、429.jpg、758.jpg、597.jpg、707.jpg、472.jpg 这 10 张编号不连续说明是从一批侧位片里筛出来的。每张图对应一个 labelme 生成的 JSON 文件JSON 里记录了蝶鞍区域的 polygon 坐标点。labelme 的标注逻辑是你在图上沿着蝶鞍边缘点一圈它把每个点的 (x, y) 存进shapes数组label字段写类别名shape_type通常是 polygon。这个结构对语义分割来说不是直接可用的——模型要的是和原图同尺寸的掩码图每个像素值代表类别而不是一堆坐标点。所以拿到数据后的第一件事是把 labelme JSON 转成掩码。常见做法是用 labelme 自带的labelme_json_to_dataset命令行工具但它一次只能处理一个文件10 张图手动跑也能忍不过更稳的方式是自己写个转换脚本顺便把类别映射和图像尺寸校验一起做了。2.2 用 Python 把 JSON 批量转成掩码下面这个脚本我一般会放在数据目录的上一级跑之前确认labelme库已经装好pip install labelme即可不需要开图形界面。脚本会遍历所有 JSON生成同名的_mask.png背景为 0蝶鞍区域为 1。import json import os import numpy as np from PIL import Image, ImageDraw # 数据目录里面放 jpg 和同名 json DATA_DIR ./sella_dataset # 类别映射labelme 里的 label 名 - 掩码像素值 LABEL_MAP {sella: 1} def json_to_mask(json_path, img_size): with open(json_path, r, encodingutf-8) as f: data json.load(f) # 创建全黑掩码尺寸和原图一致 mask Image.new(L, img_size, 0) draw ImageDraw.Draw(mask) for shape in data[shapes]: label shape[label] if label not in LABEL_MAP: continue points [tuple(p) for p in shape[points]] # polygon 填充像素值取映射值 draw.polygon(points, fillLABEL_MAP[label]) return np.array(mask) def main(): for fname in os.listdir(DATA_DIR): if not fname.endswith(.json): continue json_path os.path.join(DATA_DIR, fname) img_name fname.replace(.json, .jpg) img_path os.path.join(DATA_DIR, img_name) if not os.path.exists(img_path): print(f跳过 {fname}找不到对应图像) continue with Image.open(img_path) as img: img_size img.size # (width, height) mask_arr json_to_mask(json_path, img_size) mask_img Image.fromarray(mask_arr) out_path os.path.join(DATA_DIR, fname.replace(.json, _mask.png)) mask_img.save(out_path) print(f已生成 {out_path}前景像素数{np.sum(mask_arr 1)}) if __name__ __main__: main()逻辑说明json_to_mask先按原图尺寸建一张全黑图再用ImageDraw.polygon把每个 polygon 填成对应类别值。这里只处理sella一个类别如果你后续想加别的结构改LABEL_MAP就行。main里做了图像存在性检查避免 JSON 有但图丢了的情况。跑完可以看每个掩码的前景像素数正常蝶鞍区域在侧位片上占比不大如果某个掩码前景像素数异常大或为 0说明标注或转换有问题得回去查。参数方面LABEL_MAP的键必须和 labelme JSON 里shapes[].label完全一致大小写都不能错。Image.new(L, ...)里的 L 是 8 位灰度模式够用如果类别超过 255 再考虑 I 模式。draw.polygon默认填充闭合区域labelme 的 polygon 点序是顺时针或逆时针都行PIL 会自动闭合。2.3 转换后必须做的两项校验转完掩码别直接开训先做两件事。第一把掩码叠加到原图上看一眼确认蝶鞍位置对得上。可以用下面这段快速生成叠加图import numpy as np from PIL import Image img Image.open(./sella_dataset/643.jpg).convert(RGB) mask Image.open(./sella_dataset/643_mask.png) img_arr np.array(img) mask_arr np.array(mask) # 前景区域涂红透明度 0.5 img_arr[mask_arr 1] (img_arr[mask_arr 1] * 0.5 np.array([255, 0, 0]) * 0.5).astype(np.uint8) Image.fromarray(img_arr).save(./overlay_643.png)第二统计所有掩码的前景占比。10 张图里如果某张占比超过 20%大概率是标注时把周围组织也圈进去了这种样本要么修标注要么剔除否则模型会学到错误的边界。我一般会设一个阈值比如前景占比在 1% 到 15% 之间才保留超出范围的单独拎出来人工复查。3. 用这份数据训语义分割从划分到第一个 baseline3.1 10 张图怎么划分才不算自欺欺人10 张图做语义分割划分方式直接决定你看到的指标有没有意义。常见做法是 7:2:1 分训练、验证、测试但 10 张图按这个比例分测试集只有 1 张指标波动会非常大。我的建议是如果只是跑通流程用 8:2 分训练和验证测试集暂时不单独切等后续有更多数据再补。如果非要留测试集至少留 2 张并且固定随机种子保证每次划分一致。划分时要注意这 10 张图可能来自不同设备或不同拍摄条件如果编号能反映来源尽量让训练集和验证集覆盖不同来源否则验证指标会虚高。具体操作上我一般写个简单的划分脚本把文件名列表打乱后按比例切分同时把对应的掩码路径一起带上。import os import random random.seed(42) DATA_DIR ./sella_dataset all_files [f for f in os.listdir(DATA_DIR) if f.endswith(.jpg) and not f.endswith(_mask.png)] all_files.sort() random.shuffle(all_files) split int(len(all_files) * 0.8) train_files all_files[:split] val_files all_files[split:] with open(./train.txt, w) as f: for name in train_files: mask_name name.replace(.jpg, _mask.png) f.write(f{os.path.join(DATA_DIR, name)} {os.path.join(DATA_DIR, mask_name)}\n) with open(./val.txt, w) as f: for name in val_files: mask_name name.replace(.jpg, _mask.png) f.write(f{os.path.join(DATA_DIR, name)} {os.path.join(DATA_DIR, mask_name)}\n) print(f训练集 {len(train_files)} 张验证集 {len(val_files)} 张)这个脚本生成两个 txt每行是「图像路径 掩码路径」后续 DataLoader 直接读就行。random.seed(42)保证可复现换种子会得到不同划分但 10 张图的量级下不同划分的指标差异可能很大所以报告结果时最好说明用了哪个种子。3.2 搭一个最小可用的 U-Net 训练脚本语义分割模型选型上10 张图不适合上大模型。U-Net 是最稳的起点参数量适中对小目标分割友好医学影像里大量 baseline 都是它。下面这个脚本用 PyTorch 实现一个简化版 U-Net输入 256x256输出单通道 logits配合 BCEWithLogitsLoss 训练。图像和掩码在 Dataset 里统一 resize 到 256x256这是权衡显存和细节后的常见选择。import torch import torch.nn as nn import torch.nn.functional as F from torch.utils.data import Dataset, DataLoader from PIL import Image import numpy as np class SellaDataset(Dataset): def __init__(self, list_file, size256): self.pairs [] with open(list_file) as f: for line in f: img_path, mask_path line.strip().split() self.pairs.append((img_path, mask_path)) self.size size def __len__(self): return len(self.pairs) def __getitem__(self, idx): img_path, mask_path self.pairs[idx] img Image.open(img_path).convert(L).resize((self.size, self.size)) mask Image.open(mask_path).resize((self.size, self.size), Image.NEAREST) img np.array(img, dtypenp.float32) / 255.0 mask np.array(mask, dtypenp.float32) mask (mask 0).astype(np.float32) # 二值化蝶鞍为 1 return torch.from_numpy(img).unsqueeze(0), torch.from_numpy(mask).unsqueeze(0) class SimpleUNet(nn.Module): def __init__(self): super().__init__() self.enc1 nn.Sequential(nn.Conv2d(1, 32, 3, padding1), nn.ReLU(), nn.Conv2d(32, 32, 3, padding1), nn.ReLU()) self.enc2 nn.Sequential(nn.Conv2d(32, 64, 3, padding1), nn.ReLU(), nn.Conv2d(64, 64, 3, padding1), nn.ReLU()) self.pool nn.MaxPool2d(2) self.up nn.Upsample(scale_factor2, modebilinear, align_cornersTrue) self.dec1 nn.Sequential(nn.Conv2d(64 32, 32, 3, padding1), nn.ReLU()) self.out nn.Conv2d(32, 1, 1) def forward(self, x): e1 self.enc1(x) e2 self.enc2(self.pool(e1)) d1 self.dec1(torch.cat([self.up(e2), e1], dim1)) return self.out(d1) def train(): device torch.device(cuda if torch.cuda.is_available() else cpu) train_ds SellaDataset(./train.txt) val_ds SellaDataset(./val.txt) train_loader DataLoader(train_ds, batch_size2, shuffleTrue) val_loader DataLoader(val_ds, batch_size1) model SimpleUNet().to(device) optimizer torch.optim.Adam(model.parameters(), lr1e-3) criterion nn.BCEWithLogitsLoss() for epoch in range(50): model.train() train_loss 0 for img, mask in train_loader: img, mask img.to(device), mask.to(device) optimizer.zero_grad() pred model(img) loss criterion(pred, mask) loss.backward() optimizer.step() train_loss loss.item() model.eval() val_loss 0 with torch.no_grad(): for img, mask in val_loader: img, mask img.to(device), mask.to(device) pred model(img) val_loss criterion(pred, mask).item() print(fEpoch {epoch1}, train_loss {train_loss/len(train_loader):.4f}, val_loss {val_loss/len(val_loader):.4f}) if __name__ __main__: train()逻辑说明Dataset 里把图像转灰度、归一化到 0-1掩码 resize 用 NEAREST 避免插值引入中间值然后二值化。模型是两层编码加一层解码的极简 U-Net够跑通。损失用 BCEWithLogitsLoss它内部做 sigmoid数值更稳。训练 50 轮在 10 张图上很快CPU 也能跑。参数上batch_size2是因为数据太少再大容易过拟合lr1e-3是 Adam 的常用起点如果 loss 震荡可以降到 5e-4。size256是折中蝶鞍本身不大256 下细节还能保留再小可能丢边界。验证时如果 val_loss 持续上升而 train_loss 下降就是过拟合了得加数据增强或减模型容量。3.3 评估指标别只看 loss分割任务里 loss 低不代表掩码质量好尤其是前景占比小的时候模型全预测背景也能拿到很低的 loss。所以训练完必须算 Dice 系数和 IoU。Dice 对前景敏感IoU 更严格。10 张图的验证集上Dice 能到 0.7 以上就算这个 baseline 站住了低于 0.5 说明要么标注有问题要么模型没学到东西得回去查掩码和训练配置。def dice_score(pred, target, threshold0.5): pred_bin (torch.sigmoid(pred) threshold).float() intersection (pred_bin * target).sum() return (2 * intersection) / (pred_bin.sum() target.sum() 1e-6)这个函数在验证循环里调用对每个 batch 算完取平均。阈值 0.5 是默认如果模型输出偏保守可以试 0.4 或 0.6看哪个 Dice 高。4. 避坑与排查10 张图训分割最容易翻车的五个地方4.1 掩码全黑或全白现象转换完打开掩码一看要么全黑要么整个图都是前景。原因通常是 labelme JSON 里shapes为空或者label名和脚本里的LABEL_MAP对不上。解决先打印每个 JSON 的shapes长度和label值确认标注确实存在且名称一致。如果shapes为空说明标注时没保存或保存失败得重新标。4.2 图像和掩码尺寸不匹配现象训练时报错提示张量尺寸不一致。原因多半是原图有 EXIF 旋转信息PIL 读取时自动旋转了但掩码是按旋转前尺寸生成的。解决在 Dataset 里统一用ImageOps.exif_transpose处理原图或者转换掩码前先读原图尺寸确保两者一致。更省事的办法是转换阶段就把原图和掩码都 resize 到固定尺寸再存。4.3 前景占比异常导致 loss 不降现象训练几十轮 loss 几乎不变Dice 接近 0。原因可能是某张掩码前景占比超过 50%模型被这张图带偏或者所有掩码前景都太小BCE 被背景主导。解决先统计所有掩码前景占比剔除异常样本然后在 loss 里加pos_weight给前景加权比如nn.BCEWithLogitsLoss(pos_weighttorch.tensor([5.0]))具体权重按前景占比倒数估。4.4 验证集指标远高于训练集现象验证 Dice 0.9训练 Dice 0.6。原因通常是验证集只有 1 到 2 张图且和训练集分布接近指标偶然性大。解决别被这个数字骗了10 张图的验证集本身就不稳定。要么做交叉验证每次换不同验证集看指标均值要么等数据量上来再认真评估。报告结果时注明验证集大小。4.5 模型在新图上完全失效现象拿一张没参与训练的侧位片跑推理掩码乱七八糟。原因可能是这 10 张图来自同一设备或同一批拍摄条件模型学到了设备相关的特征而非蝶鞍形态。解决如果条件允许找不同来源的侧位片做外部验证训练时加随机亮度、对比度、轻微旋转等增强逼模型关注形状而非灰度分布。数据量小的时候这个问题几乎无解只能靠后续扩数据。5. 把 10 张图用出 100 张的效果增强策略与推理后处理数据量小的时候增强不是可选项是必选项。但医学影像的增强有边界——水平翻转对侧位片通常安全因为蝶鞍形态左右差异不大垂直翻转就不行头颅倒过来解剖结构完全变了。旋转角度控制在 ±10 度以内再大可能把蝶鞍转出合理位置。亮度和对比度扰动可以加但别太猛否则蝶鞍和周围骨的对比关系被破坏。我一般用 Albumentations 配一个保守的增强管道import albumentations as A transform A.Compose([ A.HorizontalFlip(p0.5), A.Rotate(limit10, p0.5), A.RandomBrightnessContrast(brightness_limit0.1, contrast_limit0.1, p0.5), A.Resize(256, 256), ])这个管道在 Dataset 的__getitem__里对图像和掩码同步应用注意掩码要用A.Resize的 NEAREST 插值Albumentations 默认对掩码用最近邻不用额外设。增强后训练轮数可以适当增加比如从 50 加到 100但要看验证 loss 是否还在降。推理阶段模型输出的是 logits经过 sigmoid 得到概率图阈值 0.5 二值化后往往边缘毛糙。常见后处理是取最大连通域把零散的小预测区域去掉只保留最大的那块作为蝶鞍。用scipy.ndimage.label就能做from scipy import ndimage def keep_largest_region(binary_mask): labeled, num ndimage.label(binary_mask) if num 0: return binary_mask sizes ndimage.sum(binary_mask, labeled, range(1, num 1)) largest np.argmax(sizes) 1 return (labeled largest).astype(np.uint8)这个操作对蝶鞍这种单连通结构很有效能去掉模型在周围组织上的误检。但如果蝶鞍本身被分成了两块这个后处理会丢掉一块所以用之前先确认标注里蝶鞍是单连通区域。还有一个技巧是测试时增强TTA对同一张图做水平翻转分别推理把概率图翻转回来平均再二值化。这个在 10 张图训练出的模型上往往能涨几个点 Dice代价是推理时间翻倍。我一般会在最终评估时跑一遍 TTA看提升是否稳定稳定就保留。从那以后我每次拿到小样本医学分割数据都强制走一遍「转换校验 → 前景占比统计 → 保守增强 → Dice 评估 → 最大连通域后处理」这条链路少一步都可能被某个隐蔽的坑绊住。这份蝶鞍数据集的价值不在于量大而在于它把 labelme 标注到语义分割训练的完整路径铺平了你可以在它上面把流程跑通、把参数摸熟再迁移到自己的数据上。希望帮到你。本文还有配套的精品资源点击获取