☰
蝶鞍分割小样本实战:从labelme标注到U-Net训练全流程解析
2026/10/19 1:50:38 网站建设 项目流程

简介:头颅侧位片蝶鞍形态数据集是一套面向医学影像语义分割任务的高质量标注资源,适合医学图像处理研究者、算法工程师及内分泌疾病辅助诊断相关项目开发者使用,可用于蝶鞍区域分割模型的训练与验证。数据集中所有图像均通过Labelme工具进行细致的逐像素手动标注,能够精准勾勒蝶鞍区域轮廓,尤其适用于蝶鞍这类结构较小、边界复杂的解剖区域,为训练高精度语义分割模型提供可靠的监督信息。整个压缩包共1558个文件,包含779张JPG原图与779个对应JSON标注文件,文件结构简洁清晰,便于按图索骥完成数据划分与模型迭代,包体大小约为671.16MB。标注数据与原始图像一一对应,无需额外整理即可开始训练。目前已有123人学习/下载,对于希望快速获取医学标注数据、开展蝶鞍形态分析或探索医学分割算法的读者而言,这份数据集可省去繁琐的数据采集与标注成本,直接用于模型训练、算法对比及科研验证,具有明确的实用价值。

1. 头颅侧位片蝶鞍形态数据集:十张标注图能做出一套完整的分割流程

蝶鞍是脑垂体所在的那个小小骨性凹陷。头颅侧位X光片上,它的轮廓往往只有一小块,夹在颅底骨影之间,很容易被忽略。这份头颅侧位片蝶鞍形态数据集正是对着这个区域做的 labelme 手工标注,下载后你会得到一批 jpg 原图和同名 JSON 文件,里面没有现成的分割结果,真正的产出需要你自己从 JSON 解析、Mask 生成一直到语义分割模型训练一步步打通。我一开始没把这份数据当回事,觉得图片量太小可能一次就过拟合,但真正跑下来发现关键不在数量,而在流程是否闭合。对想上手医学影像语义分割的人来说,这是少有的能直接跑通的资源,不用翻标注规范,不用找脱敏数据,打开就能看到“原图 + X光片灰度特征 + 蝶鞍小目标”这三个必踩的坎。

2. labelme标注解析:把JSON里保存的蝶鞍多边形变成可用于训练的Mask

解压数据集后,目录里是 jpg 和 JSON 标注文件成对出现。labelme 生成的 JSON 保存的是标注工程的原始信息,类别名、坐标、形状类型都在里面,但它不是语义分割训练直接要的 Mask。每张图对应一个 JSON、一个蝶鞍轮廓,你需要先把这些坐标还原成一幅 0/1 的二值图。这步听起来简单,实际做的时候有几个细节容易埋雷,比如多边形首尾不闭合、形状是凸的还是凹的、图像灰度范围不统一,都会让转换结果和真实轮廓对不上。

2.1 labelme的JSON结构:从imagePath到shapes里的那串points

labelme 的 JSON 结构比较固定,核心就几个字段。我一般会先写个小脚本把整批 JSON 扫一遍,确认 annotation 的 shape_type 都是 polygon、label 名称统一,避免训练到一半发现有的标注是矩形,或者出现了两个类别名。

字段作用训练前要确认的点
version标注器版本不用管
imagePath原始图片相对路径确认原图还在,别依赖内嵌数据
imageHeight / imageWidth原图像素尺寸记下来,后面统一 resize 时用
shapes标注区域数组每个元素里有 label、points、shape_type
imageDatabase64 内嵌图数据这份数据集大图场景通常为空字符串

检查脚本可以这样写:

import json, glob for jf in glob.glob("*.json"): with open(jf, encoding="utf-8") as f: data = json.load(f) labels = {} for s in data["shapes"]: labels[s["label"]] = labels.get(s["label"], 0) + 1 print(jf, data["imageWidth"], data["imageHeight"], labels)

逻辑不复杂:遍历所有 JSON,统计每张图的标注类别和数量。参数上要注意 imageWidth 和 imageHeight 是原始像素尺寸,后续转掩膜时如果设定了输出尺寸,会以这个尺寸作为参考;如果这批数据的 label 不止一种,shape_type 也出现混用,那转换脚本就要做分支而不是直接填多边形。

2.2 写转换脚本:闭合检查、多边形填充与Mask输出

拿到标注坐标后,最直接的工具是labelme_json_to_dataset命令,它能一次性生成 img.png、label.png 和 label_names.txt。但我更推荐自己写转换脚本,原因是蝶鞍这种不规则小结构经常出现凹多边形,而系统中自带的转换流程不见得会帮你处理首尾不闭合的问题,后续排查更麻烦。控制权拿在自己手里,出了问题才知道去哪看。

