专业X光牙齿分割数据集实战:从标注检查到nnU-Net训练与牙位编号
简介这套专业X光牙齿分割数据集面向口腔影像AI研究者、医学影像算法工程师及数字化牙科方向的学生用于解决牙齿解剖结构自动分割与量化分析的数据来源问题。资源包共2000个文件以1518张png标注图与480张jpg影像为主另含1个说明txt和1个分析py脚本压缩包约26.83MB图像与掩膜配对存放单通道标注中0代表背景、255代表包含牙冠与牙根的完整牙齿轮廓。数据覆盖全景片、根尖片等多种类型兼顾不同年龄与健康、龋齿、修复、缺齿等口腔状况并已完成尺寸统一与强度归一化按标准比例划分训练集与验证集可直接用于U-Net、nnUNet等分割网络训练。随附脚本支持一键生成分割效果可视化、牙齿区域统计与形态学特征图表便于数据质量评估与模型验证。目前已有40人学习适合作为口腔影像分割、牙齿计数与形态分析等研究的起点。1. 专业X光牙齿分割数据集从拿到手到跑通第一版分割结果你手里如果只有一堆口腔全景片想做出能自动勾出每颗牙、牙槽骨、上颌窦甚至下颌神经管的模型第一道坎往往不是网络结构而是标注。专业X光牙齿分割数据集解决的就是这件事它把放射科医生逐像素勾画的牙齿区域整理成机器可读的掩膜让你能把精力放在训练和调参上而不是跪在标注软件前一颗一颗牙去点。这类数据集通常面向全景片OPG和根尖片覆盖龋齿、阻生牙、种植体、正畸托槽等真实干扰适合做牙科AI辅助诊断、术前规划、正畸排牙的团队。下面按我实际落地的顺序把数据检查、格式转换、训练、评估和踩坑一次讲清。2. 拿到数据集先别急着训练X光牙齿分割的标注体系与选型判断2.1 全景片、根尖片、CBCT 三类数据在分割任务里的差别专业X光牙齿分割数据集最常见的载体是口腔全景片一张图里 28 到 32 颗牙全部展开左右对称牙根和牙槽骨重叠。它的优势是单张覆盖全口适合做「逐牙编号 区域分割」劣势是二维投影牙根尖和上颌窦、下颌神经管在垂直方向上有重叠标注边界容易有歧义。根尖片视野小、分辨率高适合做单颗牙的精细分割但一张图只有几颗牙做全口推理要拼接。CBCT 是三维体数据分割目标可以是牙齿、颌骨、气道标注成本最高通常以 NIfTI 或 DICOM 序列形式给出。选型时先问自己三个问题第一你的下游任务是按牙位编号还是只分「牙/非牙」如果要做正畸排牙必须逐牙实例分割全景片数据集更合适。第二你的推理场景是单张全景片还是口内扫描配准前者用 OPG 训练后者要考虑 CBCT 或口扫网格。第三标注是语义分割还是实例分割多数公开数据集给的是二值掩膜或牙位标签图实例分割需要自己从标签图里做连通域拆分。我一般会先看数据集的标签图是单通道索引图还是多通道 one-hot。索引图里每个像素值代表一个类别比如 0 背景、1 到 32 牙位这种格式省空间但训练前要转成 one-hot 或直接用交叉熵。多通道掩膜适合做多标签但文件体积大。如果标签图里牙位编号和 FDI 编号对不上别急着改先确认数据集文档里的编号映射很多翻车都出在「以为 1 号牙是中切牙结果它是第三磨牙」。2.2 标注质量检查三个必须跑的可视化脚本拿到数据后第一件事不是写 DataLoader而是把图像和掩膜叠在一起看。下面这段代码做三件事读入图像和标签、把标签叠加到原图上、保存成对比图。跑完随机抽 20 张看一遍能提前发现标签错位、掩膜全黑、图像翻转等问题。import os import cv2 import numpy as np def overlay_mask(image_path, mask_path, save_path, alpha0.5): # 读原图X光通常是灰度图 img cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) mask cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) if img is None or mask is None: print(f读取失败: {image_path} 或 {mask_path}) return # 尺寸对齐检查很多数据集图像和掩膜尺寸不一致 if img.shape ! mask.shape: print(f尺寸不一致: img{img.shape} mask{mask.shape}已 resize 掩膜) mask cv2.resize(mask, (img.shape[1], img.shape[0]), interpolationcv2.INTER_NEAREST) # 把灰度图转成三通道方便叠加彩色掩膜 img_rgb cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) # 掩膜区域涂红 color_mask np.zeros_like(img_rgb) color_mask[:, :, 2] 255 # BGR 里的红色通道 overlay cv2.addWeighted(img_rgb, 1.0, color_mask, alpha, 0) # 只在掩膜非零处叠加 result np.where(mask[:, :, None] 0, overlay, img_rgb) cv2.imwrite(save_path, result) # 批量跑前 20 张 img_dir data/images mask_dir data/masks out_dir check_overlay os.makedirs(out_dir, exist_okTrue) for i, fname in enumerate(sorted(os.listdir(img_dir))[:20]): name os.path.splitext(fname)[0] overlay_mask( os.path.join(img_dir, fname), os.path.join(mask_dir, name .png), os.path.join(out_dir, f{i:03d}.png) )逻辑说明cv2.IMREAD_GRAYSCALE保证读入单通道避免三通道 X 光图干扰cv2.resize用最近邻插值防止掩膜标签被插值成小数叠加时用np.where只在掩膜非零处上色背景保持原图。参数上alpha0.5是透明度如果掩膜边界看不清可以调到 0.7。跑完重点看三类问题掩膜比牙齿大一圈标注膨胀、掩膜只覆盖牙冠不覆盖牙根标注不完整、左右牙位编号镜像图像翻转没对齐。第二类检查是统计每张图的标签像素占比。如果某张图前景占比超过 60%大概率是掩膜把背景也标进去了如果低于 1%可能是空标注或标签丢失。第三类检查是看类别分布逐牙位分割时统计每个牙位出现的次数缺牙、阻生牙会导致某些类别样本极少训练时要考虑重采样或类别权重。2.3 从索引标签到训练用掩膜转换脚本与参数多数专业X光牙齿分割数据集给的是索引 PNG像素值 0 到 N。训练前要转成两种格式之一多通道 one-hot 用于 Dice Loss或保持索引图用 CrossEntropyLoss。我一般保留索引图在 Dataset 里做 on-the-fly 转换省磁盘也方便加增强。下面这个转换函数把索引图转成 one-hot同时忽略未标注区域像素值 255。import numpy as np def index_to_onehot(mask, num_classes, ignore_index255): # mask: H x W 的整数索引图 # 返回: num_classes x H x W 的 float32 one-hot valid (mask ! ignore_index) onehot np.zeros((num_classes, mask.shape[0], mask.shape[1]), dtypenp.float32) for c in range(num_classes): onehot[c] ((mask c) valid).astype(np.float32) return onehot # 示例假设有 33 类0 背景 32 牙位 mask np.random.randint(0, 33, size(512, 512)).astype(np.uint8) onehot index_to_onehot(mask, num_classes33) print(onehot.shape) # (33, 512, 512)参数说明num_classes必须和数据集文档一致多一类少一类都会导致训练时标签越界或漏学ignore_index255是常见约定如果数据集用 0 表示未标注要改成 0 并在损失函数里设ignore_index0。转换后检查onehot.sum(axis0)是否在有效区域全为 1如果有像素全 0说明该像素的索引值不在 0 到 num_classes-1 之间需要排查标签图。3. 用 nnU-Net 在专业X光牙齿分割数据集上跑通基线环境、配置与训练3.1 为什么牙科分割基线优先选 nnU-Net 而不是自己搭 U-Net牙科 X 光分割的难点是目标细长、边界模糊、类别多。自己搭 U-Net 不是不行但你要花大量时间调 patch size、归一化、数据增强和后处理。nnU-Net 的自配置机制会根据数据集的 spacing、图像尺寸和类别分布自动选网络深度、patch 大小和 batch size在医学分割任务上开箱即用的 Dice 通常比手调 U-Net 高 5 到 10 个点。对于专业X光牙齿分割数据集这种标注成本高、样本量通常几百到几千张的场景先用 nnU-Net 拿到基线再决定要不要换自定义结构是更稳的路径。代价是 nnU-Net 对数据格式有固定要求图像和标签要转成 NIfTI文件名要遵循case_0000.nii.gz和case.nii.gz的配对规则标签必须是整数索引且背景为 0。全景片是二维图像nnU-Net 会把它当单层三维处理需要在转换时加一个维度。下面给完整转换脚本。3.2 把 PNG 全景片转成 nnU-Net 要求的 NIfTI 格式import os import numpy as np import nibabel as nib import cv2 def convert_to_nnunet(img_dir, mask_dir, out_dir, case_prefixcase): # nnU-Net 要求 imagesTr 和 labelsTr 两个子目录 img_out os.path.join(out_dir, imagesTr) lbl_out os.path.join(out_dir, labelsTr) os.makedirs(img_out, exist_okTrue) os.makedirs(lbl_out, exist_okTrue) for idx, fname in enumerate(sorted(os.listdir(img_dir))): name os.path.splitext(fname)[0] img cv2.imread(os.path.join(img_dir, fname), cv2.IMREAD_GRAYSCALE) mask cv2.imread(os.path.join(mask_dir, name .png), cv2.IMREAD_GRAYSCALE) if img is None or mask is None: print(f跳过 {name}读取失败) continue # 尺寸对齐 if img.shape ! mask.shape: mask cv2.resize(mask, (img.shape[1], img.shape[0]), interpolationcv2.INTER_NEAREST) # nnU-Net 期望三维二维图加一个长度为 1 的维度 img_3d img[np.newaxis, :, :].astype(np.float32) mask_3d mask[np.newaxis, :, :].astype(np.uint8) # 图像文件名必须带 _0000 通道后缀 case_id f{case_prefix}_{idx:04d} nib.save(nib.Nifti1Image(img_3d, np.eye(4)), os.path.join(img_out, case_id _0000.nii.gz)) nib.save(nib.Nifti1Image(mask_3d, np.eye(4)), os.path.join(lbl_out, case_id .nii.gz)) print(f已转换 {case_id}) convert_to_nnunet(data/images, data/masks, nnunet_raw/Dataset001_Teeth)逻辑说明img[np.newaxis, :, :]把 H×W 变成 1×H×WnnU-Net 会把它当单层三维数据np.eye(4)是仿射矩阵二维数据没有真实空间信息用单位矩阵占位即可文件名case_0000.nii.gz里的_0000是通道编号多模态数据才需要_0001单通道灰度图只用_0000。转换完检查labelsTr里的标签值是否从 0 开始连续nnU-Net 要求背景为 0前景类别从 1 到 N。3.3 dataset.json 与训练计划牙科数据的关键参数转换完数据要写dataset.json放在nnunet_raw/Dataset001_Teeth/下。这个文件告诉 nnU-Net 有多少类、通道名是什么、文件后缀是什么。{ channel_names: { 0: Xray }, labels: { background: 0, tooth: 1, alveolar_bone: 2, maxillary_sinus: 3, mandibular_canal: 4 }, numTraining: 500, file_ending: .nii.gz }参数说明channel_names里0对应灰度 X 光labels必须背景为 0其余类别连续编号如果你的数据集是逐牙位 32 类这里就要列 33 项numTraining是训练样本数必须和imagesTr里的文件数一致否则 nnU-Net 会报错。写完后跑nnUNetv2_plan_and_preprocess -d 001 --verify_dataset_integrity它会检查数据完整性并生成预处理计划。牙科全景片通常分辨率在 2000×1000 以上nnU-Net 会自动选 patch size如果显存不够在nnUNet_preprocessed/Dataset001_Teeth/nnUNetPlans.json里把batch_size从默认的 2 改成 1或者把patch_size从[1, 512, 512]降到[1, 384, 384]。训练命令用nnUNetv2_train 001 2d 02d表示按二维切片训练0是 fold 编号。牙科全景片是二维投影用2d配置比3d_fullres更合适训练也快。如果要做五折交叉验证把最后的0换成0 1 2 3 4依次跑。训练完在nnUNet_results/Dataset001_Teeth/nnUNetTrainer__nnUNetPlans__2d/fold_0/下找checkpoint_best.pth用nnUNetv2_predict做推理。4. 专业X光牙齿分割的避坑与排查标注、显存与后处理4.1 掩膜边界偏移半个像素Dice 直接掉 10 个点现象训练 loss 正常下降但验证集 Dice 卡在 0.75 上不去可视化发现预测掩膜整体比标注偏移 1 到 2 个像素。原因图像和掩膜在预处理时用了不同的插值方式图像用双线性掩膜用双线性会把标签插成小数再取整就偏移了。解决所有几何变换对掩膜一律用最近邻插值nnU-Net 内部已经处理了这点但如果你自己写增强cv2.resize、torch.nn.functional.interpolate都要设modenearest。另外检查 DICOM 转 PNG 时有没有做窗宽窗位变换窗宽窗位只影响图像不影响掩膜但如果你对图像做了直方图均衡而掩膜没同步边界也会错。4.2 显存溢出patch size 和 batch size 的取舍现象训练到第 10 个 epoch 突然 OOM或者一开始就报CUDA out of memory。原因全景片分辨率高nnU-Net 默认 patch size 可能到 512×512batch size 2 在 8GB 显存上就爆了。解决优先降 batch size 到 1再降 patch size。改nnUNetPlans.json里的batch_size和patch_size然后重新跑nnUNetv2_preprocess。如果还不行把图像在转换阶段就 resize 到长边 1024牙科全景片缩到 1024 后牙齿边界仍然可辨Dice 损失通常不超过 2 个点。注意不要用torch.cuda.empty_cache()当常规手段它只释放缓存不解决根本的显存需求。4.3 类别极不平衡缺牙和阻生牙把模型带偏现象模型在多数牙位上 Dice 0.9但在第三磨牙和缺牙区 Dice 只有 0.3。原因数据集里第三磨牙样本少缺牙区没有前景模型学到「预测背景最安全」。解决在 nnU-Net 里开--c启用类别权重或者用nnUNetTrainerWeighted训练器。更直接的办法是在dataset.json里把极少的类别合并比如把「阻生第三磨牙」并入「磨牙」先保证整体分割稳定再单独训一个二阶段分类器判断牙位。如果坚持逐类分割用重采样让每个 batch 里至少包含一个稀有类别样本nnU-Net 的oversample参数可以调默认 0.33调到 0.5 会增加稀有类出现频率。4.4 后处理把该留的牙根删了现象推理结果里牙冠完整但牙根尖被后处理删掉Dice 下降。原因nnU-Net 默认后处理会移除小于一定体素的连通域牙根尖在二维切片里可能只有几十个像素被当成噪声。解决在nnUNet_results/.../postprocessing.json里把min_size调小或者直接关掉后处理用nnUNetv2_predict时加--disable_postprocessing。牙科分割里牙根尖和下颌神经管都是细长结构后处理的连通域阈值要设得比腹部器官分割小一个量级。我一般先关后处理看原始输出再决定要不要加。4.5 训练集和验证集来自不同设备Dice 断崖下跌现象交叉验证 Dice 0.88换一家医院的全景片测试掉到 0.6。原因不同设备的 X 光管电压、曝光时间、探测器响应不同图像对比度和噪声分布差异大。解决训练时加更强的灰度增强nnU-Net 默认有gamma、brightness、contrast增强把它们的概率从 0.3 调到 0.5。另外在预处理阶段用zscore归一化代替CT归一化X 光不是 CT没有固定的 HU 值zscore 按均值和标准差归一化更稳。如果目标域数据有少量标注用 nnU-Net 的微调功能在预训练权重上跑 50 个 epoch学习率降到 1e-4。5. 把分割结果变成可用的牙位编号连通域拆分与 FDI 映射技巧训练完拿到的是语义分割掩膜每个像素标了「牙」或「背景」但临床要的是「这是 16 号牙」。从语义掩膜到牙位编号中间差一个实例拆分。全景片里牙齿左右排列相邻牙在掩膜上可能粘连尤其是正畸托槽和种植体区域。我一般用分水岭加牙位先验来做先对「牙」类掩膜做距离变换找局部极大值作为种子点再用分水岭分割最后按质心横坐标排序映射到 FDI 编号。import cv2 import numpy as np from scipy import ndimage def split_teeth(mask, min_area200): # mask: 二值掩膜牙齿为 1 # 距离变换离背景越远值越大 dist cv2.distanceTransform(mask.astype(np.uint8), cv2.DIST_L2, 5) # 找种子点阈值取距离变换最大值的 0.4 倍 _, sure_fg cv2.threshold(dist, 0.4 * dist.max(), 255, 0) sure_fg sure_fg.astype(np.uint8) # 连通域标记种子 num_seeds, seeds cv2.connectedComponents(sure_fg) # 分水岭 markers seeds 1 markers[mask 0] 0 img_3ch cv2.cvtColor((mask * 255).astype(np.uint8), cv2.COLOR_GRAY2BGR) markers cv2.watershed(img_3ch, markers) # 按面积过滤小区域 instances [] for label_id in range(2, markers.max() 1): region (markers label_id) if region.sum() min_area: continue ys, xs np.where(region) instances.append({ mask: region, centroid_x: xs.mean(), centroid_y: ys.mean(), area: region.sum() }) # 按横坐标排序模拟 FDI 从左到右的牙位顺序 instances.sort(keylambda x: x[centroid_x]) return instances # 假设 pred_mask 是模型输出的二值掩膜 # instances split_teeth(pred_mask) # for i, inst in enumerate(instances): # print(f第 {i1} 个实例质心 x{inst[centroid_x]:.1f}面积{inst[area]})逻辑说明distanceTransform把每颗牙的中心变成峰值threshold取 0.4 倍最大值是为了避免种子过多导致过分割connectedComponents给每个种子独立编号watershed用种子和背景标记做分水岭边界处标记为 -1最后按面积过滤掉噪声按质心横坐标排序。参数min_area200是经验值全景片缩放到 1024 宽时一颗前磨牙面积大约 500 到 1500 像素磨牙 2000 以上如果分割结果里出现 100 像素以下的小块基本是噪声。0.4这个系数对粘连牙敏感如果两颗牙粘在一起调低到 0.3 能拆开但可能把一颗牙拆成两块需要根据验证集调。映射到 FDI 编号时全景片通常以中线为界右上到左上依次是 18 到 11、21 到 28右下到左下是 48 到 41、31 到 38。实际排序时不能只按横坐标因为缺牙会导致序号错位。更稳的做法是训练一个牙位分类头或者用检测框先定位每颗牙再分类。如果只做分割不做编号这一步可以跳过把实例掩膜直接交给下游做面积测量和角度计算。最后说一个我自己的习惯每次拿到新的专业X光牙齿分割数据集先跑一遍 20 张可视化再跑 nnU-Net 的--verify_dataset_integrity然后只训 5 个 epoch 看验证 Dice 有没有超过 0.5。如果 5 个 epoch 还在 0.3 以下不是数据有问题就是标签映射错了别硬训到 1000 epoch那是浪费卡时。希望帮到你。本文还有配套的精品资源点击获取