简介:本资源是一套基于孪生神经网络实现点选识别验证码的完整项目,面向计算机、人工智能、通信工程等专业的在校学生、教师及企业员工,尤其适合作为毕业设计、课程设计或项目初期立项演示,也适合具备一定基础的小白进阶学习。压缩包共12个文件,约284KB,包含7个Python源码文件、2张png示意图、1个txt依赖说明、1个cfg模型配置、1个md说明文档,覆盖模型定义、训练、预测与工具函数等模块,结构清晰便于按需查阅。项目代码均经过测试运行成功,答辩评审平均分达96分,已有110人学习下载。读者可从中获得孪生网络与目标检测结合的完整实现思路、可复现的训练与预测脚本、配置文件及数据集使用说明,并能在现有代码基础上修改扩展,用于其他识别任务或二次开发,具备较高的学习与参考价值。
1. 点选验证码识别为什么让传统 OCR 集体翻车
做过爬虫或者自动化测试的人,大概率都遇到过这种场景:页面上弹出一张图,上面散落着「春」「风」「得」「意」几个字,旁边一行小字写着「请依次点击:风、春、意、得」。你拿 Tesseract 或者 PaddleOCR 直接怼上去,识别出来的文字顺序完全是乱的——因为传统 OCR 的思维是「从左到右、从上到下」逐行读取,而点选验证码要的是「按语义顺序点击坐标」。这两个任务的目标根本不在一个维度上。
这就是点选识别(Click Captcha Recognition)要解决的核心问题:它不只是「认字」,而是「认字 + 定位 + 排序」三合一。孪生神经网络(Siamese Network)之所以在这个任务上表现突出,是因为它的本质是学习「两个输入是否相似」的度量,而不是直接做分类。放到点选场景里,我们可以把「提示文字」和「图中每个字符区域」分别编码,然后比对相似度,相似度最高的那个区域就是目标位置。这个思路天然适配「给定一个 query,在候选集里找最匹配项」的需求,比强行套检测+分类的流水线要干净得多。
这篇笔记面向的是有 Python 基础、想自己跑通一套点选识别方案的工程师。我会从数据集的准备讲起,把孪生网络的结构设计、训练细节、推理部署串成一条可复现的路径,中间该踩的坑一个不落。整套方案不依赖任何闭源接口,纯本地训练和推理,代码结构清晰,适合作为自己项目的起点。
2. 孪生网络做点选识别:从任务拆解到数据准备
2.1 点选识别到底在预测什么
先把任务定义清楚。一张点选验证码图片,通常包含以下元素:若干随机排列的字符(中文、数字或字母),以及一行提示文字,告诉你「请依次点击:X、Y、Z」。模型的输出应该是一个有序的坐标列表,对应提示中每个字符在图片中的位置。
形式化地讲,给定图片 I 和提示序列 T = [t1, t2, ..., tn],我们需要输出坐标序列 P = [(x1,y1), (x2,y2), ..., (xn,yn)],使得每个 (xi, yi) 指向图中字符 ti 的位置。
传统做法分两步:先做字符检测,把图中所有字符框出来并分类;再拿提示文字去匹配分类结果。这个方案的问题在于,检测模型对字符的分类精度直接决定了最终效果,而点选验证码里的字符往往有旋转、扭曲、粘连、干扰线,分类器很容易崩。
孪生网络的思路绕开了「分类」这个环节。我们不关心图中那个字到底是什么,只关心「它和提示里的某个字是不是同一个字」。具体来说,把提示中的每个字符切出来作为 query 图像,把图中每个候选区域切出来作为 candidate 图像,两者送入同一个编码器,得到特征向量,然后计算余弦相似度或欧氏距离。相似度最高的候选区域就是目标位置。
这样做的好处是:编码器只需要学好「相同字符的特征要接近,不同字符的特征要远离」这个度量关系,不需要对每个字符类别都有足够的训练样本。对于字符种类多、长尾分布严重的场景,这个优势非常明显。
2.2 数据集从哪来、怎么造
标题里提到了数据集,但点选验证码的公开数据集并不多。常见做法是自己合成。合成的好处是你可以控制字符集、干扰强度、字体样式、背景复杂度,而且标注是天然准确的——你知道每个字符贴在了哪个位置。
合成流程大致如下:准备一个字符集(比如 500 个常用汉字),准备若干背景图(纯色、纹理、自然场景图都可以),准备若干字体文件。每次生成时,随机选 n 个字符,随机选位置贴上去,记录每个字符的 bounding box 和对应的文字内容。同时生成提示文字,提示中的字符顺序和贴图顺序可以不一致,这样模型才能学会「按提示顺序」而不是「按贴图顺序」输出。
import random from PIL import Image, ImageDraw, ImageFont def generate_click_captcha(chars, fonts, bg_images, num_chars=4, img_size=(300, 200)): """ 合成一张点选验证码图片 chars: 候选字符列表 fonts: 字体文件路径列表 bg_images: 背景图路径列表 num_chars: 图中需要点击的字符数量 """ bg = Image.open(random.choice(bg_images)).resize(img_size) draw = ImageDraw.Draw(bg) # 随机选 num_chars 个不重复的字符 selected = random.sample(chars, num_chars) # 打乱提示顺序,让提示顺序和贴图顺序不同 prompt_order = selected.copy() random.shuffle(prompt_order) annotations = [] for ch in selected: font = ImageFont.truetype(random.choice(fonts), random.randint(28, 42)) # 随机位置,留出边距 x = random.randint(10, img_size[0] - 60) y = random.randint(10, img_size[1] - 60) # 随机颜色,和背景有对比 color = (random.randint(0, 100), random.randint(0, 100), random.randint(0, 100)) draw.text((x, y), ch, font=font, fill=color) annotations.append({"char": ch, "bbox": (x, y, x + 50, y + 50)}) prompt = "请依次点击:" + "、".join(prompt_order) return bg, annotations, prompt这段代码的关键点在于:prompt_order和selected是分开打乱的,这样提示顺序和图中字符的物理排列顺序无关,模型必须真正理解「提示中的每个字对应图中哪个区域」,而不是靠位置先验去猜。bbox这里给了一个固定大小的框,实际使用时可以根据字体的getbbox方法精确计算,减少背景噪声的引入。
参数方面,num_chars一般设 3 到 5,太多会导致图片拥挤、字符重叠;字体大小 28 到 42 是比较安全的范围,太小了编码器学不到判别性特征,太大了字符之间容易粘连。背景图建议至少准备 200 张以上,否则模型容易过拟合到特定背景。
2.3 孪生网络的输入构造:正负样本对怎么配
孪生网络训练时需要成对的样本。对于点选任务,正样本对是「提示中的某个字符」和「图中对应位置的字符图像」,负样本对是「提示中的某个字符」和「图中其他位置的字符图像」。
具体构造时,从一张合成图中,我们可以生成 num_chars 个正样本对和 num_chars × (num_chars - 1) 个负样本对。如果每张图有 4 个字符,那就是 4 个正对和 12 个负对。正负比例 1:3 是比较合理的,既不会让模型偏向某一类,也不会让训练集过度膨胀。
这里有一个容易翻车的地方:负样本对不能随便选。如果负样本中的字符和正样本中的字符恰好是同一个字(比如提示里有两个「春」),那这个负样本对其实是正样本,强行标成负会污染训练。合成数据时可以强制每张图的字符不重复,从源头避免这个问题。真实场景中如果遇到重复字符,需要在匹配阶段做特殊处理,这个后面会讲。
3. 编码器选型与网络结构:从 CNN 到度量学习
3.1 编码器 backbone 怎么选
孪生网络的性能上限很大程度上由编码器决定。编码器的作用是把一张字符小图映射成一个固定维度的特征向量,使得相同字符的特征向量距离近,不同字符的距离远。
常见的选择有几种。轻量级的可以用一个 4 层卷积加全局平均池化的结构,参数量在 100K 左右,推理速度快,适合部署到边缘设备。中等规模可以用 ResNet-18 或 MobileNetV3 的前几层,参数量在 1M 到 3M 之间,精度和速度比较平衡。如果对精度要求极高,可以用 ResNet-50 甚至更大的 backbone,但推理延迟会明显上升。
我一般会先用一个自定义的小型 CNN 跑通全流程,确认数据管线和训练逻辑没问题,再换更大的 backbone 做精度调优。小型 CNN 的结构大概是:Conv(3→32, 3×3) → BN → ReLU → MaxPool → Conv(32→64, 3×3) → BN → ReLU → MaxPool → Conv(64→128, 3×3) → BN → ReLU → AdaptiveAvgPool → FC(128→embedding_dim)。embedding_dim 通常设 128 或 256,太小了表达能力不够,太大了容易过拟合且推理变慢。
import torch import torch.nn as nn import torch.nn.functional as F class CharEncoder(nn.Module): def __init__(self, embedding_dim=128): super().__init__() self.conv1 = nn.Conv2d(3, 32, 3, padding=1) self.bn1 = nn.BatchNorm2d(32) self.conv2 = nn.Conv2d(32, 64, 3, padding=1) self.bn2 = nn.BatchNorm2d(64) self.conv3 = nn.Conv2d(64, 128, 3, padding=1) self.bn3 = nn.BatchNorm2d(128) self.pool = nn.AdaptiveAvgPool2d(1) self.fc = nn.Linear(128, embedding_dim) def forward(self, x): # x: (B, 3, H, W) x = F.relu(self.bn1(self.conv1(x))) x = F.max_pool2d(x, 2) x = F.relu(self.bn2(self.conv2(x))) x = F.max_pool2d(x, 2) x = F.relu(self.bn3(self.conv3(x))) x = self.pool(x).flatten(1) # (B, 128) x = self.fc(x) # L2 归一化,后续用余弦相似度 return F.normalize(x, p=2, dim=1)这个编码器的输入是 32×32 或 48×48 的字符小图,输出是 128 维的单位向量。L2 归一化这一步很关键,它把特征向量约束在单位球面上,后续计算余弦相似度时只需要做点积,数值稳定性也更好。
3.2 损失函数:对比损失还是三元组损失
孪生网络的训练目标由损失函数定义。最经典的是对比损失(Contrastive Loss),它的形式是:对于正样本对,拉近两者距离;对于负样本对,如果距离小于 margin,就推开,否则不产生梯度。
对比损失的问题是,它只考虑了「对」之间的关系,没有考虑「对与对」之间的相对关系。三元组损失(Triplet Loss)引入了锚点(anchor)、正样本、负样本的三元组,要求锚点到正样本的距离比到负样本的距离小至少一个 margin。这个约束更强,学到的特征判别性通常更好。
在点选任务里,我一般用三元组损失,因为「提示字符」天然就是锚点,「图中对应字符」是正样本,「图中其他字符」是负样本,三元组的构造非常自然。margin 设 0.3 到 0.5 之间比较合适,太小了约束不够,太大了训练容易震荡。
def triplet_loss(anchor, positive, negative, margin=0.4): """ anchor: 提示字符的特征 (B, D) positive: 图中对应字符的特征 (B, D) negative: 图中其他字符的特征 (B, D) """ pos_dist = F.pairwise_distance(anchor, positive, p=2) neg_dist = F.pairwise_distance(anchor, negative, p=2) loss = F.relu(pos_dist - neg_dist + margin) return loss.mean()这里用的是欧氏距离,因为特征已经 L2 归一化了,欧氏距离和余弦距离是等价的。F.pairwise_distance计算的是每个样本对的距离,返回的是 (B,) 的向量,最后取 mean 得到标量损失。
训练时还有一个技巧:难负样本挖掘(Hard Negative Mining)。随机选的负样本可能和锚点差异很大,模型很容易就分开了,梯度信号很弱。如果专门挑那些和锚点相似度高但又不是同一个字的负样本,训练效率会高很多。实现上可以在每个 batch 内计算所有负样本对的距离,选距离最小的那个作为难负样本。
3.3 训练流程与关键超参
训练流程大致是:每个 epoch 遍历所有合成图,对每张图构造三元组,前向传播得到特征,计算损失,反向传播更新参数。优化器用 Adam,学习率初始设 1e-3,每 10 个 epoch 衰减到原来的 0.5 倍。Batch size 设 64 或 128,太小了梯度噪声大,太大了显存吃不消。
import torch.optim as optim from torch.utils.data import DataLoader def train_one_epoch(model, dataloader, optimizer, margin=0.4): model.train() total_loss = 0 for anchor_imgs, pos_imgs, neg_imgs in dataloader: anchor_imgs = anchor_imgs.cuda() pos_imgs = pos_imgs.cuda() neg_imgs = neg_imgs.cuda() anchor_feat = model(anchor_imgs) pos_feat = model(pos_imgs) neg_feat = model(neg_imgs) loss = triplet_loss(anchor_feat, pos_feat, neg_feat, margin) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() return total_loss / len(dataloader)这里有一个工程上的细节:anchor、positive、negative 三张图都要过同一个编码器,如果分三次 forward,计算量是 3 倍。可以把三张图拼成一个 batch 一次性 forward,然后再拆开,这样 GPU 利用率更高。具体做法是把 (B, 3, H, W) 的三组张量 concat 成 (3B, 3, H, W),forward 后再 split。
训练过程中要监控验证集上的准确率。验证集的构造和训练集类似,但要用不同的随机种子和不同的背景图,确保模型没有过拟合到训练背景。如果训练 loss 持续下降但验证准确率停滞甚至下降,说明过拟合了,需要加数据增强(随机旋转、颜色抖动、高斯噪声)或者减小模型容量。
4. 推理阶段:从特征匹配到坐标输出
4.1 候选区域的提取策略
推理时,我们拿到一张完整的点选验证码图片和提示文字。第一步是把提示中的每个字符切出来,作为 query 图像。这一步相对简单,因为提示文字通常是规整的印刷体,用简单的投影法或者连通域分析就能切分。
第二步是把图中所有可能的字符区域提取出来作为 candidate。这一步比较麻烦,因为图中的字符可能有旋转、扭曲、粘连,而且还有干扰线和背景噪声。常见做法有两种:一种是用一个轻量的目标检测模型(比如 YOLO 的极简版)先把所有字符框出来;另一种是用滑动窗口或者选择性搜索(Selective Search)生成候选区域。
我一般会用一个简化版的检测头:在编码器的特征图上做一次 1×1 卷积,输出每个位置的「是否有字符」得分和边界框回归。这个检测头可以和孪生编码器共享 backbone,训练时联合优化。如果不想引入检测,也可以用 OpenCV 的 MSER(Maximally Stable Extremal Regions)算法提取候选区域,然后过滤掉面积过小或过大的区域。
import cv2 import numpy as np def extract_candidates(image_path, min_area=200, max_area=5000): """ 用 MSER 提取候选字符区域 返回候选区域的裁剪图像列表和对应的 bbox """ img = cv2.imread(image_path) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) mser = cv2.MSER_create() regions, _ = mser.detectRegions(gray) candidates = [] bboxes = [] for region in regions: x, y, w, h = cv2.boundingRect(region) area = w * h if min_area < area < max_area and 0.3 < w / h < 3.0: crop = img[y:y+h, x:x+w] crop = cv2.resize(crop, (48, 48)) candidates.append(crop) bboxes.append((x, y, w, h)) # 去重:IoU 大于 0.5 的只保留一个 keep = [] for i in range(len(bboxes)): if i in keep: continue for j in range(i + 1, len(bboxes)): if iou(bboxes[i], bboxes[j]) > 0.5: break else: keep.append(i) return [candidates[i] for i in keep], [bboxes[i] for i in keep]MSER 的参数需要根据实际图片调整。min_area和max_area控制候选区域的大小范围,太小了会把噪声当成字符,太大了会把多个字符框在一起。宽高比过滤可以去掉细长的干扰线。IoU 去重是为了避免同一个字符被多次检测。
4.2 相似度匹配与顺序输出
拿到 query 特征和所有 candidate 特征后,对每个 query 字符,计算它和所有 candidate 的余弦相似度,取相似度最高的那个作为匹配结果。如果提示中有多个字符,每个字符独立匹配,最后按提示顺序输出坐标。
def match_and_output(model, query_imgs, candidate_imgs, candidate_bboxes): """ query_imgs: 提示字符图像列表 candidate_imgs: 候选区域图像列表 candidate_bboxes: 候选区域坐标列表 返回按提示顺序排列的坐标列表 """ model.eval() with torch.no_grad(): # 批量编码 query_tensor = torch.stack([preprocess(img) for img in query_imgs]).cuda() cand_tensor = torch.stack([preprocess(img) for img in candidate_imgs]).cuda() query_feat = model(query_tensor) # (Q, D) cand_feat = model(cand_tensor) # (C, D) # 余弦相似度矩阵 sim_matrix = query_feat @ cand_feat.T # (Q, C) # 每个 query 取相似度最高的 candidate best_indices = sim_matrix.argmax(dim=1) # (Q,) result = [] for idx in best_indices: result.append(candidate_bboxes[idx.item()]) return result这里有一个潜在问题:如果两个 query 字符匹配到了同一个 candidate,说明匹配出错了。解决办法是在匹配时加一个约束,已经匹配过的 candidate 不再参与后续匹配。实现上可以用贪心策略:先匹配相似度最高的 query,然后把它选中的 candidate 从候选集中移除,再匹配下一个。
另一个问题是阈值。如果所有 candidate 的相似度都低于某个阈值(比如 0.5),说明图中可能根本没有这个字符,或者候选区域提取失败了。这时候应该返回一个「未找到」的标志,而不是强行选一个最高分。
4.3 后处理:坐标映射与可视化
模型输出的坐标是在候选区域裁剪图上的坐标,需要映射回原图。如果候选区域是从原图上裁剪的,那 bbox 本身就是原图坐标,直接用即可。如果做了缩放,需要按比例还原。
可视化是一个很实用的调试手段。把匹配结果画在原图上,用不同颜色标注不同 query 的匹配位置,可以直观地看出模型哪里匹配对了、哪里匹配错了。
def visualize_result(image_path, bboxes, prompt_chars, output_path="result.jpg"): img = cv2.imread(image_path) colors = [(0, 0, 255), (0, 255, 0), (255, 0, 0), (0, 255, 255), (255, 0, 255)] for i, (bbox, ch) in enumerate(zip(bboxes, prompt_chars)): x, y, w, h = bbox color = colors[i % len(colors)] cv2.rectangle(img, (x, y), (x + w, y + h), color, 2) cv2.putText(img, f"{i+1}:{ch}", (x, y - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2) cv2.imwrite(output_path, img)这个可视化函数会把每个匹配结果按顺序标号,并写上对应的提示字符。如果发现某个框明显偏了,或者两个框重叠了,就说明匹配环节有问题,需要回头检查候选区域提取或者相似度计算。
5. 避坑指南:点选识别落地时最容易翻车的五个地方
5.1 合成数据太干净,真实场景直接崩
现象:在合成数据集上准确率 99%,一上真实网站准确率掉到 30%。
原因:合成数据的背景太单一、字符太清晰、干扰太少。真实点选验证码往往有复杂的背景纹理、半透明干扰线、字符扭曲和颜色渐变。模型在合成数据上学到的特征和真实数据分布差距太大。
解决:合成时加入更强的数据增强。背景图要用自然场景图而不是纯色图,字符要加随机旋转(±15 度)、弹性形变、颜色抖动,还要叠加随机干扰线和噪点。如果条件允许,收集几百张真实图片做微调,哪怕标注少一点,也能显著提升泛化能力。
5.2 候选区域提取漏掉了目标字符
现象:匹配结果总是差一个,某个提示字符找不到对应的框。
原因:MSER 或者检测头对某些字符不敏感,比如颜色和背景接近的字符、被干扰线穿过的字符、面积特别小的字符。
解决:降低候选区域的过滤阈值,宁可多提一些候选,也不要漏掉。多出来的候选会被相似度匹配自然过滤掉,但漏掉的候选是永远找不回来的。另外可以多尺度提取候选,在不同分辨率下各跑一次 MSER,然后合并结果。
5.3 提示字符切分错误导致 query 本身就是错的
现象:模型匹配结果看起来「有道理」,但和预期完全不符。
原因:提示文字的切分出了问题。比如「请依次点击:春、风、得、意」这行字,如果切分时把「春」和「、」切在了一起,query 图像里就混入了标点,编码出来的特征自然不对。
解决:切分提示文字时,先用颜色阈值或者形态学操作去掉标点符号,只保留汉字或字母数字。切分后做一次尺寸归一化,确保每个 query 图像的大小和训练时一致。如果提示文字有旋转或倾斜,先做倾斜校正再切分。
5.4 相似度阈值设死,换一批数据就失效
现象:在 A 网站上调好的阈值 0.6,换到 B 网站后要么全部匹配失败,要么全部匹配到错误位置。
原因:不同网站的字符字体、大小、干扰程度不同,模型输出的相似度分布也不同。固定阈值无法适应这种分布变化。
解决:不要用固定阈值,用相对阈值。比如取最高相似度和次高相似度的比值,如果比值大于 1.2 才认为匹配可信。或者用 batch 内的相似度均值和标准差做归一化,动态判断。更稳妥的做法是保留一个小的验证集,每次换场景时重新校准阈值。
5.5 GPU 显存不够导致 batch size 被迫调小,训练不稳定
现象:训练 loss 震荡严重,准确率上不去。
原因:显存不够,batch size 只能设 8 或 16,梯度噪声太大,BatchNorm 的统计量也不准确。
解决:用梯度累积(Gradient Accumulation)。每次 forward 一个小 batch,累积多次梯度后再更新参数,等效于大 batch。另外可以把编码器的输入分辨率从 48×48 降到 32×32,显存占用能减少一半以上。如果还是不够,用混合精度训练(AMP),显存再省 30% 左右。
scaler = torch.cuda.amp.GradScaler() for i, (anchor, pos, neg) in enumerate(dataloader): with torch.cuda.amp.autocast(): anchor_feat = model(anchor.cuda()) pos_feat = model(pos.cuda()) neg_feat = model(neg.cuda()) loss = triplet_loss(anchor_feat, pos_feat, neg_feat) / accum_steps scaler.scale(loss).backward() if (i + 1) % accum_steps == 0: scaler.step(optimizer) scaler.update() optimizer.zero_grad()这段代码把 loss 除以累积步数,保证梯度尺度不变。scaler自动处理浮点精度,避免梯度下溢。累积步数一般设 4 或 8,等效 batch size 就是实际 batch size 乘以累积步数。
6. 进阶技巧:用难负样本挖掘和在线评估把准确率再拉一截
训练到后期,随机负样本已经很难提供有效的梯度信号了。这时候需要上难负样本挖掘。具体做法是:每个 epoch 结束后,用当前模型对所有训练样本做一次推理,找出那些「模型认为相似但实际不是同一个字」的负样本对,把它们加入下一个 epoch 的训练集,并赋予更高的采样权重。
这个策略的本质是让模型专注于它目前还分不清的样本。实现上不需要改网络结构,只需要在 DataLoader 的采样器上做文章。我一般会维护一个难负样本池,每个 epoch 从池中采样 30% 的负样本,剩下 70% 从随机负样本中采。池子每两个 epoch 更新一次,避免模型过拟合到特定的难样本。
另一个实用技巧是在线评估。每训练 N 个 batch,抽一小批验证数据跑一次匹配准确率,如果连续几次准确率没有提升,就降低学习率或者提前停止。这个逻辑用回调函数实现即可,不需要额外的框架。
class HardNegativeMiner: def __init__(self, model, dataloader, top_k=100): self.model = model self.dataloader = dataloader self.top_k = top_k self.hard_pool = [] def update(self): self.model.eval() all_pairs = [] with torch.no_grad(): for anchor, pos, neg in self.dataloader: anchor_feat = self.model(anchor.cuda()) neg_feat = self.model(neg.cuda()) # 计算锚点和负样本的相似度 sim = (anchor_feat * neg_feat).sum(dim=1) for i in range(len(sim)): all_pairs.append((sim[i].item(), anchor[i], neg[i])) # 按相似度降序排列,取 top_k 作为难负样本 all_pairs.sort(key=lambda x: x[0], reverse=True) self.hard_pool = all_pairs[:self.top_k] self.model.train()这个 miner 每次更新时遍历一遍训练集,计算所有锚点-负样本对的相似度,取相似度最高的 top_k 作为难负样本。这些样本在下一个 epoch 中会被优先采样。top_k一般设 100 到 500,太少了多样性不够,太多了和随机采样差别不大。
最后说一个我自己的习惯:每次调整模型结构或者超参后,不要只看最终的准确率数字,一定要把匹配错误的样本可视化出来看。很多时候准确率只掉了 1%,但错误样本的分布完全变了——可能之前错的是相似字,现在错的是被遮挡的字。看懂错误模式,比盲目调参有效得多。希望帮到你。
本文还有配套的精品资源,点击获取