基于YOLOv8的本地鱼类识别与计数工具MiroFish实战解析
2026/9/18 5:35:44 网站建设 项目流程

开始动笔写MiroFish之前,我先花了很长时间想清楚一件事:这个工具到底要解决什么问题。我家里有几个水族缸,每次给鱼“点名”都是一件让人崩溃的事。鱼这种东西吧,游来游去没有定数,你盯着看两分钟数出来的数量,和五分钟后再数一次,往往对不上。市面上确实有能识别鱼类的App,可大多数识别请求要上传到云端,有些还要订阅付费,精度也不算稳定。于是我就想,干脆自己动手做一个能跑在本地电脑上的小工具:用图片或者摄像头画面,自动识别出常见的观赏鱼是什么品种、一共有多少条,每次观测完再把结果存下来,方便后续追踪鱼缸生态的变化。这个项目就是MiroFish。

MiroFish这个名字是从西班牙语mirar演变来的,意思是“看”,后面挂一个Fish,合起来就是“认真看见水里的鱼”。项目用Python作为主语言,目标检测部分基于YOLOv8实现,图形界面用PySide6来写,数据落库用SQLite。整套方案既能打包成桌面程序,也能拆出接口跑在服务器上。这篇文章是我的完整复盘,从需求拆解、图片数据准备,到模型训练、本地部署,再到上线后遇到的各种问题和排查过程,都会写清楚。不管你是想给自己鱼缸做个点名录,还是想完整走一遍目标检测项目流程,这篇内容都可以直接参考。

1. 项目概述:MiroFish要解决的痛点和选型思路

1.1 给鱼缸“点名”的真实需求

MiroFish的核心目标可以用一句话概括:输入一张水族箱图片,返回图中每条鱼的类别和位置,并统计各类数量。听起来简单,实际拆开看,需求有几层。

第一是细粒度识别。观赏鱼里有大量近缘品种,比如红绿灯和宝莲灯,远看很像,区别只在于腹部红线的长度和位置。普通的图像分类模型如果训练数据不够,很容易把这种近似品种搞混,所以MiroFish在设计时就要把“细粒度识别”当成一个重要指标。

第二是数量统计。鱼是动态的,一条鱼游过去,可能只露出半个身体,或者几条鱼重叠在一起。如果只做“图像分类”,模型只会告诉你“这张图里有红绿灯”,不会告诉你到底有几条。所以这里必须用到目标检测,用边界框把每条鱼的位置框出来,再进行计数。

第三是离线运行。我不想把鱼缸的照片传到别人的服务器上,一方面考虑隐私,另一方面也担心在没网的时候工具就废了。所以从最初就确定:整个推理链路必须在本地完成,依赖的模型文件不能超过几百兆,CPU上也要能跑得动。

第四是长期记录。每次点名的结果如果只看一眼就丢掉,那和手动数鱼没什么区别。MiroFish会把每一条检测记录写入SQLite数据库,这样一周后、一个月后,我都能回查“上周一共几条”“这周死了几条”“哪种鱼的数量下降了”。这种长期数据对于养鱼状态的判断太重要了。

1.2 为什么选目标检测而不是图像分类

很多第一次接触这类需求的人会问:识别鱼的品种,用分类网络不就行了吗?确实,如果只是想知道“这张图里有没有金鱼”,分类网络足够。但在MiroFish这种场景里,分类网络有两个致命缺陷。

分类网络只能回答整张图“属于哪个类别”,它不关心图里有几个目标。当鱼缸里有五条红绿灯和两条宝莲灯时,分类网络根本没法给出数量分布。目标检测网络则是先在图上找到“可能是有鱼的目标区域”,再对这些区域逐一分类,天然就支持定位和计数。

目标检测还能处理遮挡和重叠。鱼群游动时,身体互相遮挡是常态。检测模型通过锚点和非极大值抑制(NMS)机制,可以保留置信度最高的框,并抑制同一目标上的重复框,这样即使鱼前面有另一条鱼游过,模型也能根据露出的部分做一个判断。这是分类模型做不到的。

我做过一个简单的对比测试:同一张鱼缸图片,用ResNet分类网络只能输出一个全图类别标签,例如“红绿灯”;而用YOLOv8检测模型,能得到5个边界框,其中4个标为红绿灯、1个标为宝莲灯,置信度都在0.8以上。这个差异决定了MiroFish必须走目标检测路线。

