鱼类图像识别实战:从13000张标注数据到模型训练与部署
简介鱼类图像识别数据集包含约13000张已标注图片覆盖大头鲤鱼、金鱼、疥鱼、银鲈等31个常见淡水及观赏鱼类别适合图像分类入门、模型微调与深度学习实践。数据已按训练集、验证集、测试集三个目录划分并分类存放图片配套json文件可查看完整类别映射内置show脚本支持一键可视化抽样样本便于快速检查标注质量。压缩包共2000个文件以jpg、jpeg图像为主体另有少量png及Python脚本、json文件整体约132.57MB解压后即可直接用于CNN或YOLOv5分类项目训练。已有682人浏览学习适合计算机视觉方向的学生与开发者用于数据准备、精度对比、课程设计或毕业论文实验。1. 拿到13000张已标注鱼图先认清它能做什么、不能做什么这套鱼类图像识别数据集约13000张已标注图片第一眼看上去是个稳当的“弹药库”真正上手你才会发现同样的数据有人练完能上线有人练完连验证集都过不了。差别基本不在模型而在你有没有先把数据组织、标注边界、类别分布这三件事想清楚。它能帮你做三件有价值的落地事训练一套分类基线、微调一个检测模型、评估哪个特征提取器更适合水下鱼体纹理它不能替你解决的是“鱼照片风格五花八门”这件事——水族馆玻璃反光、鱼市冷柜灯光、水下浑浊背景都会让模型的泛化能力缩水这跟数据量无关。适合谁用给做水产监测、观赏鱼分类、渔业资源统计的开发者做预训练和算法选型很顺手如果只想要一个现成的、开箱即用的“鱼类识别API”那还要先想清楚推理端放在室内固定机位还是野外不可控环境。下面我从数据体检讲起一步一步把它的价值榨干净。2. 数据格式与检查标注长什么样、哪些照片要提前筛掉拿到数据的第一件正事不是写模型而是把“数据包里到底是什么”完整摸一遍。鱼图像识别数据集的常见打包方式有分类版和检测版两种分类版给每张图一个类别标签检测版给每条鱼一个矩形框。多花二十分钟做一次格式摸底和体检能省出后面好几天排错时间。2.1 两种最常见的数据组织方式分类标签文件与检测框标注先看分类版。通常是一个images/文件夹配一个train_label.txt每一行是“图片相对路径 类别索引”类别名会单独放在class_names.txt里按索引顺序排列。这种格式朴素用 PyTorch 自带的Dataset就能直接读适合做鱼种分类、相似鱼种检索这种任务的起点。再看检测版。比较常见的是 Pascal VOC 风格每张图对应一个 XML 文件里面写object节点包含name和bndbox四个坐标也有 COCO JSON 风格把所有标注聚合在一个 JSON 文件里还有直接给 YOLO 格式的每行写class_id cx cy w h全部归一化到 0~1。我建议拿到数据后先写个十行脚本统计这三种格式的比例和文件名对应关系避免训练到一半才发现图集和标注对不上。格式典型文件优点注意点分类标签train_label.txt 每行“路径 类别索引”简单易调试没有位置信息一条鱼照片里混有背景干扰VOC XML每图一个 XML可读性好坐标原始值转换 YOLO 时要处理越界和坐标顺序YOLO txt每图一个 txt训练框架原生支持归一化后数值小肉眼难查错误2.2 训练前先跑一遍数据体检损坏图片、异常框、类别统计我一般拿到数据会先做两次体检图片体检和标注体检。图片体检解决的是“某些图根本读不出来训练时 DataLoader 直接崩掉”的问题。少量损坏图在整批 13000 张里不容易被注意到但会在训练中段突然抛出一个解压错误让整个 epoch 报废。下面这段脚本能快速筛出损坏文件和分辨率过低的图import os from PIL import Image img_root images bad_files [] small_files [] for name in os.listdir(img_root): path os.path.join(img_root, name) try: img Image.open(path) img.load() except Exception as exc: bad_files.append((path, str(exc))) continue if img.width 100 or img.height 100: small_files.append(path) print(损坏文件数量:, len(bad_files)) for path, err in bad_files[:10]: print( , path, err) print(尺寸过小文件数量:, len(small_files))这段脚本里的关键点是img.load()必须调用才能强制把图片数据读进内存单纯Image.open()只建立文件句柄很多坏图检测不出来。分辨率阈值按 100 设置是因为鱼类识别场景里目标本身可能偏小过小的图缩放上去只剩下模糊色块对训练贡献很低不如直接剔除或转做测试集。标注体检更关键检测版数据要检查每个框是否越界、坐标是否反了、以及各类别的真实数量分布。VOC XML 的检查脚本一般长这样import xml.etree.ElementTree as ET xml_path annotations/000001.xml tree ET.parse(xml_path) root tree.getroot() img_w int(root.find(size/width).text) img_h int(root.find(size/height).text) for obj in root.findall(object): name obj.find(name).text bnd obj.find(bndbox) x0 float(bnd.find(xmin).text) y0 float(bnd.find(ymin).text) x1 float(bnd.find(xmax).text) y1 float(bnd.find(ymax).text) if x0 x1 or y0 y1: print(坐标倒置:, xml_path, name) if x0 0 or y0 0 or x1 img_w or y1 img_h: print(越界:, xml_path, name)这类问题的本质不是标注工具差而是不同标注批次的操作习惯不一致。有人习惯从鱼嘴到鱼尾拉框有人习惯把整个胸鳍包含进去前者容易把尾鳍裁掉后者容易把背景水草框进来。体检脚本的作用是把这类差异量化后面统一决定“哪些框需要修正”。2.3 可视化标注一段代码看一眼标注质量做完数量检查还要做视觉检查。13000 张图随机抽 50 张把标注框画上去眼睛过一遍比任何统计指标都直观。我常用 OpenCV 快速生成可视化图import cv2 import xml.etree.ElementTree as ET image_path images/000001.jpg xml_path annotations/000001.xml img cv2.imread(image_path) root ET.parse(xml_path).getroot() for obj in root.findall(object): name obj.find(name).text bnd obj.find(bndbox) x0 int(float(bnd.find(xmin).text)) y0 int(float(bnd.find(ymin).text)) x1 int(float(bnd.find(xmax).text)) y1 int(float(bnd.find(ymax).text)) cv2.rectangle(img, (x0, y0), (x1, y1), (0, 255, 0), 2) cv2.putText(img, name, (x0, max(0, y0 - 8)), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2) cv2.imwrite(vis_000001.jpg, img)画框脚本本身不复杂真正有价值的是你积累了“什么框是健康的”判断标准。我自己的经验是鱼体在框内占满 85% 以上面积是健康标注如果框里有大片水草或玻璃反光模型就很容易被背景骗如果框只有鱼身没有鱼鳍那分类任务还好检测任务的边界回归就会学得犹豫。看到连续几张图都有同一个毛病就该回头找原始标注规则而不是靠模型硬扛。3. 用这套数据跑通分类和检测两套最小训练流程数据体检完就到了动手训练的阶段。鱼类识别这个场景比较特殊鱼体纹理细小、颜色受水质影响大、不同鱼种之间形状差异又可能很小。所以一个能正常收敛的基线比一个花哨的模型结构更重要。这章给你一套分类基线和一套检测数据适配流程参数都按 13000 张这个体量来设置。3.1 分类基线用预训练 ResNet 在 13000 张图上快速出指标分类任务我推荐从预训练 ResNet18 或 ResNet50 起步不要一上来就上视觉大模型。13000 张图对深度网络来说属于中等偏小规模从零训练容易欠拟合微调预训练模型能在两三个小时内得到可用的基线。先定义一个适配分类标签文件的 Datasetimport os from PIL import Image from torch.utils.data import Dataset class FishDataset(Dataset): def __init__(self, label_file, img_root, transformNone): self.samples [] with open(label_file, r) as f: for line in f: rel_path, label line.strip().split() self.samples.append((os.path.join(img_root, rel_path), int(label))) self.transform transform def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label self.samples[idx] image Image.open(path).convert(RGB) if self.transform: image self.transform(image) return image, label数据加载是关键路径所以这里把图片相对路径和根目录拼接放在初始化阶段完成避免每次读取都做字符串拼接。convert(RGB)也很重要有些水下拍摄的图可能是灰度或带透明通道的 PNG转成统一 RGB 可以防止后面张量维度对不上。训练循环用最常见的一段代码import torch import torch.nn as nn import torch.optim as optim from torchvision import models, transforms model models.resnet18(pretrainedTrue) model.fc nn.Linear(model.fc.in_features, num_classes) optimizer optim.AdamW(model.parameters(), lr1e-4) loss_fn nn.CrossEntropyLoss() for epoch in range(20): model.train() for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() logits model(images) loss loss_fn(logits, labels) loss.backward() optimizer.step() # 验证集直接用 batch 内的 top1 统计 model.eval() correct, total 0, 0 with torch.no_grad(): for images, labels in val_loader: logits model(images) preds logits.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) print(fepoch {epoch}: val_acc {correct / total:.4f})注意model.fc nn.Linear(...)是替换最后一层必须保证你自己数据集的类别数传进来了。学习率用到1e-4是基于微调场景的常见选择太大容易把预训练特征冲掉太小训练 20 轮后精度提升会很有限。AdamW比Adam更适合这种中等规模微调参数更新更稳。如果 20 轮后验证精度不再上涨就别再堆 epoch优先检查数据划分和增广。3.2 检测数据适配从 XML 标注转 YOLO 格式的转换脚本检测任务的第一个门槛不是模型而是格式。很多鱼图像识别数据集给的是 VOC XML但你手头常用的检测框架用的是 YOLO 标签。转换脚本一定要自己写一遍别直接拿网上现成的因为鱼的标注里经常出现“框贴边”“框超出图像边界”这类问题现成脚本一般不做检查。下面是我常用的转换思路import xml.etree.ElementTree as ET def voc_to_yolo(xml_file, out_file, class_list): tree ET.parse(xml_file) root tree.getroot() img_w int(root.find(size/width).text) img_h int(root.find(size/height).text) lines [] for obj in root.findall(object): cls_name obj.find(name).text cls_id class_list.index(cls_name) bnd obj.find(bndbox) x0 float(bnd.find(xmin).text) y0 float(bnd.find(ymin).text) x1 float(bnd.find(xmax).text) y1 float(bnd.find(ymax).text) # 关键归一化之前先夹到图像边界内 x0 max(0, min(x0, img_w - 1)) x1 max(0, min(x1, img_w - 1)) y0 max(0, min(y0, img_h - 1)) y1 max(0, min(y1, img_h - 1)) cx (x0 x1) / 2 / img_w cy (y0 y1) / 2 / img_h w (x1 - x0) / img_w h (y1 - y0) / img_h lines.append(f{cls_id} {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}) with open(out_file, w) as f: f.write(\n.join(lines))这段代码的核心不是换算公式而是clamp越界坐标那四行。鱼类数据集里常见的情况是标注框下边线正好压在图像边缘直接归一化会得到刚好 1.0 的坐标某些框架解析时容易出错深一步说框贴着图像边缘本身就是信息不足训练时模型很难学到完整鱼形所以转换后我还建议对w 0或h 0的框直接跳过并在日志里打出来人工复核。3.3 影响收敛的几个关键参数尺寸、批次、学习率与 epoch同样是 13000 张图参数设置不同结果可能差出两三个点。我给一组经过多次验证的起始参数参数分类任务建议检测任务建议调整思路输入尺寸224×224640×640鱼的纹理细太小的尺寸丢花纹信息batch size32~648~16按显存来GPU 8G 时检测任务用 16 稳妥学习率1e-41e-3 起步热身后降到 1e-4检测框回归比分类更需要大步长热身epoch20~3050~80检测任务的收敛曲线明显更慢分类任务把图像送到 224 就够了再往上提分辨率收益很小训练时间却成倍增加检测任务则要保证小鱼的框内分辨率足够640 是性价比不错的选择再高到 1280 需要 24G 以上显存只有小目标特别多时才值得。批次的选择要同时考虑显存和 BN 层统计批次太小 BN 的均值方差会跳得很厉害分类任务里 batch 低于 16 时我一般会把BatchNorm层的 momentum 调大一点来缓解。4. 增广与数据划分让13000张图在训练里发挥12000张的价值13000 张不算少但鱼图像识别有个天然短板很多鱼种外观相近加上水质、光照、拍摄角度的变化模型很容易抓住“整体颜色分布”这种粗糙特征。增广不是为了让模型“多看几种变化”而是逼它去学鱼的花纹、鳍形这类真正稳定的判别信息。数据划分也同理分层逻辑比随机划分可靠得多。4.1 增广策略颜色扰动与裁剪强度怎么根据鱼种特点设置鱼在照片里的姿态和陆地动物不一样它没有“上下颠倒”的概念但绝大多数拍摄场景鱼是水平游动的。所以我把水平翻转放到高概率位置上下翻转反而会引入大量不自然样本。颜色扰动要重点加强具体配置看下面from torchvision import transforms train_transform transforms.Compose([ transforms.RandomResizedCrop(size(224, 224), scale(0.6, 1.0)), transforms.RandomHorizontalFlip(p0.5), transforms.ColorJitter( brightness0.3, contrast0.2, saturation0.2, hue0.05 ), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ])RandomResizedCrop的scale我习惯不设得太激进。鱼类目标在图中占比一般不高设成(0.6, 1.0)既能模拟远近变化又不会频繁把鱼身裁到只剩一个局部导致训练太困难。ColorJitter里最重要的亮度因为水下拍照时阳光、灯光和玻璃缸反光都会显著改变整体亮度色相扰动不要给太大0.05已经足够多数鱼种的体色还是带真实语义的色相扭太多会让模型学出一个“不认识的颜色空间”。分类训练里增广后的验证集要和测试保持一致即验证集不要用RandomResizedCrop和翻转这类随机增广只做缩放归一化。不然验证指标会附带随机性跑两次结果不同不利于判断调参效果。4.2 分层划分验证集留给稀有鱼种足够的位置13000 张数据的类别分布通常很不均匀常见鱼种可能占掉一半稀有鱼种一个类别只有一两百张。如果直接用随机划分验证集里稀有鱼种可能只有几十张准确率波动极大。我一般按类别做分层划分保证每个鱼种在训练集和验证集里的比例一致import os import random from collections import defaultdict label_file train_label.txt val_ratio 0.15 class_samples defaultdict(list) with open(label_file, r) as f: for line in f: path, label line.strip().split() class_samples[label].append((path, label)) random.seed(42) train_lines, val_lines [], [] for label, samples in class_samples.items(): random.shuffle(samples) val_count int(len(samples) * val_ratio) val_count max(val_count, 3) # 每个类别至少留几条 val_lines.extend(samples[:val_count]) train_lines.extend(samples[val_count:]) with open(train_split.txt, w) as f: for path, label in train_lines: f.write(f{path} {label}\n) # 同理写入 val_split.txt关键点在于每个类单独采样而不是对全体随机采样。那个max(val_count, 3)是我个人的保护性写法类别样本很少时验证集至少保留三条保证评价不是靠单一样本碰运气代价是稀有类的训练样本被占掉一部分所以更稳妥的办法是在分层划分后再给稀有类做过采样或者干脆用StratifiedKfold做多次实验平均指标。4.3 用伪标签做半监督自训练把预测置信度变成新标注如果你的场景里还躺着大量未标注鱼图13000 张标注图可以当作启动半监督的种子。思路很简单先用这些数据训练一个分类模型让它对未标注图推理保留高置信度预测作为伪标签再混合进训练集继续训练。置信度阈值是唯一的权衡点我推荐从 0.9 起步import torch model.eval() pseudo_samples [] with torch.no_grad(): for images, _ in unlabeled_loader: logits model(images.to(device)) scores, preds torch.softmax(logits, dim1).max(dim1) mask scores 0.9 if mask.any(): for idx in torch.where(mask)[0]: pseudo_samples.append((images[idx].cpu(), preds[idx].item())) # 将伪标签样本混入 train_loader 再训练这个段落的效果取决于未标注数据和已标注数据的分布差异。如果未标注图来自同一个采集设备、同一个月拍摄伪标签的有效率很高如果来自完全不同的水域或光源0.9 的阈值也挡不住模型把“奇异背景”错认成某个鱼种。所以我习惯在第二个训练轮次结束后重新跑一遍伪标签筛选并统计新增样本的类别分布发现某一类伪标签数量异常多就要降低那类的权重因为很可能是模型在自欺欺人。5. 鱼类图像识别训练中的避坑指南五个血泪教训这部分我想把训练中反复出现的坑集中写一写。每条都有具体现象、背后原因和对应解法前三条偏向数据侧后两条偏向训练策略侧。这些都是踩出经验来的提前规避能少走很多弯路。5.1 标注框越界训练初期 loss 不降反升的常见元凶现象训练刚开始时 loss 徘徊不降或者降了两轮后突然反弹查看日志发现某些 batch 的 loss 数值是正常 batch 的两倍。检查标签文件时能看到少数框的归一化坐标大于 1 或小于 0。原因原始 XML 里存在框边线超出图片宽高的标注尤其是鱼尾扫出画面边缘时标注者可能没意识到「鱼尾还没拍全」。解决做格式转换时用clamp把所有坐标夹到[0, img_w - 1]区间内并用脚本扫出所有越界框单独复核如果鱼身主体完全在画面内、只是尾鳍被截断这类框保留价值大如果鱼头都被切掉一半这个框直接删掉更合适。5.2 类别不均衡被验证集准确率掩盖只看 acc 不够现象训练 20 轮后验证集准确率到 92%看起来很漂亮但展开每个类别的召回率某稀有鱼种的召回率只有 54%。原因总体准确率被常见鱼种主导稀有类别一两百张样本即使全错也只占很小一部分。解决分类任务至少看每个类别的 recall 和最后的混淆矩阵检测任务要看每个类别的 AP 而非整体 mAP。我一般习惯在验证阶段维护一个类别统计字典每轮打印出哪几个类别的 recall 低于平均值的 80%这些类别就是真正要去补样本或调权重的目标。5.3 水下色偏让模型学“水的风格”而不是“鱼的纹理”现象训练集里大量图片是水族馆灯光下拍摄的模型在验证集上表现不错换到自然光水下拍摄的测试图准确率掉了近 15 个点。观察模型在倒数第二层的特征图会发现它对不同颜色的背景区分得非常清楚。原因图片数量虽多但色彩风格过于单一模型把“蓝色调偏暗”和某一鱼种错误绑定。解决在训练增广里加强亮度扰动并额外加入随机白平衡变换这一步对水下图片尤其有效因为真实场景的色温变化非常剧烈。另一种可选手段是人工构造灰度样本参与训练强制模型不能只靠整体色块判断。5.4 冻结过多预训练层小数据迁移的另一种走火现象为了“保护预训练特征”把 ResNet 的 backbone 全部冻结只训练最后的全连接层结果 20 轮后验证准确率一直卡在 70% 出头。原因鱼体和 ImageNet 里日常物体在纹理上差异很大冻结层数太多等于放弃了模型对鱼体花纹的自适应能力。解决不要整段冻结而是让全模型以较小的学习率参与微调通常1e-4就能在保留基础视觉能力的同时适应鱼类特征。如果确实担心过拟合可以只冻结前两层卷积而不是冻结整个 backbone前两层学到的是边缘、纹理基元这类通用结构后面几层才需要针对鱼形态重新适应。5.5 相似鱼种互混把高置信度误检当信号而不是噪声现象某两个体色接近的鱼种验证混淆矩阵里互相串门而且被误判的样本置信度经常高于 0.8。原因这两个鱼种的关键差异点在尾鳍形状或侧面花纹而模型优先采用了颜色分布的粗特征。解决把误判样本按类别对导出拼图对比找到人眼能识别的核心差异然后裁剪对应部位的细粒度图单独训练一个“局部特征校验器”。这类二次校验器不需要新增大量数据在 13000 张里挑出两个鱼种的全部样本训练一个二分类小模型就能把混淆率显著降下来。6. 把数据集用透混淆矩阵诊断、ONNX导出与增量标注最后一公里往往不是训练本身而是如何诊断模型短板、把模型送到推理端、以及让数据继续增值。这套 13000 张的标注数据完全可以支撑起一个闭环训练 → 诊断 → 补数据 → 再训练。6.1 用混淆矩阵找“最该补拍的鱼种对”训练结束后我做的第一件事不是看总体准确率而是生成验证集混淆矩阵。用 sklearn 一行就能算出来from sklearn.metrics import confusion_matrix import numpy as np all_preds, all_labels [], [] # 推理过程略 cm confusion_matrix(all_labels, all_preds) # 找出混淆最严重的两个类别对 np.fill_diagonal(cm, 0) pairs np.unravel_index(np.argsort(cm, axisNone)[-10:], cm.shape) for i, j in zip(pairs[0][::-1], pairs[1][::-1]): print(f类别{i}误判为类别{j}{cm[i][j]}次)这里故意把对角线置零让最严重的跨类误判浮出水面。得到的输出不是让你去调分类阈值而是告诉你:下一轮数据采集应该重点拍哪两个鱼种的侧面对比图。数据增值不应该平均用力而是盯着混淆矩阵里的最热点补数据这个习惯能让每一轮新增图片都带来实际收益。6.2 导出ONNX并在推理侧验证输入尺寸模型要落地ONNX 是目前兼容性最好的一步。导出时要注意输入尺寸和前处理管线严格对齐否则推理效果会莫名下降import torch model.eval() dummy_input torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy_input, fish_resnet18.onnx, opset_version11, input_names[input], output_names[logits], dynamic_axes{input: {0: batch}, logits: {0: batch}}, )dynamic_axes让导出模型支持动态 batch方便推理服务压测opset_version11在绝大多数推理引擎上兼容性都很好。导出之后一定要用 ONNX Runtime 跑一张真实鱼图画框对比 PyTorch 的推理结果数值差在 1e-2 以内才算过关。常见的问题是训练时做了Normalize导出后推理端忘记做同样的归一化导致输入分布完全错位。6.3 增量标注与收尾最后一类增量标注可以把 13000 张的周期一直延续下去用现有模型对一整批未标注数据做预标注设置高置信度阈值自动接受低于阈值的分配给标注人员修正。我通常把阈值设在 0.85 而不是 0.9因为标注人员修正低置信度框的效率比从零画框高不少。每次增量标注完成后随机抽 20% 同步加入验证集避免模型越来越适应自己产生的伪标签而失去对真实分布的判断。这套 13000 张数据最正确的打开方式就是在你手里变成一条“标注—训练—诊断—补标”的循环线而不是一次性训练后就放在硬盘里吃灰。我每次开始这种重复度很高的训练任务前都会提醒自己先跑一遍数据体检再去看模型指标这个习惯帮我躲过了好几次白费一场的返工也希望帮到你。本文还有配套的精品资源点击获取