专业X光牙齿分割数据集:从像素级标注到U-Net训练的完整实践
简介专业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, modeL).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_limit2.0, tile_size8): clahe cv2.createCLAHE(clipLimitclip_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_size384, clahe_clip2.0): self.image_paths image_paths self.mask_paths mask_paths self.patch_size patch_size self.clahe cv2.createCLAHE(clipLimitclahe_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:yps, x:xps] mask mask[y:yps, x:xps] 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(p0.5), A.Rotate(limit15, border_modecv2.BORDER_CONSTANT), A.RandomBrightnessContrast(brightness_limit0.15, contrast_limit0.15, p0.5), A.ElasticTransform(alpha10, sigma3, p0.3), ]) mask_transform A.Compose([ A.HorizontalFlip(p0.5), A.Rotate(limit15, border_modecv2.BORDER_CONSTANT), A.RandomBrightnessContrast(brightness_limit0.15, contrast_limit0.15, p0.5), A.ElasticTransform(alpha10, sigma3, p0.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 编码器选型不必一上来就上TransformerX光片输入是单通道灰度图直接套ImageNet三通道预训练权重会有些别扭。常见做法是训练时把单通道复制成三通道我习惯用这种方式省事且效果稳定另一种做法是把首层卷积权重按通道平均重映射省一点显存但工程上多一步。编码器层面ResNet34作为U-Net骨干已经能覆盖大部分数据规模DeepLabV3边界更平滑但推理成本高TransUNet这类方案在小数据集上收敛不稳定。先用U-Net拿到基线Dice再根据误差形态决定要不要升级。方案收敛速度显存占用推荐场景U-NetResNet34快低基线首选样本量中等U-NetResNet50中中数据量足追求更高DiceDeepLabV3中中高边界平滑要求高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_weight0.5, smooth1e-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(dim1) dice 1 - (2 * intersection smooth) / ( pred.sum(dim1) target.sum(dim1) smooth ) return bce dice_weight * dice.mean()pred是未经过sigmoid的logitsBCE和dice分开算再加权。dice_weight通常给0.5前景占比特别小可以调到0.7到0.8但训练早期dice部分的梯度可能把loss顶成NaNsmooth1e-6是防御项也是防止预测全为0时除零。如果开了混合精度还出现Inf可以把smooth提高到1e-4这比调学习率更直接。4.3 训练参数与调度OneCycle能把epoch省下来我把常用的一组训练参数列在这里它是我在类似口腔X光分割任务上的起手配置参数推荐值说明输入patch384×384显存允许可升512batch size8单卡16G左右的常见起点优化器AdamWweight 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个epochloss突然变成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, threshold0.5, min_area500): 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张验证集的预测掩膜叠加到原图上逐张翻重点看牙根区域轮廓有没有贴着牙槽骨乱飘——这是模型学到纹理而非解剖边缘的经典信号。再做一个抖动测试同一张图推理两次开启随机增强看轮廓边界变化幅度变化大说明模型对输入的微小扰动敏感后处理只能掩盖问题不能解决。从那以后我每次换牙齿分割数据集都会强制走一遍完整后处理链再回头看训练指标。希望帮到你。本文还有配套的精品资源点击获取