一个稳定的转换函数大概长这样:

import json import numpy as np import cv2 def labelme_json_to_mask(json_path, out_size=None): with open(json_path, "r", encoding="utf-8") as f: data = json.load(f) h = data["imageHeight"] w = data["imageWidth"] mask = np.zeros((h, w), dtype=np.uint8) for shape in data["shapes"]: if shape.get("shape_type", "polygon") != "polygon": continue pts = np.array(shape["points"], dtype=np.float32) label = shape["label"] # 首尾闭合检查:labelme 的 polygon 通常不重复记录第一个点 if not np.allclose(pts[0], pts[-1]): pts = np.vstack([pts, pts[0]]) # 蝶鞍轮廓不规则,必须用 fillPoly,不能只用 fillConvexPoly cv2.fillPoly(mask, [np.int32(pts)], color=1) if out_size is not None: mask = cv2.resize(mask, (out_size, out_size), interpolation=cv2.INTER_NEAREST) return mask

代码逻辑分三段:第一段读出原始宽高并初始化全零 Mask;第二段遍历 shapes 里的每个标注,把首尾点补上后调用cv2.fillPoly填充;第三段是可选 resize。参数说明:out_size我建议默认不传,按原图尺寸先生成高清 Mask,等到训练加载时再做缩放。这样原图坐标系里始终保留一份无损标注,最后验证分割结果时还能把它映射回原始 X 光片去检查轮廓对齐度。填充颜色写成 1 而不是 255,是为了让 Mask 保持 0/1 二值,后面计算 Dice Loss 不用再归一化。

2.3 X光片读图与归一化:先解决灰度范围不一致再谈训练

头颅侧位 X 光片不是自然图像,不同设备、不同曝光条件下,同一解剖结构的像素灰度差异可能非常大。直接用原始像素值喂给模型,模型会把“亮度”当成特征来学,而不是学“形状”。读图时用 OpenCV 灰度模式读取最稳,然后做一遍归一化。

我一般用百分位截断而不是简单的标准化:

import cv2 import numpy as np def normalize_xray(img): # 先转灰度,百分位截断能抑制单张图的极亮和极暗噪点 if img.ndim == 3: img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) p_low, p_high = np.percentile(img, (2, 98)) img = (img - p_low) / (p_high - p_low + 1e-6) return np.clip(img, 0, 1).astype(np.float32)

逻辑说明:取 2% 和 98% 分位数作为截断上下界,把灰度线性拉伸到 0~1 区间,能避免单一像素过亮或过暗主导整张图的对比度。参数上要注意,如果图像黑色背景占比太高,2% 分位可能还是背景,截断后前景对比度反而被拉平。遇到这种情况,我一般先用一个固定灰度阈值找出头部包围盒,只在包围盒内做分位数统计,再把整张图映射到同一区间。X 光片预处理这一步不做扎实,后面的模型预测就会忽暗忽亮。

3. 蝶鞍语义分割模型训练:U-Net、损失函数与迁移学习的关键参数

Mask 准备好了,现在进入训练环节。蝶鞍分割本质上是单通道、单类别的二分类语义分割任务。模型结构层面,U-Net 这一类编码器-解码器结构是这个小样本场景下最稳的起手式;真正决定十张图能不能训成的是损失函数、输入尺寸和数据划分策略。先讲选型,再给超参数,最后说迁移学习怎么接进来。

3.1 模型选型:U-Net为什么比DeepLabV3更稳

起初我也考虑过 DeepLabV3,觉得空洞卷积能扩大感受野,可能对颅底这种复杂背景更友好。实际对比之后还是回到了 U-Net。蝶鞍在整张侧位片里占比很小,分割这种小结构靠的是边缘细节恢复能力,而 DeepLab 的重点是上下文语义,边界处的浅层信息经过多层空洞卷积后已经被稀释。U-Net 的跳跃连接把编码器浅层边缘特征直接传给解码器,对这种小目标来说收益远大于感受野扩大。

对比下来大致是这样:

结构技术特点蝶鞍分割表现
U-Net跳跃连接、逐级上采样边界完整,小结构不容易丢
DeepLabV3空洞卷积、ASPP上下文强但小目标边界偏粗
SegNet池化索引上采样细节恢复弱,不推荐小结构

实际代码里我一般用带 ResNet encoder 的 U-Net,而不是原始四层卷积版本,因为 ResNet 作为 encoder 能直接兼容 ImageNet 预训练权重;Decoder 部分保持 U-Net 的上采样结构,两边拼接特征。

