简介:本资源为舌头分割图像数据集,面向医学图像处理、计算机视觉方向的学习者与算法开发者,可用于训练和验证语义分割模型,解决舌头区域自动提取与二分类分割任务。数据图像分辨率统一为640×640,原图为jpg格式,掩膜标签为png格式,采用0/1阈值图像,0代表背景、1代表舌头,具体类别可在classes文本中查看。压缩包共约2000个文件,以1998个png掩膜、1个txt类别说明和1个py可视化脚本为主,整体约101.53MB。数据集划分为训练集与测试集:训练集含2127张图片及2127个对应掩膜,测试集含537张图片及537个对应掩膜,目录结构清晰。配套可视化脚本无需修改即可直接运行,随机抽取一张图片,展示原始图像、GT图像及GT在原图上的蒙板效果并保存到当前目录,便于快速检查标注质量。目前已有231人学习,适合入门到中级的图像分割实践者参考使用。
1. 舌头分割数据集:从 2 类标签到可视化,一套能直接跑通的落地路径
拿到一个「舌头分割(2类)」数据集,很多人第一反应是打开图片翻两页,然后卡在同一个地方:标签到底怎么读、类别 0 和 1 谁是谁、可视化代码跑出来一片黑。舌头分割在中医舌诊数字化、口腔影像分析里是个高频需求,2 类通常指背景与舌体,任务本质是二分类语义分割。它适合想快速验证 unet 图像分割流程、又不想从零标注的从业者。这篇笔记按「数据集结构 → 标签解析 → 可视化 → 训练接入 → 避坑」推一遍,目标是你照着能复现,而不是看完只知道有这么个东西。
2. 舌头分割数据集的结构与 2 类标签到底怎么读
2.1 目录组织与文件命名约定
常见做法是 images 与 masks(或 labels)两个平行目录,文件名一一对应。真正要确认的是三件事:图像格式、掩膜格式、命名是否严格对齐。我一般先跑一段统计脚本,把扩展名分布、尺寸分布、文件名交集一次性打出来,避免后面训练时才发现有图无标签。
import os from collections import Counter from PIL import Image root = "tongue_seg" # 数据集根目录 img_dir = os.path.join(root, "images") mask_dir = os.path.join(root, "masks") def scan(folder): files = [f for f in os.listdir(folder) if not f.startswith(".")] ext = Counter(os.path.splitext(f)[1].lower() for f in files) return files, ext imgs, img_ext = scan(img_dir) masks, mask_ext = scan(mask_dir) print("图像数量:", len(imgs), "扩展名:", img_ext) print("掩膜数量:", len(masks), "扩展名:", mask_ext) # 文件名(去扩展名)交集,检查是否一一对应 stem_i = {os.path.splitext(f)[0] for f in imgs} stem_m = {os.path.splitext(f)[0] for f in masks} print("仅图像有:", len(stem_i - stem_m), "仅掩膜有:", len(stem_m - stem_i))逻辑说明:先看数量是否相等,再看扩展名是否统一,最后看文件名交集。参数上,root换成你的实际路径即可;如果仅图像有或仅掩膜有不为 0,说明存在孤儿文件,训练前必须处理,否则 dataloader 会在某个 batch 直接抛异常。
2.2 2 类标签的像素值与语义映射
2 类分割最常见的坑是像素值不是 0/1,而是 0/255,甚至 0/128。直接拿去算交叉熵,类别数对不上就会报错或静默学错。先统计掩膜里出现的唯一像素值,这一步是黑匣子打开的关键。
import numpy as np from PIL import Image sample = os.path.join(mask_dir, masks[0]) m = np.array(Image.open(sample)) vals, counts = np.unique(m, return_counts=True) print("唯一像素值:", vals) print("占比:", (counts / counts.sum()).round(4))如果输出是[0 255],那类别 1 需要映射成 1;如果是[0 1],可直接用。映射方式:
def to_binary(mask): # 大于 127 视为舌体,否则背景;按你的实际像素值调整阈值 return (mask > 127).astype(np.uint8)参数说明:阈值 127 适用于 0/255 的情况;若掩膜是 0/128,阈值取 64 更稳。判断依据是唯一像素值的中位数,不要凭感觉写。类别语义上,0 一般是背景,1 是舌体,但务必用可视化确认,别默认。
2.3 尺寸、通道与长宽比分布
舌头图像往往来自手机或相机,尺寸不统一。训练前要么统一 resize,要么用 padding。先看分布再决定策略,比拍脑袋设 256×256 靠谱。
sizes = Counter() modes = Counter() for f in imgs[:200]: # 抽样 200 张足够看趋势 with Image.open(os.path.join(img_dir, f)) as im: sizes[im.size] += 1 modes[im.mode] += 1 print("尺寸 Top5:", sizes.most_common(5)) print("色彩模式:", modes)如果尺寸种类超过 20 种,建议统一 resize 到 256×256 或 512×512;如果长宽比差异大,直接 resize 会拉伸舌体,改用短边 resize + 中心裁剪更稳。色彩模式若是 RGBA,记得转 RGB,否则三通道模型读进去会多一维。
3. 数据可视化代码:把标签叠回原图看清对错
3.1 单张叠加可视化的最小实现
可视化不是锦上添花,它是你发现标签错位、掩膜反色、类别颠倒的唯一低成本手段。下面这段把原图、掩膜、叠加图并排画出来。
import matplotlib.pyplot as plt import numpy as np from PIL import Image def visualize(img_path, mask_path, alpha=0.5): img = np.array(Image.open(img_path).convert("RGB")) mask = np.array(Image.open(mask_path).convert("L")) mask_bin = (mask > 127).astype(np.uint8) overlay = img.copy() overlay[mask_bin == 1] = [255, 0, 0] # 舌体区域涂红 blended = (img * (1 - alpha) + overlay * alpha).astype(np.uint8) fig, ax = plt.subplots(1, 3, figsize=(12, 4)) ax[0].imshow(img); ax[0].set_title("原图") ax[1].imshow(mask_bin, cmap="gray"); ax[1].set_title("二值掩膜") ax[2].imshow(blended); ax[2].set_title("叠加") for a in ax: a.axis("off") plt.tight_layout(); plt.show() visualize(os.path.join(img_dir, imgs[0]), os.path.join(mask_dir, masks[0]))逻辑说明:convert("L")保证掩膜单通道;alpha控制叠加透明度,0.5 通常够看。若叠加后红色区域明显偏移,说明图像与掩膜尺寸不一致或存在配准问题,这类样本要单独挑出来。
3.2 批量网格预览与异常样本筛查
单张看效率低,批量拼网格能快速扫出异常。重点看三类:全黑掩膜、全白掩膜、叠加后边界明显错位。
def grid_preview(n=16, cols=4): idx = np.random.choice(len(imgs), min(n, len(imgs)), replace=False) rows = (len(idx) + cols - 1) // cols fig, axes = plt.subplots(rows, cols, figsize=(cols * 3, rows * 3)) for ax, i in zip(axes.ravel(), idx): img = np.array(Image.open(os.path.join(img_dir, imgs[i])).convert("RGB")) mask = np.array(Image.open(os.path.join(mask_dir, masks[i])).convert("L")) mask_bin = (mask > 127).astype(np.uint8) overlay = img.copy() overlay[mask_bin == 1] = [255, 0, 0] ax.imshow((img * 0.5 + overlay * 0.5).astype(np.uint8)) ax.set_title(imgs[i][:12], fontsize=8); ax.axis("off") for ax in axes.ravel()[len(idx):]: ax.axis("off") plt.tight_layout(); plt.show() grid_preview()参数说明:n是抽样数量,16 张够日常巡检;cols控制列数。若发现某张掩膜全黑,先查该文件是否损坏,再查是否像素值全为 0;全白同理。这类样本在训练中会拉低指标,建议先剔除或重新标注。
3.3 类别像素占比统计
2 类分割里,背景通常远多于舌体,类别不平衡会直接影响 loss 表现。先算全局占比,再决定要不要加权。
pos, total = 0, 0 for f in masks: m = np.array(Image.open(os.path.join(mask_dir, f)).convert("L")) pos += (m > 127).sum() total += m.size print("舌体像素占比: %.4f" % (pos / total))如果占比低于 0.1,训练时建议用 Dice Loss 或带权交叉熵,否则模型容易全预测背景也能拿到高准确率,指标好看但没用。这一步的数值直接决定你后面 loss 怎么选,别跳过。
4. 接入 unet 图像分割训练:从 Dataset 到第一个 epoch
4.1 自定义 Dataset 与同步增强
舌头分割数据量通常不大,增强是刚需。关键是图像和掩膜必须同步变换,否则标签就废了。下面用 albumentations 做同步增强。
import cv2 import torch from torch.utils.data import Dataset import albumentations as A from albumentations.pytorch import ToTensorV2 train_tf = A.Compose([ A.Resize(256, 256), A.HorizontalFlip(p=0.5), A.RandomBrightnessContrast(p=0.3), A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)), ToTensorV2(), ]) class TongueSeg(Dataset): def __init__(self, img_dir, mask_dir, files, tf): self.img_dir, self.mask_dir = img_dir, mask_dir self.files, self.tf = files, tf def __len__(self): return len(self.files) def __getitem__(self, i): name = self.files[i] img = cv2.imread(os.path.join(self.img_dir, name))[:, :, ::-1] mask = cv2.imread(os.path.join(self.mask_dir, name), 0) mask = (mask > 127).astype("float32") out = self.tf(image=img, mask=mask) return out["image"], out["mask"].unsqueeze(0)逻辑说明:cv2.imread读进来是 BGR,[:, :, ::-1]转 RGB;掩膜用灰度读,阈值化后转 float32。A.Compose同时接收 image 和 mask,保证几何变换一致。参数上,Resize(256,256)可按显存调整;Normalize用 ImageNet 均值方差,是分割任务的常规起点。
4.2 训练循环与 loss 选择
2 类分割常用 BCEWithLogitsLoss 或 Dice Loss。数据不平衡时,两者结合更稳。
import torch.nn as nn bce = nn.BCEWithLogitsLoss() def dice_loss(logits, targets, eps=1e-6): probs = torch.sigmoid(logits) num = 2 * (probs * targets).sum(dim=(2, 3)) den = probs.sum(dim=(2, 3)) + targets.sum(dim=(2, 3)) + eps return 1 - (num / den).mean() def criterion(logits, targets): return bce(logits, targets) + dice_loss(logits, targets)参数说明:eps防止除零;两项相加时若 Dice 波动大,可给 Dice 乘 0.5 权重。训练循环里记得optimizer.zero_grad()、loss.backward()、optimizer.step()三步齐全,别漏。第一个 epoch 后先看验证集 Dice,低于 0.5 通常说明标签映射或增强有问题,回去查第 2 章。
4.3 验证指标与可视化回查
训练中只看 loss 不够,2 类分割要看 Dice 和 IoU。每个 epoch 后在验证集上算一次,并抽几张预测图叠加回看。
@torch.no_grad() def evaluate(model, loader, device): model.eval() inter, union = 0, 0 for x, y in loader: x, y = x.to(device), y.to(device) pred = (torch.sigmoid(model(x)) > 0.5).float() inter += (pred * y).sum().item() union += (pred + y - pred * y).sum().item() return inter / (union + 1e-6) # IoU参数说明:阈值 0.5 是默认起点,若召回偏低可降到 0.4。IoU 低于 0.6 时,优先回查掩膜是否反色、图像掩膜是否错位,这两类问题在舌头分割里出现频率最高。
5. 舌头分割落地避坑:5 个真实翻车现场
5.1 掩膜反色导致模型学反
现象:训练 loss 下降但验证 Dice 一直 0.3 左右,预测图里舌体被标成背景。原因:部分掩膜像素值是 0/255,但语义与预期相反,或阈值化时把舌体判成背景。解决:用第 2.2 节的唯一像素值统计,逐张确认语义,必要时对调映射。
5.2 图像与掩膜尺寸不一致
现象:叠加可视化时红色区域整体偏移或缩放。原因:图像和掩膜在采集或后处理时被分别 resize,尺寸对不上。解决:训练前统一检查img.size == mask.size,不一致的样本要么重新对齐,要么剔除。
5.3 文件名不对齐导致静默丢样本
现象:训练集数量比预期少,但程序不报错。原因:Dataset 用文件名交集构建,孤儿文件被静默跳过。解决:第 2.1 节的交集检查必须跑,仅图像有和仅掩膜有都为 0 才继续。
5.4 增强不同步污染标签
现象:训练几个 epoch 后 Dice 突然掉。原因:图像做了翻转或旋转,掩膜没跟着变。解决:用 albumentations 的 Compose 同时传 image 和 mask,不要分别处理。
5.5 类别不平衡导致全背景预测
现象:准确率很高但 Dice 很低,预测图几乎全黑。原因:舌体像素占比过低,模型学到全预测背景。解决:先算占比,低于 0.1 时加 Dice Loss 或给正类加权,权重可设为1 / 占比量级。
6. 把 2 类舌头分割推到可用:阈值调优与边界后处理
训练跑通只是起点,真正决定落地效果的是推理阶段的阈值和后处理。2 类分割的边界往往毛糙,直接二值化会有零星噪点。我一般先扫一遍阈值,再决定要不要做形态学处理。
import numpy as np import cv2 def postprocess(prob, thr=0.5, kernel=3): mask = (prob > thr).astype(np.uint8) k = np.ones((kernel, kernel), np.uint8) mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, k) # 去噪点 mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k) # 补空洞 return mask阈值扫描可以这样写:在验证集上从 0.3 到 0.7 每隔 0.05 算一次 Dice,取最高点。参数上,kernel=3适合 256 尺寸;若原图是 512,可调到 5。开运算去孤立噪点,闭运算补内部空洞,顺序不要反,反了会把噪点连成片。
| 阈值 | 典型 Dice | 适用场景 |
|---|---|---|
| 0.3 | 召回高、边界偏大 | 舌体占比小、宁可多标 |
| 0.5 | 均衡 | 默认起点 |
| 0.7 | 精度高、边界偏紧 | 噪点多、要求干净 |
后处理不是必须,但舌头分割的边界通常需要它。我的习惯是:先不加后处理跑一版基线,再加上去对比 Dice 和视觉效果,只有提升明显才保留。曾经为了追求边界平滑把 kernel 调到 9,结果舌体边缘被啃掉一圈,Dice 反而掉了 4 个点,这个后悔药我吃过一次就够了。阈值和后处理都要用验证集说话,别凭肉眼调。希望帮到你。
本文还有配套的精品资源,点击获取