简介:基于Python与CNN模型的猫狗识别项目源码及文档说明,面向计算机相关专业学生,适用于期末大作业、课程设计或毕业设计等场景,内容涵盖模型训练、图像预处理、预测推理与图形交互界面,可直接作为图像分类任务的学习范本和可运行示例。压缩包仅7KB,共6个文件,包含5个Python脚本和1个Markdown说明文档,分别对应训练、预测、图像处理、界面展示与项目说明等模块,结构清晰,便于快速定位和二次修改;其中说明文档对运行方式和代码结构做了简要梳理,进一步降低使用门槛。目前已有244人学习下载,尤其适合希望快速搭建项目的新手参考。项目代码附带注释,降低了理解门槛;界面美观、操作简单,功能完整,经过严格调试可确保稳定运行,下载后简单部署即可使用,也可作为期末答辩或毕业设计的实用成果。
1. 猫狗识别期末作业到底在考什么:从“能跑”到“能答辩”差在哪
“基于Python+CNN模型的猫狗识别项目”这个名字在期末大作业里出现频率极高:数据集公开、模型经典、界面直观,看上去没什么门槛。可真正上手的人很快会意识到,这条链路从图片读入到GUI显示,每一步都有隐藏细节。数据目录怎么划、训练集要不要做增强、模型最后该用sigmoid还是softmax、GUI加载大模型会不会卡死,这些都直接影响最终效果与答辩观感。本文按“数据集准备 → CNN建模与训练 → GUI封装 → 排错 → 进阶验证”的顺序,把一份能跑的作业拆成能讲清原理的项目。适合课程设计、期末大作业,以及想入门CV但不想只跑通demo的读者。
2. 准备猫狗图像数据集:下载、清洗与按比例划分目录的落地做法
2.1 原始数据的目录观察与清洗
常见做法是使用 Kaggle 公开的 Dogs vs Cats 数据集,train 压缩包解压后是一整个目录,里面只有cat.0.jpg、dog.112.jpg这种带前缀的文件名。不少同学拿到手就开始写模型,结果所有猫狗混在一个文件夹里,模型根本没法判断标签从哪来。我一般会先跑一段脚本把目录结构看一眼,确认文件数量和命名格式,再决定怎么划分。
ls data/train | head -5 ls data/train | wc -l ls data/train | grep -E "^dog\." | wc -l这段命令用来确认原始目录里有 25000 张左右图片,猫狗各半,文件名前缀统一。如果数字对不上,后续flow_from_directory生成的标签就会错位。还有一个容易被忽略的点:Kaggle 原始包里偶尔混入损坏的图片,去年我帮人排查时发现一张cat.991.jpg是 0 字节文件,这类坏图会在读入时报错,需要在清洗阶段直接过滤掉。
import os from PIL import Image src_dir = 'data/train' bad_files = [] for f in os.listdir(src_dir): path = os.path.join(src_dir, f) try: with Image.open(path) as im: im.verify() except Exception: bad_files.append(f) print('bad files:', bad_files[:10])这段逻辑在抛异常时把文件名收集起来,而不是马上崩溃。im.verify()比load()轻量,只校验文件完整性,不把整张图读进内存,25000 张图跑完也就十几秒。如果发现坏图,直接在原目录里删除或记录路径跳过,避免训练到一半才炸。需要提醒的是,Windows 下解压可能产生文件名编码异常,如果os.listdir报 UnicodeDecodeError,用shutil重新解压而不是手动改文件名。
2.2 划分训练集与验证集:用脚本按类别复制而不是移动
划分目录这一步,新手最容易写错的是用os.rename把原始文件移走,一旦脚本中间报错,原始数据也被改乱了。我一般用shutil.copy,保留data/train作为备份,清洗和划分都基于副本。目标结构是标准 Kerasflow_from_directory兼容格式:
dataset/ train/ cats/ cat.*.jpg dogs/ dog.*.jpg val/ cats/ dogs/目录名用复数cats、dogs,而不是cat、dog。flow_from_directory会按子目录名生成标签,类别名叫cat还是cats不影响数值标签,但是class_indices的词典输出在 GUI 阶段要拿来映射预测结果,名字越直观越不容易错。
import os import shutil import random src_dir = 'data/train' train_dir = 'dataset/train' val_dir = 'dataset/val' val_ratio = 0.2 for cls, dirname in [('cat', 'cats'), ('dog', 'dogs')]: files = [f for f in os.listdir(src_dir) if f.startswith(cls + '.')] random.seed(42) random.shuffle(files) val_count = int(len(files) * val_ratio) os.makedirs(os.path.join(train_dir, dirname), exist_ok=True) os.makedirs(os.path.join(val_dir, dirname), exist_ok=True) for f in files[:val_count]: shutil.copy(os.path.join(src_dir, f), os.path.join(val_dir, dirname, f)) for f in files[val_count:]: shutil.copy(os.path.join(src_dir, f), os.path.join(train_dir, dirname, f)) print(dirname, 'val:', val_count, 'train:', len(files) - val_count)random.seed(42)保证每次跑出的划分结果一致,这对复现实验特别重要。期末项目通常不需要真正的五折交叉验证,一个固定的 8:2 划分足够说明问题。划分后我习惯打印每个目录的os.listdir数量,确认cat和dog的图片没有交叉混入。这里还有一个细节:验证集和训练集必须来自同一个原始目录的不同文件,绝不能先做数据增强生成了一批图片再混进去划分,那会让验证集出现训练集的近重复样本,得到的准确率虚高,答辩被问两句就会露馅。
2.3 数据增强:ImageDataGenerator 里 6 个关键参数
猫狗识别任务在 25000 张原始图上直接训练,裸 CNN 也能跑到 90% 以上,但期末作业通常课时紧张,很多人只取 5000 张图训练,这时候数据增强就是性价比最高的正则化手段,等效地扩大训练集,也降低过拟合风险。ImageDataGenerator的配置看起来参数多,实际针对猫狗图,我一般只动 6 个旋钮。
from tensorflow.keras.preprocessing.image import ImageDataGenerator train_datagen = ImageDataGenerator( rescale=1.0 / 255, rotation_range=15, width_shift_range=0.1, height_shift_range=0.1, shear_range=0.1, zoom_range=0.1, horizontal_flip=True, fill_mode='nearest' ) val_datagen = ImageDataGenerator(rescale=1.0 / 255)rescale把像素从 0-255 压到 0-1,这是 CNN 训练的标配,不缩放也能跑但收敛更慢、波动更大。旋转、平移、缩放这组参数控制在 0.1-0.15 之间,猫狗的形态不会因为旋转 90 度变成另一个物种,但能模拟拍摄角度变化。真正关键的是horizontal_flip,猫狗姿态天然左右对称,水平翻转不会破坏语义,是这几个增强里收益最高的一项。fill_mode='nearest'解决平移和旋转后边缘空洞的填充问题,如果是纯色背景数据集,可以改成fill_mode='constant',但猫狗照片背景复杂,最近邻填充最稳。
验证集和测试集绝不能做数据增强,只做rescale。这一点很多教学代码没强调,学生习惯把同一套ImageDataGenerator喂给validation_data,验证集每轮都被随机变换,结果就是验证损失上下震荡,早停回调判断失误。验证集的角色是评估模型在未知数据上的真实表现,必须保持固定。
3. 搭建 CNN 模型并跑通训练:卷积结构、损失函数与训练脚本
3.1 模型结构设计:三层卷积加全连接,为什么不直接堆 VGG
给期末项目选模型,我坚持两个原则:结构能对着板书讲清楚,训练时间在一个小时内能完成。VGG16、ResNet34 这类预训练模型准确率确实高,但参数动辄几千万,一次训练在 CPU 上可能要跑四五个小时,GPU 显存也容易爆,关键是答辩时老师会让你解释每个卷积层的意义,背文档可背不过去。自己从零搭一个三层卷积的小网络,反而每一步都能画出特征图尺寸变化。
from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Conv2D, MaxPooling2D, Flatten, Dense, Dropout model = Sequential([ Conv2D(32, (3, 3), activation='relu', input_shape=(128, 128, 3)), MaxPooling2D(pool_size=(2, 2)), Conv2D(64, (3, 3), activation='relu'), MaxPooling2D(pool_size=(2, 2)), Conv2D(128, (3, 3), activation='relu'), MaxPooling2D(pool_size=(2, 2)), Flatten(), Dropout(0.5), Dense(512, activation='relu'), Dense(1, activation='sigmoid') ]) model.summary()输入尺寸选 128x128,是精度和显存消耗的折中点。用 224x224 会让小猫小狗的毛发细节更清晰,但三层卷积的感受野覆盖不了那么大范围,收益有限;用 64x64 训练快,但卷积下采样后特征太少,准确率明显走低。第一个卷积层 32 个通道,第二层 64,第三层 128,按 2 倍递增,这是经典设计模式。每层卷积后面接一个 2x2 最大池化,特征图从 128 变 64 再变 32 最后 16,这个尺寸变化链在答辩时值得手写一遍。最后的Dense(512)是唯一一层大参数全连接,配合Dropout(0.5)随机丢弃一半神经元来抗过拟合。
model.summary()输出里能看到参数量,这个数字也可以写进作业文档。这套结构总参数量大约 340 万,VGG16 是 1.38 亿,差距一目了然。在 10000 张训练图上,这个规模足够拟合而不会“记住”训练集。
3.2 损失函数与优化器:二分类这样配最稳
猫狗识别是标准的二分类,模型的最后一层是 1 个神经元 + sigmoid,输出值 0 到 1 之间,代表“是狗”的概率。这时候损失函数用binary_crossentropy,优化器首选 Adam。这两个选择背后有明确的数学原因,也是期末答辩高频考点:sigmoid 输出一个概率值,交叉熵衡量预测分布与真实标签分布的距离,类别不平衡时比均方误差更稳定;Adam 在 CNN 训练里开箱即用,不需要手动调学习率衰减策略。
model.compile( optimizer='adam', loss='binary_crossentropy', metrics=['accuracy'] )如果有人把最后一层写成Dense(2)+ softmax,就会变成多分类形式,猫和狗两个类别各自输出一个概率,两个概率和为 1。这两种写法在这个任务上都能收敛,但二分类用 sigmoid 更省参数、输出更直观,GUI 里直接显示 0.87 就能判断是狗。还有一个细节:metrics里的accuracy和损失不是同一回事,准确率只统计大于 0.5 判对的比例,损失衡量的是置信度差距,答辩时说清楚这一点,比只报准确率更有含金量。
3.3 训练脚本与回调:跑之前先确认这 4 个参数
训练脚本本身不长,但fit的 4 个参数经常被照搬后翻车:steps_per_epoch、validation_steps、epochs、callbacks。前两个如果不指定,fit会自动用len(train_generator)推导,但因为flow_from_directory是按批次无限生成的,不写这两个参数在旧版本 Keras 里会报错。
from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint steps_per_epoch = train_generator.samples // train_generator.batch_size validation_steps = val_generator.samples // val_generator.batch_size checkpoint = ModelCheckpoint( 'cat_dog_model.h5', monitor='val_accuracy', save_best_only=True, verbose=1 ) early_stop = EarlyStopping( monitor='val_loss', patience=5, restore_best_weights=True ) history = model.fit( train_generator, steps_per_epoch=steps_per_epoch, validation_data=val_generator, validation_steps=validation_steps, epochs=30, callbacks=[checkpoint, early_stop] )train_generator.samples是生成器读取到的训练图片总数,除以batch_size后取整,得到每个 epoch 的迭代步数。这里有个隐藏风险:samples必须是 batch_size 的整数倍,否则最后一个 batch 会塌一个角。比如 8000 张训练图配 batch_size=32,8000 // 32 = 250,刚好整除;如果是 8002 张,250 步只会读到 8000 张,有两张图永远不进训练。保险起见,构建生成器时确认样本数能被 batch_size 整除,或在遍历前手动取整。
EarlyStopping的patience=5表示验证损失连续 5 轮不下降就停止训练,配合restore_best_weights=True,不需要人工从训练记录里挑最佳模型。ModelCheckpoint每轮验证准确率提升就覆盖保存一次 h5 文件,这个文件后面 GUI 直接加载。epochs 设 30,通常第 10 到 15 轮就会被早停触发,训练时间可控。保存模型用save_best_only=True能避免最后一轮过拟合权重覆盖最佳权重,这是拿去做作业和 GUI 最容易踩的空子。
4. 给 CNN 套上 GUI 界面:Tkinter 选图、加载模型与展示预测
4.1 GUI 的最小骨架:按钮、标签与文件选择框
Tkinter 是 Python 自带的 GUI 库,期末项目要求“有界面”时它是最省事的方案,不需要额外安装依赖,打包成 exe 也比较顺。我一般把界面做成三块:上方放一个“选择图片”按钮和图片预览区域,中间放“开始识别”按钮,下方用大号文字显示预测结果和置信度。核心代码就是监听文件选择事件、更新预览、调用模型推理并回填结果。
import tkinter as tk from tkinter import filedialog from PIL import Image, ImageTk import numpy as np from tensorflow.keras.models import load_model model = load_model('cat_dog_model.h5') def select_image(): path = filedialog.askopenfilename( filetypes=[('Image Files', '*.jpg *.jpeg *.png')] ) if not path: return img = Image.open(path).resize((200, 200)) photo = ImageTk.PhotoImage(img) label_img.config(image=photo) label_img.image = photo entry_path.delete(0, tk.END) entry_path.insert(0, path) def predict_image(): path = entry_path.get() if not path: result_label.config(text='请先选择图片') return img = Image.open(path).resize((128, 128)) arr = np.array(img) / 255.0 arr = np.expand_dims(arr, axis=0) p = model.predict(arr, verbose=0)[0][0] class_name = '狗' if p >= 0.5 else '猫' result_label.config(text=f'{class_name} 置信度: {p:.2f}')这里有一个非常隐蔽的 Tkinter 特性:label_img.image = photo这一行不能省。ImageTk.PhotoImage对象如果只赋值给局部变量photo,函数结束后会被垃圾回收,界面上图片立刻消失变成空白。把 photo 挂到 label 的实例属性上,是防止它被回收的标准操作,几乎每个入门 GUI 项目都会在这里翻一次车。
预测结果用p >= 0.5判定为狗,这个阈值可以做成参数暴露给用户。实际使用中,如果一张图模型输出 0.51,界面显示“狗”但置信度很低,用户会疑惑,所以 GUI 上最好同时显示原始置信度,而不是只给一个分类名。这就是很多作业“有界面但不好用”和“有界面且能解释”的分水岭。
4.2 让 GUI 不卡死:预测放到线程或 after 回调里
模型推理是耗时操作,在一张 128x128 图上推理大约需要几秒,如果用 CPU 跑更大的模型更慢。如果在 Tkinter 主线程里直接调用model.predict,整个窗口会冻结——无法拖动、无法点击,看起来像程序死掉了,这是“GUI 卡死”最常见的来源。
import threading import queue pred_queue = queue.Queue() def predict_image(): path = entry_path.get() if not path: result_label.config(text='请先选择图片') return thread = threading.Thread(target=lambda: pred_queue.put(run_predict(path)), daemon=True) thread.start() result_label.config(text='识别中...') root.after(100, poll_prediction) def poll_prediction(): try: class_name, p = pred_queue.get_nowait() result_label.config(text=f'{class_name} 置信度: {p:.2f}') except queue.Empty: root.after(100, poll_prediction)threading.Thread把model.predict丢到后台线程,队列用来把结果传回主线程;root.after(100, poll_prediction)每 100 毫秒轮询一次队列。注意 daemon 线程不能直接操作 Tkinter 控件,跨线程改界面在 Windows 上会随机闪退,所以结果必须通过队列交给主线程更新。这套异步模式比单纯把root.update()塞进循环里干净得多,答辩时被问到“为什么不卡死”也能讲出线程模型。
预测期间把按钮文字改成“识别中...”,同时禁用重复点击,这个小细节能防止用户连续触发多个后台线程抢资源。模型是全局加载的,多个线程并发调用model.predict在 TensorFlow 2 里不一定线程安全,后台任务必须串行,比如用一个任务队列,或把按钮 disabled 住。
4.3 模型加载路径、图像预处理必须和训练时完全一致
模型加载时最常见的问题是load_model('cat_dog_model.h5')路径写错,或者在打包后的 exe 中找不到模型文件。期末项目如果用 PyInstaller 打包,把 h5 文件也打成资源,运行时路径就和当前目录不一致了。更隐蔽的问题是图像预处理不一致:训练时通过ImageDataGenerator(rescale=1./255)把像素归一化,GUI 里如果忘了除以 255,输入的像素范围是 0-255,模型看到的分布和训练时完全不同,输出置信度全乱套。
def run_predict(path): img = Image.open(path).convert('RGB').resize((128, 128)) arr = np.array(img, dtype=np.float32) / 255.0 arr = np.expand_dims(arr, axis=0) return arr这里强制convert('RGB')很重要。从网上下载的图可能是 RGBA 或者灰度图,np.array得到的数组维度会变成 (128, 128, 4) 或 (128, 128),模型直接报错或维度不匹配。统一转 RGB 三通道,保证输入形状恒为 (128, 128, 3)。用dtype=np.float32而不是默认的 uint8,是因为经过归一化后 float32 精度才够,否则某些系统上会因整型除法产生异常值。如果 GUI 用 OpenCV 读图,还要注意 cv2 默认读成 BGR 三通道,不做cv2.cvtColor(img, cv2.COLOR_BGR2RGB)转换的话,猫狗的颜色通道完全互换,训练时看到的是 RGB,预测时喂进去的是 BGR,准确率直接被打到接近随机。
5. 猫狗识别项目踩坑与排查:不收敛、过拟合、GUI 卡死的现象与解决
5.1 训练到一半 loss 卡在 0.69 不动
现象:前几轮 loss 快速下降到 0.70 附近,然后十几个 epoch 都不再变化,准确率停在 50% 左右。
原因:0.69 这个数字正好是-ln(0.5),说明模型的输出始终接近 0.5,梯度消失了。最常见原因有三个:学习率太大导致 Adam 在鞍点附近震荡;输入像素没有归一化到 0-1;或者标签和输出层没有对齐——比如用了class_mode='categorical'配合最后一层 single sigmoid,生成器传来的是 one-hot 数组,模型输出的标量根本没法对应损失。
解决:先打印train_generator.class_indices查看标签映射,再确认class_mode='binary'。然后把rescale打开,model.optimizer.learning_rate设小一档,比如从默认 0.001 降到 0.0003,一般能解决。如果还卡着,把卷积层加深一层试试,过浅的网络在 128x128 输入下学习能力不足。
5.2 验证准确率高,但换自己下载的图片就翻车
现象:训练集和验证集上准确率 95% 以上,但用户从网上下几张猫狗图,用 GUI 识别经常错。
原因:验证集和训练集来自同一个数据源,存在“同分布”幻觉。Kaggle 原数据里的图片拍摄场景相对集中,模型记住了背景纹理而不是猫狗本身的形状。另一个常见原因是验证集和训练集图片有重叠,划分时忘了打乱;或者划分前做了增强,验证集里混入了训练集的副本。
解决:从网上找几十张未被训练过的真实猫狗照片单独建一个测试集,放进dataset/test/cats和dataset/test/dogs,训练完成后用这个目录做最后的评估。如果掉点超过 5%,基本就是过拟合特征背景。先开更强的数据增强(把shear_range和zoom_range调到 0.2),再考虑用预训练模型迁移学习。
5.3 GUI 点预测后窗口转圈,几秒后才恢复
现象:点击“开始识别”,窗口变成“未响应”,鼠标转圈,过几秒后弹出结果。
原因:model.predict直接跑在主线程里,阻塞了 Tkinter 的事件循环。即使模型推理只需要两三秒,在用户眼里窗口已经死了。
解决:按第 4 章的方案,把预测塞进后台线程,用队列回传结果;或者用root.after分段调用 predict 并在内部用非阻塞模式。另外注意,加载 h5 文件本身也会阻塞,模型文件超过 100MB 时,界面启动后要等几秒才能出现按钮,最好在窗口底部放一个“加载中”状态条,而不是让用户觉得程序没响应。
5.4 加载 h5 后报错 “illegal memory access” 或 shape 不匹配
现象:训练时模型好好的,GUI 里load_model成功,但一执行predict就弹 CUDA error,或者报Input 0 of layer conv2d is incompatible with the layer。
原因:illegal memory access多半发生在 TensorFlow 2.x 与显卡驱动/显存不足的组合下,模型预测时要重新构建计算图,显存不够就触发非法访问。shape 不匹配则是 GUI 里输入图片尺寸和训练时不一致,例如训练用 128x128,GUI 里 resize 到 150x150。
解决:给 GUI 加一个import tensorflow as tf; tf.config.set_visible_devices([], 'GPU')的开关,强制 CPU 推理,稳定但稍慢。图片尺寸要硬编码,和训练脚本里target_size保持一致,不要暴露成可编辑输入框。如果换机器运行,tensorflow和keras版本必须和训练环境一致,h5 的 pickle 结构跨版本反序列化会出各种怪异错误。
5.5 训练集很小但 epochs 很大,过拟合无法避免
现象:训练准确率到 99%,验证准确率只有 80%,且两者差距越来越大。
原因:训练数据太少、模型参数太多、epochs 太长,模型把训练集的噪声和细节都背下来了。猫狗识别训练集只有 2000 张时,这种情况非常典型。
解决:优先扩充训练集而不是调整模型。把 10000 张猫狗原始图全部用上,验证集保持固定;再把数据增强强度调高一点。如果图片数量实在有限,就在Dense(512)之前加一个BatchNormalization,并把Dropout从 0.5 提至 0.6。早停和save_best_only在这里是保底策略,不是根本解——过拟合的根因是数据量,不是训练轮数。
6. 把作业做成“自己的实验”:三个进阶验证与可视化技巧
6.1 混淆矩阵与逐类准确率:答辩最有说服力的一张图
只用accuracy_score汇报一个数字太单薄。猫和狗两类如果模型对狗特别敏感,对猫不敏感,总体准确率看不出来。用sklearn.metrics.confusion_matrix和classification_report打印真阳、假阳、精确率、召回率,能随手交出一张标准评估表。
from sklearn.metrics import classification_report val_generator.reset() y_pred = model.predict(val_generator, steps=validation_steps, verbose=0) y_pred = (y_pred > 0.5).astype(int).reshape(-1) y_true = val_generator.classes[:len(y_pred)] print(classification_report(y_true, y_pred, target_names=['cat', 'dog']))val_generator.reset()是把生成器指针拨回开头,保证predict和classes对齐。这一步漏掉,报告里的标签序列和预测序列会错位,结果彻底作废。打印出来的 F1、召回率可以直接截图放进作业文档。
6.2 Grad-CAM 热力图:让 CNN 的决策从黑匣子变得可解释
考试或答辩时,最有力的加分项不是“准确率 97%”,而是“模型为什么认为这是猫”。Grad-CAM 通过最后一层卷积特征的梯度权重,把模型的关注区域热力图叠加回原图。对于猫狗模型,你会看到网络的眼睛和白耳朵边缘被点亮,这比任何代码都能说明 CNN 学到了什么。
import tensorflow as tf def grad_cam(model, img_array, last_conv_layer): grad_model = tf.keras.models.Model( [model.inputs], [last_conv_layer.output, model.output] ) with tf.GradientTape() as tape: conv_out, pred = grad_model(tf.convert_to_tensor(img_array)) loss = pred[:, 0] grads = tape.gradient(loss, conv_out)[0] weights = tf.reduce_mean(grads, axis=(0, 1)) heatmap = tf.reduce_sum(weights * conv_out[0], axis=-1) return heatmap.numpy()last_conv_layer对应模型里的第三个Conv2D层,Grad-CAM 的核心思想是使用最后一个卷积层的特征图,因为它同时兼顾了空间位置信息和高级语义。热力图生成后双线性插值缩放到原图尺寸,用matplotlib叠加显示。跑一次只花一两秒,但能让你的作业从“抄的模型”变成“自己的分析”。
6.3 数据增强开关对照实验:用一行注释证明你的设计
在作业文档里写“我用了数据增强”和“我通过实验证明数据增强有效”是完全不同的层级。做法很简单:把训练数据量减小到 3000 张,跑一组开增强、一组关增强,各训练 15 轮,画两条验证准确率曲线。你会发现开增强那条更稳、最终值更高,这就是自己的实验结论,不是网上抄来的。
最后建议把这三项验证做出来后再去答辩:混淆矩阵、Grad-CAM热力图、增强对照曲线。我自己当年做完猫狗识别项目后最大的教训是,把一个 h5 模型直接扔给老师,远不如准备三张可视化图表讲十分钟更有说服力。这份代码骨架其实只占了成绩的一部分,剩下的是你怎么解释每个参数、每条坑、每个现象背后的原因。希望帮到你。
本文还有配套的精品资源,点击获取