X光牙齿分割实战:数据预处理、UNet训练与评估全解析
2026/9/14 2:06:11 网站建设 项目流程

简介:面向医学图像分割与深度学习语义分割任务的研究者和开发者,这份数据集以X光牙齿影像为对象,提供完整的二类别分割标注(前景牙齿像素255,背景为0),适合用于训练和评估U-Net、DeepLab等主流分割模型。数据已按训练集和测试集划分:训练集包含1600张原始图像及对应mask,测试集包含400张图像及对应mask,前景区域密集且占比较大,贴近真实临床X光片的复杂分布。压缩包共2000个文件,其中1999个为PNG格式图像与标注,另附1个Python可视化脚本,可随机抽取样本将原图、GT掩膜及叠加效果保存至当前目录,便于快速质检与结果展示。整个资源包约434.8MB,已有566人浏览学习,适合课程设计、算法练手或科研基线验证,下载后即可直接用于数据加载、增强和训练流程。

1. 从一张X光片里把牙齿“抠”出来,到底难在哪

牙齿X光分割不是新鲜题,但一直是个容易被低估的活。很多团队拿着UNet训练自己的数据集,跑完发现IoU停在0.7上不去,十有八九不是网络问题,而是数据本身没理顺。这个“基于X光下的牙齿分割数据(2类别分割任务,包含训练集和测试集)”的任务,核心就是两件事:把每一颗牙从背景里分出来,同时把牙齿和周围骨骼、修复体区分清楚。2类别意味着标签只有前景和背景两类,不区分牙冠牙根,也不用做实例编号。

它解决的是下游任务的第一步:正畸测量、龋齿定位、种植牙术前规划,都得先有可靠的牙齿区域。适合谁看?准备训练医学图像分割模型的工程师、要评估公开数据集可用性的算法同学,以及被标注数据质量坑过、想搞清楚训练集和测试集怎么划分才算合理的从业者。这个任务比自然影像分割更棘手的地方在于:X光图像对比度低、牙齿边界模糊、不同患者牙位形态差异大,而且标注的“牙齿”到底是什么,不同医生给的边界可能差出好几个像素。

2. 牙齿X光分割数据的类别定义与预处理方案

2.1 二分类任务的标签语义要先把边界定死

这个任务不区分牙齿实例,标签图上只有0和1两个值。0是背景,1是牙齿区域。听起来简单,但实际标注时最常出现的分歧是牙根尖和牙槽骨的交界处、金属烤瓷冠的高亮伪影、以及儿童混合牙列期的乳牙和恒牙重叠。标注规范里如果没写清楚“牙齿边界以牙釉质外轮廓为准,不包括牙周膜间隙”,训练出来的模型大概率会在牙根区域出现毛刺或空洞。

预处理阶段要做的第一件事是统一存储格式。常见格式有PNG、NIfTI、DICOM转出来的灰度图,无论哪种,读进来后先确认三点:图像尺寸是否统一、灰度范围是否一致、掩码和原图是否严格对齐。可以用一段脚本做初筛:

import cv2 import numpy as np from glob import glob for img_path in sorted(glob("train/images/*.png")): mask_path = img_path.replace("images", "masks").replace(".png", "_mask.png") img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) assert img.shape == mask.shape, f"shape mismatch: {img_path}" assert set(np.unique(mask)).issubset({0, 255}), f"mask values error: {mask_path}" mask_bool = (mask > 127).astype(np.uint8) print(img_path, img.shape, img.min(), img.max(), mask_bool.sum())

这段脚本做的事有三件:第一,逐张对比原图和掩码的尺寸,X光设备和标注工具不匹配时常见错位;第二,校验掩码灰度值,有些标注工具导出的是0和255,有些是0和1,不统一会在计算损失时出大问题;第三,统计每张图的前景像素占比,如果某张掩码几乎全黑或全白,要么是标注漏了,要么是图像本身就极度过曝或欠曝。

