简介:这份资源是面向医学图像处理与计算机视觉方向的牙齿分割及牙齿病变检测数据集,适合从事语义分割、实例分割研究的学生、算法工程师及科研人员使用。数据集共包含2616张jpg图片及对应的labelme格式json标注文件,压缩包内为1个docx文档,大小约2.22MB,文档中整理了数据集的完整说明与下载指引。标注涵盖Tooth、Caries、Cavity、Crack、calculus、inflamation共6个类别,各类别标注数量分别为1709、2913、1099、139、1207和620,使用labelme 5.5.0以多边形框方式完成标注。需要说明的是,数据集主要服务于病变检测,部分牙齿未标注并不影响病变识别;用户可自行将json转换为mask、YOLO或COCO格式以适配不同分割任务。目前已有121人学习关注,适合作为牙齿病变检测模型训练与验证的数据基础。
1. 牙齿分割数据集到底能拿来做什么:从2616张labelme标注说起
拿到一份标注好的牙齿分割数据集,第一反应往往不是“太好了”,而是“这6个类别到底怎么分、labelme的json怎么转、2616张够不够训”。我最初接触牙齿影像分割时也踩过这个坑:以为把json丢给脚本转成mask就能直接开训,结果类别映射错位、边缘像素被吞、验证集里出现训练集同源图像,模型在测试集上Dice看着还行,一上真实口内扫描就崩。这份标题里的数据集——2616张、6类别、labelme格式——本质上是一个面向牙齿及牙齿病变区域的分割任务标注集合,labelme的json里存的是多边形点集和标签名,不是现成的二值mask。它能解决的核心问题是:让你在没有原始DICOM、没有医院合作渠道的情况下,快速搭起一个牙齿区域分割或病变区域分割的训练流水线,用来验证模型结构、损失函数、后处理策略是否走得通。适合谁?适合做口腔影像AI的算法工程师、做牙科CAD/CAM软件的功能开发者、以及拿牙齿分割当分割任务练手的研究生。但别把它当成临床级数据,2616张的体量在医学分割里属于小样本,6类别之间的边界定义是否一致、病变区域标注是否由同一标注者完成,这些都会直接影响你最终模型的泛化上限。
2. labelme格式的牙齿标注怎么转成训练可用的mask
2.1 先搞清楚json里到底存了什么
labelme标注的牙齿分割数据,每个json文件对应一张原图,内部结构通常是shapes数组,每个元素包含label、points、shape_type。牙齿分割场景下,label就是那6个类别名,points是多边形顶点坐标,shape_type一般是polygon,少数情况会出现rectangle或circle。这里第一个坑:不同标注者可能把同一类写成“龋齿”“龋坏”“caries”三种写法,转换前必须先把所有json的label字段拉出来做一次频次统计,确认6个类别的真实字符串是什么。我一般会先跑一段统计脚本,不急着转mask。
import json, os from collections import Counter label_counter = Counter() json_dir = "./annotations" for fname in os.listdir(json_dir): if not fname.endswith(".json"): continue with open(os.path.join(json_dir, fname), "r", encoding="utf-8") as f: data = json.load(f) for shape in data.get("shapes", []): label_counter[shape["label"]] += 1 for label, count in label_counter.most_common(): print(f"{label}: {count}")这段代码的逻辑很直接:遍历所有json,把每个shape的label取出来计数。参数上唯一要注意的是encoding="utf-8",labelme导出的中文标签在某些系统上默认编码会出问题。跑完之后你会得到一张类别频次表,如果发现超过6个label,说明有拼写变体或者误标,需要先做一次归一化映射,否则后面转mask时会出现类别索引越界或者某类样本极少的情况。
2.2 多边形转mask的两种写法与边界处理
labelme官方提供了labelme_json_to_dataset命令行工具,但它一次只能处理一个json,而且生成的mask是彩色可视化图,不是训练用的单通道索引图。批量转换我一般用pycocotools的frPyObjects或者直接上opencv的fillPoly。下面这个脚本是实际项目中反复用过的版本,支持6类别索引mask输出。
import json, os import numpy as np import cv2 # 类别名到索引的映射,必须和统计结果一致 CLASS_MAP = { "tooth": 1, "caries": 2, "periapical_lesion": 3, "impacted_tooth": 4, "cyst": 5, "other_lesion": 6 } def json_to_mask(json_path, output_dir, image_shape=None): with open(json_path, "r", encoding="utf-8") as f: data = json.load(f) if image_shape is None: # labelme json里通常有imageHeight和imageWidth h = data.get("imageHeight") w = data.get("imageWidth") if h is None or w is None: raise ValueError("json中缺少图像尺寸,需要手动传入image_shape") else: h, w = image_shape mask = np.zeros((h, w), dtype=np.uint8) for shape in data.get("shapes", []): label = shape["label"] if label not in CLASS_MAP: continue points = np.array(shape["points"], dtype=np.int32) # fillPoly要求点集形状为(N,1,2) cv2.fillPoly(mask, [points], color=CLASS_MAP[label]) base = os.path.splitext(os.path.basename(json_path))[0] cv2.imwrite(os.path.join(output_dir, base + ".png"), mask) return mask逻辑说明:fillPoly按多边形填充,后填的类别会覆盖先填的类别,所以如果6个类别在空间上有重叠(比如病变区域在牙齿区域内部),你需要决定优先级——通常做法是先填大区域再填小区域,或者把病变单独作为一层输出。参数上dtype=np.uint8支持最多255类,6类完全够用。image_shape优先从json自带的imageHeight和imageWidth取,如果标注时用的是裁剪后的图,这两个字段可能和原图不一致,这时候必须手动传入原图尺寸,否则mask和图像对不齐,训练时loss会莫名其妙震荡。
2.3 转换后必须做的三项校验
转完mask不要直接开训,先做三项校验。第一,随机抽20张mask和原图叠加可视化,看边界是否贴合牙齿轮廓,labelme的多边形在牙根尖区域容易少标一两个像素,导致mask比真实牙齿小一圈。第二,统计每个类别的像素占比,如果某一类占比低于0.1%,说明该类在2616张里可能只有几十个有效样本,训练时需要过采样或者调class weight。第三,检查是否有全黑mask,全黑说明该json的label全部不在CLASS_MAP里,或者points格式异常。这三项校验跑完,你才能确认这份labelme格式的牙齿分割数据集真正可用。
3. 6类别牙齿分割的训练配置与损失函数选择
3.1 类别不平衡在牙齿病变分割里有多严重
牙齿分割数据集的6个类别,像素分布通常极度不均。正常牙齿区域可能占整图30%以上,而囊肿、根尖病变这类类别可能只占0.5%到2%。如果直接用交叉熵,模型会倾向于把所有像素预测成背景和正常牙齿,病变区域全漏。我一般会先算一下每个类别的像素频率,然后按频率倒数开方作为class weight,或者直接用Dice Loss加Focal Loss的组合。对于牙齿这种细长结构,Dice Loss对边界更敏感,但训练初期梯度不稳定,常见做法是前10个epoch用交叉熵预热,之后切到0.5*Dice + 0.5*Focal。
import torch import torch.nn as nn import torch.nn.functional as F class DiceFocalLoss(nn.Module): def __init__(self, num_classes=7, alpha=0.5, gamma=2.0, smooth=1e-6): super().__init__() self.num_classes = num_classes self.alpha = alpha self.gamma = gamma self.smooth = smooth def forward(self, logits, targets): # logits: (B, C, H, W), targets: (B, H, W) 取值0~C-1 probs = F.softmax(logits, dim=1) targets_onehot = F.one_hot(targets, self.num_classes).permute(0, 3, 1, 2).float() # Dice部分 intersection = (probs * targets_onehot).sum(dim=(2, 3)) union = probs.sum(dim=(2, 3)) + targets_onehot.sum(dim=(2, 3)) dice = (2 * intersection + self.smooth) / (union + self.smooth) dice_loss = 1 - dice.mean() # Focal部分 ce = F.cross_entropy(logits, targets, reduction='none') pt = torch.exp(-ce) focal = ((1 - pt) ** self.gamma * ce).mean() return self.alpha * dice_loss + (1 - self.alpha) * focal参数说明:num_classes=7是因为背景算一类,6个牙齿类别加背景共7类。alpha=0.5控制Dice和Focal的权重,如果病变类别漏检严重,可以把alpha调到0.7,让Dice主导。gamma=2.0是Focal Loss的标准值,对难分样本加权。smooth=1e-6防止除零。这个损失函数在2616张的体量上,配合AdamW和余弦退火,一般80到120个epoch能收敛。
3.2 输入分辨率与patch采样策略
牙齿分割的输入分辨率不能一刀切。全景片和根尖片的长宽比差异大,直接resize到512×512会把牙齿拉变形。我一般会保持长边缩放到512,短边padding到512,同时记录padding区域,在计算loss时把padding区域的权重设为0。如果显存不够,就用随机patch采样:从原图随机裁512×512,但保证patch内至少包含一个病变类别的像素,否则负样本太多。2616张图如果按8:1:1划分训练验证测试,训练集约2092张,每张随机裁4个patch,一个epoch就是8368个样本,batch size 8的话大约1046个iteration,这个量级在单卡24G上跑得动。
3.3 数据增强里哪些操作会破坏牙齿结构
牙齿分割的数据增强有禁区。水平翻转可以用,但垂直翻转要谨慎,因为上下颌的解剖结构不对称,垂直翻转后的图像在现实中不存在。随机旋转±15度以内安全,超过30度牙弓形态会失真。弹性形变可以加,但形变幅度要小,否则牙齿边界被扭曲后模型学到的边界特征和真实推理时不一致。颜色抖动在X光片上意义不大,但亮度和对比度调整可以加,模拟不同曝光条件。我一般用Albumentations组合:HorizontalFlip(p=0.5)、ShiftScaleRotate(shift_limit=0.05, scale_limit=0.1, rotate_limit=15, p=0.5)、ElasticTransform(alpha=1, sigma=50, p=0.3)。注意ElasticTransform的alpha和sigma不要设太大,否则牙齿会被拉成波浪形。
4. 牙齿分割模型评估与推理后处理的避坑清单
4.1 现象:验证集Dice 0.85,测试集掉到0.6
原因:2616张数据里如果同一患者的图像同时出现在训练集和验证集,模型实际上在“背”患者特征,不是学分割。牙齿影像尤其明显,同一患者的全景片和根尖片有相似的牙弓形态和病变分布。解决:按患者ID划分数据集,没有患者ID就用图像哈希做近似去重,确保验证集和测试集的患者不出现在训练集里。
4.2 现象:病变区域预测断断续续,mask碎片化
原因:Focal Loss对难分样本加权后,模型在病变边界处输出概率波动大,阈值0.5二值化后出现孔洞。解决:推理时用测试时增强(TTA),对输入做水平翻转和±10度旋转,把多次预测的概率图平均后再二值化。另外后处理加一步形态学闭运算,kernel size 3×3,填补小孔洞,但不要用开运算,否则细小的根尖病变会被抹掉。
4.3 现象:6类别里某一类始终预测不出来
原因:该类在2616张里样本数太少,或者labelme标注时该类多边形面积过小,转mask后像素数只有几十个。解决:先确认该类在训练集里的总像素数,如果低于5000,考虑把该类合并到相近类别,或者用copy-paste增强把该类mask贴到其他图像上。另一个常见原因是CLASS_MAP里该类索引和背景索引冲突,检查转换脚本里背景是否占了0,类别索引是否从1开始。
4.4 现象:推理时显存溢出,batch size降到1还是OOM
原因:输入分辨率512×512,模型如果是U-Net结构,中间层特征图通道数256或512,显存占用和分辨率平方成正比。解决:推理时用滑动窗口,窗口512×512,步长256,重叠区域取平均。或者把模型转成半精度,torch.cuda.amp.autocast(),显存能省40%左右。如果还不行,把输入短边缩到384,长边保持比例,牙齿分割在384分辨率下Dice通常只掉1到2个点。
4.5 现象:labelme转mask后图像和mask尺寸差一个像素
原因:labelme标注时用的图像可能经过一次resize,json里的imageHeight和imageWidth是resize后的尺寸,但原图是另一个尺寸。解决:转换时以原图尺寸为准,把json里的points按比例缩放回原图坐标。比例就是原图宽除以json里的imageWidth。这个坑很隐蔽,因为差一个像素在可视化时看不出来,但训练时mask和图像错位会导致边界loss异常。
5. 把2616张牙齿分割数据用出更高上限的两个技巧
第一个技巧是自监督预训练加微调。2616张对于从零训练一个分割模型偏少,但你可以先用这批数据做自监督预训练,比如用MAE结构随机mask掉60%的patch,让模型重建牙齿图像,学到的编码器再接到U-Net解码器上做有监督微调。我试过在类似体量的牙齿数据上,自监督预训练能让最终Dice提升3到5个点,尤其是病变类别提升明显。具体做法是把2616张图按512×512切patch,每个epoch随机mask,用L1 loss重建,预训练200个epoch,然后加载编码器权重,解码器随机初始化,用前面说的DiceFocalLoss微调80个epoch。注意预训练时不要用数据增强的旋转和弹性形变,只用随机裁剪和水平翻转,否则重建目标不一致。
第二个技巧是伪标签半监督。如果你手头还有一批未标注的牙齿影像,哪怕只有几百张,也可以用训练好的模型先预测,把置信度高于0.9的像素作为伪标签,和2616张有标签数据一起训练。伪标签的loss权重设0.3,有标签的设1.0。这个做法在牙齿病变分割上特别有效,因为病变区域标注成本高,但未标注图像容易获取。我一般会迭代两轮伪标签,第一轮用模型预测,第二轮用更新后的模型再预测,每轮都重新筛选高置信像素。注意伪标签不要用TTA后的结果,TTA会平滑概率,导致高置信区域偏大,把背景也带进去。
最后一个习惯:每次跑完训练,不要只看Dice,把验证集里预测最差的10张图导出来,叠加原图和mask看。牙齿分割的翻车往往集中在牙根尖、牙颈部这些边界模糊的区域,看多了你就知道该调损失函数还是该加后处理。希望帮到你。
本文还有配套的精品资源,点击获取