简介:这是一份基于Python卷积神经网络CNN的猫狗图像分类毕业设计项目源码,面向计算机相关专业学生、毕业设计者以及图像识别初学者。项目采用数据增强技术提升模型泛化能力,以卷积神经网络作为分类器,在猫狗二分类任务上识别率达到97%。压缩包共含2000个文件,主要包括1992张jpg图像样本、7个py源码脚本和1个md说明文档,图像数据划分为训练集、验证集与测试集,可依据源码自行扩展至全量数据集重新训练。资源包大小约87.85MB,目前已有1132人学习浏览。从中能够获取完整可运行的CNN分类项目,包括数据预处理、模型搭建、训练验证与预测脚本,以及数据集划分思路和调整训练规模的改写方法,便于快速上手图像分类任务或在此基础上完成毕业设计。
1. 为什么一个“猫狗分类”还能成为毕业设计
很多人看到“猫狗分类”第一反应是 Keras 官方教程里那个 30 行代码的入门示例,觉得这东西撑不起一个毕业设计。但真正动手做过的人会告诉你:官方示例跑通容易,要稳定达到 97% 的识别率并讲清楚每个参数为什么这么设,是另一回事。这个项目的价值不在于“猫 vs 狗”这个任务本身,而在于它把图像分类的完整流程走了一遍——从原始图片目录到数据划分,再到数据增强、CNN 结构设计、训练策略调整,最后落到识别率指标上。适合正在做深度学习的课程设计、毕业设计,或者刚接触 CNN、想知道图像分类工程细节的 Python 开发者。这篇文会把项目里的核心逻辑拆开讲,包括数据怎么分、网络怎么搭、97% 是怎么调出来的,以及想换自己的数据集时改哪里。
2. 数据准备阶段:从零散的 JPG 目录到结构化数据集
2.1 先看清楚原始数据的组织方式
这个项目收到的原始数据是散装的图片文件,名字形如cat.835.jpg、dog.157.jpg、dog.815.jpg,也就是说图片本身没有放在分类子目录里,类别信息编码在文件名前缀中。Keras 的ImageDataGenerator.flow_from_directory默认要求数据按子目录组织:
data/ train/ cats/ dogs/ validation/ cats/ dogs/ test/ cats/ dogs/所以在跑任何模型之前,第一步是写一个脚本把散装图片按规则归类。常见做法是直接用 Python 的shutil和os模块,按文件名前缀分拣到对应目录。核心逻辑如下:
import os import shutil source_dir = "./raw_images" # 原始图片所在目录 target_root = "./data" # 目标根目录 train_ratio = 0.5 # 训练集比例 val_ratio = 0.25 # 验证集比例 # 剩余 0.25 作为测试集 os.makedirs(target_root, exist_ok=True) # 先按类别收集所有文件路径 files_by_class = {"cat": [], "dog": []} for fname in os.listdir(source_dir): if fname.lower().startswith("cat.") and fname.endswith(".jpg"): files_by_class["cat"].append(fname) elif fname.lower().startswith("dog.") and fname.endswith(".jpg"): files_by_class["dog"].append(fname) for cls, files in files_by_class.items(): files.sort() # 保证顺序稳定 n = len(files) n_train = int(n * train_ratio) n_val = int(n * val_ratio) splits = { "train": files[:n_train], "validation": files[n_train:n_train + n_val], "test": files[n_train + n_val:], } for split_name, split_files in splits.items(): dest_dir = os.path.join(target_root, split_name, cls) os.makedirs(dest_dir, exist_ok=True) for fname in split_files: shutil.copy2( os.path.join(source_dir, fname), os.path.join(dest_dir, fname) ) print(f"{cls}: total={n}, " f"train={len(splits['train'])}, " f"val={len(splits['validation'])}, " f"test={len(splits['test'])}")这段代码按 50%/25%/25% 把每个类别的图片分别切分到三个子集,避免某个类别在某个子集中占比失衡。如果你拿到的是完整数据集而不是已经切好的 2000/1000/1000,就把train_ratio改成 0.7、val_ratio改成 0.15 之类,凑够你要的数量。注意这里用的是copy2,保留原始文件不污染源目录;如果磁盘紧张可以换成move。
2.2 训练集/验证集/测试集为什么要分开
初学者最容易犯的错是只用训练集和测试集,或者拿测试集反复调参。这个项目的划分方式是教科书标准:训练集用来更新网络权重,验证集用来在每个 epoch 结束后评估模型并决定是否调整超参数(比如学习率、Dropout 比例),测试集只在全部训练完成后用一次,模拟真实场景下的表现。如果验证集和训练集有重叠,模型会“记住”而不是“学会”,验证 loss 会虚低,换到新图片上立刻现原形。
这个项目提取了 2000 张作为训练集,1000 张作为验证集,1000 张作为测试集。对二分类任务来说,2000 张训练图并不算多,后面能跑到 97%,一部分功劳要记在数据增强上。
3. CNN 模型架构:三层卷积 + Dropout 是如何拟合猫狗问题的
3.1 为什么不用预训练模型
既然是毕业设计,很多人第一反应是上 VGG16、ResNet50 做迁移学习。但这里必须说清楚一个矛盾点:迁移学习虽然能更快收敛,但网络被冻结的部分不可训练,能改的只有最后的全连接层和分类层。评委如果问“卷积层为什么选 3×3 卷积核、为什么池化要用 2×2”,用迁移学习的学生很容易答不上来,因为那些层压根不是自己设计的。这个项目从头搭建 CNN,虽然结构不深,但每个组件的选型逻辑都是可控的、能讲清楚的。
3.2 模型结构逐层拆解
核心模型是典型的“卷积 + 池化 + Dropout + 全连接”套件,结构大致如下:
from tensorflow.keras import layers, models model = models.Sequential([ # 第一层卷积:提取低级特征(边缘、颜色块) layers.Conv2D(32, (3, 3), activation="relu", input_shape=(128, 128, 3)), layers.MaxPooling2D(2, 2), # 第二层卷积:组合边缘为纹理、局部形状 layers.Conv2D(64, (3, 3), activation="relu"), layers.MaxPooling2D(2, 2), # 第三层卷积:组合纹理为语义部件(耳朵、眼睛、尾巴形态) layers.Conv2D(128, (3, 3), activation="relu"), layers.MaxPooling2D(2, 2), # 展平后接全连接,加 Dropout 抑制过拟合 layers.Flatten(), layers.Dense(512, activation="relu"), layers.Dropout(0.5), layers.Dense(1, activation="sigmoid") # 二分类输出 ]) model.compile( optimizer="adam", loss="binary_crossentropy", metrics=["accuracy"] )网络结构不复杂,但每个参数都有讲究。Conv2D(32, (3, 3))表示第一层用 32 个 3×3 卷积核,输入图是 128×128×3(RGB 三通道)。为什么从 32 开始而不是直接上 128?因为浅层特征(边缘、颜色渐变)比较简单,少量卷积核就够,随着深度增加,特征越来越抽象,通道数才翻倍到 64、128。MaxPooling2D(2, 2)把 128×128 降到 64×64,再降到 32×32、16×16,一方面减少计算量,另一方面扩大感受野——后面的卷积层能看到更大范围的像素关系。Flatten把 16×16×128 的三维特征图拉平成 32768 维向量,Dense(512)做高层语义组合,Dropout(0.5)随机丢弃一半神经元,让网络不依赖某些特定神经元,从而缓解过拟合。
3.3 输出层和损失函数的选择逻辑
二分类任务输出层用Dense(1, activation="sigmoid"),配合loss="binary_crossentropy"(二分类交叉熵),这组搭配直接输出一个 0 到 1 之间的概率值,大于 0.5 判为狗,小于 0.5 判为猫。如果你把输出层写成Dense(2, activation="softmax"),损失函数就得换成categorical_crossentropy,标签也要改成 one-hot 编码。两种方案在这个规模的任务上精度差别极小,但 sigmoid 版本的模型文件更小、预测时少一次 softmax 计算,部署上更轻量。
下表是两者的对应关系,改造多分类时会用到:
| 任务类型 | 输出层激活函数 | 输出节点数 | 损失函数 | 标签编码 |
|---|---|---|---|---|
| 二分类 | sigmoid | 1 | binary_crossentropy | 0/1 整数 |
| 多分类 | softmax | 类别数 | categorical_crossentropy | one-hot |
| 多分类(整数标签) | softmax | 类别数 | sparse_categorical_crossentropy | 整数 |
4. 训练策略:数据增强、EarlyStopping 与 97% 识别率的来源
4.1 ImageDataGenerator 做数据增强
2000 张训练图对 CNN 来说偏少,直接硬训很容易在第一轮就出现过拟合:训练 accuracy 快速逼近 100%,验证 accuracy 卡在 80% 上下。这个项目的解法是数据增强——在训练过程中对每张图片做随机变换,相当于变相扩大数据集。关键代码是:
from tensorflow.keras.preprocessing.image import ImageDataGenerator train_datagen = ImageDataGenerator( rescale=1.0/255, # 像素归一化到 0~1,加速收敛 rotation_range=20, # 随机旋转 ±20 度 width_shift_range=0.2, # 水平平移 20% height_shift_range=0.2, # 垂直平移 20% shear_range=0.2, # 错切变换 zoom_range=0.2, # 随机缩放 horizontal_flip=True, # 水平翻转(猫狗图片镜像仍是猫狗,语义不变) fill_mode="nearest" # 变换后空白区域用最近邻像素填充 ) # 验证集和测试集只做归一化,不做增强 val_datagen = ImageDataGenerator(rescale=1.0/255) train_generator = train_datagen.flow_from_directory( "./data/train", target_size=(128, 128), batch_size=32, class_mode="binary" ) val_generator = val_datagen.flow_from_directory( "./data/validation", target_size=(128, 128), batch_size=32, class_mode="binary" )每个参数对应一类“噪声”:rotation_range和width_shift_range解决猫狗在图片中位置不居中的问题,zoom_range解决拍摄距离远近不一的问题,horizontal_flip增加样本多样性。注意验证集不能用增强,否则不同 epoch 的验证集数据不一致,loss 曲线会抖动得没法看。fill_mode="nearest"很关键,不做填充的话,旋转后边缘会出现黑色像素块,相当于给训练集注入了一种原数据中不存在的分布。
4.2 回调函数:EarlyStopping 与学习率衰减
训练部分的另一个关键设计是回调函数。如果没有 EarlyStopping,你会发现训练到第 30 个 epoch 时验证 accuracy 已经在 95% 附近震荡,继续训练不仅浪费时间,还可能让模型过拟合到验证集上。标准配置如下:
from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau, ModelCheckpoint callbacks = [ EarlyStopping( monitor="val_loss", patience=8, restore_best_weights=True ), ReduceLROnPlateau( monitor="val_loss", factor=0.5, patience=3, min_lr=1e-6 ), ModelCheckpoint( "best_cat_dog_model.keras", monitor="val_accuracy", save_best_only=True ) ] history = model.fit( train_generator, steps_per_epoch=2000 // 32, epochs=50, validation_data=val_generator, validation_steps=1000 // 32, callbacks=callbacks )steps_per_epoch=2000 // 32等于 62,意思是每个 epoch 从训练生成器中取 62 个 batch(每个 batch 32 张),共约 1984 张,剩余的零头由生成器随机补足。patience=8表示验证 loss 连续 8 个 epoch 不下降就停止训练;restore_best_weights=True保证模型回滚到验证集上表现最好的那一次权重,而不是最后一次可能已经过拟合的权重。ReduceLROnPlateau在验证 loss 连续 3 个 epoch 不降时把学习率减半,让模型在小步长下越过局部最优。
4.3 97% 是怎么来的:组合策略而非单一技巧
如果只跑一个裸 CNN 不做增强、不加回调,这个数据集上的典型结果在 90% 到 93% 之间。加上数据增强,能到 95% 左右。再配合 EarlyStopping 在最佳权重处截断,以及验证集上的多次小步微调,97% 是一个合理的数字。值得注意:class_mode="binary"时flow_from_directory会自动按字母序分配标签,cats是 0,dogs是 1,所以预测结果里np.round(pred)等于 1 时是狗,等于 0 时是猫,这个映射关系后面会用到。
训练过程中要盯两个指标:训练 accuracy 和验证 accuracy 的差距。如果差距持续超过 5 个百分点,说明 Dropout 比例不够或数据增强强度不够;如果两者都卡在 90% 上不去,说明网络的容量不够,需要加卷积层通道数或增加一层卷积。
5. 源码改造实战:换全量数据集、改预测脚本、扩展多分类
5.1 把 2000 张训练图改成全量数据
项目摘要里特意说“如果想用全部数据集作为训练,可以更改源码”。改法其实很直接:把第 1 章的分类脚本里train_ratio调高,或者干脆不切验证集和测试集,把全部图片都放训练集,然后单独留出一小部分(比如 10% 到 15%)做验证。但这里有一个隐蔽的问题:如果原本给的 2000 张训练图是从全量数据里抽出来的,直接全量训练后模型性能会更好;如果全量数据本身就是全部图片,全量训练时验证集和测试集的划分比例就得重新算,不能沿用 2000/1000/1000 的切法。
改法推荐这样做:
# 重新划分数据集,全部图片按 70% / 15% / 15% 切分 raw_root = "./all_images" target_root = "./full_data" # 统计每个类别的总图片数 cat_count = len([f for f in os.listdir(raw_root) if f.startswith("cat.")]) dog_count = len([f for f in os.listdir(raw_root) if f.startswith("dog.")]) # 然后按 0.7 / 0.15 / 0.15 的比例分配 # 核心逻辑与 1.1 节相同,只改 train_ratio 和 val_ratio切分完成后把flow_from_directory的路径从"./data/train"改成"./full_data/train",其余训练代码完全不用动。全量数据下同一个模型结构可以适当调大——比如把第三层通道数从 128 加到 256——因为样本量大了,更大的模型容量不容易过拟合。训练时长也会相应增加,建议先用 20 个 epoch 快速看趋势,确认模型在下降而不是发散后,再放开到 50 个 epoch 跑完整版。
5.2 写一个独立的预测脚本
训练完成后,predict 阶段有一个高频踩坑点:model.predict的输入必须和训练时一样经过归一化(除以 255),否则预测结果会偏。标准预测脚本如下:
import numpy as np from tensorflow.keras.models import load_model from tensorflow.keras.preprocessing import image model = load_model("./best_cat_dog_model.keras") def predict_image(img_path): img = image.load_img(img_path, target_size=(128, 128)) img_array = image.img_to_array(img) # 形状: (128, 128, 3) img_array = np.expand_dims(img_array, axis=0) # 加 batch 维度 -> (1, 128, 128, 3) img_array = img_array / 255.0 # 归一化,必须与训练一致 pred = model.predict(img_array, verbose=0)[0][0] label = "dog" if pred >= 0.5 else "cat" return label, float(pred) # 用法示例 label, prob = predict_image("./test_images/cat.100.jpg") print(f"预测类别: {label}, 置信度: {prob:.4f}")np.expand_dims这一步很容易漏。模型训练时输入形状是(batch_size, 128, 128, 3),单张图片只有三维,不补一维会直接报ValueError。预测结果是一个二维数组,[0][0]取出 batch 第一条数据的 sigmoid 输出值。边界值 0.5 可以按需调整——如果你更在意“把猫误判成狗”还是“把狗误判成猫”的代价,就把阈值往对应方向移,比如调成 0.6 会减少猫被误判为狗的概率,但会增加漏判真狗的风险。
5.3 从二分类扩展到多分类的思路
如果想把猫狗二分类改成猫狗鸟三分类,需要改四个地方:目录下加birds文件夹、flow_from_directory的class_mode改为"categorical"、输出层改成Dense(3, activation="softmax")、损失函数换成categorical_crossentropy。标签的读取方式也会变化,原来binary模式下生成整数标签,categorical模式下标签是 one-hot 向量,model.predict的输出从单个概率值变成长度为 3 的概率分布,取argmax得到类别下标。需要注意flow_from_directory的类别映射是基于文件夹名称的字母顺序,birds排在cats和dogs之间,这个顺序在写分类报告时要对齐。
6. 验证模型没跑偏:混淆矩阵、单张图和 batch 预测
训练完别急着交差,花十分钟做三个验证,能发现很多隐藏问题。
第一,打印混淆矩阵。二分类只有两个类别,混淆矩阵一眼就能看出模型更混淆哪个方向:
from sklearn.metrics import confusion_matrix, classification_report test_generator = val_datagen.flow_from_directory( "./data/test", target_size=(128, 128), batch_size=32, class_mode="binary", shuffle=False # 必须关掉,否则预测结果和标签对不上 ) test_preds = model.predict(test_generator, verbose=1) test_preds_binary = (test_preds > 0.5).astype(int) cm = confusion_matrix(test_generator.classes, test_preds_binary) report = classification_report( test_generator.classes, test_preds_binary, target_names=list(test_generator.class_indices.keys()) ) print(cm) print(report)shuffle=False是这里最容易踩的坑。flow_from_directory默认会打乱数据顺序,如果保持打乱,test_generator.classes和预测结果就不是一一对应关系,混淆矩阵会完全错乱。test_generator.class_indices是一个字典,比如{'cats': 0, 'dogs': 1},用它来对齐类别名。
第二,随机抽三张单图走一遍 5.2 节的预测脚本,和测试集整体指标做对比。单图预测能暴露预处理不一致的问题——比如某张图是 RGBA 四通道 PNG,load_img默认会转成 RGB,但如果你的数据集里有灰度图又没做灰度统一,这里就会出偏差。一致性检查是在测试集 accuracy 之外的第二道保险。
第三,统计每个 batch 的预测耗时。如果模型要在嵌入式设备或者后端服务中上线,单张预测耗时要控制在可接受范围。测法很简单:
import time start = time.time() for _ in range(50): model.predict(img_array, verbose=0) end = time.time() print(f"50 次单图预测耗时: {end - start:.2f}s, " f"平均每次: {(end - start) / 50 * 1000:.1f}ms")如果单图预测超过 100ms,考虑把输入尺寸从 128×128 降到 96×96,或者用 TensorFlow Lite 转换模型。97% 的识别率是在测试集 1000 张图片上验证出来的,前提条件是测试集和训练集来自同一分布——如果你把手机拍的、带水印的、光照极端的猫狗图片丢进去,准确率会明显下滑,这不是模型 bug,而是数据分布偏移。
本文还有配套的精品资源,点击获取