尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

肾脏结节CT图像识别实战:迁移学习与可解释性验证

肾脏结节CT图像识别实战:迁移学习与可解释性验证 简介面向医学图像分类实践这套数据资源围绕肾脏结节与肿瘤识别任务提供已划分的训练集、验证集和测试集覆盖正常、结节、肿瘤三个类别并附类别字典json文件可直接用于CNN分类网络或YOLOv5分类模型的训练与评估。压缩包共2000个文件主体为1998张jpg医学图像其余为1个Python可视化脚本和1个类别字典文件整体大小151.5MB数据按文件夹保存目录层次清晰训练、验证、测试样本分开存放便于加载与复现实验。jpg样本为原始肾脏影像py脚本可快速预览图像示例与分布json文件则直接记录类别映射关系省去自行整理样本和转换标签的繁琐步骤。该数据集已有300人学习下载适合深度学习医疗影像方向的学生、研究者作为基准数据或入门练习使用。1. 肾脏结节、肿瘤数据图像识别这个任务你其实已经拿到了一半肾脏结节与肿瘤的CT图像识别是医学图像分类里一个很典型的落地场景数据量不大、正负样本不均、标注成本高但医生对漏诊的容忍度极低。标题里给了两样很关键的东西——划分好的数据和类别字典文件。这意味着你拿到的不是一个需要从零清洗的原始影像文件夹而是一个已经按 train/val/test 组织好、按类别建好子目录的结构化数据集准备工作已经完成了一大半。剩下的工作是理解这个结构把模型在它上面跑通、调稳再用可解释性手段验证模型看的确实是结节区域。这也是深度学习做医学影像最容易出成果的一类任务适合作为第一个落地点。适合三类人做横向课题的研究生、刚入行医学影像AI的算法工程师、被DICOM整理逼疯的影像科合作者。2. 读懂“划分好的数据”和类别字典文件训练前先搞清楚三件事拿到这种数据集后的第一件事不是写模型而是用脚本扫描一遍目录确认文件夹层级、图片格式、类别数量和样本分布。文件夹保存方式太直观了直观到很多人懒得验证结果训练到一半才发现 train 里混进了空文件夹、图片扩展名不统一、某个类别只有十几张图这种返工纯属浪费时间。2.1 文件夹式数据集的三种组织方式train/val/test 与按类别建目录标题里的“文件夹保存”一般指的是 ImageFolder 约定根目录下按 train/val/test 三个子目录划分每个子目录里再按类别名建文件夹每个类别文件夹里放对应标签的图片。先跑一个目录扫描确认实际结构find dataset -type f | wc -l python - PY from pathlib import Path root Path(dataset) for split in [train, val, test]: split_dir root / split if not split_dir.exists(): print(f[缺失] {split}) continue for cls_dir in sorted(split_dir.glob(*)): if cls_dir.is_dir(): files list(cls_dir.glob(*.*)) print(f{split}/{cls_dir.name}: {len(files)} 张) PY这个脚本做了两件事先看全文件数量再逐个子目录统计每类图片数。注意glob(*.*)只匹配带扩展名的文件如果数据里有隐藏文件或子文件夹数量会跟预期不一致。我一般会把结果和作者给的 README 或字典文件对照一遍确认每个类别在三个划分里都存在并且数量比例合理。常见的目录布局有三种。第一种就是上面这种严格的 ImageFolder 三划分最省事torchvision.datasets.ImageFolder可以直接读。第二种是只有 train 和 test没有 val需要自己从 train 里切一部分出来做验证。第三种是图片全部平铺在dataset/images/下划分信息放在一个split.csv里——这种不叫文件夹保存和标题描述不符不在本文讨论范围内。2.2 类别字典文件长什么样从 classes.txt 到 label_map.json类别字典文件是这个数据集里最容易忽略、却最容易出事故的部分。它的作用是把“类别字符串”和“整数标签”的对应关系固定下来让训练和推理两侧用同一套映射。最常见的两种形式是 JSON 和 TXT。JSON 一般是{benign: 0, malignant: 1}这种显式键值对TXT 则是每行一个类别名行号当标签。{ benign: 0, malignant: 1 }TXT 文件是隐式映射坑点在“行号就是标签”这个约定上。如果作者在文件末尾留了个空行某些脚本读进来后索引会错一位。更隐蔽的问题是中文类别名文件夹叫“良性结节”和“恶性肿瘤”TXT 文件用系统默认编码保存在 Linux 上按 UTF-8 读没问题Windows 上若用 GBK 编码打开就会乱码。我拿到任何课题数据的第一件事是把类别字典统一转成一份label_map.json把它当作全流程唯一真源import json # 如果作者给的是 classes.txt先转成 json with open(classes.txt, r, encodingutf-8) as f: classes [line.strip() for line in f if line.strip()] label_map {name: idx for idx, name in enumerate(classes)} with open(label_map.json, w, encodingutf-8) as f: json.dump(label_map, f, ensure_asciiFalse, indent2)if line.strip()这行过滤掉了空行和纯空白行是防止行号错位的保险。ensure_asciiFalse保证中文类别名以可读形式落盘而不是变成\uXXXX转义序列。转好之后把字典里的 key 和文件夹名做一次比对确认完全一致。这一步能提前挡住 5.2 里那个标签错位的坑。2.3 用 ImageFolder 和 Dataset 把数据与字典接起来如果目录结构和label_map.json完全对齐最省事的做法是直接用datasets.ImageFolder它内部会按文件夹名自动生成class_to_idx。但注意ImageFolder 的类别顺序是按文件夹名的字符串排序生成的如果你的文件夹名字顺序恰好和字典文件不一致就会出现“字典里 benign 是 0ImageFolder 里 benign 是 1”的错位。所以我建议在正式项目里用自定义 Dataset代码只多几行可控性强很多import json import os from pathlib import Path from PIL import Image from torch.utils.data import Dataset class KidneyDataset(Dataset): def __init__(self, root, label_map_path, transformNone): with open(label_map_path, r, encodingutf-8) as f: self.label_map json.load(f) self.transform transform self.samples [] # 以 label_map 的 key 作为文件夹名逐个类别收集图片 for cls_name, cls_idx in self.label_map.items(): cls_dir Path(root) / cls_name if not cls_dir.is_dir(): print(f警告: {cls_dir} 不存在) continue for img_path in sorted(cls_dir.glob(*.*)): self.samples.append((str(img_path), cls_idx)) def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label self.samples[idx] img Image.open(path).convert(RGB) if self.transform: img self.transform(img) return img, label关键点有两处。一是 “以 label_map 的 key 作为文件夹名”这意味着类别字典文件里的每个类名必须和实际文件夹名一字不差我这里把 warning 打在显眼位置缺目录时一眼能看见二是Image.open(path).convert(RGB)强制把输入统一成三通道即使原始图片是灰度 PNG 或带透明度通道的格式后面接 ResNet 预训练模型时不会因为通道数不一致报错。采样标签cls_idx直接来自字典文件而不是来自文件夹排序从根上规避了标签错位。使用的时候分别给 train、val、test 建三个 Dataset 实例再传给 DataLoader。注意每个划分要传同一个label_map.json不要在三个划分里各自生成字典。提示Windows 下路径分隔符和 Linux 不同这里统一用pathlib.Path处理避免手拼字符串路径在跨平台时翻车。3. 跑通第一个肾脏病变分类模型迁移学习的最小实现这个任务的核心模型选型没有太多悬念——用 ImageNet 预训练的 ResNet 做迁移学习是医学图像分类里最稳的起点。版本选型上数据量只有几百到几千张时ResNet18 或 ResNet34 比 ResNet50 更稳。参数越多在小数据集上越容易过拟合不是越大的模型越好。3.1 为什么必须从 ImageNet 预训练权重开始而不是从零训练肾脏 CT 数据集的特点是数量少、相似度高。按病例算一个单中心课题通常只有一两百到几百例切出来的切片看似有几千张但同一病例的相邻切片高度相似有效信息量远低于表面数量。ResNet34 有两千多万参数从零训练需要百万量级样本才能学到有泛化能力的特征在这个数据量上直接训结果是训练集准确率 99%、验证集准确率 60%这叫过拟合不叫识别。ImageNet 预训练权重提供的是边缘、纹理、形状这类底层视觉先验。CT 图像虽然是灰度、是医学影像但病灶边界和正常组织的纹理差异在底层特征上和自然图像的边缘检测是相通的。医学影像较常用的处理办法是把单通道灰度图复制成三通道送进网络而不是把第一层卷积改成单通道输入。改 conv1 虽然能节省一点点计算量但会破坏预训练权重里通道间的联合统计信息对迁移学习不划算。复制成三通道的冗余是可控的模型依然能学习。3.2 最小模型ResNet34 换掉最后一层全连接用torchvision加载预训练权重替换最后的全连接层import torch import torch.nn as nn import torchvision.models as models # label_map 是第 2.2 节里生成的字典 num_classes len(label_map) model models.resnet34(weightsmodels.ResNet34_Weights.IMAGENET1K_V1) in_features model.fc.in_features model.fc nn.Linear(in_features, num_classes) device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device)model.fc.in_features是原模型全连接层的输入维度ResNet34 是 512ResNet50 是 2048写代码时不要硬编码这个数字直接取属性值最安全。最后一层改成num_classes等于len(label_map)保持和字典一致。如果后面想换成 ResNet18只需要改这一行模型名其他代码不用动。这里有个参数值得注意models.ResNet34_Weights.IMAGENET1K_V1是 torchvision 新版本的枚举写法老版本代码里常见的pretrainedTrue在新版本会告警建议直接用枚举写法显式声明权重来源。3.3 训练循环与模型保存验证集决定 checkpoint 而不是最后一个 epoch训练循环的骨架长这样from torch.utils.data import DataLoader from tqdm import tqdm criterion nn.CrossEntropyLoss() optimizer torch.optim.AdamW(model.parameters(), lr1e-4, weight_decay1e-4) train_loader DataLoader(train_dataset, batch_size16, shuffleTrue, num_workers4) val_loader DataLoader(val_dataset, batch_size16, shuffleFalse, num_workers4) best_val_acc 0.0 for epoch in range(30): model.train() train_loss 0.0 for images, labels in tqdm(train_loader, descfEpoch {epoch1}): images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() train_loss loss.item() * images.size(0) # 验证阶段 model.eval() val_correct, val_total 0, 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs model(images) preds outputs.argmax(dim1) val_correct (preds labels).sum().item() val_total labels.size(0) val_acc val_correct / val_total print(fEpoch {epoch1} | train_loss {train_loss / len(train_loader.dataset):.4f} | val_acc {val_acc:.4f}) if val_acc best_val_acc: best_val_acc val_acc torch.save(model.state_dict(), best_kidney_classifier.pt)这里有两个容易被新手忽略的习惯。第一是model.train()和model.eval()必须成对切换ResNet 里的 BatchNorm 层在训练和推理时的行为不一样漏掉切换会导致验证结果忽高忽低。第二是模型保存只认验证集指标不认训练集 loss也不认最后一个 epoch。医学分类数据量小过拟合来得很快可能第 12 个 epoch 验证集已经到顶了第 13 个 epoch 就开始崩。起一个best_val_acc变量持续跟踪只在刷新时保存这样最终拿到的 checkpoint 一定来自泛化最好的那个时刻。4. 让模型在小数据集上真正收敛增强与训练参数到这一步模型能跑起来但距离“能上台面”还差得远。肾脏 CT 分类的难点从来不是网络结构而是让模型在小样本上不跑偏。这一章讲的是图像识别算法里优先级最高的三项工作数据增强、类别不平衡、微调策略。4.1 增强套路翻转旋转之外要加的是强度扰动医学图像的增强和自然图像有本质区别。自然图像可以随便旋转 90 度、随机裁剪到 0.5 倍因为“一只狗倒过来还是狗”。但肾脏 CT 有固定解剖方位旋转超过 20 度会让器官形态变得不典型随机裁剪比例太小可能把病灶整个裁掉模型学到的是“肾周脂肪特征”而不是“结节特征”。from torchvision import transforms train_transform transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(degrees15), transforms.ColorJitter(brightness0.1, contrast0.1), 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]) ])几个参数值得解释。Resize((224, 224))是 ResNet 的标准输入尺寸和预训练权重匹配不要随意改成 256。RandomRotation(degrees15)用的是小角度这是肾脏 CT 增强里比较稳的区间超过 30 度会引入大量无意义的背景填充。ColorJitter只调了亮度和对比度没动色相和饱和度——因为灰度图复制成三通道后色相扰动等于注入无意义的彩色噪声。对比度扰动模拟的是不同 CT 扫描参数造成的软组织灰度差异这个对医学图像增强很关键。注意验证集只做 Resize 和归一化不要加任何随机操作否则验证指标每次跑出来都不一样没法判断模型是不是真的变好了。4.2 类别不平衡WeightedRandomSampler 和 Focal Loss 怎么选肾脏结节数据里恶性肿瘤的比例通常比良性低不少在极端情况下可能只有 10% 到 20%。如果不处理模型会走向“把所有样本都预测成多数类”的偷懒解准确率看着有 80%对恶性肿瘤的召回率却是 0这在临床上完全不可用。对于 3:1 以内的不平衡WeightedRandomSampler 就够用了from torch.utils.data import WeightedRandomSampler import torch # 统计每个样本的类别计算每个类别的权重权重与样本数成反比 labels [s[1] for s in train_dataset.samples] class_counts torch.bincount(torch.tensor(labels)) sample_weights 1.0 / class_counts[labels].float() sampler WeightedRandomSampler(sample_weights, num_sampleslen(sample_weights), replacementTrue) train_loader DataLoader(train_dataset, batch_size16, samplersampler)这里sample_weights是每个样本被抽中的概率权重少数类样本拥有更高的权重。replacementTrue表示抽样时有放回少数类可以在一个 epoch 里被重复抽到这是解决不平衡的直接手段。代价是每个 epoch 里看到的多数类样本会变少训练轮次可能需要相应增加。如果正负比超过 5:1或者用了 Sampler 之后模型仍然偏向多数类可以换成 Focal Loss。它的思路是让模型把注意力放在那些“预测置信度不高”的难样本上import torch.nn as nn import torch.nn.functional as F class FocalLoss(nn.Module): def __init__(self, alpha0.25, gamma2.0): super().__init__() self.alpha alpha self.gamma gamma def forward(self, logits, labels): ce F.cross_entropy(logits, labels, reductionnone) pt torch.exp(-ce) focal_weight (1 - pt) ** self.gamma if self.alpha is not None: focal_weight self.alpha[labels] * focal_weight return focal_weight.mean()gamma2是 Focal Loss 论文里的默认值控制对难样本的聚焦程度调大反而容易让训练不稳定。alpha是类别权重向量给少数类更高的权重。用 Focal Loss 时要注意学习率调低一点因为它会让 loss 数值整体变小AdamW 下建议从 5e-5 起调。4.3 两段式微调冻结、解冻与学习率设置医学图像分类最常见的微调策略是两段式先冻结 backbone 只训练新加的分类头再解冻全部参数用很小的学习率微调。冻结阶段让随机初始化的全连接层先快速适应任务避免前期反向传播把预训练特征带偏解冻阶段再用低学习率慢慢调整 backbone 特征。# 第一段冻结 backbone只训练 fc 层 for param in model.parameters(): param.requires_grad False for param in model.fc.parameters(): param.requires_grad True optimizer torch.optim.AdamW(model.fc.parameters(), lr1e-3) # 跑 5~10 个 epoch观察 val_acc 爬升后进入第二段 # 第二段解冻全部参数用小学习率微调 for param in model.parameters(): param.requires_grad True optimizer torch.optim.AdamW(model.parameters(), lr5e-5, weight_decay1e-4) # 再跑 20~40 个 epoch用验证集选最优 checkpoint学习率的选择逻辑冻结段因为只训练一层全连接参数量小1e-3 不会崩解冻段全部参数参与更新5e-5 是一个在 ResNet 医学影像微调里比较稳的起点如果验证集 loss 震荡就降到 2e-5。weight_decay1e-4是 AdamW 的标准正则设置对全连接层和卷积层都有效能缓解过拟合。总 epoch 数建议压在 50 以内医学小数据集在 30 到 50 个 epoch 之间基本收敛到头再训就是反复在验证集上做无用功。5. 医学图像分类的五个常见坑从数据读出到训练的翻车记录这章都是血泪经验。每一条我都见过不止一次有的坑表面看是模型问题排查到最后发现是数据读取出错浪费两三天时间。5.1 灰度图被当三通道读模型卡在 50% 准确率现象训练 loss 下降很慢验证集准确率一直徘徊在 50% 到 60%无论怎么调学习率都没用。原因部分数据集作者已经把 CT 切片转存成灰度 PNG但代码里用 OpenCV 的cv2.imread读图时默认行为是彩色读取灰度图会被强行复制成三通道。问题不在通道数而在于很多预处理脚本在灰度图上做了颜色相关的增强比如随机改变色相这会往图像里注入完全虚假的信息模型学到的全是噪声。解决统一用Image.open(path).convert(RGB)读图并且在 Dataset 的__getitem__里对图像尺寸和模式做一次断言灰度图就老老实实复制三通道不要做色相类增强。5.2 类别字典和文件夹顺序对不上标签整体错位现象训练 loss 正常下降验证集准确率却一直偏低画出混淆矩阵发现对角线明显偏移比如良性样本被系统性预测成恶性。原因ImageFolder按文件夹名的字典序生成类别索引而作者的类别字典文件可能按另一套顺序排列。比如文件夹叫benign和0_malignant排序后“0_malignant”排在前面索引为 0但字典文件里benign是 0。两边映射一错位整个训练过程都在学错误的标签。解决不要依赖 ImageFolder 的自动排序。用第 2.3 节的自定义 Dataset采样标签直接从label_map.json里取。训练前打印一遍dataset.label_map和实际文件夹列表确认它们一致。5.3 验证集 loss 在降但准确率不动阈值不是 0.5现象训练过程中验证集 loss 一直在下降但准确率纹丝不动甚至偶发下降。原因二分类模型默认取 softmax 后概率大于 0.5 的类别为正类但正样本是少数类时模型输出的概率整体偏低0.5 的截断点并不适合这个分布。loss 下降说明模型对多数类的置信度在提高但少数类的判定边界始终没过 0.5。解决在验证集上用 F1 分数搜索最优阈值而不是用默认 0.5from sklearn.metrics import f1_score model.eval() prob_list, label_list [], [] with torch.no_grad(): for images, labels in val_loader: images images.to(device) outputs torch.softmax(model(images), dim1) prob_list.append(outputs[:, 1].cpu()) label_list.append(labels) probs torch.cat(prob_list) labels torch.cat(label_list) best_f1, best_thr 0.0, 0.5 for thr in torch.linspace(0.1, 0.9, 81): preds (probs thr).long() f1 f1_score(labels, preds) if f1 best_f1: best_f1, best_thr f1, thr.item() print(f最佳阈值 {best_thr:.2f}, F1 {best_f1:.4f})注意阈值必须在验证集上选择选完再用测试集做最终评估。如果在测试集上搜索阈值结果会偏乐观到了外部数据上直接打回原形。5.4 DICOM 窗宽窗位没调增强后图像一团黑现象数据集如果是直接从 PACS 导出的 DICOM 文件转成 PNG 后整张图黑乎乎一片增强处理完全看不出病灶。原因DICOM 存储的是原始 CT 值范围通常在 -1024 到 3071 之间直接线性映射到 0 到 255 时软组织区域全部挤在一个很窄的低灰度区间。另一个隐蔽问题是 CT 图像可能带RescaleSlope和RescaleIntercept两个元数据必须先把原始像素值转成 HU 值再处理。解决如果需要自己转 DICOM先做窗宽窗位映射肾脏 CT 一般取窗宽 350、窗位 40import pydicom import numpy as np ds pydicom.dcmread(case001_slice012.dcm) # 转 HU 值 slope float(getattr(ds, RescaleSlope, 1)) intercept float(getattr(ds, RescaleIntercept, 0)) hu ds.pixel_array.astype(np.float32) * slope intercept # 窗宽窗位映射 window_center, window_width 40, 350 low window_center - window_width / 2 high window_center window_width / 2 img (hu - low) / (high - low) img np.clip(img, 0, 1)要说明的是如果你拿到的数据已经是按文件夹保存的 PNG 或 JPG作者大概率已经做过这个转换不需要再碰 DICOM。但值得花十分钟检查一下像素值范围如果图像整体非常暗或非常亮说明转换有问题趁早和作者确认原始处理管线不要自己默默调对比度。5.5 同一个患者的切片被分进 train 和 val数据泄露现象训练验证都顺利训练集准确率轻松到 95% 以上验证集却也很高但一上外部数据准确率直接崩到及格线以下。原因肾脏 CT 是按病例连续扫描的一个病例会产出几十到几百张切片相邻切片之间的形态极其相似。如果划分时随机打散的是“图片”而不是“病例”同一个患者的切片会同时出现在训练集和验证集模型其实在记忆患者特征而不是在识别病灶。这是医学影像课题里最严重的数据泄露。解决按病例 ID 分层划分先切病例再取每个病例下的所有切片。from pathlib import Path # 从文件名中提取病例 ID例如 case001_slice012.png - case001 case_ids [] for img_path, _ in train_val_samples: case_id Path(img_path).name.split(_)[0] case_ids.append(case_id)再用sklearn.model_selection.GroupShuffleSplit按case_ids分组切分。切完后检查一下验证集中出现的 case ID 列表和训练集的交集中元素为空这一步不能省。6. 验证模型学的到底是什么混淆矩阵与 Grad-CAM 的最后一步训练完模型准确率再高也只是第一步。医学分类的验收标准不是“总体准确率”而是“恶性肿瘤漏诊了多少”。这一章是交付前的最后一道工序。6.1 用混淆矩阵而不是 accuracy 判断模型是否可用from sklearn.metrics import confusion_matrix model.load_state_dict(torch.load(best_kidney_classifier.pt)) model.eval() y_true, y_pred [], [] with torch.no_grad(): for images, labels in test_loader: images images.to(device) preds model(images).argmax(dim1) y_true.extend(labels.cpu().numpy()) y_pred.extend(preds.cpu().numpy()) cm confusion_matrix(y_true, y_pred) tn, fp, fn, tp cm.ravel() sensitivity tp / (tp fn) # 恶性召回率越高越好 specificity tn / (tn fp) # 良性识别正确率 print(f敏感度 {sensitivity:.3f}, 特异度 {specificity:.3f})在结节筛查场景敏感度比准确率重要得多。宁可把良性误判成恶性来做进一步检查也不能漏掉一个真正的肿瘤。所以选模型时把敏感度低于 90% 的 checkpoint 直接淘汰再看特异度。6.2 Grad-CAM 验证模型关注的是病灶而不是背景数值指标只能说明模型“预测对了”不能说明模型“看对了地方”。肾脏 CT 里肿瘤有明确的局部位置用 Grad-CAM 把模型的注意力区域可视化出来经验上的做法是用pytorch_grad_cam库from pytorch_grad_cam import GradCAM from pytorch_grad_cam.utils.model_targets import ClassifierOutputTarget # 最后一层残差块是 ResNet 最抽象的特征层 cam GradCAM(modelmodel, target_layers[model.layer4[-1]]) input_tensor test_batch_images.to(device) targets [ClassifierOutputTarget(1)] # 只关注恶性类别 grayscale_cam cam(input_tensorinput_tensor, targetstargets)如果高亮区域集中在肾脏病灶周围说明模型学到了可信的特征如果高亮区散布在图像边缘、背景或整个肾脏轮廓之外那模型八成是学到了扫描仪的伪影或患者来源信息这个模型不能用于外部数据。这一步看起来玄学实际是医学影像课题审稿人和合作医生最看重的证据之一。我现在的习惯是训练指标只用来发现问题论断是否可用只看两样——混淆矩阵里的敏感度和 Grad-CAM 图上那块高亮区域。这两样都过了才敢把模型交出去。希望帮到你。本文还有配套的精品资源点击获取
返回列表