news 2026/10/13 4:26:47

舌苔数据集实战:从labelme标注到PyTorch语义分割全流程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
舌苔数据集实战:从labelme标注到PyTorch语义分割全流程

简介:面向中医舌诊研究与深度学习应用的舌苔图像数据集,收录两千余张512×512像素的原图,并配有Labelme标注的JSON标签,覆盖黑苔、地图舌、白厚腻苔、紫苔、红厚腻苔、红舌黄腻苔等常见舌象类型,适用于图像分类、目标检测与语义分割等模型的训练和验证,尤其适合使用Python、PyTorch或TensorFlow构建识别模型的研究者与学生。压缩包为ZIP格式,共2000个文件,以JSON标注文件为主,累计大小约206.13MB,每个文件对应一张原始图像的边界框或区域分割信息,下载后可直接解析坐标与类别标签,省去人工标注环节。此数据集发布以来已有2465人学习下载。借助这些精细标注的高分辨率舌象数据,可快速搭建舌苔自动分类、舌质与舌苔分离等医学辅助诊断原型,亦可作为计算机视觉课程的实例数据集,用于算法对比、论文复现或模型部署测试。

1. 舌苔数据集到底是什么:两千多张512x512舌象图,能做的事比你想的多

做中医舌诊数字化或者医学图像分割的人,应该都遇到过同一个尴尬:公开的舌象数据集要么分辨率低,要么只给了裁剪好的舌头小图,想要自己重新标注一遍又得耗费大量时间。这份舌苔数据集属于"到手就能跑"的那种——两千多张512x512的RGB图像,原图清晰度统一,每张图都配好了labelme格式的JSON标签,直接省掉了最磨人的标注和清洗环节。它能做的事分两条路:一条是图像分割,用语义分割模型把舌体轮廓、舌苔区域从背景里抠出来;另一条是分类,基于分割结果做胖瘦、齿痕、苔色苔质等特征的统计分析。适合的人群也很明确:做医学图像分割课题的学生、搞中医数字化产品的算法工程师,以及想快速验证某个分割模型在真实标注数据上效果的技术人员。

2. 数据组织与labelme标签结构:先搞清楚JSON里到底存了什么

2.1 目录结构与文件对应关系

拿到数据集后第一件事不是急着训练,而是先把目录结构看清楚。标签是labelme格式时,数据基本按"图片文件夹 + 标注文件夹"的方式组织,图片和JSON文件通过同名前缀对应。比如一张叫T001_01.jpg的图,对应标注文件就是T001_01.json,这种约定意味着你在写数据加载器时可以直接用stem字段做匹配,不用手搓映射表。

实际解压后,常见做法是看到下面这类布局:

tongue_dataset/ ├── images/ │ ├── T001_01.jpg │ ├── T001_02.jpg │ └── ... └── labels/ ├── T001_01.json ├── T001_02.json └── ...

建议拿到数据后先跑一段脚本做完整性校验:遍历所有图片名,确认每张图都有对应的JSON,反过来也要查一遍有没有孤立JSON。我一般会顺手统计一下JSON里的标注数量分布,判断哪些图标注内容多、哪些图可能就是空背景。这个步骤看着简单,但在后续做数据划分时特别有用——如果某张图没有标注或标注为空,送入训练时会给损失函数带来不必要的噪音。

2.2 labelme JSON的核心字段:shapes、imagePath、imageData

labelme导出的JSON结构相对固定,但里面有几个字段的坑值得先说清楚。一个典型的标注文件会包含version、flags、shapes、imagePath、imageData、imageWidth、imageHeight这几个字段。其中shapes是核心,它是个列表,每个元素对应一个标注对象,包含label(类别名)、points(多边形顶点坐标)、shape_type(通常为polygon)。

