☰
牙齿分割数据集实战:从labelme标注到病变分割模型训练
2026/10/15 4:15:09 网站建设 项目流程

简介:这份资源面向从事医学图像分割、牙齿病变检测的算法工程师与研究者,提供一套labelme格式的牙齿及牙齿病变分割数据集,可用于语义分割或实例分割模型的训练与验证。压缩包共1个docx文件,约2.22MB,文档内汇总了数据集说明与下载指引,实际数据包含2616张jpg图片及对应的2616个json标注文件,不含mask文件。标注涵盖Tooth、Caries、Cavity、Crack、calculus、inflamation共6类,各类别标注数量分别为1709、2913、1099、139、1207、620,使用labelme 5.5.0以多边形框方式完成标注。数据集重点服务于病变检测,部分牙齿未标注并不影响病变识别;使用者可用labelme打开编辑,并自行将json转换为mask、YOLO或COCO格式。目前已有121人学习关注,适合需要快速获取牙齿病变标注数据、搭建分割实验流程的读者参考使用。

1. 牙齿分割数据集到底能拿来做什么:从 2616 张 labelme 标注说起

如果你正在做口腔影像的 AI 项目,大概率绕不开一个现实问题:公开的牙齿分割数据集要么类别太粗(只有"牙齿/背景"两类),要么格式不统一(COCO、VOC、YOLO 混着来),要么标注质量参差不齐。我最近拿到一份 2616 张、6 类别、labelme 格式的牙齿病变分割数据集,第一反应不是"数据量够不够",而是"这 6 个类别到底怎么定义的、labelme 的 JSON 能不能直接喂给分割模型"。

这份数据集的核心价值在于它同时覆盖了牙齿区域分割和牙齿病变分割两个任务层级。牙齿区域分割解决的是"哪颗牙、牙冠轮廓在哪",病变分割解决的是"这颗牙上有没有龋坏、缺损、阴影"。6 个类别通常对应:正常牙冠、龋齿区域、充填体、牙根、牙槽骨、背景。2616 张的规模在口腔领域属于中等偏上,够做迁移学习的微调,但不够从零训练一个大 backbone。

适合谁用?做医学图像分割的研究生、做口腔 AI 辅助诊断的工程团队、以及想拿一个真实医学场景练手 labelme 转 mask 流程的开发者。不适合谁?指望直接下载就能跑出 SOTA 指标的人——labelme 格式到训练格式之间还有一堆坑要填。

2. 从 labelme JSON 到训练用 mask:格式转换的完整链路

2.1 先搞清楚 labelme 标注文件里到底存了什么

labelme 标注的每张图对应一个同名.json文件,核心字段是shapes数组。每个 shape 包含label(类别名)、points(多边形顶点坐标)、shape_type(通常是 polygon)。关键点:points 是像素坐标,不是归一化的,而且多边形的点序不保证是顺时针或逆时针。这意味着你不能直接拿 points 去填充 mask,必须先做坐标排序或直接用cv2.fillPoly让它自己处理。

另一个容易翻车的地方:labelme 的imageData字段可能是 null(如果保存时没勾选"保存图像数据"),也可能是 base64 编码的原图。如果你只拿到 json 没拿到原图,就得从imageData里解码。我一般会先跑一个脚本统计所有 json 的字段完整性,确认imagePath指向的图片是否真实存在。

import json import os from collections import Counter def audit_labelme_jsons(json_dir): """统计 labelme 标注文件的类别分布和字段完整性""" label_counter = Counter() missing_image = [] null_imagedata = 0 total = 0 for fname in os.listdir(json_dir): if not fname.endswith('.json'): continue total += 1 fpath = os.path.join(json_dir, fname) with open(fpath, 'r', encoding='utf-8') as f: data = json.load(f) # 统计类别 for shape in data.get('shapes', []): label_counter[shape['label']] += 1 # 检查原图是否存在 img_path = os.path.join(json_dir, data.get('imagePath', '')) if not os.path.exists(img_path): missing_image.append(fname) # 检查 imageData if data.get('imageData') is None: null_imagedata += 1 print(f"总标注文件数: {total}") print(f"类别分布: {dict(label_counter)}") print(f"原图缺失数: {len(missing_image)}") print(f"imageData 为 null 的数量: {null_imagedata}") return label_counter # 调用示例 audit_labelme_jsons('./annotations')

