☰
专业X光牙齿分割数据集实战:从预处理到推理的完整指南
2026/9/26 5:05:58 网站建设 项目流程

简介:这套专业X光牙齿分割数据集面向口腔影像AI研究者、医学影像算法工程师及数字化牙科方向的师生,用于解决牙齿解剖结构自动分割中标注数据稀缺、质量参差的问题。包内共2000个文件,以1518张png标注图与480张jpg影像为主,另含1个txt说明和1个py分析脚本,压缩包约26.83MB,图像与掩膜配对存放,单通道标注中0代表背景、255代表牙齿完整轮廓。数据覆盖全景片、根尖片等多种类型,涉及不同年龄与健康、龋齿、修复、缺齿等口腔状况,经口腔放射科医生像素级审核并完成尺寸统一与强度归一化,已按标准比例划分训练集与验证集。随附脚本可一键生成分割效果可视化、牙齿区域统计及形态学特征图表,便于数据质量评估与模型性能验证。目前已有40人学习,适合基于U-Net、nnUNet等网络训练完整牙列分割模型,并延伸至牙齿识别、计数、形态分析及龋齿检测、修复规划等应用。

1. 专业X光牙齿分割数据集:从拿到手到跑通第一张推理图

牙科影像的自动分割,是这几年口腔数字化里落地最快、也最容易被低估的方向。你拿到一份标注好的X光牙齿分割数据集,第一反应可能是“直接丢进U-Net训练就完事了”,但真正动手才会发现:全景片和根尖片混在一起、不同设备灰度分布差异巨大、牙齿边界在牙根处几乎糊成一团、标注类别还可能只标了牙冠没标牙根。这份专业X光牙齿分割数据集,解决的就是“从零标注成本太高”这个最痛的前置问题——它把临床上最耗时的像素级勾画工作提前做完了,让你能把精力放在模型结构、损失函数和后处理上。适合谁?做医学影像分割的算法工程师、口腔AI方向的研究生、以及想快速验证分割pipeline的产品原型开发者。如果你手上只有几十张没标注的片子,这份资源能帮你省掉至少两周的标注和清洗时间。

2. 拆开数据集:影像格式、标注体系与类别映射

2.1 影像来源与灰度分布特征

X光牙齿影像和自然图像最大的区别在于:它是单通道灰度图,且动态范围极宽。全景片(OPG)的像素值通常集中在0到4095之间(12-bit),而根尖片(Periapical)可能只有8-bit。如果你直接按自然图像的归一化方式处理,牙釉质和牙本质的对比度会被压扁,模型学到的边界全是糊的。

常见做法是先把所有影像统一到16-bit无符号整数,再做百分位裁剪。我一般用1%和99%分位点做线性拉伸,而不是简单的min-max,因为X光片边缘常有金属伪影或曝光过度的亮斑,min-max会被这些离群值带偏。

import numpy as np import cv2 def normalize_xray(img_path): # 以16-bit无符号读取,保留原始动态范围 img = cv2.imread(img_path, cv2.IMREAD_UNCHANGED) if img.dtype != np.uint16: img = img.astype(np.uint16) # 百分位裁剪:去掉1%和99%的极端值 low, high = np.percentile(img, (1, 99)) img = np.clip(img, low, high) # 线性拉伸到0-255,方便后续送入网络 img = ((img - low) / (high - low) * 255).astype(np.uint8) return img

这段代码的关键参数是(1, 99)这对百分位。如果你的数据集里金属填充物特别多,可以改成(2, 98),把更多高亮区域裁掉。注意不要用直方图均衡化(CLAHE),它在牙齿这种大面积均匀区域会产生块状伪影,反而干扰分割。

2.2 标注类别与掩码编码方式

牙齿分割的标注体系通常分两种粒度:一种是只标牙齿区域(二分类),另一种是标出每颗牙的独立实例(多分类或实例分割)。这份数据集如果包含多类标注,常见类别映射是:0-背景,1-牙冠,2-牙根,3-牙槽骨,4-其他。你需要先确认标注掩码的像素值到底对应什么,别默认0就是背景——有些标注工具会把未标注区域设为255。

def decode_mask(mask_path, class_map): # class_map: {像素值: 类别名} mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) unique_vals = np.unique(mask) print("掩码中出现的像素值:", unique_vals) # 检查是否有未在class_map中定义的像素值 unknown = set(unique_vals) - set(class_map.keys()) if unknown: print("警告:发现未定义像素值", unknown) # 转换为0-based连续标签 decoded = np.zeros_like(mask, dtype=np.uint8) for i, (pixel_val, _) in enumerate(sorted(class_map.items())): decoded[mask == pixel_val] = i return decoded

