拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

深度学习鱼类分类实战:从数据增强到ONNX部署全流程

深度学习鱼类分类实战:从数据增强到ONNX部署全流程

简介:这份PDF文档面向计算机视觉、图像分类方向的学习者与研究人员,聚焦深度学习在鱼类识别任务中的落地方法。内容以卷积神经网络为基础,进一步引入迁移学习思路,提出PreCNN+SVM混合分类模型:先用预训练网络提取图像高级特征,再交由支持向量机完成分类,在Fish4Knowledge数据集上取得98.6%的准确率,较传统人工特征方法提升明显,也缓解了小样本场景下特征不可迁移的问题。文中还涉及TensorFlow框架下的模型训练与调优,并讨论了鱼类分类在水产养殖监控、生物多样性研究与渔业管理等场景的应用价值。资源包共1个PDF文件,约1.56MB,为期刊论文全文,含中英文摘要、引言、方法论述与实验分析,适合作为算法复现、论文写作或课程作业的参考材料。目前已有105人学习,便于快速把握该方向的技术脉络与实验设计思路。

1. 鱼类分类为什么值得用深度学习重做一遍

如果你手里有一批水下拍摄的鱼群照片,或者实验室里几千张鱼类标本图,第一反应大概率是「上深度学习」。但真到动手时,问题会一个接一个冒出来:鱼的姿态千变万化,水下光照忽明忽暗,不同种类的鱼长得几乎一模一样,标注数据还少得可怜。传统做法靠人工设计特征——颜色直方图、纹理算子、形状描述子——再喂给 SVM 或随机森林,遇到真实场景基本就崩了。深度学习,尤其是 CNN 卷积神经网络,之所以在鱼类分类上能打,核心原因是它把「特征提取」这一步也交给了网络自己学,浅层学边缘和纹理,深层学鱼鳍形状和身体轮廓,端到端训练省掉了大量手工调特征的功夫。

这篇笔记面向的是想真正把鱼类分类跑起来的从业者:你可能是在做水产养殖的自动投喂系统,也可能是在做海洋生态监测,或者只是课程项目需要交一个能用的分类器。不管哪种,下面会从数据准备、模型选型、训练调参一路讲到部署推理和踩坑排查,每一步都给可复现的命令和参数。适合有 Python 基础、跑过至少一次深度学习训练的人;如果你完全没碰过 PyTorch,建议先补一下张量和 DataLoader 的基本用法再回来。

2. 数据准备与增强:鱼类分类的地基怎么打

鱼类分类的成败,七成看数据,三成看模型。很多人一上来就调 ResNet,结果发现准确率卡在 60% 上不去,回头一看数据集里同一个物种的图片全是同一个角度拍的,模型学到的只是「背景颜色」而不是「鱼」。所以这一章先把数据这件事说透。

2.1 鱼类数据集的来源与清洗策略

公开的鱼类数据集常见的有 Fish4Knowledge、OzFish、以及 Kaggle 上的 Large Scale Fish Dataset。这些数据集质量参差不齐,直接拿来用之前必须做三件事:去重、去模糊、类别平衡检查。去重用感知哈希(pHash)最快,几行代码就能筛掉大量重复帧;去模糊用拉普拉斯方差,阈值一般设在 100 左右,低于这个值的图片大概率是糊的;类别平衡则要看每个物种的样本数,如果最多的类有 2000 张、最少的只有 50 张,那训练时必然偏向多数类。

import cv2 import numpy as np from imagededup.methods import PHash # 感知哈希去重 phasher = PHash() encodings = phasher.encode_images(image_dir='data/fish_raw') duplicates = phasher.find_duplicates(encoding_map=encodings, max_distance_threshold=8) # max_distance_threshold 越小越严格,8 是经验值,再小会误删不同姿态的同一物种 # 拉普拉斯方差去模糊 def blur_score(image_path): img = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) return cv2.Laplacian(img, cv2.CV_64F).var() # 低于 100 的视为模糊,直接剔除 sharp_images = [p for p in all_paths if blur_score(p) >= 100]

上面这段代码里,max_distance_threshold=8控制去重力度,值越小越严格;拉普拉斯方差的阈值 100 不是绝对的,水下图像整体偏暗,可以适当降到 60。清洗完之后,建议把数据集按 7:2:1 切成训练集、验证集、测试集,注意切分时要按类别分层采样,否则某个稀有物种可能全被分到测试集里。