1.3 整体技术栈与功能清单

MiroFish的选型遵循一个原则:优先用成熟方案,自己只写业务逻辑。以下是最终确定的技术栈:

模块选型理由
检测模型YOLOv8实现简单,训练工具链完整,社区群众基础好
推理引擎ONNXRuntime跨平台,CPU推理优化明显,便于后期部署
界面框架PySide6Python生态里桌面端最成熟的选择,组件丰富
数据库SQLite单文件零配置,记录观测历史足够用
图像处理OpenCV读图、画框、缩放等操作绕不开它

功能清单包括:单张图片识别与计数、批量文件夹识别、摄像头实时预览识别、识别结果可视化(画框+标签)、观测数据存储与简单统计。这些功能覆盖了我日常养鱼记录的核心需求,后续如果有必要再扩展。

2. 数据准备:模型精度的地基

2.1 图像采集:图片来源与数量规划

很多人做目标检测项目上来就找公开数据集,但鱼类这种细粒度场景,公开数据集往往覆盖不够,而且标注风格不统一。MiroFish的做法是“公开数据打底 + 自己采集补强”。

我首先跑了一圈公开的鱼类数据集,比如一些水族摄影网站和科研机构的开放数据,整理出大概8个目标品种:红绿灯、宝莲灯、斑马鱼、孔雀鱼、金鱼、斗鱼、黑壳虾(对,虾也一起检测了)、清道夫。最初每个品种大约只有200到300张可用的图片,数量明显不够。

于是我在自己鱼缸和几个朋友鱼缸里补拍了大量照片。补拍的时候要注意几个点:多角度、多光照、多背景。固定在三脚架上的摄像头拍出来的图,角度永远是俯视或者平视,鱼身姿态千篇一律,模型的泛化能力会很差。我用手机在不同位置、不同时间、不同灯光色温下拍摄,同时保留了部分缸壁反光、水草遮挡、鱼群重叠的“困难样本”。这些困难样本对于实际使用非常重要。

我的经验是,每个类别的图片数量至少要达到500张以上,边缘类别、容易混淆的类别最好到800张。总图片量在5000张左右时,训练出来的模型在自家鱼缸场景里已经相当可用了。如果图片总数只有1000张,那模型稍微换个鱼缸就会失准,后面调试成本会远高于补数据的成本。

2.2 数据清洗与增强策略

图片收集回来后,不能直接拿去标注和训练,先要做一轮清洗。我会把所有图片按品种分类放进不同目录,然后一张一张快速过目,把以下几类图片挑出来删掉:严重过曝或欠曝的、鱼身被水草完全遮住仅能看到一个尾巴尖的、画面中鱼占的面积小于几十个像素的、焦距完全模糊的、以及带有明显水印文字干扰的。

清洗干净之后再做数据增强,这一步用albumentations库来实现。MiroFish用的增强策略是:

  • 水平翻转,概率0.5(注意不要做垂直翻转,鱼不会倒着游,做了反而会让模型学到错误姿态)
  • 旋转,范围正负15度
  • 亮度对比度扰动,范围0.8到1.2
  • 色温扰动,模拟不同灯光下的色彩偏移
  • 随机裁剪缩放,模拟鱼在画面中不同大小的情况

增强后的图片数量和原始图片比例控制在3比1以内。不要一股脑把每张图片增强到10倍,一是训练时间变长,二是增强过度的图片失真明显,反而拉低精度。

2.3 标注规范与常见标注错误

标注环节我用的工具是X-AnyLabeling,它对YOLO格式的支持很好,也支持自动标注后人工修正。标注的输出格式是每个图片对应一个txt文件,每一行的格式是:

类别ID 归一化中心x 归一化中心y 归一化宽度 归一化高度

注意边界框的坐标必须归一化到0到1之间,很多新手在这里翻车,训练的时候会报坐标范围错误或者loss直接变成NaN。

开始标注之前,我给自己定了几条规范:

  • 鱼身被遮挡超过三分之一的,不标注,或者标注露出部分的可见区域
  • 两条鱼头尾相连时,分别标注各自的框,宁可框略微重叠也不要只画一个框把两条鱼包进去
  • 画框要紧贴鱼身轮廓,不要像画分类框一样四周留一大圈空白
  • 模糊但能辨别品种的鱼,标成对应的类别;模糊到连人都看不出来的,直接跳过