points存的是绝对像素坐标,在512x512的图像上取值范围是0到511,这一点非常重要——后面做图像缩放或裁剪时,如果对坐标做了变换但忘了同步更新掩码,就会出对齐错位的问题。imageData字段则更像是黑匣子:labelme在保存时默认会把原图以base64编码塞进去,但有些版本或导出配置下这个字段是空的。安全做法永远是从imagePath读取原图,不要依赖imageData,否则你会在批量转换时遇到一半文件能读一半文件报错的玄学问题。

import json import pathlib json_path = pathlib.Path("labels/T001_01.json") with open(json_path, "r", encoding="utf-8") as f: ann = json.load(f) print("图片路径字段:", ann.get("imagePath")) print("标注数量:", len(ann["shapes"])) for shape in ann["shapes"]: print("类别:", shape["label"], "点数:", len(shape["points"]), "类型:", shape["shape_type"])

这段代码的作用是快速预览单张标注文件的类别体系和标注密集程度。很多初学者会直接跳过这一步就写转换脚本,结果等模型跑起来才发现两个标注文件的类别名不一致,比如一个叫tongue_coating另一个叫coating,后患无穷。类别名的统一归一化工作,最好在数据准备阶段就一次性解决。

3. 从labelme JSON到训练掩码:转换脚本与PyTorch加载流程

3.1 标签转掩码:从JSON多边形到单通道PNG

labelme的JSON不能直接喂给模型,需要把它转换成像素级的掩码图。分割任务的标准做法是生成单通道的PNG,每个像素值对应该位置的类别ID。这里有两个关键决策:一是类别ID的映射表必须固定下来,二是掩码保存格式要选对。保存成PNG而不是JPEG,因为PNG是无损压缩,JPEG压缩会引入伪边缘。

转换脚本的核心逻辑是先用cv2.fillPoly把多边形填充到空掩码上,再按类别映射表写入对应像素值。注意背景像素必须显式设为0,否则模型在计算交叉熵损失时会默认忽略背景区域。

import json import numpy as np import cv2 from pathlib import Path # 类别映射表,按labelme里的标注名称做归一化 LABEL_MAP = { "background": 0, "tongue_body": 1, "tongue_coating": 2, } def labelme_to_mask(json_path: str, height: int = 512, width: int = 512) -> np.ndarray: with open(json_path, "r", encoding="utf-8") as f: ann = json.load(f) mask = np.zeros((height, width), dtype=np.uint8) for shape in ann["shapes"]: label = shape["label"] points = np.array(shape["points"], dtype=np.int32) # 只处理多边形类型,矩形等其他类型按需扩展 if shape["shape_type"] == "polygon" and label in LABEL_MAP: cv2.fillPoly(mask, [points], LABEL_MAP[label]) return mask

这段代码有几个参数值得注意:height和width设成512是因为数据集本身是512x512,但如果后续要做随机裁剪增强,建议在转换时保留原始尺寸,把缩放步骤放到数据加载器里做,这样灵活性更高。LABEL_MAP里的类别名必须和你JSON里实际出现的label字符串完全一致,一个字母都不能差。

批量转换所有JSON文件的脚本也很直接,遍历目录、调用上面的函数、用cv2.imwrite写到掩码文件夹:

label_dir = Path("labels") mask_dir = Path("masks") mask_dir.mkdir(exist_ok=True) for json_file in label_dir.glob("*.json"): mask = labelme_to_mask(str(json_file)) out_path = mask_dir / (json_file.stem + ".png") cv2.imwrite(str(out_path), mask) print(f"已转换: {json_file.name} -> {out_path.name}")

转换完成后强烈建议做一次反向验证:随机挑几张掩码图,用OpenCV的addWeighted做原图和掩码的半透明叠加,肉眼检查轮廓是否贴合舌体边缘。这一步只需要30秒,但能拦截掉80%的标签对齐问题。

3.2 PyTorch Dataset实现:注意插值方式和张量数据类型

