资讯详情

医学图像小数据集实战:从labelme清洗到分类与分割全流程

📅 2026/9/11 23:32:01 | 华诺云谱 👁 阅读
医学图像小数据集实战:从labelme清洗到分类与分割全流程
简介面向计算机视觉与医学图像处理研究者的深度学习数据集聚焦上睑下垂疾病的分类与分割包含眼睛和虹膜图像及配套标注。数据集按类别目录存放涵盖轻度、中度、严重和正常四类样本可直接用于训练分类模型每张图像均配有labelme生成的JSON分割标签可用于眼睛、虹膜区域的分割任务目录内还附有JSON类别文件方便核对四类标签映射关系由于数据按类别分目录组织学习者可自行划分训练集、验证集与测试集也可结合JSON标注进行实例分割或弱监督实验。资源包共1568个文件包含786张JPG图像与782个JSON标签整体大小约91.96MB压缩包结构清晰便于按需读取。图像来源于实际采集与人工标注与自动生成的合成数据相比具有更强的真实场景参考价值。目前已有267人学习下载适合需要实际医疗图像数据开展分类、分割实验的初学者和研究人员。1. 一张 780 张的眼部图像藏着两个任务做医学图像分类的人常被一句话劝退数据太少模型不收敛。这套眼睛、虹膜上睑下垂数据集恰好是那种最不好啃的规模——不到 800 张图4 个类别轻度、中度、严重、正常每张图还配了一份 labelme 的 JSON 分割标注。但我想先说一个反直觉的结论这种规模的数据恰恰适合跑通从数据清洗、分类训练到分割落地的完整流程而且能逼你把迁移学习、数据增强、交叉验证这些基本功用到极致。数据集中文件的命名方式值得留意。像5 OD 仝瑞.jpg这种格式OD 表示右眼Oculus DexterOS 是左眼Oculus Sinister后面跟着患者名字缩写。后缀a.jpg表示同一只眼的另一张采集视角。这种命名习惯来自临床采集不是随手写的——它意味着同一个患者同一只眼可能出现多张图像做训练集/验证集划分时必须按患者分组否则同源图像会泄漏到验证集里评估指标虚高。整个数据集包含分类和分割两条线分类标签可以直接用目录结构或 JSON 里的类别字段分割标注用 labelme 的多边形点坐标。我建议先别急着训练把 JSON 解析、Mask 生成、类别分布统计这三个环节做扎实后面所有模型的性能上限都取决于这几步。2. 先拆 labelme 标注数据清洗决定模型上限2.1 labelme JSON 的结构与解析逻辑labelme 导出的 JSON 文件本质是一个字典核心字段只有几个shapes存放所有标注对象每个对象有label类别名、points多边形顶点、shape_typepolygon / rectangle / circleimagePath指向对应的原图imageWidth和imageHeight记录尺寸。上睑下垂的分割目标通常是眼睑轮廓和虹膜区域points 少则几十个点多则上百个。解析的第一步是过滤无效标注。我在清洗时见过三种典型问题points少于 3 个点无法形成多边形label名称不一致比如同时出现normal和正常shape_type为空。这些都要在训练前统一处理。import json import numpy as np import cv2 def parse_labelme(json_path, img_dir): with open(json_path, r, encodingutf-8) as f: data json.load(f) img_path f{img_dir}/{data[imagePath]} img cv2.imread(img_path) h, w img.shape[:2] masks {} for shape in data[shapes]: label shape[label].strip() points np.array(shape[points], dtypenp.int32) if len(points) 3: print(f[跳过] {label} 点数不足: {len(points)}) continue mask np.zeros((h, w), dtypenp.uint8) cv2.fillPoly(mask, [points], 1) masks[label] mask return img, masks这段代码做了三件事读取 JSON、把多边形点坐标转成二进制 Mask、按类别名存成字典。fillPoly是 OpenCV 的填充函数点坐标必须是int32类型否则会报数据类型错误。如果你打开 JSON 发现某个标注的label值带空格或者大小写混用统一strip()再转小写避免同一个类别被拆成两个键。2.2 类别不平衡问题怎么量化781 张图是总数但 4 个类别的分布通常不是均匀的。正常、轻度、中度、严重四类的比例可能接近 40:15:25:20这种不平衡必须量化而不是靠感觉。统计方法不复杂把所有 JSON 的label字段汇总到 Counter 里同时按文件名前缀做患者级去重。提示上睑下垂的轻度案例在临床上往往在正常和中度之间模糊标注一致性通常偏低。如果你拿到的 JSON 里同一个患者的左右眼标注差异很大优先检查标注规范不要急着调模型。2.3 类别名不一致的修复策略真实数据集里类别名的混乱程度远超预期。同一份数据里可能出现ptosis_mild、mild、轻度三种写法指同一件事。做法是维护一个映射表在解析时统一替换。CATEGORY_MAP { 轻度: mild, mild: mild, ptosis_mild: mild, 中度: moderate, moderate: moderate, 严重: severe, severe: severe, 正常: normal, normal: normal }映射表的好处是审计方便每一轮清洗后把类别分布打出来核对确保没有漏网标签。这一步花 10 分钟省掉训练结束后才发现标签错乱的大麻烦。清洗后的数据建议存成统一的目录结构分类标签直接从父目录读取分割 Mask 单独存 npy 文件不再每次训练前临时解析。3. 分类任务落地迁移学习与小数据训练策略3.1 模型选型ResNet18 还是 EfficientNet-B0分类任务的注意力应该放在预训练权重和正则化上而不是堆模型深度。780 张图撑不起 ResNet50 这种大模型ResNet18和EfficientNet-B0是更务实的选择。ResNet18 结构简单、显存占用小、PyTorch 官方权重稳定EfficientNet-B0 理论上精度更高但对输入分辨率和数据增强更敏感小数据集上调参成本更大。我实际做医学图像小数据集时更倾向于 ResNet18理由只有一个快速迭代的稳定性。预训练权重的选择也很关键。ImageNet 预训练权重虽然领域不同但低层特征边缘、纹理、明暗过渡是通用的医学图像同样受益。torchvision.models.resnet18(weightsResNet18_Weights.IMAGENET1K_V1)是当前推荐写法相比旧版pretrainedTrue更明确。3.2 数据增强不能暴力要符合临床语义通用数据增强里的随机旋转、翻转、颜色抖动可以直接用但要限制幅度。眼睑下垂的严重程度判断依赖眼睑边缘的几何关系旋转角度超过 15 度会破坏这种关系。我用的增强组合是随机水平翻转眼睑左右对称临床成立、随机旋转限制在 ±10 度、轻微亮度对比度扰动。垂直翻转不建议开因为上下翻转会把上眼睑变成下眼睑语义上不成立。from torchvision import transforms train_transform transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(degrees10, fill0), transforms.ColorJitter(brightness0.2, contrast0.2), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) val_transform transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])RandomRotation的fill0参数容易忽略——旋转后图像边缘露出的区域默认填 0对应黑色。如果原图背景是白色或灰白色边缘会出现明显的黑边模型会学到「有黑边的就是某种类别」这种伪特征。医学图像建议根据背景主色调填写白色背景用fill255或者改成fill(int, int, int)配合 RGB 三通道。3.3 完整的训练循环与关键超参数分类训练的核心超参数不是一个固定的表格而是基于数据规模推导出来的。780 张图、按患者分组后训练集约 550 张batch size 用 16 比较合理太小收敛不稳定太大会让每个 epoch 的梯度更新次数太少。初始学习率从 1e-4 开始比 ImageNet 常规的 1e-3 低一个数量级——因为冻结的层比例低大部分参数都在更新学习率过高很容易在第一个 epoch 就把预训练权重破坏掉。训练时先冻结 backbone 训练分类头几个 epoch再解冻微调全部层这是小数据集的常见做法。import torch import torch.nn as nn from torchvision import models def get_model(num_classes4, freeze_backboneTrue): model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) model.fc nn.Linear(model.fc.in_features, num_classes) if freeze_backbone: for param in model.parameters(): param.requires_grad False for param in model.fc.parameters(): param.requires_grad True return model两个阶段微调的具体参数差别很大阶段一只训练全连接层学习率可以用 1e-3权重衰减 1e-4阶段二解冻所有层学习率降到 1e-5 到 3e-5 之间防止破坏低层特征。类别不均衡靠WeightedRandomSampler解决每个 batch 按类别权重采样比在 loss 里手动加权重更稳定。阶段一训练约 20 个 epoch 后验证集准确率通常会先升后平。这时候观察 loss 曲线是否还有下降空间——如果验证 loss 开始回升说明过拟合已经开始直接进阶段二而不是硬撑。3.4 按患者划分数据集的正确姿势这个数据集最容易被忽略的坑是按患者分组。5 OD 仝瑞.jpg和5 OD a.jpg是同一个患者同一只眼的两次采集如果一张在训练集一张在验证集模型的泛化能力会被严重高估——它记住的是患者特征而不是疾病特征。按患者分组不复杂但容易遗漏把文件名里的中文姓名提出来做 key确保同一个 key 的所有图进同一侧。import os import re from sklearn.model_selection import train_test_split def split_by_patient(img_paths, test_ratio0.3): patient_map {} for p in img_paths: base os.path.basename(p) m re.search(r([\u4e00-\u9fa5]), base) # 提取中文姓名 if m: patient_map.setdefault(m.group(), []).append(p) else: patient_map.setdefault(unknown, []).append(p) patients list(patient_map.keys()) train_pts, test_pts train_test_split( patients, test_sizetest_ratio, random_state42 ) train_paths [p for pt in train_pts for p in patient_map[pt]] test_paths [p for pt in test_pts for p in patient_map[pt]] return train_paths, test_pathsrandom_state42保证每次运行划分结果一致。正则表达式按中文姓名提取患者 ID处理不了就归到 unknown 类。划分完成后打印两边数量和患者数对比确认没有同类患者跨组。这一步做对了评估结果才值得信任。评估指标也要按医学小数据集的习惯来准确率只是起点上睑下垂分类更关心敏感度和特异性。四分类任务至少输出混淆矩阵看看误分类集中出现在哪两个相邻类别——通常轻度和中度最容易搞混这在临床上也真实存在。from sklearn.metrics import classification_report, confusion_matrix y_true [...] y_pred [...] print(classification_report(y_true, y_pred, target_names[normal, mild, moderate, severe])) print(confusion_matrix(y_true, y_pred))classification_report会输出每个类别的 precision、recall、F1-score。如果 severe 的 recall 很高但 precision 很低说明模型倾向把所有严重案例都判成重度——这在临床上不是坏事但需要根据实际场景调整决策阈值。4. 分割任务U-Net 结构解析与训练细节4.1 从分类到分割为什么值得用同一个数据集做两个任务上睑下垂的临床评估依赖两个量化指标眼睑边缘到瞳孔中心的距离以及虹膜暴露率。前者需要精准的虹膜区域分割后者本质上就是分割后计算面积比。这也是这套数据集的真实价值——同一个 labelme 标注可以同时产出分类和分割两个模型在真实诊疗流程中分割模型的输出往往比单纯的分类更有决策意义。U-Net 在这个场景下依然是首选不是因为新潮而是因为 780 张图规模的医学分割任务里U-Net 的对称编码解码结构配合跳跃连接在有限数据下的收敛速度和稳定性都优于 Transformer 系模型。4.2 U-Net 核心代码不要过度封装U-Net 的实现并不需要复杂抽象两个卷积块、一条编码路径、一条解码路径、四条跳跃连接代码控制在 100 行左右足够。import torch import torch.nn as nn class DoubleConv(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue) ) def forward(self, x): return self.conv(x) class UNet(nn.Module): def __init__(self, in_channels3, num_classes2): super().__init__() self.enc1 DoubleConv(in_channels, 64) self.enc2 DoubleConv(64, 128) self.enc3 DoubleConv(128, 256) self.enc4 DoubleConv(256, 512) self.pool nn.MaxPool2d(2) self.bottleneck DoubleConv(512, 1024) self.up4 nn.ConvTranspose2d(1024, 512, 2, stride2) self.dec4 DoubleConv(1024, 512) self.up3 nn.ConvTranspose2d(512, 256, 2, stride2) self.dec3 DoubleConv(512, 256) self.up2 nn.ConvTranspose2d(256, 128, 2, stride2) self.dec2 DoubleConv(256, 128) self.up1 nn.ConvTranspose2d(128, 64, 2, stride2) self.dec1 DoubleConv(128, 64) self.out nn.Conv2d(64, num_classes, 1) def forward(self, x): e1 self.enc1(x) e2 self.enc2(self.pool(e1)) e3 self.enc3(self.pool(e2)) e4 self.enc4(self.pool(e3)) b self.bottleneck(self.pool(e4)) d4 self.dec4(torch.cat([self.up4(b), e4], dim1)) d3 self.dec3(torch.cat([self.up3(d4), e3], dim1)) d2 self.dec2(torch.cat([self.up2(d3), e2], dim1)) d1 self.dec1(torch.cat([self.up1(d2), e1], dim1)) return self.out(d1)核心逻辑在跳跃连接处torch.cat([self.up4(b), e4], dim1)把上采样后的特征图与编码器同层特征在通道维拼接让解码器同时拥有高分辨率细节和深层语义信息。dim1对应 NCHW 格式的通道维拼错维度会直接报 size mismatch。num_classes2表示只分割虹膜和背景如果要把眼睑轮廓也分割出来改成 3 并确保 JSON 里包含对应类别。4.3 分割损失函数的选择与组合分割任务的损失函数选择比模型结构更影响结果。BCEWithLogitsLoss在类别极度不平衡时容易让模型倾向预测背景DiceLoss对小目标更友好但训练初期梯度不稳定。常见做法是两者组合DiceLoss BCE权重比例 1:1 或 2:1。import torch.nn.functional as F class DiceBCELoss(nn.Module): def __init__(self, weight_dice1.0, weight_bce1.0): super().__init__() self.weight_dice weight_dice self.weight_bce weight_bce def forward(self, pred, target): bce F.binary_cross_entropy_with_logits(pred, target) pred_sigmoid torch.sigmoid(pred) smooth 1.0 intersection (pred_sigmoid * target).sum() dice 1 - (2.0 * intersection smooth) / (pred_sigmoid.sum() target.sum() smooth) return self.weight_bce * bce self.weight_dice * diceDice 系数的计算有个重要细节intersection用的是逐元素相乘再求和而不是torch.logical_and因为需要保留梯度进行反向传播。smooth参数防除零通常取 1.0。训练时务必将pred和target的尺寸对齐U-Net 输出尺寸与输入相等但Resize操作可能引入半个像素的偏移验证时要做尺寸校验。4.4 分割训练的数据加载与参数配置分割模型的数据加载与分类不同需要同步返回图像和对应的 Mask。Mask 从清洗阶段保存的 npy 文件直接读取避免每次训练前重新解析 JSON。输入分辨率推荐 256×256 或 512×512显存不够就降分辨率而不是减 batch size——分割任务对空间细节敏感。训练参数方面batch size 8 起步学习率 1e-4 配Adam优化器权重衰减 1e-5 到 1e-4。医学分割通常不需要太强的数据增强随机水平翻转和轻微的弹性变形ElasticTransform就够更强的增强反而会造成目标形态失真。提示验证分割效果时IoU 比准确率更有参考价值。二分类分割的 IoU 计算需要先对预测概率做阈值处理默认 0.5并用np.sum(pred target) / np.sum(pred | target)计算。如果 IoU 低于 0.6先检查 Mask 是否与图像对齐——这是分割任务最常见的失败原因尤其是 labelme 标注在标注过程中改变了图像尺寸时。5. 分类分割联合应用与模型验证技巧5.1 分割辅助分类不只是一个炫技方向有了分割模型分类模型的信息瓶颈可以被打破。上睑下垂的分级标准本质上与解剖学度量强相关比如边缘反射距离MRD1是临床金标准指标之一。一个实用的做法是先把虹膜区域分割出来再计算其在图像中的面积占比和垂直位置用这些数值作为分类模型输入的特征。def extract_clinical_features(mask, eye_height): mask_bool mask 0.5 rows np.any(mask_bool, axis1) cols np.any(mask_bool, axis0) top np.argmax(rows) if rows.any() else 0 bottom len(rows) - np.argmax(rows[::-1]) if rows.any() else 0 iris_height bottom - top ratio iris_height / eye_height centroid_y (top bottom) / 2 / eye_height return np.array([ratio, centroid_y])np.any(mask, axis1)按行压缩得到每一行是否有目标像素argmax找到第一个 True 的位置反转后再argmax可以定位最后一个 True。iris_height是虹膜区域的像素高度eye_height是整张图的高度两者相除得到归一化后的比例——这是一个对图像绝对尺寸不敏感的稳定特征。把[ratio, centroid_y]与 ResNet18 倒数第二层的特征拼接在一起比单独用分类模型更稳。这种做法的本质是让模型同时看到全局语义特征和细粒度解剖测量值对有标注噪声的数据尤其有效。5.2 五折交叉验证小数据集的标准操作780 张图做一次单次划分不够可靠。五折交叉验证把数据分成 5 份轮流取 1 份做验证集其余 4 份训练最终报告 5 次验证结果的均值和标准差。但交叉验证的代码必须基于患者级别划分不是文件级别。每次折的训练约 440 张图验证约 110 张。如果某个患者的图被分到不同的折模型相当于在训练时见过这个患者的另一只眼——这不利于真实评估。折数不是越多越好数据量小的时候五折或三折就够十折会让每折训练集过小。做完五折后均值 ± 标准差比单次划分的结果有说服力得多。如果 5 折里有一折结果远低于其他折不要急着调参先检查这一折里是否恰好包含了最多轻度案例——这种偶然性在小数据集里经常发生。5.3 用 SHAP 做单样本诊断分类模型的决策依据需要被验证——它学的是眼睑边缘形态还是背景亮度SHAP值的像素级可视化能回答这个问题。一个快速的做法是使用GradientShap为每一张验证图生成热力图红色区域表示推向该类别预测的区域蓝色区域表示反向推离。如果发热区域集中在上眼睑边缘而非虹膜内部说明模型的决策基础与临床认知一致如果高热区域散布在图像四个角落说明模型在利用传感器暗角或拍摄背景这种混淆特征这时要考虑添加更强的背景扰动增强。import shap def explain_prediction(model, input_tensor, device): model.eval() input_tensor input_tensor.unsqueeze(0).to(device) input_tensor.requires_grad True background torch.zeros_like(input_tensor).to(device) explainer shap.GradientExplainer(model, background) shap_values explainer.shap_values(input_tensor, nsamples50) return shap_valuesGradientExplainer的background参数不能乱填它作为与真正输入对比的基线医学图像通常用纯黑张量作为背景表示「没有信息输入」。nsamples控制采样次数值越大结果越稳定但耗时更长50 次对单张图够用。如果报维度错误检查输入张量的 batch 维是否存在。上睑下垂分类还有一个更直接的验证技巧只保留分割出的上眼睑区域去掉虹膜和睫毛噪点喂给分类模型。如果验证集准确率没有明显下降说明模型学的确实是关键解剖结构而不是背景特征。这个方法操作成本极低却是评估模型可信度最直观的手段。最后说一下多任务联合策略分割模型先产出上眼睑边缘的坐标点和虹膜区域中心点分类模型把这两个数值拼接到特征向量尾部能同时提升分类精度和抗干扰能力。ioU验证分割模型质量F1-score验证分类模型质量两个指标一起看既能定位分割边缘的偏移问题也能定位分类特征提取的短板。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。