☰
Python CNN鱼类图像分类识别:从PyTorch训练到网页部署全流程
2026/10/5 8:55:33 网站建设 项目流程

简介:面向初涉深度学习图像分类的开发者,这份资源以“常见鱼类识别”为应用场景,给出了基于Python+CNN的网页交互式项目,适合课程设计、毕业设计或算法入门练习。压缩包共368个文件,约23.11MB,包括361张按类别组织的鱼类图片(含翻转、旋转等增强样本)、3个Python脚本(分别负责数据集文本生成、模型训练以及本地网页服务)、3个文本文件(含标签映射或训练验证集划分)和1个HTML展示页面,形成完整可运行的图像分类流程。训练环节会保存本地模型,并输出记录每个epoch验证集损失值与准确率的log日志,便于观察模型收敛情况。压缩包内附requirements.txt环境依赖清单,需自行配置PyTorch环境,适合已有一定Python基础的学习者。目前已有157人学习下载,可作为搭建CNN识别项目的直接参考,也可作为学习PyTorch图像分类流程的实用样例。

1. 基于 Python CNN 的鱼类分类识别:一个小而完整的网页化落地样本

这套资源的核心,是用 PyTorch 搭建一个 CNN 卷积神经网络,对常见鱼类图片做分类识别,再把训练好的模型通过一个 HTML 网页暴露出来——你打开浏览器,上传一张鱼的照片,页面就能告诉你它属于哪个类别。整个过程从图片数据集、txt 标注文件生成,到模型训练、本地 Web 服务,是一条完整的闭环。适合刚学完 CNN 理论、想看看完整工程长什么样的新手,也适合需要快速搭一个图像分类 Demo 的从业者——它不需要 GPU,纯 CPU 也能训练和推理,数据集规模也控制在小体量,跑起来不吃力。我拆完这套资源最直接的感受是:麻雀虽小,但该有的环节一个不落,尤其适合拿来做「从零到网页识别」的路线参照。

2. 图片数据集与 txt 标签生成:flip、rotated45 增强里的门道

2.1 数据集目录结构与类别组织方式

打开资源包之后,第一眼看到的是index.html、一堆以 UUID 命名的 jpg 图片,以及三个核心 Python 脚本:01数据集文本生成制作.py、02深度学习模型训练.py、03html_server.py。如果你只是急着跑通,可能会跳过数据集直接看训练代码——但这一步恰恰是整个项目的地基。

数据集的命名方式很有意思,比如181986ae-ddf1-4134-b050-05529630c5c1-290mm.jpg对应原图,181986ae-ddf1-4134-b050-05529630c5c1-290m_flip.jpg对应水平翻转后的图,181986ae-ddf1-4134-b050-05529630c5c1-290m_rotated45.jpg对应旋转 45 度后的图。从命名能看出作者做的数据增强策略:每个原始样本派生出了flip(水平翻转)和rotated45(旋转 45 度)两个变体,把有限的小样本扩充到了三倍。这是小数据集图像分类的常见操作,CNN 本身不具备平移、翻转不变性,通过几何增强可以让模型对鱼的不同姿态、拍摄角度更鲁棒。

实际的类别组织方式是典型的「一个文件夹一个类别」。数据集文件夹下按鱼的种类分目录,每个目录里放该类别所有图片,这种做法在 PyTorch 的ImageFolder里可以直接被识别,但这个项目用的是自己生成 txt 的方式,意图在于把训练集和验证集的划分显式地写进文本里,方便后续反复调整比例,同时也能看到每张图片到底被归到了哪个集合。

2.2 01数据集文本生成.py:路径与标签如何落盘

01脚本做的事情很直接:遍历数据集下每个类别文件夹,把图片路径和对应的标签写入 txt 文件。伪代码大致是下面这个结构:

import os import random dataset_root = "数据集" # 数据集根目录 train_lines = [] val_lines = [] # 收集所有类别,按文件夹名排序,保证标签顺序稳定 categories = sorted(os.listdir(dataset_root)) label_map = {cat: idx for idx, cat in enumerate(categories)} for cat in categories: cat_dir = os.path.join(dataset_root, cat) imgs = [f for f in os.listdir(cat_dir) if f.endswith(".jpg")] for img in imgs: path = os.path.join(cat_dir, img) label = label_map[cat] # 按 8:2 划分训练集和验证集 if random.random() < 0.8: train_lines.append(f"{path} {label}\n") else: val_lines.append(f"{path} {label}\n") with open("train.txt", "w", encoding="utf-8") as f: f.writelines(train_lines) with open("val.txt", "w", encoding="utf-8") as f: f.writelines(val_lines) print(f"train: {len(train_lines)}, val: {len(val_lines)}")