掩码准备好之后,就到了写数据加载器的环节。PyTorch里自定义Dataset是标准操作,但有两个坑是初学者必踩的:一是掩码的resize不能用默认的双线性插值,否则类别边缘会出现中间值,变成一个像素值是1.5的"灰色类别";二是掩码的张量类型必须是int64或long,因为PyTorch的交叉熵损失要求目标张量是整型。

import torch from torch.utils.data import Dataset from PIL import Image import torchvision.transforms as T import numpy as np class TongueSegDataset(Dataset): def __init__(self, img_dir: str, mask_dir: str, size: int = 512): self.img_paths = sorted(Path(img_dir).glob("*.jpg")) self.mask_paths = sorted(Path(mask_dir).glob("*.png")) self.size = size def __len__(self): return len(self.img_paths) def __getitem__(self, idx): img = Image.open(self.img_paths[idx]).convert("RGB") mask = Image.open(self.mask_paths[idx]) # 统一缩放到固定尺寸,掩码必须用NEAREST img = img.resize((self.size, self.size), Image.BILINEAR) mask = mask.resize((self.size, self.size), Image.NEAREST) # 转张量:图像归一化到[0,1],掩码保持类别ID img_tensor = T.ToTensor()(img) mask_tensor = torch.from_numpy(np.array(mask, dtype=np.int64)) return img_tensor, mask_tensor

这段代码里size=512是数据集原生分辨率,如果你显存紧张可以先降到256或224跑通流程,但要注意舌苔边缘是精细结构,降分辨率会损失分割精度。我一般会在训练初期用256做快速迭代验证,模型收敛后再切回512做正式训练。

另外要注意:这里没有做任何数据增强,真实项目中你会把随机水平翻转、随机旋转、颜色抖动加进去。但舌苔图像的颜色是诊断特征,色调类增强要克制,建议只做亮度对比度的小幅度扰动,不然会把苔色特征给增强没了。

4. 训练与验证:从512x512原图到分割模型的全流程

4.1 数据划分:别让同一来源的图片同时出现在训练集和验证集

数据划分是分割项目里最容易被低估的环节。舌苔图像通常来自不同个体,同一个人的多张舌象图在纹理、光照、舌苔分布上高度相似,如果按文件名随机划分而不考虑个体维度,验证集的分数会虚高,模型实际泛化能力远低于你看到的效果。

常见做法是按来源ID对图像做分组划分。这个数据集的命名规则如果不是直接体现个体信息,也可以通过聚类或人工分类做近似划分。流程是:先统计所有图片名,找出能区分个体的前缀,做group_train_test_split,保证同一前缀的图片全部落入同一侧。

import random from pathlib import Path img_paths = sorted(Path("images").glob("*.jpg")) group_map = {} for path in img_paths: # 假设文件名前缀是个体ID,例如 T001_01.jpg -> T001 group_id = path.stem.split("_")[0] group_map.setdefault(group_id, []).append(path) group_ids = list(group_map.keys()) random.seed(42) random.shuffle(group_ids) train_groups = group_ids[: int(len(group_ids) * 0.8)] val_groups = group_ids[int(len(group_ids) * 0.8):] train_paths = [p for g in train_groups for p in group_map[g]] val_paths = [p for g in val_groups for p in group_map[g]] print(f"训练集图像数: {len(train_paths)}, 验证集图像数: {len(val_paths)}")

划分比例80/20是分割任务里的常见默认值,如果数据量小或类别不平衡严重,可以改成90/10。另外要注意给划分结果做一次类别分布统计,确认舌苔区域的像素占比在训练集和验证集里相近,不然会出现验证集恰好全是舌苔丰富图像的情况,导致评估结果失真。

4.2 训练参数与损失函数:Dice和交叉熵的组合更稳

舌苔分割的模型选型不需要太多花哨,基于编码器-解码器结构的常见UNet实现就足够用。主干网络可以选择轻量级结构,512x512输入下显存占用大约在4到8GB之间,视具体层数和通道数而定。关键不在模型结构,而在训练策略。

