☰
基于2000张超声数据集的胎儿头围分割实战:U-Net基线、避坑与后处理
2026/10/7 16:36:44 网站建设 项目流程

简介:本资源为面向医学图像分割方向的胎儿头围测量语义分割数据集,适合从事超声影像分析、产前筛查算法研究的学生与工程师使用。数据基于超声下的胎儿头围测量图像构建,共划分背景与胎儿头围两类标签,类别定义可参考随包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, alpha=0.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), train=True): 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, interpolation=TF.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, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), ) def forward(self, x): return self.net(x) class UNet(nn.Module): def __init__(self, in_ch=1, out_ch=1): 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, stride=2) self.c4 = DoubleConv(1024, 512) self.u3 = nn.ConvTranspose2d(512, 256, 2, stride=2) self.c3 = DoubleConv(512, 256) self.u2 = nn.ConvTranspose2d(256, 128, 2, stride=2) self.c2 = DoubleConv(256, 128) self.u1 = nn.ConvTranspose2d(128, 64, 2, stride=2) 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], dim=1)) x = self.c3(torch.cat([self.u3(x), d3], dim=1)) x = self.c2(torch.cat([self.u2(x), d2], dim=1)) x = self.c1(torch.cat([self.u1(x), d1], dim=1)) 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(dim=1) dice = 1 - (2 * inter + 1e-6) / (probs.sum(dim=1) + targets.sum(dim=1) + 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_ch=1对应灰度输入,out_ch=1对应二分类,如果你要做多类(比如同时分割头部和脑室),把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(), lr=1e-3) scheduler = ReduceLROnPlateau(optimizer, mode="max", factor=0.5, patience=5) def dice_score(pred, target, eps=1e-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(dim=1) return ((2 * inter + eps) / (pred.sum(dim=1) + target.sum(dim=1) + 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(f"Epoch {epoch}: val_dice={val_dice:.4f}, best={best_dice:.4f}")

逻辑说明:ReduceLROnPlateau在验证 Dice 不升时降学习率,patience=5表示连续 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_mm=0.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, key=cv2.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是否对,再检查椭圆拟合是否被声影导致的轮廓缺口带偏。

从那以后我每次拿到新的超声分割数据集,都强制先跑一遍标签像素值统计和图像-掩码配对检查,再开始写模型。这两个检查花不了五分钟,但能省掉后面几小时的无效训练。希望帮到你。

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

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

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

立即咨询