跑完这段,先看打印出来的unique_vals。如果出现255或128这种值,说明标注里有“忽略区域”或“不确定区域”,训练时要把这些像素的loss mask掉,否则模型会学到错误的边界。

2.3 训练集/验证集划分的坑

医学影像分割最忌讳随机划分。同一患者的全景片和根尖片如果一张进训练集、一张进验证集,验证指标会虚高,因为模型其实见过这个患者的牙齿形态。正确做法是按患者ID划分,确保同一个患者的所有影像只出现在一个集合里。如果数据集没提供患者ID,至少按影像来源设备或拍摄时间做分组划分。

from sklearn.model_selection import GroupKFold # 假设df有'image_path'和'patient_id'两列 groups = df['patient_id'].values gkf = GroupKFold(n_splits=5) for train_idx, val_idx in gkf.split(df, groups=groups): train_df = df.iloc[train_idx] val_df = df.iloc[val_idx] # 确保同一患者不跨集 assert set(train_df['patient_id']) & set(val_df['patient_id']) == set()

GroupKFold的groups参数就是患者ID。如果数据集里没有患者ID,退而求其次用StratifiedKFold按影像类型分层,但要在论文或报告中说明这个局限。

3. 从掩码到模型输入:预处理流水线与数据增强

3.1 尺寸归一化与长宽比保持

X光全景片通常是宽幅的(比如2440×1280),而根尖片接近正方形。直接resize到512×512会把牙齿拉变形,尤其是牙根长度这种关键特征。我一般用letterbox方式:保持长宽比缩放,短边补零到目标尺寸。这样牙齿的几何比例不变,模型学到的形状先验才可靠。

def letterbox(img, mask, target_size=(512, 512)): h, w = img.shape[:2] scale = min(target_size[0] / h, target_size[1] / w) new_h, new_w = int(h * scale), int(w * scale) img_resized = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_LINEAR) mask_resized = cv2.resize(mask, (new_w, new_h), interpolation=cv2.INTER_NEAREST) # 创建目标画布并居中放置 canvas_img = np.zeros((target_size[0], target_size[1]), dtype=np.uint8) canvas_mask = np.zeros((target_size[0], target_size[1]), dtype=np.uint8) y_offset = (target_size[0] - new_h) // 2 x_offset = (target_size[1] - new_w) // 2 canvas_img[y_offset:y_offset+new_h, x_offset:x_offset+new_w] = img_resized canvas_mask[y_offset:y_offset+new_h, x_offset:x_offset+new_w] = mask_resized return canvas_img, canvas_mask

掩码的resize必须用INTER_NEAREST,否则会出现0.5这种像素值,类别标签就乱了。图像用INTER_LINEAR没问题,但如果你做的是超分辨率预处理,可以考虑INTER_CUBIC。

3.2 针对X光的增强策略

自然图像的增强套路(颜色抖动、HSV变换)在X光上完全无效,因为它是单通道。有效的增强是:随机旋转(±15度)、随机缩放(0.9到1.1)、弹性形变(模拟不同咬合角度)、以及灰度伽马变换(模拟不同曝光条件)。注意不要用水平翻转——牙齿的左右排列是有解剖意义的,翻转后模型会学到错误的先验。

import albumentations as A train_transform = A.Compose([ A.Rotate(limit=15, p=0.5, border_mode=cv2.BORDER_CONSTANT), A.RandomScale(scale_limit=0.1, p=0.3), A.ElasticTransform(alpha=1, sigma=50, p=0.2), A.RandomGamma(gamma_limit=(80, 120), p=0.3), A.Resize(512, 512), ]) # 验证集只做resize,不做任何随机增强 val_transform = A.Compose([A.Resize(512, 512)])

ElasticTransform的alpha和sigma控制形变强度。牙齿分割里alpha=1, sigma=50是比较温和的设置,再大就会把牙根拉断。RandomGamma的gamma_limit=(80, 120)对应0.8到1.2的伽马值,模拟曝光不足和过曝。

3.3 类别不平衡的处理

牙齿区域通常只占整张全景片的15%到25%,背景占大头。如果直接用交叉熵,模型会倾向于全预测背景。常见做法是加一个Dice Loss或者Focal Loss。我一般用Dice + BCE的组合,Dice负责拉回召回率,BCE稳定训练初期。

import torch import torch.nn as nn class DiceBCELoss(nn.Module): def __init__(self, weight_bce=0.5): super().__init__() self.weight_bce = weight_bce self.bce = nn.BCEWithLogitsLoss() def forward(self, pred, target): # pred: (B, 1, H, W) logits # target: (B, 1, H, W) 0/1 bce_loss = self.bce(pred, target) pred_sigmoid = torch.sigmoid(pred) intersection = (pred_sigmoid * target).sum() dice_loss = 1 - (2. * intersection + 1e-6) / (pred_sigmoid.sum() + target.sum() + 1e-6) return self.weight_bce * bce_loss + (1 - self.weight_bce) * dice_loss

