ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

手术器械语义分割实战:1200张数据集训练与避坑指南

手术器械语义分割实战:1200张数据集训练与避坑指南 简介这套手术器械语义分割数据集面向医学图像分析与深度学习研究者涵盖32个类别已按训练集约860张与验证集约370张划分完毕每张原图均配有对应mask标注整体约1200张处理好的图片对可用于医学手术场景下的器械识别与分割模型训练评估尤其适配UNet、Swin-UNet、TransUNet及改进网络。资源共2000个文件以1236个png图像与762个jpg图片为主另有1个txt说明与1个Python可视化脚本压缩包约80.79MB。可视化脚本支持随机抽取样本将原始图、GT标签及GT叠加蒙版同屏展示并保存便于快速核查标注质量。目前已有104人浏览学习适合具备一定深度学习基础、希望开展医学图像分割实验或复现相关论文的开发者入手。整体目录按训练/验证分置结构清晰可省去数据整理与手工标注环节直接聚焦网络设计与性能调优。1. 手术器械语义分割数据集1200张已标注图多类别分割到底能做什么一份「医学图像分割数据集」如果只有 1200 张标注图乍看不算大。但你要知道手术器械图像语义分割这个方向和自动驾驶不一样公开的干净数据本来就少绝大多数开源集合是几万张通用照片里挑几个物体而内镜画面里那几把器械反光、遮挡、血液污染随便一个因素就能让通用分割模型翻车。这份数据集的卖点在于「已处理」配对完整、标签编码统一拿到手可以跳过清洗阶段直接把多类别图像分割的模型训练跑起来。适合正在做医学图像分割、手术导航、器械追迹或者想从通用分割转医学方向的人用它先把 D1 基线立住再谈精度。2. 数据集的真实构成与标签约定先搞清mask怎么编码再动手2.1 目录结构常见三种摆法先扫一遍再写代码我拿到这类「已处理」数据集第一件事不是写模型而是打开文件树看结构。手术器械数据集在交付时通常有三种摆法组织方式目录示意影响全平铺images/masks/两边同名文件一一对应需要自己按比例划分训练集和验证集按域划分train/images train/masks、val/...、test/...直接用但先确认划分比例是否合理带split文件images/ masks/ train.txt val.txt划分以txt为准images里可能有多余样本不要默认是第二种。我见过不少人拿到平铺结构就直接按os.listdir前 80% 当训练集结果同一台手术的连续帧被切进训练和验证指标虚高等到换新场景立刻打回原形。合理的划分应该是按样本来源或者时间戳切保证验证集和训练集不存在连续帧关系。另外一定要先统计两边文件数量是否一致。执行一条diff (ls images | sed s/\.jpg//) (ls masks | sed s/\.png//)看看有没有名字对不上。对不上的直接剔除不要补零或者跳过补进去的坏样本只会让分割边界学歪。2.2 多类别标签的三种编码方式识别不对后面全白做手术器械图像语义分割的 mask 编码方式常见的有三套。这是「已处理」数据集最需要确认的地方也是新手最容易翻车的地方单通道灰度 PNG像素值直接等于类别 ID背景是 0第一类器械是 1第二类是 2。这是 PyTorch 默认的torch.long标签格式最省事。RGB 彩色 PNGVOC 风格每个类别对应一个固定的 RGB 三元组比如背景是(0,0,0)、钳子是(128,0,0)。读取之后需要按颜色映射表转成 index 图。单通道灰度但值域是 0 和 255某些标注工具导出时把「是否目标」存成了二值图多类别信息丢失拿到手要仔细看 unique 值。判断方法很简单读取一张 mask 打印np.unique(arr)。如果值域是[0, 1, 2, 3]直接用。如果是 0 到 255 一大堆数字说明是灰度图压缩过的标签乘以某个系数也没规律八成是导错了需要找来源确认。提示如果数据集的类别说明文件里写了「0 代表背景1 代表钳类2 代表剪类3 代表镊类」这类描述以它为准。没有说明文件时逐个类别可视化确认别只看 unique 值就开工。2.3 「已处理」到底处理了什么三个常见动作值得验证标题里写的「已处理」在我实际接触的手术器械类数据里通常包含这几种前置动作剔除了无法配对的图像和 mask统一了所有 mask 的编码规范有的还裁剪掉了画面边缘的无意义黑色区域。这三个动作都是好消息但你不能全信至少要做两项验证。第一个验证是抽查尺寸。图像和 mask 是不是同分辨率直接决定预处理脚本怎么写。手术影像来源可能是内镜、腹腔镜或者模拟器截图混合在一起时长边不一致很正常。第二个验证是看类别分布是否平衡。手术器械分割里背景像素常常占 85% 以上而三类器械加起来可能只有 15%。如果你发现某类器械只出现在 30 张图里后面要给损失函数加权重否则这类器械基本学不出来。把这两项写进一个几行的检查脚本里比急着训练划算得多。3. 加载与预处理把1200张数据做成能直接训练的Dataset类3.1 写一个兼容三种mask编码的Dataset一次跑通不返工数据集的编码我已经在第 2 章讲透了现在直接写一个能同时兼容三种编码的 PyTorch Dataset。核心思路是读取之后做两步归一化把 RGB mask 转成 index 图把 0/255 的二值编码压成 0/1最后统一转成torch.long。import torch from torch.utils.data import Dataset from PIL import Image import numpy as np import os class SurgicalDataset(Dataset): def __init__(self, image_dir, mask_dir, size(512, 512), color_mapNone): self.pairs [] for fname in sorted(os.listdir(image_dir)): if not fname.lower().endswith((.jpg, .jpeg, .png)): continue img_path os.path.join(image_dir, fname) mask_path os.path.join(mask_dir, self._mask_name(fname)) if os.path.exists(mask_path): # 只保留有标签的样本 self.pairs.append((img_path, mask_path)) self.size size self.color_map color_map def _mask_name(self, img_name): # 兼容 jpg - png 的扩展名差异其它情况按同名处理 return os.path.splitext(img_name)[0] .png def __len__(self): return len(self.pairs) def __getitem__(self, idx): img_path, mask_path self.pairs[idx] img Image.open(img_path).convert(RGB) # 统一三通道 mask Image.open(mask_path) arr np.array(mask) if arr.ndim 3: # RGB mask按颜色映射表逐通道匹配转成 index arr self._rgb_to_index(arr) elif arr.max() 1: # 0/255 编码或其它灰度值先压到 0..num_classes arr (arr / 255.0).astype(np.uint8) # 统一尺寸图像用双线性mask 用最近邻避免边界被插值糊掉 img img.resize(self.size, Image.BILINEAR) mask Image.fromarray(arr, modeL).resize(self.size, Image.NEAREST) img_np np.array(img, dtypenp.float32) / 255.0 mask_np np.array(mask, dtypenp.int64) img_t torch.from_numpy(img_np.transpose(2, 0, 1)) mask_t torch.from_numpy(mask_np) return img_t, mask_t这段代码有三个关键点。第一_mask_name里把jpg和png差异消化掉不然后续会莫名丢样本第二resize 时图像用BILINEAR、mask 强制用NEARESTmask 一旦用了线性插值边缘会出现类与类之间的灰色过渡带训练时模型会对边界无所适从第三所有 mask 最后转成int64正好匹配 PyTorchCrossEntropyLoss对标签的要求。color_map参数在 mask 是 RGB 时用来做类别映射具体映射表从数据集说明文件读取。这个类写完之后用一个简单的循环把 1200 张数据全部过一遍打印每张的尺寸和 unique 值确认没有维数不一致的样本再进训练。3.2 数据增强图像和标签必须走同一条变换手术器械图像有几个很现实的特点器械朝向不固定镜头偶尔会上下颠倒亮度因为内镜光源摆动忽明忽暗。这些都可以通过增强来模拟。我一般使用albumentations因为它能保证同样的随机参数同时作用在图像和 mask 上。import albumentations as A train_transform A.Compose([ A.HorizontalFlip(p0.5), A.VerticalFlip(p0.1), # 内镜画面偶尔颠倒但不要给太强 A.Affine(scale(0.9, 1.15), rotate(-15, 15), p0.6), A.RandomBrightnessContrast( brightness_limit0.2, contrast_limit0.2, p0.5), A.Resize(512, 512), A.Normalize(mean(0.485, 0.456, 0.406), std(0.229, 0.224, 0.225)), ]) val_transform A.Compose([ A.Resize(512, 512), A.Normalize(mean(0.485, 0.456, 0.406), std(0.229, 0.224, 0.225)), ])albumentations的Compose有一个默认行为is_check_shapesTrue时会自动检查图像和 mask 的尺寸是否一致不一致直接抛错这比 PyTorch 里默默错位好得多。调用时把图像传进image把 mask 传进mask同一个随机种子会同时作用于两者所以翻转和旋转之后像素级对应关系不会断。有几个增强要避开。RandomGamma可以小幅用但ElasticTransform和GridDistortion我不建议因为器械形状是刚性物体弹性形变会把钳子的关节扭曲成不真实的形状模型学到的形状先验会被污染。对这类数据空间变换里旋转和平移就够用色彩变换控制在亮度对比度范围内过度做颜色抖动会让模型对真实内镜的色调变化过于敏感。3.3 加载后第一件事统计类别占比数据增强做完趁训练还没开始先统计一遍类别分布。这个统计决定了后面损失函数的写法。from collections import Counter cnt Counter() for _, mask_path in dataset.pairs: arr np.array(Image.open(mask_path)) for val in np.unique(arr): cnt[val] int((arr val).sum()) print(cnt)输出的结果通常是{0: 850000, 1: 52000, 2: 18000, 3: 12000}这种形态。背景占了绝对多数三类器械之间差了好几倍。拿到这个分布之后第 4 章的类别权重就有了依据。别跳过这一步你后面看训练过程会发现哪类权重低哪类的 IoU 就吊车尾。4. 训练多类别语义分割模型从U-Net选型到损失函数配置4.1 模型选型小数据集先跑U-Net别急着上大模型1200 张的训练规模模型选型上有两个常见路线经典 U-Net 和 DeepLabV3。在这个数据量下我强烈建议先跑 U-Net理由有两个。第一U-Net 的跳跃连接对小目标特别友好。手术器械在画面里往往只占很小一块而且边界锐利FPN 或者 DeepLab 系列的 ASPP 模块更擅长捕捉多尺度上下文但细节恢复能力不如 U-Net 的 skip connection。第二U-Net 结构简单可控参数少1200 张图的规模下不容易过拟合。DeepLabV3 的 ResNet101 骨干即使加载 ImageNet 预训练微调时对学习率也更敏感跑起来要调的东西多一圈。如果一定要用 DeepLabV3用 ResNet50 骨干而不是 ResNet101。医学图像分割数据集普遍不大ResNet101 在 1200 张图上不仅训练慢验证集指标的方差也会明显变大——同一次训练不同随机种子可能差出两三个点 mIoU这个方差会干扰你对模型改进的判断。架构上还有一个实用建议把编码器换成带 ImageNet 预训练权重的版本。你用的是segmentation_models_pytorch还是timm里的编码器都行关键是预训练权重。手术器械图像的底层纹理虽然有特殊性但边缘、颜色、光照这些基础特征和 ImageNet 是共享的从零训练在 1200 张数据上几乎必挂。4.2 损失函数Dice和CrossEntropy怎么配类别权重怎么加多类别语义分割的默认选择是CrossEntropyLoss但在这类高度不平衡的数据上光用交叉熵会出现一个问题背景像素占绝对多数模型把全部像素预测为背景就能把 loss 压得很低器械类别完全学不出来。常见的做法是加一个 Dice loss 分支它在类别维度上独立计算天然对像素频率不敏感。import torch import torch.nn as nn import torch.nn.functional as F class SoftDiceLoss(nn.Module): def __init__(self, num_classes, smooth1e-6): super().__init__() self.num_classes num_classes self.smooth smooth def forward(self, logits, target): probs torch.softmax(logits, dim1) # [B, C, H, W] target_onehot F.one_hot(target, self.num_classes) target_onehot target_onehot.permute(0, 3, 1, 2).float() intersection (probs * target_onehot).sum(dim(2, 3)) union probs.sum(dim(2, 3)) target_onehot.sum(dim(2, 3)) dice (2 * intersection self.smooth) / (union self.smooth) return 1 - dice.mean()这个实现用的是 soft Dice也就是直接在 softmax 输出的概率上计算而不是把预测转成硬标签再算。smooth 参数取 1e-6 就够它的作用是防止某类在 batch 里完全不出现时除零。训练时把两个损失加起来ce_weight torch.tensor([0.1, 1.0, 2.0, 3.0]) # 按背景占比反推 loss F.cross_entropy(logits, target, weightce_weight) loss 0.5 * dice_loss(logits, target)权重设置有个经验值背景权重 0.1器械类别按第 3.3 节统计的占比倒数归一化。Dice 部分的系数 0.5 是常规取值它给模型一个「关注小目标」的梯度方向但权重太大会让训练初期的 loss 震荡明显因为 Dice 对预测的置信度非常敏感。4.3 训练参数与早停1200张数据的稳定训练骨架训练脚本骨架我一般这样写model smp.Unet( encoder_nameresnet34, encoder_weightsimagenet, in_channels3, num_classesnum_classes, ) optimizer torch.optim.AdamW(model.parameters(), lr1e-4, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max50, eta_min1e-6) best_miou 0.0 patience 15 for epoch in range(200): train_one_epoch(model, train_loader, optimizer, criterion) miou, _ validate(model, val_loader) if miou best_miou: torch.save(model.state_dict(), ./best_surgical.pth) best_miou miou patience 15 else: patience - 1 if patience 0: break几个参数值得说清楚。batch_size在 512×512 输入下取 8 到 16显存不够时优先把batch_size降到 4而不是缩小输入分辨率——器械本身小分辨率再砍 IoU 会断崖下跌。学习率 1e-4 是 AdamW 在分割任务上比较稳的起点编码器部分如果也想控制可以单独给model.encoder设 5e-5。patience取 15 的原因是 1200 张的数据量噪声大验证集 mIoU 上下波动 2 个点很常见patience 太小会早停在半山腰。训练过程里还要盯一个东西每个类别单独的 IoU而不是只看 mIoU。常见情况是背景 IoU 到了 98%把整体 mIoU 拉得很高但器械类别的 IoU 只有 40%。在验证脚本里把per_class_iou打出来哪个类别低于 50%直接对应去看它的预测 mask 是怎么错的。5. 手术器械分割的5个常见坑从标签编码到过拟合的血泪排查5.1 训练loss降了但mask全黑mIoU虚高现象训练过程 loss 曲线很平滑地下降验证集 mIoU 看起来有 80% 多但把预测结果可视化出来整张 mask 几乎全黑只有零星几个像素是非背景类。原因类别极度不平衡时模型学成了背景分类器。背景占 85% 以上CrossEntropyLoss 只要把全部像素预测成背景loss 就能降到很低。mIoU 虚高是因为背景类别在混淆矩阵里占了绝对主导平均值被它带起来了。解决换用交叉熵加 Dice 的混合损失并按第 3.3 节的类别统计给交叉熵部分加权重。权重计算直接用「1 / 该类别像素占比」再做一次归一化别手写。另外验证时把非背景类别的 mIoU 单独算一个数字背景类别 IoU 再高也不参与这个数字这个指标才是训练时该盯的。5.2 mask读出来全是0和255模型根本学不了多类别现象打印np.unique(mask)得到[0, 255]模型输出永远只有两类。原因交付的 mask 是二值图或者标注工具导出时把多类别压缩成了 0/255 的 PNG。这种情况下原始类别信息可能已经丢失不一定能靠代码补救。解决先确认原始来源有没有保留类别 ID 版本的标签。如果只有 0/255 的图检查数据目录里有没有label_info.json或class_dict.csv。都没有的话这个数据集只能退化成「器械 vs 背景」二分类任务多类别分割的预期就得调整。以后拿到任何「已处理」数据集先跑这段 unique 检查再决定要不要继续投入。5.3 验证集mIoU忽高忽低同一份代码跑两次差3个点现象固定随机种子两次训练出来的验证集 mIoU 仍然差 3 个百分点无法判断改动到底是好是坏。原因1200 张数据本身太少训练集和验证集的划分方式直接影响分布。更隐蔽的原因是验证集只有 120 到 240 张某些类别的器械只出现在其中 10 张图里随机采样一波动这部分 IoU 就剧烈震荡。解决第一按第 2.1 节说的划分时要避免连续帧泄漏最好按来源分组切分。第二用 5 折交叉验证评估模型每折跑完取平均。第三在验证集上计算每个类别的样本覆盖率如果某个类别在验证集里出现次数少于 20 张就把划分方案重做。最后一招是固定验证集文件列表每次训练读同一个val.txt保证对比口径一致。5.4 resize之后器械全部变形边界对不上现象训练集和验证集精度都上不去把数据可视化一看器械形状明显被拉扁或者拉长标注边界和真实边界错位。原因直接用了A.Resize(512, 512)把不同宽高比的图像强行拉伸。手术画面里器械靠近镜头时占据大半个画面远离时只有指甲盖大小统一拉伸后空间关系被严重破坏。解决改成保持宽高比的预处理短边缩放到 512长边等比缩放后裁剪或 padding。常用的做法是 pad 到正方形用 0 填充边缘同时记录 padding 的偏移量推理时把结果裁回来。如果你看到数据集的原始分辨率是 1280×720 这种宽屏优先用短边缩放加中心裁剪不要硬拉。5.5 训练loss一路走低验证集却原地踏步过拟合没跑了现象训练集 Dice 到 0.95验证集 0.65差距越来越大。原因1200 张对深度学习分割模型来说偏少模型把训练集的纹理细节背下来了没有泛化到新的器械摆放角度和光照条件下。解决按优先级做三件事。第一增强里加上第 3.2 节的旋转和 scale 扰动让模型对形状变化鲁棒。第二编码器加载 ImageNet 预训练少学一些底层纹理特征多保留高层语义。第三用 5 折交叉验证选出稳定性最好的那一折作为最终模型而不是单纯盯着最后一轮的验证集指标。如果过拟合仍然严重考虑用 image size 更小的输入跑一遍对比实验很多情况下模型是记住了高频细节降分辨率反而提升泛化。6. 验证与交付mIoU按类别拆开看可视化存成档案模型训练完不要只看一个总 mIoU 就交付。手术器械分割的落地场景对漏检的容忍度很低如果某个器械类别比如剪类IoU 只有 45%总 mIoU 再好看也说明这个模型还不能用。我习惯把评估结果拆成一张按类别排列的表格类别IoUDice验证集出现次数背景0.9810.990全部钳类0.7420.852156剪类0.5830.73664镊类0.4010.57228哪类出现次数少且 IoU 低先补数据还是先调参从这张表一眼就能判断。镊类只出现 28 次IoU 0.4 并不意外这时给它再多的损失权重也只是让模型记住几十个样本的形态真正该做的是标注更多数据。评估代码我一般会用混淆矩阵的形式把每个类别的 IoU 算出来方便后面做错误分析def per_class_iou(model, loader, num_classes): model.eval() conf np.zeros((num_classes, num_classes), dtypenp.int64) with torch.no_grad(): for img, mask in loader: pred model(img.to(device)).argmax(dim1).cpu().numpy() mask mask.numpy() for p, t in zip(pred.ravel(), mask.ravel()): if t 0 and t num_classes: conf[t, p] 1 iou np.diag(conf) / (conf.sum(1) conf.sum(0) - np.diag(conf) 1e-6) return iou def visualize_errors(model, loader, save_dir): # 把预测错分区域的轮廓画在原图上单独存一个目录 ...可视化这一步不要省。把预测 mask 和真实 mask 叠加错分区域用红色轮廓画在原始图像上存成一张张对比图。我看到过不少翻车场景单看 mIoU 一切正常可视化之后才发现模型把器械和血液反光的边界搞混了——这类错误只有人眼能看出来指标是体现不了的。保存时按 epoch 和类别分目录整理调参时往回翻比翻训练日志高效得多。我自己的习惯是每次实验至少保存三个东西权重文件、按类别的评估结果 CSV、一版带错误可视化的 HTML 图集。图集会随时间积累每个改动对边界质量的影响一眼可查。图像分割这种任务边界是设备端最直接能感知到的指标mIoU 是给报告看的边界贴不贴合才是给临床看的。早期我只看整体指标结果换场景后器械边缘明显偏粗挨过几次教训之后可视化档案就成了固定动作。希望这个顺序对你有参考价值数据量越小的项目越值得在这上面花时间。本文还有配套的精品资源点击获取
返回列表