损失函数建议用Dice Loss和交叉熵的加权组合。交叉熵梯度稳定,Dice Loss能直接优化分割指标,两者结合后模型在舌苔区域上的边缘表现会明显好于单独使用其中一种。权重比可以设成dice_weight=0.5, ce_weight=1.0,类别不平衡严重时加大Dice的权重。

import torch.nn.functional as F def combined_loss(pred_logits, target, dice_weight=0.5, ce_weight=1.0): # pred_logits: [B, C, H, W] 未归一化分数 # target: [B, H, W] 类别索引 ce_loss = F.cross_entropy(pred_logits, target) pred_softmax = F.softmax(pred_logits, dim=1) target_onehot = F.one_hot(target, num_classes=pred_logits.shape[1]).permute(0, 3, 1, 2).float() smooth = 1.0 intersection = (pred_softmax * target_onehot).sum(dim=(2, 3)) union = pred_softmax.sum(dim=(2, 3)) + target_onehot.sum(dim=(2, 3)) dice = (2.0 * intersection + smooth) / (union + smooth) dice_loss = 1.0 - dice.mean() return ce_weight * ce_loss + dice_weight * dice_loss

这段实现里smooth参数是平滑项,防止分母为0,值设成1.0是常见做法。one_hot要在类别维度上对齐,注意PyTorch的F.one_hot会多出一个维度,必须用permute调整到[B, C, H, W],很多人在这一步栽过跟头。

训练时的优化器选择,AdamW配余弦退火学习率是我在分割任务上比较常用的组合。初始学习率设1e-4,如果batch size较小可以降到5e-5。512x512输入下,batch size设在4到8之间比较合适,实测显存8GB的卡最多跑到8左右。训练轮数不用贪多,80到120轮足够收敛,关键是监控验证集Dice的变化曲线,一旦连续10轮没有上升就提前停车,省时间也省显存。

评估指标方面,Dice系数和IoU是两个最常用的。实测中Dice和IoU的换算关系大约是一个固定曲线,IoU通常比Dice低5到8个百分点,所以不要拿Dice的预期去套IoU的指标,两者期望值本身就不同。

5. 避坑与常见问题:标注错位、类别失衡与数据增强的四个坎

5.1 掩码边缘出现灰色过渡,模型训练时NaN

现象:转换出来的掩码图打开一看,边缘有一圈灰色的虚边,训练时损失值偶尔跳到NaN。

原因:对掩码做resize时用成了双线性插值,类别边界被插值成小数,PyTorch的交叉熵在遇到非整数值时会报错或产生NaN梯度。

解决:所有对掩码的缩放操作一律用Image.NEAREST或cv2.INTER_NEAREST。检查点有两个位置:数据增强里的resize,以及Dataset里__getitem__的resize。注意有些增强库默认用双线性,用之前看一遍文档,别图省事直接用默认参数。

5.2 训练时模型把所有像素都预测成背景

现象:训练了50轮,验证集Dice一直不到0.1,可视化预测结果发现整个输出都是黑色。

原因:舌苔区域占整张图的比例太小,背景像素占了绝大多数,模型直接学了个"全预测背景"的捷径。这在医学图像分割里非常常见,尤其是舌象这种目标集中在图像中心但占比很小的场景。

解决:核心思路有两步。第一,检查类别像素占比分布,如果背景占了95%以上,考虑做随机裁剪或滑动窗口采样,让训练样本里目标区域占比提升。第二,损失函数加大Dice权重,Dice Loss对正负样本比例不敏感,天然适合这种场景。另外也可以尝试用两阶段策略:先用整图分割舌体,再在舌体区域内跑舌苔分析,把大比例失衡拆成两个小问题。

5.3 JSON里imageData字段为空,脚本批量报错

