ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

头颅侧位片蝶鞍形态分割:从labelme标注到U-Net训练全流程

头颅侧位片蝶鞍形态分割:从labelme标注到U-Net训练全流程 简介头颅侧位片蝶鞍形态数据集面向医学影像分析与语义分割方向的研究者和开发者聚焦头颅侧位X光片中蝶鞍区域的精确分割任务。蝶鞍作为脑垂体的容纳结构其形态异常常与内分泌疾病相关因此该数据集可用于训练模型区分蝶鞍与周围组织为临床辅助诊断提供支持。资源包共1558个文件包含779个json标注文件与779个jpg图像文件json由labelme手动标注生成记录蝶鞍轮廓的感兴趣区域信息jpg为对应原始影像压缩包约671.16MB下载后可直接用于语义分割模型的训练与验证。目前已有123人学习关注。手工标注方式保留了蝶鞍区域的细微形态特征有助于提升分割精度适合作为医学图像分割实验的入门或对比数据集降低数据收集门槛。1. 头颅侧位片蝶鞍形态数据集从标注到语义分割训练的第一公里拿到一份头颅侧位片蝶鞍形态数据集标注格式是 labelme 生成的 JSON想直接喂给语义分割模型训练中间其实隔着好几道工序。蝶鞍位于蝶骨体上面、垂体窝所在的位置在正畸和颌面外科的侧位片分析里是一个关键解剖标志点它的形态变化和垂体病变、颅底发育异常都有相关性。这份数据集用 labelme 手动标注意味着每个 JSON 里存的是多边形顶点坐标和标签名而不是训练框架直接能吃的掩码图。语义分割模型训练需要的是像素级标签所以从 labelme 标注到模型可读格式之间需要做格式转换、类别映射、图像-掩码对齐这几件事。这篇文章面向的是手里已经拿到或准备下载这类医学影像数据集、想跑通语义分割训练全流程的从业者不管你是做正畸辅助诊断还是做医学图像分割的通用研究下面的步骤和参数都能直接抄。数据集下载后先别急着写 DataLoader把目录结构和 JSON 内容看清楚后面能省掉大量返工。2. labelme 标注格式拆解与语义分割训练的数据准备2.1 labelme JSON 里到底存了什么labelme 标注一张头颅侧位片后生成的 JSON 文件结构大致如下不同版本字段略有差异但核心字段稳定{ version: 5.4.1, flags: {}, shapes: [ { label: sella, points: [[312.5, 208.3], [330.1, 215.7], [341.2, 230.4], ...], group_id: null, shape_type: polygon, flags: {} } ], imagePath: lateral_001.png, imageData: null, imageHeight: 1024, imageWidth: 1280 }关键字段是shapes数组每个元素代表一个标注实例label是类别名points是多边形顶点列表shape_type通常是polygon。imagePath指向对应的原始图像imageHeight和imageWidth是图像尺寸。注意imageData字段在多数情况下是null因为 labelme 默认不把图像 base64 塞进 JSON这意味着转换脚本需要根据imagePath去同级目录找原图。对于语义分割任务我们关心的是把points转成二值或多类掩码。蝶鞍形态数据集通常只有一个前景类别蝶鞍区域所以掩码是二值的蝶鞍区域为 1背景为 0。如果标注里还包含其他解剖结构比如蝶骨平台、鞍结节那就需要做多类映射用不同的整数值区分。2.2 从 JSON 到掩码转换脚本与参数说明下面这个脚本把 labelme JSON 批量转成 PNG 掩码同时生成一份类别映射文件。脚本假设所有 JSON 和原图在同一目录下输出目录结构为images/和masks/。import json import os import numpy as np from PIL import Image, ImageDraw # 类别映射labelme 中的 label 名 - 掩码整数值 LABEL_MAP { sella: 1, # 蝶鞍区域 # 如果有其他类别继续添加背景默认为 0 } def json_to_mask(json_path, output_mask_path, output_image_path): with open(json_path, r, encodingutf-8) as f: data json.load(f) h, w data[imageHeight], data[imageWidth] mask Image.new(L, (w, h), 0) # 单通道背景 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]] draw.polygon(points, fillLABEL_MAP[label]) mask.save(output_mask_path) # 同时把原图复制或软链到 images 目录保证文件名与掩码一一对应 src_img os.path.join(os.path.dirname(json_path), data[imagePath]) img Image.open(src_img) img.save(output_image_path) def batch_convert(json_dir, out_img_dir, out_mask_dir): os.makedirs(out_img_dir, exist_okTrue) os.makedirs(out_mask_dir, exist_okTrue) for fname in os.listdir(json_dir): if not fname.endswith(.json): continue json_path os.path.join(json_dir, fname) stem os.path.splitext(fname)[0] mask_path os.path.join(out_mask_dir, stem .png) img_path os.path.join(out_img_dir, stem .png) json_to_mask(json_path, mask_path, img_path) print(fconverted: {stem}) if __name__ __main__: batch_convert( json_dir./raw_labelme, out_img_dir./dataset/images, out_mask_dir./dataset/masks )逻辑说明Image.new(L, (w, h), 0)创建单通道灰度图像素值 0 表示背景。draw.polygon用顶点列表填充多边形区域填充值为LABEL_MAP[label]。如果一张图里有多个同类别的多边形它们会叠加到同一个掩码上这在语义分割里是正确行为——语义分割不区分实例只区分类别。如果要做实例分割那需要每个实例单独一个掩码但这份数据集标题明确说的是语义分割所以按语义分割处理。参数说明LABEL_MAP是唯一需要根据实际标注调整的地方。打开一个 JSON 看看shapes里出现了哪些label值全部列进去。如果标注里存在拼写不一致比如sella和Sella要么在脚本里做归一化要么在转换前统一改 JSON。imageHeight和imageWidth直接取自 JSON不要硬编码因为不同侧位片的尺寸可能不同。2.3 训练集/验证集划分与数据增强的注意点转换完成后dataset/images和dataset/masks里的文件应该一一对应。接下来按 8:2 或 7:3 划分训练集和验证集。对于医学影像小数据集我一般会写一个简单的划分脚本固定随机种子把文件名列表分别写入train.txt和val.txt。import os import random random.seed(42) all_files sorted([f for f in os.listdir(./dataset/images) if f.endswith(.png)]) random.shuffle(all_files) split int(len(all_files) * 0.8) train_files all_files[:split] val_files all_files[split:] with open(./dataset/train.txt, w) as f: f.write(\n.join(train_files)) with open(./dataset/val.txt, w) as f: f.write(\n.join(val_files))数据增强方面头颅侧位片是灰度或伪彩色图像蝶鞍区域的位置相对固定但形态变化大。常用的增强包括随机水平翻转、小角度旋转±10°、亮度对比度微调。注意不要做垂直翻转因为头颅侧位片有明确的上下方向垂直翻转会破坏解剖结构的语义。随机裁剪也要谨慎蝶鞍区域不能被裁掉建议用随机缩放加中心裁剪的组合保证目标始终在视野内。3. 用 U-Net 跑通蝶鞍语义分割训练的最小闭环3.1 模型选型为什么 U-Net 仍然是医学图像分割的默认起点蝶鞍形态分割属于小目标、单类别、数据量有限的医学图像分割任务。U-Net 的编码器-解码器结构配合跳跃连接能在小数据集上取得稳定效果而且参数量可控单卡就能训练。相比 DeepLabV3 这类依赖空洞卷积和大量预训练权重的模型U-Net 从零训练的门槛更低。如果数据量在几百张以上也可以考虑用 ResNet34 作为编码器的 U-Net 变体利用 ImageNet 预训练权重加速收敛。但蝶鞍区域在侧位片里对比度不算低从零训练 U-Net 通常几十个 epoch 就能看到合理结果。下面给出一个最小可运行的 U-Net 实现输入单通道灰度图输出单通道 logits配合 BCEWithLogitsLoss 做二分类语义分割。import torch import torch.nn as nn class DoubleConv(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.block nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), ) def forward(self, x): return self.block(x) class UNet(nn.Module): def __init__(self, in_ch1, out_ch1, base32): super().__init__() self.enc1 DoubleConv(in_ch, base) self.enc2 DoubleConv(base, base*2) self.enc3 DoubleConv(base*2, base*4) self.enc4 DoubleConv(base*4, base*8) self.pool nn.MaxPool2d(2) self.bottleneck DoubleConv(base*8, base*16) self.up4 nn.ConvTranspose2d(base*16, base*8, 2, stride2) self.dec4 DoubleConv(base*16, base*8) self.up3 nn.ConvTranspose2d(base*8, base*4, 2, stride2) self.dec3 DoubleConv(base*8, base*4) self.up2 nn.ConvTranspose2d(base*4, base*2, 2, stride2) self.dec2 DoubleConv(base*4, base*2) self.up1 nn.ConvTranspose2d(base*2, base, 2, stride2) self.dec1 DoubleConv(base*2, base) self.out nn.Conv2d(base, out_ch, 1) def forward(self, x): e1 self.enc1(x) e2 self.enc2(self.pool(e1)) e3 self.enc3(self.pool(e2)) e4 self.enc4(self.pool(e3)) b self.bottleneck(self.pool(e4)) d4 self.dec4(torch.cat([self.up4(b), e4], dim1)) d3 self.dec3(torch.cat([self.up3(d4), e3], dim1)) d2 self.dec2(torch.cat([self.up2(d3), e2], dim1)) d1 self.dec1(torch.cat([self.up1(d2), e1], dim1)) return self.out(d1)逻辑说明编码器每层通过 MaxPool 下采样通道数翻倍解码器通过转置卷积上采样然后与编码器对应层的特征图在通道维度拼接再经过双卷积融合。最后一层 1x1 卷积把通道数压到out_ch二分类就是 1。base32是控制模型容量的关键参数显存不够就降到 16数据量大可以升到 64。参数说明输入图像建议 resize 到 512x512 或 256x256保持长宽比做 padding 也可以但蝶鞍区域本身不大256x256 通常够用。损失函数用nn.BCEWithLogitsLoss()如果前景背景极度不平衡加pos_weight参数值设为背景像素数除以前景像素数。优化器用 Adam学习率 1e-3 起步配合ReduceLROnPlateau在验证 loss 不降时减半。3.2 训练循环与关键参数配置训练循环里需要同时加载图像和掩码做相同的空间变换。下面是一个精简的训练脚本片段import torch from torch.utils.data import Dataset, DataLoader from PIL import Image import numpy as np import torchvision.transforms as T class SellaDataset(Dataset): def __init__(self, img_dir, mask_dir, file_list, size256): self.img_dir img_dir self.mask_dir mask_dir self.files [line.strip() for line in open(file_list) if line.strip()] self.size size def __len__(self): return len(self.files) def __getitem__(self, idx): fname self.files[idx] img Image.open(f{self.img_dir}/{fname}).convert(L) mask Image.open(f{self.mask_dir}/{fname}).convert(L) img T.Resize((self.size, self.size))(img) mask T.Resize((self.size, self.size), interpolationT.InterpolationMode.NEAREST)(mask) img T.ToTensor()(img) # [1, H, W], 值域 0-1 mask torch.from_numpy(np.array(mask)).float() / 255.0 # 前景为 1 mask mask.unsqueeze(0) # [1, H, W] return img, mask # 训练循环 device torch.device(cuda if torch.cuda.is_available() else cpu) model UNet(in_ch1, out_ch1, base32).to(device) optimizer torch.optim.Adam(model.parameters(), lr1e-3) criterion torch.nn.BCEWithLogitsLoss() train_ds SellaDataset(./dataset/images, ./dataset/masks, ./dataset/train.txt) train_loader DataLoader(train_ds, batch_size8, shuffleTrue, num_workers2) for epoch in range(50): model.train() epoch_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() epoch_loss loss.item() print(fepoch {epoch1}, loss {epoch_loss/len(train_loader):.4f})逻辑说明Resize对图像用双线性插值对掩码必须用最近邻插值否则会引入不存在的类别值。掩码在ToTensor之前是 0/255 的灰度图除以 255 后变成 0/1 浮点正好匹配 BCE 的目标值域。batch_size8在 256x256 输入下对显存要求不高6GB 显存足够。参数说明num_workers在 Windows 上有时会出问题设成 0 可以规避。训练 epoch 数不是固定的看验证集 loss 和 Dice 系数通常 30-50 个 epoch 内会收敛。如果 loss 震荡厉害把学习率降到 5e-4 或 1e-4。3.3 验证指标Dice 系数和 IoU 的计算训练过程中光看 loss 不够蝶鞍区域小loss 降了但分割效果可能仍然很差。需要计算 Dice 系数和 IoU 来监控实际分割质量。def dice_coeff(pred, target, threshold0.5): pred (torch.sigmoid(pred) threshold).float() intersection (pred * target).sum() return (2 * intersection) / (pred.sum() target.sum() 1e-8) def iou_score(pred, target, threshold0.5): pred (torch.sigmoid(pred) threshold).float() intersection (pred * target).sum() union pred.sum() target.sum() - intersection return intersection / (union 1e-8)逻辑说明torch.sigmoid把 logits 转成概率阈值 0.5 是默认值实际部署时可以根据验证集上的 Dice 曲线调整。1e-8防止除零。这两个指标在验证集上每个 epoch 算一次记录下来画曲线。参数说明阈值 0.5 不是绝对的如果模型对前景预测偏保守可以降到 0.4 甚至 0.3 来提升召回。但阈值调太低会引入假阳性需要根据实际应用场景权衡。4. 蝶鞍分割训练中的避坑与排查记录4.1 掩码全黑或全白标注坐标越界与图像尺寸不匹配现象转换出来的掩码打开一看全是黑色或者偶尔全是白色。原因通常是 labelme JSON 里的points坐标超出了imageHeight和imageWidth的范围或者原图在标注后被重新保存过尺寸变了但 JSON 里的尺寸没更新。解决方法是转换前先校验遍历所有points检查 x 是否在 [0, w-1] 内y 是否在 [0, h-1] 内超出就裁剪到边界。同时用Image.open读原图对比实际尺寸和 JSON 里的imageHeight/imageWidth不一致就以实际尺寸为准并对坐标做等比缩放。4.2 训练 loss 不降标签值域和输入通道数对不上现象训练几个 epoch 后 loss 几乎不变Dice 接近 0。原因可能是掩码没有归一化到 0/1仍然是 0/255而 BCEWithLogitsLoss 期望目标在 0-1 之间。或者图像输入是三通道 RGB但模型第一层是in_ch1导致形状不匹配报错或静默广播出错。解决方法是打印一个 batch 的img.shape和mask.shape确认是[B, 1, H, W]和[B, 1, H, W]并且mask.max()不超过 1。如果原图是 RGB在 Dataset 里用.convert(L)转灰度。4.3 验证集 Dice 远低于训练集过拟合与小数据集增强不足现象训练集 Dice 能到 0.9验证集只有 0.5 左右。原因通常是数据量太少模型记住了训练样本。解决方法是加强数据增强除了水平翻转和旋转还可以加弹性形变对医学图像尤其有效、随机灰度变换、随机 gamma 校正。另外可以加 Dropout 层或权重衰减Adam 的weight_decay1e-4。如果数据量实在少考虑用预训练的 ResNet34 编码器替换 U-Net 的编码器部分冻结前几层再微调。4.4 推理时掩码边缘锯齿严重上采样方式与后处理缺失现象模型输出的掩码边缘呈锯齿状不够平滑。原因是转置卷积上采样本身会产生棋盘效应加上二值化阈值处理没有做后处理。解决方法有两个方向一是把转置卷积换成双线性插值上采样加卷积能减轻棋盘效应二是在推理后对掩码做形态学闭运算先膨胀后腐蚀填补小孔洞再用高斯模糊加阈值重新二值化边缘会平滑很多。OpenCV 的morphologyEx和GaussianBlur几行代码就能搞定。4.5 类别不平衡导致小目标被忽略pos_weight 设置与损失函数选择现象蝶鞍区域只占整张侧位片的很小比例模型倾向于全部预测为背景Dice 很低但准确率很高。原因是 BCE 对正负样本一视同仁背景像素远多于前景时模型只要全预测背景就能获得低 loss。解决方法是在BCEWithLogitsLoss里设置pos_weight值等于背景像素总数除以前景像素总数通常这个值在 10 到 50 之间。另一个选择是换用 Dice Loss 或 Tversky Loss它们直接优化重叠度对小目标更友好。我一般会 BCE 和 Dice Loss 按 1:1 加权组合效果比单用 BCE 稳定。5. 蝶鞍分割的进阶技巧从能跑到好用5.1 用 Test Time Augmentation 提升推理稳定性模型训练完之后推理阶段可以对同一张图做多次增强变换水平翻转、小角度旋转分别推理再把结果平均最后二值化。这个技巧叫 TTA在医学图像分割比赛里几乎是标配。实现上很简单def predict_with_tta(model, img_tensor, device): model.eval() preds [] with torch.no_grad(): # 原始 preds.append(torch.sigmoid(model(img_tensor.to(device))).cpu()) # 水平翻转 img_flip torch.flip(img_tensor, dims[3]) pred_flip torch.sigmoid(model(img_flip.to(device))).cpu() preds.append(torch.flip(pred_flip, dims[3])) # 小角度旋转可以用 torchvision.transforms.functional.rotate avg_pred torch.stack(preds, dim0).mean(dim0) return (avg_pred 0.5).float()逻辑说明每次变换后推理再把输出逆变换回原空间最后取平均。这样能减少单次推理的随机波动Dice 通常能提升 1-3 个百分点。参数说明TTA 的变换种类不宜过多2-4 种就够了太多会拖慢推理速度且收益递减。5.2 蝶鞍形态的量化后处理面积、周长与圆形度分割出蝶鞍掩码之后往往还需要量化形态参数。用 OpenCV 的findContours提取轮廓然后计算面积、周长、最小外接矩形、圆形度4πA/P²。这些参数可以直接用于后续的形态分类或异常检测。注意面积要按原图尺寸还原如果训练时 resize 到了 256x256推理后要把掩码 resize 回原始尺寸再算否则面积值没有临床意义。import cv2 import numpy as np mask_np (mask.squeeze().numpy() * 255).astype(np.uint8) contours, _ cv2.findContours(mask_np, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if contours: cnt max(contours, keycv2.contourArea) area cv2.contourArea(cnt) perimeter cv2.arcLength(cnt, True) circularity 4 * np.pi * area / (perimeter ** 2 1e-8) print(farea{area:.1f}, perimeter{perimeter:.1f}, circularity{circularity:.3f})逻辑说明RETR_EXTERNAL只取外轮廓忽略孔洞。contourArea返回像素面积arcLength返回周长。圆形度越接近 1 越接近圆形蝶鞍正常形态偏椭圆圆形度在 0.7-0.9 之间明显偏离这个范围可能提示形态异常。5.3 我踩过的一个玄学坑随机种子与 DataLoader 顺序最后说一个血泪经验。有一次训练蝶鞍分割模型同样的代码、同样的数据两次运行结果 Dice 差了 5 个百分点。排查了半天发现是 DataLoader 的shuffleTrue配合多进程num_workers0时如果没有固定全局随机种子每个 epoch 的数据顺序在不同运行间不一致导致模型收敛路径不同。解决方法是训练脚本开头加import random import numpy as np import torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False set_seed(42)cudnn.deterministicTrue会稍微降低训练速度但能保证结果可复现。如果追求极致速度且不在意复现性可以设benchmarkTrue。这个习惯我后来在所有医学图像项目里都保留着省去了大量“为什么这次结果不一样”的排查时间。希望帮到你。本文还有配套的精品资源点击获取
返回列表