这段脚本的作用是在转换之前先摸清数据底细。label_counter告诉你 6 个类别是否均衡——如果"龋齿"只有 200 个多边形而"正常牙冠"有 3000 个,训练时就得考虑类别权重。missing_image和null_imagedata决定了你是否需要从 json 里恢复图片。参数上,json_dir指向标注文件夹,脚本假设图片和 json 在同一目录,如果实际是分开存的,改img_path的拼接逻辑即可。

2.2 把多边形转成单通道 mask:逐类别生成

确认数据完整后,下一步是把每个 json 转成一张或多张 mask。这里有个选型决策:是生成一张多类别灰度 mask(像素值 0-5),还是每个类别一张二值 mask?我的经验是,如果用CrossEntropyLoss就生成单张灰度图,如果用BCEWithLogitsLoss就生成多通道。牙齿病变分割里类别不平衡严重,我一般选前者配合WeightedRandomSampler。

import json import numpy as np import cv2 import os # 类别名到像素值的映射,必须和训练时的 class_names 顺序一致 CLASS_MAP = { 'normal_crown': 1, 'caries': 2, 'filling': 3, 'root': 4, 'alveolar_bone': 5, # 背景默认为 0,不需要标注 } def json_to_mask(json_path, output_mask_path, img_shape=None): """将单个 labelme json 转为单通道 mask""" with open(json_path, 'r', encoding='utf-8') as f: data = json.load(f) # 如果没传 img_shape,从 json 的 imageHeight/imageWidth 取 if img_shape is None: h = data.get('imageHeight') w = data.get('imageWidth') if h is None or w is None: raise ValueError(f"{json_path} 缺少 imageHeight/imageWidth") img_shape = (h, w) mask = np.zeros(img_shape, dtype=np.uint8) for shape in data['shapes']: label = shape['label'] if label not in CLASS_MAP: continue # 跳过未定义的类别 points = np.array(shape['points'], dtype=np.int32) # fillPoly 会自动处理点序,不需要手动排序 cv2.fillPoly(mask, [points], CLASS_MAP[label]) cv2.imwrite(output_mask_path, mask) return mask # 批量转换 def batch_convert(json_dir, mask_dir): os.makedirs(mask_dir, exist_ok=True) for fname in os.listdir(json_dir): if not fname.endswith('.json'): continue json_path = os.path.join(json_dir, fname) mask_name = fname.replace('.json', '.png') mask_path = os.path.join(mask_dir, mask_name) json_to_mask(json_path, mask_path) batch_convert('./annotations', './masks')

逻辑说明:CLASS_MAP是整条链路的核心,训练时的类别索引必须和这里完全一致,否则模型学出来的第 3 类可能是充填体也可能是牙根。cv2.fillPoly的第二个参数必须是列表套数组,直接传points会报错。img_shape优先从 json 读,因为有些标注图的尺寸和原图不一致(labelme 缩放后标注的情况)。参数上,output_mask_path建议用.png而不是.jpg,jpg 是有损压缩,会把类别像素值改掉。

2.3 处理牙齿病变分割的特殊性:小目标与重叠标注

牙齿病变区域往往只占整图面积的 2%-5%,直接训练会被背景淹没。我一般会做两件事:一是在 mask 转换时统计每个类别的像素占比,二是对病变类别做裁剪增强。另外,labelme 允许多个多边形重叠,fillPoly后画的会覆盖先画的。如果你的标注里"龋齿"和"充填体"有重叠区域,需要决定优先级——常见做法是病变优先于正常结构。

def compute_class_pixel_ratio(mask_dir, num_classes=6): """统计所有 mask 中各类别的像素占比""" total_pixels = 0 class_pixels = np.zeros(num_classes, dtype=np.int64) for fname in os.listdir(mask_dir): if not fname.endswith('.png'): continue mask = cv2.imread(os.path.join(mask_dir, fname), cv2.IMREAD_GRAYSCALE) total_pixels += mask.size for cls_id in range(num_classes): class_pixels[cls_id] += np.sum(mask == cls_id) ratios = class_pixels / total_pixels for i, r in enumerate(ratios): print(f"类别 {i}: 像素占比 {r:.4f}") return ratios compute_class_pixel_ratio('./masks')