weight_bce=0.5是起步值。如果训练初期loss震荡厉害,把BCE权重调到0.7;如果验证集Dice上不去,把Dice权重调到0.6。这个参数没有理论最优,看你的数据分布试。

4. 避坑与排查:标注噪声、灰度反转与显存爆炸

4.1 掩码像素值不连续导致类别错乱

现象:训练时loss正常下降,但推理结果把所有牙齿都预测成同一类。原因:标注掩码里类别像素值是0、1、2、3,但你按连续标签处理时跳过了某个值,导致类别索引错位。解决:在decode_mask里强制做一次np.unique检查,并用class_map显式映射,不要假设像素值就是类别索引。

4.2 灰度反转导致模型学反

现象:验证集Dice只有0.3左右,但训练集能到0.9。原因:部分X光片是“白背景黑牙齿”,部分是“黑背景白牙齿”,模型把极性当成了类别特征。解决:在预处理里统一极性——统计图像四角的平均像素值,如果大于127就做255 - img反转。这个操作要在归一化之前做。

4.3 显存爆炸:大尺寸全景片直接训练

现象:batch_size设为2还是OOM。原因:全景片原始尺寸2440×1280,即使resize到1024×512,单张float32张量也有2MB,加上梯度、优化器状态,显存很快吃满。解决:用混合精度训练(AMP),并把输入尺寸降到512×256做第一轮粗分割,再用滑窗推理在原图上做精细分割。

from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() for images, masks in dataloader: images, masks = images.cuda(), masks.cuda() with autocast(): outputs = model(images) loss = criterion(outputs, masks) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() optimizer.zero_grad()

autocast会自动把卷积和矩阵乘降到float16,显存占用能降30%到40%。注意loss计算要在autocast上下文里做,但scaler.scale和scaler.step要在外面。

4.4 标注边界模糊导致Dice虚高

现象:Dice系数0.92,但可视化一看,牙根尖部分全是毛刺。原因:标注者在牙根处也拿不准边界,画得比较随意,模型学到了这种模糊边界,Dice反而因为“大差不差”而偏高。解决:在验证时单独算牙根区域的Dice,或者用Boundary Loss辅助,强迫模型关注边界像素。

4.5 验证集指标波动大

现象:同一模型跑三次验证,Dice从0.85跳到0.91。原因:验证集太小,且包含了几张特别难或特别简单的片子。解决:用5折交叉验证的均值作为最终指标,并报告标准差。如果标准差超过0.03,说明数据分布有问题,需要检查是否有设备型号或拍摄参数导致的域偏移。

5. 推理与后处理:把分割掩码变成可用的牙齿轮廓

5.1 滑窗推理与重叠融合

全景片直接resize到512×512会丢失牙根细节,我一般用滑窗:把原图切成512×512的patch,步长256,每个patch单独推理,然后按高斯权重融合重叠区域。这样既保留了分辨率,又不会显存爆炸。

def sliding_window_inference(model, image, window_size=512, stride=256): h, w = image.shape output = np.zeros((h, w), dtype=np.float32) weight_map = np.zeros((h, w), dtype=np.float32) # 生成高斯权重核 gaussian = cv2.getGaussianKernel(window_size, window_size/6) gaussian_2d = gaussian @ gaussian.T for y in range(0, h - window_size + 1, stride): for x in range(0, w - window_size + 1, stride): patch = image[y:y+window_size, x:x+window_size] patch_tensor = torch.from_numpy(patch).unsqueeze(0).unsqueeze(0).float().cuda() with torch.no_grad(): pred = torch.sigmoid(model(patch_tensor)).cpu().numpy()[0, 0] output[y:y+window_size, x:x+window_size] += pred * gaussian_2d weight_map[y:y+window_size, x:x+window_size] += gaussian_2d # 处理边缘未覆盖区域 output = output / (weight_map + 1e-6) return output

window_size=512和stride=256意味着50%重叠。高斯核的sigma设为window_size/6,这样中心权重高、边缘权重低,融合后不会出现拼接缝。如果原图尺寸不能被stride整除,最后一行和最后一列要单独处理,或者直接padding到整除。

5.2 连通域过滤与牙齿计数

分割出来的二值掩码往往有零星的小噪点,或者相邻牙齿粘连在一起。后处理第一步是用连通域分析去掉面积小于阈值的区域,第二步是用分水岭或距离变换把粘连的牙齿分开。

