基于labelme标注的蝶鞍侧位片分割数据集与U-Net训练实战
2026/9/24 18:57:27 网站建设 项目流程

简介:这份头颅侧位片蝶鞍形态数据集面向医学影像分析与语义分割方向的研究者和开发者,尤其适合从事内分泌疾病辅助诊断、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", encoding="utf-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, fill=LABEL_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. 用这份数据训语义分割:从划分到第一个 baseline

3.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, size=256): 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, dtype=np.float32) / 255.0 mask = np.array(mask, dtype=np.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, padding=1), nn.ReLU(), nn.Conv2d(32, 32, 3, padding=1), nn.ReLU()) self.enc2 = nn.Sequential(nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.Conv2d(64, 64, 3, padding=1), nn.ReLU()) self.pool = nn.MaxPool2d(2) self.up = nn.Upsample(scale_factor=2, mode="bilinear", align_corners=True) self.dec1 = nn.Sequential(nn.Conv2d(64 + 32, 32, 3, padding=1), 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], dim=1)) 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_size=2, shuffle=True) val_loader = DataLoader(val_ds, batch_size=1) model = SimpleUNet().to(device) optimizer = torch.optim.Adam(model.parameters(), lr=1e-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(f"Epoch {epoch+1}, 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_size=2是因为数据太少,再大容易过拟合;lr=1e-3是 Adam 的常用起点,如果 loss 震荡可以降到 5e-4。size=256是折中,蝶鞍本身不大,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, threshold=0.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_weight=torch.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(p=0.5), A.Rotate(limit=10, p=0.5), A.RandomBrightnessContrast(brightness_limit=0.1, contrast_limit=0.1, p=0.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 标注到语义分割训练的完整路径铺平了,你可以在它上面把流程跑通、把参数摸熟,再迁移到自己的数据上。希望帮到你。

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

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

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

立即咨询