现象:批量转换时到某几张图突然崩掉,日志提示KeyError: 'imageData'或base64解码失败。

原因:labelme某些版本导出时不会自动嵌入imageData,这个字段是空字符串。有些踩坑教程教你直接从这个字段解析图像,但对这批数据并不适用。

解决:统一走imagePath从原图目录读图,不要理会imageData。另外批量转换前先做一轮完整性扫描,把JSON字段规范性问题集中报出来,一次性修完再跑转换,别转换到一半才发现问题。

5.4 512x512的batch size设8直接爆显存

现象:训练脚本启动不到两分钟,CUDA out of memory,显存直接红了。

原因:512x512输入下的UNet显存占用是非线性的,加上中间特征图,batch size设8在8GB卡上很难跑起来。

解决:三个方案按优先级排序:先把batch size降到2或4,确认能跑通再往上试探;然后用混合精度训练,这能在几乎不损失精度的情况下省掉近一半显存;最后如果还不行,把输入尺寸临时降到384或256,验证流程用低分辨率跑,最终训练再切回512。很多项目其实默认就是先用256做实验,效果好再上512。

5.5 训练Dice很高但实际效果很差,边缘锯齿明显

现象:验证集Dice到了0.93,但可视化预测图发现边缘粗糙、锯齿感严重,看起来不像干净的舌体轮廓。

原因:Dice是区域重叠指标,对边缘细节不敏感。如果损失函数里只用了Dice,模型会偏向生成保守的平滑区域,内部大块预测对了但边缘不精细。

解决:加入边缘监督或辅助损失,比如在模型输出旁路加一个边缘预测头,或者在后处理阶段用OpenCV的形态学操作清理边缘。还有一招是训练后期切到更小的学习率做微调,让边界上的预测分数逐步细化。这类问题靠调指标看不出来,必须每轮训练结束随机挑几张预测图做可视化,养成这个习惯能省太多后悔药的时间。

6. 进阶技巧:从PyTorch切换到TensorFlow,以及分割结果的可视化验证

如果研究组里主流框架是TensorFlow,同一份数据集切换过去也不复杂,核心差异在数据读取和增强管道的写法上。TensorFlow里建议直接用tf.data加载路径列表并用map函数做解析,掩码的resize要显式指定method='nearest'。

import tensorflow as tf def parse_tongue(img_path, mask_path): img = tf.io.read_file(img_path) img = tf.image.decode_jpeg(img, channels=3) mask = tf.io.read_file(mask_path) mask = tf.image.decode_png(mask, channels=1) img = tf.image.resize(img, [512, 512], method='bilinear') mask = tf.image.resize(mask, [512, 512], method='nearest') img = tf.cast(img, tf.float32) / 255.0 mask = tf.cast(mask, tf.int64) return img, mask train_ds = tf.data.Dataset.from_tensor_slices((train_img_paths, train_mask_paths)) train_ds = train_ds.map(parse_tongue, num_parallel_calls=tf.data.AUTOTUNE) train_ds = train_ds.batch(4).prefetch(tf.data.AUTOTUNE)

这段代码在parse_tongue里完成了读图、解码、缩放和类型转换,注意tf.image.decode_png得到的灰度掩码是三维张量[H, W, 1],在计算损失前需要压缩成[H, W]或直接用tf.squeeze去掉最后一维。TensorFlow和PyTorch在数据类型上的默认行为差异比较大,PyTorch读图默认得到[0, 255]的整型张量,TensorFlow解码后是[0, 255]的uint8,归一化操作要手动加。

我在实际项目里踩过的最后一个教训是关于验证的:训练过程中的指标再好看,都不如把预测结果叠到原图上看一眼来得直接。可视化验证是我现在每个分割项目收尾前的强制步骤——写一个小函数,把预测掩码用透明色叠加到原图上,边缘放大的局部图另存一张,然后人工检查舌体边缘是否贴合、舌苔区域是否被完整覆盖、有没有大块的假阳性区域。