这里有个参数细节:mask > 127的阈值处理。如果掩码本身就是0和1的PNG,这行不会出错,但如果你把0/255的图直接用astype(np.float32)做归一化,就会把255除以255变成1,看起来没问题,可一旦掩码里混入128这种中间灰度值,二值化就会把边界区域变成噪声。统一在数据加载器里做一次严格二值化,比在损失函数里加mask清洗逻辑靠谱得多。

2.2 数据增强参数要根据X光特性来定

牙齿X光不是自然图像,增强策略不能照搬检测任务那套。颜色抖动这类增强意义有限,因为X光的灰度分布本身就受曝光参数影响,真正有用的是空间变换和局部对比度扰动。

我常用的增强配置如下:

增强项参数范围理由
随机旋转±10度患者头位有轻微偏转,但过大角度会破坏牙列结构
随机缩放0.9 ~ 1.1模拟不同放大倍率
弹性形变sigma=3, points=4模拟软组织变形,对牙齿这种刚性结构要克制
随机亮度偏移-30 ~ +30模拟曝光差异
平移±5% 图像宽度补边缘上下文

旋转角度限制在±10度是经验值。牙科全景片里牙列是弧形排列,旋转超过15度会让上下牙咬合关系失真,模型反而学到错误的几何先验。弹性形变同理,牙齿是硬组织,形变幅度太大等于在制造不存在的形态,分割网络会变得过于“柔软”,在真实数据上出现边界蠕动。

裁剪策略上,如果原始图像是全景片(Orthopantomogram),宽高比通常在2:1以上,直接整图缩放会让牙齿占比太小。常见做法是随机裁剪出固定尺寸的patch,比如512x512,训练时以牙弓区域为中心采样。注意采样要带前景分布约束:

def sample_patch(img, mask, patch_size=512, foreground_ratio=0.3): h, w = mask.shape for _ in range(50): y = np.random.randint(0, h - patch_size) x = np.random.randint(0, w - patch_size) patch_mask = mask[y:y+patch_size, x:x+patch_size] if (patch_mask > 0).mean() > foreground_ratio: return img[y:y+patch_size, x:x+patch_size], patch_mask return img[:patch_size, :patch_size], mask[:patch_size, :patch_size]

这段代码的逻辑是:随机裁剪50次,如果裁到含牙区域占比超过30%的patch就接受,否则退回左上角。参数foreground_ratio=0.3很关键,设太低(比如0.1)等于没约束,模型训练时大量时间花在全是背景的patch上;设太高(比如0.7)会让模型没见过完整背景结构,推理时在无牙区域疯狂误报。0.3到0.4是比较稳的区间。

2.3 归一化不能简单除以255

X光图像的像素值分布和自然图像完全不同。普通照片像素值在0到255之间近似均匀分布,但X光图像大量像素集中在低灰度区,牙齿的高亮区域只占很小比例。直接除以255做归一化,输入分布会严重偏斜,BatchNorm层的统计量会走偏。

推荐做法有两种。一种是全局百分位归一化:统计训练集所有图像的灰度分布,把1%和99.9%分位作为映射边界。另一种更简单但有效的方式是先做CLAHE(对比度受限自适应直方图均衡化),再做z-score归一化。

import cv2 clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) img_clahe = clahe.apply(img) mean = img_clahe.mean() std = img_clahe.std() img_norm = (img_clahe - mean) / (std + 1e-6)

clipLimit=2.0是经验值。定太大会把噪声放大——X光图像本身就带量子噪声,clipLimit超过3.0时牙本质和牙釉质的边界会冒出一堆假纹理;太低则几乎不起作用。8x8的tileGridSize适合512大小的patch,如果你的输入更大,可以适当放大到16x16。

3. 基于U型网络训练的模型选型与损失函数配置

3.1 为什么从UNet和其变体入手

牙齿分割是经典的医学图像分割场景,U型结构的编码器-解码器架构至今仍是首选。核心原因是牙齿边界需要同时依赖低层细节和高层语义:低层捕捉牙釉质和牙本质的纹理过渡,高层判断某个区域是牙齿还是颌骨。常规UNet的问题在于跳跃连接直接拼接特征,层数深了之后低层特征和高层特征的语义差距过大。

