ARTICLE DETAIL

资讯详情

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

深度度量学习实现医学图像与文本匹配:双塔模型与训练实践

深度度量学习实现医学图像与文本匹配:双塔模型与训练实践 简介针对医学图像与临床文本之间的语义匹配难题这份以深度度量学习为核心的项目源码适合毕业设计、课程设计或期末大作业场景基于Python和transformers实现代码注释详实新手也能读懂整体流程与关键模块。压缩包共105个文件体积约46MB29个py源码覆盖数据预处理、模型构建、训练与推理逻辑42张png图片用于展示界面、结构图或结果可视化7个pdf和5个md文档提供项目说明、使用指南与实验分析json记录文本嵌入、词表和标注关系pth/pt/h5/keras等权重文件可直接加载运行。该资源已有116人学习下载在同类选题中具备一定参考价值。下载后可按文档快速搭建环境利用自带权重与测试数据重现医学图像文本匹配效果也可参照源码将triplet loss替换为其他度量学习策略或按需扩展数据集以适配不同模态检索任务适合答辩展示、复现实验和二次开发整体完成度和规范性较高。1. 深度度量学习做医学图文匹配这个毕设题到底在解什么问题Python 的深度度量学习方案做医学图像与文本匹配这道题的难点不在写模型而在把两个异模态的数据塞进同一个向量空间。你拿一张胸部 X 光片和一段放射科报告文本逐字比对永远对不上图像是像素矩阵文本是离散符号二者没有直接可比性。深度度量学习的做法是让图像塔和文本塔各编各的向量再用余弦相似度衡量匹配程度让该配对的靠近、不该配对的离远。这个方向适合想同时体现模型设计、工程实现和评估能力的毕设也是多模态检索最直接的落地点。后面这套流程按可复现的路子走数据、模型、损失、参数和常见翻车点都会过一遍。2. 选型为什么是“深度度量学习”以及模型结构怎么定2.1 从分类到度量相似度计算和嵌入空间的直观理解图像分类和文本分类模型输出的是类别概率而匹配任务要回答的是“这两个输入是不是在说同一件事”。最常用的做法不是让模型直接输出“匹配/不匹配”这种二分类而是把图像和文本各编码成一个向量再用相似度函数计算二者距离。深度度量学习的本质就在这里不学类别边界学向量距离。这个差异在毕设答辩里通常是老师第一个追问的点讲清楚“分类学的是标签度量学的是距离”这一句就够了。距离函数怎么选也有讲究。有人直接用欧氏距离但两个向量模长不一样时欧氏距离容易被长度干扰。常见做法是先做 L2 归一化再用余弦相似度把分数压到 [-1, 1] 区间这样后面调阈值、调温度都直观。下面的函数就是一套最小实现import torch import torch.nn.functional as F def cosine_score(img_emb: torch.Tensor, txt_emb: torch.Tensor) - torch.Tensor: img_emb F.normalize(img_emb, dim-1) # 图像特征归一化 txt_emb F.normalize(txt_emb, dim-1) # 文本特征归一化 return img_emb txt_emb.T # [B, B] 相似度矩阵这里的逻辑是一个 batch 里有 B 张图和 B 段文本矩阵第 i 行第 j 列就表示第 i 张图和第 j 段文本的匹配分数。对角线上的元素是正样本对非对角线都是负样本对。dim-1 表示在特征维度上归一化归一化之后内积就等于余弦相似度。理解了相似度矩阵也就理解了度量学习的目标训练过程中不断拉大对角线和非对角线的差距。到验证阶段给一张新图算出它和全部文本的相似度排序取 TopK这就是检索的全部动作。2.2 双塔结构还是单塔交互给这个题目一个选型建议图文匹配的主流结构有两种。双塔结构是两个独立编码器图像塔输出图像向量文本塔输出文本向量最后做一个点积或余弦单塔结构是把图像 patch 和文本 token 拼在一起送进 Transformer 做交叉注意力特征交互充分但每查一对就要完整算一遍前向。对这个题目来说需求是从报告库里检索出和图像匹配的文本一对多检索场景天然适合双塔图像和文本可以离线向量化、建索引查询时只编码一张图再和库里向量做近邻检索。毕设里解释选型时讲清楚“离线索引 在线打分”这个点比堆模型参数更能拿分。单塔准确率确实可能更高但几千对样本就要逐对算一遍评审一问部署代价就答不上来。双塔的代价是放弃了细粒度交互换来的是可扩展性。做这种题目的源码我习惯按数据准备、模型定义、训练、评估四块拆开写下面就是模型定义这部分的骨架import torch import torch.nn as nn import torch.nn.functional as F from torchvision.models import resnet18, ResNet18_Weights class ImageEncoder(nn.Module): def __init__(self, embed_dim128): super().__init__() base resnet18(weightsResNet18_Weights.IMAGENET1K_V1) self.backbone nn.Sequential(*list(base.children())[:-2]) self.pool nn.AdaptiveAvgPool2d((1, 1)) self.head nn.Linear(512, embed_dim) def forward(self, x): x self.backbone(x) # [B, 512, 7, 7] x self.pool(x).flatten(1) # [B, 512] return F.normalize(self.head(x), dim-1) class TextEncoder(nn.Module): def __init__(self, vocab_size5000, embed_dim128, max_len128): super().__init__() self.embed nn.Embedding(vocab_size, 256, padding_idx0) self.pos nn.Parameter(torch.zeros(1, max_len, 256)) encoder_layer nn.TransformerEncoderLayer( d_model256, nhead4, dim_feedforward1024, batch_firstTrue ) self.encoder nn.TransformerEncoder(encoder_layer, num_layers2) self.pool nn.AdaptiveAvgPool1d(1) self.head nn.Linear(256, embed_dim) def forward(self, token_ids): x self.embed(token_ids) self.pos[:, :token_ids.size(1), :] x self.encoder(x) x self.pool(x.permute(0, 2, 1)).flatten(1) return F.normalize(self.head(x), dim-1)图像塔用的是 ResNet18去掉分类头只保留卷积特征池化后投影到 128 维。embed_dim 是图像塔和文本塔最终共享的向量维度也是相似度矩阵的计算维度128 在这个数据规模下够用太小会丢信息太大在小数据集上容易过拟合。注意 torchvision 版本差异旧版本用 pretrained 参数加载权重新版本用 weights 参数项目里最好两个接口都兼容一下。文本塔用了字符级词表token 序列形状是 [B, max_len]经过两层 Transformer 后做平均池化再投影到同样 128 维。文本塔没有现成预训练权重随机初始化即可词表 5000 基本覆盖中文放射报告常用字。两个塔输出都做了 L2 归一化这样后续相似度矩阵数值范围天然可控。这里没有引入外部预训练模型依赖也是为了让项目在纯 PyTorch 环境下能直接跑起来。2.3 损失函数和评估指标匹配任务不看准确率看 RecallK损失函数选型是这个项目的核心决策。匹配任务里样本关系就是“配对”最常用两个损失Triplet Loss 和 InfoNCE。Triplet 需要一个锚点、一个正样本、一个负样本要求正样本比负样本近至少 marginInfoNCE 把整个 batch 看成分类问题配对的样本对是正类其余全是负类。InfoNCE 能用上 batch 内全部负样本训练效率高是当前图文匹配的主流做法。评估指标上准确率在这个任务里没有意义因为报告库里的负样本数量远远大于正样本模型全猜不匹配也能拿到高准确率。标配是 RecallK给一张查询图对全库文本按相似度排序看正确配对是否出现在前 K 个里。R1 最严格R10 更宽容一般两个都报。还可以加一个 MedR中位排名这个指标在医学检索方向很常见答辩时提一句能加分。损失 / 指标用途初值建议Triplet Loss小 batch、显式指定困难负样本margin0.2~0.5InfoNCE大 batch、batch 内全负样本对比temperature0.07RecallK检索效果评估K 取 1、5、10MedR中位排名评估整体排序质量越低越好如果样本只有几千对InfoNCE 的负样本全部来自同一个 batchbatch128 时每个锚点能看到 127 个负文本这个信息量远大于 triplet 的单个负样本。所以我一般直接选 InfoNCE 起步后面再叠加困难负样本挖掘来补短板。3. 数据准备把图像和报告配对成可训练的样本3.1 最省事的样本结构图像-文本对清单与读取脚本数据准备的第一步是把散落的图像和文本整理成一张清单。常见做法是 CSV 文件每一行是一个配对样本图像路径、报告文本、可选的患者 ID。训练样本的“标签”其实天然存在于配对关系里——这一行图像和文本就是正样本batch 里其他行都是负样本不需要额外标注。CSV 形式对后续调试也友好抽几行出来肉眼就能看出配得对不对。import pandas as pd df pd.read_csv(./data/train_pairs.csv, encodingutf-8) print(df.head()) # 列名: image_path, report_text, patient_id # 示例: ./data/chest/001.jpg, 双肺纹理清晰..., P001参数上有几个约定要提前定死。列名固定成 image_path、report_text、patient_id后续所有脚本按列名取数不要今天叫 image 明天叫 img_path。路径统一用相对路径脚本从项目根目录运行否则换一台机器路径全碎掉。读进来之后先检查 report_text 有没有空值空报告直接删掉别让一个空字符串进训练循环。数据量上pandas 直接读 CSV 撑到两三万对没问题超过五万对建议换分块读取避免数据加载阶段就把内存吃满。这个项目规模一般到不了那个量级但心里有个数就行。3.2 文本清洗和切分把报告切成模型能吃的 token 序列医学报告和普通文本差别很大。中文报告里“患者一般情况可、神志清、精神可”这类模板句占比高真正有用的病灶描述在后面。清洗时要去掉多余空白、重复标点、无信息量的语气词但有一个坑不要过度做停用词过滤。医学里“未见”“无明显异常”这类负向词一旦被当成停用词删掉语义直接反转模型会学反。切分策略上中文报告我一般按字切分不用分词工具。原因很简单分词对“肺纹理增粗、紊乱”这类专业词汇容易切错按字切分虽然会引入一些无效组合但至少不会把关键短语拆坏。词表控制在 5000 左右用 min_count 过滤低频字既能压词表体积又能把生僻的繁体字和录入噪声挡在外面。MAX_LEN 128 VOCAB_SIZE 5000 def build_vocab(texts, min_count2): counter {} for text in texts: for ch in text: counter[ch] counter.get(ch, 0) 1 vocab {pad: 0, unk: 1} for ch, cnt in sorted(counter.items(), keylambda x: -x[1]): if cnt min_count: break if len(vocab) VOCAB_SIZE: break vocab[ch] len(vocab) return vocab def encode_text(text, vocab, max_lenMAX_LEN): chars list(text.strip()) if len(chars) max_len: chars chars[:max_len // 2] chars[-max_len // 2:] ids [vocab.get(ch, vocab[unk]) for ch in chars] ids ids [0] * (max_len - len(ids)) return ids[:max_len]build_vocab 的 min_count2 过滤只出现一次的字避免词表被噪声占满。encode_text 的截断逻辑是关键超长时取前 64 和后 64 拼接而不是只取开头。道理在前面说过报告开头经常是检查方法诊断意见在末尾只留开头就把最重要的结论截掉了。padding_idx0 对应 Embedding 里的 padding 向量训练时不会被更新这是个细节但能省不少内存。提示训练集和验证集必须共用同一个词表。先在全量文本上构建词表再存成文件验证集不能自己重建词表否则 id 对不上训练和验证等于在两个空间里各跑各的。3.3 图像预处理灰度图和归一化不能照搬 ImageNet 习惯X 光和 CT 大多是单通道灰度图这是医学图像和自然图像最大的差异。torchvision 默认的 ImageNet 归一化参数 mean[0.485, 0.456, 0.406] 是 RGB 三通道的统计值直接套到灰度图上分布不对。常见做法是把灰度图复制成三通道这样 ResNet 的第一个卷积输入保持不变预训练权重可以直接用。CT 这类高动态范围图像还要多做一步窗宽窗位截断。CT 值范围覆盖上千 HU直接把原始值归一化软组织细节会被压没。常见做法是先做一个窗宽窗位映射只保留 [-150, 250] 范围内的值并映射到 0-255再做常规归一化。X 光没有这个问题按数据集统计的 mean/std 处理就行。from torchvision import transforms IMG_SIZE 224 transform transforms.Compose([ transforms.Resize((IMG_SIZE, IMG_SIZE)), transforms.ToTensor(), transforms.Lambda(lambda x: x.repeat(3, 1, 1) if x.size(0) 1 else x), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ])transforms.Lambda 里的 repeat 是把 [1, H, W] 的灰度图复制成 [3, H, W]原图已经是三通道就跳过。IMT_SIZE224 要和模型输入一致这里直接 resize 成方形不再做 center crop因为胸片是全域结构裁掉边缘可能把病灶裁没了。如果原始图像长宽比太悬殊可以改用 Resize((256, 256)) 加 CenterCrop((224, 224)) 减少畸变但不要随意水平翻转。图像增强在这个任务里要克制。左右翻转对胸片尤其危险肺片左右翻转会让病灶位置语义改变除非做镜像数据时手工修正标签否则这个增强会污染配对关系。旋转和轻微缩放可以用幅度别超过 15 度。模型学的是“图像内容和报告语义的一致性”不是几何不变性。4. 训练与调参跑通到指标像样的四个关键步骤4.1 最小可跑通训练循环Batch 里的相似度矩阵就是 Loss训练循环不是一上来就堆花活而是先把最小闭环跑通。一个 batch 的数据进来图像塔编码出图像向量文本塔编码出文本向量两者点积得到相似度矩阵矩阵对角线上的元素正好是正样本对cross entropy 一算loss 就出来了。InfoNCE 在这个框架下实现得非常简洁。import torch import torch.nn.functional as F def info_nce_loss(img_emb, txt_emb, temperature0.07): logits img_emb txt_emb.T / temperature # [B, B] labels torch.arange(logits.size(0), devicelogits.device) return F.cross_entropy(logits, labels)labels 用 arange 生成含义是“第 i 张图应当匹配第 i 段文本”对角线就是分类目标。温度 temperature 放在除法里让 logits 数值范围变大梯度信号更强。这一步和 2.1 里的归一化是配套的输入向量必须已经 L2 归一化否则内积范围不受控。for epoch in range(epochs): for img, txt in loader: img_emb image_encoder(img) txt_emb text_encoder(txt) loss info_nce_loss(img_emb, txt_emb, temperature0.07) optimizer.zero_grad() loss.backward() optimizer.step()优化器建议用 AdamW图像塔和文本塔学习率分开设。图像塔有预训练权重lr 给 1e-5 到 2e-5让它在小步微调不要剧烈漂移文本塔随机初始化lr 给 1e-4 到 3e-4步子可以大一些。batch_size 优先保 64 以上因为 InfoNCE 的负样本数量等于 batch_size 减一batch 太小负样本不足loss 会来回震荡。训练过程中每几十步打印三个数当前 loss、对角线平均相似度、非对角线平均相似度。如果后两者差距很小说明模型没有在区分匹配和不匹配别等整个 epoch 跑完再发现问题。4.2 温度系数 tau度量学习里最值得先调的超参数温度系数是这个项目里对结果影响最大的单点参数。temperature0.07 的含义是相似度本来在 [-1, 1] 之间除以 0.07 之后变成 [-14, 14]cross entropy 会强烈惩罚“应该匹配但分数不高”的样本对。如果温度设成 1.0logits 范围太小梯度一开始就小到推不动。反过来温度太低梯度又太猛容易在小 batch 上过拟合噪声。温度值训练初期现象典型结论0.05梯度猛loss 跳得厉害容易过拟合小 batch 噪声0.07收敛平稳默认起点0.1~0.2收敛变慢相似度分布偏平数据噪声大时可以试0.5梯度很小基本学不动调参的直观方法是看相似度矩阵的标准差。每 50 步打印一次非对角线元素的 std当 std 在 2 到 6 之间时分布比较健康如果标准差持续低于 1说明模型把所有配对都推到一起没有明显区分度。温度先固定不要上来就做可学习温度固定参数跑通主线后面有余力再把它设成 nn.Parameter 让模型自己学。4.3 Hard Negative 挖掘让网络告别“全是 0.7”的平庸相似度纯 InfoNCE 的负样本来自 batch 内随机配对大量负样本是“明显不匹配”的。模型很快学会把完全不相关的推开但遇到“左肺纹理增粗”和“右肺纹理增粗”这种容易混淆的表达分数还是拉不开。要解决这个问题就得做困难负样本挖掘对每个锚点在相似度矩阵里排除正样本后取相似度最高的那个负样本作为学习对象。常见做法是 InfoNCE 和 Triplet 结合给挖掘到的困难负样本加一个小权重。下面这段代码是这套思路的简化实现def hard_negative_loss(img_emb, txt_emb, temperature0.07, margin0.2, lam0.1): sim img_emb txt_emb.T # [B, B] B sim.size(0) idx torch.arange(B) pos sim[idx, idx].diag() # 正样本得分 sim_masked sim.clone() sim_masked[idx, idx] -1.0 hard_neg, _ sim_masked.max(dim1) # 每个锚点的最难负样本 base_nce F.cross_entropy(sim / temperature, idx) hinge torch.clamp(margin - pos hard_neg, min0.0).mean() return base_nce lam * hingemargin0.2 要求正样本分数至少比最像的负样本高 0.2lam0.1 控制这个 Triplet 项在总 loss 里的权重。如果开着 hard negative 之后验证 R1 反而掉了先检查 margin 是否太大降到 0.1 通常能缓解。另一个坑是 batch 太小小于 16 时挖掘出来的硬负样本基本都是噪声配对这时候先别开这个功能优先把 batch 提上去。5. 避坑医学图文匹配常见的 5 个翻车点5.1 训练 Loss 下降但 Recall0嵌入空间坍缩的典型症状现象训练 loss 从 3.6 平稳降到 0.3看着很漂亮但验证集 R1 是 0R10 也是 0。这是度量学习里最容易遇到也最容易误判的情况。原因所有向量被网络推到同一个点附近余弦相似度全部接近某个固定值对角线和非对角线几乎没差别。坍缩常见于温度设太高、负样本全是随机配对、或者文本塔模型容量配比失衡。解决先打印相似度矩阵看对角线均值和非对角线均值的差距。正常训练下这个差距应该稳步变大如果一直卡在 0.05 以内基本就是坍缩了。处理顺序是把 temperature 降到 0.05~0.07然后叠加 hard negative 挖掘再不行就减文本塔层数把模型容量降下来。with torch.no_grad(): sim img_emb txt_emb.T pos_mean sim.diag().mean().item() neg_mean (sim - torch.eye(sim.size(0)).to(sim.device)).mean().item() print(fpos{pos_mean:.4f} neg{neg_mean:.4f} gap{pos_mean - neg_mean:.4f})我的判断标准是gap 低于 0.1 说明学了个寂寞模型没有把匹配和不匹配分开到训练后期gap 在 0.25 以上才是正常状态。看到 gap 不起来不要急着加损失函数先按这个顺序把基础参数捋一遍。5.2 图像塔用了预训练 ResNet 却忘了冻结 BN小 batch 直接雪崩现象训练刚开始几个 steploss 变成 nan或者 loss 数值正常但验证集结果完全靠猜。图像塔换成自由训练的 ResNet 时尤其明显。原因ResNet 里的 BatchNorm 统计量依赖 batch 内样本。医学图像任务 batch 经常只有 8 或 16远远低于 ImageNet 训练时的 256BN 的 running_mean 被小 batch 带偏预训练特征被冲毁。这不是学习率的问题是统计量的问题。解决迁移学习场景下默认冻结 BN 层只让 BN 保持推理模式卷积层和投影头继续训练。PyTorch 里的做法是把 BN 层单独切到 eval 状态def set_bn_to_eval(module): for m in module.modules(): if isinstance(m, torch.nn.BatchNorm2d): m.eval() image_encoder.train() image_encoder.apply(set_bn_to_eval)冻结 BN 之后BN 的 running 统计保持不变但卷积参数仍然有梯度照常更新。如果显存允许把 batch 提到 32 以上也可以选择不冻结但冻结 BN 成本低、收益稳是血泪经验里最划算的一步操作。5.3 报告截断把关键病灶截没了前 50 字经常是检查方法不是结论现象训练 loss 正常验证 R1 一直上不去人工翻检索结果发现模型返回的文本语义接近但病灶主体对不上。原因很多报告模板是“检查名称、检查方法、影像所见、诊断意见”四段式。固定从开头截取 128 字取到“影像所见”就断了真正的“诊断意见”根本没进模型。解决用第 3.2 节里的“前 64 后 64”截断策略保开头也保结尾。更精细一点可以在文本进入编码器之前做规则拼接检测“诊断意见”这类关键词如果存在就把这一整段挪到文本末尾再截断。这个规则十几行代码就能写但对最终指标的影响往往比换一个更大的模型更明显。另外评估阶段不要只看指标手动打印几条查询的 Top1 结果截断问题一眼就能看出来。5.4 训练集和验证集病人重叠匹配指标虚高但不抗干扰现象验证集 R1 高到 0.85看起来能直接拿去答辩了但一换外部新的图像测试结果完全不对。原因划分数据时按图像随机切分同一个患者的图像同时出现在训练集和验证集。模型见过这个患者的图像和报告匹配时靠的是记忆而不是语义理解。真实场景里一个患者的数据只能进一个集合。解决按 patient_id 划分。先对患者 ID 做划分再把样本归到对应集合from sklearn.model_selection import train_test_split train_patients, val_patients train_test_split( df[patient_id].unique(), test_size0.2, random_state42 ) train_df df[df[patient_id].isin(train_patients)] val_df df[df[patient_id].isin(val_patients)]需要注意一个患者的全部图像和全部报告必须在同一集合里不能图 1 进训练、图 2 进验证。R1 虚高超过 0.1 时先怀疑划分重叠不要急着加模型复杂度。这个问题在论文里叫“patient-level split”答辩时主动讲出来反而成了加分项。5.5 显存只够小 batch相似度矩阵要分块算现象把 batch 调到 128 时报 CUDA out of memory。原理上相似度矩阵本身只有 128×128 个浮点数但反向传播时要保存中间梯度显存占用会放大好几倍。原因InfoNCE 的 loss 依赖全 batch 的相似度矩阵batch 越大负样本越多训练效果越好但显存开销也线性增长。解决第一个办法是相似度矩阵分块计算不一次生成完整矩阵def compute_sim_by_block(img_emb, txt_emb, block32): B img_emb.size(0) sim torch.zeros(B, B, deviceimg_emb.device) for i in range(0, B, block): for j in range(0, B, block): sim[i:i block, j:j block] ( img_emb[i:i block] txt_emb[j:j block].T ) return simblock32 时每块临时矩阵是 32×32显存占用只有全量矩阵的十六分之一。第二个办法是 memory bank把历史 batch 的特征缓存起来负样本数量可以提升到几百而不增加当前 batch 的显存。memory bank 实现复杂度高一些但毕设里能解释清楚并使用会明显拉开和其他人的差距。6. 进阶从匹配模型到检索服务的最后一公里6.1 导出向量 近邻检索让模型真正能被“用起来”训练完的模型不只在验证集上算 Recall常见做法是把全部报告文本离线向量化存成一个矩阵。查询时只编码一张图矩阵乘法一算TopK 结果就出来了。这个流程就是双塔结构落地的完整形态。report_vecs [] with torch.no_grad(): for text in all_texts: ids torch.tensor([encode_text(text, vocab)]).to(device) report_vecs.append(text_encoder(ids).cpu()) report_mat torch.cat(report_vecs, dim0) # [N, embed_dim] img_vec image_encoder(query_img.unsqueeze(0)) scores img_vec report_mat.T # [1, N] top5 scores.squeeze().topk(5).indices.tolist()报告库在几千到几万条时线性检索完全够用矩阵乘法秒级返回。超过五万条才需要考虑近邻索引毕设阶段先把线性检索跑通把 id 和文本的映射表维护好这比堆索引更实用。topk5 对应评估时的 Recall5查询结果要和报告 id 对应起来输出成可读格式。6.2 验证技巧热力图看相似度而不是只盯指标这是我最常用的检查方法。从验证集随机抽 64 对样本算一整张相似度矩阵用 matplotlib 画成热力图。肉眼远比指标诚实对角线应该是一条亮线非对角线有明暗交替而不全是灰色。如果对角线不亮或者整张图一片模糊说明模型并没有学出稳定的配对结构。import matplotlib.pyplot as plt plt.imshow(sim_matrix, cmaphot, aspectauto) plt.colorbar() plt.title(Similarity Matrix on Val Set) plt.savefig(val_sim_heatmap.png, dpi200)我一般在项目收尾时先看这张热力图再写进汇报材料。一张能看出明显对角线结构的热力图比单一一个 R1 数字更有说服力。这个习惯帮我少翻了好几次车希望帮到你。本文还有配套的精品资源点击获取
返回列表