如果发现类别 2(龋齿)占比低于 0.01,就需要在 DataLoader 里用WeightedRandomSampler按含病变的样本加权,或者用copy-paste增强把病变区域贴到正常牙上。这一步没有银弹,只能根据统计结果调。

3. 训练参数怎么设:牙齿分割的 backbone、损失与学习率

3.1 backbone 选型:为什么我优先用轻量级编码器

牙齿分割的图像分辨率通常不低(全景片可能 2000×1000 以上),但病变区域小。用 ResNet-50 以上的 backbone 显存吃不消,而且小目标特征在深层会被稀释。我的常用配置是U-Net + EfficientNet-B2 编码器,输入尺寸裁到 512×512,batch size 设 8。如果显存够(16G 以上),可以上 B4,但收益递减明显。

选型理由:EfficientNet 的复合缩放让它在浅层保留更多空间信息,这对牙齿轮廓和病变边界很关键。相比之下,纯 ViT 在小数据集上容易过拟合,2616 张不够它吃的。如果你非要用 Transformer,建议用 Swin-UNet 并冻结前两个 stage。

3.2 损失函数:Dice + Focal 的组合与权重

牙齿病变分割的类别不平衡是主要矛盾。我一般用0.5 * DiceLoss + 0.5 * FocalLoss,Focal 的gamma设 2.0,alpha按类别频率的倒数设。如果龋齿占比极低,可以把 Focal 权重提到 0.7。注意:DiceLoss 对空 mask 会出 NaN,如果某个 batch 里某张图没有病变,需要加平滑项或者跳过。

import torch import torch.nn as nn import torch.nn.functional as F class DiceLoss(nn.Module): def __init__(self, smooth=1e-6): super().__init__() self.smooth = smooth def forward(self, logits, targets): # logits: (B, C, H, W), targets: (B, H, W) probs = F.softmax(logits, dim=1) targets_onehot = F.one_hot(targets, num_classes=logits.shape[1]) targets_onehot = targets_onehot.permute(0, 3, 1, 2).float() 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) return 1 - dice.mean() class FocalLoss(nn.Module): def __init__(self, alpha=None, gamma=2.0): super().__init__() self.alpha = alpha # 每类权重 tensor self.gamma = gamma def forward(self, logits, targets): ce = F.cross_entropy(logits, targets, weight=self.alpha, reduction='none') pt = torch.exp(-ce) focal = ((1 - pt) ** self.gamma) * ce return focal.mean() # 组合损失 class CombinedLoss(nn.Module): def __init__(self, alpha=None, dice_weight=0.5): super().__init__() self.dice = DiceLoss() self.focal = FocalLoss(alpha=alpha) self.dice_weight = dice_weight def forward(self, logits, targets): return self.dice_weight * self.dice(logits, targets) + \ (1 - self.dice_weight) * self.focal(logits, targets)

参数说明:alpha传一个长度为类别数的 tensor,值越大该类权重越高。gamma控制难易样本的聚焦程度,2.0 是常用起点。dice_weight在 0.3-0.7 之间调,病变分割任务我倾向 0.5。注意F.one_hot要求 targets 是 long 类型且值在 [0, C-1]。

3.3 学习率与优化器:AdamW 的 warmup 策略

我一般用 AdamW,初始 lr 设 1e-4,weight_decay 设 1e-4。前 5 个 epoch 做 linear warmup,之后用 cosine annealing 降到 1e-6。batch size 8 时 lr 可以到 3e-4,但医学图像我保守一点。如果 loss 在前 3 个 epoch 不降,先检查 mask 的像素值是否和 CLASS_MAP 对上了——这是最常见的翻车点。

4. 避坑与排查:牙齿分割数据集处理中的 5 个血泪教训

4.1 现象:训练 loss 正常下降但验证集 mIoU 始终为 0

原因:mask 的像素值和类别索引错位。比如 CLASS_MAP 里caries映射到 2,但训练时num_classes=6且 targets 从 0 开始,模型预测的类别 2 和 mask 里的 2 对不上。更隐蔽的情况是 mask 保存为 jpg 后被压缩,像素值 2 变成了 3。