3.2 训练配置:输入尺寸、Batch Size与Loss组合

这块是我调整最多的部分。输入尺寸选 256 是因为蝶鞍本身只占图像两三个百分点,更高的分辨率放大小结构细节的同时也会放大增强带来的伪影,而十张图的训练量扛不住太多噪声。Batch Size 4 是显存和 BN 稳定性的折中,太小的话 BatchNorm 统计不稳,太大会在小样本下迅速过拟合。

超参数取值说明
输入尺寸256 x 256显存允许可试 512,但小样本下提升有限
Batch Size4BN 稳定与显存占用的折中
LossBCEWithLogitsLoss + DiceLoss小目标必须配 Dice
OptimizerAdamW,lr=1e-4,weight_decay=0.01比纯 Adam 更容易收敛
SchedulerReduceLROnPlateau验证 Dice 停滞时降学习率

Loss 组合的写法如下:

import torch import torch.nn as nn def dice_loss(prob, target, smooth=1.0): # prob 为 sigmoid 之后的概率,target 为 0/1 Mask intersection = (prob * target).sum() return 1 - (2.0 * intersection + smooth) / (prob.sum() + target.sum() + smooth) # 训练循环内 logits = model(images) loss_bce = nn.functional.binary_cross_entropy_with_logits(logits, masks) prob = torch.sigmoid(logits) loss = loss_bce + dice_loss(prob, masks)

只靠 BCE 在蝶鞍这种正样本占比不到 3% 的数据上会迅速收敛到“全背景”的局部最优,Dice Loss 通过整体区域的交并比约束让模型必须把蝶鞍内部也预测对。smooth 参数默认 1.0 是防止分母为零,数据量小的时候 smooth 不要设太大,否则梯度会被稀释。二者相加时权重是 1:1,如果发现预测偏保守,可以把 Dice 权重提到 1.5。

3.3 迁移学习:ImageNet预训练在X光片上还能用吗

这是个老争论。X 光片和 ImageNet 自然图像差异很大,但预训练模型前半段学到的基本边缘、纹理、角点特征依然有迁移价值,尤其在小样本场景下,至少不用从零学边缘检测。我的做法是:encoder 加载预训练权重,第一层卷积输入通道从 3 改成 1,取预训练三个通道权重均值作为单通道初始化。

from torchvision.models import resnet34, ResNet34_Weights encoder = resnet34(weights=ResNet34_Weights.IMAGENET1K_V1) encoder.conv1 = nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3, bias=False) with torch.no_grad(): # 三通道权重取平均,让第一层输出分布尽量接近预训练时的状态 encoder.conv1.weight.data = encoder.conv1.weight.data.mean(dim=1, keepdim=True)

参数说明:mean(dim=1, keepdim=True)是把预训练卷积核的 RGB 三通道压成单通道,保留维度以便后面加载。Decoder 部分必须随机初始化,因为预训练任务没有对应输出。如果加载预训练后训练反而震荡,就切回随机初始化并把数据增强强度调高一档,这在十张图的数据规模下也是常见操作。

4. 小样本蝶鞍分割常见问题:五个标注入坑与训练陷阱排查

十张图的小样本分割看起来很容易跑飞,下面五个坑都是训练和标注过程中真实会遇到的,每条按现象、原因、解决来写。排查顺序建议从标注数据本身开始,再到预处理和训练策略。

4.1 训练半天Mask全黑:背景占比超过98%时BCE失效

现象:训练了五六个 epoch,loss 在下降,但验证集预测结果全是黑色,Dice 为 0。

原因:蝶鞍正样本像素在整张 256 x 256 图像里可能只有几百像素,背景占比超过 98%。BCE 对小类别不敏感,模型发现全部预测为背景也能拿到很低的 loss,于是收敛到了这种“偷懒解”。

解决:把损失函数从纯 BCE 切换成 BCE + Dice 组合,Dice Loss 天然按区域重叠比例计算,背景占比再高,只要预测全黑,Dice 就是 0,梯度信号立刻拉满。排查时不要只看 loss 曲线,第一轮训练就要输出验证集 Dice 和预测图。

4.2 验证集Dice很高但新图一塌糊涂:灰度归一化漏了一环

现象:训练集和验证集指标都正常,但拿一张没见过的头颅侧位片去推理,蝶鞍轮廓明显偏移,边缘多了一圈或缺了一块。

原因:训练时每张图都做了百分位截断归一化,但新图如果曝光偏暗或偏亮,输入的灰度分布和训练分布不一致,模型学到的对比对就失效了。X 光片之间灰度范围差异极大,这一步不做统一,模型分不清“边缘”和“亮度带”。