逻辑不复杂:先拿到所有类别名,为每个类别分配一个从 0 开始的整数标签;然后遍历每个类别文件夹里的 jpg 图片,按随机数决定进训练集还是验证集;最后把「图片路径 + 空格 + 标签」写进 txt。

这里有两个参数直接决定后续训练质量。第一个是划分比例0.8,在这个比例下训练集和验证集的比例是 8:2,如果你的数据集本身图片很少(比如几十张),可以改成 0.9 甚至 0.95,让训练数据更充足;但注意验证集也不能太少,否则验证准确率波动会非常剧烈。第二个是random.random()——这段代码没有设置随机种子,每次运行划分结果都会不一样,如果你想让实验可复现,应该在脚本开头加一行random.seed(42)。我一般会顺手在划分之后打印一下每个类别的样本数,检查类别是否均衡,防止某个类别占了绝大多数导致模型学偏。

txt 文件的格式是纯文本、每行一条记录,路径和标签用空格分隔。这种格式在 PyTorch 里解析成本很低,读取时按行 split 就能拿到路径和标签。作者选择这种方案而不是直接用ImageFolder,好处是训练集、验证集的划分看得见摸得着,你随时可以打开 txt 检查哪张图进了哪个集合,排查数据泄漏也更方便。

3. PyTorch CNN 训练:模型结构、超参与日志解读

3.1 CNN 分类模型的选型理由

鱼类图片分类是一个典型的细粒度图像识别任务。不同种类的鱼在体形、纹理、鳍条位置上的差异往往不算大,这对模型提取局部特征的能力提出了要求。CNN 通过卷积核在图像上滑动,逐层提取从边缘、纹理到部件级的语义特征,天然适合这种任务。

在这个项目里,CPU 环境是小数据集训练的现实约束,所以模型结构不会太大。一个标准的入门级 CNN 分类网络大致是:卷积层提取特征 → ReLU 激活 → 池化下采样 → 重复若干次 → Flatten 展平 → 全连接层输出类别概率。这种结构对基础鱼类分类任务已经够用,不需要搬出 ResNet、EfficientNet 这类大模型——在 CPU 上训练反而会很慢,而且小数据集上大模型更容易过拟合。

损失函数用交叉熵(CrossEntropyLoss),这是多分类问题的标准选择。优化器常见的是 Adam 或 SGD,Adam 收敛快、对学习率不敏感,更适合新手;SGD 需要更多调参技巧,但泛化能力通常略好。如果你只是在 CPU 上跑小数据集,Adam 是更稳妥的起点。

3.2 02深度学习模型训练.py:训练流程的关键段落

训练脚本的核心流程是:读取 train.txt 和 val.txt → 构建 Dataset 和 DataLoader → 定义 CNN 模型 → 定义损失函数和优化器 → 迭代训练 → 保存模型和日志。下面是一段与项目思路同构的简化版代码,关键参数都标了注释:

import torch import torch.nn as nn from torch.utils.data import Dataset, DataLoader from PIL import Image import torchvision.transforms as transforms # ---------- 自定义 Dataset:从 txt 读取路径和标签 ---------- class FishDataset(Dataset): def __init__(self, txt_path, transform=None): self.samples = [] with open(txt_path, "r", encoding="utf-8") as f: for line in f.readlines(): path, label = line.strip().split(" ") self.samples.append((path, int(label))) self.transform = transform def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label = self.samples[idx] img = Image.open(path).convert("RGB") if self.transform: img = self.transform(img) return img, label # ---------- 数据预处理:统一尺寸 + 转 Tensor + 归一化 ---------- transform = transforms.Compose([ transforms.Resize((224, 224)), # 所有图片统一缩放到 224x224 transforms.ToTensor(), # 转为 Tensor,像素值归一到 [0,1] transforms.Normalize( # 按 ImageNet 均值标准差归一化 mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225] ) ]) train_ds = FishDataset("train.txt", transform=transform) val_ds = FishDataset("val.txt", transform=transform) train_loader = DataLoader(train_ds, batch_size=16, shuffle=True, num_workers=0) val_loader = DataLoader(val_ds, batch_size=16, shuffle=False, num_workers=0) # ---------- 定义一个小型 CNN ---------- class SimpleCNN(nn.Module): def __init__(self, num_classes): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 16, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 112x112 nn.Conv2d(16, 32, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 56x56 nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 28x28 ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(64 * 28 * 28, 128), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(128, num_classes) ) def forward(self, x): return self.classifier(self.features(x)) model = SimpleCNN(num_classes=len(categories)) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=0.001) # ---------- 训练循环 + 验证 ---------- epochs = 30 best_acc = 0.0 for epoch in range(epochs): model.train() train_loss = 0.0 for imgs, labels in train_loader: optimizer.zero_grad() outputs = model(imgs) loss = criterion(outputs, labels) loss.backward() optimizer.step() train_loss += loss.item() * imgs.size(0) # 每个 epoch 结束后在验证集上评估 model.eval() correct = 0 total = 0 val_loss = 0.0 with torch.no_grad(): for imgs, labels in val_loader: outputs = model(imgs) loss = criterion(outputs, labels) val_loss += loss.item() * imgs.size(0) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() train_loss = train_loss / len(train_ds) val_loss = val_loss / len(val_ds) val_acc = correct / total print(f"Epoch {epoch+1}/{epochs} | train_loss: {train_loss:.4f} | " f"val_loss: {val_loss:.4f} | val_acc: {val_acc:.4f}") # 保存验证集准确率最高的模型 if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), "best_model.pth") print(f" -> saved best model, acc={val_acc:.4f}")