from scipy import ndimage def postprocess_mask(binary_mask, min_area=500): # 去掉小噪点 labeled, num = ndimage.label(binary_mask) for i in range(1, num + 1): if (labeled == i).sum() < min_area: binary_mask[labeled == i] = 0 # 用距离变换分离粘连牙齿 dist = cv2.distanceTransform(binary_mask, cv2.DIST_L2, 5) _, sure_fg = cv2.threshold(dist, 0.5 * dist.max(), 255, 0) sure_fg = sure_fg.astype(np.uint8) # 分水岭需要3通道输入,这里简化处理 return binary_mask, sure_fg

min_area=500是针对512×512 patch的经验值。如果你在全景原图上做后处理,这个阈值要按比例放大。距离变换的0.5 * dist.max()是分离粘连区域的种子点阈值,如果牙齿粘连严重,可以降到0.3。

5.3 从掩码到牙齿编号

如果你需要每颗牙的独立编号(比如FDI牙位标记),后处理还要加一步:对分离后的每个连通域,按质心的x坐标从左到右排序,再结合上下颌的y坐标分配象限。这一步没有通用代码,因为不同拍摄角度的牙位排列不一样,需要根据你的数据集标注体系来定。

后处理步骤关键参数作用
连通域过滤min_area=500去掉噪点
距离变换阈值0.5*max分离粘连
质心排序按x坐标左右牙位分配
形态学闭运算kernel=3×3填补小孔洞

表格里的参数是起步值,实际调参要看你的分割结果。闭运算的kernel不要超过5×5,否则会把牙缝也填上。

6. 把Dice从0.85推到0.92:一个被忽略的预处理技巧

大部分人在牙齿分割上卡在Dice 0.85左右,然后开始换模型——从U-Net换到DeepLabV3+,再换到Swin-UNet,指标涨了0.01,参数量翻了三倍。我踩过这个坑之后发现,真正有效的提升往往不在模型结构,而在预处理的一个小细节:局部对比度归一化。

X光片的灰度分布不是全局均匀的,牙冠区域亮、牙根区域暗、牙槽骨区域更暗。全局归一化之后,牙根和牙槽骨的边界几乎消失。我一般会在letterbox之前,对图像做一次基于局部窗口的对比度拉伸。具体做法是:用一个大核(比如51×51)的高斯模糊估计背景光照,然后用原图减去背景,再除以背景的标准差。这个操作在OpenCV里没有现成函数,但用cv2.GaussianBlur和矩阵运算就能实现。

def local_contrast_normalize(img, kernel_size=51): # img: uint8灰度图 img_float = img.astype(np.float32) # 估计背景光照 background = cv2.GaussianBlur(img_float, (kernel_size, kernel_size), 0) # 减去背景并归一化 normalized = (img_float - background) / (background + 1e-6) # 重新映射到0-255 normalized = cv2.normalize(normalized, None, 0, 255, cv2.NORM_MINMAX) return normalized.astype(np.uint8)

kernel_size=51是针对512×512输入的经验值。如果你在全景原图上做,要按比例放大到201或251。这个操作会让牙根区域的对比度显著提升,我实测在同一个U-Net上,Dice从0.86涨到0.91,而且训练收敛速度加快了大约20%。

另一个容易被忽略的点是掩码边界的腐蚀。标注者在画牙齿边界时,往往会稍微画大一点,把牙周膜区域也包进去。如果你直接用这个掩码训练,模型学到的边界是“膨胀”的。我一般会把训练用的掩码做1到2个像素的腐蚀,让模型学更紧的边界,推理时再膨胀回来。这个trick在牙根分割上特别有效,因为牙根和牙槽骨的过渡区本来就模糊,腐蚀之后模型被迫关注更确定的区域。

def erode_mask(mask, kernel_size=3, iterations=1): kernel = np.ones((kernel_size, kernel_size), np.uint8) eroded = cv2.erode(mask, kernel, iterations=iterations) return eroded

kernel_size=3和iterations=1是保守设置,腐蚀太多会让小牙齿的掩码消失。如果你做的是多类分割,腐蚀要在每个类别上单独做,不能对整张掩码一起腐蚀,否则类别边界会混在一起。

验证这个技巧是否有效,不要只看整体Dice。我一般会单独算三个区域的Dice:牙冠、牙根、牙槽骨。如果牙冠Dice没变但牙根Dice涨了0.05,说明局部对比度归一化起作用了。如果三个区域都没变,那可能是你的数据本身对比度就很好,不需要这一步。

从那以后我每次拿到新的X光分割数据集,都强制走一遍“局部对比度归一化 + 掩码腐蚀”的预处理,再开始调模型。这个习惯帮我省下了大量换模型的时间。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询