ARTICLE DETAIL

资讯详情

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

基于2000张超声数据集的胎儿头围分割实战:U-Net基线、避坑与后处理

基于2000张超声数据集的胎儿头围分割实战:U-Net基线、避坑与后处理 简介本资源为面向医学图像分割方向的胎儿头围测量语义分割数据集适合从事超声影像分析、产前筛查算法研究的学生与工程师使用。数据基于超声下的胎儿头围测量图像构建共划分背景与胎儿头围两类标签类别定义可参考随包classes文件训练集与测试集均已按images图片目录加masks掩膜目录的结构整理完毕其中训练集约700张、测试集约299张可直接投入模型训练与评估。压缩包共约2000个文件以1998个png图像与掩膜为主另含1个txt类别说明和1个py可视化脚本整体约126.15MB。该脚本可随机抽取一张图片将原始图像、GT图像及GT在原图上的蒙板叠加结果一并展示并保存至当前目录便于快速核验标注质量。目前已有98人学习配合作者专栏中的UNet、SwinUNet、TransUNet等改进资料可形成从数据到网络改进的完整实践链路。1. 超声影像里的胎儿头围测量这份约 2000 张的语义分割数据集到底能解决什么产科超声诊室里最耗时间的操作之一就是医生拿着探头在孕妇腹部反复滑动只为找到一个标准切面——丘脑水平横切面然后手动描出胎儿颅骨外缘的椭圆量出头围HC。这个动作熟练的医生也要几十秒遇到胎位不好、羊水偏少、母体脂肪层厚的情况可能折腾好几分钟还测不准。而头围直接关系到胎儿生长发育评估、小头畸形与脑积水的筛查误差几毫米就可能改变临床判断。问题在于超声图像本身噪声大、对比度低、颅骨边缘经常被声影遮挡靠传统阈值分割或边缘检测基本没法稳定工作。这份约 2000 张带标签的超声胎儿头围测量图像语义分割数据集就是冲着这个痛点来的它把真实超声下的胎儿头部区域逐像素标注出来让你能直接训练 U-Net、DeepLabV3 这类分割网络把「找切面 描轮廓」变成一次前向推理。适合谁用做医学图像分割的研究生、想切入智能超声的算法工程师、以及需要验证自己分割 pipeline 的从业者。它不承诺临床级精度但能让你在真实数据分布上把模型跑通、把指标测出来、把坑踩明白。2. 数据集结构与标注格式先搞清楚你拿到的是什么2.1 目录组织与图像规格这类超声分割数据集通常按「图像 掩码」成对组织常见做法是图像放一个目录、标签放另一个目录文件名一一对应。我拿到手第一件事不是急着写训练脚本而是先统计图像尺寸分布和标签像素值分布因为超声图像的尺寸往往不统一直接 resize 会引入形变。下面这段脚本就是干这个的跑一遍你就能对数据集的「脾气」有个底。import os import numpy as np from PIL import Image from collections import Counter img_dir images mask_dir masks sizes Counter() mask_values Counter() for name in os.listdir(img_dir): if not name.lower().endswith((.png, .jpg, .bmp)): continue img Image.open(os.path.join(img_dir, name)) sizes[img.size] 1 mask_name os.path.splitext(name)[0] .png mask_path os.path.join(mask_dir, mask_name) if os.path.exists(mask_path): m np.array(Image.open(mask_path)) # 统计标签里出现的像素值确认是 0/1 还是 0/255 for v in np.unique(m): mask_values[int(v)] 1 print(图像尺寸分布:, sizes.most_common(10)) print(标签像素值分布:, mask_values.most_common(10))逻辑说明sizes统计所有图像的分辨率帮你判断是否需要统一尺寸mask_values统计标签里出现的像素值这一步非常关键——很多分割数据集标签是 0 和 255而 PyTorch 的交叉熵损失默认期望 0 和 1不转换就会训练出全黑或全白的玄学结果。参数上img_dir和mask_dir按你实际解压后的路径改标签扩展名常见是.png因为 PNG 无损不会像 JPEG 那样在边缘产生伪影。2.2 标签语义与类别定义胎儿头围分割本质是二分类语义分割前景是胎儿头部区域通常指颅骨外缘围成的椭圆内部背景是羊水、母体组织、声影等。但不同标注规范会有差异有的标的是颅骨环本身细环有的标的是整个头部区域实心椭圆。这两种标注训练出来的模型行为完全不同细环标注对边缘精度要求极高实心区域标注更关注整体形状。你拿到数据集后务必可视化几张「原图 掩码叠加」确认标注口径。import numpy as np from PIL import Image import matplotlib.pyplot as plt def overlay(img_path, mask_path, alpha0.4): img np.array(Image.open(img_path).convert(RGB)) mask np.array(Image.open(mask_path).convert(L)) mask_bin (mask 127).astype(np.uint8) # 统一成 0/1 color np.zeros_like(img) color[:, :, 0] mask_bin * 255 # 前景涂红 blended (img * (1 - alpha) color * alpha).astype(np.uint8) plt.imshow(blended) plt.axis(off) plt.show() overlay(images/0001.png, masks/0001.png)逻辑说明mask 127把任意标注值二值化避免 0/255 与 0/1 混用导致可视化全黑。alpha控制叠加透明度0.4 左右既能看清原图结构又能看到标注区域。如果你发现叠加后红色区域是细环而不是实心说明标注是颅骨环后续损失函数可以考虑加边界权重如果是实心椭圆普通 Dice Loss 就够用。2.3 训练/验证划分的坑约 2000 张数据按 8:1:1 划分就是 1600/200/200。但超声数据有个特点同一个孕妇可能有多张不同切面的图像如果随机划分同一孕妇的图像可能同时出现在训练集和验证集导致验证指标虚高。常见做法是按孕妇 ID 或检查 ID 分组划分如果数据集没提供 ID至少按文件名前缀或采集批次做分组。这一点在医学图像里是血泪经验随机划分的 Dice 能到 0.95按人划分可能掉到 0.88后者才是真实泛化能力。3. 从零跑通一个 U-Net 分割基线代码、参数与训练监控3.1 数据加载与增强策略超声图像增强不能照搬自然图像那套。水平翻转要谨慎——胎儿头部左右翻转在解剖上没毛病但如果你还预测头围方向就有问题垂直翻转基本不能用因为超声切面有固定的上下解剖关系。亮度对比度扰动可以用因为不同设备增益不同高斯噪声和斑点噪声speckle noise值得加因为超声本身就有乘性斑点噪声加一点能让模型更鲁棒。import torch from torch.utils.data import Dataset, DataLoader import numpy as np from PIL import Image import random import torchvision.transforms.functional as TF class FetalHCDataset(Dataset): def __init__(self, img_paths, mask_paths, size(256, 256), trainTrue): self.img_paths img_paths self.mask_paths mask_paths self.size size self.train train def __len__(self): return len(self.img_paths) def __getitem__(self, idx): img Image.open(self.img_paths[idx]).convert(L) mask Image.open(self.mask_paths[idx]).convert(L) img TF.resize(img, self.size) mask TF.resize(mask, self.size, interpolationTF.InterpolationMode.NEAREST) img TF.to_tensor(img) # [0,1] mask (TF.to_tensor(mask) 0.5).float() # 二值化到 0/1 if self.train: # 只做水平翻转 亮度对比度扰动 if random.random() 0.5: img TF.hflip(img) mask TF.hflip(mask) if random.random() 0.3: img TF.adjust_contrast(img, random.uniform(0.8, 1.2)) if random.random() 0.3: img img torch.randn_like(img) * 0.02 # 模拟噪声 img torch.clamp(img, 0, 1) return img, mask逻辑说明convert(L)把超声转灰度因为原始超声多数是灰度图转 RGB 反而增加无意义通道。TF.resize对掩码必须用NEAREST插值用双线性会在边缘产生 0.5 这类中间值二值化后边缘错位。to_tensor把像素从 0-255 压到 0-1这是 PyTorch 的标准输入范围。增强部分只保留水平翻转、对比度和噪声垂直翻转和旋转要慎用。size设 256×256 是显存和精度的折中如果你显卡够384×384 对小目标边缘更友好。3.2 U-Net 模型定义与损失函数选择U-Net 是医学分割的默认起点编码器降采样提特征、解码器上采样恢复分辨率、跳跃连接保留边缘细节。对于头围这种「一个大目标 边缘要准」的任务U-Net 比 DeepLabV3 更稳因为后者空洞卷积在小数据集上容易过拟合。损失函数建议 Dice Loss BCE 组合BCE 管像素级分类Dice 管区域重叠两者互补。import torch.nn as nn import torch.nn.functional as F class DoubleConv(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.net 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.net(x) class UNet(nn.Module): def __init__(self, in_ch1, out_ch1): super().__init__() self.d1 DoubleConv(in_ch, 64) self.d2 DoubleConv(64, 128) self.d3 DoubleConv(128, 256) self.d4 DoubleConv(256, 512) self.pool nn.MaxPool2d(2) self.bottleneck DoubleConv(512, 1024) self.u4 nn.ConvTranspose2d(1024, 512, 2, stride2) self.c4 DoubleConv(1024, 512) self.u3 nn.ConvTranspose2d(512, 256, 2, stride2) self.c3 DoubleConv(512, 256) self.u2 nn.ConvTranspose2d(256, 128, 2, stride2) self.c2 DoubleConv(256, 128) self.u1 nn.ConvTranspose2d(128, 64, 2, stride2) self.c1 DoubleConv(128, 64) self.out nn.Conv2d(64, out_ch, 1) def forward(self, x): d1 self.d1(x) d2 self.d2(self.pool(d1)) d3 self.d3(self.pool(d2)) d4 self.d4(self.pool(d3)) b self.bottleneck(self.pool(d4)) x self.c4(torch.cat([self.u4(b), d4], dim1)) x self.c3(torch.cat([self.u3(x), d3], dim1)) x self.c2(torch.cat([self.u2(x), d2], dim1)) x self.c1(torch.cat([self.u1(x), d1], dim1)) return self.out(x) class DiceBCELoss(nn.Module): def __init__(self): super().__init__() self.bce nn.BCEWithLogitsLoss() def forward(self, logits, targets): bce self.bce(logits, targets) probs torch.sigmoid(logits) probs probs.view(probs.size(0), -1) targets targets.view(targets.size(0), -1) inter (probs * targets).sum(dim1) dice 1 - (2 * inter 1e-6) / (probs.sum(dim1) targets.sum(dim1) 1e-6) return bce dice.mean()逻辑说明DoubleConv是 U-Net 的基本块两次 3×3 卷积 BN ReLU。ConvTranspose2d做上采样torch.cat完成跳跃连接。输出层不加 sigmoid因为损失函数用BCEWithLogitsLoss内部自带 sigmoid数值更稳定。DiceBCELoss里1e-6防止除零dice.mean()对 batch 取平均。参数上in_ch1对应灰度输入out_ch1对应二分类如果你要做多类比如同时分割头部和脑室把out_ch改成类别数并换成交叉熵即可。3.3 训练循环与指标监控训练时最该盯的不是 loss而是验证集 Dice 和 Hausdorff 距离。Dice 反映区域重叠Hausdorff 反映边缘最大偏差头围测量对边缘敏感Hausdorff 比 Dice 更能暴露问题。from torch.optim import Adam from torch.optim.lr_scheduler import ReduceLROnPlateau device torch.device(cuda if torch.cuda.is_available() else cpu) model UNet().to(device) criterion DiceBCELoss() optimizer Adam(model.parameters(), lr1e-3) scheduler ReduceLROnPlateau(optimizer, modemax, factor0.5, patience5) def dice_score(pred, target, eps1e-6): pred (torch.sigmoid(pred) 0.5).float() pred pred.view(pred.size(0), -1) target target.view(target.size(0), -1) inter (pred * target).sum(dim1) return ((2 * inter eps) / (pred.sum(dim1) target.sum(dim1) eps)).mean().item() best_dice 0 for epoch in range(100): model.train() for img, mask in train_loader: img, mask img.to(device), mask.to(device) optimizer.zero_grad() logits model(img) loss criterion(logits, mask) loss.backward() optimizer.step() model.eval() val_dice 0 with torch.no_grad(): for img, mask in val_loader: img, mask img.to(device), mask.to(device) logits model(img) val_dice dice_score(logits, mask) val_dice / len(val_loader) scheduler.step(val_dice) if val_dice best_dice: best_dice val_dice torch.save(model.state_dict(), best_unet.pth) print(fEpoch {epoch}: val_dice{val_dice:.4f}, best{best_dice:.4f})逻辑说明ReduceLROnPlateau在验证 Dice 不升时降学习率patience5表示连续 5 个 epoch 没提升就降。dice_score里先 sigmoid 再阈值 0.5 二值化这是推理时的标准操作。保存best_unet.pth而不是最后一个 epoch因为医学数据小过拟合后验证指标会掉。学习率 1e-3 是 Adam 的常用起点如果 loss 震荡就降到 5e-4。4. 避坑与排查超声分割里最容易翻车的五个地方4.1 标签像素值不统一导致 loss 不下降现象训练几个 epoch 后 loss 几乎不变预测全黑或全白。原因标签是 0/255但代码里当 0/1 用BCE 的 target 超出 [0,1] 范围梯度异常。解决在 Dataset 里统一做(mask 127).float()或者先跑 2.1 的统计脚本确认像素值分布。这个坑我见过太多次属于「不知道就永远查不出来」的黑匣子问题。4.2 图像与掩码文件名不匹配现象训练时部分样本 mask 全黑Dice 异常低。原因图像是.jpg掩码是.png扩展名不同导致按名字找掩码时漏掉。解决在 Dataset 初始化时先做一次配对检查打印出没有对应掩码的图像列表提前暴露而不是训练中途才发现。4.3 验证集指标虚高现象验证 Dice 0.95但拿新数据测试只有 0.7。原因随机划分导致同一孕妇的多张图像同时进训练和验证。解决按孕妇 ID 或文件名前缀分组划分用GroupShuffleSplit而不是train_test_split。如果数据集没提供 ID至少按采集日期或设备批次分组。4.4 上采样插值方式用错现象掩码边缘出现灰色过渡带二值化后边缘偏移 1-2 像素。原因对掩码用了双线性插值。解决掩码 resize 必须用NEAREST图像可以用双线性。这个细节在头围测量里影响很大因为头围对边缘位置敏感。4.5 显存不足导致 batch size 被迫设为 1现象256×256 输入、batch size 8 就 OOM。原因U-Net 第一层 64 通道特征图大显存占用高。解决把第一层通道降到 32或者用混合精度训练torch.cuda.amp或者把输入降到 192×192。混合精度通常能省 40% 显存对分割任务精度影响很小。5. 进阶技巧用形态学后处理把头围测量误差压到临床可接受范围模型输出的掩码是像素级的但临床要的是头围数值毫米或厘米。从掩码到头围中间差一个「椭圆拟合 周长计算」。直接对掩码算周长会受边缘锯齿影响误差可能到 5-10 像素。我一般会先做形态学闭运算填掉小孔再提取最大连通域然后用cv2.fitEllipse拟合椭圆最后按椭圆周长公式算。这样得到的头围比直接数边缘像素稳定得多。import cv2 import numpy as np def mask_to_hc(mask, pixel_spacing_mm0.5): # mask: 0/1 二值图 mask (mask * 255).astype(np.uint8) kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) contours, _ cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return None cnt max(contours, keycv2.contourArea) if len(cnt) 5: return None ellipse cv2.fitEllipse(cnt) (cx, cy), (MA, ma), angle ellipse a, b MA / 2, ma / 2 # 椭圆周长近似Ramanujan h ((a - b) ** 2) / ((a b) ** 2) perimeter_px np.pi * (a b) * (1 3 * h / (10 np.sqrt(4 - 3 * h))) return perimeter_px * pixel_spacing_mm逻辑说明MORPH_CLOSE闭运算先填小孔避免轮廓断裂。findContours取最大轮廓忽略噪声小区域。fitEllipse需要至少 5 个点所以加了len(cnt) 5的保护。Ramanujan 公式是椭圆周长的经典近似精度足够。pixel_spacing_mm是每个像素对应的物理尺寸这个值必须从超声设备参数里拿不同设备不同深度下不一样用错这个参数头围直接错一个量级。验证方法拿一批有医生手动测量头围的样本把你的算法输出和医生值做 Bland-Altman 分析看一致性界限是否在临床可接受范围内通常 ±10mm 以内算不错。如果偏差大先检查pixel_spacing_mm是否对再检查椭圆拟合是否被声影导致的轮廓缺口带偏。从那以后我每次拿到新的超声分割数据集都强制先跑一遍标签像素值统计和图像-掩码配对检查再开始写模型。这两个检查花不了五分钟但能省掉后面几小时的无效训练。希望帮到你。本文还有配套的精品资源点击获取
返回列表