几个关键参数值得展开说。batch_size=16在 CPU 上是个平衡值,太小(比如 2 或 4)会让训练震荡且速度慢,太大会直接把内存吃满。lr=0.001是 Adam 的常用默认值,如果发现 loss 震荡不降,可以降到 0.0003 或 0.0001。epochs=30对于小数据集来说通常够用,但如果发现 val_acc 还在持续上升,可以加大到 50 或 80;如果 val_loss 已经开始反弹而 train_loss 还在降,说明过拟合了,要提前停。

torch.save(model.state_dict(), "best_model.pth")保存的是模型权重而不是整个模型,这样做的好处是文件小、加载灵活,使用时需要先实例化一个同结构的模型再load_state_dict。保存逻辑里有一个best_acc判断,只在验证集准确率创新高时覆盖保存,避免最后一个 epoch 的模型反而是过拟合后的劣化版本。日志打印了每个 epoch 的训练损失、验证损失和验证准确率,这些数据正是判断模型收敛状态的第一手材料。

3.3 训练日志与准确率观察

训练完成后本地会多出一个日志文件,记录了每个 epoch 的验证集损失值和准确率。看日志有一个经验法则:训练损失持续下降而验证损失下降缓慢,说明模型还在学习,可以继续训;两者同时下降是最好的状态;训练损失降、验证损失开始涨,就是过拟合的明确信号,这时候再多的 epoch 只会让模型越来越差。

验证准确率本身也需要结合数据量来看。如果验证集只有几十张图,单张图片预测错都会让准确率剧烈波动,不要因为某个 epoch 的 acc 掉下来就急着调参,看整体趋势更靠谱。这个项目在 CPU 上跑,单 epoch 时间不算长,多跑几个 epoch 观察趋势的成本很低。

4. HTML 交互页面与本地服务:03html_server.py 跑通整个闭环

4.1 Web 页面与 Python 服务端的通信方式

03html_server.py的角色是一个本地 Web 服务器,它加载训练好的模型,对外提供一个 HTTP 接口,index.html页面通过这个接口上传图片并拿到识别结果。整个交互不需要联网,在http://127.0.0.1:4399上完成,典型的本地 Demo 架构。

服务端的核心思路是这样:用 Python 内置的 HTTP 库或轻量级 Web 框架(Flask 是这类场景最常见的方案)起一个服务,监听 4399 端口;定义两个路由,一个返回 HTML 页面,一个接收图片并返回预测结果。前端把用户选择的图片通过表单或 fetch 发送给后端,后端用训练好的模型做推理,再把类别名称返回给页面显示。

4.2 从训练模型到网页预测的完整链路

网页预测的完整链路可以拆成四步。第一步,用户在页面上选择一张鱼类的图片;第二步,前端把图片二进制数据 POST 到服务端的预测接口;第三步,服务端接收图片,做与训练时完全一致的预处理(Resize、ToTensor、Normalize),然后喂给模型推理,得到各类别的概率分布;第四步,取概率最大的类别,把类别名和置信度返回给前端展示。

这里最容易踩坑的一点是预处理必须和训练时保持一致。训练时用了Resize((224, 224)),推理时也必须用同样的尺寸;训练时用了那组 ImageNet 均值和标准差做归一化,推理时也必须用完全相同的一组。如果两边不一致,模型的输入分布就和训练时不同,识别效果会明显下降。很多从网上复制的代码跑出来效果差,问题往往就出在这一步。

