简介:一套基于Python与TensorFlow实现的CNN图像识别分类项目,面向有一定编程基础、希望入门深度学习或完成课程设计的学习者。项目以卷积神经网络为核心,覆盖数据加载、模型构建、训练、验证与评估等完整流程,可应用于图像分类识别场景。压缩包共13个文件,包含6个Python脚本、3个XML配置、2个pyc编译文件等,整体仅28KB,结构清晰。已有129人学习下载。资源提供CNN理论基础与TensorFlow实现代码,涉及卷积层、池化层、激活函数、全连接层等关键组件,并包含数据预处理、模型训练与评估等模块,可作为毕业设计、课程项目或工程实训的参考资料。学习者需具备基础编程能力,自行调试代码、处理报错,并根据实际需求修改扩展功能,从而深入理解图像识别技术并掌握TensorFlow实战技能。
1. 为什么这个CNN图像识别项目值得拆一遍
你下载过那种“基于Python + TensorFlow的CNN图像识别分类”源码包吗?解压后是input_data.py、model.py、training.py、evaluateDisease.py一堆文件,还有个model.cpython-35.pyc暗示它跑在 Python 3.5 上。直接运行training.py,大概率先报No module named 'tensorflow',装完又报AttributeError: module 'tensorflow' has no attribute 'placeholder',这一套组合拳能劝退一半初学者。但这正是一个典型的 CNN 图像识别工程骨架:数据读取、模型搭建、训练、验证被拆成独立模块,适合作为毕设、课程设计或工程实训的起点。它的价值不在开箱即用,而在于你能顺着代码理解从图片到分类标签的完整数据流,并学会处理 TensorFlow 的版本迁移问题。
2. 先把训练管线理清:从 input_data.py 到 model.py 的数据流
2.1 数据加载模块:input_data.py 应该怎么组织图片样本
很多初学者拿到项目后先打开training.py,看到model.py就开始读卷积层,但真正决定训练能不能跑通的是数据入口。input_data.py在工程里承担的是把磁盘上的图片文件转换成模型能读的Tensor。如果这个模块里的目录路径写的是绝对路径,换一台机器就废了;如果它没有统一图片尺寸,模型输入的shape就会在跑到一半时炸掉。
常见做法是先扫描data/train下的子目录,把每个子目录名当作类别标签,然后构造一个数据读取器。下面这段代码基本还原了这类工程里input_data.py的核心逻辑:
import os import numpy as np from PIL import Image class DataReader: def __init__(self, data_dir, image_size=(64, 64), batch_size=32): self.data_dir = data_dir self.image_size = image_size self.batch_size = batch_size # 子目录名就是类别名,排序后顺序固定,保证每次训练标签一致 self.class_names = sorted(os.listdir(data_dir)) self.num_classes = len(self.class_names) self._load_paths_and_labels() def _load_paths_and_labels(self): self.paths = [] self.labels = [] for idx, class_name in enumerate(self.class_names): class_dir = os.path.join(self.data_dir, class_name) for fname in os.listdir(class_dir): if fname.lower().endswith(('.jpg', '.jpeg', '.png')): self.paths.append(os.path.join(class_dir, fname)) self.labels.append(idx) self.paths = np.array(self.paths) self.labels = np.array(self.labels) def _read_image(self, path): img = Image.open(path).convert('RGB').resize(self.image_size) return np.array(img, dtype=np.float32) / 255.0 def next_batch(self): indices = np.random.choice(len(self.paths), self.batch_size, replace=False) batch_x = np.stack([self._read_image(self.paths[i]) for i in indices]) batch_y = np.eye(self.num_classes, dtype=np.float32)[self.labels[indices]] return batch_x, batch_y这段代码里最容易被忽略的是标签编码方式:np.eye(num_classes)[labels]生成 one-hot 编码,shape 是[batch_size, num_classes],后面计算交叉熵时直接和模型输出的logits对齐。next_batch()每次随机采样,能让每个 batch 的样本分布更接近全局。缺点是它没有 shuffle 完整数据集,也没有数据增强,如果训练集很小,后面 val 精度会很难看,这一点在后面章节会专门说。
2.2 模型定义:model.py 中的卷积、池化、全连接层级关系
CNN 的核心在于用卷积核在图像上滑动,提取局部特征。卷积层通过参数共享大幅减少参数量,池化层则在保留主要特征的同时降低空间维度。一个标准的图像分类 CNN 结构通常由两个卷积块加一个全连接分类头组成,model.py里最典型的实现如下:
import tensorflow as tf def inference(images, num_classes, keep_prob=1.0): # images: [batch_size, height, width, channels] with tf.variable_scope('conv1'): w = tf.get_variable('weight', [5, 5, 3, 32], initializer=tf.truncated_normal_initializer(stddev=0.1)) b = tf.get_variable('bias', [32], initializer=tf.constant_initializer(0.0)) conv1 = tf.nn.relu(tf.nn.conv2d(images, w, strides=[1, 1, 1, 1], padding='SAME') + b) pool1 = tf.nn.max_pool(conv1, ksize=[1, 2, 2, 1], strides=[1, 2, 2, 1], padding='SAME') with tf.variable_scope('conv2'): w = tf.get_variable('weight', [5, 5, 32, 64], initializer=tf.truncated_normal_initializer(stddev=0.1)) b = tf.get_variable('bias', [64], initializer=tf.constant_initializer(0.0)) conv2 = tf.nn.relu(tf.nn.conv2d(pool1, w, strides=[1, 1, 1, 1], padding='SAME') + b) pool2 = tf.nn.max_pool(conv2, ksize=[1, 2, 2, 1], strides=[1, 2, 2, 1], padding='SAME') flatten = tf.layers.flatten(pool2) with tf.variable_scope('fc1'): fc1 = tf.layers.dense(flatten, 128, activation=tf.nn.relu) fc1 = tf.nn.dropout(fc1, keep_prob) with tf.variable_scope('logits'): logits = tf.layers.dense(fc1, num_classes) return logits第一层卷积核尺寸是5x5,输入通道 3(RGB),输出 32 个特征图,padding='SAME'让卷积输出尺寸和输入一致,这样边缘信息不会过快丢失。随后接2x2最大池化,步长也是 2,宽高各减半。第二层卷积把 32 个通道扩展到 64 个,相当于让网络在更高抽象层上学习更多模式。如果输入是64x64,经过两次池化后特征图变成16x16,展平后长度是16*16*64=16384,再接128维全连接层。keep_prob是 dropout 保留概率,训练时设为 0.5 能抑制过拟合,验证和预测时必须设为 1.0。这里有个容易踩的坑:如果num_classes是 1,网络不会报错,但 softmax 交叉熵会失效,因为二分类也要两个输出节点。
2.3 训练脚本:training.py 里的 loss、优化器与学习率设置
training.py的任务是把数据和模型“缝合”起来。它要定义 placeholder、损失函数、优化器,然后循环喂数据。下面这段精简后的训练循环是这个项目常见的写法:
import tensorflow as tf from input_data import DataReader from model import inference train_reader = DataReader('data/train', image_size=(64, 64), batch_size=32) images = tf.placeholder(tf.float32, [None, 64, 64, 3]) labels = tf.placeholder(tf.float32, [None, train_reader.num_classes]) keep_prob = tf.placeholder(tf.float32) logits = inference(images, train_reader.num_classes, keep_prob) loss = tf.reduce_mean(tf.nn.softmax_cross_entropy_with_logits_v2(logits=logits, labels=labels)) optimizer = tf.train.AdamOptimizer(learning_rate=1e-4).minimize(loss) correct = tf.equal(tf.argmax(logits, 1), tf.argmax(labels, 1)) accuracy = tf.reduce_mean(tf.cast(correct, tf.float32)) saver = tf.train.Saver() with tf.Session() as sess: sess.run(tf.global_variables_initializer()) for epoch in range(50): for step in range(len(train_reader.paths) // 32): batch_x, batch_y = train_reader.next_batch() _, cur_loss, cur_acc = sess.run( [optimizer, loss, accuracy], feed_dict={images: batch_x, labels: batch_y, keep_prob: 0.5}) print("epoch", epoch, "loss", cur_loss, "acc", cur_acc) saver.save(sess, 'ckpt/model', global_step=epoch)这里用softmax_cross_entropy_with_logits_v2而不是先算 softmax 再算交叉熵,是因为它内部做了 logits 与 labels 的数值稳定处理,避免log(0)导致 loss 变成NaN。优化器选择 Adam,学习率1e-4在中小型 CNN 上是一个稳妥起点。saver.save每轮都保存 checkpoint,后面验证时直接用latest_checkpoint恢复。超参数调整时,下面这张表可以作为参考:
| 参数 | 建议范围 | 说明 |
|---|---|---|
| image_size | 64x64 或 128x128 | 太小丢失空间信息,太大显存占用高 |
| batch_size | 16~64 | 小数据集推荐 32,显存不够就降 |
| learning_rate | 1e-4~1e-3 | Adam 下从 1e-4 起步,震荡就再降 |
| 卷积核大小 | 3x3 或 5x5 | 5x5 感受野大,但参数量也大 |
| dropout keep_prob | 训练 0.5,验证 1.0 | 验证阶段 dropout 会引入随机噪声 |
| epoch | 30~100 | 看 loss 是否收敛,不必强制固定 |
如果训练到 30 轮后 loss 仍然在 0.7 左右徘徊,优先检查input_data.py里的图片是不是没有归一化到[0,1],或者标签和目录名没有对齐。这类问题在工程里比模型结构问题更常见。
3. 用 evaluateDisease.py 和 CNNTensorflowValidate.py 验证模型:别只看准确率
3.1 checkpoint 恢复与单张图像预测
训练完成后,CNNTensorflowValidate.py这类文件负责把训练好的 checkpoint 加载回来,对新的图像做预测。恢复模型时不需要重新构建优化器,只需要模型结构和变量名一致即可。下面是一个完整的最小预测脚本:
import tensorflow as tf import numpy as np from PIL import Image from model import inference class_names = ['cat', 'dog'] # 一定与训练时的目录顺序一致 tf.reset_default_graph() images = tf.placeholder(tf.float32, [None, 64, 64, 3]) keep_prob = tf.placeholder(tf.float32) logits = inference(images, len(class_names), keep_prob=1.0) predict = tf.nn.softmax(logits) saver = tf.train.Saver() with tf.Session() as sess: ckpt = tf.train.latest_checkpoint('ckpt') saver.restore(sess, ckpt) img = Image.open('test_01.jpg').convert('RGB').resize((64, 64)) x = np.expand_dims(np.array(img, dtype=np.float32) / 255.0, axis=0) probs = sess.run(predict, feed_dict={images: x, keep_prob: 1.0}) pred_idx = np.argmax(probs[0]) print("pred:", class_names[pred_idx], "prob:", probs[0][pred_idx])这里有个细节:tf.train.latest_checkpoint('ckpt')会自动在ckpt目录下找最新的.index文件,但如果训练脚本保存的文件名带了global_step,恢复时模型权重里的所有变量都会被读入,不需要手动指定epoch。输入图像的预处理必须和训练完全一致,包括resize尺寸、除以 255、convert('RGB')。如果训练时用(128, 128),预测时却用了(64, 64),shape不匹配会在sess.run时直接报错。
3.2 计算混淆矩阵,找到真正的盲区
evaluateDisease.py这个文件名暗示作者当时处理的是疾病相关的图像数据,但验证思路是通用的。单张预测只能验证个别样本,批量验证时准确率是最容易骗人的指标。当数据集中类别不均衡时,比如 90% 是猫、10% 是狗,全预测成猫也能有 90% 准确率。这时候要看混淆矩阵。
from sklearn.metrics import confusion_matrix, classification_report import numpy as np preds = [] trues = [] for i in range(0, len(val_x), batch_size): bx = val_x[i:i+batch_size] logit_val = sess.run(logits, feed_dict={images: bx, keep_prob: 1.0}) preds.extend(np.argmax(logit_val, axis=1)) trues.extend(np.argmax(val_y[i:i+batch_size], axis=1)) cm = confusion_matrix(trues, preds) print(classification_report(trues, preds, target_names=class_names))confusion_matrix的行是真实类别,列是预测类别。看一个例子:
| 真实\预测 | cat | dog |
|---|---|---|
| cat | 85 | 15 |
| dog | 30 | 70 |
这个矩阵里 dog 被误判为 cat 的数量有 30,明显高于 cat 被误判为 dog 的 15。这说明模型对 dog 的特征表达不足,或者 dog 的训练样本里存在大量和 cat 共用的背景。此时优先检查训练集中 dog 的图片是否足够多,而不是盲目增大卷积通道数。classification_report会给出每个类的 precision、recall、f1-score,重点关注 recall 低的类别。
3.3 验证集划分的一个硬性要求
有些项目为了省事,直接用input_data.py读出来的所有图片既训练又验证,然后打印出 98% 的准确率,换个文件夹就崩。这是典型的数据泄漏:CNN 记住了训练样本,而不是学到了类型特征。我一般会在input_data.py里预留一个val_split参数,按类别分层抽样切出 20% 作为验证集:
from sklearn.model_selection import train_test_split train_paths, val_paths, train_labels, val_labels = train_test_split( paths, labels, test_size=0.2, stratify=labels, random_state=42)stratify=labels可以保证每个类别在训练集和验证集中的比例与原数据集一致。如果数据集小到只有几百张,更好的做法是直接使用交叉验证,但在工程里时间成本偏高,最常见的还是固定一个随机种子做一次划分。
4. 跑通这个项目的关键依赖与排错:Python、TensorFlow 版本匹配
4.1 从代码细节判断项目适用的 TensorFlow 版本
项目里留下了一个model.cpython-35.pyc,这是 Python 3.5 编译后的缓存文件,说明原作者的环境是 Python 3.5 加 TensorFlow 1.x。再看tf.placeholder、tf.Session、tf.variable_scope这些写法,可以确定它不是 TensorFlow 2.x 的 Keras 风格。如果你电脑上装的是 TensorFlow 2.x,直接跑会报AttributeError: module 'tensorflow' has no attribute 'placeholder'。
用下面这张表可以快速判断一个老项目属于哪个版本:
| 代码特征 | TensorFlow 1.x | TensorFlow 2.x |
|---|---|---|
tf.placeholder | 广泛使用 | 移除,用tf.keras.Input |
tf.Session | 必需 | 移除 |
tf.variable_scope | 常见 | 不推荐,用 Keras 层 |
tf.nn.conv2d | 手动管理变量 | 被tf.keras.layers.Conv2D取代 |
tensorflow.contrib | 存在 | 移除 |
在 macOS 上新建 Python 3.5 环境已经不太方便,最简单的做法是用 Anaconda 创建独立环境来装 TensorFlow 1.15。命令如下:
conda create -n tf1 python=3.5 conda activate tf1 pip install tensorflow==1.15这里注明一点:如果你没有 NVIDIA 显卡,装 CPU 版本就够,CNN 在小数据集上也能训练,只是慢一些。如果有 GPU,还需要单独安装匹配的 CUDA 和 cuDNN,版本对应关系建议直接查 TensorFlow 官方支持矩阵,不同小版本的匹配规则经常变化,别照抄网上任意一篇教程。
如果你不想回到 Python 3.5,也可以在 TensorFlow 2.x 里跑老代码,只需要在training.py最前面加两行:
import tensorflow.compat.v1 as tf tf.disable_v2_behavior()这样tf.placeholder、tf.Session都会恢复成 1.x 的行为,大部分老工程可以直接跑通。但要注意,disable_v2_behavior和tf.keras混用时会有一些副作用,尽量不要在一个工程里一半用 compat、一半用 Keras。
4.2 常见报错与处理
把最常见的报错整理成一个速查表,真正动手时能省很多时间:
| 报错信息 | 原因 | 解决 |
|---|---|---|
No module named 'tensorflow' | 没有安装 | conda activate tf1 && pip install tensorflow==1.15 |
module 'tensorflow' has no attribute 'placeholder' | 用了 TF2 运行 TF1 代码 | 使用tf.compat.v1或改写为 Keras |
Shape must be rank 4 but is rank 2 | 输入图像没有[batch, h, w, c]维度 | 用np.expand_dims(x, axis=0)补 batch 维度 |
Invalid argument: Expected image in [0, 1] | 没有归一化或通道数不一致 | convert('RGB')后除以 255.0 |
Resource exhausted: OOM | batch 太大或模型参数太多 | 减小 batch_size,或减少卷积核数量,或降为灰度图以减通道数 |
除了这些,还有一个很隐蔽的问题:读取灰度图时PIL.Image.open返回的数组 shape 是(h, w),不是(h, w, 1),直接喂给卷积层会报维度错误。我一般会在数据读取时统一用.convert('RGB'),把灰度图转成三通道,这样模型输入始终是[batch, h, w, 3]。如果非要保留灰度通道,就在np.array后手动np.expand_dims(axis=-1),同时把模型第一层卷积输入通道数改成 1。
5. 把这份项目改造成自己的分类任务:最小改动路径
5.1 替换数据集时只需要改三处
第一处是input_data.py里的data_dir,第二处是model.py的输入图像尺寸,第三处是training.py的 placeholder。类别数量不用手改,因为DataReader.num_classes会根据目录下有几个子目录自动计算。但目录名必须是小写英文,不要用中文,否则sorted(os.listdir())的排序顺序在不同操作系统上可能不一致。
我一般会把数据目录做成环境变量注入,这样切换数据集时不用改代码:
import os data_dir = os.environ.get('DATA_DIR', 'data/train')启动训练时用DATA_DIR=flower_data python training.py,每个实验都留一个独立日志目录,便于后面对比。
5.2 用 TensorBoard 观察训练过程
为了确认模型是否真的在收敛,可以在training.py中加入 summary,把 loss 和 acc 写入logs目录:
tf.summary.scalar('loss', loss) tf.summary.scalar('acc', accuracy) merged = tf.summary.merge_all() writer = tf.summary.FileWriter('logs', sess.graph) # 训练循环内 summary, _ = sess.run([merged, optimizer], feed_dict={images: batch_x, labels: batch_y, keep_prob: 0.5}) writer.add_summary(summary, global_step=epoch)启动命令:
tensorboard --logdir=logs浏览器打开http://localhost:6006即可看到曲线。如果 loss 一直震荡不降,先把学习率调到1e-5重新跑;如果 loss 降了但 val 准确率不上涨,把 dropout 的 keep_prob 从 1.0 改成 0.5,并观察训练集和验证集的差异是否缩小。调参时一次只动一个变量,比如这一轮只动学习率,下一轮只动卷积核数量,否则很难定位是哪个改动起到了作用。
本文还有配套的精品资源,点击获取