拓冰建站拓冰建站
首页 / 资讯中心 / 正文

真实场景下牙齿蛀牙分割数据集:从多类别标注到模型避坑指南

简介面向口腔医学影像AI研究与龋病辅助诊断场景这套专业牙齿蛀牙分割数据集涵盖400张高精度口腔内窥镜及X光影像覆盖咬合面、邻面等典型龋坏部位与多严重度形态。标注由牙科医生像素级完成掩膜图按6类精细目标划分可支撑分割模型对釉质浅龋、牙本质中龋、深龋近髓等类别的准确识别。全量共843个文件以420张JPG原图、421张PNG标注图为主体另附Python分析脚本与类别说明文本压缩包约8.56MB。配套脚本能一键生成各类蛀牙面积统计、样本对比及颜色特征图表便于掌握数据分布与优化训练策略。数据已统一为512×512并划分训练/验证集可直接用于U-Net、DeepLab等网络。迄今已有29人学习下载适合高校科研、算法开发及教学演示等场景为早期蛀牙筛查与治疗规划提供定量化参考。1. 真实场景下的牙齿蛀牙分割这份数据集要解决什么问题牙片上找一个针尖大的邻面龋比在朋友圈九宫格里找自拍还费眼神。医生对着咬翼片圈半天蛀牙区域主观性强、耗时长换个人标注就不一样。想做AI辅助分割第一道坎不是模型是数据——公开的牙齿分割数据集本来就少真实临床场景里还混着填充物、牙冠、金属伪影合成图练出来的模型一上真实牙片就翻车。这份专业牙齿影像蛀牙分割数据集主打多类别蛀牙区域的精细分割按临床分级标注龋坏深度既能用语义分割逐像素分类也能用实例分割把病灶单独框出来。适合医学影像AI、口腔数字化诊断、辅助报告系统的工程师和研究者。一句话它解决的不是能不能分割的问题而是真实场景下分割还能不能稳住的问题。2. 数据集拆解类别定义、标注格式与数据组织方式2.1 多类别怎么划分从临床分型到标注ID拿到一份多类别分割数据集第一件事不是急着跑模型而是把它的类别字典打开看。蛀牙在临床上是有深度分级的浅龋局限在牙釉质中龋侵入牙本质浅层深龋接近牙髓腔这三档直接对应完全不同的处理方式。浅龋可能只需再矿化或简单充填中龋要做窝洞制备深龋要评估是否根管治疗。如果标注把所有蛀牙统一成一个类别模型只能回答哪里有蛀牙回答不了蛀到哪一层临床决策价值直接少一大半。这类数据集常见的标注方案是标注掩码mask里每个像素值代表一个类别0 是背景或健康牙体组织1、2、3 分别对应由浅到深的龋坏分级。有的还会把发生部位作为维度比如邻面龋、窝沟龋、根面龋、继发龋。部位维度适合做筛查分诊深度维度适合做治疗决策两者不冲突但标注成本会翻倍。我拿到这种数据会先打开三五张 mask用 numpy 打印一下像素值的唯一集合确认类别 ID 是不是连续编号再继续往下走。注意如果 mask 是灰度 PNG确认像素值是否从 0 连续编号。有的数据把类别存在调色板里直接当灰度图读会得到一串看似随机的 0~255 数值训练时模型根本不知道自己在分几类。2.2 目录结构与标注格式一份能直接喂给训练脚本的数据长什么样医学分割数据集的目录组织我经手过的大致两类原图目录加 mask 目录文件名一一对应或者带一个 COCO/VIA 格式的 JSON 标注文件。按标题强调真实场景推断这份更接近第一种对新手最友好没有任何中间解析成本。典型的结构长这样dataset/ ├── images/ │ ├── 001.jpg │ ├── 002.bmp │ └── ... ├── masks/ │ ├── 001.png │ ├── 002.png │ └── ... ├── train.txt ├── val.txt └── test.txtimages 里是原始牙片可能从 DICOM 导出成 JPG/BMPmasks 里是同名 PNG宽高和原图一致像素值就是类别 ID。train.txt、val.txt、test.txt 是划分好的文件列表每行一个不带扩展名的编号训练脚本按列表加载。如果你自己制作数据集建议一上来就把划分写进文件别等训练时再临时切——临时切很容易把同一患者的多张片子同时分进训练集和验证集这个坑后面专门讲。如果拿到的不是 PNG mask而是 COCO 或 VIA 的 JSON就需要先转换用 skimage.draw.polygon 或 OpenCV 的 fillPoly 把多边形坐标回填成 mask再存成 PNG。转换脚本别删新增数据时还要复用并且要保证转换时坐标缩放关系不变resize 统一放到训练管线里做不在转换阶段就压图。很多人在这里省事后面推理输出的 mask 和原图对不上返工成本更高。2.3 先跑统计再动手类别分布与实例数脚本很多人拿到数据直接开训训练到一半才发现某个小类别完全没学到。我的习惯是拿数据当天就先跑一遍分布统计用几个数字决定后续所有配置每类像素占比、每类连通域数量近似实例数、图像分辨率分布。这段脚本虽然不起眼但能省掉大量无效训练时间。# analyze_dataset.py from pathlib import Path from collections import defaultdict import numpy as np from PIL import Image from scipy import ndimage MASK_DIR Path(masks) CLASS_NAMES {0: background, 1: enamel, 2: dentin, 3: deep} pixel_count defaultdict(int) instance_count defaultdict(int) for mask_path in sorted(MASK_DIR.glob(*.png)): mask np.array(Image.open(mask_path)).astype(np.uint8) for cls_id, name in CLASS_NAMES.items(): pixel_count[name] int((mask cls_id).sum()) binary (mask cls_id).astype(np.uint8) labeled, num ndimage.label(binary) instance_count[name] num total_pixels sum(pixel_count.values()) print( 像素占比 ) for name in CLASS_NAMES.values(): ratio pixel_count[name] / total_pixels print(f{name:12s} {pixel_count[name]:10d} {ratio:.4%}) print(\n 连通域实例数 ) for name in CLASS_NAMES.values(): print(f{name:12s} {instance_count[name]:6d})这段脚本遍历 masks 目录逐张统计每个类别的像素总数再用 scipy.ndimage.label 对每个类别的二值图做连通域分析连通域数量近似等于病灶实例数。像素占比决定损失函数要不要加权实例数决定对小类别要不要过采样。比如 enamel 像素占比只有 2% 但连通域有 500 个说明它是小而多的类型重点要放在保住小目标上如果某个类别像素占比和实例数都极低大概率是标注漏标了要回去查数据而不是死磕模型。分辨率分布也要统计。如果 images 里混着 800×600 和 2000×1500 两种尺寸统一 resize 到 512 会让大图里的小龋齿直接消失统一到 1024 又会让小图被放大变模糊。常见做法是训练前把每张图的 mask 按原图比例同步调整同时记录原始尺寸后处理阶段用来把预测结果映射回原图坐标。这个映射关系建议写进 JSON 清单一劳永逸。3. 从数据集到分割模型划分、增强与训练配置3.1 数据划分按患者分组别让验证集抄答案分割任务最常见的低级错误是随机划分文件。牙齿影像数据集有个特殊性同一个患者的牙片往往有多张正面、侧面、咬翼片它们共享同一套牙齿形态和光照条件。如果这些相近图像同时落在训练集和验证集验证指标会虚高到不真实模型等于在开卷考试。划分的唯一正确姿势是按患者或按就诊会话分组。# split_by_patient.py import json import random from pathlib import Path # 假设 patient_of.json 记录每个图像编号属于哪个患者 # 格式: {001: P001, 002: P001, 003: P002, ...} with open(patient_of.json, r, encodingutf-8) as f: patient_of json.load(f) images sorted(Path(images).glob(*.*)) random.seed(42) patient_to_images {} for img_path in images: key img_path.stem pid patient_of.get(key, key) # 没有映射时按文件名独立处理 patient_to_images.setdefault(pid, []).append(key) patients sorted(patient_to_images.keys()) random.shuffle(patients) n_train int(len(patients) * 0.7) n_val int(len(patients) * 0.15) train_keys [k for p in patients[:n_train] for k in patient_to_images[p]] val_keys [k for p in patients[n_train:n_train n_val] for k in patient_to_images[p]] test_keys [k for p in patients[n_train n_val:] for k in patient_to_images[p]] with open(train.txt, w) as f: f.write(\n.join(train_keys)) # val.txt / test.txt 同理这段脚本先把图像按患者 ID 分组再对患者列表乱序切分最后把每个患者名下的图像 key 摊回对应集合。三个点值得注意random.seed(42) 固定随机种子保证任何人复现同一划分如果数据没有患者 ID 映射退化成按文件名分组也总比完全随机好比例先粗定 70/15/15后续如果某个小类别样本数太少按类别均衡采样调整而不是动划分逻辑。划分完建议顺手生成一个每个集合里各类别实例数的对照报告。做法是把第 2 章的统计脚本稍作改动传入 train/val/test 列表分别统计。如果发现某个类别在验证集里一个实例都没有这个类别等于没被评估需要把该类别样本人工补进验证集。这一步做一次省得后面反复怀疑模型。3.2 数据增强牙齿影像的增强边界在哪里医学影像增强和自然图像增强是两回事。自然图像里水平翻转是标配牙齿影像里翻转要谨慎左右牙位在临床上有明确方位含义X 光片虽然左右对称但镜面翻转会让模型学到错误的位置先验尤其是多类别分割中左上磨牙这种隐含位置信息。上下翻转更不建议牙冠在上、牙根在下是固定解剖方向翻转后模型等于在看一张临床不存在的片子。我常用的增强组合是这样# augmentation.py import albumentations as A train_transform A.Compose([ A.Rotate(limit15, border_mode0, value0), A.RandomBrightnessContrast(brightness_limit0.15, contrast_limit0.15, p0.5), A.ElasticTransform(alpha1.0, sigma10, p0.3), A.HorizontalFlip(p0.0), # 关闭方位敏感 A.Resize(height1024, width1024), ]) val_transform A.Compose([ A.Resize(height1024, width1024), ])参数含义Rotate(limit15) 在正负 15 度内随机旋转border_mode0 配合 value0 在旋转空出的区域填黑色像素避免白色边框被模型当成牙齿组织RandomBrightnessContrast 模拟不同曝光条件口腔 X 光片因设备参数差异亮度波动很大ElasticTransform 用 alpha1.0、sigma10 的轻微弹性形变模拟软组织压迫和拍摄角度变化HorizontalFlip 概率直接置 0明确告诉后来的人这个数据不能镜像。albumentations 在传 mask 参数时会自动同步变换不需要手动对齐。一个普遍翻车的细节Resize 之前确保原图和 mask 使用相同插值策略。原图用线性插值没问题mask 必须用最近邻nearest否则类别边界会被插出一些不存在的中间像素值训练时这些值不属于任何类别轻则 loss 抖动重则模型学到错误边界。albumentations 对 mask 默认就是 nearest但如果你自己写 DataLoaderResize 时一定把 mask 的插值显式设为 nearest。3.3 训练配置与损失函数选哪条路线拿到这类数据集面前通常有三条路线U-Net 系从零训练或换 encoder、DeepLabV3 这类语义分割网络、以及 YOLOv8-seg 这类实例分割方案。如果只做蛀牙区域分类定位前两种足够如果希望每个蛀牙灶单独输出一个对象边界、面积、编号方便写进报告系统实例分割更合适。数据集的 mask 是像素级的三条路线都能吃区别在输出层设计。损失函数我建议直接用 Dice Loss 和交叉熵的组合# loss.py import torch import torch.nn.functional as F def dice_loss(pred_probs, mask, num_classes, smooth1.0): pred_probs: [B, C, H, W] softmax 后的概率 mask: [B, H, W] 类别索引 loss 0.0 for c in range(num_classes): pred pred_probs[:, c] target (mask c).float() intersection (pred * target).sum() dice (2.0 * intersection smooth) / (pred.sum() target.sum() smooth) loss 1.0 - dice return loss / num_classes def combined_loss(pred_logits, mask, num_classes): ce F.cross_entropy(pred_logits, mask, ignore_index255) probs F.softmax(pred_logits, dim1) dice dice_loss(probs, mask, num_classes) return ce dice代码逻辑dice_loss 对每个类别分别算 Dice 系数取反向最后对所有类别平均combined_loss 把交叉熵和 Dice 相加。为什么组合而不是只用 Dice纯 Dice 在小目标上梯度不稳定训练初期容易震荡纯交叉熵被背景像素主导小类别学不动。两者相加交叉熵负责稳定收敛Dice 负责拉高小类别召回。smooth1.0 是平滑项防止某张图某个类别完全缺失时除以零。ignore_index255 很关键如果你在 mask 边缘标了 255 作为不确定带交叉熵自动跳过这些像素Dice 计算时通过 maskc 也让它们不参与。输入尺寸没有标准答案牙齿这种小结构目标 1024 起步低于 512 基本没法看。显存不够就改成 768 或 640优先保证邻面龋这种最小目标至少占据 8~10 个像素否则后处理的连通域分析也救不回来。学习率按 batch size 线性缩放batch8、输入 1024、单卡 3090 级别用 AdamW lr1e-4 比较稳U-Net 从预训练 encoder 初始化可以再降到 3e-5 走一小段热身。训练时每 5 个 epoch 存一次 checkpoint同时记录 val 集每个类别的 Dice。只看平均 Dice 会掩盖小类别崩掉的问题enamel 的 Dice 掉到 0.2 而 deep 是 0.85说明整份数据里浅龋几乎没被学到需要在增强和损失权重上返工而不是盲目加 epochs。4. 避坑指南训练蛀牙分割模型最常见的五个翻车现场4.1 现象浅龋和邻面龋在预测图里完全消失浅龋、邻面龋在牙片上常常只有几个像素宽。训练完跑推理原图上肉眼可见的邻面龋位置预测 mask 里是空的整张图只剩下中龋和深龋的大块区域。原因基本是输入分辨率不够图像 resize 到 512 甚至 256 时小病灶降采样后只剩一两个像素分类器根本没机会学到稳定特征。解决思路输入尺寸提到 1024训练时用随机裁剪而不是全局 resize让每个 batch 都包含部分放大后的局部区域推理时做多尺度预测把 0.75 倍、1.0 倍、1.25 倍三档结果平均小目标召回率提升明显。我一般会训练前统计最小病灶的标注像素宽度把 Resize 的最小边长设成略大于该宽度保证网络看得见。4.2 现象loss 在下降但每个类别的 Dice 都在 0.1 以下训练曲线很漂亮loss 一路走低验证集一算每个类别 Dice 全都惨不忍睹。这种诡异现象十有八九是 mask 读取错误——灰度 PNG 被当成三通道读或者像素值被人为归一化过。我踩过一次有人把 mask 里 0、1、2 当作灰度范围做了归一化训练时 mask 里全是 0.003、0.007 这样的浮点值模型被背景完全绑架。解决训练脚本最前面写一个 assert检查 mask 的像素值集合是否等于预期类别 ID 列表不等直接报错。这个逻辑很简单但能挡掉一大类数据问题。4.3 现象验证集 Dice 高达 0.9一上真实新数据掉到 0.4验证集和训练集之间发生了数据泄漏。最常见的泄漏是同一患者的连续切面图像被随机划分到两边模型在验证集上等于见过同一个牙的不同角度照片指标虚高。解决严格按患者分组划分保证同一个患者的任何图像只在训练集或验证集其中一个出现。如果数据没有患者 ID至少按拍摄会话时间戳聚类。另一个隐蔽泄漏源是增强没关验证时如果旋转、弹性形变仍生效等于变相把验证集扩样指标一样虚高。验证集只用中心裁剪或直接 Resize任何随机因素都不要带进去。4.4 现象开了水平翻转所有病灶都被预测到同一侧给牙齿影像开 HorizontalFlip训练完测试发现分割结果整体偏向牙弓一侧邻面龋全被贴到右边缘。原因是左右牙位有明确临床语义——左上的磨牙和右上的磨牙形态近似但位置不一样盲目镜像让模型学到病灶总在图像右侧的假相关性而不是病灶本身的纹理特征。另外 X 光片的投照方向也会因左右而异翻转后阴影明暗关系颠倒进一步误导模型。解决直接关掉水平翻转只保留小角度旋转和弹性形变。确实需要更多样本时用 5 度以内的旋转比翻转更符合牙片的拍摄变化范围。4.5 现象mask 边缘锯齿状模型预测的边界反而比标注还整齐不同医生标注同一张牙片边界位置会有 1~2 个像素的差异这是标注主观性不是模型缺陷。但训练时如果 mask 带着大量孤立噪点像素标注时鼠标手滑模型会花大量容量去拟合这些噪声边界学得过于干净反而失真。解决训练前对 mask 做一次形态学处理闭运算填补小孔洞开运算去掉孤立点更稳妥的做法是在 mask 边缘做一个 2 像素宽的忽略带把这些像素值设成 255配合 ignore_index255 让训练损失绕开不确定区域。评估时用原始 mask只在训练时使用处理后的版本。5. 从分割结果到临床可用输出后处理、量化与格式转换5.1 连通域分析与形态学清理模型输出的概率图经过 argmax 得到类别 mask直接拿它去对接临床是不可用的。原因有二单像素噪声点多同一个蛀牙灶可能存在多个不相连的碎块。常规做法是先用形态学清理再做连通域分析去掉面积小于阈值的孤立区域。import numpy as np from scipy import ndimage def clean_mask(mask, min_area50): mask: [H, W] 类别索引 min_area: 小于该像素数的连通域被移除 返回清理后的 mask cleaned np.zeros_like(mask) for cls_id in range(1, mask.max() 1): binary (mask cls_id).astype(np.uint8) labeled, num ndimage.label(binary) for i in range(1, num 1): region (labeled i) if region.sum() min_area: cleaned[region] cls_id return cleaned # 推理阶段调用 logits model(img) # [B, C, H, W] pred logits.argmax(dim1)[0] # 单张图 [H, W] pred pred.cpu().numpy() pred clean_mask(pred, min_area50)这里注意一个顺序先按类别连通域过滤再合并。如果先合并再过滤会把相邻类别的杂点也吞进去边界被污染。min_area50 是经验值对应 1024 尺度下约 50 个像素换算实际面积要结合物理分辨率稍后再说。连通域过滤还有一个隐藏收益临床报告需要枚举每个病灶连通域分析把每个病灶的编号、面积、位置一次性拿到直接支撑下游格式化输出。5.2 从像素到毫米蛀牙面积的量化边界牙科影像想量化蛀牙面积最好有 DICOM 原始文件里的 PixelSpacing 标签它给出每个像素对应的物理尺寸。如果数据集提供的是导出的 JPG这个信息基本丢了只能做相对量化比如某类蛀牙占整牙面积的比例不能声称绝对面积。常见做法是逐张图记录像素间距找不到就在报告里标注相对面积避免医学合规风险。def area_in_mm2(mask, cls_id, pixel_spacing_mm0.1): pixels int((mask cls_id).sum()) return pixels * (pixel_spacing_mm ** 2)这个函数简单但它背后有个容易被忽略的点pixel_spacing_mm 对每一张图可能都不一样。全景片和根尖片分辨率不同同一张根尖片拍摄距离不同也会导致像素间距差异。正确做法是按图逐个读取 DICOM 头的 PixelSpacing或者至少按图像来源分组设置而不是全数据集用一个固定值。写代码时把 pixel_spacing 做成字典key 是图像编号value 是各自的值比全局常量严谨得多。5.3 mask 转多边形 JSON交付给下游系统的格式训练和评估都在 mask 维度上进行临床系统前端需要的是矢量轮廓方便在牙片上叠加显示和标注测量。用 OpenCV 的 findContours 把每个类别的连通域转成多边形点集导出 JSON。保持坐标与原图一致——如果推理时 Resize 过轮廓点要先乘回缩放比例否则画到原图上整体偏移。import json import cv2 def mask_to_polygons(mask, class_names, spacing_mm0.1): results [] for cls_id, name in class_names.items(): if cls_id 0: continue binary (mask cls_id).astype(np.uint8) contours, _ cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) for contour in contours: if len(contour) 4: continue points contour.reshape(-1, 2).tolist() area_px cv2.contourArea(contour) results.append({ class: name, points: points, area_px: area_px, area_mm2: area_px * (spacing_mm ** 2), }) return results # 假设 pred_mask 已映射回原图尺寸 annotations mask_to_polygons(pred_mask, {1: enamel, 2: dentin, 3: deep}) with open(result.json, w, encodingutf-8) as f: json.dump(annotations, f, ensure_asciiFalse)cv2.findContours 的轮廓点坐标和 mask 行、列方向对应做 JSON 序列化时不要调换 x/y前端绘图库的坐标约定要先对齐这是跨系统交付最常见的字段错位问题。RETR_EXTERNAL 只取最外层轮廓适合覆盖单个蛀牙区如果同一个病灶内部有不同深度等级外层中龋、内层深龋就要用 RETR_CCOMP 把内外轮廓一起导出让前端做图层叠加。轮廓点还可以用道格拉斯-普克算法做简化去掉冗余顶点减少前端渲染压力但简化容差不要超过 1 个像素否则边界精度会肉眼可见地变差。6. 端到端验证脚本把整个流程固化成一键检查拿到新数据集我的习惯是先完整跑一遍端到端验证不急着调参。脚本做的事加载训练好的模型遍历验证集逐张输出每个类别的 Dice、像素精度最后汇总成表顺带把预测 mask 和标注 mask 叠图存下来看一眼边界对齐情况。价值在于只要数据或标注有任何变化跑一遍就能第一时间发现是数据问题还是模型问题。python evaluate.py --ckpt best.pt --data val.txt --out eval_report/evaluate.py 的核心逻辑不复杂但有一个细节值得执行每个类别分别算 Dice不打印平均值。平均 Dice 0.8 完全可能掩盖浅龋 0.1、深龋 0.9 的极端失衡只有按类别拆开才能保证小病灶被持续关注。def evaluate_class_dice(pred, target, num_classes): dice_scores {} for c in range(1, num_classes 1): p (pred c) t (target c) inter (p t).sum() dice_scores[c] (2 * inter) / (p.sum() t.sum() 1e-8) return dice_scores这是一个最小可运行的检查工具。对我说每次拿到新的牙齿分割数据集都强制自己先跑统计脚本、再划分、再训练、再评估一步不跳。印象最深的一次翻车是在一批根尖片测试集上平均 Dice 0.76 看着不错按类别拆开才发现邻面龋 Dice 只有 0.19——那批图恰好全部来自同一个患者如果只看平均值整个结果就错了。从那以后我每次评估都强制输出逐类别报告和叠加图宁可多花十分钟不在指标上吃哑巴亏。这个习惯建议你也保留希望帮到你。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门