03脚本运行后在控制台打印出 URL,复制到浏览器打开,页面长什么样由index.html决定。作者在说明里特别强调「这个 url 切记不要输错」——127.0.0.1是本地回环地址,如果你手动输入时漏了一个数字或者打成127.0.0.0,浏览器会直接拒绝连接。这个问题看起来低级,但在新手手里出现频率极高,后面避坑章节我会专门展开。

一个完整的服务端推理部分,核心逻辑大致如下:

# 加载训练好的模型权重 model = SimpleCNN(num_classes=len(categories)) model.load_state_dict(torch.load("best_model.pth", map_location="cpu")) model.eval() # 预处理与训练保持一致 transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) def predict(image_bytes): img = Image.open(BytesIO(image_bytes)).convert("RGB") img = transform(img).unsqueeze(0) # 增加 batch 维度 with torch.no_grad(): outputs = model(img) probs = torch.softmax(outputs, dim=1) conf, idx = torch.max(probs, dim=1) return categories[idx.item()], conf.item()

model.eval()这一行很多人会漏掉。在推理模式下,Dropout 层会被关闭,BatchNorm 层会切换到使用全局统计量,如果不调用eval(),同一个图片每次预测的结果都可能不同——尤其是模型里用了 Dropout 的情况下,预测结果会带随机性。加载权重时加map_location="cpu"是为了防止在 GPU 机器上训练的权重在纯 CPU 环境加载时找不到 CUDA 设备报错。

unsqueeze(0)是给单张图片加一个 batch 维度。模型训练时的输入是四维张量(batch, channel, height, width),单张图片读进来只有(channel, height, width),少了 batch 维,不补上就会在 forward 时报维度不匹配的错误。torch.no_grad()则告诉 PyTorch 不要计算梯度,推理阶段用不到梯度,关掉可以省内存、提速。

5. 配置与运行避坑:环境、路径、URL 三座山

5.1 环境装完就跑不起来:版本不匹配是头号杀手

现象:按 requirements.txt 安装完依赖,运行02深度学习模型训练.py直接报ModuleNotFoundError或者 CUDA 相关的错误。

原因:大多数情况是 PyTorch 安装版本和本机环境不匹配,最常见的是在纯 CPU 机器上装到了 CUDA 版本的 PyTorch,或者 Python 版本太新/太老导致某些依赖装不上。requirements.txt 只列出包名和版本号,但 PyTorch 的安装方式和机器有没有 NVIDIA 显卡强相关。

解决:先确认自己的机器有没有可用的 NVIDIA GPU,没有就装 CPU 版 PyTorch,通常做法是pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu(环境安装的具体步骤可参照项目内附的说明)。装完后在 Python 里执行import torch; print(torch.__version__)确认能正常导入,再做下一步。这一步是后面所有操作的前提,环境不对,后面全是白费功夫。

5.2 数据集路径带中文或空格:FileNotFoundError 反复出现

现象:运行01脚本时一切正常,但跑02训练脚本时报FileNotFoundError或者提示图片文件不存在,明明路径看着没问题。

原因:Windows 下 txt 里存的是形如数据集\某类别\图片.jpg的路径,Linux 或 Mac 下路径分隔符变成/;另外如果项目所在路径包含中文、空格,某些图像处理库在读取时容易出问题。作者给出的是 Win 下开发的使用方式,换了系统就要适配。

解决:项目整体放在纯英文、无空格的路径下,比如D:\fish_project;txt 里的路径分隔符统一处理一下,可以用path.replace("\\", "/")转成 Linux 风格,或者相反,以你的操作系统为准。我每次拿到新环境都会先打印train.txt的前三行看一眼路径格式,确认无误再跑训练。

5.3 训练时报 OOM 或内存暴涨:batch_size 是第一嫌疑

现象:训练到第二个或第三个 batch 时,内存占用飙升,CPU 环境下直接卡死,或者报内存不足错误。

原因:图片统一 Resize 到 224x224 后,每张图在 Tensor 里是 3x224x224 的 float 数组,占约 600KB;batch_size=32 就意味着一个 batch 约 19MB,加上中间特征图的计算,内存压力成倍放大。CPU 训练不像 GPU 有显存上限报错,往往是整个系统内存被耗尽才暴露出来。