2.2 针对水下场景的数据增强参数怎么设

鱼类图像和 ImageNet 那种清晰的自然图像差别很大,直接套用标准增强策略效果往往不好。常见做法是:随机水平翻转(鱼左右对称,翻转不改变类别)、小角度旋转(±15 度以内,模拟鱼游动时的倾斜)、颜色抖动(亮度、对比度、饱和度各 ±0.2,模拟水下光照变化)、随机裁剪(从 256×256 裁到 224×224)。注意不要用垂直翻转,鱼翻过来就不像鱼了;也不要用大角度旋转,90 度旋转会让鱼头朝下,语义完全变了。

from torchvision import transforms train_transform = transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomCrop(224), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(degrees=15), # 小角度,模拟游动倾斜 transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.05), # hue 要小,水下色偏不宜过大 transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), # ImageNet 统计量,迁移学习时保持一致 ]) val_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]), ])

这里有几个参数值得展开说。RandomRotation(degrees=15)中的 15 是上限,实际会随机取 -15 到 15 之间的值;ColorJitter里的hue=0.05故意设得很小,因为水下图像本身色偏严重,再大幅调色相会让模型学到错误的颜色关联。归一化用的均值和标准差是 ImageNet 的统计量,如果你用预训练模型做迁移学习,这一步必须保持一致,否则预训练权重就白费了。验证集的增强只做 Resize 和归一化,不做随机变换,保证评估结果可复现。

注意:如果某个物种的样本数少于 200 张,建议单独对它做过采样或者在损失函数里加类别权重,否则模型几乎不会预测这个类。

3. 模型选型与训练:从 ResNet 到 EfficientNet 的取舍

数据搞定之后,下一步是选网络。鱼类分类这个任务,图像分辨率中等、类别数通常在几十到几百之间,不需要上 ViT 那种大模型,CNN 系列足够用,而且推理速度快、部署方便。

3.1 迁移学习选 ResNet50 还是 EfficientNet-B3

ResNet50 是经典选择,ImageNet 预训练权重好找,结构简单,改起来方便。EfficientNet-B3 参数量更少、精度更高,但输入分辨率要求 300×300,训练时显存占用会大一些。我的经验是:如果类别数在 50 以内、数据量在 1 万张以下,ResNet50 微调就够了;如果类别数超过 100、数据量上万,EfficientNet-B3 的收益更明显。两者都可以用timm库一行加载。

import timm import torch.nn as nn # 方案一:ResNet50,替换最后的全连接层 model_resnet = timm.create_model('resnet50', pretrained=True, num_classes=0) # num_classes=0 表示去掉原始分类头,输出 2048 维特征 model_resnet.fc = nn.Linear(2048, num_fish_classes) # 方案二:EfficientNet-B3 model_effnet = timm.create_model('efficientnet_b3', pretrained=True, num_classes=0) model_effnet.classifier = nn.Linear(model_effnet.num_features, num_fish_classes)

num_classes=0这个用法在timm里表示去掉分类头、只保留特征提取部分,然后自己接一个nn.Linear。这样做的好处是你可以自由控制输出维度,也方便后面做特征可视化。如果显存紧张,可以把 ResNet50 的前几层冻结,只训练 layer3、layer4 和分类头:

# 冻结浅层,只训练深层和分类头 for name, param in model_resnet.named_parameters(): if 'layer3' not in name and 'layer4' not in name and 'fc' not in name: param.requires_grad = False

冻结浅层的原因是浅层学的是通用边缘纹理,ImageNet 预训练已经学得很好了,没必要重新学;深层学的是语义特征,和具体任务相关,需要微调。这样训练速度能快 30% 左右,显存也能省不少。

3.2 训练循环里的学习率、批大小与早停参数

训练参数这块,血泪经验是:学习率千万别设大。微调预训练模型时,初始学习率一般设在 1e-4 到 1e-3 之间,用余弦退火或者 StepLR 衰减。批大小根据显存来,ResNet50 在 8GB 显存上 batch_size=32 比较稳,EfficientNet-B3 可能要降到 16。优化器用 AdamW 比 SGD 收敛快,权重衰减设 1e-4。

