用视觉识别鱼:MiroFish计算机视觉项目实战复盘 📅 发布时间:2026/9/18 4:29:27 👁 浏览次数: MiroFish 这个名字是我临时起的Miro 在西班牙语里是“看”的意思后面接一个 Fish合起来就是“用视觉去看鱼”。我拿它做了差不多两个月的计算机视觉项目用普通摄像头识别鱼缸和养殖池里的鱼自动告诉你画面里出现的是什么鱼、大概有几条、活跃状态怎么样。这篇文章就是整个项目的完整复盘从数据标注、模型训练写到边缘部署和踩坑记录。如果你也想给鱼缸、水产池子或者野采记录搞一套图像识别工具照着这个思路走能省不少时间哪怕你之前没系统碰过深度学习也能看懂每一步在做什么、为什么这么做。很多人第一反应是识别鱼还不简单拍一张照片丢给分类模型就行。真做起来完全不是这么回事。水里那点场景远比地面复杂水草在晃、气泡在冒、玻璃反光、鱼游得飞快同一个品种换个角度可能就像两种鱼。更麻烦的是大多数公开模型只解决“图片里有没有鱼”或者“这张图是哪个品种”回答不了“鱼在画面的哪个位置、一共有几条”这种实际需求。所以我在动手之前先把问题拆开再一项项决定用什么技术方案。1. 项目起源与整体设计思路1.1 为什么叫 MiroFish起名那会儿我其实纠结了很久。项目定位很明确就是一个以视觉为核心的水下生物识别工具那名字最好能直接表达“看鱼”这个动作。Miro 有“观察、注视”的意思和项目功能刚好对上另一方面超现实主义画家米罗的作品里经常出现抽象的生物形态跟鱼这种复杂多变的识别对象也有点神似。最后定成 MiroFish有点双关意味念起来也顺口。这个命名也顺带确定了项目边界MiroFish 不是一个学术论文级别的模型而是一个围绕实际监测场景搭建的轻量视觉方案。它要解决的不是“论文数据集上刷分”而是“普通摄像头拍回来的画面里能不能稳定识别出鱼、能不能少漏检、能不能在低算力设备上跑起来”。后面所有技术选型我都围绕这三句话展开。1.2 需求拆解先搞清楚到底要做什么开写代码之前我把实际需求拆成了四个子任务目标检测找到画面里每一条鱼的位置用边界框框出来品种识别针对框出来的区域判断是哪种鱼数量统计通过目标检测结果结合跟踪算法避免同一条鱼被反复计数状态判断根据检测框的置信度和持续出现情况粗略判断鱼是否活跃或是否离群这四个任务的难度是递增的。前两个是计算机视觉的经典问题第三个需要引入跟踪逻辑第四个其实很难做好我在第一版里只做了最简单的方式用检测框的稳定度来判断“这条鱼是不是一直在动”。为什么不用单纯的图像分类模型一步到位因为分类模型只能告诉你“整张图里占比最大的鱼是什么”一旦画面里有两条不同品种的鱼或者鱼只占了画面一角结果就完全不可靠。检测模型直接回归每个目标的坐标框天然支持多目标、多类别而且能顺带解决“几条鱼”的问题。所以方案从一开始就定成“检测 分类/特征提取”的两段式结构。1.3 场景选型同样决定技术路线同是识别鱼鱼缸、养殖池、野外溪流三个场景的技术复杂度差了好几倍。鱼缸玻璃容易反光但背景固定、光线可控养殖池水体浑浊、鱼群密度大检测目标小而且重叠严重野外水体背景杂乱、光照变化剧烈对模型的泛化要求最高。我的第一版 MiroFish 选择从鱼缸场景入手原因很简单数据最好采集摄像头固定背景相对干净可以先跑通“检测—识别—计数”的完整链路。等第一个版本稳定了再把同样的模型拿到养殖池场景里做迁移和微调。新手做这类项目特别容易一上来就挑战最困难的野外场景结果数据不够、模型不收敛很快就把热情耗光了。从小场景切入、逐步扩边界是性价比最高的路径。2. 核心技术拆解检测、分类与特征表达2.1 目标检测选型为什么是 YOLO 系目标检测的技术路线目前主流就几类两阶段检测器Faster R-CNN、单阶段检测器SSD、YOLO、基于 Transformer 的检测器DETR 系列。我最终选了 YOLOv8 的 nano 版本不是因为它最先进而是因为它最符合项目约束。方案精度速度部署难度适合场景Faster R-CNN高慢中离线分析、精度优先SSD中中中通用检测任务YOLOv8n中上快低边缘设备、实时视频流DETR高偏慢高研究探索、GPU 服务端MiroFish 的预期运行环境是一块树莓派或者一部手机算力有限视频流必须实时处理。YOLOv8n 的参数量只有 3.2M 左右在边缘设备上能达到接近实时的推理速度官方预训练权重又是基于 COCO 数据集训练过的迁移到鱼类识别任务时收敛速度明显更快。我在项目里做过对比同一个标注数据集用 YOLOv8n 从头训练大约 180 个 epoch 能达到预期精度而用 COCO 预训练权重迁移学习80 个 epoch 就已经接近同样的效果时间成本省了一大半。2.2 两段式结构检测框 分类器检测模型只负责定位不代表它知道鱼的品种。YOLO 也能同时输出类别但前提是训练数据里每个品种都有足够的标注框。实际项目里经常遇到这种情况我想识别十二种常见观赏鱼但是某些稀有品种只能凑到几十张图片直接让 YOLO 学多分类稀有类别的 recall 会惨不忍睹。所以我采用了检测和识别解耦的两段式设计YOLO 只输出“这里是鱼”的边界框把边界框裁切下来之后再交给一个轻量的分类网络做品种判断。这样做的好处有三个检测模型的类别数量可以非常少只需要一个“fish”类训练数据好凑漏检压力小分类模型可以单独迭代某类样本增加了只重训分类器不必动检测模型后续如果想加入新品种不改变检测逻辑只扩展分类器的类别表代价是多一次推理整体计算量会稍高但对鱼缸这种小型画面来说成本完全可以接受。实测在 640x640 输入下检测加分类总计耗时约 40ms依然能跑到 20 帧以上。2.3 分类模型的进阶玩法用向量相似度解决“没见过”的问题分类网络的标准做法是输出 softmax 概率也就是把目标强制划分到已知类别里。这带来一个很尴尬的问题如果摄像头拍到一条训练集里完全没有的鱼模型不会老老实实说“我不认识”而是会硬分到最像的那个已知类别而且置信度可能还不低。为了解决这个问题我最后在分类模块里加了一个“开集识别”的思路。不用最后一层 softmax而是取倒数第二层的特征向量作为这条鱼的 embedding 表征然后和数据库里所有已知类别的特征向量算余弦相似度。只有相似度超过阈值时才返回“属于某某类”否则返回“新品种未收录”。这种做法的工程价值很大。我在测试阶段故意拿一批没参与训练的鱼种图片去验证使用普通分类模型时错误识别率极高换成相似度检索后系统能稳定输出“未收录”的提示避免误导。当然前提是 embedding 空间要训得足够好也就是同一品种的鱼不管什么角度、什么光线特征向量都要尽量接近不同品种之间向量要尽量分开。这就依赖数据质量和度量学习损失函数的选择后面章节细说。2.4 为什么数据增强不是锦上添花水下的视觉干扰因素特别多单靠原始图片训练模型很容易记住“反光的玻璃”或者“水草的边缘”这类背景特征而不是真正记住鱼的特征。我在训练检测器时开了 YOLO 自带的一组增强项mosaic、随机翻转、HSV 色域扰动、随机透视。尤其要说 mosaic 增强它把四张图片随机拼接成一张等于强迫模型在每个小图块上独立判断目标对提升小目标检测能力帮助很大。train 阶段开着 mosaic到验证阶段必须关掉否则验证指标会被干扰。这个细节我在项目里踩过最初图省事训练和验证用同一套配置结果 mAP 显示 0.85实际跑到视频里完全不是那么回事后来才发现是增强逻辑影响了对真实样本的评价。3. 从数据准备到模型训练的完整流程3.1 数据从哪里来MiroFish 第一版设定的目标是识别十种常见淡水观赏鱼包括金鱼、锦鲤、孔雀鱼、斑马鱼、天使鱼、斗鱼、鹦鹉鱼、龙鱼、地图鱼、七彩神仙。数据来源主要有三个渠道公开数据集像 Fish4Knowledge、DeepFish 这类鱼类图像数据集可以作为预训练素材但大多数来自海洋或科研场景和淡水观赏鱼的重合度不高只能作为背景补充网络图片采集用关键词批量下载各种姿态、光线、角度的鱼图注意手动清理低质量图片自采视频抽帧拿手机对着鱼缸录 10 分钟视频每 3 秒抽一帧能一次性得到几百张不同姿态的图片三种渠道里最有价值的其实是自采视频抽帧因为画面和最终部署环境最接近。网络图片大部分是单条鱼居中、背景干净、姿态标准的“证件照”而真实场景往往多条鱼交叠、光线忽明忽暗。如果只用网络图片训练模型一到实拍画面就会现原形。3.2 图片清理原则很多教程会忽略一个关键前提不是所有采集到的图片都能进训练集。我定的清理标准很简单模糊到人眼都分不清品种的直接删鱼只占据画面不到 5% 的区域删遮挡面积超过 50% 的鱼不标注重复度极高的连续帧按时间间隔抽样避免同一姿态反复出现这套标准看起来粗暴作用非常大。模型学的是有效特征垃圾数据进得多反而会让 loss 一直震荡。我第一次训练时因为懒得清理保留了大量模糊帧结果 mAP 卡在 0.6 上不去花一个晚上清洗数据集后同样配置直接提到 0.82。3.3 标注最枯燥也最关键的一步我用的是 LabelImg 做矩形框标注格式导出为 YOLO 的 txt 格式。标注时要特别注意边界框贴合鱼身不要框进去太多水草或者玻璃边框。训练时我把数据集按 7:2:1 划分为训练集、验证集、测试集并且保证同一个视频抽出来的帧不会同时出现在训练集和测试集里。这点和图像分类的数据划分逻辑不同——如果同一段视频的连续帧既用于训练又用于测试模型等于提前看到了答案评估结果会虚高。以单类别 fish 检测器为例我标注了大约 1200 张图片其中包含 3200 多个目标框。分类器部分每个品种准备 200 到 300 张裁好的鱼身图。这个量级对迁移学习来说完全够用。如果品种少于 100 张后面的过拟合问题基本无解。3.4 检测模型的训练配置我用的是 ultralytics 的 YOLOv8 框架数据集配置文件可以直接写成这样path: ./datasets/mirofish train: images/train val: images/val names: 0: fish训练指令也很简单yolo detect train datamirofish.yaml modelyolov8n.pt epochs200 imgsz640 batch16几个参数值得单独说明。modelyolov8n.pt表示加载 COCO 预训练权重做迁移学习不是从头训练imgsz640是输入分辨率分辨率越高小目标检测越准但训练时间和显存占用也会涨batch16取决于显卡显存12GB 显存跑这个配置刚好显存不足就降到 8。训练过程看两个指标box_loss和cls_loss如果下降曲线变平基本就是收敛了train/box_loss和val/box_loss的差距如果越拉越大说明过拟合开始出现需要加数据增强或提前停止。3.5 分类器的训练与特征提取分类器我用的是 EfficientNet-B0输入尺寸 224x224参数再小一点也可以换 MobileNetV3。损失函数选择了 Additive Angular Margin Loss也就是 ArcFace 的变体它比普通交叉熵更适合做特征向量学习能让同类样本的 embedding 在向量空间里聚得更紧。训练数据是每个品种几百张图数量偏少所以我在全连接分类层之前加入了随机裁剪、旋转、色彩抖动等强数据增强。训练 60 个 epoch 后分类准确率在测试集上能到 0.93 左右Top-5 准确率 0.99。但这里必须泼一盆冷水0.93 的准确率是建立在“鱼身已经被检测框裁切干净”的前提下的。如果检测框切偏了或者把两条鱼切在一起分类准确率会直线下降。所以两段式系统里检测框质量是整个系统准确率的上限。4. 部署链路与实时监测实现4.1 模型导出的几种格式训练完成后模型不能直接拿去跑摄像头需要先转成部署格式。我用的是 ONNX 作为中间格式然后在不同设备上做转换yolo export modelruns/detect/train/weights/best.pt formatonnx opset12ONNX 的好处是跨平台CPU 上可以用 ONNX Runtime 推理手机上可以转 Core ML 或 NCNNNVIDIA 设备上还能进一步转 TensorRT精度损失都很小。树莓派这种低算力设备上ONNX Runtime 配合 CPU 推理640x640 输入单帧耗时大约在 150ms 到 250ms如果换成 TensorRT 的 FP16 模型在 Jetson Nano 上能压到 60ms 以内接近实时。实际部署时我不会让模型逐帧推理。摄像头视频流先经过抽帧策略运动检测结果变化大时抽 1 帧处理画面静止时减少推理频率能大幅降低平均功耗和延迟。4.2 用跟踪算法解决“重复计数”只做目标检测不做跟踪计数结果必然是乱的。鱼游来游去每一帧检测框位置都在变同一帧里同一条鱼可能被重复计数。MiroFish 用的是简单有效的 IoU 跟踪策略当前帧检测框和上一帧已有的轨迹框计算交并比如果 IoU 超过 0.3 就认为是同一条鱼更新轨迹位置如果连续多帧丢失就标记该轨迹结束如果检测框连续 5 帧以上稳定出现才最终计入数量统计。这种方法比 ByteTrack 这类多目标跟踪算法简单但对鱼缸场景够用。鱼缸环境里摄像头固定鱼的运动相对连续短时遮挡也不严重。如果用养殖池那种高密度、大量鱼重叠的场景就需要换成 ByteTrack 或者 BoT-SORT 这类更鲁棒的方案。处理逻辑上我把轨迹状态分成“候选”“确认”“消失”三个阶段只有确认阶段的轨迹才参与最终计数有效过滤了误检抖动。4.3 实时监测的整体流程实际运行的流程可以简化为摄像头抓取一帧画面YOLO 检测模型输出边界框只保留置信度大于 0.25 的框对每个边界框裁切送入分类模型得到品种和相似度边界框送入跟踪模块更新轨迹统计各品种的“确认轨迹数”叠加到画面上的信息面板置信度阈值 0.25 不是拍脑袋定的我实验后画了置信度与精确率的关系曲线0.25 附近是精确率和召回率平衡最好的位置。如果调高到 0.5误检虽然减少但那些游得快、姿态不正的鱼会被漏掉调低到 0.1水草和气泡的误检会大量涌入。4.4 边缘端 vs 服务端架构上我做了两层部署。树莓派和手机端跑轻量模式只部署检测模型把品种识别上抛给服务端服务端部署完整的检测 分类模型处理更复杂的水产池画面。这样边缘端就算算力不够也能先完成“发现鱼、统计数量”的核心功能。如果你打算完全离线部署建议在边缘端使用 NCNN 框架它针对移动端 ARM 芯片做过优化模型转换工具也很方便。经过转化后一颗高通骁龙 7 系芯片的手机上跑 YOLOv8n 可以达到 30 到 40ms 一帧完全满足实时监测需求。5. 实战踩坑实录与排查技巧5.1 小目标鱼漏检严重鱼缸里几十条孔雀鱼同时出现时大部分鱼在图里只占几十个像素检测器漏检率很高。我排查后发现原因是输入分辨率太低加上原始图里密集目标太多。解决办法是三管齐下把输入分辨率从 640 提到 960训练时开 mosaic 增强对小目标类别动态提高损失权重。最后漏检率从 37% 降到 17%。如果目标依然太小就上 SAHI 切片推理把大图切成若干小块分别检测再合并结果这是当前解决小目标检测比较稳定的工程手段。5.2 反光和气泡被识别成鱼玻璃反光和水中气泡在灰度上很像鱼鳞的反光区域模型很容易误判。我统计了误检样本80% 的误检框都落在玻璃边缘和水草根部。最终解决方案是两层过滤第一层删除落在画面边缘 10% 区域内的检测框第二层计算框内纹理复杂度真实鱼身的纹理方差通常高于反光和气泡大于阈值的才保留。这个手动规则简单粗暴但在固定场景下效果立竿见影。5.3 同一种鱼不同角度差异太大七彩神仙从侧面看是圆盘形从正面看只有一条细线斗鱼展开尾鳍和收缩尾鳍完全是两个形态。如果分类器只见过侧面图遇到正面游来的鱼就会误判。解决办法是为每个品种多采集几种姿态尤其是正面、半侧面、俯视这三个最难的角度。按姿态拆分后建立独立类别再在后期合并判断。比如把“斗鱼展开尾鳍”和“斗鱼收缩尾鳍”先当作两个类别训练测试阶段再映射回同一个品种。5.4 排查速查表现象可能原因排查步骤mAP 很低但训练 loss 正常数据标注不准抽查标注框是否贴合鱼身验证集好、视频里差视频帧和训练分布差异大增加自采视频抽帧到训练集同一个品种经常误判姿态覆盖不全补充正面、俯视等姿态样本误检集中在特定区域背景干扰在该区域加入负样本图片推理速度太慢模型过大或输入过大换更小模型或降低分辨率5.5 关于类别不平衡的一点心得水产养殖场景里数量最多的鱼往往是普通鲤科鱼类珍稀品种难得一遇。模型很容易被优势类别带偏看到什么鱼都往优势类别上靠。我处理类别不平衡的方法是训练集里对稀有类别做 3 到 5 倍的图像复制并配上强数据增强同时计算每个类别的召回率而不是只看整体准确率。一个整体准确率很高的模型如果稀有类别召回率只有 0.1那它在这个项目里就是废的。6. 应用场景与后续扩展6.1 水族馆和家庭鱼缸的自动记录MiroFish 最早的落地场景就是帮我哥的鱼缸做日常监测。摄像头固定在鱼缸侧面每天定时记录鱼群数量、品种分布和活动量。有一段时间发现某种鱼连续三天数量递减排查后才发现过滤泵附近有死角鱼卡在里面了。如果靠肉眼观察这种异常可能要好几天才能发现。家庭用户对这类工具的需求很明确不用精通机器学习插上摄像头就能看到“今天红绿灯鱼出现 22 次神仙鱼活跃度下降”。这部分体验要做到位UI 比算法更重要。6.2 休闲垂钓的渔获识别另一个我看好的方向是辅助休闲垂钓。钓到鱼后拍一张照片自动识别品种、估算体长记录到渔获日志里同时判断是否属于保护品种。这对新手尤其有价值避免因为不认识鱼而误捕保护鱼类。实现上把检测模型换成手机端拍照模式再加上一条参考长度标尺就能用像素比例估算出大致的体长。6.3 水产养殖的早期异常预警在水产养殖场景里鱼类行为模式往往比水质参数更早反映问题。鱼群聚集在水面、游动迟缓、离群独游都可能是疾病或缺氧的前兆。MiroFish 通过轨迹稳定性判断鱼群活跃度当某个区域检测框数量骤降时自动报警。这个功能目前在实验阶段效果取决于摄像头的覆盖范围和养殖池的能见度但方向是成立的。6.4 后续扩展的三个方向往后做我想在三个方向继续延展一是加入体型测量通过深度图或多角度画面估算鱼的尺寸二是把单帧识别升级为行为识别从轨迹序列中判断追逐、繁殖、异常游动等行为三是把模型压缩到更小的体积真正跑进普通的智能摄像头里做到完全本地化、低功耗运行。做 MiroFish 到现在我最大的体会是这类视觉项目最难的部分从来不在模型结构而在数据质量和场景理解。我常常花三天时间清理和标注数据最终只换来 mAP 提升 5 个百分点但就是这 5 个百分点决定了系统在真实环境里到底能不能用。如果你也想复刻类似的项目建议从最小的场景开始先拍 100 条鱼标注好训练一个能用的模型再慢慢加数据加功能。先把链路跑通比一开始就追求大而全重要得多。