解决:把DataLoader里的batch_size调小,16 不行就 8,8 不行就 4。如果数据集图片较大,也可以考虑把 Resize 的目标尺寸从 224 降到 128,训练速度会明显提升,代价是准确率可能小幅下降。对于这个体量的数据集,4~16 的 batch_size 完全够用。

5.4 URL 打不开:端口被占用或地址输错

现象:03脚本正常运行,控制台打印了http://127.0.0.1:4399,但浏览器打开显示无法访问,或者打开了却是别的内容。

原因:两种情况最常见。一是 4399 端口已经被其他程序占用,服务实际没起来;二是手动输入 URL 时把127.0.0.1打成了127.0.0.0或漏了数字,这类错误在浏览器里不会给你任何提示,只会显示无法访问。

解决:先确认服务真的在监听端口,Windows 下用netstat -ano | findstr 4399,Linux/Mac 用lsof -i:4399,看到监听记录说明服务起来了。如果占用了,在服务端代码里换一个端口,比如 8080,或者直接关掉占用程序。URL 输入我每次都直接复制控制台输出的完整地址,不手动敲,省得因为手误浪费时间排查。

5.5 验证准确率虚高或忽高忽低:数据集划分没固定种子

现象:同一份代码跑两次训练,得到的最终准确率差很多,甚至第二次的结果明显比第一次好,但你什么都没改。

原因:01脚本在划分训练集和验证集时用了random.random()但没有设置随机种子,每次运行划分结果都不同,运气好的时候验证集里简单样本多,准确率就高;运气差的时候验证集里全是难样本,准确率就低。另外训练过程中模型初始化和数据加载顺序也有随机性,加剧了结果波动。

解决:在01脚本最开头加random.seed(42),确保每次划分结果一致;在02训练脚本里同样给 PyTorch 设置随机种子(torch.manual_seed(42))。这样以后任何一次复现实验结果,结果都是可对比的。对做实验的人来说,这一步是基本素养。

6. 进阶调参与落盘验证:怎么判断模型真的可用

模型训练完、网页能出结果,只能算「跑通了」,不算「能用」。我这里分享一套自己常用的验证流程和调参思路。

第一步是检查日志的收敛趋势。打开训练日志,重点看 val_loss 有没有先降后升的拐点。如果 val_loss 在中间某个 epoch 开始反弹,说明从那之后模型就在过拟合,最优模型应该是拐点之前的某一版。因为项目里的保存策略是「验证准确率创新高才覆盖保存」,所以best_model.pth大概率是拐点附近的模型,但如果你想更精细,可以手动回看拐点对应的 epoch,重新跑一次只训练到那个 epoch。

第二步是做多次真实图片推理验证。不要只在网页上测训练集里出现过的图,从网上下载几张不同角度、不同光线下的同种类鱼图片,分别测一遍。如果训练集里的图识别得很好,换了实拍图就翻车,说明模型过拟合了训练集,泛化能力不够。这时候优先做两件事:一是把Dropout的比例从 0.5 调到 0.6~0.7,让模型在训练时更「保守」;二是加大数据增强的强度,原项目只有 flip 和 rotated45,你可以自己再加transforms.ColorJitter(随机调亮度、对比度、饱和度)和transforms.RandomRotation(随机旋转 10 度以内),让小数据集呈现更丰富的分布。注意加了增强之后训练集和验证集的预处理要同步更新,验证集不要加随机增强,只做 Resize 和 Normalize。

第三步是检查类别间的混淆情况。在验证集上跑一遍推理,把被分错的图片单独挑出来看。如果模型老是分不清某两个类别,通常是这两个类别的鱼本身长得像,或者某一类的训练样本明显偏少。前者可以尝试增大图片输入尺寸(Resize 到 299 或 384),让模型看到更多细节;后者需要补充该类别的样本,没有新样本就把该类别的图片多做几种几何变换,比如旋转 90 度、180 度、270 度各生成一份,尽量把数量补到和其他类别接近。

最后一步才是调学习率。如果 val_loss 在前几个 epoch 下降很慢,把学习率从 0.001 调到 0.003 试试;如果损失震荡不降,调到 0.0003。每次只调一个参数,改完跑一轮完整训练对比日志,不要同时动多个参数,否则出了问题根本不知道是哪一步造成的。从那以后我每次拿到类似的训练脚本,都会先把随机种子固定、在纯英文路径下运行、跑完后用十张训练集外的图片做一轮实测,这三步走完才敢把模型接到网页上给别人用。这套习惯帮我避开了很多「训练准确率挺高、一上真实场景就翻车」的问题,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询