import torch from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = model_resnet.to(device) criterion = nn.CrossEntropyLoss(label_smoothing=0.1) # label_smoothing=0.1 缓解过拟合,鱼类数据集标注常有噪声 optimizer = AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr=1e-3, weight_decay=1e-4) scheduler = CosineAnnealingLR(optimizer, T_max=30) # 30 个 epoch 内余弦衰减 best_acc = 0.0 patience, counter = 5, 0 for epoch in range(50): model.train() for imgs, labels in train_loader: imgs, labels = imgs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(imgs) loss = criterion(outputs, labels) loss.backward() optimizer.step() scheduler.step() # 验证 model.eval() correct, total = 0, 0 with torch.no_grad(): for imgs, labels in val_loader: imgs, labels = imgs.to(device), labels.to(device) preds = model(imgs).argmax(dim=1) correct += (preds == labels).sum().item() total += labels.size(0) acc = correct / total if acc > best_acc: best_acc = acc torch.save(model.state_dict(), 'best_fish_model.pth') counter = 0 else: counter += 1 if counter >= patience: print(f'Early stop at epoch {epoch}') break

label_smoothing=0.1是个容易被忽略但很管用的技巧,鱼类数据集里经常有标错的样本,标签平滑能降低模型对错误标签的过拟合。CosineAnnealingLR的T_max=30表示 30 个 epoch 内学习率从 1e-3 余弦衰减到接近 0,如果训练更久可以调大。早停的patience=5表示验证准确率连续 5 个 epoch 不提升就停,防止过拟合。这些参数不是死的,类别多、数据少的时候 patience 可以设小一点,比如 3。

4. 避坑与排查:鱼类分类训练中最容易翻车的五个地方

这一章全是踩过的坑,每条按「现象 → 原因 → 解决」写,你对号入座就行。

4.1 训练准确率 99% 但测试只有 60%

现象:训练集上准确率一路飙到 99%,验证集也在涨,但一上测试集就掉到 60% 左右。原因:最常见的是数据泄漏——同一张图的不同增强版本同时出现在训练集和验证集里,或者同一段视频抽的帧被随机切分到了两边。解决:按视频 ID 或拍摄批次切分数据集,确保同一来源的图片只出现在一个集合里;切分前先做一次感知哈希去重,把相似度极高的图片归为一组再切。

4.2 模型把所有鱼都预测成数量最多的那一类

现象:混淆矩阵里绝大多数样本都被分到样本数最多的物种,稀有物种召回率接近 0。原因:类别不平衡导致交叉熵损失被多数类主导。解决:在CrossEntropyLoss里加weight参数,权重设为类别频率的倒数;或者用 WeightedRandomSampler 让每个 batch 里各类样本数大致均衡。

from torch.utils.data import WeightedRandomSampler class_counts = [len(os.listdir(f'data/train/{c}')) for c in classes] weights = [1.0 / c for c in class_counts] sample_weights = [weights[label] for _, label in train_dataset] sampler = WeightedRandomSampler(sample_weights, num_samples=len(sample_weights), replacement=True) train_loader = DataLoader(train_dataset, batch_size=32, sampler=sampler)

4.3 验证损失震荡剧烈,准确率忽高忽低

现象:每个 epoch 的验证准确率波动超过 10 个百分点,损失曲线像心电图。原因:批大小太小(比如 8 以下),BatchNorm 统计量不稳定;或者学习率太大,优化器在最优解附近来回跳。解决:把 batch_size 提到 32 以上;如果显存不够,用梯度累积模拟大 batch;学习率降到 1e-4 再试。

4.4 推理时单张图片预测结果和验证集不一致

现象:验证集上准确率 85%,但拿单张图用model.eval()推理,结果经常不对。原因:推理时的预处理和验证集不一致,比如忘了做归一化、Resize 的尺寸不对、或者用了RandomCrop而不是中心裁剪。解决:把验证集的 transform 单独封装成一个函数,推理时严格复用;用transforms.CenterCrop(224)替代RandomCrop。

4.5 显存溢出(OOM)在第二个 epoch 才出现

现象:第一个 epoch 正常跑完,第二个 epoch 刚开始就报 CUDA out of memory。原因:验证阶段没有用torch.no_grad(),计算图一直在累积;或者训练循环里 loss 没有 detach,导致显存越占越多。解决:验证和推理代码块必须包在with torch.no_grad():里;训练时每个 batch 结束后optimizer.zero_grad()要放在loss.backward()之前。

