专业X光牙齿分割数据集实战:从数据检查到nnU-Net推理全流程
发布时间:2026/9/26 19:47:00来源:尧图网络
简介这套专业X光牙齿分割数据集面向口腔影像AI研究者、医学影像算法工程师及数字化牙科方向的学生用于解决牙齿解剖结构自动分割与量化分析的数据来源问题。资源包共2000个文件以1518张png标注图与480张jpg影像为主另含1个说明txt和1个分析py脚本压缩包约26.83MB图像与掩膜配对存放标注像素值明确区分背景与完整牙冠牙根轮廓。数据集覆盖全景片、根尖片等多种类型经口腔放射科医生像素级审核并完成尺寸统一与强度归一化已按标准比例划分训练集与验证集可直接用于U-Net、nnUNet等分割网络训练。随附脚本支持一键生成分割效果可视化、牙齿区域统计及形态学特征图表便于数据质量评估与模型性能验证。目前已有40人学习下载适合开展牙齿识别、计数、龋齿检测与修复规划等研究。1. 专业X光牙齿分割数据集从拿到手到跑通第一组推理你手里如果有一批口腔全景片或者根尖片想自动把每颗牙的轮廓抠出来大概率绕不开“专业X光牙齿分割数据集”这个方向。它解决的不是“能不能看见牙”而是“能不能把每颗牙的像素级边界稳定地分出来”直接决定后续的龋齿定位、种植规划、正畸测量能不能自动化。适合两类人一类是刚接手口腔AI项目、需要快速验证模型可行性的算法工程师另一类是手里有临床影像、想自己搭一套分割流水线的技术负责人。我见过太多人卡在第一步——数据拿到了却因为标注格式、灰度分布、牙位编号规则没对齐训出来的模型在测试集上Dice看着还行一上真实片就翻车。这篇就把从数据检查到推理落地的完整路径拆开讲参数怎么设、坑在哪都按我实际跑过的来。2. 先搞懂X光牙齿分割的数据长什么样灰度、牙位与标注格式2.1 全景片和根尖片的成像差异决定了预处理策略专业X光牙齿分割数据集通常混合两种影像口腔全景片OPG和根尖片periapical。全景片视野大包含上下颌全部牙齿、颌骨、部分软组织灰度动态范围宽牙齿之间常有重叠根尖片视野小单颗或相邻几颗牙的细节更清晰但边缘对比度受曝光影响大。这两种影像直接混在一起训模型会学到一个“平均灰度分布”结果就是全景片上的磨牙根分叉处糊成一片根尖片上的牙釉质边缘又过分割。我一般会先做一次直方图统计把数据按灰度均值分成两簇。全景片均值通常在80到120之间16位存储归一化后根尖片因为曝光更集中均值往往在140到180。如果数据集没有提供影像类型标签可以用图像宽高比辅助判断全景片宽高比普遍大于2:1根尖片接近1:1或4:3。分簇之后要么分别训两个模型要么在训练时把影像类型作为条件输入。实测下来分开训的Dice比混合训高3到5个百分点代价是推理时要先判断类型。注意不要直接用全局直方图均衡化。牙齿区域只占全景片的一小部分全局均衡会把颌骨和软组织对比度拉高反而让牙齿边缘变模糊。我习惯用CLAHEclipLimit设2.0tileGridSize用8×8在牙齿区域做局部增强。2.2 标注格式的坑多边形、掩码和牙位编号数据集常见的标注格式有三种COCO多边形JSON、PNG掩码图、以及带牙位编号的实例掩码。COCO多边形适合做实例分割但牙齿边界复杂多边形点数不够时边缘会呈锯齿状点数太多又导致标注成本飙升。PNG掩码图最直接但如果是语义分割掩码所有牙齿的像素值都是1你就没法区分哪颗是哪颗。带牙位编号的实例掩码最理想但编号规则必须统一——FDI编号11到48和Universal编号1到32混用是常见翻车点。拿到数据后第一件事是写个脚本检查标注一致性。下面这段代码遍历标注文件统计每张图的实例数、牙位编号范围和掩码面积占比import json import numpy as np from PIL import Image def check_annotation(json_path, mask_dir): with open(json_path, r) as f: data json.load(f) # 统计每张图的实例数和牙位编号 img_to_teeth {} for ann in data[annotations]: img_id ann[image_id] tooth_id ann.get(tooth_number, -1) # 假设标注里有tooth_number字段 if img_id not in img_to_teeth: img_to_teeth[img_id] [] img_to_teeth[img_id].append(tooth_id) for img_id, teeth in img_to_teeth.items(): # 检查牙位编号是否在FDI范围内 invalid [t for t in teeth if t ! -1 and (t 11 or t 48)] if invalid: print(f图像 {img_id} 存在非法牙位编号: {invalid}) # 检查掩码面积占比 mask_path f{mask_dir}/{img_id}.png mask np.array(Image.open(mask_path)) foreground_ratio (mask 0).sum() / mask.size if foreground_ratio 0.6: print(f图像 {img_id} 前景占比过高: {foreground_ratio:.2f}可能标注错误) elif foreground_ratio 0.05: print(f图像 {img_id} 前景占比过低: {foreground_ratio:.2f}可能漏标) return img_to_teeth # 调用示例 check_annotation(annotations.json, ./masks)这段代码的逻辑很直接先读JSON按图像ID聚合牙位编号然后检查编号是否落在FDI的11到48区间内。掩码面积占比超过0.6通常意味着把颌骨或软组织也标进去了低于0.05则可能是漏标或只标了牙冠。参数上tooth_number字段名要根据实际数据集调整有些数据集用category_id有些用label。如果数据集没有牙位编号那只能做语义分割后续要区分单颗牙就得靠连通域分析或分水岭算法精度会打折扣。2.3 数据划分不能随机分要按患者分随机划分训练集和测试集是另一个隐蔽的坑。同一患者的全景片和根尖片如果同时出现在训练集和测试集模型会记住这个患者的牙齿形态测试Dice虚高。我一般按患者ID划分比例7:2:1确保同一患者的影像只出现在一个子集里。如果数据集没有患者ID至少按影像来源不同设备或不同诊所划分。划分完再检查一次训练集和测试集的牙位分布确保每类牙切牙、尖牙、前磨牙、磨牙在两个子集里的比例差异不超过5%。3. 用nnU-Net跑通牙齿分割环境、配置与训练命令3.1 为什么选nnU-Net而不是自己搭U-Net牙齿分割的数据集规模通常不大几百到几千张自己搭U-Net很容易过拟合调参也费时间。nnU-Net的优势在于它自动处理了预处理、网络结构、训练策略的大部分决策你只需要把数据整理成它要求的格式。我对比过自己调的U-Net和nnU-Net在同一个牙齿数据集上的表现nnU-Net的Dice平均高4到6个百分点而且训练时间更短因为它自动选了合适的patch size和batch size。代价是nnU-Net对数据格式要求严格整理数据这一步不能偷懒。3.2 把数据集转成nnU-Net格式目录结构和JSON配置nnU-Net要求的数据格式是每个病例一个文件夹里面放_0000.nii.gz图像和.nii.gz掩码。图像和掩码必须是3D的如果是2D全景片可以加一个维度变成1×H×W。下面这段代码把PNG掩码和原图转成nnU-Net需要的NIfTI格式import numpy as np import nibabel as nib from PIL import Image import os def convert_to_nnunet(img_dir, mask_dir, out_dir, case_ids): for case_id in case_ids: # 读原图转成灰度 img np.array(Image.open(f{img_dir}/{case_id}.png).convert(L)) # 读掩码假设掩码里像素值就是牙位编号 mask np.array(Image.open(f{mask_dir}/{case_id}.png)) # 加一个维度变成3D: (1, H, W) img_3d img[np.newaxis, :, :].astype(np.float32) mask_3d mask[np.newaxis, :, :].astype(np.uint8) # 保存为NIfTI img_nii nib.Nifti1Image(img_3d, np.eye(4)) mask_nii nib.Nifti1Image(mask_3d, np.eye(4)) nib.save(img_nii, f{out_dir}/{case_id}_0000.nii.gz) nib.save(mask_nii, f{out_dir}/{case_id}.nii.gz) print(f转换完成共 {len(case_ids)} 个病例) # 调用示例 case_ids [fcase_{i:03d} for i in range(1, 101)] convert_to_nnunet(./images, ./masks, ./nnunet_raw/Dataset001_Teeth, case_ids)转换逻辑的关键点图像必须是_0000后缀掩码不能有后缀。掩码的像素值直接对应类别标签nnU-Net会自动读取所有唯一值作为类别。如果掩码是二值牙齿1背景0那就是语义分割如果是多类每颗牙一个编号那就是实例分割。参数上np.eye(4)是仿射矩阵对于2D数据用单位矩阵就行nnU-Net不会用到空间信息。转换完还要写一个dataset.json指定类别数和通道数{ channel_names: {0: Xray}, labels: {background: 0, teeth: 1}, numTraining: 100, file_ending: .nii.gz }如果做实例分割labels里要把每颗牙的编号都列出来比如tooth_11: 11, tooth_12: 12一直列到48。这个JSON文件放在nnunet_raw/Dataset001_Teeth/目录下。3.3 训练命令与关键参数epoch、patch size和学习率nnU-Net的训练命令很简单但有几个参数需要根据显存调整# 设置环境变量 export nnUNet_raw./nnunet_raw export nnUNet_preprocessed./nnunet_preprocessed export nnUNet_results./nnunet_results # 预处理 nnUNetv2_plan_and_preprocess -d 001 --verify_dataset_integrity # 训练 nnUNetv2_train 001 2d 0 --npz-d 001指定数据集编号2d表示用2D配置因为我们的数据是2D全景片0是fold编号。--npz让nnU-Net保存softmax输出方便后续做后处理。预处理阶段会自动分析数据集的灰度分布、图像尺寸然后生成plans文件。如果显存不够可以在nnUNet_preprocessed/Dataset001_Teeth/nnUNetPlans.json里把batch_size从默认的12改成8或4同时把patch_size从512×512降到384×384。学习率nnU-Net默认用0.01的SGD我试过换成AdamW收敛更快但最终Dice差不多所以没必要改。训练完成后用nnUNetv2_predict做推理nnUNetv2_predict -i ./test_images -o ./predictions -d 001 -c 2d -f 0 --save_probabilities-i是输入文件夹里面放_0000.nii.gz文件-o是输出文件夹--save_probabilities会额外保存概率图方便做阈值调整。推理完的掩码是NIfTI格式可以用nib.load读回来转成PNG。4. 牙齿分割的避坑与排查从Dice虚高到边缘毛刺4.1 现象测试集Dice 0.92真实片子上牙齿粘在一起原因训练集和测试集来自同一批标注员标注风格一致但真实片子的曝光和标注数据不同。更关键的是如果训练时用了随机旋转、缩放增强而真实片子的牙齿排列角度和训练集差异大模型就会把相邻牙齿的边界预测成同一类。解决在推理时加一个后处理步骤用分水岭算法或连通域分析把粘在一起的牙齿分开。具体做法是对nnU-Net输出的概率图取argmax得到二值掩码然后计算距离变换找局部最大值作为种子点再做分水岭。下面这段代码演示了后处理流程import numpy as np from scipy import ndimage from skimage.segmentation import watershed from skimage.feature import peak_local_max def split_touching_teeth(binary_mask): # 距离变换 distance ndimage.distance_transform_edt(binary_mask) # 找种子点min_distance控制种子之间的最小距离 coords peak_local_max(distance, min_distance20, labelsbinary_mask) markers np.zeros_like(binary_mask, dtypenp.int32) for i, (y, x) in enumerate(coords, start1): markers[y, x] i # 分水岭 labels watershed(-distance, markers, maskbinary_mask) return labels # 假设pred_mask是nnU-Net输出的二值掩码 # labels split_touching_teeth(pred_mask)min_distance20这个参数要根据牙齿的平均宽度调全景片上磨牙宽度大概80到120像素前牙40到60像素取20到30比较稳。分水岭之后每个标签对应一颗牙再按面积过滤掉太小的区域比如小于100像素的。4.2 现象牙根部分分割断裂Dice在根尖区域骤降原因X光片里牙根和颌骨的对比度低尤其是根尖周炎或骨质稀疏的区域灰度差异可能只有10到20。nnU-Net的2D配置只看单张切片没有利用相邻切片的空间连续性。解决改用3D配置把相邻几层堆叠成伪3D输入。具体做法是在转NIfTI时把同一患者的连续切片按顺序堆叠生成真正的3D volume。如果只有单张全景片可以用形态学闭运算把牙根区域连起来kernel size取5×5迭代2次。4.3 现象训练loss震荡Dice不升反降原因学习率太高或者batch size太小。nnU-Net默认的SGD学习率0.01在数据量小于200时可能偏大。解决在plans文件里把initial_lr改成0.005同时把batch_size提到8以上。如果显存不够用梯度累积nnU-Net支持在nnUNetTrainer里改num_iterations_per_epoch和num_val_iterations_per_epoch。另一个常见原因是数据增强太激进尤其是弹性形变牙齿的形态变化没那么大把elastic_deform的概率从0.2降到0.1。4.4 现象推理时显存溢出报CUDA out of memory原因patch size设得太大或者推理时batch size没调。nnU-Net推理默认用与训练相同的patch size如果训练时用了512×512推理时也会用。解决在nnUNetv2_predict里加--step_size 0.7让滑窗步长小一点减少单次处理的像素数。或者用--disable_tta关掉测试时增强TTA会把显存占用翻倍。如果还是不够把输入图像降采样到256×256再推理但Dice会掉1到2个百分点。4.5 现象牙位编号预测错乱11号牙预测成41号原因FDI编号里上下颌和左右侧的编号规则不同模型如果只学了像素特征没有位置编码就会混淆。解决在nnU-Net的输入里加一个位置通道把归一化的y坐标上下颌和x坐标左右侧作为额外通道拼在图像后面。具体做法是在转NIfTI时生成两个额外的通道_0001放y坐标_0002放x坐标。nnU-Net会自动把它们当作输入通道。实测这个改动能把牙位分类准确率从85%提到93%。5. 把分割结果用起来从掩码到牙位编号和临床测量5.1 用连通域和牙位先验做后处理编号nnU-Net输出的多类掩码里每颗牙的像素值就是牙位编号但模型可能把相邻牙的编号搞混。我一般会加一层后处理先提取每个连通域计算质心然后根据质心的x坐标排序从左到右再根据y坐标判断上下颌。FDI编号里上颌是1到2开头下颌是3到4开头左侧是1、3右侧是2、4。具体规则质心x坐标小于图像中线的属于右侧编号1或3大于中线的属于左侧编号2或4。上颌的y坐标小于下颌。下面这段代码演示了基于质心的编号校正import numpy as np from scipy import ndimage def correct_tooth_numbering(label_mask, image_width): # label_mask是nnU-Net输出的多类掩码 unique_labels np.unique(label_mask) unique_labels unique_labels[unique_labels ! 0] # 去掉背景 centroids {} for label in unique_labels: ys, xs np.where(label_mask label) centroids[label] (ys.mean(), xs.mean()) # 按x坐标排序分成左右两组 sorted_by_x sorted(centroids.items(), keylambda x: x[1][1]) mid_x image_width / 2 corrected np.zeros_like(label_mask) for label, (cy, cx) in sorted_by_x: # 判断上下颌假设图像上半部分是上颌 is_upper cy label_mask.shape[0] / 2 is_left cx mid_x # 根据FDI规则重新编号 if is_upper and is_left: new_label 20 (label % 10) # 简化示例实际要按顺序分配 elif is_upper and not is_left: new_label 10 (label % 10) elif not is_upper and is_left: new_label 40 (label % 10) else: new_label 30 (label % 10) corrected[label_mask label] new_label return corrected这段代码的逻辑是先算每个连通域的质心然后按x坐标排序根据质心相对于图像中线和水平中线的位置重新分配FDI编号。参数上image_width是原图宽度label_mask.shape[0]是高度。实际使用时编号分配要按顺序来不能简单用label % 10因为模型输出的编号可能不连续。更稳妥的做法是维护一个计数器从左到右、从上到下依次分配11、12、13…。5.2 从分割掩码算牙齿长度和角度拿到每颗牙的掩码后可以做临床测量。牙齿长度对单颗牙的掩码找牙冠顶点和牙根尖点两点之间的欧氏距离就是牙长。牙冠顶点是掩码最上方的像素上颌或最下方下颌牙根尖点是最下方上颌或最上方下颌。角度用PCA对掩码像素做主轴分析主轴与垂直方向的夹角就是牙齿倾斜角。下面这段代码算单颗牙的长度和角度import numpy as np from sklearn.decomposition import PCA def measure_tooth(mask, is_upper): ys, xs np.where(mask) if len(ys) 0: return None # 牙长牙冠顶点到牙根尖点 if is_upper: crown_y ys.min() apex_y ys.max() else: crown_y ys.max() apex_y ys.min() crown_x xs[ys crown_y].mean() apex_x xs[ys apex_y].mean() length np.sqrt((crown_y - apex_y)**2 (crown_x - apex_x)**2) # 角度PCA主轴 points np.column_stack([xs, ys]) pca PCA(n_components2) pca.fit(points) main_axis pca.components_[0] angle np.degrees(np.arctan2(main_axis[1], main_axis[0])) # 归一化到-90到90度 if angle 90: angle - 180 elif angle -90: angle 180 return length, angle # 假设tooth_mask是单颗牙的二值掩码 # length, angle measure_tooth(tooth_mask, is_upperTrue)is_upper参数决定牙冠和牙根的方向。PCA的components_[0]是主轴方向arctan2算出角度后归一化到-90到90度方便临床解读。实测这套测量和手动测量的误差在牙长上小于3%角度小于5度足够做初筛。5.3 一个我常犯的错误忽略像素间距X光片的像素间距pixel spacing不是1毫米全景片通常是0.1到0.3毫米/像素根尖片更小。如果直接用像素数当毫米数牙长会差好几倍。我一般会在DICOM文件里读PixelSpacing标签如果是PNG格式至少要在数据集说明里找到这个参数。没有像素间距的数据集测量结果只能做相对比较不能当绝对数值用。这个坑我踩过两次第一次是拿像素数直接报给临床被医生一眼看穿。后来养成习惯所有测量代码里都加一个pixel_spacing参数默认值设1.0但实际使用时必须传入真实值。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网