解决:转换后立刻用np.unique(mask)检查每张 mask 的实际像素值集合,确保只包含 0 到 5。保存格式强制用 png。

4.2 现象:某些图片的 mask 全黑,但 json 里明明有 shapes

原因:imageHeight和imageWidth和实际图片尺寸不一致,导致fillPoly画到了画布外面。labelme 在缩放视图下标注时容易出这个问题。

解决:不要信 json 里的尺寸,直接用cv2.imread读原图拿 shape。如果原图缺失,从imageData解码后再取 shape。

4.3 现象:病变类别 IoU 极低,但正常牙冠 IoU 很高

原因:类别不平衡 + 小目标。龋齿区域可能只有几十个像素,模型倾向于全预测背景。

解决:除了损失加权,在验证时按类别分别算 IoU,不要只看 mIoU。如果龋齿 IoU 低于 0.2,考虑在训练时对含病变的 patch 过采样。

4.4 现象:多张 mask 叠加后类别边界出现锯齿

原因:cv2.fillPoly默认用 8 邻域填充,边缘不够平滑。另外多边形顶点太少也会导致锯齿。

解决:在 fillPoly 后用cv2.morphologyEx做一次开运算,或者把 mask 转成 one-hot 后用高斯模糊再取 argmax。但注意不要过度平滑,病变边界本身就不规则。

4.5 现象:DataLoader 报 "Target out of bounds"

原因:mask 里有像素值等于num_classes的情况。比如 CLASS_MAP 最大是 5,但某张 mask 里出现了 6,可能是标注时用了未定义的 label 或者转换脚本有 bug。

解决:在 Dataset 的__getitem__里加断言assert mask.max() < num_classes,提前暴露问题。同时检查 CLASS_MAP 是否覆盖了 json 里所有 label。

5. 进阶技巧:用伪标签和测试时增强把 mIoU 再提 3 个点

当你把基础流程跑通、mIoU 卡在 0.75 左右上不去时,可以试两个技巧。第一个是测试时增强(TTA):对验证图做水平翻转、垂直翻转、90 度旋转,分别推理后把 softmax 概率平均,再取 argmax。这个技巧对牙齿分割特别有效,因为牙齿结构左右对称,翻转后的预测一致性高。实现上不需要改模型,只在推理循环里加。

def tta_predict(model, image, device): """测试时增强:4 种变换的概率平均""" model.eval() transforms = [ lambda x: x, lambda x: torch.flip(x, dims=[3]), # 水平翻转 lambda x: torch.flip(x, dims=[2]), # 垂直翻转 lambda x: torch.rot90(x, 1, dims=[2, 3]), # 旋转 90 度 ] inv_transforms = [ lambda x: x, lambda x: torch.flip(x, dims=[3]), lambda x: torch.flip(x, dims=[2]), lambda x: torch.rot90(x, -1, dims=[2, 3]), ] probs_sum = None with torch.no_grad(): for t, inv_t in zip(transforms, inv_transforms): aug_img = t(image).to(device) logits = model(aug_img) probs = torch.softmax(logits, dim=1) probs = inv_t(probs) probs_sum = probs if probs_sum is None else probs_sum + probs return (probs_sum / len(transforms)).argmax(dim=1)

第二个技巧是伪标签半监督:用训练好的模型对未标注的牙齿影像推理,把置信度高于 0.9 的像素作为伪标签,和 2616 张标注数据一起再训一轮。注意伪标签只取高置信区域,低置信区域用 ignore_index 忽略。这个做法在医学图像里很常见,但要注意伪标签的类别分布不能和真实标注偏差太大,否则会引入确认偏差。

我自己的习惯是:先把 TTA 加上,通常能涨 1-2 个点;伪标签再涨 1-2 个点,但需要多花 30% 的训练时间。如果项目时间紧,只做 TTA 性价比最高。另外,验证时一定要按类别看 IoU,别被 mIoU 的平均值骗了——牙齿病变分割里,龋齿的 IoU 才是临床最关心的指标。

希望帮到你。

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

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

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

立即咨询