提示:如果 OOM 发生在 EfficientNet-B3 上,先把输入分辨率从 300 降到 224,精度掉不了多少,显存能省一半。

5. 推理部署与精度提升:把模型真正用起来

训练完拿到一个 85% 准确率的模型只是开始,真正落地还要解决推理速度和部署问题。这一章讲两个实用技巧:怎么用 ONNX 加速推理,以及怎么用测试时增强(TTA)再榨出几个点的精度。

5.1 导出 ONNX 并用 onnxruntime 推理

PyTorch 模型直接推理在 CPU 上比较慢,导出成 ONNX 后用 onnxruntime 能快 2 到 3 倍,而且方便集成到 C++ 或 Java 服务里。

import torch import onnx import onnxruntime as ort import numpy as np # 导出 ONNX model.eval() dummy_input = torch.randn(1, 3, 224, 224).to(device) torch.onnx.export( model, dummy_input, 'fish_classifier.onnx', input_names=['input'], output_names=['output'], dynamic_axes={'input': {0: 'batch_size'}, 'output': {0: 'batch_size'}}, opset_version=12 ) # onnxruntime 推理 session = ort.InferenceSession('fish_classifier.onnx', providers=['CUDAExecutionProvider', 'CPUExecutionProvider']) def predict(image_tensor): # image_tensor 形状 [1, 3, 224, 224],已归一化 inputs = {session.get_inputs()[0].name: image_tensor.numpy()} outputs = session.run(None, inputs) return np.argmax(outputs[0], axis=1)

dynamic_axes把 batch 维度设为动态,这样同一个 ONNX 文件既能处理单张图也能处理一个 batch。opset_version=12兼容性比较好,再高一些的版本某些推理框架可能不支持。providers里把 CUDA 放在前面,有 GPU 就用 GPU,没有就自动回退到 CPU。

5.2 测试时增强(TTA)把准确率再提 2 到 3 个点

TTA 的思路很简单:对同一张测试图做多种变换(原图、水平翻转、不同裁剪),分别推理后把 softmax 概率平均,取最大值对应的类别。代价是推理时间翻几倍,但精度通常能涨 2 到 3 个点,对于竞赛或者对精度要求高的场景很划算。

def tta_predict(model, image, device): model.eval() transforms_list = [ transforms.Compose([transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485,0.456,0.406], std=[0.229,0.224,0.225])]), transforms.Compose([transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p=1.0), transforms.ToTensor(), transforms.Normalize(mean=[0.485,0.456,0.406], std=[0.229,0.224,0.225])]), transforms.Compose([transforms.Resize((256, 256)), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485,0.456,0.406], std=[0.229,0.224,0.225])]), ] probs = [] with torch.no_grad(): for t in transforms_list: inp = t(image).unsqueeze(0).to(device) prob = torch.softmax(model(inp), dim=1) probs.append(prob.cpu().numpy()) avg_prob = np.mean(probs, axis=0) return np.argmax(avg_prob, axis=1)

三种变换分别是原图、水平翻转、Resize 后中心裁剪,覆盖了最常见的姿态和尺度变化。概率平均比投票更稳,因为 softmax 输出包含了置信度信息。如果你的模型已经用了水平翻转增强训练,TTA 里的翻转增益会小一些,但仍然值得加。

5.3 一个容易被忽略的精度提升点:推理分辨率匹配

最后说一个我踩过的坑。训练时用的是 224×224,推理时如果直接拿原始 1024×1024 的图 Resize 到 224,鱼在画面里可能只占几十个像素,细节全丢了。正确做法是先做目标检测把鱼裁出来,再送分类网络。如果不想上检测模型,至少保证推理时的裁剪方式和训练一致——训练用 RandomCrop(224),推理就用 CenterCrop(224),别一个裁左上角一个裁中心。

我现在的习惯是:每训完一个模型,先导出 ONNX 测一遍单张推理延迟,再用 TTA 跑一遍测试集,两个数字都记下来。如果 ONNX 推理延迟超过 50ms 或者 TTA 提升不到 1 个点,就说明这个模型不值得部署,回去调结构或者补数据。这套流程帮我省了很多「训完才发现不能用」的后悔药。希望帮到你。

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

返回列表