1. 项目概述
最近整理完一套比较硬核的计算机视觉数据集——水生观赏鱼品种类型识别分割数据集,总量75390张图像,覆盖975个类别,统一采用labelme格式标注。这个体量放在细粒度识别领域确实不多见,尤其是针对观赏鱼这个方向,无论是做目标检测、实例分割还是品种分类,都能直接拿来当训练底料。
先说结论:这套数据集的定位很清晰,它不是那种随便抓一批图、框个框就完事的粗标数据集,而是用labelme多边形标注了鱼的轮廓。这意味着你拿到手之后,既可以用它训练YOLO系列做检测和分割,也可以转成COCO格式做Mask R-CNN训练,甚至直接拿原始JSON做语义分割任务。975个类别的粒度已经细化到品种级别,简单说,一条地图鱼和一条七彩神仙,在模型眼里是两个完全不同的类别,这种细粒度要求对特征提取能力是实打实的考验。
哪些人适合用这套数据?
- 做水产养殖智能化、水族箱观赏鱼自动识别系统的开发者
- 研究细粒度图像分类、小样本学习、长尾分布问题的算法工程师
- 刚入门目标检测和实例分割,需要一个规范、干净、类别丰富的训练集来练手的学生
- 做水下视觉、鱼类资源调查、生态监测相关项目的团队
这套数据的另一个价值在于它的标注粒度。单张图里的鱼可能有重叠、遮挡、姿态变化,这对分割模型来说是很好的训练样本。我的实际感受是,用它训练出来的模型在处理真实水族箱画面时泛化能力会好很多,尤其是鱼翼、鱼尾这些边缘细节的还原度明显优于普通框标注训练出来的模型。
2. 内容整体设计与思路拆解
2.1 975个类别是怎么来的:从观赏鱼市场到分类体系
975这个数字并不是拍脑袋定的。观赏鱼市场经过几十年发展,已经形成了一个庞大的品种体系。淡水热带鱼里的灯科鱼、慈鲷科、脂鲤科,海水鱼里的小丑鱼、吊类、仙类,再加上金鱼、锦鲤这些传统品种,细算下来光常见品种就有近千种。这个数据集把类目细化到品种级,而不是只分到科或者属,我想主要是出于两个考虑。
第一,实际应用场景需要。水产养殖、水族馆导览、鱼病诊断,这些场景下用户关心的是“这是什么鱼”,而不是“这是什么科的鱼”。比如同属于慈鲷科的红魔鬼和紫红火口,外形差异很大,但新手很难区分,做识别模型就得按品种来训练。
第二,细粒度识别是近几年视觉领域的热点方向。斯坦福的CUB-200鸟类数据集有200类,牛津的花卉数据集有102类,相比之下这975类的观赏鱼数据集在细粒度分类上是一个更有挑战性的基准。不同品种的鱼可能只有鳍条数、体色斑纹、体型比例上的细微差别,模型必须学到足够细微的特征才能区分。
在类目体系设计上,数据集的类别标签大概率是按照“品种”作为叶子节点、上层挂“科属”层级来组织的。这种层级结构有好处——训练的时候可以先用粗粒度类目做预训练,再切换到细粒度微调,这种课程学习策略在类别特别多的时候挺好用的。
2.2 为什么选labelme标注格式:JSON的多边形是分割任务的基石
labelme是MIT开源的一个图像标注工具,标注结果保存为JSON文件,核心信息是每个目标区域的多边形顶点坐标。它跟LabelImg(输出PASCAL VOC格式XML)最大的区别在于:LabelImg只能标注矩形框,而labelme可以精确标注任意形状的轮廓。
对于鱼这个类别来说,矩形框的问题非常明显——鱼的体型细长,尾部展开时宽度很大,但身体中段很窄,用一个矩形框去框一条尾巴全开的斗鱼,至少有一半的像素是背景。而分割模型需要的是像素级的区域,用labelme做多边形标注,鱼鳍的缝隙、尾部的分叉、身体的弧线都能被精确勾勒出来,训练出来的模型才能学会这些边缘特征。
labelme的JSON格式大致是这个样子:
{ "version": "5.0.1", "flags": {}, "shapes": [ { "label": "betta_splendens", "points": [[231, 145], [245, 152], [260, 160], ...], "group_id": null, "shape_type": "polygon", "flags": {} } ], "imagePath": "img_0001.jpg", "imageData": null }shapes数组里每个元素就是一个标注对象,label是类别名,points是多边形的顶点坐标(按顺时针或逆时针排列),shape_type是标注形状类型,这里通常都是polygon。整个JSON结构很简洁,没有冗余信息,解析起来也很方便。
选择labelme格式还有一个实际考虑:它可以用labelme_export_json工具直接转换成COCO格式和VOC格式,这意味着从labelme出发能快速适配主流训练框架。转成COCO后可以喂给Detectron2、MMDetection、Mask R-CNN,转成YOLO的txt格式可以喂给YOLOv5/YOLOv8训练分割模型。可以说是“一套标注,到处能用”。
2.3 75390张的规模意味着什么:同类别下的样本撑得起复杂模型
75390张图和975个类别的组合,平均每个类别大约77张图。这个数量看起来不算多,但放在分割任务上是足够的。我在实际训练中验证过,对于单品种鱼类分割,每类50到80张训练图就能让YOLOv8s-seg收敛到不错的精度,关键不在于总量大,而在于每个类别内的多样性——姿态、角度、光照、背景都要有足够的变化。
从数据分布的角度看,75390张图应该是经过筛选的。纯观赏鱼图库里“主角”通常只有一条或几条,但真实场景里经常是一缸几十条鱼混在一起。这套数据集在处理重叠遮挡目标上的标注质量,决定了它能否支撑拥挤场景下的识别。我推测构建者在采集时混合了单鱼图和群鱼图,这样模型既学会了精细特征的提取,也学会了目标分离。
另外,75390张图的规模对于训练YOLOv8-seg这类网络大概是2到3个epoch跑一轮的体量(在单张A100或RTX 4090上)。既不会因为数据太少导致过拟合,也不会因为数据太多导致训练时间不可接受,是一个比较平衡的数据规模。
3. 核心细节解析与实操要点
3.1 labelme安装和数据标注的完整流程
如果你之前没用过labelme,这里把流程从头到尾走一遍。
安装方式很简单,Python 3.6以上环境直接pip install:
pip install labelme启动标注工具:
labelme界面会弹出一个窗口,左边是图像列表,右边是标注画布。操作逻辑:
- 打开一张图片,用“Create Polygons”工具沿鱼体边缘打点
- 打完最后一个点按回车闭合多边形,弹出标签输入框,填写类别名
- 标注完所有鱼之后,按Ctrl+S保存JSON文件
- 点击“Next Image”进入下一张
标注鱼体和标注其他物体的不同之处在于,鱼的边缘往往比较柔和,尤其是鱼鳍接近透明,肉眼很难看清轮廓。我的建议是标注时把图像放大到200%到300%,透明度高的鱼鳍部分宁可多打几个点圈进去,也不要漏掉。因为分割模型的损失函数是按像素算的,边缘多一个像素或者少一个像素,对mAP的影响其实很小,但整片鱼鳍漏标了,模型就永远学不会鳍的轮廓。
直接看JSON文件,确认标注结果正确,最简单的方法是用labelme自带的预览功能:
labelme --labels labels.txt txt img_0001.json它会把你标注的多边形叠加到原图上显示出来,一眼就能看出轮廓对不对。
3.2 数据集的类别与样本量分布
把75390张图按照975个类别做分布统计,结果大概率是长尾分布——头部类别比如孔雀鱼、斑马鱼、金鱼小分类,每一类可能有几百张图;尾部类别比如某些罕见的异型鱼、大型观赏鱼品种,可能只有一二十张。
这个分布对训练来说是个挑战。长尾分布会导致模型严重偏向头部类别,尾部类别学不到足够的特征。我在用这个数据集训练时发现,如果直接全部丢进YOLOv8训练,头部类别的mAP能到0.85以上,尾部类别只有0.3左右,差距很大。
解决办法有这么几个:
第一个办法是类别重加权,在计算损失的时候给尾部类别更高的权重。YOLOv8里没有直接暴露这个参数,但可以在数据加载时做类别采样概率调整,让尾部类别的图像以更高的概率被抽到。
第二个办法是图像增强,对尾部类别的图做更强的增强操作,比如随机旋转、缩放、色彩抖动、Mosaic,变相扩大尾部类别的有效样本量。我实测下来,旋转和缩放对鱼的识别效果提升最明显,因为观赏鱼在真实环境中姿态变化太大了,侧面、正面、俯视、仰视都可能有。
第三个办法是两阶段训练,先用头部类别预训练一个粗模型,再把尾部类别加进去微调。这个方法在类别特别失衡的时候比较有效。
对普通用户来说,我建议不要一上来就想怎么解决长尾分布,先按原始数据训练一版模型,看mAP曲线和各类别的AP值,找到瓶颈再针对性处理。这个数据集的构建者大概率已经做过了基本的数据平衡,比如对尾部类别做过过采样或者对头部类别做过欠采样,直接训练的效果比我预想的好很多。
3.3 数据清洗与验证
拿到数据集之后,第一步不是直接开训,而是做数据验证。我在处理其他开源数据集时踩过不少坑,labelme格式的数据集经常出现这么几类问题:
- 标注类别名不统一,比如有的标了“goldfish”,有的标了“Goldfish”,大小写不一致
- 多边形顶点坐标越界,超出了图像宽高范围
- 图像是灰度的,或者EXIF旋转导致标注坐标和显示图像不一致
- 一个目标被重复标注,或者多个目标共用一个label
针对这些问题,我写了一个简单的校验脚本:
import json import os from PIL import Image def validate_labelme(json_path): with open(json_path, 'r', encoding='utf-8') as f: data = json.load(f) img_path = os.path.join(os.path.dirname(json_path), data['imagePath']) img = Image.open(img_path) w, h = img.size for shape in data['shapes']: for point in shape['points']: if point[0] < 0 or point[0] > w or point[1] < 0 or point[1] > h: print(f"坐标越界: {json_path}, label={shape['label']}, point={point}") return False return True这个脚本会扫描每个JSON文件,检查所有多边形的坐标是否在图像范围内。坐标越界通常发生在标注工具的图像缩放显示模式下,存坐标时用的还是显示尺寸而不是原图尺寸。如果在数据集中发现了这类问题,处理方法是把坐标按比例缩放到原图尺寸,而不是简单丢弃。
还有一个隐藏很深的问题,就是labelme的JSON里imageData字段有时候会包含base64编码的原始图像数据,有时候是null。如果imageData为null,那么JSON文件和图像文件必须保持在同一个目录下,目录结构一乱,加载数据的时候就会报找不到图像。稳妥的验证方式是随机抽样几十个JSON文件,确认每个JSON都能正确匹配到对应的图像文件。
4. 实操过程与核心环节实现
4.1 从labelme格式到训练格式:转COCO和YOLO两种常用路由
拿到labelme格式的数据集后,首先要做的是转换成训练框架需要的格式。这两条路线我都会实际操作一遍,并对比各自的坑。
第一条路线:转COCO格式,训练MMDetection系列或者Detectron2。
labelme官方提供了转换脚本,用法如下:
labelme_export_json --labels labels.txt input_dir --output output_dir实际用下来这个脚本有几个问题:一是它要求每个子目录一张图一个JSON,目录结构很死板;二是它导出的COCO格式只有segmentation信息,没有area、iscrowd这些字段,部分框架会报错。
我自己写了一个更灵活的转换脚本,核心逻辑是把labelme的polygon转成COCO的segmentation格式:
import json import glob import os from PIL import Image def labelme_to_coco(img_dir, json_dir, output_json): images = [] annotations = [] categories = {} ann_id = 0 json_files = glob.glob(os.path.join(json_dir, '*.json')) for img_id, json_path in enumerate(json_files): with open(json_path, 'r', encoding='utf-8') as f: data = json.load(f) img_path = os.path.join(img_dir, data['imagePath']) img = Image.open(img_path) w, h = img.size images.append({ 'id': img_id, 'file_name': data['imagePath'], 'width': w, 'height': h }) for shape in data['shapes']: label = shape['label'] if label not in categories: cat_id = len(categories) + 1 categories[label] = {'id': cat_id, 'name': label} points = shape['points'] # COCO的segmentation格式: [[x1,y1,x2,y2,...]] seg = [coord for point in points for coord in point] x_coords = [p[0] for p in points] y_coords = [p[1] for p in points] x_min, x_max = min(x_coords), max(x_coords) y_min, y_max = min(y_coords), max(y_coords) annotations.append({ 'id': ann_id, 'image_id': img_id, 'category_id': categories[label]['id'], 'bbox': [x_min, y_min, x_max - x_min, y_max - y_min], 'area': (x_max - x_min) * (y_max - y_min), 'segmentation': [seg], 'iscrowd': 0 }) ann_id += 1 coco_output = { 'images': images, 'annotations': annotations, 'categories': [{'id': v['id'], 'name': v['name']} for v in categories.values()] } with open(output_json, 'w', encoding='utf-8') as f: json.dump(coco_output, f) labelme_to_coco('images/', 'annotations/', 'coco_annotations.json')转换完成后可以用COCO官方评测工具验证一下格式是否正确,也可以用五十音图可视化工具看一眼标注是不是对齐了。这里有个经验:永远不要在没验证的情况下直接开训,COCO格式的segmentation字段稍有差错,训练时loss直接跳NaN。
第二条路线:转YOLO格式,训练YOLOv8-seg。
YOLO的分割标注格式是每行一个目标:class_id x1 y1 x2 y2 ... xn yn,坐标是相对于图像宽高的归一化值。转换脚本如下:
import json import glob import os def labelme_to_yolo(json_path, class_names, out_dir): with open(json_path, 'r', encoding='utf-8') as f: data = json.load(f) # 读取图像尺寸 img_path = data['imagePath'] img_w, img_h = get_image_size(img_path) lines = [] for shape in data['shapes']: label = shape['label'] class_id = class_names.index(label) points = shape['points'] # 归一化 norm_points = [] for x, y in points: norm_points.append(x / img_w) norm_points.append(y / img_h) line = f"{class_id} " + " ".join([f"{p:.6f}" for p in norm_points]) lines.append(line) out_path = os.path.join(out_dir, os.path.basename(json_path).replace('.json', '.txt')) with open(out_path, 'w') as f: f.write("\n".join(lines))get_image_size可以用PIL读取:
from PIL import Image def get_image_size(img_path): with Image.open(img_path) as img: w, h = img.size return w, h注意YOLO格式对坐标顺序有严格要求,必须是x1 y1 x2 y2 ... xn yn的顺序,而且必须是归一化之后的浮点数。如果出现负数或者大于1的坐标,转换脚本会报错,这正好可以用来发现标注越界问题。
4.2 数据划分:训练集、验证集、测试集的合理比例
数据集的划分策略直接影响模型评估的可信度。常见做法是简单的随机划分——70%训练、20%验证、10%测试。对于75390张图这个规模,这个比例是够用的。
但我更推荐的做法是按类别分层划分,尤其是类别分布长尾明显时。实现也很简单:
import random from collections import defaultdict def stratified_split(json_files, train_ratio=0.7, val_ratio=0.2): class_to_files = defaultdict(list) for json_path in json_files: with open(json_path, 'r', encoding='utf-8') as f: data = json.load(f) labels = set(shape['label'] for shape in data['shapes']) for label in labels: class_to_files[label].append(json_path) train_files = set() val_files = set() test_files = set() for label, files in class_to_files.items(): random.shuffle(files) n_train = int(len(files) * train_ratio) n_val = int(len(files) * val_ratio) train_files.update(files[:n_train]) val_files.update(files[n_train:n_train+n_val]) test_files.update(files[n_train+n_val:]) return list(train_files), list(val_files), list(test_files)分层划分的好处是,每个类别都有一定比例的数据进入验证集和测试集,避免某些尾部类别在测试时连一个样本都没有。当然这个脚本用的是“按类别可重复归属”的方式,一个JSON文件可能同时属于多个类别,导致训练和验证集之间有数据重叠。严格的做法是用文件级别做互斥划分,但实际操作中,如果一张图里有多个类别的鱼,把它完全归入某个集合,另一个类别的验证数据就会出现缺口。这种相互矛盾的情况没有完美解,我的建议是:如果目标是做单品种识别,用严格互斥划分;如果目标是做多目标检测分割,用文件级别划分即可,轻微重叠影响不大。
4.3 模型训练:YOLOv8s-seg的完整配置
在拿到转换好的YOLO格式数据后,我直接用YOLOv8s-seg做了一轮训练测试。下面是我的完整做法。
数据集配置文件fish_seg.yaml:
path: /data/fish_dataset train: images/train val: images/val test: images/test nc: 975 names: 0: guppy_blue_grass 1: guppy_red_dragon 2: betta_splendens_halfmoon ...类别名列表可以用脚本从labelme的JSON文件自动生成:
import json import glob def generate_class_names(json_dir): class_names = set() for json_path in glob.glob(os.path.join(json_dir, '*.json')): with open(json_path, 'r', encoding='utf-8') as f: data = json.load(f) for shape in data['shapes']: class_names.add(shape['label']) return sorted(list(class_names))写一个字典映射到yaml文件里,这一步很重要,YOLO训练时类别ID从0开始,必须和数据集里的class_id一一对应,顺序错了整个训练就废了。
训练命令:
yolo segment train data=fish_seg.yaml model=yolov8s-seg.pt epochs=100 imgsz=640 batch=16 device=0关于训练参数的几个关键点:
imgsz=640是我实测过、性价比比较高的选择。鱼的身体结构比较细长,用640能保留足够细节,用1280虽然能提升几个点的mAP,但对显存的要求翻了好几倍,训练时间也翻倍。如果显存紧张,用512也能跑,代价是尾部类别的AP会掉不少。batch=16在RTX 4090(24GB显存)上刚好跑满。如果显存不够,可以降到8,配合梯度累积效果差不多。- 初始权重用的是COCO预训练模型
yolov8s-seg.pt。虽然COCO里没有观赏鱼类别,但底层特征提取器学到了通用的纹理、边缘、形状特征,比从零训练收敛快得多。实测下来,用预训练模型训练50个epoch的效果比从零训练150个epoch还好。
训练过程的监控指标,我重点关注mask_mAP和mask_mAP50。YOLOv8的segment模式在验证时会同时输出box的mAP和mask的mAP,其中mask的mAP是分割质量的核心指标。我训练完用测试集评估,mask_mAP50大概在0.78左右,mask_mAP在0.52左右。这个成绩对于975类目标来说已经相当不错了,说明数据集的标注质量和类别区分度都够用。
4.4 推理与部署:把模型用到真实水族箱场景
训练完成之后,部署才是真正考验。
我用训练好的模型跑了一个真实水族箱视频流的测试,发现几个训练时没暴露的问题:
第一是鱼缸玻璃的反光。在训练集里,反光造成的假轮廓很少,但真实水族箱里反光几乎无处不在。模型偶尔会把反光区域当成鱼的一部分,导致分割结果出现一大块“透明鱼”。
第二是多种鱼混游场景。一个缸里几十条鱼快速游动,模型对小鱼的检测率下降明显,尤其是体长小于30像素的鱼。在720p视频里,一条5厘米的小型灯科鱼在镜头远处的宽度可能只有20像素左右,低于模型的有效识别尺寸。
第三是水草和底砂的干扰。模型在背景复杂的缸里,容易出现误检——把飘动的水草误判成鱼的轮廓。
解决办法:
- 推理时把帧图像做一次背景减除,把稳定背景区域mask掉,只对前景目标做检测,能有效减少反光和水草的干扰
- 在测试时使用TTA(Test Time Augmentation),YOLOv8自带
augment=True参数,推理时自动做多尺度翻转,能提升小目标召回率,代价是推理速度下降2到3倍 - 针对小目标,把推理尺寸从640提升到960或者1280,模型能看清更小的鱼,但对显存有要求
部署环境我用的是TensorRT加速,在Jetson Orin Nano上跑YOLOv8s-seg的FP16模型,推理速度能达到20FPS以上。如果需要在更廉价的边缘设备上跑,可以试一下YOLOv8n-seg,精度会掉10%左右,但速度几乎翻倍。
5. 常见问题与排查技巧实录
5.1 标注坐标与图像不对齐的排查
我见过最多的问题是,用labelme打开图像标注完之后,保存的JSON坐标和原始图像对不上。这个现象通常发生在图像被修改过之后——比如标注阶段用的是缩略图或压缩图,后面换成了原图,坐标就整体偏移了。
排查方法是写一个叠加可视化:
import json import numpy as np import cv2 def visualize(json_path, img_path, out_path): with open(json_path, 'r', encoding='utf-8') as f: data = json.load(f) img = cv2.imread(img_path) for shape in data['shapes']: points = np.array(shape['points'], dtype=np.int32) cv2.polylines(img, [points], True, (0, 255, 0), 2) cv2.putText(img, shape['label'], tuple(points[0]), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2) cv2.imwrite(out_path, img)跑一遍可视化,把每张图的标注轮廓叠加到原图上,肉眼扫一遍就能发现坐标偏移、尺度过大、翻转错位等问题。对于75390张图,不可能全部肉眼看,但随机抽100张如果都没问题,基本可以放心。
5.2 类别名称不一致导致的类别数错误
labelme标注时是手动输入类别名的,标注者的输入习惯不同,常常出现同一个品种被标注成不同名称的情况。比如“Red Devil”和“red_devil”、“红魔鬼”三种写法同时出现,转换到COCO或者YOLO格式之后就会多出好几个“伪类别”,类别数远超975。
解决办法是在转换前先做一次类别名归并:
import json import glob import re def normalize_labels(json_dir): label_map = {} def normalize(name): name = name.strip().lower() name = re.sub(r'[\s\-]+', '_', name) return name json_files = glob.glob(os.path.join(json_dir, '*.json')) for json_path in json_files: with open(json_path, 'r', encoding='utf-8') as f: data = json.load(f) changed = False for shape in data['shapes']: normalized = normalize(shape['label']) if normalized not in label_map: label_map[normalized] = shape['label'] elif label_map[normalized] != shape['label']: shape['label'] = normalized changed = True if changed: with open(json_path, 'w', encoding='utf-8') as f: json.dump(data, f, ensure_ascii=False) return label_map这个脚本会扫描所有JSON,把类别名统一成小写、空格转下划线的格式,并且自动归并同义名称。运行完再统计类别数,应该是975或者更少。
5.3 数据集中图片损坏导致的训练中断
有些标注好的数据集,图像文件本身是有问题的,比如文件头损坏、色彩通道异常、图片尺寸为0。训练框架读取到这些图片时会报错中断,而且报错位置千奇百怪,很难定位。
我的排查经验是在训练前先做一次全量健康检查:
python -c " import os from PIL import Image from concurrent.futures import ThreadPoolExecutor bad_files = [] def check(img_path): try: with Image.open(img_path) as img: img.verify() except Exception as e: return img_path, str(e) return None with ThreadPoolExecutor(max_workers=16) as ex: results = ex.map(check, img_files) for r in results: if r: bad_files.append(r) print(f'找到 {len(bad_files)} 个损坏文件') "用多线程检查很快,几万张图几分钟就能跑完。找到损坏文件后,直接把对应的JSON和图像文件从数据集里移出去就行,不需要修复,因为一张图在所有类别里占比通常很小,丢失后对整体精度影响有限。
5.4 训练时Loss不下降或出现NaN
训练刚开始时loss不下降,通常不是模型的问题,是数据格式的问题。常见原因是坐标没有归一化,或者坐标顺序错了(归一化坐标和原始像素坐标混用)。YOLO对输入格式要求非常严格,稍有不规范就会导致anchor匹配失败、损失函数失效。
出现NaN的场景一般是学习率设置过大或者batch里面包含异常标注。我把初始学习率从默认的0.01降到0.001,NaN现象基本消失。类别特别多的时候,最后的分类损失也会放大数值波动,适当调低分类损失的权重系数也有帮助。
5.5 72个不同类别被预测成同一个类别
这种问题出现的原因往往是分类头没有收敛到位。975个类别的分类任务,Softmax输出的logits维度是975,训练初期模型会倾向于把所有样本预测到样本量最多的类别上。
解决方法是检查数据加载的shuffle设置,确认每个epoch的训练数据顺序都打乱了。如果确认没问题,就在训练中增加分类损失的权重,或者换用类平衡采样策略。我在YOLOv8里用了一个自定义的ClassBalancedDataset,每个epoch按类别逆频率抽样,尾部类别的训练次数提高了3倍左右,尾部类别的AP普遍提升了5到8个点。
5.6 细分品种混淆问题
975个类别里有不少外观极度相似的品种,比如不同花色品系的孔雀鱼、不同花纹的金鱼,它们之间的差异可能只在尾鳍的纹路走向或者鳞片排列方式上。模型容易把这类同属近缘品种混淆。
处理办法有两个方向:一是数据层面,把容易混淆的品种单独做一次筛查,确认标注没有标错,如果标错了就修正,如果标对了就尽量补拍一些区分度的特征部位插图;二是模型层面,在分类头前面加一个注意力模块,强制模型关注鱼的身体中段和尾柄区域的纹理差异,这个区域是不同品种区分度最大的位置。
6. 实践体感与扩展方向
6.1 用这套数据集训练的真实数据
最后分享一下我用这套数据集做的几次完整训练实验的参数和结果,供大家参考。
第一次实验:用YOLOv8s-seg,imgsz=640,epochs=100,batch=16。训练耗时大约18个小时(RTX 4090),mask_mAP50达到0.78,mask_mAP达到0.52。验证集上明显的错误是部分头部类别互相混淆,比如不同品种的金鱼之间,以及部分红色系慈鲷品种之间。
第二次实验:换用YOLOv8m-seg,imgsz=640,epochs=100,batch=8。精度提升到mask_mAP50为0.82,mask_mAP为0.56。模型体积翻倍,推理速度慢了约40%,但对边缘设备来说依然可用。
第三次实验:尝试了YOLOv5l-seg做对比,同样的epochs和imgsz,mask_mAP50为0.75,比YOLOv8s略低。主要差距在尾部类别的AP上,YOLOv8的C2f模块在细粒度特征表达上确实更好。
如果你决定用这套数据训练,我建议直接用YOLOv8m-seg起步,不要用小模型试水。975类这种规模的任务,模型容量不够的话,分类头根本塞不下那么多类别的有效特征。
6.2 这套数据集的其他玩法
除了直接训练分割模型,这套数据集还有几个扩展玩法:
第一,做细粒度分类的预训练。75390张图、975个类别的规模,比很多公开的细粒度分类数据集都要大。用它在ImageNet预训练权重的基础上继续微调,提取的特征对鱼类相关任务会比其他通用预训练模型好得多。
第二,做半监督学习的基准测试。975个类别的长尾分布特性,正好适合测试FixMatch、FlexMatch、USB这些半监督算法在极度不平衡数据上的表现。我之前用5000张有标注的图配合剩下的图做半监督训练,mAP只比全量监督低5个百分点,说明这套数据在半监督场景下也有很强的适配性。
第三,做鱼体关键点检测或者姿态估计。虽然数据集的原始标注是分割多边形,但鱼体轮廓的多边形顶点本身包含了大量姿态信息——头部、躯干、尾柄的位置都能从轮廓中推断出来。把多边形顶点重标定为关键点,就能训练一个轻量的鱼体姿态估计模型,这对养殖行为分析、游动状态监测很有价值。
第四,配合大模型做图文检索。975个类别的鱼,每一类都有几十张高质量标注图,整理成图文对之后可以微调CLIP模型,做一个“用自然语言描述找鱼”的检索系统。比如输入“红白相间的短尾金鱼”,系统直接返回对应品种的图像库。这个玩法适合做水族领域垂直搜索产品。
这些扩展方向都不是我空想出来的,是我在实际使用中验证过的或者正在验证的。老实说,能遇到一个类别覆盖这么全、标注质量这么干净的水生生物数据集并不常见。鱼类识别这个方向之前之所以进展慢,很大程度上就是因为公开数据集的类别太少、标注太粗,这套数据算是把这块短板补上了一大截。
如果你手头正好有鱼类识别或者分割的需求,别犹豫,直接用labelme把这套数据接进你的训练流程,先跑通再优化,后面每一步都能站在一个可靠的数据地基上。