解决:推理时对每一张新图执行与训练数据完全一致的normalize_xray,注意是百分位截断还是 z-score 要做成固定函数,不能训练用一套、推理用另一套。我一般还会在推理前打印新图归一化前后的灰度分布,和训练数据的分布对一下眼。

4.3 随机划分让结果忽高忽低:小样本需要K折而不是留出法

现象:同样的模型和超参,换一个随机种子留出两张图做验证,Dice 从 0.85 跌到 0.65。

原因:十张图里不同个体的蝶鞍形态差异很大,随机留出的两张如果恰好都是形态偏大或骨影叠加复杂的,验证集指标就失真了。固定验证集在这种样本量下不够可靠,讨论模型好坏也缺乏依据。

解决:用 5 折交叉验证代替固定划分,每一折单独训练、早停、记录 Best Dice,最后把五折均值当作模型真实水平的估计。这会让训练次数从 1 次变成 5 次,但小样本下这是唯一不容易自欺的评估方式。

4.4 旋转增强把蝶鞍转出图像:设置rotate_limit前先想边界

现象:开启数据增强后,有一类训练样本的 Mask 变成全零或者边界缺失一块,模型训练很快崩掉。

原因:旋转角度设置太大,比如 rotate_limit=30,图像旋出边界后四周被黑色填充,而蝶鞍如果恰好靠近图像边缘,旋转后就被裁剪到黑边外,变成无效训练对。

解决:几何增强要克制。旋转角度控制在 ±15 度以内,缩放控制在 0.05 以内,位移控制在 2% 以内。同时在增强管线里加一个 Mask 非零面积检查,发现某张训练样本增强后正样本像素低于阈值就重新采样一次,这个防御性检查能避免“脏样本”进到训练循环。

4.5 fillPoly填充出大色块:标注多边形自交更隐蔽

现象:某张图转换出来的 Mask 明显大于实际蝶鞍区域,覆盖到了周围颅底骨影,和 JSON 里的坐标点位对不上。

原因:labelme 手工标注时,如果打点顺序出现交叉,多边形会变得自交,cv2.fillPoly按扫描线规则填充时会产生意外的大面积区域。人工标注很难避免这种问题,尤其蝶鞍轮廓又小又曲折。

解决:转换脚本里对每个 polygon 做一次简单校验,计算多边形面积和所有相邻边夹角,出现明显异常就单独打印文件名。更稳妥的做法是转完所有 Mask 后,把原图、标注轮廓、Mask 三张图拼在一起逐张过一遍。这类标注脏数据靠随机抽检是发现不了的。

5. 让十张图发挥极限:增强组合、K折交叉验证与掩膜后处理

小样本训练时数据增强是必需品,但增强参数选不对会加速过拟合,甚至直接破坏标注。这一章讲我实际用的增强组合、交叉验证评估方式,以及预测完之后的掩膜后处理流程。

5.1 增强组合怎么选:几何变换克制,光度变换放开

我的增强管线固定用下面这组配置:

import albumentations as A train_transform = A.Compose([ A.RandomBrightnessContrast( brightness_limit=0.2, contrast_limit=0.2, p=0.5 ), A.ShiftScaleRotate( shift_limit=0.02, scale_limit=0.05, rotate_limit=15, border_mode=0, p=0.8 ), A.HorizontalFlip(p=0.5), A.Resize(256, 256), ])

几个参数要特别说明:brightness_limit 和 contrast_limit 可以放开到 0.2,模拟不同 X 光机的曝光差异,这对医学影像泛化帮助最大。ShiftScaleRotate 里 shift_limit 只给 0.02、rotate_limit 只给 15,因为蝶鞍区域太小,几何变换一大就移出边界。border_mode 默认是 0,也就是黑边填充,如果改成cv2.BORDER_REFLECT_101能减少黑边干扰,但这会让图像边缘像素被反射,X 光片上等于造出了不存在的高亮伪影,所以我一般还是保持黑边,配合 4.4 的 Mask 非零检查。

flip 在这里是合理的,头颅侧位片左右翻转后蝶鞍依然是蝶鞍,解剖结构对称性足够。不要在增强里加 CoarseDropout 或随机遮挡,蝶鞍本身太小,随机遮挡很容易把唯一的正样本区域抹掉。

5.2 用5折交叉验证替代固定验证集

评估策略直接影响你最终敢不敢把这个模型用到新片子上。十张数据做 10 折留一法,每一折只有一张验证图,Dice 方差大到你无法判断模型到底行不行。5 折是更折中的选择。

