简介:本资源为面向医学影像AI研究者与计算机视觉开发者的肝脏超声图像数据集,聚焦肝病智能识别与分割任务,适用于图像分类、目标检测及语义分割等深度学习模型的训练与验证。数据集共2000个文件,全部为结构化JSON格式标注文件,完整对应超声图像的肝脏轮廓与病灶区域(含良性、恶性、正常三类标签),支撑从数据预处理、模型训练到评估分析的全流程开发。压缩包总大小66.65MB,含Benign.zip、Malignant.zip、Normal.zip三个子集,分别覆盖临床典型病理场景,标注规范统一,可直接用于PyTorch/TensorFlow框架下的数据加载与训练。目前已有201人学习下载,资源附DOI号(10.5281/zenodo.7272660)并由多位医学影像领域研究者联合构建,具备科研可复现性与教学示范价值,特别适合开展肝癌辅助诊断算法研发、医学影像课程实验设计及跨模态模型迁移研究。
1. 肝脏超声图像数据集不是“带标签的图库”,而是面向临床推理闭环的结构化医学视觉基座
很多刚接触医学影像的新手会把这份肝脏超声数据集当成普通分类数据集——扔进ResNet训个三分类,指标不错就收工。但实际拆开看,它根本不是为“单图判别”设计的:172.json、205.json这些文件名背后,是每张图像对应一份结构化标注JSON,里面同时包含肝脏整体轮廓(polygon)、病灶区域掩膜(instance mask)、以及按临床逻辑分层的语义标签(normal/benign/malignant + 质量区域存在性布尔值)。这意味着模型必须能联合建模器官解剖结构与病灶空间关系——比如恶性病灶常位于肝右叶后段,而良性囊肿多见于左外叶,这种空间先验无法靠ImageNet预训练注入。它真正适合的是构建“分割→定位→分类”三级推理链的系统,尤其适配U-Net+Transformer双路径架构或Mask R-CNN类实例感知模型。如果你正做肝癌早筛算法落地、超声报告自动生成,或需要向放射科医生解释模型决策依据(如高亮恶性区域并标注其与肝中静脉的空间距离),这个数据集提供的多粒度标注才是关键资产。
2. 从原始JSON解析到PyTorch Dataset:构建支持分割+分类双任务的数据加载器
2.1 标注JSON结构深度解析与字段映射逻辑
每个.json文件并非简单标注,而是遵循COCO格式扩展的医学语义结构。以127.json为例,核心字段包括:
{ "image_id": 127, "file_name": "127.jpg", "height": 512, "width": 768, "liver_contour": [[x1,y1],[x2,y2],...], // 闭合多边形,单位像素 "lesion_masks": [ { "type": "cyst", "bbox": [x,y,w,h], "segmentation": [[x1,y1,x2,y2,...]], // COCO RLE编码或polygon "malignancy_score": 0.82 // 连续值,非离散标签 } ], "diagnosis": "malignant", "quality_region": [[x1,y1],[x2,y2],...] // 图像质量有效区域(排除探头伪影区) }注意:
lesion_masks数组可能为空(正常样本),也可能含多个病灶;malignancy_score是放射科医生对病灶恶性概率的量化评估,需在损失函数中设计回归分支;quality_region用于裁剪有效视场,避免模型学习到探头边缘噪声。
2.2 构建支持多任务的CustomDataset类
以下代码实现同时输出图像、肝脏分割掩膜、病灶实例掩膜、诊断标签及质量区域掩膜的四通道标注:
import json import numpy as np from PIL import Image import torch from torch.utils.data import Dataset from pycocotools.mask import decode as rle_decode class LiverUSDataset(Dataset): def __init__(self, root_dir, split='train', transform=None): self.root_dir = root_dir self.split = split self.transform = transform # 加载所有JSON文件路径 self.json_files = sorted([f for f in os.listdir(root_dir) if f.endswith('.json')]) # 定义类别映射(三分类) self.class_map = {'normal': 0, 'benign': 1, 'malignant': 2} def __len__(self): return len(self.json_files) def __getitem__(self, idx): json_path = os.path.join(self.root_dir, self.json_files[idx]) with open(json_path, 'r') as f: ann = json.load(f) # 加载图像 img_path = os.path.join(self.root_dir, ann['file_name']) image = np.array(Image.open(img_path).convert('RGB')) # 初始化掩膜(H x W) liver_mask = np.zeros((ann['height'], ann['width']), dtype=np.uint8) lesion_mask = np.zeros((ann['height'], ann['width']), dtype=np.uint8) quality_mask = np.zeros((ann['height'], ann['width']), dtype=np.uint8) # 绘制肝脏轮廓(多边形填充) if 'liver_contour' in ann and ann['liver_contour']: poly = np.array(ann['liver_contour'], dtype=np.int32) cv2.fillPoly(liver_mask, [poly], 1) # 绘制病灶掩膜(多实例叠加) if 'lesion_masks' in ann: for lesion in ann['lesion_masks']: if 'segmentation' in lesion: # 支持polygon或RLE格式 seg = lesion['segmentation'] if isinstance(seg[0], list): # polygon poly = np.array(seg[0], dtype=np.int32).reshape(-1, 2) cv2.fillPoly(lesion_mask, [poly], 1) else: # RLE rle = {'size': [ann['height'], ann['width']], 'counts': seg} rle_mask = rle_decode(rle) lesion_mask = np.maximum(lesion_mask, rle_mask) # 绘制质量区域掩膜 if 'quality_region' in ann and ann['quality_region']: poly = np.array(ann['quality_region'], dtype=np.int32) cv2.fillPoly(quality_mask, [poly], 1) # 获取诊断标签 label = self.class_map.get(ann['diagnosis'], 0) # 转换为tensor image = torch.from_numpy(image).permute(2, 0, 1).float() / 255.0 liver_mask = torch.from_numpy(liver_mask).long() lesion_mask = torch.from_numpy(lesion_mask).long() quality_mask = torch.from_numpy(quality_mask).long() # 应用数据增强(如需) if self.transform: # 注意:需同步变换图像和所有掩膜 augmented = self.transform(image=image, masks=[liver_mask, lesion_mask, quality_mask]) image = augmented['image'] liver_mask, lesion_mask, quality_mask = augmented['masks'] return { 'image': image, 'liver_mask': liver_mask, 'lesion_mask': lesion_mask, 'quality_mask': quality_mask, 'label': torch.tensor(label, dtype=torch.long), 'image_id': ann['image_id'] } # 使用示例(配合albumentations) import albumentations as A from albumentations.pytorch import ToTensorV2 transform = A.Compose([ A.Resize(512, 768), A.HorizontalFlip(p=0.5), A.RandomBrightnessContrast(p=0.2), ToTensorV2() ], additional_targets={'masks': 'mask'})2.2.1 关键参数说明与临床适配要点
quality_mask的作用远不止过滤伪影:在训练时可作为loss权重图(weight_map = quality_mask * 0.5 + (1-quality_mask) * 0.1),强制模型忽略低信噪比区域;lesion_mask采用np.maximum而非累加,避免多病灶重叠处像素值溢出,确保二值掩膜纯度;malignancy_score未在__getitem__中返回,因需单独设计回归head——建议在collate_fn中将其提取为batch['malignancy_scores'],配合L1Loss监督;transform必须使用additional_targets指定掩膜同步变换,否则肝脏轮廓几何畸变将破坏解剖合理性。
2.3 数据集划分策略:按病例ID而非文件名随机切分
超声图像存在强相关性:同一患者多次扫描的图像纹理、增益设置、探头角度高度相似。若按文件名随机划分,会导致验证集出现训练集已见过的患者特征,造成指标虚高。正确做法是:
- 解析所有JSON中的
patient_id字段(若无则根据文件名前缀推断,如P001_127.jpg→P001); - 按
patient_id聚类,确保同一患者的所有图像归属同一split; - 采用分层抽样:保证
normal/benign/malignant三类在训练/验证/测试集中比例一致(如7:2:1)。
from sklearn.model_selection import train_test_split import pandas as pd # 假设已提取patient_id列表 df = pd.DataFrame({ 'json_file': self.json_files, 'patient_id': [extract_patient_id(f) for f in self.json_files], 'diagnosis': [json.load(open(os.path.join(root, f)))['diagnosis'] for f in self.json_files] }) # 分层+按patient_id分组 train_patients, val_test_patients = train_test_split( df['patient_id'].unique(), test_size=0.3, stratify=df.groupby('patient_id')['diagnosis'].first(), random_state=42 ) val_patients, test_patients = train_test_split( val_test_patients, test_size=0.5, stratify=df[df['patient_id'].isin(val_test_patients)].groupby('patient_id')['diagnosis'].first(), random_state=42 )提示:实际项目中需检查
patient_id字段是否存在于原始JSON——本数据集摘要未明确提及,但Zenodo DOI页面的元数据文档应包含该信息。若缺失,可联系作者补全或通过图像哈希聚类近似还原。
3. 双路径模型设计:U-Net主干+ViT分类头的联合训练框架
3.1 网络架构选择依据:为何不用纯CNN或纯Transformer?
肝脏超声图像存在两大挑战:
- 低对比度与斑点噪声:传统CNN易受噪声干扰,导致肝脏边界模糊;
- 病灶尺度差异大:微小囊肿(<5mm)与巨大转移瘤(>50mm)共存,单一感受野难以兼顾。
U-Net通过跳跃连接保留空间细节,但全局上下文建模弱;ViT擅长长程依赖,但对局部纹理敏感度不足。因此采用U-Net主干提取多尺度特征 + ViT分类头聚合全局语义的混合架构,既保障分割精度,又提升分类鲁棒性。
3.1.1 U-Net主干改造:引入可变形卷积与注意力门控
标准U-Net在超声场景下易产生“阶梯状”分割边界。我们替换所有3×3卷积为可变形卷积(Deformable Convolution v2),并在跳跃连接处添加注意力门控(Attention Gate):
import torch.nn as nn import torch.nn.functional as F class AttentionGate(nn.Module): def __init__(self, gating_channels, inter_channels, input_channels): super().__init__() self.W_g = nn.Sequential( nn.Conv2d(gating_channels, inter_channels, kernel_size=1), nn.BatchNorm2d(inter_channels) ) self.W_x = nn.Sequential( nn.Conv2d(input_channels, inter_channels, kernel_size=1), nn.BatchNorm2d(inter_channels) ) self.psi = nn.Sequential( nn.Conv2d(inter_channels, 1, kernel_size=1), nn.BatchNorm2d(1), nn.Sigmoid() ) def forward(self, g, x): # g: gating signal (decoder feature), x: input feature (encoder skip) g1 = self.W_g(g) x1 = self.W_x(x) psi = self.psi(F.relu(g1 + x1)) return x * psi # apply attention mask # 在U-Net decoder block中插入 class UpBlock(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.up = nn.Upsample(scale_factor=2, mode='bilinear', align_corners=True) self.attention = AttentionGate(gating_channels=in_ch//2, inter_channels=in_ch//4, input_channels=in_ch//2) self.conv = nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1), nn.ReLU(inplace=True) ) def forward(self, x, skip): x = self.up(x) skip = self.attention(x, skip) # gated skip connection x = torch.cat([x, skip], dim=1) return self.conv(x)3.1.2 ViT分类头设计:基于肝脏ROI的区域级token聚合
直接将ViT应用于整图会稀释病灶信号。我们先用U-Net输出的liver_mask裁剪肝脏ROI,再在此区域内提取patch:
def extract_liver_roi(features, liver_mask, patch_size=16): """ features: (B, C, H, W) — U-Net encoder最后层特征 liver_mask: (B, H, W) — 二值掩膜 """ B, C, H, W = features.shape # 将liver_mask上采样至feature尺寸 liver_mask = F.interpolate(liver_mask.unsqueeze(1).float(), size=(H, W), mode='nearest').squeeze(1) # 提取肝脏区域特征(mask为1的位置) roi_features = [] for b in range(B): mask_idx = torch.where(liver_mask[b] == 1) if len(mask_idx[0]) == 0: # 无肝脏区域,取全图中心patch center_h, center_w = H//2, W//2 patch = features[b, :, center_h-patch_size//2:center_h+patch_size//2, center_w-patch_size//2:center_w+patch_size//2] else: # 随机采样N个点(避免过拟合) idx = torch.randint(0, len(mask_idx[0]), (16,)) h_idx, w_idx = mask_idx[0][idx], mask_idx[1][idx] # 提取以这些点为中心的patch patches = [] for i in range(len(h_idx)): h, w = h_idx[i].item(), w_idx[i].item() h0, h1 = max(0, h-patch_size//2), min(H, h+patch_size//2) w0, w1 = max(0, w-patch_size//2), min(W, w+patch_size//2) patch = features[b, :, h0:h1, w0:w1] if patch.shape[1:] != (patch_size, patch_size): patch = F.interpolate(patch.unsqueeze(0), size=(patch_size, patch_size), mode='bilinear').squeeze(0) patches.append(patch) patch = torch.stack(patches) roi_features.append(patch) return torch.stack(roi_features) # (B, 16, C, P, P) # ViT head输入:(B, N, C*P*P) -> (B, N, D) class ViTClassifier(nn.Module): def __init__(self, in_dim, num_classes=3, depth=4, heads=4, mlp_dim=256): super().__init__() self.patch_embed = nn.Linear(in_dim * 16 * 16, 512) # P=16 self.pos_encoding = nn.Parameter(torch.randn(1, 16, 512)) self.transformer = nn.TransformerEncoder( nn.TransformerEncoderLayer(d_model=512, nhead=heads, dim_feedforward=mlp_dim), num_layers=depth ) self.cls_head = nn.Sequential( nn.LayerNorm(512), nn.Linear(512, num_classes) ) def forward(self, x): # x: (B, 16, C, 16, 16) -> (B, 16, C*256) x = x.flatten(2).flatten(2) # (B, 16, C*256) x = self.patch_embed(x) + self.pos_encoding x = self.transformer(x) x = x.mean(dim=1) # global average pooling over patches return self.cls_head(x)3.2 多任务损失函数设计:分割与分类的梯度协同
单纯加权求和(loss = 0.7*seg_loss + 0.3*cls_loss)会导致梯度冲突。我们采用不确定性加权损失(Uncertainty Weighting),让网络自动学习各任务置信度:
class MultiTaskLoss(nn.Module): def __init__(self): super().__init__() # 可学习参数,初始化为0 → exp(0)=1,初始权重相等 self.log_var_seg = nn.Parameter(torch.zeros(1)) self.log_var_cls = nn.Parameter(torch.zeros(1)) def forward(self, seg_pred, seg_target, cls_pred, cls_target): # 分割损失(Dice + BCE) dice_loss = 1 - dice_coefficient(seg_pred, seg_target) bce_loss = F.binary_cross_entropy_with_logits(seg_pred, seg_target.float()) seg_loss = dice_loss + bce_loss # 分类损失 cls_loss = F.cross_entropy(cls_pred, cls_target) # 不确定性加权 precision_seg = torch.exp(-self.log_var_seg) precision_cls = torch.exp(-self.log_var_cls) loss = precision_seg * seg_loss + self.log_var_seg + \ precision_cls * cls_loss + self.log_var_cls return loss def dice_coefficient(y_pred, y_true, smooth=1e-6): y_pred = torch.sigmoid(y_pred) intersection = (y_pred * y_true).sum(dim=(1,2,3)) union = y_pred.sum(dim=(1,2,3)) + y_true.sum(dim=(1,2,3)) return (2. * intersection + smooth) / (union + smooth)注意:
log_var_seg和log_var_cls在训练初期会快速下降(提高对应任务权重),但需监控其值——若log_var_cls持续低于-3,说明分类任务过拟合,应增加DropPath率或添加标签平滑。
4. 模型验证与临床可信度评估:超越Accuracy的三维评价体系
4.1 分割性能评估:必须报告肝脏与病灶的独立Dice系数
许多论文仅报告整体Dice,但临床要求区分两类目标:
- 肝脏轮廓分割:影响后续病灶定位精度,要求Dice ≥ 0.92;
- 病灶分割:直接关联诊断,需按类型分报(囊肿/血管瘤/转移瘤),且要求病灶中心点定位误差 < 5mm。
使用monai.metrics.compute_meandice计算时,必须传入include_background=False并指定to_onehot_y=True:
from monai.metrics import compute_meandice # pred: (B, 2, H, W) — [liver, lesion] # target: (B, 2, H, W) — one-hot encoded dice_metrics = compute_meandice( y_pred=pred, y=target, include_background=False, to_onehot_y=True, mutually_exclusive=False # 因liver与lesion可重叠 ) # dice_metrics[0] = liver Dice, dice_metrics[1] = lesion Dice4.2 分类结果可解释性:Grad-CAM热力图与病灶空间关系分析
单纯输出malignant标签无法满足临床需求。需生成两层解释:
- 像素级热力图:显示模型关注区域是否覆盖真实病灶;
- 解剖关系报告:统计病灶相对于肝中静脉(MHV)、门静脉左支(LPV)的空间位置。
# Grad-CAM实现(针对ViT分类头) def generate_cam(model, img_tensor, target_layer='transformer.layers.3'): model.eval() with torch.no_grad(): features = model.encoder(img_tensor) # U-Net encoder输出 roi_features = extract_liver_roi(features, liver_mask) # 前文函数 cls_token = model.vit_head.patch_embed(roi_features.flatten(2)) # (B,16,D) # 获取最后一层Transformer的attention权重 attn_weights = model.vit_head.transformer.layers[-1].self_attn.attn # (B,heads,N,N) # 取cls token对所有patch的平均注意力 cam = attn_weights.mean(dim=1)[:, 0, 1:] # (B,16) cam = cam.reshape(-1, 4, 4) # 16 patches → 4x4 grid cam = F.interpolate(cam.unsqueeze(1), size=(512,768), mode='bilinear') return cam # 空间关系分析(需预定义解剖标志点坐标) def analyze_anatomical_relation(lesion_mask, landmark_points): """ landmark_points: {'MHV': (x,y), 'LPV': (x,y)} 返回病灶质心到各标志点的距离(mm),及相对象限 """ cy, cx = ndimage.center_of_mass(lesion_mask) relations = {} for name, (lx, ly) in landmark_points.items(): dist = np.sqrt((cx-lx)**2 + (cy-ly)**2) * 0.25 # 假设0.25mm/pixel relations[f'{name}_distance_mm'] = dist # 象限判断(以MHV为原点) if name == 'MHV': quadrant = 'RL' if cx > lx else 'LL' relations['quadrant'] = quadrant return relations4.3 部署前必做的三项压力测试
| 测试项 | 方法 | 合格阈值 | 临床意义 |
|---|---|---|---|
| 增益鲁棒性 | 对验证集图像批量调整增益(-20dB ~ +20dB) | 分类准确率波动 ≤ 5% | 避免不同设备参数导致误判 |
| 探头角度泛化 | 使用OpenCV模拟旋转(±15°)+ 仿射变换 | 病灶Dice下降 ≤ 0.08 | 适应不同扫查体位 |
| 小病灶检出率 | 专门构建含≤3mm病灶的子集(需人工复核) | 敏感度 ≥ 75% | 满足早期肝癌筛查要求 |
执行增益测试的代码示例:
def test_gain_robustness(model, dataloader, gain_range=(-20, 20)): model.eval() acc_list = [] for gain_db in range(gain_range[0], gain_range[1]+1, 5): acc = 0 for batch in dataloader: img = batch['image'] # 模拟增益调整(超声图像强度对数变换) img_linear = torch.pow(10, img * 2 - 2) # 反归一化 img_gain = img_linear * (10 ** (gain_db / 20)) img_norm = torch.log10(torch.clamp(img_gain, 1e-6, 1e6)) / 2 + 1 # 重归一化 pred = model(img_norm.cuda()) acc += (pred.argmax(dim=1) == batch['label'].cuda()).float().mean() acc_list.append(acc.item() / len(dataloader)) return np.array(acc_list) # 要求:max(acc_list) - min(acc_list) <= 0.05提示:增益测试中
img_linear = torch.pow(10, img * 2 - 2)基于超声图像强度服从对数正态分布的物理特性,此转换比简单线性缩放更符合真实设备行为。
本文还有配套的精品资源,点击获取