import numpy as np import cv2 def overlay_mask(img: np.ndarray, mask: np.ndarray, color=(255, 0, 0), alpha=0.4): overlay = img.copy() for i in range(3): overlay[:, :, i] = np.where( mask > 0, img[:, :, i] * (1 - alpha) + color[i] * alpha, img[:, :, i], ) return overlay.astype(np.uint8) # 示例:加载原图和预测掩码,叠加后保存 img = cv2.imread("images/T001_01.jpg") img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) pred = cv2.imread("pred_masks/T001_01.png", cv2.IMREAD_GRAYSCALE) vis = overlay_mask(img, pred) cv2.imwrite("vis/T001_01_overlay.jpg", cv2.cvtColor(vis, cv2.COLOR_RGB2BGR))

overlay_mask函数里mask > 0会把所有非背景类别都标成同一种颜色,如果要做多类别可视化,可以给每个类别ID分配不同颜色,分别叠加。从那以后我每次做医学图像分割项目,都会在训练脚本里把"每N轮保存一次可视化结果"这个逻辑写成默认配置,模型跑完第一眼先看可视化而不是看Dice曲线。这套流程看着简单,但对这个数据集来说,它比任何一个花的损失函数都更能挽救你的模型效果。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/13 4:26:03

Linux内核链表实战:从自然数重排掌握list_head核心操作

1. 从一道内核链表题说起:为什么值得认真拆解第一次看到“用链表存储若干自然数,把 1 2 3 4 5 6 7 8 9 重排成 1 3 5 7 9 8 6 4 2”这个题目时,很多人第一反应是“这不就是个数组排序题吗,开个数组分奇偶两半,奇数正着…

作者头像 李华
网站建设 2026/10/13 4:25:34

SCALE-Net:面向动态车辆数的可伸缩轨迹预测架构

1. 为什么“车辆数量随机”是轨迹预测真正的拦路虎在自动驾驶和智能交通系统里,车辆轨迹预测不是一道数学题,而是一场实时动态博弈。多数论文和开源模型——比如经典的Social-GAN、Trajectron,甚至近两年火起来的MFP、CoverNet——都默认一个…

作者头像 李华
网站建设 2026/10/13 4:25:11

家居装修AI咨询助手:基于RAG的知识库搭建与多轮对话实战

装修这件事,我见过太多人卡在“查资料”这一关:搜某个材料型号,翻了几十页帖子还没搞懂;问师傅一句“墙固到底要不要刷”,得到的回答是“看你预算”;问了三家装修公司,报价术语各不相同&#xf…

作者头像 李华
网站建设 2026/10/13 4:24:52

SQL Server餐厅点餐系统实战:从表结构设计到并发事务控制

简介:这份数据库课程设计资源面向高校计算机相关专业学生与SQL Server初学者,围绕餐厅点餐业务场景,提供一套可运行、可参考的完整课程设计案例,帮助解决传统点菜方式效率低、易出错的问题,适合作为数据库课程作业、实…

作者头像 李华
网站建设 2026/10/13 4:24:32

MySQL日期格式化函数DATE_FORMAT:从入门到性能优化实践

1. 为什么日期格式化会成为查询里的隐形麻烦做数据查询的朋友应该都有过这种经历:业务表里的时间字段明明存得好好的,结果一查出来要么是2024-03-15 08:30:00这种一眼望不到头的格式,要么因为类型不带时分秒,统计报表时根本对不上…

作者头像 李华
网站建设 2026/10/13 4:24:28

微信网页开发实战:从授权登录到JSSDK接入

2. 微信网页开发的整体设计思路1.1 微信网页开发到底是什么微信网页开发,指的是在微信内置浏览器(X5内核或系统浏览器)中运行的H5页面开发,同时借助微信提供的JSSDK能力,让网页能够调用微信原生的功能,比如…

作者头像 李华