做渔业视觉项目也有几年时间了,从早期用传统图像分割数鱼、量体长,到后来全面转向深度学习检测,最深的感受就是:数据比模型难搞,部署比训练磨人。这次想聊聊一个我最近反复迭代的项目,项目代号叫“YOLOv26鱼类目标检测与识别实现”,本质上就是把YOLO系列检测框架用在一片复杂水域的鱼类识别场景里,从数据集构建一路做到端侧部署,期间踩了不少坑,也沉淀出一套比较顺手的流程,分享出来给同样在做渔业智能化、生态监测或者水下视觉相关的朋友当个参考。
这个东西能解决什么问题?一句话概括:给定一段水下摄像头拍到的视频流,它能实时把画面里出现的鱼框出来,并且告诉我框里是哪一类鱼。听起来不复杂,但真正落到养殖计数、鱼种统计、行为分析、投饵决策这些业务上时,你会发现“能跑”和“能用”之间隔着一条很宽的河,项目里大部分时间不是在调模型结构,而是在跟数据质量、环境噪声、目标尺度、部署延迟这些细节较劲。
无论是刚接触目标检测的学生,还是已经在做智慧渔业落地的工程师,这篇文章里提到的很多问题你们大概率也会遇到。尤其是数据标注策略、小目标处理、模型导出量化这三个环节,我踩过的坑比想象中多得多,希望能帮你们提前绕开。
1. 整体思路拆解:为什么“检”和“识”要放在一起做
1.1 这个项目到底在检测什么,识别什么
先厘清一个容易混淆的点:所谓“鱼类目标检测与识别”,听起来像两个任务,实际上在YOLO框架里是一个统一的过程。检测负责给出目标的位置框,识别负责给框内的内容打标签,两个输出在同一个前向推理里同时完成。也就是说,模型输出的每个预测框自带两个属性:坐标(x, y, w, h)和类别id,这跟“先检测后分类”的两阶段方案是有本质区别的。
我做这个项目时,最初的业务需求很简单:养殖场里几个不同品种的鱼混养在一个大池子里,需要实时统计各品种的数量比例,判断某一种鱼在某个区域是不是过于密集,方便后续决定是否调整投喂策略或者启动分池操作。这个场景决定了检测目标是移动的、群体性的、姿态多变的,识别目标则是品种级别的差异,比如同属鲤科的几种鱼长得很像,头部轮廓、鳞片颜色、背鳍位置都有距离不大的细节差异,这对模型的判别能力是有要求的。
项目代号里的“v26”其实并不是某个官方版本号,而是我自己的流水线版本记录,从最早的单阶段检测试验一直迭代到支持多尺度、带注意力机制的轻量化检测头,这就是为什么要保留一个版本名的原因:便于在实验记录里追踪每一次改动的影响。这里我多说一句,很多刚入门的朋友喜欢把YOLO当黑盒,“反正训练一跑就有了”,但版本和数据管理没跟上,后期想分析精度下降原因时会非常痛苦,强烈建议从一开始就给每个实验做标签化管理。
1.2 为什么选择YOLO系列的检测架构
鱼类的检测场景跟通用的行人检测、车辆检测相比,有几个特点值得琢磨。第一是背景噪声强,水下光照衰减严重,水体浑浊导致对比度低,再加上悬浮颗粒、气泡、水草等干扰物,对检测器的鲁棒性要求很高。第二是目标尺寸分布极端,近距离的鱼可能占据画面三分之一,远处巡游的鱼可能只有十几个像素,尺度差异非常明显。第三是密集重叠,鱼群聚集时目标相互遮挡、交错,边界框重叠率极高,这对NMS后处理是很大的压力。
YOLO系列模型在工业界用得多的原因,除了推理速度快、部署生态完善之外,更关键的是它天然适合这种场景的快速迭代。单阶段检测器把“找目标”和“认目标”融在一个网络里,在保持实时性能的同时,通过多尺度特征融合和多种数据增强策略,能在小目标和高重叠场景下达到相当可用的精度。我没有选择Mask R-CNN这类实例分割模型,主要原因是养殖场现场用的是普通工控机,GPU算力有限,分割模型对每个实例输出的像素级掩膜对当前业务来说属于“超额交付”,性价比低。
模型结构上,我最终保留的是在YOLO基础上加了一层轻量级注意力模块,目的很简单:让网络在特征提取时更关注“鱼”的纹理和轮廓,弱化水草、波浪折射带来的背景激活。这个调整在推理耗时上没有显著增加,但鱼种相近的误分类问题改善了不少。
1.3 方案选型背后的核心权衡
做方案设计时我一直在算一笔账:用什么样的算力,承载多大的模型,达到什么级别的准确率。这个平衡点要是没找好,就会出现两种尴尬情况:模型太大了,工控机跑不动只能降到很低帧率;模型太小了,漏检一堆,业务上根本没法用。
我给自己定的指标是:在1080p分辨率下,推理帧率不低于25 FPS,常见鱼种检测的mAP@0.5不低于0.85,AP@0.5:0.95不低于0.6。围绕这个目标,我对比了三类方案。第一类是用大规模预训练权重直接微调,收敛快但模型体量大;第二类是从头训练一个小模型,速度快但精度托底不够;第三类是知识蒸馏,先训一个大模型当老师,再让小模型去学,这个方案最终效果最好,代价是需要更多实验时间。
还有一个容易被忽略的取舍是“框+类别”的标注策略。鱼类识别里有些品种差异体现在颜色花纹上,有些体现在鱼鳍形态和身体比例上,如果在标注阶段没有统一标准,模型学到的特征会非常混乱。比如背鳍的形状在不同光照角度下会有形变,如果标注时一会儿框头部、一会儿框全身,模型就会很困惑。我的经验是:对于鱼这类柔性目标,统一标注“完整身体轮廓的外接矩形”,鳍部轻微超出不算误差,但如果身体弯曲明显,宁可框紧身体主干部位,也不要为了把鱼鳍都包进去而框入过多背景。
2. 数据准备与标注:决定精度上限的隐藏工程
2.1 采集数据时最容易忽略的变量
聊数据这个话题之前,先问一个问题:你手上有没有足够多的鱼在不同光照条件、不同水深、不同浑浊度下的画面?我最初拿到的数据集是某实验室池塘摄像机连续两周的记录,内容很丰富,但分布非常有偏向性,比如晴天正午的画面占了60%,阴天和傍晚的画面很少。直接拿这批数据训练出来的模型,在晴天场景下表现惊艳,一转到阴天或者逆光的水面就频繁漏检。
后来我把数据采集当成了单独的工程任务来做,重点覆盖了这么几个维度:光照条件(晴天正午、阴天、清晨、傍晚黄昏)、水体透明度(清水、轻度浑浊、重度浑浊)、拍摄角度(俯拍、平视、斜向)、目标密度(单鱼、三五条、鱼群)、运动状态(静止、慢游、抢食时的高速游动)。每一种组合都尽量采集至少几百帧,宁可在总量上多花时间,也不要让模型在某个环境变量上“偏科”。
采集方式上,如果条件允许,我强烈建议采用多机位同步录制。单一固定机位只能拍到鱼的侧面或背面,识别特征不完整,而两个角度同步拍到的画面可以互相补充,比如一些鱼在侧视角下花纹完全看不出差别,但在顶视角下背部的纹路特征非常明显。这个经验来自一次踩坑:某两个鱼种的正面和侧面轮廓几乎一致,模型怎么调都分不清,后来补了一批俯拍数据,错误率直接下降了一大截。
数据清洗同样是个体力活。水下画面经常有严重偏色、运动模糊、气泡遮挡,这些质量太差的帧如果不清理掉,模型会把气泡误学成鱼的特征。我清理的原则很简单:模糊到人眼都认不出的,删;鱼被大面积遮挡的,删;水面波纹反光和实际鱼体几乎无法分辨的,删。清洗完剩下的有效数据大概只有原始素材的六成,但这六成数据训练出来的模型,效果远超拿全部素材直接训练。
2.2 标注规范的制定与常见分歧
标注是整个流程中最枯燥、也最容易翻车的环节。我见过不止一次因为标注标准不统一,导致后期模型表现诡异地差,复盘时才发现是不同标注员对“框哪里”的理解不一样。所以在开始标注前,必须先定一份规则,并且写清楚一些边界情况。
我用的标注工具是常见的开源图形标注工具labelImg和X-AnyLabeling,前者轻量简单,后者胜在能加载SAM等预标注模型做半自动辅助。针对鱼类项目的标注规范,我最终形成了以下几个原则:
- 目标被遮挡超过30%时,跳过不标;被轻微遮挡但轮廓完整时,按主体框标。
- 鱼身弯曲幅度较大时,标注“能够包含完整躯干的最大内接水平矩形”,不强行贴合整条鱼的外包框。
- 鱼群密集导致边界框高度重叠时,依然按每只见得到的鱼单独标注,不允许合并框。
- 尾部鱼鳍完全展开和贴拢状态,都算同一类,不单列属性。
- 有品种争议的画面单独放一个“待确认”文件夹,由多人共同确认后再加入训练集。
标注格式上,我统一转换成YOLO格式的txt文件,每行是“class_id x_center y_center width height”,坐标都归一化到0到1。这里有个细节:很多标注工具导出时默认框坐标是原图尺寸的绝对像素值,用之前一定要确认转换函数是否正确,不然后续训练时目标框坐标会错得离谱。
标注团队内部的复核也很重要。我通常要求第二个人随机抽检20%的已标注数据,重点看三件事:类别有没有标错、框有没有明显偏大偏小、有没有漏标。如果连续两个批次抽检错误率都超过5%,说明标注规范还需要再细化,这时候先不要急着扩充数据,把标准校准了再继续投时间。
2.3 数据增强策略:用组合拳对抗水下环境干扰
数据增强在鱼类目标检测里不是锦上添花,而是刚需。水下视频流里一个很大的困扰是水体折射和悬浮颗粒会让鱼的边缘看起来不“锐利”,甚至有一部分鱼体颜色与背景几乎融为一体。模型如果只在干净增强后的数据上训练,遇到这种画面会表现得很“慌”。
我的增强管线一般是分两批跑的。第一批是通用增强,包括随机翻转、随机颜色扰动、随机缩放、mixup和mosaic。第二批是针对水环境场景的增强组合,这部分才是关键:随机低通模糊、随机噪声注入、随机对比度/饱和度扰动、随机模拟雾化效果。这样做的好处是模型在训练阶段就见过了“可能的坏画质”,到了真实水下部署时,面对不同的水体透明度都不会乱了阵脚。
增强力度上也要注意平衡。我做过一个对照组实验,把mosaic的比例从默认值调大到很高,结果模型在密集鱼群场景下的召回提升了,但单尾鱼的定位精度下降了。原因也好理解:mosaic会把四张图拼在一起,模拟出大量目标密集出现的场景,但拼接过程中目标会被截断,模型对完整小目标的建模能力反而被削弱。调到合适比例,两边的指标才能同时好看。
有一种增强方法对鱼类检测很有用,我单独提一下:给训练样本按“形态变化模拟”做几何增强。鱼在水里的姿态是动态的,同一条鱼可能从侧面快速游过时被拉得细长,转头时又变短粗。通过对标注框内的目标做随机细长的拉伸压缩,相当于给模型增加了一些“局部形变”的样本,对提升游动姿态下的检测稳定性帮助很大。
3. 模型训练实操与参数调优路径
3.1 环境准备与训练启动的完整流程
训练环境这块我最终选了PyTorch路线,底子是Ultralytics库。为了方便复现,我把整个训练流程写在了一个项目脚本里,关键步骤是固定的,跑实验只需要改配置。下面给出一个可以直接参考的训练启动流程:
# 创建虚拟环境并安装依赖 conda create -n fish_yolo python=3.10 -y conda activate fish_yolo pip install ultralytics pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 # 准备数据目录,YOLO格式数据集结构建议如下: # datasets/ # fish/ # images/ # train/ # val/ # labels/ # train/ # val/ # data.yaml # 启动训练,以YOLO预训练权重为起点做迁移学习 yolo detect train \ model=yolov8m.pt \ data=/path/to/fish/data.yaml \ epochs=150 \ imgsz=640 \ batch=16 \ device=0 \ workers=8 \ cache=True \ patience=20 \ project=fish_yolo_runs \ name=fish_v26_basedata.yaml的核心配置大概是这样的,注意names的顺序必须和标注时的class_id严格一致。
path: /path/to/fish train: images/train val: images/val nc: 5 names: 0: 'species_a' 1: 'species_b' 2: 'species_c' 3: 'species_d' 4: 'species_e'训练启动后不要急着放那儿不管,要养成盯训练曲线的习惯。我最关心的几个指标是:验证集mAP@0.5、mAP@0.5:0.95、precision和recall曲线,以及loss曲线的收敛趋势。如果在训练中后期发现验证集loss不降反升,而训练集loss还在跌,那就是过拟合信号,需要赶紧调早停参数或者增加数据增强力度。
3.2 超参数的“按需调节”:从默认值出发但要敢改
YOLO的默认超参数对大多数通用场景是合理的,但鱼这个对象太特殊,我在调参过程中有几个比较重要的经验。
输入分辨率(imgsz)是我最先调的一项。初始用640训练的模型对小鱼效果不太行,后来我把输入分辨率提到960,小目标AP提升很明显。代价是训练时间和推理时间双双上涨,尤其是显存吃紧的显卡上会比较吃力。我的建议是:如果业务里目标尺寸偏小,优先考虑提升imgsz,不要急着加复杂注意力模块,因为分辨率带来的收益往往更直接。
batch size的选择跟显存和收敛稳定性有关。显存不够的时候不要硬撑大batch,可以用梯度累积来模拟更大batch的效果,但要注意学习率的配合。我从实际对比中感受到:batch从8提到16,收敛速度有改善,但从16提到32,在总epoch相同的情况下收益不明显,反而是显存压力增大。对中等规模数据集,batch 16是一个比较甜的区间。
学习率这块我吃过亏。用过默认的lr0=0.01直接训,结果loss在前期剧烈震荡,后来降到0.001才算稳下来。对于鱼这种特征细节多、且类别间相似度极高的任务,保守一点的学习率更靠谱。另外,如果发现整体loss收敛得很慢,可以考虑在训练中段手动降低学习率,利用余弦退火的末期让损失函数进一步压到更低的谷值。
epoch数也不是越大越好。我的一个实验跑了300个epoch,结果前220个epoch模型一直在稳步提升,后面80个epoch不仅没有提升,验证集AP还出现了缓慢下降。早停参数设到20就够用了,不必把时间浪费在收益为负的尾段。
3.3 从精度指标反推问题:鱼种混淆与漏检定位
训练结束后不能只看一个总mAP,要仔细拆类别看每一类的precision和recall。之前在某个项目里,总mAP看着挺漂亮,但细看发现某一个品种的AP特别差,一查原因是这个鱼种数据量偏少,且体色花纹与环境背景接近,模型学的特征不足。
定位漏检问题的话,我会把验证集里的漏检样本镜像导出出来,单独建一个文件夹慢慢看。这个过程虽然人工,但却是最有效的:通过看漏检图片,能很快发现模型倾向于在什么姿态、什么光照、什么背景下犯错。比如有一类鱼尾巴特别大,侧身游过时尾巴摆动幅度大,模型经常只框到身体前段,把尾巴截在框外,后来通过调整标注规范,把这些鱼统一标注时把展开的完整尾巴包含进去,问题才缓解。
另外要警惕类别不均衡。如果训练集里某类鱼的数量是另一类的十倍,模型很容易在数量多的那类上表现好,而数量少的那类经常被吞掉。我的做法是在数据层面做采样权重调整,对样本少的类别提高采样概率,同时在损失权重里稍微拉高少样本类别的贡献。这两个操作同时上,比单纯堆数据有效更快。
3.4 模型、训练节奏与实验记录的小技巧
为了便于复盘,我习惯给每个训练实验建立一个说明文档,记录当时改了哪些配置、训练集有哪些新增数据、增强管线是什么样的、最终指标是多少。听起来琐碎,但这套“实验台账”在后期排查问题时帮了大忙。有一次我重跑了三遍实验都觉得结果比上一次差,根本原因是上一轮的训练集比这一轮多了两百张增强图,而这个差异我一开始没记录,后来翻台账才发现的。
训练进度上,我会用训练过程中的checkpoint机制保存每隔一段epoch的权重。最优权重和最后一次权重经常不一致,保存checkpoint的好处是不会让早停或过拟合毁了整个训练成果。最终部署时通常选择验证集mAP最优的那个checkpoint,而不是最后一个epoch的权重。
还要提一嘴训练时的随机性问题。相同超参数、相同数据,跑两遍得到的指标会有微小波动,这是正常的,但如果你发现波动幅度明显偏大,就要检查是不是数据加载顺序、增强种子或者硬件环境出了问题。我一般会固定全局种子,让实验可复现,至少在排查问题阶段能保证对照的公平性。
4. 部署与推理优化:把模型塞进受限算力
4.1 模型导出与格式选择的对比
训练好的模型最终要离开研究环境,跑到工控机上做实时推理。PyTorch训练出的权重文件是不能直接用于工业级推理的,需要先做格式转换。我最常用的导出流程是这样的:
from ultralytics import YOLO model = YOLO('/path/to/best.pt') model.export(format='engine', half=True, int8=False, dynamic=False, imgsz=960)onnx格式是最通用的一层,几乎所有平台都支持;engine格式是TensorRT的专用格式,在N卡工控机上推理速度最快。如果目标平台是国产化芯片或者没有N卡的环境,onnx配合对应平台的runtime也能跑,但速度会打折扣。我项目里的主力部署机是带N卡的工控机,所以最终选择的是TensorRT engine格式,实测下来比直接跑PyTorch模型的推理速度快了约50%。
当平台不支持TensorRT时,退而求其次的方案是使用ONNX Runtime加fp16精度。ONNX Runtime的兼容性好,而且可以动态输入分辨率,对摄像头的不同倍率变焦有更好的适配能力。代价是fp16在部分老显卡上速度加成不明显,还是得实测为准。
还有一点值得强调:导出engine时输入尺寸要和训练尺寸保持一致或者接近。很多文档推荐用dynamic shape,但动态shape会牺牲一部分TensorRT的推理优化空间。我的选择是固定输入尺寸640,原因是该项目摄像头距离不变,目标尺度变化通过实际推理时的图像缩放来解决,而这样做能换来更快的极致优化。
4.2 摄像头视频流的接入与处理队列设计
实时检测的输入源是视频流,常见的坑在于摄像头的帧率波动和网络延迟会导致推理管线时快时慢。如果你直接把推理函数放在帧循环里,每一帧都同步等推理结束,帧率会被慢速帧拖垮。正确做法是引入独立的捕捉线程和推理线程,中间用一个有界队列缓冲。
import threading import queue import cv2 import numpy as np import time frame_queue = queue.Queue(maxsize=8) result_queue = queue.Queue(maxsize=32) class CameraCapture(threading.Thread): def __init__(self, rtsp_url): super().__init__() self.cap = cv2.VideoCapture(rtsp_url) self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 2) self.running = True def run(self): while self.running: ret, frame = self.cap.read() if not ret: break if frame_queue.full(): frame_queue.get_nowait() frame_queue.put(frame) class InferenceWorker(threading.Thread): def __init__(self, model): super().__init__() self.model = model self.running = True def run(self): while self.running: try: frame = frame_queue.get(timeout=1.0) except queue.Empty: continue results = self.model(frame, conf=0.35, iou=0.5) result_queue.put((frame, results))队列长度要眼谨慎设置。太小了丢帧严重,影响统计准确度;太大了帧延迟变大,实时性变差。我日常取8到10帧的缓冲,基本能做到既不丢帧也不产生明显延迟。线程间的同步还涉及锁和性能开销,这个实现做好即可,不必过度优化。
不管是用ONNX还是engine,单帧前向推理耗时是固定的。1080p原帧直接推理太慢,我的方案是先等比缩放到合适输入尺寸,推理完再把框坐标映射回原图坐标。这个映射关系虽然简单,但要留意缩放细节,尤其是padding补边情况下,坐标映射做错了会整体偏移。我一次性踩过这个坑,最后排查了好久才发现结果框和鱼的位置整体偏了几十像素,源头就是缩放映射漏了padding。
4.3 后处理与目标去重:让输出更可用
YOLO的原始输出经过NMS之后,仍然可能出现同一目标被多个框覆盖的情况。在水下场景里,这种重复框更多,因为鱼游动速度快,相邻两帧中的同一尾鱼可能在空间上表现出较大位移。如果业务上要做“只数一次”的精确统计,需要引入目标追踪来去掉跨帧重复。
追踪方案我尝试过两种。简单场景用IoU匹配:预测框和上一帧的轨迹框IoU大于阈值则认为是同一目标。复杂场景用ByteTrack这类多目标追踪算法,它能在遮挡和交错的情况下保持ID切换较少。养殖场场景里鱼群密集、个体外观高度相似,纯IoU匹配效果不理想,最终是ByteTrack上线的。
还有一个后处理细节:类别置信度阈值。对于容易混淆的相近鱼种,把置信度阈值降太低会带来大量误报,调太高又会漏检。我的经验是识别类别的置信度阈值设在0.35到0.45之间比较平衡,定位框不算置信度置信度,只作为“这里有个目标”的证据。具体值要看最终业务是更讨厌误报还是更讨厌漏报,如果是计数业务,宁可漏报也不能多报,阈值适当上调。
部署后的实时画面我会叠加一个轻量级的可视化层,在画面的边缘标注每类鱼的数量和当前帧率,方便现场人员看到模型正在做什么。这个模块看似不起眼,但实际在现场验证时可以快速发现模型的问题,比如计数突然波动,就能立刻回溯当时画面来分析原因。
4.4 算力分配与多路视频并行推理
一个养殖池通常有好几个不同位置的摄像头,如果每个通道单独跑一个模型实例,显存和CPU都吃紧。我的做法是设计了一个简单的推理调度器,轮流从多个摄像头队列取帧,一个模型实例在所有通道间共享,保底思路类似“单GPU轮流处理多路请求”。
举个具体例子:如果用TensorRT的engine推理纯模型耗时是20毫秒,处理一路视频在30 FPS下已经偏紧。如果同时接入四路视频,每路期望达到15 FPS,总体需要处理的帧数约60 FPS,此时模型要跑约1.2秒的推理,哪怕交替分配也都够用。关键是通过多线程队列把“采集、推理、后处理”三块拆开,让GPU尽量做到不空闲。
多路并行时还要注意显存占用,不同分辨率的输入会显著影响显存占用。比如把其中两路的分辨率降到640,三路保持960,显存使用差异立刻体现。我会结合实际画面覆盖区域,对非关键区域的摄像头降低输入分辨率,把一个池子的所有水下摄像头控制在同一个模型实例内,谁空闲谁就多接收一些画面补帧。
5. 踩坑实录与常见问题排查技巧
5.1 从“检测一切”到“只检测鱼”的置信度玄学
项目初期,模型经常把水中的气泡、悬浮颗粒、甚至水面的反光当成鱼框出来,置信度还不低。这个问题的根子在于训练集里没有足够的“负样本”,也就是不会框出来的但确实在画面里晃的干扰物。增加负样本的思路有两种:一是专门标注一些“无目标”图片加入训练,二是用难负样本挖掘,把模型误检的图片重新喂给训练集,并让这些区域对模型迭代时成为“不应有目标”的区域。
我还发现一个隐含现象:水下目标检测的置信度,往往不是目标本身有多清晰,而是水体环境的复杂程度在“拉低”同一目标的置信度。清澈水质下同一尾鱼的置信度可能在0.9,到了浑浊水质下相同姿态就掉到了0.5。这不是模型“变笨了”,而是训练数据的分布没有覆盖这种混合场景。所以与其硬调阈值,不如让训练集里混合不同浑浊度的样本,让模型逐渐学会“在噪声中提取关键特征”。
置信度阈值的选择从结果上看很像玄学,但背后逻辑是这样的:选一个值,在验证集上既看precision又看recall,找出两者相交的区间。我做的项目里conf=0.35和conf=0.4差别不大,但在密集鱼群里conf=0.4时漏检稍多,最终取了0.35作为默认。实际业务部署时如果倾向于精确计数,我会再单独上调到0.45,无非就是现场做一个简单的权衡。
5.2 标签错位导致模型“精神分裂”的教训
有一次我训练出来的模型在验证集上loss下降得很漂亮,但应用时同一品种鱼被识别成两个不同类目,复核数据才发现是标注阶段某个批次的类别编号和文件名张冠李戴了。最后排查发现是一个半自动标注脚本在导出文件时,把类别序号和图片列表错位了一位,导致几百张图片的标签整体错乱。
这类问题很难从指标上直接发现,因为数据量足够大时,错标图片只占一小部分,模型为了拟合这批数据反而会导致特征边界变模糊。我的补救措施是:训练前做一个自动校验脚本,检查每个txt文件中的类别id是否在0到nc-1之间,同时抽20%的标注结果做可视化,把框画在图上人工扫一眼。这个“可视化检查”救了我不止一次,强烈建议列入训练前必做清单。
另外一个教训是类别名称别用拼音缩写。刚开始为了省事,我用拼音首字母标注类名,比如a、b、c、d、e,结果后续分析class activation map时完全分不清是哪一品种。后来改成species_a这类有语义的名字,整个项目后期维护的费力度立刻下降。
5.3 密集重叠时的目标分割:NMS参数不是越大越好
密集鱼群场景下,目标的边界框高度重叠,NMS的IoU阈值设置稍有不慎就出问题。阈值设得太小,重叠目标会被当作同一个目标过滤掉;阈值设得太大,又会留下大量重复框。我经过反复验证,IoU取0.5相对稳妥,但如果鱼群极其密集,我会放宽到0.6,同时把max_det调大一点,防止一帧画面里漏掉后面的鱼。
光调NMS还不够,还得看模型本身对重叠目标的检测能力。YOLO模型训练时每个网格预测多个候选框,彼此有竞争关系,在密集场景下天然容易丢目标。我的对策是在密集重叠场景的标注中,尽量保留每个可见个体而非合并,并且用更强的mosaic增强模拟这种画面。
有一段时间模型在密集场景下,鱼头被检出但鱼身被漏掉,原因是鱼群游泳时鱼身常被前一条鱼挡掉三分之一。后来我在预标注阶段给模型喂了一些“被遮挡鱼身”的增强样本,让它学会根据露出的前半身推断完整目标。这个思路听着简单,但对“部分可见目标”的召回提升是实打实的。
5.4 光照突变和运动模糊的现场应急处置
现场部署会碰到一个很无语的工况:水面有波浪时,阳光的反射角度变化极快,导致画面亮度在几秒内剧烈闪烁,模型在亮度过高时疯狂漏检。我用了一个简单的策略:为预处理管线增加一个自适应亮度校正模块,用直方图均衡化做轻量处理,再把图像输入模型。这步处理在亮度突变瞬间可以挽回不少漏检,代价是增加几毫秒耗时,对实时性影响可接受。
运动模糊建议从采集端找方案,如果摄像头的曝光时间太长,鱼游动快时每一帧都是糊的,再强的模型也救不了。我的解决办法是适当调高快门速度,牺牲一点暗光环境下的亮度,换来一个更锐利的画面。另外一个诀窍是:处理这类模糊帧时,与其急着把模糊帧喂给模型,不如用相邻帧的信息做一个简单的超分或去模糊前处理,但这类操作只在关键帧上做,不然算力压力太大。
这些现场问题的共性在于:你永远不能假定部署环境和训练环境一致。真实水域的光、水、鱼都是变量,模型部署后必须让运维人员懂得如何看置信度变化趋势,配合预处理和参数微调,才能让系统长期稳定运行。我后来在交付内容里加入了一个“参数旋钮”面板,让现场人员可以在界面上调整置信度和输入分辨率,省了不少来回沟通的成本。
5.5 常见问题速查表
| 问题 | 典型原因 | 解决思路 |
|---|---|---|
| 整体漏检严重 | 水体太浑浊、训练集缺少对应画质 | 增加浑浊场景样本,使用直方图均衡化预处理 |
| 小目标鱼检不出 | 输入分辨率不足、小目标样本少 | 提高imgsz到960;对小目标区域做局部裁剪扩充样本 |
| 相似鱼种误分类 | 标注标准不统一、特征差异小 | 统一标注规则;增加细节特写样本;用注意力模块强化细节 |
| 鱼群密集时漏目标 | NMS过严、重叠样本不足 | 调高IoU阈值留框;用mosaic增强模拟密集场景 |
| 同目标重复计数 | 跨帧追踪缺失 | 接入ByteTrack;按轨迹ID去重 |
| 框整体偏移几十像素 | 缩放/padding坐标映射错误 | 核对预处理流程,统一原图与模型输入的坐标映射 |
| 训练loss正常但val不降 | 过拟合或标签错乱 | 增大数据增强;检查标签是否错位,抽查可视化验证 |
| 部署后帧率低 | 同步推理、单帧耗时过长 | 拆分采集与推理线程;使用TensorRT fp16导出 |
6. 从模型到业务:应用场景扩展与迭代建议
6.1 渔业场景中检测模型还能延伸到哪些地方
鱼类检测识别框架一旦打磨稳定,很多相邻业务就能快速复用。比如养殖场的鱼群活跃度分析:通过长时间统计检测框数量和位置变化,可以计算鱼群的聚集程度和移动速度,这些指标跟投饵行为、应激反应有相关性。模型不需要换,只是在检测结果之上多做了一层时序统计分析。
苗种计数也是一个很直接的应用。鱼苗个体小、数量大、活动密集,人工计数非常耗时费力,基于检测结果的计数系统可以大幅降低劳动强度。这类场景通常需要更高的输入分辨率或更轻量化的快速推理,但整体架构和这个项目几乎没有差别。
水质预警方面,模型可以结合特定鱼类的上浮行为和分布变化来辅助判断。比如某一种鱼在特定时间段内集中出现在水体表层且游动活跃度异常高,可能是水质指标波动的前兆。这类应用对识别准确率的要求没有计数那么高,但对长时间稳定性和数据连续性的要求更高,更适合用检测模型加行为特征的方式组合实现。
6.2 数据闭环与版本迭代的三条经验
第一个经验是:一定要建立“失败样本回收”机制。现场部署的模型难免会漏检、误检,这些样本如果直接丢弃,模型永远不会有针对性的改进。我把每小时部署端的检测结果按置信度高低和是否有人工复核标记做筛选,低置信度样本每周一次打包回数据集,重新标注后加入训练集。三个版本下来,同样的低置信度误检样本明显少了一半。
第二个经验是:新版本的模型不要在全部数据上全量重训,而是用上一个版本的权重作为预训练继续增量迭代,同时在验证集里优先加入新采集的现场样本。这样既延续了旧模型已经学到的特征,又能够快速适配现场的新变化。
第三个经验是:训练集的数据量控制在“够用”就好,不要无限堆。从两千张有效图片增加到四千张,mAP大概能涨3到5个百分点,但从四千张涨到八千张,可能只涨不到两个百分点。相反,过量的相似数据会让训练时间变长,还容易导致过拟合风险。我在项目中期做了一批去重操作,把大量重复构图的图片删除,训练耗时直接省了三分之一,精度反而没有明显下滑。
6.3 硬件选型和维护的一点经验
部署硬件的选择上,我的经验是宁可用算力稍低的稳定方案,也不要用看似算力很高但散热和供电扛不住的设备。渔业现场湿度大、粉尘多、温度波动大,工控机的散热设计和防尘等级比单纯追求最大推理速度更重要。
有些朋友习惯把一个水池的四个摄像头全部拉去给单个GPU推理,结果GPU占用率打到100%,功耗高、发热快、现场偶尔还会触发保护性降频。我这边最终是控制单个GPU实例负责两个到三个摄像头的推理,剩余算力用于后续的视频存储和统计任务,整体稳定得多。
部署完成后也不要以为万事大吉。模型权重的版本管理要和现场运行版本挂钩,否则现场升级模型后现场人员不知道模型行为变了,看到检测结果变化会以为是故障。我在交付时额外写了一页“模型版本与现场行为说明”文档,记录每次升级改了什么、预期指标会怎么变,这套做法在多个项目里都被证明能减少大量沟通成本。
最后想说的
回头看这个项目,真正让我花时间的从来不是模型本身,而是“让模型在真实水下环境里稳定工作”这件事。数据规范、标注策略、增强管线、部署细节、现场调试,每一环都是在和不可控的环境变量作斗争。YOLO系列给了我们一个很好的起点,但最终效果取决于你怎样为你的场景定制整个系统。
如果只让我留三个建议,我会说:第一,花最多时间在数据上,它是所有精度的上限;第二,部署端一定把采集和推理分离,不要因为线程阻塞拖垮实时性;第三,给每一次实验做台账,否则三个月后你根本不知道“上次为什么效果好”。
最后分享一个我实测下来很管用的习惯:把验证集按“鱼种明暗程度、水体清澈程度、目标密集程度”三个维度打标签做分组评估,不只盯总mAP,而是每次迭代都看看模型针对这些特定分组的分数变化。这样你才能知道一次改动到底改善了哪个场景、有没有让另一个场景变差。做目标检测越到后期,越像在做数据科学和工程管理的平衡活,但看到系统稳定运行在真实养殖现场时,那种成就感是之前写再多的代码也比不上的。