标注过程中最常见的错误,是“一个框包含多个目标”。在鱼群密集的图片里,人会下意识地用一个框把拥挤的鱼群圈起来,对模型来说这是一个非常糟糕的学习信号。模型会去学“一个框里可以有无数条鱼”,推理的时候就会漏检。我的经验是,密集场景宁可漏标一两条,也不能用一个框包住一整群。

3. 模型训练与推理优化

3.1 模型版本选型:YOLOv8s还是YOLOv8n

YOLOv8提供了n、s、m、l、x五档模型,从轻到重。MiroFish的第一版直接用的YOLOv8s,因为我的开发机有一块3060显卡,训练速度可以接受。后来部署到普通的笔记本CPU上,发现推理速度偏慢,一张640x640的图片要跑到500毫秒以上,于是又试了YOLOv8n。

两者对比下来,YOLOv8n的模型体积只有6MB左右,CPU推理时间能压到200毫秒以内,精度mAP50大约下降2到3个百分点。对于鱼缸识别这种场景,2到3个百分点的精度损失完全能接受。最终MiroFish默认型号是YOLOv8n,但代码里保留了切换模型的选项,如果你有更好的显卡、对精度要求更高,可以一键换回YOLOv8s甚至YOLOv8m。

这里还要解释一下mAP的含义。mAP50指的是预测框和真实框的IoU(交并比)超过0.5才算匹配正确时的平均精度,mAP50-95则是在0.5到0.95之间多个IoU阈值下取平均。日常使用中,mAP50更接近人的直观感受;如果mAP50-95很低而mAP50很高,说明模型框的位置还不够精确,但识别大体是对的,对计数工具来说问题不大。

3.2 训练参数与收敛策略

训练直接用ultralytics框架。训练前把数据集按8比1比1拆分成训练集、验证集、测试集,注意先按图片所属的鱼缸场景分组再划分,避免同一个场景的相似图片既出现在训练集又出现在验证集,否则评估结果会虚高。

核心训练参数如下:

yolo detect train \ data=./data.yaml \ model=yolov8n.pt \ epochs=150 \ imgsz=640 \ batch=16 \ lr0=0.01 \ augment=True \ patience=20 \ seed=42

其中patience=20表示连续20个epoch验证集指标不再提升就提前停止训练。以我的数据量来看,一般在90到120个epoch时收敛,提前停止可以省掉不少时间。

学习率策略我用的也是默认的SGD配合余弦退火。如果发现loss曲线震荡得厉害,优先检查是不是标注数据有问题,而不是急着调学习率。我踩过的坑是某次训练loss怎么都降不下去,翻了好久的代码才发现是导出标注txt时类别ID写串了,红绿灯和宝莲灯的ID反了,模型当然学不进去。

3.3 ONNX导出与CPU端加速

训练完成后,模型格式是.pt文件,用ultralytics自带的API可以直接导出成ONNX格式:

from ultralytics import YOLO model = YOLO("best.pt") model.export(format="onnx", opset=12, simplify=True)

导出ONNX的意义在于,ONNXRuntime在CPU端的推理优化做得很成熟,比直接加载PyTorch模型跑要快不少。我实测同一个YOLOv8n模型,在笔记本CPU上,PyTorch推理需要约300毫秒,ONNXRuntime能压到200毫秒左右。如果你的电脑是Intel平台,还可以进一步用OpenVINO引擎做加速,推理时间能再降30%。

我自己最终部署用的是ONNXRuntime + CPU模式,单帧推理时间稳定在180到250毫秒。对于图片识别和低于每秒5帧的摄像头采样,完全够用。如果后期要实时视频流分析,再考虑用OpenVINO或者直接用TensorRT加GPU推理。

4. 本地工具实操:从代码到可运行的桌面程序

4.1 环境准备与项目结构

先创建一个干净的虚拟环境,避免把系统Python搞乱:

python -m venv mirofish_env source mirofish_env/bin/activate pip install ultralytics onnxruntime opencv-python PySide6 albumentations sqlalchemy

MiroFish的项目结构不复杂,核心就几个文件:

MiroFish/ ├── models/ │ └── best.onnx # 训练好的检测模型 ├── mirofish/ │ ├── detector.py # 推理封装 │ ├── database.py # 数据库操作 │ ├── main_window.py # 主界面逻辑 │ └── resources/ ├── data/ │ └── observations.db # SQLite数据库 ├── run.py # 程序入口 └── requirements.txt

