news 2026/9/26 16:09:29

专业X光牙齿分割数据集实战:从标注检查到nnU-Net训练与牙位编号

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
专业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, alpha=0.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]), interpolation=cv2.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_ok=True) 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只在掩膜非零处上色,背景保持原图。参数上alpha=0.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_index=255): # 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]), dtype=np.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_classes=33) print(onehot.shape) # (33, 512, 512)

参数说明:num_classes必须和数据集文档一致,多一类少一类都会导致训练时标签越界或漏学;ignore_index=255是常见约定,如果数据集用 0 表示未标注,要改成 0 并在损失函数里设ignore_index=0。转换后检查onehot.sum(axis=0)是否在有效区域全为 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_prefix="case"): # 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_ok=True) os.makedirs(lbl_out, exist_ok=True) 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]), interpolation=cv2.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×W,nnU-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 0,2d表示按二维切片训练,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都要设mode='nearest'。另外检查 DICOM 转 PNG 时有没有做窗宽窗位变换,窗宽窗位只影响图像,不影响掩膜,但如果你对图像做了直方图均衡而掩膜没同步,边界也会错。

4.2 显存溢出:patch size 和 batch size 的取舍

现象:训练到第 10 个 epoch 突然 OOM,或者一开始就报CUDA out of memory。原因:全景片分辨率高,nnU-Net 默认 patch size 可能到 512×512,batch 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_area=200): # 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(key=lambda x: x["centroid_x"]) return instances # 假设 pred_mask 是模型输出的二值掩膜 # instances = split_teeth(pred_mask) # for i, inst in enumerate(instances): # print(f"第 {i+1} 个实例,质心 x={inst['centroid_x']:.1f},面积={inst['area']}")

逻辑说明:distanceTransform把每颗牙的中心变成峰值,threshold取 0.4 倍最大值是为了避免种子过多导致过分割;connectedComponents给每个种子独立编号;watershed用种子和背景标记做分水岭,边界处标记为 -1;最后按面积过滤掉噪声,按质心横坐标排序。参数min_area=200是经验值,全景片缩放到 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,那是浪费卡时。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 16:06:39

Python深度学习实战:狗叫识别从音频数据集到CNN模型训练全流程

简介&#xff1a;这份资源面向希望入门音频识别与深度学习实战的Python开发者&#xff0c;聚焦狗叫声的二分类任务&#xff0c;解决从零搭建音频分类流程的问题。包内共246个文件&#xff0c;以239个wav音频样本为主体&#xff0c;搭配3个Python脚本、3个txt索引文件及1个ckpt模…

作者头像 李华
网站建设 2026/9/26 16:05:10

单机游戏修改器实战:速度、金钱、好感度修改与避坑指南

1. 单机游戏修改工具的核心逻辑与选型思路1.1 为什么单机修改器一直有市场聊到《大侠狂想曲》这类武侠养成游戏&#xff0c;很多玩家的第一反应不是“我要好好练级”&#xff0c;而是“有没有办法让我少刷一点”。这其实不是玩家懒&#xff0c;而是单机游戏的体验节奏和网络游戏…

作者头像 李华
网站建设 2026/9/26 16:04:09

用Cursor 10写12306抢票脚本:TaoToken统一Key接入与config.toml配置骨架

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华