简介:基于Python与PyTorch的常见鱼类分类识别项目,附带网页交互界面与图片数据集,适合图像分类学习者、课程设计与毕业设计开发者,可快速复现从数据准备到模型训练再到网页端推理的完整流程。压缩包共368个文件,其中361张jpg图片构成原始数据集,另含3个Python脚本用于数据集文本生成、CNN模型训练和本地服务启动,3个txt文件记录类别标签、数据划分及环境依赖,1个HTML文件作为前端展示页,整体仅23.11MB。项目运行逻辑完整:先读取各类别图片生成训练/验证列表,随后训练CNN模型并保存权重,日志中记录每个epoch的验证集损失值与准确率,便于调整参数;训练完成后启动本地网页服务,通过浏览器访问本机URL即可对图片进行实时分类识别。目前已有157人参与学习下载,适合需要快速搭建鱼类识别Demo或学习CNN工程化流程的读者参考。
1. 带 Web 界面的鱼类识别项目:模型只占一半,工程闭环才是重点
拿到一批标注好的常见鱼类图片,想做一个能上传图片、马上给出鱼种名称的工具,这是很多初学者第一次接触完整 AI 项目时的真实诉求。标题里那个 zip 包,其实是把「Python + CNN 模型训练」和「Web + HTML 交互页面」拼成了一个完整可运行的项目:用 PyTorch 训一个卷积神经网络,识别常见淡水鱼和海水鱼,再把模型挂到 Flask 后端,用浏览器打开网页就能上传照片看识别结果。这类项目最大的价值不是模型结构有多新颖,而是让你在一台普通电脑上走通「数据集 → 训练 → 部署 → 浏览器使用」的完整闭环。适合有 Python 基础、想动手做第一个深度学习落地项目的开发者,也适合课程作业或内部工具演示。
2. 从 zip 解压到数据可训练:目录结构、预处理和 PyTorch 读图流程
2.1 解压后先搞清数据集的组织方式,别上来就写训练代码
拿到 zip 包,第一步不是写模型,而是看数据怎么摆放。常见的鱼类数据集会按类别分文件夹,train 和 val 目录下各放一套,文件夹名就是鱼的种类名。这种组织方式直接对应 PyTorch 的ImageFolder接口,它会把每个子文件夹当作一个类别,自动生成从 0 开始的标签。我建议步骤是把压缩包解压后,按下面的结构整理(如果原来的命名不规范,先手动归好类再继续)。
fish_dataset/ ├── train/ │ ├── 草鱼/ │ ├── 鲤鱼/ │ ├── 鲈鱼/ │ └── 鲫鱼/ ├── val/ │ ├── 草鱼/ │ ├── 鲤鱼/ │ ├── 鲈鱼/ │ └── 鲫鱼/ └── test/ ├── 待识别图片/ # 随意放几张没见过的图整理干净的原因很简单:ImageFolder要求同一类图片必须在同一个子目录里,目录一乱,标签就乱。顺带检查一下每个类别的图片数量是否均衡——如果某一类有 200 张、另一类只有 40 张,后面训练时模型会严重偏向样本多的类,这是鱼类分类项目最常见的初始坑。
2.2 用 ImageFolder 加载数据:transform 的参数决定了训练是否过拟合
数据加载脚本是整条流程的起点。我一般会单独写一个data_utils.py,把训练集和验证集的 transform 分开定义。训练集用随机翻转和颜色抖动增强,验证集只缩放和归一化,不做任何随机操作:
from torchvision import datasets, transforms from torch.utils.data import DataLoader # 训练集:带数据增强,防止小数据集过拟合 train_transform = transforms.Compose([ transforms.Resize((96, 96)), transforms.RandomHorizontalFlip(p=0.5), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) # 验证集:只有缩放和归一化,保持结果可复现 val_transform = transforms.Compose([ transforms.Resize((96, 96)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) train_set = datasets.ImageFolder('fish_dataset/train', transform=train_transform) val_set = datasets.ImageFolder('fish_dataset/val', transform=val_transform) train_loader = DataLoader(train_set, batch_size=32, shuffle=True, num_workers=4) val_loader = DataLoader(val_set, batch_size=32, shuffle=False, num_workers=4) # 打印类别映射,训练完推理阶段要原样使用 print(train_set.class_to_idx) # {'草鱼': 0, '鲤鱼': 1, '鲈鱼': 2, '鲫鱼': 3}代码逻辑说明:Resize((96, 96))把所有图片统一到 96×96 正方形,这个尺寸兼顾了训练速度和识别精度,如果你后面想用 GPU 跑更大输入,可以改成 128 或 224,但要同步调整 CNN 全连接层的输入维度。RandomHorizontalFlip对鱼类图片是安全的增强手段,因为鱼的左右翻转不会改变类别属性。Normalize用的是 ImageNet 的均值标准差,这是 PyTorch 生态的惯例,没必要自己算数据集的真实均值。
注意class_to_idx这个映射被打印出来,是因为推理阶段必须严格复用一模一样的映射顺序。训练只认数字标签,网页端要显示中文鱼名,中间这个对应关系错了,识别结果就会张冠李戴。
2.3 训练前先算一张类别分布表,把数据不平衡问题在源头解决
很多初学者跳过这一步直接开训,等 loss 曲线异常了才回头查数据。我一般会在训练前跑一小段统计代码,把各类别图片数打印出来:
import os from collections import Counter c = Counter() for root, dirs, files in os.walk('fish_dataset/train'): # 只统计文件所在的叶子目录 if files and os.path.basename(root) in train_set.classes: c[os.path.basename(root)] += len(files) for name, count in c.most_common(): print(f"{name}: {count} 张")如果发现最少的类别不足最多类别的三分之一,有两种处理思路:一是做数据增强时对该类单独调强参数,比如增加旋转角度范围;二是使用WeightedRandomSampler按类别数量反比加权采样,让模型每个 batch 都能见到少量样本的类别。对于鱼类识别这种背景下扰动比较大的任务,我建议优先做定向增强,因为加权采样容易在少量样本类别上过拟合。
3. 把 CNN 训起来:网络结构设计、训练循环和模型保存
3.1 为什么不直接上 ResNet:从零搭一个轻量 CNN 更适合入门
鱼类分类属于细粒度图像识别——比如草鱼和鲤鱼整体轮廓接近,区别在鳞片纹理和口型细节。这个任务用深度网络效果更好,但数据集通常只有几千张图片,直接跑 ResNet50 反而容易过拟合。常见做法是先从一个三层卷积的自定义 CNN 开始,把流程跑通,再逐步换预训练模型。下面的FishCNN结构是这类项目里最经典的配置:
import torch.nn as nn import torch.nn.functional as F class FishCNN(nn.Module): def __init__(self, num_classes=4): super().__init__() # 特征提取:三层卷积 + 池化 self.conv1 = nn.Conv2d(3, 32, kernel_size=3, padding=1) self.bn1 = nn.BatchNorm2d(32) self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1) self.bn2 = nn.BatchNorm2d(64) self.conv3 = nn.Conv2d(64, 128, kernel_size=3, padding=1) self.bn3 = nn.BatchNorm2d(128) self.pool = nn.MaxPool2d(2, 2) self.dropout = nn.Dropout(0.5) # 全连接:输入维度要按特征图尺寸算好 # 96 -> 48 -> 24 -> 12,最终特征图 128 * 12 * 12 self.fc1 = nn.Linear(128 * 12 * 12, 256) self.fc2 = nn.Linear(256, num_classes) def forward(self, x): x = self.pool(F.relu(self.bn1(self.conv1(x)))) x = self.pool(F.relu(self.bn2(self.conv2(x)))) x = self.pool(F.relu(self.bn3(self.conv3(x)))) x = self.dropout(x) x = x.view(x.size(0), -1) x = F.relu(self.fc1(x)) x = self.fc2(x) return x逻辑说明:输入是 3×96×96 的 RGB 图片,每经过一次MaxPool2d(2),宽高减半,三次池化后变成 12×12。通道数从 3 逐步扩到 32、64、128,这是 CNN 特征提取的通用策略——空间尺寸缩小,通道数量加深,保留足够语义信息。BatchNorm2d放在卷积和激活之间,能让训练更稳定,学习率可以适当调大。全连接前加了 0.5 的 Dropout,对几千张图片的小数据集是很关键的正则化手段。
如果修改了输入尺寸,self.fc1的128 * 12 * 12必须跟着重算。这是这类代码里最容易翻车的地方——模型定义可以正常初始化,一输入数据就报维度不匹配的错。
3.2 训练循环的标准写法:验证集精度比 loss 更值得盯
训练脚本用 Adam 优化器加交叉熵损失,这是多分类任务的事实标准。学习率从 1e-3 起步,每 10 个 epoch 衰减一半,训练 30 个 epoch。我习惯在每个 epoch 结束后输出验证集准确率,而不是只看训练 loss——训练 loss 降到很低不一定是好事,可能只是记住了训练集:
import torch import torch.optim as optim model = FishCNN(num_classes=len(train_set.classes)) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3) scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.5) def evaluate(model, loader): model.eval() correct = 0 total = 0 with torch.no_grad(): for images, labels in loader: outputs = model(images) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() return correct / total for epoch in range(30): model.train() running_loss = 0.0 for images, labels in train_loader: optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() * images.size(0) train_acc = evaluate(model, train_loader) val_acc = evaluate(model, val_loader) scheduler.step() print(f"Epoch {epoch+1:02d} | Loss {running_loss/len(train_set):.4f} | " f"Train Acc {train_acc:.4f} | Val Acc {val_acc:.4f}")关于model.train()和model.eval()这两个状态:训练模式下 BatchNorm 会更新均值和方差,Dropout 会随机失活;验证模式下两者都关闭,否则验证集的准确率每次跑都有波动,无法判断模型是否真的收敛。这也是新手最容易踩的坑——验证时不切模式,导致同一个模型跑三次出三个结果。
如果你有一块 NVIDIA 显卡,只需要在训练循环体外加images, labels = images.cuda(), labels.cuda(),同时把模型model.cuda()。没有 GPU 也没有关系,96×96 输入的小 CNN 在 CPU 上跑 30 个 epoch,几百张图也就是一顿饭的功夫。
3.3 模型保存:只存权重还是存完整模型?推理端的选择
训练完成后保存模型,常见有两种方式。我建议保存state_dict,只存权重不存网络定义,这样部署端可以自定义模型类的写法,灵活性更高:
# 方式一:只存权重,推荐 torch.save(model.state_dict(), 'fish_cnn.pth') # 方式二:存整个模型,省事但不推荐跨版本加载 torch.save(model, 'fish_cnn_full.pth')用state_dict加载时,必须先实例化一个结构完全一致的FishCNN,再load_state_dict。如果保存模型时的网络结构和加载时不一致,会报Missing key(s)或Unexpected key(s)错误。我的经验是训练脚本和部署脚本共享同一个FishCNN定义,不要复制粘贴两份,改结构时只改一处,避免两边失同步。
4. 用 Flask 把 CNN 模型接进 Web 网页:HTML 链路和预测接口
4.1 Flask 后端:加载模型权重,写一个接收图片的预测接口
模型训练完,下一步是让它能被网页调用。常见做法是用 Flask 起一个轻量 Web 服务,前端 HTML 页面负责上传图片,后端接口接收图片、做预处理、调用模型推理、返回 JSON。下面是最小可用的后端代码,命名app.py:
from flask import Flask, request, jsonify, render_template from PIL import Image import torch from torchvision import transforms import io # 项目里的类别名,顺序必须与训练时 class_to_idx 保持一致 CLASS_NAMES = ['草鱼', '鲤鱼', '鲈鱼', '鲫鱼'] app = Flask(__name__) # 实例化模型并加载训练好的权重 model = FishCNN(num_classes=len(CLASS_NAMES)) state_dict = torch.load('fish_cnn.pth', map_location='cpu') model.load_state_dict(state_dict) model.eval() # 部署时必须切到 eval 模式 # 推理时的预处理:与训练验证集完全一致 infer_transform = transforms.Compose([ transforms.Resize((96, 96)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) @app.route('/') def index(): return render_template('index.html') @app.route('/predict', methods=['POST']) def predict(): file = request.files.get('image') if file is None: return jsonify({'error': '没有接收到图片'}), 400 # 读取上传的图片并转成 RGB,避免 PNG 带透明通道导致通道数不匹配 img = Image.open(file.stream).convert('RGB') # 预处理:单张图片需要加一个 batch 维度 tensor = infer_transform(img).unsqueeze(0) with torch.no_grad(): scores = model(tensor) probs = torch.softmax(scores, dim=1)[0] top_idx = torch.argmax(probs).item() top_conf = probs[top_idx].item() return jsonify({ 'label': CLASS_NAMES[top_idx], 'confidence': round(top_conf, 4) }) if __name__ == '__main__': app.run(host='0.0.0.0', port=8080, debug=False)代码里有几个关键点值得展开。map_location='cpu'的作用是把 GPU 上训练的权重加载到 CPU 机器上,如果你服务器没有显卡,这一行必不可少。torch.no_grad()块里的推理不记录梯度,既省内存又加速。softmax把模型输出的分数转成概率分布,这样返回给前端的是一个直观的置信度值,而不是一个没有含义的浮点数。
4.2 前端 HTML 页面:文件上传和 fetch 调用
前端页面不需要任何框架,一个templates/index.html就够了。核心是基于 HTML 的文件选择框加 fetch 异步请求:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>鱼类分类识别系统</title> <style> body { font-family: "Microsoft YaHei", sans-serif; max-width: 640px; margin: 40px auto; } .upload-box { border: 2px dashed #aaa; padding: 30px; text-align: center; } #result { margin-top: 20px; font-size: 18px; } img.preview { max-width: 300px; margin-top: 15px; } </style> </head> <body> <h1>鱼类分类识别</h1> <div class="upload-box"> <input type="file" id="fileInput" accept="image/*"> <button onclick="uploadImage()">开始识别</button> </div> <div id="result"></div> <img id="preview" class="preview" alt="预览"> <script> async function uploadImage() { const fileInput = document.getElementById('fileInput'); const file = fileInput.files[0]; if (!file) { alert('请先选择一张鱼类图片'); return; } // 本地预览 const preview = document.getElementById('preview'); preview.src = URL.createObjectURL(file); // 通过 FormData 上传文件 const formData = new FormData(); formData.append('image', file); const resp = await fetch('/predict', { method: 'POST', body: formData }); const data = await resp.json(); const resultDiv = document.getElementById('result'); if (data.error) { resultDiv.innerText = '识别失败:' + data.error; } else { resultDiv.innerHTML = '识别结果:<strong>' + data.label + '</strong>,置信度:' + data.confidence; } } </script> </body> </html>accept="image/*"限制文件选择框只显示图片格式,URL.createObjectURL(file)在图片上传前就生成本地预览。fetch 请求里的body: formData严格对应后端request.files.get('image')这一行,字段名必须一致,否则后端收到空值。
4.3 启动服务:一个命令跑通全流程
在项目根目录下执行python app.py,看到Running on http://0.0.0.0:8080后,浏览器访问http://localhost:8080即可看到网页。如果你是让同一局域网内的其他电脑访问,用那台电脑访问http://部署机器IP:8080,前提是防火墙放行了 8080 端口。
这里有一个容易忽略的目录问题:templates文件夹必须和app.py在同一级目录下,Flask 默认在这个目录查找模板文件。很多人运行时报TemplateNotFound,多半是把 index.html 放在了static目录或者项目根目录。
5. 鱼类识别模型落地避坑:数据、训练和 Web 部署的 5 个常见故障
5.1 推理时图像预处理和训练不一致,准确率直接崩到等于随机猜
现象:训练时验证集准确率有 90% 以上,部署到 Web 后随便传一张图片,识别结果经常是错的,而且不是偏心某类,而是整体混乱。
原因:训练时Resize((96, 96)),推理时忘了 resize 或者改了尺寸。更隐蔽的是ToTensor()的归一化作用——它会把 0~255 的像素值缩放到 0~1,如果推理时自己用 numpy 读图再转 tensor,像素范围对不上,模型输入分布和训练时完全不同。
解决:把训练预处理和推理预处理写成一个共享函数,放在同一个模块里,不要复制粘贴两份。改任何参数,两边同时生效。另外一个验证手段是:拿一张训练集里的图片走一遍推理流程,看输出的类别是否和真实标签一致,如果这个都错了,说明预处理链路有 bug。
5.2 类别映射顺序错位,返回的中文鱼名对不上
现象:训练时class_to_idx打印的是{'草鱼': 0, '鲤鱼': 1, '鲈鱼': 2, '鲫鱼': 3},但部署时代码里写的是['鲫鱼', '鲈鱼', '鲤鱼', '草鱼'],于是模型输出的 0 显示成了鲫鱼,实际是草鱼。
原因:全连接层输出的是数字索引,这个索引必须映射回训练时ImageFolder自动生成的顺序。很多人图省事,凭印象写类别列表。
解决:训练结束时把train_set.class_to_idx用 json 存一份,部署时直接读取这个文件,不要手写。这一步是这类项目里最不值得复用精力的地方,直接锁死映射关系。如果想改类别显示名,单独维护一个显示名字典,键用数字索引,值用中文名。
5.3 CUDA out of memory:显存溢出并不总是 batch_size 的锅
现象:训练时batch_size=32没问题,验证时偶尔报CUDA out of memory。
原因:显存占用主要来自中间特征图的缓存。96×96 输入占用小,但如果哪次不小心把Resize改到 224,同样的 batch_size 显存需求会膨胀五倍以上。另外,验证时如果忘了包在torch.no_grad()里,框架会为推理计算图缓存内存,显存占用翻倍。
解决:先确认模型输入尺寸是不是变了;再确认验证推理有没有挂在no_grad()下;最后才考虑把 batch_size 减半。代码里evaluate函数用with torch.no_grad()包裹,不是可选优化,是必须操作。
5.4 加载权重报错 Missing key(s),网络定义和权重文件对不上
现象:部署端load_state_dict时抛出Missing key(s): fc2.weight, fc2.bias。
原因:保存的模型权重里有fc2这个层,但部署时的模型类里没有这一层。最常见的变更是「训练时全连接输出 4 类,部署时改成了 2 类」——这种加载必然报错。也有些人先跑了一个 10 类的实验,中途改成 4 类忘了重训,拿旧的权重做新网络。
解决:模型实例化时传的num_classes必须和训练时完全一致。如果做了迁移学习换掉了最后一层,只加载特征层权重或干脆重新训练最后一层。另一个经验是,每次改动模型结构后重新生成权重文件,并标明是第几个版本的训练结果,避免误加载旧文件。
5.5 网页能打开但点上传没反应,F12 一看接口 404
现象:浏览器访问页面正常,点击「开始识别」后按钮无响应,打开开发者工具(F12)的 Network 面板,发现请求POST /predict返回 404。
原因:@app.route('/predict', methods=['POST'])这个路由没有生效。常见情况是 Flask 代码里该路由缩进不对,或者在某个if分支里没执行到;另一种是启动了多个服务实例,浏览器请求到了另一个旧进程。
解决:先看 Flask 终端窗口有没有打印请求日志,如果没有,说明请求根本没到这个服务,多半是端口被占或浏览器访问了缓存页面。重启服务并强制刷新浏览器(Ctrl+F5)。如果请求日志里有 404,检查路由装饰器的路径和前端 fetch 的路径是否一字不差,注意大小写和斜杠。
6. 让识别结果更可信的进阶打法:迁移学习、多结果展示和置信度阈值
基础流程跑通后,提升点集中在三个方向:换更强的特征提取器、不只看 Top-1、对低置信度结果做拒绝。
最直接的提升是把自定义 CNN 换成 PyTorch 里预训练的 ResNet18,利用 ImageNet 上学习到的通用特征。实现方式很简单——在模型定义文件里加一个函数:
from torchvision import models def create_resnet_model(num_classes): model = models.resnet18(weights=models.ResNet18_Weights.DEFAULT) # 冻结前面所有层的参数 for param in model.parameters(): param.requires_grad = False # 替换最后一层全连接 num_features = model.fc.in_features model.fc = nn.Linear(num_features, num_classes) return model迁移学习的训练设置和自定义 CNN 有几个重要差异:学习率可以放到 3e-4 甚至 1e-4,因为只训练最后几层,权重更新幅度太大会冲掉预训练特征;epoch 数不需要太多,15~20 轮基本收敛。如果数据量还行,也可以不冻结特征层,只把学习率调成两层不同——特征层 1e-5,全连接层 1e-3,用参数组分别设置优化器。
第二个方向的改动在服务端接口。把torch.topk取前三个结果返回,同时前端展示列表和概率。用户在识别鱼类时,如果 Top-1 是草鱼 0.48、鲤鱼 0.41,那基本可以判定这张图并不清晰,模型也拿不准。单看 Top-1 的置信度 0.48,你以为很靠谱,对比 Top-3 才发现前两名接近,这属于高频误判的场景。
第三个方向——置信度阈值,是部署时最容易忽略的细节。设置一个阈值(比如 0.6),低于阈值时接口返回{'label': '不确定', 'confidence': 0.42}。这个「不确定」比一个错误的确定答案更有价值。鱼类识别不像人脸识别有那么成熟的活体检测,图片里可能出现非鱼类的物体,或者鱼被水草遮挡只露出局部,训练数据里没见过这种样本,模型会强行归到某个类。我还会把置信度低于阈值的图片保存到static/uncertain/目录,定期看这些难例,把它们补充到训练集里再重新训练,这是让模型持续变好的低成本手段。
最后说一个我自己的教训:刚开始做这个项目时,我在 Web 端展示的置信度直接用了 softmax 前的 logits,值域从负数到正数都有,前端把 -2.3 当成百分比展示,闹了笑话。正确的做法是始终先过softmax再取概率值展示。如果你打算把模型接到更复杂的系统里,建议把预处理、推理、后处理封装成一个类,至少留出「输入图片路径,返回 Top-K 结果」的接口,后续加功能不用推倒重来。希望帮到你。
本文还有配套的精品资源,点击获取