简介:专业X光牙齿分割数据集,面向医学影像AI、口腔数字化诊断方向的研究者与开发者,用于训练和验证牙齿自动分割模型。数据集包含1600张口腔X光影像,覆盖全景片、根尖片等类型,涵盖不同年龄及健康、龋齿、修复、缺齿等多种口腔状况,每张影像均经口腔放射科医生审核并完成像素级标注,掩膜中0代表背景,255代表完整牙齿轮廓。资源包为7z压缩格式,共2000个文件,以1518个png图像(原图与标注掩膜)和480张jpg影像为主,另含txt说明与py分析脚本,总大小约26.83MB。目前已有43人学习。随附分析脚本支持一键生成分割可视化、牙齿区域统计与形态学特征分析图表,便于数据质量评估与模型性能验证;该数据集适用于U-Net、nnUNet等主流分割网络,可为牙齿识别、计数、形态分析及龋齿检测、修复规划等智能口腔应用提供扎实的数据基础。
1. 专业X光牙齿分割数据集:牙科AI里最不该省的就是像素级标注
专业X光牙齿分割数据集,看着是给每颗牙描边的入门任务,真做下去你会意识到它比CT器官分割还磨人。牙齿和牙周组织在灰度上几乎连成一片,智齿压着下颌神经管,金属填充物在片子里亮成白斑,牙根和牙槽骨的边界要靠解剖位置去猜。这份数据集的价值不在图多,而在掩膜是按临床测量标准做的:牙冠、牙根、邻接关系都有对应编码,训练分割模型、验证后处理流程、搭根管测量原型都能直接在上面跑。适合两类人:从自然图像分割转向医学影像的算法工程师,以及牙科AI产品里要快速出分割原型的开发者。下面按拿到数据后的实操顺序拆,讲清楚为什么这么干、参数怎么调、什么时候该停下来。
2. 数据构成与标注体系:先别搭模型,把掩膜对齐关系搞清楚
拿到这类数据集,我一般先压住直接跑模型的冲动。分割任务最怕的不是模型效果差,而是图像和掩膜悄悄错位、通道数不一致、类别编码和文档对不上。这些问题不会报错,只会让训练曲线好看、实测一塌糊涂。花二十分钟做一次全量体检,后面能省掉一整天的排查。
2.1 先用脚本做一次全量体检
第一步是遍历整个数据集,把每张图像和掩膜的尺寸、通道数、数值范围打成清单。常见做法是用Python写一个快速体检脚本,一次性把train和val两个子集都过一遍:
import os from PIL import Image import numpy as np data_root = "xp_dataset" for subset in ["train", "val"]: img_dir = os.path.join(data_root, subset, "images") mask_dir = os.path.join(data_root, subset, "masks") for name in sorted(os.listdir(img_dir)): img = np.asarray(Image.open(os.path.join(img_dir, name))) mask_name = name.replace(".png", "_mask.png") mask = np.asarray(Image.open(os.path.join(mask_dir, mask_name))) print(subset, name, "img", img.shape, img.dtype, img.min(), img.max(), "mask", mask.shape, np.unique(mask))这段代码做三件事:确认图像和掩膜同名一一对应、对齐尺寸、统计掩膜的像素类别。输出里只要出现一次mask尺寸和img不一致,就要停下来查原因,不能进训练环节。常见坑是标注工具把掩膜存成带调色板的索引图,PIL读出来shape多了一维,到NumPy里变成(h, w, 4)或(h, w, 3),和单通道灰度图直接对不上。
注意:遇到带调色板的索引图,读掩膜时先
Image.open(path).convert("L")再转数组,避免shape不匹配在训练中期才暴露。
参数上提醒两点:掩膜尽量用PNG无损格式,避免JPG压缩把牙齿边界搞毛;np.unique的返回值直接告诉你标签编码方式——只出现0和1就是二分类牙齿前景,出现0、1、2等多值,就要看是牙位编号还是牙冠/牙根分区。我的习惯是把这份统计结果存成CSV,每次训练前重跑一遍,防止有人动过数据没通知。
2.2 类别编码与配准规则:掩膜的每一条规则都写在像素里
牙齿X光分割数据集的标注体系通常分两层:整颗牙齿前景,或者按临床需求细分。我在实际项目里见过三种编码方式,对应不同用途:
| 编码方式 | 像素值 | 典型用途 |
|---|---|---|
| 二分类前景 | 0背景 / 1牙齿 | 通用分割、面积测量 |
| 多类分区 | 0背景 / 1牙冠 / 2牙根 | 根管长度分析 |
| 牙位编号 | 0背景 / 1-32对应牙位 | 正畸与牙周自动排号 |
拿到掩膜后不要只看颜色对不对,要做配准检查:把掩膜以半透明方式叠加到X光原图上,逐张抽查边缘。我的流程是每个epoch结束后随机抽三张验证图,把mask轮廓画出来叠在原图上存成日志。这样能发现两类问题:整体错位和局部边界漂移。掩膜轮廓如果整体偏向一侧,多半是翻转或仿射配准参数不一致;如果只有牙根区域偏,通常是标注时对牙槽骨边界的判断差异。这类问题比模型容量不够致命得多——Dice损失照样下降,但出来的轮廓临床根本不能用。
2.3 原始DICOM与PNG之间的转换链路
口腔全景片和根尖片在医院里多数是DICOM格式,而训练管线里常用的是PNG。如果资源包给的是DICOM原图加标注文件,需要先统一转成8位深度PNG。常见做法是pydicom读取、numpy做窗宽窗位映射、再保存,我一般写成独立脚本:
import pydicom import numpy as np from PIL import Image def dicom_to_png(dcm_path, png_path, window_center, window_width): ds = pydicom.dcmread(dcm_path) arr = ds.pixel_array.astype(np.float32) low = window_center - window_width / 2 high = window_center + window_width / 2 arr = np.clip((arr - low) / (high - low), 0, 1) arr = (arr * 255).astype(np.uint8) Image.fromarray(arr, mode="L").save(png_path)window_center和window_width是从DICOM头读取的显示参数,对应口腔软组织的灰度范围。口腔X光经常是16位灰度,直接截断到8位会把牙釉质和背景完全揉在一起,所以必须用窗宽窗位映射而不是简单除以最大值。转换后务必用第2.1节的脚本再体检一遍,重点看数值范围是否落在0到255、掩膜与转换后的PNG尺寸是否一致。同一批DICOM如果来自不同设备,窗宽窗位差异会很大,我一般按设备型号分组各自取参数,避免模型把设备差异当成语义差异学进去。
3. 预处理与数据增强:让模型在低对比度X光上不飘
X光成像和自然图像不一样,对比度完全由组织和射线的衰减决定,后期没法靠调色救回来。牙齿分割里最典型的画面是牙根和牙槽骨在灰阶上过度平滑,肉眼能靠解剖位置分辨,模型却会把边界往牙槽骨方向推。预处理这一步决定模型能不能学到边界,而不只是学多快。
3.1 CLAHE:对牙周组织最友好的对比度增强
全局直方图均衡化对这类低对比度图像很容易把背景噪声一起抬起来,所以常见做法是限制对比度自适应直方图均衡化(CLAHE)。我对牙齿X光的习惯参数是clipLimit在1.5到3.0之间、tileGridSize用(8,8),核心代码很短:
import cv2 def clahe_image(gray, clip_limit=2.0, tile_size=8): clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=(tile_size, tile_size)) return clahe.apply(gray)clipLimit控制每个tile内对比度放大的上限,设得越大牙根和背景差异越明显,但噪声也越容易被放大。tileGridSize决定局部自适应的粒度,8×8在2000像素级别的原图上能兼顾牙齿和颌骨区域;如果输入是相对小的根尖片,可以降到4×4,避免细节被过度平滑。这里要特别强调:CLAHE只对单通道生效,彩色牙片要先转灰度再应用。如果数据集里有些图已经带窗宽映射、有些没有,统一走一遍CLAHE反而能对齐特征分布。
3.2 尺寸策略与数据加载:全图缩放还是随机裁剪
口腔全景片的原生分辨率往往在两三千像素量级。直接缩放到512×512会让牙缝和根尖细节损失掉,直接全图进显存会把batch压得很低。我一般用随机裁剪加在线缩放:训练时从原图上裁出384或256见方的patch,推理时用滑窗拼接。这么做还有个额外好处,就是变相做了数据增强——裁剪窗口每次位置不同,同一张片子每轮被看到的内容不一样。
数据加载层面对牙齿X光有个容易被忽略的点:掩膜要和图像用同一种插值对齐。图像缩放用双线性,掩膜缩放必须用最近邻,否则牙根边缘会出现模糊的过渡值,训练时标签就脏了。下面这个Dataset只做读取、随机裁剪、CLAHE和归一化,训练循环里直接调用:
import random import numpy as np import cv2 from torch.utils.data import Dataset class ToothDataset(Dataset): def __init__(self, image_paths, mask_paths, patch_size=384, clahe_clip=2.0): self.image_paths = image_paths self.mask_paths = mask_paths self.patch_size = patch_size self.clahe = cv2.createCLAHE(clipLimit=clahe_clip, tileGridSize=(8, 8)) def __len__(self): return len(self.image_paths) def __getitem__(self, idx): img = cv2.imread(self.image_paths[idx], cv2.IMREAD_GRAYSCALE) mask = cv2.imread(self.mask_paths[idx], cv2.IMREAD_GRAYSCALE) h, w = img.shape[:2] ps = self.patch_size y = random.randint(0, max(0, h - ps)) x = random.randint(0, max(0, w - ps)) img = img[y:y+ps, x:x+ps] mask = mask[y:y+ps, x:x+ps] img = self.clahe.apply(img) img = (img.astype(np.float32) / 255.0 - 0.5) / 0.5 mask = (mask > 0).astype(np.float32) return img[None], mask[None]关键参数是patch_size和clahe_clip。patch_size选384,是在2K原图上保留足够牙冠牙根上下文且不会爆显存的折中;显存吃紧可以降到256,但牙缝细小的位置要靠推理时提高重叠率来补。最后两行分别是归一化和二值化,这里把掩膜所有大于0的像素都当成前景,对二分类是安全的;遇到多类编码,要先把对应类别的像素挑出来再进这个类,不能直接二值化。
3.3 数据增强管线:哪些能开,哪些一开就翻车
牙齿X光的解剖约束比自然图像强得多,不能照搬目标检测里的增强组合。旋转和小幅缩放都没问题,但垂直翻转基本不能开——牙冠在上、牙根在下是固定解剖方位,开了模型等于同时学上下颠倒的两套特征。左右翻转要看后续任务:只做整牙分割且不区分牙位可以开;如果要用模型输出做牙位编号,左右翻转会让注意力全乱。
我常用albumentations配一条管线,顺序是水平翻转、随机旋转、随机亮度对比度、弹性形变。弹性形变幅度要保守,牙根和牙槽骨交界处对形变敏感,幅度大了模型会学到扭曲边界:
import albumentations as A train_transform = A.Compose([ A.HorizontalFlip(p=0.5), A.Rotate(limit=15, border_mode=cv2.BORDER_CONSTANT), A.RandomBrightnessContrast(brightness_limit=0.15, contrast_limit=0.15, p=0.5), A.ElasticTransform(alpha=10, sigma=3, p=0.3), ]) mask_transform = A.Compose([ A.HorizontalFlip(p=0.5), A.Rotate(limit=15, border_mode=cv2.BORDER_CONSTANT), A.RandomBrightnessContrast(brightness_limit=0.15, contrast_limit=0.15, p=0.5), A.ElasticTransform(alpha=10, sigma=3, p=0.3), ])图像和掩膜要分别调用各自的transform对象,原因在于mask不需要亮度扰动。但要保证Flip和Rotate这两个几何变换在两边同步开启,albumentations官方推荐的方式是设相同的seed,或者直接用A.ReplayCompose记录变换序列再重放。Rotate的border_mode用BORDER_CONSTANT,填充值默认0,和掩膜背景的0对应。如果片子边缘有牙齿,旋转后填充区域会被当背景训练,这属于正常现象,但Rotate概率设太高会让边缘牙的召回下降,我一般控制在0.5以下。
4. U-Net训练与评估:把Dice刷上去的参数实践
分割任务到了训练阶段,网络结构反而最不用纠结,U-Net及其变体仍然是牙齿X光分割的主流选择。真正拉开效果差距的是三个点:编码器预训练怎么和灰度输入配合、损失函数怎么组合、验证指标和训练目标是不是一致。
4.1 编码器选型:不必一上来就上Transformer
X光片输入是单通道灰度图,直接套ImageNet三通道预训练权重会有些别扭。常见做法是训练时把单通道复制成三通道,我习惯用这种方式,省事且效果稳定;另一种做法是把首层卷积权重按通道平均重映射,省一点显存但工程上多一步。编码器层面,ResNet34作为U-Net骨干已经能覆盖大部分数据规模,DeepLabV3+边界更平滑但推理成本高,TransUNet这类方案在小数据集上收敛不稳定。先用U-Net拿到基线Dice,再根据误差形态决定要不要升级。
| 方案 | 收敛速度 | 显存占用 | 推荐场景 |
|---|---|---|---|
| U-Net(ResNet34) | 快 | 低 | 基线首选,样本量中等 |
| U-Net(ResNet50) | 中 | 中 | 数据量足,追求更高Dice |
| DeepLabV3+ | 中 | 中高 | 边界平滑要求高 |
| TransUNet | 慢 | 高 | 样本充足且需要长距离依赖 |
如果数据集里图像尺寸差异大,输入分辨率要统一。小数据集用patch训练相当于在样本位置层面做增广,我滑动采样时会让patch之间有一定重叠,但不让同一批patch来自同一张图的相同位置,避免梯度重复。
4.2 损失组合:BCE加上soft Dice,别只盯交叉熵
牙齿前景在整张X光里占比很小,背景大而牙齿小,单独用BCE会让模型倾向收缩边界——少预测一个前景像素对loss影响不大,但Dice损失会惩罚这种收缩。我一般用BCE和soft dice做加权求和,dice部分按区域占比加权,天然抵消前景比例失衡:
import torch import torch.nn.functional as F def bce_dice_loss(pred, target, dice_weight=0.5, smooth=1e-6): pred = pred.reshape(pred.size(0), -1) target = target.reshape(target.size(0), -1) bce = F.binary_cross_entropy_with_logits(pred, target) pred = torch.sigmoid(pred) intersection = (pred * target).sum(dim=1) dice = 1 - (2 * intersection + smooth) / ( pred.sum(dim=1) + target.sum(dim=1) + smooth ) return bce + dice_weight * dice.mean()pred是未经过sigmoid的logits,BCE和dice分开算再加权。dice_weight通常给0.5,前景占比特别小可以调到0.7到0.8,但训练早期dice部分的梯度可能把loss顶成NaN,smooth=1e-6是防御项,也是防止预测全为0时除零。如果开了混合精度还出现Inf,可以把smooth提高到1e-4,这比调学习率更直接。
4.3 训练参数与调度:OneCycle能把epoch省下来
我把常用的一组训练参数列在这里,它是我在类似口腔X光分割任务上的起手配置:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 输入patch | 384×384 | 显存允许可升512 |
| batch size | 8 | 单卡16G左右的常见起点 |
| 优化器 | AdamW | weight decay 1e-4 |
| 初始学习率 | 3e-4 | 配合OneCycle略高 |
| 调度器 | OneCycleLR | 总epoch的60%-70%处达峰 |
| 训练轮数 | 60-80 | 以验证集Dice不再上升为准 |
| 混合精度 | 开 | 出NaN再关掉排查 |
AdamW配合OneCycleLR是我验证过比较省心的组合。学习率峰值设在3e-4附近,前5个epoch线性预热,后段衰减殆尽,收敛速度比固定学习率快三分之一。早停按验证集Dice来,patience给10到15个epoch。检测任务转过来的同学不要沿用两阶段冻结微调的习惯,这里直接端到端训练,编码器预训练权重自然会逐渐适配灰度分布。
4.4 评估指标:Dice和IoU分开看,和训练loss并不等价
训练完模型,评估指标建议单独写一份脚本,原因是训练时的dice loss是平滑可导版本,和离线计算的硬Dice有细微差别。离线评估我还会额外算IoU和每类Dice,不只算全局平均:
def dice_iou(pred_mask, true_mask): pred = pred_mask > 0.5 true = true_mask > 0.5 inter = (pred & true).sum() union = (pred | true).sum() dice = (2 * inter) / max(pred.sum() + true.sum(), 1) iou = inter / max(union, 1) return dice, ioupred_mask是模型输出的概率图,先按0.5阈值二值化再计算。这里0.5不是最优阈值,牙齿分割里牙根边缘概率偏低,如果目标是对轮廓做临床测量,可以降到0.4再结合形态学后处理。Dice和IoU要一起看:Dice高IoU低,说明预测区域和真实区域重叠不够集中,边界抖动大;两者都高,结果才可能直接进临床流程。
5. 避坑指南:牙齿分割现场常见的五个翻车点
这章写的是我在类似工作上踩过的坑,每条都是先看到现象,再定位原因,最后改成稳定方案。排序按碰到频率来。
5.1 掩膜和图像尺寸匹配,Dice依旧低得诡异
现象:训练loss正常下降,验证Dice只有0.5到0.6,人工抽查发现掩膜轮廓整体比牙齿小一圈,且偏向内侧。
原因:标注工具导出时做了边缘收缩,或者掩膜生成过程用了高斯模糊后再阈值化,边界本身偏小。X光牙齿边界本来就不锐利,手动描边也会系统性内缩。
解决:先画Dice分布直方图,确认是全局偏低还是个别样本偏低。全局偏低就检查掩膜生成链路,把原图牙釉质外缘和掩膜边缘做像素级对比。我后来在加载掩膜时加了一个形态学膨胀1像素选项,专门吸收标注内缩,配合训练期的弹性形变,Dice能回到0.8以上。膨胀操作要用3×3结构元,不要用大核,否则相邻牙齿会被并到一起。
5.2 背景高亮区被模型预测成牙齿
现象:牙釉质和金属填充物在X光里亮度高,模型把修复体区域整块预测成前景,Dice不低但临床完全不可用。
原因:数据里这类样本占比少,模型学到的是高亮度等于牙齿的捷径。CLAHE增强之后,金属和牙釉质都变成高亮区,区分度反而更低。
解决:收集含高亮修复体的样本单独做一轮微调。样本量不够就在增强里加局部高斯亮斑模拟金属,让模型学会高亮不代表牙齿。推理后按连通域面积过滤,小于真实牙齿最小面积的预测区域直接丢弃,这个方法对散落的亮斑噪声非常有效,我在后处理里固定保留这一步。
5.3 同一患者的片子被拆进训练集和验证集
现象:验证Dice和训练Dice只差0.02左右,看着收敛得很漂亮,换一批新数据表现断崖式下跌。
原因:同一患者的口腔片高度相似,按文件随机切分时,把同一患者的左右侧或不同时间点的片子拆给了两个集合,造成数据泄漏。
解决:切分必须按患者ID分组。拿到数据集先看文件命名规律,确认是否有患者前缀;没有的话按文件名前几个字符推断,再写脚本把同一ID的样本全分到同一折。我一般做一个group_kfold,保证验证集只出现训练时没见过的牙齿形态。这个问题在牙齿分割里比自然图像分割更隐蔽,因为片子之间的相似度肉眼很难直接判断。
5.4 邻牙粘连,分割结果显示两颗牙粘成一坨
现象:两颗紧贴的邻牙被模型判成一片连通域,单颗牙齿面积统计完全失真,后处理也拆不开。
原因:X光片上牙间缝隙本来就窄,patch训练让模型更关注局部纹理而不是牙缝的连续性。另外掩膜里邻牙之间的边界标注如果被擦除成背景,模型就缺少判断缝隙的依据。
解决:先做连通域分析,把粘连区域标记出来,再用距离变换加分水岭按牙间距切开。训练层面,增强里增加对牙缝区域的小幅锐化,同时确认掩膜里两颗牙之间的背景区被准确标成0。如果掩膜本身就把缝隙标没了,那后处理再强也救不回来,只能回炉标注。
5.5 混合精度训练到中途loss变NaN
现象:AMP开启后训练40个epoch,loss突然变成NaN,之后一直回不来,重训到相同位置又复现。
原因:牙齿前景占比低,个别patch的dice损失梯度在fp16下溢出变成Inf。不一定是学习率问题,是损失函数对极小值敏感,混合精度转换时把梯度放大了。
解决:先把dice的smooth从1e-6提到1e-4,看是否复现。无效则把dice部分强制在fp32下计算,再与BCE相加。我最终做法是关闭AMP的自动梯度缩放,把loss_scaler固定在一个区间内跑5个epoch,确认稳定后再重新开启自动模式。出现NaN时先看是哪个loss分量先Inf,不要急着调学习率。
6. 后处理进阶:把模型概率图变成能直接落地的牙齿轮廓
模型输出的是概率图,距离“能用的牙齿轮廓”还差几步。我的固定后处理流程是概率图二值化、连通域分析、孔洞填充、邻牙分离、mask叠加到原图复查。孔洞填充对X光特别重要:牙髓腔在图像里是低灰度区域,模型容易把牙冠内部预测成背景,形成空心牙齿,scipy的binary_fill_holes可以直接解决:
from scipy import ndimage import numpy as np def refine_mask(prob, threshold=0.5, min_area=500): mask = prob > threshold mask = ndimage.binary_fill_holes(mask).astype(np.uint8) labeled, num = ndimage.label(mask) sizes = ndimage.sum(mask, labeled, range(1, num + 1)) for label_id, size in zip(range(1, num + 1), sizes): if size < min_area: mask[labeled == label_id] = 0 return maskmin_area参数一般取训练集中最小单颗牙齿的像素面积,宁可设小一点,设大了会把真正的小牙或乳牙过滤掉。我把threshold降到0.4而不是0.5,因为牙根边缘概率偏低,0.5会吃掉一圈真实边界。邻牙分离的做法是:先对掩膜做欧氏距离变换,再用分水岭按局部极大值切分,这一步我通常在临床测量前才做;如果输出只要保持每个牙位是独立连通域,连通域分析就够用。
验证时我会把30张验证集的预测掩膜叠加到原图上逐张翻,重点看牙根区域轮廓有没有贴着牙槽骨乱飘——这是模型学到纹理而非解剖边缘的经典信号。再做一个抖动测试:同一张图推理两次,开启随机增强,看轮廓边界变化幅度;变化大说明模型对输入的微小扰动敏感,后处理只能掩盖问题不能解决。
从那以后,我每次换牙齿分割数据集都会强制走一遍完整后处理链,再回头看训练指标。希望帮到你。
本文还有配套的精品资源,点击获取