目录结构设计的原则是把模型推理、数据访问和界面展示分离,这样以后要加API接口或者命令行工具,只需要复用detector和database两个模块就行。

4.2 核心推理流程代码实现

detector.py的代码核心很简洁,伪代码如下:

import cv2 import numpy as np import onnxruntime as ort class FishDetector: def __init__(self, model_path, conf_thres=0.35, iou_thres=0.5): self.session = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"]) self.conf_thres = conf_thres self.iou_thres = iou_thres self.class_names = ["black_shrimp", "bubble_eye", "guppy", ...] def detect(self, image_bgr): img = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB) # 缩放加letterbox填充,保持长宽比 resized = letterbox(img, 640) # 归一化并转成模型输入格式 blob = np.expand_dims(resized.transpose(2, 0, 1), 0).astype(np.float32) / 255.0 outputs = self.session.run(None, {self.session.get_inputs()[0].name: blob}) # 解析outputs、过滤低置信度框、做NMS boxes = postprocess(outputs[0], self.conf_thres, self.iou_thres) return boxes

这里最需要注意的是letterbox操作。YOLO系模型训练时会把图片等比缩放再填充,推理时也必须用同样的方式处理,否则目标的位置坐标会全部偏掉。很多人在部署时直接粗暴resize到640x640,出来的检测框位置明显偏移,就是这个原因。

置信度阈值我默认设置成0.35,这是一个折中的值。阈值设太低,比如0.1,误检会变多,缸壁反光都被当成鱼;阈值设太高,比如0.7,鱼被遮挡或者姿态不标准时又会漏检。如果你对精准率要求更高,可以往上调到0.5;如果要求召回率更高,往下调到0.25,具体要看使用场景。

4.3 界面交互与数据落库

界面的实现主要分成三个区域:左侧是图片显示区,识别后的结果画框直接叠加在图片上;右上角是结果列表,每一条记录对应一个检测框,显示类别、置信度和坐标;右下角是操作按钮和统计信息。

用PySide6实现时,我的做法是识别逻辑放在一个后台线程里,避免处理大图或者流量视频时界面卡死。每完成一张图片,后台线程通过信号把结果发送到主界面,主界面负责刷新显示和更新数据库。

数据库设计也很直接,就是一张观测记录表:

CREATE TABLE observations ( id INTEGER PRIMARY KEY AUTOINCREMENT, image_path TEXT NOT NULL, species TEXT NOT NULL, confidence REAL NOT NULL, x_center REAL, y_center REAL, width REAL, height REAL, observed_at DATETIME DEFAULT CURRENT_TIMESTAMP );

每次识别完成,就把每条鱼作为一个独立记录插入表中。这样后续查“最后一次观测后哪条鱼消失了”,只需要按时间筛选同品种数量,做个简单的对比就能知道。

我用SQLalchemy来操作数据库,虽然比直接写sqlite3多了一层抽象,但代码可读性好很多,而且后续如果要换MySQL之类的数据库,改动也小。

5. 常见问题与排查技巧实录

5.1 识别不准的排查思路

MiroFish上线第一周,被朋友拿去识别他家鱼缸,结果问题一堆:照片里一条宝莲灯没识别出来,反而把气泵的气泡框成了鱼。我排查了一圈,总结了三条经验。

先看图片质量。手机隔着缸壁拍,又顶着强光下的反光,模型识别困难非常正常。解决办法是拍摄时尽量让镜头平行于缸壁,关掉鱼缸灯避免反光,或者等鱼游到中间层再拍。在代码层面,可以增强预处理,比如做一个简单的去反光处理,用OpenCV的直方图均衡化提升暗部细节。

再看阈值设置。气泡被识别成鱼,说明置信度阈值偏低,把conf_thres从0.35调到0.5,误检基本消失。观察下来,正常鱼只的置信度通常在0.7到0.9之间,低于0.5的框大概率就是噪声。

最后看训练数据,这也是最根本的。如果模型在特定角度下漏检严重,说明训练集里这个角度的样本太少。我会把漏检的图片补充到训练集,重新训练一轮。这个“bad case补数据”的循环,是模型精度提升最快的手段。

5.2 视频流卡顿和掉帧处理

