ARTICLE DETAIL

资讯详情

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

YOLOv10+多实例学习:破解水下目标检测弱标注难题

YOLOv10+多实例学习:破解水下目标检测弱标注难题 简介基于多实例学习与YOLOv10实现水下目标检测的项目工程面向目标检测方向的学习者与研究人员尤其适合处理水下图像中多目标识别、遮挡与光照干扰等复杂场景。压缩包共收录508个文件包含258个Markdown说明文档用于记录原理讲解与实验笔记156个Python脚本覆盖模型训练、推理与后处理流程另有YAML配置文件、C部署代码及Docker相关文件整体大小仅1.2MB结构清晰便于快速上手。已有348人学习下载。其中包含从Two stage/One stage目标检测原理、NMS与IoU概念到YOLOv10工程实践的系统材料可直接参考数据配置、模型结构与环境构建方式结合RUOD水下数据集进行实验省去大量环境搭建与代码调试时间。1. 为什么水下目标检测要拉上多实例学习RUOD 的坑在哪水下目标检测真正难的不是模型选型而是数据本身不干净。RUODReal-world Underwater Object Detection里的海参、海胆、扇贝、海星在浑浊水体中拍出来经常是失焦、偏色、低照度的废片人眼都分不清边缘标注员更不敢下框。这个场景下强监督的 YOLO 拿清晰框去训遇到模糊子集就翻车。多实例学习MIL的思路是既然框标不准那就只用“这张图里有没有海参”这类图像级标签来监督。RUOD 恰好把数据切成了清晰子集和模糊子集前者有完整框标注后者只能依赖图级标签。把 YOLOv10 的端到端输出直接当作 MIL 的实例集合就能搭一条从弱标签到强检测的训练管线。这篇笔记适合正在做水下养殖巡检、水下机器人视觉或者弱监督检测落地的从业者我会把数据集处理、MIL 损失、训练参数和翻车现场一次说清。2. 拆解 RUOD 与 YOLOv10数据集结构、MIL 构件和端到端设计2.1 RUOD 的清晰子集和模糊子集标签粒度完全不同RUOD 的标注风格是 VOC 风格 XML每个object节点下都有name和bndbox但它的特殊之处在于数据被划分成了两类清晰子集和模糊子集。清晰子集中的图像画质较好目标边缘可辨标注框基本可信模糊子集包含水下悬浮颗粒、运动模糊、强蓝绿色偏等退化很多图要么没有框标注要么只有图像级类别标签告诉你“这张图里存在海胆和扇贝”但具体在哪个位置说不清楚。这对检测模型是个尴尬局面直接用模糊子集训练框监督会往错误方向推直接不训练模糊子集又浪费了大量真实水下退化样本。多实例学习在这里的定位就是“消化这些低质量标注”把每张模糊图看作一个包bag包内能产出的候选区域就是实例instance模型只需要学习“这个包里存在哪些类”而不是“每个类在哪个精确位置”。RUOD 类别只有四个holothurian海参、echinus海胆、scallop扇贝、starfish海星类别间外形差异大这给了 MIL 聚合足够的判别空间。2.2 MIL 的三个基本构件包、实例、聚合函数多实例学习不是某个特定网络结构它是一套处理弱监督标签的框架。经典假设是一个包被标为正类当且仅当包内至少有一个实例是正类包被标为负类则包内所有实例都是负类。对应水下目标检测一张模糊图就是包图上通过检测器得到的一系列候选框就是实例。聚合函数是 MIL 的灵魂常见有三种。最大池化max pooling取包内实例得分的最大值作为包预测符合“存在即正”的假设但容易过度自信一个高置信度的误检就能把包带偏平均池化太温和正实例被大量负实例稀释Top-K 平均池化是目前工程上常用的折中取每个类别得分最高的 K 个实例做平均既保留“存在性”语义又削弱单个误检的干扰。实践中还有注意力聚合给每个实例学一个权重但训练不稳定对新手不友好。RUOD 模糊子集这类弱标签场景我先推荐 Top-K。2.3 YOLOv10 的无 NMS 端到端输出为什么适合给 MIL 当实例来源YOLOv10 和 YOLOv8 最大的区别是推理阶段去掉了 NMS。它能这么做靠的是训练时同时维持了 one-to-many 和 one-to-one 两条标签分配分支推理时直接用 one-to-one 分支的输出每个目标天然只有一个框不需要后处理去重。这个特性对 MIL 落地很关键一张模糊图过一遍 YOLOv10拿到的就是一组质量较高、已经去重的候选框这些框可以直接当作“包内的实例集合”省掉 NMS 阈值调参的麻烦。YOLOv10 的另一个好处是它依然是 anchor-free 多尺度检测头的架构对低质图像能产生大量候选位置确保包内实例有足够的召回。用清晰子集先训练一个 YOLOv10 检测器再用它的候选框作为 MIL 的实例来源等于让强监督分支帮弱监督分支筛选“嫌疑区域”这比从零训一个 MIL 模型稳定得多。3. 从 RUOD 到 YOLOv10 能吃的格式XML 转 TXT 与数据集 yaml 生成3.1 准备环境该装什么pip 包和源码仓库怎么选YOLOv10 官方仓库基于 ultralytics 框架改造所以最稳妥的安装方式是建一个干净的 conda 环境把 torch 装好再装 yolov10 和 ultralytics。我的常用做法是 Python 3.10 CUDA 11.8 的 torch 2.x显存只有 8GB 也能跑 yolov10s只是 imgsz 要降一些。conda create -n mil-yolo python3.10 -y conda activate mil-yolo pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install yolov10 ultralytics装完后可以验证一下from yolov10 import YOLOv10是否正常。需要注意一个选择如果只是做训练和推理pip 安装就够如果打算把 MIL 损失直接加进检测头做深度集成建议把 yolov10 官方仓库 clone 到本地用源码里的模型文件因为你要在检测头 forward 的返回处改代码。两种方式我都用过各有适用场景后面第 4 章会分开讲。3.2 RUOD 的 VOC 风格 XML 转 YOLOv8/v10 的 TXT 标注YOLOv10 的标注格式和 YOLOv8 完全一样每张图对应一个同名 txt每行是class_id x_center y_center width height四个坐标都要归一化到 0~1。RUOD 原生 XML 里的bndbox是xmin, ymin, xmax, ymax的绝对值需要先换算出像素宽高再除以图像宽高。我写转换脚本时习惯兼容size节点两种解析写法因为不同来源的数据集 XML 结构有细微差异。import os import glob import xml.etree.ElementTree as ET CLASSES [holothurian, echinus, scallop, starfish] # 类别顺序必须和后面 data yaml 里的 names 一致不能随意调 def xml_to_yolo(xml_path, out_dir): tree ET.parse(xml_path) root tree.getroot() # 兼容两种 size 写法 size_node root.find(size) if size_node is not None: img_w int(size_node.find(width).text) img_h int(size_node.find(height).text) else: img_w int(root.find(width).text) img_h int(root.find(height).text) lines [] for obj in root.findall(object): name obj.find(name).text if name not in CLASSES: continue cls_id CLASSES.index(name) box obj.find(bndbox) x1 float(box.find(xmin).text) y1 float(box.find(ymin).text) x2 float(box.find(xmax).text) y2 float(box.find(ymax).text) # YOLO 格式需要中心点坐标和宽高全部除以图像尺寸 cx (x1 x2) / 2.0 / img_w cy (y1 y2) / 2.0 / img_h w (x2 - x1) / img_w h (y2 - y1) / img_h # 裁剪到 0~1防止标注出界导致训练 loss 变 nan cx min(max(cx, 0.0), 1.0) cy min(max(cy, 0.0), 1.0) w min(max(w, 0.0), 1.0) h min(max(h, 0.0), 1.0) lines.append(f{cls_id} {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}) txt_name os.path.basename(xml_path).replace(.xml, .txt) with open(os.path.join(out_dir, txt_name), w) as f: f.write(\n.join(lines)) # 遍历所有 xml xml_files glob.glob(/workspace/data/RUOD/Annotations/*.xml) for xml_file in xml_files: xml_to_yolo(xml_file, /workspace/data/RUOD/labels)这段脚本的逻辑按顺序做了三件事解析 XML 拿图像宽高遍历所有object节点过滤出四个目标类别把相对坐标写成 YOLO 格式的一行文本。参数说明如下CLASSES的顺序直接影响类别 ID必须和后面创建的 yaml 里names列表一一对应顺序错了模型训练时类别会错位宽度高度归一化后要加min(max())裁剪RUOD 里有些模糊图的标注框可能超出图像边界不裁剪的话训练时 anchor 匹配容易出 nan输出目录最好和图像目录建立镜像结构方便 YOLO 的 dataset 配置。3.3 创建数据集 yaml绝对路径、类别顺序、缩进这三个点别偷懒YOLOv10 训练时需要一个数据描述 yaml 文件这个文件决定了数据从哪读、有几类、类别名是什么。网上很多人问“yolov10 yaml 文件怎么创建”核心就一句话它就是个普通 YAML告诉框架数据路径和类别信息但缩进和路径写法有不少隐藏坑。我一般直接用 heredoc 创建避免编辑器把空格改坏。mkdir -p /workspace/data/RUOD/images/train_clear /workspace/data/RUOD/images/val_clear mkdir -p /workspace/data/RUOD/labels/train_clear /workspace/data/RUOD/labels/val_clear cat /workspace/ruod_clear.yaml EOF path: /workspace/data/RUOD train: images/train_clear val: images/val_clear nc: 4 names: 0: holothurian 1: echinus 2: scallop 3: starfish EOF三个注意点第一path建议写绝对路径YOLO 的目录拼接逻辑在不同版本里有变化写相对路径容易出现Dataset not found的玄学报错第二train和val写的是相对于path的路径不要带前导斜杠带了斜杠框架会把它当成绝对路径第三names的顺序必须和第 3.2 节脚本里的CLASSES一致而且 yaml 里不允许用 tab 缩进编辑器如果默认补全成 tab训练时解析会直接报错。创建完后可以用 python 的yaml.safe_load快速验证一下能否正常解析别等训练到一半才发现格式问题。4. 在 YOLOv10 上跑通多实例学习清晰子集先训模糊子集 MIL 微调4.1 第一步用清晰子集训练一个可用的检测器MIL 不能凭空开始它需要一个实例来源也就是一个能产出候选框的检测器。先用 RUOD 的清晰子集训练 YOLOv10是整条管线的地基。训练命令和 YOLOv8 几乎一样但有几个针对性设置建议把imgsz调到 1280RUOD 的原始图像多是 1080p 大图目标又偏小640 输入下海胆可能只有十几个像素batch根据显存调整8GB 显卡用 8 比较稳32GB 可以试 16。yolo detect train \ modelyolov10s.yaml \ data/workspace/ruod_clear.yaml \ epochs120 \ imgsz1280 \ batch8 \ lr00.01 \ ampTrue \ cacheTrue这里modelyolov10s.yaml是创建一个新的模型结构不是加载预训练权重。想用 COCO 预训练权重做迁移可以改成modelyolov10s.pt对新任务有明显帮助尤其是水下这种和自然图像分布差异大的场景。训练结束后在runs/detect/train*目录里会生成best.pt和last.pt后面 MIL 阶段加载的就是这个best.pt。cacheTrue 会把图像缓存进内存第一次训练会慢一点但能省掉每次 epoch 重新读图的磁盘 IO。4.2 第二步把模糊子集的图像级标签转成 MIL 训练样本这一步是核心。我的做法是加载已经训好的 YOLOv10 作为候选框生成器对每张模糊图跑一次推理。YOLOv10 推理时无 NMS返回的boxes就是一组去重后的候选框这组框就是“包”每个框内的内容就是“实例”。由于模糊图上模型给出的框置信度普遍不高我会把默认的conf0.25降到0.05宁可多留一些低置信度候选也要保住召回。import os import cv2 import glob import numpy as np import torch import torch.nn as nn from torch.utils.data import Dataset, DataLoader from yolov10 import YOLOv10 # 每张模糊图是一个 bagYOLOv10 的候选框区域是 instance class FuzzyBagDataset(Dataset): def __init__(self, img_dir, label_dict, det_model, instance_size64, topn32): self.img_paths sorted(glob.glob(os.path.join(img_dir, *.jpg))) self.label_dict label_dict # {文件名: [类别ID列表]} self.det det_model # 训练好的 YOLOv10 self.instance_size instance_size self.topn topn def __len__(self): return len(self.img_paths) def __getitem__(self, idx): img cv2.imread(self.img_paths[idx]) fname os.path.basename(self.img_paths[idx]) # 图像级多标签四个类别每个 0/1 label np.zeros(4, dtypenp.float32) for c in self.label_dict.get(fname, []): label[c] 1.0 # YOLOv10 端到端输出无 NMS直接把框当实例 results self.det.predict(img, conf0.05, verboseFalse) boxes results[0].boxes.xyxy.cpu().numpy() confs results[0].boxes.conf.cpu().numpy() if len(boxes) 0: # 兜底避免空包让 top-k 池化崩掉 boxes np.array([[0, 0, 64, 64]], dtypenp.float32) confs np.array([0.05], dtypenp.float32) # 按置信度从高到低取前 topn 个框作为实例 keep np.argsort(-confs)[:self.topn] boxes boxes[keep] instances [] for x1, y1, x2, y2 in boxes: x1, y1, x2, y2 int(x1), int(y1), int(x2), int(y2) if x2 x1 or y2 y1: continue crop img[y1:y2, x1:x2] crop cv2.resize(crop, (self.instance_size, self.instance_size)) instances.append(crop) if not instances: instances.append(np.zeros((self.instance_size, self.instance_size, 3), dtypenp.uint8)) instances np.stack(instances) / 255.0 instances torch.from_numpy(instances).permute(0, 3, 1, 2).float() return instances, torch.from_numpy(label)这段 Dataset 的逻辑是读图后先查图级标签然后用训练好的 YOLOv10 在低置信度阈值下找候选框把每个候选框区域抠出来缩放到统一尺寸作为包内的实例集。topn32是经验值太大会混入大量背景噪声太小会丢失真正目标的候选。实例裁剪用resize而不是 letterbox因为 MIL 关心的是“这个区域里有没有某个类”不关心物体的精确位置和尺度直接缩放反而能简化输入。接着定义 MIL 头和 Top-K 池化损失class MILHead(nn.Module): 对包内每个实例做分类再用 Top-K 池化得到包级预测 def __init__(self, instance_size64, num_classes4, top_k8): super().__init__() self.encoder nn.Sequential( nn.Conv2d(3, 32, kernel_size3, stride2, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(32, 64, kernel_size3, stride2, padding1), nn.ReLU(inplaceTrue), nn.AdaptiveAvgPool2d(1), nn.Flatten(), ) self.fc nn.Linear(64, num_classes) self.top_k top_k def forward(self, instances): # instances: [B, num_instances, C, H, W] B, N instances.shape[:2] feats self.encoder(instances.view(B * N, *instances.shape[2:])) cls_logits self.fc(feats).view(B, N, -1) # 每个类别独立取 Top-K 再做平均得到包级 logits topk_vals, _ torch.topk(cls_logits, self.top_k, dim1) bag_logits topk_vals.mean(dim1) return bag_logits mil_model MILHead(instance_size64, num_classes4, top_k8) optimizer torch.optim.AdamW(mil_model.parameters(), lr1e-3) criterion nn.BCEWithLogitsLoss() # 简化训练循环真实场景记得加 train/val 切换和模型保存 dataset FuzzyBagDataset(/workspace/data/RUOD/fuzzy_images, label_dict, det_model) loader DataLoader(dataset, batch_size8, shuffleTrue, num_workers4) for epoch in range(50): for instances, labels in loader: bag_logits mil_model(instances) loss criterion(bag_logits, labels) optimizer.zero_grad() loss.backward() optimizer.step()为什么最后用BCEWithLogitsLossRUOD 模糊子集的图级标签是多标签的同一张图里可能同时有海参和扇贝这是多标签二分类问题不是单标签多分类不能用 CrossEntropy。Top-K 池化里的 K 取 8考虑的是包内候选框一般在 10~30 个取前 8 个既能覆盖真实目标又不会让单框误检完全主导梯度。4.3 第三步把 MIL 头接回 YOLOv10 做端到端微调MILHead 单独训练只是中间产物最终检测还是要回到 YOLOv10 上。我通常会做两阶段先用上面的方式把 MIL 头训到收敛然后把 YOLOv10 检测头里类别得分这一路替换成 MIL 头的预测或者更简单地把 MIL 损失叠加到 YOLOv10 原有的检测损失上在模糊图上做几个 epoch 的微调。这一步需要对 YOLOv10 源码做一个小改动在检测头 forward 返回处保留 decode 前的类别 logits。# 以 ultralytics 源码的 Detect Head 为例改 forward 返回值 # 原来返回 decode 后的预测结果 # 改造成同时返回 decode 后的结果和原始类别 logits return x, mil_logits # mil_logits 供 MIL 损失使用 # 微调循环中对模糊图子 batch 计算两部分损失 # outputs 是正常检测损失mil_logits 走 Top-K 池化后和图级标签做 BCE loss_total loss_det 0.25 * loss_mil这里0.25是 MIL 损失的权重经验上不能太高否则模型会只顾着满足图级标签忽略了框的定位精度。微调时建议把骨干冻结只放开检测头学习率降到1e-4epoch 控制在 20~30因为模糊子集数量有限训练太久容易过拟合到模糊图上的噪点模式。如果不想动源码方案 B 也能用把 4.2 节训练好的 MILHead 保存下来推理时先让 YOLOv10 出候选框再把候选框区域交给 MILHead 做一次类别修正相当于给检测结果加一个图级语义校验。这个方案的好处是 YOLOv10 完全不用重训缺点是推理链路多了一步速度会慢一些适合快速验证 MIL 是否有效果。4.4 训练参数速查表参数建议值说明imgsz1280显存不足用 640RUOD 大图目标偏小分辨率过低海胆会被忽略conf 阈值0.05候选框生成阶段用保召回top_k8Top-K 池化的 K包内实例多时可适当调大到 16MIL 损失权重0.25叠加到检测损失上过高会牺牲定位精度学习率1e-3MIL 头 / 1e-4微调新头用 1e-3微调必须降模糊/清晰采样比1:1每次 batch 里混合两种子集防止遗忘清晰分布训练 epoch微调 20~30弱标签数据量大时不用太长5. 水下目标检测常见问题排查五个 YOLOv10MIL 的踩坑现场5.1 模糊子集上 mAP 永远为 0你拿框级指标评了一个没有框的数据集现象训练一切正常loss 在降但在模糊子集上算 mAP 结果永远是 0怀疑模型完全没学到东西。原因模糊子集很多图像根本没有实例级框标注只有图级标签。拿框级 mAP 去评等于让模型预测一个“标准答案不存在”的任务数值当然为 0。解决先确认评估对象的标注粒度。有框标注的图才跑框级 AP纯图级标签的图改用图像级 F1 或者 Top-K 召回率评估。实践中我的做法是把模糊子集再拆两半一半有框、一半只有图级标签分开报告指标这样既能看定位能力又能看识别能力。5.2 数据 yaml 一跑就报 Dataset not found问题多半在路径拼写现象训练命令刚启动就报Dataset not found或者KeyError: valOSError 提示找不到图片路径。原因yaml 里的path写成了相对路径train和val又带了前导斜杠框架拼接路径时给出的实际位置和你预期不一致。还有一种常见情况是 yaml 用 tab 缩进解析器直接把字段名读错。解决把path改成绝对路径train和val写相对路径且不带前导/创建 yaml 后先用python -c import yaml; print(yaml.safe_load(open(ruod_clear.yaml)))验证一次看结构对不对。YOLO 系列对 yaml 的报错信息通常不够直观这个验证步骤别省。5.3 Loss 在降但模糊图全漏检Max 池化被单个误检带偏现象MIL 训练时 BCE loss 下降非常快最后趋近于 0但可视化模糊图检测结果大量目标没框出来反而是背景纹理处出现一个高置信度误检。原因如果用的是 Max 池化或者 Top-K 里 K 太小包级预测只需要一个实例分数高就能让正类损失变低。模型学到了“找出一个像海胆的纹理”而不是“找出所有海胆”。水下图像里蓝色的海胆和岩石上圆形纹理很像极易触发这种捷径学习。解决把 Max 换成 Top-K 均值池化K 至少取 8同时检查包内得分最高的几个实例看它们对应的区域是不是真的是目标。如果是说明候选框生成阶段阈值太低混入了噪声如果不是把实例的类别得分乘上 YOLOv10 的框置信度让低质量候选框自动降权。5.4 大图直接训练爆显存不要整图进网络先把模糊图切成 patch现象imgsz1280 训练时显存直接 OOM尤其是 batch 稍微调大一点就崩。原因RUOD 图像本身是 1080p 级别YOLOv10 在 1280 分辨率下特征图很大检测头多尺度输出叠加显存开销远高于 640 输入。MIL 阶段如果再把整图当 bag 输入内存里还要缓存候选框区域压力更大。解决模糊子集不走整图用滑窗切成 640×640 的 patch每个 patch 作为独立的候选框来源一张图的所有 patch 组合成一个 bag。滑窗之间设 10%~20% 的 overlap避免目标被切在边界上。梯度累积也能救急但 patch 方案对显存和效果都更友好。5.5 海胆贴边严重被裁走半边检测头对边界目标先天弱势现象最终测试时海胆、扇贝这类靠近图像边缘的目标漏检率高即使有框也只框住半个目标。原因YOLOv10 的检测头输出有下采样倍数靠近图像边界的目标经过多层卷积和 stride 后在最后一层特征图上的响应位置可能落在有效感受野之外。另外滑窗切 patch 时目标如果正好在 patch 边界也会被直接切断。解决推理阶段保持滑窗 overlap 至少 20%训练阶段对边缘目标做“copy-paste”增强把边缘目标随机复制到图像中部提高模型对这些位置的感知。数据增强上也可以把随机缩放范围放开到 0.5~1.5让模型对目标尺度变化更鲁棒。6. 验证 MIL 到底救回多少分桶 AP 与包内得分分布第 6 章不写总结性的废话给一套能直接用的验证方法。不要只盯着一个整体 mAP那会把 MIL 的收益稀释掉。我的做法是先把模糊子集按清晰度分桶再分桶算指标最后看包内得分分布定位失败模式。用 Laplacian 方差量化图像清晰度按阈值分三桶。阈值的选择不用太精确以你数据集的分布画一画直方图再定。import cv2 from pathlib import Path def bucket_by_sharpness(img_dir): buckets {clear: [], blurry: [], heavy: []} for p in Path(img_dir).glob(*.jpg): img cv2.imread(str(p)) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) lap_var cv2.Laplacian(gray, cv2.CV_64F).var() if lap_var 300: buckets[clear].append(p) elif lap_var 100: buckets[blurry].append(p) else: buckets[heavy].append(p) return buckets有框标注的桶直接算 AP只有图级标签的桶算图像级召回率Top-K 预测里包含该类别就算命中。比较的重点是微调前后同一桶上的变化如果 MIL 微调只提升了 heavy 桶而 clear 桶略微下降这是正常现象弱监督信号本就是在牺牲部分强监督精度换取退化场景的鲁棒性。重点观察 heavy 桶有没有明显上升。再看包内得分分布。取一批 heavy 桶中漏检的图打印 YOLOv10 输出的前五个高分框的位置、类别和置信度。model YOLOv10(runs/detect/train/best.pt) for bag_path in heavy_samples: results model.predict(bag_path, conf0.05, verboseFalse) confs results[0].boxes.conf.cpu().numpy() cls_ids results[0].boxes.cls.cpu().numpy() order np.argsort(-confs)[:5] for idx in order: print(box:, results[0].boxes.xyxy.cpu().numpy()[idx], cls:, int(cls_ids[idx]), conf:, round(float(confs[idx]), 3))如果高分框的坐标落在背景暗角或纹理密集区说明 MIL 头学到的是纹理先验而不是目标本身。这时候我会加大 Top-K 中的 K或把 YOLOv10 的候选框置信度作为惩罚项乘进实例得分。如果高分框确实是目标但漏检的是同类另一个低对比度个体那就是候选框生成阶段漏召回需要调低 conf 或提升 imgsz。多实例学习不是万能药它解决的是“标注不到位”的问题而不是“目标不存在”的问题。我用这个方案的教训是MIL 微调后先别急着上线把模糊桶的漏检图逐张看一遍确认 model 是在找目标还是在找纹理。这个习惯帮我挡下了好几次翻车。希望这套从数据准备到分桶评估的流程能帮你在 RUOD 这类弱标注水下数据上少踩些坑。本文还有配套的精品资源点击获取
返回列表