from sklearn.model_selection import KFold import numpy as np kf = KFold(n_splits=5, shuffle=True, random_state=42) dice_scores = [] for fold, (train_idx, val_idx) in enumerate(kf.split(images)): best_dice = train_one_fold( images[train_idx], masks[train_idx], images[val_idx], masks[val_idx] ) dice_scores.append(best_dice) print(f"mean dice: {np.mean(dice_scores):.3f} ± {np.std(dice_scores):.3f}")

逻辑说明:train_one_fold内部就是前面第 3 章的训练循环,返回模型在该折验证集上的 Best Dice。KFold 的 shuffle=True 确保图像顺序被打散,random_state 固定方便复现。五折均值反映模型平均水平,标准差反映不同个体形态差异带来的波动。如果标准差超过 0.1,说明蝶鞍形态个体差异太大,需要更多样本来压住方差,而不是继续调超参。

5.3 分割后处理:连通域过滤与形态学闭合

模型输出的概率图直接二值化,通常会有两种问题:蝶鞍内部因为骨骼纹理被预测成背景,出现小孔洞;背景里出现零星的假阳性碎片。手动处理时,我会按面积过滤掉小连通域,再做一次形态学闭运算。

def postprocess(pred, min_area=50): pred = (pred > 0.5).astype(np.uint8) # 只保留面积最大的连通域,蝶鞍应该是图像里唯一的掩膜组件 num, labels, stats, _ = cv2.connectedComponentsWithStats(pred, connectivity=8) if num <= 1: return np.zeros_like(pred) largest = 1 + int(np.argmax(stats[1:, cv2.CC_STAT_AREA])) pred = np.where(labels == largest, 1, 0).astype(np.uint8) kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) pred = cv2.morphologyEx(pred, cv2.MORPH_CLOSE, kernel) return pred

参数说明:min_area=50表示小于 50 像素的碎片直接忽略,这个阈值要跟着 Mask 分辨率走,如果模型输出是 64 x 64 的小掩膜,50 像素可能把真实蝶鞍也删掉。MORPH_CLOSE的核用椭圆是为了贴合蝶鞍本身的圆润边界,正方形核容易留下棱角。后处理只影响最终指标计算,不参与训练梯度,所以参数可以根据验证集可视化结果反复调。

6. 验证与量化:把蝶鞍分割结果放回原图坐标再看一遍

模型训好后,验证不是只看一个 Dice 数字就结束。蝶鞍分割的最终价值在于让医生快速抓住形态变化,面积增大、前后径变长、轮廓变深是临床上常见的关注方向,而这些都要从 Mask 里量出来。我习惯做两步:一是把预测掩膜映射回原始 X 光片尺寸看轮廓对齐,二是从掩膜里计算出蝶鞍的形态指标。

# 训练时输入是256x256,预测完必须映射回原图尺寸 pred_orig = cv2.resize(pred_256, (orig_w, orig_h), interpolation=cv2.INTER_NEAREST) xray_bgr = cv2.cvtColor(xray_orig, cv2.COLOR_GRAY2BGR) contours, _ = cv2.findContours(pred_orig, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) cv2.drawContours(xray_bgr, contours, -1, (0, 0, 255), 2)

这一步很容易被跳过,但非常关键,因为训练时 256 x 256 分辨率下的边界偏移可能只有两三个像素,映射回原始高清 X 光片上就变成了二三十像素的偏差,临床位置上完全不可接受。轮廓叠回原图后,肉眼能直接判断模型有没有把蝶鞍下缘和颅底骨影混淆。

def sella_metrics(mask): contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return None cnt = max(contours, key=cv2.contourArea) x, y, w, h = cv2.boundingRect(cnt) area = cv2.contourArea(cnt) return {"pixel_area": area, "bbox_w": w, "bbox_h": h, "aspect_ratio": w / h}

pixel_area对应蝶鞍整体大小,aspect_ratio对应前后径与上下径的比值变化。有一点要提醒:不同 X 光片的空间分辨率可能不一样,直接用像素面积横向对比之前,必须先确认图像里有没有标尺信息,否则像素面积换算成毫米面积时会失真。

我在一次复盘里发现,某张新片预测出来的 Dice 很好看,但把掩膜映射回原图后,轮廓中心偏了快半个蝶鞍。原因就是我只在 256 分辨率下看图,边界上的小偏移被缩放掩盖了。从那以后,每次做医学影像小结构分割,都会强制走一遍“resize 回原图、轮廓叠加、形态参数输出”这套检查,训练指标和实际位置都对得上,才算真正完成一份可交付的分割结果。希望帮到你。

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

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

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

立即咨询