摄像头实时识别对算力要求高,初期我把每一帧都送进模型,笔记本CPU直接拉到100%,画面像幻灯片。后来做了两个优化。

一个是跳帧。把识别频率降到每秒2帧,在两次识别之间只显示画面,不做推理。鱼游动的速度并不快,每秒2帧的采样率足够捕捉数量和位置变化。

另一个是缩小推理分辨率。把输入从640降到480,CPU推理时间从200毫秒左右降到120毫秒,精度损失在近缘品种上大约1到2个百分点,肉眼几乎感知不到。如果画面中有很多小鱼重叠,不建议这么做,精度损失会被重叠问题放大。

5.3 训练集不平衡与难样本问题

MiroFish最初的8个类别里,黑壳虾图片最少,只有不到200张,训练出来的模型对黑壳虾的召回率惨不忍睹,10只虾只能认出3只。这是典型的类别不平衡问题。

我的解决办法分两步。第一步是给黑壳虾专门补拍和网上筛选,把数量拉到500张以上。第二步是在训练参数里设置了类别权重,让数量少的类别在loss计算中占更大比重。训练之后,黑壳虾的召回率从30%提升到大约75%。如果补数据实在困难,还可以用“拼接合成”的方法,把少数类目标抠出来粘贴到不同背景的图片上,同时自动生成标注文件,也能缓解不平衡。

5.4 问题排查速查表

现象可能原因解决建议
单张图识别非常慢用的PyTorch直接推理导出ONNX,换ONNXRuntime
检测框位置整体偏移推理时没有用letterbox改用等比缩放+填充
同一个鱼在相邻几帧反复跳动计数阈值过低,误检提高置信度阈值,或加帧间去重逻辑
某些品种始终召回率低训练集样本太少补数据、加类别权重、拼接合成
训练loss一直降不下来标注ID写错了检查txt标注文件,可视化抽查
界面拖动时卡顿推理在主线程执行用QThread把推理挪到后台线程

6. 踩坑心得和后续扩展方向

6.1 我踩过的三个实打实的坑

第一个坑是标注初期不够细致,画框时习惯性地往鱼身四周多留一圈空白,结果模型训练完,检测框总是偏大,而且两个相近的鱼合并成一个框的概率很高。后来把所有标注框统一收紧到紧贴鱼身,问题立刻好转。

第二个坑是数据划分不科学。早期我直接把所有图片随机打乱划分训练集和验证集,同一个鱼缸同一个视角连拍的照片会同时出现在两个集合里,训练时的验证mAP看起来高达0.95,一拿到新场景直接掉到0.6。改成“按场景分组划分”以后,评估结果才真实反映模型泛化能力。

第三个坑是增强参数设置得太大。我想着数据量不够就用增强凑,把旋转范围设置到45度,亮度扰动拉到0.5到1.8,结果模型训练得很慢,而且学到了一些扭曲的鱼形态。后来把旋转限制在15度以内,亮度范围收到0.8到1.2,训练稳定多了。

6.2 MiroFish还能朝哪些方向扩展

MiroFish目前的形态只是一个本地桌面工具,但实际上训练好的模型和推理代码完全可以复用到更多场景。

第一是接RTSP网络摄像头,做鱼缸实时监控加数量预警。比如鱼突然扎堆一侧,或者数量急剧下降,可以推送报警到手机。

第二是增加行为分析。检测是第一步,检测完之后还可以跟踪鱼的游动轨迹,统计活跃程度,观测鱼是否生病、是否繁殖。这部分可以用ByteTrack之类的多目标跟踪算法来实现。

第三是把观测数据做成可视化报表。SQLite里存了那么多历史数据,只用表格看太浪费。接一个前端或者用pyecharts直接生成图表,就能看到每个品种的数量变化曲线,对水质调整、喂食策略的判断都有帮助。

我在实际使用中最大的体会是,工具本身的代码改动并不复杂,真正难的是把数据准备好、把预期管理好。模型不可能一次到位,永远是“部署 -> 收集失败案例 -> 补充数据 -> 重训 -> 再部署”的循环。MiroFish做到现在这个可用状态,一半的时间其实花在整理图片和修补标注上。如果你也想做一个类似的识别工具,我的建议很简单:先把数据搞扎实,再把模型跑通,最后再考虑界面漂不漂亮。顺序反了,后期一定会加倍返工。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询