从UNet训练自己的数据集这个思路出发,我的建议是不要把UNet当黑盒跑一把就完事。先明确自己手里的数据量级:如果训练集只有几十到一两百张全景片,用标准UNet配合数据增强就够了;如果数据量到几百张以上,再考虑UNet++或者Attention UNet。因为UNet++的密集跳跃连接虽然能提升分割精度,但参数量和显存开销高一截,小数据量下容易过拟合,收益不明显。

一个更实用的小改进是在第一层卷积后增加一个固定高斯模糊分支。X光图像的牙齿边缘是渐变过渡而不是锐利跳变,直接输入原始梯度会让网络在边界处产生双响应。加上模糊分支等于让网络多一个“低频通道”可用,训练时网络会自动决定要不要用它。改造成本只有几行代码:

import torch.nn as nn import torch.nn.functional as F class GaussianBlur(nn.Module): def __init__(self, channels, kernel_size=5): super().__init__() self.conv = nn.Conv2d(channels, channels, kernel_size, padding=kernel_size//2, groups=channels, bias=False) # 使用固定高斯权重 weights = torch.ones(1, channels, kernel_size, kernel_size) self.conv.weight.data = weights.float() for p in self.conv.parameters(): p.requires_grad = False def forward(self, x): return self.conv(x)

注意这个实现用的是平均卷积近似高斯,真正的torch.distributions.Normal生成权重会更准确,但在医学图像这种低分辨率场景下,近似效果已经够用。关键参数是kernel_size=5,太大(比如9)会把牙根尖的细长结构抹掉,太小(3)和普通卷积没有区别。

3.2 二分类分割的损失函数组合策略

牙齿分割的类别不平衡问题比一般分割任务严重得多。全景片里牙齿区域通常只占图像面积的5%到10%,剩下全是骨骼、软组织和背景。用纯交叉熵训练,网络一开始就会把所有像素预测为背景,因为这样loss已经很低了。

常见做法是用Dice Loss和Focal Loss的加权组合。Dice Loss对类别不平衡天然不敏感,因为它直接在像素交集上做优化;Focal Loss则通过调制因子让网络更关注难分样本,比如牙齿边界处那些模棱两可的像素。

class CombinedLoss(nn.Module): def __init__(self, dice_weight=0.7, focal_weight=0.3, alpha=0.25, gamma=2.0): super().__init__() self.dice_weight = dice_weight self.focal_weight = focal_weight self.alpha = alpha self.gamma = gamma def forward(self, logits, targets): probs = torch.sigmoid(logits) # Dice Loss(smooth=1.0 防止除零) smooth = 1.0 intersection = (probs * targets).sum() dice = (2.0 * intersection + smooth) / (probs.sum() + targets.sum() + smooth) # Focal Loss(针对二分类) focal = -self.alpha * (1 - probs) ** self.gamma * targets * torch.log(probs + 1e-8) \ - (1 - self.alpha) * probs ** self.gamma * (1 - targets) * torch.log(1 - probs + 1e-8) focal = focal.mean() return self.dice_weight * (1 - dice) + self.focal_weight * focal

权重dice_weight=0.7focal_weight=0.3是基准配置。dice权重太高会让网络在脊柱或颌骨区域出现连通性误判——Dice Loss对区域重叠敏感,但对拓扑结构完全不敏感,连成一片的假阳性和星星点点的假阳性在Dice上可能得分相同。focal的alpha=0.25是解决前景占比过低的问题,如果训练时发现召回率上不去,把alpha调到0.4试试。gamma=2.0保持默认即可,调太大会让网络只关注极少数最难样本,训练不稳定。

3.3 训练过程中的关键超参数与早停策略

牙齿分割训练的batch size受限于显存。512x512输入下,UNet的batch size设4到6比较合理。如果显存紧,不要盲目降低batch size到1,而是先减小输入尺寸到384x384,或者换用torch.cuda.amp混合精度训练。

超参数推荐值说明
输入尺寸512x512padding到32的倍数
batch size4 ~ 6取决于GPU显存
初始学习率1e-4AdamW默认即可
学习率调度CosineAnnealing配合warmup 5个epoch
训练轮数100 ~ 150配合早停
weight decay1e-5防止过拟合

早停策略上,我通常监控验证集的Dice系数,而不是loss。原因是验证集loss可能会因为输入图像的灰度差异出现震荡,但Dice系数对灰度分布不敏感。early stopping patience设20个epoch,太短容易被训练初期的波动骗到,太长则浪费时间。

下面是一个典型的训练循环框架:

for epoch in range(num_epochs): model.train() train_loss = 0.0 for img, mask in train_loader: img = img.to(device) mask = mask.to(device) optimizer.zero_grad() logits = model(img) loss = criterion(logits, mask) loss.backward() optimizer.step() train_loss += loss.item() model.eval() val_dice = compute_dice(model, val_loader, device) if val_dice > best_dice: best_dice = val_dice torch.save(model.state_dict(), "best_model.pth") if early_stopping(val_dice, patience=20): break

注意compute_dice需要在torch.no_grad()上下文中运行,并且输出要经过sigmoid后以0.5为阈值做二值化。这里有个容易踩的坑:Dice如果在sigmoid输出上直接计算(不二值化),数值会比二值化后高一截,因为连续的预测值天然比阈值化后的值更接近标签。全流程要保持一致的评估方式,否则无法判断模型是否真的在变好。

4. 训练集和测试集划分:按患者分还是按图像分

4.1 数据泄漏是划分里最容易犯的错误

很多公开数据集在划分训练集和测试集时,只是简单地把图像随机打乱后按比例切分,但牙齿X光数据往往存在同一患者多张影像的情况。同一患者的不同图像之间高度相关:牙位结构相似、拍摄角度接近、甚至标注风格都来自同一位医生。如果同一患者的图像同时出现在训练集和测试集里,模型的测试指标会虚高,等到实际部署时面对新患者,性能立刻跳水。

正确的做法是优先保证患者级别的隔离。如果你的数据里有患者ID字段,按患者ID分组后划分:

from sklearn.model_selection import GroupShuffleSplit gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_idx, test_idx = next(gss.split(images, groups=patient_ids)) train_images = [images[i] for i in train_idx] test_images = [images[i] for i in test_idx]

test_size=0.2是通用做法,但有个细节:如果数据集本身只有几十张照片,20%的测试集可能只有几到十几张,评估结果的方差极大。这种情况下我倾向于把测试集比例提高到0.3或者干脆做K折交叉验证。使用GroupShuffleSplit而不是普通的train_test_split,就是因为后者完全无视患者分组信息,在医学图像场景下几乎等于在作弊。

4.2 测试集的评估指标不能只看Dice

2类别分割任务的评估指标,Dice是最直观的,但不能只报Dice。Dice在牙齿这种小而连续的目标上容易出现“虚高”,因为前景占比低时,即使网络漏掉一整颗牙齿,Dice可能只掉0.02到0.05。还要看IoU(Jaccard)、HD95(Hausdorff距离95分位数)和像素准确率。

指标计算方式在牙齿分割中的含义
Dice2TP / (2TP+FP+FN)区域重叠度,主流报告指标
IoUTP / (TP+FP+FN)比Dice更严格,尤其当预测区域偏大时
HD9595分位表面距离边界质量,牙齿分割的关键指标
Precision/Recall按像素计算区分过分割和欠分割

HD95在实际评估中很容易被忽视,但对牙齿分割而言它几乎是最重要的临床指标。牙齿之间间隙很小,一颗磨牙和邻牙之间的边界线只有几个像素宽,Dice看不出这种精细差异,但HD95能暴露边界毛刺问题。计算HD95需要用到medpy.metric库的hd95函数,输入是两张二值图。

4.3 从失败样本反推动画划分合理性

模型在测试集上跑完,不要急着调参。先做错误分析:把每张测试图的预测结果和原图叠加,按Dice从低到高排序,重点看Dice低于0.8的样本。常见画像有这么几类:

第一种是智齿或深埋阻生齿。这类牙齿和周围组织对比度极低,标注时医生都容易拿不准边界,模型在这类样本上表现差是预期的,不应立刻归因于网络结构问题。第二种是修复体区域。金属烤瓷冠在X光下产生星芒状伪影,模型容易把伪影也归为牙齿。这个时候就要核对训练集里这类样本的占比,如果太少,单纯调损失函数解决不了,要做的是补充数据,或者对图像做伪影抑制预处理。

如果测试集里专门挑出来的难样本恰好都来自某台设备拍摄的图像,那就不是模型问题而是域偏移问题。这个时候的应对策略不是改网络结构,而是在训练集里加入该设备的图像,或者用直方图匹配做一个简单的域自适应。

5. 推理阶段的边界处理与模型导出技巧

最后阶段从训练环境迁移到实际应用,有几个直接影响分割效果的细节值得单独处理,不只是写个torch.jit.script或者用ONNX跑推理就完事。

第一个技巧是测试时增强(TTA)。推理时对同一张输入做水平翻转预测,把两次sigmoid输出平均后再阈值化。牙齿X光存在左右对称性——当然磨牙和前牙的形态不同,但整体布局近似对称——翻转后可有效降低个别低置信度区域的误判。代码实现很简单:

with torch.no_grad(): logits = model(img) logits_flip = torch.flip(model(torch.flip(img, dims=[3])), dims=[3]) pred = torch.sigmoid((logits + logits_flip) / 2) pred = (pred > 0.5).float()

这里阈值0.5不要调得太高。牙齿分割的标签本身存在标注者间差异,如果训练时用了软标签(smooth label),推理阈值可以适当降到0.45左右,边界会更贴合牙釉质外轮廓。但如果模型是用硬标签训练的,降低阈值只会引入更多假阳性。

第二个技巧是对预测结果做连通域后处理。牙齿分割不像自然图像分割,牙齿区域应该是内部没有空洞的连通块。推理出来后用scipy.ndimage.label找连通域,删除面积小于某个阈值的孤立斑点——比如小于全图面积0.5%的候选区域大概率是颌骨高亮误报。这一步能稳定提升精度但不提升Dice,原因是Dice对小块假阳性不敏感,但对临床应用来说减少误导性区域更重要。

from scipy import ndimage def postprocess(pred_mask, min_ratio=0.005): labels, num = ndimage.label(pred_mask) total_pixels = pred_mask.size for i in range(1, num + 1): component = (labels == i) if component.sum() / total_pixels < min_ratio: pred_mask[component] = 0 return pred_mask

第三点是模型导出时的输入尺寸约束。如果训练时用512x512,推理时碰到一张3000x1500的全景片,常见的做法是切成patch推理再拼接。拼接时相邻patch要有重叠区域,重叠部分按距离权重平滑融合,否则边界处会出现明显的拼接缝。重叠比例建议不低于10%。切patch时还要注意牙弓跨越patch边界的情况——牙列是横向排列的,如果竖直切分刚好把牙根截断,模型在两块patch上的预测可能不一致,这时可以通过在推理时额外做一次垂直翻转来检验一致性,取置信度高的结果。

最后关于部署推理框架的选择:训练用PyTorch,导出时用ONNX Runtime跑CPU推理通常已经足够快。牙齿分割不是实时任务,单张全景片推理时间从0.1秒到1秒都能接受,不需要上TensorRT。但ONNX导出时要注意动态尺寸问题,如果切patch输入,固定batch和宽高维度导出即可;如果整图推理,则需要支持动态尺寸,需要在ONNX导出时指定dynamic_axes参数,否则推理端遇到非固定尺寸输入会直接报错。

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

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

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

立即咨询